diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index f3ae7f4e54fde0..a4c73ac9282096 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -162,19 +162,18 @@ jobs: cd api uv run dev/generate_swagger_markdown_docs.py --swagger-dir ../packages/contracts/openapi --markdown-dir openapi/markdown --keep-swagger-json - # TODO: Enable after the repository-wide vp fmt baseline lands. - # - name: Generate frontend contracts - # if: github.event_name != 'merge_group' && steps.frontend-contract-changes.outputs.any_changed == 'true' - # run: pnpm --dir packages/contracts gen-api-contract + - name: Generate frontend contracts + if: github.event_name != 'merge_group' && steps.frontend-contract-changes.outputs.any_changed == 'true' + run: pnpm --dir packages/contracts gen-api-contract - # - name: ESLint autofix - # if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true' - # run: | - # vp exec eslint --fix --concurrency=2 --prune-suppressions --quiet || true + - name: ESLint autofix + if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true' + run: | + vp exec eslint --fix --concurrency=2 --prune-suppressions --quiet || true - # - name: Format frontend files - # if: github.event_name != 'merge_group' && (steps.web-changes.outputs.any_changed == 'true' || steps.frontend-contract-changes.outputs.any_changed == 'true') - # run: vp fmt + - name: Format frontend files + if: github.event_name != 'merge_group' && (steps.web-changes.outputs.any_changed == 'true' || steps.frontend-contract-changes.outputs.any_changed == 'true') + run: vp fmt - if: github.event_name != 'merge_group' uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4 diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 407ec773068e2a..6c0db166fbdaca 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -173,10 +173,9 @@ jobs: if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/actions/setup-web - # TODO: Enable after the repository-wide vp fmt baseline lands. - # - name: Format check - # if: steps.changed-files.outputs.any_changed == 'true' - # run: vp fmt --check + - name: Format check + if: steps.changed-files.outputs.any_changed == 'true' + run: vp fmt --check - name: Restore ESLint cache if: steps.changed-files.outputs.any_changed == 'true' diff --git a/cli/package.json b/cli/package.json index dc02695580ca63..00b325ee535e11 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,61 +1,19 @@ { "name": "@langgenius/difyctl", - "type": "module", "version": "0.2.0-alpha", "description": "Dify command-line interface", - "difyctl": { - "channel": "alpha", - "compat": { - "minDify": "1.16.0", - "maxDify": "1.16.0" - }, - "release": { - "tagPrefix": "difyctl-v", - "binName": "difyctl", - "checksumsSuffix": "-checksums.txt", - "targets": [ - { - "id": "linux-x64", - "bunTarget": "bun-linux-x64", - "exe": false - }, - { - "id": "linux-arm64", - "bunTarget": "bun-linux-arm64", - "exe": false - }, - { - "id": "darwin-x64", - "bunTarget": "bun-darwin-x64", - "exe": false - }, - { - "id": "darwin-arm64", - "bunTarget": "bun-darwin-arm64", - "exe": false - }, - { - "id": "windows-x64", - "bunTarget": "bun-windows-x64", - "exe": true - } - ] - } - }, "license": "Apache-2.0", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, "files": [ "README.md", "bin", "dist" ], - "engines": { - "node": "^22.22.1" + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } }, "scripts": { "build": "vp pack", @@ -108,5 +66,47 @@ "vite": "catalog:", "vite-plus": "catalog:", "vitest": "catalog:" + }, + "engines": { + "node": "^22.22.1" + }, + "difyctl": { + "channel": "alpha", + "compat": { + "minDify": "1.16.0", + "maxDify": "1.16.0" + }, + "release": { + "tagPrefix": "difyctl-v", + "binName": "difyctl", + "checksumsSuffix": "-checksums.txt", + "targets": [ + { + "id": "linux-x64", + "bunTarget": "bun-linux-x64", + "exe": false + }, + { + "id": "linux-arm64", + "bunTarget": "bun-linux-arm64", + "exe": false + }, + { + "id": "darwin-x64", + "bunTarget": "bun-darwin-x64", + "exe": false + }, + { + "id": "darwin-arm64", + "bunTarget": "bun-darwin-arm64", + "exe": false + }, + { + "id": "windows-x64", + "bunTarget": "bun-windows-x64", + "exe": true + } + ] + } } } diff --git a/cli/scripts/e2e-provision.ts b/cli/scripts/e2e-provision.ts index 94c34269e34d65..f6e980334d79a4 100644 --- a/cli/scripts/e2e-provision.ts +++ b/cli/scripts/e2e-provision.ts @@ -26,7 +26,6 @@ import { Buffer } from 'node:buffer' * Output file: * .provision-output.json (also written to GITHUB_OUTPUT if set) */ - import { appendFile, readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -36,7 +35,7 @@ import { fileURLToPath } from 'node:url' const host = process.env.DIFY_E2E_HOST ?? '' const email = process.env.DIFY_E2E_EMAIL ?? '' const password = process.env.DIFY_E2E_PASSWORD ?? '' -const edition = ((process.env.DIFY_E2E_EDITION ?? 'ee').toLowerCase()) as 'ee' | 'ce' +const edition = (process.env.DIFY_E2E_EDITION ?? 'ee').toLowerCase() as 'ee' | 'ce' const preToken = process.env.DIFY_E2E_TOKEN ?? '' if (!host || !email || !password) { @@ -49,10 +48,10 @@ const base = host.replace(/\/$/, '') // ── Helpers ─────────────────────────────────────────────────────────────────── function sleep(ms: number) { - return new Promise(r => setTimeout(r, ms)) + return new Promise((r) => setTimeout(r, ms)) } -async function consoleLogin(): Promise<{ cookieString: string, csrfToken: string }> { +async function consoleLogin(): Promise<{ cookieString: string; csrfToken: string }> { const passwordB64 = Buffer.from(password, 'utf8').toString('base64') const res = await fetch(`${base}/console/api/login`, { method: 'POST', @@ -60,14 +59,15 @@ async function consoleLogin(): Promise<{ cookieString: string, csrfToken: string body: JSON.stringify({ email, password: passwordB64, remember_me: false }), signal: AbortSignal.timeout(15_000), }) - if (!res.ok) - throw new Error(`console/api/login failed: HTTP ${res.status}`) + if (!res.ok) throw new Error(`console/api/login failed: HTTP ${res.status}`) const setCookies = res.headers.getSetCookie?.() ?? [] - const cookieString = setCookies.map(c => c.split(';')[0]).join('; ') + const cookieString = setCookies.map((c) => c.split(';')[0]).join('; ') // cookie names may have __Host- prefix on HTTPS deployments - const csrfPair = setCookies.map(c => c.split(';')[0]).find(p => p.includes('csrf_token=')) - const csrfToken = csrfPair ? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length) : '' + const csrfPair = setCookies.map((c) => c.split(';')[0]).find((p) => p.includes('csrf_token=')) + const csrfToken = csrfPair + ? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length) + : '' return { cookieString, csrfToken } } @@ -78,8 +78,9 @@ async function validateToken(token: string): Promise { signal: AbortSignal.timeout(10_000), }) return res.ok + } catch { + return false } - catch { return false } } async function mintToken(cookieStr: string, csrf: string, label: string): Promise { @@ -90,28 +91,27 @@ async function mintToken(cookieStr: string, csrf: string, label: string): Promis body: JSON.stringify({ client_id: 'difyctl', device_label: label }), signal: AbortSignal.timeout(15_000), }) - if (!codeRes.ok) - throw new Error(`device/code failed: HTTP ${codeRes.status}`) - const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string } + if (!codeRes.ok) throw new Error(`device/code failed: HTTP ${codeRes.status}`) + const { device_code, user_code } = (await codeRes.json()) as { + device_code: string + user_code: string + } // Step 2: approve (with retry) let approveRes: Response | undefined for (let i = 1; i <= 5; i++) { approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Cookie': cookieStr, 'X-CSRFToken': csrf }, + headers: { 'Content-Type': 'application/json', Cookie: cookieStr, 'X-CSRFToken': csrf }, body: JSON.stringify({ user_code }), signal: AbortSignal.timeout(10_000), }) - if (approveRes.ok) - break - if (approveRes.status !== 429 && approveRes.status < 500) - break + if (approveRes.ok) break + if (approveRes.status !== 429 && approveRes.status < 500) break console.warn(`[provision] device/approve HTTP ${approveRes.status}; retry ${i}/5 in ${i * 2}s`) await sleep(i * 2_000) } - if (!approveRes?.ok) - throw new Error(`device/approve failed: HTTP ${approveRes?.status}`) + if (!approveRes?.ok) throw new Error(`device/approve failed: HTTP ${approveRes?.status}`) // Step 3: exchange token const tokenRes = await fetch(`${base}/openapi/v1/oauth/device/token`, { @@ -120,39 +120,42 @@ async function mintToken(cookieStr: string, csrf: string, label: string): Promis body: JSON.stringify({ device_code, client_id: 'difyctl' }), signal: AbortSignal.timeout(10_000), }) - if (!tokenRes.ok) - throw new Error(`device/token failed: HTTP ${tokenRes.status}`) - const body = await tokenRes.json() as { token?: string } - if (!body.token) - throw new Error(`device/token missing token: ${JSON.stringify(body)}`) + if (!tokenRes.ok) throw new Error(`device/token failed: HTTP ${tokenRes.status}`) + const body = (await tokenRes.json()) as { token?: string } + if (!body.token) throw new Error(`device/token missing token: ${JSON.stringify(body)}`) return body.token } async function discoverWorkspaces(cookieStr: string, csrf: string) { const res = await fetch(`${base}/console/api/workspaces`, { - headers: { 'Cookie': cookieStr, 'X-CSRF-Token': csrf }, + headers: { Cookie: cookieStr, 'X-CSRF-Token': csrf }, signal: AbortSignal.timeout(10_000), }) - if (!res.ok) - throw new Error(`list workspaces failed: HTTP ${res.status}`) - const data = await res.json() as { workspaces?: Array<{ id: string, name: string }> } + if (!res.ok) throw new Error(`list workspaces failed: HTTP ${res.status}`) + const data = (await res.json()) as { workspaces?: Array<{ id: string; name: string }> } const all = data.workspaces ?? [] if (edition === 'ee') { - const ws0 = all.find(w => w.name === 'auto_test0') - const ws1 = all.find(w => w.name === 'auto_test1') - if (!ws0) - throw new Error('[provision] EE: workspace "auto_test0" not found') + const ws0 = all.find((w) => w.name === 'auto_test0') + const ws1 = all.find((w) => w.name === 'auto_test1') + if (!ws0) throw new Error('[provision] EE: workspace "auto_test0" not found') console.warn(`[provision] EE primary: ${ws0.name} (${ws0.id})`) - console.warn(`[provision] EE secondary: ${ws1?.name ?? 'reuses primary'} (${ws1?.id ?? ws0.id})`) + console.warn( + `[provision] EE secondary: ${ws1?.name ?? 'reuses primary'} (${ws1?.id ?? ws0.id})`, + ) return { primaryWsId: ws0.id, primaryWsName: ws0.name, secondaryWsId: ws1?.id ?? ws0.id } } - const auto = all.filter(w => w.name.toLowerCase().includes('auto')).sort((a, b) => a.name.localeCompare(b.name)) + const auto = all + .filter((w) => w.name.toLowerCase().includes('auto')) + .sort((a, b) => a.name.localeCompare(b.name)) const primary = auto[0] ?? all[0] - if (!primary) - throw new Error('[provision] No workspaces found') - return { primaryWsId: primary.id, primaryWsName: primary.name, secondaryWsId: auto[1]?.id ?? primary.id } + if (!primary) throw new Error('[provision] No workspaces found') + return { + primaryWsId: primary.id, + primaryWsName: primary.name, + secondaryWsId: auto[1]?.id ?? primary.id, + } } async function provisionApps( @@ -162,7 +165,7 @@ async function provisionApps( secondaryWsId: string, ): Promise> { const mkH = (extra: Record = {}) => ({ - 'Cookie': cookieStr, + Cookie: cookieStr, 'X-CSRF-Token': csrf, ...extra, }) @@ -202,9 +205,10 @@ async function provisionApps( } const dsl = await readFile(join(fixturesDir, dslFile), 'utf8') - const appName = (dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1] - ?.trim() - .replace(/^['"]|['"]$/g, '') ?? dslFile + const appName = + (dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1] + ?.trim() + .replace(/^['"]|['"]$/g, '') ?? dslFile const appMode = (dsl.match(/^[ \t]+mode:[ \t]*(\S+)/m) ?? [])[1] ?? '' // Find existing or import @@ -212,34 +216,34 @@ async function provisionApps( `${base}/console/api/apps?name=${encodeURIComponent(appName)}&limit=50&page=1`, { headers: mkH(), signal: AbortSignal.timeout(10_000) }, ) - const searchData = await searchRes.json() as { data?: Array<{ id: string, name: string }> } - let appId = searchData.data?.find(a => a.name === appName)?.id + const searchData = (await searchRes.json()) as { data?: Array<{ id: string; name: string }> } + let appId = searchData.data?.find((a) => a.name === appName)?.id if (appId) { console.warn(`[provision] ${dslFile}: exists id=${appId}`) - } - else { + } else { const importRes = await fetch(`${base}/console/api/apps/imports`, { method: 'POST', headers: { ...mkH(), 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'yaml-content', yaml_content: dsl }), signal: AbortSignal.timeout(30_000), }) - const importData = await importRes.json() as { app_id?: string, import_id?: string } + const importData = (await importRes.json()) as { app_id?: string; import_id?: string } if (importRes.status === 202 && importData.import_id) { - const confirmRes = await fetch(`${base}/console/api/apps/imports/${importData.import_id}/confirm`, { - method: 'POST', - headers: mkH(), - signal: AbortSignal.timeout(15_000), - }) - const confirmData = await confirmRes.json() as { app_id?: string } + const confirmRes = await fetch( + `${base}/console/api/apps/imports/${importData.import_id}/confirm`, + { + method: 'POST', + headers: mkH(), + signal: AbortSignal.timeout(15_000), + }, + ) + const confirmData = (await confirmRes.json()) as { app_id?: string } appId = confirmData.app_id - } - else { + } else { appId = importData.app_id } - if (!appId) - throw new Error(`import failed: ${JSON.stringify(importData)}`) + if (!appId) throw new Error(`import failed: ${JSON.stringify(importData)}`) console.warn(`[provision] ${dslFile}: imported id=${appId}`) } @@ -270,8 +274,7 @@ async function provisionApps( } results[envVar] = appId - } - catch (err) { + } catch (err) { console.warn(`[provision] ${dslFile} skipped: ${err}`) } } @@ -281,7 +284,9 @@ async function provisionApps( async function writeOutputs(outputs: Record) { const ghOutput = process.env.GITHUB_OUTPUT - const lines = `${Object.entries(outputs).map(([k, v]) => `${k}=${v}`).join('\n')}\n` + const lines = `${Object.entries(outputs) + .map(([k, v]) => `${k}=${v}`) + .join('\n')}\n` // Always write local JSON for debugging const { writeFile } = await import('node:fs/promises') @@ -310,18 +315,19 @@ async function main() { // 2. Token let primaryToken = preToken - if (primaryToken && await validateToken(primaryToken)) { + if (primaryToken && (await validateToken(primaryToken))) { console.warn(`[provision] Using pre-set token: ${primaryToken.slice(0, 20)}…`) - } - else { - if (primaryToken) - console.warn('[provision] Pre-set token invalid, minting fresh…') + } else { + if (primaryToken) console.warn('[provision] Pre-set token invalid, minting fresh…') primaryToken = await mintToken(cookieString, csrfToken, 'e2e-provision') console.warn(`[provision] Minted token: ${primaryToken.slice(0, 20)}…`) } // 3. Discover workspaces - const { primaryWsId, primaryWsName, secondaryWsId } = await discoverWorkspaces(cookieString, csrfToken) + const { primaryWsId, primaryWsName, secondaryWsId } = await discoverWorkspaces( + cookieString, + csrfToken, + ) // 4. Provision apps const appIds = await provisionApps(cookieString, csrfToken, primaryWsId, secondaryWsId) @@ -333,10 +339,16 @@ async function main() { // their describe calls rejected with "workspace_id does not match app's workspace". await fetch(`${base}/console/api/workspaces/switch`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRF-Token': csrfToken }, + headers: { + 'Content-Type': 'application/json', + Cookie: cookieString, + 'X-CSRF-Token': csrfToken, + }, body: JSON.stringify({ tenant_id: primaryWsId }), signal: AbortSignal.timeout(10_000), - }).catch((err: unknown) => console.warn(`[provision] switch-back to primary failed (non-fatal): ${err}`)) + }).catch((err: unknown) => + console.warn(`[provision] switch-back to primary failed (non-fatal): ${err}`), + ) console.warn(`[provision] Session workspace reset to primary: ${primaryWsId}`) // 5. Write outputs diff --git a/cli/scripts/generate-command-tree.test.ts b/cli/scripts/generate-command-tree.test.ts index c811de8b4fe074..bf672b46aca2f1 100644 --- a/cli/scripts/generate-command-tree.test.ts +++ b/cli/scripts/generate-command-tree.test.ts @@ -14,18 +14,22 @@ import { describe('pathToTokens', () => { it('extracts tokens for nested command', () => { - expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands')) - .toEqual(['auth', 'devices', 'list']) + expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands')).toEqual([ + 'auth', + 'devices', + 'list', + ]) }) it('extracts tokens for top-level command', () => { - expect(pathToTokens('src/commands/version/index.ts', 'src/commands')) - .toEqual(['version']) + expect(pathToTokens('src/commands/version/index.ts', 'src/commands')).toEqual(['version']) }) it('normalizes backslashes (windows-style paths)', () => { - expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands')) - .toEqual(['auth', 'login']) + expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands')).toEqual([ + 'auth', + 'login', + ]) }) }) @@ -49,22 +53,35 @@ describe('tokensToIdentifier', () => { describe('buildTree', () => { it('assembles a nested tree from entries', () => { const entries = [ - { tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' }, - { tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: '@/commands/auth/devices/list/index' }, + { + tokens: ['auth', 'login'], + identifier: 'AuthLogin', + importPath: '@/commands/auth/login/index', + }, + { + tokens: ['auth', 'devices', 'list'], + identifier: 'AuthDevicesList', + importPath: '@/commands/auth/devices/list/index', + }, { tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' }, ] const tree = buildTree(entries) expect(tree.subcommands.get('auth')?.command).toBeUndefined() expect(tree.subcommands.get('auth')?.subcommands.get('login')?.command).toBe('AuthLogin') - expect(tree.subcommands.get('auth')?.subcommands.get('devices')?.subcommands.get('list')?.command) - .toBe('AuthDevicesList') + expect( + tree.subcommands.get('auth')?.subcommands.get('devices')?.subcommands.get('list')?.command, + ).toBe('AuthDevicesList') expect(tree.subcommands.get('version')?.command).toBe('Version') }) it('supports a parent command with its own children', () => { const entries = [ { tokens: ['run', 'app'], identifier: 'RunApp', importPath: '@/commands/run/app/index' }, - { tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: '@/commands/run/app/resume/index' }, + { + tokens: ['run', 'app', 'resume'], + identifier: 'RunAppResume', + importPath: '@/commands/run/app/resume/index', + }, ] const tree = buildTree(entries) const runApp = tree.subcommands.get('run')?.subcommands.get('app') @@ -76,9 +93,17 @@ describe('buildTree', () => { describe('formatModule', () => { it('produces a deterministic ESM file with imports + tree literal', () => { const entries: CommandEntry[] = [ - { tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' }, + { + tokens: ['auth', 'login'], + identifier: 'AuthLogin', + importPath: '@/commands/auth/login/index', + }, { tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' }, - { tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: '@/commands/auth/devices/list/index' }, + { + tokens: ['auth', 'devices', 'list'], + identifier: 'AuthDevicesList', + importPath: '@/commands/auth/devices/list/index', + }, ] const tree = buildTree(entries) const out = formatModule(entries, tree) @@ -111,7 +136,11 @@ export const commandTree: CommandTree = { it('emits parent-with-own-command shape', () => { const entries: CommandEntry[] = [ { tokens: ['run', 'app'], identifier: 'RunApp', importPath: '@/commands/run/app/index' }, - { tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: '@/commands/run/app/resume/index' }, + { + tokens: ['run', 'app', 'resume'], + identifier: 'RunAppResume', + importPath: '@/commands/run/app/resume/index', + }, ] const tree = buildTree(entries) const out = formatModule(entries, tree) @@ -130,7 +159,11 @@ export const commandTree: CommandTree = { it('imports sorted alphabetically by import path', () => { const entries: CommandEntry[] = [ { tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' }, - { tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' }, + { + tokens: ['auth', 'login'], + identifier: 'AuthLogin', + importPath: '@/commands/auth/login/index', + }, ] const out = formatModule(entries, buildTree(entries)) const authIdx = out.indexOf('AuthLogin') @@ -140,8 +173,16 @@ export const commandTree: CommandTree = { it('quotes hyphenated keys and leaves plain identifier keys unquoted', () => { const entries: CommandEntry[] = [ - { tokens: ['export', 'app'], identifier: 'ExportApp', importPath: '@/commands/export/app/index' }, - { tokens: ['export', 'studio-app'], identifier: 'ExportStudioApp', importPath: '@/commands/export/studio-app/index' }, + { + tokens: ['export', 'app'], + identifier: 'ExportApp', + importPath: '@/commands/export/app/index', + }, + { + tokens: ['export', 'studio-app'], + identifier: 'ExportStudioApp', + importPath: '@/commands/export/studio-app/index', + }, ] const out = formatModule(entries, buildTree(entries)) expect(out).toContain(`'studio-app': { command: ExportStudioApp, subcommands: {} },`) @@ -156,7 +197,10 @@ function makeFixture(): string { mkdirSync(join(commands, 'auth', 'login'), { recursive: true }) writeFileSync(join(commands, 'auth', 'login', 'index.ts'), 'export default class Login {}\n') mkdirSync(join(commands, 'auth', 'devices', 'list'), { recursive: true }) - writeFileSync(join(commands, 'auth', 'devices', 'list', 'index.ts'), 'export default class DevicesList {}\n') + writeFileSync( + join(commands, 'auth', 'devices', 'list', 'index.ts'), + 'export default class DevicesList {}\n', + ) mkdirSync(join(commands, '_shared'), { recursive: true }) writeFileSync(join(commands, '_shared', 'index.ts'), 'export default class Shared {}\n') mkdirSync(join(commands, 'version'), { recursive: true }) @@ -168,12 +212,12 @@ describe('discoverCommands', () => { it('returns sorted entries, skipping _-prefixed segments', async () => { const root = makeFixture() const entries = await discoverCommands(join(root, 'src', 'commands')) - expect(entries.map(e => e.tokens.join('/'))).toEqual([ + expect(entries.map((e) => e.tokens.join('/'))).toEqual([ 'auth/devices/list', 'auth/login', 'version', ]) - expect(entries.find(e => e.tokens[0] === '_shared')).toBeUndefined() + expect(entries.find((e) => e.tokens[0] === '_shared')).toBeUndefined() }) it('errors on a loose .ts file under commands/', async () => { @@ -205,8 +249,7 @@ describe('generate', () => { const commandsDir = join(root, 'src', 'commands') await generate({ commandsDir, mode: 'write' }) const result = await generate({ commandsDir, mode: 'check' }) - if (result.mode !== 'check') - throw new Error('expected check mode') + if (result.mode !== 'check') throw new Error('expected check mode') expect(result.ok).toBe(true) }) @@ -216,10 +259,8 @@ describe('generate', () => { await generate({ commandsDir, mode: 'write' }) writeFileSync(join(commandsDir, 'tree.generated.ts'), '// stale\n') const result = await generate({ commandsDir, mode: 'check' }) - if (result.mode !== 'check') - throw new Error('expected check mode') + if (result.mode !== 'check') throw new Error('expected check mode') expect(result.ok).toBe(false) - if (!result.ok) - expect(result.diff).toBeDefined() + if (!result.ok) expect(result.diff).toBeDefined() }) }) diff --git a/cli/scripts/generate-command-tree.ts b/cli/scripts/generate-command-tree.ts index 9f3357bd6ea207..7508625c18ab8f 100644 --- a/cli/scripts/generate-command-tree.ts +++ b/cli/scripts/generate-command-tree.ts @@ -57,26 +57,22 @@ export type TreeNode = { export function pathToTokens(filePath: string, commandsRoot: string): string[] { const normalized = filePath.replace(/\\/g, '/') const root = commandsRoot.replace(/\\/g, '/').replace(/\/$/, '') - const trimmed = normalized.startsWith(`${root}/`) - ? normalized.slice(root.length + 1) - : normalized + const trimmed = normalized.startsWith(`${root}/`) ? normalized.slice(root.length + 1) : normalized const withoutIndex = trimmed.replace(/\/index\.ts$/, '') - return withoutIndex.split('/').filter(s => s.length > 0) + return withoutIndex.split('/').filter((s) => s.length > 0) } function capitalize(part: string): string { - if (part.length === 0) - return '' + if (part.length === 0) return '' return part[0]!.toUpperCase() + part.slice(1) } export function tokensToIdentifier(tokens: readonly string[]): string { const id = tokens - .flatMap(t => t.split(/[-_]/)) + .flatMap((t) => t.split(/[-_]/)) .map(capitalize) .join('') - if (RESERVED_JS_KEYWORDS.has(id.toLowerCase())) - return `_${id}` + if (RESERVED_JS_KEYWORDS.has(id.toLowerCase())) return `_${id}` return id } @@ -103,18 +99,15 @@ const HEADER = `// @generated by scripts/generate-command-tree.ts — DO NOT EDI ` function compareStrings(a: string, b: string): number { - if (a < b) - return -1 - if (a > b) - return 1 + if (a < b) return -1 + if (a > b) return 1 return 0 } function emitImports(entries: readonly CommandEntry[]): string { const sorted = [...entries].sort((a, b) => compareStrings(a.importPath, b.importPath)) const lines = [`import type { CommandTree } from '@/framework/registry'`] - for (const e of sorted) - lines.push(`import ${e.identifier} from '${e.importPath}'`) + for (const e of sorted) lines.push(`import ${e.identifier} from '${e.importPath}'`) return lines.join('\n') } @@ -123,13 +116,11 @@ function emitNode(node: TreeNode, indent: string): string { const keys = [...node.subcommands.keys()].sort() const parts: string[] = [] - if (node.command !== undefined) - parts.push(`${inner}command: ${node.command},`) + if (node.command !== undefined) parts.push(`${inner}command: ${node.command},`) if (keys.length === 0) { parts.push(`${inner}subcommands: {},`) - } - else { + } else { parts.push(`${inner}subcommands: {`) for (const key of keys) { const child = node.subcommands.get(key)! @@ -154,14 +145,9 @@ function emitKey(key: string): string { function emitEntry(key: string, node: TreeNode, indent: string): string { const k = emitKey(key) const isLeaf = node.subcommands.size === 0 && node.command !== undefined - if (isLeaf) - return `${indent}${k}: { command: ${node.command}, subcommands: {} },` - - return [ - `${indent}${k}: {`, - emitNode(node, indent), - `${indent}},`, - ].join('\n') + if (isLeaf) return `${indent}${k}: { command: ${node.command}, subcommands: {} },` + + return [`${indent}${k}: {`, emitNode(node, indent), `${indent}},`].join('\n') } export function formatModule(entries: readonly CommandEntry[], tree: TreeNode): string { @@ -181,10 +167,8 @@ async function walk(dir: string): Promise { const entries = await readdir(dir, { withFileTypes: true }) for (const e of entries) { const full = join(dir, e.name) - if (e.isDirectory()) - out.push(...await walk(full)) - else if (e.isFile()) - out.push(full) + if (e.isDirectory()) out.push(...(await walk(full))) + else if (e.isFile()) out.push(full) } return out } @@ -195,21 +179,20 @@ function toPosix(p: string): string { export async function discoverCommands(commandsDir: string): Promise { const all = await walk(commandsDir) - const tsFiles = all.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts')) + const tsFiles = all.filter( + (f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts'), + ) const loose: string[] = [] for (const abs of tsFiles) { const rel = toPosix(relative(commandsDir, abs)) - if (isExcludedCommandPath(rel)) - continue - if (rel === 'tree.ts' || rel === 'tree.generated.ts') - continue + if (isExcludedCommandPath(rel)) continue + if (rel === 'tree.ts' || rel === 'tree.generated.ts') continue // Only flag files directly under commands/ (no path separator — no parent folder) - if (!rel.includes('/')) - loose.push(rel) + if (!rel.includes('/')) loose.push(rel) } if (loose.length > 0) { - const list = loose.map(p => ` - src/commands/${p}`).join('\n') + const list = loose.map((p) => ` - src/commands/${p}`).join('\n') throw new Error( `commands must live under their own folder (see CLAUDE memory: feedback_cli_command_structure). Found:\n${list}`, ) @@ -218,15 +201,11 @@ export async function discoverCommands(commandsDir: string): Promise compareStrings(a.importPath, b.importPath)) - if (entries.length === 0) - throw new Error(`no commands found under ${commandsDir}`) + if (entries.length === 0) throw new Error(`no commands found under ${commandsDir}`) assertUniqueIdentifiers(entries) return entries @@ -258,10 +236,10 @@ export type GenerateOptions = { readonly mode: 'write' | 'check' } -export type GenerateResult - = | { mode: 'write', wrote: boolean, path: string } - | { mode: 'check', ok: true, path: string } - | { mode: 'check', ok: false, path: string, diff: string } +export type GenerateResult = + | { mode: 'write'; wrote: boolean; path: string } + | { mode: 'check'; ok: true; path: string } + | { mode: 'check'; ok: false; path: string; diff: string } export async function generate(opts: GenerateOptions): Promise { const entries = await discoverCommands(opts.commandsDir) @@ -273,12 +251,10 @@ export async function generate(opts: GenerateOptions): Promise { let onDisk = '' try { onDisk = await readFile(target, 'utf8') - } - catch { + } catch { onDisk = '' } - if (onDisk === content) - return { mode: 'check', ok: true, path: target } + if (onDisk === content) return { mode: 'check', ok: true, path: target } return { mode: 'check', ok: false, path: target, diff: shortDiff(onDisk, content) } } @@ -295,10 +271,8 @@ function shortDiff(a: string, b: string): string { const max = Math.max(aLines.length, bLines.length) for (let i = 0; i < max; i++) { if (aLines[i] !== bLines[i]) { - if (aLines[i] !== undefined) - lines.push(`- ${aLines[i]}`) - if (bLines[i] !== undefined) - lines.push(`+ ${bLines[i]}`) + if (aLines[i] !== undefined) lines.push(`- ${aLines[i]}`) + if (bLines[i] !== undefined) lines.push(`+ ${bLines[i]}`) } } return lines.slice(0, 40).join('\n') @@ -318,12 +292,13 @@ async function main(): Promise { process.stderr.write(`tree:check ok\n`) return } - process.stderr.write(`tree:check FAILED — tree.generated.ts is stale.\nDiff (first 40 lines):\n${result.diff}\n\nRun \`pnpm tree:gen\` and commit.\n`) + process.stderr.write( + `tree:check FAILED — tree.generated.ts is stale.\nDiff (first 40 lines):\n${result.diff}\n\nRun \`pnpm tree:gen\` and commit.\n`, + ) process.exit(1) } -const invokedDirectly = process.argv[1] !== undefined - && fileURLToPath(import.meta.url) === process.argv[1] +const invokedDirectly = + process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1] -if (invokedDirectly) - await main() +if (invokedDirectly) await main() diff --git a/cli/scripts/install-cli.test.ts b/cli/scripts/install-cli.test.ts index 5f30114ccddcd3..28db6bcc2131aa 100644 --- a/cli/scripts/install-cli.test.ts +++ b/cli/scripts/install-cli.test.ts @@ -44,11 +44,20 @@ const FETCH_STUB = [ ].join('\n') /* eslint-enable no-template-curly-in-string */ -function runLib(program: string, env: Record = {}): { code: number, stdout: string, stderr: string } { +function runLib( + program: string, + env: Record = {}, +): { code: number; stdout: string; stderr: string } { const full = `. "${SCRIPT}"\n${FETCH_STUB}\n${program}` const r = spawnSync('sh', ['-c', full], { encoding: 'utf8', - env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', ...env }, + env: { + ...process.env, + DIFYCTL_INSTALL_LIB: '1', + DIFY_VERSION: '', + DIFYCTL_VERSION: '', + ...env, + }, }) return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' } } @@ -56,11 +65,21 @@ function runLib(program: string, env: Record = {}): { code: numb // Like runLib but with a caller-supplied fetch_json stub, so we can drive the // real rate_limit_hint / maybe_ratelimit_exit / fetch_hit_ratelimit (which the // script defines) by writing a classified reason to FETCH_ERR_FILE. -function runLibStub(stub: string, program: string, env: Record = {}): { code: number, stderr: string } { +function runLibStub( + stub: string, + program: string, + env: Record = {}, +): { code: number; stderr: string } { const full = `. "${SCRIPT}"\n${stub}\n${program}` const r = spawnSync('sh', ['-c', full], { encoding: 'utf8', - env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', ...env }, + env: { + ...process.env, + DIFYCTL_INSTALL_LIB: '1', + DIFY_VERSION: '', + DIFYCTL_VERSION: '', + ...env, + }, }) return { code: r.status ?? 1, stderr: r.stderr ?? '' } } @@ -71,9 +90,17 @@ function failStub(reason: string): string { return `fetch_json() { printf '%s' '${reason}' > "$FETCH_ERR_FILE"; return 1; }` } -const REL_1142 = JSON.stringify({ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }] }) -const REL_1150 = JSON.stringify({ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-linux-x64' }] }) -const LIST_NEWEST_FIRST = JSON.stringify({ releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }] }) +const REL_1142 = JSON.stringify({ + tag_name: '1.14.2', + assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }], +}) +const REL_1150 = JSON.stringify({ + tag_name: '1.15.0', + assets: [{ name: 'difyctl-v0.3.0-linux-x64' }], +}) +const LIST_NEWEST_FIRST = JSON.stringify({ + releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }], +}) const RELEASE = JSON.stringify({ tag_name: '1.14.2', @@ -138,7 +165,10 @@ describe('install-cli asset_version', () => { describe('install-cli resolve_release', () => { it('DIFY_VERSION pins the release directly', () => { - const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', { DIFY_VERSION: '1.14.2', TAG_1_14_2: REL_1142 }) + const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', { + DIFY_VERSION: '1.14.2', + TAG_1_14_2: REL_1142, + }) expect(r.code).toBe(0) expect(r.stdout).toBe('1.14.2') }) @@ -150,7 +180,9 @@ describe('install-cli resolve_release', () => { }) it('blank resolves to the latest stable release', () => { - const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', { LATEST_JSON: REL_1150 }) + const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', { + LATEST_JSON: REL_1150, + }) expect(r.code).toBe(0) expect(r.stdout).toBe('1.15.0') }) @@ -225,14 +257,18 @@ describe('install-cli rate limit', () => { }) it('DIFY_VERSION: rate limit wins over the misleading "not found" message', () => { - const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', { DIFY_VERSION: '1.15.0' }) + const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', { + DIFY_VERSION: '1.15.0', + }) expect(r.code).not.toBe(0) expect(r.stderr).toContain('rate limit exceeded') expect(r.stderr).not.toContain('not found') }) it('DIFYCTL_VERSION: rate limit surfaces from the nested subshell, not "not found"', () => { - const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', { DIFYCTL_VERSION: '0.2.0' }) + const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', { + DIFYCTL_VERSION: '0.2.0', + }) expect(r.code).not.toBe(0) expect(r.stderr).toContain('rate limit exceeded') expect(r.stderr).not.toContain('not found') @@ -288,32 +324,50 @@ esac // Drive the real fetch_json with FAKE_CURL first on PATH. Returns "OK|" or // "FAIL|", plus any -H lines the fake curl received. -function runRealFetch(mode: string, env: Record = {}): { result: string, headers: string } { +function runRealFetch( + mode: string, + env: Record = {}, +): { result: string; headers: string } { const dir = mkdtempSync(join(tmpdir(), 'difyctl-fakecurl-')) const hdrLog = join(dir, 'hdrlog') writeFileSync(join(dir, 'curl'), FAKE_CURL) chmodSync(join(dir, 'curl'), 0o755) - const program = 'if body=$(fetch_json "https://api.github.com/repos/x/releases/latest"); then printf \'OK|%s\' "$body"; else printf \'FAIL|%s\' "$(cat "$FETCH_ERR_FILE" 2>/dev/null)"; fi' + const program = + 'if body=$(fetch_json "https://api.github.com/repos/x/releases/latest"); then printf \'OK|%s\' "$body"; else printf \'FAIL|%s\' "$(cat "$FETCH_ERR_FILE" 2>/dev/null)"; fi' const r = spawnSync('sh', ['-c', `. "${SCRIPT}"\n${program}`], { encoding: 'utf8', - env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}`, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', FAKE_MODE: mode, FAKE_HDR_LOG: hdrLog, ...env }, + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH ?? ''}`, + DIFYCTL_INSTALL_LIB: '1', + DIFY_VERSION: '', + DIFYCTL_VERSION: '', + FAKE_MODE: mode, + FAKE_HDR_LOG: hdrLog, + ...env, + }, }) let headers = '' try { headers = readFileSync(hdrLog, 'utf8') + } catch { + /* no headers logged */ } - catch { /* no headers logged */ } rmSync(dir, { recursive: true, force: true }) return { result: (r.stdout ?? '').trim(), headers } } describe('install-cli fetch_json (real, fake curl on PATH)', () => { it('returns the response body on 200', () => { - expect(runRealFetch('ok', { FAKE_BODY: '{"tag_name":"1.15.0"}' }).result).toBe('OK|{"tag_name":"1.15.0"}') + expect(runRealFetch('ok', { FAKE_BODY: '{"tag_name":"1.15.0"}' }).result).toBe( + 'OK|{"tag_name":"1.15.0"}', + ) }) it('classifies a 403 with x-ratelimit-remaining:0 as a rate limit and captures the reset', () => { - expect(runRealFetch('ratelimit', { FAKE_RESET: '1893456000' }).result).toBe('FAIL|ratelimit:1893456000') + expect(runRealFetch('ratelimit', { FAKE_RESET: '1893456000' }).result).toBe( + 'FAIL|ratelimit:1893456000', + ) }) it('classifies a 403 with tokens left as a plain http error, not a rate limit', () => { @@ -329,14 +383,20 @@ describe('install-cli fetch_json (real, fake curl on PATH)', () => { }) it('sends an Authorization bearer header when GITHUB_TOKEN is set', () => { - expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: 'ghp_secret' }).headers).toContain('Authorization: Bearer ghp_secret') + expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: 'ghp_secret' }).headers).toContain( + 'Authorization: Bearer ghp_secret', + ) }) it('falls back to GH_TOKEN when GITHUB_TOKEN is unset', () => { - expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: 'gho_fallback' }).headers).toContain('Authorization: Bearer gho_fallback') + expect( + runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: 'gho_fallback' }).headers, + ).toContain('Authorization: Bearer gho_fallback') }) it('sends no Authorization header when neither token is set', () => { - expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: '' }).headers).not.toContain('Authorization') + expect( + runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: '' }).headers, + ).not.toContain('Authorization') }) }) diff --git a/cli/scripts/install-r2.ps1.test.ts b/cli/scripts/install-r2.ps1.test.ts index 672f103c4aade7..2de1b31eddba5d 100644 --- a/cli/scripts/install-r2.ps1.test.ts +++ b/cli/scripts/install-r2.ps1.test.ts @@ -12,23 +12,30 @@ const MANIFEST = JSON.stringify({ channel: 'edge', version: '0.1.0-edge.2fd7b82', baseUrl: 'https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82', - targets: { 'windows-x64': { asset: 'difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe', sha256: 'deadbeef' } }, + targets: { + 'windows-x64': { asset: 'difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe', sha256: 'deadbeef' }, + }, }) -function pwsh(program: string): { code: number, stdout: string, stderr: string } { +function pwsh(program: string): { code: number; stdout: string; stderr: string } { const full = `. '${SCRIPT}'\n${program}` const r = spawnSync('pwsh', ['-NoProfile', '-Command', full], { encoding: 'utf8', env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' }, }) - return { code: r.status ?? 1, stdout: (r.stdout ?? '').replace(/\r\n/g, '\n').trim(), stderr: r.stderr ?? '' } + return { + code: r.status ?? 1, + stdout: (r.stdout ?? '').replace(/\r\n/g, '\n').trim(), + stderr: r.stderr ?? '', + } } d('install-r2.ps1', () => { it('parses a target asset + sha from the manifest', () => { - const prog = `$m = ConvertFrom-Json @'\n${MANIFEST}\n'@\n` - + `Write-Output (Get-TargetField $m 'windows-x64' 'asset')\n` - + `Write-Output (Get-TargetField $m 'windows-x64' 'sha256')` + const prog = + `$m = ConvertFrom-Json @'\n${MANIFEST}\n'@\n` + + `Write-Output (Get-TargetField $m 'windows-x64' 'asset')\n` + + `Write-Output (Get-TargetField $m 'windows-x64' 'sha256')` const { stdout } = pwsh(prog) expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe\ndeadbeef') }) diff --git a/cli/scripts/install-r2.test.ts b/cli/scripts/install-r2.test.ts index 3e02439d6cfa2f..81ac63c91ce93a 100644 --- a/cli/scripts/install-r2.test.ts +++ b/cli/scripts/install-r2.test.ts @@ -49,7 +49,10 @@ const CHECKSUMS = [ 'beadc0de difyctl-v0.1.0-edge.ce4af868-windows-x64.exe', ].join('\n') -function lib(program: string, env: Record = {}): { code: number, stdout: string, stderr: string } { +function lib( + program: string, + env: Record = {}, +): { code: number; stdout: string; stderr: string } { const full = `. "${SCRIPT}"\n${program}` const r = spawnSync('sh', ['-c', full], { encoding: 'utf8', @@ -63,24 +66,33 @@ describe('install-r2 manifest parsing', () => { // so detect_target intentionally dies (Windows installs go through install-r2.ps1). it.skipIf(process.platform === 'win32')('detect_target maps to one of the 5 ids', () => { const { stdout } = lib('detect_target') - expect(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']).toContain(stdout) + expect(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']).toContain( + stdout, + ) }) it('manifest_str reads a top-level string field', () => { - const { stdout } = lib(`printf '%s' '${MANIFEST}' > "$tmp_m"; manifest_str "$tmp_m" channel`, {}) + const { stdout } = lib( + `printf '%s' '${MANIFEST}' > "$tmp_m"; manifest_str "$tmp_m" channel`, + {}, + ) expect(stdout).toBe('edge') }) it('manifest_target_field extracts per-target values from a single line', () => { - const prog = `printf '%s' '${MANIFEST}' > "$tmp_m"\n` - + 'manifest_target_field "$tmp_m" darwin-arm64 asset\n' - + 'manifest_target_field "$tmp_m" darwin-arm64 sha256' + const prog = + `printf '%s' '${MANIFEST}' > "$tmp_m"\n` + + 'manifest_target_field "$tmp_m" darwin-arm64 asset\n' + + 'manifest_target_field "$tmp_m" darwin-arm64 sha256' const { stdout } = lib(prog) expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-darwin-arm64\ncafef00d') }) it('requires DIFYCTL_R2_BASE when run as the installer (not lib)', () => { - const r = spawnSync('sh', [SCRIPT], { encoding: 'utf8', env: { ...process.env, DIFYCTL_R2_BASE: '' } }) + const r = spawnSync('sh', [SCRIPT], { + encoding: 'utf8', + env: { ...process.env, DIFYCTL_R2_BASE: '' }, + }) expect(r.status).not.toBe(0) expect(r.stderr).toMatch(/DIFYCTL_R2_BASE/) }) @@ -93,14 +105,18 @@ describe('install-r2 manifest parsing', () => { it('sha256_check passes on the correct hash', () => { // sha256('hello') = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 - const r = lib('f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 && echo OK') + const r = lib( + 'f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 && echo OK', + ) expect(r.stdout).toBe('OK') }) }) describe('install-r2 version/commit pin', () => { it('index_resolve matches a build by exact version', () => { - const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" version 0.1.0-edge.aaaa111`) + const r = lib( + `printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" version 0.1.0-edge.aaaa111`, + ) expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111') }) @@ -110,7 +126,9 @@ describe('install-r2 version/commit pin', () => { }) it('index_resolve matches the full 40-char commit too', () => { - const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit aaaa111bbbbcccc000011112222333344445555`) + const r = lib( + `printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit aaaa111bbbbcccc000011112222333344445555`, + ) expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111') }) diff --git a/cli/scripts/install.ps1.test.ts b/cli/scripts/install.ps1.test.ts index 3e5f39a3825b71..0a5ea8e5bb6ec8 100644 --- a/cli/scripts/install.ps1.test.ts +++ b/cli/scripts/install.ps1.test.ts @@ -5,9 +5,13 @@ import { describe, expect, it } from 'vitest' const SCRIPT = fileURLToPath(new URL('./install.ps1', import.meta.url)) function hasPwsh(): boolean { - const r = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'], { - encoding: 'utf8', - }) + const r = spawnSync( + 'pwsh', + ['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'], + { + encoding: 'utf8', + }, + ) return r.status === 0 } @@ -16,26 +20,26 @@ const PWSH = hasPwsh() const STUB = [ 'function Invoke-RestMethod {', ' param([string]$Uri, $Headers)', - ' if ($Uri -like \'*/releases/latest\') {', - ' if (-not $env:HX_LATEST) { throw \'mock 404\' }', + " if ($Uri -like '*/releases/latest') {", + " if (-not $env:HX_LATEST) { throw 'mock 404' }", ' return ($env:HX_LATEST | ConvertFrom-Json)', ' }', - ' elseif ($Uri -like \'*/releases?per_page=100\') {', - ' if (-not $env:HX_LIST) { throw \'mock 404\' }', + " elseif ($Uri -like '*/releases?per_page=100') {", + " if (-not $env:HX_LIST) { throw 'mock 404' }", ' return ($env:HX_LIST | ConvertFrom-Json)', ' }', - ' elseif ($Uri -like \'*/releases/tags/*\') {', - ' $t = $Uri -replace \'.*/releases/tags/\', \'\'', - ' $k = \'HX_TAG_\' + ($t -replace \'[.\\-]\', \'_\')', + " elseif ($Uri -like '*/releases/tags/*') {", + " $t = $Uri -replace '.*/releases/tags/', ''", + " $k = 'HX_TAG_' + ($t -replace '[.\\-]', '_')", ' $v = [Environment]::GetEnvironmentVariable($k)', - ' if (-not $v) { throw \'mock 404\' }', + " if (-not $v) { throw 'mock 404' }", ' return ($v | ConvertFrom-Json)', ' }', ' throw "unexpected uri $Uri"', '}', ].join('\n') -type Run = { code: number, stdout: string, stderr: string } +type Run = { code: number; stdout: string; stderr: string } function runPwsh(body: string, env: Record = {}): Run { const script = `$ErrorActionPreference='Stop'\n${STUB}\n. '${SCRIPT}'\n${body}` @@ -54,8 +58,14 @@ function runPwsh(body: string, env: Record = {}): Run { return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' } } -const REL_1142 = JSON.stringify({ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] }) -const REL_1150 = JSON.stringify({ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] }) +const REL_1142 = JSON.stringify({ + tag_name: '1.14.2', + assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }], +}) +const REL_1150 = JSON.stringify({ + tag_name: '1.15.0', + assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }], +}) const LIST_NEWEST_FIRST = JSON.stringify([ { tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] }, { tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] }, @@ -63,25 +73,31 @@ const LIST_NEWEST_FIRST = JSON.stringify([ describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => { it('extracts the version from a windows .exe asset name', () => { - const r = runPwsh('(Get-AssetSemver \'difyctl-v0.2.0-windows-x64.exe\').Version') + const r = runPwsh("(Get-AssetSemver 'difyctl-v0.2.0-windows-x64.exe').Version") expect(r.code).toBe(0) expect(r.stdout).toBe('0.2.0') }) it('extracts a prerelease version and its rc number', () => { - const r = runPwsh('$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"') + const r = runPwsh( + '$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"', + ) expect(r.code).toBe(0) expect(r.stdout).toBe('0.1.0-rc.1 1') }) it('rejects a non-windows asset (returns null)', () => { - const r = runPwsh('if ($null -eq (Get-AssetSemver \'difyctl-v0.2.0-linux-x64\')) { \'NULL\' } else { \'OBJ\' }') + const r = runPwsh( + "if ($null -eq (Get-AssetSemver 'difyctl-v0.2.0-linux-x64')) { 'NULL' } else { 'OBJ' }", + ) expect(r.code).toBe(0) expect(r.stdout).toBe('NULL') }) it('rejects a malformed core version (returns null)', () => { - const r = runPwsh('if ($null -eq (Get-AssetSemver \'difyctl-vx.y.z-windows-x64.exe\')) { \'NULL\' } else { \'OBJ\' }') + const r = runPwsh( + "if ($null -eq (Get-AssetSemver 'difyctl-vx.y.z-windows-x64.exe')) { 'NULL' } else { 'OBJ' }", + ) expect(r.code).toBe(0) expect(r.stdout).toBe('NULL') }) @@ -89,33 +105,39 @@ describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => { describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => { it('picks the highest semver among several windows builds', () => { - const rel = JSON.stringify({ assets: [ - { name: 'difyctl-v0.2.0-windows-x64.exe' }, - { name: 'difyctl-v0.10.0-windows-x64.exe' }, - { name: 'difyctl-v0.9.0-windows-x64.exe' }, - ] }) + const rel = JSON.stringify({ + assets: [ + { name: 'difyctl-v0.2.0-windows-x64.exe' }, + { name: 'difyctl-v0.10.0-windows-x64.exe' }, + { name: 'difyctl-v0.9.0-windows-x64.exe' }, + ], + }) const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`) expect(r.code).toBe(0) expect(r.stdout).toBe('0.10.0') }) it('prefers the stable release over an rc of the same core', () => { - const rel = JSON.stringify({ assets: [ - { name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' }, - { name: 'difyctl-v0.2.0-windows-x64.exe' }, - ] }) + const rel = JSON.stringify({ + assets: [ + { name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' }, + { name: 'difyctl-v0.2.0-windows-x64.exe' }, + ], + }) const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`) expect(r.code).toBe(0) expect(r.stdout).toBe('0.2.0') }) it('ignores checksums and non-windows assets', () => { - const rel = JSON.stringify({ assets: [ - { name: 'difyctl-v0.2.0-linux-x64' }, - { name: 'difyctl-v0.2.0-checksums.txt' }, - { name: 'difyctl-v0.2.0-windows-x64.exe' }, - { name: 'some-other-asset.zip' }, - ] }) + const rel = JSON.stringify({ + assets: [ + { name: 'difyctl-v0.2.0-linux-x64' }, + { name: 'difyctl-v0.2.0-checksums.txt' }, + { name: 'difyctl-v0.2.0-windows-x64.exe' }, + { name: 'some-other-asset.zip' }, + ], + }) const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Name`) expect(r.code).toBe(0) expect(r.stdout).toBe('difyctl-v0.2.0-windows-x64.exe') @@ -123,7 +145,9 @@ describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => { it('yields null when no windows asset is present', () => { const rel = JSON.stringify({ assets: [{ name: 'difyctl-v0.2.0-linux-x64' }] }) - const r = runPwsh(`if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`) + const r = runPwsh( + `if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`, + ) expect(r.code).toBe(0) expect(r.stdout).toBe('NULL') }) @@ -131,7 +155,10 @@ describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => { describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => { it('DIFY_VERSION pins the release directly', () => { - const r = runPwsh('(Resolve-Release).tag_name', { DIFY_VERSION: '1.14.2', HX_TAG_1_14_2: REL_1142 }) + const r = runPwsh('(Resolve-Release).tag_name', { + DIFY_VERSION: '1.14.2', + HX_TAG_1_14_2: REL_1142, + }) expect(r.code).toBe(0) expect(r.stdout).toBe('1.14.2') }) @@ -155,13 +182,19 @@ describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => { }) it('DIFYCTL_VERSION resolves to the release hosting that build', () => { - const r = runPwsh('(Resolve-Release).tag_name', { DIFYCTL_VERSION: '0.2.0', HX_LIST: LIST_NEWEST_FIRST }) + const r = runPwsh('(Resolve-Release).tag_name', { + DIFYCTL_VERSION: '0.2.0', + HX_LIST: LIST_NEWEST_FIRST, + }) expect(r.code).toBe(0) expect(r.stdout).toBe('1.14.2') }) it('DIFYCTL_VERSION not hosted anywhere throws', () => { - const r = runPwsh('(Resolve-Release).tag_name', { DIFYCTL_VERSION: '9.9.9', HX_LIST: LIST_NEWEST_FIRST }) + const r = runPwsh('(Resolve-Release).tag_name', { + DIFYCTL_VERSION: '9.9.9', + HX_LIST: LIST_NEWEST_FIRST, + }) expect(r.code).not.toBe(0) expect(r.stderr).toContain('difyctl 9.9.9 not found on any Dify release') }) @@ -169,13 +202,16 @@ describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => { describe.skipIf(!PWSH)('install.ps1 Find-ReleaseForDifyctl', () => { it('returns the newest release whose assets host the wanted build', () => { - const r = runPwsh('(Find-ReleaseForDifyctl \'0.2.0\').tag_name', { HX_LIST: LIST_NEWEST_FIRST }) + const r = runPwsh("(Find-ReleaseForDifyctl '0.2.0').tag_name", { HX_LIST: LIST_NEWEST_FIRST }) expect(r.code).toBe(0) expect(r.stdout).toBe('1.14.2') }) it('returns nothing when no release hosts the wanted build', () => { - const r = runPwsh('$x = Find-ReleaseForDifyctl \'9.9.9\'; if ($null -eq $x) { \'NULL\' } else { $x.tag_name }', { HX_LIST: LIST_NEWEST_FIRST }) + const r = runPwsh( + "$x = Find-ReleaseForDifyctl '9.9.9'; if ($null -eq $x) { 'NULL' } else { $x.tag_name }", + { HX_LIST: LIST_NEWEST_FIRST }, + ) expect(r.code).toBe(0) expect(r.stdout).toBe('NULL') }) @@ -198,7 +234,8 @@ const futureReset = String(Math.floor(Date.now() / 1000) + 1800) describe.skipIf(!PWSH)('install.ps1 rate limit', () => { it('classifies a 403 with x-ratelimit-remaining:0 as rate-limited, returning the reset', () => { - const r = runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': futureReset })} + const r = + runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': futureReset })} $i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { $i.Reset }`) expect(r.code).toBe(0) expect(r.stdout).toBe(futureReset) @@ -219,7 +256,9 @@ describe.skipIf(!PWSH)('install.ps1 rate limit', () => { }) it('returns null for an error without a response (e.g. a plain string throw)', () => { - const r = runPwsh('$err = [pscustomobject]@{ Exception = [pscustomobject]@{} }; if ($null -eq (Get-RateLimitInfo $err)) { \'NULL\' } else { \'OBJ\' }') + const r = runPwsh( + "$err = [pscustomobject]@{ Exception = [pscustomobject]@{} }; if ($null -eq (Get-RateLimitInfo $err)) { 'NULL' } else { 'OBJ' }", + ) expect(r.code).toBe(0) expect(r.stdout).toBe('NULL') }) @@ -233,7 +272,7 @@ describe.skipIf(!PWSH)('install.ps1 rate limit', () => { }) it('Write-RateLimitHint omits the ETA line when the reset epoch is missing', () => { - const r = runPwsh('Write-RateLimitHint \'\'') + const r = runPwsh("Write-RateLimitHint ''") expect(r.code).toBe(0) expect(r.stderr).toContain('rate limit exceeded') expect(r.stderr).not.toContain('resets in ~') diff --git a/cli/scripts/lib/resolve-buildinfo.ts b/cli/scripts/lib/resolve-buildinfo.ts index 10cdf2814b07cc..7e5b6e847000a3 100644 --- a/cli/scripts/lib/resolve-buildinfo.ts +++ b/cli/scripts/lib/resolve-buildinfo.ts @@ -27,8 +27,7 @@ const GIT_PROBE_OPTS: ExecSyncOptions = { export const defaultGitProbe: GitProbe = (cmd) => { try { return execSync(cmd, GIT_PROBE_OPTS).toString().trim() || null - } - catch { + } catch { return null } } @@ -36,7 +35,7 @@ export const defaultGitProbe: GitProbe = (cmd) => { type PackageManifest = { difyctl?: { channel?: string - compat?: { minDify?: string, maxDify?: string } + compat?: { minDify?: string; maxDify?: string } } } @@ -48,8 +47,7 @@ const defaultPackageReader: PackageReader = () => { try { const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json') return JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageManifest - } - catch { + } catch { return {} } } @@ -69,20 +67,12 @@ export function resolveBuildInfo(opts: ResolveOptions = {}): BuildInfo { const channel = env.DIFYCTL_CHANNEL ?? pkg.difyctl?.channel ?? 'dev' if (!(BUILD_CHANNELS as readonly string[]).includes(channel)) { - throw new Error( - `invalid DIFYCTL_CHANNEL: ${channel} (expected ${BUILD_CHANNELS.join(' | ')})`, - ) + throw new Error(`invalid DIFYCTL_CHANNEL: ${channel} (expected ${BUILD_CHANNELS.join(' | ')})`) } - const version - = env.DIFYCTL_VERSION - ?? git('git describe --tags --dirty --always') - ?? '0.0.0-dev' + const version = env.DIFYCTL_VERSION ?? git('git describe --tags --dirty --always') ?? '0.0.0-dev' - const commit - = env.DIFYCTL_COMMIT - ?? git('git rev-parse HEAD') - ?? 'none' + const commit = env.DIFYCTL_COMMIT ?? git('git rev-parse HEAD') ?? 'none' const buildDate = env.DIFYCTL_BUILD_DATE ?? now().toISOString() const minDify = env.DIFYCTL_MIN_DIFY ?? pkg.difyctl?.compat?.minDify ?? '0.0.0' diff --git a/cli/scripts/print-buildinfo.ts b/cli/scripts/print-buildinfo.ts index 69f448c8d48e52..34059b2361c905 100644 --- a/cli/scripts/print-buildinfo.ts +++ b/cli/scripts/print-buildinfo.ts @@ -2,8 +2,8 @@ import { resolveBuildInfo } from './lib/resolve-buildinfo.js' const info = resolveBuildInfo() process.stdout.write( - `version: ${info.version}\n` - + `commit: ${info.commit}\n` - + `built: ${info.buildDate}\n` - + `channel: ${info.channel}\n`, + `version: ${info.version}\n` + + `commit: ${info.commit}\n` + + `built: ${info.buildDate}\n` + + `channel: ${info.channel}\n`, ) diff --git a/cli/scripts/release-naming.mjs b/cli/scripts/release-naming.mjs index 376c215633e0a1..2af172f1333d6e 100644 --- a/cli/scripts/release-naming.mjs +++ b/cli/scripts/release-naming.mjs @@ -31,8 +31,8 @@ const CHANNELS = [ { name: 'edge', prerelease: true, versionForm: /^\d+\.\d+\.\d+-edge\.[0-9a-f]{7,40}$/ }, ] -const channelByName = name => CHANNELS.find(c => c.name === name) -const channelNames = () => CHANNELS.map(c => c.name).join(', ') +const channelByName = (name) => CHANNELS.find((c) => c.name === name) +const channelNames = () => CHANNELS.map((c) => c.name).join(', ') function parsePrecedence(v) { const s = String(v).replace(/^v/, '').replace(/\+.*$/, '') @@ -51,8 +51,7 @@ function edgeVersion(sha) { die('edge-version requires a git short sha (7-40 hex chars)') const { version } = loadPkg() const core = versionCore(version) - if (!/^\d+\.\d+\.\d+$/.test(core)) - die(`cannot derive edge base from version: ${version}`) + if (!/^\d+\.\d+\.\d+$/.test(core)) die(`cannot derive edge base from version: ${version}`) return `${core}-edge.${sha}` } @@ -63,8 +62,7 @@ function channelVersionProblem(version, channel) { if (typeof version !== 'string' || version.length === 0) return 'version must be a non-empty string' const ch = channelByName(channel) - if (!ch) - return `unknown channel: ${channel} (expected one of: ${channelNames()})` + if (!ch) return `unknown channel: ${channel} (expected one of: ${channelNames()})` if (!ch.versionForm.test(version)) return `version ${version} does not match the ${channel} channel form` return null @@ -72,8 +70,7 @@ function channelVersionProblem(version, channel) { function validateVersionForChannel(version, channelName) { const problem = channelVersionProblem(version, channelName) - if (problem) - die(problem) + if (problem) die(problem) return `valid: ${version} is a ${channelName} version` } @@ -82,21 +79,16 @@ function comparePre(a, b) { const bparts = b.split('.') const len = Math.max(aparts.length, bparts.length) for (let i = 0; i < len; i++) { - if (aparts[i] === undefined) - return -1 - if (bparts[i] === undefined) - return 1 + if (aparts[i] === undefined) return -1 + if (bparts[i] === undefined) return 1 const an = /^\d+$/.test(aparts[i]) const bn = /^\d+$/.test(bparts[i]) if (an && bn) { const d = Number(aparts[i]) - Number(bparts[i]) - if (d !== 0) - return d < 0 ? -1 : 1 - } - else if (an !== bn) { + if (d !== 0) return d < 0 ? -1 : 1 + } else if (an !== bn) { return an ? -1 : 1 - } - else if (aparts[i] !== bparts[i]) { + } else if (aparts[i] !== bparts[i]) { return aparts[i] < bparts[i] ? -1 : 1 } } @@ -109,15 +101,11 @@ function comparePrecedence(a, b) { for (let i = 0; i < SEMVER_CORE_LEN; i++) { const x = A.nums[i] ?? 0 const y = B.nums[i] ?? 0 - if (x !== y) - return x < y ? -1 : 1 + if (x !== y) return x < y ? -1 : 1 } - if (A.pre === B.pre) - return 0 - if (A.pre === '') - return 1 - if (B.pre === '') - return -1 + if (A.pre === B.pre) return 0 + if (A.pre === '') return 1 + if (B.pre === '') return -1 return comparePre(A.pre, B.pre) } @@ -129,8 +117,7 @@ function die(msg) { function loadPkg() { const pkgUrl = new URL('../package.json', import.meta.url) const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8')) - if (!pkg.difyctl?.release) - die('cli/package.json missing difyctl.release') + if (!pkg.difyctl?.release) die('cli/package.json missing difyctl.release') return { version: pkg.version, channel: pkg.difyctl.channel, @@ -151,32 +138,29 @@ function githubEnv() { tagPrefix: release.tagPrefix, difyctlTag: `${release.tagPrefix}${version}`, } - return Object.entries(fields).map(([k, v]) => `${k}=${v}`).join('\n') + return Object.entries(fields) + .map(([k, v]) => `${k}=${v}`) + .join('\n') } function requireVersion(version) { - if (!version) - die('version argument is required') + if (!version) die('version argument is required') return version } function assetName(release, version, id) { - const target = release.targets.find(t => t.id === id) - if (!target) - die(`unknown target id: ${id}`) + const target = release.targets.find((t) => t.id === id) + if (!target) die(`unknown target id: ${id}`) const suffix = target.exe ? '.exe' : '' return `${release.tagPrefix}${version}-${id}${suffix}` } function validateRelease(release) { const problems = [] - const str = v => typeof v === 'string' && v.length > 0 - if (!str(release.tagPrefix)) - problems.push('tagPrefix must be a non-empty string') - if (!str(release.binName)) - problems.push('binName must be a non-empty string') - if (!str(release.checksumsSuffix)) - problems.push('checksumsSuffix must be a non-empty string') + const str = (v) => typeof v === 'string' && v.length > 0 + if (!str(release.tagPrefix)) problems.push('tagPrefix must be a non-empty string') + if (!str(release.binName)) problems.push('binName must be a non-empty string') + if (!str(release.checksumsSuffix)) problems.push('checksumsSuffix must be a non-empty string') if (!Array.isArray(release.targets) || release.targets.length === 0) { problems.push('targets must be a non-empty array') return problems @@ -184,15 +168,12 @@ function validateRelease(release) { const seen = new Set() for (const t of release.targets) { const label = t?.id ?? JSON.stringify(t) - if (!str(t?.id)) - problems.push(`target ${label}: id must be a non-empty string`) - else if (seen.has(t.id)) - problems.push(`duplicate target id: ${t.id}`) + if (!str(t?.id)) problems.push(`target ${label}: id must be a non-empty string`) + else if (seen.has(t.id)) problems.push(`duplicate target id: ${t.id}`) else seen.add(t.id) if (!str(t?.bunTarget) || !BUN_TARGET_RE.test(t.bunTarget)) problems.push(`target ${label}: bunTarget must match ${BUN_TARGET_RE}`) - if (typeof t?.exe !== 'boolean') - problems.push(`target ${label}: exe must be a boolean`) + if (typeof t?.exe !== 'boolean') problems.push(`target ${label}: exe must be a boolean`) else if (str(t?.bunTarget) && t.exe !== t.bunTarget.startsWith('bun-windows-')) problems.push(`target ${label}: exe must be true iff bunTarget is bun-windows-*`) } @@ -210,7 +191,11 @@ function main(argv) { case 'tag': return `${loadPkg().release.tagPrefix}${requireVersion(rest[0])}` case 'asset': - return assetName(loadPkg().release, requireVersion(rest[0]), rest[1] ?? die('target id is required')) + return assetName( + loadPkg().release, + requireVersion(rest[0]), + rest[1] ?? die('target id is required'), + ) case 'checksums': { const { release } = loadPkg() return `${release.tagPrefix}${requireVersion(rest[0])}${release.checksumsSuffix}` @@ -218,9 +203,11 @@ function main(argv) { case 'tag-prefix': return loadPkg().release.tagPrefix case 'targets': - return loadPkg().release.targets.map(t => `${t.bunTarget}\t${t.id}\t${t.exe ? 1 : 0}`).join('\n') + return loadPkg() + .release.targets.map((t) => `${t.bunTarget}\t${t.id}\t${t.exe ? 1 : 0}`) + .join('\n') case 'channels': - return CHANNELS.map(c => c.name).join('\n') + return CHANNELS.map((c) => c.name).join('\n') case 'github-env': return githubEnv() case 'compat-check': { @@ -228,14 +215,18 @@ function main(argv) { const difyVersion = requireVersion(rest[0]) if (!compat.minDify || !compat.maxDify) die('cli/package.json missing difyctl.compat.minDify/maxDify') - if (comparePrecedence(difyVersion, compat.minDify) < 0 || comparePrecedence(difyVersion, compat.maxDify) > 0) - die(`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`) + if ( + comparePrecedence(difyVersion, compat.minDify) < 0 || + comparePrecedence(difyVersion, compat.maxDify) > 0 + ) + die( + `Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`, + ) return `compatible: Dify ${difyVersion} within ${compat.minDify}..${compat.maxDify}` } case 'prerelease': { const ch = channelByName(rest[0] ?? die('channel argument is required')) - if (!ch) - die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`) + if (!ch) die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`) return String(ch.prerelease) } case 'validate': { @@ -257,9 +248,16 @@ function main(argv) { } } -const invokedDirectly = process.argv[1] - && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) -if (invokedDirectly) - process.stdout.write(`${main(process.argv.slice(2))}\n`) +const invokedDirectly = + process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) +if (invokedDirectly) process.stdout.write(`${main(process.argv.slice(2))}\n`) -export { assetName, channelByName, CHANNELS, edgeVersion, loadPkg, validateVersionForChannel, versionCore } +export { + assetName, + channelByName, + CHANNELS, + edgeVersion, + loadPkg, + validateVersionForChannel, + versionCore, +} diff --git a/cli/scripts/release-naming.test.ts b/cli/scripts/release-naming.test.ts index 7641b531282e95..97552ed08233c9 100644 --- a/cli/scripts/release-naming.test.ts +++ b/cli/scripts/release-naming.test.ts @@ -4,13 +4,12 @@ import { describe, expect, it } from 'vitest' const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url)) -function run(args: string[]): { code: number, stdout: string, stderr: string } { +function run(args: string[]): { code: number; stdout: string; stderr: string } { try { const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }) return { code: 0, stdout, stderr: '' } - } - catch (e) { - const err = e as { status?: number, stdout?: string, stderr?: string } + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string } return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' } } } diff --git a/cli/scripts/release-r2-edge.mjs b/cli/scripts/release-r2-edge.mjs index 55b40435b6f7d8..cd1d3a5d7f97d6 100644 --- a/cli/scripts/release-r2-edge.mjs +++ b/cli/scripts/release-r2-edge.mjs @@ -24,8 +24,7 @@ function parseArgs(argv) { function requireArgs(args, keys) { for (const k of keys) { - if (!args[k]) - die(`missing --${k}`) + if (!args[k]) die(`missing --${k}`) } } @@ -34,8 +33,7 @@ function shaMap(checksumsPath) { const map = new Map() for (const line of readFileSync(checksumsPath, 'utf8').split('\n')) { const m = line.match(/^([0-9a-f]{64})\s+(\S+)$/i) - if (m) - map.set(m[2], m[1]) + if (m) map.set(m[2], m[1]) } return map } @@ -46,14 +44,15 @@ function emitManifest(args) { const { release, compat } = loadPkg() const shas = shaMap(args.checksums) - const targetLines = release.targets.map((t) => { - const asset = assetName(release, args.version, t.id) - const sha = shas.get(asset) - if (!sha) - die(`no sha256 for ${asset} in ${args.checksums}`) - // one target per line: install-r2.sh grep/sed depends on this layout - return ` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(asset)}, "sha256": ${JSON.stringify(sha)} }` - }).join(',\n') + const targetLines = release.targets + .map((t) => { + const asset = assetName(release, args.version, t.id) + const sha = shas.get(asset) + if (!sha) die(`no sha256 for ${asset} in ${args.checksums}`) + // one target per line: install-r2.sh grep/sed depends on this layout + return ` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(asset)}, "sha256": ${JSON.stringify(sha)} }` + }) + .join(',\n') const head = { schema: 1, @@ -65,20 +64,20 @@ function emitManifest(args) { compat: { minDify: compat.minDify, maxDify: compat.maxDify }, baseUrl: args['base-url'], } - const headLines = Object.entries(head).map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(',\n') + const headLines = Object.entries(head) + .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`) + .join(',\n') process.stdout.write(`{\n${headLines},\n "targets": {\n${targetLines}\n }\n}\n`) } // Newline-delimited dir names of binaries that still exist in R2. Absent file = // no reconciliation (caller could not list); empty file = no survivors. function loadExistingDirs(path) { - if (!path || !existsSync(path)) - return null + if (!path || !existsSync(path)) return null const set = new Set() for (const line of readFileSync(path, 'utf8').split('\n')) { const d = line.trim() - if (d) - set.add(d) + if (d) set.add(d) } return set } @@ -93,23 +92,26 @@ function emitIndex(args) { if (raw && raw !== '-') { try { current = JSON.parse(raw) - } - catch { + } catch { die(`current index at ${args.current} is not valid JSON`) } } } - const entry = { version: args.version, commit: args.commit, buildDate: args['build-date'], dir: args.version } - const kept = (current.builds ?? []).filter(b => b.version !== entry.version) + const entry = { + version: args.version, + commit: args.commit, + buildDate: args['build-date'], + dir: args.version, + } + const kept = (current.builds ?? []).filter((b) => b.version !== entry.version) let builds = [entry, ...kept] // Reconcile to binaries that still exist in R2: lifecycle/TTL on the bin prefix // is the only deletion mechanism, so the ledger never advertises a build whose // binary is gone. The new build is always kept (just uploaded). No count cap. const existing = loadExistingDirs(args['existing-dirs']) - if (existing) - builds = builds.filter(b => b.dir === entry.dir || existing.has(b.dir)) + if (existing) builds = builds.filter((b) => b.dir === entry.dir || existing.has(b.dir)) const index = { schema: 1, channel: args.channel, updated: args['build-date'], builds } process.stdout.write(`${JSON.stringify(index, null, 2)}\n`) diff --git a/cli/scripts/release-r2-edge.test.ts b/cli/scripts/release-r2-edge.test.ts index 9db0c0ad2cb76e..7c93133fddfdc1 100644 --- a/cli/scripts/release-r2-edge.test.ts +++ b/cli/scripts/release-r2-edge.test.ts @@ -7,12 +7,15 @@ import { describe, expect, it } from 'vitest' const SCRIPT = fileURLToPath(new URL('./release-r2-edge.mjs', import.meta.url)) -function run(args: string[]): { code: number, stdout: string, stderr: string } { +function run(args: string[]): { code: number; stdout: string; stderr: string } { try { - return { code: 0, stdout: execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }), stderr: '' } - } - catch (e) { - const err = e as { status?: number, stdout?: string, stderr?: string } + return { + code: 0, + stdout: execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }), + stderr: '', + } + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string } return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' } } } @@ -42,9 +45,9 @@ type ManifestJson = { version: string commit: string buildDate: string - compat: { minDify: string, maxDify: string } + compat: { minDify: string; maxDify: string } baseUrl: string - targets: Record + targets: Record } type IndexBuild = { @@ -61,10 +64,34 @@ type IndexJson = { builds: IndexBuild[] } -function buildManifest(version = VERSION): { code: number, json: ManifestJson, stdout: string, stderr: string } { +function buildManifest(version = VERSION): { + code: number + json: ManifestJson + stdout: string + stderr: string +} { const checksums = writeChecksums(version) - const r = run(['manifest', '--channel', 'edge', '--version', version, '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', BASE_URL, '--checksums', checksums]) - return { code: r.code, json: (r.code === 0 ? JSON.parse(r.stdout) : null) as ManifestJson, stdout: r.stdout, stderr: r.stderr } + const r = run([ + 'manifest', + '--channel', + 'edge', + '--version', + version, + '--commit', + 'abc1234', + '--build-date', + '2026-06-14T12:00:00Z', + '--base-url', + BASE_URL, + '--checksums', + checksums, + ]) + return { + code: r.code, + json: (r.code === 0 ? JSON.parse(r.stdout) : null) as ManifestJson, + stdout: r.stdout, + stderr: r.stderr, + } } describe('release-r2-edge manifest', () => { @@ -86,9 +113,13 @@ describe('release-r2-edge manifest', () => { it('lists all 5 targets with asset name + sha256 from the checksums file', () => { const { json } = buildManifest() - expect(Object.keys(json.targets).sort()).toEqual( - ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'windows-x64'], - ) + expect(Object.keys(json.targets).sort()).toEqual([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'windows-x64', + ]) expect(json.targets['linux-x64'].asset).toBe(`difyctl-v${VERSION}-linux-x64`) expect(json.targets['windows-x64'].asset).toBe(`difyctl-v${VERSION}-windows-x64.exe`) expect(json.targets['linux-x64'].sha256).toMatch(/^\d{64}$/) @@ -108,20 +139,51 @@ describe('release-r2-edge manifest', () => { const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-')) const file = join(dir, `difyctl-v${VERSION}-checksums.txt`) writeFileSync(file, `${'0'.repeat(64)} difyctl-v${VERSION}-linux-x64\n`) // only 1 of 5 - const r = run(['manifest', '--channel', 'edge', '--version', VERSION, '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', BASE_URL, '--checksums', file]) + const r = run([ + 'manifest', + '--channel', + 'edge', + '--version', + VERSION, + '--commit', + 'abc1234', + '--build-date', + '2026-06-14T12:00:00Z', + '--base-url', + BASE_URL, + '--checksums', + file, + ]) expect(r.code).not.toBe(0) }) it('rejects a malformed dropped-value argument (no silent misparse)', () => { // --version has no value; --commit must NOT be swallowed as the version - const r = run(['manifest', '--channel', 'edge', '--version', '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', 'https://x', '--checksums', '/nonexistent']) + const r = run([ + 'manifest', + '--channel', + 'edge', + '--version', + '--commit', + 'abc1234', + '--build-date', + '2026-06-14T12:00:00Z', + '--base-url', + 'https://x', + '--checksums', + '/nonexistent', + ]) expect(r.code).not.toBe(0) }) }) // ---- index ---- -function runIndex(currentContent: string | null, build: Record, existingDirs?: string[]) { +function runIndex( + currentContent: string | null, + build: Record, + existingDirs?: string[], +) { let currentArg = '-' if (currentContent !== null) { const dir = mkdtempSync(join(tmpdir(), 'difyctl-index-')) @@ -135,7 +197,25 @@ function runIndex(currentContent: string | null, build: Record, writeFileSync(f, `${existingDirs.join('\n')}\n`) extra.push('--existing-dirs', f) } - const r = spawnSync('node', [SCRIPT, 'index', '--current', currentArg, '--channel', 'edge', '--version', build.version, '--commit', build.commit, '--build-date', build.buildDate, ...extra], { encoding: 'utf8' }) + const r = spawnSync( + 'node', + [ + SCRIPT, + 'index', + '--current', + currentArg, + '--channel', + 'edge', + '--version', + build.version, + '--commit', + build.commit, + '--build-date', + build.buildDate, + ...extra, + ], + { encoding: 'utf8' }, + ) return { code: r.status ?? 1, index: (r.status === 0 ? JSON.parse(r.stdout) : null) as IndexJson, @@ -151,7 +231,11 @@ describe('release-r2-edge index', () => { expect(index.schema).toBe(1) expect(index.channel).toBe('edge') expect(index.builds).toHaveLength(1) - expect(index.builds[0]).toMatchObject({ version: B1.version, commit: B1.commit, dir: B1.version }) + expect(index.builds[0]).toMatchObject({ + version: B1.version, + commit: B1.commit, + dir: B1.version, + }) }) it('treats an empty current file as fresh (first publish, curl wrote nothing)', () => { @@ -167,40 +251,58 @@ describe('release-r2-edge index', () => { }) it('prepends the new build (publish order; newest at [0])', () => { - const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }] }) + const current = JSON.stringify({ + schema: 1, + channel: 'edge', + builds: [ + { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, + ], + }) const { index } = runIndex(current, B2) - expect(index.builds.map(b => b.version)).toEqual([B2.version, B1.version]) + expect(index.builds.map((b) => b.version)).toEqual([B2.version, B1.version]) }) it('dedups a re-cut of the same version (no duplicate, moves to top)', () => { - const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [ - { version: B2.version, commit: B2.commit, buildDate: B2.buildDate, dir: B2.version }, - { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, - ] }) + const current = JSON.stringify({ + schema: 1, + channel: 'edge', + builds: [ + { version: B2.version, commit: B2.commit, buildDate: B2.buildDate, dir: B2.version }, + { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, + ], + }) const { index } = runIndex(current, B1) // re-cut B1 - expect(index.builds.map(b => b.version)).toEqual([B1.version, B2.version]) + expect(index.builds.map((b) => b.version)).toEqual([B1.version, B2.version]) }) it('reconciles to surviving binary dirs (drops a build whose binary expired)', () => { - const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [ - { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, - ] }) + const current = JSON.stringify({ + schema: 1, + channel: 'edge', + builds: [ + { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, + ], + }) // B1's binary is gone (not in existing); the new B2 is always kept. const { index } = runIndex(current, B2, [B2.version]) - expect(index.builds.map(b => b.version)).toEqual([B2.version]) + expect(index.builds.map((b) => b.version)).toEqual([B2.version]) }) it('keeps the new build even when it is absent from the existing-dirs list', () => { const { index } = runIndex(null, B1, []) // empty survivors, fresh ledger - expect(index.builds.map(b => b.version)).toEqual([B1.version]) + expect(index.builds.map((b) => b.version)).toEqual([B1.version]) }) it('does not reconcile when no --existing-dirs is given (list unavailable)', () => { - const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [ - { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, - ] }) + const current = JSON.stringify({ + schema: 1, + channel: 'edge', + builds: [ + { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, + ], + }) const { index } = runIndex(current, B2) // no existing-dirs → keep all - expect(index.builds.map(b => b.version)).toEqual([B2.version, B1.version]) + expect(index.builds.map((b) => b.version)).toEqual([B2.version, B1.version]) }) it('dies on a non-empty current file that is not valid JSON', () => { diff --git a/cli/scripts/release-r2-publish.test.ts b/cli/scripts/release-r2-publish.test.ts index 0b99622c3dae49..9559e45c03e005 100644 --- a/cli/scripts/release-r2-publish.test.ts +++ b/cli/scripts/release-r2-publish.test.ts @@ -6,7 +6,7 @@ const SCRIPT = fileURLToPath(new URL('./release-r2-publish.sh', import.meta.url) // Stub `aws` + `curl` + `node` as shell functions that just log action verbs to // $ORDER_LOG, then run the publish `main` and assert the order of operations. -function runPublish(): { code: number, order: string[], stderr: string } { +function runPublish(): { code: number; order: string[]; stderr: string } { const stub = [ 'ORDER_LOG="$(mktemp)"', 'aws() {', @@ -23,10 +23,10 @@ function runPublish(): { code: number, order: string[], stderr: string } { 'node() {', ' case "$*" in', ' *release-naming.mjs*targets*)', - ' printf \'bun-linux-x64\\tlinux-x64\\t0\\nbun-linux-arm64\\tlinux-arm64\\t0\\nbun-darwin-x64\\tdarwin-x64\\t0\\nbun-darwin-arm64\\tdarwin-arm64\\t0\\nbun-windows-x64\\twindows-x64\\t1\\n\' ;;', - ' *release-naming.mjs*\' asset \'*) printf \'difyctl-vX\\n\' ;;', - ' *release-r2-edge.mjs*\' index \'*) echo \'{}\' ;;', - ' *release-r2-edge.mjs*\' manifest \'*) echo \'{}\' ;;', + " printf 'bun-linux-x64\\tlinux-x64\\t0\\nbun-linux-arm64\\tlinux-arm64\\t0\\nbun-darwin-x64\\tdarwin-x64\\t0\\nbun-darwin-arm64\\tdarwin-arm64\\t0\\nbun-windows-x64\\twindows-x64\\t1\\n' ;;", + " *release-naming.mjs*' asset '*) printf 'difyctl-vX\\n' ;;", + " *release-r2-edge.mjs*' index '*) echo '{}' ;;", + " *release-r2-edge.mjs*' manifest '*) echo '{}' ;;", ' *) : ;;', ' esac', '}', @@ -49,7 +49,11 @@ function runPublish(): { code: number, order: string[], stderr: string } { DIST_DIR: '/tmp', }, }) - return { code: r.status ?? 1, order: (r.stdout ?? '').trim().split('\n').filter(Boolean), stderr: r.stderr ?? '' } + return { + code: r.status ?? 1, + order: (r.stdout ?? '').trim().split('\n').filter(Boolean), + stderr: r.stderr ?? '', + } } describe('release-r2-publish order', () => { diff --git a/cli/scripts/run-smoke.ts b/cli/scripts/run-smoke.ts index 7c4e776393701c..8346e42f21fa7d 100644 --- a/cli/scripts/run-smoke.ts +++ b/cli/scripts/run-smoke.ts @@ -1,7 +1,7 @@ #!/usr/bin/env -S bun import { execSync } from 'node:child_process' -type Check = { name: string, run: () => void } +type Check = { name: string; run: () => void } const baseUrlIdx = process.argv.indexOf('--base-url') const baseUrl = baseUrlIdx > -1 ? process.argv[baseUrlIdx + 1] : 'http://localhost:5001' @@ -17,16 +17,30 @@ function cli(args: string): string { } const checks: Check[] = [ - { name: 'config show', run: () => { cli('config show') } }, - { name: 'get workspace', run: () => { - if (!cli('get workspace').includes('id')) - throw new Error('no workspace listed') - } }, - { name: 'get apps', run: () => { cli('get apps') } }, - { name: 'difyctl version prints compat', run: () => { - if (!cli('version').includes('compat:')) - throw new Error('no compat line') - } }, + { + name: 'config show', + run: () => { + cli('config show') + }, + }, + { + name: 'get workspace', + run: () => { + if (!cli('get workspace').includes('id')) throw new Error('no workspace listed') + }, + }, + { + name: 'get apps', + run: () => { + cli('get apps') + }, + }, + { + name: 'difyctl version prints compat', + run: () => { + if (!cli('version').includes('compat:')) throw new Error('no compat line') + }, + }, ] let failed = 0 @@ -34,8 +48,7 @@ for (const c of checks) { try { c.run() console.log(`[x] ${c.name}`) - } - catch (err) { + } catch (err) { failed++ console.log(`[ ] ${c.name} — ${(err as Error).message}`) } diff --git a/cli/src/api/account-sessions.test.ts b/cli/src/api/account-sessions.test.ts index 3af9bb641b4a1c..3d9938caf6b76a 100644 --- a/cli/src/api/account-sessions.test.ts +++ b/cli/src/api/account-sessions.test.ts @@ -19,7 +19,7 @@ describe('AccountSessionsClient.list', () => { }) it('GETs account/sessions with no query when paging is unset', async () => { - stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap)) + stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap)) await makeClient(stub.url).list() @@ -29,7 +29,7 @@ describe('AccountSessionsClient.list', () => { }) it('forwards page/limit when supplied', async () => { - stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap)) + stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap)) await makeClient(stub.url).list({ page: 2, limit: 25 }) @@ -50,7 +50,7 @@ describe('AccountSessionsClient.revoke', () => { // The server replies 200 + {status:"revoked"}; revoke() returns void but the // typed client still parses the body — this guards against a regression where // a non-empty 200 body trips JSON handling. - stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap)) + stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap)) await expect(makeClient(stub.url).revoke('sess-1')).resolves.toBeUndefined() expect(stub.captured.method).toBe('DELETE') @@ -58,7 +58,7 @@ describe('AccountSessionsClient.revoke', () => { }) it('URL-encodes the session id', async () => { - stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap)) + stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap)) await makeClient(stub.url).revoke('sess/1 2') @@ -66,16 +66,17 @@ describe('AccountSessionsClient.revoke', () => { }) it('propagates 404 as a classified BaseError', async () => { - stub = await startStubServer(cap => - jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap)) + stub = await startStubServer((cap) => + jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap), + ) await expect(makeClient(stub.url).revoke('missing')).rejects.toSatisfy( - err => isHttpClientError(err) && err.httpStatus === 404, + (err) => isHttpClientError(err) && err.httpStatus === 404, ) }) it('revokeSelf DELETEs the self subresource', async () => { - stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap)) + stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap)) await expect(makeClient(stub.url).revokeSelf()).resolves.toBeUndefined() expect(stub.captured.method).toBe('DELETE') diff --git a/cli/src/api/account-sessions.ts b/cli/src/api/account-sessions.ts index 667deec9a277a4..e2eec413b11b1d 100644 --- a/cli/src/api/account-sessions.ts +++ b/cli/src/api/account-sessions.ts @@ -10,7 +10,7 @@ export class AccountSessionsClient { this.orpc = createOpenApiClient(http) } - async list(q?: { page?: number, limit?: number }): Promise { + async list(q?: { page?: number; limit?: number }): Promise { return this.orpc.account.sessions.get({ query: { page: q?.page, limit: q?.limit } }) } diff --git a/cli/src/api/account.test.ts b/cli/src/api/account.test.ts index f12b63a88c337d..542093178523f5 100644 --- a/cli/src/api/account.test.ts +++ b/cli/src/api/account.test.ts @@ -17,11 +17,16 @@ describe('AccountClient.get', () => { }) it('GETs account, sends the bearer, and returns the parsed payload', async () => { - stub = await startStubServer(cap => - jsonResponder(200, { - subject_type: 'account', - account: { id: 'acct-1', email: 'a@e.com', name: 'A' }, - }, cap)) + stub = await startStubServer((cap) => + jsonResponder( + 200, + { + subject_type: 'account', + account: { id: 'acct-1', email: 'a@e.com', name: 'A' }, + }, + cap, + ), + ) const res = await makeClient(stub.url).get() @@ -32,10 +37,10 @@ describe('AccountClient.get', () => { }) it('maps 401 to a classified BaseError', async () => { - stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap)) + stub = await startStubServer((cap) => jsonResponder(401, { error: 'expired' }, cap)) await expect(makeClient(stub.url).get()).rejects.toSatisfy( - err => isHttpClientError(err) && err.httpStatus === 401, + (err) => isHttpClientError(err) && err.httpStatus === 401, ) }) }) diff --git a/cli/src/api/app-dsl.test.ts b/cli/src/api/app-dsl.test.ts index 1afff1fcbce27e..624b4988857b19 100644 --- a/cli/src/api/app-dsl.test.ts +++ b/cli/src/api/app-dsl.test.ts @@ -21,7 +21,7 @@ describe('AppDslClient.exportDsl', () => { }) it('returns the data string from the response', async () => { - stub = await startStubServer(cap => jsonResponder(200, { data: DSL_YAML }, cap)) + stub = await startStubServer((cap) => jsonResponder(200, { data: DSL_YAML }, cap)) const yaml = await makeClient(stub.url).exportDsl('app-1') @@ -31,16 +31,18 @@ describe('AppDslClient.exportDsl', () => { }) it('throws when response has no data field', async () => { - stub = await startStubServer(cap => jsonResponder(200, { wrong: 1 }, cap)) + stub = await startStubServer((cap) => jsonResponder(200, { wrong: 1 }, cap)) - await expect(makeClient(stub.url).exportDsl('app-1')).rejects.toThrow('export response missing data field') + await expect(makeClient(stub.url).exportDsl('app-1')).rejects.toThrow( + 'export response missing data field', + ) }) it('propagates 404 as a classified HttpClientError', async () => { - stub = await startStubServer(cap => jsonResponder(404, { error: 'not_found' }, cap)) + stub = await startStubServer((cap) => jsonResponder(404, { error: 'not_found' }, cap)) await expect(makeClient(stub.url).exportDsl('missing')).rejects.toSatisfy( - err => isHttpClientError(err) && err.httpStatus === 404, + (err) => isHttpClientError(err) && err.httpStatus === 404, ) }) }) @@ -53,7 +55,7 @@ describe('AppDslClient.importApp', () => { }) it('POST to /workspaces/:id/apps/imports with body and returns Import', async () => { - stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap)) + stub = await startStubServer((cap) => jsonResponder(200, COMPLETED_IMPORT, cap)) const result = await makeClient(stub.url).importApp('ws-1', { mode: 'yaml-content', @@ -67,10 +69,18 @@ describe('AppDslClient.importApp', () => { }) it('returns pending import on 202', async () => { - const pending = { id: 'imp-1', status: 'pending', current_dsl_version: '0.1.4', imported_dsl_version: '0.0.9' } - stub = await startStubServer(cap => jsonResponder(202, pending, cap)) + const pending = { + id: 'imp-1', + status: 'pending', + current_dsl_version: '0.1.4', + imported_dsl_version: '0.0.9', + } + stub = await startStubServer((cap) => jsonResponder(202, pending, cap)) - const result = await makeClient(stub.url).importApp('ws-1', { mode: 'yaml-content', yaml_content: DSL_YAML }) + const result = await makeClient(stub.url).importApp('ws-1', { + mode: 'yaml-content', + yaml_content: DSL_YAML, + }) expect(result.status).toBe('pending') expect(result.id).toBe('imp-1') @@ -85,7 +95,7 @@ describe('AppDslClient.confirmImport', () => { }) it('POST to confirm URL and returns completed Import', async () => { - stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap)) + stub = await startStubServer((cap) => jsonResponder(200, COMPLETED_IMPORT, cap)) const result = await makeClient(stub.url).confirmImport('ws-1', 'imp-1') @@ -103,7 +113,7 @@ describe('AppDslClient.checkDependencies', () => { }) it('returns empty leaked_dependencies on healthy app', async () => { - stub = await startStubServer(cap => jsonResponder(200, { leaked_dependencies: [] }, cap)) + stub = await startStubServer((cap) => jsonResponder(200, { leaked_dependencies: [] }, cap)) const result = await makeClient(stub.url).checkDependencies('app-1') diff --git a/cli/src/api/app-dsl.ts b/cli/src/api/app-dsl.ts index 19c26f5d7fb736..ccbcbc613e606b 100644 --- a/cli/src/api/app-dsl.ts +++ b/cli/src/api/app-dsl.ts @@ -35,19 +35,19 @@ export class AppDslClient { async exportDsl(appId: string, query?: ExportQuery): Promise { const resp = await this.orpc.apps.byAppId.dsl.get({ params: { app_id: appId }, - query: query !== undefined - ? { - include_secret: query.includeSecret, - workflow_id: query.workflowId, - } - : undefined, + query: + query !== undefined + ? { + include_secret: query.includeSecret, + workflow_id: query.workflowId, + } + : undefined, }) // The response schema is an open object {"data": ""}; the // contract generator marks it as loose because the backend annotation // does not narrow the shape. Extract `data` directly. const data = (resp as Record).data - if (typeof data !== 'string') - throw new Error('export response missing data field') + if (typeof data !== 'string') throw new Error('export response missing data field') return data } diff --git a/cli/src/api/app-meta.test.ts b/cli/src/api/app-meta.test.ts index 254cd5778ae68a..073ec169259453 100644 --- a/cli/src/api/app-meta.test.ts +++ b/cli/src/api/app-meta.test.ts @@ -24,10 +24,8 @@ describe('AppMetaClient', () => { process.env[ENV_CACHE_DIR] = dir }) afterEach(async () => { - if (prevCacheDir === undefined) - delete process.env[ENV_CACHE_DIR] - else - process.env[ENV_CACHE_DIR] = prevCacheDir + if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR] + else process.env[ENV_CACHE_DIR] = prevCacheDir await mock.stop() await rm(dir, { recursive: true, force: true }) }) @@ -62,15 +60,29 @@ describe('AppMetaClient', () => { }) it('expired cache entry refetches', async () => { - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') }) + const cache = await loadAppInfoCache({ + store: getCache(CACHE_APP_INFO), + ttlMs: 100, + now: () => new Date('2026-05-09T00:00:00Z'), + }) const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test')) const spy = vi.spyOn(apps, 'describe') - const client = new AppMetaClient({ apps, host: mock.url, cache, now: () => new Date('2026-05-09T00:00:00Z') }) + const client = new AppMetaClient({ + apps, + host: mock.url, + cache, + now: () => new Date('2026-05-09T00:00:00Z'), + }) await client.get('app-1', [FieldInfo]) expect(spy).toHaveBeenCalledTimes(1) - const client2 = new AppMetaClient({ apps, host: mock.url, cache, now: () => new Date('2026-05-09T00:00:01Z') }) + const client2 = new AppMetaClient({ + apps, + host: mock.url, + cache, + now: () => new Date('2026-05-09T00:00:01Z'), + }) await client2.get('app-1', [FieldInfo]) expect(spy).toHaveBeenCalledTimes(2) }) @@ -113,10 +125,12 @@ describe('AppMetaClient', () => { const validEntry = file.entries[`${mock.url}::app-1`] await writeFile( path, - dump({ entries: { - [`${mock.url}::app-1`]: 'corrupted-string', - [`${mock.url}::sibling`]: validEntry, - } }), + dump({ + entries: { + [`${mock.url}::app-1`]: 'corrupted-string', + [`${mock.url}::sibling`]: validEntry, + }, + }), 'utf8', ) diff --git a/cli/src/api/app-meta.ts b/cli/src/api/app-meta.ts index 03ae59bd392030..73472ca3af7884 100644 --- a/cli/src/api/app-meta.ts +++ b/cli/src/api/app-meta.ts @@ -25,21 +25,24 @@ export class AppMetaClient { async get(appId: string, fields: readonly AppMetaFieldKey[] = []): Promise { const cached = this.cache?.get(this.host, appId) - if (cached !== undefined && this.cache?.isFresh(cached, this.now()) === true && covers(cached.meta, fields)) + if ( + cached !== undefined && + this.cache?.isFresh(cached, this.now()) === true && + covers(cached.meta, fields) + ) return cached.meta const resp = await this.apps.describe(appId, fields.length === 0 ? undefined : fields) const fresh = fromDescribe(resp, fields) - const merged = cached !== undefined && this.cache?.isFresh(cached, this.now()) === true - ? mergeMeta(cached.meta, fresh) - : fresh - if (this.cache !== undefined) - await this.cache.set(this.host, appId, merged) + const merged = + cached !== undefined && this.cache?.isFresh(cached, this.now()) === true + ? mergeMeta(cached.meta, fresh) + : fresh + if (this.cache !== undefined) await this.cache.set(this.host, appId, merged) return merged } async invalidate(appId: string): Promise { - if (this.cache !== undefined) - await this.cache.delete(this.host, appId) + if (this.cache !== undefined) await this.cache.delete(this.host, appId) } } diff --git a/cli/src/api/app-reader.ts b/cli/src/api/app-reader.ts index fe41e35bfe9723..713ec64bbd1b81 100644 --- a/cli/src/api/app-reader.ts +++ b/cli/src/api/app-reader.ts @@ -26,8 +26,8 @@ type AppReaderFactory = (http: HttpClient) => AppReader // Maps each auth subject to the app reader for its surface. const APP_READER_BY_SUBJECT: Readonly> = { - [SubjectKind.Account]: http => new AppsClient(http), - [SubjectKind.External]: http => new PermittedExternalAppsClient(http), + [SubjectKind.Account]: (http) => new AppsClient(http), + [SubjectKind.External]: (http) => new PermittedExternalAppsClient(http), } export function selectAppReader(active: ActiveContext, http: HttpClient): AppReader { diff --git a/cli/src/api/app-run.test.ts b/cli/src/api/app-run.test.ts index 4d502224c1b406..53b7d149cd4092 100644 --- a/cli/src/api/app-run.test.ts +++ b/cli/src/api/app-run.test.ts @@ -75,21 +75,24 @@ describe('AppRunClient.runStream', () => { it('throws typed BaseError on non-2xx open', async () => { mock.setScenario('server-5xx') const c = new AppRunClient(testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 })) - await expect( - c.runStream('app-1', buildRunBody({ message: 'hi' })), - ).rejects.toMatchObject({ code: 'server_5xx' }) + await expect(c.runStream('app-1', buildRunBody({ message: 'hi' }))).rejects.toMatchObject({ + code: 'server_5xx', + }) }) it('aborts when signal fires', async () => { expect.assertions(1) const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test')) const ctrl = new AbortController() - const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }), { signal: ctrl.signal }) + const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }), { + signal: ctrl.signal, + }) ctrl.abort() try { - for await (const _ of iter) { /* drain */ } - } - catch (e) { + for await (const _ of iter) { + /* drain */ + } + } catch (e) { expect((e as Error).name).toBe('AbortError') } }) @@ -98,9 +101,13 @@ describe('AppRunClient.runStream', () => { const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test')) const iter = await c.runStream('app-2', buildRunBody({ inputs: { x: '1' } })) const names: string[] = [] - for await (const ev of iter) - names.push(ev.name) - expect(names).toEqual(['workflow_started', 'node_started', 'node_finished', 'workflow_finished']) + for await (const ev of iter) names.push(ev.name) + expect(names).toEqual([ + 'workflow_started', + 'node_started', + 'node_finished', + 'workflow_finished', + ]) }) }) diff --git a/cli/src/api/app-run.ts b/cli/src/api/app-run.ts index a75b3f0c63a1ea..0cbc607b3fc606 100644 --- a/cli/src/api/app-run.ts +++ b/cli/src/api/app-run.ts @@ -18,16 +18,13 @@ export function buildRunBody(args: RunBodyArgs): Record { const body: Record = { inputs: args.inputs ?? {}, } - if (args.message !== undefined && args.message !== '') - body.query = args.message + if (args.message !== undefined && args.message !== '') body.query = args.message if (args.conversationId !== undefined && args.conversationId !== '') body.conversation_id = args.conversationId if (args.workspaceId !== undefined && args.workspaceId !== '') body.workspace_id = args.workspaceId - if (args.workflowId !== undefined && args.workflowId !== '') - body.workflow_id = args.workflowId - if (args.files !== undefined && args.files.length > 0) - body.files = args.files + if (args.workflowId !== undefined && args.workflowId !== '') body.workflow_id = args.workflowId + if (args.files !== undefined && args.files.length > 0) body.files = args.files return body } @@ -62,8 +59,7 @@ export class AppRunClient { throwOnError: true, retryOnRateLimit: opts.retryOnRateLimit, }) - if (res.body === null) - throw new Error('streaming response body missing') + if (res.body === null) throw new Error('streaming response body missing') return normalizeDifyStream(parseSSE(res.body, opts.signal)) } @@ -100,8 +96,7 @@ export class AppRunClient { signal: opts.signal, throwOnError: true, }) - if (res.body === null) - throw new Error('reconnect stream body missing') + if (res.body === null) throw new Error('reconnect stream body missing') return normalizeDifyStream(parseSSE(res.body, opts.signal)) } } diff --git a/cli/src/api/apps.test.ts b/cli/src/api/apps.test.ts index ba9126b7b986ed..b5ac0fb8e11720 100644 --- a/cli/src/api/apps.test.ts +++ b/cli/src/api/apps.test.ts @@ -6,7 +6,9 @@ import { isHttpClientError } from '@/errors/base' import { AppsClient } from './apps.js' const LIST_BODY = { page: 1, limit: 20, total: 0, has_more: false, data: [] } -const DESCRIBE_BODY = { info: { id: 'app-1', name: 'Demo', mode: 'chat', service_api_enabled: true } } +const DESCRIBE_BODY = { + info: { id: 'app-1', name: 'Demo', mode: 'chat', service_api_enabled: true }, +} function makeClient(host: string): AppsClient { return new AppsClient(testHttpClient(host, 'dfoa_test')) @@ -24,7 +26,7 @@ describe('AppsClient.list', () => { }) it('defaults page=1 & limit=20 and always sends workspace_id', async () => { - stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap)) + stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap)) await makeClient(stub.url).list({ workspaceId: 'ws-1' }) @@ -39,7 +41,7 @@ describe('AppsClient.list', () => { }) it('forwards explicit pagination and filters', async () => { - stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap)) + stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap)) await makeClient(stub.url).list({ workspaceId: 'ws-1', @@ -57,7 +59,7 @@ describe('AppsClient.list', () => { }) it('treats empty-string filters as absent (not blank query params)', async () => { - stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap)) + stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap)) await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '' }) @@ -67,10 +69,10 @@ describe('AppsClient.list', () => { }) it('propagates server 403 as a classified BaseError', async () => { - stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap)) + stub = await startStubServer((cap) => jsonResponder(403, { error: 'forbidden' }, cap)) await expect(makeClient(stub.url).list({ workspaceId: 'ws-1' })).rejects.toSatisfy( - err => isHttpClientError(err) && err.httpStatus === 403, + (err) => isHttpClientError(err) && err.httpStatus === 403, ) }) }) @@ -83,7 +85,7 @@ describe('AppsClient.describe', () => { }) it('hits /apps/, omits workspace_id and fields when not given', async () => { - stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap)) + stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap)) const res = await makeClient(stub.url).describe('app-1') @@ -95,7 +97,7 @@ describe('AppsClient.describe', () => { }) it('joins fields with commas', async () => { - stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap)) + stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap)) await makeClient(stub.url).describe('app-1', ['parameters', 'input_schema']) @@ -103,7 +105,7 @@ describe('AppsClient.describe', () => { }) it('URL-encodes the app id', async () => { - stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap)) + stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap)) await makeClient(stub.url).describe('app/with space') diff --git a/cli/src/api/apps.ts b/cli/src/api/apps.ts index fa29d66fd14fb9..ec8471993fe5c2 100644 --- a/cli/src/api/apps.ts +++ b/cli/src/api/apps.ts @@ -1,4 +1,8 @@ -import type { AppDescribeResponse, AppListResponse, SupportedAppType } from '@dify/contracts/api/openapi/types.gen' +import type { + AppDescribeResponse, + AppListResponse, + SupportedAppType, +} from '@dify/contracts/api/openapi/types.gen' import type { AppReader } from './app-reader' import type { OpenApiClient } from '@/http/orpc' import type { HttpClient } from '@/http/types' @@ -13,7 +17,9 @@ export type ListQuery = { } // An absent or empty mode filter means "any mode" — collapse both to undefined for the query. -export function normalizeMode(mode: SupportedAppType | '' | undefined): SupportedAppType | undefined { +export function normalizeMode( + mode: SupportedAppType | '' | undefined, +): SupportedAppType | undefined { return mode !== undefined && mode !== '' ? mode : undefined } diff --git a/cli/src/api/device-flow.test.ts b/cli/src/api/device-flow.test.ts index e0118f4ae1865d..0ecf8f3505c55a 100644 --- a/cli/src/api/device-flow.test.ts +++ b/cli/src/api/device-flow.test.ts @@ -15,24 +15,33 @@ type StubServer = { stop: () => Promise } -function startStub(handler: (req: http.IncomingMessage, res: http.ServerResponse) => void): Promise { +function startStub( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, +): Promise { return new Promise((resolve, reject) => { const server = http.createServer(handler) server.listen(0, '127.0.0.1', () => { const addr = server.address() as AddressInfo resolve({ url: `http://127.0.0.1:${addr.port}`, - stop: () => new Promise((res, rej) => server.close(err => err ? rej(err) : res())), + stop: () => + new Promise((res, rej) => server.close((err) => (err ? rej(err) : res()))), }) }) server.on('error', reject) }) } -function jsonStub(status: number, body: unknown): (req: http.IncomingMessage, res: http.ServerResponse) => void { +function jsonStub( + status: number, + body: unknown, +): (req: http.IncomingMessage, res: http.ServerResponse) => void { return (_req, res) => { const payload = JSON.stringify(body) - res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }) + res.writeHead(status, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + }) res.end(payload) } } @@ -74,15 +83,12 @@ describe('DeviceFlowApi.requestCode', () => { let caught: unknown try { await api.requestCode({ device_label: 'l' }) - } - catch (e) { + } catch (e) { caught = e } expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.code).toBe(ErrorCode.UnsupportedEndpoint) - } - finally { + if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.UnsupportedEndpoint) + } finally { await stub?.stop() } }) @@ -108,8 +114,7 @@ describe('DeviceFlowApi.pollOnce', () => { const api = makeApi(mock) const r = await api.pollOnce({ device_code: 'devcode-1' }) expect(r.status).toBe('approved') - if (r.status === 'approved') - expect(r.success.token).toBe('dfoa_test') + if (r.status === 'approved') expect(r.success.token).toBe('dfoa_test') }) it('maps authorization_pending to pending', async () => { @@ -119,8 +124,7 @@ describe('DeviceFlowApi.pollOnce', () => { const api = new DeviceFlowApi(testHttpClient(stub.url)) const r = await api.pollOnce({ device_code: 'dc' }) expect(r.status).toBe('pending') - } - finally { + } finally { await stub?.stop() } }) @@ -152,8 +156,7 @@ describe('DeviceFlowApi.pollOnce', () => { stub = await startStub(jsonStub(404, {})) const api = new DeviceFlowApi(testHttpClient(stub.url)) await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/device flow/i) - } - finally { + } finally { await stub?.stop() } }) @@ -171,8 +174,7 @@ describe('DeviceFlowApi.pollOnce', () => { stub = await startStub(jsonStub(200, {})) const api = new DeviceFlowApi(testHttpClient(stub.url)) await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/no OAuth envelope|token/i) - } - finally { + } finally { await stub?.stop() } }) @@ -183,8 +185,7 @@ describe('DeviceFlowApi.pollOnce', () => { stub = await startStub(jsonStub(400, { error: 'something_else' })) const api = new DeviceFlowApi(testHttpClient(stub.url)) await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/unknown poll error/) - } - finally { + } finally { await stub?.stop() } }) diff --git a/cli/src/api/file-upload.test.ts b/cli/src/api/file-upload.test.ts index 602cf351e60209..0a24b7fb163849 100644 --- a/cli/src/api/file-upload.test.ts +++ b/cli/src/api/file-upload.test.ts @@ -36,7 +36,7 @@ describe('FileUploadClient.upload', () => { it('POSTs multipart/form-data (boundary intact, no JSON content-type) and returns the parsed file', async () => { const filePath = join(dir, 'hello.png') await writeFile(filePath, 'hello') - stub = await startStubServer(cap => jsonResponder(200, UPLOADED, cap)) + stub = await startStubServer((cap) => jsonResponder(200, UPLOADED, cap)) const result = await makeClient(stub.url).upload('app-1', filePath) @@ -57,7 +57,7 @@ describe('FileUploadClient.upload', () => { it('encodes the app id in the path', async () => { const filePath = join(dir, 'a.txt') await writeFile(filePath, 'x') - stub = await startStubServer(cap => jsonResponder(200, UPLOADED, cap)) + stub = await startStubServer((cap) => jsonResponder(200, UPLOADED, cap)) await makeClient(stub.url).upload('app/with space', filePath) @@ -67,10 +67,10 @@ describe('FileUploadClient.upload', () => { it('propagates a server 413 as a classified BaseError', async () => { const filePath = join(dir, 'big.bin') await writeFile(filePath, 'data') - stub = await startStubServer(cap => jsonResponder(413, { error: 'file too large' }, cap)) + stub = await startStubServer((cap) => jsonResponder(413, { error: 'file too large' }, cap)) await expect(makeClient(stub.url).upload('app-1', filePath)).rejects.toSatisfy( - err => isHttpClientError(err) && err.httpStatus === 413, + (err) => isHttpClientError(err) && err.httpStatus === 413, ) }) }) diff --git a/cli/src/api/file-upload.ts b/cli/src/api/file-upload.ts index 011f898c74e78b..9989de7f84a694 100644 --- a/cli/src/api/file-upload.ts +++ b/cli/src/api/file-upload.ts @@ -64,9 +64,9 @@ export class FileUploadClient { const form = new FormData() form.append('file', blob, filename) - return this.http.post( - `apps/${encodeURIComponent(appId)}/files`, - { body: form, timeoutMs: 60_000 }, - ) + return this.http.post(`apps/${encodeURIComponent(appId)}/files`, { + body: form, + timeoutMs: 60_000, + }) } } diff --git a/cli/src/api/members.test.ts b/cli/src/api/members.test.ts index a8fdc633f77230..99c7316f322f47 100644 --- a/cli/src/api/members.test.ts +++ b/cli/src/api/members.test.ts @@ -18,7 +18,7 @@ describe('MembersClient.list', () => { }) it('GETs /workspaces//members and returns parsed envelope', async () => { - stub = await startStubServer(cap => + stub = await startStubServer((cap) => jsonResponder( 200, { @@ -26,12 +26,11 @@ describe('MembersClient.list', () => { limit: 20, total: 1, has_more: false, - data: [ - { id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' }, - ], + data: [{ id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' }], }, cap, - )) + ), + ) const result = await makeClient(stub.url).list('ws-1') @@ -41,8 +40,9 @@ describe('MembersClient.list', () => { }) it('URL-encodes workspace id', async () => { - stub = await startStubServer(cap => - jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, cap)) + stub = await startStubServer((cap) => + jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, cap), + ) await makeClient(stub.url).list('ws with space') @@ -50,8 +50,9 @@ describe('MembersClient.list', () => { }) it('forwards page/limit as query params', async () => { - stub = await startStubServer(cap => - jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, cap)) + stub = await startStubServer((cap) => + jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, cap), + ) await makeClient(stub.url).list('ws-1', { page: 2, limit: 50 }) @@ -59,18 +60,18 @@ describe('MembersClient.list', () => { }) it('propagates server 403 as classified BaseError', async () => { - stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap)) + stub = await startStubServer((cap) => jsonResponder(403, { error: 'forbidden' }, cap)) await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy( - err => isHttpClientError(err) && err.httpStatus === 403, + (err) => isHttpClientError(err) && err.httpStatus === 403, ) }) it('propagates 404 as classified BaseError', async () => { - stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap)) + stub = await startStubServer((cap) => jsonResponder(404, { error: 'not found' }, cap)) await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy( - err => isHttpClientError(err) && err.httpStatus === 404, + (err) => isHttpClientError(err) && err.httpStatus === 404, ) }) }) @@ -83,7 +84,7 @@ describe('MembersClient.invite', () => { }) it('POSTs JSON body and returns parsed invite response', async () => { - stub = await startStubServer(cap => + stub = await startStubServer((cap) => jsonResponder( 201, { @@ -95,7 +96,8 @@ describe('MembersClient.invite', () => { tenant_id: 'ws-1', }, cap, - )) + ), + ) const result = await makeClient(stub.url).invite('ws-1', { email: 'new@e.com', @@ -113,11 +115,11 @@ describe('MembersClient.invite', () => { }) it('propagates 400 (already in tenant) as classified BaseError', async () => { - stub = await startStubServer(cap => jsonResponder(400, { error: 'already in tenant' }, cap)) + stub = await startStubServer((cap) => jsonResponder(400, { error: 'already in tenant' }, cap)) await expect( makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }), - ).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400) + ).rejects.toSatisfy((err) => isHttpClientError(err) && err.httpStatus === 400) }) }) @@ -129,7 +131,7 @@ describe('MembersClient.remove', () => { }) it('DELETEs member by id and returns success', async () => { - stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap)) + stub = await startStubServer((cap) => jsonResponder(200, { result: 'success' }, cap)) const result = await makeClient(stub.url).remove('ws-1', 'm-1') @@ -139,10 +141,10 @@ describe('MembersClient.remove', () => { }) it('propagates 400 (cannot operate self / cannot remove owner)', async () => { - stub = await startStubServer(cap => jsonResponder(400, { error: 'cannot operate self' }, cap)) + stub = await startStubServer((cap) => jsonResponder(400, { error: 'cannot operate self' }, cap)) await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy( - err => isHttpClientError(err) && err.httpStatus === 400, + (err) => isHttpClientError(err) && err.httpStatus === 400, ) }) }) @@ -155,7 +157,7 @@ describe('MembersClient.updateRole', () => { }) it('PATCHes role payload to the member resource', async () => { - stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap)) + stub = await startStubServer((cap) => jsonResponder(200, { result: 'success' }, cap)) const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }) @@ -166,11 +168,11 @@ describe('MembersClient.updateRole', () => { }) it('propagates 400 (admin cannot demote owner)', async () => { - stub = await startStubServer(cap => jsonResponder(400, { error: 'no permission' }, cap)) + stub = await startStubServer((cap) => jsonResponder(400, { error: 'no permission' }, cap)) await expect( makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }), - ).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400) + ).rejects.toSatisfy((err) => isHttpClientError(err) && err.httpStatus === 400) }) }) @@ -182,7 +184,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => { }) it('POSTs /workspaces/:switch and returns workspace detail', async () => { - stub = await startStubServer(cap => + stub = await startStubServer((cap) => jsonResponder( 200, { @@ -194,7 +196,8 @@ describe('WorkspacesClient.switch (integration with stub)', () => { created_at: '2026-05-18T00:00:00Z', }, cap, - )) + ), + ) const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test')) const result = await client.switch('ws-1') @@ -205,11 +208,11 @@ describe('WorkspacesClient.switch (integration with stub)', () => { }) it('propagates 404 (non-member)', async () => { - stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap)) + stub = await startStubServer((cap) => jsonResponder(404, { error: 'not found' }, cap)) const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test')) await expect(client.switch('ws-x')).rejects.toSatisfy( - err => isHttpClientError(err) && err.httpStatus === 404, + (err) => isHttpClientError(err) && err.httpStatus === 404, ) }) }) diff --git a/cli/src/api/members.ts b/cli/src/api/members.ts index 8a9bc13081fc2c..930972e55f1301 100644 --- a/cli/src/api/members.ts +++ b/cli/src/api/members.ts @@ -22,7 +22,10 @@ export class MembersClient { this.orpc = createOpenApiClient(http) } - async list(workspaceId: string, q?: { page?: number, limit?: number }): Promise { + async list( + workspaceId: string, + q?: { page?: number; limit?: number }, + ): Promise { return this.orpc.workspaces.byWorkspaceId.members.get({ params: { workspace_id: workspaceId }, query: { page: q?.page, limit: q?.limit }, diff --git a/cli/src/api/oauth-device.ts b/cli/src/api/oauth-device.ts index 693a77d952f155..955bb3d01697fc 100644 --- a/cli/src/api/oauth-device.ts +++ b/cli/src/api/oauth-device.ts @@ -46,13 +46,13 @@ export type PollSuccess = { token_id?: string } -export type PollResult - = | { status: 'pending' } - | { status: 'slow_down' } - | { status: 'expired' } - | { status: 'denied' } - | { status: 'retry_5xx' } - | { status: 'approved', success: PollSuccess } +export type PollResult = + | { status: 'pending' } + | { status: 'slow_down' } + | { status: 'expired' } + | { status: 'denied' } + | { status: 'retry_5xx' } + | { status: 'approved'; success: PollSuccess } const POLL_ERROR_TO_STATUS: Record = { authorization_pending: 'pending', @@ -77,8 +77,7 @@ export class DeviceFlowApi { } const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_label: req.device_label } const res = await this.http.fetch('oauth/device/code', { method: 'POST', json: body }) - if (res.status === 404) - throw versionSkew() + if (res.status === 404) throw versionSkew() if (!res.ok) { throw new HttpClientError({ code: ErrorCode.Server4xxOther, @@ -86,7 +85,7 @@ export class DeviceFlowApi { httpStatus: res.status, }) } - return await res.json() as CodeResponse + return (await res.json()) as CodeResponse } async pollOnce(req: PollRequest): Promise { @@ -98,16 +97,13 @@ export class DeviceFlowApi { } const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_code: req.device_code } const res = await this.http.fetch('oauth/device/token', { method: 'POST', json: body }) - if (res.status === 404) - throw versionSkew() - if (res.status >= 500) - return { status: 'retry_5xx' } + if (res.status === 404) throw versionSkew() + if (res.status >= 500) return { status: 'retry_5xx' } let payload: { error?: string } & Partial = {} try { const text = await res.text() - payload = text === '' ? {} : JSON.parse(text) as typeof payload - } - catch (err) { + payload = text === '' ? {} : (JSON.parse(text) as typeof payload) + } catch (err) { throw new BaseError({ code: ErrorCode.Unknown, message: `decode poll response: ${(err as Error).message}`, diff --git a/cli/src/api/permitted-external-apps.test.ts b/cli/src/api/permitted-external-apps.test.ts index 58f47b4566ff78..c6cc06b4559969 100644 --- a/cli/src/api/permitted-external-apps.test.ts +++ b/cli/src/api/permitted-external-apps.test.ts @@ -11,7 +11,9 @@ type WithOrpc = { orpc: unknown } describe('PermittedExternalAppsClient', () => { it('list calls permittedExternalApps.get with paging/filter query', async () => { const c = new PermittedExternalAppsClient(fakeHttp()) - const get = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] }) + const get = vi + .fn() + .mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] }) ;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { get: vi.fn() } } } await c.list({ workspaceId: '', page: 2, limit: 5, mode: undefined, name: 'a' }) expect(get).toHaveBeenCalledWith({ query: { page: 2, limit: 5, mode: undefined, name: 'a' } }) @@ -20,7 +22,9 @@ describe('PermittedExternalAppsClient', () => { it('describe calls permittedExternalApps.byAppId.get with app_id + fields', async () => { const c = new PermittedExternalAppsClient(fakeHttp()) const dget = vi.fn().mockResolvedValue({ info: null, parameters: null, input_schema: null }) - ;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get: vi.fn(), byAppId: { get: dget } } } + ;(c as unknown as WithOrpc).orpc = { + permittedExternalApps: { get: vi.fn(), byAppId: { get: dget } }, + } await c.describe('app-1', ['info']) expect(dget).toHaveBeenCalledWith({ params: { app_id: 'app-1' }, query: { fields: 'info' } }) }) diff --git a/cli/src/api/workspaces.test.ts b/cli/src/api/workspaces.test.ts index b9b5fb63474ce4..16f7453c378539 100644 --- a/cli/src/api/workspaces.test.ts +++ b/cli/src/api/workspaces.test.ts @@ -22,12 +22,17 @@ describe('WorkspacesClient.list', () => { }) it('GETs /workspaces and returns the parsed list', async () => { - stub = await startStubServer(cap => - jsonResponder(200, { - workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, - ], - }, cap)) + stub = await startStubServer((cap) => + jsonResponder( + 200, + { + workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, + ], + }, + cap, + ), + ) const res = await makeClient(stub.url).list() @@ -37,10 +42,10 @@ describe('WorkspacesClient.list', () => { }) it('maps 401 to a classified BaseError', async () => { - stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap)) + stub = await startStubServer((cap) => jsonResponder(401, { error: 'expired' }, cap)) await expect(makeClient(stub.url).list()).rejects.toSatisfy( - err => isHttpClientError(err) && err.httpStatus === 401, + (err) => isHttpClientError(err) && err.httpStatus === 401, ) }) }) diff --git a/cli/src/api/workspaces.ts b/cli/src/api/workspaces.ts index 08495a01fce084..3aad3276f4a111 100644 --- a/cli/src/api/workspaces.ts +++ b/cli/src/api/workspaces.ts @@ -1,4 +1,7 @@ -import type { WorkspaceDetailResponse, WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen' +import type { + WorkspaceDetailResponse, + WorkspaceListResponse, +} from '@dify/contracts/api/openapi/types.gen' import type { OpenApiClient } from '@/http/orpc' import type { HttpClient } from '@/http/types' import { createOpenApiClient } from '@/http/orpc' diff --git a/cli/src/auth/hosts.test.ts b/cli/src/auth/hosts.test.ts index 337b785a4c49e4..11f63a3814aa2d 100644 --- a/cli/src/auth/hosts.test.ts +++ b/cli/src/auth/hosts.test.ts @@ -71,13 +71,15 @@ describe('notLoggedInError', () => { expect(notLoggedInError().toString()).toMatch(/auth login/) }) it('accepts a custom hint', () => { - expect(notLoggedInError('run \'difyctl use host\'').toString()).toMatch(/use host/) + expect(notLoggedInError("run 'difyctl use host'").toString()).toMatch(/use host/) }) }) describe('Registry (pure)', () => { const baseReg = (): Registry => Registry.empty('file') - const ctx = (email: string): AccountContext => ({ account: { id: `id-${email}`, email, name: email } }) + const ctx = (email: string): AccountContext => ({ + account: { id: `id-${email}`, email, name: email }, + }) it('upsert creates host + account; remove drops them', () => { const reg = baseReg() diff --git a/cli/src/auth/hosts.ts b/cli/src/auth/hosts.ts index 5df8cdabdbec2d..7796e05e0e457d 100644 --- a/cli/src/auth/hosts.ts +++ b/cli/src/auth/hosts.ts @@ -62,7 +62,7 @@ export type ActiveContext = { readonly insecureTls?: boolean } -export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError { +export function notLoggedInError(hint = "run 'difyctl auth login'"): BaseError { return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint }) } @@ -75,8 +75,7 @@ export class Registry { static async load(): Promise { const raw = await getHostStore().getTyped>() - if (raw === null) - return Registry.empty() + if (raw === null) return Registry.empty() return new Registry(RegistrySchema.parse(raw)) } @@ -88,31 +87,34 @@ export class Registry { return new Registry(data) } - get hosts(): RegistryData['hosts'] { return this.data.hosts } - get current_host(): string | undefined { return this.data.current_host } - get token_storage(): StorageMode { return this.data.token_storage } - set token_storage(mode: StorageMode) { this.data.token_storage = mode } + get hosts(): RegistryData['hosts'] { + return this.data.hosts + } + get current_host(): string | undefined { + return this.data.current_host + } + get token_storage(): StorageMode { + return this.data.token_storage + } + set token_storage(mode: StorageMode) { + this.data.token_storage = mode + } resolveActive(): ActiveContext | undefined { const host = this.data.current_host - if (host === undefined || host === '') - return undefined + if (host === undefined || host === '') return undefined const entry = this.data.hosts[host] - if (entry === undefined) - return undefined + if (entry === undefined) return undefined const email = entry?.current_account - if (!email) - return undefined + if (!email) return undefined const ctx = entry.accounts[email] - if (ctx === undefined) - return undefined + if (ctx === undefined) return undefined return { host, email, ctx, scheme: entry.scheme, insecureTls: entry.insecure_tls } } requireActive(hint?: string): ActiveContext { const active = this.resolveActive() - if (active === undefined) - throw notLoggedInError(hint) + if (active === undefined) throw notLoggedInError(hint) return active } @@ -124,18 +126,14 @@ export class Registry { remove(host: string, email: string): void { const entry = this.data.hosts[host] - if (entry === undefined) - return + if (entry === undefined) return const wasActive = entry.current_account === email delete entry.accounts[email] - if (wasActive) - entry.current_account = undefined + if (wasActive) entry.current_account = undefined if (Object.keys(entry.accounts).length === 0) { delete this.data.hosts[host] - if (this.data.current_host === host) - this.data.current_host = undefined - } - else if (wasActive && this.data.current_host === host) { + if (this.data.current_host === host) this.data.current_host = undefined + } else if (wasActive && this.data.current_host === host) { this.data.current_host = undefined } } @@ -146,23 +144,19 @@ export class Registry { setAccount(email: string): void { const host = this.data.current_host - if (host === undefined) - return + if (host === undefined) return const entry = this.data.hosts[host] - if (entry !== undefined) - entry.current_account = email + if (entry !== undefined) entry.current_account = email } setScheme(host: string, scheme: string): void { const entry = this.data.hosts[host] - if (entry !== undefined) - entry.scheme = scheme + if (entry !== undefined) entry.scheme = scheme } setInsecureTls(host: string, insecure: boolean): void { const entry = this.data.hosts[host] - if (entry !== undefined) - entry.insecure_tls = insecure + if (entry !== undefined) entry.insecure_tls = insecure } activate(host: string, email: string, ctx: AccountContext): void { @@ -176,8 +170,9 @@ export class Registry { async forget(active: ActiveContext, store: TokenStore): Promise { try { await store.remove(active.host, active.email) + } catch { + /* best-effort */ } - catch { /* best-effort */ } this.remove(active.host, active.email) await this.save() } diff --git a/cli/src/cache/app-info.test.ts b/cli/src/cache/app-info.test.ts index 9e007c004a7d43..3b74044587f602 100644 --- a/cli/src/cache/app-info.test.ts +++ b/cli/src/cache/app-info.test.ts @@ -40,10 +40,8 @@ describe('app-info disk cache', () => { process.env[ENV_CACHE_DIR] = dir }) afterEach(async () => { - if (prevCacheDir === undefined) - delete process.env[ENV_CACHE_DIR] - else - process.env[ENV_CACHE_DIR] = prevCacheDir + if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR] + else process.env[ENV_CACHE_DIR] = prevCacheDir await rm(dir, { recursive: true, force: true }) }) @@ -91,8 +89,7 @@ describe('app-info disk cache', () => { await c.set('h', 'app-1', metaInfoOnly()) const { stat } = await import('node:fs/promises') const s = await stat(appInfoPath(dir)) - if (platform() !== 'win32') - expect(s.mode & 0o777).toBe(0o600) + if (platform() !== 'win32') expect(s.mode & 0o777).toBe(0o600) }) it('missing cache file is not an error', async () => { @@ -112,7 +109,9 @@ describe('app-info disk cache', () => { await seed.set('h', 'app-2', metaInfoOnly('app-2')) // Inject a corrupt sibling alongside the real one. - const file = load(await readFile(appInfoPath(dir), 'utf8')) as { entries: Record } + const file = load(await readFile(appInfoPath(dir), 'utf8')) as { + entries: Record + } file.entries['h::app-1'] = 'corrupted-string-not-object' await writeFile(appInfoPath(dir), dump(file), 'utf8') diff --git a/cli/src/cache/app-info.ts b/cli/src/cache/app-info.ts index 266a880efcebdd..2d0d033cb20675 100644 --- a/cli/src/cache/app-info.ts +++ b/cli/src/cache/app-info.ts @@ -46,7 +46,10 @@ export async function loadAppInfoCache(opts: AppInfoCacheOptions = {}): Promise< return { get: (host, appId) => state.entries.get(key(host, appId)), set: async (host, appId, meta) => { - const record: AppMetaCacheRecord = { meta, fetchedAt: (opts.now ?? (() => new Date()))().toISOString() } + const record: AppMetaCacheRecord = { + meta, + fetchedAt: (opts.now ?? (() => new Date()))().toISOString(), + } state.entries.set(key(host, appId), record) await writeEntries(store, state.entries) }, @@ -70,19 +73,16 @@ async function readEntries(store: Store): Promise try { raw = await store.get(ENTRIES_KEY) - } - catch { + } catch { return out } // A scalar/array survives Object.entries as garbage rather than throwing. - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) - return out + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return out for (const [k, e] of Object.entries(raw)) { try { out.set(k, deserialize(e)) - } - catch { + } catch { // Drop unreadable entry → becomes a cache miss → consumer refetches. } } @@ -103,10 +103,11 @@ function deserialize(e: DiskEntry): AppMetaCacheRecord { } function filterFields(input: unknown): AppMetaFieldKey[] { - if (!Array.isArray(input)) - return [] + if (!Array.isArray(input)) return [] const valid = new Set([FieldInfo, FieldParameters, FieldInputSchema]) - return input.filter((s): s is AppMetaFieldKey => typeof s === 'string' && valid.has(s as AppMetaFieldKey)) + return input.filter( + (s): s is AppMetaFieldKey => typeof s === 'string' && valid.has(s as AppMetaFieldKey), + ) } function serialize(record: AppMetaCacheRecord): DiskEntry { diff --git a/cli/src/cache/compat-store.test.ts b/cli/src/cache/compat-store.test.ts index 25ad11db06f78f..d6dfd3a9118dd6 100644 --- a/cli/src/cache/compat-store.test.ts +++ b/cli/src/cache/compat-store.test.ts @@ -19,14 +19,13 @@ describe('compat-store', () => { process.env[ENV_CACHE_DIR] = dir }) afterEach(async () => { - if (prev === undefined) - delete process.env[ENV_CACHE_DIR] - else - process.env[ENV_CACHE_DIR] = prev + if (prev === undefined) delete process.env[ENV_CACHE_DIR] + else process.env[ENV_CACHE_DIR] = prev await rm(dir, { recursive: true, force: true }) }) - const store = (now: Date = NOW) => loadCompatStore({ store: getCache(CACHE_COMPAT), now: () => now }) + const store = (now: Date = NOW) => + loadCompatStore({ store: getCache(CACHE_COMPAT), now: () => now }) it('is not fresh before anything is marked', async () => { expect((await store()).isFreshCompatible(HOST)).toBe(false) diff --git a/cli/src/cache/compat-store.ts b/cli/src/cache/compat-store.ts index 2df6dcf3377489..97d51db11d411b 100644 --- a/cli/src/cache/compat-store.ts +++ b/cli/src/cache/compat-store.ts @@ -29,8 +29,7 @@ export async function loadCompatStore(opts: CompatStoreOptions = {}): Promise { const last = memory.get(host) - if (last === undefined) - return false + if (last === undefined) return false const elapsed = Math.max(0, (now ?? clock()).getTime() - last) return elapsed < ttlMs }, @@ -51,21 +50,18 @@ async function readCompatible(store: Store): Promise> { let raw: Record try { raw = await store.get(COMPATIBLE_KEY) - } - catch { + } catch { return out } for (const [host, iso] of Object.entries(raw)) { const t = Date.parse(iso) - if (!Number.isNaN(t)) - out.set(host, t) + if (!Number.isNaN(t)) out.set(host, t) } return out } async function writeCompatible(store: Store, state: Map): Promise { const compatible: Record = {} - for (const [host, t] of state) - compatible[host] = new Date(t).toISOString() + for (const [host, t] of state) compatible[host] = new Date(t).toISOString() await store.set(COMPATIBLE_KEY, compatible) } diff --git a/cli/src/cache/nudge-store.test.ts b/cli/src/cache/nudge-store.test.ts index a13a8d8100cb57..8e038852c03314 100644 --- a/cli/src/cache/nudge-store.test.ts +++ b/cli/src/cache/nudge-store.test.ts @@ -22,10 +22,8 @@ describe('NudgeStore', () => { process.env[ENV_CACHE_DIR] = dir }) afterEach(async () => { - if (prevCacheDir === undefined) - delete process.env[ENV_CACHE_DIR] - else - process.env[ENV_CACHE_DIR] = prevCacheDir + if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR] + else process.env[ENV_CACHE_DIR] = prevCacheDir await rm(dir, { recursive: true, force: true }) }) diff --git a/cli/src/cache/nudge-store.ts b/cli/src/cache/nudge-store.ts index 3621b3b41ff3b3..344b6fb3b1013c 100644 --- a/cli/src/cache/nudge-store.ts +++ b/cli/src/cache/nudge-store.ts @@ -28,8 +28,7 @@ export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise { const last = memory.get(host) - if (last === undefined) - return true + if (last === undefined) return true const elapsed = Math.max(0, (now ?? clock()).getTime() - last) return elapsed >= intervalMs }, @@ -51,21 +50,18 @@ async function readWarned(store: Store): Promise> { let raw: Record try { raw = await store.get(WARNED_KEY) - } - catch { + } catch { return out } for (const [host, iso] of Object.entries(raw)) { const t = Date.parse(iso) - if (!Number.isNaN(t)) - out.set(host, t) + if (!Number.isNaN(t)) out.set(host, t) } return out } async function writeWarned(store: Store, state: Map): Promise { const warned: Record = {} - for (const [host, t] of state) - warned[host] = new Date(t).toISOString() + for (const [host, t] of state) warned[host] = new Date(t).toISOString() await store.set(WARNED_KEY, warned) } diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts index d40b9342041aef..ce25d08235935f 100644 --- a/cli/src/commands/_shared/authed-command.ts +++ b/cli/src/commands/_shared/authed-command.ts @@ -42,13 +42,11 @@ export async function buildAuthedContext( const io = realStreams(opts.format ?? '') const reg = await Registry.load() const active = reg.resolveActive() - if (active === undefined) - fail(cmd, opts, io) + if (active === undefined) fail(cmd, opts, io) const store = getTokenStore(reg.token_storage) const bearer = await store.read(active.host, active.email) - if (bearer === '') - fail(cmd, opts, io) + if (bearer === '') fail(cmd, opts, io) const { host, insecure } = activeHostInfo(active) const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv }) @@ -67,7 +65,9 @@ export async function buildAuthedContext( function fail(cmd: Pick, opts: AuthedContextOptions, io: IOStreams): never { const err = notLoggedInError() - cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() }) + cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { + exit: err.exit(), + }) } // Best-effort nudge: never throws, never blocks. Lives here so every authed @@ -82,17 +82,21 @@ 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, insecure: opts.insecure }) + 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), + emit: (line) => opts.io.err.write(line), isTty: opts.io.isOutTTY, format: opts.io.outputFormat, clientVersion: versionInfo.version, color: opts.io.isErrTTY, }) - } - catch { + } catch { // already swallowed inside maybeNudgeCompat; this is belt-and-braces } } diff --git a/cli/src/commands/_shared/global-flags.test.ts b/cli/src/commands/_shared/global-flags.test.ts index 914f2d2dff06f8..22e2dec77e3afb 100644 --- a/cli/src/commands/_shared/global-flags.test.ts +++ b/cli/src/commands/_shared/global-flags.test.ts @@ -22,8 +22,7 @@ describe('resolveRetryAttempts', () => { let caught: unknown try { resolveRetryAttempts({ flag: undefined, env: () => 'foo' }) - } - catch (e) { + } catch (e) { caught = e } expect((caught as { code: string }).code).toBe('usage_invalid_flag') @@ -34,8 +33,7 @@ describe('resolveRetryAttempts', () => { let caught: unknown try { resolveRetryAttempts({ flag: undefined, env: () => '-1' }) - } - catch (e) { + } catch (e) { caught = e } expect((caught as { code: string }).code).toBe('usage_invalid_flag') diff --git a/cli/src/commands/_shared/global-flags.ts b/cli/src/commands/_shared/global-flags.ts index e2b1d5b48663e9..d03bdc8256ddfc 100644 --- a/cli/src/commands/_shared/global-flags.ts +++ b/cli/src/commands/_shared/global-flags.ts @@ -5,7 +5,8 @@ import { Flags } from '@/framework/flags' export const HTTP_RETRY_DEFAULT = 3 export const httpRetryFlag = Flags.integer({ - description: 'HTTP retry attempts for GET/PUT/DELETE on transient errors. 0 disables. Overrides DIFYCTL_HTTP_RETRY.', + description: + 'HTTP retry attempts for GET/PUT/DELETE on transient errors. 0 disables. Overrides DIFYCTL_HTTP_RETRY.', helpGroup: 'GLOBAL', }) @@ -15,15 +16,15 @@ export type ResolveRetryAttemptsOpts = { } export function resolveRetryAttempts(opts: ResolveRetryAttemptsOpts): number { - if (opts.flag !== undefined) - return opts.flag + if (opts.flag !== undefined) return opts.flag const raw = opts.env('DIFYCTL_HTTP_RETRY') - if (raw === undefined || raw === '') - return HTTP_RETRY_DEFAULT + if (raw === undefined || raw === '') return HTTP_RETRY_DEFAULT if (!/^-?\d+$/.test(raw)) - throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${JSON.stringify(raw)} is not a non-negative integer`) + throw newError( + ErrorCode.UsageInvalidFlag, + `DIFYCTL_HTTP_RETRY: ${JSON.stringify(raw)} is not a non-negative integer`, + ) const n = Number(raw) - if (n < 0) - throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${n} is negative`) + if (n < 0) throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${n} is negative`) return n } diff --git a/cli/src/commands/auth/devices/_shared/devices.test.ts b/cli/src/commands/auth/devices/_shared/devices.test.ts index 0b64ac8b14ebb9..648d29b985c665 100644 --- a/cli/src/commands/auth/devices/_shared/devices.test.ts +++ b/cli/src/commands/auth/devices/_shared/devices.test.ts @@ -11,7 +11,11 @@ import { Registry } from '@/auth/hosts' import { bufferStreams } from '@/sys/io/streams' import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js' -function buildRegistry(host: string, email: string, tokenId: string): { reg: Registry, active: ActiveContext } { +function buildRegistry( + host: string, + email: string, + tokenId: string, +): { reg: Registry; active: ActiveContext } { const reg = Registry.empty('file') reg.upsert(host, email, { account: { id: 'acct-1', email, name: 'Test Tester' }, @@ -42,7 +46,7 @@ describe('runDevicesList', () => { expect(out).toContain('difyctl on laptop') expect(out).toContain('difyctl on desktop') const lines = out.trim().split('\n') - const laptopLine = lines.find(l => l.includes('difyctl on laptop'))! + const laptopLine = lines.find((l) => l.includes('difyctl on laptop'))! expect(laptopLine).toMatch(/\*\s*$/) }) @@ -75,7 +79,15 @@ describe('runDevicesRevoke', () => { await reg.save() const http = testHttpClient(mock.url, 'dfoa_test') - await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false }) + await runDevicesRevoke({ + io, + reg, + active, + store, + http, + target: 'difyctl on desktop', + all: false, + }) expect(io.outBuf()).toContain('Revoked 1 session(s)') expect(store.entries.size).toBe(1) }) @@ -106,9 +118,9 @@ describe('runDevicesRevoke', () => { const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') const http = testHttpClient(mock.url, 'dfoa_test') - await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false })) - .rejects - .toThrow(/matches multiple/) + await expect( + runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }), + ).rejects.toThrow(/matches multiple/) }) it('no match throws', async () => { @@ -117,9 +129,9 @@ describe('runDevicesRevoke', () => { const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') const http = testHttpClient(mock.url, 'dfoa_test') - await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false })) - .rejects - .toThrow(/no session matches/) + await expect( + runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }), + ).rejects.toThrow(/no session matches/) }) it('--all: revokes everything except current', async () => { @@ -153,9 +165,9 @@ describe('runDevicesRevoke', () => { const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') const http = testHttpClient(mock.url, 'dfoa_test') - await expect(runDevicesRevoke({ io, reg, active, store, http, all: true })) - .rejects - .toThrow(/aborted by user/) + await expect(runDevicesRevoke({ io, reg, active, store, http, all: true })).rejects.toThrow( + /aborted by user/, + ) expect(base.errBuf()).toContain('Revoke 2 session(s)? [y/N]') expect(base.outBuf()).not.toContain('Revoked') }) @@ -188,9 +200,9 @@ describe('runDevicesRevoke', () => { const store = new MemStore() const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') const http = testHttpClient(mock.url, 'dfoa_test') - await expect(runDevicesRevoke({ io, reg, active, store, http, all: false })) - .rejects - .toThrow(/specify a device label/) + await expect(runDevicesRevoke({ io, reg, active, store, http, all: false })).rejects.toThrow( + /specify a device label/, + ) }) }) @@ -205,12 +217,14 @@ describe('listAllSessions', () => { expires_at: null, }) - function stubClient(pages: readonly SessionListResponse[]): { client: AccountSessionsClient, list: ReturnType } { - const list = vi.fn(async (q?: { page?: number, limit?: number }) => { + function stubClient(pages: readonly SessionListResponse[]): { + client: AccountSessionsClient + list: ReturnType + } { + const list = vi.fn(async (q?: { page?: number; limit?: number }) => { const page = q?.page ?? 1 const env = pages[page - 1] - if (env === undefined) - throw new Error(`stub: no page ${page}`) + if (env === undefined) throw new Error(`stub: no page ${page}`) return env }) return { client: { list } as unknown as AccountSessionsClient, list } @@ -218,8 +232,20 @@ describe('listAllSessions', () => { it('exhausts pages until has_more=false', async () => { const { client, list } = stubClient([ - { page: 1, limit: 200, total: 250, has_more: true, data: Array.from({ length: 200 }, (_, i) => row(`s-${i}`)) }, - { page: 2, limit: 200, total: 250, has_more: false, data: Array.from({ length: 50 }, (_, i) => row(`s-${200 + i}`)) }, + { + page: 1, + limit: 200, + total: 250, + has_more: true, + data: Array.from({ length: 200 }, (_, i) => row(`s-${i}`)), + }, + { + page: 2, + limit: 200, + total: 250, + has_more: false, + data: Array.from({ length: 50 }, (_, i) => row(`s-${200 + i}`)), + }, ]) const all = await listAllSessions(client) expect(all.length).toBe(250) diff --git a/cli/src/commands/auth/devices/_shared/devices.ts b/cli/src/commands/auth/devices/_shared/devices.ts index b328e59f93f212..94c7dac1ff7c40 100644 --- a/cli/src/commands/auth/devices/_shared/devices.ts +++ b/cli/src/commands/auth/devices/_shared/devices.ts @@ -26,9 +26,8 @@ export async function runDevicesList(opts: DevicesListOptions): Promise { const env = opts.envLookup ?? ((k: string) => process.env[k]) const limit = resolveLimit(opts.limitRaw, env) const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page - const envelope = await runWithSpinner( - { io: opts.io, label: 'Fetching devices' }, - () => sessions.list({ page, limit }), + const envelope = await runWithSpinner({ io: opts.io, label: 'Fetching devices' }, () => + sessions.list({ page, limit }), ) if (opts.json === true) { @@ -40,11 +39,9 @@ export async function runDevicesList(opts: DevicesListOptions): Promise { } function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number { - if (raw !== undefined && raw !== '') - return parseLimit(raw, '--limit') + if (raw !== undefined && raw !== '') return parseLimit(raw, '--limit') const envValue = env('DIFY_LIMIT') - if (envValue !== undefined && envValue !== '') - return parseLimit(envValue, 'DIFY_LIMIT') + if (envValue !== undefined && envValue !== '') return parseLimit(envValue, 'DIFY_LIMIT') return LIMIT_DEFAULT } @@ -53,7 +50,9 @@ function resolveLimit(raw: string | undefined, env: (k: string) => string | unde * session sitting on page 2+ is still findable / revocable. Uses the max * page size (LIMIT_MAX) to minimize round-trips. */ -export async function listAllSessions(client: AccountSessionsClient): Promise { +export async function listAllSessions( + client: AccountSessionsClient, +): Promise { const out: SessionRow[] = [] let page = 1 // Hard guard against a misbehaving server that lies about has_more. @@ -61,8 +60,7 @@ export async function listAllSessions(client: AccountSessionsClient): Promise r.id !== currentId).map(r => r.id) + const ids = rows.filter((r) => r.id !== currentId).map((r) => r.id) return { ids, selfHit: false } } const target = opts.target ?? '' - const byLabel = rows.filter(r => r.device_label === target) - if (byLabel.length > 1) - throw ambiguous(target, byLabel) + const byLabel = rows.filter((r) => r.device_label === target) + if (byLabel.length > 1) throw ambiguous(target, byLabel) const onlyLabel = byLabel[0] - if (onlyLabel !== undefined) - return { ids: [onlyLabel.id], selfHit: onlyLabel.id === currentId } + if (onlyLabel !== undefined) return { ids: [onlyLabel.id], selfHit: onlyLabel.id === currentId } - const byId = rows.find(r => r.id === target) - if (byId !== undefined) - return { ids: [byId.id], selfHit: byId.id === currentId } + const byId = rows.find((r) => r.id === target) + if (byId !== undefined) return { ids: [byId.id], selfHit: byId.id === currentId } const needle = target.toLowerCase() - const bySub = rows.filter(r => r.device_label.toLowerCase().includes(needle)) - if (bySub.length > 1) - throw ambiguous(target, bySub) + const bySub = rows.filter((r) => r.device_label.toLowerCase().includes(needle)) + if (bySub.length > 1) throw ambiguous(target, bySub) const onlySub = bySub[0] - if (onlySub !== undefined) - return { ids: [onlySub.id], selfHit: onlySub.id === currentId } + if (onlySub !== undefined) return { ids: [onlySub.id], selfHit: onlySub.id === currentId } throw new BaseError({ code: ErrorCode.UsageMissingArg, @@ -154,7 +149,7 @@ export function pickTargets(rows: readonly SessionRow[], opts: { target?: string } function ambiguous(target: string, rows: readonly SessionRow[]): BaseError { - const labels = rows.map(r => `${r.device_label} (${r.id})`).join(', ') + const labels = rows.map((r) => `${r.device_label} (${r.id})`).join(', ') return new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `"${target}" matches multiple sessions: ${labels}; pass an exact id to disambiguate`, @@ -163,14 +158,19 @@ function ambiguous(target: string, rows: readonly SessionRow[]): BaseError { function renderTable(rows: readonly SessionRow[], currentId: string): string { const header = ['DEVICE', 'CREATED', 'LAST USED', 'CURRENT'] - const body = rows.map(r => [ + const body = rows.map((r) => [ r.device_label !== '' ? r.device_label : r.id, r.created_at ?? '', r.last_used_at ?? '', r.id === currentId ? '*' : '', ]) - const widths = header.map((h, i) => Math.max(h.length, ...body.map(row => (row[i] ?? '').length))) + const widths = header.map((h, i) => + Math.max(h.length, ...body.map((row) => (row[i] ?? '').length)), + ) const fmt = (cells: readonly string[]): string => - cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ').trimEnd() + cells + .map((c, i) => c.padEnd(widths[i] ?? 0)) + .join(' ') + .trimEnd() return body.length === 0 ? `${fmt(header)}\n` : `${[fmt(header), ...body.map(fmt)].join('\n')}\n` } diff --git a/cli/src/commands/auth/devices/list/index.ts b/cli/src/commands/auth/devices/list/index.ts index 50e43c37e8577f..31254c666f5abe 100644 --- a/cli/src/commands/auth/devices/list/index.ts +++ b/cli/src/commands/auth/devices/list/index.ts @@ -14,9 +14,9 @@ export default class DevicesList extends DifyCommand { static override flags = { 'http-retry': httpRetryFlag, - 'json': Flags.boolean({ description: 'emit JSON', default: false }), - 'page': Flags.integer({ description: 'page number', default: 1 }), - 'limit': Flags.string({ description: 'page size [1..200]' }), + json: Flags.boolean({ description: 'emit JSON', default: false }), + page: Flags.integer({ description: 'page number', default: 1 }), + limit: Flags.string({ description: 'page size [1..200]' }), } async run(argv: string[]): Promise { diff --git a/cli/src/commands/auth/devices/revoke/index.ts b/cli/src/commands/auth/devices/revoke/index.ts index 03f5d2294b9266..cac8928707076e 100644 --- a/cli/src/commands/auth/devices/revoke/index.ts +++ b/cli/src/commands/auth/devices/revoke/index.ts @@ -19,9 +19,12 @@ export default class DevicesRevoke extends DifyCommand { } static override flags = { - 'all': Flags.boolean({ description: 'revoke every session except the current one', default: false }), + all: Flags.boolean({ + description: 'revoke every session except the current one', + default: false, + }), 'http-retry': httpRetryFlag, - 'yes': Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }), + yes: Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }), } async run(argv: string[]): Promise { diff --git a/cli/src/commands/auth/list/handlers.ts b/cli/src/commands/auth/list/handlers.ts index 4311fa17440378..253efd8dd15367 100644 --- a/cli/src/commands/auth/list/handlers.ts +++ b/cli/src/commands/auth/list/handlers.ts @@ -52,14 +52,14 @@ export class ContextListOutput { } tableRows(): readonly (readonly TableCell[])[] { - return this.rows.map(r => r.tableRow()) + return this.rows.map((r) => r.tableRow()) } name(): string { - return this.rows.map(r => r.name()).join('\n') + return this.rows.map((r) => r.name()).join('\n') } json() { - return { contexts: this.rows.map(r => r.json()) } + return { contexts: this.rows.map((r) => r.json()) } } } diff --git a/cli/src/commands/auth/list/index.ts b/cli/src/commands/auth/list/index.ts index 3be17c0f036722..3ac980b06fa0ca 100644 --- a/cli/src/commands/auth/list/index.ts +++ b/cli/src/commands/auth/list/index.ts @@ -14,7 +14,10 @@ export default class AuthList extends DifyCommand { ] static override flags = { - output: Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME], default: '' }), + output: Flags.outputFormat({ + options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME], + default: '', + }), } async run(argv: string[]) { diff --git a/cli/src/commands/auth/list/list.test.ts b/cli/src/commands/auth/list/list.test.ts index cc4d188839c99e..5139009b29fea6 100644 --- a/cli/src/commands/auth/list/list.test.ts +++ b/cli/src/commands/auth/list/list.test.ts @@ -39,7 +39,7 @@ describe('runAuthList', () => { it('marks only the active context', () => { const result = runAuthList(twoHostReg()) - const active = result.rows.filter(r => r.active) + const active = result.rows.filter((r) => r.active) expect(active).toHaveLength(1) expect(active[0]!.host).toBe('cloud.dify.ai') expect(active[0]!.account).toBe('alice@corp.com') @@ -56,17 +56,19 @@ describe('runAuthList', () => { it('table: marks active row with *', () => { const out = stringifyOutput(table({ format: '', data: runAuthList(twoHostReg()) })) const lines = out.trim().split('\n') - const activeLine = lines.find(l => l.includes('alice@corp.com'))! + const activeLine = lines.find((l) => l.includes('alice@corp.com'))! expect(activeLine).toContain('*') - const inactiveLine = lines.find(l => l.includes('bob@corp.com'))! + const inactiveLine = lines.find((l) => l.includes('bob@corp.com'))! expect(inactiveLine).not.toContain('*') }) it('json: emits { contexts: [...] }', () => { const out = stringifyOutput(table({ format: 'json', data: runAuthList(twoHostReg()) })) - const parsed = JSON.parse(out) as { contexts: Array<{ host: string, account: string, active: boolean }> } + const parsed = JSON.parse(out) as { + contexts: Array<{ host: string; account: string; active: boolean }> + } expect(parsed.contexts).toHaveLength(3) - const activeCtx = parsed.contexts.find(c => c.active)! + const activeCtx = parsed.contexts.find((c) => c.active)! expect(activeCtx.host).toBe('cloud.dify.ai') expect(activeCtx.account).toBe('alice@corp.com') }) diff --git a/cli/src/commands/auth/login/device-flow.test.ts b/cli/src/commands/auth/login/device-flow.test.ts index 948779ab052201..249c1f38875972 100644 --- a/cli/src/commands/auth/login/device-flow.test.ts +++ b/cli/src/commands/auth/login/device-flow.test.ts @@ -27,8 +27,7 @@ class FakeClock implements Clock { async sleepMs(ms: number): Promise { this.sleeps.push(ms) - if (this.cancelAt !== undefined && this.sleeps.length >= this.cancelAt) - this.cancelled = true + if (this.cancelAt !== undefined && this.sleeps.length >= this.cancelAt) this.cancelled = true } isCancelled(): boolean { @@ -41,8 +40,7 @@ function fakeApi(scripted: PollResult[]): { pollOnce: (req: PollRequest) => Prom return { pollOnce: async () => { const r = scripted[i++] - if (r === undefined) - throw new Error('scripted-api: out of responses') + if (r === undefined) throw new Error('scripted-api: out of responses') return r }, } @@ -119,10 +117,7 @@ describe('awaitAuthorization', () => { }) it('uses default interval when CodeResponse.interval is 0', async () => { - const api = fakeApi([ - { status: 'pending' }, - { status: 'approved', success: successPayload }, - ]) + const api = fakeApi([{ status: 'pending' }, { status: 'approved', success: successPayload }]) const clock = new FakeClock() await awaitAuthorization(api, { ...code, interval: 0 }, { clock }) expect(clock.sleeps[0]).toBe(DEFAULT_INTERVAL_MS) @@ -165,7 +160,11 @@ describe('awaitAuthorization', () => { it('propagates BaseError thrown by api.pollOnce', async () => { const api = { - pollOnce: vi.fn().mockRejectedValue(new BaseError({ code: ErrorCode.UnsupportedEndpoint, message: 'old server' })), + pollOnce: vi + .fn() + .mockRejectedValue( + new BaseError({ code: ErrorCode.UnsupportedEndpoint, message: 'old server' }), + ), } const clock = new FakeClock() await expect(awaitAuthorization(api, code, { clock })).rejects.toThrow(/old server/) diff --git a/cli/src/commands/auth/login/device-flow.ts b/cli/src/commands/auth/login/device-flow.ts index a6b10c871fd3f1..eb1b2b5bad9e30 100644 --- a/cli/src/commands/auth/login/device-flow.ts +++ b/cli/src/commands/auth/login/device-flow.ts @@ -28,8 +28,7 @@ export async function awaitAuthorization( code: CodeResponse, opts: AwaitOptions, ): Promise { - if (code.device_code === '') - throw expired() + if (code.device_code === '') throw expired() const baseInterval = code.interval > 0 ? code.interval * 1000 : DEFAULT_INTERVAL_MS let interval = baseInterval @@ -39,8 +38,7 @@ export async function awaitAuthorization( } while (true) { - if (opts.clock.isCancelled()) - throw expired() + if (opts.clock.isCancelled()) throw expired() const result = await pollWithRetry(api, req, opts.clock) switch (result.status) { case 'approved': @@ -64,8 +62,7 @@ export async function awaitAuthorization( }) } await opts.clock.sleepMs(interval) - if (opts.clock.isCancelled()) - throw expired() + if (opts.clock.isCancelled()) throw expired() } } @@ -77,10 +74,8 @@ async function pollWithRetry( let backoff = POLL_RETRY_INITIAL_MS for (let attempt = 1; attempt <= POLL_RETRY_ATTEMPTS; attempt++) { const result = await api.pollOnce(req) - if (result.status !== 'retry_5xx') - return result - if (attempt === POLL_RETRY_ATTEMPTS) - break + if (result.status !== 'retry_5xx') return result + if (attempt === POLL_RETRY_ATTEMPTS) break await clock.sleepMs(backoff) backoff = Math.min(backoff * 2, POLL_RETRY_CAP_MS) } @@ -98,7 +93,7 @@ export function realClock(): Clock { const cancelled = false return { async sleepMs(ms) { - await new Promise(r => setTimeout(r, ms)) + await new Promise((r) => setTimeout(r, ms)) }, isCancelled() { return cancelled diff --git a/cli/src/commands/auth/login/index.ts b/cli/src/commands/auth/login/index.ts index 0b7022629323b8..ae051cef1c3d4a 100644 --- a/cli/src/commands/auth/login/index.ts +++ b/cli/src/commands/auth/login/index.ts @@ -18,7 +18,7 @@ export default class Login extends DifyCommand { ] static override flags = { - 'host': Flags.string({ + host: Flags.string({ description: 'Dify host URL', default: '', }), @@ -26,7 +26,7 @@ export default class Login extends DifyCommand { description: 'do not auto-open the browser', default: false, }), - 'insecure': Flags.boolean({ + insecure: Flags.boolean({ description: 'allow http:// hosts and skip TLS certificate verification (local-dev only)', default: false, }), diff --git a/cli/src/commands/auth/login/login.test.ts b/cli/src/commands/auth/login/login.test.ts index 1a8325703f447b..1b42536a412923 100644 --- a/cli/src/commands/auth/login/login.test.ts +++ b/cli/src/commands/auth/login/login.test.ts @@ -14,11 +14,15 @@ import { openAPIBase } from '@/util/host' import { runLogin } from './login.js' const noopClock: Clock = { - sleepMs: async () => { /* immediate */ }, + sleepMs: async () => { + /* immediate */ + }, isCancelled: () => false, } -const noopBrowser = async (): Promise => { /* skip OS open */ } +const noopBrowser = async (): Promise => { + /* skip OS open */ +} describe('runLogin', () => { let mock: DifyMock @@ -91,17 +95,19 @@ describe('runLogin', () => { mock.setScenario('denied') const io = bufferStreams() const store = new MemStore() - await expect(runLogin({ - io, - host: mock.url, - noBrowser: true, - insecure: true, - deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(testHttpClient(mock.url)), - store: { store, mode: 'file' }, - clock: noopClock, - browserOpener: noopBrowser, - })).rejects.toThrow(/denied/) + await expect( + runLogin({ + io, + host: mock.url, + noBrowser: true, + insecure: true, + deviceLabel: 'difyctl on test', + api: new DeviceFlowApi(testHttpClient(mock.url)), + store: { store, mode: 'file' }, + clock: noopClock, + browserOpener: noopBrowser, + }), + ).rejects.toThrow(/denied/) expect(store.entries.size).toBe(0) await expect(readFile(join(configDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/) }) @@ -110,51 +116,57 @@ describe('runLogin', () => { mock.setScenario('expired') const io = bufferStreams() const store = new MemStore() - await expect(runLogin({ - io, - host: mock.url, - noBrowser: true, - insecure: true, - deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(testHttpClient(mock.url)), - store: { store, mode: 'file' }, - clock: noopClock, - browserOpener: noopBrowser, - })).rejects.toThrow(/expired/) + await expect( + runLogin({ + io, + host: mock.url, + noBrowser: true, + insecure: true, + deviceLabel: 'difyctl on test', + api: new DeviceFlowApi(testHttpClient(mock.url)), + store: { store, mode: 'file' }, + clock: noopClock, + browserOpener: noopBrowser, + }), + ).rejects.toThrow(/expired/) }) it('rejects login when the account has no email', async () => { mock.setScenario('no-email') const io = bufferStreams() const store = new MemStore() - await expect(runLogin({ - io, - host: mock.url, - noBrowser: true, - insecure: true, - deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(mock.url) })), - store: { store, mode: 'file' }, - clock: noopClock, - browserOpener: noopBrowser, - })).rejects.toThrow(/no email/i) + await expect( + runLogin({ + io, + host: mock.url, + noBrowser: true, + insecure: true, + deviceLabel: 'difyctl on test', + api: new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(mock.url) })), + store: { store, mode: 'file' }, + clock: noopClock, + browserOpener: noopBrowser, + }), + ).rejects.toThrow(/no email/i) expect(store.entries.size).toBe(0) }) it('rejects http:// host without --insecure', async () => { const io = bufferStreams() const store = new MemStore() - await expect(runLogin({ - io, - host: mock.url, - noBrowser: true, - insecure: false, - deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(testHttpClient(mock.url)), - store: { store, mode: 'file' }, - clock: noopClock, - browserOpener: noopBrowser, - })).rejects.toThrow(/https:\/\//) + await expect( + runLogin({ + io, + host: mock.url, + noBrowser: true, + insecure: false, + deviceLabel: 'difyctl on test', + api: new DeviceFlowApi(testHttpClient(mock.url)), + store: { store, mode: 'file' }, + clock: noopClock, + browserOpener: noopBrowser, + }), + ).rejects.toThrow(/https:\/\//) }) it('emits skip-reason to stderr when --no-browser', async () => { diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts index 7777f8c7c18e24..aa3baec0a4eff8 100644 --- a/cli/src/commands/auth/login/login.ts +++ b/cli/src/commands/auth/login/login.ts @@ -17,7 +17,13 @@ import { colorEnabled, colorScheme } from '@/sys/io/color' import { promptText } from '@/sys/io/prompt' import { startSpinner } from '@/sys/io/spinner' import { decideOpen, OpenDecision, openUrl, realEnv } from '@/util/browser' -import { bareHost, DEFAULT_HOST, openAPIBase, resolveHost, validateVerificationURI } from '@/util/host' +import { + bareHost, + DEFAULT_HOST, + openAPIBase, + resolveHost, + validateVerificationURI, +} from '@/util/host' import { awaitAuthorization, realClock } from './device-flow.js' export type LoginOptions = { @@ -26,7 +32,7 @@ export type LoginOptions = { readonly noBrowser?: boolean readonly insecure?: boolean readonly deviceLabel?: string - readonly store?: { readonly store: TokenStore, readonly mode: StorageMode } + readonly store?: { readonly store: TokenStore; readonly mode: StorageMode } readonly api?: DeviceFlowApi readonly browserEnv?: BrowserEnv readonly browserOpener?: BrowserOpener @@ -44,7 +50,8 @@ 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), insecure })) + 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) @@ -56,12 +63,12 @@ export async function runLogin(opts: LoginOptions): Promise { const opener = opts.browserOpener ?? openUrl try { await opener(code.verification_uri) + } catch (err) { + opts.io.err.write( + `${cs.warningIcon()} couldn't open browser (${(err as Error).message}); open the URL above manually\n`, + ) } - catch (err) { - opts.io.err.write(`${cs.warningIcon()} couldn't open browser (${(err as Error).message}); open the URL above manually\n`) - } - } - else { + } else { opts.io.err.write(`${cs.warningIcon()} ${decision} — open the URL above manually\n`) } @@ -69,15 +76,14 @@ export async function runLogin(opts: LoginOptions): Promise { let success: PollSuccess try { success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() }) - } - finally { + } finally { spinner.stop() } // Refuse to persist a session to a server too old for this difyctl. await (opts.verifyServer ?? (async () => {}))(host) - const storeBundle = opts.store ?? await detectTokenStore() + const storeBundle = opts.store ?? (await detectTokenStore()) const display = bareHost(host) const email = accountEmail(success) const ctx = contextFromSuccess(success) @@ -97,13 +103,12 @@ export async function runLogin(opts: LoginOptions): Promise { async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise { const raw = opts.host?.trim() ?? '' - if (raw !== '') - return resolveHost({ raw, insecure }) + if (raw !== '') return resolveHost({ raw, insecure }) if (!opts.io.isErrTTY) { throw new BaseError({ code: ErrorCode.UsageMissingArg, message: '--host is required (no TTY)', - hint: 'pass the host explicitly, e.g. \'difyctl auth login --host cloud.dify.ai\'', + hint: "pass the host explicitly, e.g. 'difyctl auth login --host cloud.dify.ai'", }) } return promptHost(opts.io, insecure) @@ -113,8 +118,7 @@ function makeHostParser(insecure: boolean): (raw: string) => ParseResult return (raw: string) => { try { return { ok: true, value: resolveHost({ raw, insecure }) } - } - catch (err) { + } catch (err) { if (isBaseError(err)) { const msg = err.hint !== undefined ? `${err.message} — ${err.hint}` : err.message return { ok: false, error: msg } @@ -128,9 +132,11 @@ async function promptHost(io: IOStreams, insecure: boolean): Promise { return promptText({ io, label: 'Enter Dify host URL', - hint: insecure ? 'e.g. https://cloud.dify.ai or http://localhost' : 'e.g. https://your-dify.com', + hint: insecure + ? 'e.g. https://cloud.dify.ai or http://localhost' + : 'e.g. https://your-dify.com', default: DEFAULT_HOST, - acceptAsDefault: raw => /^y(?:es)?$/i.test(raw.trim()), + acceptAsDefault: (raw) => /^y(?:es)?$/i.test(raw.trim()), parse: makeHostParser(insecure), }) } @@ -140,34 +146,49 @@ function defaultDeviceLabel(): string { return `difyctl on ${host !== '' ? host : 'unknown-host'}` } -function renderCodePrompt(w: NodeJS.WritableStream, cs: ReturnType, code: CodeResponse): void { +function renderCodePrompt( + w: NodeJS.WritableStream, + cs: ReturnType, + code: CodeResponse, +): void { w.write(`${cs.warningIcon()} Copy this one-time code: ${cs.bold(code.user_code)}\n`) w.write(` Open: ${code.verification_uri}\n`) } -function renderLoggedIn(w: NodeJS.WritableStream, cs: ReturnType, host: string, s: PollSuccess): void { +function renderLoggedIn( + w: NodeJS.WritableStream, + cs: ReturnType, + host: string, + s: PollSuccess, +): void { const display = bareHost(host) if (s.account && s.account.email !== '') { - w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.account.email)} (${s.account.name})\n`) + w.write( + `${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.account.email)} (${s.account.name})\n`, + ) const ws = findDefaultWorkspace(s) - if (ws !== undefined) - w.write(` Workspace: ${ws.name}\n`) + if (ws !== undefined) w.write(` Workspace: ${ws.name}\n`) return } if (s.subject_email !== undefined && s.subject_email !== '') { if (s.subject_issuer !== undefined && s.subject_issuer !== '') - w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.subject_email)} (external SSO, issuer: ${s.subject_issuer})\n`) + w.write( + `${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.subject_email)} (external SSO, issuer: ${s.subject_issuer})\n`, + ) else - w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.subject_email)} (external SSO)\n`) + w.write( + `${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.subject_email)} (external SSO)\n`, + ) return } w.write(`${cs.successIcon()} Logged in to ${display}\n`) } -function findDefaultWorkspace(s: PollSuccess): { id: string, name: string, role: string } | undefined { - if (s.default_workspace_id === undefined || s.default_workspace_id === '') - return undefined - return s.workspaces?.find(w => w.id === s.default_workspace_id) +function findDefaultWorkspace( + s: PollSuccess, +): { id: string; name: string; role: string } | undefined { + if (s.default_workspace_id === undefined || s.default_workspace_id === '') return undefined + return s.workspaces?.find((w) => w.id === s.default_workspace_id) } function accountEmail(s: PollSuccess): string { @@ -189,21 +210,23 @@ function contextFromSuccess(s: PollSuccess): AccountContext { : { id: '', email: '', name: '' }, token_id: s.token_id, } - if (s.subject_email !== undefined && s.subject_email !== '' - && (!s.account || s.account.id === '')) { + if ( + s.subject_email !== undefined && + s.subject_email !== '' && + (!s.account || s.account.id === '') + ) { ctx.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '' } } const def = findDefaultWorkspace(s) - if (def !== undefined) - ctx.workspace = def + if (def !== undefined) ctx.workspace = def return ctx } function applyScheme(reg: Registry, display: string, host: string): void { try { const u = new URL(host) - if (u.protocol !== 'https:') - reg.setScheme(display, u.protocol.replace(':', '')) + if (u.protocol !== 'https:') reg.setScheme(display, u.protocol.replace(':', '')) + } catch { + /* keep scheme unset */ } - catch { /* keep scheme unset */ } } diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts index 786d525c748872..04a6e5545cce47 100644 --- a/cli/src/commands/auth/logout/index.ts +++ b/cli/src/commands/auth/logout/index.ts @@ -14,9 +14,7 @@ export default class Logout extends DifyCommand { static override effect: CommandEffect = 'write' - static override examples = [ - '<%= config.bin %> auth logout', - ] + static override examples = ['<%= config.bin %> auth logout'] async run(argv: string[]): Promise { this.parse(Logout, argv) @@ -29,17 +27,17 @@ export default class Logout extends DifyCommand { let bearer = '' try { bearer = await getTokenStore(reg.token_storage).read(active.host, active.email) + } catch { + /* keyring locked — skip remote revocation, local cleanup still runs */ } - catch { /* keyring locked — skip remote revocation, local cleanup still runs */ } if (bearer !== '') { const { host, insecure } = activeHostInfo(active) http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts: 0, insecure }) } } - await runWithSpinner( - { io, label: 'Signing out', enabled: true, style: 'dify-dim' }, - () => runLogout({ io, reg, http }), + await runWithSpinner({ io, label: 'Signing out', enabled: true, style: 'dify-dim' }, () => + runLogout({ io, reg, http }), ) } } diff --git a/cli/src/commands/auth/logout/logout.test.ts b/cli/src/commands/auth/logout/logout.test.ts index 2d1ea109e5cff3..33fd0904ccc044 100644 --- a/cli/src/commands/auth/logout/logout.test.ts +++ b/cli/src/commands/auth/logout/logout.test.ts @@ -48,8 +48,8 @@ describe('runLogout', () => { it('throws NotLoggedIn when no active context', async () => { await Registry.empty('file').save() - await expect(runLogout({ io: bufferStreams(), reg: await Registry.load(), store: new MemStore() })) - .rejects - .toThrow(/not logged in/i) + await expect( + runLogout({ io: bufferStreams(), reg: await Registry.load(), store: new MemStore() }), + ).rejects.toThrow(/not logged in/i) }) }) diff --git a/cli/src/commands/auth/logout/logout.ts b/cli/src/commands/auth/logout/logout.ts index 6724a5feef9b60..e78f8be5342a5e 100644 --- a/cli/src/commands/auth/logout/logout.ts +++ b/cli/src/commands/auth/logout/logout.ts @@ -25,26 +25,25 @@ export async function runLogout(opts: LogoutOptions): Promise { let bearer = '' try { bearer = await store.read(active.host, active.email) + } catch { + /* keyring locked — skip remote revocation, local cleanup still runs */ } - catch { /* keyring locked — skip remote revocation, local cleanup still runs */ } let revokeWarning = '' if (bearer !== '' && revokeAllowed(bearer) && opts.http !== undefined) { try { await new AccountSessionsClient(opts.http).revokeSelf() - } - catch (err) { + } catch (err) { revokeWarning = `${cs.warningIcon()} server revoke failed (${(err as Error).message}); local credentials cleared anyway\n` } } await reg.forget(active, store) - if (revokeWarning !== '') - opts.io.err.write(revokeWarning) + if (revokeWarning !== '') opts.io.err.write(revokeWarning) opts.io.out.write(`${cs.successIcon()} Logged out of ${active.host}\n`) } function revokeAllowed(bearer: string): boolean { - return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p)) + return REVOCABLE_PREFIXES.some((p) => bearer.startsWith(p)) } diff --git a/cli/src/commands/auth/whoami/index.ts b/cli/src/commands/auth/whoami/index.ts index 26b0065047bcf1..4649a7594cdf3f 100644 --- a/cli/src/commands/auth/whoami/index.ts +++ b/cli/src/commands/auth/whoami/index.ts @@ -5,7 +5,7 @@ import { realStreams } from '@/sys/io/streams' import { runWhoami } from './whoami' export default class Whoami extends DifyCommand { - static override description = 'Print the active subject\'s identity' + static override description = "Print the active subject's identity" static override examples = [ '<%= config.bin %> auth whoami', diff --git a/cli/src/commands/auth/whoami/whoami.test.ts b/cli/src/commands/auth/whoami/whoami.test.ts index e82e946c677a38..d662b5999fb7ad 100644 --- a/cli/src/commands/auth/whoami/whoami.test.ts +++ b/cli/src/commands/auth/whoami/whoami.test.ts @@ -7,9 +7,14 @@ function accountReg(): Registry { return Registry.from({ token_storage: 'file', current_host: 'cloud.dify.ai', - hosts: { 'cloud.dify.ai': { current_account: 'a@b.c', accounts: { - 'a@b.c': { account: { id: 'acct-1', email: 'a@b.c', name: 'Ann' } }, - } } }, + hosts: { + 'cloud.dify.ai': { + current_account: 'a@b.c', + accounts: { + 'a@b.c': { account: { id: 'acct-1', email: 'a@b.c', name: 'Ann' } }, + }, + }, + }, }) } @@ -17,18 +22,25 @@ function ssoReg(): Registry { return Registry.from({ token_storage: 'file', current_host: 'cloud.dify.ai', - hosts: { 'cloud.dify.ai': { current_account: 'sso@dify.ai', accounts: { - 'sso@dify.ai': { - account: { email: 'sso@dify.ai', name: '' }, - external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' }, + hosts: { + 'cloud.dify.ai': { + current_account: 'sso@dify.ai', + accounts: { + 'sso@dify.ai': { + account: { email: 'sso@dify.ai', name: '' }, + external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' }, + }, + }, }, - } } }, + }, }) } describe('runWhoami', () => { it('throws NotLoggedIn when no active context', async () => { - await expect(runWhoami({ io: bufferStreams(), reg: Registry.empty() })).rejects.toThrow(/not logged in/i) + await expect(runWhoami({ io: bufferStreams(), reg: Registry.empty() })).rejects.toThrow( + /not logged in/i, + ) }) it('prints email + name for an account', async () => { diff --git a/cli/src/commands/auth/whoami/whoami.ts b/cli/src/commands/auth/whoami/whoami.ts index e51969b1ee7b87..514283a2570fc1 100644 --- a/cli/src/commands/auth/whoami/whoami.ts +++ b/cli/src/commands/auth/whoami/whoami.ts @@ -13,12 +13,16 @@ export async function runWhoami(opts: WhoamiOptions): Promise { const sub = active.ctx.external_subject if (sub !== undefined) { if (opts.json === true) { - opts.io.out.write(`${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.issuer })}\n`) + opts.io.out.write( + `${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.issuer })}\n`, + ) return } - opts.io.out.write(sub.issuer !== '' - ? `${sub.email} (external SSO, issuer: ${sub.issuer})\n` - : `${sub.email} (external SSO)\n`) + opts.io.out.write( + sub.issuer !== '' + ? `${sub.email} (external SSO, issuer: ${sub.issuer})\n` + : `${sub.email} (external SSO)\n`, + ) return } diff --git a/cli/src/commands/config/get/index.ts b/cli/src/commands/config/get/index.ts index 65506d4defa23b..45a122d648e7ca 100644 --- a/cli/src/commands/config/get/index.ts +++ b/cli/src/commands/config/get/index.ts @@ -5,11 +5,9 @@ import { getConfigurationStore } from '@/store/manager' import { runConfigGet } from './run' export default class ConfigGet extends DifyCommand { - static override description = 'Print one config key\'s value' + static override description = "Print one config key's value" - static override examples = [ - '<%= config.bin %> config get defaults.format', - ] + static override examples = ['<%= config.bin %> config get defaults.format'] static override args = { key: Args.string({ description: 'config key', required: true }), diff --git a/cli/src/commands/config/get/run.test.ts b/cli/src/commands/config/get/run.test.ts index 482edc78af9c25..00e1d616071093 100644 --- a/cli/src/commands/config/get/run.test.ts +++ b/cli/src/commands/config/get/run.test.ts @@ -26,11 +26,11 @@ describe('runConfigGet', () => { let caught: unknown try { await runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' }) + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) + if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) }) it('returns numeric limit as string', async () => { diff --git a/cli/src/commands/config/path/index.ts b/cli/src/commands/config/path/index.ts index dcb2aed2590bc2..caa99208790d14 100644 --- a/cli/src/commands/config/path/index.ts +++ b/cli/src/commands/config/path/index.ts @@ -7,14 +7,10 @@ import { CONFIG_FILE_NAME } from '@/store/manager' export default class ConfigPath extends DifyCommand { static override description = 'Print the resolved config.yml path' - static override examples = [ - '<%= config.bin %> config path', - ] + static override examples = ['<%= config.bin %> config path'] async run(argv: string[]) { this.parse(ConfigPath, argv) - return raw( - join(resolveConfigDir(), CONFIG_FILE_NAME), - ) + return raw(join(resolveConfigDir(), CONFIG_FILE_NAME)) } } diff --git a/cli/src/commands/config/set/index.ts b/cli/src/commands/config/set/index.ts index 805a02e888af8d..21ff9ce7f48a79 100644 --- a/cli/src/commands/config/set/index.ts +++ b/cli/src/commands/config/set/index.ts @@ -22,6 +22,8 @@ export default class ConfigSet extends DifyCommand { async run(argv: string[]) { const { args } = this.parse(ConfigSet, argv) - return raw(await runConfigSet({ store: getConfigurationStore(), key: args.key, value: args.value })) + return raw( + await runConfigSet({ store: getConfigurationStore(), key: args.key, value: args.value }), + ) } } diff --git a/cli/src/commands/config/set/run.test.ts b/cli/src/commands/config/set/run.test.ts index a0c11bb4f1b80f..7d7994a2c27466 100644 --- a/cli/src/commands/config/set/run.test.ts +++ b/cli/src/commands/config/set/run.test.ts @@ -10,35 +10,38 @@ describe('runConfigSet', () => { useTempConfigDir('difyctl-set-') it('persists the value and returns "set k = v\\n"', async () => { - const out = await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'json' }) + const out = await runConfigSet({ + store: getConfigurationStore(), + key: 'defaults.format', + value: 'json', + }) expect(out).toBe('set defaults.format = json\n') const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) - if (r.found) - expect(r.config.defaults.format).toBe('json') + if (r.found) expect(r.config.defaults.format).toBe('json') }) it('rejects invalid format value with config_invalid_value', async () => { let caught: unknown try { await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' }) + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) + if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) }) it('rejects unknown key with config_invalid_key', async () => { let caught: unknown try { await runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' }) + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) + if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) }) it('preserves prior keys when setting a new one', async () => { @@ -57,30 +60,31 @@ describe('runConfigSet', () => { let caught: unknown try { await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' }) + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.exit()).toBe(ExitCode.Usage) + if (isBaseError(caught)) expect(caught.exit()).toBe(ExitCode.Usage) }) it('exit code for unknown key is Usage (2)', async () => { let caught: unknown try { await runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' }) + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.exit()).toBe(ExitCode.Usage) + if (isBaseError(caught)) expect(caught.exit()).toBe(ExitCode.Usage) }) it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', async () => { let caught: unknown try { await runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' }) + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) if (isBaseError(caught)) { expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) diff --git a/cli/src/commands/config/unset/index.ts b/cli/src/commands/config/unset/index.ts index ec9e0d0758e79a..507c73eda95e78 100644 --- a/cli/src/commands/config/unset/index.ts +++ b/cli/src/commands/config/unset/index.ts @@ -10,9 +10,7 @@ export default class ConfigUnset extends DifyCommand { static override effect: CommandEffect = 'write' - static override examples = [ - '<%= config.bin %> config unset defaults.format', - ] + static override examples = ['<%= config.bin %> config unset defaults.format'] static override args = { key: Args.string({ description: 'config key', required: true }), diff --git a/cli/src/commands/config/unset/run.test.ts b/cli/src/commands/config/unset/run.test.ts index d757b7df213d39..21ee48a3cddba7 100644 --- a/cli/src/commands/config/unset/run.test.ts +++ b/cli/src/commands/config/unset/run.test.ts @@ -30,18 +30,17 @@ describe('runConfigUnset', () => { expect(out).toBe('unset defaults.format\n') const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) - if (r.found) - expect(r.config.schema_version).toBe(1) + if (r.found) expect(r.config.schema_version).toBe(1) }) it('rejects unknown key', async () => { let caught: unknown try { await runConfigUnset({ store: getConfigurationStore(), key: 'bogus' }) + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) + if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) }) }) diff --git a/cli/src/commands/config/view/run.test.ts b/cli/src/commands/config/view/run.test.ts index 22d16975ec026f..cb3ac6c8c1b59f 100644 --- a/cli/src/commands/config/view/run.test.ts +++ b/cli/src/commands/config/view/run.test.ts @@ -18,9 +18,7 @@ describe('runConfigView', () => { state: { current_app: 'app-1' }, }) const out = await runConfigView({ store: getConfigurationStore() }) - expect(out).toBe( - 'defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n', - ) + expect(out).toBe('defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n') }) it('text format: skips unset keys', async () => { diff --git a/cli/src/commands/config/view/run.ts b/cli/src/commands/config/view/run.ts index 50dcdcaf1b06af..a3d79466061fbc 100644 --- a/cli/src/commands/config/view/run.ts +++ b/cli/src/commands/config/view/run.ts @@ -15,12 +15,10 @@ export async function runConfigView(opts: RunConfigViewOptions): Promise const loaded = await loadConfig(opts.store) const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() const out = collect(config) - if (opts.json) - return `${JSON.stringify(out, null, 2)}\n` + if (opts.json) return `${JSON.stringify(out, null, 2)}\n` let text = '' for (const k of knownKeyNames()) { - if (!(k in out)) - continue + if (!(k in out)) continue text += `${k} = ${out[k]}\n` } return text @@ -30,15 +28,12 @@ function collect(config: ConfigFile): ViewOut { const out: ViewOut = {} for (const k of knownKeyNames()) { const spec = lookupKey(k) - if (spec === undefined) - continue + if (spec === undefined) continue const v = spec.get(config) - if (v === '') - continue + if (v === '') continue if (k === 'defaults.limit') { const n = Number.parseInt(v, 10) - if (Number.isFinite(n)) - out[k] = n + if (Number.isFinite(n)) out[k] = n continue } out[k] = v diff --git a/cli/src/commands/coverage.test.ts b/cli/src/commands/coverage.test.ts index 25cc4e4de78cec..e959b30b4278b4 100644 --- a/cli/src/commands/coverage.test.ts +++ b/cli/src/commands/coverage.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest' import { isExcludedCommandPath } from '@/framework/command-fs' -const INDEX_MODULES = import.meta.glob<{ default?: unknown }>( - './**/index.ts', - { eager: true }, -) +const INDEX_MODULES = import.meta.glob<{ default?: unknown }>('./**/index.ts', { eager: true }) const COMMAND_MODULES = Object.fromEntries( Object.entries(INDEX_MODULES).filter(([path]) => !isExcludedCommandPath(path)), diff --git a/cli/src/commands/create/member/index.ts b/cli/src/commands/create/member/index.ts index 6a96a15fab8fc0..50778bb9d923f7 100644 --- a/cli/src/commands/create/member/index.ts +++ b/cli/src/commands/create/member/index.ts @@ -17,17 +17,20 @@ export default class CreateMember extends DifyCommand { ] static override flags = { - 'email': Flags.string({ description: 'invitee email address', required: true }), - 'role': Flags.string({ + email: Flags.string({ description: 'invitee email address', required: true }), + role: Flags.string({ description: 'role to assign (normal|admin); owner is not assignable here', required: true, }), - 'workspace': Flags.string({ + workspace: Flags.string({ char: 'w', description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', }), 'http-retry': httpRetryFlag, - 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }), + output: Flags.outputFormat({ + options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], + default: '', + }), } async run(argv: string[]) { diff --git a/cli/src/commands/create/member/run.test.ts b/cli/src/commands/create/member/run.test.ts index 6c363691dc639a..16f007133b183d 100644 --- a/cli/src/commands/create/member/run.test.ts +++ b/cli/src/commands/create/member/run.test.ts @@ -17,7 +17,7 @@ function active(): ActiveContext { function fakeClient() { return { - invite: vi.fn((_ws: string, body: { email: string, role: string }) => + invite: vi.fn((_ws: string, body: { email: string; role: string }) => Promise.resolve({ result: 'success' as const, email: body.email.toLowerCase(), @@ -25,7 +25,8 @@ function fakeClient() { member_id: 'acct-new', invite_url: 'https://console.example.com/activate?email=x&token=tok', tenant_id: '550e8400-e29b-41d4-a716-446655440000', - })), + }), + ), } } @@ -41,7 +42,10 @@ describe('runCreateMember', () => { membersFactory: () => client as never, }, ) - expect(client.invite).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440000', { email: 'new@example.com', role: 'normal' }) + expect(client.invite).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440000', { + email: 'new@example.com', + role: 'normal', + }) expect(result.data.text()).toMatch(/Invited new@example\.com as normal/) expect(result.data.name()).toBe('acct-new') expect(result.data.json()).toMatchObject({ @@ -89,7 +93,11 @@ describe('runCreateMember', () => { it('-w flag overrides resolved workspace', async () => { const client = fakeClient() await runCreateMember( - { email: 'new@example.com', role: 'admin', workspace: '550e8400-e29b-41d4-a716-446655440008' }, + { + email: 'new@example.com', + role: 'admin', + workspace: '550e8400-e29b-41d4-a716-446655440008', + }, { active: active(), http: {} as HttpClient, @@ -97,6 +105,9 @@ describe('runCreateMember', () => { membersFactory: () => client as never, }, ) - expect(client.invite).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440008', { email: 'new@example.com', role: 'admin' }) + expect(client.invite).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440008', { + email: 'new@example.com', + role: 'admin', + }) }) }) diff --git a/cli/src/commands/create/member/run.ts b/cli/src/commands/create/member/run.ts index df2cee7efdffe1..44b41fdd42b28e 100644 --- a/cli/src/commands/create/member/run.ts +++ b/cli/src/commands/create/member/run.ts @@ -62,9 +62,8 @@ export async function runCreateMember( active: deps.active, }) - const response = await runWithSpinner( - { io, label: `Inviting ${opts.email}` }, - () => factory(deps.http).invite(wsId, { + const response = await runWithSpinner({ io, label: `Inviting ${opts.email}` }, () => + factory(deps.http).invite(wsId, { email: opts.email, role: opts.role as 'normal' | 'admin', }), diff --git a/cli/src/commands/delete/member/index.ts b/cli/src/commands/delete/member/index.ts index 9f916f2776e1ad..878f8c46593bc9 100644 --- a/cli/src/commands/delete/member/index.ts +++ b/cli/src/commands/delete/member/index.ts @@ -21,13 +21,16 @@ export default class DeleteMember extends DifyCommand { } static override flags = { - 'workspace': Flags.string({ + workspace: Flags.string({ char: 'w', description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', }), 'http-retry': httpRetryFlag, - 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }), - 'yes': Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }), + output: Flags.outputFormat({ + options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], + default: '', + }), + yes: Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }), } async run(argv: string[]) { diff --git a/cli/src/commands/delete/member/run.test.ts b/cli/src/commands/delete/member/run.test.ts index bbc58ff2c3f062..ee460d53d524c8 100644 --- a/cli/src/commands/delete/member/run.test.ts +++ b/cli/src/commands/delete/member/run.test.ts @@ -33,7 +33,10 @@ describe('runDeleteMember', () => { membersFactory: () => client as never, }, ) - expect(client.remove).toHaveBeenCalledExactlyOnceWith('550e8400-e29b-41d4-a716-446655440000', 'acct-2') + expect(client.remove).toHaveBeenCalledExactlyOnceWith( + '550e8400-e29b-41d4-a716-446655440000', + 'acct-2', + ) expect(result.data.text()).toMatch(/Removed acct-2/) expect(result.data.name()).toBe('acct-2') expect(result.data.json()).toEqual({ id: 'acct-2', deleted: true }) diff --git a/cli/src/commands/delete/member/run.ts b/cli/src/commands/delete/member/run.ts index f11e3ee4b9387c..6796e1ab2ce31e 100644 --- a/cli/src/commands/delete/member/run.ts +++ b/cli/src/commands/delete/member/run.ts @@ -65,9 +65,8 @@ export async function runDeleteMember( } } - await runWithSpinner( - { io, label: `Removing ${opts.memberId}` }, - () => factory(deps.http).remove(wsId, opts.memberId), + await runWithSpinner({ io, label: `Removing ${opts.memberId}` }, () => + factory(deps.http).remove(wsId, opts.memberId), ) const textLine = `${cs.successIcon()} Removed ${opts.memberId}\n` diff --git a/cli/src/commands/describe/app/handlers.ts b/cli/src/commands/describe/app/handlers.ts index 133260e65095dc..79fa372e2632f0 100644 --- a/cli/src/commands/describe/app/handlers.ts +++ b/cli/src/commands/describe/app/handlers.ts @@ -33,15 +33,14 @@ export class AppDescribeOutput { ] if (info.description !== '' && info.description !== undefined) rows.push(['Description', info.description ?? '']) - if (info.is_agent) - rows.push(['Agent', 'true']) + if (info.is_agent) rows.push(['Agent', 'true']) lines.push(...alignedRows(rows)) } if (this.payload.parameters !== null && this.payload.parameters !== undefined) { lines.push('Parameters:') const indented = JSON.stringify(this.payload.parameters, null, 2) .split('\n') - .map(l => ` ${l}`) + .map((l) => ` ${l}`) .join('\n') lines.push(indented) } diff --git a/cli/src/commands/describe/app/index.ts b/cli/src/commands/describe/app/index.ts index 1a5beb386ba358..133a29f6b41867 100644 --- a/cli/src/commands/describe/app/index.ts +++ b/cli/src/commands/describe/app/index.ts @@ -22,16 +22,27 @@ export default class DescribeApp extends DifyCommand { } static override flags = { - 'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }), + workspace: Flags.string({ + description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', + }), 'http-retry': httpRetryFlag, - 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }), - 'refresh': Flags.boolean({ description: 'bypass app-info cache and fetch fresh', default: false }), + output: Flags.outputFormat({ + options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], + default: '', + }), + refresh: Flags.boolean({ + description: 'bypass app-info cache and fetch fresh', + default: false, + }), } async run(argv: string[]) { const { args, flags } = this.parse(DescribeApp, argv) if (!isValidUuid(args.id)) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `${JSON.stringify(args.id)} is not a valid app UUID` }) + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `${JSON.stringify(args.id)} is not a valid app UUID`, + }) const format = flags.output const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], withCache: true, format }) return formatted({ diff --git a/cli/src/commands/describe/app/run.test.ts b/cli/src/commands/describe/app/run.test.ts index 96dfae5acb5da1..1ff067d8722e57 100644 --- a/cli/src/commands/describe/app/run.test.ts +++ b/cli/src/commands/describe/app/run.test.ts @@ -35,20 +35,20 @@ describe('runDescribeApp', () => { }) afterEach(async () => { vi.restoreAllMocks() - if (prevCacheDir === undefined) - delete process.env[ENV_CACHE_DIR] - else - process.env[ENV_CACHE_DIR] = prevCacheDir + if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR] + else process.env[ENV_CACHE_DIR] = prevCacheDir await mock.stop() await rm(dir, { recursive: true, force: true }) }) async function render(opts: Parameters[0]): Promise { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - const data = await runDescribeApp( - opts, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache }, - ) + const data = await runDescribeApp(opts, { + active: active(), + http: testHttpClient(mock.url, 'dfoa_test'), + host: mock.url, + cache, + }) return stringifyOutput(formatted({ format: opts.format ?? '', data })) } @@ -73,7 +73,7 @@ describe('runDescribeApp', () => { it('json: passes through DescribeResponse-shaped meta', async () => { const out = await render({ appId: 'app-1', format: 'json' }) - const parsed = JSON.parse(out) as { info: { id: string }, parameters: unknown } + const parsed = JSON.parse(out) as { info: { id: string }; parameters: unknown } expect(parsed.info.id).toBe('app-1') expect(parsed.parameters).toBeDefined() }) @@ -105,18 +105,27 @@ describe('runDescribeApp', () => { }) it('unknown app id surfaces as error', async () => { - await expect(runDescribeApp( - { appId: 'nope' }, - { - active: active(), - http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), - host: mock.url, - }, - )).rejects.toThrow() + await expect( + runDescribeApp( + { appId: 'nope' }, + { + active: active(), + http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), + host: mock.url, + }, + ), + ).rejects.toThrow() }) it('external login resolves describe via the permitted-external route', async () => { - const activeExt: ActiveContext = { host: mock.url, email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } } + const activeExt: ActiveContext = { + host: mock.url, + email: 'e', + ctx: { + account: { id: 'a', email: 'e', name: 'n' }, + external_subject: { email: 'e', issuer: 'i' }, + }, + } const out = await runDescribeApp( { appId: 'app-1' }, { active: activeExt, http: testHttpClient(mock.url, 'dfoe_test'), host: mock.url }, diff --git a/cli/src/commands/describe/app/run.ts b/cli/src/commands/describe/app/run.ts index 0d5eea784d4a1c..5e2eb381db3a87 100644 --- a/cli/src/commands/describe/app/run.ts +++ b/cli/src/commands/describe/app/run.ts @@ -25,17 +25,16 @@ export type DescribeAppDeps = { readonly envLookup?: (k: string) => string | undefined } -export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise { +export async function runDescribeApp( + opts: DescribeAppOptions, + deps: DescribeAppDeps, +): Promise { const apps = selectAppReader(deps.active, deps.http) const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) const io = deps.io ?? nullStreams() - const result = await runWithSpinner( - { io, label: 'Fetching app details' }, - async () => { - if (opts.refresh === true) - await meta.invalidate(opts.appId) - return meta.get(opts.appId, [FieldInfo, FieldParameters, FieldInputSchema]) - }, - ) + const result = await runWithSpinner({ io, label: 'Fetching app details' }, async () => { + if (opts.refresh === true) await meta.invalidate(opts.appId) + return meta.get(opts.appId, [FieldInfo, FieldParameters, FieldInputSchema]) + }) return new AppDescribeOutput(result) } diff --git a/cli/src/commands/env/list/index.ts b/cli/src/commands/env/list/index.ts index 777dfd133a4f19..47622d187b53b7 100644 --- a/cli/src/commands/env/list/index.ts +++ b/cli/src/commands/env/list/index.ts @@ -6,10 +6,7 @@ import { runEnvList } from './run-list' export default class EnvList extends DifyCommand { static override description = 'Show every DIFY_* env var difyctl reads' - static override examples = [ - '<%= config.bin %> env list', - '<%= config.bin %> env list --json', - ] + static override examples = ['<%= config.bin %> env list', '<%= config.bin %> env list --json'] static override flags = { json: Flags.boolean({ description: 'emit JSON', default: false }), diff --git a/cli/src/commands/env/list/run-list.test.ts b/cli/src/commands/env/list/run-list.test.ts index 642e0d63581f24..9cd70653f9235d 100644 --- a/cli/src/commands/env/list/run-list.test.ts +++ b/cli/src/commands/env/list/run-list.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { runEnvList } from './run-list' -const stub = (overrides: Record = {}) => (name: string) => overrides[name] +const stub = + (overrides: Record = {}) => + (name: string) => + overrides[name] describe('runEnvList', () => { it('text: header is NAME VALUE DESCRIPTION', () => { @@ -11,44 +14,51 @@ describe('runEnvList', () => { it('text: for unset non-sensitive var', () => { const out = runEnvList({ lookup: stub() }) - const hostLine = out.split('\n').find(l => l.startsWith('DIFY_HOST'))! + const hostLine = out.split('\n').find((l) => l.startsWith('DIFY_HOST'))! expect(hostLine).toContain('') }) it('text: prints actual value for set non-sensitive var', () => { const out = runEnvList({ lookup: stub({ DIFY_HOST: 'https://acme' }) }) - const hostLine = out.split('\n').find(l => l.startsWith('DIFY_HOST'))! + const hostLine = out.split('\n').find((l) => l.startsWith('DIFY_HOST'))! expect(hostLine).toContain('https://acme') }) it('text: for set sensitive var (token never echoed)', () => { const out = runEnvList({ lookup: stub({ DIFY_TOKEN: 'dfoa_secret' }) }) - const tokLine = out.split('\n').find(l => l.startsWith('DIFY_TOKEN'))! + const tokLine = out.split('\n').find((l) => l.startsWith('DIFY_TOKEN'))! expect(tokLine).toContain('') expect(tokLine).not.toContain('dfoa_secret') }) it('text: for unset sensitive var', () => { const out = runEnvList({ lookup: stub() }) - const tokLine = out.split('\n').find(l => l.startsWith('DIFY_TOKEN'))! + const tokLine = out.split('\n').find((l) => l.startsWith('DIFY_TOKEN'))! expect(tokLine).toContain('') }) it('text: rows are sorted alphabetically by name', () => { const out = runEnvList({ lookup: stub() }) - const lines = out.trim().split('\n').slice(1).map(l => l.split(/\s+/)[0]) + const lines = out + .trim() + .split('\n') + .slice(1) + .map((l) => l.split(/\s+/)[0]) const sorted = [...lines].sort() expect(lines).toEqual(sorted) }) it('json: emits array with name/description/sensitive/value fields', () => { - const out = runEnvList({ json: true, lookup: stub({ DIFY_HOST: 'https://acme', DIFY_TOKEN: 'dfoa_x' }) }) - const parsed = JSON.parse(out) as Array<{ name: string, sensitive: boolean, value: string }> + const out = runEnvList({ + json: true, + lookup: stub({ DIFY_HOST: 'https://acme', DIFY_TOKEN: 'dfoa_x' }), + }) + const parsed = JSON.parse(out) as Array<{ name: string; sensitive: boolean; value: string }> expect(parsed.length).toBeGreaterThan(0) - const host = parsed.find(r => r.name === 'DIFY_HOST')! + const host = parsed.find((r) => r.name === 'DIFY_HOST')! expect(host.sensitive).toBe(false) expect(host.value).toBe('https://acme') - const tok = parsed.find(r => r.name === 'DIFY_TOKEN')! + const tok = parsed.find((r) => r.name === 'DIFY_TOKEN')! expect(tok.sensitive).toBe(true) expect(tok.value).toBe('') }) diff --git a/cli/src/commands/env/list/run-list.ts b/cli/src/commands/env/list/run-list.ts index 9979e525e4dfc9..a45ea1b807dba6 100644 --- a/cli/src/commands/env/list/run-list.ts +++ b/cli/src/commands/env/list/run-list.ts @@ -20,7 +20,7 @@ const COLUMN_PADDING = 2 export function runEnvList(opts: RunEnvListOptions = {}): string { const lookup = opts.lookup ?? getEnv if (opts.json) { - const rows: EnvListJsonRow[] = ENV_REGISTRY.map(v => ({ + const rows: EnvListJsonRow[] = ENV_REGISTRY.map((v) => ({ name: v.name, description: v.description, sensitive: v.sensitive ?? false, @@ -29,7 +29,7 @@ export function runEnvList(opts: RunEnvListOptions = {}): string { return `${JSON.stringify(rows, null, 2)}\n` } const header: readonly string[] = ['NAME', 'VALUE', 'DESCRIPTION'] - const dataRows = ENV_REGISTRY.map(v => [ + const dataRows = ENV_REGISTRY.map((v) => [ v.name, displayValue(v.name, v.sensitive ?? false, lookup), v.description, @@ -39,21 +39,18 @@ export function runEnvList(opts: RunEnvListOptions = {}): string { function displayValue(name: string, sensitive: boolean, lookup: EnvLookup): string { const raw = lookup(name) ?? '' - if (sensitive) - return raw === '' ? '' : '' + if (sensitive) return raw === '' ? '' : '' return raw === '' ? '' : raw } function renderTable(rows: readonly (readonly string[])[]): string { - if (rows.length === 0) - return '' + if (rows.length === 0) return '' const cols = rows[0]?.length ?? 0 const widths: number[] = Array.from({ length: cols }, () => 0) for (const row of rows) { for (let i = 0; i < cols; i++) { const cell = row[i] ?? '' - if (cell.length > (widths[i] ?? 0)) - widths[i] = cell.length + if (cell.length > (widths[i] ?? 0)) widths[i] = cell.length } } let out = '' diff --git a/cli/src/commands/export/studio-app/index.ts b/cli/src/commands/export/studio-app/index.ts index 69bdcf09aa32f6..50530e2c2acc9f 100644 --- a/cli/src/commands/export/studio-app/index.ts +++ b/cli/src/commands/export/studio-app/index.ts @@ -5,7 +5,7 @@ import { agentGuide } from './guide' import { runExportApp } from './run' export default class ExportStudioApp extends DifyCommand { - static override description = 'Export a studio app\'s DSL configuration as YAML' + static override description = "Export a studio app's DSL configuration as YAML" static override examples = [ '<%= config.bin %> export studio-app ', @@ -19,28 +19,40 @@ export default class ExportStudioApp extends DifyCommand { } static override flags = { - 'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }), - 'output': Flags.string({ description: 'write DSL YAML to this file path (prints to stdout if omitted)', char: 'o' }), - 'include-secret': Flags.boolean({ description: 'include encrypted secret values in the exported DSL', default: false }), - 'workflow-id': Flags.string({ description: 'export a specific workflow by ID (workflow apps only)' }), + workspace: Flags.string({ + description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', + }), + output: Flags.string({ + description: 'write DSL YAML to this file path (prints to stdout if omitted)', + char: 'o', + }), + 'include-secret': Flags.boolean({ + description: 'include encrypted secret values in the exported DSL', + default: false, + }), + 'workflow-id': Flags.string({ + description: 'export a specific workflow by ID (workflow apps only)', + }), 'http-retry': httpRetryFlag, } async run(argv: string[]) { const { args, flags } = this.parse(ExportStudioApp, argv) const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] }) - const result = await runExportApp({ - appId: args.id, - workspace: flags.workspace, - output: flags.output, - includeSecret: flags['include-secret'], - workflowId: flags['workflow-id'], - }, { active: ctx.active, http: ctx.http, io: ctx.io }) + const result = await runExportApp( + { + appId: args.id, + workspace: flags.workspace, + output: flags.output, + includeSecret: flags['include-secret'], + workflowId: flags['workflow-id'], + }, + { active: ctx.active, http: ctx.http, io: ctx.io }, + ) if (result.writtenTo === undefined) { ctx.io.out.write(result.yaml) - if (!result.yaml.endsWith('\n')) - ctx.io.out.write('\n') + if (!result.yaml.endsWith('\n')) ctx.io.out.write('\n') } } diff --git a/cli/src/commands/export/studio-app/run.ts b/cli/src/commands/export/studio-app/run.ts index 351a2fc349fa1d..30c7ad70145487 100644 --- a/cli/src/commands/export/studio-app/run.ts +++ b/cli/src/commands/export/studio-app/run.ts @@ -30,7 +30,10 @@ export type ExportAppResult = { readonly writtenTo: string | undefined } -export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps): Promise { +export async function runExportApp( + opts: ExportAppOptions, + deps: ExportAppDeps, +): Promise { const env = deps.envLookup ?? getEnv const io = deps.io ?? nullStreams() const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h)) @@ -41,9 +44,8 @@ export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps): const client = dslFactory(deps.http) - const yaml = await runWithSpinner( - { io, label: `Exporting DSL for app ${opts.appId}` }, - () => client.exportDsl(opts.appId, { + const yaml = await runWithSpinner({ io, label: `Exporting DSL for app ${opts.appId}` }, () => + client.exportDsl(opts.appId, { includeSecret: opts.includeSecret, workflowId: opts.workflowId, }), diff --git a/cli/src/commands/get/app/handlers.ts b/cli/src/commands/get/app/handlers.ts index 9c91057d26dc33..c4d7526d1f0c65 100644 --- a/cli/src/commands/get/app/handlers.ts +++ b/cli/src/commands/get/app/handlers.ts @@ -55,11 +55,11 @@ export class AppListOutput { } tableRows(): readonly (readonly TableCell[])[] { - return this.rows.map(row => row.tableRow()) + return this.rows.map((row) => row.tableRow()) } name(): string { - return this.rows.map(row => row.name()).join('\n') + return this.rows.map((row) => row.name()).join('\n') } json(): AppListResponse { diff --git a/cli/src/commands/get/app/index.ts b/cli/src/commands/get/app/index.ts index 4deed4ade9f74c..ee0222b8972c1e 100644 --- a/cli/src/commands/get/app/index.ts +++ b/cli/src/commands/get/app/index.ts @@ -12,7 +12,7 @@ import { runGetApp } from './run' const APP_MODE_VALUES: readonly SupportedAppType[] = zSupportedAppType.options export default class GetApp extends DifyCommand { - static override description = 'List apps or describe one app\'s basic info' + static override description = "List apps or describe one app's basic info" static override examples = [ '<%= config.bin %> get app', @@ -26,34 +26,42 @@ export default class GetApp extends DifyCommand { } static override flags = { - 'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }), + workspace: Flags.string({ + description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', + }), 'all-workspaces': Flags.boolean({ char: 'A', description: 'list apps across every workspace the bearer can see', default: false, }), - 'page': Flags.integer({ description: 'page number', default: 1 }), - 'limit': Flags.string({ description: 'page size [1..200]' }), - 'mode': Flags.string({ description: 'filter by app mode', options: APP_MODE_VALUES }), - 'name': Flags.string({ description: 'filter by app name (server-side substring)' }), + page: Flags.integer({ description: 'page number', default: 1 }), + limit: Flags.string({ description: 'page size [1..200]' }), + mode: Flags.string({ description: 'filter by app mode', options: APP_MODE_VALUES }), + name: Flags.string({ description: 'filter by app name (server-side substring)' }), 'http-retry': httpRetryFlag, - 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }), + output: Flags.outputFormat({ + options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], + default: '', + }), } async run(argv: string[]) { const { args, flags } = this.parse(GetApp, argv) const format = flags.output const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) - const result = await runGetApp({ - appId: args.id, - workspace: flags.workspace, - allWorkspaces: flags['all-workspaces'], - page: flags.page, - limitRaw: flags.limit, - mode: flags.mode as SupportedAppType | undefined, - name: flags.name, - format, - }, { active: ctx.active, http: ctx.http, io: ctx.io }) + const result = await runGetApp( + { + appId: args.id, + workspace: flags.workspace, + allWorkspaces: flags['all-workspaces'], + page: flags.page, + limitRaw: flags.limit, + mode: flags.mode as SupportedAppType | undefined, + name: flags.name, + format, + }, + { active: ctx.active, http: ctx.http, io: ctx.io }, + ) return table({ format, data: result.data, diff --git a/cli/src/commands/get/app/mode-whitelist.test.ts b/cli/src/commands/get/app/mode-whitelist.test.ts index 93ecd86402c556..d058a811917468 100644 --- a/cli/src/commands/get/app/mode-whitelist.test.ts +++ b/cli/src/commands/get/app/mode-whitelist.test.ts @@ -6,8 +6,12 @@ import { describe, expect, it } from 'vitest' // rejects (rag-pipeline, channel) or modes that aren't listable here (agent). describe('get app --mode whitelist', () => { it('is exactly the listable app types', () => { - expect([...zSupportedAppType.options].sort()).toEqual( - ['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow'], - ) + expect([...zSupportedAppType.options].sort()).toEqual([ + 'advanced-chat', + 'agent-chat', + 'chat', + 'completion', + 'workflow', + ]) }) }) diff --git a/cli/src/commands/get/app/run.test.ts b/cli/src/commands/get/app/run.test.ts index d517859addd7be..469f8eb0c73574 100644 --- a/cli/src/commands/get/app/run.test.ts +++ b/cli/src/commands/get/app/run.test.ts @@ -36,10 +36,12 @@ describe('runGetApp', () => { async function render(opts: Parameters[0] = {}): Promise { const result = await runGetApp(opts, { active: baseActive, http: http() }) - return stringifyOutput(table({ - format: opts.format ?? '', - data: result.data, - })) + return stringifyOutput( + table({ + format: opts.format ?? '', + data: result.data, + }), + ) } it('list (no id, default format) renders table with NAME ID MODE UPDATED', async () => { @@ -53,7 +55,7 @@ describe('runGetApp', () => { }) it('defines table headers on the output class', () => { - expect(AppListOutput.tableColumns().map(column => column.name)).toEqual([ + expect(AppListOutput.tableColumns().map((column) => column.name)).toEqual([ 'NAME', 'ID', 'MODE', @@ -87,9 +89,9 @@ describe('runGetApp', () => { it('-o json emits parseable JSON envelope', async () => { const out = await render({ format: 'json' }) - const parsed = JSON.parse(out) as { data: Array<{ id: string }>, total: number } + const parsed = JSON.parse(out) as { data: Array<{ id: string }>; total: number } expect(parsed.data).toHaveLength(2) - expect(parsed.data.map(r => r.id).sort()).toEqual(['app-1', 'app-2']) + expect(parsed.data.map((r) => r.id).sort()).toEqual(['app-1', 'app-2']) }) it('-o yaml emits YAML envelope', async () => { @@ -110,9 +112,7 @@ describe('runGetApp', () => { }) it('rejects unknown format', async () => { - await expect(render({ format: 'bogus' })) - .rejects - .toThrow(/not supported/) + await expect(render({ format: 'bogus' })).rejects.toThrow(/not supported/) }) it('--workspace flag overrides bundle default', async () => { @@ -132,10 +132,33 @@ describe('runGetApp', () => { }) it('external login lists via permitted-external client without workspace', async () => { - const list = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 1, has_more: false, data: [{ id: 'x', name: 'X', description: null, mode: 'chat', updated_at: null, workspace_id: 'w', workspace_name: 'W' }] }) + const list = vi.fn().mockResolvedValue({ + page: 1, + limit: 20, + total: 1, + has_more: false, + data: [ + { + id: 'x', + name: 'X', + description: null, + mode: 'chat', + updated_at: null, + workspace_id: 'w', + workspace_name: 'W', + }, + ], + }) const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps') vi.spyOn(PermittedExternalAppsClient.prototype, 'list').mockImplementation(list) - const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } } + const active: ActiveContext = { + host: 'h', + email: 'e', + ctx: { + account: { id: 'a', email: 'e', name: 'n' }, + external_subject: { email: 'e', issuer: 'i' }, + }, + } const http = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient const res = await runGetApp({}, { active, http }) expect(list).toHaveBeenCalled() @@ -145,10 +168,17 @@ describe('runGetApp', () => { }) it('--all-workspaces throws UsageInvalidFlag for external logins', async () => { - const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } } + const active: ActiveContext = { + host: 'h', + email: 'e', + ctx: { + account: { id: 'a', email: 'e', name: 'n' }, + external_subject: { email: 'e', issuer: 'i' }, + }, + } const httpClient = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient - await expect(runGetApp({ allWorkspaces: true }, { active, http: httpClient })) - .rejects - .toThrow(/--all-workspaces is not available for external logins/) + await expect(runGetApp({ allWorkspaces: true }, { active, http: httpClient })).rejects.toThrow( + /--all-workspaces is not available for external logins/, + ) }) }) diff --git a/cli/src/commands/get/app/run.ts b/cli/src/commands/get/app/run.ts index 9008cfb70c5962..e05973bec5d679 100644 --- a/cli/src/commands/get/app/run.ts +++ b/cli/src/commands/get/app/run.ts @@ -1,4 +1,9 @@ -import type { AppDescribeResponse, AppListResponse, AppMode, SupportedAppType } from '@dify/contracts/api/openapi/types.gen' +import type { + AppDescribeResponse, + AppListResponse, + AppMode, + SupportedAppType, +} from '@dify/contracts/api/openapi/types.gen' import type { AppReader } from '@/api/app-reader' import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' @@ -50,50 +55,65 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise< const label = opts.appId !== undefined && opts.appId !== '' ? 'Fetching app' : 'Fetching apps' const io = deps.io ?? nullStreams() - const envelope = await runWithSpinner( - { io, label }, - async (): Promise => { - if (opts.allWorkspaces === true) { - if (external) - throw newError(ErrorCode.UsageInvalidFlag, '--all-workspaces is not available for external logins') - const ws = wsFactory(deps.http) - return runAllWorkspaces(apps, ws, opts, page, pageSize) - } - if (opts.appId !== undefined && opts.appId !== '') { - const wsId = external ? '' : resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) - const wsName = external ? '' : workspaceNameForId(deps.active, wsId) - const desc = await apps.describe(opts.appId, ['info']) - return describeToEnvelope(desc, wsId, wsName) - } - if (external) { - return apps.list({ workspaceId: '', page, limit: pageSize, mode: opts.mode, name: opts.name }) - } - const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) - return apps.list({ - workspaceId: wsId, - page, - limit: pageSize, - mode: opts.mode, - name: opts.name, - }) - }, - ) + const envelope = await runWithSpinner({ io, label }, async (): Promise => { + if (opts.allWorkspaces === true) { + if (external) + throw newError( + ErrorCode.UsageInvalidFlag, + '--all-workspaces is not available for external logins', + ) + const ws = wsFactory(deps.http) + return runAllWorkspaces(apps, ws, opts, page, pageSize) + } + if (opts.appId !== undefined && opts.appId !== '') { + const wsId = external + ? '' + : resolveWorkspaceId({ + flag: opts.workspace, + env: env('DIFY_WORKSPACE_ID'), + active: deps.active, + }) + const wsName = external ? '' : workspaceNameForId(deps.active, wsId) + const desc = await apps.describe(opts.appId, ['info']) + return describeToEnvelope(desc, wsId, wsName) + } + if (external) { + return apps.list({ workspaceId: '', page, limit: pageSize, mode: opts.mode, name: opts.name }) + } + const wsId = resolveWorkspaceId({ + flag: opts.workspace, + env: env('DIFY_WORKSPACE_ID'), + active: deps.active, + }) + return apps.list({ + workspaceId: wsId, + page, + limit: pageSize, + mode: opts.mode, + name: opts.name, + }) + }) return { - data: new AppListOutput(envelope.data.map(row => new AppRow(row)), envelope), + data: new AppListOutput( + envelope.data.map((row) => new AppRow(row)), + envelope, + ), } } function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number { - if (raw !== undefined && raw !== '') - return parseLimit(raw, '--limit') + if (raw !== undefined && raw !== '') return parseLimit(raw, '--limit') const envValue = env('DIFY_LIMIT') - if (envValue !== undefined && envValue !== '') - return parseLimit(envValue, 'DIFY_LIMIT') + if (envValue !== undefined && envValue !== '') return parseLimit(envValue, 'DIFY_LIMIT') return LIMIT_DEFAULT } -function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: string): AppListResponse { +function describeToEnvelope( + desc: AppDescribeResponse, + wsId: string, + wsName: string, +): AppListResponse { if (desc.info === null || desc.info === undefined) { return { page: 1, limit: 1, total: 0, has_more: false, data: [] } } @@ -102,21 +122,22 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str limit: 1, total: 1, has_more: false, - data: [{ - id: desc.info.id, - name: desc.info.name, - description: desc.info.description, - mode: desc.info.mode as AppMode, - updated_at: desc.info.updated_at, - workspace_id: wsId, - workspace_name: wsName === '' ? undefined : wsName, - }], + data: [ + { + id: desc.info.id, + name: desc.info.name, + description: desc.info.description, + mode: desc.info.mode as AppMode, + updated_at: desc.info.updated_at, + workspace_id: wsId, + workspace_name: wsName === '' ? undefined : wsName, + }, + ], } } function workspaceNameForId(active: ActiveContext, id: string): string { - if (id === '') - return '' + if (id === '') return '' return active.ctx.workspace?.id === id ? active.ctx.workspace.name : '' } @@ -128,8 +149,7 @@ async function runAllWorkspaces( limit: number, ): Promise { const wsResp = await ws.list() - if (wsResp.workspaces.length === 0) - return { page: 1, limit, total: 0, has_more: false, data: [] } + if (wsResp.workspaces.length === 0) return { page: 1, limit, total: 0, has_more: false, data: [] } const merged: AppListResponse = { page: 1, limit, total: 0, has_more: false, data: [] } const queue = [...wsResp.workspaces] @@ -150,8 +170,7 @@ async function runAllWorkspaces( const runner = async (): Promise => { while (true) { const next = queue.shift() - if (next === undefined) - return + if (next === undefined) return await fetchOne(next.id) } } diff --git a/cli/src/commands/get/member/handlers.ts b/cli/src/commands/get/member/handlers.ts index bd38f946370e00..29398646b9f7b5 100644 --- a/cli/src/commands/get/member/handlers.ts +++ b/cli/src/commands/get/member/handlers.ts @@ -75,11 +75,11 @@ export class MemberListOutput { } tableRows(): readonly (readonly TableCell[])[] { - return this.rows.map(row => row.tableRow()) + return this.rows.map((row) => row.tableRow()) } name(): string { - return this.rows.map(row => row.name()).join('\n') + return this.rows.map((row) => row.name()).join('\n') } json(): MemberListResponse { diff --git a/cli/src/commands/get/member/index.ts b/cli/src/commands/get/member/index.ts index 0012459533f15e..0dd66de9f5fd10 100644 --- a/cli/src/commands/get/member/index.ts +++ b/cli/src/commands/get/member/index.ts @@ -16,14 +16,17 @@ export default class GetMember extends DifyCommand { ] static override flags = { - 'workspace': Flags.string({ + workspace: Flags.string({ char: 'w', description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', }), - 'page': Flags.integer({ description: 'page number', default: 1 }), - 'limit': Flags.string({ description: 'page size [1..200]' }), + page: Flags.integer({ description: 'page number', default: 1 }), + limit: Flags.string({ description: 'page size [1..200]' }), 'http-retry': httpRetryFlag, - 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }), + output: Flags.outputFormat({ + options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], + default: '', + }), } async run(argv: string[]) { diff --git a/cli/src/commands/get/member/run.test.ts b/cli/src/commands/get/member/run.test.ts index 6993e6905e1220..c0744bc2ed9f69 100644 --- a/cli/src/commands/get/member/run.test.ts +++ b/cli/src/commands/get/member/run.test.ts @@ -43,10 +43,13 @@ describe('runGetMember', () => { membersFactory: () => client as never, }, ) - expect(client.list).toHaveBeenCalledExactlyOnceWith('550e8400-e29b-41d4-a716-446655440000', { page: 1, limit: 20 }) + expect(client.list).toHaveBeenCalledExactlyOnceWith('550e8400-e29b-41d4-a716-446655440000', { + page: 1, + limit: 20, + }) expect(r.workspaceId).toBe('550e8400-e29b-41d4-a716-446655440000') - expect(r.data.rows.map(row => row.current)).toEqual([true, false]) - expect(r.data.rows.map(row => row.id)).toEqual(['acct-1', 'acct-2']) + expect(r.data.rows.map((row) => row.current)).toEqual([true, false]) + expect(r.data.rows.map((row) => row.id)).toEqual(['acct-1', 'acct-2']) }) it('-w flag overrides resolved workspace', async () => { @@ -60,7 +63,10 @@ describe('runGetMember', () => { membersFactory: () => client as never, }, ) - expect(client.list).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440008', { page: 1, limit: 20 }) + expect(client.list).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440008', { + page: 1, + limit: 20, + }) expect(r.workspaceId).toBe('550e8400-e29b-41d4-a716-446655440008') }) @@ -75,7 +81,10 @@ describe('runGetMember', () => { membersFactory: () => client as never, }, ) - expect(client.list).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440000', { page: 3, limit: 50 }) + expect(client.list).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440000', { + page: 3, + limit: 50, + }) }) it('marks no row when active context has no account id', async () => { @@ -97,7 +106,7 @@ describe('runGetMember', () => { membersFactory: () => client as never, }, ) - expect(r.data.rows.every(row => !row.current)).toBe(true) + expect(r.data.rows.every((row) => !row.current)).toBe(true) }) it('throws when no workspace can be resolved', async () => { @@ -144,7 +153,7 @@ describe('MemberListOutput shape', () => { membersFactory: () => client as never, }, ) - expect(r.data.tableColumns().map(c => c.name)).toEqual([ + expect(r.data.tableColumns().map((c) => c.name)).toEqual([ 'ID', 'NAME', 'EMAIL', diff --git a/cli/src/commands/get/member/run.ts b/cli/src/commands/get/member/run.ts index 201df51b00115c..4da1f4cb1296fc 100644 --- a/cli/src/commands/get/member/run.ts +++ b/cli/src/commands/get/member/run.ts @@ -45,21 +45,18 @@ export async function runGetMember( const limit = resolveLimit(opts.limitRaw, env) const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page - const envelope = await runWithSpinner( - { io, label: 'Fetching members' }, - () => factory(deps.http).list(wsId, { page, limit }), + const envelope = await runWithSpinner({ io, label: 'Fetching members' }, () => + factory(deps.http).list(wsId, { page, limit }), ) const callerId = deps.active.ctx.account?.id ?? '' - const rows = envelope.data.map(m => new MemberRow(m, callerId !== '' && m.id === callerId)) + const rows = envelope.data.map((m) => new MemberRow(m, callerId !== '' && m.id === callerId)) return { data: new MemberListOutput(rows, envelope), workspaceId: wsId } } function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number { - if (raw !== undefined && raw !== '') - return parseLimit(raw, '--limit') + if (raw !== undefined && raw !== '') return parseLimit(raw, '--limit') const envValue = env('DIFY_LIMIT') - if (envValue !== undefined && envValue !== '') - return parseLimit(envValue, 'DIFY_LIMIT') + if (envValue !== undefined && envValue !== '') return parseLimit(envValue, 'DIFY_LIMIT') return LIMIT_DEFAULT } diff --git a/cli/src/commands/get/workspace/handlers.test.ts b/cli/src/commands/get/workspace/handlers.test.ts index bfab5321d30b5a..2e744c8ce9d77d 100644 --- a/cli/src/commands/get/workspace/handlers.test.ts +++ b/cli/src/commands/get/workspace/handlers.test.ts @@ -6,7 +6,13 @@ function env(): WorkspaceListResponse { return { workspaces: [ { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, - { id: '00000000-0000-0000-0000-000000000002', name: 'Other', role: 'normal', status: 'normal', current: false }, + { + id: '00000000-0000-0000-0000-000000000002', + name: 'Other', + role: 'normal', + status: 'normal', + current: false, + }, ], } } @@ -16,13 +22,25 @@ describe('get/workspace handlers', () => { const row = new WorkspaceRow('ws-1', 'Default', 'owner', 'normal', true) expect(row.tableRow()).toEqual(['ws-1', 'Default', 'owner', 'normal', '*']) expect(row.name()).toBe('ws-1') - expect(row.json()).toEqual({ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }) + expect(row.json()).toEqual({ + id: 'ws-1', + name: 'Default', + role: 'owner', + status: 'normal', + current: true, + }) }) it('WorkspaceListOutput defines cohesive print behavior', () => { const row = new WorkspaceRow('ws-1', 'Default', 'owner', 'normal', true) const output = new WorkspaceListOutput([row], env()) - expect(output.tableColumns().map(column => column.name)).toEqual(['ID', 'NAME', 'ROLE', 'STATUS', 'CURRENT']) + expect(output.tableColumns().map((column) => column.name)).toEqual([ + 'ID', + 'NAME', + 'ROLE', + 'STATUS', + 'CURRENT', + ]) expect(output.tableRows()).toEqual([['ws-1', 'Default', 'owner', 'normal', '*']]) expect(output.name()).toBe('ws-1') expect(output.json().workspaces).toHaveLength(2) diff --git a/cli/src/commands/get/workspace/handlers.ts b/cli/src/commands/get/workspace/handlers.ts index 64e34eee5dcce9..9a2e1cdbbc1e76 100644 --- a/cli/src/commands/get/workspace/handlers.ts +++ b/cli/src/commands/get/workspace/handlers.ts @@ -18,13 +18,7 @@ export class WorkspaceRow { readonly status: string readonly current: boolean - constructor( - id: string, - displayName: string, - role: string, - status: string, - current: boolean, - ) { + constructor(id: string, displayName: string, role: string, status: string, current: boolean) { this.id = id this.displayName = displayName this.role = role @@ -33,13 +27,7 @@ export class WorkspaceRow { } tableRow(): readonly TableCell[] { - return [ - this.id, - this.displayName, - this.role, - this.status, - this.current ? CURRENT_MARKER : '', - ] + return [this.id, this.displayName, this.role, this.status, this.current ? CURRENT_MARKER : ''] } name(): string { @@ -75,11 +63,11 @@ export class WorkspaceListOutput { } tableRows(): readonly (readonly TableCell[])[] { - return this.rows.map(row => row.tableRow()) + return this.rows.map((row) => row.tableRow()) } name(): string { - return this.rows.map(row => row.name()).join('\n') + return this.rows.map((row) => row.name()).join('\n') } json(): WorkspaceListResponse { diff --git a/cli/src/commands/get/workspace/index.ts b/cli/src/commands/get/workspace/index.ts index 3f6f52e692d5ce..4f6e1398815bd3 100644 --- a/cli/src/commands/get/workspace/index.ts +++ b/cli/src/commands/get/workspace/index.ts @@ -15,16 +15,21 @@ export default class GetWorkspace extends DifyCommand { static override flags = { 'http-retry': httpRetryFlag, - 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }), + output: Flags.outputFormat({ + options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], + default: '', + }), } async run(argv: string[]) { const { flags } = this.parse(GetWorkspace, argv) const format = flags.output const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) - const result = await runGetWorkspace({ format }, { active: ctx.active, http: ctx.http, io: ctx.io }) - if (result.kind === 'empty') - return raw(result.message) + const result = await runGetWorkspace( + { format }, + { active: ctx.active, http: ctx.http, io: ctx.io }, + ) + if (result.kind === 'empty') return raw(result.message) return table({ format, data: result.data, diff --git a/cli/src/commands/get/workspace/run.test.ts b/cli/src/commands/get/workspace/run.test.ts index cf5238af3a4e55..a2902d6f2dba24 100644 --- a/cli/src/commands/get/workspace/run.test.ts +++ b/cli/src/commands/get/workspace/run.test.ts @@ -34,12 +34,13 @@ describe('runGetWorkspace', () => { async function render(format = '', activeCtx = baseActive): Promise { const result = await runGetWorkspace({ format }, { active: activeCtx, http: http() }) - if (result.kind === 'empty') - return result.message - return stringifyOutput(table({ - format, - data: result.data, - })) + if (result.kind === 'empty') return result.message + return stringifyOutput( + table({ + format, + data: result.data, + }), + ) } it('default format renders ID NAME ROLE STATUS CURRENT table', async () => { @@ -53,7 +54,7 @@ describe('runGetWorkspace', () => { }) it('defines table headers on the output class', () => { - expect(WorkspaceListOutput.tableColumns().map(column => column.name)).toEqual([ + expect(WorkspaceListOutput.tableColumns().map((column) => column.name)).toEqual([ 'ID', 'NAME', 'ROLE', @@ -65,27 +66,36 @@ describe('runGetWorkspace', () => { it('marks the current workspace with *', async () => { const out = await render() for (const line of out.split('\n')) { - if (line.includes('550e8400-e29b-41d4-a716-446655440000')) - expect(line).toContain('*') + if (line.includes('550e8400-e29b-41d4-a716-446655440000')) expect(line).toContain('*') else if (line.includes('550e8400-e29b-41d4-a716-446655440001')) expect(line).not.toContain('*') } }) it('falls back to active context workspace.id when server current=false', async () => { - const overridden: ActiveContext = { ...baseActive, ctx: { ...baseActive.ctx, workspace: { id: '550e8400-e29b-41d4-a716-446655440001', name: 'Other', role: 'normal' } } } + const overridden: ActiveContext = { + ...baseActive, + ctx: { + ...baseActive.ctx, + workspace: { id: '550e8400-e29b-41d4-a716-446655440001', name: 'Other', role: 'normal' }, + }, + } const out = await render('', overridden) for (const line of out.split('\n')) { - if (line.includes('550e8400-e29b-41d4-a716-446655440001')) - expect(line).toContain('*') + if (line.includes('550e8400-e29b-41d4-a716-446655440001')) expect(line).toContain('*') } }) it('-o json emits a parseable workspaces envelope', async () => { const out = await render('json') - const parsed = JSON.parse(out) as { workspaces: Array<{ id: string, status: string, current: boolean }> } + const parsed = JSON.parse(out) as { + workspaces: Array<{ id: string; status: string; current: boolean }> + } expect(parsed.workspaces).toHaveLength(2) - expect(parsed.workspaces.map(w => w.id).sort()).toEqual(['550e8400-e29b-41d4-a716-446655440000', '550e8400-e29b-41d4-a716-446655440001']) + expect(parsed.workspaces.map((w) => w.id).sort()).toEqual([ + '550e8400-e29b-41d4-a716-446655440000', + '550e8400-e29b-41d4-a716-446655440001', + ]) expect(parsed.workspaces[0]?.status).toBe('normal') expect(parsed.workspaces[0]?.current).toBe(true) }) @@ -98,7 +108,10 @@ describe('runGetWorkspace', () => { it('-o name emits ids joined by newline', async () => { const out = await render('name') - expect(out.trim().split('\n').sort()).toEqual(['550e8400-e29b-41d4-a716-446655440000', '550e8400-e29b-41d4-a716-446655440001']) + expect(out.trim().split('\n').sort()).toEqual([ + '550e8400-e29b-41d4-a716-446655440000', + '550e8400-e29b-41d4-a716-446655440001', + ]) }) it('empty workspaces (sso scenario) prints external-SSO message regardless of format', async () => { @@ -110,8 +123,6 @@ describe('runGetWorkspace', () => { }) it('rejects unknown -o format', async () => { - await expect(render('csv')) - .rejects - .toThrow(/csv|not supported|format/i) + await expect(render('csv')).rejects.toThrow(/csv|not supported|format/i) }) }) diff --git a/cli/src/commands/get/workspace/run.ts b/cli/src/commands/get/workspace/run.ts index 2d678bf0da02dd..1232cd0aca001d 100644 --- a/cli/src/commands/get/workspace/run.ts +++ b/cli/src/commands/get/workspace/run.ts @@ -6,8 +6,8 @@ import { runWithSpinner } from '@/sys/io/spinner' import { nullStreams } from '@/sys/io/streams' import { WorkspaceListOutput, WorkspaceRow } from './handlers.js' -export const EMPTY_WORKSPACES_MESSAGE - = 'No workspaces visible to this bearer (external-SSO subjects see empty data).\n' +export const EMPTY_WORKSPACES_MESSAGE = + 'No workspaces visible to this bearer (external-SSO subjects see empty data).\n' export type GetWorkspaceOptions = { readonly format?: string @@ -20,28 +20,35 @@ export type GetWorkspaceDeps = { readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient } -export type GetWorkspaceResult - = | { readonly kind: 'empty', readonly message: string } - | { readonly kind: 'output', readonly data: WorkspaceListOutput } +export type GetWorkspaceResult = + | { readonly kind: 'empty'; readonly message: string } + | { readonly kind: 'output'; readonly data: WorkspaceListOutput } -export async function runGetWorkspace(opts: GetWorkspaceOptions, deps: GetWorkspaceDeps): Promise { +export async function runGetWorkspace( + opts: GetWorkspaceOptions, + deps: GetWorkspaceDeps, +): Promise { const wsFactory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h)) const io = deps.io ?? nullStreams() - const env = await runWithSpinner( - { io, label: 'Fetching workspaces' }, - () => wsFactory(deps.http).list(), + const env = await runWithSpinner({ io, label: 'Fetching workspaces' }, () => + wsFactory(deps.http).list(), ) - if (env.workspaces.length === 0) - return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE } + if (env.workspaces.length === 0) return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE } const currentId = deps.active.ctx.workspace?.id ?? '' return { kind: 'output', - data: new WorkspaceListOutput(env.workspaces.map(w => new WorkspaceRow( - w.id, - w.name, - w.role, - w.status, - w.current || (currentId !== '' && w.id === currentId), - )), env), + data: new WorkspaceListOutput( + env.workspaces.map( + (w) => + new WorkspaceRow( + w.id, + w.name, + w.role, + w.status, + w.current || (currentId !== '' && w.id === currentId), + ), + ), + env, + ), } } diff --git a/cli/src/commands/import/studio-app/index.ts b/cli/src/commands/import/studio-app/index.ts index 6e0b491199bfc4..a3f9c51d0037fd 100644 --- a/cli/src/commands/import/studio-app/index.ts +++ b/cli/src/commands/import/studio-app/index.ts @@ -15,14 +15,21 @@ export default class ImportStudioApp extends DifyCommand { ] static override flags = { - 'from-file': Flags.string({ description: 'import DSL from a local file (relative or absolute path)', char: 'f' }), + 'from-file': Flags.string({ + description: 'import DSL from a local file (relative or absolute path)', + char: 'f', + }), 'from-url': Flags.string({ description: 'import DSL from an HTTP(S) URL' }), - 'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }), - 'name': Flags.string({ description: 'override the app name from the DSL' }), - 'description': Flags.string({ description: 'override the app description from the DSL' }), - 'app-id': Flags.string({ description: 'overwrite an existing app (workflow/advanced-chat only)' }), + workspace: Flags.string({ + description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', + }), + name: Flags.string({ description: 'override the app name from the DSL' }), + description: Flags.string({ description: 'override the app description from the DSL' }), + 'app-id': Flags.string({ + description: 'overwrite an existing app (workflow/advanced-chat only)', + }), 'icon-type': Flags.string({ description: 'override icon type' }), - 'icon': Flags.string({ description: 'override icon' }), + icon: Flags.string({ description: 'override icon' }), 'icon-background': Flags.string({ description: 'override icon background colour' }), 'http-retry': httpRetryFlag, } @@ -34,28 +41,33 @@ export default class ImportStudioApp extends DifyCommand { if (flags['from-file'] !== undefined && flags['from-url'] !== undefined) this.error('--from-file and --from-url are mutually exclusive', { exit: 1 }) const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] }) - const { result, leakedDependencies } = await runImportApp({ - fromFile: flags['from-file'], - fromUrl: flags['from-url'], - workspace: flags.workspace, - name: flags.name, - description: flags.description, - appId: flags['app-id'], - iconType: flags['icon-type'], - icon: flags.icon, - iconBackground: flags['icon-background'], - }, { active: ctx.active, http: ctx.http, io: ctx.io }) + const { result, leakedDependencies } = await runImportApp( + { + fromFile: flags['from-file'], + fromUrl: flags['from-url'], + workspace: flags.workspace, + name: flags.name, + description: flags.description, + appId: flags['app-id'], + iconType: flags['icon-type'], + icon: flags.icon, + iconBackground: flags['icon-background'], + }, + { active: ctx.active, http: ctx.http, io: ctx.io }, + ) - const status = result.status === 'completed-with-warnings' ? 'completed (with warnings)' : result.status + const status = + result.status === 'completed-with-warnings' ? 'completed (with warnings)' : result.status ctx.io.err.write(`Import ${status}`) if (result.app_id !== undefined && result.app_id !== null) ctx.io.err.write(`: app ${result.app_id}`) ctx.io.err.write('\n') if (leakedDependencies.length > 0) { - ctx.io.err.write(`\nMissing plugin dependencies (${leakedDependencies.length}); install them before using the app:\n`) - for (const dep of leakedDependencies) - ctx.io.err.write(` - ${pluginDependencyLabel(dep)}\n`) + ctx.io.err.write( + `\nMissing plugin dependencies (${leakedDependencies.length}); install them before using the app:\n`, + ) + for (const dep of leakedDependencies) ctx.io.err.write(` - ${pluginDependencyLabel(dep)}\n`) } } diff --git a/cli/src/commands/import/studio-app/run.test.ts b/cli/src/commands/import/studio-app/run.test.ts index c37e00c5376e1f..85c2f750060cd1 100644 --- a/cli/src/commands/import/studio-app/run.test.ts +++ b/cli/src/commands/import/studio-app/run.test.ts @@ -48,7 +48,10 @@ describe('runImportApp', () => { it('completes import from a local file path', async () => { const dslFile = tmpDslFile() - const { result } = await runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() }) + const { result } = await runImportApp( + { fromFile: dslFile }, + { active: baseActive, http: http() }, + ) expect(result.status).toBe('completed') expect(result.app_id).toBe('app-1') @@ -108,49 +111,61 @@ describe('runImportApp', () => { it('uses workspace from --workspace flag over context default', async () => { const dslFile = tmpDslFile() - await runImportApp( - { fromFile: dslFile, workspace: ZERO }, - { active: baseActive, http: http() }, - ) + await runImportApp({ fromFile: dslFile, workspace: ZERO }, { active: baseActive, http: http() }) expect(mock.lastImportBody).not.toBeNull() }) it('throws UsageInvalidFlag when fromFile path does not exist', async () => { await expect( - runImportApp({ fromFile: '/tmp/difyctl-no-such-file-ever.yaml' }, { active: baseActive, http: http() }), + runImportApp( + { fromFile: '/tmp/difyctl-no-such-file-ever.yaml' }, + { active: baseActive, http: http() }, + ), ).rejects.toThrow('file not found') }) it('throws UsageInvalidFlag when both fromFile and fromUrl are given', async () => { const dslFile = tmpDslFile() await expect( - runImportApp({ fromFile: dslFile, fromUrl: 'https://example.com/app.yaml' }, { active: baseActive, http: http() }), + runImportApp( + { fromFile: dslFile, fromUrl: 'https://example.com/app.yaml' }, + { active: baseActive, http: http() }, + ), ).rejects.toThrow('mutually exclusive') }) it('throws UsageInvalidFlag when neither fromFile nor fromUrl is given', async () => { - await expect( - runImportApp({}, { active: baseActive, http: http() }), - ).rejects.toThrow('required') + await expect(runImportApp({}, { active: baseActive, http: http() })).rejects.toThrow('required') }) it('returns empty leakedDependencies when the app has no missing plugins', async () => { const dslFile = tmpDslFile() - const { leakedDependencies } = await runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() }) + const { leakedDependencies } = await runImportApp( + { fromFile: dslFile }, + { active: baseActive, http: http() }, + ) expect(leakedDependencies).toEqual([]) }) it('surfaces leaked dependencies reported by check-dependencies', async () => { const dslFile = tmpDslFile() - const completed: Import = { id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' } + const completed: Import = { + id: 'imp-1', + status: 'completed', + app_id: 'app-1', + app_mode: 'chat', + } const stub = Object.assign(Object.create(AppDslClient.prototype), { importApp: async () => completed, confirmImport: async () => completed, checkDependencies: async () => ({ leaked_dependencies: [ - { type: 'marketplace', value: { marketplace_plugin_unique_identifier: 'langgenius/openai:0.0.1' } }, + { + type: 'marketplace', + value: { marketplace_plugin_unique_identifier: 'langgenius/openai:0.0.1' }, + }, ], }), }) as AppDslClient @@ -162,8 +177,7 @@ describe('runImportApp', () => { expect(leakedDependencies).toHaveLength(1) const [dep] = leakedDependencies - if (dep === undefined) - throw new Error('expected one leaked dependency') + if (dep === undefined) throw new Error('expected one leaked dependency') expect(pluginDependencyLabel(dep)).toBe('langgenius/openai:0.0.1') }) }) @@ -178,12 +192,17 @@ describe('pluginDependencyLabel', () => { }) it('reads the package plugin identifier', () => { - const label = pluginDependencyLabel({ type: 'package', value: { plugin_unique_identifier: 'pkg:2.0.0' } }) + const label = pluginDependencyLabel({ + type: 'package', + value: { plugin_unique_identifier: 'pkg:2.0.0' }, + }) expect(label).toBe('pkg:2.0.0') }) it('falls back to current_identifier then a placeholder', () => { - expect(pluginDependencyLabel({ type: 'package', value: {}, current_identifier: 'fallback' })).toBe('fallback') + expect( + pluginDependencyLabel({ type: 'package', value: {}, current_identifier: 'fallback' }), + ).toBe('fallback') expect(pluginDependencyLabel({ type: 'package', value: null })).toBe('') }) }) diff --git a/cli/src/commands/import/studio-app/run.ts b/cli/src/commands/import/studio-app/run.ts index 8a04c6d2bc5b9f..fe0f9b7163ddb2 100644 --- a/cli/src/commands/import/studio-app/run.ts +++ b/cli/src/commands/import/studio-app/run.ts @@ -42,12 +42,19 @@ type PluginDependencyLabelInput = { readonly value?: unknown } -export async function runImportApp(opts: ImportAppOptions, deps: ImportAppDeps): Promise { +export async function runImportApp( + opts: ImportAppOptions, + deps: ImportAppDeps, +): Promise { const env = deps.envLookup ?? getEnv const io = deps.io ?? nullStreams() const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h)) - const workspaceId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) + const workspaceId = resolveWorkspaceId({ + flag: opts.workspace, + env: env('DIFY_WORKSPACE_ID'), + active: deps.active, + }) const client = dslFactory(deps.http) if (opts.fromFile !== undefined && opts.fromUrl !== undefined) @@ -61,25 +68,21 @@ export async function runImportApp(opts: ImportAppOptions, deps: ImportAppDeps): mode = 'yaml-content' try { yamlContent = fs.readFileSync(opts.fromFile, 'utf8') - } - catch (err) { + } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code === 'ENOENT') throw newError(ErrorCode.UsageInvalidFlag, `--from-file: file not found: ${opts.fromFile}`) throw err } - } - else if (opts.fromUrl !== undefined) { + } else if (opts.fromUrl !== undefined) { mode = 'yaml-url' yamlUrl = opts.fromUrl - } - else { + } else { throw newError(ErrorCode.UsageInvalidFlag, 'one of --from-file or --from-url is required') } - let result = await runWithSpinner( - { io, label: 'Importing app DSL' }, - () => client.importApp(workspaceId, { + let result = await runWithSpinner({ io, label: 'Importing app DSL' }, () => + client.importApp(workspaceId, { mode, yaml_content: yamlContent, yaml_url: yamlUrl, @@ -102,10 +105,11 @@ export async function runImportApp(opts: ImportAppOptions, deps: ImportAppDeps): // DSL version mismatch: the server needs an explicit acknowledgement before // finalising. Auto-confirm here so the user does not need a second command. if (result.status === 'pending') { - io.err.write(`note: DSL version mismatch (imported ${result.imported_dsl_version ?? '?'}, current ${result.current_dsl_version ?? '?'}); confirming automatically\n`) - result = await runWithSpinner( - { io, label: 'Confirming import' }, - () => client.confirmImport(workspaceId, result.id), + io.err.write( + `note: DSL version mismatch (imported ${result.imported_dsl_version ?? '?'}, current ${result.current_dsl_version ?? '?'}); confirming automatically\n`, + ) + result = await runWithSpinner({ io, label: 'Confirming import' }, () => + client.confirmImport(workspaceId, result.id), ) } @@ -117,8 +121,7 @@ export async function runImportApp(opts: ImportAppOptions, deps: ImportAppDeps): } const appId = result.app_id - if (appId === undefined || appId === null) - return { result, leakedDependencies: [] } + if (appId === undefined || appId === null) return { result, leakedDependencies: [] } const { leaked_dependencies } = await runWithSpinner( { io, label: 'Checking plugin dependencies' }, @@ -134,11 +137,11 @@ export function pluginDependencyLabel(dep: PluginDependencyLabelInput): string { const value = dep.value if (typeof value === 'object' && value !== null) { const fields = value as Record - const id = fields.marketplace_plugin_unique_identifier - ?? fields.github_plugin_unique_identifier - ?? fields.plugin_unique_identifier - if (typeof id === 'string' && id !== '') - return id + const id = + fields.marketplace_plugin_unique_identifier ?? + fields.github_plugin_unique_identifier ?? + fields.plugin_unique_identifier + if (typeof id === 'string' && id !== '') return id } return dep.current_identifier ?? '' } diff --git a/cli/src/commands/resume/app/index.ts b/cli/src/commands/resume/app/index.ts index c4bbfbf2f56cd6..e697a2d070dc46 100644 --- a/cli/src/commands/resume/app/index.ts +++ b/cli/src/commands/resume/app/index.ts @@ -22,15 +22,39 @@ export default class ResumeApp extends DifyCommand { } static override flags = { - 'workflow-run-id': Flags.string({ description: 'workflow_run_id from the HITL pause JSON', required: true }), - 'action': Flags.string({ description: 'user action id (auto-selected when form has exactly one action)' }), - 'inputs': Flags.string({ description: 'Input variables as a JSON object, e.g. --inputs \'{"key":"value"}\'. Mutually exclusive with --inputs-file.' }), - 'inputs-file': Flags.string({ description: 'Path to a JSON file containing the inputs object. Mutually exclusive with --inputs.' }), - 'workspace': Flags.string({ description: 'workspace id override' }), - 'with-history': Flags.boolean({ description: 'Replay executed-node history before attaching to live stream.', default: false }), - 'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive. Default: collect and print at end.', default: false }), - 'think': Flags.boolean({ description: 'Show model thinking/reasoning when available — both inline ... blocks and separated reasoning streams. Hidden by default; with --think, thinking is printed to stderr.', default: false }), - 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }), + 'workflow-run-id': Flags.string({ + description: 'workflow_run_id from the HITL pause JSON', + required: true, + }), + action: Flags.string({ + description: 'user action id (auto-selected when form has exactly one action)', + }), + inputs: Flags.string({ + description: + 'Input variables as a JSON object, e.g. --inputs \'{"key":"value"}\'. Mutually exclusive with --inputs-file.', + }), + 'inputs-file': Flags.string({ + description: + 'Path to a JSON file containing the inputs object. Mutually exclusive with --inputs.', + }), + workspace: Flags.string({ description: 'workspace id override' }), + 'with-history': Flags.boolean({ + description: 'Replay executed-node history before attaching to live stream.', + default: false, + }), + stream: Flags.boolean({ + description: 'Print output live as tokens/events arrive. Default: collect and print at end.', + default: false, + }), + think: Flags.boolean({ + description: + 'Show model thinking/reasoning when available — both inline ... blocks and separated reasoning streams. Hidden by default; with --think, thinking is printed to stderr.', + default: false, + }), + output: Flags.outputFormat({ + options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], + default: '', + }), 'http-retry': httpRetryFlag, } diff --git a/cli/src/commands/resume/app/run.test.ts b/cli/src/commands/resume/app/run.test.ts index 8e1882906aefa5..5c044636fb9b9b 100644 --- a/cli/src/commands/resume/app/run.test.ts +++ b/cli/src/commands/resume/app/run.test.ts @@ -7,7 +7,15 @@ import { bufferStreams } from '@/sys/io/streams' import { resumeApp } from './run.js' const DESCRIBE_RESULT = { - info: { id: 'app-2', name: 'X', mode: 'workflow', description: '', updated_at: null, service_api_enabled: true, is_agent: false }, + info: { + id: 'app-2', + name: 'X', + mode: 'workflow', + description: '', + updated_at: null, + service_api_enabled: true, + is_agent: false, + }, parameters: null, input_schema: null, } @@ -32,7 +40,9 @@ afterEach(() => { describe('resumeApp pre-flight subject strategy', () => { it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => { const externalDescribe = vi.fn().mockResolvedValue(DESCRIBE_RESULT) - const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe) + const externalSpy = vi + .spyOn(PermittedExternalAppsClient.prototype, 'describe') + .mockImplementation(externalDescribe) const accountSpy = vi.spyOn(AppsClient.prototype, 'describe') vi.spyOn(AppRunClient.prototype, 'submitHumanInput').mockResolvedValue(undefined as never) @@ -45,18 +55,27 @@ describe('resumeApp pre-flight subject strategy', () => { return Promise.resolve(FORM_RESP) } // reconnect stream — return an async iterable that ends immediately - const iter: AsyncIterable = { [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ done: true, value: undefined as never }) }) } + const iter: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => Promise.resolve({ done: true, value: undefined as never }), + }), + } return Promise.resolve(iter) }), } as unknown as import('@/http/types').HttpClient try { await resumeApp( - { appId: 'app-2', formToken: 'ft-1', workflowRunId: 'wf-run-1', action: 'submit', inputs: {} }, + { + appId: 'app-2', + formToken: 'ft-1', + workflowRunId: 'wf-run-1', + action: 'submit', + inputs: {}, + }, { active: makeExternalActive(), http, host: 'http://localhost', io }, ) - } - catch { + } catch { // run may fail after pre-flight due to stream mock; we only check which describe was called } diff --git a/cli/src/commands/resume/app/run.ts b/cli/src/commands/resume/app/run.ts index 0610f68d5e6b1c..eda641528917d3 100644 --- a/cli/src/commands/resume/app/run.ts +++ b/cli/src/commands/resume/app/run.ts @@ -54,11 +54,9 @@ export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Pr ) if (formResp.user_actions.length === 1) { action = formResp.user_actions[0]?.id ?? '' - } - else if (formResp.user_actions.length === 0) { + } else if (formResp.user_actions.length === 0) { action = '' - } - else { + } else { throw new Error('--action required: form has multiple user actions') } } diff --git a/cli/src/commands/run/app/_strategies/streaming-structured.ts b/cli/src/commands/run/app/_strategies/streaming-structured.ts index de39bfda2e511d..ba85ff9e090022 100644 --- a/cli/src/commands/run/app/_strategies/streaming-structured.ts +++ b/cli/src/commands/run/app/_strategies/streaming-structured.ts @@ -1,7 +1,12 @@ import type { RunContext, RunStrategy } from './index' import type { SseEvent } from '@/http/sse' import { buildRunBody } from '@/api/app-run' -import { CHAT_MODES, chatConversationHint, newAppRunObject, RUN_MODES } from '@/commands/run/app/handlers' +import { + CHAT_MODES, + chatConversationHint, + newAppRunObject, + RUN_MODES, +} from '@/commands/run/app/handlers' import { renderHitlHint, renderHitlOutput } from '@/commands/run/app/hitl-render' import { collect, HitlPauseError } from '@/commands/run/app/sse-collector' import { formatted, stringifyOutput } from '@/framework/output' @@ -20,10 +25,10 @@ async function* captureTaskId( if (ev.data.byteLength > 0) { try { const parsed = JSON.parse(dec.decode(ev.data)) as Record - if (typeof parsed.task_id === 'string' && parsed.task_id !== '') - onCapture(parsed.task_id) + if (typeof parsed.task_id === 'string' && parsed.task_id !== '') onCapture(parsed.task_id) + } catch { + /* ignore parse errors */ } - catch { /* ignore parse errors */ } } yield ev } @@ -41,13 +46,16 @@ export class StreamingStructuredStrategy implements RunStrategy { workflowId: opts.workflowId, }) - const spinner = startSpinner({ io: deps.io, label: 'running', enabled: ctx.isText && !ctx.livePrint }) + const spinner = startSpinner({ + io: deps.io, + label: 'running', + enabled: ctx.isText && !ctx.livePrint, + }) let taskId: string | undefined const cleanup = () => { spinner.stop() - if (taskId !== undefined) - void ctx.runClient.stopTask(opts.appId, taskId).catch(() => {}) + if (taskId !== undefined) void ctx.runClient.stopTask(opts.appId, taskId).catch(() => {}) ctrl.abort() exit(1) } @@ -55,13 +63,15 @@ export class StreamingStructuredStrategy implements RunStrategy { let resp: Record try { - const events = await ctx.runClient.runStream(opts.appId, body, { signal: ctrl.signal, retryOnRateLimit: opts.retryOnRateLimit }) + const events = await ctx.runClient.runStream(opts.appId, body, { + signal: ctrl.signal, + retryOnRateLimit: opts.retryOnRateLimit, + }) const wrappedEvents = captureTaskId(events, (id) => { taskId = id }) resp = await collect(wrappedEvents, mode) - } - catch (err) { + } catch (err) { ctrl.abort() if (err instanceof HitlPauseError) { spinner.stop() @@ -70,8 +80,7 @@ export class StreamingStructuredStrategy implements RunStrategy { exit(0) } throw err - } - finally { + } finally { spinner.stop() unhandle('SIGINT', cleanup) } @@ -79,23 +88,25 @@ export class StreamingStructuredStrategy implements RunStrategy { if (typeof processedResp.answer === 'string') { if (ctx.think) { const { clean, thinking } = extractThinkBlocks(processedResp.answer) - if (thinking !== '') - deps.io.err.write(`${thinking}\n`) + if (thinking !== '') deps.io.err.write(`${thinking}\n`) processedResp = { ...processedResp, answer: clean } - } - else { + } else { processedResp = { ...processedResp, answer: stripThinkBlocks(processedResp.answer) } } - } - else if (mode === RUN_MODES.Workflow) { + } else if (mode === RUN_MODES.Workflow) { const data = processedResp.data if (data !== null && typeof data === 'object' && 'outputs' in data) { const raw = (data as { outputs: unknown }).outputs if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) { - const { outputs, thinking } = filterThinkInOutputs(raw as Record, ctx.think) - if (ctx.think && thinking !== '') - deps.io.err.write(`${thinking}\n`) - processedResp = { ...processedResp, data: { ...(data as Record), outputs } } + const { outputs, thinking } = filterThinkInOutputs( + raw as Record, + ctx.think, + ) + if (ctx.think && thinking !== '') deps.io.err.write(`${thinking}\n`) + processedResp = { + ...processedResp, + data: { ...(data as Record), outputs }, + } } } } @@ -103,17 +114,20 @@ export class StreamingStructuredStrategy implements RunStrategy { // Surface separated-mode reasoning (carried in message_end metadata) to stderr under --think. if (ctx.think) { const reasoningBlocks = reasoningBlocksFromMetadata(processedResp.metadata) - if (reasoningBlocks !== '') - deps.io.err.write(`${reasoningBlocks}\n`) + if (reasoningBlocks !== '') deps.io.err.write(`${reasoningBlocks}\n`) } - const respMode = typeof processedResp.mode === 'string' && processedResp.mode !== '' ? processedResp.mode : mode - deps.io.out.write(stringifyOutput(formatted({ format, data: newAppRunObject(respMode, processedResp) }))) + const respMode = + typeof processedResp.mode === 'string' && processedResp.mode !== '' + ? processedResp.mode + : mode + deps.io.out.write( + stringifyOutput(formatted({ format, data: newAppRunObject(respMode, processedResp) })), + ) if (isText && CHAT_MODES.has(respMode)) { const cs = colorScheme(colorEnabled(deps.io.isErrTTY)) const hint = chatConversationHint(processedResp, cs) - if (hint !== undefined) - deps.io.err.write(hint) + if (hint !== undefined) deps.io.err.write(hint) } } } diff --git a/cli/src/commands/run/app/_strategies/streaming-text.ts b/cli/src/commands/run/app/_strategies/streaming-text.ts index 66b2c2a88ec681..c5e7c7bc15a68c 100644 --- a/cli/src/commands/run/app/_strategies/streaming-text.ts +++ b/cli/src/commands/run/app/_strategies/streaming-text.ts @@ -19,8 +19,7 @@ export class StreamingTextStrategy implements RunStrategy { let taskId: string | undefined const cleanup = () => { - if (taskId !== undefined) - void ctx.runClient.stopTask(opts.appId, taskId).catch(() => {}) + if (taskId !== undefined) void ctx.runClient.stopTask(opts.appId, taskId).catch(() => {}) ctrl.abort() exit(1) } @@ -28,28 +27,31 @@ export class StreamingTextStrategy implements RunStrategy { handle('SIGINT', cleanup) try { - const events = await ctx.runClient.runStream(opts.appId, body, { signal: ctrl.signal, retryOnRateLimit: opts.retryOnRateLimit }) + const events = await ctx.runClient.runStream(opts.appId, body, { + signal: ctrl.signal, + retryOnRateLimit: opts.retryOnRateLimit, + }) const sp = streamPrinterFor(mode, ctx.think, deps.io.isErrTTY) const dec = new TextDecoder() for await (const ev of events) { - if (ev.name === 'ping') - continue - if (ev.name === 'error') - throw decodeStreamError(ev.data) + if (ev.name === 'ping') continue + if (ev.name === 'error') throw decodeStreamError(ev.data) if (ev.data.byteLength > 0) { try { const parsed = JSON.parse(dec.decode(ev.data)) as Record if (typeof parsed.task_id === 'string' && parsed.task_id !== '' && taskId === undefined) taskId = parsed.task_id + } catch { + /* ignore */ } - catch { /* ignore */ } } try { sp.onEvent(deps.io.out, deps.io.err, ev) - } - catch (err) { + } catch (err) { if (err instanceof HitlPauseError) { - deps.io.out.write(renderHitlOutput(opts.appId, err.pausePayload, ctx.isText, deps.io.isOutTTY)) + deps.io.out.write( + renderHitlOutput(opts.appId, err.pausePayload, ctx.isText, deps.io.isOutTTY), + ) deps.io.err.write(renderHitlHint(opts.appId, err.pausePayload, deps.io.isErrTTY)) exit(0) } @@ -57,12 +59,10 @@ export class StreamingTextStrategy implements RunStrategy { } } sp.onEnd(deps.io.out, deps.io.err) - } - catch (err) { + } catch (err) { ctrl.abort() throw err - } - finally { + } finally { unhandle('SIGINT', cleanup) } } diff --git a/cli/src/commands/run/app/file-flags.test.ts b/cli/src/commands/run/app/file-flags.test.ts index 60d11caf69e72e..6d7df99f5e1dd5 100644 --- a/cli/src/commands/run/app/file-flags.test.ts +++ b/cli/src/commands/run/app/file-flags.test.ts @@ -28,11 +28,15 @@ describe('parseFileFlag', () => { }) it('throws on missing = separator', () => { - expect(() => parseFileFlag('noequalssign')).toThrow('--file must be key=@path or key=https://url') + expect(() => parseFileFlag('noequalssign')).toThrow( + '--file must be key=@path or key=https://url', + ) }) it('throws on value that is neither @ nor URL', () => { - expect(() => parseFileFlag('doc=justaplainstring')).toThrow('--file value must start with @ (local file) or http(s):// (remote URL)') + expect(() => parseFileFlag('doc=justaplainstring')).toThrow( + '--file value must start with @ (local file) or http(s):// (remote URL)', + ) }) it('throws on empty varname', () => { @@ -77,13 +81,21 @@ describe('resolveFileInputs', () => { const result = await resolveFileInputs('app-1', ['doc=https://example.com/report.pdf'], upload) expect(upload).not.toHaveBeenCalled() expect(result).toEqual({ - doc: { type: 'document', transfer_method: 'remote_url', url: 'https://example.com/report.pdf' }, + doc: { + type: 'document', + transfer_method: 'remote_url', + url: 'https://example.com/report.pdf', + }, }) }) it('remote URL with query string: extracts correct extension', async () => { const upload = vi.fn() - const result = await resolveFileInputs('app-1', ['img=https://cdn.example.com/photo.jpg?token=abc'], upload) + const result = await resolveFileInputs( + 'app-1', + ['img=https://cdn.example.com/photo.jpg?token=abc'], + upload, + ) expect(result.img).toMatchObject({ type: 'image', transfer_method: 'remote_url' }) }) @@ -98,16 +110,23 @@ describe('resolveFileInputs', () => { it('multiple flags: produces multiple entries keyed by varname', async () => { const upload = vi.fn().mockResolvedValue({ id: 'file-uuid-2' }) - const result = await resolveFileInputs('app-1', ['img=https://x.com/logo.png', 'doc=@/tmp/file.pdf'], upload) + const result = await resolveFileInputs( + 'app-1', + ['img=https://x.com/logo.png', 'doc=@/tmp/file.pdf'], + upload, + ) expect(Object.keys(result)).toHaveLength(2) expect(result.img).toMatchObject({ transfer_method: 'remote_url' }) - expect(result.doc).toMatchObject({ transfer_method: 'local_file', upload_file_id: 'file-uuid-2' }) + expect(result.doc).toMatchObject({ + transfer_method: 'local_file', + upload_file_id: 'file-uuid-2', + }) }) it('upload failure: throws with context including varname and path', async () => { const upload = vi.fn().mockRejectedValue(new Error('413 File too large')) - await expect(resolveFileInputs('app-1', ['doc=@/tmp/big.pdf'], upload)) - .rejects - .toThrow('--file doc: upload of /tmp/big.pdf failed') + await expect(resolveFileInputs('app-1', ['doc=@/tmp/big.pdf'], upload)).rejects.toThrow( + '--file doc: upload of /tmp/big.pdf failed', + ) }) }) diff --git a/cli/src/commands/run/app/file-flags.ts b/cli/src/commands/run/app/file-flags.ts index ea689f8c77daf3..2b2edabefdc65f 100644 --- a/cli/src/commands/run/app/file-flags.ts +++ b/cli/src/commands/run/app/file-flags.ts @@ -2,51 +2,75 @@ import { basename } from 'node:path' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' -export type ParsedFileFlag - = | { varname: string, kind: 'local', path: string } - | { varname: string, kind: 'remote', url: string } +export type ParsedFileFlag = + | { varname: string; kind: 'local'; path: string } + | { varname: string; kind: 'remote'; url: string } export function parseFileFlag(raw: string): ParsedFileFlag { const eqIdx = raw.indexOf('=') if (eqIdx === -1) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--file must be key=@path or key=https://url' }) + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: '--file must be key=@path or key=https://url', + }) const varname = raw.slice(0, eqIdx) const value = raw.slice(eqIdx + 1) if (varname === '') - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--file varname must not be empty' }) + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: '--file varname must not be empty', + }) - if (value.startsWith('@')) - return { varname, kind: 'local', path: value.slice(1) } + if (value.startsWith('@')) return { varname, kind: 'local', path: value.slice(1) } if (value.startsWith('http://') || value.startsWith('https://')) return { varname, kind: 'remote', url: value } - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--file value must start with @ (local file) or http(s):// (remote URL)' }) + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: '--file value must start with @ (local file) or http(s):// (remote URL)', + }) } const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg']) const AUDIO_EXTS = new Set(['mp3', 'm4a', 'wav', 'amr', 'mpga']) const VIDEO_EXTS = new Set(['mp4', 'mov', 'mpeg', 'webm']) // Matches graphon/file/constants.py DOCUMENT_EXTENSIONS (Unstructured ETL config) -const DOCUMENT_EXTS = new Set(['txt', 'markdown', 'md', 'mdx', 'pdf', 'html', 'htm', 'xlsx', 'xls', 'vtt', 'properties', 'doc', 'docx', 'csv', 'eml', 'msg', 'ppt', 'pptx', 'xml', 'epub']) +const DOCUMENT_EXTS = new Set([ + 'txt', + 'markdown', + 'md', + 'mdx', + 'pdf', + 'html', + 'htm', + 'xlsx', + 'xls', + 'vtt', + 'properties', + 'doc', + 'docx', + 'csv', + 'eml', + 'msg', + 'ppt', + 'pptx', + 'xml', + 'epub', +]) export type DifyFileType = 'image' | 'audio' | 'video' | 'document' | 'custom' export function difyFileType(filename: string): DifyFileType { const dotIdx = filename.lastIndexOf('.') - if (dotIdx <= 0 || dotIdx === filename.length - 1) - return 'custom' + if (dotIdx <= 0 || dotIdx === filename.length - 1) return 'custom' const ext = filename.slice(dotIdx + 1).toLowerCase() - if (IMAGE_EXTS.has(ext)) - return 'image' - if (AUDIO_EXTS.has(ext)) - return 'audio' - if (VIDEO_EXTS.has(ext)) - return 'video' - if (DOCUMENT_EXTS.has(ext)) - return 'document' + if (IMAGE_EXTS.has(ext)) return 'image' + if (AUDIO_EXTS.has(ext)) return 'audio' + if (VIDEO_EXTS.has(ext)) return 'video' + if (DOCUMENT_EXTS.has(ext)) return 'document' return 'document' } @@ -69,15 +93,17 @@ export async function resolveFileInputs( transfer_method: 'remote_url', url: parsed.url, } - } - else { + } else { const filename = basename(parsed.path) let uploaded: { id: string } try { uploaded = await upload(appId, parsed.path) - } - catch (err) { - throw new BaseError({ code: ErrorCode.Unknown, message: `--file ${parsed.varname}: upload of ${parsed.path} failed: ${(err as Error).message}`, cause: err }) + } catch (err) { + throw new BaseError({ + code: ErrorCode.Unknown, + message: `--file ${parsed.varname}: upload of ${parsed.path} failed: ${(err as Error).message}`, + cause: err, + }) } result[parsed.varname] = { type: difyFileType(filename), diff --git a/cli/src/commands/run/app/handlers.ts b/cli/src/commands/run/app/handlers.ts index 9536c4fe2a9d63..93ea70305a0ddc 100644 --- a/cli/src/commands/run/app/handlers.ts +++ b/cli/src/commands/run/app/handlers.ts @@ -9,7 +9,7 @@ export const RUN_MODES = { Workflow: 'workflow', } as const -export type RunMode = typeof RUN_MODES[keyof typeof RUN_MODES] +export type RunMode = (typeof RUN_MODES)[keyof typeof RUN_MODES] export const CHAT_MODES: ReadonlySet = new Set([ RUN_MODES.Chat, @@ -45,8 +45,7 @@ function textForMode(mode: string, raw: Record): string { function renderChat(raw: Record): string { const out: string[] = [] const answer = pickString(raw, 'answer') - if (answer !== undefined) - out.push(answer) + if (answer !== undefined) out.push(answer) out.push('') return out.join('\n') } @@ -62,8 +61,7 @@ function renderWorkflow(raw: Record): string { if (outputs !== undefined) { if (typeof outputs === 'object' && outputs !== null) { const entries = Object.entries(outputs as Record) - if (entries.length === 1 && typeof entries[0]![1] === 'string') - return `${entries[0]![1]}\n` + if (entries.length === 1 && typeof entries[0]![1] === 'string') return `${entries[0]![1]}\n` } return `${JSON.stringify(outputs)}\n` } @@ -71,10 +69,12 @@ function renderWorkflow(raw: Record): string { return `${JSON.stringify(raw)}\n` } -export function chatConversationHint(resp: Record, cs: ColorScheme): string | undefined { +export function chatConversationHint( + resp: Record, + cs: ColorScheme, +): string | undefined { const cid = pickString(resp, 'conversation_id') - if (cid === undefined || cid === '') - return undefined + if (cid === undefined || cid === '') return undefined return `${cs.magenta('hint:')} continue this conversation with --conversation ${cs.cyan(cid)}\n` } diff --git a/cli/src/commands/run/app/hitl-render.test.ts b/cli/src/commands/run/app/hitl-render.test.ts index 92d9157573561f..bfd6c3fc62bdbe 100644 --- a/cli/src/commands/run/app/hitl-render.test.ts +++ b/cli/src/commands/run/app/hitl-render.test.ts @@ -29,7 +29,10 @@ describe('renderHitlHint — non-resumable form (form_token null)', () => { [['email'], 'form delivered via email — resume only from that channel'], [['console'], 'form delivered via the console — resume only from that channel'], [['web_app'], 'form delivered via the web app — resume only from that channel'], - [['console', 'email'], 'form delivered via the console or email — resume only from those channels'], + [ + ['console', 'email'], + 'form delivered via the console or email — resume only from those channels', + ], [[], 'form delivered via another channel — resume only from that channel'], ])('renders %j as the channel note', (channels, expected) => { const out = renderHitlHint('app-1', payload({ approval_channels: channels }), false) @@ -47,7 +50,11 @@ describe('renderHitlHint — non-resumable form (form_token null)', () => { describe('renderHitlHint — resumable form (form_token present)', () => { it('renders the resume command and ignores approval_channels', () => { - const out = renderHitlHint('app-1', payload({ form_token: 'tok-123', approval_channels: [] }), false) + const out = renderHitlHint( + 'app-1', + payload({ form_token: 'tok-123', approval_channels: [] }), + false, + ) expect(out).toContain('difyctl resume app app-1 tok-123 --workflow-run-id run-1') expect(out).not.toContain('delivered via') }) diff --git a/cli/src/commands/run/app/hitl-render.ts b/cli/src/commands/run/app/hitl-render.ts index 6eb251f4b381fe..ff7032226fcf28 100644 --- a/cli/src/commands/run/app/hitl-render.ts +++ b/cli/src/commands/run/app/hitl-render.ts @@ -44,8 +44,13 @@ export function renderHitlExit(obj: HitlExitObject): string { return JSON.stringify(obj, null, 2) } -type ActionRecord = { id: string, title?: string, button_style?: string } -type InputRecord = { output_variable_name?: string, label?: string, type?: string, required?: boolean } +type ActionRecord = { id: string; title?: string; button_style?: string } +type InputRecord = { + output_variable_name?: string + label?: string + type?: string + required?: boolean +} export function renderHitlBlock(_appId: string, payload: HitlPausePayload, isTTY: boolean): string { const d = payload.data @@ -56,30 +61,33 @@ export function renderHitlBlock(_appId: string, payload: HitlPausePayload, isTTY const msgLines = d.form_content.split('\n') if (msgLines.length === 1) { lines.push(` ${cs.dim('Message:')} ${d.form_content}`) - } - else { + } else { lines.push(` ${cs.dim('Message:')}`) - for (const ml of msgLines) - lines.push(` ${ml}`) + for (const ml of msgLines) lines.push(` ${ml}`) } const actions = (Array.isArray(d.actions) ? d.actions : []) as ActionRecord[] if (actions.length > 0) { - const inline = actions.map((a) => { - const title = a.title ?? '' - return `${cs.cyan(`[${a.id}]`)} ${title}` - }).join(' ') + const inline = actions + .map((a) => { + const title = a.title ?? '' + return `${cs.cyan(`[${a.id}]`)} ${title}` + }) + .join(' ') lines.push(` ${cs.dim('Actions:')} ${inline}`) } const inputs = (Array.isArray(d.inputs) ? d.inputs : []) as InputRecord[] if (inputs.length > 0) { - const inline = inputs.map((inp) => { - const name = inp.output_variable_name ?? '?' - const label = typeof inp.label === 'string' && inp.label !== '' ? ` ${cs.dim(`— ${inp.label}`)}` : '' - const req = inp.required === true ? ` ${cs.yellow('*')}` : '' - return `- ${cs.cyan(name)}${req}${label}` - }).join(' ') + const inline = inputs + .map((inp) => { + const name = inp.output_variable_name ?? '?' + const label = + typeof inp.label === 'string' && inp.label !== '' ? ` ${cs.dim(`— ${inp.label}`)}` : '' + const req = inp.required === true ? ` ${cs.yellow('*')}` : '' + return `- ${cs.cyan(name)}${req}${label}` + }) + .join(' ') lines.push(` ${cs.dim('Inputs:')} ${inline}`) } @@ -87,9 +95,13 @@ export function renderHitlBlock(_appId: string, payload: HitlPausePayload, isTTY return `${lines.join('\n')}\n` } -export function renderHitlOutput(appId: string, payload: HitlPausePayload, isText: boolean, isOutTTY: boolean): string { - if (isText) - return renderHitlBlock(appId, payload, isOutTTY) +export function renderHitlOutput( + appId: string, + payload: HitlPausePayload, + isText: boolean, + isOutTTY: boolean, +): string { + if (isText) return renderHitlBlock(appId, payload, isOutTTY) const obj = buildHitlExitObject(appId, payload) return `${renderHitlExit(obj)}\n` } @@ -102,11 +114,9 @@ const APPROVAL_CHANNEL_LABELS: Record = { } function describeApprovalChannels(channels: string[]): string { - const labels = channels.map(c => APPROVAL_CHANNEL_LABELS[c] ?? c) - if (labels.length <= 1) - return labels[0] ?? 'another channel' - if (labels.length === 2) - return `${labels[0]} or ${labels[1]}` + const labels = channels.map((c) => APPROVAL_CHANNEL_LABELS[c] ?? c) + if (labels.length <= 1) return labels[0] ?? 'another channel' + if (labels.length === 2) return `${labels[0]} or ${labels[1]}` return `${labels.slice(0, -1).join(', ')}, or ${labels[labels.length - 1]}` } @@ -115,23 +125,24 @@ function externalChannelNote(channels: string[]): string { return `form delivered via ${describeApprovalChannels(channels)} — resume only from ${where}` } -export function renderHitlHint(appId: string, payload: HitlPausePayload, isErrTTY: boolean): string { +export function renderHitlHint( + appId: string, + payload: HitlPausePayload, + isErrTTY: boolean, +): string { const d = payload.data const cs = colorScheme(colorEnabled(isErrTTY)) if (d.form_token === null) { const note = externalChannelNote(d.approval_channels ?? []) - if (!isErrTTY) - return `hint: workflow paused — ${note}\n` + if (!isErrTTY) return `hint: workflow paused — ${note}\n` return `${cs.warningIcon()} ${cs.bold('workflow paused')} — ${cs.dim(note)}\n` } const actions = (d.actions ?? []) as { id: string }[] let cmd = `difyctl resume app ${appId} ${d.form_token} --workflow-run-id ${payload.workflow_run_id}` if (actions.length > 1) { const firstAction = actions[0]?.id - if (firstAction !== undefined) - cmd += ` --action ${firstAction}` + if (firstAction !== undefined) cmd += ` --action ${firstAction}` } - if (!isErrTTY) - return `hint: workflow paused — resume with: ${cmd}\n` + if (!isErrTTY) return `hint: workflow paused — resume with: ${cmd}\n` return `${cs.warningIcon()} ${cs.bold('workflow paused')} — resume with:\n ${cs.cyan(cmd)}\n` } diff --git a/cli/src/commands/run/app/index.ts b/cli/src/commands/run/app/index.ts index 6b63e17e81ef1c..7f50a1bc0908de 100644 --- a/cli/src/commands/run/app/index.ts +++ b/cli/src/commands/run/app/index.ts @@ -24,21 +24,52 @@ export default class RunApp extends DifyCommand { static override args = { id: Args.string({ description: 'app id', required: true }), - message: Args.string({ description: 'user message (chat/agent-chat/advanced-chat/completion)', required: false }), + message: Args.string({ + description: 'user message (chat/agent-chat/advanced-chat/completion)', + required: false, + }), } static override flags = { - 'inputs': Flags.string({ description: 'Input variables as a JSON object, e.g. --inputs \'{"key":"value"}\'. Mutually exclusive with --inputs-file.' }), - 'inputs-file': Flags.string({ description: 'Path to a JSON file containing the inputs object. Mutually exclusive with --inputs.' }), - 'file': Flags.stringArray({ description: 'Named file input: --file key=@path for a local file or --file key=https://url for a remote URL. Repeatable.', default: [] }), - 'conversation': Flags.string({ description: `Resume a chat conversation by id (${[...CHAT_MODES].join('/')} only)` }), + inputs: Flags.string({ + description: + 'Input variables as a JSON object, e.g. --inputs \'{"key":"value"}\'. Mutually exclusive with --inputs-file.', + }), + 'inputs-file': Flags.string({ + description: + 'Path to a JSON file containing the inputs object. Mutually exclusive with --inputs.', + }), + file: Flags.stringArray({ + description: + 'Named file input: --file key=@path for a local file or --file key=https://url for a remote URL. Repeatable.', + default: [], + }), + conversation: Flags.string({ + description: `Resume a chat conversation by id (${[...CHAT_MODES].join('/')} only)`, + }), 'workflow-id': Flags.string({ description: 'Pin to a specific published workflow version' }), - 'workspace': Flags.string({ description: 'Workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }), - 'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive (default: collect and print at end)', default: false }), - 'think': Flags.boolean({ description: 'Show model thinking/reasoning when available — both inline ... blocks and separated reasoning streams. Hidden by default; with --think, thinking is printed to stderr.', default: false }), - 'retry-on-limit': Flags.boolean({ description: 'On a 429 rate limit, wait and retry this POST (bounded) instead of failing immediately. Off by default since running an app is not idempotent.', default: false }), + workspace: Flags.string({ + description: 'Workspace id (overrides DIFY_WORKSPACE_ID and stored default)', + }), + stream: Flags.boolean({ + description: 'Print output live as tokens/events arrive (default: collect and print at end)', + default: false, + }), + think: Flags.boolean({ + description: + 'Show model thinking/reasoning when available — both inline ... blocks and separated reasoning streams. Hidden by default; with --think, thinking is printed to stderr.', + default: false, + }), + 'retry-on-limit': Flags.boolean({ + description: + 'On a 429 rate limit, wait and retry this POST (bounded) instead of failing immediately. Off by default since running an app is not idempotent.', + default: false, + }), 'http-retry': httpRetryFlag, - 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }), + output: Flags.outputFormat({ + options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], + default: '', + }), } async run(argv: string[]): Promise { diff --git a/cli/src/commands/run/app/input-flags.ts b/cli/src/commands/run/app/input-flags.ts index b6d296ad7c0dbb..551325d219a0ef 100644 --- a/cli/src/commands/run/app/input-flags.ts +++ b/cli/src/commands/run/app/input-flags.ts @@ -12,17 +12,25 @@ export async function resolveInputs( directInputs: Readonly> | undefined, ): Promise> { if (inputsJson !== undefined && inputsFile !== undefined) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs and --inputs-file are mutually exclusive' }) + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: '--inputs and --inputs-file are mutually exclusive', + }) if (inputsJson !== undefined) { let parsed: unknown try { parsed = JSON.parse(inputsJson) - } - catch { - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be valid JSON' }) + } catch { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: '--inputs must be valid JSON', + }) } if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be a JSON object' }) + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: '--inputs must be a JSON object', + }) return parsed as Record } if (inputsFile !== undefined) { @@ -30,12 +38,17 @@ export async function resolveInputs( let parsed: unknown try { parsed = JSON.parse(await readFile(inputsFile, 'utf8')) - } - catch { - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must contain valid JSON' }) + } catch { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: '--inputs-file must contain valid JSON', + }) } if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must be a JSON object' }) + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: '--inputs-file must be a JSON object', + }) return parsed as Record } return { ...(directInputs ?? {}) } diff --git a/cli/src/commands/run/app/run.test.ts b/cli/src/commands/run/app/run.test.ts index a4e201c8ee1d0e..749a87f7e6c1d0 100644 --- a/cli/src/commands/run/app/run.test.ts +++ b/cli/src/commands/run/app/run.test.ts @@ -36,10 +36,8 @@ describe('runApp', () => { process.env[ENV_CACHE_DIR] = dir }) afterEach(async () => { - if (prevCacheDir === undefined) - delete process.env[ENV_CACHE_DIR] - else - process.env[ENV_CACHE_DIR] = prevCacheDir + if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR] + else process.env[ENV_CACHE_DIR] = prevCacheDir await mock.stop() await rm(dir, { recursive: true, force: true }) }) @@ -58,10 +56,18 @@ describe('runApp', () => { it('workflow: rejects positional message with usage error', async () => { const io = bufferStreams() const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await expect(runApp( - { appId: 'app-2', message: 'hi' }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - )).rejects.toMatchObject({ code: 'usage_invalid_flag' }) + await expect( + runApp( + { appId: 'app-2', message: 'hi' }, + { + active: active(), + http: testHttpClient(mock.url, 'dfoa_test'), + host: mock.url, + io, + cache, + }, + ), + ).rejects.toMatchObject({ code: 'usage_invalid_flag' }) }) it('workflow: prints single-string output as plain text', async () => { @@ -81,30 +87,34 @@ describe('runApp', () => { { appId: 'app-1', message: 'hi', format: 'json' }, { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) - const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string } + const parsed = JSON.parse(io.outBuf()) as { mode: string; answer: string } expect(parsed.mode).toBe('chat') expect(parsed.answer).toBe('echo: hi') }) it('rejects unknown format', async () => { const io = bufferStreams() - await expect(runApp( - { appId: 'app-1', format: 'bogus' }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, - )).rejects.toThrow(/not supported/) + await expect( + runApp( + { appId: 'app-1', format: 'bogus' }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, + ), + ).rejects.toThrow(/not supported/) }) it('unknown app id surfaces as error', async () => { const io = bufferStreams() - await expect(runApp( - { appId: 'nope', message: 'hi' }, - { - active: active(), - http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), - host: mock.url, - io, - }, - )).rejects.toThrow() + await expect( + runApp( + { appId: 'nope', message: 'hi' }, + { + active: active(), + http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), + host: mock.url, + io, + }, + ), + ).rejects.toThrow() }) it('--stream chat: streams answer to stdout and hint to stderr', async () => { @@ -126,7 +136,11 @@ describe('runApp', () => { { appId: 'app-1', message: 'hi', stream: true, format: 'json' }, { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) - const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string, conversation_id: string } + const parsed = JSON.parse(io.outBuf()) as { + mode: string + answer: string + conversation_id: string + } expect(parsed.mode).toBe('chat') expect(parsed.answer).toBe('echo: hi') expect(parsed.conversation_id).toBe('conv-1') @@ -147,7 +161,12 @@ describe('runApp', () => { const io = bufferStreams() const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( - { appId: 'app-4', workspace: '00000000-0000-0000-0000-000000000002', message: 'go', stream: true }, + { + appId: 'app-4', + workspace: '00000000-0000-0000-0000-000000000002', + message: 'go', + stream: true, + }, { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toContain('go') @@ -161,7 +180,7 @@ describe('runApp', () => { { appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' }, { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) - const parsed = JSON.parse(io.outBuf()) as { mode: string, data: { status: string } } + const parsed = JSON.parse(io.outBuf()) as { mode: string; data: { status: string } } expect(parsed.mode).toBe('workflow') expect(parsed.data.status).toBe('succeeded') }) @@ -238,7 +257,10 @@ describe('runApp', () => { { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.errBuf()).toContain('secret reasoning') - const parsed = JSON.parse(io.outBuf()) as { answer: string, metadata: { reasoning: Record } } + const parsed = JSON.parse(io.outBuf()) as { + answer: string + metadata: { reasoning: Record } + } expect(parsed.answer).toBe('final answer') expect(parsed.metadata.reasoning).toEqual({ 'llm-1': 'secret reasoning' }) }) @@ -286,10 +308,18 @@ describe('runApp', () => { mock.setScenario('stream-error') const io = bufferStreams() const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await expect(runApp( - { appId: 'app-1', message: 'hi', stream: true }, - { active: active(), http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache }, - )).rejects.toMatchObject({ code: 'server_5xx' }) + await expect( + runApp( + { appId: 'app-1', message: 'hi', stream: true }, + { + active: active(), + http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), + host: mock.url, + io, + cache, + }, + ), + ).rejects.toMatchObject({ code: 'server_5xx' }) }) it('--inputs-file: reads inputs from file', async () => { @@ -310,10 +340,12 @@ describe('runApp', () => { const { writeFile } = await import('node:fs/promises') const inputsFile = join(dir, 'bad.json') await writeFile(inputsFile, JSON.stringify([1, 2, 3])) - await expect(runApp( - { appId: 'app-2', inputsFile }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, - )).rejects.toThrow(/must be a JSON object/) + await expect( + runApp( + { appId: 'app-2', inputsFile }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, + ), + ).rejects.toThrow(/must be a JSON object/) }) it('--inputs: accepts JSON object string', async () => { @@ -331,10 +363,12 @@ describe('runApp', () => { const { writeFile } = await import('node:fs/promises') const inputsFile = join(dir, 'f.json') await writeFile(inputsFile, '{}') - await expect(runApp( - { appId: 'app-2', inputsJson: '{}', inputsFile }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, - )).rejects.toThrow(/mutually exclusive/) + await expect( + runApp( + { appId: 'app-2', inputsJson: '{}', inputsFile }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, + ), + ).rejects.toThrow(/mutually exclusive/) }) it('hitl pause (text): writes readable block to stdout, hint to stderr, exits 0', async () => { @@ -342,20 +376,22 @@ describe('runApp', () => { const io = bufferStreams() const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) let exitCode = -1 - await expect(runApp( - { appId: 'app-2', inputs: {} }, - { - active: active(), - http: testHttpClient(mock.url, 'dfoa_test'), - host: mock.url, - io, - cache, - exit: (code) => { - exitCode = code - throw new Error(`exit:${code}`) + await expect( + runApp( + { appId: 'app-2', inputs: {} }, + { + active: active(), + http: testHttpClient(mock.url, 'dfoa_test'), + host: mock.url, + io, + cache, + exit: (code) => { + exitCode = code + throw new Error(`exit:${code}`) + }, }, - }, - )).rejects.toThrow('exit:0') + ), + ).rejects.toThrow('exit:0') expect(exitCode).toBe(0) const out = io.outBuf() expect(out).toContain('Workflow paused') @@ -371,22 +407,28 @@ describe('runApp', () => { const io = bufferStreams() const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) let exitCode = -1 - await expect(runApp( - { appId: 'app-2', inputs: {}, format: 'json' }, - { - active: active(), - http: testHttpClient(mock.url, 'dfoa_test'), - host: mock.url, - io, - cache, - exit: (code) => { - exitCode = code - throw new Error(`exit:${code}`) + await expect( + runApp( + { appId: 'app-2', inputs: {}, format: 'json' }, + { + active: active(), + http: testHttpClient(mock.url, 'dfoa_test'), + host: mock.url, + io, + cache, + exit: (code) => { + exitCode = code + throw new Error(`exit:${code}`) + }, }, - }, - )).rejects.toThrow('exit:0') + ), + ).rejects.toThrow('exit:0') expect(exitCode).toBe(0) - const payload = JSON.parse(io.outBuf()) as { status: string, form_token: string, workflow_run_id: string } + const payload = JSON.parse(io.outBuf()) as { + status: string + form_token: string + workflow_run_id: string + } expect(payload.status).toBe('paused') expect(payload.form_token).toBe('ft-hitl-1') expect(payload.workflow_run_id).toBe('wf-run-hitl-1') @@ -397,7 +439,14 @@ describe('runApp', () => { const io = bufferStreams() const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await resumeApp( - { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false }, + { + appId: 'app-2', + formToken: 'ft-hitl-1', + workflowRunId: 'wf-run-hitl-1', + action: 'submit', + inputs: {}, + withHistory: false, + }, { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: resumed\n') @@ -408,7 +457,13 @@ describe('runApp', () => { const io = bufferStreams() const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await resumeApp( - { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} }, + { + appId: 'app-2', + formToken: 'ft-hitl-1', + workflowRunId: 'wf-run-hitl-1', + action: 'submit', + inputs: {}, + }, { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: resumed\n') @@ -419,7 +474,14 @@ describe('runApp', () => { const io = bufferStreams() const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await resumeApp( - { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true }, + { + appId: 'app-2', + formToken: 'ft-hitl-1', + workflowRunId: 'wf-run-hitl-1', + action: 'submit', + inputs: {}, + stream: true, + }, { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) // stream mode for workflow: node_started → "→ " on stderr @@ -477,7 +539,13 @@ describe('runApp', () => { mock.setScenario('run-422-stale') const err = await runApp( { appId: 'app-1', message: 'hi' }, - { active: active(), http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache }, + { + active: active(), + http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), + host: mock.url, + io, + cache, + }, ).catch((e: unknown) => e) expect(err).toMatchObject({ code: 'server_4xx_other', httpStatus: 422 }) expect((err as { hint?: string }).hint).toMatch(/cache cleared/) @@ -488,7 +556,11 @@ describe('runApp', () => { const io = bufferStreams() const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( - { appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] }, + { + appId: 'app-2', + inputs: { doc: 'old-value' }, + files: ['doc=https://example.com/override.pdf'], + }, { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') @@ -500,14 +572,37 @@ describe('runApp', () => { }) it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => { - const describeResult = { info: { id: 'app-1', name: 'X', mode: 'chat', description: '', updated_at: null, service_api_enabled: true, is_agent: false }, parameters: null, input_schema: null } + const describeResult = { + info: { + id: 'app-1', + name: 'X', + mode: 'chat', + description: '', + updated_at: null, + service_api_enabled: true, + is_agent: false, + }, + parameters: null, + input_schema: null, + } const externalDescribe = vi.fn().mockResolvedValue(describeResult) const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps') const { AppsClient } = await import('@/api/apps') - const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe) + const externalSpy = vi + .spyOn(PermittedExternalAppsClient.prototype, 'describe') + .mockImplementation(externalDescribe) const accountSpy = vi.spyOn(AppsClient.prototype, 'describe') const io = bufferStreams() - const http = { baseURL: mock.url, request: vi.fn().mockResolvedValue({ answer: 'echo: hi', conversation_id: 'conv-1', message_id: 'msg-1', mode: 'chat', metadata: {} }) } as unknown as HttpClient + const http = { + baseURL: mock.url, + request: vi.fn().mockResolvedValue({ + answer: 'echo: hi', + conversation_id: 'conv-1', + message_id: 'msg-1', + mode: 'chat', + metadata: {}, + }), + } as unknown as HttpClient const activeExt: ActiveContext = { host: mock.url, email: 'sso@x.io', @@ -521,8 +616,7 @@ describe('runApp', () => { { appId: 'app-1', message: 'hi' }, { active: activeExt, http, host: mock.url, io }, ) - } - catch { + } catch { // run may fail due to mocked http; we only care about which describe was called } expect(externalSpy).toHaveBeenCalled() diff --git a/cli/src/commands/run/app/run.ts b/cli/src/commands/run/app/run.ts index c0598b0eab09c5..f535e091706ec2 100644 --- a/cli/src/commands/run/app/run.ts +++ b/cli/src/commands/run/app/run.ts @@ -47,11 +47,12 @@ export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<voi try { await executeRun(opts, deps, meta) - } - catch (err) { + } catch (err) { if (err instanceof HttpClientError && err.httpStatus === 422) { await meta.invalidate(opts.appId) - throw err.withHint('app metadata cache cleared — if the app was recently republished, run the command again') + throw err.withHint( + 'app metadata cache cleared — if the app was recently republished, run the command again', + ) } throw err } @@ -64,8 +65,7 @@ async function executeRun( ): Promise<void> { const m = await meta.get(opts.appId, [FieldInfo]) const mode = m.info?.mode ?? '' - if (mode === '') - throw new Error(`app ${opts.appId}: mode missing from app metadata`) + if (mode === '') throw new Error(`app ${opts.appId}: mode missing from app metadata`) if (mode === RUN_MODES.Workflow && opts.message !== undefined && opts.message !== '') { throw new BaseError({ @@ -78,10 +78,8 @@ async function executeRun( const inputs = await resolveInputs(opts.inputsJson, opts.inputsFile, opts.inputs) if (opts.files !== undefined && opts.files.length > 0) { const uploadClient = new FileUploadClient(deps.http) - const fileInputs = await resolveFileInputs( - opts.appId, - opts.files, - (appId, path) => uploadClient.upload(appId, path), + const fileInputs = await resolveFileInputs(opts.appId, opts.files, (appId, path) => + uploadClient.upload(appId, path), ) Object.assign(inputs, fileInputs) } @@ -91,6 +89,16 @@ async function executeRun( const runClient = new AppRunClient(deps.http) const exit = deps.exit ?? processExit - const ctx = { opts: { ...opts, inputs }, deps, mode, format, isText, livePrint, runClient, exit, think: opts.think ?? false } + const ctx = { + opts: { ...opts, inputs }, + deps, + mode, + format, + isText, + livePrint, + runClient, + exit, + think: opts.think ?? false, + } await pickStrategy(isText, livePrint).execute(ctx) } diff --git a/cli/src/commands/run/app/sse-collector.test.ts b/cli/src/commands/run/app/sse-collector.test.ts index d26abb5608bfab..ded085ce7d28ac 100644 --- a/cli/src/commands/run/app/sse-collector.test.ts +++ b/cli/src/commands/run/app/sse-collector.test.ts @@ -27,11 +27,14 @@ describe('collectorFor', () => { describe('collect — chat', () => { it('aggregates message + message_end into blocking shape', async () => { - const got = await collect(iterOf( - ev('message', { conversation_id: 'c1', message_id: 'm1', mode: 'chat', answer: 'hello ' }), - ev('message', { answer: 'world' }), - ev('message_end', { metadata: { usage: { tokens: 5 } } }), - ), 'chat') + const got = await collect( + iterOf( + ev('message', { conversation_id: 'c1', message_id: 'm1', mode: 'chat', answer: 'hello ' }), + ev('message', { answer: 'world' }), + ev('message_end', { metadata: { usage: { tokens: 5 } } }), + ), + 'chat', + ) expect(got).toMatchObject({ mode: 'chat', answer: 'hello world', @@ -42,66 +45,79 @@ describe('collect — chat', () => { }) it('drops ping events', async () => { - const got = await collect(iterOf( - ev('ping', {}), - ev('message', { answer: 'x' }), - ev('ping', {}), - ), 'chat') + const got = await collect( + iterOf(ev('ping', {}), ev('message', { answer: 'x' }), ev('ping', {})), + 'chat', + ) expect(got.answer).toBe('x') }) it('ignores unknown event names', async () => { - const got = await collect(iterOf( - ev('weird_future_event', { whatever: true }), - ev('message', { answer: 'x' }), - ), 'chat') + const got = await collect( + iterOf(ev('weird_future_event', { whatever: true }), ev('message', { answer: 'x' })), + 'chat', + ) expect(got.answer).toBe('x') }) }) describe('collect — chat separated reasoning', () => { function reasoningEvent(reasoning: string, isFinal: boolean) { - return ev('reasoning_chunk', { data: { message_id: 'm1', reasoning, node_id: 'llm-1', is_final: isFinal } }) + return ev('reasoning_chunk', { + data: { message_id: 'm1', reasoning, node_id: 'llm-1', is_final: isFinal }, + }) } it('backfills metadata.reasoning from live deltas when the server omits it', async () => { - const got = await collect(iterOf( - reasoningEvent('pon', false), - reasoningEvent('dering', true), - ev('message', { message_id: 'm1', answer: 'answer' }), - ev('message_end', { metadata: { usage: { tokens: 3 } } }), - ), 'advanced-chat') + const got = await collect( + iterOf( + reasoningEvent('pon', false), + reasoningEvent('dering', true), + ev('message', { message_id: 'm1', answer: 'answer' }), + ev('message_end', { metadata: { usage: { tokens: 3 } } }), + ), + 'advanced-chat', + ) expect(got.answer).toBe('answer') expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'pondering' }) expect((got.metadata as { usage?: unknown }).usage).toEqual({ tokens: 3 }) }) it('keeps the server-persisted reasoning over live deltas', async () => { - const got = await collect(iterOf( - reasoningEvent('live', true), - ev('message', { answer: 'a' }), - ev('message_end', { metadata: { reasoning: { 'llm-1': 'persisted' } } }), - ), 'advanced-chat') + const got = await collect( + iterOf( + reasoningEvent('live', true), + ev('message', { answer: 'a' }), + ev('message_end', { metadata: { reasoning: { 'llm-1': 'persisted' } } }), + ), + 'advanced-chat', + ) expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'persisted' }) }) it('leaves metadata untouched when there is no reasoning at all', async () => { - const got = await collect(iterOf( - ev('message', { answer: 'a' }), - ev('message_end', { metadata: { usage: { tokens: 1 } } }), - ), 'advanced-chat') + const got = await collect( + iterOf( + ev('message', { answer: 'a' }), + ev('message_end', { metadata: { usage: { tokens: 1 } } }), + ), + 'advanced-chat', + ) expect((got.metadata as { reasoning?: unknown }).reasoning).toBeUndefined() }) }) describe('collect — agent-chat', () => { it('captures agent_thoughts', async () => { - const got = await collect(iterOf( - ev('agent_thought', { thought: 'first' }), - ev('agent_message', { answer: 'a' }), - ev('agent_thought', { thought: 'second' }), - ev('agent_message', { answer: 'b' }), - ), 'agent-chat') + const got = await collect( + iterOf( + ev('agent_thought', { thought: 'first' }), + ev('agent_message', { answer: 'a' }), + ev('agent_thought', { thought: 'second' }), + ev('agent_message', { answer: 'b' }), + ), + 'agent-chat', + ) expect(got.answer).toBe('ab') expect(Array.isArray(got.agent_thoughts)).toBe(true) expect((got.agent_thoughts as unknown[]).length).toBe(2) @@ -110,23 +126,29 @@ describe('collect — agent-chat', () => { describe('collect — completion', () => { it('aggregates message events into answer', async () => { - const got = await collect(iterOf( - ev('message', { mode: 'completion', message_id: 'm1', answer: 'foo' }), - ev('message', { answer: 'bar' }), - ev('message_end', { metadata: {} }), - ), 'completion') + const got = await collect( + iterOf( + ev('message', { mode: 'completion', message_id: 'm1', answer: 'foo' }), + ev('message', { answer: 'bar' }), + ev('message_end', { metadata: {} }), + ), + 'completion', + ) expect(got).toMatchObject({ mode: 'completion', answer: 'foobar', message_id: 'm1' }) }) }) describe('collect — workflow', () => { it('captures only workflow_finished payload', async () => { - const got = await collect(iterOf( - ev('workflow_started', { id: 'wf' }), - ev('node_started', { id: 'n1' }), - ev('node_finished', { id: 'n1', status: 'succeeded' }), - ev('workflow_finished', { data: { status: 'succeeded', outputs: { x: 1 } } }), - ), 'workflow') + const got = await collect( + iterOf( + ev('workflow_started', { id: 'wf' }), + ev('node_started', { id: 'n1' }), + ev('node_finished', { id: 'n1', status: 'succeeded' }), + ev('workflow_finished', { data: { status: 'succeeded', outputs: { x: 1 } } }), + ), + 'workflow', + ) expect(got.mode).toBe('workflow') expect((got.data as { outputs: { x: number } }).outputs.x).toBe(1) }) @@ -138,28 +160,40 @@ describe('collect — workflow separated reasoning', () => { } it('accumulates reasoning_chunk per node into metadata.reasoning', async () => { - const got = await collect(iterOf( - ev('node_started', { id: 'llm-1' }), - wfReasoning('pon', 'llm-1', false), - wfReasoning('dering', 'llm-1', true), - ev('workflow_finished', { data: { status: 'succeeded', outputs: { result: 'clean' } } }), - ), 'workflow') + const got = await collect( + iterOf( + ev('node_started', { id: 'llm-1' }), + wfReasoning('pon', 'llm-1', false), + wfReasoning('dering', 'llm-1', true), + ev('workflow_finished', { data: { status: 'succeeded', outputs: { result: 'clean' } } }), + ), + 'workflow', + ) expect((got.data as { outputs: { result: string } }).outputs.result).toBe('clean') expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'pondering' }) }) it('keys reasoning by node, leaves metadata absent when there is none', async () => { - const got = await collect(iterOf( - ev('workflow_finished', { data: { status: 'succeeded', outputs: { result: 'clean' } } }), - ), 'workflow') + const got = await collect( + iterOf( + ev('workflow_finished', { data: { status: 'succeeded', outputs: { result: 'clean' } } }), + ), + 'workflow', + ) expect((got.metadata as { reasoning?: unknown } | undefined)?.reasoning).toBeUndefined() }) it('merges reasoning into metadata already carried by workflow_finished', async () => { - const got = await collect(iterOf( - wfReasoning('think', 'llm-1', true), - ev('workflow_finished', { data: { status: 'succeeded' }, metadata: { usage: { tokens: 7 } } }), - ), 'workflow') + const got = await collect( + iterOf( + wfReasoning('think', 'llm-1', true), + ev('workflow_finished', { + data: { status: 'succeeded' }, + metadata: { usage: { tokens: 7 } }, + }), + ), + 'workflow', + ) expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'think' }) expect((got.metadata as { usage?: unknown }).usage).toEqual({ tokens: 7 }) }) @@ -167,9 +201,9 @@ describe('collect — workflow separated reasoning', () => { describe('collect — error event', () => { it('throws BaseError when error event arrives', async () => { - await expect(collect(iterOf( - ev('error', { message: 'boom', status: 503 }), - ), 'chat')).rejects.toMatchObject({ code: 'server_5xx', message: 'boom' }) + await expect( + collect(iterOf(ev('error', { message: 'boom', status: 503 })), 'chat'), + ).rejects.toMatchObject({ code: 'server_5xx', message: 'boom' }) }) }) @@ -191,7 +225,10 @@ describe('decodeStreamError', () => { it('unwraps openapi-v1 invoke-error: prefers args.description', () => { const inner = { - args: { description: '[models] Error: API request failed with status code 402: Insufficient Balance' }, + args: { + description: + '[models] Error: API request failed with status code 402: Insufficient Balance', + }, error_type: 'InvokeError', message: 'fallback message', } @@ -247,10 +284,9 @@ describe('collect — human_input_required', () => { expiration_time: 9999999999, }, } - await expect(collect(iterOf( - ev('workflow_started', {}), - ev('human_input_required', hitlData), - ), 'workflow')).rejects.toBeInstanceOf(HitlPauseError) + await expect( + collect(iterOf(ev('workflow_started', {}), ev('human_input_required', hitlData)), 'workflow'), + ).rejects.toBeInstanceOf(HitlPauseError) }) it('HitlPauseError carries the pause payload', async () => { @@ -273,10 +309,8 @@ describe('collect — human_input_required', () => { let caught: HitlPauseError | undefined try { await collect(iterOf(ev('human_input_required', hitlData)), 'workflow') - } - catch (e) { - if (e instanceof HitlPauseError) - caught = e + } catch (e) { + if (e instanceof HitlPauseError) caught = e } expect(caught).toBeDefined() expect(caught!.pausePayload.data.form_token).toBe('ft-abc') @@ -286,27 +320,32 @@ describe('collect — human_input_required', () => { describe('collect — silent events', () => { it('silently ignores iteration_started and loop_started', async () => { - const got = await collect(iterOf( - ev('iteration_started', { id: 'iter-1' }), - ev('loop_started', { id: 'loop-1' }), - ev('node_started', {}), - ev('message', { answer: 'x' }), - ), 'chat') + const got = await collect( + iterOf( + ev('iteration_started', { id: 'iter-1' }), + ev('loop_started', { id: 'loop-1' }), + ev('node_started', {}), + ev('message', { answer: 'x' }), + ), + 'chat', + ) expect(got.answer).toBe('x') }) it('silently ignores node_retry', async () => { - const got = await collect(iterOf( - ev('node_retry', { id: 'n1', retry: 1 }), - ev('message', { answer: 'ok' }), - ), 'chat') + const got = await collect( + iterOf(ev('node_retry', { id: 'n1', retry: 1 }), ev('message', { answer: 'ok' })), + 'chat', + ) expect(got.answer).toBe('ok') }) it('workflow_paused without prior HITL throws a plain error', async () => { - await expect(collect(iterOf( - ev('workflow_started', {}), - ev('workflow_paused', { reasons: [] }), - ), 'workflow')).rejects.toThrow(/paused/) + await expect( + collect( + iterOf(ev('workflow_started', {}), ev('workflow_paused', { reasons: [] })), + 'workflow', + ), + ).rejects.toThrow(/paused/) }) }) diff --git a/cli/src/commands/run/app/sse-collector.ts b/cli/src/commands/run/app/sse-collector.ts index b3b3a33b06cf38..395fff5b098749 100644 --- a/cli/src/commands/run/app/sse-collector.ts +++ b/cli/src/commands/run/app/sse-collector.ts @@ -44,22 +44,22 @@ export type Collector = { const dec = new TextDecoder() function parseJson(data: Uint8Array): Record<string, unknown> { - if (data.byteLength === 0) - return {} + if (data.byteLength === 0) return {} try { return JSON.parse(dec.decode(data)) as Record<string, unknown> - } - catch (e) { + } catch (e) { throw newError(ErrorCode.Unknown, `decode SSE event: ${(e as Error).message}`) } } -function copyScalar(dst: Record<string, unknown>, src: Record<string, unknown>, keys: readonly string[]): void { +function copyScalar( + dst: Record<string, unknown>, + src: Record<string, unknown>, + keys: readonly string[], +): void { for (const k of keys) { - if (k in dst) - continue - if (k in src) - dst[k] = src[k] + if (k in dst) continue + if (k in src) dst[k] = src[k] } } @@ -81,16 +81,14 @@ class ChatCollector implements Collector { switch (ev.name) { case 'message': case 'agent_message': { - if (typeof c.answer === 'string') - this.answer += c.answer + if (typeof c.answer === 'string') this.answer += c.answer copyScalar(this.base, c, ['id', 'conversation_id', 'message_id', 'task_id', 'created_at']) return } // Accumulate separated-mode reasoning deltas per LLM node. case 'reasoning_chunk': { const chunk = parseReasoningChunk(c) - if (chunk !== undefined) - accumulateReasoning(this.reasoning, chunk) + if (chunk !== undefined) accumulateReasoning(this.reasoning, chunk) return } case 'agent_thought': @@ -105,23 +103,23 @@ class ChatCollector implements Collector { finalize(): Record<string, unknown> { const out: Record<string, unknown> = { mode: this.mode, answer: this.answer, ...this.base } - if (this.metadata !== undefined) - out.metadata = this.metadata + if (this.metadata !== undefined) out.metadata = this.metadata // Fall back to live deltas only when the server didn't persist reasoning in metadata. if (Object.keys(this.reasoning).length > 0 && !hasReasoning(this.metadata)) out.metadata = { ...(this.metadata ?? {}), reasoning: this.reasoning } - if (this.isAgent || this.thoughts.length > 0) - out.agent_thoughts = this.thoughts + if (this.isAgent || this.thoughts.length > 0) out.agent_thoughts = this.thoughts return out } } function hasReasoning(metadata: Record<string, unknown> | undefined): boolean { const reasoning = metadata?.reasoning - return reasoning !== null - && typeof reasoning === 'object' - && !Array.isArray(reasoning) - && Object.keys(reasoning as object).length > 0 + return ( + reasoning !== null && + typeof reasoning === 'object' && + !Array.isArray(reasoning) && + Object.keys(reasoning as object).length > 0 + ) } class CompletionCollector implements Collector { @@ -132,8 +130,7 @@ class CompletionCollector implements Collector { const c = parseJson(ev.data) switch (ev.name) { case 'message': - if (typeof c.answer === 'string') - this.answer += c.answer + if (typeof c.answer === 'string') this.answer += c.answer copyScalar(this.base, c, ['id', 'message_id', 'task_id', 'created_at']) return case 'message_end': @@ -144,9 +141,12 @@ class CompletionCollector implements Collector { } finalize(): Record<string, unknown> { - const out: Record<string, unknown> = { mode: RUN_MODES.Completion, answer: this.answer, ...this.base } - if (this.metadata !== undefined) - out.metadata = this.metadata + const out: Record<string, unknown> = { + mode: RUN_MODES.Completion, + answer: this.answer, + ...this.base, + } + if (this.metadata !== undefined) out.metadata = this.metadata return out } } @@ -157,12 +157,10 @@ class WorkflowCollector implements Collector { consume(ev: SseEvent): void { if (ev.name === 'reasoning_chunk') { const chunk = parseReasoningChunk(parseJson(ev.data)) - if (chunk !== undefined) - accumulateReasoning(this.reasoning, chunk) + if (chunk !== undefined) accumulateReasoning(this.reasoning, chunk) return } - if (ev.name !== 'workflow_finished') - return + if (ev.name !== 'workflow_finished') return this.final = parseJson(ev.data) } @@ -170,9 +168,10 @@ class WorkflowCollector implements Collector { const out: Record<string, unknown> = { mode: RUN_MODES.Workflow, ...(this.final ?? {}) } // Workflow runs don't persist reasoning; surface live deltas under metadata.reasoning. if (Object.keys(this.reasoning).length > 0) { - const existing = (out.metadata !== null && typeof out.metadata === 'object' && !Array.isArray(out.metadata)) - ? out.metadata as Record<string, unknown> - : undefined + const existing = + out.metadata !== null && typeof out.metadata === 'object' && !Array.isArray(out.metadata) + ? (out.metadata as Record<string, unknown>) + : undefined out.metadata = { ...(existing ?? {}), reasoning: this.reasoning } } return out @@ -189,27 +188,27 @@ const FACTORIES: Record<string, () => Collector> = { export function collectorFor(mode: string): Collector { const f = FACTORIES[mode] - if (f === undefined) - throw newError(ErrorCode.Unknown, `unsupported streaming mode "${mode}"`) + if (f === undefined) throw newError(ErrorCode.Unknown, `unsupported streaming mode "${mode}"`) return f() } export function decodeStreamError(data: Uint8Array): BaseError { - type Env = { message?: string, code?: string, status?: number } + type Env = { message?: string; code?: string; status?: number } let env: Env = {} if (data.byteLength > 0) { try { env = JSON.parse(dec.decode(data)) as Env - } - catch {} + } catch {} } - const rawMessage = env.message !== undefined && env.message !== '' - ? env.message - : 'stream terminated by error event' + const rawMessage = + env.message !== undefined && env.message !== '' + ? env.message + : 'stream terminated by error event' const message = unwrapInvokeErrorMessage(rawMessage) - const code = env.status !== undefined && env.status > 0 && env.status < 500 - ? ErrorCode.Server4xxOther - : ErrorCode.Server5xx + const code = + env.status !== undefined && env.status > 0 && env.status < 500 + ? ErrorCode.Server4xxOther + : ErrorCode.Server5xx const err = newError(code, message) if (env.status !== undefined && env.status > 0) return HttpClientError.from(err).withHttpStatus(env.status) @@ -217,8 +216,7 @@ export function decodeStreamError(data: Uint8Array): BaseError { } function unwrapInvokeErrorMessage(raw: string): string { - if (!raw.startsWith('{')) - return raw + if (!raw.startsWith('{')) return raw type InvokeErrorEnv = { error_type?: string args?: { description?: string } @@ -226,11 +224,9 @@ function unwrapInvokeErrorMessage(raw: string): string { } try { const inner = JSON.parse(raw) as InvokeErrorEnv - if (inner.error_type === undefined) - return raw + if (inner.error_type === undefined) return raw return inner.args?.description ?? inner.message ?? raw - } - catch { + } catch { return raw } } @@ -251,15 +247,16 @@ export async function collect( ): Promise<Record<string, unknown>> { const c = collectorFor(mode) for await (const ev of iter) { - if (ev.name === 'ping' || SILENT_EVENTS.has(ev.name)) - continue - if (ev.name === 'error') - throw decodeStreamError(ev.data) + if (ev.name === 'ping' || SILENT_EVENTS.has(ev.name)) continue + if (ev.name === 'error') throw decodeStreamError(ev.data) if (ev.name === 'human_input_required') { throw new HitlPauseError(parseJson(ev.data) as unknown as HitlPausePayload) } if (ev.name === 'workflow_paused') { - throw newError(ErrorCode.Unknown, 'workflow paused (non-interactive pause; check server logs)') + throw newError( + ErrorCode.Unknown, + 'workflow paused (non-interactive pause; check server logs)', + ) } c.consume(ev) } diff --git a/cli/src/commands/run/app/stream-handlers.test.ts b/cli/src/commands/run/app/stream-handlers.test.ts index ec9cd957bb5a9b..b1cf35ec324500 100644 --- a/cli/src/commands/run/app/stream-handlers.test.ts +++ b/cli/src/commands/run/app/stream-handlers.test.ts @@ -10,13 +10,18 @@ function ev(name: string, data: object): SseEvent { return { name, data: enc.encode(JSON.stringify(data)) } } -function captures(): { out: PassThrough, err: PassThrough, outBuf: () => string, errBuf: () => string } { +function captures(): { + out: PassThrough + err: PassThrough + outBuf: () => string + errBuf: () => string +} { const out = new PassThrough() const err = new PassThrough() const oc: Buffer[] = [] - out.on('data', d => oc.push(d as Buffer)) + out.on('data', (d) => oc.push(d as Buffer)) const ec: Buffer[] = [] - err.on('data', d => ec.push(d as Buffer)) + err.on('data', (d) => ec.push(d as Buffer)) return { out, err, @@ -38,7 +43,9 @@ describe('streamPrinterFor — chat', () => { }) function reasoningEvent(reasoning: string, isFinal: boolean) { - return ev('reasoning_chunk', { data: { message_id: 'm1', reasoning, node_id: 'llm-1', is_final: isFinal } }) + return ev('reasoning_chunk', { + data: { message_id: 'm1', reasoning, node_id: 'llm-1', is_final: isFinal }, + }) } describe('streamPrinterFor — chat separated reasoning', () => { @@ -114,7 +121,11 @@ describe('streamPrinterFor — workflow think filtering', () => { it('think: false (default) strips <think> from string outputs, nothing to stderr', () => { const sp = streamPrinterFor('workflow') const cap = captures() - sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: { text: '<think>hidden</think>\nresult' } } })) + sp.onEvent( + cap.out, + cap.err, + ev('workflow_finished', { data: { outputs: { text: '<think>hidden</think>\nresult' } } }), + ) sp.onEnd(cap.out, cap.err) const parsed = JSON.parse(cap.outBuf().trim()) as { text: string } expect(parsed.text).toBe('result') @@ -124,7 +135,11 @@ describe('streamPrinterFor — workflow think filtering', () => { it('think: true strips <think> from string outputs and routes thinking to stderr', () => { const sp = streamPrinterFor('workflow', true) const cap = captures() - sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: { text: '<think>reasoning</think>\nresult' } } })) + sp.onEvent( + cap.out, + cap.err, + ev('workflow_finished', { data: { outputs: { text: '<think>reasoning</think>\nresult' } } }), + ) sp.onEnd(cap.out, cap.err) const parsed = JSON.parse(cap.outBuf().trim()) as { text: string } expect(parsed.text).toBe('result') @@ -154,7 +169,11 @@ describe('streamPrinterFor — workflow separated reasoning', () => { sp.onEvent(cap.out, cap.err, wfReasoning('pon', 'llm-1', false)) sp.onEvent(cap.out, cap.err, wfReasoning('dering', 'llm-1', false)) sp.onEvent(cap.out, cap.err, wfReasoning('', 'llm-1', true)) - sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: { result: 'clean answer' } } })) + sp.onEvent( + cap.out, + cap.err, + ev('workflow_finished', { data: { outputs: { result: 'clean answer' } } }), + ) sp.onEnd(cap.out, cap.err) // node title precedes the reasoning block for attribution expect(cap.errBuf()).toContain('→ LLM') @@ -203,7 +222,7 @@ describe('streamPrinterFor — unknown mode', () => { }) }) -function capture(): { stream: Writable, buf: () => string } { +function capture(): { stream: Writable; buf: () => string } { const chunks: Buffer[] = [] const stream = new Writable({ write(chunk, _enc, cb) { @@ -234,7 +253,9 @@ describe('streamPrinterFor — HITL events', () => { expiration_time: 999, }, } - expect(() => sp.onEvent(stream, stream, ev('human_input_required', hitl))).toThrow(HitlPauseError) + expect(() => sp.onEvent(stream, stream, ev('human_input_required', hitl))).toThrow( + HitlPauseError, + ) }) }) @@ -256,7 +277,11 @@ describe('streamPrinterFor — think: false strips think blocks from streamed an it('chat: strips think block from live answer chunk', () => { const sp = streamPrinterFor('chat', false) const cap = captures() - sp.onEvent(cap.out, cap.err, ev('message', { conversation_id: 'c1', answer: '<think>reasoning</think>\nhello' })) + sp.onEvent( + cap.out, + cap.err, + ev('message', { conversation_id: 'c1', answer: '<think>reasoning</think>\nhello' }), + ) sp.onEnd(cap.out, cap.err) expect(cap.outBuf()).toBe('hello\n') expect(cap.errBuf()).not.toContain('reasoning') @@ -284,7 +309,11 @@ describe('streamPrinterFor — think: true routes thinking to stderr', () => { it('chat: routes think block to stderr', () => { const sp = streamPrinterFor('chat', true) const cap = captures() - sp.onEvent(cap.out, cap.err, ev('message', { answer: '<think>my reasoning</think>\nanswer text' })) + sp.onEvent( + cap.out, + cap.err, + ev('message', { answer: '<think>my reasoning</think>\nanswer text' }), + ) sp.onEnd(cap.out, cap.err) expect(cap.outBuf()).toBe('answer text\n') expect(cap.errBuf()).toContain('my reasoning') diff --git a/cli/src/commands/run/app/stream-handlers.ts b/cli/src/commands/run/app/stream-handlers.ts index 7ce96cf53c6fe6..b7f33e7bc0dd44 100644 --- a/cli/src/commands/run/app/stream-handlers.ts +++ b/cli/src/commands/run/app/stream-handlers.ts @@ -12,12 +12,10 @@ import { HitlPauseError } from './sse-collector' const dec = new TextDecoder() function parseJson(data: Uint8Array): Record<string, unknown> { - if (data.byteLength === 0) - return {} + if (data.byteLength === 0) return {} try { return JSON.parse(dec.decode(data)) as Record<string, unknown> - } - catch { + } catch { return {} } } @@ -33,8 +31,7 @@ const SILENT_EVENTS = new Set([ ]) function handleCommonEvents(ev: SseEvent): boolean { - if (SILENT_EVENTS.has(ev.name)) - return true + if (SILENT_EVENTS.has(ev.name)) return true if (ev.name === 'human_input_required') { throw new HitlPauseError(parseJson(ev.data) as unknown as HitlPausePayload) } @@ -54,25 +51,21 @@ class ChatStreamPrinter implements StreamPrinter { } onEvent(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream, ev: SseEvent): void { - if (handleCommonEvents(ev)) - return + if (handleCommonEvents(ev)) return const c = parseJson(ev.data) switch (ev.name) { case 'message': case 'agent_message': { - if (typeof c.answer === 'string') - this.filter.push(c.answer, out, errOut) + if (typeof c.answer === 'string') this.filter.push(c.answer, out, errOut) if (typeof c.conversation_id === 'string' && c.conversation_id !== '') this.convoId = c.conversation_id return } // Stream separated-mode reasoning to stderr under --think. case 'reasoning_chunk': { - if (!this.think) - return + if (!this.think) return const chunk = parseReasoningChunk(c) - if (chunk !== undefined) - this.reasoning.push(chunk, errOut) + if (chunk !== undefined) this.reasoning.push(chunk, errOut) return } case 'agent_thought': @@ -91,7 +84,9 @@ class ChatStreamPrinter implements StreamPrinter { out.write('\n') if (this.convoId !== '') { const cs = colorScheme(colorEnabled(this.isTTY)) - errOut.write(`${cs.magenta('hint:')} continue this conversation with --conversation ${cs.cyan(this.convoId)}\n`) + errOut.write( + `${cs.magenta('hint:')} continue this conversation with --conversation ${cs.cyan(this.convoId)}\n`, + ) } } } @@ -103,13 +98,10 @@ class CompletionStreamPrinter implements StreamPrinter { } onEvent(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream, ev: SseEvent): void { - if (handleCommonEvents(ev)) - return - if (ev.name !== 'message') - return + if (handleCommonEvents(ev)) return + if (ev.name !== 'message') return const c = parseJson(ev.data) - if (typeof c.answer === 'string') - this.filter.push(c.answer, out, errOut) + if (typeof c.answer === 'string') this.filter.push(c.answer, out, errOut) } onEnd(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream): void { @@ -127,25 +119,24 @@ class WorkflowStreamPrinter implements StreamPrinter { } onEvent(_out: NodeJS.WritableStream, errOut: NodeJS.WritableStream, ev: SseEvent): void { - if (handleCommonEvents(ev)) - return + if (handleCommonEvents(ev)) return const c = parseJson(ev.data) switch (ev.name) { case 'node_started': { - const title = (typeof c.title === 'string' && c.title !== '') - ? c.title - : (typeof c.id === 'string' ? c.id : '') - if (title !== '') - errOut.write(`→ ${title}\n`) + const title = + typeof c.title === 'string' && c.title !== '' + ? c.title + : typeof c.id === 'string' + ? c.id + : '' + if (title !== '') errOut.write(`→ ${title}\n`) return } // Stream separated-mode reasoning to stderr under --think; the prior → title attributes the node. case 'reasoning_chunk': { - if (!this.think) - return + if (!this.think) return const chunk = parseReasoningChunk(c) - if (chunk !== undefined) - this.reasoning.push(chunk, errOut) + if (chunk !== undefined) this.reasoning.push(chunk, errOut) return } case 'node_finished': { @@ -163,15 +154,16 @@ class WorkflowStreamPrinter implements StreamPrinter { onEnd(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream): void { this.reasoning.flush(errOut) - if (this.final === undefined) - return + if (this.final === undefined) return const data = this.final.data if (data !== null && typeof data === 'object' && 'outputs' in data) { const raw = (data as { outputs: unknown }).outputs if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) { - const { outputs, thinking } = filterThinkInOutputs(raw as Record<string, unknown>, this.think) - if (this.think && thinking !== '') - errOut.write(`${thinking}\n`) + const { outputs, thinking } = filterThinkInOutputs( + raw as Record<string, unknown>, + this.think, + ) + if (this.think && thinking !== '') errOut.write(`${thinking}\n`) out.write(`${JSON.stringify(outputs)}\n`) return } @@ -192,7 +184,6 @@ const FACTORIES: Record<string, (think: boolean, isTTY: boolean) => StreamPrinte export function streamPrinterFor(mode: string, think = false, isTTY = false): StreamPrinter { const f = FACTORIES[mode] - if (f === undefined) - throw newError(ErrorCode.Unknown, `unsupported streaming mode "${mode}"`) + if (f === undefined) throw newError(ErrorCode.Unknown, `unsupported streaming mode "${mode}"`) return f(think, isTTY) } diff --git a/cli/src/commands/set/member/index.ts b/cli/src/commands/set/member/index.ts index 7d0ef201682132..8ef3c7e25456a8 100644 --- a/cli/src/commands/set/member/index.ts +++ b/cli/src/commands/set/member/index.ts @@ -6,7 +6,7 @@ import { formatted, OutputFormat } from '@/framework/output' import { runSetMember } from './run' export default class SetMember extends DifyCommand { - static override description = 'Change a member\'s role in the active (or specified) workspace' + static override description = "Change a member's role in the active (or specified) workspace" static override effect: CommandEffect = 'write' @@ -21,16 +21,19 @@ export default class SetMember extends DifyCommand { } static override flags = { - 'role': Flags.string({ + role: Flags.string({ description: 'new role (normal|admin); owner is not assignable here', required: true, }), - 'workspace': Flags.string({ + workspace: Flags.string({ char: 'w', description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', }), 'http-retry': httpRetryFlag, - 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }), + output: Flags.outputFormat({ + options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], + default: '', + }), } async run(argv: string[]) { diff --git a/cli/src/commands/set/member/run.test.ts b/cli/src/commands/set/member/run.test.ts index 2e86a2afe09944..8fceab755e4525 100644 --- a/cli/src/commands/set/member/run.test.ts +++ b/cli/src/commands/set/member/run.test.ts @@ -33,7 +33,11 @@ describe('runSetMember', () => { membersFactory: () => client as never, }, ) - expect(client.updateRole).toHaveBeenCalledExactlyOnceWith('550e8400-e29b-41d4-a716-446655440000', 'acct-2', { role: 'admin' }) + expect(client.updateRole).toHaveBeenCalledExactlyOnceWith( + '550e8400-e29b-41d4-a716-446655440000', + 'acct-2', + { role: 'admin' }, + ) expect(result.data.text()).toMatch(/Set acct-2 role to admin/) expect(result.data.name()).toBe('acct-2') expect(result.data.json()).toEqual({ id: 'acct-2', role: 'admin' }) @@ -82,6 +86,10 @@ describe('runSetMember', () => { membersFactory: () => client as never, }, ) - expect(client.updateRole).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440008', 'acct-2', { role: 'normal' }) + expect(client.updateRole).toHaveBeenCalledWith( + '550e8400-e29b-41d4-a716-446655440008', + 'acct-2', + { role: 'normal' }, + ) }) }) diff --git a/cli/src/commands/set/member/run.ts b/cli/src/commands/set/member/run.ts index b627ef27f8df31..0089dce2a4ae45 100644 --- a/cli/src/commands/set/member/run.ts +++ b/cli/src/commands/set/member/run.ts @@ -62,9 +62,8 @@ export async function runSetMember( active: deps.active, }) - await runWithSpinner( - { io, label: `Updating role for ${opts.memberId}` }, - () => factory(deps.http).updateRole(wsId, opts.memberId, { + await runWithSpinner({ io, label: `Updating role for ${opts.memberId}` }, () => + factory(deps.http).updateRole(wsId, opts.memberId, { role: opts.role as 'normal' | 'admin', }), ) diff --git a/cli/src/commands/skills/install/index.test.ts b/cli/src/commands/skills/install/index.test.ts index 03b0dc07b54262..7d6945e67cb616 100644 --- a/cli/src/commands/skills/install/index.test.ts +++ b/cli/src/commands/skills/install/index.test.ts @@ -6,9 +6,11 @@ import { renderSkill } from '@/help/skill' import { versionInfo } from '@/version/info' import SkillsInstall from './index' -const tree: CommandTree = { skills: { subcommands: { install: { command: SkillsInstall, subcommands: {} } } } } +const tree: CommandTree = { + skills: { subcommands: { install: { command: SkillsInstall, subcommands: {} } } }, +} -type Captured = { stdout: string, stderr: string, exit: number | undefined } +type Captured = { stdout: string; stderr: string; exit: number | undefined } async function captureRun(argv: string[]): Promise<Captured> { const captured: Captured = { stdout: '', stderr: '', exit: undefined } @@ -30,8 +32,7 @@ async function captureRun(argv: string[]): Promise<Captured> { try { await run(tree, argv) - } - finally { + } finally { process.stdout.write = origStdout process.stderr.write = origStderr process.exit = origExit diff --git a/cli/src/commands/skills/install/index.ts b/cli/src/commands/skills/install/index.ts index dff3363fdaa84f..e223c105ac59c0 100644 --- a/cli/src/commands/skills/install/index.ts +++ b/cli/src/commands/skills/install/index.ts @@ -22,13 +22,26 @@ export default class SkillsInstall extends Command { ] static override args = { - dir: Args.string({ description: 'force install into a single explicit directory (bypasses detection)' }), + dir: Args.string({ + description: 'force install into a single explicit directory (bypasses detection)', + }), } static override flags = { - yes: Flags.boolean({ char: 'y', description: 'write the skill (otherwise dry-run)', default: false }), - agent: Flags.stringArray({ description: 'restrict to specific detected agents (repeatable or comma-separated)', default: [], multiple: true }), - stdout: Flags.boolean({ description: 'print SKILL.md to stdout and write nothing', default: false }), + yes: Flags.boolean({ + char: 'y', + description: 'write the skill (otherwise dry-run)', + default: false, + }), + agent: Flags.stringArray({ + description: 'restrict to specific detected agents (repeatable or comma-separated)', + default: [], + multiple: true, + }), + stdout: Flags.boolean({ + description: 'print SKILL.md to stdout and write nothing', + default: false, + }), } async run(argv: string[]): Promise<CommandOutput> { @@ -36,10 +49,16 @@ export default class SkillsInstall extends Command { const dir = args.dir const hasDir = dir !== undefined && dir !== '' - const agents = flags.agent.flatMap(a => a.split(',')).map(s => s.trim()).filter(s => s.length > 0) + const agents = flags.agent + .flatMap((a) => a.split(',')) + .map((s) => s.trim()) + .filter((s) => s.length > 0) if (flags.stdout && (flags.yes || hasDir || agents.length > 0)) - throw newError(ErrorCode.IllegalArgumentError, '--stdout writes nothing; do not combine it with --yes, --agent, or [dir]') + throw newError( + ErrorCode.IllegalArgumentError, + '--stdout writes nothing; do not combine it with --yes, --agent, or [dir]', + ) if (hasDir && agents.length > 0) throw newError(ErrorCode.IllegalArgumentError, 'pass either [dir] or --agent, not both') @@ -52,8 +71,7 @@ export default class SkillsInstall extends Command { agents, }) - if (result.kind === 'usage') - throw newError(ErrorCode.IllegalArgumentError, result.message) + if (result.kind === 'usage') throw newError(ErrorCode.IllegalArgumentError, result.message) return raw(result.text) } diff --git a/cli/src/commands/skills/install/registry.test.ts b/cli/src/commands/skills/install/registry.test.ts index f4d17848dc14c5..6860ef9d1641c0 100644 --- a/cli/src/commands/skills/install/registry.test.ts +++ b/cli/src/commands/skills/install/registry.test.ts @@ -21,7 +21,7 @@ describe('detectAgents', () => { it('detects an agent once its config dir exists', async () => { await mkdir(join(home, '.codex')) - expect(detectAgents(home).map(a => a.name)).toEqual(['codex']) + expect(detectAgents(home).map((a) => a.name)).toEqual(['codex']) }) it('detects every agent whose config dir exists, in registry order', async () => { @@ -30,13 +30,19 @@ describe('detectAgents', () => { await mkdir(join(home, '.config', 'opencode'), { recursive: true }) await mkdir(join(home, '.cursor')) await mkdir(join(home, '.pi')) - expect(detectAgents(home).map(a => a.name)).toEqual(['claude-code', 'codex', 'opencode', 'cursor', 'pi']) + expect(detectAgents(home).map((a) => a.name)).toEqual([ + 'claude-code', + 'codex', + 'opencode', + 'cursor', + 'pi', + ]) }) it('detects cursor and pi by their config dirs', async () => { await mkdir(join(home, '.cursor')) await mkdir(join(home, '.pi')) - expect(detectAgents(home).map(a => a.name)).toEqual(['cursor', 'pi']) + expect(detectAgents(home).map((a) => a.name)).toEqual(['cursor', 'pi']) }) }) @@ -44,26 +50,26 @@ describe('agent registry paths', () => { const home = join('home', 'dev') it('installs Codex skills under ~/.agents/skills, detected via ~/.codex', () => { - const codex = AGENTS.find(a => a.name === 'codex') + const codex = AGENTS.find((a) => a.name === 'codex') expect(codex?.probeDir(home)).toBe(join(home, '.codex')) expect(codex?.skillDir(home)).toBe(join(home, '.agents', 'skills', 'difyctl')) }) it('installs Claude Code and opencode skills under their native dirs', () => { - const claude = AGENTS.find(a => a.name === 'claude-code') - const opencode = AGENTS.find(a => a.name === 'opencode') + const claude = AGENTS.find((a) => a.name === 'claude-code') + const opencode = AGENTS.find((a) => a.name === 'opencode') expect(claude?.skillDir(home)).toBe(join(home, '.claude', 'skills', 'difyctl')) expect(opencode?.skillDir(home)).toBe(join(home, '.config', 'opencode', 'skills', 'difyctl')) }) it('installs Cursor under ~/.cursor/skills, detected via ~/.cursor', () => { - const cursor = AGENTS.find(a => a.name === 'cursor') + const cursor = AGENTS.find((a) => a.name === 'cursor') expect(cursor?.probeDir(home)).toBe(join(home, '.cursor')) expect(cursor?.skillDir(home)).toBe(join(home, '.cursor', 'skills', 'difyctl')) }) it('installs pi under ~/.pi/agent/skills, detected via ~/.pi', () => { - const pi = AGENTS.find(a => a.name === 'pi') + const pi = AGENTS.find((a) => a.name === 'pi') expect(pi?.probeDir(home)).toBe(join(home, '.pi')) expect(pi?.skillDir(home)).toBe(join(home, '.pi', 'agent', 'skills', 'difyctl')) }) diff --git a/cli/src/commands/skills/install/registry.ts b/cli/src/commands/skills/install/registry.ts index 0d0242514f5954..c7ccce8403c443 100644 --- a/cli/src/commands/skills/install/registry.ts +++ b/cli/src/commands/skills/install/registry.ts @@ -26,33 +26,33 @@ export type AgentEntry = { export const AGENTS: readonly AgentEntry[] = [ { name: 'claude-code', - probeDir: home => join(home, '.claude'), - skillDir: home => join(home, '.claude', 'skills', 'difyctl'), + probeDir: (home) => join(home, '.claude'), + skillDir: (home) => join(home, '.claude', 'skills', 'difyctl'), }, { name: 'codex', - probeDir: home => join(home, '.codex'), - skillDir: home => join(home, '.agents', 'skills', 'difyctl'), + probeDir: (home) => join(home, '.codex'), + skillDir: (home) => join(home, '.agents', 'skills', 'difyctl'), }, { name: 'opencode', - probeDir: home => join(home, '.config', 'opencode'), - skillDir: home => join(home, '.config', 'opencode', 'skills', 'difyctl'), + probeDir: (home) => join(home, '.config', 'opencode'), + skillDir: (home) => join(home, '.config', 'opencode', 'skills', 'difyctl'), }, { name: 'cursor', - probeDir: home => join(home, '.cursor'), - skillDir: home => join(home, '.cursor', 'skills', 'difyctl'), + probeDir: (home) => join(home, '.cursor'), + skillDir: (home) => join(home, '.cursor', 'skills', 'difyctl'), }, { name: 'pi', - probeDir: home => join(home, '.pi'), - skillDir: home => join(home, '.pi', 'agent', 'skills', 'difyctl'), + probeDir: (home) => join(home, '.pi'), + skillDir: (home) => join(home, '.pi', 'agent', 'skills', 'difyctl'), }, ] // Agents whose config dir exists under `home`. `home` is injectable so tests can // point at a temp dir instead of the real home. export function detectAgents(home: string = homedir()): readonly AgentEntry[] { - return AGENTS.filter(agent => existsSync(agent.probeDir(home))) + return AGENTS.filter((agent) => existsSync(agent.probeDir(home))) } diff --git a/cli/src/commands/skills/install/run.test.ts b/cli/src/commands/skills/install/run.test.ts index 734ac357dd84fe..aaa98179f9cde2 100644 --- a/cli/src/commands/skills/install/run.test.ts +++ b/cli/src/commands/skills/install/run.test.ts @@ -33,15 +33,18 @@ describe('runSkillsInstall', () => { const claudeTarget = () => join(home, '.claude', 'skills', 'difyctl', 'SKILL.md') it('--stdout returns the skill verbatim and writes nothing', async () => { - expect(await runSkillsInstall(opts({ stdout: true }))).toEqual({ kind: 'ok', text: SKILL, wrote: [] }) + expect(await runSkillsInstall(opts({ stdout: true }))).toEqual({ + kind: 'ok', + text: SKILL, + wrote: [], + }) }) it('dry-run lists detected agents and writes nothing', async () => { await mkdir(join(home, '.claude')) const result = await runSkillsInstall(opts({})) expect(result.kind).toBe('ok') - if (result.kind !== 'ok') - return + if (result.kind !== 'ok') return expect(result.text).toContain('Detected 1 agent: claude-code') expect(result.text).toContain(`would write to claude-code: ${claudeTarget()}`) expect(result.text).toContain('Re-run with --yes') @@ -58,8 +61,7 @@ describe('runSkillsInstall', () => { await mkdir(join(home, '.codex')) const result = await runSkillsInstall(opts({})) expect(result.kind).toBe('ok') - if (result.kind !== 'ok') - return + if (result.kind !== 'ok') return // The detection summary lists the selectable names; the footer just shows the flag. expect(result.text).toContain('Detected 2 agents: claude-code, codex') expect(result.text).toContain('--agent <name> to write only some') @@ -70,7 +72,11 @@ describe('runSkillsInstall', () => { it('--yes writes the skill and reports the actual path', async () => { await mkdir(join(home, '.claude')) const result = await runSkillsInstall(opts({ write: true })) - expect(result).toEqual({ kind: 'ok', text: `wrote ${claudeTarget()}\n`, wrote: [claudeTarget()] }) + expect(result).toEqual({ + kind: 'ok', + text: `wrote ${claudeTarget()}\n`, + wrote: [claudeTarget()], + }) expect(await readFile(claudeTarget(), 'utf8')).toBe(SKILL) }) @@ -84,8 +90,7 @@ describe('runSkillsInstall', () => { it('guides the user and writes nothing when no agents are detected', async () => { const result = await runSkillsInstall(opts({ write: true })) expect(result.kind).toBe('ok') - if (result.kind !== 'ok') - return + if (result.kind !== 'ok') return expect(result.text).toContain('No agents detected') expect(result.wrote).toEqual([]) }) @@ -94,8 +99,7 @@ describe('runSkillsInstall', () => { await mkdir(join(home, '.claude')) const result = await runSkillsInstall(opts({ write: true, agents: ['bogus'] })) expect(result.kind).toBe('usage') - if (result.kind !== 'usage') - return + if (result.kind !== 'usage') return expect(result.message).toContain('bogus') }) @@ -118,8 +122,7 @@ describe('runSkillsInstall', () => { await mkdir(join(home, '.config', 'opencode'), { recursive: true }) const result = await runSkillsInstall(opts({ write: true })) expect(result.kind).toBe('ok') - if (result.kind !== 'ok') - return + if (result.kind !== 'ok') return expect(result.wrote).toEqual([ join(home, '.claude', 'skills', 'difyctl', 'SKILL.md'), join(home, '.agents', 'skills', 'difyctl', 'SKILL.md'), @@ -132,8 +135,7 @@ describe('runSkillsInstall', () => { await mkdir(join(home, '.codex')) const result = await runSkillsInstall(opts({ write: true, agents: ['codex'] })) expect(result.kind).toBe('ok') - if (result.kind !== 'ok') - return + if (result.kind !== 'ok') return expect(result.wrote).toEqual([join(home, '.agents', 'skills', 'difyctl', 'SKILL.md')]) }) @@ -142,12 +144,13 @@ describe('runSkillsInstall', () => { await mkdir(join(home, '.pi')) const result = await runSkillsInstall(opts({ write: true })) expect(result.kind).toBe('ok') - if (result.kind !== 'ok') - return + if (result.kind !== 'ok') return expect(result.wrote).toEqual([ join(home, '.cursor', 'skills', 'difyctl', 'SKILL.md'), join(home, '.pi', 'agent', 'skills', 'difyctl', 'SKILL.md'), ]) - expect(await readFile(join(home, '.pi', 'agent', 'skills', 'difyctl', 'SKILL.md'), 'utf8')).toBe(SKILL) + expect( + await readFile(join(home, '.pi', 'agent', 'skills', 'difyctl', 'SKILL.md'), 'utf8'), + ).toBe(SKILL) }) }) diff --git a/cli/src/commands/skills/install/run.ts b/cli/src/commands/skills/install/run.ts index ddb9a992c0b4ff..8dcafb6b22063f 100644 --- a/cli/src/commands/skills/install/run.ts +++ b/cli/src/commands/skills/install/run.ts @@ -19,9 +19,9 @@ export type SkillsInstallOptions = { readonly home?: string } -export type SkillsInstallResult - = | { readonly kind: 'ok', readonly text: string, readonly wrote: readonly string[] } - | { readonly kind: 'usage', readonly message: string } +export type SkillsInstallResult = + | { readonly kind: 'ok'; readonly text: string; readonly wrote: readonly string[] } + | { readonly kind: 'usage'; readonly message: string } type InstallTarget = { readonly name: string @@ -38,37 +38,44 @@ async function writeSkill(content: string, target: string): Promise<void> { await rename(tmp, target) } -function resolveTargets(opts: SkillsInstallOptions, home: string): InstallTarget[] | SkillsInstallResult { +function resolveTargets( + opts: SkillsInstallOptions, + home: string, +): InstallTarget[] | SkillsInstallResult { // Explicit directory: skip detection entirely. if (opts.dir !== undefined && opts.dir !== '') return [{ name: opts.dir, path: join(resolve(opts.dir), 'SKILL.md') }] const detected = detectAgents(home) - const target = (a: AgentEntry): InstallTarget => ({ name: a.name, path: join(a.skillDir(home), 'SKILL.md') }) + const target = (a: AgentEntry): InstallTarget => ({ + name: a.name, + path: join(a.skillDir(home), 'SKILL.md'), + }) // An explicit --agent must name agents that are actually detected. This is // checked before the zero-detected guidance below: naming an agent that is // not present (including when none are present) is a usage error, per spec. if (opts.agents.length > 0) { - const known = new Set(detected.map(a => a.name)) - const unknown = opts.agents.filter(name => !known.has(name)) + const known = new Set(detected.map((a) => a.name)) + const unknown = opts.agents.filter((name) => !known.has(name)) if (unknown.length > 0) { return { kind: 'usage', message: `unknown or undetected agent(s): ${unknown.join(', ')} (detected: ${[...known].join(', ') || 'none'})`, } } - return detected.filter(a => opts.agents.includes(a.name)).map(target) + return detected.filter((a) => opts.agents.includes(a.name)).map(target) } // No --agent and nothing detected: not an error — guide the user, write nothing. if (detected.length === 0) { - const lookedFor = AGENTS.map(a => a.probeDir(home).replace(home, '~')).join(', ') + const lookedFor = AGENTS.map((a) => a.probeDir(home).replace(home, '~')).join(', ') return { kind: 'ok', - text: `No agents detected (looked for ${lookedFor}).\n` - + 'Install into a directory manually with `difyctl skills install <dir>`, or\n' - + 'print the skill with `difyctl skills install --stdout`.\n', + text: + `No agents detected (looked for ${lookedFor}).\n` + + 'Install into a directory manually with `difyctl skills install <dir>`, or\n' + + 'print the skill with `difyctl skills install --stdout`.\n', wrote: [], } } @@ -81,31 +88,30 @@ export async function runSkillsInstall(opts: SkillsInstallOptions): Promise<Skil const content = renderSkill({ version: opts.version }) // --stdout: emit the skill, write nothing. - if (opts.stdout) - return { kind: 'ok', text: content, wrote: [] } + if (opts.stdout) return { kind: 'ok', text: content, wrote: [] } const targets = resolveTargets(opts, home) // resolveTargets short-circuits to a terminal result (zero detected / usage). - if (!Array.isArray(targets)) - return targets + if (!Array.isArray(targets)) return targets // Dry-run: list where the skill would land, write nothing. if (!opts.write) { - const lines = targets.map(t => `would write to ${t.name}: ${t.path}`).join('\n') + const lines = targets.map((t) => `would write to ${t.name}: ${t.path}`).join('\n') // Explicit <dir>: no detection happened, so no agent summary / selectors. if (opts.dir !== undefined && opts.dir !== '') return { kind: 'ok', text: `${lines}\n\nRe-run with --yes to write.\n`, wrote: [] } - const names = targets.map(t => t.name) + const names = targets.map((t) => t.name) const selected = opts.agents.length > 0 const header = `${selected ? 'Selected' : 'Detected'} ${names.length} agent${names.length === 1 ? '' : 's'}: ${names.join(', ')}` // Only suggest --agent when the user hasn't already used it and there is more // than one to choose from. The selectable names are the ones listed above, so // the hint just shows the flag, not the (already-visible) name list. - const pick = (!selected && names.length > 1) - ? 'Re-run with --yes to write all, or --agent <name> to write only some.' - : 'Re-run with --yes to write.' + const pick = + !selected && names.length > 1 + ? 'Re-run with --yes to write all, or --agent <name> to write only some.' + : 'Re-run with --yes to write.' const footer = `${pick}\nAgent not listed? Install into its directory with \`difyctl skills install <dir>\`.` return { kind: 'ok', text: `${header}\n\n${lines}\n\n${footer}\n`, wrote: [] } } @@ -115,5 +121,5 @@ export async function runSkillsInstall(opts: SkillsInstallOptions): Promise<Skil await writeSkill(content, target.path) wrote.push(target.path) } - return { kind: 'ok', text: `${wrote.map(p => `wrote ${p}`).join('\n')}\n`, wrote } + return { kind: 'ok', text: `${wrote.map((p) => `wrote ${p}`).join('\n')}\n`, wrote } } diff --git a/cli/src/commands/use/account/index.ts b/cli/src/commands/use/account/index.ts index 1f62ffb6142c4f..182c39b51d54a8 100644 --- a/cli/src/commands/use/account/index.ts +++ b/cli/src/commands/use/account/index.ts @@ -12,7 +12,11 @@ export default class UseAccount extends DifyCommand { ] static override flags = { - email: Flags.string({ description: 'email of the account to switch to (interactive picker shown when omitted in TTY)', default: '' }), + email: Flags.string({ + description: + 'email of the account to switch to (interactive picker shown when omitted in TTY)', + default: '', + }), } async run(argv: string[]): Promise<void> { diff --git a/cli/src/commands/use/account/use-account.test.ts b/cli/src/commands/use/account/use-account.test.ts index d6fa4124a40dfd..0fa5a13346fd08 100644 --- a/cli/src/commands/use/account/use-account.test.ts +++ b/cli/src/commands/use/account/use-account.test.ts @@ -17,25 +17,31 @@ describe('runUseAccount', () => { }) it('switches current_account when email valid + token present', async () => { - await runUseAccount({ io: bufferStreams(), email: 'b@x', store: new MemStore({ 'h1 b@x': 'dfoa_b' }) }) + await runUseAccount({ + io: bufferStreams(), + email: 'b@x', + store: new MemStore({ 'h1 b@x': 'dfoa_b' }), + }) expect((await Registry.load()).hosts.h1?.current_account).toBe('b@x') }) it('errors when the account has no stored token', async () => { - await expect(runUseAccount({ io: bufferStreams(), email: 'b@x', store: new MemStore({}) })) - .rejects - .toThrow(/log in|no credential/i) + await expect( + runUseAccount({ io: bufferStreams(), email: 'b@x', store: new MemStore({}) }), + ).rejects.toThrow(/log in|no credential/i) }) it('errors when the email is unknown on the current host', async () => { - await expect(runUseAccount({ io: bufferStreams(), email: 'z@x', store: new MemStore({ 'h1 z@x': 'x' }) })) - .rejects - .toThrow(/unknown account|no account/i) + await expect( + runUseAccount({ io: bufferStreams(), email: 'z@x', store: new MemStore({ 'h1 z@x': 'x' }) }), + ).rejects.toThrow(/unknown account|no account/i) }) it('errors in non-TTY when email omitted', async () => { const io = bufferStreams() ;(io as { isErrTTY: boolean }).isErrTTY = false - await expect(runUseAccount({ io, email: undefined, store: new MemStore({}) })).rejects.toThrow(/--email/i) + await expect(runUseAccount({ io, email: undefined, store: new MemStore({}) })).rejects.toThrow( + /--email/i, + ) }) }) diff --git a/cli/src/commands/use/account/use-account.ts b/cli/src/commands/use/account/use-account.ts index ca53333e2c1c1c..bac310bc3cd739 100644 --- a/cli/src/commands/use/account/use-account.ts +++ b/cli/src/commands/use/account/use-account.ts @@ -15,22 +15,20 @@ export type UseAccountOptions = { readonly store?: TokenStore } -type AccountChoice = { email: string, name: string, sso: boolean, active: boolean } +type AccountChoice = { email: string; name: string; sso: boolean; active: boolean } -const USE_HOST_HINT = 'run \'difyctl use host\' or \'difyctl auth login\'' +const USE_HOST_HINT = "run 'difyctl use host' or 'difyctl auth login'" export async function runUseAccount(opts: UseAccountOptions): Promise<void> { const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) const reg = await Registry.load() - if (reg.current_host === undefined) - throw notLoggedInError(USE_HOST_HINT) + if (reg.current_host === undefined) throw notLoggedInError(USE_HOST_HINT) const host = reg.current_host const entry = reg.hosts[host] - if (entry === undefined) - throw notLoggedInError(USE_HOST_HINT) + if (entry === undefined) throw notLoggedInError(USE_HOST_HINT) const emails = Object.keys(entry.accounts) - const target = opts.email ?? await pickAccount(opts, entry, host) + const target = opts.email ?? (await pickAccount(opts, entry, host)) if (!emails.includes(target)) { throw new BaseError({ code: ErrorCode.UsageInvalidFlag, @@ -39,7 +37,7 @@ export async function runUseAccount(opts: UseAccountOptions): Promise<void> { } const store = opts.store ?? getTokenStore(reg.token_storage) - if (await store.read(host, target) === '') { + if ((await store.read(host, target)) === '') { throw new BaseError({ code: ErrorCode.NotLoggedIn, message: `no credential stored for ${target} on ${host}`, @@ -52,7 +50,11 @@ export async function runUseAccount(opts: UseAccountOptions): Promise<void> { opts.io.out.write(`${cs.successIcon()} Active account on ${host} is now ${target}\n`) } -async function pickAccount(opts: UseAccountOptions, entry: HostEntry, host: string): Promise<string> { +async function pickAccount( + opts: UseAccountOptions, + entry: HostEntry, + host: string, +): Promise<string> { const emails = Object.keys(entry.accounts) if (!opts.io.isErrTTY) { throw new BaseError({ @@ -70,7 +72,8 @@ async function pickAccount(opts: UseAccountOptions, entry: HostEntry, host: stri io: opts.io, items: choices, header: `Select an account on ${host}`, - render: c => `${c.active ? '* ' : ' '}${c.email} ${c.sso ? '(SSO)' : c.name !== '' ? `(${c.name})` : ''}`.trimEnd(), + render: (c) => + `${c.active ? '* ' : ' '}${c.email} ${c.sso ? '(SSO)' : c.name !== '' ? `(${c.name})` : ''}`.trimEnd(), }) return picked.email } diff --git a/cli/src/commands/use/host/index.ts b/cli/src/commands/use/host/index.ts index ade54647dbf665..bfd976269473d6 100644 --- a/cli/src/commands/use/host/index.ts +++ b/cli/src/commands/use/host/index.ts @@ -12,7 +12,11 @@ export default class UseHost extends DifyCommand { ] static override flags = { - domain: Flags.string({ description: 'host domain to switch to, e.g. cloud.dify.ai (interactive picker shown when omitted in TTY)', default: '' }), + domain: Flags.string({ + description: + 'host domain to switch to, e.g. cloud.dify.ai (interactive picker shown when omitted in TTY)', + default: '', + }), } async run(argv: string[]): Promise<void> { diff --git a/cli/src/commands/use/host/use-host.test.ts b/cli/src/commands/use/host/use-host.test.ts index 259347faeb23d8..9ca2a698301244 100644 --- a/cli/src/commands/use/host/use-host.test.ts +++ b/cli/src/commands/use/host/use-host.test.ts @@ -21,7 +21,9 @@ describe('runUseHost', () => { }) it('errors when host is unknown, listing valid hosts', async () => { - await expect(runUseHost({ io: bufferStreams(), host: 'nope' })).rejects.toThrow(/h1.*h2|unknown host/i) + await expect(runUseHost({ io: bufferStreams(), host: 'nope' })).rejects.toThrow( + /h1.*h2|unknown host/i, + ) }) it('errors in non-TTY when host omitted', async () => { @@ -32,6 +34,8 @@ describe('runUseHost', () => { it('errors when no hosts exist', async () => { await Registry.empty('file').save() - await expect(runUseHost({ io: bufferStreams(), host: 'h1' })).rejects.toThrow(/no hosts|not logged in/i) + await expect(runUseHost({ io: bufferStreams(), host: 'h1' })).rejects.toThrow( + /no hosts|not logged in/i, + ) }) }) diff --git a/cli/src/commands/use/host/use-host.ts b/cli/src/commands/use/host/use-host.ts index fb442001de53a7..d0308200554c6e 100644 --- a/cli/src/commands/use/host/use-host.ts +++ b/cli/src/commands/use/host/use-host.ts @@ -10,16 +10,15 @@ export type UseHostOptions = { readonly host: string | undefined } -type HostChoice = { host: string, accounts: number, active: boolean } +type HostChoice = { host: string; accounts: number; active: boolean } export async function runUseHost(opts: UseHostOptions): Promise<void> { const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) const reg = await Registry.load() const hosts = Object.keys(reg.hosts) - if (hosts.length === 0) - throw notLoggedInError() + if (hosts.length === 0) throw notLoggedInError() - const target = opts.host ?? await pickHost(opts, reg, hosts) + const target = opts.host ?? (await pickHost(opts, reg, hosts)) if (!hosts.includes(target)) { throw new BaseError({ code: ErrorCode.UsageInvalidFlag, @@ -32,14 +31,18 @@ export async function runUseHost(opts: UseHostOptions): Promise<void> { opts.io.out.write(`${cs.successIcon()} Active host is now ${target}\n`) } -async function pickHost(opts: UseHostOptions, reg: Registry, hosts: readonly string[]): Promise<string> { +async function pickHost( + opts: UseHostOptions, + reg: Registry, + hosts: readonly string[], +): Promise<string> { if (!opts.io.isErrTTY) { throw new BaseError({ code: ErrorCode.UsageMissingArg, message: `--domain is required (no TTY); known hosts: ${hosts.join(', ')}`, }) } - const choices: HostChoice[] = hosts.map(h => ({ + const choices: HostChoice[] = hosts.map((h) => ({ host: h, accounts: Object.keys(reg.hosts[h]?.accounts ?? {}).length, active: reg.current_host === h, @@ -48,7 +51,8 @@ async function pickHost(opts: UseHostOptions, reg: Registry, hosts: readonly str io: opts.io, items: choices, header: 'Select a host', - render: c => `${c.active ? '* ' : ' '}${c.host} (${c.accounts} account${c.accounts === 1 ? '' : 's'})`, + render: (c) => + `${c.active ? '* ' : ' '}${c.host} (${c.accounts} account${c.accounts === 1 ? '' : 's'})`, }) return picked.host } diff --git a/cli/src/commands/use/workspace/index.ts b/cli/src/commands/use/workspace/index.ts index 9df7c5ffd769ed..38acc3f39aaf13 100644 --- a/cli/src/commands/use/workspace/index.ts +++ b/cli/src/commands/use/workspace/index.ts @@ -5,7 +5,8 @@ import { Args } from '@/framework/flags' import { runUseWorkspace } from './use' export default class UseWorkspace extends DifyCommand { - static override description = 'Switch the active workspace on the server (omit the id to pick interactively)' + static override description = + 'Switch the active workspace on the server (omit the id to pick interactively)' static override effect: CommandEffect = 'write' @@ -15,7 +16,10 @@ export default class UseWorkspace extends DifyCommand { ] static override args = { - workspaceId: Args.string({ description: 'workspace id to switch to (omit to pick interactively)', required: false }), + workspaceId: Args.string({ + description: 'workspace id to switch to (omit to pick interactively)', + required: false, + }), } static override flags = { @@ -25,11 +29,14 @@ export default class UseWorkspace extends DifyCommand { async run(argv: string[]): Promise<void> { const { args, flags } = this.parse(UseWorkspace, argv) const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] }) - await runUseWorkspace({ workspaceId: args.workspaceId }, { - reg: ctx.reg, - active: ctx.active, - http: ctx.http, - io: ctx.io, - }) + await runUseWorkspace( + { workspaceId: args.workspaceId }, + { + reg: ctx.reg, + active: ctx.active, + http: ctx.http, + io: ctx.io, + }, + ) } } diff --git a/cli/src/commands/use/workspace/use.test.ts b/cli/src/commands/use/workspace/use.test.ts index 7feeb4e0fedc15..386b2d50f5b77e 100644 --- a/cli/src/commands/use/workspace/use.test.ts +++ b/cli/src/commands/use/workspace/use.test.ts @@ -30,8 +30,7 @@ function makeRegistry(): Registry { function makeActive(reg: Registry): ActiveContext { const active = reg.resolveActive() - if (active === undefined) - throw new Error('resolveActive returned undefined in test setup') + if (active === undefined) throw new Error('resolveActive returned undefined in test setup') return active } @@ -53,12 +52,22 @@ function fakeClient(opts: { }) { return { switch: vi.fn(opts.switch ?? (() => Promise.resolve(makeDetail()))), - list: vi.fn(opts.list ?? (() => Promise.resolve({ - workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, - { id: '00000000-0000-0000-0000-000000000002', name: 'Two', role: 'owner', status: 'normal', current: false }, - ], - }))), + list: vi.fn( + opts.list ?? + (() => + Promise.resolve({ + workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, + { + id: '00000000-0000-0000-0000-000000000002', + name: 'Two', + role: 'owner', + status: 'normal', + current: false, + }, + ], + })), + ), } } @@ -91,14 +100,22 @@ describe('runUseWorkspace', () => { expect(client.list).not.toHaveBeenCalled() const activeCtx = next.resolveActive() - expect(activeCtx?.ctx.workspace).toEqual({ id: '00000000-0000-0000-0000-000000000002', name: 'Two', role: 'owner' }) - expect((activeCtx?.ctx as Record<string, unknown> | undefined)?.available_workspaces).toBeUndefined() + expect(activeCtx?.ctx.workspace).toEqual({ + id: '00000000-0000-0000-0000-000000000002', + name: 'Two', + role: 'owner', + }) + expect( + (activeCtx?.ctx as Record<string, unknown> | undefined)?.available_workspaces, + ).toBeUndefined() const reloaded = await Registry.load() const reloadedActive = reloaded?.resolveActive() expect(reloadedActive?.ctx.workspace?.id).toBe('00000000-0000-0000-0000-000000000002') expect(reloadedActive?.ctx.workspace?.name).toBe('Two') - expect((reloadedActive?.ctx as Record<string, unknown> | undefined)?.available_workspaces).toBeUndefined() + expect( + (reloadedActive?.ctx as Record<string, unknown> | undefined)?.available_workspaces, + ).toBeUndefined() expect(io.outBuf()).toMatch(/Switched to Two \(00000000-0000-0000-0000-000000000002\)/) }) @@ -153,7 +170,11 @@ describe('runUseWorkspace', () => { const active = makeActive(reg) const client = fakeClient({}) - selectFromListMock.mockResolvedValue({ id: '00000000-0000-0000-0000-000000000002', name: 'Two', role: 'owner' }) + selectFromListMock.mockResolvedValue({ + id: '00000000-0000-0000-0000-000000000002', + name: 'Two', + role: 'owner', + }) await runUseWorkspace( { workspaceId: undefined }, diff --git a/cli/src/commands/use/workspace/use.ts b/cli/src/commands/use/workspace/use.ts index 94f335c940e508..ef0303cccacf72 100644 --- a/cli/src/commands/use/workspace/use.ts +++ b/cli/src/commands/use/workspace/use.ts @@ -41,9 +41,8 @@ export async function runUseWorkspace( const argId = opts.workspaceId?.trim() ?? '' const id = argId !== '' ? argId : await pickWorkspaceId(client, deps) - const detail = await runWithSpinner( - { io: deps.io, label: `Switching to ${id}` }, - () => client.switch(id), + const detail = await runWithSpinner({ io: deps.io, label: `Switching to ${id}` }, () => + client.switch(id), ) const nextCtx = { @@ -61,15 +60,14 @@ async function pickWorkspaceId(client: WorkspacesClient, deps: UseWorkspaceDeps) throw new BaseError({ code: ErrorCode.UsageMissingArg, message: 'a workspace id is required (no TTY)', - hint: 'pass the id: \'difyctl use workspace <id>\'', + hint: "pass the id: 'difyctl use workspace <id>'", }) } - const list = await runWithSpinner( - { io: deps.io, label: 'Loading workspaces' }, - () => client.list(), + const list = await runWithSpinner({ io: deps.io, label: 'Loading workspaces' }, () => + client.list(), ) - const items = list.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role })) + const items = list.workspaces.map<Workspace>((w) => ({ id: w.id, name: w.name, role: w.role })) if (items.length === 0) { throw new BaseError({ code: ErrorCode.AccessDenied, @@ -82,7 +80,7 @@ async function pickWorkspaceId(client: WorkspacesClient, deps: UseWorkspaceDeps) io: deps.io, items, header: 'Select a workspace', - render: w => `${w.id === activeId ? '* ' : ' '}${w.name} (${w.role})`, + render: (w) => `${w.id === activeId ? '* ' : ' '}${w.name} (${w.role})`, }) return picked.id } diff --git a/cli/src/commands/version/index.ts b/cli/src/commands/version/index.ts index 7a533f71db4d1d..345929f8d6c769 100644 --- a/cli/src/commands/version/index.ts +++ b/cli/src/commands/version/index.ts @@ -20,9 +20,12 @@ export default class Version extends DifyCommand { ] static override flags = { - 'output': Flags.outputFormat({ options: [OutputFormat.TEXT, OutputFormat.JSON, OutputFormat.YAML], default: '' }), - 'client': Flags.boolean({ description: 'skip server probe' }), - 'short': Flags.boolean({ description: 'print only the client semver' }), + output: Flags.outputFormat({ + options: [OutputFormat.TEXT, OutputFormat.JSON, OutputFormat.YAML], + default: '', + }), + client: Flags.boolean({ description: 'skip server probe' }), + short: Flags.boolean({ description: 'print only the client semver' }), 'check-compat': Flags.boolean({ description: `exit ${COMPAT_FAIL_EXIT_CODE} if server is not 'compatible'`, }), @@ -31,8 +34,7 @@ export default class Version extends DifyCommand { async run(argv: string[]) { const { flags } = this.parse(Version, argv) - if (flags.short) - return raw(`${versionInfo.version}\n`) + if (flags.short) return raw(`${versionInfo.version}\n`) const report = await runVersionProbe({ skipServer: flags.client }) diff --git a/cli/src/commands/version/version.test.ts b/cli/src/commands/version/version.test.ts index be1e0f48917575..8d4a60752160c0 100644 --- a/cli/src/commands/version/version.test.ts +++ b/cli/src/commands/version/version.test.ts @@ -3,11 +3,13 @@ import * as info from '@/version/info' import * as probe from '@/version/probe' import Version, { COMPAT_FAIL_EXIT_CODE } from './index' -function fakeReport(overrides: { - channel?: probe.VersionReport['client']['channel'] - reachable?: boolean - status?: probe.VersionReport['compat']['status'] -} = {}): probe.VersionReport { +function fakeReport( + overrides: { + channel?: probe.VersionReport['client']['channel'] + reachable?: boolean + status?: probe.VersionReport['compat']['status'] + } = {}, +): probe.VersionReport { return { client: { version: '0.1.0-rc.1', @@ -17,9 +19,15 @@ function fakeReport(overrides: { platform: 'darwin', arch: 'arm64', }, - server: overrides.reachable === false - ? { endpoint: '', reachable: false } - : { endpoint: 'https://cloud.dify.ai', reachable: true, version: '1.6.4', edition: 'CLOUD' }, + server: + overrides.reachable === false + ? { endpoint: '', reachable: false } + : { + endpoint: 'https://cloud.dify.ai', + reachable: true, + version: '1.6.4', + edition: 'CLOUD', + }, compat: { minDify: '1.6.0', maxDify: '1.7.0', @@ -41,8 +49,7 @@ describe('Version command', () => { it('emits formatted text output by default with three blocks', async () => { const output = await new Version().run([]) expect(output?.kind).toBe('formatted') - if (output?.kind !== 'formatted') - throw new Error('expected formatted output') + if (output?.kind !== 'formatted') throw new Error('expected formatted output') const text = output.data.text() expect(text).toContain('Client:') @@ -53,8 +60,7 @@ describe('Version command', () => { it('emits the canonical envelope when -o json is passed', async () => { const output = await new Version().run(['-o', 'json']) expect(output?.kind).toBe('formatted') - if (output?.kind !== 'formatted') - throw new Error('expected formatted output') + if (output?.kind !== 'formatted') throw new Error('expected formatted output') const payload = output.data.json() as probe.VersionReport expect(payload).toHaveProperty('client') @@ -69,8 +75,7 @@ describe('Version command', () => { it('threads -o yaml through formatted output (envelope, not text)', async () => { const output = await new Version().run(['-o', 'yaml']) expect(output?.kind).toBe('formatted') - if (output?.kind !== 'formatted') - throw new Error('expected formatted output') + if (output?.kind !== 'formatted') throw new Error('expected formatted output') expect(output.format).toBe('yaml') // The same envelope drives json + yaml — assert the shape via the json // facet (stringifyOutput uses js-yaml.dump on this object). @@ -85,18 +90,18 @@ describe('Version command', () => { try { const output = await new Version().run(['--short']) expect(output?.kind).toBe('raw') - if (output?.kind !== 'raw') - throw new Error('expected raw output') + if (output?.kind !== 'raw') throw new Error('expected raw output') expect(output.data).toBe('0.2.0\n') - } - finally { + } finally { Object.assign(info.versionInfo, { version: orig }) } }) it('passes skipServer=true to the probe when --client is set', async () => { - const spy = vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ reachable: false, status: 'unknown' })) + const spy = vi + .spyOn(probe, 'runVersionProbe') + .mockResolvedValue(fakeReport({ reachable: false, status: 'unknown' })) await new Version().run(['--client']) expect(spy).toHaveBeenCalledWith({ skipServer: true }) }) @@ -129,14 +134,16 @@ describe('Version command', () => { // stdout must receive a parseable JSON envelope so pipelines like // `difyctl version -o json --check-compat | jq` still work on failure. expect(stdoutSpy).toHaveBeenCalled() - const written = stdoutSpy.mock.calls.map(c => String(c[0])).join('') + const written = stdoutSpy.mock.calls.map((c) => String(c[0])).join('') const parsed = JSON.parse(written) as { compat: { status: string } } expect(parsed.compat.status).toBe('too_new') expect(exitSpy).toHaveBeenCalledWith(COMPAT_FAIL_EXIT_CODE) }) it('--check-compat exits 64 when compat is unknown (no server)', async () => { - vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ reachable: false, status: 'unknown' })) + vi.spyOn(probe, 'runVersionProbe').mockResolvedValue( + fakeReport({ reachable: false, status: 'unknown' }), + ) const exitSpy = stubProcessExit() vi.spyOn(process.stderr, 'write').mockImplementation(() => true) vi.spyOn(process.stdout, 'write').mockImplementation(() => true) @@ -155,8 +162,7 @@ describe('Version command', () => { it('renders RC warning in text output when channel is rc', async () => { vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ channel: 'rc' })) const output = await new Version().run([]) - if (output?.kind !== 'formatted') - throw new Error('expected formatted output') + if (output?.kind !== 'formatted') throw new Error('expected formatted output') expect(output.data.text()).toContain('WARNING: This build is a(n) rc release') }) diff --git a/cli/src/config/config-loader.test.ts b/cli/src/config/config-loader.test.ts index 83d954db686168..d7e7c95e719bb4 100644 --- a/cli/src/config/config-loader.test.ts +++ b/cli/src/config/config-loader.test.ts @@ -18,8 +18,7 @@ describe('loadConfig', () => { await getConfigurationStore().setTyped({ schema_version: 1 }) const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) - if (r.found) - expect(r.config.schema_version).toBe(1) + if (r.found) expect(r.config.schema_version).toBe(1) }) it('parses defaults + state', async () => { @@ -41,13 +40,16 @@ describe('loadConfig', () => { // Simulate a corrupt on-disk file via a fake store; loadConfig must wrap // the underlying error as ConfigSchemaUnsupported. const throwingStore = { - getTyped: () => { throw new Error('YAML parse failure') }, + getTyped: () => { + throw new Error('YAML parse failure') + }, } as unknown as YamlStore let caught: unknown try { await loadConfig(throwingStore) + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) if (isBaseError(caught)) { expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported) @@ -60,11 +62,11 @@ describe('loadConfig', () => { let caught: unknown try { await loadConfig(getConfigurationStore()) + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported) + if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported) }) it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', async () => { @@ -72,8 +74,9 @@ describe('loadConfig', () => { let caught: unknown try { await loadConfig(getConfigurationStore()) + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) if (isBaseError(caught)) { expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported) diff --git a/cli/src/config/config-loader.ts b/cli/src/config/config-loader.ts index d8438c2f04505f..1ae3f825311b83 100644 --- a/cli/src/config/config-loader.ts +++ b/cli/src/config/config-loader.ts @@ -4,30 +4,25 @@ import { newError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { ConfigFileSchema, CURRENT_SCHEMA_VERSION } from './schema' -export type LoadResult - = | { found: false } - | { found: true, config: ConfigFile } +export type LoadResult = { found: false } | { found: true; config: ConfigFile } export async function loadConfig(store: YamlStore): Promise<LoadResult> { let raw: Record<string, unknown> | null try { raw = await store.getTyped<Record<string, unknown>>() - } - catch (err) { - throw newError( - ErrorCode.ConfigSchemaUnsupported, - `parse config.yml: ${(err as Error).message}`, - ).wrap(err).withHint('config.yml is not valid YAML') + } catch (err) { + throw newError(ErrorCode.ConfigSchemaUnsupported, `parse config.yml: ${(err as Error).message}`) + .wrap(err) + .withHint('config.yml is not valid YAML') } - if (raw === null) - return { found: false } + if (raw === null) return { found: false } const result = ConfigFileSchema.safeParse(raw) if (!result.success) { throw newError( ErrorCode.ConfigSchemaUnsupported, - `validate config.yml: ${result.error.issues.map(i => i.message).join('; ')}`, + `validate config.yml: ${result.error.issues.map((i) => i.message).join('; ')}`, ).withHint('config.yml does not match the v1 schema') } diff --git a/cli/src/config/keys.test.ts b/cli/src/config/keys.test.ts index 2f2be680c11663..88ded5ac611d79 100644 --- a/cli/src/config/keys.test.ts +++ b/cli/src/config/keys.test.ts @@ -1,25 +1,20 @@ import { describe, expect, it } from 'vitest' import { isBaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' -import { - getKey, - knownKeyNames, - knownKeys, - lookupKey, - setKey, - unsetKey, -} from './keys' +import { getKey, knownKeyNames, knownKeys, lookupKey, setKey, unsetKey } from './keys' import { emptyConfig } from './schema' describe('config keys', () => { it('exposes the v1.0 key set: defaults.format, defaults.limit, state.current_app', () => { - expect([...knownKeyNames()].sort()).toEqual( - ['defaults.format', 'defaults.limit', 'state.current_app'], - ) + expect([...knownKeyNames()].sort()).toEqual([ + 'defaults.format', + 'defaults.limit', + 'state.current_app', + ]) }) it('knownKeys is alphabetically sorted', () => { - const names = knownKeys().map(k => k.name) + const names = knownKeys().map((k) => k.name) const sorted = [...names].sort() expect(names).toEqual(sorted) }) @@ -41,11 +36,11 @@ describe('config keys', () => { let caught: unknown try { getKey(emptyConfig(), 'nope') + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) + if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) }) }) @@ -59,8 +54,9 @@ describe('config keys', () => { let caught: unknown try { setKey(emptyConfig(), 'defaults.format', 'csv') + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) if (isBaseError(caught)) { expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) @@ -77,22 +73,22 @@ describe('config keys', () => { let caught: unknown try { setKey(emptyConfig(), 'defaults.limit', '999') + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) + if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) }) it('throws config_invalid_value for non-numeric limit', () => { let caught: unknown try { setKey(emptyConfig(), 'defaults.limit', 'abc') + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) + if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) }) it('sets state.current_app to any string', () => { @@ -131,11 +127,11 @@ describe('config keys', () => { let caught: unknown try { unsetKey(emptyConfig(), 'nope') + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) - expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) + if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) }) }) }) diff --git a/cli/src/config/keys.ts b/cli/src/config/keys.ts index 035b2697aba81f..c5523bf06d8a91 100644 --- a/cli/src/config/keys.ts +++ b/cli/src/config/keys.ts @@ -16,7 +16,7 @@ const KEYS: readonly KeySpec[] = [ { name: 'defaults.format', description: `Default output format used when -o is not passed (${ALLOWED_FORMATS.join('|')}).`, - get: c => c.defaults.format ?? '', + get: (c) => c.defaults.format ?? '', set: (c, v) => { if (!(ALLOWED_FORMATS as readonly string[]).includes(v)) { throw newError( @@ -26,41 +26,41 @@ const KEYS: readonly KeySpec[] = [ } return { ...c, defaults: { ...c.defaults, format: v as AllowedFormat } } }, - unset: c => ({ ...c, defaults: { ...c.defaults, format: undefined } }), + unset: (c) => ({ ...c, defaults: { ...c.defaults, format: undefined } }), }, { name: 'defaults.limit', description: 'Default page size for list commands (1..200).', - get: c => (c.defaults.limit === undefined ? '' : String(c.defaults.limit)), + get: (c) => (c.defaults.limit === undefined ? '' : String(c.defaults.limit)), set: (c, v) => { try { const n = parseLimit(v, 'defaults.limit') return { ...c, defaults: { ...c.defaults, limit: n } } - } - catch (err) { + } catch (err) { throw newError(ErrorCode.ConfigInvalidValue, (err as Error).message).wrap(err) } }, - unset: c => ({ ...c, defaults: { ...c.defaults, limit: undefined } }), + unset: (c) => ({ ...c, defaults: { ...c.defaults, limit: undefined } }), }, { name: 'state.current_app', - description: 'App ID used when commands need an app context but no positional argument is given.', - get: c => c.state.current_app ?? '', + description: + 'App ID used when commands need an app context but no positional argument is given.', + get: (c) => c.state.current_app ?? '', set: (c, v) => ({ ...c, state: { ...c.state, current_app: v } }), - unset: c => ({ ...c, state: { ...c.state, current_app: undefined } }), + unset: (c) => ({ ...c, state: { ...c.state, current_app: undefined } }), }, ] const SORTED: readonly KeySpec[] = [...KEYS].sort((a, b) => a.name.localeCompare(b.name)) -const BY_NAME = new Map(SORTED.map(k => [k.name, k])) +const BY_NAME = new Map(SORTED.map((k) => [k.name, k])) export function knownKeys(): readonly KeySpec[] { return SORTED } export function knownKeyNames(): readonly string[] { - return SORTED.map(k => k.name) + return SORTED.map((k) => k.name) } export function lookupKey(name: string): KeySpec | undefined { @@ -69,22 +69,19 @@ export function lookupKey(name: string): KeySpec | undefined { export function getKey(config: ConfigFile, name: string): string { const spec = lookupKey(name) - if (spec === undefined) - throw unknownKey(name) + if (spec === undefined) throw unknownKey(name) return spec.get(config) } export function setKey(config: ConfigFile, name: string, value: string): ConfigFile { const spec = lookupKey(name) - if (spec === undefined) - throw unknownKey(name) + if (spec === undefined) throw unknownKey(name) return spec.set(config, value) } export function unsetKey(config: ConfigFile, name: string): ConfigFile { const spec = lookupKey(name) - if (spec === undefined) - throw unknownKey(name) + if (spec === undefined) throw unknownKey(name) return spec.unset(config) } diff --git a/cli/src/config/schema.test.ts b/cli/src/config/schema.test.ts index c3189486352f58..6f60efef0a8034 100644 --- a/cli/src/config/schema.test.ts +++ b/cli/src/config/schema.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from 'vitest' import { CONFIG_FILE_NAME } from '@/store/manager' -import { - ALLOWED_FORMATS, - ConfigFileSchema, - CURRENT_SCHEMA_VERSION, - emptyConfig, -} from './schema' +import { ALLOWED_FORMATS, ConfigFileSchema, CURRENT_SCHEMA_VERSION, emptyConfig } from './schema' describe('config schema', () => { it('CURRENT_SCHEMA_VERSION is 1', () => { @@ -17,9 +12,7 @@ describe('config schema', () => { }) it('ALLOWED_FORMATS matches Go set (json/yaml/table/wide/name/text)', () => { - expect([...ALLOWED_FORMATS].sort()).toEqual( - ['json', 'name', 'table', 'text', 'wide', 'yaml'], - ) + expect([...ALLOWED_FORMATS].sort()).toEqual(['json', 'name', 'table', 'text', 'wide', 'yaml']) }) it('emptyConfig fills defaults + state with empty objects', () => { @@ -57,7 +50,6 @@ describe('config schema', () => { it('parses an empty object into emptyConfig() shape', () => { const r = ConfigFileSchema.safeParse({}) expect(r.success).toBe(true) - if (r.success) - expect(r.data).toEqual(emptyConfig()) + if (r.success) expect(r.data).toEqual(emptyConfig()) }) }) diff --git a/cli/src/env/registry.test.ts b/cli/src/env/registry.test.ts index 9b89695b4cd7ea..c919f0bce6833f 100644 --- a/cli/src/env/registry.test.ts +++ b/cli/src/env/registry.test.ts @@ -1,14 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - ENV_REGISTRY, - getEnv, - lookupEnv, - resolveEnv, -} from './registry' +import { ENV_REGISTRY, getEnv, lookupEnv, resolveEnv } from './registry' describe('env registry', () => { it('contains every DIFY_* var from the v1.0 spec', () => { - const names = ENV_REGISTRY.map(e => e.name) + const names = ENV_REGISTRY.map((e) => e.name) expect(names).toContain('DIFY_TOKEN') expect(names).toContain('DIFY_HOST') expect(names).toContain('DIFY_WORKSPACE_ID') @@ -20,7 +15,7 @@ describe('env registry', () => { }) it('is sorted alphabetically (matches Go init() ordering)', () => { - const names = ENV_REGISTRY.map(e => e.name) + const names = ENV_REGISTRY.map((e) => e.name) const sorted = [...names].sort() expect(names).toEqual(sorted) }) @@ -54,8 +49,7 @@ describe('env registry', () => { }) afterEach(() => { for (const [k, v] of Object.entries(originals)) { - if (v === undefined) - delete process.env[k] + if (v === undefined) delete process.env[k] else process.env[k] = v } }) diff --git a/cli/src/env/registry.ts b/cli/src/env/registry.ts index a567bf01cb8978..7e6cf28e39eca0 100644 --- a/cli/src/env/registry.ts +++ b/cli/src/env/registry.ts @@ -50,7 +50,7 @@ export const ENV_REGISTRY: readonly EnvVar[] = [...REGISTRY_UNSORTED].sort((a, b a.name.localeCompare(b.name), ) -const BY_NAME = new Map(ENV_REGISTRY.map(e => [e.name, e])) +const BY_NAME = new Map(ENV_REGISTRY.map((e) => [e.name, e])) export function lookupEnv(name: string): EnvVar | undefined { return BY_NAME.get(name) @@ -61,7 +61,6 @@ export { getEnv } export function resolveEnv(name: string): unknown { const entry = lookupEnv(name) const raw = getEnv(name) ?? entry?.default - if (raw === undefined) - return undefined + if (raw === undefined) return undefined return entry?.parse ? entry.parse(raw) : raw } diff --git a/cli/src/errors/base.test.ts b/cli/src/errors/base.test.ts index fe9dc30eeaa0c7..da917275d655a0 100644 --- a/cli/src/errors/base.test.ts +++ b/cli/src/errors/base.test.ts @@ -38,11 +38,10 @@ describe('BaseError', () => { }) it('toString with hint formats "<code>: <message> (hint: <hint>)"', () => { - const err = newError(ErrorCode.AuthExpired, 'session expired') - .withHint('run \'difyctl auth login\'') - expect(err.toString()).toBe( - 'auth_expired: session expired (hint: run \'difyctl auth login\')', + const err = newError(ErrorCode.AuthExpired, 'session expired').withHint( + "run 'difyctl auth login'", ) + expect(err.toString()).toBe("auth_expired: session expired (hint: run 'difyctl auth login')") }) it('builder methods return new instances; original unchanged', () => { @@ -112,8 +111,9 @@ describe('error envelope', () => { }) it('renderEnvelope returns a single-line JSON string', () => { - const err = newError(ErrorCode.AuthExpired, 'session expired') - .withHint('run difyctl auth login') + const err = newError(ErrorCode.AuthExpired, 'session expired').withHint( + 'run difyctl auth login', + ) const out = JSON.stringify(err.toEnvelope()) expect(out).toBe( '{"error":{"code":"auth_expired","message":"session expired","hint":"run difyctl auth login"}}', diff --git a/cli/src/errors/base.ts b/cli/src/errors/base.ts index f1f9ca98535ae3..8850f0febaaf56 100644 --- a/cli/src/errors/base.ts +++ b/cli/src/errors/base.ts @@ -38,8 +38,7 @@ export class BaseError extends Error implements PrintableError { code: this.code, message: this.message, } - if (this.hint !== undefined) - payload.hint = this.hint + if (this.hint !== undefined) payload.hint = this.hint return { error: payload } } @@ -105,16 +104,11 @@ export class HttpClientError extends BaseError { override toEnvelope(): ErrorEnvelope { const envelope = super.toEnvelope() - if (this.httpStatus !== undefined) - envelope.error.http_status = this.httpStatus - if (this.method !== undefined) - envelope.error.method = this.method - if (this.url !== undefined) - envelope.error.url = this.url - if (this.rawResponse !== undefined) - envelope.error.raw_response = this.rawResponse - if (this.serverError !== undefined) - envelope.error.server = this.serverError + if (this.httpStatus !== undefined) envelope.error.http_status = this.httpStatus + if (this.method !== undefined) envelope.error.method = this.method + if (this.url !== undefined) envelope.error.url = this.url + if (this.rawResponse !== undefined) envelope.error.raw_response = this.rawResponse + if (this.serverError !== undefined) envelope.error.server = this.serverError return envelope } diff --git a/cli/src/errors/codes.test.ts b/cli/src/errors/codes.test.ts index eb76b13a22f619..39f0fef5c5f337 100644 --- a/cli/src/errors/codes.test.ts +++ b/cli/src/errors/codes.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - ALL_ERROR_CODES, - CODE_TO_EXIT_MAP, - ErrorCode, - ExitCode, - exitFor, -} from './codes' +import { ALL_ERROR_CODES, CODE_TO_EXIT_MAP, ErrorCode, ExitCode, exitFor } from './codes' describe('error codes', () => { it('has correct number codes (parity with internal/api/errors)', () => { @@ -22,8 +16,7 @@ describe('error codes', () => { }) it('every code maps to an exit', () => { - for (const code of ALL_ERROR_CODES) - expect(CODE_TO_EXIT_MAP[code]).toBeDefined() + for (const code of ALL_ERROR_CODES) expect(CODE_TO_EXIT_MAP[code]).toBeDefined() }) it('CODE_TO_EXIT_MAP entry count == ALL_ERROR_CODES length (drift guard)', () => { diff --git a/cli/src/errors/format.test.ts b/cli/src/errors/format.test.ts index dd75b53591c571..1a8340222605d3 100644 --- a/cli/src/errors/format.test.ts +++ b/cli/src/errors/format.test.ts @@ -1,6 +1,5 @@ import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' import { afterEach, describe, expect, it } from 'vitest' - import { setVerbose } from '@/framework/context' import { HttpClientError } from './base' import { ErrorCode } from './codes' @@ -13,12 +12,10 @@ type ValidationErrorOverrides = { } function validationError(overrides: ValidationErrorOverrides = {}): HttpClientError { - const details - = overrides.details - ?? [ - { type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' }, - { type: 'missing', loc: ['inputs', 'query'], msg: 'field required' }, - ] + const details = overrides.details ?? [ + { type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' }, + { type: 'missing', loc: ['inputs', 'query'], msg: 'field required' }, + ] return new HttpClientError({ code: ErrorCode.Server4xxOther, message: 'Request validation failed', @@ -40,7 +37,9 @@ afterEach(() => { describe('formatErrorForCli — human', () => { it('prints server code, message, and details without verbose', () => { - const out = formatErrorForCli(validationError({ serverHint: 'check the page parameter' }), { isErrTTY: false }) + const out = formatErrorForCli(validationError({ serverHint: 'check the page parameter' }), { + isErrTTY: false, + }) expect(out).toContain('invalid_param: Request validation failed') expect(out).toContain('- page: must be >= 1 (int_parsing)') @@ -50,7 +49,11 @@ describe('formatErrorForCli — human', () => { }) it('falls back to cli code when no server code', () => { - const err = new HttpClientError({ code: ErrorCode.Server5xx, message: 'server error (HTTP 502)', httpStatus: 502 }) + const err = new HttpClientError({ + code: ErrorCode.Server5xx, + message: 'server error (HTTP 502)', + httpStatus: 502, + }) const out = formatErrorForCli(err, { isErrTTY: false }) @@ -58,9 +61,15 @@ describe('formatErrorForCli — human', () => { }) it('cli hint wins over server hint; server hint fills when cli sent none', () => { - const withBothHints = validationError({ cliHint: 'cli local hint', serverHint: 'check the page parameter', details: [] }) + const withBothHints = validationError({ + cliHint: 'cli local hint', + serverHint: 'check the page parameter', + details: [], + }) expect(formatErrorForCli(withBothHints, { isErrTTY: false })).toContain('cli local hint') - expect(formatErrorForCli(withBothHints, { isErrTTY: false })).not.toContain('check the page parameter') + expect(formatErrorForCli(withBothHints, { isErrTTY: false })).not.toContain( + 'check the page parameter', + ) // no cli hint → server hint shown const noCliHint = validationError({ serverHint: 'check the page parameter', details: [] }) diff --git a/cli/src/errors/format.ts b/cli/src/errors/format.ts index f32bba3482cf41..656431325c7db5 100644 --- a/cli/src/errors/format.ts +++ b/cli/src/errors/format.ts @@ -29,15 +29,13 @@ export type PrintableError = { export function formatErrorForCli(err: PrintableError, opts: FormatErrorOptions = {}): string { const env = err.toEnvelope() - if (opts.format === 'json') - return renderEnvelope(env) + if (opts.format === 'json') return renderEnvelope(env) return renderHuman(env, opts.isErrTTY ?? false) } function renderEnvelope(env: ErrorEnvelope): string { const raw = env.error.raw_response - if (raw === undefined) - return JSON.stringify(env) + if (raw === undefined) return JSON.stringify(env) if (!isVerbose()) { delete env.error.raw_response return JSON.stringify(env) @@ -49,10 +47,8 @@ function renderEnvelope(env: ErrorEnvelope): string { // CLI-authored hint wins: it knows local remediation (e.g. which command to // run); the server hint fills in when the CLI has nothing for this error. function resolveHint(e: ErrorEnvelope['error']): string | undefined { - if (e.hint !== undefined) - return e.hint - if (e.server?.hint != null) - return e.server.hint + if (e.hint !== undefined) return e.hint + if (e.server?.hint != null) return e.server.hint const rawHiddenAndUnparsed = e.server === undefined && Boolean(e.raw_response) && !isVerbose() return rawHiddenAndUnparsed ? RAW_RESPONSE_HINT : undefined } @@ -68,13 +64,9 @@ function renderHuman(env: ErrorEnvelope, isErrTTY: boolean): string { lines.push(` - ${loc ? `${loc}: ` : ''}${d.msg} (${d.type})`) } const hint = resolveHint(e) - if (hint !== undefined) - lines.push(`${cs.magenta('hint:')} ${cs.cyan(hint)}`) - if (e.method !== undefined && e.url !== undefined) - lines.push(`request: ${e.method} ${e.url}`) - if (e.http_status !== undefined) - lines.push(`http_status: ${e.http_status}`) - if (isVerbose() && e.raw_response) - lines.push(`raw_response: ${redactBearer(e.raw_response)}`) + if (hint !== undefined) lines.push(`${cs.magenta('hint:')} ${cs.cyan(hint)}`) + if (e.method !== undefined && e.url !== undefined) lines.push(`request: ${e.method} ${e.url}`) + if (e.http_status !== undefined) lines.push(`http_status: ${e.http_status}`) + if (isVerbose() && e.raw_response) lines.push(`raw_response: ${redactBearer(e.raw_response)}`) return lines.join('\n') } diff --git a/cli/src/framework/command-fs.ts b/cli/src/framework/command-fs.ts index 6fb5aca6be965e..ce7f6c90a47684 100644 --- a/cli/src/framework/command-fs.ts +++ b/cli/src/framework/command-fs.ts @@ -3,12 +3,13 @@ function normalize(p: string): string { } export function isExcludedCommandPath(relPath: string): boolean { - return normalize(relPath).split('/').some(seg => seg.startsWith('_')) + return normalize(relPath) + .split('/') + .some((seg) => seg.startsWith('_')) } export function isCommandIndexPath(relPath: string): boolean { const n = normalize(relPath) - if (!n.endsWith('/index.ts')) - return false + if (!n.endsWith('/index.ts')) return false return !isExcludedCommandPath(n) } diff --git a/cli/src/framework/command.ts b/cli/src/framework/command.ts index 44bec49990ce17..7401ab90ea20cf 100644 --- a/cli/src/framework/command.ts +++ b/cli/src/framework/command.ts @@ -1,5 +1,12 @@ import type { CommandOutput } from './output' -import type { ArgDefinition, FlagDefinition, ICommand, InferArgs, InferFlags, OptionalArgValueType } from './types' +import type { + ArgDefinition, + FlagDefinition, + ICommand, + InferArgs, + InferFlags, + OptionalArgValueType, +} from './types' import { setVerbose } from './context' import { hasBooleanFlag, parseArgv, VERBOSE_CHAR, VERBOSE_FLAG } from './flags' @@ -9,7 +16,7 @@ import { hasBooleanFlag, parseArgv, VERBOSE_CHAR, VERBOSE_FLAG } from './flags' export type CommandEffect = 'read' | 'write' | 'destructive' export type CommandConstructor = { - new(): Command + new (): Command description?: string flags?: Record<string, FlagDefinition<OptionalArgValueType>> args?: Record<string, ArgDefinition<string | undefined>> @@ -19,13 +26,15 @@ export type CommandConstructor = { effect?: CommandEffect } -type InferCommandArgs<C extends CommandConstructor> = C['args'] extends Record<string, ArgDefinition<string | undefined>> - ? InferArgs<C['args']> - : Record<string, string | undefined> +type InferCommandArgs<C extends CommandConstructor> = + C['args'] extends Record<string, ArgDefinition<string | undefined>> + ? InferArgs<C['args']> + : Record<string, string | undefined> -type InferCommandFlags<C extends CommandConstructor> = C['flags'] extends Record<string, FlagDefinition<OptionalArgValueType>> - ? InferFlags<C['flags']> - : Record<string, OptionalArgValueType> +type InferCommandFlags<C extends CommandConstructor> = + C['flags'] extends Record<string, FlagDefinition<OptionalArgValueType>> + ? InferFlags<C['flags']> + : Record<string, OptionalArgValueType> type ParseResult<C extends CommandConstructor> = { args: InferCommandArgs<C> diff --git a/cli/src/framework/errors.test.ts b/cli/src/framework/errors.test.ts index be54b87136471d..d38e3aef04b898 100644 --- a/cli/src/framework/errors.test.ts +++ b/cli/src/framework/errors.test.ts @@ -11,20 +11,35 @@ describe('OutputFormatNotSupportedError', () => { describe('UnsupportedArgValueError', () => { it('includes both long and short option labels when a char exists', () => { - const def: FlagDefinition = { type: 'string', description: 'output', char: 'o', options: ['json', 'yaml'] } + const def: FlagDefinition = { + type: 'string', + description: 'output', + char: 'o', + options: ['json', 'yaml'], + } const err = new UnsupportedArgValueError('output', def, 'csv') expect(err.message).toBe('illegal value csv for flag --output / -o') }) it('omits the short option label when the flag has no char', () => { - const def: FlagDefinition = { type: 'string', description: 'app mode', options: ['chat', 'workflow'] } + const def: FlagDefinition = { + type: 'string', + description: 'app mode', + options: ['chat', 'workflow'], + } const err = new UnsupportedArgValueError('mode', def, 'chatbot') expect(err.message).toBe('illegal value chatbot for flag --mode') }) it('lists supported values in the hint', () => { - const def: FlagDefinition = { type: 'string', description: 'app mode', options: ['chat', 'workflow'] } - expect(new UnsupportedArgValueError('mode', def, 'chatbot').hint).toBe('supported value: chat, workflow') + const def: FlagDefinition = { + type: 'string', + description: 'app mode', + options: ['chat', 'workflow'], + } + expect(new UnsupportedArgValueError('mode', def, 'chatbot').hint).toBe( + 'supported value: chat, workflow', + ) }) it('leaves the hint empty when the flag declares no options', () => { diff --git a/cli/src/framework/flags.test.ts b/cli/src/framework/flags.test.ts index 1e0967dee8dae4..7b0ee519150aa5 100644 --- a/cli/src/framework/flags.test.ts +++ b/cli/src/framework/flags.test.ts @@ -75,11 +75,15 @@ describe('parseArgv', () => { }) it('throws on non-integer value for integer flag', () => { - expect(() => parseArgv(['alice', '--count', 'abc'], meta)).toThrow('expected integer, got "abc"') + expect(() => parseArgv(['alice', '--count', 'abc'], meta)).toThrow( + 'expected integer, got "abc"', + ) }) it('throws on invalid boolean value', () => { - expect(() => parseArgv(['alice', '--verbose=maybe'], meta)).toThrow('expected boolean, got "maybe"') + expect(() => parseArgv(['alice', '--verbose=maybe'], meta)).toThrow( + 'expected boolean, got "maybe"', + ) }) it('throws when non-boolean flag has no value', () => { @@ -179,7 +183,10 @@ describe('parseArgv', () => { describe('options validation', () => { const metaWithOptions = { flags: { - mode: Flags.string({ description: 'app mode', options: ['chat', 'workflow', 'completion'] }), + mode: Flags.string({ + description: 'app mode', + options: ['chat', 'workflow', 'completion'], + }), }, args: {}, } @@ -196,9 +203,7 @@ describe('parseArgv', () => { }) it('rejects an invalid option value (= form)', () => { - expect(() => parseArgv(['--mode=chatbot'], metaWithOptions)).toThrow( - UnsupportedArgValueError, - ) + expect(() => parseArgv(['--mode=chatbot'], metaWithOptions)).toThrow(UnsupportedArgValueError) }) }) diff --git a/cli/src/framework/flags.ts b/cli/src/framework/flags.ts index 0924d76c1e90e7..f9af9541196e05 100644 --- a/cli/src/framework/flags.ts +++ b/cli/src/framework/flags.ts @@ -20,14 +20,14 @@ const GLOBAL_FLAGS: Record<string, FlagDefinition> = { }), } -function stringFlag<const Opts extends { - description: string - char?: string - default?: string - options?: readonly string[] -}>( - opts: Opts, -): FlagDefinition<string> { +function stringFlag< + const Opts extends { + description: string + char?: string + default?: string + options?: readonly string[] + }, +>(opts: Opts): FlagDefinition<string> { return { type: 'string', multiple: false, @@ -35,7 +35,7 @@ function stringFlag<const Opts extends { } } -function outputFormatFlag<const Opts extends { options: readonly string[], default?: string }>( +function outputFormatFlag<const Opts extends { options: readonly string[]; default?: string }>( opts: Opts, ): FlagDefinition<string> { return { @@ -47,9 +47,9 @@ function outputFormatFlag<const Opts extends { options: readonly string[], defau } } -function stringRepeatedFlag<const Opts extends { description: string, char?: string, default?: string[], multiple?: boolean }>( - opts: Opts, -): FlagDefinition<string[]> { +function stringRepeatedFlag< + const Opts extends { description: string; char?: string; default?: string[]; multiple?: boolean }, +>(opts: Opts): FlagDefinition<string[]> { return { type: 'string', multiple: true, @@ -57,17 +57,24 @@ function stringRepeatedFlag<const Opts extends { description: string, char?: str } } -function booleanFlag(opts: { description: string, char?: string, default?: boolean, helpGroup?: 'GLOBAL' }): FlagDefinition<boolean> { +function booleanFlag(opts: { + description: string + char?: string + default?: boolean + helpGroup?: 'GLOBAL' +}): FlagDefinition<boolean> { return { type: 'boolean', ...opts } } -function integerFlag<const Opts extends { description: string, char?: string, default?: number }>( +function integerFlag<const Opts extends { description: string; char?: string; default?: number }>( opts: Opts, ): FlagDefinition<Opts extends { default: number } ? number : number | undefined> { - return { type: 'integer', ...opts } as FlagDefinition<Opts extends { default: number } ? number : number | undefined> + return { type: 'integer', ...opts } as FlagDefinition< + Opts extends { default: number } ? number : number | undefined + > } -function stringArg<const Opts extends { description: string, required?: boolean }>( +function stringArg<const Opts extends { description: string; required?: boolean }>( opts: Opts, ): ArgDefinition<Opts extends { required: true } ? string : string | undefined> { return opts as ArgDefinition<Opts extends { required: true } ? string : string | undefined> @@ -81,17 +88,14 @@ function coerceFlagValue(raw: string, def: FlagDefinition): string | boolean | n switch (def.type) { case 'integer': { const n = Number(raw) - if (Number.isNaN(n)) - throw new Error(`expected integer, got ${JSON.stringify(raw)}`) + if (Number.isNaN(n)) throw new Error(`expected integer, got ${JSON.stringify(raw)}`) return n } case 'boolean': { - if (raw === 'true' || raw === '1') - return true + if (raw === 'true' || raw === '1') return true - if (raw === 'false' || raw === '0') - return false + if (raw === 'false' || raw === '0') return false throw new Error(`expected boolean, got ${JSON.stringify(raw)}`) } @@ -100,20 +104,26 @@ function coerceFlagValue(raw: string, def: FlagDefinition): string | boolean | n } } -function accumulateFlagValue(flags: ParsedFlags, name: string, value: string | boolean | number, def: FlagDefinition): void { +function accumulateFlagValue( + flags: ParsedFlags, + name: string, + value: string | boolean | number, + def: FlagDefinition, +): void { if (def.multiple === true) { const existing = flags[name] flags[name] = Array.isArray(existing) ? [...existing, String(value)] : [String(value)] - } - else { + } else { flags[name] = value } } -function resolveByChar(char: string, flags: Record<string, FlagDefinition>): [name: string, def: FlagDefinition] | undefined { +function resolveByChar( + char: string, + flags: Record<string, FlagDefinition>, +): [name: string, def: FlagDefinition] | undefined { for (const [name, def] of Object.entries(flags)) { - if (def.char === char) - return [name, def] + if (def.char === char) return [name, def] } return undefined @@ -124,7 +134,12 @@ function validateFlagOptions(name: string, raw: string, def: FlagDefinition): vo throw new UnsupportedArgValueError(name, def, raw) } -type ResolvedFlag = { name: string, def: FlagDefinition, label: string, inlineRaw: string | undefined } +type ResolvedFlag = { + name: string + def: FlagDefinition + label: string + inlineRaw: string | undefined +} function resolveToken(token: string, flags: Record<string, FlagDefinition>): ResolvedFlag | null { if (token.startsWith('--')) { @@ -132,16 +147,14 @@ function resolveToken(token: string, flags: Record<string, FlagDefinition>): Res const name = eqIdx !== -1 ? token.slice(2, eqIdx) : token.slice(2) const inlineRaw = eqIdx !== -1 ? token.slice(eqIdx + 1) : undefined const def = flags[name] - if (!def) - throw new Error(`unknown flag: --${name}`) + if (!def) throw new Error(`unknown flag: --${name}`) return { name, def, label: `--${name}`, inlineRaw } } if (token.length === 2 && token[1] !== undefined) { const char = token[1] const resolved = resolveByChar(char, flags) - if (!resolved) - throw new Error(`unknown flag: -${char}`) + if (!resolved) throw new Error(`unknown flag: -${char}`) const [name, def] = resolved return { name, def, label: `-${char}`, inlineRaw: undefined } } @@ -153,18 +166,18 @@ function resolveToken(token: string, flags: Record<string, FlagDefinition>): Res // to call before the command-specific flag set is known (e.g. global flags). export function hasBooleanFlag(argv: readonly string[], name: string, char?: string): boolean { for (const token of argv) { - if (token === '--') - break - if (token === `--${name}` || token === `--${name}=true` || token === `--${name}=1`) - return true - if (char !== undefined && token === `-${char}`) - return true + if (token === '--') break + if (token === `--${name}` || token === `--${name}=true` || token === `--${name}=1`) return true + if (char !== undefined && token === `-${char}`) return true } return false } -export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: ParsedArgs, flags: ParsedFlags } { +export function parseArgv( + argv: readonly string[], + meta: CommandMeta, +): { args: ParsedArgs; flags: ParsedFlags } { const flags: ParsedFlags = {} const positional: string[] = [] const argDefs = Object.entries(meta.args) @@ -172,8 +185,7 @@ export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: P for (let i = 0; i < argv.length; i++) { const token = argv[i] - if (token === undefined) - break + if (token === undefined) break if (!pastDoubleDash && token === '--') { pastDoubleDash = true @@ -204,8 +216,7 @@ export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: P let raw: string if (inlineRaw !== undefined) { raw = inlineRaw - } - else { + } else { i++ const next = i < argv.length ? argv[i] : undefined if (next === undefined || next.startsWith('-')) @@ -220,21 +231,18 @@ export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: P const args: ParsedArgs = {} for (let j = 0; j < argDefs.length; j++) { const entry = argDefs[j] - if (!entry) - continue + if (!entry) continue const [argName, argDef] = entry if (j < positional.length) { args[argName] = positional[j] - } - else if (argDef.required) { + } else if (argDef.required) { throw new Error(`missing required argument: ${argName}`) } } for (const [name, def] of Object.entries(meta.flags)) { - if (!(name in flags) && def.default !== undefined) - flags[name] = def.default + if (!(name in flags) && def.default !== undefined) flags[name] = def.default } return { args, flags } diff --git a/cli/src/framework/help.test.ts b/cli/src/framework/help.test.ts index 6d2dd3b17636ad..d0f07b00cdf33b 100644 --- a/cli/src/framework/help.test.ts +++ b/cli/src/framework/help.test.ts @@ -151,7 +151,9 @@ describe('formatHelp structured output', () => { it('emits a JSON descriptor under json format', () => { const ctor = makeCmd({ description: 'Lists apps', - flags: { output: Flags.outputFormat({ options: ['json', 'yaml', 'name', 'wide'], default: '' }) }, + flags: { + output: Flags.outputFormat({ options: ['json', 'yaml', 'name', 'wide'], default: '' }), + }, args: { id: Args.string({ description: 'app id', required: true }) }, examples: ['<%= config.bin %> get app'], agentGuide: 'WORKFLOW', diff --git a/cli/src/framework/help.ts b/cli/src/framework/help.ts index 3803e7e213191e..2098f8c828c4ec 100644 --- a/cli/src/framework/help.ts +++ b/cli/src/framework/help.ts @@ -40,16 +40,14 @@ function isStructured(format: string): boolean { } function serialize(value: unknown, format: string): string { - if (format === 'yaml') - return dump(value, { indent: 2, lineWidth: -1 }) + if (format === 'yaml') return dump(value, { indent: 2, lineWidth: -1 }) return `${JSON.stringify(value, null, 2)}\n` } function flagLabel(name: string, def: FlagDefinition): string { const aliases: string[] = [] - if (def.char) - aliases.push(`-${def.char}`) + if (def.char) aliases.push(`-${def.char}`) aliases.push(`--${name}`) @@ -59,14 +57,13 @@ function flagLabel(name: string, def: FlagDefinition): string { } function flagDefault(def: FlagDefinition): string { - if (def.default === undefined) - return '' + if (def.default === undefined) return '' return ` [default: ${JSON.stringify(def.default)}]` } function renderExamples(ctor: CommandConstructor): string[] { - return (ctor.examples ?? []).map(ex => ex.replace('<%= config.bin %>', BIN)) + return (ctor.examples ?? []).map((ex) => ex.replace('<%= config.bin %>', BIN)) } function agentGuideOf(ctor: CommandConstructor): string { @@ -103,10 +100,13 @@ export function describeCommand(ctor: CommandConstructor, path: string): Command function formatHelpText(ctor: CommandConstructor, path: string): string { const lines: string[] = [] - if (ctor.description) - lines.push(ctor.description, '') + if (ctor.description) lines.push(ctor.description, '') - lines.push('USAGE', ` $ ${BIN} ${path}${ctor.args && Object.keys(ctor.args).length > 0 ? ' [ARGS]' : ''}${ctor.flags && Object.keys(ctor.flags).length > 0 ? ' [FLAGS]' : ''}`, '') + lines.push( + 'USAGE', + ` $ ${BIN} ${path}${ctor.args && Object.keys(ctor.args).length > 0 ? ' [ARGS]' : ''}${ctor.flags && Object.keys(ctor.flags).length > 0 ? ' [FLAGS]' : ''}`, + '', + ) if (ctor.args && Object.keys(ctor.args).length > 0) { lines.push('ARGUMENTS') @@ -141,15 +141,13 @@ function formatHelpText(ctor: CommandConstructor, path: string): string { const guide = agentGuideOf(ctor) - if (guide.length > 0) - lines.push(guide) + if (guide.length > 0) lines.push(guide) return lines.join('\n') } export function formatHelp(ctor: CommandConstructor, path: string, format = ''): string { - if (isStructured(format)) - return serialize(describeCommand(ctor, path), format) + if (isStructured(format)) return serialize(describeCommand(ctor, path), format) return formatHelpText(ctor, path) } @@ -166,7 +164,9 @@ export function formatTopic(topic: HelpTopic, format = ''): string { // top-level overview and namespace drill-in (`difyctl <group> --help`) so both // derive from the same full-depth `collectCommands` walk and the canonical // space-joined command path. -export function renderCommandRows(commands: Array<{ command: CommandConstructor, path: string[] }>): string { +export function renderCommandRows( + commands: Array<{ command: CommandConstructor; path: string[] }>, +): string { const rows = commands.map(({ command, path }) => ({ label: path.join(' '), desc: command.description ?? '', @@ -178,8 +178,7 @@ export function renderCommandRows(commands: Array<{ command: CommandConstructor, let prevGroup: string | undefined for (const r of rows) { - if (prevGroup !== undefined && r.group !== prevGroup) - lines.push('') + if (prevGroup !== undefined && r.group !== prevGroup) lines.push('') prevGroup = r.group lines.push(r.desc ? ` ${r.label.padEnd(width)}${r.desc}` : ` ${r.label}`) @@ -193,9 +192,15 @@ export function renderCommandRows(commands: Array<{ command: CommandConstructor, // same shape as the top-level site map's `commands` — while text renders the // aligned rows. Keeps `<group> --help -o json` machine-readable like every // other help surface. -export function formatCommandList(commands: Array<{ command: CommandConstructor, path: string[] }>, format: string): string { +export function formatCommandList( + commands: Array<{ command: CommandConstructor; path: string[] }>, + format: string, +): string { if (isStructured(format)) - return serialize({ commands: commands.map(({ command, path }) => describeCommand(command, path.join(' '))) }, format) + return serialize( + { commands: commands.map(({ command, path }) => describeCommand(command, path.join(' '))) }, + format, + ) return `COMMANDS\n${renderCommandRows(commands)}\n` } @@ -203,20 +208,16 @@ export function formatCommandList(commands: Array<{ command: CommandConstructor, // Curated onboarding examples for the top-level overview (gh-style): the // shortest path from zero to a structured app run. Editorial, not an exhaustive // dump — per-command examples live in each command's own `--help`. -const ROOT_EXAMPLES = [ - `${BIN} auth login`, - `${BIN} get app`, - `${BIN} run app <id> "hello" -o json`, -] +const ROOT_EXAMPLES = [`${BIN} auth login`, `${BIN} get app`, `${BIN} run app <id> "hello" -o json`] function renderTopicRows(): string { const width = TOPICS.reduce((max, t) => Math.max(max, t.name.length), 0) + 2 - return TOPICS.map(t => ` ${t.name.padEnd(width)}${t.summary}`).join('\n') + return TOPICS.map((t) => ` ${t.name.padEnd(width)}${t.summary}`).join('\n') } function renderGlobalFlagRows(): string { const width = GLOBAL_FLAG_HELP.reduce((max, f) => Math.max(max, f.label.length), 0) + 2 - return GLOBAL_FLAG_HELP.map(f => ` ${f.label.padEnd(width)}${f.description}`).join('\n') + return GLOBAL_FLAG_HELP.map((f) => ` ${f.label.padEnd(width)}${f.description}`).join('\n') } function formatTopLevelHelpText(tree: CommandTree): string { @@ -224,12 +225,12 @@ function formatTopLevelHelpText(tree: CommandTree): string { `${BIN} — Dify command-line interface`, `USAGE\n ${BIN} <command> <subcommand> [flags]`, `COMMANDS\n${renderCommandRows(collectCommands(tree))}`, - `EXAMPLES\n${ROOT_EXAMPLES.map(ex => ` $ ${ex}`).join('\n')}`, + `EXAMPLES\n${ROOT_EXAMPLES.map((ex) => ` $ ${ex}`).join('\n')}`, `GLOBAL FLAGS\n${renderGlobalFlagRows()}`, `GUIDES\n${renderTopicRows()}`, - `LEARN MORE\n` - + ` Use \`${BIN} <command> --help\` for details on a command.\n` - + ` New here? Run \`${BIN} help account\`. Agents: \`${BIN} help agent\` or \`${BIN} --help -o json\`.`, + `LEARN MORE\n` + + ` Use \`${BIN} <command> --help\` for details on a command.\n` + + ` New here? Run \`${BIN} help account\`. Agents: \`${BIN} help agent\` or \`${BIN} --help -o json\`.`, ] return `${sections.join('\n\n')}\n` @@ -237,12 +238,17 @@ function formatTopLevelHelpText(tree: CommandTree): string { export function formatTopLevelHelp(tree: CommandTree, format: string): string { if (isStructured(format)) { - return serialize({ - bin: BIN, - contract: CONTRACT, - commands: collectCommands(tree).map(({ command, path }) => describeCommand(command, path.join(' '))), - topics: TOPICS.map(t => ({ name: t.name, summary: t.summary })), - }, format) + return serialize( + { + bin: BIN, + contract: CONTRACT, + commands: collectCommands(tree).map(({ command, path }) => + describeCommand(command, path.join(' ')), + ), + topics: TOPICS.map((t) => ({ name: t.name, summary: t.summary })), + }, + format, + ) } return formatTopLevelHelpText(tree) diff --git a/cli/src/framework/output.test.ts b/cli/src/framework/output.test.ts index 5eaa0f54b90943..192373e0270f16 100644 --- a/cli/src/framework/output.test.ts +++ b/cli/src/framework/output.test.ts @@ -1,14 +1,13 @@ import type { FormattedPrintable, NamePrintable, TablePrintable } from './output' import { describe, expect, it } from 'vitest' import { OutputFormatNotSupportedError } from './errors' -import { - formatted, - raw, - stringifyOutput, - table, -} from './output' - -function makeFormatted(opts: { text?: string, json?: unknown, name?: string }): FormattedPrintable & NamePrintable { +import { formatted, raw, stringifyOutput, table } from './output' + +function makeFormatted(opts: { + text?: string + json?: unknown + name?: string +}): FormattedPrintable & NamePrintable { return { text: () => opts.text ?? 'hello', json: () => opts.json ?? { msg: 'hello' }, @@ -17,16 +16,17 @@ function makeFormatted(opts: { text?: string, json?: unknown, name?: string }): } function makeTable(opts: { - columns?: Array<{ name: string, priority: number }> + columns?: Array<{ name: string; priority: number }> rows?: Array<Array<string | number | boolean | null | undefined>> json?: unknown name?: string }): TablePrintable & NamePrintable { return { - tableColumns: () => opts.columns ?? [ - { name: 'NAME', priority: 0 }, - { name: 'STATUS', priority: 0 }, - ], + tableColumns: () => + opts.columns ?? [ + { name: 'NAME', priority: 0 }, + { name: 'STATUS', priority: 0 }, + ], tableRows: () => opts.rows ?? [['alice', 'active']], json: () => opts.json ?? [{ name: 'alice', status: 'active' }], name: () => opts.name ?? 'table-name', @@ -145,7 +145,7 @@ describe('stringifyOutput — table', () => { ], }) const result = stringifyOutput(table({ format: '', data })) - const lines = result.split('\n').filter(l => l.length > 0) + const lines = result.split('\n').filter((l) => l.length > 0) // 'hello' display width 5, '猜谜' display width 4 — column width=5 // padding after 'hello': 5-5+2=2 spaces → 'hello aaa' // padding after '猜谜': 5-4+2=3 spaces → '猜谜 bbb' @@ -185,8 +185,14 @@ describe('stringifyOutput — table', () => { it('table renders column padding correctly', () => { const data = makeTable({ - columns: [{ name: 'NAME', priority: 0 }, { name: 'ID', priority: 0 }], - rows: [['alice-longname', '1'], ['bob', '2']], + columns: [ + { name: 'NAME', priority: 0 }, + { name: 'ID', priority: 0 }, + ], + rows: [ + ['alice-longname', '1'], + ['bob', '2'], + ], }) const result = stringifyOutput(table({ format: '', data })) const lines = result.split('\n').filter(Boolean) diff --git a/cli/src/framework/output.ts b/cli/src/framework/output.ts index 6a5c32e02d8346..c07a66b6fe2cda 100644 --- a/cli/src/framework/output.ts +++ b/cli/src/framework/output.ts @@ -52,7 +52,10 @@ export type FormattedOutput<TData extends FormattedPrintable> = { readonly data: TData } -export type CommandOutput = RawOutput | TableOutput<TablePrintable> | FormattedOutput<FormattedPrintable> +export type CommandOutput = + | RawOutput + | TableOutput<TablePrintable> + | FormattedOutput<FormattedPrintable> export function raw(data: string): RawOutput { return { kind: 'raw', data } @@ -121,58 +124,57 @@ function renderTable(output: TableOutput<TablePrintable>): string { const keep: number[] = [] for (let i = 0; i < columns.length; i++) { const column = columns[i] - if (column !== undefined && (column.priority === 0 || wide)) - keep.push(i) + if (column !== undefined && (column.priority === 0 || wide)) keep.push(i) } const rows = [ - keep.map(i => columns[i]?.name ?? ''), - ...output.data.tableRows().map(row => keep.map((idx) => { - const cell = row[idx] - return cell === null || cell === undefined ? '' : String(cell) - })), + keep.map((i) => columns[i]?.name ?? ''), + ...output.data.tableRows().map((row) => + keep.map((idx) => { + const cell = row[idx] + return cell === null || cell === undefined ? '' : String(cell) + }), + ), ] return formatTable(rows) } function isWideCodePoint(cp: number): boolean { return ( - (cp >= 0x1100 && cp <= 0x115F) - || cp === 0x2329 || cp === 0x232A - || (cp >= 0x2E80 && cp <= 0x3247) - || (cp >= 0x3250 && cp <= 0x4DBF) - || (cp >= 0x4E00 && cp <= 0xA4C6) - || (cp >= 0xA960 && cp <= 0xA97C) - || (cp >= 0xAC00 && cp <= 0xD7A3) - || (cp >= 0xF900 && cp <= 0xFAFF) - || (cp >= 0xFE10 && cp <= 0xFE19) - || (cp >= 0xFE30 && cp <= 0xFE6B) - || (cp >= 0xFF01 && cp <= 0xFF60) - || (cp >= 0xFFE0 && cp <= 0xFFE6) - || (cp >= 0x1B000 && cp <= 0x1B001) - || (cp >= 0x1F200 && cp <= 0x1F251) - || (cp >= 0x20000 && cp <= 0x3FFFD) + (cp >= 0x1100 && cp <= 0x115f) || + cp === 0x2329 || + cp === 0x232a || + (cp >= 0x2e80 && cp <= 0x3247) || + (cp >= 0x3250 && cp <= 0x4dbf) || + (cp >= 0x4e00 && cp <= 0xa4c6) || + (cp >= 0xa960 && cp <= 0xa97c) || + (cp >= 0xac00 && cp <= 0xd7a3) || + (cp >= 0xf900 && cp <= 0xfaff) || + (cp >= 0xfe10 && cp <= 0xfe19) || + (cp >= 0xfe30 && cp <= 0xfe6b) || + (cp >= 0xff01 && cp <= 0xff60) || + (cp >= 0xffe0 && cp <= 0xffe6) || + (cp >= 0x1b000 && cp <= 0x1b001) || + (cp >= 0x1f200 && cp <= 0x1f251) || + (cp >= 0x20000 && cp <= 0x3fffd) ) } function displayWidth(s: string): number { let w = 0 - for (const ch of s) - w += isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1 + for (const ch of s) w += isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1 return w } function formatTable(rows: readonly (readonly string[])[]): string { - if (rows.length === 0) - return '' + if (rows.length === 0) return '' const colCount = rows[0]?.length ?? 0 const widths: number[] = Array.from({ length: colCount }, () => 0) for (const row of rows) { for (let i = 0; i < colCount; i++) { const cell = row[i] ?? '' const w = displayWidth(cell) - if (w > (widths[i] ?? 0)) - widths[i] = w + if (w > (widths[i] ?? 0)) widths[i] = w } } const lines = rows.map((row) => { @@ -182,8 +184,7 @@ function formatTable(rows: readonly (readonly string[])[]): string { const isLast = i === colCount - 1 if (isLast) { cells.push(cell) - } - else { + } else { const pad = (widths[i] ?? 0) - displayWidth(cell) + 2 cells.push(cell + ' '.repeat(pad)) } @@ -194,11 +195,12 @@ function formatTable(rows: readonly (readonly string[])[]): string { } function toName(data: TablePrintable | FormattedPrintable): string { - if (!isNamePrintable(data)) - throw new OutputFormatNotSupportedError('name') + if (!isNamePrintable(data)) throw new OutputFormatNotSupportedError('name') return data.name() } -function isNamePrintable(data: TablePrintable | FormattedPrintable): data is (TablePrintable | FormattedPrintable) & NamePrintable { +function isNamePrintable( + data: TablePrintable | FormattedPrintable, +): data is (TablePrintable | FormattedPrintable) & NamePrintable { return typeof (data as { name?: unknown }).name === 'function' } diff --git a/cli/src/framework/registry.test.ts b/cli/src/framework/registry.test.ts index 8c414908e45277..2229f4eb993171 100644 --- a/cli/src/framework/registry.test.ts +++ b/cli/src/framework/registry.test.ts @@ -225,7 +225,9 @@ describe('findSuggestions — cross-namespace fallback', () => { it('produces a deterministic, stable result across runs', () => { expect(findSuggestions(realish, ['login'])).toEqual(findSuggestions(realish, ['login'])) - expect(findSuggestions(realish, ['device', 'list'])).toEqual(findSuggestions(realish, ['device', 'list'])) + expect(findSuggestions(realish, ['device', 'list'])).toEqual( + findSuggestions(realish, ['device', 'list']), + ) }) }) diff --git a/cli/src/framework/registry.ts b/cli/src/framework/registry.ts index 58ef363b2a3d39..19897d83b6645e 100644 --- a/cli/src/framework/registry.ts +++ b/cli/src/framework/registry.ts @@ -14,19 +14,17 @@ function buildPath(parts: string[]): string { export function resolveCommand( tree: CommandTree, argv: string[], -): { command: CommandConstructor, path: string[] } | undefined { +): { command: CommandConstructor; path: string[] } | undefined { const path: string[] = [] let node: CommandNode | undefined - let lastMatch: { command: CommandConstructor, path: string[] } | undefined + let lastMatch: { command: CommandConstructor; path: string[] } | undefined for (let i = 0; i < argv.length; i++) { const token = argv[i] - if (token === undefined || token.startsWith('-')) - break + if (token === undefined || token.startsWith('-')) break const next = path.length === 0 ? tree[token] : node?.subcommands[token] - if (!next) - break + if (!next) break node = next path.push(token) @@ -49,9 +47,10 @@ function editDistance(a: string, b: string): number { for (let i = 1; i <= m; i++) { const curr: number[] = [i] for (let j = 1; j <= n; j++) { - curr[j] = a[i - 1] === b[j - 1] - ? (prev[j - 1] ?? 0) - : 1 + Math.min(prev[j] ?? 0, curr[j - 1] ?? 0, prev[j - 1] ?? 0) + curr[j] = + a[i - 1] === b[j - 1] + ? (prev[j - 1] ?? 0) + : 1 + Math.min(prev[j] ?? 0, curr[j - 1] ?? 0, prev[j - 1] ?? 0) } prev = curr } @@ -60,18 +59,15 @@ function editDistance(a: string, b: string): number { export function collectCommands( tree: CommandTree, -): Array<{ command: CommandConstructor, path: string[] }> { - const results: Array<{ command: CommandConstructor, path: string[] }> = [] +): Array<{ command: CommandConstructor; path: string[] }> { + const results: Array<{ command: CommandConstructor; path: string[] }> = [] function walk(node: CommandNode, path: string[]): void { - if (node.command && node.command.hidden !== true) - results.push({ command: node.command, path }) - for (const [key, child] of Object.entries(node.subcommands)) - walk(child, [...path, key]) + if (node.command && node.command.hidden !== true) results.push({ command: node.command, path }) + for (const [key, child] of Object.entries(node.subcommands)) walk(child, [...path, key]) } - for (const [key, node] of Object.entries(tree)) - walk(node, [key]) + for (const [key, node] of Object.entries(tree)) walk(node, [key]) return results } @@ -89,8 +85,7 @@ function relThreshold(token: string): number { function positionalTokens(argv: string[]): string[] { const tokens: string[] = [] for (const token of argv) { - if (token.startsWith('-')) - break + if (token.startsWith('-')) break tokens.push(token) } return tokens @@ -101,18 +96,15 @@ function positionalTokens(argv: string[]): string[] { // null when no alignment exists (e.g. more tokens than segments). function minSubsequenceCost(tokens: string[], segments: string[]): number | null { const [head, ...rest] = tokens - if (head === undefined) - return 0 + if (head === undefined) return 0 const threshold = relThreshold(head) let best: number | null = null for (const [index, segment] of segments.entries()) { const cost = editDistance(head, segment) - if (cost > threshold) - continue + if (cost > threshold) continue const tail = minSubsequenceCost(rest, segments.slice(index + 1)) - if (tail !== null && (best === null || cost + tail < best)) - best = cost + tail + if (tail !== null && (best === null || cost + tail < best)) best = cost + tail } return best } @@ -124,68 +116,62 @@ function minSubsequenceCost(tokens: string[], segments: string[]): number | null function scoreFallback(tree: CommandTree, tokens: string[]): string[] { const last = tokens.length - 1 const lastToken = tokens[last] - if (lastToken === undefined) - return [] + if (lastToken === undefined) return [] const prefix = tokens.slice(0, last) - const scored: Array<{ path: string, score: number, spelling: number, depth: number }> = [] + const scored: Array<{ path: string; score: number; spelling: number; depth: number }> = [] for (const { path } of collectCommands(tree)) { const leaf = path[path.length - 1] ?? '' const leafCost = editDistance(lastToken, leaf) - if (leafCost > relThreshold(lastToken)) - continue + if (leafCost > relThreshold(lastToken)) continue const prefixCost = minSubsequenceCost(prefix, path.slice(0, -1)) - if (prefixCost === null) - continue + if (prefixCost === null) continue const spelling = leafCost + prefixCost const score = spelling + OMIT_PENALTY * (path.length - tokens.length) - if (score >= MAX_SCORE) - continue + if (score >= MAX_SCORE) continue scored.push({ path: buildPath(path), score, spelling, depth: path.length }) } - if (scored.length === 0) - return [] + if (scored.length === 0) return [] scored.sort((a, b) => a.score - b.score || a.depth - b.depth || a.path.localeCompare(b.path)) // An exact leaf living under several namespaces is unroutable — staying silent // beats guessing an arbitrary one. const best = scored[0]?.score - const tied = scored.filter(item => item.score === best) - if (tied.length >= 2 && tied.every(item => item.spelling === 0)) - return [] + const tied = scored.filter((item) => item.score === best) + if (tied.length >= 2 && tied.every((item) => item.spelling === 0)) return [] - return scored.map(item => item.path) + return scored.map((item) => item.path) } export function findSuggestions(tree: CommandTree, argv: string[]): string[] { const results: string[] = [] function collectAll(node: CommandNode, path: string[]): void { - if (node.command && node.command.hidden !== true) - results.push(buildPath(path)) - for (const [key, child] of Object.entries(node.subcommands)) - collectAll(child, [...path, key]) + if (node.command && node.command.hidden !== true) results.push(buildPath(path)) + for (const [key, child] of Object.entries(node.subcommands)) collectAll(child, [...path, key]) } function traverse(nodes: Record<string, CommandNode>, tokens: string[], path: string[]): void { const token = tokens[0] - if (token === undefined || token.startsWith('-')) - return + if (token === undefined || token.startsWith('-')) return const rest = tokens.slice(1) const nextToken = rest.at(0) for (const [key, node] of Object.entries(nodes)) { if (editDistance(token, key) <= 1) { const newPath = [...path, key] - if (nextToken === undefined || nextToken.startsWith('-') || Object.keys(node.subcommands).length === 0) { + if ( + nextToken === undefined || + nextToken.startsWith('-') || + Object.keys(node.subcommands).length === 0 + ) { collectAll(node, newPath) - } - else { + } else { traverse(node.subcommands, rest, newPath) } } @@ -195,8 +181,7 @@ export function findSuggestions(tree: CommandTree, argv: string[]): string[] { // Same-level typos and namespace listing first; only fall back to // cross-namespace scoring when the level-by-level walk finds nothing. traverse(tree, argv, []) - if (results.length > 0) - return results + if (results.length > 0) return results return scoreFallback(tree, positionalTokens(argv)) } diff --git a/cli/src/framework/run.test.ts b/cli/src/framework/run.test.ts index 185072b9cbe866..b309172fea4211 100644 --- a/cli/src/framework/run.test.ts +++ b/cli/src/framework/run.test.ts @@ -81,8 +81,7 @@ async function captureRun(tree: CommandTree, argv: string[]): Promise<Captured> try { await run(tree, argv) - } - finally { + } finally { process.stdout.write = origStdout process.stderr.write = origStderr process.exit = origExit @@ -172,7 +171,9 @@ describe('run() catch routing', () => { it('routes Server5xx error with http_status line and generic exit', async () => { class Throwing extends Command { async run(_argv: string[]) { - throw HttpClientError.from(newError(ErrorCode.Server5xx, 'upstream boom')).withHttpStatus(502) + throw HttpClientError.from(newError(ErrorCode.Server5xx, 'upstream boom')).withHttpStatus( + 502, + ) } } const result = await captureRun(makeTree(Throwing), ['cmd']) @@ -219,7 +220,9 @@ describe('run() catch routing', () => { } } const result = await captureRun(makeTree(Throwing), ['cmd', '-o', 'json']) - expect(result.stderr).toBe(`${JSON.stringify({ error: { code: 'unknown', message: 'boom' } })}\n`) + expect(result.stderr).toBe( + `${JSON.stringify({ error: { code: 'unknown', message: 'boom' } })}\n`, + ) expect(result.exit).toBe(ExitCode.Generic) expect(result.stdout).toBe('') }) @@ -269,11 +272,9 @@ describe('run() catch routing', () => { }) as typeof process.stderr.write try { await run(makeTree(Throwing), ['cmd', '-o', 'json']) - } - catch (e) { + } catch (e) { expect((e as Error).message).toBe('__exit__') - } - finally { + } finally { process.exit = origExit process.stderr.write = origStderr } @@ -311,10 +312,10 @@ describe('run() catch routing', () => { async run(_argv: string[]) {} } - const result = await captureRun( - { cmd: { command: CtorBang, subcommands: {} } }, - ['cmd', '--output=json'], - ) + const result = await captureRun({ cmd: { command: CtorBang, subcommands: {} } }, [ + 'cmd', + '--output=json', + ]) expect(result.stderr).toContain('"code":"unknown"') expect(result.stderr).toContain('"message":"ctor-bang"') expect(result.exit).toBe(ExitCode.Generic) @@ -333,7 +334,7 @@ describe('hidden commands', () => { async run() {} } const tree: CommandTree = { - 'visible': { command: Visible, subcommands: {} }, + visible: { command: Visible, subcommands: {} }, 'secret-debug': { command: Hidden, subcommands: {} }, } const result = await captureRun(tree, []) @@ -354,7 +355,7 @@ describe('hidden commands', () => { const tree: CommandTree = { topic: { subcommands: { - 'public': { command: Public, subcommands: {} }, + public: { command: Public, subcommands: {} }, 'debug-only': { command: HiddenSub, subcommands: {} }, }, }, @@ -368,7 +369,9 @@ describe('hidden commands', () => { let ran = false class Hidden extends Command { static hidden = true - async run() { ran = true } + async run() { + ran = true + } } const tree: CommandTree = { 'secret-debug': { command: Hidden, subcommands: {} }, @@ -390,9 +393,7 @@ describe('deprecated commands', () => { old: { command: Old, subcommands: {} }, } const result = await captureRun(tree, ['old']) - expect(result.stderr).toBe( - 'deprecated: use `difyctl run app` instead; removal in 2.0\n', - ) + expect(result.stderr).toBe('deprecated: use `difyctl run app` instead; removal in 2.0\n') expect(result.stdout).toBe('old-ran\n') expect(result.exit).toBeUndefined() }) @@ -467,7 +468,9 @@ describe('run() help routing', () => { it('does not print trailing whitespace on group rows', async () => { const groupTree: CommandTree = { - auth: { subcommands: { devices: { subcommands: { list: { command: GetApp, subcommands: {} } } } } }, + auth: { + subcommands: { devices: { subcommands: { list: { command: GetApp, subcommands: {} } } } }, + }, } const result = await captureRun(groupTree, ['help']) expect(result.stdout).not.toMatch(/ \n/) @@ -548,7 +551,7 @@ describe('run() help routing', () => { expect(result.exit).toBeUndefined() expect(result.stdout).not.toContain('COMMANDS') const parsed = JSON.parse(result.stdout) as { commands: Array<{ command: string }> } - expect(parsed.commands.map(c => c.command)).toEqual([ + expect(parsed.commands.map((c) => c.command)).toEqual([ 'auth login', 'auth devices list', 'auth devices revoke', diff --git a/cli/src/framework/run.ts b/cli/src/framework/run.ts index 9bbd86aa5e29a1..1d1affe08a917d 100644 --- a/cli/src/framework/run.ts +++ b/cli/src/framework/run.ts @@ -14,10 +14,8 @@ export async function run(tree: CommandTree, argv: string[]): Promise<void> { const helpArgv: string[] = [] for (const a of argv) { - if (a === 'help' || a === '--help' || a === '-h') - continue - if (a.startsWith('-')) - break + if (a === 'help' || a === '--help' || a === '-h') continue + if (a.startsWith('-')) break helpArgv.push(a) } @@ -48,7 +46,7 @@ export async function run(tree: CommandTree, argv: string[]): Promise<void> { // misses them; surface their subtree instead of erroring. A strict-prefix // match over the full-depth command walk keeps this purely derived. const subtree = collectCommands(tree).filter( - c => c.path.length > helpArgv.length && helpArgv.every((token, i) => c.path[i] === token), + (c) => c.path.length > helpArgv.length && helpArgv.every((token, i) => c.path[i] === token), ) if (subtree.length > 0) { @@ -63,8 +61,7 @@ export async function run(tree: CommandTree, argv: string[]): Promise<void> { if (suggestions.length > 0) { process.stderr.write('\nDid you mean:\n') - for (const s of suggestions.slice(0, 5)) - process.stderr.write(` ${s}\n`) + for (const s of suggestions.slice(0, 5)) process.stderr.write(` ${s}\n`) } process.exit(1) @@ -84,8 +81,7 @@ export async function run(tree: CommandTree, argv: string[]): Promise<void> { if (suggestions.length > 0) { process.stderr.write('\nDid you mean:\n') - for (const s of suggestions.slice(0, 5)) - process.stderr.write(` ${s}\n`) + for (const s of suggestions.slice(0, 5)) process.stderr.write(` ${s}\n`) } process.exit(1) @@ -100,15 +96,13 @@ export async function run(tree: CommandTree, argv: string[]): Promise<void> { cmd.processGlobalFlags(commandArgv) const output = await cmd.run(commandArgv) - if (output !== undefined) - process.stdout.write(stringifyOutput(output)) - } - catch (err) { - if ((err as NodeJS.ErrnoException).code === 'EPIPE') - process.exit(0) - const e = err instanceof BaseError - ? err - : unknownError(err instanceof Error ? err.message : String(err), err) + if (output !== undefined) process.stdout.write(stringifyOutput(output)) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EPIPE') process.exit(0) + const e = + err instanceof BaseError + ? err + : unknownError(err instanceof Error ? err.message : String(err), err) const format = sniffOutputFormat(argv) process.stderr.write(`${formatErrorForCli(e, { format, isErrTTY: process.stderr.isTTY })}\n`) process.exit(e.exit()) @@ -118,21 +112,16 @@ export async function run(tree: CommandTree, argv: string[]): Promise<void> { export function sniffOutputFormat(argv: readonly string[]): string { for (let i = 0; i < argv.length; i++) { const t = argv[i] - if (t === undefined) - continue - if (t === '--') - return '' + if (t === undefined) continue + if (t === '--') return '' if (t === '--output' || t === '-o') { const next = argv[i + 1] - if (next !== undefined && !next.startsWith('-')) - return next + if (next !== undefined && !next.startsWith('-')) return next continue } - if (t.startsWith('--output=')) - return t.slice('--output='.length) - if (t.startsWith('-o=')) - return t.slice('-o='.length) + if (t.startsWith('--output=')) return t.slice('--output='.length) + if (t.startsWith('-o=')) return t.slice('-o='.length) } return '' } diff --git a/cli/src/help/contract.ts b/cli/src/help/contract.ts index d3a7549ecc99da..646f638648392c 100644 --- a/cli/src/help/contract.ts +++ b/cli/src/help/contract.ts @@ -14,7 +14,8 @@ export type Contract = { } const EXIT_CODE_DESCRIPTIONS: Readonly<Record<number, string>> = { - [ExitCode.Success]: 'success (also a workflow paused for human input — check stdout for status "paused")', + [ExitCode.Success]: + 'success (also a workflow paused for human input — check stdout for status "paused")', [ExitCode.Generic]: 'generic error', [ExitCode.Usage]: 'usage error (bad flag / missing arg)', [ExitCode.Auth]: 'auth error (not logged in / token expired)', @@ -51,8 +52,14 @@ export const CONTRACT: Contract = { // Single source for the top-level GLOBAL FLAGS section: flags that work across // commands. `-o` is parsed globally (see sniffOutputFormat); its accepted values // come straight from CONTRACT.outputFormats so the two can never drift. -export const GLOBAL_FLAG_HELP: ReadonlyArray<{ label: string, description: string }> = [ - { label: '-o, --output <format>', description: `Output format: ${CONTRACT.outputFormats.join('|')}` }, +export const GLOBAL_FLAG_HELP: ReadonlyArray<{ label: string; description: string }> = [ + { + label: '-o, --output <format>', + description: `Output format: ${CONTRACT.outputFormats.join('|')}`, + }, { label: '-v, --verbose', description: 'Enable verbose logging' }, - { label: '--http-retry <n>', description: 'Retry idempotent GET/PUT/DELETE on transient errors (0 disables)' }, + { + label: '--http-retry <n>', + description: 'Retry idempotent GET/PUT/DELETE on transient errors (0 disables)', + }, ] diff --git a/cli/src/help/skill.test.ts b/cli/src/help/skill.test.ts index 7ab087e982767e..ff5a8eeb0a59d3 100644 --- a/cli/src/help/skill.test.ts +++ b/cli/src/help/skill.test.ts @@ -11,8 +11,9 @@ const SELF_REFERENCES = new Set(['resume app', 'skills install', 'version']) describe('renderSkill', () => { it('substitutes the version stamp and changes nothing else', () => { - expect(renderSkill({ version: '9.9.9-test' })) - .toBe(SKILL_TEMPLATE.replaceAll('{{VERSION}}', '9.9.9-test')) + expect(renderSkill({ version: '9.9.9-test' })).toBe( + SKILL_TEMPLATE.replaceAll('{{VERSION}}', '9.9.9-test'), + ) }) it('stamps the running binary version (deterministic under test setup)', () => { @@ -32,22 +33,27 @@ describe('renderSkill', () => { const skill = renderSkill({ version: versionInfo.version }) for (const { path } of collectCommands(commandTree)) { const command = path.join(' ') - if (SELF_REFERENCES.has(command)) - continue + if (SELF_REFERENCES.has(command)) continue expect(skill, `skill must not enumerate command "${command}"`).not.toContain(command) } }) it('carries none of the old enumerated sections', () => { const skill = renderSkill({ version: versionInfo.version }) - for (const marker of ['## Safety', '## Core workflow', '## Reference', 'OUTPUT FORMATS', 'EXIT CODES', 'reference/']) + for (const marker of [ + '## Safety', + '## Core workflow', + '## Reference', + 'OUTPUT FORMATS', + 'EXIT CODES', + 'reference/', + ]) expect(skill).not.toContain(marker) }) it('inlines no command-specific flags (only the --help pointer)', () => { const skill = renderSkill({ version: versionInfo.version }) const longFlags = skill.match(/--[a-z][\w-]+/g) ?? [] - for (const flag of longFlags) - expect(flag, `unexpected flag in skill: ${flag}`).toBe('--help') + for (const flag of longFlags) expect(flag, `unexpected flag in skill: ${flag}`).toBe('--help') }) }) diff --git a/cli/src/help/topics.test.ts b/cli/src/help/topics.test.ts index 5a32689503595e..8a3004aca8c9c2 100644 --- a/cli/src/help/topics.test.ts +++ b/cli/src/help/topics.test.ts @@ -4,14 +4,13 @@ import { findTopic, TOPICS } from './topics' function render(name: string): string { const topic = findTopic(name) - if (!topic) - throw new Error(`topic not found: ${name}`) + if (!topic) throw new Error(`topic not found: ${name}`) return topic.render() } describe('topic registry', () => { it('registers account, agent, environment and external', () => { - expect(TOPICS.map(t => t.name)).toEqual(['account', 'agent', 'environment', 'external']) + expect(TOPICS.map((t) => t.name)).toEqual(['account', 'agent', 'environment', 'external']) }) it('findTopic returns undefined for unknown names', () => { @@ -78,7 +77,7 @@ describe('environment topic', () => { it('marks sensitive vars with a never-echoed notice', () => { const out = render('environment') expect(out).toContain('(treat as secret; never echoed)') - const sensitiveCount = ENV_REGISTRY.filter(v => v.sensitive).length + const sensitiveCount = ENV_REGISTRY.filter((v) => v.sensitive).length const noticeCount = (out.match(/treat as secret/g) ?? []).length expect(noticeCount).toBe(sensitiveCount) }) diff --git a/cli/src/help/topics.ts b/cli/src/help/topics.ts index de2becdfa36dea..3944e309be4814 100644 --- a/cli/src/help/topics.ts +++ b/cli/src/help/topics.ts @@ -59,8 +59,7 @@ function renderEnvironment(): string { let out = 'ENVIRONMENT VARIABLES\n\n' for (const v of ENV_REGISTRY) { out += ` ${v.name}\n ${v.description}\n` - if (v.sensitive) - out += ' (treat as secret; never echoed)\n' + if (v.sensitive) out += ' (treat as secret; never echoed)\n' out += '\n' } return out @@ -138,5 +137,5 @@ export const TOPICS: readonly HelpTopic[] = [ ] export function findTopic(name: string): HelpTopic | undefined { - return TOPICS.find(t => t.name === name) + return TOPICS.find((t) => t.name === name) } diff --git a/cli/src/http/body.test.ts b/cli/src/http/body.test.ts index e2f5c35dcfd380..31df0e45e0a51b 100644 --- a/cli/src/http/body.test.ts +++ b/cli/src/http/body.test.ts @@ -34,8 +34,14 @@ describe('isJSONSerializable', () => { describe('buildBody', () => { it('returns no body for GET regardless of json/body input', () => { - expect(buildBody({ method: 'GET', json: { a: 1 } })).toEqual({ body: undefined, contentType: undefined }) - expect(buildBody({ method: 'GET', body: 'x' })).toEqual({ body: undefined, contentType: undefined }) + expect(buildBody({ method: 'GET', json: { a: 1 } })).toEqual({ + body: undefined, + contentType: undefined, + }) + expect(buildBody({ method: 'GET', body: 'x' })).toEqual({ + body: undefined, + contentType: undefined, + }) }) it('serializes json and sets Content-Type on payload methods', () => { diff --git a/cli/src/http/body.ts b/cli/src/http/body.ts index 485b88549b4c6a..0a7bade678d4b3 100644 --- a/cli/src/http/body.ts +++ b/cli/src/http/body.ts @@ -4,24 +4,16 @@ import type { BodyInit } from './types.js' // plain objects, arrays, and anything with a `toJSON` method — but not typed // arrays/buffers, FormData, or URLSearchParams, which are sent as-is. export function isJSONSerializable(value: unknown): boolean { - if (value === undefined) - return false - if (value === null) - return true + if (value === undefined) return false + if (value === null) return true const t = typeof value - if (t === 'string' || t === 'number' || t === 'boolean') - return true - if (t !== 'object') - return false - if (Array.isArray(value)) - return true - const obj = value as { buffer?: unknown, constructor?: { name?: string }, toJSON?: unknown } - if (obj.buffer !== undefined) - return false - if (value instanceof FormData || value instanceof URLSearchParams) - return false - if (obj.constructor?.name === 'Object') - return true + if (t === 'string' || t === 'number' || t === 'boolean') return true + if (t !== 'object') return false + if (Array.isArray(value)) return true + const obj = value as { buffer?: unknown; constructor?: { name?: string }; toJSON?: unknown } + if (obj.buffer !== undefined) return false + if (value instanceof FormData || value instanceof URLSearchParams) return false + if (obj.constructor?.name === 'Object') return true return typeof obj.toJSON === 'function' } @@ -44,8 +36,7 @@ export function buildBody(input: BuildBodyInput): BuildBodyResult { const isPayloadMethod = method !== 'GET' && method !== 'HEAD' if (json !== undefined) { - if (!isPayloadMethod) - return { body: undefined, contentType: undefined } + if (!isPayloadMethod) return { body: undefined, contentType: undefined } if (isJSONSerializable(json)) return { body: JSON.stringify(json), contentType: 'application/json' } return { body: json as BodyInit, contentType: undefined } @@ -56,8 +47,7 @@ export function buildBody(input: BuildBodyInput): BuildBodyResult { // would be consumed on the first attempt and replay empty on retry. Safe today // because the only stream/multipart caller (file-upload) uses POST, which is not // in RETRY_METHODS; revisit if a ReadableStream body is ever sent over GET/PUT/DELETE. - if (body !== undefined && isPayloadMethod) - return { body, contentType: undefined } + if (body !== undefined && isPayloadMethod) return { body, contentType: undefined } return { body: undefined, contentType: undefined } } diff --git a/cli/src/http/client-tls.test.ts b/cli/src/http/client-tls.test.ts index 089b677fa977b3..723736d18cef85 100644 --- a/cli/src/http/client-tls.test.ts +++ b/cli/src/http/client-tls.test.ts @@ -8,24 +8,28 @@ 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 } { +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'] }) + 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) } } @@ -43,13 +47,13 @@ describe('createHttpClient against a real self-signed TLS server', () => { res.writeHead(200, { 'content-type': 'application/json' }) res.end(JSON.stringify({ ok: true })) }) - await new Promise<void>(resolve => server.listen(0, resolve)) + await new Promise<void>((resolve) => server.listen(0, resolve)) const port = (server.address() as AddressInfo).port baseURL = `https://localhost:${port}/` }) afterAll(async () => { - await new Promise<void>(resolve => server.close(() => resolve())) + await new Promise<void>((resolve) => server.close(() => resolve())) rmSync(certDir, { recursive: true, force: true }) }) diff --git a/cli/src/http/client.test.ts b/cli/src/http/client.test.ts index fa397852c98ab9..f5f9245777ec18 100644 --- a/cli/src/http/client.test.ts +++ b/cli/src/http/client.test.ts @@ -12,16 +12,19 @@ function base(mockUrl: string): string { return openAPIBase(mockUrl) } -type Stub = { url: string, stop: () => Promise<void> } +type Stub = { url: string; stop: () => Promise<void> } -function startStub(handler: (req: http.IncomingMessage, res: http.ServerResponse) => void): Promise<Stub> { +function startStub( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, +): Promise<Stub> { return new Promise((resolve, reject) => { const server = http.createServer(handler) server.listen(0, '127.0.0.1', () => { const addr = server.address() as AddressInfo resolve({ url: `http://127.0.0.1:${addr.port}`, - stop: () => new Promise<void>((res, rej) => server.close(err => err ? rej(err) : res())), + stop: () => + new Promise<void>((res, rej) => server.close((err) => (err ? rej(err) : res()))), }) }) server.on('error', reject) @@ -52,13 +55,14 @@ describe('http client', () => { logger: () => undefined, bearer: undefined, hooks: { - onRequest: ({ request }) => { captured = request.headers.get('authorization') }, + onRequest: ({ request }) => { + captured = request.headers.get('authorization') + }, }, }) try { await client.get('workspaces') - } - catch { + } catch { // 401 expected because no bearer } expect(captured).toBeNull() @@ -70,7 +74,9 @@ describe('http client', () => { baseURL: base(mock.url), bearer: 'dfoa_test', hooks: { - onRequest: ({ request }) => { captured = request.headers.get('authorization') }, + onRequest: ({ request }) => { + captured = request.headers.get('authorization') + }, }, }) await client.get('workspaces') @@ -84,7 +90,9 @@ describe('http client', () => { bearer: 'dfoa_test', userAgent: 'difyctl/0.0.0-test (test; arm64; dev)', hooks: { - onRequest: ({ request }) => { captured = request.headers.get('user-agent') }, + onRequest: ({ request }) => { + captured = request.headers.get('user-agent') + }, }, }) await client.get('workspaces') @@ -101,7 +109,9 @@ describe('http client', () => { baseURL: base(mock.url), bearer: 'dfoa_test', hooks: { - onRequest: ({ request }) => { captured = request.headers.get('user-agent') }, + onRequest: ({ request }) => { + captured = request.headers.get('user-agent') + }, }, }) await client.get('workspaces') @@ -114,8 +124,9 @@ describe('http client', () => { let caught: unknown try { await client.get('workspaces') + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isHttpClientError(caught)).toBe(true) if (isHttpClientError(caught)) { expect(caught.code).toBe(ErrorCode.AuthExpired) @@ -136,8 +147,9 @@ describe('http client', () => { let caught: unknown try { await client.get('workspaces') + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isHttpClientError(caught)).toBe(true) if (isHttpClientError(caught)) { expect(caught.code).toBe(ErrorCode.Server5xx) @@ -155,17 +167,18 @@ describe('http client', () => { let caught: unknown try { await client.get('workspaces') + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isBaseError(caught) || caught instanceof Error).toBe(true) }) it('logger fires twice per successful request (request + response phases)', async () => { - const events: { phase: string, status?: number }[] = [] + const events: { phase: string; status?: number }[] = [] const client = createHttpClient({ baseURL: base(mock.url), bearer: 'dfoa_test', - logger: e => events.push({ phase: e.phase, status: e.status }), + logger: (e) => events.push({ phase: e.phase, status: e.status }), }) await client.get('workspaces') expect(events).toHaveLength(2) @@ -185,11 +198,11 @@ describe('http client', () => { let caught: unknown try { await client.get('apps/nope') + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) - expect(caught.code).toBe(ErrorCode.Server4xxOther) + if (isHttpClientError(caught)) expect(caught.code).toBe(ErrorCode.Server4xxOther) }) it('surfaces a 429 as a rate-limit error (dedicated exit code), no retry when budget is 0', async () => { @@ -203,8 +216,9 @@ describe('http client', () => { let caught: unknown try { await client.get('workspaces') + } catch (err) { + caught = err } - catch (err) { caught = err } expect(isHttpClientError(caught)).toBe(true) if (isHttpClientError(caught)) { expect(caught.httpStatus).toBe(429) @@ -226,23 +240,22 @@ describe('http client', () => { res.writeHead(200, { 'content-type': 'application/json' }) res.end(JSON.stringify({ workspaces: [] })) }) - const events: { phase: string, status?: number, delayMs?: number }[] = [] + const events: { phase: string; status?: number; delayMs?: number }[] = [] try { const client = createHttpClient({ baseURL: base(stub.url), bearer: 'dfoa_test', retryAttempts: 3, timeoutMs: 5_000, - logger: e => events.push({ phase: e.phase, status: e.status, delayMs: e.delayMs }), + logger: (e) => events.push({ phase: e.phase, status: e.status, delayMs: e.delayMs }), }) const body = await client.get<{ workspaces: unknown[] }>('workspaces') expect(body.workspaces).toEqual([]) - } - finally { + } finally { await stub.stop() } expect(calls).toBe(2) - const retry = events.find(e => e.phase === 'retry') + const retry = events.find((e) => e.phase === 'retry') expect(retry?.status).toBe(429) expect(retry?.delayMs).toBeGreaterThan(0) }) @@ -261,16 +274,15 @@ describe('http client', () => { retryAttempts: 3, timeoutMs: 5_000, logger: (e) => { - if (e.phase === 'request') - requests++ + if (e.phase === 'request') requests++ }, }) try { await client.get('workspaces') + } catch (err) { + caught = err } - catch (err) { caught = err } - } - finally { + } finally { await stub.stop() } expect(requests).toBe(1) @@ -289,10 +301,14 @@ describe('http client', () => { res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) }) try { - const client = createHttpClient({ baseURL: base(stubDefault.url), bearer: 'dfoa_test', retryAttempts: 3, timeoutMs: 5_000 }) + const client = createHttpClient({ + baseURL: base(stubDefault.url), + bearer: 'dfoa_test', + retryAttempts: 3, + timeoutMs: 5_000, + }) await expect(client.post('apps/app-1/run', { json: { inputs: {} } })).rejects.toBeDefined() - } - finally { + } finally { await stubDefault.stop() } expect(postDefault).toBe(1) @@ -309,11 +325,18 @@ describe('http client', () => { res.end(JSON.stringify({ ok: true })) }) try { - const client = createHttpClient({ baseURL: base(stubOptIn.url), bearer: 'dfoa_test', retryAttempts: 3, timeoutMs: 5_000 }) - const body = await client.post<{ ok: boolean }>('apps/app-1/run', { json: { inputs: {} }, retryOnRateLimit: true }) + const client = createHttpClient({ + baseURL: base(stubOptIn.url), + bearer: 'dfoa_test', + retryAttempts: 3, + timeoutMs: 5_000, + }) + const body = await client.post<{ ok: boolean }>('apps/app-1/run', { + json: { inputs: {} }, + retryOnRateLimit: true, + }) expect(body.ok).toBe(true) - } - finally { + } finally { await stubOptIn.stop() } expect(calls).toBe(2) @@ -327,10 +350,16 @@ describe('http client', () => { res.end(JSON.stringify({ code: 'internal_server_error', message: 'boom', status: 503 })) }) try { - const client = createHttpClient({ baseURL: base(stub.url), bearer: 'dfoa_test', retryAttempts: 3, timeoutMs: 5_000 }) - await expect(client.post('apps/app-1/run', { json: { inputs: {} }, retryOnRateLimit: true })).rejects.toBeDefined() - } - finally { + const client = createHttpClient({ + baseURL: base(stub.url), + bearer: 'dfoa_test', + retryAttempts: 3, + timeoutMs: 5_000, + }) + await expect( + client.post('apps/app-1/run', { json: { inputs: {} }, retryOnRateLimit: true }), + ).rejects.toBeDefined() + } finally { await stub.stop() } expect(requests).toBe(1) @@ -351,25 +380,22 @@ describe('http client', () => { retryAttempts: 2, timeoutMs: 5_000, logger: (e) => { - if (e.phase === 'request') - requests++ - else if (e.phase === 'retry') - retries++ + if (e.phase === 'request') requests++ + else if (e.phase === 'retry') retries++ }, }) try { await client.get('workspaces') + } catch (err) { + caught = err } - catch (err) { caught = err } - } - finally { + } finally { await stub.stop() } expect(requests).toBe(3) expect(retries).toBe(2) expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) - expect(caught.code).toBe(ErrorCode.RateLimited) + if (isHttpClientError(caught)) expect(caught.code).toBe(ErrorCode.RateLimited) }) it('surfaces a throttle 429 whose Retry-After exceeds the honored cap (no retry)', async () => { @@ -387,22 +413,20 @@ describe('http client', () => { retryAttempts: 3, timeoutMs: 5_000, logger: (e) => { - if (e.phase === 'request') - requests++ + if (e.phase === 'request') requests++ }, }) try { await client.get('workspaces') + } catch (err) { + caught = err } - catch (err) { caught = err } - } - finally { + } finally { await stub.stop() } expect(requests).toBe(1) expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) - expect(caught.code).toBe(ErrorCode.RateLimited) + if (isHttpClientError(caught)) expect(caught.code).toBe(ErrorCode.RateLimited) }) it('stream GET surfaces a 429 (returns the Response, no throw, no retry)', async () => { @@ -413,12 +437,15 @@ describe('http client', () => { res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) }) try { - const client = createHttpClient({ baseURL: base(stub.url), bearer: 'dfoa_test', timeoutMs: 5_000 }) + const client = createHttpClient({ + baseURL: base(stub.url), + bearer: 'dfoa_test', + timeoutMs: 5_000, + }) const res = await client.stream('workspaces') expect(res.status).toBe(429) await res.body?.cancel() - } - finally { + } finally { await stub.stop() } expect(calls).toBe(1) @@ -437,12 +464,19 @@ describe('http client', () => { res.end('data: {}\n\n') }) try { - const client = createHttpClient({ baseURL: base(stub.url), bearer: 'dfoa_test', timeoutMs: 5_000 }) - const res = await client.stream('apps/app-1/run', { method: 'POST', json: {}, retryOnRateLimit: true }) + const client = createHttpClient({ + baseURL: base(stub.url), + bearer: 'dfoa_test', + timeoutMs: 5_000, + }) + const res = await client.stream('apps/app-1/run', { + method: 'POST', + json: {}, + retryOnRateLimit: true, + }) expect(res.status).toBe(200) await res.body?.cancel() - } - finally { + } finally { await stub.stop() } expect(calls).toBe(2) @@ -457,13 +491,12 @@ describe('http client', () => { retryAttempts: 3, timeoutMs: 5_000, logger: (e) => { - if (e.phase === 'request' || e.phase === 'retry') - attempts++ + if (e.phase === 'request' || e.phase === 'retry') attempts++ }, }) - await expect(client.post('apps/app-1/run', { json: { inputs: {}, response_mode: 'blocking' } })) - .rejects - .toBeDefined() + await expect( + client.post('apps/app-1/run', { json: { inputs: {}, response_mode: 'blocking' } }), + ).rejects.toBeDefined() expect(attempts).toBe(1) }) @@ -475,8 +508,7 @@ describe('http client', () => { retryAttempts: 3, timeoutMs: 3_000, logger: (e) => { - if (e.phase === 'request' || e.phase === 'retry') - attempts++ + if (e.phase === 'request' || e.phase === 'retry') attempts++ }, }) await expect( @@ -497,10 +529,8 @@ describe('http client', () => { retryAttempts: 2, timeoutMs: 3_000, logger: (e) => { - if (e.phase === 'request') - requests++ - else if (e.phase === 'retry') - retries++ + if (e.phase === 'request') requests++ + else if (e.phase === 'retry') retries++ }, }) await expect(client.get('workspaces')).rejects.toBeDefined() @@ -516,8 +546,7 @@ describe('http client', () => { retryAttempts: 3, timeoutMs: 3_000, logger: (e) => { - if (e.phase === 'request' || e.phase === 'retry') - attempts++ + if (e.phase === 'request' || e.phase === 'retry') attempts++ }, }) await expect(client.patch('workspaces', { json: {} })).rejects.toBeDefined() @@ -533,8 +562,7 @@ describe('empty / No-Content bodies', () => { try { const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test' }) await expect(client.delete('account/sessions/abc')).resolves.toBeUndefined() - } - finally { + } finally { await stub.stop() } }) @@ -546,8 +574,7 @@ describe('empty / No-Content bodies', () => { try { const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test' }) await expect(client.post('apps/app-1/tasks/t-1:stop', { json: {} })).resolves.toBeUndefined() - } - finally { + } finally { await stub.stop() } }) @@ -564,11 +591,9 @@ describe('classifyResponse internals', () => { logger, }) await client.get('workspaces') - const calls = logger.mock.calls.map(c => c[0]) - for (const event of calls) - expect(JSON.stringify(event)).not.toContain('dfoa_should_not_log') - } - finally { + const calls = logger.mock.calls.map((c) => c[0]) + for (const event of calls) expect(JSON.stringify(event)).not.toContain('dfoa_should_not_log') + } finally { await mock.stop() } }) @@ -589,9 +614,9 @@ describe('extend()', () => { baseURL: openAPIBase(mock.url), bearer: 'dfoa_test', userAgent: 'difyctl/parent', - logger: e => events.push({ phase: e.phase }), + logger: (e) => events.push({ phase: e.phase }), }) - let captured: { auth?: string | null, ua?: string | null } = {} + let captured: { auth?: string | null; ua?: string | null } = {} const child = parent.extend({ hooks: { onRequest: ({ request }) => { @@ -613,7 +638,7 @@ describe('extend()', () => { const parent = createHttpClient({ baseURL: openAPIBase(mock.url), bearer: 'dfoa_test', - logger: e => events.push(e), + logger: (e) => events.push(e), }) const silent = parent.extend({ logger: undefined }) await silent.get('workspaces') @@ -621,7 +646,11 @@ describe('extend()', () => { }) it('per-call timeoutMs override beats client default', async () => { - const parent = createHttpClient({ baseURL: openAPIBase(mock.url), bearer: 'dfoa_test', timeoutMs: 1 }) + const parent = createHttpClient({ + baseURL: openAPIBase(mock.url), + bearer: 'dfoa_test', + timeoutMs: 1, + }) // 1ms client default would always fail the mock GET; per-call override of 5s lets it succeed. const body = await parent.get<{ workspaces: unknown[] }>('workspaces', { timeoutMs: 5_000 }) expect(body.workspaces.length).toBeGreaterThan(0) @@ -668,8 +697,7 @@ describe('fetch() and stream()', () => { }) const res = await client.stream('apps/app-1/run', { method: 'POST', json: {} }) expect(res.ok).toBe(true) - } - finally { + } finally { await stub?.stop() } }) @@ -682,8 +710,7 @@ describe('fetch() and stream()', () => { retryAttempts: 5, timeoutMs: 0, logger: (e) => { - if (e.phase === 'request' || e.phase === 'retry') - attempts++ + if (e.phase === 'request' || e.phase === 'retry') attempts++ }, }) await expect(client.stream('workspaces')).rejects.toBeDefined() @@ -708,8 +735,7 @@ describe('timeout + abort retry policy', () => { }) await expect(client.post('apps/app-1/run', { json: {} })).rejects.toBeDefined() expect(attempts).toBe(1) - } - finally { + } finally { await stub?.stop() } }) @@ -730,8 +756,7 @@ describe('timeout + abort retry policy', () => { }) await expect(client.get('workspaces')).rejects.toBeDefined() expect(attempts).toBe(3) // initial + 2 retries - } - finally { + } finally { await stub?.stop() } }) @@ -755,8 +780,7 @@ describe('timeout + abort retry policy', () => { setTimeout(() => ac.abort(), 50) await expect(pending).rejects.toBeDefined() expect(attempts).toBe(1) - } - finally { + } finally { await stub?.stop() } }) @@ -771,7 +795,7 @@ describe('hook semantics', () => { await mock.stop() }) - it('onRequest hooks see each other\'s mutations via shared ctx.request', async () => { + it("onRequest hooks see each other's mutations via shared ctx.request", async () => { let observed: string | null = null const client = createHttpClient({ baseURL: openAPIBase(mock.url), @@ -838,13 +862,15 @@ describe('request() entrypoint (oRPC OpenAPILink socket)', () => { const mock = await startMock() try { // Deliberately wrong baseURL: if request() re-joined it via joinURL, this would miss. - const client = createHttpClient({ baseURL: 'http://wrong.invalid/openapi/v1/', bearer: 'dfoa_test' }) + const client = createHttpClient({ + baseURL: 'http://wrong.invalid/openapi/v1/', + bearer: 'dfoa_test', + }) const res = await client.request(new Request(`${base(mock.url)}workspaces`)) expect(res.status).toBe(200) - const body = await res.json() as { workspaces: unknown[] } + const body = (await res.json()) as { workspaces: unknown[] } expect(body.workspaces).toHaveLength(2) - } - finally { + } finally { await mock.stop() } }) @@ -868,8 +894,7 @@ describe('request() entrypoint (oRPC OpenAPILink socket)', () => { await client.request(new Request(`${base(mock.url)}workspaces`)) expect(auth).toBe('Bearer dfoa_test') expect(ua).toBe('difyctl/req-test') - } - finally { + } finally { await mock.stop() } }) @@ -882,8 +907,7 @@ describe('request() entrypoint (oRPC OpenAPILink socket)', () => { const res = await client.request(new Request(`${base(mock.url)}workspaces`)) expect(res.ok).toBe(false) expect(res.status).toBe(401) - } - finally { + } finally { await mock.stop() } }) @@ -907,32 +931,44 @@ describe('request() entrypoint (oRPC OpenAPILink socket)', () => { }) }) try { - const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test', retryAttempts: 2, timeoutMs: 5_000 }) + const client = createHttpClient({ + baseURL: stub.url, + bearer: 'dfoa_test', + retryAttempts: 2, + timeoutMs: 5_000, + }) // PUT is in RETRY_METHODS and carries a body — proves clone-on-retry replays it. - const res = await client.request(new Request(`${stub.url}/x`, { - method: 'PUT', - body: JSON.stringify({ a: 1 }), - headers: { 'content-type': 'application/json' }, - })) + const res = await client.request( + new Request(`${stub.url}/x`, { + method: 'PUT', + body: JSON.stringify({ a: 1 }), + headers: { 'content-type': 'application/json' }, + }), + ) expect(res.status).toBe(200) expect(hits).toBe(2) expect(secondBody).toBe('{"a":1}') - } - finally { + } finally { await stub.stop() } }) it('applies the client-default timeout on top of the Request signal', async () => { - const stub = await startStub(() => { /* never responds */ }) + const stub = await startStub(() => { + /* never responds */ + }) try { - const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test', timeoutMs: 100, retryAttempts: 0 }) + const client = createHttpClient({ + baseURL: stub.url, + bearer: 'dfoa_test', + timeoutMs: 100, + retryAttempts: 0, + }) const ac = new AbortController() // oRPC's own signal, never aborted - await expect(client.request(new Request(`${stub.url}/x`, { signal: ac.signal }))) - .rejects - .toBeDefined() - } - finally { + await expect( + client.request(new Request(`${stub.url}/x`, { signal: ac.signal })), + ).rejects.toBeDefined() + } finally { await stub.stop() } }) @@ -944,13 +980,19 @@ describe('request() entrypoint (oRPC OpenAPILink socket)', () => { }) try { const ac = new AbortController() - const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test', retryAttempts: 3, timeoutMs: 5_000 }) - const pending = client.request(new Request(`${stub.url}/x`, { method: 'GET', signal: ac.signal })) + const client = createHttpClient({ + baseURL: stub.url, + bearer: 'dfoa_test', + retryAttempts: 3, + timeoutMs: 5_000, + }) + const pending = client.request( + new Request(`${stub.url}/x`, { method: 'GET', signal: ac.signal }), + ) setTimeout(() => ac.abort(), 50) await expect(pending).rejects.toBeDefined() expect(hits).toBe(1) - } - finally { + } finally { await stub.stop() } }) @@ -961,14 +1003,18 @@ describe('request() entrypoint (oRPC OpenAPILink socket)', () => { hits++ // never responds }) try { - const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test', timeoutMs: 100, retryAttempts: 2 }) + const client = createHttpClient({ + baseURL: stub.url, + bearer: 'dfoa_test', + timeoutMs: 100, + retryAttempts: 2, + }) // GET is retryable; each attempt must mint its own 100ms timeout, so all 3 fire. If the // timeout signal were hoisted out of the retry loop, attempt 0's expired signal would // short-circuit attempts 1-2 and hits would be 1. await expect(client.request(new Request(`${stub.url}/x`))).rejects.toBeDefined() expect(hits).toBe(3) - } - finally { + } finally { await stub.stop() } }, 30_000) diff --git a/cli/src/http/client.ts b/cli/src/http/client.ts index 30f8ca27441198..197b23148dd15f 100644 --- a/cli/src/http/client.ts +++ b/cli/src/http/client.ts @@ -15,7 +15,12 @@ import { buildBody } from './body.js' import { classifyResponse } from './error-mapper.js' import { classifyTransport, logRequest, logResponse, setBearer, setUserAgent } from './hooks.js' import { proxyDispatcher } from './proxy.js' -import { classifyRateLimit, MAX_HONORED_WAIT_MS, RATE_LIMIT_MAX_ATTEMPTS, rateLimitDelayMs } from './rate-limit.js' +import { + classifyRateLimit, + MAX_HONORED_WAIT_MS, + RATE_LIMIT_MAX_ATTEMPTS, + rateLimitDelayMs, +} from './rate-limit.js' import { backoffDelay, isIdempotentRetryMethod, shouldRetry } from './retry.js' import { redactBearer } from './sanitize.js' import { appendSearchParams, joinURL } from './url.js' @@ -42,8 +47,7 @@ type ClientState = { } function toArray<T>(value: T | T[] | undefined): T[] { - if (value === undefined) - return [] + if (value === undefined) return [] return Array.isArray(value) ? value : [value] } @@ -56,8 +60,7 @@ function compileState(opts: ClientOptions): ClientState { // Always pin a difyctl-shaped UA so server logs / WAF rules see the CLI's // version + platform. Callers can override by passing `userAgent` explicitly. onRequest.push(setUserAgent(opts.userAgent ?? defaultUserAgent())) - if (opts.bearer !== undefined && opts.bearer !== '') - onRequest.push(setBearer(opts.bearer)) + if (opts.bearer !== undefined && opts.bearer !== '') onRequest.push(setBearer(opts.bearer)) if (opts.logger !== undefined) { onRequest.push(logRequest(opts.logger)) onResponse.push(logResponse(opts.logger)) @@ -81,21 +84,22 @@ function compileState(opts: ClientOptions): ClientState { } async function runHooks(hooks: readonly Hook[], ctx: FetchContext): Promise<void> { - for (const hook of hooks) - await hook(ctx) + for (const hook of hooks) await hook(ctx) } // Merge a fresh per-attempt timeout signal with the persistent user/oRPC signal. // Called once per attempt inside execute() so every retry gets its own timeout budget. -function mergeSignal(userSignal: AbortSignal | undefined, effectiveTimeoutMs: number | undefined): AbortSignal | undefined { - const timeoutSignal = effectiveTimeoutMs !== undefined && effectiveTimeoutMs > 0 - ? AbortSignal.timeout(effectiveTimeoutMs) - : undefined - - if (timeoutSignal === undefined) - return userSignal - if (userSignal === undefined) - return timeoutSignal +function mergeSignal( + userSignal: AbortSignal | undefined, + effectiveTimeoutMs: number | undefined, +): AbortSignal | undefined { + const timeoutSignal = + effectiveTimeoutMs !== undefined && effectiveTimeoutMs > 0 + ? AbortSignal.timeout(effectiveTimeoutMs) + : undefined + + if (timeoutSignal === undefined) return userSignal + if (userSignal === undefined) return timeoutSignal return AbortSignal.any([timeoutSignal, userSignal]) } @@ -116,11 +120,19 @@ type BuiltRequest = { // Path-keyed constructor: turns (path, opts) into a Request. The Request is built // WITHOUT a signal — execute() supplies a fresh per-attempt signal via fetch's // init.signal (which overrides any signal carried on the Request). -function buildRequest(state: ClientState, path: string, opts: RequestOptions, throwOnErrorDefault: boolean): BuiltRequest { +function buildRequest( + state: ClientState, + path: string, + opts: RequestOptions, + throwOnErrorDefault: boolean, +): BuiltRequest { const method: HttpMethod = opts.method ?? 'GET' - const effectiveTimeoutMs = opts.timeoutMs !== undefined - ? (opts.timeoutMs > 0 ? opts.timeoutMs : undefined) - : state.defaultTimeoutMs + const effectiveTimeoutMs = + opts.timeoutMs !== undefined + ? opts.timeoutMs > 0 + ? opts.timeoutMs + : undefined + : state.defaultTimeoutMs const effectiveRetryAttempts = opts.retryAttempts ?? state.defaultRetryAttempts const throwOnError = opts.throwOnError ?? throwOnErrorDefault @@ -178,18 +190,18 @@ async function execute( // 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 + 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 try { ctx.response = await fetch(ctx.request, init) - } - catch (err) { + } catch (err) { ctx.error = err // Snapshot the abort cause before onRequestError hooks rewrite ctx.error into BaseError. const userAborted = userSignal?.aborted === true @@ -198,10 +210,14 @@ async function execute( // User aborts (ctrl+C) must never retry. Timeouts and other transport errors fall // through to shouldRetry, which enforces the method allowlist. if (!userAborted && attempt < effectiveRetryAttempts && shouldRetry(ctx.error, ctx)) { - state.logger?.({ phase: 'retry', method, url: redactBearer(ctx.request.url), attempt: attempt + 1 }) + state.logger?.({ + phase: 'retry', + method, + url: redactBearer(ctx.request.url), + attempt: attempt + 1, + }) const delay = backoffDelay(attempt + 1) - if (delay > 0) - await new Promise(resolve => setTimeout(resolve, delay)) + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)) continue } @@ -221,17 +237,23 @@ async function execute( // retries. Surfacing reuses the shared classifyResponse so the body parses to ErrorBody. if (res.status === 429) { const decision = await classifyRateLimit(res.clone()) - const canRetry - = decision.retryable - && attempt < effectiveRetryAttempts - && (decision.retryAfterMs === undefined || decision.retryAfterMs <= MAX_HONORED_WAIT_MS) - && (isIdempotentRetryMethod(method) || (method === 'POST' && resolved.retryOnRateLimit)) + const canRetry = + decision.retryable && + attempt < effectiveRetryAttempts && + (decision.retryAfterMs === undefined || decision.retryAfterMs <= MAX_HONORED_WAIT_MS) && + (isIdempotentRetryMethod(method) || (method === 'POST' && resolved.retryOnRateLimit)) if (canRetry) { const delay = rateLimitDelayMs(decision, attempt + 1) - state.logger?.({ phase: 'retry', method, url: redactBearer(ctx.request.url), status: 429, attempt: attempt + 1, delayMs: delay }) + state.logger?.({ + phase: 'retry', + method, + url: redactBearer(ctx.request.url), + status: 429, + attempt: attempt + 1, + delayMs: delay, + }) await res.body?.cancel().catch(() => {}) - if (delay > 0) - await new Promise(resolve => setTimeout(resolve, delay)) + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)) continue } @@ -247,13 +269,17 @@ async function execute( } if (attempt < effectiveRetryAttempts && shouldRetry(res, ctx)) { - state.logger?.({ phase: 'retry', method, url: redactBearer(ctx.request.url), attempt: attempt + 1 }) + state.logger?.({ + phase: 'retry', + method, + url: redactBearer(ctx.request.url), + attempt: attempt + 1, + }) // Drain the discarded error body so undici can release the socket back to its // pool instead of holding the connection open until keep-alive timeout / GC. await res.body?.cancel().catch(() => {}) const delay = backoffDelay(attempt + 1) - if (delay > 0) - await new Promise(resolve => setTimeout(resolve, delay)) + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)) continue } @@ -276,8 +302,7 @@ async function execute( // letting `res.json()` throw an unclassified SyntaxError, so void-returning callers // (revoke, stopTask, …) stay safe when a server replies with No Content. async function parseJsonBody<T>(res: Response): Promise<T> { - if (res.status === 204 || res.status === 205) - return undefined as T + if (res.status === 204 || res.status === 205) return undefined as T const text = await res.text() return (text === '' ? undefined : JSON.parse(text)) as T } @@ -285,10 +310,20 @@ async function parseJsonBody<T>(res: Response): Promise<T> { export function createHttpClient(opts: ClientOptions): HttpClient { const state = compileState(opts) - const typedCall = async <T>(method: HttpMethod, path: string, callOpts?: RequestOptions): Promise<T> => { + const typedCall = async <T>( + method: HttpMethod, + path: string, + callOpts?: RequestOptions, + ): Promise<T> => { const finalOpts: RequestOptions = { ...callOpts, method } const built = buildRequest(state, path, finalOpts, true) - const res = await execute(state, built.request, built.resolved, built.effectiveTimeoutMs, built.userSignal) + const res = await execute( + state, + built.request, + built.resolved, + built.effectiveTimeoutMs, + built.userSignal, + ) return parseJsonBody<T>(res) } @@ -306,9 +341,8 @@ export function createHttpClient(opts: ClientOptions): HttpClient { // opts into 429 retry, allow a bounded budget: the 429 admission rejection arrives as a plain // body before the stream opens, and execute()'s 429 branch is the only path that fires for a // POST — shouldRetry still rejects POST for transport / 5xx, so nothing else replays. - const retryAttempts = callOpts?.retryOnRateLimit === true - ? (callOpts.retryAttempts ?? RATE_LIMIT_MAX_ATTEMPTS) - : 0 + const retryAttempts = + callOpts?.retryOnRateLimit === true ? (callOpts.retryAttempts ?? RATE_LIMIT_MAX_ATTEMPTS) : 0 const finalOpts: RequestOptions = { ...callOpts, method: callOpts?.method ?? 'GET', @@ -340,7 +374,8 @@ export function createHttpClient(opts: ClientOptions): HttpClient { return execute(state, req, resolved, state.defaultTimeoutMs, userSignal) } - const extend = (overrides: Partial<ClientOptions>): HttpClient => createHttpClient({ ...state.originalOptions, ...overrides }) + const extend = (overrides: Partial<ClientOptions>): HttpClient => + createHttpClient({ ...state.originalOptions, ...overrides }) return { baseURL: state.baseURL, diff --git a/cli/src/http/error-mapper.test.ts b/cli/src/http/error-mapper.test.ts index c08a3a62e48b01..d439c7899355ca 100644 --- a/cli/src/http/error-mapper.test.ts +++ b/cli/src/http/error-mapper.test.ts @@ -41,7 +41,7 @@ describe('classifyResponse — canonical ErrorBody', () => { }) expect(err.code).toBe(ErrorCode.AuthExpired) - expect(err.hint).toBe('run \'difyctl auth login\' to sign in again') + expect(err.hint).toBe("run 'difyctl auth login' to sign in again") }) it('unknown future server code is data, not behavior — status bucket decides', async () => { @@ -56,7 +56,11 @@ describe('classifyResponse — canonical ErrorBody', () => { }) it('429 classifies as RateLimited (dedicated exit code) and keeps the server code', async () => { - const err = await classified(429, { code: 'too_many_requests', message: 'slow down', status: 429 }) + const err = await classified(429, { + code: 'too_many_requests', + message: 'slow down', + status: 429, + }) expect(err.code).toBe(ErrorCode.RateLimited) expect(err.exit()).toBe(7) @@ -76,7 +80,11 @@ describe('classifyResponse 403', () => { it('maps 403 to AccessDenied (exit 4 bucket)', async () => { const req403 = new Request('https://x/openapi/v1/apps/abc/dsl') const res403 = new Response( - JSON.stringify({ code: 'unsupported_token_type', message: 'unsupported_token_type', status: 403 }), + JSON.stringify({ + code: 'unsupported_token_type', + message: 'unsupported_token_type', + status: 403, + }), { status: 403, headers: { 'content-type': 'application/json' } }, ) const err = await classifyResponse(req403, res403) diff --git a/cli/src/http/error-mapper.ts b/cli/src/http/error-mapper.ts index 2e29985037c1a2..0c0d1a8a7e77f7 100644 --- a/cli/src/http/error-mapper.ts +++ b/cli/src/http/error-mapper.ts @@ -6,7 +6,7 @@ import { ErrorCode } from '@/errors/codes' import { redactBearer } from './sanitize' const AUTH_EXPIRED_MESSAGE = 'session expired or revoked' -const AUTH_LOGIN_HINT = 'run \'difyctl auth login\' to sign in again' +const AUTH_LOGIN_HINT = "run 'difyctl auth login' to sign in again" // How one HTTP status bucket classifies: CLI code, message fallback when the // body is not a canonical ErrorBody, optional CLI hint, raw-body retention. @@ -26,13 +26,13 @@ const AUTH_EXPIRED_CLASS: StatusClass = { const SERVER_5XX_CLASS: StatusClass = { code: ErrorCode.Server5xx, - fallbackMessage: status => `server error (HTTP ${status})`, + fallbackMessage: (status) => `server error (HTTP ${status})`, includeRaw: true, } const SERVER_4XX_CLASS: StatusClass = { code: ErrorCode.Server4xxOther, - fallbackMessage: status => `request failed (HTTP ${status})`, + fallbackMessage: (status) => `request failed (HTTP ${status})`, includeRaw: true, } @@ -60,39 +60,34 @@ const VERSION_COMPAT_CLASS: StatusClass = { } function statusClass(status: number): StatusClass { - if (status === 401) - return AUTH_EXPIRED_CLASS - if (status === 403) - return ACCESS_DENIED_CLASS - if (status === 426) - return VERSION_COMPAT_CLASS - if (status === 429) - return RATE_LIMITED_CLASS - if (status >= 500) - return SERVER_5XX_CLASS + if (status === 401) return AUTH_EXPIRED_CLASS + if (status === 403) return ACCESS_DENIED_CLASS + if (status === 426) return VERSION_COMPAT_CLASS + if (status === 429) return RATE_LIMITED_CLASS + if (status >= 500) return SERVER_5XX_CLASS return SERVER_4XX_CLASS } function parseServerError(raw: string): ErrorBody | undefined { - if (raw === '') - return undefined + if (raw === '') return undefined let parsed: unknown try { parsed = JSON.parse(raw) - } - catch { + } catch { return undefined } const result = zErrorBody.safeParse(parsed) return result.success ? result.data : undefined } -export async function classifyResponse(request: Request, response: Response): Promise<HttpClientError> { +export async function classifyResponse( + request: Request, + response: Response, +): Promise<HttpClientError> { let raw = '' try { raw = await response.clone().text() - } - catch { + } catch { // ignore read errors; raw stays '' } diff --git a/cli/src/http/hooks.ts b/cli/src/http/hooks.ts index d08d2c24b8e54b..4b502dddf1d3db 100644 --- a/cli/src/http/hooks.ts +++ b/cli/src/http/hooks.ts @@ -14,8 +14,7 @@ export function setBearer(token: string): Hook { export function setUserAgent(ua: string): Hook { return ({ request }) => { - if (!request.headers.has('user-agent')) - request.headers.set('user-agent', ua) + if (!request.headers.has('user-agent')) request.headers.set('user-agent', ua) } } @@ -32,8 +31,7 @@ export function logRequest(logger: HttpLogger): Hook { export function logResponse(logger: HttpLogger): Hook { return ({ request, response, meta }) => { - if (response === undefined) - return + if (response === undefined) return const start = meta.get(HTTP_START_SYM) const durationMs = typeof start === 'number' ? performance.now() - start : undefined logger({ @@ -47,9 +45,7 @@ export function logResponse(logger: HttpLogger): Hook { } export const classifyTransport: Hook = (ctx) => { - if (ctx.error === undefined) - return - if (ctx.error instanceof BaseError) - return + if (ctx.error === undefined) return + if (ctx.error instanceof BaseError) return ctx.error = classifyTransportError(ctx.error) } diff --git a/cli/src/http/orpc.test.ts b/cli/src/http/orpc.test.ts index 0d1f2e12d571f3..77f44646a4bec3 100644 --- a/cli/src/http/orpc.test.ts +++ b/cli/src/http/orpc.test.ts @@ -13,7 +13,11 @@ import { createOpenApiClient } from './orpc.js' // method/url, and the raw body — straight from a plain `orpc.x()` call, with no per-call wrapper. function orpcClient(host: string) { // retryAttempts: 0 so the 5xx case fails fast instead of burning the backoff budget. - const http = createHttpClient({ baseURL: openAPIBase(host), bearer: 'dfoa_test', retryAttempts: 0 }) + const http = createHttpClient({ + baseURL: openAPIBase(host), + bearer: 'dfoa_test', + retryAttempts: 0, + }) return createOpenApiClient(http) } @@ -21,8 +25,7 @@ async function catchErr(run: () => Promise<unknown>): Promise<unknown> { try { await run() return undefined - } - catch (err) { + } catch (err) { return err } } @@ -35,7 +38,7 @@ describe('createOpenApiClient error mapping', () => { }) async function classifiedError(status: number, body: unknown): Promise<HttpClientError> { - stub = await startStubServer(cap => jsonResponder(status, body, cap)) + stub = await startStubServer((cap) => jsonResponder(status, body, cap)) const orpc = orpcClient(stub.url) const caught = await catchErr(() => orpc.account.get()) if (!isHttpClientError(caught)) @@ -44,7 +47,11 @@ describe('createOpenApiClient error mapping', () => { } it('recovers Dify message from a canonical ErrorBody 4xx response', async () => { - const caught = await classifiedError(422, { code: 'invalid_param', message: 'no access', status: 422 }) + const caught = await classifiedError(422, { + code: 'invalid_param', + message: 'no access', + status: 422, + }) expect(caught.code).toBe(ErrorCode.Server4xxOther) expect(caught.httpStatus).toBe(422) @@ -58,7 +65,11 @@ describe('createOpenApiClient error mapping', () => { }) it('reads server message from canonical ErrorBody on 401 and keeps the auth code', async () => { - const caught = await classifiedError(401, { code: 'unauthorized', message: 'expired', status: 401 }) + const caught = await classifiedError(401, { + code: 'unauthorized', + message: 'expired', + status: 401, + }) expect(caught.code).toBe(ErrorCode.AuthExpired) expect(caught.httpStatus).toBe(401) @@ -73,7 +84,11 @@ describe('createOpenApiClient error mapping', () => { }) it('maps 5xx to Server5xx with message from canonical ErrorBody', async () => { - const caught = await classifiedError(503, { code: 'service_unavailable', message: 'down for maintenance', status: 503 }) + const caught = await classifiedError(503, { + code: 'service_unavailable', + message: 'down for maintenance', + status: 503, + }) expect(caught.code).toBe(ErrorCode.Server5xx) expect(caught.httpStatus).toBe(503) diff --git a/cli/src/http/orpc.ts b/cli/src/http/orpc.ts index 484d2d1a616894..704fd9ca583e9f 100644 --- a/cli/src/http/orpc.ts +++ b/cli/src/http/orpc.ts @@ -22,8 +22,7 @@ export function createOpenApiClient(http: HttpClient): OpenApiClient { url: http.baseURL, fetch: async (req, init) => { const res = await http.request(req, init) - if (!res.ok) - throw await classifyResponse(req, res) + if (!res.ok) throw await classifyResponse(req, res) return res }, }) @@ -35,7 +34,6 @@ export function createOpenApiClient(http: HttpClient): OpenApiClient { // Non-2xx and transport failures already arrive as BaseError (from the fetch wrapper / transport) // and re-throw unchanged; the only residual is a 2xx body oRPC failed to decode. function mapOrpcError(err: unknown): never { - if (isBaseError(err)) - throw err + if (isBaseError(err)) throw err throw unknownError(err instanceof Error ? err.message : String(err), err) } diff --git a/cli/src/http/proxy.test.ts b/cli/src/http/proxy.test.ts index 2c1f386f320ce0..8a09b124425641 100644 --- a/cli/src/http/proxy.test.ts +++ b/cli/src/http/proxy.test.ts @@ -1,6 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const PROXY_KEYS = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy'] as const +const PROXY_KEYS = [ + 'HTTP_PROXY', + 'http_proxy', + 'HTTPS_PROXY', + 'https_proxy', + 'NO_PROXY', + 'no_proxy', +] as const describe('proxyDispatcher', () => { const saved = new Map<string, string | undefined>() @@ -17,10 +24,8 @@ describe('proxyDispatcher', () => { afterEach(() => { for (const k of PROXY_KEYS) { const v = saved.get(k) - if (v === undefined) - delete process.env[k] - else - process.env[k] = v + if (v === undefined) delete process.env[k] + else process.env[k] = v } }) diff --git a/cli/src/http/proxy.ts b/cli/src/http/proxy.ts index bd9a745c922223..f2d3caaca423a4 100644 --- a/cli/src/http/proxy.ts +++ b/cli/src/http/proxy.ts @@ -4,7 +4,7 @@ import { Agent, EnvHttpProxyAgent } from 'undici' const PROXY_ENV_KEYS = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy'] as const export function hasProxyEnv(): boolean { - return PROXY_ENV_KEYS.some(k => (process.env[k] ?? '') !== '') + return PROXY_ENV_KEYS.some((k) => (process.env[k] ?? '') !== '') } export type ProxyDispatcherOptions = { @@ -28,8 +28,12 @@ export function proxyDispatcher(opts: ProxyDispatcherOptions = {}): Dispatcher | 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) + ? 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/rate-limit.test.ts b/cli/src/http/rate-limit.test.ts index 5b81bb679ae8e0..5c2684e7f66576 100644 --- a/cli/src/http/rate-limit.test.ts +++ b/cli/src/http/rate-limit.test.ts @@ -20,7 +20,9 @@ function headers(init?: Record<string, string>): Headers { describe('classifyRateLimit', () => { it('throttle (code too_many_requests) is retryable and reads the Retry-After header', async () => { - const d = await classifyRateLimit(res429({ code: 'too_many_requests', status: 429 }, { 'retry-after': '2' })) + const d = await classifyRateLimit( + res429({ code: 'too_many_requests', status: 429 }, { 'retry-after': '2' }), + ) expect(d).toEqual({ retryable: true, retryAfterMs: 2000 }) }) @@ -30,7 +32,9 @@ describe('classifyRateLimit', () => { }) it('quota (code rate_limit_error) is not retryable', async () => { - const d = await classifyRateLimit(res429({ code: 'rate_limit_error', status: 429 }, { 'retry-after': '5' })) + const d = await classifyRateLimit( + res429({ code: 'rate_limit_error', status: 429 }, { 'retry-after': '5' }), + ) expect(d.retryable).toBe(false) expect(d.retryAfterMs).toBeUndefined() }) @@ -58,8 +62,12 @@ describe('parseRetryAfterMs', () => { it('reads an HTTP-date relative to the injected now, clamped at 0', () => { const now = Date.parse('2026-06-11T00:00:00Z') - expect(parseRetryAfterMs(headers({ 'retry-after': 'Thu, 11 Jun 2026 00:00:05 GMT' }), now)).toBe(5000) - expect(parseRetryAfterMs(headers({ 'retry-after': 'Thu, 11 Jun 2026 00:00:00 GMT' }), now + 10_000)).toBe(0) + expect( + parseRetryAfterMs(headers({ 'retry-after': 'Thu, 11 Jun 2026 00:00:05 GMT' }), now), + ).toBe(5000) + expect( + parseRetryAfterMs(headers({ 'retry-after': 'Thu, 11 Jun 2026 00:00:00 GMT' }), now + 10_000), + ).toBe(0) }) it('returns undefined when absent or unparseable', () => { diff --git a/cli/src/http/rate-limit.ts b/cli/src/http/rate-limit.ts index 15115180e693bf..c0eb4a5a7f6ca9 100644 --- a/cli/src/http/rate-limit.ts +++ b/cli/src/http/rate-limit.ts @@ -26,8 +26,7 @@ function bodyCode(raw: string): string | undefined { const code = (parsed as Record<string, unknown>).code return typeof code === 'string' ? code : undefined } - } - catch { + } catch { // not JSON } return undefined @@ -39,8 +38,7 @@ export async function classifyRateLimit(response: Response): Promise<RateLimitDe let raw = '' try { raw = await response.clone().text() - } - catch { + } catch { // ignore read errors; raw stays '' } const retryable = bodyCode(raw) === 'too_many_requests' diff --git a/cli/src/http/retry.ts b/cli/src/http/retry.ts index 6e663ba74ef09f..c585c08770eda5 100644 --- a/cli/src/http/retry.ts +++ b/cli/src/http/retry.ts @@ -15,10 +15,8 @@ export function isIdempotentRetryMethod(method: string): boolean { } export function shouldRetry(target: Response | unknown, ctx: FetchContext): boolean { - if (!RETRY_METHODS_SET.has(ctx.options.method)) - return false - if (target instanceof Response) - return RETRY_STATUS_SET.has(target.status) + if (!RETRY_METHODS_SET.has(ctx.options.method)) return false + if (target instanceof Response) return RETRY_STATUS_SET.has(target.status) // Any other transport error on a retryable method retries. User aborts are filtered // out earlier in dispatch (before this hook ever runs), so they never reach here. return true @@ -30,8 +28,7 @@ const BACKOFF_BASE_MS = 300 const BACKOFF_CAP_MS = 30_000 export function backoffDelay(attempt: number): number { - if (attempt <= 0) - return 0 + if (attempt <= 0) return 0 const exp = BACKOFF_BASE_MS * 2 ** (attempt - 1) return Math.min(exp, BACKOFF_CAP_MS) } diff --git a/cli/src/http/sse-dify.test.ts b/cli/src/http/sse-dify.test.ts index 71e3b97345109c..a9b54b0d57e291 100644 --- a/cli/src/http/sse-dify.test.ts +++ b/cli/src/http/sse-dify.test.ts @@ -9,14 +9,12 @@ function bytes(s: string): Uint8Array { } async function* fromArray(events: SseEvent[]): AsyncGenerator<SseEvent, void, void> { - for (const ev of events) - yield ev + for (const ev of events) yield ev } async function collect(iter: AsyncIterable<SseEvent>): Promise<SseEvent[]> { const out: SseEvent[] = [] - for await (const ev of iter) - out.push(ev) + for await (const ev of iter) out.push(ev) return out } @@ -54,19 +52,27 @@ describe('eventNameFromDifyData', () => { describe('normalizeDifyStream', () => { it('promotes JSON event field into ev.name when transport name absent', async () => { - const out = await collect(normalizeDifyStream(fromArray([ - { name: '', data: bytes('{"event":"workflow_started","id":"wf-1"}') }, - { name: '', data: bytes('{"event":"workflow_finished","status":"succeeded"}') }, - ]))) - expect(out.map(e => e.name)).toEqual(['workflow_started', 'workflow_finished']) + const out = await collect( + normalizeDifyStream( + fromArray([ + { name: '', data: bytes('{"event":"workflow_started","id":"wf-1"}') }, + { name: '', data: bytes('{"event":"workflow_finished","status":"succeeded"}') }, + ]), + ), + ) + expect(out.map((e) => e.name)).toEqual(['workflow_started', 'workflow_finished']) }) it('preserves transport-level event name over JSON event field', async () => { - const out = await collect(normalizeDifyStream(fromArray([ - { name: 'ping', data: bytes('') }, - { name: 'foo', data: bytes('{"event":"bar"}') }, - ]))) - expect(out.map(e => e.name)).toEqual(['ping', 'foo']) + const out = await collect( + normalizeDifyStream( + fromArray([ + { name: 'ping', data: bytes('') }, + { name: 'foo', data: bytes('{"event":"bar"}') }, + ]), + ), + ) + expect(out.map((e) => e.name)).toEqual(['ping', 'foo']) }) it('forwards unchanged when ev.name absent and data has no JSON event field', async () => { @@ -78,17 +84,15 @@ describe('normalizeDifyStream', () => { }) it('forwards unchanged when data is malformed JSON', async () => { - const out = await collect(normalizeDifyStream(fromArray([ - { name: '', data: bytes('not-json') }, - ]))) + const out = await collect( + normalizeDifyStream(fromArray([{ name: '', data: bytes('not-json') }])), + ) expect(out).toHaveLength(1) expect(out[0]?.name).toBe('') }) it('forwards empty-data events with empty name', async () => { - const out = await collect(normalizeDifyStream(fromArray([ - { name: '', data: bytes('') }, - ]))) + const out = await collect(normalizeDifyStream(fromArray([{ name: '', data: bytes('') }]))) expect(out).toHaveLength(1) expect(out[0]?.name).toBe('') }) diff --git a/cli/src/http/sse-dify.ts b/cli/src/http/sse-dify.ts index 7a798858f0a087..470c1a2ea1c7b1 100644 --- a/cli/src/http/sse-dify.ts +++ b/cli/src/http/sse-dify.ts @@ -3,16 +3,13 @@ import type { SseEvent } from './sse' const dec = new TextDecoder() export function eventNameFromDifyData(data: Uint8Array): string { - if (data.byteLength === 0) - return '' + if (data.byteLength === 0) return '' try { const obj = JSON.parse(dec.decode(data)) as unknown - if (obj === null || typeof obj !== 'object') - return '' + if (obj === null || typeof obj !== 'object') return '' const evt = (obj as { event?: unknown }).event return typeof evt === 'string' ? evt : '' - } - catch { + } catch { return '' } } diff --git a/cli/src/http/sse.test.ts b/cli/src/http/sse.test.ts index ff023d731f5cb6..f6ecca09e7b582 100644 --- a/cli/src/http/sse.test.ts +++ b/cli/src/http/sse.test.ts @@ -11,8 +11,10 @@ function streamOf(...chunks: string[]): ReadableStream<Uint8Array> { }) } -async function collect(s: ReadableStream<Uint8Array>): Promise<{ name: string, data: string, id?: string }[]> { - const out: { name: string, data: string, id?: string }[] = [] +async function collect( + s: ReadableStream<Uint8Array>, +): Promise<{ name: string; data: string; id?: string }[]> { + const out: { name: string; data: string; id?: string }[] = [] const dec = new TextDecoder() for await (const ev of parseSSE(s)) out.push({ name: ev.name, data: dec.decode(ev.data), id: ev.id }) @@ -76,11 +78,9 @@ describe('parseSSE', () => { try { for await (const _ of parseSSE(slow, ctrl.signal)) { seen++ - if (seen === 1) - ctrl.abort() + if (seen === 1) ctrl.abort() } - } - catch (e) { + } catch (e) { caught = e } expect(seen).toBeGreaterThanOrEqual(1) diff --git a/cli/src/http/sse.ts b/cli/src/http/sse.ts index 5af7692e42a33d..29d441a177a516 100644 --- a/cli/src/http/sse.ts +++ b/cli/src/http/sse.ts @@ -43,10 +43,8 @@ export async function* parseSSE( reader.cancel().catch(() => {}) } if (signal !== undefined) { - if (signal.aborted) - onAbort() - else - signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() + else signal.addEventListener('abort', onAbort, { once: true }) } const pump = (async () => { @@ -58,18 +56,15 @@ export async function* parseSSE( throw e } const { value, done: rDone } = await reader.read() - if (rDone) - break + if (rDone) break parser.feed(dec.decode(value, { stream: true })) } - } - finally { + } finally { done = true wake() try { reader.releaseLock() - } - catch {} + } catch {} } })() @@ -77,8 +72,7 @@ export async function* parseSSE( while (true) { while (queue.length > 0) { const ev = queue.shift() - if (ev !== undefined) - yield ev + if (ev !== undefined) yield ev } if (done) { await pump @@ -93,15 +87,12 @@ export async function* parseSSE( }) pendingWake = false } - } - finally { - if (signal !== undefined) - signal.removeEventListener('abort', onAbort) + } finally { + if (signal !== undefined) signal.removeEventListener('abort', onAbort) if (!done) { try { await reader.cancel() - } - catch {} + } catch {} } } } diff --git a/cli/src/http/types.ts b/cli/src/http/types.ts index a022a2532964bc..d346e30f4cdce3 100644 --- a/cli/src/http/types.ts +++ b/cli/src/http/types.ts @@ -23,7 +23,14 @@ export type SearchParamValue = string | number | boolean | undefined // RequestInit is stricter and only accepts DataView, which `Uint8Array` covers for our byte-buffer // callers. export type HeadersInit = Headers | [string, string][] | Record<string, string> -export type BodyInit = string | Blob | ArrayBuffer | FormData | URLSearchParams | ReadableStream<Uint8Array> | Uint8Array +export type BodyInit = + | string + | Blob + | ArrayBuffer + | FormData + | URLSearchParams + | ReadableStream<Uint8Array> + | Uint8Array export type FetchContext = { request: Request diff --git a/cli/src/http/url.test.ts b/cli/src/http/url.test.ts index 1d340800c76295..6ee55b122e40b8 100644 --- a/cli/src/http/url.test.ts +++ b/cli/src/http/url.test.ts @@ -3,20 +3,30 @@ import { appendSearchParams, joinURL } from './url.js' describe('joinURL', () => { it('joins base and path with single slash', () => { - expect(joinURL('https://api.example.com/openapi/v1', 'workspaces')).toBe('https://api.example.com/openapi/v1/workspaces') + expect(joinURL('https://api.example.com/openapi/v1', 'workspaces')).toBe( + 'https://api.example.com/openapi/v1/workspaces', + ) }) it('collapses double slash when base has trailing and path has leading', () => { - expect(joinURL('https://api.example.com/openapi/v1/', '/workspaces')).toBe('https://api.example.com/openapi/v1/workspaces') + expect(joinURL('https://api.example.com/openapi/v1/', '/workspaces')).toBe( + 'https://api.example.com/openapi/v1/workspaces', + ) }) it('inserts slash when neither side has one', () => { - expect(joinURL('https://api.example.com', 'workspaces')).toBe('https://api.example.com/workspaces') + expect(joinURL('https://api.example.com', 'workspaces')).toBe( + 'https://api.example.com/workspaces', + ) }) it('preserves trailing-only or leading-only slash without adding another', () => { - expect(joinURL('https://api.example.com/', 'workspaces')).toBe('https://api.example.com/workspaces') - expect(joinURL('https://api.example.com', '/workspaces')).toBe('https://api.example.com/workspaces') + expect(joinURL('https://api.example.com/', 'workspaces')).toBe( + 'https://api.example.com/workspaces', + ) + expect(joinURL('https://api.example.com', '/workspaces')).toBe( + 'https://api.example.com/workspaces', + ) }) it('returns base when path is empty or root', () => { @@ -40,7 +50,12 @@ describe('appendSearchParams', () => { }) it('omits undefined values and coerces primitives', () => { - const url = appendSearchParams('https://x/y', { page: 2, active: true, name: 'foo', skip: undefined }) + const url = appendSearchParams('https://x/y', { + page: 2, + active: true, + name: 'foo', + skip: undefined, + }) expect(url).toBe('https://x/y?page=2&active=true&name=foo') }) @@ -53,6 +68,8 @@ describe('appendSearchParams', () => { // API-client callers collapse empties to `undefined` upstream — this is the // backstop that catches a future "let's just skip empties here too" change. it('keeps empty-string values on the wire (only undefined is skipped)', () => { - expect(appendSearchParams('https://x/y', { name: '', tag: undefined })).toBe('https://x/y?name=') + expect(appendSearchParams('https://x/y', { name: '', tag: undefined })).toBe( + 'https://x/y?name=', + ) }) }) diff --git a/cli/src/http/url.ts b/cli/src/http/url.ts index 7ac0c11a8fb4db..a5233dd131850f 100644 --- a/cli/src/http/url.ts +++ b/cli/src/http/url.ts @@ -2,18 +2,14 @@ import type { SearchParamValue } from './types.js' // Joins a base URL and a path, collapsing/inserting a single slash at the seam. export function joinURL(base: string, path: string): string { - if (base === '' || base === '/') - return path === '' ? '/' : path - if (path === '' || path === '/') - return base + if (base === '' || base === '/') return path === '' ? '/' : path + if (path === '' || path === '/') return base const baseHasTrailing = base.endsWith('/') const pathHasLeading = path.startsWith('/') - if (baseHasTrailing && pathHasLeading) - return base + path.slice(1) - if (!baseHasTrailing && !pathHasLeading) - return `${base}/${path}` + if (baseHasTrailing && pathHasLeading) return base + path.slice(1) + if (!baseHasTrailing && !pathHasLeading) return `${base}/${path}` return base + path } @@ -23,19 +19,19 @@ export function joinURL(base: string, path: string): string { // empty-string filters to `undefined` at their own layer; this helper does NOT // do that for them, on purpose, so a caller that wants to send an explicit // empty value still can. -export function appendSearchParams(url: string, params: Record<string, SearchParamValue> | undefined): string { - if (params === undefined) - return url +export function appendSearchParams( + url: string, + params: Record<string, SearchParamValue> | undefined, +): string { + if (params === undefined) return url const search = new URLSearchParams() for (const [key, value] of Object.entries(params)) { - if (value === undefined) - continue + if (value === undefined) continue search.append(key, String(value)) } const qs = search.toString() - if (qs === '') - return url + if (qs === '') return url return url.includes('?') ? `${url}&${qs}` : `${url}?${qs}` } diff --git a/cli/src/limit/limit.test.ts b/cli/src/limit/limit.test.ts index aee4f5d5d50395..3bad82d6ddf6be 100644 --- a/cli/src/limit/limit.test.ts +++ b/cli/src/limit/limit.test.ts @@ -18,8 +18,7 @@ describe('limit', () => { let err: unknown try { parseLimit(String(n), '--limit') - } - catch (e) { + } catch (e) { err = e } expect(isBaseError(err)).toBe(true) @@ -32,8 +31,7 @@ describe('limit', () => { let err: unknown try { parseLimit('abc', '--limit') - } - catch (e) { + } catch (e) { err = e } expect(isBaseError(err)).toBe(true) @@ -45,8 +43,7 @@ describe('limit', () => { let err: unknown try { parseLimit('', '--limit') - } - catch (e) { + } catch (e) { err = e } expect(isBaseError(err)).toBe(true) diff --git a/cli/src/limit/limit.ts b/cli/src/limit/limit.ts index 3c1de12f3232f9..c1f194b03c651a 100644 --- a/cli/src/limit/limit.ts +++ b/cli/src/limit/limit.ts @@ -9,10 +9,7 @@ const INTEGER_PATTERN = /^-?\d+$/ export function parseLimit(raw: string, source: string): number { if (!INTEGER_PATTERN.test(raw)) { - throw newError( - ErrorCode.UsageInvalidFlag, - `${source}: ${JSON.stringify(raw)} is not a number`, - ) + throw newError(ErrorCode.UsageInvalidFlag, `${source}: ${JSON.stringify(raw)} is not a number`) } const n = Number(raw) if (n < LIMIT_MIN || n > LIMIT_MAX) { diff --git a/cli/src/store/config-writer.test.ts b/cli/src/store/config-writer.test.ts index 2c7a8e2d9ef955..587b009a68eafd 100644 --- a/cli/src/store/config-writer.test.ts +++ b/cli/src/store/config-writer.test.ts @@ -12,8 +12,7 @@ describe('saveConfig', () => { await saveConfig(getConfigurationStore(), { ...emptyConfig() }) const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) - if (r.found) - expect(r.config.schema_version).toBe(1) + if (r.found) expect(r.config.schema_version).toBe(1) }) it('overrides a stale schema_version on save', async () => { @@ -23,8 +22,7 @@ describe('saveConfig', () => { }) const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) - if (r.found) - expect(r.config.schema_version).toBe(1) + if (r.found) expect(r.config.schema_version).toBe(1) }) it('round-trips defaults + state', async () => { diff --git a/cli/src/store/dir.ts b/cli/src/store/dir.ts index 98984740958efb..3917417e28b7e6 100644 --- a/cli/src/store/dir.ts +++ b/cli/src/store/dir.ts @@ -7,14 +7,12 @@ export const DIR_PERM = 0o700 export function resolveCacheDir(): string { const override = getEnv(ENV_CACHE_DIR) - if (override !== undefined && override !== '') - return override + if (override !== undefined && override !== '') return override return resolvePlatform().cacheDir() } export function resolveConfigDir(): string { const override = getEnv(ENV_CONFIG_DIR) - if (override !== undefined && override !== '') - return override + if (override !== undefined && override !== '') return override return resolvePlatform().configDir() } diff --git a/cli/src/store/errors.ts b/cli/src/store/errors.ts index ae00a02ab7c67f..4f038ed6797b66 100644 --- a/cli/src/store/errors.ts +++ b/cli/src/store/errors.ts @@ -43,12 +43,10 @@ export class BadYamlFormatError extends BaseError { } function excerpt(raw: string, mark: YamlMark | undefined): string { - if (mark === undefined) - return '' + if (mark === undefined) return '' const lines = raw.split('\n') const target = mark.line - if (target < 0 || target >= lines.length) - return '' + if (target < 0 || target >= lines.length) return '' const start = Math.max(0, target - 2) const end = Math.min(lines.length, target + 3) const width = String(end).length @@ -57,8 +55,7 @@ function excerpt(raw: string, mark: YamlMark | undefined): string { const marker = i === target ? '>' : ' ' const num = String(i + 1).padStart(width, ' ') out.push(`${marker} ${num} | ${lines[i]}`) - if (i === target) - out.push(`${' '.repeat(width + 4)}${' '.repeat(mark.column)}^`) + if (i === target) out.push(`${' '.repeat(width + 4)}${' '.repeat(mark.column)}^`) } return out.join('\n') } diff --git a/cli/src/store/keychain-token-store.test.ts b/cli/src/store/keychain-token-store.test.ts index 57f4b099dfb19a..dbae2a95b53dc4 100644 --- a/cli/src/store/keychain-token-store.test.ts +++ b/cli/src/store/keychain-token-store.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' -type EntryArgs = { service: string, username: string } +type EntryArgs = { service: string; username: string } const passwords = new Map<string, string>() const constructed: EntryArgs[] = [] @@ -17,20 +17,17 @@ class FakeEntry { } setPassword(value: string): void { - if (setPasswordError !== null) - throw setPasswordError + if (setPasswordError !== null) throw setPasswordError passwords.set(this.key, value) } getPassword(): string | null { - if (getPasswordError !== null) - throw getPasswordError + if (getPasswordError !== null) throw getPasswordError return passwords.get(this.key) ?? null } deletePassword(): boolean { - if (!passwords.has(this.key)) - return false + if (!passwords.has(this.key)) return false passwords.delete(this.key) return true } @@ -114,8 +111,7 @@ describe('KeychainTokenStore', () => { let caught: unknown try { await store.read('h', 'e') - } - catch (err) { + } catch (err) { caught = err } expect(caught).toBeInstanceOf(BaseError) @@ -128,8 +124,7 @@ describe('KeychainTokenStore', () => { let caught: unknown try { await store.write('h', 'e', 'dfoa_secret') - } - catch (err) { + } catch (err) { caught = err } expect(caught).toBeInstanceOf(BaseError) diff --git a/cli/src/store/keyring-based-store.test.ts b/cli/src/store/keyring-based-store.test.ts index 58502a3bb24223..3754637fbeaa3a 100644 --- a/cli/src/store/keyring-based-store.test.ts +++ b/cli/src/store/keyring-based-store.test.ts @@ -23,8 +23,7 @@ class FakeEntry { async deletePassword(): Promise<boolean> { deletePassword(this.key) - if (!passwords.has(this.key)) - return false + if (!passwords.has(this.key)) return false passwords.delete(this.key) return true } @@ -79,31 +78,25 @@ describe('KeyringBasedStore', () => { it('swallows getPassword exceptions and returns default', async () => { const s = new KeyringBasedStore(SERVICE) - getPassword.mockImplementationOnce( - () => { - throw new Error('NoEntry') - }, - ) + getPassword.mockImplementationOnce(() => { + throw new Error('NoEntry') + }) expect(await s.get({ key: 'k', default: 'd' })).toBe('d') }) it('swallows unset exceptions', async () => { const s = new KeyringBasedStore(SERVICE) - deletePassword.mockImplementationOnce( - () => { - throw new Error('NoEntry') - }, - ) + deletePassword.mockImplementationOnce(() => { + throw new Error('NoEntry') + }) await expect(s.unset({ key: 'k', default: '' })).resolves.not.toThrow() }) it('lets set propagate exceptions (caller decides fallback)', async () => { const s = new KeyringBasedStore(SERVICE) - setPassword.mockImplementationOnce( - () => { - throw new Error('keyring locked') - }, - ) + setPassword.mockImplementationOnce(() => { + throw new Error('keyring locked') + }) await expect(s.set({ key: 'k', default: '' }, 'v')).rejects.toThrow(/keyring locked/) }) }) diff --git a/cli/src/store/manager.test.ts b/cli/src/store/manager.test.ts index 9da86cda631604..a43e4def0decb1 100644 --- a/cli/src/store/manager.test.ts +++ b/cli/src/store/manager.test.ts @@ -58,7 +58,9 @@ describe('detectTokenStore', () => { const f = memStore('file') const result = await detectTokenStore({ factory: { - keyring: () => { throw new Error('no backend') }, + keyring: () => { + throw new Error('no backend') + }, file: () => f, }, }) diff --git a/cli/src/store/manager.ts b/cli/src/store/manager.ts index 832aba6196b142..0ae105d5a0fb8d 100644 --- a/cli/src/store/manager.ts +++ b/cli/src/store/manager.ts @@ -46,8 +46,9 @@ export type GetTokenStoreOptions = { } const TOKEN_STORE_OPENERS: Record<StorageMode, (opts: GetTokenStoreOptions) => TokenStore> = { - file: opts => opts.factory?.file?.() ?? new FileTokenStore(join(resolveConfigDir(), TOKENS_FILE)), - keychain: opts => opts.factory?.keyring?.() ?? new KeychainTokenStore(KEYRING_SERVICE), + file: (opts) => + opts.factory?.file?.() ?? new FileTokenStore(join(resolveConfigDir(), TOKENS_FILE)), + keychain: (opts) => opts.factory?.keyring?.() ?? new KeychainTokenStore(KEYRING_SERVICE), } /** @@ -55,7 +56,9 @@ const TOKEN_STORE_OPENERS: Record<StorageMode, (opts: GetTokenStoreOptions) => T * write/read/remove round-trip. The probe MUTATES the keyring, so call this * only where a credential is about to be written anyway (login). */ -export async function detectTokenStore(opts: GetTokenStoreOptions = {}): Promise<{ store: TokenStore, mode: StorageMode }> { +export async function detectTokenStore( + opts: GetTokenStoreOptions = {}, +): Promise<{ store: TokenStore; mode: StorageMode }> { // DIFY_E2E_NO_KEYRING=1 forces file-based storage in E2E tests to avoid // macOS keychain UI prompts blocking child processes spawned by vitest. if (process.env.DIFY_E2E_NO_KEYRING === '1') @@ -66,14 +69,13 @@ export async function detectTokenStore(opts: GetTokenStoreOptions = {}): Promise let got = '' try { got = await k.read(PROBE_HOST, PROBE_EMAIL) - } - finally { + } finally { await k.remove(PROBE_HOST, PROBE_EMAIL) } - if (got === PROBE_VALUE) - return { store: k, mode: 'keychain' } + if (got === PROBE_VALUE) return { store: k, mode: 'keychain' } + } catch { + /* keyring unavailable → fall through to file */ } - catch { /* keyring unavailable → fall through to file */ } return { store: TOKEN_STORE_OPENERS.file(opts), mode: 'file' } } diff --git a/cli/src/store/store.test.ts b/cli/src/store/store.test.ts index d0af00a7665f84..e0e6ebb82b97a1 100644 --- a/cli/src/store/store.test.ts +++ b/cli/src/store/store.test.ts @@ -46,39 +46,41 @@ describe('YamlStore.doGet', () => { const store = new YamlStore('/irrelevant') const timestamp = '2026-07-10T03:04:05.000Z' store.setRawContent(`created_at: ${timestamp}\n`) - expect(store.doGet<Date | null>({ key: 'created_at', default: null })).toEqual(new Date(timestamp)) + expect(store.doGet<Date | null>({ key: 'created_at', default: null })).toEqual( + new Date(timestamp), + ) }) it('rejects multiple YAML documents', () => { const store = new YamlStore('/irrelevant') store.setRawContent('name: alice\n---\nname: bob\n') - expect(() => store.doGet({ key: 'name', default: '' })) - .toThrowError(/single document/) + expect(() => store.doGet({ key: 'name', default: '' })).toThrowError(/single document/) }) it('rejects duplicate mapping keys', () => { const store = new YamlStore('/irrelevant') store.setRawContent('name: alice\nname: bob\n') - expect(() => store.doGet({ key: 'name', default: '' })) - .toThrowError(/duplicated mapping key/) + expect(() => store.doGet({ key: 'name', default: '' })).toThrowError(/duplicated mapping key/) }) it('rejects complex mapping keys', () => { const store = new YamlStore('/irrelevant') store.setRawContent('? [name, region]\n: deployment\n') - expect(() => store.doGet({ key: 'name', default: '' })) - .toThrowError(/does not support complex keys/) + expect(() => store.doGet({ key: 'name', default: '' })).toThrowError( + /does not support complex keys/, + ) }) it('parses explicit YAML sets as JavaScript sets', () => { const store = new YamlStore('/irrelevant') store.setRawContent('features: !!set\n workflow:\n chat:\n') - expect(store.doGet<Set<string>>({ key: 'features', default: new Set() })) - .toEqual(new Set(['workflow', 'chat'])) + expect(store.doGet<Set<string>>({ key: 'features', default: new Set() })).toEqual( + new Set(['workflow', 'chat']), + ) }) it('returns default for a missing flat key', () => { @@ -106,8 +108,7 @@ describe('YamlStore.doGet', () => { let caught: unknown try { store.doGet({ key: 'name', default: '' }) - } - catch (err) { + } catch (err) { caught = err } expect(caught).toBeInstanceOf(BadYamlFormatError) @@ -183,7 +184,9 @@ describe('FileBasedStore.withLock concurrency', () => { await s1.lock() - await expect(s2.set({ key: 'key', default: '' }, 'blocked')).rejects.toThrow(ConcurrentAccessError) + await expect(s2.set({ key: 'key', default: '' }, 'blocked')).rejects.toThrow( + ConcurrentAccessError, + ) await s1.unlock() diff --git a/cli/src/store/store.ts b/cli/src/store/store.ts index 35b85888b0ae38..f5051a2398c308 100644 --- a/cli/src/store/store.ts +++ b/cli/src/store/store.ts @@ -22,17 +22,24 @@ import { BadYamlFormatError, ConcurrentAccessError } from './errors' const FILE_PERM = 0o600 const DIR_PERM = 0o700 const LOCK_STALE_MS = 30_000 -const YAML_LOAD_SCHEMA = CORE_SCHEMA.withTags(binaryTag, mergeTag, omapTag, pairsTag, setTag, timestampTag) +const YAML_LOAD_SCHEMA = CORE_SCHEMA.withTags( + binaryTag, + mergeTag, + omapTag, + pairsTag, + setTag, + timestampTag, +) function lockAsync(path: string, opts: LockOptions): Promise<void> { return new Promise((resolve, reject) => { - lockfile.lock(path, opts, err => (err ? reject(err) : resolve())) + lockfile.lock(path, opts, (err) => (err ? reject(err) : resolve())) }) } function unlockAsync(path: string): Promise<void> { return new Promise((resolve, reject) => { - lockfile.unlock(path, err => (err ? reject(err) : resolve())) + lockfile.unlock(path, (err) => (err ? reject(err) : resolve())) }) } @@ -48,7 +55,7 @@ export type Store = { } export const STORAGE_MODES = ['keychain', 'file'] as const -export type StorageMode = typeof STORAGE_MODES[number] +export type StorageMode = (typeof STORAGE_MODES)[number] abstract class FileBasedStore implements Store { filePath: string @@ -85,12 +92,12 @@ abstract class FileBasedStore implements Store { try { await fsp.writeFile(tmp, this.rawContent, { mode: FILE_PERM }) this.platform.atomicReplace(tmp, this.filePath) - } - catch (err) { + } catch (err) { try { await fsp.unlink(tmp) + } catch { + /* tmp may not exist */ } - catch { /* tmp may not exist */ } throw err } } @@ -104,8 +111,7 @@ abstract class FileBasedStore implements Store { await lockAsync(`${this.filePath}.lock`, { stale: LOCK_STALE_MS, }) - } - catch (err) { + } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code === 'EEXIST') { throw new ConcurrentAccessError(this.filePath) @@ -118,8 +124,7 @@ abstract class FileBasedStore implements Store { try { this.rawContent = await fsp.readFile(this.filePath, 'utf8') this.dirty = false - } - catch (err) { + } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code !== 'ENOENT') { throw err @@ -128,7 +133,7 @@ abstract class FileBasedStore implements Store { } public setRawContent(content: string): void { - this.dirty = (content !== this.getRawContent()) + this.dirty = content !== this.getRawContent() this.rawContent = content } @@ -140,8 +145,7 @@ abstract class FileBasedStore implements Store { await this.lock() try { return await body() - } - finally { + } finally { await this.unlock() } } @@ -175,10 +179,8 @@ abstract class FileBasedStore implements Store { async rm(): Promise<void> { try { await fsp.unlink(this.filePath) - } - catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') - throw err + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err } } @@ -223,11 +225,14 @@ export class YamlStore extends FileBasedStore { const data = loadYaml(this.getRawContent(), this.filePath) || {} const parts = key.key.split('.') const lastKey = parts.pop() - if (lastKey === undefined) - return + if (lastKey === undefined) return let current: Record<string, unknown> = data for (const part of parts) { - if (current[part] === null || current[part] === undefined || typeof current[part] !== 'object') + if ( + current[part] === null || + current[part] === undefined || + typeof current[part] !== 'object' + ) current[part] = {} current = current[part] as Record<string, unknown> } @@ -239,34 +244,28 @@ export class YamlStore extends FileBasedStore { const data = loadYaml(this.getRawContent(), this.filePath) || {} const parts = key.key.split('.') const lastKey = parts.pop() - if (lastKey === undefined) - return + if (lastKey === undefined) return let current: Record<string, unknown> = data for (const part of parts) { const next = current[part] - if (next === null || next === undefined || typeof next !== 'object') - return + if (next === null || next === undefined || typeof next !== 'object') return current = next as Record<string, unknown> } - if (!(lastKey in current)) - return + if (!(lastKey in current)) return delete current[lastKey] this.setRawContent(dump(data, { lineWidth: -1, noRefs: true })) } } function loadYaml(raw: string | undefined, file_path: string): Record<string, unknown> | null { - if (raw === undefined) - return null + if (raw === undefined) return null try { const documents = loadAll(raw, { schema: YAML_LOAD_SCHEMA }) if (documents.length > 1) throw new YAMLException('expected a single document in the stream, but found more') return (documents[0] ?? {}) as Record<string, unknown> - } - catch (err) { - if (err instanceof YAMLException) - throw new BadYamlFormatError(file_path, raw, err) + } catch (err) { + if (err instanceof YAMLException) throw new BadYamlFormatError(file_path, raw, err) throw err } } @@ -286,11 +285,9 @@ export class KeyringBasedStore implements Store { async get<T>(key: Key<T>): Promise<T> { try { const v = await new AsyncEntry(this.service, key.key).getPassword() - if (v === null || v === undefined || v === '') - return key.default + if (v === null || v === undefined || v === '') return key.default return JSON.parse(v) as T - } - catch { + } catch { return key.default } } @@ -302,7 +299,8 @@ export class KeyringBasedStore implements Store { async unset<T>(key: Key<T>): Promise<void> { try { await new AsyncEntry(this.service, key.key).deletePassword() + } catch { + /* missing entry is fine */ } - catch { /* missing entry is fine */ } } } diff --git a/cli/src/store/token-store.ts b/cli/src/store/token-store.ts index e38f2dd803f708..1dc4d643b89f2b 100644 --- a/cli/src/store/token-store.ts +++ b/cli/src/store/token-store.ts @@ -28,11 +28,9 @@ export class FileTokenStore implements TokenStore { async read(host: string, email: string): Promise<string> { const doc = await this.store.getTyped<TokenDoc>() - if (doc === null) - return '' + if (doc === null) return '' // missing version = legacy pre-v1 format (same data shape); future unknown versions are rejected - if (doc.version !== undefined && doc.version !== DOC_VERSION) - return '' + if (doc.version !== undefined && doc.version !== DOC_VERSION) return '' return doc.tokens?.[host]?.[email] ?? '' } @@ -46,27 +44,28 @@ export class FileTokenStore implements TokenStore { async remove(host: string, email: string): Promise<void> { const doc = await this.store.getTyped<TokenDoc>() - if (doc === null) - return - if (doc.version !== undefined && doc.version !== DOC_VERSION) - return + if (doc === null) return + if (doc.version !== undefined && doc.version !== DOC_VERSION) return const tokens = doc.tokens ?? {} const hostMap = tokens[host] - if (hostMap === undefined || !(email in hostMap)) - return + if (hostMap === undefined || !(email in hostMap)) return delete hostMap[email] - if (Object.keys(hostMap).length === 0) - delete tokens[host] + if (Object.keys(hostMap).length === 0) delete tokens[host] await this.store.setTyped({ version: DOC_VERSION, tokens }) } - private async load(): Promise<{ version: number, tokens: Record<string, Record<string, string>> }> { + private async load(): Promise<{ + version: number + tokens: Record<string, Record<string, string>> + }> { const doc = await this.store.getTyped<TokenDoc>() - if (doc === null) - return { version: DOC_VERSION, tokens: {} } + if (doc === null) return { version: DOC_VERSION, tokens: {} } if (doc.version !== undefined && doc.version !== DOC_VERSION) return { version: DOC_VERSION, tokens: {} } - return { version: DOC_VERSION, tokens: (doc.tokens ?? {}) as Record<string, Record<string, string>> } + return { + version: DOC_VERSION, + tokens: (doc.tokens ?? {}) as Record<string, Record<string, string>>, + } } } @@ -84,17 +83,14 @@ export class KeychainTokenStore implements TokenStore { let raw: string | null | undefined try { raw = await new AsyncEntry(this.service, entryName(host, email)).getPassword() - } - catch (err) { + } catch (err) { throw keyringUnavailableError(err) } - if (raw === null || raw === undefined || raw === '') - return '' + if (raw === null || raw === undefined || raw === '') return '' try { const parsed: unknown = JSON.parse(raw) return typeof parsed === 'string' ? parsed : '' - } - catch { + } catch { return '' } } @@ -102,8 +98,7 @@ export class KeychainTokenStore implements TokenStore { async write(host: string, email: string, bearer: string): Promise<void> { try { await new AsyncEntry(this.service, entryName(host, email)).setPassword(JSON.stringify(bearer)) - } - catch (err) { + } catch (err) { throw keyringUnavailableError(err) } } @@ -111,8 +106,9 @@ export class KeychainTokenStore implements TokenStore { async remove(host: string, email: string): Promise<void> { try { await new AsyncEntry(this.service, entryName(host, email)).deletePassword() + } catch { + /* missing entry is fine */ } - catch { /* missing entry is fine */ } } } diff --git a/cli/src/sys/index.test.ts b/cli/src/sys/index.test.ts index 3c422a2c858833..51c357f8299168 100644 --- a/cli/src/sys/index.test.ts +++ b/cli/src/sys/index.test.ts @@ -12,8 +12,7 @@ describe('resolvePlatform', () => { const p = resolvePlatform() if (p.id() === 'win32') { expect(p.configDir()).toMatch(/difyctl$/) - } - else { + } else { expect(p.configDir()).toBe(join(homedir(), '.config', SUBDIR)) } }) @@ -22,11 +21,9 @@ describe('resolvePlatform', () => { const p = resolvePlatform() if (p.id() === 'win32') { expect(p.cacheDir()).toMatch(/difyctl$/) - } - else if (p.id() === 'darwin') { + } else if (p.id() === 'darwin') { expect(p.cacheDir()).toBe(join(homedir(), 'Library', 'Caches', SUBDIR)) - } - else { + } else { expect(p.cacheDir()).toBe(join(homedir(), '.cache', SUBDIR)) } }) diff --git a/cli/src/sys/index.ts b/cli/src/sys/index.ts index 3faeaf15d930f0..27e3b48dcfaadf 100644 --- a/cli/src/sys/index.ts +++ b/cli/src/sys/index.ts @@ -68,8 +68,7 @@ function posixAtomicReplace(src: string, dst: string): void { function win32AtomicReplace(src: string, dst: string): void { try { fs.unlinkSync(dst) - } - catch { } + } catch {} fs.renameSync(src, dst) } @@ -78,11 +77,13 @@ const platformImpls: Partial<Record<NodeJS.Platform, PlatformFactory>> = { id: () => 'linux', configDir: () => { const xdg = getEnv(ENV_XDG_CONFIG_HOME) - return (xdg !== undefined && xdg !== '') ? join(xdg, SUBDIR) : join(homedir(), '.config', SUBDIR) + return xdg !== undefined && xdg !== '' + ? join(xdg, SUBDIR) + : join(homedir(), '.config', SUBDIR) }, cacheDir: () => { const xdg = getEnv(ENV_XDG_CACHE_HOME) - return (xdg !== undefined && xdg !== '') ? join(xdg, SUBDIR) : join(homedir(), '.cache', SUBDIR) + return xdg !== undefined && xdg !== '' ? join(xdg, SUBDIR) : join(homedir(), '.cache', SUBDIR) }, atomicReplace: posixAtomicReplace, }), diff --git a/cli/src/sys/io/color.ts b/cli/src/sys/io/color.ts index 4cafe9bb9d3931..6cfda7a7e774f6 100644 --- a/cli/src/sys/io/color.ts +++ b/cli/src/sys/io/color.ts @@ -29,12 +29,12 @@ export function colorScheme(enabled: boolean): ColorScheme { } } return { - bold: s => pc.bold(s), - dim: s => pc.dim(s), - cyan: s => pc.cyan(s), - green: s => pc.green(s), - yellow: s => pc.yellow(s), - magenta: s => pc.magenta(s), + bold: (s) => pc.bold(s), + dim: (s) => pc.dim(s), + cyan: (s) => pc.cyan(s), + green: (s) => pc.green(s), + yellow: (s) => pc.yellow(s), + magenta: (s) => pc.magenta(s), successIcon: () => pc.green('✓'), warningIcon: () => pc.yellow('!'), failureIcon: () => pc.red('✗'), @@ -42,8 +42,7 @@ export function colorScheme(enabled: boolean): ColorScheme { } export function colorEnabled(isTTY: boolean): boolean { - if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') - return false + if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false if (process.env.DIFYCTL_NO_COLOR !== undefined && process.env.DIFYCTL_NO_COLOR !== '') return false return isTTY diff --git a/cli/src/sys/io/prompt.test.ts b/cli/src/sys/io/prompt.test.ts index 031a6d4cec9b27..37470f05d842fe 100644 --- a/cli/src/sys/io/prompt.test.ts +++ b/cli/src/sys/io/prompt.test.ts @@ -56,9 +56,9 @@ describe('promptText (non-TTY)', () => { it('throws on invalid input (no retry in non-TTY)', async () => { const io = bufferStreams('bad\n') - await expect( - promptText({ io, label: 'Enter name', parse: parseString }), - ).rejects.toThrow(/expected "hello"/) + await expect(promptText({ io, label: 'Enter name', parse: parseString })).rejects.toThrow( + /expected "hello"/, + ) }) it('throws UsageMissingArg on EOF before reading input', async () => { @@ -66,9 +66,9 @@ describe('promptText (non-TTY)', () => { const ended = new PassThrough() ended.end() ;(io as { in: NodeJS.ReadableStream }).in = ended - await expect( - promptText({ io, label: 'Enter name', parse: parseString }), - ).rejects.toThrow(/input closed/) + await expect(promptText({ io, label: 'Enter name', parse: parseString })).rejects.toThrow( + /input closed/, + ) }) it('acceptAsDefault: treats matching input as default', async () => { @@ -77,8 +77,8 @@ describe('promptText (non-TTY)', () => { io, label: 'Enter URL', default: 'https://example.com', - acceptAsDefault: raw => /^y(?:es)?$/i.test(raw), - parse: raw => ({ ok: true, value: raw }), + acceptAsDefault: (raw) => /^y(?:es)?$/i.test(raw), + parse: (raw) => ({ ok: true, value: raw }), }) expect(result).toBe('https://example.com') }) @@ -118,8 +118,8 @@ describe('promptText (TTY)', () => { io, label: 'Enter URL', default: 'https://example.com', - acceptAsDefault: raw => /^y(?:es)?$/i.test(raw), - parse: raw => ({ ok: true, value: raw }), + acceptAsDefault: (raw) => /^y(?:es)?$/i.test(raw), + parse: (raw) => ({ ok: true, value: raw }), }) expect(result).toBe('https://example.com') }) @@ -131,8 +131,8 @@ describe('promptText (TTY)', () => { io, label: 'Enter URL', default: 'https://example.com', - acceptAsDefault: raw => /^y(?:es)?$/i.test(raw), - parse: raw => ({ ok: true, value: raw }), + acceptAsDefault: (raw) => /^y(?:es)?$/i.test(raw), + parse: (raw) => ({ ok: true, value: raw }), }) expect(result).toBe('https://example.com') } @@ -140,8 +140,8 @@ describe('promptText (TTY)', () => { it('throws UsageMissingArg on EOF before valid input is given', async () => { const io = eofStreams() - await expect( - promptText({ io, label: 'Enter name', parse: parseString }), - ).rejects.toThrow(/input closed/) + await expect(promptText({ io, label: 'Enter name', parse: parseString })).rejects.toThrow( + /input closed/, + ) }) }) diff --git a/cli/src/sys/io/prompt.ts b/cli/src/sys/io/prompt.ts index 15b3d95eb5ee0c..28cb19bce6622c 100644 --- a/cli/src/sys/io/prompt.ts +++ b/cli/src/sys/io/prompt.ts @@ -4,9 +4,7 @@ import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { colorEnabled, colorScheme } from './color' -export type ParseResult<T> - = | { ok: true, value: T } - | { ok: false, error: string } +export type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string } export type PromptTextOptions<T> = { readonly io: IOStreams @@ -17,19 +15,21 @@ export type PromptTextOptions<T> = { readonly parse: (raw: string) => ParseResult<T> } -function buildPromptLine(opts: Pick<PromptTextOptions<unknown>, 'label' | 'hint' | 'default'>): string { +function buildPromptLine( + opts: Pick<PromptTextOptions<unknown>, 'label' | 'hint' | 'default'>, +): string { let line = opts.label - if (opts.hint !== undefined) - line += ` (${opts.hint})` - if (opts.default !== undefined) - line += ` [default: ${opts.default}]` + if (opts.hint !== undefined) line += ` (${opts.hint})` + if (opts.default !== undefined) line += ` [default: ${opts.default}]` return `${line}: ` } -function normalize(raw: string, opts: Pick<PromptTextOptions<unknown>, 'default' | 'acceptAsDefault'>): string { +function normalize( + raw: string, + opts: Pick<PromptTextOptions<unknown>, 'default' | 'acceptAsDefault'>, +): string { const trimmed = raw.trim() - if (trimmed === '' || opts.acceptAsDefault?.(trimmed)) - return opts.default ?? '' + if (trimmed === '' || opts.acceptAsDefault?.(trimmed)) return opts.default ?? '' return trimmed } @@ -37,10 +37,9 @@ export async function promptConfirm(io: IOStreams, message: string): Promise<boo io.err.write(message) const rl = readline.createInterface({ input: io.in, output: io.err, terminal: false }) try { - const line = await new Promise<string>(resolve => rl.once('line', resolve)) + const line = await new Promise<string>((resolve) => rl.once('line', resolve)) return line.trim().toLowerCase() === 'y' - } - finally { + } finally { rl.close() } } @@ -52,8 +51,7 @@ export async function promptText<T>(opts: PromptTextOptions<T>): Promise<T> { return new Promise<T>((resolve, reject) => { let settled = false const settle = (fn: () => void): void => { - if (settled) - return + if (settled) return settled = true fn() } @@ -62,10 +60,12 @@ export async function promptText<T>(opts: PromptTextOptions<T>): Promise<T> { rl.on('close', () => { settle(() => - reject(new BaseError({ - code: ErrorCode.UsageMissingArg, - message: 'input closed before a valid value was provided', - })), + reject( + new BaseError({ + code: ErrorCode.UsageMissingArg, + message: 'input closed before a valid value was provided', + }), + ), ) }) @@ -77,10 +77,8 @@ export async function promptText<T>(opts: PromptTextOptions<T>): Promise<T> { rl.off('line', onLine) settle(() => { rl.close() - if (result.ok) - resolve(result.value) - else - reject(new BaseError({ code: ErrorCode.UsageInvalidFlag, message: result.error })) + if (result.ok) resolve(result.value) + else reject(new BaseError({ code: ErrorCode.UsageInvalidFlag, message: result.error })) }) return } @@ -92,8 +90,7 @@ export async function promptText<T>(opts: PromptTextOptions<T>): Promise<T> { rl.close() resolve(result.value) }) - } - else { + } else { opts.io.err.write(` ${cs.failureIcon()} ${result.error}\n`) opts.io.err.write(prompt) } diff --git a/cli/src/sys/io/reasoning.test.ts b/cli/src/sys/io/reasoning.test.ts index 1f6887a6108e5b..250e2e60d6c7e0 100644 --- a/cli/src/sys/io/reasoning.test.ts +++ b/cli/src/sys/io/reasoning.test.ts @@ -9,17 +9,18 @@ import { ReasoningChunkRenderer, } from './reasoning' -function capture(): { err: PassThrough, errBuf: () => string } { +function capture(): { err: PassThrough; errBuf: () => string } { const err = new PassThrough() const ec: Buffer[] = [] - err.on('data', d => ec.push(d as Buffer)) + err.on('data', (d) => ec.push(d as Buffer)) return { err, errBuf: () => Buffer.concat(ec).toString('utf-8') } } describe('parseReasoningChunk', () => { it('reads the payload nested under data', () => { - expect(parseReasoningChunk({ data: { reasoning: 'hi', node_id: 'llm-1', is_final: true } })) - .toEqual({ reasoning: 'hi', nodeId: 'llm-1', isFinal: true }) + expect( + parseReasoningChunk({ data: { reasoning: 'hi', node_id: 'llm-1', is_final: true } }), + ).toEqual({ reasoning: 'hi', nodeId: 'llm-1', isFinal: true }) }) it('defaults missing/wrong-typed fields', () => { @@ -104,8 +105,9 @@ describe('accumulateReasoning', () => { describe('formatReasoningBlocks', () => { it('frames and trims each node, joined by a separator', () => { - expect(formatReasoningBlocks({ n1: ' one ', n2: 'two' })) - .toBe('<think>\none\n</think>\n---\n<think>\ntwo\n</think>') + expect(formatReasoningBlocks({ n1: ' one ', n2: 'two' })).toBe( + '<think>\none\n</think>\n---\n<think>\ntwo\n</think>', + ) }) it('skips empty entries and returns empty for no reasoning', () => { @@ -116,8 +118,7 @@ describe('formatReasoningBlocks', () => { describe('reasoningBlocksFromMetadata', () => { it('extracts reasoning from a metadata object', () => { - expect(reasoningBlocksFromMetadata({ reasoning: { n1: 'why' } })) - .toBe('<think>\nwhy\n</think>') + expect(reasoningBlocksFromMetadata({ reasoning: { n1: 'why' } })).toBe('<think>\nwhy\n</think>') }) it('returns empty for tagged mode (empty reasoning) and malformed input', () => { diff --git a/cli/src/sys/io/reasoning.ts b/cli/src/sys/io/reasoning.ts index 2a88e3ae32b9d9..87c385b0e61fc4 100644 --- a/cli/src/sys/io/reasoning.ts +++ b/cli/src/sys/io/reasoning.ts @@ -13,8 +13,7 @@ export type ReasoningChunk = { // reasoning_chunk nests its payload under `data` (not top-level like `message`). export function parseReasoningChunk(parsed: Record<string, unknown>): ReasoningChunk | undefined { const data = parsed.data - if (data === null || typeof data !== 'object' || Array.isArray(data)) - return undefined + if (data === null || typeof data !== 'object' || Array.isArray(data)) return undefined const rec = data as Record<string, unknown> return { reasoning: typeof rec.reasoning === 'string' ? rec.reasoning : '', @@ -31,8 +30,7 @@ export function reasoningKey(chunk: ReasoningChunk): string { // Appends a reasoning delta to a per-node accumulator. export function accumulateReasoning(acc: Record<string, string>, chunk: ReasoningChunk): void { - if (chunk.reasoning === '') - return + if (chunk.reasoning === '') return const key = reasoningKey(chunk) acc[key] = (acc[key] ?? '') + chunk.reasoning } @@ -55,8 +53,7 @@ export class ReasoningChunkRenderer { } errOut.write(chunk.reasoning) } - if (chunk.isFinal && this.openNode === key) - this.closeActive(errOut) + if (chunk.isFinal && this.openNode === key) this.closeActive(errOut) } // Close a block left open by a truncated stream. @@ -65,8 +62,7 @@ export class ReasoningChunkRenderer { } private closeActive(errOut: NodeJS.WritableStream): void { - if (this.openNode === undefined) - return + if (this.openNode === undefined) return errOut.write(`${THINK_CLOSE}\n`) this.openNode = undefined } @@ -77,23 +73,19 @@ export function formatReasoningBlocks(reasoning: Record<string, string>): string const blocks: string[] = [] for (const text of Object.values(reasoning)) { const trimmed = text.trim() - if (trimmed !== '') - blocks.push(`${THINK_OPEN}\n${trimmed}\n${THINK_CLOSE}`) + if (trimmed !== '') blocks.push(`${THINK_OPEN}\n${trimmed}\n${THINK_CLOSE}`) } return blocks.join('\n---\n') } // Frames per-node reasoning from a message_end `metadata` object; '' when absent. export function reasoningBlocksFromMetadata(metadata: unknown): string { - if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) - return '' + if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) return '' const reasoning = (metadata as Record<string, unknown>).reasoning - if (reasoning === null || typeof reasoning !== 'object' || Array.isArray(reasoning)) - return '' + if (reasoning === null || typeof reasoning !== 'object' || Array.isArray(reasoning)) return '' const map: Record<string, string> = {} for (const [key, value] of Object.entries(reasoning as Record<string, unknown>)) { - if (typeof value === 'string') - map[key] = value + if (typeof value === 'string') map[key] = value } return formatReasoningBlocks(map) } diff --git a/cli/src/sys/io/select.test.ts b/cli/src/sys/io/select.test.ts index 7b858e7070d3c3..32d1aa39c0bef4 100644 --- a/cli/src/sys/io/select.test.ts +++ b/cli/src/sys/io/select.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { selectFromList } from './select' import { bufferStreams } from './streams' -type Row = { id: string, label: string } +type Row = { id: string; label: string } const rows: Row[] = [ { id: '1', label: 'alpha' }, { id: '2', label: 'beta' }, @@ -12,15 +12,18 @@ const rows: Row[] = [ const SHOW_CURSOR = '\x1B[?25h' -type FakeTTYIn = PassThrough & { isTTY: boolean, isRaw: boolean, setRawMode: (mode: boolean) => unknown } +type FakeTTYIn = PassThrough & { + isTTY: boolean + isRaw: boolean + setRawMode: (mode: boolean) => unknown +} function ttyInput(opts: { failRawMode?: boolean } = {}): FakeTTYIn { const stream = new PassThrough() as unknown as FakeTTYIn stream.isTTY = true stream.isRaw = false stream.setRawMode = (mode: boolean): unknown => { - if (opts.failRawMode === true && mode) - throw new Error('raw mode unavailable') + if (opts.failRawMode === true && mode) throw new Error('raw mode unavailable') stream.isRaw = mode return stream } @@ -38,7 +41,12 @@ describe('selectFromList (non-TTY numbered fallback)', () => { it('returns the item matching the typed number', async () => { const io = bufferStreams('2\n') ;(io as { isErrTTY: boolean }).isErrTTY = false - const picked = await selectFromList({ io, items: rows, header: 'Pick one', render: r => r.label }) + const picked = await selectFromList({ + io, + items: rows, + header: 'Pick one', + render: (r) => r.label, + }) expect(picked.id).toBe('2') expect(io.errBuf()).toContain('1) alpha') expect(io.errBuf()).toContain('Pick one') @@ -47,17 +55,17 @@ describe('selectFromList (non-TTY numbered fallback)', () => { it('rejects an out-of-range selection', async () => { const io = bufferStreams('9\n') ;(io as { isErrTTY: boolean }).isErrTTY = false - await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })) - .rejects - .toThrow(/invalid selection/i) + await expect( + selectFromList({ io, items: rows, header: 'Pick', render: (r) => r.label }), + ).rejects.toThrow(/invalid selection/i) }) it('throws when the list is empty', async () => { const io = bufferStreams('1\n') ;(io as { isErrTTY: boolean }).isErrTTY = false - await expect(selectFromList({ io, items: [] as Row[], header: 'Pick', render: r => (r as Row).label })) - .rejects - .toThrow(/nothing to select/i) + await expect( + selectFromList({ io, items: [] as Row[], header: 'Pick', render: (r) => (r as Row).label }), + ).rejects.toThrow(/nothing to select/i) }) }) @@ -65,7 +73,7 @@ describe('selectFromList (interactive TTY picker)', () => { it('moves with arrow keys and resolves on enter, restoring raw mode', async () => { const input = ttyInput() const io = ttyStreams(input) - const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }) + const pick = selectFromList({ io, items: rows, header: 'Pick', render: (r) => r.label }) input.write('\x1B[B') input.write('\r') const picked = await pick @@ -77,7 +85,7 @@ describe('selectFromList (interactive TTY picker)', () => { it('cancels on escape', async () => { const input = ttyInput() const io = ttyStreams(input) - const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }) + const pick = selectFromList({ io, items: rows, header: 'Pick', render: (r) => r.label }) input.write('\x1B') await expect(pick).rejects.toThrow(/cancelled/i) expect(input.isRaw).toBe(false) @@ -86,9 +94,9 @@ describe('selectFromList (interactive TTY picker)', () => { it('rejects and restores the terminal when raw-mode setup fails', async () => { const input = ttyInput({ failRawMode: true }) const io = ttyStreams(input) - await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })) - .rejects - .toThrow(/raw mode unavailable/i) + await expect( + selectFromList({ io, items: rows, header: 'Pick', render: (r) => r.label }), + ).rejects.toThrow(/raw mode unavailable/i) expect(input.isRaw).toBe(false) expect(io.errBuf()).toContain(SHOW_CURSOR) }) diff --git a/cli/src/sys/io/select.ts b/cli/src/sys/io/select.ts index 549b0bd340410f..4c9797ab9910c5 100644 --- a/cli/src/sys/io/select.ts +++ b/cli/src/sys/io/select.ts @@ -51,14 +51,12 @@ async function pickInteractive<T>(opts: SelectOptions<T>): Promise<T> { lines.push(`${pointer} ${label}`) }) const desc = opts.describe?.(opts.items[active] as T) - if (desc !== undefined && desc !== '') - lines.push(cs.dim(` ${desc}`)) + if (desc !== undefined && desc !== '') lines.push(cs.dim(` ${desc}`)) return lines } const render = (): void => { - if (rendered > 0) - out.write(cursorUp(rendered)) + if (rendered > 0) out.write(cursorUp(rendered)) const lines = frame() out.write(`${CLEAR_DOWN}${lines.join('\n')}\n`) rendered = lines.length @@ -67,11 +65,9 @@ async function pickInteractive<T>(opts: SelectOptions<T>): Promise<T> { const wasRaw = input.isTTY ? input.isRaw : false const cleanup = (): void => { input.off('keypress', onKey) - if (input.isTTY) - input.setRawMode(wasRaw) + if (input.isTTY) input.setRawMode(wasRaw) input.pause() - if (rendered > 0) - out.write(`${cursorUp(rendered)}${CLEAR_DOWN}`) + if (rendered > 0) out.write(`${cursorUp(rendered)}${CLEAR_DOWN}`) out.write(SHOW_CURSOR) } @@ -97,9 +93,10 @@ async function pickInteractive<T>(opts: SelectOptions<T>): Promise<T> { const chosen = opts.items[active] cleanup() if (chosen === undefined) - reject(new BaseError({ code: ErrorCode.UsageInvalidFlag, message: 'invalid selection' })) - else - resolve(chosen) + reject( + new BaseError({ code: ErrorCode.UsageInvalidFlag, message: 'invalid selection' }), + ) + else resolve(chosen) break } case 'escape': @@ -113,14 +110,12 @@ async function pickInteractive<T>(opts: SelectOptions<T>): Promise<T> { try { readline.emitKeypressEvents(input) - if (input.isTTY) - input.setRawMode(true) + if (input.isTTY) input.setRawMode(true) out.write(HIDE_CURSOR) input.on('keypress', onKey) input.resume() render() - } - catch (err) { + } catch (err) { cleanup() reject(err) } @@ -140,14 +135,16 @@ async function pickNumbered<T>(opts: SelectOptions<T>): Promise<T> { const rl = readline.createInterface({ input: opts.io.in, output: opts.io.err, terminal: false }) try { - const line: string = await new Promise(resolve => rl.once('line', resolve)) + const line: string = await new Promise((resolve) => rl.once('line', resolve)) const n = Number(line.trim()) const chosen = Number.isInteger(n) ? opts.items[n - 1] : undefined if (chosen === undefined) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `invalid selection: ${line.trim()}` }) + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `invalid selection: ${line.trim()}`, + }) return chosen - } - finally { + } finally { rl.close() } } diff --git a/cli/src/sys/io/spinner.ts b/cli/src/sys/io/spinner.ts index f0fa89b1db626f..89df41eb581189 100644 --- a/cli/src/sys/io/spinner.ts +++ b/cli/src/sys/io/spinner.ts @@ -10,8 +10,7 @@ const ANSI_RESET = '\x1B[0m' export type SpinnerStyle = 'dify' | 'dify-dim' function colorize(s: string, style: SpinnerStyle, truecolor: boolean): string { - if (style === 'dify-dim') - return `${DIM}${s}${ANSI_RESET}` + if (style === 'dify-dim') return `${DIM}${s}${ANSI_RESET}` return `${truecolor ? DIFY_BLUE_RGB : DIFY_BLUE_256}${s}${ANSI_RESET}` } @@ -34,7 +33,7 @@ export type SpinnerOptions = { const DEFAULT_MIN_DISPLAY_MS = 600 function sleep(ms: number): Promise<void> { - return new Promise(resolve => setTimeout(resolve, ms)) + return new Promise((resolve) => setTimeout(resolve, ms)) } export type ActiveSpinner = { stop: () => void } @@ -45,7 +44,7 @@ function buildOraSpinner(opts: SpinnerOptions) { const env = opts.env ?? process.env const truecolor = detectTruecolor(env) const style = opts.style ?? 'dify' - const frames = DIFY_FRAMES.map(f => colorize(f, style, truecolor)) + const frames = DIFY_FRAMES.map((f) => colorize(f, style, truecolor)) return oraImport({ text: opts.label, stream: opts.io.err as NodeJS.WriteStream, @@ -60,8 +59,7 @@ function isActive(opts: SpinnerOptions): boolean { } export function startSpinner(opts: SpinnerOptions): ActiveSpinner { - if (!isActive(opts)) - return NOOP_SPINNER + if (!isActive(opts)) return NOOP_SPINNER const ora = buildOraSpinner(opts).start() let stopped = false return { @@ -74,12 +72,8 @@ export function startSpinner(opts: SpinnerOptions): ActiveSpinner { } } -export async function runWithSpinner<T>( - opts: SpinnerOptions, - fn: () => Promise<T>, -): Promise<T> { - if (!isActive(opts)) - return fn() +export async function runWithSpinner<T>(opts: SpinnerOptions, fn: () => Promise<T>): Promise<T> { + if (!isActive(opts)) return fn() const minMs = opts.minDisplayMs ?? DEFAULT_MIN_DISPLAY_MS const start = Date.now() @@ -87,8 +81,7 @@ export async function runWithSpinner<T>( const enforceMin = async () => { const remaining = minMs - (Date.now() - start) - if (remaining > 0) - await sleep(remaining) + if (remaining > 0) await sleep(remaining) } try { @@ -96,8 +89,7 @@ export async function runWithSpinner<T>( await enforceMin() spinner.succeed(opts.label) return result - } - catch (err) { + } catch (err) { await enforceMin() spinner.fail(opts.label) throw err diff --git a/cli/src/sys/io/streams.ts b/cli/src/sys/io/streams.ts index d193cca4298ec0..b5deebc4b21b77 100644 --- a/cli/src/sys/io/streams.ts +++ b/cli/src/sys/io/streams.ts @@ -42,9 +42,7 @@ export function bufferStreams(stdin = ''): BufferStreams { cb() }, }) as unknown as NodeJS.WritableStream - const inStream: NodeJS.ReadableStream = stdin === '' - ? new PassThrough() - : Readable.from([stdin]) + const inStream: NodeJS.ReadableStream = stdin === '' ? new PassThrough() : Readable.from([stdin]) return { out, err, diff --git a/cli/src/sys/io/think-filter.test.ts b/cli/src/sys/io/think-filter.test.ts index db07d65cd90db0..a993fd4e4eb013 100644 --- a/cli/src/sys/io/think-filter.test.ts +++ b/cli/src/sys/io/think-filter.test.ts @@ -1,7 +1,12 @@ import { Buffer } from 'node:buffer' import { PassThrough } from 'node:stream' import { describe, expect, it } from 'vitest' -import { extractThinkBlocks, filterThinkInOutputs, stripThinkBlocks, ThinkChunkFilter } from './think-filter' +import { + extractThinkBlocks, + filterThinkInOutputs, + stripThinkBlocks, + ThinkChunkFilter, +} from './think-filter' function captures() { const out = new PassThrough() @@ -85,16 +90,19 @@ describe('filterThinkInOutputs', () => { }) it('multiple string fields — thinking joined with separator', () => { - const r = filterThinkInOutputs( - { a: '<think>x</think>\nfoo', b: '<think>y</think>\nbar' }, - true, - ) + const r = filterThinkInOutputs({ a: '<think>x</think>\nfoo', b: '<think>y</think>\nbar' }, true) expect(r.outputs).toEqual({ a: 'foo', b: 'bar' }) expect(r.thinking).toBe('<think>\nx\n</think>\n---\n<think>\ny\n</think>') }) it('non-string values pass through untouched', () => { - const outputs = { n: 42, flag: true, nested: { k: '<think>v</think>\nx' }, arr: ['a'], nil: null } + const outputs = { + n: 42, + flag: true, + nested: { k: '<think>v</think>\nx' }, + arr: ['a'], + nil: null, + } const r = filterThinkInOutputs(outputs, true) expect(r.outputs).toEqual(outputs) expect(r.thinking).toBe('') diff --git a/cli/src/sys/io/think-filter.ts b/cli/src/sys/io/think-filter.ts index 88c2fb7c717cae..a6206f7b113f8e 100644 --- a/cli/src/sys/io/think-filter.ts +++ b/cli/src/sys/io/think-filter.ts @@ -5,7 +5,7 @@ export function stripThinkBlocks(s: string): string { return s.replace(/<think>[\s\S]*?<\/think>\r?\n?/g, '') } -export function extractThinkBlocks(s: string): { clean: string, thinking: string } { +export function extractThinkBlocks(s: string): { clean: string; thinking: string } { const parts: string[] = [] const clean = s.replace(/<think>([\s\S]*?)<\/think>\r?\n?/g, (_, content: string) => { parts.push(`<think>\n${content.trim()}\n</think>`) @@ -20,7 +20,7 @@ export function extractThinkBlocks(s: string): { clean: string, thinking: string export function filterThinkInOutputs( outputs: Record<string, unknown>, showThink: boolean, -): { outputs: Record<string, unknown>, thinking: string } { +): { outputs: Record<string, unknown>; thinking: string } { const thoughts: string[] = [] const clean: Record<string, unknown> = {} for (const [key, value] of Object.entries(outputs)) { @@ -30,8 +30,7 @@ export function filterThinkInOutputs( } const extracted = extractThinkBlocks(value) clean[key] = extracted.clean - if (showThink && extracted.thinking !== '') - thoughts.push(extracted.thinking) + if (showThink && extracted.thinking !== '') thoughts.push(extracted.thinking) } return { outputs: clean, thinking: thoughts.join('\n---\n') } } @@ -64,17 +63,14 @@ export class ThinkChunkFilter { const idx = s.indexOf(OPEN) if (idx === -1) { const [safe, held] = splitAtPotentialTag(s, OPEN) - if (safe) - out.write(safe) + if (safe) out.write(safe) this.buf = held return } - if (idx > 0) - out.write(s.slice(0, idx)) + if (idx > 0) out.write(s.slice(0, idx)) s = s.slice(idx + OPEN.length) this.inThink = true - if (this.showThink) - errOut.write(`${OPEN}\n`) + if (this.showThink) errOut.write(`${OPEN}\n`) continue } @@ -82,32 +78,24 @@ export class ThinkChunkFilter { const idx = s.indexOf(CLOSE) if (idx === -1) { const [safe, held] = splitAtPotentialTag(s, CLOSE) - if (safe && this.showThink) - errOut.write(safe) + if (safe && this.showThink) errOut.write(safe) this.buf = held return } - if (idx > 0 && this.showThink) - errOut.write(s.slice(0, idx)) - if (this.showThink) - errOut.write(`${CLOSE}\n`) + if (idx > 0 && this.showThink) errOut.write(s.slice(0, idx)) + if (this.showThink) errOut.write(`${CLOSE}\n`) s = s.slice(idx + CLOSE.length) this.inThink = false - if (s.startsWith('\r\n')) - s = s.slice(2) - else if (s.startsWith('\n')) - s = s.slice(1) + if (s.startsWith('\r\n')) s = s.slice(2) + else if (s.startsWith('\n')) s = s.slice(1) } } flush(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream): void { - if (this.buf === '') - return + if (this.buf === '') return if (this.inThink) { - if (this.showThink) - errOut.write(this.buf) - } - else { + if (this.showThink) errOut.write(this.buf) + } else { out.write(this.buf) } this.buf = '' diff --git a/cli/src/types/app-meta.test.ts b/cli/src/types/app-meta.test.ts index c1ac765190dac6..a838b9b5bb52a9 100644 --- a/cli/src/types/app-meta.test.ts +++ b/cli/src/types/app-meta.test.ts @@ -1,6 +1,13 @@ import type { AppDescribeResponse } from '@dify/contracts/api/openapi/types.gen' import { describe, expect, it } from 'vitest' -import { covers, FieldInfo, FieldInputSchema, FieldParameters, fromDescribe, mergeMeta } from './app-meta' +import { + covers, + FieldInfo, + FieldInputSchema, + FieldParameters, + fromDescribe, + mergeMeta, +} from './app-meta' function describeResp(): AppDescribeResponse { return { diff --git a/cli/src/types/app-meta.ts b/cli/src/types/app-meta.ts index 2e9ea29525a401..8adb1c6b38c981 100644 --- a/cli/src/types/app-meta.ts +++ b/cli/src/types/app-meta.ts @@ -18,14 +18,16 @@ export type AppMetaCacheRecord = { fetchedAt: string } -export function fromDescribe(resp: AppDescribeResponse, requested: readonly AppMetaFieldKey[]): AppMeta { +export function fromDescribe( + resp: AppDescribeResponse, + requested: readonly AppMetaFieldKey[], +): AppMeta { const covered = new Set<AppMetaFieldKey>() if (requested.length === 0) { covered.add(FieldInfo) covered.add(FieldParameters) covered.add(FieldInputSchema) - } - else { + } else { for (const f of requested) covered.add(f) } return { @@ -37,8 +39,7 @@ export function fromDescribe(resp: AppDescribeResponse, requested: readonly AppM } export function mergeMeta(prev: AppMeta | undefined, next: AppMeta): AppMeta { - if (prev === undefined) - return next + if (prev === undefined) return next const merged = new Set<AppMetaFieldKey>(prev.coveredFields) for (const f of next.coveredFields) merged.add(f) return { @@ -51,13 +52,14 @@ export function mergeMeta(prev: AppMeta | undefined, next: AppMeta): AppMeta { export function covers(meta: AppMeta, fields: readonly AppMetaFieldKey[]): boolean { if (fields.length === 0) { - return meta.coveredFields.has(FieldInfo) - && meta.coveredFields.has(FieldParameters) - && meta.coveredFields.has(FieldInputSchema) + return ( + meta.coveredFields.has(FieldInfo) && + meta.coveredFields.has(FieldParameters) && + meta.coveredFields.has(FieldInputSchema) + ) } for (const f of fields) { - if (!meta.coveredFields.has(f)) - return false + if (!meta.coveredFields.has(f)) return false } return true } diff --git a/cli/src/util/browser.ts b/cli/src/util/browser.ts index c647db98ebd569..7ca4efa523577b 100644 --- a/cli/src/util/browser.ts +++ b/cli/src/util/browser.ts @@ -8,7 +8,7 @@ export const OpenDecision = { SkipNoTTY: 'Non-interactive TTY', SkipUserOptOut: '--no-browser requested', } as const -export type OpenDecision = typeof OpenDecision[keyof typeof OpenDecision] +export type OpenDecision = (typeof OpenDecision)[keyof typeof OpenDecision] export type BrowserEnv = { getEnv: (key: string) => string | undefined @@ -19,7 +19,7 @@ export type BrowserEnv = { export function realEnv(): BrowserEnv { return { - getEnv: k => process.env[k], + getEnv: (k) => process.env[k], platform: platform(), isOutTTY: Boolean(process.stdout.isTTY), isErrTTY: Boolean(process.stderr.isTTY), @@ -27,17 +27,17 @@ export function realEnv(): BrowserEnv { } export function decideOpen(env: BrowserEnv, userOptOut: boolean): OpenDecision { - if (userOptOut) - return OpenDecision.SkipUserOptOut + if (userOptOut) return OpenDecision.SkipUserOptOut if (truthy(env.getEnv('SSH_CONNECTION')) || truthy(env.getEnv('SSH_TTY'))) return OpenDecision.SkipSSH - if (env.platform === 'linux' - && !truthy(env.getEnv('DISPLAY')) - && !truthy(env.getEnv('WAYLAND_DISPLAY'))) { + if ( + env.platform === 'linux' && + !truthy(env.getEnv('DISPLAY')) && + !truthy(env.getEnv('WAYLAND_DISPLAY')) + ) { return OpenDecision.SkipHeadlessLinux } - if (!env.isOutTTY || !env.isErrTTY) - return OpenDecision.SkipNoTTY + if (!env.isOutTTY || !env.isErrTTY) return OpenDecision.SkipNoTTY return OpenDecision.Auto } diff --git a/cli/src/util/host.ts b/cli/src/util/host.ts index 453a68149ada2c..638f357f36f0bf 100644 --- a/cli/src/util/host.ts +++ b/cli/src/util/host.ts @@ -15,16 +15,16 @@ export type ResolveHostOptions = { export function resolveHost(opts: ResolveHostOptions): string { let raw = opts.raw.trim() - if (raw === '') - raw = DEFAULT_HOST - if (!raw.includes('://')) - raw = `https://${raw}` + if (raw === '') raw = DEFAULT_HOST + if (!raw.includes('://')) raw = `https://${raw}` let url: URL try { url = new URL(raw) - } - catch (err) { - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `host parse: ${(err as Error).message}` }) + } catch (err) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `host parse: ${(err as Error).message}`, + }) } url.pathname = url.pathname.replace(/\/+$/, '') if (url.protocol !== 'https:' && !(opts.insecure && url.protocol === 'http:')) { @@ -39,8 +39,7 @@ export function resolveHost(opts: ResolveHostOptions): string { } export function hostWithScheme(host: string, scheme: string | undefined): string { - if (host.includes('://')) - return host + if (host.includes('://')) return host const proto = scheme === undefined || scheme === '' ? 'https' : scheme return `${proto}://${host}` } @@ -48,7 +47,10 @@ export function hostWithScheme(host: string, scheme: string | undefined): string // 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<ActiveContext, 'host' | 'scheme' | 'insecureTls'>): { host: string, insecure: boolean } { +export function activeHostInfo(active: Pick<ActiveContext, 'host' | 'scheme' | 'insecureTls'>): { + host: string + insecure: boolean +} { return { host: hostWithScheme(active.host, active.scheme), insecure: active.insecureTls === true, @@ -59,8 +61,7 @@ export function bareHost(raw: string): string { try { const u = new URL(raw) return u.host !== '' ? u.host : raw - } - catch { + } catch { return raw } } @@ -69,9 +70,11 @@ export function validateVerificationURI(raw: string, insecure: boolean): void { let url: URL try { url = new URL(raw.trim()) - } - catch { - throw new BaseError({ code: ErrorCode.Unknown, message: `server returned invalid verification_uri "${raw}"` }) + } catch { + throw new BaseError({ + code: ErrorCode.Unknown, + message: `server returned invalid verification_uri "${raw}"`, + }) } if (url.protocol !== 'https:' && !(insecure && url.protocol === 'http:')) { throw new BaseError({ @@ -81,5 +84,8 @@ export function validateVerificationURI(raw: string, insecure: boolean): void { }) } if (url.host === '') - throw new BaseError({ code: ErrorCode.Unknown, message: `server returned verification_uri without host: "${raw}"` }) + throw new BaseError({ + code: ErrorCode.Unknown, + message: `server returned verification_uri without host: "${raw}"`, + }) } diff --git a/cli/src/version/compat.test.ts b/cli/src/version/compat.test.ts index c47b25e280f7bc..306b3a36576927 100644 --- a/cli/src/version/compat.test.ts +++ b/cli/src/version/compat.test.ts @@ -62,7 +62,9 @@ describe('evaluateCompat', () => { }) it('strips suffixes on the range bounds too', () => { - expect(evaluateCompat('1.6.5', { minDify: '1.6.0-alpha', maxDify: '1.7.0-rc' }).status).toBe('compatible') + expect(evaluateCompat('1.6.5', { minDify: '1.6.0-alpha', maxDify: '1.7.0-rc' }).status).toBe( + 'compatible', + ) }) }) diff --git a/cli/src/version/compat.ts b/cli/src/version/compat.ts index 5a1caf68cb108a..bf343540b87015 100644 --- a/cli/src/version/compat.ts +++ b/cli/src/version/compat.ts @@ -43,18 +43,33 @@ export function evaluateCompat( const server = tryParse(serverVersion) if (server === undefined) - return { status: 'unknown', detail: `server version ${JSON.stringify(clamp(serverVersion))} is not valid semver` } + return { + status: 'unknown', + detail: `server version ${JSON.stringify(clamp(serverVersion))} is not valid semver`, + } const min = tryParse(range.minDify) const max = tryParse(range.maxDify) if (min === undefined || max === undefined) - return { status: 'unknown', detail: `compat range ${JSON.stringify(`>=${range.minDify} <=${range.maxDify}`)} is not valid semver` } + return { + status: 'unknown', + detail: `compat range ${JSON.stringify(`>=${range.minDify} <=${range.maxDify}`)} is not valid semver`, + } if (compare(core(server), core(min)) < 0) - return { status: 'too_old', detail: `server ${serverVersion} is older than the minimum ${range.minDify}` } + return { + status: 'too_old', + detail: `server ${serverVersion} is older than the minimum ${range.minDify}`, + } if (compare(core(server), core(max)) > 0) - return { status: 'too_new', detail: `server ${serverVersion} is newer than the tested maximum ${range.maxDify}` } + return { + status: 'too_new', + detail: `server ${serverVersion} is newer than the tested maximum ${range.maxDify}`, + } - return { status: 'compatible', detail: `server ${serverVersion} in [${range.minDify}, ${range.maxDify}]` } + return { + status: 'compatible', + detail: `server ${serverVersion} in [${range.minDify}, ${range.maxDify}]`, + } } diff --git a/cli/src/version/enforce.test.ts b/cli/src/version/enforce.test.ts index 4c3395530c228f..92c9d51022d721 100644 --- a/cli/src/version/enforce.test.ts +++ b/cli/src/version/enforce.test.ts @@ -26,7 +26,9 @@ describe('enforceDifyVersion', () => { const store = fakeStore() const probe = vi.fn(async () => server('1.5.0')) - await expect(enforceDifyVersion(HOST, { store, probe })).rejects.toMatchObject({ code: ErrorCode.VersionSkew }) + await expect(enforceDifyVersion(HOST, { store, probe })).rejects.toMatchObject({ + code: ErrorCode.VersionSkew, + }) expect(store.marked).toHaveLength(0) }) @@ -60,9 +62,9 @@ describe('enforceDifyVersion', () => { const store = fakeStore(true) const probe = vi.fn(async () => server('1.5.0')) - await expect(enforceDifyVersion(HOST, { store, probe, forceFresh: true })) - .rejects - .toMatchObject({ code: ErrorCode.VersionSkew }) + await expect( + enforceDifyVersion(HOST, { store, probe, forceFresh: true }), + ).rejects.toMatchObject({ code: ErrorCode.VersionSkew }) expect(probe).toHaveBeenCalledOnce() }) diff --git a/cli/src/version/enforce.ts b/cli/src/version/enforce.ts index 8c995a912d1bbc..63865d7832d206 100644 --- a/cli/src/version/enforce.ts +++ b/cli/src/version/enforce.ts @@ -11,14 +11,19 @@ import { versionInfo } from './info' export type ServerVersionProbe = (host: string) => Promise<ServerVersionResponse> -const UPGRADE_HINT - = `upgrade the Dify server to >= ${difyCompat.minDify} ` - + '(https://docs.dify.ai/en/getting-started/install-self-hosted)' +const UPGRADE_HINT = + `upgrade the Dify server to >= ${difyCompat.minDify} ` + + '(https://docs.dify.ai/en/getting-started/install-self-hosted)' // /_version is unauthenticated; same timeout/no-retry budget as the auto-nudge probe. function buildDefaultProbe(insecure: boolean): ServerVersionProbe { return async (host) => { - const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure }) + const http = createHttpClient({ + baseURL: openAPIBase(host), + timeoutMs: META_PROBE_TIMEOUT_MS, + retryAttempts: 0, + insecure, + }) return new MetaClient(http).serverVersion() } } @@ -44,16 +49,14 @@ export async function enforceDifyVersion( host: string, opts: EnforceOptions = {}, ): Promise<ServerVersionResponse | undefined> { - const store = opts.store ?? await loadCompatStore() - if (opts.forceFresh !== true && store.isFreshCompatible(host)) - return undefined + const store = opts.store ?? (await loadCompatStore()) + if (opts.forceFresh !== true && store.isFreshCompatible(host)) return undefined const probe = opts.probe ?? buildDefaultProbe(opts.insecure === true) let server: ServerVersionResponse try { server = await probe(host) - } - catch { + } catch { return undefined } diff --git a/cli/src/version/info.ts b/cli/src/version/info.ts index c43fef07d368cd..f807fa56644594 100644 --- a/cli/src/version/info.ts +++ b/cli/src/version/info.ts @@ -23,8 +23,10 @@ export function shortVersion(): string { export function longVersion(): string { const { version, commit, buildDate, channel } = versionInfo - return `difyctl ${version} (commit ${commit.slice(0, 7)}, built ${buildDate}, channel ${channel})\n` - + `compat: ${compatString()}` + return ( + `difyctl ${version} (commit ${commit.slice(0, 7)}, built ${buildDate}, channel ${channel})\n` + + `compat: ${compatString()}` + ) } export function userAgent(): string { diff --git a/cli/src/version/nudge.test.ts b/cli/src/version/nudge.test.ts index 9aca623bb243fb..53038a28b461f4 100644 --- a/cli/src/version/nudge.test.ts +++ b/cli/src/version/nudge.test.ts @@ -23,14 +23,16 @@ function emitterSpy() { return { emit: (line: string) => lines.push(line), lines } } -function baseDeps(overrides: Partial<{ - store: NudgeStore - probe: Probe - emit: (line: string) => void - isTty: boolean - format: string - clientVersion: string -}> & { store: NudgeStore } & { probe: Probe } & { emit: (line: string) => void }) { +function baseDeps( + overrides: Partial<{ + store: NudgeStore + probe: Probe + emit: (line: string) => void + isTty: boolean + format: string + clientVersion: string + }> & { store: NudgeStore } & { probe: Probe } & { emit: (line: string) => void }, +) { return { isTty: true, format: '', @@ -52,10 +54,8 @@ describe('maybeNudgeCompat', () => { store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow }) }) afterEach(async () => { - if (prevCacheDir === undefined) - delete process.env[ENV_CACHE_DIR] - else - process.env[ENV_CACHE_DIR] = prevCacheDir + if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR] + else process.env[ENV_CACHE_DIR] = prevCacheDir await rm(dir, { recursive: true, force: true }) }) @@ -121,7 +121,9 @@ describe('maybeNudgeCompat', () => { }) it('does not warn when server version yields unknown verdict', async () => { - const probe = vi.fn(async () => ({ version: '', edition: 'SELF_HOSTED' } as ServerVersionResponse)) + const probe = vi.fn( + async () => ({ version: '', edition: 'SELF_HOSTED' }) as ServerVersionResponse, + ) const { emit, lines } = emitterSpy() await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit })) @@ -161,8 +163,12 @@ describe('maybeNudgeCompat', () => { it('never throws even when every dependency explodes', async () => { const explodingStore: NudgeStore = { - canWarn: () => { throw new Error('canWarn boom') }, - markWarned: async () => { throw new Error('markWarned boom') }, + canWarn: () => { + throw new Error('canWarn boom') + }, + markWarned: async () => { + throw new Error('markWarned boom') + }, } const probe: Probe = async () => { throw new Error('probe boom') @@ -171,10 +177,15 @@ describe('maybeNudgeCompat', () => { throw new Error('emit boom') } - await expect(maybeNudgeCompat(HOST, baseDeps({ - store: explodingStore, - probe, - emit, - }))).resolves.toBeUndefined() + await expect( + maybeNudgeCompat( + HOST, + baseDeps({ + store: explodingStore, + probe, + emit, + }), + ), + ).resolves.toBeUndefined() }) }) diff --git a/cli/src/version/nudge.ts b/cli/src/version/nudge.ts index f8d368ee0661d3..7eecf63e3506cf 100644 --- a/cli/src/version/nudge.ts +++ b/cli/src/version/nudge.ts @@ -28,33 +28,27 @@ export type NudgeDeps = { // before any I/O so the happy path costs nothing in steady state. export async function maybeNudgeCompat(host: string, deps: NudgeDeps): Promise<void> { try { - if (!deps.isTty) - return - if (SUPPRESSED_FORMATS.has(deps.format)) - return - if (!deps.store.canWarn(host, deps.now?.())) - return + if (!deps.isTty) return + if (SUPPRESSED_FORMATS.has(deps.format)) return + if (!deps.store.canWarn(host, deps.now?.())) return let server: ServerVersionResponse try { server = await deps.probe(host) - } - catch { + } catch { return } const verdict = evaluateCompat(server.version) // Only "too new" is a soft nudge here; "too old" is hard-failed up front by // enforceDifyVersion, so the command never reaches this path for it. - if (verdict.status !== 'too_new') - return + if (verdict.status !== 'too_new') return deps.emit(formatBanner(deps.clientVersion, server.version, deps.color === true)) await deps.store.markWarned(host, deps.now?.()).catch(() => { // disk failure must not propagate; the user already saw the banner. }) - } - catch { + } catch { // belt-and-braces: any unexpected throw must not affect the business command } } @@ -62,9 +56,9 @@ export async function maybeNudgeCompat(host: string, deps: NudgeDeps): Promise<v function formatBanner(clientVersion: string, serverVersion: string, color: boolean): string { const { yellow } = colorScheme(color) const { minDify, maxDify } = difyCompat - const line - = `warning: difyctl ${clientVersion} may be incompatible with server ` - + `${serverVersion} (tested: ${minDify}..${maxDify}). ` - + 'Run `difyctl version` for details.' + const line = + `warning: difyctl ${clientVersion} may be incompatible with server ` + + `${serverVersion} (tested: ${minDify}..${maxDify}). ` + + 'Run `difyctl version` for details.' return `${yellow(line)}\n` } diff --git a/cli/src/version/probe.test.ts b/cli/src/version/probe.test.ts index ec1da18188e694..232be7a5fd1ad7 100644 --- a/cli/src/version/probe.test.ts +++ b/cli/src/version/probe.test.ts @@ -75,7 +75,9 @@ describe('runVersionProbe', () => { it('distinguishes loadActive disk failure from no-host configured in the detail', async () => { const errReport = await runVersionProbe({ skipServer: false, - loadActive: async () => { throw new Error('disk-explode') }, + loadActive: async () => { + throw new Error('disk-explode') + }, probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), }) expect(errReport.server.reachable).toBe(false) @@ -131,7 +133,9 @@ describe('runVersionProbe', () => { const report = await runVersionProbe({ skipServer: false, loadActive: async () => active(), - probe: async () => { throw new Error('timeout') }, + probe: async () => { + throw new Error('timeout') + }, }) expect(report.server.reachable).toBe(false) @@ -162,7 +166,9 @@ describe('runVersionProbe', () => { try { process.env[ENV_CONFIG_DIR] = configDir const reg = Registry.empty('file') - reg.upsert(url.host, 'test@dify.ai', { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } }) + reg.upsert(url.host, 'test@dify.ai', { + account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' }, + }) reg.setHost(url.host) reg.setAccount('test@dify.ai') reg.setScheme(url.host, url.protocol.replace(':', '')) @@ -176,12 +182,9 @@ describe('runVersionProbe', () => { expect(report.server.version).toBe('1.6.4') expect(report.server.edition).toBe('CLOUD') expect(report.compat.status).toBe('compatible') - } - finally { - if (prevConfig === undefined) - delete process.env[ENV_CONFIG_DIR] - else - process.env[ENV_CONFIG_DIR] = prevConfig + } finally { + if (prevConfig === undefined) delete process.env[ENV_CONFIG_DIR] + else process.env[ENV_CONFIG_DIR] = prevConfig await mock.stop() await rm(configDir, { recursive: true, force: true }) } diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts index 05ab3ea84e38f8..30a1abf79b5141 100644 --- a/cli/src/version/probe.ts +++ b/cli/src/version/probe.ts @@ -53,7 +53,12 @@ const defaultLoadActive = async (): Promise<ActiveContext | undefined> => { function buildDefaultProbe(insecure: boolean): MetaProbe { return async (endpoint) => { - const http = createHttpClient({ baseURL: openAPIBase(endpoint), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure }) + const http = createHttpClient({ + baseURL: openAPIBase(endpoint), + timeoutMs: META_PROBE_TIMEOUT_MS, + retryAttempts: 0, + insecure, + }) return new MetaClient(http).serverVersion() } } @@ -99,8 +104,7 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver let loadFailed = false try { active = await loadActive() - } - catch { + } catch { loadFailed = true } @@ -119,13 +123,16 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver let serverInfo: ServerVersionResponse | undefined try { serverInfo = await probe(endpoint) - } - catch { + } catch { serverInfo = undefined } if (serverInfo === undefined) - return { client, server: unreachableServer(endpoint), compat: compatBlock({ status: 'unknown', detail: 'server unreachable' }) } + return { + client, + server: unreachableServer(endpoint), + compat: compatBlock({ status: 'unknown', detail: 'server unreachable' }), + } return { client, diff --git a/cli/src/version/render.test.ts b/cli/src/version/render.test.ts index 0ca2f39616d805..5ae33ded085424 100644 --- a/cli/src/version/render.test.ts +++ b/cli/src/version/render.test.ts @@ -31,7 +31,12 @@ describe('renderVersionText', () => { it('renders all three blocks for a reachable, compatible server', () => { const report: VersionReport = { client: baseClient(), - server: { endpoint: 'https://cloud.dify.ai', reachable: true, version: '1.6.4', edition: 'CLOUD' }, + server: { + endpoint: 'https://cloud.dify.ai', + reachable: true, + version: '1.6.4', + edition: 'CLOUD', + }, compat: compatible(), } const text = renderVersionText(report) @@ -127,7 +132,12 @@ describe('renderVersionText', () => { it('color=false produces no ANSI escape sequences regardless of TTY state', () => { const report: VersionReport = { client: baseClient({ channel: 'rc' }), - server: { endpoint: 'https://cloud.dify.ai', reachable: true, version: '99.0.0', edition: 'SELF_HOSTED' }, + server: { + endpoint: 'https://cloud.dify.ai', + reachable: true, + version: '99.0.0', + edition: 'SELF_HOSTED', + }, compat: { minDify: '1.6.0', maxDify: '1.7.0', @@ -171,7 +181,12 @@ describe('renderVersionText', () => { const { renderVersionText: render } = await import('./render') const report: VersionReport = { client: baseClient({ channel: 'rc' }), - server: { endpoint: 'https://cloud.dify.ai', reachable: true, version: '99.0.0', edition: 'SELF_HOSTED' }, + server: { + endpoint: 'https://cloud.dify.ai', + reachable: true, + version: '99.0.0', + edition: 'SELF_HOSTED', + }, compat: { minDify: '1.6.0', maxDify: '1.7.0', diff --git a/cli/src/version/render.ts b/cli/src/version/render.ts index 70b725c80e3dc8..d3dc184bf9d870 100644 --- a/cli/src/version/render.ts +++ b/cli/src/version/render.ts @@ -39,14 +39,14 @@ export function renderVersionText(report: VersionReport, opts: RenderOptions = { lines.push('Server:') if (server.endpoint === '') { lines.push(` ${c.dim('(skipped — no host configured or --client passed)')}`) - } - else if (!server.reachable) { + } else if (!server.reachable) { lines.push(` Endpoint: ${server.endpoint}`) lines.push(` Version: ${c.dim('(unreachable)')}`) - } - else { + } else { lines.push(` Endpoint: ${server.endpoint}`) - lines.push(` Version: ${server.version ?? ''}${server.edition !== undefined ? ` (${server.edition.toLowerCase()})` : ''}`) + lines.push( + ` Version: ${server.version ?? ''}${server.edition !== undefined ? ` (${server.edition.toLowerCase()})` : ''}`, + ) } lines.push('') @@ -56,8 +56,7 @@ export function renderVersionText(report: VersionReport, opts: RenderOptions = { if (client.channel !== 'stable') { lines.push('') - for (const line of prereleaseWarning(client.channel)) - lines.push(c.yellow(line)) + for (const line of prereleaseWarning(client.channel)) lines.push(c.yellow(line)) } return `${lines.join('\n')}\n` diff --git a/cli/src/workspace/resolver.test.ts b/cli/src/workspace/resolver.test.ts index daac192b82eb2f..f971766f4e6870 100644 --- a/cli/src/workspace/resolver.test.ts +++ b/cli/src/workspace/resolver.test.ts @@ -3,7 +3,14 @@ import { describe, expect, it } from 'vitest' import { resolveWorkspaceId } from './resolver' function active(workspaceId?: string): ActiveContext { - return { host: 'h', email: 'e', ctx: { account: { id: '', email: 'e', name: '' }, workspace: workspaceId ? { id: workspaceId, name: 'W', role: 'owner' } : undefined } } + return { + host: 'h', + email: 'e', + ctx: { + account: { id: '', email: 'e', name: '' }, + workspace: workspaceId ? { id: workspaceId, name: 'W', role: 'owner' } : undefined, + }, + } } const UUID_FLAG = 'aaaaaaaa-0000-0000-0000-000000000001' @@ -12,7 +19,9 @@ const UUID_CTX = 'aaaaaaaa-0000-0000-0000-000000000003' describe('resolveWorkspaceId', () => { it('prefers the flag', () => { - expect(resolveWorkspaceId({ flag: UUID_FLAG, env: UUID_ENV, active: active(UUID_CTX) })).toBe(UUID_FLAG) + expect(resolveWorkspaceId({ flag: UUID_FLAG, env: UUID_ENV, active: active(UUID_CTX) })).toBe( + UUID_FLAG, + ) }) it('falls back to env over active workspace', () => { expect(resolveWorkspaceId({ env: UUID_ENV, active: active(UUID_CTX) })).toBe(UUID_ENV) diff --git a/cli/src/workspace/resolver.ts b/cli/src/workspace/resolver.ts index a5b03d7c613a88..91ecf709cc171a 100644 --- a/cli/src/workspace/resolver.ts +++ b/cli/src/workspace/resolver.ts @@ -17,12 +17,18 @@ export function isValidUuid(v: string): boolean { export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string { if (truthy(inputs.flag)) { if (!isValidUuid(inputs.flag)) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `--workspace value ${JSON.stringify(inputs.flag)} is not a valid UUID` }) + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `--workspace value ${JSON.stringify(inputs.flag)} is not a valid UUID`, + }) return inputs.flag } if (truthy(inputs.env)) { if (!isValidUuid(inputs.env)) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `DIFY_WORKSPACE_ID value ${JSON.stringify(inputs.env)} is not a valid UUID` }) + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `DIFY_WORKSPACE_ID value ${JSON.stringify(inputs.env)} is not a valid UUID`, + }) return inputs.env } const wsId = inputs.active?.ctx.workspace?.id @@ -31,7 +37,7 @@ export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string { throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `stored workspace ID ${JSON.stringify(wsId)} is not a valid UUID`, - hint: 'run \'difyctl use workspace\' to update your active workspace', + hint: "run 'difyctl use workspace' to update your active workspace", }) } return wsId @@ -39,7 +45,7 @@ export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string { throw new BaseError({ code: ErrorCode.UsageMissingArg, message: 'no workspace selected', - hint: 'pass --workspace, set DIFY_WORKSPACE_ID, or run \'difyctl use workspace <id>\'', + hint: "pass --workspace, set DIFY_WORKSPACE_ID, or run 'difyctl use workspace <id>'", }) } diff --git a/cli/test/e2e/helpers/assert.ts b/cli/test/e2e/helpers/assert.ts index e5c998541ddd18..240f99b1926179 100644 --- a/cli/test/e2e/helpers/assert.ts +++ b/cli/test/e2e/helpers/assert.ts @@ -28,9 +28,9 @@ function redact(text: string): string { export function assertExitCode(result: RunResult, expected: number): void { if (result.exitCode !== expected) { process.stderr.write( - `\n[E2E assertExitCode] expected ${expected}, got ${result.exitCode}\n` - + `stdout:\n${redact(result.stdout) || '(empty)'}\n` - + `stderr:\n${redact(result.stderr) || '(empty)'}\n`, + `\n[E2E assertExitCode] expected ${expected}, got ${result.exitCode}\n` + + `stdout:\n${redact(result.stdout) || '(empty)'}\n` + + `stderr:\n${redact(result.stderr) || '(empty)'}\n`, ) } expect(result.exitCode, `exit code should be ${expected}`).toBe(expected) @@ -52,8 +52,7 @@ export function assertJson<T = unknown>(result: RunResult): T { let parsed: T try { parsed = JSON.parse(result.stdout) as T - } - catch { + } catch { throw new Error( `stdout is not valid JSON.\nstdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}`, ) @@ -77,13 +76,12 @@ export function assertJson<T = unknown>(result: RunResult): T { export function assertErrorEnvelope( result: RunResult, expectedCode?: string, -): { error: { code: string, message: string, hint?: string } } { +): { error: { code: string; message: string; hint?: string } } { const raw = result.stderr.trim() - let parsed: { error: { code: string, message: string, hint?: string } } + let parsed: { error: { code: string; message: string; hint?: string } } try { parsed = JSON.parse(raw) as typeof parsed - } - catch { + } catch { throw new Error( `stderr is not valid JSON.\nstdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}`, ) @@ -134,8 +132,8 @@ export function assertPipeFriendlyJson(result: RunResult): void { export function assertStdoutContains(result: RunResult, expected: string): void { if (!result.stdout.includes(expected)) { process.stderr.write( - `\n[E2E assertStdoutContains] "${expected}" not found in stdout.\n` - + `stdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}\n`, + `\n[E2E assertStdoutContains] "${expected}" not found in stdout.\n` + + `stdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}\n`, ) } expect(result.stdout).toContain(expected) @@ -147,8 +145,8 @@ export function assertStdoutContains(result: RunResult, expected: string): void export function assertStderrContains(result: RunResult, expected: string): void { if (!result.stderr.includes(expected)) { process.stderr.write( - `\n[E2E assertStderrContains] "${expected}" not found in stderr.\n` - + `stdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}\n`, + `\n[E2E assertStderrContains] "${expected}" not found in stderr.\n` + + `stdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}\n`, ) } expect(result.stderr).toContain(expected) diff --git a/cli/test/e2e/helpers/cleanup-registry.ts b/cli/test/e2e/helpers/cleanup-registry.ts index 1499e9660eb660..295eb8c9823097 100644 --- a/cli/test/e2e/helpers/cleanup-registry.ts +++ b/cli/test/e2e/helpers/cleanup-registry.ts @@ -32,8 +32,7 @@ export function registerConversation( appId: string, conversationId: string, ): void { - if (!conversationId || !appId) - return + if (!conversationId || !appId) return _conversations.push({ host, token, appId, conversationId }) } @@ -49,8 +48,7 @@ export function getRegisteredConversations(): readonly ConversationEntry[] { * Called once from global-teardown.ts. */ export async function cleanupRegisteredConversations(): Promise<void> { - if (_conversations.length === 0) - return + if (_conversations.length === 0) return console.warn(`[E2E teardown] Cleaning up ${_conversations.length} staged conversation(s)…`) @@ -60,14 +58,13 @@ export async function cleanupRegisteredConversations(): Promise<void> { ), ) - const failed = results.filter(r => r.status === 'rejected') + const failed = results.filter((r) => r.status === 'rejected') if (failed.length > 0) { console.warn( `[E2E teardown] ${failed.length} conversation deletion(s) failed (non-blocking):`, - failed.map(r => (r as PromiseRejectedResult).reason).join(', '), + failed.map((r) => (r as PromiseRejectedResult).reason).join(', '), ) - } - else { + } else { console.warn(`[E2E teardown] All conversations cleaned up.`) } diff --git a/cli/test/e2e/helpers/cli.ts b/cli/test/e2e/helpers/cli.ts index f706383a85b430..1600ea5f7ca9e7 100644 --- a/cli/test/e2e/helpers/cli.ts +++ b/cli/test/e2e/helpers/cli.ts @@ -36,12 +36,11 @@ function resolveBun(): string { try { execSync(`${candidate} --version`, { stdio: 'ignore', timeout: 3000 }) return candidate + } catch { + /* try next */ } - catch { /* try next */ } } - throw new Error( - 'bun not found. Install it with: curl -fsSL https://bun.sh/install | bash', - ) + throw new Error('bun not found. Install it with: curl -fsSL https://bun.sh/install | bash') } export const BUN = resolveBun() @@ -173,7 +172,7 @@ export type AuthInjectionOptions = { workspaceName: string workspaceRole?: string /** Full available workspace list. Defaults to the primary workspace only. */ - availableWorkspaces?: Array<{ id: string, name: string, role: string }> + availableWorkspaces?: Array<{ id: string; name: string; role: string }> /** * Server-side session UUID (OAuthAccessToken.id). * When provided, written as `token_id` in hosts.yml so that @@ -189,32 +188,37 @@ export type SsoAuthInjectionOptions = { issuer?: string } -function splitHost(host: string): { bare: string, scheme: string } { +function splitHost(host: string): { bare: string; scheme: string } { const bare = (() => { try { return new URL(host).host || host - } - catch { + } catch { return host } })() const scheme = (() => { try { return new URL(host).protocol.replace(':', '') - } - catch { + } catch { return 'https' } })() return { bare, scheme } } -async function writeFileToken(configDir: string, host: string, email: string, bearer: string): Promise<void> { +async function writeFileToken( + configDir: string, + host: string, + email: string, + bearer: string, +): Promise<void> { const doc: TokenDoc = { version: 1, tokens: { [host]: { [email]: bearer } }, } - await writeFile(join(configDir, 'tokens.yml'), dump(doc, { lineWidth: -1, noRefs: true }), { mode: 0o600 }) + await writeFile(join(configDir, 'tokens.yml'), dump(doc, { lineWidth: -1, noRefs: true }), { + mode: 0o600, + }) } /** @@ -234,11 +238,13 @@ export async function injectAuth(configDir: string, opts: AuthInjectionOptions): const { bare, scheme } = splitHost(opts.host) const email = opts.email ?? 'e2e@example.com' const accountName = opts.accountName ?? email.split('@')[0] ?? '' - const availableWorkspaces = opts.availableWorkspaces ?? [{ - id: opts.workspaceId, - name: opts.workspaceName, - role, - }] + const availableWorkspaces = opts.availableWorkspaces ?? [ + { + id: opts.workspaceId, + name: opts.workspaceName, + role, + }, + ] // ── hosts.yml ──────────────────────────────────────────────────────────── // difyctl 0.1.0-rc.1 uses a nested registry format: @@ -269,7 +275,7 @@ export async function injectAuth(configDir: string, opts: AuthInjectionOptions): ` name: "${opts.workspaceName}"`, ` role: ${role}`, ` available_workspaces:`, - ...availableWorkspaces.flatMap(workspace => [ + ...availableWorkspaces.flatMap((workspace) => [ ` - id: ${workspace.id}`, ` name: "${workspace.name}"`, ` role: ${workspace.role}`, @@ -287,15 +293,17 @@ export async function injectAuth(configDir: string, opts: AuthInjectionOptions): const { Entry } = await import('@napi-rs/keyring') const account = `tokens.${bare}.${email}` new Entry('difyctl', account).setPassword(JSON.stringify(opts.bearer)) - } - else { + } else { // Fall back to tokens.yml — FileTokenStore uses getTyped<TokenDoc>() // which expects flat tokens[host][email] with version: 1. await writeFileToken(configDir, bare, email, opts.bearer) } } -export async function injectSsoAuth(configDir: string, opts: SsoAuthInjectionOptions): Promise<void> { +export async function injectSsoAuth( + configDir: string, + opts: SsoAuthInjectionOptions, +): Promise<void> { await mkdir(configDir, { recursive: true, mode: 0o700 }) const { bare, scheme } = splitHost(opts.host) @@ -364,13 +372,16 @@ export function spawn_background(argv: string[], opts: RunOptions = {}): Spawned }) return { - interrupt: () => { proc.kill('SIGINT') }, - wait: () => new Promise((res) => { - proc.on('close', (code: number | null) => { - clearTimeout(timeoutId) - res({ stdout, stderr, exitCode: code ?? (timedOut ? 124 : 1) }) - }) - }), + interrupt: () => { + proc.kill('SIGINT') + }, + wait: () => + new Promise((res) => { + proc.on('close', (code: number | null) => { + clearTimeout(timeoutId) + res({ stdout, stderr, exitCode: code ?? (timedOut ? 124 : 1) }) + }) + }), } } @@ -402,9 +413,13 @@ export type AuthFixture = { * assertExitCode(result, 0) * }) */ -export async function withAuthFixture( - E: { host: string, token: string, workspaceId: string, workspaceName: string, email?: string }, -): Promise<AuthFixture> { +export async function withAuthFixture(E: { + host: string + token: string + workspaceId: string + workspaceName: string + email?: string +}): Promise<AuthFixture> { const { configDir, cleanup } = await withTempConfig() await injectAuth(configDir, { host: E.host, @@ -441,8 +456,7 @@ export async function mintFreshToken( email: string, password: string, ): Promise<string> { - if (!email || !password) - return '' + if (!email || !password) return '' const base = host.replace(/\/$/, '') const sig = AbortSignal.timeout(15_000) @@ -455,11 +469,10 @@ export async function mintFreshToken( body: JSON.stringify({ email, password: passwordB64, remember_me: false }), signal: AbortSignal.timeout(20_000), }) - if (!loginRes.ok) - return '' + if (!loginRes.ok) return '' const setCookieHeaders = loginRes.headers.getSetCookie?.() ?? [] - const cookieString = setCookieHeaders.map(c => c.split(';')[0]).join('; ') + const cookieString = setCookieHeaders.map((c) => c.split(';')[0]).join('; ') const csrfMatch = cookieString.match(/csrf_token=([^;]+)/) const csrfToken = csrfMatch ? csrfMatch[1] : '' @@ -470,19 +483,20 @@ export async function mintFreshToken( body: JSON.stringify({ client_id: 'difyctl', device_label: 'e2e-fresh' }), signal: sig, }) - if (!codeRes.ok) - return '' - const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string } + if (!codeRes.ok) return '' + const { device_code, user_code } = (await codeRes.json()) as { + device_code: string + user_code: string + } // Step 3 — approve const approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRFToken': csrfToken }, + headers: { 'Content-Type': 'application/json', Cookie: cookieString, 'X-CSRFToken': csrfToken }, body: JSON.stringify({ user_code }), signal: AbortSignal.timeout(20_000), }) - if (!approveRes.ok) - return '' + if (!approveRes.ok) return '' // Step 4 — poll token const tokenRes = await fetch(`${base}/openapi/v1/oauth/device/token`, { @@ -491,8 +505,7 @@ export async function mintFreshToken( body: JSON.stringify({ device_code, client_id: 'difyctl' }), signal: AbortSignal.timeout(20_000), }) - if (!tokenRes.ok) - return '' - const body = await tokenRes.json() as { token?: string } + if (!tokenRes.ok) return '' + const body = (await tokenRes.json()) as { token?: string } return body.token ?? '' } diff --git a/cli/test/e2e/helpers/retry.ts b/cli/test/e2e/helpers/retry.ts index 6f71ce074de94a..aa43b4798d6ffb 100644 --- a/cli/test/e2e/helpers/retry.ts +++ b/cli/test/e2e/helpers/retry.ts @@ -33,11 +33,9 @@ export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions = {} for (let attempt = 1; attempt <= total; attempt++) { try { return await fn() - } - catch (err) { + } catch (err) { lastErr = err - if (attempt === total || !shouldRetry(err)) - break + if (attempt === total || !shouldRetry(err)) break console.warn(`[E2E retry] attempt ${attempt}/${total} failed — retrying in ${delay}ms`) await sleep(delay) @@ -47,5 +45,5 @@ export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions = {} } function sleep(ms: number): Promise<void> { - return new Promise(resolve => setTimeout(resolve, ms)) + return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/cli/test/e2e/setup/env.ts b/cli/test/e2e/setup/env.ts index 33f8361d46b1d0..3936dc07f5b5af 100644 --- a/cli/test/e2e/setup/env.ts +++ b/cli/test/e2e/setup/env.ts @@ -144,8 +144,7 @@ export function isEnterpriseEdition(): boolean { /** Load and validate E2E environment variables. Throws if required vars are missing. */ export function loadE2EEnv(): E2EEnv { - if (_cached !== undefined) - return _cached + if (_cached !== undefined) return _cached const edition: DifyEdition = isEnterpriseEdition() ? 'ee' : 'ce' @@ -160,9 +159,9 @@ export function loadE2EEnv(): E2EEnv { if (missing.length > 0) { const list = missing.map(([k, desc]) => ` ${k} (${desc})`).join('\n') throw new Error( - `E2E tests require the following environment variables to be set:\n${list}\n\n` - + `Edition: ${edition.toUpperCase()}\n` - + 'See test/e2e/setup/env.ts for documentation.', + `E2E tests require the following environment variables to be set:\n${list}\n\n` + + `Edition: ${edition.toUpperCase()}\n` + + 'See test/e2e/setup/env.ts for documentation.', ) } @@ -203,8 +202,7 @@ export function isE2ELocalMode(): boolean { * global-setup returns early without calling project.provide(). */ export function resolveEnv(caps: E2ECapabilities | undefined): E2EEnv { - if (!caps) - return loadE2EEnv() + if (!caps) return loadE2EEnv() const env = loadE2EEnv() return { ...env, diff --git a/cli/test/e2e/setup/global-setup.ts b/cli/test/e2e/setup/global-setup.ts index 92cc29fc1f20d2..70e0242d3708b8 100644 --- a/cli/test/e2e/setup/global-setup.ts +++ b/cli/test/e2e/setup/global-setup.ts @@ -38,8 +38,7 @@ const TOKEN_MINT_APPROVE_ATTEMPTS = 5 const TOKEN_MINT_RETRY_BASE_MS = 2_000 export async function setup(project: TestProject): Promise<void> { - if (process.env.DIFY_E2E_MODE === 'local') - return + if (process.env.DIFY_E2E_MODE === 'local') return const E = loadE2EEnv() const consoleBase = E.consoleUrl.replace(/\/$/, '') @@ -66,20 +65,19 @@ export async function setup(project: TestProject): Promise<void> { async function loadCachedToken(): Promise<string> { try { const raw = await readFile(TOKEN_CACHE, 'utf8') - const { token, host } = JSON.parse(raw) as { token?: string, host?: string } + const { token, host } = JSON.parse(raw) as { token?: string; host?: string } // Invalidate if host changed (different staging env) - if (!token || host !== E.host) - return '' + if (!token || host !== E.host) return '' return token + } catch { + return '' } - catch { return '' } } async function saveCachedToken(token: string): Promise<void> { try { await writeFile(TOKEN_CACHE, JSON.stringify({ token, host: E.host }, null, 2), 'utf8') - } - catch (err) { + } catch (err) { console.warn(`[E2E] Could not save token cache: ${err}`) } } @@ -91,33 +89,35 @@ export async function setup(project: TestProject): Promise<void> { signal: AbortSignal.timeout(8_000), }) return r.ok + } catch { + return false } - catch { return false } } let primaryToken = E.token if (primaryToken) { console.warn(`[E2E] primaryToken from env: ${primaryToken.slice(0, 20)}…`) - } - else { + } else { // Try cache first const cached = await loadCachedToken() - if (cached && await validateToken(cached)) { + if (cached && (await validateToken(cached))) { primaryToken = cached console.warn(`[E2E] primaryToken from cache: ${primaryToken.slice(0, 20)}…`) - } - else { - if (cached) - console.warn('[E2E] Cached token invalid or expired — re-minting…') + } else { + if (cached) console.warn('[E2E] Cached token invalid or expired — re-minting…') try { - primaryToken = await mintTokenWithSession(consoleBase, cookieString, csrfToken, 'e2e-primary') + primaryToken = await mintTokenWithSession( + consoleBase, + cookieString, + csrfToken, + 'e2e-primary', + ) await saveCachedToken(primaryToken) console.warn(`[E2E] primaryToken minted and cached: ${primaryToken.slice(0, 20)}…`) - } - catch (err) { + } catch (err) { throw new Error( - `[E2E global-setup] Failed to mint primary token: ${err}\n` - + 'Ensure DIFY_E2E_EMAIL and DIFY_E2E_PASSWORD are correct.', + `[E2E global-setup] Failed to mint primary token: ${err}\n` + + 'Ensure DIFY_E2E_EMAIL and DIFY_E2E_PASSWORD are correct.', ) } } @@ -131,40 +131,35 @@ export async function setup(project: TestProject): Promise<void> { headers: { Authorization: `Bearer ${primaryToken}` }, signal: AbortSignal.timeout(10_000), }) - } - catch (err) { + } catch (err) { throw new Error( - `[E2E global-setup] Cannot reach staging server at ${sessionsUrl}.\n` - + `Check DIFY_E2E_HOST and network connectivity.\n${String(err)}`, + `[E2E global-setup] Cannot reach staging server at ${sessionsUrl}.\n` + + `Check DIFY_E2E_HOST and network connectivity.\n${String(err)}`, ) } if (!res.ok) { throw new Error( - `[E2E global-setup] Primary token is invalid or expired (HTTP ${res.status}).\n` - + `URL: ${sessionsUrl}`, + `[E2E global-setup] Primary token is invalid or expired (HTTP ${res.status}).\n` + + `URL: ${sessionsUrl}`, ) } console.warn(`[E2E] Server healthy, primary token valid at ${E.host}`) // ── Resolve token_id ───────────────────────────────────────────────────── - const body = await res.json() as { data: Array<{ id: string, prefix: string }> } - const match = body.data.find(s => s.prefix !== '' && primaryToken.startsWith(s.prefix)) + const body = (await res.json()) as { data: Array<{ id: string; prefix: string }> } + const match = body.data.find((s) => s.prefix !== '' && primaryToken.startsWith(s.prefix)) if (!match) { - console.warn('[E2E global-setup] Could not resolve token_id — devicesToken selfHit detection may not work') - } - else { + console.warn( + '[E2E global-setup] Could not resolve token_id — devicesToken selfHit detection may not work', + ) + } else { console.warn(`[E2E] Resolved token_id: ${match.id}`) } // ── Discover workspaces ────────────────────────────────────────────────── - const workspaces = await discoverWorkspaces( - consoleBase, - cookieString, - csrfToken, - E.edition, - ) + const workspaces = await discoverWorkspaces(consoleBase, cookieString, csrfToken, E.edition) if (!workspaces) { // @ts-expect-error — ProvidedContext augmentation cannot be expressed without // triggering TS2300 or TS2664 under tsgo; safe at runtime. @@ -198,24 +193,19 @@ export async function setup(project: TestProject): Promise<void> { let devicesToken = '' const mint = (label: string) => mintTokenWithSession(consoleBase, cookieString, csrfToken, label) - const [lt, dt] = await Promise.allSettled([ - mint('e2e-logout-suite'), - mint('e2e-devices-suite'), - ]) + const [lt, dt] = await Promise.allSettled([mint('e2e-logout-suite'), mint('e2e-devices-suite')]) if (lt.status === 'fulfilled') { logoutToken = lt.value console.warn(`[E2E] logoutToken minted: ${logoutToken.slice(0, 20)}…`) - } - else { + } else { console.warn(`[E2E global-setup] Failed to mint logoutToken: ${lt.reason}`) } if (dt.status === 'fulfilled') { devicesToken = dt.value console.warn(`[E2E] devicesToken minted: ${devicesToken.slice(0, 20)}…`) - } - else { + } else { console.warn(`[E2E global-setup] Failed to mint devicesToken: ${dt.reason}`) } @@ -240,18 +230,18 @@ export async function setup(project: TestProject): Promise<void> { const envAppIds: Record<string, string> = {} for (const key of preProvisioned) { const val = process.env[key] - if (val && val !== '') - envAppIds[key] = val + if (val && val !== '') envAppIds[key] = val } - const allPreset = preProvisioned.every(k => envAppIds[k] !== undefined) + const allPreset = preProvisioned.every((k) => envAppIds[k] !== undefined) if (allPreset) { // All app IDs already available via env — skip provisioning to avoid // race conditions in parallel CI jobs. provisionedIds = envAppIds - console.warn(`[E2E global-setup] App IDs pre-set via env — skipping provisionApps (${Object.keys(provisionedIds).length} apps)`) - } - else { + console.warn( + `[E2E global-setup] App IDs pre-set via env — skipping provisionApps (${Object.keys(provisionedIds).length} apps)`, + ) + } else { try { const fixturesDir = join(fileURLToPath(import.meta.url), '..', '..', 'fixtures', 'apps') provisionedIds = await provisionApps( @@ -267,9 +257,10 @@ export async function setup(project: TestProject): Promise<void> { E.email, primaryWsName, ) - console.warn(`[E2E global-setup] Provisioned ${Object.keys(provisionedIds).length} fixture apps`) - } - catch (err) { + console.warn( + `[E2E global-setup] Provisioned ${Object.keys(provisionedIds).length} fixture apps`, + ) + } catch (err) { console.warn(`[E2E global-setup] provisionApps failed (non-fatal): ${err}`) } } @@ -292,7 +283,8 @@ export async function setup(project: TestProject): Promise<void> { reasoningAppId: provisionedIds.DIFY_E2E_REASONING_APP_ID || E.reasoningAppId, hitlAppId: provisionedIds.DIFY_E2E_HITL_APP_ID || E.hitlAppId, hitlExternalAppId: provisionedIds.DIFY_E2E_HITL_EXTERNAL_APP_ID || E.hitlExternalAppId, - hitlSingleActionAppId: provisionedIds.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID || E.hitlSingleActionAppId, + hitlSingleActionAppId: + provisionedIds.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID || E.hitlSingleActionAppId, hitlMultiNodeAppId: provisionedIds.DIFY_E2E_HITL_MULTI_NODE_APP_ID || E.hitlMultiNodeAppId, ws2AppId: provisionedIds.DIFY_E2E_WS2_APP_ID || E.ws2AppId, } @@ -313,7 +305,11 @@ export { teardown } from './global-teardown.js' * Tries /init (fresh server) first, then falls back to /register. * A 409 "already exists" response is treated as success. */ -async function ceRegisterAccount(consoleBase: string, email: string, password: string): Promise<void> { +async function ceRegisterAccount( + consoleBase: string, + email: string, + password: string, +): Promise<void> { const passwordB64 = Buffer.from(password, 'utf8').toString('base64') const name = email.split('@')[0] ?? 'e2e-user' @@ -340,8 +336,7 @@ async function ceRegisterAccount(consoleBase: string, email: string, password: s console.warn( `[E2E CE] /register returned HTTP ${registerRes.status} — account may already exist; continuing`, ) - } - else { + } else { console.warn(`[E2E CE] Account ready via /register (status ${registerRes.status})`) } } @@ -356,7 +351,7 @@ async function consoleLogin( consoleBase: string, email: string, password: string, -): Promise<{ cookieString: string, csrfToken: string }> { +): Promise<{ cookieString: string; csrfToken: string }> { const passwordB64 = Buffer.from(password, 'utf8').toString('base64') const loginRes = await fetch(`${consoleBase}/console/api/login`, { method: 'POST', @@ -364,11 +359,10 @@ async function consoleLogin( body: JSON.stringify({ email, password: passwordB64, remember_me: false }), signal: AbortSignal.timeout(15_000), }) - if (!loginRes.ok) - throw new Error(`console/api/login failed: HTTP ${loginRes.status}`) + if (!loginRes.ok) throw new Error(`console/api/login failed: HTTP ${loginRes.status}`) const setCookies = loginRes.headers.getSetCookie?.() ?? [] - const cookieString = setCookies.map(c => c.split(';')[0]).join('; ') + const cookieString = setCookies.map((c) => c.split(';')[0]).join('; ') const csrfToken = (cookieString.match(/csrf_token=([^;]+)/) ?? [])[1] ?? '' return { cookieString, csrfToken } } @@ -391,29 +385,28 @@ async function discoverWorkspaces( cookieString: string, csrfToken: string, edition: 'ce' | 'ee', -): Promise<{ primaryWsId: string, primaryWsName: string, secondaryWsId: string } | null> { +): Promise<{ primaryWsId: string; primaryWsName: string; secondaryWsId: string } | null> { const wsRes = await fetch(`${consoleBase}/console/api/workspaces`, { - headers: { 'Cookie': cookieString, 'X-CSRF-Token': csrfToken }, + headers: { Cookie: cookieString, 'X-CSRF-Token': csrfToken }, signal: AbortSignal.timeout(10_000), }) - if (!wsRes.ok) - throw new Error(`list workspaces failed: HTTP ${wsRes.status}`) + if (!wsRes.ok) throw new Error(`list workspaces failed: HTTP ${wsRes.status}`) - const wsBody = await wsRes.json() as { - workspaces?: Array<{ id: string, name: string }> + const wsBody = (await wsRes.json()) as { + workspaces?: Array<{ id: string; name: string }> } const all = wsBody.workspaces ?? [] if (edition === 'ee') { // EE: must find the two pre-created workspaces by exact name - const ws0 = all.find(w => w.name === 'auto_test0') - const ws1 = all.find(w => w.name === 'auto_test1') + const ws0 = all.find((w) => w.name === 'auto_test0') + const ws1 = all.find((w) => w.name === 'auto_test1') if (!ws0 || !ws1) { - const existing = all.map(w => w.name).join(', ') || '(none)' + const existing = all.map((w) => w.name).join(', ') || '(none)' console.warn( - `[E2E EE] Required workspaces not found; expected auto_test0 and auto_test1, got: ${existing}. ` - + 'Skip fixture app provisioning.', + `[E2E EE] Required workspaces not found; expected auto_test0 and auto_test1, got: ${existing}. ` + + 'Skip fixture app provisioning.', ) return null } @@ -430,7 +423,7 @@ async function discoverWorkspaces( // CE: look for workspaces with "auto" in the name, sorted alphabetically const autoWorkspaces = all - .filter(w => w.name.toLowerCase().includes('auto')) + .filter((w) => w.name.toLowerCase().includes('auto')) .sort((a, b) => a.name.localeCompare(b.name)) if (autoWorkspaces.length > 0) { @@ -440,14 +433,12 @@ async function discoverWorkspaces( console.warn(`[E2E CE] primary workspace: ${primaryWsName} (${primaryWsId})`) if (autoWorkspaces[1]) console.warn(`[E2E CE] secondary workspace: ${autoWorkspaces[1].name} (${secondaryWsId})`) - else - console.warn('[E2E CE] only one "auto" workspace found — ws2 reuses primary') + else console.warn('[E2E CE] only one "auto" workspace found — ws2 reuses primary') return { primaryWsId, primaryWsName, secondaryWsId } } // CE fallback: use the first available workspace - if (all.length === 0) - throw new Error('[E2E CE] No workspaces found for this account') + if (all.length === 0) throw new Error('[E2E CE] No workspaces found for this account') const primaryWsId = all[0]!.id const primaryWsName = all[0]!.name @@ -490,7 +481,7 @@ async function provisionApps( const NEEDS_PUBLISH = new Set(['workflow', 'advanced-chat', 'agent-chat']) const mkHeaders = (extra: Record<string, string> = {}): Record<string, string> => ({ - 'Cookie': cookieString, + Cookie: cookieString, 'X-CSRF-Token': csrfToken, ...extra, }) @@ -509,7 +500,13 @@ async function provisionApps( // requires the workspace to have a default chat model configured. Off by // default to keep the shared bootstrap free of any model dependency. ...(process.env.DIFY_E2E_REASONING_PROVISION === '1' - ? [['reasoning-chat.yml', 'DIFY_E2E_REASONING_APP_ID', primaryWsId] as [string, string, string]] + ? [ + ['reasoning-chat.yml', 'DIFY_E2E_REASONING_APP_ID', primaryWsId] as [ + string, + string, + string, + ], + ] : []), ...(edition === 'ee' ? [['ws2-workflow.yml', 'DIFY_E2E_WS2_APP_ID', secondaryWsId] as [string, string, string]] @@ -533,8 +530,7 @@ async function provisionApps( if (result.exitCode !== 0) throw new Error(`import studio-app failed (exit ${result.exitCode}): ${result.stderr}`) const match = result.stderr.match(/app ([0-9a-f-]{36})/) - if (!match?.[1]) - throw new Error(`import studio-app: could not parse app_id: ${result.stderr}`) + if (!match?.[1]) throw new Error(`import studio-app: could not parse app_id: ${result.stderr}`) return match[1] } @@ -545,17 +541,15 @@ async function provisionApps( body: JSON.stringify({ tenant_id: wsId }), signal: AbortSignal.timeout(10_000), }) - if (!r.ok) - throw new Error(`workspace switch to ${wsId} failed: HTTP ${r.status}`) + if (!r.ok) throw new Error(`workspace switch to ${wsId} failed: HTTP ${r.status}`) } async function findAppByName(name: string): Promise<string | null> { const url = `${consoleBase}/console/api/apps?name=${encodeURIComponent(name)}&limit=50&page=1` const r = await fetch(url, { headers: mkHeaders(), signal: AbortSignal.timeout(10_000) }) - if (!r.ok) - throw new Error(`list apps by name "${name}" failed: HTTP ${r.status}`) - const d = await r.json() as { data?: Array<{ id: string, name: string }> } - return d.data?.find(a => a.name === name)?.id ?? null + if (!r.ok) throw new Error(`list apps by name "${name}" failed: HTTP ${r.status}`) + const d = (await r.json()) as { data?: Array<{ id: string; name: string }> } + return d.data?.find((a) => a.name === name)?.id ?? null } async function enableApi(appId: string): Promise<void> { @@ -586,14 +580,14 @@ async function provisionApps( }) if (r.ok) { console.warn(`[E2E provision] setAppPublic(${appId}): access_mode → public`) - } - else { + } else { // CE servers return 404 here — non-fatal const text = await r.text().catch(() => '') - console.warn(`[E2E provision] setAppPublic(${appId}) skipped: HTTP ${r.status} ${text.slice(0, 100)}`) + console.warn( + `[E2E provision] setAppPublic(${appId}) skipped: HTTP ${r.status} ${text.slice(0, 100)}`, + ) } - } - catch (err) { + } catch (err) { console.warn(`[E2E provision] setAppPublic(${appId}) error (non-fatal): ${err}`) } } @@ -609,28 +603,26 @@ async function provisionApps( } const dsl = await readFile(join(fixturesDir, dslFile), 'utf8') - const appName = (dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1] - ?.trim() - .replace(/^['"]|['"]$/g, '') ?? dslFile + const appName = + (dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1] + ?.trim() + .replace(/^['"]|['"]$/g, '') ?? dslFile const appMode = (dsl.match(/^\s+mode:\s*(\S+)/m) ?? [])[1] ?? '' let appId = await findAppByName(appName) if (appId) { console.warn(`[E2E provision] ${dslFile}: exists in workspace id=${appId}; skip import`) - } - else { + } else { appId = await importAppCli(join(fixturesDir, dslFile), wsId) console.warn(`[E2E provision] ${dslFile}: imported id=${appId}`) } await enableApi(appId) await setAppPublic(appId) - if (NEEDS_PUBLISH.has(appMode)) - await publishWorkflow(appId) + if (NEEDS_PUBLISH.has(appMode)) await publishWorkflow(appId) results[envVar] = appId - } - catch (err) { + } catch (err) { console.warn(`[E2E provision] ${dslFile} skipped: ${err}`) } } @@ -656,9 +648,11 @@ async function mintTokenWithSession( body: JSON.stringify({ client_id: 'difyctl', device_label: label }), signal: AbortSignal.timeout(15_000), }) - if (!codeRes.ok) - throw new Error(`device/code failed: HTTP ${codeRes.status}`) - const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string } + if (!codeRes.ok) throw new Error(`device/code failed: HTTP ${codeRes.status}`) + const { device_code, user_code } = (await codeRes.json()) as { + device_code: string + user_code: string + } // Step 2 — approve const approveRes = await approveDeviceCodeWithRetry({ @@ -667,8 +661,7 @@ async function mintTokenWithSession( csrfToken, userCode: user_code, }) - if (!approveRes.ok) - throw new Error(`device/approve failed: HTTP ${approveRes.status}`) + if (!approveRes.ok) throw new Error(`device/approve failed: HTTP ${approveRes.status}`) // Step 3 — exchange for bearer token const tokenRes = await fetch(`${consoleBase}/openapi/v1/oauth/device/token`, { @@ -677,10 +670,9 @@ async function mintTokenWithSession( body: JSON.stringify({ device_code, client_id: 'difyctl' }), signal: AbortSignal.timeout(10_000), }) - if (!tokenRes.ok) - throw new Error(`device/token failed: HTTP ${tokenRes.status}`) + if (!tokenRes.ok) throw new Error(`device/token failed: HTTP ${tokenRes.status}`) - const tokenBody = await tokenRes.json() as { token?: string, error?: string } + const tokenBody = (await tokenRes.json()) as { token?: string; error?: string } if (!tokenBody.token) throw new Error(`device/token response missing token: ${JSON.stringify(tokenBody)}`) @@ -699,18 +691,19 @@ async function approveDeviceCodeWithRetry(opts: { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Cookie': opts.cookieString, + Cookie: opts.cookieString, 'X-CSRFToken': opts.csrfToken, }, body: JSON.stringify({ user_code: opts.userCode }), signal: AbortSignal.timeout(10_000), }) - if (response.ok || !isRetryableApproveStatus(response.status)) - return response + if (response.ok || !isRetryableApproveStatus(response.status)) return response lastResponse = response const delayMs = TOKEN_MINT_RETRY_BASE_MS * attempt - console.warn(`[E2E] device approve HTTP ${response.status}; retrying in ${delayMs}ms (${attempt}/${TOKEN_MINT_APPROVE_ATTEMPTS})`) + console.warn( + `[E2E] device approve HTTP ${response.status}; retrying in ${delayMs}ms (${attempt}/${TOKEN_MINT_APPROVE_ATTEMPTS})`, + ) await sleep(delayMs) } return lastResponse ?? new Response(null, { status: 429 }) @@ -721,5 +714,5 @@ function isRetryableApproveStatus(status: number): boolean { } function sleep(ms: number): Promise<void> { - return new Promise(resolve => setTimeout(resolve, ms)) + return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts b/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts index dd83592be36790..c143034ea34700 100644 --- a/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts +++ b/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts @@ -63,8 +63,7 @@ describe('E2E / agent skill — bootstrap + discovery (no auth)', () => { expect(skillR.exitCode).toBe(0) const ALLOWED = new Set(['resume app', 'skills install', 'version']) for (const { command } of commands) { - if (ALLOWED.has(command)) - continue + if (ALLOWED.has(command)) continue expect(skillR.stdout, `skill must not enumerate "${command}"`).not.toContain(command) } }) @@ -105,7 +104,7 @@ describe('E2E / agent skill — bootstrap + discovery (no auth)', () => { it('[P0] every command in help -o json has args, flags, examples arrays', async () => { const { commands } = JSON.parse((await run(['help', '-o', 'json'])).stdout) as { - commands: Array<{ command: string, args: unknown, flags: unknown, examples: unknown }> + commands: Array<{ command: string; args: unknown; flags: unknown; examples: unknown }> } for (const cmd of commands) { expect(Array.isArray(cmd.args), `${cmd.command}.args`).toBe(true) @@ -138,7 +137,10 @@ describe('E2E / agent skill — bootstrap + discovery (no auth)', () => { }) it('[P1] effect=read for get app, describe app', async () => { - for (const cmd of [['help', 'get', 'app', '-o', 'json'], ['help', 'describe', 'app', '-o', 'json']]) { + for (const cmd of [ + ['help', 'get', 'app', '-o', 'json'], + ['help', 'describe', 'app', '-o', 'json'], + ]) { const r = await run(cmd) expect(r.exitCode).toBe(0) expect(JSON.parse(r.stdout).effect).toBe('read') @@ -154,7 +156,14 @@ describe('E2E / agent skill — bootstrap + discovery (no auth)', () => { it('[P1] `help agent` covers DISCOVERY, AUTH, EXIT CODES, ERRORS, HUMAN-IN-THE-LOOP, RETRY', async () => { const r = await run(['help', 'agent']) expect(r.exitCode).toBe(0) - for (const section of ['DISCOVERY', 'AUTH', 'EXIT CODES', 'ERRORS', 'HUMAN-IN-THE-LOOP', 'RETRY']) + for (const section of [ + 'DISCOVERY', + 'AUTH', + 'EXIT CODES', + 'ERRORS', + 'HUMAN-IN-THE-LOOP', + 'RETRY', + ]) expect(r.stdout, `missing section: ${section}`).toContain(section) }) }) @@ -169,8 +178,9 @@ describe('E2E / agent skill — auth error handling (no token)', () => { try { const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) expect(r.exitCode).toBe(4) + } finally { + await tc.cleanup() } - finally { await tc.cleanup() } }) it('[P0] no token + -o json → stderr is parseable JSON error envelope', async () => { @@ -178,8 +188,9 @@ describe('E2E / agent skill — auth error handling (no token)', () => { try { const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) assertErrorEnvelope(r) + } finally { + await tc.cleanup() } - finally { await tc.cleanup() } }) it('[P0] error envelope has hint field pointing to recovery action', async () => { @@ -190,8 +201,9 @@ describe('E2E / agent skill — auth error handling (no token)', () => { expect(typeof env.error.hint).toBe('string') expect(env.error.hint!.length).toBeGreaterThan(0) expect(env.error.hint).toMatch(/auth login|DIFY_TOKEN/i) + } finally { + await tc.cleanup() } - finally { await tc.cleanup() } }) it('[P0] no token → stdout is empty (error only on stderr)', async () => { @@ -199,8 +211,9 @@ describe('E2E / agent skill — auth error handling (no token)', () => { try { const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) expect(r.stdout.trim()).toBe('') + } finally { + await tc.cleanup() } - finally { await tc.cleanup() } }) it('[P1] usage error (bad flag) → non-zero exit, not exit 4 (agent can distinguish auth vs usage)', async () => { @@ -212,8 +225,9 @@ describe('E2E / agent skill — auth error handling (no token)', () => { const r = await run(['get', 'app', '--unknown-flag-xyz-e2e'], { configDir: tc.configDir }) expect(r.exitCode).not.toBe(0) expect(r.exitCode).not.toBe(4) + } finally { + await tc.cleanup() } - finally { await tc.cleanup() } }) it('[P1] stderr is pure JSON on auth error — entire trim() parses as JSON', async () => { @@ -221,8 +235,9 @@ describe('E2E / agent skill — auth error handling (no token)', () => { try { const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) expect(() => JSON.parse(r.stderr.trim())).not.toThrow() + } finally { + await tc.cleanup() } - finally { await tc.cleanup() } }) }) @@ -249,8 +264,9 @@ describe('E2E / agent skill — get app -o json (auth required)', () => { const r = await fx.r(['get', 'app', '-o', 'json']) assertExitCode(r, 0) const p = assertJson<unknown>(r) - const isIterable = Array.isArray(p) - || (typeof p === 'object' && p !== null && Array.isArray((p as Record<string, unknown>).data)) + const isIterable = + Array.isArray(p) || + (typeof p === 'object' && p !== null && Array.isArray((p as Record<string, unknown>).data)) expect(isIterable).toBe(true) }) @@ -283,9 +299,11 @@ describe('E2E / agent skill — get app -o json (auth required)', () => { const r = await fx.r(['get', 'app', '-o', 'name']) assertExitCode(r, 0) assertNoAnsi(r.stdout, 'stdout') - const lines = r.stdout.trim().split('\n').filter(l => l.trim().length > 0) - for (const line of lines) - expect(line.trim()).not.toMatch(/\s/) + const lines = r.stdout + .trim() + .split('\n') + .filter((l) => l.trim().length > 0) + for (const line of lines) expect(line.trim()).not.toMatch(/\s/) }) itWithSso('[P0] [SSO] dfoe_ get app -o json → permitted-apps list envelope', async () => { @@ -311,8 +329,9 @@ describe('E2E / agent skill — get app -o json (auth required)', () => { assertExitCode(r, 0) const parsed = assertJson<{ data: unknown[] }>(r) expect(Array.isArray(parsed.data), 'permitted-apps envelope has a data array').toBe(true) + } finally { + await tc.cleanup() } - finally { await tc.cleanup() } }) }) @@ -340,16 +359,19 @@ describe('E2E / agent skill — describe app -o json (auth required)', () => { assertExitCode(r, 0) const desc = assertJson<Record<string, unknown>>(r) // describe app wraps mode under info: { info: { mode, name, ... }, parameters, input_schema } - expect((desc.info as Record<string, unknown>)).toHaveProperty('mode') + expect(desc.info as Record<string, unknown>).toHaveProperty('mode') }) - itWithWorkflow('[P0] workflow app response has input schema — agent reads before run', async () => { - const r = await fx.r(['describe', 'app', E.workflowAppId, '-o', 'json']) - assertExitCode(r, 0) - const d = assertJson<Record<string, unknown>>(r) - const hasSchema = 'user_input_form' in d || 'parameters' in d || 'inputs' in d - expect(hasSchema, 'describe response must contain input schema').toBe(true) - }) + itWithWorkflow( + '[P0] workflow app response has input schema — agent reads before run', + async () => { + const r = await fx.r(['describe', 'app', E.workflowAppId, '-o', 'json']) + assertExitCode(r, 0) + const d = assertJson<Record<string, unknown>>(r) + const hasSchema = 'user_input_form' in d || 'parameters' in d || 'inputs' in d + expect(hasSchema, 'describe response must contain input schema').toBe(true) + }, + ) itWithChat('[P0] stdout has no ANSI — pipe-safe', async () => { const r = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json']) @@ -381,10 +403,11 @@ describe('E2E / agent skill — run app -o json (auth required)', () => { }) itWithChat('[P0] run chat app -o json → exit 0, valid JSON with answer field', async () => { - const r = await withRetry( - () => fx.r(['run', 'app', E.chatAppId, 'hello', '-o', 'json']), - { attempts: 5, delayMs: 4000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) }, - ) + const r = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'hello', '-o', 'json']), { + attempts: 5, + delayMs: 4000, + shouldRetry: (err) => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), + }) assertExitCode(r, 0) const p = assertJson<Record<string, unknown>>(r) expect(p).toHaveProperty('answer') @@ -393,31 +416,51 @@ describe('E2E / agent skill — run app -o json (auth required)', () => { itWithWorkflow('[P0] run workflow -o json → exit 0, JSON contains outputs', async () => { const r = await withRetry( - () => fx.r(['run', 'app', E.workflowAppId, '--inputs', JSON.stringify({ x: 'agent-e2e', num: 1, enum_var: 'A', paragraph: 'ok' }), '-o', 'json']), - { attempts: 5, delayMs: 4000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) }, + () => + fx.r([ + 'run', + 'app', + E.workflowAppId, + '--inputs', + JSON.stringify({ x: 'agent-e2e', num: 1, enum_var: 'A', paragraph: 'ok' }), + '-o', + 'json', + ]), + { + attempts: 5, + delayMs: 4000, + shouldRetry: (err) => + err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), + }, ) assertExitCode(r, 0) const p = assertJson<Record<string, unknown>>(r) - const hasOutputs = 'outputs' in p - || ('data' in p && typeof p.data === 'object' && p.data !== null && 'outputs' in (p.data as object)) + const hasOutputs = + 'outputs' in p || + ('data' in p && + typeof p.data === 'object' && + p.data !== null && + 'outputs' in (p.data as object)) expect(hasOutputs, 'workflow -o json must contain outputs').toBe(true) }) itWithChat('[P0] stdout has no ANSI — agent can JSON.parse directly', async () => { - const r = await withRetry( - () => fx.r(['run', 'app', E.chatAppId, 'pipe-test', '-o', 'json']), - { attempts: 5, delayMs: 4000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) }, - ) + const r = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'pipe-test', '-o', 'json']), { + attempts: 5, + delayMs: 4000, + shouldRetry: (err) => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), + }) assertExitCode(r, 0) assertNoAnsi(r.stdout, 'stdout') assertPipeFriendlyJson(r) }) itWithChat('[P0] stderr is empty on success', async () => { - const r = await withRetry( - () => fx.r(['run', 'app', E.chatAppId, 'clean-test', '-o', 'json']), - { attempts: 5, delayMs: 4000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) }, - ) + const r = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'clean-test', '-o', 'json']), { + attempts: 5, + delayMs: 4000, + shouldRetry: (err) => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), + }) assertExitCode(r, 0) expect(r.stderr.trim()).toBe('') }) @@ -443,13 +486,16 @@ describe('E2E / agent skill — error handling for agent branching (auth require expect(r.stdout.trim()).toBe('') }) - itWithAuth('[P0] error.code is stable across repeated calls (agent can cache branch logic)', async () => { - const r1 = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json']) - const r2 = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json']) - const e1 = assertErrorEnvelope(r1) - const e2 = assertErrorEnvelope(r2) - expect(e1.error.code).toBe(e2.error.code) - }) + itWithAuth( + '[P0] error.code is stable across repeated calls (agent can cache branch logic)', + async () => { + const r1 = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json']) + const r2 = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json']) + const e1 = assertErrorEnvelope(r1) + const e2 = assertErrorEnvelope(r2) + expect(e1.error.code).toBe(e2.error.code) + }, + ) itWithAuth('[P0] entire stderr (trimmed) is parseable JSON — no mixed text prefix', async () => { const r = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json']) @@ -486,7 +532,11 @@ async function runHitlPause(fx: AuthFixture, input: string): Promise<RunResult> throw new Error(`transient HITL run failure: ${result.stderr.slice(0, 200)}`) return result }, - { attempts: 5, delayMs: 4000, shouldRetry: err => err instanceof Error && HITL_TRANSIENT_RE.test(err.message) }, + { + attempts: 5, + delayMs: 4000, + shouldRetry: (err) => err instanceof Error && HITL_TRANSIENT_RE.test(err.message), + }, ) } @@ -499,10 +549,13 @@ describe('E2E / agent skill — HITL pause handling (auth required)', () => { await fx.cleanup() }) - itWithHitl('[P0] HITL app exits 0 and returns paused payload — agent resumes rather than retries', async () => { - const r = await runHitlPause(fx, 'agent-hitl-exit') - assertExitCode(r, 0) - }) + itWithHitl( + '[P0] HITL app exits 0 and returns paused payload — agent resumes rather than retries', + async () => { + const r = await runHitlPause(fx, 'agent-hitl-exit') + assertExitCode(r, 0) + }, + ) itWithHitl('[P0] HITL stdout contains status:paused JSON payload', async () => { const r = await runHitlPause(fx, 'agent-hitl-status') @@ -537,7 +590,10 @@ describe('E2E / agent skill — effect guard (no auth)', () => { }) it('[P0] get app and describe app effect=read — agent can call freely', async () => { - for (const args of [['help', 'get', 'app', '-o', 'json'], ['help', 'describe', 'app', '-o', 'json']]) { + for (const args of [ + ['help', 'get', 'app', '-o', 'json'], + ['help', 'describe', 'app', '-o', 'json'], + ]) { const r = await run(args) expect(r.exitCode).toBe(0) expect(JSON.parse(r.stdout).effect).toBe('read') @@ -546,10 +602,10 @@ describe('E2E / agent skill — effect guard (no auth)', () => { it('[P0] no command in the full tree has undefined/null effect', async () => { const { commands } = JSON.parse((await run(['help', '-o', 'json'])).stdout) as { - commands: Array<{ command: string, effect: unknown }> + commands: Array<{ command: string; effect: unknown }> } - const bad = commands.filter(c => !c.effect || typeof c.effect !== 'string') - expect(bad.map(c => c.command)).toEqual([]) + const bad = commands.filter((c) => !c.effect || typeof c.effect !== 'string') + expect(bad.map((c) => c.command)).toEqual([]) }) it('[P1] skills install effect=write', async () => { @@ -588,7 +644,12 @@ describe('E2E / agent skill — pipeline safety (auth required)', () => { itWithChat('[P0] stdout is non-empty on success under -o json', async () => { const r = await withRetry( () => fx.r(['run', 'app', E.chatAppId, 'pipeline-test', '-o', 'json']), - { attempts: 5, delayMs: 4000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) }, + { + attempts: 5, + delayMs: 4000, + shouldRetry: (err) => + err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), + }, ) assertExitCode(r, 0) expect(r.stdout.trim().length).toBeGreaterThan(0) @@ -600,8 +661,9 @@ describe('E2E / agent skill — pipeline safety (auth required)', () => { try { const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) assertNoAnsi(r.stderr, 'stderr') + } finally { + await tc.cleanup() } - finally { await tc.cleanup() } }) itWithAuth('[P1] stdout ends with newline (POSIX pipe convention)', async () => { diff --git a/cli/test/e2e/suites/auth/devices.e2e.ts b/cli/test/e2e/suites/auth/devices.e2e.ts index a8185970e2d162..2bd1b9c6442456 100644 --- a/cli/test/e2e/suites/auth/devices.e2e.ts +++ b/cli/test/e2e/suites/auth/devices.e2e.ts @@ -64,20 +64,25 @@ describe('E2E / difyctl auth devices', () => { // Spec: devices list supports JSON output const result = await r(['auth', 'devices', 'list', '--json']) assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown[], total: number }>(result) + const parsed = assertJson<{ data: unknown[]; total: number }>(result) expect(parsed).toHaveProperty('data') expect(Array.isArray(parsed.data)).toBe(true) }) - itSessions('[P1] devices list JSON schema is stable (contains data and total fields)', async () => { - // Spec: devices list JSON schema is stable - const result = await r(['auth', 'devices', 'list', '--json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown[], total: number, page: number, limit: number }>(result) - expect(parsed).toHaveProperty('total') - expect(parsed).toHaveProperty('page') - expect(parsed).toHaveProperty('limit') - }) + itSessions( + '[P1] devices list JSON schema is stable (contains data and total fields)', + async () => { + // Spec: devices list JSON schema is stable + const result = await r(['auth', 'devices', 'list', '--json']) + assertExitCode(result, 0) + const parsed = assertJson<{ data: unknown[]; total: number; page: number; limit: number }>( + result, + ) + expect(parsed).toHaveProperty('total') + expect(parsed).toHaveProperty('page') + expect(parsed).toHaveProperty('limit') + }, + ) it('[P0] unauthenticated devices list returns auth error (exit code 4)', async () => { // Spec: unauthenticated devices list returns auth error + exit code 4 @@ -86,8 +91,7 @@ describe('E2E / difyctl auth devices', () => { const result = await run(['auth', 'devices', 'list'], { configDir: unauthTmp.configDir }) assertExitCode(result, 4) expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i) - } - finally { + } finally { await unauthTmp.cleanup() } }) @@ -119,10 +123,10 @@ describe('E2E / difyctl auth devices', () => { // List sessions authenticated as the fresh token const listResult = await revokeR(['auth', 'devices', 'list', '--json']) assertExitCode(listResult, 0) - const { data } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listResult) + const { data } = assertJson<{ data: Array<{ id: string; prefix: string }> }>(listResult) // Find the entry whose prefix matches the fresh token - const entry = data.find(d => d.prefix && freshToken.startsWith(d.prefix)) + const entry = data.find((d) => d.prefix && freshToken.startsWith(d.prefix)) if (!entry) { // Fresh session not found — may have been filtered; skip gracefully. return @@ -130,8 +134,7 @@ describe('E2E / difyctl auth devices', () => { const revokeResult = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes']) assertExitCode(revokeResult, 0) - } - finally { + } finally { await revokeTmp.cleanup() } }) @@ -163,7 +166,7 @@ describe('E2E / difyctl auth devices', () => { assertExitCode(result, 0) const parsed = assertJson<{ data: Array<{ last_used_at: string | null }> }>(result) expect(parsed.data.length).toBeGreaterThan(0) - const hasNullLastUsed = parsed.data.some(d => d.last_used_at === null) + const hasNullLastUsed = parsed.data.some((d) => d.last_used_at === null) expect(hasNullLastUsed).toBe(true) }) @@ -172,8 +175,7 @@ describe('E2E / difyctl auth devices', () => { itSessions('[P0] revoked device no longer appears in devices list', async () => { // Spec 1.99: a revoked device no longer appears in devices list const freshToken = await mintFreshToken(E.host, E.email, E.password) - if (!freshToken) - return + if (!freshToken) return const revokeTmp = await withTempConfig() try { @@ -188,10 +190,11 @@ describe('E2E / difyctl auth devices', () => { const listBefore = await revokeR(['auth', 'devices', 'list', '--json']) assertExitCode(listBefore, 0) - const { data: before } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listBefore) - const entry = before.find(d => d.prefix && freshToken.startsWith(d.prefix)) - if (!entry) - return + const { data: before } = assertJson<{ data: Array<{ id: string; prefix: string }> }>( + listBefore, + ) + const entry = before.find((d) => d.prefix && freshToken.startsWith(d.prefix)) + if (!entry) return const revokeResult = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes']) assertExitCode(revokeResult, 0) @@ -200,57 +203,62 @@ describe('E2E / difyctl auth devices', () => { const listAfter = await r(['auth', 'devices', 'list', '--json']) assertExitCode(listAfter, 0) const { data: after } = assertJson<{ data: Array<{ id: string }> }>(listAfter) - const stillExists = after.some(d => d.id === entry.id) + const stillExists = after.some((d) => d.id === entry.id) expect(stillExists).toBe(false) - } - finally { + } finally { await revokeTmp.cleanup() } }) // ── Revoke current device → session invalidated ────────────────────────────── - itSessions('[P0] revoking the current device invalidates the session (auth status returns exit 4)', async () => { - // Spec 1.100: revoking the current device invalidates the session - // Uses caps.devicesToken (disposable, pre-minted for this suite). - const selfToken = caps.devicesToken - if (!selfToken) - return - - const selfTmp = await withTempConfig() - try { - await injectAuth(selfTmp.configDir, { - host: E.host, - bearer: selfToken, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const selfR = (argv: string[]) => run(argv, { configDir: selfTmp.configDir }) - - const listResult = await selfR(['auth', 'devices', 'list', '--json']) - assertExitCode(listResult, 0) - const { data } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listResult) - const entry = data.find(d => d.prefix && selfToken.startsWith(d.prefix)) - if (!entry) - return - - const revokeResult = await selfR(['auth', 'devices', 'revoke', entry.id, '--yes']) - assertExitCode(revokeResult, 0) - // Revoke succeeded — the session is invalidated on the server. - // Note: the server may cache the token briefly, so immediate API calls - // with the revoked token may still succeed; we verify only that revoke exits 0. - } - finally { - await selfTmp.cleanup() - } - }) + itSessions( + '[P0] revoking the current device invalidates the session (auth status returns exit 4)', + async () => { + // Spec 1.100: revoking the current device invalidates the session + // Uses caps.devicesToken (disposable, pre-minted for this suite). + const selfToken = caps.devicesToken + if (!selfToken) return + + const selfTmp = await withTempConfig() + try { + await injectAuth(selfTmp.configDir, { + host: E.host, + bearer: selfToken, + email: E.email, + workspaceId: E.workspaceId, + workspaceName: E.workspaceName, + }) + const selfR = (argv: string[]) => run(argv, { configDir: selfTmp.configDir }) + + const listResult = await selfR(['auth', 'devices', 'list', '--json']) + assertExitCode(listResult, 0) + const { data } = assertJson<{ data: Array<{ id: string; prefix: string }> }>(listResult) + const entry = data.find((d) => d.prefix && selfToken.startsWith(d.prefix)) + if (!entry) return + + const revokeResult = await selfR(['auth', 'devices', 'revoke', entry.id, '--yes']) + assertExitCode(revokeResult, 0) + // Revoke succeeded — the session is invalidated on the server. + // Note: the server may cache the token briefly, so immediate API calls + // with the revoked token may still succeed; we verify only that revoke exits 0. + } finally { + await selfTmp.cleanup() + } + }, + ) // ── Revoke invalid device id ────────────────────────────────────────────────── itSessions('[P1] revoking a non-existent device id returns an error', async () => { // Spec 1.101: revoking a non-existent device id returns an error - const result = await r(['auth', 'devices', 'revoke', 'invalid-device-id-does-not-exist', '--yes']) + const result = await r([ + 'auth', + 'devices', + 'revoke', + 'invalid-device-id-does-not-exist', + '--yes', + ]) expect(result.exitCode).not.toBe(0) expect(result.stderr).toMatch(/not.?found|invalid|device|error/i) }) @@ -260,8 +268,7 @@ describe('E2E / difyctl auth devices', () => { it('[P0] revoke --all exits 0 and revokes all sessions except the current one', async () => { // Spec 1.102: revoke --all exits 0 and revokes all sessions except the current one const freshToken = await mintFreshToken(E.host, E.email, E.password) - if (!freshToken) - return + if (!freshToken) return const freshTmp = await withTempConfig() try { @@ -275,11 +282,9 @@ describe('E2E / difyctl auth devices', () => { const freshR = (argv: string[]) => run(argv, { configDir: freshTmp.configDir }) const result = await freshR(['auth', 'devices', 'revoke', '--all', '--yes']) // Server may return 500 if other sessions are already revoked; skip gracefully. - if (result.exitCode !== 0) - return + if (result.exitCode !== 0) return assertExitCode(result, 0) - } - finally { + } finally { await freshTmp.cleanup() } }) @@ -287,8 +292,7 @@ describe('E2E / difyctl auth devices', () => { it('[P0] after revoke --all only the current device remains in the list', async () => { // Spec 1.103: after revoke --all only the current device remains const freshToken = await mintFreshToken(E.host, E.email, E.password) - if (!freshToken) - return + if (!freshToken) return const freshTmp = await withTempConfig() try { @@ -303,16 +307,14 @@ describe('E2E / difyctl auth devices', () => { const revokeAllResult = await freshR(['auth', 'devices', 'revoke', '--all', '--yes']) // Server may return 500 if other sessions are already revoked; skip gracefully. - if (revokeAllResult.exitCode !== 0) - return + if (revokeAllResult.exitCode !== 0) return const listResult = await freshR(['auth', 'devices', 'list', '--json']) assertExitCode(listResult, 0) - const parsed = assertJson<{ data: unknown[], total: number }>(listResult) + const parsed = assertJson<{ data: unknown[]; total: number }>(listResult) expect(parsed.total).toBe(1) expect(parsed.data).toHaveLength(1) - } - finally { + } finally { await freshTmp.cleanup() } }) @@ -330,14 +332,13 @@ describe('E2E / difyctl auth devices', () => { workspaceId: 'ws-1', workspaceName: 'Test', }) - const result = await run( - ['auth', 'devices', 'revoke', 'any-device-id', '--yes'], - { configDir: netTmp.configDir, timeout: 10_000 }, - ) + const result = await run(['auth', 'devices', 'revoke', 'any-device-id', '--yes'], { + configDir: netTmp.configDir, + timeout: 10_000, + }) expect(result.exitCode).not.toBe(0) expect(result.stderr).toMatch(/network|unreachable|connect|server|error/i) - } - finally { + } finally { await netTmp.cleanup() } }) @@ -359,12 +360,10 @@ describe('E2E / difyctl auth devices', () => { }) const result = await run(['auth', 'devices', 'list'], { configDir: ssoTmp.configDir }) // ssoToken may be expired (server 500); skip gracefully rather than fail. - if (result.exitCode !== 0) - return + if (result.exitCode !== 0) return assertExitCode(result, 0) expect(result.stdout.length).toBeGreaterThan(0) - } - finally { + } finally { await ssoTmp.cleanup() } }) @@ -374,8 +373,7 @@ describe('E2E / difyctl auth devices', () => { itSessions('[P1] revoking an already-revoked device returns a stable result', async () => { // Spec 1.107: revoking an already-revoked device returns a stable result const freshToken = await mintFreshToken(E.host, E.email, E.password) - if (!freshToken) - return + if (!freshToken) return const revokeTmp = await withTempConfig() try { @@ -390,10 +388,9 @@ describe('E2E / difyctl auth devices', () => { const listResult = await revokeR(['auth', 'devices', 'list', '--json']) assertExitCode(listResult, 0) - const { data } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listResult) - const entry = data.find(d => d.prefix && freshToken.startsWith(d.prefix)) - if (!entry) - return + const { data } = assertJson<{ data: Array<{ id: string; prefix: string }> }>(listResult) + const entry = data.find((d) => d.prefix && freshToken.startsWith(d.prefix)) + if (!entry) return // First revoke const r1 = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes']) @@ -402,8 +399,7 @@ describe('E2E / difyctl auth devices', () => { // Second revoke of the same id — must not crash const r2 = await r(['auth', 'devices', 'revoke', entry.id, '--yes']) expect(r2.exitCode).toBeLessThanOrEqual(4) - } - finally { + } finally { await revokeTmp.cleanup() } }) diff --git a/cli/test/e2e/suites/auth/login.e2e.ts b/cli/test/e2e/suites/auth/login.e2e.ts index aac26939501445..a7879f9507d329 100644 --- a/cli/test/e2e/suites/auth/login.e2e.ts +++ b/cli/test/e2e/suites/auth/login.e2e.ts @@ -44,7 +44,7 @@ describe('E2E / difyctl auth login', () => { await cleanup() }) - function r(argv: string[], extraOpts: { stdin?: string, timeout?: number } = {}) { + function r(argv: string[], extraOpts: { stdin?: string; timeout?: number } = {}) { return run(argv, { configDir, ...extraOpts }) } @@ -54,10 +54,9 @@ describe('E2E / difyctl auth login', () => { // Spec 1.13: when the host is unreachable, CLI returns a server/network error. // 127.0.0.1:19999 has nothing listening — ECONNREFUSED is immediate. // https:// passes the scheme validation; then ECONNREFUSED fires immediately. - const result = await r( - ['auth', 'login', '--host', 'https://127.0.0.1:19999'], - { timeout: 15_000 }, - ) + const result = await r(['auth', 'login', '--host', 'https://127.0.0.1:19999'], { + timeout: 15_000, + }) expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) expect(result.stderr + result.stdout).toMatch( /network|connect|ECONNREFUSED|server|unreachable|refused|fetch/i, @@ -112,7 +111,8 @@ describe('E2E / difyctl auth login', () => { let stdoutBuf = '' const seen = await new Promise<boolean>((resolve) => { const timer = setTimeout(() => resolve(false), 15_000) - const pattern = /[A-Z0-9]{4}-[A-Z0-9]{4}|https?:\/\/|user.?code|verification|one.?time|device|login/i + const pattern = + /[A-Z0-9]{4}-[A-Z0-9]{4}|https?:\/\/|user.?code|verification|one.?time|device|login/i proc.stderr.on('data', (chunk: Buffer) => { stderrBuf += chunk.toString('utf8') if (pattern.test(stderrBuf)) { @@ -135,7 +135,7 @@ describe('E2E / difyctl auth login', () => { }) proc.kill('SIGINT') - await new Promise<void>(res => proc.on('close', () => res())) + await new Promise<void>((res) => proc.on('close', () => res())) expect( seen, @@ -159,14 +159,14 @@ describe('E2E / difyctl auth login', () => { // Use https:// to bypass scheme validation; ECONNREFUSED fires immediately. // The warning may appear before or after the connection error depending on CLI version. - const result = await r( - ['auth', 'login', '--host', 'https://127.0.0.1:19999'], - { timeout: 10_000 }, - ) + const result = await r(['auth', 'login', '--host', 'https://127.0.0.1:19999'], { + timeout: 10_000, + }) const combined = result.stderr + result.stdout // Accept any of: cross-host warning, connection error, or non-zero exit (WTA-254 may not be shipped yet) expect( - result.exitCode !== 0 || /warn|different.?host|already|switch|ECONNREFUSED|refused|connect|network/i.test(combined), + result.exitCode !== 0 || + /warn|different.?host|already|switch|ECONNREFUSED|refused|connect|network/i.test(combined), `Expected non-zero exit or relevant message.\nexitCode: ${result.exitCode}\noutput: ${combined.slice(0, 400)}`, ).toBe(true) }) @@ -201,7 +201,7 @@ describe('E2E / difyctl auth login', () => { }) proc.kill('SIGINT') - await new Promise<void>(res => proc.on('close', () => res())) + await new Promise<void>((res) => proc.on('close', () => res())) expect( promptSeen, @@ -215,15 +215,11 @@ describe('E2E / difyctl auth login', () => { // Spec 1.17: when the user enters a value that is not a valid URL (e.g. "localhost" // without a scheme), CLI reports an error and re-prompts or exits. // We pipe "localhost\n" to stdin so the CLI's prompt handler receives invalid input. - const result = await r( - ['auth', 'login'], - { stdin: 'localhost\n', timeout: 10_000 }, - ) + const result = await r(['auth', 'login'], { stdin: 'localhost\n', timeout: 10_000 }) // Either exit non-0 (usage error) or output contains an error message about the URL format. const combinedOutput = result.stdout + result.stderr - const isValidationError - = result.exitCode !== 0 - || /invalid|url|format|scheme|http|expected/i.test(combinedOutput) + const isValidationError = + result.exitCode !== 0 || /invalid|url|format|scheme|http|expected/i.test(combinedOutput) expect( isValidationError, `Expected non-zero exit or URL validation error.\nexitCode: ${result.exitCode}\noutput: ${combinedOutput.slice(0, 400)}`, diff --git a/cli/test/e2e/suites/auth/logout.e2e.ts b/cli/test/e2e/suites/auth/logout.e2e.ts index 7e2cc5cba7adb2..f7619e81f56c3d 100644 --- a/cli/test/e2e/suites/auth/logout.e2e.ts +++ b/cli/test/e2e/suites/auth/logout.e2e.ts @@ -52,8 +52,9 @@ describe('E2E / difyctl auth logout', () => { try { await access(join(configDir, 'hosts.yml')) return true + } catch { + return false } - catch { return false } } async function expectNoActiveSession(): Promise<void> { diff --git a/cli/test/e2e/suites/auth/status.e2e.ts b/cli/test/e2e/suites/auth/status.e2e.ts index b184f6ad84a32e..50dccfc3965a24 100644 --- a/cli/test/e2e/suites/auth/status.e2e.ts +++ b/cli/test/e2e/suites/auth/status.e2e.ts @@ -76,7 +76,7 @@ describe('E2E / difyctl auth session state', () => { const result = await r(['auth', 'list', '-o', 'json']) assertExitCode(result, 0) const parsed = JSON.parse(result.stdout) as { - contexts: Array<{ host: string, account: string, name: string, active: boolean }> + contexts: Array<{ host: string; account: string; name: string; active: boolean }> } expect(parsed.contexts).toHaveLength(1) expect(parsed.contexts[0]).toMatchObject({ @@ -90,7 +90,7 @@ describe('E2E / difyctl auth session state', () => { await withAuth() const result = await r(['auth', 'whoami', '--json']) assertExitCode(result, 0) - const parsed = JSON.parse(result.stdout) as { id: string, email: string, name: string } + const parsed = JSON.parse(result.stdout) as { id: string; email: string; name: string } expect(parsed).toMatchObject({ id: 'acct-e2e', email: 'e2e@example.com', diff --git a/cli/test/e2e/suites/auth/use.e2e.ts b/cli/test/e2e/suites/auth/use.e2e.ts index 6fe3f35e85ee9a..680cbfc1b92eee 100644 --- a/cli/test/e2e/suites/auth/use.e2e.ts +++ b/cli/test/e2e/suites/auth/use.e2e.ts @@ -43,20 +43,23 @@ describe('E2E / difyctl use workspace', () => { async function switchWorkspace(workspaceId: string): Promise<RunResult | undefined> { try { - return await withRetry(async () => { - const result = await r(['use', 'workspace', workspaceId]) - if (isServer5xx(result)) - throw new Error(result.stderr) - return result - }, { - attempts: 3, - delayMs: 1_000, - shouldRetry: err => /server_5xx|HTTP 5\d\d/i.test(String(err)), - }) - } - catch (err) { + return await withRetry( + async () => { + const result = await r(['use', 'workspace', workspaceId]) + if (isServer5xx(result)) throw new Error(result.stderr) + return result + }, + { + attempts: 3, + delayMs: 1_000, + shouldRetry: (err) => /server_5xx|HTTP 5\d\d/i.test(String(err)), + }, + ) + } catch (err) { if (/server_5xx|HTTP 5\d\d/i.test(String(err))) { - console.warn(`[E2E] workspace switch ${workspaceId} returned persistent server_5xx; skipping server-dependent assertion.`) + console.warn( + `[E2E] workspace switch ${workspaceId} returned persistent server_5xx; skipping server-dependent assertion.`, + ) return undefined } throw err @@ -94,8 +97,7 @@ describe('E2E / difyctl use workspace', () => { // use E.workspaceId (real server id); WS2_ID is synthetic and not on server await withTwoWorkspaces() const result = await switchWorkspace(E.workspaceId) - if (result === undefined) - return + if (result === undefined) return assertExitCode(result, 0) expect(result.stdout).toMatch(/switched|workspace/i) expect(result.stdout).toContain(E.workspaceId) @@ -105,13 +107,11 @@ describe('E2E / difyctl use workspace', () => { // Spec: auth status shows new workspace after auth use await withTwoWorkspaces() const switchResult = await switchWorkspace(E.workspaceId) - if (switchResult === undefined) - return + if (switchResult === undefined) return assertExitCode(switchResult, 0) - const hostsContent = await (await import('node:fs/promises')).readFile( - join(configDir, 'hosts.yml'), - 'utf8', - ) + const hostsContent = await ( + await import('node:fs/promises') + ).readFile(join(configDir, 'hosts.yml'), 'utf8') expect(hostsContent).toContain(E.workspaceId) }) @@ -120,8 +120,7 @@ describe('E2E / difyctl use workspace', () => { // Switch to primary workspace (real server id); verify hosts.yml is updated await withTwoWorkspaces() const switchResult = await switchWorkspace(E.workspaceId) - if (switchResult === undefined) - return + if (switchResult === undefined) return assertExitCode(switchResult, 0) const { readFile } = await import('node:fs/promises') const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8') @@ -132,12 +131,10 @@ describe('E2E / difyctl use workspace', () => { // Spec: switching to the same workspace is idempotent await withTwoWorkspaces() const r1 = await switchWorkspace(E.workspaceId) - if (r1 === undefined) - return + if (r1 === undefined) return assertExitCode(r1, 0) const r2 = await switchWorkspace(E.workspaceId) - if (r2 === undefined) - return + if (r2 === undefined) return assertExitCode(r2, 0) }) diff --git a/cli/test/e2e/suites/auth/whoami.e2e.ts b/cli/test/e2e/suites/auth/whoami.e2e.ts index 69404bb95506ba..ca4e1c52c580c8 100644 --- a/cli/test/e2e/suites/auth/whoami.e2e.ts +++ b/cli/test/e2e/suites/auth/whoami.e2e.ts @@ -150,20 +150,31 @@ describe('E2E / difyctl auth whoami + SSO session', () => { let result: Awaited<ReturnType<typeof r>> try { - result = await withRetry(async () => { - const runResult = await r(['run', 'app', E.chatAppId, 'hello', '--workspace', E.workspaceId]) - if (runResult.exitCode !== 0 && /server_5xx|HTTP 5\d\d/i.test(runResult.stderr)) - throw new Error(runResult.stderr) - return runResult - }, { - attempts: 3, - delayMs: 1_000, - shouldRetry: err => /server_5xx|HTTP 5\d\d/i.test(String(err)), - }) - } - catch (err) { + result = await withRetry( + async () => { + const runResult = await r([ + 'run', + 'app', + E.chatAppId, + 'hello', + '--workspace', + E.workspaceId, + ]) + if (runResult.exitCode !== 0 && /server_5xx|HTTP 5\d\d/i.test(runResult.stderr)) + throw new Error(runResult.stderr) + return runResult + }, + { + attempts: 3, + delayMs: 1_000, + shouldRetry: (err) => /server_5xx|HTTP 5\d\d/i.test(String(err)), + }, + ) + } catch (err) { if (/server_5xx|HTTP 5\d\d/i.test(String(err))) { - console.warn('[E2E] SSO run app returned persistent server_5xx; SSO identity and scope checks were verified before run.') + console.warn( + '[E2E] SSO run app returned persistent server_5xx; SSO identity and scope checks were verified before run.', + ) return } throw err diff --git a/cli/test/e2e/suites/discovery/describe-app.e2e.ts b/cli/test/e2e/suites/discovery/describe-app.e2e.ts index 68902a32da6b37..e6822441166d2e 100644 --- a/cli/test/e2e/suites/discovery/describe-app.e2e.ts +++ b/cli/test/e2e/suites/discovery/describe-app.e2e.ts @@ -83,7 +83,7 @@ describe('E2E / difyctl describe app', () => { // Spec 3.78: -o json → raw describe response containing info + parameters. const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json']) assertExitCode(result, 0) - const parsed = assertJson<{ info: { id: string }, parameters: unknown }>(result) + const parsed = assertJson<{ info: { id: string }; parameters: unknown }>(result) expect(parsed.info?.id, 'info.id should match the queried app').toBe(E.chatAppId) expect(parsed.parameters, 'parameters field must be present').toBeDefined() }) @@ -158,8 +158,7 @@ describe('E2E / difyctl describe app', () => { const result = await run(['describe', 'app', E.chatAppId], { configDir: tmp.configDir }) assertExitCode(result, 4) expect(result.stderr).toMatch(/not.?logged.?in|auth/i) - } - finally { + } finally { await tmp.cleanup() } }) @@ -190,8 +189,7 @@ describe('E2E / difyctl describe app', () => { expect(result.stdout).toMatch(/ID:/i) expect(result.stdout).toContain(E.chatAppId) expect(result.stdout).toMatch(/Mode:/i) - } - finally { + } finally { await ssoTmp.cleanup() } }) @@ -200,10 +198,10 @@ describe('E2E / difyctl describe app', () => { it('[P0] describe output has no ANSI colour codes (non-TTY)', async () => { // withRetry: staging may return transient 500 on cold start - const result = await withRetry( - () => fx.r(['describe', 'app', E.chatAppId]), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['describe', 'app', E.chatAppId]), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) assertNoAnsi(result.stdout, 'stdout') }) @@ -213,10 +211,10 @@ describe('E2E / difyctl describe app', () => { it('[P1] describe output contains Description field (3.66)', async () => { // Spec 3.66: output includes Description when app has a non-empty description. // Prerequisite: echo-bot description set to 'e2e-test' in the Dify web console. - const result = await withRetry( - () => fx.r(['describe', 'app', E.chatAppId]), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['describe', 'app', E.chatAppId]), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) expect(result.stdout).toMatch(/Description:/i) expect(result.stdout).toContain('e2e-test') @@ -225,10 +223,10 @@ describe('E2E / difyctl describe app', () => { it('[P0] Inputs section shows parameter names (3.70)', async () => { // Spec 3.70: Parameters/Inputs section displays variable names. // workflow app has x, num, enum_var, paragraph. - const result = await withRetry( - () => fx.r(['describe', 'app', E.workflowAppId]), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['describe', 'app', E.workflowAppId]), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) expect(result.stdout).toMatch(/Parameters|Inputs/i) expect(result.stdout).toContain('"x"') @@ -238,45 +236,47 @@ describe('E2E / difyctl describe app', () => { it('[P0] Inputs section shows parameter types (3.71)', async () => { // Spec 3.71: Parameters section displays parameter type info. // input_schema is a JSON Schema object with properties.inputs.properties.<var>.type. - const result = await withRetry( - () => fx.r(['describe', 'app', E.workflowAppId, '-o', 'json']), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['describe', 'app', E.workflowAppId, '-o', 'json']), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) const parsed = assertJson<{ input_schema: { properties?: { inputs?: { properties?: Record<string, { type: string }> } } } }>(result) const varProps = parsed.input_schema?.properties?.inputs?.properties expect(varProps, 'input_schema should expose variable type properties').toBeDefined() - const types = Object.values(varProps ?? {}).map(v => v.type) + const types = Object.values(varProps ?? {}).map((v) => v.type) expect(types.length, 'should have at least one typed parameter').toBeGreaterThan(0) - types.forEach(t => expect(typeof t, 'each type must be a string').toBe('string')) + types.forEach((t) => expect(typeof t, 'each type must be a string').toBe('string')) }) it('[P0] Inputs section shows required/optional markers (3.72)', async () => { // Spec 3.72: Parameters section shows required/optional per field. // user_input_form entries each have a required:boolean flag. - const result = await withRetry( - () => fx.r(['describe', 'app', E.workflowAppId, '-o', 'json']), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['describe', 'app', E.workflowAppId, '-o', 'json']), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) - type FormItem = Record<string, { variable: string, required: boolean }> + type FormItem = Record<string, { variable: string; required: boolean }> const parsed = assertJson<{ parameters: { user_input_form: FormItem[] } }>(result) const fields = parsed.parameters.user_input_form expect(fields.length, 'user_input_form should have entries').toBeGreaterThan(0) fields.forEach((item) => { const entry = Object.values(item)[0]! - expect(typeof entry.required, `field ${entry.variable} must have required flag`).toBe('boolean') + expect(typeof entry.required, `field ${entry.variable} must have required flag`).toBe( + 'boolean', + ) }) }) it('[P0] workflow app with 4 typed fields shows all in Parameters (3.73)', async () => { // Spec 3.73: 4-field workflow app — x / num / enum_var / paragraph all appear. - const result = await withRetry( - () => fx.r(['describe', 'app', E.workflowAppId]), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['describe', 'app', E.workflowAppId]), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) expect(result.stdout).toContain('"x"') expect(result.stdout).toContain('"num"') @@ -287,10 +287,10 @@ describe('E2E / difyctl describe app', () => { it('[P1] enum parameter shows options list (3.74)', async () => { // Spec 3.74: enum-type input shows the selectable options. // enum_var has options A, B, C. - const result = await withRetry( - () => fx.r(['describe', 'app', E.workflowAppId]), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['describe', 'app', E.workflowAppId]), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) // Options A / B / C appear in the raw JSON dump of parameters expect(result.stdout).toMatch(/"A"|"B"|"C"/) @@ -299,10 +299,10 @@ describe('E2E / difyctl describe app', () => { it('[P1] paragraph parameter shows max_length value (3.75)', async () => { // Spec 3.75: paragraph input with max_length shows the limit value. // paragraph has max_length = 100. - const result = await withRetry( - () => fx.r(['describe', 'app', E.workflowAppId]), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['describe', 'app', E.workflowAppId]), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) expect(result.stdout).toContain('100') }) @@ -335,8 +335,7 @@ describe('E2E / difyctl describe app', () => { }) expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) expect(result.stderr.length).toBeGreaterThan(0) - } - finally { + } finally { await networkTmp.cleanup() } }) diff --git a/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts b/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts index 54feaf9a34cd85..bbf27c453663c9 100644 --- a/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts +++ b/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts @@ -59,38 +59,41 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { // ── Output format ───────────────────────────────────────────────────────── - eeIt('[EE][P0] -o wide output contains WORKSPACE column and JSON has workspace_id (3.92)', async () => { - // Spec 3.92: WORKSPACE column (priority:1) appears only in -o wide mode. - // Default table shows priority:0 columns only (NAME/ID/MODE/UPDATED). - const wideResult = await withRetry( - () => fx.r(['get', 'app', '-A', '-o', 'wide']), - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(wideResult, 0) - expect(wideResult.stdout).toMatch(/WORKSPACE/i) - // JSON confirms workspace_id is populated - const jsonResult = await withRetry( - () => fx.r(['get', 'app', '-A', '-o', 'json']), - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(jsonResult, 0) - const parsed = assertJson<{ data: Array<{ workspace_id: string }> }>(jsonResult) - expect(parsed.data.length, 'data must be non-empty').toBeGreaterThan(0) - parsed.data.forEach(app => - expect(typeof app.workspace_id, 'workspace_id must be a string').toBe('string'), - ) - }) + eeIt( + '[EE][P0] -o wide output contains WORKSPACE column and JSON has workspace_id (3.92)', + async () => { + // Spec 3.92: WORKSPACE column (priority:1) appears only in -o wide mode. + // Default table shows priority:0 columns only (NAME/ID/MODE/UPDATED). + const wideResult = await withRetry(() => fx.r(['get', 'app', '-A', '-o', 'wide']), { + attempts: 3, + delayMs: 2000, + }) + assertExitCode(wideResult, 0) + expect(wideResult.stdout).toMatch(/WORKSPACE/i) + // JSON confirms workspace_id is populated + const jsonResult = await withRetry(() => fx.r(['get', 'app', '-A', '-o', 'json']), { + attempts: 3, + delayMs: 2000, + }) + assertExitCode(jsonResult, 0) + const parsed = assertJson<{ data: Array<{ workspace_id: string }> }>(jsonResult) + expect(parsed.data.length, 'data must be non-empty').toBeGreaterThan(0) + parsed.data.forEach((app) => + expect(typeof app.workspace_id, 'workspace_id must be a string').toBe('string'), + ) + }, + ) it('[P0] JSON output contains workspace_id in every app entry (3.95)', async () => { // Spec 3.95: every app object must carry a workspace_id string field. - const result = await withRetry( - () => fx.r(['get', 'app', '-A', '-o', 'json']), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['get', 'app', '-A', '-o', 'json']), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) const parsed = assertJson<{ data: Array<{ workspace_id: string }> }>(result) expect(parsed.data.length, 'all-workspaces data must be non-empty').toBeGreaterThan(0) - parsed.data.forEach(app => + parsed.data.forEach((app) => expect(typeof app.workspace_id, `workspace_id must be a string`).toBe('string'), ) }) @@ -123,15 +126,17 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { const parsed = assertJson<{ data: unknown[] }>(result) expect(Array.isArray(parsed.data)).toBe(true) // With 2 workspaces each capped at 2, total should be ≤ 2 * num_workspaces - expect(parsed.data.length, 'total should be bounded by limit × workspace count') - .toBeLessThanOrEqual(10) + expect( + parsed.data.length, + 'total should be bounded by limit × workspace count', + ).toBeLessThanOrEqual(10) }) it('[P1] --mode filter applies in all-workspaces mode', async () => { const result = await fx.r(['get', 'app', '-A', '--mode', 'workflow', '-o', 'json']) assertExitCode(result, 0) const parsed = assertJson<{ data: Array<{ mode: string }> }>(result) - parsed.data.forEach(app => expect(app.mode).toBe('workflow')) + parsed.data.forEach((app) => expect(app.mode).toBe('workflow')) }) // ── Unauthenticated ─────────────────────────────────────────────────────── @@ -143,8 +148,7 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { const result = await run(['get', 'app', '-A'], { configDir: tmp.configDir }) assertExitCode(result, 4) expect(result.stderr).toMatch(/not.?logged.?in|auth/i) - } - finally { + } finally { await tmp.cleanup() } }) @@ -173,8 +177,7 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir }) assertExitCode(result, 2) expect(result.stderr).toMatch(/--all-workspaces is not available for external logins/) - } - finally { + } finally { await ssoTmp.cleanup() } }) @@ -188,8 +191,7 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { const result = await run(['get', 'app', '-A', '-o', 'json'], { configDir: tmp.configDir }) expect(result.exitCode).not.toBe(0) assertErrorEnvelope(result) - } - finally { + } finally { await tmp.cleanup() } }) @@ -209,10 +211,10 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { eeIt('[EE][P1] -o wide WORKSPACE column shows workspace name for each app (3.93)', async () => { // Spec 3.93: WORKSPACE column correctly displays the workspace name. // WORKSPACE has priority:1 so it only appears in -o wide mode. - const result = await withRetry( - () => fx.r(['get', 'app', '-A', '-o', 'wide']), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['get', 'app', '-A', '-o', 'wide']), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) expect(result.stdout).toMatch(/WORKSPACE/i) // At least one workspace name from available_workspaces should appear @@ -221,14 +223,14 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { eeIt('[EE][P1] all-workspaces result is sorted by updated_at DESC (3.94)', async () => { // Spec 3.94: results ordered by updated_at DESC (first item newest). - const result = await withRetry( - () => fx.r(['get', 'app', '-A', '-o', 'json']), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['get', 'app', '-A', '-o', 'json']), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) const parsed = assertJson<{ data: Array<{ updated_at: string }> }>(result) if (parsed.data.length >= 2) { - const dates = parsed.data.map(a => new Date(a.updated_at).getTime()) + const dates = parsed.data.map((a) => new Date(a.updated_at).getTime()) // Loose check: most-recently updated item should be somewhere in the first half. // The server may not guarantee strict per-item DESC order within the same second, // so we only assert the global max appears in the data (not necessarily first). @@ -237,8 +239,9 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { expect(maxDate, 'results should span some time range').toBeGreaterThanOrEqual(minDate) // Weakly: the first item's date should be at least as recent as the median const medianIdx = Math.floor(dates.length / 2) - expect(dates[0]!, 'first item should not be older than the median') - .toBeGreaterThanOrEqual(dates[medianIdx]!) + expect(dates[0]!, 'first item should not be older than the median').toBeGreaterThanOrEqual( + dates[medianIdx]!, + ) } }) @@ -270,8 +273,7 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { }) expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) expect(result.stderr.length).toBeGreaterThan(0) - } - finally { + } finally { await networkTmp.cleanup() } }) diff --git a/cli/test/e2e/suites/discovery/get-app-list.e2e.ts b/cli/test/e2e/suites/discovery/get-app-list.e2e.ts index 61d235794ad65b..dd3f49b9f41134 100644 --- a/cli/test/e2e/suites/discovery/get-app-list.e2e.ts +++ b/cli/test/e2e/suites/discovery/get-app-list.e2e.ts @@ -146,14 +146,14 @@ describe('E2E / difyctl get app (list)', () => { const result = await fx.r(['get', 'app', '--mode', 'chat', '-o', 'json']) assertExitCode(result, 0) const parsed = assertJson<{ data: Array<{ mode: string }> }>(result) - parsed.data.forEach(app => expect(app.mode).toBe('chat')) + parsed.data.forEach((app) => expect(app.mode).toBe('chat')) }) it('[P0] --mode workflow filters to workflow apps only', async () => { const result = await fx.r(['get', 'app', '--mode', 'workflow', '-o', 'json']) assertExitCode(result, 0) const parsed = assertJson<{ data: Array<{ mode: string }> }>(result) - parsed.data.forEach(app => expect(app.mode).toBe('workflow')) + parsed.data.forEach((app) => expect(app.mode).toBe('workflow')) }) it('[P0] --mode with a valid enum value succeeds', async () => { @@ -210,8 +210,7 @@ describe('E2E / difyctl get app (list)', () => { const result = await run(['get', 'app'], { configDir: tmp.configDir }) assertExitCode(result, 4) expect(result.stderr).toMatch(/not.?logged.?in|auth/i) - } - finally { + } finally { await tmp.cleanup() } }) @@ -240,8 +239,7 @@ describe('E2E / difyctl get app (list)', () => { const result = await run(['get', 'app'], { configDir: ssoTmp.configDir }) assertExitCode(result, 0) expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i) - } - finally { + } finally { await ssoTmp.cleanup() } }) @@ -255,8 +253,7 @@ describe('E2E / difyctl get app (list)', () => { const result = await run(['get', 'app', '-o', 'json'], { configDir: tmp.configDir }) expect(result.exitCode).not.toBe(0) assertErrorEnvelope(result) - } - finally { + } finally { await tmp.cleanup() } }) @@ -267,7 +264,7 @@ describe('E2E / difyctl get app (list)', () => { // Spec 3.7: JSON output must include core fields per item. const result = await fx.r(['get', 'app', '-o', 'json']) assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ id: string, name: string, mode: string }> }>(result) + const parsed = assertJson<{ data: Array<{ id: string; name: string; mode: string }> }>(result) expect(parsed.data.length, 'data array must be non-empty').toBeGreaterThan(0) const first = parsed.data[0]! expect(typeof first.id, 'id must be a string').toBe('string') @@ -278,20 +275,19 @@ describe('E2E / difyctl get app (list)', () => { it('[P1] app list is sorted by updated_at DESC (3.2)', async () => { // Spec 3.2: apps are returned in descending updated_at order. - const result = await withRetry( - () => fx.r(['get', 'app', '-o', 'json']), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['get', 'app', '-o', 'json']), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) const parsed = assertJson<{ data: Array<{ updated_at: string }> }>(result) // Loose check: first item's updated_at should be >= last item's. // Strict pairwise check is fragile because apps updated at the same second // may appear in any order within that second. - const dates = parsed.data.map(a => new Date(a.updated_at).getTime()) - expect( - dates[0]!, - 'first item should have the newest updated_at', - ).toBeGreaterThanOrEqual(dates[dates.length - 1]!) + const dates = parsed.data.map((a) => new Date(a.updated_at).getTime()) + expect(dates[0]!, 'first item should have the newest updated_at').toBeGreaterThanOrEqual( + dates[dates.length - 1]!, + ) }) it('[P1] --limit 100 (server max) returns apps and exits 0 (3.13)', async () => { @@ -311,7 +307,7 @@ describe('E2E / difyctl get app (list)', () => { assertExitCode(result, 0) const parsed = assertJson<{ data: Array<{ name: string }> }>(result) expect(parsed.data.length, '--name auto should return at least 1 app').toBeGreaterThan(0) - parsed.data.forEach(app => + parsed.data.forEach((app) => expect(app.name.toLowerCase(), `app "${app.name}" should contain "auto"`).toContain('auto'), ) }) @@ -323,7 +319,7 @@ describe('E2E / difyctl get app (list)', () => { assertNoAnsi(result.stdout, 'stdout') const lines = result.stdout.trim().split('\n').filter(Boolean) expect(lines.length, '-o name should output at least one line').toBeGreaterThan(0) - lines.forEach(line => + lines.forEach((line) => expect(line.trim(), `"${line}" should be a UUID`).toMatch(/^[0-9a-f-]{36}$/), ) }) @@ -353,8 +349,7 @@ describe('E2E / difyctl get app (list)', () => { const result = await run(['get', 'app'], { configDir: networkTmp.configDir, timeout: 15_000 }) expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) expect(result.stderr.length, 'stderr should contain error message').toBeGreaterThan(0) - } - finally { + } finally { await networkTmp.cleanup() } }) diff --git a/cli/test/e2e/suites/discovery/get-app-single.e2e.ts b/cli/test/e2e/suites/discovery/get-app-single.e2e.ts index 528c08c9fa05c0..3f434fe850082d 100644 --- a/cli/test/e2e/suites/discovery/get-app-single.e2e.ts +++ b/cli/test/e2e/suites/discovery/get-app-single.e2e.ts @@ -60,8 +60,7 @@ describe('E2E / difyctl get app <id> (single)', () => { const result = await run(['get', 'app', E.workflowAppId], { configDir: tmp.configDir }) assertExitCode(result, 4) expect(result.stderr).toMatch(/not.?logged.?in|auth/i) - } - finally { + } finally { await tmp.cleanup() } }) @@ -90,8 +89,7 @@ describe('E2E / difyctl get app <id> (single)', () => { const result = await run(['get', 'app', E.chatAppId], { configDir: ssoTmp.configDir }) assertExitCode(result, 0) expect(result.stdout).toContain(E.chatAppId) - } - finally { + } finally { await ssoTmp.cleanup() } }) @@ -101,10 +99,10 @@ describe('E2E / difyctl get app <id> (single)', () => { it('[P0] get app <valid-id> returns metadata and exits 0 (3.39 / 3.40 / 3.41 / 3.42-44)', async () => { // Spec 3.39: returns metadata; 3.40: table format; 3.41: no ANSI; // 3.42-44: output contains id, name, mode. - const result = await withRetry( - () => fx.r(['get', 'app', E.chatAppId]), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['get', 'app', E.chatAppId]), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) assertNoAnsi(result.stdout, 'stdout') // table format: has column headers @@ -117,12 +115,12 @@ describe('E2E / difyctl get app <id> (single)', () => { it('[P0] get app <id> -o json returns valid JSON with id, name, mode fields (3.45)', async () => { // Spec 3.45: -o json → valid JSON, contains id/name/mode per item. - const result = await withRetry( - () => fx.r(['get', 'app', E.chatAppId, '-o', 'json']), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['get', 'app', E.chatAppId, '-o', 'json']), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ id: string, name: string, mode: string }> }>(result) + const parsed = assertJson<{ data: Array<{ id: string; name: string; mode: string }> }>(result) expect(parsed.data.length, 'data array should contain the queried app').toBeGreaterThan(0) const app = parsed.data[0]! expect(typeof app.id).toBe('string') @@ -132,10 +130,10 @@ describe('E2E / difyctl get app <id> (single)', () => { it('[P1] get app <id> -o yaml returns valid YAML and exits 0 (3.46)', async () => { // Spec 3.46: -o yaml → valid YAML, exit 0. - const result = await withRetry( - () => fx.r(['get', 'app', E.chatAppId, '-o', 'yaml']), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['get', 'app', E.chatAppId, '-o', 'yaml']), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) expect(result.stdout.length).toBeGreaterThan(0) expect(result.stdout.trimStart()).not.toMatch(/^\{/) @@ -143,10 +141,10 @@ describe('E2E / difyctl get app <id> (single)', () => { it('[P1] get app <id> -o name outputs only the app ID (3.47)', async () => { // Spec 3.47: -o name → only the app ID per line. - const result = await withRetry( - () => fx.r(['get', 'app', E.chatAppId, '-o', 'name']), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['get', 'app', E.chatAppId, '-o', 'name']), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) const lines = result.stdout.trim().split('\n').filter(Boolean) expect(lines.length).toBeGreaterThan(0) @@ -155,20 +153,20 @@ describe('E2E / difyctl get app <id> (single)', () => { it('[P1] get app <id> -o wide outputs extended columns (3.48)', async () => { // Spec 3.48: -o wide → UPDATED/WORKSPACE columns, exit 0. - const result = await withRetry( - () => fx.r(['get', 'app', E.chatAppId, '-o', 'wide']), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['get', 'app', E.chatAppId, '-o', 'wide']), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) expect(result.stdout).toMatch(/UPDATED|WORKSPACE/i) }) it('[P1] get app <id> -o json is pipe-friendly with no ANSI (3.49)', async () => { // Spec 3.49: -o json | jq . works; no ANSI codes. - const result = await withRetry( - () => fx.r(['get', 'app', E.chatAppId, '-o', 'json']), - { attempts: 3, delayMs: 2000 }, - ) + const result = await withRetry(() => fx.r(['get', 'app', E.chatAppId, '-o', 'json']), { + attempts: 3, + delayMs: 2000, + }) assertExitCode(result, 0) assertPipeFriendlyJson(result) }) @@ -219,8 +217,7 @@ describe('E2E / difyctl get app <id> (single)', () => { }) expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) expect(result.stderr.length).toBeGreaterThan(0) - } - finally { + } finally { await networkTmp.cleanup() } }) @@ -238,6 +235,8 @@ describe('E2E / difyctl get app <id> (single)', () => { { attempts: 3, delayMs: 2000 }, ) expect(result.exitCode, 'app not in workspace should exit non-zero').not.toBe(0) - expect(result.stderr).toMatch(/not.?found|404|does not exist|server_5xx|not.?authorized|forbidden|workspace/i) + expect(result.stderr).toMatch( + /not.?found|404|does not exist|server_5xx|not.?authorized|forbidden|workspace/i, + ) }) }) diff --git a/cli/test/e2e/suites/dsl/export-studio-app.e2e.ts b/cli/test/e2e/suites/dsl/export-studio-app.e2e.ts index e158ceaccec0ba..8f479c30ed1225 100644 --- a/cli/test/e2e/suites/dsl/export-studio-app.e2e.ts +++ b/cli/test/e2e/suites/dsl/export-studio-app.e2e.ts @@ -11,9 +11,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest' -import { - assertExitCode, -} from '../../helpers/assert.js' +import { assertExitCode } from '../../helpers/assert.js' import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js' import { resolveEnv } from '../../setup/env.js' @@ -81,8 +79,7 @@ describe('E2E / difyctl export studio-app', () => { const content = await readFile(outPath, 'utf8') expect(content).toMatch(/^kind:\s*app/m) expect(content).toMatch(/^version:/m) - } - finally { + } finally { await rm(dir, { recursive: true, force: true }) } }) @@ -101,8 +98,7 @@ describe('E2E / difyctl export studio-app', () => { assertExitCode(stdoutResult, 0) expect(fileResult.exitCode).toBe(0) expect(fileResult.content.trim()).toBe(stdoutResult.stdout.trim()) - } - finally { + } finally { await rm(dir, { recursive: true, force: true }) } }) @@ -113,7 +109,13 @@ describe('E2E / difyctl export studio-app', () => { const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-roundtrip-')) const dslPath = join(dir, 'roundtrip.yaml') try { - const exportResult = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', dslPath]) + const exportResult = await fx.r([ + 'export', + 'studio-app', + E.workflowAppId, + '--output', + dslPath, + ]) assertExitCode(exportResult, 0) const importResult = await fx.r([ @@ -128,8 +130,7 @@ describe('E2E / difyctl export studio-app', () => { const match = importResult.stderr.match(/app ([0-9a-f-]{36})/) expect(match?.[1], 'import stderr must contain the new app UUID').toBeTruthy() - } - finally { + } finally { await rm(dir, { recursive: true, force: true }) } }) @@ -149,8 +150,7 @@ describe('E2E / difyctl export studio-app', () => { configDir: unauthTmp.configDir, }) assertExitCode(result, 4) - } - finally { + } finally { await unauthTmp.cleanup() } }) @@ -162,7 +162,13 @@ describe('E2E / difyctl export studio-app', () => { }) it('[P1] malformed --workflow-id returns a 4xx, not a 5xx', async () => { - const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--workflow-id', 'not-a-uuid']) + const result = await fx.r([ + 'export', + 'studio-app', + E.workflowAppId, + '--workflow-id', + 'not-a-uuid', + ]) expect(result.exitCode).not.toBe(0) expect(result.stderr).toMatch(/http_status:\s*4\d\d/) expect(result.stderr).not.toMatch(/http_status:\s*5\d\d/) @@ -184,12 +190,19 @@ describe('E2E / difyctl export studio-app', () => { const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-nofile-')) const outPath = join(dir, 'should-not-exist.yaml') try { - const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-nofile-e2e', '--output', outPath]) + const result = await fx.r([ + 'export', + 'studio-app', + 'nonexistent-app-id-nofile-e2e', + '--output', + outPath, + ]) expect(result.exitCode).not.toBe(0) - const exists = await readFile(outPath, 'utf8').then(() => true).catch(() => false) + const exists = await readFile(outPath, 'utf8') + .then(() => true) + .catch(() => false) expect(exists, 'output file must not be created on export failure').toBe(false) - } - finally { + } finally { await rm(dir, { recursive: true, force: true }) } }) diff --git a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts index 5c4a9f79fa0768..3e0cb19d1df6d7 100644 --- a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts +++ b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts @@ -99,11 +99,12 @@ describe('E2E / error message standards (spec 5.3)', () => { ` issuer: https://issuer.example.com`, ].join('\n')}\n` await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['export', 'studio-app', E.chatAppId], { configDir: ssoTmp.configDir }) + const result = await run(['export', 'studio-app', E.chatAppId], { + configDir: ssoTmp.configDir, + }) assertNonZeroExit(result) expect(result.stderr.trim().length, 'stderr must contain an error message').toBeGreaterThan(0) - } - finally { + } finally { await ssoTmp.cleanup() } }) @@ -115,19 +116,16 @@ describe('E2E / error message standards (spec 5.3)', () => { // include the config file path so the user knows which file to fix. const corruptTmp = await withTempConfig() try { - await writeFile( - join(corruptTmp.configDir, 'config.yml'), - ': broken: yaml: [[[', - { mode: 0o600 }, - ) + await writeFile(join(corruptTmp.configDir, 'config.yml'), ': broken: yaml: [[[', { + mode: 0o600, + }) const result = await run(['config', 'get', 'defaults.format'], { configDir: corruptTmp.configDir, }) assertNonZeroExit(result) // The error must mention the config file path (either full path or filename) expect(result.stderr).toMatch(/config\.yml/) - } - finally { + } finally { await corruptTmp.cleanup() } }) @@ -228,13 +226,20 @@ describe('E2E / error message standards (spec 5.3)', () => { const result = await fx.r(['describe', 'app', ZERO, '-o', 'json']) assertNonZeroExit(result) const envelope = JSON.parse(result.stderr.trim()) as { - error: { code: string, server?: { code: string, status: number, message: string } } + error: { code: string; server?: { code: string; status: number; message: string } } } - expect(envelope.error.server, 'error.server must be present when server returns canonical ErrorBody').toBeDefined() + expect( + envelope.error.server, + 'error.server must be present when server returns canonical ErrorBody', + ).toBeDefined() expect(typeof envelope.error.server?.code, 'error.server.code must be a string').toBe('string') expect(envelope.error.server?.code.length).toBeGreaterThan(0) - expect(typeof envelope.error.server?.status, 'error.server.status must be a number').toBe('number') - expect(typeof envelope.error.server?.message, 'error.server.message must be a string').toBe('string') + expect(typeof envelope.error.server?.status, 'error.server.status must be a number').toBe( + 'number', + ) + expect(typeof envelope.error.server?.message, 'error.server.message must be a string').toBe( + 'string', + ) expect(envelope.error.server?.message.length).toBeGreaterThan(0) }) @@ -248,10 +253,10 @@ describe('E2E / error message standards (spec 5.3)', () => { { headers: { Authorization: `Bearer ${E.token}` }, signal: AbortSignal.timeout(8_000) }, ) expect(res.status).toBe(422) - const body = await res.json() as { + const body = (await res.json()) as { code?: string status?: number - details?: Array<{ type: string, loc: Array<string | number>, msg: string }> + details?: Array<{ type: string; loc: Array<string | number>; msg: string }> } expect(body.code).toBe('invalid_param') expect(body.status).toBe(422) @@ -285,9 +290,10 @@ describe('E2E / error message standards (spec 5.3)', () => { const result = await run(['get', 'app', '-o', 'json'], { configDir: unauthTmp.configDir }) assertExitCode(result, 4) const envelope = JSON.parse(result.stderr.trim()) as { error: { hint?: string } } - expect(envelope.error.hint, 'CLI login hint must appear for auth error').toMatch(/auth login/i) - } - finally { + expect(envelope.error.hint, 'CLI login hint must appear for auth error').toMatch( + /auth login/i, + ) + } finally { await unauthTmp.cleanup() } }) @@ -301,7 +307,7 @@ describe('E2E / error message standards (spec 5.3)', () => { const result = await fx.r(['describe', 'app', ZERO, '-o', 'json']) assertNonZeroExit(result) const envelope = JSON.parse(result.stderr.trim()) as { - error: { code: string, server?: { code: string } } + error: { code: string; server?: { code: string } } } expect(envelope.error.code).toBe('server_4xx_other') expect(envelope.error.server?.code).toBeDefined() @@ -314,12 +320,15 @@ describe('E2E / error message standards (spec 5.3)', () => { // Previously, an unknown path under /openapi/v1/ returned flask-restx's default // 404 with a "Did you mean /openapi/v1/apps?" suggestion, leaking the route table. // After the fix it must return a canonical ErrorBody and contain no suggestions. - const res = await fetch(`${E.host.replace(/\/$/, '')}/openapi/v1/this-path-does-not-exist-e2e`, { - headers: { Authorization: `Bearer ${E.token}` }, - signal: AbortSignal.timeout(8_000), - }) + const res = await fetch( + `${E.host.replace(/\/$/, '')}/openapi/v1/this-path-does-not-exist-e2e`, + { + headers: { Authorization: `Bearer ${E.token}` }, + signal: AbortSignal.timeout(8_000), + }, + ) expect(res.status).toBe(404) - const body = await res.json() as Record<string, unknown> + const body = (await res.json()) as Record<string, unknown> // canonical ErrorBody fields must be present expect(typeof body.code, '404 body must have a string code field').toBe('string') expect(body.status, '404 body must have status: 404').toBe(404) @@ -340,13 +349,16 @@ describe('E2E / error message standards (spec 5.3)', () => { const res = await fetch(`${E.host.replace(/\/$/, '')}/openapi/v1/oauth/device/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ device_code: 'fake-invalid-device-code-e2e-test', client_id: 'difyctl' }), + body: JSON.stringify({ + device_code: 'fake-invalid-device-code-e2e-test', + client_id: 'difyctl', + }), signal: AbortSignal.timeout(8_000), }) // device flow errors are 4xx (400 bad_request or 401 expired_token etc.) expect(res.status).toBeGreaterThanOrEqual(400) expect(res.status).toBeLessThan(500) - const body = await res.json() as Record<string, unknown> + const body = (await res.json()) as Record<string, unknown> // RFC 8628 shape: has 'error' string, must NOT have ErrorBody 'code'/'status' pair expect(typeof body.error, 'RFC 8628 body must have a string error field').toBe('string') expect(body).not.toHaveProperty('status') @@ -369,8 +381,7 @@ describe('E2E / error message standards (spec 5.3)', () => { // regardless of -o flag. This differs from the spec which expects a JSON // envelope. We verify the minimum contract: stderr is non-empty. expect(result.stderr.trim().length, 'stderr must be non-empty on failure').toBeGreaterThan(0) - } - finally { + } finally { await unauthTmp.cleanup() } }) @@ -388,8 +399,7 @@ describe('E2E / error message standards (spec 5.3)', () => { expect(combined).not.toMatch(/dfoa_[\w-]{10,}/) expect(combined).not.toMatch(/dfoe_[\w-]{10,}/) expect(combined).not.toMatch(/password|secret/i) - } - finally { + } finally { await unauthTmp.cleanup() } }) @@ -431,8 +441,7 @@ describe('E2E / error message standards (spec 5.3)', () => { expect(combined).toMatch(/cjk-test-|\u6587\u6863|ENOENT|not.*found|failed/i) // Must not contain \uXXXX escapes for the CJK characters expect(combined).not.toMatch(/\\u[0-9a-fA-F]{4}/) - } - finally { + } finally { await rm(fileDir, { recursive: true, force: true }) } }) @@ -460,11 +469,13 @@ describe('E2E / error message standards (spec 5.3)', () => { try { const result = await run(['get', 'app'], { configDir: unauthTmp.configDir }) assertNonZeroExit(result) - expect(result.stderr.trim().length, 'stderr must be non-empty in pipe/non-TTY mode').toBeGreaterThan(0) + expect( + result.stderr.trim().length, + 'stderr must be non-empty in pipe/non-TTY mode', + ).toBeGreaterThan(0) // stderr must also have no ANSI codes (non-TTY = no colour) assertNoAnsi(result.stderr, 'stderr') - } - finally { + } finally { await unauthTmp.cleanup() } }) @@ -481,12 +492,10 @@ describe('E2E / error message standards (spec 5.3)', () => { expect(result.stderr).not.toMatch(/TypeError|SyntaxError|^\s+at\s+\S/m) if (result.exitCode !== 0) { assertErrorEnvelope(result) - } - else { + } else { expect(result.stdout.trim()).toMatch(/^\{/) } - } - finally { + } finally { await rm(cacheDir, { recursive: true, force: true }) } }) @@ -494,7 +503,9 @@ describe('E2E / error message standards (spec 5.3)', () => { it('[P1] 5.89 corrupt hosts.yml produces JSON error envelope', async () => { const corruptTmp = await withTempConfig() try { - await writeFile(join(corruptTmp.configDir, 'hosts.yml'), ': : not valid yaml', { mode: 0o600 }) + await writeFile(join(corruptTmp.configDir, 'hosts.yml'), ': : not valid yaml', { + mode: 0o600, + }) const result = await run(['get', 'app', '-o', 'json'], { configDir: corruptTmp.configDir, }) @@ -502,8 +513,7 @@ describe('E2E / error message standards (spec 5.3)', () => { const envelope = assertErrorEnvelope(result) expect(envelope.error.message).toContain('hosts.yml') expect(result.stderr).not.toMatch(/YAMLException|^\s+at\s+\S/m) - } - finally { + } finally { await corruptTmp.cleanup() } }) diff --git a/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts b/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts index f5e6bfff625dff..4ed80e902d1b61 100644 --- a/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts +++ b/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts @@ -61,17 +61,14 @@ describe('E2E / exit code standards (spec 5.4)', () => { // a non-zero code. In practice the CLI exits 6 (config_schema_unsupported). const corruptTmp = await withTempConfig() try { - await writeFile( - join(corruptTmp.configDir, 'config.yml'), - ': broken yaml [[[', - { mode: 0o600 }, - ) + await writeFile(join(corruptTmp.configDir, 'config.yml'), ': broken yaml [[[', { + mode: 0o600, + }) const result = await run(['config', 'get', 'defaults.format'], { configDir: corruptTmp.configDir, }) expect(result.exitCode).toBe(6) - } - finally { + } finally { await corruptTmp.cleanup() } }) @@ -83,13 +80,7 @@ describe('E2E / exit code standards (spec 5.4)', () => { // WTA-249 has been fixed: app not found now correctly returns exit 1. // // Scenario: get app with a non-existent UUID + -o json → server_4xx_other - const result = await fx.r([ - 'get', - 'app', - ZERO, - '-o', - 'json', - ]) + const result = await fx.r(['get', 'app', ZERO, '-o', 'json']) // WTA-249 has been fixed in the current build: 4xx with -o json now // correctly returns exit 1. expect(result.exitCode, 'app not found with -o json must exit 1 (WTA-249 fixed)').toBe(1) @@ -107,8 +98,7 @@ describe('E2E / exit code standards (spec 5.4)', () => { configDir: unauthTmp.configDir, }) assertNonZeroExit(result) - } - finally { + } finally { await unauthTmp.cleanup() } }) @@ -160,8 +150,7 @@ describe('E2E / exit code standards (spec 5.4)', () => { const r2 = await run(['get', 'app'], { configDir: unauthTmp.configDir }) expect(r1.exitCode).toBe(r2.exitCode) expect(r1.exitCode).not.toBe(0) - } - finally { + } finally { await unauthTmp.cleanup() } }) @@ -181,18 +170,13 @@ describe('E2E / exit code standards (spec 5.4)', () => { try { const authResult = await run(['get', 'app'], { configDir: unauthTmp.configDir }) authExitCode = authResult.exitCode - } - finally { + } finally { await unauthTmp.cleanup() } expect(authExitCode!, 'not_logged_in must exit 4').toBe(4) // Class 1 — server/resource error (not_found, server_5xx, network) - const serverResult = await fx.r([ - 'use', - 'workspace', - 'nonexistent-workspace-id-xyz', - ]) + const serverResult = await fx.r(['use', 'workspace', 'nonexistent-workspace-id-xyz']) expect(serverResult.exitCode, 'workspace not found must exit 1').toBe(1) }) }) diff --git a/cli/test/e2e/suites/output/json-yaml-output.e2e.ts b/cli/test/e2e/suites/output/json-yaml-output.e2e.ts index ffe386dc516eac..284c284cf681fd 100644 --- a/cli/test/e2e/suites/output/json-yaml-output.e2e.ts +++ b/cli/test/e2e/suites/output/json-yaml-output.e2e.ts @@ -48,8 +48,12 @@ const E = resolveEnv(caps) describe('E2E / JSON & YAML output format (spec 5.2)', () => { let fx: AuthFixture - beforeEach(async () => { fx = await withAuthFixture(E) }) - afterEach(async () => { await fx.cleanup() }) + beforeEach(async () => { + fx = await withAuthFixture(E) + }) + afterEach(async () => { + await fx.cleanup() + }) // ── 5.31 JSON schema stability ──────────────────────────────────────────── @@ -78,10 +82,10 @@ describe('E2E / JSON & YAML output format (spec 5.2)', () => { expect(Array.isArray(parsed.data)).toBe(true) expect(parsed.data.length).toBeGreaterThan(0) // At least one device entry must have the last_used_at key present (even if null) - const hasKey = parsed.data.some(d => Object.prototype.hasOwnProperty.call(d, 'last_used_at')) + const hasKey = parsed.data.some((d) => Object.prototype.hasOwnProperty.call(d, 'last_used_at')) expect(hasKey, 'last_used_at key must be present in device entries').toBe(true) // Verify null serialisation — if the field is null it must be JSON null - const nullEntry = parsed.data.find(d => d.last_used_at === null) + const nullEntry = parsed.data.find((d) => d.last_used_at === null) if (nullEntry) { // Confirm the raw JSON contains the literal "null" value // The JSON may be compact (no space) or indented — match both @@ -123,7 +127,10 @@ describe('E2E / JSON & YAML output format (spec 5.2)', () => { // a two-level nested structure. const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json']) assertExitCode(result, 0) - const parsed = assertJson<{ info: Record<string, unknown>, parameters: Record<string, unknown> }>(result) + const parsed = assertJson<{ + info: Record<string, unknown> + parameters: Record<string, unknown> + }>(result) // Both top-level fields must be proper objects, not strings expect(typeof parsed.info).toBe('object') expect(parsed.info).not.toBeNull() @@ -168,8 +175,7 @@ describe('E2E / JSON & YAML output format (spec 5.2)', () => { const r1 = await runFn(['get', 'app', '-o', 'json'], { configDir: unauthTmp.configDir }) assertNonZeroExit(r1) envelope1 = assertErrorEnvelope(r1) - } - finally { + } finally { await unauthTmp.cleanup() } // Scenario B: non-existent app → server error (error in stderr when -o json) diff --git a/cli/test/e2e/suites/output/table-output.e2e.ts b/cli/test/e2e/suites/output/table-output.e2e.ts index ecfb0577d96fd6..fbcec743d1a18c 100644 --- a/cli/test/e2e/suites/output/table-output.e2e.ts +++ b/cli/test/e2e/suites/output/table-output.e2e.ts @@ -51,8 +51,12 @@ const E = resolveEnv(caps) describe('E2E / table output — header and column format (spec 5.1–5.19)', () => { let fx: AuthFixture - beforeEach(async () => { fx = await withAuthFixture(E) }) - afterEach(async () => { await fx.cleanup() }) + beforeEach(async () => { + fx = await withAuthFixture(E) + }) + afterEach(async () => { + await fx.cleanup() + }) it('[P0] 5.1 default output (no -o) is an aligned text table, not JSON or YAML', async () => { // Spec 5.1: the default format is a text table; -o table does not exist. @@ -100,7 +104,10 @@ describe('E2E / table output — header and column format (spec 5.1–5.19)', () // Spec 5.5: when there are multiple apps, all rows are rendered. const result = await fx.r(['get', 'app']) assertExitCode(result, 0) - const lines = result.stdout.trim().split('\n').filter(l => l.trim()) + const lines = result.stdout + .trim() + .split('\n') + .filter((l) => l.trim()) // At least header + 1 data row expect(lines.length).toBeGreaterThan(1) }) @@ -110,7 +117,10 @@ describe('E2E / table output — header and column format (spec 5.1–5.19)', () // row with no data rows underneath (not an error, exit 0). const result = await fx.r(['get', 'app', '--name', 'zzz-nonexistent-app-xyz-000']) assertExitCode(result, 0) - const lines = result.stdout.trim().split('\n').filter(l => l.trim()) + const lines = result.stdout + .trim() + .split('\n') + .filter((l) => l.trim()) // Only the header row should remain expect(lines).toHaveLength(1) expect(lines[0] ?? '').toMatch(/NAME/i) @@ -124,7 +134,7 @@ describe('E2E / table output — header and column format (spec 5.1–5.19)', () // Extract word-like tokens from the header const tokens = header.match(/[A-Z]{2,}/g) ?? [] expect(tokens.length).toBeGreaterThan(0) - tokens.forEach(token => + tokens.forEach((token) => expect(token, `header token "${token}" should be uppercase`).toBe(token.toUpperCase()), ) }) diff --git a/cli/test/e2e/suites/run/run-app-basic.e2e.ts b/cli/test/e2e/suites/run/run-app-basic.e2e.ts index 1e3acdd666846c..cdae61d55d0cbe 100644 --- a/cli/test/e2e/suites/run/run-app-basic.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-basic.e2e.ts @@ -56,7 +56,8 @@ describe('E2E / difyctl run app', () => { const result = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'hello']), { attempts: 3, delayMs: 2000, - shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), + shouldRetry: (err) => + err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), }) assertExitCode(result, 0) assertStdoutContains(result, 'echo:hello') @@ -97,7 +98,7 @@ describe('E2E / difyctl run app', () => { // Spec: -o json produces valid JSON const result = await fx.r(['run', 'app', E.chatAppId, 'json-test', '-o', 'json']) assertExitCode(result, 0) - const parsed = assertJson<{ answer: string, mode: string }>(result) + const parsed = assertJson<{ answer: string; mode: string }>(result) expect(parsed).toHaveProperty('answer') expect(parsed.mode).toMatch(/chat/) }) @@ -135,8 +136,20 @@ describe('E2E / difyctl run app', () => { // Spec: run app supports --inputs // withRetry: staging workflow execution may have transient 5xx const result = await withRetry( - () => fx.r(['run', 'app', E.workflowAppId, '--inputs', JSON.stringify({ x: 'workflow-val', num: 42, enum_var: 'A', paragraph: 'short text' })]), - { attempts: 3, delayMs: 2000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) }, + () => + fx.r([ + 'run', + 'app', + E.workflowAppId, + '--inputs', + JSON.stringify({ x: 'workflow-val', num: 42, enum_var: 'A', paragraph: 'short text' }), + ]), + { + attempts: 3, + delayMs: 2000, + shouldRetry: (err) => + err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), + }, ) assertExitCode(result, 0) assertStdoutContains(result, 'workflow-val') @@ -193,7 +206,10 @@ describe('E2E / difyctl run app', () => { it('[P0] --inputs-file reads JSON inputs from a file', async () => { const inputsFile = join(fx.configDir, 'wf-inputs.json') - await writeFile(inputsFile, JSON.stringify({ x: 'from-file', num: 42, enum_var: 'A', paragraph: 'short text' })) + await writeFile( + inputsFile, + JSON.stringify({ x: 'from-file', num: 42, enum_var: 'A', paragraph: 'short text' }), + ) const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs-file', inputsFile]) assertExitCode(result, 0) assertStdoutContains(result, 'from-file') @@ -304,7 +320,7 @@ describe('E2E / difyctl run app', () => { ]) expect(result.exitCode).not.toBe(0) const envelope = JSON.parse(result.stderr.trim()) as { - error: { code: string, message: string, http_status?: number } + error: { code: string; message: string; http_status?: number } } expect(envelope.error.http_status, 'validation failure must return http_status 422').toBe(422) }) @@ -340,8 +356,7 @@ describe('E2E / difyctl run app', () => { }) assertExitCode(result, 4) expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i) - } - finally { + } finally { await unauthTmp.cleanup() } }) @@ -368,14 +383,13 @@ describe('E2E / difyctl run app', () => { ` role: owner`, ].join('\n')}\n` await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run( - ['run', 'app', E.chatAppId, 'hello'], - { configDir: networkTmp.configDir, timeout: 15_000 }, - ) + const result = await run(['run', 'app', E.chatAppId, 'hello'], { + configDir: networkTmp.configDir, + timeout: 15_000, + }) expect(result.exitCode).not.toBe(0) expect(result.stderr.length).toBeGreaterThan(0) - } - finally { + } finally { await networkTmp.cleanup() } }) @@ -440,10 +454,9 @@ describe('E2E / difyctl run app', () => { await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) // Run WITHOUT --workspace - const resultWithout = await run( - ['run', 'app', E.chatAppId, 'hello'], - { configDir: ssoTmp.configDir }, - ) + const resultWithout = await run(['run', 'app', E.chatAppId, 'hello'], { + configDir: ssoTmp.configDir, + }) // Run WITH --workspace (should be ignored → same exit code) const resultWith = await run( @@ -454,8 +467,7 @@ describe('E2E / difyctl run app', () => { // If --workspace were honoured for SSO users it would change behaviour; // identical exit codes confirm the parameter is silently ignored. expect(resultWith.exitCode).toBe(resultWithout.exitCode) - } - finally { + } finally { await ssoTmp.cleanup() } }) @@ -483,10 +495,13 @@ describe('E2E / difyctl run app', () => { const cacheEnv = { DIFY_CACHE_DIR: fx.configDir, DIFY_E2E_NO_KEYRING: '1' } // Step 1: prime the cache - const prime = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'cache-prime'], cacheEnv), { - attempts: 3, - delayMs: 2000, - }) + const prime = await withRetry( + () => fx.r(['run', 'app', E.chatAppId, 'cache-prime'], cacheEnv), + { + attempts: 3, + delayMs: 2000, + }, + ) assertExitCode(prime, 0) // Step 2: cache file must have been written at {configDir}/app-info.yml @@ -498,12 +513,17 @@ describe('E2E / difyctl run app', () => { await rm(cacheFile, { force: true }) // Step 4: run again — cache miss must not cause failure - const result = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'cache-miss'], cacheEnv), { - attempts: 3, - delayMs: 2000, - }) + const result = await withRetry( + () => fx.r(['run', 'app', E.chatAppId, 'cache-miss'], cacheEnv), + { + attempts: 3, + delayMs: 2000, + }, + ) assertExitCode(result, 0) - expect(result.stdout.length, 'stdout must be non-empty after cache re-fetch').toBeGreaterThan(0) + expect(result.stdout.length, 'stdout must be non-empty after cache re-fetch').toBeGreaterThan( + 0, + ) }) }) }) diff --git a/cli/test/e2e/suites/run/run-app-conversation.e2e.ts b/cli/test/e2e/suites/run/run-app-conversation.e2e.ts index ec69c9ced84f3a..f6967d2378c6fa 100644 --- a/cli/test/e2e/suites/run/run-app-conversation.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-conversation.e2e.ts @@ -18,7 +18,13 @@ import { assertStderrContains, } from '../../helpers/assert.js' import { registerConversation } from '../../helpers/cleanup-registry.js' -import { injectAuth, run, spawn_background, withAuthFixture, withTempConfig } from '../../helpers/cli.js' +import { + injectAuth, + run, + spawn_background, + withAuthFixture, + withTempConfig, +} from '../../helpers/cli.js' import { withRetry } from '../../helpers/retry.js' import { optionalIt } from '../../helpers/skip.js' import { resolveEnv } from '../../setup/env.js' @@ -83,25 +89,32 @@ describe('E2E / difyctl run app --conversation', () => { // Spec 4.3.5: omitting --conversation creates a brand-new session each time; // the new conversation_id must be non-empty and distinct from the previous one. // withRetry: echo-chat app may return empty answer on back-to-back calls under load. - const firstId = await withRetry(async () => { - const r = await fx.r(['run', 'app', E.chatAppId, 'new-conv-1', '-o', 'json']) - assertExitCode(r, 0) - const { conversation_id } = assertJson<{ conversation_id: string }>(r) - expect(conversation_id, 'first call must return a non-empty conversation_id').toBeTruthy() - return conversation_id - }, { attempts: 3, delayMs: 2000 }) - - const secondId = await withRetry(async () => { - const r = await fx.r(['run', 'app', E.chatAppId, 'new-conv-2', '-o', 'json']) - assertExitCode(r, 0) - const { conversation_id } = assertJson<{ conversation_id: string }>(r) - expect(conversation_id, 'second call must return a non-empty conversation_id').toBeTruthy() - return conversation_id - }, { attempts: 3, delayMs: 2000 }) - - expect(secondId, 'omitting --conversation must create a new session, not reuse the previous one') - .not - .toBe(firstId) + const firstId = await withRetry( + async () => { + const r = await fx.r(['run', 'app', E.chatAppId, 'new-conv-1', '-o', 'json']) + assertExitCode(r, 0) + const { conversation_id } = assertJson<{ conversation_id: string }>(r) + expect(conversation_id, 'first call must return a non-empty conversation_id').toBeTruthy() + return conversation_id + }, + { attempts: 3, delayMs: 2000 }, + ) + + const secondId = await withRetry( + async () => { + const r = await fx.r(['run', 'app', E.chatAppId, 'new-conv-2', '-o', 'json']) + assertExitCode(r, 0) + const { conversation_id } = assertJson<{ conversation_id: string }>(r) + expect(conversation_id, 'second call must return a non-empty conversation_id').toBeTruthy() + return conversation_id + }, + { attempts: 3, delayMs: 2000 }, + ) + + expect( + secondId, + 'omitting --conversation must create a new session, not reuse the previous one', + ).not.toBe(firstId) }) // ── Error scenarios ───────────────────────────────────────────────────── @@ -127,28 +140,33 @@ describe('E2E / difyctl run app --conversation', () => { // Spec 4.3.6: --conversation <cid> --stream should work and the streaming // reply must carry the same conversation_id as the one used in the request. // withRetry: echo-chat may return empty answer (no conversation_id) under load. - await withRetry(async () => { - const first = await fx.r(['run', 'app', E.chatAppId, 'init', '-o', 'json']) - assertExitCode(first, 0) - const { conversation_id } = assertJson<{ conversation_id: string }>(first) - expect(conversation_id, 'first call should return a conversation_id').toBeTruthy() - - const result = await fx.r([ - 'run', - 'app', - E.chatAppId, - 'continue', - '--conversation', - conversation_id, - '--stream', - '-o', - 'json', - ]) - assertExitCode(result, 0) - const streamed = assertJson<{ conversation_id?: string, answer?: string }>(result) - expect(streamed.conversation_id, 'streaming reply must carry the same conversation_id') - .toBe(conversation_id) - }, { attempts: 3, delayMs: 2000 }) + await withRetry( + async () => { + const first = await fx.r(['run', 'app', E.chatAppId, 'init', '-o', 'json']) + assertExitCode(first, 0) + const { conversation_id } = assertJson<{ conversation_id: string }>(first) + expect(conversation_id, 'first call should return a conversation_id').toBeTruthy() + + const result = await fx.r([ + 'run', + 'app', + E.chatAppId, + 'continue', + '--conversation', + conversation_id, + '--stream', + '-o', + 'json', + ]) + assertExitCode(result, 0) + const streamed = assertJson<{ conversation_id?: string; answer?: string }>(result) + expect( + streamed.conversation_id, + 'streaming reply must carry the same conversation_id', + ).toBe(conversation_id) + }, + { attempts: 3, delayMs: 2000 }, + ) }) it('[P1] conversation output supports piping (-o json pipe-friendly format)', async () => { @@ -170,8 +188,7 @@ describe('E2E / difyctl run app --conversation', () => { { configDir: unauthTmp.configDir }, ) assertExitCode(result, 4) - } - finally { + } finally { await unauthTmp.cleanup() } }) @@ -189,16 +206,19 @@ describe('E2E / difyctl run app --conversation', () => { workspaceName: E.workspaceName, }) const result = await withRetry( - () => run(['run', 'app', E.chatAppId, 'sso-conv-test', '-o', 'json'], { - configDir: ssoTmp.configDir, - }), + () => + run(['run', 'app', E.chatAppId, 'sso-conv-test', '-o', 'json'], { + configDir: ssoTmp.configDir, + }), { attempts: 3, delayMs: 2000 }, ) assertExitCode(result, 0) const parsed = assertJson<{ conversation_id?: string }>(result) - expect(parsed.conversation_id, 'SSO conversation run should return a conversation_id').toBeTruthy() - } - finally { + expect( + parsed.conversation_id, + 'SSO conversation run should return a conversation_id', + ).toBeTruthy() + } finally { await ssoTmp.cleanup() } }) @@ -207,54 +227,62 @@ describe('E2E / difyctl run app --conversation', () => { it('[P1] JSON output includes message_id field', async () => { // Spec 4.3.15: -o json response must include a non-empty message_id field. - const result = await withRetry(async () => { - const r = await fx.r(['run', 'app', E.chatAppId, 'msg-id-check', '-o', 'json']) - assertExitCode(r, 0) - const parsed = assertJson<{ message_id?: string }>(r) - expect(parsed.message_id, 'message_id must be non-empty').toBeTruthy() - return r - }, { attempts: 3, delayMs: 2000 }) + const result = await withRetry( + async () => { + const r = await fx.r(['run', 'app', E.chatAppId, 'msg-id-check', '-o', 'json']) + assertExitCode(r, 0) + const parsed = assertJson<{ message_id?: string }>(r) + expect(parsed.message_id, 'message_id must be non-empty').toBeTruthy() + return r + }, + { attempts: 3, delayMs: 2000 }, + ) assertExitCode(result, 0) }) it('[P1] after streaming interruption the same conversation_id remains usable', async () => { // Spec 4.3.18: interrupting a streaming run must not corrupt the conversation; // a subsequent non-streaming call with the same conversation_id must succeed. - const conversation_id = await withRetry(async () => { - const r = await fx.r(['run', 'app', E.chatAppId, 'pre-interrupt', '-o', 'json']) - assertExitCode(r, 0) - const { conversation_id: cid } = assertJson<{ conversation_id: string }>(r) - expect(cid, 'setup call must return a conversation_id').toBeTruthy() - return cid - }, { attempts: 3, delayMs: 2000 }) + const conversation_id = await withRetry( + async () => { + const r = await fx.r(['run', 'app', E.chatAppId, 'pre-interrupt', '-o', 'json']) + assertExitCode(r, 0) + const { conversation_id: cid } = assertJson<{ conversation_id: string }>(r) + expect(cid, 'setup call must return a conversation_id').toBeTruthy() + return cid + }, + { attempts: 3, delayMs: 2000 }, + ) // Start a streaming run and interrupt it after 800 ms. const proc = spawn_background( ['run', 'app', E.chatAppId, 'streaming-msg', '--conversation', conversation_id, '--stream'], { configDir: fx.configDir }, ) - await new Promise(res => setTimeout(res, 800)) + await new Promise((res) => setTimeout(res, 800)) proc.interrupt() await proc.wait() // The conversation must still be usable after the interruption. const resume = await withRetry( - () => fx.r([ - 'run', - 'app', - E.chatAppId, - 'after-interrupt', - '--conversation', - conversation_id, - '-o', - 'json', - ]), + () => + fx.r([ + 'run', + 'app', + E.chatAppId, + 'after-interrupt', + '--conversation', + conversation_id, + '-o', + 'json', + ]), { attempts: 3, delayMs: 2000 }, ) assertExitCode(resume, 0) const parsed = assertJson<{ conversation_id: string }>(resume) - expect(parsed.conversation_id, 'resumed call must carry the same conversation_id') - .toBe(conversation_id) + expect(parsed.conversation_id, 'resumed call must carry the same conversation_id').toBe( + conversation_id, + ) }) it('[P1] conversation run with unreachable host returns network error (exit non-zero)', async () => { @@ -286,8 +314,7 @@ describe('E2E / difyctl run app --conversation', () => { ) expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) expect(result.stderr.length, 'stderr should contain an error message').toBeGreaterThan(0) - } - finally { + } finally { await networkTmp.cleanup() } }) @@ -321,38 +348,47 @@ describe('E2E / difyctl run app --conversation', () => { '--conversation', 'any-conv-id-for-wf', ]) - expect(result.exitCode, '--conversation on workflow must not cause an unhandled crash (exit 2)').not.toBe(2) + expect( + result.exitCode, + '--conversation on workflow must not cause an unhandled crash (exit 2)', + ).not.toBe(2) expect(result.stderr).not.toMatch(/unhandled|uncaught|TypeError|ReferenceError/i) }) it('[P1] same conversation_id remains stable across 3 consecutive calls', async () => { // Spec 4.3.24: reusing the same conversation_id multiple times must always // succeed; each call must exit 0 and return the same conversation_id. - const conversation_id = await withRetry(async () => { - const r = await fx.r(['run', 'app', E.chatAppId, 'stable-1', '-o', 'json']) - assertExitCode(r, 0) - const { conversation_id: cid } = assertJson<{ conversation_id: string }>(r) - expect(cid, 'initial call must return a conversation_id').toBeTruthy() - return cid - }, { attempts: 3, delayMs: 2000 }) + const conversation_id = await withRetry( + async () => { + const r = await fx.r(['run', 'app', E.chatAppId, 'stable-1', '-o', 'json']) + assertExitCode(r, 0) + const { conversation_id: cid } = assertJson<{ conversation_id: string }>(r) + expect(cid, 'initial call must return a conversation_id').toBeTruthy() + return cid + }, + { attempts: 3, delayMs: 2000 }, + ) for (let i = 2; i <= 3; i++) { const result = await withRetry( - () => fx.r([ - 'run', - 'app', - E.chatAppId, - `stable-${i}`, - '--conversation', - conversation_id, - '-o', - 'json', - ]), + () => + fx.r([ + 'run', + 'app', + E.chatAppId, + `stable-${i}`, + '--conversation', + conversation_id, + '-o', + 'json', + ]), { attempts: 3, delayMs: 2000 }, ) assertExitCode(result, 0) const parsed = assertJson<{ conversation_id: string }>(result) - expect(parsed.conversation_id, `call ${i}: conversation_id must be stable`).toBe(conversation_id) + expect(parsed.conversation_id, `call ${i}: conversation_id must be stable`).toBe( + conversation_id, + ) } }) @@ -366,47 +402,51 @@ describe('E2E / difyctl run app --conversation', () => { const itWithFileChat = optionalIt(Boolean(E.fileChatAppId)) - itWithFileChat('[P1] --conversation + --file doc uploads a file and continues the conversation', async () => { - // Spec 4.3.7: --conversation <cid> --file doc=@test.txt - // File upload succeeds, app executes correctly, conversation_id is preserved. - const FILE_URL = 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' + itWithFileChat( + '[P1] --conversation + --file doc uploads a file and continues the conversation', + async () => { + // Spec 4.3.7: --conversation <cid> --file doc=@test.txt + // File upload succeeds, app executes correctly, conversation_id is preserved. + const FILE_URL = 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' - // Step 1: Start a new conversation with a file — get the conversation_id. - const first = await fx.r([ - 'run', - 'app', - E.fileChatAppId, - 'summarize this document', - '--file', - `file_input=${FILE_URL}`, - '-o', - 'json', - ]) - assertExitCode(first, 0) - const { conversation_id } = assertJson<{ conversation_id: string }>(first) - expect(conversation_id, 'first call must return a non-empty conversation_id').toBeTruthy() - registerConversation(E.host, E.token, E.fileChatAppId, conversation_id) + // Step 1: Start a new conversation with a file — get the conversation_id. + const first = await fx.r([ + 'run', + 'app', + E.fileChatAppId, + 'summarize this document', + '--file', + `file_input=${FILE_URL}`, + '-o', + 'json', + ]) + assertExitCode(first, 0) + const { conversation_id } = assertJson<{ conversation_id: string }>(first) + expect(conversation_id, 'first call must return a non-empty conversation_id').toBeTruthy() + registerConversation(E.host, E.token, E.fileChatAppId, conversation_id) - // Step 2: Continue the same conversation with another file upload. - const second = await fx.r([ - 'run', - 'app', - E.fileChatAppId, - 'what is this document about?', - '--conversation', - conversation_id, - '--file', - `file_input=${FILE_URL}`, - '-o', - 'json', - ]) - assertExitCode(second, 0) - const secondParsed = assertJson<{ conversation_id: string }>(second) + // Step 2: Continue the same conversation with another file upload. + const second = await fx.r([ + 'run', + 'app', + E.fileChatAppId, + 'what is this document about?', + '--conversation', + conversation_id, + '--file', + `file_input=${FILE_URL}`, + '-o', + 'json', + ]) + assertExitCode(second, 0) + const secondParsed = assertJson<{ conversation_id: string }>(second) - // The conversation_id must be the same across both calls. - expect(secondParsed.conversation_id, '--conversation must preserve the conversation_id') - .toBe(conversation_id) - }) + // The conversation_id must be the same across both calls. + expect(secondParsed.conversation_id, '--conversation must preserve the conversation_id').toBe( + conversation_id, + ) + }, + ) // ── 4.3.8 --conversation + --inputs ──────────────────────────────────────── // @@ -452,8 +492,9 @@ describe('E2E / difyctl run app --conversation', () => { const secondParsed = assertJson<{ conversation_id: string }>(second) // The conversation_id must be identical across both calls. - expect(secondParsed.conversation_id, '--inputs must not break conversation continuity') - .toBe(conversation_id) + expect(secondParsed.conversation_id, '--inputs must not break conversation continuity').toBe( + conversation_id, + ) }) // ── 4.3.11 wrong app id + valid conversation_id ───────────────────────────── @@ -469,34 +510,37 @@ describe('E2E / difyctl run app --conversation', () => { // pipeline. The important contract is: exit code is 1 (non-zero) and stderr is // non-empty with a recognisable error code. - itWithFileChat('[P0] running fileChatApp with a conversation_id owned by chatApp returns an error (exit 1)', async () => { - // Spec 4.3.11: using the wrong app id with a valid conversation_id from another - // app must fail with a non-zero exit code. - - // Step 1: create a conversation with chatApp. - const setup = await fx.r(['run', 'app', E.chatAppId, 'init-for-cross-app', '-o', 'json']) - assertExitCode(setup, 0) - const { conversation_id } = assertJson<{ conversation_id: string }>(setup) - expect(conversation_id, 'setup call must return a conversation_id').toBeTruthy() - registerConversation(E.host, E.token, E.chatAppId, conversation_id) - - // Step 2: attempt to continue that conversation using fileChatApp. - // The server rejects it because the conversation belongs to a different app. - const result = await fx.r([ - 'run', - 'app', - E.fileChatAppId, - 'this should fail', - '--conversation', - conversation_id, - '-o', - 'json', - ]) + itWithFileChat( + '[P0] running fileChatApp with a conversation_id owned by chatApp returns an error (exit 1)', + async () => { + // Spec 4.3.11: using the wrong app id with a valid conversation_id from another + // app must fail with a non-zero exit code. + + // Step 1: create a conversation with chatApp. + const setup = await fx.r(['run', 'app', E.chatAppId, 'init-for-cross-app', '-o', 'json']) + assertExitCode(setup, 0) + const { conversation_id } = assertJson<{ conversation_id: string }>(setup) + expect(conversation_id, 'setup call must return a conversation_id').toBeTruthy() + registerConversation(E.host, E.token, E.chatAppId, conversation_id) + + // Step 2: attempt to continue that conversation using fileChatApp. + // The server rejects it because the conversation belongs to a different app. + const result = await fx.r([ + 'run', + 'app', + E.fileChatAppId, + 'this should fail', + '--conversation', + conversation_id, + '-o', + 'json', + ]) - // The server returns HTTP 500 (stream terminated by error event) with exit 1. - expect(result.exitCode, 'cross-app conversation_id must cause a non-zero exit').toBe(1) - expect(result.stderr.trim().length, 'stderr must contain an error message').toBeGreaterThan(0) - // stderr must be a JSON error envelope when -o json is active - assertErrorEnvelope(result) - }) + // The server returns HTTP 500 (stream terminated by error event) with exit 1. + expect(result.exitCode, 'cross-app conversation_id must cause a non-zero exit').toBe(1) + expect(result.stderr.trim().length, 'stderr must contain an error message').toBeGreaterThan(0) + // stderr must be a JSON error envelope when -o json is active + assertErrorEnvelope(result) + }, + ) }) diff --git a/cli/test/e2e/suites/run/run-app-file.e2e.ts b/cli/test/e2e/suites/run/run-app-file.e2e.ts index 7822576fe93d1c..1aec5656d24751 100644 --- a/cli/test/e2e/suites/run/run-app-file.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-file.e2e.ts @@ -68,27 +68,51 @@ describeSuite('E2E / difyctl run app --file', () => { const itLocalUpload = optionalIt(supportsLocalUpload) - itLocalUpload('[P0] run app supports single file upload (key=@path) — app executes correctly', async () => { - // Spec: run app supports single file upload + app executes correctly after upload - const filePath = join(fileDir, 'test.txt') - const picPath = join(fileDir, 'test.png') - await writeFile(filePath, 'E2E test file content — single upload') - await writePng(picPath) - const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`]) - assertExitCode(result, 0) - }) + itLocalUpload( + '[P0] run app supports single file upload (key=@path) — app executes correctly', + async () => { + // Spec: run app supports single file upload + app executes correctly after upload + const filePath = join(fileDir, 'test.txt') + const picPath = join(fileDir, 'test.png') + await writeFile(filePath, 'E2E test file content — single upload') + await writePng(picPath) + const result = await r([ + 'run', + 'app', + E.fileAppId, + '--file', + `doc=@${filePath}`, + '--file', + `picture=@${picPath}`, + ]) + assertExitCode(result, 0) + }, + ) - itLocalUpload('[P0] file input argument name maps correctly (key binds to correct input field)', async () => { - // Spec: file input argument name maps correctly - const filePath = join(fileDir, 'mapping.txt') - const picPath = join(fileDir, 'mapping.png') - await writeFile(filePath, 'mapping test content') - await writePng(picPath) - const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`, '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<Record<string, unknown>>(result) - expect(parsed).toBeDefined() - }) + itLocalUpload( + '[P0] file input argument name maps correctly (key binds to correct input field)', + async () => { + // Spec: file input argument name maps correctly + const filePath = join(fileDir, 'mapping.txt') + const picPath = join(fileDir, 'mapping.png') + await writeFile(filePath, 'mapping test content') + await writePng(picPath) + const result = await r([ + 'run', + 'app', + E.fileAppId, + '--file', + `doc=@${filePath}`, + '--file', + `picture=@${picPath}`, + '-o', + 'json', + ]) + assertExitCode(result, 0) + const parsed = assertJson<Record<string, unknown>>(result) + expect(parsed).toBeDefined() + }, + ) itLocalUpload('[P0] run app --file syntax is key=@path (local file upload)', async () => { // Spec: run app --file syntax is key=@path @@ -96,7 +120,15 @@ describeSuite('E2E / difyctl run app --file', () => { const picPath = join(fileDir, 'syntax.png') await writeFile(filePath, 'syntax verification') await writePng(picPath) - const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`]) + const result = await r([ + 'run', + 'app', + E.fileAppId, + '--file', + `doc=@${filePath}`, + '--file', + `picture=@${picPath}`, + ]) assertExitCode(result, 0) }) @@ -130,14 +162,7 @@ describeSuite('E2E / difyctl run app --file', () => { it('[P1] malformed --file argument returns usage error (exit code 2)', async () => { // Spec: malformed --file argument returns a usage error - const result = await r([ - 'run', - 'app', - E.chatAppId, - 'hello', - '--file', - 'invalidformat', - ]) + const result = await r(['run', 'app', E.chatAppId, 'hello', '--file', 'invalidformat']) assertExitCode(result, 2) expect(result.stderr).toMatch(/--file|key[^\n\r@\u2028\u2029]*@.*path|invalid.*file/i) }) @@ -148,7 +173,15 @@ describeSuite('E2E / difyctl run app --file', () => { const picPath = join(fileDir, 'pic spaces.png') await writeFile(filePath, 'space in name test') await writePng(picPath) - const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`]) + const result = await r([ + 'run', + 'app', + E.fileAppId, + '--file', + `doc=@${filePath}`, + '--file', + `picture=@${picPath}`, + ]) assertExitCode(result, 0) }) @@ -158,7 +191,15 @@ describeSuite('E2E / difyctl run app --file', () => { const picPath = join(fileDir, 'note.png') await writeFile(f, 'plain text content') await writePng(picPath) - const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${f}`, '--file', `picture=@${picPath}`]) + const result = await r([ + 'run', + 'app', + E.fileAppId, + '--file', + `doc=@${f}`, + '--file', + `picture=@${picPath}`, + ]) assertExitCode(result, 0) }) @@ -168,7 +209,16 @@ describeSuite('E2E / difyctl run app --file', () => { const picPath = join(fileDir, 'stream.png') await writeFile(f, 'stream + file test') await writePng(picPath) - const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${f}`, '--file', `picture=@${picPath}`, '--stream']) + const result = await r([ + 'run', + 'app', + E.fileAppId, + '--file', + `doc=@${f}`, + '--file', + `picture=@${picPath}`, + '--stream', + ]) assertExitCode(result, 0) }) @@ -178,13 +228,11 @@ describeSuite('E2E / difyctl run app --file', () => { try { const f = join(fileDir, 'unauth.txt') await writeFile(f, 'test') - const result = await run( - ['run', 'app', E.fileAppId || E.chatAppId, '--file', `doc=@${f}`], - { configDir: unauthTmp.configDir }, - ) + const result = await run(['run', 'app', E.fileAppId || E.chatAppId, '--file', `doc=@${f}`], { + configDir: unauthTmp.configDir, + }) assertExitCode(result, 4) - } - finally { + } finally { await unauthTmp.cleanup() } }) @@ -195,14 +243,25 @@ describeSuite('E2E / difyctl run app --file', () => { // Spec 4.4.8: .pdf is a valid document type for the doc field. const pdfPath = join(fileDir, 'test.pdf') const picPath = join(fileDir, 'pdf-pic.png') - await writeFile(pdfPath, '%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj ' - + '2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj ' - + '3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 3 3]>>endobj\n' - + 'xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n' - + '0000000058 00000 n \n0000000115 00000 n \n' - + 'trailer<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF\n') + await writeFile( + pdfPath, + '%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj ' + + '2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj ' + + '3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 3 3]>>endobj\n' + + 'xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n' + + '0000000058 00000 n \n0000000115 00000 n \n' + + 'trailer<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF\n', + ) await writePng(picPath) - const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${pdfPath}`, '--file', `picture=@${picPath}`]) + const result = await r([ + 'run', + 'app', + E.fileAppId, + '--file', + `doc=@${pdfPath}`, + '--file', + `picture=@${picPath}`, + ]) assertExitCode(result, 0) }) itWithSso('[P0] SSO (dfoe_) token can execute file run (exit code 0) (4.4.23)', async () => { @@ -224,15 +283,23 @@ describeSuite('E2E / difyctl run app --file', () => { await writeFile(docPath, 'sso file run test') await writePng(picPath) const result = await withRetry( - () => run( - ['run', 'app', E.fileAppId, '--file', `doc=@${docPath}`, '--file', `picture=@${picPath}`], - { configDir: ssoTmp.configDir }, - ), + () => + run( + [ + 'run', + 'app', + E.fileAppId, + '--file', + `doc=@${docPath}`, + '--file', + `picture=@${picPath}`, + ], + { configDir: ssoTmp.configDir }, + ), { attempts: 3, delayMs: 2000 }, ) assertExitCode(result, 0) - } - finally { + } finally { await ssoTmp.cleanup() } }) @@ -245,7 +312,15 @@ describeSuite('E2E / difyctl run app --file', () => { const picPath = join(fileDir, 'empty-pic.png') await writeFile(emptyPath, '') await writePng(picPath) - const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${emptyPath}`, '--file', `picture=@${picPath}`]) + const result = await r([ + 'run', + 'app', + E.fileAppId, + '--file', + `doc=@${emptyPath}`, + '--file', + `picture=@${picPath}`, + ]) expect(result.exitCode, 'empty file must not cause CLI crash (exit 2)').not.toBe(2) expect(result.stderr).not.toMatch(/unhandled|uncaught|TypeError|ReferenceError/i) }) @@ -270,61 +345,85 @@ describeSuite('E2E / difyctl run app --file', () => { '--file', `picture=@${picPath}`, ]) - expect(result.exitCode, '--inputs and --file together must not cause CLI usage error (exit 2)').not.toBe(2) + expect( + result.exitCode, + '--inputs and --file together must not cause CLI usage error (exit 2)', + ).not.toBe(2) }) - itLocalUpload('[P1] files with same name in different paths upload without conflict (4.4.16)', async () => { - // Spec 4.4.16: multiple --file entries with the same filename (different paths) - // must all upload successfully without collision. - const { mkdtemp: mkd } = await import('node:fs/promises') - const { tmpdir: td } = await import('node:os') - const dir2 = await mkd(join(td(), 'difyctl-e2e-samename-')) - try { - const docPath = join(fileDir, 'same.txt') // doc field - const picPath = join(dir2, 'same.png') // picture field — same base name, different dir - await writeFile(docPath, 'same name doc test') + itLocalUpload( + '[P1] files with same name in different paths upload without conflict (4.4.16)', + async () => { + // Spec 4.4.16: multiple --file entries with the same filename (different paths) + // must all upload successfully without collision. + const { mkdtemp: mkd } = await import('node:fs/promises') + const { tmpdir: td } = await import('node:os') + const dir2 = await mkd(join(td(), 'difyctl-e2e-samename-')) + try { + const docPath = join(fileDir, 'same.txt') // doc field + const picPath = join(dir2, 'same.png') // picture field — same base name, different dir + await writeFile(docPath, 'same name doc test') + await writePng(picPath) + const result = await r([ + 'run', + 'app', + E.fileAppId, + '--file', + `doc=@${docPath}`, + '--file', + `picture=@${picPath}`, + ]) + assertExitCode(result, 0) + } finally { + const { rm: rmDir } = await import('node:fs/promises') + await rmDir(dir2, { recursive: true, force: true }) + } + }, + ) + + itLocalUpload( + '[P1] -o json after file upload contains workflow response fields (4.4.21)', + async () => { + // Spec 4.4.21: -o json output after a file run must contain structured response metadata. + const docPath = join(fileDir, 'json-doc.txt') + const picPath = join(fileDir, 'json-pic.png') + await writeFile(docPath, 'json output test') await writePng(picPath) - const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${docPath}`, '--file', `picture=@${picPath}`]) + const result = await r([ + 'run', + 'app', + E.fileAppId, + '--file', + `doc=@${docPath}`, + '--file', + `picture=@${picPath}`, + '-o', + 'json', + ]) assertExitCode(result, 0) - } - finally { - const { rm: rmDir } = await import('node:fs/promises') - await rmDir(dir2, { recursive: true, force: true }) - } - }) + const parsed = assertJson<Record<string, unknown>>(result) + // workflow response must contain at minimum a mode field + expect(parsed.mode, 'JSON output must contain mode field').toBeTruthy() + assertNoAnsi(result.stdout, 'stdout') + }, + ) - itLocalUpload('[P1] -o json after file upload contains workflow response fields (4.4.21)', async () => { - // Spec 4.4.21: -o json output after a file run must contain structured response metadata. - const docPath = join(fileDir, 'json-doc.txt') - const picPath = join(fileDir, 'json-pic.png') - await writeFile(docPath, 'json output test') + itLocalUpload('[P1] file path with CJK characters uploads correctly (4.4.26)', async () => { + // Spec 4.4.26: a file whose path contains CJK (Chinese) characters must upload + // and execute successfully. + const cjkPath = join(fileDir, 'cjk-test-doc.txt') + const picPath = join(fileDir, 'cjk-pic.png') + await writeFile(cjkPath, 'CJK path upload test — Chinese content') await writePng(picPath) const result = await r([ 'run', 'app', E.fileAppId, '--file', - `doc=@${docPath}`, + `doc=@${cjkPath}`, '--file', `picture=@${picPath}`, - '-o', - 'json', ]) assertExitCode(result, 0) - const parsed = assertJson<Record<string, unknown>>(result) - // workflow response must contain at minimum a mode field - expect(parsed.mode, 'JSON output must contain mode field').toBeTruthy() - assertNoAnsi(result.stdout, 'stdout') - }) - - itLocalUpload('[P1] file path with CJK characters uploads correctly (4.4.26)', async () => { - // Spec 4.4.26: a file whose path contains CJK (Chinese) characters must upload - // and execute successfully. - const cjkPath = join(fileDir, 'cjk-test-doc.txt') - const picPath = join(fileDir, 'cjk-pic.png') - await writeFile(cjkPath, 'CJK path upload test — Chinese content') - await writePng(picPath) - const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${cjkPath}`, '--file', `picture=@${picPath}`]) - assertExitCode(result, 0) }) }) diff --git a/cli/test/e2e/suites/run/run-app-hitl.e2e.ts b/cli/test/e2e/suites/run/run-app-hitl.e2e.ts index 58290331c907e0..28136725fb0be3 100644 --- a/cli/test/e2e/suites/run/run-app-hitl.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-hitl.e2e.ts @@ -13,7 +13,12 @@ import { writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, inject, it } from 'vitest' -import { assertExitCode, assertJson, assertNonZeroExit, assertStderrContains } from '../../helpers/assert.js' +import { + assertExitCode, + assertJson, + assertNonZeroExit, + assertStderrContains, +} from '../../helpers/assert.js' import { run, withAuthFixture } from '../../helpers/cli.js' import { withRetry } from '../../helpers/retry.js' import { optionalDescribe } from '../../helpers/skip.js' @@ -101,7 +106,10 @@ describeSuite('E2E / difyctl run app — HITL human intervention', () => { 'json', ]) assertExitCode(pauseResult, 0) - const { form_token, workflow_run_id } = assertJson<{ form_token: string, workflow_run_id: string }>(pauseResult) + const { form_token, workflow_run_id } = assertJson<{ + form_token: string + workflow_run_id: string + }>(pauseResult) // hint must contain all three identifiers assertStderrContains(pauseResult, 'difyctl resume app') assertStderrContains(pauseResult, '--workflow-run-id') @@ -147,8 +155,9 @@ describeSuite('E2E / difyctl run app — HITL human intervention', () => { ]) assertExitCode(resumeResult, 0) // Spec 4.5.13: final output must signal workflow completion - expect(resumeResult.stdout + resumeResult.stderr) - .toMatch(/succeeded|finished|workflow_finished|completed/i) + expect(resumeResult.stdout + resumeResult.stderr).toMatch( + /succeeded|finished|workflow_finished|completed/i, + ) }) it('[P0] resume app auto-selects the single action — workflow continues execution', async () => { @@ -156,16 +165,9 @@ describeSuite('E2E / difyctl run app — HITL human intervention', () => { // and the CLI auto-selects it. // Uses hitlSingleActionAppId (display_in_ui=true, 1 action, no required inputs). // hitlAppId now has 3 actions so it cannot be used here. - if (!E.hitlSingleActionAppId) - return + if (!E.hitlSingleActionAppId) return - const pause = await fx.r([ - 'run', - 'app', - E.hitlSingleActionAppId, - '-o', - 'json', - ]) + const pause = await fx.r(['run', 'app', E.hitlSingleActionAppId, '-o', 'json']) assertExitCode(pause, 0) const { form_token, workflow_run_id, actions } = assertJson<{ form_token: string @@ -192,15 +194,18 @@ describeSuite('E2E / difyctl run app — HITL human intervention', () => { // Spec 4.5.7: --stream mode must still emit pause block and exit 0 on HITL. // Streaming HITL: SSE connection can be closed unexpectedly; // withRetry triggers on thrown errors so we throw when exit != 0. - const result = await withRetry(async () => { - const r = await run( - ['run', 'app', E.hitlAppId, '--inputs', JSON.stringify({ x: 'hitl-stream' }), '--stream'], - { configDir: fx.configDir, timeout: 60_000 }, - ) - if (r.exitCode !== 0) - throw new Error(`streaming HITL exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`) - return r - }, { attempts: 3, delayMs: 3000 }) + const result = await withRetry( + async () => { + const r = await run( + ['run', 'app', E.hitlAppId, '--inputs', JSON.stringify({ x: 'hitl-stream' }), '--stream'], + { configDir: fx.configDir, timeout: 60_000 }, + ) + if (r.exitCode !== 0) + throw new Error(`streaming HITL exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`) + return r + }, + { attempts: 3, delayMs: 3000 }, + ) assertExitCode(result, 0) expect(result.stdout + result.stderr).toMatch(/paused|pause|resume/i) }) @@ -254,19 +259,22 @@ describeSuite('E2E / difyctl run app — HITL human intervention', () => { it('[P1] resume with --inputs-file reads form values from JSON file (4.5.12)', async () => { // Spec 4.5.12: --inputs-file must read form field values from a local JSON file. - const pause = await withRetry(async () => { - const r = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: 'inputs-file-test' }), - '-o', - 'json', - ]) - assertExitCode(r, 0) - return r - }, { attempts: 3, delayMs: 2000 }) + const pause = await withRetry( + async () => { + const r = await fx.r([ + 'run', + 'app', + E.hitlAppId, + '--inputs', + JSON.stringify({ x: 'inputs-file-test' }), + '-o', + 'json', + ]) + assertExitCode(r, 0) + return r + }, + { attempts: 3, delayMs: 2000 }, + ) const { form_token, workflow_run_id, actions } = assertJson<{ form_token: string workflow_run_id: string @@ -296,19 +304,22 @@ describeSuite('E2E / difyctl run app — HITL human intervention', () => { it('[P1] resume with --with-history returns node history in output (4.5.14)', async () => { // Spec 4.5.14: --with-history must request include_state_snapshot=true and // return historical node events; the CLI must exit 0 with non-empty output. - const pause = await withRetry(async () => { - const r = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: 'with-history-test' }), - '-o', - 'json', - ]) - assertExitCode(r, 0) - return r - }, { attempts: 3, delayMs: 2000 }) + const pause = await withRetry( + async () => { + const r = await fx.r([ + 'run', + 'app', + E.hitlAppId, + '--inputs', + JSON.stringify({ x: 'with-history-test' }), + '-o', + 'json', + ]) + assertExitCode(r, 0) + return r + }, + { attempts: 3, delayMs: 2000 }, + ) const { form_token, workflow_run_id, actions } = assertJson<{ form_token: string workflow_run_id: string @@ -334,19 +345,22 @@ describeSuite('E2E / difyctl run app — HITL human intervention', () => { it('[P1] resume with --stream outputs workflow completion in real-time (4.5.17)', async () => { // Spec 4.5.17: resume --stream must stream continuation node outputs to stdout // and exit 0 after workflow_finished. - const pause = await withRetry(async () => { - const r = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: 'resume-stream-test' }), - '-o', - 'json', - ]) - assertExitCode(r, 0) - return r - }, { attempts: 3, delayMs: 2000 }) + const pause = await withRetry( + async () => { + const r = await fx.r([ + 'run', + 'app', + E.hitlAppId, + '--inputs', + JSON.stringify({ x: 'resume-stream-test' }), + '-o', + 'json', + ]) + assertExitCode(r, 0) + return r + }, + { attempts: 3, delayMs: 2000 }, + ) const { form_token, workflow_run_id, actions } = assertJson<{ form_token: string workflow_run_id: string @@ -387,13 +401,7 @@ describeExternal('E2E / difyctl run app — HITL display_in_ui=false (4.5.8)', ( }) it('[P1] 4.5.8 HITL pause with display_in_ui=false: external-channel form is not CLI-resumable', async () => { - const result = await fx.r([ - 'run', - 'app', - E.hitlExternalAppId, - '-o', - 'json', - ]) + const result = await fx.r(['run', 'app', E.hitlExternalAppId, '-o', 'json']) assertExitCode(result, 0) const parsed = assertJson<{ @@ -405,14 +413,18 @@ describeExternal('E2E / difyctl run app — HITL display_in_ui=false (4.5.8)', ( }>(result) // display_in_ui must be false for this fixture - expect(parsed.display_in_ui, 'display_in_ui must be false for external-channel fixture').toBe(false) + expect(parsed.display_in_ui, 'display_in_ui must be false for external-channel fixture').toBe( + false, + ) // status must be paused expect(parsed.status).toBe('paused') // external delivery is not CLI-resumable: no token, channels name the real route expect(parsed.form_token, 'form_token must be null for external delivery').toBeNull() - expect(parsed.approval_channels, 'approval_channels must name the delivery channel').toContain('email') + expect(parsed.approval_channels, 'approval_channels must name the delivery channel').toContain( + 'email', + ) // stderr hint must describe the channel, not offer a resume command expect(result.stderr).toMatch(/delivered via|resume only from/i) @@ -473,7 +485,10 @@ describeMultiAction('E2E / difyctl resume app — HITL multiple actions (4.5.10) // intentionally omit --action ]) - expect(resumeResult.exitCode, 'omitting --action with multiple actions must exit non-zero').toBe(1) + expect( + resumeResult.exitCode, + 'omitting --action with multiple actions must exit non-zero', + ).toBe(1) expect(resumeResult.stderr).toMatch(/--action required|multiple.*action|action.*required/i) }) }) @@ -501,18 +516,14 @@ describeMultiNode('E2E / difyctl run + resume — HITL 2 serial nodes (4.5.18)', // Both resumes must succeed; final output must indicate success. // ── Step 1: run — pauses at first HITL node ────────────────────────── - const pause1 = await withRetry(async () => { - const r = await fx.r([ - 'run', - 'app', - E.hitlMultiNodeAppId, - '-o', - 'json', - ]) - if (r.exitCode !== 0) - throw new Error(`run failed: ${r.stderr.slice(0, 200)}`) - return r - }, { attempts: 3, delayMs: 3000 }) + const pause1 = await withRetry( + async () => { + const r = await fx.r(['run', 'app', E.hitlMultiNodeAppId, '-o', 'json']) + if (r.exitCode !== 0) throw new Error(`run failed: ${r.stderr.slice(0, 200)}`) + return r + }, + { attempts: 3, delayMs: 3000 }, + ) assertExitCode(pause1, 0) const node1 = assertJson<{ @@ -527,23 +538,25 @@ describeMultiNode('E2E / difyctl run + resume — HITL 2 serial nodes (4.5.18)', const actionId1 = node1.actions[0]?.id ?? 'action_1' // ── Step 2: resume node 1 — workflow continues to second HITL node ─── - const pause2 = await withRetry(async () => { - const r = await fx.r([ - 'resume', - 'app', - E.hitlMultiNodeAppId, - node1.form_token, - '--workflow-run-id', - node1.workflow_run_id, - '--action', - actionId1, - '-o', - 'json', - ]) - if (r.exitCode !== 0) - throw new Error(`resume 1 failed: ${r.stderr.slice(0, 200)}`) - return r - }, { attempts: 3, delayMs: 3000 }) + const pause2 = await withRetry( + async () => { + const r = await fx.r([ + 'resume', + 'app', + E.hitlMultiNodeAppId, + node1.form_token, + '--workflow-run-id', + node1.workflow_run_id, + '--action', + actionId1, + '-o', + 'json', + ]) + if (r.exitCode !== 0) throw new Error(`resume 1 failed: ${r.stderr.slice(0, 200)}`) + return r + }, + { attempts: 3, delayMs: 3000 }, + ) assertExitCode(pause2, 0) const node2 = assertJson<{ @@ -552,28 +565,32 @@ describeMultiNode('E2E / difyctl run + resume — HITL 2 serial nodes (4.5.18)', workflow_run_id: string actions: Array<{ id: string }> }>(pause2) - expect(node2.status, 'after first resume the workflow must pause again at node 2').toBe('paused') + expect(node2.status, 'after first resume the workflow must pause again at node 2').toBe( + 'paused', + ) expect(node2.form_token, 'node 2 must return a new form_token').toBeTruthy() expect(node2.form_token, 'node 2 form_token must differ from node 1').not.toBe(node1.form_token) const actionId2 = node2.actions[0]?.id ?? 'action_1' // ── Step 3: resume node 2 — workflow finishes ───────────────────────── - const finish = await withRetry(async () => { - const r = await fx.r([ - 'resume', - 'app', - E.hitlMultiNodeAppId, - node2.form_token, - '--workflow-run-id', - node2.workflow_run_id, - '--action', - actionId2, - ]) - if (r.exitCode !== 0) - throw new Error(`resume 2 failed: ${r.stderr.slice(0, 200)}`) - return r - }, { attempts: 3, delayMs: 3000 }) + const finish = await withRetry( + async () => { + const r = await fx.r([ + 'resume', + 'app', + E.hitlMultiNodeAppId, + node2.form_token, + '--workflow-run-id', + node2.workflow_run_id, + '--action', + actionId2, + ]) + if (r.exitCode !== 0) throw new Error(`resume 2 failed: ${r.stderr.slice(0, 200)}`) + return r + }, + { attempts: 3, delayMs: 3000 }, + ) assertExitCode(finish, 0) expect(finish.stdout + finish.stderr).toMatch(/succeeded|finished/i) diff --git a/cli/test/e2e/suites/run/run-app-reasoning.e2e.ts b/cli/test/e2e/suites/run/run-app-reasoning.e2e.ts index fd5f12e0b57646..a84b6869746820 100644 --- a/cli/test/e2e/suites/run/run-app-reasoning.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-reasoning.e2e.ts @@ -42,18 +42,21 @@ describe('E2E / difyctl run app — separated reasoning', () => { await fx.cleanup() }) - reasoningIt('[P1] --think --stream surfaces reasoning on stderr, clean answer on stdout', async () => { - const result = await withRetry( - () => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--think', '--stream']), - { attempts: 3, delayMs: 1000 }, - ) + reasoningIt( + '[P1] --think --stream surfaces reasoning on stderr, clean answer on stdout', + async () => { + const result = await withRetry( + () => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--think', '--stream']), + { attempts: 3, delayMs: 1000 }, + ) - assertExitCode(result, 0) - expect(result.stdout.trim().length).toBeGreaterThan(0) - // Separated mode keeps the answer free of <think>; reasoning is framed on stderr. - expect(result.stdout).not.toContain('<think>') - assertStderrContains(result, '<think>') - }) + assertExitCode(result, 0) + expect(result.stdout.trim().length).toBeGreaterThan(0) + // Separated mode keeps the answer free of <think>; reasoning is framed on stderr. + expect(result.stdout).not.toContain('<think>') + assertStderrContains(result, '<think>') + }, + ) reasoningIt('[P1] --think -o json persists reasoning under metadata.reasoning', async () => { const result = await withRetry( diff --git a/cli/test/e2e/suites/run/run-app-streaming.e2e.ts b/cli/test/e2e/suites/run/run-app-streaming.e2e.ts index a8713404c41ab2..1579cdcefec05f 100644 --- a/cli/test/e2e/suites/run/run-app-streaming.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-streaming.e2e.ts @@ -46,30 +46,39 @@ describe('E2E / difyctl run app --stream (specialisation)', () => { it('[P0] streaming output arrives in real-time chunks (stdout non-empty, echo complete)', async () => { // Spec: streaming output is printed in real-time by chunk + token order is preserved // withRetry: staging SSE connections may fail transiently on cold start - await withRetry(async () => { - const query = 'chunk-order-test' - const proc = spawn(BUN, [BIN, 'run', 'app', E.chatAppId, query, '--stream'], { - env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1', DIFY_E2E_NO_KEYRING: '1' }, - }) - - const chunks: string[] = [] - proc.stdout.on('data', (d: Buffer) => { - chunks.push(d.toString('utf8')) - }) - - let stderr = '' - proc.stderr.on('data', (d: Buffer) => { - stderr += d.toString('utf8') - }) - - const exitCode = await new Promise<number>((res) => { - proc.on('close', code => res(code ?? 1)) - }) - - assertExitCode({ stdout: chunks.join(''), stderr, exitCode }, 0) - // May arrive in multiple chunks; the concatenated result must contain the full query - expect(chunks.join('')).toContain(query) - }, { attempts: 3, delayMs: 2000 }) + await withRetry( + async () => { + const query = 'chunk-order-test' + const proc = spawn(BUN, [BIN, 'run', 'app', E.chatAppId, query, '--stream'], { + env: { + ...process.env, + DIFY_CONFIG_DIR: fx.configDir, + CI: '1', + NO_COLOR: '1', + DIFY_E2E_NO_KEYRING: '1', + }, + }) + + const chunks: string[] = [] + proc.stdout.on('data', (d: Buffer) => { + chunks.push(d.toString('utf8')) + }) + + let stderr = '' + proc.stderr.on('data', (d: Buffer) => { + stderr += d.toString('utf8') + }) + + const exitCode = await new Promise<number>((res) => { + proc.on('close', (code) => res(code ?? 1)) + }) + + assertExitCode({ stdout: chunks.join(''), stderr, exitCode }, 0) + // May arrive in multiple chunks; the concatenated result must contain the full query + expect(chunks.join('')).toContain(query) + }, + { attempts: 3, delayMs: 2000 }, + ) }) // ── Basic streaming behaviour ─────────────────────────────────────────── @@ -92,7 +101,7 @@ describe('E2E / difyctl run app --stream (specialisation)', () => { // Spec: streaming mode produces valid JSON output const result = await fx.r(['run', 'app', E.chatAppId, 'sjson', '--stream', '-o', 'json']) assertExitCode(result, 0) - const parsed = assertJson<{ mode: string, answer: string }>(result) + const parsed = assertJson<{ mode: string; answer: string }>(result) expect(parsed.mode).toMatch(/chat/) }) @@ -134,7 +143,7 @@ describe('E2E / difyctl run app --stream (specialisation)', () => { stderr += d.toString('utf8') }) const exitCode = await new Promise<number>((res) => { - proc.on('close', code => res(code ?? 1)) + proc.on('close', (code) => res(code ?? 1)) }) expect(exitCode, 'error event should cause non-zero exit').not.toBe(0) expect(stderr.length).toBeGreaterThan(0) @@ -148,8 +157,7 @@ describe('E2E / difyctl run app --stream (specialisation)', () => { configDir: unauthTmp.configDir, }) assertExitCode(result, 4) - } - finally { + } finally { await unauthTmp.cleanup() } }) @@ -162,14 +170,20 @@ describe('E2E / difyctl run app --stream (specialisation)', () => { // ⚠️ Depends on feat/cli API version (server-side pre-validation of missing required inputs). // Current local server 1.14.1 does not support this check; test passes once upgraded. const proc = spawn(BUN, [BIN, 'run', 'app', E.workflowAppId, '--stream'], { - env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1', DIFY_E2E_NO_KEYRING: '1' }, + env: { + ...process.env, + DIFY_CONFIG_DIR: fx.configDir, + CI: '1', + NO_COLOR: '1', + DIFY_E2E_NO_KEYRING: '1', + }, }) let stderr = '' proc.stderr.on('data', (d: Buffer) => { stderr += d.toString('utf8') }) const exitCode = await new Promise<number>((res) => { - proc.on('close', code => res(code ?? 1)) + proc.on('close', (code) => res(code ?? 1)) }) expect(exitCode).not.toBe(0) // The server should return a clear validation error rather than timing out @@ -194,11 +208,11 @@ describe('E2E / difyctl run app --stream (specialisation)', () => { }) // Wait for the process to start streaming, then interrupt. - await new Promise(res => setTimeout(res, 800)) + await new Promise((res) => setTimeout(res, 800)) proc.kill('SIGINT') const exitCode = await new Promise<number>((res) => { - proc.on('close', code => res(code ?? 1)) + proc.on('close', (code) => res(code ?? 1)) }) expect(exitCode, 'SIGINT should cause non-zero exit').not.toBe(0) @@ -209,16 +223,17 @@ describe('E2E / difyctl run app --stream (specialisation)', () => { it('[P1] workflow streaming with multiple inputs passes all params correctly', async () => { // Spec 4.2.8: multiple --inputs entries take effect simultaneously in streaming mode const result = await withRetry( - () => fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'multi-stream-k1', num: 42, enum_var: 'A', paragraph: 'short text' }), - '--stream', - '-o', - 'json', - ]), + () => + fx.r([ + 'run', + 'app', + E.workflowAppId, + '--inputs', + JSON.stringify({ x: 'multi-stream-k1', num: 42, enum_var: 'A', paragraph: 'short text' }), + '--stream', + '-o', + 'json', + ]), { attempts: 3, delayMs: 2000 }, ) assertExitCode(result, 0) @@ -251,14 +266,13 @@ describe('E2E / difyctl run app --stream (specialisation)', () => { ` role: owner`, ].join('\n')}\n` await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run( - ['run', 'app', E.chatAppId, 'hello', '--stream'], - { configDir: networkTmp.configDir, timeout: 15_000 }, - ) + const result = await run(['run', 'app', E.chatAppId, 'hello', '--stream'], { + configDir: networkTmp.configDir, + timeout: 15_000, + }) expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) expect(result.stderr.length, 'stderr should contain error message').toBeGreaterThan(0) - } - finally { + } finally { await networkTmp.cleanup() } }) @@ -285,37 +299,46 @@ describe('E2E / difyctl run app --stream (specialisation)', () => { it('[P0] streaming with non-existent app id and query exits 1 with app-not-found error', async () => { // Spec 4.2.16: non-existent app id + positional query → app not found, exit code 1 // Distinct from the earlier server-error test: this checks exit=1 precisely and the not-found message. - const result = await fx.r(['run', 'app', 'nonexistent-app-id-404-streaming-e2e', 'hello', '--stream']) + const result = await fx.r([ + 'run', + 'app', + 'nonexistent-app-id-404-streaming-e2e', + 'hello', + '--stream', + ]) expect(result.exitCode, 'app not found should exit with code 1').toBe(1) expect(result.stderr).toMatch(/not.?found|404|does not exist/i) }) // ── SSO (dfoe_) token in streaming mode (4.2.18) ────────────────────── - itWithSso('[P0] streaming with SSO (dfoe_) token succeeds (exit code 0, stdout non-empty)', async () => { - // Spec 4.2.18: dfoe_ token can invoke streaming run on an authorised app - const ssoTmp = await withTempConfig() - try { - await injectAuth(ssoTmp.configDir, { - host: E.host, - bearer: E.ssoToken, - email: 'sso-e2e@example.com', - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const result = await withRetry( - () => run(['run', 'app', E.chatAppId, 'sso-stream-test', '--stream'], { - configDir: ssoTmp.configDir, - }), - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(result, 0) - expect(result.stdout.length, 'SSO streaming should produce output').toBeGreaterThan(0) - } - finally { - await ssoTmp.cleanup() - } - }) + itWithSso( + '[P0] streaming with SSO (dfoe_) token succeeds (exit code 0, stdout non-empty)', + async () => { + // Spec 4.2.18: dfoe_ token can invoke streaming run on an authorised app + const ssoTmp = await withTempConfig() + try { + await injectAuth(ssoTmp.configDir, { + host: E.host, + bearer: E.ssoToken, + email: 'sso-e2e@example.com', + workspaceId: E.workspaceId, + workspaceName: E.workspaceName, + }) + const result = await withRetry( + () => + run(['run', 'app', E.chatAppId, 'sso-stream-test', '--stream'], { + configDir: ssoTmp.configDir, + }), + { attempts: 3, delayMs: 2000 }, + ) + assertExitCode(result, 0) + expect(result.stdout.length, 'SSO streaming should produce output').toBeGreaterThan(0) + } finally { + await ssoTmp.cleanup() + } + }, + ) // ── JSON error envelope for non-existent app in -o json mode (4.2.23) ─ diff --git a/cli/test/fixtures/config-dir.ts b/cli/test/fixtures/config-dir.ts index fa32563236df88..fa2cf169f9571c 100644 --- a/cli/test/fixtures/config-dir.ts +++ b/cli/test/fixtures/config-dir.ts @@ -15,8 +15,7 @@ export function useTempConfigDir(prefix: string): () => string { process.env[ENV_CONFIG_DIR] = dir }) afterEach(async () => { - if (prev === undefined) - delete process.env[ENV_CONFIG_DIR] + if (prev === undefined) delete process.env[ENV_CONFIG_DIR] else process.env[ENV_CONFIG_DIR] = prev await rm(dir, { recursive: true, force: true }) }) diff --git a/cli/test/fixtures/dify-mock/scenarios.ts b/cli/test/fixtures/dify-mock/scenarios.ts index 2e0b74d3d54354..667e2d0d0d5606 100644 --- a/cli/test/fixtures/dify-mock/scenarios.ts +++ b/cli/test/fixtures/dify-mock/scenarios.ts @@ -1,24 +1,24 @@ -export type Scenario - = | 'happy' - | 'sso' - | 'no-email' - | 'denied' - | 'expired' - | 'auth-expired' - | 'rate-limited' - | 'server-5xx' - | 'slow-down' - | 'stream-error' - | 'hitl-pause' - | 'hitl-resume' - | 'server-version-empty' - | 'server-version-unsupported' - | 'run-422-stale' - | 'workflow-think' - | 'chat-reasoning' - | 'workflow-reasoning' - | 'import-pending' - | 'import-failed' +export type Scenario = + | 'happy' + | 'sso' + | 'no-email' + | 'denied' + | 'expired' + | 'auth-expired' + | 'rate-limited' + | 'server-5xx' + | 'slow-down' + | 'stream-error' + | 'hitl-pause' + | 'hitl-resume' + | 'server-version-empty' + | 'server-version-unsupported' + | 'run-422-stale' + | 'workflow-think' + | 'chat-reasoning' + | 'workflow-reasoning' + | 'import-pending' + | 'import-failed' export type AccountFixture = { id: string @@ -73,8 +73,20 @@ export const ACCOUNT: AccountFixture = { } export const WORKSPACES: WorkspaceFixture[] = [ - { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner', status: 'normal', is_current: true }, - { id: '550e8400-e29b-41d4-a716-446655440001', name: 'Other', role: 'normal', status: 'normal', is_current: false }, + { + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'Default', + role: 'owner', + status: 'normal', + is_current: true, + }, + { + id: '550e8400-e29b-41d4-a716-446655440001', + name: 'Other', + role: 'normal', + status: 'normal', + is_current: false, + }, ] export const APPS: AppFixture[] = [ diff --git a/cli/test/fixtures/dify-mock/server.test.ts b/cli/test/fixtures/dify-mock/server.test.ts index e80e20643c7c9e..b7eb3d70c8f118 100644 --- a/cli/test/fixtures/dify-mock/server.test.ts +++ b/cli/test/fixtures/dify-mock/server.test.ts @@ -55,8 +55,14 @@ describe('dify-mock fixture server', () => { headers: { Authorization: 'Bearer dfoa_test' }, }) expect(r.status).toBe(200) - const body = await r.json() as { - workspaces: Array<{ id: string, name: string, role: string, status: string, current: boolean }> + const body = (await r.json()) as { + workspaces: Array<{ + id: string + name: string + role: string + status: string + current: boolean + }> } expect(body.workspaces).toHaveLength(2) expect(body.workspaces[0]?.id).toBe('550e8400-e29b-41d4-a716-446655440000') @@ -71,7 +77,7 @@ describe('dify-mock fixture server', () => { headers: { Authorization: 'Bearer dfoa_test' }, }) expect(r.status).toBe(200) - const body = await r.json() as { workspaces: unknown[] } + const body = (await r.json()) as { workspaces: unknown[] } expect(body.workspaces).toHaveLength(0) }) @@ -80,7 +86,7 @@ describe('dify-mock fixture server', () => { headers: { Authorization: 'Bearer dfoa_test' }, }) expect(r.status).toBe(200) - const body = await r.json() as { + const body = (await r.json()) as { subject_type: string account: { email: string } | null workspaces: Array<{ id: string }> @@ -93,37 +99,49 @@ describe('dify-mock fixture server', () => { }) it('GET /openapi/v1/apps respects ?mode filter', async () => { - const r = await fetch(`${mock.url}/openapi/v1/apps?workspace_id=550e8400-e29b-41d4-a716-446655440000&mode=workflow`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) - const body = await r.json() as { data: Array<{ mode: string }>, total: number } + const r = await fetch( + `${mock.url}/openapi/v1/apps?workspace_id=550e8400-e29b-41d4-a716-446655440000&mode=workflow`, + { + headers: { Authorization: 'Bearer dfoa_test' }, + }, + ) + const body = (await r.json()) as { data: Array<{ mode: string }>; total: number } expect(body.data).toHaveLength(1) expect(body.data[0]?.mode).toBe('workflow') expect(body.total).toBe(1) }) it('GET /openapi/v1/apps scopes by workspace_id', async () => { - const r = await fetch(`${mock.url}/openapi/v1/apps?workspace_id=550e8400-e29b-41d4-a716-446655440001`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) - const body = await r.json() as { data: Array<{ id: string }> } + const r = await fetch( + `${mock.url}/openapi/v1/apps?workspace_id=550e8400-e29b-41d4-a716-446655440001`, + { + headers: { Authorization: 'Bearer dfoa_test' }, + }, + ) + const body = (await r.json()) as { data: Array<{ id: string }> } expect(body.data).toHaveLength(2) - expect(body.data.map(r => r.id).sort()).toEqual(['app-3', 'app-4']) + expect(body.data.map((r) => r.id).sort()).toEqual(['app-3', 'app-4']) }) it('GET /openapi/v1/apps/:id returns 404 for unknown id', async () => { - const r = await fetch(`${mock.url}/openapi/v1/apps/nope?workspace_id=550e8400-e29b-41d4-a716-446655440000`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) + const r = await fetch( + `${mock.url}/openapi/v1/apps/nope?workspace_id=550e8400-e29b-41d4-a716-446655440000`, + { + headers: { Authorization: 'Bearer dfoa_test' }, + }, + ) expect(r.status).toBe(404) }) it('GET /openapi/v1/apps/:id returns the app for known id', async () => { - const r = await fetch(`${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) + const r = await fetch( + `${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000`, + { + headers: { Authorization: 'Bearer dfoa_test' }, + }, + ) expect(r.status).toBe(200) - const body = await r.json() as { info: { id: string } } + const body = (await r.json()) as { info: { id: string } } expect(body.info.id).toBe('app-1') }) @@ -131,7 +149,7 @@ describe('dify-mock fixture server', () => { const r = await fetch(`${mock.url}/openapi/v1/apps/app-1:run`, { method: 'POST', headers: { - 'Authorization': 'Bearer dfoa_test', + Authorization: 'Bearer dfoa_test', 'Content-Type': 'application/json', }, body: JSON.stringify({ query: 'hi', inputs: {} }), @@ -146,7 +164,7 @@ describe('dify-mock fixture server', () => { const r = await fetch(`${mock.url}/openapi/v1/apps/app-2:run`, { method: 'POST', headers: { - 'Authorization': 'Bearer dfoa_test', + Authorization: 'Bearer dfoa_test', 'Content-Type': 'application/json', }, body: JSON.stringify({ inputs: { x: 1 } }), @@ -158,22 +176,32 @@ describe('dify-mock fixture server', () => { }) it('GET /openapi/v1/apps/:id?fields=info returns slim payload', async () => { - const r = await fetch(`${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000&fields=info`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) + const r = await fetch( + `${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000&fields=info`, + { + headers: { Authorization: 'Bearer dfoa_test' }, + }, + ) expect(r.status).toBe(200) - const body = await r.json() as { info: { id: string }, parameters: unknown, input_schema: unknown } + const body = (await r.json()) as { + info: { id: string } + parameters: unknown + input_schema: unknown + } expect(body.info.id).toBe('app-1') expect(body.parameters).toBeNull() expect(body.input_schema).toBeNull() }) it('GET /openapi/v1/apps/:id full returns parameters when present', async () => { - const r = await fetch(`${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) + const r = await fetch( + `${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000`, + { + headers: { Authorization: 'Bearer dfoa_test' }, + }, + ) expect(r.status).toBe(200) - const body = await r.json() as { parameters: { opening_statement: string } | null } + const body = (await r.json()) as { parameters: { opening_statement: string } | null } expect(body.parameters?.opening_statement).toBe('Hi, I am Greeter.') }) @@ -184,7 +212,7 @@ describe('dify-mock fixture server', () => { body: JSON.stringify({ client_id: 'difyctl', device_label: 'difyctl on host' }), }) expect(r.status).toBe(200) - const body = await r.json() as Record<string, unknown> + const body = (await r.json()) as Record<string, unknown> expect(body.device_code).toBeDefined() expect(body.user_code).toBeDefined() expect(body.interval).toBeDefined() @@ -197,7 +225,11 @@ describe('dify-mock fixture server', () => { body: JSON.stringify({ client_id: 'difyctl', device_code: 'devcode-1' }), }) expect(r.status).toBe(200) - const body = await r.json() as { token: string, subject_type: string, account?: { email: string } } + const body = (await r.json()) as { + token: string + subject_type: string + account?: { email: string } + } expect(body.token).toMatch(/^dfoa_/) expect(body.subject_type).toBe('account') expect(body.account?.email).toBe('tester@dify.ai') @@ -211,7 +243,7 @@ describe('dify-mock fixture server', () => { body: JSON.stringify({ device_code: 'devcode-1' }), }) expect(r.status).toBe(200) - const body = await r.json() as { token: string, subject_type: string, subject_email: string } + const body = (await r.json()) as { token: string; subject_type: string; subject_email: string } expect(body.token).toMatch(/^dfoe_/) expect(body.subject_type).toBe('external_sso') expect(body.subject_email).toBe('sso@dify.ai') @@ -225,7 +257,7 @@ describe('dify-mock fixture server', () => { body: JSON.stringify({ device_code: 'devcode-1' }), }) expect(r.status).toBe(400) - const body = await r.json() as { error: string } + const body = (await r.json()) as { error: string } expect(body.error).toBe('access_denied') }) @@ -237,7 +269,7 @@ describe('dify-mock fixture server', () => { body: JSON.stringify({ device_code: 'devcode-1' }), }) expect(r.status).toBe(400) - const body = await r.json() as { error: string } + const body = (await r.json()) as { error: string } expect(body.error).toBe('expired_token') }) @@ -249,7 +281,7 @@ describe('dify-mock fixture server', () => { body: JSON.stringify({ device_code: 'devcode-1' }), }) expect(r.status).toBe(400) - const body = await r.json() as { error: string } + const body = (await r.json()) as { error: string } expect(body.error).toBe('slow_down') }) diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index d38e723988f75a..ee9bfe3a00afd3 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -32,8 +32,8 @@ function unauthorized() { ) } -function sseChunks(events: { event: string, data: Record<string, unknown> }[]): string { - return events.map(e => `data: ${JSON.stringify({ ...e.data, event: e.event })}\n\n`).join('') +function sseChunks(events: { event: string; data: Record<string, unknown> }[]): string { + return events.map((e) => `data: ${JSON.stringify({ ...e.data, event: e.event })}\n\n`).join('') } function streamingRunResponse(mode: string, query: string, isAgent: boolean): string { @@ -42,7 +42,14 @@ function streamingRunResponse(mode: string, query: string, isAgent: boolean): st { event: 'workflow_started', data: { id: 'wf-run-1', workflow_id: 'wf-1' } }, { event: 'node_started', data: { id: 'n1', title: 'first' } }, { event: 'node_finished', data: { id: 'n1', status: 'succeeded' } }, - { event: 'workflow_finished', data: { id: 'wf-run-1', workflow_id: 'wf-1', data: { id: 'wf-run-1', status: 'succeeded', outputs: { result: `echo: ${query}` } } } }, + { + event: 'workflow_finished', + data: { + id: 'wf-run-1', + workflow_id: 'wf-1', + data: { id: 'wf-run-1', status: 'succeeded', outputs: { result: `echo: ${query}` } }, + }, + }, ]) } if (mode === 'completion') { @@ -53,13 +60,18 @@ function streamingRunResponse(mode: string, query: string, isAgent: boolean): st ]) } const evt = isAgent ? 'agent_message' : 'message' - const events: { event: string, data: Record<string, unknown> }[] = [ - { event: evt, data: { message_id: 'msg-1', conversation_id: 'conv-1', mode, answer: 'echo: ' } }, + const events: { event: string; data: Record<string, unknown> }[] = [ + { + event: evt, + data: { message_id: 'msg-1', conversation_id: 'conv-1', mode, answer: 'echo: ' }, + }, { event: evt, data: { answer: query } }, ] - if (isAgent) - events.push({ event: 'agent_thought', data: { thought: 'thinking…' } }) - events.push({ event: 'message_end', data: { message_id: 'msg-1', conversation_id: 'conv-1', metadata: {} } }) + if (isAgent) events.push({ event: 'agent_thought', data: { thought: 'thinking…' } }) + events.push({ + event: 'message_end', + data: { message_id: 'msg-1', conversation_id: 'conv-1', metadata: {} }, + }) return sseChunks(events) } @@ -114,7 +126,7 @@ export type MockState = { export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { const app = new Hono() - app.get('/healthz', c => c.json({ ok: true })) + app.get('/healthz', (c) => c.json({ ok: true })) app.use('*', async (c, next) => { if (c.req.path === '/healthz') { @@ -130,18 +142,15 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { return } const auth = c.req.header('Authorization') ?? '' - if (!TOKEN_RE.test(auth)) - return unauthorized() + if (!TOKEN_RE.test(auth)) return unauthorized() const scenario = getScenario() - if (scenario === 'auth-expired') - return unauthorized() + if (scenario === 'auth-expired') return unauthorized() await next() }) app.get('/openapi/v1/_version', (c) => { const scenario = getScenario() - if (scenario === 'server-version-empty') - return c.json({ version: '', edition: 'SELF_HOSTED' }) + if (scenario === 'server-version-empty') return c.json({ version: '', edition: 'SELF_HOSTED' }) if (scenario === 'server-version-unsupported') return c.json({ version: '99.0.0', edition: 'SELF_HOSTED' }) return c.json({ version: '1.6.4', edition: 'CLOUD' }) @@ -152,15 +161,16 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { if (scenario === 'rate-limited') { // Unified ErrorBody — per-token throttle (retryable); Retry-After advises the wait. return c.json( - { code: 'too_many_requests', message: 'Too many requests for this API token.', status: 429 }, + { + code: 'too_many_requests', + message: 'Too many requests for this API token.', + status: 429, + }, { status: 429, headers: { 'retry-after': '1' } }, ) } if (scenario === 'server-5xx') { - return c.json( - { error: { code: 'server_5xx', message: 'upstream broken' } }, - { status: 503 }, - ) + return c.json({ error: { code: 'server_5xx', message: 'upstream broken' } }, { status: 503 }) } await next() }) @@ -181,7 +191,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { subject_type: 'account', subject_email: ACCOUNT.email, account: { id: ACCOUNT.id, email: ACCOUNT.email, name: ACCOUNT.name }, - workspaces: WORKSPACES.map(w => ({ id: w.id, name: w.name, role: w.role })), + workspaces: WORKSPACES.map((w) => ({ id: w.id, name: w.name, role: w.role })), default_workspace_id: ACCOUNT.current_workspace_id, }) }) @@ -202,20 +212,20 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { }) app.delete('/openapi/v1/account/sessions/self', () => - Response.json({ status: 'revoked' }, { status: 200 })) + Response.json({ status: 'revoked' }, { status: 200 }), + ) app.delete('/openapi/v1/account/sessions/:id', (c) => { const id = c.req.param('id') - if (!SESSIONS.some(s => s.id === id)) + if (!SESSIONS.some((s) => s.id === id)) return c.json({ error: { code: 'not_found', message: 'session not found' } }, { status: 404 }) return Response.json({ status: 'revoked' }, { status: 200 }) }) app.get('/openapi/v1/workspaces', (c) => { - if (getScenario() === 'sso') - return c.json({ workspaces: [] }) + if (getScenario() === 'sso') return c.json({ workspaces: [] }) return c.json({ - workspaces: WORKSPACES.map(w => ({ + workspaces: WORKSPACES.map((w) => ({ id: w.id, name: w.name, role: w.role, @@ -232,13 +242,11 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { const tag = c.req.query('tag') const name = c.req.query('name') const workspaceId = c.req.query('workspace_id') ?? ACCOUNT.current_workspace_id - let filtered = APPS.filter(a => a.workspace_id === workspaceId) - if (mode !== undefined && mode !== '') - filtered = filtered.filter(a => a.mode === mode) + let filtered = APPS.filter((a) => a.workspace_id === workspaceId) + if (mode !== undefined && mode !== '') filtered = filtered.filter((a) => a.mode === mode) if (tag !== undefined && tag !== '') - filtered = filtered.filter(a => a.tags.some(t => t.name === tag)) - if (name !== undefined && name !== '') - filtered = filtered.filter(a => a.name.includes(name)) + filtered = filtered.filter((a) => a.tags.some((t) => t.name === tag)) + if (name !== undefined && name !== '') filtered = filtered.filter((a) => a.name.includes(name)) const total = filtered.length const start = (page - 1) * limit const slice = filtered.slice(start, start + limit) @@ -255,8 +263,16 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { const id = c.req.param('id') const wsId = c.req.query('workspace_id') const fieldsRaw = c.req.query('fields') ?? '' - const fields = fieldsRaw === '' ? [] : fieldsRaw.split(',').map(s => s.trim()).filter(s => s !== '') - const app = APPS.find(a => a.id === id && (wsId === undefined || wsId === '' || a.workspace_id === wsId)) + const fields = + fieldsRaw === '' + ? [] + : fieldsRaw + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== '') + const app = APPS.find( + (a) => a.id === id && (wsId === undefined || wsId === '' || a.workspace_id === wsId), + ) if (app === undefined) return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 }) const wantInfo = fields.length === 0 || fields.includes('info') @@ -282,9 +298,15 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { app.get('/openapi/v1/permitted-external-apps/:id', (c) => { const id = c.req.param('id') const fieldsRaw = c.req.query('fields') ?? '' - const fields = fieldsRaw === '' ? [] : fieldsRaw.split(',').map(s => s.trim()).filter(s => s !== '') + const fields = + fieldsRaw === '' + ? [] + : fieldsRaw + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== '') // External subjects have no workspace scope; the app is reachable across workspaces. - const app = APPS.find(a => a.id === id) + const app = APPS.find((a) => a.id === id) if (app === undefined) return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 }) const wantInfo = fields.length === 0 || fields.includes('info') @@ -309,7 +331,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { app.get('/openapi/v1/apps/:id/dsl', (c) => { const id = c.req.param('id') - const found = APPS.find(a => a.id === id) + const found = APPS.find((a) => a.id === id) if (found === undefined) return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 }) return c.json({ data: DSL_YAML }) @@ -317,35 +339,50 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { app.get('/openapi/v1/apps/:id/dependencies:check', (c) => { const id = c.req.param('id') - const found = APPS.find(a => a.id === id) + const found = APPS.find((a) => a.id === id) if (found === undefined) return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 }) return c.json({ leaked_dependencies: [] }) }) app.post('/openapi/v1/workspaces/:wsId/apps/imports', async (c) => { - const body = await c.req.json() as Record<string, unknown> - if (state !== undefined) - state.lastImportBody = body + const body = (await c.req.json()) as Record<string, unknown> + if (state !== undefined) state.lastImportBody = body const scenario = getScenario() if (scenario === 'import-failed') - return c.json({ id: 'imp-1', status: 'failed', error: 'unsupported DSL version' }, { status: 200 }) + return c.json( + { id: 'imp-1', status: 'failed', error: 'unsupported DSL version' }, + { status: 200 }, + ) if (scenario === 'import-pending') - return c.json({ id: 'imp-1', status: 'pending', current_dsl_version: '0.1.4', imported_dsl_version: '0.0.9' }, { status: 202 }) - return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 }) + return c.json( + { + id: 'imp-1', + status: 'pending', + current_dsl_version: '0.1.4', + imported_dsl_version: '0.0.9', + }, + { status: 202 }, + ) + return c.json( + { id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, + { status: 200 }, + ) }) app.post('/openapi/v1/workspaces/:wsId/apps/imports/:importId:confirm', (c) => { - return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 }) + return c.json( + { id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, + { status: 200 }, + ) }) app.post('/openapi/v1/apps/:id:run', async (c) => { // Hono drops the param adjacent to the `:run` literal; recover the app id from the path. const id = c.req.path.replace(/^.*\/apps\//, '').replace(/:run$/, '') - const body = await c.req.json() as { query?: string, inputs?: unknown } - if (state !== undefined) - state.lastRunBody = body as Record<string, unknown> - const app = APPS.find(a => a.id === id) + const body = (await c.req.json()) as { query?: string; inputs?: unknown } + if (state !== undefined) state.lastRunBody = body as Record<string, unknown> + const app = APPS.find((a) => a.id === id) if (app === undefined) return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 }) const isAgent = app.is_agent === true || app.mode === 'agent-chat' @@ -353,7 +390,12 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { const scenario = getScenario() if (scenario === 'run-422-stale') { return c.json( - { error: { code: 'query_not_supported_for_workflow', message: 'query not supported for workflow mode' } }, + { + error: { + code: 'query_not_supported_for_workflow', + message: 'query not supported for workflow mode', + }, + }, { status: 422 }, ) } @@ -362,26 +404,75 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { return new Response(errSse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) } if (scenario === 'hitl-pause') { - return new Response(hitlPauseResponse(), { status: 200, headers: { 'content-type': 'text/event-stream' } }) + return new Response(hitlPauseResponse(), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) } if (scenario === 'workflow-think') { const thinkSse = sseChunks([ { event: 'workflow_started', data: { id: 'wf-run-1', workflow_id: 'wf-1' } }, - { event: 'workflow_finished', data: { id: 'wf-run-1', workflow_id: 'wf-1', data: { id: 'wf-run-1', status: 'succeeded', outputs: { result: '<think>secret reasoning</think>\nfinal answer' } } } }, + { + event: 'workflow_finished', + data: { + id: 'wf-run-1', + workflow_id: 'wf-1', + data: { + id: 'wf-run-1', + status: 'succeeded', + outputs: { result: '<think>secret reasoning</think>\nfinal answer' }, + }, + }, + }, ]) - return new Response(thinkSse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) + return new Response(thinkSse, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) } if (scenario === 'chat-reasoning') { // Separated mode: reasoning streams out-of-band on `reasoning_chunk` (nested // under `data`), the answer stays free of <think>, and the terminal reasoning // is persisted into message_end metadata. const reasoningSse = sseChunks([ - { event: 'reasoning_chunk', data: { data: { message_id: 'msg-1', reasoning: 'secret reasoning', node_id: 'llm-1', is_final: false } } }, - { event: 'reasoning_chunk', data: { data: { message_id: 'msg-1', reasoning: '', node_id: 'llm-1', is_final: true } } }, - { event: 'message', data: { message_id: 'msg-1', conversation_id: 'conv-1', mode: app.mode, answer: 'final answer' } }, - { event: 'message_end', data: { message_id: 'msg-1', conversation_id: 'conv-1', task_id: 'task-1', metadata: { reasoning: { 'llm-1': 'secret reasoning' } } } }, + { + event: 'reasoning_chunk', + data: { + data: { + message_id: 'msg-1', + reasoning: 'secret reasoning', + node_id: 'llm-1', + is_final: false, + }, + }, + }, + { + event: 'reasoning_chunk', + data: { data: { message_id: 'msg-1', reasoning: '', node_id: 'llm-1', is_final: true } }, + }, + { + event: 'message', + data: { + message_id: 'msg-1', + conversation_id: 'conv-1', + mode: app.mode, + answer: 'final answer', + }, + }, + { + event: 'message_end', + data: { + message_id: 'msg-1', + conversation_id: 'conv-1', + task_id: 'task-1', + metadata: { reasoning: { 'llm-1': 'secret reasoning' } }, + }, + }, ]) - return new Response(reasoningSse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) + return new Response(reasoningSse, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) } if (scenario === 'workflow-reasoning') { // Separated mode in a workflow: reasoning streams out-of-band on @@ -390,20 +481,35 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { const wfReasoningSse = sseChunks([ { event: 'workflow_started', data: { id: 'wf-run-1', workflow_id: 'wf-1' } }, { event: 'node_started', data: { id: 'llm-1', title: 'LLM' } }, - { event: 'reasoning_chunk', data: { data: { reasoning: 'secret reasoning', node_id: 'llm-1', is_final: false } } }, - { event: 'reasoning_chunk', data: { data: { reasoning: '', node_id: 'llm-1', is_final: true } } }, + { + event: 'reasoning_chunk', + data: { data: { reasoning: 'secret reasoning', node_id: 'llm-1', is_final: false } }, + }, + { + event: 'reasoning_chunk', + data: { data: { reasoning: '', node_id: 'llm-1', is_final: true } }, + }, { event: 'node_finished', data: { id: 'llm-1', status: 'succeeded' } }, - { event: 'workflow_finished', data: { id: 'wf-run-1', workflow_id: 'wf-1', data: { id: 'wf-run-1', status: 'succeeded', outputs: { result: 'final answer' } } } }, + { + event: 'workflow_finished', + data: { + id: 'wf-run-1', + workflow_id: 'wf-1', + data: { id: 'wf-run-1', status: 'succeeded', outputs: { result: 'final answer' } }, + }, + }, ]) - return new Response(wfReasoningSse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) + return new Response(wfReasoningSse, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) } const sse = streamingRunResponse(app.mode, query, isAgent) return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) }) app.post('/openapi/v1/apps/:id/files', async (c) => { - if (state !== undefined) - state.uploadCallCount++ + if (state !== undefined) state.uploadCallCount++ const form = await c.req.formData() const file = form.get('file') if (!(file instanceof File)) @@ -431,10 +537,13 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { }) app.get('/openapi/v1/apps/:id/tasks/:task_id/events', (_c) => { - return new Response(hitlResumedResponse(), { status: 200, headers: { 'content-type': 'text/event-stream' } }) + return new Response(hitlResumedResponse(), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) }) - app.post('/openapi/v1/oauth/device/code', c => + app.post('/openapi/v1/oauth/device/code', (c) => c.json({ device_code: 'devcode-1', user_code: 'ABCD-1234', @@ -442,14 +551,18 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { verification_uri_complete: `${new URL(c.req.url).origin}/device?user_code=ABCD-1234`, expires_in: 600, interval: 1, - })) + }), + ) app.post('/openapi/v1/oauth/device/token', async (c) => { const scenario = getScenario() if (scenario === 'denied') return c.json({ error: 'access_denied', error_description: 'user rejected' }, { status: 400 }) if (scenario === 'expired') - return c.json({ error: 'expired_token', error_description: 'device_code expired' }, { status: 400 }) + return c.json( + { error: 'expired_token', error_description: 'device_code expired' }, + { status: 400 }, + ) if (scenario === 'slow-down') return c.json({ error: 'slow_down', error_description: 'increase interval' }, { status: 400 }) if (scenario === 'sso') { @@ -469,7 +582,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { token: 'dfoa_test', subject_type: 'account', account: { id: ACCOUNT.id, email: '', name: '' }, - workspaces: WORKSPACES.map(w => ({ id: w.id, name: w.name, role: w.role })), + workspaces: WORKSPACES.map((w) => ({ id: w.id, name: w.name, role: w.role })), default_workspace_id: ACCOUNT.current_workspace_id, token_id: 'tok-1', }) @@ -478,7 +591,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { token: 'dfoa_test', subject_type: 'account', account: ACCOUNT, - workspaces: WORKSPACES.map(w => ({ id: w.id, name: w.name, role: w.role })), + workspaces: WORKSPACES.map((w) => ({ id: w.id, name: w.name, role: w.role })), default_workspace_id: ACCOUNT.current_workspace_id, token_id: 'tok-1', }) @@ -504,15 +617,23 @@ export function startMock(opts: DifyMockOptions = {}): Promise<DifyMock> { url: `http://127.0.0.1:${addr.port}`, port: addr.port, scenario, - setScenario(s) { scenario = s }, + setScenario(s) { + scenario = s + }, stop() { return new Promise<void>((res, rej) => { - server.close(err => err ? rej(err) : res()) + server.close((err) => (err ? rej(err) : res())) }) }, - get lastRunBody() { return state.lastRunBody }, - get uploadCallCount() { return state.uploadCallCount }, - get lastImportBody() { return state.lastImportBody }, + get lastRunBody() { + return state.lastRunBody + }, + get uploadCallCount() { + return state.uploadCallCount + }, + get lastImportBody() { + return state.lastImportBody + }, }) }) server.on('error', reject) diff --git a/cli/test/fixtures/http-client.ts b/cli/test/fixtures/http-client.ts index fdfda7c1fe43b8..ca198729c618ef 100644 --- a/cli/test/fixtures/http-client.ts +++ b/cli/test/fixtures/http-client.ts @@ -7,8 +7,7 @@ type ClientOverrides = Omit<ClientOptions, 'baseURL'> // Wraps createHttpClient + openAPIBase for tests so call sites read at a glance. // Accepts a bare bearer string for the common case, or an options object for everything else. export function testHttpClient(host: string, bearerOrOpts?: string | ClientOverrides): HttpClient { - const opts: ClientOverrides = typeof bearerOrOpts === 'string' - ? { bearer: bearerOrOpts } - : (bearerOrOpts ?? {}) + const opts: ClientOverrides = + typeof bearerOrOpts === 'string' ? { bearer: bearerOrOpts } : (bearerOrOpts ?? {}) return createHttpClient({ baseURL: openAPIBase(host), ...opts }) } diff --git a/cli/test/fixtures/stub-server.ts b/cli/test/fixtures/stub-server.ts index 947f867127113a..7b175e59c836ce 100644 --- a/cli/test/fixtures/stub-server.ts +++ b/cli/test/fixtures/stub-server.ts @@ -29,7 +29,7 @@ export function jsonResponder( captured.url = req.url captured.headers = req.headers const chunks: Buffer[] = [] - req.on('data', c => chunks.push(c)) + req.on('data', (c) => chunks.push(c)) req.on('end', () => { captured.body = Buffer.concat(chunks).toString('utf8') const payload = JSON.stringify(body) @@ -58,7 +58,7 @@ export function startStubServer( url: `http://127.0.0.1:${addr.port}`, captured, stop: () => - new Promise<void>((res, rej) => server.close(err => (err ? rej(err) : res()))), + new Promise<void>((res, rej) => server.close((err) => (err ? rej(err) : res()))), }) }) server.on('error', reject) diff --git a/cli/test/scripts/resolve-buildinfo.test.ts b/cli/test/scripts/resolve-buildinfo.test.ts index 45649f73cd0a20..8af696b26110ff 100644 --- a/cli/test/scripts/resolve-buildinfo.test.ts +++ b/cli/test/scripts/resolve-buildinfo.test.ts @@ -35,10 +35,8 @@ describe('resolveBuildInfo', () => { const calls: string[] = [] const git = (cmd: string) => { calls.push(cmd) - if (cmd.startsWith('git describe')) - return 'v1.0.0-5-gabc1234-dirty' - if (cmd.startsWith('git rev-parse')) - return '1234567890abcdef' + if (cmd.startsWith('git describe')) return 'v1.0.0-5-gabc1234-dirty' + if (cmd.startsWith('git rev-parse')) return '1234567890abcdef' return null } const info = resolveBuildInfo({ env: {}, git, now: fixedNow, pkg: noPkg }) @@ -50,10 +48,7 @@ describe('resolveBuildInfo', () => { minDify: '0.0.0', maxDify: '0.0.0', }) - expect(calls).toStrictEqual([ - 'git describe --tags --dirty --always', - 'git rev-parse HEAD', - ]) + expect(calls).toStrictEqual(['git describe --tags --dirty --always', 'git rev-parse HEAD']) }) it('uses string defaults when env unset, git unavailable, and package.json empty', () => { @@ -76,7 +71,12 @@ describe('resolveBuildInfo', () => { it('throws on removed nightly channel', () => { expect(() => - resolveBuildInfo({ env: { DIFYCTL_CHANNEL: 'nightly' }, git: noGit, now: fixedNow, pkg: noPkg }), + resolveBuildInfo({ + env: { DIFYCTL_CHANNEL: 'nightly' }, + git: noGit, + now: fixedNow, + pkg: noPkg, + }), ).toThrow(/invalid DIFYCTL_CHANNEL: nightly/) }) @@ -133,7 +133,9 @@ describe('resolveBuildInfo', () => { }) it('falls back to package.json#difyctl.compat when env unset', () => { - const pkg = () => ({ difyctl: { compat: { minDify: '1.6.0', maxDify: '1.7.0' }, channel: 'rc' } }) + const pkg = () => ({ + difyctl: { compat: { minDify: '1.6.0', maxDify: '1.7.0' }, channel: 'rc' }, + }) const info = resolveBuildInfo({ env: {}, git: noGit, now: fixedNow, pkg }) expect(info.minDify).toBe('1.6.0') expect(info.maxDify).toBe('1.7.0') @@ -141,7 +143,9 @@ describe('resolveBuildInfo', () => { }) it('env wins over package.json for compat range and channel', () => { - const pkg = () => ({ difyctl: { compat: { minDify: '1.6.0', maxDify: '1.7.0' }, channel: 'rc' } }) + const pkg = () => ({ + difyctl: { compat: { minDify: '1.6.0', maxDify: '1.7.0' }, channel: 'rc' }, + }) const info = resolveBuildInfo({ env: { DIFYCTL_MIN_DIFY: '2.0.0', diff --git a/cli/test/setup.ts b/cli/test/setup.ts index 292dff0d82f1f0..16ddd21b7846ca 100644 --- a/cli/test/setup.ts +++ b/cli/test/setup.ts @@ -1,6 +1,7 @@ -(globalThis as unknown as Record<string, string>).__DIFYCTL_VERSION__ = '0.0.0-test'; -(globalThis as unknown as Record<string, string>).__DIFYCTL_COMMIT__ = '0000000'; -(globalThis as unknown as Record<string, string>).__DIFYCTL_BUILD_DATE__ = '1970-01-01T00:00:00.000Z'; -(globalThis as unknown as Record<string, string>).__DIFYCTL_CHANNEL__ = 'dev'; -(globalThis as unknown as Record<string, string>).__DIFYCTL_MIN_DIFY__ = '1.6.0'; -(globalThis as unknown as Record<string, string>).__DIFYCTL_MAX_DIFY__ = '1.7.0' +;(globalThis as unknown as Record<string, string>).__DIFYCTL_VERSION__ = '0.0.0-test' +;(globalThis as unknown as Record<string, string>).__DIFYCTL_COMMIT__ = '0000000' +;(globalThis as unknown as Record<string, string>).__DIFYCTL_BUILD_DATE__ = + '1970-01-01T00:00:00.000Z' +;(globalThis as unknown as Record<string, string>).__DIFYCTL_CHANNEL__ = 'dev' +;(globalThis as unknown as Record<string, string>).__DIFYCTL_MIN_DIFY__ = '1.6.0' +;(globalThis as unknown as Record<string, string>).__DIFYCTL_MAX_DIFY__ = '1.7.0' diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 96ea17a8da6d81..785042c96a78f0 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -3,12 +3,8 @@ "compilerOptions": { "moduleResolution": "bundler", "paths": { - "@/*": [ - "./src/*" - ], - "@test/*": [ - "./test/*" - ] + "@/*": ["./src/*"], + "@test/*": ["./test/*"] }, "types": ["node"], "noEmit": true, // we already have bundlers to handle this. diff --git a/cli/vitest.e2e.config.ts b/cli/vitest.e2e.config.ts index d9e87ed7e146d0..5ea77e18c1eac0 100644 --- a/cli/vitest.e2e.config.ts +++ b/cli/vitest.e2e.config.ts @@ -13,18 +13,14 @@ try { const raw = readFileSync(envFilePath, 'utf8') for (const line of raw.split('\n')) { const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) - continue + if (!trimmed || trimmed.startsWith('#')) continue const eqIdx = trimmed.indexOf('=') - if (eqIdx === -1) - continue + if (eqIdx === -1) continue const key = trimmed.slice(0, eqIdx).trim() const val = trimmed.slice(eqIdx + 1).trim() - if (key && !(key in process.env)) - process.env[key] = val + if (key && !(key in process.env)) process.env[key] = val } -} -catch { +} catch { // .env.e2e not found — rely on environment variables already set in the shell } @@ -68,39 +64,43 @@ export default defineConfig({ // DIFY_E2E_INCLUDE="test/e2e/suites/run/**/*.e2e.ts" // DIFY_E2E_INCLUDE="test/e2e/suites/discovery/**/*.e2e.ts,test/e2e/suites/run/run-app-basic.e2e.ts" // Deprecated alias: DIFY_E2E_SINGLE_FILE (single path only, kept for back-compat) - include: (() => { - const raw = process.env.DIFY_E2E_INCLUDE ?? process.env.DIFY_E2E_SINGLE_FILE - if (raw) - return raw.split(',').map(s => s.trim()).filter(Boolean) - return undefined - })() - ?? (process.env.DIFY_E2E_MODE === 'local' - ? ['test/e2e/suites/framework/help.e2e.ts', 'test/e2e/suites/agent/**/*.e2e.ts'] - : [ - // auth tests first (most others depend on a valid session) - 'test/e2e/suites/auth/login.e2e.ts', - 'test/e2e/suites/auth/status.e2e.ts', - 'test/e2e/suites/auth/use.e2e.ts', - 'test/e2e/suites/auth/whoami.e2e.ts', - // help (no network, no auth — runs first) - 'test/e2e/suites/framework/help.e2e.ts', - // output format (table / cross-cutting) - 'test/e2e/suites/output/**/*.e2e.ts', - // error handling (cross-cutting error message spec) - 'test/e2e/suites/error-handling/**/*.e2e.ts', - // framework (global flags, non-interactive, debug) - 'test/e2e/suites/framework/**/*.e2e.ts', - // discovery (get app / describe app) - 'test/e2e/suites/discovery/**/*.e2e.ts', - // dsl (export / import) - 'test/e2e/suites/dsl/**/*.e2e.ts', - // run tests (require valid token) - 'test/e2e/suites/run/**/*.e2e.ts', - 'test/e2e/suites/agent/**/*.e2e.ts', - // devices + logout LAST — both can revoke tokens - 'test/e2e/suites/auth/devices.e2e.ts', - 'test/e2e/suites/auth/logout.e2e.ts', - ]), + include: + (() => { + const raw = process.env.DIFY_E2E_INCLUDE ?? process.env.DIFY_E2E_SINGLE_FILE + if (raw) + return raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + return undefined + })() ?? + (process.env.DIFY_E2E_MODE === 'local' + ? ['test/e2e/suites/framework/help.e2e.ts', 'test/e2e/suites/agent/**/*.e2e.ts'] + : [ + // auth tests first (most others depend on a valid session) + 'test/e2e/suites/auth/login.e2e.ts', + 'test/e2e/suites/auth/status.e2e.ts', + 'test/e2e/suites/auth/use.e2e.ts', + 'test/e2e/suites/auth/whoami.e2e.ts', + // help (no network, no auth — runs first) + 'test/e2e/suites/framework/help.e2e.ts', + // output format (table / cross-cutting) + 'test/e2e/suites/output/**/*.e2e.ts', + // error handling (cross-cutting error message spec) + 'test/e2e/suites/error-handling/**/*.e2e.ts', + // framework (global flags, non-interactive, debug) + 'test/e2e/suites/framework/**/*.e2e.ts', + // discovery (get app / describe app) + 'test/e2e/suites/discovery/**/*.e2e.ts', + // dsl (export / import) + 'test/e2e/suites/dsl/**/*.e2e.ts', + // run tests (require valid token) + 'test/e2e/suites/run/**/*.e2e.ts', + 'test/e2e/suites/agent/**/*.e2e.ts', + // devices + logout LAST — both can revoke tokens + 'test/e2e/suites/auth/devices.e2e.ts', + 'test/e2e/suites/auth/logout.e2e.ts', + ]), // E2E calls a real staging server — allow plenty of time per test. testTimeout: 120_000, hookTimeout: 30_000, diff --git a/e2e/cucumber.config.ts b/e2e/cucumber.config.ts index 49d47641bcab0e..e9de7f30e24939 100644 --- a/e2e/cucumber.config.ts +++ b/e2e/cucumber.config.ts @@ -1,10 +1,11 @@ import type { IConfiguration } from '@cucumber/cucumber' import './scripts/env-register' -const hasCliTags = process.argv.some(arg => arg === '--tags' || arg.startsWith('--tags=')) -const defaultNonExternalTags = 'not @fresh and not @skip and not @preview and not @external-model and not @external-tool' -const defaultTags = process.env.E2E_CUCUMBER_TAGS - || (hasCliTags ? undefined : defaultNonExternalTags) +const hasCliTags = process.argv.some((arg) => arg === '--tags' || arg.startsWith('--tags=')) +const defaultNonExternalTags = + 'not @fresh and not @skip and not @preview and not @external-model and not @external-tool' +const defaultTags = + process.env.E2E_CUCUMBER_TAGS || (hasCliTags ? undefined : defaultNonExternalTags) const config = { format: [ diff --git a/e2e/features/agent-v2/support/access-point.ts b/e2e/features/agent-v2/support/access-point.ts index a53c176372f209..96316028b3bc60 100644 --- a/e2e/features/agent-v2/support/access-point.ts +++ b/e2e/features/agent-v2/support/access-point.ts @@ -19,24 +19,21 @@ export type AgentServiceApiChatResult = { async function parseServiceApiChatResponse(response: Response) { const contentType = response.headers.get('content-type') ?? '' - if (contentType.includes('text/event-stream')) - return consumeServiceApiSse(response.body) + if (contentType.includes('text/event-stream')) return consumeServiceApiSse(response.body) const text = await response.text().catch(() => '') if (contentType.includes('application/json')) { try { return JSON.parse(text) as unknown - } - catch { + } catch { return { message: text } } } try { return JSON.parse(text) as unknown - } - catch { + } catch { return { message: text } } } @@ -47,8 +44,7 @@ export async function setAgentSiteAccessAndGetURL( ): Promise<string> { const agent = await getTestAgent(agentId) const appId = agent.app_id ?? agent.backing_app_id - if (!appId) - throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`) + if (!appId) throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`) const appDetail = await setAppSiteEnabled(appId, enabled) const token = agent.site?.access_token ?? agent.site?.code ?? appDetail.site.access_token @@ -71,8 +67,7 @@ export async function setAgentApiAccess( `${enabled ? 'Enable' : 'Disable'} Agent v2 API access for ${agentId}`, ) return (await response.json()) as AgentApiAccessResponse - } - finally { + } finally { await ctx.dispose() } } @@ -83,8 +78,7 @@ export async function createAgentApiKey(agentId: string): Promise<ApiKeyItem> { const response = await ctx.post(`/console/api/agent/${agentId}/api-keys`) await expectApiResponseOK(response, `Create Agent v2 API key for ${agentId}`) return (await response.json()) as ApiKeyItem - } - finally { + } finally { await ctx.dispose() } } @@ -110,8 +104,8 @@ export async function sendAgentServiceApiChatMessage({ const response = await fetch(`${serviceApiBaseURL.replace(/\/$/, '')}/chat-messages`, { body: JSON.stringify(body), headers: { - 'Accept': 'text/event-stream', - 'Authorization': `Bearer ${apiKey}`, + Accept: 'text/event-stream', + Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, method: 'POST', @@ -124,8 +118,7 @@ export async function sendAgentServiceApiChatMessage({ ok: response.ok, status: response.status, } - } - catch (error) { + } catch (error) { if (signal.aborted) { throw new Error( `Agent v2 Service API stream timed out after ${SERVICE_API_STREAM_TIMEOUT_MS}ms.`, diff --git a/e2e/features/agent-v2/support/agent-build-draft.ts b/e2e/features/agent-v2/support/agent-build-draft.ts index d548be5a367da6..ce94a189f0afc3 100644 --- a/e2e/features/agent-v2/support/agent-build-draft.ts +++ b/e2e/features/agent-v2/support/agent-build-draft.ts @@ -12,8 +12,7 @@ export async function checkoutAgentBuildDraft(agentId: string): Promise<AgentBui }) await expectApiResponseOK(response, `Checkout Agent v2 build draft for ${agentId}`) return (await response.json()) as AgentBuildDraftResponse - } - finally { + } finally { await ctx.dispose() } } @@ -33,8 +32,7 @@ export async function saveAgentBuildDraft( }) await expectApiResponseOK(response, `Save Agent v2 build draft for ${agentId}`) return (await response.json()) as AgentBuildDraftResponse - } - finally { + } finally { await ctx.dispose() } } @@ -43,13 +41,11 @@ export async function agentBuildDraftExists(agentId: string): Promise<boolean> { const ctx = await createApiContext() try { const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`) - if (response.status() === 404) - return false + if (response.status() === 404) return false await expectApiResponseOK(response, `Get Agent v2 build draft for ${agentId}`) return true - } - finally { + } finally { await ctx.dispose() } } @@ -60,8 +56,7 @@ export async function getAgentBuildDraft(agentId: string): Promise<AgentBuildDra const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`) await expectApiResponseOK(response, `Get Agent v2 build draft for ${agentId}`) return (await response.json()) as AgentBuildDraftResponse - } - finally { + } finally { await ctx.dispose() } } @@ -71,8 +66,7 @@ export async function applyAgentBuildDraft(agentId: string): Promise<void> { try { const response = await ctx.post(`/console/api/agent/${agentId}/build-draft/apply`) await expectApiResponseOK(response, `Apply Agent v2 build draft for ${agentId}`) - } - finally { + } finally { await ctx.dispose() } } @@ -82,8 +76,7 @@ export async function discardAgentBuildDraft(agentId: string): Promise<void> { try { const response = await ctx.delete(`/console/api/agent/${agentId}/build-draft`) await expectApiResponseOK(response, `Discard Agent v2 build draft for ${agentId}`) - } - finally { + } finally { await ctx.dispose() } } diff --git a/e2e/features/agent-v2/support/agent-builder-resources.ts b/e2e/features/agent-v2/support/agent-builder-resources.ts index 9b296ecf34eed8..a47cb64a0b24b9 100644 --- a/e2e/features/agent-v2/support/agent-builder-resources.ts +++ b/e2e/features/agent-v2/support/agent-builder-resources.ts @@ -25,7 +25,8 @@ export const agentBuilderFixedInputs = { missingToolSearch: 'E2E_NOT_EXIST_TOOL', missingToolSearchWithSuffix: 'E2E_NOT_EXIST_TOOL_12345', customKnowledgeQuery: 'Dify Agent E2E knowledge marker', - knowledgeRuntimeQuery: 'Use the connected knowledge source to find the Dify Agent E2E knowledge marker.', + knowledgeRuntimeQuery: + 'Use the connected knowledge source to find the Dify Agent E2E knowledge marker.', envPlainKey: 'E2E_AGENT_FLAG', envPlainValue: 'enabled', envModeKey: 'E2E_AGENT_MODE', diff --git a/e2e/features/agent-v2/support/agent-drive.ts b/e2e/features/agent-v2/support/agent-drive.ts index 87dfed2098e8bb..28c4772f6dde69 100644 --- a/e2e/features/agent-v2/support/agent-drive.ts +++ b/e2e/features/agent-v2/support/agent-drive.ts @@ -22,29 +22,21 @@ export type UploadedConsoleFile = { const crc32Table = new Uint32Array(256) for (let i = 0; i < crc32Table.length; i++) { let c = i - for (let k = 0; k < 8; k++) - c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1 + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 crc32Table[i] = c >>> 0 } const crc32 = (buffer: Buffer) => { - let crc = 0xFFFFFFFF - for (const byte of buffer) - crc = crc32Table[(crc ^ byte) & 0xFF]! ^ (crc >>> 8) - return (crc ^ 0xFFFFFFFF) >>> 0 + let crc = 0xffffffff + for (const byte of buffer) crc = crc32Table[(crc ^ byte) & 0xff]! ^ (crc >>> 8) + return (crc ^ 0xffffffff) >>> 0 } -const createSingleFileZip = ({ - content, - entryName, -}: { - content: Buffer - entryName: string -}) => { +const createSingleFileZip = ({ content, entryName }: { content: Buffer; entryName: string }) => { const entryNameBuffer = Buffer.from(entryName) const checksum = crc32(content) const localHeader = Buffer.alloc(30) - localHeader.writeUInt32LE(0x04034B50, 0) + localHeader.writeUInt32LE(0x04034b50, 0) localHeader.writeUInt16LE(20, 4) localHeader.writeUInt16LE(0, 6) localHeader.writeUInt16LE(0, 8) @@ -58,7 +50,7 @@ const createSingleFileZip = ({ const centralDirectoryOffset = localHeader.length + entryNameBuffer.length + content.length const centralDirectoryHeader = Buffer.alloc(46) - centralDirectoryHeader.writeUInt32LE(0x02014B50, 0) + centralDirectoryHeader.writeUInt32LE(0x02014b50, 0) centralDirectoryHeader.writeUInt16LE(20, 4) centralDirectoryHeader.writeUInt16LE(20, 6) centralDirectoryHeader.writeUInt16LE(0, 8) @@ -78,7 +70,7 @@ const createSingleFileZip = ({ const centralDirectorySize = centralDirectoryHeader.length + entryNameBuffer.length const endOfCentralDirectory = Buffer.alloc(22) - endOfCentralDirectory.writeUInt32LE(0x06054B50, 0) + endOfCentralDirectory.writeUInt32LE(0x06054b50, 0) endOfCentralDirectory.writeUInt16LE(0, 4) endOfCentralDirectory.writeUInt16LE(0, 6) endOfCentralDirectory.writeUInt16LE(1, 8) @@ -111,9 +103,10 @@ const toSkillArchiveUpload = async ({ } } const sourceDirName = path.basename(path.dirname(fileName)) - const archiveBaseName = sourceDirName && sourceDirName !== '.' - ? sourceDirName - : path.basename(fileName, path.extname(fileName)) + const archiveBaseName = + sourceDirName && sourceDirName !== '.' + ? sourceDirName + : path.basename(fileName, path.extname(fileName)) return { buffer: createSingleFileZip({ @@ -147,8 +140,7 @@ export async function uploadAgentDriveSkill({ }) await expectApiResponseOK(response, `Upload Agent v2 drive skill ${fileName} for ${agentId}`) return (await response.json()) as AgentSkillUploadResponse - } - finally { + } finally { await ctx.dispose() } } @@ -181,11 +173,13 @@ export async function uploadAgentConfigFileToDraft({ upload_file_id: uploadedFile.id, }, }) - await expectApiResponseOK(commitResponse, `Commit Agent v2 config file ${fileName} for ${agentId}`) + await expectApiResponseOK( + commitResponse, + `Commit Agent v2 config file ${fileName} for ${agentId}`, + ) const body = (await commitResponse.json()) as AgentConfigFileUploadResponse const file = body.file - if (!file.file_id) - throw new Error(`Agent v2 config file ${fileName} did not return a file_id.`) + if (!file.file_id) throw new Error(`Agent v2 config file ${fileName} did not return a file_id.`) return { file_id: file.file_id, @@ -195,8 +189,7 @@ export async function uploadAgentConfigFileToDraft({ name: file.name, size: file.size, } - } - finally { + } finally { await ctx.dispose() } } @@ -237,8 +230,7 @@ export async function uploadAgentConfigSkillToDraft({ name: skill.name, size: skill.size, } - } - finally { + } finally { await ctx.dispose() } } @@ -250,8 +242,7 @@ export async function getAgentDriveSkills(agentId: string): Promise<AgentDriveSk await expectApiResponseOK(response, `Get Agent v2 drive skills for ${agentId}`) const body = (await response.json()) as AgentDriveSkillListResponse return body.items ?? [] - } - finally { + } finally { await ctx.dispose() } } @@ -259,10 +250,11 @@ export async function getAgentDriveSkills(agentId: string): Promise<AgentDriveSk export async function deleteAgentConfigFile(agentId: string, name: string): Promise<void> { const ctx = await createApiContext() try { - const response = await ctx.delete(`/console/api/agent/${agentId}/config/files/${encodeURIComponent(name)}`) + const response = await ctx.delete( + `/console/api/agent/${agentId}/config/files/${encodeURIComponent(name)}`, + ) await expectApiResponseOK(response, `Delete Agent v2 config file ${name} for ${agentId}`) - } - finally { + } finally { await ctx.dispose() } } @@ -270,10 +262,11 @@ export async function deleteAgentConfigFile(agentId: string, name: string): Prom export async function deleteAgentConfigSkill(agentId: string, name: string): Promise<void> { const ctx = await createApiContext() try { - const response = await ctx.delete(`/console/api/agent/${agentId}/config/skills/${encodeURIComponent(name)}`) + const response = await ctx.delete( + `/console/api/agent/${agentId}/config/skills/${encodeURIComponent(name)}`, + ) await expectApiResponseOK(response, `Delete Agent v2 config skill ${name} for ${agentId}`) - } - finally { + } finally { await ctx.dispose() } } @@ -284,8 +277,7 @@ export async function deleteAgentDriveFile(agentId: string, key: string): Promis const searchParams = new URLSearchParams({ key }) const response = await ctx.delete(`/console/api/agent/${agentId}/files?${searchParams}`) await expectApiResponseOK(response, `Delete Agent v2 drive file ${key} for ${agentId}`) - } - finally { + } finally { await ctx.dispose() } } diff --git a/e2e/features/agent-v2/support/agent-soul.ts b/e2e/features/agent-v2/support/agent-soul.ts index 4697234b83cd38..367bed2530d724 100644 --- a/e2e/features/agent-v2/support/agent-soul.ts +++ b/e2e/features/agent-v2/support/agent-soul.ts @@ -18,17 +18,17 @@ export const defaultAgentSoulConfig: AgentSoulConfig = { }, } -export const normalAgentPrompt - = 'You are a Dify Agent E2E test assistant. Reply briefly to every user message, and always include AGENT_E2E_PASS in your response.' +export const normalAgentPrompt = + 'You are a Dify Agent E2E test assistant. Reply briefly to every user message, and always include AGENT_E2E_PASS in your response.' -export const updatedAgentPrompt - = 'You are a Dify Agent E2E test assistant. Every response must start with E2E_AGENT_UPDATED.' +export const updatedAgentPrompt = + 'You are a Dify Agent E2E test assistant. Every response must start with E2E_AGENT_UPDATED.' -export const concurrentFirstAgentPrompt - = 'You are a Dify Agent E2E concurrent edit assistant. Always include E2E_CONCURRENT_FIRST in saved instructions.' +export const concurrentFirstAgentPrompt = + 'You are a Dify Agent E2E concurrent edit assistant. Always include E2E_CONCURRENT_FIRST in saved instructions.' -export const concurrentSecondAgentPrompt - = 'You are a Dify Agent E2E concurrent edit assistant. Always include E2E_CONCURRENT_SECOND in saved instructions.' +export const concurrentSecondAgentPrompt = + 'You are a Dify Agent E2E concurrent edit assistant. Always include E2E_CONCURRENT_SECOND in saved instructions.' export const normalAgentSoulConfig: AgentSoulConfig = { prompt: { @@ -55,8 +55,7 @@ const stableAgentModelSettings = { const getAgentModelPluginId = (provider: string) => { const [organization, pluginName] = provider.split('/').filter(Boolean) - if (organization && pluginName) - return `${organization}/${pluginName}` + if (organization && pluginName) return `${organization}/${pluginName}` return provider ? `langgenius/${provider}` : '' } @@ -87,8 +86,7 @@ export function createAgentSoulConfigWithModel( } export function createPublishableAgentSoulConfig(agentSoul: AgentSoulConfig): AgentSoulConfig { - if (agentSoul.model) - return agentSoul + if (agentSoul.model) return agentSoul return createAgentSoulConfigWithModel(agentSoul, publishOnlyAgentModel) } @@ -126,10 +124,7 @@ export function createAgentSoulConfigWithDifyTool( ...agentSoul, tools: { ...agentSoul.tools, - dify_tools: [ - ...(agentSoul.tools?.dify_tools ?? []), - tool, - ], + dify_tools: [...(agentSoul.tools?.dify_tools ?? []), tool], }, } } diff --git a/e2e/features/agent-v2/support/agent.ts b/e2e/features/agent-v2/support/agent.ts index 4b3fdcf636e45f..d3d4e6cfc130ce 100644 --- a/e2e/features/agent-v2/support/agent.ts +++ b/e2e/features/agent-v2/support/agent.ts @@ -8,7 +8,11 @@ import type { } from '@dify/contracts/api/console/agent/types.gen' import { createApiContext, expectApiResponseOK } from '../../../support/api' import { assertE2EResourceName, createE2EResourceName } from '../../../support/naming' -import { createPublishableAgentSoulConfig, defaultAgentSoulConfig, normalAgentSoulConfig } from './agent-soul' +import { + createPublishableAgentSoulConfig, + defaultAgentSoulConfig, + normalAgentSoulConfig, +} from './agent-soul' export type AgentSeed = Pick< AgentAppDetailWithSite, @@ -54,8 +58,7 @@ export async function createTestAgent({ }) await expectApiResponseOK(response, 'Create Agent v2 test agent') return (await response.json()) as AgentSeed - } - finally { + } finally { await ctx.dispose() } } @@ -78,8 +81,7 @@ export async function getTestAgent(agentId: string): Promise<AgentSeed> { const response = await ctx.get(`/console/api/agent/${agentId}`) await expectApiResponseOK(response, `Get Agent v2 test agent ${agentId}`) return (await response.json()) as AgentSeed - } - finally { + } finally { await ctx.dispose() } } @@ -93,8 +95,7 @@ export async function getAgentVersionDetail( const response = await ctx.get(`/console/api/agent/${agentId}/versions/${versionId}`) await expectApiResponseOK(response, `Get Agent v2 version ${versionId} for ${agentId}`) return (await response.json()) as AgentConfigSnapshotDetailResponse - } - finally { + } finally { await ctx.dispose() } } @@ -104,8 +105,7 @@ export async function deleteTestAgent(agentId: string): Promise<void> { try { const response = await ctx.delete(`/console/api/agent/${agentId}`) await expectApiResponseOK(response, `Delete Agent v2 test agent ${agentId}`) - } - finally { + } finally { await ctx.dispose() } } @@ -125,21 +125,21 @@ export async function saveAgentComposerDraft( }) await expectApiResponseOK(response, `Save Agent v2 composer draft for ${agentId}`) return (await response.json()) as AgentAppComposerResponse - } - finally { + } finally { await ctx.dispose() } } -export async function getAgentReferencingWorkflows(agentId: string): Promise<AgentReferencingWorkflowResponse[]> { +export async function getAgentReferencingWorkflows( + agentId: string, +): Promise<AgentReferencingWorkflowResponse[]> { const ctx = await createApiContext() try { const response = await ctx.get(`/console/api/agent/${agentId}/referencing-workflows`) await expectApiResponseOK(response, `Get Agent v2 referencing workflows for ${agentId}`) const body = (await response.json()) as AgentReferencingWorkflowsResponse return body.data ?? [] - } - finally { + } finally { await ctx.dispose() } } @@ -150,8 +150,7 @@ export async function getAgentComposerDraft(agentId: string): Promise<AgentAppCo const response = await ctx.get(`/console/api/agent/${agentId}/composer`) await expectApiResponseOK(response, `Get Agent v2 composer draft for ${agentId}`) return (await response.json()) as AgentAppComposerResponse - } - finally { + } finally { await ctx.dispose() } } @@ -159,7 +158,10 @@ export async function getAgentComposerDraft(agentId: string): Promise<AgentAppCo export async function ensureAgentComposerDraftIsPublishable(agentId: string): Promise<void> { const composer = await getAgentComposerDraft(agentId) if (!composer.agent_soul?.model) - await saveAgentComposerDraft(agentId, createPublishableAgentSoulConfig(composer.agent_soul ?? defaultAgentSoulConfig)) + await saveAgentComposerDraft( + agentId, + createPublishableAgentSoulConfig(composer.agent_soul ?? defaultAgentSoulConfig), + ) } export async function publishAgent(agentId: string, versionNote = 'E2E publish'): Promise<void> { @@ -169,8 +171,7 @@ export async function publishAgent(agentId: string, versionNote = 'E2E publish') data: { version_note: versionNote }, }) await expectApiResponseOK(response, `Publish Agent v2 test agent ${agentId}`) - } - finally { + } finally { await ctx.dispose() } } diff --git a/e2e/features/agent-v2/support/preflight/access.ts b/e2e/features/agent-v2/support/preflight/access.ts index a315f09b92abb5..e68ba71126644e 100644 --- a/e2e/features/agent-v2/support/preflight/access.ts +++ b/e2e/features/agent-v2/support/preflight/access.ts @@ -15,8 +15,7 @@ export async function skipMissingPreseededAgentBackendApiKey( agentName: string, ): Promise<'skipped' | PreseededResource> { const agent = await skipMissingPreseededAgent(world, agentName) - if (agent === 'skipped') - return agent + if (agent === 'skipped') return agent const ctx = await createApiContext() try { @@ -46,8 +45,7 @@ export async function skipMissingPreseededAgentBackendApiKey( kind: 'api-key', name: `${agentName} Backend service API key`, } - } - finally { + } finally { await ctx.dispose() } } @@ -57,8 +55,7 @@ export async function skipMissingPreseededAgentPublishedWebApp( agentName: string, ): Promise<'skipped' | PreseededResource> { const agent = await skipMissingPreseededAgent(world, agentName) - if (agent === 'skipped') - return agent + if (agent === 'skipped') return agent const ctx = await createApiContext() try { @@ -89,8 +86,7 @@ export async function skipMissingPreseededAgentPublishedWebApp( kind: 'agent', name: agent.name, } - } - finally { + } finally { await ctx.dispose() } } @@ -101,12 +97,10 @@ export async function skipMissingPreseededAgentWorkflowReference( workflowName: string, ): Promise<'skipped' | PreseededResource> { const agent = await skipMissingPreseededAgent(world, agentName) - if (agent === 'skipped') - return agent + if (agent === 'skipped') return agent const workflow = await skipMissingPreseededWorkflow(world, workflowName) - if (workflow === 'skipped') - return workflow + if (workflow === 'skipped') return workflow const ctx = await createApiContext() try { @@ -114,7 +108,7 @@ export async function skipMissingPreseededAgentWorkflowReference( await expectApiResponseOK(response, `Check preseeded Agent workflow reference ${agentName}`) const references = (await response.json()) as AgentReferencingWorkflowsResponse const reference = references.data?.find( - item => item.app_id === workflow.id || item.app_name === workflow.name, + (item) => item.app_id === workflow.id || item.app_name === workflow.name, ) if (!reference) { @@ -136,8 +130,7 @@ export async function skipMissingPreseededAgentWorkflowReference( kind: 'workflow', name: workflow.name, } - } - finally { + } finally { await ctx.dispose() } } diff --git a/e2e/features/agent-v2/support/preflight/agent-backend.ts b/e2e/features/agent-v2/support/preflight/agent-backend.ts index 4513a83a5b89d1..03f31a472543d7 100644 --- a/e2e/features/agent-v2/support/preflight/agent-backend.ts +++ b/e2e/features/agent-v2/support/preflight/agent-backend.ts @@ -11,12 +11,10 @@ const getDefaultAgentBackendURL = () => { const getShellctlURL = () => { const explicitE2EURL = process.env.E2E_SHELLCTL_URL?.trim() - if (explicitE2EURL) - return explicitE2EURL.replace(/\/$/, '') + if (explicitE2EURL) return explicitE2EURL.replace(/\/$/, '') const explicitAgentURL = process.env.DIFY_AGENT_SHELLCTL_ENTRYPOINT?.trim() - if (explicitAgentURL) - return explicitAgentURL.replace(/\/$/, '') + if (explicitAgentURL) return explicitAgentURL.replace(/\/$/, '') if (isTruthyEnv(process.env.E2E_START_AGENT_BACKEND)) { const port = process.env.E2E_SHELLCTL_PORT?.trim() || '5004' @@ -28,15 +26,12 @@ const getShellctlURL = () => { const getAgentBackendURL = () => { const explicitE2EURL = process.env.E2E_AGENT_BACKEND_URL?.trim() - if (explicitE2EURL) - return explicitE2EURL.replace(/\/$/, '') + if (explicitE2EURL) return explicitE2EURL.replace(/\/$/, '') const explicitAPIURL = process.env.AGENT_BACKEND_BASE_URL?.trim() - if (explicitAPIURL) - return explicitAPIURL.replace(/\/$/, '') + if (explicitAPIURL) return explicitAPIURL.replace(/\/$/, '') - if (isTruthyEnv(process.env.E2E_START_AGENT_BACKEND)) - return getDefaultAgentBackendURL() + if (isTruthyEnv(process.env.E2E_START_AGENT_BACKEND)) return getDefaultAgentBackendURL() return undefined } @@ -55,8 +50,7 @@ const checkRuntimeOpenApi = async ({ const healthURL = `${url}/openapi.json` try { const response = await fetch(healthURL) - if (response.ok) - return undefined + if (response.ok) return undefined return skipBlockedPrecondition( world, @@ -66,18 +60,13 @@ const checkRuntimeOpenApi = async ({ remediation, }, ) - } - catch (error) { + } catch (error) { const message = error instanceof Error ? error.message : String(error) - return skipBlockedPrecondition( - world, - `${title} is unreachable at ${healthURL}: ${message}.`, - { - owner: 'e2e/runtime', - remediation, - }, - ) + return skipBlockedPrecondition(world, `${title} is unreachable at ${healthURL}: ${message}.`, { + owner: 'e2e/runtime', + remediation, + }) } } @@ -90,30 +79,31 @@ export async function skipMissingAgentBackendRuntime(world: DifyWorld) { 'Agent v2 runtime backend is not configured. This scenario needs the standalone dify-agent run server, not just an active model provider.', { owner: 'e2e/runtime', - remediation: 'Run with E2E_START_AGENT_BACKEND=1 to let E2E start dify-agent, or set E2E_AGENT_BACKEND_URL/AGENT_BACKEND_BASE_URL to an existing dify-agent server.', + remediation: + 'Run with E2E_START_AGENT_BACKEND=1 to let E2E start dify-agent, or set E2E_AGENT_BACKEND_URL/AGENT_BACKEND_BASE_URL to an existing dify-agent server.', }, ) } const agentBackendBlock = await checkRuntimeOpenApi({ - remediation: 'Start a healthy dify-agent server and make sure AGENT_BACKEND_BASE_URL points to it before running Agent v2 runtime scenarios.', + remediation: + 'Start a healthy dify-agent server and make sure AGENT_BACKEND_BASE_URL points to it before running Agent v2 runtime scenarios.', title: 'Agent v2 runtime backend', url: agentBackendURL, world, }) - if (agentBackendBlock) - return agentBackendBlock + if (agentBackendBlock) return agentBackendBlock const shellctlURL = getShellctlURL() if (shellctlURL) { const shellctlBlock = await checkRuntimeOpenApi({ - remediation: 'Start the shellctl local sandbox, or run with E2E_START_AGENT_BACKEND=1 so E2E starts it together with dify-agent.', + remediation: + 'Start the shellctl local sandbox, or run with E2E_START_AGENT_BACKEND=1 so E2E starts it together with dify-agent.', title: 'Agent v2 shellctl sandbox', url: shellctlURL, world, }) - if (shellctlBlock) - return shellctlBlock + if (shellctlBlock) return shellctlBlock } return agentBackendURL diff --git a/e2e/features/agent-v2/support/preflight/agents.ts b/e2e/features/agent-v2/support/preflight/agents.ts index 46f7eddc2725bf..08b70acdbd9359 100644 --- a/e2e/features/agent-v2/support/preflight/agents.ts +++ b/e2e/features/agent-v2/support/preflight/agents.ts @@ -32,12 +32,11 @@ import { splitToolResourceId, } from './tools' -type AgentSoulDifyToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['dify_tools']>[number] +type AgentSoulDifyToolConfig = NonNullable< + NonNullable<AgentSoulConfig['tools']>['dify_tools'] +>[number] -const hasKnowledgeDataset = ( - soul: Record<string, unknown>, - dataset: PreseededResource, -) => { +const hasKnowledgeDataset = (soul: Record<string, unknown>, dataset: PreseededResource) => { const knowledge = asRecord(soul.knowledge) const sets = asArray(knowledge.sets) @@ -74,10 +73,8 @@ const hasKnowledgeSet = ( return datasetRecord.id === dataset.id || datasetRecord.name === dataset.name }) - if (!hasExpectedDataset || query.mode !== queryMode) - return false - if (queryValue === undefined) - return true + if (!hasExpectedDataset || query.mode !== queryMode) return false + if (queryValue === undefined) return true return asString(query.value).trim() === queryValue }) @@ -87,8 +84,9 @@ const getOAuth2ToolCredentialFixture = (toolItems: unknown[]) => toolItems.find((item) => { const record = asRecord(item) - return record.credential_type === 'oauth2' - && Boolean(asString(asRecord(record.credential_ref).id)) + return ( + record.credential_type === 'oauth2' && Boolean(asString(asRecord(record.credential_ref).id)) + ) }) as AgentSoulDifyToolConfig | undefined export async function skipMissingPreseededAgent( @@ -139,15 +137,14 @@ export async function skipMissingPreseededAgentDriveSkill( skillName: string, ): Promise<'skipped' | PreseededResource> { const agent = await skipMissingPreseededAgent(world, agentName) - if (agent === 'skipped') - return agent + if (agent === 'skipped') return agent const ctx = await createApiContext() try { const response = await ctx.get(`/console/api/agent/${agent.id}/drive/skills`) await expectApiResponseOK(response, `Check preseeded Agent skill ${skillName}`) const body = (await response.json()) as AgentDriveSkillListResponse - const skill = body.items?.find(item => item.name === skillName) + const skill = body.items?.find((item) => item.name === skillName) if (!skill) { return skipBlockedPrecondition( @@ -161,8 +158,7 @@ export async function skipMissingPreseededAgentDriveSkill( kind: 'skill', name: skill.name, } - } - finally { + } finally { await ctx.dispose() } } @@ -172,34 +168,29 @@ export async function skipMissingPreseededFullConfigAgentCoreConfiguration( agentName: string, ): Promise<'skipped' | PreseededResource> { const stableModel = await skipMissingAgentBuilderStableChatModel(world) - if (stableModel === 'skipped') - return stableModel + if (stableModel === 'skipped') return stableModel const agent = await skipMissingPreseededAgent(world, agentName) - if (agent === 'skipped') - return agent + if (agent === 'skipped') return agent const summarySkill = await skipMissingPreseededAgentDriveSkill( world, agentName, agentBuilderPreseededResources.summarySkill, ) - if (summarySkill === 'skipped') - return summarySkill + if (summarySkill === 'skipped') return summarySkill const jsonTool = await skipMissingPreseededTool( world, agentBuilderPreseededResources.jsonReplaceTool, ) - if (jsonTool === 'skipped') - return jsonTool + if (jsonTool === 'skipped') return jsonTool const knowledgeBase = await skipMissingReadyPreseededDataset( world, agentBuilderPreseededResources.agentKnowledgeBase, ) - if (knowledgeBase === 'skipped') - return knowledgeBase + if (knowledgeBase === 'skipped') return knowledgeBase const ctx = await createApiContext() try { @@ -222,8 +213,7 @@ export async function skipMissingPreseededFullConfigAgentCoreConfiguration( agentBuilderTestMaterials.smallFile, agentBuilderTestMaterials.specialFilename, ]) { - if (!hasNamedOrKeyedEntry(files, fileName)) - missing.push(`file ${fileName}`) + if (!hasNamedOrKeyedEntry(files, fileName)) missing.push(`file ${fileName}`) } const skills = asArray(soul.config_skills) @@ -233,8 +223,8 @@ export async function skipMissingPreseededFullConfigAgentCoreConfiguration( const { providerName, toolName } = splitToolResourceId(jsonTool.id) const parsedTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) if ( - parsedTool.ok - && !hasToolEntry(asArray(asRecord(soul.tools).dify_tools), { + parsedTool.ok && + !hasToolEntry(asArray(asRecord(soul.tools).dify_tools), { providerDisplayName: parsedTool.providerName, providerName, toolDisplayName: parsedTool.toolName, @@ -255,8 +245,7 @@ export async function skipMissingPreseededFullConfigAgentCoreConfiguration( } return agent - } - finally { + } finally { await ctx.dispose() } } @@ -266,30 +255,26 @@ export async function skipMissingPreseededToolStatesAgentConfiguration( agentName: string, ): Promise<'skipped' | PreseededResource> { const agent = await skipMissingPreseededAgent(world, agentName) - if (agent === 'skipped') - return agent + if (agent === 'skipped') return agent const summarySkill = await skipMissingPreseededAgentDriveSkill( world, agentName, agentBuilderPreseededResources.summarySkill, ) - if (summarySkill === 'skipped') - return summarySkill + if (summarySkill === 'skipped') return summarySkill const jsonTool = await skipMissingPreseededTool( world, agentBuilderPreseededResources.jsonReplaceTool, ) - if (jsonTool === 'skipped') - return jsonTool + if (jsonTool === 'skipped') return jsonTool const tavilyTool = await skipMissingPreseededTool( world, agentBuilderPreseededResources.tavilySearchTool, ) - if (tavilyTool === 'skipped') - return tavilyTool + if (tavilyTool === 'skipped') return tavilyTool const ctx = await createApiContext() try { @@ -304,11 +289,13 @@ export async function skipMissingPreseededToolStatesAgentConfiguration( if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) missing.push(agentBuilderPreseededResources.summarySkill) - const { providerName: jsonProviderName, toolName: jsonToolName } = splitToolResourceId(jsonTool.id) + const { providerName: jsonProviderName, toolName: jsonToolName } = splitToolResourceId( + jsonTool.id, + ) const parsedJsonTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) if ( - parsedJsonTool.ok - && !findToolEntry(toolItems, { + parsedJsonTool.ok && + !findToolEntry(toolItems, { providerDisplayName: parsedJsonTool.providerName, providerName: jsonProviderName, toolDisplayName: parsedJsonTool.toolName, @@ -318,7 +305,9 @@ export async function skipMissingPreseededToolStatesAgentConfiguration( missing.push(agentBuilderPreseededResources.jsonReplaceTool) } - const { providerName: tavilyProviderName, toolName: tavilyToolName } = splitToolResourceId(tavilyTool.id) + const { providerName: tavilyProviderName, toolName: tavilyToolName } = splitToolResourceId( + tavilyTool.id, + ) const parsedTavilyTool = splitToolDisplayName(agentBuilderPreseededResources.tavilySearchTool) const tavilyEntry = parsedTavilyTool.ok ? findToolEntry(toolItems, { @@ -331,9 +320,10 @@ export async function skipMissingPreseededToolStatesAgentConfiguration( if (!tavilyEntry) { missing.push(agentBuilderPreseededResources.tavilySearchTool) - } - else if (!hasUnauthorizedToolCredentialState(tavilyEntry)) { - missing.push(`${agentBuilderPreseededResources.tavilySearchTool} unauthorized credential state`) + } else if (!hasUnauthorizedToolCredentialState(tavilyEntry)) { + missing.push( + `${agentBuilderPreseededResources.tavilySearchTool} unauthorized credential state`, + ) } if (missing.length > 0) { @@ -344,8 +334,7 @@ export async function skipMissingPreseededToolStatesAgentConfiguration( } return agent - } - finally { + } finally { await ctx.dispose() } } @@ -355,13 +344,15 @@ export async function skipMissingPreseededOAuthToolAgentConfiguration( agentName: string, ): Promise<'skipped' | PreseededResource> { const agent = await skipMissingPreseededAgent(world, agentName) - if (agent === 'skipped') - return agent + if (agent === 'skipped') return agent const ctx = await createApiContext() try { const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) - await expectApiResponseOK(response, `Check preseeded Agent OAuth2 tool configuration ${agentName}`) + await expectApiResponseOK( + response, + `Check preseeded Agent OAuth2 tool configuration ${agentName}`, + ) const body = (await response.json()) as AgentAppComposerResponse const toolItems = asArray(asRecord(body.agent_soul?.tools).dify_tools) const oauthTool = getOAuth2ToolCredentialFixture(toolItems) @@ -372,19 +363,21 @@ export async function skipMissingPreseededOAuthToolAgentConfiguration( `Preseeded Agent "${agentName}" is missing an OAuth2 tool with a credential reference.`, { owner: 'seed', - remediation: 'Seed an Agent v2 fixture that includes a built-in OAuth2 tool with credential_type oauth2 and credential_ref.id.', + remediation: + 'Seed an Agent v2 fixture that includes a built-in OAuth2 tool with credential_type oauth2 and credential_ref.id.', }, ) } return agent - } - finally { + } finally { await ctx.dispose() } } -export async function getPreseededOAuthToolConfig(agentId: string): Promise<AgentSoulDifyToolConfig> { +export async function getPreseededOAuthToolConfig( + agentId: string, +): Promise<AgentSoulDifyToolConfig> { const ctx = await createApiContext() try { const response = await ctx.get(`/console/api/agent/${agentId}/composer`) @@ -394,11 +387,12 @@ export async function getPreseededOAuthToolConfig(agentId: string): Promise<Agen const oauthTool = getOAuth2ToolCredentialFixture(toolItems) if (!oauthTool) - throw new Error(`Preseeded Agent ${agentId} does not include an OAuth2 tool credential fixture.`) + throw new Error( + `Preseeded Agent ${agentId} does not include an OAuth2 tool credential fixture.`, + ) return oauthTool - } - finally { + } finally { await ctx.dispose() } } @@ -408,15 +402,13 @@ export async function skipMissingPreseededDualRetrievalAgentConfiguration( agentName: string, ): Promise<'skipped' | PreseededResource> { const agent = await skipMissingPreseededAgent(world, agentName) - if (agent === 'skipped') - return agent + if (agent === 'skipped') return agent const knowledgeBase = await skipMissingReadyPreseededDataset( world, agentBuilderPreseededResources.agentKnowledgeBase, ) - if (knowledgeBase === 'skipped') - return knowledgeBase + if (knowledgeBase === 'skipped') return knowledgeBase const ctx = await createApiContext() try { @@ -446,8 +438,7 @@ export async function skipMissingPreseededDualRetrievalAgentConfiguration( } return agent - } - finally { + } finally { await ctx.dispose() } } diff --git a/e2e/features/agent-v2/support/preflight/common.ts b/e2e/features/agent-v2/support/preflight/common.ts index 5334b8601e7511..855e583ce93519 100644 --- a/e2e/features/agent-v2/support/preflight/common.ts +++ b/e2e/features/agent-v2/support/preflight/common.ts @@ -6,15 +6,15 @@ export type PreseededResource = NonNullable< DifyWorld['agentBuilder']['preflight']['preseededResources'][string] > -export type E2EResourcePrecondition - = | { - ok: true - value: string - } +export type E2EResourcePrecondition = | { - ok: false - reason: string - } + ok: true + value: string + } + | { + ok: false + reason: string + } export type NamedResource = { id: string @@ -35,8 +35,7 @@ export const readRequiredEnvResource = ( description: string, ): E2EResourcePrecondition => { const value = process.env[envName]?.trim() - if (value) - return { ok: true, value } + if (value) return { ok: true, value } return { ok: false, @@ -53,7 +52,9 @@ export function skipBlockedPrecondition( } = {}, ): 'skipped' { const owner = options.owner ?? 'seed/product' - const remediation = options.remediation ?? 'Seed the required resource or align the product capability before running this scenario.' + const remediation = + options.remediation ?? + 'Seed the required resource or align the product capability before running this scenario.' const message = `Blocked precondition: ${reason} Owner: ${owner}. Remediation: ${remediation}` console.warn(`[e2e] ${message}`) world.attach(message, 'text/plain') @@ -66,8 +67,7 @@ export function skipMissingEnvResource( description: string, ): 'skipped' | string { const resource = readRequiredEnvResource(envName, description) - if (resource.ok) - return resource.value + if (resource.ok) return resource.value return skipBlockedPrecondition(world, resource.reason) } @@ -101,9 +101,8 @@ export const findConsoleResourceByName = async <T extends NamedResource = NamedR await expectApiResponseOK(response, action) const body = (await response.json()) as NamedResourceCollection<T> - return body.data.find(item => item.name === resourceName) - } - finally { + return body.data.find((item) => item.name === resourceName) + } finally { await ctx.dispose() } } @@ -129,5 +128,5 @@ export const hasNamedOrKeyedEntry = (items: unknown[], expectedName: string) => asString, ) - return values.some(value => value === expectedName || value.endsWith(`/${expectedName}`)) + return values.some((value) => value === expectedName || value.endsWith(`/${expectedName}`)) }) diff --git a/e2e/features/agent-v2/support/preflight/datasets.ts b/e2e/features/agent-v2/support/preflight/datasets.ts index 44841af3eb1e3f..54420f4f0f0cd0 100644 --- a/e2e/features/agent-v2/support/preflight/datasets.ts +++ b/e2e/features/agent-v2/support/preflight/datasets.ts @@ -12,20 +12,15 @@ import { agentBuilderFixedInputs, agentBuilderPreseededResources, } from '../agent-builder-resources' -import { - buildQuery, - findConsoleResourceByName, - - skipBlockedPrecondition, -} from './common' +import { buildQuery, findConsoleResourceByName, skipBlockedPrecondition } from './common' -type DocumentIndexingStatus - = | 'cleaning' - | 'completed' - | 'indexing' - | 'parsing' - | 'splitting' - | 'waiting' +type DocumentIndexingStatus = + | 'cleaning' + | 'completed' + | 'indexing' + | 'parsing' + | 'splitting' + | 'waiting' const completedDocumentIndexingStatus: DocumentIndexingStatus = 'completed' const activeDocumentIndexingStatuses = new Set<string>([ @@ -54,8 +49,7 @@ const getDatasetIndexingStatuses = async (datasetId: string, resourceName: strin const body = (await response.json()) as DocumentStatusListResponse return body.data - } - finally { + } finally { await ctx.dispose() } } @@ -79,8 +73,7 @@ const getDatasetDocuments = async (datasetId: string, resourceName: string) => { } return documents - } - finally { + } finally { await ctx.dispose() } } @@ -103,27 +96,23 @@ const datasetHasEnabledSegmentContainingTokens = async ( const response = await ctx.get( `/console/api/datasets/${datasetId}/documents/${document.id}/segments?${query}`, ) - await expectApiResponseOK( - response, - `Check preseeded dataset segment content ${resourceName}`, - ) + await expectApiResponseOK(response, `Check preseeded dataset segment content ${resourceName}`) const body = (await response.json()) as ConsoleSegmentListResponse const matchingSegment = body.data.find( - segment => - segment.enabled - && expectedTokens.every(expectedToken => - segment.content.includes(expectedToken) - || segment.keywords?.some(keyword => keyword.includes(expectedToken)), + (segment) => + segment.enabled && + expectedTokens.every( + (expectedToken) => + segment.content.includes(expectedToken) || + segment.keywords?.some((keyword) => keyword.includes(expectedToken)), ), ) - if (matchingSegment) - return true + if (matchingSegment) return true } return false - } - finally { + } finally { await ctx.dispose() } } @@ -175,7 +164,7 @@ export async function skipMissingReadyPreseededDataset( } const incompleteStatus = statuses.find( - item => item.indexing_status !== completedDocumentIndexingStatus, + (item) => item.indexing_status !== completedDocumentIndexingStatus, ) if (incompleteStatus) { return skipBlockedPrecondition( @@ -220,13 +209,13 @@ export async function skipMissingIndexingPreseededDataset( return skipBlockedPrecondition(world, `Preseeded dataset "${resourceName}" was not found.`) const statuses = await getDatasetIndexingStatuses(resource.id, resourceName) - const indexingStatus = statuses.find(item => + const indexingStatus = statuses.find((item) => activeDocumentIndexingStatuses.has(item.indexing_status ?? ''), ) if (!indexingStatus) { - const actualStatuses - = statuses.map(item => item.indexing_status ?? 'missing').join(', ') || 'none' + const actualStatuses = + statuses.map((item) => item.indexing_status ?? 'missing').join(', ') || 'none' return skipBlockedPrecondition( world, diff --git a/e2e/features/agent-v2/support/preflight/models.ts b/e2e/features/agent-v2/support/preflight/models.ts index 02f032c4b91fbe..598e104108e7c6 100644 --- a/e2e/features/agent-v2/support/preflight/models.ts +++ b/e2e/features/agent-v2/support/preflight/models.ts @@ -22,23 +22,24 @@ const defaultAgentDecisionChatModelName = 'gpt-5.5' const defaultAgentDecisionChatModelType = 'llm' const defaultBrokenChatModelName = agentBuilderPreseededResources.brokenModel -const getProviderAlias = (provider: string) => provider.split('/').filter(Boolean).at(-1) ?? provider +const getProviderAlias = (provider: string) => + provider.split('/').filter(Boolean).at(-1) ?? provider const matchesProvider = (actual: string, expected: string) => actual === expected || getProviderAlias(actual) === getProviderAlias(expected) -type ModelPreflightConfig - = | { - ok: true - provider: string - resourceName: string - type: string - value: string - } +type ModelPreflightConfig = | { - ok: false - reason: string - } + ok: true + provider: string + resourceName: string + type: string + value: string + } + | { + ok: false + reason: string + } export function readAgentBuilderStableChatModelConfig(): ModelPreflightConfig { const provider = process.env[stableChatModelProviderEnv]?.trim() || defaultStableChatModelProvider @@ -55,12 +56,12 @@ export function readAgentBuilderStableChatModelConfig(): ModelPreflightConfig { } export function readAgentBuilderAgentDecisionChatModelConfig(): ModelPreflightConfig { - const provider = process.env[agentDecisionChatModelProviderEnv]?.trim() - || defaultAgentDecisionChatModelProvider - const name = process.env[agentDecisionChatModelNameEnv]?.trim() - || defaultAgentDecisionChatModelName - const type = process.env[agentDecisionChatModelTypeEnv]?.trim() - || defaultAgentDecisionChatModelType + const provider = + process.env[agentDecisionChatModelProviderEnv]?.trim() || defaultAgentDecisionChatModelProvider + const name = + process.env[agentDecisionChatModelNameEnv]?.trim() || defaultAgentDecisionChatModelName + const type = + process.env[agentDecisionChatModelTypeEnv]?.trim() || defaultAgentDecisionChatModelType return { ok: true, @@ -101,8 +102,7 @@ async function skipMissingAgentBuilderModel( requireActive: boolean }, ): Promise<'skipped' | NonNullable<DifyWorld['agentBuilder']['preflight']['stableModel']>> { - if (!config.ok) - return skipBlockedPrecondition(world, config.reason) + if (!config.ok) return skipBlockedPrecondition(world, config.reason) const ctx = await createApiContext() try { @@ -111,12 +111,12 @@ async function skipMissingAgentBuilderModel( ) await expectApiResponseOK(response, `Check ${config.resourceName}`) const body = (await response.json()) as { data: ProviderWithModelsResponse[] } - const provider = body.data.find(item => matchesProvider(item.provider, config.provider)) + const provider = body.data.find((item) => matchesProvider(item.provider, config.provider)) const model = provider?.models.find( - item => - item.model === config.value - || item.label?.en_US === config.value - || item.label?.zh_Hans === config.value, + (item) => + item.model === config.value || + item.label?.en_US === config.value || + item.label?.zh_Hans === config.value, ) if (!provider || !model) { @@ -138,8 +138,7 @@ async function skipMissingAgentBuilderModel( provider: provider.provider, type: config.type, } - } - finally { + } finally { await ctx.dispose() } } diff --git a/e2e/features/agent-v2/support/preflight/tools.ts b/e2e/features/agent-v2/support/preflight/tools.ts index 634a6318afaa0a..dc3951361d3bfb 100644 --- a/e2e/features/agent-v2/support/preflight/tools.ts +++ b/e2e/features/agent-v2/support/preflight/tools.ts @@ -1,14 +1,7 @@ import type { DifyWorld } from '../../../support/world' import type { LocalizedLabel, PreseededResource } from './common' import { createApiContext, expectApiResponseOK } from '../../../../support/api' -import { - asRecord, - asString, - - matchesNameOrLabel, - - skipBlockedPrecondition, -} from './common' +import { asRecord, asString, matchesNameOrLabel, skipBlockedPrecondition } from './common' type BuiltinToolProvider = { label?: LocalizedLabel @@ -20,7 +13,7 @@ type BuiltinToolProvider = { } export const splitToolDisplayName = (resourceName: string) => { - const [providerName, toolName] = resourceName.split('/').map(item => item.trim()) + const [providerName, toolName] = resourceName.split('/').map((item) => item.trim()) if (!providerName || !toolName) { return { @@ -37,7 +30,10 @@ export const splitToolDisplayName = (resourceName: string) => { } export const splitToolResourceId = (resourceId: string) => { - const parts = resourceId.split('/').map(item => item.trim()).filter(Boolean) + const parts = resourceId + .split('/') + .map((item) => item.trim()) + .filter(Boolean) const toolName = parts.at(-1) const providerName = parts.slice(0, -1).join('/') @@ -69,8 +65,8 @@ export const findToolEntry = ( const toolValues = [record.tool_name, record.name].map(asString) return ( - providerValues.some(value => value === providerName || value === providerDisplayName) - && toolValues.some(value => value === toolName || value === toolDisplayName) + providerValues.some((value) => value === providerName || value === providerDisplayName) && + toolValues.some((value) => value === toolName || value === toolDisplayName) ) }) @@ -95,18 +91,17 @@ export async function skipMissingPreseededTool( resourceName: string, ): Promise<'skipped' | PreseededResource> { const parsed = splitToolDisplayName(resourceName) - if (!parsed.ok) - return skipBlockedPrecondition(world, parsed.reason) + if (!parsed.ok) return skipBlockedPrecondition(world, parsed.reason) const ctx = await createApiContext() try { const response = await ctx.get('/console/api/workspaces/current/tools/builtin') await expectApiResponseOK(response, `Check preseeded tool ${resourceName}`) const providers = (await response.json()) as BuiltinToolProvider[] - const provider = providers.find(item => + const provider = providers.find((item) => matchesNameOrLabel(parsed.providerName, item.name, item.label), ) - const tool = provider?.tools.find(item => + const tool = provider?.tools.find((item) => matchesNameOrLabel(parsed.toolName, item.name, item.label), ) @@ -118,8 +113,7 @@ export async function skipMissingPreseededTool( kind: 'tool', name: resourceName, } - } - finally { + } finally { await ctx.dispose() } } diff --git a/e2e/features/agent-v2/support/seed.ts b/e2e/features/agent-v2/support/seed.ts index 0ef68c9f5ac220..167ef544e35868 100644 --- a/e2e/features/agent-v2/support/seed.ts +++ b/e2e/features/agent-v2/support/seed.ts @@ -1,4 +1,7 @@ -import type { AgentKnowledgeDatasetConfig, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentKnowledgeDatasetConfig, + AgentSoulConfig, +} from '@dify/contracts/api/console/agent/types.gen' import type { ConsoleSegmentListResponse, DatasetListItemResponse, @@ -22,23 +25,9 @@ import { } from '../../../support/api' import { bootstrapMarketplacePlugins } from '../../../support/marketplace-plugins' import { sleep } from '../../../support/process' -import { - blocked, - created, - skipped, - updated, - verified, -} from '../../../support/seed' -import { - createAgentApiKey, - setAgentApiAccess, - setAgentSiteAccessAndGetURL, -} from './access-point' -import { - createTestAgent, - publishAgent, - saveAgentComposerDraft, -} from './agent' +import { blocked, created, skipped, updated, verified } from '../../../support/seed' +import { createAgentApiKey, setAgentApiAccess, setAgentSiteAccessAndGetURL } from './access-point' +import { createTestAgent, publishAgent, saveAgentComposerDraft } from './agent' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -62,10 +51,7 @@ import { matchesNameOrLabel, } from './preflight/common' import { splitToolDisplayName } from './preflight/tools' -import { - agentBuilderTestMaterials, - getAgentBuilderTestMaterialPath, -} from './test-materials' +import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from './test-materials' type StableModel = { name: string @@ -92,15 +78,19 @@ const agentV2MarketplacePluginIds = [ 'langgenius/tavily', ] -const getProviderAlias = (provider: string) => provider.split('/').filter(Boolean).at(-1) ?? provider +const getProviderAlias = (provider: string) => + provider.split('/').filter(Boolean).at(-1) ?? provider const matchesProvider = (actual: string, expected: string) => actual === expected || getProviderAlias(actual) === getProviderAlias(expected) -const matchesProviderLabel = (provider: { label?: { en_US?: string | null, zh_Hans?: string | null } | null, provider: string }, expected: string) => - matchesProvider(provider.provider, expected) - || provider.label?.en_US === expected - || provider.label?.zh_Hans === expected +const matchesProviderLabel = ( + provider: { label?: { en_US?: string | null; zh_Hans?: string | null } | null; provider: string }, + expected: string, +) => + matchesProvider(provider.provider, expected) || + provider.label?.en_US === expected || + provider.label?.zh_Hans === expected const stableModelConfig = (): StableModel => ({ name: process.env.E2E_STABLE_MODEL_NAME?.trim() || 'gpt-5-nano', @@ -116,17 +106,14 @@ const agentDecisionModelConfig = (): StableModel => ({ const parseJsonEnv = (envName: string) => { const raw = process.env[envName]?.trim() - if (!raw) - return { ok: false as const, reason: `${envName} is required.` } + if (!raw) return { ok: false as const, reason: `${envName} is required.` } try { const value = JSON.parse(raw) as unknown - if (!isRecord(value)) - return { ok: false as const, reason: `${envName} must be a JSON object.` } + if (!isRecord(value)) return { ok: false as const, reason: `${envName} must be a JSON object.` } return { ok: true as const, value } - } - catch (error) { + } catch (error) { return { ok: false as const, reason: `${envName} must be valid JSON: ${error instanceof Error ? error.message : String(error)}`, @@ -137,19 +124,20 @@ const parseJsonEnv = (envName: string) => { const findChatModel = async (config: StableModel, title: string) => { const ctx = await createApiContext() try { - const response = await ctx.get(`/console/api/workspaces/current/models/model-types/${config.type}`) + const response = await ctx.get( + `/console/api/workspaces/current/models/model-types/${config.type}`, + ) await expectApiResponseOK(response, `Check ${title}`) const body = (await response.json()) as AvailableModelListResponse - const provider = body.data.find(item => matchesProvider(item.provider, config.provider)) + const provider = body.data.find((item) => matchesProvider(item.provider, config.provider)) const model = provider?.models.find( - item => - item.model === config.name - || item.label?.en_US === config.name - || item.label?.zh_Hans === config.name, + (item) => + item.model === config.name || + item.label?.en_US === config.name || + item.label?.zh_Hans === config.name, ) - if (!provider || !model) - return undefined + if (!provider || !model) return undefined return { name: model.model, @@ -157,8 +145,7 @@ const findChatModel = async (config: StableModel, title: string) => { status: model.status, type: config.type, } - } - finally { + } finally { await ctx.dispose() } } @@ -166,20 +153,21 @@ const findChatModel = async (config: StableModel, title: string) => { const resolveProvider = async (config: StableModel) => { const ctx = await createApiContext() try { - const response = await ctx.get(`/console/api/workspaces/current/model-providers?${buildQuery({ model_type: config.type })}`) + const response = await ctx.get( + `/console/api/workspaces/current/model-providers?${buildQuery({ model_type: config.type })}`, + ) await expectApiResponseOK(response, `Resolve model provider ${config.provider}`) const body = (await response.json()) as ModelProviderListResponse - const provider = body.data.find(item => matchesProviderLabel(item, config.provider)) + const provider = body.data.find((item) => matchesProviderLabel(item, config.provider)) return { - availableProviders: body.data.map(provider => provider.provider), + availableProviders: body.data.map((provider) => provider.provider), credential: provider?.custom_configuration.available_credentials?.find( - credential => credential.credential_name === stableModelCredentialName, + (credential) => credential.credential_name === stableModelCredentialName, ), provider: provider?.provider, } - } - finally { + } finally { await ctx.dispose() } } @@ -203,9 +191,11 @@ const selectCustomProviderCredential = async (provider: string, credentialId?: s data: { preferred_provider_type: 'custom' }, }, ) - await expectApiResponseOK(preferredResponse, `Select custom provider credential for ${provider}`) - } - finally { + await expectApiResponseOK( + preferredResponse, + `Select custom provider credential for ${provider}`, + ) + } finally { await ctx.dispose() } } @@ -242,19 +232,21 @@ const upsertStableProviderCredential = async ( }, ) await expectApiResponseOK(createResponse, `Create model provider credential for ${provider}`) - } - finally { + } finally { await ctx.dispose() } } -const seedChatModel = async (context: SeedContext, { - config, - title, -}: { - config: StableModel - title: string -}) => { +const seedChatModel = async ( + context: SeedContext, + { + config, + title, + }: { + config: StableModel + title: string + }, +) => { const existing = await findChatModel(config, title) const resource = { id: `${existing?.provider ?? config.provider}/${existing?.name ?? config.name}`, @@ -262,11 +254,13 @@ const seedChatModel = async (context: SeedContext, { name: title, } - if (existing?.status === activeModelStatus) - return verified(title, resource) + if (existing?.status === activeModelStatus) return verified(title, resource) if (context.dryRun) - return skipped(title, `Would configure ${config.provider}/${config.name} using ${modelCredentialEnv}.`) + return skipped( + title, + `Would configure ${config.provider}/${config.name} using ${modelCredentialEnv}.`, + ) const credentials = parseJsonEnv(modelCredentialEnv) if (!credentials.ok) @@ -274,9 +268,7 @@ const seedChatModel = async (context: SeedContext, { const { availableProviders, credential, provider } = await resolveProvider(config) if (!provider) { - const available = availableProviders.length > 0 - ? availableProviders.join(', ') - : 'none' + const available = availableProviders.length > 0 ? availableProviders.join(', ') : 'none' return blocked( title, `Provider ${config.provider} was not found in available model providers for ${config.type}. Available providers: ${available}.`, @@ -286,8 +278,7 @@ const seedChatModel = async (context: SeedContext, { try { await upsertStableProviderCredential(provider, credentials.value, credential?.credential_id) await selectCustomProviderCredential(provider, credential?.credential_id) - } - catch (error) { + } catch (error) { const message = error instanceof Error ? error.message : String(error) if (!message.includes(`Credential with name '${stableModelCredentialName}' already exists.`)) return blocked(title, message) @@ -307,8 +298,7 @@ const seedChatModel = async (context: SeedContext, { refreshed.credential.credential_id, ) await selectCustomProviderCredential(refreshed.provider, refreshed.credential.credential_id) - } - catch (retryError) { + } catch (retryError) { return blocked(title, retryError instanceof Error ? retryError.message : String(retryError)) } } @@ -328,39 +318,42 @@ const seedChatModel = async (context: SeedContext, { }) } -const seedStableModel = async (context: SeedContext) => seedChatModel(context, { - config: stableModelConfig(), - title: agentBuilderPreseededResources.stableChatModel, -}) +const seedStableModel = async (context: SeedContext) => + seedChatModel(context, { + config: stableModelConfig(), + title: agentBuilderPreseededResources.stableChatModel, + }) -const seedAgentDecisionModel = async (context: SeedContext) => seedChatModel(context, { - config: agentDecisionModelConfig(), - title: agentBuilderPreseededResources.agentDecisionChatModel, -}) +const seedAgentDecisionModel = async (context: SeedContext) => + seedChatModel(context, { + config: agentDecisionModelConfig(), + title: agentBuilderPreseededResources.agentDecisionChatModel, + }) type BuiltinToolProvider = { - label?: { en_US?: string, zh_Hans?: string } + label?: { en_US?: string; zh_Hans?: string } name: string tools: Array<{ - label?: { en_US?: string, zh_Hans?: string } + label?: { en_US?: string; zh_Hans?: string } name: string }> } const findBuiltinTool = async (displayName: string) => { const parsed = splitToolDisplayName(displayName) - if (!parsed.ok) - return { ok: false as const, reason: parsed.reason } + if (!parsed.ok) return { ok: false as const, reason: parsed.reason } const ctx = await createApiContext() try { const response = await ctx.get('/console/api/workspaces/current/tools/builtin') await expectApiResponseOK(response, `Check built-in tool ${displayName}`) const providers = (await response.json()) as BuiltinToolProvider[] - const provider = providers.find(item => - matchesNameOrLabel(parsed.providerName, item.name, item.label)) - const tool = provider?.tools.find(item => - matchesNameOrLabel(parsed.toolName, item.name, item.label)) + const provider = providers.find((item) => + matchesNameOrLabel(parsed.providerName, item.name, item.label), + ) + const tool = provider?.tools.find((item) => + matchesNameOrLabel(parsed.toolName, item.name, item.label), + ) if (!provider || !tool) return { ok: false as const, reason: `Built-in tool "${displayName}" was not found.` } @@ -375,8 +368,7 @@ const findBuiltinTool = async (displayName: string) => { toolName: tool.name, } satisfies ToolResource, } - } - finally { + } finally { await ctx.dispose() } } @@ -386,14 +378,16 @@ const seedTool = (displayName: string): SeedTask => ({ title: displayName, async run() { const result = await findBuiltinTool(displayName) - if (!result.ok) - return blocked(displayName, result.reason) + if (!result.ok) return blocked(displayName, result.reason) return verified(displayName, result.resource) }, }) -const uploadConsoleFile = async (fileName: string, filePath: string): Promise<UploadedConsoleFile> => { +const uploadConsoleFile = async ( + fileName: string, + filePath: string, +): Promise<UploadedConsoleFile> => { const ctx = await createApiContext() try { const response = await ctx.post('/console/api/files/upload', { @@ -407,8 +401,7 @@ const uploadConsoleFile = async (fileName: string, filePath: string): Promise<Up }) await expectApiResponseOK(response, `Upload seed file ${fileName}`) return (await response.json()) as UploadedConsoleFile - } - finally { + } finally { await ctx.dispose() } } @@ -425,12 +418,13 @@ const findDataset = (name: string) => { const getDatasetDocuments = async (datasetId: string) => { const ctx = await createApiContext() try { - const response = await ctx.get(`/console/api/datasets/${datasetId}/documents?${buildQuery({ limit: '100', page: '1' })}`) + const response = await ctx.get( + `/console/api/datasets/${datasetId}/documents?${buildQuery({ limit: '100', page: '1' })}`, + ) await expectApiResponseOK(response, `List dataset documents ${datasetId}`) const body = (await response.json()) as DocumentWithSegmentsListResponse return body.data - } - finally { + } finally { await ctx.dispose() } } @@ -454,19 +448,24 @@ const datasetHasKnowledgeSegment = async (datasetId: string) => { page: '1', })}`, ) - await expectApiResponseOK(response, `Check dataset knowledge segment ${agentBuilderExpectedTokens.knowledgeReply}`) + await expectApiResponseOK( + response, + `Check dataset knowledge segment ${agentBuilderExpectedTokens.knowledgeReply}`, + ) const body = (await response.json()) as ConsoleSegmentListResponse - if (body.data.some(segment => - segment.enabled - && requiredKnowledgeSegmentTokens.every(token => segment.content.includes(token)), - )) { + if ( + body.data.some( + (segment) => + segment.enabled && + requiredKnowledgeSegmentTokens.every((token) => segment.content.includes(token)), + ) + ) { return true } } return false - } - finally { + } finally { await ctx.dispose() } } @@ -481,16 +480,15 @@ const waitForDatasetCompleted = async (datasetId: string) => { const response = await ctx.get(`/console/api/datasets/${datasetId}/indexing-status`) await expectApiResponseOK(response, `Check dataset indexing ${datasetId}`) const body = (await response.json()) as DocumentStatusListResponse - status = body.data.length < 1 - ? 'missing' - : body.data.every(item => item.indexing_status === 'completed') - ? 'completed' - : body.data.map(item => item.indexing_status ?? 'missing').join(',') - - if (status === 'completed') - return { ok: true as const } - } - finally { + status = + body.data.length < 1 + ? 'missing' + : body.data.every((item) => item.indexing_status === 'completed') + ? 'completed' + : body.data.map((item) => item.indexing_status ?? 'missing').join(',') + + if (status === 'completed') return { ok: true as const } + } finally { await ctx.dispose() } @@ -532,8 +530,7 @@ const addKnowledgeDocument = async (datasetId: string) => { try { const response = await ctx.post(`/console/api/datasets/${datasetId}/documents`, { data: body }) await expectApiResponseOK(response, `Seed knowledge document ${datasetId}`) - } - finally { + } finally { await ctx.dispose() } } @@ -551,8 +548,7 @@ const createDataset = async (name: string) => { }) await expectApiResponseOK(response, `Create dataset ${name}`) return (await response.json()) as DatasetListItemResponse - } - finally { + } finally { await ctx.dispose() } } @@ -571,8 +567,7 @@ const seedReadyKnowledge = async (context: SeedContext) => { dataset ??= await createDataset(title) const hasKnowledgeSegment = await datasetHasKnowledgeSegment(dataset.id) - if (!hasKnowledgeSegment) - await addKnowledgeDocument(dataset.id) + if (!hasKnowledgeSegment) await addKnowledgeDocument(dataset.id) const indexing = await waitForDatasetCompleted(dataset.id) if (!indexing.ok) { @@ -582,18 +577,17 @@ const seedReadyKnowledge = async (context: SeedContext) => { ) } - return datasetHasKnowledgeSegment(dataset.id) - .then((ready) => { - if (!ready) { - return blocked( - title, - `Dataset "${title}" does not expose an indexed segment containing ${requiredKnowledgeSegmentTokens.join(' and ')} after indexing.`, - ) - } + return datasetHasKnowledgeSegment(dataset.id).then((ready) => { + if (!ready) { + return blocked( + title, + `Dataset "${title}" does not expose an indexed segment containing ${requiredKnowledgeSegmentTokens.join(' and ')} after indexing.`, + ) + } - const resource = { id: dataset.id, kind: 'dataset', name: title } - return wasCreated ? created(title, resource) : verified(title, resource) - }) + const resource = { id: dataset.id, kind: 'dataset', name: title } + return wasCreated ? created(title, resource) : verified(title, resource) + }) } const ensureAgent = async (name: string) => { @@ -604,8 +598,7 @@ const ensureAgent = async (name: string) => { resourceName: name, }) - if (existing) - return { agent: existing, created: false } + if (existing) return { agent: existing, created: false } const agent = await createTestAgent({ description: 'Created by Dify E2E seed.', @@ -618,12 +611,10 @@ const ensureAgent = async (name: string) => { const getStableModelResource = (context: SeedContext): StableModel | undefined => { const resource = context.resources.get('stable-model') - if (!resource?.id) - return undefined + if (!resource?.id) return undefined const separatorIndex = resource.id.lastIndexOf('/') - if (separatorIndex === -1) - return undefined + if (separatorIndex === -1) return undefined return { name: resource.id.slice(separatorIndex + 1), @@ -654,14 +645,12 @@ const saveSeededAgentComposer = async ({ shouldPublish?: boolean }) => { await saveAgentComposerDraft(agentId, config) - if (shouldPublish) - await publishAgent(agentId, 'E2E seed') + if (shouldPublish) await publishAgent(agentId, 'E2E seed') } const ensureDriveSkill = async (agentId: string) => { const skills = await getAgentDriveSkills(agentId) - if (skills.some(skill => skill.name === agentBuilderPreseededResources.summarySkill)) - return + if (skills.some((skill) => skill.name === agentBuilderPreseededResources.summarySkill)) return await uploadAgentDriveSkill({ agentId, @@ -682,8 +671,7 @@ const seedFullConfigAgent = async (context: SeedContext) => { if (!dataset?.id) return blocked(title, `${agentBuilderPreseededResources.agentKnowledgeBase} is not ready.`) - if (context.dryRun) - return skipped(title, `Would create or update Agent "${title}".`) + if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) const { agent, created: wasCreated } = await ensureAgent(title) const agentId = agent.id @@ -707,14 +695,17 @@ const seedFullConfigAgent = async (context: SeedContext) => { await saveSeededAgentComposer({ agentId, config: createAgentSoulConfigWithKnowledgeDataset( - createAgentSoulConfigWithModel({ - ...normalAgentSoulConfig, - config_files: [smallFile, specialFile], - config_skills: [summarySkill], - tools: { - dify_tools: [toolConfig(jsonTool)], + createAgentSoulConfigWithModel( + { + ...normalAgentSoulConfig, + config_files: [smallFile, specialFile], + config_skills: [summarySkill], + tools: { + dify_tools: [toolConfig(jsonTool)], + }, }, - }, model), + model, + ), { id: dataset.id, name: dataset.name, @@ -734,8 +725,7 @@ const seedToolStatesAgent = async (context: SeedContext) => { return blocked(title, `${agentBuilderPreseededResources.jsonReplaceTool} is not ready.`) if (!tavilyTool) return blocked(title, `${agentBuilderPreseededResources.tavilySearchTool} is not ready.`) - if (context.dryRun) - return skipped(title, `Would create or update Agent "${title}".`) + if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) const { agent, created: wasCreated } = await ensureAgent(title) const summarySkill = await uploadAgentConfigSkillToDraft({ @@ -764,8 +754,7 @@ const seedDualRetrievalAgent = async (context: SeedContext) => { const dataset = context.resources.get('ready-knowledge') if (!dataset?.id) return blocked(title, `${agentBuilderPreseededResources.agentKnowledgeBase} is not ready.`) - if (context.dryRun) - return skipped(title, `Would create or update Agent "${title}".`) + if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) const { agent, created: wasCreated } = await ensureAgent(title) const datasetConfig = { id: dataset.id, name: dataset.name } satisfies AgentKnowledgeDatasetConfig @@ -806,8 +795,7 @@ const seedPublishedWebAppAgent = async (context: SeedContext) => { const model = getStableModelResource(context) if (!model) return blocked(title, `${agentBuilderPreseededResources.stableChatModel} is not ready.`) - if (context.dryRun) - return skipped(title, `Would create or update Agent "${title}".`) + if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) const { agent, created: wasCreated } = await ensureAgent(title) await saveSeededAgentComposer({ @@ -826,8 +814,7 @@ const seedBackendApiAgent = async (context: SeedContext) => { const model = getStableModelResource(context) if (!model) return blocked(title, `${agentBuilderPreseededResources.stableChatModel} is not ready.`) - if (context.dryRun) - return skipped(title, `Would create or update Agent "${title}".`) + if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) const { agent, created: wasCreated } = await ensureAgent(title) await saveSeededAgentComposer({ @@ -836,8 +823,7 @@ const seedBackendApiAgent = async (context: SeedContext) => { shouldPublish: true, }) const access = await setAgentApiAccess(agent.id, true) - if (access.api_key_count < 1) - await createAgentApiKey(agent.id) + if (access.api_key_count < 1) await createAgentApiKey(agent.id) const resource = { id: agent.id, kind: 'agent', name: title } return wasCreated ? created(title, resource) : updated(title, resource) @@ -895,7 +881,10 @@ const seedOAuthToolAgent = async (context: SeedContext) => { ) } if (context.dryRun) - return skipped(title, `Would create or update Agent "${title}" with OAuth2 tool ${providerName}/${toolName}.`) + return skipped( + title, + `Would create or update Agent "${title}" with OAuth2 tool ${providerName}/${toolName}.`, + ) const { agent, created: wasCreated } = await ensureAgent(title) await saveSeededAgentComposer({ @@ -930,12 +919,13 @@ const agentV2BaseSeedTasks = (): SeedTask[] => [ { id: 'marketplace-plugins', title: 'Agent V2 marketplace plugins', - run: context => bootstrapMarketplacePlugins(context, { - defaultPluginIds: agentV2MarketplacePluginIds, - pluginIdsEnv: marketplacePluginIdsEnv, - pluginUniqueIdentifiersEnv: marketplacePluginUniqueIdentifiersEnv, - title: 'Agent V2 marketplace plugins', - }), + run: (context) => + bootstrapMarketplacePlugins(context, { + defaultPluginIds: agentV2MarketplacePluginIds, + pluginIdsEnv: marketplacePluginIdsEnv, + pluginUniqueIdentifiersEnv: marketplacePluginUniqueIdentifiersEnv, + title: 'Agent V2 marketplace plugins', + }), }, { id: 'stable-model', @@ -961,18 +951,20 @@ const agentV2FullSeedTasks = (): SeedTask[] => [ { id: 'indexing-knowledge', title: agentBuilderPreseededResources.indexingKnowledgeBase, - run: async () => blocked( - agentBuilderPreseededResources.indexingKnowledgeBase, - 'A deterministic long-lived "currently indexing" dataset seed is not implemented yet.', - ), + run: async () => + blocked( + agentBuilderPreseededResources.indexingKnowledgeBase, + 'A deterministic long-lived "currently indexing" dataset seed is not implemented yet.', + ), }, { id: 'broken-model', title: agentBuilderPreseededResources.brokenModelProvider, - run: async () => blocked( - agentBuilderPreseededResources.brokenModelProvider, - 'Broken model fixture is validation-only for now; provide E2E_BROKEN_MODEL_PROVIDER and keep the model entry externally.', - ), + run: async () => + blocked( + agentBuilderPreseededResources.brokenModelProvider, + 'Broken model fixture is validation-only for now; provide E2E_BROKEN_MODEL_PROVIDER and keep the model entry externally.', + ), }, { id: 'full-config-agent', @@ -1012,11 +1004,9 @@ const agentV2FullSeedTasks = (): SeedTask[] => [ ] export const createAgentV2SeedTasks = (profile: string = 'full'): SeedTask[] => { - if (profile === 'full') - return agentV2FullSeedTasks() + if (profile === 'full') return agentV2FullSeedTasks() - if (profile === 'external-runtime') - return agentV2BaseSeedTasks() + if (profile === 'external-runtime') return agentV2BaseSeedTasks() throw new Error(`Unknown Agent V2 seed profile "${profile}".`) } diff --git a/e2e/features/agent-v2/support/service-api-sse.ts b/e2e/features/agent-v2/support/service-api-sse.ts index 0ed8266321472c..63ff6340489621 100644 --- a/e2e/features/agent-v2/support/service-api-sse.ts +++ b/e2e/features/agent-v2/support/service-api-sse.ts @@ -19,19 +19,16 @@ export type ServiceApiSseResult = { events: ServiceApiSseEvent[] } -const isRecord = (value: unknown): value is Record<string, unknown> => ( +const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value) -) -const truncate = (value: string, limit = SERVICE_API_SSE_SUMMARY_LIMIT) => ( +const truncate = (value: string, limit = SERVICE_API_SSE_SUMMARY_LIMIT) => value.length > limit ? `${value.slice(0, limit)}...` : value -) const parseEventData = (event: EventSourceMessage) => { try { return JSON.parse(event.data) as unknown - } - catch (error) { + } catch (error) { throw new Error( `Agent v2 Service API SSE event contained invalid JSON: ${truncate(JSON.stringify(event.data))}`, { cause: error }, @@ -40,36 +37,37 @@ const parseEventData = (event: EventSourceMessage) => { } const getEventName = (event: EventSourceMessage, data: unknown) => { - if (event.event) - return event.event - if (isRecord(data) && typeof data.event === 'string') - return data.event + if (event.event) return event.event + if (isRecord(data) && typeof data.event === 'string') return data.event return undefined } -const summarizeEvents = (events: ServiceApiSseEvent[]) => truncate(JSON.stringify(events.map((event) => { - if (!isRecord(event.data)) - return { event: event.event, data: truncate(String(event.data), 200) } +const summarizeEvents = (events: ServiceApiSseEvent[]) => + truncate( + JSON.stringify( + events.map((event) => { + if (!isRecord(event.data)) + return { event: event.event, data: truncate(String(event.data), 200) } - const data = event.data - return { - event: event.event, - ...(['code', 'message', 'conversation_id', 'message_id', 'task_id'] as const).reduce<Record<string, unknown>>( - (summary, key) => { - if (key in data) - summary[key] = data[key] - return summary - }, - {}, + const data = event.data + return { + event: event.event, + ...(['code', 'message', 'conversation_id', 'message_id', 'task_id'] as const).reduce< + Record<string, unknown> + >((summary, key) => { + if (key in data) summary[key] = data[key] + return summary + }, {}), + } + }), ), - } -}))) + ) const createBackendError = (event: ServiceApiSseEvent, events: ServiceApiSseEvent[]) => { const data = event.data const details = isRecord(data) ? (['code', 'message', 'conversation_id', 'message_id', 'task_id'] as const) - .flatMap(key => key in data ? [`${key}=${JSON.stringify(data[key])}`] : []) + .flatMap((key) => (key in data ? [`${key}=${JSON.stringify(data[key])}`] : [])) .join(' ') : `data=${JSON.stringify(data)}` @@ -81,17 +79,16 @@ const createBackendError = (event: ServiceApiSseEvent, events: ServiceApiSseEven export async function consumeServiceApiSse( body: ReadableStream<BufferSource> | null, ): Promise<ServiceApiSseResult> { - if (!body) - throw new Error('Agent v2 Service API SSE response did not expose a readable body.') + if (!body) throw new Error('Agent v2 Service API SSE response did not expose a readable body.') const events: ServiceApiSseEvent[] = [] const answers: string[] = [] - const stream = body - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new EventSourceParserStream({ + const stream = body.pipeThrough(new TextDecoderStream()).pipeThrough( + new EventSourceParserStream({ maxBufferSize: SERVICE_API_SSE_MAX_BUFFER_SIZE, onError: 'terminate', - })) + }), + ) try { for await (const message of stream) { @@ -104,11 +101,9 @@ export async function consumeServiceApiSse( } events.push(event) - if (isRecord(data) && typeof data.answer === 'string') - answers.push(data.answer) + if (isRecord(data) && typeof data.answer === 'string') answers.push(data.answer) - if (eventName === 'error') - throw createBackendError(event, events) + if (eventName === 'error') throw createBackendError(event, events) if (eventName && SUCCESS_TERMINAL_EVENTS.has(eventName)) { return { @@ -117,10 +112,8 @@ export async function consumeServiceApiSse( } } } - } - catch (error) { - if (error instanceof Error && error.message.startsWith('Agent v2 Service API SSE')) - throw error + } catch (error) { + if (error instanceof Error && error.message.startsWith('Agent v2 Service API SSE')) throw error const message = error instanceof Error ? error.message : String(error) throw new Error( diff --git a/e2e/features/agent-v2/support/tools.ts b/e2e/features/agent-v2/support/tools.ts index ccae6f92e69695..a15b05b53d6c72 100644 --- a/e2e/features/agent-v2/support/tools.ts +++ b/e2e/features/agent-v2/support/tools.ts @@ -11,8 +11,7 @@ export const getPreseededToolContract = (world: DifyWorld, resourceName: string) const parsedDisplayName = splitToolDisplayName(resource.name) const parsedToolId = splitToolResourceId(resource.id) - if (!parsedDisplayName.ok) - throw new Error(parsedDisplayName.reason) + if (!parsedDisplayName.ok) throw new Error(parsedDisplayName.reason) if (!parsedToolId.providerName || !parsedToolId.toolName) throw new Error(`Preseeded tool "${resource.id}" must include provider and tool id segments.`) diff --git a/e2e/features/step-definitions/agent-v2/access-point-helpers.ts b/e2e/features/step-definitions/agent-v2/access-point-helpers.ts index 18e249c71d98d6..39121e817c2b72 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-helpers.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-helpers.ts @@ -2,8 +2,7 @@ import type { DifyWorld } from '../../support/world' export const getCurrentAgentId = (world: DifyWorld) => { const agentId = world.createdAgentIds.at(-1) - if (!agentId) - throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.') + if (!agentId) throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.') return agentId } @@ -31,8 +30,7 @@ export type AccessSurfaceName = 'Web app' | 'Backend service API' export const getAccessSurfaceCard = (world: DifyWorld, surface: AccessSurfaceName) => getAccessRegion(world).getByRole('article', { name: surface }).first() -export const getWebAppCard = (world: DifyWorld) => - getAccessSurfaceCard(world, 'Web app') +export const getWebAppCard = (world: DifyWorld) => getAccessSurfaceCard(world, 'Web app') export const getServiceApiCard = (world: DifyWorld) => getAccessSurfaceCard(world, 'Backend service API') diff --git a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts index 1f4513b9ff08eb..9252a063af94c9 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts @@ -6,7 +6,10 @@ import { sendAgentServiceApiChatMessage, setAgentApiAccess, } from '../../agent-v2/support/access-point' -import { agentBuilderExpectedTokens, agentBuilderFixedInputs } from '../../agent-v2/support/agent-builder-resources' +import { + agentBuilderExpectedTokens, + agentBuilderFixedInputs, +} from '../../agent-v2/support/agent-builder-resources' import { SERVICE_API_RUNTIME_STEP_TIMEOUT_MS } from '../../agent-v2/support/service-api-sse' import { getCurrentAgentId, getServiceApiCard } from './access-point-helpers' @@ -36,7 +39,9 @@ Then('I should see the Agent v2 Backend service API endpoint', async function (t timeout: 30_000, }) await expect(serviceApiCard.getByText('Service API Endpoint')).toBeVisible() - await expect(serviceApiCard.getByText(this.agentBuilder.accessPoint.serviceApiBaseURL)).toBeVisible() + await expect( + serviceApiCard.getByText(this.agentBuilder.accessPoint.serviceApiBaseURL), + ).toBeVisible() await expect(serviceApiCard.getByLabel('Copy service API endpoint')).toBeEnabled() }) @@ -128,8 +133,7 @@ Then( 'the Agent v2 API key list should not expose the full generated secret', async function (this: DifyWorld) { const fullSecret = this.agentBuilder.accessPoint.generatedApiKey - if (!fullSecret) - throw new Error('No generated Agent v2 API key found.') + if (!fullSecret) throw new Error('No generated Agent v2 API key found.') const apiKeyDialog = this.getPage().getByRole('dialog', { name: /API Secret key/i }) @@ -165,8 +169,7 @@ When('I open the Agent v2 API Reference', async function (this: DifyWorld) { Then('the Agent v2 API Reference should open in a new tab', async function (this: DifyWorld) { const apiReferencePage = this.agentBuilder.accessPoint.apiReferencePage - if (!apiReferencePage) - throw new Error('No Agent v2 API Reference page was opened.') + if (!apiReferencePage) throw new Error('No Agent v2 API Reference page was opened.') await expect(apiReferencePage).toHaveURL(/\/api-reference\/guides\/get-started/) await apiReferencePage.close() @@ -213,8 +216,7 @@ When( const stringifyServiceApiBody = (body: unknown) => { try { return JSON.stringify(body) - } - catch { + } catch { return String(body) } } @@ -223,10 +225,11 @@ const expectServiceApiResponseOK = ( response: NonNullable<DifyWorld['agentBuilder']['accessPoint']['serviceApiResponse']>, action: string, ) => { - if (response.ok) - return + if (response.ok) return - throw new Error(`${action} failed with ${response.status}: ${stringifyServiceApiBody(response.body)}`) + throw new Error( + `${action} failed with ${response.status}: ${stringifyServiceApiBody(response.body)}`, + ) } const expectServiceApiResponseIncludes = ( @@ -245,8 +248,7 @@ Then( 'the Agent v2 Backend service API request should be rejected while disabled', async function (this: DifyWorld) { const response = this.agentBuilder.accessPoint.serviceApiResponse - if (!response) - throw new Error('No Agent v2 Backend service API response was recorded.') + if (!response) throw new Error('No Agent v2 Backend service API response was recorded.') expect(response.ok).toBe(false) expect(response.status).toBe(403) @@ -258,8 +260,7 @@ Then( 'the Agent v2 Backend service API response should include the knowledge E2E marker', async function (this: DifyWorld) { const response = this.agentBuilder.accessPoint.serviceApiResponse - if (!response) - throw new Error('No Agent v2 Backend service API response was recorded.') + if (!response) throw new Error('No Agent v2 Backend service API response was recorded.') expectServiceApiResponseIncludes( response, @@ -273,8 +274,7 @@ Then( 'the Agent v2 Backend service API request should succeed with the normal E2E marker', async function (this: DifyWorld) { const response = this.agentBuilder.accessPoint.serviceApiResponse - if (!response) - throw new Error('No Agent v2 Backend service API response was recorded.') + if (!response) throw new Error('No Agent v2 Backend service API response was recorded.') expectServiceApiResponseIncludes( response, diff --git a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts index 0b601b950f2ee9..12e3260d957f7c 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts @@ -5,16 +5,11 @@ import { expect } from '@playwright/test' import { getAgentComposerDraft } from '../../agent-v2/support/agent' import { agentBuilderExpectedTokens } from '../../agent-v2/support/agent-builder-resources' import { skipBlockedPrecondition } from '../../agent-v2/support/preflight/common' -import { - getCurrentAgentId, - getDialog, - getWebAppCard, -} from './access-point-helpers' +import { getCurrentAgentId, getDialog, getWebAppCard } from './access-point-helpers' const WEB_APP_RUNTIME_RESPONSE_STEP_TIMEOUT_MS = 180_000 -const getWebAppMessageInput = (webAppPage: Page) => - webAppPage.getByPlaceholder(/^Talk to /).last() +const getWebAppMessageInput = (webAppPage: Page) => webAppPage.getByPlaceholder(/^Talk to /).last() Then('I should see the Agent v2 Web app access URL', async function (this: DifyWorld) { const webAppCard = getWebAppCard(this) @@ -25,14 +20,11 @@ Then('I should see the Agent v2 Web app access URL', async function (this: DifyW await expect(webAppCard.getByRole('link', { name: 'Launch' })).toBeVisible() }) -Then( - 'I record the current Agent v2 orchestration draft', - async function (this: DifyWorld) { - const draft = await getAgentComposerDraft(getCurrentAgentId(this)) +Then('I record the current Agent v2 orchestration draft', async function (this: DifyWorld) { + const draft = await getAgentComposerDraft(getCurrentAgentId(this)) - this.agentBuilder.accessPoint.composerDraftSnapshot = JSON.stringify(draft.agent_soul ?? {}) - }, -) + this.agentBuilder.accessPoint.composerDraftSnapshot = JSON.stringify(draft.agent_soul ?? {}) +}) When('I copy the Agent v2 Web app access URL', async function (this: DifyWorld) { await getWebAppCard(this).getByLabel('Copy access URL').click() @@ -45,13 +37,9 @@ Then('the Agent v2 Web app access URL should show it was copied', async function When('I launch the Agent v2 Web app', async function (this: DifyWorld) { const launchLink = getWebAppCard(this).getByRole('link', { name: 'Launch' }) const href = await launchLink.getAttribute('href') - if (!href) - throw new Error('Agent v2 Web app Launch link does not expose an href.') + if (!href) throw new Error('Agent v2 Web app Launch link does not expose an href.') - const [webAppPage] = await Promise.all([ - this.getPage().waitForEvent('popup'), - launchLink.click(), - ]) + const [webAppPage] = await Promise.all([this.getPage().waitForEvent('popup'), launchLink.click()]) this.agentBuilder.accessPoint.webAppURL = href this.agentBuilder.accessPoint.webAppPage = webAppPage @@ -59,10 +47,8 @@ When('I launch the Agent v2 Web app', async function (this: DifyWorld) { When('I open the Agent v2 Web app URL', async function (this: DifyWorld) { const webAppURL = this.agentBuilder.accessPoint.webAppURL - if (!webAppURL) - throw new Error('No Agent v2 Web app URL was recorded.') - if (!this.context) - throw new Error('Playwright browser context has not been initialized.') + if (!webAppURL) throw new Error('No Agent v2 Web app URL was recorded.') + if (!this.context) throw new Error('Playwright browser context has not been initialized.') const webAppPage = await this.context.newPage() await webAppPage.goto(webAppURL) @@ -72,8 +58,7 @@ When('I open the Agent v2 Web app URL', async function (this: DifyWorld) { When('I send an E2E message in the Agent v2 Web app', async function (this: DifyWorld) { const webAppPage = this.agentBuilder.accessPoint.webAppPage - if (!webAppPage) - throw new Error('No Agent v2 Web app page was opened.') + if (!webAppPage) throw new Error('No Agent v2 Web app page was opened.') const messageInput = getWebAppMessageInput(webAppPage) await expect(messageInput).toBeEditable({ timeout: 30_000 }) @@ -84,8 +69,7 @@ When('I send an E2E message in the Agent v2 Web app', async function (this: Dify Then('the Agent v2 Web app should open in a new tab', async function (this: DifyWorld) { const webAppPage = this.agentBuilder.accessPoint.webAppPage const webAppURL = this.agentBuilder.accessPoint.webAppURL - if (!webAppPage || !webAppURL) - throw new Error('No Agent v2 Web app page was opened.') + if (!webAppPage || !webAppURL) throw new Error('No Agent v2 Web app page was opened.') await expect(webAppPage).toHaveURL(webAppURL) await expect(getWebAppMessageInput(webAppPage)).toBeEditable({ timeout: 30_000 }) @@ -99,11 +83,11 @@ Then( { timeout: WEB_APP_RUNTIME_RESPONSE_STEP_TIMEOUT_MS }, async function (this: DifyWorld) { const webAppPage = this.agentBuilder.accessPoint.webAppPage - if (!webAppPage) - throw new Error('No Agent v2 Web app page was opened.') + if (!webAppPage) throw new Error('No Agent v2 Web app page was opened.') - await expect(webAppPage.getByText(agentBuilderExpectedTokens.updatedAgentReply)) - .toBeVisible({ timeout: 120_000 }) + await expect(webAppPage.getByText(agentBuilderExpectedTokens.updatedAgentReply)).toBeVisible({ + timeout: 120_000, + }) }, ) @@ -112,11 +96,11 @@ Then( { timeout: WEB_APP_RUNTIME_RESPONSE_STEP_TIMEOUT_MS }, async function (this: DifyWorld) { const webAppPage = this.agentBuilder.accessPoint.webAppPage - if (!webAppPage) - throw new Error('No Agent v2 Web app page was opened.') + if (!webAppPage) throw new Error('No Agent v2 Web app page was opened.') - await expect(webAppPage.getByText(agentBuilderExpectedTokens.agentReply)) - .toBeVisible({ timeout: 120_000 }) + await expect(webAppPage.getByText(agentBuilderExpectedTokens.agentReply)).toBeVisible({ + timeout: 120_000, + }) }, ) @@ -124,19 +108,17 @@ Then( 'the Agent v2 Web app response should not include the updated E2E marker', async function (this: DifyWorld) { const webAppPage = this.agentBuilder.accessPoint.webAppPage - if (!webAppPage) - throw new Error('No Agent v2 Web app page was opened.') + if (!webAppPage) throw new Error('No Agent v2 Web app page was opened.') - await expect(webAppPage.getByText(agentBuilderExpectedTokens.updatedAgentReply)) - .not - .toBeVisible() + await expect( + webAppPage.getByText(agentBuilderExpectedTokens.updatedAgentReply), + ).not.toBeVisible() }, ) When('I close the Agent v2 Web app', async function (this: DifyWorld) { const webAppPage = this.agentBuilder.accessPoint.webAppPage - if (!webAppPage) - throw new Error('No Agent v2 Web app page was opened.') + if (!webAppPage) throw new Error('No Agent v2 Web app page was opened.') await webAppPage.close() this.agentBuilder.accessPoint.webAppPage = undefined @@ -183,8 +165,7 @@ Then( 'the current Agent v2 orchestration draft should be unchanged', async function (this: DifyWorld) { const snapshot = this.agentBuilder.accessPoint.composerDraftSnapshot - if (!snapshot) - throw new Error('No Agent v2 orchestration draft snapshot was recorded.') + if (!snapshot) throw new Error('No Agent v2 orchestration draft snapshot was recorded.') const draft = await getAgentComposerDraft(getCurrentAgentId(this)) @@ -200,7 +181,8 @@ Given( 'Disabled Agent v2 Web app public URL does not expose a stable user-visible unavailable state; the current route redirects to Web app sign-in.', { owner: 'product', - remediation: 'Define and implement the disabled public Web app UX before enabling this scenario.', + remediation: + 'Define and implement the disabled public Web app UX before enabling this scenario.', }, ) }, @@ -208,10 +190,8 @@ Given( When('I open the disabled Agent v2 Web app URL', async function (this: DifyWorld) { const webAppURL = this.agentBuilder.accessPoint.webAppURL - if (!webAppURL) - throw new Error('No Agent v2 Web app URL was recorded.') - if (!this.context) - throw new Error('Playwright browser context has not been initialized.') + if (!webAppURL) throw new Error('No Agent v2 Web app URL was recorded.') + if (!this.context) throw new Error('Playwright browser context has not been initialized.') const webAppPage = await this.context.newPage() await webAppPage.goto(webAppURL) @@ -219,24 +199,24 @@ When('I open the disabled Agent v2 Web app URL', async function (this: DifyWorld this.agentBuilder.accessPoint.webAppPage = webAppPage }) -Then('the disabled Agent v2 Web app should show an unavailable state', async function (this: DifyWorld) { - const webAppPage = this.agentBuilder.accessPoint.webAppPage - if (!webAppPage) - throw new Error('No Agent v2 Web app page was opened.') +Then( + 'the disabled Agent v2 Web app should show an unavailable state', + async function (this: DifyWorld) { + const webAppPage = this.agentBuilder.accessPoint.webAppPage + if (!webAppPage) throw new Error('No Agent v2 Web app page was opened.') - await expect(webAppPage.getByText(/app is unavailable|site is disabled/i)).toBeVisible({ - timeout: 30_000, - }) - await webAppPage.close() - this.agentBuilder.accessPoint.webAppPage = undefined -}) + await expect(webAppPage.getByText(/app is unavailable|site is disabled/i)).toBeVisible({ + timeout: 30_000, + }) + await webAppPage.close() + this.agentBuilder.accessPoint.webAppPage = undefined + }, +) When('I open the restored Agent v2 Web app URL', async function (this: DifyWorld) { const webAppURL = this.agentBuilder.accessPoint.webAppURL - if (!webAppURL) - throw new Error('No Agent v2 Web app URL was recorded.') - if (!this.context) - throw new Error('Playwright browser context has not been initialized.') + if (!webAppURL) throw new Error('No Agent v2 Web app URL was recorded.') + if (!this.context) throw new Error('Playwright browser context has not been initialized.') const webAppPage = await this.context.newPage() await webAppPage.goto(webAppURL) @@ -244,12 +224,14 @@ When('I open the restored Agent v2 Web app URL', async function (this: DifyWorld this.agentBuilder.accessPoint.webAppPage = webAppPage }) -Then('the restored Agent v2 Web app should not show an unavailable state', async function (this: DifyWorld) { - const webAppPage = this.agentBuilder.accessPoint.webAppPage - if (!webAppPage) - throw new Error('No Agent v2 Web app page was opened.') +Then( + 'the restored Agent v2 Web app should not show an unavailable state', + async function (this: DifyWorld) { + const webAppPage = this.agentBuilder.accessPoint.webAppPage + if (!webAppPage) throw new Error('No Agent v2 Web app page was opened.') - await expect(webAppPage.getByText(/app is unavailable|site is disabled/i)).not.toBeVisible() - await webAppPage.close() - this.agentBuilder.accessPoint.webAppPage = undefined -}) + await expect(webAppPage.getByText(/app is unavailable|site is disabled/i)).not.toBeVisible() + await webAppPage.close() + this.agentBuilder.accessPoint.webAppPage = undefined + }, +) diff --git a/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts index e1f51d3b07ed72..5fdb3d725771df 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts @@ -15,7 +15,9 @@ Then( 'agent', ) const references = await getAgentReferencingWorkflows(agent.id) - const reference = references.find(item => item.app_id === workflow.id || item.app_name === workflow.name) + const reference = references.find( + (item) => item.app_id === workflow.id || item.app_name === workflow.name, + ) if (!reference) throw new Error(`Agent "${agent.name}" does not reference workflow "${workflow.name}".`) @@ -34,8 +36,7 @@ Then( await expect(row.getByText(new RegExp(`^${nodeCount} nodes?$`))).toBeVisible() if (reference.app_updated_at == null) await expect(row.getByText('N/A', { exact: true })).toBeVisible() - else - await expect(row.getByText('N/A', { exact: true })).not.toBeVisible() + else await expect(row.getByText('N/A', { exact: true })).not.toBeVisible() await expect(row.getByRole('link', { name: `Open ${workflowName} in Studio` })).toBeVisible() }, ) @@ -43,7 +44,9 @@ Then( When( 'I open the Agent v2 Workflow access reference for {string}', async function (this: DifyWorld, workflowName: string) { - const workflowLink = this.getPage().getByRole('link', { name: `Open ${workflowName} in Studio` }) + const workflowLink = this.getPage().getByRole('link', { + name: `Open ${workflowName} in Studio`, + }) const [workflowPage] = await Promise.all([ this.getPage().waitForEvent('popup'), @@ -58,8 +61,7 @@ Then( 'the Agent v2 Workflow access reference for {string} should open in Studio', async function (this: DifyWorld, workflowName: string) { const workflowPage = this.agentBuilder.accessPoint.workflowReferencePage - if (!workflowPage) - throw new Error('No Agent v2 Workflow access reference page was opened.') + if (!workflowPage) throw new Error('No Agent v2 Workflow access reference page was opened.') const workflow = getPreseededResource(this, workflowName, 'workflow') diff --git a/e2e/features/step-definitions/agent-v2/access-point.steps.ts b/e2e/features/step-definitions/agent-v2/access-point.steps.ts index 11edc563a4379e..cdc4d01fbbb508 100644 --- a/e2e/features/step-definitions/agent-v2/access-point.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point.steps.ts @@ -2,10 +2,7 @@ import type { DifyWorld } from '../../support/world' import type { AccessSurfaceName } from './access-point-helpers' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { - setAgentApiAccess, - setAgentSiteAccessAndGetURL, -} from '../../agent-v2/support/access-point' +import { setAgentApiAccess, setAgentSiteAccessAndGetURL } from '../../agent-v2/support/access-point' import { getAgentAccessPath, publishAgentWithPublishableDraft } from '../../agent-v2/support/agent' import { getAccessRegion, @@ -101,8 +98,7 @@ When( if (surface === 'Web app') { const launchLink = accessSurfaceCard.getByRole('link', { name: 'Launch' }) const href = await launchLink.getAttribute('href') - if (!href) - throw new Error('Agent v2 Web app Launch link does not expose an href.') + if (!href) throw new Error('Agent v2 Web app Launch link does not expose an href.') this.agentBuilder.accessPoint.webAppURL = href } diff --git a/e2e/features/step-definitions/agent-v2/advanced-settings.steps.ts b/e2e/features/step-definitions/agent-v2/advanced-settings.steps.ts index 8199b61b8e99b0..b36f463f5013cf 100644 --- a/e2e/features/step-definitions/agent-v2/advanced-settings.steps.ts +++ b/e2e/features/step-definitions/agent-v2/advanced-settings.steps.ts @@ -16,9 +16,7 @@ When('I collapse Agent v2 Advanced Settings', async function (this: DifyWorld) { const advancedSettings = page.getByRole('region', { name: 'Advanced Settings' }) await page.getByRole('button', { name: 'Advanced Settings' }).first().click() - await expect(advancedSettings.getByRole('heading', { name: 'Env Editor' })) - .not - .toBeVisible() + await expect(advancedSettings.getByRole('heading', { name: 'Env Editor' })).not.toBeVisible() }) Then( @@ -31,9 +29,7 @@ Then( await expect( advancedSettings.getByText('For power users. Env vars, sandbox & memory.'), ).toBeVisible() - await expect(advancedSettings.getByRole('heading', { name: 'Env Editor' })) - .not - .toBeVisible() + await expect(advancedSettings.getByRole('heading', { name: 'Env Editor' })).not.toBeVisible() }, ) @@ -45,8 +41,7 @@ Then( await expect(envEditor).toBeVisible() await expect(envEditor.getByRole('button', { name: 'Import .env' })).toBeVisible() - await expect(envEditor.getByRole('button', { name: 'Add environment variable' })) - .toBeVisible() + await expect(envEditor.getByRole('button', { name: 'Add environment variable' })).toBeVisible() await expect(envEditor.getByText('Key', { exact: true })).toBeVisible() await expect(envEditor.getByText('Value', { exact: true })).toBeVisible() await expect(envEditor.getByText('Scope', { exact: true })).toBeVisible() diff --git a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts index 5bd818bb1d45ac..fdb03d61fdf654 100644 --- a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts @@ -3,11 +3,12 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { createE2EResourceName } from '../../../support/naming' +import { getAgentComposerDraft, getTestAgent } from '../../agent-v2/support/agent' import { - getAgentComposerDraft, - getTestAgent, -} from '../../agent-v2/support/agent' -import { agentBuilderExpectedTokens, agentBuilderFixedInputs, agentBuilderPreseededResources } from '../../agent-v2/support/agent-builder-resources' + agentBuilderExpectedTokens, + agentBuilderFixedInputs, + agentBuilderPreseededResources, +} from '../../agent-v2/support/agent-builder-resources' import { normalAgentPrompt } from '../../agent-v2/support/agent-soul' import { asArray, @@ -34,10 +35,13 @@ const getComposerInheritanceSnapshot = async (agentId: string) => { const knowledgeSets = asArray(asRecord(soul.knowledge).sets) return { - fileNames: files.map(file => asString(asRecord(file).name)).filter(Boolean).sort(), + fileNames: files + .map((file) => asString(asRecord(file).name)) + .filter(Boolean) + .sort(), knowledgeDatasetNames: knowledgeSets - .flatMap(set => asArray(asRecord(set).datasets)) - .map(dataset => asString(asRecord(dataset).name)) + .flatMap((set) => asArray(asRecord(set).datasets)) + .map((dataset) => asString(asRecord(dataset).name)) .filter(Boolean) .sort(), model: { @@ -45,19 +49,23 @@ const getComposerInheritanceSnapshot = async (agentId: string) => { provider: asString(model.model_provider), }, prompt: asString(prompt.system_prompt), - skillNames: skills.map(skill => asString(asRecord(skill).name)).filter(Boolean).sort(), + skillNames: skills + .map((skill) => asString(asRecord(skill).name)) + .filter(Boolean) + .sort(), toolSignatures: tools .map((tool) => { const record = asRecord(tool) - const provider = asString(record.provider_id) - || asString(record.provider) - || asString(record.plugin_id) - || asString(record.name) + const provider = + asString(record.provider_id) || + asString(record.provider) || + asString(record.plugin_id) || + asString(record.name) const toolName = asString(record.tool_name) || asString(record.name) return `${provider}/${toolName}` }) - .filter(signature => signature !== '/') + .filter((signature) => signature !== '/') .sort(), } } @@ -70,9 +78,12 @@ When( const copyName = createE2EResourceName('Agent', 'copy') await page.goto('/agents') - const card = page.locator('article').filter({ - has: page.getByRole('link', { name: agentName }), - }).first() + const card = page + .locator('article') + .filter({ + has: page.getByRole('link', { name: agentName }), + }) + .first() await expect(card).toBeVisible({ timeout: 30_000 }) await card.hover() @@ -83,10 +94,11 @@ When( await expect(dialog).toBeVisible() await dialog.getByRole('textbox', { name: /Name/ }).fill(copyName) - const copyResponsePromise = page.waitForResponse(response => ( - response.request().method() === 'POST' - && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agent.id}/copy`) - )) + const copyResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + new URL(response.url()).pathname.endsWith(`/console/api/agent/${agent.id}/copy`), + ) await dialog.getByRole('button', { name: 'Duplicate' }).click() const copyResponse = await copyResponsePromise @@ -110,8 +122,9 @@ Then('I should see the Agent v2 full-config fixture sections', async function (t throw new Error('Stable chat model preflight must run before asserting the full-config Agent.') await expect(page.getByRole('heading', { name: 'Configure' })).toBeVisible({ timeout: 30_000 }) - await expect(page.getByText(agentBuilderPreseededResources.fullConfigAgent, { exact: true })) - .toBeVisible() + await expect( + page.getByText(agentBuilderPreseededResources.fullConfigAgent, { exact: true }), + ).toBeVisible() await expect(page.getByText(stableModel.name, { exact: true })).toBeVisible() const promptSection = page.getByRole('region', { name: 'Prompt' }) @@ -120,21 +133,27 @@ Then('I should see the Agent v2 full-config fixture sections', async function (t const skillsSection = page.getByRole('region', { name: 'Skills' }) await expect(skillsSection).toBeVisible() - await expect(skillsSection.getByRole('button', { - exact: true, - name: agentBuilderPreseededResources.summarySkill, - })).toBeVisible() + await expect( + skillsSection.getByRole('button', { + exact: true, + name: agentBuilderPreseededResources.summarySkill, + }), + ).toBeVisible() const filesSection = page.getByRole('region', { name: 'Files' }) await expect(filesSection).toBeVisible() - await expect(filesSection.getByRole('button', { - exact: true, - name: agentBuilderTestMaterials.smallFile, - })).toBeVisible() - await expect(filesSection.getByRole('button', { - exact: true, - name: agentBuilderTestMaterials.specialFilename, - })).toBeVisible() + await expect( + filesSection.getByRole('button', { + exact: true, + name: agentBuilderTestMaterials.smallFile, + }), + ).toBeVisible() + await expect( + filesSection.getByRole('button', { + exact: true, + name: agentBuilderTestMaterials.specialFilename, + }), + ).toBeVisible() const toolsSection = page.getByRole('region', { name: 'Tools' }) await expect(toolsSection).toBeVisible() @@ -172,25 +191,29 @@ Then( expect(duplicatedDetail.id).toBe(duplicatedAgentId) expect(duplicatedDetail.name).toBe(this.lastCreatedAgentName) - expect(duplicatedDetail.active_config_is_published).toBe(sourceDetail.active_config_is_published) + expect(duplicatedDetail.active_config_is_published).toBe( + sourceDetail.active_config_is_published, + ) expect(duplicatedSnapshot.model).toEqual({ name: stableModel.name, provider: stableModel.provider, }) expect(duplicatedSnapshot.model).toEqual(sourceSnapshot.model) expect(duplicatedSnapshot.prompt).toBe(sourceSnapshot.prompt) - expect(duplicatedSnapshot.fileNames).toEqual(expect.arrayContaining([ - agentBuilderTestMaterials.smallFile, - agentBuilderTestMaterials.specialFilename, - ])) - expect(duplicatedSnapshot.skillNames).toEqual(expect.arrayContaining([ - agentBuilderPreseededResources.summarySkill, - ])) + expect(duplicatedSnapshot.fileNames).toEqual( + expect.arrayContaining([ + agentBuilderTestMaterials.smallFile, + agentBuilderTestMaterials.specialFilename, + ]), + ) + expect(duplicatedSnapshot.skillNames).toEqual( + expect.arrayContaining([agentBuilderPreseededResources.summarySkill]), + ) expect(duplicatedSnapshot.skillNames).toEqual(sourceSnapshot.skillNames) expect(duplicatedSnapshot.toolSignatures).toEqual(sourceSnapshot.toolSignatures) - expect(duplicatedSnapshot.knowledgeDatasetNames).toEqual(expect.arrayContaining([ - agentBuilderPreseededResources.agentKnowledgeBase, - ])) + expect(duplicatedSnapshot.knowledgeDatasetNames).toEqual( + expect.arrayContaining([agentBuilderPreseededResources.agentKnowledgeBase]), + ) }, ) @@ -199,14 +222,16 @@ Then( async function (this: DifyWorld, agentName: string) { const sourceAgent = getPreseededAgent(this, agentName) - await expect.poll( - async () => { - const draft = await getAgentComposerDraft(sourceAgent.id) + await expect + .poll( + async () => { + const draft = await getAgentComposerDraft(sourceAgent.id) - return asString(asRecord(draft.agent_soul?.prompt).system_prompt) - }, - { timeout: 30_000 }, - ).toBe(normalAgentPrompt) + return asString(asRecord(draft.agent_soul?.prompt).system_prompt) + }, + { timeout: 30_000 }, + ) + .toBe(normalAgentPrompt) }, ) @@ -215,21 +240,27 @@ Then('I should see the Agent v2 tool state fixture tools', async function (this: const toolsSection = page.getByRole('region', { name: 'Tools' }) await expect(toolsSection).toBeVisible({ timeout: 30_000 }) - await expect(toolsSection.getByRole('button', { exact: true, name: 'Not authorized' })).toBeVisible() + await expect( + toolsSection.getByRole('button', { exact: true, name: 'Not authorized' }), + ).toBeVisible() const { action: jsonReplaceAction, tool: jsonTool } = await expectProviderToolActionVisible( toolsSection, agentBuilderPreseededResources.jsonReplaceTool, ) await jsonReplaceAction.hover() - await expect(toolsSection.getByRole('button', { - exact: true, - name: `Edit ${jsonTool.actionName}`, - })).toBeVisible() - await expect(toolsSection.getByRole('button', { - exact: true, - name: `Remove ${jsonTool.actionName}`, - })).toBeVisible() + await expect( + toolsSection.getByRole('button', { + exact: true, + name: `Edit ${jsonTool.actionName}`, + }), + ).toBeVisible() + await expect( + toolsSection.getByRole('button', { + exact: true, + name: `Remove ${jsonTool.actionName}`, + }), + ).toBeVisible() await expectProviderToolActionVisible( toolsSection, @@ -243,7 +274,8 @@ async function skipToolCredentialErrorState(world: DifyWorld) { 'Agent v2 Tool credential error state is not covered: the current fixture only proves usable and not-authorized tool states.', { owner: 'seed/product', - remediation: 'Define a stable invalid credential fixture and the expected user-visible error label before enabling this scenario.', + remediation: + 'Define a stable invalid credential fixture and the expected user-visible error label before enabling this scenario.', }, ) } @@ -265,26 +297,36 @@ Then('I should see the Agent v2 dual retrieval fixture settings', async function await expect(knowledgeSection.getByText('Retrieval 2', { exact: true })).toBeVisible() const agentDecideDialog = await openAgentKnowledgeRetrievalDialog(knowledgeSection, 'Retrieval 1') - await expect(agentDecideDialog.getByText(agentBuilderPreseededResources.agentKnowledgeBase, { - exact: true, - })).toBeVisible() - await expect(agentDecideDialog.getByRole('radio', { - exact: true, - name: 'Agent decide', - })).toBeChecked() + await expect( + agentDecideDialog.getByText(agentBuilderPreseededResources.agentKnowledgeBase, { + exact: true, + }), + ).toBeVisible() + await expect( + agentDecideDialog.getByRole('radio', { + exact: true, + name: 'Agent decide', + }), + ).toBeChecked() await agentDecideDialog.getByRole('button', { name: 'Close' }).click() await expect(agentDecideDialog).not.toBeVisible() const customQueryDialog = await openAgentKnowledgeRetrievalDialog(knowledgeSection, 'Retrieval 2') - await expect(customQueryDialog.getByText(agentBuilderPreseededResources.agentKnowledgeBase, { - exact: true, - })).toBeVisible() - await expect(customQueryDialog.getByRole('radio', { - exact: true, - name: 'Custom query', - })).toBeChecked() - await expect(customQueryDialog.getByRole('textbox', { - exact: true, - name: 'Custom query text', - })).toHaveValue(agentBuilderFixedInputs.customKnowledgeQuery) + await expect( + customQueryDialog.getByText(agentBuilderPreseededResources.agentKnowledgeBase, { + exact: true, + }), + ).toBeVisible() + await expect( + customQueryDialog.getByRole('radio', { + exact: true, + name: 'Custom query', + }), + ).toBeChecked() + await expect( + customQueryDialog.getByRole('textbox', { + exact: true, + name: 'Custom query text', + }), + ).toHaveValue(agentBuilderFixedInputs.customKnowledgeQuery) }) diff --git a/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts b/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts index c6dc4bd68bb8c4..97351d72a625da 100644 --- a/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts @@ -19,16 +19,17 @@ When('I create an Agent v2 test agent from the Agent Roster', async function (th await dialog.getByRole('textbox', { name: /Role.*optional/ }).fill(agentRole) await dialog.getByRole('textbox', { name: /Description.*optional/ }).fill(agentDescription) - const createResponsePromise = page.waitForResponse(response => ( - response.request().method() === 'POST' - && new URL(response.url()).pathname.endsWith('/console/api/agent') - )) + const createResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + new URL(response.url()).pathname.endsWith('/console/api/agent'), + ) await dialog.getByRole('button', { name: 'Create' }).click() const createResponse = await createResponsePromise expect(createResponse.ok()).toBe(true) - const createdAgent = await createResponse.json() as AgentAppDetailWithSite + const createdAgent = (await createResponse.json()) as AgentAppDetailWithSite this.createdAgentIds.push(createdAgent.id) this.lastCreatedAgentName = createdAgent.name this.lastCreatedAgentRole = createdAgent.role ?? undefined @@ -36,13 +37,12 @@ When('I create an Agent v2 test agent from the Agent Roster', async function (th Then('the created Agent v2 should open in Configure', async function (this: DifyWorld) { const agentId = this.createdAgentIds.at(-1) - if (!agentId) - throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.') - - await expect(this.getPage()).toHaveURL( - new RegExp(`/agents/${agentId}/configure(?:\\?.*)?$`), - { timeout: 30_000 }, - ) - await expect(this.getPage().getByRole('heading', { name: 'Configure' })) - .toBeVisible({ timeout: 30_000 }) + if (!agentId) throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.') + + await expect(this.getPage()).toHaveURL(new RegExp(`/agents/${agentId}/configure(?:\\?.*)?$`), { + timeout: 30_000, + }) + await expect(this.getPage().getByRole('heading', { name: 'Configure' })).toBeVisible({ + timeout: 30_000, + }) }) diff --git a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts index 77e61fa1700391..62f414ee0f482d 100644 --- a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts +++ b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts @@ -3,17 +3,17 @@ import type { DifyWorld } from '../../support/world' import { readFile } from 'node:fs/promises' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { - getAgentComposerDraft, - saveAgentComposerDraft, -} from '../../agent-v2/support/agent' +import { getAgentComposerDraft, saveAgentComposerDraft } from '../../agent-v2/support/agent' import { agentBuildDraftExists, applyAgentBuildDraft, getAgentBuildDraft, saveAgentBuildDraft, } from '../../agent-v2/support/agent-build-draft' -import { agentBuilderFixedInputs, agentBuilderPreseededResources } from '../../agent-v2/support/agent-builder-resources' +import { + agentBuilderFixedInputs, + agentBuilderPreseededResources, +} from '../../agent-v2/support/agent-builder-resources' import { uploadAgentConfigFileToDraft } from '../../agent-v2/support/agent-drive' import { createAgentSoulConfigWithModel, @@ -24,7 +24,10 @@ import { } from '../../agent-v2/support/agent-soul' import { asArray, asRecord, skipBlockedPrecondition } from '../../agent-v2/support/preflight/common' import { hasToolEntry } from '../../agent-v2/support/preflight/tools' -import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from '../../agent-v2/support/test-materials' +import { + agentBuilderTestMaterials, + getAgentBuilderTestMaterialPath, +} from '../../agent-v2/support/test-materials' import { getPreseededToolContract } from '../../agent-v2/support/tools' import { expectAgentModelRequiredFeedback, @@ -39,11 +42,11 @@ const BUILD_NOTE_FILE_NAME = 'build_note.md' const BUILD_NOTE_MARKER = 'E2E_BUILD_DRAFT_PASS' const BUILD_NOTE_GENERATED_BADGE = 'Generated' -const getBuildDraftBar = (page: Page) => - page.getByRole('group', { name: 'Build draft' }) +const getBuildDraftBar = (page: Page) => page.getByRole('group', { name: 'Build draft' }) const getBuildNoteFileButton = (page: Page) => - page.getByRole('region', { name: 'Files' }) + page + .getByRole('region', { name: 'Files' }) .getByRole('button') .filter({ hasText: BUILD_NOTE_FILE_NAME }) .filter({ hasText: BUILD_NOTE_GENERATED_BADGE }) @@ -53,8 +56,7 @@ const getConfigNote = (value: Awaited<ReturnType<typeof getAgentBuildDraft>>) => const getLastBuildChatAnswerText = async (page: Page) => { const answer = page.getByTestId('chat-answer-container').last() - if (await answer.count() === 0) - return '' + if ((await answer.count()) === 0) return '' return (await answer.textContent())?.replace(/\s+/g, ' ').trim() ?? '' } @@ -78,10 +80,16 @@ Given( this.createdAgentConfigFiles.push({ agentId, name: configFile.name }) const normalConfig = this.agentBuilder.preflight.stableModel - ? createAgentSoulConfigWithModel(normalAgentSoulConfig, this.agentBuilder.preflight.stableModel) + ? createAgentSoulConfigWithModel( + normalAgentSoulConfig, + this.agentBuilder.preflight.stableModel, + ) : normalAgentSoulConfig const updatedConfig = this.agentBuilder.preflight.stableModel - ? createAgentSoulConfigWithModel(updatedAgentSoulConfig, this.agentBuilder.preflight.stableModel) + ? createAgentSoulConfigWithModel( + updatedAgentSoulConfig, + this.agentBuilder.preflight.stableModel, + ) : updatedAgentSoulConfig await saveAgentComposerDraft(agentId, normalConfig) @@ -91,13 +99,15 @@ Given( config_skills: [skill], env: { secret_refs: [], - variables: [{ - id: agentBuilderFixedInputs.envPlainKey, - key: agentBuilderFixedInputs.envPlainKey, - name: agentBuilderFixedInputs.envPlainKey, - value: agentBuilderFixedInputs.envPlainValue, - variable: agentBuilderFixedInputs.envPlainKey, - }], + variables: [ + { + id: agentBuilderFixedInputs.envPlainKey, + key: agentBuilderFixedInputs.envPlainKey, + name: agentBuilderFixedInputs.envPlainKey, + value: agentBuilderFixedInputs.envPlainValue, + variable: agentBuilderFixedInputs.envPlainKey, + }, + ], }, }) }, @@ -108,10 +118,16 @@ Given( async function (this: DifyWorld) { const skill = await uploadSummaryConfigSkillForBuildDraft(this) const normalConfig = this.agentBuilder.preflight.stableModel - ? createAgentSoulConfigWithModel(normalAgentSoulConfig, this.agentBuilder.preflight.stableModel) + ? createAgentSoulConfigWithModel( + normalAgentSoulConfig, + this.agentBuilder.preflight.stableModel, + ) : normalAgentSoulConfig const updatedConfig = this.agentBuilder.preflight.stableModel - ? createAgentSoulConfigWithModel(updatedAgentSoulConfig, this.agentBuilder.preflight.stableModel) + ? createAgentSoulConfigWithModel( + updatedAgentSoulConfig, + this.agentBuilder.preflight.stableModel, + ) : updatedAgentSoulConfig const configSkills = [skill] @@ -134,11 +150,16 @@ Given( 'an Agent v2 Build draft uses the updated E2E prompt with the stable E2E model', async function (this: DifyWorld) { if (!this.agentBuilder.preflight.stableModel) - throw new Error('Create an Agent v2 Build draft with a stable model after stable model preflight.') + throw new Error( + 'Create an Agent v2 Build draft with a stable model after stable model preflight.', + ) await saveAgentBuildDraft( getCurrentAgentId(this), - createAgentSoulConfigWithModel(updatedAgentSoulConfig, this.agentBuilder.preflight.stableModel), + createAgentSoulConfigWithModel( + updatedAgentSoulConfig, + this.agentBuilder.preflight.stableModel, + ), ) }, ) @@ -149,19 +170,25 @@ When( async function (this: DifyWorld) { const page = this.getPage() const agentId = getCurrentAgentId(this) - const instruction = (await readFile(getAgentBuilderTestMaterialPath('buildInstruction'), 'utf8')).trim() + const instruction = ( + await readFile(getAgentBuilderTestMaterialPath('buildInstruction'), 'utf8') + ).trim() await page.getByRole('button', { exact: true, name: 'Build' }).click() await page.getByPlaceholder('Describe what your agent should do').fill(instruction) - const checkoutResponsePromise = page.waitForResponse(response => ( - response.request().method() === 'POST' - && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-draft/checkout`) - )) - const chatResponsePromise = page.waitForResponse(response => ( - response.request().method() === 'POST' - && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/chat-messages`) - )) + const checkoutResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + new URL(response.url()).pathname.endsWith( + `/console/api/agent/${agentId}/build-draft/checkout`, + ), + ) + const chatResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/chat-messages`), + ) await page.getByRole('button', { name: 'Start build' }).click() expect((await checkoutResponsePromise).ok()).toBe(true) @@ -176,31 +203,30 @@ When( }, ) -When( - 'I try to generate an Agent v2 Build draft without a model', - async function (this: DifyWorld) { - const page = this.getPage() +When('I try to generate an Agent v2 Build draft without a model', async function (this: DifyWorld) { + const page = this.getPage() - await page.getByRole('button', { exact: true, name: 'Build' }).click() - await page.getByPlaceholder('Describe what your agent should do').fill('Update the agent instructions for E2E.') - await page.getByRole('button', { name: 'Start build' }).click() - }, -) + await page.getByRole('button', { exact: true, name: 'Build' }).click() + await page + .getByPlaceholder('Describe what your agent should do') + .fill('Update the agent instructions for E2E.') + await page.getByRole('button', { name: 'Start build' }).click() +}) const expectPageResponseOK = async (response: Response, action: string) => { - if (response.ok()) - return + if (response.ok()) return let body = '' try { body = await response.text() - } - catch { + } catch { body = '<response body unavailable>' } const trimmedBody = body.length > 1000 ? `${body.slice(0, 1000)}...` : body - throw new Error(`${action} failed with ${response.status()} ${response.statusText()} at ${response.url()}: ${trimmedBody}`) + throw new Error( + `${action} failed with ${response.status()} ${response.statusText()} at ${response.url()}: ${trimmedBody}`, + ) } When('I discard the Agent v2 Build draft', async function (this: DifyWorld) { @@ -208,13 +234,16 @@ When('I discard the Agent v2 Build draft', async function (this: DifyWorld) { const agentId = getCurrentAgentId(this) await page.getByRole('button', { exact: true, name: 'Discard' }).click() - const confirmDialog = page.getByRole('alertdialog', { name: 'Clear session and discard changes?' }) + const confirmDialog = page.getByRole('alertdialog', { + name: 'Clear session and discard changes?', + }) await expect(confirmDialog).toBeVisible() - const discardResponsePromise = page.waitForResponse(response => ( - response.request().method() === 'DELETE' - && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-draft`) - )) + const discardResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'DELETE' && + new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-draft`), + ) await confirmDialog.getByRole('button', { name: 'Confirm' }).click() await expectPageResponseOK(await discardResponsePromise, 'Discard Agent v2 Build draft') @@ -229,14 +258,22 @@ When( const applyButton = page.getByRole('button', { exact: true, name: 'Apply' }) await expect(applyButton).toBeEnabled({ timeout: 30_000 }) - const finalizeResponsePromise = page.waitForResponse(response => ( - response.request().method() === 'POST' - && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-chat/finalize`) - ), { timeout: 120_000 }) - const applyResponsePromise = page.waitForResponse(response => ( - response.request().method() === 'POST' - && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-draft/apply`) - ), { timeout: 120_000 }) + const finalizeResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + new URL(response.url()).pathname.endsWith( + `/console/api/agent/${agentId}/build-chat/finalize`, + ), + { timeout: 120_000 }, + ) + const applyResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + new URL(response.url()).pathname.endsWith( + `/console/api/agent/${agentId}/build-draft/apply`, + ), + { timeout: 120_000 }, + ) await applyButton.click() await expectPageResponseOK(await finalizeResponsePromise, 'Finalize Agent v2 Build draft') @@ -264,9 +301,12 @@ Given('Agent v2 Build chat Dify Tool writeback is available', async function (th return skipBuildDraftToolWriteback(this) }) -Then('Agent v2 Build chat Dify Tool writeback should be available', async function (this: DifyWorld) { - return skipBuildDraftToolWriteback(this) -}) +Then( + 'Agent v2 Build chat Dify Tool writeback should be available', + async function (this: DifyWorld) { + return skipBuildDraftToolWriteback(this) + }, +) async function skipBuildDraftUnavailableResourceRecovery(world: DifyWorld) { return skipBlockedPrecondition( @@ -274,18 +314,25 @@ async function skipBuildDraftUnavailableResourceRecovery(world: DifyWorld) { 'Build chat unavailable Skill/Tool recovery is not covered: the product needs a stable user-visible failure state and deterministic request fixture before this can be automated.', { owner: 'product/seed', - remediation: 'Define the unavailable-resource UX contract, then seed a stable model-backed prompt that requests a missing Skill and Tool without mutating the saved Agent config.', + remediation: + 'Define the unavailable-resource UX contract, then seed a stable model-backed prompt that requests a missing Skill and Tool without mutating the saved Agent config.', }, ) } -Given('Agent v2 Build chat unavailable Skill and Tool recovery is available', async function (this: DifyWorld) { - return skipBuildDraftUnavailableResourceRecovery(this) -}) +Given( + 'Agent v2 Build chat unavailable Skill and Tool recovery is available', + async function (this: DifyWorld) { + return skipBuildDraftUnavailableResourceRecovery(this) + }, +) -Then('Agent v2 Build chat unavailable Skill and Tool recovery should be available', async function (this: DifyWorld) { - return skipBuildDraftUnavailableResourceRecovery(this) -}) +Then( + 'Agent v2 Build chat unavailable Skill and Tool recovery should be available', + async function (this: DifyWorld) { + return skipBuildDraftUnavailableResourceRecovery(this) + }, +) Then('I should see the Agent v2 Build draft pending changes', async function (this: DifyWorld) { const page = this.getPage() @@ -299,20 +346,24 @@ Then('I should see the Agent v2 Build mode confirmation state', async function ( const page = this.getPage() await expect(page.getByText('Build mode', { exact: true })).toBeVisible() - await expect(page.getByText('Configure can only be updated by the agent in this mode.')).toBeVisible() - await expect(page.getByText('Shape this setup through the chat on the right, then Apply.')).toBeVisible() + await expect( + page.getByText('Configure can only be updated by the agent in this mode.'), + ).toBeVisible() + await expect( + page.getByText('Shape this setup through the chat on the right, then Apply.'), + ).toBeVisible() }) Then( 'the Agent v2 Build draft should include the generated build note', async function (this: DifyWorld) { try { - await expect.poll( - async () => getConfigNote(await getAgentBuildDraft(getCurrentAgentId(this))), - { timeout: BUILD_DRAFT_NOTE_SYNC_TIMEOUT_MS }, - ).toContain(BUILD_NOTE_MARKER) - } - catch (error) { + await expect + .poll(async () => getConfigNote(await getAgentBuildDraft(getCurrentAgentId(this))), { + timeout: BUILD_DRAFT_NOTE_SYNC_TIMEOUT_MS, + }) + .toContain(BUILD_NOTE_MARKER) + } catch (error) { const lastAnswerText = await getLastBuildChatAnswerText(this.getPage()) throw new Error( `Agent v2 Build draft note did not include ${BUILD_NOTE_MARKER}. Last Build chat answer: ${formatBuildChatAnswerText(lastAnswerText) || '<empty>'}`, @@ -322,74 +373,101 @@ Then( }, ) -Then('I should see the generated Agent v2 build note in Configure', async function (this: DifyWorld) { - await expect(getBuildNoteFileButton(this.getPage())).toBeVisible() -}) +Then( + 'I should see the generated Agent v2 build note in Configure', + async function (this: DifyWorld) { + await expect(getBuildNoteFileButton(this.getPage())).toBeVisible() + }, +) -Then('Agent v2 Build chat should be blocked until a model is configured', async function (this: DifyWorld) { - await expectAgentModelRequiredFeedback(this.getPage()) -}) +Then( + 'Agent v2 Build chat should be blocked until a model is configured', + async function (this: DifyWorld) { + await expectAgentModelRequiredFeedback(this.getPage()) + }, +) Then('the Agent v2 Build draft should not be checked out', async function (this: DifyWorld) { - await expect.poll( - async () => agentBuildDraftExists(getCurrentAgentId(this)), - { timeout: 30_000 }, - ).toBe(false) -}) - -Then('I should see the e2e-summary-skill Skill in the Skills section', async function (this: DifyWorld) { - const skillsSection = this.getPage().getByRole('region', { name: 'Skills' }) - - await expect(skillsSection).toBeVisible({ timeout: 30_000 }) - await expect(skillsSection.getByRole('button', { - exact: true, - name: agentBuilderPreseededResources.summarySkill, - })).toBeVisible() + await expect + .poll(async () => agentBuildDraftExists(getCurrentAgentId(this)), { timeout: 30_000 }) + .toBe(false) }) -Then('I should not see the e2e-summary-skill Skill in the Skills section', async function (this: DifyWorld) { - const skillsSection = this.getPage().getByRole('region', { name: 'Skills' }) - - await expect(skillsSection).toBeVisible({ timeout: 30_000 }) - await expect(skillsSection.getByRole('button', { - exact: true, - name: agentBuilderPreseededResources.summarySkill, - })).toHaveCount(0) -}) +Then( + 'I should see the e2e-summary-skill Skill in the Skills section', + async function (this: DifyWorld) { + const skillsSection = this.getPage().getByRole('region', { name: 'Skills' }) + + await expect(skillsSection).toBeVisible({ timeout: 30_000 }) + await expect( + skillsSection.getByRole('button', { + exact: true, + name: agentBuilderPreseededResources.summarySkill, + }), + ).toBeVisible() + }, +) -Then('I should see one e2e-summary-skill Skill in the Skills section', async function (this: DifyWorld) { - const skillsSection = this.getPage().getByRole('region', { name: 'Skills' }) +Then( + 'I should not see the e2e-summary-skill Skill in the Skills section', + async function (this: DifyWorld) { + const skillsSection = this.getPage().getByRole('region', { name: 'Skills' }) + + await expect(skillsSection).toBeVisible({ timeout: 30_000 }) + await expect( + skillsSection.getByRole('button', { + exact: true, + name: agentBuilderPreseededResources.summarySkill, + }), + ).toHaveCount(0) + }, +) - await expect(skillsSection).toBeVisible({ timeout: 30_000 }) - await expect(skillsSection.getByRole('button', { - exact: true, - name: agentBuilderPreseededResources.summarySkill, - })).toHaveCount(1) -}) +Then( + 'I should see one e2e-summary-skill Skill in the Skills section', + async function (this: DifyWorld) { + const skillsSection = this.getPage().getByRole('region', { name: 'Skills' }) + + await expect(skillsSection).toBeVisible({ timeout: 30_000 }) + await expect( + skillsSection.getByRole('button', { + exact: true, + name: agentBuilderPreseededResources.summarySkill, + }), + ).toHaveCount(1) + }, +) Then( 'the normal Agent v2 draft should not include the e2e-summary-skill Skill', async function (this: DifyWorld) { - await expect.poll( - async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul - - return agentSoul?.config_skills?.some( - skill => skill.name === agentBuilderPreseededResources.summarySkill, - ) ?? false - }, - { timeout: 30_000 }, - ).toBe(false) + await expect + .poll( + async () => { + const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + + return ( + agentSoul?.config_skills?.some( + (skill) => skill.name === agentBuilderPreseededResources.summarySkill, + ) ?? false + ) + }, + { timeout: 30_000 }, + ) + .toBe(false) }, ) Then( 'the normal Agent v2 draft should not include the generated build note', async function (this: DifyWorld) { - await expect.poll( - async () => (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.config_note ?? '', - { timeout: 30_000 }, - ).not.toContain(BUILD_NOTE_MARKER) + await expect + .poll( + async () => + (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.config_note ?? '', + { timeout: 30_000 }, + ) + .not.toContain(BUILD_NOTE_MARKER) }, ) @@ -399,80 +477,90 @@ Then( const agentId = getCurrentAgentId(this) const tool = getPreseededToolContract(this, agentBuilderPreseededResources.jsonReplaceTool) - await expect.poll( - async () => { - const draft = await getAgentComposerDraft(agentId) - const tools = asArray(asRecord(draft.agent_soul?.tools).dify_tools) + await expect + .poll( + async () => { + const draft = await getAgentComposerDraft(agentId) + const tools = asArray(asRecord(draft.agent_soul?.tools).dify_tools) - return hasToolEntry(tools, tool) - }, - { timeout: 30_000 }, - ).toBe(false) + return hasToolEntry(tools, tool) + }, + { timeout: 30_000 }, + ) + .toBe(false) }, ) Then( 'the Agent v2 draft should include the supported Build draft config', async function (this: DifyWorld) { - await expect.poll( - async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul - const variables = agentSoul?.env?.variables ?? [] - - return { - envValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envPlainKey), - fileNames: agentSoul?.config_files?.map(file => file.name) ?? [], - prompt: agentSoul?.prompt, - skillNames: agentSoul?.config_skills?.map(skill => skill.name) ?? [], - } - }, - { timeout: 30_000 }, - ).toEqual({ - envValue: agentBuilderFixedInputs.envPlainValue, - fileNames: expect.arrayContaining([agentBuilderTestMaterials.smallFile]), - prompt: { system_prompt: updatedAgentPrompt }, - skillNames: expect.arrayContaining([agentBuilderPreseededResources.summarySkill]), - }) + await expect + .poll( + async () => { + const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + const variables = agentSoul?.env?.variables ?? [] + + return { + envValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envPlainKey), + fileNames: agentSoul?.config_files?.map((file) => file.name) ?? [], + prompt: agentSoul?.prompt, + skillNames: agentSoul?.config_skills?.map((skill) => skill.name) ?? [], + } + }, + { timeout: 30_000 }, + ) + .toEqual({ + envValue: agentBuilderFixedInputs.envPlainValue, + fileNames: expect.arrayContaining([agentBuilderTestMaterials.smallFile]), + prompt: { system_prompt: updatedAgentPrompt }, + skillNames: expect.arrayContaining([agentBuilderPreseededResources.summarySkill]), + }) }, ) Then( 'the Agent v2 draft should not include the supported Build draft config', async function (this: DifyWorld) { - await expect.poll( - async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul - const variables = agentSoul?.env?.variables ?? [] - - return { - envValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envPlainKey), - fileNames: agentSoul?.config_files?.map(file => file.name) ?? [], - prompt: agentSoul?.prompt, - skillNames: agentSoul?.config_skills?.map(skill => skill.name) ?? [], - } - }, - { timeout: 30_000 }, - ).toEqual({ - envValue: undefined, - fileNames: expect.not.arrayContaining([agentBuilderTestMaterials.smallFile]), - prompt: { system_prompt: normalAgentPrompt }, - skillNames: expect.not.arrayContaining([agentBuilderPreseededResources.summarySkill]), - }) + await expect + .poll( + async () => { + const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + const variables = agentSoul?.env?.variables ?? [] + + return { + envValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envPlainKey), + fileNames: agentSoul?.config_files?.map((file) => file.name) ?? [], + prompt: agentSoul?.prompt, + skillNames: agentSoul?.config_skills?.map((skill) => skill.name) ?? [], + } + }, + { timeout: 30_000 }, + ) + .toEqual({ + envValue: undefined, + fileNames: expect.not.arrayContaining([agentBuilderTestMaterials.smallFile]), + prompt: { system_prompt: normalAgentPrompt }, + skillNames: expect.not.arrayContaining([agentBuilderPreseededResources.summarySkill]), + }) }, ) Then( 'the Agent v2 draft should include one e2e-summary-skill Skill', async function (this: DifyWorld) { - await expect.poll( - async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul - return agentSoul?.config_skills?.filter( - skill => skill.name === agentBuilderPreseededResources.summarySkill, - ).length ?? 0 - }, - { timeout: 30_000 }, - ).toBe(1) + await expect + .poll( + async () => { + const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + return ( + agentSoul?.config_skills?.filter( + (skill) => skill.name === agentBuilderPreseededResources.summarySkill, + ).length ?? 0 + ) + }, + { timeout: 30_000 }, + ) + .toBe(1) }, ) diff --git a/e2e/features/step-definitions/agent-v2/configure-helpers.ts b/e2e/features/step-definitions/agent-v2/configure-helpers.ts index 10927c23b1be32..2b5771417e7765 100644 --- a/e2e/features/step-definitions/agent-v2/configure-helpers.ts +++ b/e2e/features/step-definitions/agent-v2/configure-helpers.ts @@ -12,8 +12,7 @@ import { export const getCurrentAgentId = (world: DifyWorld) => { const agentId = world.createdAgentIds.at(-1) - if (!agentId) - throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.') + if (!agentId) throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.') return agentId } @@ -40,10 +39,8 @@ const getPreseededToolDisplayParts = (displayName: string) => { export const getEnvVariableKey = (variable: AgentComposerEnvVariable) => variable.key ?? variable.name ?? variable.variable -export const getAgentEnvVariableValue = ( - variables: AgentComposerEnvVariable[], - key: string, -) => variables.find(variable => getEnvVariableKey(variable) === key)?.value +export const getAgentEnvVariableValue = (variables: AgentComposerEnvVariable[], key: string) => + variables.find((variable) => getEnvVariableKey(variable) === key)?.value export const getAgentEnvVariables = async (agentId: string) => (await getAgentComposerDraft(agentId)).agent_soul?.env?.variables ?? [] @@ -66,14 +63,15 @@ export const uploadAgentConfigFile = async ( await (await fileChooserPromise).setFiles(filePath) await expect(dialog.getByText(fileName)).toBeVisible() - const commitResponsePromise = page.waitForResponse(response => ( - response.request().method() === 'POST' - && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/config/files`) - )) + const commitResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/config/files`), + ) await dialog.getByRole('button', { name: 'Upload' }).click() const commitResponse = await commitResponsePromise expect(commitResponse.status()).toBe(201) - const committed = await commitResponse.json() as { file?: { name?: string } } + const committed = (await commitResponse.json()) as { file?: { name?: string } } await expect(dialog).not.toBeVisible({ timeout: 30_000 }) const committedName = committed.file?.name @@ -118,20 +116,23 @@ export const expectAgentConfigFileSaved = async ( const fileName = agentBuilderTestMaterials[material] await expect - .poll(async () => { - const file = (await getAgentComposerDraft(agentId)).agent_soul?.config_files?.find( - file => file.name === fileName, - ) - - return file - ? { - name: file.name, - size: file.size, - } - : undefined - }, { - timeout: 30_000, - }) + .poll( + async () => { + const file = (await getAgentComposerDraft(agentId)).agent_soul?.config_files?.find( + (file) => file.name === fileName, + ) + + return file + ? { + name: file.name, + size: file.size, + } + : undefined + }, + { + timeout: 30_000, + }, + ) .toEqual({ name: fileName, size: options?.size ?? expect.anything(), @@ -162,7 +163,7 @@ export const openAgentAdvancedSettings = async (page: ReturnType<DifyWorld['getP const advancedSettings = page.getByRole('region', { name: 'Advanced Settings' }) const envEditorHeading = advancedSettings.getByRole('heading', { name: 'Env Editor' }) - if (!await envEditorHeading.isVisible().catch(() => false)) + if (!(await envEditorHeading.isVisible().catch(() => false))) await page.getByRole('button', { name: 'Advanced Settings' }).first().click() await expect(envEditorHeading).toBeVisible() @@ -177,50 +178,52 @@ export const expectAgentEnvVariableVisible = async ( ) => { const advancedSettings = await openAgentAdvancedSettings(world.getPage()) - await expect.poll( - async () => { - const text = await advancedSettings.textContent() - const inputValues = await advancedSettings.getByRole('textbox').evaluateAll(inputs => - inputs.map(input => (input as HTMLInputElement).value), - ) - - return { - hasKey: inputValues.includes(key) || !!text?.includes(key), - hasValue: inputValues.includes(value) || !!text?.includes(value), - } - }, - { timeout: 30_000 }, - ).toEqual({ - hasKey: true, - hasValue: true, - }) + await expect + .poll( + async () => { + const text = await advancedSettings.textContent() + const inputValues = await advancedSettings + .getByRole('textbox') + .evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)) + + return { + hasKey: inputValues.includes(key) || !!text?.includes(key), + hasValue: inputValues.includes(value) || !!text?.includes(value), + } + }, + { timeout: 30_000 }, + ) + .toEqual({ + hasKey: true, + hasValue: true, + }) await expect(advancedSettings.getByText('Plain', { exact: true })).toBeVisible() } -export const expectAgentEnvVariableHidden = async ( - world: DifyWorld, - key: string, -) => { +export const expectAgentEnvVariableHidden = async (world: DifyWorld, key: string) => { const advancedSettings = await openAgentAdvancedSettings(world.getPage()) - await expect.poll( - async () => { - const text = await advancedSettings.textContent() - const inputValues = await advancedSettings.getByRole('textbox').evaluateAll(inputs => - inputs.map(input => (input as HTMLInputElement).value), - ) - - return inputValues.includes(key) || !!text?.includes(key) - }, - { timeout: 30_000 }, - ).toBe(false) + await expect + .poll( + async () => { + const text = await advancedSettings.textContent() + const inputValues = await advancedSettings + .getByRole('textbox') + .evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)) + + return inputValues.includes(key) || !!text?.includes(key) + }, + { timeout: 30_000 }, + ) + .toBe(false) } export const expectNormalAgentPromptDraft = async (world: DifyWorld) => { - await expect.poll( - async () => (await getAgentComposerDraft(getCurrentAgentId(world))).agent_soul?.prompt, - { timeout: 30_000 }, - ).toEqual({ system_prompt: normalAgentPrompt }) + await expect + .poll(async () => (await getAgentComposerDraft(getCurrentAgentId(world))).agent_soul?.prompt, { + timeout: 30_000, + }) + .toEqual({ system_prompt: normalAgentPrompt }) } export const expectProviderToolActionVisible = async ( @@ -235,19 +238,23 @@ export const expectProviderToolActionVisible = async ( await expect(provider).toBeVisible() const action = toolsSection.getByText(tool.actionName, { exact: true }) - if (!await action.isVisible()) - await provider.click() + if (!(await action.isVisible())) await provider.click() await expect(action).toBeVisible() return { action, tool } } -export const openAgentKnowledgeRetrievalDialog = async (knowledgeSection: Locator, name: string) => { +export const openAgentKnowledgeRetrievalDialog = async ( + knowledgeSection: Locator, + name: string, +) => { await knowledgeSection.getByText(name, { exact: true }).hover() - await knowledgeSection.getByRole('button', { - exact: true, - name: `Edit ${name}`, - }).click() + await knowledgeSection + .getByRole('button', { + exact: true, + name: `Edit ${name}`, + }) + .click() const dialog = knowledgeSection.page().getByRole('dialog', { name: 'Knowledge Retrieval · Agent decide', diff --git a/e2e/features/step-definitions/agent-v2/configure.steps.ts b/e2e/features/step-definitions/agent-v2/configure.steps.ts index b27b3eee6f49f9..080bc6662df417 100644 --- a/e2e/features/step-definitions/agent-v2/configure.steps.ts +++ b/e2e/features/step-definitions/agent-v2/configure.steps.ts @@ -20,23 +20,22 @@ import { normalAgentSoulConfig, updatedAgentPrompt, } from '../../agent-v2/support/agent-soul' -import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from '../../agent-v2/support/test-materials' +import { + agentBuilderTestMaterials, + getAgentBuilderTestMaterialPath, +} from '../../agent-v2/support/test-materials' import { expectNormalAgentPromptDraft, getCurrentAgentId, getPreseededAgent, } from './configure-helpers' -const concurrentAgentPrompts = [ - concurrentFirstAgentPrompt, - concurrentSecondAgentPrompt, -] +const concurrentAgentPrompts = [concurrentFirstAgentPrompt, concurrentSecondAgentPrompt] function attachPageDiagnostics(world: DifyWorld, page: Page) { page.setDefaultTimeout(30_000) page.on('console', (message) => { - if (message.type() === 'error') - world.consoleErrors.push(message.text()) + if (message.type() === 'error') world.consoleErrors.push(message.text()) }) page.on('pageerror', (error) => { world.pageErrors.push(error.message) @@ -63,10 +62,11 @@ async function selectAgentModel(page: Page, modelName: string) { } async function expectAgentComposerPrompt(agentId: string, prompt: string) { - await expect.poll( - async () => (await getAgentComposerDraft(agentId)).agent_soul?.prompt?.system_prompt, - { timeout: 30_000 }, - ).toBe(prompt) + await expect + .poll(async () => (await getAgentComposerDraft(agentId)).agent_soul?.prompt?.system_prompt, { + timeout: 30_000, + }) + .toBe(prompt) } Given('an Agent v2 test agent has been created via API', async function (this: DifyWorld) { @@ -132,33 +132,36 @@ Given('the Agent v2 composer draft uses the normal E2E prompt', async function ( await saveAgentComposerDraft(getCurrentAgentId(this), normalAgentSoulConfig) }) +Given('the Agent v2 composer draft is publishable', async function (this: DifyWorld) { + await saveAgentComposerDraft( + getCurrentAgentId(this), + createPublishableAgentSoulConfig(normalAgentSoulConfig), + ) +}) + Given( - 'the Agent v2 composer draft is publishable', + 'the e2e-summary-skill Skill is available to the Agent v2 test agent', async function (this: DifyWorld) { - await saveAgentComposerDraft( - getCurrentAgentId(this), - createPublishableAgentSoulConfig(normalAgentSoulConfig), - ) + const agentId = getCurrentAgentId(this) + const upload = await uploadAgentDriveSkill({ + agentId, + fileName: agentBuilderTestMaterials.summarySkill, + filePath: getAgentBuilderTestMaterialPath('summarySkill'), + }) + this.createdAgentDriveFiles.push({ agentId, key: upload.skill.skill_md_key }) + if (upload.skill.archive_key) + this.createdAgentDriveFiles.push({ agentId, key: upload.skill.archive_key }) }, ) -Given('the e2e-summary-skill Skill is available to the Agent v2 test agent', async function (this: DifyWorld) { - const agentId = getCurrentAgentId(this) - const upload = await uploadAgentDriveSkill({ - agentId, - fileName: agentBuilderTestMaterials.summarySkill, - filePath: getAgentBuilderTestMaterialPath('summarySkill'), - }) - this.createdAgentDriveFiles.push({ agentId, key: upload.skill.skill_md_key }) - if (upload.skill.archive_key) - this.createdAgentDriveFiles.push({ agentId, key: upload.skill.archive_key }) -}) - -Then('the Agent v2 test agent should include drive skill {string}', async function (this: DifyWorld, skillName: string) { - const skills = await getAgentDriveSkills(getCurrentAgentId(this)) +Then( + 'the Agent v2 test agent should include drive skill {string}', + async function (this: DifyWorld, skillName: string) { + const skills = await getAgentDriveSkills(getCurrentAgentId(this)) - expect(skills.map(skill => skill.name)).toContain(skillName) -}) + expect(skills.map((skill) => skill.name)).toContain(skillName) + }, +) When('I open the Agent v2 configure page', async function (this: DifyWorld) { await this.getPage().goto(getAgentConfigurePath(getCurrentAgentId(this))) @@ -184,19 +187,21 @@ When('I switch to the Agent v2 Configure section', async function (this: DifyWor await expect(page.getByRole('heading', { name: 'Configure' })).toBeVisible({ timeout: 30_000 }) }) -When('I leave the Agent v2 configure page immediately after editing', async function (this: DifyWorld) { - const page = this.getPage() +When( + 'I leave the Agent v2 configure page immediately after editing', + async function (this: DifyWorld) { + const page = this.getPage() - await page.goto('/agents') - await expect(page).toHaveURL(/\/agents(?:\?.*)?$/) -}) + await page.goto('/agents') + await expect(page).toHaveURL(/\/agents(?:\?.*)?$/) + }, +) When('I open the Agent v2 configure page from the Agent Roster', async function (this: DifyWorld) { const page = this.getPage() const agentId = getCurrentAgentId(this) const agentName = this.lastCreatedAgentName - if (!agentName) - throw new Error('No Agent v2 name found. Create an Agent v2 test agent first.') + if (!agentName) throw new Error('No Agent v2 name found. Create an Agent v2 test agent first.') await page.goto('/agents') await page.getByRole('link', { name: agentName }).click() @@ -217,13 +222,19 @@ When( }, ) -When('I fill the Agent v2 prompt editor with the normal E2E prompt', async function (this: DifyWorld) { - await fillAgentPromptEditor(this.getPage(), normalAgentPrompt) -}) +When( + 'I fill the Agent v2 prompt editor with the normal E2E prompt', + async function (this: DifyWorld) { + await fillAgentPromptEditor(this.getPage(), normalAgentPrompt) + }, +) -When('I fill the Agent v2 prompt editor with the updated E2E prompt', async function (this: DifyWorld) { - await fillAgentPromptEditor(this.getPage(), updatedAgentPrompt) -}) +When( + 'I fill the Agent v2 prompt editor with the updated E2E prompt', + async function (this: DifyWorld) { + await fillAgentPromptEditor(this.getPage(), updatedAgentPrompt) + }, +) When('I open the same Agent v2 configure page in another tab', async function (this: DifyWorld) { if (!this.context) @@ -236,7 +247,9 @@ When('I open the same Agent v2 configure page in another tab', async function (t await concurrentPage.goto(getAgentConfigurePath(agentId)) await expect(concurrentPage).toHaveURL(new RegExp(`/agents/${agentId}/configure(?:\\?.*)?$`)) - await expect(concurrentPage.getByRole('heading', { name: 'Configure' })).toBeVisible({ timeout: 30_000 }) + await expect(concurrentPage.getByRole('heading', { name: 'Configure' })).toBeVisible({ + timeout: 30_000, + }) }) When('I save the Agent v2 prompt from the first configure tab', async function (this: DifyWorld) { @@ -264,27 +277,23 @@ When('I refresh both Agent v2 configure tabs', async function (this: DifyWorld) if (!concurrentPage) throw new Error('Open the same Agent v2 configure page in another tab before refreshing it.') - await Promise.all([ - page.reload(), - concurrentPage.reload(), - ]) + await Promise.all([page.reload(), concurrentPage.reload()]) await expect(page.getByRole('heading', { name: 'Configure' })).toBeVisible({ timeout: 30_000 }) - await expect(concurrentPage.getByRole('heading', { name: 'Configure' })).toBeVisible({ timeout: 30_000 }) + await expect(concurrentPage.getByRole('heading', { name: 'Configure' })).toBeVisible({ + timeout: 30_000, + }) }) Then('I should be on the Agent v2 configure page', async function (this: DifyWorld) { const agentId = getCurrentAgentId(this) - await expect(this.getPage()).toHaveURL( - new RegExp(`/agents/${agentId}/configure(?:\\?.*)?$`), - ) + await expect(this.getPage()).toHaveURL(new RegExp(`/agents/${agentId}/configure(?:\\?.*)?$`)) }) Then('I should see the Agent v2 configure workspace', async function (this: DifyWorld) { const page = this.getPage() const agentName = this.lastCreatedAgentName - if (!agentName) - throw new Error('No Agent v2 name found. Create an Agent v2 test agent first.') + if (!agentName) throw new Error('No Agent v2 name found. Create an Agent v2 test agent first.') await expect(page.getByRole('region', { name: 'Configure' })).toBeVisible({ timeout: 30_000 }) await expect(page.getByRole('heading', { name: 'Configure' })).toBeVisible() @@ -307,8 +316,9 @@ Then( if (!stableModel) throw new Error('Stable chat model preflight must run before asserting the Agent model.') - await expect(this.getPage().getByText(stableModel.name, { exact: true })) - .toBeVisible({ timeout: 30_000 }) + await expect(this.getPage().getByText(stableModel.name, { exact: true })).toBeVisible({ + timeout: 30_000, + }) }, ) @@ -327,19 +337,22 @@ Then( const agentId = getCurrentAgentId(this) const concurrentPage = this.agentBuilder.configure.concurrentPage if (!concurrentPage) - throw new Error('Open the same Agent v2 configure page in another tab before asserting convergence.') + throw new Error( + 'Open the same Agent v2 configure page in another tab before asserting convergence.', + ) let savedPrompt = '' - await expect.poll( - async () => { - const prompt = (await getAgentComposerDraft(agentId)).agent_soul?.prompt?.system_prompt - if (prompt && concurrentAgentPrompts.includes(prompt)) - savedPrompt = prompt - - return !!savedPrompt - }, - { timeout: 30_000 }, - ).toBe(true) + await expect + .poll( + async () => { + const prompt = (await getAgentComposerDraft(agentId)).agent_soul?.prompt?.system_prompt + if (prompt && concurrentAgentPrompts.includes(prompt)) savedPrompt = prompt + + return !!savedPrompt + }, + { timeout: 30_000 }, + ) + .toBe(true) await expect(getPromptEditor(this.getPage())).toContainText(savedPrompt) await expect(getPromptEditor(concurrentPage)).toContainText(savedPrompt) @@ -373,26 +386,27 @@ Then( Then( 'the normal Agent v2 draft should use the updated E2E prompt', async function (this: DifyWorld) { - await expect.poll( - async () => (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.prompt, - { timeout: 30_000 }, - ).toEqual({ system_prompt: updatedAgentPrompt }) + await expect + .poll(async () => (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.prompt, { + timeout: 30_000, + }) + .toEqual({ system_prompt: updatedAgentPrompt }) }, ) -Then( - 'the Agent v2 draft should use the stable E2E model', - async function (this: DifyWorld) { - const stableModel = this.agentBuilder.preflight.stableModel - if (!stableModel) - throw new Error('Stable chat model preflight must run before asserting the Agent model.') +Then('the Agent v2 draft should use the stable E2E model', async function (this: DifyWorld) { + const stableModel = this.agentBuilder.preflight.stableModel + if (!stableModel) + throw new Error('Stable chat model preflight must run before asserting the Agent model.') - await expect.poll( + await expect + .poll( async () => { const model = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.model - const modelConfig = typeof model === 'object' && model !== null && !Array.isArray(model) - ? model as Record<string, unknown> - : undefined + const modelConfig = + typeof model === 'object' && model !== null && !Array.isArray(model) + ? (model as Record<string, unknown>) + : undefined return { model: modelConfig?.model, @@ -400,9 +414,9 @@ Then( } }, { timeout: 30_000 }, - ).toEqual({ + ) + .toEqual({ model: stableModel.name, provider: stableModel.provider, }) - }, -) +}) diff --git a/e2e/features/step-definitions/agent-v2/content-moderation.steps.ts b/e2e/features/step-definitions/agent-v2/content-moderation.steps.ts index 1a9604a04915bf..e28a9dbb584fb7 100644 --- a/e2e/features/step-definitions/agent-v2/content-moderation.steps.ts +++ b/e2e/features/step-definitions/agent-v2/content-moderation.steps.ts @@ -14,8 +14,7 @@ const getContentModerationRegion = (world: DifyWorld) => world.getPage().getByRole('region', { name: 'Content moderation' }) const ensureSwitchChecked = async (switchLocator: Locator) => { - if (await switchLocator.getAttribute('aria-checked') !== 'true') - await switchLocator.click() + if ((await switchLocator.getAttribute('aria-checked')) !== 'true') await switchLocator.click() } When( @@ -25,10 +24,9 @@ When( const contentModeration = getContentModerationRegion(this) const enabledSwitch = contentModeration.getByRole('switch', { name: 'Content moderation' }) - if (await enabledSwitch.getAttribute('aria-checked') === 'true') + if ((await enabledSwitch.getAttribute('aria-checked')) === 'true') await contentModeration.getByRole('button', { name: 'Settings' }).click() - else - await enabledSwitch.click() + else await enabledSwitch.click() const dialog = getModerationSettingsDialog(this) await expect(dialog).toBeVisible() @@ -39,7 +37,9 @@ When( const inputModeration = dialog.getByRole('region', { name: 'Moderate INPUT Content' }) const outputModeration = dialog.getByRole('region', { name: 'Moderate OUTPUT Content' }) - await ensureSwitchChecked(inputModeration.getByRole('switch', { name: 'Moderate INPUT Content' })) + await ensureSwitchChecked( + inputModeration.getByRole('switch', { name: 'Moderate INPUT Content' }), + ) await dialog.getByRole('button', { name: 'Save' }).click() await expect(page.getByText('Preset replies cannot be empty')).toBeVisible() @@ -48,7 +48,9 @@ When( await inputModeration .getByRole('textbox', { name: 'Preset replies' }) .fill(agentBuilderFixedInputs.inputModerationReply) - await ensureSwitchChecked(outputModeration.getByRole('switch', { name: 'Moderate OUTPUT Content' })) + await ensureSwitchChecked( + outputModeration.getByRole('switch', { name: 'Moderate OUTPUT Content' }), + ) await outputModeration .getByRole('textbox', { name: 'Preset replies' }) .fill(agentBuilderFixedInputs.outputModerationReply) @@ -63,14 +65,14 @@ Then('Agent v2 Content Moderation Settings should be available', async function try { await expect(contentModeration).toBeVisible({ timeout: 3_000 }) - } - catch { + } catch { return skipBlockedPrecondition( this, 'Agent v2 Content Moderation Settings is not available in this build.', { owner: 'product', - remediation: 'Enable the Agent v2 Content Moderation feature flag in the product or keep this scenario feature-gated.', + remediation: + 'Enable the Agent v2 Content Moderation feature flag in the product or keep this scenario feature-gated.', }, ) } @@ -82,26 +84,31 @@ Then( const agentId = getCurrentAgentId(this) await expect - .poll(async () => { - const draft = await getAgentComposerDraft(agentId) - const appFeatures = draft.agent_soul?.app_features as Record<string, unknown> | undefined - const moderation = appFeatures?.sensitive_word_avoidance as Record<string, unknown> | undefined - const config = moderation?.config as Record<string, unknown> | undefined - const inputsConfig = config?.inputs_config as Record<string, unknown> | undefined - const outputsConfig = config?.outputs_config as Record<string, unknown> | undefined - - return { - enabled: moderation?.enabled, - inputEnabled: inputsConfig?.enabled, - inputPreset: inputsConfig?.preset_response, - keywords: config?.keywords, - outputEnabled: outputsConfig?.enabled, - outputPreset: outputsConfig?.preset_response, - type: moderation?.type, - } - }, { - timeout: 30_000, - }) + .poll( + async () => { + const draft = await getAgentComposerDraft(agentId) + const appFeatures = draft.agent_soul?.app_features as Record<string, unknown> | undefined + const moderation = appFeatures?.sensitive_word_avoidance as + | Record<string, unknown> + | undefined + const config = moderation?.config as Record<string, unknown> | undefined + const inputsConfig = config?.inputs_config as Record<string, unknown> | undefined + const outputsConfig = config?.outputs_config as Record<string, unknown> | undefined + + return { + enabled: moderation?.enabled, + inputEnabled: inputsConfig?.enabled, + inputPreset: inputsConfig?.preset_response, + keywords: config?.keywords, + outputEnabled: outputsConfig?.enabled, + outputPreset: outputsConfig?.preset_response, + type: moderation?.type, + } + }, + { + timeout: 30_000, + }, + ) .toEqual({ enabled: true, inputEnabled: true, @@ -125,14 +132,19 @@ Then( const dialog = getModerationSettingsDialog(this) await expect(dialog).toBeVisible() - await expect(dialog.getByRole('textbox', { name: 'Keywords' })) - .toHaveValue(agentBuilderFixedInputs.moderationKeyword) - await expect(dialog.getByRole('region', { name: 'Moderate INPUT Content' }) - .getByRole('textbox', { name: 'Preset replies' })) - .toHaveValue(agentBuilderFixedInputs.inputModerationReply) - await expect(dialog.getByRole('region', { name: 'Moderate OUTPUT Content' }) - .getByRole('textbox', { name: 'Preset replies' })) - .toHaveValue(agentBuilderFixedInputs.outputModerationReply) + await expect(dialog.getByRole('textbox', { name: 'Keywords' })).toHaveValue( + agentBuilderFixedInputs.moderationKeyword, + ) + await expect( + dialog + .getByRole('region', { name: 'Moderate INPUT Content' }) + .getByRole('textbox', { name: 'Preset replies' }), + ).toHaveValue(agentBuilderFixedInputs.inputModerationReply) + await expect( + dialog + .getByRole('region', { name: 'Moderate OUTPUT Content' }) + .getByRole('textbox', { name: 'Preset replies' }), + ).toHaveValue(agentBuilderFixedInputs.outputModerationReply) await dialog.getByRole('button', { name: 'Cancel' }).click() await expect(dialog).not.toBeVisible() }, diff --git a/e2e/features/step-definitions/agent-v2/env-editor.steps.ts b/e2e/features/step-definitions/agent-v2/env-editor.steps.ts index 6a18bfc93e9cf2..d304e96d5d8b88 100644 --- a/e2e/features/step-definitions/agent-v2/env-editor.steps.ts +++ b/e2e/features/step-definitions/agent-v2/env-editor.steps.ts @@ -89,19 +89,22 @@ Then( const agentId = getCurrentAgentId(this) await expect - .poll(async () => { - const env = (await getAgentComposerDraft(agentId)).agent_soul?.env - const variable = env?.variables?.find(item => - getEnvVariableKey(item) === agentBuilderFixedInputs.envPlainKey, - ) - - return { - secretCount: env?.secret_refs?.length ?? 0, - value: variable?.value, - } - }, { - timeout: 30_000, - }) + .poll( + async () => { + const env = (await getAgentComposerDraft(agentId)).agent_soul?.env + const variable = env?.variables?.find( + (item) => getEnvVariableKey(item) === agentBuilderFixedInputs.envPlainKey, + ) + + return { + secretCount: env?.secret_refs?.length ?? 0, + value: variable?.value, + } + }, + { + timeout: 30_000, + }, + ) .toEqual({ secretCount: 0, value: agentBuilderFixedInputs.envPlainValue, @@ -115,16 +118,19 @@ Then( const agentId = getCurrentAgentId(this) await expect - .poll(async () => { - const variables = await getAgentEnvVariables(agentId) - - return { - modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), - plainValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envPlainKey), - } - }, { - timeout: 30_000, - }) + .poll( + async () => { + const variables = await getAgentEnvVariables(agentId) + + return { + modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), + plainValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envPlainKey), + } + }, + { + timeout: 30_000, + }, + ) .toEqual({ modeValue: agentBuilderFixedInputs.envModeValue, plainValue: agentBuilderFixedInputs.envPlainValue, @@ -138,16 +144,19 @@ Then( const agentId = getCurrentAgentId(this) await expect - .poll(async () => { - const variables = await getAgentEnvVariables(agentId) - - return { - modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), - plainValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envPlainKey), - } - }, { - timeout: 30_000, - }) + .poll( + async () => { + const variables = await getAgentEnvVariables(agentId) + + return { + modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), + plainValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envPlainKey), + } + }, + { + timeout: 30_000, + }, + ) .toEqual({ modeValue: agentBuilderFixedInputs.envModeValue, plainValue: undefined, @@ -161,16 +170,19 @@ Then( const agentId = getCurrentAgentId(this) await expect - .poll(async () => { - const variables = await getAgentEnvVariables(agentId) - - return { - modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), - plainValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envPlainKey), - } - }, { - timeout: 30_000, - }) + .poll( + async () => { + const variables = await getAgentEnvVariables(agentId) + + return { + modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), + plainValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envPlainKey), + } + }, + { + timeout: 30_000, + }, + ) .toEqual({ modeValue: agentBuilderFixedInputs.envModeValue, plainValue: agentBuilderFixedInputs.envPlainValue, @@ -191,19 +203,22 @@ Then( const agentId = getCurrentAgentId(this) await expect - .poll(async () => { - const variables = await getAgentEnvVariables(agentId) - - return { - importedValue: getAgentEnvVariableValue( - variables, - agentBuilderFixedInputs.envAfterInvalidImportKey, - ), - plainValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envPlainKey), - } - }, { - timeout: 30_000, - }) + .poll( + async () => { + const variables = await getAgentEnvVariables(agentId) + + return { + importedValue: getAgentEnvVariableValue( + variables, + agentBuilderFixedInputs.envAfterInvalidImportKey, + ), + plainValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envPlainKey), + } + }, + { + timeout: 30_000, + }, + ) .toEqual({ importedValue: agentBuilderFixedInputs.envAfterInvalidImportValue, plainValue: agentBuilderFixedInputs.envPlainValue, @@ -216,17 +231,22 @@ Then( async function (this: DifyWorld) { const advancedSettings = await openAgentAdvancedSettings(this.getPage()) - await expect.poll( - async () => advancedSettings.getByRole('textbox').evaluateAll(inputs => - inputs.map(input => (input as HTMLInputElement).value), - ), - { timeout: 30_000 }, - ).toEqual(expect.arrayContaining([ - agentBuilderFixedInputs.envPlainKey, - agentBuilderFixedInputs.envPlainValue, - agentBuilderFixedInputs.envModeKey, - agentBuilderFixedInputs.envModeValue, - ])) + await expect + .poll( + async () => + advancedSettings + .getByRole('textbox') + .evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)), + { timeout: 30_000 }, + ) + .toEqual( + expect.arrayContaining([ + agentBuilderFixedInputs.envPlainKey, + agentBuilderFixedInputs.envPlainValue, + agentBuilderFixedInputs.envModeKey, + agentBuilderFixedInputs.envModeValue, + ]), + ) await expect(advancedSettings.getByText('Plain', { exact: true })).toHaveCount(2) }, ) @@ -236,21 +256,29 @@ Then( async function (this: DifyWorld) { const advancedSettings = await openAgentAdvancedSettings(this.getPage()) - await expect.poll( - async () => advancedSettings.getByRole('textbox').evaluateAll(inputs => - inputs.map(input => (input as HTMLInputElement).value), - ), - { timeout: 30_000 }, - ).toEqual(expect.arrayContaining([ - agentBuilderFixedInputs.envModeKey, - agentBuilderFixedInputs.envModeValue, - ])) - await expect.poll( - async () => advancedSettings.getByRole('textbox').evaluateAll(inputs => - inputs.map(input => (input as HTMLInputElement).value), - ), - { timeout: 30_000 }, - ).not.toContain(agentBuilderFixedInputs.envPlainKey) + await expect + .poll( + async () => + advancedSettings + .getByRole('textbox') + .evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)), + { timeout: 30_000 }, + ) + .toEqual( + expect.arrayContaining([ + agentBuilderFixedInputs.envModeKey, + agentBuilderFixedInputs.envModeValue, + ]), + ) + await expect + .poll( + async () => + advancedSettings + .getByRole('textbox') + .evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)), + { timeout: 30_000 }, + ) + .not.toContain(agentBuilderFixedInputs.envPlainKey) await expect(advancedSettings.getByText('Plain', { exact: true })).toHaveCount(1) }, ) @@ -261,10 +289,12 @@ Then( const page = this.getPage() const advancedSettings = await openAgentAdvancedSettings(page) - await expect(advancedSettings.getByRole('textbox', { name: 'Key' })) - .toHaveValue(agentBuilderFixedInputs.envPlainKey) - await expect(advancedSettings.getByRole('textbox', { name: 'Value' })) - .toHaveValue(agentBuilderFixedInputs.envPlainValue) + await expect(advancedSettings.getByRole('textbox', { name: 'Key' })).toHaveValue( + agentBuilderFixedInputs.envPlainKey, + ) + await expect(advancedSettings.getByRole('textbox', { name: 'Value' })).toHaveValue( + agentBuilderFixedInputs.envPlainValue, + ) await expect(advancedSettings.getByText('Plain', { exact: true })).toBeVisible() await expect(page.getByRole('button', { name: /^Build$/i })).toBeVisible() }, @@ -294,17 +324,22 @@ Then( const page = this.getPage() const advancedSettings = await openAgentAdvancedSettings(page) - await expect.poll( - async () => advancedSettings.getByRole('textbox').evaluateAll(inputs => - inputs.map(input => (input as HTMLInputElement).value), - ), - { timeout: 30_000 }, - ).toEqual(expect.arrayContaining([ - agentBuilderFixedInputs.envPlainKey, - agentBuilderFixedInputs.envPlainValue, - agentBuilderFixedInputs.envAfterInvalidImportKey, - agentBuilderFixedInputs.envAfterInvalidImportValue, - ])) + await expect + .poll( + async () => + advancedSettings + .getByRole('textbox') + .evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)), + { timeout: 30_000 }, + ) + .toEqual( + expect.arrayContaining([ + agentBuilderFixedInputs.envPlainKey, + agentBuilderFixedInputs.envPlainValue, + agentBuilderFixedInputs.envAfterInvalidImportKey, + agentBuilderFixedInputs.envAfterInvalidImportValue, + ]), + ) await expect(page.getByRole('button', { name: /^Build$/i })).toBeVisible() }, ) diff --git a/e2e/features/step-definitions/agent-v2/files.steps.ts b/e2e/features/step-definitions/agent-v2/files.steps.ts index ddce18f62d7542..52399c31c72e9e 100644 --- a/e2e/features/step-definitions/agent-v2/files.steps.ts +++ b/e2e/features/step-definitions/agent-v2/files.steps.ts @@ -18,36 +18,45 @@ When('I upload the empty Agent v2 file from the Files section', async function ( await uploadAgentConfigFile(this, 'emptyFile') }) -When('I upload the special-name Agent v2 file from the Files section', async function (this: DifyWorld) { - await uploadAgentConfigFile(this, 'specialFilename') -}) +When( + 'I upload the special-name Agent v2 file from the Files section', + async function (this: DifyWorld) { + await uploadAgentConfigFile(this, 'specialFilename') + }, +) -When('I drop multiple Agent v2 files into the Files upload dialog', async function (this: DifyWorld) { - const page = this.getPage() - - await page.getByRole('button', { name: 'Add file' }).click() - const dialog = page.getByRole('dialog', { name: 'Upload file' }) - await expect(dialog).toBeVisible() - - const dropZone = dialog.getByRole('group', { name: 'Upload file' }) - await expect(dropZone).toBeVisible() - - const droppedFileNames: [string, string] = [ - agentBuilderTestMaterials.smallFile, - agentBuilderTestMaterials.emptyFile, - ] - const dataTransfer = await page.evaluateHandle(([smallFileName, emptyFileName]: [string, string]) => { - const transfer = new DataTransfer() - transfer.items.add(new File(['small agent file'], smallFileName, { type: 'text/plain' })) - transfer.items.add(new File([''], emptyFileName, { type: 'text/plain' })) - return transfer - }, droppedFileNames) - - await dropZone.dispatchEvent('dragenter', { dataTransfer }) - await dropZone.dispatchEvent('dragover', { dataTransfer }) - await dropZone.dispatchEvent('drop', { dataTransfer }) - await dataTransfer.dispose() -}) +When( + 'I drop multiple Agent v2 files into the Files upload dialog', + async function (this: DifyWorld) { + const page = this.getPage() + + await page.getByRole('button', { name: 'Add file' }).click() + const dialog = page.getByRole('dialog', { name: 'Upload file' }) + await expect(dialog).toBeVisible() + + const dropZone = dialog.getByRole('group', { name: 'Upload file' }) + await expect(dropZone).toBeVisible() + + const droppedFileNames: [string, string] = [ + agentBuilderTestMaterials.smallFile, + agentBuilderTestMaterials.emptyFile, + ] + const dataTransfer = await page.evaluateHandle( + ([smallFileName, emptyFileName]: [string, string]) => { + const transfer = new DataTransfer() + transfer.items.add(new File(['small agent file'], smallFileName, { type: 'text/plain' })) + transfer.items.add(new File([''], emptyFileName, { type: 'text/plain' })) + return transfer + }, + droppedFileNames, + ) + + await dropZone.dispatchEvent('dragenter', { dataTransfer }) + await dropZone.dispatchEvent('dragover', { dataTransfer }) + await dropZone.dispatchEvent('drop', { dataTransfer }) + await dataTransfer.dispose() + }, +) Then( 'the Agent v2 Files upload dialog should reject the multiple-file drop', @@ -57,15 +66,22 @@ Then( await expect(page.getByText('Upload one file.')).toBeVisible() await expect(dialog.getByRole('button', { name: 'Upload' })).toBeDisabled() - await expect(dialog.getByText(agentBuilderTestMaterials.smallFile, { exact: true })).not.toBeVisible() - await expect(dialog.getByText(agentBuilderTestMaterials.emptyFile, { exact: true })).not.toBeVisible() + await expect( + dialog.getByText(agentBuilderTestMaterials.smallFile, { exact: true }), + ).not.toBeVisible() + await expect( + dialog.getByText(agentBuilderTestMaterials.emptyFile, { exact: true }), + ).not.toBeVisible() }, ) -Then('I should not see the dropped Agent v2 files in the Files section', async function (this: DifyWorld) { - await expectAgentConfigFileHidden(this, 'smallFile') - await expectAgentConfigFileHidden(this, 'emptyFile') -}) +Then( + 'I should not see the dropped Agent v2 files in the Files section', + async function (this: DifyWorld) { + await expectAgentConfigFileHidden(this, 'smallFile') + await expectAgentConfigFileHidden(this, 'emptyFile') + }, +) Then('I should see the small Agent v2 file in the Files section', async function (this: DifyWorld) { await expectAgentConfigFileVisible(this, 'smallFile') @@ -75,13 +91,19 @@ Then('I should see the empty Agent v2 file in the Files section', async function await expectAgentConfigFileVisible(this, 'emptyFile') }) -Then('I should not see the small Agent v2 file in the Files section', async function (this: DifyWorld) { - await expectAgentConfigFileHidden(this, 'smallFile') -}) +Then( + 'I should not see the small Agent v2 file in the Files section', + async function (this: DifyWorld) { + await expectAgentConfigFileHidden(this, 'smallFile') + }, +) -Then('I should see the special-name Agent v2 file in the Files section', async function (this: DifyWorld) { - await expectAgentConfigFileVisible(this, 'specialFilename') -}) +Then( + 'I should see the special-name Agent v2 file in the Files section', + async function (this: DifyWorld) { + await expectAgentConfigFileVisible(this, 'specialFilename') + }, +) Then( 'the small Agent v2 file should be saved in the Agent v2 draft', async function (this: DifyWorld) { @@ -109,7 +131,8 @@ async function skipUnsupportedFileFormatRejection(world: DifyWorld) { 'Agent v2 unsupported file format rejection is not stable: default upload configuration allows arbitrary extensions unless UPLOAD_FILE_EXTENSION_BLACKLIST is seeded.', { owner: 'product/seed', - remediation: 'Define Agent config file type restrictions or seed UPLOAD_FILE_EXTENSION_BLACKLIST before enabling this scenario.', + remediation: + 'Define Agent config file type restrictions or seed UPLOAD_FILE_EXTENSION_BLACKLIST before enabling this scenario.', }, ) } @@ -118,9 +141,12 @@ Given('Agent v2 unsupported file format rejection is available', async function return skipUnsupportedFileFormatRejection(this) }) -Then('Agent v2 unsupported file format rejection should be available', async function (this: DifyWorld) { - return skipUnsupportedFileFormatRejection(this) -}) +Then( + 'Agent v2 unsupported file format rejection should be available', + async function (this: DifyWorld) { + return skipUnsupportedFileFormatRejection(this) + }, +) async function skipOversizedFileRejection(world: DifyWorld) { return skipBlockedPrecondition( diff --git a/e2e/features/step-definitions/agent-v2/knowledge.steps.ts b/e2e/features/step-definitions/agent-v2/knowledge.steps.ts index 9cdf210248cc8b..f20ccbef352e87 100644 --- a/e2e/features/step-definitions/agent-v2/knowledge.steps.ts +++ b/e2e/features/step-definitions/agent-v2/knowledge.steps.ts @@ -2,11 +2,11 @@ import type { Locator } from '@playwright/test' import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' +import { createConfiguredTestAgent, getAgentComposerDraft } from '../../agent-v2/support/agent' import { - createConfiguredTestAgent, - getAgentComposerDraft, -} from '../../agent-v2/support/agent' -import { agentBuilderFixedInputs, agentBuilderPreseededResources } from '../../agent-v2/support/agent-builder-resources' + agentBuilderFixedInputs, + agentBuilderPreseededResources, +} from '../../agent-v2/support/agent-builder-resources' import { createAgentSoulConfigWithKnowledgeDataset, normalAgentSoulConfig, @@ -15,9 +15,10 @@ import { asArray, asRecord } from '../../agent-v2/support/preflight/common' import { getCurrentAgentId } from './configure-helpers' const getPreseededKnowledgeBase = (world: DifyWorld) => { - const resource = world.agentBuilder.preflight.preseededResources[ - agentBuilderPreseededResources.agentKnowledgeBase - ] + const resource = + world.agentBuilder.preflight.preseededResources[ + agentBuilderPreseededResources.agentKnowledgeBase + ] if (!resource || resource.kind !== 'dataset') { throw new Error( `Preseeded dataset "${agentBuilderPreseededResources.agentKnowledgeBase}" is not available. Run the matching preflight step first.`, @@ -50,10 +51,7 @@ const openNewKnowledgeRetrievalDialog = async (world: DifyWorld) => { return dialog } -const selectPreseededKnowledgeBase = async ( - world: DifyWorld, - dialog: Locator, -) => { +const selectPreseededKnowledgeBase = async (world: DifyWorld, dialog: Locator) => { const knowledgeBase = getPreseededKnowledgeBase(world) const page = world.getPage() @@ -68,13 +66,14 @@ const selectPreseededKnowledgeBase = async ( const openKnowledgeRetrievalSettings = async (world: DifyWorld, name: string) => { const knowledgeSection = getKnowledgeSection(world) - await expect(knowledgeSection.getByText(name, { exact: true })) - .toBeVisible({ timeout: 30_000 }) + await expect(knowledgeSection.getByText(name, { exact: true })).toBeVisible({ timeout: 30_000 }) await knowledgeSection.getByText(name, { exact: true }).hover() - await knowledgeSection.getByRole('button', { - exact: true, - name: `Edit ${name}`, - }).click() + await knowledgeSection + .getByRole('button', { + exact: true, + name: `Edit ${name}`, + }) + .click() const dialog = world.getPage().getByRole('dialog', { name: 'Knowledge Retrieval · Agent decide', @@ -94,30 +93,32 @@ const expectKnowledgeRetrievalDraft = async ( const agentId = getCurrentAgentId(world) const knowledgeBase = getPreseededKnowledgeBase(world) - await expect.poll( - async () => { - const knowledgeSets = await getKnowledgeSets(agentId) - const knowledgeSet = asRecord(knowledgeSets[0]) - const datasets = asArray(knowledgeSet.datasets) - const query = asRecord(knowledgeSet.query) - const retrieval = asRecord(knowledgeSet.retrieval) - - return { - datasetNames: datasets.map(dataset => asRecord(dataset).name), - mode: query.mode, - name: knowledgeSet.name, - retrievalMode: retrieval.mode, - value: query.value ?? undefined, - } - }, - { timeout: 30_000 }, - ).toEqual({ - datasetNames: expect.arrayContaining([knowledgeBase.name]), - mode: expected.mode, - name: 'Retrieval 1', - retrievalMode: 'multiple', - value: expected.value, - }) + await expect + .poll( + async () => { + const knowledgeSets = await getKnowledgeSets(agentId) + const knowledgeSet = asRecord(knowledgeSets[0]) + const datasets = asArray(knowledgeSet.datasets) + const query = asRecord(knowledgeSet.query) + const retrieval = asRecord(knowledgeSet.retrieval) + + return { + datasetNames: datasets.map((dataset) => asRecord(dataset).name), + mode: query.mode, + name: knowledgeSet.name, + retrievalMode: retrieval.mode, + value: query.value ?? undefined, + } + }, + { timeout: 30_000 }, + ) + .toEqual({ + datasetNames: expect.arrayContaining([knowledgeBase.name]), + mode: expected.mode, + name: 'Retrieval 1', + retrievalMode: 'multiple', + value: expected.value, + }) } Given( @@ -125,13 +126,10 @@ Given( async function (this: DifyWorld) { const knowledgeBase = getPreseededKnowledgeBase(this) const agent = await createConfiguredTestAgent({ - agentSoul: createAgentSoulConfigWithKnowledgeDataset( - normalAgentSoulConfig, - { - id: knowledgeBase.id, - name: knowledgeBase.name, - }, - ), + agentSoul: createAgentSoulConfigWithKnowledgeDataset(normalAgentSoulConfig, { + id: knowledgeBase.id, + name: knowledgeBase.name, + }), }) this.createdAgentIds.push(agent.id) @@ -147,8 +145,9 @@ When( await expect(dialog.getByRole('radio', { name: 'Agent decide' })).toBeChecked() await selectPreseededKnowledgeBase(this, dialog) - await expect(dialog.getByText(getPreseededKnowledgeBase(this).name, { exact: true })) - .toBeVisible() + await expect( + dialog.getByText(getPreseededKnowledgeBase(this).name, { exact: true }), + ).toBeVisible() await this.getPage().keyboard.press('Escape') await expect(dialog).toBeHidden() }, @@ -164,19 +163,23 @@ When( .getByRole('textbox', { name: 'Custom query text' }) .fill(agentBuilderFixedInputs.customKnowledgeQuery) await selectPreseededKnowledgeBase(this, dialog) - await expect(dialog.getByText(getPreseededKnowledgeBase(this).name, { exact: true })) - .toBeVisible() + await expect( + dialog.getByText(getPreseededKnowledgeBase(this).name, { exact: true }), + ).toBeVisible() await this.getPage().keyboard.press('Escape') await expect(dialog).toBeHidden() }, ) -Then('I should see the Agent v2 Knowledge Retrieval {string}', async function (this: DifyWorld, name: string) { - const knowledgeSection = getKnowledgeSection(this) +Then( + 'I should see the Agent v2 Knowledge Retrieval {string}', + async function (this: DifyWorld, name: string) { + const knowledgeSection = getKnowledgeSection(this) - await expect(knowledgeSection).toBeVisible({ timeout: 30_000 }) - await expect(knowledgeSection.getByText(name, { exact: true })).toBeVisible() -}) + await expect(knowledgeSection).toBeVisible({ timeout: 30_000 }) + await expect(knowledgeSection.getByText(name, { exact: true })).toBeVisible() + }, +) Then( 'the Agent v2 Agent decide Knowledge Retrieval should be saved in the Agent v2 draft', @@ -203,8 +206,9 @@ Then( const dialog = await openKnowledgeRetrievalSettings(this, 'Retrieval 1') await expect(dialog.getByRole('radio', { name: 'Agent decide' })).toBeChecked() - await expect(dialog.getByText(getPreseededKnowledgeBase(this).name, { exact: true })) - .toBeVisible() + await expect( + dialog.getByText(getPreseededKnowledgeBase(this).name, { exact: true }), + ).toBeVisible() await expect(dialog.getByRole('button', { name: 'Disabled' })).toBeVisible() }, ) @@ -215,24 +219,31 @@ Then( const dialog = await openKnowledgeRetrievalSettings(this, 'Retrieval 1') await expect(dialog.getByRole('radio', { name: 'Custom query' })).toBeChecked() - await expect(dialog.getByRole('textbox', { name: 'Custom query text' })) - .toHaveValue(agentBuilderFixedInputs.customKnowledgeQuery) - await expect(dialog.getByText(getPreseededKnowledgeBase(this).name, { exact: true })) - .toBeVisible() + await expect(dialog.getByRole('textbox', { name: 'Custom query text' })).toHaveValue( + agentBuilderFixedInputs.customKnowledgeQuery, + ) + await expect( + dialog.getByText(getPreseededKnowledgeBase(this).name, { exact: true }), + ).toBeVisible() await expect(dialog.getByRole('button', { name: 'Disabled' })).toBeVisible() }, ) -When('I remove the Agent v2 Knowledge Retrieval {string}', async function (this: DifyWorld, name: string) { - const knowledgeSection = getKnowledgeSection(this) - - await expect(knowledgeSection).toBeVisible({ timeout: 30_000 }) - await knowledgeSection.getByText(name, { exact: true }).hover() - await knowledgeSection.getByRole('button', { - exact: true, - name: `Remove ${name}`, - }).click() -}) +When( + 'I remove the Agent v2 Knowledge Retrieval {string}', + async function (this: DifyWorld, name: string) { + const knowledgeSection = getKnowledgeSection(this) + + await expect(knowledgeSection).toBeVisible({ timeout: 30_000 }) + await knowledgeSection.getByText(name, { exact: true }).hover() + await knowledgeSection + .getByRole('button', { + exact: true, + name: `Remove ${name}`, + }) + .click() + }, +) Then( 'the Agent v2 draft should no longer reference the Agent Builder knowledge base', @@ -240,24 +251,31 @@ Then( const agentId = getCurrentAgentId(this) const knowledgeBase = getPreseededKnowledgeBase(this) - await expect.poll( - async () => { - const knowledgeSets = await getKnowledgeSets(agentId) + await expect + .poll( + async () => { + const knowledgeSets = await getKnowledgeSets(agentId) - return knowledgeSets.some(set => asArray(asRecord(set).datasets).some((dataset) => { - const record = asRecord(dataset) + return knowledgeSets.some((set) => + asArray(asRecord(set).datasets).some((dataset) => { + const record = asRecord(dataset) - return record.id === knowledgeBase.id || record.name === knowledgeBase.name - })) - }, - { timeout: 30_000 }, - ).toBe(false) + return record.id === knowledgeBase.id || record.name === knowledgeBase.name + }), + ) + }, + { timeout: 30_000 }, + ) + .toBe(false) }, ) -Then('I should not see the Agent v2 Knowledge Retrieval {string}', async function (this: DifyWorld, name: string) { - const knowledgeSection = getKnowledgeSection(this) +Then( + 'I should not see the Agent v2 Knowledge Retrieval {string}', + async function (this: DifyWorld, name: string) { + const knowledgeSection = getKnowledgeSection(this) - await expect(knowledgeSection).toBeVisible({ timeout: 30_000 }) - await expect(knowledgeSection.getByText(name, { exact: true })).not.toBeVisible() -}) + await expect(knowledgeSection).toBeVisible({ timeout: 30_000 }) + await expect(knowledgeSection.getByText(name, { exact: true })).not.toBeVisible() + }, +) diff --git a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts index 5619f5bae8afa2..c55e5598fabf9c 100644 --- a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts +++ b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts @@ -14,17 +14,18 @@ const getAgentOutputToken = (name: string) => `[§output:${name}:${name}§]` const getCurrentAppId = (world: DifyWorld) => { const appId = world.createdAppIds.at(-1) - if (!appId) - throw new Error('No app ID found. Create a workflow app first.') + if (!appId) throw new Error('No app ID found. Create a workflow app first.') return appId } const getAgentV2WorkflowNodeData = async (appId: string) => { const draft = await getWorkflowDraft(appId) - const agentNode = draft.graph.nodes.find(node => node.id === agentV2WorkflowNodeId) + const agentNode = draft.graph.nodes.find((node) => node.id === agentV2WorkflowNodeId) if (!agentNode) - throw new Error(`Workflow draft ${appId} does not include Agent v2 node ${agentV2WorkflowNodeId}.`) + throw new Error( + `Workflow draft ${appId} does not include Agent v2 node ${agentV2WorkflowNodeId}.`, + ) return agentNode.data ?? {} } @@ -32,8 +33,7 @@ const getAgentV2WorkflowNodeData = async (appId: string) => { const getDeclaredOutputsFromDraft = async (appId: string): Promise<DeclaredOutputConfig[]> => { const data = await getAgentV2WorkflowNodeData(appId) const outputs = data.agent_declared_outputs - if (!Array.isArray(outputs)) - return [] + if (!Array.isArray(outputs)) return [] return outputs as DeclaredOutputConfig[] } @@ -41,16 +41,19 @@ const getDeclaredOutputsFromDraft = async (appId: string): Promise<DeclaredOutpu const getOutputVariablesFromDraft = async (appId: string) => getDeclaredOutputsFromDraft(appId) const waitForWorkflowDraftSave = (world: DifyWorld, appId: string) => - world.getPage().waitForResponse(response => ( - response.request().method() === 'POST' - && new URL(response.url()).pathname.endsWith(`/console/api/apps/${appId}/workflows/draft`) - )) + world + .getPage() + .waitForResponse( + (response) => + response.request().method() === 'POST' && + new URL(response.url()).pathname.endsWith(`/console/api/apps/${appId}/workflows/draft`), + ) const openWorkflowOutputVariablesPanel = async (world: DifyWorld) => { const page = world.getPage() const newOutputButton = page.getByRole('button', { name: 'New output' }) - if (!await newOutputButton.isVisible().catch(() => false)) + if (!(await newOutputButton.isVisible().catch(() => false))) await page.getByRole('button', { name: 'Output Variables' }).click() await expect(newOutputButton).toBeVisible() @@ -77,8 +80,7 @@ const fillOutputVariableEditor = async ( await editor.getByRole('button', { name: 'Output type' }).click() await page.getByRole('option', { name: type, exact: true }).click() } - if (required) - await editor.getByRole('switch', { name: 'Required' }).click() + if (required) await editor.getByRole('switch', { name: 'Required' }).click() } When( @@ -103,23 +105,20 @@ When( }, ) -When( - 'I rename the Agent v2 workflow node task output reference', - async function (this: DifyWorld) { - const page = this.getPage() - const appId = getCurrentAppId(this) +When('I rename the Agent v2 workflow node task output reference', async function (this: DifyWorld) { + const page = this.getPage() + const appId = getCurrentAppId(this) - await page.getByText(taskFileOutputName, { exact: true }).hover() - const editor = page.getByRole('form', { name: 'Output variable editor' }) - await expect(editor).toBeVisible() - await editor.getByRole('textbox', { name: 'Field name' }).fill(renamedTaskFileOutputName) + await page.getByText(taskFileOutputName, { exact: true }).hover() + const editor = page.getByRole('form', { name: 'Output variable editor' }) + await expect(editor).toBeVisible() + await editor.getByRole('textbox', { name: 'Field name' }).fill(renamedTaskFileOutputName) - const saveResponse = waitForWorkflowDraftSave(this, appId) - await editor.getByRole('button', { name: 'Confirm' }).click() - expect((await saveResponse).ok()).toBe(true) - await expect(editor).not.toBeVisible() - }, -) + const saveResponse = waitForWorkflowDraftSave(this, appId) + await editor.getByRole('button', { name: 'Confirm' }).click() + expect((await saveResponse).ok()).toBe(true) + await expect(editor).not.toBeVisible() +}) When( 'I add these Agent v2 workflow node output variables', @@ -159,7 +158,10 @@ When( }) let saveResponse = waitForWorkflowDraftSave(this, appId) - await page.getByRole('form', { name: 'Output variable editor' }).getByRole('button', { name: 'Confirm' }).click() + await page + .getByRole('form', { name: 'Output variable editor' }) + .getByRole('button', { name: 'Confirm' }) + .click() expect((await saveResponse).ok()).toBe(true) for (const fieldName of ['text', 'analysis']) { @@ -168,7 +170,10 @@ When( await fillOutputVariableEditor(this, { name: fieldName }) saveResponse = waitForWorkflowDraftSave(this, appId) - await page.getByRole('form', { name: 'Output variable editor' }).getByRole('button', { name: 'Confirm' }).click() + await page + .getByRole('form', { name: 'Output variable editor' }) + .getByRole('button', { name: 'Confirm' }) + .click() expect((await saveResponse).ok()).toBe(true) } }, @@ -183,21 +188,25 @@ Then( throw new Error('No Agent v2 workflow output variables were recorded for this scenario.') await expect - .poll(async () => { - const outputs = await getOutputVariablesFromDraft(appId) - - return expectedOutputVariables.map((expected) => { - const output = outputs.find(item => item.name === expected.name) - return { - name: output?.name, - type: output?.type === 'array' - ? `array[${output.array_item?.type ?? 'object'}]` - : output?.type, - } - }) - }, { - timeout: 30_000, - }) + .poll( + async () => { + const outputs = await getOutputVariablesFromDraft(appId) + + return expectedOutputVariables.map((expected) => { + const output = outputs.find((item) => item.name === expected.name) + return { + name: output?.name, + type: + output?.type === 'array' + ? `array[${output.array_item?.type ?? 'object'}]` + : output?.type, + } + }) + }, + { + timeout: 30_000, + }, + ) .toEqual(expectedOutputVariables) }, ) @@ -222,23 +231,26 @@ Then( const appId = getCurrentAppId(this) await expect - .poll(async () => { - const outputs = await getDeclaredOutputsFromDraft(appId) - const response = outputs.find(output => output.name === 'response') + .poll( + async () => { + const outputs = await getDeclaredOutputsFromDraft(appId) + const response = outputs.find((output) => output.name === 'response') - return { - children: response?.children?.map(child => ({ - name: child.name, - required: child.required, - type: child.type, - })), - name: response?.name, - required: response?.required, - type: response?.type, - } - }, { - timeout: 30_000, - }) + return { + children: response?.children?.map((child) => ({ + name: child.name, + required: child.required, + type: child.type, + })), + name: response?.name, + required: response?.required, + type: response?.type, + } + }, + { + timeout: 30_000, + }, + ) .toEqual({ children: [ { @@ -273,17 +285,20 @@ Then( }, ) -Then('I should see the Agent v2 workflow node nested object output variable', async function (this: DifyWorld) { - const page = this.getPage() +Then( + 'I should see the Agent v2 workflow node nested object output variable', + async function (this: DifyWorld) { + const page = this.getPage() - await openWorkflowOutputVariablesPanel(this) - await expect(page.getByText('response', { exact: true })).toBeVisible() - await expect(page.getByText('object', { exact: true })).toBeVisible() - await expect(page.getByText('Required', { exact: true })).toBeVisible() - await expect(page.getByText('text', { exact: true })).toBeVisible() - await expect(page.getByText('analysis', { exact: true })).toBeVisible() - await expect(page.getByText('string', { exact: true })).toBeVisible() -}) + await openWorkflowOutputVariablesPanel(this) + await expect(page.getByText('response', { exact: true })).toBeVisible() + await expect(page.getByText('object', { exact: true })).toBeVisible() + await expect(page.getByText('Required', { exact: true })).toBeVisible() + await expect(page.getByText('text', { exact: true })).toBeVisible() + await expect(page.getByText('analysis', { exact: true })).toBeVisible() + await expect(page.getByText('string', { exact: true })).toBeVisible() + }, +) async function expectAgentTaskOutputReference( world: DifyWorld, @@ -293,41 +308,42 @@ async function expectAgentTaskOutputReference( const page = world.getPage() const appId = getCurrentAppId(world) - await expect.poll( - async () => { - const data = await getAgentV2WorkflowNodeData(appId) - const outputs = Array.isArray(data.agent_declared_outputs) - ? data.agent_declared_outputs as DeclaredOutputConfig[] - : [] - const expectedOutput = outputs.find(output => output.name === expectedName) - - return { - agentTask: data.agent_task, - expectedOutput: expectedOutput - ? { - name: expectedOutput.name, - type: expectedOutput.type, - } - : undefined, - unexpectedOutput: unexpectedName - ? outputs.some(output => output.name === unexpectedName) - : false, - } - }, - { timeout: 30_000 }, - ).toEqual({ - agentTask: expect.stringContaining(getAgentOutputToken(expectedName)), - expectedOutput: { - name: expectedName, - type: 'file', - }, - unexpectedOutput: false, - }) + await expect + .poll( + async () => { + const data = await getAgentV2WorkflowNodeData(appId) + const outputs = Array.isArray(data.agent_declared_outputs) + ? (data.agent_declared_outputs as DeclaredOutputConfig[]) + : [] + const expectedOutput = outputs.find((output) => output.name === expectedName) + + return { + agentTask: data.agent_task, + expectedOutput: expectedOutput + ? { + name: expectedOutput.name, + type: expectedOutput.type, + } + : undefined, + unexpectedOutput: unexpectedName + ? outputs.some((output) => output.name === unexpectedName) + : false, + } + }, + { timeout: 30_000 }, + ) + .toEqual({ + agentTask: expect.stringContaining(getAgentOutputToken(expectedName)), + expectedOutput: { + name: expectedName, + type: 'file', + }, + unexpectedOutput: false, + }) await expect(page.getByText(expectedName, { exact: true })).toBeVisible() await expect(page.getByText('file', { exact: true })).toBeVisible() - if (unexpectedName) - await expect(page.getByText(unexpectedName, { exact: true })).toHaveCount(0) + if (unexpectedName) await expect(page.getByText(unexpectedName, { exact: true })).toHaveCount(0) } async function skipWorkflowTaskOutputReferenceDeletionConsistency(world: DifyWorld) { @@ -336,7 +352,8 @@ async function skipWorkflowTaskOutputReferenceDeletionConsistency(world: DifyWor 'Agent v2 workflow task output deletion consistency is not available: deleting an output from the list currently leaves the Prompt token without a stable user-visible invalid-reference state.', { owner: 'product', - remediation: 'Define whether deletion should sync the Prompt token, block deletion, or expose an invalid-reference state before enabling this scenario.', + remediation: + 'Define whether deletion should sync the Prompt token, block deletion, or expose an invalid-reference state before enabling this scenario.', }, ) } diff --git a/e2e/features/step-definitions/agent-v2/preflight.steps.ts b/e2e/features/step-definitions/agent-v2/preflight.steps.ts index 9748680449ee9e..55c24d699312dc 100644 --- a/e2e/features/step-definitions/agent-v2/preflight.steps.ts +++ b/e2e/features/step-definitions/agent-v2/preflight.steps.ts @@ -29,40 +29,35 @@ import { skipMissingPreseededTool } from '../../agent-v2/support/preflight/tools Given('the Agent Builder stable chat model is available', async function (this: DifyWorld) { const stableModel = await skipMissingAgentBuilderStableChatModel(this) - if (stableModel === 'skipped') - return stableModel + if (stableModel === 'skipped') return stableModel this.agentBuilder.preflight.stableModel = stableModel }) Given('the Agent Builder agent-decision chat model is available', async function (this: DifyWorld) { const agentDecisionModel = await skipMissingAgentBuilderAgentDecisionChatModel(this) - if (agentDecisionModel === 'skipped') - return agentDecisionModel + if (agentDecisionModel === 'skipped') return agentDecisionModel this.agentBuilder.preflight.agentDecisionModel = agentDecisionModel }) Given('the Agent Builder broken chat model is available', async function (this: DifyWorld) { const brokenModel = await skipMissingAgentBuilderBrokenChatModel(this) - if (brokenModel === 'skipped') - return brokenModel + if (brokenModel === 'skipped') return brokenModel this.agentBuilder.preflight.brokenModel = brokenModel }) Given('the Agent v2 runtime backend is available', async function (this: DifyWorld) { const runtimeBackend = await skipMissingAgentBackendRuntime(this) - if (runtimeBackend === 'skipped') - return runtimeBackend + if (runtimeBackend === 'skipped') return runtimeBackend }) Given( 'the Agent Builder preseeded Agent {string} is available', async function (this: DifyWorld, resourceName: string) { const resource = await skipMissingPreseededAgent(this, resourceName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource this.agentBuilder.preflight.preseededResources[resourceName] = resource }, @@ -72,8 +67,7 @@ Given( 'the Agent Builder preseeded workflow {string} is available', async function (this: DifyWorld, resourceName: string) { const resource = await skipMissingPreseededWorkflow(this, resourceName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource this.agentBuilder.preflight.preseededResources[resourceName] = resource }, @@ -83,8 +77,7 @@ Given( 'the Agent Builder preseeded dataset {string} is available', async function (this: DifyWorld, resourceName: string) { const resource = await skipMissingPreseededDataset(this, resourceName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource this.agentBuilder.preflight.preseededResources[resourceName] = resource }, @@ -94,8 +87,7 @@ Given( 'the Agent Builder preseeded dataset {string} is indexed and ready', async function (this: DifyWorld, resourceName: string) { const resource = await skipMissingReadyPreseededDataset(this, resourceName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource this.agentBuilder.preflight.preseededResources[resourceName] = resource }, @@ -105,8 +97,7 @@ Given( 'the Agent Builder preseeded dataset {string} is indexing', async function (this: DifyWorld, resourceName: string) { const resource = await skipMissingIndexingPreseededDataset(this, resourceName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource this.agentBuilder.preflight.preseededResources[resourceName] = resource }, @@ -116,8 +107,7 @@ Given( 'the Agent Builder preseeded tool {string} is available', async function (this: DifyWorld, resourceName: string) { const resource = await skipMissingPreseededTool(this, resourceName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource this.agentBuilder.preflight.preseededResources[resourceName] = resource }, @@ -127,8 +117,7 @@ Given( 'the Agent Builder preseeded Agent {string} includes drive skill {string}', async function (this: DifyWorld, agentName: string, skillName: string) { const resource = await skipMissingPreseededAgentDriveSkill(this, agentName, skillName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource this.agentBuilder.preflight.preseededResources[`${agentName} / ${skillName}`] = resource }, @@ -138,10 +127,10 @@ Given( 'the Agent Builder preseeded Agent {string} includes the core fixture configuration', async function (this: DifyWorld, agentName: string) { const resource = await skipMissingPreseededFullConfigAgentCoreConfiguration(this, agentName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource - this.agentBuilder.preflight.preseededResources[`${agentName} / core fixture configuration`] = resource + this.agentBuilder.preflight.preseededResources[`${agentName} / core fixture configuration`] = + resource }, ) @@ -149,11 +138,11 @@ Given( 'the Agent Builder preseeded Agent {string} includes the tool state fixture configuration', async function (this: DifyWorld, agentName: string) { const resource = await skipMissingPreseededToolStatesAgentConfiguration(this, agentName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource - this.agentBuilder.preflight.preseededResources[`${agentName} / tool state fixture configuration`] - = resource + this.agentBuilder.preflight.preseededResources[ + `${agentName} / tool state fixture configuration` + ] = resource }, ) @@ -161,11 +150,10 @@ Given( 'the Agent Builder preseeded Agent {string} includes an OAuth2 tool credential', async function (this: DifyWorld, agentName: string) { const resource = await skipMissingPreseededOAuthToolAgentConfiguration(this, agentName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource - this.agentBuilder.preflight.preseededResources[`${agentName} / OAuth2 tool credential`] - = resource + this.agentBuilder.preflight.preseededResources[`${agentName} / OAuth2 tool credential`] = + resource }, ) @@ -173,11 +161,11 @@ Given( 'the Agent Builder preseeded Agent {string} includes the dual retrieval fixture configuration', async function (this: DifyWorld, agentName: string) { const resource = await skipMissingPreseededDualRetrievalAgentConfiguration(this, agentName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource - this.agentBuilder.preflight.preseededResources[`${agentName} / dual retrieval fixture configuration`] - = resource + this.agentBuilder.preflight.preseededResources[ + `${agentName} / dual retrieval fixture configuration` + ] = resource }, ) @@ -185,10 +173,10 @@ Given( 'the Agent Builder preseeded Agent {string} has Backend service API access with an API key', async function (this: DifyWorld, agentName: string) { const resource = await skipMissingPreseededAgentBackendApiKey(this, agentName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource - this.agentBuilder.preflight.preseededResources[`${agentName} / Backend service API key`] = resource + this.agentBuilder.preflight.preseededResources[`${agentName} / Backend service API key`] = + resource }, ) @@ -196,8 +184,7 @@ Given( 'the Agent Builder preseeded Agent {string} has published Web app access', async function (this: DifyWorld, agentName: string) { const resource = await skipMissingPreseededAgentPublishedWebApp(this, agentName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource this.agentBuilder.preflight.preseededResources[`${agentName} / Web app`] = resource }, @@ -207,8 +194,7 @@ Given( 'the Agent Builder preseeded Agent {string} is referenced by workflow {string}', async function (this: DifyWorld, agentName: string, workflowName: string) { const resource = await skipMissingPreseededAgentWorkflowReference(this, agentName, workflowName) - if (resource === 'skipped') - return resource + if (resource === 'skipped') return resource this.agentBuilder.preflight.preseededResources[`${agentName} / ${workflowName}`] = resource }, diff --git a/e2e/features/step-definitions/agent-v2/publish.steps.ts b/e2e/features/step-definitions/agent-v2/publish.steps.ts index c04fca83cb70f2..3b0fe4de55a16f 100644 --- a/e2e/features/step-definitions/agent-v2/publish.steps.ts +++ b/e2e/features/step-definitions/agent-v2/publish.steps.ts @@ -22,15 +22,19 @@ When('I try to publish the Agent v2 draft without a model', async function (this await publishButton.click() }) -Then('Agent v2 publish should be blocked until a model is configured', async function (this: DifyWorld) { - await expectAgentModelRequiredFeedback(this.getPage()) -}) +Then( + 'Agent v2 publish should be blocked until a model is configured', + async function (this: DifyWorld) { + await expectAgentModelRequiredFeedback(this.getPage()) + }, +) Then('the Agent v2 draft should remain unpublished', async function (this: DifyWorld) { - await expect.poll( - async () => (await getTestAgent(getCurrentAgentId(this))).active_config_is_published, - { timeout: 30_000 }, - ).toBe(false) + await expect + .poll(async () => (await getTestAgent(getCurrentAgentId(this))).active_config_is_published, { + timeout: 30_000, + }) + .toBe(false) }) Then('the Agent v2 configuration should be saved automatically', async function (this: DifyWorld) { @@ -53,21 +57,27 @@ Then( const page = this.getPage() const agentId = getCurrentAgentId(this) - await expect(page.getByRole('status', { name: /^(?:Draft|Unpublished changes)\./ })) - .toBeVisible({ timeout: 30_000 }) - await expect(page.getByRole('button', { name: /^Publish(?: update)?$/ })) - .toBeEnabled() - await expect.poll(async () => (await getTestAgent(agentId)).active_config_is_published) + await expect( + page.getByRole('status', { name: /^(?:Draft|Unpublished changes)\./ }), + ).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('button', { name: /^Publish(?: update)?$/ })).toBeEnabled() + await expect + .poll(async () => (await getTestAgent(agentId)).active_config_is_published) .toBe(false) }, ) -Then('the Agent v2 publish action should be unavailable while up to date', async function (this: DifyWorld) { - const page = this.getPage() +Then( + 'the Agent v2 publish action should be unavailable while up to date', + async function (this: DifyWorld) { + const page = this.getPage() - await expect(page.getByRole('status', { name: /^Up to date\./ })).toBeVisible({ timeout: 30_000 }) - await expect(page.getByRole('button', { name: 'Published' })).toBeDisabled() -}) + await expect(page.getByRole('status', { name: /^Up to date\./ })).toBeVisible({ + timeout: 30_000, + }) + await expect(page.getByRole('button', { name: 'Published' })).toBeDisabled() + }, +) When('I open the Agent v2 version history', async function (this: DifyWorld) { const page = this.getPage() @@ -76,28 +86,37 @@ When('I open the Agent v2 version history', async function (this: DifyWorld) { await expect(page.getByRole('heading', { name: 'Versions' })).toBeVisible({ timeout: 30_000 }) }) -When('I select Agent v2 published version {int}', async function (this: DifyWorld, versionNumber: number) { - const page = this.getPage() - const versionButton = page.getByRole('button', { name: new RegExp(`\\bVersion ${versionNumber}\\b`) }) +When( + 'I select Agent v2 published version {int}', + async function (this: DifyWorld, versionNumber: number) { + const page = this.getPage() + const versionButton = page.getByRole('button', { + name: new RegExp(`\\bVersion ${versionNumber}\\b`), + }) - await expect(versionButton).toBeVisible({ timeout: 30_000 }) - await versionButton.click() -}) + await expect(versionButton).toBeVisible({ timeout: 30_000 }) + await versionButton.click() + }, +) -Then('the selected Agent v2 version should be displayed in view-only mode', async function (this: DifyWorld) { - const page = this.getPage() +Then( + 'the selected Agent v2 version should be displayed in view-only mode', + async function (this: DifyWorld) { + const page = this.getPage() - await expect(page.getByText('View Only')).toBeVisible({ timeout: 30_000 }) - await expect(page.getByRole('button', { name: 'Restore' })).toBeEnabled() -}) + await expect(page.getByText('View Only')).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('button', { name: 'Restore' })).toBeEnabled() + }, +) When('I restore the selected Agent v2 version', async function (this: DifyWorld) { const page = this.getPage() const agentId = getCurrentAgentId(this) - const restoreResponse = page.waitForResponse(response => - response.request().method() === 'POST' - && response.url().includes(`/console/api/agent/${agentId}/versions/`) - && response.url().endsWith('/restore'), + const restoreResponse = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes(`/console/api/agent/${agentId}/versions/`) && + response.url().endsWith('/restore'), ) await page.getByRole('button', { name: 'Restore' }).click() @@ -110,9 +129,11 @@ Then( async function (this: DifyWorld) { const agentId = getCurrentAgentId(this) - await expect.poll(async () => (await getTestAgent(agentId)).active_config_is_published, { - timeout: 30_000, - }).toBe(false) + await expect + .poll(async () => (await getTestAgent(agentId)).active_config_is_published, { + timeout: 30_000, + }) + .toBe(false) const agent = await getTestAgent(agentId) const activeSnapshotId = agent?.active_config_snapshot_id diff --git a/e2e/features/step-definitions/agent-v2/tools.steps.ts b/e2e/features/step-definitions/agent-v2/tools.steps.ts index 41718724fb8668..e88f05dcd5fdc3 100644 --- a/e2e/features/step-definitions/agent-v2/tools.steps.ts +++ b/e2e/features/step-definitions/agent-v2/tools.steps.ts @@ -4,8 +4,16 @@ import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { sendAgentServiceApiChatMessage } from '../../agent-v2/support/access-point' import { createConfiguredTestAgent, getAgentComposerDraft } from '../../agent-v2/support/agent' -import { agentBuilderExpectedTokens, agentBuilderFixedInputs, agentBuilderPreseededResources } from '../../agent-v2/support/agent-builder-resources' -import { createAgentSoulConfigWithDifyTool, createAgentSoulConfigWithModel, normalAgentSoulConfig } from '../../agent-v2/support/agent-soul' +import { + agentBuilderExpectedTokens, + agentBuilderFixedInputs, + agentBuilderPreseededResources, +} from '../../agent-v2/support/agent-builder-resources' +import { + createAgentSoulConfigWithDifyTool, + createAgentSoulConfigWithModel, + normalAgentSoulConfig, +} from '../../agent-v2/support/agent-soul' import { getPreseededOAuthToolConfig } from '../../agent-v2/support/preflight/agents' import { asArray, asRecord, asString } from '../../agent-v2/support/preflight/common' import { hasToolEntry } from '../../agent-v2/support/preflight/tools' @@ -13,8 +21,7 @@ import { SERVICE_API_RUNTIME_STEP_TIMEOUT_MS } from '../../agent-v2/support/serv import { getPreseededToolContract } from '../../agent-v2/support/tools' import { expectProviderToolActionVisible, getCurrentAgentId } from './configure-helpers' -const getToolsSection = (world: DifyWorld) => - world.getPage().getByRole('region', { name: 'Tools' }) +const getToolsSection = (world: DifyWorld) => world.getPage().getByRole('region', { name: 'Tools' }) const getToolSelectorSearch = (world: DifyWorld) => world.getPage().getByRole('textbox', { name: 'Search integrations...' }) @@ -31,15 +38,17 @@ const expectJsonReplaceToolDraft = async (world: DifyWorld) => { const agentId = getCurrentAgentId(world) const tool = getPreseededToolContract(world, agentBuilderPreseededResources.jsonReplaceTool) - await expect.poll( - async () => { - const draft = await getAgentComposerDraft(agentId) - const tools = asArray(asRecord(draft.agent_soul?.tools).dify_tools) + await expect + .poll( + async () => { + const draft = await getAgentComposerDraft(agentId) + const tools = asArray(asRecord(draft.agent_soul?.tools).dify_tools) - return hasToolEntry(tools, tool) - }, - { timeout: 30_000 }, - ).toBe(true) + return hasToolEntry(tools, tool) + }, + { timeout: 30_000 }, + ) + .toBe(true) } const getOAuth2ToolEntries = async (agentId: string) => { @@ -48,15 +57,17 @@ const getOAuth2ToolEntries = async (agentId: string) => { return asArray(asRecord(draft.agent_soul?.tools).dify_tools).filter((item) => { const record = asRecord(item) - return record.credential_type === 'oauth2' - && Boolean(asString(asRecord(record.credential_ref).id)) + return ( + record.credential_type === 'oauth2' && Boolean(asString(asRecord(record.credential_ref).id)) + ) }) } const getOAuth2ToolDisplayName = async (world: DifyWorld) => { const [tool] = await getOAuth2ToolEntries(getCurrentAgentId(world)) const record = asRecord(tool) - const providerName = asString(record.provider) || asString(record.provider_id) || asString(record.plugin_id) + const providerName = + asString(record.provider) || asString(record.provider_id) || asString(record.plugin_id) const toolName = asString(record.name) || asString(record.tool_name) if (!providerName || !toolName) @@ -66,9 +77,10 @@ const getOAuth2ToolDisplayName = async (world: DifyWorld) => { } const getPreseededOAuthToolAgent = (world: DifyWorld) => { - const resource = world.agentBuilder.preflight.preseededResources[ - `${agentBuilderPreseededResources.oauthToolAgent} / OAuth2 tool credential` - ] + const resource = + world.agentBuilder.preflight.preseededResources[ + `${agentBuilderPreseededResources.oauthToolAgent} / OAuth2 tool credential` + ] if (!resource || resource.kind !== 'agent') { throw new Error( `Preseeded Agent "${agentBuilderPreseededResources.oauthToolAgent}" OAuth2 tool credential fixture is not available. Run the matching preflight step first.`, @@ -96,8 +108,7 @@ const jsonReplaceToolConfig = (world: DifyWorld): AgentSoulDifyToolConfig => { } } -const getServiceApiSseEvents = (body: unknown) => - asArray(asRecord(body).events).map(asRecord) +const getServiceApiSseEvents = (body: unknown) => asArray(asRecord(body).events).map(asRecord) const getServiceApiEventData = (event: Record<string, unknown>) => asRecord(event.data) @@ -130,11 +141,13 @@ const findJsonReplaceRuntimeThought = (body: unknown) => const toolInput = asString(data.tool_input) const observation = asString(data.observation) - return getServiceApiEventName(event) === 'agent_thought' - && asString(data.tool) === 'json_replace' - && toolInput.includes(agentBuilderExpectedTokens.jsonToolBefore) - && toolInput.includes('$.marker') - && observation.includes(agentBuilderExpectedTokens.jsonToolAfter) + return ( + getServiceApiEventName(event) === 'agent_thought' && + asString(data.tool) === 'json_replace' && + toolInput.includes(agentBuilderExpectedTokens.jsonToolBefore) && + toolInput.includes('$.marker') && + observation.includes(agentBuilderExpectedTokens.jsonToolAfter) + ) }) const expectOAuth2CredentialPreserved = async (world: DifyWorld) => { @@ -143,31 +156,35 @@ const expectOAuth2CredentialPreserved = async (world: DifyWorld) => { const expected = asRecord(expectedTool) const expectedCredentialRef = asRecord(expected.credential_ref) const expectedCredentialId = asString(expectedCredentialRef.id) - const expectedProvider = asString(expected.provider_id) || asString(expected.provider) || asString(expected.plugin_id) + const expectedProvider = + asString(expected.provider_id) || asString(expected.provider) || asString(expected.plugin_id) const expectedToolName = asString(expected.tool_name) || asString(expected.name) - await expect.poll( - async () => { - const tools = await getOAuth2ToolEntries(getCurrentAgentId(world)) - const matchingTool = tools.find((item) => { - const record = asRecord(item) - const provider = asString(record.provider_id) || asString(record.provider) || asString(record.plugin_id) - const toolName = asString(record.tool_name) || asString(record.name) - - return provider === expectedProvider && toolName === expectedToolName - }) - const record = asRecord(matchingTool) - - return { - credentialId: asString(asRecord(record.credential_ref).id), - credentialType: asString(record.credential_type), - } - }, - { timeout: 30_000 }, - ).toEqual({ - credentialId: expectedCredentialId, - credentialType: 'oauth2', - }) + await expect + .poll( + async () => { + const tools = await getOAuth2ToolEntries(getCurrentAgentId(world)) + const matchingTool = tools.find((item) => { + const record = asRecord(item) + const provider = + asString(record.provider_id) || asString(record.provider) || asString(record.plugin_id) + const toolName = asString(record.tool_name) || asString(record.name) + + return provider === expectedProvider && toolName === expectedToolName + }) + const record = asRecord(matchingTool) + + return { + credentialId: asString(asRecord(record.credential_ref).id), + credentialType: asString(record.credential_type), + } + }, + { timeout: 30_000 }, + ) + .toEqual({ + credentialId: expectedCredentialId, + credentialType: 'oauth2', + }) } Given( @@ -299,10 +316,11 @@ Then( 'the Agent v2 Backend service API response should include the JSON Replace E2E marker', async function (this: DifyWorld) { const response = this.agentBuilder.accessPoint.serviceApiResponse - if (!response) - throw new Error('No Agent v2 Backend service API response was recorded.') + if (!response) throw new Error('No Agent v2 Backend service API response was recorded.') if (!response.ok) - throw new Error(`Agent v2 Backend service API JSON Replace request failed with ${response.status}: ${JSON.stringify(response.body)}`) + throw new Error( + `Agent v2 Backend service API JSON Replace request failed with ${response.status}: ${JSON.stringify(response.body)}`, + ) const jsonReplaceThought = findJsonReplaceRuntimeThought(response.body) if (!jsonReplaceThought) { @@ -318,7 +336,9 @@ Then( expect(asString(thought.tool_input)).toContain(agentBuilderExpectedTokens.jsonToolBefore) expect(asString(thought.tool_input)).toContain('$.marker') expect(asString(thought.observation)).toContain(agentBuilderExpectedTokens.jsonToolAfter) - expect(asString(asRecord(response.body).answer)).toContain(agentBuilderExpectedTokens.jsonToolAfter) + expect(asString(asRecord(response.body).answer)).toContain( + agentBuilderExpectedTokens.jsonToolAfter, + ) }, ) @@ -329,9 +349,9 @@ Then( const displayName = await getOAuth2ToolDisplayName(this) await expectProviderToolActionVisible(toolsSection, displayName) - await expect(toolsSection.getByRole('button', { exact: true, name: 'Not authorized' })) - .not - .toBeVisible() + await expect( + toolsSection.getByRole('button', { exact: true, name: 'Not authorized' }), + ).not.toBeVisible() }, ) @@ -347,14 +367,19 @@ Then('I should see the Agent v2 tool selector empty state', async function (this await expect(page.getByText('No integrations were found')).toBeVisible({ timeout: 30_000 }) await expect(page.getByRole('link', { name: 'Requests to the community' })).toBeVisible() - await expect(page.getByText(agentBuilderFixedInputs.missingToolSearchWithSuffix)).not.toBeVisible() + await expect( + page.getByText(agentBuilderFixedInputs.missingToolSearchWithSuffix), + ).not.toBeVisible() }) -Then('I should see the Agent v2 tool selector ready for another search', async function (this: DifyWorld) { - const page = this.getPage() - const search = getToolSelectorSearch(this) +Then( + 'I should see the Agent v2 tool selector ready for another search', + async function (this: DifyWorld) { + const page = this.getPage() + const search = getToolSelectorSearch(this) - await expect(search).toHaveValue('') - await expect(page.getByText('No integrations were found')).not.toBeVisible() - await expect(page.getByText('All tools')).toBeVisible() -}) + await expect(search).toHaveValue('') + await expect(page.getByText('No integrations were found')).not.toBeVisible() + await expect(page.getByText('All tools')).toBeVisible() + }, +) diff --git a/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts b/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts index 0df872a0ada5ff..89075a2fbdafec 100644 --- a/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts +++ b/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts @@ -1,10 +1,7 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { - createTestApp, - syncAgentV2WorkflowDraft, -} from '../../../support/api' +import { createTestApp, syncAgentV2WorkflowDraft } from '../../../support/api' import { createE2EResourceName } from '../../../support/naming' import { createConfiguredTestAgent } from '../../agent-v2/support/agent' import { @@ -50,8 +47,7 @@ When('I open the Agent v2 workflow node panel', async function (this: DifyWorld) When('I open the Agent v2 workflow Agent details', async function (this: DifyWorld) { const page = this.getPage() const agentName = this.lastCreatedAgentName - if (!agentName) - throw new Error('No Agent v2 name found. Create a workflow Agent v2 node first.') + if (!agentName) throw new Error('No Agent v2 name found. Create a workflow Agent v2 node first.') await page.getByRole('button', { name: `Open ${agentName} details` }).click() await expect(page.getByRole('dialog', { name: `${agentName} details` })).toBeVisible() @@ -60,8 +56,7 @@ When('I open the Agent v2 workflow Agent details', async function (this: DifyWor When('I open the Agent v2 workflow Agent in Agent Console', async function (this: DifyWorld) { const page = this.getPage() const agentName = this.lastCreatedAgentName - if (!agentName) - throw new Error('No Agent v2 name found. Create a workflow Agent v2 node first.') + if (!agentName) throw new Error('No Agent v2 name found. Create a workflow Agent v2 node first.') const detailsDialog = page.getByRole('dialog', { name: `${agentName} details` }) const [agentConsolePage] = await Promise.all([ @@ -82,20 +77,20 @@ Then( if (!agentName) throw new Error('No Agent v2 name found. Create a workflow Agent v2 node first.') if (!stableModel) - throw new Error('Stable chat model preflight must run before asserting workflow Agent details.') + throw new Error( + 'Stable chat model preflight must run before asserting workflow Agent details.', + ) const detailsDialog = page.getByRole('dialog', { name: `${agentName} details` }) await expect(detailsDialog).toBeVisible() await expect(detailsDialog.getByText(agentName, { exact: true })).toBeVisible() - if (agentRole) - await expect(detailsDialog.getByText(agentRole, { exact: true })).toBeVisible() + if (agentRole) await expect(detailsDialog.getByText(agentRole, { exact: true })).toBeVisible() await expect(detailsDialog.getByText(stableModel.name, { exact: true })).toBeVisible() await expect(detailsDialog.getByText(normalAgentPrompt)).toBeVisible() - await expect(detailsDialog.getByRole('link', { name: 'Edit in Agent Console' })).toHaveAttribute( - 'href', - `/agents/${this.createdAgentIds.at(-1)}/configure`, - ) + await expect( + detailsDialog.getByRole('link', { name: 'Edit in Agent Console' }), + ).toHaveAttribute('href', `/agents/${this.createdAgentIds.at(-1)}/configure`) }, ) @@ -106,16 +101,13 @@ Then( const agentId = this.createdAgentIds.at(-1) const agentName = this.lastCreatedAgentName const stableModel = this.agentBuilder.preflight.stableModel - if (!agentConsolePage) - throw new Error('Agent Console page was not opened.') + if (!agentConsolePage) throw new Error('Agent Console page was not opened.') if (!agentId || !agentName) throw new Error('No Agent v2 ID or name found. Create a workflow Agent v2 node first.') if (!stableModel) throw new Error('Stable chat model preflight must run before asserting Agent Console.') - await expect(agentConsolePage).toHaveURL( - new RegExp(`/agents/${agentId}/configure(?:\\?.*)?$`), - ) + await expect(agentConsolePage).toHaveURL(new RegExp(`/agents/${agentId}/configure(?:\\?.*)?$`)) await expect(agentConsolePage.getByRole('heading', { name: 'Configure' })).toBeVisible({ timeout: 30_000, }) diff --git a/e2e/features/step-definitions/apps/duplicate-app.steps.ts b/e2e/features/step-definitions/apps/duplicate-app.steps.ts index aa8c06f5df8f02..bc5fd2ddee099b 100644 --- a/e2e/features/step-definitions/apps/duplicate-app.steps.ts +++ b/e2e/features/step-definitions/apps/duplicate-app.steps.ts @@ -13,8 +13,7 @@ Given('there is an existing E2E app available for testing', async function (this When('I open the options menu for the last created E2E app', async function (this: DifyWorld) { const appName = this.lastCreatedAppName - if (!appName) - throw new Error('No app name stored. Run "I enter a unique E2E app name" first.') + if (!appName) throw new Error('No app name stored. Run "I enter a unique E2E app name" first.') const page = this.getPage() const appLink = page.getByRole('link', { name: appName, exact: true }) diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts index eacc49f4eadf8f..2a99151f72013e 100644 --- a/e2e/features/step-definitions/apps/share-app.steps.ts +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -49,7 +49,9 @@ When('I open the shared app URL', async function (this: DifyWorld) { Then('the shared app page should be accessible', async function (this: DifyWorld) { await expect(this.getPage()).toHaveURL(/\/(workflow|chat)\/[a-zA-Z0-9]+/, { timeout: 15_000 }) - await expect(this.getPage().getByRole('button', { name: 'Execute' })).toBeVisible({ timeout: 10_000 }) + await expect(this.getPage().getByRole('button', { name: 'Execute' })).toBeVisible({ + timeout: 10_000, + }) }) When('I run the shared workflow app', async function (this: DifyWorld) { @@ -61,5 +63,7 @@ When('I run the shared workflow app', async function (this: DifyWorld) { }) Then('the shared workflow run should succeed', async function (this: DifyWorld) { - await expect(this.getPage().getByRole('img', { name: 'Workflow Process succeeded' })).toBeVisible({ timeout: 55_000 }) + await expect(this.getPage().getByRole('img', { name: 'Workflow Process succeeded' })).toBeVisible( + { timeout: 55_000 }, + ) }) diff --git a/e2e/features/step-definitions/apps/switch-app-mode.steps.ts b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts index cfaf21a66bde54..20f513f471bf9c 100644 --- a/e2e/features/step-definitions/apps/switch-app-mode.steps.ts +++ b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts @@ -24,6 +24,5 @@ Then('I should land on the switched app', async function (this: DifyWorld) { // Capture the new app's ID so the After hook can clean it up const match = page.url().match(/\/app\/([^/]+)\/workflow/) - if (match?.[1]) - this.createdAppIds.push(match[1]) + if (match?.[1]) this.createdAppIds.push(match[1]) }) diff --git a/e2e/features/step-definitions/auth/session-refresh.steps.ts b/e2e/features/step-definitions/auth/session-refresh.steps.ts index f6468bfaf3d535..c74431a6d624fc 100644 --- a/e2e/features/step-definitions/auth/session-refresh.steps.ts +++ b/e2e/features/step-definitions/auth/session-refresh.steps.ts @@ -10,34 +10,43 @@ Given('my console session requires token refresh', async function (this: DifyWor throw new Error('Playwright browser context has not been initialized for this scenario.') const cookies = await this.context.cookies() - const hasAccessToken = cookies.some(cookie => consoleAccessTokenCookieName.test(cookie.name)) - const hasRefreshToken = cookies.some(cookie => consoleRefreshTokenCookieName.test(cookie.name)) + const hasAccessToken = cookies.some((cookie) => consoleAccessTokenCookieName.test(cookie.name)) + const hasRefreshToken = cookies.some((cookie) => consoleRefreshTokenCookieName.test(cookie.name)) - expect(hasAccessToken, 'Expected the authenticated E2E session to include a console access token.').toBe(true) - expect(hasRefreshToken, 'Expected the authenticated E2E session to include a console refresh token.').toBe(true) + expect( + hasAccessToken, + 'Expected the authenticated E2E session to include a console access token.', + ).toBe(true) + expect( + hasRefreshToken, + 'Expected the authenticated E2E session to include a console refresh token.', + ).toBe(true) await this.context.clearCookies({ name: consoleAccessTokenCookieName }) const remainingCookies = await this.context.cookies() expect( - remainingCookies.some(cookie => consoleAccessTokenCookieName.test(cookie.name)), + remainingCookies.some((cookie) => consoleAccessTokenCookieName.test(cookie.name)), 'Expected the console access token to be removed before opening the default console entry.', ).toBe(false) expect( - remainingCookies.some(cookie => consoleRefreshTokenCookieName.test(cookie.name)), + remainingCookies.some((cookie) => consoleRefreshTokenCookieName.test(cookie.name)), 'Expected the console refresh token to remain available for server-side refresh.', ).toBe(true) }) -When('I open the default console entry after the access token expires', async function (this: DifyWorld) { - const page = this.getPage() - const refreshRequestPromise = page.waitForRequest((request) => { - const url = new URL(request.url()) - return url.pathname.endsWith('/auth/refresh') && url.searchParams.get('redirect_url') === '/' - }) - - await page.goto('/') - - const refreshRequest = await refreshRequestPromise - this.attach(`Session refresh request: ${refreshRequest.url()}`, 'text/plain') -}) +When( + 'I open the default console entry after the access token expires', + async function (this: DifyWorld) { + const page = this.getPage() + const refreshRequestPromise = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname.endsWith('/auth/refresh') && url.searchParams.get('redirect_url') === '/' + }) + + await page.goto('/') + + const refreshRequest = await refreshRequestPromise + this.attach(`Session refresh request: ${refreshRequest.url()}`, 'text/plain') + }, +) diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts index 658f3efc69b28f..cd3415ae44f2f2 100644 --- a/e2e/features/support/hooks.ts +++ b/e2e/features/support/hooks.ts @@ -12,7 +12,11 @@ import { deleteTestDataset } from '../../support/datasets' import { deleteBuiltinToolCredential } from '../../support/tools' import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env' import { deleteTestAgent } from '../agent-v2/support/agent' -import { deleteAgentConfigFile, deleteAgentConfigSkill, deleteAgentDriveFile } from '../agent-v2/support/agent-drive' +import { + deleteAgentConfigFile, + deleteAgentConfigSkill, + deleteAgentDriveFile, +} from '../agent-v2/support/agent-drive' const e2eRoot = fileURLToPath(new URL('../..', import.meta.url)) const artifactsDir = path.join(e2eRoot, 'cucumber-report', 'artifacts') @@ -47,16 +51,15 @@ const writeArtifact = async ( return artifactPath } -const uniqueDiagnosticPages = (pages: { label: string, page: Page | undefined }[]) => { +const uniqueDiagnosticPages = (pages: { label: string; page: Page | undefined }[]) => { const seen = new Set<Page>() return pages.filter(({ page }) => { - if (!page || page.isClosed() || seen.has(page)) - return false + if (!page || page.isClosed() || seen.has(page)) return false seen.add(page) return true - }) as { label: string, page: Page }[] + }) as { label: string; page: Page }[] } const captureDiagnosticPage = async ( @@ -78,15 +81,10 @@ const captureDiagnosticPage = async ( return [screenshotPath, htmlPath] } -const recordCleanup = async ( - errors: string[], - label: string, - cleanup: () => Promise<void>, -) => { +const recordCleanup = async (errors: string[], label: string, cleanup: () => Promise<void>) => { try { await cleanup() - } - catch (error) { + } catch (error) { errors.push(`${label}: ${error instanceof Error ? error.message : String(error)}`) } } @@ -104,18 +102,16 @@ BeforeAll({ timeout: AUTH_BOOTSTRAP_TIMEOUT_MS }, async () => { }) Before(async function (this: DifyWorld, { pickle }) { - if (!browser) - throw new Error('Shared Playwright browser is not available.') + if (!browser) throw new Error('Shared Playwright browser is not available.') - const isUnauthenticatedScenario = pickle.tags.some(tag => tag.name === '@unauthenticated') + const isUnauthenticatedScenario = pickle.tags.some((tag) => tag.name === '@unauthenticated') - if (isUnauthenticatedScenario) - await this.startUnauthenticatedSession(browser) + if (isUnauthenticatedScenario) await this.startUnauthenticatedSession(browser) else await this.startAuthenticatedSession(browser) this.scenarioStartedAt = Date.now() - const tags = pickle.tags.map(tag => tag.name).join(' ') + const tags = pickle.tags.map((tag) => tag.name).join(' ') console.warn(`[e2e] start ${pickle.name}${tags ? ` ${tags}` : ''}`) }) @@ -130,16 +126,18 @@ After(async function (this: DifyWorld, { pickle, result }) { { label: 'main-page', page: this.page }, { label: 'agent-v2-web-app', page: this.agentBuilder.accessPoint.webAppPage }, { label: 'agent-v2-api-reference', page: this.agentBuilder.accessPoint.apiReferencePage }, - { label: 'agent-v2-workflow-reference', page: this.agentBuilder.accessPoint.workflowReferencePage }, + { + label: 'agent-v2-workflow-reference', + page: this.agentBuilder.accessPoint.workflowReferencePage, + }, { label: 'agent-v2-concurrent-configure', page: this.agentBuilder.configure.concurrentPage }, { label: 'agent-v2-workflow-console', page: this.agentBuilder.workflow.agentConsolePage }, ]) for (const { label, page } of diagnosticPages) { try { - artifactPaths.push(...await captureDiagnosticPage(this, pickle.name, label, page)) - } - catch (error) { + artifactPaths.push(...(await captureDiagnosticPage(this, pickle.name, label, page))) + } catch (error) { artifactErrors.push(`${label}: ${error instanceof Error ? error.message : String(error)}`) } } @@ -165,15 +163,18 @@ After(async function (this: DifyWorld, { pickle, result }) { for (const skill of this.createdAgentConfigSkills.toReversed()) { await recordCleanup(cleanupErrors, `Delete Agent config skill ${skill.name}`, () => - deleteAgentConfigSkill(skill.agentId, skill.name)) + deleteAgentConfigSkill(skill.agentId, skill.name), + ) } for (const file of this.createdAgentConfigFiles.toReversed()) { await recordCleanup(cleanupErrors, `Delete Agent config file ${file.name}`, () => - deleteAgentConfigFile(file.agentId, file.name)) + deleteAgentConfigFile(file.agentId, file.name), + ) } for (const file of this.createdAgentDriveFiles.toReversed()) { await recordCleanup(cleanupErrors, `Delete Agent drive file ${file.key}`, () => - deleteAgentDriveFile(file.agentId, file.key)) + deleteAgentDriveFile(file.agentId, file.key), + ) } for (const id of this.createdAppIds) await recordCleanup(cleanupErrors, `Delete app ${id}`, () => deleteTestApp(id)) diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 689275c9ccf7cd..5b6736a45541c7 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -48,7 +48,7 @@ export const createAgentBuilderWorldState = () => ({ apiReferencePage: undefined as Page | undefined, composerDraftSnapshot: undefined as string | undefined, generatedApiKey: undefined as string | undefined, - serviceApiResponse: undefined as { body: unknown, ok: boolean, status: number } | undefined, + serviceApiResponse: undefined as { body: unknown; ok: boolean; status: number } | undefined, serviceApiBaseURL: undefined as string | undefined, webAppPage: undefined as Page | undefined, webAppURL: undefined as string | undefined, @@ -123,8 +123,7 @@ export class DifyWorld extends World { this.page.setDefaultTimeout(30_000) this.page.on('console', (message: ConsoleMessage) => { - if (message.type() === 'error') - this.consoleErrors.push(message.text()) + if (message.type() === 'error') this.consoleErrors.push(message.text()) }) this.page.on('pageerror', (error) => { this.pageErrors.push(error.message) @@ -143,8 +142,7 @@ export class DifyWorld extends World { } getPage() { - if (!this.page) - throw new Error('Playwright page has not been initialized for this scenario.') + if (!this.page) throw new Error('Playwright page has not been initialized for this scenario.') return this.page } @@ -164,14 +162,12 @@ export class DifyWorld extends World { for (const cleanup of this.scenarioCleanups.toReversed()) { try { await cleanup() - } - catch (error) { + } catch (error) { errors.push(error instanceof Error ? error.message : String(error)) } } - if (errors.length > 0) - this.attach(`Cleanup errors:\n${errors.join('\n')}`, 'text/plain') + if (errors.length > 0) this.attach(`Cleanup errors:\n${errors.join('\n')}`, 'text/plain') } async closeSession() { diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index 62d2880a4655c8..f9a6d80b6f8f5d 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -58,8 +58,7 @@ const getRemainingTimeout = (deadline: number) => Math.max(deadline - Date.now() const encodeField = (value: string) => Buffer.from(value, 'utf8').toString('base64') const assertAPIResponse = async (response: APIResponse, action: string) => { - if (response.ok()) - return + if (response.ok()) return const body = await response.text().catch(() => '') throw new Error( @@ -90,8 +89,7 @@ const postConsoleAPI = async ( const validateInitPasswordIfNeeded = async (context: BrowserContext, deadline: number) => { const initStatus = await getConsoleAPI<InitStatusResponse>(context, '/console/api/init', deadline) - if (initStatus.status === 'finished') - return false + if (initStatus.status === 'finished') return false console.warn('[e2e] auth bootstrap: validating init password') await postConsoleAPI(context, '/console/api/init', deadline, { password: initPassword }) @@ -167,8 +165,7 @@ export const ensureAuthenticatedState = async (browser: Browser, configuredBaseU } await writeFile(authMetadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8') - } - finally { + } finally { await context.close() } } diff --git a/e2e/package.json b/e2e/package.json index d584a515a5d7c9..0f0e9f715536f3 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,7 +1,7 @@ { "name": "dify-e2e", - "type": "module", "private": true, + "type": "module", "scripts": { "e2e": "tsx ./scripts/run-cucumber.ts", "e2e:external": "tsx ./scripts/run-external-runtime.ts", diff --git a/e2e/scripts/common.ts b/e2e/scripts/common.ts index be507b62abf5d0..d37cddd71d6c0b 100644 --- a/e2e/scripts/common.ts +++ b/e2e/scripts/common.ts @@ -52,8 +52,7 @@ const formatCommand = (command: string, args: string[]) => [command, ...args].jo export const isMainModule = (metaUrl: string) => { const entrypoint = process.argv[1] - if (!entrypoint) - return false + if (!entrypoint) return false return pathToFileURL(entrypoint).href === metaUrl } @@ -111,8 +110,7 @@ export const runCommandOrThrow = async (options: RunCommandOptions) => { } export const getTcpPortListenerDescription = async (port: number) => { - if (process.platform === 'win32') - return '' + if (process.platform === 'win32') return '' const result = await runCommand({ command: 'lsof', @@ -121,16 +119,14 @@ export const getTcpPortListenerDescription = async (port: number) => { stdio: 'pipe', }) - if (result.exitCode !== 0) - return '' + if (result.exitCode !== 0) return '' return result.stdout.trim() } const forwardSignalsToChild = (childProcess: ChildProcess) => { const handleSignal = (signal: NodeJS.Signals) => { - if (childProcess.exitCode === null) - childProcess.kill(signal) + if (childProcess.exitCode === null) childProcess.kill(signal) } const onSigint = () => handleSignal('SIGINT') @@ -175,8 +171,7 @@ export const runForegroundProcess = async ({ export const ensureFileExists = async (filePath: string, exampleFilePath: string) => { try { await access(filePath) - } - catch { + } catch { await copyFile(exampleFilePath, filePath) } } @@ -186,10 +181,9 @@ export const ensureLineInFile = async (filePath: string, line: string) => { const lines = fileContent.split(/\r?\n/) const assignmentPrefix = line.includes('=') ? `${line.slice(0, line.indexOf('='))}=` : null - if (lines.includes(line)) - return + if (lines.includes(line)) return - if (assignmentPrefix && lines.some(existingLine => existingLine.startsWith(assignmentPrefix))) + if (assignmentPrefix && lines.some((existingLine) => existingLine.startsWith(assignmentPrefix))) return const normalizedContent = fileContent.endsWith('\n') ? fileContent : `${fileContent}\n` @@ -212,16 +206,16 @@ export const readSimpleDotenv = async (filePath: string): Promise<Record<string, const fileContent = await readFile(filePath, 'utf8') const entries = fileContent .split(/\r?\n/) - .map(line => line.trim()) - .filter(line => line && !line.startsWith('#')) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) .map<[string, string]>((line) => { const separatorIndex = line.indexOf('=') const key = separatorIndex === -1 ? line : line.slice(0, separatorIndex).trim() const rawValue = separatorIndex === -1 ? '' : line.slice(separatorIndex + 1).trim() if ( - (rawValue.startsWith('"') && rawValue.endsWith('"')) - || (rawValue.startsWith('\'') && rawValue.endsWith('\'')) + (rawValue.startsWith('"') && rawValue.endsWith('"')) || + (rawValue.startsWith("'") && rawValue.endsWith("'")) ) { return [key, rawValue.slice(1, -1)] } @@ -246,8 +240,7 @@ export const waitForCondition = async ({ const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { - if (await check()) - return + if (await check()) return await sleep(intervalMs) } diff --git a/e2e/scripts/env.ts b/e2e/scripts/env.ts index 381eada1ecca13..d8cc3eb78017b4 100644 --- a/e2e/scripts/env.ts +++ b/e2e/scripts/env.ts @@ -9,32 +9,28 @@ const jsonObjectString = z.string().refine((value) => { try { const parsed = JSON.parse(value) as unknown return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) - } - catch { + } catch { return false } }, 'must be a JSON object string') const parseEnvLine = (line: string) => { const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) - return undefined + if (!trimmed || trimmed.startsWith('#')) return undefined const normalized = trimmed.startsWith('export ') ? trimmed.slice('export '.length).trim() : trimmed const separatorIndex = normalized.indexOf('=') - if (separatorIndex === -1) - return undefined + if (separatorIndex === -1) return undefined const key = normalized.slice(0, separatorIndex).trim() let value = normalized.slice(separatorIndex + 1).trim() - if (!key) - return undefined + if (!key) return undefined if ( - (value.startsWith('"') && value.endsWith('"')) - || (value.startsWith('\'') && value.endsWith('\'')) + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1) } @@ -47,104 +43,103 @@ export const loadE2eEnv = () => { ? path.resolve(process.env.E2E_ENV_FILE) : path.join(e2eDir, '.env.local') - if (!existsSync(envFilePath)) - return + if (!existsSync(envFilePath)) return const content = readFileSync(envFilePath, 'utf8') for (const line of content.split(/\r?\n/)) { const entry = parseEnvLine(line) - if (!entry) - continue + if (!entry) continue const [key, value] = entry process.env[key] ??= value } } -export const validateE2eEnv = () => createEnv({ - client: {}, - clientPrefix: 'NEXT_PUBLIC_', - emptyStringAsUndefined: true, - onValidationError: (issues) => { - const messages = issues.map((issue) => { - const path = Array.isArray(issue.path) && issue.path.length > 0 - ? issue.path.join('.') - : '(root)' - return `- ${path}: ${issue.message}` - }) +export const validateE2eEnv = () => + createEnv({ + client: {}, + clientPrefix: 'NEXT_PUBLIC_', + emptyStringAsUndefined: true, + onValidationError: (issues) => { + const messages = issues.map((issue) => { + const path = + Array.isArray(issue.path) && issue.path.length > 0 ? issue.path.join('.') : '(root)' + return `- ${path}: ${issue.message}` + }) - throw new Error(`Invalid E2E env:\n${messages.join('\n')}`) - }, - runtimeEnv: { - CUCUMBER_HEADLESS: process.env.CUCUMBER_HEADLESS, - E2E_ADMIN_EMAIL: process.env.E2E_ADMIN_EMAIL, - E2E_ADMIN_NAME: process.env.E2E_ADMIN_NAME, - E2E_ADMIN_PASSWORD: process.env.E2E_ADMIN_PASSWORD, - E2E_AGENT_BACKEND_PORT: process.env.E2E_AGENT_BACKEND_PORT, - E2E_AGENT_BACKEND_URL: process.env.E2E_AGENT_BACKEND_URL, - E2E_API_URL: process.env.E2E_API_URL, - E2E_BASE_URL: process.env.E2E_BASE_URL, - E2E_BROKEN_MODEL_NAME: process.env.E2E_BROKEN_MODEL_NAME, - E2E_BROKEN_MODEL_PROVIDER: process.env.E2E_BROKEN_MODEL_PROVIDER, - E2E_BROKEN_MODEL_TYPE: process.env.E2E_BROKEN_MODEL_TYPE, - E2E_CUCUMBER_TAGS: process.env.E2E_CUCUMBER_TAGS, - E2E_EXTERNAL_RUNTIME_SEED_SPECS: process.env.E2E_EXTERNAL_RUNTIME_SEED_SPECS, - E2E_EXTERNAL_RUNTIME_TAGS: process.env.E2E_EXTERNAL_RUNTIME_TAGS, - E2E_FORCE_WEB_BUILD: process.env.E2E_FORCE_WEB_BUILD, - E2E_INIT_PASSWORD: process.env.E2E_INIT_PASSWORD, - E2E_MARKETPLACE_API_URL: process.env.E2E_MARKETPLACE_API_URL, - E2E_MARKETPLACE_PLUGIN_IDS: process.env.E2E_MARKETPLACE_PLUGIN_IDS, - E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS: process.env.E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS, - E2E_MODEL_PROVIDER_CREDENTIALS_JSON: process.env.E2E_MODEL_PROVIDER_CREDENTIALS_JSON, - E2E_OAUTH_TOOL_CREDENTIAL_ID: process.env.E2E_OAUTH_TOOL_CREDENTIAL_ID, - E2E_OAUTH_TOOL_NAME: process.env.E2E_OAUTH_TOOL_NAME, - E2E_OAUTH_TOOL_PROVIDER: process.env.E2E_OAUTH_TOOL_PROVIDER, - E2E_REUSE_WEB_SERVER: process.env.E2E_REUSE_WEB_SERVER, - E2E_SLOW_MO: process.env.E2E_SLOW_MO, - E2E_SHELLCTL_AUTH_TOKEN: process.env.E2E_SHELLCTL_AUTH_TOKEN, - E2E_SHELLCTL_CONTAINER_NAME: process.env.E2E_SHELLCTL_CONTAINER_NAME, - E2E_SHELLCTL_IMAGE: process.env.E2E_SHELLCTL_IMAGE, - E2E_SHELLCTL_PORT: process.env.E2E_SHELLCTL_PORT, - E2E_SHELLCTL_URL: process.env.E2E_SHELLCTL_URL, - E2E_START_AGENT_BACKEND: process.env.E2E_START_AGENT_BACKEND, - E2E_STABLE_MODEL_NAME: process.env.E2E_STABLE_MODEL_NAME, - E2E_STABLE_MODEL_PROVIDER: process.env.E2E_STABLE_MODEL_PROVIDER, - E2E_STABLE_MODEL_TYPE: process.env.E2E_STABLE_MODEL_TYPE, - }, - server: { - CUCUMBER_HEADLESS: booleanString.optional(), - E2E_ADMIN_EMAIL: z.email().optional(), - E2E_ADMIN_NAME: z.string().min(1).optional(), - E2E_ADMIN_PASSWORD: z.string().min(1).optional(), - E2E_AGENT_BACKEND_PORT: z.coerce.number().int().positive().max(65535).optional(), - E2E_AGENT_BACKEND_URL: z.url().optional(), - E2E_API_URL: z.url().optional(), - E2E_BASE_URL: z.url().optional(), - E2E_BROKEN_MODEL_NAME: z.string().min(1).optional(), - E2E_BROKEN_MODEL_PROVIDER: z.string().min(1).optional(), - E2E_BROKEN_MODEL_TYPE: z.string().min(1).optional(), - E2E_CUCUMBER_TAGS: z.string().min(1).optional(), - E2E_EXTERNAL_RUNTIME_SEED_SPECS: z.string().min(1).optional(), - E2E_EXTERNAL_RUNTIME_TAGS: z.string().min(1).optional(), - E2E_FORCE_WEB_BUILD: z.literal('1').optional(), - E2E_INIT_PASSWORD: z.string().min(1).optional(), - E2E_MARKETPLACE_API_URL: z.url().optional(), - E2E_MARKETPLACE_PLUGIN_IDS: z.string().min(1).optional(), - E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS: z.string().min(1).optional(), - E2E_MODEL_PROVIDER_CREDENTIALS_JSON: jsonObjectString.optional(), - E2E_OAUTH_TOOL_CREDENTIAL_ID: z.string().min(1).optional(), - E2E_OAUTH_TOOL_NAME: z.string().min(1).optional(), - E2E_OAUTH_TOOL_PROVIDER: z.string().min(1).optional(), - E2E_REUSE_WEB_SERVER: booleanString.optional(), - E2E_SLOW_MO: z.coerce.number().nonnegative().optional(), - E2E_SHELLCTL_AUTH_TOKEN: z.string().min(1).optional(), - E2E_SHELLCTL_CONTAINER_NAME: z.string().min(1).optional(), - E2E_SHELLCTL_IMAGE: z.string().min(1).optional(), - E2E_SHELLCTL_PORT: z.coerce.number().int().positive().max(65535).optional(), - E2E_SHELLCTL_URL: z.url().optional(), - E2E_START_AGENT_BACKEND: booleanString.optional(), - E2E_STABLE_MODEL_NAME: z.string().min(1).optional(), - E2E_STABLE_MODEL_PROVIDER: z.string().min(1).optional(), - E2E_STABLE_MODEL_TYPE: z.string().min(1).optional(), - }, -}) + throw new Error(`Invalid E2E env:\n${messages.join('\n')}`) + }, + runtimeEnv: { + CUCUMBER_HEADLESS: process.env.CUCUMBER_HEADLESS, + E2E_ADMIN_EMAIL: process.env.E2E_ADMIN_EMAIL, + E2E_ADMIN_NAME: process.env.E2E_ADMIN_NAME, + E2E_ADMIN_PASSWORD: process.env.E2E_ADMIN_PASSWORD, + E2E_AGENT_BACKEND_PORT: process.env.E2E_AGENT_BACKEND_PORT, + E2E_AGENT_BACKEND_URL: process.env.E2E_AGENT_BACKEND_URL, + E2E_API_URL: process.env.E2E_API_URL, + E2E_BASE_URL: process.env.E2E_BASE_URL, + E2E_BROKEN_MODEL_NAME: process.env.E2E_BROKEN_MODEL_NAME, + E2E_BROKEN_MODEL_PROVIDER: process.env.E2E_BROKEN_MODEL_PROVIDER, + E2E_BROKEN_MODEL_TYPE: process.env.E2E_BROKEN_MODEL_TYPE, + E2E_CUCUMBER_TAGS: process.env.E2E_CUCUMBER_TAGS, + E2E_EXTERNAL_RUNTIME_SEED_SPECS: process.env.E2E_EXTERNAL_RUNTIME_SEED_SPECS, + E2E_EXTERNAL_RUNTIME_TAGS: process.env.E2E_EXTERNAL_RUNTIME_TAGS, + E2E_FORCE_WEB_BUILD: process.env.E2E_FORCE_WEB_BUILD, + E2E_INIT_PASSWORD: process.env.E2E_INIT_PASSWORD, + E2E_MARKETPLACE_API_URL: process.env.E2E_MARKETPLACE_API_URL, + E2E_MARKETPLACE_PLUGIN_IDS: process.env.E2E_MARKETPLACE_PLUGIN_IDS, + E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS: + process.env.E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS, + E2E_MODEL_PROVIDER_CREDENTIALS_JSON: process.env.E2E_MODEL_PROVIDER_CREDENTIALS_JSON, + E2E_OAUTH_TOOL_CREDENTIAL_ID: process.env.E2E_OAUTH_TOOL_CREDENTIAL_ID, + E2E_OAUTH_TOOL_NAME: process.env.E2E_OAUTH_TOOL_NAME, + E2E_OAUTH_TOOL_PROVIDER: process.env.E2E_OAUTH_TOOL_PROVIDER, + E2E_REUSE_WEB_SERVER: process.env.E2E_REUSE_WEB_SERVER, + E2E_SLOW_MO: process.env.E2E_SLOW_MO, + E2E_SHELLCTL_AUTH_TOKEN: process.env.E2E_SHELLCTL_AUTH_TOKEN, + E2E_SHELLCTL_CONTAINER_NAME: process.env.E2E_SHELLCTL_CONTAINER_NAME, + E2E_SHELLCTL_IMAGE: process.env.E2E_SHELLCTL_IMAGE, + E2E_SHELLCTL_PORT: process.env.E2E_SHELLCTL_PORT, + E2E_SHELLCTL_URL: process.env.E2E_SHELLCTL_URL, + E2E_START_AGENT_BACKEND: process.env.E2E_START_AGENT_BACKEND, + E2E_STABLE_MODEL_NAME: process.env.E2E_STABLE_MODEL_NAME, + E2E_STABLE_MODEL_PROVIDER: process.env.E2E_STABLE_MODEL_PROVIDER, + E2E_STABLE_MODEL_TYPE: process.env.E2E_STABLE_MODEL_TYPE, + }, + server: { + CUCUMBER_HEADLESS: booleanString.optional(), + E2E_ADMIN_EMAIL: z.email().optional(), + E2E_ADMIN_NAME: z.string().min(1).optional(), + E2E_ADMIN_PASSWORD: z.string().min(1).optional(), + E2E_AGENT_BACKEND_PORT: z.coerce.number().int().positive().max(65535).optional(), + E2E_AGENT_BACKEND_URL: z.url().optional(), + E2E_API_URL: z.url().optional(), + E2E_BASE_URL: z.url().optional(), + E2E_BROKEN_MODEL_NAME: z.string().min(1).optional(), + E2E_BROKEN_MODEL_PROVIDER: z.string().min(1).optional(), + E2E_BROKEN_MODEL_TYPE: z.string().min(1).optional(), + E2E_CUCUMBER_TAGS: z.string().min(1).optional(), + E2E_EXTERNAL_RUNTIME_SEED_SPECS: z.string().min(1).optional(), + E2E_EXTERNAL_RUNTIME_TAGS: z.string().min(1).optional(), + E2E_FORCE_WEB_BUILD: z.literal('1').optional(), + E2E_INIT_PASSWORD: z.string().min(1).optional(), + E2E_MARKETPLACE_API_URL: z.url().optional(), + E2E_MARKETPLACE_PLUGIN_IDS: z.string().min(1).optional(), + E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS: z.string().min(1).optional(), + E2E_MODEL_PROVIDER_CREDENTIALS_JSON: jsonObjectString.optional(), + E2E_OAUTH_TOOL_CREDENTIAL_ID: z.string().min(1).optional(), + E2E_OAUTH_TOOL_NAME: z.string().min(1).optional(), + E2E_OAUTH_TOOL_PROVIDER: z.string().min(1).optional(), + E2E_REUSE_WEB_SERVER: booleanString.optional(), + E2E_SLOW_MO: z.coerce.number().nonnegative().optional(), + E2E_SHELLCTL_AUTH_TOKEN: z.string().min(1).optional(), + E2E_SHELLCTL_CONTAINER_NAME: z.string().min(1).optional(), + E2E_SHELLCTL_IMAGE: z.string().min(1).optional(), + E2E_SHELLCTL_PORT: z.coerce.number().int().positive().max(65535).optional(), + E2E_SHELLCTL_URL: z.url().optional(), + E2E_START_AGENT_BACKEND: booleanString.optional(), + E2E_STABLE_MODEL_NAME: z.string().min(1).optional(), + E2E_STABLE_MODEL_PROVIDER: z.string().min(1).optional(), + E2E_STABLE_MODEL_TYPE: z.string().min(1).optional(), + }, + }) diff --git a/e2e/scripts/prepare-external-runtime.ts b/e2e/scripts/prepare-external-runtime.ts index e1800f3de4130c..a55b65365c3dcc 100644 --- a/e2e/scripts/prepare-external-runtime.ts +++ b/e2e/scripts/prepare-external-runtime.ts @@ -6,12 +6,14 @@ const defaultExternalRuntimeSeedSpecs = 'agent-v2:external-runtime' const parseSeedSpecs = (value: string) => value .split(',') - .map(entry => entry.trim()) + .map((entry) => entry.trim()) .filter(Boolean) .map((entry) => { - const [pack, profile = 'full'] = entry.split(':').map(part => part.trim()) + const [pack, profile = 'full'] = entry.split(':').map((part) => part.trim()) if (!pack) - throw new Error(`Invalid external runtime seed spec "${entry}". Expected "pack" or "pack:profile".`) + throw new Error( + `Invalid external runtime seed spec "${entry}". Expected "pack" or "pack:profile".`, + ) return { pack, profile } }) diff --git a/e2e/scripts/run-cucumber.ts b/e2e/scripts/run-cucumber.ts index 70b0c34902ac39..fd58c6cf028fb0 100644 --- a/e2e/scripts/run-cucumber.ts +++ b/e2e/scripts/run-cucumber.ts @@ -42,18 +42,17 @@ const parseArgs = (argv: string[]): RunOptions => { } const hasCustomTags = (forwardArgs: string[]) => - forwardArgs.some(arg => arg === '--tags' || arg.startsWith('--tags=')) + forwardArgs.some((arg) => arg === '--tags' || arg.startsWith('--tags=')) -const fullNonExternalTags = 'not @skip and not @preview and not @external-model and not @external-tool' +const fullNonExternalTags = + 'not @skip and not @preview and not @external-model and not @external-tool' const isTruthyEnv = (value: string | undefined) => value === '1' || value === 'true' const shouldStartAgentBackend = () => { - if (isTruthyEnv(process.env.E2E_START_AGENT_BACKEND)) - return true + if (isTruthyEnv(process.env.E2E_START_AGENT_BACKEND)) return true - if (process.env.E2E_AGENT_BACKEND_URL || process.env.AGENT_BACKEND_BASE_URL) - return false + if (process.env.E2E_AGENT_BACKEND_URL || process.env.AGENT_BACKEND_BASE_URL) return false return false } @@ -61,11 +60,7 @@ const shouldStartAgentBackend = () => { const readLogTail = async (logFilePath: string) => { const content = await readFile(logFilePath, 'utf8').catch(() => '') - return content - .trim() - .split(/\r?\n/) - .slice(-20) - .join('\n') + return content.trim().split(/\r?\n/).slice(-20).join('\n') } const waitForUnexpectedProcessExit = async ( @@ -83,17 +78,12 @@ const waitForUnexpectedProcessExit = async ( childProcess.once('exit', () => resolve()) }) - if (shouldIgnoreExit()) - return + if (shouldIgnoreExit()) return const logTail = await readLogTail(logFilePath) - const logTailMessage = logTail - ? `\n\nLast ${label} log lines:\n${logTail}` - : '' + const logTailMessage = logTail ? `\n\nLast ${label} log lines:\n${logTail}` : '' - throw new Error( - `${label} exited before becoming ready. See ${logFilePath}.${logTailMessage}`, - ) + throw new Error(`${label} exited before becoming ready. See ${logFilePath}.${logTailMessage}`) } const main = async () => { @@ -102,11 +92,9 @@ const main = async () => { const resetStateForRun = full const startAgentBackendForRun = shouldStartAgentBackend() - if (resetStateForRun) - await resetState() + if (resetStateForRun) await resetState() - if (startMiddlewareForRun) - await startMiddleware() + if (startMiddlewareForRun) await startMiddleware() const cucumberReportDir = path.join(e2eDir, 'cucumber-report') const logDir = path.join(e2eDir, '.logs') @@ -171,8 +159,7 @@ const main = async () => { if (startMiddlewareForRun) { try { await stopMiddleware() - } - catch { + } catch { // Cleanup should continue even if middleware shutdown fails. } } @@ -200,16 +187,14 @@ const main = async () => { waitForUrl(`http://127.0.0.1:${shellctlPort}/openapi.json`, 180_000, 1_000), waitForUnexpectedProcessExit(shellctlProcess, () => !waitingForShellctl), ]) - } - catch (error) { + } catch (error) { if (error instanceof Error && error.message.includes('exited before becoming ready')) throw error throw new Error( `Shellctl sandbox did not become ready. See ${shellctlProcess.logFilePath}.`, ) - } - finally { + } finally { waitingForShellctl = false } } @@ -222,16 +207,12 @@ const main = async () => { waitForUrl(`http://127.0.0.1:${agentBackendPort}/openapi.json`, 180_000, 1_000), waitForUnexpectedProcessExit(difyAgentProcess, () => !waitingForAgentBackend), ]) - } - catch (error) { + } catch (error) { if (error instanceof Error && error.message.includes('exited before becoming ready')) throw error - throw new Error( - `Agent backend did not become ready. See ${difyAgentProcess.logFilePath}.`, - ) - } - finally { + throw new Error(`Agent backend did not become ready. See ${difyAgentProcess.logFilePath}.`) + } finally { waitingForAgentBackend = false } } @@ -242,14 +223,14 @@ const main = async () => { waitForUrl(`${apiURL}/health`, 180_000, 1_000), waitForUnexpectedProcessExit(apiProcess, () => !waitingForApi), ]) - } - catch (error) { + } catch (error) { if (error instanceof Error && error.message.includes('exited before becoming ready')) throw error - throw new Error(`API did not become ready at ${apiURL}/health. See ${apiProcess.logFilePath}.`) - } - finally { + throw new Error( + `API did not become ready at ${apiURL}/health. See ${apiProcess.logFilePath}.`, + ) + } finally { waitingForApi = false } @@ -285,8 +266,7 @@ const main = async () => { }) process.exitCode = result.exitCode - } - finally { + } finally { process.off('SIGINT', onTerminate) process.off('SIGTERM', onTerminate) await cleanup() diff --git a/e2e/scripts/run-external-runtime.ts b/e2e/scripts/run-external-runtime.ts index d5ea4a5ecb4c4a..831174c9caf740 100644 --- a/e2e/scripts/run-external-runtime.ts +++ b/e2e/scripts/run-external-runtime.ts @@ -1,7 +1,8 @@ import { e2eDir, isMainModule, runForegroundProcess } from './common' import './env-register' -const defaultExternalRuntimeTags = '(@external-model or @external-tool) and not @feature-gated and not @skip and not @preview' +const defaultExternalRuntimeTags = + '(@external-model or @external-tool) and not @feature-gated and not @skip and not @preview' const main = async () => { const tags = process.env.E2E_EXTERNAL_RUNTIME_TAGS?.trim() || defaultExternalRuntimeTags @@ -13,5 +14,4 @@ const main = async () => { }) } -if (isMainModule(import.meta.url)) - void main() +if (isMainModule(import.meta.url)) void main() diff --git a/e2e/scripts/seed.ts b/e2e/scripts/seed.ts index 82d5dddf22cca4..3f1b44288348de 100644 --- a/e2e/scripts/seed.ts +++ b/e2e/scripts/seed.ts @@ -35,10 +35,8 @@ const parseArgs = (argv: string[]): SeedOptions => { options.pack = arg.slice('--pack='.length) continue } - if (arg === '--dry-run') - options.dryRun = true - if (arg === '--allow-blocked') - options.allowBlocked = true + if (arg === '--dry-run') options.dryRun = true + if (arg === '--allow-blocked') options.allowBlocked = true if (arg === '--profile') { options.profile = argv[index + 1] || options.profile continue @@ -53,8 +51,7 @@ const parseArgs = (argv: string[]): SeedOptions => { } const getTasks = (pack: string, profile: string) => { - if (pack === 'agent-v2') - return createAgentV2SeedTasks(profile) + if (pack === 'agent-v2') return createAgentV2SeedTasks(profile) throw new Error(`Unknown seed pack "${pack}".`) } @@ -63,8 +60,7 @@ const ensureAuth = async () => { const browser = await chromium.launch({ headless: true }) try { await ensureAuthenticatedState(browser, baseURL) - } - finally { + } finally { await browser.close() } } @@ -73,8 +69,7 @@ const startApiProcess = async (logDir: string) => { try { await waitForUrl(`${apiURL}/health`, 1_000, 250, 1_000) return undefined - } - catch { + } catch { // Start a local API process below. } @@ -89,8 +84,7 @@ const startApiProcess = async (logDir: string) => { try { await waitForUrl(`${apiURL}/health`, 180_000, 1_000) return apiProcess - } - catch (error) { + } catch (error) { await stopManagedProcess(apiProcess) throw error } @@ -139,11 +133,10 @@ const main = async () => { dryRun: options.dryRun, resources: new Map(), }) - const reportName = options.profile === 'full' - ? options.pack - : `${options.pack}-${options.profile}` + const reportName = + options.profile === 'full' ? options.pack : `${options.pack}-${options.profile}` const reportPath = await writeSeedReport(reportName, results) - const blockedCount = results.filter(result => result.status === 'blocked').length + const blockedCount = results.filter((result) => result.status === 'blocked').length console.warn(`[seed] report ${reportPath}`) if (blockedCount > 0 && !options.allowBlocked) { @@ -151,8 +144,7 @@ const main = async () => { `${blockedCount} seed task${blockedCount === 1 ? '' : 's'} blocked. Re-run with --allow-blocked only when partial readiness is intentional.`, ) } - } - finally { + } finally { await stopWebServer() await stopManagedProcess(celeryProcess) await stopManagedProcess(apiProcess) diff --git a/e2e/scripts/setup.ts b/e2e/scripts/setup.ts index 46f9362ce566cc..075994e16cd73b 100644 --- a/e2e/scripts/setup.ts +++ b/e2e/scripts/setup.ts @@ -82,12 +82,10 @@ const getApiEnvironment = async (): Promise<Record<string, string>> => { function getAgentBackendBaseUrl() { const explicitApiUrl = process.env.AGENT_BACKEND_BASE_URL?.trim() - if (explicitApiUrl) - return explicitApiUrl + if (explicitApiUrl) return explicitApiUrl const explicitE2EUrl = process.env.E2E_AGENT_BACKEND_URL?.trim() - if (explicitE2EUrl) - return explicitE2EUrl.replace(/\/$/, '') + if (explicitE2EUrl) return explicitE2EUrl.replace(/\/$/, '') if (process.env.E2E_START_AGENT_BACKEND === '1' || process.env.E2E_START_AGENT_BACKEND === 'true') return `http://${agentBackendHost}:${agentBackendPort}` @@ -101,39 +99,32 @@ const getAgentBackendEnvironment = async () => { return { DIFY_AGENT_INNER_API_KEY: - process.env.DIFY_AGENT_INNER_API_KEY - || process.env.INNER_API_KEY_FOR_PLUGIN - || process.env.PLUGIN_DIFY_INNER_API_KEY - || apiEnv.INNER_API_KEY_FOR_PLUGIN - || defaultInnerApiKeyForPlugin, - DIFY_AGENT_INNER_API_URL: process.env.DIFY_AGENT_INNER_API_URL || `http://${apiHost}:${apiPort}`, + process.env.DIFY_AGENT_INNER_API_KEY || + process.env.INNER_API_KEY_FOR_PLUGIN || + process.env.PLUGIN_DIFY_INNER_API_KEY || + apiEnv.INNER_API_KEY_FOR_PLUGIN || + defaultInnerApiKeyForPlugin, + DIFY_AGENT_INNER_API_URL: + process.env.DIFY_AGENT_INNER_API_URL || `http://${apiHost}:${apiPort}`, DIFY_AGENT_SERVER_SECRET_KEY: - process.env.DIFY_AGENT_SERVER_SECRET_KEY - || defaultAgentServerSecretKey, - DIFY_AGENT_STUB_API_BASE_URL: - process.env.DIFY_AGENT_STUB_API_BASE_URL - || agentStubApiBaseUrl, + process.env.DIFY_AGENT_SERVER_SECRET_KEY || defaultAgentServerSecretKey, + DIFY_AGENT_STUB_API_BASE_URL: process.env.DIFY_AGENT_STUB_API_BASE_URL || agentStubApiBaseUrl, DIFY_AGENT_PLUGIN_DAEMON_API_KEY: - process.env.DIFY_AGENT_PLUGIN_DAEMON_API_KEY - || process.env.PLUGIN_DAEMON_KEY - || apiEnv.PLUGIN_DAEMON_KEY - || defaultPluginDaemonKey, + process.env.DIFY_AGENT_PLUGIN_DAEMON_API_KEY || + process.env.PLUGIN_DAEMON_KEY || + apiEnv.PLUGIN_DAEMON_KEY || + defaultPluginDaemonKey, DIFY_AGENT_PLUGIN_DAEMON_URL: - process.env.DIFY_AGENT_PLUGIN_DAEMON_URL - || process.env.PLUGIN_DAEMON_URL - || 'http://127.0.0.1:5002', + process.env.DIFY_AGENT_PLUGIN_DAEMON_URL || + process.env.PLUGIN_DAEMON_URL || + 'http://127.0.0.1:5002', DIFY_AGENT_REDIS_PREFIX: process.env.DIFY_AGENT_REDIS_PREFIX || 'dify-agent-e2e', DIFY_AGENT_REDIS_URL: - process.env.DIFY_AGENT_REDIS_URL - || `redis://:${redisPassword}@127.0.0.1:6379/0`, + process.env.DIFY_AGENT_REDIS_URL || `redis://:${redisPassword}@127.0.0.1:6379/0`, DIFY_AGENT_SHELLCTL_AUTH_TOKEN: - process.env.DIFY_AGENT_SHELLCTL_AUTH_TOKEN - || process.env.E2E_SHELLCTL_AUTH_TOKEN - || '', + process.env.DIFY_AGENT_SHELLCTL_AUTH_TOKEN || process.env.E2E_SHELLCTL_AUTH_TOKEN || '', DIFY_AGENT_SHELLCTL_ENTRYPOINT: - process.env.DIFY_AGENT_SHELLCTL_ENTRYPOINT - || process.env.E2E_SHELLCTL_URL - || shellctlUrl, + process.env.DIFY_AGENT_SHELLCTL_ENTRYPOINT || process.env.E2E_SHELLCTL_URL || shellctlUrl, } } @@ -156,8 +147,7 @@ const getContainerHealth = async (containerId: string) => { stdio: 'pipe', }) - if (result.exitCode !== 0) - return '' + if (result.exitCode !== 0) return '' return result.stdout.trim() } @@ -183,8 +173,7 @@ const waitForDependency = async ({ try { await wait() - } - catch (error) { + } catch (error) { await printComposeLogs(services) throw error } @@ -267,7 +256,7 @@ export const ensureWebBuild = async () => { .then(() => true) .catch(() => false), readFile(webBuildStampPath, 'utf8') - .then(value => value.trim()) + .then((value) => value.trim()) .catch(() => ''), ]) @@ -275,8 +264,7 @@ export const ensureWebBuild = async () => { console.log('Reusing existing web build artifact.') return } - } - catch { + } catch { // Fall through to rebuild when the existing build cannot be verified. } @@ -307,9 +295,7 @@ export const startWeb = async () => { export const startApi = async () => { if (await isTcpPortReachable(apiHost, apiPort)) { const listenerDescription = await getTcpPortListenerDescription(apiPort) - const listenerMessage = listenerDescription - ? `\n\nPort listener:\n${listenerDescription}` - : '' + const listenerMessage = listenerDescription ? `\n\nPort listener:\n${listenerDescription}` : '' throw new Error( `Cannot start the E2E API server because ${apiHost}:${apiPort} is already in use.${listenerMessage}`, @@ -347,9 +333,7 @@ export const startApi = async () => { export const startAgentBackend = async () => { if (await isTcpPortReachable(agentBackendHost, agentBackendPort)) { const listenerDescription = await getTcpPortListenerDescription(agentBackendPort) - const listenerMessage = listenerDescription - ? `\n\nPort listener:\n${listenerDescription}` - : '' + const listenerMessage = listenerDescription ? `\n\nPort listener:\n${listenerDescription}` : '' throw new Error( `Cannot start the E2E Agent backend because ${agentBackendHost}:${agentBackendPort} is already in use.${listenerMessage}`, @@ -384,8 +368,7 @@ const ensureShellctlSandboxImage = async () => { stdio: 'pipe', }) - if (inspectResult.exitCode === 0 && process.env.E2E_FORCE_SHELLCTL_BUILD !== '1') - return + if (inspectResult.exitCode === 0 && process.env.E2E_FORCE_SHELLCTL_BUILD !== '1') return await runCommandOrThrow({ command: 'docker', @@ -404,9 +387,7 @@ const ensureShellctlSandboxImage = async () => { export const startShellctlSandbox = async () => { if (await isTcpPortReachable(shellctlHost, shellctlPort)) { const listenerDescription = await getTcpPortListenerDescription(shellctlPort) - const listenerMessage = listenerDescription - ? `\n\nPort listener:\n${listenerDescription}` - : '' + const listenerMessage = listenerDescription ? `\n\nPort listener:\n${listenerDescription}` : '' throw new Error( `Cannot start the E2E shellctl sandbox because ${shellctlHost}:${shellctlPort} is already in use.${listenerMessage}`, @@ -479,8 +460,7 @@ export const resetState = async () => { console.log('Stopping middleware services...') try { await stopMiddleware() - } - catch { + } catch { // Reset should continue even if middleware is already stopped. } @@ -494,7 +474,7 @@ export const resetState = async () => { console.log('Removing E2E local state...') await Promise.all( - e2eStatePaths.map(targetPath => rm(targetPath, { force: true, recursive: true })), + e2eStatePaths.map((targetPath) => rm(targetPath, { force: true, recursive: true })), ) console.log('E2E state reset complete.') @@ -573,7 +553,9 @@ export const startMiddleware = async () => { } const printUsage = () => { - console.log('Usage: tsx ./scripts/setup.ts <reset|middleware-up|middleware-down|shellctl-sandbox|agent-backend|api|celery [--queues queues]|web>') + console.log( + 'Usage: tsx ./scripts/setup.ts <reset|middleware-up|middleware-down|shellctl-sandbox|agent-backend|api|celery [--queues queues]|web>', + ) } const main = async () => { diff --git a/e2e/support/agent-configure.ts b/e2e/support/agent-configure.ts index 9adf51a59eae94..d5a3f98566e10d 100644 --- a/e2e/support/agent-configure.ts +++ b/e2e/support/agent-configure.ts @@ -2,7 +2,7 @@ import type { Page } from '@playwright/test' import { expect } from '@playwright/test' export async function waitForAgentConfigureAutosaved(page: Page) { - await expect( - page.getByRole('status', { name: /Saved/i }).first(), - ).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('status', { name: /Saved/i }).first()).toBeVisible({ + timeout: 30_000, + }) } diff --git a/e2e/support/api.ts b/e2e/support/api.ts index ae5706837a209c..444fa2ff35a9be 100644 --- a/e2e/support/api.ts +++ b/e2e/support/api.ts @@ -6,12 +6,12 @@ import { apiURL } from '../test-env' import { assertE2EResourceName, createE2EResourceName } from './naming' type StorageState = { - cookies: Array<{ name: string, value: string }> + cookies: Array<{ name: string; value: string }> } export async function createApiContext() { const state = JSON.parse(await readFile(authStatePath, 'utf8')) as StorageState - const csrfToken = state.cookies.find(c => c.name.endsWith('csrf_token'))?.value ?? '' + const csrfToken = state.cookies.find((c) => c.name.endsWith('csrf_token'))?.value ?? '' return request.newContext({ baseURL: apiURL, @@ -21,8 +21,7 @@ export async function createApiContext() { } export async function expectApiResponseOK(response: APIResponse, action: string): Promise<void> { - if (response.ok()) - return + if (response.ok()) return const body = await response.text().catch(() => '') throw new Error(`${action} failed with ${response.status()} ${response.statusText()}: ${body}`) @@ -64,8 +63,7 @@ export async function createTestApp( await expectApiResponseOK(response, `Create ${mode} app ${name}`) const body = (await response.json()) as AppSeed return body - } - finally { + } finally { await ctx.dispose() } } @@ -76,8 +74,7 @@ export async function getWorkflowDraft(appId: string): Promise<WorkflowDraft> { const response = await ctx.get(`/console/api/apps/${appId}/workflows/draft`) await expectApiResponseOK(response, `Get workflow draft for ${appId}`) return (await response.json()) as WorkflowDraft - } - finally { + } finally { await ctx.dispose() } } @@ -104,8 +101,7 @@ export async function syncMinimalWorkflowDraft(appId: string): Promise<void> { conversation_variables: [], }, }) - } - finally { + } finally { await ctx.dispose() } } @@ -150,8 +146,7 @@ export async function syncAgentV2WorkflowDraft(appId: string, agentId: string): }, }) await expectApiResponseOK(response, `Sync Agent v2 workflow draft for ${appId}`) - } - finally { + } finally { await ctx.dispose() } } @@ -160,8 +155,7 @@ export async function deleteTestApp(id: string): Promise<void> { const ctx = await createApiContext() try { await ctx.delete(`/console/api/apps/${id}`) - } - finally { + } finally { await ctx.dispose() } } @@ -208,8 +202,7 @@ export async function syncRunnableWorkflowDraft(appId: string): Promise<void> { conversation_variables: [], }, }) - } - finally { + } finally { await ctx.dispose() } } @@ -220,15 +213,14 @@ export async function publishWorkflowApp(appId: string): Promise<void> { await ctx.post(`/console/api/apps/${appId}/workflows/publish`, { data: { marked_name: '', marked_comment: '' }, }) - } - finally { + } finally { await ctx.dispose() } } export type AppDetailWithSite = { mode?: string - site: { access_token: string, app_base_url: string, enable_site: boolean } + site: { access_token: string; app_base_url: string; enable_site: boolean } } export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { @@ -254,8 +246,7 @@ export async function setAppSiteEnabled( const detailResponse = await ctx.get(`/console/api/apps/${appId}`) await expectApiResponseOK(detailResponse, `Get app site detail for ${appId}`) return (await detailResponse.json()) as AppDetailWithSite - } - finally { + } finally { await ctx.dispose() } } diff --git a/e2e/support/apps.ts b/e2e/support/apps.ts index 07d0f15716ade5..95f0687f647271 100644 --- a/e2e/support/apps.ts +++ b/e2e/support/apps.ts @@ -1,16 +1,13 @@ import type { Page } from '@playwright/test' import { expect } from '@playwright/test' -const getExpectOptions = (timeout?: number) => - timeout === undefined ? undefined : { timeout } +const getExpectOptions = (timeout?: number) => (timeout === undefined ? undefined : { timeout }) export const waitForAppsConsole = async (page: Page, timeout?: number) => { const options = getExpectOptions(timeout) await expect(page).toHaveURL(/\/apps(?:\?.*)?$/, options) - await expect(page.getByRole('heading', { name: 'Studio' })).toBeVisible( - options, - ) + await expect(page.getByRole('heading', { name: 'Studio' })).toBeVisible(options) } export const openBlankAppCreation = async (page: Page) => { diff --git a/e2e/support/datasets.ts b/e2e/support/datasets.ts index 4c0851db26d5f3..196b335af326cc 100644 --- a/e2e/support/datasets.ts +++ b/e2e/support/datasets.ts @@ -5,8 +5,7 @@ export async function deleteTestDataset(datasetId: string): Promise<void> { try { const response = await ctx.delete(`/console/api/datasets/${datasetId}`) await expectApiResponseOK(response, `Delete dataset ${datasetId}`) - } - finally { + } finally { await ctx.dispose() } } diff --git a/e2e/support/home.ts b/e2e/support/home.ts index 83fe9b475c0f7b..f6729eb277d925 100644 --- a/e2e/support/home.ts +++ b/e2e/support/home.ts @@ -1,12 +1,15 @@ import type { Page } from '@playwright/test' import { expect } from '@playwright/test' -const getExpectOptions = (timeout?: number) => - timeout === undefined ? undefined : { timeout } +const getExpectOptions = (timeout?: number) => (timeout === undefined ? undefined : { timeout }) export const waitForConsoleHome = async (page: Page, timeout?: number) => { const options = getExpectOptions(timeout) await expect.poll(() => new URL(page.url()).pathname, options).toBe('/') - await expect(page.getByRole('link', { name: 'Home' })).toHaveAttribute('aria-current', 'page', options) + await expect(page.getByRole('link', { name: 'Home' })).toHaveAttribute( + 'aria-current', + 'page', + options, + ) } diff --git a/e2e/support/marketplace-plugins.ts b/e2e/support/marketplace-plugins.ts index cd12778b06bb6d..f0325111768ba4 100644 --- a/e2e/support/marketplace-plugins.ts +++ b/e2e/support/marketplace-plugins.ts @@ -2,12 +2,7 @@ import type { SeedContext, SeedResult } from './seed' import { Buffer } from 'node:buffer' import { createApiContext, expectApiResponseOK } from './api' import { sleep } from './process' -import { - blocked, - created, - skipped, - verified, -} from './seed' +import { blocked, created, skipped, verified } from './seed' type LatestPlugin = { unique_identifier?: string @@ -51,7 +46,7 @@ const defaultMarketplaceApiUrl = 'https://marketplace.dify.ai' const parseListEnv = (envName: string) => process.env[envName] ?.split(/[\n,]/) - .map(item => item.trim()) + .map((item) => item.trim()) .filter(Boolean) ?? [] const unique = (values: string[]) => Array.from(new Set(values)) @@ -60,14 +55,13 @@ const getPluginId = (pluginUniqueIdentifier: string) => pluginUniqueIdentifier.split(':')[0]?.trim() || pluginUniqueIdentifier.trim() const findPlaceholderPluginIdentifier = (pluginUniqueIdentifiers: string[]) => - pluginUniqueIdentifiers.find(identifier => identifier.includes('replace-with-')) + pluginUniqueIdentifiers.find((identifier) => identifier.includes('replace-with-')) const withoutPlaceholderPluginIdentifiers = (pluginUniqueIdentifiers: string[]) => - pluginUniqueIdentifiers.filter(identifier => !identifier.includes('replace-with-')) + pluginUniqueIdentifiers.filter((identifier) => !identifier.includes('replace-with-')) const resolveLatestPluginIdentifiers = async (pluginIds: string[]) => { - if (pluginIds.length === 0) - return { identifiers: [] as string[], missing: [] as string[] } + if (pluginIds.length === 0) return { identifiers: [] as string[], missing: [] as string[] } const ctx = await createApiContext() try { @@ -81,33 +75,31 @@ const resolveLatestPluginIdentifiers = async (pluginIds: string[]) => { for (const pluginId of pluginIds) { const latest = body.versions?.[pluginId] - if (latest?.unique_identifier) - identifiers.push(latest.unique_identifier) - else - missing.push(pluginId) + if (latest?.unique_identifier) identifiers.push(latest.unique_identifier) + else missing.push(pluginId) } return { identifiers, missing } - } - finally { + } finally { await ctx.dispose() } } const listInstalledPlugins = async (pluginIds: string[]) => { - if (pluginIds.length === 0) - return [] as PluginInstallation[] + if (pluginIds.length === 0) return [] as PluginInstallation[] const ctx = await createApiContext() try { - const response = await ctx.post('/console/api/workspaces/current/plugin/list/installations/ids', { - data: { plugin_ids: pluginIds }, - }) + const response = await ctx.post( + '/console/api/workspaces/current/plugin/list/installations/ids', + { + data: { plugin_ids: pluginIds }, + }, + ) await expectApiResponseOK(response, 'List installed marketplace plugins') const body = (await response.json()) as { plugins?: PluginInstallation[] } return body.plugins ?? [] - } - finally { + } finally { await ctx.dispose() } } @@ -123,18 +115,19 @@ const waitForPluginInstallTask = async (taskId: string, timeoutMs = 300_000) => await expectApiResponseOK(response, `Fetch marketplace plugin install task ${taskId}`) const body = (await response.json()) as { task?: PluginInstallTask } lastTask = body.task - } - finally { + } finally { await ctx.dispose() } - if (lastTask?.status === terminalSuccessTaskStatus) - return { ok: true as const, task: lastTask } + if (lastTask?.status === terminalSuccessTaskStatus) return { ok: true as const, task: lastTask } if (lastTask?.status === terminalFailedTaskStatus) { const details = lastTask.plugins - ?.filter(plugin => plugin.status === terminalFailedTaskStatus) - .map(plugin => `${plugin.plugin_id ?? plugin.plugin_unique_identifier ?? 'unknown'}: ${plugin.message ?? 'failed'}`) + ?.filter((plugin) => plugin.status === terminalFailedTaskStatus) + .map( + (plugin) => + `${plugin.plugin_id ?? plugin.plugin_unique_identifier ?? 'unknown'}: ${plugin.message ?? 'failed'}`, + ) .join('; ') return { ok: false as const, reason: details || 'Plugin install task failed.' } } @@ -144,7 +137,10 @@ const waitForPluginInstallTask = async (taskId: string, timeoutMs = 300_000) => continue } - return { ok: false as const, reason: `Plugin install task ended with status ${lastTask.status}.` } + return { + ok: false as const, + reason: `Plugin install task ended with status ${lastTask.status}.`, + } } return { ok: false as const, reason: `Plugin install task did not finish within ${timeoutMs}ms.` } @@ -158,14 +154,16 @@ const installMarketplacePlugins = async (pluginUniqueIdentifiers: string[]) => { }) await expectApiResponseOK(response, 'Install marketplace plugins') return (await response.json()) as PluginInstallStartResponse - } - finally { + } finally { await ctx.dispose() } } const getMarketplaceDownloadUrl = (pluginUniqueIdentifier: string) => { - const url = new URL('/api/v1/plugins/download', process.env.E2E_MARKETPLACE_API_URL || defaultMarketplaceApiUrl) + const url = new URL( + '/api/v1/plugins/download', + process.env.E2E_MARKETPLACE_API_URL || defaultMarketplaceApiUrl, + ) url.searchParams.set('unique_identifier', pluginUniqueIdentifier) return url.toString() } @@ -194,14 +192,18 @@ const uploadMarketplacePluginPackage = async (pluginUniqueIdentifier: string) => }, }, }) - await expectApiResponseOK(response, `Upload marketplace package ${getPluginId(pluginUniqueIdentifier)}`) + await expectApiResponseOK( + response, + `Upload marketplace package ${getPluginId(pluginUniqueIdentifier)}`, + ) const body = (await response.json()) as { unique_identifier?: string } if (!body.unique_identifier) - throw new Error(`Upload marketplace package ${getPluginId(pluginUniqueIdentifier)} did not return a unique identifier.`) + throw new Error( + `Upload marketplace package ${getPluginId(pluginUniqueIdentifier)} did not return a unique identifier.`, + ) return body.unique_identifier - } - finally { + } finally { await ctx.dispose() } } @@ -214,8 +216,7 @@ const installLocalPluginPackages = async (pluginUniqueIdentifiers: string[]) => }) await expectApiResponseOK(response, 'Install uploaded plugin packages') return (await response.json()) as PluginInstallStartResponse - } - finally { + } finally { await ctx.dispose() } } @@ -226,16 +227,18 @@ const shouldFallbackToLocalPackageInstall = (error: string) => const installMarketplacePluginsWithFallback = async (pluginUniqueIdentifiers: string[]) => { try { return await installMarketplacePlugins(pluginUniqueIdentifiers) - } - catch (error) { + } catch (error) { const message = error instanceof Error ? error.message : String(error) - if (!shouldFallbackToLocalPackageInstall(message)) - throw error + if (!shouldFallbackToLocalPackageInstall(message)) throw error - console.warn('[seed] marketplace install download failed in API process; falling back to local package upload.') + console.warn( + '[seed] marketplace install download failed in API process; falling back to local package upload.', + ) const uploadedPluginUniqueIdentifiers: string[] = [] for (const pluginUniqueIdentifier of pluginUniqueIdentifiers) - uploadedPluginUniqueIdentifiers.push(await uploadMarketplacePluginPackage(pluginUniqueIdentifier)) + uploadedPluginUniqueIdentifiers.push( + await uploadMarketplacePluginPackage(pluginUniqueIdentifier), + ) return await installLocalPluginPackages(uploadedPluginUniqueIdentifiers) } @@ -247,10 +250,16 @@ export const bootstrapMarketplacePlugins = async ( ): Promise<SeedResult> => { const requestedPluginIds = parseListEnv(config.pluginIdsEnv) const rawExactPluginUniqueIdentifiers = unique(parseListEnv(config.pluginUniqueIdentifiersEnv)) - const exactPluginUniqueIdentifiers = withoutPlaceholderPluginIdentifiers(rawExactPluginUniqueIdentifiers) - const placeholderPluginIdentifier = findPlaceholderPluginIdentifier(rawExactPluginUniqueIdentifiers) + const exactPluginUniqueIdentifiers = withoutPlaceholderPluginIdentifiers( + rawExactPluginUniqueIdentifiers, + ) + const placeholderPluginIdentifier = findPlaceholderPluginIdentifier( + rawExactPluginUniqueIdentifiers, + ) if (placeholderPluginIdentifier) { - console.warn(`[seed] ignoring example marketplace package placeholder for ${getPluginId(placeholderPluginIdentifier)}.`) + console.warn( + `[seed] ignoring example marketplace package placeholder for ${getPluginId(placeholderPluginIdentifier)}.`, + ) } const pluginIds = unique( @@ -263,8 +272,8 @@ export const bootstrapMarketplacePlugins = async ( if (pluginIds.length > 0) { const installedPlugins = await listInstalledPlugins(pluginIds) - const installedPluginIds = new Set(installedPlugins.map(plugin => plugin.plugin_id)) - if (pluginIds.every(pluginId => installedPluginIds.has(pluginId))) { + const installedPluginIds = new Set(installedPlugins.map((plugin) => plugin.plugin_id)) + if (pluginIds.every((pluginId) => installedPluginIds.has(pluginId))) { return verified(config.title, { id: pluginIds.join(','), kind: 'marketplace-plugins', @@ -292,9 +301,9 @@ export const bootstrapMarketplacePlugins = async ( return skipped(config.title, 'No marketplace plugins were requested.') const installedPlugins = await listInstalledPlugins(requiredPluginIds) - const installedPluginIds = new Set(installedPlugins.map(plugin => plugin.plugin_id)) + const installedPluginIds = new Set(installedPlugins.map((plugin) => plugin.plugin_id)) const missingPluginUniqueIdentifiers = requiredPluginUniqueIdentifiers.filter( - identifier => !installedPluginIds.has(getPluginId(identifier)), + (identifier) => !installedPluginIds.has(getPluginId(identifier)), ) const resource = { id: requiredPluginIds.join(','), @@ -302,8 +311,7 @@ export const bootstrapMarketplacePlugins = async ( name: config.title, } - if (missingPluginUniqueIdentifiers.length === 0) - return verified(config.title, resource) + if (missingPluginUniqueIdentifiers.length === 0) return verified(config.title, resource) if (context.dryRun) { return skipped( @@ -312,22 +320,20 @@ export const bootstrapMarketplacePlugins = async ( ) } - const startedTask = await installMarketplacePluginsWithFallback(missingPluginUniqueIdentifiers).catch((error) => { + const startedTask = await installMarketplacePluginsWithFallback( + missingPluginUniqueIdentifiers, + ).catch((error) => { return { error: error instanceof Error ? error.message : String(error) } }) - if ('error' in startedTask) - return blocked(config.title, startedTask.error) + if ('error' in startedTask) return blocked(config.title, startedTask.error) - if (startedTask.all_installed) - return verified(config.title, resource) + if (startedTask.all_installed) return verified(config.title, resource) const taskId = startedTask.task_id || startedTask.task?.id - if (!taskId) - return blocked(config.title, 'Marketplace plugin install did not return a task id.') + if (!taskId) return blocked(config.title, 'Marketplace plugin install did not return a task id.') const taskResult = await waitForPluginInstallTask(taskId) - if (!taskResult.ok) - return blocked(config.title, taskResult.reason) + if (!taskResult.ok) return blocked(config.title, taskResult.reason) return created(config.title, resource) } diff --git a/e2e/support/naming.ts b/e2e/support/naming.ts index b830e599d2fb5e..71834eed27d0e3 100644 --- a/e2e/support/naming.ts +++ b/e2e/support/naming.ts @@ -4,8 +4,7 @@ export const createE2EResourceName = (resource: string, qualifier?: string) => { } export function assertE2EResourceName(name: string, resource: string) { - if (name.startsWith('E2E ')) - return + if (name.startsWith('E2E ')) return throw new Error(`${resource} test resources must use an "E2E " name prefix: ${name}`) } diff --git a/e2e/support/process.ts b/e2e/support/process.ts index 965f94e3be644b..5921c6c9be58c0 100644 --- a/e2e/support/process.ts +++ b/e2e/support/process.ts @@ -64,14 +64,11 @@ export const waitForUrl = async ( const response = await fetch(url, { signal: controller.signal, }) - if (response.ok) - return - } - finally { + if (response.ok) return + } finally { clearTimeout(timeout) } - } - catch { + } catch { // Keep polling until timeout. } @@ -144,8 +141,7 @@ const waitForProcessExit = (childProcess: ChildProcess, timeoutMs: number) => const signalManagedProcess = (childProcess: ChildProcess, signal: NodeJS.Signals) => { const { pid } = childProcess - if (!pid) - return + if (!pid) return try { if (process.platform !== 'win32') { @@ -154,15 +150,13 @@ const signalManagedProcess = (childProcess: ChildProcess, signal: NodeJS.Signals } childProcess.kill(signal) - } - catch { + } catch { // Best-effort shutdown. Cleanup continues even when the process is already gone. } } export const stopManagedProcess = async (managedProcess?: ManagedProcess) => { - if (!managedProcess) - return + if (!managedProcess) return const { childProcess, logStream } = managedProcess diff --git a/e2e/support/seed.ts b/e2e/support/seed.ts index 1e89cbcee554ec..eddac03dd460e1 100644 --- a/e2e/support/seed.ts +++ b/e2e/support/seed.ts @@ -66,8 +66,7 @@ export async function runSeedTasks(tasks: SeedTask[], context: SeedContext) { for (const task of tasks) { const result = await task.run(context) results.push(result) - if (result.resource) - context.resources.set(task.id, result.resource) + if (result.resource) context.resources.set(task.id, result.resource) const suffix = result.reason ? `: ${result.reason}` : '' console.warn(`[seed] ${result.status.padEnd(8)} ${result.title}${suffix}`) @@ -81,11 +80,15 @@ export async function writeSeedReport(pack: string, results: SeedResult[]) { const reportPath = path.join(reportDir, `${pack}.json`) await writeFile( reportPath, - `${JSON.stringify({ - generated_at: new Date().toISOString(), - pack, - results, - }, null, 2)}\n`, + `${JSON.stringify( + { + generated_at: new Date().toISOString(), + pack, + results, + }, + null, + 2, + )}\n`, 'utf8', ) diff --git a/e2e/support/tools.ts b/e2e/support/tools.ts index c4bee2278bb3ad..11ea82d4dcd8e0 100644 --- a/e2e/support/tools.ts +++ b/e2e/support/tools.ts @@ -16,8 +16,7 @@ export async function deleteBuiltinToolCredential( response, `Delete built-in tool credential ${credentialId} for ${provider}`, ) - } - finally { + } finally { await ctx.dispose() } } diff --git a/e2e/support/web-server.ts b/e2e/support/web-server.ts index 819f7effe38b9f..ad5d5d916a1daa 100644 --- a/e2e/support/web-server.ts +++ b/e2e/support/web-server.ts @@ -34,8 +34,7 @@ export const startWebServer = async ({ }: WebServerStartOptions) => { const { host, port } = getUrlHostAndPort(baseURL) - if (reuseExistingServer && (await isPortReachable(host, port))) - return + if (reuseExistingServer && (await isPortReachable(host, port))) return activeProcess = await startLoggedProcess({ command, @@ -50,8 +49,7 @@ export const startWebServer = async ({ startupError = error }) activeProcess.childProcess.once('exit', (code, signal) => { - if (startupError) - return + if (startupError) return startupError = new Error( `Web server exited before readiness (code: ${code ?? 'unknown'}, signal: ${signal ?? 'none'}).`, @@ -69,8 +67,7 @@ export const startWebServer = async ({ try { await waitForUrl(baseURL, 1_000, 250, 1_000) return - } - catch { + } catch { // Continue polling until timeout or child exit. } } diff --git a/e2e/tests/service-api-sse.test.ts b/e2e/tests/service-api-sse.test.ts index fb57d1b6d44a77..ac57b46d1da096 100644 --- a/e2e/tests/service-api-sse.test.ts +++ b/e2e/tests/service-api-sse.test.ts @@ -16,13 +16,13 @@ const encodeChunks = (text: string, splitPoints: number[]) => { return chunks } -const streamFromChunks = (chunks: Uint8Array<ArrayBuffer>[]) => new ReadableStream<Uint8Array<ArrayBuffer>>({ - start(controller) { - for (const chunk of chunks) - controller.enqueue(chunk) - controller.close() - }, -}) +const streamFromChunks = (chunks: Uint8Array<ArrayBuffer>[]) => + new ReadableStream<Uint8Array<ArrayBuffer>>({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk) + controller.close() + }, + }) describe('consumeServiceApiSse', () => { it('parses UTF-8 events split across byte chunks and multiline data fields', async () => { @@ -34,7 +34,9 @@ describe('consumeServiceApiSse', () => { 'data: "conversation_id": "conversation-1"\n', 'data: }\n\n', ].join('') - const result = await consumeServiceApiSse(streamFromChunks(encodeChunks(stream, [1, 7, 31, 43, 58]))) + const result = await consumeServiceApiSse( + streamFromChunks(encodeChunks(stream, [1, 7, 31, 43, 58])), + ) assert.equal(result.answer, '测试') assert.equal(result.events.length, 2) @@ -48,22 +50,21 @@ describe('consumeServiceApiSse', () => { }) it('reports SSE error details immediately', async () => { - const stream = streamFromChunks(encodeChunks( - 'data: {"event":"error","code":"internal_server_error","message":"workspace failed","conversation_id":"conversation-1","message_id":"message-1"}\n\n', - [13, 37, 81], - )) - - await assert.rejects( - consumeServiceApiSse(stream), - (error: unknown) => { - assert.ok(error instanceof Error) - assert.match(error.message, /internal_server_error/) - assert.match(error.message, /workspace failed/) - assert.match(error.message, /conversation-1/) - assert.match(error.message, /message-1/) - return true - }, + const stream = streamFromChunks( + encodeChunks( + 'data: {"event":"error","code":"internal_server_error","message":"workspace failed","conversation_id":"conversation-1","message_id":"message-1"}\n\n', + [13, 37, 81], + ), ) + + await assert.rejects(consumeServiceApiSse(stream), (error: unknown) => { + assert.ok(error instanceof Error) + assert.match(error.message, /internal_server_error/) + assert.match(error.message, /workspace failed/) + assert.match(error.message, /conversation-1/) + assert.match(error.message, /message-1/) + return true + }) }) it('rejects invalid JSON event data', async () => { @@ -73,15 +74,11 @@ describe('consumeServiceApiSse', () => { }) it('rejects a stream that closes before a terminal event', async () => { - const stream = streamFromChunks(encodeChunks( - 'data: {"event":"message","answer":"partial"}\n\n', - [], - )) - - await assert.rejects( - consumeServiceApiSse(stream), - /closed before a terminal event.*message/s, + const stream = streamFromChunks( + encodeChunks('data: {"event":"message","answer":"partial"}\n\n', []), ) + + await assert.rejects(consumeServiceApiSse(stream), /closed before a terminal event.*message/s) }) it('rejects a response without a readable body', async () => { diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 8499309c4e9236..c39a6b33ef977e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -7149,4 +7149,4 @@ "count": 2 } } -} +} \ No newline at end of file diff --git a/packages/contracts/generated/api/console/account/orpc.gen.ts b/packages/contracts/generated/api/console/account/orpc.gen.ts index a9261036675841..74d541a4064889 100644 --- a/packages/contracts/generated/api/console/account/orpc.gen.ts +++ b/packages/contracts/generated/api/console/account/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetAccountAvatarQuery, zGetAccountAvatarResponse, diff --git a/packages/contracts/generated/api/console/account/types.gen.ts b/packages/contracts/generated/api/console/account/types.gen.ts index bfa12122573ec6..882050e4647f7c 100644 --- a/packages/contracts/generated/api/console/account/types.gen.ts +++ b/packages/contracts/generated/api/console/account/types.gen.ts @@ -189,8 +189,8 @@ export type PostAccountChangeEmailResponses = { 200: SimpleResultDataResponse } -export type PostAccountChangeEmailResponse - = PostAccountChangeEmailResponses[keyof PostAccountChangeEmailResponses] +export type PostAccountChangeEmailResponse = + PostAccountChangeEmailResponses[keyof PostAccountChangeEmailResponses] export type PostAccountChangeEmailCheckEmailUniqueData = { body: CheckEmailUniquePayload @@ -203,8 +203,8 @@ export type PostAccountChangeEmailCheckEmailUniqueResponses = { 200: SimpleResultResponse } -export type PostAccountChangeEmailCheckEmailUniqueResponse - = PostAccountChangeEmailCheckEmailUniqueResponses[keyof PostAccountChangeEmailCheckEmailUniqueResponses] +export type PostAccountChangeEmailCheckEmailUniqueResponse = + PostAccountChangeEmailCheckEmailUniqueResponses[keyof PostAccountChangeEmailCheckEmailUniqueResponses] export type PostAccountChangeEmailResetData = { body: ChangeEmailResetPayload @@ -217,8 +217,8 @@ export type PostAccountChangeEmailResetResponses = { 200: AccountResponse } -export type PostAccountChangeEmailResetResponse - = PostAccountChangeEmailResetResponses[keyof PostAccountChangeEmailResetResponses] +export type PostAccountChangeEmailResetResponse = + PostAccountChangeEmailResetResponses[keyof PostAccountChangeEmailResetResponses] export type PostAccountChangeEmailValidityData = { body: ChangeEmailValidityPayload @@ -231,8 +231,8 @@ export type PostAccountChangeEmailValidityResponses = { 200: VerificationTokenResponse } -export type PostAccountChangeEmailValidityResponse - = PostAccountChangeEmailValidityResponses[keyof PostAccountChangeEmailValidityResponses] +export type PostAccountChangeEmailValidityResponse = + PostAccountChangeEmailValidityResponses[keyof PostAccountChangeEmailValidityResponses] export type PostAccountDeleteData = { body: AccountDeletePayload @@ -258,8 +258,8 @@ export type PostAccountDeleteFeedbackResponses = { 200: SimpleResultResponse } -export type PostAccountDeleteFeedbackResponse - = PostAccountDeleteFeedbackResponses[keyof PostAccountDeleteFeedbackResponses] +export type PostAccountDeleteFeedbackResponse = + PostAccountDeleteFeedbackResponses[keyof PostAccountDeleteFeedbackResponses] export type GetAccountDeleteVerifyData = { body?: never @@ -272,8 +272,8 @@ export type GetAccountDeleteVerifyResponses = { 200: SimpleResultDataResponse } -export type GetAccountDeleteVerifyResponse - = GetAccountDeleteVerifyResponses[keyof GetAccountDeleteVerifyResponses] +export type GetAccountDeleteVerifyResponse = + GetAccountDeleteVerifyResponses[keyof GetAccountDeleteVerifyResponses] export type GetAccountEducationData = { body?: never @@ -286,8 +286,8 @@ export type GetAccountEducationResponses = { 200: EducationStatusResponse } -export type GetAccountEducationResponse - = GetAccountEducationResponses[keyof GetAccountEducationResponses] +export type GetAccountEducationResponse = + GetAccountEducationResponses[keyof GetAccountEducationResponses] export type PostAccountEducationData = { body: EducationActivatePayload @@ -302,8 +302,8 @@ export type PostAccountEducationResponses = { } } -export type PostAccountEducationResponse - = PostAccountEducationResponses[keyof PostAccountEducationResponses] +export type PostAccountEducationResponse = + PostAccountEducationResponses[keyof PostAccountEducationResponses] export type GetAccountEducationAutocompleteData = { body?: never @@ -320,8 +320,8 @@ export type GetAccountEducationAutocompleteResponses = { 200: EducationAutocompleteResponse } -export type GetAccountEducationAutocompleteResponse - = GetAccountEducationAutocompleteResponses[keyof GetAccountEducationAutocompleteResponses] +export type GetAccountEducationAutocompleteResponse = + GetAccountEducationAutocompleteResponses[keyof GetAccountEducationAutocompleteResponses] export type GetAccountEducationVerifyData = { body?: never @@ -334,8 +334,8 @@ export type GetAccountEducationVerifyResponses = { 200: EducationVerifyResponse } -export type GetAccountEducationVerifyResponse - = GetAccountEducationVerifyResponses[keyof GetAccountEducationVerifyResponses] +export type GetAccountEducationVerifyResponse = + GetAccountEducationVerifyResponses[keyof GetAccountEducationVerifyResponses] export type PostAccountInitData = { body: AccountInitPayload @@ -361,8 +361,8 @@ export type GetAccountIntegratesResponses = { 200: AccountIntegrateListResponse } -export type GetAccountIntegratesResponse - = GetAccountIntegratesResponses[keyof GetAccountIntegratesResponses] +export type GetAccountIntegratesResponse = + GetAccountIntegratesResponses[keyof GetAccountIntegratesResponses] export type PostAccountInterfaceLanguageData = { body: AccountInterfaceLanguagePayload @@ -375,8 +375,8 @@ export type PostAccountInterfaceLanguageResponses = { 200: AccountResponse } -export type PostAccountInterfaceLanguageResponse - = PostAccountInterfaceLanguageResponses[keyof PostAccountInterfaceLanguageResponses] +export type PostAccountInterfaceLanguageResponse = + PostAccountInterfaceLanguageResponses[keyof PostAccountInterfaceLanguageResponses] export type PostAccountInterfaceThemeData = { body: AccountInterfaceThemePayload @@ -389,8 +389,8 @@ export type PostAccountInterfaceThemeResponses = { 200: AccountResponse } -export type PostAccountInterfaceThemeResponse - = PostAccountInterfaceThemeResponses[keyof PostAccountInterfaceThemeResponses] +export type PostAccountInterfaceThemeResponse = + PostAccountInterfaceThemeResponses[keyof PostAccountInterfaceThemeResponses] export type PostAccountNameData = { body: AccountNamePayload @@ -416,8 +416,8 @@ export type PostAccountPasswordResponses = { 200: AccountResponse } -export type PostAccountPasswordResponse - = PostAccountPasswordResponses[keyof PostAccountPasswordResponses] +export type PostAccountPasswordResponse = + PostAccountPasswordResponses[keyof PostAccountPasswordResponses] export type GetAccountProfileData = { body?: never @@ -443,5 +443,5 @@ export type PostAccountTimezoneResponses = { 200: AccountResponse } -export type PostAccountTimezoneResponse - = PostAccountTimezoneResponses[keyof PostAccountTimezoneResponses] +export type PostAccountTimezoneResponse = + PostAccountTimezoneResponses[keyof PostAccountTimezoneResponses] diff --git a/packages/contracts/generated/api/console/activate/orpc.gen.ts b/packages/contracts/generated/api/console/activate/orpc.gen.ts index 870f45bd2e5713..5ab609f5bb9b97 100644 --- a/packages/contracts/generated/api/console/activate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/activate/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetActivateCheckQuery, zGetActivateCheckResponse, diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index e6249427ea06b2..0b6524dbc151cf 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteAgentByAgentIdApiKeysByApiKeyIdPath, zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse, @@ -964,7 +963,7 @@ export const drive = { */ export const post12 = oc .route({ - description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)', + description: "Update an Agent App's presentation features (opener, follow-up, citations, ...)", inputStructure: 'detailed', method: 'POST', operationId: 'postAgentByAgentIdFeatures', @@ -1139,7 +1138,7 @@ export const publish = { */ export const get28 = oc .route({ - description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)', + description: "List workflow apps that reference this Agent App's bound Agent (read-only)", inputStructure: 'detailed', method: 'GET', operationId: 'getAgentByAgentIdReferencingWorkflows', diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 3f3bee9fdd2dce..f093bbdd0e25f6 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -665,12 +665,12 @@ export type WorkflowNodeJobConfig = { workflow_prompt?: string } -export type ComposerSaveStrategy - = | 'node_job_only' - | 'save_as_new_agent' - | 'save_as_new_version' - | 'save_to_current_version' - | 'save_to_roster' +export type ComposerSaveStrategy = + | 'node_job_only' + | 'save_as_new_agent' + | 'save_as_new_version' + | 'save_to_current_version' + | 'save_to_roster' export type ComposerSoulLockPayload = { locked?: boolean @@ -965,16 +965,16 @@ export type Feedback = { rating: string } -export type JsonValue - = | string - | number - | number - | boolean - | { +export type JsonValue = + | string + | number + | number + | boolean + | { [key: string]: unknown } - | Array<unknown> - | null + | Array<unknown> + | null export type MessageFile = { belongs_to?: string | null @@ -1415,14 +1415,14 @@ export type AgentUserSatisfactionRateStatisticResponse = { rate: number } -export type AgentConfigRevisionOperation - = | 'create_version' - | 'publish_draft' - | 'restore_version' - | 'save_current_version' - | 'save_new_agent' - | 'save_new_version' - | 'save_to_roster' +export type AgentConfigRevisionOperation = + | 'create_version' + | 'publish_draft' + | 'restore_version' + | 'save_current_version' + | 'save_new_agent' + | 'save_new_version' + | 'save_to_roster' export type AgentFileUploadFeatureConfig = { allowed_file_extensions?: Array<string> @@ -1627,15 +1627,15 @@ export type DeclaredOutputFileConfig = { mime_types?: Array<string> } -export type AgentCliToolAuthorizationStatus - = | 'allowed' - | 'authorized' - | 'denied' - | 'forbidden' - | 'not_required' - | 'pending' - | 'pre_authorized' - | 'unauthorized' +export type AgentCliToolAuthorizationStatus = + | 'allowed' + | 'authorized' + | 'denied' + | 'forbidden' + | 'not_required' + | 'pending' + | 'pre_authorized' + | 'unauthorized' export type AgentCliToolEnvConfig = { secret_refs?: Array<AgentSecretRefConfig> @@ -1669,19 +1669,19 @@ export type UserActionConfig = { title: string } -export type FormInputConfig - = | ({ - type: 'paragraph' - } & ParagraphInputConfig) +export type FormInputConfig = + | ({ + type: 'paragraph' + } & ParagraphInputConfig) | ({ - type: 'select' - } & SelectInputConfig) + type: 'select' + } & SelectInputConfig) | ({ - type: 'file' - } & FileInputConfig) + type: 'file' + } & FileInputConfig) | ({ - type: 'file-list' - } & FileListInputConfig) + type: 'file-list' + } & FileListInputConfig) export type JsonValue2 = unknown @@ -2013,8 +2013,8 @@ export type GetAgentInviteOptionsResponses = { 200: AgentInviteOptionsResponse } -export type GetAgentInviteOptionsResponse - = GetAgentInviteOptionsResponses[keyof GetAgentInviteOptionsResponses] +export type GetAgentInviteOptionsResponse = + GetAgentInviteOptionsResponses[keyof GetAgentInviteOptionsResponses] export type DeleteAgentByAgentIdData = { body?: never @@ -2033,8 +2033,8 @@ export type DeleteAgentByAgentIdResponses = { 204: void } -export type DeleteAgentByAgentIdResponse - = DeleteAgentByAgentIdResponses[keyof DeleteAgentByAgentIdResponses] +export type DeleteAgentByAgentIdResponse = + DeleteAgentByAgentIdResponses[keyof DeleteAgentByAgentIdResponses] export type GetAgentByAgentIdData = { body?: never @@ -2084,8 +2084,8 @@ export type GetAgentByAgentIdApiAccessResponses = { 200: AgentApiAccessResponse } -export type GetAgentByAgentIdApiAccessResponse - = GetAgentByAgentIdApiAccessResponses[keyof GetAgentByAgentIdApiAccessResponses] +export type GetAgentByAgentIdApiAccessResponse = + GetAgentByAgentIdApiAccessResponses[keyof GetAgentByAgentIdApiAccessResponses] export type PostAgentByAgentIdApiEnableData = { body: AgentApiStatusPayload @@ -2104,8 +2104,8 @@ export type PostAgentByAgentIdApiEnableResponses = { 200: AgentApiAccessResponse } -export type PostAgentByAgentIdApiEnableResponse - = PostAgentByAgentIdApiEnableResponses[keyof PostAgentByAgentIdApiEnableResponses] +export type PostAgentByAgentIdApiEnableResponse = + PostAgentByAgentIdApiEnableResponses[keyof PostAgentByAgentIdApiEnableResponses] export type GetAgentByAgentIdApiKeysData = { body?: never @@ -2120,8 +2120,8 @@ export type GetAgentByAgentIdApiKeysResponses = { 200: ApiKeyList } -export type GetAgentByAgentIdApiKeysResponse - = GetAgentByAgentIdApiKeysResponses[keyof GetAgentByAgentIdApiKeysResponses] +export type GetAgentByAgentIdApiKeysResponse = + GetAgentByAgentIdApiKeysResponses[keyof GetAgentByAgentIdApiKeysResponses] export type PostAgentByAgentIdApiKeysData = { body?: never @@ -2140,8 +2140,8 @@ export type PostAgentByAgentIdApiKeysResponses = { 201: ApiKeyItem } -export type PostAgentByAgentIdApiKeysResponse - = PostAgentByAgentIdApiKeysResponses[keyof PostAgentByAgentIdApiKeysResponses] +export type PostAgentByAgentIdApiKeysResponse = + PostAgentByAgentIdApiKeysResponses[keyof PostAgentByAgentIdApiKeysResponses] export type DeleteAgentByAgentIdApiKeysByApiKeyIdData = { body?: never @@ -2157,8 +2157,8 @@ export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponses = { 204: void } -export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponse - = DeleteAgentByAgentIdApiKeysByApiKeyIdResponses[keyof DeleteAgentByAgentIdApiKeysByApiKeyIdResponses] +export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponse = + DeleteAgentByAgentIdApiKeysByApiKeyIdResponses[keyof DeleteAgentByAgentIdApiKeysByApiKeyIdResponses] export type PostAgentByAgentIdBuildChatFinalizeData = { body?: never @@ -2178,8 +2178,8 @@ export type PostAgentByAgentIdBuildChatFinalizeResponses = { 200: SimpleResultResponse } -export type PostAgentByAgentIdBuildChatFinalizeResponse - = PostAgentByAgentIdBuildChatFinalizeResponses[keyof PostAgentByAgentIdBuildChatFinalizeResponses] +export type PostAgentByAgentIdBuildChatFinalizeResponse = + PostAgentByAgentIdBuildChatFinalizeResponses[keyof PostAgentByAgentIdBuildChatFinalizeResponses] export type DeleteAgentByAgentIdBuildDraftData = { body?: never @@ -2194,8 +2194,8 @@ export type DeleteAgentByAgentIdBuildDraftResponses = { 200: AgentSimpleResultResponse } -export type DeleteAgentByAgentIdBuildDraftResponse - = DeleteAgentByAgentIdBuildDraftResponses[keyof DeleteAgentByAgentIdBuildDraftResponses] +export type DeleteAgentByAgentIdBuildDraftResponse = + DeleteAgentByAgentIdBuildDraftResponses[keyof DeleteAgentByAgentIdBuildDraftResponses] export type GetAgentByAgentIdBuildDraftData = { body?: never @@ -2210,8 +2210,8 @@ export type GetAgentByAgentIdBuildDraftResponses = { 200: AgentBuildDraftResponse } -export type GetAgentByAgentIdBuildDraftResponse - = GetAgentByAgentIdBuildDraftResponses[keyof GetAgentByAgentIdBuildDraftResponses] +export type GetAgentByAgentIdBuildDraftResponse = + GetAgentByAgentIdBuildDraftResponses[keyof GetAgentByAgentIdBuildDraftResponses] export type PutAgentByAgentIdBuildDraftData = { body: ComposerSavePayload @@ -2226,8 +2226,8 @@ export type PutAgentByAgentIdBuildDraftResponses = { 200: AgentBuildDraftResponse } -export type PutAgentByAgentIdBuildDraftResponse - = PutAgentByAgentIdBuildDraftResponses[keyof PutAgentByAgentIdBuildDraftResponses] +export type PutAgentByAgentIdBuildDraftResponse = + PutAgentByAgentIdBuildDraftResponses[keyof PutAgentByAgentIdBuildDraftResponses] export type PostAgentByAgentIdBuildDraftApplyData = { body?: never @@ -2242,8 +2242,8 @@ export type PostAgentByAgentIdBuildDraftApplyResponses = { 200: AgentBuildDraftApplyResponse } -export type PostAgentByAgentIdBuildDraftApplyResponse - = PostAgentByAgentIdBuildDraftApplyResponses[keyof PostAgentByAgentIdBuildDraftApplyResponses] +export type PostAgentByAgentIdBuildDraftApplyResponse = + PostAgentByAgentIdBuildDraftApplyResponses[keyof PostAgentByAgentIdBuildDraftApplyResponses] export type PostAgentByAgentIdBuildDraftCheckoutData = { body: AgentBuildDraftCheckoutPayload @@ -2258,8 +2258,8 @@ export type PostAgentByAgentIdBuildDraftCheckoutResponses = { 200: AgentBuildDraftResponse } -export type PostAgentByAgentIdBuildDraftCheckoutResponse - = PostAgentByAgentIdBuildDraftCheckoutResponses[keyof PostAgentByAgentIdBuildDraftCheckoutResponses] +export type PostAgentByAgentIdBuildDraftCheckoutResponse = + PostAgentByAgentIdBuildDraftCheckoutResponses[keyof PostAgentByAgentIdBuildDraftCheckoutResponses] export type GetAgentByAgentIdChatMessagesData = { body?: never @@ -2282,8 +2282,8 @@ export type GetAgentByAgentIdChatMessagesResponses = { 200: MessageInfiniteScrollPaginationResponse } -export type GetAgentByAgentIdChatMessagesResponse - = GetAgentByAgentIdChatMessagesResponses[keyof GetAgentByAgentIdChatMessagesResponses] +export type GetAgentByAgentIdChatMessagesResponse = + GetAgentByAgentIdChatMessagesResponses[keyof GetAgentByAgentIdChatMessagesResponses] export type GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsData = { body?: never @@ -2303,8 +2303,8 @@ export type GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponses 200: SuggestedQuestionsResponse } -export type GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse - = GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponses[keyof GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponses] +export type GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse = + GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponses[keyof GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponses] export type PostAgentByAgentIdChatMessagesByTaskIdStopData = { body?: never @@ -2320,8 +2320,8 @@ export type PostAgentByAgentIdChatMessagesByTaskIdStopResponses = { 200: SimpleResultResponse } -export type PostAgentByAgentIdChatMessagesByTaskIdStopResponse - = PostAgentByAgentIdChatMessagesByTaskIdStopResponses[keyof PostAgentByAgentIdChatMessagesByTaskIdStopResponses] +export type PostAgentByAgentIdChatMessagesByTaskIdStopResponse = + PostAgentByAgentIdChatMessagesByTaskIdStopResponses[keyof PostAgentByAgentIdChatMessagesByTaskIdStopResponses] export type GetAgentByAgentIdComposerData = { body?: never @@ -2336,8 +2336,8 @@ export type GetAgentByAgentIdComposerResponses = { 200: AgentAppComposerResponse } -export type GetAgentByAgentIdComposerResponse - = GetAgentByAgentIdComposerResponses[keyof GetAgentByAgentIdComposerResponses] +export type GetAgentByAgentIdComposerResponse = + GetAgentByAgentIdComposerResponses[keyof GetAgentByAgentIdComposerResponses] export type PutAgentByAgentIdComposerData = { body: ComposerSavePayload @@ -2352,8 +2352,8 @@ export type PutAgentByAgentIdComposerResponses = { 200: AgentAppComposerResponse } -export type PutAgentByAgentIdComposerResponse - = PutAgentByAgentIdComposerResponses[keyof PutAgentByAgentIdComposerResponses] +export type PutAgentByAgentIdComposerResponse = + PutAgentByAgentIdComposerResponses[keyof PutAgentByAgentIdComposerResponses] export type GetAgentByAgentIdComposerCandidatesData = { body?: never @@ -2368,8 +2368,8 @@ export type GetAgentByAgentIdComposerCandidatesResponses = { 200: AgentComposerCandidatesResponse } -export type GetAgentByAgentIdComposerCandidatesResponse - = GetAgentByAgentIdComposerCandidatesResponses[keyof GetAgentByAgentIdComposerCandidatesResponses] +export type GetAgentByAgentIdComposerCandidatesResponse = + GetAgentByAgentIdComposerCandidatesResponses[keyof GetAgentByAgentIdComposerCandidatesResponses] export type PostAgentByAgentIdComposerValidateData = { body: ComposerSavePayload @@ -2384,8 +2384,8 @@ export type PostAgentByAgentIdComposerValidateResponses = { 200: AgentComposerValidateResponse } -export type PostAgentByAgentIdComposerValidateResponse - = PostAgentByAgentIdComposerValidateResponses[keyof PostAgentByAgentIdComposerValidateResponses] +export type PostAgentByAgentIdComposerValidateResponse = + PostAgentByAgentIdComposerValidateResponses[keyof PostAgentByAgentIdComposerValidateResponses] export type GetAgentByAgentIdConfigFilesData = { body?: never @@ -2403,8 +2403,8 @@ export type GetAgentByAgentIdConfigFilesResponses = { 200: AgentConfigFileListResponse } -export type GetAgentByAgentIdConfigFilesResponse - = GetAgentByAgentIdConfigFilesResponses[keyof GetAgentByAgentIdConfigFilesResponses] +export type GetAgentByAgentIdConfigFilesResponse = + GetAgentByAgentIdConfigFilesResponses[keyof GetAgentByAgentIdConfigFilesResponses] export type PostAgentByAgentIdConfigFilesData = { body: AgentConfigFileUploadPayload @@ -2422,8 +2422,8 @@ export type PostAgentByAgentIdConfigFilesResponses = { 201: AgentConfigFileUploadResponse } -export type PostAgentByAgentIdConfigFilesResponse - = PostAgentByAgentIdConfigFilesResponses[keyof PostAgentByAgentIdConfigFilesResponses] +export type PostAgentByAgentIdConfigFilesResponse = + PostAgentByAgentIdConfigFilesResponses[keyof PostAgentByAgentIdConfigFilesResponses] export type DeleteAgentByAgentIdConfigFilesByNameData = { body?: never @@ -2442,8 +2442,8 @@ export type DeleteAgentByAgentIdConfigFilesByNameResponses = { 200: AgentConfigDeleteResponse } -export type DeleteAgentByAgentIdConfigFilesByNameResponse - = DeleteAgentByAgentIdConfigFilesByNameResponses[keyof DeleteAgentByAgentIdConfigFilesByNameResponses] +export type DeleteAgentByAgentIdConfigFilesByNameResponse = + DeleteAgentByAgentIdConfigFilesByNameResponses[keyof DeleteAgentByAgentIdConfigFilesByNameResponses] export type GetAgentByAgentIdConfigFilesByNameDownloadData = { body?: never @@ -2462,8 +2462,8 @@ export type GetAgentByAgentIdConfigFilesByNameDownloadResponses = { 200: AgentConfigDownloadResponse } -export type GetAgentByAgentIdConfigFilesByNameDownloadResponse - = GetAgentByAgentIdConfigFilesByNameDownloadResponses[keyof GetAgentByAgentIdConfigFilesByNameDownloadResponses] +export type GetAgentByAgentIdConfigFilesByNameDownloadResponse = + GetAgentByAgentIdConfigFilesByNameDownloadResponses[keyof GetAgentByAgentIdConfigFilesByNameDownloadResponses] export type GetAgentByAgentIdConfigFilesByNamePreviewData = { body?: never @@ -2482,8 +2482,8 @@ export type GetAgentByAgentIdConfigFilesByNamePreviewResponses = { 200: AgentConfigFilePreviewResponse } -export type GetAgentByAgentIdConfigFilesByNamePreviewResponse - = GetAgentByAgentIdConfigFilesByNamePreviewResponses[keyof GetAgentByAgentIdConfigFilesByNamePreviewResponses] +export type GetAgentByAgentIdConfigFilesByNamePreviewResponse = + GetAgentByAgentIdConfigFilesByNamePreviewResponses[keyof GetAgentByAgentIdConfigFilesByNamePreviewResponses] export type GetAgentByAgentIdConfigManifestData = { body?: never @@ -2501,8 +2501,8 @@ export type GetAgentByAgentIdConfigManifestResponses = { 200: AgentConfigManifestResponse } -export type GetAgentByAgentIdConfigManifestResponse - = GetAgentByAgentIdConfigManifestResponses[keyof GetAgentByAgentIdConfigManifestResponses] +export type GetAgentByAgentIdConfigManifestResponse = + GetAgentByAgentIdConfigManifestResponses[keyof GetAgentByAgentIdConfigManifestResponses] export type GetAgentByAgentIdConfigSkillsData = { body?: never @@ -2520,8 +2520,8 @@ export type GetAgentByAgentIdConfigSkillsResponses = { 200: AgentConfigSkillListResponse } -export type GetAgentByAgentIdConfigSkillsResponse - = GetAgentByAgentIdConfigSkillsResponses[keyof GetAgentByAgentIdConfigSkillsResponses] +export type GetAgentByAgentIdConfigSkillsResponse = + GetAgentByAgentIdConfigSkillsResponses[keyof GetAgentByAgentIdConfigSkillsResponses] export type PostAgentByAgentIdConfigSkillsUploadData = { body: { @@ -2541,8 +2541,8 @@ export type PostAgentByAgentIdConfigSkillsUploadResponses = { 201: AgentConfigSkillUploadResponse } -export type PostAgentByAgentIdConfigSkillsUploadResponse - = PostAgentByAgentIdConfigSkillsUploadResponses[keyof PostAgentByAgentIdConfigSkillsUploadResponses] +export type PostAgentByAgentIdConfigSkillsUploadResponse = + PostAgentByAgentIdConfigSkillsUploadResponses[keyof PostAgentByAgentIdConfigSkillsUploadResponses] export type DeleteAgentByAgentIdConfigSkillsByNameData = { body?: never @@ -2561,8 +2561,8 @@ export type DeleteAgentByAgentIdConfigSkillsByNameResponses = { 200: AgentConfigDeleteResponse } -export type DeleteAgentByAgentIdConfigSkillsByNameResponse - = DeleteAgentByAgentIdConfigSkillsByNameResponses[keyof DeleteAgentByAgentIdConfigSkillsByNameResponses] +export type DeleteAgentByAgentIdConfigSkillsByNameResponse = + DeleteAgentByAgentIdConfigSkillsByNameResponses[keyof DeleteAgentByAgentIdConfigSkillsByNameResponses] export type GetAgentByAgentIdConfigSkillsByNameDownloadData = { body?: never @@ -2581,8 +2581,8 @@ export type GetAgentByAgentIdConfigSkillsByNameDownloadResponses = { 200: AgentConfigDownloadResponse } -export type GetAgentByAgentIdConfigSkillsByNameDownloadResponse - = GetAgentByAgentIdConfigSkillsByNameDownloadResponses[keyof GetAgentByAgentIdConfigSkillsByNameDownloadResponses] +export type GetAgentByAgentIdConfigSkillsByNameDownloadResponse = + GetAgentByAgentIdConfigSkillsByNameDownloadResponses[keyof GetAgentByAgentIdConfigSkillsByNameDownloadResponses] export type GetAgentByAgentIdConfigSkillsByNameFilesContentData = { body?: never @@ -2600,8 +2600,8 @@ export type GetAgentByAgentIdConfigSkillsByNameFilesContentResponses = { } } -export type GetAgentByAgentIdConfigSkillsByNameFilesContentResponse - = GetAgentByAgentIdConfigSkillsByNameFilesContentResponses[keyof GetAgentByAgentIdConfigSkillsByNameFilesContentResponses] +export type GetAgentByAgentIdConfigSkillsByNameFilesContentResponse = + GetAgentByAgentIdConfigSkillsByNameFilesContentResponses[keyof GetAgentByAgentIdConfigSkillsByNameFilesContentResponses] export type GetAgentByAgentIdConfigSkillsByNameFilesDownloadData = { body?: never @@ -2621,8 +2621,8 @@ export type GetAgentByAgentIdConfigSkillsByNameFilesDownloadResponses = { 200: AgentConfigDownloadResponse } -export type GetAgentByAgentIdConfigSkillsByNameFilesDownloadResponse - = GetAgentByAgentIdConfigSkillsByNameFilesDownloadResponses[keyof GetAgentByAgentIdConfigSkillsByNameFilesDownloadResponses] +export type GetAgentByAgentIdConfigSkillsByNameFilesDownloadResponse = + GetAgentByAgentIdConfigSkillsByNameFilesDownloadResponses[keyof GetAgentByAgentIdConfigSkillsByNameFilesDownloadResponses] export type GetAgentByAgentIdConfigSkillsByNameFilesPreviewData = { body?: never @@ -2642,8 +2642,8 @@ export type GetAgentByAgentIdConfigSkillsByNameFilesPreviewResponses = { 200: AgentConfigSkillFilePreviewResponse } -export type GetAgentByAgentIdConfigSkillsByNameFilesPreviewResponse - = GetAgentByAgentIdConfigSkillsByNameFilesPreviewResponses[keyof GetAgentByAgentIdConfigSkillsByNameFilesPreviewResponses] +export type GetAgentByAgentIdConfigSkillsByNameFilesPreviewResponse = + GetAgentByAgentIdConfigSkillsByNameFilesPreviewResponses[keyof GetAgentByAgentIdConfigSkillsByNameFilesPreviewResponses] export type GetAgentByAgentIdConfigSkillsByNameInspectData = { body?: never @@ -2662,8 +2662,8 @@ export type GetAgentByAgentIdConfigSkillsByNameInspectResponses = { 200: AgentConfigSkillInspectResponse } -export type GetAgentByAgentIdConfigSkillsByNameInspectResponse - = GetAgentByAgentIdConfigSkillsByNameInspectResponses[keyof GetAgentByAgentIdConfigSkillsByNameInspectResponses] +export type GetAgentByAgentIdConfigSkillsByNameInspectResponse = + GetAgentByAgentIdConfigSkillsByNameInspectResponses[keyof GetAgentByAgentIdConfigSkillsByNameInspectResponses] export type PostAgentByAgentIdCopyData = { body: AgentAppCopyPayload @@ -2683,8 +2683,8 @@ export type PostAgentByAgentIdCopyResponses = { 201: AgentAppDetailWithSite } -export type PostAgentByAgentIdCopyResponse - = PostAgentByAgentIdCopyResponses[keyof PostAgentByAgentIdCopyResponses] +export type PostAgentByAgentIdCopyResponse = + PostAgentByAgentIdCopyResponses[keyof PostAgentByAgentIdCopyResponses] export type PostAgentByAgentIdDebugConversationRefreshData = { body?: never @@ -2703,8 +2703,8 @@ export type PostAgentByAgentIdDebugConversationRefreshResponses = { 200: AgentDebugConversationRefreshResponse } -export type PostAgentByAgentIdDebugConversationRefreshResponse - = PostAgentByAgentIdDebugConversationRefreshResponses[keyof PostAgentByAgentIdDebugConversationRefreshResponses] +export type PostAgentByAgentIdDebugConversationRefreshResponse = + PostAgentByAgentIdDebugConversationRefreshResponses[keyof PostAgentByAgentIdDebugConversationRefreshResponses] export type GetAgentByAgentIdDriveFilesData = { body?: never @@ -2721,8 +2721,8 @@ export type GetAgentByAgentIdDriveFilesResponses = { 200: AgentDriveListResponse } -export type GetAgentByAgentIdDriveFilesResponse - = GetAgentByAgentIdDriveFilesResponses[keyof GetAgentByAgentIdDriveFilesResponses] +export type GetAgentByAgentIdDriveFilesResponse = + GetAgentByAgentIdDriveFilesResponses[keyof GetAgentByAgentIdDriveFilesResponses] export type GetAgentByAgentIdDriveFilesDownloadData = { body?: never @@ -2739,8 +2739,8 @@ export type GetAgentByAgentIdDriveFilesDownloadResponses = { 200: AgentDriveDownloadResponse } -export type GetAgentByAgentIdDriveFilesDownloadResponse - = GetAgentByAgentIdDriveFilesDownloadResponses[keyof GetAgentByAgentIdDriveFilesDownloadResponses] +export type GetAgentByAgentIdDriveFilesDownloadResponse = + GetAgentByAgentIdDriveFilesDownloadResponses[keyof GetAgentByAgentIdDriveFilesDownloadResponses] export type GetAgentByAgentIdDriveFilesPreviewData = { body?: never @@ -2757,8 +2757,8 @@ export type GetAgentByAgentIdDriveFilesPreviewResponses = { 200: AgentDrivePreviewResponse } -export type GetAgentByAgentIdDriveFilesPreviewResponse - = GetAgentByAgentIdDriveFilesPreviewResponses[keyof GetAgentByAgentIdDriveFilesPreviewResponses] +export type GetAgentByAgentIdDriveFilesPreviewResponse = + GetAgentByAgentIdDriveFilesPreviewResponses[keyof GetAgentByAgentIdDriveFilesPreviewResponses] export type GetAgentByAgentIdDriveSkillsData = { body?: never @@ -2773,8 +2773,8 @@ export type GetAgentByAgentIdDriveSkillsResponses = { 200: AgentDriveSkillListResponse } -export type GetAgentByAgentIdDriveSkillsResponse - = GetAgentByAgentIdDriveSkillsResponses[keyof GetAgentByAgentIdDriveSkillsResponses] +export type GetAgentByAgentIdDriveSkillsResponse = + GetAgentByAgentIdDriveSkillsResponses[keyof GetAgentByAgentIdDriveSkillsResponses] export type GetAgentByAgentIdDriveSkillsBySkillPathInspectData = { body?: never @@ -2790,8 +2790,8 @@ export type GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses = { 200: AgentDriveSkillInspectResponse } -export type GetAgentByAgentIdDriveSkillsBySkillPathInspectResponse - = GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses[keyof GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses] +export type GetAgentByAgentIdDriveSkillsBySkillPathInspectResponse = + GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses[keyof GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses] export type PostAgentByAgentIdFeaturesData = { body: AgentAppFeaturesPayload @@ -2811,8 +2811,8 @@ export type PostAgentByAgentIdFeaturesResponses = { 200: SimpleResultResponse } -export type PostAgentByAgentIdFeaturesResponse - = PostAgentByAgentIdFeaturesResponses[keyof PostAgentByAgentIdFeaturesResponses] +export type PostAgentByAgentIdFeaturesResponse = + PostAgentByAgentIdFeaturesResponses[keyof PostAgentByAgentIdFeaturesResponses] export type PostAgentByAgentIdFeedbacksData = { body: MessageFeedbackPayload @@ -2831,8 +2831,8 @@ export type PostAgentByAgentIdFeedbacksResponses = { 200: SimpleResultResponse } -export type PostAgentByAgentIdFeedbacksResponse - = PostAgentByAgentIdFeedbacksResponses[keyof PostAgentByAgentIdFeedbacksResponses] +export type PostAgentByAgentIdFeedbacksResponse = + PostAgentByAgentIdFeedbacksResponses[keyof PostAgentByAgentIdFeedbacksResponses] export type DeleteAgentByAgentIdFilesData = { body?: never @@ -2849,8 +2849,8 @@ export type DeleteAgentByAgentIdFilesResponses = { 200: AgentDriveDeleteResponse } -export type DeleteAgentByAgentIdFilesResponse - = DeleteAgentByAgentIdFilesResponses[keyof DeleteAgentByAgentIdFilesResponses] +export type DeleteAgentByAgentIdFilesResponse = + DeleteAgentByAgentIdFilesResponses[keyof DeleteAgentByAgentIdFilesResponses] export type PostAgentByAgentIdFilesData = { body: AgentDriveFilePayload @@ -2865,8 +2865,8 @@ export type PostAgentByAgentIdFilesResponses = { 201: AgentDriveFileCommitResponse } -export type PostAgentByAgentIdFilesResponse - = PostAgentByAgentIdFilesResponses[keyof PostAgentByAgentIdFilesResponses] +export type PostAgentByAgentIdFilesResponse = + PostAgentByAgentIdFilesResponses[keyof PostAgentByAgentIdFilesResponses] export type GetAgentByAgentIdLogSourcesData = { body?: never @@ -2881,8 +2881,8 @@ export type GetAgentByAgentIdLogSourcesResponses = { 200: AgentLogSourceListResponse } -export type GetAgentByAgentIdLogSourcesResponse - = GetAgentByAgentIdLogSourcesResponses[keyof GetAgentByAgentIdLogSourcesResponses] +export type GetAgentByAgentIdLogSourcesResponse = + GetAgentByAgentIdLogSourcesResponses[keyof GetAgentByAgentIdLogSourcesResponses] export type GetAgentByAgentIdLogsData = { body?: never @@ -2909,8 +2909,8 @@ export type GetAgentByAgentIdLogsResponses = { 200: AgentLogListResponse } -export type GetAgentByAgentIdLogsResponse - = GetAgentByAgentIdLogsResponses[keyof GetAgentByAgentIdLogsResponses] +export type GetAgentByAgentIdLogsResponse = + GetAgentByAgentIdLogsResponses[keyof GetAgentByAgentIdLogsResponses] export type GetAgentByAgentIdLogsByConversationIdMessagesData = { body?: never @@ -2938,8 +2938,8 @@ export type GetAgentByAgentIdLogsByConversationIdMessagesResponses = { 200: AgentLogMessageListResponse } -export type GetAgentByAgentIdLogsByConversationIdMessagesResponse - = GetAgentByAgentIdLogsByConversationIdMessagesResponses[keyof GetAgentByAgentIdLogsByConversationIdMessagesResponses] +export type GetAgentByAgentIdLogsByConversationIdMessagesResponse = + GetAgentByAgentIdLogsByConversationIdMessagesResponses[keyof GetAgentByAgentIdLogsByConversationIdMessagesResponses] export type GetAgentByAgentIdMessagesByMessageIdData = { body?: never @@ -2959,8 +2959,8 @@ export type GetAgentByAgentIdMessagesByMessageIdResponses = { 200: MessageDetailResponse } -export type GetAgentByAgentIdMessagesByMessageIdResponse - = GetAgentByAgentIdMessagesByMessageIdResponses[keyof GetAgentByAgentIdMessagesByMessageIdResponses] +export type GetAgentByAgentIdMessagesByMessageIdResponse = + GetAgentByAgentIdMessagesByMessageIdResponses[keyof GetAgentByAgentIdMessagesByMessageIdResponses] export type PostAgentByAgentIdPublishData = { body: AgentPublishPayload @@ -2979,8 +2979,8 @@ export type PostAgentByAgentIdPublishResponses = { 200: AgentPublishResponse } -export type PostAgentByAgentIdPublishResponse - = PostAgentByAgentIdPublishResponses[keyof PostAgentByAgentIdPublishResponses] +export type PostAgentByAgentIdPublishResponse = + PostAgentByAgentIdPublishResponses[keyof PostAgentByAgentIdPublishResponses] export type GetAgentByAgentIdReferencingWorkflowsData = { body?: never @@ -2999,8 +2999,8 @@ export type GetAgentByAgentIdReferencingWorkflowsResponses = { 200: AgentReferencingWorkflowsResponse } -export type GetAgentByAgentIdReferencingWorkflowsResponse - = GetAgentByAgentIdReferencingWorkflowsResponses[keyof GetAgentByAgentIdReferencingWorkflowsResponses] +export type GetAgentByAgentIdReferencingWorkflowsResponse = + GetAgentByAgentIdReferencingWorkflowsResponses[keyof GetAgentByAgentIdReferencingWorkflowsResponses] export type GetAgentByAgentIdSandboxData = { body?: never @@ -3017,8 +3017,8 @@ export type GetAgentByAgentIdSandboxResponses = { 200: SandboxInfoResponse } -export type GetAgentByAgentIdSandboxResponse - = GetAgentByAgentIdSandboxResponses[keyof GetAgentByAgentIdSandboxResponses] +export type GetAgentByAgentIdSandboxResponse = + GetAgentByAgentIdSandboxResponses[keyof GetAgentByAgentIdSandboxResponses] export type GetAgentByAgentIdSandboxFilesData = { body?: never @@ -3036,8 +3036,8 @@ export type GetAgentByAgentIdSandboxFilesResponses = { 200: SandboxListResponse } -export type GetAgentByAgentIdSandboxFilesResponse - = GetAgentByAgentIdSandboxFilesResponses[keyof GetAgentByAgentIdSandboxFilesResponses] +export type GetAgentByAgentIdSandboxFilesResponse = + GetAgentByAgentIdSandboxFilesResponses[keyof GetAgentByAgentIdSandboxFilesResponses] export type GetAgentByAgentIdSandboxFilesReadData = { body?: never @@ -3055,8 +3055,8 @@ export type GetAgentByAgentIdSandboxFilesReadResponses = { 200: SandboxReadResponse } -export type GetAgentByAgentIdSandboxFilesReadResponse - = GetAgentByAgentIdSandboxFilesReadResponses[keyof GetAgentByAgentIdSandboxFilesReadResponses] +export type GetAgentByAgentIdSandboxFilesReadResponse = + GetAgentByAgentIdSandboxFilesReadResponses[keyof GetAgentByAgentIdSandboxFilesReadResponses] export type PostAgentByAgentIdSandboxFilesUploadData = { body: AgentSandboxUploadPayload @@ -3071,8 +3071,8 @@ export type PostAgentByAgentIdSandboxFilesUploadResponses = { 200: SandboxUploadResponse } -export type PostAgentByAgentIdSandboxFilesUploadResponse - = PostAgentByAgentIdSandboxFilesUploadResponses[keyof PostAgentByAgentIdSandboxFilesUploadResponses] +export type PostAgentByAgentIdSandboxFilesUploadResponse = + PostAgentByAgentIdSandboxFilesUploadResponses[keyof PostAgentByAgentIdSandboxFilesUploadResponses] export type PostAgentByAgentIdSkillsUploadData = { body: { @@ -3093,8 +3093,8 @@ export type PostAgentByAgentIdSkillsUploadResponses = { 201: AgentSkillUploadResponse } -export type PostAgentByAgentIdSkillsUploadResponse - = PostAgentByAgentIdSkillsUploadResponses[keyof PostAgentByAgentIdSkillsUploadResponses] +export type PostAgentByAgentIdSkillsUploadResponse = + PostAgentByAgentIdSkillsUploadResponses[keyof PostAgentByAgentIdSkillsUploadResponses] export type DeleteAgentByAgentIdSkillsBySlugData = { body?: never @@ -3110,8 +3110,8 @@ export type DeleteAgentByAgentIdSkillsBySlugResponses = { 200: AgentDriveDeleteResponse } -export type DeleteAgentByAgentIdSkillsBySlugResponse - = DeleteAgentByAgentIdSkillsBySlugResponses[keyof DeleteAgentByAgentIdSkillsBySlugResponses] +export type DeleteAgentByAgentIdSkillsBySlugResponse = + DeleteAgentByAgentIdSkillsBySlugResponses[keyof DeleteAgentByAgentIdSkillsBySlugResponses] export type PostAgentByAgentIdSkillsBySlugInferToolsData = { body?: never @@ -3127,8 +3127,8 @@ export type PostAgentByAgentIdSkillsBySlugInferToolsResponses = { 200: SkillToolInferenceResult } -export type PostAgentByAgentIdSkillsBySlugInferToolsResponse - = PostAgentByAgentIdSkillsBySlugInferToolsResponses[keyof PostAgentByAgentIdSkillsBySlugInferToolsResponses] +export type PostAgentByAgentIdSkillsBySlugInferToolsResponse = + PostAgentByAgentIdSkillsBySlugInferToolsResponses[keyof PostAgentByAgentIdSkillsBySlugInferToolsResponses] export type GetAgentByAgentIdStatisticsSummaryData = { body?: never @@ -3147,8 +3147,8 @@ export type GetAgentByAgentIdStatisticsSummaryResponses = { 200: AgentStatisticSummaryEnvelopeResponse } -export type GetAgentByAgentIdStatisticsSummaryResponse - = GetAgentByAgentIdStatisticsSummaryResponses[keyof GetAgentByAgentIdStatisticsSummaryResponses] +export type GetAgentByAgentIdStatisticsSummaryResponse = + GetAgentByAgentIdStatisticsSummaryResponses[keyof GetAgentByAgentIdStatisticsSummaryResponses] export type GetAgentByAgentIdVersionsData = { body?: never @@ -3163,8 +3163,8 @@ export type GetAgentByAgentIdVersionsResponses = { 200: AgentConfigSnapshotListResponse } -export type GetAgentByAgentIdVersionsResponse - = GetAgentByAgentIdVersionsResponses[keyof GetAgentByAgentIdVersionsResponses] +export type GetAgentByAgentIdVersionsResponse = + GetAgentByAgentIdVersionsResponses[keyof GetAgentByAgentIdVersionsResponses] export type GetAgentByAgentIdVersionsByVersionIdData = { body?: never @@ -3180,8 +3180,8 @@ export type GetAgentByAgentIdVersionsByVersionIdResponses = { 200: AgentConfigSnapshotDetailResponse } -export type GetAgentByAgentIdVersionsByVersionIdResponse - = GetAgentByAgentIdVersionsByVersionIdResponses[keyof GetAgentByAgentIdVersionsByVersionIdResponses] +export type GetAgentByAgentIdVersionsByVersionIdResponse = + GetAgentByAgentIdVersionsByVersionIdResponses[keyof GetAgentByAgentIdVersionsByVersionIdResponses] export type PostAgentByAgentIdVersionsByVersionIdRestoreData = { body?: never @@ -3197,5 +3197,5 @@ export type PostAgentByAgentIdVersionsByVersionIdRestoreResponses = { 200: AgentConfigSnapshotRestoreResponse } -export type PostAgentByAgentIdVersionsByVersionIdRestoreResponse - = PostAgentByAgentIdVersionsByVersionIdRestoreResponses[keyof PostAgentByAgentIdVersionsByVersionIdRestoreResponses] +export type PostAgentByAgentIdVersionsByVersionIdRestoreResponse = + PostAgentByAgentIdVersionsByVersionIdRestoreResponses[keyof PostAgentByAgentIdVersionsByVersionIdRestoreResponses] diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index d62307a61d03cd..eb3272bb9e601b 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -2923,8 +2923,8 @@ export const zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsPath = z /** * Suggested questions retrieved successfully */ -export const zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse - = zSuggestedQuestionsResponse +export const zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse = + zSuggestedQuestionsResponse export const zPostAgentByAgentIdChatMessagesByTaskIdStopPath = z.object({ agent_id: z.uuid(), @@ -3154,8 +3154,8 @@ export const zGetAgentByAgentIdConfigSkillsByNameFilesDownloadQuery = z.object({ /** * Config skill file download URL */ -export const zGetAgentByAgentIdConfigSkillsByNameFilesDownloadResponse - = zAgentConfigDownloadResponse +export const zGetAgentByAgentIdConfigSkillsByNameFilesDownloadResponse = + zAgentConfigDownloadResponse export const zGetAgentByAgentIdConfigSkillsByNameFilesPreviewPath = z.object({ agent_id: z.uuid(), @@ -3171,8 +3171,8 @@ export const zGetAgentByAgentIdConfigSkillsByNameFilesPreviewQuery = z.object({ /** * Config skill file preview */ -export const zGetAgentByAgentIdConfigSkillsByNameFilesPreviewResponse - = zAgentConfigSkillFilePreviewResponse +export const zGetAgentByAgentIdConfigSkillsByNameFilesPreviewResponse = + zAgentConfigSkillFilePreviewResponse export const zGetAgentByAgentIdConfigSkillsByNameInspectPath = z.object({ agent_id: z.uuid(), @@ -3207,8 +3207,8 @@ export const zPostAgentByAgentIdDebugConversationRefreshPath = z.object({ /** * Agent debug conversation refreshed */ -export const zPostAgentByAgentIdDebugConversationRefreshResponse - = zAgentDebugConversationRefreshResponse +export const zPostAgentByAgentIdDebugConversationRefreshResponse = + zAgentDebugConversationRefreshResponse export const zGetAgentByAgentIdDriveFilesPath = z.object({ agent_id: z.uuid(), @@ -3266,8 +3266,8 @@ export const zGetAgentByAgentIdDriveSkillsBySkillPathInspectPath = z.object({ /** * Drive skill inspect view */ -export const zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse - = zAgentDriveSkillInspectResponse +export const zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse = + zAgentDriveSkillInspectResponse export const zPostAgentByAgentIdFeaturesBody = zAgentAppFeaturesPayload @@ -3528,5 +3528,5 @@ export const zPostAgentByAgentIdVersionsByVersionIdRestorePath = z.object({ /** * Agent version restored */ -export const zPostAgentByAgentIdVersionsByVersionIdRestoreResponse - = zAgentConfigSnapshotRestoreResponse +export const zPostAgentByAgentIdVersionsByVersionIdRestoreResponse = + zAgentConfigSnapshotRestoreResponse diff --git a/packages/contracts/generated/api/console/all-workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/all-workspaces/orpc.gen.ts index 91ccdbc408f1fb..a11ca62580ad4c 100644 --- a/packages/contracts/generated/api/console/all-workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/all-workspaces/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetAllWorkspacesQuery, zGetAllWorkspacesResponse } from './zod.gen' export const get = oc diff --git a/packages/contracts/generated/api/console/api-based-extension/orpc.gen.ts b/packages/contracts/generated/api/console/api-based-extension/orpc.gen.ts index b47ed17f342a80..38c0930b219ae3 100644 --- a/packages/contracts/generated/api/console/api-based-extension/orpc.gen.ts +++ b/packages/contracts/generated/api/console/api-based-extension/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteApiBasedExtensionByIdPath, zDeleteApiBasedExtensionByIdResponse, diff --git a/packages/contracts/generated/api/console/api-based-extension/types.gen.ts b/packages/contracts/generated/api/console/api-based-extension/types.gen.ts index e24fb6efc4471c..91799c10b505d3 100644 --- a/packages/contracts/generated/api/console/api-based-extension/types.gen.ts +++ b/packages/contracts/generated/api/console/api-based-extension/types.gen.ts @@ -31,8 +31,8 @@ export type GetApiBasedExtensionResponses = { 200: ApiBasedExtensionListResponse } -export type GetApiBasedExtensionResponse - = GetApiBasedExtensionResponses[keyof GetApiBasedExtensionResponses] +export type GetApiBasedExtensionResponse = + GetApiBasedExtensionResponses[keyof GetApiBasedExtensionResponses] export type PostApiBasedExtensionData = { body: ApiBasedExtensionPayload @@ -45,8 +45,8 @@ export type PostApiBasedExtensionResponses = { 201: ApiBasedExtensionResponse } -export type PostApiBasedExtensionResponse - = PostApiBasedExtensionResponses[keyof PostApiBasedExtensionResponses] +export type PostApiBasedExtensionResponse = + PostApiBasedExtensionResponses[keyof PostApiBasedExtensionResponses] export type DeleteApiBasedExtensionByIdData = { body?: never @@ -61,8 +61,8 @@ export type DeleteApiBasedExtensionByIdResponses = { 204: void } -export type DeleteApiBasedExtensionByIdResponse - = DeleteApiBasedExtensionByIdResponses[keyof DeleteApiBasedExtensionByIdResponses] +export type DeleteApiBasedExtensionByIdResponse = + DeleteApiBasedExtensionByIdResponses[keyof DeleteApiBasedExtensionByIdResponses] export type GetApiBasedExtensionByIdData = { body?: never @@ -77,8 +77,8 @@ export type GetApiBasedExtensionByIdResponses = { 200: ApiBasedExtensionResponse } -export type GetApiBasedExtensionByIdResponse - = GetApiBasedExtensionByIdResponses[keyof GetApiBasedExtensionByIdResponses] +export type GetApiBasedExtensionByIdResponse = + GetApiBasedExtensionByIdResponses[keyof GetApiBasedExtensionByIdResponses] export type PostApiBasedExtensionByIdData = { body: ApiBasedExtensionPayload @@ -93,5 +93,5 @@ export type PostApiBasedExtensionByIdResponses = { 200: ApiBasedExtensionResponse } -export type PostApiBasedExtensionByIdResponse - = PostApiBasedExtensionByIdResponses[keyof PostApiBasedExtensionByIdResponses] +export type PostApiBasedExtensionByIdResponse = + PostApiBasedExtensionByIdResponses[keyof PostApiBasedExtensionByIdResponses] diff --git a/packages/contracts/generated/api/console/api-key-auth/orpc.gen.ts b/packages/contracts/generated/api/console/api-key-auth/orpc.gen.ts index 75ed149fae89de..abac14bbdd38c4 100644 --- a/packages/contracts/generated/api/console/api-key-auth/orpc.gen.ts +++ b/packages/contracts/generated/api/console/api-key-auth/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteApiKeyAuthDataSourceByBindingIdPath, zDeleteApiKeyAuthDataSourceByBindingIdResponse, diff --git a/packages/contracts/generated/api/console/api-key-auth/types.gen.ts b/packages/contracts/generated/api/console/api-key-auth/types.gen.ts index baa517f18666f0..9932f21d749c31 100644 --- a/packages/contracts/generated/api/console/api-key-auth/types.gen.ts +++ b/packages/contracts/generated/api/console/api-key-auth/types.gen.ts @@ -40,8 +40,8 @@ export type GetApiKeyAuthDataSourceResponses = { 200: ApiKeyAuthDataSourceListResponse } -export type GetApiKeyAuthDataSourceResponse - = GetApiKeyAuthDataSourceResponses[keyof GetApiKeyAuthDataSourceResponses] +export type GetApiKeyAuthDataSourceResponse = + GetApiKeyAuthDataSourceResponses[keyof GetApiKeyAuthDataSourceResponses] export type PostApiKeyAuthDataSourceBindingData = { body: ApiKeyAuthBindingPayload @@ -54,8 +54,8 @@ export type PostApiKeyAuthDataSourceBindingResponses = { 200: SimpleResultResponse } -export type PostApiKeyAuthDataSourceBindingResponse - = PostApiKeyAuthDataSourceBindingResponses[keyof PostApiKeyAuthDataSourceBindingResponses] +export type PostApiKeyAuthDataSourceBindingResponse = + PostApiKeyAuthDataSourceBindingResponses[keyof PostApiKeyAuthDataSourceBindingResponses] export type DeleteApiKeyAuthDataSourceByBindingIdData = { body?: never @@ -70,5 +70,5 @@ export type DeleteApiKeyAuthDataSourceByBindingIdResponses = { 204: void } -export type DeleteApiKeyAuthDataSourceByBindingIdResponse - = DeleteApiKeyAuthDataSourceByBindingIdResponses[keyof DeleteApiKeyAuthDataSourceByBindingIdResponses] +export type DeleteApiKeyAuthDataSourceByBindingIdResponse = + DeleteApiKeyAuthDataSourceByBindingIdResponses[keyof DeleteApiKeyAuthDataSourceByBindingIdResponses] diff --git a/packages/contracts/generated/api/console/app-dsl-version/orpc.gen.ts b/packages/contracts/generated/api/console/app-dsl-version/orpc.gen.ts index c384179ab5104c..7c7d7065e8d586 100644 --- a/packages/contracts/generated/api/console/app-dsl-version/orpc.gen.ts +++ b/packages/contracts/generated/api/console/app-dsl-version/orpc.gen.ts @@ -1,7 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts import { oc } from '@orpc/contract' - import { zGetAppDslVersionResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/app/orpc.gen.ts b/packages/contracts/generated/api/console/app/orpc.gen.ts index 7ccb9338664d54..22dfb24ba0e737 100644 --- a/packages/contracts/generated/api/console/app/orpc.gen.ts +++ b/packages/contracts/generated/api/console/app/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetAppPromptTemplatesQuery, zGetAppPromptTemplatesResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/app/types.gen.ts b/packages/contracts/generated/api/console/app/types.gen.ts index 450e0acd866ccf..62dd7c77e3d7df 100644 --- a/packages/contracts/generated/api/console/app/types.gen.ts +++ b/packages/contracts/generated/api/console/app/types.gen.ts @@ -33,5 +33,5 @@ export type GetAppPromptTemplatesResponses = { 200: AdvancedPromptTemplateResponse } -export type GetAppPromptTemplatesResponse - = GetAppPromptTemplatesResponses[keyof GetAppPromptTemplatesResponses] +export type GetAppPromptTemplatesResponse = + GetAppPromptTemplatesResponses[keyof GetAppPromptTemplatesResponses] diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 2b0d927e72df3b..7acf06237439a7 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteAppsByAppIdAgentConfigFilesByNamePath, zDeleteAppsByAppIdAgentConfigFilesByNameQuery, @@ -1301,7 +1300,7 @@ export const post11 = oc operationId: 'postAppsByAppIdAgentFiles', path: '/apps/{app_id}/agent/files', successStatus: 201, - summary: 'ADD FILE: commit one uploaded file into the bound agent\'s drive', + summary: "ADD FILE: commit one uploaded file into the bound agent's drive", tags: ['console'], }) .input( @@ -1378,7 +1377,7 @@ export const upload2 = { export const post13 = oc .route({ description: - 'Infer CLI tool + ENV suggestions from a standardized skill\'s SKILL.md (draft only, ENG-371)\nSaving still goes through composer validation.', + "Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)\nSaving still goes through composer validation.", inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAgentSkillsBySlugInferTools', @@ -2379,7 +2378,7 @@ export const siteEnable = { */ export const delete9 = oc .route({ - description: 'Remove the current account\'s star from an application', + description: "Remove the current account's star from an application", inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteAppsByAppIdStar', @@ -4142,7 +4141,7 @@ export const byOutputName = { */ export const get77 = oc .route({ - description: 'One node\'s declared outputs for a draft workflow run.', + description: "One node's declared outputs for a draft workflow run.", inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeId', @@ -4162,7 +4161,7 @@ export const byNodeId9 = { */ export const get78 = oc .route({ - description: 'Snapshot of every node\'s declared outputs for a draft workflow run.', + description: "Snapshot of every node's declared outputs for a draft workflow run.", inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputs', @@ -4542,7 +4541,7 @@ export const byOutputName2 = { */ export const get86 = oc .route({ - description: 'One node\'s declared outputs for a published workflow run.', + description: "One node's declared outputs for a published workflow run.", inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeId', @@ -4562,7 +4561,7 @@ export const byNodeId10 = { */ export const get87 = oc .route({ - description: 'Snapshot of every node\'s declared outputs for a published workflow run.', + description: "Snapshot of every node's declared outputs for a published workflow run.", inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputs', diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 7bf3531d1acef0..c5175e7f3f2d9c 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -640,8 +640,8 @@ export type AppMcpServerResponse = { name: string parameters: | { - [key: string]: unknown - } + [key: string]: unknown + } | Array<unknown> | string server_code: string @@ -1223,8 +1223,8 @@ export type WorkflowDraftVariable = { | number | boolean | { - [key: string]: unknown - } + [key: string]: unknown + } | Array<unknown> | null value_type?: string @@ -1398,16 +1398,16 @@ export type AdvancedChatWorkflowRunForListResponse = { version?: string | null } -export type JsonValue - = | string - | number - | number - | boolean - | { +export type JsonValue = + | string + | number + | number + | boolean + | { [key: string]: unknown } - | Array<unknown> - | null + | Array<unknown> + | null export type AgentConfigVersionResponse = { id: string @@ -2059,12 +2059,12 @@ export type WorkflowNodeJobConfig = { workflow_prompt?: string } -export type ComposerSaveStrategy - = | 'node_job_only' - | 'save_as_new_agent' - | 'save_as_new_version' - | 'save_to_current_version' - | 'save_to_roster' +export type ComposerSaveStrategy = + | 'node_job_only' + | 'save_as_new_agent' + | 'save_as_new_version' + | 'save_to_current_version' + | 'save_to_roster' export type AgentComposerSoulLockResponse = { can_unlock?: boolean @@ -2128,14 +2128,14 @@ export type ComposerValidationWarningResponse = { surface?: string | null } -export type WorkflowExecutionStatus - = | 'failed' - | 'partial-succeeded' - | 'paused' - | 'running' - | 'scheduled' - | 'stopped' - | 'succeeded' +export type WorkflowExecutionStatus = + | 'failed' + | 'partial-succeeded' + | 'paused' + | 'running' + | 'scheduled' + | 'stopped' + | 'succeeded' export type NodeStatus = 'failed' | 'idle' | 'ready' | 'running' @@ -2149,14 +2149,14 @@ export type NodeOutputView = { value_preview?: unknown } -export type NodeOutputStatus - = | 'failed' - | 'not_produced' - | 'output_check_failed' - | 'pending' - | 'ready' - | 'running' - | 'type_check_failed' +export type NodeOutputStatus = + | 'failed' + | 'not_produced' + | 'output_check_failed' + | 'pending' + | 'ready' + | 'running' + | 'type_check_failed' export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -2589,19 +2589,19 @@ export type UserActionConfig = { title: string } -export type FormInputConfig - = | ({ - type: 'paragraph' - } & ParagraphInputConfig) +export type FormInputConfig = + | ({ + type: 'paragraph' + } & ParagraphInputConfig) | ({ - type: 'select' - } & SelectInputConfig) + type: 'select' + } & SelectInputConfig) | ({ - type: 'file' - } & FileInputConfig) + type: 'file' + } & FileInputConfig) | ({ - type: 'file-list' - } & FileListInputConfig) + type: 'file-list' + } & FileListInputConfig) export type JsonValue2 = unknown @@ -2820,15 +2820,15 @@ export type DeclaredOutputRetryConfig = { retry_interval_ms?: number } -export type AgentCliToolAuthorizationStatus - = | 'allowed' - | 'authorized' - | 'denied' - | 'forbidden' - | 'not_required' - | 'pending' - | 'pre_authorized' - | 'unauthorized' +export type AgentCliToolAuthorizationStatus = + | 'allowed' + | 'authorized' + | 'denied' + | 'forbidden' + | 'not_required' + | 'pending' + | 'pre_authorized' + | 'unauthorized' export type AgentCliToolEnvConfig = { secret_refs?: Array<AgentSecretRefConfig> @@ -3277,8 +3277,8 @@ export type GetAppsImportsByAppIdCheckDependenciesResponses = { 200: CheckDependenciesResult } -export type GetAppsImportsByAppIdCheckDependenciesResponse - = GetAppsImportsByAppIdCheckDependenciesResponses[keyof GetAppsImportsByAppIdCheckDependenciesResponses] +export type GetAppsImportsByAppIdCheckDependenciesResponse = + GetAppsImportsByAppIdCheckDependenciesResponses[keyof GetAppsImportsByAppIdCheckDependenciesResponses] export type PostAppsImportsByImportIdConfirmData = { body?: never @@ -3293,15 +3293,15 @@ export type PostAppsImportsByImportIdConfirmErrors = { 400: Import } -export type PostAppsImportsByImportIdConfirmError - = PostAppsImportsByImportIdConfirmErrors[keyof PostAppsImportsByImportIdConfirmErrors] +export type PostAppsImportsByImportIdConfirmError = + PostAppsImportsByImportIdConfirmErrors[keyof PostAppsImportsByImportIdConfirmErrors] export type PostAppsImportsByImportIdConfirmResponses = { 200: Import } -export type PostAppsImportsByImportIdConfirmResponse - = PostAppsImportsByImportIdConfirmResponses[keyof PostAppsImportsByImportIdConfirmResponses] +export type PostAppsImportsByImportIdConfirmResponse = + PostAppsImportsByImportIdConfirmResponses[keyof PostAppsImportsByImportIdConfirmResponses] export type GetAppsStarredData = { body?: never @@ -3344,8 +3344,8 @@ export type PostAppsWorkflowsOnlineUsersResponses = { 200: WorkflowOnlineUsersResponse } -export type PostAppsWorkflowsOnlineUsersResponse - = PostAppsWorkflowsOnlineUsersResponses[keyof PostAppsWorkflowsOnlineUsersResponses] +export type PostAppsWorkflowsOnlineUsersResponse = + PostAppsWorkflowsOnlineUsersResponses[keyof PostAppsWorkflowsOnlineUsersResponses] export type DeleteAppsByAppIdData = { body?: never @@ -3419,8 +3419,8 @@ export type GetAppsByAppIdAdvancedChatWorkflowRunsResponses = { 200: AdvancedChatWorkflowRunPaginationResponse } -export type GetAppsByAppIdAdvancedChatWorkflowRunsResponse - = GetAppsByAppIdAdvancedChatWorkflowRunsResponses[keyof GetAppsByAppIdAdvancedChatWorkflowRunsResponses] +export type GetAppsByAppIdAdvancedChatWorkflowRunsResponse = + GetAppsByAppIdAdvancedChatWorkflowRunsResponses[keyof GetAppsByAppIdAdvancedChatWorkflowRunsResponses] export type GetAppsByAppIdAdvancedChatWorkflowRunsCountData = { body?: never @@ -3439,8 +3439,8 @@ export type GetAppsByAppIdAdvancedChatWorkflowRunsCountResponses = { 200: WorkflowRunCountResponse } -export type GetAppsByAppIdAdvancedChatWorkflowRunsCountResponse - = GetAppsByAppIdAdvancedChatWorkflowRunsCountResponses[keyof GetAppsByAppIdAdvancedChatWorkflowRunsCountResponses] +export type GetAppsByAppIdAdvancedChatWorkflowRunsCountResponse = + GetAppsByAppIdAdvancedChatWorkflowRunsCountResponses[keyof GetAppsByAppIdAdvancedChatWorkflowRunsCountResponses] export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewData = { body: HumanInputFormPreviewPayload @@ -3456,8 +3456,8 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdForm 200: HumanInputFormPreviewResponse } -export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse - = PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses] +export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse = + PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses] export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunData = { body: HumanInputFormSubmitPayload @@ -3473,8 +3473,8 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdForm 200: HumanInputFormSubmitResponse } -export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse - = PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses] +export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse = + PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses] export type PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunData = { body: IterationNodeRunPayload @@ -3495,8 +3495,8 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunRe 200: GeneratedAppResponse } -export type PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponse - = PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponses] +export type PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponse = + PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponses] export type PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunData = { body: LoopNodeRunPayload @@ -3517,8 +3517,8 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunRespons 200: GeneratedAppResponse } -export type PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponse - = PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponses] +export type PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponse = + PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponses] export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunData = { body: AdvancedChatWorkflowRunPayload @@ -3538,8 +3538,8 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses = { 200: GeneratedAppResponse } -export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse - = PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses] +export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse = + PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses] export type GetAppsByAppIdAgentConfigFilesData = { body?: never @@ -3558,8 +3558,8 @@ export type GetAppsByAppIdAgentConfigFilesResponses = { 200: AgentConfigFileListResponse } -export type GetAppsByAppIdAgentConfigFilesResponse - = GetAppsByAppIdAgentConfigFilesResponses[keyof GetAppsByAppIdAgentConfigFilesResponses] +export type GetAppsByAppIdAgentConfigFilesResponse = + GetAppsByAppIdAgentConfigFilesResponses[keyof GetAppsByAppIdAgentConfigFilesResponses] export type PostAppsByAppIdAgentConfigFilesData = { body: AgentConfigFileUploadPayload @@ -3578,8 +3578,8 @@ export type PostAppsByAppIdAgentConfigFilesResponses = { 201: AgentConfigFileUploadResponse } -export type PostAppsByAppIdAgentConfigFilesResponse - = PostAppsByAppIdAgentConfigFilesResponses[keyof PostAppsByAppIdAgentConfigFilesResponses] +export type PostAppsByAppIdAgentConfigFilesResponse = + PostAppsByAppIdAgentConfigFilesResponses[keyof PostAppsByAppIdAgentConfigFilesResponses] export type DeleteAppsByAppIdAgentConfigFilesByNameData = { body?: never @@ -3599,8 +3599,8 @@ export type DeleteAppsByAppIdAgentConfigFilesByNameResponses = { 200: AgentConfigDeleteResponse } -export type DeleteAppsByAppIdAgentConfigFilesByNameResponse - = DeleteAppsByAppIdAgentConfigFilesByNameResponses[keyof DeleteAppsByAppIdAgentConfigFilesByNameResponses] +export type DeleteAppsByAppIdAgentConfigFilesByNameResponse = + DeleteAppsByAppIdAgentConfigFilesByNameResponses[keyof DeleteAppsByAppIdAgentConfigFilesByNameResponses] export type GetAppsByAppIdAgentConfigFilesByNameDownloadData = { body?: never @@ -3620,8 +3620,8 @@ export type GetAppsByAppIdAgentConfigFilesByNameDownloadResponses = { 200: AgentConfigDownloadResponse } -export type GetAppsByAppIdAgentConfigFilesByNameDownloadResponse - = GetAppsByAppIdAgentConfigFilesByNameDownloadResponses[keyof GetAppsByAppIdAgentConfigFilesByNameDownloadResponses] +export type GetAppsByAppIdAgentConfigFilesByNameDownloadResponse = + GetAppsByAppIdAgentConfigFilesByNameDownloadResponses[keyof GetAppsByAppIdAgentConfigFilesByNameDownloadResponses] export type GetAppsByAppIdAgentConfigFilesByNamePreviewData = { body?: never @@ -3641,8 +3641,8 @@ export type GetAppsByAppIdAgentConfigFilesByNamePreviewResponses = { 200: AgentConfigFilePreviewResponse } -export type GetAppsByAppIdAgentConfigFilesByNamePreviewResponse - = GetAppsByAppIdAgentConfigFilesByNamePreviewResponses[keyof GetAppsByAppIdAgentConfigFilesByNamePreviewResponses] +export type GetAppsByAppIdAgentConfigFilesByNamePreviewResponse = + GetAppsByAppIdAgentConfigFilesByNamePreviewResponses[keyof GetAppsByAppIdAgentConfigFilesByNamePreviewResponses] export type GetAppsByAppIdAgentConfigManifestData = { body?: never @@ -3661,8 +3661,8 @@ export type GetAppsByAppIdAgentConfigManifestResponses = { 200: AgentConfigManifestResponse } -export type GetAppsByAppIdAgentConfigManifestResponse - = GetAppsByAppIdAgentConfigManifestResponses[keyof GetAppsByAppIdAgentConfigManifestResponses] +export type GetAppsByAppIdAgentConfigManifestResponse = + GetAppsByAppIdAgentConfigManifestResponses[keyof GetAppsByAppIdAgentConfigManifestResponses] export type GetAppsByAppIdAgentConfigSkillsData = { body?: never @@ -3681,8 +3681,8 @@ export type GetAppsByAppIdAgentConfigSkillsResponses = { 200: AgentConfigSkillListResponse } -export type GetAppsByAppIdAgentConfigSkillsResponse - = GetAppsByAppIdAgentConfigSkillsResponses[keyof GetAppsByAppIdAgentConfigSkillsResponses] +export type GetAppsByAppIdAgentConfigSkillsResponse = + GetAppsByAppIdAgentConfigSkillsResponses[keyof GetAppsByAppIdAgentConfigSkillsResponses] export type PostAppsByAppIdAgentConfigSkillsUploadData = { body: { @@ -3703,8 +3703,8 @@ export type PostAppsByAppIdAgentConfigSkillsUploadResponses = { 201: AgentConfigSkillUploadResponse } -export type PostAppsByAppIdAgentConfigSkillsUploadResponse - = PostAppsByAppIdAgentConfigSkillsUploadResponses[keyof PostAppsByAppIdAgentConfigSkillsUploadResponses] +export type PostAppsByAppIdAgentConfigSkillsUploadResponse = + PostAppsByAppIdAgentConfigSkillsUploadResponses[keyof PostAppsByAppIdAgentConfigSkillsUploadResponses] export type DeleteAppsByAppIdAgentConfigSkillsByNameData = { body?: never @@ -3724,8 +3724,8 @@ export type DeleteAppsByAppIdAgentConfigSkillsByNameResponses = { 200: AgentConfigDeleteResponse } -export type DeleteAppsByAppIdAgentConfigSkillsByNameResponse - = DeleteAppsByAppIdAgentConfigSkillsByNameResponses[keyof DeleteAppsByAppIdAgentConfigSkillsByNameResponses] +export type DeleteAppsByAppIdAgentConfigSkillsByNameResponse = + DeleteAppsByAppIdAgentConfigSkillsByNameResponses[keyof DeleteAppsByAppIdAgentConfigSkillsByNameResponses] export type GetAppsByAppIdAgentConfigSkillsByNameDownloadData = { body?: never @@ -3745,8 +3745,8 @@ export type GetAppsByAppIdAgentConfigSkillsByNameDownloadResponses = { 200: AgentConfigDownloadResponse } -export type GetAppsByAppIdAgentConfigSkillsByNameDownloadResponse - = GetAppsByAppIdAgentConfigSkillsByNameDownloadResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameDownloadResponses] +export type GetAppsByAppIdAgentConfigSkillsByNameDownloadResponse = + GetAppsByAppIdAgentConfigSkillsByNameDownloadResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameDownloadResponses] export type GetAppsByAppIdAgentConfigSkillsByNameFilesContentData = { body?: never @@ -3764,8 +3764,8 @@ export type GetAppsByAppIdAgentConfigSkillsByNameFilesContentResponses = { } } -export type GetAppsByAppIdAgentConfigSkillsByNameFilesContentResponse - = GetAppsByAppIdAgentConfigSkillsByNameFilesContentResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameFilesContentResponses] +export type GetAppsByAppIdAgentConfigSkillsByNameFilesContentResponse = + GetAppsByAppIdAgentConfigSkillsByNameFilesContentResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameFilesContentResponses] export type GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadData = { body?: never @@ -3786,8 +3786,8 @@ export type GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponses = { 200: AgentConfigDownloadResponse } -export type GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponse - = GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponses] +export type GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponse = + GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponses] export type GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewData = { body?: never @@ -3808,8 +3808,8 @@ export type GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponses = { 200: AgentConfigSkillFilePreviewResponse } -export type GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponse - = GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponses] +export type GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponse = + GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponses] export type GetAppsByAppIdAgentConfigSkillsByNameInspectData = { body?: never @@ -3829,8 +3829,8 @@ export type GetAppsByAppIdAgentConfigSkillsByNameInspectResponses = { 200: AgentConfigSkillInspectResponse } -export type GetAppsByAppIdAgentConfigSkillsByNameInspectResponse - = GetAppsByAppIdAgentConfigSkillsByNameInspectResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameInspectResponses] +export type GetAppsByAppIdAgentConfigSkillsByNameInspectResponse = + GetAppsByAppIdAgentConfigSkillsByNameInspectResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameInspectResponses] export type GetAppsByAppIdAgentDriveFilesData = { body?: never @@ -3848,8 +3848,8 @@ export type GetAppsByAppIdAgentDriveFilesResponses = { 200: AgentDriveListResponse } -export type GetAppsByAppIdAgentDriveFilesResponse - = GetAppsByAppIdAgentDriveFilesResponses[keyof GetAppsByAppIdAgentDriveFilesResponses] +export type GetAppsByAppIdAgentDriveFilesResponse = + GetAppsByAppIdAgentDriveFilesResponses[keyof GetAppsByAppIdAgentDriveFilesResponses] export type GetAppsByAppIdAgentDriveFilesDownloadData = { body?: never @@ -3867,8 +3867,8 @@ export type GetAppsByAppIdAgentDriveFilesDownloadResponses = { 200: AgentDriveDownloadResponse } -export type GetAppsByAppIdAgentDriveFilesDownloadResponse - = GetAppsByAppIdAgentDriveFilesDownloadResponses[keyof GetAppsByAppIdAgentDriveFilesDownloadResponses] +export type GetAppsByAppIdAgentDriveFilesDownloadResponse = + GetAppsByAppIdAgentDriveFilesDownloadResponses[keyof GetAppsByAppIdAgentDriveFilesDownloadResponses] export type GetAppsByAppIdAgentDriveFilesPreviewData = { body?: never @@ -3886,8 +3886,8 @@ export type GetAppsByAppIdAgentDriveFilesPreviewResponses = { 200: AgentDrivePreviewResponse } -export type GetAppsByAppIdAgentDriveFilesPreviewResponse - = GetAppsByAppIdAgentDriveFilesPreviewResponses[keyof GetAppsByAppIdAgentDriveFilesPreviewResponses] +export type GetAppsByAppIdAgentDriveFilesPreviewResponse = + GetAppsByAppIdAgentDriveFilesPreviewResponses[keyof GetAppsByAppIdAgentDriveFilesPreviewResponses] export type GetAppsByAppIdAgentDriveSkillsData = { body?: never @@ -3905,8 +3905,8 @@ export type GetAppsByAppIdAgentDriveSkillsResponses = { 200: AgentDriveSkillListResponse } -export type GetAppsByAppIdAgentDriveSkillsResponse - = GetAppsByAppIdAgentDriveSkillsResponses[keyof GetAppsByAppIdAgentDriveSkillsResponses] +export type GetAppsByAppIdAgentDriveSkillsResponse = + GetAppsByAppIdAgentDriveSkillsResponses[keyof GetAppsByAppIdAgentDriveSkillsResponses] export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectData = { body?: never @@ -3924,8 +3924,8 @@ export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses = { 200: AgentDriveSkillInspectResponse } -export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse - = GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses[keyof GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses] +export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse = + GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses[keyof GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses] export type DeleteAppsByAppIdAgentFilesData = { body?: never @@ -3943,8 +3943,8 @@ export type DeleteAppsByAppIdAgentFilesResponses = { 200: AgentDriveDeleteResponse } -export type DeleteAppsByAppIdAgentFilesResponse - = DeleteAppsByAppIdAgentFilesResponses[keyof DeleteAppsByAppIdAgentFilesResponses] +export type DeleteAppsByAppIdAgentFilesResponse = + DeleteAppsByAppIdAgentFilesResponses[keyof DeleteAppsByAppIdAgentFilesResponses] export type PostAppsByAppIdAgentFilesData = { body: AgentDriveFilePayload @@ -3961,8 +3961,8 @@ export type PostAppsByAppIdAgentFilesResponses = { 201: AgentDriveFileCommitResponse } -export type PostAppsByAppIdAgentFilesResponse - = PostAppsByAppIdAgentFilesResponses[keyof PostAppsByAppIdAgentFilesResponses] +export type PostAppsByAppIdAgentFilesResponse = + PostAppsByAppIdAgentFilesResponses[keyof PostAppsByAppIdAgentFilesResponses] export type GetAppsByAppIdAgentLogsData = { body?: never @@ -3984,8 +3984,8 @@ export type GetAppsByAppIdAgentLogsResponses = { 200: AgentLogResponse } -export type GetAppsByAppIdAgentLogsResponse - = GetAppsByAppIdAgentLogsResponses[keyof GetAppsByAppIdAgentLogsResponses] +export type GetAppsByAppIdAgentLogsResponse = + GetAppsByAppIdAgentLogsResponses[keyof GetAppsByAppIdAgentLogsResponses] export type PostAppsByAppIdAgentSkillsUploadData = { body: { @@ -4008,8 +4008,8 @@ export type PostAppsByAppIdAgentSkillsUploadResponses = { 201: AgentSkillUploadResponse } -export type PostAppsByAppIdAgentSkillsUploadResponse - = PostAppsByAppIdAgentSkillsUploadResponses[keyof PostAppsByAppIdAgentSkillsUploadResponses] +export type PostAppsByAppIdAgentSkillsUploadResponse = + PostAppsByAppIdAgentSkillsUploadResponses[keyof PostAppsByAppIdAgentSkillsUploadResponses] export type DeleteAppsByAppIdAgentSkillsBySlugData = { body?: never @@ -4027,8 +4027,8 @@ export type DeleteAppsByAppIdAgentSkillsBySlugResponses = { 200: AgentDriveDeleteResponse } -export type DeleteAppsByAppIdAgentSkillsBySlugResponse - = DeleteAppsByAppIdAgentSkillsBySlugResponses[keyof DeleteAppsByAppIdAgentSkillsBySlugResponses] +export type DeleteAppsByAppIdAgentSkillsBySlugResponse = + DeleteAppsByAppIdAgentSkillsBySlugResponses[keyof DeleteAppsByAppIdAgentSkillsBySlugResponses] export type PostAppsByAppIdAgentSkillsBySlugInferToolsData = { body?: never @@ -4046,8 +4046,8 @@ export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponses = { 200: SkillToolInferenceResult } -export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponse - = PostAppsByAppIdAgentSkillsBySlugInferToolsResponses[keyof PostAppsByAppIdAgentSkillsBySlugInferToolsResponses] +export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponse = + PostAppsByAppIdAgentSkillsBySlugInferToolsResponses[keyof PostAppsByAppIdAgentSkillsBySlugInferToolsResponses] export type PostAppsByAppIdAnnotationReplyByActionData = { body: AnnotationReplyPayload @@ -4067,8 +4067,8 @@ export type PostAppsByAppIdAnnotationReplyByActionResponses = { 200: AnnotationJobStatusResponse } -export type PostAppsByAppIdAnnotationReplyByActionResponse - = PostAppsByAppIdAnnotationReplyByActionResponses[keyof PostAppsByAppIdAnnotationReplyByActionResponses] +export type PostAppsByAppIdAnnotationReplyByActionResponse = + PostAppsByAppIdAnnotationReplyByActionResponses[keyof PostAppsByAppIdAnnotationReplyByActionResponses] export type GetAppsByAppIdAnnotationReplyByActionStatusByJobIdData = { body?: never @@ -4089,8 +4089,8 @@ export type GetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponses = { 200: AnnotationJobStatusDetailResponse } -export type GetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse - = GetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponses[keyof GetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponses] +export type GetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse = + GetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponses[keyof GetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponses] export type GetAppsByAppIdAnnotationSettingData = { body?: never @@ -4109,8 +4109,8 @@ export type GetAppsByAppIdAnnotationSettingResponses = { 200: AnnotationSettingResponse } -export type GetAppsByAppIdAnnotationSettingResponse - = GetAppsByAppIdAnnotationSettingResponses[keyof GetAppsByAppIdAnnotationSettingResponses] +export type GetAppsByAppIdAnnotationSettingResponse = + GetAppsByAppIdAnnotationSettingResponses[keyof GetAppsByAppIdAnnotationSettingResponses] export type PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdData = { body: AnnotationSettingUpdatePayload @@ -4130,8 +4130,8 @@ export type PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponses = { 200: AnnotationSettingResponse } -export type PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse - = PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponses[keyof PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponses] +export type PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse = + PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponses[keyof PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponses] export type DeleteAppsByAppIdAnnotationsData = { body?: never @@ -4146,8 +4146,8 @@ export type DeleteAppsByAppIdAnnotationsResponses = { 204: void } -export type DeleteAppsByAppIdAnnotationsResponse - = DeleteAppsByAppIdAnnotationsResponses[keyof DeleteAppsByAppIdAnnotationsResponses] +export type DeleteAppsByAppIdAnnotationsResponse = + DeleteAppsByAppIdAnnotationsResponses[keyof DeleteAppsByAppIdAnnotationsResponses] export type GetAppsByAppIdAnnotationsData = { body?: never @@ -4170,8 +4170,8 @@ export type GetAppsByAppIdAnnotationsResponses = { 200: AnnotationList } -export type GetAppsByAppIdAnnotationsResponse - = GetAppsByAppIdAnnotationsResponses[keyof GetAppsByAppIdAnnotationsResponses] +export type GetAppsByAppIdAnnotationsResponse = + GetAppsByAppIdAnnotationsResponses[keyof GetAppsByAppIdAnnotationsResponses] export type PostAppsByAppIdAnnotationsData = { body: CreateAnnotationPayload @@ -4190,8 +4190,8 @@ export type PostAppsByAppIdAnnotationsResponses = { 201: Annotation } -export type PostAppsByAppIdAnnotationsResponse - = PostAppsByAppIdAnnotationsResponses[keyof PostAppsByAppIdAnnotationsResponses] +export type PostAppsByAppIdAnnotationsResponse = + PostAppsByAppIdAnnotationsResponses[keyof PostAppsByAppIdAnnotationsResponses] export type PostAppsByAppIdAnnotationsBatchImportData = { body?: never @@ -4213,8 +4213,8 @@ export type PostAppsByAppIdAnnotationsBatchImportResponses = { 200: AnnotationBatchImportResponse } -export type PostAppsByAppIdAnnotationsBatchImportResponse - = PostAppsByAppIdAnnotationsBatchImportResponses[keyof PostAppsByAppIdAnnotationsBatchImportResponses] +export type PostAppsByAppIdAnnotationsBatchImportResponse = + PostAppsByAppIdAnnotationsBatchImportResponses[keyof PostAppsByAppIdAnnotationsBatchImportResponses] export type GetAppsByAppIdAnnotationsBatchImportStatusByJobIdData = { body?: never @@ -4234,8 +4234,8 @@ export type GetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponses = { 200: AnnotationJobStatusDetailResponse } -export type GetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse - = GetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponses[keyof GetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponses] +export type GetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse = + GetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponses[keyof GetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponses] export type GetAppsByAppIdAnnotationsCountData = { body?: never @@ -4250,8 +4250,8 @@ export type GetAppsByAppIdAnnotationsCountResponses = { 200: AnnotationCountResponse } -export type GetAppsByAppIdAnnotationsCountResponse - = GetAppsByAppIdAnnotationsCountResponses[keyof GetAppsByAppIdAnnotationsCountResponses] +export type GetAppsByAppIdAnnotationsCountResponse = + GetAppsByAppIdAnnotationsCountResponses[keyof GetAppsByAppIdAnnotationsCountResponses] export type GetAppsByAppIdAnnotationsExportData = { body?: never @@ -4270,8 +4270,8 @@ export type GetAppsByAppIdAnnotationsExportResponses = { 200: AnnotationExportList } -export type GetAppsByAppIdAnnotationsExportResponse - = GetAppsByAppIdAnnotationsExportResponses[keyof GetAppsByAppIdAnnotationsExportResponses] +export type GetAppsByAppIdAnnotationsExportResponse = + GetAppsByAppIdAnnotationsExportResponses[keyof GetAppsByAppIdAnnotationsExportResponses] export type DeleteAppsByAppIdAnnotationsByAnnotationIdData = { body?: never @@ -4287,8 +4287,8 @@ export type DeleteAppsByAppIdAnnotationsByAnnotationIdResponses = { 204: void } -export type DeleteAppsByAppIdAnnotationsByAnnotationIdResponse - = DeleteAppsByAppIdAnnotationsByAnnotationIdResponses[keyof DeleteAppsByAppIdAnnotationsByAnnotationIdResponses] +export type DeleteAppsByAppIdAnnotationsByAnnotationIdResponse = + DeleteAppsByAppIdAnnotationsByAnnotationIdResponses[keyof DeleteAppsByAppIdAnnotationsByAnnotationIdResponses] export type PostAppsByAppIdAnnotationsByAnnotationIdData = { body: UpdateAnnotationPayload @@ -4309,8 +4309,8 @@ export type PostAppsByAppIdAnnotationsByAnnotationIdResponses = { 204: void } -export type PostAppsByAppIdAnnotationsByAnnotationIdResponse - = PostAppsByAppIdAnnotationsByAnnotationIdResponses[keyof PostAppsByAppIdAnnotationsByAnnotationIdResponses] +export type PostAppsByAppIdAnnotationsByAnnotationIdResponse = + PostAppsByAppIdAnnotationsByAnnotationIdResponses[keyof PostAppsByAppIdAnnotationsByAnnotationIdResponses] export type GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesData = { body?: never @@ -4333,8 +4333,8 @@ export type GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponses = { 200: AnnotationHitHistoryList } -export type GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse - = GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponses[keyof GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponses] +export type GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse = + GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponses[keyof GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponses] export type PostAppsByAppIdApiEnableData = { body: AppApiStatusPayload @@ -4353,8 +4353,8 @@ export type PostAppsByAppIdApiEnableResponses = { 200: AppDetail } -export type PostAppsByAppIdApiEnableResponse - = PostAppsByAppIdApiEnableResponses[keyof PostAppsByAppIdApiEnableResponses] +export type PostAppsByAppIdApiEnableResponse = + PostAppsByAppIdApiEnableResponses[keyof PostAppsByAppIdApiEnableResponses] export type PostAppsByAppIdAudioToTextData = { body?: never @@ -4374,8 +4374,8 @@ export type PostAppsByAppIdAudioToTextResponses = { 200: AudioTranscriptResponse } -export type PostAppsByAppIdAudioToTextResponse - = PostAppsByAppIdAudioToTextResponses[keyof PostAppsByAppIdAudioToTextResponses] +export type PostAppsByAppIdAudioToTextResponse = + PostAppsByAppIdAudioToTextResponses[keyof PostAppsByAppIdAudioToTextResponses] export type GetAppsByAppIdChatConversationsData = { body?: never @@ -4402,8 +4402,8 @@ export type GetAppsByAppIdChatConversationsResponses = { 200: ConversationWithSummaryPagination } -export type GetAppsByAppIdChatConversationsResponse - = GetAppsByAppIdChatConversationsResponses[keyof GetAppsByAppIdChatConversationsResponses] +export type GetAppsByAppIdChatConversationsResponse = + GetAppsByAppIdChatConversationsResponses[keyof GetAppsByAppIdChatConversationsResponses] export type DeleteAppsByAppIdChatConversationsByConversationIdData = { body?: never @@ -4424,8 +4424,8 @@ export type DeleteAppsByAppIdChatConversationsByConversationIdResponses = { 204: void } -export type DeleteAppsByAppIdChatConversationsByConversationIdResponse - = DeleteAppsByAppIdChatConversationsByConversationIdResponses[keyof DeleteAppsByAppIdChatConversationsByConversationIdResponses] +export type DeleteAppsByAppIdChatConversationsByConversationIdResponse = + DeleteAppsByAppIdChatConversationsByConversationIdResponses[keyof DeleteAppsByAppIdChatConversationsByConversationIdResponses] export type GetAppsByAppIdChatConversationsByConversationIdData = { body?: never @@ -4446,8 +4446,8 @@ export type GetAppsByAppIdChatConversationsByConversationIdResponses = { 200: ConversationDetail } -export type GetAppsByAppIdChatConversationsByConversationIdResponse - = GetAppsByAppIdChatConversationsByConversationIdResponses[keyof GetAppsByAppIdChatConversationsByConversationIdResponses] +export type GetAppsByAppIdChatConversationsByConversationIdResponse = + GetAppsByAppIdChatConversationsByConversationIdResponses[keyof GetAppsByAppIdChatConversationsByConversationIdResponses] export type GetAppsByAppIdChatMessagesData = { body?: never @@ -4470,8 +4470,8 @@ export type GetAppsByAppIdChatMessagesResponses = { 200: MessageInfiniteScrollPaginationResponse } -export type GetAppsByAppIdChatMessagesResponse - = GetAppsByAppIdChatMessagesResponses[keyof GetAppsByAppIdChatMessagesResponses] +export type GetAppsByAppIdChatMessagesResponse = + GetAppsByAppIdChatMessagesResponses[keyof GetAppsByAppIdChatMessagesResponses] export type GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsData = { body?: never @@ -4491,8 +4491,8 @@ export type GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponses = { 200: SuggestedQuestionsResponse } -export type GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse - = GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponses[keyof GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponses] +export type GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse = + GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponses[keyof GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponses] export type PostAppsByAppIdChatMessagesByTaskIdStopData = { body?: never @@ -4508,8 +4508,8 @@ export type PostAppsByAppIdChatMessagesByTaskIdStopResponses = { 200: SimpleResultResponse } -export type PostAppsByAppIdChatMessagesByTaskIdStopResponse - = PostAppsByAppIdChatMessagesByTaskIdStopResponses[keyof PostAppsByAppIdChatMessagesByTaskIdStopResponses] +export type PostAppsByAppIdChatMessagesByTaskIdStopResponse = + PostAppsByAppIdChatMessagesByTaskIdStopResponses[keyof PostAppsByAppIdChatMessagesByTaskIdStopResponses] export type GetAppsByAppIdCompletionConversationsData = { body?: never @@ -4535,8 +4535,8 @@ export type GetAppsByAppIdCompletionConversationsResponses = { 200: ConversationPagination } -export type GetAppsByAppIdCompletionConversationsResponse - = GetAppsByAppIdCompletionConversationsResponses[keyof GetAppsByAppIdCompletionConversationsResponses] +export type GetAppsByAppIdCompletionConversationsResponse = + GetAppsByAppIdCompletionConversationsResponses[keyof GetAppsByAppIdCompletionConversationsResponses] export type DeleteAppsByAppIdCompletionConversationsByConversationIdData = { body?: never @@ -4557,8 +4557,8 @@ export type DeleteAppsByAppIdCompletionConversationsByConversationIdResponses = 204: void } -export type DeleteAppsByAppIdCompletionConversationsByConversationIdResponse - = DeleteAppsByAppIdCompletionConversationsByConversationIdResponses[keyof DeleteAppsByAppIdCompletionConversationsByConversationIdResponses] +export type DeleteAppsByAppIdCompletionConversationsByConversationIdResponse = + DeleteAppsByAppIdCompletionConversationsByConversationIdResponses[keyof DeleteAppsByAppIdCompletionConversationsByConversationIdResponses] export type GetAppsByAppIdCompletionConversationsByConversationIdData = { body?: never @@ -4579,8 +4579,8 @@ export type GetAppsByAppIdCompletionConversationsByConversationIdResponses = { 200: ConversationMessageDetail } -export type GetAppsByAppIdCompletionConversationsByConversationIdResponse - = GetAppsByAppIdCompletionConversationsByConversationIdResponses[keyof GetAppsByAppIdCompletionConversationsByConversationIdResponses] +export type GetAppsByAppIdCompletionConversationsByConversationIdResponse = + GetAppsByAppIdCompletionConversationsByConversationIdResponses[keyof GetAppsByAppIdCompletionConversationsByConversationIdResponses] export type PostAppsByAppIdCompletionMessagesData = { body: CompletionMessagePayload @@ -4602,8 +4602,8 @@ export type PostAppsByAppIdCompletionMessagesResponses = { } } -export type PostAppsByAppIdCompletionMessagesResponse - = PostAppsByAppIdCompletionMessagesResponses[keyof PostAppsByAppIdCompletionMessagesResponses] +export type PostAppsByAppIdCompletionMessagesResponse = + PostAppsByAppIdCompletionMessagesResponses[keyof PostAppsByAppIdCompletionMessagesResponses] export type PostAppsByAppIdCompletionMessagesByTaskIdStopData = { body?: never @@ -4619,8 +4619,8 @@ export type PostAppsByAppIdCompletionMessagesByTaskIdStopResponses = { 200: SimpleResultResponse } -export type PostAppsByAppIdCompletionMessagesByTaskIdStopResponse - = PostAppsByAppIdCompletionMessagesByTaskIdStopResponses[keyof PostAppsByAppIdCompletionMessagesByTaskIdStopResponses] +export type PostAppsByAppIdCompletionMessagesByTaskIdStopResponse = + PostAppsByAppIdCompletionMessagesByTaskIdStopResponses[keyof PostAppsByAppIdCompletionMessagesByTaskIdStopResponses] export type GetAppsByAppIdConversationVariablesData = { body?: never @@ -4637,8 +4637,8 @@ export type GetAppsByAppIdConversationVariablesResponses = { 200: PaginatedConversationVariableResponse } -export type GetAppsByAppIdConversationVariablesResponse - = GetAppsByAppIdConversationVariablesResponses[keyof GetAppsByAppIdConversationVariablesResponses] +export type GetAppsByAppIdConversationVariablesResponse = + GetAppsByAppIdConversationVariablesResponses[keyof GetAppsByAppIdConversationVariablesResponses] export type PostAppsByAppIdConvertToWorkflowData = { body: ConvertToWorkflowPayload @@ -4658,8 +4658,8 @@ export type PostAppsByAppIdConvertToWorkflowResponses = { 200: NewAppResponse } -export type PostAppsByAppIdConvertToWorkflowResponse - = PostAppsByAppIdConvertToWorkflowResponses[keyof PostAppsByAppIdConvertToWorkflowResponses] +export type PostAppsByAppIdConvertToWorkflowResponse = + PostAppsByAppIdConvertToWorkflowResponses[keyof PostAppsByAppIdConvertToWorkflowResponses] export type PostAppsByAppIdCopyData = { body: CopyAppPayload @@ -4679,8 +4679,8 @@ export type PostAppsByAppIdCopyResponses = { 202: AppImportResponse } -export type PostAppsByAppIdCopyResponse - = PostAppsByAppIdCopyResponses[keyof PostAppsByAppIdCopyResponses] +export type PostAppsByAppIdCopyResponse = + PostAppsByAppIdCopyResponses[keyof PostAppsByAppIdCopyResponses] export type GetAppsByAppIdExportData = { body?: never @@ -4702,8 +4702,8 @@ export type GetAppsByAppIdExportResponses = { 200: AppExportResponse } -export type GetAppsByAppIdExportResponse - = GetAppsByAppIdExportResponses[keyof GetAppsByAppIdExportResponses] +export type GetAppsByAppIdExportResponse = + GetAppsByAppIdExportResponses[keyof GetAppsByAppIdExportResponses] export type PostAppsByAppIdFeedbacksData = { body: MessageFeedbackPayload @@ -4723,8 +4723,8 @@ export type PostAppsByAppIdFeedbacksResponses = { 200: SimpleResultResponse } -export type PostAppsByAppIdFeedbacksResponse - = PostAppsByAppIdFeedbacksResponses[keyof PostAppsByAppIdFeedbacksResponses] +export type PostAppsByAppIdFeedbacksResponse = + PostAppsByAppIdFeedbacksResponses[keyof PostAppsByAppIdFeedbacksResponses] export type GetAppsByAppIdFeedbacksExportData = { body?: never @@ -4751,8 +4751,8 @@ export type GetAppsByAppIdFeedbacksExportResponses = { 200: TextFileResponse } -export type GetAppsByAppIdFeedbacksExportResponse - = GetAppsByAppIdFeedbacksExportResponses[keyof GetAppsByAppIdFeedbacksExportResponses] +export type GetAppsByAppIdFeedbacksExportResponse = + GetAppsByAppIdFeedbacksExportResponses[keyof GetAppsByAppIdFeedbacksExportResponses] export type PostAppsByAppIdIconData = { body: AppIconPayload @@ -4771,8 +4771,8 @@ export type PostAppsByAppIdIconResponses = { 200: AppDetail } -export type PostAppsByAppIdIconResponse - = PostAppsByAppIdIconResponses[keyof PostAppsByAppIdIconResponses] +export type PostAppsByAppIdIconResponse = + PostAppsByAppIdIconResponses[keyof PostAppsByAppIdIconResponses] export type GetAppsByAppIdMessagesByMessageIdData = { body?: never @@ -4792,8 +4792,8 @@ export type GetAppsByAppIdMessagesByMessageIdResponses = { 200: MessageDetailResponse } -export type GetAppsByAppIdMessagesByMessageIdResponse - = GetAppsByAppIdMessagesByMessageIdResponses[keyof GetAppsByAppIdMessagesByMessageIdResponses] +export type GetAppsByAppIdMessagesByMessageIdResponse = + GetAppsByAppIdMessagesByMessageIdResponses[keyof GetAppsByAppIdMessagesByMessageIdResponses] export type PostAppsByAppIdModelConfigData = { body: ModelConfigRequest @@ -4813,8 +4813,8 @@ export type PostAppsByAppIdModelConfigResponses = { 200: SimpleResultResponse } -export type PostAppsByAppIdModelConfigResponse - = PostAppsByAppIdModelConfigResponses[keyof PostAppsByAppIdModelConfigResponses] +export type PostAppsByAppIdModelConfigResponse = + PostAppsByAppIdModelConfigResponses[keyof PostAppsByAppIdModelConfigResponses] export type PostAppsByAppIdNameData = { body: AppNamePayload @@ -4829,8 +4829,8 @@ export type PostAppsByAppIdNameResponses = { 200: AppDetail } -export type PostAppsByAppIdNameResponse - = PostAppsByAppIdNameResponses[keyof PostAppsByAppIdNameResponses] +export type PostAppsByAppIdNameResponse = + PostAppsByAppIdNameResponses[keyof PostAppsByAppIdNameResponses] export type PostAppsByAppIdPublishToCreatorsPlatformData = { body?: never @@ -4845,8 +4845,8 @@ export type PostAppsByAppIdPublishToCreatorsPlatformResponses = { 200: RedirectUrlResponse } -export type PostAppsByAppIdPublishToCreatorsPlatformResponse - = PostAppsByAppIdPublishToCreatorsPlatformResponses[keyof PostAppsByAppIdPublishToCreatorsPlatformResponses] +export type PostAppsByAppIdPublishToCreatorsPlatformResponse = + PostAppsByAppIdPublishToCreatorsPlatformResponses[keyof PostAppsByAppIdPublishToCreatorsPlatformResponses] export type GetAppsByAppIdServerData = { body?: never @@ -4861,8 +4861,8 @@ export type GetAppsByAppIdServerResponses = { 200: AppMcpServerResponse } -export type GetAppsByAppIdServerResponse - = GetAppsByAppIdServerResponses[keyof GetAppsByAppIdServerResponses] +export type GetAppsByAppIdServerResponse = + GetAppsByAppIdServerResponses[keyof GetAppsByAppIdServerResponses] export type PostAppsByAppIdServerData = { body: McpServerCreatePayload @@ -4881,8 +4881,8 @@ export type PostAppsByAppIdServerResponses = { 201: AppMcpServerResponse } -export type PostAppsByAppIdServerResponse - = PostAppsByAppIdServerResponses[keyof PostAppsByAppIdServerResponses] +export type PostAppsByAppIdServerResponse = + PostAppsByAppIdServerResponses[keyof PostAppsByAppIdServerResponses] export type PutAppsByAppIdServerData = { body: McpServerUpdatePayload @@ -4902,8 +4902,8 @@ export type PutAppsByAppIdServerResponses = { 200: AppMcpServerResponse } -export type PutAppsByAppIdServerResponse - = PutAppsByAppIdServerResponses[keyof PutAppsByAppIdServerResponses] +export type PutAppsByAppIdServerResponse = + PutAppsByAppIdServerResponses[keyof PutAppsByAppIdServerResponses] export type PostAppsByAppIdSiteData = { body: AppSiteUpdatePayload @@ -4923,8 +4923,8 @@ export type PostAppsByAppIdSiteResponses = { 200: AppSiteResponse } -export type PostAppsByAppIdSiteResponse - = PostAppsByAppIdSiteResponses[keyof PostAppsByAppIdSiteResponses] +export type PostAppsByAppIdSiteResponse = + PostAppsByAppIdSiteResponses[keyof PostAppsByAppIdSiteResponses] export type PostAppsByAppIdSiteEnableData = { body: AppSiteStatusPayload @@ -4943,8 +4943,8 @@ export type PostAppsByAppIdSiteEnableResponses = { 200: AppDetail } -export type PostAppsByAppIdSiteEnableResponse - = PostAppsByAppIdSiteEnableResponses[keyof PostAppsByAppIdSiteEnableResponses] +export type PostAppsByAppIdSiteEnableResponse = + PostAppsByAppIdSiteEnableResponses[keyof PostAppsByAppIdSiteEnableResponses] export type PostAppsByAppIdSiteAccessTokenResetData = { body?: never @@ -4964,8 +4964,8 @@ export type PostAppsByAppIdSiteAccessTokenResetResponses = { 200: AppSiteResponse } -export type PostAppsByAppIdSiteAccessTokenResetResponse - = PostAppsByAppIdSiteAccessTokenResetResponses[keyof PostAppsByAppIdSiteAccessTokenResetResponses] +export type PostAppsByAppIdSiteAccessTokenResetResponse = + PostAppsByAppIdSiteAccessTokenResetResponses[keyof PostAppsByAppIdSiteAccessTokenResetResponses] export type DeleteAppsByAppIdStarData = { body?: never @@ -4984,8 +4984,8 @@ export type DeleteAppsByAppIdStarResponses = { 200: SimpleResultResponse } -export type DeleteAppsByAppIdStarResponse - = DeleteAppsByAppIdStarResponses[keyof DeleteAppsByAppIdStarResponses] +export type DeleteAppsByAppIdStarResponse = + DeleteAppsByAppIdStarResponses[keyof DeleteAppsByAppIdStarResponses] export type PostAppsByAppIdStarData = { body?: never @@ -5004,8 +5004,8 @@ export type PostAppsByAppIdStarResponses = { 200: SimpleResultResponse } -export type PostAppsByAppIdStarResponse - = PostAppsByAppIdStarResponses[keyof PostAppsByAppIdStarResponses] +export type PostAppsByAppIdStarResponse = + PostAppsByAppIdStarResponses[keyof PostAppsByAppIdStarResponses] export type GetAppsByAppIdStatisticsAverageResponseTimeData = { body?: never @@ -5023,8 +5023,8 @@ export type GetAppsByAppIdStatisticsAverageResponseTimeResponses = { 200: AverageResponseTimeStatisticResponse } -export type GetAppsByAppIdStatisticsAverageResponseTimeResponse - = GetAppsByAppIdStatisticsAverageResponseTimeResponses[keyof GetAppsByAppIdStatisticsAverageResponseTimeResponses] +export type GetAppsByAppIdStatisticsAverageResponseTimeResponse = + GetAppsByAppIdStatisticsAverageResponseTimeResponses[keyof GetAppsByAppIdStatisticsAverageResponseTimeResponses] export type GetAppsByAppIdStatisticsAverageSessionInteractionsData = { body?: never @@ -5042,8 +5042,8 @@ export type GetAppsByAppIdStatisticsAverageSessionInteractionsResponses = { 200: AverageSessionInteractionStatisticResponse } -export type GetAppsByAppIdStatisticsAverageSessionInteractionsResponse - = GetAppsByAppIdStatisticsAverageSessionInteractionsResponses[keyof GetAppsByAppIdStatisticsAverageSessionInteractionsResponses] +export type GetAppsByAppIdStatisticsAverageSessionInteractionsResponse = + GetAppsByAppIdStatisticsAverageSessionInteractionsResponses[keyof GetAppsByAppIdStatisticsAverageSessionInteractionsResponses] export type GetAppsByAppIdStatisticsDailyConversationsData = { body?: never @@ -5061,8 +5061,8 @@ export type GetAppsByAppIdStatisticsDailyConversationsResponses = { 200: DailyConversationStatisticResponse } -export type GetAppsByAppIdStatisticsDailyConversationsResponse - = GetAppsByAppIdStatisticsDailyConversationsResponses[keyof GetAppsByAppIdStatisticsDailyConversationsResponses] +export type GetAppsByAppIdStatisticsDailyConversationsResponse = + GetAppsByAppIdStatisticsDailyConversationsResponses[keyof GetAppsByAppIdStatisticsDailyConversationsResponses] export type GetAppsByAppIdStatisticsDailyEndUsersData = { body?: never @@ -5080,8 +5080,8 @@ export type GetAppsByAppIdStatisticsDailyEndUsersResponses = { 200: DailyTerminalStatisticResponse } -export type GetAppsByAppIdStatisticsDailyEndUsersResponse - = GetAppsByAppIdStatisticsDailyEndUsersResponses[keyof GetAppsByAppIdStatisticsDailyEndUsersResponses] +export type GetAppsByAppIdStatisticsDailyEndUsersResponse = + GetAppsByAppIdStatisticsDailyEndUsersResponses[keyof GetAppsByAppIdStatisticsDailyEndUsersResponses] export type GetAppsByAppIdStatisticsDailyMessagesData = { body?: never @@ -5099,8 +5099,8 @@ export type GetAppsByAppIdStatisticsDailyMessagesResponses = { 200: DailyMessageStatisticResponse } -export type GetAppsByAppIdStatisticsDailyMessagesResponse - = GetAppsByAppIdStatisticsDailyMessagesResponses[keyof GetAppsByAppIdStatisticsDailyMessagesResponses] +export type GetAppsByAppIdStatisticsDailyMessagesResponse = + GetAppsByAppIdStatisticsDailyMessagesResponses[keyof GetAppsByAppIdStatisticsDailyMessagesResponses] export type GetAppsByAppIdStatisticsTokenCostsData = { body?: never @@ -5118,8 +5118,8 @@ export type GetAppsByAppIdStatisticsTokenCostsResponses = { 200: DailyTokenCostStatisticResponse } -export type GetAppsByAppIdStatisticsTokenCostsResponse - = GetAppsByAppIdStatisticsTokenCostsResponses[keyof GetAppsByAppIdStatisticsTokenCostsResponses] +export type GetAppsByAppIdStatisticsTokenCostsResponse = + GetAppsByAppIdStatisticsTokenCostsResponses[keyof GetAppsByAppIdStatisticsTokenCostsResponses] export type GetAppsByAppIdStatisticsTokensPerSecondData = { body?: never @@ -5137,8 +5137,8 @@ export type GetAppsByAppIdStatisticsTokensPerSecondResponses = { 200: TokensPerSecondStatisticResponse } -export type GetAppsByAppIdStatisticsTokensPerSecondResponse - = GetAppsByAppIdStatisticsTokensPerSecondResponses[keyof GetAppsByAppIdStatisticsTokensPerSecondResponses] +export type GetAppsByAppIdStatisticsTokensPerSecondResponse = + GetAppsByAppIdStatisticsTokensPerSecondResponses[keyof GetAppsByAppIdStatisticsTokensPerSecondResponses] export type GetAppsByAppIdStatisticsUserSatisfactionRateData = { body?: never @@ -5156,8 +5156,8 @@ export type GetAppsByAppIdStatisticsUserSatisfactionRateResponses = { 200: UserSatisfactionRateStatisticResponse } -export type GetAppsByAppIdStatisticsUserSatisfactionRateResponse - = GetAppsByAppIdStatisticsUserSatisfactionRateResponses[keyof GetAppsByAppIdStatisticsUserSatisfactionRateResponses] +export type GetAppsByAppIdStatisticsUserSatisfactionRateResponse = + GetAppsByAppIdStatisticsUserSatisfactionRateResponses[keyof GetAppsByAppIdStatisticsUserSatisfactionRateResponses] export type PostAppsByAppIdTextToAudioData = { body: TextToSpeechPayload @@ -5178,8 +5178,8 @@ export type PostAppsByAppIdTextToAudioResponses = { } } -export type PostAppsByAppIdTextToAudioResponse - = PostAppsByAppIdTextToAudioResponses[keyof PostAppsByAppIdTextToAudioResponses] +export type PostAppsByAppIdTextToAudioResponse = + PostAppsByAppIdTextToAudioResponses[keyof PostAppsByAppIdTextToAudioResponses] export type GetAppsByAppIdTextToAudioVoicesData = { body?: never @@ -5200,8 +5200,8 @@ export type GetAppsByAppIdTextToAudioVoicesResponses = { 200: TextToSpeechVoiceListResponse } -export type GetAppsByAppIdTextToAudioVoicesResponse - = GetAppsByAppIdTextToAudioVoicesResponses[keyof GetAppsByAppIdTextToAudioVoicesResponses] +export type GetAppsByAppIdTextToAudioVoicesResponse = + GetAppsByAppIdTextToAudioVoicesResponses[keyof GetAppsByAppIdTextToAudioVoicesResponses] export type GetAppsByAppIdTraceData = { body?: never @@ -5216,8 +5216,8 @@ export type GetAppsByAppIdTraceResponses = { 200: AppTraceResponse } -export type GetAppsByAppIdTraceResponse - = GetAppsByAppIdTraceResponses[keyof GetAppsByAppIdTraceResponses] +export type GetAppsByAppIdTraceResponse = + GetAppsByAppIdTraceResponses[keyof GetAppsByAppIdTraceResponses] export type PostAppsByAppIdTraceData = { body: AppTracePayload @@ -5236,8 +5236,8 @@ export type PostAppsByAppIdTraceResponses = { 200: SimpleResultResponse } -export type PostAppsByAppIdTraceResponse - = PostAppsByAppIdTraceResponses[keyof PostAppsByAppIdTraceResponses] +export type PostAppsByAppIdTraceResponse = + PostAppsByAppIdTraceResponses[keyof PostAppsByAppIdTraceResponses] export type DeleteAppsByAppIdTraceConfigData = { body?: never @@ -5259,8 +5259,8 @@ export type DeleteAppsByAppIdTraceConfigResponses = { 204: void } -export type DeleteAppsByAppIdTraceConfigResponse - = DeleteAppsByAppIdTraceConfigResponses[keyof DeleteAppsByAppIdTraceConfigResponses] +export type DeleteAppsByAppIdTraceConfigResponse = + DeleteAppsByAppIdTraceConfigResponses[keyof DeleteAppsByAppIdTraceConfigResponses] export type GetAppsByAppIdTraceConfigData = { body?: never @@ -5281,8 +5281,8 @@ export type GetAppsByAppIdTraceConfigResponses = { 200: TraceAppConfigResponse } -export type GetAppsByAppIdTraceConfigResponse - = GetAppsByAppIdTraceConfigResponses[keyof GetAppsByAppIdTraceConfigResponses] +export type GetAppsByAppIdTraceConfigResponse = + GetAppsByAppIdTraceConfigResponses[keyof GetAppsByAppIdTraceConfigResponses] export type PatchAppsByAppIdTraceConfigData = { body: TraceConfigPayload @@ -5302,8 +5302,8 @@ export type PatchAppsByAppIdTraceConfigResponses = { 200: TraceAppConfigResponse } -export type PatchAppsByAppIdTraceConfigResponse - = PatchAppsByAppIdTraceConfigResponses[keyof PatchAppsByAppIdTraceConfigResponses] +export type PatchAppsByAppIdTraceConfigResponse = + PatchAppsByAppIdTraceConfigResponses[keyof PatchAppsByAppIdTraceConfigResponses] export type PostAppsByAppIdTraceConfigData = { body: TraceConfigPayload @@ -5323,8 +5323,8 @@ export type PostAppsByAppIdTraceConfigResponses = { 201: TraceAppConfigResponse } -export type PostAppsByAppIdTraceConfigResponse - = PostAppsByAppIdTraceConfigResponses[keyof PostAppsByAppIdTraceConfigResponses] +export type PostAppsByAppIdTraceConfigResponse = + PostAppsByAppIdTraceConfigResponses[keyof PostAppsByAppIdTraceConfigResponses] export type PostAppsByAppIdTriggerEnableData = { body: ParserEnable @@ -5339,8 +5339,8 @@ export type PostAppsByAppIdTriggerEnableResponses = { 200: WorkflowTriggerResponse } -export type PostAppsByAppIdTriggerEnableResponse - = PostAppsByAppIdTriggerEnableResponses[keyof PostAppsByAppIdTriggerEnableResponses] +export type PostAppsByAppIdTriggerEnableResponse = + PostAppsByAppIdTriggerEnableResponses[keyof PostAppsByAppIdTriggerEnableResponses] export type GetAppsByAppIdTriggersData = { body?: never @@ -5355,8 +5355,8 @@ export type GetAppsByAppIdTriggersResponses = { 200: WorkflowTriggerListResponse } -export type GetAppsByAppIdTriggersResponse - = GetAppsByAppIdTriggersResponses[keyof GetAppsByAppIdTriggersResponses] +export type GetAppsByAppIdTriggersResponse = + GetAppsByAppIdTriggersResponses[keyof GetAppsByAppIdTriggersResponses] export type GetAppsByAppIdWorkflowAppLogsData = { body?: never @@ -5388,8 +5388,8 @@ export type GetAppsByAppIdWorkflowAppLogsResponses = { 200: WorkflowAppLogPaginationResponse } -export type GetAppsByAppIdWorkflowAppLogsResponse - = GetAppsByAppIdWorkflowAppLogsResponses[keyof GetAppsByAppIdWorkflowAppLogsResponses] +export type GetAppsByAppIdWorkflowAppLogsResponse = + GetAppsByAppIdWorkflowAppLogsResponses[keyof GetAppsByAppIdWorkflowAppLogsResponses] export type GetAppsByAppIdWorkflowArchivedLogsData = { body?: never @@ -5421,8 +5421,8 @@ export type GetAppsByAppIdWorkflowArchivedLogsResponses = { 200: WorkflowArchivedLogPaginationResponse } -export type GetAppsByAppIdWorkflowArchivedLogsResponse - = GetAppsByAppIdWorkflowArchivedLogsResponses[keyof GetAppsByAppIdWorkflowArchivedLogsResponses] +export type GetAppsByAppIdWorkflowArchivedLogsResponse = + GetAppsByAppIdWorkflowArchivedLogsResponses[keyof GetAppsByAppIdWorkflowArchivedLogsResponses] export type GetAppsByAppIdWorkflowRunsData = { body?: never @@ -5442,8 +5442,8 @@ export type GetAppsByAppIdWorkflowRunsResponses = { 200: WorkflowRunPaginationResponse } -export type GetAppsByAppIdWorkflowRunsResponse - = GetAppsByAppIdWorkflowRunsResponses[keyof GetAppsByAppIdWorkflowRunsResponses] +export type GetAppsByAppIdWorkflowRunsResponse = + GetAppsByAppIdWorkflowRunsResponses[keyof GetAppsByAppIdWorkflowRunsResponses] export type GetAppsByAppIdWorkflowRunsCountData = { body?: never @@ -5462,8 +5462,8 @@ export type GetAppsByAppIdWorkflowRunsCountResponses = { 200: WorkflowRunCountResponse } -export type GetAppsByAppIdWorkflowRunsCountResponse - = GetAppsByAppIdWorkflowRunsCountResponses[keyof GetAppsByAppIdWorkflowRunsCountResponses] +export type GetAppsByAppIdWorkflowRunsCountResponse = + GetAppsByAppIdWorkflowRunsCountResponses[keyof GetAppsByAppIdWorkflowRunsCountResponses] export type PostAppsByAppIdWorkflowRunsTasksByTaskIdStopData = { body?: never @@ -5484,8 +5484,8 @@ export type PostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponses = { 200: SimpleResultResponse } -export type PostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse - = PostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponses[keyof PostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponses] +export type PostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse = + PostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponses[keyof PostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponses] export type GetAppsByAppIdWorkflowRunsByRunIdData = { body?: never @@ -5505,8 +5505,8 @@ export type GetAppsByAppIdWorkflowRunsByRunIdResponses = { 200: WorkflowRunDetailResponse } -export type GetAppsByAppIdWorkflowRunsByRunIdResponse - = GetAppsByAppIdWorkflowRunsByRunIdResponses[keyof GetAppsByAppIdWorkflowRunsByRunIdResponses] +export type GetAppsByAppIdWorkflowRunsByRunIdResponse = + GetAppsByAppIdWorkflowRunsByRunIdResponses[keyof GetAppsByAppIdWorkflowRunsByRunIdResponses] export type GetAppsByAppIdWorkflowRunsByRunIdExportData = { body?: never @@ -5522,8 +5522,8 @@ export type GetAppsByAppIdWorkflowRunsByRunIdExportResponses = { 200: WorkflowRunExportResponse } -export type GetAppsByAppIdWorkflowRunsByRunIdExportResponse - = GetAppsByAppIdWorkflowRunsByRunIdExportResponses[keyof GetAppsByAppIdWorkflowRunsByRunIdExportResponses] +export type GetAppsByAppIdWorkflowRunsByRunIdExportResponse = + GetAppsByAppIdWorkflowRunsByRunIdExportResponses[keyof GetAppsByAppIdWorkflowRunsByRunIdExportResponses] export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsData = { body?: never @@ -5543,8 +5543,8 @@ export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses = { 200: WorkflowRunNodeExecutionListResponse } -export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse - = GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses[keyof GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses] +export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse = + GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses[keyof GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses] export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesData = { body?: never @@ -5564,8 +5564,8 @@ export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFi 200: SandboxListResponse } -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse - = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses] +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse = + GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses] export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadData = { body?: never @@ -5585,8 +5585,8 @@ export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFi 200: SandboxReadResponse } -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse - = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses] +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse = + GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses] export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadData = { body: WorkflowAgentSandboxUploadPayload @@ -5599,13 +5599,13 @@ export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxF url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload' } -export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses - = { +export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses = + { 200: SandboxUploadResponse } -export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse - = PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses[keyof PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses] +export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse = + PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses[keyof PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses] export type GetAppsByAppIdWorkflowCommentsData = { body?: never @@ -5620,8 +5620,8 @@ export type GetAppsByAppIdWorkflowCommentsResponses = { 200: WorkflowCommentBasicList } -export type GetAppsByAppIdWorkflowCommentsResponse - = GetAppsByAppIdWorkflowCommentsResponses[keyof GetAppsByAppIdWorkflowCommentsResponses] +export type GetAppsByAppIdWorkflowCommentsResponse = + GetAppsByAppIdWorkflowCommentsResponses[keyof GetAppsByAppIdWorkflowCommentsResponses] export type PostAppsByAppIdWorkflowCommentsData = { body: WorkflowCommentCreatePayload @@ -5636,8 +5636,8 @@ export type PostAppsByAppIdWorkflowCommentsResponses = { 201: WorkflowCommentCreate } -export type PostAppsByAppIdWorkflowCommentsResponse - = PostAppsByAppIdWorkflowCommentsResponses[keyof PostAppsByAppIdWorkflowCommentsResponses] +export type PostAppsByAppIdWorkflowCommentsResponse = + PostAppsByAppIdWorkflowCommentsResponses[keyof PostAppsByAppIdWorkflowCommentsResponses] export type GetAppsByAppIdWorkflowCommentsMentionUsersData = { body?: never @@ -5652,8 +5652,8 @@ export type GetAppsByAppIdWorkflowCommentsMentionUsersResponses = { 200: WorkflowCommentMentionUsersPayload } -export type GetAppsByAppIdWorkflowCommentsMentionUsersResponse - = GetAppsByAppIdWorkflowCommentsMentionUsersResponses[keyof GetAppsByAppIdWorkflowCommentsMentionUsersResponses] +export type GetAppsByAppIdWorkflowCommentsMentionUsersResponse = + GetAppsByAppIdWorkflowCommentsMentionUsersResponses[keyof GetAppsByAppIdWorkflowCommentsMentionUsersResponses] export type DeleteAppsByAppIdWorkflowCommentsByCommentIdData = { body?: never @@ -5669,8 +5669,8 @@ export type DeleteAppsByAppIdWorkflowCommentsByCommentIdResponses = { 204: void } -export type DeleteAppsByAppIdWorkflowCommentsByCommentIdResponse - = DeleteAppsByAppIdWorkflowCommentsByCommentIdResponses[keyof DeleteAppsByAppIdWorkflowCommentsByCommentIdResponses] +export type DeleteAppsByAppIdWorkflowCommentsByCommentIdResponse = + DeleteAppsByAppIdWorkflowCommentsByCommentIdResponses[keyof DeleteAppsByAppIdWorkflowCommentsByCommentIdResponses] export type GetAppsByAppIdWorkflowCommentsByCommentIdData = { body?: never @@ -5686,8 +5686,8 @@ export type GetAppsByAppIdWorkflowCommentsByCommentIdResponses = { 200: WorkflowCommentDetail } -export type GetAppsByAppIdWorkflowCommentsByCommentIdResponse - = GetAppsByAppIdWorkflowCommentsByCommentIdResponses[keyof GetAppsByAppIdWorkflowCommentsByCommentIdResponses] +export type GetAppsByAppIdWorkflowCommentsByCommentIdResponse = + GetAppsByAppIdWorkflowCommentsByCommentIdResponses[keyof GetAppsByAppIdWorkflowCommentsByCommentIdResponses] export type PutAppsByAppIdWorkflowCommentsByCommentIdData = { body: WorkflowCommentUpdatePayload @@ -5703,8 +5703,8 @@ export type PutAppsByAppIdWorkflowCommentsByCommentIdResponses = { 200: WorkflowCommentUpdate } -export type PutAppsByAppIdWorkflowCommentsByCommentIdResponse - = PutAppsByAppIdWorkflowCommentsByCommentIdResponses[keyof PutAppsByAppIdWorkflowCommentsByCommentIdResponses] +export type PutAppsByAppIdWorkflowCommentsByCommentIdResponse = + PutAppsByAppIdWorkflowCommentsByCommentIdResponses[keyof PutAppsByAppIdWorkflowCommentsByCommentIdResponses] export type PostAppsByAppIdWorkflowCommentsByCommentIdRepliesData = { body: WorkflowCommentReplyPayload @@ -5720,8 +5720,8 @@ export type PostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponses = { 201: WorkflowCommentReplyCreate } -export type PostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse - = PostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponses[keyof PostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponses] +export type PostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse = + PostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponses[keyof PostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponses] export type DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdData = { body?: never @@ -5738,8 +5738,8 @@ export type DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse 204: void } -export type DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse - = DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponses[keyof DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponses] +export type DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse = + DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponses[keyof DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponses] export type PutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdData = { body: WorkflowCommentReplyPayload @@ -5756,8 +5756,8 @@ export type PutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponses = 200: WorkflowCommentReplyUpdate } -export type PutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse - = PutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponses[keyof PutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponses] +export type PutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse = + PutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponses[keyof PutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponses] export type PostAppsByAppIdWorkflowCommentsByCommentIdResolveData = { body?: never @@ -5773,8 +5773,8 @@ export type PostAppsByAppIdWorkflowCommentsByCommentIdResolveResponses = { 200: WorkflowCommentResolve } -export type PostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse - = PostAppsByAppIdWorkflowCommentsByCommentIdResolveResponses[keyof PostAppsByAppIdWorkflowCommentsByCommentIdResolveResponses] +export type PostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse = + PostAppsByAppIdWorkflowCommentsByCommentIdResolveResponses[keyof PostAppsByAppIdWorkflowCommentsByCommentIdResolveResponses] export type GetAppsByAppIdWorkflowStatisticsAverageAppInteractionsData = { body?: never @@ -5792,8 +5792,8 @@ export type GetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponses = { 200: WorkflowAverageAppInteractionStatisticResponse } -export type GetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse - = GetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponses[keyof GetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponses] +export type GetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse = + GetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponses[keyof GetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponses] export type GetAppsByAppIdWorkflowStatisticsDailyConversationsData = { body?: never @@ -5811,8 +5811,8 @@ export type GetAppsByAppIdWorkflowStatisticsDailyConversationsResponses = { 200: WorkflowDailyRunsStatisticResponse } -export type GetAppsByAppIdWorkflowStatisticsDailyConversationsResponse - = GetAppsByAppIdWorkflowStatisticsDailyConversationsResponses[keyof GetAppsByAppIdWorkflowStatisticsDailyConversationsResponses] +export type GetAppsByAppIdWorkflowStatisticsDailyConversationsResponse = + GetAppsByAppIdWorkflowStatisticsDailyConversationsResponses[keyof GetAppsByAppIdWorkflowStatisticsDailyConversationsResponses] export type GetAppsByAppIdWorkflowStatisticsDailyTerminalsData = { body?: never @@ -5830,8 +5830,8 @@ export type GetAppsByAppIdWorkflowStatisticsDailyTerminalsResponses = { 200: WorkflowDailyTerminalsStatisticResponse } -export type GetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse - = GetAppsByAppIdWorkflowStatisticsDailyTerminalsResponses[keyof GetAppsByAppIdWorkflowStatisticsDailyTerminalsResponses] +export type GetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse = + GetAppsByAppIdWorkflowStatisticsDailyTerminalsResponses[keyof GetAppsByAppIdWorkflowStatisticsDailyTerminalsResponses] export type GetAppsByAppIdWorkflowStatisticsTokenCostsData = { body?: never @@ -5849,8 +5849,8 @@ export type GetAppsByAppIdWorkflowStatisticsTokenCostsResponses = { 200: WorkflowDailyTokenCostStatisticResponse } -export type GetAppsByAppIdWorkflowStatisticsTokenCostsResponse - = GetAppsByAppIdWorkflowStatisticsTokenCostsResponses[keyof GetAppsByAppIdWorkflowStatisticsTokenCostsResponses] +export type GetAppsByAppIdWorkflowStatisticsTokenCostsResponse = + GetAppsByAppIdWorkflowStatisticsTokenCostsResponses[keyof GetAppsByAppIdWorkflowStatisticsTokenCostsResponses] export type GetAppsByAppIdWorkflowsData = { body?: never @@ -5870,8 +5870,8 @@ export type GetAppsByAppIdWorkflowsResponses = { 200: WorkflowPaginationResponse } -export type GetAppsByAppIdWorkflowsResponse - = GetAppsByAppIdWorkflowsResponses[keyof GetAppsByAppIdWorkflowsResponses] +export type GetAppsByAppIdWorkflowsResponse = + GetAppsByAppIdWorkflowsResponses[keyof GetAppsByAppIdWorkflowsResponses] export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsData = { body?: never @@ -5886,8 +5886,8 @@ export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponses = { 200: DefaultBlockConfigsResponse } -export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse - = GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponses[keyof GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponses] +export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse = + GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponses[keyof GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponses] export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeData = { body?: never @@ -5909,8 +5909,8 @@ export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeRespons 200: DefaultBlockConfigResponse } -export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse - = GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponses[keyof GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponses] +export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse = + GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponses[keyof GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponses] export type GetAppsByAppIdWorkflowsDraftData = { body?: never @@ -5929,8 +5929,8 @@ export type GetAppsByAppIdWorkflowsDraftResponses = { 200: WorkflowResponse } -export type GetAppsByAppIdWorkflowsDraftResponse - = GetAppsByAppIdWorkflowsDraftResponses[keyof GetAppsByAppIdWorkflowsDraftResponses] +export type GetAppsByAppIdWorkflowsDraftResponse = + GetAppsByAppIdWorkflowsDraftResponses[keyof GetAppsByAppIdWorkflowsDraftResponses] export type PostAppsByAppIdWorkflowsDraftData = { body: SyncDraftWorkflowPayload @@ -5950,8 +5950,8 @@ export type PostAppsByAppIdWorkflowsDraftResponses = { 200: SyncDraftWorkflowResponse } -export type PostAppsByAppIdWorkflowsDraftResponse - = PostAppsByAppIdWorkflowsDraftResponses[keyof PostAppsByAppIdWorkflowsDraftResponses] +export type PostAppsByAppIdWorkflowsDraftResponse = + PostAppsByAppIdWorkflowsDraftResponses[keyof PostAppsByAppIdWorkflowsDraftResponses] export type GetAppsByAppIdWorkflowsDraftConversationVariablesData = { body?: never @@ -5970,8 +5970,8 @@ export type GetAppsByAppIdWorkflowsDraftConversationVariablesResponses = { 200: WorkflowDraftVariableList } -export type GetAppsByAppIdWorkflowsDraftConversationVariablesResponse - = GetAppsByAppIdWorkflowsDraftConversationVariablesResponses[keyof GetAppsByAppIdWorkflowsDraftConversationVariablesResponses] +export type GetAppsByAppIdWorkflowsDraftConversationVariablesResponse = + GetAppsByAppIdWorkflowsDraftConversationVariablesResponses[keyof GetAppsByAppIdWorkflowsDraftConversationVariablesResponses] export type PostAppsByAppIdWorkflowsDraftConversationVariablesData = { body: ConversationVariableUpdatePayload @@ -5986,8 +5986,8 @@ export type PostAppsByAppIdWorkflowsDraftConversationVariablesResponses = { 200: SimpleResultResponse } -export type PostAppsByAppIdWorkflowsDraftConversationVariablesResponse - = PostAppsByAppIdWorkflowsDraftConversationVariablesResponses[keyof PostAppsByAppIdWorkflowsDraftConversationVariablesResponses] +export type PostAppsByAppIdWorkflowsDraftConversationVariablesResponse = + PostAppsByAppIdWorkflowsDraftConversationVariablesResponses[keyof PostAppsByAppIdWorkflowsDraftConversationVariablesResponses] export type GetAppsByAppIdWorkflowsDraftEnvironmentVariablesData = { body?: never @@ -6006,8 +6006,8 @@ export type GetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponses = { 200: EnvironmentVariableListResponse } -export type GetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse - = GetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponses[keyof GetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponses] +export type GetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse = + GetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponses[keyof GetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponses] export type PostAppsByAppIdWorkflowsDraftEnvironmentVariablesData = { body: EnvironmentVariableUpdatePayload @@ -6022,8 +6022,8 @@ export type PostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponses = { 200: SimpleResultResponse } -export type PostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse - = PostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponses[keyof PostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponses] +export type PostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse = + PostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponses[keyof PostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponses] export type PostAppsByAppIdWorkflowsDraftFeaturesData = { body: WorkflowFeaturesPayload @@ -6038,8 +6038,8 @@ export type PostAppsByAppIdWorkflowsDraftFeaturesResponses = { 200: SimpleResultResponse } -export type PostAppsByAppIdWorkflowsDraftFeaturesResponse - = PostAppsByAppIdWorkflowsDraftFeaturesResponses[keyof PostAppsByAppIdWorkflowsDraftFeaturesResponses] +export type PostAppsByAppIdWorkflowsDraftFeaturesResponse = + PostAppsByAppIdWorkflowsDraftFeaturesResponses[keyof PostAppsByAppIdWorkflowsDraftFeaturesResponses] export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestData = { body: HumanInputDeliveryTestPayload @@ -6055,8 +6055,8 @@ export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResp 200: EmptyObjectResponse } -export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse - = PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponses[keyof PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponses] +export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse = + PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponses[keyof PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponses] export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewData = { body: HumanInputFormPreviewPayload @@ -6072,8 +6072,8 @@ export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewRespo 200: HumanInputFormPreviewResponse } -export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse - = PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses[keyof PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses] +export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse = + PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses[keyof PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses] export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunData = { body: HumanInputFormSubmitPayload @@ -6089,8 +6089,8 @@ export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses 200: HumanInputFormSubmitResponse } -export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse - = PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses[keyof PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses] +export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse = + PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses[keyof PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses] export type PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunData = { body: IterationNodeRunPayload @@ -6111,8 +6111,8 @@ export type PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponses = { 200: GeneratedAppResponse } -export type PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse - = PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponses[keyof PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponses] +export type PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse = + PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponses[keyof PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponses] export type PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunData = { body: LoopNodeRunPayload @@ -6133,8 +6133,8 @@ export type PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponses = { 200: GeneratedAppResponse } -export type PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse - = PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponses[keyof PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponses] +export type PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse = + PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponses[keyof PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponses] export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = { body?: never @@ -6152,8 +6152,8 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = { 200: WorkflowAgentComposerResponse } -export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse - = GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses] +export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = + GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses] export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = { body: ComposerSavePayload @@ -6169,8 +6169,8 @@ export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = { 200: WorkflowAgentComposerResponse } -export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse - = PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses[keyof PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses] +export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = + PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses[keyof PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses] export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesData = { body?: never @@ -6186,8 +6186,8 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResp 200: AgentComposerCandidatesResponse } -export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse - = GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses] +export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = + GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses] export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterData = { body: WorkflowComposerCopyFromRosterPayload @@ -6203,8 +6203,8 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRoste 200: WorkflowAgentComposerResponse } -export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse - = PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses] +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse = + PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses] export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData = { body: ComposerSavePayload @@ -6220,8 +6220,8 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactRespons 200: AgentComposerImpactResponse } -export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse - = PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses] +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse = + PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses] export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterData = { body: ComposerSavePayload @@ -6237,8 +6237,8 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterR 200: WorkflowAgentComposerResponse } -export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse - = PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses] +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse = + PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses] export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateData = { body: ComposerSavePayload @@ -6254,8 +6254,8 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateRespo 200: AgentComposerValidateResponse } -export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse - = PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses] +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse = + PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses] export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunData = { body?: never @@ -6276,8 +6276,8 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponses = { 200: WorkflowRunNodeExecutionResponse } -export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse - = GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponses] +export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse = + GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponses] export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunData = { body: DraftWorkflowNodeRunPayload @@ -6298,8 +6298,8 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponses = { 200: WorkflowRunNodeExecutionResponse } -export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse - = PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponses] +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse = + PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponses] export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunData = { body?: never @@ -6320,8 +6320,8 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponses = { 200: GeneratedAppResponse } -export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse - = PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponses] +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse = + PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponses] export type DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesData = { body?: never @@ -6337,8 +6337,8 @@ export type DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponses = { 204: void } -export type DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse - = DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponses] +export type DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse = + DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponses] export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesData = { body?: never @@ -6354,8 +6354,8 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponses = { 200: WorkflowDraftVariableList } -export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse - = GetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponses] +export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse = + GetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponses] export type PostAppsByAppIdWorkflowsDraftRunData = { body: DraftWorkflowRunPayload @@ -6374,8 +6374,8 @@ export type PostAppsByAppIdWorkflowsDraftRunResponses = { 200: GeneratedAppResponse } -export type PostAppsByAppIdWorkflowsDraftRunResponse - = PostAppsByAppIdWorkflowsDraftRunResponses[keyof PostAppsByAppIdWorkflowsDraftRunResponses] +export type PostAppsByAppIdWorkflowsDraftRunResponse = + PostAppsByAppIdWorkflowsDraftRunResponses[keyof PostAppsByAppIdWorkflowsDraftRunResponses] export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsData = { body?: never @@ -6395,8 +6395,8 @@ export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponses = { 200: WorkflowRunSnapshotView } -export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse - = GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponses[keyof GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponses] +export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse = + GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponses[keyof GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponses] export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsData = { body?: never @@ -6416,8 +6416,8 @@ export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponses = 200: EventStreamResponse } -export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse - = GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponses[keyof GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponses] +export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse = + GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponses[keyof GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponses] export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdData = { body?: never @@ -6438,8 +6438,8 @@ export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponses 200: NodeOutputsView } -export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse - = GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponses[keyof GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponses] +export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse = + GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponses[keyof GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponses] export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewData = { body?: never @@ -6457,13 +6457,13 @@ export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNa 404: unknown } -export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses - = { +export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses = + { 200: OutputPreviewView } -export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse - = GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses[keyof GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses] +export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse = + GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses[keyof GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses] export type GetAppsByAppIdWorkflowsDraftSystemVariablesData = { body?: never @@ -6478,8 +6478,8 @@ export type GetAppsByAppIdWorkflowsDraftSystemVariablesResponses = { 200: WorkflowDraftVariableList } -export type GetAppsByAppIdWorkflowsDraftSystemVariablesResponse - = GetAppsByAppIdWorkflowsDraftSystemVariablesResponses[keyof GetAppsByAppIdWorkflowsDraftSystemVariablesResponses] +export type GetAppsByAppIdWorkflowsDraftSystemVariablesResponse = + GetAppsByAppIdWorkflowsDraftSystemVariablesResponses[keyof GetAppsByAppIdWorkflowsDraftSystemVariablesResponses] export type PostAppsByAppIdWorkflowsDraftTriggerRunData = { body: DraftWorkflowTriggerRunRequest @@ -6499,8 +6499,8 @@ export type PostAppsByAppIdWorkflowsDraftTriggerRunResponses = { 200: GeneratedAppResponse } -export type PostAppsByAppIdWorkflowsDraftTriggerRunResponse - = PostAppsByAppIdWorkflowsDraftTriggerRunResponses[keyof PostAppsByAppIdWorkflowsDraftTriggerRunResponses] +export type PostAppsByAppIdWorkflowsDraftTriggerRunResponse = + PostAppsByAppIdWorkflowsDraftTriggerRunResponses[keyof PostAppsByAppIdWorkflowsDraftTriggerRunResponses] export type PostAppsByAppIdWorkflowsDraftTriggerRunAllData = { body: DraftWorkflowTriggerRunAllPayload @@ -6520,8 +6520,8 @@ export type PostAppsByAppIdWorkflowsDraftTriggerRunAllResponses = { 200: GeneratedAppResponse } -export type PostAppsByAppIdWorkflowsDraftTriggerRunAllResponse - = PostAppsByAppIdWorkflowsDraftTriggerRunAllResponses[keyof PostAppsByAppIdWorkflowsDraftTriggerRunAllResponses] +export type PostAppsByAppIdWorkflowsDraftTriggerRunAllResponse = + PostAppsByAppIdWorkflowsDraftTriggerRunAllResponses[keyof PostAppsByAppIdWorkflowsDraftTriggerRunAllResponses] export type DeleteAppsByAppIdWorkflowsDraftVariablesData = { body?: never @@ -6536,8 +6536,8 @@ export type DeleteAppsByAppIdWorkflowsDraftVariablesResponses = { 204: void } -export type DeleteAppsByAppIdWorkflowsDraftVariablesResponse - = DeleteAppsByAppIdWorkflowsDraftVariablesResponses[keyof DeleteAppsByAppIdWorkflowsDraftVariablesResponses] +export type DeleteAppsByAppIdWorkflowsDraftVariablesResponse = + DeleteAppsByAppIdWorkflowsDraftVariablesResponses[keyof DeleteAppsByAppIdWorkflowsDraftVariablesResponses] export type GetAppsByAppIdWorkflowsDraftVariablesData = { body?: never @@ -6555,8 +6555,8 @@ export type GetAppsByAppIdWorkflowsDraftVariablesResponses = { 200: WorkflowDraftVariableListWithoutValue } -export type GetAppsByAppIdWorkflowsDraftVariablesResponse - = GetAppsByAppIdWorkflowsDraftVariablesResponses[keyof GetAppsByAppIdWorkflowsDraftVariablesResponses] +export type GetAppsByAppIdWorkflowsDraftVariablesResponse = + GetAppsByAppIdWorkflowsDraftVariablesResponses[keyof GetAppsByAppIdWorkflowsDraftVariablesResponses] export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdData = { body?: never @@ -6576,8 +6576,8 @@ export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses = { 204: void } -export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse - = DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses[keyof DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses] +export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse = + DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses[keyof DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses] export type GetAppsByAppIdWorkflowsDraftVariablesByVariableIdData = { body?: never @@ -6597,8 +6597,8 @@ export type GetAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses = { 200: WorkflowDraftVariable } -export type GetAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse - = GetAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses[keyof GetAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses] +export type GetAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse = + GetAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses[keyof GetAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses] export type PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdData = { body: WorkflowDraftVariableUpdatePayload @@ -6618,8 +6618,8 @@ export type PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses = { 200: WorkflowDraftVariable } -export type PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse - = PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses[keyof PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses] +export type PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse = + PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses[keyof PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses] export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetData = { body?: never @@ -6640,8 +6640,8 @@ export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponses = { 204: void } -export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse - = PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponses[keyof PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponses] +export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse = + PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponses[keyof PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponses] export type GetAppsByAppIdWorkflowsPublishData = { body?: never @@ -6656,8 +6656,8 @@ export type GetAppsByAppIdWorkflowsPublishResponses = { 200: WorkflowResponse } -export type GetAppsByAppIdWorkflowsPublishResponse - = GetAppsByAppIdWorkflowsPublishResponses[keyof GetAppsByAppIdWorkflowsPublishResponses] +export type GetAppsByAppIdWorkflowsPublishResponse = + GetAppsByAppIdWorkflowsPublishResponses[keyof GetAppsByAppIdWorkflowsPublishResponses] export type PostAppsByAppIdWorkflowsPublishData = { body: PublishWorkflowPayload @@ -6672,8 +6672,8 @@ export type PostAppsByAppIdWorkflowsPublishResponses = { 200: WorkflowPublishResponse } -export type PostAppsByAppIdWorkflowsPublishResponse - = PostAppsByAppIdWorkflowsPublishResponses[keyof PostAppsByAppIdWorkflowsPublishResponses] +export type PostAppsByAppIdWorkflowsPublishResponse = + PostAppsByAppIdWorkflowsPublishResponses[keyof PostAppsByAppIdWorkflowsPublishResponses] export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsData = { body?: never @@ -6693,8 +6693,8 @@ export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponses = { 200: WorkflowRunSnapshotView } -export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse - = GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponses[keyof GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponses] +export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse = + GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponses[keyof GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponses] export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsData = { body?: never @@ -6714,8 +6714,8 @@ export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse 200: EventStreamResponse } -export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse - = GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponses[keyof GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponses] +export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse = + GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponses[keyof GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponses] export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdData = { body?: never @@ -6736,11 +6736,11 @@ export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdRespon 200: NodeOutputsView } -export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse - = GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponses[keyof GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponses] +export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse = + GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponses[keyof GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponses] -export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewData - = { +export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewData = + { body?: never path: { app_id: string @@ -6752,18 +6752,18 @@ export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutp url: '/apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/{node_id}/{output_name}/preview' } -export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewErrors - = { +export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewErrors = + { 404: unknown } -export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses - = { +export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses = + { 200: OutputPreviewView } -export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse - = GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses[keyof GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses] +export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse = + GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses[keyof GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses] export type GetAppsByAppIdWorkflowsTriggersWebhookData = { body?: never @@ -6780,8 +6780,8 @@ export type GetAppsByAppIdWorkflowsTriggersWebhookResponses = { 200: WebhookTriggerResponse } -export type GetAppsByAppIdWorkflowsTriggersWebhookResponse - = GetAppsByAppIdWorkflowsTriggersWebhookResponses[keyof GetAppsByAppIdWorkflowsTriggersWebhookResponses] +export type GetAppsByAppIdWorkflowsTriggersWebhookResponse = + GetAppsByAppIdWorkflowsTriggersWebhookResponses[keyof GetAppsByAppIdWorkflowsTriggersWebhookResponses] export type DeleteAppsByAppIdWorkflowsByWorkflowIdData = { body?: never @@ -6797,8 +6797,8 @@ export type DeleteAppsByAppIdWorkflowsByWorkflowIdResponses = { 204: void } -export type DeleteAppsByAppIdWorkflowsByWorkflowIdResponse - = DeleteAppsByAppIdWorkflowsByWorkflowIdResponses[keyof DeleteAppsByAppIdWorkflowsByWorkflowIdResponses] +export type DeleteAppsByAppIdWorkflowsByWorkflowIdResponse = + DeleteAppsByAppIdWorkflowsByWorkflowIdResponses[keyof DeleteAppsByAppIdWorkflowsByWorkflowIdResponses] export type PatchAppsByAppIdWorkflowsByWorkflowIdData = { body: WorkflowUpdatePayload @@ -6819,8 +6819,8 @@ export type PatchAppsByAppIdWorkflowsByWorkflowIdResponses = { 200: WorkflowResponse } -export type PatchAppsByAppIdWorkflowsByWorkflowIdResponse - = PatchAppsByAppIdWorkflowsByWorkflowIdResponses[keyof PatchAppsByAppIdWorkflowsByWorkflowIdResponses] +export type PatchAppsByAppIdWorkflowsByWorkflowIdResponse = + PatchAppsByAppIdWorkflowsByWorkflowIdResponses[keyof PatchAppsByAppIdWorkflowsByWorkflowIdResponses] export type PostAppsByAppIdWorkflowsByWorkflowIdRestoreData = { body?: never @@ -6841,8 +6841,8 @@ export type PostAppsByAppIdWorkflowsByWorkflowIdRestoreResponses = { 200: WorkflowRestoreResponse } -export type PostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse - = PostAppsByAppIdWorkflowsByWorkflowIdRestoreResponses[keyof PostAppsByAppIdWorkflowsByWorkflowIdRestoreResponses] +export type PostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse = + PostAppsByAppIdWorkflowsByWorkflowIdRestoreResponses[keyof PostAppsByAppIdWorkflowsByWorkflowIdRestoreResponses] export type GetAppsByResourceIdApiKeysData = { body?: never @@ -6857,8 +6857,8 @@ export type GetAppsByResourceIdApiKeysResponses = { 200: ApiKeyList } -export type GetAppsByResourceIdApiKeysResponse - = GetAppsByResourceIdApiKeysResponses[keyof GetAppsByResourceIdApiKeysResponses] +export type GetAppsByResourceIdApiKeysResponse = + GetAppsByResourceIdApiKeysResponses[keyof GetAppsByResourceIdApiKeysResponses] export type PostAppsByResourceIdApiKeysData = { body?: never @@ -6877,8 +6877,8 @@ export type PostAppsByResourceIdApiKeysResponses = { 201: ApiKeyItem } -export type PostAppsByResourceIdApiKeysResponse - = PostAppsByResourceIdApiKeysResponses[keyof PostAppsByResourceIdApiKeysResponses] +export type PostAppsByResourceIdApiKeysResponse = + PostAppsByResourceIdApiKeysResponses[keyof PostAppsByResourceIdApiKeysResponses] export type DeleteAppsByResourceIdApiKeysByApiKeyIdData = { body?: never @@ -6894,8 +6894,8 @@ export type DeleteAppsByResourceIdApiKeysByApiKeyIdResponses = { 204: void } -export type DeleteAppsByResourceIdApiKeysByApiKeyIdResponse - = DeleteAppsByResourceIdApiKeysByApiKeyIdResponses[keyof DeleteAppsByResourceIdApiKeysByApiKeyIdResponses] +export type DeleteAppsByResourceIdApiKeysByApiKeyIdResponse = + DeleteAppsByResourceIdApiKeysByApiKeyIdResponses[keyof DeleteAppsByResourceIdApiKeysByApiKeyIdResponses] export type GetAppsByServerIdServerRefreshData = { body?: never @@ -6915,5 +6915,5 @@ export type GetAppsByServerIdServerRefreshResponses = { 200: AppMcpServerResponse } -export type GetAppsByServerIdServerRefreshResponse - = GetAppsByServerIdServerRefreshResponses[keyof GetAppsByServerIdServerRefreshResponses] +export type GetAppsByServerIdServerRefreshResponse = + GetAppsByServerIdServerRefreshResponses[keyof GetAppsByServerIdServerRefreshResponses] diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 8e05a59f700f33..77ff787ed76e38 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -4455,8 +4455,8 @@ export const zGetAppsByAppIdAdvancedChatWorkflowRunsQuery = z.object({ /** * Workflow runs retrieved successfully */ -export const zGetAppsByAppIdAdvancedChatWorkflowRunsResponse - = zAdvancedChatWorkflowRunPaginationResponse +export const zGetAppsByAppIdAdvancedChatWorkflowRunsResponse = + zAdvancedChatWorkflowRunPaginationResponse export const zGetAppsByAppIdAdvancedChatWorkflowRunsCountPath = z.object({ app_id: z.uuid(), @@ -4473,11 +4473,11 @@ export const zGetAppsByAppIdAdvancedChatWorkflowRunsCountQuery = z.object({ */ export const zGetAppsByAppIdAdvancedChatWorkflowRunsCountResponse = zWorkflowRunCountResponse -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewBody - = zHumanInputFormPreviewPayload +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewBody = + zHumanInputFormPreviewPayload -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewPath - = z.object({ +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewPath = + z.object({ app_id: z.uuid(), node_id: z.string(), }) @@ -4485,14 +4485,14 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFo /** * Human input form preview */ -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse - = zHumanInputFormPreviewResponse +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse = + zHumanInputFormPreviewResponse -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunBody - = zHumanInputFormSubmitPayload +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunBody = + zHumanInputFormSubmitPayload -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunPath - = z.object({ +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunPath = + z.object({ app_id: z.uuid(), node_id: z.string(), }) @@ -4500,11 +4500,11 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFo /** * Human input form submission result */ -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse - = zHumanInputFormSubmitResponse +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse = + zHumanInputFormSubmitResponse -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunBody - = zIterationNodeRunPayload +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunBody = + zIterationNodeRunPayload export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunPath = z.object({ app_id: z.uuid(), @@ -4514,11 +4514,11 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRun /** * Iteration node run started successfully */ -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponse - = zGeneratedAppResponse +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponse = + zGeneratedAppResponse -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunBody - = zLoopNodeRunPayload +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunBody = + zLoopNodeRunPayload export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunPath = z.object({ app_id: z.uuid(), @@ -4528,8 +4528,8 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunPath /** * Loop node run started successfully */ -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponse - = zGeneratedAppResponse +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponse = + zGeneratedAppResponse export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunBody = zAdvancedChatWorkflowRunPayload @@ -4731,8 +4731,8 @@ export const zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadQuery = z.object /** * Config skill file download URL */ -export const zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponse - = zAgentConfigDownloadResponse +export const zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponse = + zAgentConfigDownloadResponse export const zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewPath = z.object({ app_id: z.uuid(), @@ -4749,8 +4749,8 @@ export const zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewQuery = z.object( /** * Config skill file preview */ -export const zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponse - = zAgentConfigSkillFilePreviewResponse +export const zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponse = + zAgentConfigSkillFilePreviewResponse export const zGetAppsByAppIdAgentConfigSkillsByNameInspectPath = z.object({ app_id: z.uuid(), @@ -4766,8 +4766,8 @@ export const zGetAppsByAppIdAgentConfigSkillsByNameInspectQuery = z.object({ /** * Config skill inspect view */ -export const zGetAppsByAppIdAgentConfigSkillsByNameInspectResponse - = zAgentConfigSkillInspectResponse +export const zGetAppsByAppIdAgentConfigSkillsByNameInspectResponse = + zAgentConfigSkillInspectResponse export const zGetAppsByAppIdAgentDriveFilesPath = z.object({ app_id: z.uuid(), @@ -4837,8 +4837,8 @@ export const zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectQuery = z.object({ /** * Drive skill inspect view */ -export const zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse - = zAgentDriveSkillInspectResponse +export const zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse = + zAgentDriveSkillInspectResponse export const zDeleteAppsByAppIdAgentFilesPath = z.object({ app_id: z.uuid(), @@ -4949,8 +4949,8 @@ export const zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdPath = z.object( /** * Job status retrieved successfully */ -export const zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse - = zAnnotationJobStatusDetailResponse +export const zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse = + zAnnotationJobStatusDetailResponse export const zGetAppsByAppIdAnnotationSettingPath = z.object({ app_id: z.uuid(), @@ -4961,8 +4961,8 @@ export const zGetAppsByAppIdAnnotationSettingPath = z.object({ */ export const zGetAppsByAppIdAnnotationSettingResponse = zAnnotationSettingResponse -export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdBody - = zAnnotationSettingUpdatePayload +export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdBody = + zAnnotationSettingUpdatePayload export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdPath = z.object({ annotation_setting_id: z.uuid(), @@ -4972,8 +4972,8 @@ export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdPath = z.obj /** * Settings updated successfully */ -export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse - = zAnnotationSettingResponse +export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse = + zAnnotationSettingResponse export const zDeleteAppsByAppIdAnnotationsPath = z.object({ app_id: z.uuid(), @@ -5027,8 +5027,8 @@ export const zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdPath = z.object({ /** * Job status retrieved successfully */ -export const zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse - = zAnnotationJobStatusDetailResponse +export const zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse = + zAnnotationJobStatusDetailResponse export const zGetAppsByAppIdAnnotationsCountPath = z.object({ app_id: z.uuid(), @@ -5080,8 +5080,8 @@ export const zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesQuery = z.objec /** * Hit histories retrieved successfully */ -export const zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse - = zAnnotationHitHistoryList +export const zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse = + zAnnotationHitHistoryList export const zPostAppsByAppIdApiEnableBody = zAppApiStatusPayload @@ -5168,8 +5168,8 @@ export const zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsPath = z.ob /** * Suggested questions retrieved successfully */ -export const zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse - = zSuggestedQuestionsResponse +export const zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse = + zSuggestedQuestionsResponse export const zPostAppsByAppIdChatMessagesByTaskIdStopPath = z.object({ app_id: z.uuid(), @@ -5217,8 +5217,8 @@ export const zGetAppsByAppIdCompletionConversationsByConversationIdPath = z.obje /** * Success */ -export const zGetAppsByAppIdCompletionConversationsByConversationIdResponse - = zConversationMessageDetail +export const zGetAppsByAppIdCompletionConversationsByConversationIdResponse = + zConversationMessageDetail export const zPostAppsByAppIdCompletionMessagesBody = zCompletionMessagePayload @@ -5460,8 +5460,8 @@ export const zGetAppsByAppIdStatisticsAverageResponseTimeQuery = z.object({ /** * Average response time statistics retrieved successfully */ -export const zGetAppsByAppIdStatisticsAverageResponseTimeResponse - = zAverageResponseTimeStatisticResponse +export const zGetAppsByAppIdStatisticsAverageResponseTimeResponse = + zAverageResponseTimeStatisticResponse export const zGetAppsByAppIdStatisticsAverageSessionInteractionsPath = z.object({ app_id: z.uuid(), @@ -5475,8 +5475,8 @@ export const zGetAppsByAppIdStatisticsAverageSessionInteractionsQuery = z.object /** * Average session interaction statistics retrieved successfully */ -export const zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse - = zAverageSessionInteractionStatisticResponse +export const zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse = + zAverageSessionInteractionStatisticResponse export const zGetAppsByAppIdStatisticsDailyConversationsPath = z.object({ app_id: z.uuid(), @@ -5490,8 +5490,8 @@ export const zGetAppsByAppIdStatisticsDailyConversationsQuery = z.object({ /** * Daily conversation statistics retrieved successfully */ -export const zGetAppsByAppIdStatisticsDailyConversationsResponse - = zDailyConversationStatisticResponse +export const zGetAppsByAppIdStatisticsDailyConversationsResponse = + zDailyConversationStatisticResponse export const zGetAppsByAppIdStatisticsDailyEndUsersPath = z.object({ app_id: z.uuid(), @@ -5561,8 +5561,8 @@ export const zGetAppsByAppIdStatisticsUserSatisfactionRateQuery = z.object({ /** * User satisfaction rate statistics retrieved successfully */ -export const zGetAppsByAppIdStatisticsUserSatisfactionRateResponse - = zUserSatisfactionRateStatisticResponse +export const zGetAppsByAppIdStatisticsUserSatisfactionRateResponse = + zUserSatisfactionRateStatisticResponse export const zPostAppsByAppIdTextToAudioBody = zTextToSpeechPayload @@ -5791,18 +5791,18 @@ export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({ /** * Node executions retrieved successfully */ -export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse - = zWorkflowRunNodeExecutionListResponse +export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse = + zWorkflowRunNodeExecutionListResponse -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesPath - = z.object({ +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesPath = + z.object({ app_id: z.uuid(), node_id: z.string(), workflow_run_id: z.uuid(), }) -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesQuery - = z.object({ +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesQuery = + z.object({ node_execution_id: z.string().optional(), path: z.string().optional().default('.'), }) @@ -5810,18 +5810,18 @@ export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandbox /** * Listing returned */ -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse - = zSandboxListResponse +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse = + zSandboxListResponse -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadPath - = z.object({ +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadPath = + z.object({ app_id: z.uuid(), node_id: z.string(), workflow_run_id: z.uuid(), }) -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadQuery - = z.object({ +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadQuery = + z.object({ node_execution_id: z.string().optional(), path: z.string().min(1), }) @@ -5829,14 +5829,14 @@ export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandbox /** * Preview returned */ -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse - = zSandboxReadResponse +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse = + zSandboxReadResponse -export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadBody - = zWorkflowAgentSandboxUploadPayload +export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadBody = + zWorkflowAgentSandboxUploadPayload -export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath - = z.object({ +export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath = + z.object({ app_id: z.uuid(), node_id: z.string(), workflow_run_id: z.uuid(), @@ -5845,8 +5845,8 @@ export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandbo /** * Uploaded */ -export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse - = zSandboxUploadResponse +export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse = + zSandboxUploadResponse export const zGetAppsByAppIdWorkflowCommentsPath = z.object({ app_id: z.uuid(), @@ -5875,8 +5875,8 @@ export const zGetAppsByAppIdWorkflowCommentsMentionUsersPath = z.object({ /** * Mentionable users retrieved successfully */ -export const zGetAppsByAppIdWorkflowCommentsMentionUsersResponse - = zWorkflowCommentMentionUsersPayload +export const zGetAppsByAppIdWorkflowCommentsMentionUsersResponse = + zWorkflowCommentMentionUsersPayload export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdPath = z.object({ app_id: z.uuid(), @@ -5920,8 +5920,8 @@ export const zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesPath = z.object({ /** * Reply created successfully */ -export const zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse - = zWorkflowCommentReplyCreate +export const zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse = + zWorkflowCommentReplyCreate export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdPath = z.object({ app_id: z.uuid(), @@ -5934,8 +5934,8 @@ export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdPath = */ export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse = z.void() -export const zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdBody - = zWorkflowCommentReplyPayload +export const zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdBody = + zWorkflowCommentReplyPayload export const zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdPath = z.object({ app_id: z.uuid(), @@ -5946,8 +5946,8 @@ export const zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdPath = z. /** * Reply updated successfully */ -export const zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse - = zWorkflowCommentReplyUpdate +export const zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse = + zWorkflowCommentReplyUpdate export const zPostAppsByAppIdWorkflowCommentsByCommentIdResolvePath = z.object({ app_id: z.uuid(), @@ -5971,8 +5971,8 @@ export const zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsQuery = z.ob /** * Average app interaction statistics retrieved successfully */ -export const zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse - = zWorkflowAverageAppInteractionStatisticResponse +export const zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse = + zWorkflowAverageAppInteractionStatisticResponse export const zGetAppsByAppIdWorkflowStatisticsDailyConversationsPath = z.object({ app_id: z.uuid(), @@ -5986,8 +5986,8 @@ export const zGetAppsByAppIdWorkflowStatisticsDailyConversationsQuery = z.object /** * Daily runs statistics retrieved successfully */ -export const zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse - = zWorkflowDailyRunsStatisticResponse +export const zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse = + zWorkflowDailyRunsStatisticResponse export const zGetAppsByAppIdWorkflowStatisticsDailyTerminalsPath = z.object({ app_id: z.uuid(), @@ -6001,8 +6001,8 @@ export const zGetAppsByAppIdWorkflowStatisticsDailyTerminalsQuery = z.object({ /** * Daily terminals statistics retrieved successfully */ -export const zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse - = zWorkflowDailyTerminalsStatisticResponse +export const zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse = + zWorkflowDailyTerminalsStatisticResponse export const zGetAppsByAppIdWorkflowStatisticsTokenCostsPath = z.object({ app_id: z.uuid(), @@ -6016,8 +6016,8 @@ export const zGetAppsByAppIdWorkflowStatisticsTokenCostsQuery = z.object({ /** * Daily token cost statistics retrieved successfully */ -export const zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse - = zWorkflowDailyTokenCostStatisticResponse +export const zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse = + zWorkflowDailyTokenCostStatisticResponse export const zGetAppsByAppIdWorkflowsPath = z.object({ app_id: z.uuid(), @@ -6042,8 +6042,8 @@ export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsPath = z.object( /** * Default block configurations retrieved successfully */ -export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse - = zDefaultBlockConfigsResponse +export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse = + zDefaultBlockConfigsResponse export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath = z.object({ app_id: z.uuid(), @@ -6057,8 +6057,8 @@ export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeQuery /** * Default block configuration retrieved successfully */ -export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse - = zDefaultBlockConfigResponse +export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse = + zDefaultBlockConfigResponse export const zGetAppsByAppIdWorkflowsDraftPath = z.object({ app_id: z.uuid(), @@ -6089,8 +6089,8 @@ export const zGetAppsByAppIdWorkflowsDraftConversationVariablesPath = z.object({ */ export const zGetAppsByAppIdWorkflowsDraftConversationVariablesResponse = zWorkflowDraftVariableList -export const zPostAppsByAppIdWorkflowsDraftConversationVariablesBody - = zConversationVariableUpdatePayload +export const zPostAppsByAppIdWorkflowsDraftConversationVariablesBody = + zConversationVariableUpdatePayload export const zPostAppsByAppIdWorkflowsDraftConversationVariablesPath = z.object({ app_id: z.uuid(), @@ -6108,11 +6108,11 @@ export const zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesPath = z.object({ /** * Environment variables retrieved successfully */ -export const zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse - = zEnvironmentVariableListResponse +export const zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse = + zEnvironmentVariableListResponse -export const zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesBody - = zEnvironmentVariableUpdatePayload +export const zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesBody = + zEnvironmentVariableUpdatePayload export const zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesPath = z.object({ app_id: z.uuid(), @@ -6134,8 +6134,8 @@ export const zPostAppsByAppIdWorkflowsDraftFeaturesPath = z.object({ */ export const zPostAppsByAppIdWorkflowsDraftFeaturesResponse = zSimpleResultResponse -export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestBody - = zHumanInputDeliveryTestPayload +export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestBody = + zHumanInputDeliveryTestPayload export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestPath = z.object({ app_id: z.uuid(), @@ -6145,11 +6145,11 @@ export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestPa /** * Human input delivery test result */ -export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse - = zEmptyObjectResponse +export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse = + zEmptyObjectResponse -export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewBody - = zHumanInputFormPreviewPayload +export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewBody = + zHumanInputFormPreviewPayload export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewPath = z.object({ app_id: z.uuid(), @@ -6159,11 +6159,11 @@ export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewPat /** * Human input form preview */ -export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse - = zHumanInputFormPreviewResponse +export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse = + zHumanInputFormPreviewResponse -export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunBody - = zHumanInputFormSubmitPayload +export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunBody = + zHumanInputFormSubmitPayload export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunPath = z.object({ app_id: z.uuid(), @@ -6173,8 +6173,8 @@ export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunPath = /** * Human input form submission result */ -export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse - = zHumanInputFormSubmitResponse +export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse = + zHumanInputFormSubmitResponse export const zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunBody = zIterationNodeRunPayload @@ -6212,8 +6212,8 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerQuery = z.ob /** * Workflow agent composer state */ -export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse - = zWorkflowAgentComposerResponse +export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = + zWorkflowAgentComposerResponse export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerBody = zComposerSavePayload @@ -6225,8 +6225,8 @@ export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.obj /** * Workflow agent composer saved */ -export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse - = zWorkflowAgentComposerResponse +export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = + zWorkflowAgentComposerResponse export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath = z.object({ app_id: z.uuid(), @@ -6236,11 +6236,11 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPa /** * Workflow agent composer candidates */ -export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse - = zAgentComposerCandidatesResponse +export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = + zAgentComposerCandidatesResponse -export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody - = zWorkflowComposerCopyFromRosterPayload +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody = + zWorkflowComposerCopyFromRosterPayload export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath = z.object({ app_id: z.uuid(), @@ -6250,11 +6250,11 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRos /** * Workflow roster agent copied to inline agent */ -export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse - = zWorkflowAgentComposerResponse +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse = + zWorkflowAgentComposerResponse -export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody - = zComposerSavePayload +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody = + zComposerSavePayload export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath = z.object({ app_id: z.uuid(), @@ -6264,11 +6264,11 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath /** * Workflow agent composer impact */ -export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse - = zAgentComposerImpactResponse +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse = + zAgentComposerImpactResponse -export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody - = zComposerSavePayload +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody = + zComposerSavePayload export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterPath = z.object({ app_id: z.uuid(), @@ -6278,11 +6278,11 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRoste /** * Workflow agent composer saved to roster */ -export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse - = zWorkflowAgentComposerResponse +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse = + zWorkflowAgentComposerResponse -export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBody - = zComposerSavePayload +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBody = + zComposerSavePayload export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePath = z.object({ app_id: z.uuid(), @@ -6292,8 +6292,8 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePat /** * Workflow agent composer validation result */ -export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse - = zAgentComposerValidateResponse +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse = + zAgentComposerValidateResponse export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ app_id: z.uuid(), @@ -6303,8 +6303,8 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ /** * Node last run retrieved successfully */ -export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse - = zWorkflowRunNodeExecutionResponse +export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse = + zWorkflowRunNodeExecutionResponse export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunBody = zDraftWorkflowNodeRunPayload @@ -6316,8 +6316,8 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunPath = z.object({ /** * Node run started successfully */ -export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse - = zWorkflowRunNodeExecutionResponse +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse = + zWorkflowRunNodeExecutionResponse export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunPath = z.object({ app_id: z.uuid(), @@ -6347,8 +6347,8 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object( /** * Node variables retrieved successfully */ -export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse - = zWorkflowDraftVariableList +export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse = + zWorkflowDraftVariableList export const zPostAppsByAppIdWorkflowsDraftRunBody = zDraftWorkflowRunPayload @@ -6379,8 +6379,8 @@ export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsPath = z.o /** * Workflow run node output event stream */ -export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse - = zEventStreamResponse +export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse = + zEventStreamResponse export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdPath = z.object({ app_id: z.uuid(), @@ -6393,8 +6393,8 @@ export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdPath = z */ export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse = zNodeOutputsView -export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewPath - = z.object({ +export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewPath = + z.object({ app_id: z.uuid(), node_id: z.string(), output_name: z.string(), @@ -6404,8 +6404,8 @@ export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutput /** * Workflow run node output preview */ -export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse - = zOutputPreviewView +export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse = + zOutputPreviewView export const zGetAppsByAppIdWorkflowsDraftSystemVariablesPath = z.object({ app_id: z.uuid(), @@ -6481,8 +6481,8 @@ export const zGetAppsByAppIdWorkflowsDraftVariablesByVariableIdPath = z.object({ */ export const zGetAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse = zWorkflowDraftVariable -export const zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdBody - = zWorkflowDraftVariableUpdatePayload +export const zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdBody = + zWorkflowDraftVariableUpdatePayload export const zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdPath = z.object({ app_id: z.uuid(), @@ -6532,8 +6532,8 @@ export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsPath = z.obj /** * Workflow run node outputs */ -export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse - = zWorkflowRunSnapshotView +export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse = + zWorkflowRunSnapshotView export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsPath = z.object({ app_id: z.uuid(), @@ -6543,8 +6543,8 @@ export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsPath = /** * Workflow run node output event stream */ -export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse - = zEventStreamResponse +export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse = + zEventStreamResponse export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdPath = z.object({ app_id: z.uuid(), @@ -6555,11 +6555,11 @@ export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdPath /** * Workflow run node output detail */ -export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse - = zNodeOutputsView +export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse = + zNodeOutputsView -export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewPath - = z.object({ +export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewPath = + z.object({ app_id: z.uuid(), node_id: z.string(), output_name: z.string(), @@ -6569,8 +6569,8 @@ export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOu /** * Workflow run node output preview */ -export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse - = zOutputPreviewView +export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse = + zOutputPreviewView export const zGetAppsByAppIdWorkflowsTriggersWebhookPath = z.object({ app_id: z.uuid(), diff --git a/packages/contracts/generated/api/console/auth/orpc.gen.ts b/packages/contracts/generated/api/console/auth/orpc.gen.ts index 7acf024d4e4582..c6c1517597284b 100644 --- a/packages/contracts/generated/api/console/auth/orpc.gen.ts +++ b/packages/contracts/generated/api/console/auth/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteAuthPluginDatasourceByProviderIdCustomClientPath, zDeleteAuthPluginDatasourceByProviderIdCustomClientResponse, diff --git a/packages/contracts/generated/api/console/auth/types.gen.ts b/packages/contracts/generated/api/console/auth/types.gen.ts index 4951cbbf9a0fc8..7b9404d5bfb1af 100644 --- a/packages/contracts/generated/api/console/auth/types.gen.ts +++ b/packages/contracts/generated/api/console/auth/types.gen.ts @@ -115,25 +115,25 @@ export type Option = { export type AppSelectorScope = 'all' | 'chat' | 'completion' | 'workflow' -export type ModelSelectorScope - = | 'llm' - | 'moderation' - | 'rerank' - | 'speech2text' - | 'text-embedding' - | 'tts' - | 'vision' +export type ModelSelectorScope = + | 'llm' + | 'moderation' + | 'rerank' + | 'speech2text' + | 'text-embedding' + | 'tts' + | 'vision' export type ToolSelectorScope = 'all' | 'builtin' | 'custom' | 'workflow' -export type ProviderConfigType - = | 'app-selector' - | 'array[tools]' - | 'boolean' - | 'model-selector' - | 'secret-input' - | 'select' - | 'text-input' +export type ProviderConfigType = + | 'app-selector' + | 'array[tools]' + | 'boolean' + | 'model-selector' + | 'secret-input' + | 'select' + | 'text-input' export type GetAuthPluginDatasourceDefaultListData = { body?: never @@ -146,8 +146,8 @@ export type GetAuthPluginDatasourceDefaultListResponses = { 200: DatasourceProviderAuthListResponse } -export type GetAuthPluginDatasourceDefaultListResponse - = GetAuthPluginDatasourceDefaultListResponses[keyof GetAuthPluginDatasourceDefaultListResponses] +export type GetAuthPluginDatasourceDefaultListResponse = + GetAuthPluginDatasourceDefaultListResponses[keyof GetAuthPluginDatasourceDefaultListResponses] export type GetAuthPluginDatasourceListData = { body?: never @@ -160,8 +160,8 @@ export type GetAuthPluginDatasourceListResponses = { 200: DatasourceProviderAuthListResponse } -export type GetAuthPluginDatasourceListResponse - = GetAuthPluginDatasourceListResponses[keyof GetAuthPluginDatasourceListResponses] +export type GetAuthPluginDatasourceListResponse = + GetAuthPluginDatasourceListResponses[keyof GetAuthPluginDatasourceListResponses] export type GetAuthPluginDatasourceByProviderIdData = { body?: never @@ -176,8 +176,8 @@ export type GetAuthPluginDatasourceByProviderIdResponses = { 200: DatasourceCredentialListResponse } -export type GetAuthPluginDatasourceByProviderIdResponse - = GetAuthPluginDatasourceByProviderIdResponses[keyof GetAuthPluginDatasourceByProviderIdResponses] +export type GetAuthPluginDatasourceByProviderIdResponse = + GetAuthPluginDatasourceByProviderIdResponses[keyof GetAuthPluginDatasourceByProviderIdResponses] export type PostAuthPluginDatasourceByProviderIdData = { body: DatasourceCredentialPayload @@ -192,8 +192,8 @@ export type PostAuthPluginDatasourceByProviderIdResponses = { 200: SimpleResultResponse } -export type PostAuthPluginDatasourceByProviderIdResponse - = PostAuthPluginDatasourceByProviderIdResponses[keyof PostAuthPluginDatasourceByProviderIdResponses] +export type PostAuthPluginDatasourceByProviderIdResponse = + PostAuthPluginDatasourceByProviderIdResponses[keyof PostAuthPluginDatasourceByProviderIdResponses] export type DeleteAuthPluginDatasourceByProviderIdCustomClientData = { body?: never @@ -208,8 +208,8 @@ export type DeleteAuthPluginDatasourceByProviderIdCustomClientResponses = { 200: SimpleResultResponse } -export type DeleteAuthPluginDatasourceByProviderIdCustomClientResponse - = DeleteAuthPluginDatasourceByProviderIdCustomClientResponses[keyof DeleteAuthPluginDatasourceByProviderIdCustomClientResponses] +export type DeleteAuthPluginDatasourceByProviderIdCustomClientResponse = + DeleteAuthPluginDatasourceByProviderIdCustomClientResponses[keyof DeleteAuthPluginDatasourceByProviderIdCustomClientResponses] export type PostAuthPluginDatasourceByProviderIdCustomClientData = { body: DatasourceCustomClientPayload @@ -224,8 +224,8 @@ export type PostAuthPluginDatasourceByProviderIdCustomClientResponses = { 200: SimpleResultResponse } -export type PostAuthPluginDatasourceByProviderIdCustomClientResponse - = PostAuthPluginDatasourceByProviderIdCustomClientResponses[keyof PostAuthPluginDatasourceByProviderIdCustomClientResponses] +export type PostAuthPluginDatasourceByProviderIdCustomClientResponse = + PostAuthPluginDatasourceByProviderIdCustomClientResponses[keyof PostAuthPluginDatasourceByProviderIdCustomClientResponses] export type PostAuthPluginDatasourceByProviderIdDefaultData = { body: DatasourceDefaultPayload @@ -240,8 +240,8 @@ export type PostAuthPluginDatasourceByProviderIdDefaultResponses = { 200: SimpleResultResponse } -export type PostAuthPluginDatasourceByProviderIdDefaultResponse - = PostAuthPluginDatasourceByProviderIdDefaultResponses[keyof PostAuthPluginDatasourceByProviderIdDefaultResponses] +export type PostAuthPluginDatasourceByProviderIdDefaultResponse = + PostAuthPluginDatasourceByProviderIdDefaultResponses[keyof PostAuthPluginDatasourceByProviderIdDefaultResponses] export type PostAuthPluginDatasourceByProviderIdDeleteData = { body: DatasourceCredentialDeletePayload @@ -256,8 +256,8 @@ export type PostAuthPluginDatasourceByProviderIdDeleteResponses = { 200: SimpleResultResponse } -export type PostAuthPluginDatasourceByProviderIdDeleteResponse - = PostAuthPluginDatasourceByProviderIdDeleteResponses[keyof PostAuthPluginDatasourceByProviderIdDeleteResponses] +export type PostAuthPluginDatasourceByProviderIdDeleteResponse = + PostAuthPluginDatasourceByProviderIdDeleteResponses[keyof PostAuthPluginDatasourceByProviderIdDeleteResponses] export type PostAuthPluginDatasourceByProviderIdUpdateData = { body: DatasourceCredentialUpdatePayload @@ -272,8 +272,8 @@ export type PostAuthPluginDatasourceByProviderIdUpdateResponses = { 201: SimpleResultResponse } -export type PostAuthPluginDatasourceByProviderIdUpdateResponse - = PostAuthPluginDatasourceByProviderIdUpdateResponses[keyof PostAuthPluginDatasourceByProviderIdUpdateResponses] +export type PostAuthPluginDatasourceByProviderIdUpdateResponse = + PostAuthPluginDatasourceByProviderIdUpdateResponses[keyof PostAuthPluginDatasourceByProviderIdUpdateResponses] export type PostAuthPluginDatasourceByProviderIdUpdateNameData = { body: DatasourceUpdateNamePayload @@ -288,5 +288,5 @@ export type PostAuthPluginDatasourceByProviderIdUpdateNameResponses = { 200: SimpleResultResponse } -export type PostAuthPluginDatasourceByProviderIdUpdateNameResponse - = PostAuthPluginDatasourceByProviderIdUpdateNameResponses[keyof PostAuthPluginDatasourceByProviderIdUpdateNameResponses] +export type PostAuthPluginDatasourceByProviderIdUpdateNameResponse = + PostAuthPluginDatasourceByProviderIdUpdateNameResponses[keyof PostAuthPluginDatasourceByProviderIdUpdateNameResponses] diff --git a/packages/contracts/generated/api/console/billing/orpc.gen.ts b/packages/contracts/generated/api/console/billing/orpc.gen.ts index 9abd8e8fe1a2cd..1100fac53d1505 100644 --- a/packages/contracts/generated/api/console/billing/orpc.gen.ts +++ b/packages/contracts/generated/api/console/billing/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetBillingInvoicesResponse, zGetBillingSubscriptionQuery, diff --git a/packages/contracts/generated/api/console/billing/types.gen.ts b/packages/contracts/generated/api/console/billing/types.gen.ts index c303947043dd95..dc7afb41d0fc12 100644 --- a/packages/contracts/generated/api/console/billing/types.gen.ts +++ b/packages/contracts/generated/api/console/billing/types.gen.ts @@ -27,8 +27,8 @@ export type GetBillingInvoicesResponses = { 200: BillingInvoiceResponse } -export type GetBillingInvoicesResponse - = GetBillingInvoicesResponses[keyof GetBillingInvoicesResponses] +export type GetBillingInvoicesResponse = + GetBillingInvoicesResponses[keyof GetBillingInvoicesResponses] export type PutBillingPartnersByPartnerKeyTenantsData = { body: PartnerTenantsPayload @@ -47,8 +47,8 @@ export type PutBillingPartnersByPartnerKeyTenantsResponses = { 200: BillingResponse } -export type PutBillingPartnersByPartnerKeyTenantsResponse - = PutBillingPartnersByPartnerKeyTenantsResponses[keyof PutBillingPartnersByPartnerKeyTenantsResponses] +export type PutBillingPartnersByPartnerKeyTenantsResponse = + PutBillingPartnersByPartnerKeyTenantsResponses[keyof PutBillingPartnersByPartnerKeyTenantsResponses] export type GetBillingSubscriptionData = { body?: never @@ -64,5 +64,5 @@ export type GetBillingSubscriptionResponses = { 200: BillingResponse } -export type GetBillingSubscriptionResponse - = GetBillingSubscriptionResponses[keyof GetBillingSubscriptionResponses] +export type GetBillingSubscriptionResponse = + GetBillingSubscriptionResponses[keyof GetBillingSubscriptionResponses] diff --git a/packages/contracts/generated/api/console/code-based-extension/orpc.gen.ts b/packages/contracts/generated/api/console/code-based-extension/orpc.gen.ts index f60e6e2c8bcc94..d3ff087d207f34 100644 --- a/packages/contracts/generated/api/console/code-based-extension/orpc.gen.ts +++ b/packages/contracts/generated/api/console/code-based-extension/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetCodeBasedExtensionQuery, zGetCodeBasedExtensionResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/code-based-extension/types.gen.ts b/packages/contracts/generated/api/console/code-based-extension/types.gen.ts index f6abfd3212e5eb..20811911090e0e 100644 --- a/packages/contracts/generated/api/console/code-based-extension/types.gen.ts +++ b/packages/contracts/generated/api/console/code-based-extension/types.gen.ts @@ -22,5 +22,5 @@ export type GetCodeBasedExtensionResponses = { 200: CodeBasedExtensionResponse } -export type GetCodeBasedExtensionResponse - = GetCodeBasedExtensionResponses[keyof GetCodeBasedExtensionResponses] +export type GetCodeBasedExtensionResponse = + GetCodeBasedExtensionResponses[keyof GetCodeBasedExtensionResponses] diff --git a/packages/contracts/generated/api/console/compliance/orpc.gen.ts b/packages/contracts/generated/api/console/compliance/orpc.gen.ts index e68c87e7eba8f0..f699f7eea6bcb5 100644 --- a/packages/contracts/generated/api/console/compliance/orpc.gen.ts +++ b/packages/contracts/generated/api/console/compliance/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetComplianceDownloadQuery, zGetComplianceDownloadResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/compliance/types.gen.ts b/packages/contracts/generated/api/console/compliance/types.gen.ts index 71ffbba58deb42..6a01752eaddb11 100644 --- a/packages/contracts/generated/api/console/compliance/types.gen.ts +++ b/packages/contracts/generated/api/console/compliance/types.gen.ts @@ -21,5 +21,5 @@ export type GetComplianceDownloadResponses = { 200: ComplianceDownloadResponse } -export type GetComplianceDownloadResponse - = GetComplianceDownloadResponses[keyof GetComplianceDownloadResponses] +export type GetComplianceDownloadResponse = + GetComplianceDownloadResponses[keyof GetComplianceDownloadResponses] diff --git a/packages/contracts/generated/api/console/data-source/orpc.gen.ts b/packages/contracts/generated/api/console/data-source/orpc.gen.ts index 209447236a1949..e6aafabe1ffbbb 100644 --- a/packages/contracts/generated/api/console/data-source/orpc.gen.ts +++ b/packages/contracts/generated/api/console/data-source/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetDataSourceIntegratesByBindingIdByActionPath, zGetDataSourceIntegratesByBindingIdByActionResponse, diff --git a/packages/contracts/generated/api/console/data-source/types.gen.ts b/packages/contracts/generated/api/console/data-source/types.gen.ts index a065af49001648..1c074190df0c03 100644 --- a/packages/contracts/generated/api/console/data-source/types.gen.ts +++ b/packages/contracts/generated/api/console/data-source/types.gen.ts @@ -55,8 +55,8 @@ export type GetDataSourceIntegratesResponses = { 200: DataSourceIntegrateListResponse } -export type GetDataSourceIntegratesResponse - = GetDataSourceIntegratesResponses[keyof GetDataSourceIntegratesResponses] +export type GetDataSourceIntegratesResponse = + GetDataSourceIntegratesResponses[keyof GetDataSourceIntegratesResponses] export type PatchDataSourceIntegratesData = { body?: never @@ -69,8 +69,8 @@ export type PatchDataSourceIntegratesResponses = { 200: SimpleResultResponse } -export type PatchDataSourceIntegratesResponse - = PatchDataSourceIntegratesResponses[keyof PatchDataSourceIntegratesResponses] +export type PatchDataSourceIntegratesResponse = + PatchDataSourceIntegratesResponses[keyof PatchDataSourceIntegratesResponses] export type GetDataSourceIntegratesByBindingIdByActionData = { body?: never @@ -86,8 +86,8 @@ export type GetDataSourceIntegratesByBindingIdByActionResponses = { 200: DataSourceIntegrateListResponse } -export type GetDataSourceIntegratesByBindingIdByActionResponse - = GetDataSourceIntegratesByBindingIdByActionResponses[keyof GetDataSourceIntegratesByBindingIdByActionResponses] +export type GetDataSourceIntegratesByBindingIdByActionResponse = + GetDataSourceIntegratesByBindingIdByActionResponses[keyof GetDataSourceIntegratesByBindingIdByActionResponses] export type PatchDataSourceIntegratesByBindingIdByActionData = { body?: never @@ -103,5 +103,5 @@ export type PatchDataSourceIntegratesByBindingIdByActionResponses = { 200: SimpleResultResponse } -export type PatchDataSourceIntegratesByBindingIdByActionResponse - = PatchDataSourceIntegratesByBindingIdByActionResponses[keyof PatchDataSourceIntegratesByBindingIdByActionResponses] +export type PatchDataSourceIntegratesByBindingIdByActionResponse = + PatchDataSourceIntegratesByBindingIdByActionResponses[keyof PatchDataSourceIntegratesByBindingIdByActionResponses] diff --git a/packages/contracts/generated/api/console/datasets/orpc.gen.ts b/packages/contracts/generated/api/console/datasets/orpc.gen.ts index ba3b00c6358fdd..326bf34b30d55b 100644 --- a/packages/contracts/generated/api/console/datasets/orpc.gen.ts +++ b/packages/contracts/generated/api/console/datasets/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteDatasetsApiKeysByApiKeyIdPath, zDeleteDatasetsApiKeysByApiKeyIdResponse, @@ -666,7 +665,7 @@ export const downloadZip = { export const post10 = oc .route({ description: - 'Generate summary index for documents\nThis endpoint checks if the dataset configuration supports summary generation\n(indexing_technique must be \'high_quality\' and summary_index_setting.enable must be true),\nthen asynchronously generates summary indexes for the provided documents.', + "Generate summary index for documents\nThis endpoint checks if the dataset configuration supports summary generation\n(indexing_technique must be 'high_quality' and summary_index_setting.enable must be true),\nthen asynchronously generates summary indexes for the provided documents.", inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsGenerateSummary', @@ -735,7 +734,7 @@ export const status = { */ export const get14 = oc .route({ - description: 'Get a signed download URL for a dataset document\'s original uploaded file', + description: "Get a signed download URL for a dataset document's original uploaded file", inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdDownload', diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index 1e2f253d3af1aa..0382f58170089f 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -904,11 +904,11 @@ export type RerankingModel = { reranking_provider_name?: string | null } -export type RetrievalMethod - = | 'full_text_search' - | 'hybrid_search' - | 'keyword_search' - | 'semantic_search' +export type RetrievalMethod = + | 'full_text_search' + | 'hybrid_search' + | 'keyword_search' + | 'semantic_search' export type WeightModel = { keyword_setting?: WeightKeywordSetting | null @@ -1129,8 +1129,8 @@ export type GetDatasetsApiBaseInfoResponses = { 200: ApiBaseUrlResponse } -export type GetDatasetsApiBaseInfoResponse - = GetDatasetsApiBaseInfoResponses[keyof GetDatasetsApiBaseInfoResponses] +export type GetDatasetsApiBaseInfoResponse = + GetDatasetsApiBaseInfoResponses[keyof GetDatasetsApiBaseInfoResponses] export type GetDatasetsApiKeysData = { body?: never @@ -1143,8 +1143,8 @@ export type GetDatasetsApiKeysResponses = { 200: ApiKeyList } -export type GetDatasetsApiKeysResponse - = GetDatasetsApiKeysResponses[keyof GetDatasetsApiKeysResponses] +export type GetDatasetsApiKeysResponse = + GetDatasetsApiKeysResponses[keyof GetDatasetsApiKeysResponses] export type PostDatasetsApiKeysData = { body?: never @@ -1161,8 +1161,8 @@ export type PostDatasetsApiKeysResponses = { 200: ApiKeyItem } -export type PostDatasetsApiKeysResponse - = PostDatasetsApiKeysResponses[keyof PostDatasetsApiKeysResponses] +export type PostDatasetsApiKeysResponse = + PostDatasetsApiKeysResponses[keyof PostDatasetsApiKeysResponses] export type DeleteDatasetsApiKeysByApiKeyIdData = { body?: never @@ -1177,8 +1177,8 @@ export type DeleteDatasetsApiKeysByApiKeyIdResponses = { 204: void } -export type DeleteDatasetsApiKeysByApiKeyIdResponse - = DeleteDatasetsApiKeysByApiKeyIdResponses[keyof DeleteDatasetsApiKeysByApiKeyIdResponses] +export type DeleteDatasetsApiKeysByApiKeyIdResponse = + DeleteDatasetsApiKeysByApiKeyIdResponses[keyof DeleteDatasetsApiKeysByApiKeyIdResponses] export type GetDatasetsBatchImportStatusByJobIdData = { body?: never @@ -1193,8 +1193,8 @@ export type GetDatasetsBatchImportStatusByJobIdResponses = { 200: SegmentBatchImportStatusResponse } -export type GetDatasetsBatchImportStatusByJobIdResponse - = GetDatasetsBatchImportStatusByJobIdResponses[keyof GetDatasetsBatchImportStatusByJobIdResponses] +export type GetDatasetsBatchImportStatusByJobIdResponse = + GetDatasetsBatchImportStatusByJobIdResponses[keyof GetDatasetsBatchImportStatusByJobIdResponses] export type PostDatasetsBatchImportStatusByJobIdData = { body: BatchImportPayload @@ -1209,8 +1209,8 @@ export type PostDatasetsBatchImportStatusByJobIdResponses = { 200: SegmentBatchImportStatusResponse } -export type PostDatasetsBatchImportStatusByJobIdResponse - = PostDatasetsBatchImportStatusByJobIdResponses[keyof PostDatasetsBatchImportStatusByJobIdResponses] +export type PostDatasetsBatchImportStatusByJobIdResponse = + PostDatasetsBatchImportStatusByJobIdResponses[keyof PostDatasetsBatchImportStatusByJobIdResponses] export type PostDatasetsExternalData = { body: ExternalDatasetCreatePayload @@ -1228,8 +1228,8 @@ export type PostDatasetsExternalResponses = { 201: DatasetDetailResponse } -export type PostDatasetsExternalResponse - = PostDatasetsExternalResponses[keyof PostDatasetsExternalResponses] +export type PostDatasetsExternalResponse = + PostDatasetsExternalResponses[keyof PostDatasetsExternalResponses] export type GetDatasetsExternalKnowledgeApiData = { body?: never @@ -1246,8 +1246,8 @@ export type GetDatasetsExternalKnowledgeApiResponses = { 200: ExternalKnowledgeApiListResponse } -export type GetDatasetsExternalKnowledgeApiResponse - = GetDatasetsExternalKnowledgeApiResponses[keyof GetDatasetsExternalKnowledgeApiResponses] +export type GetDatasetsExternalKnowledgeApiResponse = + GetDatasetsExternalKnowledgeApiResponses[keyof GetDatasetsExternalKnowledgeApiResponses] export type PostDatasetsExternalKnowledgeApiData = { body: ExternalKnowledgeApiPayload @@ -1264,8 +1264,8 @@ export type PostDatasetsExternalKnowledgeApiResponses = { 201: ExternalKnowledgeApiResponse } -export type PostDatasetsExternalKnowledgeApiResponse - = PostDatasetsExternalKnowledgeApiResponses[keyof PostDatasetsExternalKnowledgeApiResponses] +export type PostDatasetsExternalKnowledgeApiResponse = + PostDatasetsExternalKnowledgeApiResponses[keyof PostDatasetsExternalKnowledgeApiResponses] export type DeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdData = { body?: never @@ -1280,8 +1280,8 @@ export type DeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses 204: void } -export type DeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse - = DeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses[keyof DeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses] +export type DeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse = + DeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses[keyof DeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses] export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdData = { body?: never @@ -1300,8 +1300,8 @@ export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses = { 200: ExternalKnowledgeApiResponse } -export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse - = GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses[keyof GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses] +export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse = + GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses[keyof GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses] export type PatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdData = { body: ExternalKnowledgeApiPayload @@ -1320,8 +1320,8 @@ export type PatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses = 200: ExternalKnowledgeApiResponse } -export type PatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse - = PatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses[keyof PatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses] +export type PatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse = + PatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses[keyof PatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses] export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckData = { body?: never @@ -1336,8 +1336,8 @@ export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckRespo 200: UsageCountResponse } -export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckResponse - = GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckResponses[keyof GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckResponses] +export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckResponse = + GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckResponses[keyof GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckResponses] export type PostDatasetsIndexingEstimateData = { body: IndexingEstimatePayload @@ -1350,8 +1350,8 @@ export type PostDatasetsIndexingEstimateResponses = { 200: IndexingEstimateResponse } -export type PostDatasetsIndexingEstimateResponse - = PostDatasetsIndexingEstimateResponses[keyof PostDatasetsIndexingEstimateResponses] +export type PostDatasetsIndexingEstimateResponse = + PostDatasetsIndexingEstimateResponses[keyof PostDatasetsIndexingEstimateResponses] export type PostDatasetsInitData = { body: KnowledgeConfig @@ -1381,8 +1381,8 @@ export type GetDatasetsMetadataBuiltInResponses = { 200: DatasetMetadataBuiltInFieldsResponse } -export type GetDatasetsMetadataBuiltInResponse - = GetDatasetsMetadataBuiltInResponses[keyof GetDatasetsMetadataBuiltInResponses] +export type GetDatasetsMetadataBuiltInResponse = + GetDatasetsMetadataBuiltInResponses[keyof GetDatasetsMetadataBuiltInResponses] export type PostDatasetsNotionIndexingEstimateData = { body: NotionEstimatePayload @@ -1395,8 +1395,8 @@ export type PostDatasetsNotionIndexingEstimateResponses = { 200: IndexingEstimate } -export type PostDatasetsNotionIndexingEstimateResponse - = PostDatasetsNotionIndexingEstimateResponses[keyof PostDatasetsNotionIndexingEstimateResponses] +export type PostDatasetsNotionIndexingEstimateResponse = + PostDatasetsNotionIndexingEstimateResponses[keyof PostDatasetsNotionIndexingEstimateResponses] export type GetDatasetsProcessRuleData = { body?: never @@ -1411,8 +1411,8 @@ export type GetDatasetsProcessRuleResponses = { 200: ProcessRuleResponse } -export type GetDatasetsProcessRuleResponse - = GetDatasetsProcessRuleResponses[keyof GetDatasetsProcessRuleResponses] +export type GetDatasetsProcessRuleResponse = + GetDatasetsProcessRuleResponses[keyof GetDatasetsProcessRuleResponses] export type GetDatasetsRetrievalSettingData = { body?: never @@ -1425,8 +1425,8 @@ export type GetDatasetsRetrievalSettingResponses = { 200: RetrievalSettingResponse } -export type GetDatasetsRetrievalSettingResponse - = GetDatasetsRetrievalSettingResponses[keyof GetDatasetsRetrievalSettingResponses] +export type GetDatasetsRetrievalSettingResponse = + GetDatasetsRetrievalSettingResponses[keyof GetDatasetsRetrievalSettingResponses] export type GetDatasetsRetrievalSettingByVectorTypeData = { body?: never @@ -1441,8 +1441,8 @@ export type GetDatasetsRetrievalSettingByVectorTypeResponses = { 200: RetrievalSettingResponse } -export type GetDatasetsRetrievalSettingByVectorTypeResponse - = GetDatasetsRetrievalSettingByVectorTypeResponses[keyof GetDatasetsRetrievalSettingByVectorTypeResponses] +export type GetDatasetsRetrievalSettingByVectorTypeResponse = + GetDatasetsRetrievalSettingByVectorTypeResponses[keyof GetDatasetsRetrievalSettingByVectorTypeResponses] export type DeleteDatasetsByDatasetIdData = { body?: never @@ -1457,8 +1457,8 @@ export type DeleteDatasetsByDatasetIdResponses = { 204: void } -export type DeleteDatasetsByDatasetIdResponse - = DeleteDatasetsByDatasetIdResponses[keyof DeleteDatasetsByDatasetIdResponses] +export type DeleteDatasetsByDatasetIdResponse = + DeleteDatasetsByDatasetIdResponses[keyof DeleteDatasetsByDatasetIdResponses] export type GetDatasetsByDatasetIdData = { body?: never @@ -1478,8 +1478,8 @@ export type GetDatasetsByDatasetIdResponses = { 200: DatasetDetailWithPartialMembersResponse } -export type GetDatasetsByDatasetIdResponse - = GetDatasetsByDatasetIdResponses[keyof GetDatasetsByDatasetIdResponses] +export type GetDatasetsByDatasetIdResponse = + GetDatasetsByDatasetIdResponses[keyof GetDatasetsByDatasetIdResponses] export type PatchDatasetsByDatasetIdData = { body: DatasetUpdatePayload @@ -1499,8 +1499,8 @@ export type PatchDatasetsByDatasetIdResponses = { 200: DatasetDetailWithPartialMembersResponse } -export type PatchDatasetsByDatasetIdResponse - = PatchDatasetsByDatasetIdResponses[keyof PatchDatasetsByDatasetIdResponses] +export type PatchDatasetsByDatasetIdResponse = + PatchDatasetsByDatasetIdResponses[keyof PatchDatasetsByDatasetIdResponses] export type PostDatasetsByDatasetIdApiKeysByStatusData = { body?: never @@ -1516,8 +1516,8 @@ export type PostDatasetsByDatasetIdApiKeysByStatusResponses = { 200: SimpleResultResponse } -export type PostDatasetsByDatasetIdApiKeysByStatusResponse - = PostDatasetsByDatasetIdApiKeysByStatusResponses[keyof PostDatasetsByDatasetIdApiKeysByStatusResponses] +export type PostDatasetsByDatasetIdApiKeysByStatusResponse = + PostDatasetsByDatasetIdApiKeysByStatusResponses[keyof PostDatasetsByDatasetIdApiKeysByStatusResponses] export type GetDatasetsByDatasetIdAutoDisableLogsData = { body?: never @@ -1536,8 +1536,8 @@ export type GetDatasetsByDatasetIdAutoDisableLogsResponses = { 200: AutoDisableLogsResponse } -export type GetDatasetsByDatasetIdAutoDisableLogsResponse - = GetDatasetsByDatasetIdAutoDisableLogsResponses[keyof GetDatasetsByDatasetIdAutoDisableLogsResponses] +export type GetDatasetsByDatasetIdAutoDisableLogsResponse = + GetDatasetsByDatasetIdAutoDisableLogsResponses[keyof GetDatasetsByDatasetIdAutoDisableLogsResponses] export type GetDatasetsByDatasetIdBatchByBatchIndexingEstimateData = { body?: never @@ -1553,8 +1553,8 @@ export type GetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponses = { 200: IndexingEstimateResponse } -export type GetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponse - = GetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponses[keyof GetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponses] +export type GetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponse = + GetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponses[keyof GetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponses] export type GetDatasetsByDatasetIdBatchByBatchIndexingStatusData = { body?: never @@ -1570,8 +1570,8 @@ export type GetDatasetsByDatasetIdBatchByBatchIndexingStatusResponses = { 200: DocumentStatusListResponse } -export type GetDatasetsByDatasetIdBatchByBatchIndexingStatusResponse - = GetDatasetsByDatasetIdBatchByBatchIndexingStatusResponses[keyof GetDatasetsByDatasetIdBatchByBatchIndexingStatusResponses] +export type GetDatasetsByDatasetIdBatchByBatchIndexingStatusResponse = + GetDatasetsByDatasetIdBatchByBatchIndexingStatusResponses[keyof GetDatasetsByDatasetIdBatchByBatchIndexingStatusResponses] export type DeleteDatasetsByDatasetIdDocumentsData = { body?: never @@ -1586,8 +1586,8 @@ export type DeleteDatasetsByDatasetIdDocumentsResponses = { 204: void } -export type DeleteDatasetsByDatasetIdDocumentsResponse - = DeleteDatasetsByDatasetIdDocumentsResponses[keyof DeleteDatasetsByDatasetIdDocumentsResponses] +export type DeleteDatasetsByDatasetIdDocumentsResponse = + DeleteDatasetsByDatasetIdDocumentsResponses[keyof DeleteDatasetsByDatasetIdDocumentsResponses] export type GetDatasetsByDatasetIdDocumentsData = { body?: never @@ -1609,8 +1609,8 @@ export type GetDatasetsByDatasetIdDocumentsResponses = { 200: DocumentWithSegmentsListResponse } -export type GetDatasetsByDatasetIdDocumentsResponse - = GetDatasetsByDatasetIdDocumentsResponses[keyof GetDatasetsByDatasetIdDocumentsResponses] +export type GetDatasetsByDatasetIdDocumentsResponse = + GetDatasetsByDatasetIdDocumentsResponses[keyof GetDatasetsByDatasetIdDocumentsResponses] export type PostDatasetsByDatasetIdDocumentsData = { body: KnowledgeConfig @@ -1625,8 +1625,8 @@ export type PostDatasetsByDatasetIdDocumentsResponses = { 200: DatasetAndDocumentResponse } -export type PostDatasetsByDatasetIdDocumentsResponse - = PostDatasetsByDatasetIdDocumentsResponses[keyof PostDatasetsByDatasetIdDocumentsResponses] +export type PostDatasetsByDatasetIdDocumentsResponse = + PostDatasetsByDatasetIdDocumentsResponses[keyof PostDatasetsByDatasetIdDocumentsResponses] export type PostDatasetsByDatasetIdDocumentsDownloadZipData = { body: DocumentBatchDownloadZipPayload @@ -1643,8 +1643,8 @@ export type PostDatasetsByDatasetIdDocumentsDownloadZipResponses = { } } -export type PostDatasetsByDatasetIdDocumentsDownloadZipResponse - = PostDatasetsByDatasetIdDocumentsDownloadZipResponses[keyof PostDatasetsByDatasetIdDocumentsDownloadZipResponses] +export type PostDatasetsByDatasetIdDocumentsDownloadZipResponse = + PostDatasetsByDatasetIdDocumentsDownloadZipResponses[keyof PostDatasetsByDatasetIdDocumentsDownloadZipResponses] export type PostDatasetsByDatasetIdDocumentsGenerateSummaryData = { body: GenerateSummaryPayload @@ -1665,8 +1665,8 @@ export type PostDatasetsByDatasetIdDocumentsGenerateSummaryResponses = { 200: SimpleResultResponse } -export type PostDatasetsByDatasetIdDocumentsGenerateSummaryResponse - = PostDatasetsByDatasetIdDocumentsGenerateSummaryResponses[keyof PostDatasetsByDatasetIdDocumentsGenerateSummaryResponses] +export type PostDatasetsByDatasetIdDocumentsGenerateSummaryResponse = + PostDatasetsByDatasetIdDocumentsGenerateSummaryResponses[keyof PostDatasetsByDatasetIdDocumentsGenerateSummaryResponses] export type PostDatasetsByDatasetIdDocumentsMetadataData = { body: MetadataOperationData @@ -1681,8 +1681,8 @@ export type PostDatasetsByDatasetIdDocumentsMetadataResponses = { 204: void } -export type PostDatasetsByDatasetIdDocumentsMetadataResponse - = PostDatasetsByDatasetIdDocumentsMetadataResponses[keyof PostDatasetsByDatasetIdDocumentsMetadataResponses] +export type PostDatasetsByDatasetIdDocumentsMetadataResponse = + PostDatasetsByDatasetIdDocumentsMetadataResponses[keyof PostDatasetsByDatasetIdDocumentsMetadataResponses] export type PatchDatasetsByDatasetIdDocumentsStatusByActionBatchData = { body?: never @@ -1698,8 +1698,8 @@ export type PatchDatasetsByDatasetIdDocumentsStatusByActionBatchResponses = { 200: SimpleResultResponse } -export type PatchDatasetsByDatasetIdDocumentsStatusByActionBatchResponse - = PatchDatasetsByDatasetIdDocumentsStatusByActionBatchResponses[keyof PatchDatasetsByDatasetIdDocumentsStatusByActionBatchResponses] +export type PatchDatasetsByDatasetIdDocumentsStatusByActionBatchResponse = + PatchDatasetsByDatasetIdDocumentsStatusByActionBatchResponses[keyof PatchDatasetsByDatasetIdDocumentsStatusByActionBatchResponses] export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdData = { body?: never @@ -1715,8 +1715,8 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses = { 204: void } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse - = DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses] +export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse = + DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdData = { body?: never @@ -1738,8 +1738,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdResponses = { 200: DocumentDetailResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadData = { body?: never @@ -1755,8 +1755,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponses = { 200: UrlResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateData = { body?: never @@ -1777,8 +1777,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponses 200: IndexingEstimateResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusData = { body?: never @@ -1798,8 +1798,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusResponses = 200: DocumentStatusResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusResponses] export type PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataData = { body: DocumentMetadataUpdatePayload @@ -1820,8 +1820,8 @@ export type PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataResponses = { 200: SimpleResultMessageResponse } -export type PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataResponse - = PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataResponses[keyof PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataResponses] +export type PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataResponse = + PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataResponses[keyof PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdNotionSyncData = { body?: never @@ -1837,8 +1837,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdNotionSyncResponses = { 200: SimpleResultResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdNotionSyncResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdNotionSyncResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdNotionSyncResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdNotionSyncResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdNotionSyncResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdNotionSyncResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogData = { body?: never @@ -1854,8 +1854,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogRespo 200: DocumentPipelineExecutionLogResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogResponses] export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseData = { body?: never @@ -1871,8 +1871,8 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseResponse 204: void } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseResponse - = PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseResponses] +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseResponse = + PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseResponses] export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeData = { body?: never @@ -1888,8 +1888,8 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeRespons 204: void } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeResponse - = PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeResponses] +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeResponse = + PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeResponses] export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionData = { body?: never @@ -1911,8 +1911,8 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionRespo 200: SimpleResultResponse } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionResponse - = PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionResponses] +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionResponse = + PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionResponses] export type PostDatasetsByDatasetIdDocumentsByDocumentIdRenameData = { body: DocumentRenamePayload @@ -1928,8 +1928,8 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdRenameResponses = { 200: DocumentResponse } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdRenameResponse - = PostDatasetsByDatasetIdDocumentsByDocumentIdRenameResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdRenameResponses] +export type PostDatasetsByDatasetIdDocumentsByDocumentIdRenameResponse = + PostDatasetsByDatasetIdDocumentsByDocumentIdRenameResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdRenameResponses] export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentData = { body: SegmentCreatePayload @@ -1945,8 +1945,8 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentResponses = { 200: SegmentDetailResponse } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentResponse - = PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentResponses] +export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentResponse = + PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentResponses] export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionData = { body?: never @@ -1965,8 +1965,8 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionResponse 200: SimpleResultResponse } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionResponse - = PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionResponses] +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionResponse = + PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionResponses] export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsData = { body?: never @@ -1984,8 +1984,8 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses = { 204: void } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse - = DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses] +export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse = + DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsData = { body?: never @@ -2008,8 +2008,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses = { 200: ConsoleSegmentListResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportData = { body?: never @@ -2025,8 +2025,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportRespon 200: SegmentBatchImportStatusResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponses] export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportData = { body: BatchImportPayload @@ -2042,8 +2042,8 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportRespo 200: SegmentBatchImportStatusResponse } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponse - = PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponses] +export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponse = + PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponses] export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdData = { body?: never @@ -2060,8 +2060,8 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdRes 204: void } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse - = DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses] +export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = + DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses] export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdData = { body: SegmentUpdatePayload @@ -2078,8 +2078,8 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResp 200: SegmentDetailResponse } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse - = PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses] +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = + PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksData = { body?: never @@ -2100,8 +2100,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildC 200: ChildChunkListResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses] export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksData = { body: ChildChunkBatchUpdatePayload @@ -2118,8 +2118,8 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChil 200: ChildChunkBatchUpdateResponse } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse - = PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses] +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse = + PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses] export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksData = { body: ChildChunkCreatePayload @@ -2136,11 +2136,11 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChild 200: ChildChunkDetailResponse } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse - = PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses] +export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse = + PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses] -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdData - = { +export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdData = + { body?: never path: { child_chunk_id: string @@ -2152,16 +2152,16 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi url: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses - = { +export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses = + { 204: void } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse - = DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses] +export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse = + DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses] -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdData - = { +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdData = + { body: ChildChunkUpdatePayload path: { child_chunk_id: string @@ -2173,13 +2173,13 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChil url: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses - = { +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses = + { 200: ChildChunkDetailResponse } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse - = PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses] +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse = + PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusData = { body?: never @@ -2199,8 +2199,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponses = 200: DocumentSummaryStatusResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncData = { body?: never @@ -2216,8 +2216,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncResponses = { 200: SimpleResultResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncResponses] export type GetDatasetsByDatasetIdErrorDocsData = { body?: never @@ -2236,8 +2236,8 @@ export type GetDatasetsByDatasetIdErrorDocsResponses = { 200: ErrorDocsResponse } -export type GetDatasetsByDatasetIdErrorDocsResponse - = GetDatasetsByDatasetIdErrorDocsResponses[keyof GetDatasetsByDatasetIdErrorDocsResponses] +export type GetDatasetsByDatasetIdErrorDocsResponse = + GetDatasetsByDatasetIdErrorDocsResponses[keyof GetDatasetsByDatasetIdErrorDocsResponses] export type PostDatasetsByDatasetIdExternalHitTestingData = { body: ExternalHitTestingPayload @@ -2257,8 +2257,8 @@ export type PostDatasetsByDatasetIdExternalHitTestingResponses = { 200: ExternalHitTestingResponse } -export type PostDatasetsByDatasetIdExternalHitTestingResponse - = PostDatasetsByDatasetIdExternalHitTestingResponses[keyof PostDatasetsByDatasetIdExternalHitTestingResponses] +export type PostDatasetsByDatasetIdExternalHitTestingResponse = + PostDatasetsByDatasetIdExternalHitTestingResponses[keyof PostDatasetsByDatasetIdExternalHitTestingResponses] export type PostDatasetsByDatasetIdHitTestingData = { body: HitTestingPayload @@ -2278,8 +2278,8 @@ export type PostDatasetsByDatasetIdHitTestingResponses = { 200: HitTestingResponse } -export type PostDatasetsByDatasetIdHitTestingResponse - = PostDatasetsByDatasetIdHitTestingResponses[keyof PostDatasetsByDatasetIdHitTestingResponses] +export type PostDatasetsByDatasetIdHitTestingResponse = + PostDatasetsByDatasetIdHitTestingResponses[keyof PostDatasetsByDatasetIdHitTestingResponses] export type GetDatasetsByDatasetIdIndexingStatusData = { body?: never @@ -2294,8 +2294,8 @@ export type GetDatasetsByDatasetIdIndexingStatusResponses = { 200: DocumentStatusListResponse } -export type GetDatasetsByDatasetIdIndexingStatusResponse - = GetDatasetsByDatasetIdIndexingStatusResponses[keyof GetDatasetsByDatasetIdIndexingStatusResponses] +export type GetDatasetsByDatasetIdIndexingStatusResponse = + GetDatasetsByDatasetIdIndexingStatusResponses[keyof GetDatasetsByDatasetIdIndexingStatusResponses] export type GetDatasetsByDatasetIdMetadataData = { body?: never @@ -2310,8 +2310,8 @@ export type GetDatasetsByDatasetIdMetadataResponses = { 200: DatasetMetadataListResponse } -export type GetDatasetsByDatasetIdMetadataResponse - = GetDatasetsByDatasetIdMetadataResponses[keyof GetDatasetsByDatasetIdMetadataResponses] +export type GetDatasetsByDatasetIdMetadataResponse = + GetDatasetsByDatasetIdMetadataResponses[keyof GetDatasetsByDatasetIdMetadataResponses] export type PostDatasetsByDatasetIdMetadataData = { body: MetadataArgs @@ -2326,8 +2326,8 @@ export type PostDatasetsByDatasetIdMetadataResponses = { 201: DatasetMetadataResponse } -export type PostDatasetsByDatasetIdMetadataResponse - = PostDatasetsByDatasetIdMetadataResponses[keyof PostDatasetsByDatasetIdMetadataResponses] +export type PostDatasetsByDatasetIdMetadataResponse = + PostDatasetsByDatasetIdMetadataResponses[keyof PostDatasetsByDatasetIdMetadataResponses] export type PostDatasetsByDatasetIdMetadataBuiltInByActionData = { body?: never @@ -2343,8 +2343,8 @@ export type PostDatasetsByDatasetIdMetadataBuiltInByActionResponses = { 204: void } -export type PostDatasetsByDatasetIdMetadataBuiltInByActionResponse - = PostDatasetsByDatasetIdMetadataBuiltInByActionResponses[keyof PostDatasetsByDatasetIdMetadataBuiltInByActionResponses] +export type PostDatasetsByDatasetIdMetadataBuiltInByActionResponse = + PostDatasetsByDatasetIdMetadataBuiltInByActionResponses[keyof PostDatasetsByDatasetIdMetadataBuiltInByActionResponses] export type DeleteDatasetsByDatasetIdMetadataByMetadataIdData = { body?: never @@ -2360,8 +2360,8 @@ export type DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses = { 204: void } -export type DeleteDatasetsByDatasetIdMetadataByMetadataIdResponse - = DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses[keyof DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses] +export type DeleteDatasetsByDatasetIdMetadataByMetadataIdResponse = + DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses[keyof DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses] export type PatchDatasetsByDatasetIdMetadataByMetadataIdData = { body: MetadataUpdatePayload @@ -2377,8 +2377,8 @@ export type PatchDatasetsByDatasetIdMetadataByMetadataIdResponses = { 200: DatasetMetadataResponse } -export type PatchDatasetsByDatasetIdMetadataByMetadataIdResponse - = PatchDatasetsByDatasetIdMetadataByMetadataIdResponses[keyof PatchDatasetsByDatasetIdMetadataByMetadataIdResponses] +export type PatchDatasetsByDatasetIdMetadataByMetadataIdResponse = + PatchDatasetsByDatasetIdMetadataByMetadataIdResponses[keyof PatchDatasetsByDatasetIdMetadataByMetadataIdResponses] export type GetDatasetsByDatasetIdNotionSyncData = { body?: never @@ -2393,8 +2393,8 @@ export type GetDatasetsByDatasetIdNotionSyncResponses = { 200: SimpleResultResponse } -export type GetDatasetsByDatasetIdNotionSyncResponse - = GetDatasetsByDatasetIdNotionSyncResponses[keyof GetDatasetsByDatasetIdNotionSyncResponses] +export type GetDatasetsByDatasetIdNotionSyncResponse = + GetDatasetsByDatasetIdNotionSyncResponses[keyof GetDatasetsByDatasetIdNotionSyncResponses] export type GetDatasetsByDatasetIdPermissionPartUsersData = { body?: never @@ -2414,8 +2414,8 @@ export type GetDatasetsByDatasetIdPermissionPartUsersResponses = { 200: PartialMemberListResponse } -export type GetDatasetsByDatasetIdPermissionPartUsersResponse - = GetDatasetsByDatasetIdPermissionPartUsersResponses[keyof GetDatasetsByDatasetIdPermissionPartUsersResponses] +export type GetDatasetsByDatasetIdPermissionPartUsersResponse = + GetDatasetsByDatasetIdPermissionPartUsersResponses[keyof GetDatasetsByDatasetIdPermissionPartUsersResponses] export type GetDatasetsByDatasetIdQueriesData = { body?: never @@ -2430,8 +2430,8 @@ export type GetDatasetsByDatasetIdQueriesResponses = { 200: DatasetQueryListResponse } -export type GetDatasetsByDatasetIdQueriesResponse - = GetDatasetsByDatasetIdQueriesResponses[keyof GetDatasetsByDatasetIdQueriesResponses] +export type GetDatasetsByDatasetIdQueriesResponse = + GetDatasetsByDatasetIdQueriesResponses[keyof GetDatasetsByDatasetIdQueriesResponses] export type GetDatasetsByDatasetIdRelatedAppsData = { body?: never @@ -2446,8 +2446,8 @@ export type GetDatasetsByDatasetIdRelatedAppsResponses = { 200: RelatedAppListResponse } -export type GetDatasetsByDatasetIdRelatedAppsResponse - = GetDatasetsByDatasetIdRelatedAppsResponses[keyof GetDatasetsByDatasetIdRelatedAppsResponses] +export type GetDatasetsByDatasetIdRelatedAppsResponse = + GetDatasetsByDatasetIdRelatedAppsResponses[keyof GetDatasetsByDatasetIdRelatedAppsResponses] export type PostDatasetsByDatasetIdRetryData = { body: DocumentRetryPayload @@ -2462,8 +2462,8 @@ export type PostDatasetsByDatasetIdRetryResponses = { 204: void } -export type PostDatasetsByDatasetIdRetryResponse - = PostDatasetsByDatasetIdRetryResponses[keyof PostDatasetsByDatasetIdRetryResponses] +export type PostDatasetsByDatasetIdRetryResponse = + PostDatasetsByDatasetIdRetryResponses[keyof PostDatasetsByDatasetIdRetryResponses] export type GetDatasetsByDatasetIdUseCheckData = { body?: never @@ -2478,8 +2478,8 @@ export type GetDatasetsByDatasetIdUseCheckResponses = { 200: UsageCheckResponse } -export type GetDatasetsByDatasetIdUseCheckResponse - = GetDatasetsByDatasetIdUseCheckResponses[keyof GetDatasetsByDatasetIdUseCheckResponses] +export type GetDatasetsByDatasetIdUseCheckResponse = + GetDatasetsByDatasetIdUseCheckResponses[keyof GetDatasetsByDatasetIdUseCheckResponses] export type GetDatasetsByResourceIdApiKeysData = { body?: never @@ -2494,8 +2494,8 @@ export type GetDatasetsByResourceIdApiKeysResponses = { 200: ApiKeyList } -export type GetDatasetsByResourceIdApiKeysResponse - = GetDatasetsByResourceIdApiKeysResponses[keyof GetDatasetsByResourceIdApiKeysResponses] +export type GetDatasetsByResourceIdApiKeysResponse = + GetDatasetsByResourceIdApiKeysResponses[keyof GetDatasetsByResourceIdApiKeysResponses] export type PostDatasetsByResourceIdApiKeysData = { body?: never @@ -2514,8 +2514,8 @@ export type PostDatasetsByResourceIdApiKeysResponses = { 201: ApiKeyItem } -export type PostDatasetsByResourceIdApiKeysResponse - = PostDatasetsByResourceIdApiKeysResponses[keyof PostDatasetsByResourceIdApiKeysResponses] +export type PostDatasetsByResourceIdApiKeysResponse = + PostDatasetsByResourceIdApiKeysResponses[keyof PostDatasetsByResourceIdApiKeysResponses] export type DeleteDatasetsByResourceIdApiKeysByApiKeyIdData = { body?: never @@ -2531,5 +2531,5 @@ export type DeleteDatasetsByResourceIdApiKeysByApiKeyIdResponses = { 204: void } -export type DeleteDatasetsByResourceIdApiKeysByApiKeyIdResponse - = DeleteDatasetsByResourceIdApiKeysByApiKeyIdResponses[keyof DeleteDatasetsByResourceIdApiKeysByApiKeyIdResponses] +export type DeleteDatasetsByResourceIdApiKeysByApiKeyIdResponse = + DeleteDatasetsByResourceIdApiKeysByApiKeyIdResponses[keyof DeleteDatasetsByResourceIdApiKeysByApiKeyIdResponses] diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index 2ae01a8cfe3276..b809480eb51756 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -1549,11 +1549,11 @@ export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath = z.ob /** * External API template retrieved successfully */ -export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse - = zExternalKnowledgeApiResponse +export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse = + zExternalKnowledgeApiResponse -export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdBody - = zExternalKnowledgeApiPayload +export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdBody = + zExternalKnowledgeApiPayload export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath = z.object({ external_knowledge_api_id: z.uuid(), @@ -1562,8 +1562,8 @@ export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath = z. /** * External API template updated successfully */ -export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse - = zExternalKnowledgeApiResponse +export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse = + zExternalKnowledgeApiResponse export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckPath = z.object({ external_knowledge_api_id: z.uuid(), @@ -1572,8 +1572,8 @@ export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckPat /** * Usage check completed successfully */ -export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckResponse - = zUsageCountResponse +export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckResponse = + zUsageCountResponse export const zPostDatasetsIndexingEstimateBody = zIndexingEstimatePayload @@ -1818,8 +1818,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimatePath = /** * Indexing estimate calculated successfully */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponse - = zIndexingEstimateResponse +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponse = + zIndexingEstimateResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusPath = z.object({ dataset_id: z.uuid(), @@ -1829,11 +1829,11 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusPath = z. /** * Indexing status retrieved successfully */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusResponse - = zDocumentStatusResponse +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusResponse = + zDocumentStatusResponse -export const zPutDatasetsByDatasetIdDocumentsByDocumentIdMetadataBody - = zDocumentMetadataUpdatePayload +export const zPutDatasetsByDatasetIdDocumentsByDocumentIdMetadataBody = + zDocumentMetadataUpdatePayload export const zPutDatasetsByDatasetIdDocumentsByDocumentIdMetadataPath = z.object({ dataset_id: z.uuid(), @@ -1843,8 +1843,8 @@ export const zPutDatasetsByDatasetIdDocumentsByDocumentIdMetadataPath = z.object /** * Document metadata updated successfully */ -export const zPutDatasetsByDatasetIdDocumentsByDocumentIdMetadataResponse - = zSimpleResultMessageResponse +export const zPutDatasetsByDatasetIdDocumentsByDocumentIdMetadataResponse = + zSimpleResultMessageResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdNotionSyncPath = z.object({ dataset_id: z.uuid(), @@ -1864,8 +1864,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogPat /** * Pipeline execution log retrieved successfully */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogResponse - = zDocumentPipelineExecutionLogResponse +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogResponse = + zDocumentPipelineExecutionLogResponse export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPausePath = z.object({ dataset_id: z.uuid(), @@ -1896,8 +1896,8 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionPat /** * Processing status updated successfully */ -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionResponse - = zSimpleResultResponse +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionResponse = + zSimpleResultResponse export const zPostDatasetsByDatasetIdDocumentsByDocumentIdRenameBody = zDocumentRenamePayload @@ -1936,8 +1936,8 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionQuery /** * Success */ -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionResponse - = zSimpleResultResponse +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionResponse = + zSimpleResultResponse export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsPath = z.object({ dataset_id: z.uuid(), @@ -1970,8 +1970,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsQuery = z.objec /** * Segments retrieved successfully */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse - = zConsoleSegmentListResponse +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse = + zConsoleSegmentListResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportPath = z.object({ dataset_id: z.uuid(), @@ -1981,11 +1981,11 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportPath /** * Batch import status */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponse - = zSegmentBatchImportStatusResponse +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponse = + zSegmentBatchImportStatusResponse -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportBody - = zBatchImportPayload +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportBody = + zBatchImportPayload export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportPath = z.object({ dataset_id: z.uuid(), @@ -1995,8 +1995,8 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportPat /** * Batch import started */ -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponse - = zSegmentBatchImportStatusResponse +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponse = + zSegmentBatchImportStatusResponse export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath = z.object({ dataset_id: z.uuid(), @@ -2009,8 +2009,8 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdP */ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = z.void() -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdBody - = zSegmentUpdatePayload +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdBody = + zSegmentUpdatePayload export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath = z.object({ dataset_id: z.uuid(), @@ -2021,18 +2021,18 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPa /** * Segment updated successfully */ -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse - = zSegmentDetailResponse +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = + zSegmentDetailResponse -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath - = z.object({ +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath = + z.object({ dataset_id: z.uuid(), document_id: z.uuid(), segment_id: z.uuid(), }) -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksQuery - = z.object({ +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksQuery = + z.object({ keyword: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).optional().default(1), @@ -2041,14 +2041,14 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChil /** * Child chunks retrieved successfully */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse - = zChildChunkListResponse +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse = + zChildChunkListResponse -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksBody - = zChildChunkBatchUpdatePayload +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksBody = + zChildChunkBatchUpdatePayload -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath - = z.object({ +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath = + z.object({ dataset_id: z.uuid(), document_id: z.uuid(), segment_id: z.uuid(), @@ -2057,14 +2057,14 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdCh /** * Child chunks updated successfully */ -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse - = zChildChunkBatchUpdateResponse +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse = + zChildChunkBatchUpdateResponse -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksBody - = zChildChunkCreatePayload +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksBody = + zChildChunkCreatePayload -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath - = z.object({ +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath = + z.object({ dataset_id: z.uuid(), document_id: z.uuid(), segment_id: z.uuid(), @@ -2073,11 +2073,11 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi /** * Child chunk created successfully */ -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse - = zChildChunkDetailResponse +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse = + zChildChunkDetailResponse -export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath - = z.object({ +export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath = + z.object({ child_chunk_id: z.uuid(), dataset_id: z.uuid(), document_id: z.uuid(), @@ -2087,14 +2087,14 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdC /** * Child chunk deleted successfully */ -export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse - = z.void() +export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse = + z.void() -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdBody - = zChildChunkUpdatePayload +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdBody = + zChildChunkUpdatePayload -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath - = z.object({ +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath = + z.object({ child_chunk_id: z.uuid(), dataset_id: z.uuid(), document_id: z.uuid(), @@ -2104,8 +2104,8 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdCh /** * Child chunk updated successfully */ -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse - = zChildChunkDetailResponse +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse = + zChildChunkDetailResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusPath = z.object({ dataset_id: z.uuid(), @@ -2115,8 +2115,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusPath = z.o /** * Summary status retrieved successfully */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponse - = zDocumentSummaryStatusResponse +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponse = + zDocumentSummaryStatusResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncPath = z.object({ dataset_id: z.uuid(), diff --git a/packages/contracts/generated/api/console/email-code-login/orpc.gen.ts b/packages/contracts/generated/api/console/email-code-login/orpc.gen.ts index 54edabc29f91da..0303757aedbb24 100644 --- a/packages/contracts/generated/api/console/email-code-login/orpc.gen.ts +++ b/packages/contracts/generated/api/console/email-code-login/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zPostEmailCodeLoginBody, zPostEmailCodeLoginResponse, diff --git a/packages/contracts/generated/api/console/email-code-login/types.gen.ts b/packages/contracts/generated/api/console/email-code-login/types.gen.ts index 8a41589ef5ca36..a07f11dfd19d33 100644 --- a/packages/contracts/generated/api/console/email-code-login/types.gen.ts +++ b/packages/contracts/generated/api/console/email-code-login/types.gen.ts @@ -37,8 +37,8 @@ export type PostEmailCodeLoginResponses = { 200: SimpleResultDataResponse } -export type PostEmailCodeLoginResponse - = PostEmailCodeLoginResponses[keyof PostEmailCodeLoginResponses] +export type PostEmailCodeLoginResponse = + PostEmailCodeLoginResponses[keyof PostEmailCodeLoginResponses] export type PostEmailCodeLoginValidityData = { body: EmailCodeLoginPayload @@ -51,5 +51,5 @@ export type PostEmailCodeLoginValidityResponses = { 200: SimpleResultResponse } -export type PostEmailCodeLoginValidityResponse - = PostEmailCodeLoginValidityResponses[keyof PostEmailCodeLoginValidityResponses] +export type PostEmailCodeLoginValidityResponse = + PostEmailCodeLoginValidityResponses[keyof PostEmailCodeLoginValidityResponses] diff --git a/packages/contracts/generated/api/console/email-register/orpc.gen.ts b/packages/contracts/generated/api/console/email-register/orpc.gen.ts index 0fcc10b9898d36..0278e0afaf652e 100644 --- a/packages/contracts/generated/api/console/email-register/orpc.gen.ts +++ b/packages/contracts/generated/api/console/email-register/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zPostEmailRegisterBody, zPostEmailRegisterResponse, diff --git a/packages/contracts/generated/api/console/email-register/types.gen.ts b/packages/contracts/generated/api/console/email-register/types.gen.ts index 2a315d2c635b6a..2b508da7f88d3f 100644 --- a/packages/contracts/generated/api/console/email-register/types.gen.ts +++ b/packages/contracts/generated/api/console/email-register/types.gen.ts @@ -69,8 +69,8 @@ export type PostEmailRegisterSendEmailResponses = { 200: SimpleResultDataResponse } -export type PostEmailRegisterSendEmailResponse - = PostEmailRegisterSendEmailResponses[keyof PostEmailRegisterSendEmailResponses] +export type PostEmailRegisterSendEmailResponse = + PostEmailRegisterSendEmailResponses[keyof PostEmailRegisterSendEmailResponses] export type PostEmailRegisterValidityData = { body: EmailRegisterValidityPayload @@ -83,5 +83,5 @@ export type PostEmailRegisterValidityResponses = { 200: VerificationTokenResponse } -export type PostEmailRegisterValidityResponse - = PostEmailRegisterValidityResponses[keyof PostEmailRegisterValidityResponses] +export type PostEmailRegisterValidityResponse = + PostEmailRegisterValidityResponses[keyof PostEmailRegisterValidityResponses] diff --git a/packages/contracts/generated/api/console/explore/orpc.gen.ts b/packages/contracts/generated/api/console/explore/orpc.gen.ts index 23a7ef8bc42a53..79ad08394705dc 100644 --- a/packages/contracts/generated/api/console/explore/orpc.gen.ts +++ b/packages/contracts/generated/api/console/explore/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetExploreAppsByAppIdPath, zGetExploreAppsByAppIdResponse, diff --git a/packages/contracts/generated/api/console/explore/types.gen.ts b/packages/contracts/generated/api/console/explore/types.gen.ts index 509bd65c41ca56..331815aaf5685f 100644 --- a/packages/contracts/generated/api/console/explore/types.gen.ts +++ b/packages/contracts/generated/api/console/explore/types.gen.ts @@ -118,8 +118,8 @@ export type GetExploreAppsLearnDifyResponses = { 200: LearnDifyAppListResponse } -export type GetExploreAppsLearnDifyResponse - = GetExploreAppsLearnDifyResponses[keyof GetExploreAppsLearnDifyResponses] +export type GetExploreAppsLearnDifyResponse = + GetExploreAppsLearnDifyResponses[keyof GetExploreAppsLearnDifyResponses] export type GetExploreAppsByAppIdData = { body?: never @@ -134,8 +134,8 @@ export type GetExploreAppsByAppIdResponses = { 200: RecommendedAppDetailNullableResponse } -export type GetExploreAppsByAppIdResponse - = GetExploreAppsByAppIdResponses[keyof GetExploreAppsByAppIdResponses] +export type GetExploreAppsByAppIdResponse = + GetExploreAppsByAppIdResponses[keyof GetExploreAppsByAppIdResponses] export type GetExploreBannersData = { body?: never diff --git a/packages/contracts/generated/api/console/features/orpc.gen.ts b/packages/contracts/generated/api/console/features/orpc.gen.ts index 3463ccb015e21c..f59b841fcd2d20 100644 --- a/packages/contracts/generated/api/console/features/orpc.gen.ts +++ b/packages/contracts/generated/api/console/features/orpc.gen.ts @@ -1,7 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts import { oc } from '@orpc/contract' - import { zGetFeaturesResponse, zGetFeaturesVectorSpaceResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/features/types.gen.ts b/packages/contracts/generated/api/console/features/types.gen.ts index da4dac47c24831..52c6cf8040298a 100644 --- a/packages/contracts/generated/api/console/features/types.gen.ts +++ b/packages/contracts/generated/api/console/features/types.gen.ts @@ -87,5 +87,5 @@ export type GetFeaturesVectorSpaceResponses = { 200: LimitationModel } -export type GetFeaturesVectorSpaceResponse - = GetFeaturesVectorSpaceResponses[keyof GetFeaturesVectorSpaceResponses] +export type GetFeaturesVectorSpaceResponse = + GetFeaturesVectorSpaceResponses[keyof GetFeaturesVectorSpaceResponses] diff --git a/packages/contracts/generated/api/console/files/orpc.gen.ts b/packages/contracts/generated/api/console/files/orpc.gen.ts index 8a5da7a140ed69..3703a064313028 100644 --- a/packages/contracts/generated/api/console/files/orpc.gen.ts +++ b/packages/contracts/generated/api/console/files/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetFilesByFileIdPreviewPath, zGetFilesByFileIdPreviewResponse, diff --git a/packages/contracts/generated/api/console/files/types.gen.ts b/packages/contracts/generated/api/console/files/types.gen.ts index 3d22ea66236c50..be26853f3bf019 100644 --- a/packages/contracts/generated/api/console/files/types.gen.ts +++ b/packages/contracts/generated/api/console/files/types.gen.ts @@ -54,8 +54,8 @@ export type GetFilesSupportTypeResponses = { 200: AllowedExtensionsResponse } -export type GetFilesSupportTypeResponse - = GetFilesSupportTypeResponses[keyof GetFilesSupportTypeResponses] +export type GetFilesSupportTypeResponse = + GetFilesSupportTypeResponses[keyof GetFilesSupportTypeResponses] export type GetFilesUploadData = { body?: never @@ -99,5 +99,5 @@ export type GetFilesByFileIdPreviewResponses = { 200: TextContentResponse } -export type GetFilesByFileIdPreviewResponse - = GetFilesByFileIdPreviewResponses[keyof GetFilesByFileIdPreviewResponses] +export type GetFilesByFileIdPreviewResponse = + GetFilesByFileIdPreviewResponses[keyof GetFilesByFileIdPreviewResponses] diff --git a/packages/contracts/generated/api/console/forgot-password/orpc.gen.ts b/packages/contracts/generated/api/console/forgot-password/orpc.gen.ts index a5a33f407b6328..07e293ebd1bb0d 100644 --- a/packages/contracts/generated/api/console/forgot-password/orpc.gen.ts +++ b/packages/contracts/generated/api/console/forgot-password/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zPostForgotPasswordBody, zPostForgotPasswordResetsBody, diff --git a/packages/contracts/generated/api/console/forgot-password/types.gen.ts b/packages/contracts/generated/api/console/forgot-password/types.gen.ts index c551d799e5e641..1311a6772b4050 100644 --- a/packages/contracts/generated/api/console/forgot-password/types.gen.ts +++ b/packages/contracts/generated/api/console/forgot-password/types.gen.ts @@ -52,8 +52,8 @@ export type PostForgotPasswordResponses = { 200: ForgotPasswordEmailResponse } -export type PostForgotPasswordResponse - = PostForgotPasswordResponses[keyof PostForgotPasswordResponses] +export type PostForgotPasswordResponse = + PostForgotPasswordResponses[keyof PostForgotPasswordResponses] export type PostForgotPasswordResetsData = { body: ForgotPasswordResetPayload @@ -70,8 +70,8 @@ export type PostForgotPasswordResetsResponses = { 200: ForgotPasswordResetResponse } -export type PostForgotPasswordResetsResponse - = PostForgotPasswordResetsResponses[keyof PostForgotPasswordResetsResponses] +export type PostForgotPasswordResetsResponse = + PostForgotPasswordResetsResponses[keyof PostForgotPasswordResetsResponses] export type PostForgotPasswordValidityData = { body: ForgotPasswordCheckPayload @@ -88,5 +88,5 @@ export type PostForgotPasswordValidityResponses = { 200: ForgotPasswordCheckResponse } -export type PostForgotPasswordValidityResponse - = PostForgotPasswordValidityResponses[keyof PostForgotPasswordValidityResponses] +export type PostForgotPasswordValidityResponse = + PostForgotPasswordValidityResponses[keyof PostForgotPasswordValidityResponses] diff --git a/packages/contracts/generated/api/console/form/orpc.gen.ts b/packages/contracts/generated/api/console/form/orpc.gen.ts index 5cb831538f8eea..070c360c29d7d7 100644 --- a/packages/contracts/generated/api/console/form/orpc.gen.ts +++ b/packages/contracts/generated/api/console/form/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetFormHumanInputByFormTokenPath, zGetFormHumanInputByFormTokenResponse, diff --git a/packages/contracts/generated/api/console/form/types.gen.ts b/packages/contracts/generated/api/console/form/types.gen.ts index 9565a18219cb56..9a28bfd47dab23 100644 --- a/packages/contracts/generated/api/console/form/types.gen.ts +++ b/packages/contracts/generated/api/console/form/types.gen.ts @@ -35,8 +35,8 @@ export type GetFormHumanInputByFormTokenResponses = { 200: ConsoleHumanInputFormDefinitionResponse } -export type GetFormHumanInputByFormTokenResponse - = GetFormHumanInputByFormTokenResponses[keyof GetFormHumanInputByFormTokenResponses] +export type GetFormHumanInputByFormTokenResponse = + GetFormHumanInputByFormTokenResponses[keyof GetFormHumanInputByFormTokenResponses] export type PostFormHumanInputByFormTokenData = { body: HumanInputFormSubmitPayload @@ -51,5 +51,5 @@ export type PostFormHumanInputByFormTokenResponses = { 200: ConsoleHumanInputFormSubmitResponse } -export type PostFormHumanInputByFormTokenResponse - = PostFormHumanInputByFormTokenResponses[keyof PostFormHumanInputByFormTokenResponses] +export type PostFormHumanInputByFormTokenResponse = + PostFormHumanInputByFormTokenResponses[keyof PostFormHumanInputByFormTokenResponses] diff --git a/packages/contracts/generated/api/console/info/orpc.gen.ts b/packages/contracts/generated/api/console/info/orpc.gen.ts index 4eb342e9cf7bd3..2563a61ce4e6f7 100644 --- a/packages/contracts/generated/api/console/info/orpc.gen.ts +++ b/packages/contracts/generated/api/console/info/orpc.gen.ts @@ -1,7 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts import { oc } from '@orpc/contract' - import { zPostInfoResponse } from './zod.gen' export const post = oc diff --git a/packages/contracts/generated/api/console/init/orpc.gen.ts b/packages/contracts/generated/api/console/init/orpc.gen.ts index 1b92ca143ef2ef..eef7c8522c6a2f 100644 --- a/packages/contracts/generated/api/console/init/orpc.gen.ts +++ b/packages/contracts/generated/api/console/init/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetInitResponse, zPostInitBody, zPostInitResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts b/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts index 026f3bdf51372b..8179e0738ba25f 100644 --- a/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteInstalledAppsByInstalledAppIdConversationsByCIdPath, zDeleteInstalledAppsByInstalledAppIdConversationsByCIdResponse, diff --git a/packages/contracts/generated/api/console/installed-apps/types.gen.ts b/packages/contracts/generated/api/console/installed-apps/types.gen.ts index 6cba91f3987a75..4abc67defa35ec 100644 --- a/packages/contracts/generated/api/console/installed-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/types.gen.ts @@ -69,13 +69,13 @@ export type ConversationInfiniteScrollPagination = { export type ConversationRenamePayload = ( | { - auto_generate: true - name?: string | null - } + auto_generate: true + name?: string | null + } | { - auto_generate?: false - name: string - } + auto_generate?: false + name: string + } ) & { auto_generate?: boolean name?: string | null @@ -118,8 +118,8 @@ export type ExploreAppMetaResponse = { [key: string]: | string | { - [key: string]: unknown - } + [key: string]: unknown + } } } @@ -179,16 +179,16 @@ export type InstalledAppResponse = { uninstallable: boolean } -export type JsonValue - = | string - | number - | number - | boolean - | { +export type JsonValue = + | string + | number + | number + | boolean + | { [key: string]: unknown } - | Array<unknown> - | null + | Array<unknown> + | null export type ExploreMessageListItem = { agent_thoughts: Array<AgentThought> @@ -347,19 +347,19 @@ export type UserActionConfig = { title: string } -export type FormInputConfig - = | ({ - type: 'paragraph' - } & ParagraphInputConfig) +export type FormInputConfig = + | ({ + type: 'paragraph' + } & ParagraphInputConfig) | ({ - type: 'select' - } & SelectInputConfig) + type: 'select' + } & SelectInputConfig) | ({ - type: 'file' - } & FileInputConfig) + type: 'file' + } & FileInputConfig) | ({ - type: 'file-list' - } & FileListInputConfig) + type: 'file-list' + } & FileListInputConfig) export type JsonValue2 = unknown @@ -509,8 +509,8 @@ export type DeleteInstalledAppsByInstalledAppIdResponses = { 204: void } -export type DeleteInstalledAppsByInstalledAppIdResponse - = DeleteInstalledAppsByInstalledAppIdResponses[keyof DeleteInstalledAppsByInstalledAppIdResponses] +export type DeleteInstalledAppsByInstalledAppIdResponse = + DeleteInstalledAppsByInstalledAppIdResponses[keyof DeleteInstalledAppsByInstalledAppIdResponses] export type PatchInstalledAppsByInstalledAppIdData = { body: InstalledAppUpdatePayload @@ -525,8 +525,8 @@ export type PatchInstalledAppsByInstalledAppIdResponses = { 200: SimpleResultMessageResponse } -export type PatchInstalledAppsByInstalledAppIdResponse - = PatchInstalledAppsByInstalledAppIdResponses[keyof PatchInstalledAppsByInstalledAppIdResponses] +export type PatchInstalledAppsByInstalledAppIdResponse = + PatchInstalledAppsByInstalledAppIdResponses[keyof PatchInstalledAppsByInstalledAppIdResponses] export type PostInstalledAppsByInstalledAppIdAudioToTextData = { body?: never @@ -541,8 +541,8 @@ export type PostInstalledAppsByInstalledAppIdAudioToTextResponses = { 200: AudioTranscriptResponse } -export type PostInstalledAppsByInstalledAppIdAudioToTextResponse - = PostInstalledAppsByInstalledAppIdAudioToTextResponses[keyof PostInstalledAppsByInstalledAppIdAudioToTextResponses] +export type PostInstalledAppsByInstalledAppIdAudioToTextResponse = + PostInstalledAppsByInstalledAppIdAudioToTextResponses[keyof PostInstalledAppsByInstalledAppIdAudioToTextResponses] export type PostInstalledAppsByInstalledAppIdChatMessagesData = { body: ChatMessagePayload @@ -559,8 +559,8 @@ export type PostInstalledAppsByInstalledAppIdChatMessagesResponses = { } } -export type PostInstalledAppsByInstalledAppIdChatMessagesResponse - = PostInstalledAppsByInstalledAppIdChatMessagesResponses[keyof PostInstalledAppsByInstalledAppIdChatMessagesResponses] +export type PostInstalledAppsByInstalledAppIdChatMessagesResponse = + PostInstalledAppsByInstalledAppIdChatMessagesResponses[keyof PostInstalledAppsByInstalledAppIdChatMessagesResponses] export type PostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopData = { body?: never @@ -576,8 +576,8 @@ export type PostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopResponses = 200: SimpleResultResponse } -export type PostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopResponse - = PostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopResponses[keyof PostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopResponses] +export type PostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopResponse = + PostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopResponses[keyof PostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopResponses] export type PostInstalledAppsByInstalledAppIdCompletionMessagesData = { body: CompletionMessageExplorePayload @@ -594,8 +594,8 @@ export type PostInstalledAppsByInstalledAppIdCompletionMessagesResponses = { } } -export type PostInstalledAppsByInstalledAppIdCompletionMessagesResponse - = PostInstalledAppsByInstalledAppIdCompletionMessagesResponses[keyof PostInstalledAppsByInstalledAppIdCompletionMessagesResponses] +export type PostInstalledAppsByInstalledAppIdCompletionMessagesResponse = + PostInstalledAppsByInstalledAppIdCompletionMessagesResponses[keyof PostInstalledAppsByInstalledAppIdCompletionMessagesResponses] export type PostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopData = { body?: never @@ -611,8 +611,8 @@ export type PostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopRespo 200: SimpleResultResponse } -export type PostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopResponse - = PostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopResponses[keyof PostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopResponses] +export type PostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopResponse = + PostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopResponses[keyof PostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopResponses] export type GetInstalledAppsByInstalledAppIdConversationsData = { body?: never @@ -631,8 +631,8 @@ export type GetInstalledAppsByInstalledAppIdConversationsResponses = { 200: ConversationInfiniteScrollPagination } -export type GetInstalledAppsByInstalledAppIdConversationsResponse - = GetInstalledAppsByInstalledAppIdConversationsResponses[keyof GetInstalledAppsByInstalledAppIdConversationsResponses] +export type GetInstalledAppsByInstalledAppIdConversationsResponse = + GetInstalledAppsByInstalledAppIdConversationsResponses[keyof GetInstalledAppsByInstalledAppIdConversationsResponses] export type DeleteInstalledAppsByInstalledAppIdConversationsByCIdData = { body?: never @@ -648,8 +648,8 @@ export type DeleteInstalledAppsByInstalledAppIdConversationsByCIdResponses = { 204: void } -export type DeleteInstalledAppsByInstalledAppIdConversationsByCIdResponse - = DeleteInstalledAppsByInstalledAppIdConversationsByCIdResponses[keyof DeleteInstalledAppsByInstalledAppIdConversationsByCIdResponses] +export type DeleteInstalledAppsByInstalledAppIdConversationsByCIdResponse = + DeleteInstalledAppsByInstalledAppIdConversationsByCIdResponses[keyof DeleteInstalledAppsByInstalledAppIdConversationsByCIdResponses] export type PostInstalledAppsByInstalledAppIdConversationsByCIdNameData = { body: ConversationRenamePayload @@ -665,8 +665,8 @@ export type PostInstalledAppsByInstalledAppIdConversationsByCIdNameResponses = { 200: SimpleConversation } -export type PostInstalledAppsByInstalledAppIdConversationsByCIdNameResponse - = PostInstalledAppsByInstalledAppIdConversationsByCIdNameResponses[keyof PostInstalledAppsByInstalledAppIdConversationsByCIdNameResponses] +export type PostInstalledAppsByInstalledAppIdConversationsByCIdNameResponse = + PostInstalledAppsByInstalledAppIdConversationsByCIdNameResponses[keyof PostInstalledAppsByInstalledAppIdConversationsByCIdNameResponses] export type PatchInstalledAppsByInstalledAppIdConversationsByCIdPinData = { body?: never @@ -682,8 +682,8 @@ export type PatchInstalledAppsByInstalledAppIdConversationsByCIdPinResponses = { 200: ResultResponse } -export type PatchInstalledAppsByInstalledAppIdConversationsByCIdPinResponse - = PatchInstalledAppsByInstalledAppIdConversationsByCIdPinResponses[keyof PatchInstalledAppsByInstalledAppIdConversationsByCIdPinResponses] +export type PatchInstalledAppsByInstalledAppIdConversationsByCIdPinResponse = + PatchInstalledAppsByInstalledAppIdConversationsByCIdPinResponses[keyof PatchInstalledAppsByInstalledAppIdConversationsByCIdPinResponses] export type PatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinData = { body?: never @@ -699,8 +699,8 @@ export type PatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinResponses = 200: ResultResponse } -export type PatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinResponse - = PatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinResponses[keyof PatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinResponses] +export type PatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinResponse = + PatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinResponses[keyof PatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinResponses] export type GetInstalledAppsByInstalledAppIdMessagesData = { body?: never @@ -719,8 +719,8 @@ export type GetInstalledAppsByInstalledAppIdMessagesResponses = { 200: ExploreMessageInfiniteScrollPagination } -export type GetInstalledAppsByInstalledAppIdMessagesResponse - = GetInstalledAppsByInstalledAppIdMessagesResponses[keyof GetInstalledAppsByInstalledAppIdMessagesResponses] +export type GetInstalledAppsByInstalledAppIdMessagesResponse = + GetInstalledAppsByInstalledAppIdMessagesResponses[keyof GetInstalledAppsByInstalledAppIdMessagesResponses] export type PostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksData = { body: MessageFeedbackPayload @@ -736,8 +736,8 @@ export type PostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksRespons 200: ResultResponse } -export type PostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksResponse - = PostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksResponses[keyof PostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksResponses] +export type PostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksResponse = + PostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksResponses[keyof PostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksResponses] export type GetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisData = { body?: never @@ -757,8 +757,8 @@ export type GetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisRespo } } -export type GetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisResponse - = GetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisResponses[keyof GetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisResponses] +export type GetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisResponse = + GetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisResponses[keyof GetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisResponses] export type GetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestionsData = { body?: never @@ -774,8 +774,8 @@ export type GetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestion 200: SuggestedQuestionsResponse } -export type GetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestionsResponse - = GetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestionsResponses[keyof GetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestionsResponses] +export type GetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestionsResponse = + GetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestionsResponses[keyof GetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestionsResponses] export type GetInstalledAppsByInstalledAppIdMetaData = { body?: never @@ -790,8 +790,8 @@ export type GetInstalledAppsByInstalledAppIdMetaResponses = { 200: ExploreAppMetaResponse } -export type GetInstalledAppsByInstalledAppIdMetaResponse - = GetInstalledAppsByInstalledAppIdMetaResponses[keyof GetInstalledAppsByInstalledAppIdMetaResponses] +export type GetInstalledAppsByInstalledAppIdMetaResponse = + GetInstalledAppsByInstalledAppIdMetaResponses[keyof GetInstalledAppsByInstalledAppIdMetaResponses] export type GetInstalledAppsByInstalledAppIdParametersData = { body?: never @@ -806,8 +806,8 @@ export type GetInstalledAppsByInstalledAppIdParametersResponses = { 200: Parameters } -export type GetInstalledAppsByInstalledAppIdParametersResponse - = GetInstalledAppsByInstalledAppIdParametersResponses[keyof GetInstalledAppsByInstalledAppIdParametersResponses] +export type GetInstalledAppsByInstalledAppIdParametersResponse = + GetInstalledAppsByInstalledAppIdParametersResponses[keyof GetInstalledAppsByInstalledAppIdParametersResponses] export type GetInstalledAppsByInstalledAppIdSavedMessagesData = { body?: never @@ -825,8 +825,8 @@ export type GetInstalledAppsByInstalledAppIdSavedMessagesResponses = { 200: SavedMessageInfiniteScrollPagination } -export type GetInstalledAppsByInstalledAppIdSavedMessagesResponse - = GetInstalledAppsByInstalledAppIdSavedMessagesResponses[keyof GetInstalledAppsByInstalledAppIdSavedMessagesResponses] +export type GetInstalledAppsByInstalledAppIdSavedMessagesResponse = + GetInstalledAppsByInstalledAppIdSavedMessagesResponses[keyof GetInstalledAppsByInstalledAppIdSavedMessagesResponses] export type PostInstalledAppsByInstalledAppIdSavedMessagesData = { body: SavedMessageCreatePayload @@ -841,8 +841,8 @@ export type PostInstalledAppsByInstalledAppIdSavedMessagesResponses = { 200: ResultResponse } -export type PostInstalledAppsByInstalledAppIdSavedMessagesResponse - = PostInstalledAppsByInstalledAppIdSavedMessagesResponses[keyof PostInstalledAppsByInstalledAppIdSavedMessagesResponses] +export type PostInstalledAppsByInstalledAppIdSavedMessagesResponse = + PostInstalledAppsByInstalledAppIdSavedMessagesResponses[keyof PostInstalledAppsByInstalledAppIdSavedMessagesResponses] export type DeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdData = { body?: never @@ -858,8 +858,8 @@ export type DeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdResponses 204: void } -export type DeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdResponse - = DeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdResponses[keyof DeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdResponses] +export type DeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdResponse = + DeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdResponses[keyof DeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdResponses] export type PostInstalledAppsByInstalledAppIdTextToAudioData = { body: TextToAudioPayload @@ -874,8 +874,8 @@ export type PostInstalledAppsByInstalledAppIdTextToAudioResponses = { 200: AudioBinaryResponse } -export type PostInstalledAppsByInstalledAppIdTextToAudioResponse - = PostInstalledAppsByInstalledAppIdTextToAudioResponses[keyof PostInstalledAppsByInstalledAppIdTextToAudioResponses] +export type PostInstalledAppsByInstalledAppIdTextToAudioResponse = + PostInstalledAppsByInstalledAppIdTextToAudioResponses[keyof PostInstalledAppsByInstalledAppIdTextToAudioResponses] export type PostInstalledAppsByInstalledAppIdWorkflowsRunData = { body: WorkflowRunPayload @@ -892,8 +892,8 @@ export type PostInstalledAppsByInstalledAppIdWorkflowsRunResponses = { } } -export type PostInstalledAppsByInstalledAppIdWorkflowsRunResponse - = PostInstalledAppsByInstalledAppIdWorkflowsRunResponses[keyof PostInstalledAppsByInstalledAppIdWorkflowsRunResponses] +export type PostInstalledAppsByInstalledAppIdWorkflowsRunResponse = + PostInstalledAppsByInstalledAppIdWorkflowsRunResponses[keyof PostInstalledAppsByInstalledAppIdWorkflowsRunResponses] export type PostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopData = { body?: never @@ -909,5 +909,5 @@ export type PostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopResponses 200: SimpleResultResponse } -export type PostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopResponse - = PostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopResponses[keyof PostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopResponses] +export type PostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopResponse = + PostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopResponses[keyof PostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopResponses] diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index 2a1d856867bdad..029d4169f29df0 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -684,11 +684,11 @@ export const zPostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopPath = z. /** * Success */ -export const zPostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopResponse - = zSimpleResultResponse +export const zPostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopResponse = + zSimpleResultResponse -export const zPostInstalledAppsByInstalledAppIdCompletionMessagesBody - = zCompletionMessageExplorePayload +export const zPostInstalledAppsByInstalledAppIdCompletionMessagesBody = + zCompletionMessageExplorePayload export const zPostInstalledAppsByInstalledAppIdCompletionMessagesPath = z.object({ installed_app_id: z.uuid(), @@ -710,8 +710,8 @@ export const zPostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopPat /** * Success */ -export const zPostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopResponse - = zSimpleResultResponse +export const zPostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopResponse = + zSimpleResultResponse export const zGetInstalledAppsByInstalledAppIdConversationsPath = z.object({ installed_app_id: z.uuid(), @@ -726,8 +726,8 @@ export const zGetInstalledAppsByInstalledAppIdConversationsQuery = z.object({ /** * Success */ -export const zGetInstalledAppsByInstalledAppIdConversationsResponse - = zConversationInfiniteScrollPagination +export const zGetInstalledAppsByInstalledAppIdConversationsResponse = + zConversationInfiniteScrollPagination export const zDeleteInstalledAppsByInstalledAppIdConversationsByCIdPath = z.object({ c_id: z.uuid(), @@ -739,8 +739,8 @@ export const zDeleteInstalledAppsByInstalledAppIdConversationsByCIdPath = z.obje */ export const zDeleteInstalledAppsByInstalledAppIdConversationsByCIdResponse = z.void() -export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNameBody - = zConversationRenamePayload +export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNameBody = + zConversationRenamePayload export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNamePath = z.object({ c_id: z.uuid(), @@ -785,11 +785,11 @@ export const zGetInstalledAppsByInstalledAppIdMessagesQuery = z.object({ /** * Success */ -export const zGetInstalledAppsByInstalledAppIdMessagesResponse - = zExploreMessageInfiniteScrollPagination +export const zGetInstalledAppsByInstalledAppIdMessagesResponse = + zExploreMessageInfiniteScrollPagination -export const zPostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksBody - = zMessageFeedbackPayload +export const zPostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksBody = + zMessageFeedbackPayload export const zPostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksPath = z.object({ installed_app_id: z.uuid(), @@ -799,8 +799,8 @@ export const zPostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksPath /** * Feedback submitted successfully */ -export const zPostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksResponse - = zResultResponse +export const zPostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksResponse = + zResultResponse export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisPath = z.object({ installed_app_id: z.uuid(), @@ -827,8 +827,8 @@ export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuesti /** * Success */ -export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestionsResponse - = zSuggestedQuestionsResponse +export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestionsResponse = + zSuggestedQuestionsResponse export const zGetInstalledAppsByInstalledAppIdMetaPath = z.object({ installed_app_id: z.uuid(), @@ -860,8 +860,8 @@ export const zGetInstalledAppsByInstalledAppIdSavedMessagesQuery = z.object({ /** * Success */ -export const zGetInstalledAppsByInstalledAppIdSavedMessagesResponse - = zSavedMessageInfiniteScrollPagination +export const zGetInstalledAppsByInstalledAppIdSavedMessagesResponse = + zSavedMessageInfiniteScrollPagination export const zPostInstalledAppsByInstalledAppIdSavedMessagesBody = zSavedMessageCreatePayload @@ -917,5 +917,5 @@ export const zPostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopPath = /** * Success */ -export const zPostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopResponse - = zSimpleResultResponse +export const zPostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopResponse = + zSimpleResultResponse diff --git a/packages/contracts/generated/api/console/instruction-generate/orpc.gen.ts b/packages/contracts/generated/api/console/instruction-generate/orpc.gen.ts index 3aff6a9a3b6fec..a67d8651dfa2cf 100644 --- a/packages/contracts/generated/api/console/instruction-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/instruction-generate/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zPostInstructionGenerateBody, zPostInstructionGenerateResponse, diff --git a/packages/contracts/generated/api/console/instruction-generate/types.gen.ts b/packages/contracts/generated/api/console/instruction-generate/types.gen.ts index 82a9bee0864674..af334fbdaa382a 100644 --- a/packages/contracts/generated/api/console/instruction-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/instruction-generate/types.gen.ts @@ -51,8 +51,8 @@ export type PostInstructionGenerateResponses = { 200: GeneratorResponse } -export type PostInstructionGenerateResponse - = PostInstructionGenerateResponses[keyof PostInstructionGenerateResponses] +export type PostInstructionGenerateResponse = + PostInstructionGenerateResponses[keyof PostInstructionGenerateResponses] export type PostInstructionGenerateTemplateData = { body: InstructionTemplatePayload @@ -69,5 +69,5 @@ export type PostInstructionGenerateTemplateResponses = { 200: SimpleDataResponse } -export type PostInstructionGenerateTemplateResponse - = PostInstructionGenerateTemplateResponses[keyof PostInstructionGenerateTemplateResponses] +export type PostInstructionGenerateTemplateResponse = + PostInstructionGenerateTemplateResponses[keyof PostInstructionGenerateTemplateResponses] diff --git a/packages/contracts/generated/api/console/login/orpc.gen.ts b/packages/contracts/generated/api/console/login/orpc.gen.ts index b8e647a11d87b2..b853c7ff1c6dbc 100644 --- a/packages/contracts/generated/api/console/login/orpc.gen.ts +++ b/packages/contracts/generated/api/console/login/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zPostLoginBody, zPostLoginResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/logout/orpc.gen.ts b/packages/contracts/generated/api/console/logout/orpc.gen.ts index 02ecd2c82d20ca..59c55e3afdb092 100644 --- a/packages/contracts/generated/api/console/logout/orpc.gen.ts +++ b/packages/contracts/generated/api/console/logout/orpc.gen.ts @@ -1,7 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts import { oc } from '@orpc/contract' - import { zPostLogoutResponse } from './zod.gen' export const post = oc diff --git a/packages/contracts/generated/api/console/notification/orpc.gen.ts b/packages/contracts/generated/api/console/notification/orpc.gen.ts index 6fcfa31376f94d..9e01e1a62e0396 100644 --- a/packages/contracts/generated/api/console/notification/orpc.gen.ts +++ b/packages/contracts/generated/api/console/notification/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetNotificationResponse, zPostNotificationDismissBody, diff --git a/packages/contracts/generated/api/console/notification/types.gen.ts b/packages/contracts/generated/api/console/notification/types.gen.ts index 07376b7a66c712..26469735f21df4 100644 --- a/packages/contracts/generated/api/console/notification/types.gen.ts +++ b/packages/contracts/generated/api/console/notification/types.gen.ts @@ -59,5 +59,5 @@ export type PostNotificationDismissResponses = { 200: SimpleResultResponse } -export type PostNotificationDismissResponse - = PostNotificationDismissResponses[keyof PostNotificationDismissResponses] +export type PostNotificationDismissResponse = + PostNotificationDismissResponses[keyof PostNotificationDismissResponses] diff --git a/packages/contracts/generated/api/console/notion/orpc.gen.ts b/packages/contracts/generated/api/console/notion/orpc.gen.ts index 1ab71ffc812203..973f7d54632d4a 100644 --- a/packages/contracts/generated/api/console/notion/orpc.gen.ts +++ b/packages/contracts/generated/api/console/notion/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetNotionPagesByPageIdByPageTypePreviewPath, zGetNotionPagesByPageIdByPageTypePreviewQuery, diff --git a/packages/contracts/generated/api/console/notion/types.gen.ts b/packages/contracts/generated/api/console/notion/types.gen.ts index 92b26116c9ea1d..43545a9721d3d1 100644 --- a/packages/contracts/generated/api/console/notion/types.gen.ts +++ b/packages/contracts/generated/api/console/notion/types.gen.ts @@ -50,8 +50,8 @@ export type GetNotionPagesByPageIdByPageTypePreviewResponses = { 200: TextContentResponse } -export type GetNotionPagesByPageIdByPageTypePreviewResponse - = GetNotionPagesByPageIdByPageTypePreviewResponses[keyof GetNotionPagesByPageIdByPageTypePreviewResponses] +export type GetNotionPagesByPageIdByPageTypePreviewResponse = + GetNotionPagesByPageIdByPageTypePreviewResponses[keyof GetNotionPagesByPageIdByPageTypePreviewResponses] export type GetNotionPreImportPagesData = { body?: never @@ -67,5 +67,5 @@ export type GetNotionPreImportPagesResponses = { 200: NotionIntegrateInfoListResponse } -export type GetNotionPreImportPagesResponse - = GetNotionPreImportPagesResponses[keyof GetNotionPreImportPagesResponses] +export type GetNotionPreImportPagesResponse = + GetNotionPreImportPagesResponses[keyof GetNotionPreImportPagesResponses] diff --git a/packages/contracts/generated/api/console/oauth/orpc.gen.ts b/packages/contracts/generated/api/console/oauth/orpc.gen.ts index ab1bd8552b9374..31feb8abd4dd94 100644 --- a/packages/contracts/generated/api/console/oauth/orpc.gen.ts +++ b/packages/contracts/generated/api/console/oauth/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetOauthDataSourceBindingByProviderPath, zGetOauthDataSourceBindingByProviderQuery, diff --git a/packages/contracts/generated/api/console/oauth/types.gen.ts b/packages/contracts/generated/api/console/oauth/types.gen.ts index b6d27affa3d840..9c24b8b03bf286 100644 --- a/packages/contracts/generated/api/console/oauth/types.gen.ts +++ b/packages/contracts/generated/api/console/oauth/types.gen.ts @@ -84,8 +84,8 @@ export type GetOauthDataSourceBindingByProviderResponses = { 200: OAuthDataSourceBindingResponse } -export type GetOauthDataSourceBindingByProviderResponse - = GetOauthDataSourceBindingByProviderResponses[keyof GetOauthDataSourceBindingByProviderResponses] +export type GetOauthDataSourceBindingByProviderResponse = + GetOauthDataSourceBindingByProviderResponses[keyof GetOauthDataSourceBindingByProviderResponses] export type GetOauthDataSourceByProviderData = { body?: never @@ -105,8 +105,8 @@ export type GetOauthDataSourceByProviderResponses = { 200: OAuthDataSourceResponse } -export type GetOauthDataSourceByProviderResponse - = GetOauthDataSourceByProviderResponses[keyof GetOauthDataSourceByProviderResponses] +export type GetOauthDataSourceByProviderResponse = + GetOauthDataSourceByProviderResponses[keyof GetOauthDataSourceByProviderResponses] export type GetOauthDataSourceByProviderByBindingIdSyncData = { body?: never @@ -126,8 +126,8 @@ export type GetOauthDataSourceByProviderByBindingIdSyncResponses = { 200: OAuthDataSourceSyncResponse } -export type GetOauthDataSourceByProviderByBindingIdSyncResponse - = GetOauthDataSourceByProviderByBindingIdSyncResponses[keyof GetOauthDataSourceByProviderByBindingIdSyncResponses] +export type GetOauthDataSourceByProviderByBindingIdSyncResponse = + GetOauthDataSourceByProviderByBindingIdSyncResponses[keyof GetOauthDataSourceByProviderByBindingIdSyncResponses] export type GetOauthPluginByProviderIdDatasourceGetAuthorizationUrlData = { body?: never @@ -144,8 +144,8 @@ export type GetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponses = { 200: PluginOAuthAuthorizationUrlResponse } -export type GetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponse - = GetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponses[keyof GetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponses] +export type GetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponse = + GetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponses[keyof GetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponses] export type GetOauthPluginByProviderToolAuthorizationUrlData = { body?: never @@ -160,8 +160,8 @@ export type GetOauthPluginByProviderToolAuthorizationUrlResponses = { 200: PluginOAuthAuthorizationUrlResponse } -export type GetOauthPluginByProviderToolAuthorizationUrlResponse - = GetOauthPluginByProviderToolAuthorizationUrlResponses[keyof GetOauthPluginByProviderToolAuthorizationUrlResponses] +export type GetOauthPluginByProviderToolAuthorizationUrlResponse = + GetOauthPluginByProviderToolAuthorizationUrlResponses[keyof GetOauthPluginByProviderToolAuthorizationUrlResponses] export type PostOauthProviderData = { body: OAuthProviderRequest @@ -187,8 +187,8 @@ export type PostOauthProviderAccountResponses = { 200: OAuthProviderAccountResponse } -export type PostOauthProviderAccountResponse - = PostOauthProviderAccountResponses[keyof PostOauthProviderAccountResponses] +export type PostOauthProviderAccountResponse = + PostOauthProviderAccountResponses[keyof PostOauthProviderAccountResponses] export type PostOauthProviderAuthorizeData = { body: OAuthClientPayload @@ -201,8 +201,8 @@ export type PostOauthProviderAuthorizeResponses = { 200: OAuthProviderAuthorizeResponse } -export type PostOauthProviderAuthorizeResponse - = PostOauthProviderAuthorizeResponses[keyof PostOauthProviderAuthorizeResponses] +export type PostOauthProviderAuthorizeResponse = + PostOauthProviderAuthorizeResponses[keyof PostOauthProviderAuthorizeResponses] export type PostOauthProviderTokenData = { body: OAuthTokenRequest @@ -215,5 +215,5 @@ export type PostOauthProviderTokenResponses = { 200: OAuthProviderTokenResponse } -export type PostOauthProviderTokenResponse - = PostOauthProviderTokenResponses[keyof PostOauthProviderTokenResponses] +export type PostOauthProviderTokenResponse = + PostOauthProviderTokenResponses[keyof PostOauthProviderTokenResponses] diff --git a/packages/contracts/generated/api/console/oauth/zod.gen.ts b/packages/contracts/generated/api/console/oauth/zod.gen.ts index 8116d05db2bc93..726993ebf91e95 100644 --- a/packages/contracts/generated/api/console/oauth/zod.gen.ts +++ b/packages/contracts/generated/api/console/oauth/zod.gen.ts @@ -137,8 +137,8 @@ export const zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlQuery = z.o /** * Datasource OAuth authorization URL generated successfully */ -export const zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponse - = zPluginOAuthAuthorizationUrlResponse +export const zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponse = + zPluginOAuthAuthorizationUrlResponse export const zGetOauthPluginByProviderToolAuthorizationUrlPath = z.object({ provider: z.string(), @@ -147,8 +147,8 @@ export const zGetOauthPluginByProviderToolAuthorizationUrlPath = z.object({ /** * Tool OAuth authorization URL generated successfully */ -export const zGetOauthPluginByProviderToolAuthorizationUrlResponse - = zPluginOAuthAuthorizationUrlResponse +export const zGetOauthPluginByProviderToolAuthorizationUrlResponse = + zPluginOAuthAuthorizationUrlResponse export const zPostOauthProviderBody = zOAuthProviderRequest diff --git a/packages/contracts/generated/api/console/ping/orpc.gen.ts b/packages/contracts/generated/api/console/ping/orpc.gen.ts index 9db8ecec5fb816..b931e3bfb275a0 100644 --- a/packages/contracts/generated/api/console/ping/orpc.gen.ts +++ b/packages/contracts/generated/api/console/ping/orpc.gen.ts @@ -1,7 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts import { oc } from '@orpc/contract' - import { zGetPingResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/rag/orpc.gen.ts b/packages/contracts/generated/api/console/rag/orpc.gen.ts index ea52e39d5f6f12..848cfd1d15b0b0 100644 --- a/packages/contracts/generated/api/console/rag/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rag/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteRagPipelineCustomizedTemplatesByTemplateIdPath, zDeleteRagPipelineCustomizedTemplatesByTemplateIdResponse, @@ -989,7 +988,7 @@ export const get20 = oc method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraft', path: '/rag/pipelines/{pipeline_id}/workflows/draft', - summary: 'Get draft rag pipeline\'s workflow', + summary: "Get draft rag pipeline's workflow", tags: ['console'], }) .input(z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsDraftPath })) diff --git a/packages/contracts/generated/api/console/rag/types.gen.ts b/packages/contracts/generated/api/console/rag/types.gen.ts index 7dc70e39b5033b..baa0181f214c1f 100644 --- a/packages/contracts/generated/api/console/rag/types.gen.ts +++ b/packages/contracts/generated/api/console/rag/types.gen.ts @@ -301,8 +301,8 @@ export type WorkflowDraftVariable = { | number | boolean | { - [key: string]: unknown - } + [key: string]: unknown + } | Array<unknown> | null value_type?: string @@ -555,8 +555,8 @@ export type DeleteRagPipelineCustomizedTemplatesByTemplateIdResponses = { 204: void } -export type DeleteRagPipelineCustomizedTemplatesByTemplateIdResponse - = DeleteRagPipelineCustomizedTemplatesByTemplateIdResponses[keyof DeleteRagPipelineCustomizedTemplatesByTemplateIdResponses] +export type DeleteRagPipelineCustomizedTemplatesByTemplateIdResponse = + DeleteRagPipelineCustomizedTemplatesByTemplateIdResponses[keyof DeleteRagPipelineCustomizedTemplatesByTemplateIdResponses] export type PatchRagPipelineCustomizedTemplatesByTemplateIdData = { body: CustomizedPipelineTemplatePayload @@ -571,8 +571,8 @@ export type PatchRagPipelineCustomizedTemplatesByTemplateIdResponses = { 204: void } -export type PatchRagPipelineCustomizedTemplatesByTemplateIdResponse - = PatchRagPipelineCustomizedTemplatesByTemplateIdResponses[keyof PatchRagPipelineCustomizedTemplatesByTemplateIdResponses] +export type PatchRagPipelineCustomizedTemplatesByTemplateIdResponse = + PatchRagPipelineCustomizedTemplatesByTemplateIdResponses[keyof PatchRagPipelineCustomizedTemplatesByTemplateIdResponses] export type PostRagPipelineCustomizedTemplatesByTemplateIdData = { body?: never @@ -587,8 +587,8 @@ export type PostRagPipelineCustomizedTemplatesByTemplateIdResponses = { 200: SimpleDataResponse } -export type PostRagPipelineCustomizedTemplatesByTemplateIdResponse - = PostRagPipelineCustomizedTemplatesByTemplateIdResponses[keyof PostRagPipelineCustomizedTemplatesByTemplateIdResponses] +export type PostRagPipelineCustomizedTemplatesByTemplateIdResponse = + PostRagPipelineCustomizedTemplatesByTemplateIdResponses[keyof PostRagPipelineCustomizedTemplatesByTemplateIdResponses] export type PostRagPipelineDatasetData = { body: RagPipelineDatasetImportPayload @@ -601,8 +601,8 @@ export type PostRagPipelineDatasetResponses = { 201: RagPipelineImportResponse } -export type PostRagPipelineDatasetResponse - = PostRagPipelineDatasetResponses[keyof PostRagPipelineDatasetResponses] +export type PostRagPipelineDatasetResponse = + PostRagPipelineDatasetResponses[keyof PostRagPipelineDatasetResponses] export type PostRagPipelineEmptyDatasetData = { body?: never @@ -615,8 +615,8 @@ export type PostRagPipelineEmptyDatasetResponses = { 201: DatasetDetailResponse } -export type PostRagPipelineEmptyDatasetResponse - = PostRagPipelineEmptyDatasetResponses[keyof PostRagPipelineEmptyDatasetResponses] +export type PostRagPipelineEmptyDatasetResponse = + PostRagPipelineEmptyDatasetResponses[keyof PostRagPipelineEmptyDatasetResponses] export type GetRagPipelineTemplatesData = { body?: never @@ -632,8 +632,8 @@ export type GetRagPipelineTemplatesResponses = { 200: PipelineTemplateListResponse } -export type GetRagPipelineTemplatesResponse - = GetRagPipelineTemplatesResponses[keyof GetRagPipelineTemplatesResponses] +export type GetRagPipelineTemplatesResponse = + GetRagPipelineTemplatesResponses[keyof GetRagPipelineTemplatesResponses] export type GetRagPipelineTemplatesByTemplateIdData = { body?: never @@ -650,8 +650,8 @@ export type GetRagPipelineTemplatesByTemplateIdResponses = { 200: PipelineTemplateDetailResponse } -export type GetRagPipelineTemplatesByTemplateIdResponse - = GetRagPipelineTemplatesByTemplateIdResponses[keyof GetRagPipelineTemplatesByTemplateIdResponses] +export type GetRagPipelineTemplatesByTemplateIdResponse = + GetRagPipelineTemplatesByTemplateIdResponses[keyof GetRagPipelineTemplatesByTemplateIdResponses] export type GetRagPipelinesDatasourcePluginsData = { body?: never @@ -664,8 +664,8 @@ export type GetRagPipelinesDatasourcePluginsResponses = { 200: RagPipelineOpaqueResponse } -export type GetRagPipelinesDatasourcePluginsResponse - = GetRagPipelinesDatasourcePluginsResponses[keyof GetRagPipelinesDatasourcePluginsResponses] +export type GetRagPipelinesDatasourcePluginsResponse = + GetRagPipelinesDatasourcePluginsResponses[keyof GetRagPipelinesDatasourcePluginsResponses] export type PostRagPipelinesImportsData = { body: RagPipelineImportPayload @@ -678,16 +678,16 @@ export type PostRagPipelinesImportsErrors = { 400: RagPipelineImportResponse } -export type PostRagPipelinesImportsError - = PostRagPipelinesImportsErrors[keyof PostRagPipelinesImportsErrors] +export type PostRagPipelinesImportsError = + PostRagPipelinesImportsErrors[keyof PostRagPipelinesImportsErrors] export type PostRagPipelinesImportsResponses = { 200: RagPipelineImportResponse 202: RagPipelineImportResponse } -export type PostRagPipelinesImportsResponse - = PostRagPipelinesImportsResponses[keyof PostRagPipelinesImportsResponses] +export type PostRagPipelinesImportsResponse = + PostRagPipelinesImportsResponses[keyof PostRagPipelinesImportsResponses] export type PostRagPipelinesImportsByImportIdConfirmData = { body?: never @@ -702,15 +702,15 @@ export type PostRagPipelinesImportsByImportIdConfirmErrors = { 400: RagPipelineImportResponse } -export type PostRagPipelinesImportsByImportIdConfirmError - = PostRagPipelinesImportsByImportIdConfirmErrors[keyof PostRagPipelinesImportsByImportIdConfirmErrors] +export type PostRagPipelinesImportsByImportIdConfirmError = + PostRagPipelinesImportsByImportIdConfirmErrors[keyof PostRagPipelinesImportsByImportIdConfirmErrors] export type PostRagPipelinesImportsByImportIdConfirmResponses = { 200: RagPipelineImportResponse } -export type PostRagPipelinesImportsByImportIdConfirmResponse - = PostRagPipelinesImportsByImportIdConfirmResponses[keyof PostRagPipelinesImportsByImportIdConfirmResponses] +export type PostRagPipelinesImportsByImportIdConfirmResponse = + PostRagPipelinesImportsByImportIdConfirmResponses[keyof PostRagPipelinesImportsByImportIdConfirmResponses] export type GetRagPipelinesImportsByPipelineIdCheckDependenciesData = { body?: never @@ -725,8 +725,8 @@ export type GetRagPipelinesImportsByPipelineIdCheckDependenciesResponses = { 200: RagPipelineImportCheckDependenciesResponse } -export type GetRagPipelinesImportsByPipelineIdCheckDependenciesResponse - = GetRagPipelinesImportsByPipelineIdCheckDependenciesResponses[keyof GetRagPipelinesImportsByPipelineIdCheckDependenciesResponses] +export type GetRagPipelinesImportsByPipelineIdCheckDependenciesResponse = + GetRagPipelinesImportsByPipelineIdCheckDependenciesResponses[keyof GetRagPipelinesImportsByPipelineIdCheckDependenciesResponses] export type GetRagPipelinesRecommendedPluginsData = { body?: never @@ -741,8 +741,8 @@ export type GetRagPipelinesRecommendedPluginsResponses = { 200: RagPipelineOpaqueResponse } -export type GetRagPipelinesRecommendedPluginsResponse - = GetRagPipelinesRecommendedPluginsResponses[keyof GetRagPipelinesRecommendedPluginsResponses] +export type GetRagPipelinesRecommendedPluginsResponse = + GetRagPipelinesRecommendedPluginsResponses[keyof GetRagPipelinesRecommendedPluginsResponses] export type PostRagPipelinesTransformDatasetsByDatasetIdData = { body?: never @@ -757,8 +757,8 @@ export type PostRagPipelinesTransformDatasetsByDatasetIdResponses = { 200: RagPipelineOpaqueResponse } -export type PostRagPipelinesTransformDatasetsByDatasetIdResponse - = PostRagPipelinesTransformDatasetsByDatasetIdResponses[keyof PostRagPipelinesTransformDatasetsByDatasetIdResponses] +export type PostRagPipelinesTransformDatasetsByDatasetIdResponse = + PostRagPipelinesTransformDatasetsByDatasetIdResponses[keyof PostRagPipelinesTransformDatasetsByDatasetIdResponses] export type PostRagPipelinesByPipelineIdCustomizedPublishData = { body: CustomizedPipelineTemplatePayload @@ -773,8 +773,8 @@ export type PostRagPipelinesByPipelineIdCustomizedPublishResponses = { 204: void } -export type PostRagPipelinesByPipelineIdCustomizedPublishResponse - = PostRagPipelinesByPipelineIdCustomizedPublishResponses[keyof PostRagPipelinesByPipelineIdCustomizedPublishResponses] +export type PostRagPipelinesByPipelineIdCustomizedPublishResponse = + PostRagPipelinesByPipelineIdCustomizedPublishResponses[keyof PostRagPipelinesByPipelineIdCustomizedPublishResponses] export type GetRagPipelinesByPipelineIdExportsData = { body?: never @@ -791,8 +791,8 @@ export type GetRagPipelinesByPipelineIdExportsResponses = { 200: SimpleDataResponse } -export type GetRagPipelinesByPipelineIdExportsResponse - = GetRagPipelinesByPipelineIdExportsResponses[keyof GetRagPipelinesByPipelineIdExportsResponses] +export type GetRagPipelinesByPipelineIdExportsResponse = + GetRagPipelinesByPipelineIdExportsResponses[keyof GetRagPipelinesByPipelineIdExportsResponses] export type GetRagPipelinesByPipelineIdWorkflowRunsData = { body?: never @@ -810,8 +810,8 @@ export type GetRagPipelinesByPipelineIdWorkflowRunsResponses = { 200: WorkflowRunPaginationResponse } -export type GetRagPipelinesByPipelineIdWorkflowRunsResponse - = GetRagPipelinesByPipelineIdWorkflowRunsResponses[keyof GetRagPipelinesByPipelineIdWorkflowRunsResponses] +export type GetRagPipelinesByPipelineIdWorkflowRunsResponse = + GetRagPipelinesByPipelineIdWorkflowRunsResponses[keyof GetRagPipelinesByPipelineIdWorkflowRunsResponses] export type PostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopData = { body?: never @@ -827,8 +827,8 @@ export type PostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopResponses = 200: SimpleResultResponse } -export type PostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopResponse - = PostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopResponses[keyof PostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopResponses] +export type PostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopResponse = + PostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopResponses[keyof PostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopResponses] export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdData = { body?: never @@ -844,8 +844,8 @@ export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponses = { 200: WorkflowRunDetailResponse } -export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponse - = GetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponses[keyof GetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponses] +export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponse = + GetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponses[keyof GetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponses] export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsData = { body?: never @@ -861,8 +861,8 @@ export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponse 200: WorkflowRunNodeExecutionListResponse } -export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponse - = GetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponses[keyof GetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponses] +export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponse = + GetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponses[keyof GetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponses] export type GetRagPipelinesByPipelineIdWorkflowsData = { body?: never @@ -886,8 +886,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsResponses = { 200: WorkflowPaginationResponse } -export type GetRagPipelinesByPipelineIdWorkflowsResponse - = GetRagPipelinesByPipelineIdWorkflowsResponses[keyof GetRagPipelinesByPipelineIdWorkflowsResponses] +export type GetRagPipelinesByPipelineIdWorkflowsResponse = + GetRagPipelinesByPipelineIdWorkflowsResponses[keyof GetRagPipelinesByPipelineIdWorkflowsResponses] export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsData = { body?: never @@ -902,8 +902,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsRespo 200: DefaultBlockConfigsResponse } -export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponse - = GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponses] +export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponse = + GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponses] export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeData = { body?: never @@ -921,8 +921,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlo 200: DefaultBlockConfigResponse } -export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse - = GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponses] +export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse = + GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponses] export type GetRagPipelinesByPipelineIdWorkflowsDraftData = { body?: never @@ -941,8 +941,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftResponses = { 200: WorkflowResponse } -export type GetRagPipelinesByPipelineIdWorkflowsDraftResponse - = GetRagPipelinesByPipelineIdWorkflowsDraftResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftResponses] +export type GetRagPipelinesByPipelineIdWorkflowsDraftResponse = + GetRagPipelinesByPipelineIdWorkflowsDraftResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftResponses] export type PostRagPipelinesByPipelineIdWorkflowsDraftData = { body: DraftWorkflowSyncPayload @@ -957,8 +957,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftResponses = { 200: RagPipelineWorkflowSyncResponse } -export type PostRagPipelinesByPipelineIdWorkflowsDraftResponse - = PostRagPipelinesByPipelineIdWorkflowsDraftResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftResponses] +export type PostRagPipelinesByPipelineIdWorkflowsDraftResponse = + PostRagPipelinesByPipelineIdWorkflowsDraftResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftResponses] export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunData = { body: DatasourceNodeRunPayload @@ -974,8 +974,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRun 200: RagPipelineOpaqueResponse } -export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponse - = PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponses] +export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponse = + PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponses] export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectData = { body: DatasourceVariablesPayload @@ -990,8 +990,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspect 200: WorkflowRunNodeExecutionResponse } -export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectResponse - = PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectResponses] +export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectResponse = + PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectResponses] export type GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesData = { body?: never @@ -1006,8 +1006,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesRespons 200: EnvironmentVariableListResponse } -export type GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponse - = GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponses] +export type GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponse = + GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponses] export type PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunData = { body: NodeRunPayload @@ -1023,8 +1023,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunR 200: RagPipelineOpaqueResponse } -export type PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunResponse - = PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunResponses] +export type PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunResponse = + PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunResponses] export type PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunData = { body: NodeRunPayload @@ -1040,8 +1040,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunRespon 200: RagPipelineOpaqueResponse } -export type PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResponse - = PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResponses] +export type PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResponse = + PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResponses] export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunData = { body?: never @@ -1057,8 +1057,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunRespons 200: WorkflowRunNodeExecutionResponse } -export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunResponse - = GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunResponses] +export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunResponse = + GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunResponses] export type PostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunData = { body: NodeRunRequiredPayload @@ -1074,8 +1074,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponses 200: WorkflowRunNodeExecutionResponse } -export type PostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponse - = PostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponses] +export type PostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponse = + PostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponses] export type DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesData = { body?: never @@ -1091,8 +1091,8 @@ export type DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesRe 204: void } -export type DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse - = DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponses] +export type DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse = + DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponses] export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesData = { body?: never @@ -1108,8 +1108,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesRespo 200: WorkflowDraftVariableList } -export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse - = GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponses] +export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse = + GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponses] export type GetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersData = { body?: never @@ -1126,8 +1126,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResp 200: RagPipelineStepParametersResponse } -export type GetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponse - = GetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponses] +export type GetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponse = + GetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponses] export type GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersData = { body?: never @@ -1144,8 +1144,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersRespons 200: RagPipelineStepParametersResponse } -export type GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponse - = GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponses] +export type GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponse = + GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponses] export type PostRagPipelinesByPipelineIdWorkflowsDraftRunData = { body: DraftWorkflowRunPayload @@ -1160,8 +1160,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftRunResponses = { 200: RagPipelineOpaqueResponse } -export type PostRagPipelinesByPipelineIdWorkflowsDraftRunResponse - = PostRagPipelinesByPipelineIdWorkflowsDraftRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftRunResponses] +export type PostRagPipelinesByPipelineIdWorkflowsDraftRunResponse = + PostRagPipelinesByPipelineIdWorkflowsDraftRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsDraftRunResponses] export type GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesData = { body?: never @@ -1176,8 +1176,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponses = 200: WorkflowDraftVariableList } -export type GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponse - = GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponses] +export type GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponse = + GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponses] export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesData = { body?: never @@ -1192,8 +1192,8 @@ export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponses = { 204: void } -export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse - = DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponses[keyof DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponses] +export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse = + DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponses[keyof DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponses] export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesData = { body?: never @@ -1211,8 +1211,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponses = { 200: WorkflowDraftVariableListWithoutValue } -export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse - = GetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponses] +export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse = + GetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponses] export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdData = { body?: never @@ -1228,8 +1228,8 @@ export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdRes 204: void } -export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse - = DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses[keyof DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses] +export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse = + DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses[keyof DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses] export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdData = { body?: never @@ -1245,8 +1245,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdRespon 200: WorkflowDraftVariable } -export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse - = GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses] +export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse = + GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses] export type PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdData = { body: WorkflowDraftVariablePatchPayload @@ -1262,8 +1262,8 @@ export type PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResp 200: WorkflowDraftVariable } -export type PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse - = PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses[keyof PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses] +export type PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse = + PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses[keyof PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses] export type PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetData = { body?: never @@ -1280,8 +1280,8 @@ export type PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetR 204: void } -export type PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponse - = PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponses[keyof PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponses] +export type PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponse = + PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponses[keyof PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponses] export type GetRagPipelinesByPipelineIdWorkflowsPublishData = { body?: never @@ -1296,8 +1296,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsPublishResponses = { 200: WorkflowResponse } -export type GetRagPipelinesByPipelineIdWorkflowsPublishResponse - = GetRagPipelinesByPipelineIdWorkflowsPublishResponses[keyof GetRagPipelinesByPipelineIdWorkflowsPublishResponses] +export type GetRagPipelinesByPipelineIdWorkflowsPublishResponse = + GetRagPipelinesByPipelineIdWorkflowsPublishResponses[keyof GetRagPipelinesByPipelineIdWorkflowsPublishResponses] export type PostRagPipelinesByPipelineIdWorkflowsPublishData = { body?: never @@ -1312,8 +1312,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsPublishResponses = { 200: RagPipelineWorkflowPublishResponse } -export type PostRagPipelinesByPipelineIdWorkflowsPublishResponse - = PostRagPipelinesByPipelineIdWorkflowsPublishResponses[keyof PostRagPipelinesByPipelineIdWorkflowsPublishResponses] +export type PostRagPipelinesByPipelineIdWorkflowsPublishResponse = + PostRagPipelinesByPipelineIdWorkflowsPublishResponses[keyof PostRagPipelinesByPipelineIdWorkflowsPublishResponses] export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewData = { body: Parser @@ -1325,15 +1325,15 @@ export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeI url: '/rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/preview' } -export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponses - = { +export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponses = + { 200: { [key: string]: unknown } } -export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponse - = PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponses[keyof PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponses] +export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponse = + PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponses[keyof PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponses] export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunData = { body: DatasourceNodeRunPayload @@ -1349,8 +1349,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeI 200: RagPipelineOpaqueResponse } -export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunResponse - = PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunResponses] +export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunResponse = + PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunResponses] export type GetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersData = { body?: never @@ -1367,8 +1367,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParameters 200: RagPipelineStepParametersResponse } -export type GetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponse - = GetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponses[keyof GetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponses] +export type GetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponse = + GetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponses[keyof GetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponses] export type GetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersData = { body?: never @@ -1385,8 +1385,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersRes 200: RagPipelineStepParametersResponse } -export type GetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponse - = GetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponses[keyof GetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponses] +export type GetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponse = + GetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponses[keyof GetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponses] export type PostRagPipelinesByPipelineIdWorkflowsPublishedRunData = { body: PublishedWorkflowRunPayload @@ -1401,8 +1401,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsPublishedRunResponses = { 200: RagPipelineOpaqueResponse } -export type PostRagPipelinesByPipelineIdWorkflowsPublishedRunResponse - = PostRagPipelinesByPipelineIdWorkflowsPublishedRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsPublishedRunResponses] +export type PostRagPipelinesByPipelineIdWorkflowsPublishedRunResponse = + PostRagPipelinesByPipelineIdWorkflowsPublishedRunResponses[keyof PostRagPipelinesByPipelineIdWorkflowsPublishedRunResponses] export type DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdData = { body?: never @@ -1418,8 +1418,8 @@ export type DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses = { 204: void } -export type DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse - = DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses[keyof DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses] +export type DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse = + DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses[keyof DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses] export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdData = { body: WorkflowUpdatePayload @@ -1441,8 +1441,8 @@ export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses = { 200: WorkflowResponse } -export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse - = PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses[keyof PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses] +export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse = + PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses[keyof PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses] export type PostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestoreData = { body?: never @@ -1458,5 +1458,5 @@ export type PostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestoreResponses = 200: RagPipelineWorkflowSyncResponse } -export type PostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestoreResponse - = PostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestoreResponses[keyof PostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestoreResponses] +export type PostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestoreResponse = + PostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestoreResponses[keyof PostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestoreResponses] diff --git a/packages/contracts/generated/api/console/rag/zod.gen.ts b/packages/contracts/generated/api/console/rag/zod.gen.ts index ea5c59dfe38471..77ba7a76c1b5ff 100644 --- a/packages/contracts/generated/api/console/rag/zod.gen.ts +++ b/packages/contracts/generated/api/console/rag/zod.gen.ts @@ -677,8 +677,8 @@ export const zDeleteRagPipelineCustomizedTemplatesByTemplateIdPath = z.object({ */ export const zDeleteRagPipelineCustomizedTemplatesByTemplateIdResponse = z.void() -export const zPatchRagPipelineCustomizedTemplatesByTemplateIdBody - = zCustomizedPipelineTemplatePayload +export const zPatchRagPipelineCustomizedTemplatesByTemplateIdBody = + zCustomizedPipelineTemplatePayload export const zPatchRagPipelineCustomizedTemplatesByTemplateIdPath = z.object({ template_id: z.string(), @@ -761,8 +761,8 @@ export const zGetRagPipelinesImportsByPipelineIdCheckDependenciesPath = z.object /** * Dependencies checked */ -export const zGetRagPipelinesImportsByPipelineIdCheckDependenciesResponse - = zRagPipelineImportCheckDependenciesResponse +export const zGetRagPipelinesImportsByPipelineIdCheckDependenciesResponse = + zRagPipelineImportCheckDependenciesResponse export const zGetRagPipelinesRecommendedPluginsQuery = z.object({ type: z.string().optional().default('all'), @@ -828,8 +828,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopPath = z. /** * Task stopped successfully */ -export const zPostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopResponse - = zSimpleResultResponse +export const zPostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopResponse = + zSimpleResultResponse export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdPath = z.object({ pipeline_id: z.uuid(), @@ -849,8 +849,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsPath = /** * Node executions retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponse - = zWorkflowRunNodeExecutionListResponse +export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponse = + zWorkflowRunNodeExecutionListResponse export const zGetRagPipelinesByPipelineIdWorkflowsPath = z.object({ pipeline_id: z.uuid(), @@ -875,25 +875,25 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsPat /** * Default block configs retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponse - = zDefaultBlockConfigsResponse +export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponse = + zDefaultBlockConfigsResponse -export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath - = z.object({ +export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath = + z.object({ block_type: z.string(), pipeline_id: z.uuid(), }) -export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeQuery - = z.object({ +export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeQuery = + z.object({ q: z.string().optional(), }) /** * Default block config retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse - = zDefaultBlockConfigResponse +export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse = + zDefaultBlockConfigResponse export const zGetRagPipelinesByPipelineIdWorkflowsDraftPath = z.object({ pipeline_id: z.uuid(), @@ -915,8 +915,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftPath = z.object({ */ export const zPostRagPipelinesByPipelineIdWorkflowsDraftResponse = zRagPipelineWorkflowSyncResponse -export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunBody - = zDatasourceNodeRunPayload +export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunBody = + zDatasourceNodeRunPayload export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunPath = z.object({ node_id: z.string(), @@ -926,11 +926,11 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdR /** * Success */ -export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponse - = zRagPipelineOpaqueResponse +export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponse = + zRagPipelineOpaqueResponse -export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectBody - = zDatasourceVariablesPayload +export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectBody = + zDatasourceVariablesPayload export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectPath = z.object({ pipeline_id: z.uuid(), @@ -939,8 +939,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspe /** * Datasource variables set successfully */ -export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectResponse - = zWorkflowRunNodeExecutionResponse +export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectResponse = + zWorkflowRunNodeExecutionResponse export const zGetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesPath = z.object({ pipeline_id: z.uuid(), @@ -949,11 +949,11 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesPath /** * Environment variables retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponse - = zEnvironmentVariableListResponse +export const zGetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponse = + zEnvironmentVariableListResponse -export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunBody - = zNodeRunPayload +export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunBody = + zNodeRunPayload export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunPath = z.object({ node_id: z.string(), @@ -963,8 +963,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRu /** * Success */ -export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunResponse - = zRagPipelineOpaqueResponse +export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunResponse = + zRagPipelineOpaqueResponse export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunBody = zNodeRunPayload @@ -976,8 +976,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunPath /** * Success */ -export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResponse - = zRagPipelineOpaqueResponse +export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResponse = + zRagPipelineOpaqueResponse export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ node_id: z.string(), @@ -987,11 +987,11 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunPath /** * Node last run retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunResponse - = zWorkflowRunNodeExecutionResponse +export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunResponse = + zWorkflowRunNodeExecutionResponse -export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunBody - = zNodeRunRequiredPayload +export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunBody = + zNodeRunRequiredPayload export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunPath = z.object({ node_id: z.string(), @@ -1001,8 +1001,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunPath = z /** * Node run started successfully */ -export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponse - = zWorkflowRunNodeExecutionResponse +export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponse = + zWorkflowRunNodeExecutionResponse export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ node_id: z.string(), @@ -1022,8 +1022,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesPat /** * Node variables retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse - = zWorkflowDraftVariableList +export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse = + zWorkflowDraftVariableList export const zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersPath = z.object({ pipeline_id: z.uuid(), @@ -1036,8 +1036,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersQu /** * Success */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponse - = zRagPipelineStepParametersResponse +export const zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponse = + zRagPipelineStepParametersResponse export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersPath = z.object({ pipeline_id: z.uuid(), @@ -1050,8 +1050,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersQuery /** * Success */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponse - = zRagPipelineStepParametersResponse +export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponse = + zRagPipelineStepParametersResponse export const zPostRagPipelinesByPipelineIdWorkflowsDraftRunBody = zDraftWorkflowRunPayload @@ -1071,8 +1071,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesPath = z.o /** * System variables retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponse - = zWorkflowDraftVariableList +export const zGetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponse = + zWorkflowDraftVariableList export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesPath = z.object({ pipeline_id: z.uuid(), @@ -1095,8 +1095,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesQuery = z.object /** * Workflow variables retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse - = zWorkflowDraftVariableListWithoutValue +export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse = + zWorkflowDraftVariableListWithoutValue export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath = z.object({ pipeline_id: z.uuid(), @@ -1116,11 +1116,11 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath /** * Variable retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse - = zWorkflowDraftVariable +export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse = + zWorkflowDraftVariable -export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdBody - = zWorkflowDraftVariablePatchPayload +export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdBody = + zWorkflowDraftVariablePatchPayload export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath = z.object({ pipeline_id: z.uuid(), @@ -1130,8 +1130,8 @@ export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPa /** * Variable updated successfully */ -export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse - = zWorkflowDraftVariable +export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse = + zWorkflowDraftVariable export const zPutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetPath = z.object({ pipeline_id: z.uuid(), @@ -1158,14 +1158,14 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishPath = z.object({ /** * Success */ -export const zPostRagPipelinesByPipelineIdWorkflowsPublishResponse - = zRagPipelineWorkflowPublishResponse +export const zPostRagPipelinesByPipelineIdWorkflowsPublishResponse = + zRagPipelineWorkflowPublishResponse -export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewBody - = zParser +export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewBody = + zParser -export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewPath - = z.object({ +export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewPath = + z.object({ node_id: z.string(), pipeline_id: z.uuid(), }) @@ -1173,14 +1173,14 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNod /** * Success */ -export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponse - = z.record(z.string(), z.unknown()) +export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponse = + z.record(z.string(), z.unknown()) -export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunBody - = zDatasourceNodeRunPayload +export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunBody = + zDatasourceNodeRunPayload -export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunPath - = z.object({ +export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunPath = + z.object({ node_id: z.string(), pipeline_id: z.uuid(), }) @@ -1188,8 +1188,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNod /** * Success */ -export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunResponse - = zRagPipelineOpaqueResponse +export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunResponse = + zRagPipelineOpaqueResponse export const zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersPath = z.object({ pipeline_id: z.uuid(), @@ -1202,8 +1202,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParamete /** * Success */ -export const zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponse - = zRagPipelineStepParametersResponse +export const zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponse = + zRagPipelineStepParametersResponse export const zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersPath = z.object({ pipeline_id: z.uuid(), @@ -1216,8 +1216,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersQ /** * Success */ -export const zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponse - = zRagPipelineStepParametersResponse +export const zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponse = + zRagPipelineStepParametersResponse export const zPostRagPipelinesByPipelineIdWorkflowsPublishedRunBody = zPublishedWorkflowRunPayload @@ -1260,5 +1260,5 @@ export const zPostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestorePath = z.o /** * Success */ -export const zPostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestoreResponse - = zRagPipelineWorkflowSyncResponse +export const zPostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestoreResponse = + zRagPipelineWorkflowSyncResponse diff --git a/packages/contracts/generated/api/console/refresh-token/orpc.gen.ts b/packages/contracts/generated/api/console/refresh-token/orpc.gen.ts index 4faa4d7d2348f6..7e8001c2bb7d92 100644 --- a/packages/contracts/generated/api/console/refresh-token/orpc.gen.ts +++ b/packages/contracts/generated/api/console/refresh-token/orpc.gen.ts @@ -1,7 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts import { oc } from '@orpc/contract' - import { zPostRefreshTokenResponse } from './zod.gen' export const post = oc diff --git a/packages/contracts/generated/api/console/remote-files/orpc.gen.ts b/packages/contracts/generated/api/console/remote-files/orpc.gen.ts index afb26cd84a2ce8..d495e56324bbe3 100644 --- a/packages/contracts/generated/api/console/remote-files/orpc.gen.ts +++ b/packages/contracts/generated/api/console/remote-files/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetRemoteFilesByUrlPath, zGetRemoteFilesByUrlResponse, diff --git a/packages/contracts/generated/api/console/remote-files/types.gen.ts b/packages/contracts/generated/api/console/remote-files/types.gen.ts index 4162f98f93805d..7a7e3ffa2ed436 100644 --- a/packages/contracts/generated/api/console/remote-files/types.gen.ts +++ b/packages/contracts/generated/api/console/remote-files/types.gen.ts @@ -35,8 +35,8 @@ export type PostRemoteFilesUploadResponses = { 201: FileWithSignedUrl } -export type PostRemoteFilesUploadResponse - = PostRemoteFilesUploadResponses[keyof PostRemoteFilesUploadResponses] +export type PostRemoteFilesUploadResponse = + PostRemoteFilesUploadResponses[keyof PostRemoteFilesUploadResponses] export type GetRemoteFilesByUrlData = { body?: never @@ -51,5 +51,5 @@ export type GetRemoteFilesByUrlResponses = { 200: RemoteFileInfo } -export type GetRemoteFilesByUrlResponse - = GetRemoteFilesByUrlResponses[keyof GetRemoteFilesByUrlResponses] +export type GetRemoteFilesByUrlResponse = + GetRemoteFilesByUrlResponses[keyof GetRemoteFilesByUrlResponses] diff --git a/packages/contracts/generated/api/console/reset-password/orpc.gen.ts b/packages/contracts/generated/api/console/reset-password/orpc.gen.ts index 93701280db120b..7af731391c1628 100644 --- a/packages/contracts/generated/api/console/reset-password/orpc.gen.ts +++ b/packages/contracts/generated/api/console/reset-password/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zPostResetPasswordBody, zPostResetPasswordResponse } from './zod.gen' export const post = oc diff --git a/packages/contracts/generated/api/console/rule-code-generate/orpc.gen.ts b/packages/contracts/generated/api/console/rule-code-generate/orpc.gen.ts index 1c5252525c55a1..88a88e6088fb57 100644 --- a/packages/contracts/generated/api/console/rule-code-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rule-code-generate/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zPostRuleCodeGenerateBody, zPostRuleCodeGenerateResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/rule-code-generate/types.gen.ts b/packages/contracts/generated/api/console/rule-code-generate/types.gen.ts index a1165a4f8a220f..a43766f92af9bd 100644 --- a/packages/contracts/generated/api/console/rule-code-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/rule-code-generate/types.gen.ts @@ -40,5 +40,5 @@ export type PostRuleCodeGenerateResponses = { 200: GeneratorResponse } -export type PostRuleCodeGenerateResponse - = PostRuleCodeGenerateResponses[keyof PostRuleCodeGenerateResponses] +export type PostRuleCodeGenerateResponse = + PostRuleCodeGenerateResponses[keyof PostRuleCodeGenerateResponses] diff --git a/packages/contracts/generated/api/console/rule-generate/orpc.gen.ts b/packages/contracts/generated/api/console/rule-generate/orpc.gen.ts index 7bd233de2bc989..e231a07daff618 100644 --- a/packages/contracts/generated/api/console/rule-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rule-generate/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zPostRuleGenerateBody, zPostRuleGenerateResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/rule-structured-output-generate/orpc.gen.ts b/packages/contracts/generated/api/console/rule-structured-output-generate/orpc.gen.ts index 276442f1c91df1..3f43785a56abd8 100644 --- a/packages/contracts/generated/api/console/rule-structured-output-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rule-structured-output-generate/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zPostRuleStructuredOutputGenerateBody, zPostRuleStructuredOutputGenerateResponse, diff --git a/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts b/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts index f7da1cd5cc85e2..3e0988d4fc0ad3 100644 --- a/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts @@ -38,5 +38,5 @@ export type PostRuleStructuredOutputGenerateResponses = { 200: GeneratorResponse } -export type PostRuleStructuredOutputGenerateResponse - = PostRuleStructuredOutputGenerateResponses[keyof PostRuleStructuredOutputGenerateResponses] +export type PostRuleStructuredOutputGenerateResponse = + PostRuleStructuredOutputGenerateResponses[keyof PostRuleStructuredOutputGenerateResponses] diff --git a/packages/contracts/generated/api/console/setup/orpc.gen.ts b/packages/contracts/generated/api/console/setup/orpc.gen.ts index cb98a380264449..12e1631e7e8c65 100644 --- a/packages/contracts/generated/api/console/setup/orpc.gen.ts +++ b/packages/contracts/generated/api/console/setup/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetSetupResponse, zPostSetupBody, zPostSetupResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/snippets/orpc.gen.ts b/packages/contracts/generated/api/console/snippets/orpc.gen.ts index 7e79e024498fa5..80e918f97005a7 100644 --- a/packages/contracts/generated/api/console/snippets/orpc.gen.ts +++ b/packages/contracts/generated/api/console/snippets/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath, zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse, @@ -443,7 +442,7 @@ export const nodes3 = { export const post5 = oc .route({ description: - 'Executes the snippet\'s draft workflow with the provided inputs\nand returns an SSE event stream with execution progress and results.', + "Executes the snippet's draft workflow with the provided inputs\nand returns an SSE event stream with execution progress and results.", inputStructure: 'detailed', method: 'POST', operationId: 'postSnippetsBySnippetIdWorkflowsDraftRun', @@ -725,7 +724,7 @@ export const patch2 = oc method: 'PATCH', operationId: 'patchSnippetsBySnippetIdWorkflowsByWorkflowId', path: '/snippets/{snippet_id}/workflows/{workflow_id}', - summary: 'Update a published snippet workflow version\'s display metadata', + summary: "Update a published snippet workflow version's display metadata", tags: ['console'], }) .input( diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts index cb26047a07e171..47cf1a4781d94c 100644 --- a/packages/contracts/generated/api/console/snippets/types.gen.ts +++ b/packages/contracts/generated/api/console/snippets/types.gen.ts @@ -184,8 +184,8 @@ export type WorkflowDraftVariable = { | number | boolean | { - [key: string]: unknown - } + [key: string]: unknown + } | Array<unknown> | null value_type?: string @@ -288,16 +288,16 @@ export type EnvironmentVariableItemResponse = { visible: boolean } -export type JsonValue - = | string - | number - | number - | boolean - | { +export type JsonValue = + | string + | number + | number + | boolean + | { [key: string]: unknown } - | Array<unknown> - | null + | Array<unknown> + | null export type WorkflowDraftVariableWithoutValue = { description?: string @@ -327,8 +327,8 @@ export type GetSnippetsBySnippetIdWorkflowRunsResponses = { 200: WorkflowRunPaginationResponse } -export type GetSnippetsBySnippetIdWorkflowRunsResponse - = GetSnippetsBySnippetIdWorkflowRunsResponses[keyof GetSnippetsBySnippetIdWorkflowRunsResponses] +export type GetSnippetsBySnippetIdWorkflowRunsResponse = + GetSnippetsBySnippetIdWorkflowRunsResponses[keyof GetSnippetsBySnippetIdWorkflowRunsResponses] export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopData = { body?: never @@ -348,8 +348,8 @@ export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponses = { 200: SimpleResultResponse } -export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse - = PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponses[keyof PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponses] +export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse = + PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponses[keyof PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponses] export type GetSnippetsBySnippetIdWorkflowRunsByRunIdData = { body?: never @@ -369,8 +369,8 @@ export type GetSnippetsBySnippetIdWorkflowRunsByRunIdResponses = { 200: WorkflowRunDetailResponse } -export type GetSnippetsBySnippetIdWorkflowRunsByRunIdResponse - = GetSnippetsBySnippetIdWorkflowRunsByRunIdResponses[keyof GetSnippetsBySnippetIdWorkflowRunsByRunIdResponses] +export type GetSnippetsBySnippetIdWorkflowRunsByRunIdResponse = + GetSnippetsBySnippetIdWorkflowRunsByRunIdResponses[keyof GetSnippetsBySnippetIdWorkflowRunsByRunIdResponses] export type GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsData = { body?: never @@ -386,8 +386,8 @@ export type GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponses = { 200: WorkflowRunNodeExecutionListResponse } -export type GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse - = GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponses[keyof GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponses] +export type GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse = + GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponses[keyof GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponses] export type GetSnippetsBySnippetIdWorkflowsData = { body?: never @@ -405,8 +405,8 @@ export type GetSnippetsBySnippetIdWorkflowsResponses = { 200: SnippetWorkflowPaginationResponse } -export type GetSnippetsBySnippetIdWorkflowsResponse - = GetSnippetsBySnippetIdWorkflowsResponses[keyof GetSnippetsBySnippetIdWorkflowsResponses] +export type GetSnippetsBySnippetIdWorkflowsResponse = + GetSnippetsBySnippetIdWorkflowsResponses[keyof GetSnippetsBySnippetIdWorkflowsResponses] export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsData = { body?: never @@ -421,8 +421,8 @@ export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponses 200: DefaultBlockConfigsResponse } -export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse - = GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponses[keyof GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponses] +export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse = + GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponses[keyof GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponses] export type GetSnippetsBySnippetIdWorkflowsDraftData = { body?: never @@ -441,8 +441,8 @@ export type GetSnippetsBySnippetIdWorkflowsDraftResponses = { 200: SnippetWorkflowResponse } -export type GetSnippetsBySnippetIdWorkflowsDraftResponse - = GetSnippetsBySnippetIdWorkflowsDraftResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftResponses] +export type GetSnippetsBySnippetIdWorkflowsDraftResponse = + GetSnippetsBySnippetIdWorkflowsDraftResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftResponses] export type PostSnippetsBySnippetIdWorkflowsDraftData = { body: SnippetDraftSyncPayload @@ -461,8 +461,8 @@ export type PostSnippetsBySnippetIdWorkflowsDraftResponses = { 200: WorkflowRestoreResponse } -export type PostSnippetsBySnippetIdWorkflowsDraftResponse - = PostSnippetsBySnippetIdWorkflowsDraftResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftResponses] +export type PostSnippetsBySnippetIdWorkflowsDraftResponse = + PostSnippetsBySnippetIdWorkflowsDraftResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftResponses] export type GetSnippetsBySnippetIdWorkflowsDraftConfigData = { body?: never @@ -477,8 +477,8 @@ export type GetSnippetsBySnippetIdWorkflowsDraftConfigResponses = { 200: SnippetDraftConfigResponse } -export type GetSnippetsBySnippetIdWorkflowsDraftConfigResponse - = GetSnippetsBySnippetIdWorkflowsDraftConfigResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftConfigResponses] +export type GetSnippetsBySnippetIdWorkflowsDraftConfigResponse = + GetSnippetsBySnippetIdWorkflowsDraftConfigResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftConfigResponses] export type GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesData = { body?: never @@ -493,8 +493,8 @@ export type GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponses = 200: WorkflowDraftVariableList } -export type GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse - = GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponses] +export type GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse = + GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponses] export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesData = { body?: never @@ -513,8 +513,8 @@ export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponses = 200: EnvironmentVariableListResponse } -export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse - = GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponses] +export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse = + GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponses] export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunData = { body: SnippetIterationNodeRunPayload @@ -534,8 +534,8 @@ export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunRespon 200: GeneratedAppResponse } -export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse - = PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponses] +export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse = + PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponses] export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunData = { body: SnippetLoopNodeRunPayload @@ -555,8 +555,8 @@ export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses = 200: GeneratedAppResponse } -export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse - = PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses] +export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse = + PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses] export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunData = { body?: never @@ -576,8 +576,8 @@ export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponses = 200: WorkflowRunNodeExecutionResponse } -export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse - = GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponses] +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse = + GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponses] export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunData = { body: SnippetDraftNodeRunPayload @@ -597,8 +597,8 @@ export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponses = { 200: WorkflowRunNodeExecutionResponse } -export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse - = PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponses] +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse = + PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponses] export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesData = { body?: never @@ -614,8 +614,8 @@ export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesRespons 204: void } -export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse - = DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses] +export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse = + DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses] export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesData = { body?: never @@ -631,8 +631,8 @@ export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses 200: WorkflowDraftVariableList } -export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse - = GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses] +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse = + GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses] export type PostSnippetsBySnippetIdWorkflowsDraftRunData = { body: SnippetDraftRunPayload @@ -651,8 +651,8 @@ export type PostSnippetsBySnippetIdWorkflowsDraftRunResponses = { 200: GeneratedAppResponse } -export type PostSnippetsBySnippetIdWorkflowsDraftRunResponse - = PostSnippetsBySnippetIdWorkflowsDraftRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftRunResponses] +export type PostSnippetsBySnippetIdWorkflowsDraftRunResponse = + PostSnippetsBySnippetIdWorkflowsDraftRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftRunResponses] export type GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesData = { body?: never @@ -667,8 +667,8 @@ export type GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponses = { 200: WorkflowDraftVariableList } -export type GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse - = GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponses] +export type GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse = + GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponses] export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesData = { body?: never @@ -683,8 +683,8 @@ export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponses = { 204: void } -export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse - = DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponses[keyof DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponses] +export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse = + DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponses[keyof DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponses] export type GetSnippetsBySnippetIdWorkflowsDraftVariablesData = { body?: never @@ -702,8 +702,8 @@ export type GetSnippetsBySnippetIdWorkflowsDraftVariablesResponses = { 200: WorkflowDraftVariableListWithoutValue } -export type GetSnippetsBySnippetIdWorkflowsDraftVariablesResponse - = GetSnippetsBySnippetIdWorkflowsDraftVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftVariablesResponses] +export type GetSnippetsBySnippetIdWorkflowsDraftVariablesResponse = + GetSnippetsBySnippetIdWorkflowsDraftVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftVariablesResponses] export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdData = { body?: never @@ -723,8 +723,8 @@ export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse 204: void } -export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse - = DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses[keyof DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses] +export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse = + DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses[keyof DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses] export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdData = { body?: never @@ -744,8 +744,8 @@ export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses = 200: WorkflowDraftVariable } -export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse - = GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses] +export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse = + GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses] export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdData = { body: WorkflowDraftVariableUpdatePayload @@ -765,8 +765,8 @@ export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses 200: WorkflowDraftVariable } -export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse - = PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses[keyof PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses] +export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse = + PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses[keyof PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses] export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetData = { body?: never @@ -787,8 +787,8 @@ export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetRespon 204: void } -export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse - = PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponses[keyof PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponses] +export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse = + PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponses[keyof PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponses] export type GetSnippetsBySnippetIdWorkflowsPublishData = { body?: never @@ -807,8 +807,8 @@ export type GetSnippetsBySnippetIdWorkflowsPublishResponses = { 200: SnippetWorkflowResponse } -export type GetSnippetsBySnippetIdWorkflowsPublishResponse - = GetSnippetsBySnippetIdWorkflowsPublishResponses[keyof GetSnippetsBySnippetIdWorkflowsPublishResponses] +export type GetSnippetsBySnippetIdWorkflowsPublishResponse = + GetSnippetsBySnippetIdWorkflowsPublishResponses[keyof GetSnippetsBySnippetIdWorkflowsPublishResponses] export type PostSnippetsBySnippetIdWorkflowsPublishData = { body: PublishWorkflowPayload @@ -827,8 +827,8 @@ export type PostSnippetsBySnippetIdWorkflowsPublishResponses = { 200: WorkflowPublishResponse } -export type PostSnippetsBySnippetIdWorkflowsPublishResponse - = PostSnippetsBySnippetIdWorkflowsPublishResponses[keyof PostSnippetsBySnippetIdWorkflowsPublishResponses] +export type PostSnippetsBySnippetIdWorkflowsPublishResponse = + PostSnippetsBySnippetIdWorkflowsPublishResponses[keyof PostSnippetsBySnippetIdWorkflowsPublishResponses] export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdData = { body: WorkflowUpdatePayload @@ -849,8 +849,8 @@ export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses = { 200: SnippetWorkflowResponse } -export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse - = PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses[keyof PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses] +export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse = + PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses[keyof PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses] export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreData = { body?: never @@ -871,5 +871,5 @@ export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponses = { 200: WorkflowRestoreResponse } -export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse - = PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponses[keyof PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponses] +export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse = + PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponses[keyof PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponses] diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts index f98b2d9c889a7e..99d9eeaf677b03 100644 --- a/packages/contracts/generated/api/console/snippets/zod.gen.ts +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -417,8 +417,8 @@ export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsPath = z.ob /** * Node executions retrieved successfully */ -export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse - = zWorkflowRunNodeExecutionListResponse +export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse = + zWorkflowRunNodeExecutionListResponse export const zGetSnippetsBySnippetIdWorkflowsPath = z.object({ snippet_id: z.uuid(), @@ -441,8 +441,8 @@ export const zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsPath = z /** * Default block configs retrieved successfully */ -export const zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse - = zDefaultBlockConfigsResponse +export const zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse = + zDefaultBlockConfigsResponse export const zGetSnippetsBySnippetIdWorkflowsDraftPath = z.object({ snippet_id: z.uuid(), @@ -480,8 +480,8 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesPath = z. /** * Conversation variables retrieved successfully */ -export const zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse - = zWorkflowDraftVariableList +export const zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse = + zWorkflowDraftVariableList export const zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesPath = z.object({ snippet_id: z.uuid(), @@ -490,11 +490,11 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesPath = z.o /** * Environment variables retrieved successfully */ -export const zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse - = zEnvironmentVariableListResponse +export const zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse = + zEnvironmentVariableListResponse -export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunBody - = zSnippetIterationNodeRunPayload +export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunBody = + zSnippetIterationNodeRunPayload export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunPath = z.object({ node_id: z.string(), @@ -504,11 +504,11 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunPath /** * Iteration node run started successfully (SSE stream) */ -export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse - = zGeneratedAppResponse +export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse = + zGeneratedAppResponse -export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunBody - = zSnippetLoopNodeRunPayload +export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunBody = + zSnippetLoopNodeRunPayload export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunPath = z.object({ node_id: z.string(), @@ -518,8 +518,8 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunPath = z. /** * Loop node run started successfully (SSE stream) */ -export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse - = zGeneratedAppResponse +export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse = + zGeneratedAppResponse export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ node_id: z.string(), @@ -529,11 +529,11 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunPath = z.o /** * Node last run retrieved successfully */ -export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse - = zWorkflowRunNodeExecutionResponse +export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse = + zWorkflowRunNodeExecutionResponse -export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunBody - = zSnippetDraftNodeRunPayload +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunBody = + zSnippetDraftNodeRunPayload export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunPath = z.object({ node_id: z.string(), @@ -543,8 +543,8 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunPath = z.obje /** * Node run completed successfully */ -export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse - = zWorkflowRunNodeExecutionResponse +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse = + zWorkflowRunNodeExecutionResponse export const zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ node_id: z.string(), @@ -564,8 +564,8 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath = z /** * Node variables retrieved successfully */ -export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse - = zWorkflowDraftVariableList +export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse = + zWorkflowDraftVariableList export const zPostSnippetsBySnippetIdWorkflowsDraftRunBody = zSnippetDraftRunPayload @@ -585,8 +585,8 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesPath = z.object /** * System variables retrieved successfully */ -export const zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse - = zWorkflowDraftVariableList +export const zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse = + zWorkflowDraftVariableList export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesPath = z.object({ snippet_id: z.uuid(), @@ -609,8 +609,8 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesQuery = z.object({ /** * Workflow variables retrieved successfully */ -export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesResponse - = zWorkflowDraftVariableListWithoutValue +export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesResponse = + zWorkflowDraftVariableListWithoutValue export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z.object({ snippet_id: z.uuid(), @@ -630,11 +630,11 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z. /** * Variable retrieved successfully */ -export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse - = zWorkflowDraftVariable +export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse = + zWorkflowDraftVariable -export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdBody - = zWorkflowDraftVariableUpdatePayload +export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdBody = + zWorkflowDraftVariableUpdatePayload export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z.object({ snippet_id: z.uuid(), @@ -644,8 +644,8 @@ export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = /** * Variable updated successfully */ -export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse - = zWorkflowDraftVariable +export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse = + zWorkflowDraftVariable export const zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetPath = z.object({ snippet_id: z.uuid(), diff --git a/packages/contracts/generated/api/console/spec/orpc.gen.ts b/packages/contracts/generated/api/console/spec/orpc.gen.ts index bd2e750e6d6f4c..a5bc7eaff3a4f0 100644 --- a/packages/contracts/generated/api/console/spec/orpc.gen.ts +++ b/packages/contracts/generated/api/console/spec/orpc.gen.ts @@ -1,7 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts import { oc } from '@orpc/contract' - import { zGetSpecSchemaDefinitionsResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/spec/types.gen.ts b/packages/contracts/generated/api/console/spec/types.gen.ts index 853fb2cccb130a..b72f57ce90b5bd 100644 --- a/packages/contracts/generated/api/console/spec/types.gen.ts +++ b/packages/contracts/generated/api/console/spec/types.gen.ts @@ -25,5 +25,5 @@ export type GetSpecSchemaDefinitionsResponses = { 200: SchemaDefinitionsResponse } -export type GetSpecSchemaDefinitionsResponse - = GetSpecSchemaDefinitionsResponses[keyof GetSpecSchemaDefinitionsResponses] +export type GetSpecSchemaDefinitionsResponse = + GetSpecSchemaDefinitionsResponses[keyof GetSpecSchemaDefinitionsResponses] diff --git a/packages/contracts/generated/api/console/system-features/orpc.gen.ts b/packages/contracts/generated/api/console/system-features/orpc.gen.ts index 5c0a4755852080..aa1e3a88948917 100644 --- a/packages/contracts/generated/api/console/system-features/orpc.gen.ts +++ b/packages/contracts/generated/api/console/system-features/orpc.gen.ts @@ -1,7 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts import { oc } from '@orpc/contract' - import { zGetSystemFeaturesResponse } from './zod.gen' /** @@ -18,7 +17,7 @@ import { zGetSystemFeaturesResponse } from './zod.gen' export const get = oc .route({ description: - 'Get system-wide feature configuration\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for dashboard initialization.\n\nAuthentication would create circular dependency (can\'t login without dashboard loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint.', + "Get system-wide feature configuration\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for dashboard initialization.\n\nAuthentication would create circular dependency (can't login without dashboard loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint.", inputStructure: 'detailed', method: 'GET', operationId: 'getSystemFeatures', diff --git a/packages/contracts/generated/api/console/system-features/types.gen.ts b/packages/contracts/generated/api/console/system-features/types.gen.ts index a510faa22da30c..a191d5f596db6f 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -69,11 +69,11 @@ export type LicenseLimitationModel = { size: number } -export type PluginInstallationScope - = | 'all' - | 'none' - | 'official_and_specific_partners' - | 'official_only' +export type PluginInstallationScope = + | 'all' + | 'none' + | 'official_and_specific_partners' + | 'official_only' export type WebAppAuthSsoModel = { protocol: string diff --git a/packages/contracts/generated/api/console/tag-bindings/orpc.gen.ts b/packages/contracts/generated/api/console/tag-bindings/orpc.gen.ts index a3c7f5ed88ff13..2036b447222219 100644 --- a/packages/contracts/generated/api/console/tag-bindings/orpc.gen.ts +++ b/packages/contracts/generated/api/console/tag-bindings/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zPostTagBindingsBody, zPostTagBindingsRemoveBody, diff --git a/packages/contracts/generated/api/console/tag-bindings/types.gen.ts b/packages/contracts/generated/api/console/tag-bindings/types.gen.ts index 967be7d0f482d2..2470d7f120007d 100644 --- a/packages/contracts/generated/api/console/tag-bindings/types.gen.ts +++ b/packages/contracts/generated/api/console/tag-bindings/types.gen.ts @@ -46,5 +46,5 @@ export type PostTagBindingsRemoveResponses = { 200: SimpleResultResponse } -export type PostTagBindingsRemoveResponse - = PostTagBindingsRemoveResponses[keyof PostTagBindingsRemoveResponses] +export type PostTagBindingsRemoveResponse = + PostTagBindingsRemoveResponses[keyof PostTagBindingsRemoveResponses] diff --git a/packages/contracts/generated/api/console/tags/orpc.gen.ts b/packages/contracts/generated/api/console/tags/orpc.gen.ts index 6e3fc681aa6594..f315d1272e5f99 100644 --- a/packages/contracts/generated/api/console/tags/orpc.gen.ts +++ b/packages/contracts/generated/api/console/tags/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteTagsByTagIdPath, zDeleteTagsByTagIdResponse, diff --git a/packages/contracts/generated/api/console/test/orpc.gen.ts b/packages/contracts/generated/api/console/test/orpc.gen.ts index 1bdf526b70cb0a..7c7929f1cb3486 100644 --- a/packages/contracts/generated/api/console/test/orpc.gen.ts +++ b/packages/contracts/generated/api/console/test/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zPostTestRetrievalBody, zPostTestRetrievalResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts b/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts index ebc2624fa19e2a..69617d3c90e80b 100644 --- a/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetTrialAppsByAppIdDatasetsPath, zGetTrialAppsByAppIdDatasetsQuery, diff --git a/packages/contracts/generated/api/console/trial-apps/types.gen.ts b/packages/contracts/generated/api/console/trial-apps/types.gen.ts index 97c4400f1de7b6..ac0819e12c4690 100644 --- a/packages/contracts/generated/api/console/trial-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/types.gen.ts @@ -295,8 +295,8 @@ export type GetTrialAppsByAppIdResponses = { 200: TrialAppDetailResponse } -export type GetTrialAppsByAppIdResponse - = GetTrialAppsByAppIdResponses[keyof GetTrialAppsByAppIdResponses] +export type GetTrialAppsByAppIdResponse = + GetTrialAppsByAppIdResponses[keyof GetTrialAppsByAppIdResponses] export type PostTrialAppsByAppIdAudioToTextData = { body?: never @@ -311,8 +311,8 @@ export type PostTrialAppsByAppIdAudioToTextResponses = { 200: AudioTranscriptResponse } -export type PostTrialAppsByAppIdAudioToTextResponse - = PostTrialAppsByAppIdAudioToTextResponses[keyof PostTrialAppsByAppIdAudioToTextResponses] +export type PostTrialAppsByAppIdAudioToTextResponse = + PostTrialAppsByAppIdAudioToTextResponses[keyof PostTrialAppsByAppIdAudioToTextResponses] export type PostTrialAppsByAppIdChatMessagesData = { body: ChatRequest @@ -329,8 +329,8 @@ export type PostTrialAppsByAppIdChatMessagesResponses = { } } -export type PostTrialAppsByAppIdChatMessagesResponse - = PostTrialAppsByAppIdChatMessagesResponses[keyof PostTrialAppsByAppIdChatMessagesResponses] +export type PostTrialAppsByAppIdChatMessagesResponse = + PostTrialAppsByAppIdChatMessagesResponses[keyof PostTrialAppsByAppIdChatMessagesResponses] export type PostTrialAppsByAppIdCompletionMessagesData = { body: CompletionRequest @@ -347,8 +347,8 @@ export type PostTrialAppsByAppIdCompletionMessagesResponses = { } } -export type PostTrialAppsByAppIdCompletionMessagesResponse - = PostTrialAppsByAppIdCompletionMessagesResponses[keyof PostTrialAppsByAppIdCompletionMessagesResponses] +export type PostTrialAppsByAppIdCompletionMessagesResponse = + PostTrialAppsByAppIdCompletionMessagesResponses[keyof PostTrialAppsByAppIdCompletionMessagesResponses] export type GetTrialAppsByAppIdDatasetsData = { body?: never @@ -367,8 +367,8 @@ export type GetTrialAppsByAppIdDatasetsResponses = { 200: TrialDatasetListResponse } -export type GetTrialAppsByAppIdDatasetsResponse - = GetTrialAppsByAppIdDatasetsResponses[keyof GetTrialAppsByAppIdDatasetsResponses] +export type GetTrialAppsByAppIdDatasetsResponse = + GetTrialAppsByAppIdDatasetsResponses[keyof GetTrialAppsByAppIdDatasetsResponses] export type GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsData = { body?: never @@ -384,8 +384,8 @@ export type GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponses = 200: SuggestedQuestionsResponse } -export type GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse - = GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponses[keyof GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponses] +export type GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse = + GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponses[keyof GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponses] export type GetTrialAppsByAppIdParametersData = { body?: never @@ -400,8 +400,8 @@ export type GetTrialAppsByAppIdParametersResponses = { 200: Parameters } -export type GetTrialAppsByAppIdParametersResponse - = GetTrialAppsByAppIdParametersResponses[keyof GetTrialAppsByAppIdParametersResponses] +export type GetTrialAppsByAppIdParametersResponse = + GetTrialAppsByAppIdParametersResponses[keyof GetTrialAppsByAppIdParametersResponses] export type GetTrialAppsByAppIdSiteData = { body?: never @@ -416,8 +416,8 @@ export type GetTrialAppsByAppIdSiteResponses = { 200: Site } -export type GetTrialAppsByAppIdSiteResponse - = GetTrialAppsByAppIdSiteResponses[keyof GetTrialAppsByAppIdSiteResponses] +export type GetTrialAppsByAppIdSiteResponse = + GetTrialAppsByAppIdSiteResponses[keyof GetTrialAppsByAppIdSiteResponses] export type PostTrialAppsByAppIdTextToAudioData = { body: TextToSpeechRequest @@ -432,8 +432,8 @@ export type PostTrialAppsByAppIdTextToAudioResponses = { 200: AudioBinaryResponse } -export type PostTrialAppsByAppIdTextToAudioResponse - = PostTrialAppsByAppIdTextToAudioResponses[keyof PostTrialAppsByAppIdTextToAudioResponses] +export type PostTrialAppsByAppIdTextToAudioResponse = + PostTrialAppsByAppIdTextToAudioResponses[keyof PostTrialAppsByAppIdTextToAudioResponses] export type GetTrialAppsByAppIdWorkflowsData = { body?: never @@ -448,8 +448,8 @@ export type GetTrialAppsByAppIdWorkflowsResponses = { 200: TrialWorkflowResponse } -export type GetTrialAppsByAppIdWorkflowsResponse - = GetTrialAppsByAppIdWorkflowsResponses[keyof GetTrialAppsByAppIdWorkflowsResponses] +export type GetTrialAppsByAppIdWorkflowsResponse = + GetTrialAppsByAppIdWorkflowsResponses[keyof GetTrialAppsByAppIdWorkflowsResponses] export type PostTrialAppsByAppIdWorkflowsRunData = { body: WorkflowRunRequest @@ -466,8 +466,8 @@ export type PostTrialAppsByAppIdWorkflowsRunResponses = { } } -export type PostTrialAppsByAppIdWorkflowsRunResponse - = PostTrialAppsByAppIdWorkflowsRunResponses[keyof PostTrialAppsByAppIdWorkflowsRunResponses] +export type PostTrialAppsByAppIdWorkflowsRunResponse = + PostTrialAppsByAppIdWorkflowsRunResponses[keyof PostTrialAppsByAppIdWorkflowsRunResponses] export type PostTrialAppsByAppIdWorkflowsTasksByTaskIdStopData = { body?: never @@ -483,5 +483,5 @@ export type PostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponses = { 200: SimpleResultResponse } -export type PostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponse - = PostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponses[keyof PostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponses] +export type PostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponse = + PostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponses[keyof PostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponses] diff --git a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts index 35cab33cabf27a..a4dd395f92c831 100644 --- a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts @@ -411,8 +411,8 @@ export const zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsPath = z.o /** * Success */ -export const zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse - = zSuggestedQuestionsResponse +export const zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse = + zSuggestedQuestionsResponse export const zGetTrialAppsByAppIdParametersPath = z.object({ app_id: z.uuid(), diff --git a/packages/contracts/generated/api/console/trial-models/orpc.gen.ts b/packages/contracts/generated/api/console/trial-models/orpc.gen.ts index f4f4af9a3b08db..e0aa34bf478e13 100644 --- a/packages/contracts/generated/api/console/trial-models/orpc.gen.ts +++ b/packages/contracts/generated/api/console/trial-models/orpc.gen.ts @@ -1,7 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts import { oc } from '@orpc/contract' - import { zGetTrialModelsResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/version/orpc.gen.ts b/packages/contracts/generated/api/console/version/orpc.gen.ts index c93eb911e910a6..661d1a9b3cb050 100644 --- a/packages/contracts/generated/api/console/version/orpc.gen.ts +++ b/packages/contracts/generated/api/console/version/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetVersionQuery, zGetVersionResponse } from './zod.gen' /** diff --git a/packages/contracts/generated/api/console/website/orpc.gen.ts b/packages/contracts/generated/api/console/website/orpc.gen.ts index 698f6569678a6a..ed6e287f985276 100644 --- a/packages/contracts/generated/api/console/website/orpc.gen.ts +++ b/packages/contracts/generated/api/console/website/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetWebsiteCrawlStatusByJobIdPath, zGetWebsiteCrawlStatusByJobIdQuery, diff --git a/packages/contracts/generated/api/console/website/types.gen.ts b/packages/contracts/generated/api/console/website/types.gen.ts index 79640ae6f69569..4b3b284c1e5e17 100644 --- a/packages/contracts/generated/api/console/website/types.gen.ts +++ b/packages/contracts/generated/api/console/website/types.gen.ts @@ -53,5 +53,5 @@ export type GetWebsiteCrawlStatusByJobIdResponses = { 200: WebsiteCrawlResponse } -export type GetWebsiteCrawlStatusByJobIdResponse - = GetWebsiteCrawlStatusByJobIdResponses[keyof GetWebsiteCrawlStatusByJobIdResponses] +export type GetWebsiteCrawlStatusByJobIdResponse = + GetWebsiteCrawlStatusByJobIdResponses[keyof GetWebsiteCrawlStatusByJobIdResponses] diff --git a/packages/contracts/generated/api/console/workflow-generate/orpc.gen.ts b/packages/contracts/generated/api/console/workflow-generate/orpc.gen.ts index 3cea46fed05f42..8887d7f61011fb 100644 --- a/packages/contracts/generated/api/console/workflow-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workflow-generate/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zPostWorkflowGenerateBody, zPostWorkflowGenerateResponse, diff --git a/packages/contracts/generated/api/console/workflow-generate/types.gen.ts b/packages/contracts/generated/api/console/workflow-generate/types.gen.ts index d8bb2f2954b397..9aa10fd3eeecb8 100644 --- a/packages/contracts/generated/api/console/workflow-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/workflow-generate/types.gen.ts @@ -49,8 +49,8 @@ export type PostWorkflowGenerateResponses = { 200: GeneratorResponse } -export type PostWorkflowGenerateResponse - = PostWorkflowGenerateResponses[keyof PostWorkflowGenerateResponses] +export type PostWorkflowGenerateResponse = + PostWorkflowGenerateResponses[keyof PostWorkflowGenerateResponses] export type PostWorkflowGenerateStreamData = { body: WorkflowGeneratePayload @@ -69,8 +69,8 @@ export type PostWorkflowGenerateStreamResponses = { } } -export type PostWorkflowGenerateStreamResponse - = PostWorkflowGenerateStreamResponses[keyof PostWorkflowGenerateStreamResponses] +export type PostWorkflowGenerateStreamResponse = + PostWorkflowGenerateStreamResponses[keyof PostWorkflowGenerateStreamResponses] export type PostWorkflowGenerateSuggestionsData = { body: WorkflowInstructionSuggestionsPayload @@ -87,5 +87,5 @@ export type PostWorkflowGenerateSuggestionsResponses = { 200: GeneratorResponse } -export type PostWorkflowGenerateSuggestionsResponse - = PostWorkflowGenerateSuggestionsResponses[keyof PostWorkflowGenerateSuggestionsResponses] +export type PostWorkflowGenerateSuggestionsResponse = + PostWorkflowGenerateSuggestionsResponses[keyof PostWorkflowGenerateSuggestionsResponses] diff --git a/packages/contracts/generated/api/console/workflow/orpc.gen.ts b/packages/contracts/generated/api/console/workflow/orpc.gen.ts index 66f3e74a48d847..ec96e7bb9038a5 100644 --- a/packages/contracts/generated/api/console/workflow/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workflow/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zGetWorkflowByWorkflowRunIdEventsPath, zGetWorkflowByWorkflowRunIdEventsResponse, diff --git a/packages/contracts/generated/api/console/workflow/types.gen.ts b/packages/contracts/generated/api/console/workflow/types.gen.ts index bf0e0464a89ef6..efd2b9b9c84542 100644 --- a/packages/contracts/generated/api/console/workflow/types.gen.ts +++ b/packages/contracts/generated/api/console/workflow/types.gen.ts @@ -36,8 +36,8 @@ export type GetWorkflowByWorkflowRunIdEventsResponses = { 200: EventStreamResponse } -export type GetWorkflowByWorkflowRunIdEventsResponse - = GetWorkflowByWorkflowRunIdEventsResponses[keyof GetWorkflowByWorkflowRunIdEventsResponses] +export type GetWorkflowByWorkflowRunIdEventsResponse = + GetWorkflowByWorkflowRunIdEventsResponses[keyof GetWorkflowByWorkflowRunIdEventsResponses] export type GetWorkflowByWorkflowRunIdPauseDetailsData = { body?: never @@ -56,5 +56,5 @@ export type GetWorkflowByWorkflowRunIdPauseDetailsResponses = { 200: WorkflowPauseDetailsResponse } -export type GetWorkflowByWorkflowRunIdPauseDetailsResponse - = GetWorkflowByWorkflowRunIdPauseDetailsResponses[keyof GetWorkflowByWorkflowRunIdPauseDetailsResponses] +export type GetWorkflowByWorkflowRunIdPauseDetailsResponse = + GetWorkflowByWorkflowRunIdPauseDetailsResponses[keyof GetWorkflowByWorkflowRunIdPauseDetailsResponses] diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 12a20a1ca007e1..d06495b1e314b9 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdPath, zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse, @@ -3973,7 +3972,7 @@ export const get87 = oc method: 'GET', operationId: 'getWorkspacesCurrentTriggerProviderByProviderSubscriptionsList', path: '/workspaces/current/trigger-provider/{provider}/subscriptions/list', - summary: 'List all trigger subscriptions for the current tenant\'s provider', + summary: "List all trigger subscriptions for the current tenant's provider", tags: ['console'], }) .input(z.object({ params: zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListPath })) diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index f1874d56ff83fd..06fccf2e9fb27c 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -734,13 +734,13 @@ export type ToolProviderApiEntityResponse = { icon: | string | { - [key: string]: string - } + [key: string]: string + } icon_dark?: | string | { - [key: string]: string - } + [key: string]: string + } id: string identity_mode?: string is_dynamic_registration?: boolean @@ -1220,13 +1220,13 @@ export type PluginAutoUpgradeSettingsPayload = { upgrade_time_of_day?: number } -export type TenantPluginAutoUpgradeCategory - = | 'agent-strategy' - | 'datasource' - | 'extension' - | 'model' - | 'tool' - | 'trigger' +export type TenantPluginAutoUpgradeCategory = + | 'agent-strategy' + | 'datasource' + | 'extension' + | 'model' + | 'tool' + | 'trigger' export type PluginAutoUpgradeSettingsResponseModel = { exclude_plugins: Array<string> @@ -1345,13 +1345,13 @@ export type PluginCategoryBuiltinToolProviderResponse = { icon: | string | { - [key: string]: string - } + [key: string]: string + } icon_dark: | string | { - [key: string]: string - } + [key: string]: string + } | null id: string is_team_authorization: boolean @@ -1558,14 +1558,14 @@ export type I18nObject = { zh_Hans?: string | null } -export type ToolProviderType - = | 'api' - | 'app' - | 'builtin' - | 'dataset-retrieval' - | 'mcp' - | 'plugin' - | 'workflow' +export type ToolProviderType = + | 'api' + | 'app' + | 'builtin' + | 'dataset-retrieval' + | 'mcp' + | 'plugin' + | 'workflow' export type IdentityMode = 'idp_token' | 'off' @@ -1691,40 +1691,40 @@ export type SystemConfigurationResponse = { quota_configurations?: Array<QuotaConfiguration> } -export type ModelFeature - = | 'agent-thought' - | 'audio' - | 'document' - | 'multi-tool-call' - | 'polling' - | 'stream-tool-call' - | 'structured-output' - | 'tool-call' - | 'video' - | 'vision' +export type ModelFeature = + | 'agent-thought' + | 'audio' + | 'document' + | 'multi-tool-call' + | 'polling' + | 'stream-tool-call' + | 'structured-output' + | 'tool-call' + | 'video' + | 'vision' export type FetchFrom = 'customizable-model' | 'predefined-model' -export type ModelPropertyKey - = | 'audio_type' - | 'context_size' - | 'default_voice' - | 'file_upload_limit' - | 'max_characters_per_chunk' - | 'max_chunks' - | 'max_workers' - | 'mode' - | 'supported_file_extensions' - | 'voices' - | 'word_limit' - -export type ModelStatus - = | 'active' - | 'credential-removed' - | 'disabled' - | 'no-configure' - | 'no-permission' - | 'quota-exceeded' +export type ModelPropertyKey = + | 'audio_type' + | 'context_size' + | 'default_voice' + | 'file_upload_limit' + | 'max_characters_per_chunk' + | 'max_chunks' + | 'max_workers' + | 'mode' + | 'supported_file_extensions' + | 'voices' + | 'word_limit' + +export type ModelStatus = + | 'active' + | 'credential-removed' + | 'disabled' + | 'no-configure' + | 'no-permission' + | 'quota-exceeded' export type ModelLoadBalancingConfigResponse = { credential_id?: string | null @@ -1771,13 +1771,13 @@ export type AgentStrategyProviderEntity = { plugin_id?: string | null } -export type PluginCategory - = | 'agent-strategy' - | 'datasource' - | 'extension' - | 'model' - | 'tool' - | 'trigger' +export type PluginCategory = + | 'agent-strategy' + | 'datasource' + | 'extension' + | 'model' + | 'tool' + | 'trigger' export type DatasourceProviderEntity = { credentials_schema?: Array<ProviderConfig> @@ -1949,8 +1949,8 @@ export type ToolParameter = { | boolean | Array<unknown> | { - [key: string]: unknown - } + [key: string]: unknown + } | null form: ToolParameterForm human_description?: I18nObject | null @@ -1978,25 +1978,25 @@ export type Option = { export type AppSelectorScope = 'all' | 'chat' | 'completion' | 'workflow' -export type ModelSelectorScope - = | 'llm' - | 'moderation' - | 'rerank' - | 'speech2text' - | 'text-embedding' - | 'tts' - | 'vision' +export type ModelSelectorScope = + | 'llm' + | 'moderation' + | 'rerank' + | 'speech2text' + | 'text-embedding' + | 'tts' + | 'vision' export type ToolSelectorScope = 'all' | 'builtin' | 'custom' | 'workflow' -export type ProviderConfigType - = | 'app-selector' - | 'array[tools]' - | 'boolean' - | 'model-selector' - | 'secret-input' - | 'select' - | 'text-input' +export type ProviderConfigType = + | 'app-selector' + | 'array[tools]' + | 'boolean' + | 'model-selector' + | 'secret-input' + | 'select' + | 'text-input' export type ToolParameterForm = 'form' | 'llm' | 'schema' @@ -2127,11 +2127,11 @@ export type DatasourceProviderIdentity = { tags?: Array<ToolLabelEnum> | null } -export type DatasourceProviderType - = | 'local_file' - | 'online_document' - | 'online_drive' - | 'website_crawl' +export type DatasourceProviderType = + | 'local_file' + | 'online_document' + | 'online_drive' + | 'website_crawl' export type EndpointDeclaration = { hidden?: boolean @@ -2217,36 +2217,36 @@ export type PluginParameterTemplate = { enabled?: boolean } -export type ToolParameterType - = | 'any' - | 'app-selector' - | 'array' - | 'boolean' - | 'checkbox' - | 'dynamic-select' - | 'file' - | 'files' - | 'model-selector' - | 'number' - | 'object' - | 'secret-input' - | 'select' - | 'string' - | 'system-files' - -export type EventParameterType - = | 'app-selector' - | 'array' - | 'boolean' - | 'checkbox' - | 'dynamic-select' - | 'file' - | 'files' - | 'model-selector' - | 'number' - | 'object' - | 'select' - | 'string' +export type ToolParameterType = + | 'any' + | 'app-selector' + | 'array' + | 'boolean' + | 'checkbox' + | 'dynamic-select' + | 'file' + | 'files' + | 'model-selector' + | 'number' + | 'object' + | 'secret-input' + | 'select' + | 'string' + | 'system-files' + +export type EventParameterType = + | 'app-selector' + | 'array' + | 'boolean' + | 'checkbox' + | 'dynamic-select' + | 'file' + | 'files' + | 'model-selector' + | 'number' + | 'object' + | 'select' + | 'string' export type PriceConfigResponse = { currency: string @@ -2267,20 +2267,20 @@ export type EndpointProviderConfigOptionResponse = { value: string } -export type EndpointProviderConfigScope - = | 'all' - | 'builtin' - | 'chat' - | 'completion' - | 'custom' - | 'llm' - | 'moderation' - | 'rerank' - | 'speech2text' - | 'text-embedding' - | 'tts' - | 'vision' - | 'workflow' +export type EndpointProviderConfigScope = + | 'all' + | 'builtin' + | 'chat' + | 'completion' + | 'custom' + | 'llm' + | 'moderation' + | 'rerank' + | 'speech2text' + | 'text-embedding' + | 'tts' + | 'vision' + | 'workflow' export type FormOption = { label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject @@ -2303,24 +2303,24 @@ export type RestrictModel = { model_type: ModelType } -export type ToolLabelEnum - = | 'business' - | 'design' - | 'education' - | 'entertainment' - | 'finance' - | 'image' - | 'medical' - | 'news' - | 'other' - | 'productivity' - | 'rag' - | 'search' - | 'social' - | 'travel' - | 'utilities' - | 'videos' - | 'weather' +export type ToolLabelEnum = + | 'business' + | 'design' + | 'education' + | 'entertainment' + | 'finance' + | 'image' + | 'medical' + | 'news' + | 'other' + | 'productivity' + | 'rag' + | 'search' + | 'social' + | 'travel' + | 'utilities' + | 'videos' + | 'weather' export type PriceConfig = { currency: string @@ -2401,8 +2401,8 @@ export type PostWorkspacesCurrentResponses = { 200: TenantInfoResponse } -export type PostWorkspacesCurrentResponse - = PostWorkspacesCurrentResponses[keyof PostWorkspacesCurrentResponses] +export type PostWorkspacesCurrentResponse = + PostWorkspacesCurrentResponses[keyof PostWorkspacesCurrentResponses] export type GetWorkspacesCurrentAgentProviderByProviderNameData = { body?: never @@ -2417,8 +2417,8 @@ export type GetWorkspacesCurrentAgentProviderByProviderNameResponses = { 200: AgentProviderResponse } -export type GetWorkspacesCurrentAgentProviderByProviderNameResponse - = GetWorkspacesCurrentAgentProviderByProviderNameResponses[keyof GetWorkspacesCurrentAgentProviderByProviderNameResponses] +export type GetWorkspacesCurrentAgentProviderByProviderNameResponse = + GetWorkspacesCurrentAgentProviderByProviderNameResponses[keyof GetWorkspacesCurrentAgentProviderByProviderNameResponses] export type GetWorkspacesCurrentAgentProvidersData = { body?: never @@ -2431,8 +2431,8 @@ export type GetWorkspacesCurrentAgentProvidersResponses = { 200: AgentProviderListResponse } -export type GetWorkspacesCurrentAgentProvidersResponse - = GetWorkspacesCurrentAgentProvidersResponses[keyof GetWorkspacesCurrentAgentProvidersResponses] +export type GetWorkspacesCurrentAgentProvidersResponse = + GetWorkspacesCurrentAgentProvidersResponses[keyof GetWorkspacesCurrentAgentProvidersResponses] export type GetWorkspacesCurrentCustomizedSnippetsData = { body?: never @@ -2452,8 +2452,8 @@ export type GetWorkspacesCurrentCustomizedSnippetsResponses = { 200: SnippetPaginationResponse } -export type GetWorkspacesCurrentCustomizedSnippetsResponse - = GetWorkspacesCurrentCustomizedSnippetsResponses[keyof GetWorkspacesCurrentCustomizedSnippetsResponses] +export type GetWorkspacesCurrentCustomizedSnippetsResponse = + GetWorkspacesCurrentCustomizedSnippetsResponses[keyof GetWorkspacesCurrentCustomizedSnippetsResponses] export type PostWorkspacesCurrentCustomizedSnippetsData = { body: CreateSnippetPayload @@ -2470,8 +2470,8 @@ export type PostWorkspacesCurrentCustomizedSnippetsResponses = { 201: SnippetResponse } -export type PostWorkspacesCurrentCustomizedSnippetsResponse - = PostWorkspacesCurrentCustomizedSnippetsResponses[keyof PostWorkspacesCurrentCustomizedSnippetsResponses] +export type PostWorkspacesCurrentCustomizedSnippetsResponse = + PostWorkspacesCurrentCustomizedSnippetsResponses[keyof PostWorkspacesCurrentCustomizedSnippetsResponses] export type PostWorkspacesCurrentCustomizedSnippetsImportsData = { body: SnippetImportPayload @@ -2489,8 +2489,8 @@ export type PostWorkspacesCurrentCustomizedSnippetsImportsResponses = { 202: SnippetImportResponse } -export type PostWorkspacesCurrentCustomizedSnippetsImportsResponse - = PostWorkspacesCurrentCustomizedSnippetsImportsResponses[keyof PostWorkspacesCurrentCustomizedSnippetsImportsResponses] +export type PostWorkspacesCurrentCustomizedSnippetsImportsResponse = + PostWorkspacesCurrentCustomizedSnippetsImportsResponses[keyof PostWorkspacesCurrentCustomizedSnippetsImportsResponses] export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmData = { body?: never @@ -2509,8 +2509,8 @@ export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmRespo 200: SnippetImportResponse } -export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse - = PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponses[keyof PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponses] +export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse = + PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponses[keyof PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponses] export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdData = { body?: never @@ -2529,8 +2529,8 @@ export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses = { 204: void } -export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse - = DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses[keyof DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses] +export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = + DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses[keyof DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses] export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdData = { body?: never @@ -2549,8 +2549,8 @@ export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses = { 200: SnippetResponse } -export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse - = GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses] +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = + GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses] export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdData = { body: UpdateSnippetPayload @@ -2570,8 +2570,8 @@ export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses = { 200: SnippetResponse } -export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse - = PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses[keyof PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses] +export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = + PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses[keyof PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses] export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesData = { body?: never @@ -2590,8 +2590,8 @@ export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesRe 200: SnippetDependencyCheckResponse } -export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse - = GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponses[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponses] +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse = + GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponses[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponses] export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportData = { body?: never @@ -2612,8 +2612,8 @@ export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponses = { 200: TextFileResponse } -export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse - = GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponses[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponses] +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse = + GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponses[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponses] export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementData = { body?: never @@ -2632,8 +2632,8 @@ export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementR 200: SnippetUseCountResponse } -export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponse - = PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponses[keyof PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponses] +export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponse = + PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponses[keyof PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponses] export type GetWorkspacesCurrentDatasetOperatorsData = { body?: never @@ -2646,8 +2646,8 @@ export type GetWorkspacesCurrentDatasetOperatorsResponses = { 200: AccountWithRoleListResponse } -export type GetWorkspacesCurrentDatasetOperatorsResponse - = GetWorkspacesCurrentDatasetOperatorsResponses[keyof GetWorkspacesCurrentDatasetOperatorsResponses] +export type GetWorkspacesCurrentDatasetOperatorsResponse = + GetWorkspacesCurrentDatasetOperatorsResponses[keyof GetWorkspacesCurrentDatasetOperatorsResponses] export type GetWorkspacesCurrentDefaultModelData = { body?: never @@ -2662,8 +2662,8 @@ export type GetWorkspacesCurrentDefaultModelResponses = { 200: DefaultModelDataResponse } -export type GetWorkspacesCurrentDefaultModelResponse - = GetWorkspacesCurrentDefaultModelResponses[keyof GetWorkspacesCurrentDefaultModelResponses] +export type GetWorkspacesCurrentDefaultModelResponse = + GetWorkspacesCurrentDefaultModelResponses[keyof GetWorkspacesCurrentDefaultModelResponses] export type PostWorkspacesCurrentDefaultModelData = { body: ParserPostDefault @@ -2676,8 +2676,8 @@ export type PostWorkspacesCurrentDefaultModelResponses = { 200: SimpleResultResponse } -export type PostWorkspacesCurrentDefaultModelResponse - = PostWorkspacesCurrentDefaultModelResponses[keyof PostWorkspacesCurrentDefaultModelResponses] +export type PostWorkspacesCurrentDefaultModelResponse = + PostWorkspacesCurrentDefaultModelResponses[keyof PostWorkspacesCurrentDefaultModelResponses] export type PostWorkspacesCurrentEndpointsData = { body: EndpointCreatePayload @@ -2694,8 +2694,8 @@ export type PostWorkspacesCurrentEndpointsResponses = { 200: SuccessResponse } -export type PostWorkspacesCurrentEndpointsResponse - = PostWorkspacesCurrentEndpointsResponses[keyof PostWorkspacesCurrentEndpointsResponses] +export type PostWorkspacesCurrentEndpointsResponse = + PostWorkspacesCurrentEndpointsResponses[keyof PostWorkspacesCurrentEndpointsResponses] export type PostWorkspacesCurrentEndpointsCreateData = { body: EndpointCreatePayload @@ -2712,8 +2712,8 @@ export type PostWorkspacesCurrentEndpointsCreateResponses = { 200: SuccessResponse } -export type PostWorkspacesCurrentEndpointsCreateResponse - = PostWorkspacesCurrentEndpointsCreateResponses[keyof PostWorkspacesCurrentEndpointsCreateResponses] +export type PostWorkspacesCurrentEndpointsCreateResponse = + PostWorkspacesCurrentEndpointsCreateResponses[keyof PostWorkspacesCurrentEndpointsCreateResponses] export type PostWorkspacesCurrentEndpointsDeleteData = { body: EndpointIdPayload @@ -2730,8 +2730,8 @@ export type PostWorkspacesCurrentEndpointsDeleteResponses = { 200: SuccessResponse } -export type PostWorkspacesCurrentEndpointsDeleteResponse - = PostWorkspacesCurrentEndpointsDeleteResponses[keyof PostWorkspacesCurrentEndpointsDeleteResponses] +export type PostWorkspacesCurrentEndpointsDeleteResponse = + PostWorkspacesCurrentEndpointsDeleteResponses[keyof PostWorkspacesCurrentEndpointsDeleteResponses] export type PostWorkspacesCurrentEndpointsDisableData = { body: EndpointIdPayload @@ -2748,8 +2748,8 @@ export type PostWorkspacesCurrentEndpointsDisableResponses = { 200: SuccessResponse } -export type PostWorkspacesCurrentEndpointsDisableResponse - = PostWorkspacesCurrentEndpointsDisableResponses[keyof PostWorkspacesCurrentEndpointsDisableResponses] +export type PostWorkspacesCurrentEndpointsDisableResponse = + PostWorkspacesCurrentEndpointsDisableResponses[keyof PostWorkspacesCurrentEndpointsDisableResponses] export type PostWorkspacesCurrentEndpointsEnableData = { body: EndpointIdPayload @@ -2766,8 +2766,8 @@ export type PostWorkspacesCurrentEndpointsEnableResponses = { 200: SuccessResponse } -export type PostWorkspacesCurrentEndpointsEnableResponse - = PostWorkspacesCurrentEndpointsEnableResponses[keyof PostWorkspacesCurrentEndpointsEnableResponses] +export type PostWorkspacesCurrentEndpointsEnableResponse = + PostWorkspacesCurrentEndpointsEnableResponses[keyof PostWorkspacesCurrentEndpointsEnableResponses] export type GetWorkspacesCurrentEndpointsListData = { body?: never @@ -2783,8 +2783,8 @@ export type GetWorkspacesCurrentEndpointsListResponses = { 200: EndpointListResponse } -export type GetWorkspacesCurrentEndpointsListResponse - = GetWorkspacesCurrentEndpointsListResponses[keyof GetWorkspacesCurrentEndpointsListResponses] +export type GetWorkspacesCurrentEndpointsListResponse = + GetWorkspacesCurrentEndpointsListResponses[keyof GetWorkspacesCurrentEndpointsListResponses] export type GetWorkspacesCurrentEndpointsListPluginData = { body?: never @@ -2801,8 +2801,8 @@ export type GetWorkspacesCurrentEndpointsListPluginResponses = { 200: EndpointListResponse } -export type GetWorkspacesCurrentEndpointsListPluginResponse - = GetWorkspacesCurrentEndpointsListPluginResponses[keyof GetWorkspacesCurrentEndpointsListPluginResponses] +export type GetWorkspacesCurrentEndpointsListPluginResponse = + GetWorkspacesCurrentEndpointsListPluginResponses[keyof GetWorkspacesCurrentEndpointsListPluginResponses] export type PostWorkspacesCurrentEndpointsUpdateData = { body: LegacyEndpointUpdatePayload @@ -2819,8 +2819,8 @@ export type PostWorkspacesCurrentEndpointsUpdateResponses = { 200: SuccessResponse } -export type PostWorkspacesCurrentEndpointsUpdateResponse - = PostWorkspacesCurrentEndpointsUpdateResponses[keyof PostWorkspacesCurrentEndpointsUpdateResponses] +export type PostWorkspacesCurrentEndpointsUpdateResponse = + PostWorkspacesCurrentEndpointsUpdateResponses[keyof PostWorkspacesCurrentEndpointsUpdateResponses] export type DeleteWorkspacesCurrentEndpointsByIdData = { body?: never @@ -2839,8 +2839,8 @@ export type DeleteWorkspacesCurrentEndpointsByIdResponses = { 200: SuccessResponse } -export type DeleteWorkspacesCurrentEndpointsByIdResponse - = DeleteWorkspacesCurrentEndpointsByIdResponses[keyof DeleteWorkspacesCurrentEndpointsByIdResponses] +export type DeleteWorkspacesCurrentEndpointsByIdResponse = + DeleteWorkspacesCurrentEndpointsByIdResponses[keyof DeleteWorkspacesCurrentEndpointsByIdResponses] export type PatchWorkspacesCurrentEndpointsByIdData = { body: EndpointUpdatePayload @@ -2859,8 +2859,8 @@ export type PatchWorkspacesCurrentEndpointsByIdResponses = { 200: SuccessResponse } -export type PatchWorkspacesCurrentEndpointsByIdResponse - = PatchWorkspacesCurrentEndpointsByIdResponses[keyof PatchWorkspacesCurrentEndpointsByIdResponses] +export type PatchWorkspacesCurrentEndpointsByIdResponse = + PatchWorkspacesCurrentEndpointsByIdResponses[keyof PatchWorkspacesCurrentEndpointsByIdResponses] export type GetWorkspacesCurrentMembersData = { body?: never @@ -2873,8 +2873,8 @@ export type GetWorkspacesCurrentMembersResponses = { 200: AccountWithRoleListResponse } -export type GetWorkspacesCurrentMembersResponse - = GetWorkspacesCurrentMembersResponses[keyof GetWorkspacesCurrentMembersResponses] +export type GetWorkspacesCurrentMembersResponse = + GetWorkspacesCurrentMembersResponses[keyof GetWorkspacesCurrentMembersResponses] export type PostWorkspacesCurrentMembersInviteEmailData = { body: MemberInvitePayload @@ -2887,8 +2887,8 @@ export type PostWorkspacesCurrentMembersInviteEmailResponses = { 201: MemberInviteResponse } -export type PostWorkspacesCurrentMembersInviteEmailResponse - = PostWorkspacesCurrentMembersInviteEmailResponses[keyof PostWorkspacesCurrentMembersInviteEmailResponses] +export type PostWorkspacesCurrentMembersInviteEmailResponse = + PostWorkspacesCurrentMembersInviteEmailResponses[keyof PostWorkspacesCurrentMembersInviteEmailResponses] export type PostWorkspacesCurrentMembersOwnerTransferCheckData = { body: OwnerTransferCheckPayload @@ -2901,8 +2901,8 @@ export type PostWorkspacesCurrentMembersOwnerTransferCheckResponses = { 200: VerificationTokenResponse } -export type PostWorkspacesCurrentMembersOwnerTransferCheckResponse - = PostWorkspacesCurrentMembersOwnerTransferCheckResponses[keyof PostWorkspacesCurrentMembersOwnerTransferCheckResponses] +export type PostWorkspacesCurrentMembersOwnerTransferCheckResponse = + PostWorkspacesCurrentMembersOwnerTransferCheckResponses[keyof PostWorkspacesCurrentMembersOwnerTransferCheckResponses] export type PostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailData = { body: OwnerTransferEmailPayload @@ -2915,8 +2915,8 @@ export type PostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailResponses = 200: SimpleResultDataResponse } -export type PostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailResponse - = PostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailResponses[keyof PostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailResponses] +export type PostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailResponse = + PostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailResponses[keyof PostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailResponses] export type DeleteWorkspacesCurrentMembersByMemberIdData = { body?: never @@ -2931,8 +2931,8 @@ export type DeleteWorkspacesCurrentMembersByMemberIdResponses = { 200: MemberActionResponse } -export type DeleteWorkspacesCurrentMembersByMemberIdResponse - = DeleteWorkspacesCurrentMembersByMemberIdResponses[keyof DeleteWorkspacesCurrentMembersByMemberIdResponses] +export type DeleteWorkspacesCurrentMembersByMemberIdResponse = + DeleteWorkspacesCurrentMembersByMemberIdResponses[keyof DeleteWorkspacesCurrentMembersByMemberIdResponses] export type PostWorkspacesCurrentMembersByMemberIdOwnerTransferData = { body: OwnerTransferPayload @@ -2947,8 +2947,8 @@ export type PostWorkspacesCurrentMembersByMemberIdOwnerTransferResponses = { 200: SimpleResultResponse } -export type PostWorkspacesCurrentMembersByMemberIdOwnerTransferResponse - = PostWorkspacesCurrentMembersByMemberIdOwnerTransferResponses[keyof PostWorkspacesCurrentMembersByMemberIdOwnerTransferResponses] +export type PostWorkspacesCurrentMembersByMemberIdOwnerTransferResponse = + PostWorkspacesCurrentMembersByMemberIdOwnerTransferResponses[keyof PostWorkspacesCurrentMembersByMemberIdOwnerTransferResponses] export type PutWorkspacesCurrentMembersByMemberIdUpdateRoleData = { body: MemberRoleUpdatePayload @@ -2963,8 +2963,8 @@ export type PutWorkspacesCurrentMembersByMemberIdUpdateRoleResponses = { 200: SimpleResultResponse } -export type PutWorkspacesCurrentMembersByMemberIdUpdateRoleResponse - = PutWorkspacesCurrentMembersByMemberIdUpdateRoleResponses[keyof PutWorkspacesCurrentMembersByMemberIdUpdateRoleResponses] +export type PutWorkspacesCurrentMembersByMemberIdUpdateRoleResponse = + PutWorkspacesCurrentMembersByMemberIdUpdateRoleResponses[keyof PutWorkspacesCurrentMembersByMemberIdUpdateRoleResponses] export type GetWorkspacesCurrentModelProvidersData = { body?: never @@ -2979,8 +2979,8 @@ export type GetWorkspacesCurrentModelProvidersResponses = { 200: ModelProviderListResponse } -export type GetWorkspacesCurrentModelProvidersResponse - = GetWorkspacesCurrentModelProvidersResponses[keyof GetWorkspacesCurrentModelProvidersResponses] +export type GetWorkspacesCurrentModelProvidersResponse = + GetWorkspacesCurrentModelProvidersResponses[keyof GetWorkspacesCurrentModelProvidersResponses] export type GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlData = { body?: never @@ -2995,8 +2995,8 @@ export type GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponses = { 200: ModelProviderPaymentCheckoutUrlResponse } -export type GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponse - = GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponses[keyof GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponses] +export type GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponse = + GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponses[keyof GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponses] export type DeleteWorkspacesCurrentModelProvidersByProviderCredentialsData = { body: ParserCredentialDelete @@ -3011,8 +3011,8 @@ export type DeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponses 204: void } -export type DeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponse - = DeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponses[keyof DeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponses] +export type DeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponse = + DeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponses[keyof DeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponses] export type GetWorkspacesCurrentModelProvidersByProviderCredentialsData = { body?: never @@ -3029,8 +3029,8 @@ export type GetWorkspacesCurrentModelProvidersByProviderCredentialsResponses = { 200: ProviderCredentialsResponse } -export type GetWorkspacesCurrentModelProvidersByProviderCredentialsResponse - = GetWorkspacesCurrentModelProvidersByProviderCredentialsResponses[keyof GetWorkspacesCurrentModelProvidersByProviderCredentialsResponses] +export type GetWorkspacesCurrentModelProvidersByProviderCredentialsResponse = + GetWorkspacesCurrentModelProvidersByProviderCredentialsResponses[keyof GetWorkspacesCurrentModelProvidersByProviderCredentialsResponses] export type PostWorkspacesCurrentModelProvidersByProviderCredentialsData = { body: ParserCredentialCreate @@ -3045,8 +3045,8 @@ export type PostWorkspacesCurrentModelProvidersByProviderCredentialsResponses = 201: SimpleResultResponse } -export type PostWorkspacesCurrentModelProvidersByProviderCredentialsResponse - = PostWorkspacesCurrentModelProvidersByProviderCredentialsResponses[keyof PostWorkspacesCurrentModelProvidersByProviderCredentialsResponses] +export type PostWorkspacesCurrentModelProvidersByProviderCredentialsResponse = + PostWorkspacesCurrentModelProvidersByProviderCredentialsResponses[keyof PostWorkspacesCurrentModelProvidersByProviderCredentialsResponses] export type PutWorkspacesCurrentModelProvidersByProviderCredentialsData = { body: ParserCredentialUpdate @@ -3061,8 +3061,8 @@ export type PutWorkspacesCurrentModelProvidersByProviderCredentialsResponses = { 200: SimpleResultResponse } -export type PutWorkspacesCurrentModelProvidersByProviderCredentialsResponse - = PutWorkspacesCurrentModelProvidersByProviderCredentialsResponses[keyof PutWorkspacesCurrentModelProvidersByProviderCredentialsResponses] +export type PutWorkspacesCurrentModelProvidersByProviderCredentialsResponse = + PutWorkspacesCurrentModelProvidersByProviderCredentialsResponses[keyof PutWorkspacesCurrentModelProvidersByProviderCredentialsResponses] export type PostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchData = { body: ParserCredentialSwitch @@ -3077,8 +3077,8 @@ export type PostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchRespon 200: SimpleResultResponse } -export type PostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchResponse - = PostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchResponses[keyof PostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchResponses] +export type PostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchResponse = + PostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchResponses[keyof PostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchResponses] export type PostWorkspacesCurrentModelProvidersByProviderCredentialsValidateData = { body: ParserCredentialValidate @@ -3093,8 +3093,8 @@ export type PostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResp 200: ValidationResultResponse } -export type PostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponse - = PostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponses[keyof PostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponses] +export type PostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponse = + PostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponses[keyof PostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponses] export type DeleteWorkspacesCurrentModelProvidersByProviderModelsData = { body: ParserDeleteModels @@ -3109,8 +3109,8 @@ export type DeleteWorkspacesCurrentModelProvidersByProviderModelsResponses = { 204: void } -export type DeleteWorkspacesCurrentModelProvidersByProviderModelsResponse - = DeleteWorkspacesCurrentModelProvidersByProviderModelsResponses[keyof DeleteWorkspacesCurrentModelProvidersByProviderModelsResponses] +export type DeleteWorkspacesCurrentModelProvidersByProviderModelsResponse = + DeleteWorkspacesCurrentModelProvidersByProviderModelsResponses[keyof DeleteWorkspacesCurrentModelProvidersByProviderModelsResponses] export type GetWorkspacesCurrentModelProvidersByProviderModelsData = { body?: never @@ -3125,8 +3125,8 @@ export type GetWorkspacesCurrentModelProvidersByProviderModelsResponses = { 200: ProviderModelListResponse } -export type GetWorkspacesCurrentModelProvidersByProviderModelsResponse - = GetWorkspacesCurrentModelProvidersByProviderModelsResponses[keyof GetWorkspacesCurrentModelProvidersByProviderModelsResponses] +export type GetWorkspacesCurrentModelProvidersByProviderModelsResponse = + GetWorkspacesCurrentModelProvidersByProviderModelsResponses[keyof GetWorkspacesCurrentModelProvidersByProviderModelsResponses] export type PostWorkspacesCurrentModelProvidersByProviderModelsData = { body: ParserPostModels @@ -3141,8 +3141,8 @@ export type PostWorkspacesCurrentModelProvidersByProviderModelsResponses = { 200: SimpleResultResponse } -export type PostWorkspacesCurrentModelProvidersByProviderModelsResponse - = PostWorkspacesCurrentModelProvidersByProviderModelsResponses[keyof PostWorkspacesCurrentModelProvidersByProviderModelsResponses] +export type PostWorkspacesCurrentModelProvidersByProviderModelsResponse = + PostWorkspacesCurrentModelProvidersByProviderModelsResponses[keyof PostWorkspacesCurrentModelProvidersByProviderModelsResponses] export type DeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsData = { body: ParserDeleteCredential @@ -3157,8 +3157,8 @@ export type DeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResp 204: void } -export type DeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse - = DeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses[keyof DeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses] +export type DeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse = + DeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses[keyof DeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses] export type GetWorkspacesCurrentModelProvidersByProviderModelsCredentialsData = { body?: never @@ -3178,8 +3178,8 @@ export type GetWorkspacesCurrentModelProvidersByProviderModelsCredentialsRespons 200: ModelCredentialResponse } -export type GetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse - = GetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses[keyof GetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses] +export type GetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse = + GetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses[keyof GetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses] export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsData = { body: ParserCreateCredential @@ -3194,8 +3194,8 @@ export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsRespon 201: SimpleResultResponse } -export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse - = PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses[keyof PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses] +export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse = + PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses[keyof PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses] export type PutWorkspacesCurrentModelProvidersByProviderModelsCredentialsData = { body: ParserUpdateCredential @@ -3210,8 +3210,8 @@ export type PutWorkspacesCurrentModelProvidersByProviderModelsCredentialsRespons 200: SimpleResultResponse } -export type PutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse - = PutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses[keyof PutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses] +export type PutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse = + PutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses[keyof PutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses] export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchData = { body: ParserSwitch @@ -3226,8 +3226,8 @@ export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitch 200: SimpleResultResponse } -export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchResponse - = PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchResponses[keyof PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchResponses] +export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchResponse = + PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchResponses[keyof PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchResponses] export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateData = { body: ParserValidate @@ -3242,8 +3242,8 @@ export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValida 200: ValidationResultResponse } -export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponse - = PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponses[keyof PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponses] +export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponse = + PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponses[keyof PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponses] export type PatchWorkspacesCurrentModelProvidersByProviderModelsDisableData = { body: ParserDeleteModels @@ -3258,8 +3258,8 @@ export type PatchWorkspacesCurrentModelProvidersByProviderModelsDisableResponses 200: SimpleResultResponse } -export type PatchWorkspacesCurrentModelProvidersByProviderModelsDisableResponse - = PatchWorkspacesCurrentModelProvidersByProviderModelsDisableResponses[keyof PatchWorkspacesCurrentModelProvidersByProviderModelsDisableResponses] +export type PatchWorkspacesCurrentModelProvidersByProviderModelsDisableResponse = + PatchWorkspacesCurrentModelProvidersByProviderModelsDisableResponses[keyof PatchWorkspacesCurrentModelProvidersByProviderModelsDisableResponses] export type PatchWorkspacesCurrentModelProvidersByProviderModelsEnableData = { body: ParserDeleteModels @@ -3274,11 +3274,11 @@ export type PatchWorkspacesCurrentModelProvidersByProviderModelsEnableResponses 200: SimpleResultResponse } -export type PatchWorkspacesCurrentModelProvidersByProviderModelsEnableResponse - = PatchWorkspacesCurrentModelProvidersByProviderModelsEnableResponses[keyof PatchWorkspacesCurrentModelProvidersByProviderModelsEnableResponses] +export type PatchWorkspacesCurrentModelProvidersByProviderModelsEnableResponse = + PatchWorkspacesCurrentModelProvidersByProviderModelsEnableResponses[keyof PatchWorkspacesCurrentModelProvidersByProviderModelsEnableResponses] -export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateData - = { +export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateData = + { body: LoadBalancingCredentialPayload path: { provider: string @@ -3287,16 +3287,16 @@ export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConf url: '/workspaces/current/model-providers/{provider}/models/load-balancing-configs/credentials-validate' } -export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponses - = { +export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponses = + { 200: LoadBalancingCredentialValidateResponse } -export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponse - = PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponses[keyof PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponses] +export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponse = + PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponses[keyof PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponses] -export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateData - = { +export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateData = + { body: LoadBalancingCredentialPayload path: { config_id: string @@ -3306,13 +3306,13 @@ export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConf url: '/workspaces/current/model-providers/{provider}/models/load-balancing-configs/{config_id}/credentials-validate' } -export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponses - = { +export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponses = + { 200: LoadBalancingCredentialValidateResponse } -export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponse - = PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponses[keyof PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponses] +export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponse = + PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponses[keyof PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponses] export type GetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesData = { body?: never @@ -3329,8 +3329,8 @@ export type GetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResp 200: ModelParameterRuleListResponse } -export type GetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponse - = GetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponses[keyof GetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponses] +export type GetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponse = + GetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponses[keyof GetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponses] export type PostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeData = { body: ParserPreferredProviderType @@ -3345,8 +3345,8 @@ export type PostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeRe 200: SimpleResultResponse } -export type PostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponse - = PostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponses[keyof PostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponses] +export type PostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponse = + PostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponses[keyof PostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponses] export type GetWorkspacesCurrentModelsModelTypesByModelTypeData = { body?: never @@ -3361,8 +3361,8 @@ export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponses = { 200: AvailableModelListResponse } -export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponse - = GetWorkspacesCurrentModelsModelTypesByModelTypeResponses[keyof GetWorkspacesCurrentModelsModelTypesByModelTypeResponses] +export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponse = + GetWorkspacesCurrentModelsModelTypesByModelTypeResponses[keyof GetWorkspacesCurrentModelsModelTypesByModelTypeResponses] export type GetWorkspacesCurrentPermissionData = { body?: never @@ -3375,8 +3375,8 @@ export type GetWorkspacesCurrentPermissionResponses = { 200: WorkspacePermissionResponse } -export type GetWorkspacesCurrentPermissionResponse - = GetWorkspacesCurrentPermissionResponses[keyof GetWorkspacesCurrentPermissionResponses] +export type GetWorkspacesCurrentPermissionResponse = + GetWorkspacesCurrentPermissionResponses[keyof GetWorkspacesCurrentPermissionResponses] export type GetWorkspacesCurrentPluginAssetData = { body?: never @@ -3392,8 +3392,8 @@ export type GetWorkspacesCurrentPluginAssetResponses = { 200: BinaryFileResponse } -export type GetWorkspacesCurrentPluginAssetResponse - = GetWorkspacesCurrentPluginAssetResponses[keyof GetWorkspacesCurrentPluginAssetResponses] +export type GetWorkspacesCurrentPluginAssetResponse = + GetWorkspacesCurrentPluginAssetResponses[keyof GetWorkspacesCurrentPluginAssetResponses] export type PostWorkspacesCurrentPluginAutoUpgradeChangeData = { body: ParserAutoUpgradeChange @@ -3406,8 +3406,8 @@ export type PostWorkspacesCurrentPluginAutoUpgradeChangeResponses = { 200: PluginAutoUpgradeChangeResponse } -export type PostWorkspacesCurrentPluginAutoUpgradeChangeResponse - = PostWorkspacesCurrentPluginAutoUpgradeChangeResponses[keyof PostWorkspacesCurrentPluginAutoUpgradeChangeResponses] +export type PostWorkspacesCurrentPluginAutoUpgradeChangeResponse = + PostWorkspacesCurrentPluginAutoUpgradeChangeResponses[keyof PostWorkspacesCurrentPluginAutoUpgradeChangeResponses] export type PostWorkspacesCurrentPluginAutoUpgradeExcludeData = { body: ParserExcludePlugin @@ -3420,8 +3420,8 @@ export type PostWorkspacesCurrentPluginAutoUpgradeExcludeResponses = { 200: SuccessResponse } -export type PostWorkspacesCurrentPluginAutoUpgradeExcludeResponse - = PostWorkspacesCurrentPluginAutoUpgradeExcludeResponses[keyof PostWorkspacesCurrentPluginAutoUpgradeExcludeResponses] +export type PostWorkspacesCurrentPluginAutoUpgradeExcludeResponse = + PostWorkspacesCurrentPluginAutoUpgradeExcludeResponses[keyof PostWorkspacesCurrentPluginAutoUpgradeExcludeResponses] export type GetWorkspacesCurrentPluginAutoUpgradeFetchData = { body?: never @@ -3436,8 +3436,8 @@ export type GetWorkspacesCurrentPluginAutoUpgradeFetchResponses = { 200: PluginAutoUpgradeFetchResponse } -export type GetWorkspacesCurrentPluginAutoUpgradeFetchResponse - = GetWorkspacesCurrentPluginAutoUpgradeFetchResponses[keyof GetWorkspacesCurrentPluginAutoUpgradeFetchResponses] +export type GetWorkspacesCurrentPluginAutoUpgradeFetchResponse = + GetWorkspacesCurrentPluginAutoUpgradeFetchResponses[keyof GetWorkspacesCurrentPluginAutoUpgradeFetchResponses] export type GetWorkspacesCurrentPluginDebuggingKeyData = { body?: never @@ -3450,8 +3450,8 @@ export type GetWorkspacesCurrentPluginDebuggingKeyResponses = { 200: PluginDebuggingKeyResponse } -export type GetWorkspacesCurrentPluginDebuggingKeyResponse - = GetWorkspacesCurrentPluginDebuggingKeyResponses[keyof GetWorkspacesCurrentPluginDebuggingKeyResponses] +export type GetWorkspacesCurrentPluginDebuggingKeyResponse = + GetWorkspacesCurrentPluginDebuggingKeyResponses[keyof GetWorkspacesCurrentPluginDebuggingKeyResponses] export type GetWorkspacesCurrentPluginFetchManifestData = { body?: never @@ -3466,8 +3466,8 @@ export type GetWorkspacesCurrentPluginFetchManifestResponses = { 200: PluginManifestResponse } -export type GetWorkspacesCurrentPluginFetchManifestResponse - = GetWorkspacesCurrentPluginFetchManifestResponses[keyof GetWorkspacesCurrentPluginFetchManifestResponses] +export type GetWorkspacesCurrentPluginFetchManifestResponse = + GetWorkspacesCurrentPluginFetchManifestResponses[keyof GetWorkspacesCurrentPluginFetchManifestResponses] export type GetWorkspacesCurrentPluginIconData = { body?: never @@ -3483,8 +3483,8 @@ export type GetWorkspacesCurrentPluginIconResponses = { 200: BinaryFileResponse } -export type GetWorkspacesCurrentPluginIconResponse - = GetWorkspacesCurrentPluginIconResponses[keyof GetWorkspacesCurrentPluginIconResponses] +export type GetWorkspacesCurrentPluginIconResponse = + GetWorkspacesCurrentPluginIconResponses[keyof GetWorkspacesCurrentPluginIconResponses] export type PostWorkspacesCurrentPluginInstallGithubData = { body: ParserGithubInstall @@ -3497,8 +3497,8 @@ export type PostWorkspacesCurrentPluginInstallGithubResponses = { 200: PluginInstallTaskStartResponse } -export type PostWorkspacesCurrentPluginInstallGithubResponse - = PostWorkspacesCurrentPluginInstallGithubResponses[keyof PostWorkspacesCurrentPluginInstallGithubResponses] +export type PostWorkspacesCurrentPluginInstallGithubResponse = + PostWorkspacesCurrentPluginInstallGithubResponses[keyof PostWorkspacesCurrentPluginInstallGithubResponses] export type PostWorkspacesCurrentPluginInstallMarketplaceData = { body: ParserPluginIdentifiers @@ -3511,8 +3511,8 @@ export type PostWorkspacesCurrentPluginInstallMarketplaceResponses = { 200: PluginInstallTaskStartResponse } -export type PostWorkspacesCurrentPluginInstallMarketplaceResponse - = PostWorkspacesCurrentPluginInstallMarketplaceResponses[keyof PostWorkspacesCurrentPluginInstallMarketplaceResponses] +export type PostWorkspacesCurrentPluginInstallMarketplaceResponse = + PostWorkspacesCurrentPluginInstallMarketplaceResponses[keyof PostWorkspacesCurrentPluginInstallMarketplaceResponses] export type PostWorkspacesCurrentPluginInstallPkgData = { body: ParserPluginIdentifiers @@ -3525,8 +3525,8 @@ export type PostWorkspacesCurrentPluginInstallPkgResponses = { 200: PluginInstallTaskStartResponse } -export type PostWorkspacesCurrentPluginInstallPkgResponse - = PostWorkspacesCurrentPluginInstallPkgResponses[keyof PostWorkspacesCurrentPluginInstallPkgResponses] +export type PostWorkspacesCurrentPluginInstallPkgResponse = + PostWorkspacesCurrentPluginInstallPkgResponses[keyof PostWorkspacesCurrentPluginInstallPkgResponses] export type GetWorkspacesCurrentPluginListData = { body?: never @@ -3542,8 +3542,8 @@ export type GetWorkspacesCurrentPluginListResponses = { 200: PluginListResponse } -export type GetWorkspacesCurrentPluginListResponse - = GetWorkspacesCurrentPluginListResponses[keyof GetWorkspacesCurrentPluginListResponses] +export type GetWorkspacesCurrentPluginListResponse = + GetWorkspacesCurrentPluginListResponses[keyof GetWorkspacesCurrentPluginListResponses] export type PostWorkspacesCurrentPluginListInstallationsIdsData = { body: ParserLatest @@ -3556,8 +3556,8 @@ export type PostWorkspacesCurrentPluginListInstallationsIdsResponses = { 200: PluginInstallationsResponse } -export type PostWorkspacesCurrentPluginListInstallationsIdsResponse - = PostWorkspacesCurrentPluginListInstallationsIdsResponses[keyof PostWorkspacesCurrentPluginListInstallationsIdsResponses] +export type PostWorkspacesCurrentPluginListInstallationsIdsResponse = + PostWorkspacesCurrentPluginListInstallationsIdsResponses[keyof PostWorkspacesCurrentPluginListInstallationsIdsResponses] export type PostWorkspacesCurrentPluginListLatestVersionsData = { body: ParserLatest @@ -3570,8 +3570,8 @@ export type PostWorkspacesCurrentPluginListLatestVersionsResponses = { 200: PluginVersionsResponse } -export type PostWorkspacesCurrentPluginListLatestVersionsResponse - = PostWorkspacesCurrentPluginListLatestVersionsResponses[keyof PostWorkspacesCurrentPluginListLatestVersionsResponses] +export type PostWorkspacesCurrentPluginListLatestVersionsResponse = + PostWorkspacesCurrentPluginListLatestVersionsResponses[keyof PostWorkspacesCurrentPluginListLatestVersionsResponses] export type GetWorkspacesCurrentPluginMarketplacePkgData = { body?: never @@ -3586,8 +3586,8 @@ export type GetWorkspacesCurrentPluginMarketplacePkgResponses = { 200: PluginManifestResponse } -export type GetWorkspacesCurrentPluginMarketplacePkgResponse - = GetWorkspacesCurrentPluginMarketplacePkgResponses[keyof GetWorkspacesCurrentPluginMarketplacePkgResponses] +export type GetWorkspacesCurrentPluginMarketplacePkgResponse = + GetWorkspacesCurrentPluginMarketplacePkgResponses[keyof GetWorkspacesCurrentPluginMarketplacePkgResponses] export type GetWorkspacesCurrentPluginParametersDynamicOptionsData = { body?: never @@ -3607,8 +3607,8 @@ export type GetWorkspacesCurrentPluginParametersDynamicOptionsResponses = { 200: PluginDynamicOptionsResponse } -export type GetWorkspacesCurrentPluginParametersDynamicOptionsResponse - = GetWorkspacesCurrentPluginParametersDynamicOptionsResponses[keyof GetWorkspacesCurrentPluginParametersDynamicOptionsResponses] +export type GetWorkspacesCurrentPluginParametersDynamicOptionsResponse = + GetWorkspacesCurrentPluginParametersDynamicOptionsResponses[keyof GetWorkspacesCurrentPluginParametersDynamicOptionsResponses] export type PostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsData = { body: ParserDynamicOptionsWithCredentials @@ -3621,8 +3621,8 @@ export type PostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsRe 200: PluginDynamicOptionsResponse } -export type PostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse - = PostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponses[keyof PostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponses] +export type PostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse = + PostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponses[keyof PostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponses] export type PostWorkspacesCurrentPluginPermissionChangeData = { body: ParserPermissionChange @@ -3635,8 +3635,8 @@ export type PostWorkspacesCurrentPluginPermissionChangeResponses = { 200: SuccessResponse } -export type PostWorkspacesCurrentPluginPermissionChangeResponse - = PostWorkspacesCurrentPluginPermissionChangeResponses[keyof PostWorkspacesCurrentPluginPermissionChangeResponses] +export type PostWorkspacesCurrentPluginPermissionChangeResponse = + PostWorkspacesCurrentPluginPermissionChangeResponses[keyof PostWorkspacesCurrentPluginPermissionChangeResponses] export type GetWorkspacesCurrentPluginPermissionFetchData = { body?: never @@ -3649,8 +3649,8 @@ export type GetWorkspacesCurrentPluginPermissionFetchResponses = { 200: PluginPermissionResponse } -export type GetWorkspacesCurrentPluginPermissionFetchResponse - = GetWorkspacesCurrentPluginPermissionFetchResponses[keyof GetWorkspacesCurrentPluginPermissionFetchResponses] +export type GetWorkspacesCurrentPluginPermissionFetchResponse = + GetWorkspacesCurrentPluginPermissionFetchResponses[keyof GetWorkspacesCurrentPluginPermissionFetchResponses] export type GetWorkspacesCurrentPluginReadmeData = { body?: never @@ -3666,8 +3666,8 @@ export type GetWorkspacesCurrentPluginReadmeResponses = { 200: PluginReadmeResponse } -export type GetWorkspacesCurrentPluginReadmeResponse - = GetWorkspacesCurrentPluginReadmeResponses[keyof GetWorkspacesCurrentPluginReadmeResponses] +export type GetWorkspacesCurrentPluginReadmeResponse = + GetWorkspacesCurrentPluginReadmeResponses[keyof GetWorkspacesCurrentPluginReadmeResponses] export type GetWorkspacesCurrentPluginTasksData = { body?: never @@ -3683,8 +3683,8 @@ export type GetWorkspacesCurrentPluginTasksResponses = { 200: PluginTasksResponse } -export type GetWorkspacesCurrentPluginTasksResponse - = GetWorkspacesCurrentPluginTasksResponses[keyof GetWorkspacesCurrentPluginTasksResponses] +export type GetWorkspacesCurrentPluginTasksResponse = + GetWorkspacesCurrentPluginTasksResponses[keyof GetWorkspacesCurrentPluginTasksResponses] export type PostWorkspacesCurrentPluginTasksDeleteAllData = { body?: never @@ -3697,8 +3697,8 @@ export type PostWorkspacesCurrentPluginTasksDeleteAllResponses = { 200: SuccessResponse } -export type PostWorkspacesCurrentPluginTasksDeleteAllResponse - = PostWorkspacesCurrentPluginTasksDeleteAllResponses[keyof PostWorkspacesCurrentPluginTasksDeleteAllResponses] +export type PostWorkspacesCurrentPluginTasksDeleteAllResponse = + PostWorkspacesCurrentPluginTasksDeleteAllResponses[keyof PostWorkspacesCurrentPluginTasksDeleteAllResponses] export type GetWorkspacesCurrentPluginTasksByTaskIdData = { body?: never @@ -3713,8 +3713,8 @@ export type GetWorkspacesCurrentPluginTasksByTaskIdResponses = { 200: PluginTaskResponse } -export type GetWorkspacesCurrentPluginTasksByTaskIdResponse - = GetWorkspacesCurrentPluginTasksByTaskIdResponses[keyof GetWorkspacesCurrentPluginTasksByTaskIdResponses] +export type GetWorkspacesCurrentPluginTasksByTaskIdResponse = + GetWorkspacesCurrentPluginTasksByTaskIdResponses[keyof GetWorkspacesCurrentPluginTasksByTaskIdResponses] export type PostWorkspacesCurrentPluginTasksByTaskIdDeleteData = { body?: never @@ -3729,8 +3729,8 @@ export type PostWorkspacesCurrentPluginTasksByTaskIdDeleteResponses = { 200: SuccessResponse } -export type PostWorkspacesCurrentPluginTasksByTaskIdDeleteResponse - = PostWorkspacesCurrentPluginTasksByTaskIdDeleteResponses[keyof PostWorkspacesCurrentPluginTasksByTaskIdDeleteResponses] +export type PostWorkspacesCurrentPluginTasksByTaskIdDeleteResponse = + PostWorkspacesCurrentPluginTasksByTaskIdDeleteResponses[keyof PostWorkspacesCurrentPluginTasksByTaskIdDeleteResponses] export type PostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierData = { body?: never @@ -3746,8 +3746,8 @@ export type PostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierResponses 200: SuccessResponse } -export type PostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierResponse - = PostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierResponses[keyof PostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierResponses] +export type PostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierResponse = + PostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierResponses[keyof PostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierResponses] export type PostWorkspacesCurrentPluginUninstallData = { body: ParserUninstall @@ -3760,8 +3760,8 @@ export type PostWorkspacesCurrentPluginUninstallResponses = { 200: SuccessResponse } -export type PostWorkspacesCurrentPluginUninstallResponse - = PostWorkspacesCurrentPluginUninstallResponses[keyof PostWorkspacesCurrentPluginUninstallResponses] +export type PostWorkspacesCurrentPluginUninstallResponse = + PostWorkspacesCurrentPluginUninstallResponses[keyof PostWorkspacesCurrentPluginUninstallResponses] export type PostWorkspacesCurrentPluginUpgradeGithubData = { body: ParserGithubUpgrade @@ -3774,8 +3774,8 @@ export type PostWorkspacesCurrentPluginUpgradeGithubResponses = { 200: PluginInstallTaskStartResponse } -export type PostWorkspacesCurrentPluginUpgradeGithubResponse - = PostWorkspacesCurrentPluginUpgradeGithubResponses[keyof PostWorkspacesCurrentPluginUpgradeGithubResponses] +export type PostWorkspacesCurrentPluginUpgradeGithubResponse = + PostWorkspacesCurrentPluginUpgradeGithubResponses[keyof PostWorkspacesCurrentPluginUpgradeGithubResponses] export type PostWorkspacesCurrentPluginUpgradeMarketplaceData = { body: ParserMarketplaceUpgrade @@ -3788,8 +3788,8 @@ export type PostWorkspacesCurrentPluginUpgradeMarketplaceResponses = { 200: PluginInstallTaskStartResponse } -export type PostWorkspacesCurrentPluginUpgradeMarketplaceResponse - = PostWorkspacesCurrentPluginUpgradeMarketplaceResponses[keyof PostWorkspacesCurrentPluginUpgradeMarketplaceResponses] +export type PostWorkspacesCurrentPluginUpgradeMarketplaceResponse = + PostWorkspacesCurrentPluginUpgradeMarketplaceResponses[keyof PostWorkspacesCurrentPluginUpgradeMarketplaceResponses] export type PostWorkspacesCurrentPluginUploadBundleData = { body?: never @@ -3802,8 +3802,8 @@ export type PostWorkspacesCurrentPluginUploadBundleResponses = { 200: PluginBundleUploadResponse } -export type PostWorkspacesCurrentPluginUploadBundleResponse - = PostWorkspacesCurrentPluginUploadBundleResponses[keyof PostWorkspacesCurrentPluginUploadBundleResponses] +export type PostWorkspacesCurrentPluginUploadBundleResponse = + PostWorkspacesCurrentPluginUploadBundleResponses[keyof PostWorkspacesCurrentPluginUploadBundleResponses] export type PostWorkspacesCurrentPluginUploadGithubData = { body: ParserGithubUpload @@ -3816,8 +3816,8 @@ export type PostWorkspacesCurrentPluginUploadGithubResponses = { 200: PluginDecodeResponse } -export type PostWorkspacesCurrentPluginUploadGithubResponse - = PostWorkspacesCurrentPluginUploadGithubResponses[keyof PostWorkspacesCurrentPluginUploadGithubResponses] +export type PostWorkspacesCurrentPluginUploadGithubResponse = + PostWorkspacesCurrentPluginUploadGithubResponses[keyof PostWorkspacesCurrentPluginUploadGithubResponses] export type PostWorkspacesCurrentPluginUploadPkgData = { body?: never @@ -3830,8 +3830,8 @@ export type PostWorkspacesCurrentPluginUploadPkgResponses = { 200: PluginDecodeResponse } -export type PostWorkspacesCurrentPluginUploadPkgResponse - = PostWorkspacesCurrentPluginUploadPkgResponses[keyof PostWorkspacesCurrentPluginUploadPkgResponses] +export type PostWorkspacesCurrentPluginUploadPkgResponse = + PostWorkspacesCurrentPluginUploadPkgResponses[keyof PostWorkspacesCurrentPluginUploadPkgResponses] export type GetWorkspacesCurrentPluginByCategoryListData = { body?: never @@ -3849,8 +3849,8 @@ export type GetWorkspacesCurrentPluginByCategoryListResponses = { 200: PluginCategoryListResponse } -export type GetWorkspacesCurrentPluginByCategoryListResponse - = GetWorkspacesCurrentPluginByCategoryListResponses[keyof GetWorkspacesCurrentPluginByCategoryListResponses] +export type GetWorkspacesCurrentPluginByCategoryListResponse = + GetWorkspacesCurrentPluginByCategoryListResponses[keyof GetWorkspacesCurrentPluginByCategoryListResponses] export type GetWorkspacesCurrentRbacAccessPoliciesData = { body?: never @@ -3863,8 +3863,8 @@ export type GetWorkspacesCurrentRbacAccessPoliciesResponses = { 200: AccessPolicyList } -export type GetWorkspacesCurrentRbacAccessPoliciesResponse - = GetWorkspacesCurrentRbacAccessPoliciesResponses[keyof GetWorkspacesCurrentRbacAccessPoliciesResponses] +export type GetWorkspacesCurrentRbacAccessPoliciesResponse = + GetWorkspacesCurrentRbacAccessPoliciesResponses[keyof GetWorkspacesCurrentRbacAccessPoliciesResponses] export type PostWorkspacesCurrentRbacAccessPoliciesData = { body?: never @@ -3877,8 +3877,8 @@ export type PostWorkspacesCurrentRbacAccessPoliciesResponses = { 201: AccessPolicy } -export type PostWorkspacesCurrentRbacAccessPoliciesResponse - = PostWorkspacesCurrentRbacAccessPoliciesResponses[keyof PostWorkspacesCurrentRbacAccessPoliciesResponses] +export type PostWorkspacesCurrentRbacAccessPoliciesResponse = + PostWorkspacesCurrentRbacAccessPoliciesResponses[keyof PostWorkspacesCurrentRbacAccessPoliciesResponses] export type DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdData = { body?: never @@ -3893,8 +3893,8 @@ export type DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses = { 200: AccessPolicy } -export type DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse - = DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses[keyof DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses] +export type DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse = + DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses[keyof DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses] export type GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdData = { body?: never @@ -3909,8 +3909,8 @@ export type GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses = { 200: AccessPolicy } -export type GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse - = GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses[keyof GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses] +export type GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse = + GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses[keyof GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses] export type PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdData = { body?: never @@ -3925,8 +3925,8 @@ export type PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses = { 200: AccessPolicy } -export type PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse - = PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses[keyof PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses] +export type PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse = + PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses[keyof PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses] export type PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyData = { body?: never @@ -3941,8 +3941,8 @@ export type PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponses = { 201: AccessPolicy } -export type PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse - = PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponses[keyof PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponses] +export type PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse = + PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponses[keyof PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponses] export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockData = { body?: never @@ -3957,8 +3957,8 @@ export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponses 200: AccessPolicyBindingState } -export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse - = PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponses[keyof PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponses] +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse = + PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponses[keyof PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponses] export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockData = { body?: never @@ -3973,8 +3973,8 @@ export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockRespons 200: AccessPolicyBindingState } -export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse - = PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponses[keyof PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponses] +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse = + PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponses[keyof PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponses] export type DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsData = { body: DeleteMemberBindingsRequest @@ -3986,13 +3986,13 @@ export type DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMember url: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings' } -export type DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses - = { +export type DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses = + { 200: MemberBindingsResponse } -export type DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse - = DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses] +export type DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse = + DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses] export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsData = { body?: never @@ -4008,8 +4008,8 @@ export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBin 200: MemberBindingsResponse } -export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse - = GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses] +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse = + GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses] export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsData = { body?: never @@ -4025,8 +4025,8 @@ export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindi 200: RoleBindingsResponse } -export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse - = GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponses] +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse = + GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponses] export type GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyData = { body?: never @@ -4043,8 +4043,8 @@ export type GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponses = { 200: AppAccessMatrix } -export type GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse - = GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponses] +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse = + GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponses] export type GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesData = { body?: never @@ -4061,8 +4061,8 @@ export type GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponses = { 200: ResourceUserAccessPoliciesResponse } -export type GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse - = GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponses] +export type GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse = + GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponses] export type PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesData = { body: ReplaceUserAccessPolicies @@ -4078,8 +4078,8 @@ export type PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPolic 200: ReplaceUserAccessPoliciesResponse } -export type PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse - = PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponses[keyof PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponses] +export type PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse = + PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponses[keyof PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponses] export type GetWorkspacesCurrentRbacAppsByAppIdWhitelistData = { body?: never @@ -4094,8 +4094,8 @@ export type GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponses = { 200: ResourceWhitelist } -export type GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse - = GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponses] +export type GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse = + GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponses] export type PutWorkspacesCurrentRbacAppsByAppIdWhitelistData = { body: ResourceAccessScopeRequest @@ -4110,11 +4110,11 @@ export type PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponses = { 200: ResourceWhitelist } -export type PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse - = PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponses[keyof PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponses] +export type PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse = + PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponses[keyof PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponses] -export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsData - = { +export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsData = + { body: DeleteMemberBindingsRequest path: { dataset_id: string @@ -4124,16 +4124,16 @@ export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicy url: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings' } -export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses - = { +export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses = + { 200: MemberBindingsResponse } -export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse - = DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses] +export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse = + DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses] -export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsData - = { +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsData = + { body?: never path: { dataset_id: string @@ -4143,13 +4143,13 @@ export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdM url: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings' } -export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses - = { +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses = + { 200: MemberBindingsResponse } -export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse - = GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses] +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse = + GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses] export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsData = { body?: never @@ -4161,13 +4161,13 @@ export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdR url: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/role-bindings' } -export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponses - = { +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponses = + { 200: RoleBindingsResponse } -export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse - = GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponses] +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse = + GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponses] export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyData = { body?: never @@ -4184,8 +4184,8 @@ export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponses = { 200: DatasetAccessMatrix } -export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse - = GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponses] +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse = + GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponses] export type GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesData = { body?: never @@ -4202,8 +4202,8 @@ export type GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesRespons 200: ResourceUserAccessPoliciesResponse } -export type GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse - = GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponses] +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse = + GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponses] export type PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesData = { body: ReplaceUserAccessPolicies @@ -4215,13 +4215,13 @@ export type PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAcc url: '/workspaces/current/rbac/datasets/{dataset_id}/users/{target_account_id}/access-policies' } -export type PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponses - = { +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponses = + { 200: ReplaceUserAccessPoliciesResponse } -export type PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse - = PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponses[keyof PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponses] +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse = + PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponses[keyof PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponses] export type GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistData = { body?: never @@ -4236,8 +4236,8 @@ export type GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses = { 200: ResourceWhitelist } -export type GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse - = GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses] +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse = + GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses] export type PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistData = { body: ResourceAccessScopeRequest @@ -4252,8 +4252,8 @@ export type PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses = { 200: ResourceWhitelist } -export type PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse - = PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses[keyof PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses] +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse = + PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses[keyof PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses] export type GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesData = { body?: never @@ -4268,8 +4268,8 @@ export type GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses = { 200: MemberRolesResponse } -export type GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse - = GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses[keyof GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses] +export type GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse = + GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses[keyof GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses] export type PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesData = { body: ReplaceMemberRolesRequest @@ -4284,8 +4284,8 @@ export type PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses = { 200: MemberRolesResponse } -export type PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse - = PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses[keyof PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses] +export type PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse = + PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses[keyof PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses] export type GetWorkspacesCurrentRbacMyPermissionsData = { body?: never @@ -4298,8 +4298,8 @@ export type GetWorkspacesCurrentRbacMyPermissionsResponses = { 200: MyPermissionsResponse } -export type GetWorkspacesCurrentRbacMyPermissionsResponse - = GetWorkspacesCurrentRbacMyPermissionsResponses[keyof GetWorkspacesCurrentRbacMyPermissionsResponses] +export type GetWorkspacesCurrentRbacMyPermissionsResponse = + GetWorkspacesCurrentRbacMyPermissionsResponses[keyof GetWorkspacesCurrentRbacMyPermissionsResponses] export type GetWorkspacesCurrentRbacRolePermissionsCatalogData = { body?: never @@ -4312,8 +4312,8 @@ export type GetWorkspacesCurrentRbacRolePermissionsCatalogResponses = { 200: PermissionCatalogResponse } -export type GetWorkspacesCurrentRbacRolePermissionsCatalogResponse - = GetWorkspacesCurrentRbacRolePermissionsCatalogResponses[keyof GetWorkspacesCurrentRbacRolePermissionsCatalogResponses] +export type GetWorkspacesCurrentRbacRolePermissionsCatalogResponse = + GetWorkspacesCurrentRbacRolePermissionsCatalogResponses[keyof GetWorkspacesCurrentRbacRolePermissionsCatalogResponses] export type GetWorkspacesCurrentRbacRolePermissionsCatalogAppData = { body?: never @@ -4326,8 +4326,8 @@ export type GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponses = { 200: PermissionCatalogResponse } -export type GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse - = GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponses[keyof GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponses] +export type GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse = + GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponses[keyof GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponses] export type GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetData = { body?: never @@ -4340,8 +4340,8 @@ export type GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponses = { 200: PermissionCatalogResponse } -export type GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse - = GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponses[keyof GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponses] +export type GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse = + GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponses[keyof GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponses] export type GetWorkspacesCurrentRbacRolesData = { body?: never @@ -4354,8 +4354,8 @@ export type GetWorkspacesCurrentRbacRolesResponses = { 200: RbacRoleList } -export type GetWorkspacesCurrentRbacRolesResponse - = GetWorkspacesCurrentRbacRolesResponses[keyof GetWorkspacesCurrentRbacRolesResponses] +export type GetWorkspacesCurrentRbacRolesResponse = + GetWorkspacesCurrentRbacRolesResponses[keyof GetWorkspacesCurrentRbacRolesResponses] export type PostWorkspacesCurrentRbacRolesData = { body?: never @@ -4368,8 +4368,8 @@ export type PostWorkspacesCurrentRbacRolesResponses = { 201: RbacRole } -export type PostWorkspacesCurrentRbacRolesResponse - = PostWorkspacesCurrentRbacRolesResponses[keyof PostWorkspacesCurrentRbacRolesResponses] +export type PostWorkspacesCurrentRbacRolesResponse = + PostWorkspacesCurrentRbacRolesResponses[keyof PostWorkspacesCurrentRbacRolesResponses] export type DeleteWorkspacesCurrentRbacRolesByRoleIdData = { body?: never @@ -4384,8 +4384,8 @@ export type DeleteWorkspacesCurrentRbacRolesByRoleIdResponses = { 200: RbacRole } -export type DeleteWorkspacesCurrentRbacRolesByRoleIdResponse - = DeleteWorkspacesCurrentRbacRolesByRoleIdResponses[keyof DeleteWorkspacesCurrentRbacRolesByRoleIdResponses] +export type DeleteWorkspacesCurrentRbacRolesByRoleIdResponse = + DeleteWorkspacesCurrentRbacRolesByRoleIdResponses[keyof DeleteWorkspacesCurrentRbacRolesByRoleIdResponses] export type GetWorkspacesCurrentRbacRolesByRoleIdData = { body?: never @@ -4400,8 +4400,8 @@ export type GetWorkspacesCurrentRbacRolesByRoleIdResponses = { 200: RbacRole } -export type GetWorkspacesCurrentRbacRolesByRoleIdResponse - = GetWorkspacesCurrentRbacRolesByRoleIdResponses[keyof GetWorkspacesCurrentRbacRolesByRoleIdResponses] +export type GetWorkspacesCurrentRbacRolesByRoleIdResponse = + GetWorkspacesCurrentRbacRolesByRoleIdResponses[keyof GetWorkspacesCurrentRbacRolesByRoleIdResponses] export type PutWorkspacesCurrentRbacRolesByRoleIdData = { body?: never @@ -4416,8 +4416,8 @@ export type PutWorkspacesCurrentRbacRolesByRoleIdResponses = { 200: RbacRole } -export type PutWorkspacesCurrentRbacRolesByRoleIdResponse - = PutWorkspacesCurrentRbacRolesByRoleIdResponses[keyof PutWorkspacesCurrentRbacRolesByRoleIdResponses] +export type PutWorkspacesCurrentRbacRolesByRoleIdResponse = + PutWorkspacesCurrentRbacRolesByRoleIdResponses[keyof PutWorkspacesCurrentRbacRolesByRoleIdResponses] export type PostWorkspacesCurrentRbacRolesByRoleIdCopyData = { body?: never @@ -4432,8 +4432,8 @@ export type PostWorkspacesCurrentRbacRolesByRoleIdCopyResponses = { 201: RbacRole } -export type PostWorkspacesCurrentRbacRolesByRoleIdCopyResponse - = PostWorkspacesCurrentRbacRolesByRoleIdCopyResponses[keyof PostWorkspacesCurrentRbacRolesByRoleIdCopyResponses] +export type PostWorkspacesCurrentRbacRolesByRoleIdCopyResponse = + PostWorkspacesCurrentRbacRolesByRoleIdCopyResponses[keyof PostWorkspacesCurrentRbacRolesByRoleIdCopyResponses] export type GetWorkspacesCurrentRbacRolesByRoleIdMembersData = { body?: never @@ -4448,8 +4448,8 @@ export type GetWorkspacesCurrentRbacRolesByRoleIdMembersResponses = { 200: MembersInRoleList } -export type GetWorkspacesCurrentRbacRolesByRoleIdMembersResponse - = GetWorkspacesCurrentRbacRolesByRoleIdMembersResponses[keyof GetWorkspacesCurrentRbacRolesByRoleIdMembersResponses] +export type GetWorkspacesCurrentRbacRolesByRoleIdMembersResponse = + GetWorkspacesCurrentRbacRolesByRoleIdMembersResponses[keyof GetWorkspacesCurrentRbacRolesByRoleIdMembersResponses] export type PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsData = { body: ReplaceBindingsRequest @@ -4464,8 +4464,8 @@ export type PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBinding 200: AccessMatrixItem } -export type PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse - = PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponses[keyof PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponses] +export type PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse = + PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponses[keyof PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponses] export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsData = { body?: never @@ -4480,8 +4480,8 @@ export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberB 200: MemberBindingsResponse } -export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse - = GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponses] +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse = + GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponses] export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsData = { body?: never @@ -4496,8 +4496,8 @@ export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBin 200: RoleBindingsResponse } -export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse - = GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponses] +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse = + GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponses] export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyData = { body?: never @@ -4510,8 +4510,8 @@ export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponses = { 200: WorkspaceAccessMatrix } -export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse - = GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponses] +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse = + GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponses] export type PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsData = { body: ReplaceBindingsRequest @@ -4526,8 +4526,8 @@ export type PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBin 200: AccessMatrixItem } -export type PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse - = PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponses[keyof PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponses] +export type PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse = + PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponses[keyof PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponses] export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsData = { body?: never @@ -4538,13 +4538,13 @@ export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMem url: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/member-bindings' } -export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponses - = { +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponses = + { 200: MemberBindingsResponse } -export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse - = GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponses] +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse = + GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponses] export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsData = { body?: never @@ -4555,13 +4555,13 @@ export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRol url: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/role-bindings' } -export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponses - = { +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponses = + { 200: RoleBindingsResponse } -export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse - = GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponses] +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse = + GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponses] export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyData = { body?: never @@ -4574,8 +4574,8 @@ export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses = { 200: WorkspaceAccessMatrix } -export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse - = GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses] +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse = + GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses] export type GetWorkspacesCurrentToolLabelsData = { body?: never @@ -4588,8 +4588,8 @@ export type GetWorkspacesCurrentToolLabelsResponses = { 200: ToolLabelListResponse } -export type GetWorkspacesCurrentToolLabelsResponse - = GetWorkspacesCurrentToolLabelsResponses[keyof GetWorkspacesCurrentToolLabelsResponses] +export type GetWorkspacesCurrentToolLabelsResponse = + GetWorkspacesCurrentToolLabelsResponses[keyof GetWorkspacesCurrentToolLabelsResponses] export type PostWorkspacesCurrentToolProviderApiAddData = { body: ApiToolProviderAddPayload @@ -4602,8 +4602,8 @@ export type PostWorkspacesCurrentToolProviderApiAddResponses = { 200: SimpleResultResponse } -export type PostWorkspacesCurrentToolProviderApiAddResponse - = PostWorkspacesCurrentToolProviderApiAddResponses[keyof PostWorkspacesCurrentToolProviderApiAddResponses] +export type PostWorkspacesCurrentToolProviderApiAddResponse = + PostWorkspacesCurrentToolProviderApiAddResponses[keyof PostWorkspacesCurrentToolProviderApiAddResponses] export type PostWorkspacesCurrentToolProviderApiDeleteData = { body: ApiToolProviderDeletePayload @@ -4616,8 +4616,8 @@ export type PostWorkspacesCurrentToolProviderApiDeleteResponses = { 200: SimpleResultResponse } -export type PostWorkspacesCurrentToolProviderApiDeleteResponse - = PostWorkspacesCurrentToolProviderApiDeleteResponses[keyof PostWorkspacesCurrentToolProviderApiDeleteResponses] +export type PostWorkspacesCurrentToolProviderApiDeleteResponse = + PostWorkspacesCurrentToolProviderApiDeleteResponses[keyof PostWorkspacesCurrentToolProviderApiDeleteResponses] export type GetWorkspacesCurrentToolProviderApiGetData = { body?: never @@ -4632,8 +4632,8 @@ export type GetWorkspacesCurrentToolProviderApiGetResponses = { 200: ApiProviderDetailResponse } -export type GetWorkspacesCurrentToolProviderApiGetResponse - = GetWorkspacesCurrentToolProviderApiGetResponses[keyof GetWorkspacesCurrentToolProviderApiGetResponses] +export type GetWorkspacesCurrentToolProviderApiGetResponse = + GetWorkspacesCurrentToolProviderApiGetResponses[keyof GetWorkspacesCurrentToolProviderApiGetResponses] export type GetWorkspacesCurrentToolProviderApiRemoteData = { body?: never @@ -4648,8 +4648,8 @@ export type GetWorkspacesCurrentToolProviderApiRemoteResponses = { 200: ApiProviderRemoteSchemaResponse } -export type GetWorkspacesCurrentToolProviderApiRemoteResponse - = GetWorkspacesCurrentToolProviderApiRemoteResponses[keyof GetWorkspacesCurrentToolProviderApiRemoteResponses] +export type GetWorkspacesCurrentToolProviderApiRemoteResponse = + GetWorkspacesCurrentToolProviderApiRemoteResponses[keyof GetWorkspacesCurrentToolProviderApiRemoteResponses] export type PostWorkspacesCurrentToolProviderApiSchemaData = { body: ApiToolSchemaPayload @@ -4662,8 +4662,8 @@ export type PostWorkspacesCurrentToolProviderApiSchemaResponses = { 200: ApiSchemaParseResponse } -export type PostWorkspacesCurrentToolProviderApiSchemaResponse - = PostWorkspacesCurrentToolProviderApiSchemaResponses[keyof PostWorkspacesCurrentToolProviderApiSchemaResponses] +export type PostWorkspacesCurrentToolProviderApiSchemaResponse = + PostWorkspacesCurrentToolProviderApiSchemaResponses[keyof PostWorkspacesCurrentToolProviderApiSchemaResponses] export type PostWorkspacesCurrentToolProviderApiTestPreData = { body: ApiToolTestPayload @@ -4676,8 +4676,8 @@ export type PostWorkspacesCurrentToolProviderApiTestPreResponses = { 200: ApiToolPreviewResponse } -export type PostWorkspacesCurrentToolProviderApiTestPreResponse - = PostWorkspacesCurrentToolProviderApiTestPreResponses[keyof PostWorkspacesCurrentToolProviderApiTestPreResponses] +export type PostWorkspacesCurrentToolProviderApiTestPreResponse = + PostWorkspacesCurrentToolProviderApiTestPreResponses[keyof PostWorkspacesCurrentToolProviderApiTestPreResponses] export type GetWorkspacesCurrentToolProviderApiToolsData = { body?: never @@ -4692,8 +4692,8 @@ export type GetWorkspacesCurrentToolProviderApiToolsResponses = { 200: ToolApiListResponse } -export type GetWorkspacesCurrentToolProviderApiToolsResponse - = GetWorkspacesCurrentToolProviderApiToolsResponses[keyof GetWorkspacesCurrentToolProviderApiToolsResponses] +export type GetWorkspacesCurrentToolProviderApiToolsResponse = + GetWorkspacesCurrentToolProviderApiToolsResponses[keyof GetWorkspacesCurrentToolProviderApiToolsResponses] export type PostWorkspacesCurrentToolProviderApiUpdateData = { body: ApiToolProviderUpdatePayload @@ -4706,8 +4706,8 @@ export type PostWorkspacesCurrentToolProviderApiUpdateResponses = { 200: SimpleResultResponse } -export type PostWorkspacesCurrentToolProviderApiUpdateResponse - = PostWorkspacesCurrentToolProviderApiUpdateResponses[keyof PostWorkspacesCurrentToolProviderApiUpdateResponses] +export type PostWorkspacesCurrentToolProviderApiUpdateResponse = + PostWorkspacesCurrentToolProviderApiUpdateResponses[keyof PostWorkspacesCurrentToolProviderApiUpdateResponses] export type PostWorkspacesCurrentToolProviderBuiltinByProviderAddData = { body: BuiltinToolAddPayload @@ -4722,8 +4722,8 @@ export type PostWorkspacesCurrentToolProviderBuiltinByProviderAddResponses = { 200: SimpleResultResponse } -export type PostWorkspacesCurrentToolProviderBuiltinByProviderAddResponse - = PostWorkspacesCurrentToolProviderBuiltinByProviderAddResponses[keyof PostWorkspacesCurrentToolProviderBuiltinByProviderAddResponses] +export type PostWorkspacesCurrentToolProviderBuiltinByProviderAddResponse = + PostWorkspacesCurrentToolProviderBuiltinByProviderAddResponses[keyof PostWorkspacesCurrentToolProviderBuiltinByProviderAddResponses] export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoData = { body?: never @@ -4740,11 +4740,11 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoRespo 200: ToolProviderCredentialInfoApiEntity } -export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse - = GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponses] +export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse = + GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponses] -export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeData - = { +export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeData = + { body?: never path: { credential_type: string @@ -4754,13 +4754,13 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByC url: '/workspaces/current/tool-provider/builtin/{provider}/credential/schema/{credential_type}' } -export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponses - = { +export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponses = + { 200: ProviderConfigListResponse } -export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponse - = GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponses] +export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponse = + GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponses] export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsData = { body?: never @@ -4777,8 +4777,8 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse 200: ToolProviderCredentialListResponse } -export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse - = GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponses] +export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse = + GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponses] export type PostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialData = { body: BuiltinProviderDefaultCredentialPayload @@ -4793,8 +4793,8 @@ export type PostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialR 200: SimpleResultResponse } -export type PostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse - = PostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponses[keyof PostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponses] +export type PostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse = + PostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponses[keyof PostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponses] export type PostWorkspacesCurrentToolProviderBuiltinByProviderDeleteData = { body: BuiltinToolCredentialDeletePayload @@ -4809,8 +4809,8 @@ export type PostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponses = 200: SimpleResultResponse } -export type PostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse - = PostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponses[keyof PostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponses] +export type PostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse = + PostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponses[keyof PostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponses] export type GetWorkspacesCurrentToolProviderBuiltinByProviderIconData = { body?: never @@ -4827,8 +4827,8 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderIconResponses = { } } -export type GetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse - = GetWorkspacesCurrentToolProviderBuiltinByProviderIconResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderIconResponses] +export type GetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse = + GetWorkspacesCurrentToolProviderBuiltinByProviderIconResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderIconResponses] export type GetWorkspacesCurrentToolProviderBuiltinByProviderInfoData = { body?: never @@ -4843,8 +4843,8 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponses = { 200: ToolProviderApiEntityResponse } -export type GetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse - = GetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponses] +export type GetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse = + GetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponses] export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaData = { body?: never @@ -4859,8 +4859,8 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaRe 200: BuiltinProviderOAuthClientSchemaResponse } -export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse - = GetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponses] +export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse = + GetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponses] export type DeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientData = { body?: never @@ -4875,8 +4875,8 @@ export type DeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClien 200: SimpleResultResponse } -export type DeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse - = DeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses[keyof DeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses] +export type DeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse = + DeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses[keyof DeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses] export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientData = { body?: never @@ -4893,8 +4893,8 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientRe } } -export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse - = GetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses] +export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse = + GetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses] export type PostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientData = { body: ToolOAuthCustomClientPayload @@ -4909,8 +4909,8 @@ export type PostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientR 200: SimpleResultResponse } -export type PostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse - = PostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses[keyof PostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses] +export type PostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse = + PostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses[keyof PostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses] export type GetWorkspacesCurrentToolProviderBuiltinByProviderToolsData = { body?: never @@ -4925,8 +4925,8 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponses = { 200: ToolApiListResponse } -export type GetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse - = GetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponses] +export type GetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse = + GetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponses[keyof GetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponses] export type PostWorkspacesCurrentToolProviderBuiltinByProviderUpdateData = { body: BuiltinToolUpdatePayload @@ -4941,8 +4941,8 @@ export type PostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponses = 200: SimpleResultResponse } -export type PostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse - = PostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponses[keyof PostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponses] +export type PostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse = + PostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponses[keyof PostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponses] export type DeleteWorkspacesCurrentToolProviderMcpData = { body: McpProviderDeletePayload @@ -4955,8 +4955,8 @@ export type DeleteWorkspacesCurrentToolProviderMcpResponses = { 200: SimpleResultResponse } -export type DeleteWorkspacesCurrentToolProviderMcpResponse - = DeleteWorkspacesCurrentToolProviderMcpResponses[keyof DeleteWorkspacesCurrentToolProviderMcpResponses] +export type DeleteWorkspacesCurrentToolProviderMcpResponse = + DeleteWorkspacesCurrentToolProviderMcpResponses[keyof DeleteWorkspacesCurrentToolProviderMcpResponses] export type PostWorkspacesCurrentToolProviderMcpData = { body: McpProviderCreatePayload @@ -4969,8 +4969,8 @@ export type PostWorkspacesCurrentToolProviderMcpResponses = { 200: ToolProviderApiEntityResponse } -export type PostWorkspacesCurrentToolProviderMcpResponse - = PostWorkspacesCurrentToolProviderMcpResponses[keyof PostWorkspacesCurrentToolProviderMcpResponses] +export type PostWorkspacesCurrentToolProviderMcpResponse = + PostWorkspacesCurrentToolProviderMcpResponses[keyof PostWorkspacesCurrentToolProviderMcpResponses] export type PutWorkspacesCurrentToolProviderMcpData = { body: McpProviderUpdatePayload @@ -4983,8 +4983,8 @@ export type PutWorkspacesCurrentToolProviderMcpResponses = { 200: SimpleResultResponse } -export type PutWorkspacesCurrentToolProviderMcpResponse - = PutWorkspacesCurrentToolProviderMcpResponses[keyof PutWorkspacesCurrentToolProviderMcpResponses] +export type PutWorkspacesCurrentToolProviderMcpResponse = + PutWorkspacesCurrentToolProviderMcpResponses[keyof PutWorkspacesCurrentToolProviderMcpResponses] export type PostWorkspacesCurrentToolProviderMcpAuthData = { body: McpAuthPayload @@ -4997,8 +4997,8 @@ export type PostWorkspacesCurrentToolProviderMcpAuthResponses = { 200: McpAuthResponse } -export type PostWorkspacesCurrentToolProviderMcpAuthResponse - = PostWorkspacesCurrentToolProviderMcpAuthResponses[keyof PostWorkspacesCurrentToolProviderMcpAuthResponses] +export type PostWorkspacesCurrentToolProviderMcpAuthResponse = + PostWorkspacesCurrentToolProviderMcpAuthResponses[keyof PostWorkspacesCurrentToolProviderMcpAuthResponses] export type GetWorkspacesCurrentToolProviderMcpToolsByProviderIdData = { body?: never @@ -5013,8 +5013,8 @@ export type GetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponses = { 200: ToolProviderApiEntityResponse } -export type GetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse - = GetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponses[keyof GetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponses] +export type GetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse = + GetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponses[keyof GetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponses] export type GetWorkspacesCurrentToolProviderMcpUpdateByProviderIdData = { body?: never @@ -5029,8 +5029,8 @@ export type GetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponses = { 200: ToolProviderApiEntityResponse } -export type GetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse - = GetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponses[keyof GetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponses] +export type GetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse = + GetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponses[keyof GetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponses] export type PostWorkspacesCurrentToolProviderWorkflowCreateData = { body: WorkflowToolCreatePayload @@ -5043,8 +5043,8 @@ export type PostWorkspacesCurrentToolProviderWorkflowCreateResponses = { 200: SimpleResultResponse } -export type PostWorkspacesCurrentToolProviderWorkflowCreateResponse - = PostWorkspacesCurrentToolProviderWorkflowCreateResponses[keyof PostWorkspacesCurrentToolProviderWorkflowCreateResponses] +export type PostWorkspacesCurrentToolProviderWorkflowCreateResponse = + PostWorkspacesCurrentToolProviderWorkflowCreateResponses[keyof PostWorkspacesCurrentToolProviderWorkflowCreateResponses] export type PostWorkspacesCurrentToolProviderWorkflowDeleteData = { body: WorkflowToolDeletePayload @@ -5057,8 +5057,8 @@ export type PostWorkspacesCurrentToolProviderWorkflowDeleteResponses = { 200: SimpleResultResponse } -export type PostWorkspacesCurrentToolProviderWorkflowDeleteResponse - = PostWorkspacesCurrentToolProviderWorkflowDeleteResponses[keyof PostWorkspacesCurrentToolProviderWorkflowDeleteResponses] +export type PostWorkspacesCurrentToolProviderWorkflowDeleteResponse = + PostWorkspacesCurrentToolProviderWorkflowDeleteResponses[keyof PostWorkspacesCurrentToolProviderWorkflowDeleteResponses] export type GetWorkspacesCurrentToolProviderWorkflowGetData = { body?: never @@ -5074,8 +5074,8 @@ export type GetWorkspacesCurrentToolProviderWorkflowGetResponses = { 200: WorkflowToolDetailResponse } -export type GetWorkspacesCurrentToolProviderWorkflowGetResponse - = GetWorkspacesCurrentToolProviderWorkflowGetResponses[keyof GetWorkspacesCurrentToolProviderWorkflowGetResponses] +export type GetWorkspacesCurrentToolProviderWorkflowGetResponse = + GetWorkspacesCurrentToolProviderWorkflowGetResponses[keyof GetWorkspacesCurrentToolProviderWorkflowGetResponses] export type GetWorkspacesCurrentToolProviderWorkflowToolsData = { body?: never @@ -5090,8 +5090,8 @@ export type GetWorkspacesCurrentToolProviderWorkflowToolsResponses = { 200: ToolApiListResponse } -export type GetWorkspacesCurrentToolProviderWorkflowToolsResponse - = GetWorkspacesCurrentToolProviderWorkflowToolsResponses[keyof GetWorkspacesCurrentToolProviderWorkflowToolsResponses] +export type GetWorkspacesCurrentToolProviderWorkflowToolsResponse = + GetWorkspacesCurrentToolProviderWorkflowToolsResponses[keyof GetWorkspacesCurrentToolProviderWorkflowToolsResponses] export type PostWorkspacesCurrentToolProviderWorkflowUpdateData = { body: WorkflowToolUpdatePayload @@ -5104,8 +5104,8 @@ export type PostWorkspacesCurrentToolProviderWorkflowUpdateResponses = { 200: SimpleResultResponse } -export type PostWorkspacesCurrentToolProviderWorkflowUpdateResponse - = PostWorkspacesCurrentToolProviderWorkflowUpdateResponses[keyof PostWorkspacesCurrentToolProviderWorkflowUpdateResponses] +export type PostWorkspacesCurrentToolProviderWorkflowUpdateResponse = + PostWorkspacesCurrentToolProviderWorkflowUpdateResponses[keyof PostWorkspacesCurrentToolProviderWorkflowUpdateResponses] export type GetWorkspacesCurrentToolProvidersData = { body?: never @@ -5120,8 +5120,8 @@ export type GetWorkspacesCurrentToolProvidersResponses = { 200: ToolProviderListResponse } -export type GetWorkspacesCurrentToolProvidersResponse - = GetWorkspacesCurrentToolProvidersResponses[keyof GetWorkspacesCurrentToolProvidersResponses] +export type GetWorkspacesCurrentToolProvidersResponse = + GetWorkspacesCurrentToolProvidersResponses[keyof GetWorkspacesCurrentToolProvidersResponses] export type GetWorkspacesCurrentToolsApiData = { body?: never @@ -5134,8 +5134,8 @@ export type GetWorkspacesCurrentToolsApiResponses = { 200: ToolProviderListResponse } -export type GetWorkspacesCurrentToolsApiResponse - = GetWorkspacesCurrentToolsApiResponses[keyof GetWorkspacesCurrentToolsApiResponses] +export type GetWorkspacesCurrentToolsApiResponse = + GetWorkspacesCurrentToolsApiResponses[keyof GetWorkspacesCurrentToolsApiResponses] export type GetWorkspacesCurrentToolsBuiltinData = { body?: never @@ -5148,8 +5148,8 @@ export type GetWorkspacesCurrentToolsBuiltinResponses = { 200: ToolProviderListResponse } -export type GetWorkspacesCurrentToolsBuiltinResponse - = GetWorkspacesCurrentToolsBuiltinResponses[keyof GetWorkspacesCurrentToolsBuiltinResponses] +export type GetWorkspacesCurrentToolsBuiltinResponse = + GetWorkspacesCurrentToolsBuiltinResponses[keyof GetWorkspacesCurrentToolsBuiltinResponses] export type GetWorkspacesCurrentToolsMcpData = { body?: never @@ -5162,8 +5162,8 @@ export type GetWorkspacesCurrentToolsMcpResponses = { 200: ToolProviderListResponse } -export type GetWorkspacesCurrentToolsMcpResponse - = GetWorkspacesCurrentToolsMcpResponses[keyof GetWorkspacesCurrentToolsMcpResponses] +export type GetWorkspacesCurrentToolsMcpResponse = + GetWorkspacesCurrentToolsMcpResponses[keyof GetWorkspacesCurrentToolsMcpResponses] export type GetWorkspacesCurrentToolsWorkflowData = { body?: never @@ -5176,8 +5176,8 @@ export type GetWorkspacesCurrentToolsWorkflowResponses = { 200: ToolProviderListResponse } -export type GetWorkspacesCurrentToolsWorkflowResponse - = GetWorkspacesCurrentToolsWorkflowResponses[keyof GetWorkspacesCurrentToolsWorkflowResponses] +export type GetWorkspacesCurrentToolsWorkflowResponse = + GetWorkspacesCurrentToolsWorkflowResponses[keyof GetWorkspacesCurrentToolsWorkflowResponses] export type GetWorkspacesCurrentTriggerProviderByProviderIconData = { body?: never @@ -5194,8 +5194,8 @@ export type GetWorkspacesCurrentTriggerProviderByProviderIconResponses = { } } -export type GetWorkspacesCurrentTriggerProviderByProviderIconResponse - = GetWorkspacesCurrentTriggerProviderByProviderIconResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderIconResponses] +export type GetWorkspacesCurrentTriggerProviderByProviderIconResponse = + GetWorkspacesCurrentTriggerProviderByProviderIconResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderIconResponses] export type GetWorkspacesCurrentTriggerProviderByProviderInfoData = { body?: never @@ -5210,8 +5210,8 @@ export type GetWorkspacesCurrentTriggerProviderByProviderInfoResponses = { 200: TriggerProviderApiEntity } -export type GetWorkspacesCurrentTriggerProviderByProviderInfoResponse - = GetWorkspacesCurrentTriggerProviderByProviderInfoResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderInfoResponses] +export type GetWorkspacesCurrentTriggerProviderByProviderInfoResponse = + GetWorkspacesCurrentTriggerProviderByProviderInfoResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderInfoResponses] export type DeleteWorkspacesCurrentTriggerProviderByProviderOauthClientData = { body?: never @@ -5226,8 +5226,8 @@ export type DeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponses 200: SimpleResultResponse } -export type DeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponse - = DeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponses[keyof DeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponses] +export type DeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponse = + DeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponses[keyof DeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponses] export type GetWorkspacesCurrentTriggerProviderByProviderOauthClientData = { body?: never @@ -5242,8 +5242,8 @@ export type GetWorkspacesCurrentTriggerProviderByProviderOauthClientResponses = 200: TriggerOAuthClientResponse } -export type GetWorkspacesCurrentTriggerProviderByProviderOauthClientResponse - = GetWorkspacesCurrentTriggerProviderByProviderOauthClientResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderOauthClientResponses] +export type GetWorkspacesCurrentTriggerProviderByProviderOauthClientResponse = + GetWorkspacesCurrentTriggerProviderByProviderOauthClientResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderOauthClientResponses] export type PostWorkspacesCurrentTriggerProviderByProviderOauthClientData = { body: TriggerOAuthClientPayload @@ -5258,11 +5258,11 @@ export type PostWorkspacesCurrentTriggerProviderByProviderOauthClientResponses = 200: SimpleResultResponse } -export type PostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse - = PostWorkspacesCurrentTriggerProviderByProviderOauthClientResponses[keyof PostWorkspacesCurrentTriggerProviderByProviderOauthClientResponses] +export type PostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse = + PostWorkspacesCurrentTriggerProviderByProviderOauthClientResponses[keyof PostWorkspacesCurrentTriggerProviderByProviderOauthClientResponses] -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdData - = { +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdData = + { body: TriggerSubscriptionBuilderUpdatePayload path: { provider: string @@ -5272,13 +5272,13 @@ export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBu url: '/workspaces/current/trigger-provider/{provider}/subscriptions/builder/build/{subscription_builder_id}' } -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponses - = { +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponses = + { 200: SimpleResultResponse } -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponse - = PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponses[keyof PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponses] +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponse = + PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponses[keyof PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponses] export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateData = { body: TriggerSubscriptionBuilderCreatePayload @@ -5293,11 +5293,11 @@ export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCr 200: TriggerSubscriptionBuilderCreateResponse } -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse - = PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponses[keyof PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponses] +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse = + PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponses[keyof PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponses] -export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdData - = { +export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdData = + { body?: never path: { provider: string @@ -5307,16 +5307,16 @@ export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLog url: '/workspaces/current/trigger-provider/{provider}/subscriptions/builder/logs/{subscription_builder_id}' } -export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponses - = { +export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponses = + { 200: TriggerSubscriptionBuilderLogsResponse } -export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponse - = GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponses] +export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponse = + GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponses] -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdData - = { +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdData = + { body: TriggerSubscriptionBuilderUpdatePayload path: { provider: string @@ -5326,16 +5326,16 @@ export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUp url: '/workspaces/current/trigger-provider/{provider}/subscriptions/builder/update/{subscription_builder_id}' } -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponses - = { +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponses = + { 200: SubscriptionBuilderApiEntity } -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponse - = PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponses[keyof PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponses] +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponse = + PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponses[keyof PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponses] -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdData - = { +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdData = + { body: TriggerSubscriptionBuilderVerifyPayload path: { provider: string @@ -5345,16 +5345,16 @@ export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVe url: '/workspaces/current/trigger-provider/{provider}/subscriptions/builder/verify-and-update/{subscription_builder_id}' } -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponses - = { +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponses = + { 200: TriggerVerificationResponse } -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponse - = PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponses[keyof PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponses] +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponse = + PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponses[keyof PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponses] -export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdData - = { +export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdData = + { body?: never path: { provider: string @@ -5364,13 +5364,13 @@ export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderByS url: '/workspaces/current/trigger-provider/{provider}/subscriptions/builder/{subscription_builder_id}' } -export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponses - = { +export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponses = + { 200: SubscriptionBuilderApiEntity } -export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponse - = GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponses] +export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponse = + GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponses] export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListData = { body?: never @@ -5385,15 +5385,15 @@ export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListErrors 404: TriggerProviderErrorResponse } -export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListError - = GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListErrors[keyof GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListErrors] +export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListError = + GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListErrors[keyof GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListErrors] export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponses = { 200: TriggerProviderSubscriptionListResponse } -export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse - = GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponses] +export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse = + GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponses] export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeData = { body?: never @@ -5408,11 +5408,11 @@ export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAutho 200: TriggerOAuthAuthorizeResponse } -export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse - = GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponses] +export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse = + GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponses[keyof GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponses] -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdData - = { +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdData = + { body: TriggerSubscriptionBuilderVerifyPayload path: { provider: string @@ -5422,13 +5422,13 @@ export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyByS url: '/workspaces/current/trigger-provider/{provider}/subscriptions/verify/{subscription_id}' } -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponses - = { +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponses = + { 200: TriggerVerificationResponse } -export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponse - = PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponses[keyof PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponses] +export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponse = + PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponses[keyof PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponses] export type PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteData = { body?: never @@ -5443,8 +5443,8 @@ export type PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDel 200: SimpleResultResponse } -export type PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponse - = PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponses[keyof PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponses] +export type PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponse = + PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponses[keyof PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponses] export type PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateData = { body: TriggerSubscriptionBuilderUpdatePayload @@ -5459,8 +5459,8 @@ export type PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpd 200: SimpleResultResponse } -export type PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse - = PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponses[keyof PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponses] +export type PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse = + PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponses[keyof PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponses] export type GetWorkspacesCurrentTriggersData = { body?: never @@ -5473,8 +5473,8 @@ export type GetWorkspacesCurrentTriggersResponses = { 200: TriggerProviderListResponse } -export type GetWorkspacesCurrentTriggersResponse - = GetWorkspacesCurrentTriggersResponses[keyof GetWorkspacesCurrentTriggersResponses] +export type GetWorkspacesCurrentTriggersResponse = + GetWorkspacesCurrentTriggersResponses[keyof GetWorkspacesCurrentTriggersResponses] export type PostWorkspacesCustomConfigData = { body: WorkspaceCustomConfigPayload @@ -5487,8 +5487,8 @@ export type PostWorkspacesCustomConfigResponses = { 200: WorkspaceTenantResultResponse } -export type PostWorkspacesCustomConfigResponse - = PostWorkspacesCustomConfigResponses[keyof PostWorkspacesCustomConfigResponses] +export type PostWorkspacesCustomConfigResponse = + PostWorkspacesCustomConfigResponses[keyof PostWorkspacesCustomConfigResponses] export type PostWorkspacesCustomConfigWebappLogoUploadData = { body: { @@ -5503,8 +5503,8 @@ export type PostWorkspacesCustomConfigWebappLogoUploadResponses = { 201: WorkspaceLogoUploadResponse } -export type PostWorkspacesCustomConfigWebappLogoUploadResponse - = PostWorkspacesCustomConfigWebappLogoUploadResponses[keyof PostWorkspacesCustomConfigWebappLogoUploadResponses] +export type PostWorkspacesCustomConfigWebappLogoUploadResponse = + PostWorkspacesCustomConfigWebappLogoUploadResponses[keyof PostWorkspacesCustomConfigWebappLogoUploadResponses] export type PostWorkspacesInfoData = { body: WorkspaceInfoPayload @@ -5517,8 +5517,8 @@ export type PostWorkspacesInfoResponses = { 200: WorkspaceTenantResultResponse } -export type PostWorkspacesInfoResponse - = PostWorkspacesInfoResponses[keyof PostWorkspacesInfoResponses] +export type PostWorkspacesInfoResponse = + PostWorkspacesInfoResponses[keyof PostWorkspacesInfoResponses] export type PostWorkspacesSwitchData = { body: SwitchWorkspacePayload @@ -5531,8 +5531,8 @@ export type PostWorkspacesSwitchResponses = { 200: SwitchWorkspaceResponse } -export type PostWorkspacesSwitchResponse - = PostWorkspacesSwitchResponses[keyof PostWorkspacesSwitchResponses] +export type PostWorkspacesSwitchResponse = + PostWorkspacesSwitchResponses[keyof PostWorkspacesSwitchResponses] export type GetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangData = { body?: never @@ -5552,5 +5552,5 @@ export type GetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangRespo } } -export type GetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse - = GetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponses[keyof GetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponses] +export type GetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse = + GetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponses[keyof GetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponses] diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index a8a45e01c33fb7..3b344f222239cd 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -3395,8 +3395,8 @@ export const zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmPat /** * Import confirmed successfully */ -export const zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse - = zSnippetImportResponse +export const zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse = + zSnippetImportResponse export const zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object({ snippet_id: z.uuid(), @@ -3434,8 +3434,8 @@ export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependencies /** * Dependencies checked successfully */ -export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse - = zSnippetDependencyCheckResponse +export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse = + zSnippetDependencyCheckResponse export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportPath = z.object({ snippet_id: z.uuid(), @@ -3457,8 +3457,8 @@ export const zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncremen /** * Use count incremented successfully */ -export const zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponse - = zSnippetUseCountResponse +export const zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponse = + zSnippetUseCountResponse /** * Success @@ -3583,14 +3583,14 @@ export const zPostWorkspacesCurrentMembersOwnerTransferCheckBody = zOwnerTransfe */ export const zPostWorkspacesCurrentMembersOwnerTransferCheckResponse = zVerificationTokenResponse -export const zPostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailBody - = zOwnerTransferEmailPayload +export const zPostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailBody = + zOwnerTransferEmailPayload /** * Success */ -export const zPostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailResponse - = zSimpleResultDataResponse +export const zPostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailResponse = + zSimpleResultDataResponse export const zDeleteWorkspacesCurrentMembersByMemberIdPath = z.object({ member_id: z.uuid(), @@ -3641,11 +3641,11 @@ export const zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlPath = z.ob /** * Model provider checkout URL retrieved successfully */ -export const zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponse - = zModelProviderPaymentCheckoutUrlResponse +export const zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponse = + zModelProviderPaymentCheckoutUrlResponse -export const zDeleteWorkspacesCurrentModelProvidersByProviderCredentialsBody - = zParserCredentialDelete +export const zDeleteWorkspacesCurrentModelProvidersByProviderCredentialsBody = + zParserCredentialDelete export const zDeleteWorkspacesCurrentModelProvidersByProviderCredentialsPath = z.object({ provider: z.string(), @@ -3667,8 +3667,8 @@ export const zGetWorkspacesCurrentModelProvidersByProviderCredentialsQuery = z.o /** * Provider credentials retrieved successfully */ -export const zGetWorkspacesCurrentModelProvidersByProviderCredentialsResponse - = zProviderCredentialsResponse +export const zGetWorkspacesCurrentModelProvidersByProviderCredentialsResponse = + zProviderCredentialsResponse export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsBody = zParserCredentialCreate @@ -3679,8 +3679,8 @@ export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsPath = z.o /** * Credential created successfully */ -export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsResponse = + zSimpleResultResponse export const zPutWorkspacesCurrentModelProvidersByProviderCredentialsBody = zParserCredentialUpdate @@ -3691,11 +3691,11 @@ export const zPutWorkspacesCurrentModelProvidersByProviderCredentialsPath = z.ob /** * Credential updated successfully */ -export const zPutWorkspacesCurrentModelProvidersByProviderCredentialsResponse - = zSimpleResultResponse +export const zPutWorkspacesCurrentModelProvidersByProviderCredentialsResponse = + zSimpleResultResponse -export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchBody - = zParserCredentialSwitch +export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchBody = + zParserCredentialSwitch export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchPath = z.object({ provider: z.string(), @@ -3704,11 +3704,11 @@ export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchPath /** * Success */ -export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchResponse = + zSimpleResultResponse -export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsValidateBody - = zParserCredentialValidate +export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsValidateBody = + zParserCredentialValidate export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsValidatePath = z.object({ provider: z.string(), @@ -3717,8 +3717,8 @@ export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsValidatePa /** * Provider credentials validated successfully */ -export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponse - = zValidationResultResponse +export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponse = + zValidationResultResponse export const zDeleteWorkspacesCurrentModelProvidersByProviderModelsBody = zParserDeleteModels @@ -3738,8 +3738,8 @@ export const zGetWorkspacesCurrentModelProvidersByProviderModelsPath = z.object( /** * Provider models retrieved successfully */ -export const zGetWorkspacesCurrentModelProvidersByProviderModelsResponse - = zProviderModelListResponse +export const zGetWorkspacesCurrentModelProvidersByProviderModelsResponse = + zProviderModelListResponse export const zPostWorkspacesCurrentModelProvidersByProviderModelsBody = zParserPostModels @@ -3752,8 +3752,8 @@ export const zPostWorkspacesCurrentModelProvidersByProviderModelsPath = z.object */ export const zPostWorkspacesCurrentModelProvidersByProviderModelsResponse = zSimpleResultResponse -export const zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody - = zParserDeleteCredential +export const zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody = + zParserDeleteCredential export const zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath = z.object({ provider: z.string(), @@ -3778,11 +3778,11 @@ export const zGetWorkspacesCurrentModelProvidersByProviderModelsCredentialsQuery /** * Model credentials retrieved successfully */ -export const zGetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse - = zModelCredentialResponse +export const zGetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse = + zModelCredentialResponse -export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody - = zParserCreateCredential +export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody = + zParserCreateCredential export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath = z.object({ provider: z.string(), @@ -3791,11 +3791,11 @@ export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath /** * Model credential created successfully */ -export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse = + zSimpleResultResponse -export const zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody - = zParserUpdateCredential +export const zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody = + zParserUpdateCredential export const zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath = z.object({ provider: z.string(), @@ -3804,11 +3804,11 @@ export const zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath /** * Model credential updated successfully */ -export const zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse - = zSimpleResultResponse +export const zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse = + zSimpleResultResponse -export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchBody - = zParserSwitch +export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchBody = + zParserSwitch export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchPath = z.object({ provider: z.string(), @@ -3817,11 +3817,11 @@ export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwit /** * Success */ -export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchResponse = + zSimpleResultResponse -export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateBody - = zParserValidate +export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateBody = + zParserValidate export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidatePath = z.object( { @@ -3832,8 +3832,8 @@ export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsVali /** * Model credentials validated successfully */ -export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponse - = zValidationResultResponse +export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponse = + zValidationResultResponse export const zPatchWorkspacesCurrentModelProvidersByProviderModelsDisableBody = zParserDeleteModels @@ -3844,8 +3844,8 @@ export const zPatchWorkspacesCurrentModelProvidersByProviderModelsDisablePath = /** * Success */ -export const zPatchWorkspacesCurrentModelProvidersByProviderModelsDisableResponse - = zSimpleResultResponse +export const zPatchWorkspacesCurrentModelProvidersByProviderModelsDisableResponse = + zSimpleResultResponse export const zPatchWorkspacesCurrentModelProvidersByProviderModelsEnableBody = zParserDeleteModels @@ -3856,28 +3856,28 @@ export const zPatchWorkspacesCurrentModelProvidersByProviderModelsEnablePath = z /** * Success */ -export const zPatchWorkspacesCurrentModelProvidersByProviderModelsEnableResponse - = zSimpleResultResponse +export const zPatchWorkspacesCurrentModelProvidersByProviderModelsEnableResponse = + zSimpleResultResponse -export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateBody - = zLoadBalancingCredentialPayload +export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateBody = + zLoadBalancingCredentialPayload -export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidatePath - = z.object({ +export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidatePath = + z.object({ provider: z.string(), }) /** * Credential validation result */ -export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponse - = zLoadBalancingCredentialValidateResponse +export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponse = + zLoadBalancingCredentialValidateResponse -export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateBody - = zLoadBalancingCredentialPayload +export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateBody = + zLoadBalancingCredentialPayload -export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidatePath - = z.object({ +export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidatePath = + z.object({ config_id: z.string(), provider: z.string(), }) @@ -3885,8 +3885,8 @@ export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingCo /** * Credential validation result */ -export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponse - = zLoadBalancingCredentialValidateResponse +export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponse = + zLoadBalancingCredentialValidateResponse export const zGetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesPath = z.object({ provider: z.string(), @@ -3899,11 +3899,11 @@ export const zGetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesQu /** * Model parameter rules retrieved successfully */ -export const zGetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponse - = zModelParameterRuleListResponse +export const zGetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponse = + zModelParameterRuleListResponse -export const zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeBody - = zParserPreferredProviderType +export const zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeBody = + zParserPreferredProviderType export const zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypePath = z.object({ provider: z.string(), @@ -3912,8 +3912,8 @@ export const zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderType /** * Success */ -export const zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponse = + zSimpleResultResponse export const zGetWorkspacesCurrentModelsModelTypesByModelTypePath = z.object({ model_type: z.string(), @@ -3944,8 +3944,8 @@ export const zPostWorkspacesCurrentPluginAutoUpgradeChangeBody = zParserAutoUpgr /** * Success */ -export const zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse - = zPluginAutoUpgradeChangeResponse +export const zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse = + zPluginAutoUpgradeChangeResponse export const zPostWorkspacesCurrentPluginAutoUpgradeExcludeBody = zParserExcludePlugin @@ -3999,8 +3999,8 @@ export const zPostWorkspacesCurrentPluginInstallMarketplaceBody = zParserPluginI /** * Success */ -export const zPostWorkspacesCurrentPluginInstallMarketplaceResponse - = zPluginInstallTaskStartResponse +export const zPostWorkspacesCurrentPluginInstallMarketplaceResponse = + zPluginInstallTaskStartResponse export const zPostWorkspacesCurrentPluginInstallPkgBody = zParserPluginIdentifiers @@ -4054,17 +4054,17 @@ export const zGetWorkspacesCurrentPluginParametersDynamicOptionsQuery = z.object /** * Success */ -export const zGetWorkspacesCurrentPluginParametersDynamicOptionsResponse - = zPluginDynamicOptionsResponse +export const zGetWorkspacesCurrentPluginParametersDynamicOptionsResponse = + zPluginDynamicOptionsResponse -export const zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsBody - = zParserDynamicOptionsWithCredentials +export const zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsBody = + zParserDynamicOptionsWithCredentials /** * Success */ -export const zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse - = zPluginDynamicOptionsResponse +export const zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse = + zPluginDynamicOptionsResponse export const zPostWorkspacesCurrentPluginPermissionChangeBody = zParserPermissionChange @@ -4150,8 +4150,8 @@ export const zPostWorkspacesCurrentPluginUpgradeMarketplaceBody = zParserMarketp /** * Success */ -export const zPostWorkspacesCurrentPluginUpgradeMarketplaceResponse - = zPluginInstallTaskStartResponse +export const zPostWorkspacesCurrentPluginUpgradeMarketplaceResponse = + zPluginInstallTaskStartResponse /** * Success @@ -4237,8 +4237,8 @@ export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockPath = /** * Success */ -export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse - = zAccessPolicyBindingState +export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse = + zAccessPolicyBindingState export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockPath = z.object({ binding_id: z.uuid(), @@ -4247,14 +4247,14 @@ export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockPath /** * Success */ -export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse - = zAccessPolicyBindingState +export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse = + zAccessPolicyBindingState -export const zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsBody - = zDeleteMemberBindingsRequest +export const zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsBody = + zDeleteMemberBindingsRequest -export const zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath - = z.object({ +export const zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath = + z.object({ app_id: z.uuid(), policy_id: z.string(), }) @@ -4262,11 +4262,11 @@ export const zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemb /** * Success */ -export const zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse - = zMemberBindingsResponse +export const zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse = + zMemberBindingsResponse -export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath - = z.object({ +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath = + z.object({ app_id: z.uuid(), policy_id: z.string(), }) @@ -4274,11 +4274,11 @@ export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberB /** * Success */ -export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse - = zMemberBindingsResponse +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse = + zMemberBindingsResponse -export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsPath - = z.object({ +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsPath = + z.object({ app_id: z.uuid(), policy_id: z.uuid(), }) @@ -4286,8 +4286,8 @@ export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBin /** * Success */ -export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse - = zRoleBindingsResponse +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse = + zRoleBindingsResponse export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyPath = z.object({ app_id: z.uuid(), @@ -4313,14 +4313,14 @@ export const zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesQuery = z.obj /** * Success */ -export const zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse - = zResourceUserAccessPoliciesResponse +export const zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse = + zResourceUserAccessPoliciesResponse -export const zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesBody - = zReplaceUserAccessPolicies +export const zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesBody = + zReplaceUserAccessPolicies -export const zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesPath - = z.object({ +export const zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesPath = + z.object({ app_id: z.uuid(), target_account_id: z.uuid(), }) @@ -4328,8 +4328,8 @@ export const zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPol /** * Success */ -export const zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse - = zReplaceUserAccessPoliciesResponse +export const zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse = + zReplaceUserAccessPoliciesResponse export const zGetWorkspacesCurrentRbacAppsByAppIdWhitelistPath = z.object({ app_id: z.uuid(), @@ -4351,11 +4351,11 @@ export const zPutWorkspacesCurrentRbacAppsByAppIdWhitelistPath = z.object({ */ export const zPutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse = zResourceWhitelist -export const zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsBody - = zDeleteMemberBindingsRequest +export const zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsBody = + zDeleteMemberBindingsRequest -export const zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath - = z.object({ +export const zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath = + z.object({ dataset_id: z.uuid(), policy_id: z.string(), }) @@ -4363,11 +4363,11 @@ export const zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPoli /** * Success */ -export const zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse - = zMemberBindingsResponse +export const zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse = + zMemberBindingsResponse -export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath - = z.object({ +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath = + z.object({ dataset_id: z.uuid(), policy_id: z.string(), }) @@ -4375,11 +4375,11 @@ export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyI /** * Success */ -export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse - = zMemberBindingsResponse +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse = + zMemberBindingsResponse -export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsPath - = z.object({ +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsPath = + z.object({ dataset_id: z.uuid(), policy_id: z.uuid(), }) @@ -4387,8 +4387,8 @@ export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyI /** * Success */ -export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse - = zRoleBindingsResponse +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse = + zRoleBindingsResponse export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyPath = z.object({ dataset_id: z.uuid(), @@ -4414,14 +4414,14 @@ export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesQuery /** * Success */ -export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse - = zResourceUserAccessPoliciesResponse +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse = + zResourceUserAccessPoliciesResponse -export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesBody - = zReplaceUserAccessPolicies +export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesBody = + zReplaceUserAccessPolicies -export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesPath - = z.object({ +export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesPath = + z.object({ dataset_id: z.uuid(), target_account_id: z.uuid(), }) @@ -4429,8 +4429,8 @@ export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdA /** * Success */ -export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse - = zReplaceUserAccessPoliciesResponse +export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse = + zReplaceUserAccessPoliciesResponse export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath = z.object({ dataset_id: z.uuid(), @@ -4490,8 +4490,8 @@ export const zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse = zPermi /** * Success */ -export const zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse - = zPermissionCatalogResponse +export const zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse = + zPermissionCatalogResponse /** * Success @@ -4548,8 +4548,8 @@ export const zGetWorkspacesCurrentRbacRolesByRoleIdMembersPath = z.object({ */ export const zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse = zMembersInRoleList -export const zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsBody - = zReplaceBindingsRequest +export const zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsBody = + zReplaceBindingsRequest export const zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsPath = z.object({ policy_id: z.uuid(), @@ -4558,71 +4558,71 @@ export const zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindi /** * Success */ -export const zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse - = zAccessMatrixItem +export const zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse = + zAccessMatrixItem -export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsPath - = z.object({ +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsPath = + z.object({ policy_id: z.uuid(), }) /** * Success */ -export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse - = zMemberBindingsResponse +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse = + zMemberBindingsResponse -export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsPath - = z.object({ +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsPath = + z.object({ policy_id: z.uuid(), }) /** * Success */ -export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse - = zRoleBindingsResponse +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse = + zRoleBindingsResponse /** * Success */ export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse = zWorkspaceAccessMatrix -export const zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsBody - = zReplaceBindingsRequest +export const zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsBody = + zReplaceBindingsRequest -export const zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsPath - = z.object({ +export const zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsPath = + z.object({ policy_id: z.uuid(), }) /** * Success */ -export const zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse - = zAccessMatrixItem +export const zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse = + zAccessMatrixItem -export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsPath - = z.object({ +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsPath = + z.object({ policy_id: z.uuid(), }) /** * Success */ -export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse - = zMemberBindingsResponse +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse = + zMemberBindingsResponse -export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsPath - = z.object({ +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsPath = + z.object({ policy_id: z.uuid(), }) /** * Success */ -export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse - = zRoleBindingsResponse +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse = + zRoleBindingsResponse /** * Success @@ -4718,11 +4718,11 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoQue /** * Builtin provider credential info retrieved successfully */ -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse - = zToolProviderCredentialInfoApiEntity +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse = + zToolProviderCredentialInfoApiEntity -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypePath - = z.object({ +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypePath = + z.object({ credential_type: z.string(), provider: z.string(), }) @@ -4730,8 +4730,8 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaB /** * Builtin provider credential schema retrieved successfully */ -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponse - = zProviderConfigListResponse +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponse = + zProviderConfigListResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsPath = z.object({ provider: z.string(), @@ -4744,11 +4744,11 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsQuery /** * Builtin provider credentials retrieved successfully */ -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse - = zToolProviderCredentialListResponse +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse = + zToolProviderCredentialListResponse -export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialBody - = zBuiltinProviderDefaultCredentialPayload +export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialBody = + zBuiltinProviderDefaultCredentialPayload export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialPath = z.object({ provider: z.string(), @@ -4757,11 +4757,11 @@ export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentia /** * Default credential set successfully */ -export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse = + zSimpleResultResponse -export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteBody - = zBuiltinToolCredentialDeletePayload +export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteBody = + zBuiltinToolCredentialDeletePayload export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDeletePath = z.object({ provider: z.string(), @@ -4770,8 +4770,8 @@ export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDeletePath = z.o /** * Builtin provider credential deleted successfully */ -export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse = + zSimpleResultResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderIconPath = z.object({ provider: z.string(), @@ -4792,8 +4792,8 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoPath = z.obje /** * Builtin provider info retrieved successfully */ -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse - = zToolProviderApiEntityResponse +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse = + zToolProviderApiEntityResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaPath = z.object({ provider: z.string(), @@ -4802,8 +4802,8 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchema /** * Builtin provider OAuth client schema retrieved successfully */ -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse - = zBuiltinProviderOAuthClientSchemaResponse +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse = + zBuiltinProviderOAuthClientSchemaResponse export const zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientPath = z.object({ provider: z.string(), @@ -4812,8 +4812,8 @@ export const zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomCli /** * Custom OAuth client deleted successfully */ -export const zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse - = zSimpleResultResponse +export const zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse = + zSimpleResultResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientPath = z.object({ provider: z.string(), @@ -4827,8 +4827,8 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClient z.unknown(), ) -export const zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientBody - = zToolOAuthCustomClientPayload +export const zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientBody = + zToolOAuthCustomClientPayload export const zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientPath = z.object({ provider: z.string(), @@ -4837,8 +4837,8 @@ export const zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClien /** * Custom OAuth client saved successfully */ -export const zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse = + zSimpleResultResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsPath = z.object({ provider: z.string(), @@ -4849,8 +4849,8 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsPath = z.obj */ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse = zToolApiListResponse -export const zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateBody - = zBuiltinToolUpdatePayload +export const zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateBody = + zBuiltinToolUpdatePayload export const zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdatePath = z.object({ provider: z.string(), @@ -4859,8 +4859,8 @@ export const zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdatePath = z.o /** * Builtin provider updated successfully */ -export const zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse = + zSimpleResultResponse export const zDeleteWorkspacesCurrentToolProviderMcpBody = zMcpProviderDeletePayload @@ -4897,8 +4897,8 @@ export const zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdPath = z.objec /** * MCP provider retrieved successfully */ -export const zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse - = zToolProviderApiEntityResponse +export const zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse = + zToolProviderApiEntityResponse export const zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdPath = z.object({ provider_id: z.string(), @@ -4907,8 +4907,8 @@ export const zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdPath = z.obje /** * MCP provider tools refreshed successfully */ -export const zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse - = zToolProviderApiEntityResponse +export const zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse = + zToolProviderApiEntityResponse export const zPostWorkspacesCurrentToolProviderWorkflowCreateBody = zWorkflowToolCreatePayload @@ -5007,8 +5007,8 @@ export const zDeleteWorkspacesCurrentTriggerProviderByProviderOauthClientPath = /** * Trigger OAuth client deleted successfully */ -export const zDeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponse - = zSimpleResultResponse +export const zDeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponse = + zSimpleResultResponse export const zGetWorkspacesCurrentTriggerProviderByProviderOauthClientPath = z.object({ provider: z.string(), @@ -5017,11 +5017,11 @@ export const zGetWorkspacesCurrentTriggerProviderByProviderOauthClientPath = z.o /** * Trigger OAuth client retrieved successfully */ -export const zGetWorkspacesCurrentTriggerProviderByProviderOauthClientResponse - = zTriggerOAuthClientResponse +export const zGetWorkspacesCurrentTriggerProviderByProviderOauthClientResponse = + zTriggerOAuthClientResponse -export const zPostWorkspacesCurrentTriggerProviderByProviderOauthClientBody - = zTriggerOAuthClientPayload +export const zPostWorkspacesCurrentTriggerProviderByProviderOauthClientBody = + zTriggerOAuthClientPayload export const zPostWorkspacesCurrentTriggerProviderByProviderOauthClientPath = z.object({ provider: z.string(), @@ -5030,14 +5030,14 @@ export const zPostWorkspacesCurrentTriggerProviderByProviderOauthClientPath = z. /** * Trigger OAuth client saved successfully */ -export const zPostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse = + zSimpleResultResponse -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdBody - = zTriggerSubscriptionBuilderUpdatePayload +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdBody = + zTriggerSubscriptionBuilderUpdatePayload -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdPath - = z.object({ +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdPath = + z.object({ provider: z.string(), subscription_builder_id: z.string(), }) @@ -5045,25 +5045,25 @@ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilder /** * Trigger subscription builder built successfully */ -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponse = + zSimpleResultResponse -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateBody - = zTriggerSubscriptionBuilderCreatePayload +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateBody = + zTriggerSubscriptionBuilderCreatePayload -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreatePath - = z.object({ +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreatePath = + z.object({ provider: z.string(), }) /** * Trigger subscription builder created successfully */ -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse - = zTriggerSubscriptionBuilderCreateResponse +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse = + zTriggerSubscriptionBuilderCreateResponse -export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdPath - = z.object({ +export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdPath = + z.object({ provider: z.string(), subscription_builder_id: z.string(), }) @@ -5071,14 +5071,14 @@ export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderL /** * Trigger subscription builder logs retrieved successfully */ -export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponse - = zTriggerSubscriptionBuilderLogsResponse +export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponse = + zTriggerSubscriptionBuilderLogsResponse -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdBody - = zTriggerSubscriptionBuilderUpdatePayload +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdBody = + zTriggerSubscriptionBuilderUpdatePayload -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdPath - = z.object({ +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdPath = + z.object({ provider: z.string(), subscription_builder_id: z.string(), }) @@ -5086,14 +5086,14 @@ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilder /** * Trigger subscription builder updated successfully */ -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponse - = zSubscriptionBuilderApiEntity +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponse = + zSubscriptionBuilderApiEntity -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdBody - = zTriggerSubscriptionBuilderVerifyPayload +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdBody = + zTriggerSubscriptionBuilderVerifyPayload -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdPath - = z.object({ +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdPath = + z.object({ provider: z.string(), subscription_builder_id: z.string(), }) @@ -5101,11 +5101,11 @@ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilder /** * Trigger subscription builder verified successfully */ -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponse - = zTriggerVerificationResponse +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponse = + zTriggerVerificationResponse -export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdPath - = z.object({ +export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdPath = + z.object({ provider: z.string(), subscription_builder_id: z.string(), }) @@ -5113,8 +5113,8 @@ export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderB /** * Trigger subscription builder retrieved successfully */ -export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponse - = zSubscriptionBuilderApiEntity +export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponse = + zSubscriptionBuilderApiEntity export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListPath = z.object({ provider: z.string(), @@ -5123,25 +5123,25 @@ export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListPath /** * Trigger subscriptions retrieved successfully */ -export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse - = zTriggerProviderSubscriptionListResponse +export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse = + zTriggerProviderSubscriptionListResponse -export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizePath - = z.object({ +export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizePath = + z.object({ provider: z.string(), }) /** * Trigger OAuth authorization URL generated successfully */ -export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse - = zTriggerOAuthAuthorizeResponse +export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse = + zTriggerOAuthAuthorizeResponse -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdBody - = zTriggerSubscriptionBuilderVerifyPayload +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdBody = + zTriggerSubscriptionBuilderVerifyPayload -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdPath - = z.object({ +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdPath = + z.object({ provider: z.string(), subscription_id: z.string(), }) @@ -5149,33 +5149,33 @@ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyB /** * Trigger subscription verified successfully */ -export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponse - = zTriggerVerificationResponse +export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponse = + zTriggerVerificationResponse -export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeletePath - = z.object({ +export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeletePath = + z.object({ subscription_id: z.string(), }) /** * Success */ -export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponse = + zSimpleResultResponse -export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateBody - = zTriggerSubscriptionBuilderUpdatePayload +export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateBody = + zTriggerSubscriptionBuilderUpdatePayload -export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdatePath - = z.object({ +export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdatePath = + z.object({ subscription_id: z.string(), }) /** * Trigger subscription updated successfully */ -export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse - = zSimpleResultResponse +export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse = + zSimpleResultResponse /** * Trigger providers retrieved successfully diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts index 13ac22da9623ab..0640eeeb6a4cf9 100644 --- a/packages/contracts/generated/api/openapi/orpc.gen.ts +++ b/packages/contracts/generated/api/openapi/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteAccountSessionsBySessionIdPath, zDeleteAccountSessionsBySessionIdResponse, diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 677f1101f0bcec..08b572368eca89 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -97,15 +97,15 @@ export type AppListRow = { workspace_name?: string | null } -export type AppMode - = | 'advanced-chat' - | 'agent' - | 'agent-chat' - | 'channel' - | 'chat' - | 'completion' - | 'rag-pipeline' - | 'workflow' +export type AppMode = + | 'advanced-chat' + | 'agent' + | 'agent-chat' + | 'channel' + | 'chat' + | 'completion' + | 'rag-pipeline' + | 'workflow' export type AppRunRequest = { auto_generate_name?: boolean @@ -314,40 +314,40 @@ export type MessageMetadata = { usage?: UsageInfo | null } -export type OpenApiErrorCode - = | 'agent_not_published' - | 'app_unavailable' - | 'bad_gateway' - | 'bad_request' - | 'completion_request_error' - | 'conflict' - | 'conversation_completed' - | 'file_extension_blocked' - | 'file_too_large' - | 'filename_not_exists' - | 'forbidden' - | 'form_not_found' - | 'internal_server_error' - | 'invalid_param' - | 'member_license_exceeded' - | 'member_limit_exceeded' - | 'method_not_allowed' - | 'model_currently_not_support' - | 'no_file_uploaded' - | 'not_acceptable' - | 'not_found' - | 'provider_not_initialize' - | 'provider_quota_exceeded' - | 'rate_limit_error' - | 'recipient_surface_mismatch' - | 'request_entity_too_large' - | 'too_many_files' - | 'too_many_requests' - | 'unauthorized' - | 'unknown' - | 'unsupported_file_type' - | 'unsupported_media_type' - | 'upgrade_required' +export type OpenApiErrorCode = + | 'agent_not_published' + | 'app_unavailable' + | 'bad_gateway' + | 'bad_request' + | 'completion_request_error' + | 'conflict' + | 'conversation_completed' + | 'file_extension_blocked' + | 'file_too_large' + | 'filename_not_exists' + | 'forbidden' + | 'form_not_found' + | 'internal_server_error' + | 'invalid_param' + | 'member_license_exceeded' + | 'member_limit_exceeded' + | 'method_not_allowed' + | 'model_currently_not_support' + | 'no_file_uploaded' + | 'not_acceptable' + | 'not_found' + | 'provider_not_initialize' + | 'provider_quota_exceeded' + | 'rate_limit_error' + | 'recipient_surface_mismatch' + | 'request_entity_too_large' + | 'too_many_files' + | 'too_many_requests' + | 'unauthorized' + | 'unknown' + | 'unsupported_file_type' + | 'unsupported_media_type' + | 'upgrade_required' export type Package = { plugin_unique_identifier: string @@ -545,8 +545,8 @@ export type GetAccountSessionsResponses = { 200: SessionListResponse } -export type GetAccountSessionsResponse - = GetAccountSessionsResponses[keyof GetAccountSessionsResponses] +export type GetAccountSessionsResponse = + GetAccountSessionsResponses[keyof GetAccountSessionsResponses] export type DeleteAccountSessionsSelfData = { body?: never @@ -559,15 +559,15 @@ export type DeleteAccountSessionsSelfErrors = { default: ErrorBody } -export type DeleteAccountSessionsSelfError - = DeleteAccountSessionsSelfErrors[keyof DeleteAccountSessionsSelfErrors] +export type DeleteAccountSessionsSelfError = + DeleteAccountSessionsSelfErrors[keyof DeleteAccountSessionsSelfErrors] export type DeleteAccountSessionsSelfResponses = { 200: RevokeResponse } -export type DeleteAccountSessionsSelfResponse - = DeleteAccountSessionsSelfResponses[keyof DeleteAccountSessionsSelfResponses] +export type DeleteAccountSessionsSelfResponse = + DeleteAccountSessionsSelfResponses[keyof DeleteAccountSessionsSelfResponses] export type DeleteAccountSessionsBySessionIdData = { body?: never @@ -582,15 +582,15 @@ export type DeleteAccountSessionsBySessionIdErrors = { default: ErrorBody } -export type DeleteAccountSessionsBySessionIdError - = DeleteAccountSessionsBySessionIdErrors[keyof DeleteAccountSessionsBySessionIdErrors] +export type DeleteAccountSessionsBySessionIdError = + DeleteAccountSessionsBySessionIdErrors[keyof DeleteAccountSessionsBySessionIdErrors] export type DeleteAccountSessionsBySessionIdResponses = { 200: RevokeResponse } -export type DeleteAccountSessionsBySessionIdResponse - = DeleteAccountSessionsBySessionIdResponses[keyof DeleteAccountSessionsBySessionIdResponses] +export type DeleteAccountSessionsBySessionIdResponse = + DeleteAccountSessionsBySessionIdResponses[keyof DeleteAccountSessionsBySessionIdResponses] export type GetAppsData = { body?: never @@ -655,15 +655,15 @@ export type GetAppsByAppIdDependenciesCheckErrors = { default: ErrorBody } -export type GetAppsByAppIdDependenciesCheckError - = GetAppsByAppIdDependenciesCheckErrors[keyof GetAppsByAppIdDependenciesCheckErrors] +export type GetAppsByAppIdDependenciesCheckError = + GetAppsByAppIdDependenciesCheckErrors[keyof GetAppsByAppIdDependenciesCheckErrors] export type GetAppsByAppIdDependenciesCheckResponses = { 200: CheckDependenciesResult } -export type GetAppsByAppIdDependenciesCheckResponse - = GetAppsByAppIdDependenciesCheckResponses[keyof GetAppsByAppIdDependenciesCheckResponses] +export type GetAppsByAppIdDependenciesCheckResponse = + GetAppsByAppIdDependenciesCheckResponses[keyof GetAppsByAppIdDependenciesCheckResponses] export type GetAppsByAppIdDslData = { body?: never @@ -713,8 +713,8 @@ export type PostAppsByAppIdFilesResponses = { 201: FileResponse } -export type PostAppsByAppIdFilesResponse - = PostAppsByAppIdFilesResponses[keyof PostAppsByAppIdFilesResponses] +export type PostAppsByAppIdFilesResponse = + PostAppsByAppIdFilesResponses[keyof PostAppsByAppIdFilesResponses] export type GetAppsByAppIdHumanInputFormsByFormTokenData = { body?: never @@ -730,8 +730,8 @@ export type GetAppsByAppIdHumanInputFormsByFormTokenResponses = { 200: HumanInputFormDefinitionResponse } -export type GetAppsByAppIdHumanInputFormsByFormTokenResponse - = GetAppsByAppIdHumanInputFormsByFormTokenResponses[keyof GetAppsByAppIdHumanInputFormsByFormTokenResponses] +export type GetAppsByAppIdHumanInputFormsByFormTokenResponse = + GetAppsByAppIdHumanInputFormsByFormTokenResponses[keyof GetAppsByAppIdHumanInputFormsByFormTokenResponses] export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitData = { body: HumanInputFormSubmitPayload @@ -748,15 +748,15 @@ export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitErrors = { default: ErrorBody } -export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitError - = PostAppsByAppIdHumanInputFormsByFormTokenSubmitErrors[keyof PostAppsByAppIdHumanInputFormsByFormTokenSubmitErrors] +export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitError = + PostAppsByAppIdHumanInputFormsByFormTokenSubmitErrors[keyof PostAppsByAppIdHumanInputFormsByFormTokenSubmitErrors] export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses = { 200: FormSubmitResponse } -export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse - = PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses[keyof PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses] +export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse = + PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses[keyof PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses] export type GetAppsByAppIdTasksByTaskIdEventsData = { body?: never @@ -775,8 +775,8 @@ export type GetAppsByAppIdTasksByTaskIdEventsResponses = { 200: EventStreamResponse } -export type GetAppsByAppIdTasksByTaskIdEventsResponse - = GetAppsByAppIdTasksByTaskIdEventsResponses[keyof GetAppsByAppIdTasksByTaskIdEventsResponses] +export type GetAppsByAppIdTasksByTaskIdEventsResponse = + GetAppsByAppIdTasksByTaskIdEventsResponses[keyof GetAppsByAppIdTasksByTaskIdEventsResponses] export type PostAppsByAppIdTasksByTaskIdStopData = { body?: never @@ -792,15 +792,15 @@ export type PostAppsByAppIdTasksByTaskIdStopErrors = { default: ErrorBody } -export type PostAppsByAppIdTasksByTaskIdStopError - = PostAppsByAppIdTasksByTaskIdStopErrors[keyof PostAppsByAppIdTasksByTaskIdStopErrors] +export type PostAppsByAppIdTasksByTaskIdStopError = + PostAppsByAppIdTasksByTaskIdStopErrors[keyof PostAppsByAppIdTasksByTaskIdStopErrors] export type PostAppsByAppIdTasksByTaskIdStopResponses = { 200: TaskStopResponse } -export type PostAppsByAppIdTasksByTaskIdStopResponse - = PostAppsByAppIdTasksByTaskIdStopResponses[keyof PostAppsByAppIdTasksByTaskIdStopResponses] +export type PostAppsByAppIdTasksByTaskIdStopResponse = + PostAppsByAppIdTasksByTaskIdStopResponses[keyof PostAppsByAppIdTasksByTaskIdStopResponses] export type PostAppsByAppIdRunData = { body: AppRunRequest @@ -821,8 +821,8 @@ export type PostAppsByAppIdRunResponses = { 200: EventStreamResponse } -export type PostAppsByAppIdRunResponse - = PostAppsByAppIdRunResponses[keyof PostAppsByAppIdRunResponses] +export type PostAppsByAppIdRunResponse = + PostAppsByAppIdRunResponses[keyof PostAppsByAppIdRunResponses] export type PostOauthDeviceApproveData = { body: DeviceMutateRequest @@ -835,8 +835,8 @@ export type PostOauthDeviceApproveResponses = { 200: DeviceMutateResponse } -export type PostOauthDeviceApproveResponse - = PostOauthDeviceApproveResponses[keyof PostOauthDeviceApproveResponses] +export type PostOauthDeviceApproveResponse = + PostOauthDeviceApproveResponses[keyof PostOauthDeviceApproveResponses] export type PostOauthDeviceCodeData = { body: DeviceCodeRequest @@ -849,8 +849,8 @@ export type PostOauthDeviceCodeResponses = { 200: DeviceCodeResponse } -export type PostOauthDeviceCodeResponse - = PostOauthDeviceCodeResponses[keyof PostOauthDeviceCodeResponses] +export type PostOauthDeviceCodeResponse = + PostOauthDeviceCodeResponses[keyof PostOauthDeviceCodeResponses] export type PostOauthDeviceDenyData = { body: DeviceMutateRequest @@ -863,8 +863,8 @@ export type PostOauthDeviceDenyResponses = { 200: DeviceMutateResponse } -export type PostOauthDeviceDenyResponse - = PostOauthDeviceDenyResponses[keyof PostOauthDeviceDenyResponses] +export type PostOauthDeviceDenyResponse = + PostOauthDeviceDenyResponses[keyof PostOauthDeviceDenyResponses] export type GetOauthDeviceLookupData = { body?: never @@ -879,8 +879,8 @@ export type GetOauthDeviceLookupResponses = { 200: DeviceLookupResponse } -export type GetOauthDeviceLookupResponse - = GetOauthDeviceLookupResponses[keyof GetOauthDeviceLookupResponses] +export type GetOauthDeviceLookupResponse = + GetOauthDeviceLookupResponses[keyof GetOauthDeviceLookupResponses] export type PostOauthDeviceTokenData = { body: DevicePollRequest @@ -893,8 +893,8 @@ export type PostOauthDeviceTokenResponses = { 200: DeviceTokenResponse } -export type PostOauthDeviceTokenResponse - = PostOauthDeviceTokenResponses[keyof PostOauthDeviceTokenResponses] +export type PostOauthDeviceTokenResponse = + PostOauthDeviceTokenResponses[keyof PostOauthDeviceTokenResponses] export type GetPermittedExternalAppsData = { body?: never @@ -913,15 +913,15 @@ export type GetPermittedExternalAppsErrors = { default: ErrorBody } -export type GetPermittedExternalAppsError - = GetPermittedExternalAppsErrors[keyof GetPermittedExternalAppsErrors] +export type GetPermittedExternalAppsError = + GetPermittedExternalAppsErrors[keyof GetPermittedExternalAppsErrors] export type GetPermittedExternalAppsResponses = { 200: PermittedExternalAppsListResponse } -export type GetPermittedExternalAppsResponse - = GetPermittedExternalAppsResponses[keyof GetPermittedExternalAppsResponses] +export type GetPermittedExternalAppsResponse = + GetPermittedExternalAppsResponses[keyof GetPermittedExternalAppsResponses] export type GetPermittedExternalAppsByAppIdData = { body?: never @@ -939,15 +939,15 @@ export type GetPermittedExternalAppsByAppIdErrors = { default: ErrorBody } -export type GetPermittedExternalAppsByAppIdError - = GetPermittedExternalAppsByAppIdErrors[keyof GetPermittedExternalAppsByAppIdErrors] +export type GetPermittedExternalAppsByAppIdError = + GetPermittedExternalAppsByAppIdErrors[keyof GetPermittedExternalAppsByAppIdErrors] export type GetPermittedExternalAppsByAppIdResponses = { 200: AppDescribeResponse } -export type GetPermittedExternalAppsByAppIdResponse - = GetPermittedExternalAppsByAppIdResponses[keyof GetPermittedExternalAppsByAppIdResponses] +export type GetPermittedExternalAppsByAppIdResponse = + GetPermittedExternalAppsByAppIdResponses[keyof GetPermittedExternalAppsByAppIdResponses] export type GetWorkspacesData = { body?: never @@ -981,15 +981,15 @@ export type GetWorkspacesByWorkspaceIdErrors = { default: ErrorBody } -export type GetWorkspacesByWorkspaceIdError - = GetWorkspacesByWorkspaceIdErrors[keyof GetWorkspacesByWorkspaceIdErrors] +export type GetWorkspacesByWorkspaceIdError = + GetWorkspacesByWorkspaceIdErrors[keyof GetWorkspacesByWorkspaceIdErrors] export type GetWorkspacesByWorkspaceIdResponses = { 200: WorkspaceDetailResponse } -export type GetWorkspacesByWorkspaceIdResponse - = GetWorkspacesByWorkspaceIdResponses[keyof GetWorkspacesByWorkspaceIdResponses] +export type GetWorkspacesByWorkspaceIdResponse = + GetWorkspacesByWorkspaceIdResponses[keyof GetWorkspacesByWorkspaceIdResponses] export type PostWorkspacesByWorkspaceIdAppsImportsData = { body: AppDslImportPayload @@ -1006,16 +1006,16 @@ export type PostWorkspacesByWorkspaceIdAppsImportsErrors = { default: ErrorBody } -export type PostWorkspacesByWorkspaceIdAppsImportsError - = PostWorkspacesByWorkspaceIdAppsImportsErrors[keyof PostWorkspacesByWorkspaceIdAppsImportsErrors] +export type PostWorkspacesByWorkspaceIdAppsImportsError = + PostWorkspacesByWorkspaceIdAppsImportsErrors[keyof PostWorkspacesByWorkspaceIdAppsImportsErrors] export type PostWorkspacesByWorkspaceIdAppsImportsResponses = { 200: Import 202: Import } -export type PostWorkspacesByWorkspaceIdAppsImportsResponse - = PostWorkspacesByWorkspaceIdAppsImportsResponses[keyof PostWorkspacesByWorkspaceIdAppsImportsResponses] +export type PostWorkspacesByWorkspaceIdAppsImportsResponse = + PostWorkspacesByWorkspaceIdAppsImportsResponses[keyof PostWorkspacesByWorkspaceIdAppsImportsResponses] export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmData = { body?: never @@ -1032,15 +1032,15 @@ export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors = { default: ErrorBody } -export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmError - = PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors[keyof PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors] +export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmError = + PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors[keyof PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors] export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses = { 200: Import } -export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse - = PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses[keyof PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses] +export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse = + PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses[keyof PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses] export type GetWorkspacesByWorkspaceIdMembersData = { body?: never @@ -1059,15 +1059,15 @@ export type GetWorkspacesByWorkspaceIdMembersErrors = { default: ErrorBody } -export type GetWorkspacesByWorkspaceIdMembersError - = GetWorkspacesByWorkspaceIdMembersErrors[keyof GetWorkspacesByWorkspaceIdMembersErrors] +export type GetWorkspacesByWorkspaceIdMembersError = + GetWorkspacesByWorkspaceIdMembersErrors[keyof GetWorkspacesByWorkspaceIdMembersErrors] export type GetWorkspacesByWorkspaceIdMembersResponses = { 200: MemberListResponse } -export type GetWorkspacesByWorkspaceIdMembersResponse - = GetWorkspacesByWorkspaceIdMembersResponses[keyof GetWorkspacesByWorkspaceIdMembersResponses] +export type GetWorkspacesByWorkspaceIdMembersResponse = + GetWorkspacesByWorkspaceIdMembersResponses[keyof GetWorkspacesByWorkspaceIdMembersResponses] export type PostWorkspacesByWorkspaceIdMembersData = { body: MemberInvitePayload @@ -1083,15 +1083,15 @@ export type PostWorkspacesByWorkspaceIdMembersErrors = { default: ErrorBody } -export type PostWorkspacesByWorkspaceIdMembersError - = PostWorkspacesByWorkspaceIdMembersErrors[keyof PostWorkspacesByWorkspaceIdMembersErrors] +export type PostWorkspacesByWorkspaceIdMembersError = + PostWorkspacesByWorkspaceIdMembersErrors[keyof PostWorkspacesByWorkspaceIdMembersErrors] export type PostWorkspacesByWorkspaceIdMembersResponses = { 201: MemberInviteResponse } -export type PostWorkspacesByWorkspaceIdMembersResponse - = PostWorkspacesByWorkspaceIdMembersResponses[keyof PostWorkspacesByWorkspaceIdMembersResponses] +export type PostWorkspacesByWorkspaceIdMembersResponse = + PostWorkspacesByWorkspaceIdMembersResponses[keyof PostWorkspacesByWorkspaceIdMembersResponses] export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdData = { body?: never @@ -1107,15 +1107,15 @@ export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors = { default: ErrorBody } -export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdError - = DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors] +export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdError = + DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors] export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses = { 200: MemberActionResponse } -export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse - = DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses] +export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse = + DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses] export type PatchWorkspacesByWorkspaceIdMembersByMemberIdData = { body: MemberRoleUpdatePayload @@ -1132,15 +1132,15 @@ export type PatchWorkspacesByWorkspaceIdMembersByMemberIdErrors = { default: ErrorBody } -export type PatchWorkspacesByWorkspaceIdMembersByMemberIdError - = PatchWorkspacesByWorkspaceIdMembersByMemberIdErrors[keyof PatchWorkspacesByWorkspaceIdMembersByMemberIdErrors] +export type PatchWorkspacesByWorkspaceIdMembersByMemberIdError = + PatchWorkspacesByWorkspaceIdMembersByMemberIdErrors[keyof PatchWorkspacesByWorkspaceIdMembersByMemberIdErrors] export type PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses = { 200: MemberActionResponse } -export type PatchWorkspacesByWorkspaceIdMembersByMemberIdResponse - = PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses[keyof PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses] +export type PatchWorkspacesByWorkspaceIdMembersByMemberIdResponse = + PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses[keyof PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses] export type PostWorkspacesByWorkspaceIdSwitchData = { body?: never @@ -1155,12 +1155,12 @@ export type PostWorkspacesByWorkspaceIdSwitchErrors = { default: ErrorBody } -export type PostWorkspacesByWorkspaceIdSwitchError - = PostWorkspacesByWorkspaceIdSwitchErrors[keyof PostWorkspacesByWorkspaceIdSwitchErrors] +export type PostWorkspacesByWorkspaceIdSwitchError = + PostWorkspacesByWorkspaceIdSwitchErrors[keyof PostWorkspacesByWorkspaceIdSwitchErrors] export type PostWorkspacesByWorkspaceIdSwitchResponses = { 200: WorkspaceDetailResponse } -export type PostWorkspacesByWorkspaceIdSwitchResponse - = PostWorkspacesByWorkspaceIdSwitchResponses[keyof PostWorkspacesByWorkspaceIdSwitchResponses] +export type PostWorkspacesByWorkspaceIdSwitchResponse = + PostWorkspacesByWorkspaceIdSwitchResponses[keyof PostWorkspacesByWorkspaceIdSwitchResponses] diff --git a/packages/contracts/generated/api/service/orpc.gen.ts b/packages/contracts/generated/api/service/orpc.gen.ts index 7ed6b163dd984c..513a87995d9f68 100644 --- a/packages/contracts/generated/api/service/orpc.gen.ts +++ b/packages/contracts/generated/api/service/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteAppsAnnotationsByAnnotationIdPath, zDeleteAppsAnnotationsByAnnotationIdResponse, @@ -1068,7 +1067,7 @@ export const byBatch = { */ export const get9 = oc .route({ - description: 'Get a signed download URL for a document\'s original uploaded file.', + description: "Get a signed download URL for a document's original uploaded file.", inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdDownload', @@ -1243,7 +1242,7 @@ export const get11 = oc export const post20 = oc .route({ description: - 'Update a chunk\'s content, keywords, or answer. Re-triggers indexing for the modified chunk.', + "Update a chunk's content, keywords, or answer. Re-triggers indexing for the modified chunk.", inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentId', @@ -1386,7 +1385,7 @@ export const updateByFile = { export const post24 = oc .route({ description: - 'Update an existing document\'s text content, name, or processing configuration. Re-triggers indexing if content changes — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress.', + "Update an existing document's text content, name, or processing configuration. Re-triggers indexing if content changes — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress.", inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText', @@ -2078,7 +2077,7 @@ export const files = { export const get23 = oc .route({ description: - 'Retrieve a paused Human Input form\'s contents using the `form_token` from a `human_input_required` event. Requires **WebApp** delivery.', + "Retrieve a paused Human Input form's contents using the `form_token` from a `human_input_required` event. Requires **WebApp** delivery.", inputStructure: 'detailed', method: 'GET', operationId: 'getFormHumanInputByFormToken', @@ -2097,7 +2096,7 @@ export const get23 = oc export const post34 = oc .route({ description: - 'Submit the recipient\'s response to a paused Human Input form. The workflow resumes on acceptance; use [Stream Workflow Events](/api-reference/chatflows/stream-workflow-events) to follow subsequent events. Requires **WebApp** delivery.', + "Submit the recipient's response to a paused Human Input form. The workflow resumes on acceptance; use [Stream Workflow Events](/api-reference/chatflows/stream-workflow-events) to follow subsequent events. Requires **WebApp** delivery.", inputStructure: 'detailed', method: 'POST', operationId: 'postFormHumanInputByFormToken', @@ -2262,7 +2261,7 @@ export const meta = { export const get28 = oc .route({ description: - 'Retrieve the application\'s input form configuration, including feature switches, input parameter names, types, and default values.', + "Retrieve the application's input form configuration, including feature switches, input parameter names, types, and default values.", inputStructure: 'detailed', method: 'GET', operationId: 'getParameters', diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index ea06e17478e8d8..671fa451c08050 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -244,13 +244,13 @@ export type ConversationListQuery = { export type ConversationRenamePayload = ( | { - auto_generate: true - name?: string | null - } + auto_generate: true + name?: string | null + } | { - auto_generate?: false - name: string - } + auto_generate?: false + name: string + } ) & { auto_generate?: boolean name?: string | null @@ -258,15 +258,15 @@ export type ConversationRenamePayload = ( export type ConversationRenamePayloadWithUser = ( | { - auto_generate: true - name?: string | null - user?: string - } + auto_generate: true + name?: string | null + user?: string + } | { - auto_generate?: false - name: string - user?: string - } + auto_generate?: false + name: string + user?: string + } ) & { auto_generate?: boolean name?: string | null @@ -620,8 +620,8 @@ export type DocumentDetailResponse = { doc_metadata?: | Array<DocumentMetadataResponse> | { - [key: string]: unknown - } + [key: string]: unknown + } | null doc_type?: string | null document_process_rule?: { @@ -747,21 +747,21 @@ export type DocumentTextCreatePayload = { export type DocumentTextUpdate = ( | { - doc_form?: 'hierarchical_model' | 'qa_model' | 'text_model' - doc_language?: string - name: string - process_rule?: ProcessRule | null - retrieval_model?: RetrievalModel | null - text: string - } + doc_form?: 'hierarchical_model' | 'qa_model' | 'text_model' + doc_language?: string + name: string + process_rule?: ProcessRule | null + retrieval_model?: RetrievalModel | null + text: string + } | { - doc_form?: 'hierarchical_model' | 'qa_model' | 'text_model' - doc_language?: string - name?: string | null - process_rule?: ProcessRule | null - retrieval_model?: RetrievalModel | null - text?: null - } + doc_form?: 'hierarchical_model' | 'qa_model' | 'text_model' + doc_language?: string + name?: string | null + process_rule?: ProcessRule | null + retrieval_model?: RetrievalModel | null + text?: null + } ) & { doc_form?: 'hierarchical_model' | 'qa_model' | 'text_model' doc_language?: string @@ -838,19 +838,19 @@ export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' -export type FormInputConfig - = | ({ - type: 'paragraph' - } & ParagraphInputConfig) +export type FormInputConfig = + | ({ + type: 'paragraph' + } & ParagraphInputConfig) | ({ - type: 'select' - } & SelectInputConfig) + type: 'select' + } & SelectInputConfig) | ({ - type: 'file' - } & FileInputConfig) + type: 'file' + } & FileInputConfig) | ({ - type: 'file-list' - } & FileListInputConfig) + type: 'file-list' + } & FileListInputConfig) export type GeneratedAppResponse = JsonValue @@ -1015,16 +1015,16 @@ export type JsonObject = { [key: string]: unknown } -export type JsonValue - = | string - | number - | number - | boolean - | { +export type JsonValue = + | string + | number + | number + | boolean + | { [key: string]: unknown } - | Array<unknown> - | null + | Array<unknown> + | null export type JsonValueType = unknown @@ -1123,38 +1123,38 @@ export type MetadataUpdatePayload = { name: string } -export type ModelFeature - = | 'agent-thought' - | 'audio' - | 'document' - | 'multi-tool-call' - | 'polling' - | 'stream-tool-call' - | 'structured-output' - | 'tool-call' - | 'video' - | 'vision' - -export type ModelPropertyKey - = | 'audio_type' - | 'context_size' - | 'default_voice' - | 'file_upload_limit' - | 'max_characters_per_chunk' - | 'max_chunks' - | 'max_workers' - | 'mode' - | 'supported_file_extensions' - | 'voices' - | 'word_limit' - -export type ModelStatus - = | 'active' - | 'credential-removed' - | 'disabled' - | 'no-configure' - | 'no-permission' - | 'quota-exceeded' +export type ModelFeature = + | 'agent-thought' + | 'audio' + | 'document' + | 'multi-tool-call' + | 'polling' + | 'stream-tool-call' + | 'structured-output' + | 'tool-call' + | 'video' + | 'vision' + +export type ModelPropertyKey = + | 'audio_type' + | 'context_size' + | 'default_voice' + | 'file_upload_limit' + | 'max_characters_per_chunk' + | 'max_chunks' + | 'max_workers' + | 'mode' + | 'supported_file_extensions' + | 'voices' + | 'word_limit' + +export type ModelStatus = + | 'active' + | 'credential-removed' + | 'disabled' + | 'no-configure' + | 'no-permission' + | 'quota-exceeded' export type ModelType = 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts' @@ -1188,28 +1188,28 @@ export type PermissionEnum = 'all_team_members' | 'only_me' | 'partial_members' export type PipelineRunApiEntity = { datasource_info_list: Array< | { - name?: string - reference: string - } + name?: string + reference: string + } | { - credential_id?: string - page: { - page_id: string - page_name?: string - type: string + credential_id?: string + page: { + page_id: string + page_name?: string + type: string + } + workspace_id: string } - workspace_id: string - } | { - title?: string - url: string - } + title?: string + url: string + } | { - bucket?: string - id: string - name?: string - type: 'file' | 'folder' - } + bucket?: string + id: string + name?: string + type: 'file' | 'folder' + } > datasource_type: 'local_file' | 'online_document' | 'online_drive' | 'website_crawl' inputs: { @@ -1284,11 +1284,11 @@ export type ResultResponse = { result: string } -export type RetrievalMethod - = | 'full_text_search' - | 'hybrid_search' - | 'keyword_search' - | 'semantic_search' +export type RetrievalMethod = + | 'full_text_search' + | 'hybrid_search' + | 'keyword_search' + | 'semantic_search' export type RetrievalModel = { metadata_filtering_conditions?: MetadataFilteringCondition | null @@ -1520,17 +1520,17 @@ export type TagDeletePayload = { tag_id: string } -export type TagUnbindingPayload - = | { - tag_id: string - tag_ids?: Array<string> - target_id: string - } +export type TagUnbindingPayload = | { - tag_id?: string - tag_ids: Array<string> - target_id: string - } + tag_id: string + tag_ids?: Array<string> + target_id: string + } + | { + tag_id?: string + tag_ids: Array<string> + target_id: string + } export type TagUpdatePayload = { name: string @@ -1596,8 +1596,8 @@ export type WorkflowAppLogPartialResponse = { created_from?: string | null details?: | { - [key: string]: unknown - } + [key: string]: unknown + } | Array<unknown> | string | number @@ -1674,8 +1674,8 @@ export type WorkflowRunResponse = { id: string inputs?: | { - [key: string]: unknown - } + [key: string]: unknown + } | Array<unknown> | string | number @@ -1796,8 +1796,8 @@ export type PostAppsAnnotationReplyByActionResponses = { 200: AnnotationJobStatusResponse } -export type PostAppsAnnotationReplyByActionResponse - = PostAppsAnnotationReplyByActionResponses[keyof PostAppsAnnotationReplyByActionResponses] +export type PostAppsAnnotationReplyByActionResponse = + PostAppsAnnotationReplyByActionResponses[keyof PostAppsAnnotationReplyByActionResponses] export type GetAppsAnnotationReplyByActionStatusByJobIdData = { body?: never @@ -1820,8 +1820,8 @@ export type GetAppsAnnotationReplyByActionStatusByJobIdResponses = { 200: AnnotationJobStatusDetailResponse } -export type GetAppsAnnotationReplyByActionStatusByJobIdResponse - = GetAppsAnnotationReplyByActionStatusByJobIdResponses[keyof GetAppsAnnotationReplyByActionStatusByJobIdResponses] +export type GetAppsAnnotationReplyByActionStatusByJobIdResponse = + GetAppsAnnotationReplyByActionStatusByJobIdResponses[keyof GetAppsAnnotationReplyByActionStatusByJobIdResponses] export type GetAppsAnnotationsData = { body?: never @@ -1843,8 +1843,8 @@ export type GetAppsAnnotationsResponses = { 200: AnnotationList } -export type GetAppsAnnotationsResponse - = GetAppsAnnotationsResponses[keyof GetAppsAnnotationsResponses] +export type GetAppsAnnotationsResponse = + GetAppsAnnotationsResponses[keyof GetAppsAnnotationsResponses] export type PostAppsAnnotationsData = { body: AnnotationCreatePayload @@ -1862,8 +1862,8 @@ export type PostAppsAnnotationsResponses = { 201: Annotation } -export type PostAppsAnnotationsResponse - = PostAppsAnnotationsResponses[keyof PostAppsAnnotationsResponses] +export type PostAppsAnnotationsResponse = + PostAppsAnnotationsResponses[keyof PostAppsAnnotationsResponses] export type DeleteAppsAnnotationsByAnnotationIdData = { body?: never @@ -1884,8 +1884,8 @@ export type DeleteAppsAnnotationsByAnnotationIdResponses = { 204: void } -export type DeleteAppsAnnotationsByAnnotationIdResponse - = DeleteAppsAnnotationsByAnnotationIdResponses[keyof DeleteAppsAnnotationsByAnnotationIdResponses] +export type DeleteAppsAnnotationsByAnnotationIdResponse = + DeleteAppsAnnotationsByAnnotationIdResponses[keyof DeleteAppsAnnotationsByAnnotationIdResponses] export type PutAppsAnnotationsByAnnotationIdData = { body: AnnotationCreatePayload @@ -1906,8 +1906,8 @@ export type PutAppsAnnotationsByAnnotationIdResponses = { 200: Annotation } -export type PutAppsAnnotationsByAnnotationIdResponse - = PutAppsAnnotationsByAnnotationIdResponses[keyof PutAppsAnnotationsByAnnotationIdResponses] +export type PutAppsAnnotationsByAnnotationIdResponse = + PutAppsAnnotationsByAnnotationIdResponses[keyof PutAppsAnnotationsByAnnotationIdResponses] export type PostAudioToTextData = { body: { @@ -1978,8 +1978,8 @@ export type PostChatMessagesByTaskIdStopResponses = { 200: SimpleResultResponse } -export type PostChatMessagesByTaskIdStopResponse - = PostChatMessagesByTaskIdStopResponses[keyof PostChatMessagesByTaskIdStopResponses] +export type PostChatMessagesByTaskIdStopResponse = + PostChatMessagesByTaskIdStopResponses[keyof PostChatMessagesByTaskIdStopResponses] export type PostCompletionMessagesData = { body: CompletionRequestPayloadWithUser @@ -2003,8 +2003,8 @@ export type PostCompletionMessagesResponses = { } } -export type PostCompletionMessagesResponse - = PostCompletionMessagesResponses[keyof PostCompletionMessagesResponses] +export type PostCompletionMessagesResponse = + PostCompletionMessagesResponses[keyof PostCompletionMessagesResponses] export type PostCompletionMessagesByTaskIdStopData = { body: RequiredServiceApiUserPayload @@ -2026,8 +2026,8 @@ export type PostCompletionMessagesByTaskIdStopResponses = { 200: SimpleResultResponse } -export type PostCompletionMessagesByTaskIdStopResponse - = PostCompletionMessagesByTaskIdStopResponses[keyof PostCompletionMessagesByTaskIdStopResponses] +export type PostCompletionMessagesByTaskIdStopResponse = + PostCompletionMessagesByTaskIdStopResponses[keyof PostCompletionMessagesByTaskIdStopResponses] export type GetConversationsData = { body?: never @@ -2074,8 +2074,8 @@ export type DeleteConversationsByCIdResponses = { 204: void } -export type DeleteConversationsByCIdResponse - = DeleteConversationsByCIdResponses[keyof DeleteConversationsByCIdResponses] +export type DeleteConversationsByCIdResponse = + DeleteConversationsByCIdResponses[keyof DeleteConversationsByCIdResponses] export type PostConversationsByCIdNameData = { body: ConversationRenamePayloadWithUser @@ -2097,8 +2097,8 @@ export type PostConversationsByCIdNameResponses = { 200: SimpleConversation } -export type PostConversationsByCIdNameResponse - = PostConversationsByCIdNameResponses[keyof PostConversationsByCIdNameResponses] +export type PostConversationsByCIdNameResponse = + PostConversationsByCIdNameResponses[keyof PostConversationsByCIdNameResponses] export type GetConversationsByCIdVariablesData = { body?: never @@ -2125,8 +2125,8 @@ export type GetConversationsByCIdVariablesResponses = { 200: ConversationVariableInfiniteScrollPaginationResponse } -export type GetConversationsByCIdVariablesResponse - = GetConversationsByCIdVariablesResponses[keyof GetConversationsByCIdVariablesResponses] +export type GetConversationsByCIdVariablesResponse = + GetConversationsByCIdVariablesResponses[keyof GetConversationsByCIdVariablesResponses] export type PutConversationsByCIdVariablesByVariableIdData = { body: ConversationVariableUpdatePayloadWithUser @@ -2149,8 +2149,8 @@ export type PutConversationsByCIdVariablesByVariableIdResponses = { 200: ConversationVariableResponse } -export type PutConversationsByCIdVariablesByVariableIdResponse - = PutConversationsByCIdVariablesByVariableIdResponses[keyof PutConversationsByCIdVariablesByVariableIdResponses] +export type PutConversationsByCIdVariablesByVariableIdResponse = + PutConversationsByCIdVariablesByVariableIdResponses[keyof PutConversationsByCIdVariablesByVariableIdResponses] export type GetDatasetsData = { body?: never @@ -2217,8 +2217,8 @@ export type PostDatasetsPipelineFileUploadResponses = { 201: PipelineUploadFileResponse } -export type PostDatasetsPipelineFileUploadResponse - = PostDatasetsPipelineFileUploadResponses[keyof PostDatasetsPipelineFileUploadResponses] +export type PostDatasetsPipelineFileUploadResponse = + PostDatasetsPipelineFileUploadResponses[keyof PostDatasetsPipelineFileUploadResponses] export type DeleteDatasetsTagsData = { body: TagDeletePayload @@ -2236,8 +2236,8 @@ export type DeleteDatasetsTagsResponses = { 204: void } -export type DeleteDatasetsTagsResponse - = DeleteDatasetsTagsResponses[keyof DeleteDatasetsTagsResponses] +export type DeleteDatasetsTagsResponse = + DeleteDatasetsTagsResponses[keyof DeleteDatasetsTagsResponses] export type GetDatasetsTagsData = { body?: never @@ -2309,8 +2309,8 @@ export type PostDatasetsTagsBindingResponses = { 204: void } -export type PostDatasetsTagsBindingResponse - = PostDatasetsTagsBindingResponses[keyof PostDatasetsTagsBindingResponses] +export type PostDatasetsTagsBindingResponse = + PostDatasetsTagsBindingResponses[keyof PostDatasetsTagsBindingResponses] export type PostDatasetsTagsUnbindingData = { body: TagUnbindingPayload @@ -2328,8 +2328,8 @@ export type PostDatasetsTagsUnbindingResponses = { 204: void } -export type PostDatasetsTagsUnbindingResponse - = PostDatasetsTagsUnbindingResponses[keyof PostDatasetsTagsUnbindingResponses] +export type PostDatasetsTagsUnbindingResponse = + PostDatasetsTagsUnbindingResponses[keyof PostDatasetsTagsUnbindingResponses] export type DeleteDatasetsByDatasetIdData = { body?: never @@ -2351,8 +2351,8 @@ export type DeleteDatasetsByDatasetIdResponses = { 204: void } -export type DeleteDatasetsByDatasetIdResponse - = DeleteDatasetsByDatasetIdResponses[keyof DeleteDatasetsByDatasetIdResponses] +export type DeleteDatasetsByDatasetIdResponse = + DeleteDatasetsByDatasetIdResponses[keyof DeleteDatasetsByDatasetIdResponses] export type GetDatasetsByDatasetIdData = { body?: never @@ -2373,8 +2373,8 @@ export type GetDatasetsByDatasetIdResponses = { 200: DatasetDetailWithPartialMembersResponse } -export type GetDatasetsByDatasetIdResponse - = GetDatasetsByDatasetIdResponses[keyof GetDatasetsByDatasetIdResponses] +export type GetDatasetsByDatasetIdResponse = + GetDatasetsByDatasetIdResponses[keyof GetDatasetsByDatasetIdResponses] export type PatchDatasetsByDatasetIdData = { body: DatasetUpdatePayload @@ -2395,8 +2395,8 @@ export type PatchDatasetsByDatasetIdResponses = { 200: DatasetDetailWithPartialMembersResponse } -export type PatchDatasetsByDatasetIdResponse - = PatchDatasetsByDatasetIdResponses[keyof PatchDatasetsByDatasetIdResponses] +export type PatchDatasetsByDatasetIdResponse = + PatchDatasetsByDatasetIdResponses[keyof PatchDatasetsByDatasetIdResponses] export type PostDatasetsByDatasetIdDocumentCreateByFileData = { body: { @@ -2420,8 +2420,8 @@ export type PostDatasetsByDatasetIdDocumentCreateByFileResponses = { 200: DocumentAndBatchResponse } -export type PostDatasetsByDatasetIdDocumentCreateByFileResponse - = PostDatasetsByDatasetIdDocumentCreateByFileResponses[keyof PostDatasetsByDatasetIdDocumentCreateByFileResponses] +export type PostDatasetsByDatasetIdDocumentCreateByFileResponse = + PostDatasetsByDatasetIdDocumentCreateByFileResponses[keyof PostDatasetsByDatasetIdDocumentCreateByFileResponses] export type PostDatasetsByDatasetIdDocumentCreateByTextData = { body: DocumentTextCreatePayload @@ -2442,8 +2442,8 @@ export type PostDatasetsByDatasetIdDocumentCreateByTextResponses = { 200: DocumentAndBatchResponse } -export type PostDatasetsByDatasetIdDocumentCreateByTextResponse - = PostDatasetsByDatasetIdDocumentCreateByTextResponses[keyof PostDatasetsByDatasetIdDocumentCreateByTextResponses] +export type PostDatasetsByDatasetIdDocumentCreateByTextResponse = + PostDatasetsByDatasetIdDocumentCreateByTextResponses[keyof PostDatasetsByDatasetIdDocumentCreateByTextResponses] export type PostDatasetsByDatasetIdDocumentCreateByFile2Data = { body: { @@ -2467,8 +2467,8 @@ export type PostDatasetsByDatasetIdDocumentCreateByFile2Responses = { 200: DocumentAndBatchResponse } -export type PostDatasetsByDatasetIdDocumentCreateByFile2Response - = PostDatasetsByDatasetIdDocumentCreateByFile2Responses[keyof PostDatasetsByDatasetIdDocumentCreateByFile2Responses] +export type PostDatasetsByDatasetIdDocumentCreateByFile2Response = + PostDatasetsByDatasetIdDocumentCreateByFile2Responses[keyof PostDatasetsByDatasetIdDocumentCreateByFile2Responses] export type PostDatasetsByDatasetIdDocumentCreateByText2Data = { body: DocumentTextCreatePayload @@ -2489,8 +2489,8 @@ export type PostDatasetsByDatasetIdDocumentCreateByText2Responses = { 200: DocumentAndBatchResponse } -export type PostDatasetsByDatasetIdDocumentCreateByText2Response - = PostDatasetsByDatasetIdDocumentCreateByText2Responses[keyof PostDatasetsByDatasetIdDocumentCreateByText2Responses] +export type PostDatasetsByDatasetIdDocumentCreateByText2Response = + PostDatasetsByDatasetIdDocumentCreateByText2Responses[keyof PostDatasetsByDatasetIdDocumentCreateByText2Responses] export type GetDatasetsByDatasetIdDocumentsData = { body?: never @@ -2516,8 +2516,8 @@ export type GetDatasetsByDatasetIdDocumentsResponses = { 200: DocumentListResponse } -export type GetDatasetsByDatasetIdDocumentsResponse - = GetDatasetsByDatasetIdDocumentsResponses[keyof GetDatasetsByDatasetIdDocumentsResponses] +export type GetDatasetsByDatasetIdDocumentsResponse = + GetDatasetsByDatasetIdDocumentsResponses[keyof GetDatasetsByDatasetIdDocumentsResponses] export type PostDatasetsByDatasetIdDocumentsDownloadZipData = { body: DocumentBatchDownloadZipPayload @@ -2534,15 +2534,15 @@ export type PostDatasetsByDatasetIdDocumentsDownloadZipErrors = { 404: Blob | File } -export type PostDatasetsByDatasetIdDocumentsDownloadZipError - = PostDatasetsByDatasetIdDocumentsDownloadZipErrors[keyof PostDatasetsByDatasetIdDocumentsDownloadZipErrors] +export type PostDatasetsByDatasetIdDocumentsDownloadZipError = + PostDatasetsByDatasetIdDocumentsDownloadZipErrors[keyof PostDatasetsByDatasetIdDocumentsDownloadZipErrors] export type PostDatasetsByDatasetIdDocumentsDownloadZipResponses = { 200: Blob | File } -export type PostDatasetsByDatasetIdDocumentsDownloadZipResponse - = PostDatasetsByDatasetIdDocumentsDownloadZipResponses[keyof PostDatasetsByDatasetIdDocumentsDownloadZipResponses] +export type PostDatasetsByDatasetIdDocumentsDownloadZipResponse = + PostDatasetsByDatasetIdDocumentsDownloadZipResponses[keyof PostDatasetsByDatasetIdDocumentsDownloadZipResponses] export type PostDatasetsByDatasetIdDocumentsMetadataData = { body: MetadataOperationData @@ -2563,8 +2563,8 @@ export type PostDatasetsByDatasetIdDocumentsMetadataResponses = { 200: DatasetMetadataActionResponse } -export type PostDatasetsByDatasetIdDocumentsMetadataResponse - = PostDatasetsByDatasetIdDocumentsMetadataResponses[keyof PostDatasetsByDatasetIdDocumentsMetadataResponses] +export type PostDatasetsByDatasetIdDocumentsMetadataResponse = + PostDatasetsByDatasetIdDocumentsMetadataResponses[keyof PostDatasetsByDatasetIdDocumentsMetadataResponses] export type PatchDatasetsByDatasetIdDocumentsStatusByActionData = { body: DocumentStatusPayload @@ -2587,8 +2587,8 @@ export type PatchDatasetsByDatasetIdDocumentsStatusByActionResponses = { 200: SimpleResultResponse } -export type PatchDatasetsByDatasetIdDocumentsStatusByActionResponse - = PatchDatasetsByDatasetIdDocumentsStatusByActionResponses[keyof PatchDatasetsByDatasetIdDocumentsStatusByActionResponses] +export type PatchDatasetsByDatasetIdDocumentsStatusByActionResponse = + PatchDatasetsByDatasetIdDocumentsStatusByActionResponses[keyof PatchDatasetsByDatasetIdDocumentsStatusByActionResponses] export type GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusData = { body?: never @@ -2610,8 +2610,8 @@ export type GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusResponses = { 200: DocumentStatusListResponse } -export type GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusResponse - = GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusResponses[keyof GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusResponses] +export type GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusResponse = + GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusResponses[keyof GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusResponses] export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdData = { body?: never @@ -2634,8 +2634,8 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses = { 204: void } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse - = DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses] +export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse = + DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdData = { body?: never @@ -2660,8 +2660,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdResponses = { 200: DocumentDetailResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdResponses] export type PatchDatasetsByDatasetIdDocumentsByDocumentIdData = { body?: { @@ -2687,8 +2687,8 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdResponses = { 200: DocumentAndBatchResponse } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdResponse - = PatchDatasetsByDatasetIdDocumentsByDocumentIdResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdResponses] +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdResponse = + PatchDatasetsByDatasetIdDocumentsByDocumentIdResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadData = { body?: never @@ -2710,8 +2710,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponses = { 200: UrlResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsData = { body?: never @@ -2738,8 +2738,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses = { 200: SegmentListResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses] export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsData = { body: SegmentCreatePayload @@ -2762,8 +2762,8 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses = { 200: SegmentCreateListResponse } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse - = PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses] +export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse = + PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses] export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdData = { body?: never @@ -2786,8 +2786,8 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdRes 204: void } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse - = DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses] +export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = + DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdData = { body?: never @@ -2810,8 +2810,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdRespon 200: SegmentDetailResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses] export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdData = { body: SegmentUpdatePayload @@ -2834,8 +2834,8 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdRespo 200: SegmentDetailResponse } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse - = PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses] +export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = + PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses] export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksData = { body?: never @@ -2862,8 +2862,8 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildC 200: ChildChunkListResponse } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse - = GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses] +export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse = + GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses] export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksData = { body: ChildChunkCreatePayload @@ -2887,11 +2887,11 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChild 200: ChildChunkDetailResponse } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse - = PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses] +export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse = + PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses] -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdData - = { +export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdData = + { body?: never path: { child_chunk_id: string @@ -2903,24 +2903,24 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi url: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdErrors - = { +export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdErrors = + { 400: unknown 401: unknown 403: unknown 404: unknown } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses - = { +export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses = + { 204: void } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse - = DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses] +export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse = + DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses] -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdData - = { +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdData = + { body: ChildChunkUpdatePayload path: { child_chunk_id: string @@ -2932,21 +2932,21 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChil url: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdErrors - = { +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdErrors = + { 400: unknown 401: unknown 403: unknown 404: unknown } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses - = { +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses = + { 200: ChildChunkDetailResponse } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse - = PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses] +export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse = + PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses] export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileData = { body?: { @@ -2972,8 +2972,8 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponses = 200: DocumentAndBatchResponse } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponse - = PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponses] +export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponse = + PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponses] export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextData = { body: DocumentTextUpdate @@ -2996,8 +2996,8 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponses = 200: DocumentAndBatchResponse } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponse - = PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponses] +export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponse = + PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponses] export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Data = { body?: { @@ -3023,8 +3023,8 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Responses = 200: DocumentAndBatchResponse } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Response - = PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Responses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Responses] +export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Response = + PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Responses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Responses] export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Data = { body: DocumentTextUpdate @@ -3046,8 +3046,8 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Responses = 200: DocumentAndBatchResponse } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Response - = PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Responses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Responses] +export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Response = + PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Responses[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Responses] export type PostDatasetsByDatasetIdHitTestingData = { body: HitTestingPayload @@ -3070,8 +3070,8 @@ export type PostDatasetsByDatasetIdHitTestingResponses = { 200: HitTestingResponse } -export type PostDatasetsByDatasetIdHitTestingResponse - = PostDatasetsByDatasetIdHitTestingResponses[keyof PostDatasetsByDatasetIdHitTestingResponses] +export type PostDatasetsByDatasetIdHitTestingResponse = + PostDatasetsByDatasetIdHitTestingResponses[keyof PostDatasetsByDatasetIdHitTestingResponses] export type GetDatasetsByDatasetIdMetadataData = { body?: never @@ -3092,8 +3092,8 @@ export type GetDatasetsByDatasetIdMetadataResponses = { 200: DatasetMetadataListResponse } -export type GetDatasetsByDatasetIdMetadataResponse - = GetDatasetsByDatasetIdMetadataResponses[keyof GetDatasetsByDatasetIdMetadataResponses] +export type GetDatasetsByDatasetIdMetadataResponse = + GetDatasetsByDatasetIdMetadataResponses[keyof GetDatasetsByDatasetIdMetadataResponses] export type PostDatasetsByDatasetIdMetadataData = { body: MetadataArgs @@ -3114,8 +3114,8 @@ export type PostDatasetsByDatasetIdMetadataResponses = { 201: DatasetMetadataResponse } -export type PostDatasetsByDatasetIdMetadataResponse - = PostDatasetsByDatasetIdMetadataResponses[keyof PostDatasetsByDatasetIdMetadataResponses] +export type PostDatasetsByDatasetIdMetadataResponse = + PostDatasetsByDatasetIdMetadataResponses[keyof PostDatasetsByDatasetIdMetadataResponses] export type GetDatasetsByDatasetIdMetadataBuiltInData = { body?: never @@ -3135,8 +3135,8 @@ export type GetDatasetsByDatasetIdMetadataBuiltInResponses = { 200: DatasetMetadataBuiltInFieldsResponse } -export type GetDatasetsByDatasetIdMetadataBuiltInResponse - = GetDatasetsByDatasetIdMetadataBuiltInResponses[keyof GetDatasetsByDatasetIdMetadataBuiltInResponses] +export type GetDatasetsByDatasetIdMetadataBuiltInResponse = + GetDatasetsByDatasetIdMetadataBuiltInResponses[keyof GetDatasetsByDatasetIdMetadataBuiltInResponses] export type PostDatasetsByDatasetIdMetadataBuiltInByActionData = { body?: never @@ -3158,8 +3158,8 @@ export type PostDatasetsByDatasetIdMetadataBuiltInByActionResponses = { 200: DatasetMetadataActionResponse } -export type PostDatasetsByDatasetIdMetadataBuiltInByActionResponse - = PostDatasetsByDatasetIdMetadataBuiltInByActionResponses[keyof PostDatasetsByDatasetIdMetadataBuiltInByActionResponses] +export type PostDatasetsByDatasetIdMetadataBuiltInByActionResponse = + PostDatasetsByDatasetIdMetadataBuiltInByActionResponses[keyof PostDatasetsByDatasetIdMetadataBuiltInByActionResponses] export type DeleteDatasetsByDatasetIdMetadataByMetadataIdData = { body?: never @@ -3181,8 +3181,8 @@ export type DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses = { 204: void } -export type DeleteDatasetsByDatasetIdMetadataByMetadataIdResponse - = DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses[keyof DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses] +export type DeleteDatasetsByDatasetIdMetadataByMetadataIdResponse = + DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses[keyof DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses] export type PatchDatasetsByDatasetIdMetadataByMetadataIdData = { body: MetadataUpdatePayload @@ -3204,8 +3204,8 @@ export type PatchDatasetsByDatasetIdMetadataByMetadataIdResponses = { 200: DatasetMetadataResponse } -export type PatchDatasetsByDatasetIdMetadataByMetadataIdResponse - = PatchDatasetsByDatasetIdMetadataByMetadataIdResponses[keyof PatchDatasetsByDatasetIdMetadataByMetadataIdResponses] +export type PatchDatasetsByDatasetIdMetadataByMetadataIdResponse = + PatchDatasetsByDatasetIdMetadataByMetadataIdResponses[keyof PatchDatasetsByDatasetIdMetadataByMetadataIdResponses] export type GetDatasetsByDatasetIdPipelineDatasourcePluginsData = { body?: never @@ -3228,8 +3228,8 @@ export type GetDatasetsByDatasetIdPipelineDatasourcePluginsResponses = { 200: DatasourcePluginListResponse } -export type GetDatasetsByDatasetIdPipelineDatasourcePluginsResponse - = GetDatasetsByDatasetIdPipelineDatasourcePluginsResponses[keyof GetDatasetsByDatasetIdPipelineDatasourcePluginsResponses] +export type GetDatasetsByDatasetIdPipelineDatasourcePluginsResponse = + GetDatasetsByDatasetIdPipelineDatasourcePluginsResponses[keyof GetDatasetsByDatasetIdPipelineDatasourcePluginsResponses] export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunData = { body: DatasourceNodeRunPayload @@ -3253,8 +3253,8 @@ export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponses = } } -export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponse - = PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponses[keyof PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponses] +export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponse = + PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponses[keyof PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponses] export type PostDatasetsByDatasetIdPipelineRunData = { body: PipelineRunApiEntity @@ -3276,8 +3276,8 @@ export type PostDatasetsByDatasetIdPipelineRunResponses = { 200: GeneratedAppResponse } -export type PostDatasetsByDatasetIdPipelineRunResponse - = PostDatasetsByDatasetIdPipelineRunResponses[keyof PostDatasetsByDatasetIdPipelineRunResponses] +export type PostDatasetsByDatasetIdPipelineRunResponse = + PostDatasetsByDatasetIdPipelineRunResponses[keyof PostDatasetsByDatasetIdPipelineRunResponses] export type PostDatasetsByDatasetIdRetrieveData = { body: HitTestingPayload @@ -3300,8 +3300,8 @@ export type PostDatasetsByDatasetIdRetrieveResponses = { 200: HitTestingResponse } -export type PostDatasetsByDatasetIdRetrieveResponse - = PostDatasetsByDatasetIdRetrieveResponses[keyof PostDatasetsByDatasetIdRetrieveResponses] +export type PostDatasetsByDatasetIdRetrieveResponse = + PostDatasetsByDatasetIdRetrieveResponses[keyof PostDatasetsByDatasetIdRetrieveResponses] export type GetDatasetsByDatasetIdTagsData = { body?: never @@ -3321,8 +3321,8 @@ export type GetDatasetsByDatasetIdTagsResponses = { 200: DatasetBoundTagListResponse } -export type GetDatasetsByDatasetIdTagsResponse - = GetDatasetsByDatasetIdTagsResponses[keyof GetDatasetsByDatasetIdTagsResponses] +export type GetDatasetsByDatasetIdTagsResponse = + GetDatasetsByDatasetIdTagsResponses[keyof GetDatasetsByDatasetIdTagsResponses] export type GetEndUsersByEndUserIdData = { body?: never @@ -3343,8 +3343,8 @@ export type GetEndUsersByEndUserIdResponses = { 200: EndUserDetail } -export type GetEndUsersByEndUserIdResponse - = GetEndUsersByEndUserIdResponses[keyof GetEndUsersByEndUserIdResponses] +export type GetEndUsersByEndUserIdResponse = + GetEndUsersByEndUserIdResponses[keyof GetEndUsersByEndUserIdResponses] export type PostFilesUploadData = { body: { @@ -3388,15 +3388,15 @@ export type GetFilesByFileIdPreviewErrors = { 404: Blob | File } -export type GetFilesByFileIdPreviewError - = GetFilesByFileIdPreviewErrors[keyof GetFilesByFileIdPreviewErrors] +export type GetFilesByFileIdPreviewError = + GetFilesByFileIdPreviewErrors[keyof GetFilesByFileIdPreviewErrors] export type GetFilesByFileIdPreviewResponses = { 200: Blob | File } -export type GetFilesByFileIdPreviewResponse - = GetFilesByFileIdPreviewResponses[keyof GetFilesByFileIdPreviewResponses] +export type GetFilesByFileIdPreviewResponse = + GetFilesByFileIdPreviewResponses[keyof GetFilesByFileIdPreviewResponses] export type GetFormHumanInputByFormTokenData = { body?: never @@ -3418,8 +3418,8 @@ export type GetFormHumanInputByFormTokenResponses = { 200: HumanInputFormDefinitionResponse } -export type GetFormHumanInputByFormTokenResponse - = GetFormHumanInputByFormTokenResponses[keyof GetFormHumanInputByFormTokenResponses] +export type GetFormHumanInputByFormTokenResponse = + GetFormHumanInputByFormTokenResponses[keyof GetFormHumanInputByFormTokenResponses] export type PostFormHumanInputByFormTokenData = { body: HumanInputFormSubmitPayloadWithUser @@ -3442,8 +3442,8 @@ export type PostFormHumanInputByFormTokenResponses = { 200: HumanInputFormSubmitResponse } -export type PostFormHumanInputByFormTokenResponse - = PostFormHumanInputByFormTokenResponses[keyof PostFormHumanInputByFormTokenResponses] +export type PostFormHumanInputByFormTokenResponse = + PostFormHumanInputByFormTokenResponses[keyof PostFormHumanInputByFormTokenResponses] export type GetInfoData = { body?: never @@ -3508,8 +3508,8 @@ export type PostMessagesByMessageIdFeedbacksResponses = { 200: ResultResponse } -export type PostMessagesByMessageIdFeedbacksResponse - = PostMessagesByMessageIdFeedbacksResponses[keyof PostMessagesByMessageIdFeedbacksResponses] +export type PostMessagesByMessageIdFeedbacksResponse = + PostMessagesByMessageIdFeedbacksResponses[keyof PostMessagesByMessageIdFeedbacksResponses] export type GetMessagesByMessageIdSuggestedData = { body?: never @@ -3534,8 +3534,8 @@ export type GetMessagesByMessageIdSuggestedResponses = { 200: SimpleResultStringListResponse } -export type GetMessagesByMessageIdSuggestedResponse - = GetMessagesByMessageIdSuggestedResponses[keyof GetMessagesByMessageIdSuggestedResponses] +export type GetMessagesByMessageIdSuggestedResponse = + GetMessagesByMessageIdSuggestedResponses[keyof GetMessagesByMessageIdSuggestedResponses] export type GetMetaData = { body?: never @@ -3640,8 +3640,8 @@ export type GetWorkflowByTaskIdEventsResponses = { 200: EventStreamResponse } -export type GetWorkflowByTaskIdEventsResponse - = GetWorkflowByTaskIdEventsResponses[keyof GetWorkflowByTaskIdEventsResponses] +export type GetWorkflowByTaskIdEventsResponse = + GetWorkflowByTaskIdEventsResponses[keyof GetWorkflowByTaskIdEventsResponses] export type GetWorkflowsLogsData = { body?: never @@ -3712,8 +3712,8 @@ export type GetWorkflowsRunByWorkflowRunIdResponses = { 200: WorkflowRunResponse } -export type GetWorkflowsRunByWorkflowRunIdResponse - = GetWorkflowsRunByWorkflowRunIdResponses[keyof GetWorkflowsRunByWorkflowRunIdResponses] +export type GetWorkflowsRunByWorkflowRunIdResponse = + GetWorkflowsRunByWorkflowRunIdResponses[keyof GetWorkflowsRunByWorkflowRunIdResponses] export type PostWorkflowsTasksByTaskIdStopData = { body: RequiredServiceApiUserPayload @@ -3735,8 +3735,8 @@ export type PostWorkflowsTasksByTaskIdStopResponses = { 200: SimpleResultResponse } -export type PostWorkflowsTasksByTaskIdStopResponse - = PostWorkflowsTasksByTaskIdStopResponses[keyof PostWorkflowsTasksByTaskIdStopResponses] +export type PostWorkflowsTasksByTaskIdStopResponse = + PostWorkflowsTasksByTaskIdStopResponses[keyof PostWorkflowsTasksByTaskIdStopResponses] export type PostWorkflowsByWorkflowIdRunData = { body: WorkflowRunPayloadWithUser @@ -3760,8 +3760,8 @@ export type PostWorkflowsByWorkflowIdRunResponses = { 200: GeneratedAppResponse } -export type PostWorkflowsByWorkflowIdRunResponse - = PostWorkflowsByWorkflowIdRunResponses[keyof PostWorkflowsByWorkflowIdRunResponses] +export type PostWorkflowsByWorkflowIdRunResponse = + PostWorkflowsByWorkflowIdRunResponses[keyof PostWorkflowsByWorkflowIdRunResponses] export type GetWorkspacesCurrentModelsModelTypesByModelTypeData = { body?: never @@ -3780,5 +3780,5 @@ export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponses = { 200: ProviderWithModelsListResponse } -export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponse - = GetWorkspacesCurrentModelsModelTypesByModelTypeResponses[keyof GetWorkspacesCurrentModelsModelTypesByModelTypeResponses] +export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponse = + GetWorkspacesCurrentModelsModelTypesByModelTypeResponses[keyof GetWorkspacesCurrentModelsModelTypesByModelTypeResponses] diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 72745013ad6053..8be4aefea500a7 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -2410,8 +2410,8 @@ export const zGetAppsAnnotationReplyByActionStatusByJobIdPath = z.object({ /** * Successfully retrieved task status. */ -export const zGetAppsAnnotationReplyByActionStatusByJobIdResponse - = zAnnotationJobStatusDetailResponse +export const zGetAppsAnnotationReplyByActionStatusByJobIdResponse = + zAnnotationJobStatusDetailResponse export const zGetAppsAnnotationsQuery = z.object({ keyword: z.string().optional().default(''), @@ -2554,11 +2554,11 @@ export const zGetConversationsByCIdVariablesQuery = z.object({ /** * Successfully retrieved conversation variables. */ -export const zGetConversationsByCIdVariablesResponse - = zConversationVariableInfiniteScrollPaginationResponse +export const zGetConversationsByCIdVariablesResponse = + zConversationVariableInfiniteScrollPaginationResponse -export const zPutConversationsByCIdVariablesByVariableIdBody - = zConversationVariableUpdatePayloadWithUser +export const zPutConversationsByCIdVariablesByVariableIdBody = + zConversationVariableUpdatePayloadWithUser export const zPutConversationsByCIdVariablesByVariableIdPath = z.object({ c_id: z.uuid(), @@ -2778,8 +2778,8 @@ export const zGetDatasetsByDatasetIdDocumentsByBatchIndexingStatusPath = z.objec /** * Indexing status for documents in the batch. */ -export const zGetDatasetsByDatasetIdDocumentsByBatchIndexingStatusResponse - = zDocumentStatusListResponse +export const zGetDatasetsByDatasetIdDocumentsByBatchIndexingStatusResponse = + zDocumentStatusListResponse export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ dataset_id: z.uuid(), @@ -2857,8 +2857,8 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsPath = z.objec /** * Chunks created successfully. */ -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse - = zSegmentCreateListResponse +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse = + zSegmentCreateListResponse export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath = z.object({ dataset_id: z.uuid(), @@ -2880,11 +2880,11 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath /** * Chunk details. */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse - = zSegmentDetailResponse +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = + zSegmentDetailResponse -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdBody - = zSegmentUpdatePayload +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdBody = + zSegmentUpdatePayload export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath = z.object({ dataset_id: z.uuid(), @@ -2895,18 +2895,18 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPat /** * Chunk updated successfully. */ -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse - = zSegmentDetailResponse +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = + zSegmentDetailResponse -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath - = z.object({ +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath = + z.object({ dataset_id: z.uuid(), document_id: z.uuid(), segment_id: z.uuid(), }) -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksQuery - = z.object({ +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksQuery = + z.object({ keyword: z.string().optional(), limit: z.int().gte(1).optional().default(20), page: z.int().gte(1).optional().default(1), @@ -2915,14 +2915,14 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChil /** * List of child chunks. */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse - = zChildChunkListResponse +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse = + zChildChunkListResponse -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksBody - = zChildChunkCreatePayload +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksBody = + zChildChunkCreatePayload -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath - = z.object({ +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath = + z.object({ dataset_id: z.uuid(), document_id: z.uuid(), segment_id: z.uuid(), @@ -2931,11 +2931,11 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi /** * Child chunk created successfully. */ -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse - = zChildChunkDetailResponse +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse = + zChildChunkDetailResponse -export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath - = z.object({ +export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath = + z.object({ child_chunk_id: z.uuid(), dataset_id: z.uuid(), document_id: z.uuid(), @@ -2945,14 +2945,14 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdC /** * Success. */ -export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse - = z.void() +export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse = + z.void() -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdBody - = zChildChunkUpdatePayload +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdBody = + zChildChunkUpdatePayload -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath - = z.object({ +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath = + z.object({ child_chunk_id: z.uuid(), dataset_id: z.uuid(), document_id: z.uuid(), @@ -2962,8 +2962,8 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdCh /** * Child chunk updated successfully. */ -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse - = zChildChunkDetailResponse +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse = + zChildChunkDetailResponse export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileBody = z.object({ data: z.string().optional(), @@ -2978,8 +2978,8 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFilePath = z.o /** * Document updated successfully. */ -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponse - = zDocumentAndBatchResponse +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponse = + zDocumentAndBatchResponse export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextBody = zDocumentTextUpdate @@ -2991,8 +2991,8 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextPath = z.o /** * Document updated successfully. */ -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponse - = zDocumentAndBatchResponse +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponse = + zDocumentAndBatchResponse export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Body = z.object({ data: z.string().optional(), @@ -3007,8 +3007,8 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Path = z. /** * Document updated successfully. */ -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Response - = zDocumentAndBatchResponse +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Response = + zDocumentAndBatchResponse export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Body = zDocumentTextUpdate @@ -3020,8 +3020,8 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Path = z. /** * Document updated successfully */ -export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Response - = zDocumentAndBatchResponse +export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Response = + zDocumentAndBatchResponse export const zPostDatasetsByDatasetIdHitTestingBody = zHitTestingPayload @@ -3071,8 +3071,8 @@ export const zPostDatasetsByDatasetIdMetadataBuiltInByActionPath = z.object({ /** * Built-in metadata field toggled successfully. */ -export const zPostDatasetsByDatasetIdMetadataBuiltInByActionResponse - = zDatasetMetadataActionResponse +export const zPostDatasetsByDatasetIdMetadataBuiltInByActionResponse = + zDatasetMetadataActionResponse export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdPath = z.object({ dataset_id: z.uuid(), @@ -3107,11 +3107,11 @@ export const zGetDatasetsByDatasetIdPipelineDatasourcePluginsQuery = z.object({ /** * List of datasource nodes configured in the pipeline. */ -export const zGetDatasetsByDatasetIdPipelineDatasourcePluginsResponse - = zDatasourcePluginListResponse +export const zGetDatasetsByDatasetIdPipelineDatasourcePluginsResponse = + zDatasourcePluginListResponse -export const zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunBody - = zDatasourceNodeRunPayload +export const zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunBody = + zDatasourceNodeRunPayload export const zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunPath = z.object({ dataset_id: z.uuid(), @@ -3355,5 +3355,5 @@ export const zGetWorkspacesCurrentModelsModelTypesByModelTypePath = z.object({ /** * Available models for the specified type. */ -export const zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse - = zProviderWithModelsListResponse +export const zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse = + zProviderWithModelsListResponse diff --git a/packages/contracts/generated/api/web/orpc.gen.ts b/packages/contracts/generated/api/web/orpc.gen.ts index 95b8275af4d0b5..f0b9b5375e8152 100644 --- a/packages/contracts/generated/api/web/orpc.gen.ts +++ b/packages/contracts/generated/api/web/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zDeleteConversationsByCIdPath, zDeleteConversationsByCIdResponse, @@ -962,7 +961,7 @@ export const site = { export const get13 = oc .route({ description: - 'Get system feature flags and configuration\nReturns the current system feature flags and configuration\nthat control various functionalities across the platform.\n\nReturns:\n dict: System feature configuration object\n\nThis endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,\nexcept it is intended for use by the web app, instead of the console dashboard.\n\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for webapp initialization.\n\nAuthentication would create circular dependency (can\'t authenticate without webapp loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint.', + "Get system feature flags and configuration\nReturns the current system feature flags and configuration\nthat control various functionalities across the platform.\n\nReturns:\n dict: System feature configuration object\n\nThis endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,\nexcept it is intended for use by the web app, instead of the console dashboard.\n\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for webapp initialization.\n\nAuthentication would create circular dependency (can't authenticate without webapp loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint.", inputStructure: 'detailed', method: 'GET', operationId: 'getSystemFeatures', diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 17a08c15cd8aa0..9c3fe235c0e107 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -106,13 +106,13 @@ export type ConversationListQuery = { export type ConversationRenamePayload = ( | { - auto_generate: true - name?: string | null - } + auto_generate: true + name?: string | null + } | { - auto_generate?: false - name: string - } + auto_generate?: false + name: string + } ) & { auto_generate?: boolean name?: string | null @@ -200,19 +200,19 @@ export type ForgotPasswordSendPayload = { language?: string | null } -export type FormInputConfig - = | ({ - type: 'paragraph' - } & ParagraphInputConfig) +export type FormInputConfig = + | ({ + type: 'paragraph' + } & ParagraphInputConfig) | ({ - type: 'select' - } & SelectInputConfig) + type: 'select' + } & SelectInputConfig) | ({ - type: 'file' - } & FileInputConfig) + type: 'file' + } & FileInputConfig) | ({ - type: 'file-list' - } & FileListInputConfig) + type: 'file-list' + } & FileListInputConfig) export type GeneratedAppResponse = JsonValue @@ -285,16 +285,16 @@ export type JsonObject = { [key: string]: unknown } -export type JsonValue - = | string - | number - | number - | boolean - | { +export type JsonValue = + | string + | number + | number + | boolean + | { [key: string]: unknown } - | Array<unknown> - | null + | Array<unknown> + | null export type JsonValueType = unknown @@ -390,11 +390,11 @@ export type PluginInstallationPermissionModel = { restrict_to_marketplace_only: boolean } -export type PluginInstallationScope - = | 'all' - | 'none' - | 'official_and_specific_partners' - | 'official_only' +export type PluginInstallationScope = + | 'all' + | 'none' + | 'official_and_specific_partners' + | 'official_only' export type PluginManagerModel = { enabled: boolean @@ -804,8 +804,8 @@ export type PostChatMessagesByTaskIdStopResponses = { 200: SimpleResultResponse } -export type PostChatMessagesByTaskIdStopResponse - = PostChatMessagesByTaskIdStopResponses[keyof PostChatMessagesByTaskIdStopResponses] +export type PostChatMessagesByTaskIdStopResponse = + PostChatMessagesByTaskIdStopResponses[keyof PostChatMessagesByTaskIdStopResponses] export type PostCompletionMessagesData = { body: CompletionMessagePayload @@ -826,8 +826,8 @@ export type PostCompletionMessagesResponses = { 200: GeneratedAppResponse } -export type PostCompletionMessagesResponse - = PostCompletionMessagesResponses[keyof PostCompletionMessagesResponses] +export type PostCompletionMessagesResponse = + PostCompletionMessagesResponses[keyof PostCompletionMessagesResponses] export type PostCompletionMessagesByTaskIdStopData = { body?: never @@ -850,8 +850,8 @@ export type PostCompletionMessagesByTaskIdStopResponses = { 200: SimpleResultResponse } -export type PostCompletionMessagesByTaskIdStopResponse - = PostCompletionMessagesByTaskIdStopResponses[keyof PostCompletionMessagesByTaskIdStopResponses] +export type PostCompletionMessagesByTaskIdStopResponse = + PostCompletionMessagesByTaskIdStopResponses[keyof PostCompletionMessagesByTaskIdStopResponses] export type GetConversationsData = { body?: never @@ -900,8 +900,8 @@ export type DeleteConversationsByCIdResponses = { 204: void } -export type DeleteConversationsByCIdResponse - = DeleteConversationsByCIdResponses[keyof DeleteConversationsByCIdResponses] +export type DeleteConversationsByCIdResponse = + DeleteConversationsByCIdResponses[keyof DeleteConversationsByCIdResponses] export type PostConversationsByCIdNameData = { body: ConversationRenamePayload @@ -927,8 +927,8 @@ export type PostConversationsByCIdNameResponses = { 200: SimpleConversation } -export type PostConversationsByCIdNameResponse - = PostConversationsByCIdNameResponses[keyof PostConversationsByCIdNameResponses] +export type PostConversationsByCIdNameResponse = + PostConversationsByCIdNameResponses[keyof PostConversationsByCIdNameResponses] export type PatchConversationsByCIdPinData = { body?: never @@ -951,8 +951,8 @@ export type PatchConversationsByCIdPinResponses = { 200: ResultResponse } -export type PatchConversationsByCIdPinResponse - = PatchConversationsByCIdPinResponses[keyof PatchConversationsByCIdPinResponses] +export type PatchConversationsByCIdPinResponse = + PatchConversationsByCIdPinResponses[keyof PatchConversationsByCIdPinResponses] export type PatchConversationsByCIdUnpinData = { body?: never @@ -975,8 +975,8 @@ export type PatchConversationsByCIdUnpinResponses = { 200: ResultResponse } -export type PatchConversationsByCIdUnpinResponse - = PatchConversationsByCIdUnpinResponses[keyof PatchConversationsByCIdUnpinResponses] +export type PatchConversationsByCIdUnpinResponse = + PatchConversationsByCIdUnpinResponses[keyof PatchConversationsByCIdUnpinResponses] export type PostEmailCodeLoginData = { body: EmailCodeLoginSendPayload @@ -994,8 +994,8 @@ export type PostEmailCodeLoginResponses = { 200: SimpleResultDataResponse } -export type PostEmailCodeLoginResponse - = PostEmailCodeLoginResponses[keyof PostEmailCodeLoginResponses] +export type PostEmailCodeLoginResponse = + PostEmailCodeLoginResponses[keyof PostEmailCodeLoginResponses] export type PostEmailCodeLoginValidityData = { body: EmailCodeLoginVerifyPayload @@ -1014,8 +1014,8 @@ export type PostEmailCodeLoginValidityResponses = { 200: AccessTokenResultResponse } -export type PostEmailCodeLoginValidityResponse - = PostEmailCodeLoginValidityResponses[keyof PostEmailCodeLoginValidityResponses] +export type PostEmailCodeLoginValidityResponse = + PostEmailCodeLoginValidityResponses[keyof PostEmailCodeLoginValidityResponses] export type PostFilesUploadData = { body?: never @@ -1053,8 +1053,8 @@ export type PostForgotPasswordResponses = { 200: SimpleResultDataResponse } -export type PostForgotPasswordResponse - = PostForgotPasswordResponses[keyof PostForgotPasswordResponses] +export type PostForgotPasswordResponse = + PostForgotPasswordResponses[keyof PostForgotPasswordResponses] export type PostForgotPasswordResetsData = { body: ForgotPasswordResetPayload @@ -1073,8 +1073,8 @@ export type PostForgotPasswordResetsResponses = { 200: SimpleResultResponse } -export type PostForgotPasswordResetsResponse - = PostForgotPasswordResetsResponses[keyof PostForgotPasswordResetsResponses] +export type PostForgotPasswordResetsResponse = + PostForgotPasswordResetsResponses[keyof PostForgotPasswordResetsResponses] export type PostForgotPasswordValidityData = { body: ForgotPasswordCheckPayload @@ -1092,8 +1092,8 @@ export type PostForgotPasswordValidityResponses = { 200: VerificationTokenResponse } -export type PostForgotPasswordValidityResponse - = PostForgotPasswordValidityResponses[keyof PostForgotPasswordValidityResponses] +export type PostForgotPasswordValidityResponse = + PostForgotPasswordValidityResponses[keyof PostForgotPasswordValidityResponses] export type GetFormHumanInputByFormTokenData = { body?: never @@ -1115,8 +1115,8 @@ export type GetFormHumanInputByFormTokenResponses = { 200: HumanInputFormDefinitionResponse } -export type GetFormHumanInputByFormTokenResponse - = GetFormHumanInputByFormTokenResponses[keyof GetFormHumanInputByFormTokenResponses] +export type GetFormHumanInputByFormTokenResponse = + GetFormHumanInputByFormTokenResponses[keyof GetFormHumanInputByFormTokenResponses] export type PostFormHumanInputByFormTokenData = { body: HumanInputFormSubmitPayload @@ -1138,8 +1138,8 @@ export type PostFormHumanInputByFormTokenResponses = { 200: HumanInputFormSubmitResponse } -export type PostFormHumanInputByFormTokenResponse - = PostFormHumanInputByFormTokenResponses[keyof PostFormHumanInputByFormTokenResponses] +export type PostFormHumanInputByFormTokenResponse = + PostFormHumanInputByFormTokenResponses[keyof PostFormHumanInputByFormTokenResponses] export type PostFormHumanInputByFormTokenUploadTokenData = { body?: never @@ -1160,8 +1160,8 @@ export type PostFormHumanInputByFormTokenUploadTokenResponses = { 200: HumanInputUploadTokenResponse } -export type PostFormHumanInputByFormTokenUploadTokenResponse - = PostFormHumanInputByFormTokenUploadTokenResponses[keyof PostFormHumanInputByFormTokenUploadTokenResponses] +export type PostFormHumanInputByFormTokenUploadTokenResponse = + PostFormHumanInputByFormTokenUploadTokenResponses[keyof PostFormHumanInputByFormTokenUploadTokenResponses] export type PostHumanInputFormsFilesData = { body?: never @@ -1174,8 +1174,8 @@ export type PostHumanInputFormsFilesResponses = { 201: FileResponse } -export type PostHumanInputFormsFilesResponse - = PostHumanInputFormsFilesResponses[keyof PostHumanInputFormsFilesResponses] +export type PostHumanInputFormsFilesResponse = + PostHumanInputFormsFilesResponses[keyof PostHumanInputFormsFilesResponses] export type PostLoginData = { body: LoginPayload @@ -1279,8 +1279,8 @@ export type PostMessagesByMessageIdFeedbacksResponses = { 200: ResultResponse } -export type PostMessagesByMessageIdFeedbacksResponse - = PostMessagesByMessageIdFeedbacksResponses[keyof PostMessagesByMessageIdFeedbacksResponses] +export type PostMessagesByMessageIdFeedbacksResponse = + PostMessagesByMessageIdFeedbacksResponses[keyof PostMessagesByMessageIdFeedbacksResponses] export type GetMessagesByMessageIdMoreLikeThisData = { body?: never @@ -1305,8 +1305,8 @@ export type GetMessagesByMessageIdMoreLikeThisResponses = { 200: GeneratedAppResponse } -export type GetMessagesByMessageIdMoreLikeThisResponse - = GetMessagesByMessageIdMoreLikeThisResponses[keyof GetMessagesByMessageIdMoreLikeThisResponses] +export type GetMessagesByMessageIdMoreLikeThisResponse = + GetMessagesByMessageIdMoreLikeThisResponses[keyof GetMessagesByMessageIdMoreLikeThisResponses] export type GetMessagesByMessageIdSuggestedQuestionsData = { body?: never @@ -1329,8 +1329,8 @@ export type GetMessagesByMessageIdSuggestedQuestionsResponses = { 200: SuggestedQuestionsResponse } -export type GetMessagesByMessageIdSuggestedQuestionsResponse - = GetMessagesByMessageIdSuggestedQuestionsResponses[keyof GetMessagesByMessageIdSuggestedQuestionsResponses] +export type GetMessagesByMessageIdSuggestedQuestionsResponse = + GetMessagesByMessageIdSuggestedQuestionsResponses[keyof GetMessagesByMessageIdSuggestedQuestionsResponses] export type GetMetaData = { body?: never @@ -1412,8 +1412,8 @@ export type PostRemoteFilesUploadResponses = { 201: FileWithSignedUrl } -export type PostRemoteFilesUploadResponse - = PostRemoteFilesUploadResponses[keyof PostRemoteFilesUploadResponses] +export type PostRemoteFilesUploadResponse = + PostRemoteFilesUploadResponses[keyof PostRemoteFilesUploadResponses] export type GetRemoteFilesByUrlData = { body?: never @@ -1434,8 +1434,8 @@ export type GetRemoteFilesByUrlResponses = { 200: RemoteFileInfo } -export type GetRemoteFilesByUrlResponse - = GetRemoteFilesByUrlResponses[keyof GetRemoteFilesByUrlResponses] +export type GetRemoteFilesByUrlResponse = + GetRemoteFilesByUrlResponses[keyof GetRemoteFilesByUrlResponses] export type GetSavedMessagesData = { body?: never @@ -1505,8 +1505,8 @@ export type DeleteSavedMessagesByMessageIdResponses = { 204: void } -export type DeleteSavedMessagesByMessageIdResponse - = DeleteSavedMessagesByMessageIdResponses[keyof DeleteSavedMessagesByMessageIdResponses] +export type DeleteSavedMessagesByMessageIdResponse = + DeleteSavedMessagesByMessageIdResponses[keyof DeleteSavedMessagesByMessageIdResponses] export type GetSiteData = { body?: never @@ -1587,8 +1587,8 @@ export type GetWebappAccessModeResponses = { 200: AccessModeResponse } -export type GetWebappAccessModeResponse - = GetWebappAccessModeResponses[keyof GetWebappAccessModeResponses] +export type GetWebappAccessModeResponse = + GetWebappAccessModeResponses[keyof GetWebappAccessModeResponses] export type GetWebappPermissionData = { body?: never @@ -1609,8 +1609,8 @@ export type GetWebappPermissionResponses = { 200: BooleanResultResponse } -export type GetWebappPermissionResponse - = GetWebappPermissionResponses[keyof GetWebappPermissionResponses] +export type GetWebappPermissionResponse = + GetWebappPermissionResponses[keyof GetWebappPermissionResponses] export type GetWorkflowByTaskIdEventsData = { body?: never @@ -1625,8 +1625,8 @@ export type GetWorkflowByTaskIdEventsResponses = { 200: EventStreamResponse } -export type GetWorkflowByTaskIdEventsResponse - = GetWorkflowByTaskIdEventsResponses[keyof GetWorkflowByTaskIdEventsResponses] +export type GetWorkflowByTaskIdEventsResponse = + GetWorkflowByTaskIdEventsResponses[keyof GetWorkflowByTaskIdEventsResponses] export type PostWorkflowsRunData = { body: WorkflowRunPayload @@ -1670,5 +1670,5 @@ export type PostWorkflowsTasksByTaskIdStopResponses = { 200: SimpleResultResponse } -export type PostWorkflowsTasksByTaskIdStopResponse - = PostWorkflowsTasksByTaskIdStopResponses[keyof PostWorkflowsTasksByTaskIdStopResponses] +export type PostWorkflowsTasksByTaskIdStopResponse = + PostWorkflowsTasksByTaskIdStopResponses[keyof PostWorkflowsTasksByTaskIdStopResponses] diff --git a/packages/contracts/generated/enterprise/orpc.gen.ts b/packages/contracts/generated/enterprise/orpc.gen.ts index 072b74466303a9..d8c6383bcbae45 100644 --- a/packages/contracts/generated/enterprise/orpc.gen.ts +++ b/packages/contracts/generated/enterprise/orpc.gen.ts @@ -2,7 +2,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' - import { zAccessServiceCreateApiKeyBody, zAccessServiceCreateApiKeyPath, diff --git a/packages/contracts/generated/enterprise/types.gen.ts b/packages/contracts/generated/enterprise/types.gen.ts index 164e23a6e17044..a79c2178aac809 100644 --- a/packages/contracts/generated/enterprise/types.gen.ts +++ b/packages/contracts/generated/enterprise/types.gen.ts @@ -89,8 +89,8 @@ export const DeveloperApiUrlStatus = { DEVELOPER_API_URL_STATUS_NOT_CONFIGURED: 'DEVELOPER_API_URL_STATUS_NOT_CONFIGURED', } as const -export type DeveloperApiUrlStatus - = (typeof DeveloperApiUrlStatus)[keyof typeof DeveloperApiUrlStatus] +export type DeveloperApiUrlStatus = + (typeof DeveloperApiUrlStatus)[keyof typeof DeveloperApiUrlStatus] export const EnvVarValueSource = { ENV_VAR_VALUE_SOURCE_UNSPECIFIED: 'ENV_VAR_VALUE_SOURCE_UNSPECIFIED', @@ -132,16 +132,16 @@ export const RuntimeInstanceStatus = { RUNTIME_INSTANCE_STATUS_UNDEPLOYING: 'RUNTIME_INSTANCE_STATUS_UNDEPLOYING', } as const -export type RuntimeInstanceStatus - = (typeof RuntimeInstanceStatus)[keyof typeof RuntimeInstanceStatus] +export type RuntimeInstanceStatus = + (typeof RuntimeInstanceStatus)[keyof typeof RuntimeInstanceStatus] export const AppRunnerLaunchProfileMode = { APP_RUNNER_LAUNCH_PROFILE_MODE_UNSPECIFIED: 'APP_RUNNER_LAUNCH_PROFILE_MODE_UNSPECIFIED', APP_RUNNER_LAUNCH_PROFILE_MODE_DEBUG: 'APP_RUNNER_LAUNCH_PROFILE_MODE_DEBUG', } as const -export type AppRunnerLaunchProfileMode - = (typeof AppRunnerLaunchProfileMode)[keyof typeof AppRunnerLaunchProfileMode] +export type AppRunnerLaunchProfileMode = + (typeof AppRunnerLaunchProfileMode)[keyof typeof AppRunnerLaunchProfileMode] export const OperatorType = { OPERATOR_TYPE_UNSPECIFIED: 'OPERATOR_TYPE_UNSPECIFIED', @@ -170,8 +170,8 @@ export const ReleaseEnvironmentActionKind = { RELEASE_ENVIRONMENT_ACTION_KIND_BLOCKED: 'RELEASE_ENVIRONMENT_ACTION_KIND_BLOCKED', } as const -export type ReleaseEnvironmentActionKind - = (typeof ReleaseEnvironmentActionKind)[keyof typeof ReleaseEnvironmentActionKind] +export type ReleaseEnvironmentActionKind = + (typeof ReleaseEnvironmentActionKind)[keyof typeof ReleaseEnvironmentActionKind] export const AckStatus = { ACK_STATUS_UNSPECIFIED: 'ACK_STATUS_UNSPECIFIED', @@ -246,8 +246,8 @@ export const PasswordStrengthLevel = { PASSWORD_STRENGTH_LEVEL_STRONG: 'PASSWORD_STRENGTH_LEVEL_STRONG', } as const -export type PasswordStrengthLevel - = (typeof PasswordStrengthLevel)[keyof typeof PasswordStrengthLevel] +export type PasswordStrengthLevel = + (typeof PasswordStrengthLevel)[keyof typeof PasswordStrengthLevel] export const PluginInstallationScope = { PLUGIN_INSTALLATION_SCOPE_ALL: 'PLUGIN_INSTALLATION_SCOPE_ALL', @@ -257,8 +257,8 @@ export const PluginInstallationScope = { PLUGIN_INSTALLATION_SCOPE_NONE: 'PLUGIN_INSTALLATION_SCOPE_NONE', } as const -export type PluginInstallationScope - = (typeof PluginInstallationScope)[keyof typeof PluginInstallationScope] +export type PluginInstallationScope = + (typeof PluginInstallationScope)[keyof typeof PluginInstallationScope] export const LimitStatus = { LIMIT_STATUS_UNSPECIFIED: 'LIMIT_STATUS_UNSPECIFIED', @@ -2151,8 +2151,8 @@ export type AppInstanceServiceListAppInstanceSummariesResponses = { 200: ListAppInstanceSummariesResponse } -export type AppInstanceServiceListAppInstanceSummariesResponse - = AppInstanceServiceListAppInstanceSummariesResponses[keyof AppInstanceServiceListAppInstanceSummariesResponses] +export type AppInstanceServiceListAppInstanceSummariesResponse = + AppInstanceServiceListAppInstanceSummariesResponses[keyof AppInstanceServiceListAppInstanceSummariesResponses] export type AppInstanceServiceListAppInstancesData = { body?: never @@ -2170,8 +2170,8 @@ export type AppInstanceServiceListAppInstancesResponses = { 200: ListAppInstancesResponse } -export type AppInstanceServiceListAppInstancesResponse - = AppInstanceServiceListAppInstancesResponses[keyof AppInstanceServiceListAppInstancesResponses] +export type AppInstanceServiceListAppInstancesResponse = + AppInstanceServiceListAppInstancesResponses[keyof AppInstanceServiceListAppInstancesResponses] export type AppInstanceServiceCreateAppInstanceData = { body: CreateAppInstanceRequest @@ -2184,8 +2184,8 @@ export type AppInstanceServiceCreateAppInstanceResponses = { 200: CreateAppInstanceResponse } -export type AppInstanceServiceCreateAppInstanceResponse - = AppInstanceServiceCreateAppInstanceResponses[keyof AppInstanceServiceCreateAppInstanceResponses] +export type AppInstanceServiceCreateAppInstanceResponse = + AppInstanceServiceCreateAppInstanceResponses[keyof AppInstanceServiceCreateAppInstanceResponses] export type AppInstanceServiceDeleteAppInstanceData = { body?: never @@ -2200,8 +2200,8 @@ export type AppInstanceServiceDeleteAppInstanceResponses = { 200: DeleteAppInstanceResponse } -export type AppInstanceServiceDeleteAppInstanceResponse - = AppInstanceServiceDeleteAppInstanceResponses[keyof AppInstanceServiceDeleteAppInstanceResponses] +export type AppInstanceServiceDeleteAppInstanceResponse = + AppInstanceServiceDeleteAppInstanceResponses[keyof AppInstanceServiceDeleteAppInstanceResponses] export type AppInstanceServiceGetAppInstanceData = { body?: never @@ -2216,8 +2216,8 @@ export type AppInstanceServiceGetAppInstanceResponses = { 200: GetAppInstanceResponse } -export type AppInstanceServiceGetAppInstanceResponse - = AppInstanceServiceGetAppInstanceResponses[keyof AppInstanceServiceGetAppInstanceResponses] +export type AppInstanceServiceGetAppInstanceResponse = + AppInstanceServiceGetAppInstanceResponses[keyof AppInstanceServiceGetAppInstanceResponses] export type AppInstanceServiceUpdateAppInstanceData = { body: UpdateAppInstanceRequest @@ -2232,8 +2232,8 @@ export type AppInstanceServiceUpdateAppInstanceResponses = { 200: UpdateAppInstanceResponse } -export type AppInstanceServiceUpdateAppInstanceResponse - = AppInstanceServiceUpdateAppInstanceResponses[keyof AppInstanceServiceUpdateAppInstanceResponses] +export type AppInstanceServiceUpdateAppInstanceResponse = + AppInstanceServiceUpdateAppInstanceResponses[keyof AppInstanceServiceUpdateAppInstanceResponses] export type AccessServiceGetAccessChannelsData = { body?: never @@ -2248,8 +2248,8 @@ export type AccessServiceGetAccessChannelsResponses = { 200: GetAccessChannelsResponse } -export type AccessServiceGetAccessChannelsResponse - = AccessServiceGetAccessChannelsResponses[keyof AccessServiceGetAccessChannelsResponses] +export type AccessServiceGetAccessChannelsResponse = + AccessServiceGetAccessChannelsResponses[keyof AccessServiceGetAccessChannelsResponses] export type AccessServiceUpdateAccessChannelsData = { body: UpdateAccessChannelsRequest @@ -2264,8 +2264,8 @@ export type AccessServiceUpdateAccessChannelsResponses = { 200: UpdateAccessChannelsResponse } -export type AccessServiceUpdateAccessChannelsResponse - = AccessServiceUpdateAccessChannelsResponses[keyof AccessServiceUpdateAccessChannelsResponses] +export type AccessServiceUpdateAccessChannelsResponse = + AccessServiceUpdateAccessChannelsResponses[keyof AccessServiceUpdateAccessChannelsResponses] export type AccessServiceGetAccessSettingsData = { body?: never @@ -2280,8 +2280,8 @@ export type AccessServiceGetAccessSettingsResponses = { 200: GetAccessSettingsResponse } -export type AccessServiceGetAccessSettingsResponse - = AccessServiceGetAccessSettingsResponses[keyof AccessServiceGetAccessSettingsResponses] +export type AccessServiceGetAccessSettingsResponse = + AccessServiceGetAccessSettingsResponses[keyof AccessServiceGetAccessSettingsResponses] export type DeploymentServiceListDeploymentsData = { body?: never @@ -2300,8 +2300,8 @@ export type DeploymentServiceListDeploymentsResponses = { 200: ListDeploymentsResponse } -export type DeploymentServiceListDeploymentsResponse - = DeploymentServiceListDeploymentsResponses[keyof DeploymentServiceListDeploymentsResponses] +export type DeploymentServiceListDeploymentsResponse = + DeploymentServiceListDeploymentsResponses[keyof DeploymentServiceListDeploymentsResponses] export type AccessServiceGetDeveloperApiSettingsData = { body?: never @@ -2316,8 +2316,8 @@ export type AccessServiceGetDeveloperApiSettingsResponses = { 200: GetDeveloperApiSettingsResponse } -export type AccessServiceGetDeveloperApiSettingsResponse - = AccessServiceGetDeveloperApiSettingsResponses[keyof AccessServiceGetDeveloperApiSettingsResponses] +export type AccessServiceGetDeveloperApiSettingsResponse = + AccessServiceGetDeveloperApiSettingsResponses[keyof AccessServiceGetDeveloperApiSettingsResponses] export type DeploymentServiceListEnvironmentDeploymentsData = { body?: never @@ -2332,8 +2332,8 @@ export type DeploymentServiceListEnvironmentDeploymentsResponses = { 200: ListEnvironmentDeploymentsResponse } -export type DeploymentServiceListEnvironmentDeploymentsResponse - = DeploymentServiceListEnvironmentDeploymentsResponses[keyof DeploymentServiceListEnvironmentDeploymentsResponses] +export type DeploymentServiceListEnvironmentDeploymentsResponse = + DeploymentServiceListEnvironmentDeploymentsResponses[keyof DeploymentServiceListEnvironmentDeploymentsResponses] export type AccessServiceGetAccessPolicyData = { body?: never @@ -2349,8 +2349,8 @@ export type AccessServiceGetAccessPolicyResponses = { 200: GetAccessPolicyResponse } -export type AccessServiceGetAccessPolicyResponse - = AccessServiceGetAccessPolicyResponses[keyof AccessServiceGetAccessPolicyResponses] +export type AccessServiceGetAccessPolicyResponse = + AccessServiceGetAccessPolicyResponses[keyof AccessServiceGetAccessPolicyResponses] export type AccessServiceUpdateAccessPolicyData = { body: UpdateAccessPolicyRequest @@ -2366,8 +2366,8 @@ export type AccessServiceUpdateAccessPolicyResponses = { 200: UpdateAccessPolicyResponse } -export type AccessServiceUpdateAccessPolicyResponse - = AccessServiceUpdateAccessPolicyResponses[keyof AccessServiceUpdateAccessPolicyResponses] +export type AccessServiceUpdateAccessPolicyResponse = + AccessServiceUpdateAccessPolicyResponses[keyof AccessServiceUpdateAccessPolicyResponses] export type AccessServiceListApiKeysData = { body?: never @@ -2383,8 +2383,8 @@ export type AccessServiceListApiKeysResponses = { 200: ListApiKeysResponse } -export type AccessServiceListApiKeysResponse - = AccessServiceListApiKeysResponses[keyof AccessServiceListApiKeysResponses] +export type AccessServiceListApiKeysResponse = + AccessServiceListApiKeysResponses[keyof AccessServiceListApiKeysResponses] export type AccessServiceCreateApiKeyData = { body: CreateApiKeyRequest @@ -2400,8 +2400,8 @@ export type AccessServiceCreateApiKeyResponses = { 200: CreateApiKeyResponse } -export type AccessServiceCreateApiKeyResponse - = AccessServiceCreateApiKeyResponses[keyof AccessServiceCreateApiKeyResponses] +export type AccessServiceCreateApiKeyResponse = + AccessServiceCreateApiKeyResponses[keyof AccessServiceCreateApiKeyResponses] export type AccessServiceDeleteApiKeyData = { body?: never @@ -2418,8 +2418,8 @@ export type AccessServiceDeleteApiKeyResponses = { 200: DeleteApiKeyResponse } -export type AccessServiceDeleteApiKeyResponse - = AccessServiceDeleteApiKeyResponses[keyof AccessServiceDeleteApiKeyResponses] +export type AccessServiceDeleteApiKeyResponse = + AccessServiceDeleteApiKeyResponses[keyof AccessServiceDeleteApiKeyResponses] export type DeploymentServiceListRollbackTargetsData = { body?: never @@ -2438,8 +2438,8 @@ export type DeploymentServiceListRollbackTargetsResponses = { 200: ListRollbackTargetsResponse } -export type DeploymentServiceListRollbackTargetsResponse - = DeploymentServiceListRollbackTargetsResponses[keyof DeploymentServiceListRollbackTargetsResponses] +export type DeploymentServiceListRollbackTargetsResponse = + DeploymentServiceListRollbackTargetsResponses[keyof DeploymentServiceListRollbackTargetsResponses] export type DeploymentServiceCancelDeploymentData = { body: CancelDeploymentRequest @@ -2455,8 +2455,8 @@ export type DeploymentServiceCancelDeploymentResponses = { 200: CancelDeploymentResponse } -export type DeploymentServiceCancelDeploymentResponse - = DeploymentServiceCancelDeploymentResponses[keyof DeploymentServiceCancelDeploymentResponses] +export type DeploymentServiceCancelDeploymentResponse = + DeploymentServiceCancelDeploymentResponses[keyof DeploymentServiceCancelDeploymentResponses] export type DeploymentServicePromoteData = { body: PromoteRequest @@ -2472,8 +2472,8 @@ export type DeploymentServicePromoteResponses = { 200: PromoteResponse } -export type DeploymentServicePromoteResponse - = DeploymentServicePromoteResponses[keyof DeploymentServicePromoteResponses] +export type DeploymentServicePromoteResponse = + DeploymentServicePromoteResponses[keyof DeploymentServicePromoteResponses] export type DeploymentServiceRollbackData = { body: RollbackRequest @@ -2489,8 +2489,8 @@ export type DeploymentServiceRollbackResponses = { 200: RollbackResponse } -export type DeploymentServiceRollbackResponse - = DeploymentServiceRollbackResponses[keyof DeploymentServiceRollbackResponses] +export type DeploymentServiceRollbackResponse = + DeploymentServiceRollbackResponses[keyof DeploymentServiceRollbackResponses] export type DeploymentServiceUndeployData = { body: UndeployRequest @@ -2506,8 +2506,8 @@ export type DeploymentServiceUndeployResponses = { 200: UndeployResponse } -export type DeploymentServiceUndeployResponse - = DeploymentServiceUndeployResponses[keyof DeploymentServiceUndeployResponses] +export type DeploymentServiceUndeployResponse = + DeploymentServiceUndeployResponses[keyof DeploymentServiceUndeployResponses] export type ReleaseServiceListReleaseSummariesData = { body?: never @@ -2528,8 +2528,8 @@ export type ReleaseServiceListReleaseSummariesResponses = { 200: ListReleaseSummariesResponse } -export type ReleaseServiceListReleaseSummariesResponse - = ReleaseServiceListReleaseSummariesResponses[keyof ReleaseServiceListReleaseSummariesResponses] +export type ReleaseServiceListReleaseSummariesResponse = + ReleaseServiceListReleaseSummariesResponses[keyof ReleaseServiceListReleaseSummariesResponses] export type ReleaseServiceListReleasesData = { body?: never @@ -2550,8 +2550,8 @@ export type ReleaseServiceListReleasesResponses = { 200: ListReleasesResponse } -export type ReleaseServiceListReleasesResponse - = ReleaseServiceListReleasesResponses[keyof ReleaseServiceListReleasesResponses] +export type ReleaseServiceListReleasesResponse = + ReleaseServiceListReleasesResponses[keyof ReleaseServiceListReleasesResponses] export type ReleaseServiceComputeReleaseDeploymentViewData = { body?: never @@ -2569,8 +2569,8 @@ export type ReleaseServiceComputeReleaseDeploymentViewResponses = { 200: ComputeReleaseDeploymentViewResponse } -export type ReleaseServiceComputeReleaseDeploymentViewResponse - = ReleaseServiceComputeReleaseDeploymentViewResponses[keyof ReleaseServiceComputeReleaseDeploymentViewResponses] +export type ReleaseServiceComputeReleaseDeploymentViewResponse = + ReleaseServiceComputeReleaseDeploymentViewResponses[keyof ReleaseServiceComputeReleaseDeploymentViewResponses] export type AppInstanceServiceGetAppInstanceOverviewData = { body?: never @@ -2585,8 +2585,8 @@ export type AppInstanceServiceGetAppInstanceOverviewResponses = { 200: GetAppInstanceOverviewResponse } -export type AppInstanceServiceGetAppInstanceOverviewResponse - = AppInstanceServiceGetAppInstanceOverviewResponses[keyof AppInstanceServiceGetAppInstanceOverviewResponses] +export type AppInstanceServiceGetAppInstanceOverviewResponse = + AppInstanceServiceGetAppInstanceOverviewResponses[keyof AppInstanceServiceGetAppInstanceOverviewResponses] export type DeploymentServiceDeployData = { body: DeployRequest @@ -2599,8 +2599,8 @@ export type DeploymentServiceDeployResponses = { 200: DeployResponse } -export type DeploymentServiceDeployResponse - = DeploymentServiceDeployResponses[keyof DeploymentServiceDeployResponses] +export type DeploymentServiceDeployResponse = + DeploymentServiceDeployResponses[keyof DeploymentServiceDeployResponses] export type EnvironmentServiceListEnvironmentsData = { body?: never @@ -2618,8 +2618,8 @@ export type EnvironmentServiceListEnvironmentsResponses = { 200: ListEnvironmentsResponse } -export type EnvironmentServiceListEnvironmentsResponse - = EnvironmentServiceListEnvironmentsResponses[keyof EnvironmentServiceListEnvironmentsResponses] +export type EnvironmentServiceListEnvironmentsResponse = + EnvironmentServiceListEnvironmentsResponses[keyof EnvironmentServiceListEnvironmentsResponses] export type ReleaseServiceCreateReleaseData = { body: CreateReleaseRequest @@ -2632,8 +2632,8 @@ export type ReleaseServiceCreateReleaseResponses = { 200: CreateReleaseResponse } -export type ReleaseServiceCreateReleaseResponse - = ReleaseServiceCreateReleaseResponses[keyof ReleaseServiceCreateReleaseResponses] +export type ReleaseServiceCreateReleaseResponse = + ReleaseServiceCreateReleaseResponses[keyof ReleaseServiceCreateReleaseResponses] export type ReleaseServiceDeleteReleaseData = { body?: never @@ -2648,8 +2648,8 @@ export type ReleaseServiceDeleteReleaseResponses = { 200: DeleteReleaseResponse } -export type ReleaseServiceDeleteReleaseResponse - = ReleaseServiceDeleteReleaseResponses[keyof ReleaseServiceDeleteReleaseResponses] +export type ReleaseServiceDeleteReleaseResponse = + ReleaseServiceDeleteReleaseResponses[keyof ReleaseServiceDeleteReleaseResponses] export type ReleaseServiceGetReleaseData = { body?: never @@ -2664,8 +2664,8 @@ export type ReleaseServiceGetReleaseResponses = { 200: GetReleaseResponse } -export type ReleaseServiceGetReleaseResponse - = ReleaseServiceGetReleaseResponses[keyof ReleaseServiceGetReleaseResponses] +export type ReleaseServiceGetReleaseResponse = + ReleaseServiceGetReleaseResponses[keyof ReleaseServiceGetReleaseResponses] export type ReleaseServiceUpdateReleaseData = { body: UpdateReleaseRequest @@ -2680,8 +2680,8 @@ export type ReleaseServiceUpdateReleaseResponses = { 200: UpdateReleaseResponse } -export type ReleaseServiceUpdateReleaseResponse - = ReleaseServiceUpdateReleaseResponses[keyof ReleaseServiceUpdateReleaseResponses] +export type ReleaseServiceUpdateReleaseResponse = + ReleaseServiceUpdateReleaseResponses[keyof ReleaseServiceUpdateReleaseResponses] export type ReleaseServiceExportReleaseDslData = { body?: never @@ -2696,8 +2696,8 @@ export type ReleaseServiceExportReleaseDslResponses = { 200: ExportReleaseDslResponse } -export type ReleaseServiceExportReleaseDslResponse - = ReleaseServiceExportReleaseDslResponses[keyof ReleaseServiceExportReleaseDslResponses] +export type ReleaseServiceExportReleaseDslResponse = + ReleaseServiceExportReleaseDslResponses[keyof ReleaseServiceExportReleaseDslResponses] export type ReleaseServiceListReleaseCredentialCandidatesData = { body?: never @@ -2712,8 +2712,8 @@ export type ReleaseServiceListReleaseCredentialCandidatesResponses = { 200: ListReleaseCredentialCandidatesResponse } -export type ReleaseServiceListReleaseCredentialCandidatesResponse - = ReleaseServiceListReleaseCredentialCandidatesResponses[keyof ReleaseServiceListReleaseCredentialCandidatesResponses] +export type ReleaseServiceListReleaseCredentialCandidatesResponse = + ReleaseServiceListReleaseCredentialCandidatesResponses[keyof ReleaseServiceListReleaseCredentialCandidatesResponses] export type ReleaseServiceComputeDeploymentOptionsData = { body: ComputeDeploymentOptionsRequest @@ -2726,8 +2726,8 @@ export type ReleaseServiceComputeDeploymentOptionsResponses = { 200: ComputeDeploymentOptionsResponse } -export type ReleaseServiceComputeDeploymentOptionsResponse - = ReleaseServiceComputeDeploymentOptionsResponses[keyof ReleaseServiceComputeDeploymentOptionsResponses] +export type ReleaseServiceComputeDeploymentOptionsResponse = + ReleaseServiceComputeDeploymentOptionsResponses[keyof ReleaseServiceComputeDeploymentOptionsResponses] export type ReleaseServicePrecheckReleaseData = { body: PrecheckReleaseRequest @@ -2740,8 +2740,8 @@ export type ReleaseServicePrecheckReleaseResponses = { 200: PrecheckReleaseResponse } -export type ReleaseServicePrecheckReleaseResponse - = ReleaseServicePrecheckReleaseResponses[keyof ReleaseServicePrecheckReleaseResponses] +export type ReleaseServicePrecheckReleaseResponse = + ReleaseServicePrecheckReleaseResponses[keyof ReleaseServicePrecheckReleaseResponses] export type ConsoleSsoOAuth2LoginData = { body?: never @@ -2754,8 +2754,8 @@ export type ConsoleSsoOAuth2LoginResponses = { 200: OAuth2LoginReply } -export type ConsoleSsoOAuth2LoginResponse - = ConsoleSsoOAuth2LoginResponses[keyof ConsoleSsoOAuth2LoginResponses] +export type ConsoleSsoOAuth2LoginResponse = + ConsoleSsoOAuth2LoginResponses[keyof ConsoleSsoOAuth2LoginResponses] export type ConsoleSsoOidcLoginData = { body?: never @@ -2768,8 +2768,8 @@ export type ConsoleSsoOidcLoginResponses = { 200: OidcReply } -export type ConsoleSsoOidcLoginResponse - = ConsoleSsoOidcLoginResponses[keyof ConsoleSsoOidcLoginResponses] +export type ConsoleSsoOidcLoginResponse = + ConsoleSsoOidcLoginResponses[keyof ConsoleSsoOidcLoginResponses] export type ConsoleSsoSamlLoginData = { body?: never @@ -2782,8 +2782,8 @@ export type ConsoleSsoSamlLoginResponses = { 200: SamlLoginReply } -export type ConsoleSsoSamlLoginResponse - = ConsoleSsoSamlLoginResponses[keyof ConsoleSsoSamlLoginResponses] +export type ConsoleSsoSamlLoginResponse = + ConsoleSsoSamlLoginResponses[keyof ConsoleSsoSamlLoginResponses] export type WebAppAuthGetWebAppAccessModeData = { body?: never @@ -2798,8 +2798,8 @@ export type WebAppAuthGetWebAppAccessModeResponses = { 200: GetWebAppAccessModeRes } -export type WebAppAuthGetWebAppAccessModeResponse - = WebAppAuthGetWebAppAccessModeResponses[keyof WebAppAuthGetWebAppAccessModeResponses] +export type WebAppAuthGetWebAppAccessModeResponse = + WebAppAuthGetWebAppAccessModeResponses[keyof WebAppAuthGetWebAppAccessModeResponses] export type WebAppAuthUpdateWebAppWhitelistSubjectsData = { body: UpdateWebAppWhitelistSubjectsReq @@ -2812,8 +2812,8 @@ export type WebAppAuthUpdateWebAppWhitelistSubjectsResponses = { 200: UpdateWebAppWhitelistSubjectsRes } -export type WebAppAuthUpdateWebAppWhitelistSubjectsResponse - = WebAppAuthUpdateWebAppWhitelistSubjectsResponses[keyof WebAppAuthUpdateWebAppWhitelistSubjectsResponses] +export type WebAppAuthUpdateWebAppWhitelistSubjectsResponse = + WebAppAuthUpdateWebAppWhitelistSubjectsResponses[keyof WebAppAuthUpdateWebAppWhitelistSubjectsResponses] export type WebAppAuthSearchForWhilteListCandidatesData = { body?: never @@ -2831,8 +2831,8 @@ export type WebAppAuthSearchForWhilteListCandidatesResponses = { 200: SearchForWhilteListCandidatesRes } -export type WebAppAuthSearchForWhilteListCandidatesResponse - = WebAppAuthSearchForWhilteListCandidatesResponses[keyof WebAppAuthSearchForWhilteListCandidatesResponses] +export type WebAppAuthSearchForWhilteListCandidatesResponse = + WebAppAuthSearchForWhilteListCandidatesResponses[keyof WebAppAuthSearchForWhilteListCandidatesResponses] export type WebAppAuthGetWebAppWhitelistSubjectsData = { body?: never @@ -2847,8 +2847,8 @@ export type WebAppAuthGetWebAppWhitelistSubjectsResponses = { 200: GetWebAppWhitelistSubjectsRes } -export type WebAppAuthGetWebAppWhitelistSubjectsResponse - = WebAppAuthGetWebAppWhitelistSubjectsResponses[keyof WebAppAuthGetWebAppWhitelistSubjectsResponses] +export type WebAppAuthGetWebAppWhitelistSubjectsResponse = + WebAppAuthGetWebAppWhitelistSubjectsResponses[keyof WebAppAuthGetWebAppWhitelistSubjectsResponses] export type WebAppAuthGetGroupSubjectsData = { body?: never @@ -2863,8 +2863,8 @@ export type WebAppAuthGetGroupSubjectsResponses = { 200: GetGroupSubjectsRes } -export type WebAppAuthGetGroupSubjectsResponse - = WebAppAuthGetGroupSubjectsResponses[keyof WebAppAuthGetGroupSubjectsResponses] +export type WebAppAuthGetGroupSubjectsResponse = + WebAppAuthGetGroupSubjectsResponses[keyof WebAppAuthGetGroupSubjectsResponses] export type WebAppAuthIsUserAllowedToAccessWebAppData = { body?: never @@ -2879,5 +2879,5 @@ export type WebAppAuthIsUserAllowedToAccessWebAppResponses = { 200: IsUserAllowedToAccessWebAppRes } -export type WebAppAuthIsUserAllowedToAccessWebAppResponse - = WebAppAuthIsUserAllowedToAccessWebAppResponses[keyof WebAppAuthIsUserAllowedToAccessWebAppResponses] +export type WebAppAuthIsUserAllowedToAccessWebAppResponse = + WebAppAuthIsUserAllowedToAccessWebAppResponses[keyof WebAppAuthIsUserAllowedToAccessWebAppResponses] diff --git a/packages/contracts/generated/enterprise/zod.gen.ts b/packages/contracts/generated/enterprise/zod.gen.ts index 4bce9b2f6337ae..8f04399c1a4551 100644 --- a/packages/contracts/generated/enterprise/zod.gen.ts +++ b/packages/contracts/generated/enterprise/zod.gen.ts @@ -2409,8 +2409,8 @@ export const zDeploymentServiceListEnvironmentDeploymentsPath = z.object({ /** * OK */ -export const zDeploymentServiceListEnvironmentDeploymentsResponse - = zListEnvironmentDeploymentsResponse +export const zDeploymentServiceListEnvironmentDeploymentsResponse = + zListEnvironmentDeploymentsResponse export const zAccessServiceGetAccessPolicyPath = z.object({ appInstanceId: z.string(), @@ -2600,8 +2600,8 @@ export const zReleaseServiceComputeReleaseDeploymentViewQuery = z.object({ /** * OK */ -export const zReleaseServiceComputeReleaseDeploymentViewResponse - = zComputeReleaseDeploymentViewResponse +export const zReleaseServiceComputeReleaseDeploymentViewResponse = + zComputeReleaseDeploymentViewResponse export const zAppInstanceServiceGetAppInstanceOverviewPath = z.object({ appInstanceId: z.string(), @@ -2691,8 +2691,8 @@ export const zReleaseServiceListReleaseCredentialCandidatesPath = z.object({ /** * OK */ -export const zReleaseServiceListReleaseCredentialCandidatesResponse - = zListReleaseCredentialCandidatesResponse +export const zReleaseServiceListReleaseCredentialCandidatesResponse = + zListReleaseCredentialCandidatesResponse export const zReleaseServiceComputeDeploymentOptionsBody = zComputeDeploymentOptionsRequest diff --git a/packages/contracts/marketplace.ts b/packages/contracts/marketplace.ts index 92db51412b09bd..459f3d778e6249 100644 --- a/packages/contracts/marketplace.ts +++ b/packages/contracts/marketplace.ts @@ -58,23 +58,23 @@ export type MarketplaceTemplate = { categories: string[] } -export type MarketplacePluginCategory - = | 'tool' - | 'model' - | 'extension' - | 'agent-strategy' - | 'datasource' - | 'trigger' - -export type MarketplacePluginType - = | 'plugin' - | 'bundle' - | 'model' - | 'extension' - | 'tool' - | 'agent_strategy' - | 'datasource' - | 'trigger' +export type MarketplacePluginCategory = + | 'tool' + | 'model' + | 'extension' + | 'agent-strategy' + | 'datasource' + | 'trigger' + +export type MarketplacePluginType = + | 'plugin' + | 'bundle' + | 'model' + | 'extension' + | 'tool' + | 'agent_strategy' + | 'datasource' + | 'trigger' export type MarketplacePluginDependencySource = 'github' | 'marketplace' | 'package' @@ -163,7 +163,7 @@ const collectionsContract = base }) .input( type<{ - query?: CollectionsAndPluginsSearchParams & { page?: number, page_size?: number } + query?: CollectionsAndPluginsSearchParams & { page?: number; page_size?: number } }>(), ) .output(type<CollectionsResponse>()) @@ -188,12 +188,14 @@ const searchAdvancedContract = base path: '/{kind}/search/advanced', method: 'POST', }) - .input(type<{ - params: { - kind: 'plugins' | 'bundles' - } - body: Omit<PluginsSearchParams, 'type'> - }>()) + .input( + type<{ + params: { + kind: 'plugins' | 'bundles' + } + body: Omit<PluginsSearchParams, 'type'> + }>(), + ) .output(type<SearchAdvancedResponse>()) const templateDetailContract = base @@ -201,11 +203,13 @@ const templateDetailContract = base path: '/templates/{templateId}', method: 'GET', }) - .input(type<{ - params: { - templateId: string - } - }>()) + .input( + type<{ + params: { + templateId: string + } + }>(), + ) .output(type<TemplateDetailResponse>()) const downloadPluginContract = base @@ -213,13 +217,15 @@ const downloadPluginContract = base path: '/plugins/{organization}/{pluginName}/{version}/download', method: 'GET', }) - .input(type<{ - params: { - organization: string - pluginName: string - version: string - } - }>()) + .input( + type<{ + params: { + organization: string + pluginName: string + version: string + } + }>(), + ) .output(type<DownloadPluginResponse>()) export const marketplaceRouterContract = { diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index 423848d5815464..7d362f856d3ba9 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -85,7 +85,7 @@ const toWords = (value: string) => { } const toPascalCase = (words: string[]) => { - return words.map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join('') + return words.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join('') } const toCamelCase = (words: string[]) => { @@ -98,8 +98,7 @@ const toKebabCase = (value: string) => { } const segmentWords = (segment: string) => { - if (segment.startsWith('{') && segment.endsWith('}')) - return ['by', ...toWords(segment)] + if (segment.startsWith('{') && segment.endsWith('}')) return ['by', ...toWords(segment)] return toWords(segment) } @@ -112,11 +111,16 @@ const routeWords = (routePath: string) => { } const operationId = (method: string, routePath: string) => { - return toCamelCase([method, ...(routeWords(routePath).length > 0 ? routeWords(routePath) : ['root'])]) + return toCamelCase([ + method, + ...(routeWords(routePath).length > 0 ? routeWords(routePath) : ['root']), + ]) } const contractPathSegments = (operation: ApiContractOperation) => { - const segments = routeNamingSegments(operation.path).map(segment => toCamelCase(segmentWords(segment))) + const segments = routeNamingSegments(operation.path).map((segment) => + toCamelCase(segmentWords(segment)), + ) return [...(segments.length > 0 ? segments : ['root']), operation.method.toLowerCase()] } @@ -144,27 +148,24 @@ const clone = <T>(value: T): T => { const componentSchemaRefPrefix = '#/components/schemas/' const schemaNameFromRef = (ref: string) => { - if (ref.startsWith(componentSchemaRefPrefix)) - return ref.slice(componentSchemaRefPrefix.length) + if (ref.startsWith(componentSchemaRefPrefix)) return ref.slice(componentSchemaRefPrefix.length) return undefined } const getDocumentSchemas = (document: SwaggerDocument) => { - const components = document.components ??= {} - return components.schemas ??= {} + const components = (document.components ??= {}) + return (components.schemas ??= {}) } const collectSchemaRefs = (value: unknown, refs: Set<string>, visited = new WeakSet<object>()) => { - if (!value || typeof value !== 'object') - return + if (!value || typeof value !== 'object') return - if (visited.has(value)) - return + if (visited.has(value)) return visited.add(value) if (Array.isArray(value)) { - value.forEach(item => collectSchemaRefs(item, refs, visited)) + value.forEach((item) => collectSchemaRefs(item, refs, visited)) return } @@ -172,18 +173,16 @@ const collectSchemaRefs = (value: unknown, refs: Set<string>, visited = new Weak const ref = objectValue.$ref if (typeof ref === 'string') { const refName = schemaNameFromRef(ref) - if (refName) - refs.add(refName) + if (refName) refs.add(refName) } - Object.values(objectValue).forEach(item => collectSchemaRefs(item, refs, visited)) + Object.values(objectValue).forEach((item) => collectSchemaRefs(item, refs, visited)) } const addOperationIds = (document: SwaggerDocument) => { for (const [routePath, pathItem] of Object.entries(document.paths ?? {})) { for (const [method, operation] of Object.entries(pathItem)) { - if (!operationMethods.has(method) || !isObject(operation)) - continue + if (!operationMethods.has(method) || !isObject(operation)) continue const swaggerOperation = operation as SwaggerOperation swaggerOperation.operationId = operationId(method, routePath) @@ -196,14 +195,12 @@ const normalizeOpaqueContractResponses = (document: SwaggerDocument) => { // without a body schema; give those routes an opaque output so they stay in oRPC. for (const pathItem of Object.values(document.paths ?? {})) { for (const [method, operation] of Object.entries(pathItem)) { - if (!operationMethods.has(method) || !isObject(operation)) - continue + if (!operationMethods.has(method) || !isObject(operation)) continue const swaggerOperation = operation as SwaggerOperation for (const [status, response] of Object.entries(swaggerOperation.responses ?? {})) { // Ignore non-2xx or 204 or those w/o a response field - if (!/^2\d\d$/.test(status) || status === '204' || !isObject(response)) - continue + if (!/^2\d\d$/.test(status) || status === '204' || !isObject(response)) continue const content = response.content if (!isObject(content) || Object.keys(content).length === 0) { @@ -220,10 +217,8 @@ const normalizeOpaqueContractResponses = (document: SwaggerDocument) => { } for (const [mediaType, media] of Object.entries(content)) { - if (mediaType !== 'application/json' && mediaType !== 'text/event-stream') - continue - if (!isObject(media) || isObject(media.schema)) - continue + if (mediaType !== 'application/json' && mediaType !== 'text/event-stream') continue + if (!isObject(media) || isObject(media.schema)) continue // JSON/SSE media without a schema traps heyapi. Patch only that media entry so // sibling binary media keeps heyapi's Blob | File inference. @@ -240,14 +235,11 @@ const normalizeOpaqueContractResponses = (document: SwaggerDocument) => { const hasSuccessResponse = (operation: SwaggerOperation) => { return Object.entries(operation.responses ?? {}).some(([status, response]) => { - if (!/^2\d\d$/.test(status)) - return false - if (!isObject(response)) - return false + if (!/^2\d\d$/.test(status)) return false + if (!isObject(response)) return false const content = (response as JsonObject).content // 204 No Content is a valid success response without a body - if (!isObject(content) || Object.keys(content).length === 0) - return status === '204' + if (!isObject(content) || Object.keys(content).length === 0) return status === '204' return true }) } @@ -255,18 +247,16 @@ const hasSuccessResponse = (operation: SwaggerOperation) => { const filterContractOperations = (document: SwaggerDocument) => { for (const [routePath, pathItem] of Object.entries(document.paths ?? {})) { for (const [method, operation] of Object.entries(pathItem)) { - if (!operationMethods.has(method) || !isObject(operation)) - continue + if (!operationMethods.has(method) || !isObject(operation)) continue - if (!hasSuccessResponse(operation as SwaggerOperation)) - delete pathItem[method] + if (!hasSuccessResponse(operation as SwaggerOperation)) delete pathItem[method] } - const hasOperations = Object.entries(pathItem) - .some(([method, operation]) => operationMethods.has(method) && isObject(operation)) + const hasOperations = Object.entries(pathItem).some( + ([method, operation]) => operationMethods.has(method) && isObject(operation), + ) - if (!hasOperations) - delete document.paths?.[routePath] + if (!hasOperations) delete document.paths?.[routePath] } } @@ -280,7 +270,7 @@ const normalizeApiSwagger = (document: SwaggerDocument) => { const mergeFastOpenApiConsoleSwagger = (document: SwaggerDocument) => { const fastOpenApiDocument = readApiSwagger(fastOpenApiConsoleSpecFilename) - const targetPaths = document.paths ??= {} + const targetPaths = (document.paths ??= {}) for (const [routePath, pathItem] of Object.entries(fastOpenApiDocument.paths ?? {})) { const contractPath = routePath.startsWith(fastOpenApiConsolePathPrefix) @@ -319,25 +309,21 @@ const selectReferencedSchemas = ( while (pendingRefs.size > 0) { const refName = pendingRefs.values().next().value - if (!refName) - break + if (!refName) break pendingRefs.delete(refName) - if (selectedSchemas[refName]) - continue + if (selectedSchemas[refName]) continue const schema = schemas[refName] - if (!schema) - throw new Error(`Missing referenced schema: ${refName}`) + if (!schema) throw new Error(`Missing referenced schema: ${refName}`) selectedSchemas[refName] = schema const nestedRefs = new Set<string>() collectSchemaRefs(selectedSchemas[refName], nestedRefs) for (const nestedRef of nestedRefs) { - if (!selectedSchemas[nestedRef]) - pendingRefs.add(nestedRef) + if (!selectedSchemas[nestedRef]) pendingRefs.add(nestedRef) } } @@ -371,7 +357,10 @@ const consoleContractEntryContent = (segments: string[]) => { }) const contractEntries = contracts - .map(contract => ` ${contract.name}: () => import('./${contract.importPath}/orpc.gen').then(({ ${contract.name} }) => ({ ${contract.name} })),`) + .map( + (contract) => + ` ${contract.name}: () => import('./${contract.importPath}/orpc.gen').then(({ ${contract.name} }) => ({ ${contract.name} })),`, + ) .join('\n') return `// This file is auto-generated by @hey-api/openapi-ts @@ -397,12 +386,10 @@ const consoleRouterContractContent = (segments: string[]) => { }) const imports = contracts - .map(contract => `import { ${contract.name} } from './${contract.importPath}/orpc.gen'`) + .map((contract) => `import { ${contract.name} } from './${contract.importPath}/orpc.gen'`) .join('\n') - const communityContractEntries = contracts - .map(contract => ` ${contract.name},`) - .join('\n') + const communityContractEntries = contracts.map((contract) => ` ${contract.name},`).join('\n') return `// This file is auto-generated by packages/contracts/openapi-ts.api.config.ts @@ -455,10 +442,12 @@ const splitConsoleDocument = (document: SwaggerDocument) => { } const segments = [...pathsBySegment.keys()].sort((left, right) => left.localeCompare(right)) - const jobs = segments.map((segment): ApiJob => ({ - document: cloneDocumentWithPaths(document, pathsBySegment.get(segment) ?? {}), - outputPath: `generated/api/console/${toKebabCase(segment)}`, - })) + const jobs = segments.map( + (segment): ApiJob => ({ + document: cloneDocumentWithPaths(document, pathsBySegment.get(segment) ?? {}), + outputPath: `generated/api/console/${toKebabCase(segment)}`, + }), + ) return [...jobs, createConsoleContractEntryJob(document, segments)] } @@ -470,8 +459,7 @@ const createApiJobs = (spec: ApiSpec): ApiJob[] => { : readApiSwagger(spec.filename), ) - if (spec.name === 'console') - return splitConsoleDocument(document) + if (spec.name === 'console') return splitConsoleDocument(document) return [ { @@ -503,11 +491,14 @@ const createApiConfig = (job: ApiJob): UserConfig => ({ name: '@hey-api/typescript', }, { - 'name': 'zod', + name: 'zod', '~resolvers': { string: (ctx) => { if (ctx.schema.format === 'binary') - return $(ctx.symbols.z).attr('custom').call().generic($.type.or($.type('Blob'), $.type('File'))) + return $(ctx.symbols.z) + .attr('custom') + .call() + .generic($.type.or($.type('Blob'), $.type('File'))) if (ctx.schema.pattern === pydanticDecimalStringPattern) { // the pydantic generated regex will emit error like diff --git a/packages/contracts/openapi-yaml.test.ts b/packages/contracts/openapi-yaml.test.ts index 5f255bba2a5b8c..572f39da840c9a 100644 --- a/packages/contracts/openapi-yaml.test.ts +++ b/packages/contracts/openapi-yaml.test.ts @@ -17,19 +17,23 @@ info: }) it('rejects duplicate mapping keys', () => { - expect(() => loadOpenApiYaml('openapi: 3.0.0\nopenapi: 3.1.0\n')) - .toThrow(/duplicated mapping key/) + expect(() => loadOpenApiYaml('openapi: 3.0.0\nopenapi: 3.1.0\n')).toThrow( + /duplicated mapping key/, + ) }) it('rejects multiple YAML documents', () => { - expect(() => loadOpenApiYaml('openapi: 3.0.0\n---\nopenapi: 3.1.0\n')) - .toThrow(/single document/) + expect(() => loadOpenApiYaml('openapi: 3.0.0\n---\nopenapi: 3.1.0\n')).toThrow( + /single document/, + ) }) it('uses v5 mapping and set semantics', () => { - expect(() => loadOpenApiYaml('? [name, region]\n: deployment\n')) - .toThrow(/does not support complex keys/) - expect(loadOpenApiYaml('features: !!set\n workflow:\n chat:\n')) - .toEqual({ features: new Set(['workflow', 'chat']) }) + expect(() => loadOpenApiYaml('? [name, region]\n: deployment\n')).toThrow( + /does not support complex keys/, + ) + expect(loadOpenApiYaml('features: !!set\n workflow:\n chat:\n')).toEqual({ + features: new Set(['workflow', 'chat']), + }) }) }) diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json index 4ebf36d2d354cd..c56a848fea083a 100644 --- a/packages/contracts/tsconfig.json +++ b/packages/contracts/tsconfig.json @@ -1,10 +1,5 @@ { "extends": "@dify/tsconfig/node.json", - "include": [ - "*.ts", - "generated/**/*.ts" - ], - "exclude": [ - "node_modules" - ] + "include": ["*.ts", "generated/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/packages/dev-proxy/package.json b/packages/dev-proxy/package.json index 05d3ad121af797..d249d0cf50f52e 100644 --- a/packages/dev-proxy/package.json +++ b/packages/dev-proxy/package.json @@ -1,14 +1,6 @@ { "name": "@langgenius/dev-proxy", - "type": "module", "version": "0.0.5", - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "types": "./dist/index.d.mts", "bin": { "dev-proxy": "./bin/dev-proxy.js" }, @@ -17,8 +9,13 @@ "dist", "src" ], - "engines": { - "node": "^22.22.1" + "type": "module", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } }, "scripts": { "build": "vp pack", @@ -40,5 +37,8 @@ "vite": "catalog:", "vite-plus": "catalog:", "vitest": "catalog:" + }, + "engines": { + "node": "^22.22.1" } } diff --git a/packages/dev-proxy/src/cli.spec.ts b/packages/dev-proxy/src/cli.spec.ts index 87a868b4102df3..0911bd1a872e6d 100644 --- a/packages/dev-proxy/src/cli.spec.ts +++ b/packages/dev-proxy/src/cli.spec.ts @@ -35,59 +35,56 @@ const getFreePort = async () => { }) const address = server.address() - if (!address || typeof address === 'string') - throw new Error('Failed to allocate a test port.') + if (!address || typeof address === 'string') throw new Error('Failed to allocate a test port.') const { port } = address await new Promise<void>((resolve, reject) => { server.close((error) => { - if (error) - reject(error) - else - resolve() + if (error) reject(error) + else resolve() }) }) return port } -const waitForOutput = ( - child: DevProxyCliProcess, - output: () => string, - expectedOutput: string, -) => new Promise<void>((resolve, reject) => { - let timeout: ReturnType<typeof setTimeout> - - function cleanup() { - clearTimeout(timeout) - child.stdout.off('data', onData) - child.stderr.off('data', onData) - child.off('exit', onExit) - } +const waitForOutput = (child: DevProxyCliProcess, output: () => string, expectedOutput: string) => + new Promise<void>((resolve, reject) => { + let timeout: ReturnType<typeof setTimeout> - function onData() { - if (!output().includes(expectedOutput)) - return + function cleanup() { + clearTimeout(timeout) + child.stdout.off('data', onData) + child.stderr.off('data', onData) + child.off('exit', onExit) + } - cleanup() - resolve() - } + function onData() { + if (!output().includes(expectedOutput)) return - function onExit(code: number | null, signal: NodeJS.Signals | null) { - cleanup() - reject(new Error(`dev-proxy exited before writing "${expectedOutput}" with code ${code} and signal ${signal}. Output:\n${output()}`)) - } + cleanup() + resolve() + } - timeout = setTimeout(() => { - cleanup() - reject(new Error(`Timed out waiting for "${expectedOutput}". Output:\n${output()}`)) - }, 3000) + function onExit(code: number | null, signal: NodeJS.Signals | null) { + cleanup() + reject( + new Error( + `dev-proxy exited before writing "${expectedOutput}" with code ${code} and signal ${signal}. Output:\n${output()}`, + ), + ) + } - child.stdout.on('data', onData) - child.stderr.on('data', onData) - child.once('exit', onExit) - onData() -}) + timeout = setTimeout(() => { + cleanup() + reject(new Error(`Timed out waiting for "${expectedOutput}". Output:\n${output()}`)) + }, 3000) + + child.stdout.on('data', onData) + child.stderr.on('data', onData) + child.once('exit', onExit) + onData() + }) const fetchTextWithRetry = async (url: string) => { let lastError: unknown @@ -96,10 +93,9 @@ const fetchTextWithRetry = async (url: string) => { try { const response = await fetch(url) return response.text() - } - catch (error) { + } catch (error) { lastError = error - await new Promise(resolve => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 50)) } } @@ -120,23 +116,19 @@ const spawnCli = (args: readonly string[], cwd: string) => { } const stopChildProcess = async (child: DevProxyCliProcess) => { - if (child.exitCode !== null || child.signalCode !== null) - return + if (child.exitCode !== null || child.signalCode !== null) return child.kill('SIGTERM') await once(child, 'exit') } const closeHttpServer = async (server: Server) => { - if (!server.listening) - return + if (!server.listening) return await new Promise<void>((resolve, reject) => { server.close((error) => { - if (error) - reject(error) - else - resolve() + if (error) reject(error) + else resolve() }) }) } @@ -153,8 +145,7 @@ const startTextServer = async (body: string) => { }) const address = server.address() - if (!address || typeof address === 'string') - throw new Error('Failed to start test server.') + if (!address || typeof address === 'string') throw new Error('Failed to start test server.') httpServers.push(server) return { @@ -166,10 +157,14 @@ describe('dev proxy CLI', () => { afterEach(async () => { await Promise.all(childProcesses.splice(0).map(stopChildProcess)) await Promise.all(httpServers.splice(0).map(closeHttpServer)) - await Promise.all(tempDirs.splice(0).map(tempDir => fs.rm(tempDir, { - force: true, - recursive: true, - }))) + await Promise.all( + tempDirs.splice(0).map((tempDir) => + fs.rm(tempDir, { + force: true, + recursive: true, + }), + ), + ) }) // Scenario: help output should still be a normal short-lived command. @@ -190,20 +185,26 @@ describe('dev proxy CLI', () => { // Arrange const tempDir = await createTempDir() const port = await getFreePort() - await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), ` + await fs.writeFile( + path.join(tempDir, 'dev-proxy.config.ts'), + ` export default { routes: [{ paths: '/api', target: 'https://api.example.com' }], } - `) + `, + ) let output = '' - const child = spawnCli(['--config', './dev-proxy.config.ts', '--host', '127.0.0.1', '--port', String(port)], tempDir) - child.stdout.on('data', chunk => output += chunk.toString()) - child.stderr.on('data', chunk => output += chunk.toString()) + const child = spawnCli( + ['--config', './dev-proxy.config.ts', '--host', '127.0.0.1', '--port', String(port)], + tempDir, + ) + child.stdout.on('data', (chunk) => (output += chunk.toString())) + child.stderr.on('data', (chunk) => (output += chunk.toString())) // Act await waitForOutput(child, () => output, `[dev-proxy] listening on http://127.0.0.1:${port}`) - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) const response = await fetch(`http://127.0.0.1:${port}/not-proxied`) // Assert @@ -220,33 +221,45 @@ describe('dev proxy CLI', () => { const firstTarget = await startTextServer('first target') const secondTarget = await startTextServer('second target') - await fs.writeFile(path.join(tempDir, '.env.proxy'), `DEV_PROXY_TEST_TARGET=http://127.0.0.1:${firstTarget.port}\n`) - await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), ` + await fs.writeFile( + path.join(tempDir, '.env.proxy'), + `DEV_PROXY_TEST_TARGET=http://127.0.0.1:${firstTarget.port}\n`, + ) + await fs.writeFile( + path.join(tempDir, 'dev-proxy.config.ts'), + ` export default { routes: [{ paths: '/api', target: process.env.DEV_PROXY_TEST_TARGET }], } - `) + `, + ) let output = '' - const child = spawnCli([ - '--config', - './dev-proxy.config.ts', - '--env-file', - './.env.proxy', - '--host', - '127.0.0.1', - '--port', - String(port), - ], tempDir) - child.stdout.on('data', chunk => output += chunk.toString()) - child.stderr.on('data', chunk => output += chunk.toString()) + const child = spawnCli( + [ + '--config', + './dev-proxy.config.ts', + '--env-file', + './.env.proxy', + '--host', + '127.0.0.1', + '--port', + String(port), + ], + tempDir, + ) + child.stdout.on('data', (chunk) => (output += chunk.toString())) + child.stderr.on('data', (chunk) => (output += chunk.toString())) const proxyUrl = `http://127.0.0.1:${port}/api/ping` // Act await waitForOutput(child, () => output, `[dev-proxy] listening on http://127.0.0.1:${port}`) const firstResponse = await fetchTextWithRetry(proxyUrl) - await fs.writeFile(path.join(tempDir, '.env.proxy'), `DEV_PROXY_TEST_TARGET=http://127.0.0.1:${secondTarget.port}\n`) + await fs.writeFile( + path.join(tempDir, '.env.proxy'), + `DEV_PROXY_TEST_TARGET=http://127.0.0.1:${secondTarget.port}\n`, + ) await waitForOutput(child, () => output, '[dev-proxy] reloaded env file changes') const secondResponse = await fetchTextWithRetry(proxyUrl) diff --git a/packages/dev-proxy/src/cli.ts b/packages/dev-proxy/src/cli.ts index 759045d641923e..25158ff33023e3 100644 --- a/packages/dev-proxy/src/cli.ts +++ b/packages/dev-proxy/src/cli.ts @@ -3,7 +3,13 @@ import type { DevProxyCliOptions, DevProxyConfig } from './types' import process from 'node:process' import { serve } from '@hono/node-server' import { watch } from 'chokidar' -import { assertDevProxyConfig, loadDevProxyConfig, parseDevProxyCliArgs, resolveDevProxyServerOptions, watchDevProxyConfig } from './config' +import { + assertDevProxyConfig, + loadDevProxyConfig, + parseDevProxyCliArgs, + resolveDevProxyServerOptions, + watchDevProxyConfig, +} from './config' import { createDevProxyApp } from './server' function printUsage() { @@ -22,19 +28,18 @@ Options: async function flushStandardStreams() { await Promise.all([ - new Promise<void>(resolve => process.stdout.write('', () => resolve())), - new Promise<void>(resolve => process.stderr.write('', () => resolve())), + new Promise<void>((resolve) => process.stdout.write('', () => resolve())), + new Promise<void>((resolve) => process.stderr.write('', () => resolve())), ]) } -const closeServer = (server: ServerType) => new Promise<void>((resolve, reject) => { - server.close((error) => { - if (error) - reject(error) - else - resolve() +const closeServer = (server: ServerType) => + new Promise<void>((resolve, reject) => { + server.close((error) => { + if (error) reject(error) + else resolve() + }) }) -}) const startDevProxyServer = (config: DevProxyConfig, cliOptions: DevProxyCliOptions) => { let app = createDevProxyApp(config) @@ -80,8 +85,7 @@ const createDevProxyRuntime = (initialConfig: DevProxyConfig, cliOptions: DevPro reloadTask = reloadTask.then(async () => { try { await reload(await loadConfig(), reason) - } - catch (error) { + } catch (error) { console.error(`[dev-proxy] failed to reload ${reason}`) console.error(error instanceof Error ? error.message : error) } @@ -112,8 +116,7 @@ async function main() { }) const runtime = createDevProxyRuntime(config, cliOptions) - if (cliOptions.watch === false) - return + if (cliOptions.watch === false) return const configWatcher = await watchDevProxyConfig(cliOptions.config, process.cwd(), { envFile: cliOptions.envFile, @@ -129,9 +132,10 @@ async function main() { envWatcher?.on('all', () => { void runtime.enqueueReload( - () => loadDevProxyConfig(cliOptions.config, process.cwd(), { - envFile: cliOptions.envFile, - }), + () => + loadDevProxyConfig(cliOptions.config, process.cwd(), { + envFile: cliOptions.envFile, + }), 'env file changes', ) }) @@ -153,8 +157,7 @@ async function main() { try { await main() await flushStandardStreams() -} -catch (error) { +} catch (error) { console.error(error instanceof Error ? error.message : error) await flushStandardStreams() process.exit(1) diff --git a/packages/dev-proxy/src/config.spec.ts b/packages/dev-proxy/src/config.spec.ts index 46293f3e076a09..3f24538102b35d 100644 --- a/packages/dev-proxy/src/config.spec.ts +++ b/packages/dev-proxy/src/config.spec.ts @@ -20,10 +20,14 @@ describe('dev proxy config', () => { delete process.env.DEV_PROXY_TEST_PORT delete process.env.DEV_PROXY_TEST_TARGET - await Promise.all(tempDirs.splice(0).map(tempDir => fs.rm(tempDir, { - force: true, - recursive: true, - }))) + await Promise.all( + tempDirs.splice(0).map((tempDir) => + fs.rm(tempDir, { + force: true, + recursive: true, + }), + ), + ) }) // Scenario: CLI options should support both inline and separated values. @@ -53,7 +57,9 @@ describe('dev proxy config', () => { // Scenario: removed target shortcuts should fail instead of silently doing the wrong thing. it('should reject unsupported target shortcuts', () => { // Assert - expect(() => parseDevProxyCliArgs(['--target', 'enterprise'])).toThrow('Unsupported dev proxy option') + expect(() => parseDevProxyCliArgs(['--target', 'enterprise'])).toThrow( + 'Unsupported dev proxy option', + ) }) // Scenario: package manager argument separators should not be treated as proxy options. @@ -71,13 +77,16 @@ describe('dev proxy config', () => { // Scenario: CLI host and port should override config defaults. it('should resolve server options with CLI overrides', () => { // Act - const options = resolveDevProxyServerOptions({ - host: '127.0.0.1', - port: 5001, - }, { - host: '0.0.0.0', - port: '9002', - }) + const options = resolveDevProxyServerOptions( + { + host: '127.0.0.1', + port: 5001, + }, + { + host: '0.0.0.0', + port: '9002', + }, + ) // Assert expect(options).toEqual({ @@ -90,12 +99,15 @@ describe('dev proxy config', () => { it('should load a TypeScript config file', async () => { // Arrange const tempDir = await createTempDir() - await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), ` + await fs.writeFile( + path.join(tempDir, 'dev-proxy.config.ts'), + ` export default { server: { host: '127.0.0.1', port: 7777 }, routes: [{ paths: ['/api', '/files'], target: 'https://api.example.com' }], } - `) + `, + ) // Act const config = await loadDevProxyConfig('dev-proxy.config.ts', tempDir) @@ -117,16 +129,19 @@ describe('dev proxy config', () => { it('should load a TypeScript config file with env file values', async () => { // Arrange const tempDir = await createTempDir() - await fs.writeFile(path.join(tempDir, '.env.proxy'), [ - 'DEV_PROXY_TEST_PORT=7788', - 'DEV_PROXY_TEST_TARGET=https://env.example.com', - ].join('\n')) - await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), ` + await fs.writeFile( + path.join(tempDir, '.env.proxy'), + ['DEV_PROXY_TEST_PORT=7788', 'DEV_PROXY_TEST_TARGET=https://env.example.com'].join('\n'), + ) + await fs.writeFile( + path.join(tempDir, 'dev-proxy.config.ts'), + ` export default { server: { port: Number(process.env.DEV_PROXY_TEST_PORT) }, routes: [{ paths: '/api', target: process.env.DEV_PROXY_TEST_TARGET }], } - `) + `, + ) // Act const config = await loadDevProxyConfig('dev-proxy.config.ts', tempDir, { diff --git a/packages/dev-proxy/src/config.ts b/packages/dev-proxy/src/config.ts index a07d969ea2ebc3..c369014e9548c0 100644 --- a/packages/dev-proxy/src/config.ts +++ b/packages/dev-proxy/src/config.ts @@ -1,5 +1,11 @@ import type { DotenvOptions, LoadConfigOptions, WatchConfigOptions } from 'c12' -import type { DevProxyCliOptions, DevProxyConfig, DevProxyConfigLoadOptions, DevProxyServerConfig, ResolvedDevProxyServerOptions } from './types' +import type { + DevProxyCliOptions, + DevProxyConfig, + DevProxyConfigLoadOptions, + DevProxyServerConfig, + ResolvedDevProxyServerOptions, +} from './types' import path from 'node:path' import { loadConfig, watchConfig } from 'c12' @@ -20,8 +26,7 @@ type OptionName = keyof typeof OPTION_NAME_TO_KEY const isOptionName = (value: string): value is OptionName => value in OPTION_NAME_TO_KEY const requireOptionValue = (name: string, value?: string) => { - if (!value || value.startsWith('-')) - throw new Error(`Missing value for ${name}.`) + if (!value || value.startsWith('-')) throw new Error(`Missing value for ${name}.`) return value } @@ -32,8 +37,7 @@ export const parseDevProxyCliArgs = (argv: readonly string[]): DevProxyCliOption for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]! - if (arg === '--') - continue + if (arg === '--') continue if (arg === '--help' || arg === '-h') { options.help = true @@ -53,17 +57,14 @@ export const parseDevProxyCliArgs = (argv: readonly string[]): DevProxyCliOption const [rawName, inlineValue] = arg.split('=', 2) const name = rawName ?? '' - if (!name.startsWith('-')) - continue + if (!name.startsWith('-')) continue - if (!isOptionName(name)) - throw new Error(`Unsupported dev proxy option "${name}".`) + if (!isOptionName(name)) throw new Error(`Unsupported dev proxy option "${name}".`) const key = OPTION_NAME_TO_KEY[name] options[key] = inlineValue ?? requireOptionValue(name, argv[index + 1]) - if (inlineValue === undefined) - index += 1 + if (inlineValue === undefined) index += 1 } return options @@ -93,8 +94,7 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null export function assertDevProxyConfig(config: unknown): asserts config is DevProxyConfig { - if (!isRecord(config)) - throw new Error('Dev proxy config must export an object.') + if (!isRecord(config)) throw new Error('Dev proxy config must export an object.') if (!Array.isArray(config.routes)) throw new Error('Dev proxy config must include a routes array.') @@ -104,8 +104,7 @@ const resolveDotenvOptions = ( envFile: DevProxyConfigLoadOptions['envFile'], cwd: string, ): DotenvOptions | false => { - if (!envFile) - return false + if (!envFile) return false const resolvedEnvFilePath = path.resolve(cwd, envFile) return { diff --git a/packages/dev-proxy/src/cookies.spec.ts b/packages/dev-proxy/src/cookies.spec.ts index 02bce36eee998b..9c3802ede4c42f 100644 --- a/packages/dev-proxy/src/cookies.spec.ts +++ b/packages/dev-proxy/src/cookies.spec.ts @@ -13,10 +13,13 @@ describe('dev proxy cookies', () => { // Scenario: cookie names should only receive secure host prefixes when configured. it('should rewrite configured cookie names for HTTPS upstream requests', () => { // Act - const cookieHeader = rewriteCookieHeaderForUpstream('access_token=abc; theme=dark; passport-app=def', { - hostPrefixCookies: ['access_token', /^passport-/], - useHostPrefix: true, - }) + const cookieHeader = rewriteCookieHeaderForUpstream( + 'access_token=abc; theme=dark; passport-app=def', + { + hostPrefixCookies: ['access_token', /^passport-/], + useHostPrefix: true, + }, + ) // Assert expect(cookieHeader).toBe('__Host-access_token=abc; theme=dark; __Host-passport-app=def') @@ -42,9 +45,7 @@ describe('dev proxy cookies', () => { ]) // Assert - expect(cookies).toEqual([ - 'access_token=abc; Path=/; SameSite=Lax', - ]) + expect(cookies).toEqual(['access_token=abc; Path=/; SameSite=Lax']) }) // Scenario: target-scoped cookies should isolate authentication state between upstream targets. @@ -54,16 +55,19 @@ describe('dev proxy cookies', () => { const otherAccessTokenName = toScopedLocalCookieName('access_token', 'other') // Act - const cookieHeader = rewriteCookieHeaderForUpstream([ - `${activeAccessTokenName}=active-token`, - 'access_token=legacy-token', - `${otherAccessTokenName}=other-token`, - 'theme=dark', - ].join('; '), { - hostPrefixCookies: ['access_token'], - localScopeKey: 'active', - useHostPrefix: true, - }) + const cookieHeader = rewriteCookieHeaderForUpstream( + [ + `${activeAccessTokenName}=active-token`, + 'access_token=legacy-token', + `${otherAccessTokenName}=other-token`, + 'theme=dark', + ].join('; '), + { + hostPrefixCookies: ['access_token'], + localScopeKey: 'active', + useHostPrefix: true, + }, + ) // Assert expect(cookieHeader).toBe('__Host-access_token=active-token; theme=dark') @@ -75,17 +79,18 @@ describe('dev proxy cookies', () => { const scopedAccessTokenName = toScopedLocalCookieName('access_token', 'cloud') // Act - const cookies = rewriteSetCookieHeadersForLocal([ - '__Host-access_token=abc; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None; Partitioned', - ], { - hostPrefixCookies: ['access_token'], - localScopeKey: 'cloud', - }) + const cookies = rewriteSetCookieHeadersForLocal( + [ + '__Host-access_token=abc; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None; Partitioned', + ], + { + hostPrefixCookies: ['access_token'], + localScopeKey: 'cloud', + }, + ) // Assert - expect(cookies).toEqual([ - `${scopedAccessTokenName}=abc; Path=/; SameSite=Lax`, - ]) + expect(cookies).toEqual([`${scopedAccessTokenName}=abc; Path=/; SameSite=Lax`]) }) // Scenario: request header helpers should read scoped CSRF cookies without exposing scope logic to callers. @@ -94,7 +99,10 @@ describe('dev proxy cookies', () => { const scopedCsrfCookieName = toScopedLocalCookieName('csrf_token', 'cloud') // Act - const csrfToken = getCookieHeaderValue(`${scopedCsrfCookieName}=csrf; csrf_token=legacy`, scopedCsrfCookieName) + const csrfToken = getCookieHeaderValue( + `${scopedCsrfCookieName}=csrf; csrf_token=legacy`, + scopedCsrfCookieName, + ) // Assert expect(csrfToken).toBe('csrf') diff --git a/packages/dev-proxy/src/cookies.ts b/packages/dev-proxy/src/cookies.ts index b9deb6d2146228..350cffc4e88043 100644 --- a/packages/dev-proxy/src/cookies.ts +++ b/packages/dev-proxy/src/cookies.ts @@ -8,15 +8,14 @@ const COOKIE_DOMAIN_PATTERN = /^domain=/i const COOKIE_SECURE_PATTERN = /^secure$/i const COOKIE_PARTITIONED_PATTERN = /^partitioned$/i -const stripSecureCookiePrefix = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '') +const stripSecureCookiePrefix = (cookieName: string) => + cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '') const matchesCookieName = (cookieName: string, matcher: string | RegExp) => - typeof matcher === 'string' - ? matcher === cookieName - : matcher.test(cookieName) + typeof matcher === 'string' ? matcher === cookieName : matcher.test(cookieName) const hashScope = (scope: string) => { - let hash = 0x811C9DC5 + let hash = 0x811c9dc5 for (let index = 0; index < scope.length; index += 1) { hash ^= scope.charCodeAt(index) @@ -29,27 +28,30 @@ const hashScope = (scope: string) => { const shouldUseHostPrefix = (cookieName: string, options: CookieRewriteOptions) => { const normalizedCookieName = stripSecureCookiePrefix(cookieName) - return options.hostPrefixCookies?.some(matcher => matchesCookieName(normalizedCookieName, matcher)) || false + return ( + options.hostPrefixCookies?.some((matcher) => + matchesCookieName(normalizedCookieName, matcher), + ) || false + ) } const toUpstreamCookieName = (cookieName: string, options: CookieRewriteOptions) => { - if (cookieName.startsWith('__Host-')) - return cookieName + if (cookieName.startsWith('__Host-')) return cookieName - if (cookieName.startsWith('__Secure-')) - return `__Host-${stripSecureCookiePrefix(cookieName)}` + if (cookieName.startsWith('__Secure-')) return `__Host-${stripSecureCookiePrefix(cookieName)}` - if (!shouldUseHostPrefix(cookieName, options)) - return cookieName + if (!shouldUseHostPrefix(cookieName, options)) return cookieName return `__Host-${cookieName}` } export const toLocalCookieName = (cookieName: string) => stripSecureCookiePrefix(cookieName) -export const resolveCookieRewriteLocalScopeKey = (options: CookieRewriteOptions, targetUrl: URL) => { - if (options.localCookieScope === 'target-origin') - return hashScope(targetUrl.origin) +export const resolveCookieRewriteLocalScopeKey = ( + options: CookieRewriteOptions, + targetUrl: URL, +) => { + if (options.localCookieScope === 'target-origin') return hashScope(targetUrl.origin) return undefined } @@ -59,25 +61,23 @@ export const toScopedLocalCookieName = (cookieName: string, localScopeKey: strin const fromScopedLocalCookieName = (cookieName: string, localScopeKey: string) => { const scopedPrefix = `${LOCAL_SCOPED_COOKIE_PREFIX}_${localScopeKey}_` - if (!cookieName.startsWith(scopedPrefix)) - return undefined + if (!cookieName.startsWith(scopedPrefix)) return undefined return cookieName.slice(scopedPrefix.length) } -const isScopedLocalCookieName = (cookieName: string) => cookieName.startsWith(`${LOCAL_SCOPED_COOKIE_PREFIX}_`) +const isScopedLocalCookieName = (cookieName: string) => + cookieName.startsWith(`${LOCAL_SCOPED_COOKIE_PREFIX}_`) const parseCookieHeader = (cookieHeader: string | undefined) => { - if (!cookieHeader) - return [] + if (!cookieHeader) return [] return cookieHeader .split(/;\s*/) .filter(Boolean) .map((cookie) => { const separatorIndex = cookie.indexOf('=') - if (separatorIndex === -1) - return { name: cookie, value: undefined } + if (separatorIndex === -1) return { name: cookie, value: undefined } return { name: cookie.slice(0, separatorIndex).trim(), @@ -87,23 +87,21 @@ const parseCookieHeader = (cookieHeader: string | undefined) => { } export const getCookieHeaderValue = (cookieHeader: string | undefined, cookieName: string) => { - const cookie = parseCookieHeader(cookieHeader).find(cookie => cookie.name === cookieName) + const cookie = parseCookieHeader(cookieHeader).find((cookie) => cookie.name === cookieName) return cookie?.value } export const rewriteCookieHeaderForUpstream = ( cookieHeader: string | undefined, - options: CookieRewriteOptions & { useHostPrefix?: boolean, localScopeKey?: string }, + options: CookieRewriteOptions & { useHostPrefix?: boolean; localScopeKey?: string }, ) => { - if (!cookieHeader) - return cookieHeader + if (!cookieHeader) return cookieHeader const { useHostPrefix = true } = options return parseCookieHeader(cookieHeader) .map((cookie) => { - if (cookie.value === undefined) - return cookie.name + if (cookie.value === undefined) return cookie.name const scopedCookieName = options.localScopeKey ? fromScopedLocalCookieName(cookie.name, options.localScopeKey) @@ -116,7 +114,10 @@ export const rewriteCookieHeaderForUpstream = ( return `${upstreamCookieName}=${cookie.value}` } - if (options.localScopeKey && (isScopedLocalCookieName(cookie.name) || shouldUseHostPrefix(cookie.name, options))) + if ( + options.localScopeKey && + (isScopedLocalCookieName(cookie.name) || shouldUseHostPrefix(cookie.name, options)) + ) return undefined const upstreamCookieName = useHostPrefix @@ -136,8 +137,7 @@ const rewriteSetCookieValueForLocal = ( const [rawCookiePair, ...rawAttributes] = setCookieValue.split(';') const separatorIndex = rawCookiePair!.indexOf('=') - if (separatorIndex === -1) - return setCookieValue + if (separatorIndex === -1) return setCookieValue const cookieName = rawCookiePair!.slice(0, separatorIndex).trim() const cookieValue = rawCookiePair!.slice(separatorIndex + 1) @@ -149,18 +149,17 @@ const rewriteSetCookieValueForLocal = ( ? toScopedLocalCookieName(cookieName, options!.localScopeKey!) : localCookieName const rewrittenAttributes = rawAttributes - .map(attribute => attribute.trim()) - .filter(attribute => - !COOKIE_DOMAIN_PATTERN.test(attribute) - && !COOKIE_SECURE_PATTERN.test(attribute) - && !COOKIE_PARTITIONED_PATTERN.test(attribute), + .map((attribute) => attribute.trim()) + .filter( + (attribute) => + !COOKIE_DOMAIN_PATTERN.test(attribute) && + !COOKIE_SECURE_PATTERN.test(attribute) && + !COOKIE_PARTITIONED_PATTERN.test(attribute), ) .map((attribute) => { - if (SAME_SITE_NONE_PATTERN.test(attribute)) - return 'SameSite=Lax' + if (SAME_SITE_NONE_PATTERN.test(attribute)) return 'SameSite=Lax' - if (COOKIE_PATH_PATTERN.test(attribute)) - return 'Path=/' + if (COOKIE_PATH_PATTERN.test(attribute)) return 'Path=/' return attribute }) @@ -171,4 +170,4 @@ const rewriteSetCookieValueForLocal = ( export const rewriteSetCookieHeadersForLocal = ( setCookieHeaders: readonly string[], options?: CookieRewriteOptions & { localScopeKey?: string }, -) => setCookieHeaders.map(cookie => rewriteSetCookieValueForLocal(cookie, options)) +) => setCookieHeaders.map((cookie) => rewriteSetCookieValueForLocal(cookie, options)) diff --git a/packages/dev-proxy/src/index.ts b/packages/dev-proxy/src/index.ts index e35893b98f946a..8dedd2e83f196c 100644 --- a/packages/dev-proxy/src/index.ts +++ b/packages/dev-proxy/src/index.ts @@ -5,8 +5,17 @@ export { parseDevProxyCliArgs, resolveDevProxyServerOptions, } from './config' -export { rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal, toLocalCookieName } from './cookies' -export { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin, isAllowedLocalDevOrigin } from './server' +export { + rewriteCookieHeaderForUpstream, + rewriteSetCookieHeadersForLocal, + toLocalCookieName, +} from './cookies' +export { + buildUpstreamUrl, + createDevProxyApp, + isAllowedDevOrigin, + isAllowedLocalDevOrigin, +} from './server' export type { CookieNameMatcher, CookieRewriteOptions, diff --git a/packages/dev-proxy/src/server.spec.ts b/packages/dev-proxy/src/server.spec.ts index 1a60d44f9de047..e7eccfb7a952ef 100644 --- a/packages/dev-proxy/src/server.spec.ts +++ b/packages/dev-proxy/src/server.spec.ts @@ -13,7 +13,11 @@ describe('dev proxy server', () => { // Scenario: target paths should not be duplicated when the incoming route already includes them. it('should preserve prefixed targets when building upstream URLs', () => { // Act - const url = buildUpstreamUrl('https://api.example.com/console/api', '/console/api/apps', '?page=1') + const url = buildUpstreamUrl( + 'https://api.example.com/console/api', + '/console/api/apps', + '?page=1', + ) // Assert expect(url.href).toBe('https://api.example.com/console/api/apps?page=1') @@ -37,15 +41,20 @@ describe('dev proxy server', () => { // Scenario: proxy requests should rewrite cookies and surface credentialed CORS headers when configured. it('should proxy api requests with configured local cookie rewriting', async () => { // Arrange - const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok', { - status: 200, - headers: [ - ['content-encoding', 'br'], - ['content-length', '123'], - ['set-cookie', '__Host-access_token=abc; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None'], - ['transfer-encoding', 'chunked'], - ], - })) + const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue( + new Response('ok', { + status: 200, + headers: [ + ['content-encoding', 'br'], + ['content-length', '123'], + [ + 'set-cookie', + '__Host-access_token=abc; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None', + ], + ['transfer-encoding', 'chunked'], + ], + }), + ) const app = createDevProxyApp({ routes: [ { @@ -62,8 +71,8 @@ describe('dev proxy server', () => { // Act const response = await app.request('http://127.0.0.1:5001/console/api/apps?page=1', { headers: { - 'Origin': 'http://localhost:3000', - 'Cookie': 'access_token=abc; theme=dark', + Origin: 'http://localhost:3000', + Cookie: 'access_token=abc; theme=dark', 'Accept-Encoding': 'zstd, br, gzip', }, }) @@ -90,9 +99,7 @@ describe('dev proxy server', () => { expect(response.headers.get('content-encoding')).toBeNull() expect(response.headers.get('content-length')).toBeNull() expect(response.headers.get('transfer-encoding')).toBeNull() - expect(response.headers.getSetCookie()).toEqual([ - 'access_token=abc; Path=/; SameSite=Lax', - ]) + expect(response.headers.getSetCookie()).toEqual(['access_token=abc; Path=/; SameSite=Lax']) }) // Scenario: generic proxy routes should not know Dify cookie names by default. @@ -172,12 +179,17 @@ describe('dev proxy server', () => { const accessTokenCookieName = toScopedLocalCookieName('access_token', localScopeKey) const csrfTokenCookieName = toScopedLocalCookieName('csrf_token', localScopeKey) const otherScopeAccessTokenCookieName = toScopedLocalCookieName('access_token', 'other') - const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok', { - status: 200, - headers: [ - ['set-cookie', '__Host-access_token=next; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None'], - ], - })) + const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue( + new Response('ok', { + status: 200, + headers: [ + [ + 'set-cookie', + '__Host-access_token=next; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None', + ], + ], + }), + ) const app = createDevProxyApp({ routes: [ { @@ -192,7 +204,7 @@ describe('dev proxy server', () => { // Act const response = await app.request('http://127.0.0.1:5001/console/api/apps', { headers: { - 'Cookie': [ + Cookie: [ `${accessTokenCookieName}=current-access`, `${csrfTokenCookieName}=current-csrf`, 'access_token=legacy-access', @@ -209,7 +221,9 @@ describe('dev proxy server', () => { if (!(requestHeaders instanceof Headers)) throw new Error('Expected proxy request headers to be Headers') - expect(requestHeaders.get('cookie')).toBe('__Host-access_token=current-access; __Host-csrf_token=current-csrf; theme=dark') + expect(requestHeaders.get('cookie')).toBe( + '__Host-access_token=current-access; __Host-csrf_token=current-csrf; theme=dark', + ) expect(requestHeaders.get('x-csrf-token')).toBe('current-csrf') expect(response.headers.getSetCookie()).toEqual([ `${accessTokenCookieName}=next; Path=/; SameSite=Lax`, @@ -289,7 +303,7 @@ describe('dev proxy server', () => { const response = await app.request('http://127.0.0.1:5001/api/messages', { method: 'OPTIONS', headers: { - 'Origin': 'http://localhost:3000', + Origin: 'http://localhost:3000', 'Access-Control-Request-Headers': 'authorization,content-type,x-csrf-token', }, }) @@ -298,6 +312,8 @@ describe('dev proxy server', () => { expect(response.status).toBe(204) expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:3000') expect(response.headers.get('access-control-allow-credentials')).toBe('true') - expect(response.headers.get('access-control-allow-headers')).toBe('authorization,content-type,x-csrf-token') + expect(response.headers.get('access-control-allow-headers')).toBe( + 'authorization,content-type,x-csrf-token', + ) }) }) diff --git a/packages/dev-proxy/src/server.ts b/packages/dev-proxy/src/server.ts index 4719edfdfe9671..9a53975cf0c8fd 100644 --- a/packages/dev-proxy/src/server.ts +++ b/packages/dev-proxy/src/server.ts @@ -1,5 +1,10 @@ import type { Context, Hono } from 'hono' -import type { CookieRewriteOptions, CreateDevProxyAppOptions, DevProxyCorsAllowedOrigins, DevProxyRoute } from './types' +import type { + CookieRewriteOptions, + CreateDevProxyAppOptions, + DevProxyCorsAllowedOrigins, + DevProxyRoute, +} from './types' import { Hono as HonoApp } from 'hono' import { getCookieHeaderValue, @@ -33,21 +38,24 @@ const appendHeaderValue = (headers: Headers, name: string, value: string) => { return } - if (currentValue.split(',').map(item => item.trim()).includes(value)) + if ( + currentValue + .split(',') + .map((item) => item.trim()) + .includes(value) + ) return headers.set(name, `${currentValue}, ${value}`) } export const isAllowedLocalDevOrigin = (origin?: string | null) => { - if (!origin) - return false + if (!origin) return false try { const url = new URL(origin) return LOCAL_DEV_HOSTS.has(url.hostname) - } - catch { + } catch { return false } } @@ -56,11 +64,9 @@ export const isAllowedDevOrigin = ( origin?: string | null, allowedOrigins: DevProxyCorsAllowedOrigins = 'local', ) => { - if (!origin) - return false + if (!origin) return false - if (allowedOrigins === 'local') - return isAllowedLocalDevOrigin(origin) + if (allowedOrigins === 'local') return isAllowedLocalDevOrigin(origin) return allowedOrigins.includes(origin) } @@ -70,8 +76,7 @@ const applyCorsHeaders = ( origin: string | undefined | null, allowedOrigins: DevProxyCorsAllowedOrigins = 'local', ) => { - if (!isAllowedDevOrigin(origin, allowedOrigins)) - return + if (!isAllowedDevOrigin(origin, allowedOrigins)) return headers.set('Access-Control-Allow-Origin', origin!) headers.set('Access-Control-Allow-Credentials', 'true') @@ -80,10 +85,13 @@ const applyCorsHeaders = ( export const buildUpstreamUrl = (target: string, requestPath: string, search = '') => { const targetUrl = new URL(target) - const normalizedTargetPath = targetUrl.pathname === '/' ? '' : targetUrl.pathname.replace(/\/$/, '') + const normalizedTargetPath = + targetUrl.pathname === '/' ? '' : targetUrl.pathname.replace(/\/$/, '') const normalizedRequestPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}` - const hasTargetPrefix = normalizedTargetPath - && (normalizedRequestPath === normalizedTargetPath || normalizedRequestPath.startsWith(`${normalizedTargetPath}/`)) + const hasTargetPrefix = + normalizedTargetPath && + (normalizedRequestPath === normalizedTargetPath || + normalizedRequestPath.startsWith(`${normalizedTargetPath}/`)) targetUrl.pathname = hasTargetPrefix ? normalizedRequestPath @@ -102,29 +110,30 @@ const createProxyRequestHeaders = ( headers.delete('host') headers.set('accept-encoding', UPSTREAM_ACCEPT_ENCODING) - if (headers.has('origin')) - headers.set('origin', targetUrl.origin) + if (headers.has('origin')) headers.set('origin', targetUrl.origin) if (cookieRewrite) { const originalCookieHeader = headers.get('cookie') || undefined const localScopeKey = resolveCookieRewriteLocalScopeKey(cookieRewrite, targetUrl) - const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined, { - ...cookieRewrite, - localScopeKey, - useHostPrefix: targetUrl.protocol === 'https:', - }) - if (rewrittenCookieHeader) - headers.set('cookie', rewrittenCookieHeader) - else - headers.delete('cookie') + const rewrittenCookieHeader = rewriteCookieHeaderForUpstream( + headers.get('cookie') || undefined, + { + ...cookieRewrite, + localScopeKey, + useHostPrefix: targetUrl.protocol === 'https:', + }, + ) + if (rewrittenCookieHeader) headers.set('cookie', rewrittenCookieHeader) + else headers.delete('cookie') if (localScopeKey && cookieRewrite.csrfHeader) { - const scopedCsrfCookieName = toScopedLocalCookieName(cookieRewrite.csrfHeader.cookieName, localScopeKey) + const scopedCsrfCookieName = toScopedLocalCookieName( + cookieRewrite.csrfHeader.cookieName, + localScopeKey, + ) const scopedCsrfToken = getCookieHeaderValue(originalCookieHeader, scopedCsrfCookieName) - if (scopedCsrfToken) - headers.set(cookieRewrite.csrfHeader.headerName, scopedCsrfToken) - else - headers.delete(cookieRewrite.csrfHeader.headerName) + if (scopedCsrfToken) headers.set(cookieRewrite.csrfHeader.headerName, scopedCsrfToken) + else headers.delete(cookieRewrite.csrfHeader.headerName) } } @@ -134,8 +143,7 @@ const createProxyRequestHeaders = ( const getSetCookieHeaders = (headers: Headers) => { const headersWithGetSetCookie = headers as Headers & { getSetCookie?: () => string[] } const setCookieHeaders = headersWithGetSetCookie.getSetCookie?.() - if (setCookieHeaders?.length) - return setCookieHeaders + if (setCookieHeaders?.length) return setCookieHeaders const setCookie = headers.get('set-cookie') return setCookie ? [setCookie] : [] @@ -149,7 +157,7 @@ const createUpstreamResponseHeaders = ( cookieRewrite: CookieRewriteOptions | false | undefined, ) => { const headers = new Headers(response.headers) - RESPONSE_HEADERS_TO_DROP.forEach(header => headers.delete(header)) + RESPONSE_HEADERS_TO_DROP.forEach((header) => headers.delete(header)) headers.delete('set-cookie') const localScopeKey = cookieRewrite @@ -207,7 +215,8 @@ const proxyRequest = async ( }) } -const normalizeRoutePaths = (paths: DevProxyRoute['paths']) => Array.isArray(paths) ? paths : [paths] +const normalizeRoutePaths = (paths: DevProxyRoute['paths']) => + Array.isArray(paths) ? paths : [paths] const registerProxyRoute = ( app: Hono, @@ -219,8 +228,8 @@ const registerProxyRoute = ( if (!path.startsWith('/')) throw new Error(`Invalid dev proxy route path "${path}". Paths must start with "/".`) - app.all(path, context => proxyRequest(context, route, fetchImpl, allowedOrigins)) - app.all(`${path}/*`, context => proxyRequest(context, route, fetchImpl, allowedOrigins)) + app.all(path, (context) => proxyRequest(context, route, fetchImpl, allowedOrigins)) + app.all(`${path}/*`, (context) => proxyRequest(context, route, fetchImpl, allowedOrigins)) } const registerProxyRoutes = ( diff --git a/packages/dev-proxy/tsconfig.json b/packages/dev-proxy/tsconfig.json index 813a9bd8a30e43..a082278e246729 100644 --- a/packages/dev-proxy/tsconfig.json +++ b/packages/dev-proxy/tsconfig.json @@ -1,17 +1,8 @@ { "extends": "@dify/tsconfig/node.json", "compilerOptions": { - "types": [ - "node", - "vitest/globals" - ] + "types": ["node", "vitest/globals"] }, - "include": [ - "src/**/*.ts", - "vite.config.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] + "include": ["src/**/*.ts", "vite.config.ts"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/dev-proxy/vite.config.ts b/packages/dev-proxy/vite.config.ts index d060ae036e3830..2208c5108a6c91 100644 --- a/packages/dev-proxy/vite.config.ts +++ b/packages/dev-proxy/vite.config.ts @@ -4,16 +4,9 @@ export default defineConfig({ pack: { clean: true, deps: { - neverBundle: [ - '@hono/node-server', - 'c12', - 'hono', - ], + neverBundle: ['@hono/node-server', 'c12', 'hono'], }, - entry: [ - 'src/index.ts', - 'src/cli.ts', - ], + entry: ['src/index.ts', 'src/cli.ts'], format: ['esm'], outDir: 'dist', platform: 'node', diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index eccdb888d488ff..473137669cba2b 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -16,8 +16,8 @@ For a new workspace consumer, add: ```jsonc { "dependencies": { - "@langgenius/dify-ui": "workspace:*" - } + "@langgenius/dify-ui": "workspace:*", + }, } ``` @@ -215,7 +215,7 @@ Base UI can wait for `element.getAnimations()` to finish before it unmounts over Set the Base UI test flag in a Vitest setup file to skip those waits: ```ts -( +;( globalThis as typeof globalThis & { BASE_UI_ANIMATIONS_DISABLED: boolean } @@ -227,7 +227,7 @@ Set the Base UI test flag in a Vitest setup file to skip those waits: See `[AGENTS.md](./AGENTS.md)` for: - Component authoring rules (one-component-per-folder, `cva` + `cn`, relative imports inside the package, subpath imports from consumers). -- Figma `--radius/`* token → Tailwind `rounded-*` class mapping. +- Figma `--radius/`_ token → Tailwind `rounded-_` class mapping. ## Not part of this package diff --git a/packages/dify-ui/eslint.config.mjs b/packages/dify-ui/eslint.config.mjs index 1a33c409edd183..ec0751b548b8bf 100644 --- a/packages/dify-ui/eslint.config.mjs +++ b/packages/dify-ui/eslint.config.mjs @@ -11,11 +11,31 @@ const TEST_FILES = ['**/__tests__/**/*.{ts,tsx}', '**/*.spec.{ts,tsx}'] export default antfu( { - ignores: [ - 'coverage/', - 'dist/', - 'storybook-static/', - ], + ignores: ['coverage/', 'dist/', 'storybook-static/'], + stylistic: false, + perfectionist: { + overrides: { + 'perfectionist/sort-imports': 'off', + }, + }, + jsonc: { + overrides: { + 'jsonc/space-unary-ops': 'off', + }, + }, + yaml: { + overrides: { + 'yaml/block-mapping': 'off', + 'yaml/block-sequence': 'off', + 'yaml/plain-scalar': 'off', + }, + }, + toml: { + overrides: { + 'toml/comma-style': 'off', + 'toml/no-space-dots': 'off', + }, + }, react: { overrides: { 'react/exhaustive-deps': ['error', { additionalHooks: 'useIsoLayoutEffect' }], @@ -42,11 +62,6 @@ export default antfu( 'test/prefer-lowercase-title': 'off', }, }, - stylistic: { - overrides: { - 'antfu/top-level-function': 'off', - }, - }, e18e: false, pnpm: false, }, @@ -64,9 +79,12 @@ export default antfu( name: 'dify-ui/storybook', files: ['**/.storybook/main.{js,cjs,mjs,ts}'], rules: { - 'storybook/no-uninstalled-addons': ['error', { - packageJsonLocation: path.resolve(import.meta.dirname, 'package.json'), - }], + 'storybook/no-uninstalled-addons': [ + 'error', + { + packageJsonLocation: path.resolve(import.meta.dirname, 'package.json'), + }, + ], }, }, { @@ -77,11 +95,9 @@ export default antfu( tailwindcss, }, rules: { - 'tailwindcss/enforce-consistent-class-order': 'error', 'tailwindcss/no-duplicate-classes': 'error', 'tailwindcss/no-deprecated-classes': 'error', 'tailwindcss/no-unknown-classes': 'error', - 'tailwindcss/no-unnecessary-whitespace': 'error', }, settings: { 'better-tailwindcss': { @@ -99,6 +115,7 @@ export default antfu( { rules: { 'node/prefer-global/process': 'off', + 'unicorn/number-literal-case': 'off', }, }, { @@ -106,4 +123,8 @@ export default antfu( reportUnusedDisableDirectives: 'error', }, }, -) +).override('antfu/sort/package-json', { + rules: { + 'jsonc/sort-keys': 'off', + }, +}) diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index e49c4f932e0818..ec3462fdd85359 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -1,8 +1,8 @@ { "name": "@langgenius/dify-ui", - "type": "module", "version": "0.0.1", "private": true, + "type": "module", "exports": { "./styles.css": "./src/styles/styles.css", "./cn": { @@ -159,13 +159,6 @@ "test:watch": "vp test --project unit --watch", "type-check": "tsc" }, - "peerDependencies": { - "@base-ui/react": "catalog:", - "class-variance-authority": "catalog:", - "react": "catalog:", - "react-dom": "catalog:", - "tailwindcss": "catalog:" - }, "dependencies": { "clsx": "catalog:", "tailwind-merge": "catalog:" @@ -210,5 +203,12 @@ "vite-plus": "catalog:", "vitest": "catalog:", "vitest-browser-react": "catalog:" + }, + "peerDependencies": { + "@base-ui/react": "catalog:", + "class-variance-authority": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "tailwindcss": "catalog:" } } diff --git a/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx b/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx index e3a2a7cca76263..e841b55d0aea8d 100644 --- a/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx @@ -25,7 +25,9 @@ describe('AlertDialog wrapper', () => { ) await expect.element(screen.getByRole('alertdialog')).toHaveTextContent('Confirm Delete') - await expect.element(screen.getByRole('alertdialog')).toHaveTextContent('This action cannot be undone.') + await expect + .element(screen.getByRole('alertdialog')) + .toHaveTextContent('This action cannot be undone.') }) it('should not render content when dialog is closed', async () => { diff --git a/packages/dify-ui/src/alert-dialog/index.stories.tsx b/packages/dify-ui/src/alert-dialog/index.stories.tsx index d00328270b9988..03975f880af7b0 100644 --- a/packages/dify-ui/src/alert-dialog/index.stories.tsx +++ b/packages/dify-ui/src/alert-dialog/index.stories.tsx @@ -13,7 +13,8 @@ import { } from '.' import { Button } from '../button' -const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const triggerButtonClassName = + 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' const meta = { title: 'Base/UI/AlertDialog', @@ -22,7 +23,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound alert dialog built on Base UI AlertDialog. Use it for destructive or high-risk confirmations that require an explicit user decision. Compose title, description, and actions via `AlertDialogActions`, `AlertDialogCancelButton`, and `AlertDialogConfirmButton`.', + component: + 'Compound alert dialog built on Base UI AlertDialog. Use it for destructive or high-risk confirmations that require an explicit user decision. Compose title, description, and actions via `AlertDialogActions`, `AlertDialogCancelButton`, and `AlertDialogConfirmButton`.', }, }, }, @@ -35,9 +37,7 @@ type Story = StoryObj<typeof meta> export const Default: Story = { render: () => ( <AlertDialog> - <AlertDialogTrigger - render={<button type="button" className={triggerButtonClassName} />} - > + <AlertDialogTrigger render={<button type="button" className={triggerButtonClassName} />}> Delete project </AlertDialogTrigger> <AlertDialogContent> @@ -46,7 +46,8 @@ export const Default: Story = { Delete project? </AlertDialogTitle> <AlertDialogDescription className="text-sm leading-5 text-text-secondary"> - This action cannot be undone. All workflows, datasets, and API keys will be permanently removed. + This action cannot be undone. All workflows, datasets, and API keys will be permanently + removed. </AlertDialogDescription> </div> <AlertDialogActions> @@ -68,7 +69,9 @@ export const Default: Story = { await userEvent.click(body.getByRole('button', { name: 'Cancel' })) await waitFor(async () => { - await expect(body.queryByRole('alertdialog', { name: 'Delete project?' })).not.toBeInTheDocument() + await expect( + body.queryByRole('alertdialog', { name: 'Delete project?' }), + ).not.toBeInTheDocument() }) }, } @@ -76,9 +79,7 @@ export const Default: Story = { export const NonDestructive: Story = { render: () => ( <AlertDialog> - <AlertDialogTrigger - render={<button type="button" className={triggerButtonClassName} />} - > + <AlertDialogTrigger render={<button type="button" className={triggerButtonClassName} />}> Publish changes </AlertDialogTrigger> <AlertDialogContent> @@ -87,7 +88,8 @@ export const NonDestructive: Story = { Publish the latest draft? </AlertDialogTitle> <AlertDialogDescription className="text-sm leading-5 text-text-secondary"> - Collaborators will immediately see the new workflow. You can still revert from version history. + Collaborators will immediately see the new workflow. You can still revert from version + history. </AlertDialogDescription> </div> <AlertDialogActions> @@ -110,9 +112,7 @@ const ControlledDemo = () => { </Button> <span className="text-xs text-text-tertiary"> Confirmed - {count} - {' '} - times + {count} times </span> <AlertDialog open={open} onOpenChange={setOpen}> <AlertDialogContent> @@ -121,14 +121,15 @@ const ControlledDemo = () => { Revoke API token? </AlertDialogTitle> <AlertDialogDescription className="text-sm leading-5 text-text-secondary"> - Any integration using this token will immediately stop working. You can issue a new token afterwards. + Any integration using this token will immediately stop working. You can issue a new + token afterwards. </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton variant="secondary">Keep token</AlertDialogCancelButton> <AlertDialogConfirmButton onClick={() => { - setCount(prev => prev + 1) + setCount((prev) => prev + 1) setOpen(false) }} > @@ -159,9 +160,7 @@ const LoadingConfirmDemo = () => { return ( <AlertDialog open={open} onOpenChange={setOpen}> - <AlertDialogTrigger - render={<button type="button" className={triggerButtonClassName} />} - > + <AlertDialogTrigger render={<button type="button" className={triggerButtonClassName} />}> Archive workspace </AlertDialogTrigger> <AlertDialogContent> @@ -170,18 +169,15 @@ const LoadingConfirmDemo = () => { Archive this workspace? </AlertDialogTitle> <AlertDialogDescription className="text-sm leading-5 text-text-secondary"> - Members will lose access until the workspace is restored. This may take a moment to finalize. + Members will lose access until the workspace is restored. This may take a moment to + finalize. </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton variant="secondary" disabled={pending}> Cancel </AlertDialogCancelButton> - <AlertDialogConfirmButton - tone="default" - loading={pending} - onClick={handleConfirm} - > + <AlertDialogConfirmButton tone="default" loading={pending} onClick={handleConfirm}> {pending ? 'Archiving…' : 'Archive'} </AlertDialogConfirmButton> </AlertDialogActions> diff --git a/packages/dify-ui/src/alert-dialog/index.tsx b/packages/dify-ui/src/alert-dialog/index.tsx index f774f26565237c..be9af138a499d5 100644 --- a/packages/dify-ui/src/alert-dialog/index.tsx +++ b/packages/dify-ui/src/alert-dialog/index.tsx @@ -69,10 +69,7 @@ export function AlertDialogCancelButton({ ...buttonProps }: AlertDialogCancelButtonProps) { return ( - <BaseAlertDialog.Close - {...closeProps} - render={<Button {...buttonProps} />} - > + <BaseAlertDialog.Close {...closeProps} render={<Button {...buttonProps} />}> {children} </BaseAlertDialog.Close> ) @@ -85,11 +82,5 @@ export function AlertDialogConfirmButton({ tone = 'destructive', ...props }: AlertDialogConfirmButtonProps) { - return ( - <Button - variant={variant} - tone={tone} - {...props} - /> - ) + return <Button variant={variant} tone={tone} {...props} /> } diff --git a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx index ad99a97e9e92f6..d84c0c2a23f9e4 100644 --- a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx @@ -18,11 +18,8 @@ import { AutocompleteTrigger, } from '../index' -const renderWithSafeViewport = (ui: React.ReactNode) => render( - <div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}> - {ui} - </div>, -) +const renderWithSafeViewport = (ui: React.ReactNode) => + render(<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>{ui}</div>) const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement @@ -34,41 +31,42 @@ const renderAutocomplete = ({ children?: React.ReactNode open?: boolean defaultValue?: string -} = {}) => renderWithSafeViewport( - <Autocomplete open={open} defaultValue={defaultValue} items={['workflow', 'dataset']}> - {children ?? ( - <React.Fragment> - <AutocompleteInputGroup data-testid="input-group"> - <AutocompleteInput aria-label="Search suggestions" data-testid="input" /> - <AutocompleteClear data-testid="clear" /> - <AutocompleteTrigger data-testid="trigger" /> - </AutocompleteInputGroup> - <AutocompleteContent - positionerProps={{ - 'role': 'group', - 'aria-label': 'autocomplete positioner', - }} - popupProps={{ - 'role': 'dialog', - 'aria-label': 'autocomplete popup', - }} - > - <AutocompleteStatus data-testid="status">2 suggestions</AutocompleteStatus> - <AutocompleteList role="listbox" aria-label="autocomplete list" data-testid="list"> - <AutocompleteItem value="workflow"> - <AutocompleteItemText>Workflow</AutocompleteItemText> - <AutocompleteItemIndicator /> - </AutocompleteItem> - <AutocompleteItem value="dataset"> - <AutocompleteItemText>Dataset</AutocompleteItemText> - </AutocompleteItem> - </AutocompleteList> - <AutocompleteEmpty data-testid="empty">No suggestions</AutocompleteEmpty> - </AutocompleteContent> - </React.Fragment> - )} - </Autocomplete>, -) +} = {}) => + renderWithSafeViewport( + <Autocomplete open={open} defaultValue={defaultValue} items={['workflow', 'dataset']}> + {children ?? ( + <React.Fragment> + <AutocompleteInputGroup data-testid="input-group"> + <AutocompleteInput aria-label="Search suggestions" data-testid="input" /> + <AutocompleteClear data-testid="clear" /> + <AutocompleteTrigger data-testid="trigger" /> + </AutocompleteInputGroup> + <AutocompleteContent + positionerProps={{ + role: 'group', + 'aria-label': 'autocomplete positioner', + }} + popupProps={{ + role: 'dialog', + 'aria-label': 'autocomplete popup', + }} + > + <AutocompleteStatus data-testid="status">2 suggestions</AutocompleteStatus> + <AutocompleteList role="listbox" aria-label="autocomplete list" data-testid="list"> + <AutocompleteItem value="workflow"> + <AutocompleteItemText>Workflow</AutocompleteItemText> + <AutocompleteItemIndicator /> + </AutocompleteItem> + <AutocompleteItem value="dataset"> + <AutocompleteItemText>Dataset</AutocompleteItemText> + </AutocompleteItem> + </AutocompleteList> + <AutocompleteEmpty data-testid="empty">No suggestions</AutocompleteEmpty> + </AutocompleteContent> + </React.Fragment> + )} + </Autocomplete>, + ) describe('Autocomplete wrappers', () => { describe('Input group and input', () => { @@ -86,11 +84,21 @@ describe('Autocomplete wrappers', () => { ), }) - await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveAttribute('autocomplete', 'off') - await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveAttribute('type', 'text') - await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveAttribute('placeholder', 'Find a resource') - await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toBeRequired() - await expect.element(screen.getByRole('combobox', { name: 'Search suggestions' })).toHaveClass('custom-input') + await expect + .element(screen.getByRole('combobox', { name: 'Search suggestions' })) + .toHaveAttribute('autocomplete', 'off') + await expect + .element(screen.getByRole('combobox', { name: 'Search suggestions' })) + .toHaveAttribute('type', 'text') + await expect + .element(screen.getByRole('combobox', { name: 'Search suggestions' })) + .toHaveAttribute('placeholder', 'Find a resource') + await expect + .element(screen.getByRole('combobox', { name: 'Search suggestions' })) + .toBeRequired() + await expect + .element(screen.getByRole('combobox', { name: 'Search suggestions' })) + .toHaveClass('custom-input') }) }) @@ -98,8 +106,12 @@ describe('Autocomplete wrappers', () => { it('should provide fallback aria labels and decorative icons when labels are omitted', async () => { const screen = await renderAutocomplete() - await expect.element(screen.getByRole('button', { name: 'Clear autocomplete' })).toHaveAttribute('type', 'button') - await expect.element(screen.getByRole('button', { name: 'Open autocomplete suggestions' })).toHaveAttribute('type', 'button') + await expect + .element(screen.getByRole('button', { name: 'Clear autocomplete' })) + .toHaveAttribute('type', 'button') + await expect + .element(screen.getByRole('button', { name: 'Open autocomplete suggestions' })) + .toHaveAttribute('type', 'button') }) it('should preserve explicit labels and custom children', async () => { @@ -117,8 +129,12 @@ describe('Autocomplete wrappers', () => { ), }) - expect(screen.getByRole('button', { name: 'Reset search' }).element()).toContainElement(screen.getByTestId('custom-clear').element()) - expect(screen.getByRole('button', { name: 'Show suggestions' }).element()).toContainElement(screen.getByTestId('custom-trigger').element()) + expect(screen.getByRole('button', { name: 'Reset search' }).element()).toContainElement( + screen.getByTestId('custom-clear').element(), + ) + expect(screen.getByRole('button', { name: 'Show suggestions' }).element()).toContainElement( + screen.getByTestId('custom-trigger').element(), + ) }) it('should rely on aria-labelledby when provided instead of injecting fallback labels', async () => { @@ -136,8 +152,12 @@ describe('Autocomplete wrappers', () => { ), }) - await expect.element(screen.getByRole('button', { name: 'Clear from label' })).not.toHaveAttribute('aria-label') - await expect.element(screen.getByRole('button', { name: 'Trigger from label' })).not.toHaveAttribute('aria-label') + await expect + .element(screen.getByRole('button', { name: 'Clear from label' })) + .not.toHaveAttribute('aria-label') + await expect + .element(screen.getByRole('button', { name: 'Trigger from label' })) + .not.toHaveAttribute('aria-label') }) }) @@ -145,8 +165,12 @@ describe('Autocomplete wrappers', () => { it('should use default overlay placement', async () => { const screen = await renderAutocomplete({ open: true }) - await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-side', 'bottom') - await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-align', 'start') + await expect + .element(screen.getByRole('group', { name: 'autocomplete positioner' })) + .toHaveAttribute('data-side', 'bottom') + await expect + .element(screen.getByRole('group', { name: 'autocomplete positioner' })) + .toHaveAttribute('data-align', 'start') }) it('should apply custom placement side and passthrough popup props', async () => { @@ -160,11 +184,11 @@ describe('Autocomplete wrappers', () => { placement="top-end" sideOffset={12} alignOffset={6} - positionerProps={{ 'role': 'group', 'aria-label': 'autocomplete positioner' }} + positionerProps={{ role: 'group', 'aria-label': 'autocomplete positioner' }} popupProps={{ - 'role': 'dialog', + role: 'dialog', 'aria-label': 'autocomplete popup', - 'onClick': onPopupClick, + onClick: onPopupClick, }} > <AutocompleteList role="listbox" aria-label="autocomplete list"> @@ -178,7 +202,9 @@ describe('Autocomplete wrappers', () => { asHTMLElement(screen.getByRole('dialog', { name: 'autocomplete popup' }).element()).click() - await expect.element(screen.getByRole('group', { name: 'autocomplete positioner' })).toHaveAttribute('data-side', 'top') + await expect + .element(screen.getByRole('group', { name: 'autocomplete positioner' })) + .toHaveAttribute('data-side', 'top') expect(onPopupClick).toHaveBeenCalledTimes(1) }) @@ -188,7 +214,7 @@ describe('Autocomplete wrappers', () => { <AutocompleteInputGroup> <AutocompleteInput aria-label="Search suggestions" /> </AutocompleteInputGroup> - <AutocompleteContent popupProps={{ 'role': 'dialog', 'aria-label': 'autocomplete popup' }}> + <AutocompleteContent popupProps={{ role: 'dialog', 'aria-label': 'autocomplete popup' }}> <AutocompleteList role="listbox" aria-label="autocomplete list"> <AutocompleteGroup items={['workflow']}> <AutocompleteGroupLabel className="custom-label">Resources</AutocompleteGroupLabel> @@ -205,7 +231,9 @@ describe('Autocomplete wrappers', () => { await expect.element(screen.getByText('Resources')).toHaveClass('custom-label') await expect.element(screen.getByTestId('separator')).toHaveClass('custom-separator') - await expect.element(screen.getByRole('option', { name: 'Workflow' })).toHaveClass('custom-item') + await expect + .element(screen.getByRole('option', { name: 'Workflow' })) + .toHaveClass('custom-item') await expect.element(screen.getByText('Workflow')).toHaveClass('custom-text') await expect.element(screen.getByTestId('indicator')).toHaveClass('custom-indicator') }) @@ -228,15 +256,25 @@ describe('Autocomplete wrappers', () => { </Autocomplete>, ) - const input = asHTMLElement(screen.getByRole('combobox', { name: 'Search resources' }).element()) + const input = asHTMLElement( + screen.getByRole('combobox', { name: 'Search resources' }).element(), + ) input.focus() - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })) - await expect.element(screen.getByRole('option', { name: 'workflow' })).toHaveAttribute('data-highlighted') + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }), + ) + await expect + .element(screen.getByRole('option', { name: 'workflow' })) + .toHaveAttribute('data-highlighted') - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })) + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }), + ) - await expect.element(screen.getByRole('option', { name: 'dataset' })).toHaveAttribute('data-highlighted') + await expect + .element(screen.getByRole('option', { name: 'dataset' })) + .toHaveAttribute('data-highlighted') }) }) }) diff --git a/packages/dify-ui/src/autocomplete/index.stories.tsx b/packages/dify-ui/src/autocomplete/index.stories.tsx index 953b90de0af713..80b96d710d6e4f 100644 --- a/packages/dify-ui/src/autocomplete/index.stories.tsx +++ b/packages/dify-ui/src/autocomplete/index.stories.tsx @@ -52,8 +52,7 @@ const scrollHighlightedVirtualItem = ( }, virtualizer: StoryVirtualizer | null, ) => { - if (!item || !virtualizer) - return + if (!item || !virtualizer) return const isStart = index === 0 const isEnd = index === virtualizer.options.count - 1 @@ -72,28 +71,103 @@ const tagSuggestions: Suggestion[] = [ { value: 'docs', label: 'docs', description: 'Documentation updates' }, { value: 'internal', label: 'internal', description: 'Workspace-only notes' }, { value: 'mobile', label: 'mobile', description: 'Mobile app issues' }, - { value: 'component: autocomplete', label: 'component: autocomplete', description: 'Base UI primitive wrapper' }, - { value: 'component: combobox', label: 'component: combobox', description: 'Filterable predefined selection' }, - { value: 'component: select', label: 'component: select', description: 'Compact predefined selection' }, + { + value: 'component: autocomplete', + label: 'component: autocomplete', + description: 'Base UI primitive wrapper', + }, + { + value: 'component: combobox', + label: 'component: combobox', + description: 'Filterable predefined selection', + }, + { + value: 'component: select', + label: 'component: select', + description: 'Compact predefined selection', + }, ] const promptCompletions: Suggestion[] = [ { value: 'summarize this conversation', label: 'summarize this conversation' }, - { value: 'summarize this dataset with citations', label: 'summarize this dataset with citations' }, - { value: 'summarize this workflow run for an operator', label: 'summarize this workflow run for an operator' }, - { value: 'summarize this support ticket in 3 bullets', label: 'summarize this support ticket in 3 bullets' }, + { + value: 'summarize this dataset with citations', + label: 'summarize this dataset with citations', + }, + { + value: 'summarize this workflow run for an operator', + label: 'summarize this workflow run for an operator', + }, + { + value: 'summarize this support ticket in 3 bullets', + label: 'summarize this support ticket in 3 bullets', + }, ] const workflowSuggestions: Suggestion[] = [ - { value: 'http-request', label: 'HTTP Request', description: 'Call an external API', icon: 'i-ri-global-line', meta: 'Tool' }, - { value: 'knowledge-retrieval', label: 'Knowledge Retrieval', description: 'Search configured datasets', icon: 'i-ri-database-2-line', meta: 'Tool' }, - { value: 'code-execution', label: 'Code Execution', description: 'Run sandboxed snippets', icon: 'i-ri-code-s-slash-line', meta: 'Tool' }, - { value: 'template-transform', label: 'Template Transform', description: 'Compose variables into output', icon: 'i-ri-braces-line', meta: 'Tool' }, - { value: 'question-classifier', label: 'Question Classifier', description: 'Route by intent', icon: 'i-ri-git-branch-line', meta: 'Tool' }, - { value: 'parameter-extractor', label: 'Parameter Extractor', description: 'Extract typed values', icon: 'i-ri-list-check-3', meta: 'Tool' }, - { value: 'answer-node', label: 'Answer Node', description: 'Return a final assistant answer', icon: 'i-ri-message-3-line', meta: 'Node' }, - { value: 'iteration-node', label: 'Iteration Node', description: 'Run a loop over array items', icon: 'i-ri-repeat-line', meta: 'Node' }, - { value: 'variable-assigner', label: 'Variable Assigner', description: 'Persist intermediate state', icon: 'i-ri-pencil-ruler-2-line', meta: 'Node' }, + { + value: 'http-request', + label: 'HTTP Request', + description: 'Call an external API', + icon: 'i-ri-global-line', + meta: 'Tool', + }, + { + value: 'knowledge-retrieval', + label: 'Knowledge Retrieval', + description: 'Search configured datasets', + icon: 'i-ri-database-2-line', + meta: 'Tool', + }, + { + value: 'code-execution', + label: 'Code Execution', + description: 'Run sandboxed snippets', + icon: 'i-ri-code-s-slash-line', + meta: 'Tool', + }, + { + value: 'template-transform', + label: 'Template Transform', + description: 'Compose variables into output', + icon: 'i-ri-braces-line', + meta: 'Tool', + }, + { + value: 'question-classifier', + label: 'Question Classifier', + description: 'Route by intent', + icon: 'i-ri-git-branch-line', + meta: 'Tool', + }, + { + value: 'parameter-extractor', + label: 'Parameter Extractor', + description: 'Extract typed values', + icon: 'i-ri-list-check-3', + meta: 'Tool', + }, + { + value: 'answer-node', + label: 'Answer Node', + description: 'Return a final assistant answer', + icon: 'i-ri-message-3-line', + meta: 'Node', + }, + { + value: 'iteration-node', + label: 'Iteration Node', + description: 'Run a loop over array items', + icon: 'i-ri-repeat-line', + meta: 'Node', + }, + { + value: 'variable-assigner', + label: 'Variable Assigner', + description: 'Persist intermediate state', + icon: 'i-ri-pencil-ruler-2-line', + meta: 'Node', + }, ] const groupedSuggestions: SuggestionGroup[] = [ @@ -115,17 +189,47 @@ const commandGroups: SuggestionGroup[] = [ { label: 'App', items: [ - { value: '/run', label: 'Run workflow', description: 'Execute the current draft', icon: 'i-ri-play-circle-line' }, - { value: '/publish', label: 'Publish app', description: 'Ship the current configuration', icon: 'i-ri-upload-cloud-2-line' }, - { value: '/trace', label: 'Open trace', description: 'Inspect the latest workflow run', icon: 'i-ri-route-line' }, + { + value: '/run', + label: 'Run workflow', + description: 'Execute the current draft', + icon: 'i-ri-play-circle-line', + }, + { + value: '/publish', + label: 'Publish app', + description: 'Ship the current configuration', + icon: 'i-ri-upload-cloud-2-line', + }, + { + value: '/trace', + label: 'Open trace', + description: 'Inspect the latest workflow run', + icon: 'i-ri-route-line', + }, ], }, { label: 'Workspace', items: [ - { value: '/dataset', label: 'Search datasets', description: 'Find knowledge attached to this app', icon: 'i-ri-database-line' }, - { value: '/members', label: 'Invite members', description: 'Open workspace access settings', icon: 'i-ri-user-add-line' }, - { value: '/usage', label: 'View usage', description: 'Open model and workflow usage', icon: 'i-ri-bar-chart-line' }, + { + value: '/dataset', + label: 'Search datasets', + description: 'Find knowledge attached to this app', + icon: 'i-ri-database-line', + }, + { + value: '/members', + label: 'Invite members', + description: 'Open workspace access settings', + icon: 'i-ri-user-add-line', + }, + { + value: '/usage', + label: 'View usage', + description: 'Open model and workflow usage', + icon: 'i-ri-bar-chart-line', + }, ], }, ] @@ -133,7 +237,11 @@ const commandGroups: SuggestionGroup[] = [ const remoteSuggestions: Suggestion[] = [ { value: 'agent-builder', label: 'Agent Builder', description: 'Workspace app' }, { value: 'agent-observability', label: 'Agent Observability', description: 'Dataset' }, - { value: 'agent-routing-dataset', label: 'Agent Routing Dataset', description: 'Knowledge source' }, + { + value: 'agent-routing-dataset', + label: 'Agent Routing Dataset', + description: 'Knowledge source', + }, ] const virtualizedSuggestions: Suggestion[] = Array.from({ length: 1000 }, (_, index) => { @@ -146,13 +254,14 @@ const virtualizedSuggestions: Suggestion[] = Array.from({ length: 1000 }, (_, in value: `${family}-${index + 1}`, label: `${family} suggestion ${number}`, description: `Free-form autocomplete result from ${family} search`, - icon: family === 'dataset' - ? 'i-ri-database-2-line' - : family === 'prompt' - ? 'i-ri-text-snippet' - : family === 'tool' - ? 'i-ri-tools-line' - : 'i-ri-flow-chart', + icon: + family === 'dataset' + ? 'i-ri-database-2-line' + : family === 'prompt' + ? 'i-ri-text-snippet' + : family === 'tool' + ? 'i-ri-tools-line' + : 'i-ri-flow-chart', meta: family, } }) @@ -163,8 +272,8 @@ async function searchSuggestions( suggestions: Suggestion[], query: string, filter: (item: string, query: string) => boolean, -): Promise<{ items: Suggestion[], error: string | null }> { - await new Promise(resolve => window.setTimeout(resolve, 500)) +): Promise<{ items: Suggestion[]; error: string | null }> { + await new Promise((resolve) => window.setTimeout(resolve, 500)) if (query === 'will_error') { return { @@ -174,23 +283,19 @@ async function searchSuggestions( } return { - items: suggestions.filter(item => ( - filter(item.label, query) - || (item.description ? filter(item.description, query) : false) - )), + items: suggestions.filter( + (item) => + filter(item.label, query) || (item.description ? filter(item.description, query) : false), + ), error: null, } } -const SuggestionItem = ({ - item, - dense, -}: { - item: Suggestion - dense?: boolean -}) => ( +const SuggestionItem = ({ item, dense }: { item: Suggestion; dense?: boolean }) => ( <AutocompleteItem value={item}> - {item.icon && <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />} + {item.icon && ( + <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" /> + )} <div className="flex min-w-0 grow flex-col"> <AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText> {!dense && item.description && ( @@ -216,7 +321,9 @@ const VirtualizedSuggestionItem = ({ dense?: boolean }) => ( <AutocompleteItem value={item} index={index}> - {item.icon && <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />} + {item.icon && ( + <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" /> + )} <div className="flex min-w-0 grow flex-col"> <AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText> {!dense && item.description && ( @@ -231,22 +338,18 @@ const VirtualizedSuggestionItem = ({ </AutocompleteItem> ) -const TagSuggestionItem = ({ - item, -}: { - item: Suggestion -}) => ( +const TagSuggestionItem = ({ item }: { item: Suggestion }) => ( <AutocompleteItem value={item}> <AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText> - {item.description && <span className="ml-auto max-w-36 truncate system-xs-regular text-text-tertiary">{item.description}</span>} + {item.description && ( + <span className="ml-auto max-w-36 truncate system-xs-regular text-text-tertiary"> + {item.description} + </span> + )} </AutocompleteItem> ) -const BasicTagAutocomplete = ({ - size = 'medium', -}: { - size?: 'small' | 'medium' | 'large' -}) => ( +const BasicTagAutocomplete = ({ size = 'medium' }: { size?: 'small' | 'medium' | 'large' }) => ( <Autocomplete items={tagSuggestions} itemToStringValue={getSuggestionLabel} @@ -254,16 +357,21 @@ const BasicTagAutocomplete = ({ openOnInputClick > <AutocompleteInputGroup size={size}> - <span className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> - <AutocompleteInput size={size} placeholder="Search tags or type a new one…" aria-label="Search tags or type a new one" /> + <span + className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> + <AutocompleteInput + size={size} + placeholder="Search tags or type a new one…" + aria-label="Search tags or type a new one" + /> <AutocompleteClear size={size} /> <AutocompleteTrigger size={size} /> </AutocompleteInputGroup> <AutocompleteContent> <AutocompleteList> - {(item: Suggestion) => ( - <TagSuggestionItem key={item.value} item={item} /> - )} + {(item: Suggestion) => <TagSuggestionItem key={item.value} item={item} />} </AutocompleteList> <AutocompleteEmpty>No tag suggestion. Keep the typed value.</AutocompleteEmpty> </AutocompleteContent> @@ -280,9 +388,7 @@ const GroupedSuggestionList = () => { {groupIndex > 0 && <AutocompleteSeparator />} <AutocompleteGroupLabel>{group.label}</AutocompleteGroupLabel> <AutocompleteCollection> - {(item: Suggestion) => ( - <SuggestionItem key={item.value} item={item} /> - )} + {(item: Suggestion) => <SuggestionItem key={item.value} item={item} />} </AutocompleteCollection> </AutocompleteGroup> ))} @@ -297,22 +403,29 @@ const CommandPaletteList = () => { <AutocompleteList className="max-h-72 rounded-lg border border-divider-subtle bg-components-panel-bg p-1 shadow-xs"> {groups.map((group, groupIndex) => ( <AutocompleteGroup key={group.label} items={group.items}> - <AutocompleteGroupLabel className={groupIndex > 0 ? 'mt-1 border-t border-divider-subtle pt-2' : undefined}> + <AutocompleteGroupLabel + className={groupIndex > 0 ? 'mt-1 border-t border-divider-subtle pt-2' : undefined} + > {group.label} </AutocompleteGroupLabel> <AutocompleteCollection> {(item: Suggestion) => ( <AutocompleteItem key={item.value} value={item} className="grid grid-cols-[1fr_auto]"> <span className="flex min-w-0 items-center gap-2"> - {item.icon && <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />} + {item.icon && ( + <span + className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} + aria-hidden="true" + /> + )} <span className="min-w-0"> <AutocompleteItemText className="block px-0">{item.label}</AutocompleteItemText> - <span className="block truncate system-xs-regular text-text-tertiary">{item.description}</span> + <span className="block truncate system-xs-regular text-text-tertiary"> + {item.description} + </span> </span> </span> - <Kbd className="text-text-quaternary"> - Enter - </Kbd> + <Kbd className="text-text-quaternary">Enter</Kbd> </AutocompleteItem> )} </AutocompleteCollection> @@ -322,11 +435,7 @@ const CommandPaletteList = () => { ) } -const LimitedStatus = ({ - total, -}: { - total: number -}) => { +const LimitedStatus = ({ total }: { total: number }) => { const items = useAutocompleteFilteredItems<Suggestion>() const hidden = Math.max(0, total - items.length) @@ -344,17 +453,13 @@ const AsyncSearchDemo = () => { const abortControllerRef = React.useRef<AbortController | null>(null) const status = (() => { - if (isPending) - return 'Searching remote suggestions…' + if (isPending) return 'Searching remote suggestions…' - if (error) - return error + if (error) return error - if (searchValue === '') - return null + if (searchValue === '') return null - if (searchResults.length === 0) - return `No remote suggestion matches "${searchValue}".` + if (searchResults.length === 0) return `No remote suggestion matches "${searchValue}".` return `${searchResults.length} remote suggestion${searchResults.length === 1 ? '' : 's'} found` })() @@ -382,8 +487,7 @@ const AsyncSearchDemo = () => { const result = await searchSuggestions(remoteSuggestions, nextSearchValue, contains) - if (controller.signal.aborted) - return + if (controller.signal.aborted) return startTransition(() => { setSearchResults(result.items) @@ -396,19 +500,24 @@ const AsyncSearchDemo = () => { mode="list" > <AutocompleteInputGroup> - <span className="ml-2 i-ri-cloud-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> - <AutocompleteInput placeholder="Search remote resources…" aria-label="Search remote resources" /> + <span + className="ml-2 i-ri-cloud-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> + <AutocompleteInput + placeholder="Search remote resources…" + aria-label="Search remote resources" + /> <AutocompleteClear /> <AutocompleteTrigger /> </AutocompleteInputGroup> - <AutocompleteContent portalProps={{ hidden: !status }} popupProps={{ 'aria-busy': isPending || undefined }}> - <AutocompleteStatus> - {status} - </AutocompleteStatus> + <AutocompleteContent + portalProps={{ hidden: !status }} + popupProps={{ 'aria-busy': isPending || undefined }} + > + <AutocompleteStatus>{status}</AutocompleteStatus> <AutocompleteList> - {(item: Suggestion) => ( - <SuggestionItem key={item.value} item={item} /> - )} + {(item: Suggestion) => <SuggestionItem key={item.value} item={item} />} </AutocompleteList> </AutocompleteContent> </Autocomplete> @@ -450,8 +559,7 @@ const VirtualizedSuggestionList = ({ {virtualizer.getVirtualItems().map((virtualItem) => { const item = filteredItems[virtualItem.index] - if (!item) - return null + if (!item) return null return ( <div @@ -476,25 +584,16 @@ const VirtualizedStatus = () => { return ( <AutocompleteStatus className="border-b border-divider-subtle text-text-quaternary tabular-nums"> - {filteredItems.length} - {' '} - matching suggestions. Selecting one only replaces the input text. + {filteredItems.length} matching suggestions. Selecting one only replaces the input text. </AutocompleteStatus> ) } -const FuzzyHighlight = ({ - text, - query, -}: { - text: string - query: string -}) => { +const FuzzyHighlight = ({ text, query }: { text: string; query: string }) => { const parts = React.useMemo(() => { const trimmed = query.trim() - if (!trimmed) - return [text] + if (!trimmed) return [text] const escaped = trimmed.slice(0, 80).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return text.split(new RegExp(`(${escaped})`, 'i')) @@ -502,12 +601,16 @@ const FuzzyHighlight = ({ return ( <React.Fragment> - {parts.map((part, index) => ( - part.toLowerCase() === query.trim().toLowerCase() + {parts.map((part, index) => + part.toLowerCase() === query.trim().toLowerCase() ? ( // eslint-disable-next-line react/no-array-index-key -- Repeated text fragments have no stable identity and never preserve state. - ? <mark key={`${part}-${index}`} className="bg-transparent text-text-accent">{part}</mark> - : part - ))} + <mark key={`${part}-${index}`} className="bg-transparent text-text-accent"> + {part} + </mark> + ) : ( + part + ), + )} </React.Fragment> ) } @@ -528,8 +631,14 @@ const FuzzyMatchingDemo = () => { openOnInputClick > <AutocompleteInputGroup> - <span className="ml-2 i-ri-sparkling-2-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> - <AutocompleteInput placeholder="Fuzzy search workflow suggestions…" aria-label="Fuzzy search workflow suggestions" /> + <span + className="ml-2 i-ri-sparkling-2-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> + <AutocompleteInput + placeholder="Fuzzy search workflow suggestions…" + aria-label="Fuzzy search workflow suggestions" + /> <AutocompleteClear /> <AutocompleteTrigger /> </AutocompleteInputGroup> @@ -537,12 +646,19 @@ const FuzzyMatchingDemo = () => { <AutocompleteList> {(item: Suggestion) => ( <AutocompleteItem key={item.value} value={item}> - {item.icon && <span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />} + {item.icon && ( + <span + className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} + aria-hidden="true" + /> + )} <div className="min-w-0 grow"> <AutocompleteItemText className="block px-0"> <FuzzyHighlight text={item.label} query={value} /> </AutocompleteItemText> - <span className="block truncate system-xs-regular text-text-tertiary">{item.description}</span> + <span className="block truncate system-xs-regular text-text-tertiary"> + {item.description} + </span> </div> </AutocompleteItem> )} @@ -561,7 +677,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound autocomplete built on Base UI Autocomplete. Use it for free-form inputs where suggestions can replace or complete the typed text, but selection is not persistent state.', + component: + 'Compound autocomplete built on Base UI Autocomplete. Use it for free-form inputs where suggestions can replace or complete the typed text, but selection is not persistent state.', }, }, }, @@ -582,7 +699,7 @@ export const SearchTags: Story = { export const Sizes: Story = { render: () => ( <div className="flex flex-col gap-3"> - {(['small', 'medium', 'large'] as const).map(size => ( + {(['small', 'medium', 'large'] as const).map((size) => ( <div key={size} className={inputWidth}> <BasicTagAutocomplete size={size} /> </div> @@ -601,16 +718,20 @@ export const InlineAutocomplete: Story = { openOnInputClick > <AutocompleteInputGroup> - <span className="ml-2 i-ri-text-snippet size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> - <AutocompleteInput placeholder="Type a prompt starter…" aria-label="Type a prompt starter" /> + <span + className="ml-2 i-ri-text-snippet size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> + <AutocompleteInput + placeholder="Type a prompt starter…" + aria-label="Type a prompt starter" + /> <AutocompleteClear /> <AutocompleteTrigger /> </AutocompleteInputGroup> <AutocompleteContent> <AutocompleteList> - {(item: Suggestion) => ( - <SuggestionItem key={item.value} item={item} dense /> - )} + {(item: Suggestion) => <SuggestionItem key={item.value} item={item} dense />} </AutocompleteList> <AutocompleteEmpty>No inline completion. Continue typing freely.</AutocompleteEmpty> </AutocompleteContent> @@ -629,8 +750,14 @@ export const GroupedSuggestions: Story = { openOnInputClick > <AutocompleteInputGroup> - <span className="ml-2 i-ri-command-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> - <AutocompleteInput placeholder="Search tags, nodes, or prompt starters…" aria-label="Search tags, nodes, or prompt starters" /> + <span + className="ml-2 i-ri-command-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> + <AutocompleteInput + placeholder="Search tags, nodes, or prompt starters…" + aria-label="Search tags, nodes, or prompt starters" + /> <AutocompleteClear /> <AutocompleteTrigger /> </AutocompleteInputGroup> @@ -658,8 +785,14 @@ export const LimitResults: Story = { openOnInputClick > <AutocompleteInputGroup> - <span className="ml-2 i-ri-tools-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> - <AutocompleteInput placeholder="Search workflow suggestions…" aria-label="Search workflow suggestions" /> + <span + className="ml-2 i-ri-tools-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> + <AutocompleteInput + placeholder="Search workflow suggestions…" + aria-label="Search workflow suggestions" + /> <AutocompleteClear /> <AutocompleteTrigger /> </AutocompleteInputGroup> @@ -668,9 +801,7 @@ export const LimitResults: Story = { <LimitedStatus total={workflowSuggestions.length} /> </AutocompleteStatus> <AutocompleteList> - {(item: Suggestion) => ( - <SuggestionItem key={item.value} item={item} /> - )} + {(item: Suggestion) => <SuggestionItem key={item.value} item={item} />} </AutocompleteList> <AutocompleteEmpty>No suggestion. Submit the typed text instead.</AutocompleteEmpty> </AutocompleteContent> @@ -692,8 +823,15 @@ export const CommandPalette: Story = { keepHighlight > <AutocompleteInputGroup className="mb-2"> - <span className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> - <AutocompleteInput placeholder="Run a command…" aria-label="Run a command" aria-expanded="true" /> + <span + className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> + <AutocompleteInput + placeholder="Run a command…" + aria-label="Run a command" + aria-expanded="true" + /> <AutocompleteClear /> </AutocompleteInputGroup> <CommandPaletteList /> @@ -718,8 +856,14 @@ const VirtualizedLongSuggestionsDemo = () => { }} > <AutocompleteInputGroup> - <span className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> - <AutocompleteInput placeholder="Search 1,000 workspace suggestions…" aria-label="Search 1,000 workspace suggestions" /> + <span + className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> + <AutocompleteInput + placeholder="Search 1,000 workspace suggestions…" + aria-label="Search 1,000 workspace suggestions" + /> <AutocompleteClear /> <AutocompleteTrigger /> </AutocompleteInputGroup> @@ -752,16 +896,20 @@ export const Empty: Story = { openOnInputClick > <AutocompleteInputGroup> - <span className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> - <AutocompleteInput placeholder="Search tags or type a new one…" aria-label="Search tags or type a new one" /> + <span + className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> + <AutocompleteInput + placeholder="Search tags or type a new one…" + aria-label="Search tags or type a new one" + /> <AutocompleteClear /> <AutocompleteTrigger /> </AutocompleteInputGroup> <AutocompleteContent> <AutocompleteList> - {(item: Suggestion) => ( - <TagSuggestionItem key={item.value} item={item} /> - )} + {(item: Suggestion) => <TagSuggestionItem key={item.value} item={item} />} </AutocompleteList> <AutocompleteEmpty>No tag suggestion. The custom text remains valid.</AutocompleteEmpty> </AutocompleteContent> @@ -773,7 +921,13 @@ export const Empty: Story = { export const DisabledAndReadOnly: Story = { render: () => ( <div className="flex w-80 flex-col gap-3"> - <Autocomplete items={tagSuggestions} itemToStringValue={getSuggestionLabel} defaultValue="feature" mode="list" disabled> + <Autocomplete + items={tagSuggestions} + itemToStringValue={getSuggestionLabel} + defaultValue="feature" + mode="list" + disabled + > <AutocompleteInputGroup> <AutocompleteInput aria-label="Disabled tag autocomplete" /> <AutocompleteClear /> @@ -781,13 +935,17 @@ export const DisabledAndReadOnly: Story = { </AutocompleteInputGroup> <AutocompleteContent> <AutocompleteList> - {(item: Suggestion) => ( - <TagSuggestionItem key={item.value} item={item} /> - )} + {(item: Suggestion) => <TagSuggestionItem key={item.value} item={item} />} </AutocompleteList> </AutocompleteContent> </Autocomplete> - <Autocomplete items={promptCompletions} itemToStringValue={getSuggestionLabel} defaultValue="summarize this conversation" mode="both" readOnly> + <Autocomplete + items={promptCompletions} + itemToStringValue={getSuggestionLabel} + defaultValue="summarize this conversation" + mode="both" + readOnly + > <AutocompleteInputGroup> <AutocompleteInput aria-label="Read-only prompt autocomplete" /> <AutocompleteClear /> @@ -795,9 +953,7 @@ export const DisabledAndReadOnly: Story = { </AutocompleteInputGroup> <AutocompleteContent> <AutocompleteList> - {(item: Suggestion) => ( - <SuggestionItem key={item.value} item={item} /> - )} + {(item: Suggestion) => <SuggestionItem key={item.value} item={item} />} </AutocompleteList> </AutocompleteContent> </Autocomplete> diff --git a/packages/dify-ui/src/autocomplete/index.tsx b/packages/dify-ui/src/autocomplete/index.tsx index 360347ec0ba440..e8badea173824a 100644 --- a/packages/dify-ui/src/autocomplete/index.tsx +++ b/packages/dify-ui/src/autocomplete/index.tsx @@ -18,26 +18,21 @@ import { parsePlacement } from '../placement' export type { Placement } export type AutocompleteProps<ItemValue> = BaseAutocomplete.Root.Props<ItemValue> -export type AutocompleteGroupedProps< - Items extends readonly { items: readonly unknown[] }[], -> = Omit<AutocompleteProps<Items[number]['items'][number]>, 'items'> & { +export type AutocompleteGroupedProps<Items extends readonly { items: readonly unknown[] }[]> = Omit< + AutocompleteProps<Items[number]['items'][number]>, + 'items' +> & { items: Items } -export type AutocompleteFlatProps<ItemValue> - = Omit<AutocompleteProps<ItemValue>, 'items'> - & { - items?: readonly ItemValue[] - } +export type AutocompleteFlatProps<ItemValue> = Omit<AutocompleteProps<ItemValue>, 'items'> & { + items?: readonly ItemValue[] +} export function Autocomplete<Items extends readonly { items: readonly unknown[] }[]>( props: AutocompleteGroupedProps<Items>, ): React.JSX.Element -export function Autocomplete<ItemValue>( - props: AutocompleteFlatProps<ItemValue>, -): React.JSX.Element -export function Autocomplete( - props: AutocompleteProps<unknown>, -): React.JSX.Element { +export function Autocomplete<ItemValue>(props: AutocompleteFlatProps<ItemValue>): React.JSX.Element +export function Autocomplete(props: AutocompleteProps<unknown>): React.JSX.Element { return <BaseAutocomplete.Root {...props} /> } @@ -93,11 +88,12 @@ const autocompleteInputGroupVariants = cva( }, ) -export type AutocompleteSize = NonNullable<VariantProps<typeof autocompleteInputGroupVariants>['size']> +export type AutocompleteSize = NonNullable< + VariantProps<typeof autocompleteInputGroupVariants>['size'] +> -export type AutocompleteInputGroupProps - = BaseAutocomplete.InputGroup.Props - & VariantProps<typeof autocompleteInputGroupVariants> +export type AutocompleteInputGroupProps = BaseAutocomplete.InputGroup.Props & + VariantProps<typeof autocompleteInputGroupVariants> export function AutocompleteInputGroup({ className, @@ -133,9 +129,8 @@ const autocompleteInputVariants = cva( }, ) -export type AutocompleteInputProps - = Omit<BaseAutocomplete.Input.Props, 'size'> - & VariantProps<typeof autocompleteInputVariants> +export type AutocompleteInputProps = Omit<BaseAutocomplete.Input.Props, 'size'> & + VariantProps<typeof autocompleteInputVariants> export function AutocompleteInput({ className, @@ -178,10 +173,8 @@ const autocompleteControlVariants = cva( }, ) -export type AutocompleteControlProps - = Omit<BaseAutocomplete.Trigger.Props, 'className'> - & VariantProps<typeof autocompleteControlVariants> - & { className?: string } +export type AutocompleteControlProps = Omit<BaseAutocomplete.Trigger.Props, 'className'> & + VariantProps<typeof autocompleteControlVariants> & { className?: string } export function AutocompleteTrigger({ className, @@ -193,7 +186,10 @@ export function AutocompleteTrigger({ return ( <BaseAutocomplete.Trigger type={type} - aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Open autocomplete suggestions')} + aria-label={ + props['aria-label'] ?? + (props['aria-labelledby'] ? undefined : 'Open autocomplete suggestions') + } className={cn(autocompleteControlVariants({ size }), className)} {...props} > @@ -202,10 +198,8 @@ export function AutocompleteTrigger({ ) } -export type AutocompleteClearProps - = Omit<BaseAutocomplete.Clear.Props, 'className'> - & VariantProps<typeof autocompleteControlVariants> - & { className?: string } +export type AutocompleteClearProps = Omit<BaseAutocomplete.Clear.Props, 'className'> & + VariantProps<typeof autocompleteControlVariants> & { className?: string } export function AutocompleteClear({ className, @@ -217,7 +211,9 @@ export function AutocompleteClear({ return ( <BaseAutocomplete.Clear type={type} - aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Clear autocomplete')} + aria-label={ + props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Clear autocomplete') + } className={cn( autocompleteControlVariants({ size }), 'data-ending-style:opacity-0 data-starting-style:opacity-0', @@ -230,11 +226,7 @@ export function AutocompleteClear({ ) } -export function AutocompleteIcon({ - className, - children, - ...props -}: BaseAutocomplete.Icon.Props) { +export function AutocompleteIcon({ className, children, ...props }: BaseAutocomplete.Icon.Props) { return ( <BaseAutocomplete.Icon className={cn('flex shrink-0 items-center text-text-tertiary', className)} @@ -257,10 +249,7 @@ type AutocompleteContentProps = { BaseAutocomplete.Positioner.Props, 'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset' > - popupProps?: Omit< - BaseAutocomplete.Popup.Props, - 'children' | 'className' - > + popupProps?: Omit<BaseAutocomplete.Popup.Props, 'children' | 'className'> } export function AutocompleteContent({ @@ -287,11 +276,7 @@ export function AutocompleteContent({ {...positionerProps} > <BaseAutocomplete.Popup - className={cn( - autocompletePopupClassName, - overlayPopupAnimationClassName, - popupClassName, - )} + className={cn(autocompletePopupClassName, overlayPopupAnimationClassName, popupClassName)} {...popupProps} > {children} @@ -301,84 +286,45 @@ export function AutocompleteContent({ ) } -export function AutocompleteList({ - className, - ...props -}: BaseAutocomplete.List.Props) { - return ( - <BaseAutocomplete.List - className={cn(autocompleteListClassName, className)} - {...props} - /> - ) +export function AutocompleteList({ className, ...props }: BaseAutocomplete.List.Props) { + return <BaseAutocomplete.List className={cn(autocompleteListClassName, className)} {...props} /> } -export function AutocompleteItem({ - className, - ...props -}: BaseAutocomplete.Item.Props) { - return ( - <BaseAutocomplete.Item - className={cn(autocompleteItemClassName, className)} - {...props} - /> - ) +export function AutocompleteItem({ className, ...props }: BaseAutocomplete.Item.Props) { + return <BaseAutocomplete.Item className={cn(autocompleteItemClassName, className)} {...props} /> } export type AutocompleteItemTextProps = React.ComponentProps<'span'> -export function AutocompleteItemText({ - className, - ...props -}: AutocompleteItemTextProps) { +export function AutocompleteItemText({ className, ...props }: AutocompleteItemTextProps) { return ( - <span - className={cn('min-w-0 grow truncate px-1 system-sm-medium', className)} - {...props} - /> + <span className={cn('min-w-0 grow truncate px-1 system-sm-medium', className)} {...props} /> ) } -export function AutocompleteGroupLabel({ - className, - ...props -}: BaseAutocomplete.GroupLabel.Props) { - return ( - <BaseAutocomplete.GroupLabel - className={cn(overlayLabelClassName, className)} - {...props} - /> - ) +export function AutocompleteGroupLabel({ className, ...props }: BaseAutocomplete.GroupLabel.Props) { + return <BaseAutocomplete.GroupLabel className={cn(overlayLabelClassName, className)} {...props} /> } -export function AutocompleteSeparator({ - className, - ...props -}: BaseAutocomplete.Separator.Props) { +export function AutocompleteSeparator({ className, ...props }: BaseAutocomplete.Separator.Props) { return ( - <BaseAutocomplete.Separator - className={cn(overlaySeparatorClassName, className)} - {...props} - /> + <BaseAutocomplete.Separator className={cn(overlaySeparatorClassName, className)} {...props} /> ) } -export function AutocompleteEmpty({ - className, - ...props -}: BaseAutocomplete.Empty.Props) { +export function AutocompleteEmpty({ className, ...props }: BaseAutocomplete.Empty.Props) { return ( <BaseAutocomplete.Empty - className={cn('px-3 py-2 system-sm-regular text-text-tertiary empty:h-0 empty:p-0', className)} + className={cn( + 'px-3 py-2 system-sm-regular text-text-tertiary empty:h-0 empty:p-0', + className, + )} {...props} /> ) } -export function AutocompleteStatus({ - className, - ...props -}: BaseAutocomplete.Status.Props) { +export function AutocompleteStatus({ className, ...props }: BaseAutocomplete.Status.Props) { return ( <BaseAutocomplete.Status className={cn('px-3 py-2 system-sm-regular text-text-tertiary', className)} @@ -393,10 +339,7 @@ export function AutocompleteItemIndicator({ ...props }: React.ComponentProps<'span'>) { return ( - <span - className={cn(overlayIndicatorClassName, className)} - {...props} - > + <span className={cn(overlayIndicatorClassName, className)} {...props}> {children ?? <span className="i-ri-arrow-right-line size-4" aria-hidden="true" />} </span> ) diff --git a/packages/dify-ui/src/avatar/__tests__/index.spec.tsx b/packages/dify-ui/src/avatar/__tests__/index.spec.tsx index 2287358b32d97e..b92cc39c9a3ab5 100644 --- a/packages/dify-ui/src/avatar/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/avatar/__tests__/index.spec.tsx @@ -28,7 +28,9 @@ function stubImageLoader() { describe('Avatar', () => { describe('Rendering', () => { it('should keep the fallback visible when avatar URL is provided before image load', async () => { - const screen = await render(<Avatar name="John Doe" avatar="https://example.com/avatar.jpg" />) + const screen = await render( + <Avatar name="John Doe" avatar="https://example.com/avatar.jpg" />, + ) await expect.element(screen.getByText('J')).toBeInTheDocument() }) @@ -49,9 +51,7 @@ describe('Avatar', () => { describe('className prop', () => { it('should merge className with avatar variant classes on root', async () => { - const screen = await render( - <Avatar name="Test" avatar={null} className="custom-class" />, - ) + const screen = await render(<Avatar name="Test" avatar={null} className="custom-class" />) const root = screen.container.firstElementChild as HTMLElement expect(root).toHaveClass('custom-class') @@ -86,11 +86,14 @@ describe('Avatar', () => { it.each([ { name: '中文名', expected: '中', label: 'Chinese characters' }, { name: '123User', expected: '1', label: 'number' }, - ])('should display first character when name starts with $label', async ({ name, expected }) => { - const screen = await render(<Avatar name={name} avatar={null} />) + ])( + 'should display first character when name starts with $label', + async ({ name, expected }) => { + const screen = await render(<Avatar name={name} avatar={null} />) - await expect.element(screen.getByText(expected)).toBeInTheDocument() - }) + await expect.element(screen.getByText(expected)).toBeInTheDocument() + }, + ) it('should handle empty string avatar as falsy value', async () => { const screen = await render(<Avatar name="Test" avatar="" />) @@ -123,8 +126,7 @@ describe('Avatar', () => { await vi.waitFor(() => { expect(onStatusChange).toHaveBeenCalledWith('loaded') }) - } - finally { + } finally { restore() } }) diff --git a/packages/dify-ui/src/avatar/index.stories.tsx b/packages/dify-ui/src/avatar/index.stories.tsx index f8b90c0b16b802..d54b8a243ab5e7 100644 --- a/packages/dify-ui/src/avatar/index.stories.tsx +++ b/packages/dify-ui/src/avatar/index.stories.tsx @@ -7,7 +7,8 @@ const meta = { parameters: { docs: { description: { - component: 'Initials or image-based avatar built on Base UI. Falls back to the first letter when the image fails to load.', + component: + 'Initials or image-based avatar built on Base UI. Falls back to the first letter when the image fails to load.', }, source: { language: 'tsx', @@ -48,9 +49,9 @@ export const WithFallback: Story = { } export const AllSizes: Story = { - render: args => ( + render: (args) => ( <div className="flex items-end gap-4"> - {(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map(size => ( + {(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map((size) => ( <div key={size} className="flex flex-col items-center gap-2"> <Avatar {...args} size={size} avatar="https://i.pravatar.cc/96?u=size-test" /> <span className="text-xs text-text-tertiary">{size}</span> @@ -73,9 +74,9 @@ export const AllSizes: Story = { } export const AllFallbackSizes: Story = { - render: args => ( + render: (args) => ( <div className="flex items-end gap-4"> - {(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map(size => ( + {(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map((size) => ( <div key={size} className="flex flex-col items-center gap-2"> <Avatar {...args} size={size} avatar={null} name="Alex" /> <span className="text-xs text-text-tertiary">{size}</span> diff --git a/packages/dify-ui/src/avatar/index.tsx b/packages/dify-ui/src/avatar/index.tsx index 83360068f41a15..5a7a6a145a74dd 100644 --- a/packages/dify-ui/src/avatar/index.tsx +++ b/packages/dify-ui/src/avatar/index.tsx @@ -5,12 +5,12 @@ import { Avatar as BaseAvatar } from '@base-ui/react/avatar' import { cn } from '../cn' const avatarSizeClasses = { - 'xxs': { root: 'size-4', text: 'text-[7px]' }, - 'xs': { root: 'size-5', text: 'text-[8px]' }, - 'sm': { root: 'size-6', text: 'text-[10px]' }, - 'md': { root: 'size-8', text: 'text-xs' }, - 'lg': { root: 'size-9', text: 'text-sm' }, - 'xl': { root: 'size-10', text: 'text-base' }, + xxs: { root: 'size-4', text: 'text-[7px]' }, + xs: { root: 'size-5', text: 'text-[8px]' }, + sm: { root: 'size-6', text: 'text-[10px]' }, + md: { root: 'size-8', text: 'text-xs' }, + lg: { root: 'size-9', text: 'text-sm' }, + xl: { root: 'size-10', text: 'text-base' }, '2xl': { root: 'size-12', text: 'text-xl' }, '3xl': { root: 'size-16', text: 'text-2xl' }, } as const @@ -29,11 +29,7 @@ type AvatarRootProps = BaseAvatar.Root.Props & { size?: AvatarSize } -export function AvatarRoot({ - size = 'md', - className, - ...props -}: AvatarRootProps) { +export function AvatarRoot({ size = 'md', className, ...props }: AvatarRootProps) { return ( <BaseAvatar.Root className={cn( @@ -50,11 +46,7 @@ type AvatarFallbackProps = BaseAvatar.Fallback.Props & { size?: AvatarSize } -export function AvatarFallback({ - size = 'md', - className, - ...props -}: AvatarFallbackProps) { +export function AvatarFallback({ size = 'md', className, ...props }: AvatarFallbackProps) { return ( <BaseAvatar.Fallback className={cn( @@ -69,10 +61,7 @@ export function AvatarFallback({ type AvatarImageProps = BaseAvatar.Image.Props -export function AvatarImage({ - className, - ...props -}: AvatarImageProps) { +export function AvatarImage({ className, ...props }: AvatarImageProps) { return ( <BaseAvatar.Image className={cn('absolute inset-0 size-full object-cover', className)} @@ -91,15 +80,9 @@ export const Avatar = ({ return ( <AvatarRoot size={size} className={className}> {avatar && ( - <AvatarImage - src={avatar} - alt={name} - onLoadingStatusChange={onLoadingStatusChange} - /> + <AvatarImage src={avatar} alt={name} onLoadingStatusChange={onLoadingStatusChange} /> )} - <AvatarFallback size={size}> - {name?.[0]?.toLocaleUpperCase()} - </AvatarFallback> + <AvatarFallback size={size}>{name?.[0]?.toLocaleUpperCase()}</AvatarFallback> </AvatarRoot> ) } diff --git a/packages/dify-ui/src/button/__tests__/index.spec.tsx b/packages/dify-ui/src/button/__tests__/index.spec.tsx index bb2eeb505382fb..d5ddfb8a4218b0 100644 --- a/packages/dify-ui/src/button/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/button/__tests__/index.spec.tsx @@ -28,7 +28,11 @@ describe('Button', () => { }) it('renders custom element via render prop', async () => { - const screen = await render(<Button nativeButton={false} render={<a href="/test" />}>Link</Button>) + const screen = await render( + <Button nativeButton={false} render={<a href="/test" />}> + Link + </Button>, + ) const button = screen.getByRole('button', { name: 'Link' }).element() expect(button.tagName).toBe('A') expect(button).toHaveAttribute('href', '/test') @@ -38,12 +42,16 @@ describe('Button', () => { describe('loading', () => { it('shows spinner when loading', async () => { const screen = await render(<Button loading>Click me</Button>) - expect(screen.getByRole('button').element().querySelector('[aria-hidden="true"]')).toBeInTheDocument() + expect( + screen.getByRole('button').element().querySelector('[aria-hidden="true"]'), + ).toBeInTheDocument() }) it('hides spinner when not loading', async () => { const screen = await render(<Button loading={false}>Click me</Button>) - expect(screen.getByRole('button').element().querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() + expect( + screen.getByRole('button').element().querySelector('[aria-hidden="true"]'), + ).not.toBeInTheDocument() }) it('keeps loading buttons focusable by default', async () => { @@ -84,7 +92,11 @@ describe('Button', () => { }) it('allows loading focusability to be opted out', async () => { - const screen = await render(<Button loading focusableWhenDisabled={false}>Loading</Button>) + const screen = await render( + <Button loading focusableWhenDisabled={false}> + Loading + </Button>, + ) await expect.element(screen.getByRole('button')).toBeDisabled() }) }) @@ -99,14 +111,22 @@ describe('Button', () => { it('does not fire onClick when disabled', async () => { const onClick = vi.fn() - const screen = await render(<Button onClick={onClick} disabled>Click me</Button>) + const screen = await render( + <Button onClick={onClick} disabled> + Click me + </Button>, + ) asHTMLElement(screen.getByRole('button').element()).click() expect(onClick).not.toHaveBeenCalled() }) it('does not fire onClick when loading', async () => { const onClick = vi.fn() - const screen = await render(<Button onClick={onClick} loading>Click me</Button>) + const screen = await render( + <Button onClick={onClick} loading> + Click me + </Button>, + ) asHTMLElement(screen.getByRole('button').element()).click() expect(onClick).not.toHaveBeenCalled() }) @@ -115,7 +135,9 @@ describe('Button', () => { const onSubmit = vi.fn((event: React.FormEvent<HTMLFormElement>) => event.preventDefault()) const screen = await render( <form onSubmit={onSubmit}> - <Button type="submit" loading>Submit</Button> + <Button type="submit" loading> + Submit + </Button> </form>, ) @@ -130,7 +152,9 @@ describe('Button', () => { <form onSubmit={onSubmit}> <label htmlFor="name">Name</label> <input id="name" /> - <Button type="submit" loading>Submit</Button> + <Button type="submit" loading> + Submit + </Button> </form>, ) @@ -153,9 +177,10 @@ describe('Button', () => { it('forwards ref to the button element', async () => { let buttonRef: HTMLButtonElement | null = null await render( - <Button ref={(el) => { - buttonRef = el - }} + <Button + ref={(el) => { + buttonRef = el + }} > Click me </Button>, diff --git a/packages/dify-ui/src/button/index.stories.tsx b/packages/dify-ui/src/button/index.stories.tsx index 8e4934dbfff2a2..dec3a3aaf6ff12 100644 --- a/packages/dify-ui/src/button/index.stories.tsx +++ b/packages/dify-ui/src/button/index.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' import { expect, fn } from 'storybook/test' - import { Button } from '.' const meta = { @@ -109,7 +108,8 @@ export const Loading: Story = { parameters: { docs: { description: { - story: 'Loading buttons remain focusable by default so focus is not lost after activation. Pass `focusableWhenDisabled={false}` to opt out.', + story: + 'Loading buttons remain focusable by default so focus is not lost after activation. Pass `focusableWhenDisabled={false}` to opt out.', }, }, }, diff --git a/packages/dify-ui/src/button/index.tsx b/packages/dify-ui/src/button/index.tsx index c070e0680f5282..631680fa950775 100644 --- a/packages/dify-ui/src/button/index.tsx +++ b/packages/dify-ui/src/button/index.tsx @@ -11,12 +11,12 @@ const buttonVariants = cva( { variants: { variant: { - 'primary': [ + primary: [ 'border-components-button-primary-border bg-components-button-primary-bg text-components-button-primary-text shadow-sm', 'hover:border-components-button-primary-border-hover hover:bg-components-button-primary-bg-hover', 'data-[disabled]:border-components-button-primary-border-disabled data-[disabled]:bg-components-button-primary-bg-disabled data-[disabled]:text-components-button-primary-text-disabled data-[disabled]:shadow-none', ], - 'secondary': [ + secondary: [ 'border-[0.5px] shadow-xs backdrop-blur-[5px]', 'border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text', 'hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover', @@ -28,12 +28,12 @@ const buttonVariants = cva( 'hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover', 'data-[disabled]:border-components-button-secondary-border-disabled data-[disabled]:bg-components-button-secondary-bg-disabled data-[disabled]:text-components-button-secondary-accent-text-disabled', ], - 'tertiary': [ + tertiary: [ 'bg-components-button-tertiary-bg text-components-button-tertiary-text', 'hover:bg-components-button-tertiary-bg-hover', 'data-[disabled]:bg-components-button-tertiary-bg-disabled data-[disabled]:text-components-button-tertiary-text-disabled', ], - 'ghost': [ + ghost: [ 'text-components-button-ghost-text', 'hover:bg-components-button-ghost-bg-hover', 'data-[disabled]:text-components-button-ghost-text-disabled', @@ -100,12 +100,11 @@ const buttonVariants = cva( }, ) -export type ButtonProps - = Omit<BaseButtonNS.Props, 'className'> - & VariantProps<typeof buttonVariants> & { - loading?: boolean - className?: string - } +export type ButtonProps = Omit<BaseButtonNS.Props, 'className'> & + VariantProps<typeof buttonVariants> & { + loading?: boolean + className?: string + } export function Button({ className, diff --git a/packages/dify-ui/src/checkbox-group/index.stories.tsx b/packages/dify-ui/src/checkbox-group/index.stories.tsx index 328b1be7bbd719..7e76fe60589687 100644 --- a/packages/dify-ui/src/checkbox-group/index.stories.tsx +++ b/packages/dify-ui/src/checkbox-group/index.stories.tsx @@ -2,12 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' import { CheckboxGroup } from '.' import { Checkbox } from '../checkbox' -import { - Field, - FieldDescription, - FieldItem, - FieldLabel, -} from '../field' +import { Field, FieldDescription, FieldItem, FieldLabel } from '../field' import { Fieldset, FieldsetLegend } from '../fieldset' const meta = { @@ -17,7 +12,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'CheckboxGroup primitive built on Base UI. It owns multi-checkbox array state, allValues, and parent checkbox semantics. Import from `@langgenius/dify-ui/checkbox-group` and compose with `Checkbox` from `@langgenius/dify-ui/checkbox`.', + component: + 'CheckboxGroup primitive built on Base UI. It owns multi-checkbox array state, allValues, and parent checkbox semantics. Import from `@langgenius/dify-ui/checkbox-group` and compose with `Checkbox` from `@langgenius/dify-ui/checkbox`.', }, }, }, @@ -40,7 +36,10 @@ function DocumentSelectionDemo() { allValues={documentIds} className="flex flex-col gap-3" > - <label id={groupLabelId} className="flex items-center gap-2 system-sm-semibold-uppercase text-text-secondary"> + <label + id={groupLabelId} + className="flex items-center gap-2 system-sm-semibold-uppercase text-text-secondary" + > <Checkbox parent /> Current page documents </label> @@ -49,8 +48,11 @@ function DocumentSelectionDemo() { { id: 'doc-1', name: 'onboarding-guide.pdf' }, { id: 'doc-2', name: 'pricing-faq.md' }, { id: 'doc-3', name: 'release-notes.txt' }, - ].map(document => ( - <label key={document.id} className="flex items-center gap-2 system-sm-medium text-text-secondary"> + ].map((document) => ( + <label + key={document.id} + className="flex items-center gap-2 system-sm-medium text-text-secondary" + > <Checkbox value={document.id} /> {document.name} </label> @@ -65,7 +67,8 @@ export const DocumentSelection: Story = { parameters: { docs: { description: { - story: 'Matches Dify table/list selection patterns such as documents, segments, annotations, and install bundle items: CheckboxGroup owns the selected ID array, allValues defines the current selectable page, and the parent checkbox provides select-all plus mixed state.', + story: + 'Matches Dify table/list selection patterns such as documents, segments, annotations, and install bundle items: CheckboxGroup owns the selected ID array, allValues defines the current selectable page, and the parent checkbox provides select-all plus mixed state.', }, }, }, @@ -82,21 +85,22 @@ function DynamicFormFieldDemo() { return ( <Field name="allowed_file_types" className="flex w-80 flex-col gap-2"> <FieldDescription className="body-xs-regular text-text-tertiary"> - This mirrors Dify dynamic form fields where checkbox options are controlled by schema and persisted as a string array. + This mirrors Dify dynamic form fields where checkbox options are controlled by schema and + persisted as a string array. </FieldDescription> <Fieldset - render={( + render={ <CheckboxGroup value={selected} onValueChange={setSelected} className="flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg p-3" /> - )} + } > <FieldsetLegend className="system-sm-medium text-text-secondary"> Allowed file types </FieldsetLegend> - {options.map(option => ( + {options.map((option) => ( <FieldItem key={option.value}> <FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary"> <Checkbox value={option.value} /> @@ -114,7 +118,8 @@ export const DynamicFormField: Story = { parameters: { docs: { description: { - story: 'Matches Dify checkbox-list form usage in workflow node forms and base form rendering. Field and Fieldset provide group labeling; CheckboxGroup owns controlled array state.', + story: + 'Matches Dify checkbox-list form usage in workflow node forms and base form rendering. Field and Fieldset provide group labeling; CheckboxGroup owns controlled array state.', }, }, }, diff --git a/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx b/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx index 20c8bb93a4ed34..f3e9d33972a34b 100644 --- a/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx @@ -1,10 +1,5 @@ import { render } from 'vitest-browser-react' -import { - Checkbox, - CheckboxIndicator, - CheckboxRoot, - CheckboxSkeleton, -} from '../index' +import { Checkbox, CheckboxIndicator, CheckboxRoot, CheckboxSkeleton } from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement @@ -58,14 +53,23 @@ describe('Checkbox', () => { expect(onCheckedChange.mock.calls[0]?.[0]).toBe(true) await expect.element(checkbox).toHaveAttribute('aria-checked', 'false') - await screen.rerender(<Checkbox checked aria-label="Accept terms" onCheckedChange={onCheckedChange} />) - await expect.element(screen.getByRole('checkbox', { name: 'Accept terms' })).toHaveAttribute('aria-checked', 'true') + await screen.rerender( + <Checkbox checked aria-label="Accept terms" onCheckedChange={onCheckedChange} />, + ) + await expect + .element(screen.getByRole('checkbox', { name: 'Accept terms' })) + .toHaveAttribute('aria-checked', 'true') }) it('should ignore interaction when disabled', async () => { const onCheckedChange = vi.fn() const screen = await render( - <Checkbox checked={false} disabled aria-label="Accept terms" onCheckedChange={onCheckedChange} />, + <Checkbox + checked={false} + disabled + aria-label="Accept terms" + onCheckedChange={onCheckedChange} + />, ) const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' }) @@ -109,7 +113,9 @@ describe('Checkbox', () => { </CheckboxRoot>, ) - await expect.element(screen.getByRole('checkbox', { name: 'Custom checkbox' })).toHaveClass('custom-root') + await expect + .element(screen.getByRole('checkbox', { name: 'Custom checkbox' })) + .toHaveClass('custom-root') expect(screen.container.querySelector('.custom-indicator')).toBeInTheDocument() }) }) diff --git a/packages/dify-ui/src/checkbox/index.stories.tsx b/packages/dify-ui/src/checkbox/index.stories.tsx index ff15aaa9870894..f1d5e10c28af3c 100644 --- a/packages/dify-ui/src/checkbox/index.stories.tsx +++ b/packages/dify-ui/src/checkbox/index.stories.tsx @@ -1,9 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' -import { - Checkbox, - CheckboxSkeleton, -} from '.' +import { Checkbox, CheckboxSkeleton } from '.' const meta = { title: 'Base/Form/Checkbox', @@ -12,7 +9,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Checkbox primitive built on Base UI. It preserves Base UI checked, indeterminate, disabled, and hidden input semantics while applying the Dify 16px checkbox design from Figma. Import from `@langgenius/dify-ui/checkbox`.', + component: + 'Checkbox primitive built on Base UI. It preserves Base UI checked, indeterminate, disabled, and hidden input semantics while applying the Dify 16px checkbox design from Figma. Import from `@langgenius/dify-ui/checkbox`.', }, }, }, @@ -46,18 +44,14 @@ function CheckboxDemo(args: Partial<React.ComponentProps<typeof Checkbox>>) { return ( <label className="flex items-center gap-2 system-sm-medium text-text-secondary"> - <Checkbox - {...args} - checked={checked} - onCheckedChange={setChecked} - /> + <Checkbox {...args} checked={checked} onCheckedChange={setChecked} /> Enable feature </label> ) } export const Default: Story = { - render: args => <CheckboxDemo {...args} />, + render: (args) => <CheckboxDemo {...args} />, args: { checked: false, indeterminate: false, @@ -66,7 +60,7 @@ export const Default: Story = { } export const Checked: Story = { - render: args => <CheckboxDemo {...args} />, + render: (args) => <CheckboxDemo {...args} />, args: { checked: true, indeterminate: false, @@ -76,9 +70,9 @@ export const Checked: Story = { export const Indeterminate: Story = { args: { - 'checked': false, - 'indeterminate': true, - 'disabled': false, + checked: false, + indeterminate: true, + disabled: false, 'aria-label': 'Partial selection', }, } @@ -114,8 +108,11 @@ function StateMatrixDemo() { return ( <div className="flex flex-col gap-3"> - {states.map(state => ( - <label key={state.label} className="flex items-center gap-2 system-sm-medium text-text-secondary"> + {states.map((state) => ( + <label + key={state.label} + className="flex items-center gap-2 system-sm-medium text-text-secondary" + > <Checkbox checked={state.checked} indeterminate={state.indeterminate} @@ -137,7 +134,8 @@ export const StateMatrix: Story = { parameters: { docs: { description: { - story: 'The full visual matrix for Dify checkbox states. State styling comes from Base UI data attributes such as data-checked, data-indeterminate, and data-disabled.', + story: + 'The full visual matrix for Dify checkbox states. State styling comes from Base UI data attributes such as data-checked, data-indeterminate, and data-disabled.', }, }, }, diff --git a/packages/dify-ui/src/checkbox/index.tsx b/packages/dify-ui/src/checkbox/index.tsx index bd9a8197c34aba..a829ec12549e3a 100644 --- a/packages/dify-ui/src/checkbox/index.tsx +++ b/packages/dify-ui/src/checkbox/index.tsx @@ -20,60 +20,50 @@ const checkboxRootClassName = cn( 'data-disabled:data-indeterminate:hover:bg-components-checkbox-bg-disabled-checked', ) -const checkboxIndicatorClassName = 'flex size-3 items-center justify-center text-current data-unchecked:hidden' +const checkboxIndicatorClassName = + 'flex size-3 items-center justify-center text-current data-unchecked:hidden' const checkboxSkeletonClassName = 'size-4 shrink-0 rounded-sm bg-text-quaternary opacity-20' -export type CheckboxRootProps - = Omit<BaseCheckboxNS.Root.Props, 'className'> - & { - className?: string - } +export type CheckboxRootProps = Omit<BaseCheckboxNS.Root.Props, 'className'> & { + className?: string +} -export function CheckboxRoot({ - className, - ...props -}: CheckboxRootProps) { - return ( - <BaseCheckbox.Root - className={cn(checkboxRootClassName, className)} - {...props} - /> - ) +export function CheckboxRoot({ className, ...props }: CheckboxRootProps) { + return <BaseCheckbox.Root className={cn(checkboxRootClassName, className)} {...props} /> } -export type CheckboxIndicatorProps - = Omit<BaseCheckboxNS.Indicator.Props, 'className' | 'children'> - & { - className?: string - } +export type CheckboxIndicatorProps = Omit< + BaseCheckboxNS.Indicator.Props, + 'className' | 'children' +> & { + className?: string +} -export function CheckboxIndicator({ - className, - render, - ...props -}: CheckboxIndicatorProps) { +export function CheckboxIndicator({ className, render, ...props }: CheckboxIndicatorProps) { return ( <BaseCheckbox.Indicator className={cn(checkboxIndicatorClassName, className)} - render={render ?? ((indicatorProps, state) => ( - <span {...indicatorProps}> - {state.indeterminate - ? <span className="block h-[1.5px] w-1.75 rounded-full bg-current" /> - : <span className="i-ri-check-line block size-3 shrink-0" />} - </span> - ))} + render={ + render ?? + ((indicatorProps, state) => ( + <span {...indicatorProps}> + {state.indeterminate ? ( + <span className="block h-[1.5px] w-1.75 rounded-full bg-current" /> + ) : ( + <span className="i-ri-check-line block size-3 shrink-0" /> + )} + </span> + )) + } {...props} /> ) } -export type CheckboxProps - = Omit<CheckboxRootProps, 'children'> +export type CheckboxProps = Omit<CheckboxRootProps, 'children'> -export function Checkbox({ - ...props -}: CheckboxProps) { +export function Checkbox({ ...props }: CheckboxProps) { return ( <CheckboxRoot {...props}> <CheckboxIndicator /> @@ -81,20 +71,10 @@ export function Checkbox({ ) } -export type CheckboxSkeletonProps - = Omit<React.ComponentProps<'div'>, 'className'> - & { - className?: string - } +export type CheckboxSkeletonProps = Omit<React.ComponentProps<'div'>, 'className'> & { + className?: string +} -export function CheckboxSkeleton({ - className, - ...props -}: CheckboxSkeletonProps) { - return ( - <div - className={cn(checkboxSkeletonClassName, className)} - {...props} - /> - ) +export function CheckboxSkeleton({ className, ...props }: CheckboxSkeletonProps) { + return <div className={cn(checkboxSkeletonClassName, className)} {...props} /> } diff --git a/packages/dify-ui/src/collapsible/__tests__/index.spec.tsx b/packages/dify-ui/src/collapsible/__tests__/index.spec.tsx index 26955b216bbbac..6f8579cdbb31d6 100644 --- a/packages/dify-ui/src/collapsible/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/collapsible/__tests__/index.spec.tsx @@ -1,9 +1,5 @@ import { render } from 'vitest-browser-react' -import { - Collapsible, - CollapsiblePanel, - CollapsibleTrigger, -} from '../index' +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement @@ -17,7 +13,9 @@ describe('Collapsible wrappers', () => { ) await expect.element(screen.getByTestId('collapsible-root')).toBeInTheDocument() - await expect.element(screen.getByRole('button', { name: 'Recovery keys' })).toHaveAttribute('data-panel-open', '') + await expect + .element(screen.getByRole('button', { name: 'Recovery keys' })) + .toHaveAttribute('data-panel-open', '') await expect.element(screen.getByText('Panel content')).toBeInTheDocument() }) @@ -46,7 +44,9 @@ describe('Collapsible wrappers', () => { </Collapsible>, ) - await expect.element(screen.getByRole('button', { name: 'Custom' })).toHaveClass('custom-trigger') + await expect + .element(screen.getByRole('button', { name: 'Custom' })) + .toHaveClass('custom-trigger') expect(screen.getByText('Custom panel').element()).toHaveClass('custom-panel') expect(screen.container.querySelector('.custom-root')).toBeInTheDocument() }) @@ -59,7 +59,9 @@ describe('Collapsible wrappers', () => { </Collapsible>, ) - await expect.element(screen.getByRole('button', { name: 'Styled trigger' })).toHaveAttribute('data-panel-open', '') + await expect + .element(screen.getByRole('button', { name: 'Styled trigger' })) + .toHaveAttribute('data-panel-open', '') await expect.element(screen.getByText('Styled panel')).toBeInTheDocument() }) }) diff --git a/packages/dify-ui/src/collapsible/index.stories.tsx b/packages/dify-ui/src/collapsible/index.stories.tsx index f9fde3c94b7dfa..d8943fde28fa0b 100644 --- a/packages/dify-ui/src/collapsible/index.stories.tsx +++ b/packages/dify-ui/src/collapsible/index.stories.tsx @@ -1,10 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' -import { - Collapsible, - CollapsiblePanel, - CollapsibleTrigger, -} from '.' +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '.' import { cn } from '../cn' const meta = { @@ -14,7 +10,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Unstyled Base UI Collapsible primitive. The examples mirror the official Root, Trigger, and Panel anatomy, with presentation supplied at the call site using Dify UI tokens.', + component: + 'Unstyled Base UI Collapsible primitive. The examples mirror the official Root, Trigger, and Panel anatomy, with presentation supplied at the call site using Dify UI tokens.', }, }, }, @@ -24,16 +21,16 @@ const meta = { export default meta type Story = StoryObj<typeof meta> -const rootClassName = 'w-72 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5' +const rootClassName = + 'w-72 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5' const triggerClassName = 'h-8' const panelClassName = 'system-sm-regular text-text-secondary' const contentClassName = 'flex flex-col gap-2 px-2.5 pb-2 pt-1' -const iconClassName = 'i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary transition-transform duration-100 ease-out group-data-panel-open:rotate-90 motion-reduce:transition-none' -const sectionRootClassName = 'w-90 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5' -const sectionTriggerClassName = cn( - triggerClassName, - 'h-auto min-h-12 px-3 py-2', -) +const iconClassName = + 'i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary transition-transform duration-100 ease-out group-data-panel-open:rotate-90 motion-reduce:transition-none' +const sectionRootClassName = + 'w-90 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5' +const sectionTriggerClassName = cn(triggerClassName, 'h-auto min-h-12 px-3 py-2') const sectionPanelClassName = panelClassName function TriggerIcon() { @@ -66,7 +63,7 @@ export const Anatomy: Story = { args: { defaultOpen: true, }, - render: args => ( + render: (args) => ( <Collapsible {...args} className={rootClassName}> <RecoveryKeys /> </Collapsible> @@ -97,7 +94,7 @@ function ControlledDemo() { <button type="button" className="rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 system-sm-medium text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" - onClick={() => setOpen(value => !value)} + onClick={() => setOpen((value) => !value)} > {open ? 'Close panel' : 'Open panel'} </button> @@ -169,7 +166,9 @@ export const SettingsSections: Story = { <CollapsibleTrigger className={sectionTriggerClassName}> <span className="flex min-w-0 flex-col gap-1"> <span className="truncate system-sm-medium text-text-primary">{section.title}</span> - <span className="line-clamp-2 system-xs-regular text-text-tertiary">{section.description}</span> + <span className="line-clamp-2 system-xs-regular text-text-tertiary"> + {section.description} + </span> </span> <TriggerIcon /> </CollapsibleTrigger> diff --git a/packages/dify-ui/src/collapsible/index.tsx b/packages/dify-ui/src/collapsible/index.tsx index b3ca9e496a03ab..ef6c6c68a5ca88 100644 --- a/packages/dify-ui/src/collapsible/index.tsx +++ b/packages/dify-ui/src/collapsible/index.tsx @@ -4,34 +4,19 @@ import type { Collapsible as BaseCollapsibleNS } from '@base-ui/react/collapsibl import { Collapsible as BaseCollapsible } from '@base-ui/react/collapsible' import { cn } from '../cn' -export type CollapsibleProps - = Omit<BaseCollapsibleNS.Root.Props, 'className'> - & { - className?: string - } +export type CollapsibleProps = Omit<BaseCollapsibleNS.Root.Props, 'className'> & { + className?: string +} -export function Collapsible({ - className, - ...props -}: CollapsibleProps) { - return ( - <BaseCollapsible.Root - className={cn('flex min-w-0 flex-col', className)} - {...props} - /> - ) +export function Collapsible({ className, ...props }: CollapsibleProps) { + return <BaseCollapsible.Root className={cn('flex min-w-0 flex-col', className)} {...props} /> } -export type CollapsibleTriggerProps - = Omit<BaseCollapsibleNS.Trigger.Props, 'className'> - & { - className?: string - } +export type CollapsibleTriggerProps = Omit<BaseCollapsibleNS.Trigger.Props, 'className'> & { + className?: string +} -export function CollapsibleTrigger({ - className, - ...props -}: CollapsibleTriggerProps) { +export function CollapsibleTrigger({ className, ...props }: CollapsibleTriggerProps) { return ( <BaseCollapsible.Trigger className={cn( @@ -47,21 +32,16 @@ export function CollapsibleTrigger({ ) } -export type CollapsiblePanelProps - = Omit<BaseCollapsibleNS.Panel.Props, 'className'> - & { - className?: string - } +export type CollapsiblePanelProps = Omit<BaseCollapsibleNS.Panel.Props, 'className'> & { + className?: string +} -export function CollapsiblePanel({ - className, - ...props -}: CollapsiblePanelProps) { +export function CollapsiblePanel({ className, ...props }: CollapsiblePanelProps) { return ( <BaseCollapsible.Panel className={cn( 'h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none', - '[&[hidden]:not([hidden=\'until-found\'])]:hidden', + "[&[hidden]:not([hidden='until-found'])]:hidden", 'data-ending-style:h-0 data-starting-style:h-0', className, )} diff --git a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx index 13b9fa2b165128..56ab5c79645a5d 100644 --- a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx @@ -24,11 +24,8 @@ import { ComboboxValue, } from '../index' -const renderWithSafeViewport = (ui: React.ReactNode) => render( - <div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}> - {ui} - </div>, -) +const renderWithSafeViewport = (ui: React.ReactNode) => + render(<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>{ui}</div>) const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement @@ -38,40 +35,41 @@ const renderSelectLikeCombobox = ({ }: { children?: React.ReactNode open?: boolean -} = {}) => renderWithSafeViewport( - <Combobox open={open} defaultValue="workflow" items={['workflow', 'dataset']}> - {children ?? ( - <React.Fragment> - <ComboboxLabel data-testid="label">Resource type</ComboboxLabel> - <ComboboxTrigger aria-label="Resource type" data-testid="trigger"> - <ComboboxValue placeholder="Select resource" /> - </ComboboxTrigger> - <ComboboxContent - positionerProps={{ - 'role': 'group', - 'aria-label': 'combobox positioner', - }} - popupProps={{ - 'role': 'dialog', - 'aria-label': 'combobox popup', - }} - > - <ComboboxStatus data-testid="status">2 options</ComboboxStatus> - <ComboboxList role="listbox" aria-label="combobox list" data-testid="list"> - <ComboboxItem value="workflow"> - <ComboboxItemText>Workflow</ComboboxItemText> - <ComboboxItemIndicator /> - </ComboboxItem> - <ComboboxItem value="dataset"> - <ComboboxItemText>Dataset</ComboboxItemText> - </ComboboxItem> - </ComboboxList> - <ComboboxEmpty data-testid="empty">No options</ComboboxEmpty> - </ComboboxContent> - </React.Fragment> - )} - </Combobox>, -) +} = {}) => + renderWithSafeViewport( + <Combobox open={open} defaultValue="workflow" items={['workflow', 'dataset']}> + {children ?? ( + <React.Fragment> + <ComboboxLabel data-testid="label">Resource type</ComboboxLabel> + <ComboboxTrigger aria-label="Resource type" data-testid="trigger"> + <ComboboxValue placeholder="Select resource" /> + </ComboboxTrigger> + <ComboboxContent + positionerProps={{ + role: 'group', + 'aria-label': 'combobox positioner', + }} + popupProps={{ + role: 'dialog', + 'aria-label': 'combobox popup', + }} + > + <ComboboxStatus data-testid="status">2 options</ComboboxStatus> + <ComboboxList role="listbox" aria-label="combobox list" data-testid="list"> + <ComboboxItem value="workflow"> + <ComboboxItemText>Workflow</ComboboxItemText> + <ComboboxItemIndicator /> + </ComboboxItem> + <ComboboxItem value="dataset"> + <ComboboxItemText>Dataset</ComboboxItemText> + </ComboboxItem> + </ComboboxList> + <ComboboxEmpty data-testid="empty">No options</ComboboxEmpty> + </ComboboxContent> + </React.Fragment> + )} + </Combobox>, + ) const renderInputCombobox = ({ children, @@ -79,27 +77,28 @@ const renderInputCombobox = ({ }: { children?: React.ReactNode open?: boolean -} = {}) => renderWithSafeViewport( - <Combobox open={open} defaultValue="workflow" items={['workflow', 'dataset']}> - {children ?? ( - <React.Fragment> - <ComboboxInputGroup data-testid="input-group"> - <ComboboxInput aria-label="Search resources" data-testid="input" /> - <ComboboxClear data-testid="clear" /> - <ComboboxInputTrigger data-testid="input-trigger" /> - </ComboboxInputGroup> - <ComboboxContent popupProps={{ 'role': 'dialog', 'aria-label': 'combobox popup' }}> - <ComboboxList role="listbox" aria-label="combobox list"> - <ComboboxItem value="workflow"> - <ComboboxItemText>Workflow</ComboboxItemText> - <ComboboxItemIndicator /> - </ComboboxItem> - </ComboboxList> - </ComboboxContent> - </React.Fragment> - )} - </Combobox>, -) +} = {}) => + renderWithSafeViewport( + <Combobox open={open} defaultValue="workflow" items={['workflow', 'dataset']}> + {children ?? ( + <React.Fragment> + <ComboboxInputGroup data-testid="input-group"> + <ComboboxInput aria-label="Search resources" data-testid="input" /> + <ComboboxClear data-testid="clear" /> + <ComboboxInputTrigger data-testid="input-trigger" /> + </ComboboxInputGroup> + <ComboboxContent popupProps={{ role: 'dialog', 'aria-label': 'combobox popup' }}> + <ComboboxList role="listbox" aria-label="combobox list"> + <ComboboxItem value="workflow"> + <ComboboxItemText>Workflow</ComboboxItemText> + <ComboboxItemIndicator /> + </ComboboxItem> + </ComboboxList> + </ComboboxContent> + </React.Fragment> + )} + </Combobox>, + ) describe('Combobox wrappers', () => { describe('Select-like trigger', () => { @@ -107,7 +106,9 @@ describe('Combobox wrappers', () => { const screen = await renderSelectLikeCombobox() await expect.element(screen.getByText('Resource type')).toBeInTheDocument() - await expect.element(screen.getByRole('combobox', { name: 'Resource type' })).toBeInTheDocument() + await expect + .element(screen.getByRole('combobox', { name: 'Resource type' })) + .toBeInTheDocument() }) }) @@ -126,18 +127,32 @@ describe('Combobox wrappers', () => { ), }) - await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveAttribute('autocomplete', 'off') - await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveAttribute('type', 'text') - await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveAttribute('placeholder', 'Find a resource') - await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toBeRequired() - await expect.element(screen.getByRole('combobox', { name: 'Search resources' })).toHaveClass('custom-input') + await expect + .element(screen.getByRole('combobox', { name: 'Search resources' })) + .toHaveAttribute('autocomplete', 'off') + await expect + .element(screen.getByRole('combobox', { name: 'Search resources' })) + .toHaveAttribute('type', 'text') + await expect + .element(screen.getByRole('combobox', { name: 'Search resources' })) + .toHaveAttribute('placeholder', 'Find a resource') + await expect + .element(screen.getByRole('combobox', { name: 'Search resources' })) + .toBeRequired() + await expect + .element(screen.getByRole('combobox', { name: 'Search resources' })) + .toHaveClass('custom-input') }) it('should provide fallback aria labels and decorative icons for input controls', async () => { const screen = await renderInputCombobox() - await expect.element(screen.getByRole('button', { name: 'Clear combobox' })).toHaveAttribute('type', 'button') - await expect.element(screen.getByRole('button', { name: 'Open combobox options' })).toHaveAttribute('type', 'button') + await expect + .element(screen.getByRole('button', { name: 'Clear combobox' })) + .toHaveAttribute('type', 'button') + await expect + .element(screen.getByRole('button', { name: 'Open combobox options' })) + .toHaveAttribute('type', 'button') }) it('should rely on aria-labelledby when provided instead of injecting fallback labels', async () => { @@ -155,8 +170,12 @@ describe('Combobox wrappers', () => { ), }) - await expect.element(screen.getByRole('button', { name: 'Clear from label' })).not.toHaveAttribute('aria-label') - await expect.element(screen.getByRole('button', { name: 'Trigger from label' })).not.toHaveAttribute('aria-label') + await expect + .element(screen.getByRole('button', { name: 'Clear from label' })) + .not.toHaveAttribute('aria-label') + await expect + .element(screen.getByRole('button', { name: 'Trigger from label' })) + .not.toHaveAttribute('aria-label') }) }) @@ -164,8 +183,12 @@ describe('Combobox wrappers', () => { it('should use default overlay placement', async () => { const screen = await renderSelectLikeCombobox({ open: true }) - await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-side', 'bottom') - await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-align', 'start') + await expect + .element(screen.getByRole('group', { name: 'combobox positioner' })) + .toHaveAttribute('data-side', 'bottom') + await expect + .element(screen.getByRole('group', { name: 'combobox positioner' })) + .toHaveAttribute('data-align', 'start') }) it('should apply custom placement side and passthrough popup props', async () => { @@ -179,11 +202,11 @@ describe('Combobox wrappers', () => { placement="top-end" sideOffset={12} alignOffset={6} - positionerProps={{ 'role': 'group', 'aria-label': 'combobox positioner' }} + positionerProps={{ role: 'group', 'aria-label': 'combobox positioner' }} popupProps={{ - 'role': 'dialog', + role: 'dialog', 'aria-label': 'combobox popup', - 'onClick': onPopupClick, + onClick: onPopupClick, }} > <ComboboxList role="listbox" aria-label="combobox list"> @@ -197,7 +220,9 @@ describe('Combobox wrappers', () => { asHTMLElement(screen.getByRole('dialog', { name: 'combobox popup' }).element()).click() - await expect.element(screen.getByRole('group', { name: 'combobox positioner' })).toHaveAttribute('data-side', 'top') + await expect + .element(screen.getByRole('group', { name: 'combobox positioner' })) + .toHaveAttribute('data-side', 'top') expect(onPopupClick).toHaveBeenCalledTimes(1) }) @@ -207,7 +232,7 @@ describe('Combobox wrappers', () => { <ComboboxTrigger aria-label="Resource type"> <ComboboxValue /> </ComboboxTrigger> - <ComboboxContent popupProps={{ 'role': 'dialog', 'aria-label': 'combobox popup' }}> + <ComboboxContent popupProps={{ role: 'dialog', 'aria-label': 'combobox popup' }}> <ComboboxList role="listbox" aria-label="combobox list" data-testid="custom-list"> <ComboboxGroup items={['workflow']}> <ComboboxGroupLabel className="custom-label">Resources</ComboboxGroupLabel> @@ -224,8 +249,12 @@ describe('Combobox wrappers', () => { await expect.element(screen.getByText('Resources')).toHaveClass('custom-label') await expect.element(screen.getByTestId('separator')).toHaveClass('custom-separator') - await expect.element(screen.getByRole('option', { name: 'Workflow' })).toHaveClass('custom-item') - await expect.element(screen.getByTestId('custom-list').getByText('Workflow')).toHaveClass('custom-text') + await expect + .element(screen.getByRole('option', { name: 'Workflow' })) + .toHaveClass('custom-item') + await expect + .element(screen.getByTestId('custom-list').getByText('Workflow')) + .toHaveClass('custom-text') await expect.element(screen.getByTestId('indicator')).toHaveClass('custom-indicator') }) @@ -248,15 +277,25 @@ describe('Combobox wrappers', () => { </Combobox>, ) - const input = asHTMLElement(screen.getByRole('combobox', { name: 'Search resources' }).element()) + const input = asHTMLElement( + screen.getByRole('combobox', { name: 'Search resources' }).element(), + ) input.focus() - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })) - await expect.element(screen.getByRole('option', { name: 'workflow' })).toHaveAttribute('data-highlighted') + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }), + ) + await expect + .element(screen.getByRole('option', { name: 'workflow' })) + .toHaveAttribute('data-highlighted') - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })) + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }), + ) - await expect.element(screen.getByRole('option', { name: 'dataset' })).toHaveAttribute('data-highlighted') + await expect + .element(screen.getByRole('option', { name: 'dataset' })) + .toHaveAttribute('data-highlighted') }) }) @@ -269,7 +308,7 @@ describe('Combobox wrappers', () => { <ComboboxValue> {(selectedValue: string[]) => ( <React.Fragment> - {selectedValue.map(item => ( + {selectedValue.map((item) => ( <ComboboxChip key={item} className="custom-chip"> <span>{item}</span> <ComboboxChipRemove data-testid="remove-chip" /> @@ -285,8 +324,12 @@ describe('Combobox wrappers', () => { ) await expect.element(screen.getByTestId('chips')).toHaveClass('custom-chips') - await expect.element(screen.getByText('maya').element().parentElement!).toHaveClass('custom-chip') - await expect.element(screen.getByRole('button', { name: 'Remove selected item' })).toHaveAttribute('type', 'button') + await expect + .element(screen.getByText('maya').element().parentElement!) + .toHaveClass('custom-chip') + await expect + .element(screen.getByRole('button', { name: 'Remove selected item' })) + .toHaveAttribute('type', 'button') }) it('should preserve chip remove aria-labelledby over fallback label', async () => { @@ -297,7 +340,7 @@ describe('Combobox wrappers', () => { <ComboboxValue> {(selectedValue: string[]) => ( <React.Fragment> - {selectedValue.map(item => ( + {selectedValue.map((item) => ( <ComboboxChip key={item}> <span id="remove-maya">Remove Maya</span> <ComboboxChipRemove aria-labelledby="remove-maya" /> @@ -312,7 +355,9 @@ describe('Combobox wrappers', () => { </Combobox>, ) - await expect.element(screen.getByRole('button', { name: 'Remove Maya' })).not.toHaveAttribute('aria-label') + await expect + .element(screen.getByRole('button', { name: 'Remove Maya' })) + .not.toHaveAttribute('aria-label') }) }) }) diff --git a/packages/dify-ui/src/combobox/index.stories.tsx b/packages/dify-ui/src/combobox/index.stories.tsx index 57f4a9a32017e5..7374457d7099b6 100644 --- a/packages/dify-ui/src/combobox/index.stories.tsx +++ b/packages/dify-ui/src/combobox/index.stories.tsx @@ -30,11 +30,7 @@ import { useComboboxFilteredItems, } from '.' import { cn } from '../cn' -import { - Field, - FieldDescription, - FieldLabel, -} from '../field' +import { Field, FieldDescription, FieldLabel } from '../field' type Option = { value: string @@ -64,8 +60,7 @@ const scrollHighlightedVirtualItem = ( }, virtualizer: StoryVirtualizer | null, ) => { - if (!item || !virtualizer) - return + if (!item || !virtualizer) return const isStart = index === 0 const isEnd = index === virtualizer.options.count - 1 @@ -80,14 +75,35 @@ const scrollHighlightedVirtualItem = ( const providerOptions: Option[] = [ { value: 'openai', label: 'OpenAI', meta: 'GPT-5, GPT-4.1', icon: 'i-ri-openai-fill' }, - { value: 'anthropic', label: 'Anthropic', meta: 'Claude Opus, Sonnet', icon: 'i-ri-sparkling-2-line' }, + { + value: 'anthropic', + label: 'Anthropic', + meta: 'Claude Opus, Sonnet', + icon: 'i-ri-sparkling-2-line', + }, { value: 'google', label: 'Google', meta: 'Gemini 2.5', icon: 'i-ri-google-fill' }, - { value: 'azure-openai', label: 'Azure OpenAI', meta: 'Enterprise workspace', icon: 'i-ri-microsoft-fill' }, - { value: 'localai', label: 'LocalAI', meta: 'Self-hosted endpoint', icon: 'i-ri-server-line', disabled: true }, + { + value: 'azure-openai', + label: 'Azure OpenAI', + meta: 'Enterprise workspace', + icon: 'i-ri-microsoft-fill', + }, + { + value: 'localai', + label: 'LocalAI', + meta: 'Self-hosted endpoint', + icon: 'i-ri-server-line', + disabled: true, + }, ] const dataSourceOptions: Option[] = [ - { value: 'knowledge-base', label: 'Knowledge Base', meta: 'Vector index', icon: 'i-ri-database-2-line' }, + { + value: 'knowledge-base', + label: 'Knowledge Base', + meta: 'Vector index', + icon: 'i-ri-database-2-line', + }, { value: 'notion', label: 'Notion', meta: 'Synced pages', icon: 'i-ri-notion-fill' }, { value: 'website', label: 'Website crawler', meta: 'Public URLs', icon: 'i-ri-global-line' }, { value: 's3', label: 'S3 bucket', meta: 'Private files', icon: 'i-ri-cloud-line' }, @@ -106,22 +122,52 @@ const toolGroups: OptionGroup[] = [ { label: 'Retrieval', items: [ - { value: 'dataset-search', label: 'Dataset search', meta: 'Search workspace knowledge', icon: 'i-ri-search-eye-line' }, - { value: 'web-scraper', label: 'Web scraper', meta: 'Fetch public pages', icon: 'i-ri-global-line' }, + { + value: 'dataset-search', + label: 'Dataset search', + meta: 'Search workspace knowledge', + icon: 'i-ri-search-eye-line', + }, + { + value: 'web-scraper', + label: 'Web scraper', + meta: 'Fetch public pages', + icon: 'i-ri-global-line', + }, ], }, { label: 'Actions', items: [ - { value: 'http-request', label: 'HTTP request', meta: 'Call external APIs', icon: 'i-ri-terminal-box-line' }, - { value: 'code-runner', label: 'Code runner', meta: 'Execute sandboxed scripts', icon: 'i-ri-code-s-slash-line' }, + { + value: 'http-request', + label: 'HTTP request', + meta: 'Call external APIs', + icon: 'i-ri-terminal-box-line', + }, + { + value: 'code-runner', + label: 'Code runner', + meta: 'Execute sandboxed scripts', + icon: 'i-ri-code-s-slash-line', + }, ], }, { label: 'Operations', items: [ - { value: 'human-review', label: 'Human review', meta: 'Assign approval task', icon: 'i-ri-user-voice-line' }, - { value: 'audit-log', label: 'Audit log', meta: 'Record workflow events', icon: 'i-ri-file-list-3-line' }, + { + value: 'human-review', + label: 'Human review', + meta: 'Assign approval task', + icon: 'i-ri-user-voice-line', + }, + { + value: 'audit-log', + label: 'Audit log', + meta: 'Record workflow events', + icon: 'i-ri-file-list-3-line', + }, ], }, ] @@ -136,12 +182,42 @@ const tagOptions: Option[] = [ ] const directoryOptions: Option[] = [ - { value: 'maya-chen', label: 'Maya Chen', meta: 'Product owner · maya@example.com', icon: 'i-ri-user-3-line' }, - { value: 'liam-brooks', label: 'Liam Brooks', meta: 'Prompt engineer · liam@example.com', icon: 'i-ri-user-3-line' }, - { value: 'nora-park', label: 'Nora Park', meta: 'Data steward · nora@example.com', icon: 'i-ri-user-3-line' }, - { value: 'owen-reed', label: 'Owen Reed', meta: 'Security reviewer · owen@example.com', icon: 'i-ri-shield-user-line' }, - { value: 'yuki-tanaka', label: 'Yuki Tanaka', meta: 'ML engineer · yuki@example.com', icon: 'i-ri-user-3-line' }, - { value: 'ava-martin', label: 'Ava Martin', meta: 'Support lead · ava@example.com', icon: 'i-ri-customer-service-2-line' }, + { + value: 'maya-chen', + label: 'Maya Chen', + meta: 'Product owner · maya@example.com', + icon: 'i-ri-user-3-line', + }, + { + value: 'liam-brooks', + label: 'Liam Brooks', + meta: 'Prompt engineer · liam@example.com', + icon: 'i-ri-user-3-line', + }, + { + value: 'nora-park', + label: 'Nora Park', + meta: 'Data steward · nora@example.com', + icon: 'i-ri-user-3-line', + }, + { + value: 'owen-reed', + label: 'Owen Reed', + meta: 'Security reviewer · owen@example.com', + icon: 'i-ri-shield-user-line', + }, + { + value: 'yuki-tanaka', + label: 'Yuki Tanaka', + meta: 'ML engineer · yuki@example.com', + icon: 'i-ri-user-3-line', + }, + { + value: 'ava-martin', + label: 'Ava Martin', + meta: 'Support lead · ava@example.com', + icon: 'i-ri-customer-service-2-line', + }, ] const emptyOptions: Option[] = [ @@ -161,13 +237,14 @@ const modelCatalogOptions: Option[] = Array.from({ length: 1000 }, (_, index) => value: `model-${index + 1}`, label: `${provider} ${family} ${number}`, meta: `${provider} provider · ${family}`, - icon: family === 'embedding' - ? 'i-ri-vector-triangle' - : family === 'vision' - ? 'i-ri-image-circle-line' - : family === 'reasoning' - ? 'i-ri-brain-line' - : 'i-ri-chat-1-line', + icon: + family === 'embedding' + ? 'i-ri-vector-triangle' + : family === 'vision' + ? 'i-ri-image-circle-line' + : family === 'reasoning' + ? 'i-ri-brain-line' + : 'i-ri-chat-1-line', } }) @@ -188,8 +265,8 @@ async function searchOptions( options: Option[], query: string, filter: (item: string, query: string) => boolean, -): Promise<{ items: Option[], error: string | null }> { - await new Promise(resolve => window.setTimeout(resolve, 450)) +): Promise<{ items: Option[]; error: string | null }> { + await new Promise((resolve) => window.setTimeout(resolve, 450)) if (query === 'will_error') { return { @@ -199,21 +276,29 @@ async function searchOptions( } return { - items: options.filter(option => ( - filter(option.label, query) - || (option.meta ? filter(option.meta, query) : false) - )), + items: options.filter( + (option) => filter(option.label, query) || (option.meta ? filter(option.meta, query) : false), + ), error: null, } } const renderOptionItem = (option: Option) => ( - <ComboboxItem key={option.value} value={option} disabled={option.disabled} className="h-auto min-h-8 py-1.5"> + <ComboboxItem + key={option.value} + value={option} + disabled={option.disabled} + className="h-auto min-h-8 py-1.5" + > <ComboboxItemText className="flex items-center gap-2 px-0"> - {option.icon && <span aria-hidden className={cn(option.icon, 'size-4 shrink-0 text-text-tertiary')} />} + {option.icon && ( + <span aria-hidden className={cn(option.icon, 'size-4 shrink-0 text-text-tertiary')} /> + )} <span className="min-w-0 flex-1"> <span className="block truncate system-sm-medium text-text-secondary">{option.label}</span> - {option.meta && <span className="block truncate system-xs-regular text-text-tertiary">{option.meta}</span>} + {option.meta && ( + <span className="block truncate system-xs-regular text-text-tertiary">{option.meta}</span> + )} </span> </ComboboxItemText> <ComboboxItemIndicator /> @@ -229,29 +314,40 @@ const renderSimpleOptionItem = (option: Option) => ( // Only virtualized items receive an explicit index; ordinary lists must let Base UI register items by DOM order for keyboard navigation. const renderVirtualizedOptionItem = (option: Option, index: number) => ( - <ComboboxItem key={option.value} value={option} index={index} disabled={option.disabled} className="h-auto min-h-8 py-1.5"> + <ComboboxItem + key={option.value} + value={option} + index={index} + disabled={option.disabled} + className="h-auto min-h-8 py-1.5" + > <ComboboxItemText className="flex items-center gap-2 px-0"> - {option.icon && <span aria-hidden className={cn(option.icon, 'size-4 shrink-0 text-text-tertiary')} />} + {option.icon && ( + <span aria-hidden className={cn(option.icon, 'size-4 shrink-0 text-text-tertiary')} /> + )} <span className="min-w-0 flex-1"> <span className="block truncate system-sm-medium text-text-secondary">{option.label}</span> - {option.meta && <span className="block truncate system-xs-regular text-text-tertiary">{option.meta}</span>} + {option.meta && ( + <span className="block truncate system-xs-regular text-text-tertiary">{option.meta}</span> + )} </span> </ComboboxItemText> <ComboboxItemIndicator /> </ComboboxItem> ) -const PopupSearchInput = ({ - label, - placeholder, -}: { - label: string - placeholder: string -}) => ( +const PopupSearchInput = ({ label, placeholder }: { label: string; placeholder: string }) => ( <div className="p-1 pb-0"> <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" /> - <ComboboxInput aria-label={label} placeholder={`${placeholder}…`} className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" /> + <span + aria-hidden + className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" + /> + <ComboboxInput + aria-label={label} + placeholder={`${placeholder}…`} + className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" + /> <ComboboxClear className="mr-0" /> </ComboboxInputGroup> </div> @@ -266,9 +362,7 @@ const GroupedToolList = () => { <ComboboxGroup key={group.label} items={group.items}> {groupIndex > 0 && <ComboboxSeparator />} <ComboboxGroupLabel>{group.label}</ComboboxGroupLabel> - <ComboboxCollection> - {(option: Option) => renderOptionItem(option)} - </ComboboxCollection> + <ComboboxCollection>{(option: Option) => renderOptionItem(option)}</ComboboxCollection> </ComboboxGroup> ))} </ComboboxList> @@ -311,8 +405,7 @@ const VirtualizedModelList = ({ {virtualizer.getVirtualItems().map((virtualItem) => { const option = filteredItems[virtualItem.index] - if (!option) - return null + if (!option) return null return ( <div @@ -337,9 +430,7 @@ const FilteredModelStatus = () => { return ( <ComboboxStatus className="border-y border-divider-subtle px-2 py-1 text-text-quaternary tabular-nums"> - {filteredItems.length} - {' '} - matching models + {filteredItems.length} matching models </ComboboxStatus> ) } @@ -384,31 +475,28 @@ const AsyncDirectoryDemo = () => { const abortControllerRef = React.useRef<AbortController | null>(null) const trimmedSearchValue = searchValue.trim() const items = React.useMemo(() => { - if (!selectedValue || searchResults.some(option => option.value === selectedValue.value)) + if (!selectedValue || searchResults.some((option) => option.value === selectedValue.value)) return searchResults return [...searchResults, selectedValue] }, [searchResults, selectedValue]) const status = (() => { - if (isPending) - return 'Searching directory matches…' + if (isPending) return 'Searching directory matches…' - if (error) - return error + if (error) return error - if (trimmedSearchValue === '') - return selectedValue ? null : 'Start typing to search owners…' + if (trimmedSearchValue === '') return selectedValue ? null : 'Start typing to search owners…' - if (searchResults.length === 0) - return `No matches for "${trimmedSearchValue}".` + if (searchResults.length === 0) return `No matches for "${trimmedSearchValue}".` return `${searchResults.length} owner${searchResults.length === 1 ? '' : 's'} found` })() - const emptyMessage = trimmedSearchValue === '' || isPending || searchResults.length > 0 || error - ? null - : 'Try a different owner search.' + const emptyMessage = + trimmedSearchValue === '' || isPending || searchResults.length > 0 || error + ? null + : 'Try a different owner search.' return ( <Field name="owner" className={fieldWidth}> @@ -419,8 +507,7 @@ const AsyncDirectoryDemo = () => { filter={null} value={selectedValue} onOpenChangeComplete={(open) => { - if (!open && selectedValue) - setSearchResults([selectedValue]) + if (!open && selectedValue) setSearchResults([selectedValue]) }} onValueChange={(nextSelectedValue) => { setSelectedValue(nextSelectedValue) @@ -436,8 +523,7 @@ const AsyncDirectoryDemo = () => { return } - if (reason === 'item-press') - return + if (reason === 'item-press') return const controller = new AbortController() abortControllerRef.current?.abort() @@ -448,8 +534,7 @@ const AsyncDirectoryDemo = () => { const result = await searchOptions(directoryOptions, nextSearchValue, contains) - if (controller.signal.aborted) - return + if (controller.signal.aborted) return startTransition(() => { setSearchResults(result.items) @@ -459,15 +544,22 @@ const AsyncDirectoryDemo = () => { }} > <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" /> - <ComboboxInput placeholder="Search owners…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" /> + <span + aria-hidden + className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" + /> + <ComboboxInput + placeholder="Search owners…" + className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" + /> <ComboboxClear className="mr-0.5" /> <ComboboxInputTrigger className="mr-0" /> </ComboboxInputGroup> - <ComboboxContent popupClassName="w-[420px]" popupProps={{ 'aria-busy': isPending || undefined }}> - <ComboboxStatus className="border-b border-divider-subtle"> - {status} - </ComboboxStatus> + <ComboboxContent + popupClassName="w-[420px]" + popupProps={{ 'aria-busy': isPending || undefined }} + > + <ComboboxStatus className="border-b border-divider-subtle">{status}</ComboboxStatus> <ComboboxList>{renderOptionItem}</ComboboxList> <ComboboxEmpty>{emptyMessage}</ComboboxEmpty> </ComboboxContent> @@ -489,25 +581,21 @@ const AsyncReviewerDemo = () => { const trimmedSearchValue = searchValue.trim() const items = React.useMemo(() => { - if (selectedValues.length === 0) - return searchResults + if (selectedValues.length === 0) return searchResults const merged = [...searchResults] selectedValues.forEach((selected) => { - if (!searchResults.some(result => result.value === selected.value)) - merged.push(selected) + if (!searchResults.some((result) => result.value === selected.value)) merged.push(selected) }) return merged }, [searchResults, selectedValues]) const status = (() => { - if (isPending) - return 'Searching reviewer matches…' + if (isPending) return 'Searching reviewer matches…' - if (error) - return error + if (error) return error if (trimmedSearchValue === '' && !blockStartStatus) return selectedValues.length > 0 ? null : 'Start typing to search reviewers…' @@ -518,9 +606,10 @@ const AsyncReviewerDemo = () => { return `${searchResults.length} reviewer${searchResults.length === 1 ? '' : 's'} found` })() - const emptyMessage = trimmedSearchValue === '' || isPending || searchResults.length > 0 || error - ? null - : 'Try a different reviewer search.' + const emptyMessage = + trimmedSearchValue === '' || isPending || searchResults.length > 0 || error + ? null + : 'Try a different reviewer search.' return ( <Field name="asyncReviewers" className={fieldWidth}> @@ -546,8 +635,7 @@ const AsyncReviewerDemo = () => { if (nextSelectedValues.length === 0) { setSearchResults([]) setBlockStartStatus(false) - } - else { + } else { setBlockStartStatus(true) } }} @@ -565,16 +653,14 @@ const AsyncReviewerDemo = () => { return } - if (reason === 'item-press') - return + if (reason === 'item-press') return startTransition(async () => { setError(null) const result = await searchOptions(reviewerOptions, nextSearchValue, contains) - if (controller.signal.aborted) - return + if (controller.signal.aborted) return startTransition(() => { setSearchResults(result.items) @@ -588,27 +674,33 @@ const AsyncReviewerDemo = () => { <ComboboxValue> {(selectedValue: Option[]) => ( <React.Fragment> - {selectedValue.map(item => ( + {selectedValue.map((item) => ( <ComboboxChip key={item.value} aria-label={item.label}> <span className="max-w-32 truncate">{item.label}</span> <ComboboxChipRemove aria-label={`Remove ${item.label}`} /> </ComboboxChip> ))} - <ComboboxInput placeholder={selectedValue.length ? '' : 'Search reviewers…'} className="min-w-24 px-1 py-0.5" /> + <ComboboxInput + placeholder={selectedValue.length ? '' : 'Search reviewers…'} + className="min-w-24 px-1 py-0.5" + /> </React.Fragment> )} </ComboboxValue> </ComboboxChips> </ComboboxInputGroup> - <ComboboxContent popupClassName="w-[420px]" popupProps={{ 'aria-busy': isPending || undefined }}> - <ComboboxStatus className="border-b border-divider-subtle"> - {status} - </ComboboxStatus> + <ComboboxContent + popupClassName="w-[420px]" + popupProps={{ 'aria-busy': isPending || undefined }} + > + <ComboboxStatus className="border-b border-divider-subtle">{status}</ComboboxStatus> <ComboboxList>{renderOptionItem}</ComboboxList> <ComboboxEmpty>{emptyMessage}</ComboboxEmpty> </ComboboxContent> </Combobox> - <FieldDescription>Selected reviewers stay available while async matches change.</FieldDescription> + <FieldDescription> + Selected reviewers stay available while async matches change. + </FieldDescription> </Field> ) } @@ -620,7 +712,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound combobox built on Base UI Combobox for searchable predefined selections. Compose triggers, inputs, lists, groups, status, empty states, and chips without importing Base UI primitives directly.', + component: + 'Compound combobox built on Base UI Combobox for searchable predefined selections. Compose triggers, inputs, lists, groups, status, empty states, and chips without importing Base UI primitives directly.', }, }, }, @@ -636,8 +729,14 @@ export const Default: Story = { <FieldLabel>Connect source</FieldLabel> <Combobox items={dataSourceOptions} defaultValue={defaultDataSource}> <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" /> - <ComboboxInput placeholder="Search data sources…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" /> + <span + aria-hidden + className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" + /> + <ComboboxInput + placeholder="Search data sources…" + className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" + /> <ComboboxClear className="mr-0.5" /> <ComboboxInputTrigger className="mr-0" /> </ComboboxInputGroup> @@ -655,8 +754,14 @@ export const FormField: Story = { <FieldLabel>Connect source</FieldLabel> <Combobox items={dataSourceOptions} defaultValue={defaultDataSource}> <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" /> - <ComboboxInput placeholder="Search data sources…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" /> + <span + aria-hidden + className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" + /> + <ComboboxInput + placeholder="Search data sources…" + className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" + /> <ComboboxClear className="mr-0.5" /> <ComboboxInputTrigger className="mr-0" /> </ComboboxInputGroup> @@ -697,12 +802,15 @@ export const AsyncSearchMultiple: Story = { export const Sizes: Story = { render: () => ( <div className="flex w-80 flex-col gap-3"> - {(['small', 'medium', 'large'] as const).map(size => ( + {(['small', 'medium', 'large'] as const).map((size) => ( <Field key={size} name={`provider-${size}`}> <FieldLabel>{`${size[0]!.toUpperCase()}${size.slice(1)}`}</FieldLabel> <Combobox items={sizeOptions} defaultValue={defaultProvider}> <ComboboxInputGroup size={size} className="px-2"> - <span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" /> + <span + aria-hidden + className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" + /> <ComboboxInput size={size} placeholder="Search providers…" className="px-1" /> <ComboboxClear size={size} className="mr-0.5" /> <ComboboxInputTrigger size={size} className="mr-0" /> @@ -746,13 +854,16 @@ const MultipleChipsDemo = () => { <ComboboxValue> {(selectedValue: Option[]) => ( <React.Fragment> - {selectedValue.map(item => ( + {selectedValue.map((item) => ( <ComboboxChip key={item.value}> <span className="max-w-32 truncate">{item.label}</span> <ComboboxChipRemove aria-label={`Remove ${item.label}`} /> </ComboboxChip> ))} - <ComboboxInput placeholder={selectedValue.length ? '' : 'Assign reviewers…'} className="min-w-24 px-1 py-0.5" /> + <ComboboxInput + placeholder={selectedValue.length ? '' : 'Assign reviewers…'} + className="min-w-24 px-1 py-0.5" + /> </React.Fragment> )} </ComboboxValue> @@ -762,7 +873,9 @@ const MultipleChipsDemo = () => { <ComboboxList>{renderOptionItem}</ComboboxList> </ComboboxContent> </Combobox> - <FieldDescription>Selected reviewers wrap inside the input instead of scrolling horizontally.</FieldDescription> + <FieldDescription> + Selected reviewers wrap inside the input instead of scrolling horizontally. + </FieldDescription> </Field> ) } @@ -790,8 +903,14 @@ export const EmptyAndStatus: Story = { <FieldLabel>Connector</FieldLabel> <Combobox items={emptyOptions} defaultInputValue="salesforce"> <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" /> - <ComboboxInput placeholder="Search connectors…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" /> + <span + aria-hidden + className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" + /> + <ComboboxInput + placeholder="Search connectors…" + className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" + /> <ComboboxClear className="mr-0.5" /> <ComboboxInputTrigger className="mr-0" /> </ComboboxInputGroup> @@ -824,7 +943,10 @@ export const DisabledAndReadOnly: Story = { <FieldLabel>Read-only source</FieldLabel> <Combobox items={dataSourceOptions} defaultValue={readOnlyDataSource} readOnly> <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <ComboboxInput placeholder="Read-only data source…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" /> + <ComboboxInput + placeholder="Read-only data source…" + className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" + /> <ComboboxClear className="mr-0.5" /> <ComboboxInputTrigger className="mr-0" /> </ComboboxInputGroup> @@ -855,9 +977,7 @@ const ControlledDemo = () => { </Combobox> </div> <span className="rounded-md border border-divider-subtle bg-components-panel-bg px-2 py-1 system-xs-regular text-text-tertiary"> - Selected: - {' '} - {value?.label ?? 'None'} + Selected: {value?.label ?? 'None'} </span> </div> ) diff --git a/packages/dify-ui/src/combobox/index.tsx b/packages/dify-ui/src/combobox/index.tsx index cb5deb51edb695..e588c9554f073e 100644 --- a/packages/dify-ui/src/combobox/index.tsx +++ b/packages/dify-ui/src/combobox/index.tsx @@ -17,8 +17,10 @@ import { parsePlacement } from '../placement' export type { Placement } -export type ComboboxProps<Value, Multiple extends boolean | undefined = false> - = BaseCombobox.Root.Props<Value, Multiple> +export type ComboboxProps< + Value, + Multiple extends boolean | undefined = false, +> = BaseCombobox.Root.Props<Value, Multiple> export function Combobox<Value, Multiple extends boolean | undefined = false>( props: ComboboxProps<Value, Multiple>, @@ -81,13 +83,11 @@ const comboboxTriggerVariants = cva( export type ComboboxSize = NonNullable<VariantProps<typeof comboboxTriggerVariants>['size']> -type ComboboxTriggerProps - = Omit<BaseCombobox.Trigger.Props, 'className'> - & VariantProps<typeof comboboxTriggerVariants> - & { - className?: string - icon?: React.ReactNode | false - } +type ComboboxTriggerProps = Omit<BaseCombobox.Trigger.Props, 'className'> & + VariantProps<typeof comboboxTriggerVariants> & { + className?: string + icon?: React.ReactNode | false + } export function ComboboxTrigger({ className, @@ -103,9 +103,7 @@ export function ComboboxTrigger({ className={cn(comboboxTriggerVariants({ size, className }))} {...props} > - <span className="min-w-0 grow truncate"> - {children} - </span> + <span className="min-w-0 grow truncate">{children}</span> {icon !== false && ( <BaseCombobox.Icon className="shrink-0 text-text-quaternary transition-colors group-hover/combobox-trigger:text-text-secondary group-data-popup-open/combobox-trigger:text-text-secondary group-data-readonly/combobox-trigger:hidden"> {icon ?? <span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />} @@ -141,9 +139,8 @@ const comboboxInputGroupVariants = cva( }, ) -export type ComboboxInputGroupProps - = BaseCombobox.InputGroup.Props - & VariantProps<typeof comboboxInputGroupVariants> +export type ComboboxInputGroupProps = BaseCombobox.InputGroup.Props & + VariantProps<typeof comboboxInputGroupVariants> export function ComboboxInputGroup({ className, @@ -179,9 +176,8 @@ const comboboxInputVariants = cva( }, ) -export type ComboboxInputProps - = Omit<BaseCombobox.Input.Props, 'size'> - & VariantProps<typeof comboboxInputVariants> +export type ComboboxInputProps = Omit<BaseCombobox.Input.Props, 'size'> & + VariantProps<typeof comboboxInputVariants> export function ComboboxInput({ className, @@ -224,10 +220,8 @@ const comboboxControlVariants = cva( }, ) -export type ComboboxClearProps - = Omit<BaseCombobox.Clear.Props, 'className'> - & VariantProps<typeof comboboxControlVariants> - & { className?: string } +export type ComboboxClearProps = Omit<BaseCombobox.Clear.Props, 'className'> & + VariantProps<typeof comboboxControlVariants> & { className?: string } export function ComboboxClear({ className, @@ -252,10 +246,8 @@ export function ComboboxClear({ ) } -export type ComboboxInputTriggerProps - = Omit<BaseCombobox.Trigger.Props, 'className'> - & VariantProps<typeof comboboxControlVariants> - & { className?: string } +export type ComboboxInputTriggerProps = Omit<BaseCombobox.Trigger.Props, 'className'> & + VariantProps<typeof comboboxControlVariants> & { className?: string } export function ComboboxInputTrigger({ className, @@ -267,7 +259,9 @@ export function ComboboxInputTrigger({ return ( <BaseCombobox.Trigger type={type} - aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Open combobox options')} + aria-label={ + props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Open combobox options') + } className={cn(comboboxControlVariants({ size }), className)} {...props} > @@ -276,11 +270,7 @@ export function ComboboxInputTrigger({ ) } -export function ComboboxIcon({ - className, - children, - ...props -}: BaseCombobox.Icon.Props) { +export function ComboboxIcon({ className, children, ...props }: BaseCombobox.Icon.Props) { return ( <BaseCombobox.Icon className={cn('flex shrink-0 items-center text-text-tertiary', className)} @@ -303,10 +293,7 @@ type ComboboxContentProps = { BaseCombobox.Positioner.Props, 'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset' > - popupProps?: Omit< - BaseCombobox.Popup.Props, - 'children' | 'className' - > + popupProps?: Omit<BaseCombobox.Popup.Props, 'children' | 'className'> } export function ComboboxContent({ @@ -333,11 +320,7 @@ export function ComboboxContent({ {...positionerProps} > <BaseCombobox.Popup - className={cn( - comboboxPopupClassName, - overlayPopupAnimationClassName, - popupClassName, - )} + className={cn(comboboxPopupClassName, overlayPopupAnimationClassName, popupClassName)} {...popupProps} > {children} @@ -347,41 +330,19 @@ export function ComboboxContent({ ) } -export function ComboboxList({ - className, - ...props -}: BaseCombobox.List.Props) { - return ( - <BaseCombobox.List - className={cn(comboboxListClassName, className)} - {...props} - /> - ) +export function ComboboxList({ className, ...props }: BaseCombobox.List.Props) { + return <BaseCombobox.List className={cn(comboboxListClassName, className)} {...props} /> } -export function ComboboxItem({ - className, - ...props -}: BaseCombobox.Item.Props) { - return ( - <BaseCombobox.Item - className={cn(comboboxItemClassName, className)} - {...props} - /> - ) +export function ComboboxItem({ className, ...props }: BaseCombobox.Item.Props) { + return <BaseCombobox.Item className={cn(comboboxItemClassName, className)} {...props} /> } export type ComboboxItemTextProps = React.ComponentProps<'span'> -export function ComboboxItemText({ - className, - ...props -}: ComboboxItemTextProps) { +export function ComboboxItemText({ className, ...props }: ComboboxItemTextProps) { return ( - <span - className={cn('min-w-0 grow truncate px-1 system-sm-medium', className)} - {...props} - /> + <span className={cn('min-w-0 grow truncate px-1 system-sm-medium', className)} {...props} /> ) } @@ -391,67 +352,37 @@ export function ComboboxItemIndicator({ ...props }: Omit<BaseCombobox.ItemIndicator.Props, 'children'> & { children?: React.ReactNode }) { return ( - <BaseCombobox.ItemIndicator - className={cn(overlayIndicatorClassName, className)} - {...props} - > + <BaseCombobox.ItemIndicator className={cn(overlayIndicatorClassName, className)} {...props}> {children ?? <span className="i-ri-check-line h-4 w-4" aria-hidden="true" />} </BaseCombobox.ItemIndicator> ) } -export function ComboboxLabel({ - className, - ...props -}: BaseCombobox.Label.Props) { - return ( - <BaseCombobox.Label - className={cn(formLabelClassName, className)} - {...props} - /> - ) +export function ComboboxLabel({ className, ...props }: BaseCombobox.Label.Props) { + return <BaseCombobox.Label className={cn(formLabelClassName, className)} {...props} /> } -export function ComboboxGroupLabel({ - className, - ...props -}: BaseCombobox.GroupLabel.Props) { - return ( - <BaseCombobox.GroupLabel - className={cn(overlayLabelClassName, className)} - {...props} - /> - ) +export function ComboboxGroupLabel({ className, ...props }: BaseCombobox.GroupLabel.Props) { + return <BaseCombobox.GroupLabel className={cn(overlayLabelClassName, className)} {...props} /> } -export function ComboboxSeparator({ - className, - ...props -}: BaseCombobox.Separator.Props) { - return ( - <BaseCombobox.Separator - className={cn(overlaySeparatorClassName, className)} - {...props} - /> - ) +export function ComboboxSeparator({ className, ...props }: BaseCombobox.Separator.Props) { + return <BaseCombobox.Separator className={cn(overlaySeparatorClassName, className)} {...props} /> } -export function ComboboxEmpty({ - className, - ...props -}: BaseCombobox.Empty.Props) { +export function ComboboxEmpty({ className, ...props }: BaseCombobox.Empty.Props) { return ( <BaseCombobox.Empty - className={cn('px-3 py-2 system-sm-regular text-text-tertiary empty:h-0 empty:p-0', className)} + className={cn( + 'px-3 py-2 system-sm-regular text-text-tertiary empty:h-0 empty:p-0', + className, + )} {...props} /> ) } -export function ComboboxStatus({ - className, - ...props -}: BaseCombobox.Status.Props) { +export function ComboboxStatus({ className, ...props }: BaseCombobox.Status.Props) { return ( <BaseCombobox.Status className={cn('px-3 py-2 system-sm-regular text-text-tertiary', className)} @@ -460,10 +391,7 @@ export function ComboboxStatus({ ) } -export function ComboboxChips({ - className, - ...props -}: BaseCombobox.Chips.Props) { +export function ComboboxChips({ className, ...props }: BaseCombobox.Chips.Props) { return ( <BaseCombobox.Chips className={cn('flex w-full min-w-0 flex-wrap items-center gap-1 px-1', className)} @@ -472,13 +400,13 @@ export function ComboboxChips({ ) } -export function ComboboxChip({ - className, - ...props -}: BaseCombobox.Chip.Props) { +export function ComboboxChip({ className, ...props }: BaseCombobox.Chip.Props) { return ( <BaseCombobox.Chip - className={cn('inline-flex max-w-full min-w-0 items-center gap-1 rounded-md bg-state-base-hover px-1.5 py-0.5 system-xs-medium text-text-secondary', className)} + className={cn( + 'inline-flex max-w-full min-w-0 items-center gap-1 rounded-md bg-state-base-hover px-1.5 py-0.5 system-xs-medium text-text-secondary', + className, + )} {...props} /> ) @@ -493,8 +421,13 @@ export function ComboboxChipRemove({ return ( <BaseCombobox.ChipRemove type={type} - aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Remove selected item')} - className={cn('flex size-3.5 shrink-0 items-center justify-center rounded-sm text-text-tertiary outline-hidden hover:bg-state-base-hover-alt hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid', className)} + aria-label={ + props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Remove selected item') + } + className={cn( + 'flex size-3.5 shrink-0 items-center justify-center rounded-sm text-text-tertiary outline-hidden hover:bg-state-base-hover-alt hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid', + className, + )} {...props} > {children ?? <span className="i-ri-close-line size-3" aria-hidden="true" />} diff --git a/packages/dify-ui/src/context-menu/__tests__/index.spec.tsx b/packages/dify-ui/src/context-menu/__tests__/index.spec.tsx index f19c5842cffcc2..4e6c7c79e4e6c7 100644 --- a/packages/dify-ui/src/context-menu/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/context-menu/__tests__/index.spec.tsx @@ -12,11 +12,8 @@ import { ContextMenuTrigger, } from '../index' -const renderWithSafeViewport = (ui: React.ReactNode) => render( - <div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}> - {ui} - </div>, -) +const renderWithSafeViewport = (ui: React.ReactNode) => + render(<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>{ui}</div>) describe('context-menu wrapper', () => { describe('ContextMenuContent', () => { @@ -24,15 +21,23 @@ describe('context-menu wrapper', () => { const screen = await renderWithSafeViewport( <ContextMenu open> <ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger> - <ContextMenuContent positionerProps={{ 'role': 'group', 'aria-label': 'content positioner' }}> + <ContextMenuContent + positionerProps={{ role: 'group', 'aria-label': 'content positioner' }} + > <ContextMenuItem>Content action</ContextMenuItem> </ContextMenuContent> </ContextMenu>, ) - await expect.element(screen.getByRole('group', { name: 'content positioner' })).toHaveAttribute('data-side', 'bottom') - await expect.element(screen.getByRole('group', { name: 'content positioner' })).toHaveAttribute('data-align', 'start') - await expect.element(screen.getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument() + await expect + .element(screen.getByRole('group', { name: 'content positioner' })) + .toHaveAttribute('data-side', 'bottom') + await expect + .element(screen.getByRole('group', { name: 'content positioner' })) + .toHaveAttribute('data-align', 'start') + await expect + .element(screen.getByRole('menuitem', { name: 'Content action' })) + .toBeInTheDocument() }) it('should apply custom top placement and keep point-anchor alignment stable when custom positioning props are provided', async () => { @@ -43,16 +48,22 @@ describe('context-menu wrapper', () => { placement="top-end" sideOffset={12} alignOffset={-3} - positionerProps={{ 'role': 'group', 'aria-label': 'custom content positioner' }} + positionerProps={{ role: 'group', 'aria-label': 'custom content positioner' }} > <ContextMenuItem>Custom content</ContextMenuItem> </ContextMenuContent> </ContextMenu>, ) - await expect.element(screen.getByRole('group', { name: 'custom content positioner' })).toHaveAttribute('data-side', 'top') - await expect.element(screen.getByRole('group', { name: 'custom content positioner' })).toHaveAttribute('data-align', 'start') - await expect.element(screen.getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument() + await expect + .element(screen.getByRole('group', { name: 'custom content positioner' })) + .toHaveAttribute('data-side', 'top') + await expect + .element(screen.getByRole('group', { name: 'custom content positioner' })) + .toHaveAttribute('data-align', 'start') + await expect + .element(screen.getByRole('menuitem', { name: 'Custom content' })) + .toBeInTheDocument() }) it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', async () => { @@ -64,10 +75,10 @@ describe('context-menu wrapper', () => { <ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger> <ContextMenuContent positionerProps={{ - 'role': 'group', + role: 'group', 'aria-label': 'context content positioner', - 'id': 'context-content-positioner', - 'onMouseEnter': handlePositionerMouseEnter, + id: 'context-content-positioner', + onMouseEnter: handlePositionerMouseEnter, }} popupProps={{ role: 'menu', @@ -83,7 +94,9 @@ describe('context-menu wrapper', () => { await screen.getByRole('group', { name: 'context content positioner' }).hover() await screen.getByRole('menu').click() - await expect.element(screen.getByRole('group', { name: 'context content positioner' })).toHaveAttribute('id', 'context-content-positioner') + await expect + .element(screen.getByRole('group', { name: 'context content positioner' })) + .toHaveAttribute('id', 'context-content-positioner') await expect.element(screen.getByRole('menu')).toHaveAttribute('id', 'context-content-popup') expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1) expect(handlePopupClick).toHaveBeenCalledTimes(1) @@ -98,7 +111,9 @@ describe('context-menu wrapper', () => { <ContextMenuContent> <ContextMenuSub open> <ContextMenuSubTrigger>More actions</ContextMenuSubTrigger> - <ContextMenuSubContent positionerProps={{ 'role': 'group', 'aria-label': 'sub positioner' }}> + <ContextMenuSubContent + positionerProps={{ role: 'group', 'aria-label': 'sub positioner' }} + > <ContextMenuItem>Sub action</ContextMenuItem> </ContextMenuSubContent> </ContextMenuSub> @@ -106,89 +121,110 @@ describe('context-menu wrapper', () => { </ContextMenu>, ) - await expect.element(screen.getByRole('group', { name: 'sub positioner' })).toHaveAttribute('data-side', 'right') - await expect.element(screen.getByRole('group', { name: 'sub positioner' })).toHaveAttribute('data-align', 'start') + await expect + .element(screen.getByRole('group', { name: 'sub positioner' })) + .toHaveAttribute('data-side', 'right') + await expect + .element(screen.getByRole('group', { name: 'sub positioner' })) + .toHaveAttribute('data-align', 'start') await expect.element(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument() }) }) describe('variant prop behavior', () => { - it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant on item when variant is %s', async (variant) => { - const handleClick = vi.fn() - - const screen = await render( - <ContextMenu open> - <ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem - variant={variant} - aria-label="menu action" - id={`context-item-${variant}`} - onClick={handleClick} - > - Item label - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu>, - ) - - await screen.getByRole('menuitem', { name: 'menu action' }).click() - await expect.element(screen.getByRole('menuitem', { name: 'menu action' })).toHaveAttribute('id', `context-item-${variant}`) - await expect.element(screen.getByRole('menuitem', { name: 'menu action' })).toHaveAttribute('data-variant', variant) - expect(handleClick).toHaveBeenCalledTimes(1) - }) + it.each(['default', 'destructive'] as const)( + 'should remain interactive and set data-variant on item when variant is %s', + async (variant) => { + const handleClick = vi.fn() - it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant on submenu trigger when variant is %s', async (variant) => { - const handleClick = vi.fn() - - const screen = await render( - <ContextMenu open> - <ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuSub open> - <ContextMenuSubTrigger + const screen = await render( + <ContextMenu open> + <ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem variant={variant} - aria-label="submenu action" - id={`context-sub-${variant}`} + aria-label="menu action" + id={`context-item-${variant}`} onClick={handleClick} > - Trigger item - </ContextMenuSubTrigger> - </ContextMenuSub> - </ContextMenuContent> - </ContextMenu>, - ) + Item label + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu>, + ) - await screen.getByRole('menuitem', { name: 'submenu action' }).click() - await expect.element(screen.getByRole('menuitem', { name: 'submenu action' })).toHaveAttribute('id', `context-sub-${variant}`) - await expect.element(screen.getByRole('menuitem', { name: 'submenu action' })).toHaveAttribute('data-variant', variant) - expect(handleClick).toHaveBeenCalledTimes(1) - }) + await screen.getByRole('menuitem', { name: 'menu action' }).click() + await expect + .element(screen.getByRole('menuitem', { name: 'menu action' })) + .toHaveAttribute('id', `context-item-${variant}`) + await expect + .element(screen.getByRole('menuitem', { name: 'menu action' })) + .toHaveAttribute('data-variant', variant) + expect(handleClick).toHaveBeenCalledTimes(1) + }, + ) - it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant on link item when variant is %s', async (variant) => { - const screen = await render( - <ContextMenu open> - <ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuLinkItem - variant={variant} - href="https://example.com/docs" - aria-label="context docs link" - id={`context-link-${variant}`} - target="_blank" - rel="noopener noreferrer" - > - Docs - </ContextMenuLinkItem> - </ContextMenuContent> - </ContextMenu>, - ) + it.each(['default', 'destructive'] as const)( + 'should remain interactive and set data-variant on submenu trigger when variant is %s', + async (variant) => { + const handleClick = vi.fn() - const link = screen.getByRole('menuitem', { name: 'context docs link' }).element() - expect(link.tagName.toLowerCase()).toBe('a') - expect(link).toHaveAttribute('id', `context-link-${variant}`) - expect(link).toHaveAttribute('data-variant', variant) - }) + const screen = await render( + <ContextMenu open> + <ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuSub open> + <ContextMenuSubTrigger + variant={variant} + aria-label="submenu action" + id={`context-sub-${variant}`} + onClick={handleClick} + > + Trigger item + </ContextMenuSubTrigger> + </ContextMenuSub> + </ContextMenuContent> + </ContextMenu>, + ) + + await screen.getByRole('menuitem', { name: 'submenu action' }).click() + await expect + .element(screen.getByRole('menuitem', { name: 'submenu action' })) + .toHaveAttribute('id', `context-sub-${variant}`) + await expect + .element(screen.getByRole('menuitem', { name: 'submenu action' })) + .toHaveAttribute('data-variant', variant) + expect(handleClick).toHaveBeenCalledTimes(1) + }, + ) + + it.each(['default', 'destructive'] as const)( + 'should remain interactive and set data-variant on link item when variant is %s', + async (variant) => { + const screen = await render( + <ContextMenu open> + <ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuLinkItem + variant={variant} + href="https://example.com/docs" + aria-label="context docs link" + id={`context-link-${variant}`} + target="_blank" + rel="noopener noreferrer" + > + Docs + </ContextMenuLinkItem> + </ContextMenuContent> + </ContextMenu>, + ) + + const link = screen.getByRole('menuitem', { name: 'context docs link' }).element() + expect(link.tagName.toLowerCase()).toBe('a') + expect(link).toHaveAttribute('id', `context-link-${variant}`) + expect(link).toHaveAttribute('data-variant', variant) + }, + ) }) describe('ContextMenuLinkItem close behavior', () => { @@ -219,22 +255,27 @@ describe('context-menu wrapper', () => { it('should open menu when right-clicking trigger area', async () => { const screen = await render( <ContextMenu> - <ContextMenuTrigger aria-label="context trigger area"> - Trigger area - </ContextMenuTrigger> + <ContextMenuTrigger aria-label="context trigger area">Trigger area</ContextMenuTrigger> <ContextMenuContent> <ContextMenuItem>Open on right click</ContextMenuItem> </ContextMenuContent> </ContextMenu>, ) - screen.getByLabelText('context trigger area').element().dispatchEvent(new MouseEvent('contextmenu', { - bubbles: true, - cancelable: true, - button: 2, - })) + screen + .getByLabelText('context trigger area') + .element() + .dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + button: 2, + }), + ) - await expect.element(screen.getByRole('menuitem', { name: 'Open on right click' })).toBeInTheDocument() + await expect + .element(screen.getByRole('menuitem', { name: 'Open on right click' })) + .toBeInTheDocument() }) }) @@ -251,8 +292,12 @@ describe('context-menu wrapper', () => { </ContextMenu>, ) - await expect.element(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument() - await expect.element(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument() + await expect + .element(screen.getByRole('menuitem', { name: 'First action' })) + .toBeInTheDocument() + await expect + .element(screen.getByRole('menuitem', { name: 'Second action' })) + .toBeInTheDocument() expect(screen.getByRole('separator').elements()).toHaveLength(1) }) }) diff --git a/packages/dify-ui/src/context-menu/index.stories.tsx b/packages/dify-ui/src/context-menu/index.stories.tsx index dcd58a1da21bc8..de1ff794a288c3 100644 --- a/packages/dify-ui/src/context-menu/index.stories.tsx +++ b/packages/dify-ui/src/context-menu/index.stories.tsx @@ -35,7 +35,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound context menu built on Base UI ContextMenu. Open by right-clicking the trigger area.', + component: + 'Compound context menu built on Base UI ContextMenu. Open by right-clicking the trigger area.', }, }, }, @@ -167,11 +168,20 @@ export const WithLinkItems: Story = { <ContextMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank"> Dify Docs </ContextMenuLinkItem> - <ContextMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank"> + <ContextMenuLinkItem + href="https://roadmap.dify.ai" + rel="noopener noreferrer" + target="_blank" + > Product Roadmap </ContextMenuLinkItem> <ContextMenuSeparator /> - <ContextMenuLinkItem variant="destructive" href="https://example.com/delete" rel="noopener noreferrer" target="_blank"> + <ContextMenuLinkItem + variant="destructive" + href="https://example.com/delete" + rel="noopener noreferrer" + target="_blank" + > Dangerous External Action </ContextMenuLinkItem> </ContextMenuContent> diff --git a/packages/dify-ui/src/context-menu/index.tsx b/packages/dify-ui/src/context-menu/index.tsx index 9c43402961a8ef..d19cacd7e9ab93 100644 --- a/packages/dify-ui/src/context-menu/index.tsx +++ b/packages/dify-ui/src/context-menu/index.tsx @@ -37,10 +37,7 @@ type ContextMenuContentProps = { BaseContextMenu.Positioner.Props, 'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset' > - popupProps?: Omit< - BaseContextMenu.Popup.Props, - 'children' | 'className' - > + popupProps?: Omit<BaseContextMenu.Popup.Props, 'children' | 'className'> } type ContextMenuPopupRenderProps = Required<Pick<ContextMenuContentProps, 'children'>> & { @@ -76,11 +73,7 @@ function renderContextMenuPopup({ {...positionerProps} > <BaseContextMenu.Popup - className={cn( - overlayPopupBaseClassName, - overlayPopupAnimationClassName, - popupClassName, - )} + className={cn(overlayPopupBaseClassName, overlayPopupAnimationClassName, popupClassName)} {...popupProps} > {children} @@ -150,28 +143,15 @@ export function ContextMenuLinkItem({ ) } -export function ContextMenuRadioItem({ - className, - ...props -}: BaseContextMenu.RadioItem.Props) { - return ( - <BaseContextMenu.RadioItem - className={cn(overlayRowClassName, className)} - {...props} - /> - ) +export function ContextMenuRadioItem({ className, ...props }: BaseContextMenu.RadioItem.Props) { + return <BaseContextMenu.RadioItem className={cn(overlayRowClassName, className)} {...props} /> } export function ContextMenuCheckboxItem({ className, ...props }: BaseContextMenu.CheckboxItem.Props) { - return ( - <BaseContextMenu.CheckboxItem - className={cn(overlayRowClassName, className)} - {...props} - /> - ) + return <BaseContextMenu.CheckboxItem className={cn(overlayRowClassName, className)} {...props} /> } export function ContextMenuCheckboxItemIndicator({ @@ -219,7 +199,10 @@ export function ContextMenuSubTrigger({ {...props} > {children} - <span aria-hidden className="ms-auto i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" /> + <span + aria-hidden + className="ms-auto i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" + /> </BaseContextMenu.SubmenuTrigger> ) } @@ -257,26 +240,12 @@ export function ContextMenuSubContent({ }) } -export function ContextMenuLabel({ - className, - ...props -}: BaseContextMenu.GroupLabel.Props) { - return ( - <BaseContextMenu.GroupLabel - className={cn(overlayLabelClassName, className)} - {...props} - /> - ) +export function ContextMenuLabel({ className, ...props }: BaseContextMenu.GroupLabel.Props) { + return <BaseContextMenu.GroupLabel className={cn(overlayLabelClassName, className)} {...props} /> } -export function ContextMenuSeparator({ - className, - ...props -}: BaseContextMenu.Separator.Props) { +export function ContextMenuSeparator({ className, ...props }: BaseContextMenu.Separator.Props) { return ( - <BaseContextMenu.Separator - className={cn(overlaySeparatorClassName, className)} - {...props} - /> + <BaseContextMenu.Separator className={cn(overlaySeparatorClassName, className)} {...props} /> ) } diff --git a/packages/dify-ui/src/dialog/__tests__/index.spec.tsx b/packages/dify-ui/src/dialog/__tests__/index.spec.tsx index e2126d1441eb4c..1c7906ad85cab6 100644 --- a/packages/dify-ui/src/dialog/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/dialog/__tests__/index.spec.tsx @@ -1,11 +1,5 @@ import { render } from 'vitest-browser-react' -import { - Dialog, - DialogCloseButton, - DialogContent, - DialogDescription, - DialogTitle, -} from '../index' +import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement @@ -49,7 +43,9 @@ describe('Dialog wrapper', () => { </Dialog>, ) - await expect.element(screen.getByRole('button', { name: 'Dismiss dialog' })).toBeInTheDocument() + await expect + .element(screen.getByRole('button', { name: 'Dismiss dialog' })) + .toBeInTheDocument() }) it('should render default close button label when aria-label is omitted', async () => { diff --git a/packages/dify-ui/src/dialog/index.stories.tsx b/packages/dify-ui/src/dialog/index.stories.tsx index 7946f6e11fae05..725cdd01874fa6 100644 --- a/packages/dify-ui/src/dialog/index.stories.tsx +++ b/packages/dify-ui/src/dialog/index.stories.tsx @@ -32,7 +32,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound modal dialog built on Base UI Dialog. Use it for focused flows that interrupt the user, such as editing settings, confirming non-destructive actions, or collecting short-form input. Compose `DialogTitle`, `DialogDescription`, and optional `DialogCloseButton` inside `DialogContent`.', + component: + 'Compound modal dialog built on Base UI Dialog. Use it for focused flows that interrupt the user, such as editing settings, confirming non-destructive actions, or collecting short-form input. Compose `DialogTitle`, `DialogDescription`, and optional `DialogCloseButton` inside `DialogContent`.', }, }, }, @@ -48,13 +49,7 @@ const releaseNoteItems = Array.from({ length: 24 }, (_, index) => ({ body: 'Refined a workflow behavior so long content naturally overflows and scrolls inside the dialog.', })) -function ReleaseNoteHeader({ - title, - description, -}: { - title: string - description: string -}) { +function ReleaseNoteHeader({ title, description }: { title: string; description: string }) { return ( <div className="flex shrink-0 flex-col gap-2 p-6 pr-12 pb-4"> <DialogTitle className="text-lg leading-7 font-semibold text-text-primary"> @@ -70,11 +65,9 @@ function ReleaseNoteHeader({ function ReleaseNoteSections() { return ( <div className="flex flex-col gap-3 px-6 py-2 text-sm leading-5 text-text-secondary"> - {releaseNoteItems.map(item => ( + {releaseNoteItems.map((item) => ( <section key={item.id} className="rounded-lg bg-background-default-subtle px-3 py-2"> - <h3 className="font-medium text-text-primary"> - {item.title} - </h3> + <h3 className="font-medium text-text-primary">{item.title}</h3> <p>{item.body}</p> </section> ))} @@ -82,16 +75,10 @@ function ReleaseNoteSections() { ) } -function ReleaseNoteFooter({ - onClose, -}: { - onClose: () => void -}) { +function ReleaseNoteFooter({ onClose }: { onClose: () => void }) { return ( <div className="flex shrink-0 justify-end border-t border-divider-subtle p-4"> - <Button onClick={onClose}> - Close - </Button> + <Button onClick={onClose}>Close</Button> </div> ) } @@ -99,11 +86,7 @@ function ReleaseNoteFooter({ export const Default: Story = { render: () => ( <Dialog> - <DialogTrigger - render={<Button />} - > - Open dialog - </DialogTrigger> + <DialogTrigger render={<Button />}>Open dialog</DialogTrigger> <DialogContent> <DialogCloseButton /> <div className="flex flex-col gap-2 pr-8"> @@ -118,11 +101,7 @@ export const Default: Story = { <label className="text-xs font-medium text-text-tertiary" htmlFor="invite-email"> Email address </label> - <Input - id="invite-email" - type="email" - placeholder="teammate@example.com" - /> + <Input id="invite-email" type="email" placeholder="teammate@example.com" /> </div> <div className="mt-6 flex items-center justify-end"> <Button variant="primary">Send invite</Button> @@ -143,7 +122,9 @@ export const Default: Story = { await userEvent.click(body.getByRole('button', { name: 'Close' })) await waitFor(async () => { - await expect(body.queryByRole('dialog', { name: 'Invite collaborators' })).not.toBeInTheDocument() + await expect( + body.queryByRole('dialog', { name: 'Invite collaborators' }), + ).not.toBeInTheDocument() }) }, } @@ -151,18 +132,15 @@ export const Default: Story = { export const WithoutCloseButton: Story = { render: () => ( <Dialog> - <DialogTrigger - render={<Button />} - > - Start onboarding - </DialogTrigger> + <DialogTrigger render={<Button />}>Start onboarding</DialogTrigger> <DialogContent> <div className="flex flex-col gap-2"> <DialogTitle className="text-lg leading-7 font-semibold text-text-primary"> Welcome to Dify </DialogTitle> <DialogDescription className="text-sm leading-5 text-text-secondary"> - Let's get your workspace ready. This takes about a minute and sets up your default models, datasets, and API keys. + Let's get your workspace ready. This takes about a minute and sets up your default + models, datasets, and API keys. </DialogDescription> </div> <div className="mt-6 flex items-center justify-end gap-2"> @@ -182,11 +160,7 @@ const ControlledDemo = () => { <Button variant="secondary" onClick={() => setOpen(true)}> Open controlled dialog </Button> - <span className="text-xs text-text-tertiary"> - State: - {' '} - {open ? 'open' : 'closed'} - </span> + <span className="text-xs text-text-tertiary">State: {open ? 'open' : 'closed'}</span> <Dialog open={open} onOpenChange={setOpen}> <DialogContent> <DialogCloseButton /> @@ -198,11 +172,7 @@ const ControlledDemo = () => { The workspace URL will stay the same, but the display name updates everywhere. </DialogDescription> </div> - <Input - type="text" - defaultValue="Acme Workspace" - className="mt-4" - /> + <Input type="text" defaultValue="Acme Workspace" className="mt-4" /> <div className="mt-6 flex items-center justify-end gap-2"> <Button variant="secondary" onClick={() => setOpen(false)}> Cancel @@ -232,11 +202,7 @@ const FormDialogDemo = () => { return ( <Dialog open={open} onOpenChange={setOpen} disablePointerDismissal> - <DialogTrigger - render={<Button />} - > - Configure API extension - </DialogTrigger> + <DialogTrigger render={<Button />}>Configure API extension</DialogTrigger> <DialogContent backdropProps={{ forceRender: true }} className="w-160"> <DialogCloseButton /> <div className="grid gap-2 pr-8"> @@ -310,16 +276,16 @@ const OutsideScrollingContentDemo = () => { return ( <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger - render={<Button />} - > - Review long release notes - </DialogTrigger> + <DialogTrigger render={<Button />}>Review long release notes</DialogTrigger> <DialogPortal> <DialogBackdrop className="duration-[600ms] ease-[cubic-bezier(0.22,1,0.36,1)] data-ending-style:duration-[350ms] data-ending-style:ease-[cubic-bezier(0.375,0.015,0.545,0.455)]" /> <DialogViewport className="group/dialog"> <ScrollAreaRoot className="h-full overscroll-contain group-data-ending-style/dialog:pointer-events-none"> - <ScrollAreaViewport aria-label="Scrollable dialog viewport" role="region" className="h-full max-h-full max-w-full overscroll-contain group-data-ending-style/dialog:pointer-events-none"> + <ScrollAreaViewport + aria-label="Scrollable dialog viewport" + role="region" + className="h-full max-h-full max-w-full overscroll-contain group-data-ending-style/dialog:pointer-events-none" + > <ScrollAreaContent className="flex min-h-full items-center justify-center px-4 py-16"> <DialogPopup ref={popupRef} @@ -353,11 +319,7 @@ export const OutsideScrollingContent: Story = { export const OutsidePopupElements: Story = { render: () => ( <Dialog> - <DialogTrigger - render={<Button />} - > - Open uncontained dialog - </DialogTrigger> + <DialogTrigger render={<Button />}>Open uncontained dialog</DialogTrigger> <DialogPortal> <DialogBackdrop className="min-h-dvh" /> <DialogViewport className="grid place-items-center px-4 py-12 xl:py-6"> @@ -365,15 +327,15 @@ export const OutsidePopupElements: Story = { <DialogCloseButton aria-label="Close" className="pointer-events-auto absolute -top-10 right-0 z-10 flex size-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg text-text-tertiary shadow-xs outline-hidden hover:bg-components-button-secondary-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid xl:top-0" - > - </DialogCloseButton> + ></DialogCloseButton> <div className="pointer-events-auto flex h-full w-full max-w-[70rem] flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl transition-[scale] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-starting-style/popup:scale-105"> <div className="grid gap-2"> <DialogTitle className="text-lg leading-7 font-semibold text-text-primary"> Knowledge review </DialogTitle> <DialogDescription className="text-sm leading-5 text-text-secondary"> - The close button is visually outside this panel but remains inside the popup for focus order and screen reader context. + The close button is visually outside this panel but remains inside the popup for + focus order and screen reader context. </DialogDescription> </div> <div className="mt-6 grid flex-1 place-items-center rounded-xl bg-background-default-subtle text-sm text-text-tertiary"> @@ -392,24 +354,22 @@ const InsideScrollingContentDemo = () => { return ( <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger - render={<Button />} - > - Review release notes - </DialogTrigger> + <DialogTrigger render={<Button />}>Review release notes</DialogTrigger> <DialogPortal> <DialogBackdrop /> <DialogViewport className="flex items-center justify-center overflow-hidden p-4"> - <DialogPopup - className="relative flex h-[min(44rem,calc(100dvh-2rem))] min-h-0 w-120 max-w-[calc(100vw-2rem)] flex-col overflow-hidden" - > + <DialogPopup className="relative flex h-[min(44rem,calc(100dvh-2rem))] min-h-0 w-120 max-w-[calc(100vw-2rem)] flex-col overflow-hidden"> <DialogCloseButton /> <ReleaseNoteHeader title="Release notes" description="Highlights from the latest workspace update." /> <ScrollAreaRoot className="relative flex min-h-0 flex-auto overflow-hidden"> - <ScrollAreaViewport aria-label="Release note improvements" role="region" className="h-full max-h-full max-w-full overflow-y-auto overscroll-contain"> + <ScrollAreaViewport + aria-label="Release note improvements" + role="region" + className="h-full max-h-full max-w-full overflow-y-auto overscroll-contain" + > <ScrollAreaContent> <ReleaseNoteSections /> </ScrollAreaContent> diff --git a/packages/dify-ui/src/dialog/index.tsx b/packages/dify-ui/src/dialog/index.tsx index 24fed0e23d263f..827c72a5e57203 100644 --- a/packages/dify-ui/src/dialog/index.tsx +++ b/packages/dify-ui/src/dialog/index.tsx @@ -14,10 +14,7 @@ type DialogBackdropProps = Omit<BaseDialog.Backdrop.Props, 'className'> & { className?: string } -export function DialogBackdrop({ - className, - ...props -}: DialogBackdropProps) { +export function DialogBackdrop({ className, ...props }: DialogBackdropProps) { return ( <BaseDialog.Backdrop {...props} @@ -34,26 +31,15 @@ type DialogViewportProps = Omit<BaseDialog.Viewport.Props, 'className'> & { className?: string } -export function DialogViewport({ - className, - ...props -}: DialogViewportProps) { - return ( - <BaseDialog.Viewport - className={cn('fixed inset-0 z-50', className)} - {...props} - /> - ) +export function DialogViewport({ className, ...props }: DialogViewportProps) { + return <BaseDialog.Viewport className={cn('fixed inset-0 z-50', className)} {...props} /> } type DialogPopupProps = Omit<BaseDialog.Popup.Props, 'className'> & { className?: string } -export function DialogPopup({ - className, - ...props -}: DialogPopupProps) { +export function DialogPopup({ className, ...props }: DialogPopupProps) { return ( <BaseDialog.Popup className={cn( diff --git a/packages/dify-ui/src/drawer/index.stories.tsx b/packages/dify-ui/src/drawer/index.stories.tsx index 519ca24f4a9b97..b12cf82b4e3cad 100644 --- a/packages/dify-ui/src/drawer/index.stories.tsx +++ b/packages/dify-ui/src/drawer/index.stories.tsx @@ -30,9 +30,12 @@ import { ScrollAreaViewport, } from '../scroll-area' -const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' -const textCloseClassName = 'inline-flex h-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 text-[13px] font-medium text-components-button-secondary-text shadow-xs outline-hidden hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' -const primaryCloseClassName = 'inline-flex h-8 items-center justify-center rounded-lg border-components-button-primary-border bg-components-button-primary-bg px-3.5 text-[13px] font-medium text-components-button-primary-text shadow outline-hidden hover:border-components-button-primary-border-hover hover:bg-components-button-primary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const triggerButtonClassName = + 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const textCloseClassName = + 'inline-flex h-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 text-[13px] font-medium text-components-button-secondary-text shadow-xs outline-hidden hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const primaryCloseClassName = + 'inline-flex h-8 items-center justify-center rounded-lg border-components-button-primary-border bg-components-button-primary-bg px-3.5 text-[13px] font-medium text-components-button-primary-text shadow outline-hidden hover:border-components-button-primary-border-hover hover:bg-components-button-primary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' const handleClassName = 'mx-auto mt-3 h-1 w-10 shrink-0 rounded-full bg-state-base-handle' const bottomHandleClassName = 'mx-auto mb-3 h-1 w-10 shrink-0 rounded-full bg-state-base-handle' @@ -43,7 +46,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound drawer built on Base UI Drawer. Use it for side panels, bottom sheets, nested editor panels, snap-point sheets, and mobile navigation surfaces that need swipe gestures. If the panel only needs modal focus management without gestures, use Dialog instead.', + component: + 'Compound drawer built on Base UI Drawer. Use it for side panels, bottom sheets, nested editor panels, snap-point sheets, and mobile navigation surfaces that need swipe gestures. If the panel only needs modal focus management without gestures, use Dialog instead.', }, }, }, @@ -84,7 +88,10 @@ export const Default: Story = { <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6"> <div className="grid gap-3"> {settingRows.map(([label, value]) => ( - <div key={label} className="rounded-xl border-[0.5px] border-divider-subtle bg-background-section-burn p-3"> + <div + key={label} + className="rounded-xl border-[0.5px] border-divider-subtle bg-background-section-burn p-3" + > <div className="text-xs font-medium text-text-tertiary">{label}</div> <div className="mt-1 text-sm font-medium text-text-secondary">{value}</div> </div> @@ -111,11 +118,7 @@ function ControlledDemo() { <Button variant="secondary" onClick={() => setOpen(true)}> Open controlled drawer </Button> - <span className="text-xs text-text-tertiary"> - State: - {' '} - {open ? 'open' : 'closed'} - </span> + <span className="text-xs text-text-tertiary">State: {open ? 'open' : 'closed'}</span> <Drawer open={open} onOpenChange={setOpen} swipeDirection="right"> <DrawerPortal> <DrawerBackdrop className="fixed" /> @@ -128,15 +131,23 @@ function ControlledDemo() { Controlled drawer </DrawerTitle> <DrawerDescription className="mt-1 text-sm/5 text-text-tertiary"> - Use open and onOpenChange when the owning feature needs to react to close events. + Use open and onOpenChange when the owning feature needs to react to close + events. </DrawerDescription> </div> <DrawerCloseButton className="shrink-0" /> </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6"> - <label className="grid gap-1 text-sm font-medium text-text-secondary" htmlFor="controlled-workspace-name"> + <label + className="grid gap-1 text-sm font-medium text-text-secondary" + htmlFor="controlled-workspace-name" + > Workspace name - <Input id="controlled-workspace-name" defaultValue="Acme workspace" autoComplete="organization" /> + <Input + id="controlled-workspace-name" + defaultValue="Acme workspace" + autoComplete="organization" + /> </label> </div> <div className="flex shrink-0 items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4"> @@ -174,14 +185,16 @@ export const Positions: Story = { Right panel </DrawerTitle> <DrawerDescription className="mt-1 text-sm/5 text-text-tertiary"> - This drawer is positioned with swipeDirection="right" and the Dify default popup styles. + This drawer is positioned with swipeDirection="right" and the Dify default + popup styles. </DrawerDescription> </div> <DrawerCloseButton className="shrink-0" /> </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6"> <div className="rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg-alt p-4 text-sm/5 text-text-secondary"> - Position is controlled by Base UI data attributes and the drawer popup classes, not by a separate wrapper component. + Position is controlled by Base UI data attributes and the drawer popup classes, + not by a separate wrapper component. </div> </div> <div className="flex shrink-0 items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4"> @@ -207,14 +220,16 @@ export const Positions: Story = { Left panel </DrawerTitle> <DrawerDescription className="mt-1 text-sm/5 text-text-tertiary"> - This drawer is positioned with swipeDirection="left" and the Dify default popup styles. + This drawer is positioned with swipeDirection="left" and the Dify default + popup styles. </DrawerDescription> </div> <DrawerCloseButton className="shrink-0" /> </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6"> <div className="rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg-alt p-4 text-sm/5 text-text-secondary"> - Position is controlled by Base UI data attributes and the drawer popup classes, not by a separate wrapper component. + Position is controlled by Base UI data attributes and the drawer popup classes, + not by a separate wrapper component. </div> </div> <div className="flex shrink-0 items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4"> @@ -339,7 +354,10 @@ function SnapPointsDemo() { </div> <div className="mb-6 grid gap-2" aria-hidden> {Array.from({ length: 20 }, (_, index) => ( - <div key={index} className="flex h-12 items-center gap-3 rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg-alt px-3"> + <div + key={index} + className="flex h-12 items-center gap-3 rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg-alt px-3" + > <span className="flex size-7 items-center justify-center rounded-lg bg-state-base-hover text-xs font-medium text-text-secondary"> {index + 1} </span> @@ -389,8 +407,14 @@ export const NestedDrawers: Story = { 'data-nested-drawer-open:[&_[data-nested-handle]]:opacity-0 data-nested-drawer-swiping:[&_[data-nested-handle]]:opacity-100', )} > - <div data-nested-handle className={cn(handleClassName, 'transition-opacity duration-200')} /> - <DrawerContent data-nested-content className="flex min-h-0 flex-1 flex-col p-0 pb-12 transition-opacity duration-200"> + <div + data-nested-handle + className={cn(handleClassName, 'transition-opacity duration-200')} + /> + <DrawerContent + data-nested-content + className="flex min-h-0 flex-1 flex-col p-0 pb-12 transition-opacity duration-200" + > <div className="shrink-0 px-6 pt-6 pb-4 text-center"> <div className="mx-auto max-w-96"> <DrawerTitle className="text-lg/6 font-semibold text-text-primary"> @@ -404,7 +428,9 @@ export const NestedDrawers: Story = { <div className="min-h-0 flex-1 px-6 pb-6" /> <div className="flex shrink-0 items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4"> <Drawer> - <DrawerTrigger render={<button type="button" className={triggerButtonClassName} />}> + <DrawerTrigger + render={<button type="button" className={triggerButtonClassName} />} + > Security settings </DrawerTrigger> <DrawerPortal> @@ -426,8 +452,14 @@ export const NestedDrawers: Story = { 'data-nested-drawer-open:[&_[data-nested-handle]]:opacity-0 data-nested-drawer-swiping:[&_[data-nested-handle]]:opacity-100', )} > - <div data-nested-handle className={cn(handleClassName, 'transition-opacity duration-200')} /> - <DrawerContent data-nested-content className="flex min-h-0 flex-1 flex-col p-0 pb-12 transition-opacity duration-200"> + <div + data-nested-handle + className={cn(handleClassName, 'transition-opacity duration-200')} + /> + <DrawerContent + data-nested-content + className="flex min-h-0 flex-1 flex-col p-0 pb-12 transition-opacity duration-200" + > <div className="shrink-0 px-6 pt-6 pb-4 text-center"> <div className="mx-auto max-w-96"> <DrawerTitle className="text-lg/6 font-semibold text-text-primary"> @@ -440,14 +472,22 @@ export const NestedDrawers: Story = { </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6"> <ul className="grid gap-2 text-sm text-text-secondary"> - <li className="rounded-xl bg-background-section-burn p-3">Passkeys enabled</li> - <li className="rounded-xl bg-background-section-burn p-3">2FA via authenticator app</li> - <li className="rounded-xl bg-background-section-burn p-3">3 signed-in devices</li> + <li className="rounded-xl bg-background-section-burn p-3"> + Passkeys enabled + </li> + <li className="rounded-xl bg-background-section-burn p-3"> + 2FA via authenticator app + </li> + <li className="rounded-xl bg-background-section-burn p-3"> + 3 signed-in devices + </li> </ul> </div> <div className="flex shrink-0 items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4"> <Drawer> - <DrawerTrigger render={<button type="button" className={triggerButtonClassName} />}> + <DrawerTrigger + render={<button type="button" className={triggerButtonClassName} />} + > Advanced options </DrawerTrigger> <DrawerPortal> @@ -469,26 +509,41 @@ export const NestedDrawers: Story = { 'data-nested-drawer-open:[&_[data-nested-handle]]:opacity-0 data-nested-drawer-swiping:[&_[data-nested-handle]]:opacity-100', )} > - <div data-nested-handle className={cn(handleClassName, 'transition-opacity duration-200')} /> - <DrawerContent data-nested-content className="flex min-h-0 flex-1 flex-col p-0 pb-12 transition-opacity duration-200"> + <div + data-nested-handle + className={cn( + handleClassName, + 'transition-opacity duration-200', + )} + /> + <DrawerContent + data-nested-content + className="flex min-h-0 flex-1 flex-col p-0 pb-12 transition-opacity duration-200" + > <div className="shrink-0 px-6 pt-6 pb-4 text-center"> <div className="mx-auto max-w-96"> <DrawerTitle className="text-lg/6 font-semibold text-text-primary"> Advanced </DrawerTitle> <DrawerDescription className="mt-1 text-sm/5 text-text-tertiary"> - The stack uses Base UI nested drawer data attributes for visual treatment. + The stack uses Base UI nested drawer data attributes for + visual treatment. </DrawerDescription> </div> </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6"> - <label className="grid gap-1 text-sm font-medium text-text-secondary" htmlFor="device-name"> + <label + className="grid gap-1 text-sm font-medium text-text-secondary" + htmlFor="device-name" + > Device name <Input id="device-name" defaultValue="Personal laptop" /> </label> </div> <div className="flex shrink-0 items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4"> - <DrawerClose className={primaryCloseClassName}>Done</DrawerClose> + <DrawerClose className={primaryCloseClassName}> + Done + </DrawerClose> </div> </DrawerContent> </DrawerPopup> @@ -517,7 +572,10 @@ function IndentEffectDemo() { return ( <DrawerProvider> - <div ref={setPortalContainer} className="relative h-[420px] w-[640px] overflow-hidden rounded-2xl border-[0.5px] border-divider-subtle bg-background-default"> + <div + ref={setPortalContainer} + className="relative h-[420px] w-[640px] overflow-hidden rounded-2xl border-[0.5px] border-divider-subtle bg-background-default" + > <DrawerIndentBackground className="absolute inset-0 bg-state-accent-hover opacity-0 transition-opacity duration-200 data-active:opacity-100" /> <DrawerIndent className="relative flex size-full flex-col items-center justify-center gap-4 bg-background-body transition-[border-radius,transform] duration-200 data-active:scale-[0.96] data-active:rounded-[20px]"> <div className="grid max-w-sm gap-2 text-center"> @@ -541,12 +599,14 @@ function IndentEffectDemo() { Notifications </DrawerTitle> <DrawerDescription className="mt-1 text-sm/5 text-text-tertiary"> - The indented shell uses DrawerProvider, DrawerIndentBackground, and DrawerIndent. + The indented shell uses DrawerProvider, DrawerIndentBackground, and + DrawerIndent. </DrawerDescription> </div> <div className="mx-auto min-h-0 w-full max-w-96 flex-1 px-6 pb-4"> <div className="rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg-alt p-4 text-center text-sm/5 text-text-secondary"> - The app shell scales behind this sheet while the drawer stays inside the local portal container. + The app shell scales behind this sheet while the drawer stays inside the + local portal container. </div> </div> <div className="flex shrink-0 items-center justify-center gap-2 px-6 py-4"> @@ -579,14 +639,10 @@ function NonModalDemo() { <DrawerTrigger render={<button type="button" className={triggerButtonClassName} />}> Open non-modal drawer </DrawerTrigger> - <Button variant="secondary" onClick={() => setBackgroundClicks(count => count + 1)}> + <Button variant="secondary" onClick={() => setBackgroundClicks((count) => count + 1)}> Background action </Button> - <span className="text-xs text-text-tertiary"> - Background clicks: - {' '} - {backgroundClicks} - </span> + <span className="text-xs text-text-tertiary">Background clicks: {backgroundClicks}</span> </div> <DrawerPortal> <DrawerViewport className="pointer-events-none"> @@ -605,7 +661,8 @@ function NonModalDemo() { </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6"> <div className="rounded-xl border-[0.5px] border-divider-subtle bg-background-section-burn p-3 text-sm/5 text-text-secondary"> - The background action remains clickable while this drawer is open. Outside clicks do not dismiss it. + The background action remains clickable while this drawer is open. Outside clicks + do not dismiss it. </div> </div> <div className="flex shrink-0 items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4"> @@ -652,7 +709,8 @@ export const MobileNavigation: Story = { parameters: { docs: { description: { - story: 'Based on the Base UI mobile navigation example. Open this story in a mobile or narrow viewport to evaluate the full-screen sheet behavior, long-list scrolling, and flick-to-dismiss gesture.', + story: + 'Based on the Base UI mobile navigation example. Open this story in a mobile or narrow viewport to evaluate the full-screen sheet behavior, long-list scrolling, and flick-to-dismiss gesture.', }, }, }, @@ -668,10 +726,17 @@ export const MobileNavigation: Story = { style={{ position: undefined }} className="h-full overscroll-contain transition-transform duration-600 ease-[cubic-bezier(0.45,1.005,0,1.005)] group-data-ending-style:pointer-events-none group-data-starting-style:translate-y-[100dvh] motion-reduce:transition-none" > - <ScrollAreaViewport className="size-full touch-auto overscroll-contain" role="region" aria-label="Mobile drawer viewport"> + <ScrollAreaViewport + className="size-full touch-auto overscroll-contain" + role="region" + aria-label="Mobile drawer viewport" + > <ScrollAreaContent className="flex min-h-full min-w-0 items-end justify-center pt-8 min-[42rem]:px-16 min-[42rem]:py-16"> <DrawerPopup className="relative w-full max-w-[42rem] touch-auto overflow-visible transition-transform duration-600 ease-[cubic-bezier(0.45,1.005,0,1.005)] data-ending-style:duration-350 data-ending-style:ease-[cubic-bezier(0.375,0.015,0.545,0.455)] data-swiping:select-none data-[swipe-direction=down]:inset-auto data-[swipe-direction=down]:max-h-none data-[swipe-direction=down]:transform-[translateY(var(--drawer-swipe-movement-y,0px))] data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(max(100dvh,100%)_+_2px))] motion-reduce:transition-none min-[42rem]:rounded-2xl min-[42rem]:border-[0.5px] min-[42rem]:border-b-[0.5px]"> - <nav aria-label="Mobile navigation" className="relative flex flex-col bg-components-panel-bg"> + <nav + aria-label="Mobile navigation" + className="relative flex flex-col bg-components-panel-bg" + > <div className="grid grid-cols-[1fr_auto_1fr] items-start px-6 pt-4"> <div aria-hidden className="h-9 w-9" /> <div className="h-1 w-12 justify-self-center rounded-full bg-state-base-handle" /> @@ -679,25 +744,33 @@ export const MobileNavigation: Story = { </div> <DrawerContent className="w-full flex-none overflow-visible overscroll-auto p-0 pb-0"> <div className="px-6 pt-5 pb-4"> - <DrawerTitle className="text-lg/6 font-semibold text-text-primary">Menu</DrawerTitle> + <DrawerTitle className="text-lg/6 font-semibold text-text-primary"> + Menu + </DrawerTitle> <DrawerDescription className="mt-1 text-sm/5 text-text-tertiary"> Scroll the long list. Flick down from the top to dismiss. </DrawerDescription> </div> <div className="px-6 pb-8"> <ul className="m-0 grid list-none gap-1 p-0"> - {mobilePrimaryLinks.map(item => ( + {mobilePrimaryLinks.map((item) => ( <li key={item.label} className="flex"> - <a href={item.href} className="flex h-11 w-full min-w-0 items-center rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg-alt px-3 text-sm font-medium text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"> + <a + href={item.href} + className="flex h-11 w-full min-w-0 items-center rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg-alt px-3 text-sm font-medium text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > <span className="truncate">{item.label}</span> </a> </li> ))} </ul> <ul aria-label="Component links" className="mt-6 grid list-none gap-1 p-0"> - {mobileComponentLinks.map(item => ( + {mobileComponentLinks.map((item) => ( <li key={item.label} className="flex"> - <a href={item.href} className="flex h-11 w-full min-w-0 items-center rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg-alt px-3 text-sm text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"> + <a + href={item.href} + className="flex h-11 w-full min-w-0 items-center rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg-alt px-3 text-sm text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > <span className="truncate">{item.label}</span> </a> </li> @@ -726,7 +799,10 @@ function SwipeToOpenDemo() { const [portalContainer, setPortalContainer] = React.useState<HTMLDivElement | null>(null) return ( - <div ref={setPortalContainer} className="relative h-[360px] w-[560px] overflow-hidden rounded-2xl border-[0.5px] border-divider-subtle bg-background-body"> + <div + ref={setPortalContainer} + className="relative h-[360px] w-[560px] overflow-hidden rounded-2xl border-[0.5px] border-divider-subtle bg-background-body" + > <Drawer swipeDirection="right" modal={false}> <DrawerSwipeArea className="absolute inset-y-0 right-0 z-10 flex w-10 touch-none items-center justify-center bg-state-accent-hover text-text-accent"> <span className="rotate-90 text-xs font-medium">Swipe</span> @@ -734,7 +810,9 @@ function SwipeToOpenDemo() { <div className="flex size-full items-center justify-center px-8 text-center"> <div className="grid gap-2"> <div className="text-lg font-semibold text-text-primary">Swipe area</div> - <div className="text-sm text-text-tertiary">Drag from the highlighted right edge to open the drawer.</div> + <div className="text-sm text-text-tertiary"> + Drag from the highlighted right edge to open the drawer. + </div> <div className="flex justify-center pt-2"> <DrawerTrigger render={<button type="button" className={triggerButtonClassName} />}> Open drawer @@ -798,7 +876,10 @@ function ActionSheetDemo() { <DrawerDescription className="sr-only"> Choose an action for Customer support assistant. </DrawerDescription> - <ul className="m-0 list-none divide-y divide-divider-subtle p-0" aria-label="App actions"> + <ul + className="m-0 list-none divide-y divide-divider-subtle p-0" + aria-label="App actions" + > {actionItems.map(([label, description]) => ( <li key={label}> <DrawerClose @@ -859,17 +940,24 @@ function DetachedTriggersDemo() { <div className="mb-3 flex items-center justify-between gap-3"> <div className="min-w-0"> <div className="text-sm font-semibold text-text-primary">External trigger surface</div> - <div className="text-xs text-text-tertiary">These triggers are rendered before the shared Drawer.Root.</div> + <div className="text-xs text-text-tertiary"> + These triggers are rendered before the shared Drawer.Root. + </div> </div> <span className="shrink-0 rounded-full bg-background-section-burn px-2 py-1 font-mono text-xs text-text-tertiary"> handle </span> </div> <div className="grid gap-2 sm:grid-cols-2"> - {detachedPayloads.map(payload => ( - <div key={payload.title} className="rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg-alt p-3"> + {detachedPayloads.map((payload) => ( + <div + key={payload.title} + className="rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg-alt p-3" + > <div className="mb-2 min-w-0"> - <div className="truncate text-sm font-medium text-text-secondary">{payload.title}</div> + <div className="truncate text-sm font-medium text-text-secondary"> + {payload.title} + </div> <div className="truncate text-xs text-text-tertiary">{`payload: ${payload.fields.join(', ')}`}</div> </div> <DrawerTrigger @@ -884,7 +972,8 @@ function DetachedTriggersDemo() { </div> </div> <div className="rounded-2xl border-[0.5px] border-dashed border-divider-subtle bg-background-section-burn p-4 text-sm text-text-secondary"> - One Drawer.Root is mounted separately below this trigger surface and reads the payload from whichever detached trigger opened it. + One Drawer.Root is mounted separately below this trigger surface and reads the payload from + whichever detached trigger opened it. </div> <Drawer handle={drawerHandle} swipeDirection="right"> {({ payload }) => ( @@ -899,7 +988,8 @@ function DetachedTriggersDemo() { {payload?.title ?? 'Detached drawer'} </DrawerTitle> <DrawerDescription className="mt-1 text-sm/5 text-text-tertiary"> - {payload?.description ?? 'This drawer is opened by a trigger outside Drawer.Root.'} + {payload?.description ?? + 'This drawer is opened by a trigger outside Drawer.Root.'} </DrawerDescription> </div> <DrawerCloseButton className="shrink-0" /> @@ -909,8 +999,11 @@ function DetachedTriggersDemo() { Opened by detached trigger payload </div> <div className="grid gap-2"> - {(payload?.fields ?? ['Detached trigger']).map(field => ( - <div key={field} className="rounded-xl border-[0.5px] border-divider-subtle px-3 py-2 text-sm text-text-secondary"> + {(payload?.fields ?? ['Detached trigger']).map((field) => ( + <div + key={field} + className="rounded-xl border-[0.5px] border-divider-subtle px-3 py-2 text-sm text-text-secondary" + > {field} </div> ))} @@ -943,14 +1036,18 @@ export const StackingAndAnimations: Story = { <DrawerBackdrop className="fixed" /> <DrawerViewport> <DrawerPopup className="duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] data-ending-style:duration-[calc(var(--drawer-swipe-strength)_*_400ms)] data-nested-drawer-open:brightness-95 data-swiping:shadow-none data-[swipe-direction=right]:max-w-105 data-[swipe-direction=right]:transform-[translateX(var(--drawer-swipe-movement-x,0px))] data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(100%_+_2px))] data-starting-style:data-[swipe-direction=right]:transform-[translateX(calc(100%_+_2px))] data-nested-drawer-open:[&_[data-animation-content]]:opacity-0 data-nested-drawer-swiping:[&_[data-animation-content]]:opacity-100"> - <DrawerContent data-animation-content className="flex min-h-0 flex-1 flex-col p-0 pb-0 transition-opacity duration-300"> + <DrawerContent + data-animation-content + className="flex min-h-0 flex-1 flex-col p-0 pb-0 transition-opacity duration-300" + > <div className="flex shrink-0 items-start justify-between gap-4 px-6 pt-6 pb-4"> <div className="min-w-0"> <DrawerTitle className="text-lg/6 font-semibold text-text-primary"> Right panel animation </DrawerTitle> <DrawerDescription className="mt-1 text-sm/5 text-text-tertiary"> - This panel slides in from the right using Base UI starting, ending, swiping, and nested data attributes. + This panel slides in from the right using Base UI starting, ending, swiping, and + nested data attributes. </DrawerDescription> </div> <DrawerCloseButton className="shrink-0" /> @@ -958,10 +1055,13 @@ export const StackingAndAnimations: Story = { <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6"> <div className="grid gap-3"> <div className="rounded-xl border-[0.5px] border-divider-subtle bg-background-section-burn p-3 text-sm/5 text-text-secondary"> - Open the nested right panel to see the parent drawer dim while the frontmost drawer keeps the same right-side motion. + Open the nested right panel to see the parent drawer dim while the frontmost + drawer keeps the same right-side motion. </div> <Drawer swipeDirection="right"> - <DrawerTrigger render={<button type="button" className={triggerButtonClassName} />}> + <DrawerTrigger + render={<button type="button" className={triggerButtonClassName} />} + > Open nested right panel </DrawerTrigger> <DrawerPortal> @@ -974,18 +1074,22 @@ export const StackingAndAnimations: Story = { Nested right panel </DrawerTitle> <DrawerDescription className="mt-1 text-sm/5 text-text-tertiary"> - The front drawer uses the same right-side entering, ending, and swipe transition classes. + The front drawer uses the same right-side entering, ending, and + swipe transition classes. </DrawerDescription> </div> <DrawerCloseButton className="shrink-0" /> </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6"> <div className="rounded-xl bg-background-section-burn p-3 text-sm/5 text-text-secondary"> - Close this drawer to watch focus and visual stacking return to the parent drawer. + Close this drawer to watch focus and visual stacking return to the + parent drawer. </div> </div> <div className="flex shrink-0 items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4"> - <DrawerClose className={primaryCloseClassName}>Close nested</DrawerClose> + <DrawerClose className={primaryCloseClassName}> + Close nested + </DrawerClose> </div> </DrawerContent> </DrawerPopup> @@ -1035,14 +1139,16 @@ export const InstantRightPanel: Story = { Instant right panel </DrawerTitle> <DrawerDescription className="mt-1 text-sm/5 text-text-tertiary"> - This non-modal drawer opens without a slide-in animation and ignores outside clicks. + This non-modal drawer opens without a slide-in animation and ignores outside + clicks. </DrawerDescription> </div> <DrawerCloseButton className="shrink-0" /> </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6"> <div className="rounded-xl border-[0.5px] border-divider-subtle bg-background-section-burn p-3 text-sm/5 text-text-secondary"> - The page stays interactive because this drawer is non-modal. Starting and ending styles both keep the panel at translateX(0), so open and close are instant. + The page stays interactive because this drawer is non-modal. Starting and ending + styles both keep the panel at translateX(0), so open and close are instant. </div> </div> <div className="flex shrink-0 items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4"> diff --git a/packages/dify-ui/src/drawer/index.tsx b/packages/dify-ui/src/drawer/index.tsx index e44f8308f72188..1f7efb64cfb3e5 100644 --- a/packages/dify-ui/src/drawer/index.tsx +++ b/packages/dify-ui/src/drawer/index.tsx @@ -25,10 +25,7 @@ export type DrawerSnapPointChangeEventDetails = BaseDrawer.Root.SnapPointChangeE export type DrawerSnapPointChangeEventReason = BaseDrawer.Root.SnapPointChangeEventReason export type DrawerTriggerProps<Payload = unknown> = BaseDrawer.Trigger.Props<Payload> -export function DrawerBackdrop({ - className, - ...props -}: BaseDrawer.Backdrop.Props) { +export function DrawerBackdrop({ className, ...props }: BaseDrawer.Backdrop.Props) { return ( <BaseDrawer.Backdrop className={cn( @@ -41,22 +38,19 @@ export function DrawerBackdrop({ ) } -export function DrawerViewport({ - className, - ...props -}: BaseDrawer.Viewport.Props) { +export function DrawerViewport({ className, ...props }: BaseDrawer.Viewport.Props) { return ( <BaseDrawer.Viewport - className={cn('fixed inset-0 z-50 touch-none overflow-hidden overscroll-contain outline-hidden', className)} + className={cn( + 'fixed inset-0 z-50 touch-none overflow-hidden overscroll-contain outline-hidden', + className, + )} {...props} /> ) } -export function DrawerPopup({ - className, - ...props -}: BaseDrawer.Popup.Props) { +export function DrawerPopup({ className, ...props }: BaseDrawer.Popup.Props) { return ( <BaseDrawer.Popup className={cn( @@ -77,13 +71,13 @@ export function DrawerPopup({ ) } -export function DrawerContent({ - className, - ...props -}: BaseDrawer.Content.Props) { +export function DrawerContent({ className, ...props }: BaseDrawer.Content.Props) { return ( <BaseDrawer.Content - className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain p-6 pb-[calc(1.5rem+env(safe-area-inset-bottom,0))]', className)} + className={cn( + 'min-h-0 flex-1 overflow-y-auto overscroll-contain p-6 pb-[calc(1.5rem+env(safe-area-inset-bottom,0))]', + className, + )} {...props} /> ) diff --git a/packages/dify-ui/src/dropdown-menu/__tests__/index.spec.tsx b/packages/dify-ui/src/dropdown-menu/__tests__/index.spec.tsx index f28606670e3b82..075a0e865ccdbc 100644 --- a/packages/dify-ui/src/dropdown-menu/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/dropdown-menu/__tests__/index.spec.tsx @@ -12,11 +12,8 @@ import { DropdownMenuTrigger, } from '../index' -const renderWithSafeViewport = (ui: React.ReactNode) => render( - <div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}> - {ui} - </div>, -) +const renderWithSafeViewport = (ui: React.ReactNode) => + render(<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>{ui}</div>) describe('dropdown-menu wrapper', () => { describe('DropdownMenuContent', () => { @@ -24,15 +21,23 @@ describe('dropdown-menu wrapper', () => { const screen = await renderWithSafeViewport( <DropdownMenu open> <DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger> - <DropdownMenuContent positionerProps={{ 'role': 'group', 'aria-label': 'content positioner' }}> + <DropdownMenuContent + positionerProps={{ role: 'group', 'aria-label': 'content positioner' }} + > <DropdownMenuItem>Content action</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu>, ) - await expect.element(screen.getByRole('group', { name: 'content positioner' })).toHaveAttribute('data-side', 'bottom') - await expect.element(screen.getByRole('group', { name: 'content positioner' })).toHaveAttribute('data-align', 'end') - await expect.element(screen.getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument() + await expect + .element(screen.getByRole('group', { name: 'content positioner' })) + .toHaveAttribute('data-side', 'bottom') + await expect + .element(screen.getByRole('group', { name: 'content positioner' })) + .toHaveAttribute('data-align', 'end') + await expect + .element(screen.getByRole('menuitem', { name: 'Content action' })) + .toBeInTheDocument() }) it('should apply custom placement when custom positioning props are provided', async () => { @@ -43,16 +48,22 @@ describe('dropdown-menu wrapper', () => { placement="top-start" sideOffset={12} alignOffset={-3} - positionerProps={{ 'role': 'group', 'aria-label': 'custom content positioner' }} + positionerProps={{ role: 'group', 'aria-label': 'custom content positioner' }} > <DropdownMenuItem>Custom content</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu>, ) - await expect.element(screen.getByRole('group', { name: 'custom content positioner' })).toHaveAttribute('data-side', 'top') - await expect.element(screen.getByRole('group', { name: 'custom content positioner' })).toHaveAttribute('data-align', 'start') - await expect.element(screen.getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument() + await expect + .element(screen.getByRole('group', { name: 'custom content positioner' })) + .toHaveAttribute('data-side', 'top') + await expect + .element(screen.getByRole('group', { name: 'custom content positioner' })) + .toHaveAttribute('data-align', 'start') + await expect + .element(screen.getByRole('menuitem', { name: 'Custom content' })) + .toBeInTheDocument() }) it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', async () => { @@ -64,10 +75,10 @@ describe('dropdown-menu wrapper', () => { <DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger> <DropdownMenuContent positionerProps={{ - 'role': 'group', + role: 'group', 'aria-label': 'dropdown content positioner', - 'id': 'dropdown-content-positioner', - 'onMouseEnter': handlePositionerMouseEnter, + id: 'dropdown-content-positioner', + onMouseEnter: handlePositionerMouseEnter, }} popupProps={{ role: 'menu', @@ -83,7 +94,9 @@ describe('dropdown-menu wrapper', () => { await screen.getByRole('group', { name: 'dropdown content positioner' }).hover() await screen.getByRole('menu').click() - await expect.element(screen.getByRole('group', { name: 'dropdown content positioner' })).toHaveAttribute('id', 'dropdown-content-positioner') + await expect + .element(screen.getByRole('group', { name: 'dropdown content positioner' })) + .toHaveAttribute('id', 'dropdown-content-positioner') await expect.element(screen.getByRole('menu')).toHaveAttribute('id', 'dropdown-content-popup') expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1) expect(handlePopupClick).toHaveBeenCalledTimes(1) @@ -98,7 +111,9 @@ describe('dropdown-menu wrapper', () => { <DropdownMenuContent> <DropdownMenuSub open> <DropdownMenuSubTrigger>More actions</DropdownMenuSubTrigger> - <DropdownMenuSubContent positionerProps={{ 'role': 'group', 'aria-label': 'sub positioner' }}> + <DropdownMenuSubContent + positionerProps={{ role: 'group', 'aria-label': 'sub positioner' }} + > <DropdownMenuItem>Sub action</DropdownMenuItem> </DropdownMenuSubContent> </DropdownMenuSub> @@ -106,8 +121,12 @@ describe('dropdown-menu wrapper', () => { </DropdownMenu>, ) - await expect.element(screen.getByRole('group', { name: 'sub positioner' })).toHaveAttribute('data-side', 'left') - await expect.element(screen.getByRole('group', { name: 'sub positioner' })).toHaveAttribute('data-align', 'start') + await expect + .element(screen.getByRole('group', { name: 'sub positioner' })) + .toHaveAttribute('data-side', 'left') + await expect + .element(screen.getByRole('group', { name: 'sub positioner' })) + .toHaveAttribute('data-align', 'start') await expect.element(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument() }) @@ -126,10 +145,10 @@ describe('dropdown-menu wrapper', () => { sideOffset={6} alignOffset={2} positionerProps={{ - 'role': 'group', + role: 'group', 'aria-label': 'dropdown sub positioner', - 'id': 'dropdown-sub-positioner', - 'onFocus': handlePositionerFocus, + id: 'dropdown-sub-positioner', + onFocus: handlePositionerFocus, }} popupProps={{ role: 'menu', @@ -144,15 +163,28 @@ describe('dropdown-menu wrapper', () => { </DropdownMenu>, ) - screen.getByRole('group', { name: 'dropdown sub positioner' }).element().dispatchEvent(new FocusEvent('focus', { - bubbles: true, - })) + screen + .getByRole('group', { name: 'dropdown sub positioner' }) + .element() + .dispatchEvent( + new FocusEvent('focus', { + bubbles: true, + }), + ) await screen.getByRole('menu', { name: 'More actions' }).click() - await expect.element(screen.getByRole('group', { name: 'dropdown sub positioner' })).toHaveAttribute('data-side', 'right') - await expect.element(screen.getByRole('group', { name: 'dropdown sub positioner' })).toHaveAttribute('data-align', 'end') - await expect.element(screen.getByRole('group', { name: 'dropdown sub positioner' })).toHaveAttribute('id', 'dropdown-sub-positioner') - await expect.element(screen.getByRole('menu', { name: 'More actions' })).toHaveAttribute('id', 'dropdown-sub-popup') + await expect + .element(screen.getByRole('group', { name: 'dropdown sub positioner' })) + .toHaveAttribute('data-side', 'right') + await expect + .element(screen.getByRole('group', { name: 'dropdown sub positioner' })) + .toHaveAttribute('data-align', 'end') + await expect + .element(screen.getByRole('group', { name: 'dropdown sub positioner' })) + .toHaveAttribute('id', 'dropdown-sub-positioner') + await expect + .element(screen.getByRole('menu', { name: 'More actions' })) + .toHaveAttribute('id', 'dropdown-sub-popup') expect(handlePositionerFocus).toHaveBeenCalledTimes(1) expect(handlePopupClick).toHaveBeenCalledTimes(1) }) @@ -171,62 +203,78 @@ describe('dropdown-menu wrapper', () => { </DropdownMenu>, ) - await expect.element(screen.getByRole('menuitem', { name: 'Trigger item' })).toBeInTheDocument() + await expect + .element(screen.getByRole('menuitem', { name: 'Trigger item' })) + .toBeInTheDocument() }) - it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant on submenu trigger when variant is %s', async (variant) => { - const handleClick = vi.fn() + it.each(['default', 'destructive'] as const)( + 'should remain interactive and set data-variant on submenu trigger when variant is %s', + async (variant) => { + const handleClick = vi.fn() + + const screen = await render( + <DropdownMenu open> + <DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger> + <DropdownMenuContent> + <DropdownMenuSub open> + <DropdownMenuSubTrigger + variant={variant} + aria-label="submenu action" + id={`submenu-trigger-${variant}`} + onClick={handleClick} + > + Trigger item + </DropdownMenuSubTrigger> + </DropdownMenuSub> + </DropdownMenuContent> + </DropdownMenu>, + ) + + await screen.getByRole('menuitem', { name: 'submenu action' }).click() + await expect + .element(screen.getByRole('menuitem', { name: 'submenu action' })) + .toHaveAttribute('id', `submenu-trigger-${variant}`) + await expect + .element(screen.getByRole('menuitem', { name: 'submenu action' })) + .toHaveAttribute('data-variant', variant) + expect(handleClick).toHaveBeenCalledTimes(1) + }, + ) + }) - const screen = await render( - <DropdownMenu open> - <DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger> - <DropdownMenuContent> - <DropdownMenuSub open> - <DropdownMenuSubTrigger + describe('DropdownMenuItem', () => { + it.each(['default', 'destructive'] as const)( + 'should remain interactive and set data-variant when variant is %s', + async (variant) => { + const handleClick = vi.fn() + + const screen = await render( + <DropdownMenu open> + <DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger> + <DropdownMenuContent> + <DropdownMenuItem variant={variant} - aria-label="submenu action" - id={`submenu-trigger-${variant}`} + aria-label="menu action" + id={`menu-item-${variant}`} onClick={handleClick} > - Trigger item - </DropdownMenuSubTrigger> - </DropdownMenuSub> - </DropdownMenuContent> - </DropdownMenu>, - ) - - await screen.getByRole('menuitem', { name: 'submenu action' }).click() - await expect.element(screen.getByRole('menuitem', { name: 'submenu action' })).toHaveAttribute('id', `submenu-trigger-${variant}`) - await expect.element(screen.getByRole('menuitem', { name: 'submenu action' })).toHaveAttribute('data-variant', variant) - expect(handleClick).toHaveBeenCalledTimes(1) - }) - }) - - describe('DropdownMenuItem', () => { - it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant when variant is %s', async (variant) => { - const handleClick = vi.fn() - - const screen = await render( - <DropdownMenu open> - <DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger> - <DropdownMenuContent> - <DropdownMenuItem - variant={variant} - aria-label="menu action" - id={`menu-item-${variant}`} - onClick={handleClick} - > - Item label - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu>, - ) - - await screen.getByRole('menuitem', { name: 'menu action' }).click() - await expect.element(screen.getByRole('menuitem', { name: 'menu action' })).toHaveAttribute('id', `menu-item-${variant}`) - await expect.element(screen.getByRole('menuitem', { name: 'menu action' })).toHaveAttribute('data-variant', variant) - expect(handleClick).toHaveBeenCalledTimes(1) - }) + Item label + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu>, + ) + + await screen.getByRole('menuitem', { name: 'menu action' }).click() + await expect + .element(screen.getByRole('menuitem', { name: 'menu action' })) + .toHaveAttribute('id', `menu-item-${variant}`) + await expect + .element(screen.getByRole('menuitem', { name: 'menu action' })) + .toHaveAttribute('data-variant', variant) + expect(handleClick).toHaveBeenCalledTimes(1) + }, + ) }) describe('DropdownMenuLinkItem', () => { @@ -235,7 +283,11 @@ describe('dropdown-menu wrapper', () => { <DropdownMenu open> <DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger> <DropdownMenuContent> - <DropdownMenuLinkItem href="https://example.com/docs" target="_blank" rel="noopener noreferrer"> + <DropdownMenuLinkItem + href="https://example.com/docs" + target="_blank" + rel="noopener noreferrer" + > Docs </DropdownMenuLinkItem> </DropdownMenuContent> @@ -276,10 +328,7 @@ describe('dropdown-menu wrapper', () => { <DropdownMenu open> <DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger> <DropdownMenuContent> - <DropdownMenuLinkItem - render={<a href="/account" />} - aria-label="account link" - > + <DropdownMenuLinkItem render={<a href="/account" />} aria-label="account link"> Account settings </DropdownMenuLinkItem> </DropdownMenuContent> @@ -292,37 +341,40 @@ describe('dropdown-menu wrapper', () => { expect(link).toHaveTextContent('Account settings') }) - it.each(['default', 'destructive'] as const)('should remain interactive and set data-variant when variant is %s', async (variant) => { - const handleClick = vi.fn() - - const screen = await render( - <DropdownMenu open> - <DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger> - <DropdownMenuContent> - <DropdownMenuLinkItem - variant={variant} - href="https://example.com/docs" - aria-label="docs link" - id={`menu-link-${variant}`} - onClick={(event) => { - event.preventDefault() - handleClick(event) - }} - > - Docs - </DropdownMenuLinkItem> - </DropdownMenuContent> - </DropdownMenu>, - ) - - await screen.getByRole('menuitem', { name: 'docs link' }).click() + it.each(['default', 'destructive'] as const)( + 'should remain interactive and set data-variant when variant is %s', + async (variant) => { + const handleClick = vi.fn() - const link = screen.getByRole('menuitem', { name: 'docs link' }).element() - expect(link.tagName.toLowerCase()).toBe('a') - expect(link).toHaveAttribute('id', `menu-link-${variant}`) - expect(link).toHaveAttribute('data-variant', variant) - expect(handleClick).toHaveBeenCalledTimes(1) - }) + const screen = await render( + <DropdownMenu open> + <DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger> + <DropdownMenuContent> + <DropdownMenuLinkItem + variant={variant} + href="https://example.com/docs" + aria-label="docs link" + id={`menu-link-${variant}`} + onClick={(event) => { + event.preventDefault() + handleClick(event) + }} + > + Docs + </DropdownMenuLinkItem> + </DropdownMenuContent> + </DropdownMenu>, + ) + + await screen.getByRole('menuitem', { name: 'docs link' }).click() + + const link = screen.getByRole('menuitem', { name: 'docs link' }).element() + expect(link.tagName.toLowerCase()).toBe('a') + expect(link).toHaveAttribute('id', `menu-link-${variant}`) + expect(link).toHaveAttribute('data-variant', variant) + expect(handleClick).toHaveBeenCalledTimes(1) + }, + ) }) describe('DropdownMenuSeparator', () => { @@ -342,10 +394,17 @@ describe('dropdown-menu wrapper', () => { </DropdownMenu>, ) - screen.getByRole('separator', { name: 'actions divider' }).element().dispatchEvent(new MouseEvent('mouseover', { - bubbles: true, - })) - await expect.element(screen.getByRole('separator', { name: 'actions divider' })).toHaveAttribute('id', 'menu-separator') + screen + .getByRole('separator', { name: 'actions divider' }) + .element() + .dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + }), + ) + await expect + .element(screen.getByRole('separator', { name: 'actions divider' })) + .toHaveAttribute('id', 'menu-separator') expect(handleMouseEnter).toHaveBeenCalledTimes(1) }) @@ -361,8 +420,12 @@ describe('dropdown-menu wrapper', () => { </DropdownMenu>, ) - await expect.element(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument() - await expect.element(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument() + await expect + .element(screen.getByRole('menuitem', { name: 'First action' })) + .toBeInTheDocument() + await expect + .element(screen.getByRole('menuitem', { name: 'Second action' })) + .toBeInTheDocument() expect(screen.getByRole('separator').elements()).toHaveLength(1) }) }) diff --git a/packages/dify-ui/src/dropdown-menu/index.stories.tsx b/packages/dify-ui/src/dropdown-menu/index.stories.tsx index 7d5baafb6ef869..fe5c342daeae0f 100644 --- a/packages/dify-ui/src/dropdown-menu/index.stories.tsx +++ b/packages/dify-ui/src/dropdown-menu/index.stories.tsx @@ -21,7 +21,12 @@ import { const TriggerButton = ({ label = 'Open Menu' }: { label?: string }) => ( <DropdownMenuTrigger - render={<button type="button" className="rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" />} + render={ + <button + type="button" + className="rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" + /> + } > {label} </DropdownMenuTrigger> @@ -34,7 +39,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound dropdown menu built on Base UI Menu. Supports items, separators, group labels, submenus, radio groups, checkbox items, destructive items, and disabled states.', + component: + 'Compound dropdown menu built on Base UI Menu. Supports items, separators, group labels, submenus, radio groups, checkbox items, destructive items, and disabled states.', }, }, }, @@ -243,7 +249,11 @@ export const WithLinkItems: Story = { <DropdownMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank"> Dify Docs </DropdownMenuLinkItem> - <DropdownMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank"> + <DropdownMenuLinkItem + href="https://roadmap.dify.ai" + rel="noopener noreferrer" + target="_blank" + > Product Roadmap </DropdownMenuLinkItem> </DropdownMenuContent> diff --git a/packages/dify-ui/src/dropdown-menu/index.tsx b/packages/dify-ui/src/dropdown-menu/index.tsx index 7cb585dab3dd7f..696bc61e189a3f 100644 --- a/packages/dify-ui/src/dropdown-menu/index.tsx +++ b/packages/dify-ui/src/dropdown-menu/index.tsx @@ -24,16 +24,8 @@ export const DropdownMenuSub = Menu.SubmenuRoot export const DropdownMenuGroup = Menu.Group export const DropdownMenuRadioGroup = Menu.RadioGroup -export function DropdownMenuRadioItem({ - className, - ...props -}: Menu.RadioItem.Props) { - return ( - <Menu.RadioItem - className={cn(overlayRowClassName, className)} - {...props} - /> - ) +export function DropdownMenuRadioItem({ className, ...props }: Menu.RadioItem.Props) { + return <Menu.RadioItem className={cn(overlayRowClassName, className)} {...props} /> } export function DropdownMenuRadioItemIndicator({ @@ -41,25 +33,14 @@ export function DropdownMenuRadioItemIndicator({ ...props }: Omit<Menu.RadioItemIndicator.Props, 'children'>) { return ( - <Menu.RadioItemIndicator - className={cn(overlayIndicatorClassName, className)} - {...props} - > + <Menu.RadioItemIndicator className={cn(overlayIndicatorClassName, className)} {...props}> <span aria-hidden className="i-ri-check-line h-4 w-4" /> </Menu.RadioItemIndicator> ) } -export function DropdownMenuCheckboxItem({ - className, - ...props -}: Menu.CheckboxItem.Props) { - return ( - <Menu.CheckboxItem - className={cn(overlayRowClassName, className)} - {...props} - /> - ) +export function DropdownMenuCheckboxItem({ className, ...props }: Menu.CheckboxItem.Props) { + return <Menu.CheckboxItem className={cn(overlayRowClassName, className)} {...props} /> } export function DropdownMenuCheckboxItemIndicator({ @@ -67,25 +48,14 @@ export function DropdownMenuCheckboxItemIndicator({ ...props }: Omit<Menu.CheckboxItemIndicator.Props, 'children'>) { return ( - <Menu.CheckboxItemIndicator - className={cn(overlayIndicatorClassName, className)} - {...props} - > + <Menu.CheckboxItemIndicator className={cn(overlayIndicatorClassName, className)} {...props}> <span aria-hidden className="i-ri-check-line h-4 w-4" /> </Menu.CheckboxItemIndicator> ) } -export function DropdownMenuLabel({ - className, - ...props -}: Menu.GroupLabel.Props) { - return ( - <Menu.GroupLabel - className={cn(overlayLabelClassName, className)} - {...props} - /> - ) +export function DropdownMenuLabel({ className, ...props }: Menu.GroupLabel.Props) { + return <Menu.GroupLabel className={cn(overlayLabelClassName, className)} {...props} /> } type DropdownMenuContentProps = { @@ -99,10 +69,7 @@ type DropdownMenuContentProps = { Menu.Positioner.Props, 'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset' > - popupProps?: Omit< - Menu.Popup.Props, - 'children' | 'className' - > + popupProps?: Omit<Menu.Popup.Props, 'children' | 'className'> } type DropdownMenuPopupRenderProps = Required<Pick<DropdownMenuContentProps, 'children'>> & { @@ -138,11 +105,7 @@ function renderDropdownMenuPopup({ {...positionerProps} > <Menu.Popup - className={cn( - overlayPopupBaseClassName, - overlayPopupAnimationClassName, - popupClassName, - )} + className={cn(overlayPopupBaseClassName, overlayPopupAnimationClassName, popupClassName)} {...popupProps} > {children} @@ -191,7 +154,10 @@ export function DropdownMenuSubTrigger({ {...props} > {children} - <span aria-hidden className="ms-auto i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" /> + <span + aria-hidden + className="ms-auto i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" + /> </Menu.SubmenuTrigger> ) } @@ -267,14 +233,6 @@ export function DropdownMenuLinkItem({ ) } -export function DropdownMenuSeparator({ - className, - ...props -}: Menu.Separator.Props) { - return ( - <Menu.Separator - className={cn(overlaySeparatorClassName, className)} - {...props} - /> - ) +export function DropdownMenuSeparator({ className, ...props }: Menu.Separator.Props) { + return <Menu.Separator className={cn(overlaySeparatorClassName, className)} {...props} /> } diff --git a/packages/dify-ui/src/field/__tests__/index.spec.tsx b/packages/dify-ui/src/field/__tests__/index.spec.tsx index 8fb6982781136b..44778c25470bea 100644 --- a/packages/dify-ui/src/field/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/field/__tests__/index.spec.tsx @@ -3,14 +3,7 @@ import { Checkbox } from '../../checkbox' import { CheckboxGroup } from '../../checkbox-group' import { Fieldset, FieldsetLegend } from '../../fieldset' import { Form } from '../../form' -import { - Field, - FieldControl, - FieldDescription, - FieldError, - FieldItem, - FieldLabel, -} from '../index' +import { Field, FieldControl, FieldDescription, FieldError, FieldItem, FieldLabel } from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement @@ -36,7 +29,9 @@ describe('Field primitives', () => { await expect.element(input).toHaveAccessibleDescription('Used for account notifications.') expect(label.tagName).toBe('LABEL') expect(label).toHaveAttribute('for', asHTMLElement(input.element()).id) - expect(asHTMLElement(input.element()).getAttribute('aria-describedby')?.split(' ')).toContain(description.id) + expect(asHTMLElement(input.element()).getAttribute('aria-describedby')?.split(' ')).toContain( + description.id, + ) asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click() @@ -86,7 +81,9 @@ describe('Field primitives', () => { ) await expect.element(screen.getByRole('group', { name: 'Features' })).toBeInTheDocument() - await expect.element(screen.getByRole('checkbox', { name: 'Search' })).toHaveAttribute('aria-checked', 'true') + await expect + .element(screen.getByRole('checkbox', { name: 'Search' })) + .toHaveAttribute('aria-checked', 'true') }) it('should expose the read-only state', async () => { diff --git a/packages/dify-ui/src/field/index.stories.tsx b/packages/dify-ui/src/field/index.stories.tsx index 563be84ffe0065..46e85639734065 100644 --- a/packages/dify-ui/src/field/index.stories.tsx +++ b/packages/dify-ui/src/field/index.stories.tsx @@ -1,12 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import { Button } from '../button' -import { - Field, - FieldControl, - FieldDescription, - FieldError, - FieldLabel, -} from './index' +import { Field, FieldControl, FieldDescription, FieldError, FieldLabel } from './index' const meta = { title: 'Base/Form/Field', @@ -15,7 +9,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Field primitives built on Base UI Field. Use Field with FieldLabel, FieldControl, FieldDescription, and FieldError for one named form field. External form libraries can control invalid, dirty, and touched on Field.', + component: + 'Field primitives built on Base UI Field. Use Field with FieldLabel, FieldControl, FieldDescription, and FieldError for one named form field. External form libraries can control invalid, dirty, and touched on Field.', }, }, }, @@ -37,7 +32,9 @@ export const TextField: Story = { <FieldError match="typeMismatch">Enter a valid URL.</FieldError> </Field> <div className="flex justify-end"> - <Button type="submit" variant="primary">Save</Button> + <Button type="submit" variant="primary"> + Save + </Button> </div> </form> ), @@ -65,7 +62,9 @@ export const MultipleFields: Story = { <FieldError match="valueMissing">API key is required.</FieldError> </Field> <div className="flex justify-end"> - <Button type="submit" variant="primary">Save</Button> + <Button type="submit" variant="primary"> + Save + </Button> </div> </form> ), diff --git a/packages/dify-ui/src/field/index.tsx b/packages/dify-ui/src/field/index.tsx index 06e81f464b6ac1..9a8540c6496223 100644 --- a/packages/dify-ui/src/field/index.tsx +++ b/packages/dify-ui/src/field/index.tsx @@ -6,96 +6,50 @@ import { Field as BaseField } from '@base-ui/react/field' import { cn } from '../cn' import { formLabelClassName, textControlVariants } from '../form-control-shared' -export type FieldProps - = Omit<BaseFieldNS.Root.Props, 'className'> - & { - className?: string - } +export type FieldProps = Omit<BaseFieldNS.Root.Props, 'className'> & { + className?: string +} export type FieldActions = BaseFieldNS.Root.Actions -export function Field({ - className, - ...props -}: FieldProps) { - return ( - <BaseField.Root - className={cn('group/field grid min-w-0 gap-1', className)} - {...props} - /> - ) +export function Field({ className, ...props }: FieldProps) { + return <BaseField.Root className={cn('group/field grid min-w-0 gap-1', className)} {...props} /> } -export type FieldItemProps - = Omit<BaseFieldNS.Item.Props, 'className'> - & { - className?: string - } +export type FieldItemProps = Omit<BaseFieldNS.Item.Props, 'className'> & { + className?: string +} -export function FieldItem({ - className, - ...props -}: FieldItemProps) { - return ( - <BaseField.Item - className={cn('grid min-w-0 gap-1', className)} - {...props} - /> - ) +export function FieldItem({ className, ...props }: FieldItemProps) { + return <BaseField.Item className={cn('grid min-w-0 gap-1', className)} {...props} /> } -export type FieldLabelProps - = Omit<BaseFieldNS.Label.Props, 'className'> - & { - className?: string - } +export type FieldLabelProps = Omit<BaseFieldNS.Label.Props, 'className'> & { + className?: string +} -export function FieldLabel({ - className, - ...props -}: FieldLabelProps) { - return ( - <BaseField.Label - className={cn(formLabelClassName, className)} - {...props} - /> - ) +export function FieldLabel({ className, ...props }: FieldLabelProps) { + return <BaseField.Label className={cn(formLabelClassName, className)} {...props} /> } export type FieldControlSize = NonNullable<VariantProps<typeof textControlVariants>['size']> -export type FieldControlProps - = Omit<BaseFieldNS.Control.Props, 'className' | 'size'> - & VariantProps<typeof textControlVariants> - & { - className?: string - } +export type FieldControlProps = Omit<BaseFieldNS.Control.Props, 'className' | 'size'> & + VariantProps<typeof textControlVariants> & { + className?: string + } export type FieldControlChangeEventDetails = BaseFieldNS.Control.ChangeEventDetails -export function FieldControl({ - className, - size = 'medium', - ...props -}: FieldControlProps) { - return ( - <BaseField.Control - className={cn(textControlVariants({ size }), className)} - {...props} - /> - ) +export function FieldControl({ className, size = 'medium', ...props }: FieldControlProps) { + return <BaseField.Control className={cn(textControlVariants({ size }), className)} {...props} /> } -export type FieldDescriptionProps - = Omit<BaseFieldNS.Description.Props, 'className'> - & { - className?: string - } +export type FieldDescriptionProps = Omit<BaseFieldNS.Description.Props, 'className'> & { + className?: string +} -export function FieldDescription({ - className, - ...props -}: FieldDescriptionProps) { +export function FieldDescription({ className, ...props }: FieldDescriptionProps) { return ( <BaseField.Description className={cn('py-0.5 body-xs-regular text-text-tertiary', className)} @@ -104,16 +58,11 @@ export function FieldDescription({ ) } -export type FieldErrorProps - = Omit<BaseFieldNS.Error.Props, 'className'> - & { - className?: string - } +export type FieldErrorProps = Omit<BaseFieldNS.Error.Props, 'className'> & { + className?: string +} -export function FieldError({ - className, - ...props -}: FieldErrorProps) { +export function FieldError({ className, ...props }: FieldErrorProps) { return ( <BaseField.Error className={cn('py-0.5 body-xs-regular text-text-destructive', className)} diff --git a/packages/dify-ui/src/fieldset/__tests__/index.spec.tsx b/packages/dify-ui/src/fieldset/__tests__/index.spec.tsx index b26cf60d676195..0d50455b613247 100644 --- a/packages/dify-ui/src/fieldset/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/fieldset/__tests__/index.spec.tsx @@ -1,8 +1,5 @@ import { render } from 'vitest-browser-react' -import { - Fieldset, - FieldsetLegend, -} from '../index' +import { Fieldset, FieldsetLegend } from '../index' describe('Fieldset primitives', () => { it('should forward className to the fieldset and legend', async () => { diff --git a/packages/dify-ui/src/fieldset/index.stories.tsx b/packages/dify-ui/src/fieldset/index.stories.tsx index 774f1d8e194628..4fd0e02e39265e 100644 --- a/packages/dify-ui/src/fieldset/index.stories.tsx +++ b/packages/dify-ui/src/fieldset/index.stories.tsx @@ -2,10 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import { Checkbox } from '../checkbox' import { CheckboxGroup } from '../checkbox-group' import { Field, FieldItem, FieldLabel } from '../field' -import { - Fieldset, - FieldsetLegend, -} from './index' +import { Fieldset, FieldsetLegend } from './index' const meta = { title: 'Base/Form/Fieldset', @@ -14,7 +11,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Fieldset primitives built on Base UI Fieldset. Use Fieldset and FieldsetLegend when one field is represented by a group of related controls such as checkbox groups, radio groups, or multi-thumb sliders. Fieldset provides group semantics and labeling; pass interactive state such as disabled and value to the actual group primitive.', + component: + 'Fieldset primitives built on Base UI Fieldset. Use Fieldset and FieldsetLegend when one field is represented by a group of related controls such as checkbox groups, radio groups, or multi-thumb sliders. Fieldset provides group semantics and labeling; pass interactive state such as disabled and value to the actual group primitive.', }, }, }, diff --git a/packages/dify-ui/src/fieldset/index.tsx b/packages/dify-ui/src/fieldset/index.tsx index 0c969fb85b86c8..5ae29cce4b04bc 100644 --- a/packages/dify-ui/src/fieldset/index.tsx +++ b/packages/dify-ui/src/fieldset/index.tsx @@ -4,34 +4,19 @@ import type { Fieldset as BaseFieldsetNS } from '@base-ui/react/fieldset' import { Fieldset as BaseFieldset } from '@base-ui/react/fieldset' import { cn } from '../cn' -export type FieldsetProps - = Omit<BaseFieldsetNS.Root.Props, 'className'> - & { - className?: string - } +export type FieldsetProps = Omit<BaseFieldsetNS.Root.Props, 'className'> & { + className?: string +} -export function Fieldset({ - className, - ...props -}: FieldsetProps) { - return ( - <BaseFieldset.Root - className={cn('m-0 min-w-0 border-0 p-0', className)} - {...props} - /> - ) +export function Fieldset({ className, ...props }: FieldsetProps) { + return <BaseFieldset.Root className={cn('m-0 min-w-0 border-0 p-0', className)} {...props} /> } -export type FieldsetLegendProps - = Omit<BaseFieldsetNS.Legend.Props, 'className'> - & { - className?: string - } +export type FieldsetLegendProps = Omit<BaseFieldsetNS.Legend.Props, 'className'> & { + className?: string +} -export function FieldsetLegend({ - className, - ...props -}: FieldsetLegendProps) { +export function FieldsetLegend({ className, ...props }: FieldsetLegendProps) { return ( <BaseFieldset.Legend className={cn('mb-1 py-1 system-sm-medium text-text-secondary', className)} diff --git a/packages/dify-ui/src/file-tree/__tests__/index.spec.tsx b/packages/dify-ui/src/file-tree/__tests__/index.spec.tsx index dca30446689093..c11ee693d88e6c 100644 --- a/packages/dify-ui/src/file-tree/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/file-tree/__tests__/index.spec.tsx @@ -12,11 +12,7 @@ import { const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement -function TestFileTree({ - onPreview = vi.fn(), -}: { - onPreview?: (itemId: string) => void -}) { +function TestFileTree({ onPreview = vi.fn() }: { onPreview?: (itemId: string) => void }) { return ( <FileTree aria-label="Project files"> <FileTreeList> @@ -79,12 +75,16 @@ describe('FileTree', () => { src.click() - await expect.element(screen.getByRole('button', { name: 'src' })).toHaveAttribute('aria-expanded', 'false') + await expect + .element(screen.getByRole('button', { name: 'src' })) + .toHaveAttribute('aria-expanded', 'false') expect(screen.container.textContent).not.toContain('components') src.click() - await expect.element(screen.getByRole('button', { name: 'src' })).toHaveAttribute('aria-expanded', 'true') + await expect + .element(screen.getByRole('button', { name: 'src' })) + .toHaveAttribute('aria-expanded', 'true') await expect.element(screen.getByRole('button', { name: 'components' })).toBeInTheDocument() }) @@ -95,7 +95,9 @@ describe('FileTree', () => { asHTMLElement(screen.getByRole('button', { name: 'README.md' }).element()).click() expect(onPreview).toHaveBeenCalledWith('readme') - await expect.element(screen.getByRole('button', { name: 'README.md' })).not.toHaveAttribute('href') + await expect + .element(screen.getByRole('button', { name: 'README.md' })) + .not.toHaveAttribute('href') }) it('does not activate disabled file buttons', async () => { @@ -115,7 +117,9 @@ describe('FileTree', () => { expect(onPreview).not.toHaveBeenCalled() await expect.element(screen.getByRole('button', { name: 'disabled.txt' })).toBeDisabled() - await expect.element(screen.getByRole('button', { name: 'disabled.txt' })).toHaveAttribute('data-disabled') + await expect + .element(screen.getByRole('button', { name: 'disabled.txt' })) + .toHaveAttribute('data-disabled') }) it('resolves disabled folder triggers through the collapsible state', async () => { diff --git a/packages/dify-ui/src/file-tree/index.stories.tsx b/packages/dify-ui/src/file-tree/index.stories.tsx index 61fc810b36ac91..61c5cbcb36dc87 100644 --- a/packages/dify-ui/src/file-tree/index.stories.tsx +++ b/packages/dify-ui/src/file-tree/index.stories.tsx @@ -86,7 +86,10 @@ function FileTreeNodeRows({ return nodes.map((node) => { if (node.children?.length) { return ( - <FileTreeFolder key={node.id} defaultOpen={node.id === 'app' || node.id === 'app-components'}> + <FileTreeFolder + key={node.id} + defaultOpen={node.id === 'app' || node.id === 'app-components'} + > <FileTreeFolderTrigger> <FileTreeIcon type="folder" /> <FileTreeLabel>{node.name}</FileTreeLabel> @@ -138,35 +141,56 @@ function ComposedFileTree() { <FileTreeLabel>components</FileTreeLabel> </FileTreeFolderTrigger> <FileTreeFolderPanel> - <FileTreeFile selected={selectedItemId === 'button'} onClick={() => setSelectedItemId('button')}> + <FileTreeFile + selected={selectedItemId === 'button'} + onClick={() => setSelectedItemId('button')} + > <FileTreeIcon type="code" /> <FileTreeLabel>button.tsx</FileTreeLabel> </FileTreeFile> - <FileTreeFile selected={selectedItemId === 'dialog'} onClick={() => setSelectedItemId('dialog')}> + <FileTreeFile + selected={selectedItemId === 'dialog'} + onClick={() => setSelectedItemId('dialog')} + > <FileTreeIcon type="code" /> <FileTreeLabel>dialog.tsx</FileTreeLabel> </FileTreeFile> - <FileTreeFile selected={selectedItemId === 'readme'} onClick={() => setSelectedItemId('readme')}> + <FileTreeFile + selected={selectedItemId === 'readme'} + onClick={() => setSelectedItemId('readme')} + > <FileTreeIcon type="markdown" /> <FileTreeLabel>README.md</FileTreeLabel> </FileTreeFile> - <FileTreeFile selected={selectedItemId === 'config'} onClick={() => setSelectedItemId('config')}> + <FileTreeFile + selected={selectedItemId === 'config'} + onClick={() => setSelectedItemId('config')} + > <FileTreeIcon type="json" /> <FileTreeLabel>config.json</FileTreeLabel> </FileTreeFile> </FileTreeFolderPanel> </FileTreeFolder> - <FileTreeFile selected={selectedItemId === 'index'} onClick={() => setSelectedItemId('index')}> + <FileTreeFile + selected={selectedItemId === 'index'} + onClick={() => setSelectedItemId('index')} + > <FileTreeIcon type="code" /> <FileTreeLabel>index.ts</FileTreeLabel> </FileTreeFile> </FileTreeFolderPanel> </FileTreeFolder> - <FileTreeFile selected={selectedItemId === 'hero'} onClick={() => setSelectedItemId('hero')}> + <FileTreeFile + selected={selectedItemId === 'hero'} + onClick={() => setSelectedItemId('hero')} + > <FileTreeIcon type="image" /> <FileTreeLabel>hero.png</FileTreeLabel> </FileTreeFile> - <FileTreeFile selected={selectedItemId === 'license'} onClick={() => setSelectedItemId('license')}> + <FileTreeFile + selected={selectedItemId === 'license'} + onClick={() => setSelectedItemId('license')} + > <FileTreeIcon type="text" /> <FileTreeLabel>LICENSE</FileTreeLabel> <FileTreeMeta>root</FileTreeMeta> @@ -177,7 +201,9 @@ function ComposedFileTree() { } function DataDrivenFileTree() { - const [selectedItemId, setSelectedItemId] = React.useState<string | null>('app-components-file-tree') + const [selectedItemId, setSelectedItemId] = React.useState<string | null>( + 'app-components-file-tree', + ) return ( <FileTree @@ -211,45 +237,41 @@ function IconGallery() { ] as const return ( - <FileTree aria-label="File icon examples" className="w-64 rounded-lg border border-divider-subtle bg-background-default-subtle"> + <FileTree + aria-label="File icon examples" + className="w-64 rounded-lg border border-divider-subtle bg-background-default-subtle" + > <FileTreeList> - {iconTypes.map(type => ( - type === 'folder' - ? ( - <FileTreeFolder key={type}> - <FileTreeFolderTrigger> - <FileTreeIcon type={type} /> - <FileTreeLabel>{type}</FileTreeLabel> - </FileTreeFolderTrigger> - <FileTreeFolderPanel /> - </FileTreeFolder> - ) - : ( - <FileTreeFile key={type}> - <FileTreeIcon type={type} /> - <FileTreeLabel>{type}</FileTreeLabel> - </FileTreeFile> - ) - ))} + {iconTypes.map((type) => + type === 'folder' ? ( + <FileTreeFolder key={type}> + <FileTreeFolderTrigger> + <FileTreeIcon type={type} /> + <FileTreeLabel>{type}</FileTreeLabel> + </FileTreeFolderTrigger> + <FileTreeFolderPanel /> + </FileTreeFolder> + ) : ( + <FileTreeFile key={type}> + <FileTreeIcon type={type} /> + <FileTreeLabel>{type}</FileTreeLabel> + </FileTreeFile> + ), + )} </FileTreeList> </FileTree> ) } -function StateFrame({ - label, - children, -}: { - label: string - children: React.ReactNode -}) { +function StateFrame({ label, children }: { label: string; children: React.ReactNode }) { return ( <div className="w-80 min-w-0 space-y-1"> <div className="system-xs-medium-uppercase text-text-tertiary">{label}</div> - <FileTree aria-label={label} className="rounded-lg border border-divider-subtle bg-background-default-subtle"> - <FileTreeList> - {children} - </FileTreeList> + <FileTree + aria-label={label} + className="rounded-lg border border-divider-subtle bg-background-default-subtle" + > + <FileTreeList>{children}</FileTreeList> </FileTree> </div> ) @@ -321,7 +343,9 @@ function VisualStates() { <StateFrame label="Long label"> <FileTreeFile selected> <FileTreeIcon type="text" /> - <FileTreeLabel>very-long-file-name-that-should-truncate-without-shifting-layout.txt</FileTreeLabel> + <FileTreeLabel> + very-long-file-name-that-should-truncate-without-shifting-layout.txt + </FileTreeLabel> <FileTreeMeta>preview</FileTreeMeta> </FileTreeFile> </StateFrame> diff --git a/packages/dify-ui/src/file-tree/index.tsx b/packages/dify-ui/src/file-tree/index.tsx index a584429ba4191f..11575e51d2b572 100644 --- a/packages/dify-ui/src/file-tree/index.tsx +++ b/packages/dify-ui/src/file-tree/index.tsx @@ -13,15 +13,11 @@ function useFileTreeLevel() { } function getLabelText(children: React.ReactNode) { - return typeof children === 'string' || typeof children === 'number' - ? String(children) - : undefined + return typeof children === 'string' || typeof children === 'number' ? String(children) : undefined } function renderGuides(level: number) { - return Array.from({ length: Math.max(level - 1, 0) }, (_, index) => ( - <FileTreeGuide key={index} /> - )) + return Array.from({ length: Math.max(level - 1, 0) }, (_, index) => <FileTreeGuide key={index} />) } type FileTreeRowState = { @@ -30,11 +26,7 @@ type FileTreeRowState = { level: number } -function fileTreeRowClassName({ - className, -}: { - className?: string -}) { +function fileTreeRowClassName({ className }: { className?: string }) { return cn( 'group/file-tree-row relative flex h-6 w-full min-w-0 cursor-pointer items-center rounded-md ps-2 pe-1.5 text-start outline-hidden select-none', 'hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid', @@ -47,19 +39,10 @@ function fileTreeRowClassName({ export type FileTreeProps = useRender.ComponentProps<'section'> -export function FileTree({ - render, - className, - children, - ...props -}: FileTreeProps) { +export function FileTree({ render, className, children, ...props }: FileTreeProps) { const defaultProps: useRender.ElementProps<'section'> = { className: cn('flex min-w-0 flex-col gap-px p-1', className), - children: ( - <FileTreeLevelContext.Provider value={1}> - {children} - </FileTreeLevelContext.Provider> - ), + children: <FileTreeLevelContext.Provider value={1}>{children}</FileTreeLevelContext.Provider>, } return useRender({ @@ -71,11 +54,7 @@ export function FileTree({ export type FileTreeListProps = useRender.ComponentProps<'ul'> -export function FileTreeList({ - render, - className, - ...props -}: FileTreeListProps) { +export function FileTreeList({ render, className, ...props }: FileTreeListProps) { const defaultProps: useRender.ElementProps<'ul'> = { className: cn('m-0 flex min-w-0 list-none flex-col gap-px p-0', className), } @@ -87,32 +66,18 @@ export function FileTreeList({ }) } -export type FileTreeFolderProps - = Omit<BaseCollapsible.Root.Props, 'render'> - & { - render?: BaseCollapsible.Root.Props['render'] - } +export type FileTreeFolderProps = Omit<BaseCollapsible.Root.Props, 'render'> & { + render?: BaseCollapsible.Root.Props['render'] +} -export function FileTreeFolder({ - render = <li />, - className, - ...props -}: FileTreeFolderProps) { - return ( - <BaseCollapsible.Root - render={render} - className={cn('min-w-0', className)} - {...props} - /> - ) +export function FileTreeFolder({ render = <li />, className, ...props }: FileTreeFolderProps) { + return <BaseCollapsible.Root render={render} className={cn('min-w-0', className)} {...props} /> } -export type FileTreeFolderTriggerProps - = Omit<BaseCollapsible.Trigger.Props, 'className'> - & { - className?: string - level?: number - } +export type FileTreeFolderTriggerProps = Omit<BaseCollapsible.Trigger.Props, 'className'> & { + className?: string + level?: number +} export function FileTreeFolderTrigger({ className, @@ -132,18 +97,14 @@ export function FileTreeFolderTrigger({ {...props} > {renderGuides(level)} - <div className="flex min-w-0 flex-[1_0_0] items-center py-0.5"> - {children} - </div> + <div className="flex min-w-0 flex-[1_0_0] items-center py-0.5">{children}</div> </BaseCollapsible.Trigger> ) } -export type FileTreeFolderPanelProps - = Omit<BaseCollapsible.Panel.Props, 'render'> - & { - render?: BaseCollapsible.Panel.Props['render'] - } +export type FileTreeFolderPanelProps = Omit<BaseCollapsible.Panel.Props, 'render'> & { + render?: BaseCollapsible.Panel.Props['render'] +} export function FileTreeFolderPanel({ render = <ul />, @@ -159,19 +120,18 @@ export function FileTreeFolderPanel({ className={cn('m-0 flex min-w-0 list-none flex-col gap-px p-0', className)} {...props} > - <FileTreeLevelContext.Provider value={level + 1}> - {children} - </FileTreeLevelContext.Provider> + <FileTreeLevelContext.Provider value={level + 1}>{children}</FileTreeLevelContext.Provider> </BaseCollapsible.Panel> ) } -export type FileTreeFileProps - = Omit<useRender.ComponentProps<'button', FileTreeRowState>, 'type'> - & { - level?: number - selected?: boolean - } +export type FileTreeFileProps = Omit< + useRender.ComponentProps<'button', FileTreeRowState>, + 'type' +> & { + level?: number + selected?: boolean +} export function FileTreeFile({ render, @@ -190,18 +150,16 @@ export function FileTreeFile({ level, } const defaultProps = { - 'type': 'button', - 'disabled': disabled, + type: 'button', + disabled, 'data-selected': selected || undefined, 'data-disabled': disabled || undefined, 'aria-current': selected ? 'true' : undefined, - 'className': fileTreeRowClassName({ className }), - 'children': ( + className: fileTreeRowClassName({ className }), + children: ( <React.Fragment> {renderGuides(level)} - <div className="flex min-w-0 flex-[1_0_0] items-center py-0.5"> - {children} - </div> + <div className="flex min-w-0 flex-[1_0_0] items-center py-0.5">{children}</div> </React.Fragment> ), } as useRender.ElementProps<'button'> @@ -218,14 +176,10 @@ export function FileTreeFile({ export type FileTreeGuideProps = useRender.ComponentProps<'span'> -export function FileTreeGuide({ - render, - className, - ...props -}: FileTreeGuideProps) { +export function FileTreeGuide({ render, className, ...props }: FileTreeGuideProps) { const defaultProps: useRender.ElementProps<'span'> = { 'aria-hidden': true, - 'className': cn( + className: cn( 'relative h-6 w-5 shrink-0 before:absolute before:top-0 before:bottom-[-1px] before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-divider-subtle', className, ), @@ -238,18 +192,18 @@ export function FileTreeGuide({ }) } -export type FileTreeIconType - = 'folder' - | 'file' - | 'markdown' - | 'json' - | 'image' - | 'code' - | 'database' - | 'text' - | 'pdf' - | 'table' - | 'archive' +export type FileTreeIconType = + | 'folder' + | 'file' + | 'markdown' + | 'json' + | 'image' + | 'code' + | 'database' + | 'text' + | 'pdf' + | 'table' + | 'archive' const fileTreeIconClassNames: Record<Exclude<FileTreeIconType, 'folder'>, string> = { file: 'i-ri-file-3-fill text-[#A4AABF]', @@ -264,12 +218,10 @@ const fileTreeIconClassNames: Record<Exclude<FileTreeIconType, 'folder'>, string archive: 'i-ri-file-zip-fill text-[#A4AABF]', } -export type FileTreeIconProps - = Omit<useRender.ComponentProps<'span'>, 'children'> - & { - type?: FileTreeIconType - children?: React.ReactNode - } +export type FileTreeIconProps = Omit<useRender.ComponentProps<'span'>, 'children'> & { + type?: FileTreeIconType + children?: React.ReactNode +} export function FileTreeIcon({ type = 'file', @@ -280,19 +232,21 @@ export function FileTreeIcon({ }: FileTreeIconProps) { const defaultProps: useRender.ElementProps<'span'> = { 'aria-hidden': true, - 'className': cn('relative flex size-5 shrink-0 items-center justify-center text-text-secondary', className), - 'children': ( + className: cn( + 'relative flex size-5 shrink-0 items-center justify-center text-text-secondary', + className, + ), + children: ( <React.Fragment> - {children ?? ( - type === 'folder' - ? ( - <React.Fragment> - <span className="i-ri-folder-line size-4 group-data-panel-open/file-tree-row:hidden" /> - <span className="i-ri-folder-open-line hidden size-4 text-text-secondary group-data-panel-open/file-tree-row:block" /> - </React.Fragment> - ) - : <span className={cn('size-4', fileTreeIconClassNames[type])} /> - )} + {children ?? + (type === 'folder' ? ( + <React.Fragment> + <span className="i-ri-folder-line size-4 group-data-panel-open/file-tree-row:hidden" /> + <span className="i-ri-folder-open-line hidden size-4 text-text-secondary group-data-panel-open/file-tree-row:block" /> + </React.Fragment> + ) : ( + <span className={cn('size-4', fileTreeIconClassNames[type])} /> + ))} </React.Fragment> ), } @@ -309,18 +263,14 @@ type FileTreeLabelElementProps = useRender.ElementProps<'span'> & { 'data-label'?: string } -export function FileTreeLabel({ - render, - className, - children, - ...props -}: FileTreeLabelProps) { +export function FileTreeLabel({ render, className, children, ...props }: FileTreeLabelProps) { const labelText = getLabelText(children) const defaultProps = { 'data-label': labelText, - 'className': cn( + className: cn( 'w-0 min-w-0 flex-1 truncate rounded-[5px] px-1 py-0.5', - labelText && 'after:invisible after:block after:h-0 after:overflow-hidden after:system-sm-medium after:content-[attr(data-label)]', + labelText && + 'after:invisible after:block after:h-0 after:overflow-hidden after:system-sm-medium after:content-[attr(data-label)]', 'system-sm-regular text-text-secondary group-data-[selected]/file-tree-row:system-sm-medium group-data-[selected]/file-tree-row:text-text-primary', className, ), @@ -336,11 +286,7 @@ export function FileTreeLabel({ export type FileTreeMetaProps = useRender.ComponentProps<'span'> -export function FileTreeMeta({ - render, - className, - ...props -}: FileTreeMetaProps) { +export function FileTreeMeta({ render, className, ...props }: FileTreeMetaProps) { const defaultProps: useRender.ElementProps<'span'> = { className: cn('min-w-0 shrink truncate system-xs-regular text-text-tertiary', className), } @@ -354,11 +300,7 @@ export function FileTreeMeta({ export type FileTreeBadgeProps = useRender.ComponentProps<'span'> -export function FileTreeBadge({ - render, - className, - ...props -}: FileTreeBadgeProps) { +export function FileTreeBadge({ render, className, ...props }: FileTreeBadgeProps) { const defaultProps: useRender.ElementProps<'span'> = { className: cn( 'ms-1 inline-flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary', diff --git a/packages/dify-ui/src/form-control-shared.ts b/packages/dify-ui/src/form-control-shared.ts index 288c1f7fcd27d3..0a9a5ced598caa 100644 --- a/packages/dify-ui/src/form-control-shared.ts +++ b/packages/dify-ui/src/form-control-shared.ts @@ -1,10 +1,13 @@ import { cva } from 'class-variance-authority' -export const formLabelClassName = 'w-fit py-1 text-text-secondary system-sm-medium data-disabled:cursor-not-allowed' +export const formLabelClassName = + 'w-fit py-1 text-text-secondary system-sm-medium data-disabled:cursor-not-allowed' -export const textControlFocusClassName = 'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs' +export const textControlFocusClassName = + 'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs' -export const textControlCompoundFocusClassName = 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs' +export const textControlCompoundFocusClassName = + 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs' export const textControlVariants = cva( [ diff --git a/packages/dify-ui/src/form/__tests__/index.spec.tsx b/packages/dify-ui/src/form/__tests__/index.spec.tsx index 6fcccaeefa41e4..75a088160c1f6e 100644 --- a/packages/dify-ui/src/form/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/form/__tests__/index.spec.tsx @@ -15,7 +15,9 @@ describe('Form primitive', () => { </Form>, ) - await expect.element(screen.getByRole('form', { name: 'profile form' })).toHaveClass('custom-form') + await expect + .element(screen.getByRole('form', { name: 'profile form' })) + .toHaveClass('custom-form') }) it('should call onFormSubmit with submitted values', async () => { @@ -48,6 +50,8 @@ describe('Form primitive', () => { </Form>, ) - await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveAttribute('aria-invalid', 'true') + await expect + .element(screen.getByRole('textbox', { name: 'Token' })) + .toHaveAttribute('aria-invalid', 'true') }) }) diff --git a/packages/dify-ui/src/form/index.stories.tsx b/packages/dify-ui/src/form/index.stories.tsx index 29cdbe88ebce28..2ed973627eaa45 100644 --- a/packages/dify-ui/src/form/index.stories.tsx +++ b/packages/dify-ui/src/form/index.stories.tsx @@ -2,14 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import { Button } from '../button' import { Checkbox } from '../checkbox' import { CheckboxGroup } from '../checkbox-group' -import { - Field, - FieldControl, - FieldDescription, - FieldError, - FieldItem, - FieldLabel, -} from '../field' +import { Field, FieldControl, FieldDescription, FieldError, FieldItem, FieldLabel } from '../field' import { Fieldset, FieldsetLegend } from '../fieldset' import { Form } from './index' @@ -63,7 +56,9 @@ export const Basic: Story = { </Field> <div className="flex justify-end"> - <Button type="submit" variant="primary">Save</Button> + <Button type="submit" variant="primary"> + Save + </Button> </div> </Form> ), diff --git a/packages/dify-ui/src/input/index.stories.tsx b/packages/dify-ui/src/input/index.stories.tsx index 3111b8e9ecb998..a8d76bd9bfa377 100644 --- a/packages/dify-ui/src/input/index.stories.tsx +++ b/packages/dify-ui/src/input/index.stories.tsx @@ -1,11 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import { Button } from '../button' -import { - Field, - FieldDescription, - FieldError, - FieldLabel, -} from '../field' +import { Field, FieldDescription, FieldError, FieldLabel } from '../field' import { Form } from '../form' import { Input } from './index' @@ -16,7 +11,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'A standalone text input primitive built on Base UI Input. Use it for labelled text boxes outside FieldControl, and keep FieldControl for full Field form composition.', + component: + 'A standalone text input primitive built on Base UI Input. Use it for labelled text boxes outside FieldControl, and keep FieldControl for full Field form composition.', }, }, }, @@ -30,7 +26,10 @@ type Story = StoryObj<typeof meta> export const Basic: Story = { render: () => ( <div className="w-80"> - <label htmlFor="workspace-name" className="mb-1 block w-fit py-1 system-sm-medium text-text-secondary"> + <label + htmlFor="workspace-name" + className="mb-1 block w-fit py-1 system-sm-medium text-text-secondary" + > Workspace name </label> <Input @@ -48,15 +47,32 @@ export const Sizes: Story = { <div className="grid w-80 gap-3"> <label className="grid gap-1 system-sm-medium text-text-secondary" htmlFor="small-input"> Small - <Input id="small-input" size="small" name="smallInput" placeholder="e.g. tag…" autoComplete="off" /> + <Input + id="small-input" + size="small" + name="smallInput" + placeholder="e.g. tag…" + autoComplete="off" + /> </label> <label className="grid gap-1 system-sm-medium text-text-secondary" htmlFor="medium-input"> Medium - <Input id="medium-input" name="mediumInput" placeholder="e.g. Production API…" autoComplete="off" /> + <Input + id="medium-input" + name="mediumInput" + placeholder="e.g. Production API…" + autoComplete="off" + /> </label> <label className="grid gap-1 system-sm-medium text-text-secondary" htmlFor="large-input"> Large - <Input id="large-input" size="large" name="largeInput" placeholder="e.g. Customer portal…" autoComplete="off" /> + <Input + id="large-input" + size="large" + name="largeInput" + placeholder="e.g. Customer portal…" + autoComplete="off" + /> </label> </div> ), @@ -66,12 +82,26 @@ export const States: Story = { render: () => ( <div className="grid w-80 gap-3"> <div className="grid gap-1"> - <label className="system-sm-medium text-text-secondary" htmlFor="placeholder-state">Placeholder</label> - <Input id="placeholder-state" name="placeholderState" placeholder="e.g. Search datasets…" autoComplete="off" /> + <label className="system-sm-medium text-text-secondary" htmlFor="placeholder-state"> + Placeholder + </label> + <Input + id="placeholder-state" + name="placeholderState" + placeholder="e.g. Search datasets…" + autoComplete="off" + /> </div> <div className="grid gap-1"> - <label className="system-sm-medium text-text-secondary" htmlFor="filled-state">Filled</label> - <Input id="filled-state" name="filledState" defaultValue="Customer knowledge base" autoComplete="off" /> + <label className="system-sm-medium text-text-secondary" htmlFor="filled-state"> + Filled + </label> + <Input + id="filled-state" + name="filledState" + defaultValue="Customer knowledge base" + autoComplete="off" + /> </div> <div className="grid gap-1"> <Field name="repositoryUrl" invalid> @@ -88,12 +118,34 @@ export const States: Story = { </Field> </div> <div className="grid gap-1"> - <label className="system-sm-medium text-text-secondary" htmlFor="disabled-state">Disabled</label> - <Input id="disabled-state" disabled name="disabledEmail" type="email" inputMode="email" placeholder="name@example.com…" autoComplete="email" spellCheck={false} /> + <label className="system-sm-medium text-text-secondary" htmlFor="disabled-state"> + Disabled + </label> + <Input + id="disabled-state" + disabled + name="disabledEmail" + type="email" + inputMode="email" + placeholder="name@example.com…" + autoComplete="email" + spellCheck={false} + /> </div> <div className="grid gap-1"> - <label className="system-sm-medium text-text-secondary" htmlFor="readonly-state">Read-only</label> - <Input id="readonly-state" readOnly name="endpoint" type="url" inputMode="url" defaultValue="https://api.example.com" autoComplete="url" spellCheck={false} /> + <label className="system-sm-medium text-text-secondary" htmlFor="readonly-state"> + Read-only + </label> + <Input + id="readonly-state" + readOnly + name="endpoint" + type="url" + inputMode="url" + defaultValue="https://api.example.com" + autoComplete="url" + spellCheck={false} + /> </div> </div> ), @@ -104,20 +156,36 @@ export const WithField: Story = { <Form aria-label="Account form" className="grid w-80 gap-4" onFormSubmit={() => undefined}> <Field name="email"> <FieldLabel>Email</FieldLabel> - <Input type="email" inputMode="email" required autoComplete="email" placeholder="name@example.com…" spellCheck={false} /> + <Input + type="email" + inputMode="email" + required + autoComplete="email" + placeholder="name@example.com…" + spellCheck={false} + /> <FieldDescription>Used for account notifications.</FieldDescription> <FieldError match="valueMissing">Email is required.</FieldError> <FieldError match="typeMismatch">Enter a valid email address.</FieldError> </Field> <Field name="repositoryUrl"> <FieldLabel>Repository URL</FieldLabel> - <Input type="url" inputMode="url" required autoComplete="off" placeholder="https://github.com/langgenius/dify…" spellCheck={false} /> + <Input + type="url" + inputMode="url" + required + autoComplete="off" + placeholder="https://github.com/langgenius/dify…" + spellCheck={false} + /> <FieldDescription>Use the full GitHub repository URL.</FieldDescription> <FieldError match="valueMissing">Repository URL is required.</FieldError> <FieldError match="typeMismatch">Enter a valid URL.</FieldError> </Field> <div className="flex justify-end"> - <Button type="submit" variant="primary">Save Settings</Button> + <Button type="submit" variant="primary"> + Save Settings + </Button> </div> </Form> ), diff --git a/packages/dify-ui/src/input/index.tsx b/packages/dify-ui/src/input/index.tsx index 4f48f3cdae6219..aa51fc4dd0806a 100644 --- a/packages/dify-ui/src/input/index.tsx +++ b/packages/dify-ui/src/input/index.tsx @@ -8,24 +8,13 @@ import { textControlVariants } from '../form-control-shared' export type InputSize = NonNullable<VariantProps<typeof textControlVariants>['size']> -export type InputProps - = Omit<BaseInputNS.Props, 'className' | 'size'> - & VariantProps<typeof textControlVariants> - & { - className?: string - } +export type InputProps = Omit<BaseInputNS.Props, 'className' | 'size'> & + VariantProps<typeof textControlVariants> & { + className?: string + } export type InputChangeEventDetails = BaseInputNS.ChangeEventDetails -export function Input({ - className, - size = 'medium', - ...props -}: InputProps) { - return ( - <BaseInput - className={cn(textControlVariants({ size }), className)} - {...props} - /> - ) +export function Input({ className, size = 'medium', ...props }: InputProps) { + return <BaseInput className={cn(textControlVariants({ size }), className)} {...props} /> } diff --git a/packages/dify-ui/src/internals/use-iso-layout-effect.ts b/packages/dify-ui/src/internals/use-iso-layout-effect.ts index 44bf4fb9371dbf..b99a0c6ce974c7 100644 --- a/packages/dify-ui/src/internals/use-iso-layout-effect.ts +++ b/packages/dify-ui/src/internals/use-iso-layout-effect.ts @@ -4,6 +4,4 @@ import * as React from 'react' const noop: typeof React.useLayoutEffect = () => {} -export const useIsoLayoutEffect = typeof document !== 'undefined' - ? React.useLayoutEffect - : noop +export const useIsoLayoutEffect = typeof document !== 'undefined' ? React.useLayoutEffect : noop diff --git a/packages/dify-ui/src/kbd/index.stories.tsx b/packages/dify-ui/src/kbd/index.stories.tsx index f54762f63bd7be..11c9b59e268199 100644 --- a/packages/dify-ui/src/kbd/index.stories.tsx +++ b/packages/dify-ui/src/kbd/index.stories.tsx @@ -9,12 +9,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from '../context-menu' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '../tooltip' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../tooltip' const meta = { title: 'Base/UI/Kbd', @@ -24,9 +19,9 @@ const meta = { docs: { description: { component: - 'Keyboard input primitives aligned with the Dify Key Set design. ' - + '`Kbd` renders a native `<kbd>` element for a single key or key-like token. ' - + '`KbdGroup` only groups multiple keycaps; it does not replace the individual `<kbd>` semantics.', + 'Keyboard input primitives aligned with the Dify Key Set design. ' + + '`Kbd` renders a native `<kbd>` element for a single key or key-like token. ' + + '`KbdGroup` only groups multiple keycaps; it does not replace the individual `<kbd>` semantics.', }, }, }, @@ -51,13 +46,12 @@ const displayKeys = ( hotkey: RegisterableHotkey | (string & {}), platform: FormatDisplayOptions['platform'] = 'mac', ) => { - if (typeof hotkey !== 'string') - return [formatForDisplay(hotkey, { platform })] + if (typeof hotkey !== 'string') return [formatForDisplay(hotkey, { platform })] return hotkey .split('+') .filter(Boolean) - .map(key => formatForDisplay(key, { platform })) + .map((key) => formatForDisplay(key, { platform })) } const HotkeyKbdGroup = ({ @@ -87,7 +81,8 @@ export const KeySet: Story = { parameters: { docs: { description: { - story: 'Figma Key Set variants: gray and white, each with a disabled state. Disabled is visual only because `<kbd>` is not an interactive widget.', + story: + 'Figma Key Set variants: gray and white, each with a disabled state. Disabled is visual only because `<kbd>` is not an interactive widget.', }, }, }, @@ -112,8 +107,12 @@ export const KeySet: Story = { </div> <div className="rounded-lg bg-gray-900 p-2"> <KbdGroup> - <Kbd color="white" disabled>⌘</Kbd> - <Kbd color="white" disabled>⇧</Kbd> + <Kbd color="white" disabled> + ⌘ + </Kbd> + <Kbd color="white" disabled> + ⇧ + </Kbd> </KbdGroup> </div> </div> @@ -124,7 +123,8 @@ export const FormattedShortcuts: Story = { parameters: { docs: { description: { - story: '`Kbd` does not parse hotkeys. Compose it with a formatter at the feature layer; this story uses TanStack Hotkeys `formatForDisplay` for platform-aware labels.', + story: + '`Kbd` does not parse hotkeys. Compose it with a formatter at the feature layer; this story uses TanStack Hotkeys `formatForDisplay` for platform-aware labels.', }, }, }, @@ -151,7 +151,7 @@ export const FormattedShortcuts: Story = { export const InTooltip: Story = { decorators: [ - Story => ( + (Story) => ( <TooltipProvider delay={0}> <Story /> </TooltipProvider> @@ -160,14 +160,15 @@ export const InTooltip: Story = { parameters: { docs: { description: { - story: 'Shortcut keycaps can be composed inside short tooltip content. The trigger keeps its own accessible name; the tooltip is only a visual hint.', + story: + 'Shortcut keycaps can be composed inside short tooltip content. The trigger keeps its own accessible name; the tooltip is only a visual hint.', }, }, }, render: () => ( <Tooltip open> <TooltipTrigger - render={( + render={ <button type="button" aria-label="Collapse sidebar" @@ -175,7 +176,7 @@ export const InTooltip: Story = { > <span aria-hidden className="i-ri-sidebar-fold-line size-4" /> </button> - )} + } /> <TooltipContent className="flex items-center gap-1"> <span>Collapse sidebar</span> @@ -195,19 +196,20 @@ export const InContextMenu: Story = { parameters: { docs: { description: { - story: 'A compact context-menu composition based on the Dify Design Kit context menu example. The menu is intentionally small here because the story focuses on shortcut keycaps.', + story: + 'A compact context-menu composition based on the Dify Design Kit context menu example. The menu is intentionally small here because the story focuses on shortcut keycaps.', }, }, }, render: () => ( <ContextMenu> <ContextMenuTrigger - render={( + render={ <button type="button" className="flex h-28 w-60 items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle px-6 text-center system-sm-regular text-text-tertiary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" /> - )} + } > Context menu trigger </ContextMenuTrigger> diff --git a/packages/dify-ui/src/kbd/index.tsx b/packages/dify-ui/src/kbd/index.tsx index 195b84af2db7c9..7959a071daafe9 100644 --- a/packages/dify-ui/src/kbd/index.tsx +++ b/packages/dify-ui/src/kbd/index.tsx @@ -27,16 +27,9 @@ const kbdVariants = cva( export type KbdColor = NonNullable<VariantProps<typeof kbdVariants>['color']> -export type KbdProps - = Omit<React.ComponentProps<'kbd'>, 'color'> - & VariantProps<typeof kbdVariants> +export type KbdProps = Omit<React.ComponentProps<'kbd'>, 'color'> & VariantProps<typeof kbdVariants> -export function Kbd({ - className, - color, - disabled, - ...props -}: KbdProps) { +export function Kbd({ className, color, disabled, ...props }: KbdProps) { return ( <kbd data-disabled={disabled ? '' : undefined} @@ -48,14 +41,8 @@ export function Kbd({ export type KbdGroupProps = React.ComponentProps<'span'> -export function KbdGroup({ - className, - ...props -}: KbdGroupProps) { +export function KbdGroup({ className, ...props }: KbdGroupProps) { return ( - <span - className={cn('inline-flex items-center gap-0.5 align-middle', className)} - {...props} - /> + <span className={cn('inline-flex items-center gap-0.5 align-middle', className)} {...props} /> ) } diff --git a/packages/dify-ui/src/meter/__tests__/index.spec.tsx b/packages/dify-ui/src/meter/__tests__/index.spec.tsx index a19bb9f49d0bff..90a2cf74c799cf 100644 --- a/packages/dify-ui/src/meter/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/meter/__tests__/index.spec.tsx @@ -1,11 +1,5 @@ import { render } from 'vitest-browser-react' -import { - Meter, - MeterIndicator, - MeterLabel, - MeterTrack, - MeterValue, -} from '../index' +import { Meter, MeterIndicator, MeterLabel, MeterTrack, MeterValue } from '../index' describe('Meter compound primitives', () => { it('exposes role="meter" with ARIA value metadata', async () => { diff --git a/packages/dify-ui/src/meter/index.stories.tsx b/packages/dify-ui/src/meter/index.stories.tsx index aafd400b422683..11d775c56cda9f 100644 --- a/packages/dify-ui/src/meter/index.stories.tsx +++ b/packages/dify-ui/src/meter/index.stories.tsx @@ -9,10 +9,10 @@ const meta = { docs: { description: { component: - 'A graphical display of a numeric value within a known range. ' - + 'Use the compound primitives (`Meter / MeterTrack / MeterIndicator / ' - + 'MeterValue / MeterLabel`) for quota, capacity, or score indicators; do ' - + 'not use for task-completion progress.', + 'A graphical display of a numeric value within a known range. ' + + 'Use the compound primitives (`Meter / MeterTrack / MeterIndicator / ' + + 'MeterValue / MeterLabel`) for quota, capacity, or score indicators; do ' + + 'not use for task-completion progress.', }, }, }, @@ -25,10 +25,10 @@ type Story = StoryObj<typeof meta> export const Default: Story = { args: { - 'value': 42, + value: 42, 'aria-label': 'Quota used', }, - render: args => ( + render: (args) => ( <div className="w-[320px]"> <Meter {...args}> <MeterTrack> @@ -41,10 +41,10 @@ export const Default: Story = { export const Warning: Story = { args: { - 'value': 85, + value: 85, 'aria-label': 'Quota used', }, - render: args => ( + render: (args) => ( <div className="w-[320px]"> <Meter {...args}> <MeterTrack> @@ -57,10 +57,10 @@ export const Warning: Story = { export const Error: Story = { args: { - 'value': 100, + value: 100, 'aria-label': 'Quota used', }, - render: args => ( + render: (args) => ( <div className="w-[320px]"> <Meter {...args}> <MeterTrack> @@ -73,10 +73,10 @@ export const Error: Story = { export const ComposedWithLabelAndValue: Story = { args: { - 'value': 62, + value: 62, 'aria-label': 'Storage used', }, - render: args => ( + render: (args) => ( <div className="w-[320px] space-y-2 rounded-xl bg-components-panel-bg p-4"> <Meter {...args}> <div className="flex items-center justify-between"> @@ -93,13 +93,13 @@ export const ComposedWithLabelAndValue: Story = { export const PercentFormatted: Story = { args: { - 'value': 0.73, - 'min': 0, - 'max': 1, - 'format': { style: 'percent', maximumFractionDigits: 0 }, + value: 0.73, + min: 0, + max: 1, + format: { style: 'percent', maximumFractionDigits: 0 }, 'aria-label': 'Retrieval score', }, - render: args => ( + render: (args) => ( <div className="w-[320px] space-y-2"> <Meter {...args}> <div className="flex items-center justify-between"> diff --git a/packages/dify-ui/src/meter/index.tsx b/packages/dify-ui/src/meter/index.tsx index 92e6a8df68c04a..00f7f156a8d2ed 100644 --- a/packages/dify-ui/src/meter/index.tsx +++ b/packages/dify-ui/src/meter/index.tsx @@ -18,18 +18,13 @@ import { cn } from '../cn' export const Meter = BaseMeter.Root export type MeterProps = BaseMeter.Root.Props -const meterTrackClassName - = 'relative block h-1 w-full overflow-hidden rounded-md bg-components-progress-bar-bg' +const meterTrackClassName = + 'relative block h-1 w-full overflow-hidden rounded-md bg-components-progress-bar-bg' export type MeterTrackProps = BaseMeter.Track.Props export function MeterTrack({ className, ...props }: MeterTrackProps) { - return ( - <BaseMeter.Track - className={cn(meterTrackClassName, className)} - {...props} - /> - ) + return <BaseMeter.Track className={cn(meterTrackClassName, className)} {...props} /> } const meterIndicatorVariants = cva( @@ -56,10 +51,7 @@ export type MeterIndicatorProps = BaseMeter.Indicator.Props & { export function MeterIndicator({ className, tone, ...props }: MeterIndicatorProps) { return ( - <BaseMeter.Indicator - className={cn(meterIndicatorVariants({ tone }), className)} - {...props} - /> + <BaseMeter.Indicator className={cn(meterIndicatorVariants({ tone }), className)} {...props} /> ) } @@ -67,22 +59,12 @@ const meterValueClassName = 'system-xs-regular text-text-tertiary tabular-nums' export type MeterValueProps = BaseMeter.Value.Props export function MeterValue({ className, ...props }: MeterValueProps) { - return ( - <BaseMeter.Value - className={cn(meterValueClassName, className)} - {...props} - /> - ) + return <BaseMeter.Value className={cn(meterValueClassName, className)} {...props} /> } const meterLabelClassName = 'system-xs-medium text-text-tertiary' export type MeterLabelProps = BaseMeter.Label.Props export function MeterLabel({ className, ...props }: MeterLabelProps) { - return ( - <BaseMeter.Label - className={cn(meterLabelClassName, className)} - {...props} - /> - ) + return <BaseMeter.Label className={cn(meterLabelClassName, className)} {...props} /> } diff --git a/packages/dify-ui/src/number-field/__tests__/index.spec.tsx b/packages/dify-ui/src/number-field/__tests__/index.spec.tsx index 2bf94a5ee8c4d0..5f83f07f52dd90 100644 --- a/packages/dify-ui/src/number-field/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/number-field/__tests__/index.spec.tsx @@ -7,10 +7,7 @@ import type { } from '../index' import * as React from 'react' import { render } from 'vitest-browser-react' -import { - Field, - FieldLabel, -} from '../../field' +import { Field, FieldLabel } from '../../field' import { NumberField, NumberFieldControls, @@ -40,10 +37,7 @@ const renderNumberField = ({ incrementProps, decrementProps, }: RenderNumberFieldOptions = {}) => { - const { - children: unitChildren = 'ms', - ...restUnitProps - } = unitProps ?? {} + const { children: unitChildren = 'ms', ...restUnitProps } = unitProps ?? {} return render( <NumberField defaultValue={defaultValue}> @@ -90,7 +84,9 @@ describe('NumberField wrapper', () => { ) await expect.element(screen.getByTestId('group')).toHaveAttribute('data-invalid') - await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveAttribute('aria-invalid', 'true') + await expect + .element(screen.getByRole('textbox', { name: 'Amount' })) + .toHaveAttribute('aria-invalid', 'true') }) it('should set input defaults and forward passthrough props', async () => { @@ -102,11 +98,19 @@ describe('NumberField wrapper', () => { }, }) - await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveAttribute('autocomplete', 'off') - await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveAttribute('autocorrect', 'off') - await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveAttribute('placeholder', 'Regular placeholder') + await expect + .element(screen.getByRole('textbox', { name: 'Amount' })) + .toHaveAttribute('autocomplete', 'off') + await expect + .element(screen.getByRole('textbox', { name: 'Amount' })) + .toHaveAttribute('autocorrect', 'off') + await expect + .element(screen.getByRole('textbox', { name: 'Amount' })) + .toHaveAttribute('placeholder', 'Regular placeholder') await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toBeRequired() - await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveClass('custom-input') + await expect + .element(screen.getByRole('textbox', { name: 'Amount' })) + .toHaveClass('custom-input') }) }) @@ -132,7 +136,9 @@ describe('NumberField wrapper', () => { }, }) - await expect.element(screen.getByTestId('controls')).toHaveAttribute('title', 'controls-title') + await expect + .element(screen.getByTestId('controls')) + .toHaveAttribute('title', 'controls-title') await expect.element(screen.getByTestId('controls')).toHaveClass('custom-controls') }) }) @@ -143,8 +149,12 @@ describe('NumberField wrapper', () => { controlsProps: {}, }) - await expect.element(screen.getByRole('button', { name: 'Increment value' })).toBeInTheDocument() - await expect.element(screen.getByRole('button', { name: 'Decrement value' })).toBeInTheDocument() + await expect + .element(screen.getByRole('button', { name: 'Increment value' })) + .toBeInTheDocument() + await expect + .element(screen.getByRole('button', { name: 'Decrement value' })) + .toBeInTheDocument() }) it('should preserve explicit aria labels and custom children', async () => { @@ -152,16 +162,20 @@ describe('NumberField wrapper', () => { controlsProps: {}, incrementProps: { 'aria-label': 'Increase amount', - 'children': <span data-testid="custom-increment-icon">+</span>, + children: <span data-testid="custom-increment-icon">+</span>, }, decrementProps: { 'aria-label': 'Decrease amount', - 'children': <span data-testid="custom-decrement-icon">-</span>, + children: <span data-testid="custom-decrement-icon">-</span>, }, }) - expect(screen.getByRole('button', { name: 'Increase amount' }).element()).toContainElement(screen.getByTestId('custom-increment-icon').element()) - expect(screen.getByRole('button', { name: 'Decrease amount' }).element()).toContainElement(screen.getByTestId('custom-decrement-icon').element()) + expect(screen.getByRole('button', { name: 'Increase amount' }).element()).toContainElement( + screen.getByTestId('custom-increment-icon').element(), + ) + expect(screen.getByRole('button', { name: 'Decrease amount' }).element()).toContainElement( + screen.getByTestId('custom-decrement-icon').element(), + ) }) it('should keep the fallback aria labels when aria-label is omitted in props', async () => { @@ -175,8 +189,12 @@ describe('NumberField wrapper', () => { }, }) - await expect.element(screen.getByRole('button', { name: 'Increment value' })).toBeInTheDocument() - await expect.element(screen.getByRole('button', { name: 'Decrement value' })).toBeInTheDocument() + await expect + .element(screen.getByRole('button', { name: 'Increment value' })) + .toBeInTheDocument() + await expect + .element(screen.getByRole('button', { name: 'Decrement value' })) + .toBeInTheDocument() }) it('should rely on aria-labelledby when provided instead of injecting a fallback aria-label', async () => { @@ -196,8 +214,12 @@ describe('NumberField wrapper', () => { </React.Fragment>, ) - await expect.element(screen.getByRole('button', { name: 'Increment from label' })).not.toHaveAttribute('aria-label') - await expect.element(screen.getByRole('button', { name: 'Decrement from label' })).not.toHaveAttribute('aria-label') + await expect + .element(screen.getByRole('button', { name: 'Increment from label' })) + .not.toHaveAttribute('aria-label') + await expect + .element(screen.getByRole('button', { name: 'Decrement from label' })) + .not.toHaveAttribute('aria-label') }) it('should forward passthrough props to control buttons', async () => { @@ -214,7 +236,9 @@ describe('NumberField wrapper', () => { await expect.element(screen.getByTestId('increment')).toHaveClass('custom-increment') await expect.element(screen.getByTestId('decrement')).toHaveClass('custom-decrement') - await expect.element(screen.getByTestId('decrement')).toHaveAttribute('title', 'decrement-title') + await expect + .element(screen.getByTestId('decrement')) + .toHaveAttribute('title', 'decrement-title') }) }) }) diff --git a/packages/dify-ui/src/number-field/index.stories.tsx b/packages/dify-ui/src/number-field/index.stories.tsx index 44fbc3ab9698e6..bd1311b3681c5e 100644 --- a/packages/dify-ui/src/number-field/index.stories.tsx +++ b/packages/dify-ui/src/number-field/index.stories.tsx @@ -1,12 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' import { Button } from '../button' -import { - Field, - FieldDescription, - FieldError, - FieldLabel, -} from '../field' +import { Field, FieldDescription, FieldError, FieldLabel } from '../field' import { Form } from '../form' import { NumberField, @@ -25,7 +20,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound numeric input built on Base UI NumberField. Use it with Field for labelled, described, and validated form fields.', + component: + 'Compound numeric input built on Base UI NumberField. Use it with Field for labelled, described, and validated form fields.', }, }, }, @@ -234,9 +230,7 @@ function ControlledDemo() { </NumberFieldGroup> </NumberField> <FieldDescription> - Current value: - {' '} - {value === null ? 'Empty' : value.toFixed(2)} + Current value: {value === null ? 'Empty' : value.toFixed(2)} </FieldDescription> </Field> ) @@ -278,13 +272,13 @@ function FormDemo() { <FieldError match="rangeOverflow">Use 10 or fewer.</FieldError> </Field> <div className="flex justify-end"> - <Button type="submit" variant="primary">Save Settings</Button> + <Button type="submit" variant="primary"> + Save Settings + </Button> </div> {savedValue && ( <div className="rounded-lg bg-background-section px-3 py-2 system-xs-regular text-text-secondary"> - Saved: - {' '} - {savedValue} + Saved: {savedValue} </div> )} </Form> diff --git a/packages/dify-ui/src/number-field/index.tsx b/packages/dify-ui/src/number-field/index.tsx index 9a9c5060eeedd3..455d01259b878d 100644 --- a/packages/dify-ui/src/number-field/index.tsx +++ b/packages/dify-ui/src/number-field/index.tsx @@ -35,18 +35,12 @@ export const numberFieldGroupVariants = cva( ) export type NumberFieldSize = NonNullable<VariantProps<typeof numberFieldGroupVariants>['size']> -export type NumberFieldGroupProps - = Omit<BaseNumberField.Group.Props, 'className'> - & VariantProps<typeof numberFieldGroupVariants> - & { - className?: string - } - -export function NumberFieldGroup({ - className, - size = 'medium', - ...props -}: NumberFieldGroupProps) { +export type NumberFieldGroupProps = Omit<BaseNumberField.Group.Props, 'className'> & + VariantProps<typeof numberFieldGroupVariants> & { + className?: string + } + +export function NumberFieldGroup({ className, size = 'medium', ...props }: NumberFieldGroupProps) { return ( <BaseNumberField.Group className={cn(numberFieldGroupVariants({ size }), className)} @@ -75,18 +69,12 @@ export const numberFieldInputVariants = cva( }, ) -export type NumberFieldInputProps - = Omit<BaseNumberField.Input.Props, 'className' | 'size'> - & VariantProps<typeof numberFieldInputVariants> - & { - className?: string - } +export type NumberFieldInputProps = Omit<BaseNumberField.Input.Props, 'className' | 'size'> & + VariantProps<typeof numberFieldInputVariants> & { + className?: string + } -export function NumberFieldInput({ - className, - size = 'medium', - ...props -}: NumberFieldInputProps) { +export function NumberFieldInput({ className, size = 'medium', ...props }: NumberFieldInputProps) { return ( <BaseNumberField.Input className={cn(numberFieldInputVariants({ size }), className)} @@ -110,19 +98,11 @@ export const numberFieldUnitVariants = cva( }, ) -export type NumberFieldUnitProps = React.ComponentProps<'span'> & VariantProps<typeof numberFieldUnitVariants> +export type NumberFieldUnitProps = React.ComponentProps<'span'> & + VariantProps<typeof numberFieldUnitVariants> -export function NumberFieldUnit({ - className, - size = 'medium', - ...props -}: NumberFieldUnitProps) { - return ( - <span - className={cn(numberFieldUnitVariants({ size }), className)} - {...props} - /> - ) +export function NumberFieldUnit({ className, size = 'medium', ...props }: NumberFieldUnitProps) { + return <span className={cn(numberFieldUnitVariants({ size }), className)} {...props} /> } const numberFieldControlsVariants = cva( @@ -131,16 +111,8 @@ const numberFieldControlsVariants = cva( export type NumberFieldControlsProps = React.ComponentProps<'div'> -export function NumberFieldControls({ - className, - ...props -}: NumberFieldControlsProps) { - return ( - <div - className={cn(numberFieldControlsVariants(), className)} - {...props} - /> - ) +export function NumberFieldControls({ className, ...props }: NumberFieldControlsProps) { + return <div className={cn(numberFieldControlsVariants(), className)} {...props} /> } const numberFieldControlButtonVariants = cva( @@ -198,12 +170,10 @@ type NumberFieldButtonVariantProps = Omit< 'direction' > -export type NumberFieldButtonProps - = Omit<BaseNumberField.Increment.Props, 'className'> - & NumberFieldButtonVariantProps - & { - className?: string - } +export type NumberFieldButtonProps = Omit<BaseNumberField.Increment.Props, 'className'> & + NumberFieldButtonVariantProps & { + className?: string + } const incrementAriaLabel = 'Increment value' const decrementAriaLabel = 'Decrement value' @@ -217,7 +187,9 @@ export function NumberFieldIncrement({ return ( <BaseNumberField.Increment {...props} - aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : incrementAriaLabel)} + aria-label={ + props['aria-label'] ?? (props['aria-labelledby'] ? undefined : incrementAriaLabel) + } className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)} > {children ?? <span aria-hidden="true" className="i-ri-arrow-up-s-line size-3" />} @@ -234,7 +206,9 @@ export function NumberFieldDecrement({ return ( <BaseNumberField.Decrement {...props} - aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : decrementAriaLabel)} + aria-label={ + props['aria-label'] ?? (props['aria-labelledby'] ? undefined : decrementAriaLabel) + } className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)} > {children ?? <span aria-hidden="true" className="i-ri-arrow-down-s-line size-3" />} diff --git a/packages/dify-ui/src/overlay-shared.ts b/packages/dify-ui/src/overlay-shared.ts index 2d90dd372af23a..7710ed0a08079f 100644 --- a/packages/dify-ui/src/overlay-shared.ts +++ b/packages/dify-ui/src/overlay-shared.ts @@ -1,10 +1,16 @@ export type OverlayItemVariant = 'default' | 'destructive' -export const overlayRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-hidden data-highlighted:bg-state-base-hover data-disabled:cursor-not-allowed data-disabled:opacity-30' -export const overlayDestructiveClassName = 'data-[variant=destructive]:text-text-destructive data-[variant=destructive]:data-highlighted:bg-state-destructive-hover' +export const overlayRowClassName = + 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-hidden data-highlighted:bg-state-base-hover data-disabled:cursor-not-allowed data-disabled:opacity-30' +export const overlayDestructiveClassName = + 'data-[variant=destructive]:text-text-destructive data-[variant=destructive]:data-highlighted:bg-state-destructive-hover' export const overlayIndicatorClassName = 'ms-auto flex shrink-0 items-center text-text-accent' -export const overlayLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase' +export const overlayLabelClassName = + 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase' export const overlaySeparatorClassName = 'my-1 h-px bg-divider-subtle' -export const overlayPopupBaseClassName = 'max-h-(--available-height) overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-hidden focus:outline-hidden focus-visible:outline-hidden backdrop-blur-[5px]' -export const overlayPopupAnimationClassName = 'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' -export const overlayBackdropClassName = 'fixed inset-0 z-50 bg-transparent transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' +export const overlayPopupBaseClassName = + 'max-h-(--available-height) overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-hidden focus:outline-hidden focus-visible:outline-hidden backdrop-blur-[5px]' +export const overlayPopupAnimationClassName = + 'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' +export const overlayBackdropClassName = + 'fixed inset-0 z-50 bg-transparent transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' diff --git a/packages/dify-ui/src/pagination/__tests__/index.spec.tsx b/packages/dify-ui/src/pagination/__tests__/index.spec.tsx index a55f6d2f6cb40f..bdcac83f3929fb 100644 --- a/packages/dify-ui/src/pagination/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/pagination/__tests__/index.spec.tsx @@ -62,11 +62,17 @@ describe('Pagination primitive', () => { it('renders the pagination structure with semantic navigation', async () => { const { screen } = await renderPagination() - await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '2') + await expect + .element(screen.getByRole('navigation', { name: 'Pagination' })) + .toHaveAttribute('data-page', '2') await expect.element(screen.getByRole('button', { name: 'Previous page' })).toBeInTheDocument() await expect.element(screen.getByRole('button', { name: 'Next page' })).toBeInTheDocument() - await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toHaveTextContent('2/200') - await expect.element(screen.getByRole('button', { name: 'Page 2, current page' })).toHaveAttribute('aria-current', 'page') + await expect + .element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })) + .toHaveTextContent('2/200') + await expect + .element(screen.getByRole('button', { name: 'Page 2, current page' })) + .toHaveAttribute('aria-current', 'page') await expect.element(screen.getByText('…')).toBeInTheDocument() }) @@ -97,20 +103,31 @@ describe('Pagination primitive', () => { it('clamps invalid root page values without exposing invalid state', async () => { const { screen } = await renderPagination({ page: 999, totalPages: 10 }) - await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '10') - await expect.element(screen.getByRole('button', { name: 'Page 10, current page' })).toHaveAttribute('aria-current', 'page') + await expect + .element(screen.getByRole('navigation', { name: 'Pagination' })) + .toHaveAttribute('data-page', '10') + await expect + .element(screen.getByRole('button', { name: 'Page 10, current page' })) + .toHaveAttribute('aria-current', 'page') }) it('switches the page summary into a selected labelled number field', async () => { const { screen } = await renderPagination() - asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click() + asHTMLElement( + screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element(), + ).click() await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument() - const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement + const input = asHTMLElement( + screen.getByRole('textbox', { name: 'Page number' }).element(), + ) as HTMLInputElement await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toHaveValue('2') - expect(input.parentElement?.parentElement?.parentElement).toHaveAttribute('data-page-summary', '2/200') + expect(input.parentElement?.parentElement?.parentElement).toHaveAttribute( + 'data-page-summary', + '2/200', + ) await vi.waitFor(() => { expect(input.selectionStart).toBe(0) expect(input.selectionEnd).toBe(1) @@ -120,31 +137,43 @@ describe('Pagination primitive', () => { it('returns to the summary button when the page input loses focus', async () => { const { screen } = await renderPagination() - asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click() + asHTMLElement( + screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element(), + ).click() await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument() asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()).blur() - await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeInTheDocument() + await expect + .element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })) + .toBeInTheDocument() }) it('commits the page input editing mode with Enter', async () => { const { screen } = await renderPagination() - asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click() + asHTMLElement( + screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element(), + ).click() await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument() - const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement + const input = asHTMLElement( + screen.getByRole('textbox', { name: 'Page number' }).element(), + ) as HTMLInputElement await vi.waitFor(() => { expect(document.activeElement).toBe(input) }) - input.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Enter', - bubbles: true, - cancelable: true, - })) + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }), + ) - const summaryButton = screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }) + const summaryButton = screen.getByRole('button', { + name: 'Edit page number, current page 2 of 200', + }) await expect.element(summaryButton).toBeInTheDocument() await vi.waitFor(() => { expect(document.activeElement).toBe(summaryButton.element()) @@ -154,21 +183,29 @@ describe('Pagination primitive', () => { it('cancels the page input editing mode with Escape', async () => { const { screen, onPageChange } = await renderPagination() - asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click() + asHTMLElement( + screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element(), + ).click() await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument() - const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement + const input = asHTMLElement( + screen.getByRole('textbox', { name: 'Page number' }).element(), + ) as HTMLInputElement await vi.waitFor(() => { expect(document.activeElement).toBe(input) }) - input.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Escape', - bubbles: true, - cancelable: true, - })) + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }), + ) - const summaryButton = screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }) + const summaryButton = screen.getByRole('button', { + name: 'Edit page number, current page 2 of 200', + }) await expect.element(summaryButton).toBeInTheDocument() await vi.waitFor(() => { expect(document.activeElement).toBe(summaryButton.element()) @@ -179,7 +216,9 @@ describe('Pagination primitive', () => { it('uses segmented control semantics for page size', async () => { const { screen, onPageSizeChange } = await renderPagination() - await expect.element(screen.getByRole('button', { name: '25' })).toHaveAttribute('aria-pressed', 'true') + await expect + .element(screen.getByRole('button', { name: '25' })) + .toHaveAttribute('aria-pressed', 'true') asHTMLElement(screen.getByRole('button', { name: '50' }).element()).click() @@ -201,7 +240,9 @@ describe('Pagination primitive', () => { />, ) - await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 10' })).toBeInTheDocument() + await expect + .element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 10' })) + .toBeInTheDocument() await expect.element(screen.getByRole('group', { name: 'Items per page' })).toBeInTheDocument() }) @@ -212,45 +253,44 @@ describe('Pagination primitive', () => { totalPages={10} onPageChange={vi.fn()} labels={{ - editPageNumber: (page, totalPages) => `Change page, current page ${page} of ${totalPages}`, + editPageNumber: (page, totalPages) => + `Change page, current page ${page} of ${totalPages}`, }} />, ) - await expect.element(screen.getByRole('button', { name: 'Change page, current page 2 of 10' })).toBeInTheDocument() + await expect + .element(screen.getByRole('button', { name: 'Change page, current page 2 of 10' })) + .toBeInTheDocument() }) it('keeps facade page numbers centered when page size controls are omitted', async () => { - const screen = await render( - <Pagination - page={2} - totalPages={10} - onPageChange={vi.fn()} - />, - ) + const screen = await render(<Pagination page={2} totalPages={10} onPageChange={vi.fn()} />) await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument() }) it('does not expose invalid page controls when there are no pages', async () => { - const screen = await render( - <Pagination - page={1} - totalPages={0} - onPageChange={vi.fn()} - />, - ) + const screen = await render(<Pagination page={1} totalPages={0} onPageChange={vi.fn()} />) expect(screen.container.querySelector('nav[aria-label="Pagination"]')).not.toBeInTheDocument() - expect(screen.container.querySelector('button[aria-label*="current page 1 of 0"]')).not.toBeInTheDocument() + expect( + screen.container.querySelector('button[aria-label*="current page 1 of 0"]'), + ).not.toBeInTheDocument() }) it('omits compound page jump and page list content for empty pagination state', async () => { const { screen } = await renderPagination({ page: 1, totalPages: 0 }) - await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '1') - expect(screen.container.querySelector('button[aria-label*="current page 1 of 0"]')).not.toBeInTheDocument() - expect(screen.container.querySelector('button[aria-label="Previous page"]')).not.toBeInTheDocument() + await expect + .element(screen.getByRole('navigation', { name: 'Pagination' })) + .toHaveAttribute('data-page', '1') + expect( + screen.container.querySelector('button[aria-label*="current page 1 of 0"]'), + ).not.toBeInTheDocument() + expect( + screen.container.querySelector('button[aria-label="Previous page"]'), + ).not.toBeInTheDocument() expect(screen.container.querySelector('button[aria-label="Next page"]')).not.toBeInTheDocument() expect(screen.container.querySelector('ol')).not.toBeInTheDocument() }) @@ -283,7 +323,9 @@ describe('Pagination primitive', () => { asHTMLElement(screen.getByRole('button', { name: 'Go to page 4' }).element()).click() - await expect.element(screen.getByRole('button', { name: 'Go to page 4' })).toHaveClass('custom-page') + await expect + .element(screen.getByRole('button', { name: 'Go to page 4' })) + .toHaveClass('custom-page') expect(onPageChange).toHaveBeenCalledWith(4) }) diff --git a/packages/dify-ui/src/pagination/index.stories.tsx b/packages/dify-ui/src/pagination/index.stories.tsx index 47beccaf95fb8f..9e2b5d808acc8c 100644 --- a/packages/dify-ui/src/pagination/index.stories.tsx +++ b/packages/dify-ui/src/pagination/index.stories.tsx @@ -1,10 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' import { expect } from 'storybook/test' -import { - Pagination, - PaginationSkeleton, -} from '.' +import { Pagination, PaginationSkeleton } from '.' function PaginationExample({ initialPage = 2, @@ -61,7 +58,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound pagination primitive for list navigation. It combines semantic page buttons, a NumberField-backed page jump summary, and a SegmentedControl-backed page-size selector.', + component: + 'Compound pagination primitive for list navigation. It combines semantic page buttons, a NumberField-backed page jump summary, and a SegmentedControl-backed page-size selector.', }, }, }, @@ -79,10 +77,14 @@ type Story = StoryObj<typeof meta> export const Playground: Story = { render: () => <PaginationDemo />, play: async ({ canvas, userEvent }) => { - await expect(canvas.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeVisible() + await expect( + canvas.getByRole('button', { name: 'Edit page number, current page 2 of 200' }), + ).toBeVisible() await userEvent.click(canvas.getByRole('button', { name: 'Next page' })) - await expect(canvas.getByRole('button', { name: 'Edit page number, current page 3 of 200' })).toBeVisible() + await expect( + canvas.getByRole('button', { name: 'Edit page number, current page 3 of 200' }), + ).toBeVisible() await userEvent.click(canvas.getByRole('button', { name: '50' })) await expect(canvas.getByRole('button', { name: '50' })).toHaveAttribute('aria-pressed', 'true') @@ -94,7 +96,8 @@ export const DesignSpec: Story = { parameters: { docs: { description: { - story: 'Pagination rows with default, hover-like, focused, page-size, and skeleton examples.', + story: + 'Pagination rows with default, hover-like, focused, page-size, and skeleton examples.', }, }, }, diff --git a/packages/dify-ui/src/pagination/index.tsx b/packages/dify-ui/src/pagination/index.tsx index 90e0b1cc221dc5..541548fe4748b1 100644 --- a/packages/dify-ui/src/pagination/index.tsx +++ b/packages/dify-ui/src/pagination/index.tsx @@ -7,15 +7,8 @@ import { useRender } from '@base-ui/react/use-render' import * as React from 'react' import { cn } from '../cn' import { useIsoLayoutEffect } from '../internals/use-iso-layout-effect' -import { - NumberField, - NumberFieldGroup, - NumberFieldInput, -} from '../number-field' -import { - SegmentedControl, - SegmentedControlItem, -} from '../segmented-control' +import { NumberField, NumberFieldGroup, NumberFieldInput } from '../number-field' +import { SegmentedControl, SegmentedControlItem } from '../segmented-control' type PageItem = number | 'ellipsis-start' | 'ellipsis-end' @@ -33,22 +26,19 @@ const PaginationContext = React.createContext<PaginationContextValue | null>(nul function usePaginationContext(component: string) { const context = React.useContext(PaginationContext) - if (!context) - throw new Error(`${component} must be used inside PaginationRoot.`) + if (!context) throw new Error(`${component} must be used inside PaginationRoot.`) return context } function clampPage(page: number, totalPages: number) { - if (!Number.isFinite(page)) - return 1 + if (!Number.isFinite(page)) return 1 return Math.min(Math.max(Math.trunc(page), 1), Math.max(totalPages, 1)) } function range(start: number, end: number) { - if (end < start) - return [] + if (end < start) return [] return Array.from({ length: end - start + 1 }, (_, index) => start + index) } @@ -68,36 +58,29 @@ function getPageItems({ boundaryCount, visiblePageCount, }: GetPageItemsOptions): PageItem[] { - if (totalPages <= 0) - return [] + if (totalPages <= 0) return [] const normalizedPage = clampPage(page, totalPages) const normalizedBoundaryCount = Math.max(Math.trunc(boundaryCount), 1) const normalizedSiblingCount = Math.max(Math.trunc(siblingCount), 0) - const windowSize = Math.max( - Math.trunc(visiblePageCount), - normalizedSiblingCount * 2 + 1, - ) + const windowSize = Math.max(Math.trunc(visiblePageCount), normalizedSiblingCount * 2 + 1) - if (totalPages <= windowSize + normalizedBoundaryCount) - return range(1, totalPages) + if (totalPages <= windowSize + normalizedBoundaryCount) return range(1, totalPages) const nearStartEnd = windowSize const nearEndStart = totalPages - windowSize + 1 - const middleStart = Math.max( - normalizedBoundaryCount + 1, - normalizedPage - normalizedSiblingCount, - ) + const middleStart = Math.max(normalizedBoundaryCount + 1, normalizedPage - normalizedSiblingCount) const middleEnd = Math.min( totalPages - normalizedBoundaryCount, normalizedPage + normalizedSiblingCount, ) - const windowPages = normalizedPage <= nearStartEnd - normalizedSiblingCount - ? range(1, nearStartEnd) - : normalizedPage >= nearEndStart + normalizedSiblingCount - ? range(nearEndStart, totalPages) - : range(middleStart, middleEnd) + const windowPages = + normalizedPage <= nearStartEnd - normalizedSiblingCount + ? range(1, nearStartEnd) + : normalizedPage >= nearEndStart + normalizedSiblingCount + ? range(nearEndStart, totalPages) + : range(middleStart, middleEnd) const pageSet = new Set([ ...range(1, normalizedBoundaryCount), @@ -105,14 +88,13 @@ function getPageItems({ ...range(totalPages - normalizedBoundaryCount + 1, totalPages), ]) const pages = Array.from(pageSet) - .filter(item => item >= 1 && item <= totalPages) + .filter((item) => item >= 1 && item <= totalPages) .sort((a, b) => a - b) return pages.reduce<PageItem[]>((items, item, index) => { const previous = pages[index - 1] - if (previous && item - previous === 2) - items.push(previous + 1) + if (previous && item - previous === 2) items.push(previous + 1) else if (previous && item - previous > 2) items.push(item < normalizedPage ? 'ellipsis-start' : 'ellipsis-end') @@ -156,37 +138,37 @@ export function PaginationRoot({ const normalizedPage = clampPage(page, normalizedTotalPages) const hasPages = normalizedTotalPages > 0 const disabled = normalizedTotalPages <= 1 - const items = React.useMemo(() => getPageItems({ - page: normalizedPage, - totalPages: normalizedTotalPages, - siblingCount, - boundaryCount, - visiblePageCount, - }), [ - boundaryCount, - normalizedPage, - normalizedTotalPages, - siblingCount, - visiblePageCount, - ]) + const items = React.useMemo( + () => + getPageItems({ + page: normalizedPage, + totalPages: normalizedTotalPages, + siblingCount, + boundaryCount, + visiblePageCount, + }), + [boundaryCount, normalizedPage, normalizedTotalPages, siblingCount, visiblePageCount], + ) - const context = React.useMemo<PaginationContextValue>(() => ({ - page: normalizedPage, - totalPages: normalizedTotalPages, - hasPages, - disabled, - onPageChange: nextPage => onPageChange(clampPage(nextPage, normalizedTotalPages)), - items, - }), [disabled, hasPages, items, normalizedPage, normalizedTotalPages, onPageChange]) + const context = React.useMemo<PaginationContextValue>( + () => ({ + page: normalizedPage, + totalPages: normalizedTotalPages, + hasPages, + disabled, + onPageChange: (nextPage) => onPageChange(clampPage(nextPage, normalizedTotalPages)), + items, + }), + [disabled, hasPages, items, normalizedPage, normalizedTotalPages, onPageChange], + ) const defaultProps: useRender.ElementProps<'nav'> = { 'aria-label': 'Pagination', - 'className': cn('flex w-full min-w-0 items-center justify-between px-6 py-3 select-none', className), - 'children': ( - <PaginationContext.Provider value={context}> - {children} - </PaginationContext.Provider> + className: cn( + 'flex w-full min-w-0 items-center justify-between px-6 py-3 select-none', + className, ), + children: <PaginationContext.Provider value={context}>{children}</PaginationContext.Provider>, } return useRender({ @@ -206,13 +188,12 @@ export type PaginationNavigationProps = useRender.ComponentProps<'div'> export type PaginationContentProps = useRender.ComponentProps<'div'> -export function PaginationContent({ - render, - className, - ...props -}: PaginationContentProps) { +export function PaginationContent({ render, className, ...props }: PaginationContentProps) { const defaultProps: useRender.ElementProps<'div'> = { - className: cn('grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2', className), + className: cn( + 'grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2', + className, + ), } return useRender({ @@ -222,13 +203,12 @@ export function PaginationContent({ }) } -export function PaginationNavigation({ - render, - className, - ...props -}: PaginationNavigationProps) { +export function PaginationNavigation({ render, className, ...props }: PaginationNavigationProps) { const defaultProps: useRender.ElementProps<'div'> = { - className: cn('flex shrink-0 items-center gap-0.5 justify-self-start rounded-[10px] bg-background-section-burn p-0.5', className), + className: cn( + 'flex shrink-0 items-center gap-0.5 justify-self-start rounded-[10px] bg-background-section-burn p-0.5', + className, + ), } return useRender({ @@ -258,8 +238,7 @@ export function PaginationPrevious({ }: PaginationButtonProps) { const pagination = usePaginationContext('PaginationPrevious') - if (!pagination.hasPages) - return null + if (!pagination.hasPages) return null const disabled = props.disabled || pagination.page <= 1 || pagination.disabled @@ -273,8 +252,7 @@ export function PaginationPrevious({ onClick={(event) => { props.onClick?.(event) - if (!event.defaultPrevented && !disabled) - pagination.onPageChange(pagination.page - 1) + if (!event.defaultPrevented && !disabled) pagination.onPageChange(pagination.page - 1) }} > {children ?? <span className="i-ri-arrow-left-line size-4" aria-hidden="true" />} @@ -290,8 +268,7 @@ export function PaginationNext({ }: PaginationButtonProps) { const pagination = usePaginationContext('PaginationNext') - if (!pagination.hasPages) - return null + if (!pagination.hasPages) return null const disabled = props.disabled || pagination.page >= pagination.totalPages || pagination.disabled @@ -305,8 +282,7 @@ export function PaginationNext({ onClick={(event) => { props.onClick?.(event) - if (!event.defaultPrevented && !disabled) - pagination.onPageChange(pagination.page + 1) + if (!event.defaultPrevented && !disabled) pagination.onPageChange(pagination.page + 1) }} > {children ?? <span className="i-ri-arrow-right-line size-4" aria-hidden="true" />} @@ -332,24 +308,20 @@ export function PaginationPageJump({ const restoreSummaryFocusRef = React.useRef(false) useIsoLayoutEffect(() => { - if (editing || !restoreSummaryFocusRef.current) - return + if (editing || !restoreSummaryFocusRef.current) return restoreSummaryFocusRef.current = false const summaryButton = summaryButtonRef.current - if (!summaryButton) - return + if (!summaryButton) return const activeElement = summaryButton.ownerDocument.activeElement - if (activeElement && activeElement !== summaryButton.ownerDocument.body) - return + if (activeElement && activeElement !== summaryButton.ownerDocument.body) return summaryButton.focus({ preventScroll: true }) }, [editing]) - if (!pagination.hasPages) - return null + if (!pagination.hasPages) return null if (editing) { return ( @@ -364,15 +336,12 @@ export function PaginationPageJump({ min={1} max={Math.max(pagination.totalPages, 1)} onValueCommitted={(value) => { - if (value !== null) - pagination.onPageChange(value) + if (value !== null) pagination.onPageChange(value) setEditing(false) }} > - <NumberFieldGroup - className="h-7 w-full min-w-0 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-active shadow-xs" - > + <NumberFieldGroup className="h-7 w-full min-w-0 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-active shadow-xs"> <NumberFieldInput aria-label={inputLabel} // eslint-disable-next-line jsx-a11y/no-autofocus -- Editing starts after an explicit user action and focus must move to the replacement input. @@ -409,7 +378,9 @@ export function PaginationPageJump({ {...props} ref={summaryButtonRef} type="button" - aria-label={ariaLabel ?? `Edit page number, current page ${pagination.page} of ${pagination.totalPages}`} + aria-label={ + ariaLabel ?? `Edit page number, current page ${pagination.page} of ${pagination.totalPages}` + } className={cn( 'inline-flex h-7 touch-manipulation items-center justify-center gap-0.5 rounded-lg px-2 py-1.5 system-xs-medium text-text-secondary tabular-nums outline-hidden transition-colors hover:cursor-text hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none', className, @@ -417,8 +388,7 @@ export function PaginationPageJump({ onClick={(event) => { props.onClick?.(event) - if (!event.defaultPrevented) - setEditing(true) + if (!event.defaultPrevented) setEditing(true) }} > {children ?? ( @@ -434,20 +404,17 @@ export function PaginationPageJump({ export type PaginationPageListProps = useRender.ComponentProps<'ol'> -export function PaginationPageList({ - render, - className, - ...props -}: PaginationPageListProps) { +export function PaginationPageList({ render, className, ...props }: PaginationPageListProps) { const pagination = usePaginationContext('PaginationPageList') const defaultProps: useRender.ElementProps<'ol'> = { - className: cn('col-start-2 flex min-w-0 list-none items-center gap-0.5 justify-self-center', className), - children: pagination.items.map(item => ( + className: cn( + 'col-start-2 flex min-w-0 list-none items-center gap-0.5 justify-self-center', + className, + ), + children: pagination.items.map((item) => ( <li key={item}> - {typeof item === 'number' - ? <PaginationPage page={item} /> - : <PaginationEllipsis />} + {typeof item === 'number' ? <PaginationPage page={item} /> : <PaginationEllipsis />} </li> )), } @@ -483,14 +450,14 @@ export function PaginationPage({ aria-label={ariaLabel ?? (current ? `Page ${page}, current page` : `Go to page ${page}`)} className={cn( 'inline-flex h-8 min-w-8 touch-manipulation items-center justify-center rounded-lg px-1 py-2 system-sm-medium text-text-tertiary tabular-nums outline-hidden hover:bg-components-button-ghost-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid', - current && 'bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover', + current && + 'bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover', className, )} onClick={(event) => { props.onClick?.(event) - if (!event.defaultPrevented) - pagination.onPageChange(page) + if (!event.defaultPrevented) pagination.onPageChange(page) }} > {children ?? page} @@ -500,15 +467,14 @@ export function PaginationPage({ export type PaginationEllipsisProps = useRender.ComponentProps<'span'> -export function PaginationEllipsis({ - render, - className, - ...props -}: PaginationEllipsisProps) { +export function PaginationEllipsis({ render, className, ...props }: PaginationEllipsisProps) { const defaultProps: useRender.ElementProps<'span'> = { 'aria-hidden': true, - 'className': cn('flex size-8 items-center justify-center px-1 py-2 system-sm-medium text-text-tertiary', className), - 'children': '…', + className: cn( + 'flex size-8 items-center justify-center px-1 py-2 system-sm-medium text-text-tertiary', + className, + ), + children: '…', } return useRender({ @@ -519,12 +485,12 @@ export function PaginationEllipsis({ } export type PaginationPageSizeProps<Value extends number = number> = { - 'value': Value - 'options': readonly Value[] - 'onValueChange': (value: Value) => void - 'label'?: React.ReactNode + value: Value + options: readonly Value[] + onValueChange: (value: Value) => void + label?: React.ReactNode 'aria-label'?: string - 'className'?: string + className?: string } export function PaginationPageSize<Value extends number = number>({ @@ -536,7 +502,12 @@ export function PaginationPageSize<Value extends number = number>({ className, }: PaginationPageSizeProps<Value>) { return ( - <div className={cn('group/page-size col-start-3 flex shrink-0 items-center justify-end gap-2 justify-self-end', className)}> + <div + className={cn( + 'group/page-size col-start-3 flex shrink-0 items-center justify-end gap-2 justify-self-end', + className, + )} + > <div className="w-13 shrink-0 text-end system-2xs-regular-uppercase text-text-tertiary opacity-0 transition-opacity group-focus-within/page-size:opacity-100 group-hover/page-size:opacity-100 motion-reduce:transition-none"> {label} </div> @@ -546,16 +517,14 @@ export function PaginationPageSize<Value extends number = number>({ onValueChange={(nextValue) => { const [selectedValue] = nextValue - if (!selectedValue) - return + if (!selectedValue) return - const selectedOption = options.find(option => String(option) === selectedValue) + const selectedOption = options.find((option) => String(option) === selectedValue) - if (selectedOption !== undefined) - onValueChange(selectedOption) + if (selectedOption !== undefined) onValueChange(selectedOption) }} > - {options.map(option => ( + {options.map((option) => ( <SegmentedControlItem key={option} value={String(option)} @@ -584,7 +553,10 @@ export type PaginationPageSizeConfig<Value extends number = number> = { ariaLabel?: string } -export type PaginationProps<Value extends number = number> = Omit<PaginationRootProps, 'children'> & { +export type PaginationProps<Value extends number = number> = Omit< + PaginationRootProps, + 'children' +> & { labels?: PaginationLabels pageSize?: PaginationPageSizeConfig<Value> } @@ -601,23 +573,14 @@ export function Pagination<Value extends number = number>({ const normalizedPage = clampPage(page, normalizedTotalPages) const editPageNumber = labels?.editPageNumber?.(normalizedPage, normalizedTotalPages) - if (normalizedTotalPages <= 0) - return null + if (normalizedTotalPages <= 0) return null return ( - <PaginationRoot - page={page} - totalPages={totalPages} - onPageChange={onPageChange} - {...props} - > + <PaginationRoot page={page} totalPages={totalPages} onPageChange={onPageChange} {...props}> <PaginationContent> <PaginationNavigation> <PaginationPrevious aria-label={labels?.previous} /> - <PaginationPageJump - aria-label={editPageNumber} - inputLabel={labels?.pageNumberInput} - /> + <PaginationPageJump aria-label={editPageNumber} inputLabel={labels?.pageNumberInput} /> <PaginationNext aria-label={labels?.next} /> </PaginationNavigation> <PaginationPageList /> @@ -637,15 +600,14 @@ export function Pagination<Value extends number = number>({ export type PaginationSkeletonProps = useRender.ComponentProps<'div'> -export function PaginationSkeleton({ - render, - className, - ...props -}: PaginationSkeletonProps) { +export function PaginationSkeleton({ render, className, ...props }: PaginationSkeletonProps) { const defaultProps: useRender.ElementProps<'div'> = { 'aria-hidden': true, - 'className': cn('flex w-full min-w-0 items-center justify-between px-6 py-3 select-none', className), - 'children': ( + className: cn( + 'flex w-full min-w-0 items-center justify-between px-6 py-3 select-none', + className, + ), + children: ( <div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2"> <div className="flex shrink-0 items-center gap-0.5 justify-self-start rounded-[10px] bg-background-section-burn p-0.5"> <div className="size-7 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" /> @@ -653,8 +615,11 @@ export function PaginationSkeleton({ <div className="size-7 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" /> </div> <div className="col-start-2 flex items-center gap-0.5 justify-self-center"> - {range(1, 8).map(item => ( - <div key={item} className="h-8 min-w-8 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" /> + {range(1, 8).map((item) => ( + <div + key={item} + className="h-8 min-w-8 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" + /> ))} </div> <div className="col-start-3 flex shrink-0 items-center justify-self-end"> diff --git a/packages/dify-ui/src/placement.ts b/packages/dify-ui/src/placement.ts index bf5534c92d0730..63196e036b1f76 100644 --- a/packages/dify-ui/src/placement.ts +++ b/packages/dify-ui/src/placement.ts @@ -5,35 +5,35 @@ type Side = 'top' | 'bottom' | 'left' | 'right' type Align = 'start' | 'center' | 'end' -export type Placement - = 'top' - | 'top-start' - | 'top-end' - | 'right' - | 'right-start' - | 'right-end' - | 'bottom' - | 'bottom-start' - | 'bottom-end' - | 'left' - | 'left-start' - | 'left-end' +export type Placement = + | 'top' + | 'top-start' + | 'top-end' + | 'right' + | 'right-start' + | 'right-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'left' + | 'left-start' + | 'left-end' const PLACEMENT_PARTS = { - 'top': { side: 'top', align: 'center' }, + top: { side: 'top', align: 'center' }, 'top-start': { side: 'top', align: 'start' }, 'top-end': { side: 'top', align: 'end' }, - 'right': { side: 'right', align: 'center' }, + right: { side: 'right', align: 'center' }, 'right-start': { side: 'right', align: 'start' }, 'right-end': { side: 'right', align: 'end' }, - 'bottom': { side: 'bottom', align: 'center' }, + bottom: { side: 'bottom', align: 'center' }, 'bottom-start': { side: 'bottom', align: 'start' }, 'bottom-end': { side: 'bottom', align: 'end' }, - 'left': { side: 'left', align: 'center' }, + left: { side: 'left', align: 'center' }, 'left-start': { side: 'left', align: 'start' }, 'left-end': { side: 'left', align: 'end' }, -} satisfies Record<Placement, { side: Side, align: Align }> +} satisfies Record<Placement, { side: Side; align: Align }> -export function parsePlacement(placement: Placement): { side: Side, align: Align } { +export function parsePlacement(placement: Placement): { side: Side; align: Align } { return PLACEMENT_PARTS[placement] } diff --git a/packages/dify-ui/src/popover/__tests__/index.spec.tsx b/packages/dify-ui/src/popover/__tests__/index.spec.tsx index 208a6cded2c34f..23351eb667e7a1 100644 --- a/packages/dify-ui/src/popover/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/popover/__tests__/index.spec.tsx @@ -1,16 +1,9 @@ import type * as React from 'react' import { render } from 'vitest-browser-react' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '..' +import { Popover, PopoverContent, PopoverTrigger } from '..' -const renderWithSafeViewport = (ui: React.ReactNode) => render( - <div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}> - {ui} - </div>, -) +const renderWithSafeViewport = (ui: React.ReactNode) => + render(<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>{ui}</div>) describe('PopoverContent', () => { describe('Placement', () => { @@ -19,17 +12,23 @@ describe('PopoverContent', () => { <Popover open> <PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger> <PopoverContent - positionerProps={{ 'role': 'group', 'aria-label': 'default positioner' }} - popupProps={{ 'role': 'dialog', 'aria-label': 'default popover' }} + positionerProps={{ role: 'group', 'aria-label': 'default positioner' }} + popupProps={{ role: 'dialog', 'aria-label': 'default popover' }} > <span>Default content</span> </PopoverContent> </Popover>, ) - await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-side', 'bottom') - await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-align', 'center') - await expect.element(screen.getByRole('dialog', { name: 'default popover' })).toHaveTextContent('Default content') + await expect + .element(screen.getByRole('group', { name: 'default positioner' })) + .toHaveAttribute('data-side', 'bottom') + await expect + .element(screen.getByRole('group', { name: 'default positioner' })) + .toHaveAttribute('data-align', 'center') + await expect + .element(screen.getByRole('dialog', { name: 'default popover' })) + .toHaveTextContent('Default content') }) it('should apply parsed custom placement and custom offsets when placement props are provided', async () => { @@ -40,17 +39,23 @@ describe('PopoverContent', () => { placement="top-end" sideOffset={14} alignOffset={6} - positionerProps={{ 'role': 'group', 'aria-label': 'custom positioner' }} - popupProps={{ 'role': 'dialog', 'aria-label': 'custom popover' }} + positionerProps={{ role: 'group', 'aria-label': 'custom positioner' }} + popupProps={{ role: 'dialog', 'aria-label': 'custom popover' }} > <span>Custom placement content</span> </PopoverContent> </Popover>, ) - await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-side', 'top') - await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-align', 'end') - await expect.element(screen.getByRole('dialog', { name: 'custom popover' })).toHaveTextContent('Custom placement content') + await expect + .element(screen.getByRole('group', { name: 'custom positioner' })) + .toHaveAttribute('data-side', 'top') + await expect + .element(screen.getByRole('group', { name: 'custom positioner' })) + .toHaveAttribute('data-align', 'end') + await expect + .element(screen.getByRole('dialog', { name: 'custom popover' })) + .toHaveTextContent('Custom placement content') }) }) @@ -63,15 +68,15 @@ describe('PopoverContent', () => { <PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger> <PopoverContent positionerProps={{ - 'role': 'group', + role: 'group', 'aria-label': 'popover positioner', - 'id': 'popover-positioner-id', + id: 'popover-positioner-id', }} popupProps={{ - 'id': 'popover-popup-id', - 'role': 'dialog', + id: 'popover-popup-id', + role: 'dialog', 'aria-label': 'popover content', - 'onClick': onPopupClick, + onClick: onPopupClick, }} > <span>Popover body</span> @@ -82,7 +87,9 @@ describe('PopoverContent', () => { const popup = screen.getByRole('dialog', { name: 'popover content' }) await popup.click() - await expect.element(screen.getByRole('group', { name: 'popover positioner' })).toHaveAttribute('id', 'popover-positioner-id') + await expect + .element(screen.getByRole('group', { name: 'popover positioner' })) + .toHaveAttribute('id', 'popover-positioner-id') await expect.element(popup).toHaveAttribute('id', 'popover-popup-id') expect(onPopupClick).toHaveBeenCalledTimes(1) }) diff --git a/packages/dify-ui/src/popover/index.stories.tsx b/packages/dify-ui/src/popover/index.stories.tsx index 81254db49e5364..9af6dd2b5739ec 100644 --- a/packages/dify-ui/src/popover/index.stories.tsx +++ b/packages/dify-ui/src/popover/index.stories.tsx @@ -12,7 +12,8 @@ import { import { Button } from '../button' import { Kbd, KbdGroup } from '../kbd' -const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const triggerButtonClassName = + 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' const meta = { title: 'Base/UI/Popover', @@ -21,7 +22,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.\n\nPass `openOnHover` on `PopoverTrigger` when the popup should also reveal on hover (see the **Infotip** story). Unlike `Tooltip` and `PreviewCard`, hover on `Popover` still falls back to tap/focus, so touch and screen-reader users can reach the content.', + component: + 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.\n\nPass `openOnHover` on `PopoverTrigger` when the popup should also reveal on hover (see the **Infotip** story). Unlike `Tooltip` and `PreviewCard`, hover on `Popover` still falls back to tap/focus, so touch and screen-reader users can reach the content.', }, }, }, @@ -34,9 +36,7 @@ type Story = StoryObj<typeof meta> export const Default: Story = { render: () => ( <Popover> - <PopoverTrigger - render={<button type="button" className={triggerButtonClassName} />} - > + <PopoverTrigger render={<button type="button" className={triggerButtonClassName} />}> Open popover </PopoverTrigger> <PopoverContent> @@ -45,13 +45,11 @@ export const Default: Story = { Keyboard shortcuts </PopoverTitle> <PopoverDescription className="text-xs text-text-secondary"> - Press - {' '} + Press{' '} <KbdGroup> <Kbd>⌘</Kbd> <Kbd>K</Kbd> - </KbdGroup> - {' '} + </KbdGroup>{' '} to open the command palette anywhere in the app. </PopoverDescription> </div> @@ -63,9 +61,7 @@ export const Default: Story = { export const WithActions: Story = { render: () => ( <Popover> - <PopoverTrigger - render={<button type="button" className={triggerButtonClassName} />} - > + <PopoverTrigger render={<button type="button" className={triggerButtonClassName} />}> Share workspace </PopoverTrigger> <PopoverContent> @@ -84,14 +80,8 @@ export const WithActions: Story = { className="h-8 rounded-md border border-transparent bg-components-input-bg-normal px-2 text-sm text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs" /> <div className="flex items-center justify-end gap-2"> - <PopoverClose - render={<Button variant="secondary" size="small" />} - > - Cancel - </PopoverClose> - <PopoverClose - render={<Button variant="primary" size="small" />} - > + <PopoverClose render={<Button variant="secondary" size="small" />}>Cancel</PopoverClose> + <PopoverClose render={<Button variant="primary" size="small" />}> Send invite </PopoverClose> </div> @@ -108,9 +98,9 @@ export const Infotip: Story = { story: [ 'The **infotip** pattern from [Base UI](https://base-ui.com/react/components/tooltip#infotips): an info glyph (`?`, `(i)`) whose sole purpose is to reveal explanatory text. Use `Popover` with `openOnHover` on the trigger — never `Tooltip`.', '', - 'Why not `Tooltip`? Tooltips are disabled on touch devices and not announced to screen readers; descriptive help text hidden in them is unreachable for those users. Why not `PreviewCard`? PreviewCard\'s a11y contract requires the trigger to already own a primary click destination, but an info glyph has no other purpose.', + "Why not `Tooltip`? Tooltips are disabled on touch devices and not announced to screen readers; descriptive help text hidden in them is unreachable for those users. Why not `PreviewCard`? PreviewCard's a11y contract requires the trigger to already own a primary click destination, but an info glyph has no other purpose.", '', - 'Base UI rule of thumb: *"If the trigger\'s purpose is to open the popup itself, it\'s a popover. If the trigger\'s purpose is unrelated to opening the popup, it\'s a tooltip."*', + "Base UI rule of thumb: *\"If the trigger's purpose is to open the popup itself, it's a popover. If the trigger's purpose is unrelated to opening the popup, it's a tooltip.\"*", '', 'Hover, tap, or focus the `?` icon to open. In the Dify app, reach for `@/app/components/base/infotip` (`<Infotip aria-label={...}>{helpText}</Infotip>`) which wraps this pattern with consistent delays (300/200), typography, and `aria-label` plumbing.', ].join('\n'), @@ -126,17 +116,24 @@ export const Infotip: Story = { delay={300} closeDelay={200} aria-label="Set which resource to use first when running models." - render={( - <button type="button" className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"> - <span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" /> + render={ + <button + type="button" + className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + <span + aria-hidden + className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" + /> </button> - )} + } /> <PopoverContent placement="top" popupClassName="max-w-[300px] px-3 py-2 system-xs-regular text-text-tertiary" > - Set which resource to use first when running models. The Trial quota will be used after the paid quota is exhausted. + Set which resource to use first when running models. The Trial quota will be used after + the paid quota is exhausted. </PopoverContent> </Popover> </div> @@ -164,7 +161,7 @@ const PlacementsDemo = () => { return ( <div className="flex flex-col items-center gap-4 p-20"> <div className="grid grid-cols-3 gap-2 text-xs"> - {PLACEMENTS.map(value => ( + {PLACEMENTS.map((value) => ( <button key={value} type="button" @@ -178,17 +175,14 @@ const PlacementsDemo = () => { ))} </div> <Popover open> - <PopoverTrigger - render={<button type="button" className={triggerButtonClassName} />} - > + <PopoverTrigger render={<button type="button" className={triggerButtonClassName} />}> Anchored trigger </PopoverTrigger> <PopoverContent placement={placement}> <div className="flex w-56 flex-col gap-1 p-3"> <PopoverTitle className="text-sm font-semibold text-text-primary"> placement=" - {placement} - " + {placement}" </PopoverTitle> <PopoverDescription className="text-xs text-text-secondary"> Popover positions itself relative to the trigger using the selected placement. @@ -212,13 +206,11 @@ const ControlledDemo = () => { return ( <div className="flex items-center gap-3"> - <Button variant="secondary" onClick={() => setOpen(prev => !prev)}> + <Button variant="secondary" onClick={() => setOpen((prev) => !prev)}> {open ? 'Close from outside' : 'Open from outside'} </Button> <Popover open={open} onOpenChange={setOpen}> - <PopoverTrigger - render={<button type="button" className={triggerButtonClassName} />} - > + <PopoverTrigger render={<button type="button" className={triggerButtonClassName} />}> Anchor </PopoverTrigger> <PopoverContent> diff --git a/packages/dify-ui/src/popover/index.tsx b/packages/dify-ui/src/popover/index.tsx index a231c3fdedfbbd..c314e92f2c006d 100644 --- a/packages/dify-ui/src/popover/index.tsx +++ b/packages/dify-ui/src/popover/index.tsx @@ -26,10 +26,7 @@ type PopoverContentProps = { BasePopover.Positioner.Props, 'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset' > - popupProps?: Omit< - BasePopover.Popup.Props, - 'children' | 'className' - > + popupProps?: Omit<BasePopover.Popup.Props, 'children' | 'className'> } export function PopoverContent({ diff --git a/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx b/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx index b3515365fdd541..a4c2c03002a930 100644 --- a/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx @@ -1,16 +1,9 @@ import type * as React from 'react' import { render } from 'vitest-browser-react' -import { - PreviewCard, - PreviewCardContent, - PreviewCardTrigger, -} from '..' +import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '..' -const renderWithSafeViewport = (ui: React.ReactNode) => render( - <div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}> - {ui} - </div>, -) +const renderWithSafeViewport = (ui: React.ReactNode) => + render(<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>{ui}</div>) describe('PreviewCardContent', () => { describe('Placement', () => { @@ -18,43 +11,63 @@ describe('PreviewCardContent', () => { const screen = await renderWithSafeViewport( <PreviewCard open> <PreviewCardTrigger - render={<button type="button" aria-label="preview trigger">Open</button>} + render={ + <button type="button" aria-label="preview trigger"> + Open + </button> + } /> <PreviewCardContent - positionerProps={{ 'role': 'group', 'aria-label': 'default positioner' }} - popupProps={{ 'role': 'dialog', 'aria-label': 'default popup' }} + positionerProps={{ role: 'group', 'aria-label': 'default positioner' }} + popupProps={{ role: 'dialog', 'aria-label': 'default popup' }} > <span>Default content</span> </PreviewCardContent> </PreviewCard>, ) - await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-side', 'bottom') - await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-align', 'center') - await expect.element(screen.getByRole('dialog', { name: 'default popup' })).toHaveTextContent('Default content') + await expect + .element(screen.getByRole('group', { name: 'default positioner' })) + .toHaveAttribute('data-side', 'bottom') + await expect + .element(screen.getByRole('group', { name: 'default positioner' })) + .toHaveAttribute('data-align', 'center') + await expect + .element(screen.getByRole('dialog', { name: 'default popup' })) + .toHaveTextContent('Default content') }) it('should apply parsed custom placement and custom offsets when placement props are provided', async () => { const screen = await renderWithSafeViewport( <PreviewCard open> <PreviewCardTrigger - render={<button type="button" aria-label="preview trigger">Open</button>} + render={ + <button type="button" aria-label="preview trigger"> + Open + </button> + } /> <PreviewCardContent placement="top-end" sideOffset={14} alignOffset={6} - positionerProps={{ 'role': 'group', 'aria-label': 'custom positioner' }} - popupProps={{ 'role': 'dialog', 'aria-label': 'custom popup' }} + positionerProps={{ role: 'group', 'aria-label': 'custom positioner' }} + popupProps={{ role: 'dialog', 'aria-label': 'custom popup' }} > <span>Custom placement content</span> </PreviewCardContent> </PreviewCard>, ) - await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-side', 'top') - await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-align', 'end') - await expect.element(screen.getByRole('dialog', { name: 'custom popup' })).toHaveTextContent('Custom placement content') + await expect + .element(screen.getByRole('group', { name: 'custom positioner' })) + .toHaveAttribute('data-side', 'top') + await expect + .element(screen.getByRole('group', { name: 'custom positioner' })) + .toHaveAttribute('data-align', 'end') + await expect + .element(screen.getByRole('dialog', { name: 'custom popup' })) + .toHaveTextContent('Custom placement content') }) }) @@ -65,19 +78,23 @@ describe('PreviewCardContent', () => { const screen = await render( <PreviewCard open> <PreviewCardTrigger - render={<button type="button" aria-label="preview trigger">Open</button>} + render={ + <button type="button" aria-label="preview trigger"> + Open + </button> + } /> <PreviewCardContent positionerProps={{ - 'role': 'group', + role: 'group', 'aria-label': 'preview positioner', - 'id': 'preview-positioner-id', + id: 'preview-positioner-id', }} popupProps={{ - 'id': 'preview-popup-id', - 'role': 'dialog', + id: 'preview-popup-id', + role: 'dialog', 'aria-label': 'preview content', - 'onClick': onPopupClick, + onClick: onPopupClick, }} > <span>Preview body</span> @@ -88,7 +105,9 @@ describe('PreviewCardContent', () => { const popup = screen.getByRole('dialog', { name: 'preview content' }) await popup.click() - await expect.element(screen.getByRole('group', { name: 'preview positioner' })).toHaveAttribute('id', 'preview-positioner-id') + await expect + .element(screen.getByRole('group', { name: 'preview positioner' })) + .toHaveAttribute('id', 'preview-positioner-id') await expect.element(popup).toHaveAttribute('id', 'preview-popup-id') expect(onPopupClick).toHaveBeenCalledTimes(1) }) @@ -101,19 +120,13 @@ describe('PreviewCardContent', () => { const screen = await renderWithSafeViewport( <PreviewCard> <PreviewCardTrigger - render={( - <button - type="button" - aria-label="preview trigger" - onClick={onPrimaryClick} - > + render={ + <button type="button" aria-label="preview trigger" onClick={onPrimaryClick}> Open </button> - )} + } /> - <PreviewCardContent - popupProps={{ 'role': 'dialog', 'aria-label': 'preview content' }} - > + <PreviewCardContent popupProps={{ role: 'dialog', 'aria-label': 'preview content' }}> <span>Preview body</span> </PreviewCardContent> </PreviewCard>, diff --git a/packages/dify-ui/src/preview-card/index.stories.tsx b/packages/dify-ui/src/preview-card/index.stories.tsx index d5d691d6b58ef4..6dee917e455fba 100644 --- a/packages/dify-ui/src/preview-card/index.stories.tsx +++ b/packages/dify-ui/src/preview-card/index.stories.tsx @@ -1,21 +1,16 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { Placement } from '.' import * as React from 'react' -import { - createPreviewCardHandle, - PreviewCard, - PreviewCardContent, - PreviewCardTrigger, -} from '.' +import { createPreviewCardHandle, PreviewCard, PreviewCardContent, PreviewCardTrigger } from '.' -const rowButtonClassName - = 'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const rowButtonClassName = + 'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' -const triggerButtonClassName - = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const triggerButtonClassName = + 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' -const inlineLinkClassName - = 'text-text-accent underline decoration-text-accent/60 decoration-1 underline-offset-2 outline-hidden hover:decoration-text-accent focus-visible:rounded-xs focus-visible:no-underline focus-visible:ring-1 focus-visible:ring-components-input-border-active data-[popup-open]:decoration-text-accent' +const inlineLinkClassName = + 'text-text-accent underline decoration-text-accent/60 decoration-1 underline-offset-2 outline-hidden hover:decoration-text-accent focus-visible:rounded-xs focus-visible:no-underline focus-visible:ring-1 focus-visible:ring-components-input-border-active data-[popup-open]:decoration-text-accent' const meta = { title: 'Base/UI/PreviewCard', @@ -25,7 +20,7 @@ const meta = { docs: { description: { component: - 'Hover- and focus-activated rich preview for triggers whose primary click has its own destination (following a link, selecting a row, jumping to a definition). Built on Base UI PreviewCard.\n\n**A11y contract:** touch and screen-reader users cannot open the preview. Never place information or actions in the popup that are not also reachable from the trigger\'s primary click destination. If that is unavoidable, add a separate click affordance (Popover) or move the unique content onto the destination.', + "Hover- and focus-activated rich preview for triggers whose primary click has its own destination (following a link, selecting a row, jumping to a definition). Built on Base UI PreviewCard.\n\n**A11y contract:** touch and screen-reader users cannot open the preview. Never place information or actions in the popup that are not also reachable from the trigger's primary click destination. If that is unavoidable, add a separate click affordance (Popover) or move the unique content onto the destination.", }, }, }, @@ -56,8 +51,7 @@ export const LinkPreview: Story = { render: () => ( <div className="max-w-md p-6 text-sm leading-6 text-text-secondary"> <p> - The principles of good - {' '} + The principles of good{' '} <PreviewCardTrigger handle={typographyPreview} href="https://en.wikipedia.org/wiki/Typography" @@ -66,8 +60,7 @@ export const LinkPreview: Story = { className={inlineLinkClassName} > typography - </PreviewCardTrigger> - {' '} + </PreviewCardTrigger>{' '} remain in the digital age. </p> @@ -82,9 +75,8 @@ export const LinkPreview: Story = { alt="Station Hofplein signage in Rotterdam, Netherlands" /> <p className="m-0 text-xs leading-5 text-text-secondary"> - <strong className="text-text-primary">Typography</strong> - {' '} - is the art and science of arranging type to make written language legible, readable, and visually appealing. + <strong className="text-text-primary">Typography</strong> is the art and science of + arranging type to make written language legible, readable, and visually appealing. </p> </div> </PreviewCardContent> @@ -106,17 +98,14 @@ export const Supplementary: Story = { render: () => ( <PreviewCard> <PreviewCardTrigger - render={( + render={ <button type="button" className={rowButtonClassName}> <span className="i-ri-sparkling-fill h-4 w-4 text-text-accent" /> <span>gpt-4o</span> </button> - )} + } /> - <PreviewCardContent - placement="right" - popupClassName="w-[220px] p-3" - > + <PreviewCardContent placement="right" popupClassName="w-[220px] p-3"> <div className="flex flex-col gap-2"> <div className="text-sm font-medium text-text-primary">gpt-4o</div> <div className="text-xs text-text-tertiary"> @@ -149,7 +138,7 @@ const PlacementsDemo = () => { return ( <div className="flex flex-col items-center gap-4 p-20"> <div className="grid grid-cols-3 gap-2 text-xs"> - {PLACEMENTS.map(value => ( + {PLACEMENTS.map((value) => ( <button key={value} type="button" @@ -164,14 +153,17 @@ const PlacementsDemo = () => { </div> <PreviewCard open> <PreviewCardTrigger - render={<button type="button" className={triggerButtonClassName}>Hover me</button>} + render={ + <button type="button" className={triggerButtonClassName}> + Hover me + </button> + } /> <PreviewCardContent placement={placement} popupClassName="w-56 p-3"> <div className="flex flex-col gap-1"> <div className="text-sm font-semibold text-text-primary"> placement=" - {placement} - " + {placement}" </div> <div className="text-xs text-text-secondary"> Preview positions itself relative to the trigger. @@ -195,13 +187,18 @@ const CustomDelayDemo = () => ( <PreviewCardTrigger delay={100} closeDelay={100} - render={<button type="button" className={triggerButtonClassName}>Snappy trigger</button>} + render={ + <button type="button" className={triggerButtonClassName}> + Snappy trigger + </button> + } /> <PreviewCardContent popupClassName="w-64 p-3"> <div className="flex flex-col gap-1"> <div className="text-sm font-semibold text-text-primary">Fast hover</div> <div className="text-xs text-text-secondary"> - Base UI defaults (600ms / 300ms) are tuned for link previews. Override per trigger for denser UIs. + Base UI defaults (600ms / 300ms) are tuned for link previews. Override per trigger for + denser UIs. </div> </div> </PreviewCardContent> diff --git a/packages/dify-ui/src/preview-card/index.tsx b/packages/dify-ui/src/preview-card/index.tsx index a3e8b751007af9..ec4a4fe92a739c 100644 --- a/packages/dify-ui/src/preview-card/index.tsx +++ b/packages/dify-ui/src/preview-card/index.tsx @@ -37,10 +37,7 @@ type PreviewCardContentProps = { BasePreviewCard.Positioner.Props, 'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset' > - popupProps?: Omit< - BasePreviewCard.Popup.Props, - 'children' | 'className' - > + popupProps?: Omit<BasePreviewCard.Popup.Props, 'children' | 'className'> } export function PreviewCardContent({ diff --git a/packages/dify-ui/src/progress/__tests__/index.spec.tsx b/packages/dify-ui/src/progress/__tests__/index.spec.tsx index 2e86633247bcd9..c013b1344540b7 100644 --- a/packages/dify-ui/src/progress/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/progress/__tests__/index.spec.tsx @@ -14,7 +14,9 @@ describe('ProgressCircle', () => { }) it('supports custom min and max', async () => { - const screen = await render(<ProgressCircle value={3} min={1} max={5} aria-label="Installing" />) + const screen = await render( + <ProgressCircle value={3} min={1} max={5} aria-label="Installing" />, + ) const progress = screen.getByLabelText('Installing') diff --git a/packages/dify-ui/src/progress/index.stories.tsx b/packages/dify-ui/src/progress/index.stories.tsx index 2ee8253ab86b27..4a6339abf07780 100644 --- a/packages/dify-ui/src/progress/index.stories.tsx +++ b/packages/dify-ui/src/progress/index.stories.tsx @@ -27,32 +27,30 @@ type Story = StoryObj<typeof meta> export const Circle: Story = { args: { - 'value': 42, - 'color': 'blue', - 'size': 'small', + value: 42, + color: 'blue', + size: 'small', 'aria-label': 'Uploading', }, } export const CircleMatrix: Story = { args: { - 'value': 62, + value: 62, 'aria-label': 'Progress', }, render: () => ( <div className="grid grid-cols-[auto_auto_auto_auto] items-center gap-4 rounded-lg bg-components-panel-bg p-4"> <div /> - {sizes.map(size => ( + {sizes.map((size) => ( <div key={size} className="system-xs-medium text-text-tertiary"> {size} </div> ))} - {colors.map(color => ( + {colors.map((color) => ( <React.Fragment key={color}> - <div className="system-xs-semibold-uppercase text-text-secondary"> - {color} - </div> - {sizes.map(size => ( + <div className="system-xs-semibold-uppercase text-text-secondary">{color}</div> + {sizes.map((size) => ( <ProgressCircle key={`${color}-${size}`} value={62} @@ -69,9 +67,9 @@ export const CircleMatrix: Story = { export const Indeterminate: Story = { args: { - 'value': null, - 'color': 'gray', - 'size': 'medium', + value: null, + color: 'gray', + size: 'medium', 'aria-label': 'Processing', }, } diff --git a/packages/dify-ui/src/progress/index.tsx b/packages/dify-ui/src/progress/index.tsx index 05bfd8c355c4c2..e4559b97f09b22 100644 --- a/packages/dify-ui/src/progress/index.tsx +++ b/packages/dify-ui/src/progress/index.tsx @@ -5,21 +5,18 @@ import { Progress as BaseProgress } from '@base-ui/react/progress' import { cva } from 'class-variance-authority' import { cn } from '../cn' -const progressCircleRootVariants = cva( - 'inline-flex shrink-0 items-center justify-center', - { - variants: { - size: { - small: 'size-3', - medium: 'size-4', - large: 'size-5', - }, - }, - defaultVariants: { - size: 'small', +const progressCircleRootVariants = cva('inline-flex shrink-0 items-center justify-center', { + variants: { + size: { + small: 'size-3', + medium: 'size-4', + large: 'size-5', }, }, -) + defaultVariants: { + size: 'small', + }, +}) const progressCircleColorClasses = { gray: { @@ -49,7 +46,9 @@ const progressCircleColorClasses = { }, } as const -export type ProgressCircleSize = NonNullable<VariantProps<typeof progressCircleRootVariants>['size']> +export type ProgressCircleSize = NonNullable< + VariantProps<typeof progressCircleRootVariants>['size'] +> export type ProgressCircleColor = keyof typeof progressCircleColorClasses const progressCircleSizeValues = { @@ -58,36 +57,35 @@ const progressCircleSizeValues = { large: 20, } as const satisfies Record<ProgressCircleSize, number> -type ProgressCircleAccessibleNameProps - = | { - 'aria-label': string - 'aria-labelledby'?: never - } +type ProgressCircleAccessibleNameProps = | { - 'aria-label'?: never - 'aria-labelledby': string - } - -export type ProgressCircleProps - = Omit<BaseProgress.Root.Props, 'children' | 'className' | 'aria-label' | 'aria-labelledby'> - & ProgressCircleAccessibleNameProps - & { - className?: string - color?: ProgressCircleColor - size?: ProgressCircleSize - circleStrokeWidth?: number + 'aria-label': string + 'aria-labelledby'?: never } + | { + 'aria-label'?: never + 'aria-labelledby': string + } + +export type ProgressCircleProps = Omit< + BaseProgress.Root.Props, + 'children' | 'className' | 'aria-label' | 'aria-labelledby' +> & + ProgressCircleAccessibleNameProps & { + className?: string + color?: ProgressCircleColor + size?: ProgressCircleSize + circleStrokeWidth?: number + } function getProgressPercentage(value: number | null, min: number, max: number) { - if (value === null || !Number.isFinite(value) || max <= min) - return null + if (value === null || !Number.isFinite(value) || max <= min) return null return Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100)) } function getSectorPath(size: number, percentage: number | null) { - if (percentage === null || percentage <= 0) - return '' + if (percentage === null || percentage <= 0) return '' const radius = size / 2 const center = size / 2 diff --git a/packages/dify-ui/src/radio/__tests__/index.spec.tsx b/packages/dify-ui/src/radio/__tests__/index.spec.tsx index 7d66461ae54d20..579e287bba541c 100644 --- a/packages/dify-ui/src/radio/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/radio/__tests__/index.spec.tsx @@ -35,10 +35,7 @@ type TestRadioOptionProps = React.ComponentProps<typeof Radio> & { children: React.ReactNode } -function TestRadioOption({ - children, - ...props -}: TestRadioOptionProps) { +function TestRadioOption({ children, ...props }: TestRadioOptionProps) { return ( <FieldItem> <FieldLabel> @@ -79,13 +76,19 @@ describe('RadioGroup', () => { const screen = await render(<StorageDemo />) - await expect.element(screen.getByRole('radio', { name: 'SSD' })).toHaveAttribute('aria-checked', 'true') + await expect + .element(screen.getByRole('radio', { name: 'SSD' })) + .toHaveAttribute('aria-checked', 'true') clickElement(screen.getByRole('radio', { name: 'HDD' }).element()) await vi.waitFor(async () => { - await expect.element(screen.getByRole('radio', { name: 'SSD' })).toHaveAttribute('aria-checked', 'false') - await expect.element(screen.getByRole('radio', { name: 'HDD' })).toHaveAttribute('aria-checked', 'true') + await expect + .element(screen.getByRole('radio', { name: 'SSD' })) + .toHaveAttribute('aria-checked', 'false') + await expect + .element(screen.getByRole('radio', { name: 'HDD' })) + .toHaveAttribute('aria-checked', 'true') }) }) @@ -98,7 +101,9 @@ describe('RadioGroup', () => { </TestRadioGroup>, ) - await expect.element(screen.getByRole('radiogroup', { name: 'Storage type' })).toBeInTheDocument() + await expect + .element(screen.getByRole('radiogroup', { name: 'Storage type' })) + .toBeInTheDocument() const hdd = screen.getByRole('radio', { name: 'HDD' }) await expect.element(hdd).toHaveAttribute('aria-checked', 'false') @@ -141,7 +146,9 @@ describe('Radio', () => { expect(onValueChange).toHaveBeenCalledTimes(1) expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd') - await expect.element(screen.getByRole('radio', { name: 'HDD' })).toHaveAttribute('aria-checked', 'true') + await expect + .element(screen.getByRole('radio', { name: 'HDD' })) + .toHaveAttribute('aria-checked', 'true') }) it('should ignore interaction when disabled', async () => { @@ -149,7 +156,9 @@ describe('Radio', () => { const screen = await render( <TestRadioGroup defaultValue="ssd" label="Storage type" onValueChange={onValueChange}> <TestRadioOption value="ssd">SSD</TestRadioOption> - <TestRadioOption value="hdd" disabled>HDD</TestRadioOption> + <TestRadioOption value="hdd" disabled> + HDD + </TestRadioOption> </TestRadioGroup>, ) @@ -173,8 +182,7 @@ describe('Radio', () => { ) const form = screen.container.querySelector<HTMLFormElement>('form') expect(form).not.toBeNull() - if (!form) - return + if (!form) return const data = new FormData(form) @@ -195,9 +203,13 @@ describe('Radio', () => { </RadioGroup>, ) - await expect.element(screen.getByRole('radio', { name: 'Card option' })).toHaveClass('custom-card') + await expect + .element(screen.getByRole('radio', { name: 'Card option' })) + .toHaveClass('custom-card') expect(screen.container.querySelector('.custom-control')).toBeInTheDocument() - await expect.element(screen.getByRole('radio', { name: 'Card option' })).toHaveAttribute('data-checked', '') + await expect + .element(screen.getByRole('radio', { name: 'Card option' })) + .toHaveAttribute('data-checked', '') }) }) diff --git a/packages/dify-ui/src/radio/index.stories.tsx b/packages/dify-ui/src/radio/index.stories.tsx index 799e0c6335c7e2..912a796561a704 100644 --- a/packages/dify-ui/src/radio/index.stories.tsx +++ b/packages/dify-ui/src/radio/index.stories.tsx @@ -1,18 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' -import { - Radio, - RadioControl, - RadioGroup, - RadioItem, - RadioSkeleton, -} from '.' -import { - Field, - FieldDescription, - FieldItem, - FieldLabel, -} from '../field' +import { Radio, RadioControl, RadioGroup, RadioItem, RadioSkeleton } from '.' +import { Field, FieldDescription, FieldItem, FieldLabel } from '../field' import { Fieldset, FieldsetLegend } from '../fieldset' const meta = { @@ -22,7 +11,8 @@ const meta = { layout: 'centered', docs: { description: { - component: '`@langgenius/dify-ui/radio` exports the complete radio family. `RadioGroup` owns single-selection state, `Radio` is the default Dify control for plain form rows, `RadioItem` makes custom UI the radio item, and `RadioControl` renders the standard visual dot inside custom items.', + component: + '`@langgenius/dify-ui/radio` exports the complete radio family. `RadioGroup` owns single-selection state, `Radio` is the default Dify control for plain form rows, `RadioItem` makes custom UI the radio item, and `RadioControl` renders the standard visual dot inside custom items.', }, }, }, @@ -38,16 +28,20 @@ function StandardFormRowsDemo() { return ( <Field name="retrievalIndex" className="w-80"> <Fieldset - render={( - <RadioGroup value={value} onValueChange={setValue} className="flex-col items-start gap-3" /> - )} + render={ + <RadioGroup + value={value} + onValueChange={setValue} + className="flex-col items-start gap-3" + /> + } > <FieldsetLegend>Retrieval index</FieldsetLegend> {[ { value: 'vector', label: 'Vector storage' }, { value: 'keyword', label: 'Keyword index' }, { value: 'hybrid', label: 'Hybrid retrieval' }, - ].map(option => ( + ].map((option) => ( <FieldItem key={option.value}> <FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary"> <Radio value={option.value} /> @@ -65,7 +59,8 @@ export const StandardFormRows: Story = { parameters: { docs: { description: { - story: 'Plain form-row composition. `RadioGroup` owns value, `FieldsetLegend` names the group, `FieldLabel` makes each option label clickable, and `Radio` renders the default dot.', + story: + 'Plain form-row composition. `RadioGroup` owns value, `FieldsetLegend` names the group, `FieldLabel` makes each option label clickable, and `Radio` renders the default dot.', }, }, }, @@ -77,9 +72,7 @@ function BooleanInlineDemo() { return ( <Field name="streaming" className="w-80"> <Fieldset - render={( - <RadioGroup<boolean> value={value} onValueChange={setValue} className="gap-3" /> - )} + render={<RadioGroup<boolean> value={value} onValueChange={setValue} className="gap-3" />} > <FieldsetLegend>Streaming output</FieldsetLegend> <div className="flex items-center gap-3"> @@ -106,7 +99,8 @@ export const BooleanInline: Story = { parameters: { docs: { description: { - story: 'Compact boolean radio fields. Type `RadioGroup<boolean>` and each child `Radio<boolean>` when the selected value is not a string.', + story: + 'Compact boolean radio fields. Type `RadioGroup<boolean>` and each child `Radio<boolean>` when the selected value is not a string.', }, }, }, @@ -137,12 +131,16 @@ function OptionCardsDemo() { return ( <Field name="promptMode" className="w-100"> <Fieldset - render={( - <RadioGroup<PromptMode> value={value} onValueChange={setValue} className="flex-col items-stretch gap-3" /> - )} + render={ + <RadioGroup<PromptMode> + value={value} + onValueChange={setValue} + className="flex-col items-stretch gap-3" + /> + } > <FieldsetLegend>Prompt mode</FieldsetLegend> - {options.map(option => ( + {options.map((option) => ( <FieldItem key={option.value}> <RadioItem<PromptMode> value={option.value} @@ -152,9 +150,7 @@ function OptionCardsDemo() { > <div className="flex items-start justify-between gap-3"> <div> - <div className="system-sm-semibold text-text-primary"> - {option.title} - </div> + <div className="system-sm-semibold text-text-primary">{option.title}</div> <div className="mt-1 system-xs-regular text-text-tertiary"> {option.description} </div> @@ -174,7 +170,8 @@ export const OptionCards: Story = { parameters: { docs: { description: { - story: 'Custom option UIs should make the whole interactive surface the radio item with `RadioItem`. `RadioControl` is the Dify visual dot; the radio semantics stay on `RadioItem`.', + story: + 'Custom option UIs should make the whole interactive surface the radio item with `RadioItem`. `RadioControl` is the Dify visual dot; the radio semantics stay on `RadioItem`.', }, }, }, @@ -191,21 +188,22 @@ function DynamicFormFieldDemo() { return ( <Field name="generation_mode" className="flex w-80 flex-col gap-2"> <FieldDescription className="body-xs-regular text-text-tertiary"> - This mirrors Dify dynamic form fields where radio options are controlled by schema and persisted as a single value. + This mirrors Dify dynamic form fields where radio options are controlled by schema and + persisted as a single value. </FieldDescription> <Fieldset - render={( + render={ <RadioGroup value={selected} onValueChange={setSelected} className="flex-col items-start gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg p-3" /> - )} + } > <FieldsetLegend className="system-sm-medium text-text-secondary"> Generation mode </FieldsetLegend> - {options.map(option => ( + {options.map((option) => ( <FieldItem key={option.value}> <FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary"> <Radio value={option.value} /> @@ -223,7 +221,8 @@ export const DynamicFormField: Story = { parameters: { docs: { description: { - story: 'Matches Dify form composition: Field and Fieldset provide group labeling while `RadioGroup` owns controlled single-selection state.', + story: + 'Matches Dify form composition: Field and Fieldset provide group labeling while `RadioGroup` owns controlled single-selection state.', }, }, }, @@ -233,7 +232,9 @@ export const StateMatrix: Story = { render: () => ( <div className="flex flex-col gap-3"> <Field name="radioStates"> - <Fieldset render={<RadioGroup defaultValue="checked" className="flex-col items-start gap-3" />}> + <Fieldset + render={<RadioGroup defaultValue="checked" className="flex-col items-start gap-3" />} + > <FieldsetLegend>Interactive radio states</FieldsetLegend> <FieldItem> <FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary"> @@ -250,7 +251,15 @@ export const StateMatrix: Story = { </Fieldset> </Field> <Field name="disabledRadioStates"> - <Fieldset render={<RadioGroup defaultValue="disabled-checked" disabled className="flex-col items-start gap-3" />}> + <Fieldset + render={ + <RadioGroup + defaultValue="disabled-checked" + disabled + className="flex-col items-start gap-3" + /> + } + > <FieldsetLegend>Disabled radio states</FieldsetLegend> <FieldItem> <FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary"> @@ -275,7 +284,8 @@ export const StateMatrix: Story = { parameters: { docs: { description: { - story: 'The visual matrix for Dify radio states. State styling comes from Base UI data attributes such as data-checked and data-disabled.', + story: + 'The visual matrix for Dify radio states. State styling comes from Base UI data attributes such as data-checked and data-disabled.', }, }, }, diff --git a/packages/dify-ui/src/radio/index.tsx b/packages/dify-ui/src/radio/index.tsx index 12df297b2eb629..dfcfc2882e9beb 100644 --- a/packages/dify-ui/src/radio/index.tsx +++ b/packages/dify-ui/src/radio/index.tsx @@ -7,52 +7,30 @@ import { Radio as BaseRadio } from '@base-ui/react/radio' import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group' import { cn } from '../cn' -export type RadioGroupProps<Value = string> - = Omit<BaseRadioGroupNS.Props<Value>, 'className'> - & { - className?: string - } +export type RadioGroupProps<Value = string> = Omit<BaseRadioGroupNS.Props<Value>, 'className'> & { + className?: string +} -export function RadioGroup<Value = string>({ - className, - ...props -}: RadioGroupProps<Value>) { - return ( - <BaseRadioGroup<Value> - className={cn('flex items-center gap-2', className)} - {...props} - /> - ) +export function RadioGroup<Value = string>({ className, ...props }: RadioGroupProps<Value>) { + return <BaseRadioGroup<Value> className={cn('flex items-center gap-2', className)} {...props} /> } -export type RadioItemProps<Value = string> - = Omit<BaseRadioNS.Root.Props<Value>, 'className'> - & { - className?: string - } +export type RadioItemProps<Value = string> = Omit<BaseRadioNS.Root.Props<Value>, 'className'> & { + className?: string +} -export function RadioItem<Value = string>({ - className, - ...props -}: RadioItemProps<Value>) { - return ( - <BaseRadio.Root<Value> - className={className} - {...props} - /> - ) +export function RadioItem<Value = string>({ className, ...props }: RadioItemProps<Value>) { + return <BaseRadio.Root<Value> className={className} {...props} /> } -export type RadioControlProps - = Omit<BaseRadioNS.Indicator.Props, 'className' | 'children' | 'keepMounted'> - & { - className?: string - } +export type RadioControlProps = Omit< + BaseRadioNS.Indicator.Props, + 'className' | 'children' | 'keepMounted' +> & { + className?: string +} -export function RadioControl({ - className, - ...props -}: RadioControlProps) { +export function RadioControl({ className, ...props }: RadioControlProps) { return ( <BaseRadio.Indicator {...props} @@ -73,13 +51,9 @@ export function RadioControl({ ) } -export type RadioProps<Value = string> - = Omit<RadioItemProps<Value>, 'children'> +export type RadioProps<Value = string> = Omit<RadioItemProps<Value>, 'children'> -export function Radio<Value = string>({ - className, - ...props -}: RadioProps<Value>) { +export function Radio<Value = string>({ className, ...props }: RadioProps<Value>) { return ( <BaseRadio.Root<Value> className={cn( @@ -101,10 +75,7 @@ export function Radio<Value = string>({ export type RadioSkeletonProps = React.ComponentProps<'div'> -export function RadioSkeleton({ - className, - ...props -}: RadioSkeletonProps) { +export function RadioSkeleton({ className, ...props }: RadioSkeletonProps) { return ( <div className={cn('size-4 shrink-0 rounded-full bg-text-quaternary opacity-20', className)} diff --git a/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx b/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx index 624ff832756056..dac161a3c0889a 100644 --- a/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx @@ -32,14 +32,16 @@ const stubElementMetric = ( } } -const renderScrollArea = (options: { - rootClassName?: string - viewportClassName?: string - verticalScrollbarClassName?: string - horizontalScrollbarClassName?: string - verticalThumbClassName?: string - horizontalThumbClassName?: string -} = {}) => { +const renderScrollArea = ( + options: { + rootClassName?: string + viewportClassName?: string + verticalScrollbarClassName?: string + horizontalScrollbarClassName?: string + verticalThumbClassName?: string + horizontalThumbClassName?: string + } = {}, +) => { return render( <ScrollAreaRoot className={options.rootClassName ?? 'h-40 w-40'} data-testid="scroll-area-root"> <ScrollAreaViewport data-testid="scroll-area-viewport" className={options.viewportClassName}> @@ -52,7 +54,10 @@ const renderScrollArea = (options: { data-testid="scroll-area-vertical-scrollbar" className={options.verticalScrollbarClassName} > - <ScrollAreaThumb data-testid="scroll-area-vertical-thumb" className={options.verticalThumbClassName} /> + <ScrollAreaThumb + data-testid="scroll-area-vertical-thumb" + className={options.verticalThumbClassName} + /> </ScrollAreaScrollbar> <ScrollAreaScrollbar keepMounted @@ -76,10 +81,14 @@ describe('scroll-area wrapper', () => { await expect.element(screen.getByTestId('scroll-area-root')).toBeInTheDocument() await expect.element(screen.getByTestId('scroll-area-viewport')).toBeInTheDocument() - await expect.element(screen.getByTestId('scroll-area-content')).toHaveTextContent('Scrollable content') + await expect + .element(screen.getByTestId('scroll-area-content')) + .toHaveTextContent('Scrollable content') await expect.element(screen.getByTestId('scroll-area-vertical-scrollbar')).toBeInTheDocument() await expect.element(screen.getByTestId('scroll-area-vertical-thumb')).toBeInTheDocument() - await expect.element(screen.getByTestId('scroll-area-horizontal-scrollbar')).toBeInTheDocument() + await expect + .element(screen.getByTestId('scroll-area-horizontal-scrollbar')) + .toBeInTheDocument() await expect.element(screen.getByTestId('scroll-area-horizontal-thumb')).toBeInTheDocument() }) @@ -117,17 +126,29 @@ describe('scroll-area wrapper', () => { it('should apply the vertical orientation data attribute', async () => { const screen = await renderScrollArea() - await expect.element(screen.getByTestId('scroll-area-vertical-scrollbar')).toHaveAttribute('data-orientation', 'vertical') - await expect.element(screen.getByTestId('scroll-area-vertical-scrollbar')).toHaveAttribute('data-dify-scrollbar') - await expect.element(screen.getByTestId('scroll-area-vertical-thumb')).toHaveAttribute('data-orientation', 'vertical') + await expect + .element(screen.getByTestId('scroll-area-vertical-scrollbar')) + .toHaveAttribute('data-orientation', 'vertical') + await expect + .element(screen.getByTestId('scroll-area-vertical-scrollbar')) + .toHaveAttribute('data-dify-scrollbar') + await expect + .element(screen.getByTestId('scroll-area-vertical-thumb')) + .toHaveAttribute('data-orientation', 'vertical') }) it('should apply horizontal orientation data attributes', async () => { const screen = await renderScrollArea() - await expect.element(screen.getByTestId('scroll-area-horizontal-scrollbar')).toHaveAttribute('data-orientation', 'horizontal') - await expect.element(screen.getByTestId('scroll-area-horizontal-scrollbar')).toHaveAttribute('data-dify-scrollbar') - await expect.element(screen.getByTestId('scroll-area-horizontal-thumb')).toHaveAttribute('data-orientation', 'horizontal') + await expect + .element(screen.getByTestId('scroll-area-horizontal-scrollbar')) + .toHaveAttribute('data-orientation', 'horizontal') + await expect + .element(screen.getByTestId('scroll-area-horizontal-scrollbar')) + .toHaveAttribute('data-dify-scrollbar') + await expect + .element(screen.getByTestId('scroll-area-horizontal-thumb')) + .toHaveAttribute('data-orientation', 'horizontal') }) }) @@ -137,23 +158,25 @@ describe('scroll-area wrapper', () => { viewportClassName: 'custom-viewport-class', }) - await expect.element(screen.getByTestId('scroll-area-viewport')).toHaveClass('custom-viewport-class') + await expect + .element(screen.getByTestId('scroll-area-viewport')) + .toHaveClass('custom-viewport-class') }) it('should let callers control scrollbar inset spacing via margin-based className overrides', async () => { const screen = await renderScrollArea({ - verticalScrollbarClassName: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:-me-3', - horizontalScrollbarClassName: 'data-[orientation=horizontal]:mx-2 data-[orientation=horizontal]:mb-2', + verticalScrollbarClassName: + 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:-me-3', + horizontalScrollbarClassName: + 'data-[orientation=horizontal]:mx-2 data-[orientation=horizontal]:mb-2', }) - await expect.element(screen.getByTestId('scroll-area-vertical-scrollbar')).toHaveClass( - 'data-[orientation=vertical]:my-2', - 'data-[orientation=vertical]:-me-3', - ) - await expect.element(screen.getByTestId('scroll-area-horizontal-scrollbar')).toHaveClass( - 'data-[orientation=horizontal]:mx-2', - 'data-[orientation=horizontal]:mb-2', - ) + await expect + .element(screen.getByTestId('scroll-area-vertical-scrollbar')) + .toHaveClass('data-[orientation=vertical]:my-2', 'data-[orientation=vertical]:-me-3') + await expect + .element(screen.getByTestId('scroll-area-horizontal-scrollbar')) + .toHaveClass('data-[orientation=horizontal]:mx-2', 'data-[orientation=horizontal]:mb-2') }) }) @@ -167,8 +190,7 @@ describe('scroll-area wrapper', () => { <ScrollAreaViewport data-testid="scroll-area-viewport" ref={(node) => { - if (!node || restoreViewportMetrics.length > 0) - return + if (!node || restoreViewportMetrics.length > 0) return restoreViewportMetrics.push( stubElementMetric(node, 'clientHeight', 80), @@ -199,9 +221,8 @@ describe('scroll-area wrapper', () => { await vi.waitFor(() => { expect(screen.getByTestId('scroll-area-corner').element()).toBeInTheDocument() }) - } - finally { - restoreViewportMetrics.splice(0).forEach(restore => restore()) + } finally { + restoreViewportMetrics.splice(0).forEach((restore) => restore()) } }) }) diff --git a/packages/dify-ui/src/scroll-area/index.stories.tsx b/packages/dify-ui/src/scroll-area/index.stories.tsx index 962a9980421c10..626c9ce8ee8ffc 100644 --- a/packages/dify-ui/src/scroll-area/index.stories.tsx +++ b/packages/dify-ui/src/scroll-area/index.stories.tsx @@ -17,7 +17,8 @@ const meta = { layout: 'padded', docs: { description: { - component: 'Compound scroll container built on Base UI Scroll Area. The examples mirror the upstream anatomy and focus patterns while applying Dify UI tokens and surface treatments. Base UI ScrollArea.Content defaults to min-width: fit-content, so vertical-only regions that should truncate long content must set min-width: 0 on the content slot.', + component: + 'Compound scroll container built on Base UI Scroll Area. The examples mirror the upstream anatomy and focus patterns while applying Dify UI tokens and surface treatments. Base UI ScrollArea.Content defaults to min-width: fit-content, so vertical-only regions that should truncate long content must set min-width: 0 on the content slot.', }, }, }, @@ -64,15 +65,20 @@ function StorySection({ className?: string }) { return ( - <section className={cn('min-w-0 rounded-[28px] border border-divider-subtle bg-background-body p-5', className)}> + <section + className={cn( + 'min-w-0 rounded-[28px] border border-divider-subtle bg-background-body p-5', + className, + )} + > <div className="space-y-1"> <div className="system-xs-medium-uppercase text-text-tertiary">{eyebrow}</div> <h3 className="system-md-semibold text-text-primary">{title}</h3> - <p className="max-w-[72ch] system-sm-regular text-pretty text-text-secondary">{description}</p> - </div> - <div className="mt-5 flex justify-center"> - {children} + <p className="max-w-[72ch] system-sm-regular text-pretty text-text-secondary"> + {description} + </p> </div> + <div className="mt-5 flex justify-center">{children}</div> </section> ) } @@ -102,12 +108,14 @@ export const Anatomy: Story = { description="The baseline story mirrors the official Scroll Area anatomy: Root, Viewport, Content, Scrollbar, and Thumb, with keyboard focus drawn by the viewport." > <ScrollAreaRoot className="relative h-75 w-full max-w-105 min-w-0"> - <ScrollAreaViewport aria-label="Scrollable anatomy example" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg"> + <ScrollAreaViewport + aria-label="Scrollable anatomy example" + role="region" + className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg" + > <VerticalContent className="flex flex-col gap-4 py-2 pr-5 pl-3 system-sm-regular leading-6 text-text-secondary"> - {articleParagraphs.map(paragraph => ( - <p key={paragraph}> - {paragraph} - </p> + {articleParagraphs.map((paragraph) => ( + <p key={paragraph}>{paragraph}</p> ))} </VerticalContent> </ScrollAreaViewport> @@ -127,19 +135,21 @@ export const Vertical: Story = { description="Vertical overflow keeps the official viewport focus pattern while constraining content width so text never leaks outside the frame." > <ScrollAreaRoot className="relative h-90 w-full max-w-130 min-w-0"> - <ScrollAreaViewport aria-label="Long form content" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg"> + <ScrollAreaViewport + aria-label="Long form content" + role="region" + className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg" + > <VerticalContent className="flex flex-col gap-4 p-4 pr-6 system-sm-regular leading-6 text-text-secondary"> <div className="space-y-1"> <div className="system-xs-medium-uppercase text-text-tertiary">Article</div> <div className="system-md-semibold text-text-primary">Scrollable text region</div> </div> - {Array.from({ length: 4 }, (_, groupIndex) => ( - articleParagraphs.map(paragraph => ( - <p key={`${groupIndex}-${paragraph}`}> - {paragraph} - </p> - )) - ))} + {Array.from({ length: 4 }, (_, groupIndex) => + articleParagraphs.map((paragraph) => ( + <p key={`${groupIndex}-${paragraph}`}>{paragraph}</p> + )), + )} </VerticalContent> </ScrollAreaViewport> <ScrollAreaScrollbar> @@ -158,9 +168,13 @@ export const VerticalTruncation: Story = { description="Use width constraints plus minWidth: 0 on ScrollArea.Content when a vertical-only list should keep vertical scrolling while truncating long labels instead of creating horizontal scroll." > <ScrollAreaRoot className="relative h-48 w-full max-w-80 min-w-0"> - <ScrollAreaViewport aria-label="Vertical file list" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg"> + <ScrollAreaViewport + aria-label="Vertical file list" + role="region" + className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg" + > <VerticalContent className="flex flex-col gap-0.5 p-2"> - {fileRows.map(file => ( + {fileRows.map((file) => ( <div key={file} className="flex h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-text-secondary hover:bg-state-base-hover" @@ -188,10 +202,11 @@ export const ScrollFade: Story = { title="Viewport mask with root focus" description="This mirrors the Base UI scroll-fade example: the viewport owns the mask and the root owns the focus outline so the indicator is never clipped." > - <ScrollAreaRoot className={cn( - 'relative h-90 w-full max-w-130 min-w-0', - 'has-[>_:first-child:focus-visible]:outline-2 has-[>_:first-child:focus-visible]:outline-offset-0 has-[>_:first-child:focus-visible]:outline-state-accent-solid', - )} + <ScrollAreaRoot + className={cn( + 'relative h-90 w-full max-w-130 min-w-0', + 'has-[>_:first-child:focus-visible]:outline-2 has-[>_:first-child:focus-visible]:outline-offset-0 has-[>_:first-child:focus-visible]:outline-state-accent-solid', + )} > <ScrollAreaViewport aria-label="Scroll fade article" @@ -202,13 +217,11 @@ export const ScrollFade: Story = { )} > <VerticalContent className="flex flex-col gap-4 px-4 py-3 pr-6 system-sm-regular leading-6 text-text-secondary"> - {Array.from({ length: 5 }, (_, groupIndex) => ( - articleParagraphs.map(paragraph => ( - <p key={`${groupIndex}-${paragraph}`}> - {paragraph} - </p> - )) - ))} + {Array.from({ length: 5 }, (_, groupIndex) => + articleParagraphs.map((paragraph) => ( + <p key={`${groupIndex}-${paragraph}`}>{paragraph}</p> + )), + )} </VerticalContent> </ScrollAreaViewport> <ScrollAreaScrollbar className="opacity-0 data-hovering:opacity-100 data-scrolling:opacity-100 data-scrolling:duration-0"> @@ -228,11 +241,18 @@ export const Horizontal: Story = { className="mx-auto max-w-190" > <ScrollAreaRoot className="relative h-46 w-full max-w-130 min-w-0"> - <ScrollAreaViewport aria-label="Horizontal numbered row" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg"> + <ScrollAreaViewport + aria-label="Horizontal numbered row" + role="region" + className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg" + > <ScrollAreaContent className="min-h-full min-w-max p-4 pb-6"> <div className="grid grid-cols-[repeat(18,6.25rem)] gap-3"> - {gridCells.slice(0, 18).map(cell => ( - <div key={cell} className="flex h-24 items-center justify-center rounded-xl border border-divider-subtle bg-components-panel-bg-alt system-md-semibold text-text-secondary tabular-nums"> + {gridCells.slice(0, 18).map((cell) => ( + <div + key={cell} + className="flex h-24 items-center justify-center rounded-xl border border-divider-subtle bg-components-panel-bg-alt system-md-semibold text-text-secondary tabular-nums" + > {cell} </div> ))} @@ -255,11 +275,18 @@ export const BothAxes: Story = { description="This follows the official two-axis example: both scrollbars are rendered and Corner reserves the intersection." > <ScrollAreaRoot className="relative h-85 w-full max-w-140 min-w-0"> - <ScrollAreaViewport aria-label="Numbered grid" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg"> + <ScrollAreaViewport + aria-label="Numbered grid" + role="region" + className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg" + > <ScrollAreaContent className="pt-3 pr-6 pb-6 pl-3"> <div className="grid grid-cols-[repeat(10,6.25rem)] grid-rows-[repeat(10,6.25rem)] gap-3"> - {gridCells.map(cell => ( - <div key={cell} className="flex items-center justify-center rounded-lg border border-divider-subtle bg-components-panel-bg-alt system-md-semibold text-text-secondary tabular-nums"> + {gridCells.map((cell) => ( + <div + key={cell} + className="flex items-center justify-center rounded-lg border border-divider-subtle bg-components-panel-bg-alt system-md-semibold text-text-secondary tabular-nums" + > {cell} </div> ))} diff --git a/packages/dify-ui/src/scroll-area/index.tsx b/packages/dify-ui/src/scroll-area/index.tsx index 6097f2cedf723e..f6914d33002193 100644 --- a/packages/dify-ui/src/scroll-area/index.tsx +++ b/packages/dify-ui/src/scroll-area/index.tsx @@ -49,24 +49,15 @@ const scrollAreaCornerClassName = 'bg-transparent' type ScrollAreaViewportProps = BaseScrollArea.Viewport.Props -export function ScrollAreaViewport({ - className, - ...props -}: ScrollAreaViewportProps) { +export function ScrollAreaViewport({ className, ...props }: ScrollAreaViewportProps) { return ( - <BaseScrollArea.Viewport - className={cn(scrollAreaViewportClassName, className)} - {...props} - /> + <BaseScrollArea.Viewport className={cn(scrollAreaViewportClassName, className)} {...props} /> ) } type ScrollAreaScrollbarProps = BaseScrollArea.Scrollbar.Props -export function ScrollAreaScrollbar({ - className, - ...props -}: ScrollAreaScrollbarProps) { +export function ScrollAreaScrollbar({ className, ...props }: ScrollAreaScrollbarProps) { return ( <BaseScrollArea.Scrollbar data-dify-scrollbar="" @@ -78,30 +69,14 @@ export function ScrollAreaScrollbar({ type ScrollAreaThumbProps = BaseScrollArea.Thumb.Props -export function ScrollAreaThumb({ - className, - ...props -}: ScrollAreaThumbProps) { - return ( - <BaseScrollArea.Thumb - className={cn(scrollAreaThumbClassName, className)} - {...props} - /> - ) +export function ScrollAreaThumb({ className, ...props }: ScrollAreaThumbProps) { + return <BaseScrollArea.Thumb className={cn(scrollAreaThumbClassName, className)} {...props} /> } type ScrollAreaCornerProps = BaseScrollArea.Corner.Props -export function ScrollAreaCorner({ - className, - ...props -}: ScrollAreaCornerProps) { - return ( - <BaseScrollArea.Corner - className={cn(scrollAreaCornerClassName, className)} - {...props} - /> - ) +export function ScrollAreaCorner({ className, ...props }: ScrollAreaCornerProps) { + return <BaseScrollArea.Corner className={cn(scrollAreaCornerClassName, className)} {...props} /> } export function ScrollArea({ @@ -121,9 +96,7 @@ export function ScrollArea({ className={slotClassNames?.viewport} role={label || labelledBy ? 'region' : undefined} > - <ScrollAreaContent className={slotClassNames?.content}> - {children} - </ScrollAreaContent> + <ScrollAreaContent className={slotClassNames?.content}>{children}</ScrollAreaContent> </ScrollAreaViewport> <ScrollAreaScrollbar orientation={orientation} className={slotClassNames?.scrollbar}> <ScrollAreaThumb /> diff --git a/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx b/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx index c68088feebba77..bddcd56e0f76ad 100644 --- a/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx @@ -1,9 +1,5 @@ import { render } from 'vitest-browser-react' -import { - SegmentedControl, - SegmentedControlDivider, - SegmentedControlItem, -} from '../index' +import { SegmentedControl, SegmentedControlDivider, SegmentedControlItem } from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement @@ -16,7 +12,9 @@ describe('SegmentedControl wrappers', () => { </SegmentedControl>, ) - await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true') + await expect + .element(screen.getByRole('button', { name: 'One' })) + .toHaveAttribute('aria-pressed', 'true') }) it('uses single selection by default', async () => { @@ -29,8 +27,12 @@ describe('SegmentedControl wrappers', () => { asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click() - await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'false') - await expect.element(screen.getByRole('button', { name: 'Two' })).toHaveAttribute('aria-pressed', 'true') + await expect + .element(screen.getByRole('button', { name: 'One' })) + .toHaveAttribute('aria-pressed', 'false') + await expect + .element(screen.getByRole('button', { name: 'Two' })) + .toHaveAttribute('aria-pressed', 'true') }) it('calls onValueChange while leaving controlled value to the caller', async () => { @@ -45,7 +47,9 @@ describe('SegmentedControl wrappers', () => { asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click() expect(onValueChange).toHaveBeenCalledWith(['two'], expect.anything()) - await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true') + await expect + .element(screen.getByRole('button', { name: 'One' })) + .toHaveAttribute('aria-pressed', 'true') }) it('preserves Base UI empty-array behavior when a single selected item is toggled off', async () => { @@ -60,15 +64,21 @@ describe('SegmentedControl wrappers', () => { asHTMLElement(screen.getByRole('button', { name: 'One' }).element()).click() expect(onValueChange).toHaveBeenCalledWith([], expect.anything()) - await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true') + await expect + .element(screen.getByRole('button', { name: 'One' })) + .toHaveAttribute('aria-pressed', 'true') }) it('forwards disabled and className to composable parts', async () => { const screen = await render( <SegmentedControl defaultValue={['one']} aria-label="View" className="custom-group"> - <SegmentedControlItem value="one" className="custom-item">One</SegmentedControlItem> + <SegmentedControlItem value="one" className="custom-item"> + One + </SegmentedControlItem> <SegmentedControlDivider className="custom-divider" data-testid="divider" /> - <SegmentedControlItem value="two" disabled>Two</SegmentedControlItem> + <SegmentedControlItem value="two" disabled> + Two + </SegmentedControlItem> </SegmentedControl>, ) diff --git a/packages/dify-ui/src/segmented-control/index.stories.tsx b/packages/dify-ui/src/segmented-control/index.stories.tsx index f2b6f9cc1f5001..7377bf10314395 100644 --- a/packages/dify-ui/src/segmented-control/index.stories.tsx +++ b/packages/dify-ui/src/segmented-control/index.stories.tsx @@ -1,10 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' -import { - SegmentedControl, - SegmentedControlDivider, - SegmentedControlItem, -} from '.' +import { SegmentedControl, SegmentedControlDivider, SegmentedControlItem } from '.' const meta = { title: 'Base/UI/SegmentedControl', @@ -13,7 +9,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Segmented control built on Base UI ToggleGroup and Toggle. Use it for mode, filter, and view selection that does not need tabpanel semantics.', + component: + 'Segmented control built on Base UI ToggleGroup and Toggle. Use it for mode, filter, and view selection that does not need tabpanel semantics.', }, }, }, @@ -30,9 +27,7 @@ type SegmentedControlProps = { noPadding?: boolean } -const Icon = () => ( - <i className="i-ri-information-line size-4 shrink-0" aria-hidden="true" /> -) +const Icon = () => <i className="i-ri-information-line size-4 shrink-0" aria-hidden="true" /> const Item = () => ( <React.Fragment> @@ -60,12 +55,13 @@ function SegmentedControlExample({ aria-label={iconOnly ? `Item ${index + 1}` : undefined} > <Icon /> - {!iconOnly && ( - <span className="px-0.5">Item</span> - )} + {!iconOnly && <span className="px-0.5">Item</span>} </SegmentedControlItem> {index === 1 && ( - <span className="pointer-events-none absolute top-0 -right-px flex h-full items-center" aria-hidden="true"> + <span + className="pointer-events-none absolute top-0 -right-px flex h-full items-center" + aria-hidden="true" + > <SegmentedControlDivider /> </span> )} @@ -88,18 +84,10 @@ function SpecColumn() { ) } -function SpecPanel({ - className, - children, -}: { - className?: string - children: React.ReactNode -}) { +function SpecPanel({ className, children }: { className?: string; children: React.ReactNode }) { return ( <div className={className}> - <div className="flex min-h-105 items-center justify-center"> - {children} - </div> + <div className="flex min-h-105 items-center justify-center">{children}</div> </div> ) } @@ -115,7 +103,8 @@ export const DesignSpec: Story = { parameters: { docs: { description: { - story: 'Figma node 2473:9851: segmented control examples with text+icon and icon-only rows, with and without outer padding.', + story: + 'Figma node 2473:9851: segmented control examples with text+icon and icon-only rows, with and without outer padding.', }, }, }, @@ -140,10 +129,7 @@ export const DataAttributeStates: Story = { <SegmentedControlItem value="accent-light"> <Item /> </SegmentedControlItem> - <SegmentedControlItem - value="neutral" - className="data-pressed:text-text-primary" - > + <SegmentedControlItem value="neutral" className="data-pressed:text-text-primary"> <Item /> </SegmentedControlItem> <SegmentedControlItem @@ -170,7 +156,8 @@ export const DataAttributeStates: Story = { parameters: { docs: { description: { - story: '`SegmentedControlItem` gets `data-pressed` and `data-disabled` from Base UI Toggle. Accent, neutral, and multiple-selection examples are composed through props and className.', + story: + '`SegmentedControlItem` gets `data-pressed` and `data-disabled` from Base UI Toggle. Accent, neutral, and multiple-selection examples are composed through props and className.', }, }, }, diff --git a/packages/dify-ui/src/segmented-control/index.tsx b/packages/dify-ui/src/segmented-control/index.tsx index 843435d442c392..3017feedd3c3d3 100644 --- a/packages/dify-ui/src/segmented-control/index.tsx +++ b/packages/dify-ui/src/segmented-control/index.tsx @@ -7,7 +7,10 @@ import { Toggle as BaseToggle } from '@base-ui/react/toggle' import { ToggleGroup as BaseToggleGroup } from '@base-ui/react/toggle-group' import { cn } from '../cn' -export type SegmentedControlProps<Value extends string = string> = Omit<BaseToggleGroupNS.Props<Value>, 'className'> & { +export type SegmentedControlProps<Value extends string = string> = Omit< + BaseToggleGroupNS.Props<Value>, + 'className' +> & { className?: string } @@ -17,13 +20,19 @@ export function SegmentedControl<Value extends string = string>({ }: SegmentedControlProps<Value>) { return ( <BaseToggleGroup - className={cn('inline-flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5', className)} + className={cn( + 'inline-flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5', + className, + )} {...props} /> ) } -export type SegmentedControlItemProps<Value extends string = string> = Omit<BaseToggleNS.Props<Value>, 'className'> & { +export type SegmentedControlItemProps<Value extends string = string> = Omit< + BaseToggleNS.Props<Value>, + 'className' +> & { className?: string } @@ -33,7 +42,10 @@ export function SegmentedControlItem<Value extends string = string>({ }: SegmentedControlItemProps<Value>) { return ( <BaseToggle - className={cn('relative flex h-7 min-w-0 touch-manipulation items-center justify-center gap-0.5 overflow-hidden rounded-lg border-[0.5px] border-transparent px-2 py-1 system-sm-medium whitespace-nowrap text-text-secondary transition-colors duration-150 hover:bg-state-base-hover hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden data-disabled:cursor-not-allowed data-disabled:bg-transparent data-disabled:text-text-disabled data-disabled:shadow-none data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled data-pressed:border-components-segmented-control-item-active-border data-pressed:bg-components-segmented-control-item-active-bg data-pressed:text-text-accent-light-mode-only data-pressed:shadow-xs data-pressed:shadow-shadow-shadow-3 motion-reduce:transition-none', className)} + className={cn( + 'relative flex h-7 min-w-0 touch-manipulation items-center justify-center gap-0.5 overflow-hidden rounded-lg border-[0.5px] border-transparent px-2 py-1 system-sm-medium whitespace-nowrap text-text-secondary transition-colors duration-150 hover:bg-state-base-hover hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden data-disabled:cursor-not-allowed data-disabled:bg-transparent data-disabled:text-text-disabled data-disabled:shadow-none data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled data-pressed:border-components-segmented-control-item-active-border data-pressed:bg-components-segmented-control-item-active-bg data-pressed:text-text-accent-light-mode-only data-pressed:shadow-xs data-pressed:shadow-shadow-shadow-3 motion-reduce:transition-none', + className, + )} {...props} /> ) @@ -43,10 +55,7 @@ export type SegmentedControlDividerProps = Omit<React.ComponentProps<'span'>, 'c className?: string } -export function SegmentedControlDivider({ - className, - ...props -}: SegmentedControlDividerProps) { +export function SegmentedControlDivider({ className, ...props }: SegmentedControlDividerProps) { return ( <span role="presentation" diff --git a/packages/dify-ui/src/select/__tests__/index.spec.tsx b/packages/dify-ui/src/select/__tests__/index.spec.tsx index ae4dc56610783a..6b5ccbf10bb98f 100644 --- a/packages/dify-ui/src/select/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/select/__tests__/index.spec.tsx @@ -14,11 +14,8 @@ import { } from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement -const renderWithSafeViewport = (ui: React.ReactNode) => render( - <div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}> - {ui} - </div>, -) +const renderWithSafeViewport = (ui: React.ReactNode) => + render(<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>{ui}</div>) const renderOpenSelect = ({ rootProps = {}, @@ -38,15 +35,15 @@ const renderOpenSelect = ({ </SelectTrigger> <SelectContent positionerProps={{ - 'role': 'group', + role: 'group', 'aria-label': 'select positioner', }} popupProps={{ - 'role': 'dialog', + role: 'dialog', 'aria-label': 'select popup', }} listProps={{ - 'role': 'listbox', + role: 'listbox', 'aria-label': 'select list', }} {...contentProps} @@ -73,7 +70,7 @@ describe('Select wrappers', () => { <SelectTrigger aria-label="city select"> <SelectValue /> </SelectTrigger> - <SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}> + <SelectContent listProps={{ role: 'listbox', 'aria-label': 'select list' }}> <SelectItem value="seattle"> <SelectItemText>Seattle</SelectItemText> <SelectItemIndicator /> @@ -119,7 +116,7 @@ describe('Select wrappers', () => { const screen = await renderOpenSelect({ triggerProps: { 'aria-label': 'Choose option', - 'disabled': true, + disabled: true, }, }) @@ -131,7 +128,9 @@ describe('Select wrappers', () => { triggerProps: { disabled: true }, }) - await expect.element(screen.getByRole('combobox', { name: 'city select' })).toHaveAttribute('data-disabled') + await expect + .element(screen.getByRole('combobox', { name: 'city select' })) + .toHaveAttribute('data-disabled') }) it('should expose readonly state via data attributes when Root is readOnly', async () => { @@ -139,7 +138,9 @@ describe('Select wrappers', () => { rootProps: { readOnly: true }, }) - await expect.element(screen.getByRole('combobox', { name: 'city select' })).toHaveAttribute('data-readonly') + await expect + .element(screen.getByRole('combobox', { name: 'city select' })) + .toHaveAttribute('data-readonly') }) }) @@ -150,7 +151,7 @@ describe('Select wrappers', () => { <SelectTrigger aria-label="city select"> <SelectValue /> </SelectTrigger> - <SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}> + <SelectContent listProps={{ role: 'listbox', 'aria-label': 'select list' }}> <SelectGroup> <SelectGroupLabel className="custom-label">Popular cities</SelectGroupLabel> <SelectItem value="seattle"> @@ -162,15 +163,21 @@ describe('Select wrappers', () => { </Select>, ) - await expect.element(screen.getByRole('combobox', { name: 'city select' })).toBeInTheDocument() + await expect + .element(screen.getByRole('combobox', { name: 'city select' })) + .toBeInTheDocument() await expect.element(screen.getByText('Popular cities')).toHaveClass('custom-label') }) it('should use positioning attributes when placement is not provided', async () => { const screen = await renderOpenSelect() - await expect.element(screen.getByRole('group', { name: 'select positioner' })).toHaveAttribute('data-side', 'bottom') - await expect.element(screen.getByRole('group', { name: 'select positioner' })).toHaveAttribute('data-align', 'start') + await expect + .element(screen.getByRole('group', { name: 'select positioner' })) + .toHaveAttribute('data-side', 'bottom') + await expect + .element(screen.getByRole('group', { name: 'select positioner' })) + .toHaveAttribute('data-align', 'start') }) it('should preserve positioning attributes when placement props are provided', async () => { @@ -182,8 +189,12 @@ describe('Select wrappers', () => { }, }) - await expect.element(screen.getByRole('group', { name: 'select positioner' })).toHaveAttribute('data-side', 'top') - await expect.element(screen.getByRole('group', { name: 'select positioner' })).toHaveAttribute('data-align', 'end') + await expect + .element(screen.getByRole('group', { name: 'select positioner' })) + .toHaveAttribute('data-side', 'top') + await expect + .element(screen.getByRole('group', { name: 'select positioner' })) + .toHaveAttribute('data-align', 'end') }) it('should forward passthrough props to positioner popup and list when passthrough props are provided', async () => { @@ -197,21 +208,21 @@ describe('Select wrappers', () => { </SelectTrigger> <SelectContent positionerProps={{ - 'role': 'group', + role: 'group', 'aria-label': 'select positioner', - 'id': 'select-positioner', + id: 'select-positioner', }} popupProps={{ - 'role': 'dialog', + role: 'dialog', 'aria-label': 'select popup', - 'id': 'select-popup', - 'onClick': onPopupClick, + id: 'select-popup', + onClick: onPopupClick, }} listProps={{ - 'role': 'listbox', + role: 'listbox', 'aria-label': 'select list', - 'id': 'select-list', - 'onFocus': onListFocus, + id: 'select-list', + onFocus: onListFocus, }} > <SelectItem value="seattle"> @@ -223,13 +234,24 @@ describe('Select wrappers', () => { ) await screen.getByRole('dialog', { name: 'select popup' }).click() - screen.getByRole('listbox', { name: 'select list' }).element().dispatchEvent(new FocusEvent('focusin', { - bubbles: true, - })) - - await expect.element(screen.getByRole('group', { name: 'select positioner' })).toHaveAttribute('id', 'select-positioner') - await expect.element(screen.getByRole('dialog', { name: 'select popup' })).toHaveAttribute('id', 'select-popup') - await expect.element(screen.getByRole('listbox', { name: 'select list' })).toHaveAttribute('id', 'select-list') + screen + .getByRole('listbox', { name: 'select list' }) + .element() + .dispatchEvent( + new FocusEvent('focusin', { + bubbles: true, + }), + ) + + await expect + .element(screen.getByRole('group', { name: 'select positioner' })) + .toHaveAttribute('id', 'select-positioner') + await expect + .element(screen.getByRole('dialog', { name: 'select popup' })) + .toHaveAttribute('id', 'select-popup') + await expect + .element(screen.getByRole('listbox', { name: 'select list' })) + .toHaveAttribute('id', 'select-list') expect(onPopupClick).toHaveBeenCalledTimes(1) expect(onListFocus).toHaveBeenCalled() }) @@ -249,7 +271,7 @@ describe('Select wrappers', () => { <SelectTrigger aria-label="city select"> <SelectValue /> </SelectTrigger> - <SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}> + <SelectContent listProps={{ role: 'listbox', 'aria-label': 'select list' }}> <SelectItem value="seattle"> <SelectItemText>Seattle</SelectItemText> <SelectItemIndicator /> @@ -269,13 +291,23 @@ describe('Select wrappers', () => { const trigger = asHTMLElement(screen.getByRole('combobox', { name: 'city select' }).element()) trigger.focus() - trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })) - await expect.element(screen.getByRole('option', { name: 'Seattle' })).toHaveAttribute('data-highlighted') + trigger.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }), + ) + await expect + .element(screen.getByRole('option', { name: 'Seattle' })) + .toHaveAttribute('data-highlighted') - const highlightedItem = asHTMLElement(screen.getByRole('option', { name: 'Seattle' }).element()) - highlightedItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })) + const highlightedItem = asHTMLElement( + screen.getByRole('option', { name: 'Seattle' }).element(), + ) + highlightedItem.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }), + ) - await expect.element(screen.getByRole('option', { name: 'New York' })).toHaveAttribute('data-highlighted') + await expect + .element(screen.getByRole('option', { name: 'New York' })) + .toHaveAttribute('data-highlighted') }) it('should not call onValueChange when disabled item is clicked', async () => { @@ -286,7 +318,7 @@ describe('Select wrappers', () => { <SelectTrigger aria-label="city select"> <SelectValue /> </SelectTrigger> - <SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}> + <SelectContent listProps={{ role: 'listbox', 'aria-label': 'select list' }}> <SelectItem value="seattle"> <SelectItemText>Seattle</SelectItemText> <SelectItemIndicator /> @@ -310,7 +342,7 @@ describe('Select wrappers', () => { <SelectTrigger aria-label="custom select"> <SelectValue /> </SelectTrigger> - <SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}> + <SelectContent listProps={{ role: 'listbox', 'aria-label': 'select list' }}> <SelectItem value="a" className="gap-2"> <SelectItemText>Custom Item</SelectItemText> </SelectItem> diff --git a/packages/dify-ui/src/select/index.stories.tsx b/packages/dify-ui/src/select/index.stories.tsx index 0ac822735a8996..4c386f81857af1 100644 --- a/packages/dify-ui/src/select/index.stories.tsx +++ b/packages/dify-ui/src/select/index.stories.tsx @@ -34,7 +34,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound select built on Base UI Select. Compose `SelectTrigger`, `SelectContent`, and `SelectItem` to build accessible single-value pickers with groups, labels, separators, and keyboard selection.', + component: + 'Compound select built on Base UI Select. Compose `SelectTrigger`, `SelectContent`, and `SelectItem` to build accessible single-value pickers with groups, labels, separators, and keyboard selection.', }, }, }, @@ -147,7 +148,7 @@ export const WithPlaceholder: Story = { export const Sizes: Story = { render: () => ( <div className="flex flex-col gap-3"> - {(['small', 'medium', 'large'] as const).map(size => ( + {(['small', 'medium', 'large'] as const).map((size) => ( <div key={size} className={triggerWidth}> <Select defaultValue="seattle"> <SelectTrigger aria-label={`${size} select`} size={size}> @@ -354,7 +355,9 @@ export const InForm: Story = { <FieldDescription>Used to schedule workflow runs.</FieldDescription> </Field> <div className="flex justify-end"> - <Button type="submit" variant="primary">Save</Button> + <Button type="submit" variant="primary"> + Save + </Button> </div> </Form> ), diff --git a/packages/dify-ui/src/select/index.tsx b/packages/dify-ui/src/select/index.tsx index bdaab8041e2dd0..d66ed5c4b4d1b9 100644 --- a/packages/dify-ui/src/select/index.tsx +++ b/packages/dify-ui/src/select/index.tsx @@ -54,25 +54,13 @@ const selectTriggerVariants = cva( }, ) -type SelectTriggerProps - = Omit<BaseSelect.Trigger.Props, 'className'> - & VariantProps<typeof selectTriggerVariants> - & { className?: string } +type SelectTriggerProps = Omit<BaseSelect.Trigger.Props, 'className'> & + VariantProps<typeof selectTriggerVariants> & { className?: string } -export function SelectTrigger({ - className, - children, - size, - ...props -}: SelectTriggerProps) { +export function SelectTrigger({ className, children, size, ...props }: SelectTriggerProps) { return ( - <BaseSelect.Trigger - className={cn(selectTriggerVariants({ size, className }))} - {...props} - > - <span className="min-w-0 grow truncate"> - {children} - </span> + <BaseSelect.Trigger className={cn(selectTriggerVariants({ size, className }))} {...props}> + <span className="min-w-0 grow truncate">{children}</span> <BaseSelect.Icon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary group-data-readonly:hidden data-popup-open:text-text-secondary"> <span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" /> </BaseSelect.Icon> @@ -80,40 +68,16 @@ export function SelectTrigger({ ) } -export function SelectLabel({ - className, - ...props -}: BaseSelect.Label.Props) { - return ( - <BaseSelect.Label - className={cn(formLabelClassName, className)} - {...props} - /> - ) +export function SelectLabel({ className, ...props }: BaseSelect.Label.Props) { + return <BaseSelect.Label className={cn(formLabelClassName, className)} {...props} /> } -export function SelectGroupLabel({ - className, - ...props -}: BaseSelect.GroupLabel.Props) { - return ( - <BaseSelect.GroupLabel - className={cn(overlayLabelClassName, className)} - {...props} - /> - ) +export function SelectGroupLabel({ className, ...props }: BaseSelect.GroupLabel.Props) { + return <BaseSelect.GroupLabel className={cn(overlayLabelClassName, className)} {...props} /> } -export function SelectSeparator({ - className, - ...props -}: BaseSelect.Separator.Props) { - return ( - <BaseSelect.Separator - className={cn(overlaySeparatorClassName, className)} - {...props} - /> - ) +export function SelectSeparator({ className, ...props }: BaseSelect.Separator.Props) { + return <BaseSelect.Separator className={cn(overlaySeparatorClassName, className)} {...props} /> } type SelectContentProps = { @@ -128,14 +92,8 @@ type SelectContentProps = { BaseSelect.Positioner.Props, 'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset' > - popupProps?: Omit< - BaseSelect.Popup.Props, - 'children' | 'className' - > - listProps?: Omit< - BaseSelect.List.Props, - 'children' | 'className' - > + popupProps?: Omit<BaseSelect.Popup.Props, 'children' | 'className'> + listProps?: Omit<BaseSelect.List.Props, 'children' | 'className'> } export function SelectContent({ @@ -183,10 +141,7 @@ export function SelectContent({ ) } -export function SelectItem({ - className, - ...props -}: BaseSelect.Item.Props) { +export function SelectItem({ className, ...props }: BaseSelect.Item.Props) { return ( <BaseSelect.Item className={cn( @@ -199,15 +154,9 @@ export function SelectItem({ ) } -export function SelectItemText({ - className, - ...props -}: BaseSelect.ItemText.Props) { +export function SelectItemText({ className, ...props }: BaseSelect.ItemText.Props) { return ( - <BaseSelect.ItemText - className={cn('me-1 min-w-0 grow truncate px-1', className)} - {...props} - /> + <BaseSelect.ItemText className={cn('me-1 min-w-0 grow truncate px-1', className)} {...props} /> ) } diff --git a/packages/dify-ui/src/slider/__tests__/index.spec.tsx b/packages/dify-ui/src/slider/__tests__/index.spec.tsx index eeffbb4470787e..8bd6f78600ba4e 100644 --- a/packages/dify-ui/src/slider/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/slider/__tests__/index.spec.tsx @@ -21,7 +21,9 @@ describe('Slider', () => { }) it('should apply custom min, max, and step values', async () => { - const screen = await render(<Slider value={10} min={5} max={20} step={5} onValueChange={vi.fn()} aria-label="Value" />) + const screen = await render( + <Slider value={10} min={5} max={20} step={5} onValueChange={vi.fn()} aria-label="Value" />, + ) await expect.element(screen.getByLabelText('Value')).toHaveAttribute('min', '5') await expect.element(screen.getByLabelText('Value')).toHaveAttribute('max', '20') @@ -29,14 +31,18 @@ describe('Slider', () => { }) it('should clamp non-finite values to min', async () => { - const screen = await render(<Slider value={Number.NaN} min={5} onValueChange={vi.fn()} aria-label="Value" />) + const screen = await render( + <Slider value={Number.NaN} min={5} onValueChange={vi.fn()} aria-label="Value" />, + ) await expect.element(screen.getByLabelText('Value')).toHaveAttribute('aria-valuenow', '5') }) it('should call onValueChange when arrow keys are pressed', async () => { const onValueChange = vi.fn() - const screen = await render(<Slider value={20} onValueChange={onValueChange} aria-label="Value" />) + const screen = await render( + <Slider value={20} onValueChange={onValueChange} aria-label="Value" />, + ) const slider = screen.getByLabelText('Value').element() slider.focus() @@ -50,7 +56,16 @@ describe('Slider', () => { it('should round floating point keyboard updates to the configured step', async () => { const onValueChange = vi.fn() - const screen = await render(<Slider value={0.2} min={0} max={1} step={0.1} onValueChange={onValueChange} aria-label="Value" />) + const screen = await render( + <Slider + value={0.2} + min={0} + max={1} + step={0.1} + onValueChange={onValueChange} + aria-label="Value" + />, + ) const slider = screen.getByLabelText('Value').element() slider.focus() @@ -64,7 +79,9 @@ describe('Slider', () => { it('should not trigger onValueChange when disabled', async () => { const onValueChange = vi.fn() - const screen = await render(<Slider value={20} onValueChange={onValueChange} disabled aria-label="Value" />) + const screen = await render( + <Slider value={20} onValueChange={onValueChange} disabled aria-label="Value" />, + ) const slider = screen.getByLabelText('Value').element() expect(slider).toBeDisabled() @@ -75,7 +92,9 @@ describe('Slider', () => { }) it('should apply custom class names on root', async () => { - const screen = await render(<Slider value={10} onValueChange={vi.fn()} className="outer-test" aria-label="Value" />) + const screen = await render( + <Slider value={10} onValueChange={vi.fn()} className="outer-test" aria-label="Value" />, + ) expect(screen.container.querySelector('.outer-test')).toBeInTheDocument() }) @@ -99,7 +118,9 @@ describe('Slider', () => { </SliderRoot>, ) - await expect.element(screen.getByRole('slider', { name: 'Temperature' })).toHaveAttribute('aria-valuenow', '50') + await expect + .element(screen.getByRole('slider', { name: 'Temperature' })) + .toHaveAttribute('aria-valuenow', '50') await expect.element(screen.getByText('Temperature')).toBeInTheDocument() }) }) diff --git a/packages/dify-ui/src/slider/index.stories.tsx b/packages/dify-ui/src/slider/index.stories.tsx index 8f9c8d7cc9900d..2524278585aeb0 100644 --- a/packages/dify-ui/src/slider/index.stories.tsx +++ b/packages/dify-ui/src/slider/index.stories.tsx @@ -54,21 +54,14 @@ function SliderDemo({ return ( <div className="w-[320px] space-y-3"> - <Slider - {...args} - value={value} - onValueChange={setValue} - aria-label="Demo slider" - /> - <div className="text-center system-sm-medium text-text-secondary"> - {value} - </div> + <Slider {...args} value={value} onValueChange={setValue} aria-label="Demo slider" /> + <div className="text-center system-sm-medium text-text-secondary">{value}</div> </div> ) } export const Default: Story = { - render: args => <SliderDemo {...args} />, + render: (args) => <SliderDemo {...args} />, args: { value: 50, min: 0, @@ -78,7 +71,7 @@ export const Default: Story = { } export const Decimal: Story = { - render: args => <SliderDemo {...args} />, + render: (args) => <SliderDemo {...args} />, args: { value: 0.5, min: 0, @@ -88,7 +81,7 @@ export const Decimal: Story = { } export const Disabled: Story = { - render: args => <SliderDemo {...args} />, + render: (args) => <SliderDemo {...args} />, args: { value: 75, min: 0, @@ -100,7 +93,10 @@ export const Disabled: Story = { export const ComposedWithLabel: Story = { render: () => ( - <SliderRoot defaultValue={50} className="group/slider relative inline-flex w-[320px] flex-col gap-1 data-disabled:opacity-30"> + <SliderRoot + defaultValue={50} + className="group/slider relative inline-flex w-[320px] flex-col gap-1 data-disabled:opacity-30" + > <SliderLabel>Temperature</SliderLabel> <SliderControl> <SliderTrack> diff --git a/packages/dify-ui/src/slider/index.tsx b/packages/dify-ui/src/slider/index.tsx index fb53c468f253f4..db6cdde2144751 100644 --- a/packages/dify-ui/src/slider/index.tsx +++ b/packages/dify-ui/src/slider/index.tsx @@ -6,16 +6,8 @@ import { formLabelClassName } from '../form-control-shared' export const SliderRoot = BaseSlider.Root -export function SliderLabel({ - className, - ...props -}: BaseSlider.Label.Props) { - return ( - <BaseSlider.Label - className={cn(formLabelClassName, className)} - {...props} - /> - ) +export function SliderLabel({ className, ...props }: BaseSlider.Label.Props) { + return <BaseSlider.Label className={cn(formLabelClassName, className)} {...props} /> } type SliderRootProps = BaseSlider.Root.Props<number> @@ -28,12 +20,7 @@ const sliderControlClassName = cn( type SliderControlProps = BaseSlider.Control.Props export function SliderControl({ className, ...props }: SliderControlProps) { - return ( - <BaseSlider.Control - className={cn(sliderControlClassName, className)} - {...props} - /> - ) + return <BaseSlider.Control className={cn(sliderControlClassName, className)} {...props} /> } const sliderTrackClassName = cn( @@ -44,28 +31,15 @@ const sliderTrackClassName = cn( type SliderTrackProps = BaseSlider.Track.Props export function SliderTrack({ className, ...props }: SliderTrackProps) { - return ( - <BaseSlider.Track - className={cn(sliderTrackClassName, className)} - {...props} - /> - ) + return <BaseSlider.Track className={cn(sliderTrackClassName, className)} {...props} /> } -const sliderIndicatorClassName = cn( - 'h-full rounded-full', - 'bg-components-slider-range', -) +const sliderIndicatorClassName = cn('h-full rounded-full', 'bg-components-slider-range') type SliderIndicatorProps = BaseSlider.Indicator.Props export function SliderIndicator({ className, ...props }: SliderIndicatorProps) { - return ( - <BaseSlider.Indicator - className={cn(sliderIndicatorClassName, className)} - {...props} - /> - ) + return <BaseSlider.Indicator className={cn(sliderIndicatorClassName, className)} {...props} /> } const sliderThumbClassName = cn( @@ -81,12 +55,7 @@ const sliderThumbClassName = cn( type SliderThumbProps = BaseSlider.Thumb.Props export function SliderThumb({ className, ...props }: SliderThumbProps) { - return ( - <BaseSlider.Thumb - className={cn(sliderThumbClassName, className)} - {...props} - /> - ) + return <BaseSlider.Thumb className={cn(sliderThumbClassName, className)} {...props} /> } type SliderSlotClassNames = { @@ -99,10 +68,11 @@ type SliderSlotClassNames = { type SliderBaseProps = Pick< SliderRootProps, 'onValueChange' | 'min' | 'max' | 'step' | 'disabled' | 'name' -> & Pick<SliderThumbProps, 'aria-label' | 'aria-labelledby'> & { - className?: string - slotClassNames?: SliderSlotClassNames -} +> & + Pick<SliderThumbProps, 'aria-label' | 'aria-labelledby'> & { + className?: string + slotClassNames?: SliderSlotClassNames + } type ControlledSliderProps = SliderBaseProps & { value: number @@ -119,8 +89,7 @@ type SliderProps = ControlledSliderProps | UncontrolledSliderProps const sliderRootClassName = 'group/slider relative inline-flex w-full data-disabled:opacity-30' const getSafeValue = (value: number | undefined, min: number) => { - if (value === undefined) - return undefined + if (value === undefined) return undefined return Number.isFinite(value) ? value : min } diff --git a/packages/dify-ui/src/status-dot/index.stories.tsx b/packages/dify-ui/src/status-dot/index.stories.tsx index c6b6d56e27cc47..7a45821e055e5b 100644 --- a/packages/dify-ui/src/status-dot/index.stories.tsx +++ b/packages/dify-ui/src/status-dot/index.stories.tsx @@ -38,12 +38,10 @@ export const Matrix: Story = { <div /> <div className="system-xs-medium text-text-tertiary">Small</div> <div className="system-xs-medium text-text-tertiary">Medium</div> - {statuses.map(status => ( + {statuses.map((status) => ( <React.Fragment key={status}> - <div className="system-xs-semibold-uppercase text-text-secondary"> - {status} - </div> - {sizes.map(size => ( + <div className="system-xs-semibold-uppercase text-text-secondary">{status}</div> + {sizes.map((size) => ( <StatusDot key={`${status}-${size}`} status={status} size={size} /> ))} </React.Fragment> diff --git a/packages/dify-ui/src/status-dot/index.tsx b/packages/dify-ui/src/status-dot/index.tsx index e2ce1917e63c00..0f6891b4f781e3 100644 --- a/packages/dify-ui/src/status-dot/index.tsx +++ b/packages/dify-ui/src/status-dot/index.tsx @@ -5,28 +5,30 @@ import type * as React from 'react' import { cva } from 'class-variance-authority' import { cn } from '../cn' -const statusDotVariants = cva( - 'block shrink-0 border border-solid', - { - variants: { - status: { - success: 'border-components-badge-status-light-success-border-inner bg-components-badge-status-light-success-bg shadow-status-indicator-green-shadow', - warning: 'border-components-badge-status-light-warning-border-inner bg-components-badge-status-light-warning-bg shadow-status-indicator-warning-shadow', - error: 'border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg shadow-status-indicator-red-shadow', - normal: 'border-components-badge-status-light-normal-border-inner bg-components-badge-status-light-normal-bg shadow-status-indicator-blue-shadow', - disabled: 'border-components-badge-status-light-disabled-border-inner bg-components-badge-status-light-disabled-bg shadow-status-indicator-gray-shadow', - }, - size: { - small: 'size-1.5 rounded-xs', - medium: 'size-2 rounded-[3px]', - }, +const statusDotVariants = cva('block shrink-0 border border-solid', { + variants: { + status: { + success: + 'border-components-badge-status-light-success-border-inner bg-components-badge-status-light-success-bg shadow-status-indicator-green-shadow', + warning: + 'border-components-badge-status-light-warning-border-inner bg-components-badge-status-light-warning-bg shadow-status-indicator-warning-shadow', + error: + 'border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg shadow-status-indicator-red-shadow', + normal: + 'border-components-badge-status-light-normal-border-inner bg-components-badge-status-light-normal-bg shadow-status-indicator-blue-shadow', + disabled: + 'border-components-badge-status-light-disabled-border-inner bg-components-badge-status-light-disabled-bg shadow-status-indicator-gray-shadow', }, - defaultVariants: { - status: 'success', - size: 'medium', + size: { + small: 'size-1.5 rounded-xs', + medium: 'size-2 rounded-[3px]', }, }, -) + defaultVariants: { + status: 'success', + size: 'medium', + }, +}) const statusDotSkeletonVariants = cva( 'block shrink-0 border border-transparent bg-text-primary opacity-30', @@ -48,18 +50,14 @@ type StatusDotVariants = VariantProps<typeof statusDotVariants> export type StatusDotStatus = NonNullable<StatusDotVariants['status']> export type StatusDotSize = NonNullable<StatusDotVariants['size']> -export type StatusDotProps - = Omit<React.ComponentProps<'span'>, 'children'> - & { - status?: StatusDotStatus - size?: StatusDotSize - } +export type StatusDotProps = Omit<React.ComponentProps<'span'>, 'children'> & { + status?: StatusDotStatus + size?: StatusDotSize +} -export type StatusDotSkeletonProps - = Omit<React.ComponentProps<'span'>, 'children'> - & { - size?: StatusDotSize - } +export type StatusDotSkeletonProps = Omit<React.ComponentProps<'span'>, 'children'> & { + size?: StatusDotSize +} export function StatusDot({ className, @@ -74,10 +72,7 @@ export function StatusDot({ return ( <span - className={cn( - statusDotVariants({ status, size }), - className, - )} + className={cn(statusDotVariants({ status, size }), className)} aria-hidden={hidden} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} diff --git a/packages/dify-ui/src/styles/styles.css b/packages/dify-ui/src/styles/styles.css index e2403d308add2a..34e7f805a74ab4 100644 --- a/packages/dify-ui/src/styles/styles.css +++ b/packages/dify-ui/src/styles/styles.css @@ -60,18 +60,29 @@ /* shadows */ --shadow-xs: 0px 1px 2px 0px rgba(16, 24, 40, 0.05); - --shadow-sm: 0px 1px 2px 0px rgba(16, 24, 40, 0.06), 0px 1px 3px 0px rgba(16, 24, 40, 0.10); - --shadow-sm-no-bottom: 0px -1px 2px 0px rgba(16, 24, 40, 0.06), 0px -1px 3px 0px rgba(16, 24, 40, 0.10); - --shadow-md: 0px 2px 4px -2px rgba(16, 24, 40, 0.06), 0px 4px 8px -2px rgba(16, 24, 40, 0.10); + --shadow-sm: 0px 1px 2px 0px rgba(16, 24, 40, 0.06), 0px 1px 3px 0px rgba(16, 24, 40, 0.1); + --shadow-sm-no-bottom: + 0px -1px 2px 0px rgba(16, 24, 40, 0.06), 0px -1px 3px 0px rgba(16, 24, 40, 0.1); + --shadow-md: 0px 2px 4px -2px rgba(16, 24, 40, 0.06), 0px 4px 8px -2px rgba(16, 24, 40, 0.1); --shadow-lg: 0px 4px 6px -2px rgba(16, 24, 40, 0.03), 0px 12px 16px -4px rgba(16, 24, 40, 0.08); --shadow-xl: 0px 8px 8px -4px rgba(16, 24, 40, 0.03), 0px 20px 24px -4px rgba(16, 24, 40, 0.08); --shadow-2xl: 0px 24px 48px -12px rgba(16, 24, 40, 0.18); --shadow-3xl: 0px 32px 64px -12px rgba(16, 24, 40, 0.14); - --shadow-status-indicator-green-shadow: 0px 2px 6px 0px var(--color-components-badge-status-light-success-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer); - --shadow-status-indicator-warning-shadow: 0px 2px 6px 0px var(--color-components-badge-status-light-warning-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer); - --shadow-status-indicator-red-shadow: 0px 2px 6px 0px var(--color-components-badge-status-light-error-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer); - --shadow-status-indicator-blue-shadow: 0px 2px 6px 0px var(--color-components-badge-status-light-normal-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer); - --shadow-status-indicator-gray-shadow: 0px 1px 2px 0px var(--color-components-badge-status-light-disabled-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer); + --shadow-status-indicator-green-shadow: + 0px 2px 6px 0px var(--color-components-badge-status-light-success-halo), + 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer); + --shadow-status-indicator-warning-shadow: + 0px 2px 6px 0px var(--color-components-badge-status-light-warning-halo), + 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer); + --shadow-status-indicator-red-shadow: + 0px 2px 6px 0px var(--color-components-badge-status-light-error-halo), + 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer); + --shadow-status-indicator-blue-shadow: + 0px 2px 6px 0px var(--color-components-badge-status-light-normal-halo), + 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer); + --shadow-status-indicator-gray-shadow: + 0px 1px 2px 0px var(--color-components-badge-status-light-disabled-halo), + 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer); /* font sizes */ --text-2xs: 0.625rem; diff --git a/packages/dify-ui/src/switch/__tests__/index.spec.tsx b/packages/dify-ui/src/switch/__tests__/index.spec.tsx index 1a9e6d78cc3869..0a698047e2c8d2 100644 --- a/packages/dify-ui/src/switch/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/switch/__tests__/index.spec.tsx @@ -64,7 +64,9 @@ describe('Switch', () => { it('should not call onCheckedChange when disabled', async () => { const onCheckedChange = vi.fn() - const screen = await render(<Switch checked={false} disabled onCheckedChange={onCheckedChange} />) + const screen = await render( + <Switch checked={false} disabled onCheckedChange={onCheckedChange} />, + ) const switchElement = screen.getByRole('switch') await expect.element(switchElement).toHaveAttribute('data-disabled', '') @@ -104,7 +106,9 @@ describe('Switch', () => { describe('loading state', () => { it('should render as disabled when loading', async () => { const onCheckedChange = vi.fn() - const screen = await render(<Switch checked={false} loading onCheckedChange={onCheckedChange} />) + const screen = await render( + <Switch checked={false} loading onCheckedChange={onCheckedChange} />, + ) const switchElement = screen.getByRole('switch') await expect.element(switchElement).toHaveAttribute('aria-busy', 'true') diff --git a/packages/dify-ui/src/switch/index.stories.tsx b/packages/dify-ui/src/switch/index.stories.tsx index 7f1b71115bb9c0..40350587f3ea48 100644 --- a/packages/dify-ui/src/switch/index.stories.tsx +++ b/packages/dify-ui/src/switch/index.stories.tsx @@ -2,11 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' import { expect } from 'storybook/test' import { Switch, SwitchSkeleton } from '.' -import { - Field, - FieldDescription, - FieldLabel, -} from '../field' +import { Field, FieldDescription, FieldLabel } from '../field' const meta = { title: 'Base/Form/Switch', @@ -15,7 +11,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Toggle switch primitive with controlled and uncontrolled state support, loading state, and skeleton placeholder.', + component: + 'Toggle switch primitive with controlled and uncontrolled state support, loading state, and skeleton placeholder.', }, }, }, @@ -47,7 +44,9 @@ const meta = { export default meta type Story = StoryObj<typeof meta> -type SwitchDemoProps = Partial<Omit<React.ComponentProps<typeof Switch>, 'checked' | 'defaultChecked' | 'onCheckedChange'>> & { +type SwitchDemoProps = Partial< + Omit<React.ComponentProps<typeof Switch>, 'checked' | 'defaultChecked' | 'onCheckedChange'> +> & { checked?: boolean } @@ -58,11 +57,7 @@ const SwitchDemo = (args: SwitchDemoProps) => { <Field name="autoRetry" className="w-72"> <FieldLabel className="flex items-center justify-between gap-3"> <span>Enable auto retry</span> - <Switch - {...args} - checked={enabled} - onCheckedChange={setEnabled} - /> + <Switch {...args} checked={enabled} onCheckedChange={setEnabled} /> </FieldLabel> <FieldDescription> {enabled ? 'Failures will retry automatically.' : 'Failures require manual retry.'} @@ -72,7 +67,7 @@ const SwitchDemo = (args: SwitchDemoProps) => { } export const Default: Story = { - render: args => <SwitchDemo {...args} />, + render: (args) => <SwitchDemo {...args} />, args: { size: 'md', checked: false, @@ -92,7 +87,7 @@ export const Default: Story = { } export const DefaultOn: Story = { - render: args => <SwitchDemo {...args} />, + render: (args) => <SwitchDemo {...args} />, args: { size: 'md', checked: true, @@ -101,7 +96,7 @@ export const DefaultOn: Story = { } export const DisabledOff: Story = { - render: args => <SwitchDemo {...args} />, + render: (args) => <SwitchDemo {...args} />, args: { size: 'md', checked: false, @@ -110,7 +105,7 @@ export const DisabledOff: Story = { } export const DisabledOn: Story = { - render: args => <SwitchDemo {...args} />, + render: (args) => <SwitchDemo {...args} />, args: { size: 'md', checked: true, @@ -134,25 +129,55 @@ const AllStatesDemo = () => { </tr> </thead> <tbody> - {sizes.map(size => ( + {sizes.map((size) => ( <tr key={size} className="border-t border-gray-100"> <td className="py-3 font-medium text-gray-900">{size}</td> <td className="py-3"> <div className="flex gap-2"> - <Switch size={size} checked={false} onCheckedChange={() => {}} aria-label={`${size} unchecked switch`} /> - <Switch size={size} checked={true} onCheckedChange={() => {}} aria-label={`${size} checked switch`} /> + <Switch + size={size} + checked={false} + onCheckedChange={() => {}} + aria-label={`${size} unchecked switch`} + /> + <Switch + size={size} + checked={true} + onCheckedChange={() => {}} + aria-label={`${size} checked switch`} + /> </div> </td> <td className="py-3"> <div className="flex gap-2"> - <Switch size={size} checked={false} disabled aria-label={`${size} disabled unchecked switch`} /> - <Switch size={size} checked={true} disabled aria-label={`${size} disabled checked switch`} /> + <Switch + size={size} + checked={false} + disabled + aria-label={`${size} disabled unchecked switch`} + /> + <Switch + size={size} + checked={true} + disabled + aria-label={`${size} disabled checked switch`} + /> </div> </td> <td className="py-3"> <div className="flex gap-2"> - <Switch size={size} checked={false} loading aria-label={`${size} loading unchecked switch`} /> - <Switch size={size} checked={true} loading aria-label={`${size} loading checked switch`} /> + <Switch + size={size} + checked={false} + loading + aria-label={`${size} loading unchecked switch`} + /> + <Switch + size={size} + checked={true} + loading + aria-label={`${size} loading checked switch`} + /> </div> </td> <td className="py-3"> @@ -189,25 +214,41 @@ const SizeComparisonDemo = () => { <div className="flex flex-col items-center space-y-4"> <Field name="extraSmallSwitch"> <FieldLabel className="flex items-center gap-3"> - <Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} /> + <Switch + size="xs" + checked={states.xs} + onCheckedChange={(v) => setStates({ ...states, xs: v })} + /> Extra Small (xs) - 14x10 </FieldLabel> </Field> <Field name="smallSwitch"> <FieldLabel className="flex items-center gap-3"> - <Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} /> + <Switch + size="sm" + checked={states.sm} + onCheckedChange={(v) => setStates({ ...states, sm: v })} + /> Small (sm) - 20x12 </FieldLabel> </Field> <Field name="regularSwitch"> <FieldLabel className="flex items-center gap-3"> - <Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} /> + <Switch + size="md" + checked={states.md} + onCheckedChange={(v) => setStates({ ...states, md: v })} + /> Regular (md) - 28x16 </FieldLabel> </Field> <Field name="largeSwitch"> <FieldLabel className="flex items-center gap-3"> - <Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} /> + <Switch + size="lg" + checked={states.lg} + onCheckedChange={(v) => setStates({ ...states, lg: v })} + /> Large (lg) - 36x20 </FieldLabel> </Field> @@ -283,7 +324,7 @@ export const Loading: Story = { }, } -const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) function useMockAutoRetrySettingQuery() { const [enabled, setEnabled] = React.useState(false) @@ -305,11 +346,10 @@ function useMockUpdateAutoRetrySettingMutation({ const [isPending, startTransition] = React.useTransition() const mutate = (nextValue: boolean) => { - if (isPending) - return + if (isPending) return startTransition(async () => { - setRequestCount(current => current + 1) + setRequestCount((current) => current + 1) await wait(1200) onSuccess(nextValue) }) @@ -349,11 +389,7 @@ const MutationLoadingDemo = () => { </Field> <span className="text-xs text-text-tertiary" aria-live="polite"> - {statusText} - {' '} - Save attempts: - {' '} - {updateAutoRetrySetting.requestCount} + {statusText} Save attempts: {updateAutoRetrySetting.requestCount} </span> </div> ) @@ -403,7 +439,7 @@ export const Skeleton: Story = { } export const Playground: Story = { - render: args => <SwitchDemo {...args} />, + render: (args) => <SwitchDemo {...args} />, args: { size: 'md', checked: false, diff --git a/packages/dify-ui/src/switch/index.tsx b/packages/dify-ui/src/switch/index.tsx index bc3e54be904f24..11e3c8c454d10b 100644 --- a/packages/dify-ui/src/switch/index.tsx +++ b/packages/dify-ui/src/switch/index.tsx @@ -7,7 +7,8 @@ import { Switch as BaseSwitch } from '@base-ui/react/switch' import { cva } from 'class-variance-authority' import { cn } from '../cn' -const switchRootStateClassName = 'bg-components-toggle-bg-unchecked hover:bg-components-toggle-bg-unchecked-hover data-checked:bg-components-toggle-bg data-checked:hover:bg-components-toggle-bg-hover data-disabled:cursor-not-allowed data-disabled:bg-components-toggle-bg-unchecked-disabled data-disabled:hover:bg-components-toggle-bg-unchecked-disabled data-disabled:data-checked:bg-components-toggle-bg-disabled data-disabled:data-checked:hover:bg-components-toggle-bg-disabled' +const switchRootStateClassName = + 'bg-components-toggle-bg-unchecked hover:bg-components-toggle-bg-unchecked-hover data-checked:bg-components-toggle-bg data-checked:hover:bg-components-toggle-bg-hover data-disabled:cursor-not-allowed data-disabled:bg-components-toggle-bg-unchecked-disabled data-disabled:hover:bg-components-toggle-bg-unchecked-disabled data-disabled:data-checked:bg-components-toggle-bg-disabled data-disabled:data-checked:hover:bg-components-toggle-bg-disabled' const switchRootVariants = cva( `group relative inline-flex shrink-0 cursor-pointer touch-manipulation items-center outline-hidden transition-colors duration-200 ease-in-out focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none ${switchRootStateClassName}`, @@ -45,17 +46,14 @@ const switchThumbVariants = cva( export type SwitchSize = NonNullable<VariantProps<typeof switchRootVariants>['size']> -const switchSpinnerVariants = cva( - 'absolute top-1/2 -translate-x-1/2 -translate-y-1/2', - { - variants: { - size: { - md: 'left-[calc(50%+6px)] size-2 group-data-checked:left-[calc(50%-6px)]', - lg: 'left-[calc(50%+8px)] size-2.5 group-data-checked:left-[calc(50%-8px)]', - }, +const switchSpinnerVariants = cva('absolute top-1/2 -translate-x-1/2 -translate-y-1/2', { + variants: { + size: { + md: 'left-[calc(50%+6px)] size-2 group-data-checked:left-[calc(50%-6px)]', + lg: 'left-[calc(50%+8px)] size-2.5 group-data-checked:left-[calc(50%-8px)]', }, }, -) +}) type ControlledSwitchProps = { checked: boolean @@ -69,15 +67,16 @@ type UncontrolledSwitchProps = { type SwitchControlProps = ControlledSwitchProps | UncontrolledSwitchProps -export type SwitchProps - = Omit<BaseSwitchNS.Root.Props, 'checked' | 'defaultChecked' | 'className' | 'size' | 'onCheckedChange'> - & VariantProps<typeof switchRootVariants> - & SwitchControlProps - & { - onCheckedChange?: (checked: boolean) => void - loading?: boolean - className?: string - } +export type SwitchProps = Omit< + BaseSwitchNS.Root.Props, + 'checked' | 'defaultChecked' | 'className' | 'size' | 'onCheckedChange' +> & + VariantProps<typeof switchRootVariants> & + SwitchControlProps & { + onCheckedChange?: (checked: boolean) => void + loading?: boolean + className?: string + } export function Switch({ checked, @@ -96,56 +95,36 @@ export function Switch({ disabled={isDisabled} aria-busy={loading || undefined} className={cn(switchRootVariants({ size }), className)} - onCheckedChange={value => onCheckedChange?.(value)} + onCheckedChange={(value) => onCheckedChange?.(value)} {...props} > - <BaseSwitch.Thumb - className={switchThumbVariants({ size })} - /> - {loading && (size === 'md' || size === 'lg') - ? ( - <span - className={switchSpinnerVariants({ size })} - aria-hidden="true" - > - <i className="i-ri-loader-2-line size-full animate-spin text-text-tertiary motion-reduce:animate-none" /> - </span> - ) - : null} + <BaseSwitch.Thumb className={switchThumbVariants({ size })} /> + {loading && (size === 'md' || size === 'lg') ? ( + <span className={switchSpinnerVariants({ size })} aria-hidden="true"> + <i className="i-ri-loader-2-line size-full animate-spin text-text-tertiary motion-reduce:animate-none" /> + </span> + ) : null} </BaseSwitch.Root> ) } -const switchSkeletonVariants = cva( - 'bg-text-quaternary opacity-20', - { - variants: { - size: { - xs: 'h-2.5 w-3.5 rounded-xs', - sm: 'h-3 w-5 rounded-[3.5px]', - md: 'h-4 w-7 rounded-[5px]', - lg: 'h-5 w-9 rounded-md', - }, - }, - defaultVariants: { - size: 'md', +const switchSkeletonVariants = cva('bg-text-quaternary opacity-20', { + variants: { + size: { + xs: 'h-2.5 w-3.5 rounded-xs', + sm: 'h-3 w-5 rounded-[3.5px]', + md: 'h-4 w-7 rounded-[5px]', + lg: 'h-5 w-9 rounded-md', }, }, -) + defaultVariants: { + size: 'md', + }, +}) -export type SwitchSkeletonProps - = React.ComponentProps<'div'> - & VariantProps<typeof switchSkeletonVariants> +export type SwitchSkeletonProps = React.ComponentProps<'div'> & + VariantProps<typeof switchSkeletonVariants> -export function SwitchSkeleton({ - size = 'md', - className, - ...props -}: SwitchSkeletonProps) { - return ( - <div - className={cn(switchSkeletonVariants({ size }), className)} - {...props} - /> - ) +export function SwitchSkeleton({ size = 'md', className, ...props }: SwitchSkeletonProps) { + return <div className={cn(switchSkeletonVariants({ size }), className)} {...props} /> } diff --git a/packages/dify-ui/src/tabs/__tests__/index.spec.tsx b/packages/dify-ui/src/tabs/__tests__/index.spec.tsx index 521e17b7aeef45..f6c8d9a95c59dc 100644 --- a/packages/dify-ui/src/tabs/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/tabs/__tests__/index.spec.tsx @@ -1,10 +1,5 @@ import { render } from 'vitest-browser-react' -import { - Tabs, - TabsList, - TabsPanel, - TabsTab, -} from '../index' +import { Tabs, TabsList, TabsPanel, TabsTab } from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement @@ -22,8 +17,12 @@ describe('Tabs wrappers', () => { ) await expect.element(screen.getByRole('tablist')).toBeInTheDocument() - await expect.element(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute('aria-selected', 'true') - await expect.element(screen.getByRole('tab', { name: 'Python' })).toHaveAttribute('aria-selected', 'false') + await expect + .element(screen.getByRole('tab', { name: 'JavaScript' })) + .toHaveAttribute('aria-selected', 'true') + await expect + .element(screen.getByRole('tab', { name: 'Python' })) + .toHaveAttribute('aria-selected', 'false') await expect.element(screen.getByText('JS panel')).toBeInTheDocument() }) @@ -41,16 +40,22 @@ describe('Tabs wrappers', () => { asHTMLElement(screen.getByRole('tab', { name: 'Python' }).element()).click() expect(onValueChange).toHaveBeenCalledWith('py', expect.anything()) - await expect.element(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute('aria-selected', 'true') + await expect + .element(screen.getByRole('tab', { name: 'JavaScript' })) + .toHaveAttribute('aria-selected', 'true') }) it('forwards className to composable parts', async () => { const screen = await render( <Tabs defaultValue="first"> <TabsList className="custom-list"> - <TabsTab value="first" className="custom-tab">First</TabsTab> + <TabsTab value="first" className="custom-tab"> + First + </TabsTab> </TabsList> - <TabsPanel value="first" className="custom-panel">Panel</TabsPanel> + <TabsPanel value="first" className="custom-panel"> + Panel + </TabsPanel> </Tabs>, ) diff --git a/packages/dify-ui/src/tabs/index.stories.tsx b/packages/dify-ui/src/tabs/index.stories.tsx index 11087a7411c1db..2e4e69b9ed170b 100644 --- a/packages/dify-ui/src/tabs/index.stories.tsx +++ b/packages/dify-ui/src/tabs/index.stories.tsx @@ -1,10 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { - Tabs, - TabsList, - TabsPanel, - TabsTab, -} from '.' +import { Tabs, TabsList, TabsPanel, TabsTab } from '.' const meta = { title: 'Base/UI/Tabs', @@ -13,7 +8,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Composable tabs built on Base UI. Use this when a tab controls a corresponding tab panel.', + component: + 'Composable tabs built on Base UI. Use this when a tab controls a corresponding tab panel.', }, }, }, @@ -27,12 +23,8 @@ export const Basic: Story = { render: () => ( <Tabs defaultValue="overview" className="w-96"> <TabsList> - <TabsTab value="overview"> - Overview - </TabsTab> - <TabsTab value="activity"> - Activity - </TabsTab> + <TabsTab value="overview">Overview</TabsTab> + <TabsTab value="activity">Activity</TabsTab> </TabsList> <TabsPanel value="overview" className="py-3 system-sm-regular text-text-secondary"> Overview panel diff --git a/packages/dify-ui/src/tabs/index.tsx b/packages/dify-ui/src/tabs/index.tsx index 86dcbc23c6d7ac..cc275adf51d151 100644 --- a/packages/dify-ui/src/tabs/index.tsx +++ b/packages/dify-ui/src/tabs/index.tsx @@ -12,29 +12,21 @@ export type TabsListProps = Omit<BaseTabsNS.List.Props, 'className'> & { className?: string } -export function TabsList({ - className, - ...props -}: TabsListProps) { - return ( - <BaseTabs.List - className={cn('flex gap-4', className)} - {...props} - /> - ) +export function TabsList({ className, ...props }: TabsListProps) { + return <BaseTabs.List className={cn('flex gap-4', className)} {...props} /> } export type TabsTabProps = Omit<BaseTabsNS.Tab.Props, 'className'> & { className?: string } -export function TabsTab({ - className, - ...props -}: TabsTabProps) { +export function TabsTab({ className, ...props }: TabsTabProps) { return ( <BaseTabs.Tab - className={cn('relative flex cursor-pointer touch-manipulation items-center border-b-2 border-transparent pt-2.5 pb-2 system-md-semibold text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-active:border-components-tab-active data-active:text-text-primary data-disabled:cursor-not-allowed data-disabled:text-text-tertiary data-disabled:opacity-30 data-active:data-disabled:text-text-primary', className)} + className={cn( + 'relative flex cursor-pointer touch-manipulation items-center border-b-2 border-transparent pt-2.5 pb-2 system-md-semibold text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-active:border-components-tab-active data-active:text-text-primary data-disabled:cursor-not-allowed data-disabled:text-text-tertiary data-disabled:opacity-30 data-active:data-disabled:text-text-primary', + className, + )} {...props} /> ) @@ -44,16 +36,8 @@ export type TabsPanelProps = Omit<BaseTabsNS.Panel.Props, 'className'> & { className?: string } -export function TabsPanel({ - className, - ...props -}: TabsPanelProps) { - return ( - <BaseTabs.Panel - className={className} - {...props} - /> - ) +export function TabsPanel({ className, ...props }: TabsPanelProps) { + return <BaseTabs.Panel className={className} {...props} /> } export const TabsIndicator = BaseTabs.Indicator diff --git a/packages/dify-ui/src/textarea/__tests__/index.spec.tsx b/packages/dify-ui/src/textarea/__tests__/index.spec.tsx index b6fbd74af30682..cfa3d236366120 100644 --- a/packages/dify-ui/src/textarea/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/textarea/__tests__/index.spec.tsx @@ -1,18 +1,16 @@ import type * as React from 'react' import { render } from 'vitest-browser-react' -import { - Field, - FieldDescription, - FieldError, - FieldLabel, -} from '../../field' +import { Field, FieldDescription, FieldError, FieldLabel } from '../../field' import { Form } from '../../form' import { Textarea } from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement const setTextareaValue = (element: HTMLElement | SVGElement, value: string) => { const textarea = asHTMLElement(element) as HTMLTextAreaElement - const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + 'value', + )?.set valueSetter?.call(textarea, value) textarea.dispatchEvent(new Event('input', { bubbles: true })) } @@ -88,7 +86,9 @@ describe('Textarea', () => { await vi.waitFor(async () => { await expect.element(screen.getByText('Summary is required.')).toBeInTheDocument() - await expect.element(screen.getByRole('textbox', { name: 'Summary' })).toHaveAttribute('aria-invalid', 'true') + await expect + .element(screen.getByRole('textbox', { name: 'Summary' })) + .toHaveAttribute('aria-invalid', 'true') }) expect(onFormSubmit).not.toHaveBeenCalled() @@ -96,7 +96,12 @@ describe('Textarea', () => { <Form aria-label="dataset form" onFormSubmit={onFormSubmit}> <Field name="summary"> <FieldLabel>Summary</FieldLabel> - <Textarea key="valid-summary" required minLength={10} defaultValue="Long enough summary" /> + <Textarea + key="valid-summary" + required + minLength={10} + defaultValue="Long enough summary" + /> <FieldError match="valueMissing">Summary is required.</FieldError> <FieldError match="tooShort">Summary is too short.</FieldError> </Field> @@ -151,9 +156,9 @@ describe('Textarea', () => { ) const profileSummary = screen.getByRole('textbox', { name: 'Profile summary' }) - expect( - asHTMLElement(screen.getByText('Profile summary').element()).getAttribute('for'), - ).toBe('profile-summary') + expect(asHTMLElement(screen.getByText('Profile summary').element()).getAttribute('for')).toBe( + 'profile-summary', + ) await expect.element(profileSummary).toHaveAttribute('id', 'profile-summary') await expect.element(profileSummary).toHaveAttribute('name', 'profileSummary') await expect.element(profileSummary).toHaveAttribute('rows', '6') diff --git a/packages/dify-ui/src/textarea/index.stories.tsx b/packages/dify-ui/src/textarea/index.stories.tsx index 74ec66fa5437b9..98e21a18af67b5 100644 --- a/packages/dify-ui/src/textarea/index.stories.tsx +++ b/packages/dify-ui/src/textarea/index.stories.tsx @@ -1,12 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' import { Button } from '../button' -import { - Field, - FieldDescription, - FieldError, - FieldLabel, -} from '../field' +import { Field, FieldDescription, FieldError, FieldLabel } from '../field' import { Form } from '../form' import { Textarea } from './index' @@ -17,7 +12,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Multiline text control built on Base UI Field.Control. Use it with Field for labelled, described, and validated form fields.', + component: + 'Multiline text control built on Base UI Field.Control. Use it with Field for labelled, described, and validated form fields.', }, }, }, @@ -31,7 +27,10 @@ type Story = StoryObj<typeof meta> export const Basic: Story = { render: () => ( <div className="w-80"> - <label htmlFor="workspace-description" className="mb-1 block w-fit py-1 system-sm-medium text-text-secondary"> + <label + htmlFor="workspace-description" + className="mb-1 block w-fit py-1 system-sm-medium text-text-secondary" + > Workspace description </label> <Textarea @@ -48,15 +47,32 @@ export const Sizes: Story = { <div className="grid w-80 gap-3"> <label className="grid gap-1 system-sm-medium text-text-secondary" htmlFor="small-textarea"> Small - <Textarea id="small-textarea" size="small" name="smallTextarea" placeholder="Short note..." rows={3} /> + <Textarea + id="small-textarea" + size="small" + name="smallTextarea" + placeholder="Short note..." + rows={3} + /> </label> <label className="grid gap-1 system-sm-medium text-text-secondary" htmlFor="medium-textarea"> Medium - <Textarea id="medium-textarea" name="mediumTextarea" placeholder="Add context..." rows={3} /> + <Textarea + id="medium-textarea" + name="mediumTextarea" + placeholder="Add context..." + rows={3} + /> </label> <label className="grid gap-1 system-sm-medium text-text-secondary" htmlFor="large-textarea"> Large - <Textarea id="large-textarea" size="large" name="largeTextarea" placeholder="Write a longer instruction..." rows={3} /> + <Textarea + id="large-textarea" + size="large" + name="largeTextarea" + placeholder="Write a longer instruction..." + rows={3} + /> </label> </div> ), @@ -84,7 +100,11 @@ export const States: Story = { </Field> <Field name="readonlyState"> <FieldLabel>Read-only</FieldLabel> - <Textarea readOnly defaultValue="Generated from the published workflow configuration." rows={3} /> + <Textarea + readOnly + defaultValue="Generated from the published workflow configuration." + rows={3} + /> </Field> </div> ), @@ -116,13 +136,13 @@ const FormDemo = () => { <FieldError match="tooShort">Use at least 20 characters.</FieldError> </Field> <div className="flex justify-end"> - <Button type="submit" variant="primary">Save Settings</Button> + <Button type="submit" variant="primary"> + Save Settings + </Button> </div> {savedDescription && ( <div className="rounded-lg bg-background-section px-3 py-2 system-xs-regular text-text-secondary"> - Saved: - {' '} - {savedDescription} + Saved: {savedDescription} </div> )} </Form> @@ -134,14 +154,16 @@ export const WithField: Story = { } const ControlledDemo = () => { - const [value, setValue] = React.useState('Summarize customer feedback into actionable product themes.') + const [value, setValue] = React.useState( + 'Summarize customer feedback into actionable product themes.', + ) return ( <Field name="prompt"> <FieldLabel>Prompt</FieldLabel> <Textarea value={value} - onValueChange={nextValue => setValue(nextValue)} + onValueChange={(nextValue) => setValue(nextValue)} rows={4} className="resize-y" /> @@ -160,7 +182,9 @@ export const Controlled: Story = { const CharacterCounterDemo = () => { const maxLength = 120 - const [value, setValue] = React.useState('Summarize customer feedback into actionable product themes.') + const [value, setValue] = React.useState( + 'Summarize customer feedback into actionable product themes.', + ) return ( <Field name="limitedPrompt"> @@ -168,18 +192,18 @@ const CharacterCounterDemo = () => { <div className="relative"> <Textarea value={value} - onValueChange={nextValue => setValue(nextValue)} + onValueChange={(nextValue) => setValue(nextValue)} maxLength={maxLength} rows={4} className="resize-y pb-8" /> <div className="pointer-events-none absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 system-xs-medium text-text-quaternary"> - <span>{value.length}</span> - / - <span className="text-text-tertiary">{maxLength}</span> + <span>{value.length}</span>/<span className="text-text-tertiary">{maxLength}</span> </div> </div> - <FieldDescription>Character counters are composed at the usage site when the workflow needs one.</FieldDescription> + <FieldDescription> + Character counters are composed at the usage site when the workflow needs one. + </FieldDescription> </Field> ) } diff --git a/packages/dify-ui/src/textarea/index.tsx b/packages/dify-ui/src/textarea/index.tsx index 7075a3bd5114d2..3c88495392e6dc 100644 --- a/packages/dify-ui/src/textarea/index.tsx +++ b/packages/dify-ui/src/textarea/index.tsx @@ -55,7 +55,15 @@ type TextareaNativeProps = React.ComponentPropsWithRef<'textarea'> type TextareaOnlyProps = Pick<TextareaNativeProps, 'cols' | 'rows' | 'wrap'> type TextareaElementProps = Omit< TextareaNativeProps, - 'children' | 'className' | 'cols' | 'defaultValue' | 'onChange' | 'rows' | 'size' | 'value' | 'wrap' + | 'children' + | 'className' + | 'cols' + | 'defaultValue' + | 'onChange' + | 'rows' + | 'size' + | 'value' + | 'wrap' > type TextareaControlProps = ControlledTextareaProps | UncontrolledTextareaProps @@ -65,15 +73,13 @@ type FieldControlTextareaProps = Omit< 'className' | 'defaultValue' | 'onValueChange' | 'render' | 'value' > -export type TextareaProps - = TextareaElementProps - & TextareaOnlyProps - & TextareaControlProps - & TextareaVariantProps - & { - children?: never - className?: string - } +export type TextareaProps = TextareaElementProps & + TextareaOnlyProps & + TextareaControlProps & + TextareaVariantProps & { + children?: never + className?: string + } export function Textarea({ className, diff --git a/packages/dify-ui/src/themes/dark.css b/packages/dify-ui/src/themes/dark.css index fb969b5fa01cba..e2d0e341e30ee5 100644 --- a/packages/dify-ui/src/themes/dark.css +++ b/packages/dify-ui/src/themes/dark.css @@ -1,5 +1,5 @@ /* Attention: Generate by code. Don't update by hand!!! */ -html[data-theme="dark"] { +html[data-theme='dark'] { --color-text-primary: #fbfbfc; --color-text-secondary: #d9d9de; --color-text-tertiary: rgb(200 206 218 / 0.6); @@ -818,5 +818,4 @@ html[data-theme="dark"] { --color-brand-color-opacity-800: #bac6f5; --color-brand-color-opacity-900: #e2e7fb; --color-brand-color-opacity-1000: #f3f5fd; - } diff --git a/packages/dify-ui/src/themes/light.css b/packages/dify-ui/src/themes/light.css index 059e1e207e1b70..e99de9cff71df4 100644 --- a/packages/dify-ui/src/themes/light.css +++ b/packages/dify-ui/src/themes/light.css @@ -1,5 +1,5 @@ /* Attention: Generate by code. Don't update by hand!!! */ -html[data-theme="light"] { +html[data-theme='light'] { --color-text-primary: #101828; --color-text-secondary: #354052; --color-text-tertiary: #676f83; @@ -818,5 +818,4 @@ html[data-theme="light"] { --color-brand-color-opacity-800: #051969; --color-brand-color-opacity-900: #031042; --color-brand-color-opacity-1000: #020821; - } diff --git a/packages/dify-ui/src/themes/theme.css b/packages/dify-ui/src/themes/theme.css index c6ed246f9b1201..7ff75229c6a65c 100644 --- a/packages/dify-ui/src/themes/theme.css +++ b/packages/dify-ui/src/themes/theme.css @@ -56,18 +56,32 @@ --color-background-interaction-from-bg-2: var(--color-background-interaction-from-bg-2); --color-background-gradient-bg-fill-chat-bg-1: var(--color-background-gradient-bg-fill-chat-bg-1); --color-background-gradient-bg-fill-chat-bg-2: var(--color-background-gradient-bg-fill-chat-bg-2); - --color-background-gradient-bg-fill-chat-bubble-bg-1: var(--color-background-gradient-bg-fill-chat-bubble-bg-1); - --color-background-gradient-bg-fill-chat-bubble-bg-2: var(--color-background-gradient-bg-fill-chat-bubble-bg-2); - --color-background-gradient-bg-fill-debug-bg-1: var(--color-background-gradient-bg-fill-debug-bg-1); - --color-background-gradient-bg-fill-debug-bg-2: var(--color-background-gradient-bg-fill-debug-bg-2); + --color-background-gradient-bg-fill-chat-bubble-bg-1: var( + --color-background-gradient-bg-fill-chat-bubble-bg-1 + ); + --color-background-gradient-bg-fill-chat-bubble-bg-2: var( + --color-background-gradient-bg-fill-chat-bubble-bg-2 + ); + --color-background-gradient-bg-fill-debug-bg-1: var( + --color-background-gradient-bg-fill-debug-bg-1 + ); + --color-background-gradient-bg-fill-debug-bg-2: var( + --color-background-gradient-bg-fill-debug-bg-2 + ); --color-background-gradient-mask-gray: var(--color-background-gradient-mask-gray); --color-background-gradient-mask-transparent: var(--color-background-gradient-mask-transparent); - --color-background-gradient-mask-transparent-dark: var(--color-background-gradient-mask-transparent-dark); + --color-background-gradient-mask-transparent-dark: var( + --color-background-gradient-mask-transparent-dark + ); --color-background-gradient-mask-side-panel-1: var(--color-background-gradient-mask-side-panel-1); --color-background-gradient-mask-side-panel-2: var(--color-background-gradient-mask-side-panel-2); - --color-background-gradient-mask-input-clear-1: var(--color-background-gradient-mask-input-clear-1); - --color-background-gradient-mask-input-clear-2: var(--color-background-gradient-mask-input-clear-2); + --color-background-gradient-mask-input-clear-1: var( + --color-background-gradient-mask-input-clear-1 + ); + --color-background-gradient-mask-input-clear-2: var( + --color-background-gradient-mask-input-clear-2 + ); --color-state-base-hover-subtle: var(--color-state-base-hover-subtle); --color-state-base-hover: var(--color-state-base-hover); @@ -118,7 +132,9 @@ --color-workflow-link-line-failure-active: var(--color-workflow-link-line-failure-active); --color-workflow-link-line-failure-handle: var(--color-workflow-link-line-failure-handle); --color-workflow-link-line-failure-button-bg: var(--color-workflow-link-line-failure-button-bg); - --color-workflow-link-line-failure-button-hover: var(--color-workflow-link-line-failure-button-hover); + --color-workflow-link-line-failure-button-hover: var( + --color-workflow-link-line-failure-button-hover + ); --color-workflow-link-line-success-active: var(--color-workflow-link-line-success-active); --color-workflow-link-line-success-handle: var(--color-workflow-link-line-success-handle); @@ -135,34 +151,52 @@ --color-workflow-display-outline: var(--color-workflow-display-outline); --color-workflow-display-vignette-dark: var(--color-workflow-display-vignette-dark); --color-workflow-display-success-bg: var(--color-workflow-display-success-bg); - --color-workflow-display-success-bg-line-pattern: var(--color-workflow-display-success-bg-line-pattern); + --color-workflow-display-success-bg-line-pattern: var( + --color-workflow-display-success-bg-line-pattern + ); --color-workflow-display-success-border-1: var(--color-workflow-display-success-border-1); --color-workflow-display-success-border-2: var(--color-workflow-display-success-border-2); - --color-workflow-display-success-vignette-color: var(--color-workflow-display-success-vignette-color); + --color-workflow-display-success-vignette-color: var( + --color-workflow-display-success-vignette-color + ); --color-workflow-display-error-bg: var(--color-workflow-display-error-bg); - --color-workflow-display-error-bg-line-pattern: var(--color-workflow-display-error-bg-line-pattern); + --color-workflow-display-error-bg-line-pattern: var( + --color-workflow-display-error-bg-line-pattern + ); --color-workflow-display-error-border-1: var(--color-workflow-display-error-border-1); --color-workflow-display-error-border-2: var(--color-workflow-display-error-border-2); --color-workflow-display-error-vignette-color: var(--color-workflow-display-error-vignette-color); --color-workflow-display-warning-bg: var(--color-workflow-display-warning-bg); - --color-workflow-display-warning-bg-line-pattern: var(--color-workflow-display-warning-bg-line-pattern); + --color-workflow-display-warning-bg-line-pattern: var( + --color-workflow-display-warning-bg-line-pattern + ); --color-workflow-display-warning-border-1: var(--color-workflow-display-warning-border-1); --color-workflow-display-warning-border-2: var(--color-workflow-display-warning-border-2); - --color-workflow-display-warning-vignette-color: var(--color-workflow-display-warning-vignette-color); + --color-workflow-display-warning-vignette-color: var( + --color-workflow-display-warning-vignette-color + ); --color-workflow-display-normal-bg: var(--color-workflow-display-normal-bg); - --color-workflow-display-normal-bg-line-pattern: var(--color-workflow-display-normal-bg-line-pattern); + --color-workflow-display-normal-bg-line-pattern: var( + --color-workflow-display-normal-bg-line-pattern + ); --color-workflow-display-normal-border-1: var(--color-workflow-display-normal-border-1); --color-workflow-display-normal-border-2: var(--color-workflow-display-normal-border-2); - --color-workflow-display-normal-vignette-color: var(--color-workflow-display-normal-vignette-color); + --color-workflow-display-normal-vignette-color: var( + --color-workflow-display-normal-vignette-color + ); --color-workflow-display-disabled-bg: var(--color-workflow-display-disabled-bg); - --color-workflow-display-disabled-bg-line-pattern: var(--color-workflow-display-disabled-bg-line-pattern); + --color-workflow-display-disabled-bg-line-pattern: var( + --color-workflow-display-disabled-bg-line-pattern + ); --color-workflow-display-disabled-border-1: var(--color-workflow-display-disabled-border-1); --color-workflow-display-disabled-border-2: var(--color-workflow-display-disabled-border-2); - --color-workflow-display-disabled-vignette-color: var(--color-workflow-display-disabled-vignette-color); + --color-workflow-display-disabled-vignette-color: var( + --color-workflow-display-disabled-vignette-color + ); --color-workflow-display-disabled-outline: var(--color-workflow-display-disabled-outline); --color-workflow-canvas-workflow-dot-color: var(--color-workflow-canvas-workflow-dot-color); @@ -211,7 +245,9 @@ --color-components-icon-bg-midnight-soft: var(--color-components-icon-bg-midnight-soft); --color-components-avatar-default-avatar-bg: var(--color-components-avatar-default-avatar-bg); - --color-components-avatar-mask-darkmode-dimmed: var(--color-components-avatar-mask-darkmode-dimmed); + --color-components-avatar-mask-darkmode-dimmed: var( + --color-components-avatar-mask-darkmode-dimmed + ); --color-components-avatar-shape-fill-stop-0: var(--color-components-avatar-shape-fill-stop-0); --color-components-avatar-shape-fill-stop-100: var(--color-components-avatar-shape-fill-stop-100); @@ -220,67 +256,163 @@ --color-components-chat-input-audio-bg: var(--color-components-chat-input-audio-bg); --color-components-chat-input-audio-bg-alt: var(--color-components-chat-input-audio-bg-alt); - --color-components-chat-input-audio-wave-default: var(--color-components-chat-input-audio-wave-default); - --color-components-chat-input-audio-wave-active: var(--color-components-chat-input-audio-wave-active); + --color-components-chat-input-audio-wave-default: var( + --color-components-chat-input-audio-wave-default + ); + --color-components-chat-input-audio-wave-active: var( + --color-components-chat-input-audio-wave-active + ); --color-components-chat-input-bg-mask-1: var(--color-components-chat-input-bg-mask-1); --color-components-chat-input-bg-mask-2: var(--color-components-chat-input-bg-mask-2); --color-components-chat-input-border: var(--color-components-chat-input-border); --color-components-label-gray: var(--color-components-label-gray); - --color-components-premium-badge-highlight-stop-0: var(--color-components-premium-badge-highlight-stop-0); - --color-components-premium-badge-highlight-stop-100: var(--color-components-premium-badge-highlight-stop-100); - --color-components-premium-badge-orange-bg-stop-0: var(--color-components-premium-badge-orange-bg-stop-0); - --color-components-premium-badge-orange-bg-stop-100: var(--color-components-premium-badge-orange-bg-stop-100); - --color-components-premium-badge-orange-stroke-stop-0: var(--color-components-premium-badge-orange-stroke-stop-0); - --color-components-premium-badge-orange-stroke-stop-100: var(--color-components-premium-badge-orange-stroke-stop-100); - --color-components-premium-badge-orange-text-stop-0: var(--color-components-premium-badge-orange-text-stop-0); - --color-components-premium-badge-orange-text-stop-100: var(--color-components-premium-badge-orange-text-stop-100); + --color-components-premium-badge-highlight-stop-0: var( + --color-components-premium-badge-highlight-stop-0 + ); + --color-components-premium-badge-highlight-stop-100: var( + --color-components-premium-badge-highlight-stop-100 + ); + --color-components-premium-badge-orange-bg-stop-0: var( + --color-components-premium-badge-orange-bg-stop-0 + ); + --color-components-premium-badge-orange-bg-stop-100: var( + --color-components-premium-badge-orange-bg-stop-100 + ); + --color-components-premium-badge-orange-stroke-stop-0: var( + --color-components-premium-badge-orange-stroke-stop-0 + ); + --color-components-premium-badge-orange-stroke-stop-100: var( + --color-components-premium-badge-orange-stroke-stop-100 + ); + --color-components-premium-badge-orange-text-stop-0: var( + --color-components-premium-badge-orange-text-stop-0 + ); + --color-components-premium-badge-orange-text-stop-100: var( + --color-components-premium-badge-orange-text-stop-100 + ); --color-components-premium-badge-orange-glow: var(--color-components-premium-badge-orange-glow); - --color-components-premium-badge-orange-glow-hover: var(--color-components-premium-badge-orange-glow-hover); - --color-components-premium-badge-orange-bg-stop-0-hover: var(--color-components-premium-badge-orange-bg-stop-0-hover); - --color-components-premium-badge-orange-bg-stop-100-hover: var(--color-components-premium-badge-orange-bg-stop-100-hover); - --color-components-premium-badge-orange-stroke-stop-0-hover: var(--color-components-premium-badge-orange-stroke-stop-0-hover); - --color-components-premium-badge-orange-stroke-stop-100-hover: var(--color-components-premium-badge-orange-stroke-stop-100-hover); - - --color-components-premium-badge-blue-bg-stop-0: var(--color-components-premium-badge-blue-bg-stop-0); - --color-components-premium-badge-blue-bg-stop-100: var(--color-components-premium-badge-blue-bg-stop-100); - --color-components-premium-badge-blue-stroke-stop-0: var(--color-components-premium-badge-blue-stroke-stop-0); - --color-components-premium-badge-blue-stroke-stop-100: var(--color-components-premium-badge-blue-stroke-stop-100); - --color-components-premium-badge-blue-text-stop-0: var(--color-components-premium-badge-blue-text-stop-0); - --color-components-premium-badge-blue-text-stop-100: var(--color-components-premium-badge-blue-text-stop-100); + --color-components-premium-badge-orange-glow-hover: var( + --color-components-premium-badge-orange-glow-hover + ); + --color-components-premium-badge-orange-bg-stop-0-hover: var( + --color-components-premium-badge-orange-bg-stop-0-hover + ); + --color-components-premium-badge-orange-bg-stop-100-hover: var( + --color-components-premium-badge-orange-bg-stop-100-hover + ); + --color-components-premium-badge-orange-stroke-stop-0-hover: var( + --color-components-premium-badge-orange-stroke-stop-0-hover + ); + --color-components-premium-badge-orange-stroke-stop-100-hover: var( + --color-components-premium-badge-orange-stroke-stop-100-hover + ); + + --color-components-premium-badge-blue-bg-stop-0: var( + --color-components-premium-badge-blue-bg-stop-0 + ); + --color-components-premium-badge-blue-bg-stop-100: var( + --color-components-premium-badge-blue-bg-stop-100 + ); + --color-components-premium-badge-blue-stroke-stop-0: var( + --color-components-premium-badge-blue-stroke-stop-0 + ); + --color-components-premium-badge-blue-stroke-stop-100: var( + --color-components-premium-badge-blue-stroke-stop-100 + ); + --color-components-premium-badge-blue-text-stop-0: var( + --color-components-premium-badge-blue-text-stop-0 + ); + --color-components-premium-badge-blue-text-stop-100: var( + --color-components-premium-badge-blue-text-stop-100 + ); --color-components-premium-badge-blue-glow: var(--color-components-premium-badge-blue-glow); - --color-components-premium-badge-blue-glow-hover: var(--color-components-premium-badge-blue-glow-hover); - --color-components-premium-badge-blue-bg-stop-0-hover: var(--color-components-premium-badge-blue-bg-stop-0-hover); - --color-components-premium-badge-blue-bg-stop-100-hover: var(--color-components-premium-badge-blue-bg-stop-100-hover); - --color-components-premium-badge-blue-stroke-stop-0-hover: var(--color-components-premium-badge-blue-stroke-stop-0-hover); - --color-components-premium-badge-blue-stroke-stop-100-hover: var(--color-components-premium-badge-blue-stroke-stop-100-hover); - - --color-components-premium-badge-indigo-bg-stop-0: var(--color-components-premium-badge-indigo-bg-stop-0); - --color-components-premium-badge-indigo-bg-stop-100: var(--color-components-premium-badge-indigo-bg-stop-100); - --color-components-premium-badge-indigo-stroke-stop-0: var(--color-components-premium-badge-indigo-stroke-stop-0); - --color-components-premium-badge-indigo-stroke-stop-100: var(--color-components-premium-badge-indigo-stroke-stop-100); - --color-components-premium-badge-indigo-text-stop-0: var(--color-components-premium-badge-indigo-text-stop-0); - --color-components-premium-badge-indigo-text-stop-100: var(--color-components-premium-badge-indigo-text-stop-100); + --color-components-premium-badge-blue-glow-hover: var( + --color-components-premium-badge-blue-glow-hover + ); + --color-components-premium-badge-blue-bg-stop-0-hover: var( + --color-components-premium-badge-blue-bg-stop-0-hover + ); + --color-components-premium-badge-blue-bg-stop-100-hover: var( + --color-components-premium-badge-blue-bg-stop-100-hover + ); + --color-components-premium-badge-blue-stroke-stop-0-hover: var( + --color-components-premium-badge-blue-stroke-stop-0-hover + ); + --color-components-premium-badge-blue-stroke-stop-100-hover: var( + --color-components-premium-badge-blue-stroke-stop-100-hover + ); + + --color-components-premium-badge-indigo-bg-stop-0: var( + --color-components-premium-badge-indigo-bg-stop-0 + ); + --color-components-premium-badge-indigo-bg-stop-100: var( + --color-components-premium-badge-indigo-bg-stop-100 + ); + --color-components-premium-badge-indigo-stroke-stop-0: var( + --color-components-premium-badge-indigo-stroke-stop-0 + ); + --color-components-premium-badge-indigo-stroke-stop-100: var( + --color-components-premium-badge-indigo-stroke-stop-100 + ); + --color-components-premium-badge-indigo-text-stop-0: var( + --color-components-premium-badge-indigo-text-stop-0 + ); + --color-components-premium-badge-indigo-text-stop-100: var( + --color-components-premium-badge-indigo-text-stop-100 + ); --color-components-premium-badge-indigo-glow: var(--color-components-premium-badge-indigo-glow); - --color-components-premium-badge-indigo-glow-hover: var(--color-components-premium-badge-indigo-glow-hover); - --color-components-premium-badge-indigo-bg-stop-0-hover: var(--color-components-premium-badge-indigo-bg-stop-0-hover); - --color-components-premium-badge-indigo-bg-stop-100-hover: var(--color-components-premium-badge-indigo-bg-stop-100-hover); - --color-components-premium-badge-indigo-stroke-stop-0-hover: var(--color-components-premium-badge-indigo-stroke-stop-0-hover); - --color-components-premium-badge-indigo-stroke-stop-100-hover: var(--color-components-premium-badge-indigo-stroke-stop-100-hover); - - --color-components-premium-badge-grey-bg-stop-0: var(--color-components-premium-badge-grey-bg-stop-0); - --color-components-premium-badge-grey-bg-stop-100: var(--color-components-premium-badge-grey-bg-stop-100); - --color-components-premium-badge-grey-stroke-stop-0: var(--color-components-premium-badge-grey-stroke-stop-0); - --color-components-premium-badge-grey-stroke-stop-100: var(--color-components-premium-badge-grey-stroke-stop-100); - --color-components-premium-badge-grey-text-stop-0: var(--color-components-premium-badge-grey-text-stop-0); - --color-components-premium-badge-grey-text-stop-100: var(--color-components-premium-badge-grey-text-stop-100); + --color-components-premium-badge-indigo-glow-hover: var( + --color-components-premium-badge-indigo-glow-hover + ); + --color-components-premium-badge-indigo-bg-stop-0-hover: var( + --color-components-premium-badge-indigo-bg-stop-0-hover + ); + --color-components-premium-badge-indigo-bg-stop-100-hover: var( + --color-components-premium-badge-indigo-bg-stop-100-hover + ); + --color-components-premium-badge-indigo-stroke-stop-0-hover: var( + --color-components-premium-badge-indigo-stroke-stop-0-hover + ); + --color-components-premium-badge-indigo-stroke-stop-100-hover: var( + --color-components-premium-badge-indigo-stroke-stop-100-hover + ); + + --color-components-premium-badge-grey-bg-stop-0: var( + --color-components-premium-badge-grey-bg-stop-0 + ); + --color-components-premium-badge-grey-bg-stop-100: var( + --color-components-premium-badge-grey-bg-stop-100 + ); + --color-components-premium-badge-grey-stroke-stop-0: var( + --color-components-premium-badge-grey-stroke-stop-0 + ); + --color-components-premium-badge-grey-stroke-stop-100: var( + --color-components-premium-badge-grey-stroke-stop-100 + ); + --color-components-premium-badge-grey-text-stop-0: var( + --color-components-premium-badge-grey-text-stop-0 + ); + --color-components-premium-badge-grey-text-stop-100: var( + --color-components-premium-badge-grey-text-stop-100 + ); --color-components-premium-badge-grey-glow: var(--color-components-premium-badge-grey-glow); - --color-components-premium-badge-grey-glow-hover: var(--color-components-premium-badge-grey-glow-hover); - --color-components-premium-badge-grey-bg-stop-0-hover: var(--color-components-premium-badge-grey-bg-stop-0-hover); - --color-components-premium-badge-grey-bg-stop-100-hover: var(--color-components-premium-badge-grey-bg-stop-100-hover); - --color-components-premium-badge-grey-stroke-stop-0-hover: var(--color-components-premium-badge-grey-stroke-stop-0-hover); - --color-components-premium-badge-grey-stroke-stop-100-hover: var(--color-components-premium-badge-grey-stroke-stop-100-hover); + --color-components-premium-badge-grey-glow-hover: var( + --color-components-premium-badge-grey-glow-hover + ); + --color-components-premium-badge-grey-bg-stop-0-hover: var( + --color-components-premium-badge-grey-bg-stop-0-hover + ); + --color-components-premium-badge-grey-bg-stop-100-hover: var( + --color-components-premium-badge-grey-bg-stop-100-hover + ); + --color-components-premium-badge-grey-stroke-stop-0-hover: var( + --color-components-premium-badge-grey-stroke-stop-0-hover + ); + --color-components-premium-badge-grey-stroke-stop-100-hover: var( + --color-components-premium-badge-grey-stroke-stop-100-hover + ); --color-components-dropzone-bg: var(--color-components-dropzone-bg); --color-components-dropzone-bg-alt: var(--color-components-dropzone-bg-alt); @@ -299,10 +431,18 @@ --color-components-panel-gradient-1: var(--color-components-panel-gradient-1); --color-components-panel-gradient-2: var(--color-components-panel-gradient-2); --color-components-panel-on-panel-item-bg: var(--color-components-panel-on-panel-item-bg); - --color-components-panel-on-panel-item-bg-transparent: var(--color-components-panel-on-panel-item-bg-transparent); - --color-components-panel-on-panel-item-bg-hover: var(--color-components-panel-on-panel-item-bg-hover); - --color-components-panel-on-panel-item-bg-hover-transparent: var(--color-components-panel-on-panel-item-bg-hover-transparent); - --color-components-panel-on-panel-item-bg-destructive-hover-transparent: var(--color-components-panel-on-panel-item-bg-destructive-hover-transparent); + --color-components-panel-on-panel-item-bg-transparent: var( + --color-components-panel-on-panel-item-bg-transparent + ); + --color-components-panel-on-panel-item-bg-hover: var( + --color-components-panel-on-panel-item-bg-hover + ); + --color-components-panel-on-panel-item-bg-hover-transparent: var( + --color-components-panel-on-panel-item-bg-hover-transparent + ); + --color-components-panel-on-panel-item-bg-destructive-hover-transparent: var( + --color-components-panel-on-panel-item-bg-destructive-hover-transparent + ); --color-components-panel-on-panel-item-bg-alt: var(--color-components-panel-on-panel-item-bg-alt); --color-components-marketplace-header-bg: var(--color-components-marketplace-header-bg); @@ -330,8 +470,12 @@ --color-components-input-text-filled-blue: var(--color-components-input-text-filled-blue); --color-components-input-border-hover: var(--color-components-input-border-hover); --color-components-input-border-active: var(--color-components-input-border-active); - --color-components-input-border-active-prompt-1: var(--color-components-input-border-active-prompt-1); - --color-components-input-border-active-prompt-2: var(--color-components-input-border-active-prompt-2); + --color-components-input-border-active-prompt-1: var( + --color-components-input-border-active-prompt-1 + ); + --color-components-input-border-active-prompt-2: var( + --color-components-input-border-active-prompt-2 + ); --color-components-input-border-destructive: var(--color-components-input-border-destructive); --color-components-progress-brand-progress: var(--color-components-progress-brand-progress); @@ -355,76 +499,160 @@ --color-components-progress-error-bg: var(--color-components-progress-error-bg); --color-components-progress-bar-progress: var(--color-components-progress-bar-progress); - --color-components-progress-bar-progress-highlight: var(--color-components-progress-bar-progress-highlight); - --color-components-progress-bar-progress-solid: var(--color-components-progress-bar-progress-solid); + --color-components-progress-bar-progress-highlight: var( + --color-components-progress-bar-progress-highlight + ); + --color-components-progress-bar-progress-solid: var( + --color-components-progress-bar-progress-solid + ); --color-components-progress-bar-border: var(--color-components-progress-bar-border); --color-components-progress-bar-bg: var(--color-components-progress-bar-bg); --color-components-button-button-seam: var(--color-components-button-button-seam); --color-components-button-primary-text: var(--color-components-button-primary-text); - --color-components-button-primary-text-disabled: var(--color-components-button-primary-text-disabled); + --color-components-button-primary-text-disabled: var( + --color-components-button-primary-text-disabled + ); --color-components-button-primary-bg: var(--color-components-button-primary-bg); --color-components-button-primary-bg-hover: var(--color-components-button-primary-bg-hover); --color-components-button-primary-bg-disabled: var(--color-components-button-primary-bg-disabled); --color-components-button-primary-border: var(--color-components-button-primary-border); - --color-components-button-primary-border-hover: var(--color-components-button-primary-border-hover); - --color-components-button-primary-border-disabled: var(--color-components-button-primary-border-disabled); + --color-components-button-primary-border-hover: var( + --color-components-button-primary-border-hover + ); + --color-components-button-primary-border-disabled: var( + --color-components-button-primary-border-disabled + ); --color-components-button-secondary-text: var(--color-components-button-secondary-text); - --color-components-button-secondary-text-disabled: var(--color-components-button-secondary-text-disabled); + --color-components-button-secondary-text-disabled: var( + --color-components-button-secondary-text-disabled + ); --color-components-button-secondary-bg: var(--color-components-button-secondary-bg); --color-components-button-secondary-bg-hover: var(--color-components-button-secondary-bg-hover); - --color-components-button-secondary-bg-disabled: var(--color-components-button-secondary-bg-disabled); + --color-components-button-secondary-bg-disabled: var( + --color-components-button-secondary-bg-disabled + ); --color-components-button-secondary-border: var(--color-components-button-secondary-border); - --color-components-button-secondary-border-hover: var(--color-components-button-secondary-border-hover); - --color-components-button-secondary-border-disabled: var(--color-components-button-secondary-border-disabled); - - --color-components-button-secondary-accent-text: var(--color-components-button-secondary-accent-text); - --color-components-button-secondary-accent-text-disabled: var(--color-components-button-secondary-accent-text-disabled); + --color-components-button-secondary-border-hover: var( + --color-components-button-secondary-border-hover + ); + --color-components-button-secondary-border-disabled: var( + --color-components-button-secondary-border-disabled + ); + + --color-components-button-secondary-accent-text: var( + --color-components-button-secondary-accent-text + ); + --color-components-button-secondary-accent-text-disabled: var( + --color-components-button-secondary-accent-text-disabled + ); --color-components-button-secondary-accent-bg: var(--color-components-button-secondary-accent-bg); - --color-components-button-secondary-accent-bg-hover: var(--color-components-button-secondary-accent-bg-hover); - --color-components-button-secondary-accent-bg-disabled: var(--color-components-button-secondary-accent-bg-disabled); - --color-components-button-secondary-accent-border: var(--color-components-button-secondary-accent-border); - --color-components-button-secondary-accent-border-hover: var(--color-components-button-secondary-accent-border-hover); - --color-components-button-secondary-accent-border-disabled: var(--color-components-button-secondary-accent-border-disabled); + --color-components-button-secondary-accent-bg-hover: var( + --color-components-button-secondary-accent-bg-hover + ); + --color-components-button-secondary-accent-bg-disabled: var( + --color-components-button-secondary-accent-bg-disabled + ); + --color-components-button-secondary-accent-border: var( + --color-components-button-secondary-accent-border + ); + --color-components-button-secondary-accent-border-hover: var( + --color-components-button-secondary-accent-border-hover + ); + --color-components-button-secondary-accent-border-disabled: var( + --color-components-button-secondary-accent-border-disabled + ); --color-components-button-tertiary-text: var(--color-components-button-tertiary-text); - --color-components-button-tertiary-text-disabled: var(--color-components-button-tertiary-text-disabled); + --color-components-button-tertiary-text-disabled: var( + --color-components-button-tertiary-text-disabled + ); --color-components-button-tertiary-bg: var(--color-components-button-tertiary-bg); --color-components-button-tertiary-bg-hover: var(--color-components-button-tertiary-bg-hover); - --color-components-button-tertiary-bg-disabled: var(--color-components-button-tertiary-bg-disabled); + --color-components-button-tertiary-bg-disabled: var( + --color-components-button-tertiary-bg-disabled + ); --color-components-button-ghost-text: var(--color-components-button-ghost-text); --color-components-button-ghost-text-disabled: var(--color-components-button-ghost-text-disabled); --color-components-button-ghost-bg-hover: var(--color-components-button-ghost-bg-hover); - --color-components-button-destructive-primary-text: var(--color-components-button-destructive-primary-text); - --color-components-button-destructive-primary-text-disabled: var(--color-components-button-destructive-primary-text-disabled); - --color-components-button-destructive-primary-bg: var(--color-components-button-destructive-primary-bg); - --color-components-button-destructive-primary-bg-hover: var(--color-components-button-destructive-primary-bg-hover); - --color-components-button-destructive-primary-bg-disabled: var(--color-components-button-destructive-primary-bg-disabled); - --color-components-button-destructive-primary-border: var(--color-components-button-destructive-primary-border); - --color-components-button-destructive-primary-border-hover: var(--color-components-button-destructive-primary-border-hover); - --color-components-button-destructive-primary-border-disabled: var(--color-components-button-destructive-primary-border-disabled); - - --color-components-button-destructive-secondary-text: var(--color-components-button-destructive-secondary-text); - --color-components-button-destructive-secondary-text-disabled: var(--color-components-button-destructive-secondary-text-disabled); - --color-components-button-destructive-secondary-bg: var(--color-components-button-destructive-secondary-bg); - --color-components-button-destructive-secondary-bg-hover: var(--color-components-button-destructive-secondary-bg-hover); - --color-components-button-destructive-secondary-bg-disabled: var(--color-components-button-destructive-secondary-bg-disabled); - --color-components-button-destructive-secondary-border: var(--color-components-button-destructive-secondary-border); - --color-components-button-destructive-secondary-border-hover: var(--color-components-button-destructive-secondary-border-hover); - --color-components-button-destructive-secondary-border-disabled: var(--color-components-button-destructive-secondary-border-disabled); - - --color-components-button-destructive-tertiary-text: var(--color-components-button-destructive-tertiary-text); - --color-components-button-destructive-tertiary-text-disabled: var(--color-components-button-destructive-tertiary-text-disabled); - --color-components-button-destructive-tertiary-bg: var(--color-components-button-destructive-tertiary-bg); - --color-components-button-destructive-tertiary-bg-hover: var(--color-components-button-destructive-tertiary-bg-hover); - --color-components-button-destructive-tertiary-bg-disabled: var(--color-components-button-destructive-tertiary-bg-disabled); - - --color-components-button-destructive-ghost-text: var(--color-components-button-destructive-ghost-text); - --color-components-button-destructive-ghost-text-disabled: var(--color-components-button-destructive-ghost-text-disabled); - --color-components-button-destructive-ghost-bg-hover: var(--color-components-button-destructive-ghost-bg-hover); + --color-components-button-destructive-primary-text: var( + --color-components-button-destructive-primary-text + ); + --color-components-button-destructive-primary-text-disabled: var( + --color-components-button-destructive-primary-text-disabled + ); + --color-components-button-destructive-primary-bg: var( + --color-components-button-destructive-primary-bg + ); + --color-components-button-destructive-primary-bg-hover: var( + --color-components-button-destructive-primary-bg-hover + ); + --color-components-button-destructive-primary-bg-disabled: var( + --color-components-button-destructive-primary-bg-disabled + ); + --color-components-button-destructive-primary-border: var( + --color-components-button-destructive-primary-border + ); + --color-components-button-destructive-primary-border-hover: var( + --color-components-button-destructive-primary-border-hover + ); + --color-components-button-destructive-primary-border-disabled: var( + --color-components-button-destructive-primary-border-disabled + ); + + --color-components-button-destructive-secondary-text: var( + --color-components-button-destructive-secondary-text + ); + --color-components-button-destructive-secondary-text-disabled: var( + --color-components-button-destructive-secondary-text-disabled + ); + --color-components-button-destructive-secondary-bg: var( + --color-components-button-destructive-secondary-bg + ); + --color-components-button-destructive-secondary-bg-hover: var( + --color-components-button-destructive-secondary-bg-hover + ); + --color-components-button-destructive-secondary-bg-disabled: var( + --color-components-button-destructive-secondary-bg-disabled + ); + --color-components-button-destructive-secondary-border: var( + --color-components-button-destructive-secondary-border + ); + --color-components-button-destructive-secondary-border-hover: var( + --color-components-button-destructive-secondary-border-hover + ); + --color-components-button-destructive-secondary-border-disabled: var( + --color-components-button-destructive-secondary-border-disabled + ); + + --color-components-button-destructive-tertiary-text: var( + --color-components-button-destructive-tertiary-text + ); + --color-components-button-destructive-tertiary-text-disabled: var( + --color-components-button-destructive-tertiary-text-disabled + ); + --color-components-button-destructive-tertiary-bg: var( + --color-components-button-destructive-tertiary-bg + ); + --color-components-button-destructive-tertiary-bg-hover: var( + --color-components-button-destructive-tertiary-bg-hover + ); + --color-components-button-destructive-tertiary-bg-disabled: var( + --color-components-button-destructive-tertiary-bg-disabled + ); + + --color-components-button-destructive-ghost-text: var( + --color-components-button-destructive-ghost-text + ); + --color-components-button-destructive-ghost-text-disabled: var( + --color-components-button-destructive-ghost-text-disabled + ); + --color-components-button-destructive-ghost-bg-hover: var( + --color-components-button-destructive-ghost-bg-hover + ); --color-components-button-indigo-bg: var(--color-components-button-indigo-bg); --color-components-button-indigo-bg-hover: var(--color-components-button-indigo-bg-hover); @@ -437,16 +665,22 @@ --color-components-button-debug-bg-disabled: var(--color-components-button-debug-bg-disabled); --color-components-button-debug-border: var(--color-components-button-debug-border); --color-components-button-debug-border-hover: var(--color-components-button-debug-border-hover); - --color-components-button-debug-border-disabled: var(--color-components-button-debug-border-disabled); + --color-components-button-debug-border-disabled: var( + --color-components-button-debug-border-disabled + ); --color-components-checkbox-icon: var(--color-components-checkbox-icon); --color-components-checkbox-icon-disabled: var(--color-components-checkbox-icon-disabled); --color-components-checkbox-bg: var(--color-components-checkbox-bg); --color-components-checkbox-bg-hover: var(--color-components-checkbox-bg-hover); --color-components-checkbox-bg-disabled: var(--color-components-checkbox-bg-disabled); - --color-components-checkbox-bg-disabled-checked: var(--color-components-checkbox-bg-disabled-checked); + --color-components-checkbox-bg-disabled-checked: var( + --color-components-checkbox-bg-disabled-checked + ); --color-components-checkbox-bg-unchecked: var(--color-components-checkbox-bg-unchecked); - --color-components-checkbox-bg-unchecked-hover: var(--color-components-checkbox-bg-unchecked-hover); + --color-components-checkbox-bg-unchecked-hover: var( + --color-components-checkbox-bg-unchecked-hover + ); --color-components-checkbox-border: var(--color-components-checkbox-border); --color-components-checkbox-border-hover: var(--color-components-checkbox-border-hover); --color-components-checkbox-border-disabled: var(--color-components-checkbox-border-disabled); @@ -456,7 +690,9 @@ --color-components-radio-bg-disabled: var(--color-components-radio-bg-disabled); --color-components-radio-border-checked: var(--color-components-radio-border-checked); --color-components-radio-border-checked-hover: var(--color-components-radio-border-checked-hover); - --color-components-radio-border-checked-disabled: var(--color-components-radio-border-checked-disabled); + --color-components-radio-border-checked-disabled: var( + --color-components-radio-border-checked-disabled + ); --color-components-radio-border: var(--color-components-radio-border); --color-components-radio-border-hover: var(--color-components-radio-border-hover); --color-components-radio-border-disabled: var(--color-components-radio-border-disabled); @@ -469,14 +705,24 @@ --color-components-toggle-bg-disabled: var(--color-components-toggle-bg-disabled); --color-components-toggle-bg-unchecked: var(--color-components-toggle-bg-unchecked); --color-components-toggle-bg-unchecked-hover: var(--color-components-toggle-bg-unchecked-hover); - --color-components-toggle-bg-unchecked-disabled: var(--color-components-toggle-bg-unchecked-disabled); + --color-components-toggle-bg-unchecked-disabled: var( + --color-components-toggle-bg-unchecked-disabled + ); --color-components-option-card-option-bg: var(--color-components-option-card-option-bg); --color-components-option-card-option-border: var(--color-components-option-card-option-border); - --color-components-option-card-option-bg-hover: var(--color-components-option-card-option-bg-hover); - --color-components-option-card-option-border-hover: var(--color-components-option-card-option-border-hover); - --color-components-option-card-option-selected-bg: var(--color-components-option-card-option-selected-bg); - --color-components-option-card-option-selected-border: var(--color-components-option-card-option-selected-border); + --color-components-option-card-option-bg-hover: var( + --color-components-option-card-option-bg-hover + ); + --color-components-option-card-option-border-hover: var( + --color-components-option-card-option-border-hover + ); + --color-components-option-card-option-selected-bg: var( + --color-components-option-card-option-selected-bg + ); + --color-components-option-card-option-selected-border: var( + --color-components-option-card-option-selected-border + ); --color-components-slider-knob: var(--color-components-slider-knob); --color-components-slider-knob-border: var(--color-components-slider-knob-border); @@ -494,15 +740,27 @@ --color-components-menu-item-text: var(--color-components-menu-item-text); --color-components-menu-item-text-hover: var(--color-components-menu-item-text-hover); --color-components-menu-item-text-active: var(--color-components-menu-item-text-active); - --color-components-menu-item-text-active-accent: var(--color-components-menu-item-text-active-accent); + --color-components-menu-item-text-active-accent: var( + --color-components-menu-item-text-active-accent + ); --color-components-menu-item-bg-hover: var(--color-components-menu-item-bg-hover); --color-components-menu-item-bg-active: var(--color-components-menu-item-bg-active); - --color-components-segmented-control-bg-normal: var(--color-components-segmented-control-bg-normal); - --color-components-segmented-control-item-active-bg: var(--color-components-segmented-control-item-active-bg); - --color-components-segmented-control-item-active-border: var(--color-components-segmented-control-item-active-border); - --color-components-segmented-control-item-active-accent-bg: var(--color-components-segmented-control-item-active-accent-bg); - --color-components-segmented-control-item-active-accent-border: var(--color-components-segmented-control-item-active-accent-border); + --color-components-segmented-control-bg-normal: var( + --color-components-segmented-control-bg-normal + ); + --color-components-segmented-control-item-active-bg: var( + --color-components-segmented-control-item-active-bg + ); + --color-components-segmented-control-item-active-border: var( + --color-components-segmented-control-item-active-border + ); + --color-components-segmented-control-item-active-accent-bg: var( + --color-components-segmented-control-item-active-accent-bg + ); + --color-components-segmented-control-item-active-accent-border: var( + --color-components-segmented-control-item-active-accent-border + ); --color-components-badge-white-to-dark: var(--color-components-badge-white-to-dark); --color-components-badge-white-to-dark-alpha: var(--color-components-badge-white-to-dark-alpha); @@ -513,27 +771,61 @@ --color-components-badge-bg-gray-soft: var(--color-components-badge-bg-gray-soft); --color-components-badge-bg-dimm: var(--color-components-badge-bg-dimm); - --color-components-badge-status-light-border-outer: var(--color-components-badge-status-light-border-outer); - --color-components-badge-status-light-high-light: var(--color-components-badge-status-light-high-light); - --color-components-badge-status-light-success-bg: var(--color-components-badge-status-light-success-bg); - --color-components-badge-status-light-success-border-inner: var(--color-components-badge-status-light-success-border-inner); - --color-components-badge-status-light-success-halo: var(--color-components-badge-status-light-success-halo); - - --color-components-badge-status-light-warning-bg: var(--color-components-badge-status-light-warning-bg); - --color-components-badge-status-light-warning-border-inner: var(--color-components-badge-status-light-warning-border-inner); - --color-components-badge-status-light-warning-halo: var(--color-components-badge-status-light-warning-halo); - - --color-components-badge-status-light-error-bg: var(--color-components-badge-status-light-error-bg); - --color-components-badge-status-light-error-border-inner: var(--color-components-badge-status-light-error-border-inner); - --color-components-badge-status-light-error-halo: var(--color-components-badge-status-light-error-halo); - - --color-components-badge-status-light-normal-bg: var(--color-components-badge-status-light-normal-bg); - --color-components-badge-status-light-normal-border-inner: var(--color-components-badge-status-light-normal-border-inner); - --color-components-badge-status-light-normal-halo: var(--color-components-badge-status-light-normal-halo); - - --color-components-badge-status-light-disabled-bg: var(--color-components-badge-status-light-disabled-bg); - --color-components-badge-status-light-disabled-border-inner: var(--color-components-badge-status-light-disabled-border-inner); - --color-components-badge-status-light-disabled-halo: var(--color-components-badge-status-light-disabled-halo); + --color-components-badge-status-light-border-outer: var( + --color-components-badge-status-light-border-outer + ); + --color-components-badge-status-light-high-light: var( + --color-components-badge-status-light-high-light + ); + --color-components-badge-status-light-success-bg: var( + --color-components-badge-status-light-success-bg + ); + --color-components-badge-status-light-success-border-inner: var( + --color-components-badge-status-light-success-border-inner + ); + --color-components-badge-status-light-success-halo: var( + --color-components-badge-status-light-success-halo + ); + + --color-components-badge-status-light-warning-bg: var( + --color-components-badge-status-light-warning-bg + ); + --color-components-badge-status-light-warning-border-inner: var( + --color-components-badge-status-light-warning-border-inner + ); + --color-components-badge-status-light-warning-halo: var( + --color-components-badge-status-light-warning-halo + ); + + --color-components-badge-status-light-error-bg: var( + --color-components-badge-status-light-error-bg + ); + --color-components-badge-status-light-error-border-inner: var( + --color-components-badge-status-light-error-border-inner + ); + --color-components-badge-status-light-error-halo: var( + --color-components-badge-status-light-error-halo + ); + + --color-components-badge-status-light-normal-bg: var( + --color-components-badge-status-light-normal-bg + ); + --color-components-badge-status-light-normal-border-inner: var( + --color-components-badge-status-light-normal-border-inner + ); + --color-components-badge-status-light-normal-halo: var( + --color-components-badge-status-light-normal-halo + ); + + --color-components-badge-status-light-disabled-bg: var( + --color-components-badge-status-light-disabled-bg + ); + --color-components-badge-status-light-disabled-border-inner: var( + --color-components-badge-status-light-disabled-border-inner + ); + --color-components-badge-status-light-disabled-halo: var( + --color-components-badge-status-light-disabled-halo + ); --color-components-tab-active: var(--color-components-tab-active); @@ -541,29 +833,57 @@ --color-components-main-nav-text-active: var(--color-components-main-nav-text-active); --color-components-main-nav-nav-button-border: var(--color-components-main-nav-nav-button-border); --color-components-main-nav-nav-button-text: var(--color-components-main-nav-nav-button-text); - --color-components-main-nav-nav-button-text-active: var(--color-components-main-nav-nav-button-text-active); + --color-components-main-nav-nav-button-text-active: var( + --color-components-main-nav-nav-button-text-active + ); --color-components-main-nav-nav-button-bg: var(--color-components-main-nav-nav-button-bg); - --color-components-main-nav-nav-button-bg-hover: var(--color-components-main-nav-nav-button-bg-hover); - --color-components-main-nav-nav-button-bg-active: var(--color-components-main-nav-nav-button-bg-active); + --color-components-main-nav-nav-button-bg-hover: var( + --color-components-main-nav-nav-button-bg-hover + ); + --color-components-main-nav-nav-button-bg-active: var( + --color-components-main-nav-nav-button-bg-active + ); --color-components-main-nav-nav-user-border: var(--color-components-main-nav-nav-user-border); --color-components-main-nav-glass-inner-glow: var(--color-components-main-nav-glass-inner-glow); - --color-components-main-nav-glass-shadow-reflection: var(--color-components-main-nav-glass-shadow-reflection); - --color-components-main-nav-glass-shadow-reflection-glow: var(--color-components-main-nav-glass-shadow-reflection-glow); + --color-components-main-nav-glass-shadow-reflection: var( + --color-components-main-nav-glass-shadow-reflection + ); + --color-components-main-nav-glass-shadow-reflection-glow: var( + --color-components-main-nav-glass-shadow-reflection-glow + ); --color-components-main-nav-glass-text-glow: var(--color-components-main-nav-glass-text-glow); - --color-components-main-nav-glass-surface-first: var(--color-components-main-nav-glass-surface-first); - --color-components-main-nav-glass-surface-middle-1: var(--color-components-main-nav-glass-surface-middle-1); - --color-components-main-nav-glass-surface-middle-2: var(--color-components-main-nav-glass-surface-middle-2); + --color-components-main-nav-glass-surface-first: var( + --color-components-main-nav-glass-surface-first + ); + --color-components-main-nav-glass-surface-middle-1: var( + --color-components-main-nav-glass-surface-middle-1 + ); + --color-components-main-nav-glass-surface-middle-2: var( + --color-components-main-nav-glass-surface-middle-2 + ); --color-components-main-nav-glass-surface-end: var(--color-components-main-nav-glass-surface-end); - --color-components-main-nav-glass-edge-reflection-first: var(--color-components-main-nav-glass-edge-reflection-first); - --color-components-main-nav-glass-edge-reflection-middle: var(--color-components-main-nav-glass-edge-reflection-middle); - --color-components-main-nav-glass-edge-reflection-end: var(--color-components-main-nav-glass-edge-reflection-end); - - --color-components-main-nav-glass-edge-highlight-first: var(--color-components-main-nav-glass-edge-highlight-first); - --color-components-main-nav-glass-edge-highlight-middle: var(--color-components-main-nav-glass-edge-highlight-middle); - --color-components-main-nav-glass-edge-highlight-end: var(--color-components-main-nav-glass-edge-highlight-end); + --color-components-main-nav-glass-edge-reflection-first: var( + --color-components-main-nav-glass-edge-reflection-first + ); + --color-components-main-nav-glass-edge-reflection-middle: var( + --color-components-main-nav-glass-edge-reflection-middle + ); + --color-components-main-nav-glass-edge-reflection-end: var( + --color-components-main-nav-glass-edge-reflection-end + ); + + --color-components-main-nav-glass-edge-highlight-first: var( + --color-components-main-nav-glass-edge-highlight-first + ); + --color-components-main-nav-glass-edge-highlight-middle: var( + --color-components-main-nav-glass-edge-highlight-middle + ); + --color-components-main-nav-glass-edge-highlight-end: var( + --color-components-main-nav-glass-edge-highlight-end + ); --color-components-chart-bg: var(--color-components-chart-bg); --color-components-chart-line: var(--color-components-chart-line); @@ -609,14 +929,30 @@ --color-third-party-model-bg-anthropic: var(--color-third-party-model-bg-anthropic); --color-third-party-model-bg-default: var(--color-third-party-model-bg-default); - --color-util-colors-orange-dark-orange-dark-50: var(--color-util-colors-orange-dark-orange-dark-50); - --color-util-colors-orange-dark-orange-dark-100: var(--color-util-colors-orange-dark-orange-dark-100); - --color-util-colors-orange-dark-orange-dark-200: var(--color-util-colors-orange-dark-orange-dark-200); - --color-util-colors-orange-dark-orange-dark-300: var(--color-util-colors-orange-dark-orange-dark-300); - --color-util-colors-orange-dark-orange-dark-400: var(--color-util-colors-orange-dark-orange-dark-400); - --color-util-colors-orange-dark-orange-dark-500: var(--color-util-colors-orange-dark-orange-dark-500); - --color-util-colors-orange-dark-orange-dark-600: var(--color-util-colors-orange-dark-orange-dark-600); - --color-util-colors-orange-dark-orange-dark-700: var(--color-util-colors-orange-dark-orange-dark-700); + --color-util-colors-orange-dark-orange-dark-50: var( + --color-util-colors-orange-dark-orange-dark-50 + ); + --color-util-colors-orange-dark-orange-dark-100: var( + --color-util-colors-orange-dark-orange-dark-100 + ); + --color-util-colors-orange-dark-orange-dark-200: var( + --color-util-colors-orange-dark-orange-dark-200 + ); + --color-util-colors-orange-dark-orange-dark-300: var( + --color-util-colors-orange-dark-orange-dark-300 + ); + --color-util-colors-orange-dark-orange-dark-400: var( + --color-util-colors-orange-dark-orange-dark-400 + ); + --color-util-colors-orange-dark-orange-dark-500: var( + --color-util-colors-orange-dark-orange-dark-500 + ); + --color-util-colors-orange-dark-orange-dark-600: var( + --color-util-colors-orange-dark-orange-dark-600 + ); + --color-util-colors-orange-dark-orange-dark-700: var( + --color-util-colors-orange-dark-orange-dark-700 + ); --color-util-colors-orange-orange-50: var(--color-util-colors-orange-orange-50); --color-util-colors-orange-orange-100: var(--color-util-colors-orange-orange-100); @@ -626,7 +962,9 @@ --color-util-colors-orange-orange-500: var(--color-util-colors-orange-orange-500); --color-util-colors-orange-orange-600: var(--color-util-colors-orange-orange-600); --color-util-colors-orange-orange-700: var(--color-util-colors-orange-orange-700); - --color-util-colors-orange-orange-100-transparent: var(--color-util-colors-orange-orange-100-transparent); + --color-util-colors-orange-orange-100-transparent: var( + --color-util-colors-orange-orange-100-transparent + ); --color-util-colors-pink-pink-50: var(--color-util-colors-pink-pink-50); --color-util-colors-pink-pink-100: var(--color-util-colors-pink-pink-100); @@ -718,14 +1056,30 @@ --color-util-colors-green-green-600: var(--color-util-colors-green-green-600); --color-util-colors-green-green-700: var(--color-util-colors-green-green-700); - --color-util-colors-green-light-green-light-50: var(--color-util-colors-green-light-green-light-50); - --color-util-colors-green-light-green-light-100: var(--color-util-colors-green-light-green-light-100); - --color-util-colors-green-light-green-light-200: var(--color-util-colors-green-light-green-light-200); - --color-util-colors-green-light-green-light-300: var(--color-util-colors-green-light-green-light-300); - --color-util-colors-green-light-green-light-400: var(--color-util-colors-green-light-green-light-400); - --color-util-colors-green-light-green-light-500: var(--color-util-colors-green-light-green-light-500); - --color-util-colors-green-light-green-light-600: var(--color-util-colors-green-light-green-light-600); - --color-util-colors-green-light-green-light-700: var(--color-util-colors-green-light-green-light-700); + --color-util-colors-green-light-green-light-50: var( + --color-util-colors-green-light-green-light-50 + ); + --color-util-colors-green-light-green-light-100: var( + --color-util-colors-green-light-green-light-100 + ); + --color-util-colors-green-light-green-light-200: var( + --color-util-colors-green-light-green-light-200 + ); + --color-util-colors-green-light-green-light-300: var( + --color-util-colors-green-light-green-light-300 + ); + --color-util-colors-green-light-green-light-400: var( + --color-util-colors-green-light-green-light-400 + ); + --color-util-colors-green-light-green-light-500: var( + --color-util-colors-green-light-green-light-500 + ); + --color-util-colors-green-light-green-light-600: var( + --color-util-colors-green-light-green-light-600 + ); + --color-util-colors-green-light-green-light-700: var( + --color-util-colors-green-light-green-light-700 + ); --color-util-colors-warning-warning-50: var(--color-util-colors-warning-warning-50); --color-util-colors-warning-warning-100: var(--color-util-colors-warning-warning-100); @@ -825,5 +1179,4 @@ --color-brand-color-opacity-800: var(--color-brand-color-opacity-800); --color-brand-color-opacity-900: var(--color-brand-color-opacity-900); --color-brand-color-opacity-1000: var(--color-brand-color-opacity-1000); - } diff --git a/packages/dify-ui/src/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx index e8b3d84139b3b5..c256cc15b9eaef 100644 --- a/packages/dify-ui/src/toast/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/toast/__tests__/index.spec.tsx @@ -8,9 +8,11 @@ type BaseUIAnimationGlobal = typeof globalThis & { } const dispatchToastMouseOver = (element: HTMLElement | SVGElement) => { - element.dispatchEvent(new MouseEvent('mouseover', { - bubbles: true, - })) + element.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + }), + ) } describe('@langgenius/dify-ui/toast', () => { @@ -62,8 +64,10 @@ describe('@langgenius/dify-ui/toast', () => { it('should wrap long unbroken toast content within the card width', async () => { const screen = await render(<ToastHost />) - const longTitle = 'operation error S3: PutObject, exceeded maximum number of attempts, 3, StatusCode: 0, RequestID: , HostID: , request send failed' - const longDescription = 'Put "https://plugin/assets/1bd032bb73218a5d141b80cab7111?x-id=PutObject": dial tcp 192.168.0.200:19000: connect: connection refused, icon small en_US failed to remap assets failed to store plugin asset' + const longTitle = + 'operation error S3: PutObject, exceeded maximum number of attempts, 3, StatusCode: 0, RequestID: , HostID: , request send failed' + const longDescription = + 'Put "https://plugin/assets/1bd032bb73218a5d141b80cab7111?x-id=PutObject": dial tcp 192.168.0.200:19000: connect: connection refused, icon small en_US failed to remap assets failed to store plugin asset' toast.error(longTitle, { description: longDescription, @@ -116,7 +120,9 @@ describe('@langgenius/dify-ui/toast', () => { const viewport = screen.getByRole('region', { name: 'Notifications' }).element() dispatchToastMouseOver(viewport) - await expect.element(screen.getByRole('button', { name: 'Close notification' })).toBeInTheDocument() + await expect + .element(screen.getByRole('button', { name: 'Close notification' })) + .toBeInTheDocument() asHTMLElement(screen.getByRole('button', { name: 'Close notification' }).element()).click() await vi.waitFor(() => { @@ -178,30 +184,37 @@ describe('@langgenius/dify-ui/toast', () => { const viewport = screen.getByRole('region', { name: 'Notifications' }).element() dispatchToastMouseOver(viewport) - await expect.element(screen.getByRole('button', { name: 'Close notification' })).toBeInTheDocument() + await expect + .element(screen.getByRole('button', { name: 'Close notification' })) + .toBeInTheDocument() asHTMLElement(screen.getByRole('button', { name: 'Close notification' }).element()).click() await vi.waitFor(() => { - expect(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).toHaveAttribute('data-ending-style') + expect(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).toHaveAttribute( + 'data-ending-style', + ) }) asHTMLElement(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).click() - const underlyingAction = asHTMLElement(screen.getByRole('button', { name: 'Underlying action' }).element()) + const underlyingAction = asHTMLElement( + screen.getByRole('button', { name: 'Underlying action' }).element(), + ) const rect = underlyingAction.getBoundingClientRect() const x = rect.left + rect.width / 2 const y = rect.top + rect.height / 2 - document.elementFromPoint(x, y)?.dispatchEvent(new MouseEvent('click', { - bubbles: true, - cancelable: true, - clientX: x, - clientY: y, - })) + document.elementFromPoint(x, y)?.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + }), + ) expect(onClick).toHaveBeenCalledTimes(1) - } - finally { + } finally { baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED = animationState } }) @@ -303,7 +316,7 @@ describe('@langgenius/dify-ui/toast', () => { void toast.promise(promise, { loading: 'Saving…', - success: result => ({ + success: (result) => ({ title: 'Saved', description: result, type: 'success', diff --git a/packages/dify-ui/src/toast/index.stories.tsx b/packages/dify-ui/src/toast/index.stories.tsx index a12f9bb4d271e6..ecc77e85424340 100644 --- a/packages/dify-ui/src/toast/index.stories.tsx +++ b/packages/dify-ui/src/toast/index.stories.tsx @@ -2,8 +2,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' import { toast, ToastHost } from '.' -const buttonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-2 text-sm text-text-secondary shadow-xs outline-hidden transition-colors hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' -const cardClassName = 'flex min-h-[220px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6 shadow-sm shadow-shadow-shadow-3' +const buttonClassName = + 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-2 text-sm text-text-secondary shadow-xs outline-hidden transition-colors hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const cardClassName = + 'flex min-h-[220px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6 shadow-sm shadow-shadow-shadow-3' const ExampleCard = ({ eyebrow, @@ -19,19 +21,11 @@ const ExampleCard = ({ return ( <section className={cardClassName}> <div className="space-y-2"> - <div className="text-xs tracking-[0.18em] text-text-tertiary uppercase"> - {eyebrow} - </div> - <h3 className="text-base leading-6 font-semibold text-text-primary"> - {title} - </h3> - <p className="text-sm leading-6 text-text-secondary"> - {description} - </p> - </div> - <div className="mt-auto flex flex-wrap gap-3"> - {children} + <div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">{eyebrow}</div> + <h3 className="text-base leading-6 font-semibold text-text-primary">{title}</h3> + <p className="text-sm leading-6 text-text-secondary">{description}</p> </div> + <div className="mt-auto flex flex-wrap gap-3">{children}</div> </section> ) } @@ -68,13 +62,21 @@ const VariantExamples = () => { title="Tone-specific notifications" description="Trigger the four supported tones from the shared viewport to validate iconography, gradient treatment, and copy density." > - <button type="button" className={buttonClassName} onClick={() => createVariantToast('success')}> + <button + type="button" + className={buttonClassName} + onClick={() => createVariantToast('success')} + > Success </button> <button type="button" className={buttonClassName} onClick={() => createVariantToast('info')}> Info </button> - <button type="button" className={buttonClassName} onClick={() => createVariantToast('warning')}> + <button + type="button" + className={buttonClassName} + onClick={() => createVariantToast('warning')} + > Warning </button> <button type="button" className={buttonClassName} onClick={() => createVariantToast('error')}> @@ -119,7 +121,8 @@ const StackExamples = () => { const createVaryingHeightStack = () => { toast.info('Long background toast', { - description: 'This longer toast intentionally spans multiple lines so the collapsed stack can be checked against the shorter frontmost toast height without panel overflow.', + description: + 'This longer toast intentionally spans multiple lines so the collapsed stack can be checked against the shorter frontmost toast height without panel overflow.', }) toast.success('Short front toast', { description: 'Short message.', @@ -157,7 +160,7 @@ const PromiseExamples = () => { title: 'Deploying workflow', description: 'Provisioning runtime and publishing the latest version.', }, - success: result => ({ + success: (result) => ({ type: 'success', title: 'Deployment complete', description: result, @@ -222,7 +225,8 @@ const ActionExamples = () => { const createLongCopyToast = () => { toast.info('Knowledge ingestion in progress', { - description: 'This longer example helps validate line wrapping, close button alignment, and action button placement when the content spans multiple rows.', + description: + 'This longer example helps validate line wrapping, close button alignment, and action button placement when the content spans multiple rows.', actionProps: { children: 'View details', onClick: () => { @@ -255,9 +259,10 @@ const DeduplicateExamples = () => { saveCountRef.current += 1 toast.success('Draft saved', { id: 'draft-save-status', - description: saveCountRef.current === 1 - ? 'Click again while this toast is visible to update the same mounted toast.' - : `Same toast updated ${saveCountRef.current} times.`, + description: + saveCountRef.current === 1 + ? 'Click again while this toast is visible to update the same mounted toast.' + : `Same toast updated ${saveCountRef.current} times.`, }) } @@ -325,7 +330,9 @@ const ToastDocsDemo = () => { Shared stacked toast examples </h2> <p className="max-w-3xl text-sm leading-6 text-text-secondary"> - Each example card below triggers the same shared toast viewport in the top-right corner, so you can review stacking, state transitions, actions, and tone variants the same way the official Base UI documentation demonstrates toast behavior. + Each example card below triggers the same shared toast viewport in the top-right + corner, so you can review stacking, state transitions, actions, and tone variants the + same way the official Base UI documentation demonstrates toast behavior. </p> </div> <div className="grid grid-cols-1 gap-4 xl:grid-cols-2"> @@ -349,7 +356,8 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Dify toast host built on Base UI Toast. The story is organized as multiple example panels that all feed the same shared toast viewport, matching the way the Base UI documentation showcases toast behavior.', + component: + 'Dify toast host built on Base UI Toast. The story is organized as multiple example panels that all feed the same shared toast viewport, matching the way the Base UI documentation showcases toast behavior.', }, }, }, diff --git a/packages/dify-ui/src/toast/index.tsx b/packages/dify-ui/src/toast/index.tsx index 973f97fe4f9269..9aad5890c17e5a 100644 --- a/packages/dify-ui/src/toast/index.tsx +++ b/packages/dify-ui/src/toast/index.tsx @@ -18,19 +18,23 @@ type ToastToneStyle = { const TOAST_TONE_STYLES = { success: { iconClassName: 'i-ri-checkbox-circle-fill text-text-success', - gradientClassName: 'from-components-badge-status-light-success-halo to-background-gradient-mask-transparent', + gradientClassName: + 'from-components-badge-status-light-success-halo to-background-gradient-mask-transparent', }, error: { iconClassName: 'i-ri-error-warning-fill text-text-destructive', - gradientClassName: 'from-components-badge-status-light-error-halo to-background-gradient-mask-transparent', + gradientClassName: + 'from-components-badge-status-light-error-halo to-background-gradient-mask-transparent', }, warning: { iconClassName: 'i-ri-alert-fill text-text-warning-secondary', - gradientClassName: 'from-components-badge-status-light-warning-halo to-background-gradient-mask-transparent', + gradientClassName: + 'from-components-badge-status-light-warning-halo to-background-gradient-mask-transparent', }, info: { iconClassName: 'i-ri-information-2-fill text-text-accent', - gradientClassName: 'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent', + gradientClassName: + 'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent', }, } satisfies Record<string, ToastToneStyle> @@ -39,18 +43,27 @@ const toastViewportLabel = 'Notifications' type ToastType = keyof typeof TOAST_TONE_STYLES -type ToastAddOptions = Omit<ToastManagerAddOptions<ToastData>, 'data' | 'positionerProps' | 'type'> & { +type ToastAddOptions = Omit< + ToastManagerAddOptions<ToastData>, + 'data' | 'positionerProps' | 'type' +> & { type?: ToastType } -type ToastUpdateOptions = Omit<ToastManagerUpdateOptions<ToastData>, 'data' | 'positionerProps' | 'type'> & { +type ToastUpdateOptions = Omit< + ToastManagerUpdateOptions<ToastData>, + 'data' | 'positionerProps' | 'type' +> & { type?: ToastType } type ToastOptions = Omit<ToastAddOptions, 'title'> type TypedToastOptions = Omit<ToastOptions, 'type'> -type ToastPromiseResultOption<Value> = string | ToastUpdateOptions | ((value: Value) => string | ToastUpdateOptions) +type ToastPromiseResultOption<Value> = + | string + | ToastUpdateOptions + | ((value: Value) => string | ToastUpdateOptions) type ToastPromiseOptions<Value> = { loading: string | ToastUpdateOptions @@ -75,7 +88,10 @@ type ToastApi = { info: TypedToastCall dismiss: ToastDismiss update: (toastId: string, options: ToastUpdateOptions) => void - promise: <Value>(promiseValue: Promise<Value>, options: ToastPromiseOptions<Value>) => Promise<Value> + promise: <Value>( + promiseValue: Promise<Value>, + options: ToastPromiseOptions<Value>, + ) => Promise<Value> } const toastManager = BaseToast.createToastManager<ToastData>() @@ -92,21 +108,23 @@ function addToast(options: ToastAddOptions) { return toastManager.add(options) } -const showToast: ToastCall = (title, options) => addToast({ - ...options, - title, -}) +const showToast: ToastCall = (title, options) => + addToast({ + ...options, + title, + }) const dismissToast: ToastDismiss = (toastId) => { toastManager.close(toastId) } function createTypedToast(type: ToastType): TypedToastCall { - return (title, options) => addToast({ - ...options, - title, - type, - }) + return (title, options) => + addToast({ + ...options, + title, + type, + }) } function updateToast(toastId: string, options: ToastUpdateOptions) { @@ -117,36 +135,28 @@ function promiseToast<Value>(promiseValue: Promise<Value>, options: ToastPromise return toastManager.promise(promiseValue, options) } -export const toast: ToastApi = Object.assign( - showToast, - { - success: createTypedToast('success'), - error: createTypedToast('error'), - warning: createTypedToast('warning'), - info: createTypedToast('info'), - dismiss: dismissToast, - update: updateToast, - promise: promiseToast, - }, -) +export const toast: ToastApi = Object.assign(showToast, { + success: createTypedToast('success'), + error: createTypedToast('error'), + warning: createTypedToast('warning'), + info: createTypedToast('info'), + dismiss: dismissToast, + update: updateToast, + promise: promiseToast, +}) function ToastIcon({ type }: { type?: ToastType }) { - return type - ? <span aria-hidden="true" className={cn('h-5 w-5', TOAST_TONE_STYLES[type].iconClassName)} /> - : null + return type ? ( + <span aria-hidden="true" className={cn('h-5 w-5', TOAST_TONE_STYLES[type].iconClassName)} /> + ) : null } function getToneGradientClasses(type?: ToastType) { - if (type) - return TOAST_TONE_STYLES[type].gradientClassName + if (type) return TOAST_TONE_STYLES[type].gradientClassName return 'from-background-default-subtle to-background-gradient-mask-transparent' } -function ToastCard({ - toast: toastItem, -}: { - toast: ToastObject<ToastData> -}) { +function ToastCard({ toast: toastItem }: { toast: ToastObject<ToastData> }) { const toastType = getToastType(toastItem.type) return ( @@ -163,13 +173,16 @@ function ToastCard({ 'data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+150%))]', 'data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+150%))]', 'data-limited:pointer-events-none data-limited:opacity-0 data-starting-style:transform-[translateY(-150%)] data-starting-style:opacity-0', - 'after:pointer-events-auto after:absolute after:top-full after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full after:content-[\'\']', + "after:pointer-events-auto after:absolute after:top-full after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full after:content-['']", )} > <div className="relative h-full overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"> <div aria-hidden="true" - className={cn('absolute -inset-px bg-linear-to-r opacity-40', getToneGradientClasses(toastType))} + className={cn( + 'absolute -inset-px bg-linear-to-r opacity-40', + getToneGradientClasses(toastType), + )} /> <BaseToast.Content className="relative flex h-full items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100 motion-reduce:transition-none"> <div className="flex shrink-0 items-center justify-center p-0.5"> @@ -225,20 +238,14 @@ function ToastViewport() { 'group/toast-viewport pointer-events-none fixed top-4 right-4 z-60 w-90 max-w-[calc(100vw-2rem)] overflow-visible sm:right-8', )} > - {toasts.map(toastItem => ( - <ToastCard - key={toastItem.id} - toast={toastItem} - /> + {toasts.map((toastItem) => ( + <ToastCard key={toastItem.id} toast={toastItem} /> ))} </BaseToast.Viewport> ) } -export function ToastHost({ - timeout, - limit, -}: ToastHostProps) { +export function ToastHost({ timeout, limit }: ToastHostProps) { return ( <BaseToast.Provider toastManager={toastManager} timeout={timeout} limit={limit}> <BaseToast.Portal> diff --git a/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx b/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx index 441da70c92aa95..711158c3fed2ab 100644 --- a/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx @@ -2,11 +2,8 @@ import type * as React from 'react' import { render } from 'vitest-browser-react' import { Tooltip, TooltipContent, TooltipTrigger } from '../index' -const renderWithSafeViewport = (ui: React.ReactNode) => render( - <div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}> - {ui} - </div>, -) +const renderWithSafeViewport = (ui: React.ReactNode) => + render(<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>{ui}</div>) describe('TooltipContent', () => { describe('Placement and offsets', () => { @@ -20,9 +17,15 @@ describe('TooltipContent', () => { </Tooltip>, ) - await expect.element(screen.getByRole('tooltip', { name: 'default tooltip' })).toHaveAttribute('data-side', 'top') - await expect.element(screen.getByRole('tooltip', { name: 'default tooltip' })).toHaveAttribute('data-align', 'center') - await expect.element(screen.getByRole('tooltip', { name: 'default tooltip' })).toHaveTextContent('Tooltip body') + await expect + .element(screen.getByRole('tooltip', { name: 'default tooltip' })) + .toHaveAttribute('data-side', 'top') + await expect + .element(screen.getByRole('tooltip', { name: 'default tooltip' })) + .toHaveAttribute('data-align', 'center') + await expect + .element(screen.getByRole('tooltip', { name: 'default tooltip' })) + .toHaveTextContent('Tooltip body') }) it('should apply custom placement when placement props are provided', async () => { @@ -41,9 +44,15 @@ describe('TooltipContent', () => { </Tooltip>, ) - await expect.element(screen.getByRole('tooltip', { name: 'custom tooltip' })).toHaveAttribute('data-side', 'bottom') - await expect.element(screen.getByRole('tooltip', { name: 'custom tooltip' })).toHaveAttribute('data-align', 'start') - await expect.element(screen.getByRole('tooltip', { name: 'custom tooltip' })).toHaveTextContent('Custom tooltip body') + await expect + .element(screen.getByRole('tooltip', { name: 'custom tooltip' })) + .toHaveAttribute('data-side', 'bottom') + await expect + .element(screen.getByRole('tooltip', { name: 'custom tooltip' })) + .toHaveAttribute('data-align', 'start') + await expect + .element(screen.getByRole('tooltip', { name: 'custom tooltip' })) + .toHaveTextContent('Custom tooltip body') }) }) diff --git a/packages/dify-ui/src/tooltip/index.stories.tsx b/packages/dify-ui/src/tooltip/index.stories.tsx index ba20b430c4baa2..379d63369353c2 100644 --- a/packages/dify-ui/src/tooltip/index.stories.tsx +++ b/packages/dify-ui/src/tooltip/index.stories.tsx @@ -1,21 +1,18 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { Placement } from '.' import * as React from 'react' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '.' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '.' -const iconButtonClassName = 'inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-subtle bg-components-button-secondary-bg text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' -const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const iconButtonClassName = + 'inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-subtle bg-components-button-secondary-bg text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const triggerButtonClassName = + 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' const meta = { title: 'Base/UI/Tooltip', component: Tooltip, decorators: [ - Story => ( + (Story) => ( <TooltipProvider> <Story /> </TooltipProvider> @@ -25,7 +22,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement and offsets.\n\n**Usage contract** (mirrors the [Base UI tooltip guidelines](https://base-ui.com/react/components/tooltip#alternatives-to-tooltips)):\n\n- Tooltips are **supplementary visual labels** for sighted mouse and keyboard users. They are disabled on touch devices and are not announced to screen readers.\n- The trigger **must carry its own `aria-label` or visible text** that matches the tooltip — the tooltip does not replace labeling.\n- Keep content short and non-interactive (an icon-button label, a keyboard shortcut, one-word clarification).\n- **Do not** place descriptions, prose, links, or interactive controls inside a tooltip — touch and screen-reader users cannot reach them.\n- For hover-triggered rich previews that users move their cursor onto, use `PreviewCard` (dwell-able, structured content).\n- For an info icon that explains a concept (an "infotip"), or for any hover popup that needs interactive content or to reach touch/assistive-tech users, use `Popover` with `openOnHover` on the trigger.', + component: + 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement and offsets.\n\n**Usage contract** (mirrors the [Base UI tooltip guidelines](https://base-ui.com/react/components/tooltip#alternatives-to-tooltips)):\n\n- Tooltips are **supplementary visual labels** for sighted mouse and keyboard users. They are disabled on touch devices and are not announced to screen readers.\n- The trigger **must carry its own `aria-label` or visible text** that matches the tooltip — the tooltip does not replace labeling.\n- Keep content short and non-interactive (an icon-button label, a keyboard shortcut, one-word clarification).\n- **Do not** place descriptions, prose, links, or interactive controls inside a tooltip — touch and screen-reader users cannot reach them.\n- For hover-triggered rich previews that users move their cursor onto, use `PreviewCard` (dwell-able, structured content).\n- For an info icon that explains a concept (an "infotip"), or for any hover popup that needs interactive content or to reach touch/assistive-tech users, use `Popover` with `openOnHover` on the trigger.', }, }, }, @@ -47,7 +45,8 @@ export const IconButton: Story = { parameters: { docs: { description: { - story: 'The canonical tooltip use case: an icon-only button surfaces its accessible label as a tooltip for sighted mouse and keyboard users. The trigger already carries `aria-label` — the tooltip mirrors that label visually; it does **not** replace it.', + story: + 'The canonical tooltip use case: an icon-only button surfaces its accessible label as a tooltip for sighted mouse and keyboard users. The trigger already carries `aria-label` — the tooltip mirrors that label visually; it does **not** replace it.', }, }, }, @@ -56,11 +55,11 @@ export const IconButton: Story = { {ICON_ACTIONS.map(({ icon, label }) => ( <Tooltip key={label}> <TooltipTrigger - render={( + render={ <button type="button" aria-label={label} className={iconButtonClassName}> <span aria-hidden className={`${icon} h-4 w-4`} /> </button> - )} + } /> <TooltipContent>{label}</TooltipContent> </Tooltip> @@ -73,18 +72,19 @@ export const KeyboardShortcut: Story = { parameters: { docs: { description: { - story: 'A short, supplementary hint that surfaces a keyboard shortcut next to a visible button label. The trigger is fully self-describing ("Save"); the tooltip only adds non-essential extra clarity for mouse/keyboard users.', + story: + 'A short, supplementary hint that surfaces a keyboard shortcut next to a visible button label. The trigger is fully self-describing ("Save"); the tooltip only adds non-essential extra clarity for mouse/keyboard users.', }, }, }, render: () => ( <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" className={triggerButtonClassName}> Save </button> - )} + } /> <TooltipContent>⌘S</TooltipContent> </Tooltip> @@ -112,7 +112,7 @@ const PlacementsDemo = () => { return ( <div className="flex flex-col items-center gap-4 p-24"> <div className="grid grid-cols-3 gap-2 text-xs"> - {PLACEMENTS.map(value => ( + {PLACEMENTS.map((value) => ( <button key={value} type="button" @@ -127,11 +127,13 @@ const PlacementsDemo = () => { </div> <Tooltip open> <TooltipTrigger - render={<button type="button" aria-label="Placement anchor" className={iconButtonClassName}><span aria-hidden className="i-ri-pushpin-line h-4 w-4" /></button>} + render={ + <button type="button" aria-label="Placement anchor" className={iconButtonClassName}> + <span aria-hidden className="i-ri-pushpin-line h-4 w-4" /> + </button> + } /> - <TooltipContent placement={placement}> - {`placement="${placement}"`} - </TooltipContent> + <TooltipContent placement={placement}>{`placement="${placement}"`}</TooltipContent> </Tooltip> </div> ) @@ -142,14 +144,15 @@ export const Placements: Story = { layout: 'fullscreen', docs: { description: { - story: 'Placement reference. `placement` accepts the 12 standard side/align combinations; Base UI flips automatically if the tooltip would overflow the viewport.', + story: + 'Placement reference. `placement` accepts the 12 standard side/align combinations; Base UI flips automatically if the tooltip would overflow the viewport.', }, }, }, render: () => <PlacementsDemo />, } -const DELAY_PRESETS: Array<{ label: string, delay: number }> = [ +const DELAY_PRESETS: Array<{ label: string; delay: number }> = [ { label: 'Instant', delay: 0 }, { label: 'Fast', delay: 150 }, { label: 'Default', delay: 600 }, @@ -161,11 +164,15 @@ const DelayDemo = () => ( <TooltipProvider key={delay} delay={delay}> <Tooltip> <TooltipTrigger - render={( - <button type="button" aria-label={`${label} (${delay}ms)`} className={iconButtonClassName}> + render={ + <button + type="button" + aria-label={`${label} (${delay}ms)`} + className={iconButtonClassName} + > <span aria-hidden className="i-ri-timer-line h-4 w-4" /> </button> - )} + } /> <TooltipContent>{`${label} (${delay}ms)`}</TooltipContent> </Tooltip> @@ -178,7 +185,8 @@ export const WithDelay: Story = { parameters: { docs: { description: { - story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown. The Dify app root sets `delay={300} closeDelay={200}` — override locally only when the surrounding UX demands it.', + story: + '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown. The Dify app root sets `delay={300} closeDelay={200}` — override locally only when the surrounding UX demands it.', }, }, }, diff --git a/packages/dify-ui/vite.config.ts b/packages/dify-ui/vite.config.ts index ef6b439f75f4b5..d814d8b96d31c6 100644 --- a/packages/dify-ui/vite.config.ts +++ b/packages/dify-ui/vite.config.ts @@ -7,10 +7,6 @@ export default defineConfig({ tsconfigPaths: true, }, optimizeDeps: { - include: [ - '@base-ui/react/form', - '@base-ui/react/merge-props', - '@base-ui/react/use-render', - ], + include: ['@base-ui/react/form', '@base-ui/react/merge-props', '@base-ui/react/use-render'], }, }) diff --git a/packages/dify-ui/vitest.setup.ts b/packages/dify-ui/vitest.setup.ts index 285d6e7760bc15..b42c603a1f5406 100644 --- a/packages/dify-ui/vitest.setup.ts +++ b/packages/dify-ui/vitest.setup.ts @@ -1,4 +1,4 @@ -( +;( globalThis as typeof globalThis & { BASE_UI_ANIMATIONS_DISABLED: boolean } diff --git a/packages/iconify-collections/scripts/check-icon-dimensions.ts b/packages/iconify-collections/scripts/check-icon-dimensions.ts index ab933fdf290e01..b517c7d5b5400b 100644 --- a/packages/iconify-collections/scripts/check-icon-dimensions.ts +++ b/packages/iconify-collections/scripts/check-icon-dimensions.ts @@ -79,8 +79,7 @@ const main = async () => { if (failures.length) { console.error('Icon dimension check failed:') - for (const failure of failures) - console.error(`- ${failure}`) + for (const failure of failures) console.error(`- ${failure}`) process.exitCode = 1 return } diff --git a/packages/iconify-collections/scripts/generate-collections.ts b/packages/iconify-collections/scripts/generate-collections.ts index 0a4d969fff3653..38737fed09a557 100644 --- a/packages/iconify-collections/scripts/generate-collections.ts +++ b/packages/iconify-collections/scripts/generate-collections.ts @@ -64,8 +64,12 @@ const flattenCollections = (collections: ImportedCollections, prefix: string) => const applyCollectionSize = <T extends IconData | AliasData>(iconData: T): T => ({ ...iconData, - ...(iconData.width === undefined && collection.width !== undefined ? { width: collection.width } : {}), - ...(iconData.height === undefined && collection.height !== undefined ? { height: collection.height } : {}), + ...(iconData.width === undefined && collection.width !== undefined + ? { width: collection.width } + : {}), + ...(iconData.height === undefined && collection.height !== undefined + ? { height: collection.height } + : {}), }) for (const [iconName, iconData] of Object.entries(collection.icons ?? {})) diff --git a/packages/jotai-tanstack-form/package.json b/packages/jotai-tanstack-form/package.json index 6114c29d6d7de3..01855a4aeffffc 100644 --- a/packages/jotai-tanstack-form/package.json +++ b/packages/jotai-tanstack-form/package.json @@ -1,8 +1,8 @@ { "name": "jotai-tanstack-form", - "type": "module", "version": "0.0.0-private", "private": true, + "type": "module", "exports": { ".": { "types": "./src/index.ts", @@ -13,10 +13,6 @@ "test": "vp test", "type-check": "tsc" }, - "peerDependencies": { - "@tanstack/form-core": "catalog:", - "jotai": "catalog:" - }, "devDependencies": { "@dify/tsconfig": "workspace:*", "@tanstack/form-core": "catalog:", @@ -26,5 +22,9 @@ "vite": "catalog:", "vite-plus": "catalog:", "vitest": "catalog:" + }, + "peerDependencies": { + "@tanstack/form-core": "catalog:", + "jotai": "catalog:" } } diff --git a/packages/jotai-tanstack-form/src/index.spec.ts b/packages/jotai-tanstack-form/src/index.spec.ts index 2af107fa3247ee..87ecd8a65197e1 100644 --- a/packages/jotai-tanstack-form/src/index.spec.ts +++ b/packages/jotai-tanstack-form/src/index.spec.ts @@ -1,9 +1,6 @@ import { createStore } from 'jotai' import { describe, expect, it, vi } from 'vitest' -import { - atomWithForm, - createFormAtoms, -} from './index' +import { atomWithForm, createFormAtoms } from './index' type TestFormValues = { name: string @@ -34,8 +31,7 @@ function createSubmitValidatedFormAtom(onSubmit = vi.fn()) { defaultValues, validators: { onSubmit: ({ value }) => { - if (value.name.trim()) - return undefined + if (value.name.trim()) return undefined return { fields: { @@ -60,8 +56,7 @@ function createChangeAndSubmitValidatedFormAtom(onSubmit = vi.fn()) { defaultValues, validators: { onChange: ({ value }) => { - if (value.name !== 'blocked') - return undefined + if (value.name !== 'blocked') return undefined return { fields: { @@ -70,8 +65,7 @@ function createChangeAndSubmitValidatedFormAtom(onSubmit = vi.fn()) { } }, onSubmit: ({ value }) => { - if (value.name.trim()) - return undefined + if (value.name.trim()) return undefined return { fields: { @@ -200,7 +194,7 @@ describe('jotai-tanstack-form', () => { const unsubscribe = store.sub(formAtoms.stateAtom, () => undefined) await store.set(formAtoms.submitAtom) - store.get(formAtoms.formAtom).api.setFieldMeta('name', prev => ({ + store.get(formAtoms.formAtom).api.setFieldMeta('name', (prev) => ({ ...prev, errorMap: { ...prev.errorMap, diff --git a/packages/jotai-tanstack-form/src/index.ts b/packages/jotai-tanstack-form/src/index.ts index 4d5ea4bf325933..963857b0603dae 100644 --- a/packages/jotai-tanstack-form/src/index.ts +++ b/packages/jotai-tanstack-form/src/index.ts @@ -8,10 +8,7 @@ import type { Updater, ValidationCause, } from '@tanstack/form-core' -import type { - Atom, - WritableAtom, -} from 'jotai' +import type { Atom, WritableAtom } from 'jotai' import { FormApi } from '@tanstack/form-core' import { atom } from 'jotai' import { atomWithLazy } from 'jotai/vanilla/utils' @@ -19,10 +16,7 @@ import { atomWithLazy } from 'jotai/vanilla/utils' type FormValidator<TValues> = FormValidateOrFn<TValues> | undefined type FormAsyncValidator<TValues> = FormAsyncValidateOrFn<TValues> | undefined -export type TanStackFormApi< - TValues, - TSubmitMeta = never, -> = FormApi< +export type TanStackFormApi<TValues, TSubmitMeta = never> = FormApi< TValues, FormValidator<TValues>, FormValidator<TValues>, @@ -39,23 +33,24 @@ export type TanStackFormApi< export type FormState<TValues, TSubmitMeta = never> = TanStackFormApi<TValues, TSubmitMeta>['state'] -type FormOptionsInput<TValues, TSubmitMeta = never> = TanStackFormApi<TValues, TSubmitMeta>['options'] +type FormOptionsInput<TValues, TSubmitMeta = never> = TanStackFormApi< + TValues, + TSubmitMeta +>['options'] export type FormFieldAtomValue<TValue> = { value: TValue meta: AnyFieldLikeMeta | undefined } -export type FormFieldUpdate< - TValues, - TField extends DeepKeys<TValues> = DeepKeys<TValues>, -> = TField extends DeepKeys<TValues> - ? { - name: TField - value: Updater<DeepValue<TValues, TField>> - options?: UpdateMetaOptions - } - : never +export type FormFieldUpdate<TValues, TField extends DeepKeys<TValues> = DeepKeys<TValues>> = + TField extends DeepKeys<TValues> + ? { + name: TField + value: Updater<DeepValue<TValues, TField>> + options?: UpdateMetaOptions + } + : never export type FormAtomInstance<TValues, TSubmitMeta = never> = { api: TanStackFormApi<TValues, TSubmitMeta> @@ -106,32 +101,29 @@ function createFormInstance<TValues, TSubmitMeta = never>( } } -function setFormFieldValue< - TValues, - TSubmitMeta, - TField extends DeepKeys<TValues>, ->( +function setFormFieldValue<TValues, TSubmitMeta, TField extends DeepKeys<TValues>>( form: FormAtomInstance<TValues, TSubmitMeta>, name: TField, value: Updater<DeepValue<TValues, TField>>, options?: UpdateMetaOptions, ) { - const shouldValidate = !options?.dontValidate - && !(options?.dontUpdateMeta && !form.api.getFieldMeta(name)?.isTouched) + const shouldValidate = + !options?.dontValidate && !(options?.dontUpdateMeta && !form.api.getFieldMeta(name)?.isTouched) - form.api.setFieldValue(name, value, shouldValidate ? options : { ...(options ?? {}), dontValidate: true }) + form.api.setFieldValue( + name, + value, + shouldValidate ? options : { ...(options ?? {}), dontValidate: true }, + ) - if (!shouldValidate) - return + if (!shouldValidate) return const fieldMeta = form.api.getFieldMeta(name) - if (!fieldMeta?.errorMap.onSubmit) - return + if (!fieldMeta?.errorMap.onSubmit) return - if (fieldMeta.errorMap.onChange || fieldMeta.errorMap.onDynamic) - return + if (fieldMeta.errorMap.onChange || fieldMeta.errorMap.onDynamic) return - form.api.setFieldMeta(name, prev => ({ + form.api.setFieldMeta(name, (prev) => ({ ...prev, errorMap: { ...prev.errorMap, @@ -194,9 +186,12 @@ export function createFormAtoms<TValues, TSubmitMeta = never>( ) } - const validateAtom = atom<null, [ValidationCause], unknown | Promise<unknown>>(null, (get, _set, cause) => { - return get(formAtom).api.validate(cause) - }) + const validateAtom = atom<null, [ValidationCause], unknown | Promise<unknown>>( + null, + (get, _set, cause) => { + return get(formAtom).api.validate(cause) + }, + ) const submitAtom = atom<null, [], Promise<void>>(null, (get) => { return get(formAtom).api.handleSubmit() diff --git a/packages/migrate-no-unchecked-indexed-access/bin/migrate-no-unchecked-indexed-access.js b/packages/migrate-no-unchecked-indexed-access/bin/migrate-no-unchecked-indexed-access.js index 2f2b0d72a9b2d5..3494b67e43457f 100755 --- a/packages/migrate-no-unchecked-indexed-access/bin/migrate-no-unchecked-indexed-access.js +++ b/packages/migrate-no-unchecked-indexed-access/bin/migrate-no-unchecked-indexed-access.js @@ -10,19 +10,16 @@ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ' const entryFile = path.join(packageRoot, 'dist', 'cli.mjs') if (!fs.existsSync(entryFile)) - throw new Error(`Built CLI entry not found at ${entryFile}. Run "pnpm --filter migrate-no-unchecked-indexed-access build" first.`) + throw new Error( + `Built CLI entry not found at ${entryFile}. Run "pnpm --filter migrate-no-unchecked-indexed-access build" first.`, + ) -const result = spawnSync( - process.execPath, - [entryFile, ...process.argv.slice(2)], - { - cwd: process.cwd(), - env: process.env, - stdio: 'inherit', - }, -) +const result = spawnSync(process.execPath, [entryFile, ...process.argv.slice(2)], { + cwd: process.cwd(), + env: process.env, + stdio: 'inherit', +}) -if (result.error) - throw result.error +if (result.error) throw result.error process.exit(result.status ?? 1) diff --git a/packages/migrate-no-unchecked-indexed-access/package.json b/packages/migrate-no-unchecked-indexed-access/package.json index 4dc5d0acad51e0..c65e5638a35014 100644 --- a/packages/migrate-no-unchecked-indexed-access/package.json +++ b/packages/migrate-no-unchecked-indexed-access/package.json @@ -1,11 +1,11 @@ { "name": "migrate-no-unchecked-indexed-access", - "type": "module", "version": "0.0.0-private", "private": true, "bin": { "migrate-no-unchecked-indexed-access": "./bin/migrate-no-unchecked-indexed-access.js" }, + "type": "module", "scripts": { "build": "vp pack", "type-check": "tsc" diff --git a/packages/migrate-no-unchecked-indexed-access/src/cli.ts b/packages/migrate-no-unchecked-indexed-access/src/cli.ts index 99142c388f7201..1e704b01995ca2 100644 --- a/packages/migrate-no-unchecked-indexed-access/src/cli.ts +++ b/packages/migrate-no-unchecked-indexed-access/src/cli.ts @@ -15,8 +15,8 @@ Options: async function flushStandardStreams() { await Promise.all([ - new Promise<void>(resolve => process.stdout.write('', () => resolve())), - new Promise<void>(resolve => process.stderr.write('', () => resolve())), + new Promise<void>((resolve) => process.stdout.write('', () => resolve())), + new Promise<void>((resolve) => process.stderr.write('', () => resolve())), ]) } @@ -36,8 +36,7 @@ try { await main() const currentExitCode = process.exitCode exitCode = typeof currentExitCode === 'number' ? currentExitCode : 0 -} -catch (error) { +} catch (error) { console.error(error instanceof Error ? error.message : error) exitCode = 1 } diff --git a/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/migrate.ts b/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/migrate.ts index 6b79e214bb4c88..34e09b0a0b8267 100644 --- a/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/migrate.ts +++ b/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/migrate.ts @@ -4,7 +4,9 @@ import process from 'node:process' import ts from 'typescript' const SUPPORTED_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']) -export const SUPPORTED_DIAGNOSTIC_CODES = new Set([2322, 2339, 2345, 2488, 2532, 2538, 2604, 2722, 2769, 2786, 7006, 18047, 18048]) +export const SUPPORTED_DIAGNOSTIC_CODES = new Set([ + 2322, 2339, 2345, 2488, 2532, 2538, 2604, 2722, 2769, 2786, 7006, 18047, 18048, +]) const DEFAULT_MAX_ITERATIONS = 10 const ACCESS_DIAGNOSTIC_CODES = new Set([2339, 2532, 18047, 18048]) const ASSIGNABILITY_DIAGNOSTIC_CODES = new Set([2322, 2345, 2769]) @@ -26,10 +28,20 @@ type TextEdit = { start: number } -type EditTarget - = { expression: ts.Expression, kind: 'expression', sourceFile: ts.SourceFile } - | { end: number, kind: 'direct-edit', replacement: string, sourceFile: ts.SourceFile, start: number } - | { kind: 'shorthand-property', property: ts.ShorthandPropertyAssignment, sourceFile: ts.SourceFile } +type EditTarget = + | { expression: ts.Expression; kind: 'expression'; sourceFile: ts.SourceFile } + | { + end: number + kind: 'direct-edit' + replacement: string + sourceFile: ts.SourceFile + start: number + } + | { + kind: 'shorthand-property' + property: ts.ShorthandPropertyAssignment + sourceFile: ts.SourceFile + } export function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { @@ -42,11 +54,9 @@ export function parseArgs(argv: string[]): CliOptions { for (let i = 0; i < argv.length; i += 1) { const arg = argv[i] - if (!arg) - continue + if (!arg) continue - if (arg === '--') - continue + if (arg === '--') continue if (arg === '--write') { options.write = true @@ -60,8 +70,7 @@ export function parseArgs(argv: string[]): CliOptions { if (arg === '--project') { const value = argv[i + 1] - if (!value) - throw new Error('Missing value for --project') + if (!value) throw new Error('Missing value for --project') options.project = value i += 1 @@ -70,8 +79,7 @@ export function parseArgs(argv: string[]): CliOptions { if (arg === '--max-iterations') { const value = argv[i + 1] - if (!value) - throw new Error('Missing value for --max-iterations') + if (!value) throw new Error('Missing value for --max-iterations') const parsed = Number(value) if (!Number.isInteger(parsed) || parsed <= 0) @@ -84,16 +92,14 @@ export function parseArgs(argv: string[]): CliOptions { if (arg === '--files') { const value = argv[i + 1] - if (!value) - throw new Error('Missing value for --files') + if (!value) throw new Error('Missing value for --files') options.files.push(...splitFilesArgument(value)) i += 1 continue } - if (arg.startsWith('--')) - throw new Error(`Unknown option: ${arg}`) + if (arg.startsWith('--')) throw new Error(`Unknown option: ${arg}`) options.files.push(...splitFilesArgument(arg)) } @@ -104,18 +110,16 @@ export function parseArgs(argv: string[]): CliOptions { function splitFilesArgument(value: string): string[] { return value .split(',') - .map(item => item.trim()) + .map((item) => item.trim()) .filter(Boolean) } function parseTsConfig(projectPath: string): ts.ParsedCommandLine { const cached = parsedConfigCache.get(projectPath) - if (cached) - return cached + if (cached) return cached const configFile = ts.readConfigFile(projectPath, ts.sys.readFile) - if (configFile.error) - throw new Error(formatDiagnostic(configFile.error)) + if (configFile.error) throw new Error(formatDiagnostic(configFile.error)) const configDirectory = path.dirname(projectPath) const parsedConfig = ts.parseJsonConfigFileContent( @@ -144,8 +148,7 @@ function createMigrationProgram( compilerHost.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => { const text = fileTexts.get(fileName) - if (text !== undefined) - return ts.createSourceFile(fileName, text, languageVersion, true) + if (text !== undefined) return ts.createSourceFile(fileName, text, languageVersion, true) return originalGetSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile) } @@ -161,11 +164,9 @@ function createMigrationProgram( function isTargetFile(fileName: string): boolean { const extension = path.extname(fileName) - if (!SUPPORTED_EXTENSIONS.has(extension)) - return false + if (!SUPPORTED_EXTENSIONS.has(extension)) return false - if (fileName.endsWith('.d.ts')) - return false + if (fileName.endsWith('.d.ts')) return false return !fileName.includes(`${path.sep}.next${path.sep}`) } @@ -180,14 +181,16 @@ function isDeclarationSupportFile(fileName: string): boolean { function isSetupSupportFile(fileName: string): boolean { const baseName = path.basename(fileName) - return baseName === 'vitest.setup.ts' - || baseName === 'vitest.setup.tsx' - || baseName === 'jest.setup.ts' - || baseName === 'jest.setup.tsx' - || baseName === 'setupTests.ts' - || baseName === 'setupTests.tsx' - || baseName === 'test.setup.ts' - || baseName === 'test.setup.tsx' + return ( + baseName === 'vitest.setup.ts' || + baseName === 'vitest.setup.tsx' || + baseName === 'jest.setup.ts' || + baseName === 'jest.setup.tsx' || + baseName === 'setupTests.ts' || + baseName === 'setupTests.tsx' || + baseName === 'test.setup.ts' || + baseName === 'test.setup.tsx' + ) } function getMigrationRootNames( @@ -197,32 +200,31 @@ function getMigrationRootNames( const rootNames = new Set(targetFiles) for (const fileName of parsedConfig.fileNames.map(normalizeFileName)) { - if (isDeclarationSupportFile(fileName) || isSetupSupportFile(fileName)) - rootNames.add(fileName) + if (isDeclarationSupportFile(fileName) || isSetupSupportFile(fileName)) rootNames.add(fileName) } return Array.from(rootNames) } function createFileMatcher(filePatterns: string[]): (fileName: string) => boolean { - if (filePatterns.length === 0) - return () => true + if (filePatterns.length === 0) return () => true - const patterns = filePatterns.map(pattern => ({ + const patterns = filePatterns.map((pattern) => ({ absolute: normalizeFileName(pattern), raw: pattern.split(path.sep).join('/'), })) return (fileName: string) => { const normalized = normalizeFileName(fileName) const unixStyle = normalized.split(path.sep).join('/') - return patterns.some(pattern => normalized === pattern.absolute || unixStyle.endsWith(pattern.raw)) + return patterns.some( + (pattern) => normalized === pattern.absolute || unixStyle.endsWith(pattern.raw), + ) } } function formatDiagnostic(diagnostic: ts.Diagnostic): string { const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') - if (!diagnostic.file || diagnostic.start === undefined) - return message + if (!diagnostic.file || diagnostic.start === undefined) return message const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) return `${diagnostic.file.fileName}:${position.line + 1}:${position.character + 1} TS${diagnostic.code}: ${message}` @@ -230,17 +232,14 @@ function formatDiagnostic(diagnostic: ts.Diagnostic): string { function ensureTrailingNonNullAssertion(expression: string): string { const trimmedExpression = expression.trimEnd() - return trimmedExpression.endsWith('!') - ? trimmedExpression - : `${trimmedExpression}!` + return trimmedExpression.endsWith('!') ? trimmedExpression : `${trimmedExpression}!` } function hasOptionalChainDescendant(node: ts.Node): boolean { let found = false const visit = (current: ts.Node) => { - if (found) - return + if (found) return if (ts.isOptionalChain(current)) { found = true @@ -255,19 +254,27 @@ function hasOptionalChainDescendant(node: ts.Node): boolean { } function shouldPrintInlineNonNullAssertion(expression: ts.Expression): boolean { - return ts.isOptionalChain(expression) - || (ts.isParenthesizedExpression(expression) && hasOptionalChainDescendant(expression.expression)) + return ( + ts.isOptionalChain(expression) || + (ts.isParenthesizedExpression(expression) && hasOptionalChainDescendant(expression.expression)) + ) } function normalizeOptionalChainNonNullContinuations(text: string): string { - const sourceFile = ts.createSourceFile('normalize.tsx', text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) + const sourceFile = ts.createSourceFile( + 'normalize.tsx', + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ) const edits: TextEdit[] = [] const visit = (node: ts.Node) => { if ( - ts.isNonNullExpression(node) - && ts.isParenthesizedExpression(node.expression) - && hasOptionalChainDescendant(node.expression.expression) + ts.isNonNullExpression(node) && + ts.isParenthesizedExpression(node.expression) && + hasOptionalChainDescendant(node.expression.expression) ) { edits.push({ end: node.getEnd(), @@ -282,8 +289,7 @@ function normalizeOptionalChainNonNullContinuations(text: string): string { visit(sourceFile) - if (edits.length === 0) - return text + if (edits.length === 0) return text return applyEdits(text, edits).text } @@ -293,27 +299,27 @@ function collapseRepeatedInlineComments(text: string): string { .split('\n') .map((line) => { const commentIndex = line.indexOf('//') - if (commentIndex < 0) - return line + if (commentIndex < 0) return line const prefix = line.slice(0, commentIndex).trimEnd() const comment = line.slice(commentIndex + 2).trim() const segments = comment .split(/\s+\/\/\s+/) - .map(item => item.trim()) + .map((item) => item.trim()) .filter(Boolean) - if (segments.length < 2) - return line + if (segments.length < 2) return line const lastSegment = segments[segments.length - 1]! const stableSegments = segments.slice(0, -1) - const repeatedSameComment = stableSegments.length > 0 - && stableSegments.every(segment => segment === segments[0]) - && (lastSegment === segments[0] || segments[0]!.startsWith(lastSegment) || lastSegment.startsWith(segments[0]!)) + const repeatedSameComment = + stableSegments.length > 0 && + stableSegments.every((segment) => segment === segments[0]) && + (lastSegment === segments[0] || + segments[0]!.startsWith(lastSegment) || + lastSegment.startsWith(segments[0]!)) - if (!repeatedSameComment) - return line.replace(/!{2,}$/g, '!') + if (!repeatedSameComment) return line.replace(/!{2,}$/g, '!') const normalizedComment = segments[0]!.replace(/!{2,}$/g, '!') return prefix ? `${prefix} // ${normalizedComment}` : `// ${normalizedComment}` @@ -330,18 +336,22 @@ export function normalizeMalformedAssertions(text: string): string { .replace(/\b([A-Z_$][\w$]*)!!,/gi, '$1: $1!,') .replace(/\b([A-Z_$][\w$]*)!!:/gi, '$1:') .replace(/([,{]\s*)([A-Z_$][\w$]*)!:/gi, '$1$2:') - .replace(/\b(const|let|var)\s+\{([^=\n]+)\}\s*=\s*([^\n;]+)/g, (fullMatch, keyword: string, bindings: string, expression: string) => { - if (!bindings.includes('!')) - return fullMatch + .replace( + /\b(const|let|var)\s+\{([^=\n]+)\}\s*=\s*([^\n;]+)/g, + (fullMatch, keyword: string, bindings: string, expression: string) => { + if (!bindings.includes('!')) return fullMatch - const normalizedBindings = bindings.replace(/!([,\s}:])/g, '$1') - return `${keyword} {${normalizedBindings}} = ${ensureTrailingNonNullAssertion(expression)}` - }) + const normalizedBindings = bindings.replace(/!([,\s}:])/g, '$1') + return `${keyword} {${normalizedBindings}} = ${ensureTrailingNonNullAssertion(expression)}` + }, + ) return collapseRepeatedInlineComments(normalizeOptionalChainNonNullContinuations(normalizedText)) } -function isExpressionTarget(target: EditTarget): target is Extract<EditTarget, { kind: 'expression' }> { +function isExpressionTarget( + target: EditTarget, +): target is Extract<EditTarget, { kind: 'expression' }> { return target.kind === 'expression' } @@ -403,14 +413,11 @@ function createArrayLiteralIterableFallbackTarget( const edits: TextEdit[] = [] for (const element of arrayLiteral.elements) { - if (!ts.isSpreadElement(element)) - continue + if (!ts.isSpreadElement(element)) continue - if (isAlreadyNonNull(element.expression)) - continue + if (isAlreadyNonNull(element.expression)) continue - if (!typeIncludesUndefined(checker.getTypeAtLocation(element.expression))) - continue + if (!typeIncludesUndefined(checker.getTypeAtLocation(element.expression))) continue edits.push({ end: element.expression.getEnd() - start, @@ -419,15 +426,9 @@ function createArrayLiteralIterableFallbackTarget( }) } - if (edits.length === 0) - return undefined + if (edits.length === 0) return undefined - return createDirectEditTarget( - sourceFile, - start, - end, - applyEdits(originalText, edits).text, - ) + return createDirectEditTarget(sourceFile, start, end, applyEdits(originalText, edits).text) } function getTokenAtPosition(sourceFile: ts.SourceFile, position: number): ts.Node { @@ -436,12 +437,10 @@ function getTokenAtPosition(sourceFile: ts.SourceFile, position: number): ts.Nod while (true) { let next: ts.Node | undefined current.forEachChild((child) => { - if (!next && position >= child.getFullStart() && position < child.getEnd()) - next = child + if (!next && position >= child.getFullStart() && position < child.getEnd()) next = child }) - if (!next) - return current + if (!next) return current current = next } @@ -454,8 +453,7 @@ function findAncestor<NodeType extends ts.Node>( let current = node while (current) { - if (predicate(current)) - return current + if (predicate(current)) return current current = current.parent } @@ -463,15 +461,18 @@ function findAncestor<NodeType extends ts.Node>( return undefined } -function findTightestExpression(sourceFile: ts.SourceFile, start: number, end: number): ts.Expression | undefined { +function findTightestExpression( + sourceFile: ts.SourceFile, + start: number, + end: number, +): ts.Expression | undefined { let node: ts.Node | undefined = getTokenAtPosition(sourceFile, start) while (node) { if (ts.isExpression(node)) { const nodeStart = node.getStart(sourceFile) const nodeEnd = node.getEnd() - if (nodeStart <= start && end <= nodeEnd) - return node + if (nodeStart <= start && end <= nodeEnd) return node } node = node.parent @@ -485,11 +486,9 @@ function isAssignmentOperator(token: ts.SyntaxKind): boolean { } function typeIncludesUndefined(type: ts.Type): boolean { - if ((type.flags & ts.TypeFlags.Undefined) !== 0) - return true + if ((type.flags & ts.TypeFlags.Undefined) !== 0) return true - if (!type.isUnion()) - return false + if (!type.isUnion()) return false return type.types.some(typeIncludesUndefined) } @@ -506,8 +505,7 @@ function skipOuterExpressions(expression: ts.Expression): ts.Expression { function isAlreadyNonNull(expression: ts.Expression): boolean { let current = expression - while (ts.isParenthesizedExpression(current)) - current = current.expression + while (ts.isParenthesizedExpression(current)) current = current.expression return ts.isNonNullExpression(current) } @@ -521,32 +519,30 @@ function findAssignmentLikeCandidate( let current: ts.Node | undefined = token while (current) { - if (ts.isVariableDeclaration(current) && current.initializer) - return current.initializer + if (ts.isVariableDeclaration(current) && current.initializer) return current.initializer - if (ts.isPropertyDeclaration(current) && current.initializer) - return current.initializer + if (ts.isPropertyDeclaration(current) && current.initializer) return current.initializer - if (ts.isPropertyAssignment(current)) - return current.initializer + if (ts.isPropertyAssignment(current)) return current.initializer - if (ts.isShorthandPropertyAssignment(current)) - return current.name + if (ts.isShorthandPropertyAssignment(current)) return current.name - if (ts.isParameter(current) && current.initializer) - return current.initializer + if (ts.isParameter(current) && current.initializer) return current.initializer - if (ts.isReturnStatement(current) && current.expression) - return current.expression + if (ts.isReturnStatement(current) && current.expression) return current.expression if (ts.isBinaryExpression(current) && isAssignmentOperator(current.operatorToken.kind)) return current.right - if (ts.isJsxAttribute(current) && current.initializer && ts.isJsxExpression(current.initializer) && current.initializer.expression) + if ( + ts.isJsxAttribute(current) && + current.initializer && + ts.isJsxExpression(current.initializer) && + current.initializer.expression + ) return current.initializer.expression - if (ts.isJsxSpreadAttribute(current)) - return current.expression + if (ts.isJsxSpreadAttribute(current)) return current.expression current = current.parent } @@ -569,8 +565,7 @@ function findArgumentCandidate( const itemEnd = item.getEnd() return itemStart <= start && end <= itemEnd }) - if (argument) - return argument + if (argument) return argument } current = current.parent @@ -589,60 +584,61 @@ function findTargetFromExpression( expression: ts.Expression, checker: ts.TypeChecker, ): EditTarget | undefined { - const referencedDeclarationTarget = findReferencedDeclarationInitializerTarget(expression, checker) - if (referencedDeclarationTarget) - return referencedDeclarationTarget + const referencedDeclarationTarget = findReferencedDeclarationInitializerTarget( + expression, + checker, + ) + if (referencedDeclarationTarget) return referencedDeclarationTarget const nestedTarget = findNestedContainerTarget(expression, checker) - if (nestedTarget) - return nestedTarget + if (nestedTarget) return nestedTarget const innerExpression = skipOuterExpressions(expression) if (ts.isConditionalExpression(innerExpression)) { - return findTargetFromExpression(innerExpression.whenTrue, checker) - ?? findTargetFromExpression(innerExpression.whenFalse, checker) + return ( + findTargetFromExpression(innerExpression.whenTrue, checker) ?? + findTargetFromExpression(innerExpression.whenFalse, checker) + ) } if ( - ts.isBinaryExpression(innerExpression) - && ( - innerExpression.operatorToken.kind === ts.SyntaxKind.BarBarToken - || innerExpression.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken - || innerExpression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken - ) + ts.isBinaryExpression(innerExpression) && + (innerExpression.operatorToken.kind === ts.SyntaxKind.BarBarToken || + innerExpression.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken || + innerExpression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) ) { - return findTargetFromExpression(innerExpression.left, checker) - ?? findTargetFromExpression(innerExpression.right, checker) + return ( + findTargetFromExpression(innerExpression.left, checker) ?? + findTargetFromExpression(innerExpression.right, checker) + ) } if (ts.isArrowFunction(innerExpression) || ts.isFunctionExpression(innerExpression)) { const functionTarget = findFunctionLikeReturnTarget(innerExpression, checker) - if (functionTarget) - return functionTarget + if (functionTarget) return functionTarget } if (ts.isPropertyAccessExpression(innerExpression)) { - const namedPropertyTarget = findNamedPropertyTarget(innerExpression.expression, innerExpression.name.text, checker) - if (namedPropertyTarget) - return namedPropertyTarget + const namedPropertyTarget = findNamedPropertyTarget( + innerExpression.expression, + innerExpression.name.text, + checker, + ) + if (namedPropertyTarget) return namedPropertyTarget } if (ts.isCallExpression(innerExpression)) { const collectionCallbackTarget = findCollectionCallbackTarget(innerExpression, checker) - if (collectionCallbackTarget) - return collectionCallbackTarget + if (collectionCallbackTarget) return collectionCallbackTarget const callbackArgumentTarget = findCallbackArgumentTarget(innerExpression, checker) - if (callbackArgumentTarget) - return callbackArgumentTarget + if (callbackArgumentTarget) return callbackArgumentTarget const callExpressionTarget = findCallExpressionDeclarationTarget(innerExpression, checker) - if (callExpressionTarget) - return callExpressionTarget + if (callExpressionTarget) return callExpressionTarget } - if (!typeIncludesUndefined(checker.getTypeAtLocation(expression))) - return undefined + if (!typeIncludesUndefined(checker.getTypeAtLocation(expression))) return undefined return createExpressionTarget(expression) } @@ -652,22 +648,20 @@ function findJsxSpreadAttributeTarget( checker: ts.TypeChecker, ): EditTarget | undefined { const spreadAttribute = findAncestor(token, ts.isJsxSpreadAttribute) - if (spreadAttribute) - return findTargetFromExpression(spreadAttribute.expression, checker) + if (spreadAttribute) return findTargetFromExpression(spreadAttribute.expression, checker) - const openingLikeElement = findAncestor(token, node => - ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) + const openingLikeElement = findAncestor( + token, + (node) => ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node), + ) - if (!openingLikeElement) - return undefined + if (!openingLikeElement) return undefined for (const attribute of openingLikeElement.attributes.properties) { - if (!ts.isJsxSpreadAttribute(attribute)) - continue + if (!ts.isJsxSpreadAttribute(attribute)) continue const target = findTargetFromExpression(attribute.expression, checker) - if (target) - return target + if (target) return target } return undefined @@ -678,8 +672,7 @@ function findShorthandPropertyTarget( checker: ts.TypeChecker, ): EditTarget | undefined { const property = findAncestor(token, ts.isShorthandPropertyAssignment) - if (!property) - return undefined + if (!property) return undefined return typeIncludesUndefined(checker.getTypeAtLocation(property.name)) ? createShorthandPropertyTarget(property) @@ -692,21 +685,17 @@ function findPropertyAssignmentInitializerTarget( checker: ts.TypeChecker, ): EditTarget | undefined { const propertyAssignment = findAncestor(token, ts.isPropertyAssignment) - if (!propertyAssignment) - return undefined + if (!propertyAssignment) return undefined const propertyNameStart = propertyAssignment.name.getStart() const propertyNameEnd = propertyAssignment.name.getEnd() - if (start < propertyNameStart || start >= propertyNameEnd) - return undefined + if (start < propertyNameStart || start >= propertyNameEnd) return undefined const directTarget = findTargetFromExpression(propertyAssignment.initializer, checker) - if (directTarget) - return directTarget + if (directTarget) return directTarget const nestedTarget = findNestedContainerTarget(propertyAssignment.initializer, checker) - if (nestedTarget) - return nestedTarget + if (nestedTarget) return nestedTarget if (!typeIncludesUndefined(checker.getTypeAtLocation(propertyAssignment.initializer))) return undefined @@ -714,13 +703,9 @@ function findPropertyAssignmentInitializerTarget( return createExpressionTarget(propertyAssignment.initializer) } -function findPropertyAccessExpressionTarget( - token: ts.Node, - start: number, -): EditTarget | undefined { +function findPropertyAccessExpressionTarget(token: ts.Node, start: number): EditTarget | undefined { const propertyAccess = findAncestor(token, ts.isPropertyAccessExpression) - if (!propertyAccess) - return undefined + if (!propertyAccess) return undefined if (start >= propertyAccess.name.getStart() && start < propertyAccess.name.getEnd()) return createExpressionTarget(propertyAccess.expression) @@ -738,13 +723,19 @@ function findUndefinedAccessTarget( while (current) { if (ts.isPropertyAccessExpression(current)) { const expression = current.expression - if (typeIncludesUndefined(checker.getTypeAtLocation(expression)) && !isAlreadyNonNull(expression)) + if ( + typeIncludesUndefined(checker.getTypeAtLocation(expression)) && + !isAlreadyNonNull(expression) + ) bestTarget = createExpressionTarget(expression) } if (ts.isElementAccessExpression(current)) { const expression = current.expression - if (typeIncludesUndefined(checker.getTypeAtLocation(expression)) && !isAlreadyNonNull(expression)) + if ( + typeIncludesUndefined(checker.getTypeAtLocation(expression)) && + !isAlreadyNonNull(expression) + ) bestTarget = createExpressionTarget(expression) } @@ -765,8 +756,7 @@ function findElementAccessArgumentTarget(token: ts.Node): EditTarget | undefined current = current.parent } - if (!matchingElementAccess?.argumentExpression) - return undefined + if (!matchingElementAccess?.argumentExpression) return undefined return createExpressionTarget(matchingElementAccess.argumentExpression) } @@ -781,8 +771,7 @@ function findIterableTarget( const arrayLiteral = findAncestor(token, ts.isArrayLiteralExpression) if (arrayLiteral) { const arrayLiteralTarget = createArrayLiteralIterableFallbackTarget(arrayLiteral, checker) - if (arrayLiteralTarget) - return arrayLiteralTarget + if (arrayLiteralTarget) return arrayLiteralTarget } const spreadElement = findAncestor(token, ts.isSpreadElement) @@ -791,19 +780,19 @@ function findIterableTarget( const variableDeclaration = findAncestor(token, ts.isVariableDeclaration) if ( - variableDeclaration?.initializer - && typeIncludesUndefined(checker.getTypeAtLocation(variableDeclaration.initializer)) - && !isAlreadyNonNull(variableDeclaration.initializer) + variableDeclaration?.initializer && + typeIncludesUndefined(checker.getTypeAtLocation(variableDeclaration.initializer)) && + !isAlreadyNonNull(variableDeclaration.initializer) ) { return createExpressionTarget(variableDeclaration.initializer) } const binaryExpression = findAncestor(token, ts.isBinaryExpression) if ( - binaryExpression - && isAssignmentOperator(binaryExpression.operatorToken.kind) - && typeIncludesUndefined(checker.getTypeAtLocation(binaryExpression.right)) - && !isAlreadyNonNull(binaryExpression.right) + binaryExpression && + isAssignmentOperator(binaryExpression.operatorToken.kind) && + typeIncludesUndefined(checker.getTypeAtLocation(binaryExpression.right)) && + !isAlreadyNonNull(binaryExpression.right) ) { return createExpressionTarget(binaryExpression.right) } @@ -813,13 +802,13 @@ function findIterableTarget( function findImplicitAnyParameterTarget(token: ts.Node): EditTarget | undefined { const parameter = findAncestor(token, ts.isParameter) - if (!parameter || parameter.type || !ts.isIdentifier(parameter.name)) - return undefined + if (!parameter || parameter.type || !ts.isIdentifier(parameter.name)) return undefined const sourceFile = parameter.getSourceFile() - const replacement = ts.isArrowFunction(parameter.parent) && parameter.parent.parameters.length === 1 - ? `(${parameter.name.getText(sourceFile)}: any)` - : `${parameter.name.getText(sourceFile)}: any` + const replacement = + ts.isArrowFunction(parameter.parent) && parameter.parent.parameters.length === 1 + ? `(${parameter.name.getText(sourceFile)}: any)` + : `${parameter.name.getText(sourceFile)}: any` return createDirectEditTarget( sourceFile, @@ -833,12 +822,9 @@ function getArrayPatternElementTypeText( element: ts.ArrayBindingElement | ts.Expression, checker: ts.TypeChecker, ): string { - if (ts.isOmittedExpression(element)) - return 'unknown' + if (ts.isOmittedExpression(element)) return 'unknown' - const targetNode = ts.isBindingElement(element) - ? element.name - : element + const targetNode = ts.isBindingElement(element) ? element.name : element const targetType = checker.getNonNullableType(checker.getTypeAtLocation(targetNode)) const typeText = checker.typeToString(targetType) @@ -854,10 +840,9 @@ function createArrayDestructuringReplacement( fallbackToEmptyArray?: boolean }, ): string | undefined { - if (elements.length === 0) - return undefined + if (elements.length === 0) return undefined - const tupleTypes = elements.map(element => getArrayPatternElementTypeText(element, checker)) + const tupleTypes = elements.map((element) => getArrayPatternElementTypeText(element, checker)) const expressionText = options?.fallbackToEmptyArray ? `(${expression.getText(sourceFile)} ?? [])` : `(${expression.getText(sourceFile)})` @@ -869,14 +854,20 @@ function findArrayDestructuringTarget( checker: ts.TypeChecker, ): EditTarget | undefined { const binaryExpression = findAncestor(token, ts.isBinaryExpression) - if (binaryExpression && isAssignmentOperator(binaryExpression.operatorToken.kind) && ts.isArrayLiteralExpression(binaryExpression.left)) { + if ( + binaryExpression && + isAssignmentOperator(binaryExpression.operatorToken.kind) && + ts.isArrayLiteralExpression(binaryExpression.left) + ) { const replacement = createArrayDestructuringReplacement( binaryExpression.getSourceFile(), binaryExpression.right, binaryExpression.left.elements, checker, { - fallbackToEmptyArray: typeIncludesUndefined(checker.getTypeAtLocation(binaryExpression.right)), + fallbackToEmptyArray: typeIncludesUndefined( + checker.getTypeAtLocation(binaryExpression.right), + ), }, ) if (replacement) { @@ -897,7 +888,9 @@ function findArrayDestructuringTarget( variableDeclaration.name.elements, checker, { - fallbackToEmptyArray: typeIncludesUndefined(checker.getTypeAtLocation(variableDeclaration.initializer)), + fallbackToEmptyArray: typeIncludesUndefined( + checker.getTypeAtLocation(variableDeclaration.initializer), + ), }, ) if (replacement) { @@ -919,12 +912,10 @@ function findVariableDeclarationInitializerTarget( checker: ts.TypeChecker, ): EditTarget | undefined { const variableDeclaration = findAncestor(token, ts.isVariableDeclaration) - if (!variableDeclaration?.initializer) - return undefined + if (!variableDeclaration?.initializer) return undefined const nestedTarget = findNestedContainerTarget(variableDeclaration.initializer, checker) - if (nestedTarget) - return nestedTarget + if (nestedTarget) return nestedTarget if (!typeIncludesUndefined(checker.getTypeAtLocation(variableDeclaration.initializer))) return undefined @@ -936,12 +927,10 @@ function getResolvedValueDeclaration( symbol: ts.Symbol | undefined, checker: ts.TypeChecker, ): ts.Declaration | undefined { - if (!symbol) - return undefined + if (!symbol) return undefined - const resolvedSymbol = symbol.flags & ts.SymbolFlags.Alias - ? checker.getAliasedSymbol(symbol) - : symbol + const resolvedSymbol = + symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol return resolvedSymbol.valueDeclaration ?? resolvedSymbol.declarations?.[0] } @@ -950,18 +939,19 @@ function getFunctionLikeDeclaration( declaration: ts.Declaration, ): ts.FunctionLikeDeclarationBase | undefined { if ( - ts.isFunctionDeclaration(declaration) - || ts.isMethodDeclaration(declaration) - || ts.isFunctionExpression(declaration) - || ts.isArrowFunction(declaration) + ts.isFunctionDeclaration(declaration) || + ts.isMethodDeclaration(declaration) || + ts.isFunctionExpression(declaration) || + ts.isArrowFunction(declaration) ) { return declaration } if ( - ts.isVariableDeclaration(declaration) - && declaration.initializer - && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)) + ts.isVariableDeclaration(declaration) && + declaration.initializer && + (ts.isArrowFunction(declaration.initializer) || + ts.isFunctionExpression(declaration.initializer)) ) { return declaration.initializer } @@ -976,12 +966,17 @@ function getPropertyNameText(name: ts.PropertyName | ts.BindingName): string | u return undefined } -function getCallExpressionPropertyAccess(callExpression: ts.CallExpression): ts.PropertyAccessExpression | undefined { +function getCallExpressionPropertyAccess( + callExpression: ts.CallExpression, +): ts.PropertyAccessExpression | undefined { const callee = skipOuterExpressions(callExpression.expression) return ts.isPropertyAccessExpression(callee) ? callee : undefined } -function getFunctionExpressionArgument(callExpression: ts.CallExpression, index = 0): ts.ArrowFunction | ts.FunctionExpression | undefined { +function getFunctionExpressionArgument( + callExpression: ts.CallExpression, + index = 0, +): ts.ArrowFunction | ts.FunctionExpression | undefined { const callback = callExpression.arguments[index] return callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) ? callback @@ -995,8 +990,7 @@ function findTargetInFunctionBody( if (ts.isBlock(body)) { for (const expression of findReturnStatementExpressions(body)) { const target = resolveExpression(expression) - if (target) - return target + if (target) return target } return undefined @@ -1010,9 +1004,12 @@ function getParameterCollectionExpression( ): ts.Expression | undefined { const functionLikeDeclaration = declaration.parent if ( - !(ts.isArrowFunction(functionLikeDeclaration) || ts.isFunctionExpression(functionLikeDeclaration)) - || !ts.isCallExpression(functionLikeDeclaration.parent) - || functionLikeDeclaration.parent.arguments[0] !== functionLikeDeclaration + !( + ts.isArrowFunction(functionLikeDeclaration) || + ts.isFunctionExpression(functionLikeDeclaration) + ) || + !ts.isCallExpression(functionLikeDeclaration.parent) || + functionLikeDeclaration.parent.arguments[0] !== functionLikeDeclaration ) { return undefined } @@ -1027,19 +1024,19 @@ function findObjectLiteralNamedPropertyTarget( checker: ts.TypeChecker, ): EditTarget | undefined { for (const property of objectLiteral.properties) { - if (ts.isSpreadAssignment(property)) - continue + if (ts.isSpreadAssignment(property)) continue if (ts.isShorthandPropertyAssignment(property) && property.name.text === propertyName) return createShorthandPropertyTarget(property) if (ts.isPropertyAssignment(property)) { const currentPropertyName = getPropertyNameText(property.name) - if (currentPropertyName !== propertyName) - continue + if (currentPropertyName !== propertyName) continue - return findTargetFromExpression(property.initializer, checker) - ?? createExpressionTarget(property.initializer) + return ( + findTargetFromExpression(property.initializer, checker) ?? + createExpressionTarget(property.initializer) + ) } } @@ -1051,12 +1048,10 @@ function findFunctionLikeNamedReturnTarget( propertyName: string, checker: ts.TypeChecker, ): EditTarget | undefined { - if (!declaration.body) - return undefined + if (!declaration.body) return undefined - return findTargetInFunctionBody( - declaration.body, - expression => findNamedPropertyTarget(expression, propertyName, checker), + return findTargetInFunctionBody(declaration.body, (expression) => + findNamedPropertyTarget(expression, propertyName, checker), ) } @@ -1070,21 +1065,17 @@ function findCollectionPropertyTarget( if (ts.isIdentifier(innerExpression)) return findNamedPropertyTarget(innerExpression, propertyName, checker) - if (!ts.isCallExpression(innerExpression)) - return undefined + if (!ts.isCallExpression(innerExpression)) return undefined const callee = getCallExpressionPropertyAccess(innerExpression) - if (!callee) - return undefined + if (!callee) return undefined if (callee.name.text === 'map' || callee.name.text === 'flatMap') { const callback = getFunctionExpressionArgument(innerExpression) - if (!callback) - return undefined + if (!callback) return undefined - return findTargetInFunctionBody( - callback.body, - returnedExpression => findNamedPropertyTarget(returnedExpression, propertyName, checker), + return findTargetInFunctionBody(callback.body, (returnedExpression) => + findNamedPropertyTarget(returnedExpression, propertyName, checker), ) } @@ -1105,9 +1096,11 @@ function findNamedPropertyTarget( return findObjectLiteralNamedPropertyTarget(innerExpression, propertyName, checker) if (ts.isIdentifier(innerExpression)) { - const declaration = getResolvedValueDeclaration(checker.getSymbolAtLocation(innerExpression), checker) - if (!declaration) - return undefined + const declaration = getResolvedValueDeclaration( + checker.getSymbolAtLocation(innerExpression), + checker, + ) + if (!declaration) return undefined if (ts.isParameter(declaration)) { const collectionExpression = getParameterCollectionExpression(declaration) @@ -1126,17 +1119,21 @@ function findNamedPropertyTarget( } if (ts.isCallExpression(innerExpression)) { - const collectionPropertyTarget = findCollectionPropertyTarget(innerExpression, propertyName, checker) - if (collectionPropertyTarget) - return collectionPropertyTarget + const collectionPropertyTarget = findCollectionPropertyTarget( + innerExpression, + propertyName, + checker, + ) + if (collectionPropertyTarget) return collectionPropertyTarget - const declaration = getResolvedValueDeclaration(checker.getSymbolAtLocation(skipOuterExpressions(innerExpression.expression)), checker) - if (!declaration) - return undefined + const declaration = getResolvedValueDeclaration( + checker.getSymbolAtLocation(skipOuterExpressions(innerExpression.expression)), + checker, + ) + if (!declaration) return undefined const functionLikeDeclaration = getFunctionLikeDeclaration(declaration) - if (!functionLikeDeclaration) - return undefined + if (!functionLikeDeclaration) return undefined return findFunctionLikeNamedReturnTarget(functionLikeDeclaration, propertyName, checker) } @@ -1149,19 +1146,16 @@ function findReturnStatementExpressions(node: ts.Node): ts.Expression[] { const visit = (current: ts.Node) => { if ( - current !== node - && ( - ts.isArrowFunction(current) - || ts.isFunctionExpression(current) - || ts.isFunctionDeclaration(current) - || ts.isMethodDeclaration(current) - ) + current !== node && + (ts.isArrowFunction(current) || + ts.isFunctionExpression(current) || + ts.isFunctionDeclaration(current) || + ts.isMethodDeclaration(current)) ) { return } - if (ts.isReturnStatement(current) && current.expression) - expressions.push(current.expression) + if (ts.isReturnStatement(current) && current.expression) expressions.push(current.expression) current.forEachChild(visit) } @@ -1174,12 +1168,10 @@ function findFunctionLikeReturnTarget( declaration: ts.FunctionLikeDeclarationBase, checker: ts.TypeChecker, ): EditTarget | undefined { - if (!declaration.body) - return undefined + if (!declaration.body) return undefined - return findTargetInFunctionBody( - declaration.body, - expression => findTargetFromExpression(expression, checker), + return findTargetInFunctionBody(declaration.body, (expression) => + findTargetFromExpression(expression, checker), ) } @@ -1187,13 +1179,14 @@ function findCallExpressionDeclarationTarget( callExpression: ts.CallExpression, checker: ts.TypeChecker, ): EditTarget | undefined { - const declaration = getResolvedValueDeclaration(checker.getSymbolAtLocation(skipOuterExpressions(callExpression.expression)), checker) - if (!declaration) - return undefined + const declaration = getResolvedValueDeclaration( + checker.getSymbolAtLocation(skipOuterExpressions(callExpression.expression)), + checker, + ) + if (!declaration) return undefined const functionLikeDeclaration = getFunctionLikeDeclaration(declaration) - if (!functionLikeDeclaration) - return undefined + if (!functionLikeDeclaration) return undefined return findFunctionLikeReturnTarget(functionLikeDeclaration, checker) } @@ -1203,14 +1196,14 @@ function findCallbackArgumentTarget( checker: ts.TypeChecker, ): EditTarget | undefined { const callee = skipOuterExpressions(callExpression.expression) - const calleeName = ts.isIdentifier(callee) ? callee.text : getCallExpressionPropertyAccess(callExpression)?.name.text + const calleeName = ts.isIdentifier(callee) + ? callee.text + : getCallExpressionPropertyAccess(callExpression)?.name.text - if (calleeName !== 'useCallback' && calleeName !== 'useMemo') - return undefined + if (calleeName !== 'useCallback' && calleeName !== 'useMemo') return undefined const callback = getFunctionExpressionArgument(callExpression) - if (!callback) - return undefined + if (!callback) return undefined return findFunctionLikeReturnTarget(callback, checker) } @@ -1220,12 +1213,13 @@ function findReferencedDeclarationInitializerTarget( checker: ts.TypeChecker, ): EditTarget | undefined { const innerExpression = skipOuterExpressions(expression) - if (!ts.isIdentifier(innerExpression)) - return undefined + if (!ts.isIdentifier(innerExpression)) return undefined - const declaration = getResolvedValueDeclaration(checker.getSymbolAtLocation(innerExpression), checker) - if (!declaration) - return undefined + const declaration = getResolvedValueDeclaration( + checker.getSymbolAtLocation(innerExpression), + checker, + ) + if (!declaration) return undefined if (ts.isBindingElement(declaration)) { const propertyName = declaration.propertyName @@ -1233,10 +1227,17 @@ function findReferencedDeclarationInitializerTarget( : getPropertyNameText(declaration.name) const variableDeclaration = declaration.parent.parent - if (propertyName && ts.isVariableDeclaration(variableDeclaration) && variableDeclaration.initializer) { - const namedPropertyTarget = findNamedPropertyTarget(variableDeclaration.initializer, propertyName, checker) - if (namedPropertyTarget) - return namedPropertyTarget + if ( + propertyName && + ts.isVariableDeclaration(variableDeclaration) && + variableDeclaration.initializer + ) { + const namedPropertyTarget = findNamedPropertyTarget( + variableDeclaration.initializer, + propertyName, + checker, + ) + if (namedPropertyTarget) return namedPropertyTarget } } @@ -1244,35 +1245,28 @@ function findReferencedDeclarationInitializerTarget( const collectionExpression = getParameterCollectionExpression(declaration) if (collectionExpression) { const collectionTarget = findTargetFromExpression(collectionExpression, checker) - if (collectionTarget) - return collectionTarget + if (collectionTarget) return collectionTarget } } const functionLikeDeclaration = getFunctionLikeDeclaration(declaration) if (functionLikeDeclaration) { const functionTarget = findFunctionLikeReturnTarget(functionLikeDeclaration, checker) - if (functionTarget) - return functionTarget + if (functionTarget) return functionTarget } - if (!ts.isVariableDeclaration(declaration) || !declaration.initializer) - return undefined + if (!ts.isVariableDeclaration(declaration) || !declaration.initializer) return undefined const collectionCallbackTarget = findCollectionCallbackTarget(declaration.initializer, checker) - if (collectionCallbackTarget) - return collectionCallbackTarget + if (collectionCallbackTarget) return collectionCallbackTarget const initializerTarget = findTargetFromExpression(declaration.initializer, checker) - if (initializerTarget) - return initializerTarget + if (initializerTarget) return initializerTarget const nestedTarget = findNestedContainerTarget(declaration.initializer, checker) - if (nestedTarget) - return nestedTarget + if (nestedTarget) return nestedTarget - if (!typeIncludesUndefined(checker.getTypeAtLocation(declaration.initializer))) - return undefined + if (!typeIncludesUndefined(checker.getTypeAtLocation(declaration.initializer))) return undefined return createExpressionTarget(declaration.initializer) } @@ -1282,19 +1276,15 @@ function findCollectionCallbackTarget( checker: ts.TypeChecker, ): EditTarget | undefined { const innerExpression = skipOuterExpressions(expression) - if (!ts.isCallExpression(innerExpression)) - return undefined + if (!ts.isCallExpression(innerExpression)) return undefined const callee = getCallExpressionPropertyAccess(innerExpression) - if (!callee) - return undefined + if (!callee) return undefined - if (callee.name.text !== 'map' && callee.name.text !== 'flatMap') - return undefined + if (callee.name.text !== 'map' && callee.name.text !== 'flatMap') return undefined const callback = getFunctionExpressionArgument(innerExpression) - if (!callback) - return undefined + if (!callback) return undefined return findFunctionLikeReturnTarget(callback, checker) } @@ -1303,22 +1293,21 @@ function findJsxComponentDeclarationTarget( token: ts.Node, checker: ts.TypeChecker, ): EditTarget | undefined { - const openingLikeElement = findAncestor(token, node => - ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) - if (!openingLikeElement) - return undefined + const openingLikeElement = findAncestor( + token, + (node) => ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node), + ) + if (!openingLikeElement) return undefined const tagName = openingLikeElement.tagName - if (!ts.isIdentifier(tagName)) - return undefined + if (!ts.isIdentifier(tagName)) return undefined const symbol = checker.getSymbolAtLocation(tagName) const declaration = symbol?.valueDeclaration if (!declaration || !ts.isVariableDeclaration(declaration) || !declaration.initializer) return undefined - if (!typeIncludesUndefined(checker.getTypeAtLocation(declaration.initializer))) - return undefined + if (!typeIncludesUndefined(checker.getTypeAtLocation(declaration.initializer))) return undefined return createExpressionTarget(declaration.initializer) } @@ -1330,8 +1319,7 @@ function findObjectLiteralPropertyTarget( for (const property of objectLiteral.properties) { if (ts.isSpreadAssignment(property)) { const directTarget = findTargetFromExpression(property.expression, checker) - if (directTarget) - return directTarget + if (directTarget) return directTarget if (typeIncludesUndefined(checker.getTypeAtLocation(property.expression))) return createExpressionTarget(property.expression) @@ -1346,12 +1334,10 @@ function findObjectLiteralPropertyTarget( if (ts.isPropertyAssignment(property)) { const directTarget = findTargetFromExpression(property.initializer, checker) - if (directTarget) - return directTarget + if (directTarget) return directTarget const nestedTarget = findNestedContainerTarget(property.initializer, checker) - if (nestedTarget) - return nestedTarget + if (nestedTarget) return nestedTarget if (typeIncludesUndefined(checker.getTypeAtLocation(property.initializer))) return createExpressionTarget(property.initializer) @@ -1366,14 +1352,12 @@ function findArrayLiteralElementTarget( checker: ts.TypeChecker, ): EditTarget | undefined { const iterableFallbackTarget = createArrayLiteralIterableFallbackTarget(arrayLiteral, checker) - if (iterableFallbackTarget) - return iterableFallbackTarget + if (iterableFallbackTarget) return iterableFallbackTarget for (const element of arrayLiteral.elements) { if (ts.isSpreadElement(element)) { const directTarget = findTargetFromExpression(element.expression, checker) - if (directTarget) - return directTarget + if (directTarget) return directTarget if (typeIncludesUndefined(checker.getTypeAtLocation(element.expression))) return createExpressionTarget(element.expression) @@ -1381,12 +1365,10 @@ function findArrayLiteralElementTarget( } const directTarget = findTargetFromExpression(element, checker) - if (directTarget) - return directTarget + if (directTarget) return directTarget const nestedTarget = findNestedContainerTarget(element, checker) - if (nestedTarget) - return nestedTarget + if (nestedTarget) return nestedTarget if (typeIncludesUndefined(checker.getTypeAtLocation(element))) return createExpressionTarget(element) @@ -1394,14 +1376,11 @@ function findArrayLiteralElementTarget( for (let index = arrayLiteral.elements.length - 1; index >= 0; index -= 1) { const element = arrayLiteral.elements[index] - if (!element) - continue + if (!element) continue - if (ts.isSpreadElement(element)) - continue + if (ts.isSpreadElement(element)) continue - if (!isAlreadyNonNull(element)) - return createExpressionTarget(element) + if (!isAlreadyNonNull(element)) return createExpressionTarget(element) } return undefined @@ -1430,24 +1409,46 @@ function findAccessDiagnosticTarget( ): EditTarget | undefined { const directExpression = findTightestExpression(sourceFile, start, end) if (directExpression) { - if (typeIncludesUndefined(checker.getTypeAtLocation(directExpression)) && !isAlreadyNonNull(directExpression)) + if ( + typeIncludesUndefined(checker.getTypeAtLocation(directExpression)) && + !isAlreadyNonNull(directExpression) + ) return createExpressionTarget(directExpression) - const referencedDeclarationTarget = findReferencedDeclarationInitializerTarget(directExpression, checker) - if (referencedDeclarationTarget && isExpressionTarget(referencedDeclarationTarget) && !isAlreadyNonNull(referencedDeclarationTarget.expression)) + const referencedDeclarationTarget = findReferencedDeclarationInitializerTarget( + directExpression, + checker, + ) + if ( + referencedDeclarationTarget && + isExpressionTarget(referencedDeclarationTarget) && + !isAlreadyNonNull(referencedDeclarationTarget.expression) + ) return referencedDeclarationTarget } const bindingPatternTarget = findVariableDeclarationInitializerTarget(sourceFile, token, checker) - if (bindingPatternTarget && isExpressionTarget(bindingPatternTarget) && !isAlreadyNonNull(bindingPatternTarget.expression)) + if ( + bindingPatternTarget && + isExpressionTarget(bindingPatternTarget) && + !isAlreadyNonNull(bindingPatternTarget.expression) + ) return bindingPatternTarget const accessTarget = findUndefinedAccessTarget(token, checker) - if (accessTarget && isExpressionTarget(accessTarget) && !isAlreadyNonNull(accessTarget.expression)) + if ( + accessTarget && + isExpressionTarget(accessTarget) && + !isAlreadyNonNull(accessTarget.expression) + ) return accessTarget const propertyAccessTarget = findPropertyAccessExpressionTarget(token, start) - if (propertyAccessTarget && isExpressionTarget(propertyAccessTarget) && !isAlreadyNonNull(propertyAccessTarget.expression)) + if ( + propertyAccessTarget && + isExpressionTarget(propertyAccessTarget) && + !isAlreadyNonNull(propertyAccessTarget.expression) + ) return propertyAccessTarget return undefined @@ -1469,13 +1470,11 @@ function findDiagnosticCandidate( return findAssignmentLikeCandidate(token, sourceFile, start, end) } - if (diagnosticCode === 2345) - return findArgumentCandidate(token, sourceFile, start, end) + if (diagnosticCode === 2345) return findArgumentCandidate(token, sourceFile, start, end) if (diagnosticCode === 2722) { const current = findTightestExpression(sourceFile, start, end) - if (current && ts.isCallExpression(current)) - return current.expression + if (current && ts.isCallExpression(current)) return current.expression return findTightestExpression(sourceFile, start, end) } @@ -1493,28 +1492,29 @@ function resolveEditTarget( const token = getTokenAtPosition(sourceFile, start) const shorthandTarget = findShorthandPropertyTarget(token, checker) - if (shorthandTarget) - return shorthandTarget + if (shorthandTarget) return shorthandTarget const propertyAssignmentTarget = findPropertyAssignmentInitializerTarget(token, start, checker) - if (propertyAssignmentTarget) - return propertyAssignmentTarget + if (propertyAssignmentTarget) return propertyAssignmentTarget const jsxSpreadTarget = findJsxSpreadAttributeTarget(token, checker) - if (jsxSpreadTarget && isExpressionTarget(jsxSpreadTarget) && !isAlreadyNonNull(jsxSpreadTarget.expression)) + if ( + jsxSpreadTarget && + isExpressionTarget(jsxSpreadTarget) && + !isAlreadyNonNull(jsxSpreadTarget.expression) + ) return jsxSpreadTarget const jsxAttribute = findAncestor(token, ts.isJsxAttribute) const jsxExpression = jsxAttribute ? getExpressionFromJsxAttribute(jsxAttribute) : undefined if ( - ASSIGNABILITY_DIAGNOSTIC_CODES.has(diagnostic.code) - && jsxExpression - && typeIncludesUndefined(checker.getTypeAtLocation(jsxExpression)) - && !isAlreadyNonNull(jsxExpression) + ASSIGNABILITY_DIAGNOSTIC_CODES.has(diagnostic.code) && + jsxExpression && + typeIncludesUndefined(checker.getTypeAtLocation(jsxExpression)) && + !isAlreadyNonNull(jsxExpression) ) { - return findTargetFromExpression(jsxExpression, checker) - ?? createExpressionTarget(jsxExpression) + return findTargetFromExpression(jsxExpression, checker) ?? createExpressionTarget(jsxExpression) } if (ACCESS_DIAGNOSTIC_CODES.has(diagnostic.code)) @@ -1522,28 +1522,37 @@ function resolveEditTarget( if (diagnostic.code === 2322 || diagnostic.code === 2488) { const arrayDestructuringTarget = findArrayDestructuringTarget(token, checker) - if (arrayDestructuringTarget) - return arrayDestructuringTarget + if (arrayDestructuringTarget) return arrayDestructuringTarget } if (diagnostic.code === 2538) { const elementAccessTarget = findElementAccessArgumentTarget(token) - if (elementAccessTarget && isExpressionTarget(elementAccessTarget) && !isAlreadyNonNull(elementAccessTarget.expression)) + if ( + elementAccessTarget && + isExpressionTarget(elementAccessTarget) && + !isAlreadyNonNull(elementAccessTarget.expression) + ) return elementAccessTarget } - if (diagnostic.code === 7006) - return findImplicitAnyParameterTarget(token) + if (diagnostic.code === 7006) return findImplicitAnyParameterTarget(token) if (diagnostic.code === 2488) { const iterableTarget = findIterableTarget(sourceFile, token, start, end, checker) - if (iterableTarget && (!isExpressionTarget(iterableTarget) || !isAlreadyNonNull(iterableTarget.expression))) + if ( + iterableTarget && + (!isExpressionTarget(iterableTarget) || !isAlreadyNonNull(iterableTarget.expression)) + ) return iterableTarget } if (diagnostic.code === 2604 || diagnostic.code === 2786) { const jsxComponentTarget = findJsxComponentDeclarationTarget(token, checker) - if (jsxComponentTarget && isExpressionTarget(jsxComponentTarget) && !isAlreadyNonNull(jsxComponentTarget.expression)) + if ( + jsxComponentTarget && + isExpressionTarget(jsxComponentTarget) && + !isAlreadyNonNull(jsxComponentTarget.expression) + ) return jsxComponentTarget } @@ -1557,66 +1566,93 @@ function resolveEditTarget( if (ASSIGNABILITY_DIAGNOSTIC_CODES.has(diagnostic.code)) { if ( - diagnostic.code === 2345 - && typeIncludesUndefined(checker.getTypeAtLocation(candidate)) - && !isAlreadyNonNull(candidate) - && ( - ts.isIdentifier(candidate) - || ts.isElementAccessExpression(candidate) - || ts.isPropertyAccessExpression(candidate) - ) + diagnostic.code === 2345 && + typeIncludesUndefined(checker.getTypeAtLocation(candidate)) && + !isAlreadyNonNull(candidate) && + (ts.isIdentifier(candidate) || + ts.isElementAccessExpression(candidate) || + ts.isPropertyAccessExpression(candidate)) ) { return createExpressionTarget(candidate) } - const referencedDeclarationTarget = findReferencedDeclarationInitializerTarget(candidate, checker) - if (referencedDeclarationTarget && isExpressionTarget(referencedDeclarationTarget) && !isAlreadyNonNull(referencedDeclarationTarget.expression)) + const referencedDeclarationTarget = findReferencedDeclarationInitializerTarget( + candidate, + checker, + ) + if ( + referencedDeclarationTarget && + isExpressionTarget(referencedDeclarationTarget) && + !isAlreadyNonNull(referencedDeclarationTarget.expression) + ) return referencedDeclarationTarget const collectionCallbackTarget = findCollectionCallbackTarget(candidate, checker) - if (collectionCallbackTarget && isExpressionTarget(collectionCallbackTarget) && !isAlreadyNonNull(collectionCallbackTarget.expression)) + if ( + collectionCallbackTarget && + isExpressionTarget(collectionCallbackTarget) && + !isAlreadyNonNull(collectionCallbackTarget.expression) + ) return collectionCallbackTarget } const targetFromCandidate = findTargetFromExpression(candidate, checker) - if (targetFromCandidate && (!isExpressionTarget(targetFromCandidate) || !isAlreadyNonNull(targetFromCandidate.expression))) + if ( + targetFromCandidate && + (!isExpressionTarget(targetFromCandidate) || !isAlreadyNonNull(targetFromCandidate.expression)) + ) return targetFromCandidate - if (ASSIGNABILITY_DIAGNOSTIC_CODES.has(diagnostic.code) && (ts.isArrowFunction(candidate) || ts.isFunctionExpression(candidate))) { + if ( + ASSIGNABILITY_DIAGNOSTIC_CODES.has(diagnostic.code) && + (ts.isArrowFunction(candidate) || ts.isFunctionExpression(candidate)) + ) { const functionTarget = findFunctionLikeReturnTarget(candidate, checker) - if (functionTarget && isExpressionTarget(functionTarget) && !isAlreadyNonNull(functionTarget.expression)) + if ( + functionTarget && + isExpressionTarget(functionTarget) && + !isAlreadyNonNull(functionTarget.expression) + ) return functionTarget } const nestedContainerTarget = findNestedContainerTarget(candidate, checker) - if (nestedContainerTarget) - return nestedContainerTarget + if (nestedContainerTarget) return nestedContainerTarget - if (isAlreadyNonNull(candidate)) - return undefined + if (isAlreadyNonNull(candidate)) return undefined - if (ASSIGNABILITY_DIAGNOSTIC_CODES.has(diagnostic.code) && ts.isObjectLiteralExpression(candidate)) { + if ( + ASSIGNABILITY_DIAGNOSTIC_CODES.has(diagnostic.code) && + ts.isObjectLiteralExpression(candidate) + ) { const objectLiteralTarget = findObjectLiteralPropertyTarget(candidate, checker) - if (objectLiteralTarget) - return objectLiteralTarget + if (objectLiteralTarget) return objectLiteralTarget } if (diagnostic.code === 2322) { - const declarationInitializerTarget = findVariableDeclarationInitializerTarget(sourceFile, token, checker) - if (declarationInitializerTarget && isExpressionTarget(declarationInitializerTarget) && !isAlreadyNonNull(declarationInitializerTarget.expression)) + const declarationInitializerTarget = findVariableDeclarationInitializerTarget( + sourceFile, + token, + checker, + ) + if ( + declarationInitializerTarget && + isExpressionTarget(declarationInitializerTarget) && + !isAlreadyNonNull(declarationInitializerTarget.expression) + ) return declarationInitializerTarget } - if (ASSIGNABILITY_DIAGNOSTIC_CODES.has(diagnostic.code) && !typeIncludesUndefined(checker.getTypeAtLocation(candidate))) + if ( + ASSIGNABILITY_DIAGNOSTIC_CODES.has(diagnostic.code) && + !typeIncludesUndefined(checker.getTypeAtLocation(candidate)) + ) return undefined return createExpressionTarget(candidate) } -function createEditForTarget( - target: EditTarget, - printer: ts.Printer, -): TextEdit { +function createEditForTarget(target: EditTarget, printer: ts.Printer): TextEdit { const sourceFile = target.sourceFile if (target.kind === 'direct-edit') { @@ -1637,7 +1673,10 @@ function createEditForTarget( ) return { end: target.property.getEnd(), - expectedText: sourceFile.text.slice(target.property.getStart(sourceFile), target.property.getEnd()), + expectedText: sourceFile.text.slice( + target.property.getStart(sourceFile), + target.property.getEnd(), + ), replacement: `${name.getText(sourceFile)}: ${nonNullName}`, start: target.property.getStart(sourceFile), } @@ -1653,29 +1692,30 @@ function createEditForTarget( return { end: target.expression.getEnd(), - expectedText: sourceFile.text.slice(target.expression.getStart(sourceFile), target.expression.getEnd()), + expectedText: sourceFile.text.slice( + target.expression.getStart(sourceFile), + target.expression.getEnd(), + ), replacement, start: target.expression.getStart(sourceFile), } } function hasOverlap(existingEdits: TextEdit[], nextEdit: TextEdit): boolean { - return existingEdits.some(edit => nextEdit.start < edit.end && edit.start < nextEdit.end) + return existingEdits.some((edit) => nextEdit.start < edit.end && edit.start < nextEdit.end) } -function applyEdits(text: string, edits: TextEdit[]): { appliedEditCount: number, text: string } { +function applyEdits(text: string, edits: TextEdit[]): { appliedEditCount: number; text: string } { let currentText = text let appliedEditCount = 0 for (const edit of edits.sort((left, right) => right.start - left.start)) { - if (edit.replacement.length > currentText.length * 4) - continue + if (edit.replacement.length > currentText.length * 4) continue try { currentText = `${currentText.slice(0, edit.start)}${edit.replacement}${currentText.slice(edit.end)}` appliedEditCount += 1 - } - catch { + } catch { continue } } @@ -1687,15 +1727,21 @@ function applyEdits(text: string, edits: TextEdit[]): { appliedEditCount: number } function isValidEditRange(text: string, edit: TextEdit): boolean { - return Number.isInteger(edit.start) - && Number.isInteger(edit.end) - && edit.start >= 0 - && edit.end >= edit.start - && edit.end <= text.length + return ( + Number.isInteger(edit.start) && + Number.isInteger(edit.end) && + edit.start >= 0 && + edit.end >= edit.start && + edit.end <= text.length + ) } function filterApplicableEdits(text: string, edits: TextEdit[]): TextEdit[] { - return edits.filter(edit => isValidEditRange(text, edit) && (!edit.expectedText || text.slice(edit.start, edit.end) === edit.expectedText)) + return edits.filter( + (edit) => + isValidEditRange(text, edit) && + (!edit.expectedText || text.slice(edit.start, edit.end) === edit.expectedText), + ) } export async function runMigration(options: CliOptions) { @@ -1724,47 +1770,55 @@ export async function runMigration(options: CliOptions) { let previousProgram: ts.Program | undefined for (let iteration = 1; iteration <= options.maxIterations; iteration += 1) { - const program = createMigrationProgram(migrationRootNames, parsedConfig, fileTexts, previousProgram) + const program = createMigrationProgram( + migrationRootNames, + parsedConfig, + fileTexts, + previousProgram, + ) const checker = program.getTypeChecker() const editsByFile = new Map<string, TextEdit[]>() for (const fileName of targetFiles) { const sourceFile = program.getSourceFile(fileName) - if (!sourceFile) - continue + if (!sourceFile) continue const diagnostics = program .getSemanticDiagnostics(sourceFile) .filter((diagnostic): diagnostic is ts.DiagnosticWithLocation => { - return diagnostic.file !== undefined - && diagnostic.start !== undefined - && diagnostic.length !== undefined - && SUPPORTED_DIAGNOSTIC_CODES.has(diagnostic.code) + return ( + diagnostic.file !== undefined && + diagnostic.start !== undefined && + diagnostic.length !== undefined && + SUPPORTED_DIAGNOSTIC_CODES.has(diagnostic.code) + ) }) if (options.verbose && diagnostics.length > 0) - console.log(`file ${path.relative(process.cwd(), fileName)}: ${diagnostics.length} supported diagnostic(s)`) + console.log( + `file ${path.relative(process.cwd(), fileName)}: ${diagnostics.length} supported diagnostic(s)`, + ) for (const diagnostic of diagnostics) { const target = resolveEditTarget(sourceFile, diagnostic, checker) if (!target) { - if (options.verbose) - console.log(`unresolved ${formatDiagnostic(diagnostic)}`) + if (options.verbose) console.log(`unresolved ${formatDiagnostic(diagnostic)}`) continue } const editFileName = target.sourceFile.fileName const edit = createEditForTarget(target, printer) const existing = editsByFile.get(editFileName) ?? [] - if (hasOverlap(existing, edit)) - continue + if (hasOverlap(existing, edit)) continue existing.push(edit) editsByFile.set(editFileName, existing) if (options.verbose) { const position = target.sourceFile.getLineAndCharacterOfPosition(edit.start) - console.log(`iter ${iteration}: ${path.relative(process.cwd(), editFileName)}:${position.line + 1}:${position.character + 1} -> add !`) + console.log( + `iter ${iteration}: ${path.relative(process.cwd(), editFileName)}:${position.line + 1}:${position.character + 1} -> add !`, + ) } } } @@ -1778,20 +1832,20 @@ export async function runMigration(options: CliOptions) { let iterationEditCount = 0 for (const [fileName, edits] of editsByFile) { - const currentText = fileTexts.get(fileName) ?? await fs.readFile(fileName, 'utf8') + const currentText = fileTexts.get(fileName) ?? (await fs.readFile(fileName, 'utf8')) const applicableEdits = filterApplicableEdits(currentText, edits) - if (applicableEdits.length === 0) - continue + if (applicableEdits.length === 0) continue const { appliedEditCount, text: editedText } = applyEdits(currentText, applicableEdits) - if (appliedEditCount === 0) - continue + if (appliedEditCount === 0) continue const nextText = normalizeMalformedAssertions(editedText) if (nextText === currentText) { if (options.verbose) { const firstEdit = applicableEdits[0] - console.log(`iter ${iteration}: no-op after normalization for ${path.relative(process.cwd(), fileName)}:${firstEdit?.start ?? 0} ${JSON.stringify(firstEdit ? currentText.slice(firstEdit.start, firstEdit.end) : '')} -> ${JSON.stringify(firstEdit?.replacement ?? '')}`) + console.log( + `iter ${iteration}: no-op after normalization for ${path.relative(process.cwd(), fileName)}:${firstEdit?.start ?? 0} ${JSON.stringify(firstEdit ? currentText.slice(firstEdit.start, firstEdit.end) : '')} -> ${JSON.stringify(firstEdit?.replacement ?? '')}`, + ) } continue } @@ -1801,7 +1855,9 @@ export async function runMigration(options: CliOptions) { } totalEdits += iterationEditCount - console.log(`Iteration ${iteration}: ${iterationEditCount} edit(s) across ${editsByFile.size} file(s).`) + console.log( + `Iteration ${iteration}: ${iterationEditCount} edit(s) across ${editsByFile.size} file(s).`, + ) previousProgram = program } @@ -1811,20 +1867,22 @@ export async function runMigration(options: CliOptions) { } if (!options.write) { - if (!converged) - console.log(`Stopped after reaching --max-iterations=${options.maxIterations}.`) + if (!converged) console.log(`Stopped after reaching --max-iterations=${options.maxIterations}.`) - console.log(`Dry run complete. ${totalEdits} edit(s) are ready. Re-run with --write to apply them.`) + console.log( + `Dry run complete. ${totalEdits} edit(s) are ready. Re-run with --write to apply them.`, + ) return { converged, totalEdits } } const changedFiles = Array.from(fileTexts.entries()) - await Promise.all(changedFiles.map(async ([fileName, text]) => { - await fs.writeFile(fileName, text) - })) + await Promise.all( + changedFiles.map(async ([fileName, text]) => { + await fs.writeFile(fileName, text) + }), + ) - if (!converged) - console.log(`Stopped after reaching --max-iterations=${options.maxIterations}.`) + if (!converged) console.log(`Stopped after reaching --max-iterations=${options.maxIterations}.`) console.log(`Wrote ${totalEdits} edit(s) to ${changedFiles.length} file(s).`) return { converged, totalEdits } diff --git a/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/normalize.ts b/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/normalize.ts index d3b88736fcb368..ca8f8598d85d0a 100644 --- a/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/normalize.ts +++ b/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/normalize.ts @@ -11,17 +11,15 @@ async function collectFiles(directory: string): Promise<string[]> { const files: string[] = [] for (const entry of entries) { - if (entry.name === 'node_modules' || entry.name === '.next') - continue + if (entry.name === 'node_modules' || entry.name === '.next') continue const absolutePath = path.join(directory, entry.name) if (entry.isDirectory()) { - files.push(...await collectFiles(absolutePath)) + files.push(...(await collectFiles(absolutePath))) continue } - if (!EXTENSIONS.has(path.extname(entry.name))) - continue + if (!EXTENSIONS.has(path.extname(entry.name))) continue files.push(absolutePath) } @@ -33,15 +31,16 @@ async function main() { const files = await collectFiles(ROOT) let changedFileCount = 0 - await Promise.all(files.map(async (fileName) => { - const currentText = await fs.readFile(fileName, 'utf8') - const nextText = normalizeMalformedAssertions(currentText) - if (nextText === currentText) - return + await Promise.all( + files.map(async (fileName) => { + const currentText = await fs.readFile(fileName, 'utf8') + const nextText = normalizeMalformedAssertions(currentText) + if (nextText === currentText) return - await fs.writeFile(fileName, nextText) - changedFileCount += 1 - })) + await fs.writeFile(fileName, nextText) + changedFileCount += 1 + }), + ) console.log(`Normalized malformed assertion syntax in ${changedFileCount} file(s).`) } diff --git a/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/run.ts b/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/run.ts index 6eea6c24594409..7963e69a115a35 100644 --- a/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/run.ts +++ b/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/run.ts @@ -41,8 +41,7 @@ function parseArgs(argv: string[]): CliOptions { for (let i = 0; i < argv.length; i += 1) { const arg = argv[i] - if (arg === '--') - continue + if (arg === '--') continue if (arg === '--verbose') { options.verbose = true @@ -51,8 +50,7 @@ function parseArgs(argv: string[]): CliOptions { if (arg === '--project') { const value = argv[i + 1] - if (!value) - throw new Error('Missing value for --project') + if (!value) throw new Error('Missing value for --project') options.project = value i += 1 @@ -61,8 +59,7 @@ function parseArgs(argv: string[]): CliOptions { if (arg === '--batch-size') { const value = Number(argv[i + 1]) - if (!Number.isInteger(value) || value <= 0) - throw new Error('Invalid value for --batch-size') + if (!Number.isInteger(value) || value <= 0) throw new Error('Invalid value for --batch-size') options.batchSize = value i += 1 @@ -81,8 +78,7 @@ function parseArgs(argv: string[]): CliOptions { if (arg === '--max-rounds') { const value = Number(argv[i + 1]) - if (!Number.isInteger(value) || value <= 0) - throw new Error('Invalid value for --max-rounds') + if (!Number.isInteger(value) || value <= 0) throw new Error('Invalid value for --max-rounds') options.maxRounds = value i += 1 @@ -96,10 +92,7 @@ function parseArgs(argv: string[]): CliOptions { } function getTypeCheckBuildInfoPath(projectPath: string): string { - const hash = createHash('sha1') - .update(projectPath) - .digest('hex') - .slice(0, 16) + const hash = createHash('sha1').update(projectPath).digest('hex').slice(0, 16) return path.join(TYPECHECK_CACHE_DIR, `${hash}.tsbuildinfo`) } @@ -109,7 +102,7 @@ async function runTypeCheck( options?: { incremental?: boolean }, -): Promise<{ diagnostics: DiagnosticEntry[], exitCode: number, rawOutput: string }> { +): Promise<{ diagnostics: DiagnosticEntry[]; exitCode: number; rawOutput: string }> { const projectPath = path.resolve(process.cwd(), project) const projectDirectory = path.dirname(projectPath) const buildInfoPath = getTypeCheckBuildInfoPath(projectPath) @@ -120,8 +113,7 @@ async function runTypeCheck( const tsgoArgs = ['exec', 'tsgo', '--noEmit', '--pretty', 'false'] if (incremental) { tsgoArgs.push('--incremental', '--tsBuildInfoFile', buildInfoPath) - } - else { + } else { tsgoArgs.push('--incremental', 'false') } tsgoArgs.push('--project', projectPath) @@ -142,17 +134,19 @@ async function runTypeCheck( exitCode: 0, rawOutput, } - } - catch (error) { - const exitCode = typeof error === 'object' && error && 'code' in error && typeof error.code === 'number' - ? error.code - : 1 - const stdout = typeof error === 'object' && error && 'stdout' in error && typeof error.stdout === 'string' - ? error.stdout - : '' - const stderr = typeof error === 'object' && error && 'stderr' in error && typeof error.stderr === 'string' - ? error.stderr - : '' + } catch (error) { + const exitCode = + typeof error === 'object' && error && 'code' in error && typeof error.code === 'number' + ? error.code + : 1 + const stdout = + typeof error === 'object' && error && 'stdout' in error && typeof error.stdout === 'string' + ? error.stdout + : '' + const stderr = + typeof error === 'object' && error && 'stderr' in error && typeof error.stderr === 'string' + ? error.stderr + : '' const rawOutput = `${stdout}${stderr}`.trim() return { @@ -166,18 +160,19 @@ async function runTypeCheck( function parseDiagnostics(rawOutput: string, projectDirectory: string): DiagnosticEntry[] { return rawOutput .split('\n') - .map(line => line.trim()) + .map((line) => line.trim()) .flatMap((line) => { const match = line.match(DIAGNOSTIC_PATTERN) - if (!match) - return [] - - return [{ - code: Number(match[4]), - fileName: path.resolve(projectDirectory, match[1]!), - line: Number(match[2]), - message: match[5] ?? '', - }] + if (!match) return [] + + return [ + { + code: Number(match[4]), + fileName: path.resolve(projectDirectory, match[1]!), + line: Number(match[2]), + message: match[5] ?? '', + }, + ] }) } @@ -195,8 +190,7 @@ function summarizeCodes(diagnostics: DiagnosticEntry[]): string { function chunk<T>(items: T[], size: number): T[][] { const batches: T[][] = [] - for (let i = 0; i < items.length; i += size) - batches.push(items.slice(i, i + size)) + for (let i = 0; i < items.length; i += size) batches.push(items.slice(i, i + size)) return batches } @@ -208,18 +202,24 @@ async function runBatchMigration(options: CliOptions) { const finalCheck = await runTypeCheck(options.project, { incremental: false }) if (finalCheck.exitCode !== 0) { const finalDiagnostics = finalCheck.diagnostics - console.log(`Final cold type check found ${finalDiagnostics.length} diagnostic(s). ${summarizeCodes(finalDiagnostics)}`) + console.log( + `Final cold type check found ${finalDiagnostics.length} diagnostic(s). ${summarizeCodes(finalDiagnostics)}`, + ) if (options.verbose) { for (const diagnostic of finalDiagnostics.slice(0, 40)) - console.log(`${path.relative(process.cwd(), diagnostic.fileName)}:${diagnostic.line} TS${diagnostic.code} ${diagnostic.message}`) + console.log( + `${path.relative(process.cwd(), diagnostic.fileName)}:${diagnostic.line} TS${diagnostic.code} ${diagnostic.message}`, + ) } - const finalSupportedFiles = Array.from(new Set( - finalDiagnostics - .filter(diagnostic => SUPPORTED_DIAGNOSTIC_CODES.has(diagnostic.code)) - .map(diagnostic => diagnostic.fileName), - )) + const finalSupportedFiles = Array.from( + new Set( + finalDiagnostics + .filter((diagnostic) => SUPPORTED_DIAGNOSTIC_CODES.has(diagnostic.code)) + .map((diagnostic) => diagnostic.fileName), + ), + ) if (finalSupportedFiles.length > 0) { console.log(` Final pass batch: ${finalSupportedFiles.length} file(s)`) @@ -243,12 +243,10 @@ async function runBatchMigration(options: CliOptions) { }) } - if (finalResult.totalEdits > 0) - continue + if (finalResult.totalEdits > 0) continue } - if (finalCheck.rawOutput) - process.stderr.write(`${finalCheck.rawOutput}\n`) + if (finalCheck.rawOutput) process.stderr.write(`${finalCheck.rawOutput}\n`) process.exitCode = 1 return } @@ -257,15 +255,25 @@ async function runBatchMigration(options: CliOptions) { return } - const supportedDiagnostics = diagnostics.filter(diagnostic => SUPPORTED_DIAGNOSTIC_CODES.has(diagnostic.code)) - const unsupportedDiagnostics = diagnostics.filter(diagnostic => !SUPPORTED_DIAGNOSTIC_CODES.has(diagnostic.code)) - const supportedFiles = Array.from(new Set(supportedDiagnostics.map(diagnostic => diagnostic.fileName))) + const supportedDiagnostics = diagnostics.filter((diagnostic) => + SUPPORTED_DIAGNOSTIC_CODES.has(diagnostic.code), + ) + const unsupportedDiagnostics = diagnostics.filter( + (diagnostic) => !SUPPORTED_DIAGNOSTIC_CODES.has(diagnostic.code), + ) + const supportedFiles = Array.from( + new Set(supportedDiagnostics.map((diagnostic) => diagnostic.fileName)), + ) - console.log(`Round ${round}: ${diagnostics.length} diagnostic(s). ${summarizeCodes(diagnostics)}`) + console.log( + `Round ${round}: ${diagnostics.length} diagnostic(s). ${summarizeCodes(diagnostics)}`, + ) if (options.verbose) { for (const diagnostic of diagnostics.slice(0, 40)) - console.log(`${path.relative(process.cwd(), diagnostic.fileName)}:${diagnostic.line} TS${diagnostic.code} ${diagnostic.message}`) + console.log( + `${path.relative(process.cwd(), diagnostic.fileName)}:${diagnostic.line} TS${diagnostic.code} ${diagnostic.message}`, + ) } if (supportedFiles.length === 0) { @@ -273,10 +281,11 @@ async function runBatchMigration(options: CliOptions) { if (unsupportedDiagnostics.length > 0) { console.error('Remaining unsupported diagnostics:') for (const diagnostic of unsupportedDiagnostics.slice(0, 40)) - console.error(`${path.relative(process.cwd(), diagnostic.fileName)}:${diagnostic.line} TS${diagnostic.code} ${diagnostic.message}`) + console.error( + `${path.relative(process.cwd(), diagnostic.fileName)}:${diagnostic.line} TS${diagnostic.code} ${diagnostic.message}`, + ) } - if (rawOutput) - process.stderr.write(`${rawOutput}\n`) + if (rawOutput) process.stderr.write(`${rawOutput}\n`) process.exitCode = 1 return } @@ -310,7 +319,9 @@ async function runBatchMigration(options: CliOptions) { } if (roundEdits === 0) { - console.error('Migration script made no edits in this round; stopping to avoid an infinite loop.') + console.error( + 'Migration script made no edits in this round; stopping to avoid an infinite loop.', + ) process.exitCode = 1 return } diff --git a/sdks/nodejs-client/README.md b/sdks/nodejs-client/README.md index 7051bbc788a13a..d889c653191673 100644 --- a/sdks/nodejs-client/README.md +++ b/sdks/nodejs-client/README.md @@ -19,7 +19,7 @@ import { CompletionClient, WorkflowClient, KnowledgeBaseClient, - WorkspaceClient + WorkspaceClient, } from 'dify-client' const API_KEY = 'your-app-api-key' @@ -42,7 +42,7 @@ await client.messageFeedback('message-id', 'like', user) await completionClient.createCompletionMessage({ inputs: { query }, user, - response_mode: 'blocking' + response_mode: 'blocking', }) // Chat (streaming) @@ -50,7 +50,7 @@ const stream = await chatClient.createChatMessage({ inputs: {}, query, user, - response_mode: 'streaming' + response_mode: 'streaming', }) for await (const event of stream) { console.log(event.event, event.data) @@ -62,14 +62,14 @@ await chatClient.createChatMessage({ query, user, workflow_id: 'workflow-id', - response_mode: 'blocking' + response_mode: 'blocking', }) // Workflow run (blocking or streaming) await workflowClient.run({ inputs: { query }, user, - response_mode: 'blocking' + response_mode: 'blocking', }) // Knowledge base (dataset token required) @@ -83,7 +83,7 @@ const pipelineStream = await kbClient.runPipeline('dataset-id', { datasource_info_list: [], start_node_id: 'start-node-id', is_published: true, - response_mode: 'streaming' + response_mode: 'streaming', }) for await (const event of pipelineStream) { console.log(event.data) @@ -91,7 +91,6 @@ for await (const event of pipelineStream) { // Workspace models (dataset token required) await workspaceClient.getModelsByType('text-embedding') - ``` Notes: diff --git a/sdks/nodejs-client/package.json b/sdks/nodejs-client/package.json index e3e77e3b470efd..619cebb2b5b49b 100644 --- a/sdks/nodejs-client/package.json +++ b/sdks/nodejs-client/package.json @@ -2,32 +2,19 @@ "name": "dify-client", "version": "3.1.0", "description": "This is the Node.js SDK for the Dify.AI API, which allows you to easily integrate Dify.AI into your Node.js applications.", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "engines": { - "node": ">=18.0.0" - }, - "files": [ - "dist/index.js", - "dist/index.d.ts", - "README.md", - "LICENSE" - ], "keywords": [ + "AI", + "API", "Dify", "Dify.AI", "LLM", - "AI", - "SDK", - "API" + "SDK" ], + "homepage": "https://dify.ai", + "bugs": { + "url": "https://github.com/langgenius/dify/issues" + }, + "license": "MIT", "author": "LangGenius", "contributors": [ "Joel <iamjoel007@gmail.com> (https://github.com/iamjoel)", @@ -39,11 +26,21 @@ "url": "https://github.com/langgenius/dify.git", "directory": "sdks/nodejs-client" }, - "bugs": { - "url": "https://github.com/langgenius/dify/issues" + "files": [ + "dist/index.js", + "dist/index.d.ts", + "README.md", + "LICENSE" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } }, - "homepage": "https://dify.ai", - "license": "MIT", "scripts": { "build": "vp pack", "lint": "eslint", @@ -67,5 +64,8 @@ "vite": "catalog:", "vite-plus": "catalog:", "vitest": "catalog:" + }, + "engines": { + "node": ">=18.0.0" } } diff --git a/sdks/nodejs-client/src/client/base.test.ts b/sdks/nodejs-client/src/client/base.test.ts index 868c476432ca16..0b8f679a0a76c3 100644 --- a/sdks/nodejs-client/src/client/base.test.ts +++ b/sdks/nodejs-client/src/client/base.test.ts @@ -1,175 +1,175 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ValidationError } from "../errors/dify-error"; -import { DifyClient } from "./base"; -import { createHttpClientWithSpies } from "../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createHttpClientWithSpies } from '../../tests/test-utils' +import { ValidationError } from '../errors/dify-error' +import { DifyClient } from './base' -describe("DifyClient base", () => { +describe('DifyClient base', () => { beforeEach(() => { - vi.restoreAllMocks(); - }); + vi.restoreAllMocks() + }) - it("getRoot calls root endpoint", async () => { - const { client, request } = createHttpClientWithSpies(); - const dify = new DifyClient(client); + it('getRoot calls root endpoint', async () => { + const { client, request } = createHttpClientWithSpies() + const dify = new DifyClient(client) - await dify.getRoot(); + await dify.getRoot() expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/", - }); - }); + method: 'GET', + path: '/', + }) + }) - it("getApplicationParameters includes optional user", async () => { - const { client, request } = createHttpClientWithSpies(); - const dify = new DifyClient(client); + it('getApplicationParameters includes optional user', async () => { + const { client, request } = createHttpClientWithSpies() + const dify = new DifyClient(client) - await dify.getApplicationParameters(); + await dify.getApplicationParameters() expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/parameters", + method: 'GET', + path: '/parameters', query: undefined, - }); + }) - await dify.getApplicationParameters("user-1"); + await dify.getApplicationParameters('user-1') expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/parameters", - query: { user: "user-1" }, - }); - }); + method: 'GET', + path: '/parameters', + query: { user: 'user-1' }, + }) + }) - it("getMeta includes optional user", async () => { - const { client, request } = createHttpClientWithSpies(); - const dify = new DifyClient(client); + it('getMeta includes optional user', async () => { + const { client, request } = createHttpClientWithSpies() + const dify = new DifyClient(client) - await dify.getMeta("user-1"); + await dify.getMeta('user-1') expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/meta", - query: { user: "user-1" }, - }); - }); + method: 'GET', + path: '/meta', + query: { user: 'user-1' }, + }) + }) - it("getInfo and getSite support optional user", async () => { - const { client, request } = createHttpClientWithSpies(); - const dify = new DifyClient(client); + it('getInfo and getSite support optional user', async () => { + const { client, request } = createHttpClientWithSpies() + const dify = new DifyClient(client) - await dify.getInfo(); - await dify.getSite("user"); + await dify.getInfo() + await dify.getSite('user') expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/info", + method: 'GET', + path: '/info', query: undefined, - }); + }) expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/site", - query: { user: "user" }, - }); - }); + method: 'GET', + path: '/site', + query: { user: 'user' }, + }) + }) - it("messageFeedback builds payload from request object", async () => { - const { client, request } = createHttpClientWithSpies(); - const dify = new DifyClient(client); + it('messageFeedback builds payload from request object', async () => { + const { client, request } = createHttpClientWithSpies() + const dify = new DifyClient(client) await dify.messageFeedback({ - messageId: "msg", - user: "user", - rating: "like", - content: "good", - }); + messageId: 'msg', + user: 'user', + rating: 'like', + content: 'good', + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/messages/msg/feedbacks", - data: { user: "user", rating: "like", content: "good" }, - }); - }); + method: 'POST', + path: '/messages/msg/feedbacks', + data: { user: 'user', rating: 'like', content: 'good' }, + }) + }) - it("fileUpload appends user to form data", async () => { - const { client, request } = createHttpClientWithSpies(); - const dify = new DifyClient(client); - const form = { append: vi.fn(), getHeaders: () => ({}) }; + it('fileUpload appends user to form data', async () => { + const { client, request } = createHttpClientWithSpies() + const dify = new DifyClient(client) + const form = { append: vi.fn(), getHeaders: () => ({}) } - await dify.fileUpload(form, "user"); + await dify.fileUpload(form, 'user') - expect(form.append).toHaveBeenCalledWith("user", "user"); + expect(form.append).toHaveBeenCalledWith('user', 'user') expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/files/upload", + method: 'POST', + path: '/files/upload', data: form, - }); - }); + }) + }) - it("filePreview uses bytes response", async () => { - const { client, request } = createHttpClientWithSpies(); - const dify = new DifyClient(client); + it('filePreview uses bytes response', async () => { + const { client, request } = createHttpClientWithSpies() + const dify = new DifyClient(client) - await dify.filePreview("file", "user", true); + await dify.filePreview('file', 'user', true) expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/files/file/preview", - query: { user: "user", as_attachment: "true" }, - responseType: "bytes", - }); - }); + method: 'GET', + path: '/files/file/preview', + query: { user: 'user', as_attachment: 'true' }, + responseType: 'bytes', + }) + }) - it("audioToText appends user and sends form", async () => { - const { client, request } = createHttpClientWithSpies(); - const dify = new DifyClient(client); - const form = { append: vi.fn(), getHeaders: () => ({}) }; + it('audioToText appends user and sends form', async () => { + const { client, request } = createHttpClientWithSpies() + const dify = new DifyClient(client) + const form = { append: vi.fn(), getHeaders: () => ({}) } - await dify.audioToText(form, "user"); + await dify.audioToText(form, 'user') - expect(form.append).toHaveBeenCalledWith("user", "user"); + expect(form.append).toHaveBeenCalledWith('user', 'user') expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/audio-to-text", + method: 'POST', + path: '/audio-to-text', data: form, - }); - }); + }) + }) - it("textToAudio supports streaming and message id", async () => { - const { client, request, requestBinaryStream } = createHttpClientWithSpies(); - const dify = new DifyClient(client); + it('textToAudio supports streaming and message id', async () => { + const { client, request, requestBinaryStream } = createHttpClientWithSpies() + const dify = new DifyClient(client) await dify.textToAudio({ - user: "user", - message_id: "msg", + user: 'user', + message_id: 'msg', streaming: true, - }); + }) expect(requestBinaryStream).toHaveBeenCalledWith({ - method: "POST", - path: "/text-to-audio", + method: 'POST', + path: '/text-to-audio', data: { - user: "user", - message_id: "msg", + user: 'user', + message_id: 'msg', streaming: true, }, - }); + }) - await dify.textToAudio("hello", "user", false, "voice"); + await dify.textToAudio('hello', 'user', false, 'voice') expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/text-to-audio", + method: 'POST', + path: '/text-to-audio', data: { - text: "hello", - user: "user", + text: 'hello', + user: 'user', streaming: false, - voice: "voice", + voice: 'voice', }, - responseType: "bytes", - }); - }); + responseType: 'bytes', + }) + }) - it("textToAudio requires text or message id", () => { - const { client } = createHttpClientWithSpies(); - const dify = new DifyClient(client); + it('textToAudio requires text or message id', () => { + const { client } = createHttpClientWithSpies() + const dify = new DifyClient(client) - expect(() => dify.textToAudio({ user: "user" })).toThrow(ValidationError); - }); -}); + expect(() => dify.textToAudio({ user: 'user' })).toThrow(ValidationError) + }) +}) diff --git a/sdks/nodejs-client/src/client/base.ts b/sdks/nodejs-client/src/client/base.ts index f02b88be3ac0b6..3f465d2946e1dc 100644 --- a/sdks/nodejs-client/src/client/base.ts +++ b/sdks/nodejs-client/src/client/base.ts @@ -1,3 +1,5 @@ +import type { HttpRequestBody } from '../http/client' +import type { SdkFormData } from '../http/form-data' import type { BinaryStream, DifyClientConfig, @@ -8,49 +10,44 @@ import type { RequestMethod, SuccessResponse, TextToAudioRequest, -} from "../types/common"; -import type { HttpRequestBody } from "../http/client"; -import { HttpClient } from "../http/client"; -import { ensureNonEmptyString, ensureRating } from "./validation"; -import { FileUploadError, ValidationError } from "../errors/dify-error"; -import type { SdkFormData } from "../http/form-data"; -import { isFormData } from "../http/form-data"; +} from '../types/common' +import { FileUploadError, ValidationError } from '../errors/dify-error' +import { HttpClient } from '../http/client' +import { isFormData } from '../http/form-data' +import { ensureNonEmptyString, ensureRating } from './validation' -const toConfig = ( - init: string | DifyClientConfig, - baseUrl?: string -): DifyClientConfig => { - if (typeof init === "string") { +const toConfig = (init: string | DifyClientConfig, baseUrl?: string): DifyClientConfig => { + if (typeof init === 'string') { return { apiKey: init, baseUrl, - }; + } } - return init; -}; + return init +} const appendUserToFormData = (form: SdkFormData, user: string): void => { - form.append("user", user); -}; + form.append('user', user) +} export class DifyClient { - protected http: HttpClient; + protected http: HttpClient constructor(config: string | DifyClientConfig | HttpClient, baseUrl?: string) { if (config instanceof HttpClient) { - this.http = config; + this.http = config } else { - this.http = new HttpClient(toConfig(config, baseUrl)); + this.http = new HttpClient(toConfig(config, baseUrl)) } } updateApiKey(apiKey: string): void { - ensureNonEmptyString(apiKey, "apiKey"); - this.http.updateApiKey(apiKey); + ensureNonEmptyString(apiKey, 'apiKey') + this.http.updateApiKey(apiKey) } getHttpClient(): HttpClient { - return this.http; + return this.http } sendRequest( @@ -59,225 +56,217 @@ export class DifyClient { data: HttpRequestBody = null, params: QueryParams | null = null, stream = false, - headerParams: Record<string, string> = {} - ): ReturnType<HttpClient["requestRaw"]> { + headerParams: Record<string, string> = {}, + ): ReturnType<HttpClient['requestRaw']> { return this.http.requestRaw({ method, path: endpoint, data, query: params ?? undefined, headers: headerParams, - responseType: stream ? "stream" : "json", - }); + responseType: stream ? 'stream' : 'json', + }) } getRoot(): Promise<DifyResponse<JsonObject>> { return this.http.request({ - method: "GET", - path: "/", - }); + method: 'GET', + path: '/', + }) } getApplicationParameters(user?: string): Promise<DifyResponse<JsonObject>> { if (user) { - ensureNonEmptyString(user, "user"); + ensureNonEmptyString(user, 'user') } return this.http.request({ - method: "GET", - path: "/parameters", + method: 'GET', + path: '/parameters', query: user ? { user } : undefined, - }); + }) } async getParameters(user?: string): Promise<DifyResponse<JsonObject>> { - return this.getApplicationParameters(user); + return this.getApplicationParameters(user) } getMeta(user?: string): Promise<DifyResponse<JsonObject>> { if (user) { - ensureNonEmptyString(user, "user"); + ensureNonEmptyString(user, 'user') } return this.http.request({ - method: "GET", - path: "/meta", + method: 'GET', + path: '/meta', query: user ? { user } : undefined, - }); + }) } - messageFeedback( - request: MessageFeedbackRequest - ): Promise<DifyResponse<SuccessResponse>>; + messageFeedback(request: MessageFeedbackRequest): Promise<DifyResponse<SuccessResponse>> messageFeedback( messageId: string, - rating: "like" | "dislike" | null, + rating: 'like' | 'dislike' | null, user: string, - content?: string - ): Promise<DifyResponse<SuccessResponse>>; + content?: string, + ): Promise<DifyResponse<SuccessResponse>> messageFeedback( messageIdOrRequest: string | MessageFeedbackRequest, - rating?: "like" | "dislike" | null, + rating?: 'like' | 'dislike' | null, user?: string, - content?: string + content?: string, ): Promise<DifyResponse<SuccessResponse>> { - let messageId: string; - const payload: JsonObject = {}; + let messageId: string + const payload: JsonObject = {} - if (typeof messageIdOrRequest === "string") { - messageId = messageIdOrRequest; - ensureNonEmptyString(messageId, "messageId"); - ensureNonEmptyString(user, "user"); - payload.user = user; + if (typeof messageIdOrRequest === 'string') { + messageId = messageIdOrRequest + ensureNonEmptyString(messageId, 'messageId') + ensureNonEmptyString(user, 'user') + payload.user = user if (rating !== undefined && rating !== null) { - ensureRating(rating); - payload.rating = rating; + ensureRating(rating) + payload.rating = rating } if (content !== undefined) { - payload.content = content; + payload.content = content } } else { - const request = messageIdOrRequest; - messageId = request.messageId; - ensureNonEmptyString(messageId, "messageId"); - ensureNonEmptyString(request.user, "user"); - payload.user = request.user; + const request = messageIdOrRequest + messageId = request.messageId + ensureNonEmptyString(messageId, 'messageId') + ensureNonEmptyString(request.user, 'user') + payload.user = request.user if (request.rating !== undefined && request.rating !== null) { - ensureRating(request.rating); - payload.rating = request.rating; + ensureRating(request.rating) + payload.rating = request.rating } if (request.content !== undefined) { - payload.content = request.content; + payload.content = request.content } } return this.http.request({ - method: "POST", + method: 'POST', path: `/messages/${messageId}/feedbacks`, data: payload, - }); + }) } getInfo(user?: string): Promise<DifyResponse<JsonObject>> { if (user) { - ensureNonEmptyString(user, "user"); + ensureNonEmptyString(user, 'user') } return this.http.request({ - method: "GET", - path: "/info", + method: 'GET', + path: '/info', query: user ? { user } : undefined, - }); + }) } getSite(user?: string): Promise<DifyResponse<JsonObject>> { if (user) { - ensureNonEmptyString(user, "user"); + ensureNonEmptyString(user, 'user') } return this.http.request({ - method: "GET", - path: "/site", + method: 'GET', + path: '/site', query: user ? { user } : undefined, - }); + }) } fileUpload(form: unknown, user: string): Promise<DifyResponse<JsonObject>> { if (!isFormData(form)) { - throw new FileUploadError("FormData is required for file uploads"); + throw new FileUploadError('FormData is required for file uploads') } - ensureNonEmptyString(user, "user"); - appendUserToFormData(form, user); + ensureNonEmptyString(user, 'user') + appendUserToFormData(form, user) return this.http.request({ - method: "POST", - path: "/files/upload", + method: 'POST', + path: '/files/upload', data: form, - }); + }) } - filePreview( - fileId: string, - user: string, - asAttachment?: boolean - ): Promise<DifyResponse<Buffer>> { - ensureNonEmptyString(fileId, "fileId"); - ensureNonEmptyString(user, "user"); - return this.http.request<Buffer, "bytes">({ - method: "GET", + filePreview(fileId: string, user: string, asAttachment?: boolean): Promise<DifyResponse<Buffer>> { + ensureNonEmptyString(fileId, 'fileId') + ensureNonEmptyString(user, 'user') + return this.http.request<Buffer, 'bytes'>({ + method: 'GET', path: `/files/${fileId}/preview`, query: { user, - as_attachment: asAttachment ? "true" : undefined, + as_attachment: asAttachment ? 'true' : undefined, }, - responseType: "bytes", - }); + responseType: 'bytes', + }) } audioToText(form: unknown, user: string): Promise<DifyResponse<JsonObject>> { if (!isFormData(form)) { - throw new FileUploadError("FormData is required for audio uploads"); + throw new FileUploadError('FormData is required for audio uploads') } - ensureNonEmptyString(user, "user"); - appendUserToFormData(form, user); + ensureNonEmptyString(user, 'user') + appendUserToFormData(form, user) return this.http.request({ - method: "POST", - path: "/audio-to-text", + method: 'POST', + path: '/audio-to-text', data: form, - }); + }) } - textToAudio( - request: TextToAudioRequest - ): Promise<DifyResponse<Buffer> | BinaryStream>; + textToAudio(request: TextToAudioRequest): Promise<DifyResponse<Buffer> | BinaryStream> textToAudio( text: string, user: string, streaming?: boolean, - voice?: string - ): Promise<DifyResponse<Buffer> | BinaryStream>; + voice?: string, + ): Promise<DifyResponse<Buffer> | BinaryStream> textToAudio( textOrRequest: string | TextToAudioRequest, user?: string, streaming = false, - voice?: string + voice?: string, ): Promise<DifyResponse<Buffer> | BinaryStream> { - let payload: TextToAudioRequest; + let payload: TextToAudioRequest - if (typeof textOrRequest === "string") { - ensureNonEmptyString(textOrRequest, "text"); - ensureNonEmptyString(user, "user"); + if (typeof textOrRequest === 'string') { + ensureNonEmptyString(textOrRequest, 'text') + ensureNonEmptyString(user, 'user') payload = { text: textOrRequest, user, streaming, - }; + } if (voice) { - payload.voice = voice; + payload.voice = voice } } else { - payload = { ...textOrRequest }; - ensureNonEmptyString(payload.user, "user"); + payload = { ...textOrRequest } + ensureNonEmptyString(payload.user, 'user') if (payload.text !== undefined && payload.text !== null) { - ensureNonEmptyString(payload.text, "text"); + ensureNonEmptyString(payload.text, 'text') } if (payload.message_id !== undefined && payload.message_id !== null) { - ensureNonEmptyString(payload.message_id, "messageId"); + ensureNonEmptyString(payload.message_id, 'messageId') } if (!payload.text && !payload.message_id) { - throw new ValidationError("text or message_id is required"); + throw new ValidationError('text or message_id is required') } - payload.streaming = payload.streaming ?? false; + payload.streaming = payload.streaming ?? false } if (payload.streaming) { return this.http.requestBinaryStream({ - method: "POST", - path: "/text-to-audio", + method: 'POST', + path: '/text-to-audio', data: payload, - }); + }) } - return this.http.request<Buffer, "bytes">({ - method: "POST", - path: "/text-to-audio", + return this.http.request<Buffer, 'bytes'>({ + method: 'POST', + path: '/text-to-audio', data: payload, - responseType: "bytes", - }); + responseType: 'bytes', + }) } } diff --git a/sdks/nodejs-client/src/client/chat.test.ts b/sdks/nodejs-client/src/client/chat.test.ts index 712ad64fd15cd4..35a6da9f138bd5 100644 --- a/sdks/nodejs-client/src/client/chat.test.ts +++ b/sdks/nodejs-client/src/client/chat.test.ts @@ -1,239 +1,237 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ValidationError } from "../errors/dify-error"; -import { ChatClient } from "./chat"; -import { createHttpClientWithSpies } from "../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createHttpClientWithSpies } from '../../tests/test-utils' +import { ValidationError } from '../errors/dify-error' +import { ChatClient } from './chat' -describe("ChatClient", () => { +describe('ChatClient', () => { beforeEach(() => { - vi.restoreAllMocks(); - }); + vi.restoreAllMocks() + }) - it("creates chat messages in blocking mode", async () => { - const { client, request } = createHttpClientWithSpies(); - const chat = new ChatClient(client); + it('creates chat messages in blocking mode', async () => { + const { client, request } = createHttpClientWithSpies() + const chat = new ChatClient(client) - await chat.createChatMessage({ input: "x" }, "hello", "user", false, null); + await chat.createChatMessage({ input: 'x' }, 'hello', 'user', false, null) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/chat-messages", + method: 'POST', + path: '/chat-messages', data: { - inputs: { input: "x" }, - query: "hello", - user: "user", - response_mode: "blocking", + inputs: { input: 'x' }, + query: 'hello', + user: 'user', + response_mode: 'blocking', files: undefined, }, - }); - }); + }) + }) - it("creates chat messages in streaming mode", async () => { - const { client, requestStream } = createHttpClientWithSpies(); - const chat = new ChatClient(client); + it('creates chat messages in streaming mode', async () => { + const { client, requestStream } = createHttpClientWithSpies() + const chat = new ChatClient(client) await chat.createChatMessage({ - inputs: { input: "x" }, - query: "hello", - user: "user", - response_mode: "streaming", - }); + inputs: { input: 'x' }, + query: 'hello', + user: 'user', + response_mode: 'streaming', + }) expect(requestStream).toHaveBeenCalledWith({ - method: "POST", - path: "/chat-messages", + method: 'POST', + path: '/chat-messages', data: { - inputs: { input: "x" }, - query: "hello", - user: "user", - response_mode: "streaming", + inputs: { input: 'x' }, + query: 'hello', + user: 'user', + response_mode: 'streaming', }, - }); - }); + }) + }) - it("stops chat messages", async () => { - const { client, request } = createHttpClientWithSpies(); - const chat = new ChatClient(client); + it('stops chat messages', async () => { + const { client, request } = createHttpClientWithSpies() + const chat = new ChatClient(client) - await chat.stopChatMessage("task", "user"); - await chat.stopMessage("task", "user"); + await chat.stopChatMessage('task', 'user') + await chat.stopMessage('task', 'user') expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/chat-messages/task/stop", - data: { user: "user" }, - }); - }); + method: 'POST', + path: '/chat-messages/task/stop', + data: { user: 'user' }, + }) + }) - it("gets suggested questions", async () => { - const { client, request } = createHttpClientWithSpies(); - const chat = new ChatClient(client); + it('gets suggested questions', async () => { + const { client, request } = createHttpClientWithSpies() + const chat = new ChatClient(client) - await chat.getSuggested("msg", "user"); + await chat.getSuggested('msg', 'user') expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/messages/msg/suggested", - query: { user: "user" }, - }); - }); + method: 'GET', + path: '/messages/msg/suggested', + query: { user: 'user' }, + }) + }) - it("submits message feedback", async () => { - const { client, request } = createHttpClientWithSpies(); - const chat = new ChatClient(client); + it('submits message feedback', async () => { + const { client, request } = createHttpClientWithSpies() + const chat = new ChatClient(client) - await chat.messageFeedback("msg", "like", "user", "good"); + await chat.messageFeedback('msg', 'like', 'user', 'good') await chat.messageFeedback({ - messageId: "msg", - user: "user", - rating: "dislike", - }); + messageId: 'msg', + user: 'user', + rating: 'dislike', + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/messages/msg/feedbacks", - data: { user: "user", rating: "like", content: "good" }, - }); - }); + method: 'POST', + path: '/messages/msg/feedbacks', + data: { user: 'user', rating: 'like', content: 'good' }, + }) + }) - it("lists app feedbacks", async () => { - const { client, request } = createHttpClientWithSpies(); - const chat = new ChatClient(client); + it('lists app feedbacks', async () => { + const { client, request } = createHttpClientWithSpies() + const chat = new ChatClient(client) - await chat.getAppFeedbacks(2, 5); + await chat.getAppFeedbacks(2, 5) expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/app/feedbacks", + method: 'GET', + path: '/app/feedbacks', query: { page: 2, limit: 5 }, - }); - }); + }) + }) - it("lists conversations and messages", async () => { - const { client, request } = createHttpClientWithSpies(); - const chat = new ChatClient(client); + it('lists conversations and messages', async () => { + const { client, request } = createHttpClientWithSpies() + const chat = new ChatClient(client) - await chat.getConversations("user", "last", 10, "-updated_at"); - await chat.getConversationMessages("user", "conv", "first", 5); + await chat.getConversations('user', 'last', 10, '-updated_at') + await chat.getConversationMessages('user', 'conv', 'first', 5) expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/conversations", + method: 'GET', + path: '/conversations', query: { - user: "user", - last_id: "last", + user: 'user', + last_id: 'last', limit: 10, - sort_by: "-updated_at", + sort_by: '-updated_at', }, - }); + }) expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/messages", + method: 'GET', + path: '/messages', query: { - user: "user", - conversation_id: "conv", - first_id: "first", + user: 'user', + conversation_id: 'conv', + first_id: 'first', limit: 5, }, - }); - }); + }) + }) - it("renames conversations with optional auto-generate", async () => { - const { client, request } = createHttpClientWithSpies(); - const chat = new ChatClient(client); + it('renames conversations with optional auto-generate', async () => { + const { client, request } = createHttpClientWithSpies() + const chat = new ChatClient(client) - await chat.renameConversation("conv", "name", "user", false); - await chat.renameConversation("conv", "user", { autoGenerate: true }); + await chat.renameConversation('conv', 'name', 'user', false) + await chat.renameConversation('conv', 'user', { autoGenerate: true }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/conversations/conv/name", - data: { user: "user", auto_generate: false, name: "name" }, - }); + method: 'POST', + path: '/conversations/conv/name', + data: { user: 'user', auto_generate: false, name: 'name' }, + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/conversations/conv/name", - data: { user: "user", auto_generate: true }, - }); - }); + method: 'POST', + path: '/conversations/conv/name', + data: { user: 'user', auto_generate: true }, + }) + }) - it("requires name when autoGenerate is false", () => { - const { client } = createHttpClientWithSpies(); - const chat = new ChatClient(client); + it('requires name when autoGenerate is false', () => { + const { client } = createHttpClientWithSpies() + const chat = new ChatClient(client) - expect(() => chat.renameConversation("conv", "", "user", false)).toThrow( - ValidationError - ); - }); + expect(() => chat.renameConversation('conv', '', 'user', false)).toThrow(ValidationError) + }) - it("deletes conversations", async () => { - const { client, request } = createHttpClientWithSpies(); - const chat = new ChatClient(client); + it('deletes conversations', async () => { + const { client, request } = createHttpClientWithSpies() + const chat = new ChatClient(client) - await chat.deleteConversation("conv", "user"); + await chat.deleteConversation('conv', 'user') expect(request).toHaveBeenCalledWith({ - method: "DELETE", - path: "/conversations/conv", - data: { user: "user" }, - }); - }); + method: 'DELETE', + path: '/conversations/conv', + data: { user: 'user' }, + }) + }) - it("manages conversation variables", async () => { - const { client, request } = createHttpClientWithSpies(); - const chat = new ChatClient(client); + it('manages conversation variables', async () => { + const { client, request } = createHttpClientWithSpies() + const chat = new ChatClient(client) - await chat.getConversationVariables("conv", "user", "last", 10, "name"); - await chat.updateConversationVariable("conv", "var", "user", "value"); + await chat.getConversationVariables('conv', 'user', 'last', 10, 'name') + await chat.updateConversationVariable('conv', 'var', 'user', 'value') expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/conversations/conv/variables", + method: 'GET', + path: '/conversations/conv/variables', query: { - user: "user", - last_id: "last", + user: 'user', + last_id: 'last', limit: 10, - variable_name: "name", + variable_name: 'name', }, - }); + }) expect(request).toHaveBeenCalledWith({ - method: "PUT", - path: "/conversations/conv/variables/var", - data: { user: "user", value: "value" }, - }); - }); + method: 'PUT', + path: '/conversations/conv/variables/var', + data: { user: 'user', value: 'value' }, + }) + }) - it("handles annotation APIs", async () => { - const { client, request } = createHttpClientWithSpies(); - const chat = new ChatClient(client); + it('handles annotation APIs', async () => { + const { client, request } = createHttpClientWithSpies() + const chat = new ChatClient(client) - await chat.annotationReplyAction("enable", { + await chat.annotationReplyAction('enable', { score_threshold: 0.5, - embedding_provider_name: "prov", - embedding_model_name: "model", - }); - await chat.getAnnotationReplyStatus("enable", "job"); - await chat.listAnnotations({ page: 1, limit: 10, keyword: "k" }); - await chat.createAnnotation({ question: "q", answer: "a" }); - await chat.updateAnnotation("id", { question: "q", answer: "a" }); - await chat.deleteAnnotation("id"); + embedding_provider_name: 'prov', + embedding_model_name: 'model', + }) + await chat.getAnnotationReplyStatus('enable', 'job') + await chat.listAnnotations({ page: 1, limit: 10, keyword: 'k' }) + await chat.createAnnotation({ question: 'q', answer: 'a' }) + await chat.updateAnnotation('id', { question: 'q', answer: 'a' }) + await chat.deleteAnnotation('id') expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/apps/annotation-reply/enable", + method: 'POST', + path: '/apps/annotation-reply/enable', data: { score_threshold: 0.5, - embedding_provider_name: "prov", - embedding_model_name: "model", + embedding_provider_name: 'prov', + embedding_model_name: 'model', }, - }); + }) expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/apps/annotation-reply/enable/status/job", - }); + method: 'GET', + path: '/apps/annotation-reply/enable/status/job', + }) expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/apps/annotations", - query: { page: 1, limit: 10, keyword: "k" }, - }); - }); -}); + method: 'GET', + path: '/apps/annotations', + query: { page: 1, limit: 10, keyword: 'k' }, + }) + }) +}) diff --git a/sdks/nodejs-client/src/client/chat.ts b/sdks/nodejs-client/src/client/chat.ts index 9c232e5117c29e..488ed7cc800be6 100644 --- a/sdks/nodejs-client/src/client/chat.ts +++ b/sdks/nodejs-client/src/client/chat.ts @@ -1,15 +1,10 @@ -import { DifyClient } from "./base"; -import type { - ChatMessageRequest, - ChatMessageResponse, - ConversationSortBy, -} from "../types/chat"; import type { AnnotationCreateRequest, AnnotationListOptions, AnnotationReplyActionRequest, AnnotationResponse, -} from "../types/annotation"; +} from '../types/annotation' +import type { ChatMessageRequest, ChatMessageResponse, ConversationSortBy } from '../types/chat' import type { DifyResponse, DifyStream, @@ -18,242 +13,224 @@ import type { QueryParams, SuccessResponse, SuggestedQuestionsResponse, -} from "../types/common"; -import { - ensureNonEmptyString, - ensureOptionalInt, - ensureOptionalString, -} from "./validation"; +} from '../types/common' +import { DifyClient } from './base' +import { ensureNonEmptyString, ensureOptionalInt, ensureOptionalString } from './validation' export class ChatClient extends DifyClient { createChatMessage( - request: ChatMessageRequest - ): Promise<DifyResponse<ChatMessageResponse> | DifyStream<ChatMessageResponse>>; + request: ChatMessageRequest, + ): Promise<DifyResponse<ChatMessageResponse> | DifyStream<ChatMessageResponse>> createChatMessage( inputs: JsonObject, query: string, user: string, stream?: boolean, conversationId?: string | null, - files?: ChatMessageRequest["files"] - ): Promise<DifyResponse<ChatMessageResponse> | DifyStream<ChatMessageResponse>>; + files?: ChatMessageRequest['files'], + ): Promise<DifyResponse<ChatMessageResponse> | DifyStream<ChatMessageResponse>> createChatMessage( inputOrRequest: ChatMessageRequest | JsonObject, query?: string, user?: string, stream = false, conversationId?: string | null, - files?: ChatMessageRequest["files"] + files?: ChatMessageRequest['files'], ): Promise<DifyResponse<ChatMessageResponse> | DifyStream<ChatMessageResponse>> { - let payload: ChatMessageRequest; - let shouldStream = stream; + let payload: ChatMessageRequest + let shouldStream = stream - if (query === undefined && "user" in (inputOrRequest as ChatMessageRequest)) { - payload = inputOrRequest as ChatMessageRequest; - shouldStream = payload.response_mode === "streaming"; + if (query === undefined && 'user' in (inputOrRequest as ChatMessageRequest)) { + payload = inputOrRequest as ChatMessageRequest + shouldStream = payload.response_mode === 'streaming' } else { - ensureNonEmptyString(query, "query"); - ensureNonEmptyString(user, "user"); - payload = { + ensureNonEmptyString(query, 'query') + ensureNonEmptyString(user, 'user') + payload = { inputs: inputOrRequest, query, user, - response_mode: stream ? "streaming" : "blocking", + response_mode: stream ? 'streaming' : 'blocking', files, - }; + } if (conversationId) { - payload.conversation_id = conversationId; + payload.conversation_id = conversationId } } - ensureNonEmptyString(payload.user, "user"); - ensureNonEmptyString(payload.query, "query"); + ensureNonEmptyString(payload.user, 'user') + ensureNonEmptyString(payload.query, 'query') if (shouldStream) { return this.http.requestStream<ChatMessageResponse>({ - method: "POST", - path: "/chat-messages", + method: 'POST', + path: '/chat-messages', data: payload, - }); + }) } return this.http.request<ChatMessageResponse>({ - method: "POST", - path: "/chat-messages", + method: 'POST', + path: '/chat-messages', data: payload, - }); + }) } - stopChatMessage( - taskId: string, - user: string - ): Promise<DifyResponse<SuccessResponse>> { - ensureNonEmptyString(taskId, "taskId"); - ensureNonEmptyString(user, "user"); + stopChatMessage(taskId: string, user: string): Promise<DifyResponse<SuccessResponse>> { + ensureNonEmptyString(taskId, 'taskId') + ensureNonEmptyString(user, 'user') return this.http.request<SuccessResponse>({ - method: "POST", + method: 'POST', path: `/chat-messages/${taskId}/stop`, data: { user }, - }); + }) } - stopMessage( - taskId: string, - user: string - ): Promise<DifyResponse<SuccessResponse>> { - return this.stopChatMessage(taskId, user); + stopMessage(taskId: string, user: string): Promise<DifyResponse<SuccessResponse>> { + return this.stopChatMessage(taskId, user) } - getSuggested( - messageId: string, - user: string - ): Promise<DifyResponse<SuggestedQuestionsResponse>> { - ensureNonEmptyString(messageId, "messageId"); - ensureNonEmptyString(user, "user"); + getSuggested(messageId: string, user: string): Promise<DifyResponse<SuggestedQuestionsResponse>> { + ensureNonEmptyString(messageId, 'messageId') + ensureNonEmptyString(user, 'user') return this.http.request<SuggestedQuestionsResponse>({ - method: "GET", + method: 'GET', path: `/messages/${messageId}/suggested`, query: { user }, - }); + }) } // Note: messageFeedback is inherited from DifyClient - getAppFeedbacks( - page?: number, - limit?: number - ): Promise<DifyResponse<JsonObject>> { - ensureOptionalInt(page, "page"); - ensureOptionalInt(limit, "limit"); + getAppFeedbacks(page?: number, limit?: number): Promise<DifyResponse<JsonObject>> { + ensureOptionalInt(page, 'page') + ensureOptionalInt(limit, 'limit') return this.http.request({ - method: "GET", - path: "/app/feedbacks", + method: 'GET', + path: '/app/feedbacks', query: { page, limit, }, - }); + }) } getConversations( user: string, lastId?: string | null, limit?: number | null, - sortBy?: ConversationSortBy | null + sortBy?: ConversationSortBy | null, ): Promise<DifyResponse<JsonObject>> { - ensureNonEmptyString(user, "user"); - ensureOptionalString(lastId, "lastId"); - ensureOptionalInt(limit, "limit"); + ensureNonEmptyString(user, 'user') + ensureOptionalString(lastId, 'lastId') + ensureOptionalInt(limit, 'limit') - const params: QueryParams = { user }; + const params: QueryParams = { user } if (lastId) { - params.last_id = lastId; + params.last_id = lastId } if (limit) { - params.limit = limit; + params.limit = limit } if (sortBy) { - params.sort_by = sortBy; + params.sort_by = sortBy } return this.http.request({ - method: "GET", - path: "/conversations", + method: 'GET', + path: '/conversations', query: params, - }); + }) } getConversationMessages( user: string, conversationId: string, firstId?: string | null, - limit?: number | null + limit?: number | null, ): Promise<DifyResponse<JsonObject>> { - ensureNonEmptyString(user, "user"); - ensureNonEmptyString(conversationId, "conversationId"); - ensureOptionalString(firstId, "firstId"); - ensureOptionalInt(limit, "limit"); + ensureNonEmptyString(user, 'user') + ensureNonEmptyString(conversationId, 'conversationId') + ensureOptionalString(firstId, 'firstId') + ensureOptionalInt(limit, 'limit') - const params: QueryParams = { user }; - params.conversation_id = conversationId; + const params: QueryParams = { user } + params.conversation_id = conversationId if (firstId) { - params.first_id = firstId; + params.first_id = firstId } if (limit) { - params.limit = limit; + params.limit = limit } return this.http.request({ - method: "GET", - path: "/messages", + method: 'GET', + path: '/messages', query: params, - }); + }) } renameConversation( conversationId: string, name: string, user: string, - autoGenerate?: boolean - ): Promise<DifyResponse<JsonObject>>; + autoGenerate?: boolean, + ): Promise<DifyResponse<JsonObject>> renameConversation( conversationId: string, user: string, - options?: { name?: string | null; autoGenerate?: boolean } - ): Promise<DifyResponse<JsonObject>>; + options?: { name?: string | null; autoGenerate?: boolean }, + ): Promise<DifyResponse<JsonObject>> renameConversation( conversationId: string, nameOrUser: string, userOrOptions?: string | { name?: string | null; autoGenerate?: boolean }, - autoGenerate?: boolean + autoGenerate?: boolean, ): Promise<DifyResponse<JsonObject>> { - ensureNonEmptyString(conversationId, "conversationId"); + ensureNonEmptyString(conversationId, 'conversationId') - let name: string | null | undefined; - let user: string; - let resolvedAutoGenerate: boolean; + let name: string | null | undefined + let user: string + let resolvedAutoGenerate: boolean - if (typeof userOrOptions === "string" || userOrOptions === undefined) { - name = nameOrUser; - user = userOrOptions ?? ""; - resolvedAutoGenerate = autoGenerate ?? false; + if (typeof userOrOptions === 'string' || userOrOptions === undefined) { + name = nameOrUser + user = userOrOptions ?? '' + resolvedAutoGenerate = autoGenerate ?? false } else { - user = nameOrUser; - name = userOrOptions.name; - resolvedAutoGenerate = userOrOptions.autoGenerate ?? false; + user = nameOrUser + name = userOrOptions.name + resolvedAutoGenerate = userOrOptions.autoGenerate ?? false } - ensureNonEmptyString(user, "user"); + ensureNonEmptyString(user, 'user') if (!resolvedAutoGenerate) { - ensureNonEmptyString(name, "name"); + ensureNonEmptyString(name, 'name') } const payload: JsonObject = { user, auto_generate: resolvedAutoGenerate, - }; - if (typeof name === "string" && name.trim().length > 0) { - payload.name = name; + } + if (typeof name === 'string' && name.trim().length > 0) { + payload.name = name } return this.http.request({ - method: "POST", + method: 'POST', path: `/conversations/${conversationId}/name`, data: payload, - }); + }) } - deleteConversation( - conversationId: string, - user: string - ): Promise<DifyResponse<SuccessResponse>> { - ensureNonEmptyString(conversationId, "conversationId"); - ensureNonEmptyString(user, "user"); + deleteConversation(conversationId: string, user: string): Promise<DifyResponse<SuccessResponse>> { + ensureNonEmptyString(conversationId, 'conversationId') + ensureNonEmptyString(user, 'user') return this.http.request({ - method: "DELETE", + method: 'DELETE', path: `/conversations/${conversationId}`, data: { user }, - }); + }) } getConversationVariables( @@ -261,16 +238,16 @@ export class ChatClient extends DifyClient { user: string, lastId?: string | null, limit?: number | null, - variableName?: string | null + variableName?: string | null, ): Promise<DifyResponse<JsonObject>> { - ensureNonEmptyString(conversationId, "conversationId"); - ensureNonEmptyString(user, "user"); - ensureOptionalString(lastId, "lastId"); - ensureOptionalInt(limit, "limit"); - ensureOptionalString(variableName, "variableName"); + ensureNonEmptyString(conversationId, 'conversationId') + ensureNonEmptyString(user, 'user') + ensureOptionalString(lastId, 'lastId') + ensureOptionalInt(limit, 'limit') + ensureOptionalString(variableName, 'variableName') return this.http.request({ - method: "GET", + method: 'GET', path: `/conversations/${conversationId}/variables`, query: { user, @@ -278,105 +255,99 @@ export class ChatClient extends DifyClient { limit: limit ?? undefined, variable_name: variableName ?? undefined, }, - }); + }) } updateConversationVariable( conversationId: string, variableId: string, user: string, - value: JsonValue + value: JsonValue, ): Promise<DifyResponse<JsonObject>> { - ensureNonEmptyString(conversationId, "conversationId"); - ensureNonEmptyString(variableId, "variableId"); - ensureNonEmptyString(user, "user"); + ensureNonEmptyString(conversationId, 'conversationId') + ensureNonEmptyString(variableId, 'variableId') + ensureNonEmptyString(user, 'user') return this.http.request({ - method: "PUT", + method: 'PUT', path: `/conversations/${conversationId}/variables/${variableId}`, data: { user, value, }, - }); + }) } annotationReplyAction( - action: "enable" | "disable", - request: AnnotationReplyActionRequest + action: 'enable' | 'disable', + request: AnnotationReplyActionRequest, ): Promise<DifyResponse<AnnotationResponse>> { - ensureNonEmptyString(action, "action"); - ensureNonEmptyString(request.embedding_provider_name, "embedding_provider_name"); - ensureNonEmptyString(request.embedding_model_name, "embedding_model_name"); + ensureNonEmptyString(action, 'action') + ensureNonEmptyString(request.embedding_provider_name, 'embedding_provider_name') + ensureNonEmptyString(request.embedding_model_name, 'embedding_model_name') return this.http.request({ - method: "POST", + method: 'POST', path: `/apps/annotation-reply/${action}`, data: request, - }); + }) } getAnnotationReplyStatus( - action: "enable" | "disable", - jobId: string + action: 'enable' | 'disable', + jobId: string, ): Promise<DifyResponse<AnnotationResponse>> { - ensureNonEmptyString(action, "action"); - ensureNonEmptyString(jobId, "jobId"); + ensureNonEmptyString(action, 'action') + ensureNonEmptyString(jobId, 'jobId') return this.http.request({ - method: "GET", + method: 'GET', path: `/apps/annotation-reply/${action}/status/${jobId}`, - }); + }) } - listAnnotations( - options?: AnnotationListOptions - ): Promise<DifyResponse<AnnotationResponse>> { - ensureOptionalInt(options?.page, "page"); - ensureOptionalInt(options?.limit, "limit"); - ensureOptionalString(options?.keyword, "keyword"); + listAnnotations(options?: AnnotationListOptions): Promise<DifyResponse<AnnotationResponse>> { + ensureOptionalInt(options?.page, 'page') + ensureOptionalInt(options?.limit, 'limit') + ensureOptionalString(options?.keyword, 'keyword') return this.http.request({ - method: "GET", - path: "/apps/annotations", + method: 'GET', + path: '/apps/annotations', query: { page: options?.page, limit: options?.limit, keyword: options?.keyword ?? undefined, }, - }); + }) } - createAnnotation( - request: AnnotationCreateRequest - ): Promise<DifyResponse<AnnotationResponse>> { - ensureNonEmptyString(request.question, "question"); - ensureNonEmptyString(request.answer, "answer"); + createAnnotation(request: AnnotationCreateRequest): Promise<DifyResponse<AnnotationResponse>> { + ensureNonEmptyString(request.question, 'question') + ensureNonEmptyString(request.answer, 'answer') return this.http.request({ - method: "POST", - path: "/apps/annotations", + method: 'POST', + path: '/apps/annotations', data: request, - }); + }) } updateAnnotation( annotationId: string, - request: AnnotationCreateRequest + request: AnnotationCreateRequest, ): Promise<DifyResponse<AnnotationResponse>> { - ensureNonEmptyString(annotationId, "annotationId"); - ensureNonEmptyString(request.question, "question"); - ensureNonEmptyString(request.answer, "answer"); + ensureNonEmptyString(annotationId, 'annotationId') + ensureNonEmptyString(request.question, 'question') + ensureNonEmptyString(request.answer, 'answer') return this.http.request({ - method: "PUT", + method: 'PUT', path: `/apps/annotations/${annotationId}`, data: request, - }); + }) } - deleteAnnotation( - annotationId: string - ): Promise<DifyResponse<AnnotationResponse>> { - ensureNonEmptyString(annotationId, "annotationId"); + deleteAnnotation(annotationId: string): Promise<DifyResponse<AnnotationResponse>> { + ensureNonEmptyString(annotationId, 'annotationId') return this.http.request({ - method: "DELETE", + method: 'DELETE', path: `/apps/annotations/${annotationId}`, - }); + }) } // Note: audioToText is inherited from DifyClient diff --git a/sdks/nodejs-client/src/client/completion.test.ts b/sdks/nodejs-client/src/client/completion.test.ts index b79cf3fb8fcff5..37116baf8862d6 100644 --- a/sdks/nodejs-client/src/client/completion.test.ts +++ b/sdks/nodejs-client/src/client/completion.test.ts @@ -1,83 +1,83 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { CompletionClient } from "./completion"; -import { createHttpClientWithSpies } from "../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createHttpClientWithSpies } from '../../tests/test-utils' +import { CompletionClient } from './completion' -describe("CompletionClient", () => { +describe('CompletionClient', () => { beforeEach(() => { - vi.restoreAllMocks(); - }); + vi.restoreAllMocks() + }) - it("creates completion messages in blocking mode", async () => { - const { client, request } = createHttpClientWithSpies(); - const completion = new CompletionClient(client); + it('creates completion messages in blocking mode', async () => { + const { client, request } = createHttpClientWithSpies() + const completion = new CompletionClient(client) - await completion.createCompletionMessage({ input: "x" }, "user", false); + await completion.createCompletionMessage({ input: 'x' }, 'user', false) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/completion-messages", + method: 'POST', + path: '/completion-messages', data: { - inputs: { input: "x" }, - user: "user", + inputs: { input: 'x' }, + user: 'user', files: undefined, - response_mode: "blocking", + response_mode: 'blocking', }, - }); - }); + }) + }) - it("creates completion messages in streaming mode", async () => { - const { client, requestStream } = createHttpClientWithSpies(); - const completion = new CompletionClient(client); + it('creates completion messages in streaming mode', async () => { + const { client, requestStream } = createHttpClientWithSpies() + const completion = new CompletionClient(client) await completion.createCompletionMessage({ - inputs: { input: "x" }, - user: "user", - response_mode: "streaming", - }); + inputs: { input: 'x' }, + user: 'user', + response_mode: 'streaming', + }) expect(requestStream).toHaveBeenCalledWith({ - method: "POST", - path: "/completion-messages", + method: 'POST', + path: '/completion-messages', data: { - inputs: { input: "x" }, - user: "user", - response_mode: "streaming", + inputs: { input: 'x' }, + user: 'user', + response_mode: 'streaming', }, - }); - }); + }) + }) - it("stops completion messages", async () => { - const { client, request } = createHttpClientWithSpies(); - const completion = new CompletionClient(client); + it('stops completion messages', async () => { + const { client, request } = createHttpClientWithSpies() + const completion = new CompletionClient(client) - await completion.stopCompletionMessage("task", "user"); - await completion.stop("task", "user"); + await completion.stopCompletionMessage('task', 'user') + await completion.stop('task', 'user') expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/completion-messages/task/stop", - data: { user: "user" }, - }); - }); + method: 'POST', + path: '/completion-messages/task/stop', + data: { user: 'user' }, + }) + }) - it("supports deprecated runWorkflow", async () => { - const { client, request, requestStream } = createHttpClientWithSpies(); - const completion = new CompletionClient(client); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + it('supports deprecated runWorkflow', async () => { + const { client, request, requestStream } = createHttpClientWithSpies() + const completion = new CompletionClient(client) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await completion.runWorkflow({ input: "x" }, "user", false); - await completion.runWorkflow({ input: "x" }, "user", true); + await completion.runWorkflow({ input: 'x' }, 'user', false) + await completion.runWorkflow({ input: 'x' }, 'user', true) - expect(warn).toHaveBeenCalled(); + expect(warn).toHaveBeenCalled() expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/workflows/run", - data: { inputs: { input: "x" }, user: "user", response_mode: "blocking" }, - }); + method: 'POST', + path: '/workflows/run', + data: { inputs: { input: 'x' }, user: 'user', response_mode: 'blocking' }, + }) expect(requestStream).toHaveBeenCalledWith({ - method: "POST", - path: "/workflows/run", - data: { inputs: { input: "x" }, user: "user", response_mode: "streaming" }, - }); - }); -}); + method: 'POST', + path: '/workflows/run', + data: { inputs: { input: 'x' }, user: 'user', response_mode: 'streaming' }, + }) + }) +}) diff --git a/sdks/nodejs-client/src/client/completion.ts b/sdks/nodejs-client/src/client/completion.ts index f4e7121776ab07..0f7f9ad6fcb657 100644 --- a/sdks/nodejs-client/src/client/completion.ts +++ b/sdks/nodejs-client/src/client/completion.ts @@ -1,116 +1,103 @@ -import { DifyClient } from "./base"; -import type { CompletionRequest, CompletionResponse } from "../types/completion"; -import type { - DifyResponse, - DifyStream, - JsonObject, - SuccessResponse, -} from "../types/common"; -import { ensureNonEmptyString } from "./validation"; +import type { DifyResponse, DifyStream, JsonObject, SuccessResponse } from '../types/common' +import type { CompletionRequest, CompletionResponse } from '../types/completion' +import { DifyClient } from './base' +import { ensureNonEmptyString } from './validation' -const warned = new Set<string>(); +const warned = new Set<string>() const warnOnce = (message: string): void => { if (warned.has(message)) { - return; + return } - warned.add(message); - console.warn(message); -}; + warned.add(message) + console.warn(message) +} export class CompletionClient extends DifyClient { createCompletionMessage( - request: CompletionRequest - ): Promise<DifyResponse<CompletionResponse> | DifyStream<CompletionResponse>>; + request: CompletionRequest, + ): Promise<DifyResponse<CompletionResponse> | DifyStream<CompletionResponse>> createCompletionMessage( inputs: JsonObject, user: string, stream?: boolean, - files?: CompletionRequest["files"] - ): Promise<DifyResponse<CompletionResponse> | DifyStream<CompletionResponse>>; + files?: CompletionRequest['files'], + ): Promise<DifyResponse<CompletionResponse> | DifyStream<CompletionResponse>> createCompletionMessage( inputOrRequest: CompletionRequest | JsonObject, user?: string, stream = false, - files?: CompletionRequest["files"] + files?: CompletionRequest['files'], ): Promise<DifyResponse<CompletionResponse> | DifyStream<CompletionResponse>> { - let payload: CompletionRequest; - let shouldStream = stream; + let payload: CompletionRequest + let shouldStream = stream - if (user === undefined && "user" in (inputOrRequest as CompletionRequest)) { - payload = inputOrRequest as CompletionRequest; - shouldStream = payload.response_mode === "streaming"; + if (user === undefined && 'user' in (inputOrRequest as CompletionRequest)) { + payload = inputOrRequest as CompletionRequest + shouldStream = payload.response_mode === 'streaming' } else { - ensureNonEmptyString(user, "user"); + ensureNonEmptyString(user, 'user') payload = { inputs: inputOrRequest, user, files, - response_mode: stream ? "streaming" : "blocking", - }; + response_mode: stream ? 'streaming' : 'blocking', + } } - ensureNonEmptyString(payload.user, "user"); + ensureNonEmptyString(payload.user, 'user') if (shouldStream) { return this.http.requestStream<CompletionResponse>({ - method: "POST", - path: "/completion-messages", + method: 'POST', + path: '/completion-messages', data: payload, - }); + }) } return this.http.request<CompletionResponse>({ - method: "POST", - path: "/completion-messages", + method: 'POST', + path: '/completion-messages', data: payload, - }); + }) } - stopCompletionMessage( - taskId: string, - user: string - ): Promise<DifyResponse<SuccessResponse>> { - ensureNonEmptyString(taskId, "taskId"); - ensureNonEmptyString(user, "user"); + stopCompletionMessage(taskId: string, user: string): Promise<DifyResponse<SuccessResponse>> { + ensureNonEmptyString(taskId, 'taskId') + ensureNonEmptyString(user, 'user') return this.http.request<SuccessResponse>({ - method: "POST", + method: 'POST', path: `/completion-messages/${taskId}/stop`, data: { user }, - }); + }) } - stop( - taskId: string, - user: string - ): Promise<DifyResponse<SuccessResponse>> { - return this.stopCompletionMessage(taskId, user); + stop(taskId: string, user: string): Promise<DifyResponse<SuccessResponse>> { + return this.stopCompletionMessage(taskId, user) } runWorkflow( inputs: JsonObject, user: string, - stream = false + stream = false, ): Promise<DifyResponse<JsonObject> | DifyStream<JsonObject>> { - warnOnce( - "CompletionClient.runWorkflow is deprecated. Use WorkflowClient.run instead." - ); - ensureNonEmptyString(user, "user"); + warnOnce('CompletionClient.runWorkflow is deprecated. Use WorkflowClient.run instead.') + ensureNonEmptyString(user, 'user') const payload = { inputs, user, - response_mode: stream ? "streaming" : "blocking", - }; + response_mode: stream ? 'streaming' : 'blocking', + } if (stream) { return this.http.requestStream<JsonObject>({ - method: "POST", - path: "/workflows/run", + method: 'POST', + path: '/workflows/run', data: payload, - }); + }) } return this.http.request<JsonObject>({ - method: "POST", - path: "/workflows/run", + method: 'POST', + path: '/workflows/run', data: payload, - }); + }) } } diff --git a/sdks/nodejs-client/src/client/knowledge-base.test.ts b/sdks/nodejs-client/src/client/knowledge-base.test.ts index 113a9db24b3927..60fd82285d2242 100644 --- a/sdks/nodejs-client/src/client/knowledge-base.test.ts +++ b/sdks/nodejs-client/src/client/knowledge-base.test.ts @@ -1,266 +1,262 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { FileUploadError, ValidationError } from "../errors/dify-error"; -import { KnowledgeBaseClient } from "./knowledge-base"; -import { createHttpClientWithSpies } from "../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createHttpClientWithSpies } from '../../tests/test-utils' +import { FileUploadError, ValidationError } from '../errors/dify-error' +import { KnowledgeBaseClient } from './knowledge-base' -describe("KnowledgeBaseClient", () => { +describe('KnowledgeBaseClient', () => { beforeEach(() => { - vi.restoreAllMocks(); - }); + vi.restoreAllMocks() + }) - it("handles dataset and tag operations", async () => { - const { client, request } = createHttpClientWithSpies(); - const kb = new KnowledgeBaseClient(client); + it('handles dataset and tag operations', async () => { + const { client, request } = createHttpClientWithSpies() + const kb = new KnowledgeBaseClient(client) await kb.listDatasets({ page: 1, limit: 2, - keyword: "k", + keyword: 'k', includeAll: true, - tagIds: ["t1"], - }); - await kb.createDataset({ name: "dataset" }); - await kb.getDataset("ds"); - await kb.updateDataset("ds", { name: "new" }); - await kb.deleteDataset("ds"); - await kb.updateDocumentStatus("ds", "enable", ["doc1"]); + tagIds: ['t1'], + }) + await kb.createDataset({ name: 'dataset' }) + await kb.getDataset('ds') + await kb.updateDataset('ds', { name: 'new' }) + await kb.deleteDataset('ds') + await kb.updateDocumentStatus('ds', 'enable', ['doc1']) - await kb.listTags(); - await kb.createTag({ name: "tag" }); - await kb.updateTag({ tag_id: "tag", name: "name" }); - await kb.deleteTag({ tag_id: "tag" }); - await kb.bindTags({ tag_ids: ["tag"], target_id: "doc" }); - await kb.unbindTags({ tag_id: "tag", target_id: "doc" }); - await kb.getDatasetTags("ds"); + await kb.listTags() + await kb.createTag({ name: 'tag' }) + await kb.updateTag({ tag_id: 'tag', name: 'name' }) + await kb.deleteTag({ tag_id: 'tag' }) + await kb.bindTags({ tag_ids: ['tag'], target_id: 'doc' }) + await kb.unbindTags({ tag_id: 'tag', target_id: 'doc' }) + await kb.getDatasetTags('ds') expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/datasets", + method: 'GET', + path: '/datasets', query: { page: 1, limit: 2, - keyword: "k", + keyword: 'k', include_all: true, - tag_ids: ["t1"], + tag_ids: ['t1'], }, - }); + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets", - data: { name: "dataset" }, - }); + method: 'POST', + path: '/datasets', + data: { name: 'dataset' }, + }) expect(request).toHaveBeenCalledWith({ - method: "PATCH", - path: "/datasets/ds", - data: { name: "new" }, - }); + method: 'PATCH', + path: '/datasets/ds', + data: { name: 'new' }, + }) expect(request).toHaveBeenCalledWith({ - method: "PATCH", - path: "/datasets/ds/documents/status/enable", - data: { document_ids: ["doc1"] }, - }); + method: 'PATCH', + path: '/datasets/ds/documents/status/enable', + data: { document_ids: ['doc1'] }, + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets/tags/binding", - data: { tag_ids: ["tag"], target_id: "doc" }, - }); - }); + method: 'POST', + path: '/datasets/tags/binding', + data: { tag_ids: ['tag'], target_id: 'doc' }, + }) + }) - it("handles document operations", async () => { - const { client, request } = createHttpClientWithSpies(); - const kb = new KnowledgeBaseClient(client); - const form = { append: vi.fn(), getHeaders: () => ({}) }; + it('handles document operations', async () => { + const { client, request } = createHttpClientWithSpies() + const kb = new KnowledgeBaseClient(client) + const form = { append: vi.fn(), getHeaders: () => ({}) } - await kb.createDocumentByText("ds", { name: "doc", text: "text" }); - await kb.updateDocumentByText("ds", "doc", { name: "doc2" }); - await kb.createDocumentByFile("ds", form); - await kb.updateDocumentByFile("ds", "doc", form); - await kb.listDocuments("ds", { page: 1, limit: 20, keyword: "k" }); - await kb.getDocument("ds", "doc", { metadata: "all" }); - await kb.deleteDocument("ds", "doc"); - await kb.getDocumentIndexingStatus("ds", "batch"); + await kb.createDocumentByText('ds', { name: 'doc', text: 'text' }) + await kb.updateDocumentByText('ds', 'doc', { name: 'doc2' }) + await kb.createDocumentByFile('ds', form) + await kb.updateDocumentByFile('ds', 'doc', form) + await kb.listDocuments('ds', { page: 1, limit: 20, keyword: 'k' }) + await kb.getDocument('ds', 'doc', { metadata: 'all' }) + await kb.deleteDocument('ds', 'doc') + await kb.getDocumentIndexingStatus('ds', 'batch') expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets/ds/document/create_by_text", - data: { name: "doc", text: "text" }, - }); + method: 'POST', + path: '/datasets/ds/document/create_by_text', + data: { name: 'doc', text: 'text' }, + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets/ds/documents/doc/update_by_text", - data: { name: "doc2" }, - }); + method: 'POST', + path: '/datasets/ds/documents/doc/update_by_text', + data: { name: 'doc2' }, + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets/ds/document/create_by_file", + method: 'POST', + path: '/datasets/ds/document/create_by_file', data: form, - }); + }) expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/datasets/ds/documents", - query: { page: 1, limit: 20, keyword: "k", status: undefined }, - }); - }); + method: 'GET', + path: '/datasets/ds/documents', + query: { page: 1, limit: 20, keyword: 'k', status: undefined }, + }) + }) - it("handles segments and child chunks", async () => { - const { client, request } = createHttpClientWithSpies(); - const kb = new KnowledgeBaseClient(client); + it('handles segments and child chunks', async () => { + const { client, request } = createHttpClientWithSpies() + const kb = new KnowledgeBaseClient(client) - await kb.createSegments("ds", "doc", { segments: [{ content: "x" }] }); - await kb.listSegments("ds", "doc", { page: 1, limit: 10, keyword: "k" }); - await kb.getSegment("ds", "doc", "seg"); - await kb.updateSegment("ds", "doc", "seg", { - segment: { content: "y" }, - }); - await kb.deleteSegment("ds", "doc", "seg"); + await kb.createSegments('ds', 'doc', { segments: [{ content: 'x' }] }) + await kb.listSegments('ds', 'doc', { page: 1, limit: 10, keyword: 'k' }) + await kb.getSegment('ds', 'doc', 'seg') + await kb.updateSegment('ds', 'doc', 'seg', { + segment: { content: 'y' }, + }) + await kb.deleteSegment('ds', 'doc', 'seg') - await kb.createChildChunk("ds", "doc", "seg", { content: "c" }); - await kb.listChildChunks("ds", "doc", "seg", { page: 1, limit: 10 }); - await kb.updateChildChunk("ds", "doc", "seg", "child", { - content: "c2", - }); - await kb.deleteChildChunk("ds", "doc", "seg", "child"); + await kb.createChildChunk('ds', 'doc', 'seg', { content: 'c' }) + await kb.listChildChunks('ds', 'doc', 'seg', { page: 1, limit: 10 }) + await kb.updateChildChunk('ds', 'doc', 'seg', 'child', { + content: 'c2', + }) + await kb.deleteChildChunk('ds', 'doc', 'seg', 'child') expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets/ds/documents/doc/segments", - data: { segments: [{ content: "x" }] }, - }); + method: 'POST', + path: '/datasets/ds/documents/doc/segments', + data: { segments: [{ content: 'x' }] }, + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets/ds/documents/doc/segments/seg", - data: { segment: { content: "y" } }, - }); + method: 'POST', + path: '/datasets/ds/documents/doc/segments/seg', + data: { segment: { content: 'y' } }, + }) expect(request).toHaveBeenCalledWith({ - method: "PATCH", - path: "/datasets/ds/documents/doc/segments/seg/child_chunks/child", - data: { content: "c2" }, - }); - }); + method: 'PATCH', + path: '/datasets/ds/documents/doc/segments/seg/child_chunks/child', + data: { content: 'c2' }, + }) + }) - it("handles metadata and retrieval", async () => { - const { client, request } = createHttpClientWithSpies(); - const kb = new KnowledgeBaseClient(client); + it('handles metadata and retrieval', async () => { + const { client, request } = createHttpClientWithSpies() + const kb = new KnowledgeBaseClient(client) - await kb.listMetadata("ds"); - await kb.createMetadata("ds", { name: "m", type: "string" }); - await kb.updateMetadata("ds", "mid", { name: "m2" }); - await kb.deleteMetadata("ds", "mid"); - await kb.listBuiltInMetadata("ds"); - await kb.updateBuiltInMetadata("ds", "enable"); - await kb.updateDocumentsMetadata("ds", { - operation_data: [ - { document_id: "doc", metadata_list: [{ id: "m", name: "n" }] }, - ], - }); - await kb.hitTesting("ds", { query: "q" }); - await kb.retrieve("ds", { query: "q" }); + await kb.listMetadata('ds') + await kb.createMetadata('ds', { name: 'm', type: 'string' }) + await kb.updateMetadata('ds', 'mid', { name: 'm2' }) + await kb.deleteMetadata('ds', 'mid') + await kb.listBuiltInMetadata('ds') + await kb.updateBuiltInMetadata('ds', 'enable') + await kb.updateDocumentsMetadata('ds', { + operation_data: [{ document_id: 'doc', metadata_list: [{ id: 'm', name: 'n' }] }], + }) + await kb.hitTesting('ds', { query: 'q' }) + await kb.retrieve('ds', { query: 'q' }) expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/datasets/ds/metadata", - }); + method: 'GET', + path: '/datasets/ds/metadata', + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets/ds/metadata", - data: { name: "m", type: "string" }, - }); + method: 'POST', + path: '/datasets/ds/metadata', + data: { name: 'm', type: 'string' }, + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets/ds/hit-testing", - data: { query: "q" }, - }); - }); + method: 'POST', + path: '/datasets/ds/hit-testing', + data: { query: 'q' }, + }) + }) - it("handles pipeline operations", async () => { - const { client, request, requestStream } = createHttpClientWithSpies(); - const kb = new KnowledgeBaseClient(client); - const form = { append: vi.fn(), getHeaders: () => ({}) }; + it('handles pipeline operations', async () => { + const { client, request, requestStream } = createHttpClientWithSpies() + const kb = new KnowledgeBaseClient(client) + const form = { append: vi.fn(), getHeaders: () => ({}) } - await kb.listDatasourcePlugins("ds", { isPublished: true }); - await kb.runDatasourceNode("ds", "node", { - inputs: { input: "x" }, - datasource_type: "custom", + await kb.listDatasourcePlugins('ds', { isPublished: true }) + await kb.runDatasourceNode('ds', 'node', { + inputs: { input: 'x' }, + datasource_type: 'custom', is_published: true, - }); - await kb.runPipeline("ds", { - inputs: { input: "x" }, - datasource_type: "custom", + }) + await kb.runPipeline('ds', { + inputs: { input: 'x' }, + datasource_type: 'custom', datasource_info_list: [], - start_node_id: "start", + start_node_id: 'start', is_published: true, - response_mode: "streaming", - }); - await kb.runPipeline("ds", { - inputs: { input: "x" }, - datasource_type: "custom", + response_mode: 'streaming', + }) + await kb.runPipeline('ds', { + inputs: { input: 'x' }, + datasource_type: 'custom', datasource_info_list: [], - start_node_id: "start", + start_node_id: 'start', is_published: true, - response_mode: "blocking", - }); - await kb.uploadPipelineFile(form); + response_mode: 'blocking', + }) + await kb.uploadPipelineFile(form) expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/datasets/ds/pipeline/datasource-plugins", + method: 'GET', + path: '/datasets/ds/pipeline/datasource-plugins', query: { is_published: true }, - }); + }) expect(requestStream).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets/ds/pipeline/datasource/nodes/node/run", + method: 'POST', + path: '/datasets/ds/pipeline/datasource/nodes/node/run', data: { - inputs: { input: "x" }, - datasource_type: "custom", + inputs: { input: 'x' }, + datasource_type: 'custom', is_published: true, }, - }); + }) expect(requestStream).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets/ds/pipeline/run", + method: 'POST', + path: '/datasets/ds/pipeline/run', data: { - inputs: { input: "x" }, - datasource_type: "custom", + inputs: { input: 'x' }, + datasource_type: 'custom', datasource_info_list: [], - start_node_id: "start", + start_node_id: 'start', is_published: true, - response_mode: "streaming", + response_mode: 'streaming', }, - }); + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets/ds/pipeline/run", + method: 'POST', + path: '/datasets/ds/pipeline/run', data: { - inputs: { input: "x" }, - datasource_type: "custom", + inputs: { input: 'x' }, + datasource_type: 'custom', datasource_info_list: [], - start_node_id: "start", + start_node_id: 'start', is_published: true, - response_mode: "blocking", + response_mode: 'blocking', }, - }); + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/datasets/pipeline/file-upload", + method: 'POST', + path: '/datasets/pipeline/file-upload', data: form, - }); - }); + }) + }) - it("validates form-data and optional array filters", async () => { - const { client } = createHttpClientWithSpies(); - const kb = new KnowledgeBaseClient(client); + it('validates form-data and optional array filters', async () => { + const { client } = createHttpClientWithSpies() + const kb = new KnowledgeBaseClient(client) - await expect(kb.createDocumentByFile("ds", {})).rejects.toBeInstanceOf( - FileUploadError - ); + await expect(kb.createDocumentByFile('ds', {})).rejects.toBeInstanceOf(FileUploadError) await expect( - kb.listSegments("ds", "doc", { status: ["ok", 1] as unknown as string[] }) - ).rejects.toBeInstanceOf(ValidationError); + kb.listSegments('ds', 'doc', { status: ['ok', 1] as unknown as string[] }), + ).rejects.toBeInstanceOf(ValidationError) await expect( - kb.hitTesting("ds", { - query: "q", - attachment_ids: ["att-1", 2] as unknown as string[], - }) - ).rejects.toBeInstanceOf(ValidationError); - }); -}); + kb.hitTesting('ds', { + query: 'q', + attachment_ids: ['att-1', 2] as unknown as string[], + }), + ).rejects.toBeInstanceOf(ValidationError) + }) +}) diff --git a/sdks/nodejs-client/src/client/knowledge-base.ts b/sdks/nodejs-client/src/client/knowledge-base.ts index 9871c098e96f03..bab1177d791537 100644 --- a/sdks/nodejs-client/src/client/knowledge-base.ts +++ b/sdks/nodejs-client/src/client/knowledge-base.ts @@ -1,4 +1,5 @@ -import { DifyClient } from "./base"; +import type { SdkFormData } from '../http/form-data' +import type { DifyResponse, DifyStream, QueryParams } from '../types/common' import type { DatasetCreateRequest, DatasetListOptions, @@ -28,267 +29,249 @@ import type { PipelineRunRequest, KnowledgeBaseResponse, PipelineStreamEvent, -} from "../types/knowledge-base"; -import type { DifyResponse, DifyStream, QueryParams } from "../types/common"; +} from '../types/knowledge-base' +import { FileUploadError, ValidationError } from '../errors/dify-error' +import { isFormData } from '../http/form-data' +import { DifyClient } from './base' import { ensureNonEmptyString, ensureOptionalBoolean, ensureOptionalInt, ensureOptionalString, ensureStringArray, -} from "./validation"; -import { FileUploadError, ValidationError } from "../errors/dify-error"; -import type { SdkFormData } from "../http/form-data"; -import { isFormData } from "../http/form-data"; - -function ensureFormData( - form: unknown, - context: string -): asserts form is SdkFormData { +} from './validation' + +function ensureFormData(form: unknown, context: string): asserts form is SdkFormData { if (!isFormData(form)) { - throw new FileUploadError(`${context} requires FormData`); + throw new FileUploadError(`${context} requires FormData`) } } const ensureNonEmptyArray = (value: unknown, name: string): void => { if (!Array.isArray(value) || value.length === 0) { - throw new ValidationError(`${name} must be a non-empty array`); + throw new ValidationError(`${name} must be a non-empty array`) } -}; +} export class KnowledgeBaseClient extends DifyClient { - async listDatasets( - options?: DatasetListOptions - ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureOptionalInt(options?.page, "page"); - ensureOptionalInt(options?.limit, "limit"); - ensureOptionalString(options?.keyword, "keyword"); - ensureOptionalBoolean(options?.includeAll, "includeAll"); + async listDatasets(options?: DatasetListOptions): Promise<DifyResponse<KnowledgeBaseResponse>> { + ensureOptionalInt(options?.page, 'page') + ensureOptionalInt(options?.limit, 'limit') + ensureOptionalString(options?.keyword, 'keyword') + ensureOptionalBoolean(options?.includeAll, 'includeAll') const query: QueryParams = { page: options?.page, limit: options?.limit, keyword: options?.keyword ?? undefined, include_all: options?.includeAll ?? undefined, - }; + } if (options?.tagIds && options.tagIds.length > 0) { - ensureStringArray(options.tagIds, "tagIds"); - query.tag_ids = options.tagIds; + ensureStringArray(options.tagIds, 'tagIds') + query.tag_ids = options.tagIds } return this.http.request({ - method: "GET", - path: "/datasets", + method: 'GET', + path: '/datasets', query, - }); + }) } - async createDataset( - request: DatasetCreateRequest - ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(request.name, "name"); + async createDataset(request: DatasetCreateRequest): Promise<DifyResponse<KnowledgeBaseResponse>> { + ensureNonEmptyString(request.name, 'name') return this.http.request({ - method: "POST", - path: "/datasets", + method: 'POST', + path: '/datasets', data: request, - }); + }) } async getDataset(datasetId: string): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); + ensureNonEmptyString(datasetId, 'datasetId') return this.http.request({ - method: "GET", + method: 'GET', path: `/datasets/${datasetId}`, - }); + }) } async updateDataset( datasetId: string, - request: DatasetUpdateRequest + request: DatasetUpdateRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); + ensureNonEmptyString(datasetId, 'datasetId') if (request.name !== undefined && request.name !== null) { - ensureNonEmptyString(request.name, "name"); + ensureNonEmptyString(request.name, 'name') } return this.http.request({ - method: "PATCH", + method: 'PATCH', path: `/datasets/${datasetId}`, data: request, - }); + }) } async deleteDataset(datasetId: string): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); + ensureNonEmptyString(datasetId, 'datasetId') return this.http.request({ - method: "DELETE", + method: 'DELETE', path: `/datasets/${datasetId}`, - }); + }) } async updateDocumentStatus( datasetId: string, action: DocumentStatusAction, - documentIds: string[] + documentIds: string[], ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(action, "action"); - ensureStringArray(documentIds, "documentIds"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(action, 'action') + ensureStringArray(documentIds, 'documentIds') return this.http.request({ - method: "PATCH", + method: 'PATCH', path: `/datasets/${datasetId}/documents/status/${action}`, data: { document_ids: documentIds, }, - }); + }) } async listTags(): Promise<DifyResponse<KnowledgeBaseResponse>> { return this.http.request({ - method: "GET", - path: "/datasets/tags", - }); + method: 'GET', + path: '/datasets/tags', + }) } - async createTag( - request: DatasetTagCreateRequest - ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(request.name, "name"); + async createTag(request: DatasetTagCreateRequest): Promise<DifyResponse<KnowledgeBaseResponse>> { + ensureNonEmptyString(request.name, 'name') return this.http.request({ - method: "POST", - path: "/datasets/tags", + method: 'POST', + path: '/datasets/tags', data: request, - }); + }) } - async updateTag( - request: DatasetTagUpdateRequest - ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(request.tag_id, "tag_id"); - ensureNonEmptyString(request.name, "name"); + async updateTag(request: DatasetTagUpdateRequest): Promise<DifyResponse<KnowledgeBaseResponse>> { + ensureNonEmptyString(request.tag_id, 'tag_id') + ensureNonEmptyString(request.name, 'name') return this.http.request({ - method: "PATCH", - path: "/datasets/tags", + method: 'PATCH', + path: '/datasets/tags', data: request, - }); + }) } - async deleteTag( - request: DatasetTagDeleteRequest - ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(request.tag_id, "tag_id"); + async deleteTag(request: DatasetTagDeleteRequest): Promise<DifyResponse<KnowledgeBaseResponse>> { + ensureNonEmptyString(request.tag_id, 'tag_id') return this.http.request({ - method: "DELETE", - path: "/datasets/tags", + method: 'DELETE', + path: '/datasets/tags', data: request, - }); + }) } - async bindTags( - request: DatasetTagBindingRequest - ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureStringArray(request.tag_ids, "tag_ids"); - ensureNonEmptyString(request.target_id, "target_id"); + async bindTags(request: DatasetTagBindingRequest): Promise<DifyResponse<KnowledgeBaseResponse>> { + ensureStringArray(request.tag_ids, 'tag_ids') + ensureNonEmptyString(request.target_id, 'target_id') return this.http.request({ - method: "POST", - path: "/datasets/tags/binding", + method: 'POST', + path: '/datasets/tags/binding', data: request, - }); + }) } async unbindTags( - request: DatasetTagUnbindingRequest + request: DatasetTagUnbindingRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(request.tag_id, "tag_id"); - ensureNonEmptyString(request.target_id, "target_id"); + ensureNonEmptyString(request.tag_id, 'tag_id') + ensureNonEmptyString(request.target_id, 'target_id') return this.http.request({ - method: "POST", - path: "/datasets/tags/unbinding", + method: 'POST', + path: '/datasets/tags/unbinding', data: request, - }); + }) } - async getDatasetTags( - datasetId: string - ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); + async getDatasetTags(datasetId: string): Promise<DifyResponse<KnowledgeBaseResponse>> { + ensureNonEmptyString(datasetId, 'datasetId') return this.http.request({ - method: "GET", + method: 'GET', path: `/datasets/${datasetId}/tags`, - }); + }) } async createDocumentByText( datasetId: string, - request: DocumentTextCreateRequest + request: DocumentTextCreateRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(request.name, "name"); - ensureNonEmptyString(request.text, "text"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(request.name, 'name') + ensureNonEmptyString(request.text, 'text') return this.http.request({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/document/create_by_text`, data: request, - }); + }) } async updateDocumentByText( datasetId: string, documentId: string, - request: DocumentTextUpdateRequest + request: DocumentTextUpdateRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') if (request.name !== undefined && request.name !== null) { - ensureNonEmptyString(request.name, "name"); + ensureNonEmptyString(request.name, 'name') } return this.http.request({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/documents/${documentId}/update_by_text`, data: request, - }); + }) } async createDocumentByFile( datasetId: string, - form: unknown + form: unknown, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureFormData(form, "createDocumentByFile"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureFormData(form, 'createDocumentByFile') return this.http.request({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/document/create_by_file`, data: form, - }); + }) } async updateDocumentByFile( datasetId: string, documentId: string, - form: unknown + form: unknown, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); - ensureFormData(form, "updateDocumentByFile"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') + ensureFormData(form, 'updateDocumentByFile') return this.http.request({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/documents/${documentId}/update_by_file`, data: form, - }); + }) } async listDocuments( datasetId: string, - options?: DocumentListOptions + options?: DocumentListOptions, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureOptionalInt(options?.page, "page"); - ensureOptionalInt(options?.limit, "limit"); - ensureOptionalString(options?.keyword, "keyword"); - ensureOptionalString(options?.status, "status"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureOptionalInt(options?.page, 'page') + ensureOptionalInt(options?.limit, 'limit') + ensureOptionalString(options?.keyword, 'keyword') + ensureOptionalString(options?.status, 'status') return this.http.request({ - method: "GET", + method: 'GET', path: `/datasets/${datasetId}/documents`, query: { page: options?.page, @@ -296,183 +279,183 @@ export class KnowledgeBaseClient extends DifyClient { keyword: options?.keyword ?? undefined, status: options?.status ?? undefined, }, - }); + }) } async getDocument( datasetId: string, documentId: string, - options?: DocumentGetOptions + options?: DocumentGetOptions, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') if (options?.metadata) { - const allowed = new Set(["all", "only", "without"]); + const allowed = new Set(['all', 'only', 'without']) if (!allowed.has(options.metadata)) { - throw new ValidationError("metadata must be one of all, only, without"); + throw new ValidationError('metadata must be one of all, only, without') } } return this.http.request({ - method: "GET", + method: 'GET', path: `/datasets/${datasetId}/documents/${documentId}`, query: { metadata: options?.metadata ?? undefined, }, - }); + }) } async deleteDocument( datasetId: string, - documentId: string + documentId: string, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') return this.http.request({ - method: "DELETE", + method: 'DELETE', path: `/datasets/${datasetId}/documents/${documentId}`, - }); + }) } async getDocumentIndexingStatus( datasetId: string, - batch: string + batch: string, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(batch, "batch"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(batch, 'batch') return this.http.request({ - method: "GET", + method: 'GET', path: `/datasets/${datasetId}/documents/${batch}/indexing-status`, - }); + }) } async createSegments( datasetId: string, documentId: string, - request: SegmentCreateRequest + request: SegmentCreateRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); - ensureNonEmptyArray(request.segments, "segments"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') + ensureNonEmptyArray(request.segments, 'segments') return this.http.request({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/documents/${documentId}/segments`, data: request, - }); + }) } async listSegments( datasetId: string, documentId: string, - options?: SegmentListOptions + options?: SegmentListOptions, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); - ensureOptionalInt(options?.page, "page"); - ensureOptionalInt(options?.limit, "limit"); - ensureOptionalString(options?.keyword, "keyword"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') + ensureOptionalInt(options?.page, 'page') + ensureOptionalInt(options?.limit, 'limit') + ensureOptionalString(options?.keyword, 'keyword') if (options?.status && options.status.length > 0) { - ensureStringArray(options.status, "status"); + ensureStringArray(options.status, 'status') } const query: QueryParams = { page: options?.page, limit: options?.limit, keyword: options?.keyword ?? undefined, - }; + } if (options?.status && options.status.length > 0) { - query.status = options.status; + query.status = options.status } return this.http.request({ - method: "GET", + method: 'GET', path: `/datasets/${datasetId}/documents/${documentId}/segments`, query, - }); + }) } async getSegment( datasetId: string, documentId: string, - segmentId: string + segmentId: string, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); - ensureNonEmptyString(segmentId, "segmentId"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') + ensureNonEmptyString(segmentId, 'segmentId') return this.http.request({ - method: "GET", + method: 'GET', path: `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}`, - }); + }) } async updateSegment( datasetId: string, documentId: string, segmentId: string, - request: SegmentUpdateRequest + request: SegmentUpdateRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); - ensureNonEmptyString(segmentId, "segmentId"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') + ensureNonEmptyString(segmentId, 'segmentId') return this.http.request({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}`, data: request, - }); + }) } async deleteSegment( datasetId: string, documentId: string, - segmentId: string + segmentId: string, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); - ensureNonEmptyString(segmentId, "segmentId"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') + ensureNonEmptyString(segmentId, 'segmentId') return this.http.request({ - method: "DELETE", + method: 'DELETE', path: `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}`, - }); + }) } async createChildChunk( datasetId: string, documentId: string, segmentId: string, - request: ChildChunkCreateRequest + request: ChildChunkCreateRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); - ensureNonEmptyString(segmentId, "segmentId"); - ensureNonEmptyString(request.content, "content"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') + ensureNonEmptyString(segmentId, 'segmentId') + ensureNonEmptyString(request.content, 'content') return this.http.request({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks`, data: request, - }); + }) } async listChildChunks( datasetId: string, documentId: string, segmentId: string, - options?: ChildChunkListOptions + options?: ChildChunkListOptions, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); - ensureNonEmptyString(segmentId, "segmentId"); - ensureOptionalInt(options?.page, "page"); - ensureOptionalInt(options?.limit, "limit"); - ensureOptionalString(options?.keyword, "keyword"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') + ensureNonEmptyString(segmentId, 'segmentId') + ensureOptionalInt(options?.page, 'page') + ensureOptionalInt(options?.limit, 'limit') + ensureOptionalString(options?.keyword, 'keyword') return this.http.request({ - method: "GET", + method: 'GET', path: `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks`, query: { page: options?.page, limit: options?.limit, keyword: options?.keyword ?? undefined, }, - }); + }) } async updateChildChunk( @@ -480,212 +463,206 @@ export class KnowledgeBaseClient extends DifyClient { documentId: string, segmentId: string, childChunkId: string, - request: ChildChunkUpdateRequest + request: ChildChunkUpdateRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); - ensureNonEmptyString(segmentId, "segmentId"); - ensureNonEmptyString(childChunkId, "childChunkId"); - ensureNonEmptyString(request.content, "content"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') + ensureNonEmptyString(segmentId, 'segmentId') + ensureNonEmptyString(childChunkId, 'childChunkId') + ensureNonEmptyString(request.content, 'content') return this.http.request({ - method: "PATCH", + method: 'PATCH', path: `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks/${childChunkId}`, data: request, - }); + }) } async deleteChildChunk( datasetId: string, documentId: string, segmentId: string, - childChunkId: string + childChunkId: string, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(documentId, "documentId"); - ensureNonEmptyString(segmentId, "segmentId"); - ensureNonEmptyString(childChunkId, "childChunkId"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(documentId, 'documentId') + ensureNonEmptyString(segmentId, 'segmentId') + ensureNonEmptyString(childChunkId, 'childChunkId') return this.http.request({ - method: "DELETE", + method: 'DELETE', path: `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks/${childChunkId}`, - }); + }) } - async listMetadata( - datasetId: string - ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); + async listMetadata(datasetId: string): Promise<DifyResponse<KnowledgeBaseResponse>> { + ensureNonEmptyString(datasetId, 'datasetId') return this.http.request({ - method: "GET", + method: 'GET', path: `/datasets/${datasetId}/metadata`, - }); + }) } async createMetadata( datasetId: string, - request: MetadataCreateRequest + request: MetadataCreateRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(request.name, "name"); - ensureNonEmptyString(request.type, "type"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(request.name, 'name') + ensureNonEmptyString(request.type, 'type') return this.http.request({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/metadata`, data: request, - }); + }) } async updateMetadata( datasetId: string, metadataId: string, - request: MetadataUpdateRequest + request: MetadataUpdateRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(metadataId, "metadataId"); - ensureNonEmptyString(request.name, "name"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(metadataId, 'metadataId') + ensureNonEmptyString(request.name, 'name') return this.http.request({ - method: "PATCH", + method: 'PATCH', path: `/datasets/${datasetId}/metadata/${metadataId}`, data: request, - }); + }) } async deleteMetadata( datasetId: string, - metadataId: string + metadataId: string, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(metadataId, "metadataId"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(metadataId, 'metadataId') return this.http.request({ - method: "DELETE", + method: 'DELETE', path: `/datasets/${datasetId}/metadata/${metadataId}`, - }); + }) } - async listBuiltInMetadata( - datasetId: string - ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); + async listBuiltInMetadata(datasetId: string): Promise<DifyResponse<KnowledgeBaseResponse>> { + ensureNonEmptyString(datasetId, 'datasetId') return this.http.request({ - method: "GET", + method: 'GET', path: `/datasets/${datasetId}/metadata/built-in`, - }); + }) } async updateBuiltInMetadata( datasetId: string, - action: "enable" | "disable" + action: 'enable' | 'disable', ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(action, "action"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(action, 'action') return this.http.request({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/metadata/built-in/${action}`, - }); + }) } async updateDocumentsMetadata( datasetId: string, - request: MetadataOperationRequest + request: MetadataOperationRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyArray(request.operation_data, "operation_data"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyArray(request.operation_data, 'operation_data') return this.http.request({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/documents/metadata`, data: request, - }); + }) } async hitTesting( datasetId: string, - request: HitTestingRequest + request: HitTestingRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); + ensureNonEmptyString(datasetId, 'datasetId') if (request.query !== undefined && request.query !== null) { - ensureOptionalString(request.query, "query"); + ensureOptionalString(request.query, 'query') } if (request.attachment_ids && request.attachment_ids.length > 0) { - ensureStringArray(request.attachment_ids, "attachment_ids"); + ensureStringArray(request.attachment_ids, 'attachment_ids') } return this.http.request({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/hit-testing`, data: request, - }); + }) } async retrieve( datasetId: string, - request: HitTestingRequest + request: HitTestingRequest, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); + ensureNonEmptyString(datasetId, 'datasetId') return this.http.request({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/retrieve`, data: request, - }); + }) } async listDatasourcePlugins( datasetId: string, - options?: DatasourcePluginListOptions + options?: DatasourcePluginListOptions, ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureOptionalBoolean(options?.isPublished, "isPublished"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureOptionalBoolean(options?.isPublished, 'isPublished') return this.http.request({ - method: "GET", + method: 'GET', path: `/datasets/${datasetId}/pipeline/datasource-plugins`, query: { is_published: options?.isPublished ?? undefined, }, - }); + }) } async runDatasourceNode( datasetId: string, nodeId: string, - request: DatasourceNodeRunRequest + request: DatasourceNodeRunRequest, ): Promise<DifyStream<PipelineStreamEvent>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(nodeId, "nodeId"); - ensureNonEmptyString(request.datasource_type, "datasource_type"); + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(nodeId, 'nodeId') + ensureNonEmptyString(request.datasource_type, 'datasource_type') return this.http.requestStream<PipelineStreamEvent>({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/pipeline/datasource/nodes/${nodeId}/run`, data: request, - }); + }) } async runPipeline( datasetId: string, - request: PipelineRunRequest + request: PipelineRunRequest, ): Promise<DifyResponse<KnowledgeBaseResponse> | DifyStream<PipelineStreamEvent>> { - ensureNonEmptyString(datasetId, "datasetId"); - ensureNonEmptyString(request.datasource_type, "datasource_type"); - ensureNonEmptyString(request.start_node_id, "start_node_id"); - const shouldStream = request.response_mode === "streaming"; + ensureNonEmptyString(datasetId, 'datasetId') + ensureNonEmptyString(request.datasource_type, 'datasource_type') + ensureNonEmptyString(request.start_node_id, 'start_node_id') + const shouldStream = request.response_mode === 'streaming' if (shouldStream) { return this.http.requestStream<PipelineStreamEvent>({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/pipeline/run`, data: request, - }); + }) } return this.http.request<KnowledgeBaseResponse>({ - method: "POST", + method: 'POST', path: `/datasets/${datasetId}/pipeline/run`, data: request, - }); + }) } - async uploadPipelineFile( - form: unknown - ): Promise<DifyResponse<KnowledgeBaseResponse>> { - ensureFormData(form, "uploadPipelineFile"); + async uploadPipelineFile(form: unknown): Promise<DifyResponse<KnowledgeBaseResponse>> { + ensureFormData(form, 'uploadPipelineFile') return this.http.request({ - method: "POST", - path: "/datasets/pipeline/file-upload", + method: 'POST', + path: '/datasets/pipeline/file-upload', data: form, - }); + }) } } diff --git a/sdks/nodejs-client/src/client/validation.test.ts b/sdks/nodejs-client/src/client/validation.test.ts index 384dd463099473..49f4b2c532f86f 100644 --- a/sdks/nodejs-client/src/client/validation.test.ts +++ b/sdks/nodejs-client/src/client/validation.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest' import { ensureNonEmptyString, ensureOptionalBoolean, @@ -8,81 +8,80 @@ import { ensureRating, ensureStringArray, validateParams, -} from "./validation"; +} from './validation' -const makeLongString = (length: number) => "a".repeat(length); +const makeLongString = (length: number) => 'a'.repeat(length) -describe("validation utilities", () => { - it("ensureNonEmptyString throws on empty or whitespace", () => { - expect(() => ensureNonEmptyString("", "name")).toThrow(); - expect(() => ensureNonEmptyString(" ", "name")).toThrow(); - }); +describe('validation utilities', () => { + it('ensureNonEmptyString throws on empty or whitespace', () => { + expect(() => ensureNonEmptyString('', 'name')).toThrow() + expect(() => ensureNonEmptyString(' ', 'name')).toThrow() + }) - it("ensureNonEmptyString throws on overly long strings", () => { - expect(() => ensureNonEmptyString(makeLongString(10001), "name")).toThrow(); - }); + it('ensureNonEmptyString throws on overly long strings', () => { + expect(() => ensureNonEmptyString(makeLongString(10001), 'name')).toThrow() + }) - it("ensureOptionalString ignores undefined and validates when set", () => { - expect(() => ensureOptionalString(undefined, "opt")).not.toThrow(); - expect(() => ensureOptionalString("", "opt")).toThrow(); - }); + it('ensureOptionalString ignores undefined and validates when set', () => { + expect(() => ensureOptionalString(undefined, 'opt')).not.toThrow() + expect(() => ensureOptionalString('', 'opt')).toThrow() + }) - it("ensureOptionalString throws on overly long strings", () => { - expect(() => ensureOptionalString(makeLongString(10001), "opt")).toThrow(); - }); + it('ensureOptionalString throws on overly long strings', () => { + expect(() => ensureOptionalString(makeLongString(10001), 'opt')).toThrow() + }) - it("ensureOptionalInt validates integer", () => { - expect(() => ensureOptionalInt(undefined, "limit")).not.toThrow(); - expect(() => ensureOptionalInt(1.2, "limit")).toThrow(); - }); + it('ensureOptionalInt validates integer', () => { + expect(() => ensureOptionalInt(undefined, 'limit')).not.toThrow() + expect(() => ensureOptionalInt(1.2, 'limit')).toThrow() + }) - it("ensureOptionalBoolean validates boolean", () => { - expect(() => ensureOptionalBoolean(undefined, "flag")).not.toThrow(); - expect(() => ensureOptionalBoolean("yes", "flag")).toThrow(); - }); + it('ensureOptionalBoolean validates boolean', () => { + expect(() => ensureOptionalBoolean(undefined, 'flag')).not.toThrow() + expect(() => ensureOptionalBoolean('yes', 'flag')).toThrow() + }) - it("ensureStringArray enforces size and content", () => { - expect(() => ensureStringArray([], "items")).toThrow(); - expect(() => ensureStringArray([""], "items")).toThrow(); + it('ensureStringArray enforces size and content', () => { + expect(() => ensureStringArray([], 'items')).toThrow() + expect(() => ensureStringArray([''], 'items')).toThrow() expect(() => - ensureStringArray(Array.from({ length: 1001 }, () => "a"), "items") - ).toThrow(); - expect(() => ensureStringArray(["ok"], "items")).not.toThrow(); - }); + ensureStringArray( + Array.from({ length: 1001 }, () => 'a'), + 'items', + ), + ).toThrow() + expect(() => ensureStringArray(['ok'], 'items')).not.toThrow() + }) - it("ensureOptionalStringArray ignores undefined", () => { - expect(() => ensureOptionalStringArray(undefined, "tags")).not.toThrow(); - }); + it('ensureOptionalStringArray ignores undefined', () => { + expect(() => ensureOptionalStringArray(undefined, 'tags')).not.toThrow() + }) - it("ensureOptionalStringArray validates when set", () => { - expect(() => ensureOptionalStringArray(["valid"], "tags")).not.toThrow(); - expect(() => ensureOptionalStringArray([], "tags")).toThrow(); - expect(() => ensureOptionalStringArray([""], "tags")).toThrow(); - }); + it('ensureOptionalStringArray validates when set', () => { + expect(() => ensureOptionalStringArray(['valid'], 'tags')).not.toThrow() + expect(() => ensureOptionalStringArray([], 'tags')).toThrow() + expect(() => ensureOptionalStringArray([''], 'tags')).toThrow() + }) - it("ensureRating validates allowed values", () => { - expect(() => ensureRating(undefined)).not.toThrow(); - expect(() => ensureRating("like")).not.toThrow(); - expect(() => ensureRating("bad")).toThrow(); - }); + it('ensureRating validates allowed values', () => { + expect(() => ensureRating(undefined)).not.toThrow() + expect(() => ensureRating('like')).not.toThrow() + expect(() => ensureRating('bad')).toThrow() + }) - it("validateParams enforces generic rules", () => { - expect(() => validateParams({ user: 123 })).toThrow(); - expect(() => validateParams({ rating: "bad" })).toThrow(); - expect(() => validateParams({ page: 1.1 })).toThrow(); - expect(() => validateParams({ files: "bad" })).toThrow(); - expect(() => validateParams({ keyword: "" })).not.toThrow(); - expect(() => validateParams({ name: makeLongString(10001) })).toThrow(); - expect(() => - validateParams({ items: Array.from({ length: 1001 }, () => "a") }) - ).toThrow(); + it('validateParams enforces generic rules', () => { + expect(() => validateParams({ user: 123 })).toThrow() + expect(() => validateParams({ rating: 'bad' })).toThrow() + expect(() => validateParams({ page: 1.1 })).toThrow() + expect(() => validateParams({ files: 'bad' })).toThrow() + expect(() => validateParams({ keyword: '' })).not.toThrow() + expect(() => validateParams({ name: makeLongString(10001) })).toThrow() + expect(() => validateParams({ items: Array.from({ length: 1001 }, () => 'a') })).toThrow() expect(() => validateParams({ - data: Object.fromEntries( - Array.from({ length: 101 }, (_, i) => [String(i), i]) - ), - }) - ).toThrow(); - expect(() => validateParams({ user: "u", page: 1 })).not.toThrow(); - }); -}); + data: Object.fromEntries(Array.from({ length: 101 }, (_, i) => [String(i), i])), + }), + ).toThrow() + expect(() => validateParams({ user: 'u', page: 1 })).not.toThrow() + }) +}) diff --git a/sdks/nodejs-client/src/client/validation.ts b/sdks/nodejs-client/src/client/validation.ts index 0fe747a8f9cb60..18d0b140657356 100644 --- a/sdks/nodejs-client/src/client/validation.ts +++ b/sdks/nodejs-client/src/client/validation.ts @@ -1,21 +1,16 @@ -import { ValidationError } from "../errors/dify-error"; -import { isRecord } from "../internal/type-guards"; +import { ValidationError } from '../errors/dify-error' +import { isRecord } from '../internal/type-guards' -const MAX_STRING_LENGTH = 10000; -const MAX_LIST_LENGTH = 1000; -const MAX_DICT_LENGTH = 100; +const MAX_STRING_LENGTH = 10000 +const MAX_LIST_LENGTH = 1000 +const MAX_DICT_LENGTH = 100 -export function ensureNonEmptyString( - value: unknown, - name: string -): asserts value is string { - if (typeof value !== "string" || value.trim().length === 0) { - throw new ValidationError(`${name} must be a non-empty string`); +export function ensureNonEmptyString(value: unknown, name: string): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new ValidationError(`${name} must be a non-empty string`) } if (value.length > MAX_STRING_LENGTH) { - throw new ValidationError( - `${name} exceeds maximum length of ${MAX_STRING_LENGTH} characters` - ); + throw new ValidationError(`${name} exceeds maximum length of ${MAX_STRING_LENGTH} characters`) } } @@ -28,110 +23,103 @@ export function ensureNonEmptyString( */ export function ensureOptionalString(value: unknown, name: string): void { if (value === undefined || value === null) { - return; + return } - if (typeof value !== "string" || value.trim().length === 0) { - throw new ValidationError(`${name} must be a non-empty string when set`); + if (typeof value !== 'string' || value.trim().length === 0) { + throw new ValidationError(`${name} must be a non-empty string when set`) } if (value.length > MAX_STRING_LENGTH) { - throw new ValidationError( - `${name} exceeds maximum length of ${MAX_STRING_LENGTH} characters` - ); + throw new ValidationError(`${name} exceeds maximum length of ${MAX_STRING_LENGTH} characters`) } } export function ensureOptionalInt(value: unknown, name: string): void { if (value === undefined || value === null) { - return; + return } if (!Number.isInteger(value)) { - throw new ValidationError(`${name} must be an integer when set`); + throw new ValidationError(`${name} must be an integer when set`) } } export function ensureOptionalBoolean(value: unknown, name: string): void { if (value === undefined || value === null) { - return; + return } - if (typeof value !== "boolean") { - throw new ValidationError(`${name} must be a boolean when set`); + if (typeof value !== 'boolean') { + throw new ValidationError(`${name} must be a boolean when set`) } } export function ensureStringArray(value: unknown, name: string): void { if (!Array.isArray(value) || value.length === 0) { - throw new ValidationError(`${name} must be a non-empty string array`); + throw new ValidationError(`${name} must be a non-empty string array`) } if (value.length > MAX_LIST_LENGTH) { - throw new ValidationError( - `${name} exceeds maximum size of ${MAX_LIST_LENGTH} items` - ); + throw new ValidationError(`${name} exceeds maximum size of ${MAX_LIST_LENGTH} items`) } value.forEach((item) => { - if (typeof item !== "string" || item.trim().length === 0) { - throw new ValidationError(`${name} must contain non-empty strings`); + if (typeof item !== 'string' || item.trim().length === 0) { + throw new ValidationError(`${name} must contain non-empty strings`) } - }); + }) } export function ensureOptionalStringArray(value: unknown, name: string): void { if (value === undefined || value === null) { - return; + return } - ensureStringArray(value, name); + ensureStringArray(value, name) } export function ensureRating(value: unknown): void { if (value === undefined || value === null) { - return; + return } - if (value !== "like" && value !== "dislike") { - throw new ValidationError("rating must be either 'like' or 'dislike'"); + if (value !== 'like' && value !== 'dislike') { + throw new ValidationError("rating must be either 'like' or 'dislike'") } } export function validateParams(params: Record<string, unknown>): void { Object.entries(params).forEach(([key, value]) => { if (value === undefined || value === null) { - return; + return } // Only check max length for strings; empty strings are allowed for optional params // Required fields are validated at method level via ensureNonEmptyString - if (typeof value === "string") { + if (typeof value === 'string') { if (value.length > MAX_STRING_LENGTH) { throw new ValidationError( - `Parameter '${key}' exceeds maximum length of ${MAX_STRING_LENGTH} characters` - ); + `Parameter '${key}' exceeds maximum length of ${MAX_STRING_LENGTH} characters`, + ) } } else if (Array.isArray(value)) { if (value.length > MAX_LIST_LENGTH) { throw new ValidationError( - `Parameter '${key}' exceeds maximum size of ${MAX_LIST_LENGTH} items` - ); + `Parameter '${key}' exceeds maximum size of ${MAX_LIST_LENGTH} items`, + ) } } else if (isRecord(value)) { if (Object.keys(value).length > MAX_DICT_LENGTH) { throw new ValidationError( - `Parameter '${key}' exceeds maximum size of ${MAX_DICT_LENGTH} items` - ); + `Parameter '${key}' exceeds maximum size of ${MAX_DICT_LENGTH} items`, + ) } } - if (key === "user" && typeof value !== "string") { - throw new ValidationError(`Parameter '${key}' must be a string`); + if (key === 'user' && typeof value !== 'string') { + throw new ValidationError(`Parameter '${key}' must be a string`) } - if ( - (key === "page" || key === "limit" || key === "page_size") && - !Number.isInteger(value) - ) { - throw new ValidationError(`Parameter '${key}' must be an integer`); + if ((key === 'page' || key === 'limit' || key === 'page_size') && !Number.isInteger(value)) { + throw new ValidationError(`Parameter '${key}' must be an integer`) } - if (key === "files" && !Array.isArray(value) && typeof value !== "object") { - throw new ValidationError(`Parameter '${key}' must be a list or dict`); + if (key === 'files' && !Array.isArray(value) && typeof value !== 'object') { + throw new ValidationError(`Parameter '${key}' must be a list or dict`) } - if (key === "rating" && value !== "like" && value !== "dislike") { - throw new ValidationError(`Parameter '${key}' must be 'like' or 'dislike'`); + if (key === 'rating' && value !== 'like' && value !== 'dislike') { + throw new ValidationError(`Parameter '${key}' must be 'like' or 'dislike'`) } - }); + }) } diff --git a/sdks/nodejs-client/src/client/workflow.test.ts b/sdks/nodejs-client/src/client/workflow.test.ts index 281540304e1f58..c09f359c48e4da 100644 --- a/sdks/nodejs-client/src/client/workflow.test.ts +++ b/sdks/nodejs-client/src/client/workflow.test.ts @@ -1,118 +1,118 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { WorkflowClient } from "./workflow"; -import { createHttpClientWithSpies } from "../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createHttpClientWithSpies } from '../../tests/test-utils' +import { WorkflowClient } from './workflow' -describe("WorkflowClient", () => { +describe('WorkflowClient', () => { beforeEach(() => { - vi.restoreAllMocks(); - }); + vi.restoreAllMocks() + }) - it("runs workflows with blocking and streaming modes", async () => { - const { client, request, requestStream } = createHttpClientWithSpies(); - const workflow = new WorkflowClient(client); + it('runs workflows with blocking and streaming modes', async () => { + const { client, request, requestStream } = createHttpClientWithSpies() + const workflow = new WorkflowClient(client) - await workflow.run({ inputs: { input: "x" }, user: "user" }); - await workflow.run({ input: "x" }, "user", true); + await workflow.run({ inputs: { input: 'x' }, user: 'user' }) + await workflow.run({ input: 'x' }, 'user', true) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/workflows/run", + method: 'POST', + path: '/workflows/run', data: { - inputs: { input: "x" }, - user: "user", + inputs: { input: 'x' }, + user: 'user', }, - }); + }) expect(requestStream).toHaveBeenCalledWith({ - method: "POST", - path: "/workflows/run", + method: 'POST', + path: '/workflows/run', data: { - inputs: { input: "x" }, - user: "user", - response_mode: "streaming", + inputs: { input: 'x' }, + user: 'user', + response_mode: 'streaming', }, - }); - }); + }) + }) - it("runs workflow by id", async () => { - const { client, request, requestStream } = createHttpClientWithSpies(); - const workflow = new WorkflowClient(client); + it('runs workflow by id', async () => { + const { client, request, requestStream } = createHttpClientWithSpies() + const workflow = new WorkflowClient(client) - await workflow.runById("wf", { - inputs: { input: "x" }, - user: "user", - response_mode: "blocking", - }); - await workflow.runById("wf", { - inputs: { input: "x" }, - user: "user", - response_mode: "streaming", - }); + await workflow.runById('wf', { + inputs: { input: 'x' }, + user: 'user', + response_mode: 'blocking', + }) + await workflow.runById('wf', { + inputs: { input: 'x' }, + user: 'user', + response_mode: 'streaming', + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/workflows/wf/run", + method: 'POST', + path: '/workflows/wf/run', data: { - inputs: { input: "x" }, - user: "user", - response_mode: "blocking", + inputs: { input: 'x' }, + user: 'user', + response_mode: 'blocking', }, - }); + }) expect(requestStream).toHaveBeenCalledWith({ - method: "POST", - path: "/workflows/wf/run", + method: 'POST', + path: '/workflows/wf/run', data: { - inputs: { input: "x" }, - user: "user", - response_mode: "streaming", + inputs: { input: 'x' }, + user: 'user', + response_mode: 'streaming', }, - }); - }); + }) + }) - it("gets run details and stops workflow", async () => { - const { client, request } = createHttpClientWithSpies(); - const workflow = new WorkflowClient(client); + it('gets run details and stops workflow', async () => { + const { client, request } = createHttpClientWithSpies() + const workflow = new WorkflowClient(client) - await workflow.getRun("run"); - await workflow.stop("task", "user"); + await workflow.getRun('run') + await workflow.stop('task', 'user') expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/workflows/run/run", - }); + method: 'GET', + path: '/workflows/run/run', + }) expect(request).toHaveBeenCalledWith({ - method: "POST", - path: "/workflows/tasks/task/stop", - data: { user: "user" }, - }); - }); + method: 'POST', + path: '/workflows/tasks/task/stop', + data: { user: 'user' }, + }) + }) - it("fetches workflow logs", async () => { - const { client, request } = createHttpClientWithSpies(); - const workflow = new WorkflowClient(client); + it('fetches workflow logs', async () => { + const { client, request } = createHttpClientWithSpies() + const workflow = new WorkflowClient(client) await workflow.getLogs({ - keyword: "k", - status: "succeeded", - startTime: "2024-01-01", - endTime: "2024-01-02", - createdByEndUserSessionId: "session-123", + keyword: 'k', + status: 'succeeded', + startTime: '2024-01-01', + endTime: '2024-01-02', + createdByEndUserSessionId: 'session-123', page: 1, limit: 20, - }); + }) expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/workflows/logs", + method: 'GET', + path: '/workflows/logs', query: { - keyword: "k", - status: "succeeded", - created_at__before: "2024-01-02", - created_at__after: "2024-01-01", - created_by_end_user_session_id: "session-123", + keyword: 'k', + status: 'succeeded', + created_at__before: '2024-01-02', + created_at__after: '2024-01-01', + created_by_end_user_session_id: 'session-123', created_by_account: undefined, page: 1, limit: 20, }, - }); - }); -}); + }) + }) +}) diff --git a/sdks/nodejs-client/src/client/workflow.ts b/sdks/nodejs-client/src/client/workflow.ts index 6e073b12d21f21..18287e11b99a4e 100644 --- a/sdks/nodejs-client/src/client/workflow.ts +++ b/sdks/nodejs-client/src/client/workflow.ts @@ -1,103 +1,96 @@ -import { DifyClient } from "./base"; -import type { WorkflowRunRequest, WorkflowRunResponse } from "../types/workflow"; import type { DifyResponse, DifyStream, JsonObject, QueryParams, SuccessResponse, -} from "../types/common"; -import { - ensureNonEmptyString, - ensureOptionalInt, - ensureOptionalString, -} from "./validation"; +} from '../types/common' +import type { WorkflowRunRequest, WorkflowRunResponse } from '../types/workflow' +import { DifyClient } from './base' +import { ensureNonEmptyString, ensureOptionalInt, ensureOptionalString } from './validation' export class WorkflowClient extends DifyClient { run( - request: WorkflowRunRequest - ): Promise<DifyResponse<WorkflowRunResponse> | DifyStream<WorkflowRunResponse>>; + request: WorkflowRunRequest, + ): Promise<DifyResponse<WorkflowRunResponse> | DifyStream<WorkflowRunResponse>> run( inputs: JsonObject, user: string, - stream?: boolean - ): Promise<DifyResponse<WorkflowRunResponse> | DifyStream<WorkflowRunResponse>>; + stream?: boolean, + ): Promise<DifyResponse<WorkflowRunResponse> | DifyStream<WorkflowRunResponse>> run( inputOrRequest: WorkflowRunRequest | JsonObject, user?: string, - stream = false + stream = false, ): Promise<DifyResponse<WorkflowRunResponse> | DifyStream<WorkflowRunResponse>> { - let payload: WorkflowRunRequest; - let shouldStream = stream; + let payload: WorkflowRunRequest + let shouldStream = stream - if (user === undefined && "user" in (inputOrRequest as WorkflowRunRequest)) { - payload = inputOrRequest as WorkflowRunRequest; - shouldStream = payload.response_mode === "streaming"; + if (user === undefined && 'user' in (inputOrRequest as WorkflowRunRequest)) { + payload = inputOrRequest as WorkflowRunRequest + shouldStream = payload.response_mode === 'streaming' } else { - ensureNonEmptyString(user, "user"); + ensureNonEmptyString(user, 'user') payload = { inputs: inputOrRequest, user, - response_mode: stream ? "streaming" : "blocking", - }; + response_mode: stream ? 'streaming' : 'blocking', + } } - ensureNonEmptyString(payload.user, "user"); + ensureNonEmptyString(payload.user, 'user') if (shouldStream) { return this.http.requestStream<WorkflowRunResponse>({ - method: "POST", - path: "/workflows/run", + method: 'POST', + path: '/workflows/run', data: payload, - }); + }) } return this.http.request<WorkflowRunResponse>({ - method: "POST", - path: "/workflows/run", + method: 'POST', + path: '/workflows/run', data: payload, - }); + }) } runById( workflowId: string, - request: WorkflowRunRequest + request: WorkflowRunRequest, ): Promise<DifyResponse<WorkflowRunResponse> | DifyStream<WorkflowRunResponse>> { - ensureNonEmptyString(workflowId, "workflowId"); - ensureNonEmptyString(request.user, "user"); - if (request.response_mode === "streaming") { + ensureNonEmptyString(workflowId, 'workflowId') + ensureNonEmptyString(request.user, 'user') + if (request.response_mode === 'streaming') { return this.http.requestStream<WorkflowRunResponse>({ - method: "POST", + method: 'POST', path: `/workflows/${workflowId}/run`, data: request, - }); + }) } return this.http.request<WorkflowRunResponse>({ - method: "POST", + method: 'POST', path: `/workflows/${workflowId}/run`, data: request, - }); + }) } getRun(workflowRunId: string): Promise<DifyResponse<WorkflowRunResponse>> { - ensureNonEmptyString(workflowRunId, "workflowRunId"); + ensureNonEmptyString(workflowRunId, 'workflowRunId') return this.http.request({ - method: "GET", + method: 'GET', path: `/workflows/run/${workflowRunId}`, - }); + }) } - stop( - taskId: string, - user: string - ): Promise<DifyResponse<SuccessResponse>> { - ensureNonEmptyString(taskId, "taskId"); - ensureNonEmptyString(user, "user"); + stop(taskId: string, user: string): Promise<DifyResponse<SuccessResponse>> { + ensureNonEmptyString(taskId, 'taskId') + ensureNonEmptyString(user, 'user') return this.http.request<SuccessResponse>({ - method: "POST", + method: 'POST', path: `/workflows/tasks/${taskId}/stop`, data: { user }, - }); + }) } /** @@ -107,49 +100,46 @@ export class WorkflowClient extends DifyClient { * or `createdByAccount` (account ID), not by a generic `user` parameter. */ getLogs(options?: { - keyword?: string; - status?: string; - createdAtBefore?: string; - createdAtAfter?: string; - createdByEndUserSessionId?: string; - createdByAccount?: string; - page?: number; - limit?: number; - startTime?: string; - endTime?: string; + keyword?: string + status?: string + createdAtBefore?: string + createdAtAfter?: string + createdByEndUserSessionId?: string + createdByAccount?: string + page?: number + limit?: number + startTime?: string + endTime?: string }): Promise<DifyResponse<JsonObject>> { if (options?.keyword) { - ensureOptionalString(options.keyword, "keyword"); + ensureOptionalString(options.keyword, 'keyword') } if (options?.status) { - ensureOptionalString(options.status, "status"); + ensureOptionalString(options.status, 'status') } if (options?.createdAtBefore) { - ensureOptionalString(options.createdAtBefore, "createdAtBefore"); + ensureOptionalString(options.createdAtBefore, 'createdAtBefore') } if (options?.createdAtAfter) { - ensureOptionalString(options.createdAtAfter, "createdAtAfter"); + ensureOptionalString(options.createdAtAfter, 'createdAtAfter') } if (options?.createdByEndUserSessionId) { - ensureOptionalString( - options.createdByEndUserSessionId, - "createdByEndUserSessionId" - ); + ensureOptionalString(options.createdByEndUserSessionId, 'createdByEndUserSessionId') } if (options?.createdByAccount) { - ensureOptionalString(options.createdByAccount, "createdByAccount"); + ensureOptionalString(options.createdByAccount, 'createdByAccount') } if (options?.startTime) { - ensureOptionalString(options.startTime, "startTime"); + ensureOptionalString(options.startTime, 'startTime') } if (options?.endTime) { - ensureOptionalString(options.endTime, "endTime"); + ensureOptionalString(options.endTime, 'endTime') } - ensureOptionalInt(options?.page, "page"); - ensureOptionalInt(options?.limit, "limit"); + ensureOptionalInt(options?.page, 'page') + ensureOptionalInt(options?.limit, 'limit') - const createdAtAfter = options?.createdAtAfter ?? options?.startTime; - const createdAtBefore = options?.createdAtBefore ?? options?.endTime; + const createdAtAfter = options?.createdAtAfter ?? options?.startTime + const createdAtBefore = options?.createdAtBefore ?? options?.endTime const query: QueryParams = { keyword: options?.keyword, @@ -160,12 +150,12 @@ export class WorkflowClient extends DifyClient { created_by_account: options?.createdByAccount, page: options?.page, limit: options?.limit, - }; + } return this.http.request({ - method: "GET", - path: "/workflows/logs", + method: 'GET', + path: '/workflows/logs', query, - }); + }) } } diff --git a/sdks/nodejs-client/src/client/workspace.test.ts b/sdks/nodejs-client/src/client/workspace.test.ts index f8fb6e375a43c1..15e32e1427e378 100644 --- a/sdks/nodejs-client/src/client/workspace.test.ts +++ b/sdks/nodejs-client/src/client/workspace.test.ts @@ -1,21 +1,21 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { WorkspaceClient } from "./workspace"; -import { createHttpClientWithSpies } from "../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createHttpClientWithSpies } from '../../tests/test-utils' +import { WorkspaceClient } from './workspace' -describe("WorkspaceClient", () => { +describe('WorkspaceClient', () => { beforeEach(() => { - vi.restoreAllMocks(); - }); + vi.restoreAllMocks() + }) - it("gets models by type", async () => { - const { client, request } = createHttpClientWithSpies(); - const workspace = new WorkspaceClient(client); + it('gets models by type', async () => { + const { client, request } = createHttpClientWithSpies() + const workspace = new WorkspaceClient(client) - await workspace.getModelsByType("llm"); + await workspace.getModelsByType('llm') expect(request).toHaveBeenCalledWith({ - method: "GET", - path: "/workspaces/current/models/model-types/llm", - }); - }); -}); + method: 'GET', + path: '/workspaces/current/models/model-types/llm', + }) + }) +}) diff --git a/sdks/nodejs-client/src/client/workspace.ts b/sdks/nodejs-client/src/client/workspace.ts index daee540c994cc1..8eaf782238edf9 100644 --- a/sdks/nodejs-client/src/client/workspace.ts +++ b/sdks/nodejs-client/src/client/workspace.ts @@ -1,16 +1,16 @@ -import { DifyClient } from "./base"; -import type { WorkspaceModelType, WorkspaceModelsResponse } from "../types/workspace"; -import type { DifyResponse } from "../types/common"; -import { ensureNonEmptyString } from "./validation"; +import type { DifyResponse } from '../types/common' +import type { WorkspaceModelType, WorkspaceModelsResponse } from '../types/workspace' +import { DifyClient } from './base' +import { ensureNonEmptyString } from './validation' export class WorkspaceClient extends DifyClient { async getModelsByType( - modelType: WorkspaceModelType + modelType: WorkspaceModelType, ): Promise<DifyResponse<WorkspaceModelsResponse>> { - ensureNonEmptyString(modelType, "modelType"); + ensureNonEmptyString(modelType, 'modelType') return this.http.request({ - method: "GET", + method: 'GET', path: `/workspaces/current/models/model-types/${modelType}`, - }); + }) } } diff --git a/sdks/nodejs-client/src/errors/dify-error.test.ts b/sdks/nodejs-client/src/errors/dify-error.test.ts index d151eca391345d..77981176a7dbd6 100644 --- a/sdks/nodejs-client/src/errors/dify-error.test.ts +++ b/sdks/nodejs-client/src/errors/dify-error.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest' import { APIError, AuthenticationError, @@ -8,30 +8,30 @@ import { RateLimitError, TimeoutError, ValidationError, -} from "./dify-error"; +} from './dify-error' -describe("Dify errors", () => { - it("sets base error fields", () => { - const err = new DifyError("base", { +describe('Dify errors', () => { + it('sets base error fields', () => { + const err = new DifyError('base', { statusCode: 400, - responseBody: { message: "bad" }, - requestId: "req", + responseBody: { message: 'bad' }, + requestId: 'req', retryAfter: 1, - }); - expect(err.name).toBe("DifyError"); - expect(err.statusCode).toBe(400); - expect(err.responseBody).toEqual({ message: "bad" }); - expect(err.requestId).toBe("req"); - expect(err.retryAfter).toBe(1); - }); + }) + expect(err.name).toBe('DifyError') + expect(err.statusCode).toBe(400) + expect(err.responseBody).toEqual({ message: 'bad' }) + expect(err.requestId).toBe('req') + expect(err.retryAfter).toBe(1) + }) - it("creates specific error types", () => { - expect(new APIError("api").name).toBe("APIError"); - expect(new AuthenticationError("auth").name).toBe("AuthenticationError"); - expect(new RateLimitError("rate").name).toBe("RateLimitError"); - expect(new ValidationError("val").name).toBe("ValidationError"); - expect(new NetworkError("net").name).toBe("NetworkError"); - expect(new TimeoutError("timeout").name).toBe("TimeoutError"); - expect(new FileUploadError("upload").name).toBe("FileUploadError"); - }); -}); + it('creates specific error types', () => { + expect(new APIError('api').name).toBe('APIError') + expect(new AuthenticationError('auth').name).toBe('AuthenticationError') + expect(new RateLimitError('rate').name).toBe('RateLimitError') + expect(new ValidationError('val').name).toBe('ValidationError') + expect(new NetworkError('net').name).toBe('NetworkError') + expect(new TimeoutError('timeout').name).toBe('TimeoutError') + expect(new FileUploadError('upload').name).toBe('FileUploadError') + }) +}) diff --git a/sdks/nodejs-client/src/errors/dify-error.ts b/sdks/nodejs-client/src/errors/dify-error.ts index e393e1b132a0b0..0f8f0b426111e2 100644 --- a/sdks/nodejs-client/src/errors/dify-error.ts +++ b/sdks/nodejs-client/src/errors/dify-error.ts @@ -1,75 +1,75 @@ export type DifyErrorOptions = { - statusCode?: number; - responseBody?: unknown; - requestId?: string; - retryAfter?: number; - cause?: unknown; -}; + statusCode?: number + responseBody?: unknown + requestId?: string + retryAfter?: number + cause?: unknown +} export class DifyError extends Error { - statusCode?: number; - responseBody?: unknown; - requestId?: string; - retryAfter?: number; + statusCode?: number + responseBody?: unknown + requestId?: string + retryAfter?: number constructor(message: string, options: DifyErrorOptions = {}) { - super(message); - this.name = "DifyError"; - this.statusCode = options.statusCode; - this.responseBody = options.responseBody; - this.requestId = options.requestId; - this.retryAfter = options.retryAfter; + super(message) + this.name = 'DifyError' + this.statusCode = options.statusCode + this.responseBody = options.responseBody + this.requestId = options.requestId + this.retryAfter = options.retryAfter if (options.cause) { - (this as { cause?: unknown }).cause = options.cause; + ;(this as { cause?: unknown }).cause = options.cause } } } export class APIError extends DifyError { constructor(message: string, options: DifyErrorOptions = {}) { - super(message, options); - this.name = "APIError"; + super(message, options) + this.name = 'APIError' } } export class AuthenticationError extends APIError { constructor(message: string, options: DifyErrorOptions = {}) { - super(message, options); - this.name = "AuthenticationError"; + super(message, options) + this.name = 'AuthenticationError' } } export class RateLimitError extends APIError { constructor(message: string, options: DifyErrorOptions = {}) { - super(message, options); - this.name = "RateLimitError"; + super(message, options) + this.name = 'RateLimitError' } } export class ValidationError extends APIError { constructor(message: string, options: DifyErrorOptions = {}) { - super(message, options); - this.name = "ValidationError"; + super(message, options) + this.name = 'ValidationError' } } export class NetworkError extends DifyError { constructor(message: string, options: DifyErrorOptions = {}) { - super(message, options); - this.name = "NetworkError"; + super(message, options) + this.name = 'NetworkError' } } export class TimeoutError extends DifyError { constructor(message: string, options: DifyErrorOptions = {}) { - super(message, options); - this.name = "TimeoutError"; + super(message, options) + this.name = 'TimeoutError' } } export class FileUploadError extends DifyError { constructor(message: string, options: DifyErrorOptions = {}) { - super(message, options); - this.name = "FileUploadError"; + super(message, options) + this.name = 'FileUploadError' } } diff --git a/sdks/nodejs-client/src/http/client.test.ts b/sdks/nodejs-client/src/http/client.test.ts index 421bd7abfb8d2d..55d9b8b05da401 100644 --- a/sdks/nodejs-client/src/http/client.test.ts +++ b/sdks/nodejs-client/src/http/client.test.ts @@ -1,5 +1,5 @@ -import { Readable, Stream } from "node:stream"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Readable, Stream } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { APIError, AuthenticationError, @@ -8,43 +8,40 @@ import { RateLimitError, TimeoutError, ValidationError, -} from "../errors/dify-error"; -import { HttpClient } from "./client"; +} from '../errors/dify-error' +import { HttpClient } from './client' const stubFetch = (): ReturnType<typeof vi.fn> => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - return fetchMock; -}; + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} const getFetchCall = ( fetchMock: ReturnType<typeof vi.fn>, - index = 0 + index = 0, ): [string, RequestInit | undefined] => { - const call = fetchMock.mock.calls[index]; + const call = fetchMock.mock.calls[index] if (!call) { - throw new Error(`Missing fetch call at index ${index}`); + throw new Error(`Missing fetch call at index ${index}`) } - return call as [string, RequestInit | undefined]; -}; + return call as [string, RequestInit | undefined] +} const getDuplex = (init: RequestInit | undefined) => - init && "duplex" in init ? init.duplex : undefined; + init && 'duplex' in init ? init.duplex : undefined const toHeaderRecord = (headers: HeadersInit | undefined): Record<string, string> => - Object.fromEntries(new Headers(headers).entries()); + Object.fromEntries(new Headers(headers).entries()) -const jsonResponse = ( - body: unknown, - init: ResponseInit = {} -): Response => +const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => new Response(JSON.stringify(body), { ...init, headers: { - "content-type": "application/json", + 'content-type': 'application/json', ...init.headers, }, - }); + }) const textResponse = (body: string, init: ResponseInit = {}): Response => new Response(body, { @@ -52,479 +49,463 @@ const textResponse = (body: string, init: ResponseInit = {}): Response => headers: { ...init.headers, }, - }); + }) -describe("HttpClient", () => { +describe('HttpClient', () => { beforeEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) - it("builds requests with auth headers and JSON content type", async () => { - const fetchMock = stubFetch(); + it('builds requests with auth headers and JSON content type', async () => { + const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( - jsonResponse({ ok: true }, { status: 200, headers: { "x-request-id": "req" } }) - ); + jsonResponse({ ok: true }, { status: 200, headers: { 'x-request-id': 'req' } }), + ) - const client = new HttpClient({ apiKey: "test" }); + const client = new HttpClient({ apiKey: 'test' }) const response = await client.request({ - method: "POST", - path: "/chat-messages", - data: { user: "u" }, - }); - - expect(response.requestId).toBe("req"); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe("https://api.dify.ai/v1/chat-messages"); + method: 'POST', + path: '/chat-messages', + data: { user: 'u' }, + }) + + expect(response.requestId).toBe('req') + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = getFetchCall(fetchMock) + expect(url).toBe('https://api.dify.ai/v1/chat-messages') expect(toHeaderRecord(init?.headers)).toMatchObject({ - authorization: "Bearer test", - "content-type": "application/json", - "user-agent": "dify-client-node", - }); - expect(init?.body).toBe(JSON.stringify({ user: "u" })); - }); - - it("serializes array query params", async () => { - const fetchMock = stubFetch(); - fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); - - const client = new HttpClient({ apiKey: "test" }); + authorization: 'Bearer test', + 'content-type': 'application/json', + 'user-agent': 'dify-client-node', + }) + expect(init?.body).toBe(JSON.stringify({ user: 'u' })) + }) + + it('serializes array query params', async () => { + const fetchMock = stubFetch() + fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) + + const client = new HttpClient({ apiKey: 'test' }) await client.requestRaw({ - method: "GET", - path: "/datasets", - query: { tag_ids: ["a", "b"], limit: 2 }, - }); - - const [url] = getFetchCall(fetchMock); - expect(new URL(url).searchParams.toString()).toBe( - "tag_ids=a&tag_ids=b&limit=2" - ); - }); - - it("returns SSE stream helpers", async () => { - const fetchMock = stubFetch(); + method: 'GET', + path: '/datasets', + query: { tag_ids: ['a', 'b'], limit: 2 }, + }) + + const [url] = getFetchCall(fetchMock) + expect(new URL(url).searchParams.toString()).toBe('tag_ids=a&tag_ids=b&limit=2') + }) + + it('returns SSE stream helpers', async () => { + const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( new Response('data: {"text":"hi"}\n\n', { status: 200, - headers: { "x-request-id": "req" }, - }) - ); + headers: { 'x-request-id': 'req' }, + }), + ) - const client = new HttpClient({ apiKey: "test" }); + const client = new HttpClient({ apiKey: 'test' }) const stream = await client.requestStream({ - method: "POST", - path: "/chat-messages", - data: { user: "u" }, - }); - - expect(stream.status).toBe(200); - expect(stream.requestId).toBe("req"); - await expect(stream.toText()).resolves.toBe("hi"); - }); - - it("returns binary stream helpers", async () => { - const fetchMock = stubFetch(); + method: 'POST', + path: '/chat-messages', + data: { user: 'u' }, + }) + + expect(stream.status).toBe(200) + expect(stream.requestId).toBe('req') + await expect(stream.toText()).resolves.toBe('hi') + }) + + it('returns binary stream helpers', async () => { + const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( - new Response("chunk", { + new Response('chunk', { status: 200, - headers: { "x-request-id": "req" }, - }) - ); + headers: { 'x-request-id': 'req' }, + }), + ) - const client = new HttpClient({ apiKey: "test" }); + const client = new HttpClient({ apiKey: 'test' }) const stream = await client.requestBinaryStream({ - method: "POST", - path: "/text-to-audio", - data: { user: "u", text: "hi" }, - }); + method: 'POST', + path: '/text-to-audio', + data: { user: 'u', text: 'hi' }, + }) - expect(stream.status).toBe(200); - expect(stream.requestId).toBe("req"); - expect(stream.data).toBeInstanceOf(Readable); - }); + expect(stream.status).toBe(200) + expect(stream.requestId).toBe('req') + expect(stream.data).toBeInstanceOf(Readable) + }) - it("respects form-data headers", async () => { - const fetchMock = stubFetch(); - fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); + it('respects form-data headers', async () => { + const fetchMock = stubFetch() + fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) - const client = new HttpClient({ apiKey: "test" }); - const form = new FormData(); - form.append("file", new Blob(["abc"]), "file.txt"); + const client = new HttpClient({ apiKey: 'test' }) + const form = new FormData() + form.append('file', new Blob(['abc']), 'file.txt') await client.requestRaw({ - method: "POST", - path: "/files/upload", + method: 'POST', + path: '/files/upload', data: form, - }); + }) - const [, init] = getFetchCall(fetchMock); + const [, init] = getFetchCall(fetchMock) expect(toHeaderRecord(init?.headers)).toMatchObject({ - authorization: "Bearer test", - }); - expect(toHeaderRecord(init?.headers)["content-type"]).toBeUndefined(); - }); + authorization: 'Bearer test', + }) + expect(toHeaderRecord(init?.headers)['content-type']).toBeUndefined() + }) - it("sends legacy form-data as a readable request body", async () => { - const fetchMock = stubFetch(); - fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); + it('sends legacy form-data as a readable request body', async () => { + const fetchMock = stubFetch() + fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) - const client = new HttpClient({ apiKey: "test" }); - const legacyForm = Object.assign(Readable.from(["chunk"]), { + const client = new HttpClient({ apiKey: 'test' }) + const legacyForm = Object.assign(Readable.from(['chunk']), { append: vi.fn(), getHeaders: () => ({ - "content-type": "multipart/form-data; boundary=test", + 'content-type': 'multipart/form-data; boundary=test', }), - }); + }) await client.requestRaw({ - method: "POST", - path: "/files/upload", + method: 'POST', + path: '/files/upload', data: legacyForm, - }); + }) - const [, init] = getFetchCall(fetchMock); + const [, init] = getFetchCall(fetchMock) expect(toHeaderRecord(init?.headers)).toMatchObject({ - authorization: "Bearer test", - "content-type": "multipart/form-data; boundary=test", - }); - expect(getDuplex(init)).toBe( - "half" - ); - expect(init?.body).not.toBe(legacyForm); - }); - - it("rejects legacy form-data objects that are not readable streams", async () => { - const fetchMock = stubFetch(); - const client = new HttpClient({ apiKey: "test" }); + authorization: 'Bearer test', + 'content-type': 'multipart/form-data; boundary=test', + }) + expect(getDuplex(init)).toBe('half') + expect(init?.body).not.toBe(legacyForm) + }) + + it('rejects legacy form-data objects that are not readable streams', async () => { + const fetchMock = stubFetch() + const client = new HttpClient({ apiKey: 'test' }) const legacyForm = { append: vi.fn(), getHeaders: () => ({ - "content-type": "multipart/form-data; boundary=test", + 'content-type': 'multipart/form-data; boundary=test', }), - }; + } await expect( client.requestRaw({ - method: "POST", - path: "/files/upload", + method: 'POST', + path: '/files/upload', data: legacyForm, - }) - ).rejects.toBeInstanceOf(FileUploadError); + }), + ).rejects.toBeInstanceOf(FileUploadError) - expect(fetchMock).not.toHaveBeenCalled(); - }); + expect(fetchMock).not.toHaveBeenCalled() + }) - it("accepts legacy pipeable streams that are not Readable instances", async () => { - const fetchMock = stubFetch(); - fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); - const client = new HttpClient({ apiKey: "test" }); + it('accepts legacy pipeable streams that are not Readable instances', async () => { + const fetchMock = stubFetch() + fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) + const client = new HttpClient({ apiKey: 'test' }) const legacyStream = new Stream() as Stream & NodeJS.ReadableStream & { - append: ReturnType<typeof vi.fn>; - getHeaders: () => Record<string, string>; - }; - legacyStream.readable = true; - legacyStream.pause = () => legacyStream; - legacyStream.resume = () => legacyStream; - legacyStream.append = vi.fn(); + append: ReturnType<typeof vi.fn> + getHeaders: () => Record<string, string> + } + legacyStream.readable = true + legacyStream.pause = () => legacyStream + legacyStream.resume = () => legacyStream + legacyStream.append = vi.fn() legacyStream.getHeaders = () => ({ - "content-type": "multipart/form-data; boundary=test", - }); + 'content-type': 'multipart/form-data; boundary=test', + }) queueMicrotask(() => { - legacyStream.emit("data", Buffer.from("chunk")); - legacyStream.emit("end"); - }); + legacyStream.emit('data', Buffer.from('chunk')) + legacyStream.emit('end') + }) await client.requestRaw({ - method: "POST", - path: "/files/upload", + method: 'POST', + path: '/files/upload', data: legacyStream as unknown as FormData, - }); + }) - const [, init] = getFetchCall(fetchMock); - expect(getDuplex(init)).toBe( - "half" - ); - }); + const [, init] = getFetchCall(fetchMock) + expect(getDuplex(init)).toBe('half') + }) - it("returns buffers for byte responses", async () => { - const fetchMock = stubFetch(); + it('returns buffers for byte responses', async () => { + const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( new Response(Uint8Array.from([1, 2, 3]), { status: 200, - headers: { "content-type": "application/octet-stream" }, - }) - ); - - const client = new HttpClient({ apiKey: "test" }); - const response = await client.request<Buffer, "bytes">({ - method: "GET", - path: "/files/file-1/preview", - responseType: "bytes", - }); - - expect(Buffer.isBuffer(response.data)).toBe(true); - expect(Array.from(response.data.values())).toEqual([1, 2, 3]); - }); - - it("keeps arraybuffer as a backward-compatible binary alias", async () => { - const fetchMock = stubFetch(); + headers: { 'content-type': 'application/octet-stream' }, + }), + ) + + const client = new HttpClient({ apiKey: 'test' }) + const response = await client.request<Buffer, 'bytes'>({ + method: 'GET', + path: '/files/file-1/preview', + responseType: 'bytes', + }) + + expect(Buffer.isBuffer(response.data)).toBe(true) + expect(Array.from(response.data.values())).toEqual([1, 2, 3]) + }) + + it('keeps arraybuffer as a backward-compatible binary alias', async () => { + const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( new Response(Uint8Array.from([4, 5, 6]), { status: 200, - headers: { "content-type": "application/octet-stream" }, - }) - ); - - const client = new HttpClient({ apiKey: "test" }); - const response = await client.request<Buffer, "arraybuffer">({ - method: "GET", - path: "/files/file-1/preview", - responseType: "arraybuffer", - }); - - expect(Buffer.isBuffer(response.data)).toBe(true); - expect(Array.from(response.data.values())).toEqual([4, 5, 6]); - }); - - it("returns null for empty no-content responses", async () => { - const fetchMock = stubFetch(); - fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); - - const client = new HttpClient({ apiKey: "test" }); + headers: { 'content-type': 'application/octet-stream' }, + }), + ) + + const client = new HttpClient({ apiKey: 'test' }) + const response = await client.request<Buffer, 'arraybuffer'>({ + method: 'GET', + path: '/files/file-1/preview', + responseType: 'arraybuffer', + }) + + expect(Buffer.isBuffer(response.data)).toBe(true) + expect(Array.from(response.data.values())).toEqual([4, 5, 6]) + }) + + it('returns null for empty no-content responses', async () => { + const fetchMock = stubFetch() + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })) + + const client = new HttpClient({ apiKey: 'test' }) const response = await client.requestRaw({ - method: "GET", - path: "/meta", - }); + method: 'GET', + path: '/meta', + }) - expect(response.data).toBeNull(); - }); + expect(response.data).toBeNull() + }) - it("maps 401 and 429 errors", async () => { - const fetchMock = stubFetch(); + it('maps 401 and 429 errors', async () => { + const fetchMock = stubFetch() fetchMock + .mockResolvedValueOnce(jsonResponse({ message: 'unauthorized' }, { status: 401 })) .mockResolvedValueOnce( - jsonResponse({ message: "unauthorized" }, { status: 401 }) + jsonResponse({ message: 'rate' }, { status: 429, headers: { 'retry-after': '2' } }), ) - .mockResolvedValueOnce( - jsonResponse({ message: "rate" }, { status: 429, headers: { "retry-after": "2" } }) - ); - const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); + const client = new HttpClient({ apiKey: 'test', maxRetries: 0 }) - await expect( - client.requestRaw({ method: "GET", path: "/meta" }) - ).rejects.toBeInstanceOf(AuthenticationError); + await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toBeInstanceOf( + AuthenticationError, + ) const error = await client - .requestRaw({ method: "GET", path: "/meta" }) - .catch((err: unknown) => err); - expect(error).toBeInstanceOf(RateLimitError); - expect((error as RateLimitError).retryAfter).toBe(2); - }); - - it("maps validation and upload errors", async () => { - const fetchMock = stubFetch(); + .requestRaw({ method: 'GET', path: '/meta' }) + .catch((err: unknown) => err) + expect(error).toBeInstanceOf(RateLimitError) + expect((error as RateLimitError).retryAfter).toBe(2) + }) + + it('maps validation and upload errors', async () => { + const fetchMock = stubFetch() fetchMock - .mockResolvedValueOnce(jsonResponse({ message: "invalid" }, { status: 422 })) - .mockResolvedValueOnce(jsonResponse({ message: "bad upload" }, { status: 400 })); - const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); + .mockResolvedValueOnce(jsonResponse({ message: 'invalid' }, { status: 422 })) + .mockResolvedValueOnce(jsonResponse({ message: 'bad upload' }, { status: 400 })) + const client = new HttpClient({ apiKey: 'test', maxRetries: 0 }) await expect( - client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } }) - ).rejects.toBeInstanceOf(ValidationError); + client.requestRaw({ method: 'POST', path: '/chat-messages', data: { user: 'u' } }), + ).rejects.toBeInstanceOf(ValidationError) await expect( - client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } }) - ).rejects.toBeInstanceOf(FileUploadError); - }); + client.requestRaw({ method: 'POST', path: '/files/upload', data: { user: 'u' } }), + ).rejects.toBeInstanceOf(FileUploadError) + }) - it("maps timeout and network errors", async () => { - const fetchMock = stubFetch(); + it('maps timeout and network errors', async () => { + const fetchMock = stubFetch() fetchMock - .mockRejectedValueOnce(Object.assign(new Error("timeout"), { name: "AbortError" })) - .mockRejectedValueOnce(new Error("network")); - const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); - - await expect( - client.requestRaw({ method: "GET", path: "/meta" }) - ).rejects.toBeInstanceOf(TimeoutError); - - await expect( - client.requestRaw({ method: "GET", path: "/meta" }) - ).rejects.toBeInstanceOf(NetworkError); - }); - - it("maps unknown transport failures to NetworkError", async () => { - const fetchMock = stubFetch(); - fetchMock.mockRejectedValueOnce("boom"); - const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); - - await expect( - client.requestRaw({ method: "GET", path: "/meta" }) - ).rejects.toMatchObject({ - name: "NetworkError", - message: "Unexpected network error", - }); - }); - - it("retries on timeout errors", async () => { - const fetchMock = stubFetch(); + .mockRejectedValueOnce(Object.assign(new Error('timeout'), { name: 'AbortError' })) + .mockRejectedValueOnce(new Error('network')) + const client = new HttpClient({ apiKey: 'test', maxRetries: 0 }) + + await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toBeInstanceOf( + TimeoutError, + ) + + await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toBeInstanceOf( + NetworkError, + ) + }) + + it('maps unknown transport failures to NetworkError', async () => { + const fetchMock = stubFetch() + fetchMock.mockRejectedValueOnce('boom') + const client = new HttpClient({ apiKey: 'test', maxRetries: 0 }) + + await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toMatchObject({ + name: 'NetworkError', + message: 'Unexpected network error', + }) + }) + + it('retries on timeout errors', async () => { + const fetchMock = stubFetch() fetchMock - .mockRejectedValueOnce(Object.assign(new Error("timeout"), { name: "AbortError" })) - .mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); - const client = new HttpClient({ apiKey: "test", maxRetries: 1, retryDelay: 0 }); + .mockRejectedValueOnce(Object.assign(new Error('timeout'), { name: 'AbortError' })) + .mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) + const client = new HttpClient({ apiKey: 'test', maxRetries: 1, retryDelay: 0 }) - await client.requestRaw({ method: "GET", path: "/meta" }); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); + await client.requestRaw({ method: 'GET', path: '/meta' }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) - it("does not retry non-replayable readable request bodies", async () => { - const fetchMock = stubFetch(); - fetchMock.mockRejectedValueOnce(new Error("network")); - const client = new HttpClient({ apiKey: "test", maxRetries: 2, retryDelay: 0 }); + it('does not retry non-replayable readable request bodies', async () => { + const fetchMock = stubFetch() + fetchMock.mockRejectedValueOnce(new Error('network')) + const client = new HttpClient({ apiKey: 'test', maxRetries: 2, retryDelay: 0 }) await expect( client.requestRaw({ - method: "POST", - path: "/chat-messages", - data: Readable.from(["chunk"]), - }) - ).rejects.toBeInstanceOf(NetworkError); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const [, init] = getFetchCall(fetchMock); - expect(getDuplex(init)).toBe( - "half" - ); - }); - - it("validates query parameters before request", async () => { - const fetchMock = stubFetch(); - const client = new HttpClient({ apiKey: "test" }); + method: 'POST', + path: '/chat-messages', + data: Readable.from(['chunk']), + }), + ).rejects.toBeInstanceOf(NetworkError) - await expect( - client.requestRaw({ method: "GET", path: "/meta", query: { user: 1 } }) - ).rejects.toBeInstanceOf(ValidationError); - expect(fetchMock).not.toHaveBeenCalled(); - }); + expect(fetchMock).toHaveBeenCalledTimes(1) + const [, init] = getFetchCall(fetchMock) + expect(getDuplex(init)).toBe('half') + }) - it("returns APIError for other http failures", async () => { - const fetchMock = stubFetch(); - fetchMock.mockResolvedValueOnce(jsonResponse({ message: "server" }, { status: 500 })); - const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); + it('validates query parameters before request', async () => { + const fetchMock = stubFetch() + const client = new HttpClient({ apiKey: 'test' }) await expect( - client.requestRaw({ method: "GET", path: "/meta" }) - ).rejects.toBeInstanceOf(APIError); - }); - - it("uses plain text bodies when json parsing is not possible", async () => { - const fetchMock = stubFetch(); + client.requestRaw({ method: 'GET', path: '/meta', query: { user: 1 } }), + ).rejects.toBeInstanceOf(ValidationError) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns APIError for other http failures', async () => { + const fetchMock = stubFetch() + fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'server' }, { status: 500 })) + const client = new HttpClient({ apiKey: 'test', maxRetries: 0 }) + + await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toBeInstanceOf( + APIError, + ) + }) + + it('uses plain text bodies when json parsing is not possible', async () => { + const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( - textResponse("plain text", { + textResponse('plain text', { status: 200, - headers: { "content-type": "text/plain" }, - }) - ); - const client = new HttpClient({ apiKey: "test" }); + headers: { 'content-type': 'text/plain' }, + }), + ) + const client = new HttpClient({ apiKey: 'test' }) const response = await client.requestRaw({ - method: "GET", - path: "/info", - }); + method: 'GET', + path: '/info', + }) - expect(response.data).toBe("plain text"); - }); + expect(response.data).toBe('plain text') + }) - it("keeps invalid json error bodies as API errors", async () => { - const fetchMock = stubFetch(); + it('keeps invalid json error bodies as API errors', async () => { + const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( - textResponse("{invalid", { + textResponse('{invalid', { status: 500, - headers: { "content-type": "application/json", "x-request-id": "req-500" }, - }) - ); - const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); + headers: { 'content-type': 'application/json', 'x-request-id': 'req-500' }, + }), + ) + const client = new HttpClient({ apiKey: 'test', maxRetries: 0 }) - await expect( - client.requestRaw({ method: "GET", path: "/meta" }) - ).rejects.toMatchObject({ - name: "APIError", + await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toMatchObject({ + name: 'APIError', statusCode: 500, - requestId: "req-500", - responseBody: "{invalid", - }); - }); + requestId: 'req-500', + responseBody: '{invalid', + }) + }) - it("sends raw string bodies without additional json encoding", async () => { - const fetchMock = stubFetch(); - fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); - const client = new HttpClient({ apiKey: "test" }); + it('sends raw string bodies without additional json encoding', async () => { + const fetchMock = stubFetch() + fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) + const client = new HttpClient({ apiKey: 'test' }) await client.requestRaw({ - method: "POST", - path: "/meta", + method: 'POST', + path: '/meta', data: '{"pre":"serialized"}', - headers: { "Content-Type": "application/custom+json" }, - }); + headers: { 'Content-Type': 'application/custom+json' }, + }) - const [, init] = getFetchCall(fetchMock); - expect(init?.body).toBe('{"pre":"serialized"}'); + const [, init] = getFetchCall(fetchMock) + expect(init?.body).toBe('{"pre":"serialized"}') expect(toHeaderRecord(init?.headers)).toMatchObject({ - "content-type": "application/custom+json", - }); - }); + 'content-type': 'application/custom+json', + }) + }) - it("preserves explicit user-agent headers", async () => { - const fetchMock = stubFetch(); - fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 })); - const client = new HttpClient({ apiKey: "test" }); + it('preserves explicit user-agent headers', async () => { + const fetchMock = stubFetch() + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 })) + const client = new HttpClient({ apiKey: 'test' }) await client.requestRaw({ - method: "GET", - path: "/meta", - headers: { "User-Agent": "custom-agent" }, - }); + method: 'GET', + path: '/meta', + headers: { 'User-Agent': 'custom-agent' }, + }) - const [, init] = getFetchCall(fetchMock); + const [, init] = getFetchCall(fetchMock) expect(toHeaderRecord(init?.headers)).toMatchObject({ - "user-agent": "custom-agent", - }); - }); + 'user-agent': 'custom-agent', + }) + }) - it("logs requests and responses when enableLogging is true", async () => { - const fetchMock = stubFetch(); - fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 })); - const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}); + it('logs requests and responses when enableLogging is true', async () => { + const fetchMock = stubFetch() + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 })) + const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {}) - const client = new HttpClient({ apiKey: "test", enableLogging: true }); - await client.requestRaw({ method: "GET", path: "/meta" }); + const client = new HttpClient({ apiKey: 'test', enableLogging: true }) + await client.requestRaw({ method: 'GET', path: '/meta' }) expect(consoleInfo).toHaveBeenCalledWith( - expect.stringContaining("dify-client-node response 200 GET") - ); - }); + expect.stringContaining('dify-client-node response 200 GET'), + ) + }) - it("logs retry attempts when enableLogging is true", async () => { - const fetchMock = stubFetch(); + it('logs retry attempts when enableLogging is true', async () => { + const fetchMock = stubFetch() fetchMock - .mockRejectedValueOnce(Object.assign(new Error("timeout"), { name: "AbortError" })) - .mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); - const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}); + .mockRejectedValueOnce(Object.assign(new Error('timeout'), { name: 'AbortError' })) + .mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) + const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {}) const client = new HttpClient({ - apiKey: "test", + apiKey: 'test', maxRetries: 1, retryDelay: 0, enableLogging: true, - }); + }) - await client.requestRaw({ method: "GET", path: "/meta" }); + await client.requestRaw({ method: 'GET', path: '/meta' }) - expect(consoleInfo).toHaveBeenCalledWith( - expect.stringContaining("dify-client-node retry") - ); - }); -}); + expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('dify-client-node retry')) + }) +}) diff --git a/sdks/nodejs-client/src/http/client.ts b/sdks/nodejs-client/src/http/client.ts index bc6ecbc3bd3aaf..2684115ee1365e 100644 --- a/sdks/nodejs-client/src/http/client.ts +++ b/sdks/nodejs-client/src/http/client.ts @@ -1,10 +1,3 @@ -import { Readable } from "node:stream"; -import { - DEFAULT_BASE_URL, - DEFAULT_MAX_RETRIES, - DEFAULT_RETRY_DELAY_SECONDS, - DEFAULT_TIMEOUT_SECONDS, -} from "../types/common"; import type { BinaryStream, DifyClientConfig, @@ -14,7 +7,10 @@ import type { JsonValue, QueryParams, RequestMethod, -} from "../types/common"; +} from '../types/common' +import type { SdkFormData } from './form-data' +import { Readable } from 'node:stream' +import { validateParams } from '../client/validation' import { APIError, AuthenticationError, @@ -24,17 +20,21 @@ import { RateLimitError, TimeoutError, ValidationError, -} from "../errors/dify-error"; -import type { SdkFormData } from "./form-data"; -import { getFormDataHeaders, isFormData } from "./form-data"; -import { createBinaryStream, createSseStream } from "./sse"; -import { getRetryDelayMs, shouldRetry, sleep } from "./retry"; -import { validateParams } from "../client/validation"; -import { hasStringProperty, isRecord } from "../internal/type-guards"; +} from '../errors/dify-error' +import { hasStringProperty, isRecord } from '../internal/type-guards' +import { + DEFAULT_BASE_URL, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_DELAY_SECONDS, + DEFAULT_TIMEOUT_SECONDS, +} from '../types/common' +import { getFormDataHeaders, isFormData } from './form-data' +import { getRetryDelayMs, shouldRetry, sleep } from './retry' +import { createBinaryStream, createSseStream } from './sse' -const DEFAULT_USER_AGENT = "dify-client-node"; +const DEFAULT_USER_AGENT = 'dify-client-node' -export type HttpResponseType = "json" | "bytes" | "stream" | "arraybuffer"; +export type HttpResponseType = 'json' | 'bytes' | 'stream' | 'arraybuffer' export type HttpRequestBody = | JsonValue @@ -45,54 +45,51 @@ export type HttpRequestBody = | ArrayBufferView | Blob | string - | null; + | null -export type ResponseDataFor<TResponseType extends HttpResponseType> = - TResponseType extends "stream" - ? Readable - : TResponseType extends "bytes" | "arraybuffer" - ? Buffer - : JsonValue | string | null; +export type ResponseDataFor<TResponseType extends HttpResponseType> = TResponseType extends 'stream' + ? Readable + : TResponseType extends 'bytes' | 'arraybuffer' + ? Buffer + : JsonValue | string | null export type RawHttpResponse<TData = unknown> = { - data: TData; - status: number; - headers: Headers; - requestId?: string; - url: string; -}; - -export type RequestOptions<TResponseType extends HttpResponseType = "json"> = { - method: RequestMethod; - path: string; - query?: QueryParams; - data?: HttpRequestBody; - headers?: Headers; - responseType?: TResponseType; -}; - -export type HttpClientSettings = Required< - Omit<DifyClientConfig, "apiKey"> -> & { - apiKey: string; -}; + data: TData + status: number + headers: Headers + requestId?: string + url: string +} + +export type RequestOptions<TResponseType extends HttpResponseType = 'json'> = { + method: RequestMethod + path: string + query?: QueryParams + data?: HttpRequestBody + headers?: Headers + responseType?: TResponseType +} + +export type HttpClientSettings = Required<Omit<DifyClientConfig, 'apiKey'>> & { + apiKey: string +} type FetchRequestInit = RequestInit & { - duplex?: "half"; -}; + duplex?: 'half' +} type PreparedRequestBody = { - body?: BodyInit | null; - headers: Headers; - duplex?: "half"; - replayable: boolean; -}; + body?: BodyInit | null + headers: Headers + duplex?: 'half' + replayable: boolean +} type TimeoutContext = { - cleanup: () => void; - reason: Error; - signal: AbortSignal; -}; + cleanup: () => void + reason: Error + signal: AbortSignal +} const normalizeSettings = (config: DifyClientConfig): HttpClientSettings => ({ apiKey: config.apiKey, @@ -101,304 +98,292 @@ const normalizeSettings = (config: DifyClientConfig): HttpClientSettings => ({ maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES, retryDelay: config.retryDelay ?? DEFAULT_RETRY_DELAY_SECONDS, enableLogging: config.enableLogging ?? false, -}); +}) const normalizeHeaders = (headers: globalThis.Headers): Headers => { - const result: Headers = {}; + const result: Headers = {} headers.forEach((value, key) => { - result[key.toLowerCase()] = value; - }); - return result; -}; + result[key.toLowerCase()] = value + }) + return result +} const resolveRequestId = (headers: Headers): string | undefined => - headers["x-request-id"] ?? headers["x-requestid"]; + headers['x-request-id'] ?? headers['x-requestid'] -const buildRequestUrl = ( - baseUrl: string, - path: string, - query?: QueryParams -): string => { - const trimmed = baseUrl.replace(/\/+$/, ""); - const url = new URL(`${trimmed}${path}`); - const queryString = buildQueryString(query); +const buildRequestUrl = (baseUrl: string, path: string, query?: QueryParams): string => { + const trimmed = baseUrl.replace(/\/+$/, '') + const url = new URL(`${trimmed}${path}`) + const queryString = buildQueryString(query) if (queryString) { - url.search = queryString; + url.search = queryString } - return url.toString(); -}; + return url.toString() +} const buildQueryString = (params?: QueryParams): string => { if (!params) { - return ""; + return '' } - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams() Object.entries(params).forEach(([key, value]) => { if (value === undefined || value === null) { - return; + return } if (Array.isArray(value)) { value.forEach((item) => { - searchParams.append(key, String(item)); - }); - return; + searchParams.append(key, String(item)) + }) + return } - searchParams.append(key, String(value)); - }); - return searchParams.toString(); -}; + searchParams.append(key, String(value)) + }) + return searchParams.toString() +} const parseRetryAfterSeconds = (headerValue?: string): number | undefined => { if (!headerValue) { - return undefined; + return undefined } - const asNumber = Number.parseInt(headerValue, 10); + const asNumber = Number.parseInt(headerValue, 10) if (!Number.isNaN(asNumber)) { - return asNumber; + return asNumber } - const asDate = Date.parse(headerValue); + const asDate = Date.parse(headerValue) if (!Number.isNaN(asDate)) { - const diff = asDate - Date.now(); - return diff > 0 ? Math.ceil(diff / 1000) : 0; + const diff = asDate - Date.now() + return diff > 0 ? Math.ceil(diff / 1000) : 0 } - return undefined; -}; + return undefined +} const isPipeableStream = (value: unknown): value is { pipe: (destination: unknown) => unknown } => { - if (!value || typeof value !== "object") { - return false; + if (!value || typeof value !== 'object') { + return false } - return typeof (value as { pipe?: unknown }).pipe === "function"; -}; + return typeof (value as { pipe?: unknown }).pipe === 'function' +} const toNodeReadable = (value: unknown): Readable | null => { if (value instanceof Readable) { - return value; + return value } if (!isPipeableStream(value)) { - return null; + return null } const readable = new Readable({ read() {}, - }); - return readable.wrap(value as NodeJS.ReadableStream); -}; + }) + return readable.wrap(value as NodeJS.ReadableStream) +} -const isBinaryBody = ( - value: unknown -): value is ArrayBuffer | ArrayBufferView | Blob => { +const isBinaryBody = (value: unknown): value is ArrayBuffer | ArrayBufferView | Blob => { if (value instanceof Blob) { - return true; + return true } if (value instanceof ArrayBuffer) { - return true; + return true } - return ArrayBuffer.isView(value); -}; + return ArrayBuffer.isView(value) +} const isJsonBody = (value: unknown): value is Exclude<JsonValue, string> => value === null || - typeof value === "boolean" || - typeof value === "number" || + typeof value === 'boolean' || + typeof value === 'number' || Array.isArray(value) || - isRecord(value); + isRecord(value) const isUploadLikeRequest = (path: string): boolean => { - const normalizedPath = path.toLowerCase(); + const normalizedPath = path.toLowerCase() return ( - normalizedPath.includes("upload") || - normalizedPath.includes("/files/") || - normalizedPath.includes("audio-to-text") || - normalizedPath.includes("create_by_file") || - normalizedPath.includes("update_by_file") - ); -}; + normalizedPath.includes('upload') || + normalizedPath.includes('/files/') || + normalizedPath.includes('audio-to-text') || + normalizedPath.includes('create_by_file') || + normalizedPath.includes('update_by_file') + ) +} const resolveErrorMessage = (status: number, responseBody: unknown): string => { - if (typeof responseBody === "string" && responseBody.trim().length > 0) { - return responseBody; + if (typeof responseBody === 'string' && responseBody.trim().length > 0) { + return responseBody } - if (hasStringProperty(responseBody, "message")) { - const message = responseBody.message.trim(); + if (hasStringProperty(responseBody, 'message')) { + const message = responseBody.message.trim() if (message.length > 0) { - return message; + return message } } - return `Request failed with status code ${status}`; -}; + return `Request failed with status code ${status}` +} const parseJsonLikeText = ( value: string, - contentType?: string | null + contentType?: string | null, ): JsonValue | string | null => { if (value.length === 0) { - return null; + return null } const shouldParseJson = - contentType?.includes("application/json") === true || - contentType?.includes("+json") === true; + contentType?.includes('application/json') === true || contentType?.includes('+json') === true if (!shouldParseJson) { try { - return JSON.parse(value) as JsonValue; + return JSON.parse(value) as JsonValue } catch { - return value; + return value } } - return JSON.parse(value) as JsonValue; -}; + return JSON.parse(value) as JsonValue +} const prepareRequestBody = ( method: RequestMethod, - data: HttpRequestBody | undefined + data: HttpRequestBody | undefined, ): PreparedRequestBody => { - if (method === "GET" || data === undefined) { + if (method === 'GET' || data === undefined) { return { body: undefined, headers: {}, replayable: true, - }; + } } if (isFormData(data)) { - if ("getHeaders" in data && typeof data.getHeaders === "function") { - const readable = toNodeReadable(data); + if ('getHeaders' in data && typeof data.getHeaders === 'function') { + const readable = toNodeReadable(data) if (!readable) { - throw new FileUploadError( - "Legacy FormData must be a readable stream when used with fetch" - ); + throw new FileUploadError('Legacy FormData must be a readable stream when used with fetch') } return { body: Readable.toWeb(readable) as BodyInit, headers: getFormDataHeaders(data), - duplex: "half", + duplex: 'half', replayable: false, - }; + } } return { body: data as BodyInit, headers: getFormDataHeaders(data), replayable: true, - }; + } } - if (typeof data === "string") { + if (typeof data === 'string') { return { body: data, headers: {}, replayable: true, - }; + } } - const readable = toNodeReadable(data); + const readable = toNodeReadable(data) if (readable) { return { body: Readable.toWeb(readable) as BodyInit, headers: {}, - duplex: "half", + duplex: 'half', replayable: false, - }; + } } if (data instanceof URLSearchParams || isBinaryBody(data)) { const body = ArrayBuffer.isView(data) && !(data instanceof Uint8Array) ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) - : data; + : data return { body: body as BodyInit, headers: {}, replayable: true, - }; + } } if (isJsonBody(data)) { return { body: JSON.stringify(data), headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, replayable: true, - }; + } } - throw new ValidationError("Unsupported request body type"); -}; + throw new ValidationError('Unsupported request body type') +} const createTimeoutContext = (timeoutMs: number): TimeoutContext => { - const controller = new AbortController(); - const reason = new Error("Request timed out"); + const controller = new AbortController() + const reason = new Error('Request timed out') const timer = setTimeout(() => { - controller.abort(reason); - }, timeoutMs); + controller.abort(reason) + }, timeoutMs) return { signal: controller.signal, reason, cleanup: () => { - clearTimeout(timer); + clearTimeout(timer) }, - }; -}; + } +} const parseResponseBody = async <TResponseType extends HttpResponseType>( response: Response, - responseType: TResponseType + responseType: TResponseType, ): Promise<ResponseDataFor<TResponseType>> => { - if (responseType === "stream") { + if (responseType === 'stream') { if (!response.body) { - throw new NetworkError("Response body is empty"); + throw new NetworkError('Response body is empty') } return Readable.fromWeb( - response.body as unknown as Parameters<typeof Readable.fromWeb>[0] - ) as ResponseDataFor<TResponseType>; + response.body as unknown as Parameters<typeof Readable.fromWeb>[0], + ) as ResponseDataFor<TResponseType> } - if (responseType === "bytes" || responseType === "arraybuffer") { - const bytes = Buffer.from(await response.arrayBuffer()); - return bytes as ResponseDataFor<TResponseType>; + if (responseType === 'bytes' || responseType === 'arraybuffer') { + const bytes = Buffer.from(await response.arrayBuffer()) + return bytes as ResponseDataFor<TResponseType> } if (response.status === 204 || response.status === 205 || response.status === 304) { - return null as ResponseDataFor<TResponseType>; + return null as ResponseDataFor<TResponseType> } - const text = await response.text(); + const text = await response.text() try { return parseJsonLikeText( text, - response.headers.get("content-type") - ) as ResponseDataFor<TResponseType>; + response.headers.get('content-type'), + ) as ResponseDataFor<TResponseType> } catch (error) { if (!response.ok && error instanceof SyntaxError) { - return text as ResponseDataFor<TResponseType>; + return text as ResponseDataFor<TResponseType> } - throw error; + throw error } -}; +} -const mapHttpError = ( - response: RawHttpResponse, - path: string -): DifyError => { - const status = response.status; - const responseBody = response.data; - const message = resolveErrorMessage(status, responseBody); +const mapHttpError = (response: RawHttpResponse, path: string): DifyError => { + const status = response.status + const responseBody = response.data + const message = resolveErrorMessage(status, responseBody) if (status === 401) { return new AuthenticationError(message, { statusCode: status, responseBody, requestId: response.requestId, - }); + }) } if (status === 429) { - const retryAfter = parseRetryAfterSeconds(response.headers["retry-after"]); + const retryAfter = parseRetryAfterSeconds(response.headers['retry-after']) return new RateLimitError(message, { statusCode: status, responseBody, requestId: response.requestId, retryAfter, - }); + }) } if (status === 422) { @@ -406,7 +391,7 @@ const mapHttpError = ( statusCode: status, responseBody, requestId: response.requestId, - }); + }) } if (status === 400 && isUploadLikeRequest(path)) { @@ -414,187 +399,177 @@ const mapHttpError = ( statusCode: status, responseBody, requestId: response.requestId, - }); + }) } return new APIError(message, { statusCode: status, responseBody, requestId: response.requestId, - }); -}; + }) +} -const mapTransportError = ( - error: unknown, - timeoutContext: TimeoutContext -): DifyError => { +const mapTransportError = (error: unknown, timeoutContext: TimeoutContext): DifyError => { if (error instanceof DifyError) { - return error; + return error } - if ( - timeoutContext.signal.aborted && - timeoutContext.signal.reason === timeoutContext.reason - ) { - return new TimeoutError("Request timed out", { cause: error }); + if (timeoutContext.signal.aborted && timeoutContext.signal.reason === timeoutContext.reason) { + return new TimeoutError('Request timed out', { cause: error }) } if (error instanceof Error) { - if (error.name === "AbortError" || error.name === "TimeoutError") { - return new TimeoutError("Request timed out", { cause: error }); + if (error.name === 'AbortError' || error.name === 'TimeoutError') { + return new TimeoutError('Request timed out', { cause: error }) } - return new NetworkError(error.message, { cause: error }); + return new NetworkError(error.message, { cause: error }) } - return new NetworkError("Unexpected network error", { cause: error }); -}; + return new NetworkError('Unexpected network error', { cause: error }) +} export class HttpClient { - private settings: HttpClientSettings; + private settings: HttpClientSettings constructor(config: DifyClientConfig) { - this.settings = normalizeSettings(config); + this.settings = normalizeSettings(config) } updateApiKey(apiKey: string): void { - this.settings.apiKey = apiKey; + this.settings.apiKey = apiKey } getSettings(): HttpClientSettings { - return { ...this.settings }; + return { ...this.settings } } - async request< - T, - TResponseType extends HttpResponseType = "json", - >(options: RequestOptions<TResponseType>): Promise<DifyResponse<T>> { - const response = await this.requestRaw(options); + async request<T, TResponseType extends HttpResponseType = 'json'>( + options: RequestOptions<TResponseType>, + ): Promise<DifyResponse<T>> { + const response = await this.requestRaw(options) return { data: response.data as T, status: response.status, headers: response.headers, requestId: response.requestId, - }; + } } async requestStream<T>(options: RequestOptions): Promise<DifyStream<T>> { const response = await this.requestRaw({ ...options, - responseType: "stream", - }); + responseType: 'stream', + }) return createSseStream<T>(response.data, { status: response.status, headers: response.headers, requestId: response.requestId, - }); + }) } async requestBinaryStream(options: RequestOptions): Promise<BinaryStream> { const response = await this.requestRaw({ ...options, - responseType: "stream", - }); + responseType: 'stream', + }) return createBinaryStream(response.data, { status: response.status, headers: response.headers, requestId: response.requestId, - }); + }) } - async requestRaw<TResponseType extends HttpResponseType = "json">( - options: RequestOptions<TResponseType> + async requestRaw<TResponseType extends HttpResponseType = 'json'>( + options: RequestOptions<TResponseType>, ): Promise<RawHttpResponse<ResponseDataFor<TResponseType>>> { - const responseType = options.responseType ?? "json"; - const { method, path, query, data, headers } = options; - const { apiKey, enableLogging, maxRetries, retryDelay, timeout } = this.settings; + const responseType = options.responseType ?? 'json' + const { method, path, query, data, headers } = options + const { apiKey, enableLogging, maxRetries, retryDelay, timeout } = this.settings if (query) { - validateParams(query); + validateParams(query) } if (isRecord(data) && !Array.isArray(data) && !isFormData(data) && !isPipeableStream(data)) { - validateParams(data); + validateParams(data) } - const url = buildRequestUrl(this.settings.baseUrl, path, query); + const url = buildRequestUrl(this.settings.baseUrl, path, query) if (enableLogging) { - console.info(`dify-client-node request ${method} ${url}`); + console.info(`dify-client-node request ${method} ${url}`) } - let attempt = 0; + let attempt = 0 while (true) { - const preparedBody = prepareRequestBody(method, data); + const preparedBody = prepareRequestBody(method, data) const requestHeaders: Headers = { Authorization: `Bearer ${apiKey}`, ...preparedBody.headers, ...headers, - }; + } if ( - typeof process !== "undefined" && + typeof process !== 'undefined' && !!process.versions?.node && - !requestHeaders["User-Agent"] && - !requestHeaders["user-agent"] + !requestHeaders['User-Agent'] && + !requestHeaders['user-agent'] ) { - requestHeaders["User-Agent"] = DEFAULT_USER_AGENT; + requestHeaders['User-Agent'] = DEFAULT_USER_AGENT } - const timeoutContext = createTimeoutContext(timeout * 1000); + const timeoutContext = createTimeoutContext(timeout * 1000) const requestInit: FetchRequestInit = { method, headers: requestHeaders, body: preparedBody.body, signal: timeoutContext.signal, - }; + } if (preparedBody.duplex) { - requestInit.duplex = preparedBody.duplex; + requestInit.duplex = preparedBody.duplex } try { - const fetchResponse = await fetch(url, requestInit); - const responseHeaders = normalizeHeaders(fetchResponse.headers); - const parsedBody = - (await parseResponseBody(fetchResponse, responseType)) as ResponseDataFor<TResponseType>; + const fetchResponse = await fetch(url, requestInit) + const responseHeaders = normalizeHeaders(fetchResponse.headers) + const parsedBody = (await parseResponseBody( + fetchResponse, + responseType, + )) as ResponseDataFor<TResponseType> const response: RawHttpResponse<ResponseDataFor<TResponseType>> = { data: parsedBody, status: fetchResponse.status, headers: responseHeaders, requestId: resolveRequestId(responseHeaders), url, - }; + } if (!fetchResponse.ok) { - throw mapHttpError(response, path); + throw mapHttpError(response, path) } if (enableLogging) { - console.info( - `dify-client-node response ${response.status} ${method} ${url}` - ); + console.info(`dify-client-node response ${response.status} ${method} ${url}`) } - return response; + return response } catch (error) { - const mapped = mapTransportError(error, timeoutContext); + const mapped = mapTransportError(error, timeoutContext) const shouldRetryRequest = - preparedBody.replayable && shouldRetry(mapped, attempt, maxRetries); + preparedBody.replayable && shouldRetry(mapped, attempt, maxRetries) if (!shouldRetryRequest) { - throw mapped; + throw mapped } - const retryAfterSeconds = - mapped instanceof RateLimitError ? mapped.retryAfter : undefined; - const delay = getRetryDelayMs(attempt + 1, retryDelay, retryAfterSeconds); + const retryAfterSeconds = mapped instanceof RateLimitError ? mapped.retryAfter : undefined + const delay = getRetryDelayMs(attempt + 1, retryDelay, retryAfterSeconds) if (enableLogging) { - console.info( - `dify-client-node retry ${attempt + 1} in ${delay}ms for ${method} ${url}` - ); + console.info(`dify-client-node retry ${attempt + 1} in ${delay}ms for ${method} ${url}`) } - attempt += 1; - await sleep(delay); + attempt += 1 + await sleep(delay) } finally { - timeoutContext.cleanup(); + timeoutContext.cleanup() } } } diff --git a/sdks/nodejs-client/src/http/form-data.test.ts b/sdks/nodejs-client/src/http/form-data.test.ts index 922f220c69b48c..0578a146ce7af7 100644 --- a/sdks/nodejs-client/src/http/form-data.test.ts +++ b/sdks/nodejs-client/src/http/form-data.test.ts @@ -1,29 +1,29 @@ -import { describe, expect, it, vi } from "vitest"; -import { getFormDataHeaders, isFormData } from "./form-data"; +import { describe, expect, it, vi } from 'vitest' +import { getFormDataHeaders, isFormData } from './form-data' -describe("form-data helpers", () => { - it("detects form-data like objects", () => { +describe('form-data helpers', () => { + it('detects form-data like objects', () => { const formLike = { append: () => {}, - getHeaders: () => ({ "content-type": "multipart/form-data" }), - }; - expect(isFormData(formLike)).toBe(true); - expect(isFormData({})).toBe(false); - }); + getHeaders: () => ({ 'content-type': 'multipart/form-data' }), + } + expect(isFormData(formLike)).toBe(true) + expect(isFormData({})).toBe(false) + }) - it("detects native FormData", () => { - const form = new FormData(); - form.append("field", "value"); - expect(isFormData(form)).toBe(true); - }); + it('detects native FormData', () => { + const form = new FormData() + form.append('field', 'value') + expect(isFormData(form)).toBe(true) + }) - it("returns headers from form-data", () => { + it('returns headers from form-data', () => { const formLike = { append: vi.fn(), - getHeaders: () => ({ "content-type": "multipart/form-data" }), - }; + getHeaders: () => ({ 'content-type': 'multipart/form-data' }), + } expect(getFormDataHeaders(formLike)).toEqual({ - "content-type": "multipart/form-data", - }); - }); -}); + 'content-type': 'multipart/form-data', + }) + }) +}) diff --git a/sdks/nodejs-client/src/http/form-data.ts b/sdks/nodejs-client/src/http/form-data.ts index 6091b7cfdd9573..7e77497ab8ab3b 100644 --- a/sdks/nodejs-client/src/http/form-data.ts +++ b/sdks/nodejs-client/src/http/form-data.ts @@ -1,37 +1,37 @@ -import type { Headers } from "../types/common"; +import type { Headers } from '../types/common' -type FormDataAppendValue = Blob | string; +type FormDataAppendValue = Blob | string -export type WebFormData = FormData; +export type WebFormData = FormData export type LegacyNodeFormData = { - append: (name: string, value: FormDataAppendValue, fileName?: string) => void; - getHeaders: () => Headers; - constructor?: { name?: string }; -}; + append: (name: string, value: FormDataAppendValue, fileName?: string) => void + getHeaders: () => Headers + constructor?: { name?: string } +} -export type SdkFormData = WebFormData | LegacyNodeFormData; +export type SdkFormData = WebFormData | LegacyNodeFormData export const isFormData = (value: unknown): value is SdkFormData => { - if (!value || typeof value !== "object") { - return false; + if (!value || typeof value !== 'object') { + return false } - if (typeof FormData !== "undefined" && value instanceof FormData) { - return true; + if (typeof FormData !== 'undefined' && value instanceof FormData) { + return true } - const candidate = value as Partial<LegacyNodeFormData>; - if (typeof candidate.append !== "function") { - return false; + const candidate = value as Partial<LegacyNodeFormData> + if (typeof candidate.append !== 'function') { + return false } - if (typeof candidate.getHeaders === "function") { - return true; + if (typeof candidate.getHeaders === 'function') { + return true } - return candidate.constructor?.name === "FormData"; -}; + return candidate.constructor?.name === 'FormData' +} export const getFormDataHeaders = (form: SdkFormData): Headers => { - if ("getHeaders" in form && typeof form.getHeaders === "function") { - return form.getHeaders(); + if ('getHeaders' in form && typeof form.getHeaders === 'function') { + return form.getHeaders() } - return {}; -}; + return {} +} diff --git a/sdks/nodejs-client/src/http/retry.test.ts b/sdks/nodejs-client/src/http/retry.test.ts index f53f7428b7d479..35ff9330791ac5 100644 --- a/sdks/nodejs-client/src/http/retry.test.ts +++ b/sdks/nodejs-client/src/http/retry.test.ts @@ -1,38 +1,38 @@ -import { describe, expect, it } from "vitest"; -import { getRetryDelayMs, shouldRetry } from "./retry"; -import { NetworkError, RateLimitError, TimeoutError } from "../errors/dify-error"; +import { describe, expect, it } from 'vitest' +import { NetworkError, RateLimitError, TimeoutError } from '../errors/dify-error' +import { getRetryDelayMs, shouldRetry } from './retry' const withMockedRandom = (value: number, fn: () => void): void => { - const original = Math.random; - Math.random = () => value; + const original = Math.random + Math.random = () => value try { - fn(); + fn() } finally { - Math.random = original; + Math.random = original } -}; +} -describe("retry helpers", () => { - it("getRetryDelayMs honors retry-after header", () => { - expect(getRetryDelayMs(1, 1, 3)).toBe(3000); - }); +describe('retry helpers', () => { + it('getRetryDelayMs honors retry-after header', () => { + expect(getRetryDelayMs(1, 1, 3)).toBe(3000) + }) - it("getRetryDelayMs uses exponential backoff with jitter", () => { + it('getRetryDelayMs uses exponential backoff with jitter', () => { withMockedRandom(0, () => { - expect(getRetryDelayMs(1, 1)).toBe(1000); - expect(getRetryDelayMs(2, 1)).toBe(2000); - expect(getRetryDelayMs(3, 1)).toBe(4000); - }); - }); + expect(getRetryDelayMs(1, 1)).toBe(1000) + expect(getRetryDelayMs(2, 1)).toBe(2000) + expect(getRetryDelayMs(3, 1)).toBe(4000) + }) + }) - it("shouldRetry respects max retries", () => { - expect(shouldRetry(new TimeoutError("timeout"), 3, 3)).toBe(false); - }); + it('shouldRetry respects max retries', () => { + expect(shouldRetry(new TimeoutError('timeout'), 3, 3)).toBe(false) + }) - it("shouldRetry retries on network, timeout, and rate limit", () => { - expect(shouldRetry(new TimeoutError("timeout"), 0, 3)).toBe(true); - expect(shouldRetry(new NetworkError("network"), 0, 3)).toBe(true); - expect(shouldRetry(new RateLimitError("limit"), 0, 3)).toBe(true); - expect(shouldRetry(new Error("other"), 0, 3)).toBe(false); - }); -}); + it('shouldRetry retries on network, timeout, and rate limit', () => { + expect(shouldRetry(new TimeoutError('timeout'), 0, 3)).toBe(true) + expect(shouldRetry(new NetworkError('network'), 0, 3)).toBe(true) + expect(shouldRetry(new RateLimitError('limit'), 0, 3)).toBe(true) + expect(shouldRetry(new Error('other'), 0, 3)).toBe(false) + }) +}) diff --git a/sdks/nodejs-client/src/http/retry.ts b/sdks/nodejs-client/src/http/retry.ts index 3776b78d5f2393..d3f4bd6431c7ec 100644 --- a/sdks/nodejs-client/src/http/retry.ts +++ b/sdks/nodejs-client/src/http/retry.ts @@ -1,40 +1,36 @@ -import { RateLimitError, NetworkError, TimeoutError } from "../errors/dify-error"; +import { RateLimitError, NetworkError, TimeoutError } from '../errors/dify-error' export const sleep = (ms: number): Promise<void> => new Promise((resolve) => { - setTimeout(resolve, ms); - }); + setTimeout(resolve, ms) + }) export const getRetryDelayMs = ( attempt: number, retryDelaySeconds: number, - retryAfterSeconds?: number + retryAfterSeconds?: number, ): number => { if (retryAfterSeconds && retryAfterSeconds > 0) { - return retryAfterSeconds * 1000; + return retryAfterSeconds * 1000 } - const base = retryDelaySeconds * 1000; - const exponential = base * Math.pow(2, Math.max(0, attempt - 1)); - const jitter = Math.random() * base; - return exponential + jitter; -}; + const base = retryDelaySeconds * 1000 + const exponential = base * Math.pow(2, Math.max(0, attempt - 1)) + const jitter = Math.random() * base + return exponential + jitter +} -export const shouldRetry = ( - error: unknown, - attempt: number, - maxRetries: number -): boolean => { +export const shouldRetry = (error: unknown, attempt: number, maxRetries: number): boolean => { if (attempt >= maxRetries) { - return false; + return false } if (error instanceof TimeoutError) { - return true; + return true } if (error instanceof NetworkError) { - return true; + return true } if (error instanceof RateLimitError) { - return true; + return true } - return false; -}; + return false +} diff --git a/sdks/nodejs-client/src/http/sse.test.ts b/sdks/nodejs-client/src/http/sse.test.ts index 83cde28de3dd02..dd62d67850d2e1 100644 --- a/sdks/nodejs-client/src/http/sse.test.ts +++ b/sdks/nodejs-client/src/http/sse.test.ts @@ -1,95 +1,88 @@ -import { Readable } from "node:stream"; -import { describe, expect, it } from "vitest"; -import { createBinaryStream, createSseStream, parseSseStream } from "./sse"; +import { Readable } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { createBinaryStream, createSseStream, parseSseStream } from './sse' -describe("sse parsing", () => { - it("parses event and data lines", async () => { - const stream = Readable.from([ - "event: message\n", - 'data: {"answer":"hi"}\n', - "\n", - ]); - const events: Array<{ event?: string; data: unknown; raw: string }> = []; +describe('sse parsing', () => { + it('parses event and data lines', async () => { + const stream = Readable.from(['event: message\n', 'data: {"answer":"hi"}\n', '\n']) + const events: Array<{ event?: string; data: unknown; raw: string }> = [] for await (const event of parseSseStream(stream)) { - events.push(event); + events.push(event) } - expect(events).toHaveLength(1); - expect(events[0]!.event).toBe("message"); - expect(events[0]!.data).toEqual({ answer: "hi" }); - }); + expect(events).toHaveLength(1) + expect(events[0]!.event).toBe('message') + expect(events[0]!.data).toEqual({ answer: 'hi' }) + }) - it("handles multi-line data payloads", async () => { - const stream = Readable.from(["data: line1\n", "data: line2\n", "\n"]); - const events: Array<{ event?: string; data: unknown; raw: string }> = []; + it('handles multi-line data payloads', async () => { + const stream = Readable.from(['data: line1\n', 'data: line2\n', '\n']) + const events: Array<{ event?: string; data: unknown; raw: string }> = [] for await (const event of parseSseStream(stream)) { - events.push(event); + events.push(event) } - expect(events[0]!.raw).toBe("line1\nline2"); - expect(events[0]!.data).toBe("line1\nline2"); - }); + expect(events[0]!.raw).toBe('line1\nline2') + expect(events[0]!.data).toBe('line1\nline2') + }) - it("ignores comments and flushes the last event without a trailing separator", async () => { + it('ignores comments and flushes the last event without a trailing separator', async () => { const stream = Readable.from([ - Buffer.from(": keep-alive\n"), + Buffer.from(': keep-alive\n'), Uint8Array.from(Buffer.from('event: message\ndata: {"delta":"hi"}\n')), - ]); - const events: Array<{ event?: string; data: unknown; raw: string }> = []; + ]) + const events: Array<{ event?: string; data: unknown; raw: string }> = [] for await (const event of parseSseStream(stream)) { - events.push(event); + events.push(event) } expect(events).toEqual([ { - event: "message", - data: { delta: "hi" }, + event: 'message', + data: { delta: 'hi' }, raw: '{"delta":"hi"}', }, - ]); - }); + ]) + }) - it("createSseStream exposes toText", async () => { - const stream = Readable.from([ - 'data: {"answer":"hello"}\n\n', - 'data: {"delta":" world"}\n\n', - ]); + it('createSseStream exposes toText', async () => { + const stream = Readable.from(['data: {"answer":"hello"}\n\n', 'data: {"delta":" world"}\n\n']) const sseStream = createSseStream(stream, { status: 200, headers: {}, - requestId: "req", - }); - const text = await sseStream.toText(); - expect(text).toBe("hello world"); - }); + requestId: 'req', + }) + const text = await sseStream.toText() + expect(text).toBe('hello world') + }) - it("toText extracts text from string data", async () => { - const stream = Readable.from(["data: plain text\n\n"]); - const sseStream = createSseStream(stream, { status: 200, headers: {} }); - const text = await sseStream.toText(); - expect(text).toBe("plain text"); - }); + it('toText extracts text from string data', async () => { + const stream = Readable.from(['data: plain text\n\n']) + const sseStream = createSseStream(stream, { status: 200, headers: {} }) + const text = await sseStream.toText() + expect(text).toBe('plain text') + }) - it("toText extracts text field from object", async () => { - const stream = Readable.from(['data: {"text":"hello"}\n\n']); - const sseStream = createSseStream(stream, { status: 200, headers: {} }); - const text = await sseStream.toText(); - expect(text).toBe("hello"); - }); + it('toText extracts text field from object', async () => { + const stream = Readable.from(['data: {"text":"hello"}\n\n']) + const sseStream = createSseStream(stream, { status: 200, headers: {} }) + const text = await sseStream.toText() + expect(text).toBe('hello') + }) - it("toText returns empty for invalid data", async () => { - const stream = Readable.from(["data: null\n\n", "data: 123\n\n"]); - const sseStream = createSseStream(stream, { status: 200, headers: {} }); - const text = await sseStream.toText(); - expect(text).toBe(""); - }); + it('toText returns empty for invalid data', async () => { + const stream = Readable.from(['data: null\n\n', 'data: 123\n\n']) + const sseStream = createSseStream(stream, { status: 200, headers: {} }) + const text = await sseStream.toText() + expect(text).toBe('') + }) - it("createBinaryStream exposes metadata", () => { - const stream = Readable.from(["chunk"]); + it('createBinaryStream exposes metadata', () => { + const stream = Readable.from(['chunk']) const binary = createBinaryStream(stream, { status: 200, - headers: { "content-type": "audio/mpeg" }, - requestId: "req", - }); - expect(binary.status).toBe(200); - expect(binary.headers["content-type"]).toBe("audio/mpeg"); - expect(binary.toReadable()).toBe(stream); - }); -}); + headers: { 'content-type': 'audio/mpeg' }, + requestId: 'req', + }) + expect(binary.status).toBe(200) + expect(binary.headers['content-type']).toBe('audio/mpeg') + expect(binary.toReadable()).toBe(stream) + }) +}) diff --git a/sdks/nodejs-client/src/http/sse.ts b/sdks/nodejs-client/src/http/sse.ts index 75a2544f71b25d..d7716652061f8f 100644 --- a/sdks/nodejs-client/src/http/sse.ts +++ b/sdks/nodejs-client/src/http/sse.ts @@ -1,123 +1,115 @@ -import type { Readable } from "node:stream"; -import { StringDecoder } from "node:string_decoder"; -import type { - BinaryStream, - DifyStream, - Headers, - JsonValue, - StreamEvent, -} from "../types/common"; -import { isRecord } from "../internal/type-guards"; +import type { Readable } from 'node:stream' +import type { BinaryStream, DifyStream, Headers, JsonValue, StreamEvent } from '../types/common' +import { StringDecoder } from 'node:string_decoder' +import { isRecord } from '../internal/type-guards' const toBufferChunk = (chunk: unknown): Buffer => { if (Buffer.isBuffer(chunk)) { - return chunk; + return chunk } if (chunk instanceof Uint8Array) { - return Buffer.from(chunk); + return Buffer.from(chunk) } - return Buffer.from(String(chunk)); -}; + return Buffer.from(String(chunk)) +} const readLines = async function* (stream: Readable): AsyncIterable<string> { - const decoder = new StringDecoder("utf8"); - let buffered = ""; + const decoder = new StringDecoder('utf8') + let buffered = '' for await (const chunk of stream) { - buffered += decoder.write(toBufferChunk(chunk)); - let index = buffered.indexOf("\n"); + buffered += decoder.write(toBufferChunk(chunk)) + let index = buffered.indexOf('\n') while (index >= 0) { - let line = buffered.slice(0, index); - buffered = buffered.slice(index + 1); - if (line.endsWith("\r")) { - line = line.slice(0, -1); + let line = buffered.slice(0, index) + buffered = buffered.slice(index + 1) + if (line.endsWith('\r')) { + line = line.slice(0, -1) } - yield line; - index = buffered.indexOf("\n"); + yield line + index = buffered.indexOf('\n') } } - buffered += decoder.end(); + buffered += decoder.end() if (buffered) { - yield buffered; + yield buffered } -}; +} const parseMaybeJson = (value: string): JsonValue | string | null => { if (!value) { - return null; + return null } try { - return JSON.parse(value) as JsonValue; + return JSON.parse(value) as JsonValue } catch { - return value; + return value } -}; +} -export const parseSseStream = async function* <T>( - stream: Readable -): AsyncIterable<StreamEvent<T>> { - let eventName: string | undefined; - const dataLines: string[] = []; +export const parseSseStream = async function* <T>(stream: Readable): AsyncIterable<StreamEvent<T>> { + let eventName: string | undefined + const dataLines: string[] = [] const emitEvent = function* (): Iterable<StreamEvent<T>> { if (!eventName && dataLines.length === 0) { - return; + return } - const raw = dataLines.join("\n"); - const parsed = parseMaybeJson(raw) as T | string | null; + const raw = dataLines.join('\n') + const parsed = parseMaybeJson(raw) as T | string | null yield { event: eventName, data: parsed, raw, - }; - eventName = undefined; - dataLines.length = 0; - }; + } + eventName = undefined + dataLines.length = 0 + } for await (const line of readLines(stream)) { if (!line) { - yield* emitEvent(); - continue; + yield* emitEvent() + continue } - if (line.startsWith(":")) { - continue; + if (line.startsWith(':')) { + continue } - if (line.startsWith("event:")) { - eventName = line.slice("event:".length).trim(); - continue; + if (line.startsWith('event:')) { + eventName = line.slice('event:'.length).trim() + continue } - if (line.startsWith("data:")) { - dataLines.push(line.slice("data:".length).trimStart()); - continue; + if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trimStart()) + continue } } - yield* emitEvent(); -}; + yield* emitEvent() +} const extractTextFromEvent = (data: unknown): string => { - if (typeof data === "string") { - return data; + if (typeof data === 'string') { + return data } if (!isRecord(data)) { - return ""; + return '' } - if (typeof data.answer === "string") { - return data.answer; + if (typeof data.answer === 'string') { + return data.answer } - if (typeof data.text === "string") { - return data.text; + if (typeof data.text === 'string') { + return data.text } - if (typeof data.delta === "string") { - return data.delta; + if (typeof data.delta === 'string') { + return data.delta } - return ""; -}; + return '' +} export const createSseStream = <T>( stream: Readable, - meta: { status: number; headers: Headers; requestId?: string } + meta: { status: number; headers: Headers; requestId?: string }, ): DifyStream<T> => { - const iterator = parseSseStream<T>(stream)[Symbol.asyncIterator](); + const iterator = parseSseStream<T>(stream)[Symbol.asyncIterator]() const iterable = { [Symbol.asyncIterator]: () => iterator, data: stream, @@ -126,24 +118,24 @@ export const createSseStream = <T>( requestId: meta.requestId, toReadable: () => stream, toText: async () => { - let text = ""; + let text = '' for await (const event of iterable) { - text += extractTextFromEvent(event.data); + text += extractTextFromEvent(event.data) } - return text; + return text }, - } satisfies DifyStream<T>; + } satisfies DifyStream<T> - return iterable; -}; + return iterable +} export const createBinaryStream = ( stream: Readable, - meta: { status: number; headers: Headers; requestId?: string } + meta: { status: number; headers: Headers; requestId?: string }, ): BinaryStream => ({ data: stream, status: meta.status, headers: meta.headers, requestId: meta.requestId, toReadable: () => stream, -}); +}) diff --git a/sdks/nodejs-client/src/index.test.ts b/sdks/nodejs-client/src/index.test.ts index b3233718912ef2..e13db378947dab 100644 --- a/sdks/nodejs-client/src/index.test.ts +++ b/sdks/nodejs-client/src/index.test.ts @@ -1,102 +1,102 @@ -import { Readable } from "node:stream"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { BASE_URL, ChatClient, DifyClient, WorkflowClient, routes } from "./index"; +import { Readable } from 'node:stream' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { BASE_URL, ChatClient, DifyClient, WorkflowClient, routes } from './index' const stubFetch = (): ReturnType<typeof vi.fn> => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - return fetchMock; -}; + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => new Response(JSON.stringify(body), { status: 200, ...init, headers: { - "content-type": "application/json", + 'content-type': 'application/json', ...init.headers, }, - }); + }) -describe("Client", () => { +describe('Client', () => { beforeEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) - it("creates a client with default settings", () => { - const difyClient = new DifyClient("test"); + it('creates a client with default settings', () => { + const difyClient = new DifyClient('test') expect(difyClient.getHttpClient().getSettings()).toMatchObject({ - apiKey: "test", + apiKey: 'test', baseUrl: BASE_URL, timeout: 60, - }); - }); + }) + }) - it("updates the api key", () => { - const difyClient = new DifyClient("test"); - difyClient.updateApiKey("test2"); + it('updates the api key', () => { + const difyClient = new DifyClient('test') + difyClient.updateApiKey('test2') - expect(difyClient.getHttpClient().getSettings().apiKey).toBe("test2"); - }); -}); + expect(difyClient.getHttpClient().getSettings().apiKey).toBe('test2') + }) +}) -describe("Send Requests", () => { +describe('Send Requests', () => { beforeEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) - it("makes a successful request to the application parameter route", async () => { - const fetchMock = stubFetch(); - const difyClient = new DifyClient("test"); - const method = "GET"; - const endpoint = routes.application.url(); + it('makes a successful request to the application parameter route', async () => { + const fetchMock = stubFetch() + const difyClient = new DifyClient('test') + const method = 'GET' + const endpoint = routes.application.url() - fetchMock.mockResolvedValueOnce(jsonResponse("response")); + fetchMock.mockResolvedValueOnce(jsonResponse('response')) - const response = await difyClient.sendRequest(method, endpoint); + const response = await difyClient.sendRequest(method, endpoint) expect(response).toMatchObject({ status: 200, - data: "response", + data: 'response', headers: { - "content-type": "application/json", + 'content-type': 'application/json', }, - }); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe(`${BASE_URL}${endpoint}`); - expect(init.method).toBe(method); + }) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe(`${BASE_URL}${endpoint}`) + expect(init.method).toBe(method) expect(init.headers).toMatchObject({ - Authorization: "Bearer test", - "User-Agent": "dify-client-node", - }); - }); + Authorization: 'Bearer test', + 'User-Agent': 'dify-client-node', + }) + }) - it("uses the getMeta route configuration", async () => { - const fetchMock = stubFetch(); - const difyClient = new DifyClient("test"); - fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + it('uses the getMeta route configuration', async () => { + const fetchMock = stubFetch() + const difyClient = new DifyClient('test') + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })) - await difyClient.getMeta("end-user"); + await difyClient.getMeta('end-user') - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe(`${BASE_URL}${routes.getMeta.url()}?user=end-user`); - expect(init.method).toBe(routes.getMeta.method); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe(`${BASE_URL}${routes.getMeta.url()}?user=end-user`) + expect(init.method).toBe(routes.getMeta.method) expect(init.headers).toMatchObject({ - Authorization: "Bearer test", - }); - }); -}); + Authorization: 'Bearer test', + }) + }) +}) -describe("File uploads", () => { - const OriginalFormData = globalThis.FormData; +describe('File uploads', () => { + const OriginalFormData = globalThis.FormData beforeAll(() => { globalThis.FormData = class FormDataMock extends Readable { constructor() { - super(); + super() } override _read() {} @@ -105,136 +105,132 @@ describe("File uploads", () => { getHeaders() { return { - "content-type": "multipart/form-data; boundary=test", - }; + 'content-type': 'multipart/form-data; boundary=test', + } } - } as unknown as typeof FormData; - }); + } as unknown as typeof FormData + }) afterAll(() => { - globalThis.FormData = OriginalFormData; - }); + globalThis.FormData = OriginalFormData + }) beforeEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) - it("does not override multipart boundary headers for legacy FormData", async () => { - const fetchMock = stubFetch(); - const difyClient = new DifyClient("test"); - const form = new globalThis.FormData(); - fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + it('does not override multipart boundary headers for legacy FormData', async () => { + const fetchMock = stubFetch() + const difyClient = new DifyClient('test') + const form = new globalThis.FormData() + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })) - await difyClient.fileUpload(form, "end-user"); + await difyClient.fileUpload(form, 'end-user') - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe(`${BASE_URL}${routes.fileUpload.url()}`); - expect(init.method).toBe(routes.fileUpload.method); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe(`${BASE_URL}${routes.fileUpload.url()}`) + expect(init.method).toBe(routes.fileUpload.method) expect(init.headers).toMatchObject({ - Authorization: "Bearer test", - "content-type": "multipart/form-data; boundary=test", - }); - expect(init.body).not.toBe(form); - expect((init as RequestInit & { duplex?: string }).duplex).toBe("half"); - }); -}); - -describe("Workflow client", () => { + Authorization: 'Bearer test', + 'content-type': 'multipart/form-data; boundary=test', + }) + expect(init.body).not.toBe(form) + expect((init as RequestInit & { duplex?: string }).duplex).toBe('half') + }) +}) + +describe('Workflow client', () => { beforeEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) - it("uses tasks stop path for workflow stop", async () => { - const fetchMock = stubFetch(); - const workflowClient = new WorkflowClient("test"); - fetchMock.mockResolvedValueOnce(jsonResponse({ result: "success" })); + it('uses tasks stop path for workflow stop', async () => { + const fetchMock = stubFetch() + const workflowClient = new WorkflowClient('test') + fetchMock.mockResolvedValueOnce(jsonResponse({ result: 'success' })) - await workflowClient.stop("task-1", "end-user"); + await workflowClient.stop('task-1', 'end-user') - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe(`${BASE_URL}${routes.stopWorkflow.url("task-1")}`); - expect(init.method).toBe(routes.stopWorkflow.method); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe(`${BASE_URL}${routes.stopWorkflow.url('task-1')}`) + expect(init.method).toBe(routes.stopWorkflow.method) expect(init.headers).toMatchObject({ - Authorization: "Bearer test", - "Content-Type": "application/json", - }); - expect(init.body).toBe(JSON.stringify({ user: "end-user" })); - }); + Authorization: 'Bearer test', + 'Content-Type': 'application/json', + }) + expect(init.body).toBe(JSON.stringify({ user: 'end-user' })) + }) - it("maps workflow log filters to service api params", async () => { - const fetchMock = stubFetch(); - const workflowClient = new WorkflowClient("test"); - fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + it('maps workflow log filters to service api params', async () => { + const fetchMock = stubFetch() + const workflowClient = new WorkflowClient('test') + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })) await workflowClient.getLogs({ - createdAtAfter: "2024-01-01T00:00:00Z", - createdAtBefore: "2024-01-02T00:00:00Z", - createdByEndUserSessionId: "sess-1", - createdByAccount: "acc-1", + createdAtAfter: '2024-01-01T00:00:00Z', + createdAtBefore: '2024-01-02T00:00:00Z', + createdByEndUserSessionId: 'sess-1', + createdByAccount: 'acc-1', page: 2, limit: 10, - }); - - const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; - const parsedUrl = new URL(url); - expect(parsedUrl.origin + parsedUrl.pathname).toBe(`${BASE_URL}/workflows/logs`); - expect(parsedUrl.searchParams.get("created_at__before")).toBe( - "2024-01-02T00:00:00Z" - ); - expect(parsedUrl.searchParams.get("created_at__after")).toBe( - "2024-01-01T00:00:00Z" - ); - expect(parsedUrl.searchParams.get("created_by_end_user_session_id")).toBe( - "sess-1" - ); - expect(parsedUrl.searchParams.get("created_by_account")).toBe("acc-1"); - expect(parsedUrl.searchParams.get("page")).toBe("2"); - expect(parsedUrl.searchParams.get("limit")).toBe("10"); - }); -}); - -describe("Chat client", () => { + }) + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit] + const parsedUrl = new URL(url) + expect(parsedUrl.origin + parsedUrl.pathname).toBe(`${BASE_URL}/workflows/logs`) + expect(parsedUrl.searchParams.get('created_at__before')).toBe('2024-01-02T00:00:00Z') + expect(parsedUrl.searchParams.get('created_at__after')).toBe('2024-01-01T00:00:00Z') + expect(parsedUrl.searchParams.get('created_by_end_user_session_id')).toBe('sess-1') + expect(parsedUrl.searchParams.get('created_by_account')).toBe('acc-1') + expect(parsedUrl.searchParams.get('page')).toBe('2') + expect(parsedUrl.searchParams.get('limit')).toBe('10') + }) +}) + +describe('Chat client', () => { beforeEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) - it("places user in query for suggested messages", async () => { - const fetchMock = stubFetch(); - const chatClient = new ChatClient("test"); - fetchMock.mockResolvedValueOnce(jsonResponse({ result: "success", data: [] })); + it('places user in query for suggested messages', async () => { + const fetchMock = stubFetch() + const chatClient = new ChatClient('test') + fetchMock.mockResolvedValueOnce(jsonResponse({ result: 'success', data: [] })) - await chatClient.getSuggested("msg-1", "end-user"); + await chatClient.getSuggested('msg-1', 'end-user') - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe(`${BASE_URL}${routes.getSuggested.url("msg-1")}?user=end-user`); - expect(init.method).toBe(routes.getSuggested.method); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe(`${BASE_URL}${routes.getSuggested.url('msg-1')}?user=end-user`) + expect(init.method).toBe(routes.getSuggested.method) expect(init.headers).toMatchObject({ - Authorization: "Bearer test", - }); - }); - - it("uses last_id when listing conversations", async () => { - const fetchMock = stubFetch(); - const chatClient = new ChatClient("test"); - fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); - - await chatClient.getConversations("end-user", "last-1", 10); - - const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe(`${BASE_URL}${routes.getConversations.url()}?user=end-user&last_id=last-1&limit=10`); - }); - - it("lists app feedbacks without user params", async () => { - const fetchMock = stubFetch(); - const chatClient = new ChatClient("test"); - fetchMock.mockResolvedValueOnce(jsonResponse({ data: [] })); - - await chatClient.getAppFeedbacks(1, 20); - - const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe(`${BASE_URL}/app/feedbacks?page=1&limit=20`); - }); -}); + Authorization: 'Bearer test', + }) + }) + + it('uses last_id when listing conversations', async () => { + const fetchMock = stubFetch() + const chatClient = new ChatClient('test') + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })) + + await chatClient.getConversations('end-user', 'last-1', 10) + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe( + `${BASE_URL}${routes.getConversations.url()}?user=end-user&last_id=last-1&limit=10`, + ) + }) + + it('lists app feedbacks without user params', async () => { + const fetchMock = stubFetch() + const chatClient = new ChatClient('test') + fetchMock.mockResolvedValueOnce(jsonResponse({ data: [] })) + + await chatClient.getAppFeedbacks(1, 20) + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe(`${BASE_URL}/app/feedbacks?page=1&limit=20`) + }) +}) diff --git a/sdks/nodejs-client/src/index.ts b/sdks/nodejs-client/src/index.ts index 3ba3757baa58ef..573c52d308fbf7 100644 --- a/sdks/nodejs-client/src/index.ts +++ b/sdks/nodejs-client/src/index.ts @@ -1,103 +1,103 @@ -import { DEFAULT_BASE_URL } from "./types/common"; +import { DEFAULT_BASE_URL } from './types/common' -export const BASE_URL = DEFAULT_BASE_URL; +export const BASE_URL = DEFAULT_BASE_URL export const routes = { feedback: { - method: "POST", + method: 'POST', url: (messageId: string) => `/messages/${messageId}/feedbacks`, }, application: { - method: "GET", - url: () => "/parameters", + method: 'GET', + url: () => '/parameters', }, fileUpload: { - method: "POST", - url: () => "/files/upload", + method: 'POST', + url: () => '/files/upload', }, filePreview: { - method: "GET", + method: 'GET', url: (fileId: string) => `/files/${fileId}/preview`, }, textToAudio: { - method: "POST", - url: () => "/text-to-audio", + method: 'POST', + url: () => '/text-to-audio', }, audioToText: { - method: "POST", - url: () => "/audio-to-text", + method: 'POST', + url: () => '/audio-to-text', }, getMeta: { - method: "GET", - url: () => "/meta", + method: 'GET', + url: () => '/meta', }, getInfo: { - method: "GET", - url: () => "/info", + method: 'GET', + url: () => '/info', }, getSite: { - method: "GET", - url: () => "/site", + method: 'GET', + url: () => '/site', }, createCompletionMessage: { - method: "POST", - url: () => "/completion-messages", + method: 'POST', + url: () => '/completion-messages', }, stopCompletionMessage: { - method: "POST", + method: 'POST', url: (taskId: string) => `/completion-messages/${taskId}/stop`, }, createChatMessage: { - method: "POST", - url: () => "/chat-messages", + method: 'POST', + url: () => '/chat-messages', }, getSuggested: { - method: "GET", + method: 'GET', url: (messageId: string) => `/messages/${messageId}/suggested`, }, stopChatMessage: { - method: "POST", + method: 'POST', url: (taskId: string) => `/chat-messages/${taskId}/stop`, }, getConversations: { - method: "GET", - url: () => "/conversations", + method: 'GET', + url: () => '/conversations', }, getConversationMessages: { - method: "GET", - url: () => "/messages", + method: 'GET', + url: () => '/messages', }, renameConversation: { - method: "POST", + method: 'POST', url: (conversationId: string) => `/conversations/${conversationId}/name`, }, deleteConversation: { - method: "DELETE", + method: 'DELETE', url: (conversationId: string) => `/conversations/${conversationId}`, }, runWorkflow: { - method: "POST", - url: () => "/workflows/run", + method: 'POST', + url: () => '/workflows/run', }, stopWorkflow: { - method: "POST", + method: 'POST', url: (taskId: string) => `/workflows/tasks/${taskId}/stop`, }, -}; +} -export { DifyClient } from "./client/base"; -export { ChatClient } from "./client/chat"; -export { CompletionClient } from "./client/completion"; -export { WorkflowClient } from "./client/workflow"; -export { KnowledgeBaseClient } from "./client/knowledge-base"; -export { WorkspaceClient } from "./client/workspace"; +export { DifyClient } from './client/base' +export { ChatClient } from './client/chat' +export { CompletionClient } from './client/completion' +export { WorkflowClient } from './client/workflow' +export { KnowledgeBaseClient } from './client/knowledge-base' +export { WorkspaceClient } from './client/workspace' -export * from "./errors/dify-error"; -export * from "./types/common"; -export * from "./types/annotation"; -export * from "./types/chat"; -export * from "./types/completion"; -export * from "./types/knowledge-base"; -export * from "./types/workflow"; -export * from "./types/workspace"; -export { HttpClient } from "./http/client"; +export * from './errors/dify-error' +export * from './types/common' +export * from './types/annotation' +export * from './types/chat' +export * from './types/completion' +export * from './types/knowledge-base' +export * from './types/workflow' +export * from './types/workspace' +export { HttpClient } from './http/client' diff --git a/sdks/nodejs-client/src/internal/type-guards.ts b/sdks/nodejs-client/src/internal/type-guards.ts index 3d74df00fbc32c..31e39be2058382 100644 --- a/sdks/nodejs-client/src/internal/type-guards.ts +++ b/sdks/nodejs-client/src/internal/type-guards.ts @@ -1,9 +1,7 @@ export const isRecord = (value: unknown): value is Record<string, unknown> => - typeof value === "object" && value !== null; + typeof value === 'object' && value !== null -export const hasStringProperty = < - TKey extends string, ->( +export const hasStringProperty = <TKey extends string>( value: unknown, - key: TKey -): value is Record<TKey, string> => isRecord(value) && typeof value[key] === "string"; + key: TKey, +): value is Record<TKey, string> => isRecord(value) && typeof value[key] === 'string' diff --git a/sdks/nodejs-client/src/types/annotation.ts b/sdks/nodejs-client/src/types/annotation.ts index eda48e565c9b90..b50d17cdbb56cb 100644 --- a/sdks/nodejs-client/src/types/annotation.ts +++ b/sdks/nodejs-client/src/types/annotation.ts @@ -1,19 +1,19 @@ export type AnnotationCreateRequest = { - question: string; - answer: string; -}; + question: string + answer: string +} export type AnnotationReplyActionRequest = { - score_threshold: number; - embedding_provider_name: string; - embedding_model_name: string; -}; + score_threshold: number + embedding_provider_name: string + embedding_model_name: string +} export type AnnotationListOptions = { - page?: number; - limit?: number; - keyword?: string; -}; + page?: number + limit?: number + keyword?: string +} -export type AnnotationResponse = JsonObject; -import type { JsonObject } from "./common"; +export type AnnotationResponse = JsonObject +import type { JsonObject } from './common' diff --git a/sdks/nodejs-client/src/types/chat.ts b/sdks/nodejs-client/src/types/chat.ts index 0e714c83f97ba9..e9be58b028bfd7 100644 --- a/sdks/nodejs-client/src/types/chat.ts +++ b/sdks/nodejs-client/src/types/chat.ts @@ -1,28 +1,19 @@ -import type { - DifyRequestFile, - JsonObject, - ResponseMode, - StreamEvent, -} from "./common"; +import type { DifyRequestFile, JsonObject, ResponseMode, StreamEvent } from './common' export type ChatMessageRequest = { - inputs?: JsonObject; - query: string; - user: string; - response_mode?: ResponseMode; - files?: DifyRequestFile[] | null; - conversation_id?: string; - auto_generate_name?: boolean; - workflow_id?: string; - retriever_from?: "app" | "dataset"; -}; + inputs?: JsonObject + query: string + user: string + response_mode?: ResponseMode + files?: DifyRequestFile[] | null + conversation_id?: string + auto_generate_name?: boolean + workflow_id?: string + retriever_from?: 'app' | 'dataset' +} -export type ChatMessageResponse = JsonObject; +export type ChatMessageResponse = JsonObject -export type ChatStreamEvent = StreamEvent<JsonObject>; +export type ChatStreamEvent = StreamEvent<JsonObject> -export type ConversationSortBy = - | "created_at" - | "-created_at" - | "updated_at" - | "-updated_at"; +export type ConversationSortBy = 'created_at' | '-created_at' | 'updated_at' | '-updated_at' diff --git a/sdks/nodejs-client/src/types/common.ts b/sdks/nodejs-client/src/types/common.ts index 60b1f8adf564c1..ac465001cb1bcc 100644 --- a/sdks/nodejs-client/src/types/common.ts +++ b/sdks/nodejs-client/src/types/common.ts @@ -1,87 +1,87 @@ -import type { Readable } from "node:stream"; +import type { Readable } from 'node:stream' -export const DEFAULT_BASE_URL = "https://api.dify.ai/v1"; -export const DEFAULT_TIMEOUT_SECONDS = 60; -export const DEFAULT_MAX_RETRIES = 3; -export const DEFAULT_RETRY_DELAY_SECONDS = 1; +export const DEFAULT_BASE_URL = 'https://api.dify.ai/v1' +export const DEFAULT_TIMEOUT_SECONDS = 60 +export const DEFAULT_MAX_RETRIES = 3 +export const DEFAULT_RETRY_DELAY_SECONDS = 1 -export type RequestMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; -export type ResponseMode = "blocking" | "streaming"; -export type JsonPrimitive = string | number | boolean | null; -export type JsonValue = JsonPrimitive | JsonObject | JsonArray; +export type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' +export type ResponseMode = 'blocking' | 'streaming' +export type JsonPrimitive = string | number | boolean | null +export type JsonValue = JsonPrimitive | JsonObject | JsonArray export type JsonObject = { - [key: string]: JsonValue; -}; -export type JsonArray = JsonValue[]; + [key: string]: JsonValue +} +export type JsonArray = JsonValue[] export type QueryParamValue = | string | number | boolean | Array<string | number | boolean> - | undefined; + | undefined -export type QueryParams = Record<string, QueryParamValue>; +export type QueryParams = Record<string, QueryParamValue> -export type Headers = Record<string, string>; -export type DifyRequestFile = JsonObject; +export type Headers = Record<string, string> +export type DifyRequestFile = JsonObject export type SuccessResponse = { - result: "success"; -}; + result: 'success' +} export type SuggestedQuestionsResponse = SuccessResponse & { - data: string[]; -}; + data: string[] +} export type DifyClientConfig = { - apiKey: string; - baseUrl?: string; - timeout?: number; - maxRetries?: number; - retryDelay?: number; - enableLogging?: boolean; -}; + apiKey: string + baseUrl?: string + timeout?: number + maxRetries?: number + retryDelay?: number + enableLogging?: boolean +} export type DifyResponse<T> = { - data: T; - status: number; - headers: Headers; - requestId?: string; -}; + data: T + status: number + headers: Headers + requestId?: string +} export type MessageFeedbackRequest = { - messageId: string; - user: string; - rating?: "like" | "dislike" | null; - content?: string | null; -}; + messageId: string + user: string + rating?: 'like' | 'dislike' | null + content?: string | null +} export type TextToAudioRequest = { - user: string; - text?: string; - message_id?: string; - streaming?: boolean; - voice?: string; -}; + user: string + text?: string + message_id?: string + streaming?: boolean + voice?: string +} export type StreamEvent<T = unknown> = { - event?: string; - data: T | string | null; - raw: string; -}; + event?: string + data: T | string | null + raw: string +} export type DifyStream<T = unknown> = AsyncIterable<StreamEvent<T>> & { - data: Readable; - status: number; - headers: Headers; - requestId?: string; - toText(): Promise<string>; - toReadable(): Readable; -}; + data: Readable + status: number + headers: Headers + requestId?: string + toText(): Promise<string> + toReadable(): Readable +} export type BinaryStream = { - data: Readable; - status: number; - headers: Headers; - requestId?: string; - toReadable(): Readable; -}; + data: Readable + status: number + headers: Headers + requestId?: string + toReadable(): Readable +} diff --git a/sdks/nodejs-client/src/types/completion.ts b/sdks/nodejs-client/src/types/completion.ts index 99b1757b662e50..9626987e055180 100644 --- a/sdks/nodejs-client/src/types/completion.ts +++ b/sdks/nodejs-client/src/types/completion.ts @@ -1,18 +1,13 @@ -import type { - DifyRequestFile, - JsonObject, - ResponseMode, - StreamEvent, -} from "./common"; +import type { DifyRequestFile, JsonObject, ResponseMode, StreamEvent } from './common' export type CompletionRequest = { - inputs?: JsonObject; - response_mode?: ResponseMode; - user: string; - files?: DifyRequestFile[] | null; - retriever_from?: "app" | "dataset"; -}; + inputs?: JsonObject + response_mode?: ResponseMode + user: string + files?: DifyRequestFile[] | null + retriever_from?: 'app' | 'dataset' +} -export type CompletionResponse = JsonObject; +export type CompletionResponse = JsonObject -export type CompletionStreamEvent = StreamEvent<JsonObject>; +export type CompletionStreamEvent = StreamEvent<JsonObject> diff --git a/sdks/nodejs-client/src/types/knowledge-base.ts b/sdks/nodejs-client/src/types/knowledge-base.ts index 3180148ce76a9f..d5cafc2f61cfae 100644 --- a/sdks/nodejs-client/src/types/knowledge-base.ts +++ b/sdks/nodejs-client/src/types/knowledge-base.ts @@ -1,185 +1,185 @@ export type DatasetListOptions = { - page?: number; - limit?: number; - keyword?: string | null; - tagIds?: string[]; - includeAll?: boolean; -}; + page?: number + limit?: number + keyword?: string | null + tagIds?: string[] + includeAll?: boolean +} export type DatasetCreateRequest = { - name: string; - description?: string; - indexing_technique?: "high_quality" | "economy"; - permission?: string | null; - external_knowledge_api_id?: string | null; - provider?: string; - external_knowledge_id?: string | null; - retrieval_model?: JsonObject | null; - embedding_model?: string | null; - embedding_model_provider?: string | null; -}; + name: string + description?: string + indexing_technique?: 'high_quality' | 'economy' + permission?: string | null + external_knowledge_api_id?: string | null + provider?: string + external_knowledge_id?: string | null + retrieval_model?: JsonObject | null + embedding_model?: string | null + embedding_model_provider?: string | null +} export type DatasetUpdateRequest = { - name?: string; - description?: string | null; - indexing_technique?: "high_quality" | "economy" | null; - permission?: string | null; - embedding_model?: string | null; - embedding_model_provider?: string | null; - retrieval_model?: JsonObject | null; - partial_member_list?: Array<Record<string, string>> | null; - external_retrieval_model?: JsonObject | null; - external_knowledge_id?: string | null; - external_knowledge_api_id?: string | null; -}; - -export type DocumentStatusAction = "enable" | "disable" | "archive" | "un_archive"; + name?: string + description?: string | null + indexing_technique?: 'high_quality' | 'economy' | null + permission?: string | null + embedding_model?: string | null + embedding_model_provider?: string | null + retrieval_model?: JsonObject | null + partial_member_list?: Array<Record<string, string>> | null + external_retrieval_model?: JsonObject | null + external_knowledge_id?: string | null + external_knowledge_api_id?: string | null +} + +export type DocumentStatusAction = 'enable' | 'disable' | 'archive' | 'un_archive' export type DatasetTagCreateRequest = { - name: string; -}; + name: string +} export type DatasetTagUpdateRequest = { - tag_id: string; - name: string; -}; + tag_id: string + name: string +} export type DatasetTagDeleteRequest = { - tag_id: string; -}; + tag_id: string +} export type DatasetTagBindingRequest = { - tag_ids: string[]; - target_id: string; -}; + tag_ids: string[] + target_id: string +} export type DatasetTagUnbindingRequest = { - tag_id: string; - target_id: string; -}; + tag_id: string + target_id: string +} export type DocumentTextCreateRequest = { - name: string; - text: string; - process_rule?: JsonObject | null; - original_document_id?: string | null; - doc_form?: string; - doc_language?: string; - indexing_technique?: string | null; - retrieval_model?: JsonObject | null; - embedding_model?: string | null; - embedding_model_provider?: string | null; -}; + name: string + text: string + process_rule?: JsonObject | null + original_document_id?: string | null + doc_form?: string + doc_language?: string + indexing_technique?: string | null + retrieval_model?: JsonObject | null + embedding_model?: string | null + embedding_model_provider?: string | null +} export type DocumentTextUpdateRequest = { - name?: string | null; - text?: string | null; - process_rule?: JsonObject | null; - doc_form?: string; - doc_language?: string; - retrieval_model?: JsonObject | null; -}; + name?: string | null + text?: string | null + process_rule?: JsonObject | null + doc_form?: string + doc_language?: string + retrieval_model?: JsonObject | null +} export type DocumentListOptions = { - page?: number; - limit?: number; - keyword?: string | null; - status?: string | null; -}; + page?: number + limit?: number + keyword?: string | null + status?: string | null +} export type DocumentGetOptions = { - metadata?: "all" | "only" | "without"; -}; + metadata?: 'all' | 'only' | 'without' +} export type SegmentCreateRequest = { - segments: JsonObject[]; -}; + segments: JsonObject[] +} export type SegmentUpdateRequest = { segment: { - content?: string | null; - answer?: string | null; - keywords?: string[] | null; - regenerate_child_chunks?: boolean; - enabled?: boolean | null; - attachment_ids?: string[] | null; - }; -}; + content?: string | null + answer?: string | null + keywords?: string[] | null + regenerate_child_chunks?: boolean + enabled?: boolean | null + attachment_ids?: string[] | null + } +} export type SegmentListOptions = { - page?: number; - limit?: number; - status?: string[]; - keyword?: string | null; -}; + page?: number + limit?: number + status?: string[] + keyword?: string | null +} export type ChildChunkCreateRequest = { - content: string; -}; + content: string +} export type ChildChunkUpdateRequest = { - content: string; -}; + content: string +} export type ChildChunkListOptions = { - page?: number; - limit?: number; - keyword?: string | null; -}; + page?: number + limit?: number + keyword?: string | null +} export type MetadataCreateRequest = { - type: "string" | "number" | "time"; - name: string; -}; + type: 'string' | 'number' | 'time' + name: string +} export type MetadataUpdateRequest = { - name: string; - value?: string | number | null; -}; + name: string + value?: string | number | null +} export type DocumentMetadataDetail = { - id: string; - name: string; - value?: string | number | null; -}; + id: string + name: string + value?: string | number | null +} export type DocumentMetadataOperation = { - document_id: string; - metadata_list: DocumentMetadataDetail[]; - partial_update?: boolean; -}; + document_id: string + metadata_list: DocumentMetadataDetail[] + partial_update?: boolean +} export type MetadataOperationRequest = { - operation_data: DocumentMetadataOperation[]; -}; + operation_data: DocumentMetadataOperation[] +} export type HitTestingRequest = { - query?: string | null; - retrieval_model?: JsonObject | null; - external_retrieval_model?: JsonObject | null; - attachment_ids?: string[] | null; -}; + query?: string | null + retrieval_model?: JsonObject | null + external_retrieval_model?: JsonObject | null + attachment_ids?: string[] | null +} export type DatasourcePluginListOptions = { - isPublished?: boolean; -}; + isPublished?: boolean +} export type DatasourceNodeRunRequest = { - inputs: JsonObject; - datasource_type: string; - credential_id?: string | null; - is_published: boolean; -}; + inputs: JsonObject + datasource_type: string + credential_id?: string | null + is_published: boolean +} export type PipelineRunRequest = { - inputs: JsonObject; - datasource_type: string; - datasource_info_list: JsonObject[]; - start_node_id: string; - is_published: boolean; - response_mode: ResponseMode; -}; - -export type KnowledgeBaseResponse = JsonObject; -export type PipelineStreamEvent = JsonObject; -import type { JsonObject, ResponseMode } from "./common"; + inputs: JsonObject + datasource_type: string + datasource_info_list: JsonObject[] + start_node_id: string + is_published: boolean + response_mode: ResponseMode +} + +export type KnowledgeBaseResponse = JsonObject +export type PipelineStreamEvent = JsonObject +import type { JsonObject, ResponseMode } from './common' diff --git a/sdks/nodejs-client/src/types/workflow.ts b/sdks/nodejs-client/src/types/workflow.ts index 9ddedce1c20a23..c10d02915e3ba1 100644 --- a/sdks/nodejs-client/src/types/workflow.ts +++ b/sdks/nodejs-client/src/types/workflow.ts @@ -1,17 +1,12 @@ -import type { - DifyRequestFile, - JsonObject, - ResponseMode, - StreamEvent, -} from "./common"; +import type { DifyRequestFile, JsonObject, ResponseMode, StreamEvent } from './common' export type WorkflowRunRequest = { - inputs?: JsonObject; - user: string; - response_mode?: ResponseMode; - files?: DifyRequestFile[] | null; -}; + inputs?: JsonObject + user: string + response_mode?: ResponseMode + files?: DifyRequestFile[] | null +} -export type WorkflowRunResponse = JsonObject; +export type WorkflowRunResponse = JsonObject -export type WorkflowStreamEvent = StreamEvent<JsonObject>; +export type WorkflowStreamEvent = StreamEvent<JsonObject> diff --git a/sdks/nodejs-client/src/types/workspace.ts b/sdks/nodejs-client/src/types/workspace.ts index 5bb07ad373e06c..3c5813e9465e6d 100644 --- a/sdks/nodejs-client/src/types/workspace.ts +++ b/sdks/nodejs-client/src/types/workspace.ts @@ -1,4 +1,4 @@ -import type { JsonObject } from "./common"; +import type { JsonObject } from './common' -export type WorkspaceModelType = string; -export type WorkspaceModelsResponse = JsonObject; +export type WorkspaceModelType = string +export type WorkspaceModelsResponse = JsonObject diff --git a/sdks/nodejs-client/tests/http.integration.test.ts b/sdks/nodejs-client/tests/http.integration.test.ts index e73b192a67a51a..5d22afe168b60e 100644 --- a/sdks/nodejs-client/tests/http.integration.test.ts +++ b/sdks/nodejs-client/tests/http.integration.test.ts @@ -1,137 +1,137 @@ -import { createServer } from "node:http"; -import { Readable } from "node:stream"; -import type { AddressInfo } from "node:net"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { HttpClient } from "../src/http/client"; +import type { AddressInfo } from 'node:net' +import { createServer } from 'node:http' +import { Readable } from 'node:stream' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { HttpClient } from '../src/http/client' const readBody = async (stream: NodeJS.ReadableStream): Promise<Buffer> => { - const chunks: Buffer[] = []; + const chunks: Buffer[] = [] for await (const chunk of stream) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) } - return Buffer.concat(chunks); -}; + return Buffer.concat(chunks) +} -describe("HttpClient integration", () => { +describe('HttpClient integration', () => { const requests: Array<{ - url: string; - method: string; - headers: Record<string, string | string[] | undefined>; - body: Buffer; - }> = []; + url: string + method: string + headers: Record<string, string | string[] | undefined> + body: Buffer + }> = [] const server = createServer((req, res) => { void (async () => { - const body = await readBody(req); + const body = await readBody(req) requests.push({ - url: req.url ?? "", - method: req.method ?? "", + url: req.url ?? '', + method: req.method ?? '', headers: req.headers, body, - }); + }) - if (req.url?.startsWith("/json")) { - res.writeHead(200, { "content-type": "application/json", "x-request-id": "req-json" }); - res.end(JSON.stringify({ ok: true })); - return; + if (req.url?.startsWith('/json')) { + res.writeHead(200, { 'content-type': 'application/json', 'x-request-id': 'req-json' }) + res.end(JSON.stringify({ ok: true })) + return } - if (req.url === "/stream") { - res.writeHead(200, { "content-type": "text/event-stream" }); - res.end('data: {"answer":"hello"}\n\ndata: {"delta":" world"}\n\n'); - return; + if (req.url === '/stream') { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end('data: {"answer":"hello"}\n\ndata: {"delta":" world"}\n\n') + return } - if (req.url === "/bytes") { - res.writeHead(200, { "content-type": "application/octet-stream" }); - res.end(Buffer.from([1, 2, 3, 4])); - return; + if (req.url === '/bytes') { + res.writeHead(200, { 'content-type': 'application/octet-stream' }) + res.end(Buffer.from([1, 2, 3, 4])) + return } - if (req.url === "/upload-stream") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ received: body.toString("utf8") })); - return; + if (req.url === '/upload-stream') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ received: body.toString('utf8') })) + return } - res.writeHead(404, { "content-type": "application/json" }); - res.end(JSON.stringify({ message: "not found" })); - })(); - }); + res.writeHead(404, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ message: 'not found' })) + })() + }) - let client: HttpClient; + let client: HttpClient beforeAll(async () => { await new Promise<void>((resolve) => { - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address() as AddressInfo; + server.listen(0, '127.0.0.1', () => resolve()) + }) + const address = server.address() as AddressInfo client = new HttpClient({ - apiKey: "test-key", + apiKey: 'test-key', baseUrl: `http://127.0.0.1:${address.port}`, maxRetries: 0, retryDelay: 0, - }); - }); + }) + }) afterAll(async () => { await new Promise<void>((resolve, reject) => { server.close((error) => { if (error) { - reject(error); - return; + reject(error) + return } - resolve(); - }); - }); - }); + resolve() + }) + }) + }) - it("uses real fetch for query serialization and json bodies", async () => { + it('uses real fetch for query serialization and json bodies', async () => { const response = await client.request({ - method: "POST", - path: "/json", - query: { tag_ids: ["a", "b"], limit: 2 }, - data: { user: "u" }, - }); - - expect(response.requestId).toBe("req-json"); - expect(response.data).toEqual({ ok: true }); + method: 'POST', + path: '/json', + query: { tag_ids: ['a', 'b'], limit: 2 }, + data: { user: 'u' }, + }) + + expect(response.requestId).toBe('req-json') + expect(response.data).toEqual({ ok: true }) expect(requests.at(-1)).toMatchObject({ - url: "/json?tag_ids=a&tag_ids=b&limit=2", - method: "POST", - }); - expect(requests.at(-1)?.headers.authorization).toBe("Bearer test-key"); - expect(requests.at(-1)?.headers["content-type"]).toBe("application/json"); - expect(requests.at(-1)?.body.toString("utf8")).toBe(JSON.stringify({ user: "u" })); - }); - - it("supports streaming request bodies with duplex fetch", async () => { + url: '/json?tag_ids=a&tag_ids=b&limit=2', + method: 'POST', + }) + expect(requests.at(-1)?.headers.authorization).toBe('Bearer test-key') + expect(requests.at(-1)?.headers['content-type']).toBe('application/json') + expect(requests.at(-1)?.body.toString('utf8')).toBe(JSON.stringify({ user: 'u' })) + }) + + it('supports streaming request bodies with duplex fetch', async () => { const response = await client.request<{ received: string }>({ - method: "POST", - path: "/upload-stream", - data: Readable.from(["hello ", "world"]), - }); + method: 'POST', + path: '/upload-stream', + data: Readable.from(['hello ', 'world']), + }) - expect(response.data).toEqual({ received: "hello world" }); - expect(requests.at(-1)?.body.toString("utf8")).toBe("hello world"); - }); + expect(response.data).toEqual({ received: 'hello world' }) + expect(requests.at(-1)?.body.toString('utf8')).toBe('hello world') + }) - it("parses real sse responses into text", async () => { + it('parses real sse responses into text', async () => { const stream = await client.requestStream({ - method: "GET", - path: "/stream", - }); - - await expect(stream.toText()).resolves.toBe("hello world"); - }); - - it("parses real byte responses into buffers", async () => { - const response = await client.request<Buffer, "bytes">({ - method: "GET", - path: "/bytes", - responseType: "bytes", - }); - - expect(Array.from(response.data.values())).toEqual([1, 2, 3, 4]); - }); -}); + method: 'GET', + path: '/stream', + }) + + await expect(stream.toText()).resolves.toBe('hello world') + }) + + it('parses real byte responses into buffers', async () => { + const response = await client.request<Buffer, 'bytes'>({ + method: 'GET', + path: '/bytes', + responseType: 'bytes', + }) + + expect(Array.from(response.data.values())).toEqual([1, 2, 3, 4]) + }) +}) diff --git a/sdks/nodejs-client/tests/test-utils.ts b/sdks/nodejs-client/tests/test-utils.ts index cc48f3ef603a17..d59fa173ed2f74 100644 --- a/sdks/nodejs-client/tests/test-utils.ts +++ b/sdks/nodejs-client/tests/test-utils.ts @@ -1,48 +1,48 @@ -import { vi } from "vitest"; -import { HttpClient } from "../src/http/client"; -import type { DifyClientConfig } from "../src/types/common"; +import type { DifyClientConfig } from '../src/types/common' +import { vi } from 'vitest' +import { HttpClient } from '../src/http/client' -type FetchMock = ReturnType<typeof vi.fn>; -type RequestSpy = ReturnType<typeof vi.fn>; +type FetchMock = ReturnType<typeof vi.fn> +type RequestSpy = ReturnType<typeof vi.fn> type HttpClientWithFetchMock = { - client: HttpClient; - fetchMock: FetchMock; -}; + client: HttpClient + fetchMock: FetchMock +} type HttpClientWithSpies = HttpClientWithFetchMock & { - request: RequestSpy; - requestStream: RequestSpy; - requestBinaryStream: RequestSpy; -}; + request: RequestSpy + requestStream: RequestSpy + requestBinaryStream: RequestSpy +} export const createHttpClient = ( - configOverrides: Partial<DifyClientConfig> = {} + configOverrides: Partial<DifyClientConfig> = {}, ): HttpClientWithFetchMock => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - const client = new HttpClient({ apiKey: "test", ...configOverrides }); - return { client, fetchMock }; -}; + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const client = new HttpClient({ apiKey: 'test', ...configOverrides }) + return { client, fetchMock } +} export const createHttpClientWithSpies = ( - configOverrides: Partial<DifyClientConfig> = {} + configOverrides: Partial<DifyClientConfig> = {}, ): HttpClientWithSpies => { - const { client, fetchMock } = createHttpClient(configOverrides); + const { client, fetchMock } = createHttpClient(configOverrides) const request = vi - .spyOn(client, "request") - .mockResolvedValue({ data: "ok", status: 200, headers: {} }); + .spyOn(client, 'request') + .mockResolvedValue({ data: 'ok', status: 200, headers: {} }) const requestStream = vi - .spyOn(client, "requestStream") - .mockResolvedValue({ data: null, status: 200, headers: {} } as never); + .spyOn(client, 'requestStream') + .mockResolvedValue({ data: null, status: 200, headers: {} } as never) const requestBinaryStream = vi - .spyOn(client, "requestBinaryStream") - .mockResolvedValue({ data: null, status: 200, headers: {} } as never); + .spyOn(client, 'requestBinaryStream') + .mockResolvedValue({ data: null, status: 200, headers: {} } as never) return { client, fetchMock, request, requestStream, requestBinaryStream, - }; -}; + } +} diff --git a/sdks/nodejs-client/vite.config.ts b/sdks/nodejs-client/vite.config.ts index 68ced551a37a8d..31a61fc3cae714 100644 --- a/sdks/nodejs-client/vite.config.ts +++ b/sdks/nodejs-client/vite.config.ts @@ -1,26 +1,26 @@ -import { defineConfig } from "vite-plus"; +import { defineConfig } from 'vite-plus' export default defineConfig({ pack: { - entry: ["src/index.ts"], - format: ["esm"], - platform: "node", + entry: ['src/index.ts'], + format: ['esm'], + platform: 'node', dts: true, clean: true, sourcemap: true, // splitting: false, treeshake: true, - outDir: "dist", + outDir: 'dist', target: false, }, test: { - environment: "node", - include: ["**/*.test.ts"], + environment: 'node', + include: ['**/*.test.ts'], coverage: { - provider: "v8", - reporter: ["text", "text-summary"], - include: ["src/**/*.ts"], - exclude: ["src/**/*.test.*", "src/**/*.spec.*"], + provider: 'v8', + reporter: ['text', 'text-summary'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.*', 'src/**/*.spec.*'], }, }, -}); +}) diff --git a/web/.storybook/preview.tsx b/web/.storybook/preview.tsx index 0209fc535c85e3..e1216869115a12 100644 --- a/web/.storybook/preview.tsx +++ b/web/.storybook/preview.tsx @@ -5,7 +5,6 @@ import { withThemeByDataAttribute } from '@storybook/addon-themes' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { I18nClientProvider as I18N } from '../app/components/provider/i18n' import commonEnUS from '../i18n/en-US/common.json' - import '../app/styles/markdown.css' import './storybook.css' diff --git a/web/.storybook/utils/audio-player-manager.mock.ts b/web/.storybook/utils/audio-player-manager.mock.ts index aca8b56b767199..aff5655fc6bdfc 100644 --- a/web/.storybook/utils/audio-player-manager.mock.ts +++ b/web/.storybook/utils/audio-player-manager.mock.ts @@ -24,8 +24,7 @@ class MockAudioPlayer { } private clearTimer() { - if (this.finishTimer) - clearTimeout(this.finishTimer) + if (this.finishTimer) clearTimeout(this.finishTimer) } } @@ -55,8 +54,7 @@ export const ensureMockAudioManager = () => { __isStorybookMockInstalled?: boolean } - if (managerAny.__isStorybookMockInstalled) - return + if (managerAny.__isStorybookMockInstalled) return const mock = new MockAudioPlayerManager() managerAny.getInstance = () => mock as unknown as AudioPlayerManager diff --git a/web/__mocks__/@tanstack/react-virtual.ts b/web/__mocks__/@tanstack/react-virtual.ts index 59cca5e33f68a2..14018c6d38af0a 100644 --- a/web/__mocks__/@tanstack/react-virtual.ts +++ b/web/__mocks__/@tanstack/react-virtual.ts @@ -10,7 +10,8 @@ const mockVirtualizer = ({ const getSize = (index: number) => estimateSize?.(index) ?? 0 return { - getTotalSize: () => Array.from({ length: count }).reduce<number>((total, _, index) => total + getSize(index), 0), + getTotalSize: () => + Array.from({ length: count }).reduce<number>((total, _, index) => total + getSize(index), 0), getVirtualItems: () => { let start = 0 diff --git a/web/__mocks__/__tests__/base-ui-popover.spec.tsx b/web/__mocks__/__tests__/base-ui-popover.spec.tsx index 3b5b741ca0883d..915895977a8b29 100644 --- a/web/__mocks__/__tests__/base-ui-popover.spec.tsx +++ b/web/__mocks__/__tests__/base-ui-popover.spec.tsx @@ -1,10 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '../base-ui-popover' +import { Popover, PopoverContent, PopoverTrigger } from '../base-ui-popover' type PopoverHarnessProps = { useRenderElement?: boolean @@ -22,20 +18,19 @@ const PopoverHarness = ({ <div data-testid="outside-area">outside</div> <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={useRenderElement - ? ( - <button - type="button" - data-testid="custom-trigger" - onClick={(event) => { - if (preventDefaultOnTrigger) - event.preventDefault() - }} - > - toggle - </button> - ) - : undefined} + render={ + useRenderElement ? ( + <button + type="button" + data-testid="custom-trigger" + onClick={(event) => { + if (preventDefaultOnTrigger) event.preventDefault() + }} + > + toggle + </button> + ) : undefined + } > fallback trigger </PopoverTrigger> @@ -44,7 +39,9 @@ const PopoverHarness = ({ placement="bottom-start" sideOffset={4} alignOffset={8} - positionerProps={{ 'data-positioner': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>} + positionerProps={ + { 'data-positioner': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement> + } popupProps={{ 'data-popup': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>} > <div>popover body</div> @@ -110,9 +107,7 @@ describe('base-ui-popover mock', () => { render( <div> <Popover open={false} onOpenChange={vi.fn()}> - <PopoverTrigger onClick={handleClick}> - fallback trigger - </PopoverTrigger> + <PopoverTrigger onClick={handleClick}>fallback trigger</PopoverTrigger> <PopoverContent> <div>popover body</div> </PopoverContent> diff --git a/web/__mocks__/base-ui-dropdown-menu.tsx b/web/__mocks__/base-ui-dropdown-menu.tsx index 9e2bfa2d412c07..90ff259754a01b 100644 --- a/web/__mocks__/base-ui-dropdown-menu.tsx +++ b/web/__mocks__/base-ui-dropdown-menu.tsx @@ -26,17 +26,16 @@ type DropdownMenuContentProps = React.HTMLAttributes<HTMLDivElement> & { popupClassName?: string } -export const DropdownMenu = ({ - children, - open, - onOpenChange, -}: DropdownMenuProps) => { +export const DropdownMenu = ({ children, open, onOpenChange }: DropdownMenuProps) => { const [localOpen, setLocalOpen] = React.useState(false) const resolvedOpen = open ?? localOpen - const handleOpenChange = React.useCallback((nextOpen: boolean) => { - setLocalOpen(nextOpen) - onOpenChange?.(nextOpen) - }, [onOpenChange]) + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + setLocalOpen(nextOpen) + onOpenChange?.(nextOpen) + }, + [onOpenChange], + ) return ( <DropdownMenuContext.Provider value={{ open: resolvedOpen, onOpenChange: handleOpenChange }}> @@ -59,30 +58,48 @@ export const DropdownMenuTrigger = ({ const isNativeButton = React.isValidElement(node) && node.type === 'button' const handleClick = (event: React.MouseEvent<HTMLElement>) => { onClick?.(event) - if (!event.defaultPrevented) - onOpenChange(!open) + if (!event.defaultPrevented) onOpenChange(!open) } if (React.isValidElement(node)) { const triggerElement = node as React.ReactElement<Record<string, unknown>> - const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string } + const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & { + 'data-testid'?: string + } const triggerProps = props as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string } - const role = childProps.role ?? triggerProps.role ?? (!isNativeButton && (childProps['aria-label'] || triggerProps['aria-label']) ? 'button' : undefined) - return React.cloneElement(triggerElement, { - ...props, - ...childProps, - 'data-testid': childProps['data-testid'] ?? triggerProps['data-testid'] ?? 'dropdown-menu-trigger', - role, - 'tabIndex': childProps.tabIndex ?? triggerProps.tabIndex ?? (role === 'button' ? 0 : undefined), - 'onClick': (event: React.MouseEvent<HTMLElement>) => { - childProps.onClick?.(event) - handleClick(event) + const role = + childProps.role ?? + triggerProps.role ?? + (!isNativeButton && (childProps['aria-label'] || triggerProps['aria-label']) + ? 'button' + : undefined) + return React.cloneElement( + triggerElement, + { + ...props, + ...childProps, + 'data-testid': + childProps['data-testid'] ?? triggerProps['data-testid'] ?? 'dropdown-menu-trigger', + role, + tabIndex: + childProps.tabIndex ?? triggerProps.tabIndex ?? (role === 'button' ? 0 : undefined), + onClick: (event: React.MouseEvent<HTMLElement>) => { + childProps.onClick?.(event) + handleClick(event) + }, }, - }, render ? (children ?? childProps.children) : childProps.children) + render ? (children ?? childProps.children) : childProps.children, + ) } return ( - <div data-testid="dropdown-menu-trigger" role="button" tabIndex={0} onClick={handleClick} {...props}> + <div + data-testid="dropdown-menu-trigger" + role="button" + tabIndex={0} + onClick={handleClick} + {...props} + > {node} </div> ) @@ -98,8 +115,7 @@ export const DropdownMenuContent = ({ ...props }: DropdownMenuContentProps) => { const { open } = React.useContext(DropdownMenuContext) - if (!open) - return null + if (!open) return null return ( <div @@ -129,16 +145,18 @@ export const DropdownMenuRadioGroup = ({ children, onValueChange, ...props -}: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode, value?: unknown, onValueChange?: (value: unknown) => void }) => ( - <div - role="radiogroup" - {...props} - data-on-value-change={onValueChange ? 'true' : undefined} - > +}: React.HTMLAttributes<HTMLDivElement> & { + children?: ReactNode + value?: unknown + onValueChange?: (value: unknown) => void +}) => ( + <div role="radiogroup" {...props} data-on-value-change={onValueChange ? 'true' : undefined}> {React.Children.map(children, (child) => { - if (!React.isValidElement(child)) - return child - return React.cloneElement(child as React.ReactElement<{ __onValueChange?: (value: unknown) => void }>, { __onValueChange: onValueChange }) + if (!React.isValidElement(child)) return child + return React.cloneElement( + child as React.ReactElement<{ __onValueChange?: (value: unknown) => void }>, + { __onValueChange: onValueChange }, + ) })} </div> ) @@ -149,7 +167,11 @@ export const DropdownMenuRadioItem = ({ onClick, __onValueChange, ...props -}: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode, value?: unknown, __onValueChange?: (value: unknown) => void }) => ( +}: React.HTMLAttributes<HTMLDivElement> & { + children?: ReactNode + value?: unknown + __onValueChange?: (value: unknown) => void +}) => ( <div role="radio" onClick={(event) => { @@ -162,11 +184,17 @@ export const DropdownMenuRadioItem = ({ </div> ) -export const DropdownMenuRadioItemIndicator = ({ children }: { children?: ReactNode }) => <>{children}</> +export const DropdownMenuRadioItemIndicator = ({ children }: { children?: ReactNode }) => ( + <>{children}</> +) export const DropdownMenuCheckboxItem = DropdownMenuItem -export const DropdownMenuCheckboxItemIndicator = ({ children }: { children?: ReactNode }) => <>{children}</> +export const DropdownMenuCheckboxItemIndicator = ({ children }: { children?: ReactNode }) => ( + <>{children}</> +) export const DropdownMenuLabel = ({ children }: { children?: ReactNode }) => <>{children}</> -export const DropdownMenuSeparator = (props: React.HTMLAttributes<HTMLDivElement>) => <div role="separator" {...props} /> +export const DropdownMenuSeparator = (props: React.HTMLAttributes<HTMLDivElement>) => ( + <div role="separator" {...props} /> +) export const DropdownMenuSub = ({ children }: { children?: ReactNode }) => <>{children}</> export const DropdownMenuSubTrigger = DropdownMenuItem export const DropdownMenuSubContent = ({ children }: { children?: ReactNode }) => <>{children}</> diff --git a/web/__mocks__/base-ui-popover.tsx b/web/__mocks__/base-ui-popover.tsx index 0874aedc9382bf..3c393ebd0b87fc 100644 --- a/web/__mocks__/base-ui-popover.tsx +++ b/web/__mocks__/base-ui-popover.tsx @@ -28,33 +28,29 @@ type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & { popupProps?: React.HTMLAttributes<HTMLDivElement> } -export const Popover = ({ - children, - open, - onOpenChange, -}: PopoverProps) => { +export const Popover = ({ children, open, onOpenChange }: PopoverProps) => { const [localOpen, setLocalOpen] = React.useState(false) const resolvedOpen = open ?? localOpen - const handleOpenChange = React.useCallback((nextOpen: boolean) => { - setLocalOpen(nextOpen) - onOpenChange?.(nextOpen) - }, [onOpenChange]) + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + setLocalOpen(nextOpen) + onOpenChange?.(nextOpen) + }, + [onOpenChange], + ) React.useEffect(() => { - if (!resolvedOpen) - return + if (!resolvedOpen) return const handleMouseDown = (event: MouseEvent) => { const target = event.target as Element | null - if (target?.closest?.('[data-popover-trigger="true"], [data-popover-content="true"]')) - return + if (target?.closest?.('[data-popover-trigger="true"], [data-popover-content="true"]')) return handleOpenChange(false) } const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') - handleOpenChange(false) + if (event.key === 'Escape') handleOpenChange(false) } document.addEventListener('mousedown', handleMouseDown) @@ -67,10 +63,11 @@ export const Popover = ({ }, [resolvedOpen, handleOpenChange]) return ( - <PopoverContext.Provider value={{ - open: resolvedOpen, - onOpenChange: handleOpenChange, - }} + <PopoverContext.Provider + value={{ + open: resolvedOpen, + onOpenChange: handleOpenChange, + }} > <div data-testid="popover" data-open={String(resolvedOpen)}> {children} @@ -91,23 +88,29 @@ export const PopoverTrigger = ({ if (React.isValidElement(node)) { const triggerElement = node as React.ReactElement<Record<string, unknown>> - const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string } + const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & { + 'data-testid'?: string + } const triggerProps = props as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string } - return React.cloneElement(triggerElement, { - ...props, - ...childProps, - 'data-testid': childProps['data-testid'] ?? triggerProps['data-testid'] ?? 'popover-trigger', - 'data-popover-trigger': 'true', - 'data-popup-open': open ? '' : undefined, - 'onClick': (event: React.MouseEvent<HTMLElement>) => { - childProps.onClick?.(event) - onClick?.(event) - if (event.defaultPrevented) - return - onOpenChange(!open) + return React.cloneElement( + triggerElement, + { + ...props, + ...childProps, + 'data-testid': + childProps['data-testid'] ?? triggerProps['data-testid'] ?? 'popover-trigger', + 'data-popover-trigger': 'true', + 'data-popup-open': open ? '' : undefined, + onClick: (event: React.MouseEvent<HTMLElement>) => { + childProps.onClick?.(event) + onClick?.(event) + if (event.defaultPrevented) return + onOpenChange(!open) + }, }, - }, render ? (children ?? childProps.children) : childProps.children) + render ? (children ?? childProps.children) : childProps.children, + ) } return ( @@ -117,8 +120,7 @@ export const PopoverTrigger = ({ data-popup-open={open ? '' : undefined} onClick={(event) => { onClick?.(event) - if (event.defaultPrevented) - return + if (event.defaultPrevented) return onOpenChange(!open) }} {...props} @@ -141,8 +143,7 @@ export const PopoverContent = ({ }: PopoverContentProps) => { const { open } = React.useContext(PopoverContext) - if (!open) - return null + if (!open) return null return ( <div diff --git a/web/__mocks__/base-ui-select.tsx b/web/__mocks__/base-ui-select.tsx index 327d89712dc7c5..80fce8a5a9b9b1 100644 --- a/web/__mocks__/base-ui-select.tsx +++ b/web/__mocks__/base-ui-select.tsx @@ -12,11 +12,7 @@ type SelectProps = { onValueChange?: (value: unknown) => void } -export const Select = ({ - children, - value, - onValueChange, -}: SelectProps) => ( +export const Select = ({ children, value, onValueChange }: SelectProps) => ( <SelectContext.Provider value={{ value, onValueChange: onValueChange ?? (() => {}) }}> <div data-testid="select-root">{children}</div> </SelectContext.Provider> @@ -40,7 +36,9 @@ export const SelectContent = ({ children?: ReactNode popupClassName?: string }) => ( - <div data-side="bottom" data-testid="select-content" className={popupClassName}>{children}</div> + <div data-side="bottom" data-testid="select-content" className={popupClassName}> + {children} + </div> ) export const SelectItem = ({ @@ -48,7 +46,7 @@ export const SelectItem = ({ value, onClick, ...props -}: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode, value?: unknown }) => { +}: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode; value?: unknown }) => { const select = React.useContext(SelectContext) return ( <div @@ -69,4 +67,6 @@ export const SelectItemIndicator = ({ children }: { children?: ReactNode }) => < export const SelectGroup = ({ children }: { children?: ReactNode }) => <>{children}</> export const SelectLabel = () => null export const SelectGroupLabel = ({ children }: { children?: ReactNode }) => <>{children}</> -export const SelectSeparator = (props: React.HTMLAttributes<HTMLDivElement>) => <div role="separator" {...props} /> +export const SelectSeparator = (props: React.HTMLAttributes<HTMLDivElement>) => ( + <div role="separator" {...props} /> +) diff --git a/web/__mocks__/base-ui-tooltip.tsx b/web/__mocks__/base-ui-tooltip.tsx index 23e05f0864e743..c1ee0790b0aa88 100644 --- a/web/__mocks__/base-ui-tooltip.tsx +++ b/web/__mocks__/base-ui-tooltip.tsx @@ -15,10 +15,13 @@ type TooltipProps = { export const Tooltip = ({ children, open, onOpenChange }: TooltipProps) => { const [localOpen, setLocalOpen] = React.useState(false) const resolvedOpen = open ?? localOpen - const handleOpenChange = React.useCallback((nextOpen: boolean) => { - setLocalOpen(nextOpen) - onOpenChange?.(nextOpen) - }, [onOpenChange]) + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + setLocalOpen(nextOpen) + onOpenChange?.(nextOpen) + }, + [onOpenChange], + ) return ( <TooltipContext.Provider value={{ open: resolvedOpen, onOpenChange: handleOpenChange }}> @@ -32,7 +35,11 @@ export const TooltipTrigger = ({ render, nativeButton: _nativeButton, ...props -}: React.HTMLAttributes<HTMLElement> & { children?: ReactNode, render?: React.ReactElement, nativeButton?: boolean }) => { +}: React.HTMLAttributes<HTMLElement> & { + children?: ReactNode + render?: React.ReactElement + nativeButton?: boolean +}) => { const { open, onOpenChange } = React.useContext(TooltipContext) const node = render ?? children @@ -87,8 +94,7 @@ export const TooltipContent = ({ ...props }: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode }) => { const { open } = React.useContext(TooltipContext) - if (!open) - return null + if (!open) return null return <div {...props}>{children}</div> } diff --git a/web/__mocks__/provider-context.ts b/web/__mocks__/provider-context.ts index 38afc2ff0e5a7c..c4d33e30fb28f5 100644 --- a/web/__mocks__/provider-context.ts +++ b/web/__mocks__/provider-context.ts @@ -40,7 +40,9 @@ export const baseProviderContextValue: ProviderContextState = { humanInputEmailDeliveryEnabled: false, } -export const createMockProviderContextValue = (overrides: Partial<ProviderContextState> = {}): ProviderContextState => { +export const createMockProviderContextValue = ( + overrides: Partial<ProviderContextState> = {}, +): ProviderContextState => { const merged = merge({}, baseProviderContextValue, overrides) return { @@ -58,7 +60,10 @@ export const createMockPlan = (plan: Plan): ProviderContextState => }), }) -export const createMockPlanUsage = (usage: UsagePlanInfo, ctx: Partial<ProviderContextState>): ProviderContextState => +export const createMockPlanUsage = ( + usage: UsagePlanInfo, + ctx: Partial<ProviderContextState>, +): ProviderContextState => createMockProviderContextValue({ ...ctx, plan: merge(ctx.plan, { @@ -66,7 +71,10 @@ export const createMockPlanUsage = (usage: UsagePlanInfo, ctx: Partial<ProviderC }), }) -export const createMockPlanTotal = (total: UsagePlanInfo, ctx: Partial<ProviderContextState>): ProviderContextState => +export const createMockPlanTotal = ( + total: UsagePlanInfo, + ctx: Partial<ProviderContextState>, +): ProviderContextState => createMockProviderContextValue({ ...ctx, plan: merge(ctx.plan, { @@ -74,7 +82,10 @@ export const createMockPlanTotal = (total: UsagePlanInfo, ctx: Partial<ProviderC }), }) -export const createMockPlanReset = (reset: Partial<ProviderContextState['plan']['reset']>, ctx: Partial<ProviderContextState>): ProviderContextState => +export const createMockPlanReset = ( + reset: Partial<ProviderContextState['plan']['reset']>, + ctx: Partial<ProviderContextState>, +): ProviderContextState => createMockProviderContextValue({ ...ctx, plan: merge(ctx?.plan, { diff --git a/web/__mocks__/zustand.ts b/web/__mocks__/zustand.ts index 7255e5ef86e318..2c4211b60a5dd8 100644 --- a/web/__mocks__/zustand.ts +++ b/web/__mocks__/zustand.ts @@ -3,15 +3,13 @@ import { act } from '@testing-library/react' export * from 'zustand' -const { create: actualCreate, createStore: actualCreateStore } +const { create: actualCreate, createStore: actualCreateStore } = // eslint-disable-next-line antfu/no-top-level-await - = await vi.importActual<typeof ZustandExportedTypes>('zustand') + await vi.importActual<typeof ZustandExportedTypes>('zustand') export const storeResetFns = new Set<() => void>() -const createUncurried = <T>( - stateCreator: ZustandExportedTypes.StateCreator<T>, -) => { +const createUncurried = <T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => { const store = actualCreate(stateCreator) const initialState = store.getInitialState() storeResetFns.add(() => { @@ -20,17 +18,11 @@ const createUncurried = <T>( return store } -export const create = (<T>( - stateCreator: ZustandExportedTypes.StateCreator<T>, -) => { - return typeof stateCreator === 'function' - ? createUncurried(stateCreator) - : createUncurried +export const create = (<T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => { + return typeof stateCreator === 'function' ? createUncurried(stateCreator) : createUncurried }) as typeof ZustandExportedTypes.create -const createStoreUncurried = <T>( - stateCreator: ZustandExportedTypes.StateCreator<T>, -) => { +const createStoreUncurried = <T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => { const store = actualCreateStore(stateCreator) const initialState = store.getInitialState() storeResetFns.add(() => { @@ -39,9 +31,7 @@ const createStoreUncurried = <T>( return store } -export const createStore = (<T>( - stateCreator: ZustandExportedTypes.StateCreator<T>, -) => { +export const createStore = (<T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => { return typeof stateCreator === 'function' ? createStoreUncurried(stateCreator) : createStoreUncurried diff --git a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx index b3cb9527bca35b..76b4462621e829 100644 --- a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx +++ b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx @@ -23,9 +23,10 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ - dataset: mockDataset, - }), + useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => + selector({ + dataset: mockDataset, + }), })) vi.mock('@/hooks/use-knowledge', () => ({ @@ -67,14 +68,17 @@ vi.mock('@/app/components/datasets/rename-modal', () => ({ show: boolean onClose: () => void onSuccess: () => void - }) => show - ? ( - <div data-testid="rename-dataset-modal"> - <button type="button" onClick={onSuccess}>rename-success</button> - <button type="button" onClick={onClose}>rename-close</button> - </div> - ) - : null, + }) => + show ? ( + <div data-testid="rename-dataset-modal"> + <button type="button" onClick={onSuccess}> + rename-success + </button> + <button type="button" onClick={onClose}> + rename-close + </button> + </div> + ) : null, })) const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ @@ -177,9 +181,11 @@ describe('App Sidebar Dataset Info Flow', () => { pipelineId: 'pipeline-1', include: false, }) - expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({ - fileName: 'Dataset Name.pipeline', - })) + expect(mockDownloadBlob).toHaveBeenCalledWith( + expect.objectContaining({ + fileName: 'Dataset Name.pipeline', + }), + ) }) }) diff --git a/web/__tests__/app-star-i18n.test.ts b/web/__tests__/app-star-i18n.test.ts index c785d7d8c8499a..b9bd485069309e 100644 --- a/web/__tests__/app-star-i18n.test.ts +++ b/web/__tests__/app-star-i18n.test.ts @@ -13,15 +13,16 @@ const REQUIRED_APP_STAR_KEYS = [ type AppTranslations = Record<string, unknown> -const getSupportedLocales = () => fs.readdirSync(I18N_DIR) - .filter(item => fs.statSync(path.join(I18N_DIR, item)).isDirectory()) - .sort() +const getSupportedLocales = () => + fs + .readdirSync(I18N_DIR) + .filter((item) => fs.statSync(path.join(I18N_DIR, item)).isDirectory()) + .sort() const loadAppTranslations = (locale: string): AppTranslations => { const filePath = path.join(I18N_DIR, locale, 'app.json') - if (!fs.existsSync(filePath)) - throw new Error(`Translation file not found: ${filePath}`) + if (!fs.existsSync(filePath)) throw new Error(`Translation file not found: ${filePath}`) return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as AppTranslations } @@ -33,12 +34,10 @@ describe('App star i18n translations', () => { const missingKeys = supportedLocales.flatMap((locale) => { const translations = loadAppTranslations(locale) - return REQUIRED_APP_STAR_KEYS - .filter((key) => { - const value = translations[key] - return typeof value !== 'string' || value.trim() === '' - }) - .map(key => `${locale}:${key}`) + return REQUIRED_APP_STAR_KEYS.filter((key) => { + const value = translations[key] + return typeof value !== 'string' || value.trim() === '' + }).map((key) => `${locale}:${key}`) }) expect(supportedLocales.length).toBeGreaterThan(0) diff --git a/web/__tests__/app/app-access-control-flow.test.tsx b/web/__tests__/app/app-access-control-flow.test.tsx index 5db55c5583550c..bb624faf6ef83b 100644 --- a/web/__tests__/app/app-access-control-flow.test.tsx +++ b/web/__tests__/app/app-access-control-flow.test.tsx @@ -33,10 +33,11 @@ const renderWithQueryClient = (ui: React.ReactElement) => }, }) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({ - appDetail: mockAppDetail, - setAppDetail: mockSetAppDetail, - }), + useStore: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + appDetail: mockAppDetail, + setAppDetail: mockSetAppDetail, + }), })) vi.mock('@/hooks/use-format-time-from-now', () => ({ @@ -82,16 +83,14 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () })) vi.mock('@/app/components/app/app-access-control', () => ({ - default: ({ - onConfirm, - onClose, - }: { - onConfirm: () => Promise<void> - onClose: () => void - }) => ( + default: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>; onClose: () => void }) => ( <div data-testid="access-control-modal"> - <button type="button" onClick={() => void onConfirm()}>confirm-access-control</button> - <button type="button" onClick={onClose}>close-access-control</button> + <button type="button" onClick={() => void onConfirm()}> + confirm-access-control + </button> + <button type="button" onClick={onClose}> + close-access-control + </button> </div> ), })) @@ -133,12 +132,17 @@ describe('App Access Control Flow', () => { await waitFor(() => { expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) }) - expect(setQueryDataSpy).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({ - access_mode: AccessMode.PUBLIC, - })) - expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({ - access_mode: AccessMode.PUBLIC, - })) + expect(setQueryDataSpy).toHaveBeenCalledWith( + ['apps', 'detail', 'app-1'], + expect.objectContaining({ + access_mode: AccessMode.PUBLIC, + }), + ) + expect(mockSetAppDetail).toHaveBeenCalledWith( + expect.objectContaining({ + access_mode: AccessMode.PUBLIC, + }), + ) await waitFor(() => { expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument() diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx index 975b14f58985e5..52c46905d98957 100644 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -36,10 +36,11 @@ const renderWithQueryClient = (ui: React.ReactElement) => }, }) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({ - appDetail: mockAppDetail, - setAppDetail: mockSetAppDetail, - }), + useStore: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + appDetail: mockAppDetail, + setAppDetail: mockSetAppDetail, + }), })) vi.mock('@/hooks/use-format-time-from-now', () => ({ @@ -82,15 +83,12 @@ vi.mock('@/app/components/base/amplitude', () => ({ })) vi.mock('@/app/components/app/overview/embedded', () => ({ - default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( - isShow - ? ( - <div data-testid="embedded-modal"> - <button onClick={onClose}>close-embedded</button> - </div> - ) - : null - ), + default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) => + isShow ? ( + <div data-testid="embedded-modal"> + <button onClick={onClose}>close-embedded</button> + </div> + ) : null, })) vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({ @@ -140,28 +138,21 @@ describe('App Publisher Flow', () => { id: 'app-1', access_mode: AccessMode.PUBLIC, }) - mockOpenAsyncWindow.mockImplementation(async ( - resolver: () => Promise<string>, - options?: { onError?: (error: Error) => void }, - ) => { - try { - return await resolver() - } - catch (error) { - options?.onError?.(error as Error) - } - }) + mockOpenAsyncWindow.mockImplementation( + async (resolver: () => Promise<string>, options?: { onError?: (error: Error) => void }) => { + try { + return await resolver() + } catch (error) { + options?.onError?.(error as Error) + } + }, + ) }) it('publishes from the summary panel and tracks the publish event', async () => { const onPublish = vi.fn().mockResolvedValue(undefined) - renderWithQueryClient( - <AppPublisher - publishedAt={1700000000} - onPublish={onPublish} - />, - ) + renderWithQueryClient(<AppPublisher publishedAt={1700000000} onPublish={onPublish} />) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) @@ -172,11 +163,14 @@ describe('App Publisher Flow', () => { await waitFor(() => { expect(onPublish).toHaveBeenCalledTimes(1) - expect(mockTrackEvent).toHaveBeenCalledWith('app_published_time', expect.objectContaining({ - action_mode: 'app', - app_id: 'app-1', - app_name: 'Demo App', - })) + expect(mockTrackEvent).toHaveBeenCalledWith( + 'app_published_time', + expect.objectContaining({ + action_mode: 'app', + app_id: 'app-1', + app_name: 'Demo App', + }), + ) }) }) @@ -208,7 +202,9 @@ describe('App Publisher Flow', () => { fireEvent.click(screen.getByText(/(?:^|\.)common\.openInExplore(?=$|:)/)) await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)notPublishedYet(?=$|:)/)) + expect(mockToastError).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)notPublishedYet(?=$|:)/), + ) }) }) }) diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index f6397211113cd1..e0322e58f10329 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -34,10 +34,14 @@ const mockRouterPush = vi.fn() vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { - success: (message: string, options?: Record<string, unknown>) => toastMocks.mockNotify({ type: 'success', message, ...options }), - error: (message: string, options?: Record<string, unknown>) => toastMocks.mockNotify({ type: 'error', message, ...options }), - warning: (message: string, options?: Record<string, unknown>) => toastMocks.mockNotify({ type: 'warning', message, ...options }), - info: (message: string, options?: Record<string, unknown>) => toastMocks.mockNotify({ type: 'info', message, ...options }), + success: (message: string, options?: Record<string, unknown>) => + toastMocks.mockNotify({ type: 'success', message, ...options }), + error: (message: string, options?: Record<string, unknown>) => + toastMocks.mockNotify({ type: 'error', message, ...options }), + warning: (message: string, options?: Record<string, unknown>) => + toastMocks.mockNotify({ type: 'warning', message, ...options }), + info: (message: string, options?: Record<string, unknown>) => + toastMocks.mockNotify({ type: 'info', message, ...options }), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -66,12 +70,15 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { vi.mock('@/next/dynamic', () => ({ default: (loader: () => Promise<React.ComponentType | { default: React.ComponentType }>) => { let Component: React.ComponentType<Record<string, unknown>> | null = null - loader().then((mod) => { - Component = (typeof mod === 'function' ? mod : mod.default) as React.ComponentType<Record<string, unknown>> - }).catch(() => {}) + loader() + .then((mod) => { + Component = (typeof mod === 'function' ? mod : mod.default) as React.ComponentType< + Record<string, unknown> + > + }) + .catch(() => {}) const Wrapper = (props: Record<string, unknown>) => { - if (Component) - return <Component {...props} /> + if (Component) return <Component {...props} /> return null } Wrapper.displayName = 'DynamicWrapper' @@ -126,24 +133,27 @@ vi.mock('@/hooks/use-async-window-open', () => ({ // Mock modals loaded via next/dynamic vi.mock('@/app/components/explore/create-app-modal', () => ({ default: ({ show, onConfirm, onHide, appName }: Record<string, unknown>) => { - if (!show) - return null + if (!show) return null return ( <div data-testid="edit-app-modal"> <span data-testid="modal-app-name">{appName as string}</span> <button data-testid="confirm-edit" - onClick={() => (onConfirm as (data: Record<string, unknown>) => void)({ - name: 'Updated App Name', - icon_type: 'emoji', - icon: '🔥', - icon_background: '#fff', - description: 'Updated description', - })} + onClick={() => + (onConfirm as (data: Record<string, unknown>) => void)({ + name: 'Updated App Name', + icon_type: 'emoji', + icon: '🔥', + icon_background: '#fff', + description: 'Updated description', + }) + } > Confirm </button> - <button data-testid="cancel-edit" onClick={onHide as () => void}>Cancel</button> + <button data-testid="cancel-edit" onClick={onHide as () => void}> + Cancel + </button> </div> ) }, @@ -151,22 +161,25 @@ vi.mock('@/app/components/explore/create-app-modal', () => ({ vi.mock('@/app/components/app/duplicate-modal', () => ({ default: ({ show, onConfirm, onHide }: Record<string, unknown>) => { - if (!show) - return null + if (!show) return null return ( <div data-testid="duplicate-app-modal"> <button data-testid="confirm-duplicate" - onClick={() => (onConfirm as (data: Record<string, unknown>) => void)({ - name: 'Copied App', - icon_type: 'emoji', - icon: '📋', - icon_background: '#fff', - })} + onClick={() => + (onConfirm as (data: Record<string, unknown>) => void)({ + name: 'Copied App', + icon_type: 'emoji', + icon: '📋', + icon_background: '#fff', + }) + } > Confirm Duplicate </button> - <button data-testid="cancel-duplicate" onClick={onHide as () => void}>Cancel</button> + <button data-testid="cancel-duplicate" onClick={onHide as () => void}> + Cancel + </button> </div> ) }, @@ -174,12 +187,15 @@ vi.mock('@/app/components/app/duplicate-modal', () => ({ vi.mock('@/app/components/app/switch-app-modal', () => ({ default: ({ show, onClose, onSuccess }: Record<string, unknown>) => { - if (!show) - return null + if (!show) return null return ( <div data-testid="switch-app-modal"> - <button data-testid="confirm-switch" onClick={onSuccess as () => void}>Confirm Switch</button> - <button data-testid="cancel-switch" onClick={onClose as () => void}>Cancel</button> + <button data-testid="confirm-switch" onClick={onSuccess as () => void}> + Confirm Switch + </button> + <button data-testid="cancel-switch" onClick={onClose as () => void}> + Cancel + </button> </div> ) }, @@ -188,8 +204,15 @@ vi.mock('@/app/components/app/switch-app-modal', () => ({ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ default: ({ onConfirm, onClose }: Record<string, unknown>) => ( <div data-testid="dsl-export-confirm-modal"> - <button data-testid="export-include" onClick={() => (onConfirm as (include: boolean) => void)(true)}>Include</button> - <button data-testid="export-close" onClick={onClose as () => void}>Close</button> + <button + data-testid="export-include" + onClick={() => (onConfirm as (include: boolean) => void)(true)} + > + Include + </button> + <button data-testid="export-close" onClick={onClose as () => void}> + Close + </button> </div> ), })) @@ -197,8 +220,12 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ vi.mock('@/app/components/app/app-access-control', () => { const MockAccessControl = ({ onConfirm, onClose }: Record<string, unknown>) => ( <div data-testid="access-control-modal"> - <button data-testid="confirm-access" onClick={onConfirm as () => void}>Confirm</button> - <button data-testid="cancel-access" onClick={onClose as () => void}>Cancel</button> + <button data-testid="confirm-access" onClick={onConfirm as () => void}> + Confirm + </button> + <button data-testid="cancel-access" onClick={onClose as () => void}> + Cancel + </button> </div> ) @@ -224,11 +251,11 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({ api_rpm: overrides.api_rpm ?? 60, api_rph: overrides.api_rph ?? 3600, is_demo: overrides.is_demo ?? false, - model_config: overrides.model_config ?? {} as App['model_config'], - app_model_config: overrides.app_model_config ?? {} as App['app_model_config'], + model_config: overrides.model_config ?? ({} as App['model_config']), + app_model_config: overrides.app_model_config ?? ({} as App['app_model_config']), created_at: overrides.created_at ?? 1700000000, updated_at: overrides.updated_at ?? 1700001000, - site: overrides.site ?? {} as App['site'], + site: overrides.site ?? ({} as App['site']), api_base_url: overrides.api_base_url ?? 'https://api.example.com', tags: overrides.tags ?? [], access_mode: overrides.access_mode ?? AccessMode.PUBLIC, @@ -246,10 +273,9 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({ const mockOnRefresh = vi.fn() const renderAppCard = (app?: Partial<App>) => { - return renderWithSystemFeatures( - <AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />, - { systemFeatures: mockSystemFeatures }, - ) + return renderWithSystemFeatures(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />, { + systemFeatures: mockSystemFeatures, + }) } const openOperationsMenu = () => { @@ -287,13 +313,19 @@ describe('App Card Operations Flow', () => { it('should navigate to app config page when card is clicked', () => { renderAppCard({ id: 'app-123', mode: AppModeEnum.CHAT }) - expect(screen.getByRole('link', { name: 'Test Chat App' })).toHaveAttribute('href', '/app/app-123/configuration') + expect(screen.getByRole('link', { name: 'Test Chat App' })).toHaveAttribute( + 'href', + '/app/app-123/configuration', + ) }) it('should navigate to workflow page for workflow apps', () => { renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' }) - expect(screen.getByRole('link', { name: 'WF App' })).toHaveAttribute('href', '/app/app-wf/workflow') + expect(screen.getByRole('link', { name: 'WF App' })).toHaveAttribute( + 'href', + '/app/app-wf/workflow', + ) }) }) @@ -359,7 +391,9 @@ describe('App Card Operations Flow', () => { it('should not render operations menu when user has no app permissions', () => { renderAppCard({ name: 'Readonly App', created_by: 'another-user', permission_keys: [] }) - expect(screen.queryByRole('button', { name: 'common.operation.more' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.more' }), + ).not.toBeInTheDocument() }) }) diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index d97ee7d906369a..502b466b3e99f7 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -115,7 +115,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -226,11 +227,11 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({ api_rpm: overrides.api_rpm ?? 60, api_rph: overrides.api_rph ?? 3600, is_demo: overrides.is_demo ?? false, - model_config: overrides.model_config ?? {} as App['model_config'], - app_model_config: overrides.app_model_config ?? {} as App['app_model_config'], + model_config: overrides.model_config ?? ({} as App['model_config']), + app_model_config: overrides.app_model_config ?? ({} as App['app_model_config']), created_at: overrides.created_at ?? 1700000000, updated_at: overrides.updated_at ?? 1700001000, - site: overrides.site ?? {} as App['site'], + site: overrides.site ?? ({} as App['site']), api_base_url: overrides.api_base_url ?? 'https://api.example.com', tags: overrides.tags ?? [], access_mode: overrides.access_mode ?? AccessMode.PUBLIC, @@ -309,9 +310,7 @@ describe('App List Browsing Flow', () => { // Data loads mockIsLoading = false - mockPages = [createPage([ - createMockApp({ id: 'app-1', name: 'Loaded App' }), - ])] + mockPages = [createPage([createMockApp({ id: 'app-1', name: 'Loaded App' })])] rerender(<List controlRefreshList={0} />) @@ -322,11 +321,13 @@ describe('App List Browsing Flow', () => { // -- Rendering apps -- describe('App List Rendering', () => { it('should render all app cards from the data', () => { - mockPages = [createPage([ - createMockApp({ id: 'app-1', name: 'Chat Bot' }), - createMockApp({ id: 'app-2', name: 'Workflow Engine', mode: AppModeEnum.WORKFLOW }), - createMockApp({ id: 'app-3', name: 'Completion Tool', mode: AppModeEnum.COMPLETION }), - ])] + mockPages = [ + createPage([ + createMockApp({ id: 'app-1', name: 'Chat Bot' }), + createMockApp({ id: 'app-2', name: 'Workflow Engine', mode: AppModeEnum.WORKFLOW }), + createMockApp({ id: 'app-3', name: 'Completion Tool', mode: AppModeEnum.COMPLETION }), + ]), + ] renderList() @@ -336,9 +337,9 @@ describe('App List Browsing Flow', () => { }) it('should display app descriptions', () => { - mockPages = [createPage([ - createMockApp({ name: 'My App', description: 'A powerful AI assistant' }), - ])] + mockPages = [ + createPage([createMockApp({ name: 'My App', description: 'A powerful AI assistant' })]), + ] renderList() @@ -346,9 +347,7 @@ describe('App List Browsing Flow', () => { }) it('should show the create menu for workspace editors', () => { - mockPages = [createPage([ - createMockApp({ name: 'Test App' }), - ])] + mockPages = [createPage([createMockApp({ name: 'Test App' })])] renderList() @@ -357,15 +356,19 @@ describe('App List Browsing Flow', () => { it('should hide the create menu when user lacks app creation permission', () => { mockWorkspacePermissionKeys = [] - mockPages = [createPage([ - createMockApp({ name: 'Test App' }), - ])] + mockPages = [createPage([createMockApp({ name: 'Test App' })])] renderList() - expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'app.newApp.startFromBlank' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'app.newApp.startFromTemplate' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.create' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'app.newApp.startFromBlank' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'app.newApp.startFromTemplate' }), + ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'app.importDSL' })).not.toBeInTheDocument() }) }) @@ -418,12 +421,24 @@ describe('App List Browsing Flow', () => { fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.types' })) - expect(await screen.findByRole('menuitemradio', { name: 'app.types.all' })).toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.types.workflow' })).toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.types.advanced' })).toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.types.chatbot' })).toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.types.agent' })).toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.newApp.completeApp' })).toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.all' }), + ).toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.workflow' }), + ).toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.advanced' }), + ).toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.chatbot' }), + ).toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.agent' }), + ).toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.newApp.completeApp' }), + ).toBeInTheDocument() }) }) @@ -497,12 +512,8 @@ describe('App List Browsing Flow', () => { describe('Multi-page Data', () => { it('should render apps from multiple pages', () => { mockPages = [ - createPage([ - createMockApp({ id: 'app-1', name: 'Page One App' }), - ], true, 1), - createPage([ - createMockApp({ id: 'app-2', name: 'Page Two App' }), - ], false, 2), + createPage([createMockApp({ id: 'app-1', name: 'Page One App' })], true, 1), + createPage([createMockApp({ id: 'app-2', name: 'Page Two App' })], false, 2), ] renderList() diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index 4b43c3b8302d43..14a4a69a7af34f 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -102,7 +102,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -190,12 +191,13 @@ vi.mock('ahooks', async () => { vi.mock('@/next/dynamic', () => ({ default: (loader: () => Promise<{ default: React.ComponentType }>) => { let Component: React.ComponentType<Record<string, unknown>> | null = null - loader().then((mod) => { - Component = mod.default as React.ComponentType<Record<string, unknown>> - }).catch(() => {}) + loader() + .then((mod) => { + Component = mod.default as React.ComponentType<Record<string, unknown>> + }) + .catch(() => {}) const Wrapper = (props: Record<string, unknown>) => { - if (Component) - return <Component {...props} /> + if (Component) return <Component {...props} /> return null } Wrapper.displayName = 'DynamicWrapper' @@ -205,15 +207,20 @@ vi.mock('@/next/dynamic', () => ({ vi.mock('@/app/components/app/create-app-modal', () => ({ default: ({ show, onClose, onSuccess, onCreateFromTemplate }: Record<string, unknown>) => { - if (!show) - return null + if (!show) return null return ( <div data-testid="create-app-modal"> - <button data-testid="create-blank-confirm" onClick={onSuccess as () => void}>Create Blank</button> + <button data-testid="create-blank-confirm" onClick={onSuccess as () => void}> + Create Blank + </button> {!!onCreateFromTemplate && ( - <button data-testid="switch-to-template" onClick={onCreateFromTemplate as () => void}>From Template</button> + <button data-testid="switch-to-template" onClick={onCreateFromTemplate as () => void}> + From Template + </button> )} - <button data-testid="create-blank-cancel" onClick={onClose as () => void}>Cancel</button> + <button data-testid="create-blank-cancel" onClick={onClose as () => void}> + Cancel + </button> </div> ) }, @@ -221,15 +228,20 @@ vi.mock('@/app/components/app/create-app-modal', () => ({ vi.mock('@/app/components/app/create-app-dialog', () => ({ default: ({ show, onClose, onSuccess, onCreateFromBlank }: Record<string, unknown>) => { - if (!show) - return null + if (!show) return null return ( <div data-testid="template-dialog"> - <button data-testid="template-confirm" onClick={onSuccess as () => void}>Create from Template</button> + <button data-testid="template-confirm" onClick={onSuccess as () => void}> + Create from Template + </button> {!!onCreateFromBlank && ( - <button data-testid="switch-to-blank" onClick={onCreateFromBlank as () => void}>From Blank</button> + <button data-testid="switch-to-blank" onClick={onCreateFromBlank as () => void}> + From Blank + </button> )} - <button data-testid="template-cancel" onClick={onClose as () => void}>Cancel</button> + <button data-testid="template-cancel" onClick={onClose as () => void}> + Cancel + </button> </div> ) }, @@ -237,12 +249,15 @@ vi.mock('@/app/components/app/create-app-dialog', () => ({ vi.mock('@/app/components/app/create-from-dsl-modal', () => ({ default: ({ show, onClose, onSuccess }: Record<string, unknown>) => { - if (!show) - return null + if (!show) return null return ( <div data-testid="create-from-dsl-modal"> - <button data-testid="dsl-import-confirm" onClick={onSuccess as () => void}>Import DSL</button> - <button data-testid="dsl-import-cancel" onClick={onClose as () => void}>Cancel</button> + <button data-testid="dsl-import-confirm" onClick={onSuccess as () => void}> + Import DSL + </button> + <button data-testid="dsl-import-cancel" onClick={onClose as () => void}> + Cancel + </button> </div> ) }, @@ -268,11 +283,11 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({ api_rpm: overrides.api_rpm ?? 60, api_rph: overrides.api_rph ?? 3600, is_demo: overrides.is_demo ?? false, - model_config: overrides.model_config ?? {} as App['model_config'], - app_model_config: overrides.app_model_config ?? {} as App['app_model_config'], + model_config: overrides.model_config ?? ({} as App['model_config']), + app_model_config: overrides.app_model_config ?? ({} as App['app_model_config']), created_at: overrides.created_at ?? 1700000000, updated_at: overrides.updated_at ?? 1700001000, - site: overrides.site ?? {} as App['site'], + site: overrides.site ?? ({} as App['site']), api_base_url: overrides.api_base_url ?? 'https://api.example.com', tags: overrides.tags ?? [], access_mode: overrides.access_mode ?? AccessMode.PUBLIC, @@ -339,7 +354,9 @@ describe('Create App Flow', () => { mockWorkspacePermissionKeys = [] renderList() - expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.create' }), + ).not.toBeInTheDocument() }) it('should keep the create menu available while workspace state is loading', () => { @@ -514,8 +531,7 @@ describe('Create App Flow', () => { await waitFor(() => { const modal = screen.queryByTestId('create-from-dsl-modal') - if (modal) - expect(modal).toBeInTheDocument() + if (modal) expect(modal).toBeInTheDocument() }) } }) @@ -542,9 +558,10 @@ describe('Create App Flow', () => { // Should not crash, and some modal should be present await waitFor(() => { - const anyModal = screen.queryByTestId('create-app-modal') - || screen.queryByTestId('template-dialog') - || screen.queryByTestId('create-from-dsl-modal') + const anyModal = + screen.queryByTestId('create-app-modal') || + screen.queryByTestId('template-dialog') || + screen.queryByTestId('create-from-dsl-modal') expect(anyModal).toBeTruthy() }) }) diff --git a/web/__tests__/base/chat-flow.test.tsx b/web/__tests__/base/chat-flow.test.tsx index 6ede7c766becf8..b8bf3ab2f04f32 100644 --- a/web/__tests__/base/chat-flow.test.tsx +++ b/web/__tests__/base/chat-flow.test.tsx @@ -53,10 +53,22 @@ const defaultHookReturn: HookReturn = { appData: mockAppData, appParams: {} as ChatConfig, appMeta: {} as AppMeta, - appPinnedConversationData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, - appConversationData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appPinnedConversationData: { + data: [] as ConversationItem[], + has_more: false, + limit: 20, + } as AppConversationData, + appConversationData: { + data: [] as ConversationItem[], + has_more: false, + limit: 20, + } as AppConversationData, appConversationDataLoading: false, - appChatListData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appChatListData: { + data: [] as ConversationItem[], + has_more: false, + limit: 20, + } as AppConversationData, appChatListDataLoading: false, appPrevChatTree: [], pinnedConversationList: [], diff --git a/web/__tests__/base/file-uploader-flow.test.tsx b/web/__tests__/base/file-uploader-flow.test.tsx index c77c92ad31747d..ba1d82323998a9 100644 --- a/web/__tests__/base/file-uploader-flow.test.tsx +++ b/web/__tests__/base/file-uploader-flow.test.tsx @@ -18,19 +18,20 @@ vi.mock('@/service/common', () => ({ uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args), })) -const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({ - enabled: true, - allowed_file_types: ['document'], - allowed_file_extensions: [], - allowed_file_upload_methods: [TransferMethod.remote_url], - number_limits: 5, - preview_config: { - enabled: false, - mode: 'current_page', - file_type_list: [], - }, - ...overrides, -} as FileUpload) +const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => + ({ + enabled: true, + allowed_file_types: ['document'], + allowed_file_extensions: [], + allowed_file_upload_methods: [TransferMethod.remote_url], + number_limits: 5, + preview_config: { + enabled: false, + mode: 'current_page', + file_type_list: [], + }, + ...overrides, + }) as FileUpload const renderChatInput = (fileConfig: FileUpload, readonly = false) => { return render( @@ -65,7 +66,10 @@ describe('Base File Uploader Flow', () => { ) await user.click(screen.getByRole('button', { name: /fileUploader\.pasteFileLink/i })) - await user.type(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/i), 'https://example.com/guide.pdf') + await user.type( + screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/i), + 'https://example.com/guide.pdf', + ) await user.click(screen.getByRole('button', { name: /operation\.ok/i })) await waitFor(() => { @@ -96,7 +100,9 @@ describe('Base File Uploader Flow', () => { expect(activeTrigger).toBeEnabled() await user.click(activeTrigger) - expect(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/i)).toBeInTheDocument() + expect( + screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/i), + ).toBeInTheDocument() expect(screen.queryByText(/fileUploader\.uploadFromComputer/i)).not.toBeInTheDocument() unmount() diff --git a/web/__tests__/base/notion-page-selector-flow.test.tsx b/web/__tests__/base/notion-page-selector-flow.test.tsx index 3943507d7e798d..e55956311bdb32 100644 --- a/web/__tests__/base/notion-page-selector-flow.test.tsx +++ b/web/__tests__/base/notion-page-selector-flow.test.tsx @@ -13,17 +13,19 @@ const mockUsePreImportNotionPages = vi.fn() vi.mock('@tanstack/react-virtual', () => ({ useVirtualizer: ({ count }: { count: number }) => ({ - getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ - index, - size: 28, - start: index * 28, - })), + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + size: 28, + start: index * 28, + })), getTotalSize: () => count * 28 + 16, }), })) vi.mock('@/service/knowledge/use-import', () => ({ - usePreImportNotionPages: (params: { datasetId: string, credentialId: string }) => mockUsePreImportNotionPages(params), + usePreImportNotionPages: (params: { datasetId: string; credentialId: string }) => + mockUsePreImportNotionPages(params), useInvalidPreImportNotionPages: () => mockInvalidPreImportNotionPages, })) @@ -31,11 +33,18 @@ vi.mock('@/context/modal-context', () => ({ useModalContext: () => ({ setShowAccountSettingModal: mockSetShowAccountSettingModal, }), - useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown) => - selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), + useModalContextSelector: ( + selector: (state: { + setShowAccountSettingModal: typeof mockSetShowAccountSettingModal + }) => unknown, + ) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), })) -const buildCredential = (id: string, name: string, workspaceName: string): DataSourceCredential => ({ +const buildCredential = ( + id: string, + name: string, + workspaceName: string, +): DataSourceCredential => ({ id, name, type: CredentialTypeEnum.OAUTH2, @@ -59,9 +68,30 @@ const workspacePagesByCredential: Record<string, DataSourceNotionWorkspace[]> = workspace_icon: '', workspace_name: 'Workspace 1', pages: [ - { page_id: 'root-1', page_name: 'Root 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false }, - { page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1', page_icon: null, type: 'page', is_bound: false }, - { page_id: 'bound-1', page_name: 'Bound 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: true }, + { + page_id: 'root-1', + page_name: 'Root 1', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, + { + page_id: 'child-1', + page_name: 'Child 1', + parent_id: 'root-1', + page_icon: null, + type: 'page', + is_bound: false, + }, + { + page_id: 'bound-1', + page_name: 'Bound 1', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: true, + }, ], }, ], @@ -71,7 +101,14 @@ const workspacePagesByCredential: Record<string, DataSourceNotionWorkspace[]> = workspace_icon: '', workspace_name: 'Workspace 2', pages: [ - { page_id: 'external-1', page_name: 'External 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false }, + { + page_id: 'external-1', + page_name: 'External 1', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, ], }, ], @@ -80,13 +117,15 @@ const workspacePagesByCredential: Record<string, DataSourceNotionWorkspace[]> = describe('Base Notion Page Selector Flow', () => { beforeEach(() => { vi.clearAllMocks() - mockUsePreImportNotionPages.mockImplementation(({ credentialId }: { credentialId: string }) => ({ - data: { - notion_info: workspacePagesByCredential[credentialId] ?? workspacePagesByCredential.c1, - }, - isFetching: false, - isError: false, - })) + mockUsePreImportNotionPages.mockImplementation( + ({ credentialId }: { credentialId: string }) => ({ + data: { + notion_info: workspacePagesByCredential[credentialId] ?? workspacePagesByCredential.c1, + }, + isFetching: false, + isError: false, + }), + ) }) it('selects a page tree, filters through search, clears search, and previews the current page', async () => { @@ -105,11 +144,13 @@ describe('Base Notion Page Selector Flow', () => { await user.click(screen.getByRole('checkbox', { name: 'Root 1' })) - expect(onSelect).toHaveBeenLastCalledWith(expect.arrayContaining([ - expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }), - expect.objectContaining({ page_id: 'child-1', workspace_id: 'w1' }), - expect.objectContaining({ page_id: 'bound-1', workspace_id: 'w1' }), - ])) + expect(onSelect).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }), + expect.objectContaining({ page_id: 'child-1', workspace_id: 'w1' }), + expect.objectContaining({ page_id: 'bound-1', workspace_id: 'w1' }), + ]), + ) await user.type(screen.getByTestId('notion-search-input'), 'missing-page') expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() @@ -118,7 +159,9 @@ describe('Base Notion Page Selector Flow', () => { expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument() await user.click(screen.getByTestId('notion-page-preview-root-1')) - expect(onPreview).toHaveBeenCalledWith(expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' })) + expect(onPreview).toHaveBeenCalledWith( + expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }), + ) }) it('switches workspace credentials and opens the configuration entry point', async () => { @@ -140,7 +183,10 @@ describe('Base Notion Page Selector Flow', () => { await user.click(screen.getByRole('combobox', { name: /Workspace 1/ })) await user.click(screen.getByTestId('notion-credential-item-c2')) - expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-1', credentialId: 'c2' }) + expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ + datasetId: 'dataset-1', + credentialId: 'c2', + }) expect(onSelect).toHaveBeenCalledWith([]) await waitFor(() => { @@ -148,7 +194,11 @@ describe('Base Notion Page Selector Flow', () => { expect(screen.getByTestId('notion-page-name-external-1')).toBeInTheDocument() }) - await user.click(screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' })) - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) + await user.click( + screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' }), + ) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, + }) }) }) diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index 35e89e2c3da306..7c889cb5dad41e 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -54,7 +54,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -133,7 +134,10 @@ const createPlanData = (overrides: PlanOverrides = {}) => ({ reset: { ...defaultPlan.reset, ...overrides.reset }, }) -const setupProviderContext = (planOverrides: PlanOverrides = {}, extra: Record<string, unknown> = {}) => { +const setupProviderContext = ( + planOverrides: PlanOverrides = {}, + extra: Record<string, unknown> = {}, +) => { mockProviderCtx = { plan: createPlanData(planOverrides), enableBilling: true, @@ -148,11 +152,7 @@ const setupProviderContext = (planOverrides: PlanOverrides = {}, extra: Record<s const setupAppContext = (overrides: Record<string, unknown> = {}) => { mockAppCtx = { isCurrentWorkspaceManager: true, - workspacePermissionKeys: [ - 'billing.view', - 'billing.manage', - 'billing.subscription.manage', - ], + workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'], userProfile: { email: 'test@example.com' }, langGeniusVersionInfo: { current_version: '1.0.0' }, ...overrides, @@ -363,10 +363,13 @@ describe('Plan Type Display Integration', () => { }) it('should show education verify button when enableEducationPlan is true and not yet verified', () => { - setupProviderContext({ type: Plan.sandbox }, { - enableEducationPlan: true, - isEducationAccount: false, - }) + setupProviderContext( + { type: Plan.sandbox }, + { + enableEducationPlan: true, + isEducationAccount: false, + }, + ) render(<PlanComp loc="test" />) @@ -495,14 +498,7 @@ describe('Upgrade Flow Integration', () => { const user = userEvent.setup() const onClose = vi.fn() - render( - <PlanUpgradeModal - show={true} - onClose={onClose} - title="Test" - description="Test" - />, - ) + render(<PlanUpgradeModal show={true} onClose={onClose} title="Test" description="Test" />) const dismissBtn = screen.getByText(/triggerLimitModal\.dismiss/i) await user.click(dismissBtn) @@ -838,7 +834,9 @@ describe('Usage Display Edge Cases', () => { const { container } = render(<PlanComp loc="test" />) // 20% usage — at least one Meter indicator should carry the neutral tone - expect(container.querySelector('.bg-components-progress-bar-progress-solid')).toBeInTheDocument() + expect( + container.querySelector('.bg-components-progress-bar-progress-solid'), + ).toBeInTheDocument() }) }) diff --git a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx index f8aacfc7084dfe..066a505e854674 100644 --- a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx +++ b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx @@ -47,7 +47,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -81,11 +82,7 @@ vi.mock('@/next/navigation', () => ({ const setupAppContext = (overrides: Record<string, unknown> = {}) => { mockAppCtx = { isCurrentWorkspaceManager: true, - workspacePermissionKeys: [ - 'billing.view', - 'billing.manage', - 'billing.subscription.manage', - ], + workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'], ...overrides, } } @@ -106,12 +103,7 @@ const renderCloudPlanItem = ({ return render( <> <ToastHost timeout={0} /> - <CloudPlanItem - currentPlan={currentPlan} - plan={plan} - planRange={planRange} - canPay={canPay} - /> + <CloudPlanItem currentPlan={currentPlan} plan={plan} planRange={planRange} canPay={canPay} /> </>, ) } diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx index 67a1ff1a534af0..c24bb7ab5c483a 100644 --- a/web/__tests__/billing/education-verification-flow.test.tsx +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -59,7 +59,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -109,23 +110,27 @@ vi.mock('@/app/education-apply/storage', () => ({ // ─── External component mocks ─────────────────────────────────────────────── vi.mock('@/app/education-apply/verify-state-modal', () => ({ - default: ({ isShow, title, content, email, showLink }: { + default: ({ + isShow, + title, + content, + email, + showLink, + }: { isShow: boolean title?: string content?: string email?: string showLink?: boolean }) => - isShow - ? ( - <div data-testid="verify-state-modal"> - {title && <span data-testid="modal-title">{title}</span>} - {content && <span data-testid="modal-content">{content}</span>} - {email && <span data-testid="modal-email">{email}</span>} - {showLink && <span data-testid="modal-show-link">link</span>} - </div> - ) - : null, + isShow ? ( + <div data-testid="verify-state-modal"> + {title && <span data-testid="modal-title">{title}</span>} + {content && <span data-testid="modal-content">{content}</span>} + {email && <span data-testid="modal-email">{email}</span>} + {showLink && <span data-testid="modal-show-link">link</span>} + </div> + ) : null, })) // ─── Test data factories ──────────────────────────────────────────────────── @@ -161,11 +166,7 @@ const setupContexts = ( } mockAppCtx = { isCurrentWorkspaceManager: true, - workspacePermissionKeys: [ - 'billing.view', - 'billing.manage', - 'billing.subscription.manage', - ], + workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'], userProfile: { email: 'student@university.edu' }, langGeniusVersionInfo: { current_version: '1.0.0' }, ...appOverrides, @@ -199,11 +200,14 @@ describe('Education Verification Flow', () => { }) it('should not show verify button when already verified and not about to expire', () => { - setupContexts({}, { - enableEducationPlan: true, - isEducationAccount: true, - allowRefreshEducationVerify: false, - }) + setupContexts( + {}, + { + enableEducationPlan: true, + isEducationAccount: true, + allowRefreshEducationVerify: false, + }, + ) render(<PlanComp loc="test" />) @@ -211,11 +215,14 @@ describe('Education Verification Flow', () => { }) it('should show verify button when about to expire (allowRefreshEducationVerify is true)', () => { - setupContexts({}, { - enableEducationPlan: true, - isEducationAccount: true, - allowRefreshEducationVerify: true, - }) + setupContexts( + {}, + { + enableEducationPlan: true, + isEducationAccount: true, + allowRefreshEducationVerify: true, + }, + ) render(<PlanComp loc="test" />) @@ -343,10 +350,7 @@ describe('Education Verification Flow', () => { }) it('should show team plan with plain upgrade button and education button', () => { - setupContexts( - { type: Plan.team }, - { enableEducationPlan: true, isEducationAccount: false }, - ) + setupContexts({ type: Plan.team }, { enableEducationPlan: true, isEducationAccount: false }) render(<PlanComp loc="test" />) diff --git a/web/__tests__/billing/partner-stack-flow.test.tsx b/web/__tests__/billing/partner-stack-flow.test.tsx index fe642ac70bef80..95e2a5748a28de 100644 --- a/web/__tests__/billing/partner-stack-flow.test.tsx +++ b/web/__tests__/billing/partner-stack-flow.test.tsx @@ -50,12 +50,10 @@ vi.mock('@/config', async (importOriginal) => { // ─── Cookie helpers ────────────────────────────────────────────────────────── const getCookieData = () => { const raw = Cookies.get(PARTNER_STACK_CONFIG.cookieName) - if (!raw) - return null + if (!raw) return null try { return JSON.parse(raw) - } - catch { + } catch { return null } } diff --git a/web/__tests__/billing/pricing-modal-flow.test.tsx b/web/__tests__/billing/pricing-modal-flow.test.tsx index dab3e83ef7db14..08c2f85df06e3d 100644 --- a/web/__tests__/billing/pricing-modal-flow.test.tsx +++ b/web/__tests__/billing/pricing-modal-flow.test.tsx @@ -46,7 +46,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -125,7 +126,10 @@ const defaultPlanData = { }, } -const setupContexts = (planOverrides: Record<string, unknown> = {}, appOverrides: Record<string, unknown> = {}) => { +const setupContexts = ( + planOverrides: Record<string, unknown> = {}, + appOverrides: Record<string, unknown> = {}, +) => { mockProviderCtx = { plan: { ...defaultPlanData, ...planOverrides }, enableBilling: true, @@ -136,11 +140,7 @@ const setupContexts = (planOverrides: Record<string, unknown> = {}, appOverrides } mockAppCtx = { isCurrentWorkspaceManager: true, - workspacePermissionKeys: [ - 'billing.view', - 'billing.manage', - 'billing.subscription.manage', - ], + workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'], userProfile: { email: 'test@example.com' }, langGeniusVersionInfo: { current_version: '1.0.0' }, ...appOverrides, diff --git a/web/__tests__/billing/self-hosted-plan-flow.test.tsx b/web/__tests__/billing/self-hosted-plan-flow.test.tsx index 92d7975990a3c8..1051be5b9597a3 100644 --- a/web/__tests__/billing/self-hosted-plan-flow.test.tsx +++ b/web/__tests__/billing/self-hosted-plan-flow.test.tsx @@ -11,7 +11,11 @@ import { toast, ToastHost } from '@langgenius/dify-ui/toast' import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '@/app/components/billing/config' +import { + contactSalesUrl, + getStartedWithCommunityUrl, + getWithPremiumUrl, +} from '@/app/components/billing/config' import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-hosted-plan-item' import { SelfHostedPlan } from '@/app/components/billing/type' @@ -42,7 +46,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -93,8 +98,12 @@ describe('Self-Hosted Plan Flow', () => { Object.defineProperty(window, 'location', { configurable: true, value: { - get href() { return assignedHref }, - set href(value: string) { assignedHref = value }, + get href() { + return assignedHref + }, + set href(value: string) { + assignedHref = value + }, }, }) }) diff --git a/web/__tests__/custom/custom-page-flow.test.tsx b/web/__tests__/custom/custom-page-flow.test.tsx index c4af4166dfc613..78b4e0f27976a6 100644 --- a/web/__tests__/custom/custom-page-flow.test.tsx +++ b/web/__tests__/custom/custom-page-flow.test.tsx @@ -35,7 +35,9 @@ const { useProviderContext } = await import('@/context/provider-context') const mockUseProviderContext = vi.mocked(useProviderContext) const mockUseWebAppBrand = vi.mocked(useWebAppBrand) -const createBrandState = (overrides: Partial<ReturnType<typeof useWebAppBrand>> = {}): ReturnType<typeof useWebAppBrand> => ({ +const createBrandState = ( + overrides: Partial<ReturnType<typeof useWebAppBrand>> = {}, +): ReturnType<typeof useWebAppBrand> => ({ fileId: '', imgKey: 1, uploadProgress: 0, @@ -55,13 +57,15 @@ const createBrandState = (overrides: Partial<ReturnType<typeof useWebAppBrand>> }) const setProviderPlan = (planType: Plan, enableBilling = true) => { - mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ - enableBilling, - plan: { - ...defaultPlan, - type: planType, - }, - })) + mockUseProviderContext.mockReturnValue( + createMockProviderContextValue({ + enableBilling, + plan: { + ...defaultPlan, + type: planType, + }, + }), + ) } describe('Custom Page Flow', () => { diff --git a/web/__tests__/datasets/create-dataset-flow.test.tsx b/web/__tests__/datasets/create-dataset-flow.test.tsx index c8504a10c738ca..94cce5dda66608 100644 --- a/web/__tests__/datasets/create-dataset-flow.test.tsx +++ b/web/__tests__/datasets/create-dataset-flow.test.tsx @@ -19,11 +19,11 @@ vi.mock('@/service/knowledge/use-create-dataset', () => ({ useCreateDocument: () => ({ mutateAsync: mockCreateDocument, isPending: false }), getNotionInfo: (pages: { page_id: string }[], credentialId: string) => ({ workspace_id: 'ws-1', - pages: pages.map(p => p.page_id), + pages: pages.map((p) => p.page_id), notion_credential_id: credentialId, }), - getWebsiteInfo: (opts: { websitePages: { url: string }[], websiteCrawlProvider: string }) => ({ - urls: opts.websitePages.map(p => p.url), + getWebsiteInfo: (opts: { websitePages: { url: string }[]; websiteCrawlProvider: string }) => ({ + urls: opts.websitePages.map((p) => p.url), only_main_content: true, provider: opts.websiteCrawlProvider, }), @@ -48,22 +48,27 @@ vi.mock('@/app/components/base/amplitude', () => ({ })) // Import hooks after mocks -const { useSegmentationState, DEFAULT_SEGMENT_IDENTIFIER, DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP } - = await import('@/app/components/datasets/create/step-two/hooks') -const { useDocumentCreation, IndexingType } - = await import('@/app/components/datasets/create/step-two/hooks') +const { + useSegmentationState, + DEFAULT_SEGMENT_IDENTIFIER, + DEFAULT_MAXIMUM_CHUNK_LENGTH, + DEFAULT_OVERLAP, +} = await import('@/app/components/datasets/create/step-two/hooks') +const { useDocumentCreation, IndexingType } = + await import('@/app/components/datasets/create/step-two/hooks') -const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => ({ - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 1024, - extension: '.txt', - mime_type: 'text/plain', - created_at: 0, - created_by: '', - ...overrides, -} as CustomFile) +const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => + ({ + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 1024, + extension: '.txt', + mime_type: 'text/plain', + created_at: 0, + created_by: '', + ...overrides, + }) as CustomFile describe('Create Dataset Flow - Cross-Step Data Contract', () => { beforeEach(() => { diff --git a/web/__tests__/datasets/dataset-settings-flow.test.tsx b/web/__tests__/datasets/dataset-settings-flow.test.tsx index e33f9bad895f67..b2a711985a2308 100644 --- a/web/__tests__/datasets/dataset-settings-flow.test.tsx +++ b/web/__tests__/datasets/dataset-settings-flow.test.tsx @@ -14,7 +14,12 @@ import type { DataSet } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' import { act, renderHook, waitFor } from '@testing-library/react' import { IndexingType } from '@/app/components/datasets/create/step-two' -import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets' +import { + ChunkingMode, + DatasetPermission, + DataSourceType, + WeightedScoreEnum, +} from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import { DatasetACLPermission } from '@/utils/permission' @@ -40,9 +45,39 @@ vi.mock('@/service/use-common', () => ({ useMembers: () => ({ data: { accounts: [ - { id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, - { id: 'user-2', name: 'Bob', email: 'bob@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, - { id: 'user-3', name: 'Charlie', email: 'charlie@example.com', role: 'normal', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + role: 'owner', + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, + { + id: 'user-2', + name: 'Bob', + email: 'bob@example.com', + role: 'admin', + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, + { + id: 'user-3', + name: 'Charlie', + email: 'charlie@example.com', + role: 'normal', + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, ], }, }), @@ -65,82 +100,82 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ // --- Dataset factory --- -const createMockDataset = (overrides?: Partial<DataSet>): DataSet => ({ - id: 'ds-settings-1', - name: 'Settings Test Dataset', - description: 'Integration test dataset', - permission: DatasetPermission.onlyMe, - icon_info: { - icon_type: 'emoji', - icon: '📙', - icon_background: '#FFF4ED', - icon_url: '', - }, - indexing_technique: 'high_quality', - indexing_status: 'completed', - data_source_type: DataSourceType.FILE, - doc_form: ChunkingMode.text, - embedding_model: 'text-embedding-ada-002', - embedding_model_provider: 'openai', - embedding_available: true, - app_count: 2, - document_count: 10, - total_document_count: 10, - word_count: 5000, - provider: 'vendor', - tags: [], - partial_member_list: [], - external_knowledge_info: { - external_knowledge_id: '', - external_knowledge_api_id: '', - external_knowledge_api_name: '', - external_knowledge_api_endpoint: '', - }, - external_retrieval_model: { - top_k: 2, - score_threshold: 0.5, - score_threshold_enabled: false, - }, - retrieval_model_dict: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0, - } as RetrievalConfig, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0, - } as RetrievalConfig, - built_in_field_enabled: false, - keyword_number: 10, - created_by: 'user-1', - updated_by: 'user-1', - updated_at: Date.now(), - runtime_mode: 'general', - enable_api: true, - is_multimodal: false, - permission_keys: [DatasetACLPermission.Edit], - ...overrides, -} as DataSet) +const createMockDataset = (overrides?: Partial<DataSet>): DataSet => + ({ + id: 'ds-settings-1', + name: 'Settings Test Dataset', + description: 'Integration test dataset', + permission: DatasetPermission.onlyMe, + icon_info: { + icon_type: 'emoji', + icon: '📙', + icon_background: '#FFF4ED', + icon_url: '', + }, + indexing_technique: 'high_quality', + indexing_status: 'completed', + data_source_type: DataSourceType.FILE, + doc_form: ChunkingMode.text, + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + embedding_available: true, + app_count: 2, + document_count: 10, + total_document_count: 10, + word_count: 5000, + provider: 'vendor', + tags: [], + partial_member_list: [], + external_knowledge_info: { + external_knowledge_id: '', + external_knowledge_api_id: '', + external_knowledge_api_name: '', + external_knowledge_api_endpoint: '', + }, + external_retrieval_model: { + top_k: 2, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + } as RetrievalConfig, + retrieval_model: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + } as RetrievalConfig, + built_in_field_enabled: false, + keyword_number: 10, + created_by: 'user-1', + updated_by: 'user-1', + updated_at: Date.now(), + runtime_mode: 'general', + enable_api: true, + is_multimodal: false, + permission_keys: [DatasetACLPermission.Edit], + ...overrides, + }) as DataSet let mockDataset: DataSet = createMockDataset() vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: ( - selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown, + selector: (state: { dataset: DataSet | null; mutateDatasetRes: () => void }) => unknown, ) => selector({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasets }), })) // Import after mocks are registered -const { useFormState } = await import( - '@/app/components/datasets/settings/form/hooks/use-form-state', -) +const { useFormState } = + await import('@/app/components/datasets/settings/form/hooks/use-form-state') describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => { beforeEach(() => { @@ -253,7 +288,7 @@ describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => { expect(result.current.permission).toBe(DatasetPermission.partialMembers) expect(result.current.memberList).toHaveLength(3) - expect(result.current.memberList.map(m => m.id)).toEqual(['user-1', 'user-2', 'user-3']) + expect(result.current.memberList.map((m) => m.id)).toEqual(['user-1', 'user-2', 'user-3']) }) it('should persist member selection through permission toggle', () => { @@ -291,9 +326,7 @@ describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => { datasetId: 'ds-settings-1', body: expect.objectContaining({ permission: DatasetPermission.partialMembers, - partial_member_list: [ - expect.objectContaining({ user_id: 'user-2', role: 'admin' }), - ], + partial_member_list: [expect.objectContaining({ user_id: 'user-2', role: 'admin' })], }), }) }) @@ -386,7 +419,9 @@ describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => { provider: 'cohere', model: 'embed-english-v3.0', }) - expect(result.current.retrievalConfig.search_method).toBe(originalRetrievalConfig.search_method) + expect(result.current.retrievalConfig.search_method).toBe( + originalRetrievalConfig.search_method, + ) }) it('should propagate embedding model into weighted retrieval config on save', async () => { diff --git a/web/__tests__/datasets/document-management.test.tsx b/web/__tests__/datasets/document-management.test.tsx index 8ce661ff4f3187..655607141e3ede 100644 --- a/web/__tests__/datasets/document-management.test.tsx +++ b/web/__tests__/datasets/document-management.test.tsx @@ -19,19 +19,15 @@ vi.mock('@/next/navigation', () => ({ usePathname: () => '/datasets/ds-1/documents', })) -const { sanitizeStatusValue, normalizeStatusForQuery } = await import( - '@/app/components/datasets/documents/status-filter', -) - -const { useDocumentSort } = await import( - '@/app/components/datasets/documents/components/document-list/hooks/use-document-sort', -) -const { useDocumentSelection } = await import( - '@/app/components/datasets/documents/components/document-list/hooks/use-document-selection', -) -const { useDocumentListQueryState } = await import( - '@/app/components/datasets/documents/hooks/use-document-list-query-state', -) +const { sanitizeStatusValue, normalizeStatusForQuery } = + await import('@/app/components/datasets/documents/status-filter') + +const { useDocumentSort } = + await import('@/app/components/datasets/documents/components/document-list/hooks/use-document-sort') +const { useDocumentSelection } = + await import('@/app/components/datasets/documents/components/document-list/hooks/use-document-selection') +const { useDocumentListQueryState } = + await import('@/app/components/datasets/documents/hooks/use-document-list-query-state') type LocalDoc = SimpleDocumentDetail & { percent?: number } @@ -39,23 +35,24 @@ const renderQueryStateHook = (searchParams = '') => { return renderHookWithNuqs(() => useDocumentListQueryState(), { searchParams }) } -const createDoc = (overrides?: Partial<LocalDoc>): LocalDoc => ({ - id: `doc-${Math.random().toString(36).slice(2, 8)}`, - name: 'test-doc.txt', - word_count: 500, - hit_count: 10, - created_at: Date.now() / 1000, - data_source_type: DataSourceType.FILE, - display_status: 'available', - indexing_status: 'completed', - enabled: true, - archived: false, - doc_type: null, - doc_metadata: null, - position: 1, - dataset_process_rule_id: 'rule-1', - ...overrides, -} as LocalDoc) +const createDoc = (overrides?: Partial<LocalDoc>): LocalDoc => + ({ + id: `doc-${Math.random().toString(36).slice(2, 8)}`, + name: 'test-doc.txt', + word_count: 500, + hit_count: 10, + created_at: Date.now() / 1000, + data_source_type: DataSourceType.FILE, + display_status: 'available', + indexing_status: 'completed', + enabled: true, + archived: false, + doc_type: null, + doc_metadata: null, + position: 1, + dataset_process_rule_id: 'rule-1', + ...overrides, + }) as LocalDoc describe('Document Management Flow', () => { beforeEach(() => { @@ -131,10 +128,12 @@ describe('Document Management Flow', () => { describe('Document Sort Integration', () => { it('should derive sort field and order from remote sort value', () => { - const { result } = renderHook(() => useDocumentSort({ - remoteSortValue: '-created_at', - onRemoteSortChange: vi.fn(), - })) + const { result } = renderHook(() => + useDocumentSort({ + remoteSortValue: '-created_at', + onRemoteSortChange: vi.fn(), + }), + ) expect(result.current.sortField).toBe('created_at') expect(result.current.sortOrder).toBe('desc') @@ -142,10 +141,12 @@ describe('Document Management Flow', () => { it('should call remote sort change with descending sort for a new field', () => { const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => useDocumentSort({ - remoteSortValue: '-created_at', - onRemoteSortChange, - })) + const { result } = renderHook(() => + useDocumentSort({ + remoteSortValue: '-created_at', + onRemoteSortChange, + }), + ) act(() => { result.current.handleSort('hit_count') @@ -156,10 +157,12 @@ describe('Document Management Flow', () => { it('should toggle descending to ascending when clicking active field', () => { const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => useDocumentSort({ - remoteSortValue: '-hit_count', - onRemoteSortChange, - })) + const { result } = renderHook(() => + useDocumentSort({ + remoteSortValue: '-hit_count', + onRemoteSortChange, + }), + ) act(() => { result.current.handleSort('hit_count') @@ -170,10 +173,12 @@ describe('Document Management Flow', () => { it('should ignore null sort field updates', () => { const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => useDocumentSort({ - remoteSortValue: '-created_at', - onRemoteSortChange, - })) + const { result } = renderHook(() => + useDocumentSort({ + remoteSortValue: '-created_at', + onRemoteSortChange, + }), + ) act(() => { result.current.handleSort(null) @@ -192,11 +197,13 @@ describe('Document Management Flow', () => { ] const onSelectedIdChange = vi.fn() - const { result } = renderHook(() => useDocumentSelection({ - documents: docs, - selectedIds: [], - onSelectedIdChange, - })) + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: [], + onSelectedIdChange, + }), + ) expect(result.current.downloadableSelectedIds).toEqual([]) expect(result.current.hasErrorDocumentsSelected).toBe(false) @@ -209,11 +216,13 @@ describe('Document Management Flow', () => { createDoc({ id: 'doc-2', data_source_type: DataSourceType.NOTION }), ] - const { result } = renderHook(() => useDocumentSelection({ - documents: docs, - selectedIds: ['doc-1', 'doc-2'], - onSelectedIdChange: vi.fn(), - })) + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1', 'doc-2'], + onSelectedIdChange: vi.fn(), + }), + ) expect(result.current.downloadableSelectedIds).toEqual(['doc-1']) }) @@ -222,11 +231,13 @@ describe('Document Management Flow', () => { const onSelectedIdChange = vi.fn() const docs = [createDoc({ id: 'doc-1' })] - const { result } = renderHook(() => useDocumentSelection({ - documents: docs, - selectedIds: ['doc-1'], - onSelectedIdChange, - })) + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1'], + onSelectedIdChange, + }), + ) act(() => { result.current.clearSelection() @@ -240,15 +251,19 @@ describe('Document Management Flow', () => { it('should maintain consistent default state across all hooks', () => { const docs = [createDoc({ id: 'doc-1' })] const { result: queryResult } = renderQueryStateHook() - const { result: sortResult } = renderHook(() => useDocumentSort({ - remoteSortValue: queryResult.current.query.sort, - onRemoteSortChange: vi.fn(), - })) - const { result: selResult } = renderHook(() => useDocumentSelection({ - documents: docs, - selectedIds: [], - onSelectedIdChange: vi.fn(), - })) + const { result: sortResult } = renderHook(() => + useDocumentSort({ + remoteSortValue: queryResult.current.query.sort, + onRemoteSortChange: vi.fn(), + }), + ) + const { result: selResult } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: [], + onSelectedIdChange: vi.fn(), + }), + ) // Query defaults expect(queryResult.current.query.sort).toBe('-created_at') diff --git a/web/__tests__/datasets/external-knowledge-base.test.tsx b/web/__tests__/datasets/external-knowledge-base.test.tsx index 9c2b0da19de508..03bd9b6ee70372 100644 --- a/web/__tests__/datasets/external-knowledge-base.test.tsx +++ b/web/__tests__/datasets/external-knowledge-base.test.tsx @@ -65,11 +65,11 @@ describe('External Knowledge Base Creation Flow', () => { describe('Form Validation Logic', () => { const isFormValid = (form: CreateKnowledgeBaseReq): boolean => { return ( - form.name.trim() !== '' - && form.external_knowledge_api_id !== '' - && form.external_knowledge_id !== '' - && form.external_retrieval_model.top_k !== undefined - && form.external_retrieval_model.score_threshold !== undefined + form.name.trim() !== '' && + form.external_knowledge_api_id !== '' && + form.external_knowledge_id !== '' && + form.external_retrieval_model.top_k !== undefined && + form.external_retrieval_model.score_threshold !== undefined ) } diff --git a/web/__tests__/datasets/hit-testing-flow.test.tsx b/web/__tests__/datasets/hit-testing-flow.test.tsx index 54f85c5200f98b..d2439304c6da64 100644 --- a/web/__tests__/datasets/hit-testing-flow.test.tsx +++ b/web/__tests__/datasets/hit-testing-flow.test.tsx @@ -7,10 +7,7 @@ * and invokes callbacks on success/failure. */ -import type { - HitTestingResponse, - Query, -} from '@/models/datasets' +import type { HitTestingResponse, Query } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import QueryInput from '@/app/components/datasets/hit-testing/components/query-input' @@ -30,31 +27,41 @@ vi.mock('use-context-selector', () => ({ createContext: vi.fn(() => ({})), })) -vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ - default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => ( - <div data-testid="image-uploader-mock"> - {textArea} - {actionButton} - </div> - ), -})) +vi.mock( + '@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', + () => ({ + default: ({ + textArea, + actionButton, + }: { + textArea: React.ReactNode + actionButton: React.ReactNode + }) => ( + <div data-testid="image-uploader-mock"> + {textArea} + {actionButton} + </div> + ), + }), +) // --- Factories --- -const createRetrievalConfig = (overrides = {}): RetrievalConfig => ({ - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_mode: undefined, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - weights: undefined, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - ...overrides, -} as RetrievalConfig) +const createRetrievalConfig = (overrides = {}): RetrievalConfig => + ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + weights: undefined, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + ...overrides, + }) as RetrievalConfig const createHitTestingResponse = (numResults: number): HitTestingResponse => ({ query: { @@ -113,7 +120,7 @@ const createTextQuery = (content: string): Query[] => [ const findSubmitButton = () => { const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + const submitButton = buttons.find((btn) => btn.classList.contains('w-[88px]')) expect(submitButton).toBeTruthy() return submitButton! } @@ -161,10 +168,11 @@ describe('Hit Testing Flow', () => { mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(3)) render( - <QueryInput {...createDefaultProps({ - queries: createTextQuery('How does RAG work?'), - retrievalConfig, - })} + <QueryInput + {...createDefaultProps({ + queries: createTextQuery('How does RAG work?'), + retrievalConfig, + })} />, ) @@ -193,11 +201,12 @@ describe('Hit Testing Flow', () => { mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(1)) render( - <QueryInput {...createDefaultProps({ - queries: createTextQuery('test query'), - retrievalConfig, - isEconomy: true, - })} + <QueryInput + {...createDefaultProps({ + queries: createTextQuery('test query'), + retrievalConfig, + isEconomy: true, + })} />, ) @@ -217,24 +226,25 @@ describe('Hit Testing Flow', () => { it('should handle empty results by calling setHitResult with empty records', async () => { const emptyResponse = createHitTestingResponse(0) - mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { - options?.onSuccess?.(emptyResponse) - return emptyResponse - }) + mockHitTestingMutation.mockImplementation( + async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { + options?.onSuccess?.(emptyResponse) + return emptyResponse + }, + ) render( - <QueryInput {...createDefaultProps({ - queries: createTextQuery('nonexistent topic'), - })} + <QueryInput + {...createDefaultProps({ + queries: createTextQuery('nonexistent topic'), + })} />, ) fireEvent.click(findSubmitButton()) await waitFor(() => { - expect(mockSetHitResult).toHaveBeenCalledWith( - expect.objectContaining({ records: [] }), - ) + expect(mockSetHitResult).toHaveBeenCalledWith(expect.objectContaining({ records: [] })) }) }) @@ -243,9 +253,10 @@ describe('Hit Testing Flow', () => { mockHitTestingMutation.mockResolvedValue(undefined) render( - <QueryInput {...createDefaultProps({ - queries: createTextQuery('test'), - })} + <QueryInput + {...createDefaultProps({ + queries: createTextQuery('test'), + })} />, ) @@ -298,15 +309,18 @@ describe('Hit Testing Flow', () => { describe('Successful Submission → Callback Chain', () => { it('should call setHitResult, onUpdateList, and onSubmit after successful submission', async () => { const response = createHitTestingResponse(3) - mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { - options?.onSuccess?.(response) - return response - }) + mockHitTestingMutation.mockImplementation( + async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { + options?.onSuccess?.(response) + return response + }, + ) render( - <QueryInput {...createDefaultProps({ - queries: createTextQuery('Test query'), - })} + <QueryInput + {...createDefaultProps({ + queries: createTextQuery('Test query'), + })} />, ) @@ -321,15 +335,18 @@ describe('Hit Testing Flow', () => { it('should trigger records list refresh via onUpdateList after query', async () => { const response = createHitTestingResponse(1) - mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { - options?.onSuccess?.(response) - return response - }) + mockHitTestingMutation.mockImplementation( + async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { + options?.onSuccess?.(response) + return response + }, + ) render( - <QueryInput {...createDefaultProps({ - queries: createTextQuery('new query'), - })} + <QueryInput + {...createDefaultProps({ + queries: createTextQuery('new query'), + })} />, ) @@ -343,17 +360,23 @@ describe('Hit Testing Flow', () => { describe('External KB Hit Testing', () => { it('should use external mutation with correct payload for external datasets', async () => { - mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => { - const response = { records: [] } - options?.onSuccess?.(response) - return response - }) + mockExternalMutation.mockImplementation( + async ( + _params: unknown, + options?: { onSuccess?: (data: { records: never[] }) => void }, + ) => { + const response = { records: [] } + options?.onSuccess?.(response) + return response + }, + ) render( - <QueryInput {...createDefaultProps({ - queries: createTextQuery('test'), - isExternal: true, - })} + <QueryInput + {...createDefaultProps({ + queries: createTextQuery('test'), + isExternal: true, + })} />, ) @@ -380,16 +403,22 @@ describe('Hit Testing Flow', () => { it('should call setExternalHitResult and onUpdateList on successful external submission', async () => { const externalResponse = { records: [] } - mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => { - options?.onSuccess?.(externalResponse) - return externalResponse - }) + mockExternalMutation.mockImplementation( + async ( + _params: unknown, + options?: { onSuccess?: (data: { records: never[] }) => void }, + ) => { + options?.onSuccess?.(externalResponse) + return externalResponse + }, + ) render( - <QueryInput {...createDefaultProps({ - queries: createTextQuery('external query'), - isExternal: true, - })} + <QueryInput + {...createDefaultProps({ + queries: createTextQuery('external query'), + isExternal: true, + })} />, ) diff --git a/web/__tests__/datasets/metadata-management-flow.test.tsx b/web/__tests__/datasets/metadata-management-flow.test.tsx index 2f37b8c9cdd1fd..2490592e8994f0 100644 --- a/web/__tests__/datasets/metadata-management-flow.test.tsx +++ b/web/__tests__/datasets/metadata-management-flow.test.tsx @@ -16,9 +16,8 @@ import type { MetadataItemWithValueLength } from '@/app/components/datasets/meta import { renderHook } from '@testing-library/react' import { DataType } from '@/app/components/datasets/metadata/types' -const { default: useCheckMetadataName } = await import( - '@/app/components/datasets/metadata/hooks/use-check-metadata-name', -) +const { default: useCheckMetadataName } = + await import('@/app/components/datasets/metadata/hooks/use-check-metadata-name') // --- Factory functions --- @@ -95,9 +94,9 @@ describe('Metadata Management Flow - Cross-Module Validation Composition', () => it('should use consistent types in metadata items', () => { const metadataList = createMetadataList() - const stringItems = metadataList.filter(m => m.type === DataType.string) - const numberItems = metadataList.filter(m => m.type === DataType.number) - const timeItems = metadataList.filter(m => m.type === DataType.time) + const stringItems = metadataList.filter((m) => m.type === DataType.string) + const numberItems = metadataList.filter((m) => m.type === DataType.number) + const timeItems = metadataList.filter((m) => m.type === DataType.time) expect(stringItems).toHaveLength(2) expect(numberItems).toHaveLength(2) @@ -121,9 +120,8 @@ describe('Metadata Management Flow - Cross-Module Validation Composition', () => const checkDuplicate = (newName: string): boolean => { const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) - return false - return existingMetadata.some(m => m.name === newName) + if (formatCheck.errorMsg) return false + return existingMetadata.some((m) => m.name === newName) } expect(checkDuplicate('author')).toBe(true) @@ -137,9 +135,8 @@ describe('Metadata Management Flow - Cross-Module Validation Composition', () => const isNameAvailable = (newName: string): boolean => { const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) - return false - return !existingMetadata.some(m => m.name === newName) + if (formatCheck.errorMsg) return false + return !existingMetadata.some((m) => m.name === newName) } expect(isNameAvailable('category')).toBe(true) @@ -150,10 +147,9 @@ describe('Metadata Management Flow - Cross-Module Validation Composition', () => it('should reject names that fail format validation before duplicate check', () => { const { result } = renderHook(() => useCheckMetadataName()) - const validateAndCheckDuplicate = (newName: string): { valid: boolean, reason: string } => { + const validateAndCheckDuplicate = (newName: string): { valid: boolean; reason: string } => { const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) - return { valid: false, reason: 'format' } + if (formatCheck.errorMsg) return { valid: false, reason: 'format' } return { valid: true, reason: '' } } @@ -170,10 +166,9 @@ describe('Metadata Management Flow - Cross-Module Validation Composition', () => const isRenameValid = (itemId: string, newName: string): boolean => { const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) - return false + if (formatCheck.errorMsg) return false // Allow keeping the same name (skip self in duplicate check) - return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + return !existingMetadata.some((m) => m.name === newName && m.id !== itemId) } // Author keeping its own name should be valid @@ -188,9 +183,8 @@ describe('Metadata Management Flow - Cross-Module Validation Composition', () => const isRenameValid = (itemId: string, newName: string): boolean => { const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) - return false - return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + if (formatCheck.errorMsg) return false + return !existingMetadata.some((m) => m.name === newName && m.id !== itemId) } // Author trying to rename to "page_count" (taken by meta-3) @@ -205,9 +199,8 @@ describe('Metadata Management Flow - Cross-Module Validation Composition', () => const isRenameValid = (itemId: string, newName: string): boolean => { const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) - return false - return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + if (formatCheck.errorMsg) return false + return !existingMetadata.some((m) => m.name === newName && m.id !== itemId) } expect(isRenameValid('meta-1', 'document_author')).toBe(true) @@ -221,9 +214,8 @@ describe('Metadata Management Flow - Cross-Module Validation Composition', () => const isRenameValid = (itemId: string, newName: string): boolean => { const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) - return false - return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + if (formatCheck.errorMsg) return false + return !existingMetadata.some((m) => m.name === newName && m.id !== itemId) } expect(isRenameValid('meta-1', 'New Author')).toBe(false) @@ -240,12 +232,11 @@ describe('Metadata Management Flow - Cross-Module Validation Composition', () => const addMetadataField = ( name: string, type: DataType, - ): { success: boolean, error?: string } => { + ): { success: boolean; error?: string } => { const formatCheck = result.current.checkName(name) - if (formatCheck.errorMsg) - return { success: false, error: 'invalid_format' } + if (formatCheck.errorMsg) return { success: false, error: 'invalid_format' } - if (existingMetadata.some(m => m.name === name)) + if (existingMetadata.some((m) => m.name === name)) return { success: false, error: 'duplicate_name' } existingMetadata.push(createMetadataItem(`meta-${existingMetadata.length + 1}`, name, type)) @@ -282,17 +273,15 @@ describe('Metadata Management Flow - Cross-Module Validation Composition', () => const renameMetadataField = ( itemId: string, newName: string, - ): { success: boolean, error?: string } => { + ): { success: boolean; error?: string } => { const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) - return { success: false, error: 'invalid_format' } + if (formatCheck.errorMsg) return { success: false, error: 'invalid_format' } - if (existingMetadata.some(m => m.name === newName && m.id !== itemId)) + if (existingMetadata.some((m) => m.name === newName && m.id !== itemId)) return { success: false, error: 'duplicate_name' } - const item = existingMetadata.find(m => m.id === itemId) - if (!item) - return { success: false, error: 'not_found' } + const item = existingMetadata.find((m) => m.id === itemId) + if (!item) return { success: false, error: 'not_found' } // Simulate the rename in-place const index = existingMetadata.indexOf(item) @@ -302,7 +291,7 @@ describe('Metadata Management Flow - Cross-Module Validation Composition', () => // Rename author to document_author expect(renameMetadataField('meta-1', 'document_author').success).toBe(true) - expect(existingMetadata.find(m => m.id === 'meta-1')?.name).toBe('document_author') + expect(existingMetadata.find((m) => m.id === 'meta-1')?.name).toBe('document_author') // Try renaming created_date to page_count (already taken) expect(renameMetadataField('meta-2', 'page_count').error).toBe('duplicate_name') @@ -321,11 +310,11 @@ describe('Metadata Management Flow - Cross-Module Validation Composition', () => const name = 'consistent_field' const results = Array.from({ length: 5 }, () => result.current.checkName(name)) - expect(results.every(r => r.errorMsg === '')).toBe(true) + expect(results.every((r) => r.errorMsg === '')).toBe(true) // Validate an invalid name multiple times const invalidResults = Array.from({ length: 5 }, () => result.current.checkName('Invalid')) - expect(invalidResults.every(r => r.errorMsg !== '')).toBe(true) + expect(invalidResults.every((r) => r.errorMsg !== '')).toBe(true) }) }) }) diff --git a/web/__tests__/datasets/pipeline-datasource-flow.test.tsx b/web/__tests__/datasets/pipeline-datasource-flow.test.tsx index d005fda5581e3c..be3ef01fb522a6 100644 --- a/web/__tests__/datasets/pipeline-datasource-flow.test.tsx +++ b/web/__tests__/datasets/pipeline-datasource-flow.test.tsx @@ -32,7 +32,11 @@ const createCrawlResultItem = (url: string, title?: string): CrawlResultItem => source_url: url, }) -const createOnlineDriveFile = (id: string, name: string, type = OnlineDriveFileType.file): OnlineDriveFile => ({ +const createOnlineDriveFile = ( + id: string, + name: string, + type = OnlineDriveFileType.file, +): OnlineDriveFile => ({ id, name, size: 2048, @@ -430,7 +434,9 @@ describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () expect(store.getState().websitePages).toHaveLength(3) expect(store.getState().crawlResult?.time_consuming).toBe(12.5) expect(store.getState().previewIndex).toBe(1) - expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://docs.example.com/guide') + expect(store.getState().previewWebsitePageRef.current?.source_url).toBe( + 'https://docs.example.com/guide', + ) }) it('should support a complete online drive navigation workflow', () => { @@ -458,10 +464,12 @@ describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () store.getState().setPrefix([...store.getState().prefix, 'project-alpha']) // Step 5: Select files - store.getState().setOnlineDriveFileList([ - createOnlineDriveFile('doc-1', 'spec.pdf'), - createOnlineDriveFile('doc-2', 'design.fig'), - ]) + store + .getState() + .setOnlineDriveFileList([ + createOnlineDriveFile('doc-1', 'spec.pdf'), + createOnlineDriveFile('doc-2', 'design.fig'), + ]) store.getState().setSelectedFileIds(['doc-1']) // Verify full state diff --git a/web/__tests__/datasets/segment-crud.test.tsx b/web/__tests__/datasets/segment-crud.test.tsx index 1308b0c103e291..1b114a867af339 100644 --- a/web/__tests__/datasets/segment-crud.test.tsx +++ b/web/__tests__/datasets/segment-crud.test.tsx @@ -12,32 +12,33 @@ import { useModalState } from '@/app/components/datasets/documents/detail/comple import { useSearchFilter } from '@/app/components/datasets/documents/detail/completed/hooks/use-search-filter' import { useSegmentSelection } from '@/app/components/datasets/documents/detail/completed/hooks/use-segment-selection' -const createSegment = (id: string, content = 'Test segment content'): SegmentDetailModel => ({ - id, - position: 1, - document_id: 'doc-1', - content, - sign_content: content, - answer: '', - word_count: 50, - tokens: 25, - keywords: ['test'], - index_node_id: 'idx-1', - index_node_hash: 'hash-1', - hit_count: 0, - enabled: true, - disabled_at: 0, - disabled_by: '', - status: 'completed', - created_by: 'user-1', - created_at: Date.now(), - indexing_at: Date.now(), - completed_at: Date.now(), - error: null, - stopped_at: 0, - updated_at: Date.now(), - attachments: [], -} as SegmentDetailModel) +const createSegment = (id: string, content = 'Test segment content'): SegmentDetailModel => + ({ + id, + position: 1, + document_id: 'doc-1', + content, + sign_content: content, + answer: '', + word_count: 50, + tokens: 25, + keywords: ['test'], + index_node_id: 'idx-1', + index_node_hash: 'hash-1', + hit_count: 0, + enabled: true, + disabled_at: 0, + disabled_by: '', + status: 'completed', + created_by: 'user-1', + created_at: Date.now(), + indexing_at: Date.now(), + completed_at: Date.now(), + error: null, + stopped_at: 0, + updated_at: Date.now(), + attachments: [], + }) as SegmentDetailModel describe('Segment CRUD Flow', () => { beforeEach(() => { @@ -217,12 +218,8 @@ describe('Segment CRUD Flow', () => { it('should maintain independent state across all three hooks', () => { const segments = [createSegment('seg-1'), createSegment('seg-2')] - const { result: filterResult } = renderHook(() => - useSearchFilter({ onPageChange: vi.fn() }), - ) - const { result: selectionResult } = renderHook(() => - useSegmentSelection(), - ) + const { result: filterResult } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() })) + const { result: selectionResult } = renderHook(() => useSegmentSelection()) const { result: modalResult } = renderHook(() => useModalState({ onNewSegmentModalChange: vi.fn() }), ) diff --git a/web/__tests__/description-validation.test.tsx b/web/__tests__/description-validation.test.tsx index 984f89f01adf29..a7c3e8ab3963eb 100644 --- a/web/__tests__/description-validation.test.tsx +++ b/web/__tests__/description-validation.test.tsx @@ -59,8 +59,7 @@ describe('Description Validation Logic', () => { const invalidDescription = 'x'.repeat(401) try { validateDescriptionLength(invalidDescription) - } - catch (error) { + } catch (error) { expect((error as Error).message).toBe(expectedErrorMessage) } }) @@ -85,8 +84,7 @@ describe('Description Validation Logic', () => { if (shouldPass) { expect(() => validateDescriptionLength(testDescription)).not.toThrow() expect(validateDescriptionLength(testDescription)).toBe(testDescription) - } - else { + } else { expect(() => validateDescriptionLength(testDescription)).toThrow( 'Description cannot exceed 400 characters.', ) diff --git a/web/__tests__/document-detail-navigation-fix.test.tsx b/web/__tests__/document-detail-navigation-fix.test.tsx index c5641d2adfda40..81636f8618b307 100644 --- a/web/__tests__/document-detail-navigation-fix.test.tsx +++ b/web/__tests__/document-detail-navigation-fix.test.tsx @@ -5,7 +5,6 @@ import type { Mock } from 'vitest' * This test specifically validates that the backToPrev function in the document detail * component correctly preserves pagination and filter states. */ - import { fireEvent, render, screen } from '@testing-library/react' import { useRouter } from '@/next/navigation' import { useDocumentDetail, useDocumentMetadata } from '@/service/knowledge/use-document' @@ -40,7 +39,13 @@ vi.mock('@/service/knowledge/use-segment', () => ({ })) // Create a minimal version of the DocumentDetail component that includes our fix -const DocumentDetailWithFix = ({ datasetId, documentId }: { datasetId: string, documentId: string }) => { +const DocumentDetailWithFix = ({ + datasetId, + documentId, +}: { + datasetId: string + documentId: string +}) => { const router = useRouter() // This is the FIXED implementation from detail/index.tsx @@ -59,12 +64,7 @@ const DocumentDetailWithFix = ({ datasetId, documentId }: { datasetId: string, d Back to Documents </button> <div data-testid="document-info"> - Dataset: - {' '} - {datasetId} - , Document: - {' '} - {documentId} + Dataset: {datasetId}, Document: {documentId} </div> </div> ) @@ -127,7 +127,9 @@ describe('Document Detail Navigation Fix Verification', () => { fireEvent.click(screen.getByTestId('back-button-fixed')) // Should preserve all query parameters - expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents?page=2&limit=10&keyword=API+documentation&status=active') + expect(mockPush).toHaveBeenCalledWith( + '/datasets/dataset-123/documents?page=2&limit=10&keyword=API+documentation&status=active', + ) console.log('✅ Search and filters preserved') }) @@ -136,7 +138,8 @@ describe('Document Detail Navigation Fix Verification', () => { // Test with complex query string including encoded characters Object.defineProperty(window, 'location', { value: { - search: '?page=1&limit=50&keyword=test%20%26%20debug&sort=name&order=desc&filter=%7B%22type%22%3A%22pdf%22%7D', + search: + '?page=1&limit=50&keyword=test%20%26%20debug&sort=name&order=desc&filter=%7B%22type%22%3A%22pdf%22%7D', }, writable: true, }) @@ -213,7 +216,9 @@ describe('Document Detail Navigation Fix Verification', () => { fireEvent.click(screen.getByTestId('back-button-fixed')) // Should return to page 3 of API search results - expect(mockPush).toHaveBeenCalledWith('/datasets/main-dataset/documents?keyword=API&page=3&limit=10') + expect(mockPush).toHaveBeenCalledWith( + '/datasets/main-dataset/documents?keyword=API&page=3&limit=10', + ) console.log('✅ Real user scenario: search + pagination preserved') }) @@ -232,7 +237,9 @@ describe('Document Detail Navigation Fix Verification', () => { fireEvent.click(screen.getByTestId('back-button-fixed')) // All filters should be preserved - expect(mockPush).toHaveBeenCalledWith('/datasets/filtered-dataset/documents?page=2&limit=25&status=active&type=pdf&sort=created_at&order=desc') + expect(mockPush).toHaveBeenCalledWith( + '/datasets/filtered-dataset/documents?page=2&limit=25&status=active&type=pdf&sort=created_at&order=desc', + ) console.log('✅ Complex filtering scenario preserved') }) diff --git a/web/__tests__/document-list-sorting.test.tsx b/web/__tests__/document-list-sorting.test.tsx index 6d6d16db9d55be..bfef01be6bad69 100644 --- a/web/__tests__/document-list-sorting.test.tsx +++ b/web/__tests__/document-list-sorting.test.tsx @@ -38,8 +38,7 @@ describe('Document List Sorting', () => { if (field === 'name') { const result = aValue.localeCompare(bValue) return order === 'asc' ? result : -result - } - else { + } else { const result = aValue - bValue return order === 'asc' ? result : -result } @@ -48,27 +47,27 @@ describe('Document List Sorting', () => { it('sorts by name descending (default for UI consistency)', () => { const sorted = sortDocuments(mockDocuments, 'name', 'desc') - expect(sorted.map(doc => doc.name)).toEqual(['Gamma.docx', 'Beta.pdf', 'Alpha.txt']) + expect(sorted.map((doc) => doc.name)).toEqual(['Gamma.docx', 'Beta.pdf', 'Alpha.txt']) }) it('sorts by name ascending (after toggle)', () => { const sorted = sortDocuments(mockDocuments, 'name', 'asc') - expect(sorted.map(doc => doc.name)).toEqual(['Alpha.txt', 'Beta.pdf', 'Gamma.docx']) + expect(sorted.map((doc) => doc.name)).toEqual(['Alpha.txt', 'Beta.pdf', 'Gamma.docx']) }) it('sorts by word_count descending', () => { const sorted = sortDocuments(mockDocuments, 'word_count', 'desc') - expect(sorted.map(doc => doc.word_count)).toEqual([800, 500, 200]) + expect(sorted.map((doc) => doc.word_count)).toEqual([800, 500, 200]) }) it('sorts by hit_count descending', () => { const sorted = sortDocuments(mockDocuments, 'hit_count', 'desc') - expect(sorted.map(doc => doc.hit_count)).toEqual([25, 10, 5]) + expect(sorted.map((doc) => doc.hit_count)).toEqual([25, 10, 5]) }) it('sorts by created_at descending (newest first)', () => { const sorted = sortDocuments(mockDocuments, 'created_at', 'desc') - expect(sorted.map(doc => doc.created_at)).toEqual([1699123500, 1699123456, 1699123400]) + expect(sorted.map((doc) => doc.created_at)).toEqual([1699123500, 1699123456, 1699123400]) }) it('handles empty values correctly', () => { @@ -78,6 +77,6 @@ describe('Document List Sorting', () => { ] const sorted = sortDocuments(docsWithEmpty, 'word_count', 'desc') - expect(sorted.map(doc => doc.word_count)).toEqual([100, 0]) + expect(sorted.map((doc) => doc.word_count)).toEqual([100, 0]) }) }) diff --git a/web/__tests__/embedded-user-id-auth.test.tsx b/web/__tests__/embedded-user-id-auth.test.tsx index cacd6331f8ac9d..f2092832d9e797 100644 --- a/web/__tests__/embedded-user-id-auth.test.tsx +++ b/web/__tests__/embedded-user-id-auth.test.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' - import CheckCode from '@/app/(shareLayout)/webapp-signin/check-code/page' import MailAndPasswordAuth from '@/app/(shareLayout)/webapp-signin/components/mail-and-password-auth' @@ -27,7 +26,8 @@ const useWebAppStoreMock = vi.fn((selector?: (state: typeof mockStoreState) => a }) vi.mock('@/context/web-app-context', () => ({ - useWebAppStore: (selector?: (state: typeof mockStoreState) => any) => useWebAppStoreMock(selector), + useWebAppStore: (selector?: (state: typeof mockStoreState) => any) => + useWebAppStoreMock(selector), })) const webAppLoginMock = vi.fn() @@ -55,7 +55,9 @@ vi.mock('@/service/webapp-auth', () => ({ webAppLogout: vi.fn(), })) -vi.mock('@/app/components/signin/countdown', () => ({ default: () => <div data-testid="countdown" /> })) +vi.mock('@/app/components/signin/countdown', () => ({ + default: () => <div data-testid="countdown" />, +})) vi.mock('@remixicon/react', () => ({ RiMailSendFill: () => <div data-testid="mail-icon" />, @@ -77,8 +79,12 @@ describe('embedded user id propagation in authentication flows', () => { render(<MailAndPasswordAuth isEmailSetup />) - fireEvent.change(screen.getByLabelText('login.email'), { target: { value: 'user@example.com' } }) - fireEvent.change(screen.getByLabelText(/login\.password/), { target: { value: 'strong-password' } }) + fireEvent.change(screen.getByLabelText('login.email'), { + target: { value: 'user@example.com' }, + }) + fireEvent.change(screen.getByLabelText(/login\.password/), { + target: { value: 'strong-password' }, + }) fireEvent.click(screen.getByRole('button', { name: 'login.signBtn' })) await waitFor(() => { @@ -99,15 +105,17 @@ describe('embedded user id propagation in authentication flows', () => { params.set('token', encodeURIComponent('token-abc')) useSearchParamsMock.mockReturnValue(params) - webAppEmailLoginWithCodeMock.mockResolvedValue({ result: 'success', data: { access_token: 'code-token' } }) + webAppEmailLoginWithCodeMock.mockResolvedValue({ + result: 'success', + data: { access_token: 'code-token' }, + }) fetchAccessTokenMock.mockResolvedValue({ access_token: 'passport-token' }) render(<CheckCode />) - fireEvent.change( - screen.getByPlaceholderText('login.checkCode.verificationCodePlaceholder'), - { target: { value: '123456' } }, - ) + fireEvent.change(screen.getByPlaceholderText('login.checkCode.verificationCodePlaceholder'), { + target: { value: '123456' }, + }) fireEvent.click(screen.getByRole('button', { name: 'login.checkCode.verify' })) await waitFor(() => { diff --git a/web/__tests__/embedded-user-id-store.test.tsx b/web/__tests__/embedded-user-id-store.test.tsx index 7adb33b7ffd071..d95c52d743d7ce 100644 --- a/web/__tests__/embedded-user-id-store.test.tsx +++ b/web/__tests__/embedded-user-id-store.test.tsx @@ -1,9 +1,11 @@ import { screen, waitFor } from '@testing-library/react' import * as React from 'react' import { renderToString } from 'react-dom/server' -import { createSystemFeaturesWrapper, renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import { + createSystemFeaturesWrapper, + renderWithSystemFeatures, +} from '@/__tests__/utils/mock-system-features' import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context' - import { AccessMode } from '@/models/access-control' const navigationMocks = vi.hoisted(() => ({ @@ -25,12 +27,13 @@ vi.mock('@/service/use-share', () => ({ const mockGetProcessedSystemVariablesFromUrlParams = vi.fn() vi.mock('@/app/components/base/chat/utils', () => ({ - getProcessedSystemVariablesFromUrlParams: (...args: any[]) => mockGetProcessedSystemVariablesFromUrlParams(...args), + getProcessedSystemVariablesFromUrlParams: (...args: any[]) => + mockGetProcessedSystemVariablesFromUrlParams(...args), })) const TestConsumer = () => { - const embeddedUserId = useWebAppStore(state => state.embeddedUserId) - const embeddedConversationId = useWebAppStore(state => state.embeddedConversationId) + const embeddedUserId = useWebAppStore((state) => state.embeddedUserId) + const embeddedConversationId = useWebAppStore((state) => state.embeddedConversationId) return ( <> <div data-testid="embedded-user-id">{embeddedUserId ?? 'null'}</div> @@ -89,15 +92,16 @@ describe('WebAppStoreProvider embedded user id handling', () => { const { wrapper: Wrapper } = createSystemFeaturesWrapper() try { - expect(() => renderToString( - <Wrapper> - <WebAppStoreProvider> - <div /> - </WebAppStoreProvider> - </Wrapper>, - )).not.toThrow() - } - finally { + expect(() => + renderToString( + <Wrapper> + <WebAppStoreProvider> + <div /> + </WebAppStoreProvider> + </Wrapper>, + ), + ).not.toThrow() + } finally { Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow, @@ -128,7 +132,7 @@ describe('WebAppStoreProvider embedded user id handling', () => { }) it('clears embedded user id when system variable is absent', async () => { - useWebAppStore.setState(state => ({ + useWebAppStore.setState((state) => ({ ...state, embeddedUserId: 'previous-user', embeddedConversationId: 'existing-conversation', diff --git a/web/__tests__/env.spec.ts b/web/__tests__/env.spec.ts index 419dcadf03062f..3a9f258a123649 100644 --- a/web/__tests__/env.spec.ts +++ b/web/__tests__/env.spec.ts @@ -14,15 +14,11 @@ describe('env runtime transport', () => { }) afterAll(() => { - if (originalAgentV2Env === undefined) - delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 - else - process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 = originalAgentV2Env - - if (originalRbacEnv === undefined) - delete process.env.NEXT_PUBLIC_RBAC_ENABLED - else - process.env.NEXT_PUBLIC_RBAC_ENABLED = originalRbacEnv + if (originalAgentV2Env === undefined) delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 + else process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 = originalAgentV2Env + + if (originalRbacEnv === undefined) delete process.env.NEXT_PUBLIC_RBAC_ENABLED + else process.env.NEXT_PUBLIC_RBAC_ENABLED = originalRbacEnv }) it('should read NEXT_PUBLIC_ENABLE_AGENT_V2 from the browser runtime dataset key', async () => { diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx index e0c16cce0272e7..05e45475ef6273 100644 --- a/web/__tests__/explore/explore-app-list-flow.test.tsx +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -8,7 +8,10 @@ import type { Mock } from 'vitest' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { App } from '@/models/explore' import { fireEvent, screen, waitFor } from '@testing-library/react' -import { createTestQueryClient, renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' +import { + createTestQueryClient, + renderWithSystemFeatures as render, +} from '@/__tests__/utils/mock-system-features' import AppList from '@/app/components/explore/app-list' import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore' import { useMembers } from '@/service/use-common' @@ -24,7 +27,7 @@ const mockUseAppContext = vi.hoisted(() => vi.fn<() => MockAppContext>()) const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en-US"}' let mockTabValue = allCategoriesEn const mockSetTab = vi.fn() -let mockExploreData: { categories: string[], allList: App[] } | undefined +let mockExploreData: { categories: string[]; allList: App[] } | undefined let mockIsLoading = false const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() @@ -106,12 +109,24 @@ vi.mock('@/service/client', () => ({ explore: { apps: { get: { - queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'apps', 'get', input], + queryKey: ({ input }: { input?: unknown } = {}) => [ + 'console', + 'explore', + 'apps', + 'get', + input, + ], }, }, banners: { get: { - queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'banners', 'get', input], + queryKey: ({ input }: { input?: unknown } = {}) => [ + 'console', + 'explore', + 'banners', + 'get', + input, + ], }, }, }, @@ -145,7 +160,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -165,33 +181,40 @@ vi.mock('@/hooks/use-import-dsl', () => ({ vi.mock('@/app/components/explore/create-app-modal', () => ({ default: (props: CreateAppModalProps) => { - if (!props.show) - return null + if (!props.show) return null return ( <div data-testid="create-app-modal"> <button data-testid="confirm-create" - onClick={() => props.onConfirm({ - name: 'New App', - icon_type: 'emoji', - icon: '🤖', - icon_background: '#fff', - description: 'desc', - })} + onClick={() => + props.onConfirm({ + name: 'New App', + icon_type: 'emoji', + icon: '🤖', + icon_background: '#fff', + description: 'desc', + }) + } > confirm </button> - <button data-testid="hide-create" onClick={props.onHide}>hide</button> + <button data-testid="hide-create" onClick={props.onHide}> + hide + </button> </div> ) }, })) vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({ - default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => ( + default: ({ onConfirm, onCancel }: { onConfirm: () => void; onCancel: () => void }) => ( <div data-testid="dsl-confirm-modal"> - <button data-testid="dsl-confirm" onClick={onConfirm}>confirm</button> - <button data-testid="dsl-cancel" onClick={onCancel}>cancel</button> + <button data-testid="dsl-confirm" onClick={onConfirm}> + confirm + </button> + <button data-testid="dsl-cancel" onClick={onCancel}> + cancel + </button> </div> ), })) @@ -276,9 +299,21 @@ describe('Explore App List Flow', () => { mockExploreData = { categories: ['Writing', 'Translate', 'Programming'], allList: [ - createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, categories: ['Writing'] }), - createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, categories: ['Translate'] }), - createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, categories: ['Programming'] }), + createApp({ + app_id: 'app-1', + app: { ...createApp().app, name: 'Writer Bot' }, + categories: ['Writing'], + }), + createApp({ + app_id: 'app-2', + app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, + categories: ['Translate'], + }), + createApp({ + app_id: 'app-3', + app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, + categories: ['Programming'], + }), ], } ;(fetchAppList as unknown as Mock).mockImplementation(() => new Promise(() => {})) @@ -346,12 +381,16 @@ describe('Explore App List Flow', () => { // Step 1: User clicks "Add to Workspace" on an app card const onSuccess = vi.fn() ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' }) - mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => { - options.onPending?.() - }) - mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { - options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) - }) + mockHandleImportDSL.mockImplementation( + async (_payload: unknown, options: { onSuccess?: () => void; onPending?: () => void }) => { + options.onPending?.() + }, + ) + mockHandleImportDSLConfirm.mockImplementation( + async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { + options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) + }, + ) renderAppList(true, onSuccess) diff --git a/web/__tests__/explore/installed-app-flow.test.tsx b/web/__tests__/explore/installed-app-flow.test.tsx index b37e69bce92331..39da25725ec01e 100644 --- a/web/__tests__/explore/installed-app-flow.test.tsx +++ b/web/__tests__/explore/installed-app-flow.test.tsx @@ -12,7 +12,12 @@ import InstalledApp from '@/app/components/explore/installed-app' import { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' -import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore' +import { + useGetInstalledAppAccessModeByAppId, + useGetInstalledAppMeta, + useGetInstalledAppParams, + useGetInstalledApps, +} from '@/service/use-explore' import { AppModeEnum } from '@/types/app' vi.mock('@/context/web-app-context', () => ({ @@ -41,11 +46,7 @@ vi.mock('@/app/components/share/text-generation', () => ({ vi.mock('@/app/components/base/chat/chat-with-history', () => ({ default: ({ installedAppInfo }: { installedAppInfo?: InstalledAppModel }) => ( - <div data-testid="chat-with-history"> - Chat - - {' '} - {installedAppInfo?.app.name} - </div> + <div data-testid="chat-with-history">Chat - {installedAppInfo?.app.name}</div> ), })) @@ -80,11 +81,11 @@ describe('Installed App Flow', () => { } type MockOverrides = { - installedApps?: { apps?: InstalledAppModel[], isPending?: boolean, isFetching?: boolean } - accessMode?: { isPending?: boolean, data?: unknown, error?: unknown } - params?: { isPending?: boolean, data?: unknown, error?: unknown } - meta?: { isPending?: boolean, data?: unknown, error?: unknown } - userAccess?: { data?: unknown, error?: unknown } + installedApps?: { apps?: InstalledAppModel[]; isPending?: boolean; isFetching?: boolean } + accessMode?: { isPending?: boolean; data?: unknown; error?: unknown } + params?: { isPending?: boolean; data?: unknown; error?: unknown } + meta?: { isPending?: boolean; data?: unknown; error?: unknown } + userAccess?: { data?: unknown; error?: unknown } } const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => { @@ -97,15 +98,17 @@ describe('Installed App Flow', () => { ...overrides.installedApps, }) - ;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record<string, Mock>) => unknown) => { - return selector({ - updateAppInfo: mockUpdateAppInfo, - updateWebAppAccessMode: mockUpdateWebAppAccessMode, - updateAppParams: mockUpdateAppParams, - updateWebAppMeta: mockUpdateWebAppMeta, - updateUserCanAccessApp: mockUpdateUserCanAccessApp, - }) - }) + ;(useWebAppStore as unknown as Mock).mockImplementation( + (selector: (state: Record<string, Mock>) => unknown) => { + return selector({ + updateAppInfo: mockUpdateAppInfo, + updateWebAppAccessMode: mockUpdateWebAppAccessMode, + updateAppParams: mockUpdateAppParams, + updateWebAppMeta: mockUpdateWebAppMeta, + updateUserCanAccessApp: mockUpdateUserCanAccessApp, + }) + }, + ) ;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({ isPending: false, diff --git a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx index cf433b309819a5..b1bfd55d91a720 100644 --- a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx +++ b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx @@ -174,8 +174,16 @@ describe('Sidebar Lifecycle Flow', () => { describe('Multi-App Ordering', () => { it('should display pinned apps before unpinned apps with divider', () => { mockInstalledApps = [ - createInstalledApp({ id: 'pinned-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' } }), - createInstalledApp({ id: 'unpinned-1', is_pinned: false, app: { ...createInstalledApp().app, name: 'Regular App' } }), + createInstalledApp({ + id: 'pinned-1', + is_pinned: true, + app: { ...createInstalledApp().app, name: 'Pinned App' }, + }), + createInstalledApp({ + id: 'unpinned-1', + is_pinned: false, + app: { ...createInstalledApp().app, name: 'Regular App' }, + }), ] const { container } = renderSidebar() @@ -189,7 +197,9 @@ describe('Sidebar Lifecycle Flow', () => { // Pinned app appears before unpinned app in the DOM const pinnedItem = pinnedApp.closest('[class*="rounded-lg"]')! const regularItem = regularApp.closest('[class*="rounded-lg"]')! - expect(pinnedItem.compareDocumentPosition(regularItem) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect( + pinnedItem.compareDocumentPosition(regularItem) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() // Divider is rendered between pinned and unpinned sections const divider = container.querySelector('[class*="bg-divider-regular"]') diff --git a/web/__tests__/goto-anything/command-selector.test.tsx b/web/__tests__/goto-anything/command-selector.test.tsx index f0168ab3be7dbf..2bf1798d8e8877 100644 --- a/web/__tests__/goto-anything/command-selector.test.tsx +++ b/web/__tests__/goto-anything/command-selector.test.tsx @@ -60,12 +60,7 @@ describe('CommandSelector', () => { describe('Basic Rendering', () => { it('should render all actions when no filter is provided', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - />, - ) + render(<CommandSelector actions={mockActions} onCommandSelect={mockOnCommandSelect} />) expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument() @@ -248,13 +243,7 @@ describe('CommandSelector', () => { describe('Edge Cases', () => { it('should handle empty actions object', () => { - render( - <CommandSelector - actions={{}} - onCommandSelect={mockOnCommandSelect} - searchFilter="" - />, - ) + render(<CommandSelector actions={{}} onCommandSelect={mockOnCommandSelect} searchFilter="" />) expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() }) @@ -297,12 +286,7 @@ describe('CommandSelector', () => { describe('Backward Compatibility', () => { it('should work without searchFilter prop (backward compatible)', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - />, - ) + render(<CommandSelector actions={mockActions} onCommandSelect={mockOnCommandSelect} />) expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument() diff --git a/web/__tests__/goto-anything/match-action.test.ts b/web/__tests__/goto-anything/match-action.test.ts index 66b170d45ee582..297f5d40fdaa6b 100644 --- a/web/__tests__/goto-anything/match-action.test.ts +++ b/web/__tests__/goto-anything/match-action.test.ts @@ -1,6 +1,5 @@ import type { Mock } from 'vitest' import type { ActionItem } from '../../app/components/goto-anything/actions/types' - // Import after mocking to get mocked version import { matchAction } from '../../app/components/goto-anything/actions' import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry' @@ -25,8 +24,7 @@ const actualMatchAction = (query: string, actions: Record<string, ActionItem>) = const cmdPattern = `/${cmd.name}` // For direct mode commands, don't match (keep in command selector) - if (cmd.mode === 'direct') - return false + if (cmd.mode === 'direct') return false // For submenu mode commands, match when complete command is entered return query === cmdPattern || query.startsWith(`${cmdPattern} `) diff --git a/web/__tests__/goto-anything/scope-command-tags.test.tsx b/web/__tests__/goto-anything/scope-command-tags.test.tsx index c25f4fc74e0142..acb978b6419b55 100644 --- a/web/__tests__/goto-anything/scope-command-tags.test.tsx +++ b/web/__tests__/goto-anything/scope-command-tags.test.tsx @@ -6,8 +6,7 @@ type SearchMode = 'scopes' | 'commands' | null // Mock component to test tag display logic const TagDisplay: React.FC<{ searchMode: SearchMode }> = ({ searchMode }) => { - if (!searchMode) - return null + if (!searchMode) return null return ( <div className="flex items-center gap-1 text-xs text-text-tertiary"> @@ -38,10 +37,8 @@ describe('Scope and Command Tags', () => { describe('Search Mode Detection', () => { const getSearchMode = (query: string): SearchMode => { - if (query.startsWith('@')) - return 'scopes' - if (query.startsWith('/')) - return 'commands' + if (query.startsWith('@')) return 'scopes' + if (query.startsWith('/')) return 'commands' return null } @@ -73,7 +70,9 @@ describe('Scope and Command Tags', () => { describe('Tag Styling', () => { it('should apply correct styling classes', () => { const { container } = render(<TagDisplay searchMode="scopes" />) - const tagContainer = container.querySelector('.flex.items-center.gap-1.text-xs.text-text-tertiary') + const tagContainer = container.querySelector( + '.flex.items-center.gap-1.text-xs.text-text-tertiary', + ) expect(tagContainer).toBeInTheDocument() }) @@ -93,10 +92,8 @@ describe('Scope and Command Tags', () => { const SearchComponent: React.FC<{ query: string }> = ({ query }) => { let searchMode: SearchMode = null - if (query.startsWith('@')) - searchMode = 'scopes' - else if (query.startsWith('/')) - searchMode = 'commands' + if (query.startsWith('@')) searchMode = 'scopes' + else if (query.startsWith('/')) searchMode = 'commands' return ( <div> diff --git a/web/__tests__/goto-anything/search-error-handling.test.ts b/web/__tests__/goto-anything/search-error-handling.test.ts index 3a495834cd3607..584614c6d693a0 100644 --- a/web/__tests__/goto-anything/search-error-handling.test.ts +++ b/web/__tests__/goto-anything/search-error-handling.test.ts @@ -8,7 +8,6 @@ import type { MockedFunction } from 'vitest' * 3. Verify consistent error handling across different search types * 4. Ensure errors don't propagate to UI layer causing "search failed" */ - import { Actions, searchAnything } from '@/app/components/goto-anything/actions' import { fetchAppList } from '@/service/apps' import { postMarketplace } from '@/service/base' @@ -116,8 +115,20 @@ describe('GotoAnything Search Error Handling', () => { describe('Unified search entry error handling', () => { it('regular search (without @prefix) should return successful results even when partial APIs fail', async () => { // Set app and knowledge success, plugin failure - mockFetchAppList.mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 }) - mockFetchDatasets.mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 }) + mockFetchAppList.mockResolvedValue({ + data: [], + has_more: false, + limit: 10, + page: 1, + total: 0, + }) + mockFetchDatasets.mockResolvedValue({ + data: [], + has_more: false, + limit: 10, + page: 1, + total: 0, + }) mockPostMarketplace.mockRejectedValue(new Error('Plugin API failed')) const result = await searchAnything('en', 'test') diff --git a/web/__tests__/goto-anything/slash-command-modes.test.tsx b/web/__tests__/goto-anything/slash-command-modes.test.tsx index bcaddfb27b145e..36c218e7d23db8 100644 --- a/web/__tests__/goto-anything/slash-command-modes.test.tsx +++ b/web/__tests__/goto-anything/slash-command-modes.test.tsx @@ -50,10 +50,8 @@ describe('Slash Command Dual-Mode System', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(slashCommandRegistry.findCommand).mockImplementation((name: string) => { - if (name === 'docs') - return mockDirectCommand - if (name === 'theme') - return mockSubmenuCommand + if (name === 'docs') return mockDirectCommand + if (name === 'theme') return mockSubmenuCommand return undefined }) vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([ @@ -131,8 +129,8 @@ describe('Slash Command Dual-Mode System', () => { describe('Mode Detection and Routing', () => { it('should correctly identify direct mode commands', () => { const commands = slashCommandRegistry.getAllCommands() - const directCommands = commands.filter(cmd => cmd.mode === 'direct') - const submenuCommands = commands.filter(cmd => cmd.mode === 'submenu') + const directCommands = commands.filter((cmd) => cmd.mode === 'direct') + const submenuCommands = commands.filter((cmd) => cmd.mode === 'submenu') expect(directCommands).toContainEqual(expect.objectContaining({ name: 'docs' })) expect(submenuCommands).toContainEqual(expect.objectContaining({ name: 'theme' })) diff --git a/web/__tests__/header/account-dropdown-flow.test.tsx b/web/__tests__/header/account-dropdown-flow.test.tsx index b25934eeebed5b..c633fff8c65c74 100644 --- a/web/__tests__/header/account-dropdown-flow.test.tsx +++ b/web/__tests__/header/account-dropdown-flow.test.tsx @@ -9,50 +9,44 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.com${path}`, })) -const { - mockAppContextState, - mockPush, - mockLogout, - mockResetUser, - mockSetShowAccountSettingModal, -} = vi.hoisted(() => ({ - 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, +const { mockAppContextState, mockPush, mockLogout, mockResetUser, mockSetShowAccountSettingModal } = + vi.hoisted(() => ({ + 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, }, - isCurrentWorkspaceOwner: false, - }, - mockPush: vi.fn(), - mockLogout: vi.fn(), - mockResetUser: vi.fn(), - mockSetShowAccountSettingModal: vi.fn(), -})) + mockPush: vi.fn(), + mockLogout: vi.fn(), + mockResetUser: vi.fn(), + mockSetShowAccountSettingModal: vi.fn(), + })) vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ - t: withSelectorKey((key: string, options?: { ns?: string, version?: string }) => { - if (options?.version) - return `${options.ns}.${key}:${options.version}` + t: withSelectorKey((key: string, options?: { ns?: string; version?: string }) => { + if (options?.version) return `${options.ns}.${key}:${options.version}` return options?.ns ? `${options.ns}.${key}` : key }), }), - }) + } }) vi.mock('@/context/account-state', async (importOriginal) => { @@ -77,7 +71,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -96,8 +91,8 @@ vi.mock('@/context/modal-context', () => ({ }), })) -vi.mock('@/service/use-common', async importOriginal => ({ - ...await importOriginal<typeof import('@/service/use-common')>(), +vi.mock('@/service/use-common', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/service/use-common')>()), useLogout: () => ({ mutateAsync: mockLogout, }), @@ -142,12 +137,17 @@ const renderAccountDropdown = () => { describe('Header Account Dropdown Flow', () => { beforeEach(() => { vi.clearAllMocks() - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ - repo: { stars: 123456 }, - }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })) + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + repo: { stars: 123456 }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) localStorage.clear() }) diff --git a/web/__tests__/i18n-upload-features.test.ts b/web/__tests__/i18n-upload-features.test.ts index 3d0f1d30dd2859..50aef054f0c663 100644 --- a/web/__tests__/i18n-upload-features.test.ts +++ b/web/__tests__/i18n-upload-features.test.ts @@ -9,8 +9,9 @@ import path from 'node:path' // Get all supported locales from the i18n directory const I18N_DIR = path.join(__dirname, '../i18n') const getSupportedLocales = (): string[] => { - return fs.readdirSync(I18N_DIR) - .filter(item => fs.statSync(path.join(I18N_DIR, item)).isDirectory()) + return fs + .readdirSync(I18N_DIR) + .filter((item) => fs.statSync(path.join(I18N_DIR, item)).isDirectory()) .sort() } @@ -18,8 +19,7 @@ const getSupportedLocales = (): string[] => { const loadTranslationContent = (locale: string): string => { const filePath = path.join(I18N_DIR, locale, 'app-debug.json') - if (!fs.existsSync(filePath)) - throw new Error(`Translation file not found: ${filePath}`) + if (!fs.existsSync(filePath)) throw new Error(`Translation file not found: ${filePath}`) return fs.readFileSync(filePath, 'utf-8') } diff --git a/web/__tests__/plugin-tool-workflow-error.test.tsx b/web/__tests__/plugin-tool-workflow-error.test.tsx index 87bda8fa1390ab..07a11214b4ae5d 100644 --- a/web/__tests__/plugin-tool-workflow-error.test.tsx +++ b/web/__tests__/plugin-tool-workflow-error.test.tsx @@ -22,7 +22,7 @@ describe('Plugin Tool Workflow Error Reproduction', () => { it('should reproduce error when uniqueIdentifier is null', () => { expect(() => { mockSwitchPluginVersionLogic(null) - }).toThrow('Cannot read properties of null (reading \'split\')') + }).toThrow("Cannot read properties of null (reading 'split')") }) /** @@ -31,7 +31,7 @@ describe('Plugin Tool Workflow Error Reproduction', () => { it('should reproduce error when uniqueIdentifier is undefined', () => { expect(() => { mockSwitchPluginVersionLogic(undefined) - }).toThrow('Cannot read properties of undefined (reading \'split\')') + }).toThrow("Cannot read properties of undefined (reading 'split')") }) /** @@ -76,13 +76,14 @@ describe('Variable Processing Split Error', () => { * } */ const mockGetDependentVars = (varInputs: Array<{ variable: string | null | undefined }>) => { - return varInputs.map((item) => { - // Guard against null/undefined variable to prevent app crash - if (!item.variable || typeof item.variable !== 'string') - return [] - - return item.variable.slice(1, -1).split('.') - }).filter(arr => arr.length > 0) // Filter out empty arrays + return varInputs + .map((item) => { + // Guard against null/undefined variable to prevent app crash + if (!item.variable || typeof item.variable !== 'string') return [] + + return item.variable.slice(1, -1).split('.') + }) + .filter((arr) => arr.length > 0) // Filter out empty arrays } /** @@ -167,7 +168,7 @@ describe('Plugin Tool Workflow Integration', () => { // Simulate the code path in switch-plugin-version.tsx:29 // The actual problematic code doesn't use optional chaining const _pluginId = (incompletePluginData.uniqueIdentifier as any).split(':')[0] - }).toThrow('Cannot read properties of null (reading \'split\')') + }).toThrow("Cannot read properties of null (reading 'split')") }) /** @@ -195,8 +196,7 @@ describe('Plugin Tool Workflow Integration', () => { expect(() => { const _pluginId = (tool.uniqueIdentifier as any).split(':')[0] }).toThrow() - } - else { + } else { // Valid tools should work fine expect(() => { const _pluginId = tool.uniqueIdentifier.split(':')[0] diff --git a/web/__tests__/plugins/plugin-auth-flow.test.tsx b/web/__tests__/plugins/plugin-auth-flow.test.tsx index 2795aeceebb09e..0a708acda6511e 100644 --- a/web/__tests__/plugins/plugin-auth-flow.test.tsx +++ b/web/__tests__/plugins/plugin-auth-flow.test.tsx @@ -8,12 +8,11 @@ */ import { cleanup, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import { AuthCategory, CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey((key: string) => { const map: Record<string, string> = { @@ -25,7 +24,7 @@ vi.mock('react-i18next', async () => { return map[key] ?? key }), }), - }) + } }) vi.mock('@langgenius/dify-ui/cn', () => ({ @@ -38,7 +37,11 @@ vi.mock('@/app/components/plugins/plugin-auth/hooks/use-plugin-auth', () => ({ })) vi.mock('@/app/components/plugins/plugin-auth/authorize', () => ({ - default: ({ pluginPayload, canOAuth, canApiKey }: { + default: ({ + pluginPayload, + canOAuth, + canApiKey, + }: { pluginPayload: { provider: string } canOAuth: boolean canApiKey: boolean @@ -52,17 +55,16 @@ vi.mock('@/app/components/plugins/plugin-auth/authorize', () => ({ })) vi.mock('@/app/components/plugins/plugin-auth/authorized', () => ({ - default: ({ pluginPayload, credentials }: { + default: ({ + pluginPayload, + credentials, + }: { pluginPayload: { provider: string } - credentials: Array<{ id: string, name: string }> + credentials: Array<{ id: string; name: string }> }) => ( <div data-testid="authorized-component"> <span data-testid="auth-provider">{pluginPayload.provider}</span> - <span data-testid="auth-credential-count"> - {credentials.length} - {' '} - credentials - </span> + <span data-testid="auth-credential-count">{credentials.length} credentials</span> </div> ), })) @@ -138,9 +140,7 @@ describe('Plugin Authentication Flow Integration', () => { isAuthorized: true, canOAuth: false, canApiKey: true, - credentials: [ - { id: 'cred-1', name: 'My API Key', is_default: true }, - ], + credentials: [{ id: 'cred-1', name: 'My API Key', is_default: true }], invalidPluginCredentialInfo: vi.fn(), notAllowCustomCredential: false, }) @@ -245,7 +245,12 @@ describe('Plugin Authentication Flow Integration', () => { credentials: [ { id: 'cred-1', name: 'API Key 1', is_default: true }, { id: 'cred-2', name: 'API Key 2', is_default: false }, - { id: 'cred-3', name: 'OAuth Token', is_default: false, credential_type: CredentialTypeEnum.OAUTH2 }, + { + id: 'cred-3', + name: 'OAuth Token', + is_default: false, + credential_type: CredentialTypeEnum.OAUTH2, + }, ], invalidPluginCredentialInfo: vi.fn(), notAllowCustomCredential: false, diff --git a/web/__tests__/plugins/plugin-card-rendering.test.tsx b/web/__tests__/plugins/plugin-card-rendering.test.tsx index c7894b94b85b85..1978a676ec793a 100644 --- a/web/__tests__/plugins/plugin-card-rendering.test.tsx +++ b/web/__tests__/plugins/plugin-card-rendering.test.tsx @@ -23,7 +23,7 @@ vi.mock('@/types/app', async () => { }) vi.mock('@langgenius/dify-ui/cn', () => ({ - cn: (...args: unknown[]) => args.filter(a => typeof a === 'string' && a).join(' '), + cn: (...args: unknown[]) => args.filter((a) => typeof a === 'string' && a).join(' '), })) vi.mock('@/app/components/plugins/hooks', () => ({ @@ -45,7 +45,15 @@ vi.mock('@/app/components/plugins/base/badges/verified', () => ({ })) vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ - default: ({ src, installed, installFailed }: { src: string | object, installed?: boolean, installFailed?: boolean }) => ( + default: ({ + src, + installed, + installFailed, + }: { + src: string | object + installed?: boolean + installFailed?: boolean + }) => ( <div data-testid="card-icon" data-installed={installed} data-install-failed={installFailed}> {typeof src === 'string' ? src : 'emoji-icon'} </div> @@ -53,37 +61,31 @@ vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ })) vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({ - default: ({ text }: { text: string }) => ( - <div data-testid="corner-mark">{text}</div> - ), + default: ({ text }: { text: string }) => <div data-testid="corner-mark">{text}</div>, })) vi.mock('@/app/components/plugins/card/base/description', () => ({ - default: ({ text, descriptionLineRows }: { text: string, descriptionLineRows?: number }) => ( - <div data-testid="description" data-rows={descriptionLineRows}>{text}</div> + default: ({ text, descriptionLineRows }: { text: string; descriptionLineRows?: number }) => ( + <div data-testid="description" data-rows={descriptionLineRows}> + {text} + </div> ), })) vi.mock('@/app/components/plugins/card/base/org-info', () => ({ - default: ({ orgName, packageName }: { orgName: string, packageName: string }) => ( + default: ({ orgName, packageName }: { orgName: string; packageName: string }) => ( <div data-testid="org-info"> - {orgName} - / - {packageName} + {orgName}/{packageName} </div> ), })) vi.mock('@/app/components/plugins/card/base/placeholder', () => ({ - default: ({ text }: { text: string }) => ( - <div data-testid="placeholder">{text}</div> - ), + default: ({ text }: { text: string }) => <div data-testid="placeholder">{text}</div>, })) vi.mock('@/app/components/plugins/card/base/title', () => ({ - default: ({ title }: { title: string }) => ( - <div data-testid="title">{title}</div> - ), + default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>, })) const { default: Card } = await import('@/app/components/plugins/card/index') @@ -95,18 +97,19 @@ describe('Plugin Card Rendering Integration', () => { mockTheme = 'light' }) - const makePayload = (overrides = {}) => ({ - category: 'tool', - type: 'plugin', - name: 'google-search', - org: 'langgenius', - label: { en_US: 'Google Search', zh_Hans: 'Google搜索' }, - brief: { en_US: 'Search the web using Google', zh_Hans: '使用Google搜索网页' }, - icon: 'https://example.com/icon.png', - verified: true, - badges: [] as string[], - ...overrides, - }) as CardPayload + const makePayload = (overrides = {}) => + ({ + category: 'tool', + type: 'plugin', + name: 'google-search', + org: 'langgenius', + label: { en_US: 'Google Search', zh_Hans: 'Google搜索' }, + brief: { en_US: 'Search the web using Google', zh_Hans: '使用Google搜索网页' }, + icon: 'https://example.com/icon.png', + verified: true, + badges: [] as string[], + ...overrides, + }) as CardPayload it('renders a complete plugin card with all subcomponents', () => { const payload = makePayload() @@ -164,24 +167,14 @@ describe('Plugin Card Rendering Integration', () => { it('renders footer content when provided', () => { const payload = makePayload() - render( - <Card - payload={payload} - footer={<div data-testid="custom-footer">Custom footer</div>} - />, - ) + render(<Card payload={payload} footer={<div data-testid="custom-footer">Custom footer</div>} />) expect(screen.getByTestId('custom-footer')).toBeInTheDocument() }) it('renders titleLeft content when provided', () => { const payload = makePayload() - render( - <Card - payload={payload} - titleLeft={<span data-testid="title-left-content">New</span>} - />, - ) + render(<Card payload={payload} titleLeft={<span data-testid="title-left-content">New</span>} />) expect(screen.getByTestId('title-left-content')).toBeInTheDocument() }) diff --git a/web/__tests__/plugins/plugin-install-flow.test.ts b/web/__tests__/plugins/plugin-install-flow.test.ts index d975eb7167960b..f3eae0a5858361 100644 --- a/web/__tests__/plugins/plugin-install-flow.test.ts +++ b/web/__tests__/plugins/plugin-install-flow.test.ts @@ -7,19 +7,27 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { checkForUpdates, fetchReleases, handleUpload } from '@/app/components/plugins/install-plugin/hooks' +import { + checkForUpdates, + fetchReleases, + handleUpload, +} from '@/app/components/plugins/install-plugin/hooks' const mockToastNotify = vi.fn() vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: Object.assign((message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { - success: (message: string) => mockToastNotify({ type: 'success', message }), - error: (message: string) => mockToastNotify({ type: 'error', message }), - warning: (message: string) => mockToastNotify({ type: 'warning', message }), - info: (message: string) => mockToastNotify({ type: 'info', message }), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }), + toast: Object.assign( + (message: string, options?: { type?: string }) => + mockToastNotify({ type: options?.type, message }), + { + success: (message: string) => mockToastNotify({ type: 'success', message }), + error: (message: string) => mockToastNotify({ type: 'error', message }), + warning: (message: string) => mockToastNotify({ type: 'warning', message }), + info: (message: string) => mockToastNotify({ type: 'info', message }), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), + }, + ), })) const mockUploadGitHub = vi.fn() @@ -137,9 +145,7 @@ describe('Plugin Installation Flow Integration', () => { const releases = await fetchReleases('nonexistent-org', 'nonexistent-repo') expect(releases).toEqual([]) - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('handles upload failure gracefully', async () => { @@ -160,7 +166,8 @@ describe('Plugin Installation Flow Integration', () => { describe('Task Status Polling Integration', () => { it('polls until plugin installation succeeds', async () => { - const mockCheckTaskStatus = vi.fn() + const mockCheckTaskStatus = vi + .fn() .mockResolvedValueOnce({ task: { plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'running' }], @@ -179,9 +186,8 @@ describe('Plugin Installation Flow Integration', () => { sleep: () => Promise.resolve(), })) - const { default: checkTaskStatus } = await import( - '@/app/components/plugins/install-plugin/base/check-task-status', - ) + const { default: checkTaskStatus } = + await import('@/app/components/plugins/install-plugin/base/check-task-status') const checker = checkTaskStatus() const result = await checker.check({ @@ -202,9 +208,8 @@ describe('Plugin Installation Flow Integration', () => { const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins') ;(fetchCheckTaskStatus as ReturnType<typeof vi.fn>).mockImplementation(mockCheckTaskStatus) - const { default: checkTaskStatus } = await import( - '@/app/components/plugins/install-plugin/base/check-task-status', - ) + const { default: checkTaskStatus } = + await import('@/app/components/plugins/install-plugin/base/check-task-status') const checker = checkTaskStatus() const result = await checker.check({ @@ -217,9 +222,8 @@ describe('Plugin Installation Flow Integration', () => { }) it('stops polling when stop() is called', async () => { - const { default: checkTaskStatus } = await import( - '@/app/components/plugins/install-plugin/base/check-task-status', - ) + const { default: checkTaskStatus } = + await import('@/app/components/plugins/install-plugin/base/check-task-status') const checker = checkTaskStatus() checker.stop() diff --git a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx index 34bf47098338a0..591d1a2ac7b885 100644 --- a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx +++ b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx @@ -26,37 +26,55 @@ describe('Plugin Marketplace to Install Flow', () => { } it('should allow marketplace plugin when all sources allowed', () => { - const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } } + const plugin = { + from: 'marketplace' as const, + verification: { authorized_category: 'langgenius' }, + } const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never) expect(result.canInstall).toBe(true) }) it('should allow github plugin when all sources allowed', () => { - const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } } + const plugin = { + from: 'github' as const, + verification: { authorized_category: 'langgenius' }, + } const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never) expect(result.canInstall).toBe(true) }) it('should block github plugin when marketplace only', () => { - const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } } + const plugin = { + from: 'github' as const, + verification: { authorized_category: 'langgenius' }, + } const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never) expect(result.canInstall).toBe(false) }) it('should allow marketplace plugin when marketplace only', () => { - const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'partner' } } + const plugin = { + from: 'marketplace' as const, + verification: { authorized_category: 'partner' }, + } const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never) expect(result.canInstall).toBe(true) }) it('should allow official plugin when official only', () => { - const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } } + const plugin = { + from: 'marketplace' as const, + verification: { authorized_category: 'langgenius' }, + } const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never) expect(result.canInstall).toBe(true) }) it('should block community plugin when official only', () => { - const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'community' } } + const plugin = { + from: 'marketplace' as const, + verification: { authorized_category: 'community' }, + } const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never) expect(result.canInstall).toBe(false) }) @@ -72,7 +90,7 @@ describe('Plugin Marketplace to Install Flow', () => { }, } - const results = sources.map(source => ({ + const results = sources.map((source) => ({ source, canInstall: pluginInstallLimit( { from: source, verification: { authorized_category: 'langgenius' } } as never, @@ -80,9 +98,9 @@ describe('Plugin Marketplace to Install Flow', () => { ).canInstall, })) - expect(results.find(r => r.source === 'marketplace')?.canInstall).toBe(true) - expect(results.find(r => r.source === 'github')?.canInstall).toBe(false) - expect(results.find(r => r.source === 'package')?.canInstall).toBe(false) + expect(results.find((r) => r.source === 'marketplace')?.canInstall).toBe(true) + expect(results.find((r) => r.source === 'github')?.canInstall).toBe(false) + expect(results.find((r) => r.source === 'package')?.canInstall).toBe(false) }) }) }) diff --git a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx index 75696378014615..701fe138159eb9 100644 --- a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx +++ b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx @@ -42,11 +42,7 @@ vi.mock('@/context/account-state', async (importOriginal) => { release_notes: '', can_auto_update: false, }, - workspacePermissionKeys: [ - 'plugin.install', - 'plugin.delete', - 'plugin.plugin_preferences', - ], + workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.plugin_preferences'], })) }) vi.mock('@/context/workspace-state', async (importOriginal) => { @@ -63,11 +59,7 @@ vi.mock('@/context/workspace-state', async (importOriginal) => { release_notes: '', can_auto_update: false, }, - workspacePermissionKeys: [ - 'plugin.install', - 'plugin.delete', - 'plugin.plugin_preferences', - ], + workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.plugin_preferences'], })) }) vi.mock('@/context/permission-state', async (importOriginal) => { @@ -84,11 +76,7 @@ vi.mock('@/context/permission-state', async (importOriginal) => { release_notes: '', can_auto_update: false, }, - workspacePermissionKeys: [ - 'plugin.install', - 'plugin.delete', - 'plugin.plugin_preferences', - ], + workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.plugin_preferences'], })) }) vi.mock('@/context/version-state', async (importOriginal) => { @@ -105,11 +93,7 @@ vi.mock('@/context/version-state', async (importOriginal) => { release_notes: '', can_auto_update: false, }, - workspacePermissionKeys: [ - 'plugin.install', - 'plugin.delete', - 'plugin.plugin_preferences', - ], + workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.plugin_preferences'], })) }) vi.mock('@/context/system-features-state', async (importOriginal) => { @@ -126,16 +110,13 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { release_notes: '', can_auto_update: false, }, - workspacePermissionKeys: [ - 'plugin.install', - 'plugin.delete', - 'plugin.plugin_preferences', - ], + workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.plugin_preferences'], })) }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -201,16 +182,12 @@ vi.mock('@/app/components/plugins/plugin-page/install-plugin-dropdown', () => ({ })) vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ - default: ({ - uniqueIdentifier, - onClose, - }: { - uniqueIdentifier: string - onClose: () => void - }) => ( + default: ({ uniqueIdentifier, onClose }: { uniqueIdentifier: string; onClose: () => void }) => ( <div data-testid="install-from-marketplace-modal"> <span>{uniqueIdentifier}</span> - <button type="button" onClick={onClose}>close-install-modal</button> + <button type="button" onClick={onClose}> + close-install-modal + </button> </div> ), })) diff --git a/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts b/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts index 6c8dfb982f6ece..d557bfcd7b80e0 100644 --- a/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts +++ b/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts @@ -18,9 +18,8 @@ vi.mock('@/models/datasets', () => ({ }, })) -const { formatPreviewChunks } = await import( - '@/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils', -) +const { formatPreviewChunks } = + await import('@/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils') describe('Chunk Preview Formatting', () => { describe('general text chunks', () => { @@ -36,7 +35,7 @@ describe('Chunk Preview Formatting', () => { const result = formatPreviewChunks(outputs) expect(Array.isArray(result)).toBe(true) - const chunks = result as Array<{ content: string, summary?: string }> + const chunks = result as Array<{ content: string; summary?: string }> expect(chunks).toHaveLength(2) expect(chunks[0]!.content).toBe('Chunk 1 content') expect(chunks[0]!.summary).toBe('Summary 1') @@ -164,7 +163,7 @@ describe('Chunk Preview Formatting', () => { } const result = formatPreviewChunks(outputs) as { - qa_chunks: Array<{ question: string, answer: string }> + qa_chunks: Array<{ question: string; answer: string }> } expect(result.qa_chunks).toHaveLength(2) diff --git a/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts b/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts index 981107fe44649e..887c98eee11fe5 100644 --- a/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts +++ b/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts @@ -32,9 +32,8 @@ vi.mock('@/config', () => ({ describe('Input Field CRUD Flow', () => { describe('Create → Edit → Convert Round-trip', () => { it('should create a text field and roundtrip through form data', async () => { - const { convertToInputFieldFormData, convertFormDataToINputField } = await import( - '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', - ) + const { convertToInputFieldFormData, convertFormDataToINputField } = + await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') // Create new field from template (no data passed) const newFormData = convertToInputFieldFormData() @@ -68,9 +67,8 @@ describe('Input Field CRUD Flow', () => { }) it('should handle file field with upload settings', async () => { - const { convertToInputFieldFormData, convertFormDataToINputField } = await import( - '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', - ) + const { convertToInputFieldFormData, convertFormDataToINputField } = + await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') const fileInputVar: InputVar = { type: PipelineInputVarType.singleFile, @@ -90,7 +88,10 @@ describe('Input Field CRUD Flow', () => { // Convert to form data const formData = convertToInputFieldFormData(fileInputVar) - expect(formData.allowedFileUploadMethods).toEqual([TransferMethod.local_file, TransferMethod.remote_url]) + expect(formData.allowedFileUploadMethods).toEqual([ + TransferMethod.local_file, + TransferMethod.remote_url, + ]) expect(formData.allowedTypesAndExtensions).toEqual({ allowedFileTypes: [SupportUploadFileTypes.document], allowedFileExtensions: ['.pdf', '.docx'], @@ -98,15 +99,17 @@ describe('Input Field CRUD Flow', () => { // Round-trip back const restored = convertFormDataToINputField(formData) - expect(restored.allowed_file_upload_methods).toEqual([TransferMethod.local_file, TransferMethod.remote_url]) + expect(restored.allowed_file_upload_methods).toEqual([ + TransferMethod.local_file, + TransferMethod.remote_url, + ]) expect(restored.allowed_file_types).toEqual([SupportUploadFileTypes.document]) expect(restored.allowed_file_extensions).toEqual(['.pdf', '.docx']) }) it('should handle select field with options', async () => { - const { convertToInputFieldFormData, convertFormDataToINputField } = await import( - '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', - ) + const { convertToInputFieldFormData, convertFormDataToINputField } = + await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') const selectVar: InputVar = { type: PipelineInputVarType.select, @@ -134,9 +137,8 @@ describe('Input Field CRUD Flow', () => { }) it('should handle number field with unit', async () => { - const { convertToInputFieldFormData, convertFormDataToINputField } = await import( - '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', - ) + const { convertToInputFieldFormData, convertFormDataToINputField } = + await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') const numberVar: InputVar = { type: PipelineInputVarType.number, @@ -166,9 +168,8 @@ describe('Input Field CRUD Flow', () => { describe('Omit optional fields', () => { it('should not include tooltips when undefined', async () => { - const { convertToInputFieldFormData } = await import( - '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', - ) + const { convertToInputFieldFormData } = + await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') const inputVar: InputVar = { type: PipelineInputVarType.textInput, @@ -196,9 +197,8 @@ describe('Input Field CRUD Flow', () => { }) it('should include optional fields when explicitly set to empty string', async () => { - const { convertToInputFieldFormData } = await import( - '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', - ) + const { convertToInputFieldFormData } = + await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') const inputVar: InputVar = { type: PipelineInputVarType.textInput, @@ -227,9 +227,8 @@ describe('Input Field CRUD Flow', () => { describe('Multiple fields workflow', () => { it('should process multiple fields independently', async () => { - const { convertToInputFieldFormData, convertFormDataToINputField } = await import( - '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', - ) + const { convertToInputFieldFormData, convertFormDataToINputField } = + await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') const fields: InputVar[] = [ { @@ -264,8 +263,8 @@ describe('Input Field CRUD Flow', () => { }, ] - const formDataList = fields.map(f => convertToInputFieldFormData(f)) - const restoredFields = formDataList.map(fd => convertFormDataToINputField(fd)) + const formDataList = fields.map((f) => convertToInputFieldFormData(f)) + const restoredFields = formDataList.map((fd) => convertFormDataToINputField(fd)) expect(restoredFields).toHaveLength(2) expect(restoredFields[0]!.variable).toBe('name') diff --git a/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts index 0fc4699aa860fa..14fc21bb03e12e 100644 --- a/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts +++ b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts @@ -26,9 +26,8 @@ vi.mock('@/config', () => ({ })) // Import real functions (not mocked) -const { convertToInputFieldFormData, convertFormDataToINputField } = await import( - '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', -) +const { convertToInputFieldFormData, convertFormDataToINputField } = + await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') describe('Input Field Editor Data Flow', () => { describe('convertToInputFieldFormData', () => { diff --git a/web/__tests__/rag-pipeline/test-run-flow.test.ts b/web/__tests__/rag-pipeline/test-run-flow.test.ts index d9af2172a211d1..bd24030e1b3cb4 100644 --- a/web/__tests__/rag-pipeline/test-run-flow.test.ts +++ b/web/__tests__/rag-pipeline/test-run-flow.test.ts @@ -95,9 +95,8 @@ describe('Test Run Flow Integration', () => { describe('Step Navigation', () => { it('should start at step 1 and navigate forward', async () => { - const { useTestRunSteps } = await import( - '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', - ) + const { useTestRunSteps } = + await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') const { result } = renderHook(() => useTestRunSteps()) expect(result.current.currentStep).toBe(1) @@ -110,9 +109,8 @@ describe('Test Run Flow Integration', () => { }) it('should navigate back from step 2 to step 1', async () => { - const { useTestRunSteps } = await import( - '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', - ) + const { useTestRunSteps } = + await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') const { result } = renderHook(() => useTestRunSteps()) act(() => { @@ -127,9 +125,8 @@ describe('Test Run Flow Integration', () => { }) it('should provide labeled steps', async () => { - const { useTestRunSteps } = await import( - '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', - ) + const { useTestRunSteps } = + await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') const { result } = renderHook(() => useTestRunSteps()) expect(result.current.steps).toHaveLength(2) @@ -140,9 +137,8 @@ describe('Test Run Flow Integration', () => { describe('Datasource Options', () => { it('should filter nodes to only DataSource type', async () => { - const { useDatasourceOptions } = await import( - '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', - ) + const { useDatasourceOptions } = + await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') const { result } = renderHook(() => useDatasourceOptions()) // Should only include DataSource nodes, not KnowledgeBase @@ -152,9 +148,8 @@ describe('Test Run Flow Integration', () => { }) it('should include node data in options', async () => { - const { useDatasourceOptions } = await import( - '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', - ) + const { useDatasourceOptions } = + await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') const { result } = renderHook(() => useDatasourceOptions()) expect(result.current[0]!.label).toBe('Local Files') @@ -164,9 +159,8 @@ describe('Test Run Flow Integration', () => { describe('Data Clearing Flow', () => { it('should clear online document data', async () => { - const { useOnlineDocument } = await import( - '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', - ) + const { useOnlineDocument } = + await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') const { result } = renderHook(() => useOnlineDocument()) act(() => { @@ -181,9 +175,8 @@ describe('Test Run Flow Integration', () => { }) it('should clear website crawl data', async () => { - const { useWebsiteCrawl } = await import( - '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', - ) + const { useWebsiteCrawl } = + await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') const { result } = renderHook(() => useWebsiteCrawl()) act(() => { @@ -198,9 +191,8 @@ describe('Test Run Flow Integration', () => { }) it('should clear online drive data', async () => { - const { useOnlineDrive } = await import( - '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', - ) + const { useOnlineDrive } = + await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') const { result } = renderHook(() => useOnlineDrive()) act(() => { @@ -217,9 +209,8 @@ describe('Test Run Flow Integration', () => { describe('Full Flow Simulation', () => { it('should support complete step navigation cycle', async () => { - const { useTestRunSteps } = await import( - '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', - ) + const { useTestRunSteps } = + await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') const { result } = renderHook(() => useTestRunSteps()) // Start at step 1 @@ -245,13 +236,8 @@ describe('Test Run Flow Integration', () => { }) it('should not regress when clearing all data sources in sequence', async () => { - const { - useOnlineDocument, - useWebsiteCrawl, - useOnlineDrive, - } = await import( - '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', - ) + const { useOnlineDocument, useWebsiteCrawl, useOnlineDrive } = + await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') const { result: docResult } = renderHook(() => useOnlineDocument()) const { result: crawlResult } = renderHook(() => useWebsiteCrawl()) const { result: driveResult } = renderHook(() => useOnlineDrive()) diff --git a/web/__tests__/real-browser-flicker.test.tsx b/web/__tests__/real-browser-flicker.test.tsx index 288a5198a871e7..92d7d7d5a78503 100644 --- a/web/__tests__/real-browser-flicker.test.tsx +++ b/web/__tests__/real-browser-flicker.test.tsx @@ -17,20 +17,16 @@ const DARK_MODE_MEDIA_QUERY = /prefers-color-scheme:\s*dark/i // Setup browser environment for testing const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = false) => { - if (typeof window === 'undefined') - return + if (typeof window === 'undefined') return try { window.localStorage.clear() - } - catch { + } catch { // ignore if localStorage has been replaced by a throwing stub } - if (storedTheme === null) - window.localStorage.removeItem('theme') - else - window.localStorage.setItem('theme', storedTheme) + if (storedTheme === null) window.localStorage.removeItem('theme') + else window.localStorage.setItem('theme', storedTheme) document.documentElement.removeAttribute('data-theme') @@ -58,7 +54,7 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa } const handleDispatchEvent = (event: Event) => { - listeners.forEach(listener => listener(event as MediaQueryListEvent)) + listeners.forEach((listener) => listener(event as MediaQueryListEvent)) return true } @@ -81,9 +77,13 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa // Helper function to create timing page component const createTimingPageComponent = ( - timingData: Array<{ phase: string, timestamp: number, styles: { backgroundColor: string, color: string } }>, + timingData: Array<{ + phase: string + timestamp: number + styles: { backgroundColor: string; color: string } + }>, ) => { - const recordTiming = (phase: string, styles: { backgroundColor: string, color: string }) => { + const recordTiming = (phase: string, styles: { backgroundColor: string; color: string }) => { timingData.push({ phase, timestamp: performance.now(), @@ -108,21 +108,9 @@ const createTimingPageComponent = ( }, []) return ( - <div - data-testid="timing-page" - style={currentStyles} - > + <div data-testid="timing-page" style={currentStyles}> <div data-testid="timing-status"> - Phase: - {' '} - {mounted ? 'CSR' : 'Initial'} - {' '} - | Theme: - {' '} - {theme} - {' '} - | Visual: - {' '} + Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual:{' '} {isDark ? 'dark' : 'light'} </div> </div> @@ -133,9 +121,7 @@ const createTimingPageComponent = ( } // Helper function to create CSS test component -const createCSSTestComponent = ( - cssStates: Array<{ className: string, timestamp: number }>, -) => { +const createCSSTestComponent = (cssStates: Array<{ className: string; timestamp: number }>) => { const recordCSSState = (className: string) => { cssStates.push({ className, @@ -157,10 +143,7 @@ const createCSSTestComponent = ( }, []) return ( - <div - data-testid="css-component" - className={className} - > + <div data-testid="css-component" className={className}> <div data-testid="css-classes"> Classes: {className} @@ -174,7 +157,7 @@ const createCSSTestComponent = ( // Helper function to create performance test component const createPerformanceTestComponent = ( - performanceMarks: Array<{ event: string, timestamp: number }>, + performanceMarks: Array<{ event: string; timestamp: number }>, ) => { const recordPerformanceMark = (event: string) => { performanceMarks.push({ event, timestamp: performance.now() }) @@ -193,19 +176,12 @@ const createPerformanceTestComponent = ( }, []) useEffect(() => { - if (theme) - recordPerformanceMark('theme-available') + if (theme) recordPerformanceMark('theme-available') }, [theme]) return ( <div data-testid="performance-test"> - Mounted: - {' '} - {mounted.toString()} - {' '} - | Theme: - {' '} - {theme || 'loading'} + Mounted: {mounted.toString()} | Theme: {theme || 'loading'} </div> ) } @@ -227,23 +203,10 @@ const PageComponent = () => { return ( <div data-theme={isDark ? 'dark' : 'light'}> - <div - data-testid="page-content" - style={{ backgroundColor: isDark ? '#1f2937' : '#ffffff' }} - > - <h1 style={{ color: isDark ? '#ffffff' : '#000000' }}> - Dify Application - </h1> - <div data-testid="theme-indicator"> - Current Theme: - {' '} - {mounted ? theme : 'unknown'} - </div> - <div data-testid="visual-appearance"> - Appearance: - {' '} - {isDark ? 'dark' : 'light'} - </div> + <div data-testid="page-content" style={{ backgroundColor: isDark ? '#1f2937' : '#ffffff' }}> + <h1 style={{ color: isDark ? '#ffffff' : '#000000' }}>Dify Application</h1> + <div data-testid="theme-indicator">Current Theme: {mounted ? theme : 'unknown'}</div> + <div data-testid="visual-appearance">Appearance: {isDark ? 'dark' : 'light'}</div> </div> </div> ) @@ -267,8 +230,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { if (typeof window !== 'undefined') { try { window.localStorage.clear() - } - catch { + } catch { // ignore when localStorage is replaced with an error-throwing stub } document.documentElement.removeAttribute('data-theme') @@ -373,7 +335,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { it('measures timing window of style changes', async () => { setupMockEnvironment('dark') - const timingData: Array<{ phase: string, timestamp: number, styles: any }> = [] + const timingData: Array<{ phase: string; timestamp: number; styles: any }> = [] const TimingPageComponent = createTimingPageComponent(timingData) render( @@ -389,17 +351,19 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { // Analyze timing and style changes console.log('\n=== Style Change Timeline ===') timingData.forEach((data, index) => { - console.log(`${index + 1}. ${data.phase}: bg=${data.styles.backgroundColor}, color=${data.styles.color}`) + console.log( + `${index + 1}. ${data.phase}: bg=${data.styles.backgroundColor}, color=${data.styles.color}`, + ) }) // Check if there are style changes (this is visible flicker) - const hasStyleChange = timingData.length > 1 - && timingData[0]!.styles.backgroundColor !== timingData[timingData.length - 1]!.styles.backgroundColor + const hasStyleChange = + timingData.length > 1 && + timingData[0]!.styles.backgroundColor !== + timingData[timingData.length - 1]!.styles.backgroundColor - if (hasStyleChange) - console.log('⚠️ Style changes detected - this causes visible flicker') - else - console.log('✅ No style changes detected') + if (hasStyleChange) console.log('⚠️ Style changes detected - this causes visible flicker') + else console.log('✅ No style changes detected') expect(timingData.length).toBeGreaterThan(1) }) @@ -409,7 +373,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { it('checks CSS class changes causing flicker', async () => { setupMockEnvironment('dark') - const cssStates: Array<{ className: string, timestamp: number }> = [] + const cssStates: Array<{ className: string; timestamp: number }> = [] const CSSTestComponent = createCSSTestComponent(cssStates) render( @@ -428,8 +392,9 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { }) // Check if CSS classes have changed - const hasCSSChange = cssStates.length > 1 - && cssStates[0]!.className !== cssStates[cssStates.length - 1]!.className + const hasCSSChange = + cssStates.length > 1 && + cssStates[0]!.className !== cssStates[cssStates.length - 1]!.className if (hasCSSChange) { console.log('⚠️ CSS class changes detected - may cause style flicker') @@ -474,8 +439,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { // Should default to light theme when localStorage fails // Should default to light theme when localStorage fails expect(screen.getByTestId('visual-appearance'))!.toHaveTextContent('Appearance: light') - } - finally { + } finally { Reflect.deleteProperty(window, 'localStorage') } }) @@ -501,7 +465,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { describe('Performance and Regression Tests', () => { it('verifies ThemeProvider position fix reduces initialization delay', async () => { - const performanceMarks: Array<{ event: string, timestamp: number }> = [] + const performanceMarks: Array<{ event: string; timestamp: number }> = [] setupMockEnvironment('dark') diff --git a/web/__tests__/share/text-generation-index-flow.test.tsx b/web/__tests__/share/text-generation-index-flow.test.tsx index 1295a211cb16eb..dbc2e5428b8858 100644 --- a/web/__tests__/share/text-generation-index-flow.test.tsx +++ b/web/__tests__/share/text-generation-index-flow.test.tsx @@ -50,20 +50,14 @@ vi.mock('@/app/components/share/text-generation/run-once', () => ({ vi.mock('@/app/components/share/text-generation/run-batch', () => ({ default: ({ onSend }: { onSend: (data: string[][]) => void }) => ( - <button - onClick={() => onSend([ - ['Name'], - ['Alpha'], - ['Beta'], - ])} - > - run-batch - </button> + <button onClick={() => onSend([['Name'], ['Alpha'], ['Beta']])}>run-batch</button> ), })) vi.mock('@/app/components/app/text-generate/saved-items', () => ({ - default: ({ list }: { list: { id: string }[] }) => <div data-testid="saved-items-mock">{list.length}</div>, + default: ({ list }: { list: { id: string }[] }) => ( + <div data-testid="saved-items-mock">{list.length}</div> + ), })) vi.mock('@/app/components/share/text-generation/menu-dropdown', () => ({ @@ -78,7 +72,7 @@ vi.mock('@/app/components/share/text-generation/result', () => { taskId, }: { isCallBatchAPI: boolean - onRunControlChange?: (control: { onStop: () => void, isStopping: boolean } | null) => void + onRunControlChange?: (control: { onStop: () => void; isStopping: boolean } | null) => void onRunStart: () => void taskId?: number }) => { @@ -109,7 +103,8 @@ vi.mock('@/service/share', async () => { const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share') return { ...actual, - fetchSavedMessage: (...args: Parameters<typeof actual.fetchSavedMessage>) => fetchSavedMessageMock(...args), + fetchSavedMessage: (...args: Parameters<typeof actual.fetchSavedMessage>) => + fetchSavedMessageMock(...args), removeMessage: vi.fn(), saveMessage: vi.fn(), } @@ -172,7 +167,8 @@ const mockWebAppState = { } vi.mock('@/context/web-app-context', () => ({ - useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => selector(mockWebAppState), + useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => + selector(mockWebAppState), })) describe('TextGeneration', () => { diff --git a/web/__tests__/share/text-generation-mode-flow.test.tsx b/web/__tests__/share/text-generation-mode-flow.test.tsx index a05599250aa2aa..6e51d326f21f25 100644 --- a/web/__tests__/share/text-generation-mode-flow.test.tsx +++ b/web/__tests__/share/text-generation-mode-flow.test.tsx @@ -33,8 +33,12 @@ vi.mock('@/app/components/share/text-generation/text-generation-sidebar', () => }) => ( <div data-testid="text-generation-sidebar"> <span data-testid="current-tab">{currentTab}</span> - <button type="button" onClick={() => onTabChange('batch')}>switch-to-batch</button> - <button type="button" onClick={() => onTabChange('create')}>switch-to-create</button> + <button type="button" onClick={() => onTabChange('batch')}> + switch-to-batch + </button> + <button type="button" onClick={() => onTabChange('create')}> + switch-to-create + </button> </div> ), })) @@ -143,6 +147,9 @@ describe('Text Generation Mode Flow', () => { render(<TextGeneration />) expect(screen.getByTestId('current-tab')).toHaveTextContent('create') - expect(screen.getByTestId('text-generation-result-panel')).toHaveAttribute('data-batch', 'false') + expect(screen.getByTestId('text-generation-result-panel')).toHaveAttribute( + 'data-batch', + 'false', + ) }) }) diff --git a/web/__tests__/share/text-generation-run-batch-flow.test.tsx b/web/__tests__/share/text-generation-run-batch-flow.test.tsx index a511527e16d8d7..c28305835bce71 100644 --- a/web/__tests__/share/text-generation-run-batch-flow.test.tsx +++ b/web/__tests__/share/text-generation-run-batch-flow.test.tsx @@ -25,9 +25,7 @@ vi.mock('@/app/components/share/text-generation/run-batch/csv-reader', () => ({ vi.mock('@/app/components/share/text-generation/run-batch/csv-download', () => ({ default: ({ vars }: { vars: { name: string }[] }) => ( - <div data-testid="csv-download"> - {vars.map(v => v.name).join(', ')} - </div> + <div data-testid="csv-download">{vars.map((v) => v.name).join(', ')}</div> ), })) @@ -42,9 +40,7 @@ describe('RunBatch – integration flow', () => { it('full lifecycle: upload CSV → run → finish → run again', async () => { const onSend = vi.fn() - const { rerender } = render( - <RunBatch vars={vars} onSend={onSend} isAllFinished />, - ) + const { rerender } = render(<RunBatch vars={vars} onSend={onSend} isAllFinished />) // Phase 1 – verify child components rendered expect(screen.getByTestId('csv-reader')).toBeInTheDocument() @@ -102,9 +98,7 @@ describe('RunBatch – integration flow', () => { it('should show spinner icon when results are still running', async () => { const onSend = vi.fn() - const { container } = render( - <RunBatch vars={vars} onSend={onSend} isAllFinished={false} />, - ) + const { container } = render(<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />) // Upload CSV first await act(async () => { diff --git a/web/__tests__/share/text-generation-run-once-flow.test.tsx b/web/__tests__/share/text-generation-run-once-flow.test.tsx index 1471effa2d3271..b09da97ce9442c 100644 --- a/web/__tests__/share/text-generation-run-once-flow.test.tsx +++ b/web/__tests__/share/text-generation-run-once-flow.test.tsx @@ -20,8 +20,12 @@ vi.mock('@/hooks/use-breakpoints', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ value, onChange }: { value?: string, onChange?: (val: string) => void }) => ( - <textarea data-testid="code-editor" value={value ?? ''} onChange={e => onChange?.(e.target.value)} /> + default: ({ value, onChange }: { value?: string; onChange?: (val: string) => void }) => ( + <textarea + data-testid="code-editor" + value={value ?? ''} + onChange={(e) => onChange?.(e.target.value)} + /> ), })) @@ -105,9 +109,7 @@ describe('RunOnce – integration flow', () => { } // Phase 1 – render, wait for initialisation - const { rerender } = render( - <Harness promptConfig={config} onSendSpy={onSend} />, - ) + const { rerender } = render(<Harness promptConfig={config} onSendSpy={onSend} />) await waitFor(() => { expect(screen.getByPlaceholderText('Name')).toBeInTheDocument() @@ -132,7 +134,9 @@ describe('RunOnce – integration flow', () => { />, ) - const stopBtn = screen.getByRole('button', { name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}' }) + const stopBtn = screen.getByRole('button', { + name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}', + }) expect(stopBtn).toBeInTheDocument() fireEvent.click(stopBtn) expect(onStop).toHaveBeenCalledTimes(1) @@ -145,7 +149,9 @@ describe('RunOnce – integration flow', () => { runControl={{ onStop, isStopping: true }} />, ) - expect(screen.getByRole('button', { name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}' })).toBeDisabled() + expect( + screen.getByRole('button', { name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}' }), + ).toBeDisabled() }) it('clear resets all field types and allows re-submit', async () => { diff --git a/web/__tests__/tools/provider-list-shell-flow.test.tsx b/web/__tests__/tools/provider-list-shell-flow.test.tsx index 1ffd9bf2fcf184..e1f8c5ef980fda 100644 --- a/web/__tests__/tools/provider-list-shell-flow.test.tsx +++ b/web/__tests__/tools/provider-list-shell-flow.test.tsx @@ -52,30 +52,39 @@ vi.mock('@/service/use-plugins', () => ({ useCheckInstalled: ({ enabled }: { enabled: boolean }) => ({ data: enabled ? { - plugins: [{ - plugin_id: 'langgenius/plugin-tool', - declaration: { - category: 'tool', + plugins: [ + { + plugin_id: 'langgenius/plugin-tool', + declaration: { + category: 'tool', + }, }, - }], + ], } : null, }), useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, useMutationPluginPermissionSettings: () => ({ mutate: vi.fn(), isPending: false }), - usePluginPermissionSettings: () => ({ data: undefined, isLoading: false, isFetching: false, error: null }), + usePluginPermissionSettings: () => ({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + }), })) vi.mock('@/app/components/tools/labels/filter', () => ({ default: ({ onChange }: { onChange: (value: string[]) => void }) => ( <div data-testid="tool-label-filter"> - <button type="button" onClick={() => onChange(['search'])}>apply-search-filter</button> + <button type="button" onClick={() => onChange(['search'])}> + apply-search-filter + </button> </div> ), })) vi.mock('@/app/components/plugins/card', () => ({ - default: ({ payload, className }: { payload: { name: string }, className?: string }) => ( + default: ({ payload, className }: { payload: { name: string }; className?: string }) => ( <div data-testid={`tool-card-${payload.name}`} className={className}> {payload.name} </div> @@ -83,14 +92,18 @@ vi.mock('@/app/components/plugins/card', () => ({ })) vi.mock('@/app/components/plugins/card/card-more-info', () => ({ - default: ({ tags }: { tags: string[] }) => <div data-testid="tool-card-more-info">{tags.join(',')}</div>, + default: ({ tags }: { tags: string[] }) => ( + <div data-testid="tool-card-more-info">{tags.join(',')}</div> + ), })) vi.mock('@/app/components/tools/provider/detail', () => ({ - default: ({ collection, onHide }: { collection: { name: string }, onHide: () => void }) => ( + default: ({ collection, onHide }: { collection: { name: string }; onHide: () => void }) => ( <div data-testid="tool-provider-detail"> <span>{collection.name}</span> - <button type="button" onClick={onHide}>close-provider-detail</button> + <button type="button" onClick={onHide}> + close-provider-detail + </button> </div> ), })) @@ -104,15 +117,18 @@ vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ detail?: { plugin_id: string } onHide: () => void onUpdate: () => void - }) => detail - ? ( - <div data-testid="tool-plugin-detail-panel"> - <span>{detail.plugin_id}</span> - <button type="button" onClick={onUpdate}>update-plugin-detail</button> - <button type="button" onClick={onHide}>close-plugin-detail</button> - </div> - ) - : null, + }) => + detail ? ( + <div data-testid="tool-plugin-detail-panel"> + <span>{detail.plugin_id}</span> + <button type="button" onClick={onUpdate}> + update-plugin-detail + </button> + <button type="button" onClick={onHide}> + close-plugin-detail + </button> + </div> + ) : null, })) vi.mock('@/app/components/tools/provider/empty', () => ({ @@ -131,7 +147,12 @@ vi.mock('@/app/components/tools/marketplace', () => ({ isMarketplaceArrowVisible: boolean showMarketplacePanel: () => void }) => ( - <button type="button" data-testid="marketplace-arrow" data-visible={String(isMarketplaceArrowVisible)} onClick={showMarketplacePanel}> + <button + type="button" + data-testid="marketplace-arrow" + data-visible={String(isMarketplaceArrowVisible)} + onClick={showMarketplacePanel} + > marketplace-arrow </button> ), @@ -144,7 +165,9 @@ vi.mock('@/app/components/tools/marketplace/hooks', () => ({ })) vi.mock('@/app/components/tools/mcp', () => ({ - default: ({ searchText }: { searchText: string }) => <div data-testid="mcp-list">{searchText}</div>, + default: ({ searchText }: { searchText: string }) => ( + <div data-testid="mcp-list">{searchText}</div> + ), })) vi.mock('@/app/components/header/account-setting/update-setting-dialog', () => ({ diff --git a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx index 885a22bbcc10f8..1392c9dc6045e3 100644 --- a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx +++ b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx @@ -7,7 +7,6 @@ import type { Collection } from '@/app/components/tools/types' * filtering, and label filtering work together correctly. */ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' - import { beforeEach, describe, expect, it, vi } from 'vitest' import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import { CollectionType } from '@/app/components/tools/types' @@ -16,19 +15,19 @@ import { CollectionType } from '@/app/components/tools/types' vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey((key: string) => { const map: Record<string, string> = { 'type.builtIn': 'Built-in', 'type.custom': 'Custom', 'type.workflow': 'Workflow', - 'noTools': 'No tools found', + noTools: 'No tools found', } return map[key] ?? key }), }), - }) + } }) vi.mock('nuqs', async (importOriginal) => { @@ -50,7 +49,12 @@ vi.mock('@/service/use-plugins', () => ({ useCheckInstalled: () => ({ data: null }), useInvalidateInstalledPluginList: () => vi.fn(), useMutationPluginPermissionSettings: () => ({ mutate: vi.fn(), isPending: false }), - usePluginPermissionSettings: () => ({ data: undefined, isLoading: false, isFetching: false, error: null }), + usePluginPermissionSettings: () => ({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + }), })) const mockCollections: Collection[] = [ @@ -118,9 +122,17 @@ vi.mock('@/service/use-tools', () => ({ })) vi.mock('@/app/components/base/tab-slider-new', () => ({ - default: ({ value, onChange, options }: { value: string, onChange: (v: string) => void, options: Array<{ value: string, text: string }> }) => ( + default: ({ + value, + onChange, + options, + }: { + value: string + onChange: (v: string) => void + options: Array<{ value: string; text: string }> + }) => ( <div data-testid="tab-slider"> - {options.map((opt: { value: string, text: string }) => ( + {options.map((opt: { value: string; text: string }) => ( <button key={opt.value} data-testid={`tab-${opt.value}`} @@ -135,7 +147,13 @@ vi.mock('@/app/components/base/tab-slider-new', () => ({ })) vi.mock('@/app/components/plugins/card', () => ({ - default: ({ payload, className }: { payload: { brief: Record<string, string> | string, name: string }, className?: string }) => { + default: ({ + payload, + className, + }: { + payload: { brief: Record<string, string> | string; name: string } + className?: string + }) => { const briefText = typeof payload.brief === 'object' ? payload.brief?.en_US || '' : payload.brief return ( <div data-testid={`card-${payload.name}`} className={className}> @@ -153,11 +171,17 @@ vi.mock('@/app/components/plugins/card/card-more-info', () => ({ })) vi.mock('@/app/components/tools/labels/filter', () => ({ - default: ({ value: _value, onChange }: { value: string[], onChange: (v: string[]) => void }) => ( + default: ({ value: _value, onChange }: { value: string[]; onChange: (v: string[]) => void }) => ( <div data-testid="label-filter"> - <button data-testid="filter-search" onClick={() => onChange(['search'])}>Filter: search</button> - <button data-testid="filter-utility" onClick={() => onChange(['utility'])}>Filter: utility</button> - <button data-testid="filter-clear" onClick={() => onChange([])}>Clear filter</button> + <button data-testid="filter-search" onClick={() => onChange(['search'])}> + Filter: search + </button> + <button data-testid="filter-utility" onClick={() => onChange(['utility'])}> + Filter: utility + </button> + <button data-testid="filter-clear" onClick={() => onChange([])}> + Clear filter + </button> </div> ), })) @@ -167,10 +191,12 @@ vi.mock('@/app/components/tools/provider/custom-create-card', () => ({ })) vi.mock('@/app/components/tools/provider/detail', () => ({ - default: ({ collection, onHide }: { collection: Collection, onHide: () => void }) => ( + default: ({ collection, onHide }: { collection: Collection; onHide: () => void }) => ( <div data-testid="provider-detail"> <span data-testid="detail-name">{collection.name}</span> - <button data-testid="detail-close" onClick={onHide}>Close</button> + <button data-testid="detail-close" onClick={onHide}> + Close + </button> </div> ), })) @@ -180,9 +206,12 @@ vi.mock('@/app/components/tools/provider/empty', () => ({ })) vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ - default: ({ detail, onHide }: { detail: unknown, onHide: () => void }) => ( - detail ? <div data-testid="plugin-detail-panel"><button onClick={onHide}>Close</button></div> : null - ), + default: ({ detail, onHide }: { detail: unknown; onHide: () => void }) => + detail ? ( + <div data-testid="plugin-detail-panel"> + <button onClick={onHide}>Close</button> + </div> + ) : null, })) vi.mock('@/app/components/plugins/marketplace/empty', () => ({ diff --git a/web/__tests__/tools/tool-data-processing.test.ts b/web/__tests__/tools/tool-data-processing.test.ts index 69be82447acbe0..52ba0fc3a2f0af 100644 --- a/web/__tests__/tools/tool-data-processing.test.ts +++ b/web/__tests__/tools/tool-data-processing.test.ts @@ -6,7 +6,6 @@ * raw API data → form schemas → form values → configured values. */ import { describe, expect, it } from 'vitest' - import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils/index' import { addDefaultValue, @@ -48,7 +47,9 @@ describe('Tool Data Processing Pipeline Integration', () => { }, ] - const formSchemas = toolParametersToFormSchemas(rawParameters as unknown as Parameters<typeof toolParametersToFormSchemas>[0]) + const formSchemas = toolParametersToFormSchemas( + rawParameters as unknown as Parameters<typeof toolParametersToFormSchemas>[0], + ) expect(formSchemas).toHaveLength(2) expect(formSchemas[0]!.variable).toBe('query') expect(formSchemas[0]!.required).toBe(true) @@ -81,7 +82,9 @@ describe('Tool Data Processing Pipeline Integration', () => { }, ] - const credentialSchemas = toolCredentialToFormSchemas(rawCredentials as Parameters<typeof toolCredentialToFormSchemas>[0]) + const credentialSchemas = toolCredentialToFormSchemas( + rawCredentials as Parameters<typeof toolCredentialToFormSchemas>[0], + ) expect(credentialSchemas).toHaveLength(1) expect(credentialSchemas[0]!.variable).toBe('api_key') expect(credentialSchemas[0]!.required).toBe(true) @@ -105,7 +108,9 @@ describe('Tool Data Processing Pipeline Integration', () => { }, ] - const schemas = triggerEventParametersToFormSchemas(rawParams as unknown as Parameters<typeof triggerEventParametersToFormSchemas>[0]) + const schemas = triggerEventParametersToFormSchemas( + rawParams as unknown as Parameters<typeof triggerEventParametersToFormSchemas>[0], + ) expect(schemas).toHaveLength(1) expect(schemas[0]!.name).toBe('event_type') expect(schemas[0]!.type).toBe('select') @@ -166,9 +171,7 @@ describe('Tool Data Processing Pipeline Integration', () => { }) it('preserves existing values in getConfiguredValue', () => { - const formSchemas = [ - { variable: 'query', type: 'text-input', default: 'default-query' }, - ] + const formSchemas = [{ variable: 'query', type: 'text-input', default: 'default-query' }] const configured = getConfiguredValue({ query: 'my-existing-query' }, formSchemas) expect(configured.query).toBe('my-existing-query') @@ -228,9 +231,7 @@ describe('Tool Data Processing Pipeline Integration', () => { }) it('handles boolean type conversion in defaults', () => { - const schemas = [ - { variable: 'enabled', type: 'boolean', default: 'true', name: 'enabled' }, - ] + const schemas = [{ variable: 'enabled', type: 'boolean', default: 'true', name: 'enabled' }] const result = addDefaultValue({ enabled: 'true' }, schemas) expect(result.enabled).toBe(true) diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index 5ff11507456a14..6776590457baf6 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -8,13 +8,12 @@ import type { Collection } from '@/app/components/tools/types' * handle auth/edit/delete flows. */ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' - import { beforeEach, describe, expect, it, vi } from 'vitest' import { CollectionType } from '@/app/components/tools/types' vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey((key: string, opts?: Record<string, unknown>) => { const map: Record<string, string> = { @@ -26,17 +25,15 @@ vi.mock('react-i18next', async () => { 'createTool.deleteToolConfirmContent': 'Are you sure?', 'createTool.toolInput.title': 'Tool Input', 'createTool.toolInput.required': 'Required', - 'openInStudio': 'Open in Studio', + openInStudio: 'Open in Studio', 'api.actionSuccess': 'Action succeeded', } - if (key === 'detailPanel.actionNum') - return `${opts?.num ?? 0} actions` - if (key === 'includeToolNum') - return `${opts?.num ?? 0} actions` + if (key === 'detailPanel.actionNum') return `${opts?.num ?? 0} actions` + if (key === 'includeToolNum') return `${opts?.num ?? 0} actions` return map[key] ?? key }), }), - }) + } }) vi.mock('@/context/i18n', () => ({ @@ -51,40 +48,66 @@ vi.mock('@/context/account-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateAtomMock(importOriginal, () => ({ isCurrentWorkspaceManager: true, - workspacePermissionKeys: ['tool.manage', 'credential.create', 'credential.manage', 'credential.use'], + workspacePermissionKeys: [ + 'tool.manage', + 'credential.create', + 'credential.manage', + 'credential.use', + ], })) }) vi.mock('@/context/workspace-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateAtomMock(importOriginal, () => ({ isCurrentWorkspaceManager: true, - workspacePermissionKeys: ['tool.manage', 'credential.create', 'credential.manage', 'credential.use'], + workspacePermissionKeys: [ + 'tool.manage', + 'credential.create', + 'credential.manage', + 'credential.use', + ], })) }) vi.mock('@/context/permission-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateAtomMock(importOriginal, () => ({ isCurrentWorkspaceManager: true, - workspacePermissionKeys: ['tool.manage', 'credential.create', 'credential.manage', 'credential.use'], + workspacePermissionKeys: [ + 'tool.manage', + 'credential.create', + 'credential.manage', + 'credential.use', + ], })) }) vi.mock('@/context/version-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateAtomMock(importOriginal, () => ({ isCurrentWorkspaceManager: true, - workspacePermissionKeys: ['tool.manage', 'credential.create', 'credential.manage', 'credential.use'], + workspacePermissionKeys: [ + 'tool.manage', + 'credential.create', + 'credential.manage', + 'credential.use', + ], })) }) vi.mock('@/context/system-features-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateAtomMock(importOriginal, () => ({ isCurrentWorkspaceManager: true, - workspacePermissionKeys: ['tool.manage', 'credential.create', 'credential.manage', 'credential.use'], + workspacePermissionKeys: [ + 'tool.manage', + 'credential.create', + 'credential.manage', + 'credential.use', + ], })) }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -97,9 +120,7 @@ vi.mock('@/context/modal-context', () => ({ vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ - modelProviders: [ - { provider: 'model-provider-1', name: 'Model Provider 1' }, - ], + modelProviders: [{ provider: 'model-provider-1', name: 'Model Provider 1' }], }), })) @@ -118,7 +139,13 @@ const mockFetchWorkflowToolDetail = vi.fn().mockResolvedValue({ workflow_app_id: 'app-123', tool: { parameters: [ - { name: 'query', llm_description: 'Search query', form: 'text', required: true, type: 'string' }, + { + name: 'query', + llm_description: 'Search query', + form: 'text', + required: true, + type: 'string', + }, ], labels: ['search'], }, @@ -182,7 +209,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/declaration })) vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ - default: ({ src }: { src: string }) => <div data-testid="card-icon" data-src={typeof src === 'string' ? src : 'emoji'} />, + default: ({ src }: { src: string }) => ( + <div data-testid="card-icon" data-src={typeof src === 'string' ? src : 'emoji'} /> + ), })) vi.mock('@/app/components/plugins/card/base/description', () => ({ @@ -190,13 +219,9 @@ vi.mock('@/app/components/plugins/card/base/description', () => ({ })) vi.mock('@/app/components/plugins/card/base/org-info', () => ({ - default: ({ orgName, packageName }: { orgName: string, packageName: string }) => ( + default: ({ orgName, packageName }: { orgName: string; packageName: string }) => ( <div data-testid="org-info"> - {orgName} - {' '} - / - {' '} - {packageName} + {orgName} / {packageName} </div> ), })) @@ -206,31 +231,79 @@ vi.mock('@/app/components/plugins/card/base/title', () => ({ })) vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({ - default: ({ onHide, onEdit, onRemove }: { onHide: () => void, onEdit: (data: unknown) => void, onRemove: () => void, payload: unknown }) => ( + default: ({ + onHide, + onEdit, + onRemove, + }: { + onHide: () => void + onEdit: (data: unknown) => void + onRemove: () => void + payload: unknown + }) => ( <div data-testid="edit-custom-modal"> - <button data-testid="custom-modal-hide" onClick={onHide}>Hide</button> - <button data-testid="custom-modal-save" onClick={() => onEdit({ name: 'updated', labels: [] })}>Save</button> - <button data-testid="custom-modal-remove" onClick={onRemove}>Remove</button> + <button data-testid="custom-modal-hide" onClick={onHide}> + Hide + </button> + <button + data-testid="custom-modal-save" + onClick={() => onEdit({ name: 'updated', labels: [] })} + > + Save + </button> + <button data-testid="custom-modal-remove" onClick={onRemove}> + Remove + </button> </div> ), })) vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({ - default: ({ onCancel, onSaved, onRemove }: { collection: Collection, onCancel: () => void, onSaved: (v: Record<string, unknown>) => void, onRemove: () => void }) => ( + default: ({ + onCancel, + onSaved, + onRemove, + }: { + collection: Collection + onCancel: () => void + onSaved: (v: Record<string, unknown>) => void + onRemove: () => void + }) => ( <div data-testid="config-credential"> - <button data-testid="cred-cancel" onClick={onCancel}>Cancel</button> - <button data-testid="cred-save" onClick={() => onSaved({ api_key: 'test-key' })}>Save</button> - <button data-testid="cred-remove" onClick={onRemove}>Remove</button> + <button data-testid="cred-cancel" onClick={onCancel}> + Cancel + </button> + <button data-testid="cred-save" onClick={() => onSaved({ api_key: 'test-key' })}> + Save + </button> + <button data-testid="cred-remove" onClick={onRemove}> + Remove + </button> </div> ), })) vi.mock('@/app/components/tools/workflow-tool', () => ({ - WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => ( + WorkflowToolDrawer: ({ + onHide, + onSave, + onRemove, + }: { + payload: unknown + onHide: () => void + onSave: (d: unknown) => void + onRemove: () => void + }) => ( <div data-testid="workflow-tool-modal"> - <button data-testid="wf-modal-hide" onClick={onHide}>Hide</button> - <button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}>Save</button> - <button data-testid="wf-modal-remove" onClick={onRemove}>Remove</button> + <button data-testid="wf-modal-hide" onClick={onHide}> + Hide + </button> + <button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}> + Save + </button> + <button data-testid="wf-modal-remove" onClick={onRemove}> + Remove + </button> </div> ), })) @@ -272,7 +345,13 @@ describe('Tool Provider Detail Flow Integration', () => { describe('Built-in Provider', () => { it('renders provider detail with title, author, and description', async () => { const collection = makeCollection() - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(screen.getByTestId('title')).toHaveTextContent('Test Collection') @@ -283,7 +362,13 @@ describe('Tool Provider Detail Flow Integration', () => { it('loads tool list from API on mount', async () => { const collection = makeCollection() - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(mockFetchBuiltInToolList).toHaveBeenCalledWith('test_collection') @@ -300,7 +385,13 @@ describe('Tool Provider Detail Flow Integration', () => { allow_delete: true, is_team_authorization: false, }) - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(screen.getByText('Set up credentials')).toBeInTheDocument() @@ -312,7 +403,13 @@ describe('Tool Provider Detail Flow Integration', () => { allow_delete: true, is_team_authorization: true, }) - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(screen.getByText('Authorized')).toBeInTheDocument() @@ -325,7 +422,13 @@ describe('Tool Provider Detail Flow Integration', () => { allow_delete: true, is_team_authorization: false, }) - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(screen.getByText('Set up credentials')).toBeInTheDocument() @@ -342,7 +445,13 @@ describe('Tool Provider Detail Flow Integration', () => { allow_delete: true, is_team_authorization: false, }) - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(screen.getByText('Set up credentials')).toBeInTheDocument() @@ -355,7 +464,9 @@ describe('Tool Provider Detail Flow Integration', () => { fireEvent.click(screen.getByTestId('cred-save')) await waitFor(() => { - expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test_collection', { api_key: 'test-key' }) + expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test_collection', { + api_key: 'test-key', + }) expect(mockOnRefreshData).toHaveBeenCalled() }) }) @@ -365,7 +476,13 @@ describe('Tool Provider Detail Flow Integration', () => { allow_delete: true, is_team_authorization: false, }) - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { fireEvent.click(screen.getByText('Set up credentials')) @@ -391,7 +508,13 @@ describe('Tool Provider Detail Flow Integration', () => { allow_delete: true, is_team_authorization: false, }) - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(screen.getByText('Set up credentials')).toBeInTheDocument() @@ -416,7 +539,13 @@ describe('Tool Provider Detail Flow Integration', () => { type: CollectionType.custom, allow_delete: true, }) - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(mockFetchCustomCollection).toHaveBeenCalledWith('test_collection') @@ -432,7 +561,13 @@ describe('Tool Provider Detail Flow Integration', () => { type: CollectionType.custom, allow_delete: true, }) - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(screen.getByText('Edit')).toBeInTheDocument() @@ -455,7 +590,13 @@ describe('Tool Provider Detail Flow Integration', () => { type: CollectionType.custom, allow_delete: true, }) - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(screen.getByText('Edit')).toBeInTheDocument() @@ -485,7 +626,13 @@ describe('Tool Provider Detail Flow Integration', () => { type: CollectionType.workflow, allow_delete: true, }) - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-collection') @@ -502,7 +649,13 @@ describe('Tool Provider Detail Flow Integration', () => { type: CollectionType.workflow, allow_delete: true, }) - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(screen.getByText('query')).toBeInTheDocument() @@ -516,7 +669,13 @@ describe('Tool Provider Detail Flow Integration', () => { type: CollectionType.workflow, allow_delete: true, }) - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(screen.getByText('Edit')).toBeInTheDocument() @@ -543,7 +702,13 @@ describe('Tool Provider Detail Flow Integration', () => { describe('Drawer Interaction', () => { it('calls onHide when closing the drawer', async () => { const collection = makeCollection() - render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />) + render( + <ProviderDetail + collection={collection} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) await waitFor(() => { expect(screen.getByRole('dialog')).toBeInTheDocument() diff --git a/web/__tests__/unified-tags-logic.test.ts b/web/__tests__/unified-tags-logic.test.ts index 25324b65838894..eb17b9f730ee88 100644 --- a/web/__tests__/unified-tags-logic.test.ts +++ b/web/__tests__/unified-tags-logic.test.ts @@ -12,10 +12,10 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { const newSelectedTagIDs = ['tag1', 'tag3'] // This is the valueNotChanged logic from TagSelector component - const valueNotChanged - = currentValue.length === newSelectedTagIDs.length - && currentValue.every(v => newSelectedTagIDs.includes(v)) - && newSelectedTagIDs.every(v => currentValue.includes(v)) + const valueNotChanged = + currentValue.length === newSelectedTagIDs.length && + currentValue.every((v) => newSelectedTagIDs.includes(v)) && + newSelectedTagIDs.every((v) => currentValue.includes(v)) expect(valueNotChanged).toBe(false) }) @@ -24,10 +24,10 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { const currentValue = ['tag1', 'tag2'] const newSelectedTagIDs = ['tag2', 'tag1'] // Same tags, different order - const valueNotChanged - = currentValue.length === newSelectedTagIDs.length - && currentValue.every(v => newSelectedTagIDs.includes(v)) - && newSelectedTagIDs.every(v => currentValue.includes(v)) + const valueNotChanged = + currentValue.length === newSelectedTagIDs.length && + currentValue.every((v) => newSelectedTagIDs.includes(v)) && + newSelectedTagIDs.every((v) => currentValue.includes(v)) expect(valueNotChanged).toBe(true) }) @@ -37,8 +37,8 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { const selectedTagIDs = ['tag2', 'tag3'] // This is the handleValueChange logic from TagSelector - const addTagIDs = selectedTagIDs.filter(v => !currentValue.includes(v)) - const removeTagIDs = currentValue.filter(v => !selectedTagIDs.includes(v)) + const addTagIDs = selectedTagIDs.filter((v) => !currentValue.includes(v)) + const removeTagIDs = currentValue.filter((v) => !selectedTagIDs.includes(v)) expect(addTagIDs).toEqual(['tag3']) expect(removeTagIDs).toEqual(['tag1']) @@ -48,8 +48,8 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { const currentValue: string[] = [] const selectedTagIDs = ['tag1'] - const addTagIDs = selectedTagIDs.filter(v => !currentValue.includes(v)) - const removeTagIDs = currentValue.filter(v => !selectedTagIDs.includes(v)) + const addTagIDs = selectedTagIDs.filter((v) => !currentValue.includes(v)) + const removeTagIDs = currentValue.filter((v) => !selectedTagIDs.includes(v)) expect(addTagIDs).toEqual(['tag1']) expect(removeTagIDs).toEqual([]) @@ -60,8 +60,8 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { const currentValue = ['tag1', 'tag2'] const selectedTagIDs: string[] = [] - const addTagIDs = selectedTagIDs.filter(v => !currentValue.includes(v)) - const removeTagIDs = currentValue.filter(v => !selectedTagIDs.includes(v)) + const addTagIDs = selectedTagIDs.filter((v) => !currentValue.includes(v)) + const removeTagIDs = currentValue.filter((v) => !selectedTagIDs.includes(v)) expect(addTagIDs).toEqual([]) expect(removeTagIDs).toEqual(['tag1', 'tag2']) @@ -70,7 +70,7 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { }) describe('Fallback Logic (from layout-main.tsx)', () => { - type Tag = { id: string, name: string } + type Tag = { id: string; name: string } type AppDetail = { tags: Tag[] } type FallbackResult = { tags?: Tag[] } | null // no-op @@ -82,7 +82,8 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { // This simulates the condition in layout-main.tsx const shouldFallback1 = appDetailWithoutTags.tags.length === 0 const shouldFallback2 = appDetailWithTags.tags.length === 0 - const shouldFallback3 = !appDetailWithUndefinedTags.tags || appDetailWithUndefinedTags.tags.length === 0 + const shouldFallback3 = + !appDetailWithUndefinedTags.tags || appDetailWithUndefinedTags.tags.length === 0 expect(shouldFallback1).toBe(true) // Empty array should trigger fallback expect(shouldFallback2).toBe(false) // Has tags, no fallback needed @@ -95,8 +96,7 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { // This simulates the successful fallback in layout-main.tsx const tags = fallbackResult.tags - if (tags) - originalAppDetail.tags = tags + if (tags) originalAppDetail.tags = tags expect(originalAppDetail.tags).toEqual(fallbackResult.tags) expect(originalAppDetail.tags.length).toBe(1) @@ -107,9 +107,9 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { const fallbackResult = null as FallbackResult // This simulates fallback failure in layout-main.tsx - const tags: Tag[] | undefined = fallbackResult && 'tags' in fallbackResult ? fallbackResult.tags : undefined - if (tags) - originalAppDetail.tags = tags + const tags: Tag[] | undefined = + fallbackResult && 'tags' in fallbackResult ? fallbackResult.tags : undefined + if (tags) originalAppDetail.tags = tags expect(originalAppDetail.tags).toEqual([]) }) @@ -124,8 +124,7 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { } // This simulates the useEffect in TagSelector - if (tagList.length === 0) - getTagList() + if (tagList.length === 0) getTagList() expect(getTagListCalled).toBe(true) }) @@ -138,8 +137,7 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { } // This simulates the useEffect in TagSelector - if (tagList.length === 0) - getTagList() + if (tagList.length === 0) getTagList() expect(getTagListCalled).toBe(false) }) @@ -215,7 +213,7 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { const targetAppId = 'test-app-id' // This simulates the logic in fetchAppWithTags - const foundApp = appList.find(app => app.id === targetAppId) + const foundApp = appList.find((app) => app.id === targetAppId) expect(foundApp).toBeDefined() expect(foundApp?.id).toBe('test-app-id') @@ -229,7 +227,7 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { ] const targetAppId = 'nonexistent-app' - const foundApp = appList.find(app => app.id === targetAppId) || null + const foundApp = appList.find((app) => app.id === targetAppId) || null expect(foundApp).toBeNull() }) @@ -238,7 +236,7 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { const appList: any[] = [] const targetAppId = 'any-app' - const foundApp = appList.find(app => app.id === targetAppId) || null + const foundApp = appList.find((app) => app.id === targetAppId) || null expect(foundApp).toBeNull() expect(appList.length).toBe(0) // Verify empty array usage @@ -270,16 +268,14 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { expect(Array.isArray(tags)).toBe(true) expect(tags.length).toBe(2) - expect(tags.every(tag => tag.type === 'app')).toBe(true) + expect(tags.every((tag) => tag.type === 'app')).toBe(true) }) it('should validate app data structure with tags', () => { const app = { id: 'test-app', name: 'Test App', - tags: [ - { id: 'tag1', name: 'Tag 1', type: 'app', binding_count: '' }, - ], + tags: [{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: '' }], } expect(app).toHaveProperty('id') @@ -297,8 +293,8 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { // Performance test: filtering should be efficient const startTime = Date.now() - const addTags = selectedTags.filter(tag => !largeTags.includes(tag)) - const removeTags = largeTags.filter(tag => !selectedTags.includes(tag)) + const addTags = selectedTags.filter((tag) => !largeTags.includes(tag)) + const removeTags = largeTags.filter((tag) => !selectedTags.includes(tag)) const endTime = Date.now() expect(endTime - startTime).toBeLessThan(10) // Should be very fast @@ -316,18 +312,19 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { ] // Filter out invalid entries - const validTags = mixedData.filter((tag): tag is { id: string, name: string, type: string, binding_count: string } => - tag != null - && typeof tag === 'object' - && 'id' in tag - && 'name' in tag - && 'type' in tag - && 'binding_count' in tag - && typeof tag.binding_count === 'string', + const validTags = mixedData.filter( + (tag): tag is { id: string; name: string; type: string; binding_count: string } => + tag != null && + typeof tag === 'object' && + 'id' in tag && + 'name' in tag && + 'type' in tag && + 'binding_count' in tag && + typeof tag.binding_count === 'string', ) expect(validTags.length).toBe(2) - expect(validTags.every(tag => tag.id && tag.name)).toBe(true) + expect(validTags.every((tag) => tag.id && tag.name)).toBe(true) }) it('should handle concurrent tag operations correctly', () => { @@ -338,14 +335,14 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { ] // Simulate processing operations - const results = operations.map(op => ({ + const results = operations.map((op) => ({ ...op, processed: true, timestamp: Date.now(), })) expect(results.length).toBe(3) - expect(results.every(result => result.processed)).toBe(true) + expect(results.every((result) => result.processed)).toBe(true) }) }) diff --git a/web/__tests__/utils/mock-app-context-state.ts b/web/__tests__/utils/mock-app-context-state.ts index 2caf653bbcc69b..58f1d4d7817406 100644 --- a/web/__tests__/utils/mock-app-context-state.ts +++ b/web/__tests__/utils/mock-app-context-state.ts @@ -12,10 +12,12 @@ export type AppContextStateMockState = { avatar_url?: string | null is_password_set?: boolean } | null - currentWorkspace?: ({ - id?: string - name?: string - } & Partial<ICurrentWorkspace>) | null + currentWorkspace?: + | ({ + id?: string + name?: string + } & Partial<ICurrentWorkspace>) + | null isCurrentWorkspaceManager?: boolean isCurrentWorkspaceOwner?: boolean isCurrentWorkspaceEditor?: boolean @@ -31,24 +33,24 @@ export type AppContextStateMockState = { mutateCurrentWorkspace?: () => void } -type AppContextStateAtomKind - = | 'userProfile' - | 'userProfileId' - | 'userProfileEmail' - | 'currentWorkspace' - | 'currentWorkspaceId' - | 'isCurrentWorkspaceManager' - | 'isCurrentWorkspaceOwner' - | 'isCurrentWorkspaceEditor' - | 'isCurrentWorkspaceDatasetOperator' - | 'currentWorkspaceLoading' - | 'workspacePermissionKeys' - | 'workspacePermissionKeysLoading' - | 'datasetRbacEnabled' - | 'langGeniusVersionInfo' - | 'langGeniusCurrentVersion' - | 'refreshUserProfile' - | 'refreshCurrentWorkspace' +type AppContextStateAtomKind = + | 'userProfile' + | 'userProfileId' + | 'userProfileEmail' + | 'currentWorkspace' + | 'currentWorkspaceId' + | 'isCurrentWorkspaceManager' + | 'isCurrentWorkspaceOwner' + | 'isCurrentWorkspaceEditor' + | 'isCurrentWorkspaceDatasetOperator' + | 'currentWorkspaceLoading' + | 'workspacePermissionKeys' + | 'workspacePermissionKeysLoading' + | 'datasetRbacEnabled' + | 'langGeniusVersionInfo' + | 'langGeniusCurrentVersion' + | 'refreshUserProfile' + | 'refreshCurrentWorkspace' type AppContextStateMockAtom = { [APP_CONTEXT_STATE_ATOM_KIND]: AppContextStateAtomKind @@ -96,9 +98,7 @@ const defaultLangGeniusVersionInfo = { let appContextStateMockRegistry: AppContextStateMockRegistry | undefined -const createMockAtom = ( - kind: AppContextStateAtomKind, -): AppContextStateMockAtom => ({ +const createMockAtom = (kind: AppContextStateAtomKind): AppContextStateMockAtom => ({ [APP_CONTEXT_STATE_ATOM_KIND]: kind, }) @@ -152,9 +152,7 @@ export const createAppContextStateAtomMock = async <TModule extends object>( } } -export const createAppContextStateJotaiMock = async ( - importOriginal: <T>() => Promise<T>, -) => { +export const createAppContextStateJotaiMock = async (importOriginal: <T>() => Promise<T>) => { const actual = await importOriginal<typeof import('jotai')>() return { @@ -170,20 +168,15 @@ export const createAppContextStateJotaiMock = async ( const userProfile = getUserProfile(state) const currentWorkspace = getCurrentWorkspace(state) - if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'userProfile') - return userProfile + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'userProfile') return userProfile - if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'userProfileId') - return userProfile.id + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'userProfileId') return userProfile.id - if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'userProfileEmail') - return userProfile.email + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'userProfileEmail') return userProfile.email - if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspace') - return currentWorkspace + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspace') return currentWorkspace - if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspaceId') - return currentWorkspace.id + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspaceId') return currentWorkspace.id if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'isCurrentWorkspaceManager') return state.isCurrentWorkspaceManager ?? false @@ -232,7 +225,9 @@ export const createAppContextStateJotaiMock = async ( 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]}`) + throw new Error( + `Unsupported app context state write atom: ${atom[APP_CONTEXT_STATE_ATOM_KIND]}`, + ) }, } } diff --git a/web/__tests__/utils/mock-system-features.tsx b/web/__tests__/utils/mock-system-features.tsx index 31762627d1aa0d..3d9375d2891aaa 100644 --- a/web/__tests__/utils/mock-system-features.tsx +++ b/web/__tests__/utils/mock-system-features.tsx @@ -1,5 +1,10 @@ import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen' -import type { RenderHookOptions, RenderHookResult, RenderOptions, RenderResult } from '@testing-library/react' +import type { + RenderHookOptions, + RenderHookResult, + RenderOptions, + RenderResult, +} from '@testing-library/react' import type { ReactElement, ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, renderHook } from '@testing-library/react' @@ -28,16 +33,18 @@ const getTrialModelsQueryKey = () => { } const getAppDslVersionQueryKey = () => { - const appDslVersionQuery = (consoleQuery as { appDslVersion?: AppDslVersionQueryProvider }).appDslVersion + const appDslVersionQuery = (consoleQuery as { appDslVersion?: AppDslVersionQueryProvider }) + .appDslVersion return appDslVersionQuery?.get?.queryKey() ?? fallbackAppDslVersionQueryKey } -type DeepPartial<T> = T extends Array<infer U> - ? Array<U> - : T extends object - ? { [K in keyof T]?: DeepPartial<T[K]> } - : T +type DeepPartial<T> = + T extends Array<infer U> + ? Array<U> + : T extends object + ? { [K in keyof T]?: DeepPartial<T[K]> } + : T const buildSystemFeatures = ( overrides: DeepPartial<GetSystemFeaturesResponse> = {}, @@ -105,17 +112,11 @@ export const seedSystemFeatures = ( return data } -const seedTrialModels = ( - queryClient: QueryClient, - trialModels: readonly string[] = [], -) => { +const seedTrialModels = (queryClient: QueryClient, trialModels: readonly string[] = []) => { queryClient.setQueryData(getTrialModelsQueryKey(), { trial_models: [...trialModels] }) } -export const seedAppDslVersion = ( - queryClient: QueryClient, - appDslVersion = '0.6.0', -) => { +export const seedAppDslVersion = (queryClient: QueryClient, appDslVersion = '0.6.0') => { queryClient.setQueryData(getAppDslVersionQueryKey(), { app_dsl_version: appDslVersion }) } @@ -146,9 +147,8 @@ export const createSystemFeaturesWrapper = ( options: SystemFeaturesTestOptions = {}, ): SystemFeaturesWrapper => { const queryClient = options.queryClient ?? createTestQueryClient() - const systemFeatures = options.systemFeatures === null - ? null - : seedSystemFeatures(queryClient, options.systemFeatures) + const systemFeatures = + options.systemFeatures === null ? null : seedSystemFeatures(queryClient, options.systemFeatures) if (options.trialModels !== undefined && options.trialModels !== null) seedTrialModels(queryClient, options.trialModels) if (options.appDslVersion !== undefined && options.appDslVersion !== null) @@ -162,8 +162,17 @@ export const createSystemFeaturesWrapper = ( export const renderWithSystemFeatures = ( ui: ReactElement, options: SystemFeaturesTestOptions & Omit<RenderOptions, 'wrapper'> = {}, -): RenderResult & { queryClient: QueryClient, systemFeatures: GetSystemFeaturesResponse | null } => { - const { systemFeatures: sf, trialModels, appDslVersion, queryClient: qc, ...renderOptions } = options +): RenderResult & { + queryClient: QueryClient + systemFeatures: GetSystemFeaturesResponse | null +} => { + const { + systemFeatures: sf, + trialModels, + appDslVersion, + queryClient: qc, + ...renderOptions + } = options const { wrapper, queryClient, systemFeatures } = createSystemFeaturesWrapper({ systemFeatures: sf, trialModels, @@ -177,8 +186,17 @@ export const renderWithSystemFeatures = ( export const renderHookWithSystemFeatures = <Result, Props = void>( callback: (props: Props) => Result, options: SystemFeaturesTestOptions & Omit<RenderHookOptions<Props>, 'wrapper'> = {}, -): RenderHookResult<Result, Props> & { queryClient: QueryClient, systemFeatures: GetSystemFeaturesResponse | null } => { - const { systemFeatures: sf, trialModels, appDslVersion, queryClient: qc, ...hookOptions } = options +): RenderHookResult<Result, Props> & { + queryClient: QueryClient + systemFeatures: GetSystemFeaturesResponse | null +} => { + const { + systemFeatures: sf, + trialModels, + appDslVersion, + queryClient: qc, + ...hookOptions + } = options const { wrapper, queryClient, systemFeatures } = createSystemFeaturesWrapper({ systemFeatures: sf, trialModels, diff --git a/web/__tests__/workflow-onboarding-integration.test.tsx b/web/__tests__/workflow-onboarding-integration.test.tsx index a991115dfb5c50..31f8fd6fa7deae 100644 --- a/web/__tests__/workflow-onboarding-integration.test.tsx +++ b/web/__tests__/workflow-onboarding-integration.test.tsx @@ -102,10 +102,11 @@ describe('Workflow Onboarding Integration Logic', () => { } // Simulate the validation logic from use-nodes-sync-draft.ts - const isValidStartNode = mockNode.data.type === BlockEnum.Start - || mockNode.data.type === BlockEnum.TriggerSchedule - || mockNode.data.type === BlockEnum.TriggerWebhook - || mockNode.data.type === BlockEnum.TriggerPlugin + const isValidStartNode = + mockNode.data.type === BlockEnum.Start || + mockNode.data.type === BlockEnum.TriggerSchedule || + mockNode.data.type === BlockEnum.TriggerWebhook || + mockNode.data.type === BlockEnum.TriggerPlugin expect(isValidStartNode).toBe(true) }) @@ -116,10 +117,11 @@ describe('Workflow Onboarding Integration Logic', () => { id: 'trigger-schedule-1', } - const isValidStartNode = mockNode.data.type === BlockEnum.Start - || mockNode.data.type === BlockEnum.TriggerSchedule - || mockNode.data.type === BlockEnum.TriggerWebhook - || mockNode.data.type === BlockEnum.TriggerPlugin + const isValidStartNode = + mockNode.data.type === BlockEnum.Start || + mockNode.data.type === BlockEnum.TriggerSchedule || + mockNode.data.type === BlockEnum.TriggerWebhook || + mockNode.data.type === BlockEnum.TriggerPlugin expect(isValidStartNode).toBe(true) }) @@ -130,10 +132,11 @@ describe('Workflow Onboarding Integration Logic', () => { id: 'trigger-webhook-1', } - const isValidStartNode = mockNode.data.type === BlockEnum.Start - || mockNode.data.type === BlockEnum.TriggerSchedule - || mockNode.data.type === BlockEnum.TriggerWebhook - || mockNode.data.type === BlockEnum.TriggerPlugin + const isValidStartNode = + mockNode.data.type === BlockEnum.Start || + mockNode.data.type === BlockEnum.TriggerSchedule || + mockNode.data.type === BlockEnum.TriggerWebhook || + mockNode.data.type === BlockEnum.TriggerPlugin expect(isValidStartNode).toBe(true) }) @@ -144,10 +147,11 @@ describe('Workflow Onboarding Integration Logic', () => { id: 'trigger-plugin-1', } - const isValidStartNode = mockNode.data.type === BlockEnum.Start - || mockNode.data.type === BlockEnum.TriggerSchedule - || mockNode.data.type === BlockEnum.TriggerWebhook - || mockNode.data.type === BlockEnum.TriggerPlugin + const isValidStartNode = + mockNode.data.type === BlockEnum.Start || + mockNode.data.type === BlockEnum.TriggerSchedule || + mockNode.data.type === BlockEnum.TriggerWebhook || + mockNode.data.type === BlockEnum.TriggerPlugin expect(isValidStartNode).toBe(true) }) @@ -158,10 +162,11 @@ describe('Workflow Onboarding Integration Logic', () => { id: 'llm-1', } - const isValidStartNode = mockNode.data.type === BlockEnum.Start - || mockNode.data.type === BlockEnum.TriggerSchedule - || mockNode.data.type === BlockEnum.TriggerWebhook - || mockNode.data.type === BlockEnum.TriggerPlugin + const isValidStartNode = + mockNode.data.type === BlockEnum.Start || + mockNode.data.type === BlockEnum.TriggerSchedule || + mockNode.data.type === BlockEnum.TriggerWebhook || + mockNode.data.type === BlockEnum.TriggerPlugin expect(isValidStartNode).toBe(false) }) @@ -174,11 +179,12 @@ describe('Workflow Onboarding Integration Logic', () => { ] // Simulate hasStartNode logic from use-nodes-sync-draft.ts - const hasStartNode = mockNodes.find(node => - node.data.type === BlockEnum.Start - || node.data.type === BlockEnum.TriggerSchedule - || node.data.type === BlockEnum.TriggerWebhook - || node.data.type === BlockEnum.TriggerPlugin, + const hasStartNode = mockNodes.find( + (node) => + node.data.type === BlockEnum.Start || + node.data.type === BlockEnum.TriggerSchedule || + node.data.type === BlockEnum.TriggerWebhook || + node.data.type === BlockEnum.TriggerPlugin, ) expect(hasStartNode).toBeTruthy() @@ -191,11 +197,12 @@ describe('Workflow Onboarding Integration Logic', () => { { data: { type: BlockEnum.Answer }, id: 'answer-1' }, ] - const hasStartNode = mockNodes.find(node => - node.data.type === BlockEnum.Start - || node.data.type === BlockEnum.TriggerSchedule - || node.data.type === BlockEnum.TriggerWebhook - || node.data.type === BlockEnum.TriggerPlugin, + const hasStartNode = mockNodes.find( + (node) => + node.data.type === BlockEnum.Start || + node.data.type === BlockEnum.TriggerSchedule || + node.data.type === BlockEnum.TriggerWebhook || + node.data.type === BlockEnum.TriggerPlugin, ) expect(hasStartNode).toBeUndefined() @@ -212,12 +219,13 @@ describe('Workflow Onboarding Integration Logic', () => { const nodeType = BlockEnum.Start const isChatMode = false - const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( - nodeType === BlockEnum.Start - || nodeType === BlockEnum.TriggerSchedule - || nodeType === BlockEnum.TriggerWebhook - || nodeType === BlockEnum.TriggerPlugin - ) && !isChatMode + const shouldAutoExpand = + shouldAutoOpenStartNodeSelector && + (nodeType === BlockEnum.Start || + nodeType === BlockEnum.TriggerSchedule || + nodeType === BlockEnum.TriggerWebhook || + nodeType === BlockEnum.TriggerPlugin) && + !isChatMode expect(shouldAutoExpand).toBe(true) }) @@ -226,9 +234,15 @@ describe('Workflow Onboarding Integration Logic', () => { const shouldAutoOpenStartNodeSelector = true const nodeType: BlockEnum = BlockEnum.TriggerSchedule const isChatMode = false - const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin] + const validStartTypes = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ] - const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode + const shouldAutoExpand = + shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode expect(shouldAutoExpand).toBe(true) }) @@ -237,9 +251,15 @@ describe('Workflow Onboarding Integration Logic', () => { const shouldAutoOpenStartNodeSelector = true const nodeType: BlockEnum = BlockEnum.TriggerWebhook const isChatMode = false - const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin] + const validStartTypes = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ] - const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode + const shouldAutoExpand = + shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode expect(shouldAutoExpand).toBe(true) }) @@ -248,9 +268,15 @@ describe('Workflow Onboarding Integration Logic', () => { const shouldAutoOpenStartNodeSelector = true const nodeType: BlockEnum = BlockEnum.TriggerPlugin const isChatMode = false - const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin] + const validStartTypes = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ] - const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode + const shouldAutoExpand = + shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode expect(shouldAutoExpand).toBe(true) }) @@ -259,9 +285,15 @@ describe('Workflow Onboarding Integration Logic', () => { const shouldAutoOpenStartNodeSelector = true const nodeType: BlockEnum = BlockEnum.LLM const isChatMode = false - const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin] + const validStartTypes = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ] - const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode + const shouldAutoExpand = + shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode expect(shouldAutoExpand).toBe(false) }) @@ -271,12 +303,13 @@ describe('Workflow Onboarding Integration Logic', () => { const nodeType = BlockEnum.Start const isChatMode = true - const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( - nodeType === BlockEnum.Start - || nodeType === BlockEnum.TriggerSchedule - || nodeType === BlockEnum.TriggerWebhook - || nodeType === BlockEnum.TriggerPlugin - ) && !isChatMode + const shouldAutoExpand = + shouldAutoOpenStartNodeSelector && + (nodeType === BlockEnum.Start || + nodeType === BlockEnum.TriggerSchedule || + nodeType === BlockEnum.TriggerWebhook || + nodeType === BlockEnum.TriggerPlugin) && + !isChatMode expect(shouldAutoExpand).toBe(false) }) @@ -286,12 +319,13 @@ describe('Workflow Onboarding Integration Logic', () => { const nodeType = BlockEnum.Start const isChatMode = false - const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( - nodeType === BlockEnum.Start - || nodeType === BlockEnum.TriggerSchedule - || nodeType === BlockEnum.TriggerWebhook - || nodeType === BlockEnum.TriggerPlugin - ) && !isChatMode + const shouldAutoExpand = + shouldAutoOpenStartNodeSelector && + (nodeType === BlockEnum.Start || + nodeType === BlockEnum.TriggerSchedule || + nodeType === BlockEnum.TriggerWebhook || + nodeType === BlockEnum.TriggerPlugin) && + !isChatMode expect(shouldAutoExpand).toBe(false) }) @@ -300,15 +334,15 @@ describe('Workflow Onboarding Integration Logic', () => { const nodeType = BlockEnum.Start const isChatMode = false - const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( - nodeType === BlockEnum.Start - || nodeType === BlockEnum.TriggerSchedule - || nodeType === BlockEnum.TriggerWebhook - || nodeType === BlockEnum.TriggerPlugin - ) && !isChatMode + const shouldAutoExpand = + shouldAutoOpenStartNodeSelector && + (nodeType === BlockEnum.Start || + nodeType === BlockEnum.TriggerSchedule || + nodeType === BlockEnum.TriggerWebhook || + nodeType === BlockEnum.TriggerPlugin) && + !isChatMode - if (shouldAutoExpand) - shouldAutoOpenStartNodeSelector = false + if (shouldAutoExpand) shouldAutoOpenStartNodeSelector = false expect(shouldAutoExpand).toBe(true) expect(shouldAutoOpenStartNodeSelector).toBe(false) @@ -498,7 +532,9 @@ describe('Workflow Onboarding Integration Logic', () => { BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin, ] - const hasStartNode = nodes.some((node: MockNode) => startNodeTypes.includes(node.data?.type as BlockEnum)) + const hasStartNode = nodes.some((node: MockNode) => + startNodeTypes.includes(node.data?.type as BlockEnum), + ) const isEmpty = nodes.length === 0 || !hasStartNode expect(isEmpty).toBe(true) @@ -519,7 +555,9 @@ describe('Workflow Onboarding Integration Logic', () => { BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin, ] - const hasStartNode = nodes.some((node: MockNode) => startNodeTypes.includes(node.data.type as BlockEnum)) + const hasStartNode = nodes.some((node: MockNode) => + startNodeTypes.includes(node.data.type as BlockEnum), + ) const isEmpty = nodes.length === 0 || !hasStartNode expect(isEmpty).toBe(true) @@ -528,9 +566,7 @@ describe('Workflow Onboarding Integration Logic', () => { it('should not detect canvas with start nodes as empty', () => { // Mock canvas with start node - mockGetNodes.mockReturnValue([ - { id: '1', data: { type: BlockEnum.Start } }, - ]) + mockGetNodes.mockReturnValue([{ id: '1', data: { type: BlockEnum.Start } }]) const nodes = mockGetNodes() const startNodeTypes = [ @@ -539,7 +575,9 @@ describe('Workflow Onboarding Integration Logic', () => { BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin, ] - const hasStartNode = nodes.some((node: MockNode) => startNodeTypes.includes(node.data.type as BlockEnum)) + const hasStartNode = nodes.some((node: MockNode) => + startNodeTypes.includes(node.data.type as BlockEnum), + ) const isEmpty = nodes.length === 0 || !hasStartNode expect(isEmpty).toBe(false) @@ -575,7 +613,8 @@ describe('Workflow Onboarding Integration Logic', () => { // Simulate the check logic with hasShownOnboarding = true const store = useWorkflowStore() as unknown as MockWorkflowStore - const shouldTrigger = !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow + const shouldTrigger = + !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow expect(shouldTrigger).toBe(false) }) @@ -609,7 +648,8 @@ describe('Workflow Onboarding Integration Logic', () => { // Simulate the check logic with notInitialWorkflow = true const store = useWorkflowStore() as unknown as MockWorkflowStore - const shouldTrigger = !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow + const shouldTrigger = + !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow expect(shouldTrigger).toBe(false) }) diff --git a/web/__tests__/workflow/workflow-generator/use-gen-graph.test.ts b/web/__tests__/workflow/workflow-generator/use-gen-graph.test.ts index d4ea0d8ae08af4..80583529f1db20 100644 --- a/web/__tests__/workflow/workflow-generator/use-gen-graph.test.ts +++ b/web/__tests__/workflow/workflow-generator/use-gen-graph.test.ts @@ -8,7 +8,14 @@ describe('useGenGraph', () => { const makeResponse = (label: string) => ({ graph: { - nodes: [{ id: label, type: 'custom', position: { x: 0, y: 0 }, data: { type: 'start', title: label } }], + nodes: [ + { + id: label, + type: 'custom', + position: { x: 0, y: 0 }, + data: { type: 'start', title: label }, + }, + ], edges: [], viewport: { x: 0, y: 0, zoom: 1 }, }, diff --git a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx index 45bbf24a25586b..1d3de80f98804f 100644 --- a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx +++ b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx @@ -63,11 +63,15 @@ describe('CommonLayoutHydrationBoundary', () => { beforeEach(() => { vi.clearAllMocks() mocks.queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - mocks.headers.mockResolvedValue(new Headers({ - 'x-dify-pathname': '/apps', - 'x-dify-search': '?tag=workflow', - })) - mocks.resolveServerConsoleApiUrl.mockReturnValue('https://console.example.com/console/api/account/profile') + mocks.headers.mockResolvedValue( + new Headers({ + 'x-dify-pathname': '/apps', + 'x-dify-search': '?tag=workflow', + }), + ) + mocks.resolveServerConsoleApiUrl.mockReturnValue( + 'https://console.example.com/console/api/account/profile', + ) mocks.profileQueryFn.mockResolvedValue({ profile: { id: 'account-id', @@ -122,17 +126,23 @@ describe('CommonLayoutHydrationBoundary', () => { }) it('should redirect unauthorized users to the refresh route with the current path', async () => { - mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'unauthorized' }), { status: 401 })) + mocks.profileQueryFn.mockRejectedValue( + new Response(JSON.stringify({ code: 'unauthorized' }), { status: 401 }), + ) const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary') await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT') - expect(mocks.redirect).toHaveBeenCalledWith('/auth/refresh?redirect_url=%2Fapps%3Ftag%3Dworkflow') + expect(mocks.redirect).toHaveBeenCalledWith( + '/auth/refresh?redirect_url=%2Fapps%3Ftag%3Dworkflow', + ) }) it('should default unauthorized refresh redirects to the home path when the pathname header is missing', async () => { mocks.headers.mockResolvedValue(new Headers()) - mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'unauthorized' }), { status: 401 })) + mocks.profileQueryFn.mockRejectedValue( + new Response(JSON.stringify({ code: 'unauthorized' }), { status: 401 }), + ) const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary') await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT') @@ -141,7 +151,9 @@ describe('CommonLayoutHydrationBoundary', () => { }) it('should redirect setup errors to install', async () => { - mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'not_setup' }), { status: 401 })) + mocks.profileQueryFn.mockRejectedValue( + new Response(JSON.stringify({ code: 'not_setup' }), { status: 401 }), + ) const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary') await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT') diff --git a/web/app/(commonLayout)/agents/[agentId]/access/page.tsx b/web/app/(commonLayout)/agents/[agentId]/access/page.tsx index 31d2fac865217a..2f3834beeb7078 100644 --- a/web/app/(commonLayout)/agents/[agentId]/access/page.tsx +++ b/web/app/(commonLayout)/agents/[agentId]/access/page.tsx @@ -4,9 +4,7 @@ type PageProps = { params: Promise<{ agentId: string }> } -export default async function Page({ - params, -}: PageProps) { +export default async function Page({ params }: PageProps) { const { agentId } = await params return <AgentDetailPage agentId={agentId} section="access" /> diff --git a/web/app/(commonLayout)/agents/[agentId]/configure/page.tsx b/web/app/(commonLayout)/agents/[agentId]/configure/page.tsx index a8a25ba6c3f4a5..2813d60243b623 100644 --- a/web/app/(commonLayout)/agents/[agentId]/configure/page.tsx +++ b/web/app/(commonLayout)/agents/[agentId]/configure/page.tsx @@ -4,9 +4,7 @@ type PageProps = { params: Promise<{ agentId: string }> } -export default async function Page({ - params, -}: PageProps) { +export default async function Page({ params }: PageProps) { const { agentId } = await params return <AgentDetailPage agentId={agentId} section="configure" /> diff --git a/web/app/(commonLayout)/agents/[agentId]/layout.tsx b/web/app/(commonLayout)/agents/[agentId]/layout.tsx index 384443d58bc76d..b34708e30fa20b 100644 --- a/web/app/(commonLayout)/agents/[agentId]/layout.tsx +++ b/web/app/(commonLayout)/agents/[agentId]/layout.tsx @@ -6,15 +6,8 @@ type LayoutProps = { params: Promise<{ agentId: string }> } -export default async function Layout({ - children, - params, -}: LayoutProps) { +export default async function Layout({ children, params }: LayoutProps) { const { agentId } = await params - return ( - <AgentDetailLayout agentId={agentId}> - {children} - </AgentDetailLayout> - ) + return <AgentDetailLayout agentId={agentId}>{children}</AgentDetailLayout> } diff --git a/web/app/(commonLayout)/agents/[agentId]/logs/page.tsx b/web/app/(commonLayout)/agents/[agentId]/logs/page.tsx index 1cbcd7bbd90c3a..0449ad4a8471cf 100644 --- a/web/app/(commonLayout)/agents/[agentId]/logs/page.tsx +++ b/web/app/(commonLayout)/agents/[agentId]/logs/page.tsx @@ -4,9 +4,7 @@ type PageProps = { params: Promise<{ agentId: string }> } -export default async function Page({ - params, -}: PageProps) { +export default async function Page({ params }: PageProps) { const { agentId } = await params return <AgentDetailPage agentId={agentId} section="logs" /> diff --git a/web/app/(commonLayout)/agents/[agentId]/monitoring/page.tsx b/web/app/(commonLayout)/agents/[agentId]/monitoring/page.tsx index ad61db5f8b8a73..59d87683e9d880 100644 --- a/web/app/(commonLayout)/agents/[agentId]/monitoring/page.tsx +++ b/web/app/(commonLayout)/agents/[agentId]/monitoring/page.tsx @@ -4,9 +4,7 @@ type PageProps = { params: Promise<{ agentId: string }> } -export default async function Page({ - params, -}: PageProps) { +export default async function Page({ params }: PageProps) { const { agentId } = await params return <AgentDetailPage agentId={agentId} section="monitoring" /> diff --git a/web/app/(commonLayout)/agents/[agentId]/page.tsx b/web/app/(commonLayout)/agents/[agentId]/page.tsx index 9480283a593e3e..fd369872b1092c 100644 --- a/web/app/(commonLayout)/agents/[agentId]/page.tsx +++ b/web/app/(commonLayout)/agents/[agentId]/page.tsx @@ -4,9 +4,7 @@ type PageProps = { params: Promise<{ agentId: string }> } -export default async function Page({ - params, -}: PageProps) { +export default async function Page({ params }: PageProps) { const { agentId } = await params redirect(`/agents/${agentId}/configure`) diff --git a/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx b/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx index 0d4dbc66dc28b9..0f7601244af24c 100644 --- a/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx +++ b/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx @@ -33,11 +33,13 @@ describe('RosterLayout', () => { const { default: RosterLayout } = await import('../layout') - expect(() => render( - <RosterLayout> - <div>Roster content</div> - </RosterLayout>, - )).toThrow('NEXT_NOT_FOUND') + expect(() => + render( + <RosterLayout> + <div>Roster content</div> + </RosterLayout>, + ), + ).toThrow('NEXT_NOT_FOUND') expect(mocks.guardAgentV2Route).toHaveBeenCalled() }) }) diff --git a/web/app/(commonLayout)/agents/feature-guard.ts b/web/app/(commonLayout)/agents/feature-guard.ts index 41f74b6696e0e2..bd2d8611761d4b 100644 --- a/web/app/(commonLayout)/agents/feature-guard.ts +++ b/web/app/(commonLayout)/agents/feature-guard.ts @@ -2,6 +2,5 @@ import { env } from '@/env' import { notFound } from '@/next/navigation' export const guardAgentV2Route = () => { - if (!env.NEXT_PUBLIC_ENABLE_AGENT_V2) - notFound() + if (!env.NEXT_PUBLIC_ENABLE_AGENT_V2) notFound() } diff --git a/web/app/(commonLayout)/agents/layout.tsx b/web/app/(commonLayout)/agents/layout.tsx index be88ab89e2c691..f707fe9d14edbd 100644 --- a/web/app/(commonLayout)/agents/layout.tsx +++ b/web/app/(commonLayout)/agents/layout.tsx @@ -1,11 +1,7 @@ import type { ReactNode } from 'react' import { guardAgentV2Route } from './feature-guard' -export default function Layout({ - children, -}: { - children: ReactNode -}) { +export default function Layout({ children }: { children: ReactNode }) { guardAgentV2Route() return children diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index 36266e0d4ea55a..1c411dc99a8322 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -19,11 +19,12 @@ const mockAppContextState = vi.hoisted(() => ({ workspacePermissionKeys: [] as string[], })) -const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, { - systemFeatures: { - rbac_enabled: mockIsRbacEnabled, - }, -}) +const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => + renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, + }) vi.mock('@/next/navigation', () => ({ usePathname: vi.fn(), @@ -61,7 +62,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -74,13 +76,14 @@ const mockUsePathname = vi.mocked(usePathname) const mockUseRouter = vi.mocked(useRouter) const mockFetchAppDetailDirect = vi.mocked(fetchAppDetailDirect) -const createAppDetail = (overrides: Partial<App> = {}) => ({ - id: 'app-1', - name: 'Demo App', - mode: AppModeEnum.WORKFLOW, - permission_keys: [AppACLPermission.ViewLayout, AppACLPermission.Monitor], - ...overrides, -}) as App +const createAppDetail = (overrides: Partial<App> = {}) => + ({ + id: 'app-1', + name: 'Demo App', + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.ViewLayout, AppACLPermission.Monitor], + ...overrides, + }) as App const waitForAppContent = async () => { await waitFor(() => { @@ -173,7 +176,9 @@ describe('AppDetailLayout', () => { it('should redirect restricted app pages before exposing app detail content', async () => { mockPathname = '/app/app-1/logs' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }), + ) render( <AppDetailLayout appId="app-1"> @@ -190,7 +195,9 @@ describe('AppDetailLayout', () => { it('should redirect logs pages when log and annotation access is missing', async () => { mockPathname = '/app/app-1/logs' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.Monitor] })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.Monitor] }), + ) render( <AppDetailLayout appId="app-1"> @@ -207,7 +214,9 @@ describe('AppDetailLayout', () => { it('should allow users with log and annotation access to open logs directly', async () => { mockPathname = '/app/app-1/logs' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.LogAndAnnotation] })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.LogAndAnnotation] }), + ) render( <AppDetailLayout appId="app-1"> @@ -255,7 +264,9 @@ describe('AppDetailLayout', () => { it('should redirect overview pages when monitor access is missing', async () => { mockPathname = '/app/app-1/overview' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }), + ) render( <AppDetailLayout appId="app-1"> @@ -273,7 +284,9 @@ describe('AppDetailLayout', () => { it('should wait for workspace permission keys before redirecting restricted pages', async () => { mockAppContextState.isLoadingWorkspacePermissionKeys = true mockPathname = '/app/app-1/overview' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }), + ) const { rerender } = render( <AppDetailLayout appId="app-1"> @@ -301,7 +314,9 @@ describe('AppDetailLayout', () => { it('should allow users with monitor access to open overview directly', async () => { mockPathname = '/app/app-1/overview' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.Monitor] })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.Monitor] }), + ) render( <AppDetailLayout appId="app-1"> @@ -317,7 +332,9 @@ describe('AppDetailLayout', () => { it('should redirect access config pages when access config access is missing', async () => { mockPathname = '/app/app-1/access-config' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }), + ) render( <AppDetailLayout appId="app-1"> @@ -334,7 +351,9 @@ describe('AppDetailLayout', () => { it('should allow users with access config access to open access config directly', async () => { mockPathname = '/app/app-1/access-config' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.AccessConfig] })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.AccessConfig] }), + ) render( <AppDetailLayout appId="app-1"> @@ -351,7 +370,9 @@ describe('AppDetailLayout', () => { it('should redirect access config pages when RBAC is disabled', async () => { mockIsRbacEnabled = false mockPathname = '/app/app-1/access-config' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.AccessConfig] })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.AccessConfig] }), + ) render( <AppDetailLayout appId="app-1"> @@ -368,10 +389,12 @@ describe('AppDetailLayout', () => { it('should redirect annotation pages when log and annotation access is missing', async () => { mockPathname = '/app/app-1/annotations' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ - mode: AppModeEnum.CHAT, - permission_keys: [AppACLPermission.Monitor], - })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ + mode: AppModeEnum.CHAT, + permission_keys: [AppACLPermission.Monitor], + }), + ) render( <AppDetailLayout appId="app-1"> @@ -388,10 +411,12 @@ describe('AppDetailLayout', () => { it('should allow users with log and annotation access to open annotations directly', async () => { mockPathname = '/app/app-1/annotations' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ - mode: AppModeEnum.CHAT, - permission_keys: [AppACLPermission.LogAndAnnotation], - })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ + mode: AppModeEnum.CHAT, + permission_keys: [AppACLPermission.LogAndAnnotation], + }), + ) render( <AppDetailLayout appId="app-1"> diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/__tests__/page.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/__tests__/page.spec.tsx index 1aa69c277b4b65..3a8e778e11b448 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/__tests__/page.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/__tests__/page.spec.tsx @@ -14,9 +14,11 @@ describe('App access config route', () => { // Route rendering resolves the async app id params for the client page. describe('Rendering', () => { it('should pass app id from route params', async () => { - render(await AccessConfig({ - params: Promise.resolve({ locale: 'en-US', appId: 'app-route-id' }), - })) + render( + await AccessConfig({ + params: Promise.resolve({ locale: 'en-US', appId: 'app-route-id' }), + }), + ) expect(screen.getByTestId('app-access-config')).toHaveAttribute('data-app-id', 'app-route-id') }) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx index 85ce85bc3988cf..6a111ee77d12d8 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx @@ -2,7 +2,7 @@ import type { Locale } from '@/i18n-config' import AppAccessConfigPage from '@/app/components/app/access-config' export type AccessConfigPageProps = { - params: Promise<{ locale: Locale, appId: string }> + params: Promise<{ locale: Locale; appId: string }> } const AccessConfig = async (props: AccessConfigPageProps) => { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/annotations/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/annotations/page.tsx index a17a4a3d035bdf..fb22b843176f20 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/annotations/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/annotations/page.tsx @@ -7,9 +7,7 @@ export type IProps = { } const Logs = async () => { - return ( - <Main pageType={PageType.annotation} /> - ) + return <Main pageType={PageType.annotation} /> } export default Logs diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/configuration/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/configuration/page.tsx index 850bd47aad0fbf..44325a667678cb 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/configuration/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/configuration/page.tsx @@ -2,9 +2,7 @@ import * as React from 'react' import Configuration from '@/app/components/app/configuration' const IConfiguration = async () => { - return ( - <Configuration /> - ) + return <Configuration /> } export default IConfiguration diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/develop/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/develop/page.tsx index 14864aba8b1b28..a48279ac2cfe95 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/develop/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/develop/page.tsx @@ -3,15 +3,13 @@ import * as React from 'react' import DevelopMain from '@/app/components/develop' export type IDevelopProps = { - params: Promise<{ locale: Locale, appId: string }> + params: Promise<{ locale: Locale; appId: string }> } const Develop = async (props: IDevelopProps) => { const params = await props.params - const { - appId, - } = params + const { appId } = params return <DevelopMain appId={appId} /> } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index ade1dc57f2d278..83e26192ff048d 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -11,7 +11,10 @@ import { useShallow } from 'zustand/react/shallow' import { useStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' import { userProfileIdAtom } from '@/context/account-state' -import { workspacePermissionKeysAtom, workspacePermissionKeysLoadingAtom } from '@/context/permission-state' +import { + workspacePermissionKeysAtom, + workspacePermissionKeysLoadingAtom, +} from '@/context/permission-state' import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useDocumentTitle from '@/hooks/use-document-title' @@ -26,12 +29,8 @@ type IAppDetailLayoutProps = { appId: string } -const isNotFoundError = (error: unknown) => ( - typeof error === 'object' - && error !== null - && 'status' in error - && error.status === 404 -) +const isNotFoundError = (error: unknown) => + typeof error === 'object' && error !== null && 'status' in error && error.status === 404 const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => { const { @@ -48,15 +47,17 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => { const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled - const { appDetail, setAppDetail } = useStore(useShallow(state => ({ - appDetail: state.appDetail, - setAppDetail: state.setAppDetail, - }))) + const { appDetail, setAppDetail } = useStore( + useShallow((state) => ({ + appDetail: state.appDetail, + setAppDetail: state.setAppDetail, + })), + ) const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false) const [appDetailRes, setAppDetailRes] = useState<App | null>(null) const routeAppDetail = appDetailRes ?? (appDetail?.id === appId ? appDetail : null) - useDocumentTitle(appDetail?.name || t($ => $['menus.appDetail'], { ns: 'common' })) + useDocumentTitle(appDetail?.name || t(($) => $['menus.appDetail'], { ns: 'common' })) useEffect(() => { let ignore = false @@ -70,26 +71,24 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => { setAppDetail() void Promise.resolve().then(() => { - if (!ignore) - setIsLoadingAppDetail(true) - }) - fetchAppDetailDirect({ url: '/apps', id: appId }).then((res: App) => { - if (ignore) - return - - setAppDetailRes(res) - }).catch((error: unknown) => { - if (ignore) - return - - if (isNotFoundError(error)) - router.replace('/apps') - }).finally(() => { - if (ignore) - return - - setIsLoadingAppDetail(false) + if (!ignore) setIsLoadingAppDetail(true) }) + fetchAppDetailDirect({ url: '/apps', id: appId }) + .then((res: App) => { + if (ignore) return + + setAppDetailRes(res) + }) + .catch((error: unknown) => { + if (ignore) return + + if (isNotFoundError(error)) router.replace('/apps') + }) + .finally(() => { + if (ignore) return + + setIsLoadingAppDetail(false) + }) return () => { ignore = true @@ -97,10 +96,15 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => { }, [appId, router, setAppDetail]) useEffect(() => { - if (!routeAppDetail || !currentWorkspace.id || isLoadingCurrentWorkspace || isLoadingWorkspacePermissionKeys || isLoadingAppDetail) - return - if (routeAppDetail.id !== appId) + if ( + !routeAppDetail || + !currentWorkspace.id || + isLoadingCurrentWorkspace || + isLoadingWorkspacePermissionKeys || + isLoadingAppDetail + ) return + if (routeAppDetail.id !== appId) return const appACLCapabilities = getAppACLCapabilities(routeAppDetail.permission_keys, { currentUserId, @@ -114,54 +118,78 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => { const isOverviewPath = pathname.endsWith('overview') const isAccessConfigPath = pathname.endsWith('access-config') if ( - (isLayoutPath && !appACLCapabilities.canAccessLayout) - || (isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) - || (isAnnotationsPath && !appACLCapabilities.canAccessLogAndAnnotation) - || (isOverviewPath && !appACLCapabilities.canMonitor) - || (isAccessConfigPath && !appACLCapabilities.canAccessConfig) + (isLayoutPath && !appACLCapabilities.canAccessLayout) || + (isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) || + (isAnnotationsPath && !appACLCapabilities.canAccessLogAndAnnotation) || + (isOverviewPath && !appACLCapabilities.canMonitor) || + (isAccessConfigPath && !appACLCapabilities.canAccessConfig) ) { - router.replace(getRedirectionPath(routeAppDetail, { - currentUserId, - resourceMaintainer: routeAppDetail.maintainer, - workspacePermissionKeys, - isRbacEnabled, - })) + router.replace( + getRedirectionPath(routeAppDetail, { + currentUserId, + resourceMaintainer: routeAppDetail.maintainer, + workspacePermissionKeys, + isRbacEnabled, + }), + ) return } - if ((routeAppDetail.mode === AppModeEnum.WORKFLOW || routeAppDetail.mode === AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('configuration')) { + if ( + (routeAppDetail.mode === AppModeEnum.WORKFLOW || + routeAppDetail.mode === AppModeEnum.ADVANCED_CHAT) && + pathname.endsWith('configuration') + ) { router.replace(`/app/${appId}/workflow`) - } - else if ((routeAppDetail.mode !== AppModeEnum.WORKFLOW && routeAppDetail.mode !== AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('workflow')) { + } else if ( + routeAppDetail.mode !== AppModeEnum.WORKFLOW && + routeAppDetail.mode !== AppModeEnum.ADVANCED_CHAT && + pathname.endsWith('workflow') + ) { router.replace(`/app/${appId}/configuration`) return } if (appDetailRes && appDetail?.id !== appDetailRes.id) setAppDetail({ ...appDetailRes, enable_sso: false }) - }, [appDetail?.id, appDetailRes, appId, currentUserId, currentWorkspace.id, isLoadingAppDetail, isLoadingCurrentWorkspace, isLoadingWorkspacePermissionKeys, isRbacEnabled, pathname, routeAppDetail, router, setAppDetail, workspacePermissionKeys]) + }, [ + appDetail?.id, + appDetailRes, + appId, + currentUserId, + currentWorkspace.id, + isLoadingAppDetail, + isLoadingCurrentWorkspace, + isLoadingWorkspacePermissionKeys, + isRbacEnabled, + pathname, + routeAppDetail, + router, + setAppDetail, + workspacePermissionKeys, + ]) const isWorkflowPage = pathname.endsWith('/workflow') - const content = !appDetail - ? ( - <div className="flex min-w-0 grow items-center justify-center bg-background-body"> - <Loading /> - </div> - ) - : ( - <div className={cn( - 'relative flex h-0 min-h-0 min-w-0 grow overflow-hidden', - !isWorkflowPage && 'pt-1 pr-1 pb-1', + const content = !appDetail ? ( + <div className="flex min-w-0 grow items-center justify-center bg-background-body"> + <Loading /> + </div> + ) : ( + <div + className={cn( + 'relative flex h-0 min-h-0 min-w-0 grow overflow-hidden', + !isWorkflowPage && 'pt-1 pr-1 pb-1', + )} + > + <div + className={cn( + 'min-w-0 grow overflow-hidden bg-components-panel-bg', + !isWorkflowPage && 'rounded-lg shadow-xs shadow-shadow-shadow-3', )} - > - <div className={cn( - 'min-w-0 grow overflow-hidden bg-components-panel-bg', - !isWorkflowPage && 'rounded-lg shadow-xs shadow-shadow-shadow-3', - )} - > - {children} - </div> - </div> - ) + > + {children} + </div> + </div> + ) return ( <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background-body"> diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx index 491a046e7dcb8d..74c2d2b0893524 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx @@ -4,10 +4,7 @@ const AppDetailLayout = async (props: { children: React.ReactNode params: Promise<{ appId: string }> }) => { - const { - children, - params, - } = props + const { children, params } = props return <Main appId={(await params).appId}>{children}</Main> } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/logs/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/logs/page.tsx index eb8ff8f795d8d8..c5536f03a11790 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/logs/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/logs/page.tsx @@ -3,9 +3,7 @@ import Main from '@/app/components/app/log-annotation' import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type' const Logs = async () => { - return ( - <Main pageType={PageType.log} /> - ) + return <Main pageType={PageType.log} /> } export default Logs diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx index d565854c7ab27f..9de6dcae87d958 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx @@ -91,7 +91,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -122,22 +123,16 @@ vi.mock('@/app/components/app/overview/app-card', () => ({ }) => ( <div> <button type="button" onClick={() => onChangeStatus?.(true)}> - toggle - {' '} - {cardType} + toggle {cardType} </button> {onGenerateCode && ( <button type="button" onClick={() => onGenerateCode()}> - generate - {' '} - {cardType} + generate {cardType} </button> )} {onSaveSiteConfig && ( <button type="button" onClick={() => onSaveSiteConfig({ title: 'Site title' })}> - save - {' '} - {cardType} + save {cardType} </button> )} </div> @@ -224,12 +219,17 @@ describe('CardView ACL edit guards', () => { expect(mockFetchAppDetail).toHaveBeenCalled() }) expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) - expect(mockSetQueryData).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({ - site: expect.objectContaining({ title: 'Saved site title' }), - })) - expect(mockAppState.setAppDetail).toHaveBeenCalledWith(expect.objectContaining({ - site: expect.objectContaining({ title: 'Saved site title' }), - })) + expect(mockSetQueryData).toHaveBeenCalledWith( + ['apps', 'detail', 'app-1'], + expect.objectContaining({ + site: expect.objectContaining({ title: 'Saved site title' }), + }), + ) + expect(mockAppState.setAppDetail).toHaveBeenCalledWith( + expect.objectContaining({ + site: expect.objectContaining({ title: 'Saved site title' }), + }), + ) }) it('should refresh the Zustand app detail after saving webapp settings', async () => { @@ -243,12 +243,17 @@ describe('CardView ACL edit guards', () => { await waitFor(() => { expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) }) - expect(mockSetQueryData).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({ - site: expect.objectContaining({ title: 'Saved site title' }), - })) - expect(mockAppState.setAppDetail).toHaveBeenCalledWith(expect.objectContaining({ - site: expect.objectContaining({ title: 'Saved site title' }), - })) + expect(mockSetQueryData).toHaveBeenCalledWith( + ['apps', 'detail', 'app-1'], + expect.objectContaining({ + site: expect.objectContaining({ title: 'Saved site title' }), + }), + ) + expect(mockAppState.setAppDetail).toHaveBeenCalledWith( + expect.objectContaining({ + site: expect.objectContaining({ title: 'Saved site title' }), + }), + ) }) }) }) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx index b3b3b5a8cf0d24..587a069744f59d 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx @@ -15,9 +15,10 @@ const testState = vi.hoisted(() => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: <T,>(selector: (state: { appDetail: typeof testState.appDetail }) => T): T => selector({ - appDetail: testState.appDetail, - }), + useStore: <T,>(selector: (state: { appDetail: typeof testState.appDetail }) => T): T => + selector({ + appDetail: testState.appDetail, + }), })) vi.mock('@/context/i18n', () => ({ @@ -101,7 +102,9 @@ describe('ChartView monitor permission', () => { it('should not render monitoring charts when app monitor permission is missing', () => { render(<ChartView appId="app-1" headerRight={<button type="button">header action</button>} />) - expect(screen.queryByRole('heading', { name: 'common.appMenus.overview' })).not.toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'common.appMenus.overview' }), + ).not.toBeInTheDocument() expect(screen.queryByText('header action')).not.toBeInTheDocument() expect(testState.chartRenderSpy).not.toHaveBeenCalled() }) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/view.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/view.spec.tsx index 63fe536f4966a5..97e70cc592c06f 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/view.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/view.spec.tsx @@ -15,9 +15,10 @@ const testState = vi.hoisted(() => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: <T,>(selector: (state: { appDetail: typeof testState.appDetail }) => T): T => selector({ - appDetail: testState.appDetail, - }), + useStore: <T,>(selector: (state: { appDetail: typeof testState.appDetail }) => T): T => + selector({ + appDetail: testState.appDetail, + }), })) vi.mock('@/app/components/app/overview/apikey-info-panel', () => ({ @@ -25,11 +26,9 @@ vi.mock('@/app/components/app/overview/apikey-info-panel', () => ({ })) vi.mock('../chart-view', () => ({ - default: ({ appId, headerRight }: { appId: string, headerRight: ReactNode }) => ( + default: ({ appId, headerRight }: { appId: string; headerRight: ReactNode }) => ( <div> - chart view - {' '} - {appId} + chart view {appId} {headerRight} </div> ), @@ -73,7 +72,10 @@ describe('OverviewView monitor permission', () => { }) it('should render tracing entry when app tracing config permission is granted with monitor access', () => { - testState.appDetail.permission_keys = [AppACLPermission.Monitor, AppACLPermission.TracingConfig] + testState.appDetail.permission_keys = [ + AppACLPermission.Monitor, + AppACLPermission.TracingConfig, + ] render(<OverviewView appId="app-1" />) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx index ef68c19b3d1247..a5e0856452d0a0 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx @@ -42,25 +42,27 @@ type ICardViewProps = { const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => { const { t } = useTranslation() const queryClient = useQueryClient() - const appDetail = useAppStore(state => state.appDetail) - const setAppDetail = useAppStore(state => state.setAppDetail) + const appDetail = useAppStore((state) => state.appDetail) + const setAppDetail = useAppStore((state) => state.setAppDetail) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const canEditApp = useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { - currentUserId, - resourceMaintainer: appDetail?.maintainer, - workspacePermissionKeys, - }).canEdit, [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys]) + const canEditApp = useMemo( + () => + getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }).canEdit, + [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], + ) const isWorkflowApp = appDetail?.mode === AppModeEnum.WORKFLOW const showMCPCard = isInPanel const showTriggerCard = isInPanel && isWorkflowApp const { data: currentWorkflow } = useAppWorkflow(isWorkflowApp ? appDetail.id : '') const hasTriggerNode = useMemo<boolean | null>(() => { - if (!isWorkflowApp) - return false - if (!currentWorkflow) - return null + if (!isWorkflowApp) return false + if (!currentWorkflow) return null const nodes = currentWorkflow.graph?.nodes || [] return nodes.some((node) => { const nodeType = node.data?.type as BlockEnum | undefined @@ -70,22 +72,28 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => { const shouldRenderAppCards = !isWorkflowApp || hasTriggerNode === false const disableAppCards = !shouldRenderAppCards - const buildTriggerModeMessage = useCallback((featureName: string) => ( - <div className="flex flex-col gap-1"> - <div className="text-xs text-text-secondary"> - {t($ => $['overview.disableTooltip.triggerMode'], { ns: 'appOverview', feature: featureName })} + const buildTriggerModeMessage = useCallback( + (featureName: string) => ( + <div className="flex flex-col gap-1"> + <div className="text-xs text-text-secondary"> + {t(($) => $['overview.disableTooltip.triggerMode'], { + ns: 'appOverview', + feature: featureName, + })} + </div> </div> - </div> - ), [t]) + ), + [t], + ) const disableWebAppTooltip = disableAppCards - ? buildTriggerModeMessage(t($ => $['overview.appInfo.title'], { ns: 'appOverview' })) + ? buildTriggerModeMessage(t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })) : null const disableApiTooltip = disableAppCards - ? buildTriggerModeMessage(t($ => $['overview.apiInfo.title'], { ns: 'appOverview' })) + ? buildTriggerModeMessage(t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' })) : null const disableMcpTooltip = disableAppCards - ? buildTriggerModeMessage(t($ => $['mcp.server.title'], { ns: 'tools' })) + ? buildTriggerModeMessage(t(($) => $['mcp.server.title'], { ns: 'tools' })) : null const setNeedRefresh = useSetNeedRefreshAppList() @@ -95,16 +103,18 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => { const res = await fetchAppDetail({ url: '/apps', id: appId }) queryClient.setQueryData([...appDetailQueryKeyPrefix, appId], res) setAppDetail({ ...res }) - } - catch (error) { + } catch (error) { console.error(error) } }, [appId, queryClient, setAppDetail]) - const handleCallbackResult = (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => { + const handleCallbackResult = ( + err: Error | null, + message?: I18nKeysByPrefix<'common', 'actionMsg.'>, + ) => { const type = err ? 'error' : 'success' - message ||= (type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully') + message ||= type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully' if (type === 'success') { updateAppDetail() @@ -120,20 +130,18 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => { } } - toast(t($ => $[`actionMsg.${message}`], { ns: 'common' }) as string, { type }) + toast(t(($) => $[`actionMsg.${message}`], { ns: 'common' }) as string, { type }) } // Listen for collaborative app state updates from other clients useEffect(() => { - if (!appId) - return + if (!appId) return const unsubscribe = collaborationManager.onAppStateUpdate(async () => { try { // Update app detail when other clients modify app state await updateAppDetail() - } - catch (error) { + } catch (error) { console.error('app state update failed:', error) } }) @@ -142,8 +150,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => { }, [appId, updateAppDetail]) const onChangeSiteStatus = async (value: boolean) => { - if (!canEditApp) - return + if (!canEditApp) return const [err] = await asyncRunSafe<App>( updateAppSiteStatus({ @@ -156,8 +163,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => { } const onChangeApiStatus = async (value: boolean) => { - if (!canEditApp) - return + if (!canEditApp) return const [err] = await asyncRunSafe<App>( updateAppSiteStatus({ @@ -170,8 +176,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => { } const onSaveSiteConfig: IAppCardProps['onSaveSiteConfig'] = async (params) => { - if (!canEditApp) - return + if (!canEditApp) return const [err] = await asyncRunSafe<App>( updateAppSiteConfig({ @@ -179,15 +184,13 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => { body: params, }) as Promise<App>, ) - if (!err) - setNeedRefresh('1') + if (!err) setNeedRefresh('1') handleCallbackResult(err) } const onGenerateCode = async () => { - if (!canEditApp) - return + if (!canEditApp) return const [err] = await asyncRunSafe<UpdateAppSiteCodeResponse>( updateAppSiteAccessToken({ @@ -198,8 +201,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => { handleCallbackResult(err, err ? 'generatedUnsuccessfully' : 'generatedSuccessfully') } - if (!appDetail) - return <Loading /> + if (!appDetail) return <Loading /> const appCards = ( <> @@ -231,14 +233,9 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => { </> ) - const triggerCardNode = showTriggerCard - ? ( - <TriggerCard - appInfo={appDetail} - onToggleResult={handleCallbackResult} - /> - ) - : null + const triggerCardNode = showTriggerCard ? ( + <TriggerCard appInfo={appDetail} onToggleResult={handleCallbackResult} /> + ) : null return ( <div className={className || 'mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'}> diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx index 9cd536b95840ad..e55345a8287528 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx @@ -8,7 +8,20 @@ import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter' -import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart' +import { + AvgResponseTime, + AvgSessionInteractions, + AvgUserInteractions, + ConversationsChart, + CostChart, + EndUsersChart, + MessagesChart, + TokenPerSecond, + UserSatisfactionRate, + WorkflowCostChart, + WorkflowDailyTerminalsChart, + WorkflowMessagesChart, +} from '@/app/components/app/overview/app-chart' import { useStore as useAppStore } from '@/app/components/app/store' import { IS_CLOUD_EDITION } from '@/config' import { userProfileIdAtom } from '@/context/account-state' @@ -24,7 +37,7 @@ const today = dayjs() type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'> -const TIME_PERIOD_MAPPING: { value: number, name: TimePeriodName }[] = [ +const TIME_PERIOD_MAPPING: { value: number; name: TimePeriodName }[] = [ { value: 0, name: 'today' }, { value: 7, name: 'last7days' }, { value: 30, name: 'last30days' }, @@ -40,60 +53,78 @@ type IChartViewProps = { export default function ChartView({ appId, headerRight }: IChartViewProps) { const { t } = useTranslation() const docLink = useDocLink() - const appDetail = useAppStore(state => state.appDetail) + const appDetail = useAppStore((state) => state.appDetail) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const canMonitor = React.useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { - currentUserId, - resourceMaintainer: appDetail?.maintainer, - workspacePermissionKeys, - }).canMonitor, [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys]) + const canMonitor = React.useMemo( + () => + getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }).canMonitor, + [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], + ) const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow' const isWorkflow = appDetail?.mode === 'workflow' - const [period, setPeriod] = useState<PeriodParams>(IS_CLOUD_EDITION - ? { name: t($ => $['filter.period.today'], { ns: 'appLog' }), query: { start: today.startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } } - : { name: t($ => $['filter.period.last7days'], { ns: 'appLog' }), query: { start: today.subtract(7, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } }, + const [period, setPeriod] = useState<PeriodParams>( + IS_CLOUD_EDITION + ? { + name: t(($) => $['filter.period.today'], { ns: 'appLog' }), + query: { + start: today.startOf('day').format(queryDateFormat), + end: today.endOf('day').format(queryDateFormat), + }, + } + : { + name: t(($) => $['filter.period.last7days'], { ns: 'appLog' }), + query: { + start: today.subtract(7, 'day').startOf('day').format(queryDateFormat), + end: today.endOf('day').format(queryDateFormat), + }, + }, ) - if (!appDetail || !canMonitor) - return null + if (!appDetail || !canMonitor) return null return ( <div className="flex h-full min-h-0 flex-col"> <div className="h-[106px] shrink-0"> <div className="px-6 pt-3"> <div className="flex h-6 items-center"> - <h1 className="title-2xl-semi-bold text-text-primary">{t($ => $['appMenus.overview'], { ns: 'common' })}</h1> + <h1 className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['appMenus.overview'], { ns: 'common' })} + </h1> </div> <div className="mt-0.5 flex h-4 min-w-0 items-start gap-0.5 system-xs-regular text-text-tertiary"> - <p className="min-w-0 truncate">{t($ => $['monitoring.description'], { ns: 'appLog' })}</p> + <p className="min-w-0 truncate"> + {t(($) => $['monitoring.description'], { ns: 'appLog' })} + </p> <a href={docLink('/use-dify/monitor/analysis')} target="_blank" rel="noopener noreferrer" className="inline-flex shrink-0 items-center text-text-accent hover:underline" > - <span>{t($ => $['operation.learnMore'], { ns: 'common' })}</span> + <span>{t(($) => $['operation.learnMore'], { ns: 'common' })}</span> <span className="i-ri-external-link-line size-3" aria-hidden="true" /> </a> </div> </div> <div className="mt-1 flex h-10 items-center justify-between pr-10 pl-6"> - {IS_CLOUD_EDITION - ? ( - <TimeRangePicker - ranges={TIME_PERIOD_MAPPING} - onSelect={setPeriod} - queryDateFormat={queryDateFormat} - /> - ) - : ( - <LongTimeRangePicker - periodMapping={LONG_TIME_PERIOD_MAPPING} - onSelect={setPeriod} - queryDateFormat={queryDateFormat} - /> - )} + {IS_CLOUD_EDITION ? ( + <TimeRangePicker + ranges={TIME_PERIOD_MAPPING} + onSelect={setPeriod} + queryDateFormat={queryDateFormat} + /> + ) : ( + <LongTimeRangePicker + periodMapping={LONG_TIME_PERIOD_MAPPING} + onSelect={setPeriod} + queryDateFormat={queryDateFormat} + /> + )} {headerRight} </div> @@ -104,13 +135,11 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) { <> <ConversationsChart period={period} id={appId} /> <EndUsersChart period={period} id={appId} /> - {isChatApp - ? ( - <AvgSessionInteractions period={period} id={appId} /> - ) - : ( - <AvgResponseTime period={period} id={appId} /> - )} + {isChatApp ? ( + <AvgSessionInteractions period={period} id={appId} /> + ) : ( + <AvgResponseTime period={period} id={appId} /> + )} <TokenPerSecond period={period} id={appId} /> <UserSatisfactionRate period={period} id={appId} /> <CostChart period={period} id={appId} /> diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx index 8f0c5fc341c3da..c727f700ed335d 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx @@ -2,7 +2,14 @@ import type { FC } from 'react' import type { PeriodParams } from '@/app/components/app/overview/app-chart' import type { I18nKeysByPrefix } from '@/types/i18n' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import dayjs from 'dayjs' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -14,77 +21,75 @@ type TimePeriodOption = { } type Props = Readonly<{ - periodMapping: { [key: string]: { value: number, name: TimePeriodName } } + periodMapping: { [key: string]: { value: number; name: TimePeriodName } } onSelect: (payload: PeriodParams) => void queryDateFormat: string }> const today = dayjs() -const LongTimeRangePicker: FC<Props> = ({ - periodMapping, - onSelect, - queryDateFormat, -}) => { +const LongTimeRangePicker: FC<Props> = ({ periodMapping, onSelect, queryDateFormat }) => { const { t } = useTranslation() const items = React.useMemo<TimePeriodOption[]>(() => { return Object.entries(periodMapping).map(([key, period]) => ({ value: key, - name: t($ => $[`filter.period.${period.name}`], { ns: 'appLog' }), + name: t(($) => $[`filter.period.${period.name}`], { ns: 'appLog' }), })) }, [periodMapping, t]) const [value, setValue] = React.useState('2') const selectedItem = React.useMemo(() => { - return items.find(item => item.value === value) ?? null + return items.find((item) => item.value === value) ?? null }, [items, value]) - const handleSelect = React.useCallback((item: TimePeriodOption) => { - const id = item.value - const value = periodMapping[id]?.value ?? '-1' - const name = item.name || t($ => $['filter.period.allTime'], { ns: 'appLog' }) - if (value === -1) { - onSelect({ name: t($ => $['filter.period.allTime'], { ns: 'appLog' }), query: undefined }) - } - else if (value === 0) { - const startOfToday = today.startOf('day').format(queryDateFormat) - const endOfToday = today.endOf('day').format(queryDateFormat) - onSelect({ - name, - query: { - start: startOfToday, - end: endOfToday, - }, - }) - } - else { - onSelect({ - name, - query: { - start: today.subtract(value as number, 'day').startOf('day').format(queryDateFormat), - end: today.endOf('day').format(queryDateFormat), - }, - }) - } - }, [onSelect, periodMapping, queryDateFormat, t]) + const handleSelect = React.useCallback( + (item: TimePeriodOption) => { + const id = item.value + const value = periodMapping[id]?.value ?? '-1' + const name = item.name || t(($) => $['filter.period.allTime'], { ns: 'appLog' }) + if (value === -1) { + onSelect({ name: t(($) => $['filter.period.allTime'], { ns: 'appLog' }), query: undefined }) + } else if (value === 0) { + const startOfToday = today.startOf('day').format(queryDateFormat) + const endOfToday = today.endOf('day').format(queryDateFormat) + onSelect({ + name, + query: { + start: startOfToday, + end: endOfToday, + }, + }) + } else { + onSelect({ + name, + query: { + start: today + .subtract(value as number, 'day') + .startOf('day') + .format(queryDateFormat), + end: today.endOf('day').format(queryDateFormat), + }, + }) + } + }, + [onSelect, periodMapping, queryDateFormat, t], + ) return ( <Select value={selectedItem?.value ?? null} onValueChange={(nextValue) => { - if (!nextValue) - return - const nextItem = items.find(item => item.value === nextValue) - if (!nextItem) - return + if (!nextValue) return + const nextItem = items.find((item) => item.value === nextValue) + if (!nextItem) return setValue(nextValue) handleSelect(nextItem) }} > <SelectTrigger className="mt-0 w-fit max-w-none"> - {selectedItem?.name ?? t($ => $['placeholder.select'], { ns: 'common' })} + {selectedItem?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })} </SelectTrigger> <SelectContent> - {items.map(item => ( + {items.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx index 7b84d311fc9d96..de7cc37416f5ac 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx @@ -8,13 +8,9 @@ export type IDevelopProps = { const Overview = async (props: IDevelopProps) => { const params = await props.params - const { - appId, - } = params + const { appId } = params - return ( - <OverviewView appId={appId} /> - ) + return <OverviewView appId={appId} /> } export default Overview diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx index 2e59c09bce1788..4cfc061c2737b3 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx @@ -20,35 +20,49 @@ type Props = Readonly<{ }> const today = dayjs() -const DatePicker: FC<Props> = ({ - start, - end, - onStartChange, - onEndChange, -}) => { +const DatePicker: FC<Props> = ({ start, end, onStartChange, onEndChange }) => { const locale = useLocale() - const renderDate = useCallback(({ value, handleClickTrigger, isOpen }: TriggerProps) => { - return ( - <div className={cn('flex h-7 cursor-pointer items-center rounded-lg px-1 system-sm-regular text-components-input-text-filled hover:bg-state-base-hover', isOpen && 'bg-state-base-hover')} onClick={handleClickTrigger}> - {value ? formatToLocalTime(value, locale, 'MMM D') : ''} - </div> - ) - }, [locale]) + const renderDate = useCallback( + ({ value, handleClickTrigger, isOpen }: TriggerProps) => { + return ( + <div + className={cn( + 'flex h-7 cursor-pointer items-center rounded-lg px-1 system-sm-regular text-components-input-text-filled hover:bg-state-base-hover', + isOpen && 'bg-state-base-hover', + )} + onClick={handleClickTrigger} + > + {value ? formatToLocalTime(value, locale, 'MMM D') : ''} + </div> + ) + }, + [locale], + ) const availableStartDate = end.subtract(30, 'day') - const startDateDisabled = useCallback((date: Dayjs) => { - if (date.isAfter(today, 'date')) - return true - return !((date.isAfter(availableStartDate, 'date') || date.isSame(availableStartDate, 'date')) && (date.isBefore(end, 'date') || date.isSame(end, 'date'))) - }, [availableStartDate, end]) + const startDateDisabled = useCallback( + (date: Dayjs) => { + if (date.isAfter(today, 'date')) return true + return !( + (date.isAfter(availableStartDate, 'date') || date.isSame(availableStartDate, 'date')) && + (date.isBefore(end, 'date') || date.isSame(end, 'date')) + ) + }, + [availableStartDate, end], + ) const availableEndDate = start.add(30, 'day') - const endDateDisabled = useCallback((date: Dayjs) => { - if (date.isAfter(today, 'date')) - return true - return !((date.isAfter(start, 'date') || date.isSame(start, 'date')) && (date.isBefore(availableEndDate, 'date') || date.isSame(availableEndDate, 'date'))) - }, [availableEndDate, start]) + const endDateDisabled = useCallback( + (date: Dayjs) => { + if (date.isAfter(today, 'date')) return true + return !( + (date.isAfter(start, 'date') || date.isSame(start, 'date')) && + (date.isBefore(availableEndDate, 'date') || date.isSame(availableEndDate, 'date')) + ) + }, + [availableEndDate, start], + ) return ( <div className="flex h-8 items-center space-x-0.5 rounded-lg bg-components-input-bg-normal px-2"> @@ -75,7 +89,6 @@ const DatePicker: FC<Props> = ({ getIsDateDisabled={endDateDisabled} /> </div> - ) } export default React.memo(DatePicker) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx index 8491f01e8afb1f..1977fd053bbb49 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx @@ -1,7 +1,10 @@ 'use client' import type { Dayjs } from 'dayjs' import type { FC } from 'react' -import type { PeriodParams, PeriodParamsWithTimeRange } from '@/app/components/app/overview/app-chart' +import type { + PeriodParams, + PeriodParamsWithTimeRange, +} from '@/app/components/app/overview/app-chart' import type { I18nKeysByPrefix } from '@/types/i18n' import dayjs from 'dayjs' import * as React from 'react' @@ -17,69 +20,62 @@ const today = dayjs() type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'> type Props = Readonly<{ - ranges: { value: number, name: TimePeriodName }[] + ranges: { value: number; name: TimePeriodName }[] onSelect: (payload: PeriodParams) => void queryDateFormat: string }> -const TimeRangePicker: FC<Props> = ({ - ranges, - onSelect, - queryDateFormat, -}) => { +const TimeRangePicker: FC<Props> = ({ ranges, onSelect, queryDateFormat }) => { const locale = useLocale() const [isCustomRange, setIsCustomRange] = useState(false) const [start, setStart] = useState<Dayjs>(today) const [end, setEnd] = useState<Dayjs>(today) - const handleRangeChange = useCallback((payload: PeriodParamsWithTimeRange) => { - setIsCustomRange(false) - setStart(payload.query!.start) - setEnd(payload.query!.end) - onSelect({ - name: payload.name, - query: { - start: payload.query!.start.format(queryDateFormat), - end: payload.query!.end.format(queryDateFormat), - }, - }) - }, [onSelect, queryDateFormat]) - - const handleDateChange = useCallback((type: 'start' | 'end') => { - return (date?: Dayjs) => { - if (!date) - return - if (type === 'start' && date.isSame(start)) - return - if (type === 'end' && date.isSame(end)) - return - if (type === 'start') - setStart(date) - else - setEnd(date) - - const currStart = type === 'start' ? date : start - const currEnd = type === 'end' ? date : end + const handleRangeChange = useCallback( + (payload: PeriodParamsWithTimeRange) => { + setIsCustomRange(false) + setStart(payload.query!.start) + setEnd(payload.query!.end) onSelect({ - name: `${formatToLocalTime(currStart, locale, 'MMM D')} - ${formatToLocalTime(currEnd, locale, 'MMM D')}`, + name: payload.name, query: { - start: currStart.format(queryDateFormat), - end: currEnd.format(queryDateFormat), + start: payload.query!.start.format(queryDateFormat), + end: payload.query!.end.format(queryDateFormat), }, }) + }, + [onSelect, queryDateFormat], + ) + + const handleDateChange = useCallback( + (type: 'start' | 'end') => { + return (date?: Dayjs) => { + if (!date) return + if (type === 'start' && date.isSame(start)) return + if (type === 'end' && date.isSame(end)) return + if (type === 'start') setStart(date) + else setEnd(date) - setIsCustomRange(true) - } - }, [start, end, onSelect, locale, queryDateFormat]) + const currStart = type === 'start' ? date : start + const currEnd = type === 'end' ? date : end + onSelect({ + name: `${formatToLocalTime(currStart, locale, 'MMM D')} - ${formatToLocalTime(currEnd, locale, 'MMM D')}`, + query: { + start: currStart.format(queryDateFormat), + end: currEnd.format(queryDateFormat), + }, + }) + + setIsCustomRange(true) + } + }, + [start, end, onSelect, locale, queryDateFormat], + ) return ( <div className="flex items-center"> - <RangeSelector - isCustomRange={isCustomRange} - ranges={ranges} - onSelect={handleRangeChange} - /> + <RangeSelector isCustomRange={isCustomRange} ranges={ranges} onSelect={handleRangeChange} /> <HourglassShape className="h-3.5 w-2 text-components-input-bg-normal" /> <DatePicker start={start} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx index dc4538dff5c35f..4094ad0edc394a 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx @@ -2,7 +2,14 @@ import type { FC } from 'react' import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart' import type { I18nKeysByPrefix } from '@/types/i18n' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { RiArrowDownSLine } from '@remixicon/react' import dayjs from 'dayjs' import * as React from 'react' @@ -19,41 +26,42 @@ type TimePeriodOption = { type Props = Readonly<{ isCustomRange: boolean - ranges: { value: number, name: TimePeriodName }[] + ranges: { value: number; name: TimePeriodName }[] onSelect: (payload: PeriodParamsWithTimeRange) => void }> -const RangeSelector: FC<Props> = ({ - isCustomRange, - ranges, - onSelect, -}) => { +const RangeSelector: FC<Props> = ({ isCustomRange, ranges, onSelect }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const items = useMemo<TimePeriodOption[]>(() => { - return ranges.map(range => ({ + return ranges.map((range) => ({ ...range, - name: t($ => $[`filter.period.${range.name}`], { ns: 'appLog' }), + name: t(($) => $[`filter.period.${range.name}`], { ns: 'appLog' }), })) }, [ranges, t]) const [value, setValue] = useState(0) const selectedItem = useMemo(() => { - return items.find(item => item.value === value) ?? null + return items.find((item) => item.value === value) ?? null }, [items, value]) - const handleSelectRange = useCallback((item: TimePeriodOption) => { - const { name, value } = item - let period: TimeRange | null = null - if (value === 0) { - const startOfToday = today.startOf('day') - const endOfToday = today.endOf('day') - period = { start: startOfToday, end: endOfToday } - } - else { - period = { start: today.subtract(item.value, 'day').startOf('day'), end: today.endOf('day') } - } - onSelect({ query: period!, name }) - }, [onSelect]) + const handleSelectRange = useCallback( + (item: TimePeriodOption) => { + const { name, value } = item + let period: TimeRange | null = null + if (value === 0) { + const startOfToday = today.startOf('day') + const endOfToday = today.endOf('day') + period = { start: startOfToday, end: endOfToday } + } else { + period = { + start: today.subtract(item.value, 'day').startOf('day'), + end: today.endOf('day'), + } + } + onSelect({ query: period!, name }) + }, + [onSelect], + ) return ( <Select<number> @@ -61,26 +69,30 @@ const RangeSelector: FC<Props> = ({ open={open} onOpenChange={setOpen} onValueChange={(nextValue) => { - if (nextValue == null) - return - const nextItem = items.find(item => item.value === nextValue) - if (!nextItem) - return + if (nextValue == null) return + const nextItem = items.find((item) => item.value === nextValue) + if (!nextItem) return setValue(nextValue) handleSelectRange(nextItem) }} > - <SelectTrigger - className="h-auto w-fit max-w-none border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden" - > + <SelectTrigger className="h-auto w-fit max-w-none border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"> <div className="flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3 group-data-popup-open:bg-state-base-hover-alt"> - <div className="system-sm-regular text-components-input-text-filled">{isCustomRange ? t($ => $['filter.period.custom'], { ns: 'appLog' }) : selectedItem?.name}</div> + <div className="system-sm-regular text-components-input-text-filled"> + {isCustomRange + ? t(($) => $['filter.period.custom'], { ns: 'appLog' }) + : selectedItem?.name} + </div> <RiArrowDownSLine className="size-4 text-text-quaternary group-data-popup-open:text-text-secondary" /> </div> </SelectTrigger> <SelectContent className="translate-x-[-24px]" popupClassName="w-[200px]" listClassName="p-1"> - {items.map(item => ( - <SelectItem key={item.value} value={item.value} className="h-8 py-0 pr-2 pl-7 system-md-regular"> + {items.map((item) => ( + <SelectItem + key={item.value} + value={item.value} + className="h-8 py-0 pr-2 pl-7 system-md-regular" + > <SelectItemText className="px-0">{item.name}</SelectItemText> <SelectItemIndicator className="absolute top-[8px] left-2 ml-0" /> </SelectItem> diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/panel.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/panel.spec.tsx index 7bf8b7a5d2f19e..656994420451ad 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/panel.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/panel.spec.tsx @@ -18,11 +18,13 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: vi.fn((selector: (state: { appDetail: { permission_keys: string[] } }) => unknown) => selector({ - appDetail: { - permission_keys: testState.appPermissionKeys, - }, - })), + useStore: vi.fn((selector: (state: { appDetail: { permission_keys: string[] } }) => unknown) => + selector({ + appDetail: { + permission_keys: testState.appPermissionKeys, + }, + }), + ), })) vi.mock('@/service/apps', () => ({ @@ -54,7 +56,14 @@ vi.mock('@/app/components/base/icons/src/public/tracing', () => ({ })) vi.mock('../config-button', () => ({ - default: ({ children, ...props }: ComponentProps<'div'> & { readOnly: boolean, hasConfigured: boolean, children?: ReactNode }) => { + default: ({ + children, + ...props + }: ComponentProps<'div'> & { + readOnly: boolean + hasConfigured: boolean + children?: ReactNode + }) => { testState.configButtonProps.push({ readOnly: props.readOnly, hasConfigured: props.hasConfigured, diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/provider-config-modal.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/provider-config-modal.spec.tsx index f9e5ea28eeaa04..ceebf3bdeb37d5 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/provider-config-modal.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/provider-config-modal.spec.tsx @@ -1,4 +1,15 @@ -import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from '../type' +import type { + AliyunConfig, + ArizeConfig, + DatabricksConfig, + LangFuseConfig, + LangSmithConfig, + MLflowConfig, + OpikConfig, + PhoenixConfig, + TencentConfig, + WeaveConfig, +} from '../type' import { toast } from '@langgenius/dify-ui/toast' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -17,7 +28,17 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: vi.fn(), })) -type ProviderPayload = AliyunConfig | ArizeConfig | DatabricksConfig | LangFuseConfig | LangSmithConfig | MLflowConfig | OpikConfig | PhoenixConfig | TencentConfig | WeaveConfig +type ProviderPayload = + | AliyunConfig + | ArizeConfig + | DatabricksConfig + | LangFuseConfig + | LangSmithConfig + | MLflowConfig + | OpikConfig + | PhoenixConfig + | TencentConfig + | WeaveConfig const validConfigs = { [TracingProvider.arize]: { @@ -80,15 +101,41 @@ const validConfigs = { } satisfies Record<TracingProvider, ProviderPayload> const providerFieldLabels = [ - [TracingProvider.arize, ['API Key', 'Space ID', 'app.tracing.configProvider.project', 'Endpoint']], + [ + TracingProvider.arize, + ['API Key', 'Space ID', 'app.tracing.configProvider.project', 'Endpoint'], + ], [TracingProvider.phoenix, ['API Key', 'app.tracing.configProvider.project', 'Endpoint']], [TracingProvider.langSmith, ['API Key', 'app.tracing.configProvider.project', 'Endpoint']], - [TracingProvider.langfuse, ['app.tracing.configProvider.secretKey', 'app.tracing.configProvider.publicKey', 'Host']], + [ + TracingProvider.langfuse, + ['app.tracing.configProvider.secretKey', 'app.tracing.configProvider.publicKey', 'Host'], + ], [TracingProvider.opik, ['API Key', 'app.tracing.configProvider.project', 'Workspace', 'Url']], - [TracingProvider.weave, ['API Key', 'app.tracing.configProvider.project', 'Entity', 'Endpoint', 'Host']], + [ + TracingProvider.weave, + ['API Key', 'app.tracing.configProvider.project', 'Entity', 'Endpoint', 'Host'], + ], [TracingProvider.aliyun, ['License Key', 'Endpoint', 'App Name']], - [TracingProvider.mlflow, ['app.tracing.configProvider.trackingUri', 'app.tracing.configProvider.experimentId', 'app.tracing.configProvider.username', 'app.tracing.configProvider.password']], - [TracingProvider.databricks, ['app.tracing.configProvider.experimentId', 'app.tracing.configProvider.databricksHost', 'app.tracing.configProvider.clientId', 'app.tracing.configProvider.clientSecret', 'app.tracing.configProvider.personalAccessToken']], + [ + TracingProvider.mlflow, + [ + 'app.tracing.configProvider.trackingUri', + 'app.tracing.configProvider.experimentId', + 'app.tracing.configProvider.username', + 'app.tracing.configProvider.password', + ], + ], + [ + TracingProvider.databricks, + [ + 'app.tracing.configProvider.experimentId', + 'app.tracing.configProvider.databricksHost', + 'app.tracing.configProvider.clientId', + 'app.tracing.configProvider.clientSecret', + 'app.tracing.configProvider.personalAccessToken', + ], + ], [TracingProvider.tencent, ['Token', 'Endpoint', 'Service Name']], ] as const @@ -97,27 +144,111 @@ const invalidConfigCases: Array<{ payload: ProviderPayload missingField: string }> = [ - { provider: TracingProvider.arize, payload: { ...validConfigs[TracingProvider.arize], api_key: '' }, missingField: 'API Key' }, - { provider: TracingProvider.arize, payload: { ...validConfigs[TracingProvider.arize], space_id: '' }, missingField: 'Space ID' }, - { provider: TracingProvider.arize, payload: { ...validConfigs[TracingProvider.arize], project: '' }, missingField: 'app.tracing.configProvider.project' }, - { provider: TracingProvider.phoenix, payload: { ...validConfigs[TracingProvider.phoenix], api_key: '' }, missingField: 'API Key' }, - { provider: TracingProvider.phoenix, payload: { ...validConfigs[TracingProvider.phoenix], project: '' }, missingField: 'app.tracing.configProvider.project' }, - { provider: TracingProvider.langSmith, payload: { ...validConfigs[TracingProvider.langSmith], api_key: '' }, missingField: 'API Key' }, - { provider: TracingProvider.langSmith, payload: { ...validConfigs[TracingProvider.langSmith], project: '' }, missingField: 'app.tracing.configProvider.project' }, - { provider: TracingProvider.langfuse, payload: { ...validConfigs[TracingProvider.langfuse], secret_key: '' }, missingField: 'app.tracing.configProvider.secretKey' }, - { provider: TracingProvider.langfuse, payload: { ...validConfigs[TracingProvider.langfuse], public_key: '' }, missingField: 'app.tracing.configProvider.publicKey' }, - { provider: TracingProvider.langfuse, payload: { ...validConfigs[TracingProvider.langfuse], host: '' }, missingField: 'Host' }, - { provider: TracingProvider.weave, payload: { ...validConfigs[TracingProvider.weave], api_key: '' }, missingField: 'API Key' }, - { provider: TracingProvider.weave, payload: { ...validConfigs[TracingProvider.weave], project: '' }, missingField: 'app.tracing.configProvider.project' }, - { provider: TracingProvider.aliyun, payload: { ...validConfigs[TracingProvider.aliyun], app_name: '' }, missingField: 'App Name' }, - { provider: TracingProvider.aliyun, payload: { ...validConfigs[TracingProvider.aliyun], license_key: '' }, missingField: 'License Key' }, - { provider: TracingProvider.aliyun, payload: { ...validConfigs[TracingProvider.aliyun], endpoint: '' }, missingField: 'Endpoint' }, - { provider: TracingProvider.mlflow, payload: { ...validConfigs[TracingProvider.mlflow], tracking_uri: '' }, missingField: 'Tracking URI' }, - { provider: TracingProvider.databricks, payload: { ...validConfigs[TracingProvider.databricks], experiment_id: '' }, missingField: 'Experiment ID' }, - { provider: TracingProvider.databricks, payload: { ...validConfigs[TracingProvider.databricks], host: '' }, missingField: 'Host' }, - { provider: TracingProvider.tencent, payload: { ...validConfigs[TracingProvider.tencent], token: '' }, missingField: 'Token' }, - { provider: TracingProvider.tencent, payload: { ...validConfigs[TracingProvider.tencent], endpoint: '' }, missingField: 'Endpoint' }, - { provider: TracingProvider.tencent, payload: { ...validConfigs[TracingProvider.tencent], service_name: '' }, missingField: 'Service Name' }, + { + provider: TracingProvider.arize, + payload: { ...validConfigs[TracingProvider.arize], api_key: '' }, + missingField: 'API Key', + }, + { + provider: TracingProvider.arize, + payload: { ...validConfigs[TracingProvider.arize], space_id: '' }, + missingField: 'Space ID', + }, + { + provider: TracingProvider.arize, + payload: { ...validConfigs[TracingProvider.arize], project: '' }, + missingField: 'app.tracing.configProvider.project', + }, + { + provider: TracingProvider.phoenix, + payload: { ...validConfigs[TracingProvider.phoenix], api_key: '' }, + missingField: 'API Key', + }, + { + provider: TracingProvider.phoenix, + payload: { ...validConfigs[TracingProvider.phoenix], project: '' }, + missingField: 'app.tracing.configProvider.project', + }, + { + provider: TracingProvider.langSmith, + payload: { ...validConfigs[TracingProvider.langSmith], api_key: '' }, + missingField: 'API Key', + }, + { + provider: TracingProvider.langSmith, + payload: { ...validConfigs[TracingProvider.langSmith], project: '' }, + missingField: 'app.tracing.configProvider.project', + }, + { + provider: TracingProvider.langfuse, + payload: { ...validConfigs[TracingProvider.langfuse], secret_key: '' }, + missingField: 'app.tracing.configProvider.secretKey', + }, + { + provider: TracingProvider.langfuse, + payload: { ...validConfigs[TracingProvider.langfuse], public_key: '' }, + missingField: 'app.tracing.configProvider.publicKey', + }, + { + provider: TracingProvider.langfuse, + payload: { ...validConfigs[TracingProvider.langfuse], host: '' }, + missingField: 'Host', + }, + { + provider: TracingProvider.weave, + payload: { ...validConfigs[TracingProvider.weave], api_key: '' }, + missingField: 'API Key', + }, + { + provider: TracingProvider.weave, + payload: { ...validConfigs[TracingProvider.weave], project: '' }, + missingField: 'app.tracing.configProvider.project', + }, + { + provider: TracingProvider.aliyun, + payload: { ...validConfigs[TracingProvider.aliyun], app_name: '' }, + missingField: 'App Name', + }, + { + provider: TracingProvider.aliyun, + payload: { ...validConfigs[TracingProvider.aliyun], license_key: '' }, + missingField: 'License Key', + }, + { + provider: TracingProvider.aliyun, + payload: { ...validConfigs[TracingProvider.aliyun], endpoint: '' }, + missingField: 'Endpoint', + }, + { + provider: TracingProvider.mlflow, + payload: { ...validConfigs[TracingProvider.mlflow], tracking_uri: '' }, + missingField: 'Tracking URI', + }, + { + provider: TracingProvider.databricks, + payload: { ...validConfigs[TracingProvider.databricks], experiment_id: '' }, + missingField: 'Experiment ID', + }, + { + provider: TracingProvider.databricks, + payload: { ...validConfigs[TracingProvider.databricks], host: '' }, + missingField: 'Host', + }, + { + provider: TracingProvider.tencent, + payload: { ...validConfigs[TracingProvider.tencent], token: '' }, + missingField: 'Token', + }, + { + provider: TracingProvider.tencent, + payload: { ...validConfigs[TracingProvider.tencent], endpoint: '' }, + missingField: 'Endpoint', + }, + { + provider: TracingProvider.tencent, + payload: { ...validConfigs[TracingProvider.tencent], service_name: '' }, + missingField: 'Service Name', + }, ] const renderConfigButton = () => { @@ -199,27 +330,38 @@ describe('ProviderConfigModal', () => { expect(configActions.length).toBeGreaterThan(0) await user.click(configActions[0]!) await waitFor(() => { - expect(screen.getByText('app.tracing.configProvider.titleapp.tracing.langfuse.title')).toBeInTheDocument() + expect( + screen.getByText('app.tracing.configProvider.titleapp.tracing.langfuse.title'), + ).toBeInTheDocument() }) expect(screen.getByRole('dialog')).toBeInTheDocument() await user.click(screen.getByPlaceholderText('https://cloud.langfuse.com')) expect(screen.getByText('app.tracing.tracing')).toBeInTheDocument() - expect(screen.getByText('app.tracing.configProvider.titleapp.tracing.langfuse.title')).toBeInTheDocument() + expect( + screen.getByText('app.tracing.configProvider.titleapp.tracing.langfuse.title'), + ).toBeInTheDocument() }) }) describe('Rendering', () => { - it.each(providerFieldLabels)('should render %s fields when adding a provider', (provider, expectedLabels) => { - renderProviderConfigModal({ type: provider }) - - expect(screen.getByText(`app.tracing.configProvider.titleapp.tracing.${provider}.title`)).toBeInTheDocument() - expectedLabels.forEach((label) => { - expect(screen.getByText(label)).toBeInTheDocument() - }) - expect(screen.getByRole('button', { name: 'common.operation.saveAndEnable' })).toBeInTheDocument() - }) + it.each(providerFieldLabels)( + 'should render %s fields when adding a provider', + (provider, expectedLabels) => { + renderProviderConfigModal({ type: provider }) + + expect( + screen.getByText(`app.tracing.configProvider.titleapp.tracing.${provider}.title`), + ).toBeInTheDocument() + expectedLabels.forEach((label) => { + expect(screen.getByText(label)).toBeInTheDocument() + }) + expect( + screen.getByRole('button', { name: 'common.operation.saveAndEnable' }), + ).toBeInTheDocument() + }, + ) }) describe('Saving', () => { @@ -247,43 +389,46 @@ describe('ProviderConfigModal', () => { expect(toast).toHaveBeenCalledWith('common.api.success', { type: 'success' }) }) - it.each(Object.values(TracingProvider))('should update valid %s config in edit mode', async (provider) => { - const user = userEvent.setup() - const callbacks = renderProviderConfigModal({ - type: provider, - payload: validConfigs[provider], - }) + it.each(Object.values(TracingProvider))( + 'should update valid %s config in edit mode', + async (provider) => { + const user = userEvent.setup() + const callbacks = renderProviderConfigModal({ + type: provider, + payload: validConfigs[provider], + }) - await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) - await waitFor(() => { - expect(updateTracingConfig).toHaveBeenCalledWith({ - appId: 'app-id', - body: { - tracing_provider: provider, - tracing_config: validConfigs[provider], - }, + await waitFor(() => { + expect(updateTracingConfig).toHaveBeenCalledWith({ + appId: 'app-id', + body: { + tracing_provider: provider, + tracing_config: validConfigs[provider], + }, + }) + }) + expect(callbacks.onSaved).toHaveBeenCalledWith(validConfigs[provider]) + expect(callbacks.onChosen).not.toHaveBeenCalled() + }, + ) + + it.each(invalidConfigCases)( + 'should reject $provider config when $missingField is missing', + async ({ provider, payload, missingField }) => { + const user = userEvent.setup() + renderProviderConfigModal({ + type: provider, + payload, }) - }) - expect(callbacks.onSaved).toHaveBeenCalledWith(validConfigs[provider]) - expect(callbacks.onChosen).not.toHaveBeenCalled() - }) - - it.each(invalidConfigCases)('should reject $provider config when $missingField is missing', async ({ provider, payload, missingField }) => { - const user = userEvent.setup() - renderProviderConfigModal({ - type: provider, - payload, - }) - await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(updateTracingConfig).not.toHaveBeenCalled() - expect(toast).toHaveBeenCalledWith( - expect.stringContaining(missingField), - { type: 'error' }, - ) - }) + expect(updateTracingConfig).not.toHaveBeenCalled() + expect(toast).toHaveBeenCalledWith(expect.stringContaining(missingField), { type: 'error' }) + }, + ) }) describe('Closing And Removing', () => { @@ -315,7 +460,11 @@ describe('ProviderConfigModal', () => { }) await user.click(screen.getByRole('button', { name: 'common.operation.remove' })) - expect(screen.getByText('app.tracing.configProvider.removeConfirmTitle:{"key":"app.tracing.langfuse.title"}')).toBeInTheDocument() + expect( + screen.getByText( + 'app.tracing.configProvider.removeConfirmTitle:{"key":"app.tracing.langfuse.title"}', + ), + ).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) @@ -340,7 +489,9 @@ describe('ProviderConfigModal', () => { await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) expect(removeTracingConfig).not.toHaveBeenCalled() - expect(screen.getByText('app.tracing.configProvider.titleapp.tracing.langfuse.title')).toBeInTheDocument() + expect( + screen.getByText('app.tracing.configProvider.titleapp.tracing.langfuse.title'), + ).toBeInTheDocument() }) }) }) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx index fffc1ff2a56ff0..0a1c5932cf75be 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx @@ -30,8 +30,8 @@ describe('SVG Attribute Error Reproduction', () => { const { unmount } = render(<OpikIconBig />) // Check for specific inkscape attribute errors - const inkscapeErrors = errorMessages.filter(msg => - typeof msg === 'string' && msg.includes('inkscape'), + const inkscapeErrors = errorMessages.filter( + (msg) => typeof msg === 'string' && msg.includes('inkscape'), ) if (inkscapeErrors.length > 0) { @@ -39,8 +39,7 @@ describe('SVG Attribute Error Reproduction', () => { inkscapeErrors.forEach((error, index) => { console.log(` ${index + 1}. ${error.substring(0, 100)}...`) }) - } - else { + } else { console.log('No inkscape errors found in this render') } @@ -72,8 +71,8 @@ describe('SVG Attribute Error Reproduction', () => { // Check attributes for inkscape/sodipodi properties if (node.attributes) { - const problematicAttrs = Object.keys(node.attributes).filter(attr => - attr.startsWith('inkscape:') || attr.startsWith('sodipodi:'), + const problematicAttrs = Object.keys(node.attributes).filter( + (attr) => attr.startsWith('inkscape:') || attr.startsWith('sodipodi:'), ) if (problematicAttrs.length > 0) { @@ -121,7 +120,7 @@ describe('SVG Attribute Error Reproduction', () => { 'xmlns:svg': 'https://www.w3.org/2000/svg', 'data-name': 'Layer 1', 'normal-attr': 'value', - 'class': 'test-class', + class: 'test-class', } console.log('Input attributes:', Object.keys(testAttributes)) @@ -132,13 +131,12 @@ describe('SVG Attribute Error Reproduction', () => { console.log('Normalized values:', normalized) // Check if problematic attributes are still present - const problematicKeys = Object.keys(normalized).filter(key => - key.toLowerCase().includes('inkscape') || key.toLowerCase().includes('sodipodi'), + const problematicKeys = Object.keys(normalized).filter( + (key) => key.toLowerCase().includes('inkscape') || key.toLowerCase().includes('sodipodi'), ) if (problematicKeys.length > 0) console.log(`🚨 PROBLEM: Still found problematic attributes: ${problematicKeys.join(', ')}`) - else - console.log('✅ No problematic attributes found after normalization') + else console.log('✅ No problematic attributes found after normalization') }) }) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx index d587c3bd8436e1..846b01e1711eee 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx @@ -1,13 +1,8 @@ 'use client' import type { FC } from 'react' import type { PopupProps } from './config-popup' - import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useState } from 'react' import ConfigPopup from './config-popup' @@ -17,31 +12,17 @@ type Props = Readonly<{ className?: string hasConfigured: boolean children?: React.ReactNode -}> & PopupProps +}> & + PopupProps -const ConfigBtn: FC<Props> = ({ - className, - hasConfigured, - children, - ...popupProps -}) => { +const ConfigBtn: FC<Props> = ({ className, hasConfigured, children, ...popupProps }) => { const [open, setOpen] = useState(false) - if (popupProps.readOnly && !hasConfigured) - return null + if (popupProps.readOnly && !hasConfigured) return null return ( - <Popover - open={open} - onOpenChange={setOpen} - > - <PopoverTrigger - render={( - <div className={cn('select-none', className)}> - {children} - </div> - )} - /> + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger render={<div className={cn('select-none', className)}>{children}</div>} /> <PopoverContent placement="bottom-end" sideOffset={12} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx index 0b8e655909df23..aef1069ed4ac87 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx @@ -1,6 +1,17 @@ 'use client' import type { FC, JSX } from 'react' -import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type' +import type { + AliyunConfig, + ArizeConfig, + DatabricksConfig, + LangFuseConfig, + LangSmithConfig, + MLflowConfig, + OpikConfig, + PhoenixConfig, + TencentConfig, + WeaveConfig, +} from './type' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Switch } from '@langgenius/dify-ui/switch' @@ -34,7 +45,20 @@ export type PopupProps = { mlflowConfig: MLflowConfig | null databricksConfig: DatabricksConfig | null tencentConfig: TencentConfig | null - onConfigUpdated: (provider: TracingProvider, payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | TencentConfig | MLflowConfig | DatabricksConfig) => void + onConfigUpdated: ( + provider: TracingProvider, + payload: + | ArizeConfig + | PhoenixConfig + | LangSmithConfig + | LangFuseConfig + | OpikConfig + | WeaveConfig + | AliyunConfig + | TencentConfig + | MLflowConfig + | DatabricksConfig, + ) => void onConfigRemoved: (provider: TracingProvider) => void } @@ -60,36 +84,77 @@ const ConfigPopup: FC<PopupProps> = ({ }) => { const { t } = useTranslation() - const [currentProvider, setCurrentProvider] = useState<TracingProvider | null>(TracingProvider.langfuse) - const [isShowConfigModal, { - setTrue: showConfigModal, - setFalse: hideConfigModal, - }] = useBoolean(false) - const handleOnConfig = useCallback((provider: TracingProvider) => { - return () => { - setCurrentProvider(provider) - showConfigModal() - } - }, [showConfigModal]) - - const handleOnChoose = useCallback((provider: TracingProvider) => { - return () => { - onChooseProvider(provider) - } - }, [onChooseProvider]) - - const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | MLflowConfig | DatabricksConfig | TencentConfig) => { - onConfigUpdated(currentProvider!, payload) - hideConfigModal() - }, [currentProvider, hideConfigModal, onConfigUpdated]) + const [currentProvider, setCurrentProvider] = useState<TracingProvider | null>( + TracingProvider.langfuse, + ) + const [isShowConfigModal, { setTrue: showConfigModal, setFalse: hideConfigModal }] = + useBoolean(false) + const handleOnConfig = useCallback( + (provider: TracingProvider) => { + return () => { + setCurrentProvider(provider) + showConfigModal() + } + }, + [showConfigModal], + ) + + const handleOnChoose = useCallback( + (provider: TracingProvider) => { + return () => { + onChooseProvider(provider) + } + }, + [onChooseProvider], + ) + + const handleConfigUpdated = useCallback( + ( + payload: + | ArizeConfig + | PhoenixConfig + | LangSmithConfig + | LangFuseConfig + | OpikConfig + | WeaveConfig + | AliyunConfig + | MLflowConfig + | DatabricksConfig + | TencentConfig, + ) => { + onConfigUpdated(currentProvider!, payload) + hideConfigModal() + }, + [currentProvider, hideConfigModal, onConfigUpdated], + ) const handleConfigRemoved = useCallback(() => { onConfigRemoved(currentProvider!) hideConfigModal() }, [currentProvider, hideConfigModal, onConfigRemoved]) - const providerAllConfigured = arizeConfig && phoenixConfig && langSmithConfig && langFuseConfig && opikConfig && weaveConfig && aliyunConfig && mlflowConfig && databricksConfig && tencentConfig - const providerAllNotConfigured = !arizeConfig && !phoenixConfig && !langSmithConfig && !langFuseConfig && !opikConfig && !weaveConfig && !aliyunConfig && !mlflowConfig && !databricksConfig && !tencentConfig + const providerAllConfigured = + arizeConfig && + phoenixConfig && + langSmithConfig && + langFuseConfig && + opikConfig && + weaveConfig && + aliyunConfig && + mlflowConfig && + databricksConfig && + tencentConfig + const providerAllNotConfigured = + !arizeConfig && + !phoenixConfig && + !langSmithConfig && + !langFuseConfig && + !opikConfig && + !weaveConfig && + !aliyunConfig && + !mlflowConfig && + !databricksConfig && + !tencentConfig const switchContent = ( <Switch @@ -231,35 +296,25 @@ const ConfigPopup: FC<PopupProps> = ({ const configuredProviderPanel = () => { const configuredPanels: JSX.Element[] = [] - if (langFuseConfig) - configuredPanels.push(langfusePanel) + if (langFuseConfig) configuredPanels.push(langfusePanel) - if (langSmithConfig) - configuredPanels.push(langSmithPanel) + if (langSmithConfig) configuredPanels.push(langSmithPanel) - if (opikConfig) - configuredPanels.push(opikPanel) + if (opikConfig) configuredPanels.push(opikPanel) - if (weaveConfig) - configuredPanels.push(weavePanel) + if (weaveConfig) configuredPanels.push(weavePanel) - if (arizeConfig) - configuredPanels.push(arizePanel) + if (arizeConfig) configuredPanels.push(arizePanel) - if (phoenixConfig) - configuredPanels.push(phoenixPanel) + if (phoenixConfig) configuredPanels.push(phoenixPanel) - if (aliyunConfig) - configuredPanels.push(aliyunPanel) + if (aliyunConfig) configuredPanels.push(aliyunPanel) - if (mlflowConfig) - configuredPanels.push(mlflowPanel) + if (mlflowConfig) configuredPanels.push(mlflowPanel) - if (databricksConfig) - configuredPanels.push(databricksPanel) + if (databricksConfig) configuredPanels.push(databricksPanel) - if (tencentConfig) - configuredPanels.push(tencentPanel) + if (tencentConfig) configuredPanels.push(tencentPanel) return configuredPanels } @@ -267,58 +322,39 @@ const ConfigPopup: FC<PopupProps> = ({ const moreProviderPanel = () => { const notConfiguredPanels: JSX.Element[] = [] - if (!arizeConfig) - notConfiguredPanels.push(arizePanel) + if (!arizeConfig) notConfiguredPanels.push(arizePanel) - if (!phoenixConfig) - notConfiguredPanels.push(phoenixPanel) + if (!phoenixConfig) notConfiguredPanels.push(phoenixPanel) - if (!langFuseConfig) - notConfiguredPanels.push(langfusePanel) + if (!langFuseConfig) notConfiguredPanels.push(langfusePanel) - if (!langSmithConfig) - notConfiguredPanels.push(langSmithPanel) + if (!langSmithConfig) notConfiguredPanels.push(langSmithPanel) - if (!opikConfig) - notConfiguredPanels.push(opikPanel) + if (!opikConfig) notConfiguredPanels.push(opikPanel) - if (!weaveConfig) - notConfiguredPanels.push(weavePanel) + if (!weaveConfig) notConfiguredPanels.push(weavePanel) - if (!aliyunConfig) - notConfiguredPanels.push(aliyunPanel) + if (!aliyunConfig) notConfiguredPanels.push(aliyunPanel) - if (!mlflowConfig) - notConfiguredPanels.push(mlflowPanel) + if (!mlflowConfig) notConfiguredPanels.push(mlflowPanel) - if (!databricksConfig) - notConfiguredPanels.push(databricksPanel) + if (!databricksConfig) notConfiguredPanels.push(databricksPanel) - if (!tencentConfig) - notConfiguredPanels.push(tencentPanel) + if (!tencentConfig) notConfiguredPanels.push(tencentPanel) return notConfiguredPanels } const configuredProviderConfig = () => { - if (currentProvider === TracingProvider.mlflow) - return mlflowConfig - if (currentProvider === TracingProvider.databricks) - return databricksConfig - if (currentProvider === TracingProvider.arize) - return arizeConfig - if (currentProvider === TracingProvider.phoenix) - return phoenixConfig - if (currentProvider === TracingProvider.langSmith) - return langSmithConfig - if (currentProvider === TracingProvider.langfuse) - return langFuseConfig - if (currentProvider === TracingProvider.opik) - return opikConfig - if (currentProvider === TracingProvider.aliyun) - return aliyunConfig - if (currentProvider === TracingProvider.tencent) - return tencentConfig + if (currentProvider === TracingProvider.mlflow) return mlflowConfig + if (currentProvider === TracingProvider.databricks) return databricksConfig + if (currentProvider === TracingProvider.arize) return arizeConfig + if (currentProvider === TracingProvider.phoenix) return phoenixConfig + if (currentProvider === TracingProvider.langSmith) return langSmithConfig + if (currentProvider === TracingProvider.langfuse) return langFuseConfig + if (currentProvider === TracingProvider.opik) return opikConfig + if (currentProvider === TracingProvider.aliyun) return aliyunConfig + if (currentProvider === TracingProvider.tencent) return tencentConfig return weaveConfig } @@ -327,68 +363,80 @@ const ConfigPopup: FC<PopupProps> = ({ <div className="flex items-center justify-between"> <div className="flex items-center"> <TracingIcon size="md" className="mr-2" /> - <div className="title-2xl-semi-bold text-text-primary">{t($ => $[`${I18N_PREFIX}.tracing`], { ns: 'app' })}</div> + <div className="title-2xl-semi-bold text-text-primary"> + {t(($) => $[`${I18N_PREFIX}.tracing`], { ns: 'app' })} + </div> </div> <div className="flex items-center"> <StatusDot status={enabled ? 'success' : 'disabled'} /> - <div className={cn('ml-1 system-xs-semibold-uppercase text-text-tertiary', enabled && 'text-util-colors-green-green-600')}> - {t($ => $[`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`], { ns: 'app' })} + <div + className={cn( + 'ml-1 system-xs-semibold-uppercase text-text-tertiary', + enabled && 'text-util-colors-green-green-600', + )} + > + {t(($) => $[`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`], { ns: 'app' })} </div> {!readOnly && ( <> - {providerAllNotConfigured - ? ( - <Tooltip> - <TooltipTrigger - render={switchContent} - /> - <TooltipContent> - {t($ => $[`${I18N_PREFIX}.disabledTip`], { ns: 'app' })} - </TooltipContent> - </Tooltip> - ) - : switchContent} + {providerAllNotConfigured ? ( + <Tooltip> + <TooltipTrigger render={switchContent} /> + <TooltipContent> + {t(($) => $[`${I18N_PREFIX}.disabledTip`], { ns: 'app' })} + </TooltipContent> + </Tooltip> + ) : ( + switchContent + )} </> )} </div> </div> <div className="mt-2 system-xs-regular text-text-tertiary"> - {t($ => $[`${I18N_PREFIX}.tracingDescription`], { ns: 'app' })} + {t(($) => $[`${I18N_PREFIX}.tracingDescription`], { ns: 'app' })} </div> <Divider className="my-3" /> <div className="relative"> - {(providerAllConfigured || providerAllNotConfigured) - ? ( - <> - <div className="system-xs-medium-uppercase text-text-tertiary">{t($ => $[`${I18N_PREFIX}.configProviderTitle.${providerAllConfigured ? 'configured' : 'notConfigured'}`], { ns: 'app' })}</div> - <div className="mt-2 max-h-96 space-y-2 overflow-y-auto"> - {langfusePanel} - {langSmithPanel} - {opikPanel} - {mlflowPanel} - {databricksPanel} - {weavePanel} - {arizePanel} - {phoenixPanel} - {aliyunPanel} - {tencentPanel} - </div> - </> - ) - : ( - <> - <div className="system-xs-medium-uppercase text-text-tertiary">{t($ => $[`${I18N_PREFIX}.configProviderTitle.configured`], { ns: 'app' })}</div> - <div className="mt-2 max-h-40 space-y-2 overflow-y-auto"> - {configuredProviderPanel()} - </div> - <div className="mt-3 system-xs-medium-uppercase text-text-tertiary">{t($ => $[`${I18N_PREFIX}.configProviderTitle.moreProvider`], { ns: 'app' })}</div> - <div className="mt-2 max-h-40 space-y-2 overflow-y-auto"> - {moreProviderPanel()} - </div> - </> - )} - + {providerAllConfigured || providerAllNotConfigured ? ( + <> + <div className="system-xs-medium-uppercase text-text-tertiary"> + {t( + ($) => + $[ + `${I18N_PREFIX}.configProviderTitle.${providerAllConfigured ? 'configured' : 'notConfigured'}` + ], + { ns: 'app' }, + )} + </div> + <div className="mt-2 max-h-96 space-y-2 overflow-y-auto"> + {langfusePanel} + {langSmithPanel} + {opikPanel} + {mlflowPanel} + {databricksPanel} + {weavePanel} + {arizePanel} + {phoenixPanel} + {aliyunPanel} + {tencentPanel} + </div> + </> + ) : ( + <> + <div className="system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $[`${I18N_PREFIX}.configProviderTitle.configured`], { ns: 'app' })} + </div> + <div className="mt-2 max-h-40 space-y-2 overflow-y-auto"> + {configuredProviderPanel()} + </div> + <div className="mt-3 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $[`${I18N_PREFIX}.configProviderTitle.moreProvider`], { ns: 'app' })} + </div> + <div className="mt-2 max-h-40 space-y-2 overflow-y-auto">{moreProviderPanel()}</div> + </> + )} </div> {isShowConfigModal && ( <ProviderConfigModal diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts index 71f5b009d34351..fc342a8745fde1 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts @@ -7,7 +7,8 @@ export const docURL = { [TracingProvider.langfuse]: 'https://docs.langfuse.com', [TracingProvider.opik]: 'https://www.comet.com/docs/opik/integrations/dify', [TracingProvider.weave]: 'https://weave-docs.wandb.ai/', - [TracingProvider.aliyun]: 'https://help.aliyun.com/zh/arms/tracing-analysis/untitled-document-1750672984680', + [TracingProvider.aliyun]: + 'https://help.aliyun.com/zh/arms/tracing-analysis/untitled-document-1750672984680', [TracingProvider.mlflow]: 'https://mlflow.org/docs/latest/genai/', [TracingProvider.databricks]: 'https://docs.databricks.com/aws/en/mlflow3/genai/tracing/', [TracingProvider.tencent]: 'https://cloud.tencent.com/document/product/248/116531', diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx index 5b1fc27a9352f7..eb4b1ce88c44e5 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx @@ -26,15 +26,19 @@ const Field: FC<Props> = ({ return ( <div className={cn(className)}> <div className="flex py-[7px]"> - <div className={cn(labelClassName, 'flex h-[18px] items-center text-[13px] font-medium text-text-primary')}> - {label} - {' '} + <div + className={cn( + labelClassName, + 'flex h-[18px] items-center text-[13px] font-medium text-text-primary', + )} + > + {label}{' '} </div> {isRequired && <span className="ml-0.5 text-xs font-semibold text-[#D92D20]">*</span>} </div> <Input value={value} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} className="h-9" placeholder={placeholder} /> diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx index 249ce83cfad934..52a34256507974 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx @@ -1,6 +1,17 @@ 'use client' import type { FC } from 'react' -import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type' +import type { + AliyunConfig, + ArizeConfig, + DatabricksConfig, + LangFuseConfig, + LangSmithConfig, + MLflowConfig, + OpikConfig, + PhoenixConfig, + TencentConfig, + WeaveConfig, +} from './type' import type { TracingStatus } from '@/models/app' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' @@ -28,7 +39,11 @@ import Loading from '@/app/components/base/loading' import { userProfileIdAtom } from '@/context/account-state' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { usePathname } from '@/next/navigation' -import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps' +import { + fetchTracingConfig as doFetchTracingConfig, + fetchTracingStatus, + updateTracingStatus, +} from '@/service/apps' import { getAppACLCapabilities } from '@/utils/permission' import ConfigButton from './config-button' import TracingIcon from './tracing-icon' @@ -40,21 +55,23 @@ const Panel: FC = () => { const { t } = useTranslation() const pathname = usePathname() const matched = /\/app\/([^/]+)/.exec(pathname) - const appId = (matched?.length && matched[1]) ? matched[1] : '' + const appId = matched?.length && matched[1] ? matched[1] : '' const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const appDetail = useAppStore(s => s.appDetail) - const appACLCapabilities = React.useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { - currentUserId, - resourceMaintainer: appDetail?.maintainer, - workspacePermissionKeys, - }), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys]) + const appDetail = useAppStore((s) => s.appDetail) + const appACLCapabilities = React.useMemo( + () => + getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }), + [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], + ) const canConfigTracing = appACLCapabilities.canConfigureTracing const readOnly = !canConfigTracing - const [isLoaded, { - setTrue: setLoaded, - }] = useBoolean(false) + const [isLoaded, { setTrue: setLoaded }] = useBoolean(false) const [tracingStatus, setTracingStatus] = useState<TracingStatus | null>(null) const enabled = tracingStatus?.enabled || false @@ -62,7 +79,10 @@ const Panel: FC = () => { await updateTracingStatus({ appId, body: tracingStatus }) setTracingStatus(tracingStatus) if (!noToast) { - toast(t($ => $['api.success'], { ns: 'common' }), { type: 'success' }) + toast( + t(($) => $['api.success'], { ns: 'common' }), + { type: 'success' }, + ) } } @@ -104,58 +124,69 @@ const Panel: FC = () => { const [mlflowConfig, setMLflowConfig] = useState<MLflowConfig | null>(null) const [databricksConfig, setDatabricksConfig] = useState<DatabricksConfig | null>(null) const [tencentConfig, setTencentConfig] = useState<TencentConfig | null>(null) - const hasConfiguredTracing = !!(langSmithConfig || langFuseConfig || opikConfig || weaveConfig || arizeConfig || phoenixConfig || aliyunConfig || mlflowConfig || databricksConfig || tencentConfig) + const hasConfiguredTracing = !!( + langSmithConfig || + langFuseConfig || + opikConfig || + weaveConfig || + arizeConfig || + phoenixConfig || + aliyunConfig || + mlflowConfig || + databricksConfig || + tencentConfig + ) const fetchTracingConfig = async () => { const getArizeConfig = async () => { - const { tracing_config: arizeConfig, has_not_configured: arizeHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.arize }) - if (!arizeHasNotConfig) - setArizeConfig(arizeConfig as ArizeConfig) + const { tracing_config: arizeConfig, has_not_configured: arizeHasNotConfig } = + await doFetchTracingConfig({ appId, provider: TracingProvider.arize }) + if (!arizeHasNotConfig) setArizeConfig(arizeConfig as ArizeConfig) } const getPhoenixConfig = async () => { - const { tracing_config: phoenixConfig, has_not_configured: phoenixHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.phoenix }) - if (!phoenixHasNotConfig) - setPhoenixConfig(phoenixConfig as PhoenixConfig) + const { tracing_config: phoenixConfig, has_not_configured: phoenixHasNotConfig } = + await doFetchTracingConfig({ appId, provider: TracingProvider.phoenix }) + if (!phoenixHasNotConfig) setPhoenixConfig(phoenixConfig as PhoenixConfig) } const getLangSmithConfig = async () => { - const { tracing_config: langSmithConfig, has_not_configured: langSmithHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.langSmith }) - if (!langSmithHasNotConfig) - setLangSmithConfig(langSmithConfig as LangSmithConfig) + const { tracing_config: langSmithConfig, has_not_configured: langSmithHasNotConfig } = + await doFetchTracingConfig({ appId, provider: TracingProvider.langSmith }) + if (!langSmithHasNotConfig) setLangSmithConfig(langSmithConfig as LangSmithConfig) } const getLangFuseConfig = async () => { - const { tracing_config: langFuseConfig, has_not_configured: langFuseHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.langfuse }) - if (!langFuseHasNotConfig) - setLangFuseConfig(langFuseConfig as LangFuseConfig) + const { tracing_config: langFuseConfig, has_not_configured: langFuseHasNotConfig } = + await doFetchTracingConfig({ appId, provider: TracingProvider.langfuse }) + if (!langFuseHasNotConfig) setLangFuseConfig(langFuseConfig as LangFuseConfig) } const getOpikConfig = async () => { - const { tracing_config: opikConfig, has_not_configured: OpikHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.opik }) - if (!OpikHasNotConfig) - setOpikConfig(opikConfig as OpikConfig) + const { tracing_config: opikConfig, has_not_configured: OpikHasNotConfig } = + await doFetchTracingConfig({ appId, provider: TracingProvider.opik }) + if (!OpikHasNotConfig) setOpikConfig(opikConfig as OpikConfig) } const getWeaveConfig = async () => { - const { tracing_config: weaveConfig, has_not_configured: weaveHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.weave }) - if (!weaveHasNotConfig) - setWeaveConfig(weaveConfig as WeaveConfig) + const { tracing_config: weaveConfig, has_not_configured: weaveHasNotConfig } = + await doFetchTracingConfig({ appId, provider: TracingProvider.weave }) + if (!weaveHasNotConfig) setWeaveConfig(weaveConfig as WeaveConfig) } const getAliyunConfig = async () => { - const { tracing_config: aliyunConfig, has_not_configured: aliyunHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.aliyun }) - if (!aliyunHasNotConfig) - setAliyunConfig(aliyunConfig as AliyunConfig) + const { tracing_config: aliyunConfig, has_not_configured: aliyunHasNotConfig } = + await doFetchTracingConfig({ appId, provider: TracingProvider.aliyun }) + if (!aliyunHasNotConfig) setAliyunConfig(aliyunConfig as AliyunConfig) } const getMLflowConfig = async () => { - const { tracing_config: mlflowConfig, has_not_configured: mlflowHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.mlflow }) - if (!mlflowHasNotConfig) - setMLflowConfig(mlflowConfig as MLflowConfig) + const { tracing_config: mlflowConfig, has_not_configured: mlflowHasNotConfig } = + await doFetchTracingConfig({ appId, provider: TracingProvider.mlflow }) + if (!mlflowHasNotConfig) setMLflowConfig(mlflowConfig as MLflowConfig) } const getDatabricksConfig = async () => { - const { tracing_config: databricksConfig, has_not_configured: databricksHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.databricks }) - if (!databricksHasNotConfig) - setDatabricksConfig(databricksConfig as DatabricksConfig) + const { tracing_config: databricksConfig, has_not_configured: databricksHasNotConfig } = + await doFetchTracingConfig({ appId, provider: TracingProvider.databricks }) + if (!databricksHasNotConfig) setDatabricksConfig(databricksConfig as DatabricksConfig) } const getTencentConfig = async () => { - const { tracing_config: tencentConfig, has_not_configured: tencentHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.tencent }) - if (!tencentHasNotConfig) - setTencentConfig(tencentConfig as TencentConfig) + const { tracing_config: tencentConfig, has_not_configured: tencentHasNotConfig } = + await doFetchTracingConfig({ appId, provider: TracingProvider.tencent }) + if (!tencentHasNotConfig) setTencentConfig(tencentConfig as TencentConfig) } Promise.all([ getArizeConfig(), @@ -174,55 +205,42 @@ const Panel: FC = () => { const handleTracingConfigUpdated = async (provider: TracingProvider) => { // call api to hide secret key value const { tracing_config } = await doFetchTracingConfig({ appId, provider }) - if (provider === TracingProvider.arize) - setArizeConfig(tracing_config as ArizeConfig) - else if (provider === TracingProvider.phoenix) - setPhoenixConfig(tracing_config as PhoenixConfig) + if (provider === TracingProvider.arize) setArizeConfig(tracing_config as ArizeConfig) + else if (provider === TracingProvider.phoenix) setPhoenixConfig(tracing_config as PhoenixConfig) else if (provider === TracingProvider.langSmith) setLangSmithConfig(tracing_config as LangSmithConfig) else if (provider === TracingProvider.langfuse) setLangFuseConfig(tracing_config as LangFuseConfig) - else if (provider === TracingProvider.opik) - setOpikConfig(tracing_config as OpikConfig) - else if (provider === TracingProvider.weave) - setWeaveConfig(tracing_config as WeaveConfig) - else if (provider === TracingProvider.aliyun) - setAliyunConfig(tracing_config as AliyunConfig) - else if (provider === TracingProvider.tencent) - setTencentConfig(tracing_config as TencentConfig) + else if (provider === TracingProvider.opik) setOpikConfig(tracing_config as OpikConfig) + else if (provider === TracingProvider.weave) setWeaveConfig(tracing_config as WeaveConfig) + else if (provider === TracingProvider.aliyun) setAliyunConfig(tracing_config as AliyunConfig) + else if (provider === TracingProvider.tencent) setTencentConfig(tracing_config as TencentConfig) } const handleTracingConfigRemoved = (provider: TracingProvider) => { - if (provider === TracingProvider.arize) - setArizeConfig(null) - else if (provider === TracingProvider.phoenix) - setPhoenixConfig(null) - else if (provider === TracingProvider.langSmith) - setLangSmithConfig(null) - else if (provider === TracingProvider.langfuse) - setLangFuseConfig(null) - else if (provider === TracingProvider.opik) - setOpikConfig(null) - else if (provider === TracingProvider.weave) - setWeaveConfig(null) - else if (provider === TracingProvider.aliyun) - setAliyunConfig(null) - else if (provider === TracingProvider.mlflow) - setMLflowConfig(null) - else if (provider === TracingProvider.databricks) - setDatabricksConfig(null) - else if (provider === TracingProvider.tencent) - setTencentConfig(null) + if (provider === TracingProvider.arize) setArizeConfig(null) + else if (provider === TracingProvider.phoenix) setPhoenixConfig(null) + else if (provider === TracingProvider.langSmith) setLangSmithConfig(null) + else if (provider === TracingProvider.langfuse) setLangFuseConfig(null) + else if (provider === TracingProvider.opik) setOpikConfig(null) + else if (provider === TracingProvider.weave) setWeaveConfig(null) + else if (provider === TracingProvider.aliyun) setAliyunConfig(null) + else if (provider === TracingProvider.mlflow) setMLflowConfig(null) + else if (provider === TracingProvider.databricks) setDatabricksConfig(null) + else if (provider === TracingProvider.tencent) setTencentConfig(null) if (provider === inUseTracingProvider) { - handleTracingStatusChange({ - enabled: false, - tracing_provider: null, - }, true) + handleTracingStatusChange( + { + enabled: false, + tracing_provider: null, + }, + true, + ) } } useEffect(() => { - (async () => { + ;(async () => { const tracingStatus = await fetchTracingStatus({ appId }) setTracingStatus(tracingStatus) await fetchTracingConfig() @@ -270,7 +288,9 @@ const Panel: FC = () => { )} > <TracingIcon size="md" /> - <div className="mx-2 system-sm-semibold text-text-secondary">{t($ => $[`${I18N_PREFIX}.title`], { ns: 'app' })}</div> + <div className="mx-2 system-sm-semibold text-text-secondary"> + {t(($) => $[`${I18N_PREFIX}.title`], { ns: 'app' })} + </div> <div className="rounded-md p-1"> <span className="i-ri-equalizer-2-line size-4 text-text-tertiary" /> </div> @@ -311,7 +331,7 @@ const Panel: FC = () => { <div className="mr-1 ml-4 flex items-center"> <StatusDot status={enabled ? 'success' : 'disabled'} /> <div className="ml-1.5 system-xs-semibold-uppercase text-text-tertiary"> - {t($ => $[`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`], { ns: 'app' })} + {t(($) => $[`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`], { ns: 'app' })} </div> </div> {InUseProviderIcon && <InUseProviderIcon className="ml-1 h-4" />} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx index 7338dbf8a4dcee..f41236e66cfbcf 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx @@ -1,6 +1,17 @@ 'use client' import type { FC } from 'react' -import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type' +import type { + AliyunConfig, + ArizeConfig, + DatabricksConfig, + LangFuseConfig, + LangSmithConfig, + MLflowConfig, + OpikConfig, + PhoenixConfig, + TencentConfig, + WeaveConfig, +} from './type' import { AlertDialog, AlertDialogActions, @@ -11,10 +22,7 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' -import { - Dialog, - DialogContent, -} from '@langgenius/dify-ui/dialog' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { useBoolean } from 'ahooks' import * as React from 'react' @@ -31,10 +39,33 @@ import { TracingProvider } from './type' type Props = Readonly<{ appId: string type: TracingProvider - payload?: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | MLflowConfig | DatabricksConfig | TencentConfig | null + payload?: + | ArizeConfig + | PhoenixConfig + | LangSmithConfig + | LangFuseConfig + | OpikConfig + | WeaveConfig + | AliyunConfig + | MLflowConfig + | DatabricksConfig + | TencentConfig + | null onRemoved: () => void onCancel: () => void - onSaved: (payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | MLflowConfig | DatabricksConfig | TencentConfig) => void + onSaved: ( + payload: + | ArizeConfig + | PhoenixConfig + | LangSmithConfig + | LangFuseConfig + | OpikConfig + | WeaveConfig + | AliyunConfig + | MLflowConfig + | DatabricksConfig + | TencentConfig, + ) => void onChosen: (provider: TracingProvider) => void }> @@ -120,103 +151,116 @@ const ProviderConfigModal: FC<Props> = ({ const isEdit = !!payload const isAdd = !isEdit const [isSaving, setIsSaving] = useState(false) - const [config, setConfig] = useState<ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | MLflowConfig | DatabricksConfig | TencentConfig>((() => { - if (isEdit) - return payload - - if (type === TracingProvider.arize) - return arizeConfigTemplate - - else if (type === TracingProvider.phoenix) - return phoenixConfigTemplate - - else if (type === TracingProvider.langSmith) - return langSmithConfigTemplate - - else if (type === TracingProvider.langfuse) - return langFuseConfigTemplate - - else if (type === TracingProvider.opik) - return opikConfigTemplate - - else if (type === TracingProvider.aliyun) - return aliyunConfigTemplate - - else if (type === TracingProvider.mlflow) - return mlflowConfigTemplate - - else if (type === TracingProvider.databricks) - return databricksConfigTemplate - - else if (type === TracingProvider.tencent) - return tencentConfigTemplate - - return weaveConfigTemplate - })()) - const [isConfigDialogOpen, { - set: setIsConfigDialogOpen, - }] = useBoolean(true) - const [isRemoveDialogOpen, { - set: setIsRemoveDialogOpen, - setTrue: showRemoveConfirm, - setFalse: hideRemoveConfirm, - }] = useBoolean(false) + const [config, setConfig] = useState< + | ArizeConfig + | PhoenixConfig + | LangSmithConfig + | LangFuseConfig + | OpikConfig + | WeaveConfig + | AliyunConfig + | MLflowConfig + | DatabricksConfig + | TencentConfig + >( + (() => { + if (isEdit) return payload + + if (type === TracingProvider.arize) return arizeConfigTemplate + else if (type === TracingProvider.phoenix) return phoenixConfigTemplate + else if (type === TracingProvider.langSmith) return langSmithConfigTemplate + else if (type === TracingProvider.langfuse) return langFuseConfigTemplate + else if (type === TracingProvider.opik) return opikConfigTemplate + else if (type === TracingProvider.aliyun) return aliyunConfigTemplate + else if (type === TracingProvider.mlflow) return mlflowConfigTemplate + else if (type === TracingProvider.databricks) return databricksConfigTemplate + else if (type === TracingProvider.tencent) return tencentConfigTemplate + + return weaveConfigTemplate + })(), + ) + const [isConfigDialogOpen, { set: setIsConfigDialogOpen }] = useBoolean(true) + const [ + isRemoveDialogOpen, + { set: setIsRemoveDialogOpen, setTrue: showRemoveConfirm, setFalse: hideRemoveConfirm }, + ] = useBoolean(false) const handleRemove = useCallback(async () => { await removeTracingConfig({ appId, provider: type, }) - toast(t($ => $['api.remove'], { ns: 'common' }), { type: 'success' }) + toast( + t(($) => $['api.remove'], { ns: 'common' }), + { type: 'success' }, + ) onRemoved() hideRemoveConfirm() }, [hideRemoveConfirm, appId, type, t, onRemoved]) - const handleConfigChange = useCallback((key: string) => { - return (value: string) => { - setConfig({ - ...config, - [key]: value, - }) - } - }, [config]) + const handleConfigChange = useCallback( + (key: string) => { + return (value: string) => { + setConfig({ + ...config, + [key]: value, + }) + } + }, + [config], + ) const checkValid = useCallback(() => { let errorMessage = '' if (type === TracingProvider.arize) { const postData = config as ArizeConfig if (!postData.api_key) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'API Key' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: 'API Key' }) if (!postData.space_id) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Space ID' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Space ID' }) if (!errorMessage && !postData.project) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' }) }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }), + }) } if (type === TracingProvider.phoenix) { const postData = config as PhoenixConfig if (!postData.api_key) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'API Key' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: 'API Key' }) if (!errorMessage && !postData.project) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' }) }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }), + }) } if (type === TracingProvider.langSmith) { const postData = config as LangSmithConfig if (!postData.api_key) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'API Key' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: 'API Key' }) if (!errorMessage && !postData.project) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' }) }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }), + }) } if (type === TracingProvider.langfuse) { const postData = config as LangFuseConfig if (!errorMessage && !postData.secret_key) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: t($ => $[`${I18N_PREFIX}.secretKey`], { ns: 'app' }) }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: t(($) => $[`${I18N_PREFIX}.secretKey`], { ns: 'app' }), + }) if (!errorMessage && !postData.public_key) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: t($ => $[`${I18N_PREFIX}.publicKey`], { ns: 'app' }) }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: t(($) => $[`${I18N_PREFIX}.publicKey`], { ns: 'app' }), + }) if (!errorMessage && !postData.host) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Host' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Host' }) } if (type === TracingProvider.opik) { @@ -227,50 +271,61 @@ const ProviderConfigModal: FC<Props> = ({ if (type === TracingProvider.weave) { const postData = config as WeaveConfig if (!errorMessage && !postData.api_key) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'API Key' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: 'API Key' }) if (!errorMessage && !postData.project) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' }) }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }), + }) } if (type === TracingProvider.aliyun) { const postData = config as AliyunConfig if (!errorMessage && !postData.app_name) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'App Name' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: 'App Name' }) if (!errorMessage && !postData.license_key) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'License Key' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: 'License Key' }) if (!errorMessage && !postData.endpoint) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Endpoint' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Endpoint' }) } if (type === TracingProvider.mlflow) { const postData = config as MLflowConfig if (!errorMessage && !postData.tracking_uri) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Tracking URI' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: 'Tracking URI', + }) } if (type === TracingProvider.databricks) { const postData = config as DatabricksConfig if (!errorMessage && !postData.experiment_id) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Experiment ID' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: 'Experiment ID', + }) if (!errorMessage && !postData.host) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Host' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Host' }) } if (type === TracingProvider.tencent) { const postData = config as TencentConfig if (!errorMessage && !postData.token) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Token' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Token' }) if (!errorMessage && !postData.endpoint) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Endpoint' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Endpoint' }) if (!errorMessage && !postData.service_name) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: 'Service Name' }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: 'Service Name', + }) } return errorMessage }, [config, t, type]) const handleSave = useCallback(async () => { - if (isSaving) - return + if (isSaving) return const errorMessage = checkValid() if (errorMessage) { toast(errorMessage, { type: 'error' }) @@ -285,442 +340,572 @@ const ProviderConfigModal: FC<Props> = ({ tracing_config: config, }, }) - toast(t($ => $['api.success'], { ns: 'common' }), { type: 'success' }) + toast( + t(($) => $['api.success'], { ns: 'common' }), + { type: 'success' }, + ) onSaved(config) - if (isAdd) - onChosen(type) - } - finally { + if (isAdd) onChosen(type) + } finally { setIsSaving(false) } }, [appId, checkValid, config, isAdd, isEdit, isSaving, onChosen, onSaved, t, type]) // Defer onCancel to onOpenChangeComplete so the dialog's exit animation // (scale/opacity transition) can finish before the parent unmounts this modal. - const handleConfigDialogOpenChangeComplete = useCallback((open: boolean) => { - if (!open) - onCancel() - }, [onCancel]) + const handleConfigDialogOpenChangeComplete = useCallback( + (open: boolean) => { + if (!open) onCancel() + }, + [onCancel], + ) return ( <> - {!isRemoveDialogOpen - ? ( - <Dialog - open={isConfigDialogOpen} - onOpenChange={setIsConfigDialogOpen} - onOpenChangeComplete={handleConfigDialogOpenChangeComplete} - > - <DialogContent className="max-h-[calc(100dvh-1rem)] w-auto max-w-[calc(100vw-1rem)] overflow-visible border-none bg-transparent p-0 shadow-none"> - <div className="flex items-center justify-center"> - <div className="mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl"> - <div className="px-8 pt-8"> - <div className="mb-4 flex items-center justify-between"> - <div className="title-2xl-semi-bold text-text-primary"> - {t($ => $[`${I18N_PREFIX}.title`], { ns: 'app' })} - {t($ => $[`tracing.${type}.title`], { ns: 'app' })} - </div> - </div> + {!isRemoveDialogOpen ? ( + <Dialog + open={isConfigDialogOpen} + onOpenChange={setIsConfigDialogOpen} + onOpenChangeComplete={handleConfigDialogOpenChangeComplete} + > + <DialogContent className="max-h-[calc(100dvh-1rem)] w-auto max-w-[calc(100vw-1rem)] overflow-visible border-none bg-transparent p-0 shadow-none"> + <div className="flex items-center justify-center"> + <div className="mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl"> + <div className="px-8 pt-8"> + <div className="mb-4 flex items-center justify-between"> + <div className="title-2xl-semi-bold text-text-primary"> + {t(($) => $[`${I18N_PREFIX}.title`], { ns: 'app' })} + {t(($) => $[`tracing.${type}.title`], { ns: 'app' })} + </div> + </div> - <div className="space-y-4"> - {type === TracingProvider.arize && ( - <> - <Field - label="API Key" - labelClassName="text-sm!" - isRequired - value={(config as ArizeConfig).api_key} - onChange={handleConfigChange('api_key')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'API Key' })!} - /> - <Field - label="Space ID" - labelClassName="text-sm!" - isRequired - value={(config as ArizeConfig).space_id} - onChange={handleConfigChange('space_id')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'Space ID' })!} - /> - <Field - label={t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' })!} - labelClassName="text-sm!" - isRequired - value={(config as ArizeConfig).project} - onChange={handleConfigChange('project')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' }) })!} - /> - <Field - label="Endpoint" - labelClassName="text-sm!" - value={(config as ArizeConfig).endpoint} - onChange={handleConfigChange('endpoint')} - placeholder="https://otlp.arize.com" - /> - </> - )} - {type === TracingProvider.phoenix && ( - <> - <Field - label="API Key" - labelClassName="text-sm!" - isRequired - value={(config as PhoenixConfig).api_key} - onChange={handleConfigChange('api_key')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'API Key' })!} - /> - <Field - label={t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' })!} - labelClassName="text-sm!" - isRequired - value={(config as PhoenixConfig).project} - onChange={handleConfigChange('project')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' }) })!} - /> - <Field - label="Endpoint" - labelClassName="text-sm!" - value={(config as PhoenixConfig).endpoint} - onChange={handleConfigChange('endpoint')} - placeholder="https://app.phoenix.arize.com" - /> - </> - )} - {type === TracingProvider.aliyun && ( - <> - <Field - label="License Key" - labelClassName="text-sm!" - isRequired - value={(config as AliyunConfig).license_key} - onChange={handleConfigChange('license_key')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'License Key' })!} - /> - <Field - label="Endpoint" - labelClassName="text-sm!" - value={(config as AliyunConfig).endpoint} - onChange={handleConfigChange('endpoint')} - placeholder="https://tracing.arms.aliyuncs.com" - /> - <Field - label="App Name" - labelClassName="text-sm!" - value={(config as AliyunConfig).app_name} - onChange={handleConfigChange('app_name')} - /> - </> - )} - {type === TracingProvider.tencent && ( - <> - <Field - label="Token" - labelClassName="text-sm!" - isRequired - value={(config as TencentConfig).token} - onChange={handleConfigChange('token')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'Token' })!} - /> - <Field - label="Endpoint" - labelClassName="text-sm!" - isRequired - value={(config as TencentConfig).endpoint} - onChange={handleConfigChange('endpoint')} - placeholder="https://your-region.cls.tencentcs.com" - /> - <Field - label="Service Name" - labelClassName="text-sm!" - isRequired - value={(config as TencentConfig).service_name} - onChange={handleConfigChange('service_name')} - placeholder="dify_app" - /> - </> - )} - {type === TracingProvider.weave && ( - <> - <Field - label="API Key" - labelClassName="text-sm!" - isRequired - value={(config as WeaveConfig).api_key} - onChange={handleConfigChange('api_key')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'API Key' })!} - /> - <Field - label={t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' })!} - labelClassName="text-sm!" - isRequired - value={(config as WeaveConfig).project} - onChange={handleConfigChange('project')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' }) })!} - /> - <Field - label="Entity" - labelClassName="text-sm!" - value={(config as WeaveConfig).entity} - onChange={handleConfigChange('entity')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'Entity' })!} - /> - <Field - label="Endpoint" - labelClassName="text-sm!" - value={(config as WeaveConfig).endpoint} - onChange={handleConfigChange('endpoint')} - placeholder="https://trace.wandb.ai/" - /> - <Field - label="Host" - labelClassName="text-sm!" - value={(config as WeaveConfig).host} - onChange={handleConfigChange('host')} - placeholder="https://api.wandb.ai" - /> - </> - )} - {type === TracingProvider.langSmith && ( - <> - <Field - label="API Key" - labelClassName="text-sm!" - isRequired - value={(config as LangSmithConfig).api_key} - onChange={handleConfigChange('api_key')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'API Key' })!} - /> - <Field - label={t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' })!} - labelClassName="text-sm!" - isRequired - value={(config as LangSmithConfig).project} - onChange={handleConfigChange('project')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' }) })!} - /> - <Field - label="Endpoint" - labelClassName="text-sm!" - value={(config as LangSmithConfig).endpoint} - onChange={handleConfigChange('endpoint')} - placeholder="https://api.smith.langchain.com" - /> - </> - )} - {type === TracingProvider.langfuse && ( - <> - <Field - label={t($ => $[`${I18N_PREFIX}.secretKey`], { ns: 'app' })!} - labelClassName="text-sm!" - value={(config as LangFuseConfig).secret_key} - isRequired - onChange={handleConfigChange('secret_key')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.secretKey`], { ns: 'app' }) })!} - /> - <Field - label={t($ => $[`${I18N_PREFIX}.publicKey`], { ns: 'app' })!} - labelClassName="text-sm!" - isRequired - value={(config as LangFuseConfig).public_key} - onChange={handleConfigChange('public_key')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.publicKey`], { ns: 'app' }) })!} - /> - <Field - label="Host" - labelClassName="text-sm!" - isRequired - value={(config as LangFuseConfig).host} - onChange={handleConfigChange('host')} - placeholder="https://cloud.langfuse.com" - /> - </> - )} - {type === TracingProvider.opik && ( - <> - <Field - label="API Key" - labelClassName="text-sm!" - value={(config as OpikConfig).api_key} - onChange={handleConfigChange('api_key')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'API Key' })!} - /> - <Field - label={t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' })!} - labelClassName="text-sm!" - value={(config as OpikConfig).project} - onChange={handleConfigChange('project')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.project`], { ns: 'app' }) })!} - /> - <Field - label="Workspace" - labelClassName="text-sm!" - value={(config as OpikConfig).workspace} - onChange={handleConfigChange('workspace')} - placeholder="default" - /> - <Field - label="Url" - labelClassName="text-sm!" - value={(config as OpikConfig).url} - onChange={handleConfigChange('url')} - placeholder="https://www.comet.com/opik/api/" - /> - </> - )} - {type === TracingProvider.mlflow && ( - <> - <Field - label={t($ => $[`${I18N_PREFIX}.trackingUri`], { ns: 'app' })!} - labelClassName="text-sm!" - value={(config as MLflowConfig).tracking_uri} - isRequired - onChange={handleConfigChange('tracking_uri')} - placeholder="http://localhost:5000" - /> - <Field - label={t($ => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' })!} - labelClassName="text-sm!" - isRequired - value={(config as MLflowConfig).experiment_id} - onChange={handleConfigChange('experiment_id')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' }) })!} - /> - <Field - label={t($ => $[`${I18N_PREFIX}.username`], { ns: 'app' })!} - labelClassName="text-sm!" - value={(config as MLflowConfig).username} - onChange={handleConfigChange('username')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.username`], { ns: 'app' }) })!} - /> - <Field - label={t($ => $[`${I18N_PREFIX}.password`], { ns: 'app' })!} - labelClassName="text-sm!" - value={(config as MLflowConfig).password} - onChange={handleConfigChange('password')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.password`], { ns: 'app' }) })!} - /> - </> - )} - {type === TracingProvider.databricks && ( - <> - <Field - label={t($ => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' })!} - labelClassName="text-sm!" - value={(config as DatabricksConfig).experiment_id} - onChange={handleConfigChange('experiment_id')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' }) })!} - isRequired - /> - <Field - label={t($ => $[`${I18N_PREFIX}.databricksHost`], { ns: 'app' })!} - labelClassName="text-sm!" - value={(config as DatabricksConfig).host} - onChange={handleConfigChange('host')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.databricksHost`], { ns: 'app' }) })!} - isRequired - /> - <Field - label={t($ => $[`${I18N_PREFIX}.clientId`], { ns: 'app' })!} - labelClassName="text-sm!" - value={(config as DatabricksConfig).client_id} - onChange={handleConfigChange('client_id')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.clientId`], { ns: 'app' }) })!} - /> - <Field - label={t($ => $[`${I18N_PREFIX}.clientSecret`], { ns: 'app' })!} - labelClassName="text-sm!" - value={(config as DatabricksConfig).client_secret} - onChange={handleConfigChange('client_secret')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.clientSecret`], { ns: 'app' }) })!} - /> - <Field - label={t($ => $[`${I18N_PREFIX}.personalAccessToken`], { ns: 'app' })!} - labelClassName="text-sm!" - value={(config as DatabricksConfig).personal_access_token} - onChange={handleConfigChange('personal_access_token')} - placeholder={t($ => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: t($ => $[`${I18N_PREFIX}.personalAccessToken`], { ns: 'app' }) })!} - /> - </> - )} - </div> - <div className="my-8 flex h-8 items-center justify-between"> - <a - className="flex items-center space-x-1 text-xs leading-[18px] font-normal text-[#155EEF]" - target="_blank" - href={docURL[type]} - > - <span>{t($ => $[`${I18N_PREFIX}.viewDocsLink`], { ns: 'app', key: t($ => $[`tracing.${type}.title`], { ns: 'app' }) })}</span> - <LinkExternal02 className="size-3" /> - </a> - <div className="flex items-center"> - {isEdit && ( - <> - <Button - className="h-9 text-sm font-medium text-text-secondary" - onClick={showRemoveConfirm} - > - <span className="text-[#D92D20]">{t($ => $['operation.remove'], { ns: 'common' })}</span> - </Button> - <Divider type="vertical" className="mx-3 h-[18px]" /> - </> - )} - <Button - className="mr-2 h-9 text-sm font-medium text-text-secondary" - onClick={() => setIsConfigDialogOpen(false)} - > - {t($ => $['operation.cancel'], { ns: 'common' })} - </Button> + <div className="space-y-4"> + {type === TracingProvider.arize && ( + <> + <Field + label="API Key" + labelClassName="text-sm!" + isRequired + value={(config as ArizeConfig).api_key} + onChange={handleConfigChange('api_key')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: 'API Key', + })! + } + /> + <Field + label="Space ID" + labelClassName="text-sm!" + isRequired + value={(config as ArizeConfig).space_id} + onChange={handleConfigChange('space_id')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: 'Space ID', + })! + } + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!} + labelClassName="text-sm!" + isRequired + value={(config as ArizeConfig).project} + onChange={handleConfigChange('project')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }), + })! + } + /> + <Field + label="Endpoint" + labelClassName="text-sm!" + value={(config as ArizeConfig).endpoint} + onChange={handleConfigChange('endpoint')} + placeholder="https://otlp.arize.com" + /> + </> + )} + {type === TracingProvider.phoenix && ( + <> + <Field + label="API Key" + labelClassName="text-sm!" + isRequired + value={(config as PhoenixConfig).api_key} + onChange={handleConfigChange('api_key')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: 'API Key', + })! + } + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!} + labelClassName="text-sm!" + isRequired + value={(config as PhoenixConfig).project} + onChange={handleConfigChange('project')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }), + })! + } + /> + <Field + label="Endpoint" + labelClassName="text-sm!" + value={(config as PhoenixConfig).endpoint} + onChange={handleConfigChange('endpoint')} + placeholder="https://app.phoenix.arize.com" + /> + </> + )} + {type === TracingProvider.aliyun && ( + <> + <Field + label="License Key" + labelClassName="text-sm!" + isRequired + value={(config as AliyunConfig).license_key} + onChange={handleConfigChange('license_key')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: 'License Key', + })! + } + /> + <Field + label="Endpoint" + labelClassName="text-sm!" + value={(config as AliyunConfig).endpoint} + onChange={handleConfigChange('endpoint')} + placeholder="https://tracing.arms.aliyuncs.com" + /> + <Field + label="App Name" + labelClassName="text-sm!" + value={(config as AliyunConfig).app_name} + onChange={handleConfigChange('app_name')} + /> + </> + )} + {type === TracingProvider.tencent && ( + <> + <Field + label="Token" + labelClassName="text-sm!" + isRequired + value={(config as TencentConfig).token} + onChange={handleConfigChange('token')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'Token' })! + } + /> + <Field + label="Endpoint" + labelClassName="text-sm!" + isRequired + value={(config as TencentConfig).endpoint} + onChange={handleConfigChange('endpoint')} + placeholder="https://your-region.cls.tencentcs.com" + /> + <Field + label="Service Name" + labelClassName="text-sm!" + isRequired + value={(config as TencentConfig).service_name} + onChange={handleConfigChange('service_name')} + placeholder="dify_app" + /> + </> + )} + {type === TracingProvider.weave && ( + <> + <Field + label="API Key" + labelClassName="text-sm!" + isRequired + value={(config as WeaveConfig).api_key} + onChange={handleConfigChange('api_key')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: 'API Key', + })! + } + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!} + labelClassName="text-sm!" + isRequired + value={(config as WeaveConfig).project} + onChange={handleConfigChange('project')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }), + })! + } + /> + <Field + label="Entity" + labelClassName="text-sm!" + value={(config as WeaveConfig).entity} + onChange={handleConfigChange('entity')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'Entity' })! + } + /> + <Field + label="Endpoint" + labelClassName="text-sm!" + value={(config as WeaveConfig).endpoint} + onChange={handleConfigChange('endpoint')} + placeholder="https://trace.wandb.ai/" + /> + <Field + label="Host" + labelClassName="text-sm!" + value={(config as WeaveConfig).host} + onChange={handleConfigChange('host')} + placeholder="https://api.wandb.ai" + /> + </> + )} + {type === TracingProvider.langSmith && ( + <> + <Field + label="API Key" + labelClassName="text-sm!" + isRequired + value={(config as LangSmithConfig).api_key} + onChange={handleConfigChange('api_key')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: 'API Key', + })! + } + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!} + labelClassName="text-sm!" + isRequired + value={(config as LangSmithConfig).project} + onChange={handleConfigChange('project')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }), + })! + } + /> + <Field + label="Endpoint" + labelClassName="text-sm!" + value={(config as LangSmithConfig).endpoint} + onChange={handleConfigChange('endpoint')} + placeholder="https://api.smith.langchain.com" + /> + </> + )} + {type === TracingProvider.langfuse && ( + <> + <Field + label={t(($) => $[`${I18N_PREFIX}.secretKey`], { ns: 'app' })!} + labelClassName="text-sm!" + value={(config as LangFuseConfig).secret_key} + isRequired + onChange={handleConfigChange('secret_key')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.secretKey`], { ns: 'app' }), + })! + } + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.publicKey`], { ns: 'app' })!} + labelClassName="text-sm!" + isRequired + value={(config as LangFuseConfig).public_key} + onChange={handleConfigChange('public_key')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.publicKey`], { ns: 'app' }), + })! + } + /> + <Field + label="Host" + labelClassName="text-sm!" + isRequired + value={(config as LangFuseConfig).host} + onChange={handleConfigChange('host')} + placeholder="https://cloud.langfuse.com" + /> + </> + )} + {type === TracingProvider.opik && ( + <> + <Field + label="API Key" + labelClassName="text-sm!" + value={(config as OpikConfig).api_key} + onChange={handleConfigChange('api_key')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: 'API Key', + })! + } + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!} + labelClassName="text-sm!" + value={(config as OpikConfig).project} + onChange={handleConfigChange('project')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }), + })! + } + /> + <Field + label="Workspace" + labelClassName="text-sm!" + value={(config as OpikConfig).workspace} + onChange={handleConfigChange('workspace')} + placeholder="default" + /> + <Field + label="Url" + labelClassName="text-sm!" + value={(config as OpikConfig).url} + onChange={handleConfigChange('url')} + placeholder="https://www.comet.com/opik/api/" + /> + </> + )} + {type === TracingProvider.mlflow && ( + <> + <Field + label={t(($) => $[`${I18N_PREFIX}.trackingUri`], { ns: 'app' })!} + labelClassName="text-sm!" + value={(config as MLflowConfig).tracking_uri} + isRequired + onChange={handleConfigChange('tracking_uri')} + placeholder="http://localhost:5000" + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' })!} + labelClassName="text-sm!" + isRequired + value={(config as MLflowConfig).experiment_id} + onChange={handleConfigChange('experiment_id')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' }), + })! + } + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.username`], { ns: 'app' })!} + labelClassName="text-sm!" + value={(config as MLflowConfig).username} + onChange={handleConfigChange('username')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.username`], { ns: 'app' }), + })! + } + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.password`], { ns: 'app' })!} + labelClassName="text-sm!" + value={(config as MLflowConfig).password} + onChange={handleConfigChange('password')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.password`], { ns: 'app' }), + })! + } + /> + </> + )} + {type === TracingProvider.databricks && ( + <> + <Field + label={t(($) => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' })!} + labelClassName="text-sm!" + value={(config as DatabricksConfig).experiment_id} + onChange={handleConfigChange('experiment_id')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' }), + })! + } + isRequired + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.databricksHost`], { ns: 'app' })!} + labelClassName="text-sm!" + value={(config as DatabricksConfig).host} + onChange={handleConfigChange('host')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.databricksHost`], { ns: 'app' }), + })! + } + isRequired + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.clientId`], { ns: 'app' })!} + labelClassName="text-sm!" + value={(config as DatabricksConfig).client_id} + onChange={handleConfigChange('client_id')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.clientId`], { ns: 'app' }), + })! + } + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.clientSecret`], { ns: 'app' })!} + labelClassName="text-sm!" + value={(config as DatabricksConfig).client_secret} + onChange={handleConfigChange('client_secret')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.clientSecret`], { ns: 'app' }), + })! + } + /> + <Field + label={t(($) => $[`${I18N_PREFIX}.personalAccessToken`], { ns: 'app' })!} + labelClassName="text-sm!" + value={(config as DatabricksConfig).personal_access_token} + onChange={handleConfigChange('personal_access_token')} + placeholder={ + t(($) => $[`${I18N_PREFIX}.placeholder`], { + ns: 'app', + key: t(($) => $[`${I18N_PREFIX}.personalAccessToken`], { ns: 'app' }), + })! + } + /> + </> + )} + </div> + <div className="my-8 flex h-8 items-center justify-between"> + <a + className="flex items-center space-x-1 text-xs leading-[18px] font-normal text-[#155EEF]" + target="_blank" + href={docURL[type]} + > + <span> + {t(($) => $[`${I18N_PREFIX}.viewDocsLink`], { + ns: 'app', + key: t(($) => $[`tracing.${type}.title`], { ns: 'app' }), + })} + </span> + <LinkExternal02 className="size-3" /> + </a> + <div className="flex items-center"> + {isEdit && ( + <> <Button - className="h-9 text-sm font-medium" - variant="primary" - onClick={handleSave} - loading={isSaving} + className="h-9 text-sm font-medium text-text-secondary" + onClick={showRemoveConfirm} > - {t($ => $[`operation.${isAdd ? 'saveAndEnable' : 'save'}`], { ns: 'common' })} + <span className="text-[#D92D20]"> + {t(($) => $['operation.remove'], { ns: 'common' })} + </span> </Button> - </div> - - </div> - </div> - <div className="border-t-[0.5px] border-divider-regular"> - <div className="flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary"> - <Lock01 className="mr-1 size-3 text-text-tertiary" /> - {t($ => $['modelProvider.encrypted.front'], { ns: 'common' })} - <a - className="mx-1 text-primary-600" - target="_blank" - rel="noopener noreferrer" - href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html" - > - PKCS1_OAEP - </a> - {t($ => $['modelProvider.encrypted.back'], { ns: 'common' })} - </div> + <Divider type="vertical" className="mx-3 h-[18px]" /> + </> + )} + <Button + className="mr-2 h-9 text-sm font-medium text-text-secondary" + onClick={() => setIsConfigDialogOpen(false)} + > + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button + className="h-9 text-sm font-medium" + variant="primary" + onClick={handleSave} + loading={isSaving} + > + {t(($) => $[`operation.${isAdd ? 'saveAndEnable' : 'save'}`], { + ns: 'common', + })} + </Button> </div> </div> </div> - </DialogContent> - </Dialog> - ) - : ( - <AlertDialog open={isRemoveDialogOpen} onOpenChange={setIsRemoveDialogOpen}> - <AlertDialogContent> - <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> - <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $[`${I18N_PREFIX}.removeConfirmTitle`], { ns: 'app', key: t($ => $[`tracing.${type}.title`], { ns: 'app' }) })!} - </AlertDialogTitle> - <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $[`${I18N_PREFIX}.removeConfirmContent`], { ns: 'app' })} - </AlertDialogDescription> + <div className="border-t-[0.5px] border-divider-regular"> + <div className="flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary"> + <Lock01 className="mr-1 size-3 text-text-tertiary" /> + {t(($) => $['modelProvider.encrypted.front'], { ns: 'common' })} + <a + className="mx-1 text-primary-600" + target="_blank" + rel="noopener noreferrer" + href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html" + > + PKCS1_OAEP + </a> + {t(($) => $['modelProvider.encrypted.back'], { ns: 'common' })} + </div> </div> - <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton onClick={handleRemove}> - {t($ => $['operation.confirm'], { ns: 'common' })} - </AlertDialogConfirmButton> - </AlertDialogActions> - </AlertDialogContent> - </AlertDialog> - )} + </div> + </div> + </DialogContent> + </Dialog> + ) : ( + <AlertDialog open={isRemoveDialogOpen} onOpenChange={setIsRemoveDialogOpen}> + <AlertDialogContent> + <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> + <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> + { + t(($) => $[`${I18N_PREFIX}.removeConfirmTitle`], { + ns: 'app', + key: t(($) => $[`tracing.${type}.title`], { ns: 'app' }), + })! + } + </AlertDialogTitle> + <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> + {t(($) => $[`${I18N_PREFIX}.removeConfirmContent`], { ns: 'app' })} + </AlertDialogDescription> + </div> + <AlertDialogActions> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton onClick={handleRemove}> + {t(($) => $['operation.confirm'], { ns: 'common' })} + </AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> + )} </> ) } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx index c4b3c97947eca5..fc1f4267c805ff 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx @@ -1,13 +1,22 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - RiEqualizer2Line, -} from '@remixicon/react' +import { RiEqualizer2Line } from '@remixicon/react' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { AliyunIconBig, ArizeIconBig, DatabricksIconBig, LangfuseIconBig, LangsmithIconBig, MlflowIconBig, OpikIconBig, PhoenixIconBig, TencentIconBig, WeaveIconBig } from '@/app/components/base/icons/src/public/tracing' +import { + AliyunIconBig, + ArizeIconBig, + DatabricksIconBig, + LangfuseIconBig, + LangsmithIconBig, + MlflowIconBig, + OpikIconBig, + PhoenixIconBig, + TencentIconBig, + WeaveIconBig, +} from '@/app/components/base/icons/src/public/tracing' import { Eye as View } from '@/app/components/base/icons/src/vender/solid/general' import { TracingProvider } from './type' @@ -24,7 +33,7 @@ type Props = Readonly<{ }> const getIcon = (type: TracingProvider) => { - return ({ + return { [TracingProvider.arize]: ArizeIconBig, [TracingProvider.phoenix]: PhoenixIconBig, [TracingProvider.langSmith]: LangsmithIconBig, @@ -35,7 +44,7 @@ const getIcon = (type: TracingProvider) => { [TracingProvider.mlflow]: MlflowIconBig, [TracingProvider.databricks]: DatabricksIconBig, [TracingProvider.tencent]: TencentIconBig, - })[type] + }[type] } const ProviderPanel: FC<Props> = ({ @@ -50,31 +59,40 @@ const ProviderPanel: FC<Props> = ({ const { t } = useTranslation() const Icon = getIcon(type) - const handleConfigBtnClick = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - onConfig() - }, [onConfig]) + const handleConfigBtnClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onConfig() + }, + [onConfig], + ) - const viewBtnClick = useCallback((e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() + const viewBtnClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() - const url = config?.project_url - if (url) - window.open(url, '_blank', 'noopener,noreferrer') - }, [config?.project_url]) + const url = config?.project_url + if (url) window.open(url, '_blank', 'noopener,noreferrer') + }, + [config?.project_url], + ) - const handleChosen = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - if (isChosen || !hasConfigured || readOnly) - return - onChoose() - }, [hasConfigured, isChosen, onChoose, readOnly]) + const handleChosen = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (isChosen || !hasConfigured || readOnly) return + onChoose() + }, + [hasConfigured, isChosen, onChoose, readOnly], + ) return ( <div className={cn( 'rounded-xl border-[1.5px] bg-background-section-burn px-4 py-3', - isChosen ? 'border-components-option-card-option-selected-border bg-background-section' : 'border-transparent', + isChosen + ? 'border-components-option-card-option-selected-border bg-background-section' + : 'border-transparent', !isChosen && hasConfigured && !readOnly && 'cursor-pointer', )} onClick={handleChosen} @@ -82,14 +100,23 @@ const ProviderPanel: FC<Props> = ({ <div className="flex items-center justify-between space-x-1"> <div className="flex items-center"> <Icon className="h-6" /> - {isChosen && <div className="ml-1 flex h-4 items-center rounded-sm border border-text-accent-secondary px-1 system-2xs-medium-uppercase text-text-accent-secondary">{t($ => $[`${I18N_PREFIX}.inUse`], { ns: 'app' })}</div>} + {isChosen && ( + <div className="ml-1 flex h-4 items-center rounded-sm border border-text-accent-secondary px-1 system-2xs-medium-uppercase text-text-accent-secondary"> + {t(($) => $[`${I18N_PREFIX}.inUse`], { ns: 'app' })} + </div> + )} </div> {!readOnly && ( <div className="flex items-center justify-between space-x-1"> {hasConfigured && ( - <div className="flex h-6 cursor-pointer items-center space-x-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-text-secondary shadow-xs" onClick={viewBtnClick}> + <div + className="flex h-6 cursor-pointer items-center space-x-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-text-secondary shadow-xs" + onClick={viewBtnClick} + > <View className="size-3" /> - <div className="text-xs font-medium">{t($ => $[`${I18N_PREFIX}.view`], { ns: 'app' })}</div> + <div className="text-xs font-medium"> + {t(($) => $[`${I18N_PREFIX}.view`], { ns: 'app' })} + </div> </div> )} <div @@ -97,13 +124,15 @@ const ProviderPanel: FC<Props> = ({ onClick={handleConfigBtnClick} > <RiEqualizer2Line className="size-3" /> - <div className="text-xs font-medium">{t($ => $[`${I18N_PREFIX}.config`], { ns: 'app' })}</div> + <div className="text-xs font-medium"> + {t(($) => $[`${I18N_PREFIX}.config`], { ns: 'app' })} + </div> </div> </div> )} </div> <div className="mt-2 system-xs-regular text-text-tertiary"> - {t($ => $[`${I18N_PREFIX}.${type}.description`], { ns: 'app' })} + {t(($) => $[`${I18N_PREFIX}.${type}.description`], { ns: 'app' })} </div> </div> ) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/tracing-icon.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/tracing-icon.tsx index a7450e70354bd1..6bbf84148cf3d7 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/tracing-icon.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/tracing-icon.tsx @@ -14,10 +14,7 @@ const sizeClassMap = { md: 'w-6 h-6 p-1 rounded-lg', } -const TracingIcon: FC<Props> = ({ - className, - size, -}) => { +const TracingIcon: FC<Props> = ({ className, size }) => { const sizeClass = sizeClassMap[size] return ( <div className={cn(className, sizeClass, 'bg-primary-500 shadow-md')}> diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/view.tsx index 2ea350bd1e8267..6839aa4e72716b 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/view.tsx @@ -15,17 +15,20 @@ type OverviewViewProps = { } const OverviewView = ({ appId }: OverviewViewProps) => { - const appDetail = useAppStore(state => state.appDetail) + const appDetail = useAppStore((state) => state.appDetail) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const appACLCapabilities = React.useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { - currentUserId, - resourceMaintainer: appDetail?.maintainer, - workspacePermissionKeys, - }), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys]) + const appACLCapabilities = React.useMemo( + () => + getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }), + [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], + ) - if (!appDetail || !appACLCapabilities.canMonitor) - return null + if (!appDetail || !appACLCapabilities.canMonitor) return null return ( <div className="flex h-full min-h-0 flex-col"> diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/layout.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/layout.tsx index 04e89335c0da17..3d423c1a72f8be 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/layout.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/layout.tsx @@ -10,13 +10,9 @@ export type IAppDetail = { const AppDetail: FC<IAppDetail> = ({ children }) => { const { t } = useTranslation() - useDocumentTitle(t($ => $['menus.appDetail'], { ns: 'common' })) + useDocumentTitle(t(($) => $['menus.appDetail'], { ns: 'common' })) - return ( - <> - {children} - </> - ) + return <>{children}</> } export default React.memo(AppDetail) diff --git a/web/app/(commonLayout)/apps/page.tsx b/web/app/(commonLayout)/apps/page.tsx index 25b6d55d11585a..52d908114cdf21 100644 --- a/web/app/(commonLayout)/apps/page.tsx +++ b/web/app/(commonLayout)/apps/page.tsx @@ -1,9 +1,7 @@ import Apps from '@/app/components/apps' const AppList = () => { - return ( - <Apps /> - ) + return <Apps /> } export default AppList diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx index 356af3d7c25927..ab6e11ce22155a 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx @@ -8,11 +8,12 @@ import DatasetDetailLayout from '../layout-main' const mockReplace = vi.fn() let mockIsRbacEnabled = true -const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, { - systemFeatures: { - rbac_enabled: mockIsRbacEnabled, - }, -}) +const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => + renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, + }) vi.mock('@/next/navigation', () => ({ usePathname: vi.fn(), @@ -24,54 +25,79 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ })) vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - workspacePermissionKeys: [], - }), () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => ({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [], + }), + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - workspacePermissionKeys: [], - }), () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => ({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [], + }), + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - workspacePermissionKeys: [], - }), () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => ({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [], + }), + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - workspacePermissionKeys: [], - }), () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => ({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [], + }), + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - workspacePermissionKeys: [], - }), () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => ({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [], + }), + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/event-emitter', () => ({ @@ -81,7 +107,8 @@ vi.mock('@/context/event-emitter', () => ({ })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) @@ -110,27 +137,30 @@ describe('DatasetDetailLayout', () => { }) describe('Access Errors', () => { - it.each([403, 404])('should redirect to datasets page when dataset detail returns %s', async (status) => { - // Arrange - mockUseDatasetDetail.mockReturnValue({ - data: undefined, - error: new Response(null, { status }), - refetch: vi.fn(), - } as unknown as ReturnType<typeof useDatasetDetail>) - - // Act - render( - <DatasetDetailLayout datasetId="dataset-1"> - <div>Pipeline content</div> - </DatasetDetailLayout>, - ) - - // Assert - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/datasets') - }) - expect(screen.queryByText('Pipeline content')).not.toBeInTheDocument() - }) + it.each([403, 404])( + 'should redirect to datasets page when dataset detail returns %s', + async (status) => { + // Arrange + mockUseDatasetDetail.mockReturnValue({ + data: undefined, + error: new Response(null, { status }), + refetch: vi.fn(), + } as unknown as ReturnType<typeof useDatasetDetail>) + + // Act + render( + <DatasetDetailLayout datasetId="dataset-1"> + <div>Pipeline content</div> + </DatasetDetailLayout>, + ) + + // Assert + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + expect(screen.queryByText('Pipeline content')).not.toBeInTheDocument() + }, + ) it('should redirect when the dataset detail error exposes status without being a Response', async () => { // Arrange @@ -286,7 +316,9 @@ describe('DatasetDetailLayout', () => { ) // Assert - expect(screen.getByText('Create from pipeline content').parentElement).not.toHaveClass('rounded-lg') + expect(screen.getByText('Create from pipeline content').parentElement).not.toHaveClass( + 'rounded-lg', + ) }) it('should render document creation route content without owning the main skip target', () => { diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/__tests__/page.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/__tests__/page.spec.tsx index b1eec08101f35a..002a3747ff15c3 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/__tests__/page.spec.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/__tests__/page.spec.tsx @@ -14,11 +14,16 @@ describe('Dataset access config route', () => { // Route rendering resolves the async dataset id params for the client page. describe('Rendering', () => { it('should pass dataset id from route params', async () => { - render(await AccessConfig({ - params: Promise.resolve({ datasetId: 'dataset-route-id' }), - })) + render( + await AccessConfig({ + params: Promise.resolve({ datasetId: 'dataset-route-id' }), + }), + ) - expect(screen.getByTestId('dataset-access-config')).toHaveAttribute('data-dataset-id', 'dataset-route-id') + expect(screen.getByTestId('dataset-access-config')).toHaveAttribute( + 'data-dataset-id', + 'dataset-route-id', + ) }) }) }) diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/api/page.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/api/page.tsx index 200fc994ea241c..e33c58d7eeb74d 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/api/page.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/api/page.tsx @@ -1,9 +1,7 @@ import * as React from 'react' const page = () => { - return ( - <div>dataset detail api</div> - ) + return <div>dataset detail api</div> } export default page diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/[documentId]/page.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/[documentId]/page.tsx index dd51c84bcf99ca..2b893018082884 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/[documentId]/page.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/[documentId]/page.tsx @@ -2,20 +2,15 @@ import * as React from 'react' import MainDetail from '@/app/components/datasets/documents/detail' export type IDocumentDetailProps = { - params: Promise<{ datasetId: string, documentId: string }> + params: Promise<{ datasetId: string; documentId: string }> } const DocumentDetail = async (props: IDocumentDetailProps) => { const params = await props.params - const { - datasetId, - documentId, - } = params + const { datasetId, documentId } = params - return ( - <MainDetail datasetId={datasetId} documentId={documentId} /> - ) + return <MainDetail datasetId={datasetId} documentId={documentId} /> } export default DocumentDetail diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/[documentId]/settings/page.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/[documentId]/settings/page.tsx index cd9a37b4265a04..203a9be74a0788 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/[documentId]/settings/page.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/[documentId]/settings/page.tsx @@ -2,20 +2,15 @@ import * as React from 'react' import Settings from '@/app/components/datasets/documents/detail/settings' export type IProps = { - params: Promise<{ datasetId: string, documentId: string }> + params: Promise<{ datasetId: string; documentId: string }> } const DocumentSettings = async (props: IProps) => { const params = await props.params - const { - datasetId, - documentId, - } = params + const { datasetId, documentId } = params - return ( - <Settings datasetId={datasetId} documentId={documentId} /> - ) + return <Settings datasetId={datasetId} documentId={documentId} /> } export default DocumentSettings diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/create-from-pipeline/page.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/create-from-pipeline/page.tsx index 046f69dab2b56e..64c0186fd42df3 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/create-from-pipeline/page.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/create-from-pipeline/page.tsx @@ -2,9 +2,7 @@ import * as React from 'react' import CreateFromPipeline from '@/app/components/datasets/documents/create-from-pipeline' const CreateFromPipelinePage = async () => { - return ( - <CreateFromPipeline /> - ) + return <CreateFromPipeline /> } export default CreateFromPipelinePage diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/create/page.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/create/page.tsx index 987bd1ea705755..6d6928f16b16f1 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/create/page.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/create/page.tsx @@ -8,13 +8,9 @@ export type IProps = { const Create = async (props: IProps) => { const params = await props.params - const { - datasetId, - } = params + const { datasetId } = params - return ( - <DatasetUpdateForm datasetId={datasetId} /> - ) + return <DatasetUpdateForm datasetId={datasetId} /> } export default Create diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/page.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/page.tsx index 7a049e0b1b2c37..9d8b24eb84b6d3 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/page.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/page.tsx @@ -8,13 +8,9 @@ export type IProps = { const Documents = async (props: IProps) => { const params = await props.params - const { - datasetId, - } = params + const { datasetId } = params - return ( - <Main datasetId={datasetId} /> - ) + return <Main datasetId={datasetId} /> } export default Documents diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/hitTesting/page.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/hitTesting/page.tsx index 5b8c8e3197655b..5ba6b7693fef15 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/hitTesting/page.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/hitTesting/page.tsx @@ -8,13 +8,9 @@ type Props = Readonly<{ const HitTesting = async (props: Props) => { const params = await props.params - const { - datasetId, - } = params + const { datasetId } = params - return ( - <Main datasetId={datasetId} /> - ) + return <Main datasetId={datasetId} /> } export default HitTesting diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index 3c9aa8262a5e52..86e90b3aa684d4 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -9,7 +9,10 @@ import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { userProfileIdAtom } from '@/context/account-state' import DatasetDetailContext from '@/context/dataset-detail' -import { workspacePermissionKeysAtom, workspacePermissionKeysLoadingAtom } from '@/context/permission-state' +import { + workspacePermissionKeysAtom, + workspacePermissionKeysLoadingAtom, +} from '@/context/permission-state' import { datasetRbacEnabledAtom } from '@/context/system-features-state' import { currentWorkspaceLoadingAtom } from '@/context/workspace-state' import useDocumentTitle from '@/hooks/use-document-title' @@ -23,8 +26,7 @@ type IAppDetailLayoutProps = { } const getResponseStatus = (error: unknown) => { - if (error instanceof Response) - return error.status + if (error instanceof Response) return error.status if (typeof error === 'object' && error && 'status' in error && typeof error.status === 'number') return error.status @@ -40,8 +42,7 @@ const getDatasetRedirectionPath = ( datasetACLCapabilities: ReturnType<typeof getDatasetACLCapabilities>, ) => { if (dataset.provider === 'external') { - if (datasetACLCapabilities.canRetrievalRecall) - return `/datasets/${dataset.id}/hitTesting` + if (datasetACLCapabilities.canRetrievalRecall) return `/datasets/${dataset.id}/hitTesting` return `/datasets/${dataset.id}/settings` } @@ -53,10 +54,7 @@ const getDatasetRedirectionPath = ( } const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => { - const { - children, - datasetId, - } = props + const { children, datasetId } = props const { t } = useTranslation() const router = useRouter() const pathname = usePathname() @@ -68,66 +66,81 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => { const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId) const shouldRedirect = shouldRedirectToDatasetList(error) - const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(datasetRes?.permission_keys, { - currentUserId, - resourceMaintainer: datasetRes?.maintainer, - workspacePermissionKeys, - isRbacEnabled, - }), [datasetRes?.maintainer, datasetRes?.permission_keys, isRbacEnabled, currentUserId, workspacePermissionKeys]) + const datasetACLCapabilities = React.useMemo( + () => + getDatasetACLCapabilities(datasetRes?.permission_keys, { + currentUserId, + resourceMaintainer: datasetRes?.maintainer, + workspacePermissionKeys, + isRbacEnabled, + }), + [ + datasetRes?.maintainer, + datasetRes?.permission_keys, + isRbacEnabled, + currentUserId, + workspacePermissionKeys, + ], + ) const isAccessConfigPath = pathname.endsWith('/access-config') const isHitTestingPath = pathname.endsWith('/hitTesting') const isPermissionControlledPath = isAccessConfigPath || isHitTestingPath - const isCheckingRouteAccess = !!datasetRes - && isPermissionControlledPath - && (isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys) - const shouldRedirectUnauthorizedRoute = !!datasetRes - && !isCheckingRouteAccess - && ( - (isAccessConfigPath && !datasetACLCapabilities.canAccessConfig) - || (isHitTestingPath && !datasetACLCapabilities.canRetrievalRecall) - ) - - useDocumentTitle(datasetRes?.name || t($ => $['menus.datasets'], { ns: 'common' })) + const isCheckingRouteAccess = + !!datasetRes && + isPermissionControlledPath && + (isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys) + const shouldRedirectUnauthorizedRoute = + !!datasetRes && + !isCheckingRouteAccess && + ((isAccessConfigPath && !datasetACLCapabilities.canAccessConfig) || + (isHitTestingPath && !datasetACLCapabilities.canRetrievalRecall)) + + useDocumentTitle(datasetRes?.name || t(($) => $['menus.datasets'], { ns: 'common' })) useEffect(() => { - if (shouldRedirect) - router.replace('/datasets') + if (shouldRedirect) router.replace('/datasets') }, [router, shouldRedirect]) useEffect(() => { - if (!datasetRes || !shouldRedirectUnauthorizedRoute) - return + if (!datasetRes || !shouldRedirectUnauthorizedRoute) return router.replace(getDatasetRedirectionPath(datasetRes, datasetACLCapabilities)) }, [datasetACLCapabilities, datasetRes, router, shouldRedirectUnauthorizedRoute]) - const isPipelinePage = pathname.endsWith('/pipeline') || pathname.includes('/create-from-pipeline') - const shouldShowLoading = (!datasetRes && !error) || shouldRedirect || isCheckingRouteAccess || shouldRedirectUnauthorizedRoute - const content = shouldShowLoading - ? <Loading type="app" /> - : ( + const isPipelinePage = + pathname.endsWith('/pipeline') || pathname.includes('/create-from-pipeline') + const shouldShowLoading = + (!datasetRes && !error) || + shouldRedirect || + isCheckingRouteAccess || + shouldRedirectUnauthorizedRoute + const content = shouldShowLoading ? ( + <Loading type="app" /> + ) : ( + <div + className={cn( + 'relative flex h-0 min-h-0 min-w-0 grow overflow-hidden', + !isPipelinePage && 'pt-1 pr-1 pb-1', + )} + > + <DatasetDetailContext.Provider + value={{ + indexingTechnique: datasetRes?.indexing_technique, + dataset: datasetRes, + mutateDatasetRes, + }} + > <div className={cn( - 'relative flex h-0 min-h-0 min-w-0 grow overflow-hidden', - !isPipelinePage && 'pt-1 pr-1 pb-1', + 'min-w-0 grow overflow-hidden bg-components-panel-bg', + !isPipelinePage && 'rounded-lg shadow-xs shadow-shadow-shadow-3', )} > - <DatasetDetailContext.Provider value={{ - indexingTechnique: datasetRes?.indexing_technique, - dataset: datasetRes, - mutateDatasetRes, - }} - > - <div className={cn( - 'min-w-0 grow overflow-hidden bg-components-panel-bg', - !isPipelinePage && 'rounded-lg shadow-xs shadow-shadow-shadow-3', - )} - > - {children} - </div> - </DatasetDetailContext.Provider> + {children} </div> - ) + </DatasetDetailContext.Provider> + </div> + ) return ( <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background-body"> diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx index 64f3df16698636..fb75d1305172da 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx @@ -1,15 +1,10 @@ import Main from './layout-main' -const DatasetDetailLayout = async ( - props: { - children: React.ReactNode - params: Promise<{ datasetId: string }> - }, -) => { - const { - children, - params, - } = props +const DatasetDetailLayout = async (props: { + children: React.ReactNode + params: Promise<{ datasetId: string }> +}) => { + const { children, params } = props return <Main datasetId={(await params).datasetId}>{children}</Main> } diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/settings/page.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/settings/page.tsx index 74d13082537860..c0c0d814afb24c 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/settings/page.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/settings/page.tsx @@ -7,8 +7,8 @@ const Settings = () => { return ( <div className="h-full overflow-y-auto"> <div className="flex flex-col gap-y-0.5 px-6 pt-3 pb-2"> - <div className="system-xl-semibold text-text-primary">{t($ => $.title)}</div> - <div className="system-sm-regular text-text-tertiary">{t($ => $.desc)}</div> + <div className="system-xl-semibold text-text-primary">{t(($) => $.title)}</div> + <div className="system-sm-regular text-text-tertiary">{t(($) => $.desc)}</div> </div> <Form /> </div> diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/layout.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/layout.tsx index 09555ae0f03f29..6a107a15f5ae79 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/layout.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/layout.tsx @@ -6,11 +6,7 @@ export type IDatasetDetail = { } const AppDetail: FC<IDatasetDetail> = ({ children }) => { - return ( - <> - {children} - </> - ) + return <>{children}</> } export default React.memo(AppDetail) diff --git a/web/app/(commonLayout)/datasets/create-from-pipeline/page.tsx b/web/app/(commonLayout)/datasets/create-from-pipeline/page.tsx index 63f09655b37d1f..2494ee4923d0b0 100644 --- a/web/app/(commonLayout)/datasets/create-from-pipeline/page.tsx +++ b/web/app/(commonLayout)/datasets/create-from-pipeline/page.tsx @@ -2,9 +2,7 @@ import * as React from 'react' import CreateFromPipeline from '@/app/components/datasets/create-from-pipeline' const DatasetCreation = async () => { - return ( - <CreateFromPipeline /> - ) + return <CreateFromPipeline /> } export default DatasetCreation diff --git a/web/app/(commonLayout)/datasets/create/page.tsx b/web/app/(commonLayout)/datasets/create/page.tsx index fe5765437fa122..8b76c9d569ef94 100644 --- a/web/app/(commonLayout)/datasets/create/page.tsx +++ b/web/app/(commonLayout)/datasets/create/page.tsx @@ -2,9 +2,7 @@ import * as React from 'react' import DatasetUpdateForm from '@/app/components/datasets/create' const DatasetCreation = async () => { - return ( - <DatasetUpdateForm /> - ) + return <DatasetUpdateForm /> } export default DatasetCreation diff --git a/web/app/(commonLayout)/datasets/layout.spec.tsx b/web/app/(commonLayout)/datasets/layout.spec.tsx index 6f88392387051d..6510d4f299ba17 100644 --- a/web/app/(commonLayout)/datasets/layout.spec.tsx +++ b/web/app/(commonLayout)/datasets/layout.spec.tsx @@ -16,27 +16,32 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockUseAppContext()) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockUseAppContext()) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockUseAppContext()) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockUseAppContext()) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockUseAppContext()) }) @@ -46,13 +51,20 @@ vi.mock('@/context/external-api-panel-context', () => ({ })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) vi.mock('@/context/external-knowledge-api-context', () => ({ - ExternalKnowledgeApiProvider: ({ children, enabled }: { children: ReactNode, enabled?: boolean }) => { + ExternalKnowledgeApiProvider: ({ + children, + enabled, + }: { + children: ReactNode + enabled?: boolean + }) => { mockExternalKnowledgeApiProviderEnabled = enabled return <>{children}</> }, @@ -101,11 +113,11 @@ describe('DatasetsLayout', () => { currentWorkspace: { id: '' }, }) - render(( + render( <DatasetsLayout> <div>datasets</div> - </DatasetsLayout> - )) + </DatasetsLayout>, + ) expect(screen.getByRole('status')).toBeInTheDocument() expect(screen.queryByText('datasets')).not.toBeInTheDocument() @@ -118,11 +130,11 @@ describe('DatasetsLayout', () => { workspacePermissionKeys: [], }) - render(( + render( <DatasetsLayout> <div>datasets</div> - </DatasetsLayout> - )) + </DatasetsLayout>, + ) expect(screen.getByRole('status')).toBeInTheDocument() expect(screen.queryByText('datasets')).not.toBeInTheDocument() @@ -136,11 +148,11 @@ describe('DatasetsLayout', () => { workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'], }) - render(( + render( <DatasetsLayout> <div>datasets</div> - </DatasetsLayout> - )) + </DatasetsLayout>, + ) expect(screen.getByText('datasets')).toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() @@ -153,36 +165,36 @@ describe('DatasetsLayout', () => { workspacePermissionKeys: [], }) - render(( + render( <DatasetsLayout> <div>datasets</div> - </DatasetsLayout> - )) + </DatasetsLayout>, + ) expect(screen.getByText('datasets')).toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() }) - it.each([ - '/datasets/create', - '/datasets/create-from-pipeline', - ])('should redirect direct dataset creation route to /datasets without dataset.create_and_management: %s', async (pathname) => { - mockPathname = pathname - setAppContext({ - workspacePermissionKeys: [], - }) - - render(( - <DatasetsLayout> - <div>datasets</div> - </DatasetsLayout> - )) - - expect(screen.queryByText('datasets')).not.toBeInTheDocument() - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/datasets') - }) - }) + it.each(['/datasets/create', '/datasets/create-from-pipeline'])( + 'should redirect direct dataset creation route to /datasets without dataset.create_and_management: %s', + async (pathname) => { + mockPathname = pathname + setAppContext({ + workspacePermissionKeys: [], + }) + + render( + <DatasetsLayout> + <div>datasets</div> + </DatasetsLayout>, + ) + + expect(screen.queryByText('datasets')).not.toBeInTheDocument() + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + }, + ) it('should render direct dataset creation route when workspace has dataset.create_and_management', () => { mockPathname = '/datasets/create' @@ -190,11 +202,11 @@ describe('DatasetsLayout', () => { workspacePermissionKeys: ['dataset.create_and_management'], }) - render(( + render( <DatasetsLayout> <div>datasets</div> - </DatasetsLayout> - )) + </DatasetsLayout>, + ) expect(screen.getByText('datasets')).toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() @@ -206,11 +218,11 @@ describe('DatasetsLayout', () => { workspacePermissionKeys: [], }) - render(( + render( <DatasetsLayout> <div>datasets</div> - </DatasetsLayout> - )) + </DatasetsLayout>, + ) expect(screen.queryByText('datasets')).not.toBeInTheDocument() await waitFor(() => { @@ -224,11 +236,11 @@ describe('DatasetsLayout', () => { workspacePermissionKeys: ['dataset.external.connect'], }) - render(( + render( <DatasetsLayout> <div>datasets</div> - </DatasetsLayout> - )) + </DatasetsLayout>, + ) expect(screen.getByText('datasets')).toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() @@ -239,11 +251,11 @@ describe('DatasetsLayout', () => { workspacePermissionKeys: [], }) - render(( + render( <DatasetsLayout> <div>datasets</div> - </DatasetsLayout> - )) + </DatasetsLayout>, + ) expect(mockExternalKnowledgeApiProviderEnabled).toBe(false) }) @@ -253,11 +265,11 @@ describe('DatasetsLayout', () => { workspacePermissionKeys: ['dataset.external.connect'], }) - render(( + render( <DatasetsLayout> <div>datasets</div> - </DatasetsLayout> - )) + </DatasetsLayout>, + ) expect(mockExternalKnowledgeApiProviderEnabled).toBe(true) }) diff --git a/web/app/(commonLayout)/datasets/layout.tsx b/web/app/(commonLayout)/datasets/layout.tsx index e12f4cc934470e..4228173a6f8d9b 100644 --- a/web/app/(commonLayout)/datasets/layout.tsx +++ b/web/app/(commonLayout)/datasets/layout.tsx @@ -5,21 +5,25 @@ import { useEffect } from 'react' import Loading from '@/app/components/base/loading' import { ExternalApiPanelProvider } from '@/context/external-api-panel-context' import { ExternalKnowledgeApiProvider } from '@/context/external-knowledge-api-context' -import { workspacePermissionKeysAtom, workspacePermissionKeysLoadingAtom } from '@/context/permission-state' +import { + workspacePermissionKeysAtom, + workspacePermissionKeysLoadingAtom, +} from '@/context/permission-state' import { currentWorkspaceIdAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' import { usePathname, useRouter } from '@/next/navigation' import { hasPermission } from '@/utils/permission' const isDatasetCreatePath = (pathname: string) => { - return pathname === '/datasets/create' - || pathname.startsWith('/datasets/create/') - || pathname === '/datasets/create-from-pipeline' - || pathname.startsWith('/datasets/create-from-pipeline/') + return ( + pathname === '/datasets/create' || + pathname.startsWith('/datasets/create/') || + pathname === '/datasets/create-from-pipeline' || + pathname.startsWith('/datasets/create-from-pipeline/') + ) } const isDatasetExternalConnectPath = (pathname: string) => { - return pathname === '/datasets/connect' - || pathname.startsWith('/datasets/connect/') + return pathname === '/datasets/connect' || pathname.startsWith('/datasets/connect/') } export default function DatasetsLayout({ children }: { children: React.ReactNode }) { @@ -31,19 +35,21 @@ export default function DatasetsLayout({ children }: { children: React.ReactNode const pathname = usePathname() const isLoadingAccess = isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management') - const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect') - const shouldRedirectToDatasets = !isLoadingAccess - && !!currentWorkspaceId - && ((isDatasetCreatePath(pathname) && !canCreateDataset) - || (isDatasetExternalConnectPath(pathname) && !canConnectExternalDataset)) + const canConnectExternalDataset = hasPermission( + workspacePermissionKeys, + 'dataset.external.connect', + ) + const shouldRedirectToDatasets = + !isLoadingAccess && + !!currentWorkspaceId && + ((isDatasetCreatePath(pathname) && !canCreateDataset) || + (isDatasetExternalConnectPath(pathname) && !canConnectExternalDataset)) useEffect(() => { - if (shouldRedirectToDatasets) - router.replace('/datasets') + if (shouldRedirectToDatasets) router.replace('/datasets') }, [shouldRedirectToDatasets, router]) - if (isLoadingAccess || !currentWorkspaceId) - return <Loading type="app" /> + if (isLoadingAccess || !currentWorkspaceId) return <Loading type="app" /> if (shouldRedirectToDatasets) { return null @@ -51,9 +57,7 @@ export default function DatasetsLayout({ children }: { children: React.ReactNode return ( <ExternalKnowledgeApiProvider enabled={canConnectExternalDataset}> - <ExternalApiPanelProvider> - {children} - </ExternalApiPanelProvider> + <ExternalApiPanelProvider>{children}</ExternalApiPanelProvider> </ExternalKnowledgeApiProvider> ) } diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx index 1ad1bf7175cffc..a6d2dfacd61dac 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx @@ -1,12 +1,6 @@ import type { ReactNode } from 'react' import { InstanceDetail } from '@/features/deployments/detail' -export default function InstanceDetailLayout({ children }: { - children: ReactNode -}) { - return ( - <InstanceDetail> - {children} - </InstanceDetail> - ) +export default function InstanceDetailLayout({ children }: { children: ReactNode }) { + return <InstanceDetail>{children}</InstanceDetail> } diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/page.tsx index b0ae57a3a870fd..95530c5e4ffed4 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/page.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/page.tsx @@ -1,6 +1,8 @@ import { redirect } from '@/next/navigation' -export default async function InstanceDetailPage({ params }: { +export default async function InstanceDetailPage({ + params, +}: { params: Promise<{ appInstanceId: string }> }) { const { appInstanceId } = await params diff --git a/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx b/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx index 2b86761699e7d2..fbf32ce0f98364 100644 --- a/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx +++ b/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx @@ -52,9 +52,11 @@ describe('DeploymentsLayout', () => { mocks.ensureQueryData.mockResolvedValue({ enable_app_deploy: false }) const { default: DeploymentsLayout } = await import('../layout') - await expect(DeploymentsLayout({ - children: <div>Deployments content</div>, - })).rejects.toThrow('NEXT_NOT_FOUND') + await expect( + DeploymentsLayout({ + children: <div>Deployments content</div>, + }), + ).rejects.toThrow('NEXT_NOT_FOUND') expect(mocks.notFound).toHaveBeenCalledTimes(1) }) diff --git a/web/app/(commonLayout)/deployments/create/page.tsx b/web/app/(commonLayout)/deployments/create/page.tsx index e16af7f32f2c9a..0be7206cc623b1 100644 --- a/web/app/(commonLayout)/deployments/create/page.tsx +++ b/web/app/(commonLayout)/deployments/create/page.tsx @@ -6,7 +6,7 @@ import useDocumentTitle from '@/hooks/use-document-title' export default function CreateDeploymentPage() { const { t } = useTranslation('deployments') - useDocumentTitle(t($ => $['documentTitle.create'])) + useDocumentTitle(t(($) => $['documentTitle.create'])) return <CreateDeploymentGuide /> } diff --git a/web/app/(commonLayout)/deployments/layout.tsx b/web/app/(commonLayout)/deployments/layout.tsx index f1ab03c323edcf..c9b2c5fedf09bd 100644 --- a/web/app/(commonLayout)/deployments/layout.tsx +++ b/web/app/(commonLayout)/deployments/layout.tsx @@ -4,13 +4,12 @@ import { DeployDrawer } from '@/features/deployments/deploy-drawer' import { serverSystemFeaturesQueryOptions } from '@/features/system-features/server' import { notFound } from '@/next/navigation' -export default async function DeploymentsLayout({ children }: { - children: ReactNode -}) { - const systemFeatures = await getQueryClientServer().ensureQueryData(serverSystemFeaturesQueryOptions()) +export default async function DeploymentsLayout({ children }: { children: ReactNode }) { + const systemFeatures = await getQueryClientServer().ensureQueryData( + serverSystemFeaturesQueryOptions(), + ) - if (!systemFeatures.enable_app_deploy) - notFound() + if (!systemFeatures.enable_app_deploy) notFound() return ( <> diff --git a/web/app/(commonLayout)/deployments/page.tsx b/web/app/(commonLayout)/deployments/page.tsx index 447a2ea19e0b91..0dfa211704870f 100644 --- a/web/app/(commonLayout)/deployments/page.tsx +++ b/web/app/(commonLayout)/deployments/page.tsx @@ -5,6 +5,6 @@ import useDocumentTitle from '@/hooks/use-document-title' export default function DeploymentsPage() { const { t } = useTranslation('deployments') - useDocumentTitle(t($ => $['documentTitle.list'])) + useDocumentTitle(t(($) => $['documentTitle.list'])) return <DeploymentsList /> } diff --git a/web/app/(commonLayout)/education-apply/page.tsx b/web/app/(commonLayout)/education-apply/page.tsx index e5d5a9a5facbd4..10b4ddb67ee2c5 100644 --- a/web/app/(commonLayout)/education-apply/page.tsx +++ b/web/app/(commonLayout)/education-apply/page.tsx @@ -4,27 +4,19 @@ import { useEffect } from 'react' import { FullScreenLoading } from '@/app/components/full-screen-loading' import EducationApplyPage from '@/app/education-apply/education-apply-page' import { useProviderContext } from '@/context/provider-context' -import { - useRouter, - useSearchParams, -} from '@/next/navigation' +import { useRouter, useSearchParams } from '@/next/navigation' export default function EducationApply() { const router = useRouter() - const { - enableEducationPlan, - isFetchedPlanInfo, - isLoadingEducationAccountInfo, - } = useProviderContext() + const { enableEducationPlan, isFetchedPlanInfo, isLoadingEducationAccountInfo } = + useProviderContext() const searchParams = useSearchParams() const token = searchParams.get('token') useEffect(() => { - if (!isFetchedPlanInfo) - return + if (!isFetchedPlanInfo) return - if (!enableEducationPlan || !token) - router.replace('/') + if (!enableEducationPlan || !token) router.replace('/') }, [enableEducationPlan, isFetchedPlanInfo, router, token]) if (!isFetchedPlanInfo || !enableEducationPlan || !token || isLoadingEducationAccountInfo) diff --git a/web/app/(commonLayout)/error.tsx b/web/app/(commonLayout)/error.tsx index 0ec56009789d9b..c396b6968eb61c 100644 --- a/web/app/(commonLayout)/error.tsx +++ b/web/app/(commonLayout)/error.tsx @@ -19,16 +19,15 @@ export default function CommonLayoutError({ error, unstable_retry }: Props) { // until the browser navigation completes, matching main's Splash behavior. // Showing the "Try again" button here would just flash for a few frames before // the page navigates away, and clicking it would 401 again anyway. - if (isLegacyBase401(error)) - return <FullScreenLoading /> + if (isLegacyBase401(error)) return <FullScreenLoading /> return ( <div className="flex h-full min-h-0 w-full flex-1 flex-col items-center justify-center gap-4 bg-background-body"> <div className="system-sm-regular text-text-tertiary"> - {t($ => $['errorBoundary.message'])} + {t(($) => $['errorBoundary.message'])} </div> <Button size="small" variant="secondary" onClick={() => unstable_retry()}> - {t($ => $['errorBoundary.tryAgain'])} + {t(($) => $['errorBoundary.tryAgain'])} </Button> </div> ) diff --git a/web/app/(commonLayout)/explore/apps/page.tsx b/web/app/(commonLayout)/explore/apps/page.tsx index ba9128770237ba..cd6065fda345c4 100644 --- a/web/app/(commonLayout)/explore/apps/page.tsx +++ b/web/app/(commonLayout)/explore/apps/page.tsx @@ -8,10 +8,9 @@ const Apps = async ({ searchParams }: AppsPageProps) => { const resolvedSearchParams = await searchParams const urlSearchParams = new URLSearchParams() Object.entries(resolvedSearchParams).forEach(([key, value]) => { - if (value === undefined) - return + if (value === undefined) return if (Array.isArray(value)) { - value.forEach(item => urlSearchParams.append(key, item)) + value.forEach((item) => urlSearchParams.append(key, item)) return } urlSearchParams.set(key, value) diff --git a/web/app/(commonLayout)/explore/installed/[appId]/__tests__/page.spec.tsx b/web/app/(commonLayout)/explore/installed/[appId]/__tests__/page.spec.tsx index affa960e339c8e..575d302e414563 100644 --- a/web/app/(commonLayout)/explore/installed/[appId]/__tests__/page.spec.tsx +++ b/web/app/(commonLayout)/explore/installed/[appId]/__tests__/page.spec.tsx @@ -10,9 +10,11 @@ vi.mock('@/next/navigation', () => ({ describe('legacy installed app route', () => { it('redirects to the canonical installed app route', async () => { - await expect(InstalledApp({ - params: Promise.resolve({ appId: 'installed-1' }), - })).rejects.toThrow('redirect:/installed/installed-1') + await expect( + InstalledApp({ + params: Promise.resolve({ appId: 'installed-1' }), + }), + ).rejects.toThrow('redirect:/installed/installed-1') expect(redirect).toHaveBeenCalledWith('/installed/installed-1') }) diff --git a/web/app/(commonLayout)/explore/layout.tsx b/web/app/(commonLayout)/explore/layout.tsx index 1d4cd99ce4bd01..1e0a619c734f1d 100644 --- a/web/app/(commonLayout)/explore/layout.tsx +++ b/web/app/(commonLayout)/explore/layout.tsx @@ -7,12 +7,8 @@ import useDocumentTitle from '@/hooks/use-document-title' const ExploreLayout: FC<PropsWithChildren> = ({ children }) => { const { t } = useTranslation() - useDocumentTitle(t($ => $['menus.explore'], { ns: 'common' })) - return ( - <ExploreClient> - {children} - </ExploreClient> - ) + useDocumentTitle(t(($) => $['menus.explore'], { ns: 'common' })) + return <ExploreClient>{children}</ExploreClient> } export default React.memo(ExploreLayout) diff --git a/web/app/(commonLayout)/global-mounts.tsx b/web/app/(commonLayout)/global-mounts.tsx index 9da151d84f8b9c..7d0c118b95f289 100644 --- a/web/app/(commonLayout)/global-mounts.tsx +++ b/web/app/(commonLayout)/global-mounts.tsx @@ -2,11 +2,20 @@ import dynamic from '@/next/dynamic' -const InSiteMessageNotification = dynamic(() => import('@/app/components/app/in-site-message/notification'), { ssr: false }) +const InSiteMessageNotification = dynamic( + () => import('@/app/components/app/in-site-message/notification'), + { ssr: false }, +) const PartnerStack = dynamic(() => import('@/app/components/billing/partner-stack'), { ssr: false }) const ReadmePanel = dynamic(() => import('@/app/components/plugins/readme-panel'), { ssr: false }) -const WorkflowGeneratorMount = dynamic(() => import('@/app/components/workflow/workflow-generator/mount'), { ssr: false }) -const GotoAnything = dynamic(() => import('@/app/components/goto-anything').then(mod => mod.GotoAnything), { ssr: false }) +const WorkflowGeneratorMount = dynamic( + () => import('@/app/components/workflow/workflow-generator/mount'), + { ssr: false }, +) +const GotoAnything = dynamic( + () => import('@/app/components/goto-anything').then((mod) => mod.GotoAnything), + { ssr: false }, +) export function CommonLayoutGlobalMounts() { return ( diff --git a/web/app/(commonLayout)/hydration-boundary.tsx b/web/app/(commonLayout)/hydration-boundary.tsx index ea6d9a5cbeded0..52182450b04215 100644 --- a/web/app/(commonLayout)/hydration-boundary.tsx +++ b/web/app/(commonLayout)/hydration-boundary.tsx @@ -5,7 +5,11 @@ import { serverUserProfileQueryOptions } from '@/features/account-profile/server import { serverSystemFeaturesQueryOptions } from '@/features/system-features/server' import { headers } from '@/next/headers' import { redirect } from '@/next/navigation' -import { getServerConsoleClientContext, resolveServerConsoleApiUrl, serverConsoleQuery } from '@/service/server' +import { + getServerConsoleClientContext, + resolveServerConsoleApiUrl, + serverConsoleQuery, +} from '@/service/server' import { basePath } from '@/utils/var' const CURRENT_PATHNAME_HEADER = 'x-dify-pathname' @@ -24,8 +28,7 @@ const parseConsoleErrorPayload = async (error: Response): Promise<ConsoleErrorPa try { const payload: unknown = await error.clone().json() return isConsoleErrorPayload(payload) ? payload : null - } - catch { + } catch { return null } } @@ -43,16 +46,12 @@ const redirectToAuthRefresh = async () => { } const handleProfileError = async (error: unknown) => { - if (!(error instanceof Response)) - throw error + if (!(error instanceof Response)) throw error const errorData = await parseConsoleErrorPayload(error) - if (errorData?.code === 'not_setup') - redirect(`${basePath}/install`) - if (errorData?.code === 'not_init_validated') - redirect(`${basePath}/init`) - if (error.status === 401) - await redirectToAuthRefresh() + if (errorData?.code === 'not_setup') redirect(`${basePath}/install`) + if (errorData?.code === 'not_init_validated') redirect(`${basePath}/init`) + if (error.status === 401) await redirectToAuthRefresh() throw error } @@ -68,20 +67,17 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re await Promise.all([ queryClient.fetchQuery(serverUserProfileQueryOptions()), queryClient.prefetchQuery(serverSystemFeaturesQueryOptions()), - queryClient.prefetchQuery(serverConsoleQuery.workspaces.current.post.queryOptions({ - context, - retry: false, - })), + queryClient.prefetchQuery( + serverConsoleQuery.workspaces.current.post.queryOptions({ + context, + retry: false, + }), + ), ]) - } - catch (error) { + } catch (error) { await handleProfileError(error) } } - return ( - <HydrationBoundary state={dehydrate(queryClient)}> - {children} - </HydrationBoundary> - ) + return <HydrationBoundary state={dehydrate(queryClient)}>{children}</HydrationBoundary> } diff --git a/web/app/(commonLayout)/installed/[appId]/__tests__/page.spec.tsx b/web/app/(commonLayout)/installed/[appId]/__tests__/page.spec.tsx index e736baf812c08f..87074921b29db5 100644 --- a/web/app/(commonLayout)/installed/[appId]/__tests__/page.spec.tsx +++ b/web/app/(commonLayout)/installed/[appId]/__tests__/page.spec.tsx @@ -3,11 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import InstalledApp from '../page' vi.mock('@/app/components/explore/installed-app', () => ({ - default: ({ id }: { id: string }) => ( - <main aria-label="installed app"> - {id} - </main> - ), + default: ({ id }: { id: string }) => <main aria-label="installed app">{id}</main>, })) describe('installed app route', () => { diff --git a/web/app/(commonLayout)/installed/[appId]/page.tsx b/web/app/(commonLayout)/installed/[appId]/page.tsx index 0b71d1a26c17bb..dc1a8ec2102e3d 100644 --- a/web/app/(commonLayout)/installed/[appId]/page.tsx +++ b/web/app/(commonLayout)/installed/[appId]/page.tsx @@ -10,9 +10,7 @@ export type IInstalledAppProps = { // Using Next.js page convention for async server components async function InstalledApp({ params }: IInstalledAppProps) { const { appId } = await (params ?? Promise.reject(new Error('Missing params'))) - return ( - <Main id={appId} /> - ) + return <Main id={appId} /> } export default InstalledApp diff --git a/web/app/(commonLayout)/integrations/[[...slug]]/page.tsx b/web/app/(commonLayout)/integrations/[[...slug]]/page.tsx index f2d8efbe136bea..16c81e0edd226c 100644 --- a/web/app/(commonLayout)/integrations/[[...slug]]/page.tsx +++ b/web/app/(commonLayout)/integrations/[[...slug]]/page.tsx @@ -10,18 +10,13 @@ type IntegrationsRoutePageProps = { searchParams?: Promise<IntegrationRouteSearchParams> } -const IntegrationsRoutePage = async ({ - params, - searchParams, -}: IntegrationsRoutePageProps) => { +const IntegrationsRoutePage = async ({ params, searchParams }: IntegrationsRoutePageProps) => { const { slug } = await params const target = getIntegrationRouteTargetBySlug(slug, await searchParams) - if (target.type === 'redirect') - redirect(target.destination) + if (target.type === 'redirect') redirect(target.destination) - if (target.type === 'not-found') - notFound() + if (target.type === 'not-found') notFound() return <IntegrationsPage section={target.section} /> } diff --git a/web/app/(commonLayout)/integrations/layout.tsx b/web/app/(commonLayout)/integrations/layout.tsx index e5bb06497c14a0..77a74eae08aeb8 100644 --- a/web/app/(commonLayout)/integrations/layout.tsx +++ b/web/app/(commonLayout)/integrations/layout.tsx @@ -6,7 +6,7 @@ import useDocumentTitle from '@/hooks/use-document-title' export default function IntegrationsLayout({ children }: PropsWithChildren) { const { t } = useTranslation() - useDocumentTitle(t($ => $['mainNav.integrations'], { ns: 'common' })) + useDocumentTitle(t(($) => $['mainNav.integrations'], { ns: 'common' })) return children } diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index 966ee0b4b4376f..60e56f80f438ee 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -20,9 +20,7 @@ export default async function Layout({ <div className="flex h-full flex-col overflow-hidden"> <MaintenanceNotice /> <ConsoleContextProviders> - <MainNavLayout detailSidebar={detailSidebar}> - {children} - </MainNavLayout> + <MainNavLayout detailSidebar={detailSidebar}>{children}</MainNavLayout> <CommonLayoutGlobalMounts /> </ConsoleContextProviders> </div> diff --git a/web/app/(commonLayout)/marketplace/layout.tsx b/web/app/(commonLayout)/marketplace/layout.tsx index 32e89669395bd3..5a40a6a3a62283 100644 --- a/web/app/(commonLayout)/marketplace/layout.tsx +++ b/web/app/(commonLayout)/marketplace/layout.tsx @@ -6,7 +6,7 @@ import useDocumentTitle from '@/hooks/use-document-title' export default function MarketplaceLayout({ children }: PropsWithChildren) { const { t } = useTranslation() - useDocumentTitle(t($ => $['mainNav.marketplace'], { ns: 'common' })) + useDocumentTitle(t(($) => $['mainNav.marketplace'], { ns: 'common' })) return children } diff --git a/web/app/(commonLayout)/marketplace/page.tsx b/web/app/(commonLayout)/marketplace/page.tsx index b87761b2bfa1d7..9f56a38d4a9c53 100644 --- a/web/app/(commonLayout)/marketplace/page.tsx +++ b/web/app/(commonLayout)/marketplace/page.tsx @@ -6,17 +6,14 @@ type MarketplacePageProps = { searchParams?: Promise<SearchParams> } -const MarketplacePage = ({ - searchParams, -}: MarketplacePageProps) => { +const MarketplacePage = ({ searchParams }: MarketplacePageProps) => { return ( - <div id="marketplace-container" className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default-subtle pr-1"> + <div + id="marketplace-container" + className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default-subtle pr-1" + > <MarketplaceInstallPermissionProvider> - <Marketplace - searchParams={searchParams} - isMarketplacePlatform - showInstallButton - /> + <Marketplace searchParams={searchParams} isMarketplacePlatform showInstallButton /> </MarketplaceInstallPermissionProvider> </div> ) diff --git a/web/app/(commonLayout)/page.tsx b/web/app/(commonLayout)/page.tsx index d5ea648bcbb24f..0af351494d4a10 100644 --- a/web/app/(commonLayout)/page.tsx +++ b/web/app/(commonLayout)/page.tsx @@ -7,7 +7,7 @@ import useDocumentTitle from '@/hooks/use-document-title' const Home = () => { const { t } = useTranslation() - useDocumentTitle(t($ => $['mainNav.home'], { ns: 'common' })) + useDocumentTitle(t(($) => $['mainNav.home'], { ns: 'common' })) return <AppList /> } diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index 5972cd33ff6ef4..f815ed0e1b2f32 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -31,39 +31,36 @@ const fetchPluginCategoryFromMarketplace = async (packageId: string) => { { cache: 'no-store' }, ) - if (!response.ok) - return undefined + if (!response.ok) return undefined - const payload = await response.json() as MarketplaceManifestCategoryResponse + const payload = (await response.json()) as MarketplaceManifestCategoryResponse return payload.data?.plugin?.category - } - catch { + } catch { return undefined } } -const PluginList = async ({ - searchParams, -}: PluginListProps) => { - const resolvedSearchParams = await searchParams ?? {} - const installRedirectPathFromSearchParams = getInstallRedirectPathFromSearchParams(resolvedSearchParams) +const PluginList = async ({ searchParams }: PluginListProps) => { + const resolvedSearchParams = (await searchParams) ?? {} + const installRedirectPathFromSearchParams = + getInstallRedirectPathFromSearchParams(resolvedSearchParams) - if (installRedirectPathFromSearchParams) - redirect(installRedirectPathFromSearchParams) + if (installRedirectPathFromSearchParams) redirect(installRedirectPathFromSearchParams) if (shouldResolveInstallCategoryRedirect(resolvedSearchParams)) { const packageId = getFirstPackageIdFromSearchParams(resolvedSearchParams) const category = packageId ? await fetchPluginCategoryFromMarketplace(packageId) : undefined - const installRedirectPath = getInstallRedirectPathByPluginCategory(category, resolvedSearchParams) + const installRedirectPath = getInstallRedirectPathByPluginCategory( + category, + resolvedSearchParams, + ) - if (installRedirectPath) - redirect(installRedirectPath) + if (installRedirectPath) redirect(installRedirectPath) } const redirectPath = getLegacyPluginRedirectPath(resolvedSearchParams) - if (redirectPath) - redirect(redirectPath) + if (redirectPath) redirect(redirectPath) return ( <PluginPage diff --git a/web/app/(commonLayout)/providers.tsx b/web/app/(commonLayout)/providers.tsx index 6b0b579b2d0b8e..28140d0db84cb2 100644 --- a/web/app/(commonLayout)/providers.tsx +++ b/web/app/(commonLayout)/providers.tsx @@ -28,9 +28,7 @@ export function ConsoleContextProviders({ children }: { children: ReactNode }) { return ( <EventEmitterContextProvider> <ProviderContextProvider> - <ModalContextProvider> - {children} - </ModalContextProvider> + <ModalContextProvider>{children}</ModalContextProvider> </ProviderContextProvider> </EventEmitterContextProvider> ) diff --git a/web/app/(commonLayout)/snippets/[snippetId]/orchestrate/page.tsx b/web/app/(commonLayout)/snippets/[snippetId]/orchestrate/page.tsx index 8a39dc710b16e7..069d7680595e9c 100644 --- a/web/app/(commonLayout)/snippets/[snippetId]/orchestrate/page.tsx +++ b/web/app/(commonLayout)/snippets/[snippetId]/orchestrate/page.tsx @@ -1,8 +1,6 @@ import SnippetPage from '@/app/components/snippets' -const Page = async (props: { - params: Promise<{ snippetId: string }> -}) => { +const Page = async (props: { params: Promise<{ snippetId: string }> }) => { const { snippetId } = await props.params return <SnippetPage snippetId={snippetId} /> diff --git a/web/app/(commonLayout)/snippets/[snippetId]/page.tsx b/web/app/(commonLayout)/snippets/[snippetId]/page.tsx index 2aea2751f07933..ea10dd4f7b0e38 100644 --- a/web/app/(commonLayout)/snippets/[snippetId]/page.tsx +++ b/web/app/(commonLayout)/snippets/[snippetId]/page.tsx @@ -1,8 +1,6 @@ import { redirect } from '@/next/navigation' -const Page = async (props: { - params: Promise<{ snippetId: string }> -}) => { +const Page = async (props: { params: Promise<{ snippetId: string }> }) => { const { snippetId } = await props.params redirect(`/snippets/${snippetId}/orchestrate`) diff --git a/web/app/(commonLayout)/tools/page.tsx b/web/app/(commonLayout)/tools/page.tsx index f384ad2e719989..c57a73fe67ee6f 100644 --- a/web/app/(commonLayout)/tools/page.tsx +++ b/web/app/(commonLayout)/tools/page.tsx @@ -6,9 +6,7 @@ type ToolsPageProps = { searchParams?: Promise<LegacyToolsSearchParams> } -const ToolsPage = async ({ - searchParams, -}: ToolsPageProps) => { +const ToolsPage = async ({ searchParams }: ToolsPageProps) => { const resolvedSearchParams = await searchParams redirect(getIntegrationRedirectPathByLegacyToolsSearchParams(resolvedSearchParams)) diff --git a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx index 03e5506b130f92..147af155fff6da 100644 --- a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx +++ b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx @@ -53,7 +53,13 @@ vi.mock('@/hooks/use-document-title', () => ({ vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({ __esModule: true, - default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => { + default: ({ + content, + onInputChange, + }: { + content: string + onInputChange: (name: string, value: unknown) => void + }) => { const isSummaryField = content.includes('summary') const isAttachmentField = content.includes('attachments') @@ -77,13 +83,21 @@ vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item <> <button type="button" - onClick={() => mockContentItemState.staleAttachmentInputChange?.('attachments', [mockContentItemState.uploadingFile])} + onClick={() => + mockContentItemState.staleAttachmentInputChange?.('attachments', [ + mockContentItemState.uploadingFile, + ]) + } > share-uploading-attachments </button> <button type="button" - onClick={() => mockContentItemState.staleAttachmentInputChange?.('attachments', [mockContentItemState.uploadedFile])} + onClick={() => + mockContentItemState.staleAttachmentInputChange?.('attachments', [ + mockContentItemState.uploadedFile, + ]) + } > share-update-attachments </button> @@ -222,15 +236,14 @@ describe('Human input share form', () => { const { unmount } = render(<FormContent />) expect(screen.getByText(title)).toBeInTheDocument() - if (subtitle) - expect(screen.getByText(subtitle)).toBeInTheDocument() - else - expect(screen.queryByText('share.humanInput.expired')).not.toBeInTheDocument() + if (subtitle) expect(screen.getByText(subtitle)).toBeInTheDocument() + else expect(screen.queryByText('share.humanInput.expired')).not.toBeInTheDocument() if (submissionID) - expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument() - else - expect(screen.queryByText(/share\.humanInput\.submissionID/)).not.toBeInTheDocument() + expect( + screen.getByText('share.humanInput.submissionID:{"id":"token-123"}'), + ).toBeInTheDocument() + else expect(screen.queryByText(/share\.humanInput\.submissionID/)).not.toBeInTheDocument() expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument() expect(screen.getByText('dify-logo')).toBeInTheDocument() @@ -247,23 +260,28 @@ describe('Human input share form', () => { await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) await user.click(screen.getByRole('button', { name: 'Approve' })) - expect(mockSubmitForm).toHaveBeenCalledWith({ - token: 'token-123', - data: { - action: 'approve', - inputs: { - summary: 'updated summary', - attachments: [{ - type: 'document', - transfer_method: TransferMethod.local_file, - url: '', - upload_file_id: 'upload-file-1', - }], + expect(mockSubmitForm).toHaveBeenCalledWith( + { + token: 'token-123', + data: { + action: 'approve', + inputs: { + summary: 'updated summary', + attachments: [ + { + type: 'document', + transfer_method: TransferMethod.local_file, + url: '', + upload_file_id: 'upload-file-1', + }, + ], + }, }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) }) it('should show the success status after the submit mutation succeeds', async () => { @@ -296,15 +314,18 @@ describe('Human input share form', () => { ) }) - expect(mockSubmitForm).toHaveBeenCalledWith({ - token: 'token-empty', - data: { - action: 'reject', - inputs: {}, + expect(mockSubmitForm).toHaveBeenCalledWith( + { + token: 'token-empty', + data: { + action: 'reject', + inputs: {}, + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) }) it('should keep initialized defaults when file upload uses the initial change callback', async () => { @@ -315,23 +336,28 @@ describe('Human input share form', () => { await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) await user.click(screen.getByRole('button', { name: 'Approve' })) - expect(mockSubmitForm).toHaveBeenCalledWith({ - token: 'token-123', - data: { - action: 'approve', - inputs: { - summary: 'initial summary', - attachments: [{ - type: 'document', - transfer_method: TransferMethod.local_file, - url: '', - upload_file_id: 'upload-file-1', - }], + expect(mockSubmitForm).toHaveBeenCalledWith( + { + token: 'token-123', + data: { + action: 'approve', + inputs: { + summary: 'initial summary', + attachments: [ + { + type: 'document', + transfer_method: TransferMethod.local_file, + url: '', + upload_file_id: 'upload-file-1', + }, + ], + }, }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) }) it('should disable action buttons until every required field is filled and files are uploaded', async () => { @@ -396,7 +422,10 @@ describe('Human input share form', () => { render(<FormContent />) expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument() - expect(screen.getByRole('img', { name: 'logo' })).toHaveAttribute('src', 'https://example.com/custom-logo.png') + expect(screen.getByRole('img', { name: 'logo' })).toHaveAttribute( + 'src', + 'https://example.com/custom-logo.png', + ) expect(screen.queryByText('dify-logo')).not.toBeInTheDocument() }) }) diff --git a/web/app/(humanInputLayout)/form/[token]/branding-footer.tsx b/web/app/(humanInputLayout)/form/[token]/branding-footer.tsx index 14be1762a7170e..8c26efbfdc59d2 100644 --- a/web/app/(humanInputLayout)/form/[token]/branding-footer.tsx +++ b/web/app/(humanInputLayout)/form/[token]/branding-footer.tsx @@ -6,22 +6,22 @@ type BrandingFooterProps = { replaceWebappLogo?: string | null } -const BrandingFooter = ({ - removeWebappBrand, - replaceWebappLogo, -}: BrandingFooterProps) => { +const BrandingFooter = ({ removeWebappBrand, replaceWebappLogo }: BrandingFooterProps) => { const { t } = useTranslation() - if (removeWebappBrand) - return null + if (removeWebappBrand) return null return ( <div className="flex flex-row-reverse px-2 py-3"> <div className="flex shrink-0 items-center gap-1.5 px-1"> - <div className="system-2xs-medium-uppercase text-text-tertiary">{t($ => $['chat.poweredBy'], { ns: 'share' })}</div> - {replaceWebappLogo - ? <img src={replaceWebappLogo} alt="logo" className="block h-5 w-auto" /> - : <DifyLogo size="small" />} + <div className="system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['chat.poweredBy'], { ns: 'share' })} + </div> + {replaceWebappLogo ? ( + <img src={replaceWebappLogo} alt="logo" className="block h-5 w-auto" /> + ) : ( + <DifyLogo size="small" /> + )} </div> </div> ) diff --git a/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx b/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx index e08c955ff544ff..0ce8984f209aa1 100644 --- a/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx @@ -31,13 +31,11 @@ const FormStatusCard = ({ </div> <div className="grow"> <div className="title-4xl-semi-bold text-text-primary">{title}</div> - {!!subtitle && ( - <div className="title-4xl-semi-bold text-text-primary">{subtitle}</div> - )} + {!!subtitle && <div className="title-4xl-semi-bold text-text-primary">{subtitle}</div>} </div> {submissionID && ( <div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary"> - {t($ => $['humanInput.submissionID'], { id: submissionID, ns: 'share' })} + {t(($) => $['humanInput.submissionID'], { id: submissionID, ns: 'share' })} </div> )} </div> diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index 2c0b4d32626a28..c7cc132ecc4573 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -35,26 +35,26 @@ const FormContent = () => { const { isSubmitting, submit, success } = useFormSubmit(token) const removeWebappBrand = formData?.site?.custom_config?.remove_webapp_brand === true - const replaceWebappLogo = typeof formData?.site?.custom_config?.replace_webapp_logo === 'string' - ? formData.site.custom_config.replace_webapp_logo - : null + const replaceWebappLogo = + typeof formData?.site?.custom_config?.replace_webapp_logo === 'string' + ? formData.site.custom_config.replace_webapp_logo + : null const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired' const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted' - const rateLimitExceeded = (error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded' + const rateLimitExceeded = + (error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded' if (isLoading) { - return ( - <Loading type="app" /> - ) + return <Loading type="app" /> } if (success) { return ( <FormStatusCard iconClassName="i-ri-checkbox-circle-fill text-text-success" - title={t($ => $['humanInput.thanks'], { ns: 'share' })} - subtitle={t($ => $['humanInput.recorded'], { ns: 'share' })} + title={t(($) => $['humanInput.thanks'], { ns: 'share' })} + subtitle={t(($) => $['humanInput.recorded'], { ns: 'share' })} submissionID={token} removeWebappBrand={removeWebappBrand} replaceWebappLogo={replaceWebappLogo} @@ -66,8 +66,8 @@ const FormContent = () => { return ( <FormStatusCard iconClassName="i-ri-information-2-fill text-text-accent" - title={t($ => $['humanInput.sorry'], { ns: 'share' })} - subtitle={t($ => $['humanInput.expired'], { ns: 'share' })} + title={t(($) => $['humanInput.sorry'], { ns: 'share' })} + subtitle={t(($) => $['humanInput.expired'], { ns: 'share' })} submissionID={token} /> ) @@ -77,8 +77,8 @@ const FormContent = () => { return ( <FormStatusCard iconClassName="i-ri-information-2-fill text-text-accent" - title={t($ => $['humanInput.sorry'], { ns: 'share' })} - subtitle={t($ => $['humanInput.completed'], { ns: 'share' })} + title={t(($) => $['humanInput.sorry'], { ns: 'share' })} + subtitle={t(($) => $['humanInput.completed'], { ns: 'share' })} submissionID={token} /> ) @@ -88,7 +88,7 @@ const FormContent = () => { return ( <FormStatusCard iconClassName="i-ri-error-warning-fill text-text-destructive" - title={t($ => $['humanInput.rateLimitExceeded'], { ns: 'share' })} + title={t(($) => $['humanInput.rateLimitExceeded'], { ns: 'share' })} /> ) } @@ -97,7 +97,7 @@ const FormContent = () => { return ( <FormStatusCard iconClassName="i-ri-error-warning-fill text-text-destructive" - title={t($ => $['humanInput.formNotFound'], { ns: 'share' })} + title={t(($) => $['humanInput.formNotFound'], { ns: 'share' })} /> ) } diff --git a/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx index 9810d0a5b12b53..8d415f1eaf808b 100644 --- a/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx +++ b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx @@ -9,13 +9,23 @@ import { useMemo, useState } from 'react' import AppIcon from '@/app/components/base/app-icon' import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item' import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time' -import { getButtonStyle, getRenderedFormInputs, hasInvalidRequiredHumanInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils' +import { + getButtonStyle, + getRenderedFormInputs, + hasInvalidRequiredHumanInput, + initializeInputs, + splitByOutputVar, +} from '@/app/components/base/chat/chat/answer/human-input-content/utils' import BrandingFooter from './branding-footer' type LoadedFormContentProps = { formData: FormData isSubmitting: boolean - onSubmit: (inputs: Record<string, HumanInputFieldValue>, actionID: string, formInputs: FormData['inputs']) => void + onSubmit: ( + inputs: Record<string, HumanInputFieldValue>, + actionID: string, + formInputs: FormData['inputs'], + ) => void removeWebappBrand?: boolean replaceWebappLogo?: string | null } @@ -47,9 +57,11 @@ const LoadedFormContent = ({ }, [formData.form_content]) const handleInputsChange = (name: string, value: HumanInputFieldValue) => { - setInputs(prevInputs => produce(prevInputs, (draft) => { - draft[name] = value - })) + setInputs((prevInputs) => + produce(prevInputs, (draft) => { + draft[name] = value + }), + ) } const submit = (actionID: string) => { diff --git a/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts b/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts index de018b28ed37b8..44e1b6e347d8e8 100644 --- a/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts +++ b/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts @@ -8,22 +8,29 @@ export const useFormSubmit = (token: string) => { const [success, setSuccess] = useState(false) const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm() - const submit = useCallback((inputs: Record<string, HumanInputFieldValue>, actionID: string, formInputs: FormInputItem[]) => { - submitForm( - { - token, - data: { - inputs: getProcessedHumanInputFormInputs(formInputs, inputs) || {}, - action: actionID, + const submit = useCallback( + ( + inputs: Record<string, HumanInputFieldValue>, + actionID: string, + formInputs: FormInputItem[], + ) => { + submitForm( + { + token, + data: { + inputs: getProcessedHumanInputFormInputs(formInputs, inputs) || {}, + action: actionID, + }, }, - }, - { - onSuccess: () => { - setSuccess(true) + { + onSuccess: () => { + setSuccess(true) + }, }, - }, - ) - }, [submitForm, token]) + ) + }, + [submitForm, token], + ) return { isSubmitting, diff --git a/web/app/(shareLayout)/components/__tests__/authenticated-layout.spec.tsx b/web/app/(shareLayout)/components/__tests__/authenticated-layout.spec.tsx index 9768daf58bebbd..ca274a517e7683 100644 --- a/web/app/(shareLayout)/components/__tests__/authenticated-layout.spec.tsx +++ b/web/app/(shareLayout)/components/__tests__/authenticated-layout.spec.tsx @@ -67,7 +67,8 @@ const userCanAccessAppQueryState: QueryState<UserCanAccessApp> = { } vi.mock('@/context/web-app-context', () => ({ - useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => selector(mockWebAppState), + useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => + selector(mockWebAppState), })) vi.mock('@/next/navigation', () => ({ @@ -126,11 +127,12 @@ const resetQueryStates = () => { userCanAccessAppQueryState.isFetching = false } -const renderLayout = () => render( - <AuthenticatedLayout> - <div>Workflow form content</div> - </AuthenticatedLayout>, -) +const renderLayout = () => + render( + <AuthenticatedLayout> + <div>Workflow form content</div> + </AuthenticatedLayout>, + ) describe('AuthenticatedLayout', () => { beforeEach(() => { diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx index cda6afbb599c15..a75c5312daad7d 100644 --- a/web/app/(shareLayout)/components/authenticated-layout.tsx +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -13,25 +13,38 @@ import { webAppLogout } from '@/service/webapp-auth' const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { const { t } = useTranslation() - const shareCode = useWebAppStore(s => s.shareCode) - const updateAppInfo = useWebAppStore(s => s.updateAppInfo) - const updateAppParams = useWebAppStore(s => s.updateAppParams) - const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta) - const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp) - const { isLoading: isLoadingAppParams, data: appParams, error: appParamsError } = useGetWebAppParams() + const shareCode = useWebAppStore((s) => s.shareCode) + const updateAppInfo = useWebAppStore((s) => s.updateAppInfo) + const updateAppParams = useWebAppStore((s) => s.updateAppParams) + const updateWebAppMeta = useWebAppStore((s) => s.updateWebAppMeta) + const updateUserCanAccessApp = useWebAppStore((s) => s.updateUserCanAccessApp) + const { + isLoading: isLoadingAppParams, + data: appParams, + error: appParamsError, + } = useGetWebAppParams() const { isLoading: isLoadingAppInfo, data: appInfo, error: appInfoError } = useGetWebAppInfo() const { isLoading: isLoadingAppMeta, data: appMeta, error: appMetaError } = useGetWebAppMeta() - const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp: false }) + const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ + appId: appInfo?.app_id, + isInstalledApp: false, + }) useEffect(() => { - if (appInfo) - updateAppInfo(appInfo) - if (appParams) - updateAppParams(appParams) - if (appMeta) - updateWebAppMeta(appMeta) + if (appInfo) updateAppInfo(appInfo) + if (appParams) updateAppParams(appParams) + if (appMeta) updateWebAppMeta(appMeta) updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result)) - }, [appInfo, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp]) + }, [ + appInfo, + appMeta, + appParams, + updateAppInfo, + updateAppParams, + updateUserCanAccessApp, + updateWebAppMeta, + userCanAccessApp, + ]) const router = useRouter() const pathname = usePathname() @@ -83,11 +96,20 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { return ( <div className="flex h-full flex-col items-center justify-center gap-y-2"> <AppUnavailable className="size-auto" code={403} unknownReason="no permission." /> - <span className="cursor-pointer system-sm-regular text-text-tertiary" onClick={backToHome}>{t($ => $['userProfile.logout'], { ns: 'common' })}</span> + <span className="cursor-pointer system-sm-regular text-text-tertiary" onClick={backToHome}> + {t(($) => $['userProfile.logout'], { ns: 'common' })} + </span> </div> ) } - if (isLoadingAppInfo || isLoadingAppParams || isLoadingAppMeta || !appInfo || !appParams || !appMeta) { + if ( + isLoadingAppInfo || + isLoadingAppParams || + isLoadingAppMeta || + !appInfo || + !appParams || + !appMeta + ) { return ( <div className="flex h-full items-center justify-center"> <Loading /> diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index dd6da96d28e5d8..71125b4c08668c 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -7,13 +7,18 @@ import Loading from '@/app/components/base/loading' import { useWebAppStore } from '@/context/web-app-context' import { useRouter, useSearchParams } from '@/next/navigation' import { fetchAccessToken } from '@/service/share' -import { setWebAppAccessToken, setWebAppPassport, webAppLoginStatus, webAppLogout } from '@/service/webapp-auth' +import { + setWebAppAccessToken, + setWebAppPassport, + webAppLoginStatus, + webAppLogout, +} from '@/service/webapp-auth' const Splash: FC<PropsWithChildren> = ({ children }) => { const { t } = useTranslation() - const shareCode = useWebAppStore(s => s.shareCode) - const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) - const embeddedUserId = useWebAppStore(s => s.embeddedUserId) + const shareCode = useWebAppStore((s) => s.shareCode) + const webAppAccessMode = useWebAppStore((s) => s.webAppAccessMode) + const embeddedUserId = useWebAppStore((s) => s.embeddedUserId) const searchParams = useSearchParams() const router = useRouter() const redirectUrl = searchParams.get('redirect_url') @@ -40,33 +45,30 @@ const Splash: FC<PropsWithChildren> = ({ children }) => { return } - if (tokenFromUrl) - setWebAppAccessToken(tokenFromUrl) + if (tokenFromUrl) setWebAppAccessToken(tokenFromUrl) const redirectOrFinish = () => { - if (redirectUrl) - router.replace(decodeURIComponent(redirectUrl)) - else - setIsLoading(false) + if (redirectUrl) router.replace(decodeURIComponent(redirectUrl)) + else setIsLoading(false) } const proceedToAuth = () => { setIsLoading(false) } - (async () => { + ;(async () => { // if access mode is public, user login is always true, but the app login(passport) may be expired - const { userLoggedIn, appLoggedIn } = await webAppLoginStatus(shareCode!, embeddedUserId || undefined) + const { userLoggedIn, appLoggedIn } = await webAppLoginStatus( + shareCode!, + embeddedUserId || undefined, + ) if (userLoggedIn && appLoggedIn) { redirectOrFinish() - } - else if (!userLoggedIn && !appLoggedIn) { + } else if (!userLoggedIn && !appLoggedIn) { proceedToAuth() - } - else if (!userLoggedIn && appLoggedIn) { + } else if (!userLoggedIn && appLoggedIn) { redirectOrFinish() - } - else if (userLoggedIn && !appLoggedIn) { + } else if (userLoggedIn && !appLoggedIn) { try { const { access_token } = await fetchAccessToken({ appCode: shareCode!, @@ -74,28 +76,27 @@ const Splash: FC<PropsWithChildren> = ({ children }) => { }) setWebAppPassport(shareCode!, access_token) redirectOrFinish() - } - catch { + } catch { await webAppLogout(shareCode!) proceedToAuth() } } })() - }, [ - shareCode, - redirectUrl, - router, - message, - webAppAccessMode, - tokenFromUrl, - embeddedUserId, - ]) + }, [shareCode, redirectUrl, router, message, webAppAccessMode, tokenFromUrl, embeddedUserId]) if (message) { return ( <div className="flex h-full flex-col items-center justify-center gap-y-4"> - <AppUnavailable className="size-auto" code={code || t($ => $['common.appUnavailable'], { ns: 'share' })} unknownReason={message} /> - <span className="cursor-pointer system-sm-regular text-text-tertiary" onClick={backToHome}>{code === '403' ? t($ => $['userProfile.logout'], { ns: 'common' }) : t($ => $['login.backToHome'], { ns: 'share' })}</span> + <AppUnavailable + className="size-auto" + code={code || t(($) => $['common.appUnavailable'], { ns: 'share' })} + unknownReason={message} + /> + <span className="cursor-pointer system-sm-regular text-text-tertiary" onClick={backToHome}> + {code === '403' + ? t(($) => $['userProfile.logout'], { ns: 'common' }) + : t(($) => $['login.backToHome'], { ns: 'share' })} + </span> </div> ) } diff --git a/web/app/(shareLayout)/layout.tsx b/web/app/(shareLayout)/layout.tsx index 5af913cac9f9d3..df4a9e2847a5e2 100644 --- a/web/app/(shareLayout)/layout.tsx +++ b/web/app/(shareLayout)/layout.tsx @@ -6,9 +6,7 @@ const Layout: FC<PropsWithChildren> = ({ children }) => { return ( <div className="h-full min-w-[300px] pb-[env(safe-area-inset-bottom)]"> <WebAppStoreProvider> - <Splash> - {children} - </Splash> + <Splash>{children}</Splash> </WebAppStoreProvider> </div> ) diff --git a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx index 5d412afb2e201c..b4228de485a2b3 100644 --- a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx @@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import Countdown from '@/app/components/signin/countdown' import { useLocale } from '@/context/i18n' - import { useRouter, useSearchParams } from '@/next/navigation' import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common' @@ -24,11 +23,11 @@ export default function CheckCode() { const verify = async () => { try { if (!code.trim()) { - toast.error(t($ => $['checkCode.emptyCode'], { ns: 'login' })) + toast.error(t(($) => $['checkCode.emptyCode'], { ns: 'login' })) return } if (!/\d{6}/.test(code)) { - toast.error(t($ => $['checkCode.invalidCode'], { ns: 'login' })) + toast.error(t(($) => $['checkCode.invalidCode'], { ns: 'login' })) return } setIsLoading(true) @@ -38,9 +37,9 @@ export default function CheckCode() { params.set('token', encodeURIComponent(ret.token)) router.push(`/webapp-reset-password/set-password?${params.toString()}`) } - } - catch (error) { console.error(error) } - finally { + } catch (error) { + console.error(error) + } finally { setIsLoading(false) } } @@ -53,8 +52,9 @@ export default function CheckCode() { params.set('token', encodeURIComponent(res.data)) router.replace(`/webapp-reset-password/check-code?${params.toString()}`) } + } catch (error) { + console.error(error) } - catch (error) { console.error(error) } } return ( @@ -63,32 +63,53 @@ export default function CheckCode() { <RiMailSendFill className="size-6 text-2xl" /> </div> <div className="pt-2 pb-4"> - <h2 className="title-4xl-semi-bold text-text-primary">{t($ => $['checkCode.checkYourEmail'], { ns: 'login' })}</h2> + <h2 className="title-4xl-semi-bold text-text-primary"> + {t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })} + </h2> <p className="mt-2 body-md-regular text-text-secondary"> <span> - {t($ => $['checkCode.tipsPrefix'], { ns: 'login' })} + {t(($) => $['checkCode.tipsPrefix'], { ns: 'login' })} <strong>{email}</strong> </span> <br /> - {t($ => $['checkCode.validTime'], { ns: 'login' })} + {t(($) => $['checkCode.validTime'], { ns: 'login' })} </p> </div> <form action=""> <input type="text" className="hidden" /> - <label htmlFor="code" className="mb-1 system-md-semibold text-text-secondary">{t($ => $['checkCode.verificationCode'], { ns: 'login' })}</label> - <Input value={code} onChange={e => setVerifyCode(e.target.value)} maxLength={6} className="mt-1" placeholder={t($ => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) || ''} /> - <Button loading={loading} disabled={loading} className="my-3 w-full" variant="primary" onClick={verify}>{t($ => $['checkCode.verify'], { ns: 'login' })}</Button> + <label htmlFor="code" className="mb-1 system-md-semibold text-text-secondary"> + {t(($) => $['checkCode.verificationCode'], { ns: 'login' })} + </label> + <Input + value={code} + onChange={(e) => setVerifyCode(e.target.value)} + maxLength={6} + className="mt-1" + placeholder={t(($) => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) || ''} + /> + <Button + loading={loading} + disabled={loading} + className="my-3 w-full" + variant="primary" + onClick={verify} + > + {t(($) => $['checkCode.verify'], { ns: 'login' })} + </Button> <Countdown onResend={resendCode} /> </form> <div className="py-2"> <div className="h-px bg-linear-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div> </div> - <div onClick={() => router.back()} className="flex h-9 cursor-pointer items-center justify-center text-text-tertiary"> + <div + onClick={() => router.back()} + className="flex h-9 cursor-pointer items-center justify-center text-text-tertiary" + > <div className="bg-background-default-dimm inline-block rounded-full p-1"> <RiArrowLeftLine size={12} /> </div> - <span className="ml-2 system-xs-regular">{t($ => $.back, { ns: 'login' })}</span> + <span className="ml-2 system-xs-regular">{t(($) => $.back, { ns: 'login' })}</span> </div> </div> ) diff --git a/web/app/(shareLayout)/webapp-reset-password/layout.tsx b/web/app/(shareLayout)/webapp-reset-password/layout.tsx index a8388e0957a98b..4d65514b4a5488 100644 --- a/web/app/(shareLayout)/webapp-reset-password/layout.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/layout.tsx @@ -1,6 +1,5 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' - import { useSuspenseQuery } from '@tanstack/react-query' import Header from '@/app/signin/_header' import { systemFeaturesQueryOptions } from '@/features/system-features/client' @@ -10,27 +9,24 @@ export default function SignInLayout({ children }: any) { return ( <> <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> - <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> + <div + className={cn( + 'flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle', + )} + > <Header /> - <div className={ - cn( + <div + className={cn( 'flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]', - ) - } + )} > - <div className="flex w-[400px] flex-col"> - {children} - </div> + <div className="flex w-[400px] flex-col">{children}</div> </div> {!systemFeatures.branding.enabled && ( <div className="px-8 py-6 system-xs-regular text-text-tertiary"> - © - {' '} - {new Date().getFullYear()} - {' '} - LangGenius, Inc. All rights reserved. + © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. </div> )} </div> diff --git a/web/app/(shareLayout)/webapp-reset-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/page.tsx index bedaf700a6e1de..adccea64f80192 100644 --- a/web/app/(shareLayout)/webapp-reset-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/page.tsx @@ -10,7 +10,6 @@ import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '@/app/components/si import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' - import Link from '@/next/link' import { useRouter, useSearchParams } from '@/next/navigation' import { sendResetPasswordCode } from '@/service/common' @@ -28,12 +27,12 @@ export default function CheckCode() { const handleGetEMailVerificationCode = async () => { try { if (!email) { - toast.error(t($ => $['error.emailEmpty'], { ns: 'login' })) + toast.error(t(($) => $['error.emailEmpty'], { ns: 'login' })) return } if (!emailRegex.test(email)) { - toast.error(t($ => $['error.emailInValid'], { ns: 'login' })) + toast.error(t(($) => $['error.emailInValid'], { ns: 'login' })) return } setIsLoading(true) @@ -44,18 +43,14 @@ export default function CheckCode() { params.set('token', encodeURIComponent(res.data)) params.set('email', encodeURIComponent(email)) router.push(`/webapp-reset-password/check-code?${params.toString()}`) - } - else if (res.code === 'account_not_found') { - toast.error(t($ => $['error.registrationNotAllowed'], { ns: 'login' })) - } - else { + } else if (res.code === 'account_not_found') { + toast.error(t(($) => $['error.registrationNotAllowed'], { ns: 'login' })) + } else { toast.error(res.data) } - } - catch (error) { + } catch (error) { console.error(error) - } - finally { + } finally { setIsLoading(false) } } @@ -66,32 +61,54 @@ export default function CheckCode() { <RiLockPasswordLine className="size-6 text-2xl text-text-accent-light-mode-only" /> </div> <div className="pt-2 pb-4"> - <h2 className="title-4xl-semi-bold text-text-primary">{t($ => $.resetPassword, { ns: 'login' })}</h2> + <h2 className="title-4xl-semi-bold text-text-primary"> + {t(($) => $.resetPassword, { ns: 'login' })} + </h2> <p className="mt-2 body-md-regular text-text-secondary"> - {t($ => $.resetPasswordDesc, { ns: 'login' })} + {t(($) => $.resetPasswordDesc, { ns: 'login' })} </p> </div> <form onSubmit={noop}> <input type="text" className="hidden" /> <div className="mb-2"> - <label htmlFor="email" className="my-2 system-md-semibold text-text-secondary">{t($ => $.email, { ns: 'login' })}</label> + <label htmlFor="email" className="my-2 system-md-semibold text-text-secondary"> + {t(($) => $.email, { ns: 'login' })} + </label> <div className="mt-1"> - <Input id="email" type="email" disabled={loading} value={email} placeholder={t($ => $.emailPlaceholder, { ns: 'login' }) as string} onChange={e => setEmail(e.target.value)} /> + <Input + id="email" + type="email" + disabled={loading} + value={email} + placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) as string} + onChange={(e) => setEmail(e.target.value)} + /> </div> <div className="mt-3"> - <Button loading={loading} disabled={loading} variant="primary" className="w-full" onClick={handleGetEMailVerificationCode}>{t($ => $.sendVerificationCode, { ns: 'login' })}</Button> + <Button + loading={loading} + disabled={loading} + variant="primary" + className="w-full" + onClick={handleGetEMailVerificationCode} + > + {t(($) => $.sendVerificationCode, { ns: 'login' })} + </Button> </div> </div> </form> <div className="py-2"> <div className="h-px bg-linear-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div> </div> - <Link href={`/webapp-signin?${searchParams.toString()}`} className="flex h-9 items-center justify-center text-text-tertiary hover:text-text-primary"> + <Link + href={`/webapp-signin?${searchParams.toString()}`} + className="flex h-9 items-center justify-center text-text-tertiary hover:text-text-primary" + > <div className="inline-block rounded-full bg-background-default-dimmed p-1"> <RiArrowLeftLine size={12} /> </div> - <span className="ml-2 system-xs-regular">{t($ => $.backToLogin, { ns: 'login' })}</span> + <span className="ml-2 system-xs-regular">{t(($) => $.backToLogin, { ns: 'login' })}</span> </Link> </div> ) diff --git a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx index 31b986c6c74ae7..40fec223015277 100644 --- a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx @@ -42,23 +42,22 @@ const ChangePasswordForm = () => { const valid = useCallback(() => { if (!password.trim()) { - showErrorMessage(t($ => $['error.passwordEmpty'], { ns: 'login' })) + showErrorMessage(t(($) => $['error.passwordEmpty'], { ns: 'login' })) return false } if (!validPassword.test(password)) { - showErrorMessage(t($ => $['error.passwordInvalid'], { ns: 'login' })) + showErrorMessage(t(($) => $['error.passwordInvalid'], { ns: 'login' })) return false } if (password !== confirmPassword) { - showErrorMessage(t($ => $['account.notEqual'], { ns: 'common' })) + showErrorMessage(t(($) => $['account.notEqual'], { ns: 'common' })) return false } return true }, [password, confirmPassword, showErrorMessage, t]) const handleChangePassword = useCallback(async () => { - if (!valid()) - return + if (!valid()) return try { await changeWebAppPasswordWithToken({ url: '/forgot-password/resets', @@ -70,29 +69,27 @@ const ChangePasswordForm = () => { }) setShowSuccess(true) setLeftTime(AUTO_REDIRECT_TIME) - } - catch (error) { + } catch (error) { console.error(error) } }, [password, token, valid, confirmPassword]) return ( - <div className={ - cn( + <div + className={cn( 'flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]', - ) - } + )} > {!showSuccess && ( <div className="flex flex-col md:w-[400px]"> <div className="mx-auto w-full"> <h2 className="title-4xl-semi-bold text-text-primary"> - {t($ => $.changePassword, { ns: 'login' })} + {t(($) => $.changePassword, { ns: 'login' })} </h2> <p className="mt-2 body-md-regular text-text-secondary"> - {t($ => $.changePasswordTip, { ns: 'login' })} + {t(($) => $.changePasswordTip, { ns: 'login' })} </p> </div> @@ -101,15 +98,15 @@ const ChangePasswordForm = () => { {/* Password */} <div className="mb-5"> <label htmlFor="password" className="my-2 system-md-semibold text-text-secondary"> - {t($ => $['account.newPassword'], { ns: 'common' })} + {t(($) => $['account.newPassword'], { ns: 'common' })} </label> <div className="relative mt-1"> <Input id="password" type={showPassword ? 'text' : 'password'} value={password} - onChange={e => setPassword(e.target.value)} - placeholder={t($ => $.passwordPlaceholder, { ns: 'login' }) || ''} + onChange={(e) => setPassword(e.target.value)} + placeholder={t(($) => $.passwordPlaceholder, { ns: 'login' }) || ''} /> <div className="absolute inset-y-0 right-0 flex items-center"> @@ -122,20 +119,25 @@ const ChangePasswordForm = () => { </Button> </div> </div> - <div className="mt-1 body-xs-regular text-text-secondary">{t($ => $['error.passwordInvalid'], { ns: 'login' })}</div> + <div className="mt-1 body-xs-regular text-text-secondary"> + {t(($) => $['error.passwordInvalid'], { ns: 'login' })} + </div> </div> {/* Confirm Password */} <div className="mb-5"> - <label htmlFor="confirmPassword" className="my-2 system-md-semibold text-text-secondary"> - {t($ => $['account.confirmPassword'], { ns: 'common' })} + <label + htmlFor="confirmPassword" + className="my-2 system-md-semibold text-text-secondary" + > + {t(($) => $['account.confirmPassword'], { ns: 'common' })} </label> <div className="relative mt-1"> <Input id="confirmPassword" type={showConfirmPassword ? 'text' : 'password'} value={confirmPassword} - onChange={e => setConfirmPassword(e.target.value)} - placeholder={t($ => $.confirmPasswordPlaceholder, { ns: 'login' }) || ''} + onChange={(e) => setConfirmPassword(e.target.value)} + placeholder={t(($) => $.confirmPasswordPlaceholder, { ns: 'login' }) || ''} /> <div className="absolute inset-y-0 right-0 flex items-center"> <Button @@ -149,12 +151,8 @@ const ChangePasswordForm = () => { </div> </div> <div> - <Button - variant="primary" - className="w-full" - onClick={handleChangePassword} - > - {t($ => $.changePasswordBtn, { ns: 'login' })} + <Button variant="primary" className="w-full" onClick={handleChangePassword}> + {t(($) => $.changePasswordBtn, { ns: 'login' })} </Button> </div> </div> @@ -168,7 +166,7 @@ const ChangePasswordForm = () => { <RiCheckboxCircleFill className="size-6 text-text-success" /> </div> <h2 className="title-4xl-semi-bold text-text-primary"> - {t($ => $.passwordChangedTip, { ns: 'login' })} + {t(($) => $.passwordChangedTip, { ns: 'login' })} </h2> </div> <div className="mx-auto mt-6 w-full"> @@ -180,12 +178,7 @@ const ChangePasswordForm = () => { router.replace(getSignInUrl()) }} > - {t($ => $.passwordChanged, { ns: 'login' })} - {' '} - ( - {Math.round(countdown / 1000)} - ) - {' '} + {t(($) => $.passwordChanged, { ns: 'login' })} ({Math.round(countdown / 1000)}){' '} </Button> </div> </div> diff --git a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx index 78e63a85cd4ff8..7e0c734a21df65 100644 --- a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx @@ -26,15 +26,13 @@ export default function CheckCode() { const locale = useLocale() const codeInputRef = useRef<HTMLInputElement>(null) const redirectUrl = searchParams.get('redirect_url') - const embeddedUserId = useWebAppStore(s => s.embeddedUserId) + const embeddedUserId = useWebAppStore((s) => s.embeddedUserId) const getAppCodeFromRedirectUrl = useCallback(() => { - if (!redirectUrl) - return null + if (!redirectUrl) return null const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) const appCode = url.pathname.split('/').pop() - if (!appCode) - return null + if (!appCode) return null return appCode }, [redirectUrl]) @@ -43,19 +41,23 @@ export default function CheckCode() { try { const appCode = getAppCodeFromRedirectUrl() if (!code.trim()) { - toast.error(t($ => $['checkCode.emptyCode'], { ns: 'login' })) + toast.error(t(($) => $['checkCode.emptyCode'], { ns: 'login' })) return } if (!/\d{6}/.test(code)) { - toast.error(t($ => $['checkCode.invalidCode'], { ns: 'login' })) + toast.error(t(($) => $['checkCode.invalidCode'], { ns: 'login' })) return } if (!redirectUrl || !appCode) { - toast.error(t($ => $['error.redirectUrlMissing'], { ns: 'login' })) + toast.error(t(($) => $['error.redirectUrlMissing'], { ns: 'login' })) return } setIsLoading(true) - const ret = await webAppEmailLoginWithCode({ email, code: encryptVerificationCode(code), token }) + const ret = await webAppEmailLoginWithCode({ + email, + code: encryptVerificationCode(code), + token, + }) if (ret.result === 'success') { if (ret?.data?.access_token) { setWebAppAccessToken(ret.data.access_token) @@ -67,9 +69,9 @@ export default function CheckCode() { setWebAppPassport(appCode!, access_token) router.replace(decodeURIComponent(redirectUrl)) } - } - catch (error) { console.error(error) } - finally { + } catch (error) { + console.error(error) + } finally { setIsLoading(false) } } @@ -91,8 +93,9 @@ export default function CheckCode() { params.set('token', encodeURIComponent(ret.data)) router.replace(`/webapp-signin/check-code?${params.toString()}`) } + } catch (error) { + console.error(error) } - catch (error) { console.error(error) } } return ( @@ -101,39 +104,54 @@ export default function CheckCode() { <RiMailSendFill className="size-6 text-2xl text-text-accent-light-mode-only" /> </div> <div className="pt-2 pb-4"> - <h2 className="title-4xl-semi-bold text-text-primary">{t($ => $['checkCode.checkYourEmail'], { ns: 'login' })}</h2> + <h2 className="title-4xl-semi-bold text-text-primary"> + {t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })} + </h2> <p className="mt-2 body-md-regular text-text-secondary"> <span> - {t($ => $['checkCode.tipsPrefix'], { ns: 'login' })} + {t(($) => $['checkCode.tipsPrefix'], { ns: 'login' })} <strong>{email}</strong> </span> <br /> - {t($ => $['checkCode.validTime'], { ns: 'login' })} + {t(($) => $['checkCode.validTime'], { ns: 'login' })} </p> </div> <form onSubmit={handleSubmit}> - <label htmlFor="code" className="mb-1 system-md-semibold text-text-secondary">{t($ => $['checkCode.verificationCode'], { ns: 'login' })}</label> + <label htmlFor="code" className="mb-1 system-md-semibold text-text-secondary"> + {t(($) => $['checkCode.verificationCode'], { ns: 'login' })} + </label> <Input ref={codeInputRef} id="code" value={code} - onChange={e => setVerifyCode(e.target.value)} + onChange={(e) => setVerifyCode(e.target.value)} maxLength={6} className="mt-1" - placeholder={t($ => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) || ''} + placeholder={t(($) => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) || ''} /> - <Button type="submit" loading={loading} disabled={loading} className="my-3 w-full" variant="primary">{t($ => $['checkCode.verify'], { ns: 'login' })}</Button> + <Button + type="submit" + loading={loading} + disabled={loading} + className="my-3 w-full" + variant="primary" + > + {t(($) => $['checkCode.verify'], { ns: 'login' })} + </Button> <Countdown onResend={resendCode} /> </form> <div className="py-2"> <div className="h-px bg-linear-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div> </div> - <div onClick={() => router.back()} className="flex h-9 cursor-pointer items-center justify-center text-text-tertiary"> + <div + onClick={() => router.back()} + className="flex h-9 cursor-pointer items-center justify-center text-text-tertiary" + > <div className="bg-background-default-dimm inline-block rounded-full p-1"> <RiArrowLeftLine size={12} /> </div> - <span className="ml-2 system-xs-regular">{t($ => $.back, { ns: 'login' })}</span> + <span className="ml-2 system-xs-regular">{t(($) => $.back, { ns: 'login' })}</span> </div> </div> ) diff --git a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx index ec313ac988afc5..3615fb4441910a 100644 --- a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx @@ -22,12 +22,10 @@ const ExternalMemberSSOAuth = () => { } const getAppCodeFromRedirectUrl = useCallback(() => { - if (!redirectUrl) - return null + if (!redirectUrl) return null const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) const appCode = url.pathname.split('/').pop() - if (!appCode) - return null + if (!appCode) return null return appCode }, [redirectUrl]) @@ -60,7 +58,12 @@ const ExternalMemberSSOAuth = () => { default: showErrorToast('SSO protocol is not supported.') } - }, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol]) + }, [ + getAppCodeFromRedirectUrl, + redirectUrl, + router, + systemFeatures.webapp_auth.sso_config.protocol, + ]) useEffect(() => { handleSSOLogin() diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx index af0a9ea10ea13e..5b10b54e79ea98 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx @@ -23,12 +23,12 @@ export default function MailAndCodeAuth() { const handleGetEMailVerificationCode = async () => { try { if (!email) { - toast.error(t($ => $['error.emailEmpty'], { ns: 'login' })) + toast.error(t(($) => $['error.emailEmpty'], { ns: 'login' })) return } if (!emailRegex.test(email)) { - toast.error(t($ => $['error.emailInValid'], { ns: 'login' })) + toast.error(t(($) => $['error.emailInValid'], { ns: 'login' })) return } setIsLoading(true) @@ -40,11 +40,9 @@ export default function MailAndCodeAuth() { params.set('token', encodeURIComponent(ret.data)) router.push(`/webapp-signin/check-code?${params.toString()}`) } - } - catch (error) { + } catch (error) { console.error(error) - } - finally { + } finally { setIsLoading(false) } } @@ -53,12 +51,28 @@ export default function MailAndCodeAuth() { <form onSubmit={noop}> <input type="text" className="hidden" /> <div className="mb-2"> - <label htmlFor="email" className="my-2 system-md-semibold text-text-secondary">{t($ => $.email, { ns: 'login' })}</label> + <label htmlFor="email" className="my-2 system-md-semibold text-text-secondary"> + {t(($) => $.email, { ns: 'login' })} + </label> <div className="mt-1"> - <Input id="email" type="email" value={email} placeholder={t($ => $.emailPlaceholder, { ns: 'login' }) as string} onChange={e => setEmail(e.target.value)} /> + <Input + id="email" + type="email" + value={email} + placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) as string} + onChange={(e) => setEmail(e.target.value)} + /> </div> <div className="mt-3"> - <Button loading={loading} disabled={loading || !email} variant="primary" className="w-full" onClick={handleGetEMailVerificationCode}>{t($ => $['signup.verifyMail'], { ns: 'login' })}</Button> + <Button + loading={loading} + disabled={loading || !email} + variant="primary" + className="w-full" + onClick={handleGetEMailVerificationCode} + > + {t(($) => $['signup.verifyMail'], { ns: 'login' })} + </Button> </div> </div> </form> diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx index c27a44387302b0..c598e2a1bdad5d 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx @@ -31,35 +31,33 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut const [isLoading, setIsLoading] = useState(false) const redirectUrl = searchParams.get('redirect_url') - const embeddedUserId = useWebAppStore(s => s.embeddedUserId) + const embeddedUserId = useWebAppStore((s) => s.embeddedUserId) const getAppCodeFromRedirectUrl = useCallback(() => { - if (!redirectUrl) - return null + if (!redirectUrl) return null const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) const appCode = url.pathname.split('/').pop() - if (!appCode) - return null + if (!appCode) return null return appCode }, [redirectUrl]) const appCode = getAppCodeFromRedirectUrl() const handleEmailPasswordLogin = async () => { if (!email) { - toast.error(t($ => $['error.emailEmpty'], { ns: 'login' })) + toast.error(t(($) => $['error.emailEmpty'], { ns: 'login' })) return } if (!emailRegex.test(email)) { - toast.error(t($ => $['error.emailInValid'], { ns: 'login' })) + toast.error(t(($) => $['error.emailInValid'], { ns: 'login' })) return } if (!password?.trim()) { - toast.error(t($ => $['error.passwordEmpty'], { ns: 'login' })) + toast.error(t(($) => $['error.passwordEmpty'], { ns: 'login' })) return } if (!redirectUrl || !appCode) { - toast.error(t($ => $['error.redirectUrlMissing'], { ns: 'login' })) + toast.error(t(($) => $['error.redirectUrlMissing'], { ns: 'login' })) return } try { @@ -86,16 +84,12 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut }) setWebAppPassport(appCode!, access_token) router.replace(decodeURIComponent(redirectUrl)) - } - else { + } else { toast.error(res.data) } - } - catch (e: any) { - if (e.code === 'authentication_failed') - toast.error(e.message) - } - finally { + } catch (e: any) { + if (e.code === 'authentication_failed') toast.error(e.message) + } finally { setIsLoading(false) } } @@ -104,16 +98,16 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut <form onSubmit={noop}> <div className="mb-3"> <label htmlFor="email" className="my-2 system-md-semibold text-text-secondary"> - {t($ => $.email, { ns: 'login' })} + {t(($) => $.email, { ns: 'login' })} </label> <div className="mt-1"> <Input value={email} - onChange={e => setEmail(e.target.value)} + onChange={(e) => setEmail(e.target.value)} id="email" type="email" autoComplete="email" - placeholder={t($ => $.emailPlaceholder, { ns: 'login' }) || ''} + placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) || ''} tabIndex={1} /> </div> @@ -121,36 +115,33 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut <div className="mb-3"> <label htmlFor="password" className="my-2 flex items-center justify-between"> - <span className="system-md-semibold text-text-secondary">{t($ => $.password, { ns: 'login' })}</span> + <span className="system-md-semibold text-text-secondary"> + {t(($) => $.password, { ns: 'login' })} + </span> <Link href={`/webapp-reset-password?${searchParams.toString()}`} className={`system-xs-regular ${isEmailSetup ? 'text-components-button-secondary-accent-text' : 'pointer-events-none text-components-button-secondary-accent-text-disabled'}`} tabIndex={isEmailSetup ? 0 : -1} aria-disabled={!isEmailSetup} > - {t($ => $.forget, { ns: 'login' })} + {t(($) => $.forget, { ns: 'login' })} </Link> </label> <div className="relative mt-1"> <Input value={password} - onChange={e => setPassword(e.target.value)} + onChange={(e) => setPassword(e.target.value)} id="password" onKeyDown={(e) => { - if (e.key === 'Enter') - handleEmailPasswordLogin() + if (e.key === 'Enter') handleEmailPasswordLogin() }} type={showPassword ? 'text' : 'password'} autoComplete="current-password" - placeholder={t($ => $.passwordPlaceholder, { ns: 'login' }) || ''} + placeholder={t(($) => $.passwordPlaceholder, { ns: 'login' }) || ''} tabIndex={2} /> <div className="absolute inset-y-0 right-0 flex items-center"> - <Button - type="button" - variant="ghost" - onClick={() => setShowPassword(!showPassword)} - > + <Button type="button" variant="ghost" onClick={() => setShowPassword(!showPassword)}> {showPassword ? '👀' : '😝'} </Button> </div> @@ -165,7 +156,7 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut disabled={isLoading || !email || !password} className="w-full" > - {t($ => $.signBtn, { ns: 'login' })} + {t(($) => $.signBtn, { ns: 'login' })} </Button> </div> </form> diff --git a/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx index f3fe299fcec58e..4a4ef60d9c52ea 100644 --- a/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx @@ -7,27 +7,27 @@ import { useTranslation } from 'react-i18next' import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' import { SSOProtocol } from '@/features/system-features/constants' import { useRouter, useSearchParams } from '@/next/navigation' -import { fetchMembersOAuth2SSOUrl, fetchMembersOIDCSSOUrl, fetchMembersSAMLSSOUrl } from '@/service/share' +import { + fetchMembersOAuth2SSOUrl, + fetchMembersOIDCSSOUrl, + fetchMembersSAMLSSOUrl, +} from '@/service/share' type SSOAuthProps = { protocol: string } -const SSOAuth: FC<SSOAuthProps> = ({ - protocol, -}) => { +const SSOAuth: FC<SSOAuthProps> = ({ protocol }) => { const router = useRouter() const { t } = useTranslation() const searchParams = useSearchParams() const redirectUrl = searchParams.get('redirect_url') const getAppCodeFromRedirectUrl = useCallback(() => { - if (!redirectUrl) - return null + if (!redirectUrl) return null const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) const appCode = url.pathname.split('/').pop() - if (!appCode) - return null + if (!appCode) return null return appCode }, [redirectUrl]) @@ -37,33 +37,36 @@ const SSOAuth: FC<SSOAuthProps> = ({ const handleSSOLogin = () => { const appCode = getAppCodeFromRedirectUrl() if (!redirectUrl || !appCode) { - toast.error(t($ => $['error.invalidRedirectUrlOrAppCode'], { ns: 'login' })) + toast.error(t(($) => $['error.invalidRedirectUrlOrAppCode'], { ns: 'login' })) return } setIsLoading(true) if (protocol === SSOProtocol.SAML) { - fetchMembersSAMLSSOUrl(appCode, redirectUrl).then((res) => { - router.push(res.url) - }).finally(() => { - setIsLoading(false) - }) - } - else if (protocol === SSOProtocol.OIDC) { - fetchMembersOIDCSSOUrl(appCode, redirectUrl).then((res) => { - router.push(res.url) - }).finally(() => { - setIsLoading(false) - }) - } - else if (protocol === SSOProtocol.OAuth2) { - fetchMembersOAuth2SSOUrl(appCode, redirectUrl).then((res) => { - router.push(res.url) - }).finally(() => { - setIsLoading(false) - }) - } - else { - toast.error(t($ => $['error.invalidSSOProtocol'], { ns: 'login' })) + fetchMembersSAMLSSOUrl(appCode, redirectUrl) + .then((res) => { + router.push(res.url) + }) + .finally(() => { + setIsLoading(false) + }) + } else if (protocol === SSOProtocol.OIDC) { + fetchMembersOIDCSSOUrl(appCode, redirectUrl) + .then((res) => { + router.push(res.url) + }) + .finally(() => { + setIsLoading(false) + }) + } else if (protocol === SSOProtocol.OAuth2) { + fetchMembersOAuth2SSOUrl(appCode, redirectUrl) + .then((res) => { + router.push(res.url) + }) + .finally(() => { + setIsLoading(false) + }) + } else { + toast.error(t(($) => $['error.invalidSSOProtocol'], { ns: 'login' })) setIsLoading(false) } } @@ -71,12 +74,14 @@ const SSOAuth: FC<SSOAuthProps> = ({ return ( <Button tabIndex={0} - onClick={() => { handleSSOLogin() }} + onClick={() => { + handleSSOLogin() + }} disabled={isLoading} className="w-full" > <Lock01 className="mr-2 size-5 text-text-accent-light-mode-only" /> - <span className="truncate">{t($ => $.withSSO, { ns: 'login' })}</span> + <span className="truncate">{t(($) => $.withSSO, { ns: 'login' })}</span> </Button> ) } diff --git a/web/app/(shareLayout)/webapp-signin/layout.tsx b/web/app/(shareLayout)/webapp-signin/layout.tsx index 7d9be4146e631b..b1f582bf6f48ff 100644 --- a/web/app/(shareLayout)/webapp-signin/layout.tsx +++ b/web/app/(shareLayout)/webapp-signin/layout.tsx @@ -10,24 +10,26 @@ import useDocumentTitle from '@/hooks/use-document-title' export default function SignInLayout({ children }: PropsWithChildren) { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - useDocumentTitle(t($ => $['webapp.login'], { ns: 'login' })) + useDocumentTitle(t(($) => $['webapp.login'], { ns: 'login' })) return ( <> <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> - <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> + <div + className={cn( + 'flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle', + )} + > {/* <Header /> */} - <div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}> - <div className="flex justify-center md:w-[440px] lg:w-[600px]"> - {children} - </div> + <div + className={cn( + 'flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]', + )} + > + <div className="flex justify-center md:w-[440px] lg:w-[600px]">{children}</div> </div> {systemFeatures.branding.enabled === false && ( <div className="px-8 py-6 system-xs-regular text-text-tertiary"> - © - {' '} - {new Date().getFullYear()} - {' '} - LangGenius, Inc. All rights reserved. + © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. </div> )} </div> diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index 6965b0a0f74ec1..7def2fc6823500 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -25,28 +25,35 @@ const NormalForm = () => { const init = useCallback(async () => { try { - setAllMethodsAreDisabled(!systemFeatures.enable_social_oauth_login && !systemFeatures.enable_email_code_login && !systemFeatures.enable_email_password_login && !systemFeatures.sso_enforced_for_signin) - setShowORLine((systemFeatures.enable_social_oauth_login || systemFeatures.sso_enforced_for_signin) && (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login)) + setAllMethodsAreDisabled( + !systemFeatures.enable_social_oauth_login && + !systemFeatures.enable_email_code_login && + !systemFeatures.enable_email_password_login && + !systemFeatures.sso_enforced_for_signin, + ) + setShowORLine( + (systemFeatures.enable_social_oauth_login || systemFeatures.sso_enforced_for_signin) && + (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login), + ) updateAuthType(systemFeatures.enable_email_password_login ? 'password' : 'code') - } - catch (error) { + } catch (error) { console.error(error) setAllMethodsAreDisabled(true) + } finally { + setIsLoading(false) } - finally { setIsLoading(false) } }, [systemFeatures]) useEffect(() => { init() }, [init]) if (isLoading) { return ( - <div className={ - cn( + <div + className={cn( 'flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]', - ) - } + )} > <Loading type="area" /> </div> @@ -61,8 +68,12 @@ const NormalForm = () => { <RiContractLine className="size-5" /> <RiErrorWarningFill className="absolute -top-1 -right-1 size-4 text-text-warning-secondary" /> </div> - <p className="system-sm-medium text-text-primary">{t($ => $.licenseLost, { ns: 'login' })}</p> - <p className="mt-1 system-xs-regular text-text-tertiary">{t($ => $.licenseLostTip, { ns: 'login' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $.licenseLost, { ns: 'login' })} + </p> + <p className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $.licenseLostTip, { ns: 'login' })} + </p> </div> </div> </div> @@ -77,8 +88,12 @@ const NormalForm = () => { <RiContractLine className="size-5" /> <RiErrorWarningFill className="absolute -top-1 -right-1 size-4 text-text-warning-secondary" /> </div> - <p className="system-sm-medium text-text-primary">{t($ => $.licenseExpired, { ns: 'login' })}</p> - <p className="mt-1 system-xs-regular text-text-tertiary">{t($ => $.licenseExpiredTip, { ns: 'login' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $.licenseExpired, { ns: 'login' })} + </p> + <p className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $.licenseExpiredTip, { ns: 'login' })} + </p> </div> </div> </div> @@ -93,8 +108,12 @@ const NormalForm = () => { <RiContractLine className="size-5" /> <RiErrorWarningFill className="absolute -top-1 -right-1 size-4 text-text-warning-secondary" /> </div> - <p className="system-sm-medium text-text-primary">{t($ => $.licenseInactive, { ns: 'login' })}</p> - <p className="mt-1 system-xs-regular text-text-tertiary">{t($ => $.licenseInactiveTip, { ns: 'login' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $.licenseInactive, { ns: 'login' })} + </p> + <p className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $.licenseInactiveTip, { ns: 'login' })} + </p> </div> </div> </div> @@ -105,8 +124,14 @@ const NormalForm = () => { <> <div className="mx-auto mt-8 w-full"> <div className="mx-auto w-full"> - <h2 className="title-4xl-semi-bold text-text-primary">{systemFeatures.branding.enabled ? t($ => $.pageTitleForE, { ns: 'login' }) : t($ => $.pageTitle, { ns: 'login' })}</h2> - <p className="mt-2 body-md-regular text-text-tertiary">{t($ => $.welcome, { ns: 'login' })}</p> + <h2 className="title-4xl-semi-bold text-text-primary"> + {systemFeatures.branding.enabled + ? t(($) => $.pageTitleForE, { ns: 'login' }) + : t(($) => $.pageTitle, { ns: 'login' })} + </h2> + <p className="mt-2 body-md-regular text-text-tertiary"> + {t(($) => $.welcome, { ns: 'login' })} + </p> </div> <div className="relative"> <div className="mt-6 flex flex-col gap-3"> @@ -123,44 +148,63 @@ const NormalForm = () => { <div className="h-px w-full bg-linear-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div> </div> <div className="relative flex justify-center"> - <span className="px-2 system-xs-medium-uppercase text-text-tertiary">{t($ => $.or, { ns: 'login' })}</span> + <span className="px-2 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $.or, { ns: 'login' })} + </span> </div> </div> )} - { - (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && ( - <> - {systemFeatures.enable_email_code_login && authType === 'code' && ( - <> - <MailAndCodeAuth /> - {systemFeatures.enable_email_password_login && ( - <div className="cursor-pointer py-1 text-center" onClick={() => { updateAuthType('password') }}> - <span className="system-xs-medium text-components-button-secondary-accent-text">{t($ => $.usePassword, { ns: 'login' })}</span> - </div> - )} - </> - )} - {systemFeatures.enable_email_password_login && authType === 'password' && ( - <> - <MailAndPasswordAuth isEmailSetup={systemFeatures.is_email_setup} /> - {systemFeatures.enable_email_code_login && ( - <div className="cursor-pointer py-1 text-center" onClick={() => { updateAuthType('code') }}> - <span className="system-xs-medium text-components-button-secondary-accent-text">{t($ => $.useVerificationCode, { ns: 'login' })}</span> - </div> - )} - </> - )} - </> - ) - } + {(systemFeatures.enable_email_code_login || + systemFeatures.enable_email_password_login) && ( + <> + {systemFeatures.enable_email_code_login && authType === 'code' && ( + <> + <MailAndCodeAuth /> + {systemFeatures.enable_email_password_login && ( + <div + className="cursor-pointer py-1 text-center" + onClick={() => { + updateAuthType('password') + }} + > + <span className="system-xs-medium text-components-button-secondary-accent-text"> + {t(($) => $.usePassword, { ns: 'login' })} + </span> + </div> + )} + </> + )} + {systemFeatures.enable_email_password_login && authType === 'password' && ( + <> + <MailAndPasswordAuth isEmailSetup={systemFeatures.is_email_setup} /> + {systemFeatures.enable_email_code_login && ( + <div + className="cursor-pointer py-1 text-center" + onClick={() => { + updateAuthType('code') + }} + > + <span className="system-xs-medium text-components-button-secondary-accent-text"> + {t(($) => $.useVerificationCode, { ns: 'login' })} + </span> + </div> + )} + </> + )} + </> + )} {allMethodsAreDisabled && ( <> <div className="rounded-lg bg-linear-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4"> <div className="shadows-shadow-lg mb-2 flex size-10 items-center justify-center rounded-xl bg-components-card-bg shadow"> <RiDoorLockLine className="size-5" /> </div> - <p className="system-sm-medium text-text-primary">{t($ => $.noLoginMethod, { ns: 'login' })}</p> - <p className="mt-1 system-xs-regular text-text-tertiary">{t($ => $.noLoginMethodTip, { ns: 'login' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $.noLoginMethod, { ns: 'login' })} + </p> + <p className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $.noLoginMethodTip, { ns: 'login' })} + </p> </div> <div className="relative my-2 py-2"> <div className="absolute inset-0 flex items-center" aria-hidden="true"> @@ -172,41 +216,40 @@ const NormalForm = () => { {!systemFeatures.branding.enabled && ( <> <div className="mt-2 block w-full system-xs-regular text-text-tertiary"> - {t($ => $.tosDesc, { ns: 'login' })} -   + {t(($) => $.tosDesc, { ns: 'login' })} +   <Link className="system-xs-medium text-text-secondary hover:underline" target="_blank" rel="noopener noreferrer" href="https://dify.ai/terms" > - {t($ => $.tos, { ns: 'login' })} + {t(($) => $.tos, { ns: 'login' })} </Link> -  &  +  &  <Link className="system-xs-medium text-text-secondary hover:underline" target="_blank" rel="noopener noreferrer" href="https://dify.ai/privacy" > - {t($ => $.pp, { ns: 'login' })} + {t(($) => $.pp, { ns: 'login' })} </Link> </div> {IS_CE_EDITION && ( <div className="w-hull mt-2 block system-xs-regular text-text-tertiary"> - {t($ => $.goToInit, { ns: 'login' })} -   + {t(($) => $.goToInit, { ns: 'login' })} +   <Link className="system-xs-medium text-text-secondary hover:underline" href="/install" > - {t($ => $.setAdminAccount, { ns: 'login' })} + {t(($) => $.setAdminAccount, { ns: 'login' })} </Link> </div> )} </> )} - </div> </div> </> diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index 216da3743909be..5c5602c154ef1e 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -16,7 +16,7 @@ import NormalForm from './normalForm' const WebSSOForm: FC = () => { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) + const webAppAccessMode = useWebAppStore((s) => s.webAppAccessMode) const searchParams = useSearchParams() const router = useRouter() @@ -28,7 +28,7 @@ const WebSSOForm: FC = () => { return `/webapp-signin?${params.toString()}` }, [redirectUrl]) - const shareCode = useWebAppStore(s => s.shareCode) + const shareCode = useWebAppStore((s) => s.shareCode) const backToHome = useCallback(async () => { await webAppLogout(shareCode!) const url = getSigninUrl() @@ -38,7 +38,10 @@ const WebSSOForm: FC = () => { if (!redirectUrl) { return ( <div className="flex h-full items-center justify-center"> - <AppUnavailable code={t($ => $['common.appUnavailable'], { ns: 'share' })} unknownReason="redirect url is invalid." /> + <AppUnavailable + code={t(($) => $['common.appUnavailable'], { ns: 'share' })} + unknownReason="redirect url is invalid." + /> </div> ) } @@ -46,11 +49,17 @@ const WebSSOForm: FC = () => { if (!systemFeatures.webapp_auth.enabled) { return ( <div className="flex h-full items-center justify-center"> - <p className="system-xs-regular text-text-tertiary">{t($ => $['webapp.disabled'], { ns: 'login' })}</p> + <p className="system-xs-regular text-text-tertiary"> + {t(($) => $['webapp.disabled'], { ns: 'login' })} + </p> </div> ) } - if (webAppAccessMode && (webAppAccessMode === AccessMode.ORGANIZATION || webAppAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS)) { + if ( + webAppAccessMode && + (webAppAccessMode === AccessMode.ORGANIZATION || + webAppAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS) + ) { return ( <div className="w-full max-w-[400px]"> <NormalForm /> @@ -64,7 +73,9 @@ const WebSSOForm: FC = () => { return ( <div className="flex h-full flex-col items-center justify-center gap-y-4"> <AppUnavailable className="size-auto" isUnknownReason={true} /> - <span className="cursor-pointer system-sm-regular text-text-tertiary" onClick={backToHome}>{t($ => $['login.backToHome'], { ns: 'share' })}</span> + <span className="cursor-pointer system-sm-regular text-text-tertiary" onClick={backToHome}> + {t(($) => $['login.backToHome'], { ns: 'share' })} + </span> </div> ) } diff --git a/web/app/(shareLayout)/workflow/[token]/page.tsx b/web/app/(shareLayout)/workflow/[token]/page.tsx index b2828ee5dbdd57..5c879a1f066630 100644 --- a/web/app/(shareLayout)/workflow/[token]/page.tsx +++ b/web/app/(shareLayout)/workflow/[token]/page.tsx @@ -1,5 +1,4 @@ import * as React from 'react' - import Main from '@/app/components/share/text-generation' import AuthenticatedLayout from '../../components/authenticated-layout' diff --git a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx index df45c99bcd6f0b..0210e693c0c050 100644 --- a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx +++ b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx @@ -18,7 +18,9 @@ import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config' import { updateUserProfile } from '@/service/common' -type InputImageInfo = { file: File } | { tempUrl: string, croppedAreaPixels: Area, fileName: string } +type InputImageInfo = + | { file: File } + | { tempUrl: string; croppedAreaPixels: Area; fileName: string } type AvatarWithEditProps = AvatarProps & { onSave?: () => void } const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => { @@ -32,34 +34,47 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => { const [onAvatarError, setOnAvatarError] = useState(false) const canDeleteAvatar = !!props.avatar && !onAvatarError - const handleImageInput: OnImageInput = useCallback(async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => { - setInputImageInfo( - isCropped - ? { tempUrl: fileOrTempUrl as string, croppedAreaPixels: croppedAreaPixels!, fileName: fileName! } - : { file: fileOrTempUrl as File }, - ) - }, [setInputImageInfo]) + const handleImageInput: OnImageInput = useCallback( + async ( + isCropped: boolean, + fileOrTempUrl: string | File, + croppedAreaPixels?: Area, + fileName?: string, + ) => { + setInputImageInfo( + isCropped + ? { + tempUrl: fileOrTempUrl as string, + croppedAreaPixels: croppedAreaPixels!, + fileName: fileName!, + } + : { file: fileOrTempUrl as File }, + ) + }, + [setInputImageInfo], + ) - const handleSaveAvatar = useCallback(async (uploadedFileId: string) => { - try { - await updateUserProfile({ url: 'account/avatar', body: { avatar: uploadedFileId } }) - setIsShowAvatarPicker(false) - onSave?.() - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - } - catch (e) { - toast.error((e as Error).message) - } - }, [onSave, t]) + const handleSaveAvatar = useCallback( + async (uploadedFileId: string) => { + try { + await updateUserProfile({ url: 'account/avatar', body: { avatar: uploadedFileId } }) + setIsShowAvatarPicker(false) + onSave?.() + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + } catch (e) { + toast.error((e as Error).message) + } + }, + [onSave, t], + ) const handleDeleteAvatar = useCallback(async () => { try { await updateUserProfile({ url: 'account/avatar', body: { avatar: '' } }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) setIsShowDeleteConfirm(false) onSave?.() - } - catch (e) { + } catch (e) { toast.error((e as Error).message) } }, [onSave, t]) @@ -80,44 +95,51 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => { } // Error - if (imageFile.progress === -1) - setUploading(false) + if (imageFile.progress === -1) setUploading(false) }, }) const handleSelect = useCallback(async () => { - if (!inputImageInfo) - return + if (!inputImageInfo) return setUploading(true) if ('file' in inputImageInfo) { handleLocalFileUpload(inputImageInfo.file) return } - const blob = await getCroppedImg(inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName) + const blob = await getCroppedImg( + inputImageInfo.tempUrl, + inputImageInfo.croppedAreaPixels, + inputImageInfo.fileName, + ) const file = new File([blob], inputImageInfo.fileName, { type: blob.type }) handleLocalFileUpload(file) }, [handleLocalFileUpload, inputImageInfo]) - if (DISABLE_UPLOAD_IMAGE_AS_ICON) - return <Avatar {...props} /> + if (DISABLE_UPLOAD_IMAGE_AS_ICON) return <Avatar {...props} /> return ( <> <div> <button type="button" - aria-label={t($ => $['avatar.editAction'], { ns: 'common' })} + aria-label={t(($) => $['avatar.editAction'], { ns: 'common' })} className="group relative inline-flex overflow-hidden rounded-full border-none bg-transparent p-0 outline-hidden hover:opacity-90 focus-visible:ring-2 focus-visible:ring-components-input-border-hover active:opacity-80" onClick={() => setIsShowAvatarPicker(true)} > - <Avatar {...props} onLoadingStatusChange={status => setOnAvatarError(status === 'error')} /> + <Avatar + {...props} + onLoadingStatusChange={(status) => setOnAvatarError(status === 'error')} + /> <span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 motion-safe:transition-opacity"> <span aria-hidden="true" className="i-ri-pencil-line size-5" /> </span> </button> </div> - <Dialog open={isShowAvatarPicker} onOpenChange={open => !open && setIsShowAvatarPicker(false)}> + <Dialog + open={isShowAvatarPicker} + onOpenChange={(open) => !open && setIsShowAvatarPicker(false)} + > <DialogContent className="w-[362px]! p-0!"> <ImageInput onImageInput={handleImageInput} cropShape="round" /> <Divider className="m-0" /> @@ -125,32 +147,50 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => { <div className="flex w-full items-center justify-center gap-2 p-3"> {canDeleteAvatar && ( <Button tone="destructive" className="shrink-0" onClick={handleDeleteAvatarClick}> - {t($ => $['operation.delete'], { ns: 'common' })} + {t(($) => $['operation.delete'], { ns: 'common' })} </Button> )} <Button className="min-w-0 flex-1" onClick={() => setIsShowAvatarPicker(false)}> - {t($ => $['iconPicker.cancel'], { ns: 'app' })} + {t(($) => $['iconPicker.cancel'], { ns: 'app' })} </Button> - <Button variant="primary" className="min-w-0 flex-1" disabled={uploading || !inputImageInfo} loading={uploading} onClick={handleSelect}> - {t($ => $['iconPicker.ok'], { ns: 'app' })} + <Button + variant="primary" + className="min-w-0 flex-1" + disabled={uploading || !inputImageInfo} + loading={uploading} + onClick={handleSelect} + > + {t(($) => $['iconPicker.ok'], { ns: 'app' })} </Button> </div> </DialogContent> </Dialog> - <Dialog open={isShowDeleteConfirm} onOpenChange={open => !open && setIsShowDeleteConfirm(false)}> + <Dialog + open={isShowDeleteConfirm} + onOpenChange={(open) => !open && setIsShowDeleteConfirm(false)} + > <DialogContent className="w-[362px]! p-6!"> - <div className="mb-3 title-2xl-semi-bold text-text-primary">{t($ => $['avatar.deleteTitle'], { ns: 'common' })}</div> - <p className="mb-8 text-text-secondary">{t($ => $['avatar.deleteDescription'], { ns: 'common' })}</p> + <div className="mb-3 title-2xl-semi-bold text-text-primary"> + {t(($) => $['avatar.deleteTitle'], { ns: 'common' })} + </div> + <p className="mb-8 text-text-secondary"> + {t(($) => $['avatar.deleteDescription'], { ns: 'common' })} + </p> <div className="flex w-full items-center justify-center gap-2"> <Button className="w-full" onClick={() => setIsShowDeleteConfirm(false)}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button variant="primary" tone="destructive" className="w-full" onClick={handleDeleteAvatar}> - {t($ => $['operation.delete'], { ns: 'common' })} + <Button + variant="primary" + tone="destructive" + className="w-full" + onClick={handleDeleteAvatar} + > + {t(($) => $['operation.delete'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx index 2ae04a328ded31..b686c0ded7afc3 100644 --- a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx +++ b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx @@ -8,12 +8,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import { useRouter } from '@/next/navigation' -import { - checkEmailExisted, - resetEmail, - sendVerifyCode, - verifyEmail, -} from '@/service/common' +import { checkEmailExisted, resetEmail, sendVerifyCode, verifyEmail } from '@/service/common' import { useLogout } from '@/service/use-common' import { asyncRunSafe } from '@/utils' @@ -29,7 +24,7 @@ const STEP = { verifyNew: 'verifyNew', } as const -type Step = typeof STEP[keyof typeof STEP] +type Step = (typeof STEP)[keyof typeof STEP] const emailPattern = /^[\w.!#$%&'*+\-/=?^`{|}~]+@(?:[\w-]+\.)+[\w-]{2,}$/ @@ -39,8 +34,7 @@ type FetchResponseError = { } function getErrorMessage(error: unknown) { - if (error instanceof Error) - return error.message + if (error instanceof Error) return error.message if (typeof error === 'object' && error !== null && 'message' in error) { const message = (error as { message?: unknown }).message return typeof message === 'string' ? message : '' @@ -49,10 +43,9 @@ function getErrorMessage(error: unknown) { } function isFetchResponseError(error: unknown): error is FetchResponseError { - if (typeof error !== 'object' || error === null) - return false + if (typeof error !== 'object' || error === null) return false - const maybeError = error as { status?: unknown, json?: unknown } + const maybeError = error as { status?: unknown; json?: unknown } return typeof maybeError.status === 'number' && typeof maybeError.json === 'function' } @@ -71,8 +64,7 @@ const EmailChangeModal = ({ onClose, email }: Props) => { const latestEmailRef = useRef<string>('') const clearCountdown = useCallback(() => { - if (!timerRef.current) - return + if (!timerRef.current) return clearInterval(timerRef.current) timerRef.current = null @@ -102,15 +94,18 @@ const EmailChangeModal = ({ onClose, email }: Props) => { token, }) startCount() - if (res.data) - setStepToken(res.data) - } - catch (error) { + if (res.data) setStepToken(res.data) + } catch (error) { toast.error(`Error sending verification code: ${getErrorMessage(error)}`) } } - const verifyEmailAddress = async (email: string, code: string, token: string, callback?: (token: string) => void) => { + const verifyEmailAddress = async ( + email: string, + code: string, + token: string, + callback?: (token: string) => void, + ) => { try { const res = await verifyEmail({ email, @@ -120,21 +115,16 @@ const EmailChangeModal = ({ onClose, email }: Props) => { if (res.is_valid) { setStepToken(res.token) callback?.(res.token) - } - else { + } else { toast.error('Verifying email failed') } - } - catch (error) { + } catch (error) { toast.error(`Error verifying email: ${getErrorMessage(error)}`) } } const sendCodeToOriginEmail = async () => { - await sendEmail( - email, - true, - ) + await sendEmail(email, true) setStep(STEP.verifyOrigin) } @@ -153,33 +143,26 @@ const EmailChangeModal = ({ onClose, email }: Props) => { await checkEmailExisted({ email, }) - if (latestEmailRef.current !== email) - return + if (latestEmailRef.current !== email) return setNewEmailExited(false) setUnAvailableEmail(false) - } - catch (e: unknown) { - if (latestEmailRef.current !== email) - return + } catch (e: unknown) { + if (latestEmailRef.current !== email) return if (isFetchResponseError(e) && e.status === 400) { const [, errRespData] = await asyncRunSafe<ResponseError>(e.json()) const { code } = errRespData || {} - if (code === 'email_already_in_use') - setNewEmailExited(true) - if (code === 'account_in_freeze') - setUnAvailableEmail(true) + if (code === 'email_already_in_use') setNewEmailExited(true) + if (code === 'account_in_freeze') setUnAvailableEmail(true) } - } - finally { - if (latestEmailRef.current === email) - setIsCheckingEmail(false) + } finally { + if (latestEmailRef.current === email) setIsCheckingEmail(false) } } - const { - run: checkNewEmailExistedDebounced, - cancel: cancelCheckNewEmailExisted, - } = useDebounceFn(checkNewEmailExisted, { wait: 500 }) + const { run: checkNewEmailExistedDebounced, cancel: cancelCheckNewEmailExisted } = useDebounceFn( + checkNewEmailExisted, + { wait: 500 }, + ) useEffect(() => cancelCheckNewEmailExisted, [cancelCheckNewEmailExisted]) @@ -204,11 +187,7 @@ const EmailChangeModal = ({ onClose, email }: Props) => { toast.error('Invalid email format') return } - await sendEmail( - normalizedMail, - false, - stepToken, - ) + await sendEmail(normalizedMail, false, stepToken) setStep(STEP.verifyNew) } @@ -228,8 +207,7 @@ const EmailChangeModal = ({ onClose, email }: Props) => { token: lastToken, }) handleLogout() - } - catch (error) { + } catch (error) { toast.error(`Error changing email: ${getErrorMessage(error)}`) } } @@ -240,22 +218,27 @@ const EmailChangeModal = ({ onClose, email }: Props) => { const normalizedMail = mail.trim() const isMailValid = isValidEmail(normalizedMail) - const isSendCodeDisabled = !normalizedMail || newEmailExited || unAvailableEmail || isCheckingEmail || !isMailValid + const isSendCodeDisabled = + !normalizedMail || newEmailExited || unAvailableEmail || isCheckingEmail || !isMailValid return ( - <Dialog open onOpenChange={open => !open && onClose()}> + <Dialog open onOpenChange={(open) => !open && onClose()}> <DialogContent className="w-105! p-6!"> <div className="absolute top-5 right-5 cursor-pointer p-1.5" onClick={onClose}> <RiCloseLine className="size-5 text-text-tertiary" /> </div> {step === STEP.start && ( <> - <div className="pb-3 title-2xl-semi-bold text-text-primary">{t($ => $['account.changeEmail.title'], { ns: 'common' })}</div> + <div className="pb-3 title-2xl-semi-bold text-text-primary"> + {t(($) => $['account.changeEmail.title'], { ns: 'common' })} + </div> <div className="space-y-0.5 pt-1 pb-2"> - <div className="body-md-medium text-text-warning">{t($ => $['account.changeEmail.authTip'], { ns: 'common' })}</div> + <div className="body-md-medium text-text-warning"> + {t(($) => $['account.changeEmail.authTip'], { ns: 'common' })} + </div> <div className="body-md-regular text-text-secondary"> <Trans - i18nKey={$ => $['account.changeEmail.content1']} + i18nKey={($) => $['account.changeEmail.content1']} ns="common" components={{ email: <span className="body-md-medium text-text-primary"></span> }} values={{ email }} @@ -264,29 +247,24 @@ const EmailChangeModal = ({ onClose, email }: Props) => { </div> <div className="pt-3"></div> <div className="space-y-2"> - <Button - className="w-full!" - variant="primary" - onClick={sendCodeToOriginEmail} - > - {t($ => $['account.changeEmail.sendVerifyCode'], { ns: 'common' })} + <Button className="w-full!" variant="primary" onClick={sendCodeToOriginEmail}> + {t(($) => $['account.changeEmail.sendVerifyCode'], { ns: 'common' })} </Button> - <Button - className="w-full!" - onClick={onClose} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button className="w-full!" onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> </div> </> )} {step === STEP.verifyOrigin && ( <> - <div className="pb-3 title-2xl-semi-bold text-text-primary">{t($ => $['account.changeEmail.verifyEmail'], { ns: 'common' })}</div> + <div className="pb-3 title-2xl-semi-bold text-text-primary"> + {t(($) => $['account.changeEmail.verifyEmail'], { ns: 'common' })} + </div> <div className="space-y-0.5 pt-1 pb-2"> <div className="body-md-regular text-text-secondary"> <Trans - i18nKey={$ => $['account.changeEmail.content2']} + i18nKey={($) => $['account.changeEmail.content2']} ns="common" components={{ email: <span className="body-md-medium text-text-primary"></span> }} values={{ email }} @@ -294,12 +272,14 @@ const EmailChangeModal = ({ onClose, email }: Props) => { </div> </div> <div className="pt-3"> - <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">{t($ => $['account.changeEmail.codeLabel'], { ns: 'common' })}</div> + <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> + {t(($) => $['account.changeEmail.codeLabel'], { ns: 'common' })} + </div> <Input className="w-full!" - placeholder={t($ => $['account.changeEmail.codePlaceholder'], { ns: 'common' })} + placeholder={t(($) => $['account.changeEmail.codePlaceholder'], { ns: 'common' })} value={code} - onChange={e => setCode(e.target.value)} + onChange={(e) => setCode(e.target.value)} maxLength={6} /> </div> @@ -310,46 +290,60 @@ const EmailChangeModal = ({ onClose, email }: Props) => { variant="primary" onClick={handleVerifyOriginEmail} > - {t($ => $['account.changeEmail.continue'], { ns: 'common' })} + {t(($) => $['account.changeEmail.continue'], { ns: 'common' })} </Button> - <Button - className="w-full!" - onClick={onClose} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button className="w-full!" onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> </div> <div className="mt-3 flex items-center gap-1 system-xs-regular text-text-tertiary"> - <span>{t($ => $['account.changeEmail.resendTip'], { ns: 'common' })}</span> + <span>{t(($) => $['account.changeEmail.resendTip'], { ns: 'common' })}</span> {time > 0 && ( - <span>{t($ => $['account.changeEmail.resendCount'], { ns: 'common', count: time })}</span> + <span> + {t(($) => $['account.changeEmail.resendCount'], { ns: 'common', count: time })} + </span> )} {!time && ( - <span onClick={sendCodeToOriginEmail} className="cursor-pointer system-xs-medium text-text-accent-secondary">{t($ => $['account.changeEmail.resend'], { ns: 'common' })}</span> + <span + onClick={sendCodeToOriginEmail} + className="cursor-pointer system-xs-medium text-text-accent-secondary" + > + {t(($) => $['account.changeEmail.resend'], { ns: 'common' })} + </span> )} </div> </> )} {step === STEP.newEmail && ( <> - <div className="pb-3 title-2xl-semi-bold text-text-primary">{t($ => $['account.changeEmail.newEmail'], { ns: 'common' })}</div> + <div className="pb-3 title-2xl-semi-bold text-text-primary"> + {t(($) => $['account.changeEmail.newEmail'], { ns: 'common' })} + </div> <div className="space-y-0.5 pt-1 pb-2"> - <div className="body-md-regular text-text-secondary">{t($ => $['account.changeEmail.content3'], { ns: 'common' })}</div> + <div className="body-md-regular text-text-secondary"> + {t(($) => $['account.changeEmail.content3'], { ns: 'common' })} + </div> </div> <div className="pt-3"> - <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">{t($ => $['account.changeEmail.emailLabel'], { ns: 'common' })}</div> + <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> + {t(($) => $['account.changeEmail.emailLabel'], { ns: 'common' })} + </div> <Input className="w-full!" - placeholder={t($ => $['account.changeEmail.emailPlaceholder'], { ns: 'common' })} + placeholder={t(($) => $['account.changeEmail.emailPlaceholder'], { ns: 'common' })} value={mail} - onChange={e => handleNewEmailValueChange(e.target.value)} + onChange={(e) => handleNewEmailValueChange(e.target.value)} destructive={newEmailExited || unAvailableEmail} /> {newEmailExited && ( - <div className="mt-1 py-0.5 body-xs-regular text-text-destructive">{t($ => $['account.changeEmail.existingEmail'], { ns: 'common' })}</div> + <div className="mt-1 py-0.5 body-xs-regular text-text-destructive"> + {t(($) => $['account.changeEmail.existingEmail'], { ns: 'common' })} + </div> )} {unAvailableEmail && ( - <div className="mt-1 py-0.5 body-xs-regular text-text-destructive">{t($ => $['account.changeEmail.unAvailableEmail'], { ns: 'common' })}</div> + <div className="mt-1 py-0.5 body-xs-regular text-text-destructive"> + {t(($) => $['account.changeEmail.unAvailableEmail'], { ns: 'common' })} + </div> )} </div> <div className="mt-3 space-y-2"> @@ -359,24 +353,23 @@ const EmailChangeModal = ({ onClose, email }: Props) => { variant="primary" onClick={sendCodeToNewEmail} > - {t($ => $['account.changeEmail.sendVerifyCode'], { ns: 'common' })} + {t(($) => $['account.changeEmail.sendVerifyCode'], { ns: 'common' })} </Button> - <Button - className="w-full!" - onClick={onClose} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button className="w-full!" onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> </div> </> )} {step === STEP.verifyNew && ( <> - <div className="pb-3 title-2xl-semi-bold text-text-primary">{t($ => $['account.changeEmail.verifyNew'], { ns: 'common' })}</div> + <div className="pb-3 title-2xl-semi-bold text-text-primary"> + {t(($) => $['account.changeEmail.verifyNew'], { ns: 'common' })} + </div> <div className="space-y-0.5 pt-1 pb-2"> <div className="body-md-regular text-text-secondary"> <Trans - i18nKey={$ => $['account.changeEmail.content4']} + i18nKey={($) => $['account.changeEmail.content4']} ns="common" components={{ email: <span className="body-md-medium text-text-primary"></span> }} values={{ email: mail }} @@ -384,12 +377,14 @@ const EmailChangeModal = ({ onClose, email }: Props) => { </div> </div> <div className="pt-3"> - <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">{t($ => $['account.changeEmail.codeLabel'], { ns: 'common' })}</div> + <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> + {t(($) => $['account.changeEmail.codeLabel'], { ns: 'common' })} + </div> <Input className="w-full!" - placeholder={t($ => $['account.changeEmail.codePlaceholder'], { ns: 'common' })} + placeholder={t(($) => $['account.changeEmail.codePlaceholder'], { ns: 'common' })} value={code} - onChange={e => setCode(e.target.value)} + onChange={(e) => setCode(e.target.value)} maxLength={6} /> </div> @@ -400,22 +395,26 @@ const EmailChangeModal = ({ onClose, email }: Props) => { variant="primary" onClick={submitNewEmail} > - {t($ => $['account.changeEmail.changeTo'], { ns: 'common', email: mail })} + {t(($) => $['account.changeEmail.changeTo'], { ns: 'common', email: mail })} </Button> - <Button - className="w-full!" - onClick={onClose} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button className="w-full!" onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> </div> <div className="mt-3 flex items-center gap-1 system-xs-regular text-text-tertiary"> - <span>{t($ => $['account.changeEmail.resendTip'], { ns: 'common' })}</span> + <span>{t(($) => $['account.changeEmail.resendTip'], { ns: 'common' })}</span> {time > 0 && ( - <span>{t($ => $['account.changeEmail.resendCount'], { ns: 'common', count: time })}</span> + <span> + {t(($) => $['account.changeEmail.resendCount'], { ns: 'common', count: time })} + </span> )} {!time && ( - <span onClick={sendCodeToNewEmail} className="cursor-pointer system-xs-medium text-text-accent-secondary">{t($ => $['account.changeEmail.resend'], { ns: 'common' })}</span> + <span + onClick={sendCodeToNewEmail} + className="cursor-pointer system-xs-medium text-text-accent-secondary" + > + {t(($) => $['account.changeEmail.resend'], { ns: 'common' })} + </span> )} </div> </> diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index c15ed338a8fb1a..5415c56e5b7427 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -4,9 +4,7 @@ import type { App } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' -import { - RiGraduationCapFill, -} from '@remixicon/react' +import { RiGraduationCapFill } from '@remixicon/react' import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -22,7 +20,6 @@ import { consoleQuery } from '@/service/client' import { updateUserProfile } from '@/service/common' import { normalizeAppPagination } from '@/service/use-apps' import DeleteAccount from '../delete-account' - import AvatarWithEdit from './AvatarWithEdit' import EmailChangeModal from './email-change-modal' @@ -36,22 +33,25 @@ const descriptionClassName = ` export default function AccountPage() { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { data: appList } = useQuery(consoleQuery.apps.get.queryOptions({ - input: { - query: { - page: 1, - limit: 100, - name: '', + const { data: appList } = useQuery( + consoleQuery.apps.get.queryOptions({ + input: { + query: { + page: 1, + limit: 100, + name: '', + }, }, - }, - select: normalizeAppPagination, - })) + select: normalizeAppPagination, + }), + ) const apps = appList?.data || [] const queryClient = useQueryClient() // Cache is hydrated by CommonLayoutHydrationBoundary; this hits cache synchronously. const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions()) const userProfile = userProfileResp.profile - const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey }) + const mutateUserProfile = () => + queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey }) const { isEducationAccount } = useProviderContext() const [editNameModalVisible, setEditNameModalVisible] = useState(false) const [editName, setEditName] = useState('') @@ -66,8 +66,7 @@ export default function AccountPage() { const [showConfirmPassword, setShowConfirmPassword] = useState(false) const [showUpdateEmail, setShowUpdateEmail] = useState(false) - if (!userProfile) - return null + if (!userProfile) return null const handleEditName = () => { setEditNameModalVisible(true) @@ -77,12 +76,11 @@ export default function AccountPage() { try { setEditing(true) await updateUserProfile({ url: 'account/name', body: { name: editName } }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) mutateUserProfile() setEditNameModalVisible(false) setEditing(false) - } - catch (e) { + } catch (e) { toast.error((e as Error).message) setEditing(false) } @@ -93,15 +91,15 @@ export default function AccountPage() { } const valid = () => { if (!password.trim()) { - showErrorMessage(t($ => $['error.passwordEmpty'], { ns: 'login' })) + showErrorMessage(t(($) => $['error.passwordEmpty'], { ns: 'login' })) return false } if (!validPassword.test(password)) { - showErrorMessage(t($ => $['error.passwordInvalid'], { ns: 'login' })) + showErrorMessage(t(($) => $['error.passwordInvalid'], { ns: 'login' })) return false } if (password !== confirmPassword) { - showErrorMessage(t($ => $['account.notEqual'], { ns: 'common' })) + showErrorMessage(t(($) => $['account.notEqual'], { ns: 'common' })) return false } @@ -113,8 +111,7 @@ export default function AccountPage() { setConfirmPassword('') } const handleSavePassword = async () => { - if (!valid()) - return + if (!valid()) return try { setEditing(true) await updateUserProfile({ @@ -125,13 +122,12 @@ export default function AccountPage() { repeat_new_password: confirmPassword, }, }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) mutateUserProfile() setEditPasswordModalVisible(false) resetPasswordForm() setEditing(false) - } - catch (e) { + } catch (e) { toast.error((e as Error).message) setEditPasswordModalVisible(false) setEditing(false) @@ -139,7 +135,8 @@ export default function AccountPage() { } const renderAppItem = (item: IItem) => { - const { icon, icon_background, icon_type, icon_url } = item as IItem & Pick<App, 'icon' | 'icon_background' | 'icon_type' | 'icon_url'> + const { icon, icon_background, icon_type, icon_url } = item as IItem & + Pick<App, 'icon' | 'icon_background' | 'icon_type' | 'icon_url'> return ( <div className="flex px-3 py-1"> <div className="mr-3"> @@ -159,10 +156,17 @@ export default function AccountPage() { return ( <> <div className="pt-2 pb-3"> - <h4 className="title-2xl-semi-bold text-text-primary">{t($ => $['account.myAccount'], { ns: 'common' })}</h4> + <h4 className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['account.myAccount'], { ns: 'common' })} + </h4> </div> <div className="mb-8 flex items-center rounded-xl bg-linear-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6"> - <AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={mutateUserProfile} size="3xl" /> + <AvatarWithEdit + avatar={userProfile.avatar_url} + name={userProfile.name} + onSave={mutateUserProfile} + size="3xl" + /> <div className="ml-4"> <p className="system-xl-semibold text-text-primary"> {userProfile.name} @@ -177,86 +181,117 @@ export default function AccountPage() { </div> </div> <div className="mb-8"> - <div className={titleClassName}>{t($ => $['account.name'], { ns: 'common' })}</div> + <div className={titleClassName}>{t(($) => $['account.name'], { ns: 'common' })}</div> <div className="mt-2 flex w-full items-center justify-between gap-2"> <div className="flex-1 rounded-lg bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled"> <span className="pl-1">{userProfile.name}</span> </div> - <div className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 system-sm-medium text-components-button-tertiary-text" onClick={handleEditName}> - {t($ => $['operation.edit'], { ns: 'common' })} + <div + className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 system-sm-medium text-components-button-tertiary-text" + onClick={handleEditName} + > + {t(($) => $['operation.edit'], { ns: 'common' })} </div> </div> </div> <div className="mb-8"> - <div className={titleClassName}>{t($ => $['account.email'], { ns: 'common' })}</div> + <div className={titleClassName}>{t(($) => $['account.email'], { ns: 'common' })}</div> <div className="mt-2 flex w-full items-center justify-between gap-2"> <div className="flex-1 rounded-lg bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled"> <span className="pl-1">{userProfile.email}</span> </div> {systemFeatures.enable_change_email && ( - <div className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 system-sm-medium text-components-button-tertiary-text" onClick={() => setShowUpdateEmail(true)}> - {t($ => $['operation.change'], { ns: 'common' })} + <div + className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 system-sm-medium text-components-button-tertiary-text" + onClick={() => setShowUpdateEmail(true)} + > + {t(($) => $['operation.change'], { ns: 'common' })} </div> )} </div> </div> - { - systemFeatures.enable_email_password_login && ( - <div className="mb-8 flex justify-between gap-2"> - <div> - <div className="mb-1 system-sm-semibold text-text-secondary">{t($ => $['account.password'], { ns: 'common' })}</div> - <div className="mb-2 body-xs-regular text-text-tertiary">{t($ => $['account.passwordTip'], { ns: 'common' })}</div> + {systemFeatures.enable_email_password_login && ( + <div className="mb-8 flex justify-between gap-2"> + <div> + <div className="mb-1 system-sm-semibold text-text-secondary"> + {t(($) => $['account.password'], { ns: 'common' })} + </div> + <div className="mb-2 body-xs-regular text-text-tertiary"> + {t(($) => $['account.passwordTip'], { ns: 'common' })} </div> - <Button onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t($ => $['account.resetPassword'], { ns: 'common' }) : t($ => $['account.setPassword'], { ns: 'common' })}</Button> </div> - ) - } + <Button onClick={() => setEditPasswordModalVisible(true)}> + {userProfile.is_password_set + ? t(($) => $['account.resetPassword'], { ns: 'common' }) + : t(($) => $['account.setPassword'], { ns: 'common' })} + </Button> + </div> + )} <div className="mb-6 border border-divider-subtle" /> <div className="mb-8"> - <div className={titleClassName}>{t($ => $['account.langGeniusAccount'], { ns: 'common' })}</div> - <div className={descriptionClassName}>{t($ => $['account.langGeniusAccountTip'], { ns: 'common' })}</div> + <div className={titleClassName}> + {t(($) => $['account.langGeniusAccount'], { ns: 'common' })} + </div> + <div className={descriptionClassName}> + {t(($) => $['account.langGeniusAccountTip'], { ns: 'common' })} + </div> {!!apps.length && ( <Collapse - title={`${t($ => $['account.showAppLength'], { ns: 'common', length: apps.length })}`} + title={`${t(($) => $['account.showAppLength'], { ns: 'common', length: apps.length })}`} items={apps.map((app: App) => ({ ...app, key: app.id, name: app.name }))} renderItem={renderAppItem} wrapperClassName="mt-2" /> )} - {!IS_CE_EDITION && <Button className="mt-2 text-components-button-destructive-secondary-text" onClick={() => setShowDeleteAccountModal(true)}>{t($ => $['account.delete'], { ns: 'common' })}</Button>} + {!IS_CE_EDITION && ( + <Button + className="mt-2 text-components-button-destructive-secondary-text" + onClick={() => setShowDeleteAccountModal(true)} + > + {t(($) => $['account.delete'], { ns: 'common' })} + </Button> + )} </div> - <Dialog open={editNameModalVisible} onOpenChange={open => !open && setEditNameModalVisible(false)}> + <Dialog + open={editNameModalVisible} + onOpenChange={(open) => !open && setEditNameModalVisible(false)} + > <DialogContent className="w-105 p-6"> - <div className="mb-6 title-2xl-semi-bold text-text-primary">{t($ => $['account.editName'], { ns: 'common' })}</div> - <div className={titleClassName}>{t($ => $['account.name'], { ns: 'common' })}</div> - <Input - className="mt-2" - value={editName} - onChange={e => setEditName(e.target.value)} - /> + <div className="mb-6 title-2xl-semi-bold text-text-primary"> + {t(($) => $['account.editName'], { ns: 'common' })} + </div> + <div className={titleClassName}>{t(($) => $['account.name'], { ns: 'common' })}</div> + <Input className="mt-2" value={editName} onChange={(e) => setEditName(e.target.value)} /> <div className="mt-10 flex justify-end"> - <Button className="mr-2" onClick={() => setEditNameModalVisible(false)}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button - disabled={editing || !editName} - variant="primary" - onClick={handleSaveName} - > - {t($ => $['operation.save'], { ns: 'common' })} + <Button className="mr-2" onClick={() => setEditNameModalVisible(false)}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button disabled={editing || !editName} variant="primary" onClick={handleSaveName}> + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </DialogContent> </Dialog> - <Dialog open={editPasswordModalVisible} onOpenChange={open => !open && (setEditPasswordModalVisible(false), resetPasswordForm())}> + <Dialog + open={editPasswordModalVisible} + onOpenChange={(open) => !open && (setEditPasswordModalVisible(false), resetPasswordForm())} + > <DialogContent className="w-[420px]! p-6!"> - <div className="mb-6 title-2xl-semi-bold text-text-primary">{userProfile.is_password_set ? t($ => $['account.resetPassword'], { ns: 'common' }) : t($ => $['account.setPassword'], { ns: 'common' })}</div> + <div className="mb-6 title-2xl-semi-bold text-text-primary"> + {userProfile.is_password_set + ? t(($) => $['account.resetPassword'], { ns: 'common' }) + : t(($) => $['account.setPassword'], { ns: 'common' })} + </div> {userProfile.is_password_set && ( <> - <div className={titleClassName}>{t($ => $['account.currentPassword'], { ns: 'common' })}</div> + <div className={titleClassName}> + {t(($) => $['account.currentPassword'], { ns: 'common' })} + </div> <div className="relative mt-2"> <Input type={showCurrentPassword ? 'text' : 'password'} value={currentPassword} - onChange={e => setCurrentPassword(e.target.value)} + onChange={(e) => setCurrentPassword(e.target.value)} /> <div className="absolute inset-y-0 right-0 flex items-center"> <Button @@ -271,30 +306,30 @@ export default function AccountPage() { </> )} <div className="mt-8 system-sm-semibold text-text-secondary"> - {userProfile.is_password_set ? t($ => $['account.newPassword'], { ns: 'common' }) : t($ => $['account.password'], { ns: 'common' })} + {userProfile.is_password_set + ? t(($) => $['account.newPassword'], { ns: 'common' }) + : t(($) => $['account.password'], { ns: 'common' })} </div> <div className="relative mt-2"> <Input type={showPassword ? 'text' : 'password'} value={password} - onChange={e => setPassword(e.target.value)} + onChange={(e) => setPassword(e.target.value)} /> <div className="absolute inset-y-0 right-0 flex items-center"> - <Button - type="button" - variant="ghost" - onClick={() => setShowPassword(!showPassword)} - > + <Button type="button" variant="ghost" onClick={() => setShowPassword(!showPassword)}> {showPassword ? '👀' : '😝'} </Button> </div> </div> - <div className="mt-8 system-sm-semibold text-text-secondary">{t($ => $['account.confirmPassword'], { ns: 'common' })}</div> + <div className="mt-8 system-sm-semibold text-text-secondary"> + {t(($) => $['account.confirmPassword'], { ns: 'common' })} + </div> <div className="relative mt-2"> <Input type={showConfirmPassword ? 'text' : 'password'} value={confirmPassword} - onChange={e => setConfirmPassword(e.target.value)} + onChange={(e) => setConfirmPassword(e.target.value)} /> <div className="absolute inset-y-0 right-0 flex items-center"> <Button @@ -314,35 +349,26 @@ export default function AccountPage() { resetPasswordForm() }} > - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button - disabled={editing} - variant="primary" - onClick={handleSavePassword} - > - {userProfile.is_password_set ? t($ => $['operation.reset'], { ns: 'common' }) : t($ => $['operation.save'], { ns: 'common' })} + <Button disabled={editing} variant="primary" onClick={handleSavePassword}> + {userProfile.is_password_set + ? t(($) => $['operation.reset'], { ns: 'common' }) + : t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </DialogContent> </Dialog> - { - showDeleteAccountModal && ( - <DeleteAccount - onCancel={() => setShowDeleteAccountModal(false)} - onConfirm={() => setShowDeleteAccountModal(false)} - /> - ) - } + {showDeleteAccountModal && ( + <DeleteAccount + onCancel={() => setShowDeleteAccountModal(false)} + onConfirm={() => setShowDeleteAccountModal(false)} + /> + )} {/* Use conditional JSX instead of a mounted controlled Dialog so closing destroys the email-change form session. */} - {showUpdateEmail - ? ( - <EmailChangeModal - onClose={() => setShowUpdateEmail(false)} - email={userProfile.email} - /> - ) - : null} + {showUpdateEmail ? ( + <EmailChangeModal onClose={() => setShowUpdateEmail(false)} email={userProfile.email} /> + ) : null} </> ) } diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx index af6d602370211f..c4d71b51a573b5 100644 --- a/web/app/account/(commonLayout)/avatar.tsx +++ b/web/app/account/(commonLayout)/avatar.tsx @@ -26,8 +26,7 @@ export default function AppSelector() { const { mutateAsync: logout } = useLogout() - if (!userProfile) - return null + if (!userProfile) return null const handleLogout = async () => { await logout() @@ -66,18 +65,22 @@ export default function AppSelector() { </PremiumBadge> )} </div> - <div className="system-xs-regular break-all text-text-tertiary">{userProfile.email}</div> + <div className="system-xs-regular break-all text-text-tertiary"> + {userProfile.email} + </div> </div> <Avatar avatar={userProfile.avatar_url} name={userProfile.name} /> </div> </div> <div className="p-1"> - <DropdownMenuItem - className="h-9 justify-start px-3" - onClick={handleLogout} - > - <span aria-hidden="true" className="mr-1 i-custom-vender-line-general-log-out-01 flex size-4 text-text-tertiary" /> - <span className="text-[14px] font-normal text-text-secondary">{t($ => $['userProfile.logout'], { ns: 'common' })}</span> + <DropdownMenuItem className="h-9 justify-start px-3" onClick={handleLogout}> + <span + aria-hidden="true" + className="mr-1 i-custom-vender-line-general-log-out-01 flex size-4 text-text-tertiary" + /> + <span className="text-[14px] font-normal text-text-secondary"> + {t(($) => $['userProfile.logout'], { ns: 'common' })} + </span> </DropdownMenuItem> </div> </DropdownMenuContent> 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 481037a415186b..8dbd69da8af372 100644 --- a/web/app/account/(commonLayout)/delete-account/components/check-email.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/check-email.tsx @@ -18,36 +18,51 @@ export default function CheckEmail(props: DeleteAccountProps) { const userProfileEmail = useAtomValue(userProfileEmailAtom) const [userInputEmail, setUserInputEmail] = useState('') - const { isPending: isSendingEmail, mutateAsync: getDeleteEmailVerifyCode } = useSendDeleteAccountEmail() + const { isPending: isSendingEmail, mutateAsync: getDeleteEmailVerifyCode } = + useSendDeleteAccountEmail() const handleConfirm = useCallback(async () => { try { const ret = await getDeleteEmailVerifyCode() - if (ret.result === 'success') - props.onConfirm() + if (ret.result === 'success') props.onConfirm() + } catch (error) { + console.error(error) } - catch (error) { console.error(error) } }, [getDeleteEmailVerifyCode, props]) return ( <> <div className="py-1 body-md-medium text-text-destructive"> - {t($ => $['account.deleteTip'], { ns: 'common' })} + {t(($) => $['account.deleteTip'], { ns: 'common' })} </div> <div className="pt-1 pb-2 body-md-regular text-text-secondary"> - {t($ => $['account.deletePrivacyLinkTip'], { ns: 'common' })} - <Link href="https://dify.ai/privacy" className="text-text-accent">{t($ => $['account.deletePrivacyLink'], { ns: 'common' })}</Link> + {t(($) => $['account.deletePrivacyLinkTip'], { ns: 'common' })} + <Link href="https://dify.ai/privacy" className="text-text-accent"> + {t(($) => $['account.deletePrivacyLink'], { ns: 'common' })} + </Link> </div> - <label className="mt-3 mb-1 flex h-6 items-center system-sm-semibold text-text-secondary">{t($ => $['account.deleteLabel'], { ns: 'common' })}</label> + <label className="mt-3 mb-1 flex h-6 items-center system-sm-semibold text-text-secondary"> + {t(($) => $['account.deleteLabel'], { ns: 'common' })} + </label> <Input - placeholder={t($ => $['account.deletePlaceholder'], { ns: 'common' }) as string} + placeholder={t(($) => $['account.deletePlaceholder'], { ns: 'common' }) as string} onChange={(e) => { setUserInputEmail(e.target.value) }} /> <div className="mt-3 flex w-full flex-col gap-2"> - <Button className="w-full" disabled={userInputEmail !== userProfileEmail || isSendingEmail} loading={isSendingEmail} variant="primary" onClick={handleConfirm}>{t($ => $['account.sendVerificationButton'], { ns: 'common' })}</Button> - <Button className="w-full" onClick={props.onCancel}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> + <Button + className="w-full" + disabled={userInputEmail !== userProfileEmail || isSendingEmail} + loading={isSendingEmail} + variant="primary" + onClick={handleConfirm} + > + {t(($) => $['account.sendVerificationButton'], { ns: 'common' })} + </Button> + <Button className="w-full" onClick={props.onCancel}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> </div> </> ) 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 b919d48c8cc0f3..21bc9d617128c9 100644 --- a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx @@ -29,9 +29,10 @@ export default function FeedBack(props: DeleteAccountProps) { await logout() // Tokens are now stored in cookies and cleared by backend router.push('/signin') - toast.info(t($ => $['account.deleteSuccessTip'], { ns: 'common' })) + toast.info(t(($) => $['account.deleteSuccessTip'], { ns: 'common' })) + } catch (error) { + console.error(error) } - catch (error) { console.error(error) } }, [router, t]) const handleSubmit = useCallback(async () => { @@ -39,8 +40,9 @@ export default function FeedBack(props: DeleteAccountProps) { await sendFeedback({ feedback: userFeedback, email: userProfileEmail }) props.onConfirm() await handleSuccess() + } catch (error) { + console.error(error) } - catch (error) { console.error(error) } }, [handleSuccess, userFeedback, sendFeedback, userProfileEmail, props]) const handleSkip = useCallback(() => { @@ -51,8 +53,7 @@ export default function FeedBack(props: DeleteAccountProps) { <Dialog open onOpenChange={(open) => { - if (!open) - props.onCancel() + if (!open) props.onCancel() }} > <DialogContent @@ -60,21 +61,27 @@ export default function FeedBack(props: DeleteAccountProps) { backdropClassName="bg-background-overlay-backdrop backdrop-blur-[6px]" > <DialogTitle className="pr-8 pb-3 title-2xl-semi-bold text-text-primary"> - {t($ => $['account.feedbackTitle'], { ns: 'common' })} + {t(($) => $['account.feedbackTitle'], { ns: 'common' })} </DialogTitle> - <label className="mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary">{t($ => $['account.feedbackLabel'], { ns: 'common' })}</label> + <label className="mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary"> + {t(($) => $['account.feedbackLabel'], { ns: 'common' })} + </label> <Textarea - aria-label={t($ => $['account.feedbackLabel'], { ns: 'common' }) as string} + aria-label={t(($) => $['account.feedbackLabel'], { ns: 'common' }) as string} rows={6} value={userFeedback} - placeholder={t($ => $['account.feedbackPlaceholder'], { ns: 'common' }) as string} + placeholder={t(($) => $['account.feedbackPlaceholder'], { ns: 'common' }) as string} onValueChange={(value) => { setUserFeedback(value) }} /> <div className="mt-3 flex w-full flex-col gap-2"> - <Button className="w-full" loading={isPending} variant="primary" onClick={handleSubmit}>{t($ => $['operation.submit'], { ns: 'common' })}</Button> - <Button className="w-full" onClick={handleSkip}>{t($ => $['operation.skip'], { ns: 'common' })}</Button> + <Button className="w-full" loading={isPending} variant="primary" onClick={handleSubmit}> + {t(($) => $['operation.submit'], { ns: 'common' })} + </Button> + <Button className="w-full" onClick={handleSkip}> + {t(($) => $['operation.skip'], { ns: 'common' })} + </Button> </div> </DialogContent> </Dialog> diff --git a/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx b/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx index 6b86e2c774f6d5..c5d152aaeffb07 100644 --- a/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx @@ -16,7 +16,7 @@ type DeleteAccountProps = { export default function VerifyEmail(props: DeleteAccountProps) { const { t } = useTranslation() - const emailToken = useAccountDeleteStore(state => state.sendEmailToken) + const emailToken = useAccountDeleteStore((state) => state.sendEmailToken) const [verificationCode, setVerificationCode] = useState<string>() const [shouldButtonDisabled, setShouldButtonDisabled] = useState(true) const { mutate: sendEmail } = useSendDeleteAccountEmail() @@ -29,32 +29,47 @@ export default function VerifyEmail(props: DeleteAccountProps) { const handleConfirm = useCallback(async () => { try { const ret = await confirmDeleteAccount({ code: verificationCode!, token: emailToken }) - if (ret.result === 'success') - props.onConfirm() + if (ret.result === 'success') props.onConfirm() + } catch (error) { + console.error(error) } - catch (error) { console.error(error) } }, [emailToken, verificationCode, confirmDeleteAccount, props]) return ( <> <div className="pt-1 body-md-medium text-text-destructive"> - {t($ => $['account.deleteTip'], { ns: 'common' })} + {t(($) => $['account.deleteTip'], { ns: 'common' })} </div> <div className="pt-1 pb-2 body-md-regular text-text-secondary"> - {t($ => $['account.deletePrivacyLinkTip'], { ns: 'common' })} - <Link href="https://dify.ai/privacy" className="text-text-accent">{t($ => $['account.deletePrivacyLink'], { ns: 'common' })}</Link> + {t(($) => $['account.deletePrivacyLinkTip'], { ns: 'common' })} + <Link href="https://dify.ai/privacy" className="text-text-accent"> + {t(($) => $['account.deletePrivacyLink'], { ns: 'common' })} + </Link> </div> - <label className="mt-3 mb-1 flex h-6 items-center system-sm-semibold text-text-secondary">{t($ => $['account.verificationLabel'], { ns: 'common' })}</label> + <label className="mt-3 mb-1 flex h-6 items-center system-sm-semibold text-text-secondary"> + {t(($) => $['account.verificationLabel'], { ns: 'common' })} + </label> <Input minLength={6} maxLength={6} - placeholder={t($ => $['account.verificationPlaceholder'], { ns: 'common' }) as string} + placeholder={t(($) => $['account.verificationPlaceholder'], { ns: 'common' }) as string} onChange={(e) => { setVerificationCode(e.target.value) }} /> <div className="mt-3 flex w-full flex-col gap-2"> - <Button className="w-full" disabled={shouldButtonDisabled} loading={isDeleting} variant="primary" tone="destructive" onClick={handleConfirm}>{t($ => $['account.permanentlyDeleteButton'], { ns: 'common' })}</Button> - <Button className="w-full" onClick={props.onCancel}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> + <Button + className="w-full" + disabled={shouldButtonDisabled} + loading={isDeleting} + variant="primary" + tone="destructive" + onClick={handleConfirm} + > + {t(($) => $['account.permanentlyDeleteButton'], { ns: 'common' })} + </Button> + <Button className="w-full" onClick={props.onCancel}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> <Countdown onResend={sendEmail} /> </div> </> diff --git a/web/app/account/(commonLayout)/delete-account/index.tsx b/web/app/account/(commonLayout)/delete-account/index.tsx index 65684f594723f2..0f1eee0ac43e01 100644 --- a/web/app/account/(commonLayout)/delete-account/index.tsx +++ b/web/app/account/(commonLayout)/delete-account/index.tsx @@ -23,19 +23,18 @@ export default function DeleteAccount(props: DeleteAccountProps) { try { setShowVerifyEmail(true) setCountdownLeftTime(`${COUNT_DOWN_TIME_MS}`) + } catch (error) { + console.error(error) } - catch (error) { console.error(error) } }, [setCountdownLeftTime]) - if (showFeedbackDialog) - return <FeedBack onCancel={props.onCancel} onConfirm={props.onConfirm} /> + if (showFeedbackDialog) return <FeedBack onCancel={props.onCancel} onConfirm={props.onConfirm} /> return ( <Dialog open onOpenChange={(open) => { - if (!open) - props.onCancel() + if (!open) props.onCancel() }} > <DialogContent @@ -43,9 +42,11 @@ export default function DeleteAccount(props: DeleteAccountProps) { backdropClassName="bg-background-overlay-backdrop backdrop-blur-[6px]" > <DialogTitle className="pr-8 pb-3 title-2xl-semi-bold text-text-primary"> - {t($ => $['account.delete'], { ns: 'common' })} + {t(($) => $['account.delete'], { ns: 'common' })} </DialogTitle> - {!showVerifyEmail && <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} />} + {!showVerifyEmail && ( + <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} /> + )} {showVerifyEmail && ( <VerifyEmail onCancel={props.onCancel} diff --git a/web/app/account/(commonLayout)/delete-account/state.tsx b/web/app/account/(commonLayout)/delete-account/state.tsx index 4c43fba12dd528..20f78ca38df1b9 100644 --- a/web/app/account/(commonLayout)/delete-account/state.tsx +++ b/web/app/account/(commonLayout)/delete-account/state.tsx @@ -1,25 +1,28 @@ import { useMutation } from '@tanstack/react-query' import { create } from 'zustand' -import { sendDeleteAccountCode, submitDeleteAccountFeedback, verifyDeleteAccountCode } from '@/service/common' +import { + sendDeleteAccountCode, + submitDeleteAccountFeedback, + verifyDeleteAccountCode, +} from '@/service/common' type State = { sendEmailToken: string setSendEmailToken: (token: string) => void } -export const useAccountDeleteStore = create<State>(set => ({ +export const useAccountDeleteStore = create<State>((set) => ({ sendEmailToken: '', setSendEmailToken: (token: string) => set({ sendEmailToken: token }), })) export function useSendDeleteAccountEmail() { - const updateEmailToken = useAccountDeleteStore(state => state.setSendEmailToken) + const updateEmailToken = useAccountDeleteStore((state) => state.setSendEmailToken) return useMutation({ mutationKey: ['delete-account'], mutationFn: sendDeleteAccountCode, onSuccess: (ret) => { - if (ret.result === 'success') - updateEmailToken(ret.data) + if (ret.result === 'success') updateEmailToken(ret.data) }, }) } diff --git a/web/app/account/(commonLayout)/header.tsx b/web/app/account/(commonLayout)/header.tsx index e853acc08a9ec0..18cfb9763fc465 100644 --- a/web/app/account/(commonLayout)/header.tsx +++ b/web/app/account/(commonLayout)/header.tsx @@ -17,7 +17,10 @@ const Header = () => { const goToHome = useCallback(() => { router.push('/') }, [router]) - const logoLabel = systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify' + const logoLabel = + systemFeatures.branding.enabled && systemFeatures.branding.application_title + ? systemFeatures.branding.application_title + : 'Dify' return ( <div className="flex flex-1 items-center justify-between px-4"> @@ -27,23 +30,25 @@ const Header = () => { className="flex items-center rounded-sm hover:opacity-80 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" aria-label={logoLabel} > - {systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo - ? ( - <img - src={systemFeatures.branding.login_page_logo} - className="block h-[22px] w-auto object-contain" - alt="" - /> - ) - : <DifyLogo alt="" />} + {systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo ? ( + <img + src={systemFeatures.branding.login_page_logo} + className="block h-[22px] w-auto object-contain" + alt="" + /> + ) : ( + <DifyLogo alt="" /> + )} </Link> <div className="h-4 w-px origin-center rotate-[11.31deg] bg-divider-regular" /> - <p className="relative mt-[-2px] title-3xl-semi-bold text-text-primary">{t($ => $['account.account'], { ns: 'common' })}</p> + <p className="relative mt-[-2px] title-3xl-semi-bold text-text-primary"> + {t(($) => $['account.account'], { ns: 'common' })} + </p> </div> <div className="flex shrink-0 items-center gap-3"> <Button className="gap-2 px-3 py-2 system-sm-medium" onClick={goToHome}> <span aria-hidden className="i-custom-vender-main-nav-home size-4" /> - <p>{t($ => $['mainNav.home'], { ns: 'common' })}</p> + <p>{t(($) => $['mainNav.home'], { ns: 'common' })}</p> <span aria-hidden className="i-ri-arrow-right-up-line size-4" /> </Button> <div className="h-4 w-px bg-divider-regular" /> diff --git a/web/app/account/(commonLayout)/page.tsx b/web/app/account/(commonLayout)/page.tsx index 51f71f5d6a8943..a406950674871b 100644 --- a/web/app/account/(commonLayout)/page.tsx +++ b/web/app/account/(commonLayout)/page.tsx @@ -5,7 +5,7 @@ import AccountPage from './account-page' export default function Account() { const { t } = useTranslation() - useDocumentTitle(t($ => $['menus.account'], { ns: 'common' })) + useDocumentTitle(t(($) => $['menus.account'], { ns: 'common' })) return ( <div className="mx-auto w-full max-w-[640px] px-6 pt-12"> <AccountPage /> diff --git a/web/app/account/oauth/authorize/layout.tsx b/web/app/account/oauth/authorize/layout.tsx index 339624fe027db4..c17a08064425c5 100644 --- a/web/app/account/oauth/authorize/layout.tsx +++ b/web/app/account/oauth/authorize/layout.tsx @@ -1,7 +1,6 @@ 'use client' import type { ReactNode } from 'react' import { useSuspenseQuery } from '@tanstack/react-query' - import Header from '@/app/signin/_header' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useDocumentTitle from '@/hooks/use-document-title' @@ -21,17 +20,11 @@ export default function OAuthAuthorizeLayout({ children }: Props) { <div className="flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle"> <Header /> <div className="flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]"> - <div className="flex flex-col md:w-[400px]"> - {children} - </div> + <div className="flex flex-col md:w-[400px]">{children}</div> </div> {systemFeatures.branding.enabled === false && ( <div className="px-8 py-6 system-xs-regular text-text-tertiary"> - © - {' '} - {copyrightYear} - {' '} - LangGenius, Inc. All rights reserved. + © {copyrightYear} LangGenius, Inc. All rights reserved. </div> )} </div> diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index 7ce9119502d865..5d2c5ab45b4d40 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -25,8 +25,7 @@ function buildReturnUrl(pathname: string, search: string) { try { const base = `${globalThis.location.origin}${pathname}${search}` return base - } - catch { + } catch { return pathname + search } } @@ -34,26 +33,29 @@ function buildReturnUrl(pathname: string, search: string) { export default function OAuthAuthorize() { const { t } = useTranslation() - const SCOPE_INFO_MAP: Record<string, { icon: React.ComponentType<{ className?: string }>, label: string }> = { + const SCOPE_INFO_MAP: Record< + string, + { icon: React.ComponentType<{ className?: string }>; label: string } + > = { 'read:name': { icon: RiInfoCardLine, - label: t($ => $['scopes.name'], { ns: 'oauth' }), + label: t(($) => $['scopes.name'], { ns: 'oauth' }), }, 'read:email': { icon: RiMailLine, - label: t($ => $['scopes.email'], { ns: 'oauth' }), + label: t(($) => $['scopes.email'], { ns: 'oauth' }), }, 'read:avatar': { icon: RiAccountCircleLine, - label: t($ => $['scopes.avatar'], { ns: 'oauth' }), + label: t(($) => $['scopes.avatar'], { ns: 'oauth' }), }, 'read:interface_language': { icon: RiTranslate2, - label: t($ => $['scopes.languagePreference'], { ns: 'oauth' }), + label: t(($) => $['scopes.languagePreference'], { ns: 'oauth' }), }, 'read:timezone': { icon: RiGlobalLine, - label: t($ => $['scopes.timezone'], { ns: 'oauth' }), + label: t(($) => $['scopes.timezone'], { ns: 'oauth' }), }, } @@ -65,13 +67,21 @@ export default function OAuthAuthorize() { // Probe user profile. 401 stays as `error` (legitimate "not logged in" state), // other errors throw to the nearest error.tsx; jumpTo same-pathname guard in // service/base.ts prevents a redirect loop here. - const { data: userProfileResp, isPending: isProfileLoading, error: profileError } = useQuery({ + const { + data: userProfileResp, + isPending: isProfileLoading, + error: profileError, + } = useQuery({ ...userProfileQueryOptions(), - throwOnError: err => !isLegacyBase401(err), + throwOnError: (err) => !isLegacyBase401(err), }) const isLoggedIn = !!userProfileResp && !profileError const userProfile = userProfileResp?.profile - const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri) + const { + data: authAppInfo, + isLoading: isOAuthLoading, + isError, + } = useOAuthAppInfo(client_id, redirect_uri) const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp() const { mutateAsync: logout } = useLogout() const hasNotifiedRef = useRef(false) @@ -80,26 +90,22 @@ export default function OAuthAuthorize() { const onLoginSwitchClick = async () => { try { const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`) - if (isLoggedIn) - await logout() + if (isLoggedIn) await logout() router.push(`/signin?redirect_url=${encodeURIComponent(returnUrl)}`) - } - catch { + } catch { router.push('/signin') } } const onAuthorize = async () => { - if (!client_id || !redirect_uri) - return + if (!client_id || !redirect_uri) return try { const { code } = await authorize({ client_id }) const url = new URL(redirect_uri) url.searchParams.set('code', code) globalThis.location.href = url.toString() - } - catch (err: any) { - toast.error(`${t($ => $['error.authorizeFailed'], { ns: 'oauth' })}: ${err.message}`) + } catch (err: any) { + toast.error(`${t(($) => $['error.authorizeFailed'], { ns: 'oauth' })}: ${err.message}`) } } @@ -108,7 +114,9 @@ export default function OAuthAuthorize() { if ((invalidParams || isError) && !hasNotifiedRef.current) { hasNotifiedRef.current = true toast.error( - invalidParams ? t($ => $['error.invalidParams'], { ns: 'oauth' }) : t($ => $['error.authAppInfoFetchFailed'], { ns: 'oauth' }), + invalidParams + ? t(($) => $['error.invalidParams'], { ns: 'oauth' }) + : t(($) => $['error.authAppInfoFetchFailed'], { ns: 'oauth' }), { timeout: 0 }, ) } @@ -132,11 +140,25 @@ export default function OAuthAuthorize() { <div className={`mt-5 mb-4 flex flex-col gap-2 ${isLoggedIn ? 'pb-2' : ''}`}> <div className="title-4xl-semi-bold"> - {isLoggedIn && <div className="text-text-primary">{t($ => $.connect, { ns: 'oauth' })}</div>} - <div className="text-saas-dify-blue-inverted">{authAppInfo?.app_label[language] || authAppInfo?.app_label?.en_US || t($ => $.unknownApp, { ns: 'oauth' })}</div> - {!isLoggedIn && <div className="text-text-primary">{t($ => $['tips.notLoggedIn'], { ns: 'oauth' })}</div>} + {isLoggedIn && ( + <div className="text-text-primary">{t(($) => $.connect, { ns: 'oauth' })}</div> + )} + <div className="text-saas-dify-blue-inverted"> + {authAppInfo?.app_label[language] || + authAppInfo?.app_label?.en_US || + t(($) => $.unknownApp, { ns: 'oauth' })} + </div> + {!isLoggedIn && ( + <div className="text-text-primary"> + {t(($) => $['tips.notLoggedIn'], { ns: 'oauth' })} + </div> + )} + </div> + <div className="body-md-regular text-text-secondary"> + {isLoggedIn + ? `${authAppInfo?.app_label[language] || authAppInfo?.app_label?.en_US || t(($) => $.unknownApp, { ns: 'oauth' })} ${t(($) => $['tips.loggedIn'], { ns: 'oauth' })}` + : t(($) => $['tips.needLogin'], { ns: 'oauth' })} </div> - <div className="body-md-regular text-text-secondary">{isLoggedIn ? `${authAppInfo?.app_label[language] || authAppInfo?.app_label?.en_US || t($ => $.unknownApp, { ns: 'oauth' })} ${t($ => $['tips.loggedIn'], { ns: 'oauth' })}` : t($ => $['tips.needLogin'], { ns: 'oauth' })}</div> </div> {isLoggedIn && userProfile && ( @@ -148,41 +170,77 @@ export default function OAuthAuthorize() { <div className="system-xs-regular text-text-tertiary">{userProfile.email}</div> </div> </div> - <Button variant="tertiary" size="small" onClick={onLoginSwitchClick}>{t($ => $.switchAccount, { ns: 'oauth' })}</Button> + <Button variant="tertiary" size="small" onClick={onLoginSwitchClick}> + {t(($) => $.switchAccount, { ns: 'oauth' })} + </Button> </div> )} {isLoggedIn && Boolean(authAppInfo?.scope) && ( <div className="mt-2 flex flex-col gap-2.5 rounded-xl bg-background-section-burn-inverted px-[22px] py-5 text-text-secondary"> - {authAppInfo!.scope.split(/\s+/).filter(Boolean).map((scope: string) => { - const Icon = SCOPE_INFO_MAP[scope] - return ( - <div key={scope} className="flex items-center gap-2 body-sm-medium text-text-secondary"> - {Icon ? <Icon.icon className="size-4" /> : <RiAccountCircleLine className="size-4" />} - {Icon!.label} - </div> - ) - })} + {authAppInfo!.scope + .split(/\s+/) + .filter(Boolean) + .map((scope: string) => { + const Icon = SCOPE_INFO_MAP[scope] + return ( + <div + key={scope} + className="flex items-center gap-2 body-sm-medium text-text-secondary" + > + {Icon ? ( + <Icon.icon className="size-4" /> + ) : ( + <RiAccountCircleLine className="size-4" /> + )} + {Icon!.label} + </div> + ) + })} </div> )} <div className="flex flex-col items-center gap-2 pt-4"> - {!isLoggedIn - ? ( - <Button variant="primary" size="large" className="w-full" onClick={onLoginSwitchClick}>{t($ => $.login, { ns: 'oauth' })}</Button> - ) - : ( - <> - <Button variant="primary" size="large" className="w-full" onClick={onAuthorize} disabled={!client_id || !redirect_uri || isError || authorizing} loading={authorizing}>{t($ => $.continue, { ns: 'oauth' })}</Button> - <Button size="large" className="w-full" onClick={() => router.push('/apps')}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - </> - )} + {!isLoggedIn ? ( + <Button variant="primary" size="large" className="w-full" onClick={onLoginSwitchClick}> + {t(($) => $.login, { ns: 'oauth' })} + </Button> + ) : ( + <> + <Button + variant="primary" + size="large" + className="w-full" + onClick={onAuthorize} + disabled={!client_id || !redirect_uri || isError || authorizing} + loading={authorizing} + > + {t(($) => $.continue, { ns: 'oauth' })} + </Button> + <Button size="large" className="w-full" onClick={() => router.push('/apps')}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + </> + )} </div> <div className="mt-4 py-2"> - <svg xmlns="http://www.w3.org/2000/svg" width="400" height="1" viewBox="0 0 400 1" fill="none"> + <svg + xmlns="http://www.w3.org/2000/svg" + width="400" + height="1" + viewBox="0 0 400 1" + fill="none" + > <path d="M0 0.5H400" stroke="url(#paint0_linear_2_5904)" /> <defs> - <linearGradient id="paint0_linear_2_5904" x1="400" y1="9.49584" x2="0.000228929" y2="9.17666" gradientUnits="userSpaceOnUse"> + <linearGradient + id="paint0_linear_2_5904" + x1="400" + y1="9.49584" + x2="0.000228929" + y2="9.17666" + gradientUnits="userSpaceOnUse" + > <stop stop-color="white" stop-opacity="0.01" /> <stop offset="0.505" stop-color="#101828" stop-opacity="0.08" /> <stop offset="1" stop-color="white" stop-opacity="0.01" /> @@ -190,7 +248,9 @@ export default function OAuthAuthorize() { </defs> </svg> </div> - <div className="mt-3 system-xs-regular text-text-tertiary">{t($ => $['tips.common'], { ns: 'oauth' })}</div> + <div className="mt-3 system-xs-regular text-text-tertiary"> + {t(($) => $['tips.common'], { ns: 'oauth' })} + </div> </div> ) } diff --git a/web/app/activate/activateForm.tsx b/web/app/activate/activateForm.tsx index 64ff1874fe4886..0c7c71a699055a 100644 --- a/web/app/activate/activateForm.tsx +++ b/web/app/activate/activateForm.tsx @@ -4,7 +4,6 @@ import { cn } from '@langgenius/dify-ui/cn' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' - import useDocumentTitle from '@/hooks/use-document-title' import { useRouter, useSearchParams } from '@/next/navigation' import { useInvitationCheck } from '@/service/use-common' @@ -21,15 +20,18 @@ const ActivateForm = () => { const checkParams = { url: '/activate/check', params: { - ...workspaceID && { workspace_id: workspaceID }, - ...email && { email }, + ...(workspaceID && { workspace_id: workspaceID }), + ...(email && { email }), token, }, } - const { data: checkRes } = useInvitationCheck({ - ...checkParams.params, - token: token || undefined, - }, true) + const { data: checkRes } = useInvitationCheck( + { + ...checkParams.params, + token: token || undefined, + }, + true, + ) useEffect(() => { if (checkRes?.is_valid) { @@ -43,24 +45,27 @@ const ActivateForm = () => { }, [checkRes, router, searchParams, token]) return ( - <div className={ - cn( + <div + className={cn( 'flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]', - ) - } + )} > {!checkRes && <Loading />} {checkRes && !checkRes.is_valid && ( <div className="flex flex-col md:w-[400px]"> <div className="mx-auto w-full"> - <div className="mb-3 flex h-20 w-20 items-center justify-center rounded-[20px] border border-divider-regular bg-components-option-card-option-bg p-5 text-[40px] font-bold shadow-lg">🤷‍♂️</div> - <h2 className="text-[32px] font-bold text-text-primary">{t($ => $.invalid, { ns: 'login' })}</h2> + <div className="mb-3 flex h-20 w-20 items-center justify-center rounded-[20px] border border-divider-regular bg-components-option-card-option-bg p-5 text-[40px] font-bold shadow-lg"> + 🤷‍♂️ + </div> + <h2 className="text-[32px] font-bold text-text-primary"> + {t(($) => $.invalid, { ns: 'login' })} + </h2> </div> <div className="mx-auto mt-6 w-full"> <Button variant="primary" className="w-full text-sm!"> - <a href="https://dify.ai">{t($ => $.explore, { ns: 'login' })}</a> + <a href="https://dify.ai">{t(($) => $.explore, { ns: 'login' })}</a> </Button> </div> </div> diff --git a/web/app/activate/page.tsx b/web/app/activate/page.tsx index c028074620bb38..4707ccc1bee12b 100644 --- a/web/app/activate/page.tsx +++ b/web/app/activate/page.tsx @@ -10,16 +10,16 @@ const Activate = () => { const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) return ( <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> - <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> + <div + className={cn( + 'flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle', + )} + > <Header /> <ActivateForm /> {!systemFeatures.branding.enabled && ( <div className="px-8 py-6 text-sm font-normal text-text-tertiary"> - © - {' '} - {new Date().getFullYear()} - {' '} - LangGenius, Inc. All rights reserved. + © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. </div> )} </div> diff --git a/web/app/auth/refresh/__tests__/route.spec.ts b/web/app/auth/refresh/__tests__/route.spec.ts index ac1b5e6747f0e8..feec5c0be80ebc 100644 --- a/web/app/auth/refresh/__tests__/route.spec.ts +++ b/web/app/auth/refresh/__tests__/route.spec.ts @@ -36,10 +36,11 @@ const getSetCookieHeaders = (headers: Headers) => { return setCookie ? [setCookie] : [] } -const createRequest = (url: string, cookie?: string) => ({ - url, - headers: new Headers(cookie ? { cookie } : undefined), -}) as Request +const createRequest = (url: string, cookie?: string) => + ({ + url, + headers: new Headers(cookie ? { cookie } : undefined), + }) as Request describe('auth refresh route', () => { beforeEach(() => { @@ -64,10 +65,12 @@ describe('auth refresh route', () => { vi.stubGlobal('fetch', fetchMock) const { GET } = await import('../route') - const response = await GET(createRequest( - 'http://localhost:3000/auth/refresh?redirect_url=%2Fapps%3Fcategory%3Dworkflow', - 'refresh_token=old-refresh', - )) + const response = await GET( + createRequest( + 'http://localhost:3000/auth/refresh?redirect_url=%2Fapps%3Fcategory%3Dworkflow', + 'refresh_token=old-refresh', + ), + ) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:5001/console/api/refresh-token', @@ -91,10 +94,12 @@ describe('auth refresh route', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))) const { GET } = await import('../route') - const response = await GET(createRequest( - 'http://localhost:3000/auth/refresh?redirect_url=%2Fapps', - 'refresh_token=expired', - )) + const response = await GET( + createRequest( + 'http://localhost:3000/auth/refresh?redirect_url=%2Fapps', + 'refresh_token=expired', + ), + ) expect(response.status).toBe(303) expect(response.headers.get('location')).toBe('/signin?redirect_url=%2Fapps') @@ -105,10 +110,12 @@ describe('auth refresh route', () => { vi.stubGlobal('fetch', fetchMock) const { GET } = await import('../route') - const response = await GET(createRequest( - 'http://localhost:3000/auth/refresh?redirect_url=https%3A%2F%2Fevil.example', - 'refresh_token=expired', - )) + const response = await GET( + createRequest( + 'http://localhost:3000/auth/refresh?redirect_url=https%3A%2F%2Fevil.example', + 'refresh_token=expired', + ), + ) expect(response.status).toBe(303) expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F') @@ -118,10 +125,9 @@ describe('auth refresh route', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))) const { GET } = await import('../route') - const response = await GET(createRequest( - 'http://localhost:3000/auth/refresh', - 'refresh_token=expired', - )) + const response = await GET( + createRequest('http://localhost:3000/auth/refresh', 'refresh_token=expired'), + ) expect(response.status).toBe(303) expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F') @@ -131,10 +137,12 @@ describe('auth refresh route', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))) const { GET } = await import('../route') - const response = await GET(createRequest( - 'http://internal-service:3000/auth/refresh?redirect_url=%2F', - 'refresh_token=expired', - )) + const response = await GET( + createRequest( + 'http://internal-service:3000/auth/refresh?redirect_url=%2F', + 'refresh_token=expired', + ), + ) expect(response.status).toBe(303) expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F') @@ -156,10 +164,12 @@ describe('auth refresh route', () => { vi.stubGlobal('fetch', fetchMock) const { GET } = await import('../route') - const response = await GET(createRequest( - 'http://localhost:3000/console/auth/refresh?redirect_url=%2Fconsole%2Fapps%3Fcategory%3Dworkflow', - 'refresh_token=old-refresh', - )) + const response = await GET( + createRequest( + 'http://localhost:3000/console/auth/refresh?redirect_url=%2Fconsole%2Fapps%3Fcategory%3Dworkflow', + 'refresh_token=old-refresh', + ), + ) expect(response.status).toBe(303) expect(response.headers.get('location')).toBe('/console/apps?category=workflow') @@ -174,10 +184,12 @@ describe('auth refresh route', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))) const { GET } = await import('../route') - const response = await GET(createRequest( - 'http://localhost:3000/console/auth/refresh?redirect_url=%2Fconsole%2Fauth%2Frefresh', - 'refresh_token=expired', - )) + const response = await GET( + createRequest( + 'http://localhost:3000/console/auth/refresh?redirect_url=%2Fconsole%2Fauth%2Frefresh', + 'refresh_token=expired', + ), + ) expect(response.status).toBe(303) expect(response.headers.get('location')).toBe('/console/signin?redirect_url=%2Fconsole%2F') diff --git a/web/app/auth/refresh/route.ts b/web/app/auth/refresh/route.ts index d36d9be92eab41..508fa2bb6224f9 100644 --- a/web/app/auth/refresh/route.ts +++ b/web/app/auth/refresh/route.ts @@ -9,19 +9,15 @@ const resolveSafeRedirectPath = (request: Request) => { const requestUrl = new URL(request.url) const redirectUrl = requestUrl.searchParams.get('redirect_url') - if (!redirectUrl) - return DEFAULT_REDIRECT_PATH + if (!redirectUrl) return DEFAULT_REDIRECT_PATH try { const target = new URL(redirectUrl, requestUrl.origin) - if (target.origin !== requestUrl.origin) - return DEFAULT_REDIRECT_PATH - if (target.pathname === AUTH_REFRESH_PATH) - return DEFAULT_REDIRECT_PATH + if (target.origin !== requestUrl.origin) return DEFAULT_REDIRECT_PATH + if (target.pathname === AUTH_REFRESH_PATH) return DEFAULT_REDIRECT_PATH return `${target.pathname}${target.search}` - } - catch { + } catch { return DEFAULT_REDIRECT_PATH } } @@ -43,11 +39,10 @@ const getSetCookieHeaders = (headers: Headers) => { const createRedirectResponse = (pathname: string, setCookies: string[] = []) => { const headers = new Headers({ 'Cache-Control': 'no-store', - 'Location': pathname, + Location: pathname, }) - for (const cookie of setCookies) - headers.append('Set-Cookie', cookie) + for (const cookie of setCookies) headers.append('Set-Cookie', cookie) return new Response(null, { status: 303, @@ -63,8 +58,7 @@ export async function GET(request: Request) { const refreshUrl = resolveServerConsoleApiUrl(REFRESH_TOKEN_PATH) const cookie = request.headers.get('cookie') - if (!refreshUrl || !cookie) - return createSigninRedirectResponse(redirectPath) + if (!refreshUrl || !cookie) return createSigninRedirectResponse(redirectPath) try { const response = await fetch(refreshUrl, { @@ -76,12 +70,10 @@ export async function GET(request: Request) { cache: 'no-store', }) - if (!response.ok) - return createSigninRedirectResponse(redirectPath) + if (!response.ok) return createSigninRedirectResponse(redirectPath) return createRedirectResponse(redirectPath, getSetCookieHeaders(response.headers)) - } - catch { + } catch { return createSigninRedirectResponse(redirectPath) } } diff --git a/web/app/components/__tests__/education-verify-action-recorder.spec.tsx b/web/app/components/__tests__/education-verify-action-recorder.spec.tsx index fa8858144588ac..6abcf2226ed774 100644 --- a/web/app/components/__tests__/education-verify-action-recorder.spec.tsx +++ b/web/app/components/__tests__/education-verify-action-recorder.spec.tsx @@ -1,8 +1,6 @@ import { render, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, -} from '@/app/education-apply/constants' +import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION } from '@/app/education-apply/constants' import { useSearchParams } from '@/next/navigation' import { EducationVerifyActionRecorder } from '../education-verify-action-recorder' @@ -22,12 +20,16 @@ describe('EducationVerifyActionRecorder', () => { beforeEach(() => { vi.clearAllMocks() window.localStorage.clear() - mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>) + mockUseSearchParams.mockReturnValue( + new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>, + ) }) it('should store the education verification flag when the callback action is present', async () => { mockUseSearchParams.mockReturnValue( - new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType<typeof useSearchParams>, + new URLSearchParams( + `action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`, + ) as unknown as ReturnType<typeof useSearchParams>, ) render(<EducationVerifyActionRecorder />) diff --git a/web/app/components/__tests__/external-attribution-recorder.spec.tsx b/web/app/components/__tests__/external-attribution-recorder.spec.tsx index 763d7e3ea47982..51cc0ef3a1081a 100644 --- a/web/app/components/__tests__/external-attribution-recorder.spec.tsx +++ b/web/app/components/__tests__/external-attribution-recorder.spec.tsx @@ -20,13 +20,16 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/utils/create-app-tracking', () => ({ - rememberCreateAppExternalAttribution: (...args: unknown[]) => mockRememberCreateAppExternalAttribution(...args), + rememberCreateAppExternalAttribution: (...args: unknown[]) => + mockRememberCreateAppExternalAttribution(...args), })) const mockUseSearchParams = vi.mocked(useSearchParams) const setSearchParams = (search = '') => { - mockUseSearchParams.mockReturnValue(new URLSearchParams(search) as unknown as ReturnType<typeof useSearchParams>) + mockUseSearchParams.mockReturnValue( + new URLSearchParams(search) as unknown as ReturnType<typeof useSearchParams>, + ) } const getUtmInfoCookie = () => { @@ -59,7 +62,9 @@ describe('ExternalAttributionRecorder', () => { }) it('seeds attribution from the redirect_url when auth redirects away from the landing url', async () => { - setSearchParams(`redirect_url=${encodeURIComponent('/apps?utm_source=dify_blog&slug=buildaisupportassistantwithdify')}`) + setSearchParams( + `redirect_url=${encodeURIComponent('/apps?utm_source=dify_blog&slug=buildaisupportassistantwithdify')}`, + ) render(<ExternalAttributionRecorder />) @@ -85,7 +90,9 @@ describe('ExternalAttributionRecorder', () => { }) it('does nothing for cross-origin redirect_url attribution params', () => { - setSearchParams(`redirect_url=${encodeURIComponent('https://example.com/apps?utm_source=dify_blog&slug=get-started-with-dify')}`) + setSearchParams( + `redirect_url=${encodeURIComponent('https://example.com/apps?utm_source=dify_blog&slug=get-started-with-dify')}`, + ) render(<ExternalAttributionRecorder />) diff --git a/web/app/components/__tests__/oauth-registration-analytics.spec.tsx b/web/app/components/__tests__/oauth-registration-analytics.spec.tsx index 4d71fefc3698ff..d0ffff7279ac01 100644 --- a/web/app/components/__tests__/oauth-registration-analytics.spec.tsx +++ b/web/app/components/__tests__/oauth-registration-analytics.spec.tsx @@ -24,7 +24,9 @@ vi.mock('../base/amplitude/registration-tracking', () => ({ const mockUseSearchParams = vi.mocked(useSearchParams) const setSearchParams = (searchParams = '') => { - mockUseSearchParams.mockReturnValue(new URLSearchParams(searchParams) as unknown as ReturnType<typeof useSearchParams>) + mockUseSearchParams.mockReturnValue( + new URLSearchParams(searchParams) as unknown as ReturnType<typeof useSearchParams>, + ) window.history.replaceState(null, '', `/signin${searchParams ? `?${searchParams}` : ''}`) } @@ -37,10 +39,13 @@ describe('OAuthRegistrationAnalytics', () => { }) it('should track oauth registration with utm info and clear the query flag', async () => { - Cookies.set('utm_info', JSON.stringify({ - utm_source: 'linkedin', - slug: 'agent-launch', - })) + Cookies.set( + 'utm_info', + JSON.stringify({ + utm_source: 'linkedin', + slug: 'agent-launch', + }), + ) setSearchParams('oauth_new_user=true&source=signin') const replaceStateSpy = vi.spyOn(window.history, 'replaceState') diff --git a/web/app/components/access-rules-editor/__tests__/index.spec.tsx b/web/app/components/access-rules-editor/__tests__/index.spec.tsx index e501e1c90677ad..7cad4ae550550d 100644 --- a/web/app/components/access-rules-editor/__tests__/index.spec.tsx +++ b/web/app/components/access-rules-editor/__tests__/index.spec.tsx @@ -40,25 +40,29 @@ const createUserAccessSetting = (): ResourceUserAccessSetting => ({ account_name: 'Evan', email: 'evan@example.com', }, - roles: [{ - id: 'role-1', - type: 'app', - category: 'global_custom', - name: 'Maintainer', - is_builtin: false, - permission_keys: [], - }], - access_policies: [{ - id: 'app-policy-id', - tenant_id: 'tenant-id', - resource_type: 'app', - policy_key: 'app-policy-key', - name: 'Manage', - description: 'Can manage this app', - permission_keys: [], - is_builtin: false, - category: 'global_custom', - }], + roles: [ + { + id: 'role-1', + type: 'app', + category: 'global_custom', + name: 'Maintainer', + is_builtin: false, + permission_keys: [], + }, + ], + access_policies: [ + { + id: 'app-policy-id', + tenant_id: 'tenant-id', + resource_type: 'app', + policy_key: 'app-policy-key', + name: 'Manage', + description: 'Can manage this app', + permission_keys: [], + is_builtin: false, + category: 'global_custom', + }, + ], }) const createDefaultUserAccessSetting = (): ResourceUserAccessSetting => ({ @@ -66,17 +70,18 @@ const createDefaultUserAccessSetting = (): ResourceUserAccessSetting => ({ access_policies: [], }) -const createMember = (overrides: Partial<Member> = {}): Member => ({ - id: 'account-2', - name: 'Mia', - email: 'mia@example.com', - avatar: '', - avatar_url: '', - status: 'active', - role: 'normal', - roles: [], - ...overrides, -} as Member) +const createMember = (overrides: Partial<Member> = {}): Member => + ({ + id: 'account-2', + name: 'Mia', + email: 'mia@example.com', + avatar: '', + avatar_url: '', + status: 'active', + role: 'normal', + roles: [], + ...overrides, + }) as Member describe('AccessRulesEditor', () => { beforeEach(() => { @@ -131,11 +136,17 @@ describe('AccessRulesEditor', () => { ) expect(screen.getByText('permission.accessRule.resourceOpenScope')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'permission.accessRule.resourceOpenScopeDescription' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'permission.accessRule.resourceOpenScopeDescription' }), + ).toBeInTheDocument() - const allMembersButton = screen.getByRole('button', { name: /permission\.accessRule\.allPermittedMembers/ }) + const allMembersButton = screen.getByRole('button', { + name: /permission\.accessRule\.allPermittedMembers/, + }) const onlyMeButton = screen.getByRole('button', { name: /permission\.accessRule\.onlyMe/ }) - const specificMembersButton = screen.getByRole('button', { name: /permission\.accessRule\.specificMembersOnly/ }) + const specificMembersButton = screen.getByRole('button', { + name: /permission\.accessRule\.specificMembersOnly/, + }) expect(allMembersButton).toBeDisabled() expect(onlyMeButton).toBeDisabled() expect(specificMembersButton).toBeDisabled() @@ -165,14 +176,20 @@ describe('AccessRulesEditor', () => { ) expect(screen.getByText('permission.accessRule.resourceOpenScope')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /permission\.accessRule\.specificMembersOnly/ })).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByText('permission.accessRule.individualPermissionSettings')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /permission\.accessRule\.specificMembersOnly/ }), + ).toHaveAttribute('aria-pressed', 'true') + expect( + screen.getByText('permission.accessRule.individualPermissionSettings'), + ).toBeInTheDocument() expect(screen.getByText('Evan')).toBeInTheDocument() expect(screen.getByText('evan@example.com')).toBeInTheDocument() expect(screen.queryByText('Maintainer')).not.toBeInTheDocument() expect(screen.getAllByText('Manage').length).toBeGreaterThan(0) - fireEvent.click(screen.getByRole('button', { name: /permission\.accessRule\.allPermittedMembers/ })) + fireEvent.click( + screen.getByRole('button', { name: /permission\.accessRule\.allPermittedMembers/ }), + ) expect(onOpenScopeChange).not.toHaveBeenCalled() expect(screen.getByText('permission.accessRule.changeOpenScopeTitle')).toBeInTheDocument() expect(screen.getByText('permission.accessRule.changeOpenScopeDescription')).toBeInTheDocument() @@ -200,9 +217,14 @@ describe('AccessRulesEditor', () => { />, ) - expect(screen.getByRole('button', { name: /permission\.accessRule\.onlyMe/ })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: /permission\.accessRule\.onlyMe/ })).toHaveAttribute( + 'aria-pressed', + 'true', + ) - fireEvent.click(screen.getByRole('button', { name: /permission\.accessRule\.specificMembersOnly/ })) + fireEvent.click( + screen.getByRole('button', { name: /permission\.accessRule\.specificMembersOnly/ }), + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.change' })) expect(onOpenScopeChange).toHaveBeenCalledWith('specific') @@ -270,7 +292,9 @@ describe('AccessRulesEditor', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /permission\.accessRule\.allPermittedMembers/ })) + fireEvent.click( + screen.getByRole('button', { name: /permission\.accessRule\.allPermittedMembers/ }), + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) expect(onOpenScopeChange).not.toHaveBeenCalled() @@ -306,11 +330,19 @@ describe('AccessRulesEditor', () => { const dialog = screen.getByRole('dialog', { name: 'permission.accessRule.addMembersTitle' }) expect(within(dialog).getByText('Evan')).toBeInTheDocument() expect(within(dialog).getByRole('button', { name: 'common.operation.added' })).toBeDisabled() - expect(within(dialog).queryByRole('button', { name: 'permission.accessRule.addMemberAria:{"name":"Evan"}' })).not.toBeInTheDocument() + expect( + within(dialog).queryByRole('button', { + name: 'permission.accessRule.addMemberAria:{"name":"Evan"}', + }), + ).not.toBeInTheDocument() expect(within(dialog).getByText('Mia')).toBeInTheDocument() expect(within(dialog).queryByRole('tablist')).not.toBeInTheDocument() - await user.click(within(dialog).getByRole('button', { name: 'permission.accessRule.addMemberAria:{"name":"Mia"}' })) + await user.click( + within(dialog).getByRole('button', { + name: 'permission.accessRule.addMemberAria:{"name":"Mia"}', + }), + ) expect(onAddAccessSubject).toHaveBeenCalledWith('account-2', ['default']) }) diff --git a/web/app/components/access-rules-editor/add-access-subject-popover.tsx b/web/app/components/access-rules-editor/add-access-subject-popover.tsx index a2fa496ed339d3..1590cb60d747a8 100644 --- a/web/app/components/access-rules-editor/add-access-subject-popover.tsx +++ b/web/app/components/access-rules-editor/add-access-subject-popover.tsx @@ -6,11 +6,7 @@ import { Avatar } from '@langgenius/dify-ui/avatar' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' @@ -33,66 +29,69 @@ function AddAccessSubjectPopover({ const [searchValue, setSearchValue] = useState('') const { data: membersData, isLoading } = useMembers() const existingAccountIds = useMemo(() => { - return new Set(userAccessSettings.map(setting => setting.account.account_id)) + return new Set(userAccessSettings.map((setting) => setting.account.account_id)) }, [userAccessSettings]) const availableMembers = useMemo(() => { const normalizedSearchValue = searchValue.trim().toLowerCase() return (membersData?.accounts ?? []).filter((member) => { - if (!normalizedSearchValue) - return true + if (!normalizedSearchValue) return true const name = member.name || '' const email = member.email || '' - return name.toLowerCase().includes(normalizedSearchValue) - || email.toLowerCase().includes(normalizedSearchValue) + return ( + name.toLowerCase().includes(normalizedSearchValue) || + email.toLowerCase().includes(normalizedSearchValue) + ) }) }, [membersData?.accounts, searchValue]) - const handleAddMember = useCallback((member: Member) => { - onAddAccessSubject(member.id, [DEFAULT_ACCESS_POLICY_ID]) - }, [onAddAccessSubject]) + const handleAddMember = useCallback( + (member: Member) => { + onAddAccessSubject(member.id, [DEFAULT_ACCESS_POLICY_ID]) + }, + [onAddAccessSubject], + ) const handleOpenChange = useCallback((nextOpen: boolean) => { - if (!nextOpen) - setSearchValue('') + if (!nextOpen) setSearchValue('') setOpen(nextOpen) }, []) - const addLabel = t($ => $['operation.add'], { ns: 'common' }) - const addedLabel = t($ => $['operation.added'], { ns: 'common' }) + const addLabel = t(($) => $['operation.add'], { ns: 'common' }) + const addedLabel = t(($) => $['operation.added'], { ns: 'common' }) return ( <Popover open={open} onOpenChange={handleOpenChange}> <PopoverTrigger - render={( - <Button - variant="primary" - size="medium" - > + render={ + <Button variant="primary" size="medium"> <span className="i-ri-add-line size-3.5" aria-hidden /> <span>{addLabel}</span> </Button> - )} + } /> <PopoverContent placement="bottom-end" sideOffset={8} popupClassName="w-[344px] max-w-[calc(100vw-32px)] overflow-hidden bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]" popupProps={{ - 'role': 'dialog', - 'aria-label': t($ => $['accessRule.addMembersTitle'], { ns: 'permission' }), + role: 'dialog', + 'aria-label': t(($) => $['accessRule.addMembersTitle'], { ns: 'permission' }), }} > <div className="p-2 pb-1"> <div className="relative"> - <span className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" aria-hidden="true" /> + <span + className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" + aria-hidden="true" + /> <Input type="search" - aria-label={t($ => $['operation.search'], { ns: 'common' })} + aria-label={t(($) => $['operation.search'], { ns: 'common' })} value={searchValue} - placeholder={t($ => $['placeholder.search'], { ns: 'common' })} + placeholder={t(($) => $['placeholder.search'], { ns: 'common' })} className="h-8 ps-7 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none" onValueChange={setSearchValue} autoComplete="off" @@ -100,79 +99,78 @@ function AddAccessSubjectPopover({ /> </div> </div> - {isLoading - ? ( - <div className="flex h-20 items-center justify-center p-1"> - <Loading type="app" /> - </div> - ) - : availableMembers.length === 0 - ? ( - <div className="px-3 py-6 text-center system-xs-regular text-text-tertiary"> - {t($ => $['accessRule.noAvailableMembers'], { ns: 'permission' })} - </div> - ) - : ( - <ul className="max-h-80 overflow-y-auto p-1"> - {availableMembers.map((member) => { - const isAdded = existingAccountIds.has(member.id) - const isUpdating = updatingAccountId === member.id - const memberName = member.name || member.email + {isLoading ? ( + <div className="flex h-20 items-center justify-center p-1"> + <Loading type="app" /> + </div> + ) : availableMembers.length === 0 ? ( + <div className="px-3 py-6 text-center system-xs-regular text-text-tertiary"> + {t(($) => $['accessRule.noAvailableMembers'], { ns: 'permission' })} + </div> + ) : ( + <ul className="max-h-80 overflow-y-auto p-1"> + {availableMembers.map((member) => { + const isAdded = existingAccountIds.has(member.id) + const isUpdating = updatingAccountId === member.id + const memberName = member.name || member.email - return ( - <li - key={member.id} - className={cn( - 'flex min-h-10 items-center gap-2 rounded-lg py-1 pr-3 pl-2', - !isAdded && 'hover:bg-state-base-hover', - )} - > - <Avatar - avatar={member.avatar_url ?? member.avatar ?? null} - name={memberName} - size="sm" - className={cn('bg-components-icon-bg-blue-solid', isAdded && 'opacity-50')} - /> - <div className={cn('min-w-0 flex-1', isAdded && 'opacity-50')}> - <div className="truncate system-sm-medium text-text-secondary"> - {memberName} - </div> - <div className="truncate system-xs-regular text-text-tertiary"> - {member.email} - </div> - </div> - {isAdded - ? ( - <button - type="button" - disabled - className="shrink-0 cursor-not-allowed border-0 bg-transparent p-0 system-xs-regular text-text-tertiary disabled:opacity-100" - > - {addedLabel} - </button> - ) - : ( - <button - type="button" - disabled={isUpdating} - aria-label={t($ => $['accessRule.addMemberAria'], { ns: 'permission', name: memberName })} - className={cn( - 'flex h-6 shrink-0 items-center rounded-md px-1 system-xs-medium text-text-accent outline-hidden', - 'hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid', - 'disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent', - )} - onClick={() => handleAddMember(member)} - > - {isUpdating - ? <span className="i-ri-loader-2-line size-3.5 animate-spin" aria-hidden /> - : `+ ${addLabel}`} - </button> - )} - </li> - ) - })} - </ul> - )} + return ( + <li + key={member.id} + className={cn( + 'flex min-h-10 items-center gap-2 rounded-lg py-1 pr-3 pl-2', + !isAdded && 'hover:bg-state-base-hover', + )} + > + <Avatar + avatar={member.avatar_url ?? member.avatar ?? null} + name={memberName} + size="sm" + className={cn('bg-components-icon-bg-blue-solid', isAdded && 'opacity-50')} + /> + <div className={cn('min-w-0 flex-1', isAdded && 'opacity-50')}> + <div className="truncate system-sm-medium text-text-secondary"> + {memberName} + </div> + <div className="truncate system-xs-regular text-text-tertiary"> + {member.email} + </div> + </div> + {isAdded ? ( + <button + type="button" + disabled + className="shrink-0 cursor-not-allowed border-0 bg-transparent p-0 system-xs-regular text-text-tertiary disabled:opacity-100" + > + {addedLabel} + </button> + ) : ( + <button + type="button" + disabled={isUpdating} + aria-label={t(($) => $['accessRule.addMemberAria'], { + ns: 'permission', + name: memberName, + })} + className={cn( + 'flex h-6 shrink-0 items-center rounded-md px-1 system-xs-medium text-text-accent outline-hidden', + 'hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid', + 'disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent', + )} + onClick={() => handleAddMember(member)} + > + {isUpdating ? ( + <span className="i-ri-loader-2-line size-3.5 animate-spin" aria-hidden /> + ) : ( + `+ ${addLabel}` + )} + </button> + )} + </li> + ) + })} + </ul> + )} </PopoverContent> </Popover> ) diff --git a/web/app/components/access-rules-editor/index.tsx b/web/app/components/access-rules-editor/index.tsx index 895260da400f37..ab528af33660c5 100644 --- a/web/app/components/access-rules-editor/index.tsx +++ b/web/app/components/access-rules-editor/index.tsx @@ -49,9 +49,12 @@ export default function AccessRulesEditor({ }: AccessRulesEditorProps) { const { t } = useTranslation() const isLoading = isLoadingRules || isLoadingUserAccessSettings - const individualPermissionSettingsTip = t($ => $['accessRule.individualPermissionSettingsTip'], { ns: 'permission' }) + const individualPermissionSettingsTip = t( + ($) => $['accessRule.individualPermissionSettingsTip'], + { ns: 'permission' }, + ) const policyOptions = useMemo(() => { - return rules.map(rule => ({ + return rules.map((rule) => ({ id: rule.policy.id, name: rule.policy.name, })) @@ -67,63 +70,58 @@ export default function AccessRulesEditor({ <div className="flex min-h-8 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-1"> <h2 className="system-sm-semibold text-text-secondary"> - {t($ => $['accessRule.individualPermissionSettings'], { ns: 'permission' })} + {t(($) => $['accessRule.individualPermissionSettings'], { ns: 'permission' })} </h2> <TitleInfotip content={individualPermissionSettingsTip} /> </div> - {onAddAccessSubject - ? ( - <AddAccessSubjectPopover - userAccessSettings={userAccessSettings} - updatingAccountId={updatingAccountId} - onAddAccessSubject={onAddAccessSubject} - /> - ) - : ( - <Button - variant="primary" - size="medium" - disabled - > - <span className="i-ri-add-line size-3.5" aria-hidden /> - <span>{t($ => $['operation.add'], { ns: 'common' })}</span> - </Button> - )} + {onAddAccessSubject ? ( + <AddAccessSubjectPopover + userAccessSettings={userAccessSettings} + updatingAccountId={updatingAccountId} + onAddAccessSubject={onAddAccessSubject} + /> + ) : ( + <Button variant="primary" size="medium" disabled> + <span className="i-ri-add-line size-3.5" aria-hidden /> + <span>{t(($) => $['operation.add'], { ns: 'common' })}</span> + </Button> + )} </div> <section className="overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg"> - <div className={cn('grid items-center gap-4 border-b border-divider-deep px-10 py-4 system-sm-semibold text-text-tertiary', ACCESS_RULE_TABLE_GRID)}> - <div>{t($ => $['accessRule.member'], { ns: 'permission' })}</div> - <div>{t($ => $['accessRule.permission'], { ns: 'permission' })}</div> - <div>{t($ => $['accessRule.actions'], { ns: 'permission' })}</div> + <div + className={cn( + 'grid items-center gap-4 border-b border-divider-deep px-10 py-4 system-sm-semibold text-text-tertiary', + ACCESS_RULE_TABLE_GRID, + )} + > + <div>{t(($) => $['accessRule.member'], { ns: 'permission' })}</div> + <div>{t(($) => $['accessRule.permission'], { ns: 'permission' })}</div> + <div>{t(($) => $['accessRule.actions'], { ns: 'permission' })}</div> </div> - {isLoading - ? ( - <div className="px-4 py-8 text-center"> - <Loading type="app" /> - </div> - ) - : userAccessSettings.length === 0 - ? ( - <div className="px-4 py-8 text-center system-sm-regular text-text-tertiary"> - {t($ => $['accessRule.noUserAccessSettings'], { ns: 'permission' })} - </div> - ) - : ( - <div className="px-4"> - {userAccessSettings.map((setting, index) => ( - <UserAccessPolicyRow - key={setting.account.account_id} - setting={setting} - policyOptions={policyOptions} - disabled={updatingAccountId === setting.account.account_id} - isMaintainer={maintainerId === setting.account.account_id} - className={cn(index > 0 && 'border-t border-divider-subtle')} - onChange={onUserAccessPoliciesChange} - onRemove={onRemoveAccessPolicyMemberBinding} - /> - ))} - </div> - )} + {isLoading ? ( + <div className="px-4 py-8 text-center"> + <Loading type="app" /> + </div> + ) : userAccessSettings.length === 0 ? ( + <div className="px-4 py-8 text-center system-sm-regular text-text-tertiary"> + {t(($) => $['accessRule.noUserAccessSettings'], { ns: 'permission' })} + </div> + ) : ( + <div className="px-4"> + {userAccessSettings.map((setting, index) => ( + <UserAccessPolicyRow + key={setting.account.account_id} + setting={setting} + policyOptions={policyOptions} + disabled={updatingAccountId === setting.account.account_id} + isMaintainer={maintainerId === setting.account.account_id} + className={cn(index > 0 && 'border-t border-divider-subtle')} + onChange={onUserAccessPoliciesChange} + onRemove={onRemoveAccessPolicyMemberBinding} + /> + ))} + </div> + )} </section> </div> ) diff --git a/web/app/components/access-rules-editor/open-scope-confirm-dialog.tsx b/web/app/components/access-rules-editor/open-scope-confirm-dialog.tsx index 20bb8e2003bd3f..e164b1354b9180 100644 --- a/web/app/components/access-rules-editor/open-scope-confirm-dialog.tsx +++ b/web/app/components/access-rules-editor/open-scope-confirm-dialog.tsx @@ -18,37 +18,35 @@ type OpenScopeConfirmDialogProps = { onConfirm: () => void } -function OpenScopeConfirmDialog({ - open, - onCancel, - onConfirm, -}: OpenScopeConfirmDialogProps) { +function OpenScopeConfirmDialog({ open, onCancel, onConfirm }: OpenScopeConfirmDialogProps) { const { t } = useTranslation() - const handleOpenChange = useCallback((nextOpen: boolean) => { - if (!nextOpen) - onCancel() - }, [onCancel]) + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) onCancel() + }, + [onCancel], + ) return ( - <AlertDialog - open={open} - onOpenChange={handleOpenChange} - > + <AlertDialog open={open} onOpenChange={handleOpenChange}> <AlertDialogContent className="w-120 overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-lg"> <div className="flex flex-col items-start gap-2 self-stretch p-6 pb-4"> <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['accessRule.changeOpenScopeTitle'], { ns: 'permission' })} + {t(($) => $['accessRule.changeOpenScopeTitle'], { ns: 'permission' })} </AlertDialogTitle> - <AlertDialogDescription render={<div />} className="system-md-regular text-text-secondary"> - {t($ => $['accessRule.changeOpenScopeDescription'], { ns: 'permission' })} + <AlertDialogDescription + render={<div />} + className="system-md-regular text-text-secondary" + > + {t(($) => $['accessRule.changeOpenScopeDescription'], { ns: 'permission' })} </AlertDialogDescription> </div> <AlertDialogActions className="gap-2 p-6"> <AlertDialogCancelButton variant="secondary"> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={onConfirm}> - {t($ => $['operation.change'], { ns: 'common' })} + {t(($) => $['operation.change'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/access-rules-editor/open-scope-section.tsx b/web/app/components/access-rules-editor/open-scope-section.tsx index f2b1e7545080e3..8c24653dc5089e 100644 --- a/web/app/components/access-rules-editor/open-scope-section.tsx +++ b/web/app/components/access-rules-editor/open-scope-section.tsx @@ -13,29 +13,28 @@ type ResourceOpenScopeSectionProps = { onChange?: (openScope: ResourceOpenScope) => void } -function ResourceOpenScopeSection({ - value, - disabled, - onChange, -}: ResourceOpenScopeSectionProps) { +function ResourceOpenScopeSection({ value, disabled, onChange }: ResourceOpenScopeSectionProps) { const { t } = useTranslation() const [pendingOpenScope, setPendingOpenScope] = useState<ResourceOpenScope | null>(null) - const resourceOpenScopeDescription = t($ => $['accessRule.resourceOpenScopeDescription'], { ns: 'permission' }) + const resourceOpenScopeDescription = t(($) => $['accessRule.resourceOpenScopeDescription'], { + ns: 'permission', + }) - const handleRequestChange = useCallback((nextOpenScope: ResourceOpenScope) => { - if (nextOpenScope === value) - return + const handleRequestChange = useCallback( + (nextOpenScope: ResourceOpenScope) => { + if (nextOpenScope === value) return - setPendingOpenScope(nextOpenScope) - }, [value]) + setPendingOpenScope(nextOpenScope) + }, + [value], + ) const handleCancelChange = useCallback(() => { setPendingOpenScope(null) }, []) const handleConfirmChange = useCallback(() => { - if (!pendingOpenScope) - return + if (!pendingOpenScope) return onChange?.(pendingOpenScope) setPendingOpenScope(null) @@ -45,7 +44,7 @@ function ResourceOpenScopeSection({ <section className="flex flex-col gap-2"> <div className="flex min-w-0 items-center gap-1"> <h2 className="system-sm-semibold text-text-secondary"> - {t($ => $['accessRule.resourceOpenScope'], { ns: 'permission' })} + {t(($) => $['accessRule.resourceOpenScope'], { ns: 'permission' })} </h2> <TitleInfotip content={resourceOpenScopeDescription} /> </div> @@ -54,24 +53,28 @@ function ResourceOpenScopeSection({ value="all" selected={value === 'all'} disabled={disabled || !onChange} - title={t($ => $['accessRule.allPermittedMembers'], { ns: 'permission' })} - description={t($ => $['accessRule.allPermittedMembersDescription'], { ns: 'permission' })} + title={t(($) => $['accessRule.allPermittedMembers'], { ns: 'permission' })} + description={t(($) => $['accessRule.allPermittedMembersDescription'], { + ns: 'permission', + })} onChange={onChange ? handleRequestChange : undefined} /> <OpenScopeOption value="only_me" selected={value === 'only_me'} disabled={disabled || !onChange} - title={t($ => $['accessRule.onlyMe'], { ns: 'permission' })} - description={t($ => $['accessRule.onlyMeDescription'], { ns: 'permission' })} + title={t(($) => $['accessRule.onlyMe'], { ns: 'permission' })} + description={t(($) => $['accessRule.onlyMeDescription'], { ns: 'permission' })} onChange={onChange ? handleRequestChange : undefined} /> <OpenScopeOption value="specific" selected={value === 'specific'} disabled={disabled || !onChange} - title={t($ => $['accessRule.specificMembersOnly'], { ns: 'permission' })} - description={t($ => $['accessRule.specificMembersOnlyDescription'], { ns: 'permission' })} + title={t(($) => $['accessRule.specificMembersOnly'], { ns: 'permission' })} + description={t(($) => $['accessRule.specificMembersOnlyDescription'], { + ns: 'permission', + })} onChange={onChange ? handleRequestChange : undefined} /> </div> diff --git a/web/app/components/access-rules-editor/user-access-policy-row.tsx b/web/app/components/access-rules-editor/user-access-policy-row.tsx index 4f4e3636999c94..efa817549c9504 100644 --- a/web/app/components/access-rules-editor/user-access-policy-row.tsx +++ b/web/app/components/access-rules-editor/user-access-policy-row.tsx @@ -47,25 +47,32 @@ function UserAccessPolicyRow({ const selectedPolicyId = selectedAccessPolicyId ?? DEFAULT_ACCESS_POLICY_ID const isPolicySelectDisabled = disabled || isMaintainer || !onChange const isRemoveDisabled = disabled || isMaintainer || !onRemove || !selectedAccessPolicyId - const defaultAccessPolicyName = t($ => $['accessRule.defaultPermission'], { ns: 'permission' }) + const defaultAccessPolicyName = t(($) => $['accessRule.defaultPermission'], { ns: 'permission' }) const accountEmail = setting.account.email || setting.account.account_name - const handlePolicyChange = useCallback((nextPolicyId: string | null) => { - if (isPolicySelectDisabled || !nextPolicyId || nextPolicyId === selectedPolicyId) - return + const handlePolicyChange = useCallback( + (nextPolicyId: string | null) => { + if (isPolicySelectDisabled || !nextPolicyId || nextPolicyId === selectedPolicyId) return - onChange?.(accountId, [nextPolicyId]) - }, [accountId, isPolicySelectDisabled, onChange, selectedPolicyId]) + onChange?.(accountId, [nextPolicyId]) + }, + [accountId, isPolicySelectDisabled, onChange, selectedPolicyId], + ) const handleRemove = useCallback(() => { - if (isRemoveDisabled || !selectedAccessPolicyId) - return + if (isRemoveDisabled || !selectedAccessPolicyId) return onRemove?.(accountId, selectedAccessPolicyId) }, [accountId, isRemoveDisabled, onRemove, selectedAccessPolicyId]) return ( - <div className={cn('grid min-h-19 items-center gap-4 px-6 py-4', ACCESS_RULE_TABLE_GRID, className)}> + <div + className={cn( + 'grid min-h-19 items-center gap-4 px-6 py-4', + ACCESS_RULE_TABLE_GRID, + className, + )} + > <div className="flex min-w-0 items-center gap-3"> <Avatar avatar={setting.account.avatar ?? null} @@ -80,21 +87,19 @@ function UserAccessPolicyRow({ </span> {isMaintainer && ( <span className="max-w-24 shrink-0 truncate rounded-[5px] border border-text-accent-secondary px-1 py-0.5 system-2xs-medium-uppercase text-text-accent-secondary"> - {t($ => $['accessRule.maintainer'], { ns: 'permission' })} + {t(($) => $['accessRule.maintainer'], { ns: 'permission' })} </span> )} </div> - <p className="truncate system-xs-regular text-text-tertiary"> - {accountEmail} - </p> + <p className="truncate system-xs-regular text-text-tertiary">{accountEmail}</p> </div> </div> - <Select - value={selectedPolicyId} - onValueChange={handlePolicyChange} - > + <Select value={selectedPolicyId} onValueChange={handlePolicyChange}> <SelectTrigger - aria-label={t($ => $['accessRule.exceptionPermissionFor'], { ns: 'permission', name: setting.account.account_name })} + aria-label={t(($) => $['accessRule.exceptionPermissionFor'], { + ns: 'permission', + name: setting.account.account_name, + })} size="small" disabled={isPolicySelectDisabled} className="w-36" @@ -110,7 +115,7 @@ function UserAccessPolicyRow({ <SelectItemText>{defaultAccessPolicyName}</SelectItemText> <SelectItemIndicator /> </SelectItem> - {policyOptions.map(policy => ( + {policyOptions.map((policy) => ( <SelectItem key={policy.id} value={policy.id}> <SelectItemText>{policy.name}</SelectItemText> <SelectItemIndicator /> @@ -124,7 +129,7 @@ function UserAccessPolicyRow({ className="w-fit rounded-md border-none bg-transparent p-0 text-left system-xs-medium text-text-destructive outline-hidden hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:no-underline" onClick={handleRemove} > - {t($ => $['operation.remove'], { ns: 'common' })} + {t(($) => $['operation.remove'], { ns: 'common' })} </button> </div> ) diff --git a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx index 5da53a24e28f0f..7e0a658e95a5da 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx @@ -15,24 +15,26 @@ const mockAppContextState = vi.hoisted(() => ({ }, })) -const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, { - systemFeatures: { - rbac_enabled: mockIsRbacEnabled, - }, -}) +const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => + renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, + }) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({ - appDetail: { - id: 'app-1', - name: 'Test App', - mode: mockAppMode, - icon: '🤖', - icon_type: 'emoji', - icon_background: '#fff', - permission_keys: mockAppPermissionKeys, - }, - }), + useStore: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + appDetail: { + id: 'app-1', + name: 'Test App', + mode: mockAppMode, + icon: '🤖', + icon_type: 'emoji', + icon_background: '#fff', + permission_keys: mockAppPermissionKeys, + }, + }), })) vi.mock('@/context/account-state', async (importOriginal) => { @@ -57,7 +59,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -66,7 +69,9 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('../app-info', () => ({ - AppInfoView: ({ expand }: { expand: boolean }) => <div data-testid="app-info" data-expand={expand} />, + AppInfoView: ({ expand }: { expand: boolean }) => ( + <div data-testid="app-info" data-expand={expand} /> + ), })) vi.mock('../app-info/use-app-info-actions', () => ({ @@ -78,8 +83,20 @@ vi.mock('../../base/divider', () => ({ })) vi.mock('../nav-link', () => ({ - default: ({ name, href, mode, iconMap }: { name: string, href: string, mode: string, iconMap: { normal: { displayName?: string } } }) => ( - <a href={href} data-mode={mode} data-icon={iconMap.normal.displayName}>{name}</a> + default: ({ + name, + href, + mode, + iconMap, + }: { + name: string + href: string + mode: string + iconMap: { normal: { displayName?: string } } + }) => ( + <a href={href} data-mode={mode} data-icon={iconMap.normal.displayName}> + {name} + </a> ), })) @@ -102,9 +119,14 @@ describe('AppDetailSection', () => { render(<AppDetailSection />) // Assert - expect(screen.getByRole('link', { name: 'common.appMenus.overview' })).toHaveAttribute('href', '/app/app-1/overview') + expect(screen.getByRole('link', { name: 'common.appMenus.overview' })).toHaveAttribute( + 'href', + '/app/app-1/overview', + ) expect(screen.queryByRole('link', { name: 'common.appMenus.logs' })).not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'common.appMenus.annotations' }), + ).not.toBeInTheDocument() expect(screen.queryAllByRole('separator')).toHaveLength(0) }) @@ -117,10 +139,21 @@ describe('AppDetailSection', () => { render(<AppDetailSection />) // Assert - expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs') - expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('href', '/app/app-1/annotations') - expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('data-icon', 'Annotations') - expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute( + 'href', + '/app/app-1/logs', + ) + expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute( + 'href', + '/app/app-1/annotations', + ) + expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute( + 'data-icon', + 'Annotations', + ) + expect( + screen.queryByRole('link', { name: 'common.appMenus.overview' }), + ).not.toBeInTheDocument() }) it('should render dividers before logs and after annotations for chat apps', () => { @@ -144,8 +177,13 @@ describe('AppDetailSection', () => { render(<AppDetailSection />) // Assert - expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs') - expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute( + 'href', + '/app/app-1/logs', + ) + expect( + screen.queryByRole('link', { name: 'common.appMenus.annotations' }), + ).not.toBeInTheDocument() }) it('should render dividers before and after logs for workflow apps', () => { @@ -169,8 +207,13 @@ describe('AppDetailSection', () => { render(<AppDetailSection />) // Assert - expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs') - expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute( + 'href', + '/app/app-1/logs', + ) + expect( + screen.queryByRole('link', { name: 'common.appMenus.annotations' }), + ).not.toBeInTheDocument() }) it('should not render log and annotation group dividers without log and annotation permission', () => { @@ -183,7 +226,9 @@ describe('AppDetailSection', () => { // Assert expect(screen.queryAllByRole('separator')).toHaveLength(0) expect(screen.queryByRole('link', { name: 'common.appMenus.logs' })).not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'common.appMenus.annotations' }), + ).not.toBeInTheDocument() expect(screen.getByRole('link', { name: 'common.appMenus.overview' })).toBeInTheDocument() }) @@ -195,9 +240,17 @@ describe('AppDetailSection', () => { render(<AppDetailSection />) // Assert - expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs') - expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('href', '/app/app-1/annotations') - expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute( + 'href', + '/app/app-1/logs', + ) + expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute( + 'href', + '/app/app-1/annotations', + ) + expect( + screen.queryByRole('link', { name: 'common.appMenus.overview' }), + ).not.toBeInTheDocument() expect(screen.getAllByRole('separator')).toHaveLength(2) }) @@ -209,10 +262,17 @@ describe('AppDetailSection', () => { render(<AppDetailSection />) // Assert - expect(screen.getByRole('link', { name: 'common.appMenus.promptEng' })).toHaveAttribute('href', '/app/app-1/configuration') + expect(screen.getByRole('link', { name: 'common.appMenus.promptEng' })).toHaveAttribute( + 'href', + '/app/app-1/configuration', + ) expect(screen.queryByRole('link', { name: 'common.appMenus.logs' })).not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'common.appMenus.annotations' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'common.appMenus.overview' }), + ).not.toBeInTheDocument() }) it('should hide the layout navigation when layout access is missing', () => { @@ -220,7 +280,9 @@ describe('AppDetailSection', () => { render(<AppDetailSection />) // Assert - expect(screen.queryByRole('link', { name: 'common.appMenus.promptEng' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'common.appMenus.promptEng' }), + ).not.toBeInTheDocument() }) it('should render resource access navigation when app access config permission is granted', () => { @@ -231,8 +293,13 @@ describe('AppDetailSection', () => { render(<AppDetailSection />) // Assert - expect(screen.getByRole('link', { name: 'common.settings.resourceAccess' })).toHaveAttribute('href', '/app/app-1/access-config') - expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'common.settings.resourceAccess' })).toHaveAttribute( + 'href', + '/app/app-1/access-config', + ) + expect( + screen.queryByRole('link', { name: 'common.appMenus.overview' }), + ).not.toBeInTheDocument() }) it('should hide resource access navigation when app access config permission is missing', () => { @@ -240,7 +307,9 @@ describe('AppDetailSection', () => { render(<AppDetailSection />) // Assert - expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'common.settings.resourceAccess' }), + ).not.toBeInTheDocument() }) it('should hide resource access navigation when RBAC is disabled', () => { @@ -252,7 +321,9 @@ describe('AppDetailSection', () => { render(<AppDetailSection />) // Assert - expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'common.settings.resourceAccess' }), + ).not.toBeInTheDocument() }) it('should pass collapsed mode to app info and navigation links when collapsed', () => { @@ -264,7 +335,10 @@ describe('AppDetailSection', () => { // Assert expect(screen.getByTestId('app-info')).toHaveAttribute('data-expand', 'false') - expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('data-mode', 'collapse') + expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute( + 'data-mode', + 'collapse', + ) }) it('should scope app info state to the app instead of the current path', () => { diff --git a/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx index 1ae260cbb00929..24f76bb2962466 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx @@ -5,8 +5,22 @@ import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms' import AppDetailTop from '../app-detail-top' vi.mock('../toggle-button', () => ({ - default: ({ expand, handleToggle, icon }: { expand: boolean, handleToggle: () => void, icon?: ReactNode }) => ( - <button type="button" data-testid="toggle-button" data-expand={expand} data-has-icon={Boolean(icon)} onClick={handleToggle}> + default: ({ + expand, + handleToggle, + icon, + }: { + expand: boolean + handleToggle: () => void + icon?: ReactNode + }) => ( + <button + type="button" + data-testid="toggle-button" + data-expand={expand} + data-has-icon={Boolean(icon)} + onClick={handleToggle} + > Toggle </button> ), @@ -21,11 +35,7 @@ function GotoAnythingOpenProbe() { const renderWithGotoAnythingStore = (ui: ReactNode) => { const store = createStore() - return render( - <JotaiProvider store={store}> - {ui} - </JotaiProvider>, - ) + return render(<JotaiProvider store={store}>{ui}</JotaiProvider>) } describe('AppDetailTop', () => { diff --git a/web/app/components/app-sidebar/__tests__/basic.spec.tsx b/web/app/components/app-sidebar/__tests__/basic.spec.tsx index 1abb56d7c61059..5beaf1daa61d2f 100644 --- a/web/app/components/app-sidebar/__tests__/basic.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/basic.spec.tsx @@ -4,11 +4,18 @@ import AppBasic from '../basic' vi.mock('@/app/components/base/icons/src/vender/workflow', () => ({ ApiAggregate: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="api-icon" {...props} />, - WindowCursor: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="webapp-icon" {...props} />, + WindowCursor: (props: React.SVGProps<SVGSVGElement>) => ( + <svg data-testid="webapp-icon" {...props} /> + ), })) vi.mock('../../base/app-icon', () => ({ - default: ({ icon, background, innerIcon, className }: { + default: ({ + icon, + background, + innerIcon, + className, + }: { icon?: string background?: string innerIcon?: React.ReactNode diff --git a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx index 7f4e79215154ea..5fd76d630a736e 100644 --- a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx @@ -15,11 +15,12 @@ const mockAppContextState = vi.hoisted(() => ({ }, })) -const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, { - systemFeatures: { - rbac_enabled: mockIsRbacEnabled, - }, -}) +const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => + renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, + }) vi.mock('@/next/navigation', () => ({ usePathname: () => mockPathname, @@ -47,7 +48,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -57,54 +59,56 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ })) vi.mock('../dataset-info', () => ({ - default: ({ expand }: { expand: boolean }) => <div data-testid="dataset-info" data-expand={expand} />, + default: ({ expand }: { expand: boolean }) => ( + <div data-testid="dataset-info" data-expand={expand} /> + ), })) vi.mock('../nav-link', () => ({ - default: ({ name, href, disabled }: { name: string, href: string, disabled?: boolean }) => { - if (disabled) - return <button disabled>{name}</button> + default: ({ name, href, disabled }: { name: string; href: string; disabled?: boolean }) => { + if (disabled) return <button disabled>{name}</button> return <a href={href}>{name}</a> }, })) vi.mock('../../datasets/extra-info', () => ({ - default: ({ expand, documentCount }: { expand: boolean, documentCount?: number }) => ( + default: ({ expand, documentCount }: { expand: boolean; documentCount?: number }) => ( <div data-testid="extra-info" data-expand={expand} data-document-count={documentCount} /> ), })) -const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ - id: 'dataset-1', - name: 'Camera Technical Spec', - description: '', - provider: 'internal', - icon_info: { - icon: '📙', - icon_type: 'emoji', - icon_background: '#F0F9FF', - icon_url: '', - }, - doc_form: 'hierarchical_model', - indexing_technique: 'high_quality', - document_count: 120, - runtime_mode: 'general', - retrieval_model_dict: { - search_method: 'semantic_search', - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', +const createDataset = (overrides: Partial<DataSet> = {}): DataSet => + ({ + id: 'dataset-1', + name: 'Camera Technical Spec', + description: '', + provider: 'internal', + icon_info: { + icon: '📙', + icon_type: 'emoji', + icon_background: '#F0F9FF', + icon_url: '', }, - top_k: 5, - score_threshold_enabled: false, - score_threshold: 0, - }, - enable_api: true, - permission_keys: [DatasetACLPermission.Edit], - ...overrides, -} as DataSet) + doc_form: 'hierarchical_model', + indexing_technique: 'high_quality', + document_count: 120, + runtime_mode: 'general', + retrieval_model_dict: { + search_method: 'semantic_search', + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + enable_api: true, + permission_keys: [DatasetACLPermission.Edit], + ...overrides, + }) as DataSet describe('DatasetDetailSection', () => { beforeEach(() => { @@ -145,13 +149,18 @@ describe('DatasetDetailSection', () => { render(<DatasetDetailSection expand />) - expect(screen.getByRole('link', { name: 'common.settings.resourceAccess' })).toHaveAttribute('href', '/datasets/dataset-1/access-config') + expect(screen.getByRole('link', { name: 'common.settings.resourceAccess' })).toHaveAttribute( + 'href', + '/datasets/dataset-1/access-config', + ) }) it('should hide resource access navigation when dataset access config permission is missing', () => { render(<DatasetDetailSection expand />) - expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'common.settings.resourceAccess' }), + ).not.toBeInTheDocument() }) it('should hide resource access navigation when RBAC is disabled', () => { @@ -162,7 +171,9 @@ describe('DatasetDetailSection', () => { render(<DatasetDetailSection expand />) - expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'common.settings.resourceAccess' }), + ).not.toBeInTheDocument() }) it('should render hit testing navigation as a link when retrieval recall permission is granted', () => { @@ -172,13 +183,18 @@ describe('DatasetDetailSection', () => { render(<DatasetDetailSection expand />) - expect(screen.getByRole('link', { name: 'common.datasetMenus.hitTesting' })).toHaveAttribute('href', '/datasets/dataset-1/hitTesting') + expect(screen.getByRole('link', { name: 'common.datasetMenus.hitTesting' })).toHaveAttribute( + 'href', + '/datasets/dataset-1/hitTesting', + ) }) it('should disable hit testing navigation when retrieval recall permission is missing', () => { render(<DatasetDetailSection expand />) expect(screen.getByRole('button', { name: 'common.datasetMenus.hitTesting' })).toBeDisabled() - expect(screen.queryByRole('link', { name: 'common.datasetMenus.hitTesting' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'common.datasetMenus.hitTesting' }), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx index 1f23bf5c334a4c..7b013d54c38d98 100644 --- a/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx @@ -5,8 +5,22 @@ import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms' import DatasetDetailTop from '../dataset-detail-top' vi.mock('../toggle-button', () => ({ - default: ({ expand, handleToggle, icon }: { expand: boolean, handleToggle: () => void, icon?: ReactNode }) => ( - <button type="button" data-testid="toggle-button" data-expand={expand} data-has-icon={Boolean(icon)} onClick={handleToggle}> + default: ({ + expand, + handleToggle, + icon, + }: { + expand: boolean + handleToggle: () => void + icon?: ReactNode + }) => ( + <button + type="button" + data-testid="toggle-button" + data-expand={expand} + data-has-icon={Boolean(icon)} + onClick={handleToggle} + > Toggle </button> ), @@ -21,11 +35,7 @@ function GotoAnythingOpenProbe() { const renderWithGotoAnythingStore = (ui: ReactNode) => { const store = createStore() - return render( - <JotaiProvider store={store}> - {ui} - </JotaiProvider>, - ) + return render(<JotaiProvider store={store}>{ui}</JotaiProvider>) } describe('DatasetDetailTop', () => { @@ -37,7 +47,10 @@ describe('DatasetDetailTop', () => { renderWithGotoAnythingStore(<DatasetDetailTop />) expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/') - expect(screen.getByRole('link', { name: 'common.menus.datasets' })).toHaveAttribute('href', '/datasets') + expect(screen.getByRole('link', { name: 'common.menus.datasets' })).toHaveAttribute( + 'href', + '/datasets', + ) expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument() }) diff --git a/web/app/components/app-sidebar/__tests__/sidebar-animation-issues.spec.tsx b/web/app/components/app-sidebar/__tests__/sidebar-animation-issues.spec.tsx index fef65fcad3fff0..4d9c054e294407 100644 --- a/web/app/components/app-sidebar/__tests__/sidebar-animation-issues.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/sidebar-animation-issues.spec.tsx @@ -2,13 +2,10 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' // Simple Mock Components that reproduce the exact UI issues -const MockNavLink = ({ name, mode }: { name: string, mode: string }) => { +const MockNavLink = ({ name, mode }: { name: string; mode: string }) => { return ( <a - className={` - group flex h-9 items-center rounded-md py-2 text-sm font-normal - ${mode === 'expand' ? 'px-3' : 'px-2.5'} - `} + className={`group flex h-9 items-center rounded-md py-2 text-sm font-normal ${mode === 'expand' ? 'px-3' : 'px-2.5'} `} data-testid={`nav-link-${name}`} data-mode={mode} > @@ -23,13 +20,16 @@ const MockNavLink = ({ name, mode }: { name: string, mode: string }) => { ) } -const MockSidebarToggleButton = ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => { +const MockSidebarToggleButton = ({ + expand, + onToggle, +}: { + expand: boolean + onToggle: () => void +}) => { return ( <div - className={` - flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all - ${expand ? 'w-[216px]' : 'w-14'} - `} + className={`flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all ${expand ? 'w-[216px]' : 'w-14'} `} data-testid="sidebar-container" > {/* Top section with variable padding - reproduces issue #1 */} @@ -46,10 +46,7 @@ const MockSidebarToggleButton = ({ expand, onToggle }: { expand: boolean, onTogg </nav> {/* Toggle button section with consistent padding - issue #1 FIXED */} - <div - className="shrink-0 px-4 py-3" - data-testid="toggle-section" - > + <div className="shrink-0 px-4 py-3" data-testid="toggle-section"> <button type="button" className="flex h-6 w-6 cursor-pointer items-center justify-center" @@ -68,9 +65,14 @@ const MockAppInfo = ({ expand }: { expand: boolean }) => { <div data-testid="app-info" data-expand={expand}> <button type="button" className="block w-full"> {/* Container with layout mode switching - reproduces issue #3 */} - <div className={`flex rounded-lg ${expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1'}`}> + <div + className={`flex rounded-lg ${expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1'}`} + > {/* Icon container with justify-between to flex-col switch - reproduces issue #3 */} - <div className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`} data-testid="icon-container"> + <div + className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`} + data-testid="icon-container" + > {/* Icon with size changes - reproduces issue #3 */} <div data-testid="app-icon" @@ -85,16 +87,14 @@ const MockAppInfo = ({ expand }: { expand: boolean }) => { Icon </div> <div className="flex items-center justify-center rounded-md p-0.5"> - <div className="flex h-5 w-5 items-center justify-center"> - ⚙️ - </div> + <div className="flex h-5 w-5 items-center justify-center">⚙️</div> </div> </div> {/* Text that appears/disappears conditionally */} {expand && ( <div className="flex flex-col items-start gap-1"> <div className="flex w-full"> - <div className="system-md-semibold truncate text-text-secondary">Test App</div> + <div className="truncate system-md-semibold text-text-secondary">Test App</div> </div> <div className="system-2xs-medium-uppercase text-text-tertiary">chatflow</div> </div> @@ -128,7 +128,9 @@ describe('Sidebar Animation Issues Reproduction', () => { expanded = !expanded } - const { rerender } = render(<MockSidebarToggleButton expand={false} onToggle={handleToggle} />) + const { rerender } = render( + <MockSidebarToggleButton expand={false} onToggle={handleToggle} />, + ) // Check collapsed state padding const toggleSection = screen.getByTestId('toggle-section') @@ -147,7 +149,9 @@ describe('Sidebar Animation Issues Reproduction', () => { it('should verify sidebar width animation is working correctly', () => { const handleToggle = vi.fn() - const { rerender } = render(<MockSidebarToggleButton expand={false} onToggle={handleToggle} />) + const { rerender } = render( + <MockSidebarToggleButton expand={false} onToggle={handleToggle} />, + ) const container = screen.getByTestId('sidebar-container') diff --git a/web/app/components/app-sidebar/__tests__/text-squeeze-fix-verification.spec.tsx b/web/app/components/app-sidebar/__tests__/text-squeeze-fix-verification.spec.tsx index b8a06e6697ffd6..2b204a6eaf96dc 100644 --- a/web/app/components/app-sidebar/__tests__/text-squeeze-fix-verification.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/text-squeeze-fix-verification.spec.tsx @@ -22,13 +22,12 @@ const TestNavLink = ({ mode }: { mode: 'expand' | 'collapse' }) => { return ( <div className="nav-link-container"> - <div className={`flex h-9 items-center rounded-md py-2 text-sm font-normal ${ - mode === 'expand' ? 'px-3' : 'px-2.5' - }`} + <div + className={`flex h-9 items-center rounded-md py-2 text-sm font-normal ${ + mode === 'expand' ? 'px-3' : 'px-2.5' + }`} > - <div className={`h-4 w-4 shrink-0 ${mode === 'expand' ? 'mr-2' : 'mr-0'}`}> - Icon - </div> + <div className={`h-4 w-4 shrink-0 ${mode === 'expand' ? 'mr-2' : 'mr-0'}`}>Icon</div> <span className={`whitespace-nowrap transition-all duration-200 ease-in-out ${ mode === 'expand' @@ -53,22 +52,24 @@ const TestAppInfo = ({ expand }: { expand: boolean }) => { return ( <div className="app-info-container"> - <div className={`flex rounded-lg ${expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1'}`}> - <div className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`}> + <div + className={`flex rounded-lg ${expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1'}`} + > + <div + className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`} + > <div className="app-icon">AppIcon</div> <div className="dashboard-icon">Dashboard</div> </div> <div className={`flex flex-col items-start gap-1 transition-all duration-200 ease-in-out ${ - expand - ? 'w-auto opacity-100' - : 'pointer-events-none w-0 overflow-hidden opacity-0' + expand ? 'w-auto opacity-100' : 'pointer-events-none w-0 overflow-hidden opacity-0' }`} data-testid="app-text-container" > <div className="flex w-full"> <div - className="system-md-semibold truncate whitespace-nowrap text-text-secondary" + className="truncate system-md-semibold whitespace-nowrap text-text-secondary" data-testid="app-name" > {appDetail.name} diff --git a/web/app/components/app-sidebar/app-detail-section.tsx b/web/app/components/app-sidebar/app-detail-section.tsx index 51b48752258675..e0e47df6ef80e2 100644 --- a/web/app/components/app-sidebar/app-detail-section.tsx +++ b/web/app/components/app-sidebar/app-detail-section.tsx @@ -40,10 +40,7 @@ type AppDetailNavItem = { } const AnnotationNavIcon = ({ className, ...props }: ComponentProps<typeof Annotations>) => ( - <Annotations - {...props} - className={cn(className, 'size-4')} - /> + <Annotations {...props} className={cn(className, 'size-4')} /> ) AnnotationNavIcon.displayName = 'Annotations' @@ -70,27 +67,26 @@ type AppDetailSectionProps = { expand?: boolean } -const AppDetailSection = ({ - expand = true, -}: AppDetailSectionProps) => { +const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => { const { t } = useTranslation() const pathname = usePathname() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled - const appDetail = useStore(state => state.appDetail) + const appDetail = useStore((state) => state.appDetail) const appInfoActions = useAppInfoActions({ resetKey: appDetail?.id, }) const navigation = useMemo<AppDetailNavItem[]>(() => { - if (!appDetail) - return [] + if (!appDetail) return [] const appId = appDetail.id - const isWorkflowApp = appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT - const supportsAnnotations = appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION + const isWorkflowApp = + appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT + const supportsAnnotations = + appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, { currentUserId, resourceMaintainer: appDetail.maintainer, @@ -100,61 +96,65 @@ const AppDetailSection = ({ return [ ...(appACLCapabilities.canAccessLayout - ? [{ - name: t($ => $['appMenus.promptEng'], { ns: 'common' }), - href: `/app/${appId}/${isWorkflowApp ? 'workflow' : 'configuration'}`, - icon: RiTerminalWindowLine, - selectedIcon: RiTerminalWindowFill, - }] - : [] - ), + ? [ + { + name: t(($) => $['appMenus.promptEng'], { ns: 'common' }), + href: `/app/${appId}/${isWorkflowApp ? 'workflow' : 'configuration'}`, + icon: RiTerminalWindowLine, + selectedIcon: RiTerminalWindowFill, + }, + ] + : []), { - name: t($ => $['appMenus.apiAccess'], { ns: 'common' }), + name: t(($) => $['appMenus.apiAccess'], { ns: 'common' }), href: `/app/${appId}/develop`, icon: RiTerminalBoxLine, selectedIcon: RiTerminalBoxFill, }, ...(appACLCapabilities.canAccessLogAndAnnotation - ? [{ - name: t($ => $['appMenus.logs'], { ns: 'common' }), - href: `/app/${appId}/logs`, - icon: RiFileList3Line, - selectedIcon: RiFileList3Fill, - }] - : [] - ), + ? [ + { + name: t(($) => $['appMenus.logs'], { ns: 'common' }), + href: `/app/${appId}/logs`, + icon: RiFileList3Line, + selectedIcon: RiFileList3Fill, + }, + ] + : []), ...(appACLCapabilities.canAccessLogAndAnnotation && supportsAnnotations - ? [{ - name: t($ => $['appMenus.annotations'], { ns: 'common' }), - href: `/app/${appId}/annotations`, - icon: AnnotationNavIcon, - selectedIcon: AnnotationNavIcon, - }] - : [] - ), + ? [ + { + name: t(($) => $['appMenus.annotations'], { ns: 'common' }), + href: `/app/${appId}/annotations`, + icon: AnnotationNavIcon, + selectedIcon: AnnotationNavIcon, + }, + ] + : []), ...(appACLCapabilities.canMonitor - ? [{ - name: t($ => $['appMenus.overview'], { ns: 'common' }), - href: `/app/${appId}/overview`, - icon: RiDashboard2Line, - selectedIcon: RiDashboard2Fill, - }] - : [] - ), + ? [ + { + name: t(($) => $['appMenus.overview'], { ns: 'common' }), + href: `/app/${appId}/overview`, + icon: RiDashboard2Line, + selectedIcon: RiDashboard2Fill, + }, + ] + : []), ...(appACLCapabilities.canAccessConfig - ? [{ - name: t($ => $['settings.resourceAccess'], { ns: 'common' }), - href: `/app/${appId}/access-config`, - icon: RiLock2Line, - selectedIcon: RiLock2Fill, - }] - : [] - ), + ? [ + { + name: t(($) => $['settings.resourceAccess'], { ns: 'common' }), + href: `/app/${appId}/access-config`, + icon: RiLock2Line, + selectedIcon: RiLock2Fill, + }, + ] + : []), ] }, [appDetail, t, currentUserId, workspacePermissionKeys, isRbacEnabled]) - if (!appDetail) - return null + if (!appDetail) return null const hasLogsNavigation = navigation.some(isLogsNavItem) const hasAnnotationsNavigation = navigation.some(isAnnotationsNavItem) @@ -171,15 +171,15 @@ const AppDetailSection = ({ </div> )} <div className="px-1 py-2"> - <AppInfoView - expand={expand} - actions={appInfoActions} - /> + <AppInfoView expand={expand} actions={appInfoActions} /> </div> <nav className={cn('flex flex-col gap-y-0.5 py-1', expand ? 'px-1' : 'px-3')}> {navigation.map((item) => { - const shouldRenderDividerBefore = isLogsNavItem(item) || (!hasLogsNavigation && isAnnotationsNavItem(item)) - const shouldRenderDividerAfter = hasAnnotationsNavigation ? isAnnotationsNavItem(item) : isLogsNavItem(item) + const shouldRenderDividerBefore = + isLogsNavItem(item) || (!hasLogsNavigation && isAnnotationsNavItem(item)) + const shouldRenderDividerAfter = hasAnnotationsNavigation + ? isAnnotationsNavItem(item) + : isLogsNavItem(item) return ( <Fragment key={item.href}> diff --git a/web/app/components/app-sidebar/app-detail-sidebar.tsx b/web/app/components/app-sidebar/app-detail-sidebar.tsx index a9b238406ff94e..019dbb41f16be9 100644 --- a/web/app/components/app-sidebar/app-detail-sidebar.tsx +++ b/web/app/components/app-sidebar/app-detail-sidebar.tsx @@ -7,12 +7,7 @@ import AppDetailTop from './app-detail-top' export function AppDetailSidebar() { return ( <DetailSidebarFrame - renderTop={({ expand, onToggle }) => ( - <AppDetailTop - expand={expand} - onToggle={onToggle} - /> - )} + renderTop={({ expand, onToggle }) => <AppDetailTop expand={expand} onToggle={onToggle} />} renderSection={({ expand }) => <AppDetailSection expand={expand} />} /> ) diff --git a/web/app/components/app-sidebar/app-detail-top.tsx b/web/app/components/app-sidebar/app-detail-top.tsx index b22f9282e34e55..6968c0dc564b72 100644 --- a/web/app/components/app-sidebar/app-detail-top.tsx +++ b/web/app/components/app-sidebar/app-detail-top.tsx @@ -16,10 +16,7 @@ type AppDetailTopProps = { const SEARCH_SHORTCUT = ['Mod', 'K'] -const AppDetailTop = ({ - expand = true, - onToggle, -}: AppDetailTopProps) => { +const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => { const { t } = useTranslation() const setGotoAnythingOpen = useSetGotoAnythingOpen() @@ -43,25 +40,20 @@ const AppDetailTop = ({ <div className="flex min-w-0 flex-1 items-center gap-px"> <Link href="/" - aria-label={t($ => $['mainNav.home'], { ns: 'common' })} + aria-label={t(($) => $['mainNav.home'], { ns: 'common' })} className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 text-text-tertiary transition-colors hover:bg-background-default-hover hover:text-text-secondary" > <span aria-hidden className="i-ri-arrow-left-s-line size-4" /> - <span - aria-hidden - className="i-custom-vender-main-nav-app-home size-4" - /> + <span aria-hidden className="i-custom-vender-main-nav-app-home size-4" /> </Link> {expand && ( <> - <span className="shrink-0 system-md-regular text-text-quaternary"> - / - </span> + <span className="shrink-0 system-md-regular text-text-quaternary">/</span> <Link href="/apps" className="shrink-0 truncate rounded-lg px-1.5 py-2 system-sm-semibold-uppercase text-text-secondary transition-colors hover:bg-background-default-hover hover:text-text-primary" > - {t($ => $['menus.apps'], { ns: 'common' })} + {t(($) => $['menus.apps'], { ns: 'common' })} </Link> </> )} @@ -69,21 +61,24 @@ const AppDetailTop = ({ {expand && ( <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['gotoAnything.searchTitle'], { ns: 'app' })} + aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })} className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary" onClick={() => setGotoAnythingOpen(true)} > <span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" /> </button> - )} + } /> - <TooltipContent placement="bottom" className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]"> - <span className="px-0.5">{t($ => $['gotoAnything.quickAction'], { ns: 'app' })}</span> + <TooltipContent + placement="bottom" + className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]" + > + <span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span> <KbdGroup> - {SEARCH_SHORTCUT.map(key => ( + {SEARCH_SHORTCUT.map((key) => ( <Kbd key={key}>{formatForDisplay(key)}</Kbd> ))} </KbdGroup> diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx index e133dfd1be7121..98f798697693c5 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx @@ -40,67 +40,92 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('../../../base/app-icon', () => ({ - default: ({ size, icon }: { size: string, icon: string }) => ( + default: ({ size, icon }: { size: string; icon: string }) => ( <div data-testid="app-icon" data-size={size} data-icon={icon} /> ), })) vi.mock('../app-info-detail-drawer', () => ({ - AppInfoDetailDrawer: ({ open, onClose, children }: { + AppInfoDetailDrawer: ({ + open, + onClose, + children, + }: { open: boolean onClose: () => void children: React.ReactNode - }) => ( - open - ? ( - <div data-testid="app-info-detail-drawer"> - <button type="button" data-testid="drawer-close" onClick={onClose}>Close</button> - {children} - </div> - ) - : null - ), + }) => + open ? ( + <div data-testid="app-info-detail-drawer"> + <button type="button" data-testid="drawer-close" onClick={onClose}> + Close + </button> + {children} + </div> + ) : null, })) vi.mock('@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view', () => ({ - default: ({ appId }: { appId: string }) => ( - <div data-testid="card-view" data-app-id={appId} /> - ), + default: ({ appId }: { appId: string }) => <div data-testid="card-view" data-app-id={appId} />, })) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick, className, size, variant }: { + Button: ({ + children, + onClick, + className, + size, + variant, + }: { children: React.ReactNode onClick?: () => void className?: string size?: string variant?: string }) => ( - <button type="button" onClick={onClick} className={className} data-size={size} data-variant={variant}> + <button + type="button" + onClick={onClick} + className={className} + data-size={size} + data-variant={variant} + > {children} </button> ), })) vi.mock('../app-operations', () => ({ - default: ({ primaryOperations, secondaryOperations }: { - primaryOperations?: Array<{ id: string, title: string, onClick: () => void }> - secondaryOperations?: Array<{ id: string, title: string, onClick: () => void, type?: string }> + default: ({ + primaryOperations, + secondaryOperations, + }: { + primaryOperations?: Array<{ id: string; title: string; onClick: () => void }> + secondaryOperations?: Array<{ id: string; title: string; onClick: () => void; type?: string }> }) => ( <div data-testid="app-operations"> - {primaryOperations?.map(op => ( - <button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}>{op.title}</button> - ))} - {secondaryOperations?.map(op => ( - op.type === 'divider' - ? <button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}>divider</button> - : <button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}>{op.title}</button> + {primaryOperations?.map((op) => ( + <button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}> + {op.title} + </button> ))} + {secondaryOperations?.map((op) => + op.type === 'divider' ? ( + <button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}> + divider + </button> + ) : ( + <button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}> + {op.title} + </button> + ), + )} </div> ), })) @@ -111,19 +136,20 @@ const defaultAppPermissionKeys = [ AppACLPermission.Delete, ] -const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> => ({ - id: 'app-1', - name: 'Test App', - mode: AppModeEnum.CHAT, - icon: '🤖', - icon_type: 'emoji', - icon_background: '#FFEAD5', - icon_url: '', - description: 'A test description', - use_icon_as_answer_icon: false, - permission_keys: defaultAppPermissionKeys, - ...overrides, -} as App & Partial<AppSSO>) +const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> => + ({ + id: 'app-1', + name: 'Test App', + mode: AppModeEnum.CHAT, + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + icon_url: '', + description: 'A test description', + use_icon_as_answer_icon: false, + permission_keys: defaultAppPermissionKeys, + ...overrides, + }) as App & Partial<AppSSO> describe('AppInfoDetailPanel', () => { const defaultProps = { @@ -166,12 +192,19 @@ describe('AppInfoDetailPanel', () => { }) it('should not display description when empty', () => { - render(<AppInfoDetailPanel {...defaultProps} appDetail={createAppDetail({ description: '' })} />) + render( + <AppInfoDetailPanel {...defaultProps} appDetail={createAppDetail({ description: '' })} />, + ) expect(screen.queryByText('A test description')).not.toBeInTheDocument() }) it('should not display description when undefined', () => { - render(<AppInfoDetailPanel {...defaultProps} appDetail={createAppDetail({ description: undefined as unknown as string })} />) + render( + <AppInfoDetailPanel + {...defaultProps} + appDetail={createAppDetail({ description: undefined as unknown as string })} + />, + ) expect(screen.queryByText('A test description')).not.toBeInTheDocument() }) diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx index ebe1dd51d3398c..1f0d2c72b84fe5 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx @@ -20,60 +20,108 @@ vi.mock('@/next/dynamic', () => ({ })) vi.mock('@/app/components/app/switch-app-modal', () => ({ - default: ({ show, onClose }: { show: boolean, onClose: () => void }) => ( - show ? <div data-testid="switch-modal"><button type="button" onClick={onClose}>Close Switch</button></div> : null - ), + default: ({ show, onClose }: { show: boolean; onClose: () => void }) => + show ? ( + <div data-testid="switch-modal"> + <button type="button" onClick={onClose}> + Close Switch + </button> + </div> + ) : null, })) vi.mock('@/app/components/explore/create-app-modal', () => ({ - default: ({ show, onHide, isEditModal }: { show: boolean, onHide: () => void, isEditModal?: boolean }) => ( - show ? <div data-testid={isEditModal ? 'edit-modal' : 'create-modal'}><button type="button" onClick={onHide}>Close Edit</button></div> : null - ), + default: ({ + show, + onHide, + isEditModal, + }: { + show: boolean + onHide: () => void + isEditModal?: boolean + }) => + show ? ( + <div data-testid={isEditModal ? 'edit-modal' : 'create-modal'}> + <button type="button" onClick={onHide}> + Close Edit + </button> + </div> + ) : null, })) vi.mock('@/app/components/app/duplicate-modal', () => ({ - default: ({ show, onHide }: { show: boolean, onHide: () => void }) => ( - show ? <div data-testid="duplicate-modal"><button type="button" onClick={onHide}>Close Dup</button></div> : null - ), + default: ({ show, onHide }: { show: boolean; onHide: () => void }) => + show ? ( + <div data-testid="duplicate-modal"> + <button type="button" onClick={onHide}> + Close Dup + </button> + </div> + ) : null, })) vi.mock('@/app/components/workflow/update-dsl-modal', () => ({ - default: ({ onCancel, onBackup }: { onCancel: () => void, onBackup: () => void }) => ( + default: ({ onCancel, onBackup }: { onCancel: () => void; onBackup: () => void }) => ( <div data-testid="import-dsl-modal"> - <button type="button" onClick={onCancel}>Cancel Import</button> - <button type="button" onClick={onBackup}>Backup</button> + <button type="button" onClick={onCancel}> + Cancel Import + </button> + <button type="button" onClick={onBackup}> + Backup + </button> </div> ), })) vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ - DSLExportConfirmContent: ({ onConfirm, onClose }: { onConfirm: (include?: boolean) => void, onClose: () => void }) => ( + DSLExportConfirmContent: ({ + onConfirm, + onClose, + }: { + onConfirm: (include?: boolean) => void + onClose: () => void + }) => ( <div data-testid="dsl-export-confirm-modal"> - <button type="button" onClick={() => onConfirm(true)}>Export Include</button> - <button type="button" onClick={onClose}>Close Export</button> + <button type="button" onClick={() => onConfirm(true)}> + Export Include + </button> + <button type="button" onClick={onClose}> + Close Export + </button> </div> ), - default: ({ onConfirm, onClose }: { onConfirm: (include?: boolean) => void, onClose: () => void }) => ( + default: ({ + onConfirm, + onClose, + }: { + onConfirm: (include?: boolean) => void + onClose: () => void + }) => ( <div data-testid="dsl-export-confirm-modal"> - <button type="button" onClick={() => onConfirm(true)}>Export Include</button> - <button type="button" onClick={onClose}>Close Export</button> + <button type="button" onClick={() => onConfirm(true)}> + Export Include + </button> + <button type="button" onClick={onClose}> + Close Export + </button> </div> ), })) -const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> => ({ - id: 'app-1', - name: 'Test App', - mode: AppModeEnum.CHAT, - icon: '🤖', - icon_type: 'emoji', - icon_background: '#FFEAD5', - icon_url: '', - description: '', - use_icon_as_answer_icon: false, - max_active_requests: null, - ...overrides, -} as App & Partial<AppSSO>) +const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> => + ({ + id: 'app-1', + name: 'Test App', + mode: AppModeEnum.CHAT, + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + icon_url: '', + description: '', + use_icon_as_answer_icon: false, + max_active_requests: null, + ...overrides, + }) as App & Partial<AppSSO> const defaultProps = { appDetail: createAppDetail(), @@ -90,7 +138,7 @@ const defaultProps = { describe('AppInfoModals', () => { beforeAll(async () => { - await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) }) beforeEach(() => { @@ -166,7 +214,15 @@ describe('AppInfoModals', () => { <AppInfoModals {...defaultProps} activeModal={null} - secretEnvList={[{ id: 'env-1', key: 'SECRET', value: '', value_type: 'secret', name: 'Secret' } as never]} + secretEnvList={[ + { + id: 'env-1', + key: 'SECRET', + value: '', + value_type: 'secret', + name: 'Secret', + } as never, + ]} />, ) }) @@ -188,7 +244,9 @@ describe('AppInfoModals', () => { render(<AppInfoModals {...defaultProps} activeModal="delete" />) }) - await waitFor(() => expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()) + await waitFor(() => + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument(), + ) await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) expect(defaultProps.closeModal).toHaveBeenCalledTimes(1) @@ -239,7 +297,9 @@ describe('AppInfoModals', () => { render(<AppInfoModals {...defaultProps} activeModal="exportWarning" />) }) - await waitFor(() => expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeInTheDocument()) + await waitFor(() => + expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeInTheDocument(), + ) await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(defaultProps.handleConfirmExport).toHaveBeenCalledTimes(1) @@ -247,9 +307,12 @@ describe('AppInfoModals', () => { it('should disable export confirm button and avoid duplicate submits while confirming export', async () => { let resolveConfirmExport: () => void - const handleConfirmExport = vi.fn(() => new Promise<void>((resolve) => { - resolveConfirmExport = resolve - })) + const handleConfirmExport = vi.fn( + () => + new Promise<void>((resolve) => { + resolveConfirmExport = resolve + }), + ) const user = userEvent.setup() await act(async () => { @@ -300,7 +363,15 @@ describe('AppInfoModals', () => { <AppInfoModals {...defaultProps} activeModal={null} - secretEnvList={[{ id: 'env-1', key: 'SECRET', value: '', value_type: 'secret', name: 'Secret' } as never]} + secretEnvList={[ + { + id: 'env-1', + key: 'SECRET', + value: '', + value_type: 'secret', + name: 'Secret', + } as never, + ]} />, ) }) diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx index 6546352b00d45d..318ded91825476 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx @@ -6,29 +6,32 @@ import { AppModeEnum } from '@/types/app' import AppInfoTrigger from '../app-info-trigger' vi.mock('../../../base/app-icon', () => ({ - default: ({ size, icon, background }: { + default: ({ + size, + icon, + background, + }: { size: string icon: string background: string iconType?: string imageUrl?: string - }) => ( - <div data-testid="app-icon" data-size={size} data-icon={icon} data-bg={background} /> - ), + }) => <div data-testid="app-icon" data-size={size} data-icon={icon} data-bg={background} />, })) -const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> => ({ - id: 'app-1', - name: 'Test App', - mode: AppModeEnum.CHAT, - icon: '🤖', - icon_type: 'emoji', - icon_background: '#FFEAD5', - icon_url: '', - description: 'A test app', - use_icon_as_answer_icon: false, - ...overrides, -} as App & Partial<AppSSO>) +const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> => + ({ + id: 'app-1', + name: 'Test App', + mode: AppModeEnum.CHAT, + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + icon_url: '', + description: 'A test app', + use_icon_as_answer_icon: false, + ...overrides, + }) as App & Partial<AppSSO> describe('AppInfoTrigger', () => { it('should render app icon with correct size when expanded', () => { @@ -44,17 +47,35 @@ describe('AppInfoTrigger', () => { }) it('should show app name when expanded', () => { - render(<AppInfoTrigger appDetail={createAppDetail({ name: 'My Chatbot' })} expand onClick={vi.fn()} />) + render( + <AppInfoTrigger + appDetail={createAppDetail({ name: 'My Chatbot' })} + expand + onClick={vi.fn()} + />, + ) expect(screen.getByText('My Chatbot')).toBeInTheDocument() }) it('should not show app name when collapsed', () => { - render(<AppInfoTrigger appDetail={createAppDetail({ name: 'My Chatbot' })} expand={false} onClick={vi.fn()} />) + render( + <AppInfoTrigger + appDetail={createAppDetail({ name: 'My Chatbot' })} + expand={false} + onClick={vi.fn()} + />, + ) expect(screen.queryByText('My Chatbot')).not.toBeInTheDocument() }) it('should show app mode label when expanded', () => { - render(<AppInfoTrigger appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })} expand onClick={vi.fn()} />) + render( + <AppInfoTrigger + appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })} + expand + onClick={vi.fn()} + />, + ) expect(screen.getByText('app.types.advanced')).toBeInTheDocument() }) @@ -84,9 +105,7 @@ describe('AppInfoTrigger', () => { }) it('should center the icon wrapper when collapsed', () => { - render( - <AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />, - ) + render(<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />) const iconWrapper = screen.getByTestId('app-icon').parentElement expect(iconWrapper?.parentElement).toHaveClass('items-center') }) diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx index ff6aed2c716212..2f6793c1307c37 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx @@ -5,14 +5,23 @@ import * as React from 'react' import AppOperations from '../app-operations' vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick, className, size, variant, id, tabIndex, ...rest }: { - 'children': React.ReactNode - 'onClick'?: () => void - 'className'?: string - 'size'?: string - 'variant'?: string - 'id'?: string - 'tabIndex'?: number + Button: ({ + children, + onClick, + className, + size, + variant, + id, + tabIndex, + ...rest + }: { + children: React.ReactNode + onClick?: () => void + className?: string + size?: string + variant?: string + id?: string + tabIndex?: number 'data-targetid'?: string }) => ( <button @@ -31,19 +40,31 @@ vi.mock('@langgenius/dify-ui/button', () => ({ })) vi.mock('@langgenius/dify-ui/dropdown-menu', () => { - const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + const DropdownMenuContext = React.createContext<{ + isOpen: boolean + setOpen: (open: boolean) => void + } | null>(null) const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + DropdownMenu: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( <DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}> - <div data-testid="dropdown-menu" data-open={open}>{children}</div> + <div data-testid="dropdown-menu" data-open={open}> + {children} + </div> </DropdownMenuContext> ), DropdownMenuTrigger: ({ @@ -62,18 +83,41 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => { } if (render) - return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record<string, unknown>, children) + return React.cloneElement( + render, + { 'data-testid': 'dropdown-trigger', onClick: handleClick } as Record<string, unknown>, + children, + ) - return <button data-testid="dropdown-trigger" onClick={handleClick}>{children}</button> + return ( + <button data-testid="dropdown-trigger" onClick={handleClick}> + {children} + </button> + ) }, - DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => { + DropdownMenuContent: ({ + children, + popupClassName, + }: { + children: React.ReactNode + popupClassName?: string + }) => { const { isOpen } = useDropdownMenuContext() - if (!isOpen) - return null + if (!isOpen) return null - return <div data-testid="dropdown-content" className={popupClassName}>{children}</div> + return ( + <div data-testid="dropdown-content" className={popupClassName}> + {children} + </div> + ) }, - DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => { + DropdownMenuItem: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler<HTMLButtonElement> + }) => { const { setOpen } = useDropdownMenuContext() return ( <button @@ -106,10 +150,8 @@ function setupDomMeasurements(navWidth: number, moreWidth: number, childWidths: Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get(this: HTMLElement) { - if (this.getAttribute('aria-hidden') === 'true') - return navWidth - if (this.id === 'more-measure') - return moreWidth + if (this.getAttribute('aria-hidden') === 'true') return navWidth + if (this.id === 'more-measure') return moreWidth if (this.dataset.targetid) { const idx = Array.from(this.parentElement?.children ?? []).indexOf(this) return childWidths[idx] ?? 50 @@ -220,8 +262,7 @@ describe('AppOperations', () => { render(<AppOperations gap={4} primaryOperations={ops} secondaryOperations={secondary} />) const trigger = screen.queryByTestId('dropdown-trigger') - if (trigger) - await user.click(trigger) + if (trigger) await user.click(trigger) cleanup() }) @@ -237,9 +278,8 @@ describe('AppOperations', () => { render(<AppOperations gap={4} operations={[editOp, copyOp]} />) const visibleButtons = screen.getAllByText('Edit') - const clickableButton = visibleButtons.find(btn => btn.closest('button')?.tabIndex !== -1) - if (clickableButton) - await user.click(clickableButton) + const clickableButton = visibleButtons.find((btn) => btn.closest('button')?.tabIndex !== -1) + if (clickableButton) await user.click(clickableButton) cleanup() }) @@ -271,10 +311,9 @@ describe('AppOperations', () => { const { container } = render(<AppOperations gap={4} operations={ops} />) const containers = container.querySelectorAll('div[style]') const visibleContainer = Array.from(containers).find( - el => el.getAttribute('aria-hidden') !== 'true', + (el) => el.getAttribute('aria-hidden') !== 'true', ) - if (visibleContainer) - expect(visibleContainer).toHaveStyle({ gap: '4px' }) + if (visibleContainer) expect(visibleContainer).toHaveStyle({ gap: '4px' }) }) }) diff --git a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts index 8f417ee7ce4ef4..5255039f8723ae 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts +++ b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts @@ -6,7 +6,9 @@ const toastMocks = vi.hoisted(() => { const call = vi.fn() return { call, - api: vi.fn((message: unknown, options?: Record<string, unknown>) => call({ message, ...options })), + api: vi.fn((message: unknown, options?: Record<string, unknown>) => + call({ message, ...options }), + ), dismiss: vi.fn(), update: vi.fn(), promise: vi.fn(), @@ -45,10 +47,11 @@ vi.mock('@/context/provider-context', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({ - appDetail: mockAppDetail, - setAppDetail: mockSetAppDetail, - }), + useStore: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + appDetail: mockAppDetail, + setAppDetail: mockSetAppDetail, + }), })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -164,10 +167,9 @@ describe('useAppInfoActions', () => { }) it('should reset app-scoped state when resetKey changes', () => { - const { result, rerender } = renderHook( - ({ resetKey }) => useAppInfoActions({ resetKey }), - { initialProps: { resetKey: 'app-1' } }, - ) + const { result, rerender } = renderHook(({ resetKey }) => useAppInfoActions({ resetKey }), { + initialProps: { resetKey: 'app-1' }, + }) act(() => { result.current.openModal('delete') @@ -257,7 +259,7 @@ describe('useAppInfoActions', () => { use_icon_as_answer_icon: false, }) }) - await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) expect(mockGetSocket).toHaveBeenCalledWith('app-1') expect(socket.emit).toHaveBeenCalledWith( @@ -324,7 +326,10 @@ describe('useAppInfoActions', () => { }) expect(mockCopyApp).toHaveBeenCalled() - expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' }) + expect(toastMocks.call).toHaveBeenCalledWith({ + type: 'success', + message: 'app.newApp.appCreated', + }) expect(mockOnPlanInfoChanged).toHaveBeenCalled() }) @@ -342,7 +347,10 @@ describe('useAppInfoActions', () => { }) }) - expect(toastMocks.call).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' }) + expect(toastMocks.call).toHaveBeenCalledWith({ + type: 'error', + message: 'app.newApp.appCreateFailed', + }) }) }) @@ -593,7 +601,7 @@ describe('useAppInfoActions', () => { mockFetchAppDetail.mockResolvedValue(updated) const { unmount } = renderHook(() => useAppInfoActions({})) - await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) await act(async () => { await onUpdate?.() diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx index 420653a7c9e82d..57a737f6c95880 100644 --- a/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx @@ -14,18 +14,13 @@ type AppInfoDetailDrawerProps = { children: ReactNode } -export function AppInfoDetailDrawer({ - open, - onClose, - children, -}: AppInfoDetailDrawerProps) { +export function AppInfoDetailDrawer({ open, onClose, children }: AppInfoDetailDrawerProps) { return ( <Drawer open={open} swipeDirection="left" onOpenChange={(nextOpen) => { - if (!nextOpen) - onClose() + if (!nextOpen) onClose() }} > <DrawerPortal> diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx index 369efb23d68cfc..42cc6e6d1c7d44 100644 --- a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx @@ -42,86 +42,101 @@ const AppInfoDetailPanel = ({ const { t } = useTranslation() const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const appACLCapabilities = useMemo(() => getAppACLCapabilities(appDetail.permission_keys, { - currentUserId, - resourceMaintainer: appDetail.maintainer, - workspacePermissionKeys, - }), [appDetail.maintainer, appDetail.permission_keys, currentUserId, workspacePermissionKeys]) + const appACLCapabilities = useMemo( + () => + getAppACLCapabilities(appDetail.permission_keys, { + currentUserId, + resourceMaintainer: appDetail.maintainer, + workspacePermissionKeys, + }), + [appDetail.maintainer, appDetail.permission_keys, currentUserId, workspacePermissionKeys], + ) const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') - const primaryOperations = useMemo<Operation[]>(() => [ - ...(appACLCapabilities.canEdit - ? [{ - id: 'edit', - title: t($ => $.editApp, { ns: 'app' }), - icon: <RiEditLine />, - onClick: () => openModal('edit'), - }] - : []), - ...(canCreateApp - ? [{ - id: 'duplicate', - title: t($ => $.duplicate, { ns: 'app' }), - icon: <RiFileCopy2Line />, - onClick: () => openModal('duplicate'), - }] - : []), - ...(appACLCapabilities.canImportExportDSL - ? [{ - id: 'export', - title: t($ => $.export, { ns: 'app' }), - icon: <RiFileDownloadLine />, - onClick: exportCheck, - }] - : []), - ], [appACLCapabilities, canCreateApp, t, openModal, exportCheck]) + const primaryOperations = useMemo<Operation[]>( + () => [ + ...(appACLCapabilities.canEdit + ? [ + { + id: 'edit', + title: t(($) => $.editApp, { ns: 'app' }), + icon: <RiEditLine />, + onClick: () => openModal('edit'), + }, + ] + : []), + ...(canCreateApp + ? [ + { + id: 'duplicate', + title: t(($) => $.duplicate, { ns: 'app' }), + icon: <RiFileCopy2Line />, + onClick: () => openModal('duplicate'), + }, + ] + : []), + ...(appACLCapabilities.canImportExportDSL + ? [ + { + id: 'export', + title: t(($) => $.export, { ns: 'app' }), + icon: <RiFileDownloadLine />, + onClick: exportCheck, + }, + ] + : []), + ], + [appACLCapabilities, canCreateApp, t, openModal, exportCheck], + ) - const secondaryOperations = useMemo<Operation[]>(() => [ - ...(appACLCapabilities.canImportExportDSL && (appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW) - ? [{ - id: 'import', - title: t($ => $['common.importDSL'], { ns: 'workflow' }), - icon: <RiFileUploadLine />, - onClick: () => openModal('importDSL'), - }] - : []), - ...(appACLCapabilities.canDelete - ? [ - { - id: 'divider-1', - title: '', - icon: <></>, - onClick: () => {}, - type: 'divider' as const, - }, - { - id: 'delete', - title: t($ => $['operation.delete'], { ns: 'common' }), - icon: <RiDeleteBinLine />, - onClick: () => openModal('delete'), - }, - ] - : []), - ], [appACLCapabilities, appDetail.mode, t, openModal]) + const secondaryOperations = useMemo<Operation[]>( + () => [ + ...(appACLCapabilities.canImportExportDSL && + (appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW) + ? [ + { + id: 'import', + title: t(($) => $['common.importDSL'], { ns: 'workflow' }), + icon: <RiFileUploadLine />, + onClick: () => openModal('importDSL'), + }, + ] + : []), + ...(appACLCapabilities.canDelete + ? [ + { + id: 'divider-1', + title: '', + icon: <></>, + onClick: () => {}, + type: 'divider' as const, + }, + { + id: 'delete', + title: t(($) => $['operation.delete'], { ns: 'common' }), + icon: <RiDeleteBinLine />, + onClick: () => openModal('delete'), + }, + ] + : []), + ], + [appACLCapabilities, appDetail.mode, t, openModal], + ) const switchOperation = useMemo(() => { - if (!appACLCapabilities.canEdit) - return null + if (!appACLCapabilities.canEdit) return null if (appDetail.mode !== AppModeEnum.COMPLETION && appDetail.mode !== AppModeEnum.CHAT) return null return { id: 'switch', - title: t($ => $.switch, { ns: 'app' }), + title: t(($) => $.switch, { ns: 'app' }), icon: <RiExchange2Line />, onClick: () => openModal('switch'), } }, [appACLCapabilities.canEdit, appDetail.mode, t, openModal]) return ( - <AppInfoDetailDrawer - open={show} - onClose={onClose} - > + <AppInfoDetailDrawer open={show} onClose={onClose}> <div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4"> <div className="flex items-center gap-3 self-stretch"> <AppIcon @@ -132,7 +147,9 @@ const AppInfoDetailPanel = ({ imageUrl={appDetail.icon_url} /> <div className="flex flex-1 flex-col items-start justify-center overflow-hidden"> - <h2 className="w-full truncate system-md-semibold text-text-secondary">{appDetail.name}</h2> + <h2 className="w-full truncate system-md-semibold text-text-secondary"> + {appDetail.name} + </h2> <div className="system-2xs-medium-uppercase text-text-tertiary"> {getAppModeLabel(appDetail.mode, t)} </div> diff --git a/web/app/components/app-sidebar/app-info/app-info-modals.tsx b/web/app/components/app-sidebar/app-info/app-info-modals.tsx index 0c23613cdd605b..6dbd132c16fdbd 100644 --- a/web/app/components/app-sidebar/app-info/app-info-modals.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-modals.tsx @@ -19,10 +19,18 @@ import Input from '@/app/components/base/input' import { DSLExportConfirmContent } from '@/app/components/workflow/dsl-export-confirm-modal' import dynamic from '@/next/dynamic' -const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { ssr: false }) -const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { ssr: false }) -const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), { ssr: false }) -const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), { ssr: false }) +const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { + ssr: false, +}) +const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { + ssr: false, +}) +const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), { + ssr: false, +}) +const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), { + ssr: false, +}) type AppInfoModalsProps = { appDetail: App & Partial<AppSSO> @@ -56,11 +64,8 @@ const AppInfoModals = ({ const [isConfirmingExport, setIsConfirmingExport] = useState(false) const [isSecretExporting, setIsSecretExporting] = useState(false) const isDeleteConfirmDisabled = confirmDeleteInput !== appDetail.name - const exportDialogMode = secretEnvList.length > 0 - ? 'secret' - : activeModal === 'exportWarning' - ? 'warning' - : null + const exportDialogMode = + secretEnvList.length > 0 ? 'secret' : activeModal === 'exportWarning' ? 'warning' : null const isExportDialogOpen = exportDialogMode !== null const handleDeleteDialogClose = () => { @@ -69,14 +74,12 @@ const AppInfoModals = ({ } const handleExportWarningConfirm = useCallback(async () => { - if (isConfirmingExport) - return + if (isConfirmingExport) return setIsConfirmingExport(true) try { await handleConfirmExport() - } - finally { + } finally { setIsConfirmingExport(false) } }, [handleConfirmExport, isConfirmingExport]) @@ -90,12 +93,14 @@ const AppInfoModals = ({ closeModal() }, [closeModal, exportDialogMode, setSecretEnvList]) - const handleExportDialogOpenChange = useCallback((open: boolean) => { - if (open || isConfirmingExport || isSecretExporting) - return + const handleExportDialogOpenChange = useCallback( + (open: boolean) => { + if (open || isConfirmingExport || isSecretExporting) return - handleExportDialogClose() - }, [handleExportDialogClose, isConfirmingExport, isSecretExporting]) + handleExportDialogClose() + }, + [handleExportDialogClose, isConfirmingExport, isSecretExporting], + ) return ( <> @@ -137,32 +142,36 @@ const AppInfoModals = ({ onHide={closeModal} /> )} - <AlertDialog open={activeModal === 'delete'} onOpenChange={open => !open && handleDeleteDialogClose()}> + <AlertDialog + open={activeModal === 'delete'} + onOpenChange={(open) => !open && handleDeleteDialogClose()} + > <AlertDialogContent> <form className="flex flex-col" onSubmit={(e) => { e.preventDefault() - if (isDeleteConfirmDisabled) - return + if (isDeleteConfirmDisabled) return onConfirmDelete() }} > <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $.deleteAppConfirmTitle, { ns: 'app' })} + {t(($) => $.deleteAppConfirmTitle, { ns: 'app' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $.deleteAppConfirmContent, { ns: 'app' })} + {t(($) => $.deleteAppConfirmContent, { ns: 'app' })} </AlertDialogDescription> <div className="mt-2"> <label className="mb-1 block system-sm-regular text-text-secondary"> <Trans - i18nKey={$ => $.deleteAppConfirmInputLabel} + i18nKey={($) => $.deleteAppConfirmInputLabel} ns="app" values={{ appName: appDetail.name }} components={{ - appName: <span className="system-sm-semibold text-text-primary" translate="no" />, + appName: ( + <span className="system-sm-semibold text-text-primary" translate="no" /> + ), }} /> </label> @@ -171,9 +180,9 @@ const AppInfoModals = ({ type="text" autoComplete="off" spellCheck={false} - placeholder={t($ => $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} + placeholder={t(($) => $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} value={confirmDeleteInput} - onChange={e => setConfirmDeleteInput(e.target.value)} + onChange={(e) => setConfirmDeleteInput(e.target.value)} className="pr-20" /> <button @@ -181,50 +190,48 @@ const AppInfoModals = ({ onClick={() => setConfirmDeleteInput(appDetail.name)} className="absolute top-1/2 right-2 -translate-y-1/2 rounded-full bg-black/[0.06] px-2.5 py-1 system-xs-medium text-text-secondary hover:bg-black/[0.1]" > - {t($ => $['operation.fill'], { ns: 'common' })} + {t(($) => $['operation.fill'], { ns: 'common' })} </button> </div> </div> </div> <AlertDialogActions> <AlertDialogCancelButton type="button"> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton type="submit" disabled={isDeleteConfirmDisabled}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </form> </AlertDialogContent> </AlertDialog> {activeModal === 'importDSL' && ( - <UpdateDSLModal - onCancel={closeModal} - onBackup={exportCheck} - /> + <UpdateDSLModal onCancel={closeModal} onBackup={exportCheck} /> )} <AlertDialog open={isExportDialogOpen} onOpenChange={handleExportDialogOpenChange}> - {exportDialogMode === 'secret' - ? ( - <DSLExportConfirmContent - envList={secretEnvList} - onConfirm={onExport} - onClose={() => setSecretEnvList([])} - onExportingChange={setIsSecretExporting} - /> - ) - : exportDialogMode === 'warning' && ( + {exportDialogMode === 'secret' ? ( + <DSLExportConfirmContent + envList={secretEnvList} + onConfirm={onExport} + onClose={() => setSecretEnvList([])} + onExportingChange={setIsSecretExporting} + /> + ) : ( + exportDialogMode === 'warning' && ( <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['sidebar.exportWarning'], { ns: 'workflow' })} + {t(($) => $['sidebar.exportWarning'], { ns: 'workflow' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['sidebar.exportWarningDesc'], { ns: 'workflow' })} + {t(($) => $['sidebar.exportWarningDesc'], { ns: 'workflow' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton tone="default" loading={isConfirmingExport} @@ -232,12 +239,13 @@ const AppInfoModals = ({ onClick={handleExportWarningConfirm} > {isConfirmingExport - ? t($ => $['operation.exporting'], { ns: 'common' }) - : t($ => $['operation.confirm'], { ns: 'common' })} + ? t(($) => $['operation.exporting'], { ns: 'common' }) + : t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> - )} + ) + )} </AlertDialog> </> ) diff --git a/web/app/components/app-sidebar/app-info/app-info-trigger.tsx b/web/app/components/app-sidebar/app-info/app-info-trigger.tsx index 9c64cfbb3065e3..a6829b951e25f7 100644 --- a/web/app/components/app-sidebar/app-info/app-info-trigger.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-trigger.tsx @@ -22,10 +22,11 @@ const AppInfoTrigger = ({ appDetail, expand, onClick }: AppInfoTriggerProps) => className="block w-full" aria-label={!expand ? `${appDetail.name} - ${modeLabel}` : undefined} > - <div className={cn( - 'rounded-xl hover:bg-state-base-hover', - expand ? 'flex items-start gap-2 p-2' : 'flex items-center justify-center px-1 py-1.5', - )} + <div + className={cn( + 'rounded-xl hover:bg-state-base-hover', + expand ? 'flex items-start gap-2 p-2' : 'flex items-center justify-center px-1 py-1.5', + )} > <div className="flex shrink-0 items-center"> <div> @@ -42,7 +43,9 @@ const AppInfoTrigger = ({ appDetail, expand, onClick }: AppInfoTriggerProps) => <> <div className="flex min-w-0 flex-1 flex-col items-start justify-center gap-0.5 self-stretch"> <div className="flex w-full min-w-0 pr-1"> - <div className="truncate system-md-semibold text-text-secondary">{appDetail.name}</div> + <div className="truncate system-md-semibold text-text-secondary"> + {appDetail.name} + </div> </div> <div className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary"> {modeLabel} diff --git a/web/app/components/app-sidebar/app-info/app-mode-labels.ts b/web/app/components/app-sidebar/app-info/app-mode-labels.ts index 774ffb2d58be2d..1ff7559f650071 100644 --- a/web/app/components/app-sidebar/app-info/app-mode-labels.ts +++ b/web/app/components/app-sidebar/app-info/app-mode-labels.ts @@ -4,14 +4,14 @@ import { AppModeEnum } from '@/types/app' export function getAppModeLabel(mode: string, t: TFunction): string { switch (mode) { case AppModeEnum.ADVANCED_CHAT: - return t($ => $['types.advanced'], { ns: 'app' }) + return t(($) => $['types.advanced'], { ns: 'app' }) case AppModeEnum.AGENT_CHAT: - return t($ => $['types.agent'], { ns: 'app' }) + return t(($) => $['types.agent'], { ns: 'app' }) case AppModeEnum.CHAT: - return t($ => $['types.chatbot'], { ns: 'app' }) + return t(($) => $['types.chatbot'], { ns: 'app' }) case AppModeEnum.COMPLETION: - return t($ => $['types.completion'], { ns: 'app' }) + return t(($) => $['types.completion'], { ns: 'app' }) default: - return t($ => $['types.workflow'], { ns: 'app' }) + return t(($) => $['types.workflow'], { ns: 'app' }) } } diff --git a/web/app/components/app-sidebar/app-info/app-operations.tsx b/web/app/components/app-sidebar/app-info/app-operations.tsx index 6e9c4874ba45c4..d82f5d92446ad0 100644 --- a/web/app/components/app-sidebar/app-info/app-operations.tsx +++ b/web/app/components/app-sidebar/app-info/app-operations.tsx @@ -41,32 +41,27 @@ const AppOperations = ({ const navRef = useRef<HTMLDivElement>(null) const primaryOps = useMemo(() => { - if (operations) - return operations - if (primaryOperations) - return primaryOperations + if (operations) return operations + if (primaryOperations) return primaryOperations return EMPTY_OPERATIONS }, [operations, primaryOperations]) const secondaryOps = useMemo(() => { - if (operations) - return EMPTY_OPERATIONS - if (secondaryOperations) - return secondaryOperations + if (operations) return EMPTY_OPERATIONS + if (secondaryOperations) return secondaryOperations return EMPTY_OPERATIONS }, [operations, secondaryOperations]) - const inlineOperations = primaryOps.filter(operation => operation.type !== 'divider') + const inlineOperations = primaryOps.filter((operation) => operation.type !== 'divider') useEffect(() => { const applyState = (visible: Operation[], overflow: Operation[]) => { const combinedMore = [...overflow, ...secondaryOps] - if (!overflow.length && combinedMore[0]?.type === 'divider') - combinedMore.shift() + if (!overflow.length && combinedMore[0]?.type === 'divider') combinedMore.shift() setVisibleOperations(visible) setMoreOperations(combinedMore) } - const inline = primaryOps.filter(operation => operation.type !== 'divider') + const inline = primaryOps.filter((operation) => operation.type !== 'divider') if (!inline.length) { applyState([], []) @@ -76,43 +71,41 @@ const AppOperations = ({ const navElement = navRef.current const moreElement = document.getElementById('more-measure') - if (!navElement || !moreElement) - return + if (!navElement || !moreElement) return let width = 0 const containerWidth = navElement.clientWidth const moreWidth = moreElement.clientWidth - if (containerWidth === 0 || moreWidth === 0) - return + if (containerWidth === 0 || moreWidth === 0) return - const updatedEntries: Record<string, boolean> = inline.reduce((pre, cur) => { - pre[cur.id] = false - return pre - }, {} as Record<string, boolean>) + const updatedEntries: Record<string, boolean> = inline.reduce( + (pre, cur) => { + pre[cur.id] = false + return pre + }, + {} as Record<string, boolean>, + ) const childrens = Array.from(navElement.children).slice(0, -1) for (let i = 0; i < childrens.length; i++) { const child = childrens[i] as HTMLElement const id = child.dataset.targetid - if (!id) - break + if (!id) break const childWidth = child.clientWidth if (width + gap + childWidth + moreWidth <= containerWidth) { updatedEntries[id] = true width += gap + childWidth - } - else { + } else { if (i === childrens.length - 1 && width + childWidth <= containerWidth) updatedEntries[id] = true - else - updatedEntries[id] = false + else updatedEntries[id] = false break } } - const visible = inline.filter(item => updatedEntries[item.id]) - const overflow = inline.filter(item => !updatedEntries[item.id]) + const visible = inline.filter((item) => updatedEntries[item.id]) + const overflow = inline.filter((item) => !updatedEntries[item.id]) applyState(visible, overflow) }, [gap, primaryOps, secondaryOps]) @@ -127,7 +120,7 @@ const AppOperations = ({ className="pointer-events-none flex h-0 items-center self-stretch overflow-hidden" style={{ gap }} > - {inlineOperations.map(operation => ( + {inlineOperations.map((operation) => ( <Button key={operation.id} data-targetid={operation.id} @@ -136,7 +129,9 @@ const AppOperations = ({ className="gap-px focus-visible:ring-inset" tabIndex={-1} > - {cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })} + {cloneElement(operation.icon, { + className: 'h-3.5 w-3.5 text-components-button-secondary-text', + })} <span className="system-xs-medium text-components-button-secondary-text"> {operation.title} </span> @@ -151,12 +146,12 @@ const AppOperations = ({ > <RiMoreLine className="size-3.5 text-components-button-secondary-text" /> <span className="system-xs-medium text-components-button-secondary-text"> - {t($ => $['operation.more'], { ns: 'common' })} + {t(($) => $['operation.more'], { ns: 'common' })} </span> </Button> </div> <div className="flex items-center self-stretch overflow-hidden" style={{ gap }}> - {visibleOpreations.map(operation => ( + {visibleOpreations.map((operation) => ( <Button key={operation.id} data-targetid={operation.id} @@ -165,7 +160,9 @@ const AppOperations = ({ className="gap-px focus-visible:ring-inset" onClick={operation.onClick} > - {cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })} + {cloneElement(operation.icon, { + className: 'h-3.5 w-3.5 text-components-button-secondary-text', + })} <span className="system-xs-medium text-components-button-secondary-text"> {operation.title} </span> @@ -174,18 +171,18 @@ const AppOperations = ({ {shouldShowMoreButton && ( <DropdownMenu open={showMore} onOpenChange={setShowMore}> <DropdownMenuTrigger - render={( + render={ <Button size="small" variant="secondary" className="gap-px focus-visible:ring-inset" /> - )} + } > <> <RiMoreLine className="size-3.5 text-components-button-secondary-text" /> <span className="system-xs-medium text-components-button-secondary-text"> - {t($ => $['operation.more'], { ns: 'common' })} + {t(($) => $['operation.more'], { ns: 'common' })} </span> </> </DropdownMenuTrigger> @@ -194,20 +191,16 @@ const AppOperations = ({ sideOffset={4} popupClassName="min-w-[264px]" > - {moreOperations.map(item => item.type === 'divider' - ? ( - <DropdownMenuSeparator key={item.id} /> - ) - : ( - <DropdownMenuItem - key={item.id} - className="gap-x-1 px-1.5" - onClick={item.onClick} - > - {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} - <span className="system-md-regular text-text-secondary">{item.title}</span> - </DropdownMenuItem> - ))} + {moreOperations.map((item) => + item.type === 'divider' ? ( + <DropdownMenuSeparator key={item.id} /> + ) : ( + <DropdownMenuItem key={item.id} className="gap-x-1 px-1.5" onClick={item.onClick}> + {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} + <span className="system-md-regular text-text-secondary">{item.title}</span> + </DropdownMenuItem> + ), + )} </DropdownMenuContent> </DropdownMenu> )} diff --git a/web/app/components/app-sidebar/app-info/index.tsx b/web/app/components/app-sidebar/app-info/index.tsx index aa27a4c5fb7671..8479c3b6bfe217 100644 --- a/web/app/components/app-sidebar/app-info/index.tsx +++ b/web/app/components/app-sidebar/app-info/index.tsx @@ -25,10 +25,7 @@ type AppInfoDetailLayerProps = { open?: boolean } -const AppInfoDetailLayer = ({ - actions, - open = actions.panelOpen, -}: AppInfoDetailLayerProps) => { +const AppInfoDetailLayer = ({ actions, open = actions.panelOpen }: AppInfoDetailLayerProps) => { const { appDetail, closePanel, @@ -45,8 +42,7 @@ const AppInfoDetailLayer = ({ onConfirmDelete, } = actions - if (!appDetail) - return null + if (!appDetail) return null return ( <> @@ -81,13 +77,7 @@ export const AppInfoView = ({ actions, renderDetail = true, }: AppInfoViewProps) => { - const { - appDetail, - panelOpen, - setPanelOpen, - activeModal, - secretEnvList, - } = actions + const { appDetail, panelOpen, setPanelOpen, activeModal, secretEnvList } = actions const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const appACLCapabilities = getAppACLCapabilities(appDetail?.permission_keys, { @@ -96,11 +86,11 @@ export const AppInfoView = ({ workspacePermissionKeys, }) - if (!appDetail) - return null + if (!appDetail) return null const detailLayerOpen = onlyShowDetail ? openState : panelOpen - const shouldRenderDetailLayer = renderDetail && (detailLayerOpen || activeModal || secretEnvList.length > 0) + const shouldRenderDetailLayer = + renderDetail && (detailLayerOpen || activeModal || secretEnvList.length > 0) return ( <div> @@ -109,17 +99,11 @@ export const AppInfoView = ({ appDetail={appDetail} expand={expand} onClick={() => { - if (appACLCapabilities.canAccessLayout) - setPanelOpen(v => !v) + if (appACLCapabilities.canAccessLayout) setPanelOpen((v) => !v) }} /> )} - {shouldRenderDetailLayer && ( - <AppInfoDetailLayer - actions={actions} - open={detailLayerOpen} - /> - )} + {shouldRenderDetailLayer && <AppInfoDetailLayer actions={actions} open={detailLayerOpen} />} </div> ) } diff --git a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts index 36c3366e1ba6f7..766fe27fca8aa1 100644 --- a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts +++ b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts @@ -18,7 +18,14 @@ import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { downloadBlob } from '@/utils/download' -export type AppInfoModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'importDSL' | 'exportWarning' | null +export type AppInfoModalType = + | 'edit' + | 'duplicate' + | 'delete' + | 'switch' + | 'importDSL' + | 'exportWarning' + | null type UseAppInfoActionsParams = { onDetailExpand?: (expand: boolean) => void @@ -42,9 +49,7 @@ const createInitialUiState = (resetKey?: string): AppInfoUiState => ({ }) const resolveStateAction = <T>(value: SetStateAction<T>, previous: T) => { - return typeof value === 'function' - ? (value as (previous: T) => T)(previous) - : value + return typeof value === 'function' ? (value as (previous: T) => T)(previous) : value } const getCurrentUiState = (state: AppInfoUiState, resetKey?: string) => { @@ -56,8 +61,8 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction const { replace } = useRouter() const queryClient = useQueryClient() const { onPlanInfoChanged } = useProviderContext() - const appDetail = useAppStore(state => state.appDetail) - const setAppDetail = useAppStore(state => state.setAppDetail) + const appDetail = useAppStore((state) => state.appDetail) + const setAppDetail = useAppStore((state) => state.setAppDetail) const invalidateAppList = useInvalidateAppList() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isRbacEnabled = systemFeatures.rbac_enabled @@ -68,45 +73,57 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction const activeModal = uiStateMatchesResetKey ? uiState.activeModal : null const secretEnvList = uiStateMatchesResetKey ? uiState.secretEnvList : emptySecretEnvList - const setPanelOpen = useCallback<Dispatch<SetStateAction<boolean>>>((value) => { - setUiState((state) => { - const current = getCurrentUiState(state, resetKey) - return { - ...current, - panelOpen: resolveStateAction(value, current.panelOpen), - } - }) - }, [resetKey]) + const setPanelOpen = useCallback<Dispatch<SetStateAction<boolean>>>( + (value) => { + setUiState((state) => { + const current = getCurrentUiState(state, resetKey) + return { + ...current, + panelOpen: resolveStateAction(value, current.panelOpen), + } + }) + }, + [resetKey], + ) - const setActiveModal = useCallback<Dispatch<SetStateAction<AppInfoModalType>>>((value) => { - setUiState((state) => { - const current = getCurrentUiState(state, resetKey) - return { - ...current, - activeModal: resolveStateAction(value, current.activeModal), - } - }) - }, [resetKey]) + const setActiveModal = useCallback<Dispatch<SetStateAction<AppInfoModalType>>>( + (value) => { + setUiState((state) => { + const current = getCurrentUiState(state, resetKey) + return { + ...current, + activeModal: resolveStateAction(value, current.activeModal), + } + }) + }, + [resetKey], + ) - const setSecretEnvList = useCallback<Dispatch<SetStateAction<EnvironmentVariable[]>>>((value) => { - setUiState((state) => { - const current = getCurrentUiState(state, resetKey) - return { - ...current, - secretEnvList: resolveStateAction(value, current.secretEnvList), - } - }) - }, [resetKey]) + const setSecretEnvList = useCallback<Dispatch<SetStateAction<EnvironmentVariable[]>>>( + (value) => { + setUiState((state) => { + const current = getCurrentUiState(state, resetKey) + return { + ...current, + secretEnvList: resolveStateAction(value, current.secretEnvList), + } + }) + }, + [resetKey], + ) const closePanel = useCallback(() => { setPanelOpen(false) onDetailExpand?.(false) }, [onDetailExpand, setPanelOpen]) - const openModal = useCallback((modal: Exclude<AppInfoModalType, null>) => { - closePanel() - setActiveModal(modal) - }, [closePanel, setActiveModal]) + const openModal = useCallback( + (modal: Exclude<AppInfoModalType, null>) => { + closePanel() + setActiveModal(modal) + }, + [closePanel, setActiveModal], + ) const closeModal = useCallback(() => { setActiveModal(null) @@ -115,49 +132,43 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction const setNeedRefresh = useSetNeedRefreshAppList() const emitAppMetaUpdate = useCallback(() => { - if (!appDetail?.id) - return + if (!appDetail?.id) return void import('@/app/components/workflow/collaboration/core/websocket-manager') .then(({ webSocketClient }) => { const socket = webSocketClient.getSocket(appDetail.id) - if (!socket) - return + if (!socket) return socket.emit('collaboration_event', { type: 'app_meta_update', data: { timestamp: Date.now() }, timestamp: Date.now(), }) }) - .catch(() => { }) + .catch(() => {}) }, [appDetail?.id]) useEffect(() => { - if (!appDetail?.id) - return + if (!appDetail?.id) return let unsubscribe: (() => void) | null = null let disposed = false void import('@/app/components/workflow/collaboration/core/collaboration-manager') .then(({ collaborationManager }) => { - if (disposed) - return + if (disposed) return unsubscribe = collaborationManager.onAppMetaUpdate(async () => { try { const res = await fetchAppDetail({ url: '/apps', id: appDetail.id }) - if (disposed) - return + if (disposed) return queryClient.setQueryData([...appDetailQueryKeyPrefix, appDetail.id], res) setAppDetail({ ...res }) - } - catch (error) { + } catch (error) { console.error('failed to refresh app detail from collaboration update:', error) } }) }) - .catch(() => { }) + .catch(() => {}) return () => { disposed = true @@ -165,83 +176,95 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction } }, [appDetail?.id, queryClient, setAppDetail]) - const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ - name, - icon_type, - icon, - icon_background, - description, - use_icon_as_answer_icon, - max_active_requests, - }) => { - if (!appDetail) - return - try { - const app = await updateAppInfo({ - appID: appDetail.id, - name, - icon_type, - icon, - icon_background, - description, - use_icon_as_answer_icon, - max_active_requests, - }) - closeModal() - toast(t($ => $.editDone, { ns: 'app' }), { type: 'success' }) - queryClient.setQueryData([...appDetailQueryKeyPrefix, app.id], app) - setAppDetail(app) - emitAppMetaUpdate() - } - catch { - toast(t($ => $.editFailed, { ns: 'app' }), { type: 'error' }) - } - }, [appDetail, closeModal, queryClient, setAppDetail, t, emitAppMetaUpdate]) + const onEdit: CreateAppModalProps['onConfirm'] = useCallback( + async ({ + name, + icon_type, + icon, + icon_background, + description, + use_icon_as_answer_icon, + max_active_requests, + }) => { + if (!appDetail) return + try { + const app = await updateAppInfo({ + appID: appDetail.id, + name, + icon_type, + icon, + icon_background, + description, + use_icon_as_answer_icon, + max_active_requests, + }) + closeModal() + toast( + t(($) => $.editDone, { ns: 'app' }), + { type: 'success' }, + ) + queryClient.setQueryData([...appDetailQueryKeyPrefix, app.id], app) + setAppDetail(app) + emitAppMetaUpdate() + } catch { + toast( + t(($) => $.editFailed, { ns: 'app' }), + { type: 'error' }, + ) + } + }, + [appDetail, closeModal, queryClient, setAppDetail, t, emitAppMetaUpdate], + ) - const onCopy: DuplicateAppModalProps['onConfirm'] = useCallback(async ({ - name, - icon_type, - icon, - icon_background, - }) => { - if (!appDetail) - return - try { - const newApp = await copyApp({ - appID: appDetail.id, - name, - icon_type, - icon, - icon_background, - mode: appDetail.mode, - }) - closeModal() - toast(t($ => $['newApp.appCreated'], { ns: 'app' }), { type: 'success' }) - setNeedRefresh('1') - onPlanInfoChanged() - getRedirection(newApp, replace, { isRbacEnabled }) - } - catch { - toast(t($ => $['newApp.appCreateFailed'], { ns: 'app' }), { type: 'error' }) - } - }, [appDetail, closeModal, isRbacEnabled, onPlanInfoChanged, replace, setNeedRefresh, t]) + const onCopy: DuplicateAppModalProps['onConfirm'] = useCallback( + async ({ name, icon_type, icon, icon_background }) => { + if (!appDetail) return + try { + const newApp = await copyApp({ + appID: appDetail.id, + name, + icon_type, + icon, + icon_background, + mode: appDetail.mode, + }) + closeModal() + toast( + t(($) => $['newApp.appCreated'], { ns: 'app' }), + { type: 'success' }, + ) + setNeedRefresh('1') + onPlanInfoChanged() + getRedirection(newApp, replace, { isRbacEnabled }) + } catch { + toast( + t(($) => $['newApp.appCreateFailed'], { ns: 'app' }), + { type: 'error' }, + ) + } + }, + [appDetail, closeModal, isRbacEnabled, onPlanInfoChanged, replace, setNeedRefresh, t], + ) - const onExport = useCallback(async (include = false) => { - if (!appDetail) - return - try { - const { data } = await exportAppConfig({ appID: appDetail.id, include }) - const file = new Blob([data], { type: 'application/yaml' }) - downloadBlob({ data: file, fileName: `${appDetail.name}.yml` }) - } - catch { - toast(t($ => $.exportFailed, { ns: 'app' }), { type: 'error' }) - } - }, [appDetail, t]) + const onExport = useCallback( + async (include = false) => { + if (!appDetail) return + try { + const { data } = await exportAppConfig({ appID: appDetail.id, include }) + const file = new Blob([data], { type: 'application/yaml' }) + downloadBlob({ data: file, fileName: `${appDetail.name}.yml` }) + } catch { + toast( + t(($) => $.exportFailed, { ns: 'app' }), + { type: 'error' }, + ) + } + }, + [appDetail, t], + ) const exportCheck = useCallback(async () => { - if (!appDetail) - return + if (!appDetail) return if (appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.ADVANCED_CHAT) { onExport() return @@ -250,38 +273,44 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction }, [appDetail, onExport, setActiveModal]) const handleConfirmExport = useCallback(async () => { - if (!appDetail) - return + if (!appDetail) return try { const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`) - const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret') + const list = (workflowDraft.environment_variables || []).filter( + (env) => env.value_type === 'secret', + ) if (list.length === 0) { onExport() return } setSecretEnvList(list) - } - catch { - toast(t($ => $.exportFailed, { ns: 'app' }), { type: 'error' }) - } - finally { + } catch { + toast( + t(($) => $.exportFailed, { ns: 'app' }), + { type: 'error' }, + ) + } finally { closeModal() } }, [appDetail, closeModal, onExport, setSecretEnvList, t]) const onConfirmDelete = useCallback(async () => { - if (!appDetail) - return + if (!appDetail) return try { await deleteApp(appDetail.id) - toast(t($ => $.appDeleted, { ns: 'app' }), { type: 'success' }) + toast( + t(($) => $.appDeleted, { ns: 'app' }), + { type: 'success' }, + ) invalidateAppList() onPlanInfoChanged() setAppDetail() replace('/apps') - } - catch (e: unknown) { - toast(`${t($ => $.appDeleteFailed, { ns: 'app' })}${e instanceof Error && e.message ? `: ${e.message}` : ''}`, { type: 'error' }) + } catch (e: unknown) { + toast( + `${t(($) => $.appDeleteFailed, { ns: 'app' })}${e instanceof Error && e.message ? `: ${e.message}` : ''}`, + { type: 'error' }, + ) } closeModal() }, [appDetail, closeModal, invalidateAppList, onPlanInfoChanged, replace, setAppDetail, t]) diff --git a/web/app/components/app-sidebar/basic.tsx b/web/app/components/app-sidebar/basic.tsx index dea277adc7514a..503b1b3afb0ece 100644 --- a/web/app/components/app-sidebar/basic.tsx +++ b/web/app/components/app-sidebar/basic.tsx @@ -1,9 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { - ApiAggregate, - WindowCursor, -} from '@/app/components/base/icons/src/vender/workflow' +import { ApiAggregate, WindowCursor } from '@/app/components/base/icons/src/vender/workflow' import { Infotip } from '@/app/components/base/infotip' import AppIcon from '../base/app-icon' @@ -15,7 +12,7 @@ type IAppBasicProps = { name: string type: string | React.ReactNode hoverTip?: string - textStyle?: { main?: string, extra?: string } + textStyle?: { main?: string; extra?: string } isExtraInLine?: boolean mode?: string hideType?: boolean @@ -23,16 +20,34 @@ type IAppBasicProps = { const DatasetSvg = ( <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path fillRule="evenodd" clipRule="evenodd" d="M0.833497 5.13481C0.833483 4.69553 0.83347 4.31654 0.858973 4.0044C0.88589 3.67495 0.94532 3.34727 1.10598 3.03195C1.34567 2.56155 1.72812 2.17909 2.19852 1.93941C2.51384 1.77875 2.84152 1.71932 3.17097 1.6924C3.48312 1.6669 3.86209 1.66691 4.30137 1.66693L7.62238 1.66684C8.11701 1.66618 8.55199 1.66561 8.95195 1.80356C9.30227 1.92439 9.62134 2.12159 9.88607 2.38088C10.1883 2.67692 10.3823 3.06624 10.603 3.50894L11.3484 5.00008H14.3679C15.0387 5.00007 15.5924 5.00006 16.0434 5.03691C16.5118 5.07518 16.9424 5.15732 17.3468 5.36339C17.974 5.68297 18.4839 6.19291 18.8035 6.82011C19.0096 7.22456 19.0917 7.65515 19.13 8.12356C19.1668 8.57455 19.1668 9.12818 19.1668 9.79898V13.5345C19.1668 14.2053 19.1668 14.7589 19.13 15.2099C19.0917 15.6784 19.0096 16.1089 18.8035 16.5134C18.4839 17.1406 17.974 17.6505 17.3468 17.9701C16.9424 18.1762 16.5118 18.2583 16.0434 18.2966C15.5924 18.3334 15.0387 18.3334 14.3679 18.3334H5.63243C4.96163 18.3334 4.40797 18.3334 3.95698 18.2966C3.48856 18.2583 3.05798 18.1762 2.65353 17.9701C2.02632 17.6505 1.51639 17.1406 1.19681 16.5134C0.990734 16.1089 0.908597 15.6784 0.870326 15.2099C0.833478 14.7589 0.833487 14.2053 0.833497 13.5345V5.13481ZM7.51874 3.33359C8.17742 3.33359 8.30798 3.34447 8.4085 3.37914C8.52527 3.41942 8.63163 3.48515 8.71987 3.57158C8.79584 3.64598 8.86396 3.7579 9.15852 4.34704L9.48505 5.00008L2.50023 5.00008C2.50059 4.61259 2.50314 4.34771 2.5201 4.14012C2.5386 3.91374 2.57 3.82981 2.59099 3.7886C2.67089 3.6318 2.79837 3.50432 2.95517 3.42442C2.99638 3.40343 3.08031 3.37203 3.30669 3.35353C3.54281 3.33424 3.85304 3.33359 4.3335 3.33359H7.51874Z" fill="#444CE7" /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M0.833497 5.13481C0.833483 4.69553 0.83347 4.31654 0.858973 4.0044C0.88589 3.67495 0.94532 3.34727 1.10598 3.03195C1.34567 2.56155 1.72812 2.17909 2.19852 1.93941C2.51384 1.77875 2.84152 1.71932 3.17097 1.6924C3.48312 1.6669 3.86209 1.66691 4.30137 1.66693L7.62238 1.66684C8.11701 1.66618 8.55199 1.66561 8.95195 1.80356C9.30227 1.92439 9.62134 2.12159 9.88607 2.38088C10.1883 2.67692 10.3823 3.06624 10.603 3.50894L11.3484 5.00008H14.3679C15.0387 5.00007 15.5924 5.00006 16.0434 5.03691C16.5118 5.07518 16.9424 5.15732 17.3468 5.36339C17.974 5.68297 18.4839 6.19291 18.8035 6.82011C19.0096 7.22456 19.0917 7.65515 19.13 8.12356C19.1668 8.57455 19.1668 9.12818 19.1668 9.79898V13.5345C19.1668 14.2053 19.1668 14.7589 19.13 15.2099C19.0917 15.6784 19.0096 16.1089 18.8035 16.5134C18.4839 17.1406 17.974 17.6505 17.3468 17.9701C16.9424 18.1762 16.5118 18.2583 16.0434 18.2966C15.5924 18.3334 15.0387 18.3334 14.3679 18.3334H5.63243C4.96163 18.3334 4.40797 18.3334 3.95698 18.2966C3.48856 18.2583 3.05798 18.1762 2.65353 17.9701C2.02632 17.6505 1.51639 17.1406 1.19681 16.5134C0.990734 16.1089 0.908597 15.6784 0.870326 15.2099C0.833478 14.7589 0.833487 14.2053 0.833497 13.5345V5.13481ZM7.51874 3.33359C8.17742 3.33359 8.30798 3.34447 8.4085 3.37914C8.52527 3.41942 8.63163 3.48515 8.71987 3.57158C8.79584 3.64598 8.86396 3.7579 9.15852 4.34704L9.48505 5.00008L2.50023 5.00008C2.50059 4.61259 2.50314 4.34771 2.5201 4.14012C2.5386 3.91374 2.57 3.82981 2.59099 3.7886C2.67089 3.6318 2.79837 3.50432 2.95517 3.42442C2.99638 3.40343 3.08031 3.37203 3.30669 3.35353C3.54281 3.33424 3.85304 3.33359 4.3335 3.33359H7.51874Z" + fill="#444CE7" + /> </svg> ) const NotionSvg = ( <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <g clipPath="url(#clip0_6294_13848)"> - <path fill-rule="evenodd" clip-rule="evenodd" d="M4.287 21.9133L1.70748 18.6999C1.08685 17.9267 0.75 16.976 0.75 15.9974V4.36124C0.75 2.89548 1.92269 1.67923 3.43553 1.57594L15.3991 0.759137C16.2682 0.699797 17.1321 0.930818 17.8461 1.41353L22.0494 4.25543C22.8018 4.76414 23.25 5.59574 23.25 6.48319V19.7124C23.25 21.1468 22.0969 22.3345 20.6157 22.4256L7.3375 23.243C6.1555 23.3158 5.01299 22.8178 4.287 21.9133Z" fill="white" /> - <path d="M8.43607 10.1842V10.0318C8.43607 9.64564 8.74535 9.32537 9.14397 9.29876L12.0475 9.10491L16.0628 15.0178V9.82823L15.0293 9.69046V9.6181C15.0293 9.22739 15.3456 8.90501 15.7493 8.88433L18.3912 8.74899V9.12918C18.3912 9.30765 18.2585 9.46031 18.0766 9.49108L17.4408 9.59861V18.0029L16.6429 18.2773C15.9764 18.5065 15.2343 18.2611 14.8527 17.6853L10.9545 11.803V17.4173L12.1544 17.647L12.1377 17.7583C12.0853 18.1069 11.7843 18.3705 11.4202 18.3867L8.43607 18.5195C8.39662 18.1447 8.67758 17.8093 9.06518 17.7686L9.45771 17.7273V10.2416L8.43607 10.1842Z" fill="black" /> - <path fill-rule="evenodd" clip-rule="evenodd" d="M15.5062 2.22521L3.5426 3.04201C2.82599 3.09094 2.27051 3.66706 2.27051 4.36136V15.9975C2.27051 16.6499 2.49507 17.2837 2.90883 17.7992L5.48835 21.0126C5.90541 21.5322 6.56174 21.8183 7.24076 21.7765L20.519 20.9591C21.1995 20.9172 21.7293 20.3716 21.7293 19.7125V6.48332C21.7293 6.07557 21.5234 5.69348 21.1777 5.45975L16.9743 2.61784C16.546 2.32822 16.0277 2.1896 15.5062 2.22521ZM4.13585 4.54287C3.96946 4.41968 4.04865 4.16303 4.25768 4.14804L15.5866 3.33545C15.9476 3.30956 16.3063 3.40896 16.5982 3.61578L18.8713 5.22622C18.9576 5.28736 18.9171 5.41935 18.8102 5.42516L6.8129 6.07764C6.44983 6.09739 6.09144 5.99073 5.80276 5.77699L4.13585 4.54287ZM6.25018 8.12315C6.25018 7.7334 6.56506 7.41145 6.9677 7.38952L19.6523 6.69871C20.0447 6.67734 20.375 6.97912 20.375 7.35898V18.8141C20.375 19.2031 20.0613 19.5247 19.6594 19.5476L7.05516 20.2648C6.61845 20.2896 6.25018 19.954 6.25018 19.5312V8.12315Z" fill="black" /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M4.287 21.9133L1.70748 18.6999C1.08685 17.9267 0.75 16.976 0.75 15.9974V4.36124C0.75 2.89548 1.92269 1.67923 3.43553 1.57594L15.3991 0.759137C16.2682 0.699797 17.1321 0.930818 17.8461 1.41353L22.0494 4.25543C22.8018 4.76414 23.25 5.59574 23.25 6.48319V19.7124C23.25 21.1468 22.0969 22.3345 20.6157 22.4256L7.3375 23.243C6.1555 23.3158 5.01299 22.8178 4.287 21.9133Z" + fill="white" + /> + <path + d="M8.43607 10.1842V10.0318C8.43607 9.64564 8.74535 9.32537 9.14397 9.29876L12.0475 9.10491L16.0628 15.0178V9.82823L15.0293 9.69046V9.6181C15.0293 9.22739 15.3456 8.90501 15.7493 8.88433L18.3912 8.74899V9.12918C18.3912 9.30765 18.2585 9.46031 18.0766 9.49108L17.4408 9.59861V18.0029L16.6429 18.2773C15.9764 18.5065 15.2343 18.2611 14.8527 17.6853L10.9545 11.803V17.4173L12.1544 17.647L12.1377 17.7583C12.0853 18.1069 11.7843 18.3705 11.4202 18.3867L8.43607 18.5195C8.39662 18.1447 8.67758 17.8093 9.06518 17.7686L9.45771 17.7273V10.2416L8.43607 10.1842Z" + fill="black" + /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M15.5062 2.22521L3.5426 3.04201C2.82599 3.09094 2.27051 3.66706 2.27051 4.36136V15.9975C2.27051 16.6499 2.49507 17.2837 2.90883 17.7992L5.48835 21.0126C5.90541 21.5322 6.56174 21.8183 7.24076 21.7765L20.519 20.9591C21.1995 20.9172 21.7293 20.3716 21.7293 19.7125V6.48332C21.7293 6.07557 21.5234 5.69348 21.1777 5.45975L16.9743 2.61784C16.546 2.32822 16.0277 2.1896 15.5062 2.22521ZM4.13585 4.54287C3.96946 4.41968 4.04865 4.16303 4.25768 4.14804L15.5866 3.33545C15.9476 3.30956 16.3063 3.40896 16.5982 3.61578L18.8713 5.22622C18.9576 5.28736 18.9171 5.41935 18.8102 5.42516L6.8129 6.07764C6.44983 6.09739 6.09144 5.99073 5.80276 5.77699L4.13585 4.54287ZM6.25018 8.12315C6.25018 7.7334 6.56506 7.41145 6.9677 7.38952L19.6523 6.69871C20.0447 6.67734 20.375 6.97912 20.375 7.35898V18.8141C20.375 19.2031 20.0613 19.5247 19.6594 19.5476L7.05516 20.2648C6.61845 20.2896 6.25018 19.954 6.25018 19.5312V8.12315Z" + fill="black" + /> </g> <defs> <clipPath id="clip0_6294_13848"> @@ -49,16 +64,32 @@ const ICON_MAP = { <ApiAggregate className="size-4 text-text-primary-on-surface" /> </div> ), - dataset: <AppIcon innerIcon={DatasetSvg} className="border-[0.5px]! border-indigo-100! bg-indigo-25!" />, + dataset: ( + <AppIcon innerIcon={DatasetSvg} className="border-[0.5px]! border-indigo-100! bg-indigo-25!" /> + ), webapp: ( <div className="rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md"> <WindowCursor className="size-4 text-text-primary-on-surface" /> </div> ), - notion: <AppIcon innerIcon={NotionSvg} className="border-[0.5px]! border-indigo-100! bg-white!" />, + notion: ( + <AppIcon innerIcon={NotionSvg} className="border-[0.5px]! border-indigo-100! bg-white!" /> + ), } -export default function AppBasic({ icon, icon_background, name, isExternal, type, hoverTip, textStyle, isExtraInLine, mode = 'expand', iconType = 'app', hideType }: IAppBasicProps) { +export default function AppBasic({ + icon, + icon_background, + name, + isExternal, + type, + hoverTip, + textStyle, + isExtraInLine, + mode = 'expand', + iconType = 'app', + hideType, +}: IAppBasicProps) { const { t } = useTranslation() return ( @@ -68,30 +99,26 @@ export default function AppBasic({ icon, icon_background, name, isExternal, type <AppIcon icon={icon} background={icon_background} /> </div> )} - {iconType !== 'app' - && ( - <div className="mr-2 shrink-0"> - {ICON_MAP[iconType]} - </div> - )} + {iconType !== 'app' && <div className="mr-2 shrink-0">{ICON_MAP[iconType]}</div>} {mode === 'expand' && ( <div className="group w-full"> - <div className={`flex flex-row items-center system-md-semibold text-text-secondary group-hover:text-text-primary ${textStyle?.main ?? ''}`}> - <div className="min-w-0 overflow-hidden break-normal text-ellipsis"> - {name} - </div> - {hoverTip - && ( - <Infotip aria-label={hoverTip} className="ml-1" popupClassName="w-[240px]"> - {hoverTip} - </Infotip> - )} + <div + className={`flex flex-row items-center system-md-semibold text-text-secondary group-hover:text-text-primary ${textStyle?.main ?? ''}`} + > + <div className="min-w-0 overflow-hidden break-normal text-ellipsis">{name}</div> + {hoverTip && ( + <Infotip aria-label={hoverTip} className="ml-1" popupClassName="w-[240px]"> + {hoverTip} + </Infotip> + )} </div> {!hideType && isExtraInLine && ( <div className="flex system-2xs-medium-uppercase text-text-tertiary">{type}</div> )} {!hideType && !isExtraInLine && ( - <div className="system-2xs-medium-uppercase text-text-tertiary">{isExternal ? t($ => $.externalTag, { ns: 'dataset' }) : type}</div> + <div className="system-2xs-medium-uppercase text-text-tertiary"> + {isExternal ? t(($) => $.externalTag, { ns: 'dataset' }) : type} + </div> )} </div> )} diff --git a/web/app/components/app-sidebar/dataset-detail-section.tsx b/web/app/components/app-sidebar/dataset-detail-section.tsx index bdb50da1bae297..b9e5e2c19b95c8 100644 --- a/web/app/components/app-sidebar/dataset-detail-section.tsx +++ b/web/app/components/app-sidebar/dataset-detail-section.tsx @@ -38,9 +38,7 @@ type DatasetDetailSectionProps = { expand?: boolean } -const DatasetDetailSection = ({ - expand = true, -}: DatasetDetailSectionProps) => { +const DatasetDetailSection = ({ expand = true }: DatasetDetailSectionProps) => { const { t } = useTranslation() const pathname = usePathname() const datasetId = getDatasetIdFromPathname(pathname) @@ -49,65 +47,74 @@ const DatasetDetailSection = ({ const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled const { data: datasetRes, refetch: mutateDatasetRes } = useDatasetDetail(datasetId ?? '') - const { data: relatedApps } = useDatasetRelatedApps(datasetId ?? '', { enabled: !!datasetId && !!datasetRes }) - const datasetACLCapabilities = useMemo(() => getDatasetACLCapabilities(datasetRes?.permission_keys, { - currentUserId, - resourceMaintainer: datasetRes?.maintainer, - workspacePermissionKeys, - isRbacEnabled, - }), [currentUserId, datasetRes?.maintainer, datasetRes?.permission_keys, isRbacEnabled, workspacePermissionKeys]) + const { data: relatedApps } = useDatasetRelatedApps(datasetId ?? '', { + enabled: !!datasetId && !!datasetRes, + }) + const datasetACLCapabilities = useMemo( + () => + getDatasetACLCapabilities(datasetRes?.permission_keys, { + currentUserId, + resourceMaintainer: datasetRes?.maintainer, + workspacePermissionKeys, + isRbacEnabled, + }), + [ + currentUserId, + datasetRes?.maintainer, + datasetRes?.permission_keys, + isRbacEnabled, + workspacePermissionKeys, + ], + ) const isButtonDisabledWithPipeline = useMemo(() => { - if (!datasetRes) - return true - if (datasetRes.provider === 'external') - return false - if (datasetRes.runtime_mode === 'general') - return false + if (!datasetRes) return true + if (datasetRes.provider === 'external') return false + if (datasetRes.runtime_mode === 'general') return false return !datasetRes.is_published }, [datasetRes]) const navigation = useMemo(() => { - if (!datasetId) - return [] + if (!datasetId) return [] const baseNavigation = [ { - name: t($ => $['datasetMenus.hitTesting'], { ns: 'common' }), + name: t(($) => $['datasetMenus.hitTesting'], { ns: 'common' }), href: `/datasets/${datasetId}/hitTesting`, icon: RiFocus2Line, selectedIcon: RiFocus2Fill, disabled: isButtonDisabledWithPipeline || !datasetACLCapabilities.canRetrievalRecall, }, { - name: t($ => $['datasetMenus.settings'], { ns: 'common' }), + name: t(($) => $['datasetMenus.settings'], { ns: 'common' }), href: `/datasets/${datasetId}/settings`, icon: RiEqualizer2Line, selectedIcon: RiEqualizer2Fill, disabled: false, }, ...(datasetACLCapabilities.canAccessConfig - ? [{ - name: t($ => $['settings.resourceAccess'], { ns: 'common' }), - href: `/datasets/${datasetId}/access-config`, - icon: RiLock2Line, - selectedIcon: RiLock2Fill, - disabled: false, - }] - : [] - ), + ? [ + { + name: t(($) => $['settings.resourceAccess'], { ns: 'common' }), + href: `/datasets/${datasetId}/access-config`, + icon: RiLock2Line, + selectedIcon: RiLock2Fill, + disabled: false, + }, + ] + : []), ] if (datasetRes?.provider !== 'external') { baseNavigation.unshift({ - name: t($ => $['datasetMenus.pipeline'], { ns: 'common' }), + name: t(($) => $['datasetMenus.pipeline'], { ns: 'common' }), href: `/datasets/${datasetId}/pipeline`, icon: PipelineLine as RemixiconComponentType, selectedIcon: PipelineFill as RemixiconComponentType, disabled: false, }) baseNavigation.unshift({ - name: t($ => $['datasetMenus.documents'], { ns: 'common' }), + name: t(($) => $['datasetMenus.documents'], { ns: 'common' }), href: `/datasets/${datasetId}/documents`, icon: RiFileTextLine, selectedIcon: RiFileTextFill, @@ -118,15 +125,15 @@ const DatasetDetailSection = ({ return baseNavigation }, [t, datasetId, isButtonDisabledWithPipeline, datasetRes?.provider, datasetACLCapabilities]) - if (!datasetRes) - return null + if (!datasetRes) return null return ( - <DatasetDetailContext.Provider value={{ - indexingTechnique: datasetRes.indexing_technique, - dataset: datasetRes, - mutateDatasetRes, - }} + <DatasetDetailContext.Provider + value={{ + indexingTechnique: datasetRes.indexing_technique, + dataset: datasetRes, + mutateDatasetRes, + }} > <div className={cn('flex min-h-0 flex-1 flex-col', expand ? 'px-2 pb-2' : 'pb-2')}> {!expand && ( @@ -142,7 +149,7 @@ const DatasetDetailSection = ({ <DatasetInfo expand={expand} /> </div> <nav className={cn('mt-3 flex flex-col gap-y-0.5 pb-2', expand ? 'px-1' : 'px-3')}> - {navigation.map(item => ( + {navigation.map((item) => ( <NavLink key={item.href} mode={expand ? 'expand' : 'collapse'} diff --git a/web/app/components/app-sidebar/dataset-detail-sidebar.tsx b/web/app/components/app-sidebar/dataset-detail-sidebar.tsx index e969177084d6c3..aad4d4fe0de9b1 100644 --- a/web/app/components/app-sidebar/dataset-detail-sidebar.tsx +++ b/web/app/components/app-sidebar/dataset-detail-sidebar.tsx @@ -7,12 +7,7 @@ import DatasetDetailTop from './dataset-detail-top' export function DatasetDetailSidebar() { return ( <DetailSidebarFrame - renderTop={({ expand, onToggle }) => ( - <DatasetDetailTop - expand={expand} - onToggle={onToggle} - /> - )} + renderTop={({ expand, onToggle }) => <DatasetDetailTop expand={expand} onToggle={onToggle} />} renderSection={({ expand }) => <DatasetDetailSection expand={expand} />} /> ) diff --git a/web/app/components/app-sidebar/dataset-detail-top.tsx b/web/app/components/app-sidebar/dataset-detail-top.tsx index 597020ab09ffe4..3a9f11cc054268 100644 --- a/web/app/components/app-sidebar/dataset-detail-top.tsx +++ b/web/app/components/app-sidebar/dataset-detail-top.tsx @@ -16,10 +16,7 @@ type DatasetDetailTopProps = { const SEARCH_SHORTCUT = ['Mod', 'K'] -const DatasetDetailTop = ({ - expand = true, - onToggle, -}: DatasetDetailTopProps) => { +const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) => { const { t } = useTranslation() const setGotoAnythingOpen = useSetGotoAnythingOpen() @@ -43,7 +40,7 @@ const DatasetDetailTop = ({ <div className="flex min-w-0 flex-1 items-center gap-px"> <Link href="/" - aria-label={t($ => $['mainNav.home'], { ns: 'common' })} + aria-label={t(($) => $['mainNav.home'], { ns: 'common' })} className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 text-text-tertiary transition-colors hover:bg-background-default-hover hover:text-text-secondary" > <span aria-hidden className="i-ri-arrow-left-s-line size-4" /> @@ -51,14 +48,12 @@ const DatasetDetailTop = ({ </Link> {expand && ( <> - <span className="shrink-0 system-md-regular text-text-quaternary"> - / - </span> + <span className="shrink-0 system-md-regular text-text-quaternary">/</span> <Link href="/datasets" className="shrink-0 truncate rounded-lg px-1.5 py-2 system-sm-semibold-uppercase text-text-secondary transition-colors hover:bg-background-default-hover hover:text-text-primary" > - {t($ => $['menus.datasets'], { ns: 'common' })} + {t(($) => $['menus.datasets'], { ns: 'common' })} </Link> </> )} @@ -66,21 +61,24 @@ const DatasetDetailTop = ({ {expand && ( <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['gotoAnything.searchTitle'], { ns: 'app' })} + aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })} className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary" onClick={() => setGotoAnythingOpen(true)} > <span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" /> </button> - )} + } /> - <TooltipContent placement="bottom" className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]"> - <span className="px-0.5">{t($ => $['gotoAnything.quickAction'], { ns: 'app' })}</span> + <TooltipContent + placement="bottom" + className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]" + > + <span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span> <KbdGroup> - {SEARCH_SHORTCUT.map(key => ( + {SEARCH_SHORTCUT.map((key) => ( <Kbd key={key}>{formatForDisplay(key)}</Kbd> ))} </KbdGroup> diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index 65a8f1bc4b4c92..df9bc9badd1040 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -3,11 +3,7 @@ import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import { - ChunkingMode, - DatasetPermission, - DataSourceType, -} from '@/models/datasets' +import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import { DatasetACLPermission } from '@/utils/permission' import Dropdown from '../dropdown' @@ -28,11 +24,12 @@ const mockAppContextState = vi.hoisted(() => ({ }, })) -const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, { - systemFeatures: { - rbac_enabled: mockIsRbacEnabled, - }, -}) +const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => + renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, + }) const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ id: 'dataset-1', @@ -105,7 +102,8 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }), + useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => + selector({ dataset: mockDataset }), })) vi.mock('@/context/account-state', async (importOriginal) => { @@ -130,7 +128,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -168,12 +167,15 @@ vi.mock('@/app/components/datasets/rename-modal', () => ({ onClose: () => void onSuccess?: () => void }) => { - if (!show) - return null + if (!show) return null return ( <div data-testid="rename-modal"> - <button type="button" onClick={onSuccess}>Success</button> - <button type="button" onClick={onClose}>Close</button> + <button type="button" onClick={onSuccess}> + Success + </button> + <button type="button" onClick={onClose}> + Close + </button> </div> ) }, diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index 96317ed4082285..6782bfdd873eb1 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -3,11 +3,7 @@ import { createEvent, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import { - ChunkingMode, - DatasetPermission, - DataSourceType, -} from '@/models/datasets' +import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import { DatasetACLPermission } from '@/utils/permission' import DatasetInfo from '..' @@ -32,11 +28,12 @@ const mockAppContextState = vi.hoisted(() => ({ }, })) -const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, { - systemFeatures: { - rbac_enabled: mockIsRbacEnabled, - }, -}) +const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => + renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, + }) const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ id: 'dataset-1', @@ -118,7 +115,8 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }), + useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => + selector({ dataset: mockDataset }), })) vi.mock('@/context/account-state', async (importOriginal) => { @@ -143,7 +141,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -183,12 +182,15 @@ vi.mock('@/app/components/datasets/rename-modal', () => ({ onClose: () => void onSuccess?: () => void }) => { - if (!show) - return null + if (!show) return null return ( <div data-testid="rename-modal"> - <button type="button" onClick={onSuccess}>Success</button> - <button type="button" onClick={onClose}>Close</button> + <button type="button" onClick={onSuccess}> + Success + </button> + <button type="button" onClick={onClose}> + Close + </button> </div> ) }, @@ -344,7 +346,9 @@ describe('Menu', () => { // Assert expect(screen.getByText('common.operation.edit')).toBeInTheDocument() - expect(screen.queryByText('datasetPipeline.operations.exportPipeline')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetPipeline.operations.exportPipeline'), + ).not.toBeInTheDocument() expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() }) }) @@ -441,10 +445,7 @@ describe('Dropdown', () => { mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline', - permission_keys: [ - DatasetACLPermission.Edit, - DatasetACLPermission.ImportExportDSL, - ], + permission_keys: [DatasetACLPermission.Edit, DatasetACLPermission.ImportExportDSL], }) render(<Dropdown expand />) diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index 61bd7001eea684..f95a950d27288d 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -45,24 +45,22 @@ type JsonErrorResponse = { } const isJsonErrorResponse = (error: unknown): error is JsonErrorResponse => { - return typeof error === 'object' - && error !== null - && 'json' in error - && typeof error.json === 'function' + return ( + typeof error === 'object' && + error !== null && + 'json' in error && + typeof error.json === 'function' + ) } const getErrorMessage = async (error: unknown) => { - if (!isJsonErrorResponse(error)) - return 'Unknown error' + if (!isJsonErrorResponse(error)) return 'Unknown error' const res = await error.json() return res?.message || 'Unknown error' } -const DropDown = ({ - expand, - triggerClassName, -}: DropDownProps) => { +const DropDown = ({ expand, triggerClassName }: DropDownProps) => { const { t } = useTranslation() const { push, replace } = useRouter() const [open, setOpen] = useState(false) @@ -70,21 +68,32 @@ const DropDown = ({ const [confirmMessage, setConfirmMessage] = useState<string>('') const [showConfirmDelete, setShowConfirmDelete] = useState(false) - const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet + const dataset = useDatasetDetailContextWithSelector((state) => state.dataset) as DataSet const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isRbacEnabled = systemFeatures.rbac_enabled - const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys, { - currentUserId, - resourceMaintainer: dataset?.maintainer, - workspacePermissionKeys, - isRbacEnabled, - }), [dataset?.maintainer, dataset?.permission_keys, currentUserId, isRbacEnabled, workspacePermissionKeys]) - const canShowOperations = datasetACLCapabilities.canEdit - || datasetACLCapabilities.canImportExportDSL - || datasetACLCapabilities.canAccessConfig - || datasetACLCapabilities.canDelete + const datasetACLCapabilities = React.useMemo( + () => + getDatasetACLCapabilities(dataset?.permission_keys, { + currentUserId, + resourceMaintainer: dataset?.maintainer, + workspacePermissionKeys, + isRbacEnabled, + }), + [ + dataset?.maintainer, + dataset?.permission_keys, + currentUserId, + isRbacEnabled, + workspacePermissionKeys, + ], + ) + const canShowOperations = + datasetACLCapabilities.canEdit || + datasetACLCapabilities.canImportExportDSL || + datasetACLCapabilities.canAccessConfig || + datasetACLCapabilities.canDelete const invalidDatasetList = useInvalidDatasetList() const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id]) @@ -103,32 +112,36 @@ const DropDown = ({ const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL() - const handleExportPipeline = useCallback(async (include = false) => { - const { pipeline_id, name } = dataset - if (!pipeline_id) - return - setOpen(false) - try { - const { data } = await exportPipelineConfig({ - pipelineId: pipeline_id, - include, - }) - const file = new Blob([data], { type: 'application/yaml' }) - downloadBlob({ data: file, fileName: `${name}.pipeline` }) - } - catch { - toast.error(t($ => $.exportFailed, { ns: 'app' })) - } - }, [dataset, exportPipelineConfig, t]) + const handleExportPipeline = useCallback( + async (include = false) => { + const { pipeline_id, name } = dataset + if (!pipeline_id) return + setOpen(false) + try { + const { data } = await exportPipelineConfig({ + pipelineId: pipeline_id, + include, + }) + const file = new Blob([data], { type: 'application/yaml' }) + downloadBlob({ data: file, fileName: `${name}.pipeline` }) + } catch { + toast.error(t(($) => $.exportFailed, { ns: 'app' })) + } + }, + [dataset, exportPipelineConfig, t], + ) const detectIsUsedByApp = useCallback(async () => { setOpen(false) try { const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id) - setConfirmMessage(isUsedByApp ? t($ => $.datasetUsedByApp, { ns: 'dataset' })! : t($ => $.deleteDatasetConfirmContent, { ns: 'dataset' })!) + setConfirmMessage( + isUsedByApp + ? t(($) => $.datasetUsedByApp, { ns: 'dataset' })! + : t(($) => $.deleteDatasetConfirmContent, { ns: 'dataset' })!, + ) setShowConfirmDelete(true) - } - catch (e: unknown) { + } catch (e: unknown) { toast.error(await getErrorMessage(e)) } }, [dataset.id, t]) @@ -141,31 +154,29 @@ const DropDown = ({ const onConfirmDelete = useCallback(async () => { try { await deleteDataset(dataset.id) - toast(t($ => $.datasetDeleted, { ns: 'dataset' }), { type: 'success' }) + toast( + t(($) => $.datasetDeleted, { ns: 'dataset' }), + { type: 'success' }, + ) invalidDatasetList() replace('/datasets') - } - finally { + } finally { setShowConfirmDelete(false) } }, [dataset.id, replace, invalidDatasetList, t]) - if (!canShowOperations) - return null + if (!canShowOperations) return null return ( - <DropdownMenu - open={open} - onOpenChange={setOpen} - > + <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger - render={( + render={ <ActionButton - aria-label={t($ => $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.more'], { ns: 'common' })} size={expand ? 'l' : 'm'} className={cn('data-popup-open:bg-state-base-hover', triggerClassName)} /> - )} + } > <span aria-hidden className="i-ri-more-fill size-4" /> </DropdownMenuTrigger> @@ -193,11 +204,14 @@ const DropDown = ({ onSuccess={refreshDataset} /> )} - <AlertDialog open={showConfirmDelete} onOpenChange={open => !open && setShowConfirmDelete(false)}> + <AlertDialog + open={showConfirmDelete} + onOpenChange={(open) => !open && setShowConfirmDelete(false)} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $.deleteDatasetConfirmTitle, { ns: 'dataset' })} + {t(($) => $.deleteDatasetConfirmTitle, { ns: 'dataset' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> {confirmMessage} @@ -205,10 +219,10 @@ const DropDown = ({ </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={onConfirmDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/app-sidebar/dataset-info/index.tsx b/web/app/components/app-sidebar/dataset-info/index.tsx index f3bc00452ab939..c71ce99b28dd8a 100644 --- a/web/app/components/app-sidebar/dataset-info/index.tsx +++ b/web/app/components/app-sidebar/dataset-info/index.tsx @@ -13,11 +13,9 @@ type DatasetInfoProps = { expand: boolean } -const DatasetInfo = ({ - expand, -}: DatasetInfoProps) => { +const DatasetInfo = ({ expand }: DatasetInfoProps) => { const { t } = useTranslation() - const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet + const dataset = useDatasetDetailContextWithSelector((state) => state.dataset) as DataSet const iconInfo = dataset.icon_info || { icon: '📙', icon_type: 'emoji', @@ -35,7 +33,11 @@ const DatasetInfo = ({ )} aria-label={!expand ? dataset.name : undefined} > - <div className={cn(expand ? 'flex w-full items-start gap-2' : 'flex items-center justify-center')}> + <div + className={cn( + expand ? 'flex w-full items-start gap-2' : 'flex items-center justify-center', + )} + > <div className="flex shrink-0 items-center"> <AppIcon size="medium" @@ -58,12 +60,21 @@ const DatasetInfo = ({ </div> <div className="flex w-full min-w-0 items-center gap-2 system-2xs-medium-uppercase text-text-tertiary"> {isExternalProvider && ( - <span className="truncate">{t($ => $.externalTag, { ns: 'dataset' })}</span> + <span className="truncate">{t(($) => $.externalTag, { ns: 'dataset' })}</span> )} {!!(!isExternalProvider && dataset.doc_form && dataset.indexing_technique) && ( <> - <span className="shrink-0 truncate">{t($ => $[`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`], { ns: 'dataset' })}</span> - <span className="min-w-0 truncate">{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span> + <span className="shrink-0 truncate"> + {t(($) => $[`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`], { + ns: 'dataset', + })} + </span> + <span className="min-w-0 truncate"> + {formatIndexingTechniqueAndMethod( + dataset.indexing_technique, + dataset.retrieval_model_dict?.search_method, + )} + </span> </> )} </div> diff --git a/web/app/components/app-sidebar/dataset-info/menu-item.tsx b/web/app/components/app-sidebar/dataset-info/menu-item.tsx index d4265121760565..6e0269d269a181 100644 --- a/web/app/components/app-sidebar/dataset-info/menu-item.tsx +++ b/web/app/components/app-sidebar/dataset-info/menu-item.tsx @@ -7,11 +7,7 @@ type MenuItemProps = { handleClick?: () => void } -const MenuItem = ({ - Icon, - name, - handleClick, -}: MenuItemProps) => { +const MenuItem = ({ Icon, name, handleClick }: MenuItemProps) => { return ( <div className="flex items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover" diff --git a/web/app/components/app-sidebar/dataset-info/menu.tsx b/web/app/components/app-sidebar/dataset-info/menu.tsx index b906569e7658db..8d6644feefb03f 100644 --- a/web/app/components/app-sidebar/dataset-info/menu.tsx +++ b/web/app/components/app-sidebar/dataset-info/menu.tsx @@ -1,9 +1,4 @@ -import { - RiDeleteBinLine, - RiEditLine, - RiFileDownloadLine, - RiLock2Line, -} from '@remixicon/react' +import { RiDeleteBinLine, RiEditLine, RiFileDownloadLine, RiLock2Line } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' @@ -32,7 +27,7 @@ const Menu = ({ openAccessConfig, }: MenuProps) => { const { t } = useTranslation() - const runtimeMode = useDatasetDetailContextWithSelector(state => state.dataset?.runtime_mode) + const runtimeMode = useDatasetDetailContextWithSelector((state) => state.dataset?.runtime_mode) return ( <div className="flex w-[200px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"> @@ -40,21 +35,21 @@ const Menu = ({ {showEdit && ( <MenuItem Icon={RiEditLine} - name={t($ => $['operation.edit'], { ns: 'common' })} + name={t(($) => $['operation.edit'], { ns: 'common' })} handleClick={openRenameModal} /> )} {showExportPipeline && runtimeMode === 'rag_pipeline' && ( <MenuItem Icon={RiFileDownloadLine} - name={t($ => $['operations.exportPipeline'], { ns: 'datasetPipeline' })} + name={t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })} handleClick={handleExportPipeline} /> )} {showAccessConfig && ( <MenuItem Icon={RiLock2Line} - name={t($ => $['settings.resourceAccess'], { ns: 'common' })} + name={t(($) => $['settings.resourceAccess'], { ns: 'common' })} handleClick={openAccessConfig} /> )} @@ -65,7 +60,7 @@ const Menu = ({ <div className="flex flex-col p-1"> <MenuItem Icon={RiDeleteBinLine} - name={t($ => $['operation.delete'], { ns: 'common' })} + name={t(($) => $['operation.delete'], { ns: 'common' })} handleClick={detectIsUsedByApp} /> </div> diff --git a/web/app/components/app-sidebar/nav-link/__tests__/index.spec.tsx b/web/app/components/app-sidebar/nav-link/__tests__/index.spec.tsx index dff81e26412d6f..90983df2247d7a 100644 --- a/web/app/components/app-sidebar/nav-link/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/nav-link/__tests__/index.spec.tsx @@ -10,7 +10,17 @@ vi.mock('@/next/navigation', () => ({ // Mock Next.js Link component vi.mock('@/next/link', () => ({ - default: function MockLink({ children, href, className, title }: { children: React.ReactNode, href: string, className?: string, title?: string }) { + default: function MockLink({ + children, + href, + className, + title, + }: { + children: React.ReactNode + href: string + className?: string + title?: string + }) { return ( <a href={href} className={className} title={title} data-testid="nav-link"> {children} @@ -225,26 +235,14 @@ describe('NavLink Animation and Layout Issues', () => { }) it('should not mark logs active on the annotations pathname', () => { - render( - <NavLink - {...mockProps} - href="/app/123/logs" - pathname="/app/123/annotations" - />, - ) + render(<NavLink {...mockProps} href="/app/123/logs" pathname="/app/123/annotations" />) const linkElement = screen.getByRole('link', { name: 'Orchestrate' }) expect(linkElement).not.toHaveClass('bg-components-menu-item-bg-active') }) it('should use pathname to mark annotations active when rendered outside the app detail route segment', () => { - render( - <NavLink - {...mockProps} - href="/app/123/annotations" - pathname="/app/123/annotations" - />, - ) + render(<NavLink {...mockProps} href="/app/123/annotations" pathname="/app/123/annotations" />) const linkElement = screen.getByRole('link', { name: 'Orchestrate' }) expect(linkElement).toHaveClass('bg-components-menu-item-bg-active') diff --git a/web/app/components/app-sidebar/nav-link/index.tsx b/web/app/components/app-sidebar/nav-link/index.tsx index 1f4e17cfec0fcf..aed01c81ed0815 100644 --- a/web/app/components/app-sidebar/nav-link/index.tsx +++ b/web/app/components/app-sidebar/nav-link/index.tsx @@ -5,12 +5,14 @@ import * as React from 'react' import Link from '@/next/link' import { useSelectedLayoutSegment } from '@/next/navigation' -export type NavIcon = React.ComponentType< - React.PropsWithoutRef<React.ComponentProps<'svg'>> & { - title?: string | undefined - titleId?: string | undefined - } -> | RemixiconComponentType +export type NavIcon = + | React.ComponentType< + React.PropsWithoutRef<React.ComponentProps<'svg'>> & { + title?: string | undefined + titleId?: string | undefined + } + > + | RemixiconComponentType export type NavLinkProps = { name: string @@ -42,8 +44,11 @@ const NavLink = ({ return !pathname && res === 'annotations' ? 'logs' : res } - const formattedSegment = formatSegment(pathname ? pathname.split('/').filter(Boolean).pop() : segment) - const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false) + const formattedSegment = formatSegment( + pathname ? pathname.split('/').filter(Boolean).pop() : segment, + ) + const isActive = + active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false) const NavIcon = isActive ? iconMap.selected : iconMap.normal const isCollapsed = mode !== 'expand' @@ -53,7 +58,9 @@ const NavLink = ({ isActive ? 'border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only' : 'border-transparent system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', - isCollapsed ? 'flex size-8 items-center justify-center p-1.5' : 'flex h-8 items-center rounded-lg pr-1 pl-3', + isCollapsed + ? 'flex size-8 items-center justify-center p-1.5' + : 'flex h-8 items-center rounded-lg pr-1 pl-3', 'rounded-lg', ) @@ -73,16 +80,19 @@ const NavLink = ({ borderClassName, 'cursor-not-allowed rounded-lg system-sm-medium text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover', 'border-transparent', - isCollapsed ? 'flex size-8 items-center justify-center p-1.5' : 'flex h-8 items-center pr-1 pl-3', + isCollapsed + ? 'flex size-8 items-center justify-center p-1.5' + : 'flex h-8 items-center pr-1 pl-3', )} title={mode === 'collapse' ? name : ''} aria-disabled > {renderIcon()} <span - className={cn('overflow-hidden whitespace-nowrap transition-[margin-left,max-width,opacity] duration-200 ease-in-out', mode === 'expand' - ? 'ml-2 max-w-none opacity-100' - : 'ml-0 max-w-0 opacity-0')} + className={cn( + 'overflow-hidden whitespace-nowrap transition-[margin-left,max-width,opacity] duration-200 ease-in-out', + mode === 'expand' ? 'ml-2 max-w-none opacity-100' : 'ml-0 max-w-0 opacity-0', + )} > {name} </span> @@ -101,9 +111,10 @@ const NavLink = ({ > {renderIcon()} <span - className={cn('overflow-hidden whitespace-nowrap transition-[margin-left,max-width,opacity] duration-200 ease-in-out', mode === 'expand' - ? 'ml-2 max-w-none opacity-100' - : 'ml-0 max-w-0 opacity-0')} + className={cn( + 'overflow-hidden whitespace-nowrap transition-[margin-left,max-width,opacity] duration-200 ease-in-out', + mode === 'expand' ? 'ml-2 max-w-none opacity-100' : 'ml-0 max-w-0 opacity-0', + )} > {name} </span> @@ -112,17 +123,13 @@ const NavLink = ({ } return ( - <Link - key={name} - href={href} - className={linkClassName} - title={mode === 'collapse' ? name : ''} - > + <Link key={name} href={href} className={linkClassName} title={mode === 'collapse' ? name : ''}> {renderIcon()} <span - className={cn('overflow-hidden whitespace-nowrap transition-[margin-left,max-width,opacity] duration-200 ease-in-out', mode === 'expand' - ? 'ml-2 max-w-none opacity-100' - : 'ml-0 max-w-0 opacity-0')} + className={cn( + 'overflow-hidden whitespace-nowrap transition-[margin-left,max-width,opacity] duration-200 ease-in-out', + mode === 'expand' ? 'ml-2 max-w-none opacity-100' : 'ml-0 max-w-0 opacity-0', + )} > {name} </span> diff --git a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx index d511c3801cdea5..f61cd08ae75c54 100644 --- a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx +++ b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx @@ -45,7 +45,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -56,7 +57,7 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/utils/download', () => ({ - downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args), + downloadBlob: (args: { data: Blob; fileName: string }) => mockDownloadBlob(args), })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -95,9 +96,8 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({ {children} </button> ), - DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( - mockDropdownOpen ? <div>{children}</div> : null - ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => + mockDropdownOpen ? <div>{children}</div> : null, DropdownMenuItem: ({ children, onClick, @@ -148,8 +148,7 @@ vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ onClose, onConfirm, }: MockCreateSnippetDialogProps) => { - if (!isOpen) - return null + if (!isOpen) return null return ( <div data-testid="create-snippet-dialog"> @@ -159,19 +158,23 @@ vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ <div>{initialValue?.description}</div> <button type="button" - onClick={() => onConfirm({ - name: 'Updated snippet', - description: 'Updated description', - graph: { - nodes: [], - edges: [], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - })} + onClick={() => + onConfirm({ + name: 'Updated snippet', + description: 'Updated description', + graph: { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + }) + } > submit-edit </button> - <button type="button" onClick={onClose}>close-edit</button> + <button type="button" onClick={onClose}> + close-edit + </button> </div> ) }, @@ -237,9 +240,11 @@ describe('SnippetInfoDropdown', () => { describe('Edit Snippet', () => { it('should open the edit dialog and submit snippet updates', async () => { const user = userEvent.setup() - mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => { - options?.onSuccess?.() - }) + mockUpdateMutate.mockImplementation( + (_variables: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }, + ) render(<SnippetInfoDropdown snippet={mockSnippet} />) await user.click(screen.getByRole('button')) @@ -255,16 +260,19 @@ describe('SnippetInfoDropdown', () => { await user.click(screen.getByRole('button', { name: 'submit-edit' })) - expect(mockUpdateMutate).toHaveBeenCalledWith({ - params: { snippetId: mockSnippet.id }, - body: { - name: 'Updated snippet', - description: 'Updated description', + expect(mockUpdateMutate).toHaveBeenCalledWith( + { + params: { snippetId: mockSnippet.id }, + body: { + name: 'Updated snippet', + description: 'Updated description', + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone') }) }) @@ -311,9 +319,11 @@ describe('SnippetInfoDropdown', () => { describe('Delete Snippet', () => { it('should confirm deletion and redirect to the snippets list', async () => { const user = userEvent.setup() - mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => { - options?.onSuccess?.() - }) + mockDeleteMutate.mockImplementation( + (_variables: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }, + ) render(<SnippetInfoDropdown snippet={mockSnippet} />) @@ -325,12 +335,15 @@ describe('SnippetInfoDropdown', () => { await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' })) - expect(mockDeleteMutate).toHaveBeenCalledWith({ - params: { snippetId: mockSnippet.id }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - })) + expect(mockDeleteMutate).toHaveBeenCalledWith( + { + params: { snippetId: mockSnippet.id }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted') expect(mockReplace).toHaveBeenCalledWith('/snippets') }) diff --git a/web/app/components/app-sidebar/snippet-info/dropdown.tsx b/web/app/components/app-sidebar/snippet-info/dropdown.tsx index 454c6ec1e41c4c..a2c8fa469756f7 100644 --- a/web/app/components/app-sidebar/snippet-info/dropdown.tsx +++ b/web/app/components/app-sidebar/snippet-info/dropdown.tsx @@ -23,11 +23,17 @@ import { useAtomValue } from 'jotai' import * as React from 'react' import { useTranslation } from 'react-i18next' import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog' -import { canCreateAndModifySnippets, canManageSnippets } from '@/app/components/snippets/utils/permission' +import { + canCreateAndModifySnippets, + canManageSnippets, +} from '@/app/components/snippets/utils/permission' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useRouter } from '@/next/navigation' -import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets' - +import { + useDeleteSnippetMutation, + useExportSnippetMutation, + useUpdateSnippetMutation, +} from '@/service/use-snippets' import { downloadBlob } from '@/utils/download' type SnippetInfoDropdownProps = { @@ -48,10 +54,13 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { const canManageSnippet = canManageSnippets(workspacePermissionKeys) const canShowOperations = canCreateAndModifySnippet || canManageSnippet - const initialValue = React.useMemo(() => ({ - name: snippet.name, - description: snippet.description ?? undefined, - }), [snippet.description, snippet.name]) + const initialValue = React.useMemo( + () => ({ + name: snippet.name, + description: snippet.description ?? undefined, + }), + [snippet.description, snippet.name], + ) const handleOpenEditDialog = React.useCallback(() => { setOpen(false) @@ -59,87 +68,94 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { }, []) const handleExportSnippet = React.useCallback(async () => { - if (!canCreateAndModifySnippet) - return + if (!canCreateAndModifySnippet) return setOpen(false) try { const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id }) const file = new Blob([data], { type: 'application/yaml' }) downloadBlob({ data: file, fileName: `${snippet.name}.yml` }) - } - catch { - toast.error(t($ => $.exportFailed)) + } catch { + toast.error(t(($) => $.exportFailed)) } }, [canCreateAndModifySnippet, exportSnippetMutation, snippet.id, snippet.name, t]) - const handleEditSnippet = React.useCallback(async ({ name, description }: { - name: string - description: string - }) => { - updateSnippetMutation.mutate({ - params: { snippetId: snippet.id }, - body: { - name, - description, - }, - }, { - onSuccess: () => { - toast.success(t($ => $.editDone)) - setIsEditDialogOpen(false) - }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : t($ => $.editFailed)) - }, - }) - }, [snippet.id, t, updateSnippetMutation]) + const handleEditSnippet = React.useCallback( + async ({ name, description }: { name: string; description: string }) => { + updateSnippetMutation.mutate( + { + params: { snippetId: snippet.id }, + body: { + name, + description, + }, + }, + { + onSuccess: () => { + toast.success(t(($) => $.editDone)) + setIsEditDialogOpen(false) + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : t(($) => $.editFailed)) + }, + }, + ) + }, + [snippet.id, t, updateSnippetMutation], + ) const handleDeleteSnippet = React.useCallback(() => { - deleteSnippetMutation.mutate({ - params: { snippetId: snippet.id }, - }, { - onSuccess: () => { - toast.success(t($ => $.deleted)) - setIsDeleteDialogOpen(false) - replace('/snippets') + deleteSnippetMutation.mutate( + { + params: { snippetId: snippet.id }, }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : t($ => $.deleteFailed)) + { + onSuccess: () => { + toast.success(t(($) => $.deleted)) + setIsDeleteDialogOpen(false) + replace('/snippets') + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : t(($) => $.deleteFailed)) + }, }, - }) + ) }, [deleteSnippetMutation, replace, snippet.id, t]) - if (!canShowOperations) - return null + if (!canShowOperations) return null return ( <> <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger - className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')} + className={cn( + 'action-btn action-btn-m size-6 rounded-md text-text-tertiary', + open && 'bg-state-base-hover text-text-secondary', + )} > <span aria-hidden className="i-ri-more-fill size-4" /> </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="w-[180px] p-1" - > + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[180px] p-1"> {canCreateAndModifySnippet && ( <> <DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}> <span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" /> - <span className="grow">{t($ => $['menu.editInfo'])}</span> + <span className="grow">{t(($) => $['menu.editInfo'])}</span> </DropdownMenuItem> <DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}> - <span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" /> - <span className="grow">{t($ => $['menu.exportSnippet'])}</span> + <span + aria-hidden + className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" + /> + <span className="grow">{t(($) => $['menu.exportSnippet'])}</span> </DropdownMenuItem> </> )} {canManageSnippet && ( <> - {canCreateAndModifySnippet && <DropdownMenuSeparator className="my-1! bg-divider-subtle" />} + {canCreateAndModifySnippet && ( + <DropdownMenuSeparator className="my-1! bg-divider-subtle" /> + )} <DropdownMenuItem className="mx-0 gap-2" variant="destructive" @@ -149,7 +165,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { }} > <span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" /> - <span className="grow">{t($ => $['menu.deleteSnippet'])}</span> + <span className="grow">{t(($) => $['menu.deleteSnippet'])}</span> </DropdownMenuItem> </> )} @@ -160,8 +176,8 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { <CreateSnippetDialog isOpen={isEditDialogOpen} initialValue={initialValue} - title={t($ => $.editDialogTitle)} - confirmText={t($ => $['operation.save'], { ns: 'common' })} + title={t(($) => $.editDialogTitle)} + confirmText={t(($) => $['operation.save'], { ns: 'common' })} isSubmitting={updateSnippetMutation.isPending} onClose={() => setIsEditDialogOpen(false)} onConfirm={handleEditSnippet} @@ -172,21 +188,21 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { <AlertDialogContent className="w-100"> <div className="space-y-2 p-6"> <AlertDialogTitle className="title-md-semi-bold text-text-primary"> - {t($ => $.deleteConfirmTitle)} + {t(($) => $.deleteConfirmTitle)} </AlertDialogTitle> <AlertDialogDescription className="system-sm-regular text-text-tertiary"> - {t($ => $.deleteConfirmContent)} + {t(($) => $.deleteConfirmContent)} </AlertDialogDescription> </div> <AlertDialogActions className="pt-0"> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton loading={deleteSnippetMutation.isPending} onClick={handleDeleteSnippet} > - {t($ => $['menu.deleteSnippet'])} + {t(($) => $['menu.deleteSnippet'])} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/app-sidebar/toggle-button.tsx b/web/app/components/app-sidebar/toggle-button.tsx index ab528aac6880ce..5a7c5c00db90c6 100644 --- a/web/app/components/app-sidebar/toggle-button.tsx +++ b/web/app/components/app-sidebar/toggle-button.tsx @@ -12,16 +12,18 @@ type ToggleTooltipContentProps = { const TOGGLE_SHORTCUT = ['Mod', 'B'] -const ToggleTooltipContent = ({ - expand, -}: ToggleTooltipContentProps) => { +const ToggleTooltipContent = ({ expand }: ToggleTooltipContentProps) => { const { t } = useTranslation() return ( <div className="flex items-center gap-x-1"> - <span className="px-0.5 system-xs-medium text-text-secondary">{expand ? t($ => $['sidebar.collapseSidebar'], { ns: 'layout' }) : t($ => $['sidebar.expandSidebar'], { ns: 'layout' })}</span> + <span className="px-0.5 system-xs-medium text-text-secondary"> + {expand + ? t(($) => $['sidebar.collapseSidebar'], { ns: 'layout' }) + : t(($) => $['sidebar.expandSidebar'], { ns: 'layout' })} + </span> <KbdGroup> - {TOGGLE_SHORTCUT.map(key => ( + {TOGGLE_SHORTCUT.map((key) => ( <Kbd key={key}>{formatForDisplay(key)}</Kbd> ))} </KbdGroup> @@ -47,20 +49,22 @@ const ToggleButton = ({ return ( <Tooltip> <TooltipTrigger - render={( + render={ <Button size="small" onClick={handleToggle} className={cn('rounded-full px-1', className)} /> - )} + } > - {icon - || (iconClassName - ? <span aria-hidden className={cn('size-4', iconClassName)} /> - : expand - ? <span aria-hidden className="i-ri-arrow-left-s-line size-4" /> - : <span aria-hidden className="i-ri-arrow-right-s-line size-4" />)} + {icon || + (iconClassName ? ( + <span aria-hidden className={cn('size-4', iconClassName)} /> + ) : expand ? ( + <span aria-hidden className="i-ri-arrow-left-s-line size-4" /> + ) : ( + <span aria-hidden className="i-ri-arrow-right-s-line size-4" /> + ))} </TooltipTrigger> <TooltipContent placement="right" className="rounded-lg p-1.5"> <ToggleTooltipContent expand={expand} /> diff --git a/web/app/components/app/__tests__/store.spec.ts b/web/app/components/app/__tests__/store.spec.ts index 6245d4b2d3d2e8..6a503e9a2bf6ae 100644 --- a/web/app/components/app/__tests__/store.spec.ts +++ b/web/app/components/app/__tests__/store.spec.ts @@ -18,20 +18,24 @@ describe('app store', () => { }) it('should expose the default state', () => { - expect(useStore.getState()).toEqual(expect.objectContaining({ - appDetail: undefined, - currentLogItem: undefined, - currentLogModalActiveTab: 'DETAIL', - showPromptLogModal: false, - showAgentLogModal: false, - showMessageLogModal: false, - showAppConfigureFeaturesModal: false, - })) + expect(useStore.getState()).toEqual( + expect.objectContaining({ + appDetail: undefined, + currentLogItem: undefined, + currentLogModalActiveTab: 'DETAIL', + showPromptLogModal: false, + showAgentLogModal: false, + showMessageLogModal: false, + showAppConfigureFeaturesModal: false, + }), + ) }) it('should update every mutable field through its actions', () => { const appDetail = { id: 'app-1' } as ReturnType<typeof useStore.getState>['appDetail'] - const currentLogItem = { id: 'message-1' } as ReturnType<typeof useStore.getState>['currentLogItem'] + const currentLogItem = { id: 'message-1' } as ReturnType< + typeof useStore.getState + >['currentLogItem'] useStore.getState().setAppDetail(appDetail) useStore.getState().setCurrentLogItem(currentLogItem) @@ -40,14 +44,16 @@ describe('app store', () => { useStore.getState().setShowAgentLogModal(true) useStore.getState().setShowAppConfigureFeaturesModal(true) - expect(useStore.getState()).toEqual(expect.objectContaining({ - appDetail, - currentLogItem, - currentLogModalActiveTab: 'MESSAGE', - showPromptLogModal: true, - showAgentLogModal: true, - showAppConfigureFeaturesModal: true, - })) + expect(useStore.getState()).toEqual( + expect.objectContaining({ + appDetail, + currentLogItem, + currentLogModalActiveTab: 'MESSAGE', + showPromptLogModal: true, + showAgentLogModal: true, + showAppConfigureFeaturesModal: true, + }), + ) }) it('should reset the active tab when the message log modal closes', () => { diff --git a/web/app/components/app/access-config/__tests__/index.spec.tsx b/web/app/components/app/access-config/__tests__/index.spec.tsx index 5f2780c31b686d..2d6fd26007f89f 100644 --- a/web/app/components/app/access-config/__tests__/index.spec.tsx +++ b/web/app/components/app/access-config/__tests__/index.spec.tsx @@ -16,11 +16,12 @@ const mockAppContext = vi.hoisted(() => ({ let mockIsRbacEnabled = true -const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, { - systemFeatures: { - rbac_enabled: mockIsRbacEnabled, - }, -}) +const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => + renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, + }) const mockAppAccessRules = vi.hoisted(() => ({ items: [] as AccessRulesEditorProps['rules'], @@ -70,7 +71,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -101,9 +103,7 @@ vi.mock('@/service/access-control/use-app-access-config', () => ({ vi.mock('@/app/components/access-rules-editor', () => ({ default: (props: AccessRulesEditorProps) => { mockAccessRulesEditor.props = props - return ( - <div data-testid="access-rules-editor" /> - ) + return <div data-testid="access-rules-editor" /> }, })) @@ -133,7 +133,9 @@ describe('AppAccessConfigPage', () => { it('should render access config title and pass app rules to the editor', () => { render(<AppAccessConfigPage appId="app-1" />) - expect(screen.getByRole('heading', { name: 'common.settings.resourceAccess' })).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'common.settings.resourceAccess' }), + ).toBeInTheDocument() expect(screen.getByText('permission.accessRule.appDescription')).toBeInTheDocument() expect(screen.getByTestId('access-rules-editor')).toBeInTheDocument() expect(mockAccessRulesEditor.props?.className).toBe('w-full max-w-200') @@ -188,33 +190,45 @@ describe('AppAccessConfigPage', () => { render(<AppAccessConfigPage appId="app-1" />) mockAccessRulesEditor.props?.onOpenScopeChange?.('all') - expect(mockMutations.updateOpenScope).toHaveBeenCalledWith('all', expect.objectContaining({ - onError: expect.any(Function), - })) + expect(mockMutations.updateOpenScope).toHaveBeenCalledWith( + 'all', + expect.objectContaining({ + onError: expect.any(Function), + }), + ) mockAccessRulesEditor.props?.onUserAccessPoliciesChange?.('account-1', ['policy-1']) - expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ - accountId: 'account-1', - accessPolicyIds: ['policy-1'], - }, expect.objectContaining({ - onSettled: expect.any(Function), - })) + expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith( + { + accountId: 'account-1', + accessPolicyIds: ['policy-1'], + }, + expect.objectContaining({ + onSettled: expect.any(Function), + }), + ) mockAccessRulesEditor.props?.onAddAccessSubject?.('account-2', ['default']) - expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ - accountId: 'account-2', - accessPolicyIds: ['default'], - }, expect.objectContaining({ - onSettled: expect.any(Function), - })) + expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith( + { + accountId: 'account-2', + accessPolicyIds: ['default'], + }, + expect.objectContaining({ + onSettled: expect.any(Function), + }), + ) mockAccessRulesEditor.props?.onRemoveAccessPolicyMemberBinding?.('account-3', 'policy-3') - expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({ - accessPolicyId: 'policy-3', - accountIds: ['account-3'], - }, expect.objectContaining({ - onSettled: expect.any(Function), - })) + expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith( + { + accessPolicyId: 'policy-3', + accountIds: ['account-3'], + }, + expect.objectContaining({ + onSettled: expect.any(Function), + }), + ) }) it('should not mount access config data hooks when access config permission is missing', () => { diff --git a/web/app/components/app/access-config/index.tsx b/web/app/components/app/access-config/index.tsx index 170fea1dd6dee4..1efea364470d54 100644 --- a/web/app/components/app/access-config/index.tsx +++ b/web/app/components/app/access-config/index.tsx @@ -35,11 +35,17 @@ const AppAccessConfigContent = ({ appId, maintainerId }: AppAccessConfigContentP const { t } = useTranslation() const locale = useLocale() const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale]) - const { data: appAccessRulesResponse, isLoading: isLoadingAppAccessRules } = useAppAccessRules(appId, language) - const { data: appUserAccessSettingsResponse, isLoading: isLoadingAppUserAccessSettings } = useAppUserAccessSettings(appId, language) - const { mutate: updateAppOpenScope, isPending: isUpdatingAppOpenScope } = useUpdateAppOpenScope(appId) + const { data: appAccessRulesResponse, isLoading: isLoadingAppAccessRules } = useAppAccessRules( + appId, + language, + ) + const { data: appUserAccessSettingsResponse, isLoading: isLoadingAppUserAccessSettings } = + useAppUserAccessSettings(appId, language) + const { mutate: updateAppOpenScope, isPending: isUpdatingAppOpenScope } = + useUpdateAppOpenScope(appId) const { mutate: updateAppUserAccessSettings } = useUpdateAppUserAccessSettings(appId) - const { mutate: removeAppAccessPolicyMemberBindings } = useRemoveAppAccessPolicyMemberBindings(appId) + const { mutate: removeAppAccessPolicyMemberBindings } = + useRemoveAppAccessPolicyMemberBindings(appId) const [optimisticOpenScope, setOptimisticOpenScope] = useState<ResourceOpenScope | null>(null) const [updatingAccountId, setUpdatingAccountId] = useState<string | null>(null) @@ -47,32 +53,40 @@ const AppAccessConfigContent = ({ appId, maintainerId }: AppAccessConfigContentP const appUserAccessSettings = appUserAccessSettingsResponse?.data || [] const openScope = optimisticOpenScope || appUserAccessSettingsResponse?.scope - const handleOpenScopeChange = useCallback((nextOpenScope: ResourceOpenScope) => { - if (nextOpenScope === openScope) - return + const handleOpenScopeChange = useCallback( + (nextOpenScope: ResourceOpenScope) => { + if (nextOpenScope === openScope) return - const previousOptimisticOpenScope = optimisticOpenScope - setOptimisticOpenScope(nextOpenScope) - updateAppOpenScope(nextOpenScope, { - onError: () => setOptimisticOpenScope(previousOptimisticOpenScope), - }) - }, [openScope, optimisticOpenScope, updateAppOpenScope]) + const previousOptimisticOpenScope = optimisticOpenScope + setOptimisticOpenScope(nextOpenScope) + updateAppOpenScope(nextOpenScope, { + onError: () => setOptimisticOpenScope(previousOptimisticOpenScope), + }) + }, + [openScope, optimisticOpenScope, updateAppOpenScope], + ) - const handleUserAccessPoliciesChange = useCallback((accountId: string, accessPolicyIds: string[]) => { - setUpdatingAccountId(accountId) - updateAppUserAccessSettings( - { accountId, accessPolicyIds }, - { onSettled: () => setUpdatingAccountId(null) }, - ) - }, [updateAppUserAccessSettings]) + const handleUserAccessPoliciesChange = useCallback( + (accountId: string, accessPolicyIds: string[]) => { + setUpdatingAccountId(accountId) + updateAppUserAccessSettings( + { accountId, accessPolicyIds }, + { onSettled: () => setUpdatingAccountId(null) }, + ) + }, + [updateAppUserAccessSettings], + ) - const handleRemoveAccessPolicyMemberBinding = useCallback((accountId: string, accessPolicyId: string) => { - setUpdatingAccountId(accountId) - removeAppAccessPolicyMemberBindings( - { accessPolicyId, accountIds: [accountId] }, - { onSettled: () => setUpdatingAccountId(null) }, - ) - }, [removeAppAccessPolicyMemberBindings]) + const handleRemoveAccessPolicyMemberBinding = useCallback( + (accountId: string, accessPolicyId: string) => { + setUpdatingAccountId(accountId) + removeAppAccessPolicyMemberBindings( + { accessPolicyId, accountIds: [accountId] }, + { onSettled: () => setUpdatingAccountId(null) }, + ) + }, + [removeAppAccessPolicyMemberBindings], + ) return ( <ScrollArea @@ -80,9 +94,11 @@ const AppAccessConfigContent = ({ appId, maintainerId }: AppAccessConfigContentP slotClassNames={{ viewport: 'overscroll-contain' }} > <header className="flex min-h-15.5 flex-col justify-center px-6 py-3"> - <h1 className="system-xl-semibold text-text-primary">{t($ => $['settings.resourceAccess'], { ns: 'common' })}</h1> + <h1 className="system-xl-semibold text-text-primary"> + {t(($) => $['settings.resourceAccess'], { ns: 'common' })} + </h1> <p className="mt-0.5 system-sm-regular text-text-tertiary"> - {t($ => $['accessRule.appDescription'], { ns: 'permission' })} + {t(($) => $['accessRule.appDescription'], { ns: 'permission' })} </p> </header> <main className="w-full px-6 pt-8 pb-10"> @@ -111,16 +127,25 @@ const AppAccessConfigPage = ({ appId }: AppAccessConfigPageProps) => { const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled - const appDetail = useStore(state => state.appDetail) - const appACLCapabilities = useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { - currentUserId, - resourceMaintainer: appDetail?.maintainer, - workspacePermissionKeys, - isRbacEnabled, - }), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, isRbacEnabled, workspacePermissionKeys]) + const appDetail = useStore((state) => state.appDetail) + const appACLCapabilities = useMemo( + () => + getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + isRbacEnabled, + }), + [ + appDetail?.maintainer, + appDetail?.permission_keys, + currentUserId, + isRbacEnabled, + workspacePermissionKeys, + ], + ) - if (!appDetail || appDetail.id !== appId || !appACLCapabilities.canAccessConfig) - return null + if (!appDetail || appDetail.id !== appId || !appACLCapabilities.canAccessConfig) return null return <AppAccessConfigContent appId={appId} maintainerId={appDetail.maintainer} /> } diff --git a/web/app/components/app/annotation/__tests__/filter.spec.tsx b/web/app/components/app/annotation/__tests__/filter.spec.tsx index 1d7b895d6d3388..46d391d429d4bf 100644 --- a/web/app/components/app/annotation/__tests__/filter.spec.tsx +++ b/web/app/components/app/annotation/__tests__/filter.spec.tsx @@ -16,21 +16,18 @@ const mockUseAnnotationsCount = useLogModule.useAnnotationsCount as Mock // Test Utilities // ============================================================================ -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, }, - }, -}) + }) const renderWithQueryClient = (ui: React.ReactElement) => { const queryClient = createQueryClient() - return render( - <QueryClientProvider client={queryClient}> - {ui} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>) } // ============================================================================ @@ -74,11 +71,7 @@ describe('Filter', () => { // Act const { container } = renderWithQueryClient( - <Filter - appId={appId} - queryParams={defaultQueryParams} - setQueryParams={vi.fn()} - > + <Filter appId={appId} queryParams={defaultQueryParams} setQueryParams={vi.fn()}> <div>{childContent}</div> </Filter>, ) @@ -95,11 +88,7 @@ describe('Filter', () => { // Act const { container } = renderWithQueryClient( - <Filter - appId={appId} - queryParams={defaultQueryParams} - setQueryParams={vi.fn()} - > + <Filter appId={appId} queryParams={defaultQueryParams} setQueryParams={vi.fn()}> <div>{childContent}</div> </Filter>, ) @@ -119,11 +108,7 @@ describe('Filter', () => { // Act renderWithQueryClient( - <Filter - appId={appId} - queryParams={defaultQueryParams} - setQueryParams={vi.fn()} - > + <Filter appId={appId} queryParams={defaultQueryParams} setQueryParams={vi.fn()}> <div>{childContent}</div> </Filter>, ) @@ -149,11 +134,7 @@ describe('Filter', () => { // Act renderWithQueryClient( - <Filter - appId={appId} - queryParams={defaultQueryParams} - setQueryParams={vi.fn()} - > + <Filter appId={appId} queryParams={defaultQueryParams} setQueryParams={vi.fn()}> <div>{childContent}</div> </Filter>, ) @@ -174,11 +155,7 @@ describe('Filter', () => { // Act renderWithQueryClient( - <Filter - appId={appId} - queryParams={queryParams} - setQueryParams={vi.fn()} - > + <Filter appId={appId} queryParams={queryParams} setQueryParams={vi.fn()}> <div>{childContent}</div> </Filter>, ) @@ -204,11 +181,7 @@ describe('Filter', () => { const setQueryParams = vi.fn() renderWithQueryClient( - <Filter - appId={appId} - queryParams={queryParams} - setQueryParams={setQueryParams} - > + <Filter appId={appId} queryParams={queryParams} setQueryParams={setQueryParams}> <div>{childContent}</div> </Filter>, ) @@ -233,11 +206,7 @@ describe('Filter', () => { const setQueryParams = vi.fn() renderWithQueryClient( - <Filter - appId={appId} - queryParams={queryParams} - setQueryParams={setQueryParams} - > + <Filter appId={appId} queryParams={queryParams} setQueryParams={setQueryParams}> <div>{childContent}</div> </Filter>, ) @@ -265,11 +234,7 @@ describe('Filter', () => { // Act renderWithQueryClient( - <Filter - appId={appId} - queryParams={{ keyword: '' }} - setQueryParams={vi.fn()} - > + <Filter appId={appId} queryParams={{ keyword: '' }} setQueryParams={vi.fn()}> <div>{childContent}</div> </Filter>, ) @@ -289,11 +254,7 @@ describe('Filter', () => { // Act renderWithQueryClient( - <Filter - appId={appId} - queryParams={{ keyword: undefined }} - setQueryParams={vi.fn()} - > + <Filter appId={appId} queryParams={{ keyword: undefined }} setQueryParams={vi.fn()}> <div>{childContent}</div> </Filter>, ) @@ -313,11 +274,7 @@ describe('Filter', () => { // Act renderWithQueryClient( - <Filter - appId={appId} - queryParams={defaultQueryParams} - setQueryParams={vi.fn()} - > + <Filter appId={appId} queryParams={defaultQueryParams} setQueryParams={vi.fn()}> <div>{childContent}</div> </Filter>, ) diff --git a/web/app/components/app/annotation/__tests__/index.spec.tsx b/web/app/components/app/annotation/__tests__/index.spec.tsx index e4120d6f114aec..e7b27bd1a7aeec 100644 --- a/web/app/components/app/annotation/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/__tests__/index.spec.tsx @@ -58,7 +58,10 @@ vi.mock('../empty-element', () => ({ vi.mock('../header-opts', () => ({ default: (props: any) => ( <div data-testid="header-opts"> - <button data-testid="trigger-add" onClick={() => props.onAdd({ question: 'new question', answer: 'new answer' })}> + <button + data-testid="trigger-add" + onClick={() => props.onAdd({ question: 'new question', answer: 'new answer' })} + > add </button> <button data-testid="trigger-added" onClick={() => props.onAdded()}> @@ -73,13 +76,18 @@ let latestListProps: any vi.mock('../list', () => ({ List: (props: any) => { latestListProps = props - if (!props.list.length) - return <div data-testid="list-empty" /> + if (!props.list.length) return <div data-testid="list-empty" /> return ( <div data-testid="list"> - <button data-testid="list-view" onClick={() => props.onView(props.list[0])}>view</button> - <button data-testid="list-remove" onClick={() => props.onRemove(props.list[0].id)}>remove</button> - <button data-testid="list-batch-delete" onClick={() => props.onBatchDelete()}>batch-delete</button> + <button data-testid="list-view" onClick={() => props.onView(props.list[0])}> + view + </button> + <button data-testid="list-remove" onClick={() => props.onRemove(props.list[0].id)}> + remove + </button> + <button data-testid="list-batch-delete" onClick={() => props.onBatchDelete()}> + batch-delete + </button> </div> ) }, @@ -87,45 +95,63 @@ vi.mock('../list', () => ({ vi.mock('../view-annotation-modal', () => ({ default: (props: any) => { - if (!props.isShow) - return null + if (!props.isShow) return null return ( <div data-testid="view-modal"> <div>{props.item.question}</div> - <button data-testid="view-modal-remove" onClick={props.onRemove}>remove</button> - <button data-testid="view-modal-save" onClick={() => props.onSave('Edited question', 'Edited answer')}>save</button> - <button data-testid="view-modal-close" onClick={props.onHide}>close</button> + <button data-testid="view-modal-remove" onClick={props.onRemove}> + remove + </button> + <button + data-testid="view-modal-save" + onClick={() => props.onSave('Edited question', 'Edited answer')} + > + save + </button> + <button data-testid="view-modal-close" onClick={props.onHide}> + close + </button> </div> ) }, })) -vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => ({ - default: (props: any) => props.isShow - ? ( +vi.mock( + '@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', + () => ({ + default: (props: any) => + props.isShow ? ( <div data-testid="config-modal"> <button data-testid="config-save" - onClick={() => props.onSave({ - embedding_model_name: 'next-model', - embedding_provider_name: 'next-provider', - }, 0.7)} + onClick={() => + props.onSave( + { + embedding_model_name: 'next-model', + embedding_provider_name: 'next-provider', + }, + 0.7, + ) + } > save-config </button> - <button data-testid="config-hide" onClick={props.onHide}>hide-config</button> + <button data-testid="config-hide" onClick={props.onHide}> + hide-config + </button> </div> - ) - : null, -})) + ) : null, + }), +) vi.mock('@/app/components/billing/annotation-full/modal', () => ({ - default: (props: any) => props.show - ? ( - <div data-testid="annotation-full-modal"> - <button data-testid="hide-annotation-full-modal" onClick={props.onHide}>hide-full</button> - </div> - ) - : null, + default: (props: any) => + props.show ? ( + <div data-testid="annotation-full-modal"> + <button data-testid="hide-annotation-full-modal" onClick={props.onHide}> + hide-full + </button> + </div> + ) : null, })) const mockNotify = vi.fn() @@ -203,12 +229,18 @@ describe('Annotation', () => { expect(screen.getByRole('heading', { name: 'appAnnotation.title' })).toBeInTheDocument() expect(screen.getByText('appAnnotation.noData.description')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'common.operation.learnMore' })).toHaveAttribute('href', 'https://docs.example.com/use-dify/monitor/annotation-reply') + expect(screen.getByRole('link', { name: 'common.operation.learnMore' })).toHaveAttribute( + 'href', + 'https://docs.example.com/use-dify/monitor/annotation-reply', + ) expect(await screen.findByTestId('empty-element')).toBeInTheDocument() - expect(fetchAnnotationListMock).toHaveBeenCalledWith(appDetail.id, expect.objectContaining({ - page: 1, - keyword: '', - })) + expect(fetchAnnotationListMock).toHaveBeenCalledWith( + appDetail.id, + expect.objectContaining({ + page: 1, + keyword: '', + }), + ) }) it('should handle annotation creation and refresh list data', async () => { @@ -222,11 +254,16 @@ describe('Annotation', () => { fireEvent.click(screen.getByTestId('trigger-add')) await waitFor(() => { - expect(addAnnotationMock).toHaveBeenCalledWith(appDetail.id, { question: 'new question', answer: 'new answer' }) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - message: 'common.api.actionSuccess', - type: 'success', - })) + expect(addAnnotationMock).toHaveBeenCalledWith(appDetail.id, { + question: 'new question', + answer: 'new answer', + }) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'common.api.actionSuccess', + type: 'success', + }), + ) }) expect(fetchAnnotationListMock).toHaveBeenCalledTimes(2) }) @@ -252,9 +289,11 @@ describe('Annotation', () => { }) await waitFor(() => { expect(delAnnotationsMock).toHaveBeenCalledWith(appDetail.id, [annotation.id]) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + }), + ) expect(latestListProps.selectedIds).toEqual([]) }) @@ -319,23 +358,25 @@ describe('Annotation', () => { }) it('should disable annotations and refetch config after the async job completes', async () => { - fetchAnnotationConfigMock.mockResolvedValueOnce({ - id: 'config-id', - enabled: true, - embedding_model: { - embedding_model_name: 'model', - embedding_provider_name: 'provider', - }, - score_threshold: 0.5, - }).mockResolvedValueOnce({ - id: 'config-id', - enabled: false, - embedding_model: { - embedding_model_name: 'model', - embedding_provider_name: 'provider', - }, - score_threshold: 0.5, - }) + fetchAnnotationConfigMock + .mockResolvedValueOnce({ + id: 'config-id', + enabled: true, + embedding_model: { + embedding_model_name: 'model', + embedding_provider_name: 'provider', + }, + score_threshold: 0.5, + }) + .mockResolvedValueOnce({ + id: 'config-id', + enabled: false, + embedding_model: { + embedding_model_name: 'model', + embedding_provider_name: 'provider', + }, + score_threshold: 0.5, + }) renderComponent() @@ -355,11 +396,17 @@ describe('Annotation', () => { }), 0.5, ) - expect(queryAnnotationJobStatusMock).toHaveBeenCalledWith(appDetail.id, AnnotationEnableStatus.disable, 'job-1') - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - message: 'common.api.actionSuccess', - type: 'success', - })) + expect(queryAnnotationJobStatusMock).toHaveBeenCalledWith( + appDetail.id, + AnnotationEnableStatus.disable, + 'job-1', + ) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'common.api.actionSuccess', + type: 'success', + }), + ) }) }) @@ -393,10 +440,12 @@ describe('Annotation', () => { 0.7, ) expect(updateAnnotationScoreMock).toHaveBeenCalledWith(appDetail.id, 'config-id', 0.7) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - message: 'common.api.actionSuccess', - type: 'success', - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'common.api.actionSuccess', + type: 'success', + }), + ) }) }) diff --git a/web/app/components/app/annotation/__tests__/list.spec.tsx b/web/app/components/app/annotation/__tests__/list.spec.tsx index 1116725fb2c849..031c667556f42c 100644 --- a/web/app/components/app/annotation/__tests__/list.spec.tsx +++ b/web/app/components/app/annotation/__tests__/list.spec.tsx @@ -46,7 +46,10 @@ describe('List', () => { }) it('should toggle single and bulk selection states', () => { - const list = [createAnnotation({ id: 'a', question: 'A' }), createAnnotation({ id: 'b', question: 'B' })] + const list = [ + createAnnotation({ id: 'a', question: 'A' }), + createAnnotation({ id: 'b', question: 'B' }), + ] const onSelectedIdsChange = vi.fn() const { rerender } = render( <List @@ -97,7 +100,9 @@ describe('List', () => { const actionButtons = within(row).getAllByRole('button') fireEvent.click(actionButtons[1]!) - expect(await screen.findByText('appDebug.feature.annotation.removeConfirm'))!.toBeInTheDocument() + expect( + await screen.findByText('appDebug.feature.annotation.removeConfirm'), + )!.toBeInTheDocument() const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' }) fireEvent.click(confirmButton) expect(onRemove).toHaveBeenCalledWith(item.id) diff --git a/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx index 00f91ab429d16a..4976a4d36edf5b 100644 --- a/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx @@ -11,7 +11,7 @@ vi.mock('@/context/provider-context', () => ({ const mockToastNotify = vi.fn() vi.mock('@langgenius/dify-ui/toast', () => ({ default: { - notify: vi.fn(args => mockToastNotify(args)), + notify: vi.fn((args) => mockToastNotify(args)), }, toast: { success: (message: string) => mockToastNotify({ type: 'success', message }), @@ -68,17 +68,23 @@ describe('AddAnnotationModal', () => { it('should capture query input text when typing', () => { render(<AddAnnotationModal {...baseProps} />) typeQuestion('Sample question') - expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('Sample question') + expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue( + 'Sample question', + ) }) it('should capture answer input text when typing', () => { render(<AddAnnotationModal {...baseProps} />) typeAnswer('Sample answer') - expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('Sample answer') + expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue( + 'Sample answer', + ) }) it('should show annotation full notice and disable submit when quota exceeded', () => { - mockUseProviderContext.mockReturnValue(getProviderContext({ usage: 10, total: 10, enableBilling: true })) + mockUseProviderContext.mockReturnValue( + getProviderContext({ usage: 10, total: 10, enableBilling: true }), + ) render(<AddAnnotationModal {...baseProps} />) expect(screen.getByTestId('annotation-full')).toBeInTheDocument() @@ -114,7 +120,9 @@ describe('AddAnnotationModal', () => { await waitFor(() => { expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('') - expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('') + expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue( + '', + ) }) }) @@ -122,10 +130,12 @@ describe('AddAnnotationModal', () => { render(<AddAnnotationModal {...baseProps} />) fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'appAnnotation.errorMessage.queryRequired', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'appAnnotation.errorMessage.queryRequired', + }), + ) }) it('should show toast when validation fails for missing answer', () => { @@ -133,10 +143,12 @@ describe('AddAnnotationModal', () => { typeQuestion('Filled question') fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'appAnnotation.errorMessage.answerRequired', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'appAnnotation.errorMessage.answerRequired', + }), + ) }) it('should close modal when save completes and create next unchecked', async () => { diff --git a/web/app/components/app/annotation/add-annotation-modal/edit-item/__tests__/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/edit-item/__tests__/index.spec.tsx index 6dd1d42246e91d..4d154b67cc2ea5 100644 --- a/web/app/components/app/annotation/add-annotation-modal/edit-item/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/edit-item/__tests__/index.spec.tsx @@ -4,44 +4,32 @@ import EditItem, { EditItemType } from '../index' describe('AddAnnotationModal/EditItem', () => { it('should render query inputs with user avatar and placeholder strings', () => { - render( - <EditItem - type={EditItemType.Query} - content="Why?" - onChange={vi.fn()} - />, - ) + render(<EditItem type={EditItemType.Query} content="Why?" onChange={vi.fn()} />) expect(screen.getByText('appAnnotation.addModal.queryName')).toBeInTheDocument() - expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder'), + ).toBeInTheDocument() expect(screen.getByText('Why?')).toBeInTheDocument() }) it('should render answer name and placeholder text', () => { - render( - <EditItem - type={EditItemType.Answer} - content="Existing answer" - onChange={vi.fn()} - />, - ) + render(<EditItem type={EditItemType.Answer} content="Existing answer" onChange={vi.fn()} />) expect(screen.getByText('appAnnotation.addModal.answerName')).toBeInTheDocument() - expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), + ).toBeInTheDocument() expect(screen.getByDisplayValue('Existing answer')).toBeInTheDocument() }) it('should propagate changes when answer content updates', () => { const handleChange = vi.fn() - render( - <EditItem - type={EditItemType.Answer} - content="" - onChange={handleChange} - />, - ) + render(<EditItem type={EditItemType.Answer} content="" onChange={handleChange} />) - fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), { target: { value: 'Because' } }) + fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), { + target: { value: 'Because' }, + }) expect(handleChange).toHaveBeenCalledWith('Because') }) }) diff --git a/web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx b/web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx index edd352a63c99a9..c383753e2aba5b 100644 --- a/web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx @@ -15,27 +15,28 @@ type Props = Readonly<{ onChange: (content: string) => void }> -const EditItem: FC<Props> = ({ - type, - content, - onChange, -}) => { +const EditItem: FC<Props> = ({ type, content, onChange }) => { const { t } = useTranslation() - const avatar = type === EditItemType.Query ? <User className="size-6" /> : <Robot className="size-6" /> - const name = type === EditItemType.Query ? t($ => $['addModal.queryName'], { ns: 'appAnnotation' }) : t($ => $['addModal.answerName'], { ns: 'appAnnotation' }) - const placeholder = type === EditItemType.Query ? t($ => $['addModal.queryPlaceholder'], { ns: 'appAnnotation' }) : t($ => $['addModal.answerPlaceholder'], { ns: 'appAnnotation' }) + const avatar = + type === EditItemType.Query ? <User className="size-6" /> : <Robot className="size-6" /> + const name = + type === EditItemType.Query + ? t(($) => $['addModal.queryName'], { ns: 'appAnnotation' }) + : t(($) => $['addModal.answerName'], { ns: 'appAnnotation' }) + const placeholder = + type === EditItemType.Query + ? t(($) => $['addModal.queryPlaceholder'], { ns: 'appAnnotation' }) + : t(($) => $['addModal.answerPlaceholder'], { ns: 'appAnnotation' }) return ( - <div className="flex" onClick={e => e.stopPropagation()}> - <div className="mr-3 shrink-0"> - {avatar} - </div> + <div className="flex" onClick={(e) => e.stopPropagation()}> + <div className="mr-3 shrink-0">{avatar}</div> <div className="grow"> <div className="mb-1 system-xs-semibold text-text-primary">{name}</div> <Textarea aria-label={name} value={content} - onValueChange={value => onChange(value)} + onValueChange={(value) => onChange(value)} placeholder={placeholder} autoFocus /> diff --git a/web/app/components/app/annotation/add-annotation-modal/index.tsx b/web/app/components/app/annotation/add-annotation-modal/index.tsx index 2d10130128c261..69e16fe405d054 100644 --- a/web/app/components/app/annotation/add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/index.tsx @@ -27,25 +27,20 @@ type Props = Readonly<{ onAdd: (payload: AnnotationItemBasic) => void }> -const AddAnnotationModal: FC<Props> = ({ - isShow, - onHide, - onAdd, -}) => { +const AddAnnotationModal: FC<Props> = ({ isShow, onHide, onAdd }) => { const { t } = useTranslation() const { plan, enableBilling } = useProviderContext() - const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse) + const isAnnotationFull = + enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse const [question, setQuestion] = useState('') const [answer, setAnswer] = useState('') const [isCreateNext, setIsCreateNext] = useState(false) const [isSaving, setIsSaving] = useState(false) const isValid = (payload: AnnotationItemBasic) => { - if (!payload.question) - return t($ => $['errorMessage.queryRequired'], { ns: 'appAnnotation' }) + if (!payload.question) return t(($) => $['errorMessage.queryRequired'], { ns: 'appAnnotation' }) - if (!payload.answer) - return t($ => $['errorMessage.answerRequired'], { ns: 'appAnnotation' }) + if (!payload.answer) return t(($) => $['errorMessage.answerRequired'], { ns: 'appAnnotation' }) return true } @@ -63,21 +58,17 @@ const AddAnnotationModal: FC<Props> = ({ setIsSaving(true) try { await onAdd(payload) - } - catch { - } + } catch {} setIsSaving(false) if (isCreateNext) { setQuestion('') setAnswer('') - } - else { + } else { onHide() } } - if (!isShow) - return null + if (!isShow) return null return ( <div> @@ -87,8 +78,7 @@ const AddAnnotationModal: FC<Props> = ({ disablePointerDismissal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DrawerPortal> @@ -99,26 +89,18 @@ const AddAnnotationModal: FC<Props> = ({ <div className="shrink-0 border-b border-divider-subtle py-4"> <div className="flex h-6 items-center justify-between pr-5 pl-6"> <DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary"> - {t($ => $['addModal.title'], { ns: 'appAnnotation' })} + {t(($) => $['addModal.title'], { ns: 'appAnnotation' })} </DrawerTitle> <DrawerCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-6 rounded-md" /> </div> </div> <div className="min-h-0 flex-1 overflow-y-auto"> <div className="space-y-6 p-6 pb-4"> - <EditItem - type={EditItemType.Query} - content={question} - onChange={setQuestion} - /> - <EditItem - type={EditItemType.Answer} - content={answer} - onChange={setAnswer} - /> + <EditItem type={EditItemType.Query} content={question} onChange={setQuestion} /> + <EditItem type={EditItemType.Answer} content={answer} onChange={setAnswer} /> </div> </div> <div className="shrink-0"> @@ -130,11 +112,21 @@ const AddAnnotationModal: FC<Props> = ({ <div className="flex h-16 items-center justify-between rounded-b-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary"> <label className="flex items-center space-x-2"> <Checkbox checked={isCreateNext} onCheckedChange={setIsCreateNext} /> - <span>{t($ => $['addModal.createNext'], { ns: 'appAnnotation' })}</span> + <span>{t(($) => $['addModal.createNext'], { ns: 'appAnnotation' })}</span> </label> <div className="mt-2 flex space-x-2"> - <Button className="h-7 text-xs" onClick={onHide}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button className="h-7 text-xs" variant="primary" onClick={handleSave} loading={isSaving} disabled={isAnnotationFull}>{t($ => $['operation.add'], { ns: 'common' })}</Button> + <Button className="h-7 text-xs" onClick={onHide}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button + className="h-7 text-xs" + variant="primary" + onClick={handleSave} + loading={isSaving} + disabled={isAnnotationFull} + > + {t(($) => $['operation.add'], { ns: 'common' })} + </Button> </div> </div> </div> diff --git a/web/app/components/app/annotation/batch-action.tsx b/web/app/components/app/annotation/batch-action.tsx index 77a7d7f57dac93..96897fca47f27e 100644 --- a/web/app/components/app/annotation/batch-action.tsx +++ b/web/app/components/app/annotation/batch-action.tsx @@ -30,14 +30,9 @@ const BatchAction: FC<IBatchActionProps> = ({ onSelectedIdsChange, }) => { const { t } = useTranslation() - const [isShowDeleteConfirm, { - setTrue: showDeleteConfirm, - setFalse: hideDeleteConfirm, - }] = useBoolean(false) - const [isDeleting, { - setTrue: setIsDeleting, - setFalse: setIsNotDeleting, - }] = useBoolean(false) + const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = + useBoolean(false) + const [isDeleting, { setTrue: setIsDeleting, setFalse: setIsNotDeleting }] = useBoolean(false) const handleBatchDelete = async () => { setIsDeleting() @@ -52,7 +47,9 @@ const BatchAction: FC<IBatchActionProps> = ({ <span className="flex size-5 items-center justify-center rounded-md bg-text-accent system-xs-medium text-text-primary-on-surface"> {selectedIds.length} </span> - <span className="system-sm-semibold text-text-accent">{t($ => $[`${i18nPrefix}.selected`], { ns: 'appAnnotation' })}</span> + <span className="system-sm-semibold text-text-accent"> + {t(($) => $[`${i18nPrefix}.selected`], { ns: 'appAnnotation' })} + </span> </div> <Divider type="vertical" className="mx-0.5 h-3.5 bg-divider-regular" /> <Button @@ -62,33 +59,31 @@ const BatchAction: FC<IBatchActionProps> = ({ onClick={showDeleteConfirm} > <span aria-hidden className="i-ri-delete-bin-line size-4" /> - <span className="px-0.5"> - {t($ => $['operation.delete'], { ns: 'common' })} - </span> + <span className="px-0.5">{t(($) => $['operation.delete'], { ns: 'common' })}</span> </Button> <Divider type="vertical" className="mx-0.5 h-3.5 bg-divider-regular" /> - <Button - variant="ghost" - className="px-3" - onClick={() => onSelectedIdsChange([])} - > - <span className="px-0.5">{t($ => $['operation.cancel'], { ns: 'common' })}</span> + <Button variant="ghost" className="px-3" onClick={() => onSelectedIdsChange([])}> + <span className="px-0.5">{t(($) => $['operation.cancel'], { ns: 'common' })}</span> </Button> </div> - <AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}> + <AlertDialog open={isShowDeleteConfirm} onOpenChange={(open) => !open && hideDeleteConfirm()}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['list.delete.title'], { ns: 'appAnnotation' })} + {t(($) => $['list.delete.title'], { ns: 'appAnnotation' })} </AlertDialogTitle> </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> - <AlertDialogConfirmButton loading={isDeleting} disabled={isDeleting} onClick={handleBatchDelete}> - {t($ => $['operation.delete'], { ns: 'common' })} + <AlertDialogConfirmButton + loading={isDeleting} + disabled={isDeleting} + onClick={handleBatchDelete} + > + {t(($) => $['operation.delete'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx index 8e6dd6cc281e35..e59fd66daf4a27 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx @@ -12,10 +12,14 @@ const toastMocks = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { - success: (message: string, options?: Record<string, unknown>) => toastMocks.notify({ type: 'success', message, ...options }), - error: (message: string, options?: Record<string, unknown>) => toastMocks.notify({ type: 'error', message, ...options }), - warning: (message: string, options?: Record<string, unknown>) => toastMocks.notify({ type: 'warning', message, ...options }), - info: (message: string, options?: Record<string, unknown>) => toastMocks.notify({ type: 'info', message, ...options }), + success: (message: string, options?: Record<string, unknown>) => + toastMocks.notify({ type: 'success', message, ...options }), + error: (message: string, options?: Record<string, unknown>) => + toastMocks.notify({ type: 'error', message, ...options }), + warning: (message: string, options?: Record<string, unknown>) => + toastMocks.notify({ type: 'warning', message, ...options }), + info: (message: string, options?: Record<string, unknown>) => + toastMocks.notify({ type: 'info', message, ...options }), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -28,8 +32,7 @@ describe('CSVUploader', () => { const getDropElements = () => { const title = screen.getByText('appAnnotation.batchModal.csvUploadTitle') const dropZone = title.parentElement?.parentElement as HTMLDivElement | null - if (!dropZone || !dropZone.parentElement) - throw new Error('Drop zone not found') + if (!dropZone || !dropZone.parentElement) throw new Error('Drop zone not found') const dropContainer = dropZone.parentElement as HTMLDivElement return { dropZone, dropContainer } } @@ -40,10 +43,7 @@ describe('CSVUploader', () => { updateFile, ...props, } - return render( - <CSVUploader {...mergedProps} />, - - ) + return render(<CSVUploader {...mergedProps} />) } beforeEach(() => { @@ -106,10 +106,12 @@ describe('CSVUploader', () => { fireEvent.drop(dropContainer, { dataTransfer: { files: [fileA, fileB] } }) - await waitFor(() => expect(toastMocks.notify).toHaveBeenCalledWith({ - type: 'error', - message: 'datasetCreation.stepOne.uploader.validation.count', - })) + await waitFor(() => + expect(toastMocks.notify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetCreation.stepOne.uploader.validation.count', + }), + ) expect(updateFile).not.toHaveBeenCalled() }) diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx index 74b59ff79f6db6..ae47856662e783 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx @@ -22,7 +22,7 @@ vi.mock('../csv-downloader', () => ({ let lastUploadedFile: File | undefined vi.mock('../csv-uploader', () => ({ - default: ({ file, updateFile }: { file?: File, updateFile: (file?: File) => void }) => ( + default: ({ file, updateFile }: { file?: File; updateFile: (file?: File) => void }) => ( <div> <button data-testid="mock-uploader" @@ -132,7 +132,10 @@ describe('BatchModal', () => { const runButton = screen.getByRole('button', { name: 'appAnnotation.batchModal.run' }) expect(runButton).not.toBeDisabled() - annotationBatchImportMock.mockResolvedValue({ job_id: 'job-1', job_status: ProcessStatus.PROCESSING }) + annotationBatchImportMock.mockResolvedValue({ + job_id: 'job-1', + job_status: ProcessStatus.PROCESSING, + }) checkAnnotationBatchImportProgressMock .mockResolvedValueOnce({ job_id: 'job-1', job_status: ProcessStatus.PROCESSING }) .mockResolvedValueOnce({ job_id: 'job-1', job_status: ProcessStatus.COMPLETED }) diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx index dadbbab836f297..a3d1cef55015cb 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx @@ -2,11 +2,8 @@ import type { FC } from 'react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { - useCSVDownloader, -} from 'react-papaparse' +import { useCSVDownloader } from 'react-papaparse' import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general' - import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' @@ -33,38 +30,36 @@ const CSVDownload: FC = () => { return ( <div className="mt-6"> - <div className="system-sm-medium text-text-primary">{t($ => $['generation.csvStructureTitle'], { ns: 'share' })}</div> + <div className="system-sm-medium text-text-primary"> + {t(($) => $['generation.csvStructureTitle'], { ns: 'share' })} + </div> <div className="mt-2 max-h-[500px] overflow-auto"> <table className="w-full table-fixed border-separate border-spacing-0 rounded-lg border border-divider-regular text-xs"> <thead className="text-text-tertiary"> <tr> - <td className="h-9 border-b border-divider-regular pr-2 pl-3">{t($ => $['batchModal.question'], { ns: 'appAnnotation' })}</td> - <td className="h-9 border-b border-divider-regular pr-2 pl-3">{t($ => $['batchModal.answer'], { ns: 'appAnnotation' })}</td> + <td className="h-9 border-b border-divider-regular pr-2 pl-3"> + {t(($) => $['batchModal.question'], { ns: 'appAnnotation' })} + </td> + <td className="h-9 border-b border-divider-regular pr-2 pl-3"> + {t(($) => $['batchModal.answer'], { ns: 'appAnnotation' })} + </td> </tr> </thead> <tbody className="text-text-secondary"> <tr> <td className="h-9 border-b border-divider-subtle pr-2 pl-3 text-[13px]"> - {t($ => $['batchModal.question'], { ns: 'appAnnotation' })} - {' '} - 1 + {t(($) => $['batchModal.question'], { ns: 'appAnnotation' })} 1 </td> <td className="h-9 border-b border-divider-subtle pr-2 pl-3 text-[13px]"> - {t($ => $['batchModal.answer'], { ns: 'appAnnotation' })} - {' '} - 1 + {t(($) => $['batchModal.answer'], { ns: 'appAnnotation' })} 1 </td> </tr> <tr> <td className="h-9 pr-2 pl-3 text-[13px]"> - {t($ => $['batchModal.question'], { ns: 'appAnnotation' })} - {' '} - 2 + {t(($) => $['batchModal.question'], { ns: 'appAnnotation' })} 2 </td> <td className="h-9 pr-2 pl-3 text-[13px]"> - {t($ => $['batchModal.answer'], { ns: 'appAnnotation' })} - {' '} - 2 + {t(($) => $['batchModal.answer'], { ns: 'appAnnotation' })} 2 </td> </tr> </tbody> @@ -79,11 +74,10 @@ const CSVDownload: FC = () => { > <div className="flex h-[18px] items-center space-x-1 system-xs-medium text-text-accent"> <DownloadIcon className="mr-1 size-3" /> - {t($ => $['batchModal.template'], { ns: 'appAnnotation' })} + {t(($) => $['batchModal.template'], { ns: 'appAnnotation' })} </div> </CSVDownloader> </div> - ) } export default React.memo(CSVDownload) diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx index 3d000346e7f2eb..38eb1e4ae00cba 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx @@ -14,10 +14,7 @@ export type Props = Readonly<{ updateFile: (file?: File) => void }> -const CSVUploader: FC<Props> = ({ - file, - updateFile, -}) => { +const CSVUploader: FC<Props> = ({ file, updateFile }) => { const { t } = useTranslation() const [dragging, setDragging] = useState(false) const dropRef = useRef<HTMLDivElement>(null) @@ -27,8 +24,7 @@ const CSVUploader: FC<Props> = ({ const handleDragEnter = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.target !== dragRef.current) - setDragging(true) + if (e.target !== dragRef.current) setDragging(true) } const handleDragOver = (e: DragEvent) => { e.preventDefault() @@ -37,29 +33,25 @@ const CSVUploader: FC<Props> = ({ const handleDragLeave = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.target === dragRef.current) - setDragging(false) + if (e.target === dragRef.current) setDragging(false) } const handleDrop = (e: DragEvent) => { e.preventDefault() e.stopPropagation() setDragging(false) - if (!e.dataTransfer) - return + if (!e.dataTransfer) return const files = Array.from(e.dataTransfer.files) if (files.length > 1) { - toast.error(t($ => $['stepOne.uploader.validation.count'], { ns: 'datasetCreation' })) + toast.error(t(($) => $['stepOne.uploader.validation.count'], { ns: 'datasetCreation' })) return } updateFile(files[0]) } const selectHandle = () => { - if (fileUploader.current) - fileUploader.current.click() + if (fileUploader.current) fileUploader.current.click() } const removeFile = () => { - if (fileUploader.current) - fileUploader.current.value = '' + if (fileUploader.current) fileUploader.current.value = '' updateFile() } const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => { @@ -92,17 +84,23 @@ const CSVUploader: FC<Props> = ({ /> <div ref={dropRef}> {!file && ( - <div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg system-sm-regular', dragging && 'border border-components-dropzone-border-accent bg-components-dropzone-bg-accent')}> + <div + className={cn( + 'flex h-20 items-center rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg system-sm-regular', + dragging && + 'border border-components-dropzone-border-accent bg-components-dropzone-bg-accent', + )} + > <div className="flex w-full items-center justify-center space-x-2"> <CSVIcon className="shrink-0" /> <div className="text-text-tertiary"> - {t($ => $['batchModal.csvUploadTitle'], { ns: 'appAnnotation' })} + {t(($) => $['batchModal.csvUploadTitle'], { ns: 'appAnnotation' })} <button type="button" className="inline cursor-pointer border-none bg-transparent p-0 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={selectHandle} > - {t($ => $['batchModal.browse'], { ns: 'appAnnotation' })} + {t(($) => $['batchModal.browse'], { ns: 'appAnnotation' })} </button> </div> </div> @@ -110,19 +108,28 @@ const CSVUploader: FC<Props> = ({ </div> )} {file && ( - <div className={cn('group flex h-20 items-center rounded-xl border border-components-panel-border bg-components-panel-bg px-6 text-sm font-normal', 'hover:border-components-panel-bg-blur hover:bg-components-panel-bg-blur')}> + <div + className={cn( + 'group flex h-20 items-center rounded-xl border border-components-panel-border bg-components-panel-bg px-6 text-sm font-normal', + 'hover:border-components-panel-bg-blur hover:bg-components-panel-bg-blur', + )} + > <CSVIcon className="shrink-0" /> <div className="ml-2 flex w-0 grow"> - <span className="max-w-[calc(100%-30px)] overflow-hidden text-ellipsis whitespace-nowrap text-text-primary">{file.name.replace(/.csv$/, '')}</span> + <span className="max-w-[calc(100%-30px)] overflow-hidden text-ellipsis whitespace-nowrap text-text-primary"> + {file.name.replace(/.csv$/, '')} + </span> <span className="shrink-0 text-text-tertiary">.csv</span> </div> <div className="hidden items-center group-hover:flex"> - <Button variant="secondary" onClick={selectHandle}>{t($ => $['stepOne.uploader.change'], { ns: 'datasetCreation' })}</Button> + <Button variant="secondary" onClick={selectHandle}> + {t(($) => $['stepOne.uploader.change'], { ns: 'datasetCreation' })} + </Button> <div className="mx-2 h-4 w-px bg-divider-regular" /> <button type="button" className="cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.delete'], { ns: 'common' })} + aria-label={t(($) => $['operation.delete'], { ns: 'common' })} onClick={removeFile} > <RiDeleteBinLine className="size-4 text-text-tertiary" aria-hidden="true" /> diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx index 5c0a96babea59f..6b387d05dc6a6c 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx @@ -27,21 +27,16 @@ export type IBatchModalProps = { onAdded: () => void } -const BatchModal: FC<IBatchModalProps> = ({ - appId, - isShow, - onCancel, - onAdded, -}) => { +const BatchModal: FC<IBatchModalProps> = ({ appId, isShow, onCancel, onAdded }) => { const { t } = useTranslation() const { plan, enableBilling } = useProviderContext() - const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse) + const isAnnotationFull = + enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse const [currentCSV, setCurrentCSV] = useState<File>() const handleFile = (file?: File) => setCurrentCSV(file) useEffect(() => { - if (!isShow) - setCurrentCSV(undefined) + if (!isShow) setCurrentCSV(undefined) }, [isShow]) const [importStatus, setImportStatus] = useState<ProcessStatus | string>() @@ -52,15 +47,16 @@ const BatchModal: FC<IBatchModalProps> = ({ if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING) setTimeout(() => checkProcess(res.job_id), 2500) if (res.job_status === ProcessStatus.ERROR) - toast.error(`${t($ => $['batchModal.runError'], { ns: 'appAnnotation' })}`) + toast.error(`${t(($) => $['batchModal.runError'], { ns: 'appAnnotation' })}`) if (res.job_status === ProcessStatus.COMPLETED) { - toast.success(`${t($ => $['batchModal.completed'], { ns: 'appAnnotation' })}`) + toast.success(`${t(($) => $['batchModal.completed'], { ns: 'appAnnotation' })}`) onAdded() onCancel() } - } - catch (e: any) { - toast.error(`${t($ => $['batchModal.runError'], { ns: 'appAnnotation' })}${'message' in e ? `: ${e.message}` : ''}`) + } catch (e: any) { + toast.error( + `${t(($) => $['batchModal.runError'], { ns: 'appAnnotation' })}${'message' in e ? `: ${e.message}` : ''}`, + ) } } @@ -74,35 +70,33 @@ const BatchModal: FC<IBatchModalProps> = ({ }) setImportStatus(res.job_status) checkProcess(res.job_id) - } - catch (e: any) { - toast.error(`${t($ => $['batchModal.runError'], { ns: 'appAnnotation' })}${'message' in e ? `: ${e.message}` : ''}`) + } catch (e: any) { + toast.error( + `${t(($) => $['batchModal.runError'], { ns: 'appAnnotation' })}${'message' in e ? `: ${e.message}` : ''}`, + ) } } const handleSend = () => { - if (!currentCSV) - return + if (!currentCSV) return runBatch(currentCSV) } return ( <Dialog open={isShow}> <DialogContent className="w-full max-w-[520px]! overflow-hidden! rounded-xl! border-none px-8 py-6 text-left align-middle"> - - <div className="relative pb-1 system-xl-medium text-text-primary">{t($ => $['batchModal.title'], { ns: 'appAnnotation' })}</div> + <div className="relative pb-1 system-xl-medium text-text-primary"> + {t(($) => $['batchModal.title'], { ns: 'appAnnotation' })} + </div> <button type="button" className="absolute top-4 right-4 cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onCancel} > <RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" /> </button> - <CSVUploader - file={currentCSV} - updateFile={handleFile} - /> + <CSVUploader file={currentCSV} updateFile={handleFile} /> <CSVDownloader /> {isAnnotationFull && ( @@ -113,15 +107,17 @@ const BatchModal: FC<IBatchModalProps> = ({ <div className="mt-[28px] flex justify-end pt-6"> <Button className="mr-2 system-sm-medium text-text-tertiary" onClick={onCancel}> - {t($ => $['batchModal.cancel'], { ns: 'appAnnotation' })} + {t(($) => $['batchModal.cancel'], { ns: 'appAnnotation' })} </Button> <Button variant="primary" onClick={handleSend} disabled={isAnnotationFull || !currentCSV} - loading={importStatus === ProcessStatus.PROCESSING || importStatus === ProcessStatus.WAITING} + loading={ + importStatus === ProcessStatus.PROCESSING || importStatus === ProcessStatus.WAITING + } > - {t($ => $['batchModal.run'], { ns: 'appAnnotation' })} + {t(($) => $['batchModal.run'], { ns: 'appAnnotation' })} </Button> </div> </DialogContent> diff --git a/web/app/components/app/annotation/clear-all-annotations-confirm-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/clear-all-annotations-confirm-modal/__tests__/index.spec.tsx index 0fc2a0f1d117bd..268e3c9ea15c60 100644 --- a/web/app/components/app/annotation/clear-all-annotations-confirm-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/clear-all-annotations-confirm-modal/__tests__/index.spec.tsx @@ -4,7 +4,7 @@ import ClearAllAnnotationsConfirmModal from '../index' vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey((key: string, options?: { ns?: string }) => { const translations: Record<string, string> = { @@ -12,13 +12,12 @@ vi.mock('react-i18next', async () => { 'operation.confirm': 'Confirm', 'operation.cancel': 'Cancel', } - if (translations[key]) - return translations[key] + if (translations[key]) return translations[key] const prefix = options?.ns ? `${options.ns}.` : '' return `${prefix}${key}` }), }), - }) + } }) beforeEach(() => { @@ -30,13 +29,7 @@ describe('ClearAllAnnotationsConfirmModal', () => { describe('Rendering', () => { it('should show confirmation dialog when isShow is true', () => { // Arrange - render( - <ClearAllAnnotationsConfirmModal - isShow - onHide={vi.fn()} - onConfirm={vi.fn()} - />, - ) + render(<ClearAllAnnotationsConfirmModal isShow onHide={vi.fn()} onConfirm={vi.fn()} />) // Assert expect(screen.getByText('Clear all annotations?')).toBeInTheDocument() @@ -47,11 +40,7 @@ describe('ClearAllAnnotationsConfirmModal', () => { it('should not render anything when isShow is false', () => { // Arrange render( - <ClearAllAnnotationsConfirmModal - isShow={false} - onHide={vi.fn()} - onConfirm={vi.fn()} - />, + <ClearAllAnnotationsConfirmModal isShow={false} onHide={vi.fn()} onConfirm={vi.fn()} />, ) // Assert @@ -65,13 +54,7 @@ describe('ClearAllAnnotationsConfirmModal', () => { const onHide = vi.fn() const onConfirm = vi.fn() // Arrange - render( - <ClearAllAnnotationsConfirmModal - isShow - onHide={onHide} - onConfirm={onConfirm} - />, - ) + render(<ClearAllAnnotationsConfirmModal isShow onHide={onHide} onConfirm={onConfirm} />) // Act fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) @@ -85,13 +68,7 @@ describe('ClearAllAnnotationsConfirmModal', () => { const onHide = vi.fn() const onConfirm = vi.fn() // Arrange - render( - <ClearAllAnnotationsConfirmModal - isShow - onHide={onHide} - onConfirm={onConfirm} - />, - ) + render(<ClearAllAnnotationsConfirmModal isShow onHide={onHide} onConfirm={onConfirm} />) // Act fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) diff --git a/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx b/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx index daf41acf316909..5cbd69dfc6afeb 100644 --- a/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx +++ b/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx @@ -18,16 +18,12 @@ type Props = Readonly<{ onConfirm: () => void }> -const ClearAllAnnotationsConfirmModal: FC<Props> = ({ - isShow, - onHide, - onConfirm, -}) => { +const ClearAllAnnotationsConfirmModal: FC<Props> = ({ isShow, onHide, onConfirm }) => { const { t } = useTranslation() - const title = t($ => $['table.header.clearAllConfirm'], { ns: 'appAnnotation' }) + const title = t(($) => $['table.header.clearAllConfirm'], { ns: 'appAnnotation' }) return ( - <AlertDialog open={isShow} onOpenChange={open => !open && onHide()}> + <AlertDialog open={isShow} onOpenChange={(open) => !open && onHide()}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> @@ -36,10 +32,10 @@ const ClearAllAnnotationsConfirmModal: FC<Props> = ({ </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={onConfirm}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/app/annotation/edit-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/__tests__/index.spec.tsx index 733529dff82d13..7df51eed087de7 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/__tests__/index.spec.tsx @@ -304,15 +304,11 @@ describe('EditAnnotationModal', () => { await user.click(saveButton) // Assert - expect(mockEditAnnotation).toHaveBeenCalledWith( - 'test-app-id', - 'test-annotation-id', - { - message_id: 'test-message-id', - question: 'Modified query', - answer: 'Test answer', - }, - ) + expect(mockEditAnnotation).toHaveBeenCalledWith('test-app-id', 'test-annotation-id', { + message_id: 'test-message-id', + question: 'Modified query', + answer: 'Test answer', + }) }) }) @@ -357,7 +353,9 @@ describe('EditAnnotationModal', () => { // Assert - Confirm dialog should not be visible initially // Assert - Confirm dialog should not be visible initially // Assert - Confirm dialog should not be visible initially - expect(screen.queryByText('appDebug.feature.annotation.removeConfirm')).not.toBeInTheDocument() + expect( + screen.queryByText('appDebug.feature.annotation.removeConfirm'), + ).not.toBeInTheDocument() }) it('should show confirm modal when remove is clicked', async () => { @@ -415,7 +413,9 @@ describe('EditAnnotationModal', () => { await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => { - expect(screen.queryByText('appDebug.feature.annotation.removeConfirm')).not.toBeInTheDocument() + expect( + screen.queryByText('appDebug.feature.annotation.removeConfirm'), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/app/annotation/edit-annotation-modal/edit-item/__tests__/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/edit-item/__tests__/index.spec.tsx index 4efd999d89da72..b84546551d8cba 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/edit-item/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/edit-item/__tests__/index.spec.tsx @@ -428,7 +428,8 @@ describe('EditItem', () => { it('should handle delete action failure gracefully', async () => { // Arrange - const mockSave = vi.fn() + const mockSave = vi + .fn() .mockResolvedValueOnce(undefined) // First save succeeds .mockRejectedValueOnce(new Error('Delete failed')) // Delete fails const props = { diff --git a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx index 3d4d45f200ffbd..195ba37c667c4e 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx @@ -20,7 +20,7 @@ type Props = Readonly<{ onSave: (content: string) => Promise<void> }> -export const EditTitle: FC<{ className?: string, title: string }> = ({ className, title }) => ( +export const EditTitle: FC<{ className?: string; title: string }> = ({ className, title }) => ( <div className={cn(className, 'flex h-[18px] items-center system-xs-medium text-text-tertiary')}> <RiEditFill className="mr-1 size-3.5" /> <div>{title}</div> @@ -29,23 +29,27 @@ export const EditTitle: FC<{ className?: string, title: string }> = ({ className style={{ background: 'linear-gradient(90deg, rgba(0, 0, 0, 0.05) -1.65%, rgba(0, 0, 0, 0.00) 100%)', }} - > - </div> + ></div> </div> ) -const EditItem: FC<Props> = ({ - type, - readonly, - content, - onSave, -}) => { +const EditItem: FC<Props> = ({ type, readonly, content, onSave }) => { const { t } = useTranslation() const [newContent, setNewContent] = useState('') const showNewContent = newContent && newContent !== content - const avatar = type === EditItemType.Query ? <User className="size-6" /> : <Robot className="size-6" /> - const name = type === EditItemType.Query ? t($ => $['editModal.queryName'], { ns: 'appAnnotation' }) : t($ => $['editModal.answerName'], { ns: 'appAnnotation' }) - const editTitle = type === EditItemType.Query ? t($ => $['editModal.yourQuery'], { ns: 'appAnnotation' }) : t($ => $['editModal.yourAnswer'], { ns: 'appAnnotation' }) - const placeholder = type === EditItemType.Query ? t($ => $['editModal.queryPlaceholder'], { ns: 'appAnnotation' }) : t($ => $['editModal.answerPlaceholder'], { ns: 'appAnnotation' }) + const avatar = + type === EditItemType.Query ? <User className="size-6" /> : <Robot className="size-6" /> + const name = + type === EditItemType.Query + ? t(($) => $['editModal.queryName'], { ns: 'appAnnotation' }) + : t(($) => $['editModal.answerName'], { ns: 'appAnnotation' }) + const editTitle = + type === EditItemType.Query + ? t(($) => $['editModal.yourQuery'], { ns: 'appAnnotation' }) + : t(($) => $['editModal.yourAnswer'], { ns: 'appAnnotation' }) + const placeholder = + type === EditItemType.Query + ? t(($) => $['editModal.queryPlaceholder'], { ns: 'appAnnotation' }) + : t(($) => $['editModal.answerPlaceholder'], { ns: 'appAnnotation' }) const [isEdit, setIsEdit] = useState(false) // Reset newContent when content prop changes @@ -57,8 +61,7 @@ const EditItem: FC<Props> = ({ try { await onSave(newContent) setIsEdit(false) - } - catch { + } catch { // Keep edit mode open when save fails // Error notification is handled by the parent component } @@ -70,78 +73,77 @@ const EditItem: FC<Props> = ({ } return ( - <div className="flex" onClick={e => e.stopPropagation()}> - <div className="mr-3 shrink-0"> - {avatar} - </div> + <div className="flex" onClick={(e) => e.stopPropagation()}> + <div className="mr-3 shrink-0">{avatar}</div> <div className="grow"> <div className="mb-1 system-xs-semibold text-text-primary">{name}</div> <div className="system-sm-regular text-text-primary">{content}</div> - {!isEdit - ? ( - <div> - {showNewContent && ( - <div className="mt-3"> - <EditTitle title={editTitle} /> - <div className="mt-1 system-sm-regular text-text-primary">{newContent}</div> - </div> - )} - <div className="mt-2 flex items-center"> - {!readonly && ( - <div - className="flex cursor-pointer items-center space-x-1 system-xs-medium text-text-accent" - onClick={() => { - setIsEdit(true) - }} - > - <RiEditLine className="mr-1 size-3.5" /> - <div>{t($ => $['operation.edit'], { ns: 'common' })}</div> - </div> - )} - - {showNewContent && ( - <div className="ml-2 flex items-center system-xs-medium text-text-tertiary"> - <div className="mr-2">·</div> - <div - className="flex cursor-pointer items-center space-x-1" - onClick={async () => { - try { - await onSave(content) - // Only update UI state after successful delete - setNewContent(content) - } - catch { - // Delete action failed - error is already handled by parent - // UI state remains unchanged, user can retry - } - }} - > - <div className="size-3.5"> - <RiDeleteBinLine className="size-3.5" /> - </div> - <div>{t($ => $['operation.delete'], { ns: 'common' })}</div> - </div> - </div> - )} - </div> - </div> - ) - : ( + {!isEdit ? ( + <div> + {showNewContent && ( <div className="mt-3"> <EditTitle title={editTitle} /> - <Textarea - aria-label={editTitle} - value={newContent} - onValueChange={value => setNewContent(value)} - placeholder={placeholder} - autoFocus - /> - <div className="mt-2 flex space-x-2"> - <Button size="small" variant="primary" onClick={handleSave}>{t($ => $['operation.save'], { ns: 'common' })}</Button> - <Button size="small" onClick={handleCancel}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - </div> + <div className="mt-1 system-sm-regular text-text-primary">{newContent}</div> </div> )} + <div className="mt-2 flex items-center"> + {!readonly && ( + <div + className="flex cursor-pointer items-center space-x-1 system-xs-medium text-text-accent" + onClick={() => { + setIsEdit(true) + }} + > + <RiEditLine className="mr-1 size-3.5" /> + <div>{t(($) => $['operation.edit'], { ns: 'common' })}</div> + </div> + )} + + {showNewContent && ( + <div className="ml-2 flex items-center system-xs-medium text-text-tertiary"> + <div className="mr-2">·</div> + <div + className="flex cursor-pointer items-center space-x-1" + onClick={async () => { + try { + await onSave(content) + // Only update UI state after successful delete + setNewContent(content) + } catch { + // Delete action failed - error is already handled by parent + // UI state remains unchanged, user can retry + } + }} + > + <div className="size-3.5"> + <RiDeleteBinLine className="size-3.5" /> + </div> + <div>{t(($) => $['operation.delete'], { ns: 'common' })}</div> + </div> + </div> + )} + </div> + </div> + ) : ( + <div className="mt-3"> + <EditTitle title={editTitle} /> + <Textarea + aria-label={editTitle} + value={newContent} + onValueChange={(value) => setNewContent(value)} + placeholder={placeholder} + autoFocus + /> + <div className="mt-2 flex space-x-2"> + <Button size="small" variant="primary" onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> + <Button size="small" onClick={handleCancel}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + </div> + </div> + )} </div> </div> ) diff --git a/web/app/components/app/annotation/edit-annotation-modal/index.tsx b/web/app/components/app/annotation/edit-annotation-modal/index.tsx index d651c2015d53b7..dc35003979a7ba 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/index.tsx @@ -38,7 +38,12 @@ type Props = Readonly<{ query: string answer: string onEdited: (editedQuery: string, editedAnswer: string) => void - onAdded: (annotationId: string, authorName: string, editedQuery: string, editedAnswer: string) => void + onAdded: ( + annotationId: string, + authorName: string, + editedQuery: string, + editedAnswer: string, + ) => void createdAt?: number onRemove: () => void onlyEditResponse?: boolean @@ -62,14 +67,13 @@ const EditAnnotationModal: FC<Props> = ({ const { formatTime } = useTimestamp() const { plan, enableBilling } = useProviderContext() const isAdd = !annotationId - const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse) + const isAnnotationFull = + enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse const handleSave = async (type: EditItemType, editedContent: string) => { let postQuery = query let postAnswer = answer - if (type === EditItemType.Query) - postQuery = editedContent - else - postAnswer = editedContent + if (type === EditItemType.Query) postQuery = editedContent + else postAnswer = editedContent try { if (!isAdd) { await editAnnotation(appId, annotationId, { @@ -78,8 +82,7 @@ const EditAnnotationModal: FC<Props> = ({ answer: postAnswer, }) onEdited(postQuery, postAnswer) - } - else { + } else { const res = await addAnnotation(appId, { question: postQuery, answer: postAnswer, @@ -88,10 +91,9 @@ const EditAnnotationModal: FC<Props> = ({ onAdded(res.id, res.account?.name ?? '', postQuery, postAnswer) } - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' }) as string) - } - catch (error) { - const fallbackMessage = t($ => $['api.actionFailed'], { ns: 'common' }) as string + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }) as string) + } catch (error) { + const fallbackMessage = t(($) => $['api.actionFailed'], { ns: 'common' }) as string const message = error instanceof Error && error.message ? error.message : fallbackMessage toast.error(message) // Re-throw to preserve edit mode behavior for UI components @@ -99,8 +101,7 @@ const EditAnnotationModal: FC<Props> = ({ } } const [showModal, setShowModal] = useState(false) - if (!isShow) - return null + if (!isShow) return null return ( <div> @@ -110,8 +111,7 @@ const EditAnnotationModal: FC<Props> = ({ disablePointerDismissal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DrawerPortal> @@ -122,10 +122,10 @@ const EditAnnotationModal: FC<Props> = ({ <div className="shrink-0 border-b border-divider-subtle py-4"> <div className="flex h-6 items-center justify-between pr-5 pl-6"> <DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary"> - {t($ => $['editModal.title'], { ns: 'appAnnotation' })} + {t(($) => $['editModal.title'], { ns: 'appAnnotation' })} </DrawerTitle> <DrawerCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-6 rounded-md" /> </div> @@ -136,27 +136,32 @@ const EditAnnotationModal: FC<Props> = ({ type={EditItemType.Query} content={query} readonly={(isAdd && isAnnotationFull) || onlyEditResponse} - onSave={editedContent => handleSave(EditItemType.Query, editedContent)} + onSave={(editedContent) => handleSave(EditItemType.Query, editedContent)} /> <EditItem type={EditItemType.Answer} content={answer} readonly={isAdd && isAnnotationFull} - onSave={editedContent => handleSave(EditItemType.Answer, editedContent)} + onSave={(editedContent) => handleSave(EditItemType.Answer, editedContent)} /> - <AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}> + <AlertDialog + open={showModal} + onOpenChange={(open) => !open && setShowModal(false)} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle - title={t($ => $['feature.annotation.removeConfirm'], { ns: 'appDebug' })} + title={t(($) => $['feature.annotation.removeConfirm'], { + ns: 'appDebug', + })} className="w-full truncate title-2xl-semi-bold text-text-primary" > - {t($ => $['feature.annotation.removeConfirm'], { ns: 'appDebug' })} + {t(($) => $['feature.annotation.removeConfirm'], { ns: 'appDebug' })} </AlertDialogTitle> </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton tone="destructive" @@ -166,7 +171,7 @@ const EditAnnotationModal: FC<Props> = ({ onHide() }} > - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> @@ -180,28 +185,29 @@ const EditAnnotationModal: FC<Props> = ({ </div> )} - { - annotationId - ? ( - <div className="flex h-16 items-center justify-between rounded-b-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary"> - <div - className="flex cursor-pointer items-center space-x-2 pl-3" - onClick={() => setShowModal(true)} - > - <MessageCheckRemove /> - <div>{t($ => $['editModal.removeThisCache'], { ns: 'appAnnotation' })}</div> - </div> - {!!createdAt && ( - <div> - {t($ => $['editModal.createdAt'], { ns: 'appAnnotation' })} -  - {formatTime(createdAt, t($ => $.dateTimeFormat, { ns: 'appLog' }) as string)} - </div> - )} - </div> - ) - : undefined - } + {annotationId ? ( + <div className="flex h-16 items-center justify-between rounded-b-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary"> + <div + className="flex cursor-pointer items-center space-x-2 pl-3" + onClick={() => setShowModal(true)} + > + <MessageCheckRemove /> + <div> + {t(($) => $['editModal.removeThisCache'], { ns: 'appAnnotation' })} + </div> + </div> + {!!createdAt && ( + <div> + {t(($) => $['editModal.createdAt'], { ns: 'appAnnotation' })} +   + {formatTime( + createdAt, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + )} + </div> + )} + </div> + ) : undefined} </div> </DrawerContent> </DrawerPopup> @@ -209,7 +215,6 @@ const EditAnnotationModal: FC<Props> = ({ </DrawerPortal> </Drawer> </div> - ) } export default React.memo(EditAnnotationModal) diff --git a/web/app/components/app/annotation/empty-element.tsx b/web/app/components/app/annotation/empty-element.tsx index 4e06a51be7f2a0..d7fa7e38a69d7c 100644 --- a/web/app/components/app/annotation/empty-element.tsx +++ b/web/app/components/app/annotation/empty-element.tsx @@ -5,8 +5,21 @@ import { useTranslation } from 'react-i18next' const ThreeDotsIcon = ({ className }: SVGProps<SVGElement>) => { return ( - <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}> - <path d="M5 6.5V5M8.93934 7.56066L10 6.5M10.0103 11.5H11.5103" stroke="#374151" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> + <svg + width="16" + height="16" + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className={className ?? ''} + > + <path + d="M5 6.5V5M8.93934 7.56066L10 6.5M10.0103 11.5H11.5103" + stroke="#374151" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> </svg> ) } @@ -18,11 +31,11 @@ const EmptyElement: FC = () => { <div className="flex h-full items-center justify-center"> <div className="box-border h-fit w-[560px] rounded-2xl bg-background-section-burn px-5 py-4"> <span className="system-md-semibold text-text-secondary"> - {t($ => $['noData.title'], { ns: 'appAnnotation' })} + {t(($) => $['noData.title'], { ns: 'appAnnotation' })} <ThreeDotsIcon className="relative -top-3 -left-1.5 inline" /> </span> <div className="mt-2 system-sm-regular text-text-tertiary"> - {t($ => $['noData.description'], { ns: 'appAnnotation' })} + {t(($) => $['noData.description'], { ns: 'appAnnotation' })} </div> </div> </div> diff --git a/web/app/components/app/annotation/filter.tsx b/web/app/components/app/annotation/filter.tsx index 9f3a72d0a261c9..4d44ca4f7e267a 100644 --- a/web/app/components/app/annotation/filter.tsx +++ b/web/app/components/app/annotation/filter.tsx @@ -16,16 +16,10 @@ type IFilterProps = { children: React.JSX.Element } -const Filter: FC<IFilterProps> = ({ - appId, - queryParams, - setQueryParams, - children, -}) => { +const Filter: FC<IFilterProps> = ({ appId, queryParams, setQueryParams, children }) => { const { data, isLoading } = useAnnotationsCount(appId) const { t } = useTranslation() - if (isLoading || !data) - return null + if (isLoading || !data) return null return ( <div className="mb-2 flex flex-row flex-wrap items-center justify-between gap-2"> <Input @@ -33,7 +27,7 @@ const Filter: FC<IFilterProps> = ({ showLeftIcon showClearIcon value={queryParams.keyword} - placeholder={t($ => $['operation.search'], { ns: 'common' })!} + placeholder={t(($) => $['operation.search'], { ns: 'common' })!} onChange={(e) => { setQueryParams({ ...queryParams, keyword: e.target.value }) }} diff --git a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx index f95351470246ba..3383e96a2b9550 100644 --- a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx @@ -70,7 +70,9 @@ const openOperationsPopover = async (user: ReturnType<typeof userEvent.setup>) = const expandExportMenu = async (user: ReturnType<typeof userEvent.setup>) => { await openOperationsPopover(user) - const exportItem = await screen.findByRole('menuitem', { name: /appAnnotation\.table\.header\.bulkExport/i }) + const exportItem = await screen.findByRole('menuitem', { + name: /appAnnotation\.table\.header\.bulkExport/i, + }) await user.hover(exportItem) } @@ -146,18 +148,17 @@ describe('HeaderOptions', () => { const originalCreateElement = document.createElement.bind(document) const anchor = originalCreateElement('a') as HTMLAnchorElement const clickSpy = vi.spyOn(anchor, 'click').mockImplementation(vi.fn()) - const createElementSpy = vi.spyOn(document, 'createElement') + const createElementSpy = vi + .spyOn(document, 'createElement') .mockImplementation((tagName: Parameters<Document['createElement']>[0]) => { - if (tagName === 'a') - return anchor + if (tagName === 'a') return anchor return originalCreateElement(tagName) }) let capturedBlob: Blob | null = null - const objectURLSpy = vi.spyOn(URL, 'createObjectURL') - .mockImplementation((blob) => { - capturedBlob = blob as Blob - return 'blob://mock-url' - }) + const objectURLSpy = vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + capturedBlob = blob as Blob + return 'blob://mock-url' + }) const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(vi.fn()) renderComponent({}, LanguagesSupported[1]) @@ -242,9 +243,7 @@ describe('HeaderOptions', () => { await clickOperationAction('appAnnotation.table.header.bulkImport') expect(await screen.findByText('appAnnotation.batchModal.title'))!.toBeInTheDocument() - await user.click( - screen.getByRole('button', { name: 'appAnnotation.batchModal.cancel' }), - ) + await user.click(screen.getByRole('button', { name: 'appAnnotation.batchModal.cancel' })) expect(onAdded).not.toHaveBeenCalled() }) @@ -254,18 +253,17 @@ describe('HeaderOptions', () => { const originalCreateElement = document.createElement.bind(document) const anchor = originalCreateElement('a') as HTMLAnchorElement const clickSpy = vi.spyOn(anchor, 'click').mockImplementation(vi.fn()) - const createElementSpy = vi.spyOn(document, 'createElement') + const createElementSpy = vi + .spyOn(document, 'createElement') .mockImplementation((tagName: Parameters<Document['createElement']>[0]) => { - if (tagName === 'a') - return anchor + if (tagName === 'a') return anchor return originalCreateElement(tagName) }) let capturedBlob: Blob | null = null - const objectURLSpy = vi.spyOn(URL, 'createObjectURL') - .mockImplementation((blob) => { - capturedBlob = blob as Blob - return 'blob://mock-url' - }) + const objectURLSpy = vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + capturedBlob = blob as Blob + return 'blob://mock-url' + }) const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(vi.fn()) renderComponent({}, LanguagesSupported[1]) @@ -352,12 +350,7 @@ describe('HeaderOptions', () => { await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(1)) view.rerender( - <HeaderOptions - appId="test-app-id" - onAdd={vi.fn()} - onAdded={vi.fn()} - controlUpdateList={1} - />, + <HeaderOptions appId="test-app-id" onAdd={vi.fn()} onAdded={vi.fn()} controlUpdateList={1} />, ) await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(2)) diff --git a/web/app/components/app/annotation/header-opts/index.tsx b/web/app/components/app/annotation/header-opts/index.tsx index 868fe4e9f00979..1ddc0432163b0c 100644 --- a/web/app/components/app/annotation/header-opts/index.tsx +++ b/web/app/components/app/annotation/header-opts/index.tsx @@ -14,11 +14,8 @@ import { import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - jsonToCSV, -} from 'react-papaparse' +import { jsonToCSV } from 'react-papaparse' import { useLocale } from '@/context/i18n' - import { LanguagesSupported } from '@/i18n-config/language' import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation' import { downloadBlob } from '@/utils/download' @@ -44,11 +41,10 @@ type OperationsMenuProps = { onExportJsonl: () => void } -const buildAnnotationJsonlRecords = (list: AnnotationItemBasic[]) => list.map( - (item: AnnotationItemBasic) => { +const buildAnnotationJsonlRecords = (list: AnnotationItemBasic[]) => + list.map((item: AnnotationItemBasic) => { return `{"messages": [{"role": "system", "content": ""}, {"role": "user", "content": ${JSON.stringify(item.question)}}, {"role": "assistant", "content": ${JSON.stringify(item.answer)}}]}` - }, -) + }) const downloadAnnotationJsonl = (list: AnnotationItemBasic[], locale: string) => { const content = buildAnnotationJsonlRecords(list).join('\n') @@ -59,7 +55,7 @@ const downloadAnnotationJsonl = (list: AnnotationItemBasic[], locale: string) => const downloadAnnotationCsv = (list: AnnotationItemBasic[], locale: string) => { const content = jsonToCSV([ locale !== LanguagesSupported[1] ? CSV_HEADER_QA_EN : CSV_HEADER_QA_CN, - ...list.map(item => [item.question, item.answer]), + ...list.map((item) => [item.question, item.answer]), ]) const file = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' }) downloadBlob({ data: file, fileName: `annotations-${locale}.csv` }) @@ -85,13 +81,19 @@ const OperationsMenu: FC<OperationsMenuProps> = ({ onBulkImport() }} > - <span aria-hidden className="i-custom-vender-line-files-file-plus-02 size-4 shrink-0 text-text-tertiary" /> - {t($ => $['table.header.bulkImport'], { ns: 'appAnnotation' })} + <span + aria-hidden + className="i-custom-vender-line-files-file-plus-02 size-4 shrink-0 text-text-tertiary" + /> + {t(($) => $['table.header.bulkImport'], { ns: 'appAnnotation' })} </DropdownMenuItem> <DropdownMenuSub> <DropdownMenuSubTrigger className="gap-2"> - <span aria-hidden className="i-custom-vender-line-files-file-download-02 size-4 shrink-0 text-text-tertiary" /> - {t($ => $['table.header.bulkExport'], { ns: 'appAnnotation' })} + <span + aria-hidden + className="i-custom-vender-line-files-file-download-02 size-4 shrink-0 text-text-tertiary" + /> + {t(($) => $['table.header.bulkExport'], { ns: 'appAnnotation' })} </DropdownMenuSubTrigger> <DropdownMenuSubContent placement="left-start" @@ -127,18 +129,13 @@ const OperationsMenu: FC<OperationsMenuProps> = ({ }} > <span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" /> - {t($ => $['table.header.clearAll'], { ns: 'appAnnotation' })} + {t(($) => $['table.header.clearAll'], { ns: 'appAnnotation' })} </DropdownMenuItem> </> ) } -const HeaderOptions: FC<Props> = ({ - appId, - onAdd, - onAdded, - controlUpdateList, -}) => { +const HeaderOptions: FC<Props> = ({ appId, onAdd, onAdded, controlUpdateList }) => { const { t } = useTranslation() const locale = useLocale() const [list, setList] = useState<AnnotationItemBasic[]>([]) @@ -152,8 +149,7 @@ const HeaderOptions: FC<Props> = ({ fetchList() }, [fetchList]) useEffect(() => { - if (controlUpdateList) - fetchList() + if (controlUpdateList) fetchList() }, [controlUpdateList, fetchList]) const [showBulkImportModal, setShowBulkImportModal] = useState(false) @@ -172,11 +168,9 @@ const HeaderOptions: FC<Props> = ({ try { await clearAllAnnotations(appId) onAdded() - } - catch (e) { + } catch (e) { console.error(`failed to clear all annotations, ${e}`) - } - finally { + } finally { setShowClearConfirm(false) } } @@ -187,20 +181,20 @@ const HeaderOptions: FC<Props> = ({ <div className="flex space-x-2"> <Button variant="primary" onClick={() => setShowAddModal(true)}> <span aria-hidden className="mr-0.5 i-ri-add-line size-4" /> - <div>{t($ => $['table.header.addAnnotation'], { ns: 'appAnnotation' })}</div> + <div>{t(($) => $['table.header.addAnnotation'], { ns: 'appAnnotation' })}</div> </Button> - <DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}> + <DropdownMenu + modal={false} + open={isOperationsMenuOpen} + onOpenChange={setIsOperationsMenuOpen} + > <DropdownMenuTrigger - aria-label={t($ => $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.more'], { ns: 'common' })} className="mr-0 box-border inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-0 text-components-button-secondary-text shadow-xs backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover data-popup-open:border-components-button-secondary-border-hover data-popup-open:bg-components-button-secondary-bg-hover" > <span aria-hidden className="i-ri-more-fill size-4" /> </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="w-[155px]" - > + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[155px]"> <OperationsMenu list={list} onClose={() => setIsOperationsMenuOpen(false)} @@ -218,25 +212,21 @@ const HeaderOptions: FC<Props> = ({ /> )} - { - showBulkImportModal && ( - <BatchAddModal - appId={appId} - isShow={showBulkImportModal} - onCancel={() => setShowBulkImportModal(false)} - onAdded={onAdded} - /> - ) - } - { - showClearConfirm && ( - <ClearAllAnnotationsConfirmModal - isShow={showClearConfirm} - onHide={() => setShowClearConfirm(false)} - onConfirm={handleConfirmed} - /> - ) - } + {showBulkImportModal && ( + <BatchAddModal + appId={appId} + isShow={showBulkImportModal} + onCancel={() => setShowBulkImportModal(false)} + onAdded={onAdded} + /> + )} + {showClearConfirm && ( + <ClearAllAnnotationsConfirmModal + isShow={showClearConfirm} + onHide={() => setShowClearConfirm(false)} + onConfirm={handleConfirmed} + /> + )} </div> ) } diff --git a/web/app/components/app/annotation/index.tsx b/web/app/components/app/annotation/index.tsx index a35f9370482f28..afb27cdc5350d9 100644 --- a/web/app/components/app/annotation/index.tsx +++ b/web/app/components/app/annotation/index.tsx @@ -21,7 +21,17 @@ import AnnotationFullModal from '@/app/components/billing/annotation-full/modal' import { APP_PAGE_LIMIT } from '@/config' import { useDocLink } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' -import { addAnnotation, delAnnotation, delAnnotations, fetchAnnotationConfig as doFetchAnnotationConfig, editAnnotation, fetchAnnotationList, queryAnnotationJobStatus, updateAnnotationScore, updateAnnotationStatus } from '@/service/annotation' +import { + addAnnotation, + delAnnotation, + delAnnotations, + fetchAnnotationConfig as doFetchAnnotationConfig, + editAnnotation, + fetchAnnotationList, + queryAnnotationJobStatus, + updateAnnotationScore, + updateAnnotationStatus, +} from '@/service/annotation' import { AppModeEnum } from '@/types/app' import { sleep } from '@/utils' import PageTitle from '../log-annotation/page-title' @@ -45,7 +55,8 @@ const Annotation: FC<Props> = (props) => { const [isChatApp] = useState(appDetail.mode !== AppModeEnum.COMPLETION) const [controlRefreshSwitch, setControlRefreshSwitch] = useState(() => Date.now()) const { plan, enableBilling } = useProviderContext() - const isAnnotationFull = enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse + const isAnnotationFull = + enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse const [isShowAnnotationFullModal, setIsShowAnnotationFullModal] = useState(false) const [queryParams, setQueryParams] = useState<QueryParam>({}) const [currPage, setCurrPage] = useState(0) @@ -67,15 +78,13 @@ const Annotation: FC<Props> = (props) => { } useEffect(() => { - if (isChatApp) - fetchAnnotationConfig() + if (isChatApp) fetchAnnotationConfig() }, []) const ensureJobCompleted = async (jobId: string, status: AnnotationEnableStatus) => { while (true) { const res: any = await queryAnnotationJobStatus(appDetail.id, status, jobId) - if (res.job_status === JobStatus.completed) - break + if (res.job_status === JobStatus.completed) break await sleep(2000) } } @@ -90,8 +99,7 @@ const Annotation: FC<Props> = (props) => { }) setList(data as AnnotationItem[]) setTotal(total) - } - finally { + } finally { setIsLoading(false) } } @@ -102,14 +110,14 @@ const Annotation: FC<Props> = (props) => { const handleAdd = async (payload: AnnotationItemBasic) => { await addAnnotation(appDetail.id, payload) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) fetchList() setControlUpdateList(Date.now()) } const handleRemove = async (id: string) => { await delAnnotation(appDetail.id, id) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) fetchList() setControlUpdateList(Date.now()) } @@ -117,13 +125,12 @@ const Annotation: FC<Props> = (props) => { const handleBatchDelete = async () => { try { await delAnnotations(appDetail.id, selectedIds) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) fetchList() setControlUpdateList(Date.now()) setSelectedIds([]) - } - catch (e: any) { - toast.error(e.message || t($ => $['api.actionFailed'], { ns: 'common' })) + } catch (e: any) { + toast.error(e.message || t(($) => $['api.actionFailed'], { ns: 'common' })) } } @@ -133,35 +140,40 @@ const Annotation: FC<Props> = (props) => { } const handleSave = async (question: string, answer: string) => { - if (!currItem) - return + if (!currItem) return await editAnnotation(appDetail.id, currItem.id, { question, answer }) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) fetchList() setControlUpdateList(Date.now()) } useEffect(() => { - if (!isShowEdit) - setControlRefreshSwitch(Date.now()) + if (!isShowEdit) setControlRefreshSwitch(Date.now()) }, [isShowEdit]) return ( <div className="flex h-full flex-col"> <PageTitle - title={t($ => $.title, { ns: 'appAnnotation' })} - description={t($ => $['noData.description'], { ns: 'appAnnotation' })} + title={t(($) => $.title, { ns: 'appAnnotation' })} + description={t(($) => $['noData.description'], { ns: 'appAnnotation' })} learnMoreHref={docLink('/use-dify/monitor/annotation-reply')} - learnMoreLabel={t($ => $['operation.learnMore'], { ns: 'common' })} + learnMoreLabel={t(($) => $['operation.learnMore'], { ns: 'common' })} /> <div className="relative flex min-h-0 flex-1 flex-col py-4"> <Filter appId={appDetail.id} queryParams={queryParams} setQueryParams={setQueryParams}> <div className="flex items-center space-x-2"> {isChatApp && ( <> - <div className={cn(!annotationConfig?.enabled && 'pr-2', 'flex h-7 items-center space-x-1 rounded-lg border border-components-panel-border bg-components-panel-bg-blur pl-2')}> + <div + className={cn( + !annotationConfig?.enabled && 'pr-2', + 'flex h-7 items-center space-x-1 rounded-lg border border-components-panel-border bg-components-panel-bg-blur pl-2', + )} + > <MessageFast className="size-4 text-util-colors-indigo-indigo-600" /> - <div className="system-sm-medium text-text-primary">{t($ => $.name, { ns: 'appAnnotation' })}</div> + <div className="system-sm-medium text-text-primary"> + {t(($) => $.name, { ns: 'appAnnotation' })} + </div> <Switch key={controlRefreshSwitch} checked={annotationConfig?.enabled ?? false} @@ -174,16 +186,19 @@ const Annotation: FC<Props> = (props) => { return } setIsShowEdit(true) - } - else { - const { job_id: jobId }: any = await updateAnnotationStatus(appDetail.id, AnnotationEnableStatus.disable, annotationConfig?.embedding_model, annotationConfig?.score_threshold) + } else { + const { job_id: jobId }: any = await updateAnnotationStatus( + appDetail.id, + AnnotationEnableStatus.disable, + annotationConfig?.embedding_model, + annotationConfig?.score_threshold, + ) await ensureJobCompleted(jobId, AnnotationEnableStatus.disable) await fetchAnnotationConfig() - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) } }} - > - </Switch> + ></Switch> {annotationConfig?.enabled && ( <div className="flex items-center pr-1 pl-1.5"> <div className="mr-1 h-3.5 w-px shrink-0 bg-divider-subtle"></div> @@ -207,44 +222,44 @@ const Annotation: FC<Props> = (props) => { /> </div> </Filter> - {isLoading - ? <Loading type="app" /> - - : total > 0 - ? ( - <List - list={list} - onRemove={handleRemove} - onView={handleView} - selectedIds={selectedIds} - onSelectedIdsChange={setSelectedIds} - onBatchDelete={handleBatchDelete} - /> - ) - : <div className="flex h-full grow items-center justify-center"><EmptyElement /></div>} + {isLoading ? ( + <Loading type="app" /> + ) : total > 0 ? ( + <List + list={list} + onRemove={handleRemove} + onView={handleView} + selectedIds={selectedIds} + onSelectedIdsChange={setSelectedIds} + onBatchDelete={handleBatchDelete} + /> + ) : ( + <div className="flex h-full grow items-center justify-center"> + <EmptyElement /> + </div> + )} {/* Show Pagination only if the total is more than the limit */} - {(total && total > APP_PAGE_LIMIT) - ? ( - <Pagination - page={currPage + 1} - totalPages={totalPages} - onPageChange={page => setCurrPage(page - 1)} - labels={{ - previous: t($ => $['pagination.previous'], { ns: 'common' }), - next: t($ => $['pagination.next'], { ns: 'common' }), - editPageNumber: (page, totalPages) => t($ => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), - pageNumberInput: t($ => $['pagination.pageNumber'], { ns: 'common' }), - }} - pageSize={{ - value: limit, - options: [10, 25, 50], - onValueChange: setLimit, - label: t($ => $['pagination.perPage'], { ns: 'common' }), - ariaLabel: t($ => $['pagination.perPage'], { ns: 'common' }), - }} - /> - ) - : null} + {total && total > APP_PAGE_LIMIT ? ( + <Pagination + page={currPage + 1} + totalPages={totalPages} + onPageChange={(page) => setCurrPage(page - 1)} + labels={{ + previous: t(($) => $['pagination.previous'], { ns: 'common' }), + next: t(($) => $['pagination.next'], { ns: 'common' }), + editPageNumber: (page, totalPages) => + t(($) => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), + pageNumberInput: t(($) => $['pagination.pageNumber'], { ns: 'common' }), + }} + pageSize={{ + value: limit, + options: [10, 25, 50], + onValueChange: setLimit, + label: t(($) => $['pagination.perPage'], { ns: 'common' }), + ariaLabel: t(($) => $['pagination.perPage'], { ns: 'common' }), + }} + /> + ) : null} {isShowViewModal && ( <ViewAnnotationModal @@ -268,10 +283,17 @@ const Annotation: FC<Props> = (props) => { }} onSave={async (embeddingModel, score) => { if ( - embeddingModel.embedding_model_name !== annotationConfig?.embedding_model?.embedding_model_name - || embeddingModel.embedding_provider_name !== annotationConfig?.embedding_model?.embedding_provider_name + embeddingModel.embedding_model_name !== + annotationConfig?.embedding_model?.embedding_model_name || + embeddingModel.embedding_provider_name !== + annotationConfig?.embedding_model?.embedding_provider_name ) { - const { job_id: jobId }: any = await updateAnnotationStatus(appDetail.id, AnnotationEnableStatus.enable, embeddingModel, score) + const { job_id: jobId }: any = await updateAnnotationStatus( + appDetail.id, + AnnotationEnableStatus.enable, + embeddingModel, + score, + ) await ensureJobCompleted(jobId, AnnotationEnableStatus.enable) } const annotationId = await fetchAnnotationConfig() @@ -279,20 +301,18 @@ const Annotation: FC<Props> = (props) => { await updateAnnotationScore(appDetail.id, annotationId, score) await fetchAnnotationConfig() - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) setIsShowEdit(false) }} annotationConfig={annotationConfig!} /> )} - { - isShowAnnotationFullModal && ( - <AnnotationFullModal - show={isShowAnnotationFullModal} - onHide={() => setIsShowAnnotationFullModal(false)} - /> - ) - } + {isShowAnnotationFullModal && ( + <AnnotationFullModal + show={isShowAnnotationFullModal} + onHide={() => setIsShowAnnotationFullModal(false)} + /> + )} </div> </div> ) diff --git a/web/app/components/app/annotation/list.tsx b/web/app/components/app/annotation/list.tsx index f13ba6f83f77b2..d2c8e8600955b4 100644 --- a/web/app/components/app/annotation/list.tsx +++ b/web/app/components/app/annotation/list.tsx @@ -39,36 +39,29 @@ function AnnotationTableRow({ className="cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover" onClick={() => onView(item)} > - <td className="w-12 px-2 align-middle" onClick={e => e.stopPropagation()}> + <td className="w-12 px-2 align-middle" onClick={(e) => e.stopPropagation()}> <div className="flex items-center"> - <Checkbox - className="shrink-0" - value={item.id} - aria-labelledby={questionId} - /> + <Checkbox className="shrink-0" value={item.id} aria-labelledby={questionId} /> </div> </td> - <td - className="max-w-62.5 truncate p-3 pr-2" - title={item.question} - > + <td className="max-w-62.5 truncate p-3 pr-2" title={item.question}> <span id={questionId}>{item.question}</span> </td> - <td - className="max-w-62.5 truncate p-3 pr-2" - title={item.answer} - > + <td className="max-w-62.5 truncate p-3 pr-2" title={item.answer}> {item.answer} </td> <td className="p-3 pr-2">{formattedCreatedAt}</td> <td className="p-3 pr-2">{item.hit_count}</td> - <td className="w-24 p-3 pr-2" onClick={e => e.stopPropagation()}> + <td className="w-24 p-3 pr-2" onClick={(e) => e.stopPropagation()}> <div className="flex space-x-1 text-text-tertiary"> - <ActionButton aria-label={t($ => $['feature.annotation.edit'], { ns: 'appDebug' })} onClick={() => onView(item)}> + <ActionButton + aria-label={t(($) => $['feature.annotation.edit'], { ns: 'appDebug' })} + onClick={() => onView(item)} + > <span aria-hidden className="i-ri-edit-line size-4" /> </ActionButton> <ActionButton - aria-label={t($ => $['feature.annotation.remove'], { ns: 'appDebug' })} + aria-label={t(($) => $['feature.annotation.remove'], { ns: 'appDebug' })} onClick={() => onRemoveClick(item.id)} > <span aria-hidden className="i-ri-delete-bin-line size-4" /> @@ -91,7 +84,7 @@ export function List({ const { formatTime } = useTimestamp() const [currId, setCurrId] = React.useState<string | null>(null) const [showConfirmDelete, setShowConfirmDelete] = React.useState(false) - const annotationIds = list.map(item => item.id) + const annotationIds = list.map((item) => item.id) return ( <> @@ -109,23 +102,36 @@ export function List({ <Checkbox className="shrink-0" parent - aria-label={t($ => $['operation.selectAll'], { ns: 'common' })} + aria-label={t(($) => $['operation.selectAll'], { ns: 'common' })} /> </div> </td> - <td className="w-5 bg-background-section-burn pr-1 pl-2 whitespace-nowrap">{t($ => $['table.header.question'], { ns: 'appAnnotation' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.answer'], { ns: 'appAnnotation' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.createdAt'], { ns: 'appAnnotation' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.hits'], { ns: 'appAnnotation' })}</td> - <td className="w-24 rounded-r-lg bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.actions'], { ns: 'appAnnotation' })}</td> + <td className="w-5 bg-background-section-burn pr-1 pl-2 whitespace-nowrap"> + {t(($) => $['table.header.question'], { ns: 'appAnnotation' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.answer'], { ns: 'appAnnotation' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.createdAt'], { ns: 'appAnnotation' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.hits'], { ns: 'appAnnotation' })} + </td> + <td className="w-24 rounded-r-lg bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.actions'], { ns: 'appAnnotation' })} + </td> </tr> </thead> <tbody className="system-sm-regular text-text-secondary"> - {list.map(item => ( + {list.map((item) => ( <AnnotationTableRow key={item.id} item={item} - formattedCreatedAt={formatTime(item.created_at, t($ => $.dateTimeFormat, { ns: 'appLog' }) as string)} + formattedCreatedAt={formatTime( + item.created_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + )} onView={onView} onRemoveClick={(id) => { setCurrId(id) diff --git a/web/app/components/app/annotation/remove-annotation-confirm-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/remove-annotation-confirm-modal/__tests__/index.spec.tsx index 59c9dd3236b8fc..2279ef387baecb 100644 --- a/web/app/components/app/annotation/remove-annotation-confirm-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/remove-annotation-confirm-modal/__tests__/index.spec.tsx @@ -4,7 +4,7 @@ import RemoveAnnotationConfirmModal from '../index' vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey((key: string, options?: { ns?: string }) => { const translations: Record<string, string> = { @@ -12,13 +12,12 @@ vi.mock('react-i18next', async () => { 'operation.confirm': 'Confirm', 'operation.cancel': 'Cancel', } - if (translations[key]) - return translations[key] + if (translations[key]) return translations[key] const prefix = options?.ns ? `${options.ns}.` : '' return `${prefix}${key}` }), }), - }) + } }) beforeEach(() => { @@ -30,13 +29,7 @@ describe('RemoveAnnotationConfirmModal', () => { describe('Rendering', () => { it('should display the confirm modal when visible', () => { // Arrange - render( - <RemoveAnnotationConfirmModal - isShow - onHide={vi.fn()} - onRemove={vi.fn()} - />, - ) + render(<RemoveAnnotationConfirmModal isShow onHide={vi.fn()} onRemove={vi.fn()} />) // Assert expect(screen.getByText('Remove annotation?')).toBeInTheDocument() @@ -46,13 +39,7 @@ describe('RemoveAnnotationConfirmModal', () => { it('should not render modal content when hidden', () => { // Arrange - render( - <RemoveAnnotationConfirmModal - isShow={false} - onHide={vi.fn()} - onRemove={vi.fn()} - />, - ) + render(<RemoveAnnotationConfirmModal isShow={false} onHide={vi.fn()} onRemove={vi.fn()} />) // Assert expect(screen.queryByText('Remove annotation?')).not.toBeInTheDocument() @@ -65,13 +52,7 @@ describe('RemoveAnnotationConfirmModal', () => { const onHide = vi.fn() const onRemove = vi.fn() // Arrange - render( - <RemoveAnnotationConfirmModal - isShow - onHide={onHide} - onRemove={onRemove} - />, - ) + render(<RemoveAnnotationConfirmModal isShow onHide={onHide} onRemove={onRemove} />) // Act fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) @@ -85,13 +66,7 @@ describe('RemoveAnnotationConfirmModal', () => { const onHide = vi.fn() const onRemove = vi.fn() // Arrange - render( - <RemoveAnnotationConfirmModal - isShow - onHide={onHide} - onRemove={onRemove} - />, - ) + render(<RemoveAnnotationConfirmModal isShow onHide={onHide} onRemove={onRemove} />) // Act fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) diff --git a/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx b/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx index 212f02cccae886..55e347dfdfa9e6 100644 --- a/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx +++ b/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx @@ -17,16 +17,12 @@ type Props = Readonly<{ onRemove: () => void }> -const RemoveAnnotationConfirmModal: FC<Props> = ({ - isShow, - onHide, - onRemove, -}) => { +const RemoveAnnotationConfirmModal: FC<Props> = ({ isShow, onHide, onRemove }) => { const { t } = useTranslation() - const title = t($ => $['feature.annotation.removeConfirm'], { ns: 'appDebug' }) + const title = t(($) => $['feature.annotation.removeConfirm'], { ns: 'appDebug' }) return ( - <AlertDialog open={isShow} onOpenChange={open => !open && onHide()}> + <AlertDialog open={isShow} onOpenChange={(open) => !open && onHide()}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> @@ -35,10 +31,10 @@ const RemoveAnnotationConfirmModal: FC<Props> = ({ </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton tone="destructive" onClick={onRemove}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/app/annotation/view-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/view-annotation-modal/__tests__/index.spec.tsx index 1b46443bd8a89b..4244b856d11159 100644 --- a/web/app/components/app/annotation/view-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/view-annotation-modal/__tests__/index.spec.tsx @@ -23,7 +23,15 @@ vi.mock('../../edit-annotation-modal/edit-item', () => { Answer: 'answer', } return { - default: ({ type, content, onSave }: { type: string, content: string, onSave: (value: string) => void }) => ( + default: ({ + type, + content, + onSave, + }: { + type: string + content: string + onSave: (value: string) => void + }) => ( <div> <div data-testid={`content-${type}`}>{content}</div> <button data-testid={`edit-${type}`} onClick={() => onSave(`${type}-updated`)}> @@ -110,8 +118,7 @@ describe('ViewAnnotationModal', () => { // Assert await waitFor(() => { expect(props.onSave).toHaveBeenCalledWith(props.item.question, 'answer-updated') - }, - ) + }) }) it('should switch to hit history tab and show no data message', async () => { @@ -132,7 +139,10 @@ describe('ViewAnnotationModal', () => { }) it('should render hit history entries with pagination badge when data exists', async () => { - const hits = [createHitHistoryItem({ question: 'user input' }), createHitHistoryItem({ id: 'hit-2', question: 'second' })] + const hits = [ + createHitHistoryItem({ question: 'user input' }), + createHitHistoryItem({ id: 'hit-2', question: 'second' }), + ] fetchHitHistoryListMock.mockResolvedValue({ data: hits, total: 15 }) renderComponent() @@ -148,7 +158,9 @@ describe('ViewAnnotationModal', () => { const { props } = renderComponent() fireEvent.click(screen.getByText('appAnnotation.editModal.removeThisCache')) - expect(await screen.findByText('appDebug.feature.annotation.removeConfirm'))!.toBeInTheDocument() + expect( + await screen.findByText('appDebug.feature.annotation.removeConfirm'), + )!.toBeInTheDocument() const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' }) fireEvent.click(confirmButton) @@ -163,12 +175,16 @@ describe('ViewAnnotationModal', () => { renderComponent() fireEvent.click(screen.getByText('appAnnotation.editModal.removeThisCache')) - expect(await screen.findByText('appDebug.feature.annotation.removeConfirm'))!.toBeInTheDocument() + expect( + await screen.findByText('appDebug.feature.annotation.removeConfirm'), + )!.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => { - expect(screen.queryByText('appDebug.feature.annotation.removeConfirm')).not.toBeInTheDocument() + expect( + screen.queryByText('appDebug.feature.annotation.removeConfirm'), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/app/annotation/view-annotation-modal/hit-history-no-data.tsx b/web/app/components/app/annotation/view-annotation-modal/hit-history-no-data.tsx index b44a7d02596c0a..5fad004b822f6b 100644 --- a/web/app/components/app/annotation/view-annotation-modal/hit-history-no-data.tsx +++ b/web/app/components/app/annotation/view-annotation-modal/hit-history-no-data.tsx @@ -11,7 +11,9 @@ const HitHistoryNoData: FC = () => { <div className="inline-block rounded-lg border border-divider-subtle p-3"> <ClockFastForward className="size-5 text-text-tertiary" /> </div> - <div className="system-sm-regular text-text-tertiary">{t($ => $['viewModal.noHitHistory'], { ns: 'appAnnotation' })}</div> + <div className="system-sm-regular text-text-tertiary"> + {t(($) => $['viewModal.noHitHistory'], { ns: 'appAnnotation' })} + </div> </div> ) } diff --git a/web/app/components/app/annotation/view-annotation-modal/index.tsx b/web/app/components/app/annotation/view-annotation-modal/index.tsx index cb82f00bcd481f..df14922184df42 100644 --- a/web/app/components/app/annotation/view-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/view-annotation-modal/index.tsx @@ -47,14 +47,7 @@ enum TabType { hitHistory = 'hitHistory', } -const ViewAnnotationModal: FC<Props> = ({ - appId, - isShow, - onHide, - item, - onSave, - onRemove, -}) => { +const ViewAnnotationModal: FC<Props> = ({ appId, isShow, onHide, item, onSave, onRemove }) => { const { id, question, answer, created_at: createdAt } = item const [newQuestion, setNewQuery] = useState(question) const [newAnswer, setNewAnswer] = useState(answer) @@ -82,9 +75,7 @@ const ViewAnnotationModal: FC<Props> = ({ }) setHitHistoryList(data as HitHistoryItem[]) setTotal(total) - } - catch { - } + } catch {} } useEffect(() => { @@ -93,26 +84,27 @@ const ViewAnnotationModal: FC<Props> = ({ // Fetch hit history when item changes useEffect(() => { - if (isShow && id) - fetchHitHistory(1) + if (isShow && id) fetchHitHistory(1) }, [id, isShow]) const tabs = [ - { value: TabType.annotation, text: t($ => $['viewModal.annotatedResponse'], { ns: 'appAnnotation' }) }, + { + value: TabType.annotation, + text: t(($) => $['viewModal.annotatedResponse'], { ns: 'appAnnotation' }), + }, { value: TabType.hitHistory, - text: ( - hitHistoryList.length > 0 - ? ( - <div className="flex items-center space-x-1"> - <div>{t($ => $['viewModal.hitHistory'], { ns: 'appAnnotation' })}</div> - <Badge - text={`${total} ${t($ => $[`viewModal.hit${hitHistoryList.length > 1 ? 's' : ''}`], { ns: 'appAnnotation' })}`} - /> - </div> - ) - : t($ => $['viewModal.hitHistory'], { ns: 'appAnnotation' }) - ), + text: + hitHistoryList.length > 0 ? ( + <div className="flex items-center space-x-1"> + <div>{t(($) => $['viewModal.hitHistory'], { ns: 'appAnnotation' })}</div> + <Badge + text={`${total} ${t(($) => $[`viewModal.hit${hitHistoryList.length > 1 ? 's' : ''}`], { ns: 'appAnnotation' })}`} + /> + </div> + ) : ( + t(($) => $['viewModal.hitHistory'], { ns: 'appAnnotation' }) + ), }, ] const [activeTab, setActiveTab] = useState(TabType.annotation) @@ -121,13 +113,11 @@ const ViewAnnotationModal: FC<Props> = ({ if (type === EditItemType.Query) { await onSave(editedContent, newAnswer) setNewQuery(editedContent) - } - else { + } else { await onSave(newQuestion, editedContent) setNewAnswer(editedContent) } - } - catch (error) { + } catch (error) { // If save fails, don't update local state console.error('Failed to save annotation:', error) } @@ -139,82 +129,97 @@ const ViewAnnotationModal: FC<Props> = ({ <EditItem type={EditItemType.Query} content={question} - onSave={editedContent => handleSave(EditItemType.Query, editedContent)} + onSave={(editedContent) => handleSave(EditItemType.Query, editedContent)} /> <EditItem type={EditItemType.Answer} content={answer} - onSave={editedContent => handleSave(EditItemType.Answer, editedContent)} + onSave={(editedContent) => handleSave(EditItemType.Answer, editedContent)} /> </> ) - const hitHistoryTab = total === 0 - ? (<HitHistoryNoData />) - : ( - <div> - <table className={cn('w-full min-w-[440px] border-collapse border-0')}> - <thead className="system-xs-medium-uppercase text-text-tertiary"> - <tr> - <td className="w-5 rounded-l-lg bg-background-section-burn pr-1 pl-2 whitespace-nowrap">{t($ => $['hitHistoryTable.query'], { ns: 'appAnnotation' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['hitHistoryTable.match'], { ns: 'appAnnotation' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['hitHistoryTable.response'], { ns: 'appAnnotation' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['hitHistoryTable.source'], { ns: 'appAnnotation' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['hitHistoryTable.score'], { ns: 'appAnnotation' })}</td> - <td className="w-[160px] rounded-r-lg bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['hitHistoryTable.time'], { ns: 'appAnnotation' })}</td> - </tr> - </thead> - <tbody className="system-sm-regular text-text-secondary"> - {hitHistoryList.map(item => ( - <tr - key={item.id} - className="cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover" + const hitHistoryTab = + total === 0 ? ( + <HitHistoryNoData /> + ) : ( + <div> + <table className={cn('w-full min-w-[440px] border-collapse border-0')}> + <thead className="system-xs-medium-uppercase text-text-tertiary"> + <tr> + <td className="w-5 rounded-l-lg bg-background-section-burn pr-1 pl-2 whitespace-nowrap"> + {t(($) => $['hitHistoryTable.query'], { ns: 'appAnnotation' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['hitHistoryTable.match'], { ns: 'appAnnotation' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['hitHistoryTable.response'], { ns: 'appAnnotation' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['hitHistoryTable.source'], { ns: 'appAnnotation' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['hitHistoryTable.score'], { ns: 'appAnnotation' })} + </td> + <td className="w-[160px] rounded-r-lg bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['hitHistoryTable.time'], { ns: 'appAnnotation' })} + </td> + </tr> + </thead> + <tbody className="system-sm-regular text-text-secondary"> + {hitHistoryList.map((item) => ( + <tr + key={item.id} + className="cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover" + > + <td + className="max-w-[250px] overflow-hidden p-3 pr-2 text-ellipsis whitespace-nowrap" + title={item.question} > - <td - className="max-w-[250px] overflow-hidden p-3 pr-2 text-ellipsis whitespace-nowrap" - title={item.question} - > - {item.question} - </td> - <td - className="max-w-[250px] overflow-hidden p-3 pr-2 text-ellipsis whitespace-nowrap" - title={item.match} - > - {item.match} - </td> - <td - className="max-w-[250px] overflow-hidden p-3 pr-2 text-ellipsis whitespace-nowrap" - title={item.response} - > - {item.response} - </td> - <td className="p-3 pr-2">{item.source}</td> - <td className="p-3 pr-2">{item.score ? item.score.toFixed(2) : '-'}</td> - <td className="p-3 pr-2">{formatTime(item.created_at, t($ => $.dateTimeFormat, { ns: 'appLog' }) as string)}</td> - </tr> - ))} - </tbody> - </table> - {(total && total > APP_PAGE_LIMIT) - ? ( - <Pagination - page={currPage + 1} - totalPages={totalPages} - onPageChange={page => setCurrPage(page - 1)} - labels={{ - previous: t($ => $['pagination.previous'], { ns: 'common' }), - next: t($ => $['pagination.next'], { ns: 'common' }), - editPageNumber: (page, totalPages) => t($ => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), - pageNumberInput: t($ => $['pagination.pageNumber'], { ns: 'common' }), - }} - /> - ) - : null} - </div> - - ) - if (!isShow) - return null + {item.question} + </td> + <td + className="max-w-[250px] overflow-hidden p-3 pr-2 text-ellipsis whitespace-nowrap" + title={item.match} + > + {item.match} + </td> + <td + className="max-w-[250px] overflow-hidden p-3 pr-2 text-ellipsis whitespace-nowrap" + title={item.response} + > + {item.response} + </td> + <td className="p-3 pr-2">{item.source}</td> + <td className="p-3 pr-2">{item.score ? item.score.toFixed(2) : '-'}</td> + <td className="p-3 pr-2"> + {formatTime( + item.created_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + )} + </td> + </tr> + ))} + </tbody> + </table> + {total && total > APP_PAGE_LIMIT ? ( + <Pagination + page={currPage + 1} + totalPages={totalPages} + onPageChange={(page) => setCurrPage(page - 1)} + labels={{ + previous: t(($) => $['pagination.previous'], { ns: 'common' }), + next: t(($) => $['pagination.next'], { ns: 'common' }), + editPageNumber: (page, totalPages) => + t(($) => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), + pageNumberInput: t(($) => $['pagination.pageNumber'], { ns: 'common' }), + }} + /> + ) : null} + </div> + ) + if (!isShow) return null return ( <div> @@ -224,8 +229,7 @@ const ViewAnnotationModal: FC<Props> = ({ disablePointerDismissal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DrawerPortal> @@ -239,14 +243,14 @@ const ViewAnnotationModal: FC<Props> = ({ <TabSlider className="relative top-[9px] shrink-0" value={activeTab} - onChange={v => setActiveTab(v as TabType)} + onChange={(v) => setActiveTab(v as TabType)} options={tabs} noBorderBottom itemClassName="pb-3.5!" /> </DrawerTitle> <DrawerCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-6 rounded-md" /> </div> @@ -255,19 +259,24 @@ const ViewAnnotationModal: FC<Props> = ({ <div className="space-y-6 p-6 pb-4"> {activeTab === TabType.annotation ? annotationTab : hitHistoryTab} </div> - <AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}> + <AlertDialog + open={showModal} + onOpenChange={(open) => !open && setShowModal(false)} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle - title={t($ => $['feature.annotation.removeConfirm'], { ns: 'appDebug' })} + title={t(($) => $['feature.annotation.removeConfirm'], { + ns: 'appDebug', + })} className="w-full truncate title-2xl-semi-bold text-text-primary" > - {t($ => $['feature.annotation.removeConfirm'], { ns: 'appDebug' })} + {t(($) => $['feature.annotation.removeConfirm'], { ns: 'appDebug' })} </AlertDialogTitle> </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton tone="destructive" @@ -277,7 +286,7 @@ const ViewAnnotationModal: FC<Props> = ({ onHide() }} > - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> @@ -290,12 +299,15 @@ const ViewAnnotationModal: FC<Props> = ({ onClick={() => setShowModal(true)} > <MessageCheckRemove /> - <div>{t($ => $['editModal.removeThisCache'], { ns: 'appAnnotation' })}</div> + <div>{t(($) => $['editModal.removeThisCache'], { ns: 'appAnnotation' })}</div> </div> <div> - {t($ => $['editModal.createdAt'], { ns: 'appAnnotation' })} -  - {formatTime(createdAt, t($ => $.dateTimeFormat, { ns: 'appLog' }) as string)} + {t(($) => $['editModal.createdAt'], { ns: 'appAnnotation' })} +   + {formatTime( + createdAt, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + )} </div> </div> )} @@ -305,7 +317,6 @@ const ViewAnnotationModal: FC<Props> = ({ </DrawerPortal> </Drawer> </div> - ) } export default React.memo(ViewAnnotationModal) diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index b5e53457cdd4f5..0d96d732b16a08 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -25,7 +25,8 @@ const intersectionObserverMocks = vi.hoisted(() => ({ vi.mock('@/service/access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), - useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), + useSearchForWhiteListCandidates: (...args: unknown[]) => + mockUseSearchForWhiteListCandidates(...args), useUpdateAccessMode: () => mockUseUpdateAccessMode(), })) @@ -37,21 +38,23 @@ vi.mock('ahooks', async (importOriginal) => { } }) -const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({ - id: 'group-1', - name: 'Group One', - groupSize: 5, - ...overrides, -} as AccessControlGroup) - -const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => ({ - id: 'member-1', - name: 'Member One', - email: 'member@example.com', - avatar: '', - avatarUrl: '', - ...overrides, -} as AccessControlAccount) +const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => + ({ + id: 'group-1', + name: 'Group One', + groupSize: 5, + ...overrides, + }) as AccessControlGroup + +const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => + ({ + id: 'member-1', + name: 'Member One', + email: 'member@example.com', + avatar: '', + avatarUrl: '', + ...overrides, + }) as AccessControlAccount const baseGroup = createGroup() const baseMember = createMember() @@ -176,7 +179,10 @@ describe('SpecificGroupsOrMembers', () => { }) it('should show loading state while pending', async () => { - useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }) + useAccessControlStore.setState({ + appId: 'app-1', + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + }) mockUseAppWhiteListSubjects.mockReturnValue({ isPending: true, data: undefined, @@ -190,7 +196,10 @@ describe('SpecificGroupsOrMembers', () => { }) it('should render fetched groups and members and support removal', async () => { - useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }) + useAccessControlStore.setState({ + appId: 'app-1', + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + }) render(<SpecificGroupsOrMembers />) @@ -226,7 +235,11 @@ describe('AddMemberOrGroupDialog', () => { await user.click(screen.getByText('common.operation.add')) - expect(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText( + 'app.accessControlDialog.operateGroupAndMember.searchPlaceholder', + ), + ).toBeInTheDocument() expect(screen.getByText(baseGroup.name)).toBeInTheDocument() expect(screen.getByText(baseMember.name)).toBeInTheDocument() }) @@ -259,7 +272,12 @@ describe('AddMemberOrGroupDialog', () => { render(<AddMemberOrGroupDialog />) await user.click(screen.getByText('common.operation.add')) - await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group') + await user.type( + screen.getByPlaceholderText( + 'app.accessControlDialog.operateGroupAndMember.searchPlaceholder', + ), + 'Group', + ) expect(document.querySelector('.spin-animation')).toBeInTheDocument() const groupOption = screen.getByRole('option', { name: /Group One/ }) @@ -292,7 +310,9 @@ describe('AddMemberOrGroupDialog', () => { await user.click(screen.getByText('common.operation.add')) - expect(screen.getByRole('status')).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult') + expect(screen.getByRole('status')).toHaveTextContent( + 'app.accessControlDialog.operateGroupAndMember.noResult', + ) }) }) @@ -311,13 +331,7 @@ describe('AccessControl', () => { access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, } as App - render( - <AccessControl - app={app} - onClose={onClose} - onConfirm={onConfirm} - />, - ) + render(<AccessControl app={app} onClose={onClose} onConfirm={onConfirm} />) await waitFor(() => { expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS) @@ -345,12 +359,7 @@ describe('AccessControl', () => { access_mode: AccessMode.PUBLIC, } as App - render( - <AccessControl - app={app} - onClose={vi.fn()} - />, - ) + render(<AccessControl app={app} onClose={vi.fn()} />) expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument() expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument() diff --git a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx index 25df73c08ba06f..3cc43ea6314be2 100644 --- a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx @@ -11,24 +11,27 @@ const intersectionObserverMocks = vi.hoisted(() => ({ })) vi.mock('@/service/access-control', () => ({ - useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), + useSearchForWhiteListCandidates: (...args: unknown[]) => + mockUseSearchForWhiteListCandidates(...args), })) -const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({ - id: 'group-1', - name: 'Group One', - groupSize: 5, - ...overrides, -} as AccessControlGroup) - -const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => ({ - id: 'member-1', - name: 'Member One', - email: 'member@example.com', - avatar: '', - avatarUrl: '', - ...overrides, -} as AccessControlAccount) +const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => + ({ + id: 'group-1', + name: 'Group One', + groupSize: 5, + ...overrides, + }) as AccessControlGroup + +const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => + ({ + id: 'member-1', + name: 'Member One', + email: 'member@example.com', + avatar: '', + avatarUrl: '', + ...overrides, + }) as AccessControlAccount describe('AddMemberOrGroupDialog', () => { const baseGroup = createGroup() @@ -84,7 +87,11 @@ describe('AddMemberOrGroupDialog', () => { await user.click(screen.getByText('common.operation.add')) - expect(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText( + 'app.accessControlDialog.operateGroupAndMember.searchPlaceholder', + ), + ).toBeInTheDocument() expect(screen.getByText(baseGroup.name)).toBeInTheDocument() expect(screen.getByText(baseMember.name)).toBeInTheDocument() }) @@ -116,7 +123,9 @@ describe('AddMemberOrGroupDialog', () => { await user.click(screen.getByText('common.operation.add')) - expect(screen.getByRole('status')).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult') + expect(screen.getByRole('status')).toHaveTextContent( + 'app.accessControlDialog.operateGroupAndMember.noResult', + ) }) it('should keep breadcrumbs visible when the current group has no candidates', async () => { @@ -135,11 +144,21 @@ describe('AddMemberOrGroupDialog', () => { await user.click(screen.getByText('common.operation.add')) - expect(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'app.accessControlDialog.operateGroupAndMember.allMembers', + }), + ).toBeInTheDocument() expect(screen.getByText(baseGroup.name)).toBeInTheDocument() - expect(screen.getByRole('status')).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult') - - await user.click(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' })) + expect(screen.getByRole('status')).toHaveTextContent( + 'app.accessControlDialog.operateGroupAndMember.noResult', + ) + + await user.click( + screen.getByRole('button', { + name: 'app.accessControlDialog.operateGroupAndMember.allMembers', + }), + ) expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([]) }) diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx index 74e7d7046c0c22..497bf26d867c3a 100644 --- a/web/app/components/app/app-access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -14,9 +14,10 @@ let mockWebappAuth = { allow_email_code_login: false, } -const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { - systemFeatures: { webapp_auth: mockWebappAuth }, -}) +const render = (ui: ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { webapp_auth: mockWebappAuth }, + }) const mockMutateAsync = vi.fn() const mockUseUpdateAccessMode = vi.fn(() => ({ @@ -28,7 +29,8 @@ const mockUseSearchForWhiteListCandidates = vi.fn() vi.mock('@/service/access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), - useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), + useSearchForWhiteListCandidates: (...args: unknown[]) => + mockUseSearchForWhiteListCandidates(...args), useUpdateAccessMode: () => mockUseUpdateAccessMode(), })) @@ -73,13 +75,7 @@ describe('AccessControl', () => { access_mode: AccessMode.PUBLIC, } as App - render( - <AccessControl - app={app} - onClose={onClose} - onConfirm={onConfirm} - />, - ) + render(<AccessControl app={app} onClose={onClose} onConfirm={onConfirm} />) await waitFor(() => { expect(useAccessControlStore.getState().appId).toBe(app.id) diff --git a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx index e7635219405046..9869734179b30b 100644 --- a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx @@ -14,21 +14,23 @@ vi.mock('../add-member-or-group-pop', () => ({ default: () => <div data-testid="add-member-or-group-dialog" />, })) -const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({ - id: 'group-1', - name: 'Group One', - groupSize: 5, - ...overrides, -} as AccessControlGroup) - -const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => ({ - id: 'member-1', - name: 'Member One', - email: 'member@example.com', - avatar: '', - avatarUrl: '', - ...overrides, -} as AccessControlAccount) +const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => + ({ + id: 'group-1', + name: 'Group One', + groupSize: 5, + ...overrides, + }) as AccessControlGroup + +const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => + ({ + id: 'member-1', + name: 'Member One', + email: 'member@example.com', + avatar: '', + avatarUrl: '', + ...overrides, + }) as AccessControlAccount describe('SpecificGroupsOrMembers', () => { const baseGroup = createGroup() diff --git a/web/app/components/app/app-access-control/access-control-dialog.tsx b/web/app/components/app/app-access-control/access-control-dialog.tsx index a863935f90dc3b..e11c60308e979e 100644 --- a/web/app/components/app/app-access-control/access-control-dialog.tsx +++ b/web/app/components/app/app-access-control/access-control-dialog.tsx @@ -1,10 +1,6 @@ import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - Dialog, - DialogCloseButton, - DialogContent, -} from '@langgenius/dify-ui/dialog' +import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog' import { useCallback } from 'react' type DialogProps = { @@ -14,18 +10,13 @@ type DialogProps = { onClose?: () => void } -const AccessControlDialog = ({ - className, - children, - show, - onClose, -}: DialogProps) => { +const AccessControlDialog = ({ className, children, show, onClose }: DialogProps) => { const close = useCallback(() => { onClose?.() }, [onClose]) return ( - <Dialog open={show} disablePointerDismissal onOpenChange={open => !open && close()}> + <Dialog open={show} disablePointerDismissal onOpenChange={(open) => !open && close()}> <DialogContent className={cn( 'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-all', diff --git a/web/app/components/app/app-access-control/access-control-item.tsx b/web/app/components/app/app-access-control/access-control-item.tsx index cc2cf94f0c497e..5fdba89788a46a 100644 --- a/web/app/components/app/app-access-control/access-control-item.tsx +++ b/web/app/components/app/app-access-control/access-control-item.tsx @@ -8,14 +8,12 @@ type AccessControlItemProps = PropsWithChildren<{ }> const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => { - const currentMenu = useAccessControlStore(s => s.currentMenu) - const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu) + const currentMenu = useAccessControlStore((s) => s.currentMenu) + const setCurrentMenu = useAccessControlStore((s) => s.setCurrentMenu) if (currentMenu !== type) { return ( <div - className="cursor-pointer rounded-[10px] border - border-components-option-card-option-border bg-components-option-card-option-bg - hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover" + className="cursor-pointer rounded-[10px] border border-components-option-card-option-border bg-components-option-card-option-bg hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover" onClick={() => setCurrentMenu(type)} > {children} @@ -24,9 +22,7 @@ const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => { } return ( - <div className="rounded-[10px] border-[1.5px] - border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm" - > + <div className="rounded-[10px] border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm"> {children} </div> ) diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx index 0a783f14721d45..c9cee77bda22f8 100644 --- a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx +++ b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx @@ -1,6 +1,12 @@ 'use client' import type { ComboboxChangeEventDetails } from '@langgenius/dify-ui/combobox' -import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control' +import type { + AccessControlAccount, + AccessControlGroup, + Subject, + SubjectAccount, + SubjectGroup, +} from '@/models/access-control' import { Avatar } from '@langgenius/dify-ui/avatar' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' @@ -33,17 +39,20 @@ export default function AddMemberOrGroupDialog() { const [keyword, setKeyword] = useState('') const scrollRootRef = useRef<HTMLDivElement>(null) const anchorRef = useRef<HTMLDivElement>(null) - const specificGroups = useAccessControlStore(s => s.specificGroups) - const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups) - const specificMembers = useAccessControlStore(s => s.specificMembers) - const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers) - const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb) + const specificGroups = useAccessControlStore((s) => s.specificGroups) + const setSpecificGroups = useAccessControlStore((s) => s.setSpecificGroups) + const specificMembers = useAccessControlStore((s) => s.specificMembers) + const setSpecificMembers = useAccessControlStore((s) => s.setSpecificMembers) + const selectedGroupsForBreadcrumb = useAccessControlStore((s) => s.selectedGroupsForBreadcrumb) const debouncedKeyword = useDebounce(keyword, { wait: 500 }) const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1] - const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open) + const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates( + { keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, + open, + ) const pages = data?.pages ?? [] - const subjects = pages.flatMap(page => page.subjects ?? []) + const subjects = pages.flatMap((page) => page.subjects ?? []) const selectedSubjects = [ ...specificGroups.map(groupToSubject), ...specificMembers.map(memberToSubject), @@ -55,25 +64,25 @@ export default function AddMemberOrGroupDialog() { useEffect(() => { let observer: IntersectionObserver | undefined if (anchorRef.current) { - observer = new IntersectionObserver((entries) => { - if (entries[0]!.isIntersecting && !isLoading && hasMore) - fetchNextPage() - }, { root: scrollRootRef.current, rootMargin: '20px' }) + observer = new IntersectionObserver( + (entries) => { + if (entries[0]!.isIntersecting && !isLoading && hasMore) fetchNextPage() + }, + { root: scrollRootRef.current, rootMargin: '20px' }, + ) observer.observe(anchorRef.current) } return () => observer?.disconnect() }, [isLoading, fetchNextPage, hasMore]) const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen) - setKeyword('') + if (!nextOpen) setKeyword('') setOpen(nextOpen) } const handleInputValueChange = (inputValue: string, details: ComboboxChangeEventDetails) => { - if (details.reason !== 'item-press') - setKeyword(inputValue) + if (details.reason !== 'item-press') setKeyword(inputValue) } const handleValueChange = (nextSubjects: Subject[]) => { @@ -83,8 +92,7 @@ export default function AddMemberOrGroupDialog() { for (const subject of nextSubjects) { if (subject.subjectType === SubjectType.GROUP) nextGroups.push((subject as SubjectGroup).groupData) - else - nextMembers.push((subject as SubjectAccount).accountData) + else nextMembers.push((subject as SubjectAccount).accountData) } setSpecificGroups(nextGroups) @@ -107,14 +115,14 @@ export default function AddMemberOrGroupDialog() { onValueChange={handleValueChange} > <ComboboxTrigger - aria-label={t($ => $['operation.add'], { ns: 'common' })} + aria-label={t(($) => $['operation.add'], { ns: 'common' })} icon={false} size="small" className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover" > <span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap"> <span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" /> - <span className="shrink-0">{t($ => $['operation.add'], { ns: 'common' })}</span> + <span className="shrink-0">{t(($) => $['operation.add'], { ns: 'common' })}</span> </span> </ComboboxTrigger> <ComboboxContent @@ -125,44 +133,51 @@ export default function AddMemberOrGroupDialog() { <div ref={scrollRootRef} className="min-h-0 overflow-y-auto"> <div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]"> <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> + <span + className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> <ComboboxInput - aria-label={t($ => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'], { ns: 'app' })} - placeholder={t($ => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'], { ns: 'app' })} + aria-label={t( + ($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'], + { ns: 'app' }, + )} + placeholder={t( + ($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'], + { ns: 'app' }, + )} className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary" /> </ComboboxInputGroup> </div> - {isLoading - ? ( - <ComboboxStatus className="p-1"> - <Loading /> - </ComboboxStatus> - ) - : ( + {isLoading ? ( + <ComboboxStatus className="p-1"> + <Loading /> + </ComboboxStatus> + ) : ( + <> + {shouldShowBreadcrumb && ( + <div className="flex h-7 items-center px-2 py-0.5"> + <SelectedGroupsBreadCrumb /> + </div> + )} + {hasResults ? ( <> - {shouldShowBreadcrumb && ( - <div className="flex h-7 items-center px-2 py-0.5"> - <SelectedGroupsBreadCrumb /> - </div> - )} - {hasResults - ? ( - <> - <ComboboxList className="max-h-none p-1"> - {(subject: Subject) => <SubjectItem key={getSubjectValue(subject)} subject={subject} />} - </ComboboxList> - {isFetchingNextPage && <Loading />} - <div ref={anchorRef} className="h-0" /> - </> - ) - : ( - <ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5"> - {t($ => $['accessControlDialog.operateGroupAndMember.noResult'], { ns: 'app' })} - </ComboboxEmpty> - )} + <ComboboxList className="max-h-none p-1"> + {(subject: Subject) => ( + <SubjectItem key={getSubjectValue(subject)} subject={subject} /> + )} + </ComboboxList> + {isFetchingNextPage && <Loading />} + <div ref={anchorRef} className="h-0" /> </> + ) : ( + <ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5"> + {t(($) => $['accessControlDialog.operateGroupAndMember.noResult'], { ns: 'app' })} + </ComboboxEmpty> )} + </> + )} </div> </ComboboxContent> </Combobox> @@ -186,8 +201,7 @@ function memberToSubject(member: AccessControlAccount): SubjectAccount { } function getSubjectLabel(subject: Subject) { - if (subject.subjectType === SubjectType.GROUP) - return (subject as SubjectGroup).groupData.name + if (subject.subjectType === SubjectType.GROUP) return (subject as SubjectGroup).groupData.name return (subject as SubjectAccount).accountData.name } @@ -208,8 +222,10 @@ function SubjectItem({ subject }: { subject: Subject }) { } function SelectedGroupsBreadCrumb() { - const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb) - const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb) + const selectedGroupsForBreadcrumb = useAccessControlStore((s) => s.selectedGroupsForBreadcrumb) + const setSelectedGroupsForBreadcrumb = useAccessControlStore( + (s) => s.setSelectedGroupsForBreadcrumb, + ) const { t } = useTranslation() const handleBreadCrumbClick = (index: number) => { @@ -223,36 +239,39 @@ function SelectedGroupsBreadCrumb() { return ( <div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5"> - {hasBreadcrumb - ? ( - <button - type="button" - className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={handleReset} - > - {t($ => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })} - </button> - ) - : ( - <span className="system-xs-regular text-text-tertiary">{t($ => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })}</span> - )} + {hasBreadcrumb ? ( + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleReset} + > + {t(($) => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })} + </button> + ) : ( + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })} + </span> + )} {selectedGroupsForBreadcrumb.map((group, index) => { const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1 return ( - <div key={index} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary"> + <div + key={index} + className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary" + > <span>/</span> - {isLastGroup - ? <span>{group.name}</span> - : ( - <button - type="button" - className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={() => handleBreadCrumbClick(index)} - > - {group.name} - </button> - )} + {isLastGroup ? ( + <span>{group.name}</span> + ) : ( + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => handleBreadCrumbClick(index)} + > + {group.name} + </button> + )} </div> ) })} @@ -266,10 +285,12 @@ type GroupItemProps = { } function GroupItem({ group, subject }: GroupItemProps) { const { t } = useTranslation() - const specificGroups = useAccessControlStore(s => s.specificGroups) - const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb) - const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb) - const isChecked = specificGroups.some(g => g.id === group.id) + const specificGroups = useAccessControlStore((s) => s.specificGroups) + const selectedGroupsForBreadcrumb = useAccessControlStore((s) => s.selectedGroupsForBreadcrumb) + const setSelectedGroupsForBreadcrumb = useAccessControlStore( + (s) => s.setSelectedGroupsForBreadcrumb, + ) + const isChecked = specificGroups.some((g) => g.id === group.id) const handleExpandClick = () => { setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group]) @@ -282,7 +303,10 @@ function GroupItem({ group, subject }: GroupItemProps) { <ComboboxItemText className="flex grow items-center px-0"> <div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid"> <div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center"> - <RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" /> + <RiOrganizationChart + className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" + aria-hidden="true" + /> </div> </div> <span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span> @@ -294,10 +318,12 @@ function GroupItem({ group, subject }: GroupItemProps) { disabled={isChecked} variant="ghost-accent" className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1" - onPointerDown={event => event.preventDefault()} + onPointerDown={(event) => event.preventDefault()} onClick={handleExpandClick} > - <span className="px-[3px]">{t($ => $['accessControlDialog.operateGroupAndMember.expand'], { ns: 'app' })}</span> + <span className="px-[3px]"> + {t(($) => $['accessControlDialog.operateGroupAndMember.expand'], { ns: 'app' })} + </span> <RiArrowRightSLine className="size-4" aria-hidden="true" /> </Button> </div> @@ -311,8 +337,8 @@ type MemberItemProps = { function MemberItem({ member, subject }: MemberItemProps) { const currentUser = useAtomValue(userProfileAtom) const { t } = useTranslation() - const specificMembers = useAccessControlStore(s => s.specificMembers) - const isChecked = specificMembers.some(m => m.id === member.id) + const specificMembers = useAccessControlStore((s) => s.specificMembers) + const isChecked = specificMembers.some((m) => m.id === member.id) return ( <BaseItem subject={subject} className="pr-3"> <SelectionBox checked={isChecked} /> @@ -325,9 +351,7 @@ function MemberItem({ member, subject }: MemberItemProps) { <span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span> {currentUser.email === member.email && ( <span className="system-xs-regular text-text-tertiary"> - ( - {t($ => $.you, { ns: 'common' })} - ) + ({t(($) => $.you, { ns: 'common' })}) </span> )} </ComboboxItemText> diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index f7ebc71140dcb9..8cdd7688fed9b7 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -26,15 +26,16 @@ export default function AccessControl(props: AccessControlProps) { const { app, onClose, onConfirm } = props const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const setAppId = useAccessControlStore(s => s.setAppId) - const specificGroups = useAccessControlStore(s => s.specificGroups) - const specificMembers = useAccessControlStore(s => s.specificMembers) - const currentMenu = useAccessControlStore(s => s.currentMenu) - const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu) - const hideTip = systemFeatures.webapp_auth.enabled - && (systemFeatures.webapp_auth.allow_sso - || systemFeatures.webapp_auth.allow_email_password_login - || systemFeatures.webapp_auth.allow_email_code_login) + const setAppId = useAccessControlStore((s) => s.setAppId) + const specificGroups = useAccessControlStore((s) => s.specificGroups) + const specificMembers = useAccessControlStore((s) => s.specificMembers) + const currentMenu = useAccessControlStore((s) => s.currentMenu) + const setCurrentMenu = useAccessControlStore((s) => s.setCurrentMenu) + const hideTip = + systemFeatures.webapp_auth.enabled && + (systemFeatures.webapp_auth.allow_sso || + systemFeatures.webapp_auth.allow_email_password_login || + systemFeatures.webapp_auth.allow_email_code_login) useEffect(() => { setAppId(app.id) @@ -62,25 +63,33 @@ export default function AccessControl(props: AccessControlProps) { submitData.subjects = subjects } await updateAccessMode(submitData) - toast.success(t($ => $['accessControlDialog.updateSuccess'], { ns: 'app' })) + toast.success(t(($) => $['accessControlDialog.updateSuccess'], { ns: 'app' })) onConfirm?.() }, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu]) return ( <AccessControlDialog show onClose={onClose}> <div className="flex flex-col gap-y-3"> <div className="pt-6 pr-14 pb-3 pl-6"> - <DialogTitle className="title-2xl-semi-bold text-text-primary">{t($ => $['accessControlDialog.title'], { ns: 'app' })}</DialogTitle> - <DialogDescription className="mt-1 system-xs-regular text-text-tertiary">{t($ => $['accessControlDialog.description'], { ns: 'app' })}</DialogDescription> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['accessControlDialog.title'], { ns: 'app' })} + </DialogTitle> + <DialogDescription className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $['accessControlDialog.description'], { ns: 'app' })} + </DialogDescription> </div> <div className="flex flex-col gap-y-1 px-6 pb-3"> <div className="leading-6"> - <p className="system-sm-medium text-text-tertiary">{t($ => $['accessControlDialog.accessLabel'], { ns: 'app' })}</p> + <p className="system-sm-medium text-text-tertiary"> + {t(($) => $['accessControlDialog.accessLabel'], { ns: 'app' })} + </p> </div> <AccessControlItem type={AccessMode.ORGANIZATION}> <div className="flex items-center p-3"> <div className="flex grow items-center gap-x-2"> <RiBuildingLine className="size-4 text-text-primary" /> - <p className="system-sm-medium text-text-primary">{t($ => $['accessControlDialog.accessItems.organization'], { ns: 'app' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $['accessControlDialog.accessItems.organization'], { ns: 'app' })} + </p> </div> </div> </AccessControlItem> @@ -91,7 +100,9 @@ export default function AccessControl(props: AccessControlProps) { <div className="flex items-center p-3"> <div className="flex grow items-center gap-x-2"> <RiVerifiedBadgeLine className="size-4 text-text-primary" /> - <p className="system-sm-medium text-text-primary">{t($ => $['accessControlDialog.accessItems.external'], { ns: 'app' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $['accessControlDialog.accessItems.external'], { ns: 'app' })} + </p> </div> {!hideTip && <WebAppSSONotEnabledTip />} </div> @@ -99,13 +110,22 @@ export default function AccessControl(props: AccessControlProps) { <AccessControlItem type={AccessMode.PUBLIC}> <div className="flex items-center gap-x-2 p-3"> <RiGlobalLine className="size-4 text-text-primary" /> - <p className="system-sm-medium text-text-primary">{t($ => $['accessControlDialog.accessItems.anyone'], { ns: 'app' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $['accessControlDialog.accessItems.anyone'], { ns: 'app' })} + </p> </div> </AccessControlItem> </div> <div className="flex items-center justify-end gap-x-2 p-6 pt-5"> - <Button onClick={onClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button disabled={isPending} loading={isPending} variant="primary" onClick={handleConfirm}>{t($ => $['operation.confirm'], { ns: 'common' })}</Button> + <Button onClick={onClose}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> + <Button + disabled={isPending} + loading={isPending} + variant="primary" + onClick={handleConfirm} + > + {t(($) => $['operation.confirm'], { ns: 'common' })} + </Button> </div> </div> </AccessControlDialog> diff --git a/web/app/components/app/app-access-control/specific-groups-or-members.tsx b/web/app/components/app/app-access-control/specific-groups-or-members.tsx index b29652bf55452d..98f76f3aa64f90 100644 --- a/web/app/components/app/app-access-control/specific-groups-or-members.tsx +++ b/web/app/components/app/app-access-control/specific-groups-or-members.tsx @@ -12,13 +12,16 @@ import Loading from '../../base/loading' import AddMemberOrGroupDialog from './add-member-or-group-pop' export default function SpecificGroupsOrMembers() { - const currentMenu = useAccessControlStore(s => s.currentMenu) - const appId = useAccessControlStore(s => s.appId) - const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups) - const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers) + const currentMenu = useAccessControlStore((s) => s.currentMenu) + const appId = useAccessControlStore((s) => s.appId) + const setSpecificGroups = useAccessControlStore((s) => s.setSpecificGroups) + const setSpecificMembers = useAccessControlStore((s) => s.setSpecificMembers) const { t } = useTranslation() - const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) + const { isPending, data } = useAppWhiteListSubjects( + appId, + Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS, + ) useEffect(() => { setSpecificGroups(data?.groups ?? []) setSpecificMembers(data?.members ?? []) @@ -29,7 +32,9 @@ export default function SpecificGroupsOrMembers() { <div className="flex items-center p-3"> <div className="flex grow items-center gap-x-2"> <RiLockLine className="size-4 text-text-primary" /> - <p className="system-sm-medium text-text-primary">{t($ => $['accessControlDialog.accessItems.specific'], { ns: 'app' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $['accessControlDialog.accessItems.specific'], { ns: 'app' })} + </p> </div> </div> ) @@ -40,7 +45,9 @@ export default function SpecificGroupsOrMembers() { <div className="flex items-center gap-x-1 p-3"> <div className="flex grow items-center gap-x-1"> <RiLockLine className="size-4 text-text-primary" /> - <p className="system-sm-medium text-text-primary">{t($ => $['accessControlDialog.accessItems.specific'], { ns: 'app' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $['accessControlDialog.accessItems.specific'], { ns: 'app' })} + </p> </div> <div className="flex items-center gap-x-1"> <AddMemberOrGroupDialog /> @@ -57,19 +64,39 @@ export default function SpecificGroupsOrMembers() { function RenderGroupsAndMembers() { const { t } = useTranslation() - const specificGroups = useAccessControlStore(s => s.specificGroups) - const specificMembers = useAccessControlStore(s => s.specificMembers) + const specificGroups = useAccessControlStore((s) => s.specificGroups) + const specificMembers = useAccessControlStore((s) => s.specificMembers) if (specificGroups.length <= 0 && specificMembers.length <= 0) - return <div className="px-2 pt-5 pb-1.5"><p className="text-center system-xs-regular text-text-tertiary">{t($ => $['accessControlDialog.noGroupsOrMembers'], { ns: 'app' })}</p></div> + return ( + <div className="px-2 pt-5 pb-1.5"> + <p className="text-center system-xs-regular text-text-tertiary"> + {t(($) => $['accessControlDialog.noGroupsOrMembers'], { ns: 'app' })} + </p> + </div> + ) return ( <> - <p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['accessControlDialog.groups'], { ns: 'app', count: specificGroups.length ?? 0 })}</p> + <p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['accessControlDialog.groups'], { + ns: 'app', + count: specificGroups.length ?? 0, + })} + </p> <div className="flex flex-row flex-wrap gap-1"> - {specificGroups.map((group, index) => <GroupItem key={index} group={group} />)} + {specificGroups.map((group, index) => ( + <GroupItem key={index} group={group} /> + ))} </div> - <p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['accessControlDialog.members'], { ns: 'app', count: specificMembers.length ?? 0 })}</p> + <p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['accessControlDialog.members'], { + ns: 'app', + count: specificMembers.length ?? 0, + })} + </p> <div className="flex flex-row flex-wrap gap-1"> - {specificMembers.map((member, index) => <MemberItem key={index} member={member} />)} + {specificMembers.map((member, index) => ( + <MemberItem key={index} member={member} /> + ))} </div> </> ) @@ -79,14 +106,16 @@ type GroupItemProps = { group: AccessControlGroup } function GroupItem({ group }: GroupItemProps) { - const specificGroups = useAccessControlStore(s => s.specificGroups) - const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups) + const specificGroups = useAccessControlStore((s) => s.specificGroups) + const setSpecificGroups = useAccessControlStore((s) => s.setSpecificGroups) const handleRemoveGroup = useCallback(() => { - setSpecificGroups(specificGroups.filter(g => g.id !== group.id)) + setSpecificGroups(specificGroups.filter((g) => g.id !== group.id)) }, [group, setSpecificGroups, specificGroups]) return ( <BaseItem - icon={<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" />} + icon={ + <RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" /> + } onRemove={handleRemoveGroup} > <p className="system-xs-regular text-text-primary">{group.name}</p> @@ -99,10 +128,10 @@ type MemberItemProps = { member: AccessControlAccount } function MemberItem({ member }: MemberItemProps) { - const specificMembers = useAccessControlStore(s => s.specificMembers) - const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers) + const specificMembers = useAccessControlStore((s) => s.specificMembers) + const setSpecificMembers = useAccessControlStore((s) => s.setSpecificMembers) const handleRemoveMember = useCallback(() => { - setSpecificMembers(specificMembers.filter(m => m.id !== member.id)) + setSpecificMembers(specificMembers.filter((m) => m.id !== member.id)) }, [member, setSpecificMembers, specificMembers]) return ( <BaseItem @@ -133,7 +162,7 @@ function BaseItem({ icon, onRemove, children }: BaseItemProps) { <button type="button" className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.remove'], { ns: 'common' })} + aria-label={t(($) => $['operation.remove'], { ns: 'common' })} onClick={onRemove} > <RiCloseCircleFill className="h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" /> @@ -144,7 +173,7 @@ function BaseItem({ icon, onRemove, children }: BaseItemProps) { export function WebAppSSONotEnabledTip() { const { t } = useTranslation() - const tip = t($ => $['accessControlDialog.webAppSSONotEnabledTip'], { ns: 'app' }) + const tip = t(($) => $['accessControlDialog.webAppSSONotEnabledTip'], { ns: 'app' }) return ( <Infotip diff --git a/web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx b/web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx index 824a0f5de2d672..9108cf3d3e4ac0 100644 --- a/web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx @@ -36,7 +36,9 @@ vi.mock('@/app/components/app/app-publisher', () => ({ mockAppPublisherProps.current = props return ( <div> - <button onClick={() => props.onPublish?.({ id: 'model-1' })}>publish-through-wrapper</button> + <button onClick={() => props.onPublish?.({ id: 'model-1' })}> + publish-through-wrapper + </button> <button onClick={() => props.onRestore?.()}>restore-through-wrapper</button> </div> ) @@ -44,7 +46,8 @@ vi.mock('@/app/components/app/app-publisher', () => ({ })) vi.mock('@/app/components/base/features/hooks', () => ({ - useFeatures: (selector: (state: { features: typeof mockFeatures }) => unknown) => selector({ features: mockFeatures }), + useFeatures: (selector: (state: { features: typeof mockFeatures }) => unknown) => + selector({ features: mockFeatures }), useFeaturesStore: () => ({ getState: () => ({ features: mockFeatures, @@ -103,45 +106,41 @@ describe('FeaturesWrappedAppPublisher', () => { }) it('should restore published features after confirmation', async () => { - render( - <FeaturesWrappedAppPublisher - publishedConfig={publishedConfig as any} - />, - ) + render(<FeaturesWrappedAppPublisher publishedConfig={publishedConfig as any} />) fireEvent.click(screen.getByText('restore-through-wrapper')) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.confirm(?=$|:)/ })) await waitFor(() => { expect(publishedConfig.modelConfig.resetAppConfig).toHaveBeenCalledTimes(1) - expect(mockSetFeatures).toHaveBeenCalledWith(expect.objectContaining({ - moreLikeThis: { enabled: true }, - opening: { - enabled: true, - opening_statement: 'Hello there', - suggested_questions: ['Q1'], - }, - moderation: { enabled: true }, - speech2text: { enabled: true }, - text2speech: { enabled: true }, - suggested: { enabled: true }, - citation: { enabled: true }, - annotationReply: { enabled: true }, - })) + expect(mockSetFeatures).toHaveBeenCalledWith( + expect.objectContaining({ + moreLikeThis: { enabled: true }, + opening: { + enabled: true, + opening_statement: 'Hello there', + suggested_questions: ['Q1'], + }, + moderation: { enabled: true }, + speech2text: { enabled: true }, + text2speech: { enabled: true }, + suggested: { enabled: true }, + citation: { enabled: true }, + annotationReply: { enabled: true }, + }), + ) }) }) it('should close restore confirmation without restoring when cancelled', async () => { - render( - <FeaturesWrappedAppPublisher - publishedConfig={publishedConfig as any} - />, - ) + render(<FeaturesWrappedAppPublisher publishedConfig={publishedConfig as any} />) fireEvent.click(screen.getByText('restore-through-wrapper')) const dialog = screen.getByRole('alertdialog') - fireEvent.click(within(dialog).getByRole('button', { name: /(?:^|\.)operation\.cancel(?=$|:)/ })) + fireEvent.click( + within(dialog).getByRole('button', { name: /(?:^|\.)operation\.cancel(?=$|:)/ }), + ) await waitFor(() => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index b36d83a079dd1c..dfeb00d899cf44 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -7,9 +7,10 @@ import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' import { AppPublisher } from '../index' -const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, { - systemFeatures: { webapp_auth: { enabled: true } }, -}) +const render = (ui: React.ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { webapp_auth: { enabled: true } }, + }) const mockOnPublish = vi.fn() const mockOnToggle = vi.fn() @@ -45,10 +46,16 @@ vi.mock('@tanstack/react-hotkeys', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { appDetail: Record<string, any> | null, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({ - appDetail: mockAppDetail, - setAppDetail: mockSetAppDetail, - }), + useStore: ( + selector: (state: { + appDetail: Record<string, any> | null + setAppDetail: typeof mockSetAppDetail + }) => unknown, + ) => + selector({ + appDetail: mockAppDetail, + setAppDetail: mockSetAppDetail, + }), })) vi.mock('@/hooks/use-format-time-from-now', () => ({ @@ -137,7 +144,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -152,18 +160,23 @@ vi.mock('@/app/components/base/amplitude', () => ({ })) vi.mock('@/app/components/app/overview/embedded', () => ({ - default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (isShow - ? ( - <div data-testid="embedded-modal"> - embedded modal - <button onClick={onClose}>close-embedded-modal</button> - </div> - ) - : null), + default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) => + isShow ? ( + <div data-testid="embedded-modal"> + embedded modal + <button onClick={onClose}>close-embedded-modal</button> + </div> + ) : null, })) vi.mock('../../app-access-control', () => { - const MockAccessControl = ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => ( + const MockAccessControl = ({ + onConfirm, + onClose, + }: { + onConfirm: () => Promise<void> + onClose: () => void + }) => ( <div data-testid="access-control"> <button onClick={() => void onConfirm()}>confirm-access-control</button> <button onClick={onClose}>close-access-control</button> @@ -209,8 +222,12 @@ vi.mock('../sections', () => ({ <button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button> {props.handleOpenRunConfig && ( <> - <button onClick={() => props.handleOpenRunConfig(props.appURL)}>publisher-run-config</button> - <button onClick={() => props.handleOpenRunConfig(`${props.appURL}?mode=batch`)}>publisher-batch-run-config</button> + <button onClick={() => props.handleOpenRunConfig(props.appURL)}> + publisher-run-config + </button> + <button onClick={() => props.handleOpenRunConfig(`${props.appURL}?mode=batch`)}> + publisher-batch-run-config + </button> </> )} <button onClick={props.onConfigureWorkflowTool}>publisher-workflow-tool</button> @@ -255,12 +272,7 @@ describe('AppPublisher', () => { }) it('should enable access permission query when the publish popover opens', async () => { - render( - <AppPublisher - publishedAt={Date.now()} - onToggle={mockOnToggle} - />, - ) + render(<AppPublisher publishedAt={Date.now()} onToggle={mockOnToggle} />) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) @@ -280,32 +292,26 @@ describe('AppPublisher', () => { it('should publish and track the publish event', async () => { mockOnPublish.mockResolvedValue(undefined) - render( - <AppPublisher - publishedAt={Date.now()} - onPublish={mockOnPublish} - />, - ) + render(<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} />) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText('publisher-summary-publish')) await waitFor(() => { expect(mockOnPublish).toHaveBeenCalledTimes(1) - expect(mockTrackEvent).toHaveBeenCalledWith('app_published_time', expect.objectContaining({ - action_mode: 'app', - app_id: 'app-1', - app_name: 'Demo App', - })) + expect(mockTrackEvent).toHaveBeenCalledWith( + 'app_published_time', + expect.objectContaining({ + action_mode: 'app', + app_id: 'app-1', + app_name: 'Demo App', + }), + ) }) }) it('should open the embedded modal from the actions section', () => { - render( - <AppPublisher - publishedAt={Date.now()} - />, - ) + render(<AppPublisher publishedAt={Date.now()} />) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText('publisher-embed')) @@ -317,26 +323,32 @@ describe('AppPublisher', () => { render( <AppPublisher publishedAt={Date.now()} - inputs={[{ - variable: 'secret', - label: 'Secret', - type: 'text-input', - required: true, - hide: true, - default: '', - } as any]} + inputs={[ + { + variable: 'secret', + label: 'Secret', + type: 'text-input', + required: true, + hide: true, + default: '', + } as any, + ]} />, ) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText('publisher-run-config')) - expect(screen.getByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/)).toBeInTheDocument() + expect( + screen.getByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/), + ).toBeInTheDocument() fireEvent.change(screen.getByLabelText('Secret'), { target: { value: 'top-secret' }, }) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ })) + fireEvent.click( + screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ }), + ) await waitFor(() => { expect(mockWindowOpen).toHaveBeenCalledWith( @@ -355,14 +367,16 @@ describe('AppPublisher', () => { render( <AppPublisher publishedAt={Date.now()} - inputs={[{ - variable: 'batch_secret', - label: 'Batch Secret', - type: 'text-input', - required: true, - hide: true, - default: '', - } as any]} + inputs={[ + { + variable: 'batch_secret', + label: 'Batch Secret', + type: 'text-input', + required: true, + hide: true, + default: '', + } as any, + ]} />, ) @@ -372,7 +386,9 @@ describe('AppPublisher', () => { fireEvent.change(screen.getByLabelText('Batch Secret'), { target: { value: 'batch-value' }, }) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ })) + fireEvent.click( + screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ }), + ) await waitFor(() => { expect(mockWindowOpen).toHaveBeenCalledWith( @@ -388,11 +404,7 @@ describe('AppPublisher', () => { mode: AppModeEnum.WORKFLOW, } - render( - <AppPublisher - publishedAt={Date.now()} - />, - ) + render(<AppPublisher publishedAt={Date.now()} />) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText('publisher-workflow-tool')) @@ -408,11 +420,7 @@ describe('AppPublisher', () => { mode: AppModeEnum.WORKFLOW, } - render( - <AppPublisher - publishedAt={Date.now()} - />, - ) + render(<AppPublisher publishedAt={Date.now()} />) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText('publisher-workflow-tool')) @@ -422,11 +430,7 @@ describe('AppPublisher', () => { }) it('should close embedded and access control panels through child callbacks', async () => { - render( - <AppPublisher - publishedAt={Date.now()} - />, - ) + render(<AppPublisher publishedAt={Date.now()} />) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText('publisher-embed')) @@ -441,11 +445,7 @@ describe('AppPublisher', () => { }) it('should refresh app detail after access control confirmation', async () => { - const { queryClient } = render( - <AppPublisher - publishedAt={Date.now()} - />, - ) + const { queryClient } = render(<AppPublisher publishedAt={Date.now()} />) const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData') fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) @@ -458,12 +458,17 @@ describe('AppPublisher', () => { await waitFor(() => { expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) }) - expect(setQueryDataSpy).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({ - access_mode: AccessMode.PUBLIC, - })) - expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({ - access_mode: AccessMode.PUBLIC, - })) + expect(setQueryDataSpy).toHaveBeenCalledWith( + ['apps', 'detail', 'app-1'], + expect.objectContaining({ + access_mode: AccessMode.PUBLIC, + }), + ) + expect(mockSetAppDetail).toHaveBeenCalledWith( + expect.objectContaining({ + access_mode: AccessMode.PUBLIC, + }), + ) }) it('should open the installed explore page through the async window helper', async () => { @@ -472,11 +477,7 @@ describe('AppPublisher', () => { openedUrl = await resolver() }) - render( - <AppPublisher - publishedAt={Date.now()} - />, - ) + render(<AppPublisher publishedAt={Date.now()} />) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText('publisher-open-in-explore')) @@ -490,15 +491,12 @@ describe('AppPublisher', () => { }) it('should ignore the trigger when the publish button is disabled', () => { - render( - <AppPublisher - disabled - publishedAt={Date.now()} - onToggle={mockOnToggle} - />, - ) + render(<AppPublisher disabled publishedAt={Date.now()} onToggle={mockOnToggle} />) - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/).parentElement?.parentElement as HTMLElement) + fireEvent.click( + screen.getByText(/(?:^|\.)common\.publish(?=$|:)/).parentElement + ?.parentElement as HTMLElement, + ) expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument() expect(mockOnToggle).not.toHaveBeenCalled() @@ -510,11 +508,7 @@ describe('AppPublisher', () => { mockOnPublish.mockResolvedValue(undefined) render( - <AppPublisher - publishedAt={Date.now()} - onPublish={mockOnPublish} - onRestore={onRestore} - />, + <AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} onRestore={onRestore} />, ) expect(hotkeyMocks.hotkeys).toContain('Mod+Shift+P') @@ -540,11 +534,7 @@ describe('AppPublisher', () => { mockOnPublish.mockRejectedValueOnce(new Error('publish failed')) render( - <AppPublisher - publishedAt={Date.now()} - onPublish={mockOnPublish} - onRestore={onRestore} - />, + <AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} onRestore={onRestore} />, ) hotkeyMocks.handlers[0]!({ preventDefault }) @@ -568,26 +558,25 @@ describe('AppPublisher', () => { mockFetchInstalledAppList.mockResolvedValueOnce({ installed_apps: [], }) - mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => { - try { - await resolver() - } - catch (error) { - options.onError(error as Error) - } - }) - - render( - <AppPublisher - publishedAt={Date.now()} - />, + mockOpenAsyncWindow.mockImplementation( + async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => { + try { + await resolver() + } catch (error) { + options.onError(error as Error) + } + }, ) + render(<AppPublisher publishedAt={Date.now()} />) + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText('publisher-open-in-explore')) await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)notPublishedYet(?=$|:)/)) + expect(mockToastError).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)notPublishedYet(?=$|:)/), + ) }) }) @@ -596,21 +585,18 @@ describe('AppPublisher', () => { ...mockAppDetail, id: undefined, } - mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => { - try { - await resolver() - } - catch (error) { - options.onError(error as Error) - } - }) - - render( - <AppPublisher - publishedAt={Date.now()} - />, + mockOpenAsyncWindow.mockImplementation( + async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => { + try { + await resolver() + } catch (error) { + options.onError(error as Error) + } + }, ) + render(<AppPublisher publishedAt={Date.now()} />) + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText('publisher-open-in-explore')) @@ -620,23 +606,24 @@ describe('AppPublisher', () => { }) it('should show marketplace button and open redirect URL on success', async () => { - mockPublishToCreatorsPlatform.mockResolvedValue({ redirect_url: 'https://marketplace.example.com/publish?code=abc' }) + mockPublishToCreatorsPlatform.mockResolvedValue({ + redirect_url: 'https://marketplace.example.com/publish?code=abc', + }) const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null) - renderWithSystemFeatures( - <AppPublisher - publishedAt={Date.now()} - onPublish={mockOnPublish} - />, - { systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true } }, - ) + renderWithSystemFeatures(<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} />, { + systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true }, + }) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/)) await waitFor(() => { expect(mockPublishToCreatorsPlatform).toHaveBeenCalledWith({ appID: 'app-1' }) - expect(windowOpenSpy).toHaveBeenCalledWith('https://marketplace.example.com/publish?code=abc', '_blank') + expect(windowOpenSpy).toHaveBeenCalledWith( + 'https://marketplace.example.com/publish?code=abc', + '_blank', + ) }) windowOpenSpy.mockRestore() @@ -645,32 +632,29 @@ describe('AppPublisher', () => { it('should show toast error when publish to marketplace fails', async () => { mockPublishToCreatorsPlatform.mockRejectedValue(new Error('network error')) - renderWithSystemFeatures( - <AppPublisher - publishedAt={Date.now()} - onPublish={mockOnPublish} - />, - { systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true } }, - ) + renderWithSystemFeatures(<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} />, { + systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true }, + }) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/)) await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)common\.publishToMarketplaceFailed(?=$|:)/)) + expect(mockToastError).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)common\.publishToMarketplaceFailed(?=$|:)/), + ) }) }) it('should disable marketplace button when not yet published', () => { - renderWithSystemFeatures( - <AppPublisher - onPublish={mockOnPublish} - />, - { systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true } }, - ) + renderWithSystemFeatures(<AppPublisher onPublish={mockOnPublish} />, { + systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true }, + }) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - const marketplaceButton = screen.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/).closest('a, button, div[role="button"]') as HTMLElement + const marketplaceButton = screen + .getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/) + .closest('a, button, div[role="button"]') as HTMLElement expect(marketplaceButton).toBeInTheDocument() // clicking should not call the API because publishedAt is undefined fireEvent.click(screen.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/)) @@ -678,25 +662,18 @@ describe('AppPublisher', () => { }) it('should hide marketplace button when enable_creators_platform is false', () => { - render( - <AppPublisher - publishedAt={Date.now()} - onPublish={mockOnPublish} - />, - ) + render(<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} />) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - expect(screen.queryByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/)).not.toBeInTheDocument() + expect( + screen.queryByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/), + ).not.toBeInTheDocument() }) it('should keep access control open when app detail is unavailable during confirmation', async () => { mockAppDetail = null - render( - <AppPublisher - publishedAt={Date.now()} - />, - ) + render(<AppPublisher publishedAt={Date.now()} />) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText('publisher-access-control')) diff --git a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx index f39c10a9873774..6991ea8aab7a6f 100644 --- a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx @@ -12,22 +12,34 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({ - default: ({ modelName }: { modelName: string }) => <span data-testid="model-icon">{modelName}</span>, + default: ({ modelName }: { modelName: string }) => ( + <span data-testid="model-icon">{modelName}</span> + ), })) vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const ReactModule = await vi.importActual<typeof import('react')>('react') - const OpenContext = ReactModule.createContext<{ open: boolean, setOpen: (nextOpen: boolean) => void } | null>(null) + const OpenContext = ReactModule.createContext<{ + open: boolean + setOpen: (nextOpen: boolean) => void + } | null>(null) const useOpenContext = () => { const context = ReactModule.use(OpenContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + DropdownMenu: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( <OpenContext.Provider value={{ open, setOpen: onOpenChange ?? vi.fn() }}> <div data-testid="portal-root">{children}</div> </OpenContext.Provider> @@ -42,18 +54,38 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const { open, setOpen } = useOpenContext() if (render) { - return ReactModule.cloneElement(render, { - onClick: () => setOpen(!open), - } as Record<string, unknown>, children) + return ReactModule.cloneElement( + render, + { + onClick: () => setOpen(!open), + } as Record<string, unknown>, + children, + ) } - return <button type="button" onClick={() => setOpen(!open)}>{children}</button> + return ( + <button type="button" onClick={() => setOpen(!open)}> + {children} + </button> + ) }, - DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => { + DropdownMenuContent: ({ + children, + popupClassName, + }: { + children: React.ReactNode + popupClassName?: string + }) => { const context = useOpenContext() return context.open ? <div className={popupClassName}>{children}</div> : null }, - DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => { + DropdownMenuItem: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler<HTMLButtonElement> + }) => { const { setOpen } = useOpenContext() return ( <button @@ -105,7 +137,9 @@ describe('PublishWithMultipleModel', () => { />, ) - expect(screen.getByRole('button', { name: /(?:^|\.)operation\.applyConfig(?=$|:)/ })).toBeDisabled() + expect( + screen.getByRole('button', { name: /(?:^|\.)operation\.applyConfig(?=$|:)/ }), + ).toBeDisabled() expect(screen.queryByText(/(?:^|\.)publishAs(?=$|:)/)).not.toBeInTheDocument() }) @@ -119,10 +153,7 @@ describe('PublishWithMultipleModel', () => { } render( - <PublishWithMultipleModel - multipleModelConfigs={[modelConfig]} - onSelect={handleSelect} - />, + <PublishWithMultipleModel multipleModelConfigs={[modelConfig]} onSelect={handleSelect} />, ) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.applyConfig(?=$|:)/ })) diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx index e64e7b517164b7..17c32dd470e3a5 100644 --- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx @@ -3,11 +3,18 @@ import type { ReactNode } from 'react' import { fireEvent, render, screen } from '@testing-library/react' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' -import { AccessModeDisplay, PublisherAccessSection, PublisherActionsSection, PublisherSummarySection } from '../sections' +import { + AccessModeDisplay, + PublisherAccessSection, + PublisherActionsSection, + PublisherSummarySection, +} from '../sections' vi.mock('../publish-with-multiple-model', () => ({ default: ({ onSelect }: { onSelect: (item: Record<string, unknown>) => void }) => ( - <button type="button" onClick={() => onSelect({ model: 'gpt-4o' })}>publish-multiple-model</button> + <button type="button" onClick={() => onSelect({ model: 'gpt-4o' })}> + publish-multiple-model + </button> ), })) @@ -23,10 +30,12 @@ vi.mock('../suggested-action', () => ({ onClick?: () => void link?: string disabled?: boolean - actionButton?: { ariaLabel: string, onClick: () => void } + actionButton?: { ariaLabel: string; onClick: () => void } }) => ( <div> - <button type="button" data-link={link} disabled={disabled} onClick={onClick}>{children}</button> + <button type="button" data-link={link} disabled={disabled} onClick={onClick}> + {children} + </button> {actionButton && ( <button type="button" @@ -184,7 +193,9 @@ describe('app-publisher sections', () => { />, ) - expect(screen.getByText(/(?:^|\.)accessControlDialog\.accessItems\.anyone(?=$|:)/)).toBeInTheDocument() + expect( + screen.getByText(/(?:^|\.)accessControlDialog\.accessItems\.anyone(?=$|:)/), + ).toBeInTheDocument() expect(render(<AccessModeDisplay />).container).toBeEmptyDOMElement() }) @@ -200,7 +211,9 @@ describe('app-publisher sections', () => { ) expect(screen.queryByText(/(?:^|\.)publishApp\.title(?=$|:)/)).not.toBeInTheDocument() - expect(screen.queryByText(/(?:^|\.)accessControlDialog\.accessItems\.anyone(?=$|:)/)).not.toBeInTheDocument() + expect( + screen.queryByText(/(?:^|\.)accessControlDialog\.accessItems\.anyone(?=$|:)/), + ).not.toBeInTheDocument() }) it('should render workflow actions, batch run links, and workflow tool configuration', () => { @@ -242,7 +255,10 @@ describe('app-publisher sections', () => { />, ) - expect(screen.getByText(/(?:^|\.)common\.batchRunApp(?=$|:)/)).toHaveAttribute('data-link', 'https://example.com/app?mode=batch') + expect(screen.getByText(/(?:^|\.)common\.batchRunApp(?=$|:)/)).toHaveAttribute( + 'data-link', + 'https://example.com/app?mode=batch', + ) fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })[0]!) expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app') fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })[1]!) diff --git a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx index 2ca9e77abff2d1..f27dba11ba5f1c 100644 --- a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx @@ -4,11 +4,7 @@ import SuggestedAction from '../suggested-action' describe('SuggestedAction', () => { it('should render an enabled external link', () => { - render( - <SuggestedAction link="https://example.com/docs"> - Open docs - </SuggestedAction>, - ) + render(<SuggestedAction link="https://example.com/docs">Open docs</SuggestedAction>) const link = screen.getByRole('link', { name: 'Open docs' }) expect(link).toHaveAttribute('href', 'https://example.com/docs') @@ -65,7 +61,10 @@ describe('SuggestedAction', () => { fireEvent.click(screen.getByRole('button', { name: 'Configure action' })) - expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute('href', 'https://example.com/docs') + expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute( + 'href', + 'https://example.com/docs', + ) expect(handleActionClick).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/app/app-publisher/__tests__/utils.spec.ts b/web/app/components/app/app-publisher/__tests__/utils.spec.ts index 7e57d1639a248d..ca735567e58fec 100644 --- a/web/app/components/app/app-publisher/__tests__/utils.spec.ts +++ b/web/app/components/app/app-publisher/__tests__/utils.spec.ts @@ -23,27 +23,33 @@ describe('app-publisher utils', () => { describe('getPublisherAppUrl', () => { it('should build the published app url from site info', () => { - expect(getPublisherAppUrl({ - appBaseUrl: 'https://example.com', - accessToken: 'token-1', - mode: AppModeEnum.CHAT, - })).toBe(`https://example.com${basePath}/chat/token-1`) + expect( + getPublisherAppUrl({ + appBaseUrl: 'https://example.com', + accessToken: 'token-1', + mode: AppModeEnum.CHAT, + }), + ).toBe(`https://example.com${basePath}/chat/token-1`) }) }) describe('isPublisherAccessConfigured', () => { it('should require members or groups for specific access mode', () => { - expect(isPublisherAccessConfigured( - { access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - { groups: [], members: [] }, - )).toBe(false) + expect( + isPublisherAccessConfigured( + { access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + { groups: [], members: [] }, + ), + ).toBe(false) }) it('should treat public access as configured', () => { - expect(isPublisherAccessConfigured( - { access_mode: AccessMode.PUBLIC }, - { groups: [], members: [] }, - )).toBe(true) + expect( + isPublisherAccessConfigured( + { access_mode: AccessMode.PUBLIC }, + { groups: [], members: [] }, + ), + ).toBe(true) }) }) @@ -51,21 +57,25 @@ describe('app-publisher utils', () => { const t = withSelectorKey((key: string) => key, 'app') as unknown as TFunction it('should prioritize the unpublished hint', () => { - expect(getDisabledFunctionTooltip({ - t, - publishedAt: undefined, - missingStartNode: false, - noAccessPermission: false, - })).toBe('notPublishedYet') + expect( + getDisabledFunctionTooltip({ + t, + publishedAt: undefined, + missingStartNode: false, + noAccessPermission: false, + }), + ).toBe('notPublishedYet') }) it('should return the access error when the app is published but blocked', () => { - expect(getDisabledFunctionTooltip({ - t, - publishedAt: Date.now(), - missingStartNode: false, - noAccessPermission: true, - })).toBe('noAccessPermission') + expect( + getDisabledFunctionTooltip({ + t, + publishedAt: Date.now(), + missingStartNode: false, + noAccessPermission: true, + }), + ).toBe('noAccessPermission') }) }) }) diff --git a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx index 885f19b9fc68c0..afe3ce4a63f694 100644 --- a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx @@ -18,11 +18,13 @@ describe('VersionInfoModal', () => { render( <VersionInfoModal isOpen - versionInfo={{ - id: 'version-1', - marked_name: 'Release 1', - marked_comment: 'Initial release', - } as any} + versionInfo={ + { + id: 'version-1', + marked_name: 'Release 1', + marked_comment: 'Initial release', + } as any + } onClose={vi.fn()} onPublish={vi.fn()} />, @@ -35,19 +37,15 @@ describe('VersionInfoModal', () => { it('should reject overlong titles', () => { const handlePublish = vi.fn() - render( - <VersionInfoModal - isOpen - onClose={vi.fn()} - onPublish={handlePublish} - />, - ) + render(<VersionInfoModal isOpen onClose={vi.fn()} onPublish={handlePublish} />) const [titleInput] = screen.getAllByRole('textbox') fireEvent.change(titleInput!, { target: { value: 'a'.repeat(16) } }) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ })) - expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)versionHistory\.editField\.titleLengthLimit(?=$|:)/)) + expect(toast.error).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)versionHistory\.editField\.titleLengthLimit(?=$|:)/), + ) expect(handlePublish).not.toHaveBeenCalled() }) @@ -58,11 +56,13 @@ describe('VersionInfoModal', () => { render( <VersionInfoModal isOpen - versionInfo={{ - id: 'version-2', - marked_name: 'Old title', - marked_comment: 'Old notes', - } as any} + versionInfo={ + { + id: 'version-2', + marked_name: 'Old title', + marked_comment: 'Old notes', + } as any + } onClose={handleClose} onPublish={handlePublish} />, @@ -84,13 +84,7 @@ describe('VersionInfoModal', () => { it('should close when the dialog requests close', () => { const handleClose = vi.fn() - render( - <VersionInfoModal - isOpen - onClose={handleClose} - onPublish={vi.fn()} - />, - ) + render(<VersionInfoModal isOpen onClose={handleClose} onPublish={vi.fn()} />) fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) @@ -100,13 +94,7 @@ describe('VersionInfoModal', () => { it('should close when the close button is clicked', () => { const handleClose = vi.fn() - render( - <VersionInfoModal - isOpen - onClose={handleClose} - onPublish={vi.fn()} - />, - ) + render(<VersionInfoModal isOpen onClose={handleClose} onPublish={vi.fn()} />) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.close(?=$|:)/ })) @@ -120,11 +108,13 @@ describe('VersionInfoModal', () => { render( <VersionInfoModal isOpen - versionInfo={{ - id: 'version-3', - marked_name: 'Old title', - marked_comment: 'Old notes', - } as any} + versionInfo={ + { + id: 'version-3', + marked_name: 'Old title', + marked_comment: 'Old notes', + } as any + } onClose={handleClose} onPublish={handlePublish} />, @@ -134,12 +124,16 @@ describe('VersionInfoModal', () => { fireEvent.change(titleInput!, { target: { value: 'a'.repeat(16) } }) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ })) - expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)versionHistory\.editField\.titleLengthLimit(?=$|:)/)) + expect(toast.error).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)versionHistory\.editField\.titleLengthLimit(?=$|:)/), + ) fireEvent.change(titleInput!, { target: { value: 'Release 3' } }) fireEvent.change(notesInput!, { target: { value: 'b'.repeat(101) } }) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ })) - expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)versionHistory\.editField\.releaseNotesLengthLimit(?=$|:)/)) + expect(toast.error).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)versionHistory\.editField\.releaseNotesLengthLimit(?=$|:)/), + ) fireEvent.change(notesInput!, { target: { value: 'Stable release notes' } }) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ })) diff --git a/web/app/components/app/app-publisher/features-wrapper.tsx b/web/app/components/app/app-publisher/features-wrapper.tsx index 7dc248fcf66fbf..5de3bf0d7b0bf3 100644 --- a/web/app/components/app/app-publisher/features-wrapper.tsx +++ b/web/app/components/app/app-publisher/features-wrapper.tsx @@ -1,4 +1,7 @@ -import type { AppPublisherProps, AppPublisherPublishParams } from '@/app/components/app/app-publisher' +import type { + AppPublisherProps, + AppPublisherPublishParams, +} from '@/app/components/app/app-publisher' import type { Features, FileUpload } from '@/app/components/base/features/types' import type { ModelConfig } from '@/models/debug' import { @@ -25,7 +28,10 @@ type PublishedModelConfig = ModelConfig & { } type Props = Omit<AppPublisherProps, 'onPublish'> & { - onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise<unknown> | unknown + onPublish?: ( + params?: AppPublisherPublishParams, + features?: Features, + ) => Promise<unknown> | unknown publishedConfig: { modelConfig: PublishedModelConfig } @@ -34,17 +40,26 @@ type Props = Omit<AppPublisherProps, 'onPublish'> & { const FeaturesWrappedAppPublisher = (props: Props) => { const { t } = useTranslation() - const features = useFeatures(s => s.features) + const features = useFeatures((s) => s.features) const featuresStore = useFeaturesStore() const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) - const { more_like_this, opening_statement, suggested_questions, sensitive_word_avoidance, speech_to_text, text_to_speech, suggested_questions_after_answer, retriever_resource, annotation_reply, file_upload, resetAppConfig } = props.publishedConfig.modelConfig + const { + more_like_this, + opening_statement, + suggested_questions, + sensitive_word_avoidance, + speech_to_text, + text_to_speech, + suggested_questions_after_answer, + retriever_resource, + annotation_reply, + file_upload, + resetAppConfig, + } = props.publishedConfig.modelConfig const handleConfirm = useCallback(() => { resetAppConfig?.() - const { - features, - setFeatures, - } = featuresStore!.getState() + const { features, setFeatures } = featuresStore!.getState() const newFeatures = produce(features, (draft) => { draft.moreLikeThis = more_like_this || { enabled: false } draft.opening = { @@ -67,8 +82,11 @@ const FeaturesWrappedAppPublisher = (props: Props) => { }, enabled: !!(file_upload?.enabled || file_upload?.image?.enabled), allowed_file_types: file_upload?.allowed_file_types || [SupportUploadFileTypes.image], - allowed_file_extensions: file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image]!.map(ext => `.${ext}`), - allowed_file_upload_methods: file_upload?.allowed_file_upload_methods || file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + allowed_file_extensions: + file_upload?.allowed_file_extensions || + FILE_EXTS[SupportUploadFileTypes.image]!.map((ext) => `.${ext}`), + allowed_file_upload_methods: file_upload?.allowed_file_upload_methods || + file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], number_limits: file_upload?.number_limits || file_upload?.image?.number_limits || 3, } as FileUpload }) @@ -76,32 +94,41 @@ const FeaturesWrappedAppPublisher = (props: Props) => { setRestoreConfirmOpen(false) }, [featuresStore, props]) - const handlePublish = useCallback((params?: AppPublisherPublishParams) => { - return props.onPublish?.(params, features) - }, [features, props]) + const handlePublish = useCallback( + (params?: AppPublisherPublishParams) => { + return props.onPublish?.(params, features) + }, + [features, props], + ) return ( <> - <AppPublisher {...{ - ...props, - onPublish: handlePublish, - onRestore: () => setRestoreConfirmOpen(true), - }} + <AppPublisher + {...{ + ...props, + onPublish: handlePublish, + onRestore: () => setRestoreConfirmOpen(true), + }} /> - <AlertDialog open={restoreConfirmOpen} onOpenChange={open => !open && setRestoreConfirmOpen(false)}> + <AlertDialog + open={restoreConfirmOpen} + onOpenChange={(open) => !open && setRestoreConfirmOpen(false)} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['resetConfig.title'], { ns: 'appDebug' })} + {t(($) => $['resetConfig.title'], { ns: 'appDebug' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['resetConfig.message'], { ns: 'appDebug' })} + {t(($) => $['resetConfig.message'], { ns: 'appDebug' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={handleConfirm}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index ead6bb20a08659..c2668a22360393 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -1,7 +1,10 @@ import type { RegisterableHotkey } from '@tanstack/react-hotkeys' import type { FormEvent } from 'react' import type { ModelAndParameter } from '../configuration/debug/types' -import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '@/app/components/app/overview/app-card-utils' +import type { + WorkflowHiddenStartVariable, + WorkflowLaunchInputValue, +} from '@/app/components/app/overview/app-card-utils' import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' @@ -10,18 +13,13 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop import { toast } from '@langgenius/dify-ui/toast' import { useHotkey } from '@tanstack/react-hotkeys' import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' -import { - use, - useEffect, - useState, -} from 'react' +import { use, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections' import { buildWorkflowLaunchUrl, createWorkflowLaunchInitialValues, isWorkflowLaunchInputSupported, - } from '@/app/components/app/overview/app-card-utils' import EmbeddedModal from '@/app/components/app/overview/embedded' import { useStore as useAppStore } from '@/app/components/app/store' @@ -88,9 +86,9 @@ const PUBLISH_SHORTCUT = PUBLISH_HOTKEY.split('+') export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams -type AppPublisherPublishHandler - = | ((params?: AppPublisherPublishParams) => Promise<unknown> | unknown) - | ((params?: unknown) => Promise<unknown> | unknown) +type AppPublisherPublishHandler = + | ((params?: AppPublisherPublishParams) => Promise<unknown> | unknown) + | ((params?: unknown) => Promise<unknown> | unknown) type AppPublisherRestoreHandler = () => Promise<unknown> | unknown @@ -125,12 +123,14 @@ export function AppPublisher({ const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false) const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('') - const [workflowLaunchValues, setWorkflowLaunchValues] = useState<Record<string, WorkflowLaunchInputValue>>({}) + const [workflowLaunchValues, setWorkflowLaunchValues] = useState< + Record<string, WorkflowLaunchInputValue> + >({}) const [publishingToMarketplace, setPublishingToMarketplace] = useState(false) const workflowStore = use(WorkflowContext) - const appDetail = useAppStore(state => state.appDetail) - const setAppDetail = useAppStore(state => state.setAppDetail) + const appDetail = useAppStore((state) => state.appDetail) + const setAppDetail = useAppStore((state) => state.setAppDetail) const canManageTools = useCanManageTools() const queryClient = useQueryClient() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) @@ -138,28 +138,46 @@ export function AppPublisher({ const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode }) - const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT) - const hiddenLaunchVariables: WorkflowHiddenStartVariable[] = (inputs ?? []).filter(input => input.hide === true) - const supportedWorkflowLaunchVariables = hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported) - const unsupportedWorkflowLaunchVariables = hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)) - const initialWorkflowLaunchValues = createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables) + const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes( + appDetail?.mode || AppModeEnum.CHAT, + ) + const hiddenLaunchVariables: WorkflowHiddenStartVariable[] = (inputs ?? []).filter( + (input) => input.hide === true, + ) + const supportedWorkflowLaunchVariables = hiddenLaunchVariables.filter( + isWorkflowLaunchInputSupported, + ) + const unsupportedWorkflowLaunchVariables = hiddenLaunchVariables.filter( + (variable) => !isWorkflowLaunchInputSupported(variable), + ) + const initialWorkflowLaunchValues = createWorkflowLaunchInitialValues( + supportedWorkflowLaunchVariables, + ) - const shouldLoadUserCanAccessApp = Boolean(appDetail?.id && open && systemFeatures.webapp_auth.enabled) + const shouldLoadUserCanAccessApp = Boolean( + appDetail?.id && open && systemFeatures.webapp_auth.enabled, + ) const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: shouldLoadUserCanAccessApp, }) - const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) + const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = + useAppWhiteListSubjects( + appDetail?.id, + open && + systemFeatures.webapp_auth.enabled && + appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS, + ) const invalidateAppWorkflow = useInvalidateAppWorkflow() const openAsyncWindow = useAsyncWindowOpen() const isAppAccessSet = isPublisherAccessConfigured(appDetail, appAccessSubjects) const noAccessPermission = Boolean( - systemFeatures.webapp_auth.enabled - && appDetail - && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS - && !userCanAccessApp?.result, + systemFeatures.webapp_auth.enabled && + appDetail && + appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && + !userCanAccessApp?.result, ) const disabledFunctionButton = !publishedAt || missingStartNode || noAccessPermission const disabledFunctionTooltip = getDisabledFunctionTooltip({ @@ -176,10 +194,8 @@ export function AppPublisher({ const appId = appDetail?.id const socket = appId ? webSocketClient.getSocket(appId) : null - if (appId) - invalidateAppWorkflow(appId) - else - console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit') + if (appId) invalidateAppWorkflow(appId) + else console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit') if (socket) { const timestamp = Date.now() socket.emit('collaboration_event', { @@ -190,14 +206,16 @@ export function AppPublisher({ }, timestamp, }) - } - else if (appId) { + } else if (appId) { console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId }) } - trackEvent('app_published_time', { action_mode: 'app', app_id: appDetail?.id, app_name: appDetail?.name }) - } - catch (error) { + trackEvent('app_published_time', { + action_mode: 'app', + app_id: appDetail?.id, + app_name: appDetail?.name, + }) + } catch (error) { console.warn('[app-publisher] publish failed', error) setPublished(false) } @@ -207,8 +225,7 @@ export function AppPublisher({ try { await onRestore?.() setOpen(false) - } - catch { } + } catch {} } function handleOpenChange(nextOpen: boolean) { @@ -220,34 +237,33 @@ export function AppPublisher({ onToggle?.(nextOpen) setOpen(nextOpen) - if (nextOpen) - setPublished(false) + if (nextOpen) setPublished(false) } async function handleOpenInExplore() { - await openAsyncWindow(async () => { - if (!appDetail?.id) - throw new Error('App not found') - const { installed_apps } = await fetchInstalledAppList(appDetail.id) - if (installed_apps?.length > 0) - return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}` - throw new Error(t($ => $.notPublishedYet, { ns: 'app' })) - }, { - onError: (err) => { - toast.error(`${err.message || err}`) + await openAsyncWindow( + async () => { + if (!appDetail?.id) throw new Error('App not found') + const { installed_apps } = await fetchInstalledAppList(appDetail.id) + if (installed_apps?.length > 0) + return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}` + throw new Error(t(($) => $.notPublishedYet, { ns: 'app' })) }, - }) + { + onError: (err) => { + toast.error(`${err.message || err}`) + }, + }, + ) } async function handleAccessControlUpdate() { - if (!appDetail) - return + if (!appDetail) return try { const res = await fetchAppDetail({ url: '/apps', id: appDetail.id }) queryClient.setQueryData([...appDetailQueryKeyPrefix, appDetail.id], res) setAppDetail({ ...res }) - } - finally { + } finally { setShowAppAccessControl(false) } } @@ -259,7 +275,7 @@ export function AppPublisher({ } function handleWorkflowLaunchValueChange(variable: string, value: WorkflowLaunchInputValue) { - setWorkflowLaunchValues(prev => ({ + setWorkflowLaunchValues((prev) => ({ ...prev, [variable]: value, })) @@ -279,33 +295,27 @@ export function AppPublisher({ } async function handlePublishToMarketplace() { - if (!appDetail?.id || publishingToMarketplace) - return + if (!appDetail?.id || publishingToMarketplace) return setPublishingToMarketplace(true) try { const res = await publishToCreatorsPlatform({ appID: appDetail.id }) - if (res.redirect_url) - window.open(res.redirect_url, '_blank') - } - catch { - toast.error(t($ => $['common.publishToMarketplaceFailed'], { ns: 'workflow' })) - } - finally { + if (res.redirect_url) window.open(res.redirect_url, '_blank') + } catch { + toast.error(t(($) => $['common.publishToMarketplaceFailed'], { ns: 'workflow' })) + } finally { setPublishingToMarketplace(false) } } useHotkey(PUBLISH_HOTKEY, (e) => { e.preventDefault() - if (publishDisabled || published) - return + if (publishDisabled || published) return handlePublish() }) useEffect(() => { const appId = appDetail?.id - if (!appId) - return + if (!appId) return const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => { const action = typeof update.data.action === 'string' ? update.data.action : undefined @@ -326,18 +336,22 @@ export function AppPublisher({ }, [appDetail?.id, invalidateAppWorkflow, workflowStore]) const hasPublishedVersion = !!publishedAt - const workflowToolVisible = appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode + const workflowToolVisible = + appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode const workflowToolAvailableForUser = workflowToolAvailable && canManageTools - const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable - ? t($ => $['common.workflowAsToolDisabledHint'], { ns: 'workflow' }) - : undefined + const workflowToolMessage = + !hasPublishedVersion || !workflowToolAvailable + ? t(($) => $['common.workflowAsToolDisabledHint'], { ns: 'workflow' }) + : undefined const workflowToolPublished = !!toolPublished function closeWorkflowToolDrawer() { setWorkflowToolDrawerOpen(false) } const workflowToolIcon = { content: (appDetail?.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖', - background: (appDetail?.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground, + background: + (appDetail?.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || + appDefaultIconBackground, } const workflowTool = useConfigureButton({ enabled: workflowToolVisible && canManageTools, @@ -354,14 +368,14 @@ export function AppPublisher({ onConfigured: closeWorkflowToolDrawer, }) function openWorkflowToolDrawer() { - if (!canManageTools) - return + if (!canManageTools) return handleOpenChange(false) setWorkflowToolDrawerOpen(true) } const upgradeHighlightStyle = { - background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)', + background: + 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)', WebkitBackgroundClip: 'text', backgroundClip: 'text', WebkitTextFillColor: 'transparent', @@ -369,21 +383,14 @@ export function AppPublisher({ return ( <> - <Popover - open={open} - onOpenChange={handleOpenChange} - > + <Popover open={open} onOpenChange={handleOpenChange}> <PopoverTrigger - render={( - <Button - variant="primary" - className="py-2 pr-2 pl-3" - disabled={disabled} - > - {t($ => $['common.publish'], { ns: 'workflow' })} + render={ + <Button variant="primary" className="py-2 pr-2 pl-3" disabled={disabled}> + {t(($) => $['common.publish'], { ns: 'workflow' })} <span className="i-ri-arrow-down-s-line size-4 text-components-button-primary-text" /> </Button> - )} + } /> <PopoverContent placement="bottom-end" @@ -410,7 +417,10 @@ export function AppPublisher({ <PublisherAccessSection enabled={systemFeatures.webapp_auth.enabled} isAppAccessSet={isAppAccessSet} - isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))} + isLoading={Boolean( + systemFeatures.webapp_auth.enabled && + (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects), + )} accessMode={appDetail?.access_mode} onClick={() => { handleOpenChange(false) @@ -437,7 +447,11 @@ export function AppPublisher({ missingStartNode={missingStartNode} published={published} publishedAt={publishedAt} - showBatchRunConfig={hiddenLaunchVariables.length > 0 && (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION)} + showBatchRunConfig={ + hiddenLaunchVariables.length > 0 && + (appDetail?.mode === AppModeEnum.WORKFLOW || + appDetail?.mode === AppModeEnum.COMPLETION) + } showRunConfig={hiddenLaunchVariables.length > 0} toolPublished={toolPublished} workflowToolAvailable={workflowToolAvailableForUser} @@ -454,8 +468,8 @@ export function AppPublisher({ onClick={handlePublishToMarketplace} > {publishingToMarketplace - ? t($ => $['common.publishingToMarketplace'], { ns: 'workflow' }) - : t($ => $['common.publishToMarketplace'], { ns: 'workflow' })} + ? t(($) => $['common.publishingToMarketplace'], { ns: 'workflow' }) + : t(($) => $['common.publishToMarketplace'], { ns: 'workflow' })} </SuggestedAction> </div> )} @@ -469,7 +483,15 @@ export function AppPublisher({ accessToken={accessToken} hiddenInputs={hiddenLaunchVariables} /> - {showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />} + {showAppAccessControl && ( + <AccessControl + app={appDetail!} + onConfirm={handleAccessControlUpdate} + onClose={() => { + setShowAppAccessControl(false) + }} + /> + )} <WorkflowLaunchDialog t={t} open={workflowLaunchDialogOpen} diff --git a/web/app/components/app/app-publisher/publish-with-multiple-model.tsx b/web/app/components/app/app-publisher/publish-with-multiple-model.tsx index 35c9b32c5abe9c..fabae55ddb6bbf 100644 --- a/web/app/components/app/app-publisher/publish-with-multiple-model.tsx +++ b/web/app/components/app/app-publisher/publish-with-multiple-model.tsx @@ -1,6 +1,9 @@ import type { FC } from 'react' import type { ModelAndParameter } from '../configuration/debug/types' -import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + Model, + ModelItem, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { Button } from '@langgenius/dify-ui/button' import { DropdownMenu, @@ -30,13 +33,14 @@ const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({ const { textGenerationModelList } = useProviderContext() const [open, setOpen] = useState(false) - const validModelConfigs: (ModelAndParameter & { modelItem: ModelItem, providerItem: Model })[] = [] + const validModelConfigs: (ModelAndParameter & { modelItem: ModelItem; providerItem: Model })[] = + [] multipleModelConfigs.forEach((item) => { - const provider = textGenerationModelList.find(model => model.provider === item.provider) + const provider = textGenerationModelList.find((model) => model.provider === item.provider) if (provider) { - const model = provider.models.find(model => model.model === item.model) + const model = provider.models.find((model) => model.model === item.model) if (model) { validModelConfigs.push({ @@ -52,54 +56,34 @@ const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({ }) return ( - <DropdownMenu - open={open} - onOpenChange={setOpen} - > + <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger disabled={!validModelConfigs.length} - render={( - <Button - variant="primary" - disabled={!validModelConfigs.length} - className="mt-3 w-full" - /> - )} + render={ + <Button variant="primary" disabled={!validModelConfigs.length} className="mt-3 w-full" /> + } > <> - {t($ => $['operation.applyConfig'], { ns: 'appDebug' })} + {t(($) => $['operation.applyConfig'], { ns: 'appDebug' })} <RiArrowDownSLine className="ml-0.5 size-3" /> </> </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="w-[288px] p-1" - > + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[288px] p-1"> <div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary"> - {t($ => $.publishAs, { ns: 'appDebug' })} + {t(($) => $.publishAs, { ns: 'appDebug' })} </div> - { - validModelConfigs.map((item, index) => ( - <DropdownMenuItem - key={item.id} - className="gap-0 px-3" - onClick={() => onSelect(item)} + {validModelConfigs.map((item, index) => ( + <DropdownMenuItem key={item.id} className="gap-0 px-3" onClick={() => onSelect(item)}> + <span className="min-w-[18px] italic">#{index + 1}</span> + <ModelIcon modelName={item.model} provider={item.providerItem} className="ml-2" /> + <div + className="ml-1 truncate text-text-secondary" + title={item.modelItem.label[language]} > - <span className="min-w-[18px] italic"> - # - {index + 1} - </span> - <ModelIcon modelName={item.model} provider={item.providerItem} className="ml-2" /> - <div - className="ml-1 truncate text-text-secondary" - title={item.modelItem.label[language]} - > - {item.modelItem.label[language]} - </div> - </DropdownMenuItem> - )) - } + {item.modelItem.label[language]} + </div> + </DropdownMenuItem> + ))} </DropdownMenuContent> </DropdownMenu> ) diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx index cd774cb810b012..d3e0417d8a210b 100644 --- a/web/app/components/app/app-publisher/sections.tsx +++ b/web/app/components/app/app-publisher/sections.tsx @@ -4,11 +4,7 @@ import type { AppPublisherProps } from './index' import type { PublishWorkflowParams } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { formatForDisplay } from '@tanstack/react-hotkeys' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' @@ -20,20 +16,23 @@ import PublishWithMultipleModel from './publish-with-multiple-model' import SuggestedAction from './suggested-action' import { ACCESS_MODE_MAP } from './utils' -type SummarySectionProps = Pick<AppPublisherProps, | 'debugWithMultipleModel' +type SummarySectionProps = Pick< + AppPublisherProps, + | 'debugWithMultipleModel' | 'draftUpdatedAt' | 'multipleModelConfigs' | 'publishDisabled' | 'publishedAt' - | 'startNodeLimitExceeded'> & { - formatTimeFromNow: (value: number) => string - handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void> - handleRestore: () => Promise<void> - isChatApp: boolean - published: boolean - publishShortcut: string[] - upgradeHighlightStyle: CSSProperties - } + | 'startNodeLimitExceeded' +> & { + formatTimeFromNow: (value: number) => string + handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void> + handleRestore: () => Promise<void> + isChatApp: boolean + published: boolean + publishShortcut: string[] + upgradeHighlightStyle: CSSProperties +} type AccessSectionProps = { enabled: boolean @@ -43,42 +42,47 @@ type AccessSectionProps = { onClick: () => void } -type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode' +type ActionsSectionProps = Pick< + AppPublisherProps, + | 'hasHumanInputNode' | 'hasTriggerNode' | 'missingStartNode' | 'toolPublished' | 'publishedAt' - | 'workflowToolAvailable'> & { - appDetail: { - id?: string - icon?: string - icon_type?: string | null - icon_background?: string | null - description?: string - mode?: AppModeEnum - name?: string - } | null | undefined - appURL: string - disabledFunctionButton: boolean - disabledFunctionTooltip?: string - handleEmbed: () => void - handleOpenInExplore: () => void - handleOpenRunConfig?: (url: string) => void - handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void> - published: boolean - showBatchRunConfig?: boolean - showRunConfig?: boolean - workflowToolIsLoading: boolean - workflowToolOutdated: boolean - workflowToolMessage?: string - onConfigureWorkflowTool: () => void - } + | 'workflowToolAvailable' +> & { + appDetail: + | { + id?: string + icon?: string + icon_type?: string | null + icon_background?: string | null + description?: string + mode?: AppModeEnum + name?: string + } + | null + | undefined + appURL: string + disabledFunctionButton: boolean + disabledFunctionTooltip?: string + handleEmbed: () => void + handleOpenInExplore: () => void + handleOpenRunConfig?: (url: string) => void + handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void> + published: boolean + showBatchRunConfig?: boolean + showRunConfig?: boolean + workflowToolIsLoading: boolean + workflowToolOutdated: boolean + workflowToolMessage?: string + onConfigureWorkflowTool: () => void +} export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => { const { t } = useTranslation() - if (!mode || !ACCESS_MODE_MAP[mode]) - return null + if (!mode || !ACCESS_MODE_MAP[mode]) return null const { icon, label } = ACCESS_MODE_MAP[mode] @@ -86,7 +90,9 @@ export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MA <> <span className={`${icon} size-4 shrink-0 text-text-secondary`} /> <div className="grow truncate"> - <span className="system-sm-medium text-text-secondary">{t($ => $[`accessControlDialog.accessItems.${label}`], { ns: 'app' })}</span> + <span className="system-sm-medium text-text-secondary"> + {t(($) => $[`accessControlDialog.accessItems.${label}`], { ns: 'app' })} + </span> </div> </> ) @@ -112,84 +118,78 @@ export const PublisherSummarySection = ({ return ( <div className="p-4 pt-3"> <div className="flex h-6 items-center system-xs-medium-uppercase text-text-tertiary"> - {publishedAt ? t($ => $['common.latestPublished'], { ns: 'workflow' }) : t($ => $['common.currentDraftUnpublished'], { ns: 'workflow' })} + {publishedAt + ? t(($) => $['common.latestPublished'], { ns: 'workflow' }) + : t(($) => $['common.currentDraftUnpublished'], { ns: 'workflow' })} </div> - {publishedAt - ? ( - <div className="flex items-center justify-between"> - <div className="flex items-center system-sm-medium text-text-secondary"> - {t($ => $['common.publishedAt'], { ns: 'workflow' })} - {' '} - {formatTimeFromNow(publishedAt)} + {publishedAt ? ( + <div className="flex items-center justify-between"> + <div className="flex items-center system-sm-medium text-text-secondary"> + {t(($) => $['common.publishedAt'], { ns: 'workflow' })} {formatTimeFromNow(publishedAt)} + </div> + {isChatApp && ( + <Button + variant="secondary-accent" + size="small" + onClick={handleRestore} + disabled={published} + > + {t(($) => $['common.restore'], { ns: 'workflow' })} + </Button> + )} + </div> + ) : ( + <div className="flex items-center system-sm-medium text-text-secondary"> + {t(($) => $['common.autoSaved'], { ns: 'workflow' })} · + {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} + </div> + )} + {debugWithMultipleModel ? ( + <PublishWithMultipleModel + multipleModelConfigs={multipleModelConfigs} + onSelect={(item) => handlePublish(item)} + /> + ) : ( + <> + <Button + variant="primary" + className="mt-3 w-full" + onClick={() => handlePublish()} + disabled={publishDisabled || published} + > + {published ? ( + t(($) => $['common.published'], { ns: 'workflow' }) + ) : ( + <div className="flex gap-1"> + <span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span> + <KbdGroup> + {publishShortcut.map((key) => ( + <Kbd key={key} color="white"> + {formatForDisplay(key)} + </Kbd> + ))} + </KbdGroup> </div> - {isChatApp && ( - <Button - variant="secondary-accent" - size="small" - onClick={handleRestore} - disabled={published} - > - {t($ => $['common.restore'], { ns: 'workflow' })} - </Button> - )} - </div> - ) - : ( - <div className="flex items-center system-sm-medium text-text-secondary"> - {t($ => $['common.autoSaved'], { ns: 'workflow' })} - {' '} - · - {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} + )} + </Button> + {startNodeLimitExceeded && ( + <div className="mt-3 flex flex-col items-stretch"> + <p className="text-sm/5 font-semibold text-transparent" style={upgradeHighlightStyle}> + <span className="block"> + {t(($) => $['publishLimit.startNodeTitlePrefix'], { ns: 'workflow' })} + </span> + <span className="block"> + {t(($) => $['publishLimit.startNodeTitleSuffix'], { ns: 'workflow' })} + </span> + </p> + <p className="mt-1 text-xs/4 text-text-secondary"> + {t(($) => $['publishLimit.startNodeDesc'], { ns: 'workflow' })} + </p> + <UpgradeBtn isShort className="mt-[9px] mb-[12px] h-[32px] w-[93px] self-start" /> </div> )} - {debugWithMultipleModel - ? ( - <PublishWithMultipleModel - multipleModelConfigs={multipleModelConfigs} - onSelect={item => handlePublish(item)} - /> - ) - : ( - <> - <Button - variant="primary" - className="mt-3 w-full" - onClick={() => handlePublish()} - disabled={publishDisabled || published} - > - {published - ? t($ => $['common.published'], { ns: 'workflow' }) - : ( - <div className="flex gap-1"> - <span>{t($ => $['common.publishUpdate'], { ns: 'workflow' })}</span> - <KbdGroup> - {publishShortcut.map(key => ( - <Kbd key={key} color="white">{formatForDisplay(key)}</Kbd> - ))} - </KbdGroup> - </div> - )} - </Button> - {startNodeLimitExceeded && ( - <div className="mt-3 flex flex-col items-stretch"> - <p - className="text-sm/5 font-semibold text-transparent" - style={upgradeHighlightStyle} - > - <span className="block">{t($ => $['publishLimit.startNodeTitlePrefix'], { ns: 'workflow' })}</span> - <span className="block">{t($ => $['publishLimit.startNodeTitleSuffix'], { ns: 'workflow' })}</span> - </p> - <p className="mt-1 text-xs/4 text-text-secondary"> - {t($ => $['publishLimit.startNodeDesc'], { ns: 'workflow' })} - </p> - <UpgradeBtn - isShort - className="mt-[9px] mb-[12px] h-[32px] w-[93px] self-start" - /> - </div> - )} - </> - )} + </> + )} </div> ) } @@ -204,7 +204,11 @@ export const PublisherAccessSection = ({ const { t } = useTranslation() if (isLoading) - return <div className="py-2"><Loading /></div> + return ( + <div className="py-2"> + <Loading /> + </div> + ) return ( <> @@ -212,7 +216,9 @@ export const PublisherAccessSection = ({ {enabled && ( <div className="p-4 pt-3"> <div className="flex h-6 items-center"> - <p className="system-xs-medium text-text-tertiary">{t($ => $['publishApp.title'], { ns: 'app' })}</p> + <p className="system-xs-medium text-text-tertiary"> + {t(($) => $['publishApp.title'], { ns: 'app' })} + </p> </div> <div className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pr-2 pl-2.5 hover:bg-primary-50 hover:text-text-accent" @@ -221,12 +227,20 @@ export const PublisherAccessSection = ({ <div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1"> <AccessModeDisplay mode={accessMode} /> </div> - {!isAppAccessSet && <p className="shrink-0 system-xs-regular text-text-tertiary">{t($ => $['publishApp.notSet'], { ns: 'app' })}</p>} + {!isAppAccessSet && ( + <p className="shrink-0 system-xs-regular text-text-tertiary"> + {t(($) => $['publishApp.notSet'], { ns: 'app' })} + </p> + )} <div className="flex size-4 shrink-0 items-center justify-center"> <span className="i-ri-arrow-right-s-line size-4 text-text-quaternary" /> </div> </div> - {!isAppAccessSet && <p className="mt-1 system-xs-regular text-text-warning">{t($ => $['publishApp.notSetDesc'], { ns: 'app' })}</p>} + {!isAppAccessSet && ( + <p className="mt-1 system-xs-regular text-text-warning"> + {t(($) => $['publishApp.notSetDesc'], { ns: 'app' })} + </p> + )} </div> )} </> @@ -242,15 +256,12 @@ const ActionTooltip = ({ tooltip?: ReactNode children: ReactNode }) => { - if (!disabled || !tooltip) - return <>{children}</> + if (!disabled || !tooltip) return <>{children}</> return ( <Tooltip> <TooltipTrigger render={<div className="flex">{children}</div>} /> - <TooltipContent> - {tooltip} - </TooltipContent> + <TooltipContent>{tooltip}</TooltipContent> </Tooltip> ) } @@ -278,8 +289,7 @@ export const PublisherActionsSection = ({ }: ActionsSectionProps) => { const { t } = useTranslation() - if (hasTriggerNode) - return null + if (hasTriggerNode) return null const workflowToolDisabled = !publishedAt || !workflowToolAvailable @@ -291,62 +301,70 @@ export const PublisherActionsSection = ({ disabled={disabledFunctionButton} link={appURL} icon={<span className="i-ri-play-circle-line size-4" />} - actionButton={showRunConfig - ? { - ariaLabel: t($ => $['operation.config'], { ns: 'common' }), - icon: <span className="i-ri-settings-2-line size-4" />, - onClick: () => handleOpenRunConfig?.(appURL), - } - : undefined} + actionButton={ + showRunConfig + ? { + ariaLabel: t(($) => $['operation.config'], { ns: 'common' }), + icon: <span className="i-ri-settings-2-line size-4" />, + onClick: () => handleOpenRunConfig?.(appURL), + } + : undefined + } > - {t($ => $['common.runApp'], { ns: 'workflow' })} + {t(($) => $['common.runApp'], { ns: 'workflow' })} </SuggestedAction> </ActionTooltip> - {appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION - ? ( - <ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}> - <SuggestedAction - className="flex-1" - disabled={disabledFunctionButton} - link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`} - icon={<span className="i-ri-play-list-2-line size-4" />} - actionButton={showBatchRunConfig - ? { - ariaLabel: t($ => $['operation.config'], { ns: 'common' }), - icon: <span className="i-ri-settings-2-line size-4" />, - onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`), - } - : undefined} - > - {t($ => $['common.batchRunApp'], { ns: 'workflow' })} - </SuggestedAction> - </ActionTooltip> - ) - : ( - <SuggestedAction - onClick={handleEmbed} - disabled={!publishedAt} - icon={<span className="i-custom-vender-line-development-code-browser size-4" />} - > - {t($ => $['common.embedIntoSite'], { ns: 'workflow' })} - </SuggestedAction> - )} + {appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION ? ( + <ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}> + <SuggestedAction + className="flex-1" + disabled={disabledFunctionButton} + link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`} + icon={<span className="i-ri-play-list-2-line size-4" />} + actionButton={ + showBatchRunConfig + ? { + ariaLabel: t(($) => $['operation.config'], { ns: 'common' }), + icon: <span className="i-ri-settings-2-line size-4" />, + onClick: () => + handleOpenRunConfig?.( + `${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`, + ), + } + : undefined + } + > + {t(($) => $['common.batchRunApp'], { ns: 'workflow' })} + </SuggestedAction> + </ActionTooltip> + ) : ( + <SuggestedAction + onClick={handleEmbed} + disabled={!publishedAt} + icon={<span className="i-custom-vender-line-development-code-browser size-4" />} + > + {t(($) => $['common.embedIntoSite'], { ns: 'workflow' })} + </SuggestedAction> + )} <ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}> <SuggestedAction className="flex-1" onClick={() => { - if (publishedAt) - handleOpenInExplore() + if (publishedAt) handleOpenInExplore() }} disabled={disabledFunctionButton} icon={<span className="i-ri-planet-line size-4" />} > - {t($ => $['common.openInExplore'], { ns: 'workflow' })} + {t(($) => $['common.openInExplore'], { ns: 'workflow' })} </SuggestedAction> </ActionTooltip> <ActionTooltip disabled={!publishedAt || missingStartNode} - tooltip={!publishedAt ? t($ => $.notPublishedYet, { ns: 'app' }) : t($ => $.noUserInputNode, { ns: 'app' })} + tooltip={ + !publishedAt + ? t(($) => $.notPublishedYet, { ns: 'app' }) + : t(($) => $.noUserInputNode, { ns: 'app' }) + } > <SuggestedAction className="flex-1" @@ -354,7 +372,7 @@ export const PublisherActionsSection = ({ link="./develop" icon={<span className="i-ri-terminal-box-line size-4" />} > - {t($ => $['common.accessAPIReference'], { ns: 'workflow' })} + {t(($) => $['common.accessAPIReference'], { ns: 'workflow' })} </SuggestedAction> </ActionTooltip> {appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && ( diff --git a/web/app/components/app/app-publisher/suggested-action.tsx b/web/app/components/app/app-publisher/suggested-action.tsx index 6dfeb9610ab4b1..9147fe59d21f0e 100644 --- a/web/app/components/app/app-publisher/suggested-action.tsx +++ b/web/app/components/app/app-publisher/suggested-action.tsx @@ -8,12 +8,14 @@ type SuggestedActionButton = { onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void } -type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement> & { - icon?: React.ReactNode - link?: string - disabled?: boolean - actionButton?: SuggestedActionButton -}> +type SuggestedActionProps = PropsWithChildren< + HTMLProps<HTMLAnchorElement> & { + icon?: React.ReactNode + link?: string + disabled?: boolean + actionButton?: SuggestedActionButton + } +> const SuggestedAction = ({ icon, @@ -50,8 +52,12 @@ const SuggestedAction = ({ rel="noreferrer" className={cn( 'flex min-w-0 items-center justify-start gap-2 px-2.5 py-2 text-text-secondary transition-colors', - actionButton ? 'flex-1 rounded-l-lg' : 'rounded-lg bg-background-section-burn not-first:mt-1', - disabled ? 'cursor-not-allowed opacity-30 shadow-xs' : 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent', + actionButton + ? 'flex-1 rounded-l-lg' + : 'rounded-lg bg-background-section-burn not-first:mt-1', + disabled + ? 'cursor-not-allowed opacity-30 shadow-xs' + : 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent', )} onClick={handleClick} {...props} @@ -62,8 +68,7 @@ const SuggestedAction = ({ </a> ) - if (!actionButton) - return mainAction + if (!actionButton) return mainAction return ( <div @@ -80,7 +85,9 @@ const SuggestedAction = ({ disabled={disabled} className={cn( 'flex w-9 shrink-0 items-center justify-center rounded-r-lg border-l-[0.5px] border-divider-subtle text-text-tertiary transition-colors', - disabled ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent', + disabled + ? 'cursor-not-allowed' + : 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent', )} onClick={handleActionClick} > diff --git a/web/app/components/app/app-publisher/utils.ts b/web/app/components/app/app-publisher/utils.ts index 152d7583942a2b..fe81f5713f635f 100644 --- a/web/app/components/app/app-publisher/utils.ts +++ b/web/app/components/app/app-publisher/utils.ts @@ -4,10 +4,13 @@ import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' -type AccessSubjectsLike = { - groups?: unknown[] - members?: unknown[] -} | null | undefined +type AccessSubjectsLike = + | { + groups?: unknown[] + members?: unknown[] + } + | null + | undefined type AppDetailLike = { access_mode?: AccessMode @@ -16,7 +19,7 @@ type AppDetailLike = { type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'> -export const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = { +export const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel; icon: string }> = { [AccessMode.ORGANIZATION]: { label: 'organization', icon: 'i-ri-building-line', @@ -36,8 +39,7 @@ export const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: } export const getPublisherAppMode = (mode?: AppModeEnum) => { - if (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) - return AppModeEnum.CHAT + if (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) return AppModeEnum.CHAT return mode } @@ -52,12 +54,13 @@ export const getPublisherAppUrl = ({ mode?: AppModeEnum }) => `${appBaseUrl}${basePath}/${getPublisherAppMode(mode)}/${accessToken}` -export const isPublisherAccessConfigured = (appDetail: AppDetailLike | null | undefined, appAccessSubjects: AccessSubjectsLike) => { - if (!appDetail || !appAccessSubjects) - return true +export const isPublisherAccessConfigured = ( + appDetail: AppDetailLike | null | undefined, + appAccessSubjects: AccessSubjectsLike, +) => { + if (!appDetail || !appAccessSubjects) return true - if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS) - return true + if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS) return true return Boolean(appAccessSubjects.groups?.length || appAccessSubjects.members?.length) } @@ -73,12 +76,9 @@ export const getDisabledFunctionTooltip = ({ missingStartNode: boolean noAccessPermission: boolean }) => { - if (!publishedAt) - return t($ => $.notPublishedYet, { ns: 'app' }) - if (missingStartNode) - return t($ => $.noUserInputNode, { ns: 'app' }) - if (noAccessPermission) - return t($ => $.noAccessPermission, { ns: 'app' }) + if (!publishedAt) return t(($) => $.notPublishedYet, { ns: 'app' }) + if (missingStartNode) return t(($) => $.noUserInputNode, { ns: 'app' }) + if (noAccessPermission) return t(($) => $.noAccessPermission, { ns: 'app' }) return undefined } diff --git a/web/app/components/app/app-publisher/version-info-modal.tsx b/web/app/components/app/app-publisher/version-info-modal.tsx index 5c64652f218ea8..b6dcd6a3ce6ac6 100644 --- a/web/app/components/app/app-publisher/version-info-modal.tsx +++ b/web/app/components/app/app-publisher/version-info-modal.tsx @@ -14,7 +14,7 @@ type VersionInfoModalProps = { isOpen: boolean versionInfo?: VersionHistory onClose: () => void - onPublish: (params: { title: string, releaseNotes: string, id?: string }) => void + onPublish: (params: { title: string; releaseNotes: string; id?: string }) => void } const TITLE_MAX_LENGTH = 15 @@ -35,22 +35,28 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({ const handlePublish = () => { if (title.length > TITLE_MAX_LENGTH) { setTitleError(true) - toast.error(t($ => $['versionHistory.editField.titleLengthLimit'], { ns: 'workflow', limit: TITLE_MAX_LENGTH })) + toast.error( + t(($) => $['versionHistory.editField.titleLengthLimit'], { + ns: 'workflow', + limit: TITLE_MAX_LENGTH, + }), + ) return - } - else { - if (titleError) - setTitleError(false) + } else { + if (titleError) setTitleError(false) } if (releaseNotes.length > RELEASE_NOTES_MAX_LENGTH) { setReleaseNotesError(true) - toast.error(t($ => $['versionHistory.editField.releaseNotesLengthLimit'], { ns: 'workflow', limit: RELEASE_NOTES_MAX_LENGTH })) + toast.error( + t(($) => $['versionHistory.editField.releaseNotesLengthLimit'], { + ns: 'workflow', + limit: RELEASE_NOTES_MAX_LENGTH, + }), + ) return - } - else { - if (releaseNotesError) - setReleaseNotesError(false) + } else { + if (releaseNotesError) setReleaseNotesError(false) } onPublish({ title, releaseNotes, id: versionInfo?.id }) @@ -65,20 +71,20 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({ <Dialog open={isOpen} onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DialogContent className="w-full max-w-[480px] overflow-hidden! border-none p-0 text-left align-middle"> - <div className="relative w-full p-6 pr-14 pb-4"> <div className="title-2xl-semi-bold text-text-primary first-letter:capitalize"> - {versionInfo?.marked_name ? t($ => $['versionHistory.editVersionInfo'], { ns: 'workflow' }) : t($ => $['versionHistory.nameThisVersion'], { ns: 'workflow' })} + {versionInfo?.marked_name + ? t(($) => $['versionHistory.editVersionInfo'], { ns: 'workflow' }) + : t(($) => $['versionHistory.nameThisVersion'], { ns: 'workflow' })} </div> <button type="button" className="absolute top-5 right-5 flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onClose} > <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" aria-hidden="true" /> @@ -87,29 +93,33 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({ <div className="flex flex-col gap-y-4 px-6 py-3"> <Field name="title" invalid={titleError} className="gap-y-1"> <FieldLabel className="flex h-6 items-center py-0 system-sm-semibold text-text-secondary"> - {t($ => $['versionHistory.editField.title'], { ns: 'workflow' })} + {t(($) => $['versionHistory.editField.title'], { ns: 'workflow' })} </FieldLabel> <FieldControl value={title} - placeholder={`${t($ => $['versionHistory.nameThisVersion'], { ns: 'workflow' })}${t($ => $['panel.optional'], { ns: 'workflow' })}`} + placeholder={`${t(($) => $['versionHistory.nameThisVersion'], { ns: 'workflow' })}${t(($) => $['panel.optional'], { ns: 'workflow' })}`} onValueChange={setTitle} /> </Field> <Field name="releaseNotes" invalid={releaseNotesError} className="gap-y-1"> <FieldLabel className="flex h-6 items-center py-0 system-sm-semibold text-text-secondary"> - {t($ => $['versionHistory.editField.releaseNotes'], { ns: 'workflow' })} + {t(($) => $['versionHistory.editField.releaseNotes'], { ns: 'workflow' })} </FieldLabel> <Textarea value={releaseNotes} - placeholder={`${t($ => $['versionHistory.releaseNotesPlaceholder'], { ns: 'workflow' })}${t($ => $['panel.optional'], { ns: 'workflow' })}`} + placeholder={`${t(($) => $['versionHistory.releaseNotesPlaceholder'], { ns: 'workflow' })}${t(($) => $['panel.optional'], { ns: 'workflow' })}`} onValueChange={handleDescriptionChange} /> </Field> </div> <div className="flex justify-end p-6 pt-5"> <div className="flex items-center gap-x-3"> - <Button nativeButton={false} onClick={onClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button nativeButton={false} variant="primary" onClick={handlePublish}>{t($ => $['common.publish'], { ns: 'workflow' })}</Button> + <Button nativeButton={false} onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button nativeButton={false} variant="primary" onClick={handlePublish}> + {t(($) => $['common.publish'], { ns: 'workflow' })} + </Button> </div> </div> </DialogContent> diff --git a/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx b/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx index 76ef3bba449394..bae633b0c27f8e 100644 --- a/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx +++ b/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx @@ -29,9 +29,12 @@ vi.mock('@/app/components/app/configuration/config/agent-setting-button', () => default: () => <div data-testid="agent-setting-button" />, })) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - default: () => <div data-testid="model-parameter-modal" />, -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + default: () => <div data-testid="model-parameter-modal" />, + }), +) vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () => ({ default: () => <div data-testid="select-dataset" />, @@ -199,7 +202,9 @@ const createContextValue = (): ComponentProps<typeof ConfigContext.Provider>['va setRerankSettingModalOpen: vi.fn(), }) -const createViewModel = (overrides: Partial<ConfigurationViewModel> = {}): ConfigurationViewModel => ({ +const createViewModel = ( + overrides: Partial<ConfigurationViewModel> = {}, +): ConfigurationViewModel => ({ appPublisherProps: { publishDisabled: false, publishedAt: 0, @@ -219,7 +224,10 @@ const createViewModel = (overrides: Partial<ConfigurationViewModel> = {}): Confi moderation: { enabled: false }, speech2text: { enabled: false }, text2speech: { enabled: false, voice: '', language: '' }, - file: { enabled: false, image: { enabled: false, detail: 'high', number_limits: 3, transfer_methods: ['local_file'] } } as never, + file: { + enabled: false, + image: { enabled: false, detail: 'high', number_limits: 3, transfer_methods: ['local_file'] }, + } as never, suggested: { enabled: false }, citation: { enabled: false }, annotationReply: { enabled: false }, @@ -281,7 +289,11 @@ describe('ConfigurationView', () => { it('should close the GPT-4 confirmation dialog when cancel is clicked', () => { const setShowUseGPT4Confirm = vi.fn() - render(<ConfigurationView {...createViewModel({ showUseGPT4Confirm: true, setShowUseGPT4Confirm })} />) + render( + <ConfigurationView + {...createViewModel({ showUseGPT4Confirm: true, setShowUseGPT4Confirm })} + />, + ) fireEvent.click(screen.getByRole('button', { name: /operation.cancel/i })) @@ -301,9 +313,15 @@ describe('ConfigurationView', () => { fireEvent.click(badge) expect(await screen.findByText('appDebug.legacyAgentBadge.description')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ })).toHaveAttribute('href', '/agents') - expect(screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ })).toHaveAttribute('target', '_blank') - expect(screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ })).toHaveAttribute('rel', 'noopener noreferrer') + expect( + screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ }), + ).toHaveAttribute('href', '/agents') + expect( + screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ }), + ).toHaveAttribute('target', '_blank') + expect( + screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ }), + ).toHaveAttribute('rel', 'noopener noreferrer') }) it('should not show the legacy Agent badge when Agent v2 is disabled', () => { diff --git a/web/app/components/app/configuration/__tests__/utils.spec.ts b/web/app/components/app/configuration/__tests__/utils.spec.ts index 6db1c0c09c706b..997d852d83e671 100644 --- a/web/app/components/app/configuration/__tests__/utils.spec.ts +++ b/web/app/components/app/configuration/__tests__/utils.spec.ts @@ -1,6 +1,10 @@ import type { ModelConfig } from '@/models/debug' import { AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app' -import { buildConfigurationFeaturesData, getConfigurationPublishingState, withCollectionIconBasePath } from '../utils' +import { + buildConfigurationFeaturesData, + getConfigurationPublishingState, + withCollectionIconBasePath, +} from '../utils' const createModelConfig = (overrides: Partial<ModelConfig> = {}): ModelConfig => ({ provider: 'openai', @@ -55,10 +59,13 @@ describe('configuration utils', () => { }) it('should prefix relative collection icons with the base path', () => { - const result = withCollectionIconBasePath([ - { id: 'tool-1', icon: '/icons/tool.svg' }, - { id: 'tool-2', icon: '/console/icons/prefixed.svg' }, - ] as never, '/console') + const result = withCollectionIconBasePath( + [ + { id: 'tool-1', icon: '/icons/tool.svg' }, + { id: 'tool-2', icon: '/console/icons/prefixed.svg' }, + ] as never, + '/console', + ) expect(result[0]!.icon).toBe('/console/icons/tool.svg') expect(result[1]!.icon).toBe('/console/icons/prefixed.svg') @@ -71,23 +78,26 @@ describe('configuration utils', () => { }) it('should derive feature toggles and upload fallbacks from model config', () => { - const result = buildConfigurationFeaturesData(createModelConfig({ - opening_statement: 'Welcome', - suggested_questions: ['How are you?'], - file_upload: { - enabled: true, - image: { + const result = buildConfigurationFeaturesData( + createModelConfig({ + opening_statement: 'Welcome', + suggested_questions: ['How are you?'], + file_upload: { enabled: true, - detail: Resolution.low, + image: { + enabled: true, + detail: Resolution.low, + number_limits: 2, + transfer_methods: [TransferMethod.local_file], + }, + allowed_file_types: ['image'], + allowed_file_extensions: ['.png'], + allowed_file_upload_methods: [TransferMethod.local_file], number_limits: 2, - transfer_methods: [TransferMethod.local_file], }, - allowed_file_types: ['image'], - allowed_file_extensions: ['.png'], - allowed_file_upload_methods: [TransferMethod.local_file], - number_limits: 2, - }, - }), undefined) + }), + undefined, + ) expect(result.opening).toEqual({ enabled: true, diff --git a/web/app/components/app/configuration/base/feature-panel/index.tsx b/web/app/components/app/configuration/base/feature-panel/index.tsx index 54e2587c1b8c2a..27aea5eb3306cb 100644 --- a/web/app/components/app/configuration/base/feature-panel/index.tsx +++ b/web/app/components/app/configuration/base/feature-panel/index.tsx @@ -23,17 +23,26 @@ const FeaturePanel: FC<IFeaturePanelProps> = ({ children, }) => { return ( - <div className={cn('rounded-xl border-t-[0.5px] border-l-[0.5px] border-effects-highlight bg-background-section-burn pb-3', noBodySpacing && 'pb-0', className)}> + <div + className={cn( + 'rounded-xl border-t-[0.5px] border-l-[0.5px] border-effects-highlight bg-background-section-burn pb-3', + noBodySpacing && 'pb-0', + className, + )} + > {/* Header */} - <div className={cn('px-3 pt-2', hasHeaderBottomBorder && 'border-b border-divider-subtle')} data-testid="feature-panel-header"> + <div + className={cn('px-3 pt-2', hasHeaderBottomBorder && 'border-b border-divider-subtle')} + data-testid="feature-panel-header" + > <div className="flex h-8 items-center justify-between"> <div className="flex shrink-0 items-center space-x-1"> - {!!headerIcon && <div className="flex size-6 items-center justify-center">{headerIcon}</div>} + {!!headerIcon && ( + <div className="flex size-6 items-center justify-center">{headerIcon}</div> + )} <div className="system-sm-semibold text-text-secondary">{title}</div> </div> - <div className="flex items-center gap-2"> - {!!headerRight && <div>{headerRight}</div>} - </div> + <div className="flex items-center gap-2">{!!headerRight && <div>{headerRight}</div>}</div> </div> </div> {/* Body */} diff --git a/web/app/components/app/configuration/base/group-name/index.tsx b/web/app/components/app/configuration/base/group-name/index.tsx index cd73a2ef60d9c1..a54a829bb2dc63 100644 --- a/web/app/components/app/configuration/base/group-name/index.tsx +++ b/web/app/components/app/configuration/base/group-name/index.tsx @@ -6,20 +6,18 @@ type IGroupNameProps = { name: string } -const GroupName: FC<IGroupNameProps> = ({ - name, -}) => { +const GroupName: FC<IGroupNameProps> = ({ name }) => { return ( <div className="mb-1 flex items-center"> - <div className="mr-3 text-xs leading-[18px] font-semibold text-text-tertiary uppercase">{name}</div> + <div className="mr-3 text-xs leading-[18px] font-semibold text-text-tertiary uppercase"> + {name} + </div> <div className="h-px grow" style={{ background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, #F3F4F6 100%)', - }} - > - </div> + ></div> </div> ) } diff --git a/web/app/components/app/configuration/base/operation-btn/index.tsx b/web/app/components/app/configuration/base/operation-btn/index.tsx index 9024e2a74d6327..f51ee34872bd08 100644 --- a/web/app/components/app/configuration/base/operation-btn/index.tsx +++ b/web/app/components/app/configuration/base/operation-btn/index.tsx @@ -1,10 +1,7 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - RiAddLine, - RiEditLine, -} from '@remixicon/react' +import { RiAddLine, RiEditLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -21,23 +18,19 @@ const iconMap = { edit: <RiEditLine className="size-3.5" />, } -const OperationBtn: FC<IOperationBtnProps> = ({ - className, - type, - actionName, - onClick = noop, -}) => { +const OperationBtn: FC<IOperationBtnProps> = ({ className, type, actionName, onClick = noop }) => { const { t } = useTranslation() return ( <div - className={cn('flex h-7 cursor-pointer items-center space-x-1 rounded-md px-3 text-text-secondary select-none hover:bg-state-base-hover', className)} + className={cn( + 'flex h-7 cursor-pointer items-center space-x-1 rounded-md px-3 text-text-secondary select-none hover:bg-state-base-hover', + className, + )} onClick={onClick} > - <div> - {iconMap[type]} - </div> + <div>{iconMap[type]}</div> <div className="text-xs font-medium"> - {actionName || t($ => $[`operation.${type}`], { ns: 'common' })} + {actionName || t(($) => $[`operation.${type}`], { ns: 'common' })} </div> </div> ) diff --git a/web/app/components/app/configuration/base/var-highlight/index.tsx b/web/app/components/app/configuration/base/var-highlight/index.tsx index 4789750500c075..ca76b90a43e1f4 100644 --- a/web/app/components/app/configuration/base/var-highlight/index.tsx +++ b/web/app/components/app/configuration/base/var-highlight/index.tsx @@ -1,7 +1,6 @@ 'use client' import type { FC } from 'react' import * as React from 'react' - import s from './style.module.css' type IVarHighlightProps = { @@ -9,10 +8,7 @@ type IVarHighlightProps = { className?: string } -const VarHighlight: FC<IVarHighlightProps> = ({ - name, - className = '', -}) => { +const VarHighlight: FC<IVarHighlightProps> = ({ name, className = '' }) => { return ( <div key={name} diff --git a/web/app/components/app/configuration/base/warning-mask/__tests__/cannot-query-dataset.spec.tsx b/web/app/components/app/configuration/base/warning-mask/__tests__/cannot-query-dataset.spec.tsx index 161bd5073d06b6..a380803edc1af1 100644 --- a/web/app/components/app/configuration/base/warning-mask/__tests__/cannot-query-dataset.spec.tsx +++ b/web/app/components/app/configuration/base/warning-mask/__tests__/cannot-query-dataset.spec.tsx @@ -7,16 +7,24 @@ describe('CannotQueryDataset WarningMask', () => { const onConfirm = vi.fn() render(<CannotQueryDataset onConfirm={onConfirm} />) - expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSet')).toBeInTheDocument() - expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSetTip')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' })).toBeInTheDocument() + expect( + screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSet'), + ).toBeInTheDocument() + expect( + screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSetTip'), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' }), + ).toBeInTheDocument() }) it('should invoke onConfirm when OK button clicked', () => { const onConfirm = vi.fn() render(<CannotQueryDataset onConfirm={onConfirm} />) - fireEvent.click(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' })) + fireEvent.click( + screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' }), + ) expect(onConfirm).toHaveBeenCalledTimes(1) }) }) diff --git a/web/app/components/app/configuration/base/warning-mask/__tests__/formatting-changed.spec.tsx b/web/app/components/app/configuration/base/warning-mask/__tests__/formatting-changed.spec.tsx index 81655f2d993399..a27521483ef5c6 100644 --- a/web/app/components/app/configuration/base/warning-mask/__tests__/formatting-changed.spec.tsx +++ b/web/app/components/app/configuration/base/warning-mask/__tests__/formatting-changed.spec.tsx @@ -7,12 +7,7 @@ describe('FormattingChanged WarningMask', () => { const onConfirm = vi.fn() const onCancel = vi.fn() - render( - <FormattingChanged - onConfirm={onConfirm} - onCancel={onCancel} - />, - ) + render(<FormattingChanged onConfirm={onConfirm} onCancel={onCancel} />) expect(screen.getByText('appDebug.formattingChangedTitle')).toBeInTheDocument() expect(screen.getByText('appDebug.formattingChangedText')).toBeInTheDocument() @@ -23,12 +18,7 @@ describe('FormattingChanged WarningMask', () => { it('should call callbacks when buttons are clicked', () => { const onConfirm = vi.fn() const onCancel = vi.fn() - render( - <FormattingChanged - onConfirm={onConfirm} - onCancel={onCancel} - />, - ) + render(<FormattingChanged onConfirm={onConfirm} onCancel={onCancel} />) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.refresh/ })) fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) diff --git a/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.tsx b/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.tsx index 8a6c9c48419f09..3662e24e0c9895 100644 --- a/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.tsx +++ b/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.tsx @@ -9,22 +9,24 @@ type IFormattingChangedProps = { onConfirm: () => void } -const FormattingChanged: FC<IFormattingChangedProps> = ({ - onConfirm, -}) => { +const FormattingChanged: FC<IFormattingChangedProps> = ({ onConfirm }) => { const { t } = useTranslation() return ( <WarningMask - title={t($ => $['feature.dataSet.queryVariable.unableToQueryDataSet'], { ns: 'appDebug' })} - description={t($ => $['feature.dataSet.queryVariable.unableToQueryDataSetTip'], { ns: 'appDebug' })} - footer={( + title={t(($) => $['feature.dataSet.queryVariable.unableToQueryDataSet'], { ns: 'appDebug' })} + description={t(($) => $['feature.dataSet.queryVariable.unableToQueryDataSetTip'], { + ns: 'appDebug', + })} + footer={ <div className="flex space-x-2"> <Button variant="primary" className="flex w-[96px]! justify-start" onClick={onConfirm}> - <span className="text-[13px] font-medium">{t($ => $['feature.dataSet.queryVariable.ok'], { ns: 'appDebug' })}</span> + <span className="text-[13px] font-medium"> + {t(($) => $['feature.dataSet.queryVariable.ok'], { ns: 'appDebug' })} + </span> </Button> </div> - )} + } /> ) } diff --git a/web/app/components/app/configuration/base/warning-mask/formatting-changed.tsx b/web/app/components/app/configuration/base/warning-mask/formatting-changed.tsx index 9da649b44d497d..9017defb805d06 100644 --- a/web/app/components/app/configuration/base/warning-mask/formatting-changed.tsx +++ b/web/app/components/app/configuration/base/warning-mask/formatting-changed.tsx @@ -12,29 +12,34 @@ type IFormattingChangedProps = { const icon = ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M1.33337 6.66667C1.33337 6.66667 2.67003 4.84548 3.75593 3.75883C4.84183 2.67218 6.34244 2 8.00004 2C11.3137 2 14 4.68629 14 8C14 11.3137 11.3137 14 8.00004 14C5.26465 14 2.95678 12.1695 2.23455 9.66667M1.33337 6.66667V2.66667M1.33337 6.66667H5.33337" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> + <path + d="M1.33337 6.66667C1.33337 6.66667 2.67003 4.84548 3.75593 3.75883C4.84183 2.67218 6.34244 2 8.00004 2C11.3137 2 14 4.68629 14 8C14 11.3137 11.3137 14 8.00004 14C5.26465 14 2.95678 12.1695 2.23455 9.66667M1.33337 6.66667V2.66667M1.33337 6.66667H5.33337" + stroke="white" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> </svg> ) -const FormattingChanged: FC<IFormattingChangedProps> = ({ - onConfirm, - onCancel, -}) => { +const FormattingChanged: FC<IFormattingChangedProps> = ({ onConfirm, onCancel }) => { const { t } = useTranslation() return ( <WarningMask - title={t($ => $.formattingChangedTitle, { ns: 'appDebug' })} - description={t($ => $.formattingChangedText, { ns: 'appDebug' })} - footer={( + title={t(($) => $.formattingChangedTitle, { ns: 'appDebug' })} + description={t(($) => $.formattingChangedText, { ns: 'appDebug' })} + footer={ <div className="flex space-x-2"> <Button variant="primary" className="flex space-x-2" onClick={onConfirm}> {icon} - <span>{t($ => $['operation.refresh'], { ns: 'common' })}</span> + <span>{t(($) => $['operation.refresh'], { ns: 'common' })}</span> + </Button> + <Button onClick={onCancel}> + {t(($) => $['operation.cancel'], { ns: 'common' }) as string} </Button> - <Button onClick={onCancel}>{t($ => $['operation.cancel'], { ns: 'common' }) as string}</Button> </div> - )} + } /> ) } diff --git a/web/app/components/app/configuration/base/warning-mask/has-not-set-api.tsx b/web/app/components/app/configuration/base/warning-mask/has-not-set-api.tsx index 4714a45287b270..5cbb09f043a102 100644 --- a/web/app/components/app/configuration/base/warning-mask/has-not-set-api.tsx +++ b/web/app/components/app/configuration/base/warning-mask/has-not-set-api.tsx @@ -7,9 +7,7 @@ type IHasNotSetAPIProps = { onSetting: () => void } -const HasNotSetAPI: FC<IHasNotSetAPIProps> = ({ - onSetting, -}) => { +const HasNotSetAPI: FC<IHasNotSetAPIProps> = ({ onSetting }) => { const { t } = useTranslation() return ( @@ -21,15 +19,21 @@ const HasNotSetAPI: FC<IHasNotSetAPIProps> = ({ </div> </div> <div className="flex flex-col gap-1"> - <div className="system-md-semibold text-text-secondary">{t($ => $.noModelProviderConfigured, { ns: 'appDebug' })}</div> - <div className="system-xs-regular text-text-tertiary">{t($ => $.noModelProviderConfiguredTip, { ns: 'appDebug' })}</div> + <div className="system-md-semibold text-text-secondary"> + {t(($) => $.noModelProviderConfigured, { ns: 'appDebug' })} + </div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $.noModelProviderConfiguredTip, { ns: 'appDebug' })} + </div> </div> <button type="button" className="flex w-fit items-center gap-1 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 shadow-xs backdrop-blur-[5px]" onClick={onSetting} > - <span className="system-sm-medium text-components-button-secondary-accent-text">{t($ => $.manageModels, { ns: 'appDebug' })}</span> + <span className="system-sm-medium text-components-button-secondary-accent-text"> + {t(($) => $.manageModels, { ns: 'appDebug' })} + </span> <span className="i-ri-arrow-right-line size-4 text-components-button-secondary-accent-text" /> </button> </div> diff --git a/web/app/components/app/configuration/base/warning-mask/index.tsx b/web/app/components/app/configuration/base/warning-mask/index.tsx index a408cb913a4529..3ecfe37784aba2 100644 --- a/web/app/components/app/configuration/base/warning-mask/index.tsx +++ b/web/app/components/app/configuration/base/warning-mask/index.tsx @@ -1,7 +1,6 @@ 'use client' import type { FC } from 'react' import * as React from 'react' - import s from './style.module.css' type IWarningMaskProps = { @@ -12,30 +11,31 @@ type IWarningMaskProps = { const warningIcon = ( <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M9.99996 13.3334V10.0001M9.99996 6.66675H10.0083M18.3333 10.0001C18.3333 14.6025 14.6023 18.3334 9.99996 18.3334C5.39759 18.3334 1.66663 14.6025 1.66663 10.0001C1.66663 5.39771 5.39759 1.66675 9.99996 1.66675C14.6023 1.66675 18.3333 5.39771 18.3333 10.0001Z" stroke="#F79009" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> + <path + d="M9.99996 13.3334V10.0001M9.99996 6.66675H10.0083M18.3333 10.0001C18.3333 14.6025 14.6023 18.3334 9.99996 18.3334C5.39759 18.3334 1.66663 14.6025 1.66663 10.0001C1.66663 5.39771 5.39759 1.66675 9.99996 1.66675C14.6023 1.66675 18.3333 5.39771 18.3333 10.0001Z" + stroke="#F79009" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> </svg> ) -const WarningMask: FC<IWarningMaskProps> = ({ - title, - description, - footer, -}) => { +const WarningMask: FC<IWarningMaskProps> = ({ title, description, footer }) => { return ( <div className={`${s.mask} absolute inset-0 z-10 bg-components-panel-bg-blur pt-16`}> <div className="mx-auto px-10"> - <div className={`${s.icon} flex size-11 items-center justify-center rounded-xl bg-components-panel-bg`}>{warningIcon}</div> + <div + className={`${s.icon} flex size-11 items-center justify-center rounded-xl bg-components-panel-bg`} + > + {warningIcon} + </div> <div className="mt-4 text-[24px] leading-normal font-semibold text-text-primary"> {title} </div> - <div className="mt-3 text-base text-text-secondary"> - {description} - </div> - <div className="mt-6"> - {footer} - </div> + <div className="mt-3 text-base text-text-secondary">{description}</div> + <div className="mt-6">{footer}</div> </div> - </div> ) } diff --git a/web/app/components/app/configuration/base/warning-mask/style.module.css b/web/app/components/app/configuration/base/warning-mask/style.module.css index a2c394de2a8607..1fb911f38664ac 100644 --- a/web/app/components/app/configuration/base/warning-mask/style.module.css +++ b/web/app/components/app/configuration/base/warning-mask/style.module.css @@ -3,5 +3,7 @@ } .icon { - box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03); + box-shadow: + 0px 12px 16px -4px rgba(16, 24, 40, 0.08), + 0px 4px 6px -2px rgba(16, 24, 40, 0.03); } diff --git a/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx b/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx index 22348b7d0a8079..19a4d443f6fbdf 100644 --- a/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx @@ -32,9 +32,7 @@ vi.mock('@remixicon/react', async (importOriginal) => { }) vi.mock('@/app/components/base/icons/src/vender/line/files', () => ({ - Copy: ({ onClick }: { onClick: () => void }) => ( - <button onClick={onClick}>copy-prompt</button> - ), + Copy: ({ onClick }: { onClick: () => void }) => <button onClick={onClick}>copy-prompt</button>, CopyCheck: () => <span>copy-checked</span>, })) @@ -59,7 +57,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('../message-type-selector', () => ({ - default: ({ onChange, value }: { onChange: (value: PromptRole) => void, value: PromptRole }) => ( + default: ({ onChange, value }: { onChange: (value: PromptRole) => void; value: PromptRole }) => ( <button onClick={() => onChange('assistant' as PromptRole)}>{`selector:${value}`}</button> ), })) @@ -79,7 +77,7 @@ vi.mock('@/app/components/base/prompt-editor', () => ({ })) vi.mock('../prompt-editor-height-resize-wrap', () => ({ - default: ({ children, footer }: { children: ReactNode, footer: ReactNode }) => ( + default: ({ children, footer }: { children: ReactNode; footer: ReactNode }) => ( <div> {children} {footer} @@ -87,30 +85,31 @@ vi.mock('../prompt-editor-height-resize-wrap', () => ({ ), })) -const createContextValue = () => ({ - mode: AppModeEnum.CHAT, - hasSetBlockStatus: { - context: false, - history: false, - query: false, - }, - modelConfig: { - configs: { - prompt_variables: [ - { key: 'existing_var', name: 'Existing', type: 'string', required: true }, - ], +const createContextValue = () => + ({ + mode: AppModeEnum.CHAT, + hasSetBlockStatus: { + context: false, + history: false, + query: false, }, - }, - setModelConfig: mockSetModelConfig, - conversationHistoriesRole: { - user_prefix: 'user', - assistant_prefix: 'assistant', - }, - showHistoryModal: vi.fn(), - dataSets: [], - showSelectDataSet: vi.fn(), - externalDataToolsConfig: [], -}) as any + modelConfig: { + configs: { + prompt_variables: [ + { key: 'existing_var', name: 'Existing', type: 'string', required: true }, + ], + }, + }, + setModelConfig: mockSetModelConfig, + conversationHistoriesRole: { + user_prefix: 'user', + assistant_prefix: 'assistant', + }, + showHistoryModal: vi.fn(), + dataSets: [], + showSelectDataSet: vi.fn(), + externalDataToolsConfig: [], + }) as any describe('AdvancedPromptInput', () => { beforeEach(() => { @@ -167,16 +166,18 @@ describe('AdvancedPromptInput', () => { fireEvent.click(screen.getByText('blur-advanced')) fireEvent.click(screen.getByText(/(?:^|\.)operation\.add(?=$|:)/)) - expect(mockSetModelConfig).toHaveBeenCalledWith(expect.objectContaining({ - configs: expect.objectContaining({ - prompt_variables: expect.arrayContaining([ - expect.objectContaining({ - key: 'new_var', - name: 'new_var', - }), - ]), + expect(mockSetModelConfig).toHaveBeenCalledWith( + expect.objectContaining({ + configs: expect.objectContaining({ + prompt_variables: expect.arrayContaining([ + expect.objectContaining({ + key: 'new_var', + name: 'new_var', + }), + ]), + }), }), - })) + ) }) it('should open the external data tool modal and validate duplicates', () => { @@ -203,19 +204,25 @@ describe('AdvancedPromptInput', () => { const modalConfig = mockSetShowExternalDataToolModal.mock.calls[0]![0] expect(modalConfig.onValidateBeforeSaveCallback({ variable: 'existing_var' })).toBe(false) - expect(mockToastError).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)varKeyError\.keyAlreadyExists(?=$|:)/)) + expect(mockToastError).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)varKeyError\.keyAlreadyExists(?=$|:)/), + ) modalConfig.onSaveCallback({ label: 'Search', variable: 'search_api', }) - expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ - type: 'ADD_EXTERNAL_DATA_TOOL', - })) - expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ - payload: 'search_api', - type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND, - })) + expect(mockEmit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ADD_EXTERNAL_DATA_TOOL', + }), + ) + expect(mockEmit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: 'search_api', + type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND, + }), + ) }) }) diff --git a/web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx index 70283e02c9f90b..9b1fa6194de9f7 100644 --- a/web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx @@ -173,7 +173,10 @@ describe('Prompt config component', () => { expect(renderedMessages).toHaveLength(2) expect(renderedMessages[0])!.toHaveAttribute('data-context-missing', 'true') fireEvent.click(screen.getAllByText('hide-context')[0]!) - expect(screen.getAllByTestId('advanced-message-input')[0])!.toHaveAttribute('data-context-missing', 'false') + expect(screen.getAllByTestId('advanced-message-input')[0])!.toHaveAttribute( + 'data-context-missing', + 'false', + ) }) // Chat message mutations @@ -220,12 +223,10 @@ describe('Prompt config component', () => { ) fireEvent.click(screen.getAllByText('type')[1]!) - expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith( - [ - { role: PromptRole.user, text: 'first' }, - { role: PromptRole.assistant, text: 'second' }, - ], - ) + expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith([ + { role: PromptRole.user, text: 'first' }, + { role: PromptRole.assistant, text: 'second' }, + ]) }) it('should delete chat prompt item', () => { @@ -245,14 +246,14 @@ describe('Prompt config component', () => { ) fireEvent.click(screen.getAllByText('delete')[0]!) - expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith([{ role: PromptRole.assistant, text: 'second' }]) + expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith([ + { role: PromptRole.assistant, text: 'second' }, + ]) }) // Add message behavior it('should append a mirrored role message when clicking add in chat mode', () => { - const currentAdvancedPrompt: PromptItem[] = [ - { role: PromptRole.user, text: 'first' }, - ] + const currentAdvancedPrompt: PromptItem[] = [{ role: PromptRole.user, text: 'first' }] const setCurrentAdvancedPrompt = vi.fn() renderComponent( {}, @@ -272,9 +273,7 @@ describe('Prompt config component', () => { }) it('should append a user role when the last chat prompt is from assistant', () => { - const currentAdvancedPrompt: PromptItem[] = [ - { role: PromptRole.assistant, text: 'reply' }, - ] + const currentAdvancedPrompt: PromptItem[] = [{ role: PromptRole.assistant, text: 'reply' }] const setCurrentAdvancedPrompt = vi.fn() renderComponent( {}, @@ -341,6 +340,9 @@ describe('Prompt config component', () => { fireEvent.click(screen.getByText('change')) - expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith({ role: PromptRole.user, text: 'updated text' }, true) + expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith( + { role: PromptRole.user, text: 'updated text' }, + true, + ) }) }) diff --git a/web/app/components/app/configuration/config-prompt/__tests__/prompt-editor-height-resize-wrap.spec.tsx b/web/app/components/app/configuration/config-prompt/__tests__/prompt-editor-height-resize-wrap.spec.tsx index 7e168cc7f796f8..cd332e95ea345a 100644 --- a/web/app/components/app/configuration/config-prompt/__tests__/prompt-editor-height-resize-wrap.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/__tests__/prompt-editor-height-resize-wrap.spec.tsx @@ -36,11 +36,7 @@ describe('PromptEditorHeightResizeWrap', () => { const onHeightChange = vi.fn() const { container } = render( - <PromptEditorHeightResizeWrap - height={150} - minHeight={100} - onHeightChange={onHeightChange} - > + <PromptEditorHeightResizeWrap height={150} minHeight={100} onHeightChange={onHeightChange}> <div>content</div> </PromptEditorHeightResizeWrap>, ) diff --git a/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx b/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx index fca684fa8b6bf6..74f21d0a6a0da7 100644 --- a/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx @@ -57,12 +57,22 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('@/app/components/app/configuration/config/automatic/automatic-btn', () => ({ - default: ({ onClick }: { onClick: () => void }) => <button onClick={onClick}>automatic-btn</button>, + default: ({ onClick }: { onClick: () => void }) => ( + <button onClick={onClick}>automatic-btn</button> + ), })) vi.mock('@/app/components/app/configuration/config/automatic/get-automatic-res', () => ({ default: ({ onFinished }: { onFinished: (value: Record<string, unknown>) => void }) => ( - <button onClick={() => onFinished({ modified: 'auto prompt', variables: ['city'], opening_statement: 'hello there' })}> + <button + onClick={() => + onFinished({ + modified: 'auto prompt', + variables: ['city'], + opening_statement: 'hello there', + }) + } + > finish-automatic </button> ), @@ -72,18 +82,18 @@ vi.mock('@/app/components/base/prompt-editor', () => ({ default: (props: { onBlur: () => void onChange: (value: string) => void - contextBlock: { datasets: Array<{ id: string, name: string, type: string }> } - variableBlock: { variables: Array<{ name: string, value: string }> } + contextBlock: { datasets: Array<{ id: string; name: string; type: string }> } + variableBlock: { variables: Array<{ name: string; value: string }> } queryBlock: { selectable: boolean } externalToolBlock: { onAddExternalTool: () => void - externalTools: Array<{ name: string, variableName: string }> + externalTools: Array<{ name: string; variableName: string }> } }) => ( <div> - <div>{`datasets:${props.contextBlock.datasets.map(item => item.name).join(',')}`}</div> - <div>{`variables:${props.variableBlock.variables.map(item => item.value).join(',')}`}</div> - <div>{`external-tools:${props.externalToolBlock.externalTools.map(item => item.variableName).join(',')}`}</div> + <div>{`datasets:${props.contextBlock.datasets.map((item) => item.name).join(',')}`}</div> + <div>{`variables:${props.variableBlock.variables.map((item) => item.value).join(',')}`}</div> + <div>{`external-tools:${props.externalToolBlock.externalTools.map((item) => item.variableName).join(',')}`}</div> <div>{`query-selectable:${String(props.queryBlock.selectable)}`}</div> <button onClick={() => props.onChange('Hello {{new_var}}')}>change-prompt</button> <button onClick={props.onBlur}>blur-prompt</button> @@ -93,7 +103,7 @@ vi.mock('@/app/components/base/prompt-editor', () => ({ })) vi.mock('../prompt-editor-height-resize-wrap', () => ({ - default: ({ children, footer }: { children: ReactNode, footer: ReactNode }) => ( + default: ({ children, footer }: { children: ReactNode; footer: ReactNode }) => ( <div> {children} {footer} @@ -101,29 +111,30 @@ vi.mock('../prompt-editor-height-resize-wrap', () => ({ ), })) -const createContextValue = (overrides: Record<string, unknown> = {}) => ({ - appId: 'app-1', - modelConfig: { - configs: { - prompt_template: 'Hello {{new_var}}', - prompt_variables: [ - { key: 'existing_var', name: 'Existing', type: 'string', required: true }, - ], +const createContextValue = (overrides: Record<string, unknown> = {}) => + ({ + appId: 'app-1', + modelConfig: { + configs: { + prompt_template: 'Hello {{new_var}}', + prompt_variables: [ + { key: 'existing_var', name: 'Existing', type: 'string', required: true }, + ], + }, }, - }, - dataSets: [], - setModelConfig: mockSetModelConfig, - setPrevPromptConfig: mockSetPrevPromptConfig, - setIntroduction: mockSetIntroduction, - hasSetBlockStatus: { - context: false, - history: false, - query: false, - }, - showSelectDataSet: vi.fn(), - externalDataToolsConfig: [], - ...overrides, -}) as any + dataSets: [], + setModelConfig: mockSetModelConfig, + setPrevPromptConfig: mockSetPrevPromptConfig, + setIntroduction: mockSetIntroduction, + hasSetBlockStatus: { + context: false, + history: false, + query: false, + }, + showSelectDataSet: vi.fn(), + externalDataToolsConfig: [], + ...overrides, + }) as any describe('SimplePromptInput', () => { beforeEach(() => { @@ -176,7 +187,9 @@ describe('SimplePromptInput', () => { const modalConfig = mockSetShowExternalDataToolModal.mock.calls[0]![0] expect(modalConfig.onValidateBeforeSaveCallback({ variable: 'existing_var' })).toBe(false) - expect(mockToastError).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)varKeyError\.keyAlreadyExists(?=$|:)/)) + expect(mockToastError).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)varKeyError\.keyAlreadyExists(?=$|:)/), + ) expect(modalConfig.onValidateBeforeSaveCallback({ variable: 'fresh_var' })).toBe(true) modalConfig.onSaveCallback(undefined) @@ -187,13 +200,17 @@ describe('SimplePromptInput', () => { variable: 'search_api', }) - expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ - type: 'ADD_EXTERNAL_DATA_TOOL', - })) - expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ - payload: 'search_api', - type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND, - })) + expect(mockEmit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ADD_EXTERNAL_DATA_TOOL', + }), + ) + expect(mockEmit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: 'search_api', + type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND, + }), + ) }) it('should apply automatic generation results to prompt and opening statement', () => { @@ -211,18 +228,20 @@ describe('SimplePromptInput', () => { fireEvent.click(screen.getByText('automatic-btn')) fireEvent.click(screen.getByText('finish-automatic')) - expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ - payload: 'auto prompt', - type: 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER', - })) - expect(mockSetModelConfig).toHaveBeenCalledWith(expect.objectContaining({ - configs: expect.objectContaining({ - prompt_template: 'auto prompt', - prompt_variables: [ - expect.objectContaining({ key: 'city', name: 'city' }), - ], + expect(mockEmit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: 'auto prompt', + type: 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER', + }), + ) + expect(mockSetModelConfig).toHaveBeenCalledWith( + expect.objectContaining({ + configs: expect.objectContaining({ + prompt_template: 'auto prompt', + prompt_variables: [expect.objectContaining({ key: 'city', name: 'city' })], + }), }), - })) + ) expect(mockSetPrevPromptConfig).toHaveBeenCalled() expect(mockSetIntroduction).toHaveBeenCalledWith('hello there') expect(mockSetFeatures).toHaveBeenCalled() @@ -230,23 +249,31 @@ describe('SimplePromptInput', () => { it('should expose dataset and external tool metadata to the editor', () => { render( - <ConfigContext.Provider value={createContextValue({ - dataSets: [{ id: 'dataset-1', name: 'Knowledge Base', data_source_type: 'file' }], - hasSetBlockStatus: { - context: false, - history: false, - query: true, - }, - modelConfig: { - configs: { - prompt_template: 'Hello {{existing_var}}', - prompt_variables: [ - { key: 'existing_var', name: 'Existing', type: 'string', required: true }, - { key: 'search_api', name: 'Search API', type: 'api', required: false, icon: 'search', icon_background: '#fff' }, - ], + <ConfigContext.Provider + value={createContextValue({ + dataSets: [{ id: 'dataset-1', name: 'Knowledge Base', data_source_type: 'file' }], + hasSetBlockStatus: { + context: false, + history: false, + query: true, }, - }, - })} + modelConfig: { + configs: { + prompt_template: 'Hello {{existing_var}}', + prompt_variables: [ + { key: 'existing_var', name: 'Existing', type: 'string', required: true }, + { + key: 'search_api', + name: 'Search API', + type: 'api', + required: false, + icon: 'search', + icon_background: '#fff', + }, + ], + }, + }, + })} > <Prompt mode={AppModeEnum.CHAT} @@ -268,9 +295,10 @@ describe('SimplePromptInput', () => { it('should skip external tool variables and incomplete prompt variables when deciding whether to auto add', () => { render( - <ConfigContext.Provider value={createContextValue({ - externalDataToolsConfig: [{ variable: 'search_api' }], - })} + <ConfigContext.Provider + value={createContextValue({ + externalDataToolsConfig: [{ variable: 'search_api' }], + })} > <Prompt mode={AppModeEnum.CHAT} @@ -296,9 +324,7 @@ describe('SimplePromptInput', () => { <Prompt mode={AppModeEnum.CHAT} promptTemplate="Hello {{existing_var}}" - promptVariables={[ - { key: 'existing_var', name: '', type: 'string', required: true }, - ]} + promptVariables={[{ key: 'existing_var', name: '', type: 'string', required: true }]} onChange={mockOnChange} /> </ConfigContext.Provider>, diff --git a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx index 256b9a0b4ddcbf..cd39405509c0b9 100644 --- a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx @@ -5,10 +5,7 @@ import type { PromptRole, PromptVariable } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { - RiDeleteBinLine, - RiErrorWarningFill, -} from '@remixicon/react' +import { RiDeleteBinLine, RiErrorWarningFill } from '@remixicon/react' import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' import { produce } from 'immer' @@ -16,10 +13,7 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import { ADD_EXTERNAL_DATA_TOOL } from '@/app/components/app/configuration/config-var' -import { - Copy, - CopyCheck, -} from '@/app/components/base/icons/src/vender/line/files' +import { Copy, CopyCheck } from '@/app/components/base/icons/src/vender/line/files' import { Infotip } from '@/app/components/base/infotip' import PromptEditor from '@/app/components/base/prompt-editor' import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block' @@ -79,8 +73,7 @@ const AdvancedPromptInput: FC<Props> = ({ setShowExternalDataToolModal({ payload: {}, onSaveCallback: (newExternalDataTool?: ExternalDataTool) => { - if (!newExternalDataTool) - return + if (!newExternalDataTool) return eventEmitter?.emit({ type: ADD_EXTERNAL_DATA_TOOL, payload: newExternalDataTool, @@ -93,7 +86,12 @@ const AdvancedPromptInput: FC<Props> = ({ onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => { for (let i = 0; i < promptVariables.length; i++) { if (promptVariables[i]!.key === newExternalDataTool.variable) { - toast.error(t($ => $['varKeyError.keyAlreadyExists'], { ns: 'appDebug', key: promptVariables[i]!.key })) + toast.error( + t(($) => $['varKeyError.keyAlreadyExists'], { + ns: 'appDebug', + key: promptVariables[i]!.key, + }), + ) return false } } @@ -112,16 +110,23 @@ const AdvancedPromptInput: FC<Props> = ({ }) return obj })() - const [newPromptVariables, setNewPromptVariables] = React.useState<PromptVariable[]>(promptVariables) - const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = useBoolean(false) + const [newPromptVariables, setNewPromptVariables] = + React.useState<PromptVariable[]>(promptVariables) + const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = + useBoolean(false) const handlePromptChange = (newValue: string) => { - if (value === newValue) - return + if (value === newValue) return onChange(newValue) } const handleBlur = () => { const keys = getVars(value) - const newPromptVariables = keys.filter(key => !(key in promptVariablesObj) && !externalDataToolsConfig.find(item => item.variable === key)).map(key => getNewVar(key, '')) + const newPromptVariables = keys + .filter( + (key) => + !(key in promptVariablesObj) && + !externalDataToolsConfig.find((item) => item.variable === key), + ) + .map((key) => getNewVar(key, '')) if (newPromptVariables.length > 0) { setNewPromptVariables(newPromptVariables) showConfirmAddVar() @@ -132,7 +137,10 @@ const AdvancedPromptInput: FC<Props> = ({ return () => { if (isAdd) { const newModelConfig = produce(modelConfig, (draft) => { - draft.configs.prompt_variables = [...draft.configs.prompt_variables, ...newPromptVariables] + draft.configs.prompt_variables = [ + ...draft.configs.prompt_variables, + ...newPromptVariables, + ] }) setModelConfig(newModelConfig) } @@ -151,74 +159,79 @@ const AdvancedPromptInput: FC<Props> = ({ > <div className="flex items-center pr-2"> <RiErrorWarningFill className="mr-1 h-4 w-4 text-[#F79009]" /> - <div className="text-[13px] leading-[18px] font-medium text-[#DC6803]">{t($ => $['promptMode.contextMissing'], { ns: 'appDebug' })}</div> + <div className="text-[13px] leading-[18px] font-medium text-[#DC6803]"> + {t(($) => $['promptMode.contextMissing'], { ns: 'appDebug' })} + </div> </div> - <Button - size="small" - variant="secondary-accent" - onClick={onHideContextMissingTip} - > - {t($ => $['operation.ok'], { ns: 'common' })} + <Button size="small" variant="secondary-accent" onClick={onHideContextMissingTip}> + {t(($) => $['operation.ok'], { ns: 'common' })} </Button> </div> ) return ( - <div className={`rounded-xl bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs ${!isContextMissing ? '' : s.warningBorder}`}> + <div + className={`rounded-xl bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs ${!isContextMissing ? '' : s.warningBorder}`} + > <div className="rounded-xl bg-background-default"> - {isContextMissing - ? contextMissing - : ( - <div className={cn(s.boxHeader, 'flex h-11 items-center justify-between rounded-t-xl bg-background-default pt-2 pr-3 pb-1 pl-4 hover:shadow-xs')}> - {isChatMode - ? ( - <MessageTypeSelector value={type} onChange={onTypeChange} /> - ) - : ( - <div className="flex items-center space-x-1"> - - <div className="text-sm font-semibold text-indigo-800 uppercase"> - {t($ => $['pageTitle.line1'], { ns: 'appDebug' })} - </div> - <Infotip - aria-label={t($ => $.promptTip, { ns: 'appDebug' })} - className="ml-1" - popupClassName="w-[180px]" - > - {t($ => $.promptTip, { ns: 'appDebug' })} - </Infotip> - </div> - )} - <div className={cn(s.optionWrap, 'items-center space-x-1')}> - {canDelete && ( - <RiDeleteBinLine onClick={onDelete} className="size-6 cursor-pointer p-1 text-text-tertiary" /> - )} - {!isCopied - ? ( - <Copy - className="size-6 cursor-pointer p-1 text-text-tertiary" - onClick={() => { - copy(value) - setIsCopied(true) - }} - /> - ) - : ( - <CopyCheck className="size-6 p-1 text-text-tertiary" /> - )} + {isContextMissing ? ( + contextMissing + ) : ( + <div + className={cn( + s.boxHeader, + 'flex h-11 items-center justify-between rounded-t-xl bg-background-default pt-2 pr-3 pb-1 pl-4 hover:shadow-xs', + )} + > + {isChatMode ? ( + <MessageTypeSelector value={type} onChange={onTypeChange} /> + ) : ( + <div className="flex items-center space-x-1"> + <div className="text-sm font-semibold text-indigo-800 uppercase"> + {t(($) => $['pageTitle.line1'], { ns: 'appDebug' })} </div> + <Infotip + aria-label={t(($) => $.promptTip, { ns: 'appDebug' })} + className="ml-1" + popupClassName="w-[180px]" + > + {t(($) => $.promptTip, { ns: 'appDebug' })} + </Infotip> </div> )} + <div className={cn(s.optionWrap, 'items-center space-x-1')}> + {canDelete && ( + <RiDeleteBinLine + onClick={onDelete} + className="size-6 cursor-pointer p-1 text-text-tertiary" + /> + )} + {!isCopied ? ( + <Copy + className="size-6 cursor-pointer p-1 text-text-tertiary" + onClick={() => { + copy(value) + setIsCopied(true) + }} + /> + ) : ( + <CopyCheck className="size-6 p-1 text-text-tertiary" /> + )} + </div> + </div> + )} <PromptEditorHeightResizeWrap className="min-h-[102px] overflow-y-auto px-4 text-sm text-text-secondary" height={editorHeight} minHeight={minHeight} onHeightChange={setEditorHeight} - footer={( + footer={ <div className="flex pb-2 pl-4"> - <div className="h-[18px] rounded-md bg-divider-regular px-1 text-xs leading-[18px] text-text-tertiary">{value.length}</div> + <div className="h-[18px] rounded-md bg-divider-regular px-1 text-xs leading-[18px] text-text-tertiary"> + {value.length} + </div> </div> - )} + } hideResize={noResize} > <PromptEditor @@ -227,7 +240,7 @@ const AdvancedPromptInput: FC<Props> = ({ contextBlock={{ show: true, selectable: !hasSetBlockStatus.context, - datasets: dataSets.map(item => ({ + datasets: dataSets.map((item) => ({ id: item.id, name: item.name, type: item.data_source_type, @@ -236,18 +249,29 @@ const AdvancedPromptInput: FC<Props> = ({ }} variableBlock={{ show: true, - variables: modelConfig.configs.prompt_variables.filter(item => item.type !== 'api' && item.key && item.key.trim() && item.name && item.name.trim()).map(item => ({ - name: item.name, - value: item.key, - })), + variables: modelConfig.configs.prompt_variables + .filter( + (item) => + item.type !== 'api' && + item.key && + item.key.trim() && + item.name && + item.name.trim(), + ) + .map((item) => ({ + name: item.name, + value: item.key, + })), }} externalToolBlock={{ - externalTools: modelConfig.configs.prompt_variables.filter(item => item.type === 'api').map(item => ({ - name: item.name, - variableName: item.key, - icon: item.icon, - icon_background: item.icon_background, - })), + externalTools: modelConfig.configs.prompt_variables + .filter((item) => item.type === 'api') + .map((item) => ({ + name: item.name, + variableName: item.key, + icon: item.icon, + icon_background: item.icon_background, + })), onAddExternalTool: handleOpenExternalDataToolModal, }} historyBlock={{ @@ -267,12 +291,11 @@ const AdvancedPromptInput: FC<Props> = ({ onBlur={handleBlur} /> </PromptEditorHeightResizeWrap> - </div> {isShowConfirmAddVar && ( <ConfirmAddVar - varNameArr={newPromptVariables.map(v => v.name)} + varNameArr={newPromptVariables.map((v) => v.name)} onConfirm={handleAutoAdd(true)} onCancel={handleAutoAdd(false)} onHide={hideConfirmAddVar} diff --git a/web/app/components/app/configuration/config-prompt/confirm-add-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-prompt/confirm-add-var/__tests__/index.spec.tsx index 1d6aa915527f56..5f6b5b78a8bf8c 100644 --- a/web/app/components/app/configuration/config-prompt/confirm-add-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/confirm-add-var/__tests__/index.spec.tsx @@ -12,7 +12,14 @@ describe('ConfirmAddVar', () => { }) it('should render variable names', () => { - render(<ConfirmAddVar varNameArr={['foo', 'bar']} onConfirm={vi.fn()} onCancel={vi.fn()} onHide={vi.fn()} />) + render( + <ConfirmAddVar + varNameArr={['foo', 'bar']} + onConfirm={vi.fn()} + onCancel={vi.fn()} + onHide={vi.fn()} + />, + ) const highlights = screen.getAllByTestId('var-highlight') expect(highlights).toHaveLength(2) @@ -23,7 +30,14 @@ describe('ConfirmAddVar', () => { it('should trigger cancel actions', () => { const onConfirm = vi.fn() const onCancel = vi.fn() - render(<ConfirmAddVar varNameArr={['foo']} onConfirm={onConfirm} onCancel={onCancel} onHide={vi.fn()} />) + render( + <ConfirmAddVar + varNameArr={['foo']} + onConfirm={onConfirm} + onCancel={onCancel} + onHide={vi.fn()} + />, + ) fireEvent.click(screen.getByText('common.operation.cancel')) @@ -33,7 +47,14 @@ describe('ConfirmAddVar', () => { it('should trigger confirm actions', () => { const onConfirm = vi.fn() const onCancel = vi.fn() - render(<ConfirmAddVar varNameArr={['foo']} onConfirm={onConfirm} onCancel={onCancel} onHide={vi.fn()} />) + render( + <ConfirmAddVar + varNameArr={['foo']} + onConfirm={onConfirm} + onCancel={onCancel} + onHide={vi.fn()} + />, + ) fireEvent.click(screen.getByText('common.operation.add')) diff --git a/web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx b/web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx index 26f1cc9dea7416..f6eab489f37dd8 100644 --- a/web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx +++ b/web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx @@ -15,9 +15,18 @@ type IConfirmAddVarProps = { const VarIcon = ( <svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M13.8683 0.704745C13.7051 0.374685 13.3053 0.239393 12.9752 0.402563C12.6452 0.565732 12.5099 0.965573 12.673 1.29563C13.5221 3.01316 13.9999 4.94957 13.9999 7.00019C13.9999 9.05081 13.5221 10.9872 12.673 12.7047C12.5099 13.0348 12.6452 13.4346 12.9752 13.5978C13.3053 13.761 13.7051 13.6257 13.8683 13.2956C14.8063 11.3983 15.3333 9.26009 15.3333 7.00019C15.3333 4.74029 14.8063 2.60209 13.8683 0.704745Z" fill="#FD853A" /> - <path d="M3.32687 1.29563C3.49004 0.965573 3.35475 0.565732 3.02469 0.402563C2.69463 0.239393 2.29479 0.374685 2.13162 0.704745C1.19364 2.60209 0.666626 4.74029 0.666626 7.00019C0.666626 9.26009 1.19364 11.3983 2.13162 13.2956C2.29479 13.6257 2.69463 13.761 3.02469 13.5978C3.35475 13.4346 3.49004 13.0348 3.32687 12.7047C2.47779 10.9872 1.99996 9.05081 1.99996 7.00019C1.99996 4.94957 2.47779 3.01316 3.32687 1.29563Z" fill="#FD853A" /> - <path d="M9.33238 4.8413C9.74208 4.36081 10.3411 4.08337 10.9726 4.08337H11.0324C11.4006 4.08337 11.6991 4.38185 11.6991 4.75004C11.6991 5.11823 11.4006 5.41671 11.0324 5.41671H10.9726C10.7329 5.41671 10.5042 5.52196 10.347 5.7064L8.78693 7.536L9.28085 9.27382C9.29145 9.31112 9.32388 9.33337 9.35696 9.33337H10.2864C10.6545 9.33337 10.953 9.63185 10.953 10C10.953 10.3682 10.6545 10.6667 10.2864 10.6667H9.35696C8.72382 10.6667 8.17074 10.245 7.99832 9.63834L7.74732 8.75524L6.76373 9.90878C6.35403 10.3893 5.75501 10.6667 5.1235 10.6667H5.06372C4.69553 10.6667 4.39705 10.3682 4.39705 10C4.39705 9.63185 4.69553 9.33337 5.06372 9.33337H5.1235C5.3632 9.33337 5.59189 9.22812 5.74915 9.04368L7.30926 7.21399L6.81536 5.47626C6.80476 5.43897 6.77233 5.41671 6.73925 5.41671H5.80986C5.44167 5.41671 5.14319 5.11823 5.14319 4.75004C5.14319 4.38185 5.44167 4.08337 5.80986 4.08337H6.73925C7.37239 4.08337 7.92547 4.50508 8.0979 5.11174L8.34887 5.99475L9.33238 4.8413Z" fill="#FD853A" /> + <path + d="M13.8683 0.704745C13.7051 0.374685 13.3053 0.239393 12.9752 0.402563C12.6452 0.565732 12.5099 0.965573 12.673 1.29563C13.5221 3.01316 13.9999 4.94957 13.9999 7.00019C13.9999 9.05081 13.5221 10.9872 12.673 12.7047C12.5099 13.0348 12.6452 13.4346 12.9752 13.5978C13.3053 13.761 13.7051 13.6257 13.8683 13.2956C14.8063 11.3983 15.3333 9.26009 15.3333 7.00019C15.3333 4.74029 14.8063 2.60209 13.8683 0.704745Z" + fill="#FD853A" + /> + <path + d="M3.32687 1.29563C3.49004 0.965573 3.35475 0.565732 3.02469 0.402563C2.69463 0.239393 2.29479 0.374685 2.13162 0.704745C1.19364 2.60209 0.666626 4.74029 0.666626 7.00019C0.666626 9.26009 1.19364 11.3983 2.13162 13.2956C2.29479 13.6257 2.69463 13.761 3.02469 13.5978C3.35475 13.4346 3.49004 13.0348 3.32687 12.7047C2.47779 10.9872 1.99996 9.05081 1.99996 7.00019C1.99996 4.94957 2.47779 3.01316 3.32687 1.29563Z" + fill="#FD853A" + /> + <path + d="M9.33238 4.8413C9.74208 4.36081 10.3411 4.08337 10.9726 4.08337H11.0324C11.4006 4.08337 11.6991 4.38185 11.6991 4.75004C11.6991 5.11823 11.4006 5.41671 11.0324 5.41671H10.9726C10.7329 5.41671 10.5042 5.52196 10.347 5.7064L8.78693 7.536L9.28085 9.27382C9.29145 9.31112 9.32388 9.33337 9.35696 9.33337H10.2864C10.6545 9.33337 10.953 9.63185 10.953 10C10.953 10.3682 10.6545 10.6667 10.2864 10.6667H9.35696C8.72382 10.6667 8.17074 10.245 7.99832 9.63834L7.74732 8.75524L6.76373 9.90878C6.35403 10.3893 5.75501 10.6667 5.1235 10.6667H5.06372C4.69553 10.6667 4.39705 10.3682 4.39705 10C4.39705 9.63185 4.69553 9.33337 5.06372 9.33337H5.1235C5.3632 9.33337 5.59189 9.22812 5.74915 9.04368L7.30926 7.21399L6.81536 5.47626C6.80476 5.43897 6.77233 5.41671 6.73925 5.41671H5.80986C5.44167 5.41671 5.14319 5.11823 5.14319 4.75004C5.14319 4.38185 5.44167 4.08337 5.80986 4.08337H6.73925C7.37239 4.08337 7.92547 4.50508 8.0979 5.11174L8.34887 5.99475L9.33238 4.8413Z" + fill="#FD853A" + /> </svg> ) @@ -44,30 +53,32 @@ const ConfirmAddVar: FC<IConfirmAddVarProps> = ({ ref={mainContentRef} className="w-[420px] rounded-xl bg-components-panel-bg p-6" style={{ - boxShadow: '0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)', + boxShadow: + '0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)', }} > <div className="flex items-start space-x-3"> - <div - className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-components-card-border bg-components-card-bg-alt shadow-lg" - > + <div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-components-card-border bg-components-card-bg-alt shadow-lg"> {VarIcon} </div> <div className="grow"> - <div className="text-sm font-medium text-text-primary">{t($ => $.autoAddVar, { ns: 'appDebug' })}</div> + <div className="text-sm font-medium text-text-primary"> + {t(($) => $.autoAddVar, { ns: 'appDebug' })} + </div> <div className="mt-[15px] flex max-h-[66px] flex-wrap space-x-1 overflow-y-auto px-1"> - {varNameArr.map(name => ( + {varNameArr.map((name) => ( <VarHighlight key={name} name={name} /> ))} </div> </div> </div> <div className="mt-7 flex justify-end space-x-2"> - <Button onClick={onCancel}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" onClick={onConfirm}>{t($ => $['operation.add'], { ns: 'common' })}</Button> + <Button onClick={onCancel}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> + <Button variant="primary" onClick={onConfirm}> + {t(($) => $['operation.add'], { ns: 'common' })} + </Button> </div> </div> - </div> ) } diff --git a/web/app/components/app/configuration/config-prompt/conversation-history/__tests__/edit-modal.spec.tsx b/web/app/components/app/configuration/config-prompt/conversation-history/__tests__/edit-modal.spec.tsx index 1dd5d03889d91d..7ffa5d72524613 100644 --- a/web/app/components/app/configuration/config-prompt/conversation-history/__tests__/edit-modal.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/conversation-history/__tests__/edit-modal.spec.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import EditModal from '../edit-modal' vi.mock('@langgenius/dify-ui/dialog', () => ({ - Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => + Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => open === false ? null : <>{children}</>, DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, diff --git a/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx b/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx index 8d3cae5e6382a6..9236e969700000 100644 --- a/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx +++ b/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx @@ -15,52 +15,62 @@ type Props = Readonly<{ onSave: (data: any) => void }> -const EditModal: FC<Props> = ({ - isShow, - saveLoading, - data, - onClose, - onSave, -}) => { +const EditModal: FC<Props> = ({ isShow, saveLoading, data, onClose, onSave }) => { const { t } = useTranslation() const [tempData, setTempData] = useState(data) return ( <Dialog open={isShow} onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DialogContent className="w-full max-w-[480px] overflow-hidden! border-none p-6 text-left align-middle"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['feature.conversationHistory.editModal.title'], { ns: 'appDebug' })} + {t(($) => $['feature.conversationHistory.editModal.title'], { ns: 'appDebug' })} </DialogTitle> - <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t($ => $['feature.conversationHistory.editModal.userPrefix'], { ns: 'appDebug' })}</div> + <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary"> + {t(($) => $['feature.conversationHistory.editModal.userPrefix'], { ns: 'appDebug' })} + </div> <input className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm/10" value={tempData.user_prefix} - onChange={e => setTempData({ - ...tempData, - user_prefix: e.target.value, - })} + onChange={(e) => + setTempData({ + ...tempData, + user_prefix: e.target.value, + }) + } /> - <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t($ => $['feature.conversationHistory.editModal.assistantPrefix'], { ns: 'appDebug' })}</div> + <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary"> + {t(($) => $['feature.conversationHistory.editModal.assistantPrefix'], { ns: 'appDebug' })} + </div> <input className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm/10" value={tempData.assistant_prefix} - onChange={e => setTempData({ - ...tempData, - assistant_prefix: e.target.value, - })} - placeholder={t($ => $['chat.conversationNamePlaceholder'], { ns: 'common' }) || ''} + onChange={(e) => + setTempData({ + ...tempData, + assistant_prefix: e.target.value, + }) + } + placeholder={t(($) => $['chat.conversationNamePlaceholder'], { ns: 'common' }) || ''} /> <div className="mt-10 flex justify-end"> - <Button className="mr-2 shrink-0" onClick={onClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" className="shrink-0" onClick={() => onSave(tempData)} loading={saveLoading}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button className="mr-2 shrink-0" onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button + variant="primary" + className="shrink-0" + onClick={() => onSave(tempData)} + loading={saveLoading} + > + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> </DialogContent> </Dialog> diff --git a/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.tsx b/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.tsx index 4de91ca8c7657a..8a43d876b2f981 100644 --- a/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.tsx +++ b/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.tsx @@ -11,38 +11,35 @@ type Props = Readonly<{ onShowEditModal: () => void }> -const HistoryPanel: FC<Props> = ({ - showWarning, - onShowEditModal, -}) => { +const HistoryPanel: FC<Props> = ({ showWarning, onShowEditModal }) => { const { t } = useTranslation() return ( <Panel className="mt-2" - title={( + title={ <div className="flex items-center gap-2"> - <div>{t($ => $['feature.conversationHistory.title'], { ns: 'appDebug' })}</div> + <div>{t(($) => $['feature.conversationHistory.title'], { ns: 'appDebug' })}</div> </div> - )} - headerIcon={( + } + headerIcon={ <div className="rounded-md p-1 shadow-xs"> <MessageClockCircle className="h-4 w-4 text-[#DD2590]" /> </div> - )} - headerRight={( + } + headerRight={ <div className="flex items-center"> - <div className="text-xs text-text-tertiary">{t($ => $['feature.conversationHistory.description'], { ns: 'appDebug' })}</div> + <div className="text-xs text-text-tertiary"> + {t(($) => $['feature.conversationHistory.description'], { ns: 'appDebug' })} + </div> <div className="ml-3 h-[14px] w-px bg-divider-regular"></div> <OperationBtn type="edit" onClick={onShowEditModal} /> </div> - )} + } noBodySpacing > {showWarning && ( <div className="flex justify-between rounded-b-xl bg-background-section-burn px-3 py-2 text-xs text-text-secondary"> - <div> - {t($ => $['feature.conversationHistory.tip'], { ns: 'appDebug' })} - </div> + <div>{t(($) => $['feature.conversationHistory.tip'], { ns: 'appDebug' })}</div> </div> )} </Panel> diff --git a/web/app/components/app/configuration/config-prompt/index.tsx b/web/app/components/app/configuration/config-prompt/index.tsx index 7e32a534a48502..0893249d5da71c 100644 --- a/web/app/components/app/configuration/config-prompt/index.tsx +++ b/web/app/components/app/configuration/config-prompt/index.tsx @@ -3,9 +3,7 @@ import type { FC } from 'react' import type { PromptItem, PromptVariable } from '@/models/debug' import type { AppModeEnum } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' -import { - RiAddLine, -} from '@remixicon/react' +import { RiAddLine } from '@remixicon/react' import { produce } from 'immer' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -64,23 +62,27 @@ const Prompt: FC<IPromptProps> = ({ draft[index as number]!.text = value }) setCurrentAdvancedPrompt(newPrompt, true) - } - else { + } else { const prompt = currentAdvancedPrompt as PromptItem - setCurrentAdvancedPrompt({ - ...prompt, - text: value, - }, true) + setCurrentAdvancedPrompt( + { + ...prompt, + text: value, + }, + true, + ) } } const handleAddMessage = () => { const currentAdvancedPromptList = currentAdvancedPrompt as PromptItem[] if (currentAdvancedPromptList.length === 0) { - setCurrentAdvancedPrompt([{ - role: PromptRole.system, - text: '', - }]) + setCurrentAdvancedPrompt([ + { + role: PromptRole.system, + text: '', + }, + ]) return } const lastMessageType = currentAdvancedPromptList[currentAdvancedPromptList.length - 1]?.role @@ -121,50 +123,46 @@ const Prompt: FC<IPromptProps> = ({ return ( <div> <div className="space-y-3"> - {modelModeType === ModelModeType.chat - ? ( - (currentAdvancedPrompt as PromptItem[]).map((item, index) => ( - <AdvancedMessageInput - key={index} - isChatMode - type={item.role as PromptRole} - value={item.text} - onTypeChange={type => handleMessageTypeChange(index, type)} - canDelete={(currentAdvancedPrompt as PromptItem[]).length > 1} - onDelete={() => handlePromptDelete(index)} - onChange={value => handleValueChange(value, index)} - promptVariables={promptVariables} - isContextMissing={isContextMissing && !isHideContextMissTip} - onHideContextMissingTip={() => setIsHideContextMissTip(true)} - noResize={noResize} - /> - )) - ) - : ( - <AdvancedMessageInput - type={(currentAdvancedPrompt as PromptItem).role as PromptRole} - isChatMode={false} - value={(currentAdvancedPrompt as PromptItem).text} - onTypeChange={type => handleMessageTypeChange(0, type)} - canDelete={false} - onDelete={() => handlePromptDelete(0)} - onChange={value => handleValueChange(value)} - promptVariables={promptVariables} - isContextMissing={isContextMissing && !isHideContextMissTip} - onHideContextMissingTip={() => setIsHideContextMissTip(true)} - noResize={noResize} - /> - )} + {modelModeType === ModelModeType.chat ? ( + (currentAdvancedPrompt as PromptItem[]).map((item, index) => ( + <AdvancedMessageInput + key={index} + isChatMode + type={item.role as PromptRole} + value={item.text} + onTypeChange={(type) => handleMessageTypeChange(index, type)} + canDelete={(currentAdvancedPrompt as PromptItem[]).length > 1} + onDelete={() => handlePromptDelete(index)} + onChange={(value) => handleValueChange(value, index)} + promptVariables={promptVariables} + isContextMissing={isContextMissing && !isHideContextMissTip} + onHideContextMissingTip={() => setIsHideContextMissTip(true)} + noResize={noResize} + /> + )) + ) : ( + <AdvancedMessageInput + type={(currentAdvancedPrompt as PromptItem).role as PromptRole} + isChatMode={false} + value={(currentAdvancedPrompt as PromptItem).text} + onTypeChange={(type) => handleMessageTypeChange(0, type)} + canDelete={false} + onDelete={() => handlePromptDelete(0)} + onChange={(value) => handleValueChange(value)} + promptVariables={promptVariables} + isContextMissing={isContextMissing && !isHideContextMissTip} + onHideContextMissingTip={() => setIsHideContextMissTip(true)} + noResize={noResize} + /> + )} </div> - {(modelModeType === ModelModeType.chat && (currentAdvancedPrompt as PromptItem[]).length < MAX_PROMPT_MESSAGE_LENGTH) && ( - <Button - onClick={handleAddMessage} - className="mt-3 w-full" - > - <RiAddLine className="mr-2 size-4" /> - <div>{t($ => $['promptMode.operation.addMessage'], { ns: 'appDebug' })}</div> - </Button> - )} + {modelModeType === ModelModeType.chat && + (currentAdvancedPrompt as PromptItem[]).length < MAX_PROMPT_MESSAGE_LENGTH && ( + <Button onClick={handleAddMessage} className="mt-3 w-full"> + <RiAddLine className="mr-2 size-4" /> + <div>{t(($) => $['promptMode.operation.addMessage'], { ns: 'appDebug' })}</div> + </Button> + )} </div> ) } diff --git a/web/app/components/app/configuration/config-prompt/message-type-selector.tsx b/web/app/components/app/configuration/config-prompt/message-type-selector.tsx index 4dec3f4433f13d..13dd4d9278c232 100644 --- a/web/app/components/app/configuration/config-prompt/message-type-selector.tsx +++ b/web/app/components/app/configuration/config-prompt/message-type-selector.tsx @@ -12,10 +12,7 @@ type Props = Readonly<{ }> const allTypes = [PromptRole.system, PromptRole.user, PromptRole.assistant] -const MessageTypeSelector: FC<Props> = ({ - value, - onChange, -}) => { +const MessageTypeSelector: FC<Props> = ({ value, onChange }) => { const [showOption, { setFalse: setHide, toggle: toggleShow }] = useBoolean(false) const ref = React.useRef(null) useClickAway(() => { @@ -25,14 +22,17 @@ const MessageTypeSelector: FC<Props> = ({ <div className="relative left-[-8px]" ref={ref}> <div onClick={toggleShow} - className={cn(showOption && 'bg-indigo-100', 'flex h-7 cursor-pointer items-center space-x-0.5 rounded-lg pr-1 pl-1.5 text-indigo-800')} + className={cn( + showOption && 'bg-indigo-100', + 'flex h-7 cursor-pointer items-center space-x-0.5 rounded-lg pr-1 pl-1.5 text-indigo-800', + )} > <div className="text-sm font-semibold uppercase">{value}</div> <ChevronSelectorVertical className="size-3" /> </div> {showOption && ( <div className="absolute top-[30px] z-10 rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg"> - {allTypes.map(type => ( + {allTypes.map((type) => ( <div key={type} onClick={() => { diff --git a/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx b/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx index 6c94336ca5c02f..0dbd29cf28899e 100644 --- a/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx +++ b/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx @@ -26,34 +26,40 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({ }) => { const [clientY, setClientY] = useState(0) const [isResizing, setIsResizing] = useState(false) - const [prevUserSelectStyle, setPrevUserSelectStyle] = useState(() => getComputedStyle(document.body).userSelect) + const [prevUserSelectStyle, setPrevUserSelectStyle] = useState( + () => getComputedStyle(document.body).userSelect, + ) const [oldHeight, setOldHeight] = useState(height) - const handleStartResize = useCallback((e: React.MouseEvent<HTMLElement>) => { - setClientY(e.clientY) - setIsResizing(true) - setOldHeight(height) - setPrevUserSelectStyle(getComputedStyle(document.body).userSelect) - document.body.style.userSelect = 'none' - }, [height]) + const handleStartResize = useCallback( + (e: React.MouseEvent<HTMLElement>) => { + setClientY(e.clientY) + setIsResizing(true) + setOldHeight(height) + setPrevUserSelectStyle(getComputedStyle(document.body).userSelect) + document.body.style.userSelect = 'none' + }, + [height], + ) const handleStopResize = useCallback(() => { setIsResizing(false) document.body.style.userSelect = prevUserSelectStyle }, [prevUserSelectStyle]) - const { run: didHandleResize } = useDebounceFn((e) => { - if (!isResizing) - return + const { run: didHandleResize } = useDebounceFn( + (e) => { + if (!isResizing) return - const offset = e.clientY - clientY - let newHeight = oldHeight + offset - if (newHeight < minHeight) - newHeight = minHeight - onHeightChange(newHeight) - }, { - wait: 0, - }) + const offset = e.clientY - clientY + let newHeight = oldHeight + offset + if (newHeight < minHeight) newHeight = minHeight + onHeightChange(newHeight) + }, + { + wait: 0, + }, + ) const handleResize = useCallback(didHandleResize, [isResizing, height, minHeight, clientY]) @@ -72,9 +78,7 @@ const PromptEditorHeightResizeWrap: FC<Props> = ({ }, [handleStopResize]) return ( - <div - className="relative" - > + <div className="relative"> <div className={cn(className, 'overflow-y-auto')} style={{ diff --git a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx index 77fc015f0893fd..61f9e60ad49746 100644 --- a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx @@ -55,10 +55,7 @@ const Prompt: FC<ISimplePromptInput> = ({ const media = useBreakpoints() const isMobile = media === MediaType.mobile const featuresStore = useFeaturesStore() - const { - features, - setFeatures, - } = featuresStore!.getState() + const { features, setFeatures } = featuresStore!.getState() const { eventEmitter } = useEventEmitterContextContext() const { @@ -77,8 +74,7 @@ const Prompt: FC<ISimplePromptInput> = ({ setShowExternalDataToolModal({ payload: {}, onSaveCallback: (newExternalDataTool?: ExternalDataTool) => { - if (!newExternalDataTool) - return + if (!newExternalDataTool) return eventEmitter?.emit({ type: ADD_EXTERNAL_DATA_TOOL, payload: newExternalDataTool, @@ -91,7 +87,12 @@ const Prompt: FC<ISimplePromptInput> = ({ onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => { for (let i = 0; i < promptVariables.length; i++) { if (promptVariables[i]!.key === newExternalDataTool.variable) { - toast.error(t($ => $['varKeyError.keyAlreadyExists'], { ns: 'appDebug', key: promptVariables[i]!.key })) + toast.error( + t(($) => $['varKeyError.keyAlreadyExists'], { + ns: 'appDebug', + key: promptVariables[i]!.key, + }), + ) return false } } @@ -101,29 +102,38 @@ const Prompt: FC<ISimplePromptInput> = ({ }) } - const [newPromptVariables, setNewPromptVariables] = React.useState<PromptVariable[]>(promptVariables) + const [newPromptVariables, setNewPromptVariables] = + React.useState<PromptVariable[]>(promptVariables) const [newTemplates, setNewTemplates] = React.useState('') - const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = useBoolean(false) + const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = + useBoolean(false) const handleChange = (newTemplates: string, keys: string[]) => { // Filter out keys that are not properly defined (either not exist or exist but without valid name) - const newPromptVariables = keys.filter((key) => { - // Check if key exists in external data tools - if (externalDataToolsConfig.find((item: ExternalDataTool) => item.variable === key)) - return false + const newPromptVariables = keys + .filter((key) => { + // Check if key exists in external data tools + if (externalDataToolsConfig.find((item: ExternalDataTool) => item.variable === key)) + return false - // Check if key exists in prompt variables - const existingVar = promptVariables.find((item: PromptVariable) => item.key === key) - if (!existingVar) { - // Variable doesn't exist at all - return true - } + // Check if key exists in prompt variables + const existingVar = promptVariables.find((item: PromptVariable) => item.key === key) + if (!existingVar) { + // Variable doesn't exist at all + return true + } - // Variable exists but check if it has valid name and key - return !existingVar.name || !existingVar.name.trim() || !existingVar.key || !existingVar.key.trim() + // Variable exists but check if it has valid name and key + return ( + !existingVar.name || + !existingVar.name.trim() || + !existingVar.key || + !existingVar.key.trim() + ) - return false - }).map(key => getNewVar(key, '')) + return false + }) + .map((key) => getNewVar(key, '')) if (newPromptVariables.length > 0) { setNewPromptVariables(newPromptVariables) @@ -141,7 +151,8 @@ const Prompt: FC<ISimplePromptInput> = ({ } } - const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = useBoolean(false) + const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = + useBoolean(false) const handleAutomaticRes = (res: GenRes) => { // put eventEmitter in first place to prevent overwrite the configs.prompt_variables.But another problem is that prompt won't hight the prompt_variables. eventEmitter?.emit({ @@ -150,7 +161,12 @@ const Prompt: FC<ISimplePromptInput> = ({ } as any) const newModelConfig = produce(modelConfig, (draft) => { draft.configs.prompt_template = res.modified - draft.configs.prompt_variables = (res.variables || []).map(key => ({ key, name: key, type: 'string', required: true })) + draft.configs.prompt_variables = (res.variables || []).map((key) => ({ + key, + name: key, + type: 'string', + required: true, + })) }) setModelConfig(newModelConfig) setPrevPromptConfig(modelConfig.configs) @@ -172,26 +188,32 @@ const Prompt: FC<ISimplePromptInput> = ({ const [editorHeight, setEditorHeight] = useState(minHeight) return ( - <div className={cn('relative rounded-xl bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs')}> + <div + className={cn( + 'relative rounded-xl bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs', + )} + > <div className="rounded-xl bg-background-section-burn"> {!noTitle && ( <div className="flex h-11 items-center justify-between pr-2.5 pl-3"> <div className="flex items-center space-x-1"> - <div className="system-sm-semibold-uppercase text-text-secondary">{mode !== AppModeEnum.COMPLETION ? t($ => $.chatSubTitle, { ns: 'appDebug' }) : t($ => $.completionSubTitle, { ns: 'appDebug' })}</div> + <div className="system-sm-semibold-uppercase text-text-secondary"> + {mode !== AppModeEnum.COMPLETION + ? t(($) => $.chatSubTitle, { ns: 'appDebug' }) + : t(($) => $.completionSubTitle, { ns: 'appDebug' })} + </div> {!readonly && ( <Infotip - aria-label={t($ => $.promptTip, { ns: 'appDebug' })} + aria-label={t(($) => $.promptTip, { ns: 'appDebug' })} className="ml-1" popupClassName="w-[180px]" > - {t($ => $.promptTip, { ns: 'appDebug' })} + {t(($) => $.promptTip, { ns: 'appDebug' })} </Infotip> )} </div> <div className="flex items-center"> - {!readonly && !isMobile && ( - <AutomaticBtn onClick={showAutomaticTrue} /> - )} + {!readonly && !isMobile && <AutomaticBtn onClick={showAutomaticTrue} />} </div> </div> )} @@ -202,11 +224,13 @@ const Prompt: FC<ISimplePromptInput> = ({ minHeight={minHeight} onHeightChange={setEditorHeight} hideResize={noResize} - footer={( + footer={ <div className="flex rounded-b-xl bg-background-default pb-2 pl-4"> - <div className="h-[18px] rounded-md bg-components-badge-bg-gray-soft px-1 text-xs leading-[18px] text-text-tertiary">{promptTemplate.length}</div> + <div className="h-[18px] rounded-md bg-components-badge-bg-gray-soft px-1 text-xs leading-[18px] text-text-tertiary"> + {promptTemplate.length} + </div> </div> - )} + } > <PromptEditor className="min-h-[210px]" @@ -215,7 +239,7 @@ const Prompt: FC<ISimplePromptInput> = ({ contextBlock={{ show: false, selectable: !hasSetBlockStatus.context, - datasets: dataSets.map(item => ({ + datasets: dataSets.map((item) => ({ id: item.id, name: item.name, type: item.data_source_type, @@ -224,19 +248,30 @@ const Prompt: FC<ISimplePromptInput> = ({ }} variableBlock={{ show: true, - variables: modelConfig.configs.prompt_variables.filter((item: PromptVariable) => item.type !== 'api' && item.key && item.key.trim() && item.name && item.name.trim()).map((item: PromptVariable) => ({ - name: item.name, - value: item.key, - })), + variables: modelConfig.configs.prompt_variables + .filter( + (item: PromptVariable) => + item.type !== 'api' && + item.key && + item.key.trim() && + item.name && + item.name.trim(), + ) + .map((item: PromptVariable) => ({ + name: item.name, + value: item.key, + })), }} externalToolBlock={{ show: true, - externalTools: modelConfig.configs.prompt_variables.filter((item: PromptVariable) => item.type === 'api').map((item: PromptVariable) => ({ - name: item.name, - variableName: item.key, - icon: item.icon, - icon_background: item.icon_background, - })), + externalTools: modelConfig.configs.prompt_variables + .filter((item: PromptVariable) => item.type === 'api') + .map((item: PromptVariable) => ({ + name: item.name, + variableName: item.key, + icon: item.icon, + icon_background: item.icon_background, + })), onAddExternalTool: handleOpenExternalDataToolModal, }} historyBlock={{ @@ -253,8 +288,7 @@ const Prompt: FC<ISimplePromptInput> = ({ selectable: !hasSetBlockStatus.query, }} onChange={(value) => { - if (handleChange) - handleChange(value, []) + if (handleChange) handleChange(value, []) }} onBlur={() => { handleChange(promptTemplate, getVars(promptTemplate)) @@ -266,7 +300,7 @@ const Prompt: FC<ISimplePromptInput> = ({ {isShowConfirmAddVar && ( <ConfirmAddVar - varNameArr={newPromptVariables.map(v => v.name)} + varNameArr={newPromptVariables.map((v) => v.name)} onConfirm={handleAutoAdd(true)} onCancel={handleAutoAdd(false)} onHide={hideConfirmAddVar} diff --git a/web/app/components/app/configuration/config-prompt/style.module.css b/web/app/components/app/configuration/config-prompt/style.module.css index 224d59d9c8a041..fee2e28259e3ce 100644 --- a/web/app/components/app/configuration/config-prompt/style.module.css +++ b/web/app/components/app/configuration/config-prompt/style.module.css @@ -1,21 +1,26 @@ .gradientBorder { - background: radial-gradient(circle at 100% 100%, #fcfcfd 0, #fcfcfd 10px, transparent 10px) 0% 0%/12px 12px no-repeat, - radial-gradient(circle at 0 100%, #fcfcfd 0, #fcfcfd 10px, transparent 10px) 100% 0%/12px 12px no-repeat, - radial-gradient(circle at 100% 0, #fcfcfd 0, #fcfcfd 10px, transparent 10px) 0% 100%/12px 12px no-repeat, - radial-gradient(circle at 0 0, #fcfcfd 0, #fcfcfd 10px, transparent 10px) 100% 100%/12px 12px no-repeat, - linear-gradient(#fcfcfd, #fcfcfd) 50% 50%/calc(100% - 4px) calc(100% - 24px) no-repeat, - linear-gradient(#fcfcfd, #fcfcfd) 50% 50%/calc(100% - 24px) calc(100% - 4px) no-repeat, - radial-gradient(at 100% 100%, rgba(45,13,238,0.8) 0%, transparent 70%), - radial-gradient(at 100% 0%, rgba(45,13,238,0.8) 0%, transparent 70%), - radial-gradient(at 0% 0%, rgba(42,135,245,0.8) 0%, transparent 70%), - radial-gradient(at 0% 100%, rgba(42,135,245,0.8) 0%, transparent 70%); + background: + radial-gradient(circle at 100% 100%, #fcfcfd 0, #fcfcfd 10px, transparent 10px) 0% 0%/12px 12px + no-repeat, + radial-gradient(circle at 0 100%, #fcfcfd 0, #fcfcfd 10px, transparent 10px) 100% 0%/12px 12px + no-repeat, + radial-gradient(circle at 100% 0, #fcfcfd 0, #fcfcfd 10px, transparent 10px) 0% 100%/12px 12px + no-repeat, + radial-gradient(circle at 0 0, #fcfcfd 0, #fcfcfd 10px, transparent 10px) 100% 100%/12px 12px + no-repeat, + linear-gradient(#fcfcfd, #fcfcfd) 50% 50%/calc(100% - 4px) calc(100% - 24px) no-repeat, + linear-gradient(#fcfcfd, #fcfcfd) 50% 50%/calc(100% - 24px) calc(100% - 4px) no-repeat, + radial-gradient(at 100% 100%, rgba(45, 13, 238, 0.8) 0%, transparent 70%), + radial-gradient(at 100% 0%, rgba(45, 13, 238, 0.8) 0%, transparent 70%), + radial-gradient(at 0% 0%, rgba(42, 135, 245, 0.8) 0%, transparent 70%), + radial-gradient(at 0% 100%, rgba(42, 135, 245, 0.8) 0%, transparent 70%); border-radius: 12px; padding: 2px; box-sizing: border-box; } .warningBorder { - border: 2px solid #F79009; + border: 2px solid #f79009; border-radius: 12px; } diff --git a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx index 604db8288ac467..140792112b58b6 100644 --- a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx @@ -8,7 +8,6 @@ import * as React from 'react' import { vi } from 'vitest' import DebugConfigurationContext from '@/context/debug-configuration' import { AppModeEnum } from '@/types/app' - import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from '../index' const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error') @@ -58,7 +57,9 @@ vi.mock('react-sortablejs', () => ({ }, })) -type DebugConfigurationState = React.ComponentProps<typeof DebugConfigurationContext.Provider>['value'] +type DebugConfigurationState = React.ComponentProps< + typeof DebugConfigurationContext.Provider +>['value'] const defaultDebugConfigValue = { mode: AppModeEnum.CHAT, @@ -68,10 +69,13 @@ const defaultDebugConfigValue = { }, } as unknown as DebugConfigurationState -const createDebugConfigValue = (overrides: Partial<DebugConfigurationState> = {}): DebugConfigurationState => ({ - ...defaultDebugConfigValue, - ...overrides, -} as unknown as DebugConfigurationState) +const createDebugConfigValue = ( + overrides: Partial<DebugConfigurationState> = {}, +): DebugConfigurationState => + ({ + ...defaultDebugConfigValue, + ...overrides, + }) as unknown as DebugConfigurationState let variableIndex = 0 const createPromptVariable = (overrides: Partial<PromptVariable> = {}): PromptVariable => { @@ -85,7 +89,10 @@ const createPromptVariable = (overrides: Partial<PromptVariable> = {}): PromptVa } } -const renderConfigVar = (props: Partial<IConfigVarProps> = {}, debugOverrides: Partial<DebugConfigurationState> = {}) => { +const renderConfigVar = ( + props: Partial<IConfigVarProps> = {}, + debugOverrides: Partial<DebugConfigurationState> = {}, +) => { const defaultProps: IConfigVarProps = { promptVariables: [], readonly: false, @@ -233,7 +240,9 @@ describe('ConfigVar', () => { const item = screen.getByTitle('name · Name') const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) + fireEvent.click( + within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' }), + ) const editDialog = await screen.findByRole('dialog') const saveButton = within(editDialog).getByRole('button', { name: 'common.operation.save' }) @@ -257,9 +266,13 @@ describe('ConfigVar', () => { const item = screen.getByTitle('first · First') const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) + fireEvent.click( + within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' }), + ) - const inputs = await screen.findAllByPlaceholderText('appDebug.variableConfig.inputPlaceholder') + const inputs = await screen.findAllByPlaceholderText( + 'appDebug.variableConfig.inputPlaceholder', + ) fireEvent.change(inputs[0]!, { target: { value: 'second' } }) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) @@ -281,9 +294,13 @@ describe('ConfigVar', () => { const item = screen.getByTitle('first · First') const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) + fireEvent.click( + within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' }), + ) - const inputs = await screen.findAllByPlaceholderText('appDebug.variableConfig.inputPlaceholder') + const inputs = await screen.findAllByPlaceholderText( + 'appDebug.variableConfig.inputPlaceholder', + ) fireEvent.change(inputs[1]!, { target: { value: 'Second' } }) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) @@ -405,7 +422,9 @@ describe('ConfigVar', () => { const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) + fireEvent.click( + within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' }), + ) const modalState = setShowExternalDataToolModal.mock.calls.at(-1)?.[0] @@ -453,7 +472,9 @@ describe('ConfigVar', () => { const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) + fireEvent.click( + within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' }), + ) const modalState = setShowExternalDataToolModal.mock.calls.at(-1)?.[0] diff --git a/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx index 0b492a06edf726..79cd5c2c1035f2 100644 --- a/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx +++ b/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx @@ -2,25 +2,33 @@ import { render, screen } from '@testing-library/react' import { InputVarType } from '@/app/components/workflow/types' import InputTypeIcon from '../input-type-icon' -const mockInputVarTypeIcon = vi.fn(({ type, className }: { type: InputVarType, className?: string }) => ( - <div data-testid="input-var-type-icon" data-type={type} className={className} /> -)) +const mockInputVarTypeIcon = vi.fn( + ({ type, className }: { type: InputVarType; className?: string }) => ( + <div data-testid="input-var-type-icon" data-type={type} className={className} /> + ), +) vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', () => ({ - default: (props: { type: InputVarType, className?: string }) => mockInputVarTypeIcon(props), + default: (props: { type: InputVarType; className?: string }) => mockInputVarTypeIcon(props), })) describe('InputTypeIcon', () => { it('should map string variables to the workflow text-input icon', () => { render(<InputTypeIcon type="string" className="marker" />) - expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute('data-type', InputVarType.textInput) + expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute( + 'data-type', + InputVarType.textInput, + ) expect(screen.getByTestId('input-var-type-icon')).toHaveClass('marker') }) it('should map select variables to the workflow select icon', () => { render(<InputTypeIcon type="select" className="marker" />) - expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute('data-type', InputVarType.select) + expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute( + 'data-type', + InputVarType.select, + ) }) }) diff --git a/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx index e84189ddff7541..75b7e9dc1dc2c3 100644 --- a/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx +++ b/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx @@ -6,9 +6,7 @@ describe('ModalFoot', () => { const onCancel = vi.fn() const onConfirm = vi.fn() - render( - <ModalFoot onCancel={onCancel} onConfirm={onConfirm} />, - ) + render(<ModalFoot onCancel={onCancel} onConfirm={onConfirm} />) fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx index bcfc25b125e98e..7b1aed85dae29c 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx @@ -16,12 +16,14 @@ vi.mock('react-i18next', async () => { }), i18n: { language: 'en', changeLanguage: vi.fn() }, }), - Trans: withSelectorKeyProps(({ i18nKey, components }: { i18nKey: string, components?: Record<string, ReactNode> }) => ( - <span data-i18n-key={i18nKey}> - {i18nKey} - {components?.docLink} - </span> - )), + Trans: withSelectorKeyProps( + ({ i18nKey, components }: { i18nKey: string; components?: Record<string, ReactNode> }) => ( + <span data-i18n-key={i18nKey}> + {i18nKey} + {components?.docLink} + </span> + ), + ), } }) @@ -44,10 +46,12 @@ vi.mock('@/app/components/base/file-uploader', () => ({ <span data-testid="file-uploader-config">{JSON.stringify(fileConfig)}</span> <button type="button" - onClick={() => onChange([ - { fileId: 'file-1', type: 'local_file', url: 'https://example.com/file.png' }, - { fileId: 'file-2', type: 'remote_url', url: 'https://example.com/file-2.png' }, - ])} + onClick={() => + onChange([ + { fileId: 'file-1', type: 'local_file', url: 'https://example.com/file.png' }, + { fileId: 'file-2', type: 'remote_url', url: 'https://example.com/file-2.png' }, + ]) + } > upload-file </button> @@ -59,7 +63,13 @@ vi.mock('@/app/components/base/file-uploader', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({ - default: ({ onChange, isMultiple }: { onChange: (payload: Record<string, unknown>) => void, isMultiple: boolean }) => ( + default: ({ + onChange, + isMultiple, + }: { + onChange: (payload: Record<string, unknown>) => void + isMultiple: boolean + }) => ( <button type="button" onClick={() => onChange({ number_limits: isMultiple ? 3 : 1 })}> {isMultiple ? 'multi-file-setting' : 'single-file-setting'} </button> @@ -68,7 +78,9 @@ vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ default: ({ onChange }: { onChange: (value: string) => void }) => ( - <button type="button" onClick={() => onChange('{\n "type": "object"\n}')}>json-editor</button> + <button type="button" onClick={() => onChange('{\n "type": "object"\n}')}> + json-editor + </button> ), })) @@ -77,10 +89,23 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { return { ...actual, - Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => ( + Select: ({ + value, + onValueChange, + children, + }: { + value: string + onValueChange: (value: string) => void + children: ReactNode + }) => ( <div> - <button type="button" onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')}>{`ui-select:${value}`}</button> - <button type="button" onClick={() => onValueChange('__empty__')}>ui-select-empty</button> + <button + type="button" + onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')} + >{`ui-select:${value}`}</button> + <button type="button" onClick={() => onValueChange('__empty__')}> + ui-select-empty + </button> {children} </div> ), @@ -100,7 +125,7 @@ vi.mock('@langgenius/dify-ui/tooltip', () => ({ })) vi.mock('../field', () => ({ - default: ({ children, title }: { children: ReactNode, title: string }) => ( + default: ({ children, title }: { children: ReactNode; title: string }) => ( <div> <span>{title}</span> {children} @@ -110,19 +135,25 @@ vi.mock('../field', () => ({ vi.mock('../type-select', () => ({ default: ({ onSelect }: { onSelect: (item: { value: InputVarType }) => void }) => ( - <button type="button" onClick={() => onSelect({ value: InputVarType.select })}>type-selector</button> + <button type="button" onClick={() => onSelect({ value: InputVarType.select })}> + type-selector + </button> ), })) vi.mock('../../config-select', () => ({ default: ({ onChange }: { onChange: (value: string[]) => void }) => ( - <button type="button" onClick={() => onChange(['alpha', 'beta'])}>config-select</button> + <button type="button" onClick={() => onChange(['alpha', 'beta'])}> + config-select + </button> ), })) vi.mock('../../config-string', () => ({ - default: ({ onChange, maxLength }: { onChange: (value: number) => void, maxLength: number }) => ( - <button type="button" data-max-length={String(maxLength)} onClick={() => onChange(64)}>config-string</button> + default: ({ onChange, maxLength }: { onChange: (value: number) => void; maxLength: number }) => ( + <button type="button" data-max-length={String(maxLength)} onClick={() => onChange(64)}> + config-string + </button> ), })) @@ -149,8 +180,7 @@ const createBaseProps = () => { onFilePayloadChange: vi.fn(), onJSONSchemaChange: vi.fn(), onPayloadChange: (key: string) => { - if (!payloadChangeHandlers[key]) - payloadChangeHandlers[key] = createPayloadChangeHandler() + if (!payloadChangeHandlers[key]) payloadChangeHandlers[key] = createPayloadChangeHandler() return payloadChangeHandlers[key] }, onTypeChange: vi.fn(), @@ -173,26 +203,42 @@ const createBaseProps = () => { describe('ConfigModalFormFields', () => { it('should update paragraph, number, checkbox, and select defaults', () => { const paragraphProps = createBaseProps() - paragraphProps.tempPayload = { ...paragraphProps.tempPayload, type: InputVarType.paragraph, default: 'hello' } + paragraphProps.tempPayload = { + ...paragraphProps.tempPayload, + type: InputVarType.paragraph, + default: 'hello', + } render(<ConfigModalFormFields {...paragraphProps} />) fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated paragraph' } }) expect(paragraphProps.payloadChangeHandlers.default).toHaveBeenCalledWith('updated paragraph') const numberProps = createBaseProps() - numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: '1' } + numberProps.tempPayload = { + ...numberProps.tempPayload, + type: InputVarType.number, + default: '1', + } render(<ConfigModalFormFields {...numberProps} />) fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } }) expect(numberProps.payloadChangeHandlers.default).toHaveBeenCalledWith('2') const checkboxProps = createBaseProps() - checkboxProps.tempPayload = { ...checkboxProps.tempPayload, type: InputVarType.checkbox, default: false } + checkboxProps.tempPayload = { + ...checkboxProps.tempPayload, + type: InputVarType.checkbox, + default: false, + } checkboxProps.checkboxDefaultSelectValue = 'true' render(<ConfigModalFormFields {...checkboxProps} />) fireEvent.click(screen.getByText('ui-select:true')) expect(checkboxProps.payloadChangeHandlers.default).toHaveBeenCalledWith(false) const selectProps = createBaseProps() - selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: 'alpha' } + selectProps.tempPayload = { + ...selectProps.tempPayload, + type: InputVarType.select, + default: 'alpha', + } selectProps.options = ['alpha', 'beta'] render(<ConfigModalFormFields {...selectProps} />) fireEvent.click(screen.getByText('config-select')) @@ -208,13 +254,18 @@ describe('ConfigModalFormFields', () => { fireEvent.click(screen.getByRole('button', { name: 'variableConfig.hiddenDescription' })) expect(await screen.findByText('variableConfig.hiddenDescription')).toBeInTheDocument() const docLink = await screen.findByRole('link') - expect(docLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/nodes/user-input#hide-and-pre-fill-input-fields') + expect(docLink).toHaveAttribute( + 'href', + 'https://docs.example.com/use-dify/nodes/user-input#hide-and-pre-fill-input-fields', + ) expect(docLink).toHaveAttribute('target', '_blank') expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') textInputView.unmount() const hiddenFieldDisabledProps = createBaseProps() - const hiddenFieldDisabledView = render(<ConfigModalFormFields {...hiddenFieldDisabledProps} showHiddenField={false} />) + const hiddenFieldDisabledView = render( + <ConfigModalFormFields {...hiddenFieldDisabledProps} showHiddenField={false} />, + ) expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument() expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument() hiddenFieldDisabledView.unmount() @@ -235,9 +286,11 @@ describe('ConfigModalFormFields', () => { fireEvent.click(screen.getByRole('checkbox', { name: 'variableConfig.required' })) expect(singleFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 1 }) - expect(singleFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith(expect.objectContaining({ - fileId: 'file-1', - })) + expect(singleFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith( + expect.objectContaining({ + fileId: 'file-1', + }), + ) expect(singleFileProps.payloadChangeHandlers.required).toHaveBeenCalledWith(true) expect(singleFileProps.payloadChangeHandlers.hide).not.toHaveBeenCalled() singleFileView.unmount() @@ -297,7 +350,11 @@ describe('ConfigModalFormFields', () => { it('should clear select defaults and apply uploader fallback values', () => { const selectProps = createBaseProps() - selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: 'alpha' } + selectProps.tempPayload = { + ...selectProps.tempPayload, + type: InputVarType.select, + default: 'alpha', + } selectProps.options = ['alpha', ' ', 'beta'] render(<ConfigModalFormFields {...selectProps} />) @@ -313,8 +370,12 @@ describe('ConfigModalFormFields', () => { render(<ConfigModalFormFields {...singleFallbackProps} />) expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent('[]') - expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"allowed_file_types":["document"]') - expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"allowed_file_upload_methods":["remote_url"]') + expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent( + '"allowed_file_types":["document"]', + ) + expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent( + '"allowed_file_upload_methods":["remote_url"]', + ) expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"number_limits":1') fireEvent.click(screen.getAllByTestId('upload-empty-file')[0]!) expect(singleFallbackProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined) @@ -336,14 +397,21 @@ describe('ConfigModalFormFields', () => { it('should clear number defaults and skip rendering the default selector when options are missing', () => { const numberProps = createBaseProps() - numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: '9' } + numberProps.tempPayload = { + ...numberProps.tempPayload, + type: InputVarType.number, + default: '9', + } render(<ConfigModalFormFields {...numberProps} />) fireEvent.change(screen.getByDisplayValue('9'), { target: { value: '' } }) expect(numberProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined) const selectWithoutOptionsProps = createBaseProps() - selectWithoutOptionsProps.tempPayload = { ...selectWithoutOptionsProps.tempPayload, type: InputVarType.select } + selectWithoutOptionsProps.tempPayload = { + ...selectWithoutOptionsProps.tempPayload, + type: InputVarType.select, + } selectWithoutOptionsProps.options = undefined render(<ConfigModalFormFields {...selectWithoutOptionsProps} />) @@ -353,13 +421,21 @@ describe('ConfigModalFormFields', () => { it('should preserve existing select and file defaults when present', () => { const selectProps = createBaseProps() - selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: undefined } + selectProps.tempPayload = { + ...selectProps.tempPayload, + type: InputVarType.select, + default: undefined, + } selectProps.options = ['alpha', 'beta'] render(<ConfigModalFormFields {...selectProps} />) expect(screen.getByText('ui-select:__empty__')).toBeInTheDocument() - const existingFile = { fileId: 'existing-file', type: 'local_file', url: 'https://example.com/existing.png' } + const existingFile = { + fileId: 'existing-file', + type: 'local_file', + url: 'https://example.com/existing.png', + } const singleFileProps = createBaseProps() singleFileProps.tempPayload = { ...singleFileProps.tempPayload, @@ -368,7 +444,9 @@ describe('ConfigModalFormFields', () => { } render(<ConfigModalFormFields {...singleFileProps} />) - expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent('"fileId":"existing-file"') + expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent( + '"fileId":"existing-file"', + ) const existingFiles = [ { fileId: 'file-1', type: 'local_file', url: 'https://example.com/1.png' }, @@ -390,7 +468,11 @@ describe('ConfigModalFormFields', () => { it('should render empty fallback values for text, paragraph, and number defaults', () => { const textProps = createBaseProps() textProps.isStringInput = true - textProps.tempPayload = { ...textProps.tempPayload, type: InputVarType.textInput, default: undefined } + textProps.tempPayload = { + ...textProps.tempPayload, + type: InputVarType.textInput, + default: undefined, + } const textView = render(<ConfigModalFormFields {...textProps} />) expect(screen.getAllByPlaceholderText('variableConfig.inputPlaceholder')[2]).toHaveValue('') @@ -399,7 +481,11 @@ describe('ConfigModalFormFields', () => { const paragraphProps = createBaseProps() paragraphProps.isStringInput = true - paragraphProps.tempPayload = { ...paragraphProps.tempPayload, type: InputVarType.paragraph, default: undefined } + paragraphProps.tempPayload = { + ...paragraphProps.tempPayload, + type: InputVarType.paragraph, + default: undefined, + } const paragraphView = render(<ConfigModalFormFields {...paragraphProps} />) expect(screen.getByText('config-string')).toHaveAttribute('data-max-length', 'Infinity') @@ -407,7 +493,11 @@ describe('ConfigModalFormFields', () => { paragraphView.unmount() const numberProps = createBaseProps() - numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: undefined } + numberProps.tempPayload = { + ...numberProps.tempPayload, + type: InputVarType.number, + default: undefined, + } render(<ConfigModalFormFields {...numberProps} />) expect(screen.getByRole('spinbutton')).toHaveValue(null) @@ -415,17 +505,36 @@ describe('ConfigModalFormFields', () => { it('should disable hide checkbox when required is true and disable required when hide is true', () => { const requiredProps = createBaseProps() - requiredProps.tempPayload = { ...requiredProps.tempPayload, type: InputVarType.textInput, required: true, hide: false } + requiredProps.tempPayload = { + ...requiredProps.tempPayload, + type: InputVarType.textInput, + required: true, + hide: false, + } const { unmount } = render(<ConfigModalFormFields {...requiredProps} />) - expect(screen.getByRole('checkbox', { name: 'variableConfig.hidden' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('checkbox', { name: 'variableConfig.hidden' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) unmount() const hideProps = createBaseProps() - hideProps.tempPayload = { ...hideProps.tempPayload, type: InputVarType.textInput, required: false, hide: true } + hideProps.tempPayload = { + ...hideProps.tempPayload, + type: InputVarType.textInput, + required: false, + hide: true, + } render(<ConfigModalFormFields {...hideProps} />) - expect(screen.getByRole('checkbox', { name: 'variableConfig.required' })).toHaveAttribute('aria-disabled', 'true') - expect(screen.getByRole('checkbox', { name: 'variableConfig.hidden' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('checkbox', { name: 'variableConfig.required' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(screen.getByRole('checkbox', { name: 'variableConfig.hidden' })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) }) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx index 897aecfac78aab..b42a75b3b973d4 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx @@ -22,26 +22,62 @@ vi.mock('../form-fields', () => ({ <div data-testid="payload-label">{String(props.tempPayload.label ?? '')}</div> <div data-testid="payload-schema">{String(props.tempPayload.json_schema ?? '')}</div> <div data-testid="payload-default">{String(props.tempPayload.default ?? '')}</div> - <button data-testid="invalid-key-blur" onClick={() => props.onVarKeyBlur({ target: { value: 'invalid key' } })}>invalid-key-blur</button> - <button data-testid="valid-key-blur" onClick={() => props.onVarKeyBlur({ target: { value: 'auto_label' } })}>valid-key-blur</button> + <button + data-testid="invalid-key-blur" + onClick={() => props.onVarKeyBlur({ target: { value: 'invalid key' } })} + > + invalid-key-blur + </button> + <button + data-testid="valid-key-blur" + onClick={() => props.onVarKeyBlur({ target: { value: 'auto_label' } })} + > + valid-key-blur + </button> <button data-testid="invalid-name-change" - onClick={() => props.onVarNameChange({ - target: { - value: 'invalid-key!', - selectionStart: 0, - selectionEnd: 0, - setSelectionRange: vi.fn(), - }, - })} + onClick={() => + props.onVarNameChange({ + target: { + value: 'invalid-key!', + selectionStart: 0, + selectionEnd: 0, + setSelectionRange: vi.fn(), + }, + }) + } > invalid-name-change </button> - <button data-testid="valid-json-change" onClick={() => props.onJSONSchemaChange('{\n "foo": "bar"\n}')}>valid-json-change</button> - <button data-testid="empty-json-change" onClick={() => props.onJSONSchemaChange(' ')}>empty-json-change</button> - <button data-testid="invalid-json-change" onClick={() => props.onJSONSchemaChange('{invalid-json}')}>invalid-json-change</button> - <button data-testid="type-change" onClick={() => props.onTypeChange({ value: InputVarType.singleFile })}>type-change</button> - <button data-testid="file-payload-change" onClick={() => props.onFilePayloadChange({ ...props.tempPayload, default: 'file-default' })}>file-payload-change</button> + <button + data-testid="valid-json-change" + onClick={() => props.onJSONSchemaChange('{\n "foo": "bar"\n}')} + > + valid-json-change + </button> + <button data-testid="empty-json-change" onClick={() => props.onJSONSchemaChange(' ')}> + empty-json-change + </button> + <button + data-testid="invalid-json-change" + onClick={() => props.onJSONSchemaChange('{invalid-json}')} + > + invalid-json-change + </button> + <button + data-testid="type-change" + onClick={() => props.onTypeChange({ value: InputVarType.singleFile })} + > + type-change + </button> + <button + data-testid="file-payload-change" + onClick={() => + props.onFilePayloadChange({ ...props.tempPayload, default: 'file-default' }) + } + > + file-payload-change + </button> </div> ) }, @@ -59,22 +95,20 @@ const createPayload = (overrides: Partial<InputVar> = {}): InputVar => ({ ...overrides, }) -const renderConfigModal = (payload: InputVar = createPayload()) => render( - <DebugConfigurationContext.Provider value={{ - mode: AppModeEnum.CHAT, - dataSets: [], - modelConfig: { model_id: 'model-1' }, - } as any} - > - <ConfigModal - isCreate - isShow - payload={payload} - onClose={vi.fn()} - onConfirm={vi.fn()} - /> - </DebugConfigurationContext.Provider>, -) +const renderConfigModal = (payload: InputVar = createPayload()) => + render( + <DebugConfigurationContext.Provider + value={ + { + mode: AppModeEnum.CHAT, + dataSets: [], + modelConfig: { model_id: 'model-1' }, + } as any + } + > + <ConfigModal isCreate isShow payload={payload} onClose={vi.fn()} onConfirm={vi.fn()} /> + </DebugConfigurationContext.Provider>, + ) describe('ConfigModal logic', () => { beforeEach(() => { diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx index a0679a037654eb..dfc652b42a9e3e 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx @@ -64,11 +64,14 @@ describe('ConfigModal', () => { fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated default' } }) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ - default: 'updated default', - label: 'Question', - variable: 'question', - }), undefined) + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + default: 'updated default', + label: 'Question', + variable: 'question', + }), + undefined, + ) }) it('should keep scrolling inside the form body so scrollbars do not cover dialog corners', () => { diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts b/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts index d7c2e643f1d2e1..4a2a779b3c10bc 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts @@ -74,18 +74,24 @@ describe('config-modal utils', () => { }) it('should normalize empty select defaults to undefined', () => { - const nextPayload = normalizeSelectDefaultValue(createInputVar({ - type: InputVarType.select, - default: '', - })) + const nextPayload = normalizeSelectDefaultValue( + createInputVar({ + type: InputVarType.select, + default: '', + }), + ) expect(nextPayload.default).toBeUndefined() }) it('should normalize json schema editor content', () => { - expect(getJsonSchemaEditorValue(InputVarType.jsonObject, { type: 'object' } as never)).toBe(JSON.stringify({ type: 'object' }, null, 2)) + expect(getJsonSchemaEditorValue(InputVarType.jsonObject, { type: 'object' } as never)).toBe( + JSON.stringify({ type: 'object' }, null, 2), + ) expect(getJsonSchemaEditorValue(InputVarType.textInput, '{"type":"object"}')).toBe('') - expect(getJsonSchemaEditorValue(InputVarType.jsonObject, '{"type":"object"}')).toBe('{"type":"object"}') + expect(getJsonSchemaEditorValue(InputVarType.jsonObject, '{"type":"object"}')).toBe( + '{"type":"object"}', + ) }) it('should fall back to an empty editor value when json schema serialization fails', () => { @@ -108,11 +114,13 @@ describe('config-modal utils', () => { t, }) - expect(options.map(option => option.value)).toEqual(expect.arrayContaining([ - InputVarType.singleFile, - InputVarType.multiFiles, - InputVarType.jsonObject, - ])) + expect(options.map((option) => option.value)).toEqual( + expect.arrayContaining([ + InputVarType.singleFile, + InputVarType.multiFiles, + InputVarType.jsonObject, + ]), + ) }) it('should derive checkbox defaults from boolean and string values', () => { @@ -236,10 +244,12 @@ describe('config-modal utils', () => { }) expect(result.errorMessage).toBeUndefined() - expect(result.payloadToSave).toEqual(expect.objectContaining({ - json_schema: undefined, - variable: 'question_new', - })) + expect(result.payloadToSave).toEqual( + expect.objectContaining({ + json_schema: undefined, + variable: 'question_new', + }), + ) expect(result.moreInfo).toEqual({ type: ChangeType.changeVarName, payload: { @@ -262,9 +272,11 @@ describe('config-modal utils', () => { t, }) - expect(result.payloadToSave).toEqual(expect.objectContaining({ - hide: false, - })) + expect(result.payloadToSave).toEqual( + expect.objectContaining({ + hide: false, + }), + ) }) it('should stop validation when the variable name checker rejects the payload', () => { diff --git a/web/app/components/app/configuration/config-var/config-modal/field.tsx b/web/app/components/app/configuration/config-var/config-modal/field.tsx index f45abe5bed0887..b259bed3f388a2 100644 --- a/web/app/components/app/configuration/config-var/config-modal/field.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/field.tsx @@ -11,12 +11,7 @@ type Props = Readonly<{ children: React.JSX.Element }> -const Field: FC<Props> = ({ - className, - title, - isOptional, - children, -}) => { +const Field: FC<Props> = ({ className, title, isOptional, children }) => { const { t } = useTranslation() return ( <div className={cn(className)}> @@ -24,9 +19,7 @@ const Field: FC<Props> = ({ {title} {isOptional && ( <span className="ml-1 system-xs-regular text-text-tertiary"> - ( - {t($ => $['variableConfig.optional'], { ns: 'appDebug' })} - ) + ({t(($) => $['variableConfig.optional'], { ns: 'appDebug' })}) </span> )} </div> diff --git a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx index b6f980360e61ee..43563dff4d9f32 100644 --- a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx @@ -77,32 +77,34 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({ const { type, label, variable } = tempPayload const isFileInput = [InputVarType.singleFile, InputVarType.multiFiles].includes(type) const docLink = useDocLink() - const hiddenDescriptionAriaLabel = t($ => $['variableConfig.hiddenDescription'], { ns: 'appDebug' }).replace(/<[^>]+>/g, '') + const hiddenDescriptionAriaLabel = t(($) => $['variableConfig.hiddenDescription'], { + ns: 'appDebug', + }).replace(/<[^>]+>/g, '') return ( <div className="space-y-2"> - <Field title={t($ => $['variableConfig.fieldType'], { ns: 'appDebug' })}> + <Field title={t(($) => $['variableConfig.fieldType'], { ns: 'appDebug' })}> <TypeSelector value={type} items={selectOptions} onSelect={onTypeChange} /> </Field> - <Field title={t($ => $['variableConfig.varName'], { ns: 'appDebug' })}> + <Field title={t(($) => $['variableConfig.varName'], { ns: 'appDebug' })}> <Input value={variable} onChange={onVarNameChange} onBlur={onVarKeyBlur} - placeholder={t($ => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' })} + placeholder={t(($) => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' })} /> </Field> - <Field title={t($ => $['variableConfig.labelName'], { ns: 'appDebug' })}> + <Field title={t(($) => $['variableConfig.labelName'], { ns: 'appDebug' })}> <Input value={label as string} - onChange={e => onPayloadChange('label')(e.target.value)} - placeholder={t($ => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' })} + onChange={(e) => onPayloadChange('label')(e.target.value)} + placeholder={t(($) => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' })} /> </Field> {isStringInput && ( - <Field title={t($ => $['variableConfig.maxLength'], { ns: 'appDebug' })}> + <Field title={t(($) => $['variableConfig.maxLength'], { ns: 'appDebug' })}> <ConfigString maxLength={type === InputVarType.textInput ? TEXT_MAX_LENGTH : Infinity} modelId={modelId} @@ -113,50 +115,65 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({ )} {type === InputVarType.textInput && ( - <Field title={t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' })}> + <Field title={t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' })}> <Input value={typeof tempPayload.default === 'string' ? tempPayload.default : ''} - onChange={e => onPayloadChange('default')(e.target.value || undefined)} - placeholder={t($ => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' })} + onChange={(e) => onPayloadChange('default')(e.target.value || undefined)} + placeholder={t(($) => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' })} /> </Field> )} {type === InputVarType.paragraph && ( - <Field title={t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' })}> + <Field title={t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' })}> <Textarea - aria-label={t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' })} + aria-label={t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' })} value={String(tempPayload.default ?? '')} - onValueChange={value => onPayloadChange('default')(value || undefined)} - placeholder={t($ => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' })} + onValueChange={(value) => onPayloadChange('default')(value || undefined)} + placeholder={t(($) => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' })} /> </Field> )} {type === InputVarType.number && ( - <Field title={t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' })}> + <Field title={t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' })}> <Input type="number" - value={typeof tempPayload.default === 'number' || typeof tempPayload.default === 'string' ? tempPayload.default : ''} - onChange={e => onPayloadChange('default')(e.target.value || undefined)} - placeholder={t($ => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' })} + value={ + typeof tempPayload.default === 'number' || typeof tempPayload.default === 'string' + ? tempPayload.default + : '' + } + onChange={(e) => onPayloadChange('default')(e.target.value || undefined)} + placeholder={t(($) => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' })} /> </Field> )} {type === InputVarType.checkbox && ( - <Field title={t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' })}> - <Select value={checkboxDefaultSelectValue} onValueChange={value => onPayloadChange('default')(value === CHECKBOX_DEFAULT_TRUE_VALUE)}> + <Field title={t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' })}> + <Select + value={checkboxDefaultSelectValue} + onValueChange={(value) => + onPayloadChange('default')(value === CHECKBOX_DEFAULT_TRUE_VALUE) + } + > <SelectTrigger size="large" className="w-full"> - <SelectValue placeholder={t($ => $['variableConfig.selectDefaultValue'], { ns: 'appDebug' })} /> + <SelectValue + placeholder={t(($) => $['variableConfig.selectDefaultValue'], { ns: 'appDebug' })} + /> </SelectTrigger> <SelectContent listClassName="max-h-[140px] overflow-y-auto"> <SelectItem value={CHECKBOX_DEFAULT_TRUE_VALUE}> - <SelectItemText>{t($ => $['variableConfig.startChecked'], { ns: 'appDebug' })}</SelectItemText> + <SelectItemText> + {t(($) => $['variableConfig.startChecked'], { ns: 'appDebug' })} + </SelectItemText> <SelectItemIndicator /> </SelectItem> <SelectItem value={CHECKBOX_DEFAULT_FALSE_VALUE}> - <SelectItemText>{t($ => $['variableConfig.noDefaultSelected'], { ns: 'appDebug' })}</SelectItemText> + <SelectItemText> + {t(($) => $['variableConfig.noDefaultSelected'], { ns: 'appDebug' })} + </SelectItemText> <SelectItemIndicator /> </SelectItem> </SelectContent> @@ -166,34 +183,43 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({ {type === InputVarType.select && ( <> - <Field title={t($ => $['variableConfig.options'], { ns: 'appDebug' })}> + <Field title={t(($) => $['variableConfig.options'], { ns: 'appDebug' })}> <ConfigSelect options={options || []} onChange={onPayloadChange('options')} /> </Field> {options && options.length > 0 && ( - <Field title={t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' })}> + <Field title={t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' })}> <Select<string> key={`default-select-${options.join('-')}`} - value={typeof tempPayload.default === 'string' ? tempPayload.default : EMPTY_SELECT_VALUE} + value={ + typeof tempPayload.default === 'string' ? tempPayload.default : EMPTY_SELECT_VALUE + } onValueChange={(value) => { - if (value == null) - return + if (value == null) return onPayloadChange('default')(value === EMPTY_SELECT_VALUE ? undefined : value) }} > <SelectTrigger size="large" className="w-full"> - <SelectValue placeholder={t($ => $['variableConfig.selectDefaultValue'], { ns: 'appDebug' })} /> + <SelectValue + placeholder={t(($) => $['variableConfig.selectDefaultValue'], { + ns: 'appDebug', + })} + /> </SelectTrigger> <SelectContent listClassName="max-h-[140px] overflow-y-auto"> <SelectItem value={EMPTY_SELECT_VALUE}> - <SelectItemText>{t($ => $['variableConfig.noDefaultValue'], { ns: 'appDebug' })}</SelectItemText> + <SelectItemText> + {t(($) => $['variableConfig.noDefaultValue'], { ns: 'appDebug' })} + </SelectItemText> <SelectItemIndicator /> </SelectItem> - {options.filter(option => option.trim() !== '').map(option => ( - <SelectItem key={option} value={option}> - <SelectItemText>{option}</SelectItemText> - <SelectItemIndicator /> - </SelectItem> - ))} + {options + .filter((option) => option.trim() !== '') + .map((option) => ( + <SelectItem key={option} value={option}> + <SelectItemText>{option}</SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ))} </SelectContent> </Select> </Field> @@ -208,19 +234,28 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({ onChange={onFilePayloadChange} isMultiple={type === InputVarType.multiFiles} /> - <Field title={t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' })}> + <Field title={t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' })}> <FileUploaderInAttachmentWrapper - value={(type === InputVarType.singleFile ? (tempPayload.default ? [tempPayload.default] : []) : (tempPayload.default || [])) as unknown as FileEntity[]} + value={ + (type === InputVarType.singleFile + ? tempPayload.default + ? [tempPayload.default] + : [] + : tempPayload.default || []) as unknown as FileEntity[] + } onChange={(files) => { if (type === InputVarType.singleFile) onPayloadChange('default')(files?.[0] || undefined) - else - onPayloadChange('default')(files || undefined) + else onPayloadChange('default')(files || undefined) }} fileConfig={{ - allowed_file_types: tempPayload.allowed_file_types || [SupportUploadFileTypes.document], + allowed_file_types: tempPayload.allowed_file_types || [ + SupportUploadFileTypes.document, + ], allowed_file_extensions: tempPayload.allowed_file_extensions || [], - allowed_file_upload_methods: tempPayload.allowed_file_upload_methods || [TransferMethod.remote_url], + allowed_file_upload_methods: tempPayload.allowed_file_upload_methods || [ + TransferMethod.remote_url, + ], number_limits: type === InputVarType.singleFile ? 1 : tempPayload.max_length || 5, }} /> @@ -229,7 +264,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({ )} {type === InputVarType.jsonObject && ( - <Field title={t($ => $['variableConfig.jsonSchema'], { ns: 'appDebug' })} isOptional> + <Field title={t(($) => $['variableConfig.jsonSchema'], { ns: 'appDebug' })} isOptional> <CodeEditor language={CodeLanguage.json} value={jsonSchemaStr} @@ -245,9 +280,11 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({ <Checkbox checked={tempPayload.required} disabled={!isFileInput && tempPayload.hide} - onCheckedChange={checked => onPayloadChange('required')(checked)} + onCheckedChange={(checked) => onPayloadChange('required')(checked)} /> - <span className="system-sm-semibold text-text-secondary">{t($ => $['variableConfig.required'], { ns: 'appDebug' })}</span> + <span className="system-sm-semibold text-text-secondary"> + {t(($) => $['variableConfig.required'], { ns: 'appDebug' })} + </span> </label> {showHiddenField && !isFileInput && ( @@ -256,17 +293,16 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({ <Checkbox checked={tempPayload.hide} disabled={tempPayload.required} - onCheckedChange={checked => onPayloadChange('hide')(checked)} + onCheckedChange={(checked) => onPayloadChange('hide')(checked)} /> - <span className="system-sm-semibold text-text-secondary">{t($ => $['variableConfig.hidden'], { ns: 'appDebug' })}</span> + <span className="system-sm-semibold text-text-secondary"> + {t(($) => $['variableConfig.hidden'], { ns: 'appDebug' })} + </span> </label> <div className="flex items-center gap-1"> - <Infotip - aria-label={hiddenDescriptionAriaLabel} - popupClassName="max-w-[300px]" - > + <Infotip aria-label={hiddenDescriptionAriaLabel} popupClassName="max-w-[300px]"> <Trans - i18nKey={$ => $['variableConfig.hiddenDescription']} + i18nKey={($) => $['variableConfig.hiddenDescription']} ns="appDebug" components={{ docLink: ( diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index 0adb9124d2b8fb..b5082af848d436 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -11,7 +11,11 @@ import { useContext } from 'use-context-selector' import { useStore as useAppStore } from '@/app/components/app/store' import ConfigContext from '@/context/debug-configuration' import { AppModeEnum } from '@/types/app' -import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var' +import { + checkKeys, + getNewVarInWorkflow, + replaceSpaceWithUnderscoreInVarNameInput, +} from '@/utils/var' import ModalFoot from '../modal-foot' import ConfigModalFormFields from './form-fields' import { @@ -47,83 +51,112 @@ const ConfigModal: FC<IConfigModalProps> = ({ }) => { const { modelConfig } = useContext(ConfigContext) const { t } = useTranslation() - const [tempPayload, setTempPayload] = useState<InputVar>(() => normalizeSelectDefaultValue(payload || getNewVarInWorkflow('') as any)) + const [tempPayload, setTempPayload] = useState<InputVar>(() => + normalizeSelectDefaultValue(payload || (getNewVarInWorkflow('') as any)), + ) const { type, options, max_length } = tempPayload const modalRef = useRef<HTMLDivElement>(null) - const appDetail = useAppStore(state => state.appDetail) - const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW - const jsonSchemaStr = useMemo(() => getJsonSchemaEditorValue(type, tempPayload.json_schema), [tempPayload.json_schema, type]) + const appDetail = useAppStore((state) => state.appDetail) + const isBasicApp = + appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW + const jsonSchemaStr = useMemo( + () => getJsonSchemaEditorValue(type, tempPayload.json_schema), + [tempPayload.json_schema, type], + ) useEffect(() => { // To fix the first input element auto focus, then directly close modal will raise error - if (isShow) - modalRef.current?.focus() + if (isShow) modalRef.current?.focus() }, [isShow]) const isStringInput = isStringInputType(type) - const checkVariableName = useCallback((value: string, canBeEmpty?: boolean) => { - const { isValid, errorMessageKey } = checkKeys([value], canBeEmpty) - if (!isValid) { - toast.error(t($ => $[`varKeyError.${errorMessageKey}`], { ns: 'appDebug', key: t($ => $['variableConfig.varName'], { ns: 'appDebug' }) })) - return false - } - return true - }, [t]) + const checkVariableName = useCallback( + (value: string, canBeEmpty?: boolean) => { + const { isValid, errorMessageKey } = checkKeys([value], canBeEmpty) + if (!isValid) { + toast.error( + t(($) => $[`varKeyError.${errorMessageKey}`], { + ns: 'appDebug', + key: t(($) => $['variableConfig.varName'], { ns: 'appDebug' }), + }), + ) + return false + } + return true + }, + [t], + ) const handlePayloadChange = useCallback((key: string) => { return (value: any) => { - setTempPayload(prev => updatePayloadField(prev, key, value)) + setTempPayload((prev) => updatePayloadField(prev, key, value)) } }, []) - const handleJSONSchemaChange = useCallback((value: string) => { - const isEmpty = value == null || value.trim() === '' - if (isEmpty) { - handlePayloadChange('json_schema')(undefined) - return null - } - try { - const v = JSON.parse(value) - handlePayloadChange('json_schema')(JSON.stringify(v, null, 2)) - } - catch { - return null - } - }, [handlePayloadChange]) + const handleJSONSchemaChange = useCallback( + (value: string) => { + const isEmpty = value == null || value.trim() === '' + if (isEmpty) { + handlePayloadChange('json_schema')(undefined) + return null + } + try { + const v = JSON.parse(value) + handlePayloadChange('json_schema')(JSON.stringify(v, null, 2)) + } catch { + return null + } + }, + [handlePayloadChange], + ) - const selectOptions: SelectItem[] = useMemo(() => buildSelectOptions({ - isBasicApp, - supportFile, - t, - }), [isBasicApp, supportFile, t]) + const selectOptions: SelectItem[] = useMemo( + () => + buildSelectOptions({ + isBasicApp, + supportFile, + t, + }), + [isBasicApp, supportFile, t], + ) const handleTypeChange = useCallback((item: SelectItem) => { - setTempPayload(prev => createPayloadForType(prev, item.value as InputVarType)) + setTempPayload((prev) => createPayloadForType(prev, item.value as InputVarType)) }, []) - const handleVarKeyBlur = useCallback((e: any) => { - const varName = e.target.value - if (!checkVariableName(varName, true) || tempPayload.label) - return + const handleVarKeyBlur = useCallback( + (e: any) => { + const varName = e.target.value + if (!checkVariableName(varName, true) || tempPayload.label) return - setTempPayload((prev) => { - return { - ...prev, - label: varName, - } - }) - }, [checkVariableName, tempPayload.label]) + setTempPayload((prev) => { + return { + ...prev, + label: varName, + } + }) + }, + [checkVariableName, tempPayload.label], + ) - const handleVarNameChange = useCallback((e: ChangeEvent<any>) => { - replaceSpaceWithUnderscoreInVarNameInput(e.target) - const value = e.target.value - const { isValid, errorKey, errorMessageKey } = checkKeys([value], true) - if (!isValid) { - toast.error(t($ => $[`varKeyError.${errorMessageKey}`], { ns: 'appDebug', key: errorKey })) - return - } - handlePayloadChange('variable')(e.target.value) - }, [handlePayloadChange, t]) + const handleVarNameChange = useCallback( + (e: ChangeEvent<any>) => { + replaceSpaceWithUnderscoreInVarNameInput(e.target) + const value = e.target.value + const { isValid, errorKey, errorMessageKey } = checkKeys([value], true) + if (!isValid) { + toast.error( + t(($) => $[`varKeyError.${errorMessageKey}`], { ns: 'appDebug', key: errorKey }), + ) + return + } + handlePayloadChange('variable')(e.target.value) + }, + [handlePayloadChange, t], + ) - const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default]) + const checkboxDefaultSelectValue = useMemo( + () => getCheckboxDefaultSelectValue(tempPayload.default), + [tempPayload.default], + ) const handleConfirm = () => { const { errorMessage, moreInfo, payloadToSave } = validateConfigModalPayload({ @@ -138,21 +171,21 @@ const ConfigModal: FC<IConfigModalProps> = ({ return } - if (payloadToSave) - onConfirm(payloadToSave, moreInfo) + if (payloadToSave) onConfirm(payloadToSave, moreInfo) } return ( <Dialog open={isShow} onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden! border-none p-0! text-left align-middle"> <DialogTitle className="shrink-0 px-6 pt-6 title-2xl-semi-bold text-text-primary"> - {t($ => $[`variableConfig.${isCreate ? 'addModalTitle' : 'editModalTitle'}`], { ns: 'appDebug' })} + {t(($) => $[`variableConfig.${isCreate ? 'addModalTitle' : 'editModalTitle'}`], { + ns: 'appDebug', + })} </DialogTitle> <div @@ -167,7 +200,7 @@ const ConfigModal: FC<IConfigModalProps> = ({ jsonSchemaStr={jsonSchemaStr} maxLength={max_length} modelId={modelConfig.model_id} - onFilePayloadChange={payload => setTempPayload(payload as InputVar)} + onFilePayloadChange={(payload) => setTempPayload(payload as InputVar)} onJSONSchemaChange={handleJSONSchemaChange} onPayloadChange={handlePayloadChange} onTypeChange={handleTypeChange} @@ -181,10 +214,7 @@ const ConfigModal: FC<IConfigModalProps> = ({ /> </div> <div className="shrink-0 px-6 pt-2 pb-6"> - <ModalFoot - onConfirm={handleConfirm} - onCancel={onClose} - /> + <ModalFoot onConfirm={handleConfirm} onCancel={onClose} /> </div> </DialogContent> </Dialog> diff --git a/web/app/components/app/configuration/config-var/config-modal/type-select.tsx b/web/app/components/app/configuration/config-var/config-modal/type-select.tsx index 26d1b5c4a81d1c..0d62abd699a467 100644 --- a/web/app/components/app/configuration/config-var/config-modal/type-select.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/type-select.tsx @@ -29,23 +29,16 @@ type Props = Readonly<{ readonly?: boolean hideChecked?: boolean }> -const TypeSelector: FC<Props> = ({ - value, - onSelect, - items, - popupInnerClassName, - readonly, -}) => { - const selectedItem = value ? items.find(item => item.value === value) : undefined +const TypeSelector: FC<Props> = ({ value, onSelect, items, popupInnerClassName, readonly }) => { + const selectedItem = value ? items.find((item) => item.value === value) : undefined return ( <Select value={selectedItem?.value} readOnly={readonly} onValueChange={(nextValue) => { - const selected = items.find(item => item.value === nextValue) - if (selected) - onSelect(selected) + const selected = items.find((item) => item.value === nextValue) + if (selected) onSelect(selected) }} > <SelectTrigger @@ -57,7 +50,10 @@ const TypeSelector: FC<Props> = ({ > <div className="flex min-w-0 items-center justify-between"> <div className="flex items-center"> - <InputVarTypeIcon type={selectedItem?.value as InputVarType} className="size-4 shrink-0 text-text-secondary" /> + <InputVarTypeIcon + type={selectedItem?.value as InputVarType} + className="size-4 shrink-0 text-text-secondary" + /> <span className={cn( 'ml-1.5 truncate text-components-input-text-filled', @@ -68,13 +64,18 @@ const TypeSelector: FC<Props> = ({ </span> </div> <div className="ml-2 flex shrink-0 items-center space-x-1"> - <Badge uppercase={false}>{inputVarTypeToVarType(selectedItem?.value as InputVarType)}</Badge> + <Badge uppercase={false}> + {inputVarTypeToVarType(selectedItem?.value as InputVarType)} + </Badge> </div> </div> </SelectTrigger> <SelectContent sideOffset={4} - popupClassName={cn('w-(--anchor-width) rounded-md px-1 py-1 text-base sm:text-sm', popupInnerClassName)} + popupClassName={cn( + 'w-(--anchor-width) rounded-md px-1 py-1 text-base sm:text-sm', + popupInnerClassName, + )} listClassName="max-h-80 p-0" > {items.map((item: Item) => ( @@ -84,9 +85,7 @@ const TypeSelector: FC<Props> = ({ className="h-9 justify-between px-2 text-text-secondary" title={item.name} > - <SelectItemText - className="flex items-center space-x-2 px-0" - > + <SelectItemText className="flex items-center space-x-2 px-0"> <InputVarTypeIcon type={item.value} className="size-4 shrink-0 text-text-secondary" /> <span title={item.name}>{item.name}</span> </SelectItemText> diff --git a/web/app/components/app/configuration/config-var/config-modal/utils.ts b/web/app/components/app/configuration/config-var/config-modal/utils.ts index 46c98964e1b4ca..e906d05cacac15 100644 --- a/web/app/components/app/configuration/config-var/config-modal/utils.ts +++ b/web/app/components/app/configuration/config-var/config-modal/utils.ts @@ -30,7 +30,9 @@ export const getCheckboxDefaultSelectValue = (value: InputVar['default'] | boole if (typeof value === 'boolean') return value ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE if (typeof value === 'string') - return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE + return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE + ? CHECKBOX_DEFAULT_TRUE_VALUE + : CHECKBOX_DEFAULT_FALSE_VALUE return CHECKBOX_DEFAULT_FALSE_VALUE } export const normalizeSelectDefaultValue = (inputVar: InputVar) => { @@ -39,26 +41,24 @@ export const normalizeSelectDefaultValue = (inputVar: InputVar) => { return inputVar } -export const getJsonSchemaEditorValue = (type: InputVarType, jsonSchema?: InputVar['json_schema']) => { - if (type !== InputVarType.jsonObject || !jsonSchema) - return '' +export const getJsonSchemaEditorValue = ( + type: InputVarType, + jsonSchema?: InputVar['json_schema'], +) => { + if (type !== InputVarType.jsonObject || !jsonSchema) return '' try { - if (typeof jsonSchema !== 'string') - return JSON.stringify(jsonSchema, null, 2) + if (typeof jsonSchema !== 'string') return JSON.stringify(jsonSchema, null, 2) return jsonSchema - } - catch { + } catch { return '' } } export const isJsonSchemaEmpty = (value: InputVar['json_schema']) => { - if (value === null || value === undefined) - return true - if (typeof value !== 'string') - return false + if (value === null || value === undefined) return true + if (typeof value !== 'string') return false return value.trim() === '' } @@ -70,8 +70,7 @@ export const updatePayloadField = (payload: InputVar, key: string, value: unknow if (key === 'options' && payload.default) { const options = Array.isArray(value) ? value : [] - if (!options.includes(payload.default)) - nextPayload.default = undefined + if (!options.includes(payload.default)) nextPayload.default = undefined } return nextPayload @@ -80,15 +79,15 @@ export const updatePayloadField = (payload: InputVar, key: string, value: unknow export const createPayloadForType = (payload: InputVar, type: InputVarType) => { return produce(payload, (draft) => { draft.type = type - if (type === InputVarType.select) - draft.default = undefined + if (type === InputVarType.select) draft.default = undefined if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) { draft.hide = false - const fileUploadSettingKeys = Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array<keyof typeof DEFAULT_FILE_UPLOAD_SETTING> + const fileUploadSettingKeys = Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array< + keyof typeof DEFAULT_FILE_UPLOAD_SETTING + > fileUploadSettingKeys.forEach((key) => { - if (key !== 'max_length') - draft[key] = DEFAULT_FILE_UPLOAD_SETTING[key] as never + if (key !== 'max_length') draft[key] = DEFAULT_FILE_UPLOAD_SETTING[key] as never }) if (type === InputVarType.multiFiles) @@ -109,33 +108,33 @@ export const buildSelectOptions = ({ const t = getStringSelectorTranslate(rawTranslate) return [ { - name: t($ => $['variableConfig.text-input'], { ns: 'appDebug' }), + name: t(($) => $['variableConfig.text-input'], { ns: 'appDebug' }), value: InputVarType.textInput, }, { - name: t($ => $['variableConfig.paragraph'], { ns: 'appDebug' }), + name: t(($) => $['variableConfig.paragraph'], { ns: 'appDebug' }), value: InputVarType.paragraph, }, { - name: t($ => $['variableConfig.select'], { ns: 'appDebug' }), + name: t(($) => $['variableConfig.select'], { ns: 'appDebug' }), value: InputVarType.select, }, { - name: t($ => $['variableConfig.number'], { ns: 'appDebug' }), + name: t(($) => $['variableConfig.number'], { ns: 'appDebug' }), value: InputVarType.number, }, { - name: t($ => $['variableConfig.checkbox'], { ns: 'appDebug' }), + name: t(($) => $['variableConfig.checkbox'], { ns: 'appDebug' }), value: InputVarType.checkbox, }, ...(supportFile ? [ { - name: t($ => $['variableConfig.single-file'], { ns: 'appDebug' }), + name: t(($) => $['variableConfig.single-file'], { ns: 'appDebug' }), value: InputVarType.singleFile, }, { - name: t($ => $['variableConfig.multi-files'], { ns: 'appDebug' }), + name: t(($) => $['variableConfig.multi-files'], { ns: 'appDebug' }), value: InputVarType.multiFiles, }, ] @@ -143,7 +142,7 @@ export const buildSelectOptions = ({ ...(!isBasicApp ? [ { - name: t($ => $['variableConfig.json'], { ns: 'appDebug' }), + name: t(($) => $['variableConfig.json'], { ns: 'appDebug' }), value: InputVarType.jsonObject, }, ] @@ -158,43 +157,45 @@ export const validateConfigModalPayload = ({ t: rawTranslate, }: ValidateConfigModalPayloadOptions): ValidateConfigModalPayloadResult => { const t = getStringSelectorTranslate(rawTranslate) - const normalizedTempPayload = [InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type) + const normalizedTempPayload = [InputVarType.singleFile, InputVarType.multiFiles].includes( + tempPayload.type, + ) ? { ...tempPayload, hide: false } : tempPayload const jsonSchemaValue = tempPayload.json_schema const schemaEmpty = isJsonSchemaEmpty(jsonSchemaValue) const normalizedJsonSchema = schemaEmpty ? undefined : jsonSchemaValue - const payloadToSave = normalizedTempPayload.type === InputVarType.jsonObject && schemaEmpty - ? { ...normalizedTempPayload, json_schema: undefined } - : normalizedTempPayload - - const moreInfo = normalizedTempPayload.variable === payload?.variable - ? undefined - : { - type: ChangeType.changeVarName, - payload: { beforeKey: payload?.variable || '', afterKey: normalizedTempPayload.variable }, - } + const payloadToSave = + normalizedTempPayload.type === InputVarType.jsonObject && schemaEmpty + ? { ...normalizedTempPayload, json_schema: undefined } + : normalizedTempPayload - if (!checkVariableName(normalizedTempPayload.variable)) - return {} + const moreInfo = + normalizedTempPayload.variable === payload?.variable + ? undefined + : { + type: ChangeType.changeVarName, + payload: { beforeKey: payload?.variable || '', afterKey: normalizedTempPayload.variable }, + } + + if (!checkVariableName(normalizedTempPayload.variable)) return {} if (!normalizedTempPayload.label) { return { - errorMessage: t($ => $['variableConfig.errorMsg.labelNameRequired'], { ns: 'appDebug' }), + errorMessage: t(($) => $['variableConfig.errorMsg.labelNameRequired'], { ns: 'appDebug' }), } } if (normalizedTempPayload.type === InputVarType.select) { if (!normalizedTempPayload.options?.length) { return { - errorMessage: t($ => $['variableConfig.errorMsg.atLeastOneOption'], { ns: 'appDebug' }), + errorMessage: t(($) => $['variableConfig.errorMsg.atLeastOneOption'], { ns: 'appDebug' }), } } const duplicated = new Set<string>() const hasRepeatedItem = normalizedTempPayload.options.some((option) => { - if (duplicated.has(option)) - return true + if (duplicated.has(option)) return true duplicated.add(option) return false @@ -202,7 +203,7 @@ export const validateConfigModalPayload = ({ if (hasRepeatedItem) { return { - errorMessage: t($ => $['variableConfig.errorMsg.optionRepeat'], { ns: 'appDebug' }), + errorMessage: t(($) => $['variableConfig.errorMsg.optionRepeat'], { ns: 'appDebug' }), } } } @@ -210,35 +211,43 @@ export const validateConfigModalPayload = ({ if ([InputVarType.singleFile, InputVarType.multiFiles].includes(normalizedTempPayload.type)) { if (!normalizedTempPayload.allowed_file_types?.length) { return { - errorMessage: t($ => $['errorMsg.fieldRequired'], { + errorMessage: t(($) => $['errorMsg.fieldRequired'], { ns: 'workflow', - field: t($ => $['variableConfig.file.supportFileTypes'], { ns: 'appDebug' }), + field: t(($) => $['variableConfig.file.supportFileTypes'], { ns: 'appDebug' }), }), } } - if (normalizedTempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !normalizedTempPayload.allowed_file_extensions?.length) { + if ( + normalizedTempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && + !normalizedTempPayload.allowed_file_extensions?.length + ) { return { - errorMessage: t($ => $['errorMsg.fieldRequired'], { + errorMessage: t(($) => $['errorMsg.fieldRequired'], { ns: 'workflow', - field: t($ => $['variableConfig.file.custom.name'], { ns: 'appDebug' }), + field: t(($) => $['variableConfig.file.custom.name'], { ns: 'appDebug' }), }), } } } - if (normalizedTempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') { + if ( + normalizedTempPayload.type === InputVarType.jsonObject && + !schemaEmpty && + typeof normalizedJsonSchema === 'string' + ) { try { const schema = JSON.parse(normalizedJsonSchema) if (schema?.type !== 'object') { return { - errorMessage: t($ => $['variableConfig.errorMsg.jsonSchemaMustBeObject'], { ns: 'appDebug' }), + errorMessage: t(($) => $['variableConfig.errorMsg.jsonSchemaMustBeObject'], { + ns: 'appDebug', + }), } } - } - catch { + } catch { return { - errorMessage: t($ => $['variableConfig.errorMsg.jsonSchemaInvalid'], { ns: 'appDebug' }), + errorMessage: t(($) => $['variableConfig.errorMsg.jsonSchemaInvalid'], { ns: 'appDebug' }), } } } diff --git a/web/app/components/app/configuration/config-var/config-select/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-select/__tests__/index.spec.tsx index 24517eb341bef7..2f0c547a8c0817 100644 --- a/web/app/components/app/configuration/config-var/config-select/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-select/__tests__/index.spec.tsx @@ -8,8 +8,8 @@ vi.mock('react-sortablejs', () => ({ setList, }: { children: React.ReactNode - list: Array<{ id: number, name: string }> - setList: (list: Array<{ id: number, name: string }>) => void + list: Array<{ id: number; name: string }> + setList: (list: Array<{ id: number; name: string }>) => void }) => ( <div> <button onClick={() => setList([...list].reverse())}>reorder-options</button> @@ -83,8 +83,7 @@ describe('ConfigSelect Component', () => { const optionContainer = screen.getByDisplayValue('Option 1').closest('div') const deleteButton = screen.getAllByRole('button', { name: 'common.operation.delete' })[0] - if (!deleteButton) - return + if (!deleteButton) return fireEvent.mouseEnter(deleteButton) expect(optionContainer).toHaveClass('border-components-input-border-destructive') fireEvent.mouseLeave(deleteButton) diff --git a/web/app/components/app/configuration/config-var/config-select/index.tsx b/web/app/components/app/configuration/config-var/config-select/index.tsx index 044800fb1c1826..6e289bf5bc54bc 100644 --- a/web/app/components/app/configuration/config-var/config-select/index.tsx +++ b/web/app/components/app/configuration/config-var/config-select/index.tsx @@ -13,19 +13,16 @@ type IConfigSelectProps = { onChange: (options: Options) => void } -const ConfigSelect: FC<IConfigSelectProps> = ({ - options, - onChange, -}) => { +const ConfigSelect: FC<IConfigSelectProps> = ({ options, onChange }) => { const { t } = useTranslation() const [focusID, setFocusID] = useState<number | null>(null) const [deletingID, setDeletingID] = useState<number | null>(null) const optionList = options.map((content, index) => { - return ({ + return { id: index, name: content, - }) + } }) return ( @@ -35,7 +32,7 @@ const ConfigSelect: FC<IConfigSelectProps> = ({ <ReactSortable className="space-y-1" list={optionList} - setList={list => onChange(list.map(item => item.name))} + setList={(list) => onChange(list.map((item) => item.name))} handle=".handle" ghostClass="opacity-50" animation={150} @@ -44,8 +41,10 @@ const ConfigSelect: FC<IConfigSelectProps> = ({ <div className={cn( 'group relative flex items-center rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 hover:bg-components-panel-on-panel-item-bg-hover', - focusID === index && 'border-components-input-border-active bg-components-input-bg-active hover:border-components-input-border-active hover:bg-components-input-bg-active', - deletingID === index && 'border-components-input-border-destructive bg-state-destructive-hover hover:border-components-input-border-destructive hover:bg-state-destructive-hover', + focusID === index && + 'border-components-input-border-active bg-components-input-bg-active hover:border-components-input-border-active hover:bg-components-input-bg-active', + deletingID === index && + 'border-components-input-border-destructive bg-state-destructive-hover hover:border-components-input-border-destructive hover:bg-state-destructive-hover', )} key={index} > @@ -56,12 +55,13 @@ const ConfigSelect: FC<IConfigSelectProps> = ({ value={o || ''} onChange={(e) => { const value = e.target.value - onChange(options.map((item, i) => { - if (index === i) - return value + onChange( + options.map((item, i) => { + if (index === i) return value - return item - })) + return item + }), + ) }} className="h-9 w-full grow cursor-pointer overflow-x-auto rounded-lg border-0 bg-transparent pr-8 pl-1.5 text-sm/9 text-text-secondary focus:outline-hidden" onFocus={() => setFocusID(index)} @@ -69,7 +69,7 @@ const ConfigSelect: FC<IConfigSelectProps> = ({ /> <button type="button" - aria-label={t($ => $['operation.delete'], { ns: 'common' })} + aria-label={t(($) => $['operation.delete'], { ns: 'common' })} className="absolute top-1/2 right-1.5 block translate-y-[-50%] cursor-pointer rounded-md border-none bg-transparent p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden" onClick={() => { onChange(options.filter((_, i) => index !== i)) @@ -87,11 +87,15 @@ const ConfigSelect: FC<IConfigSelectProps> = ({ )} <div - onClick={() => { onChange([...options, '']) }} + onClick={() => { + onChange([...options, '']) + }} className="mt-1 flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-components-button-tertiary-bg px-3 text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover" > <RiAddLine className="size-4" /> - <div className="system-sm-medium text-[13px]">{t($ => $['variableConfig.addOption'], { ns: 'appDebug' })}</div> + <div className="system-sm-medium text-[13px]"> + {t(($) => $['variableConfig.addOption'], { ns: 'appDebug' })} + </div> </div> </div> ) diff --git a/web/app/components/app/configuration/config-var/config-string/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-string/__tests__/index.spec.tsx index b83a27d670bd8f..85d2027027d998 100644 --- a/web/app/components/app/configuration/config-var/config-string/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-string/__tests__/index.spec.tsx @@ -43,14 +43,7 @@ describe('ConfigString', () => { describe('Effect behavior', () => { it('should clamp initial value to maxLength when it exceeds limit', async () => { const onChange = vi.fn() - render( - <ConfigString - value={15} - maxLength={10} - modelId="model-id" - onChange={onChange} - />, - ) + render(<ConfigString value={15} maxLength={10} modelId="model-id" onChange={onChange} />) await waitFor(() => { expect(onChange).toHaveBeenCalledWith(10) @@ -61,22 +54,10 @@ describe('ConfigString', () => { it('should clamp when updated prop value exceeds maxLength', async () => { const onChange = vi.fn() const { rerender } = render( - <ConfigString - value={4} - maxLength={6} - modelId="model-id" - onChange={onChange} - />, + <ConfigString value={4} maxLength={6} modelId="model-id" onChange={onChange} />, ) - rerender( - <ConfigString - value={9} - maxLength={6} - modelId="model-id" - onChange={onChange} - />, - ) + rerender(<ConfigString value={9} maxLength={6} modelId="model-id" onChange={onChange} />) await waitFor(() => { expect(onChange).toHaveBeenCalledWith(6) diff --git a/web/app/components/app/configuration/config-var/config-string/index.tsx b/web/app/components/app/configuration/config-var/config-string/index.tsx index fce687ac3e7154..82fdcd37c7b022 100644 --- a/web/app/components/app/configuration/config-var/config-string/index.tsx +++ b/web/app/components/app/configuration/config-var/config-string/index.tsx @@ -11,14 +11,9 @@ export type IConfigStringProps = { onChange: (value: number | undefined) => void } -const ConfigString: FC<IConfigStringProps> = ({ - value, - onChange, - maxLength, -}) => { +const ConfigString: FC<IConfigStringProps> = ({ value, onChange, maxLength }) => { useEffect(() => { - if (value && value > maxLength) - onChange(maxLength) + if (value && value > maxLength) onChange(maxLength) }, [value, maxLength, onChange]) return ( @@ -30,11 +25,8 @@ const ConfigString: FC<IConfigStringProps> = ({ value={value || ''} onChange={(e) => { let value = Number.parseInt(e.target.value, 10) - if (value > maxLength) - value = maxLength - - else if (value < 1) - value = 1 + if (value > maxLength) value = maxLength + else if (value < 1) value = 1 onChange(value) }} diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index 1c301a2fcf2a8e..4f069cd10ababa 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -65,20 +65,19 @@ const buildPromptVariableFromInput = (payload: InputVar): PromptVariable => { name: label as string, } - if (payload.type !== InputVarType.select) - delete nextItem.options + if (payload.type !== InputVarType.select) delete nextItem.options return nextItem } const getDuplicateError = (list: PromptVariable[]) => { - if (hasDuplicateStr(list.map(item => item.key))) { + if (hasDuplicateStr(list.map((item) => item.key))) { return { errorMsgKey: 'varKeyError.keyAlreadyExists', typeName: 'variableConfig.varName', } as const } - if (hasDuplicateStr(list.map(item => item.name as string))) { + if (hasDuplicateStr(list.map((item) => item.name as string))) { return { errorMsgKey: 'varKeyError.keyAlreadyExists', typeName: 'variableConfig.labelName', @@ -95,100 +94,116 @@ export type IConfigVarProps = { const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVariablesChange }) => { const { t } = useTranslation() - const { - mode, - dataSets, - } = useContext(ConfigContext) + const { mode, dataSets } = useContext(ConfigContext) const { eventEmitter } = useEventEmitterContextContext() const hasVar = promptVariables.length > 0 const [currIndex, setCurrIndex] = useState<number>(-1) const currItem = currIndex !== -1 ? promptVariables[currIndex] : null const currItemToEdit = useMemo(() => { - if (!currItem) - return null + if (!currItem) return null return toInputVar(currItem) }, [currItem]) - const updatePromptVariableItem = useCallback((payload: InputVar) => { - const newPromptVariables = produce(promptVariables, (draft) => { - draft[currIndex] = buildPromptVariableFromInput(payload) - }) - const duplicateError = getDuplicateError(newPromptVariables) - if (duplicateError) { - toast.error(t($ => $[duplicateError.errorMsgKey], { ns: 'appDebug', key: t($ => $[duplicateError.typeName], { ns: 'appDebug' }) })) - return false - } + const updatePromptVariableItem = useCallback( + (payload: InputVar) => { + const newPromptVariables = produce(promptVariables, (draft) => { + draft[currIndex] = buildPromptVariableFromInput(payload) + }) + const duplicateError = getDuplicateError(newPromptVariables) + if (duplicateError) { + toast.error( + t(($) => $[duplicateError.errorMsgKey], { + ns: 'appDebug', + key: t(($) => $[duplicateError.typeName], { ns: 'appDebug' }), + }), + ) + return false + } - onPromptVariablesChange?.(newPromptVariables) - return true - }, [currIndex, onPromptVariablesChange, promptVariables, t]) + onPromptVariablesChange?.(newPromptVariables) + return true + }, + [currIndex, onPromptVariablesChange, promptVariables, t], + ) const { setShowExternalDataToolModal } = useModalContext() - const handleOpenExternalDataToolModal = useCallback(( - { key, type, index, name, config, icon, icon_background }: ExternalDataToolParams, - oldPromptVariables: PromptVariable[], - ) => { - setShowExternalDataToolModal({ - payload: { - type, - variable: key, - label: name, - config, - icon, - icon_background, - }, - onSaveCallback: (newExternalDataTool?: ExternalDataTool) => { - if (!newExternalDataTool) - return - const newPromptVariables = oldPromptVariables.map((item, i) => { - if (i === index) { - return { - key: newExternalDataTool.variable as string, - name: newExternalDataTool.label as string, - enabled: newExternalDataTool.enabled, - type: newExternalDataTool.type as string, - config: newExternalDataTool.config, - required: item.required, - icon: newExternalDataTool.icon, - icon_background: newExternalDataTool.icon_background, + const handleOpenExternalDataToolModal = useCallback( + ( + { key, type, index, name, config, icon, icon_background }: ExternalDataToolParams, + oldPromptVariables: PromptVariable[], + ) => { + setShowExternalDataToolModal({ + payload: { + type, + variable: key, + label: name, + config, + icon, + icon_background, + }, + onSaveCallback: (newExternalDataTool?: ExternalDataTool) => { + if (!newExternalDataTool) return + const newPromptVariables = oldPromptVariables.map((item, i) => { + if (i === index) { + return { + key: newExternalDataTool.variable as string, + name: newExternalDataTool.label as string, + enabled: newExternalDataTool.enabled, + type: newExternalDataTool.type as string, + config: newExternalDataTool.config, + required: item.required, + icon: newExternalDataTool.icon, + icon_background: newExternalDataTool.icon_background, + } + } + return item + }) + onPromptVariablesChange?.(newPromptVariables) + }, + onCancelCallback: () => { + if (!key) onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index)) + }, + onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => { + for (let i = 0; i < promptVariables.length; i++) { + if (promptVariables[i]!.key === newExternalDataTool.variable && i !== index) { + toast.error( + t(($) => $['varKeyError.keyAlreadyExists'], { + ns: 'appDebug', + key: promptVariables[i]!.key, + }), + ) + return false } } - return item - }) - onPromptVariablesChange?.(newPromptVariables) - }, - onCancelCallback: () => { - if (!key) - onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index)) - }, - onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => { - for (let i = 0; i < promptVariables.length; i++) { - if (promptVariables[i]!.key === newExternalDataTool.variable && i !== index) { - toast.error(t($ => $['varKeyError.keyAlreadyExists'], { ns: 'appDebug', key: promptVariables[i]!.key })) - return false - } - } - return true - }, - }) - }, [onPromptVariablesChange, promptVariables, setShowExternalDataToolModal, t]) + return true + }, + }) + }, + [onPromptVariablesChange, promptVariables, setShowExternalDataToolModal, t], + ) - const handleAddVar = useCallback((type: string) => { - const newVar = getNewVar('', type) - const newPromptVariables = [...promptVariables, newVar] - onPromptVariablesChange?.(newPromptVariables) + const handleAddVar = useCallback( + (type: string) => { + const newVar = getNewVar('', type) + const newPromptVariables = [...promptVariables, newVar] + onPromptVariablesChange?.(newPromptVariables) - if (type === 'api') { - handleOpenExternalDataToolModal({ - type, - key: newVar.key, - name: newVar.name, - index: promptVariables.length, - }, newPromptVariables) - } - }, [handleOpenExternalDataToolModal, onPromptVariablesChange, promptVariables]) + if (type === 'api') { + handleOpenExternalDataToolModal( + { + type, + key: newVar.key, + name: newVar.name, + index: promptVariables.length, + }, + newPromptVariables, + ) + } + }, + [handleOpenExternalDataToolModal, onPromptVariablesChange, promptVariables], + ) // eslint-disable-next-line ts/no-explicit-any eventEmitter?.useSubscription((v: any) => { @@ -210,64 +225,88 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar } }) - const [isShowDeleteContextVarModal, { setTrue: showDeleteContextVarModal, setFalse: hideDeleteContextVarModal }] = useBoolean(false) + const [ + isShowDeleteContextVarModal, + { setTrue: showDeleteContextVarModal, setFalse: hideDeleteContextVarModal }, + ] = useBoolean(false) const [removeIndex, setRemoveIndex] = useState<number | null>(null) - const didRemoveVar = useCallback((index: number) => { - onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index)) - }, [onPromptVariablesChange, promptVariables]) + const didRemoveVar = useCallback( + (index: number) => { + onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index)) + }, + [onPromptVariablesChange, promptVariables], + ) - const handleRemoveVar = useCallback((index: number) => { - const removeVar = promptVariables[index] + const handleRemoveVar = useCallback( + (index: number) => { + const removeVar = promptVariables[index] - if (mode === AppModeEnum.COMPLETION && dataSets.length > 0 && removeVar!.is_context_var) { - showDeleteContextVarModal() - setRemoveIndex(index) - return - } - didRemoveVar(index) - }, [dataSets.length, didRemoveVar, mode, promptVariables, showDeleteContextVarModal]) + if (mode === AppModeEnum.COMPLETION && dataSets.length > 0 && removeVar!.is_context_var) { + showDeleteContextVarModal() + setRemoveIndex(index) + return + } + didRemoveVar(index) + }, + [dataSets.length, didRemoveVar, mode, promptVariables, showDeleteContextVarModal], + ) const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false) - const handleConfig = useCallback(({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => { - // setCurrKey(key) - setCurrIndex(index) - if (!BASIC_INPUT_TYPES.has(type)) { - handleOpenExternalDataToolModal({ key, type, index, name, config, icon, icon_background }, promptVariables) - return - } + const handleConfig = useCallback( + ({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => { + // setCurrKey(key) + setCurrIndex(index) + if (!BASIC_INPUT_TYPES.has(type)) { + handleOpenExternalDataToolModal( + { key, type, index, name, config, icon, icon_background }, + promptVariables, + ) + return + } - showEditModal() - }, [handleOpenExternalDataToolModal, promptVariables, showEditModal]) + showEditModal() + }, + [handleOpenExternalDataToolModal, promptVariables, showEditModal], + ) - const promptVariablesWithIds = useMemo(() => promptVariables.map((item) => { - return { - id: item.key, - variable: { ...item }, - } - }), [promptVariables]) + const promptVariablesWithIds = useMemo( + () => + promptVariables.map((item) => { + return { + id: item.key, + variable: { ...item }, + } + }), + [promptVariables], + ) const canDrag = !readonly && promptVariables.length > 1 return ( <Panel className="mt-2" - title={( + title={ <div className="flex items-center"> - <div className="mr-1">{t($ => $.variableTitle, { ns: 'appDebug' })}</div> + <div className="mr-1">{t(($) => $.variableTitle, { ns: 'appDebug' })}</div> {!readonly && ( - <Infotip aria-label={t($ => $.variableTip, { ns: 'appDebug' })} popupClassName="w-[180px]"> - {t($ => $.variableTip, { ns: 'appDebug' })} + <Infotip + aria-label={t(($) => $.variableTip, { ns: 'appDebug' })} + popupClassName="w-[180px]" + > + {t(($) => $.variableTip, { ns: 'appDebug' })} </Infotip> )} </div> - )} + } headerRight={!readonly ? <SelectVarType onChange={handleAddVar} /> : null} noBodySpacing > {!hasVar && ( <div className="mt-1 px-3 pb-3"> - <div className="pt-2 pb-1 text-xs text-text-tertiary">{t($ => $.notSetVar, { ns: 'appDebug' })}</div> + <div className="pt-2 pb-1 text-xs text-text-tertiary"> + {t(($) => $.notSetVar, { ns: 'appDebug' })} + </div> </div> )} {hasVar && ( @@ -275,7 +314,9 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar <ReactSortable className={cn('grid-col-1 grid space-y-1', readonly && 'grid-cols-2 gap-1 space-y-0')} list={promptVariablesWithIds} - setList={(list) => { onPromptVariablesChange?.(list.map(item => item.variable)) }} + setList={(list) => { + onPromptVariablesChange?.(list.map((item) => item.variable)) + }} handle=".handle" ghostClass="opacity-50" animation={150} @@ -291,7 +332,9 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar label={name} required={!!required} type={type} - onEdit={() => handleConfig({ type, key, index, name, config, icon, icon_background })} + onEdit={() => + handleConfig({ type, key, index, name, config, icon, icon_background }) + } onRemove={() => handleRemoveVar(index)} canDrag={canDrag} /> @@ -308,38 +351,44 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar onClose={hideEditModal} onConfirm={(item) => { const isValid = updatePromptVariableItem(item) - if (!isValid) - return + if (!isValid) return hideEditModal() }} - varKeys={promptVariables.map(v => v.key)} + varKeys={promptVariables.map((v) => v.key)} /> )} - <AlertDialog open={isShowDeleteContextVarModal} onOpenChange={open => !open && hideDeleteContextVarModal()}> + <AlertDialog + open={isShowDeleteContextVarModal} + onOpenChange={(open) => !open && hideDeleteContextVarModal()} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['feature.dataSet.queryVariable.deleteContextVarTitle'], { ns: 'appDebug', varName: promptVariables[removeIndex as number]?.name })} + {t(($) => $['feature.dataSet.queryVariable.deleteContextVarTitle'], { + ns: 'appDebug', + varName: promptVariables[removeIndex as number]?.name, + })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['feature.dataSet.queryVariable.deleteContextVarTip'], { ns: 'appDebug' })} + {t(($) => $['feature.dataSet.queryVariable.deleteContextVarTip'], { ns: 'appDebug' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={() => { didRemoveVar(removeIndex as number) hideDeleteContextVarModal() }} > - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> </AlertDialog> - </Panel> ) } diff --git a/web/app/components/app/configuration/config-var/input-type-icon.tsx b/web/app/components/app/configuration/config-var/input-type-icon.tsx index d7a45727958c85..fb4c2ac4785438 100644 --- a/web/app/components/app/configuration/config-var/input-type-icon.tsx +++ b/web/app/components/app/configuration/config-var/input-type-icon.tsx @@ -13,30 +13,17 @@ export type IInputTypeIconProps = { const IconMap = (type: IInputTypeIconProps['type'], className: string) => { const classNames = `size-3.5 ${className}` const icons = { - string: ( - <InputVarTypeIcon type={InputVarType.textInput} className={classNames} /> - ), - paragraph: ( - <InputVarTypeIcon type={InputVarType.paragraph} className={classNames} /> - ), - select: ( - <InputVarTypeIcon type={InputVarType.select} className={classNames} /> - ), - number: ( - <InputVarTypeIcon type={InputVarType.number} className={classNames} /> - ), - api: ( - <ApiConnection className={classNames} /> - ), + string: <InputVarTypeIcon type={InputVarType.textInput} className={classNames} />, + paragraph: <InputVarTypeIcon type={InputVarType.paragraph} className={classNames} />, + select: <InputVarTypeIcon type={InputVarType.select} className={classNames} />, + number: <InputVarTypeIcon type={InputVarType.number} className={classNames} />, + api: <ApiConnection className={classNames} />, } return icons[type] } -const InputTypeIcon: FC<IInputTypeIconProps> = ({ - type, - className, -}) => { +const InputTypeIcon: FC<IInputTypeIconProps> = ({ type, className }) => { const Icon = IconMap(type, className) return Icon } diff --git a/web/app/components/app/configuration/config-var/modal-foot.tsx b/web/app/components/app/configuration/config-var/modal-foot.tsx index 8d653fcbfe765e..0c4c45a4d6334f 100644 --- a/web/app/components/app/configuration/config-var/modal-foot.tsx +++ b/web/app/components/app/configuration/config-var/modal-foot.tsx @@ -9,15 +9,14 @@ type IModalFootProps = { onCancel: () => void } -const ModalFoot: FC<IModalFootProps> = ({ - onConfirm, - onCancel, -}) => { +const ModalFoot: FC<IModalFootProps> = ({ onConfirm, onCancel }) => { const { t } = useTranslation() return ( <div className="flex justify-end gap-2"> - <Button onClick={onCancel}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" onClick={onConfirm}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button onClick={onCancel}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> + <Button variant="primary" onClick={onConfirm}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> ) } diff --git a/web/app/components/app/configuration/config-var/select-var-type.tsx b/web/app/components/app/configuration/config-var/select-var-type.tsx index e1539c4aceb085..a4a7b064084c5c 100644 --- a/web/app/components/app/configuration/config-var/select-var-type.tsx +++ b/web/app/components/app/configuration/config-var/select-var-type.tsx @@ -33,25 +33,24 @@ const SelectItem: FC<ItemProps> = ({ text, type, value, Icon, onClick }) => { className="h-8 rounded-lg px-3 text-text-primary" onClick={() => onClick(value)} > - {Icon ? <Icon className="size-4 text-text-secondary" /> : <InputVarTypeIcon type={type!} className="size-4 text-text-secondary" />} + {Icon ? ( + <Icon className="size-4 text-text-secondary" /> + ) : ( + <InputVarTypeIcon type={type!} className="size-4 text-text-secondary" /> + )} <div className="ml-2 truncate text-xs text-text-primary">{text}</div> </DropdownMenuItem> ) } -const SelectVarType: FC<Props> = ({ - onChange, -}) => { +const SelectVarType: FC<Props> = ({ onChange }) => { const { t } = useTranslation() const handleChange = (value: string) => { onChange(value) } return ( <DropdownMenu> - <DropdownMenuTrigger - nativeButton={false} - render={<div className="block" />} - > + <DropdownMenuTrigger nativeButton={false} render={<div className="block" />}> <OperationBtn type="add" /> </DropdownMenuTrigger> <DropdownMenuContent @@ -61,15 +60,45 @@ const SelectVarType: FC<Props> = ({ popupClassName="min-w-[192px] rounded-lg border bg-components-panel-bg-blur p-0 backdrop-blur-xs" > <div className="p-1"> - <SelectItem type={InputVarType.textInput} value="string" text={t($ => $['variableConfig.string'], { ns: 'appDebug' })} onClick={handleChange}></SelectItem> - <SelectItem type={InputVarType.paragraph} value="paragraph" text={t($ => $['variableConfig.paragraph'], { ns: 'appDebug' })} onClick={handleChange}></SelectItem> - <SelectItem type={InputVarType.select} value="select" text={t($ => $['variableConfig.select'], { ns: 'appDebug' })} onClick={handleChange}></SelectItem> - <SelectItem type={InputVarType.number} value="number" text={t($ => $['variableConfig.number'], { ns: 'appDebug' })} onClick={handleChange}></SelectItem> - <SelectItem type={InputVarType.checkbox} value="checkbox" text={t($ => $['variableConfig.checkbox'], { ns: 'appDebug' })} onClick={handleChange}></SelectItem> + <SelectItem + type={InputVarType.textInput} + value="string" + text={t(($) => $['variableConfig.string'], { ns: 'appDebug' })} + onClick={handleChange} + ></SelectItem> + <SelectItem + type={InputVarType.paragraph} + value="paragraph" + text={t(($) => $['variableConfig.paragraph'], { ns: 'appDebug' })} + onClick={handleChange} + ></SelectItem> + <SelectItem + type={InputVarType.select} + value="select" + text={t(($) => $['variableConfig.select'], { ns: 'appDebug' })} + onClick={handleChange} + ></SelectItem> + <SelectItem + type={InputVarType.number} + value="number" + text={t(($) => $['variableConfig.number'], { ns: 'appDebug' })} + onClick={handleChange} + ></SelectItem> + <SelectItem + type={InputVarType.checkbox} + value="checkbox" + text={t(($) => $['variableConfig.checkbox'], { ns: 'appDebug' })} + onClick={handleChange} + ></SelectItem> </div> <DropdownMenuSeparator className="my-0" /> <div className="p-1"> - <SelectItem Icon={ApiConnection} value="api" text={t($ => $['variableConfig.apiBasedVar'], { ns: 'appDebug' })} onClick={handleChange}></SelectItem> + <SelectItem + Icon={ApiConnection} + value="api" + text={t(($) => $['variableConfig.apiBasedVar'], { ns: 'appDebug' })} + onClick={handleChange} + ></SelectItem> </div> </DropdownMenuContent> </DropdownMenu> diff --git a/web/app/components/app/configuration/config-var/var-item.tsx b/web/app/components/app/configuration/config-var/var-item.tsx index 17fd45eaf3b557..35fa59397b4844 100644 --- a/web/app/components/app/configuration/config-var/var-item.tsx +++ b/web/app/components/app/configuration/config-var/var-item.tsx @@ -2,11 +2,7 @@ import type { FC } from 'react' import type { IInputTypeIconProps } from './input-type-icon' import { cn } from '@langgenius/dify-ui/cn' -import { - RiDeleteBinLine, - RiDraggable, - RiEditLine, -} from '@remixicon/react' +import { RiDeleteBinLine, RiDraggable, RiEditLine } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -41,8 +37,17 @@ const VarItem: FC<ItemProps> = ({ const [isDeleting, setIsDeleting] = useState(false) return ( - <div className={cn('group relative mb-1 flex h-[34px] w-full items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pr-3 pl-2.5 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover', readonly && 'cursor-not-allowed', className)}> - <VarIcon className={cn('mr-1 size-4 shrink-0 text-text-accent', canDrag && 'group-hover:opacity-0')} /> + <div + className={cn( + 'group relative mb-1 flex h-[34px] w-full items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pr-3 pl-2.5 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', + isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover', + readonly && 'cursor-not-allowed', + className, + )} + > + <VarIcon + className={cn('mr-1 size-4 shrink-0 text-text-accent', canDrag && 'group-hover:opacity-0')} + /> {canDrag && ( <RiDraggable className="absolute top-3 left-3 hidden size-3 cursor-pointer text-text-tertiary group-hover:block" /> )} @@ -59,10 +64,15 @@ const VarItem: FC<ItemProps> = ({ <span className="pr-1 pl-2 system-xs-regular text-text-tertiary">{type}</span> <IconTypeIcon type={type as IInputTypeIconProps['type']} className="text-text-tertiary" /> </div> - <div className={cn('hidden items-center justify-end rounded-lg', !readonly && 'group-hover:flex')}> + <div + className={cn( + 'hidden items-center justify-end rounded-lg', + !readonly && 'group-hover:flex', + )} + > <button type="button" - aria-label={t($ => $['operation.edit'], { ns: 'common' })} + aria-label={t(($) => $['operation.edit'], { ns: 'common' })} className="mr-1 flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onEdit} > @@ -70,7 +80,7 @@ const VarItem: FC<ItemProps> = ({ </button> <button type="button" - aria-label={t($ => $['operation.delete'], { ns: 'common' })} + aria-label={t(($) => $['operation.delete'], { ns: 'common' })} className="flex size-6 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-text-tertiary hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden" onClick={onRemove} onMouseOver={() => setIsDeleting(true)} diff --git a/web/app/components/app/configuration/config-vision/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-vision/__tests__/index.spec.tsx index b46f561bdb1f30..c762d25fa6da66 100644 --- a/web/app/components/app/configuration/config-vision/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-vision/__tests__/index.spec.tsx @@ -64,12 +64,14 @@ const setupFeatureStore = (fileOverrides: Partial<FileUpload> = {}) => { mockUseFeaturesStore.mockReturnValue({ getState: () => featureStoreState, }) - mockUseFeatures.mockImplementation(selector => selector(featureStoreState)) + mockUseFeatures.mockImplementation((selector) => selector(featureStoreState)) } const getLatestFileConfig = () => { expect(setFeaturesMock).toHaveBeenCalled() - const latestFeatures = setFeaturesMock.mock.calls[setFeaturesMock.mock.calls.length - 1]![0] as { file: FileUpload } + const latestFeatures = setFeaturesMock.mock.calls[setFeaturesMock.mock.calls.length - 1]![0] as { + file: FileUpload + } return latestFeatures.file } @@ -116,7 +118,10 @@ describe('ConfigVision', () => { await user.click(screen.getByRole('switch')) const updatedFile = getLatestFileConfig() - expect(updatedFile.allowed_file_types).toEqual([SupportUploadFileTypes.image, SupportUploadFileTypes.video]) + expect(updatedFile.allowed_file_types).toEqual([ + SupportUploadFileTypes.image, + SupportUploadFileTypes.video, + ]) expect(updatedFile.image?.enabled).toBe(true) expect(updatedFile.enabled).toBe(true) }) diff --git a/web/app/components/app/configuration/config-vision/__tests__/param-config-content.spec.tsx b/web/app/components/app/configuration/config-vision/__tests__/param-config-content.spec.tsx index 2cb919b6db4be7..e48dcde91f1142 100644 --- a/web/app/components/app/configuration/config-vision/__tests__/param-config-content.spec.tsx +++ b/web/app/components/app/configuration/config-vision/__tests__/param-config-content.spec.tsx @@ -35,7 +35,7 @@ const setupFeatureStore = (fileOverrides: Partial<FileUpload> = {}) => { setShowFeaturesModal: vi.fn(), } as unknown as FeatureStoreState - mockUseFeatures.mockImplementation(selector => selector(featureStoreState)) + mockUseFeatures.mockImplementation((selector) => selector(featureStoreState)) mockUseFeaturesStore.mockReturnValue({ getState: () => featureStoreState, }) diff --git a/web/app/components/app/configuration/config-vision/__tests__/param-config.spec.tsx b/web/app/components/app/configuration/config-vision/__tests__/param-config.spec.tsx index 617f14629eb4fb..7cd75b91a3dd34 100644 --- a/web/app/components/app/configuration/config-vision/__tests__/param-config.spec.tsx +++ b/web/app/components/app/configuration/config-vision/__tests__/param-config.spec.tsx @@ -33,7 +33,7 @@ const setupFeatureStore = (fileOverrides: Partial<FileUpload> = {}) => { showFeaturesModal: false, setShowFeaturesModal: vi.fn(), } as unknown as FeatureStoreState - mockUseFeatures.mockImplementation(selector => selector(featureStoreState)) + mockUseFeatures.mockImplementation((selector) => selector(featureStoreState)) mockUseFeaturesStore.mockReturnValue({ getState: () => featureStoreState, }) diff --git a/web/app/components/app/configuration/config-vision/index.tsx b/web/app/components/app/configuration/config-vision/index.tsx index 85ac6a9bea3242..278c2cb0474cec 100644 --- a/web/app/components/app/configuration/config-vision/index.tsx +++ b/web/app/components/app/configuration/config-vision/index.tsx @@ -22,44 +22,46 @@ import ParamConfig from './param-config' const ConfigVision: FC = () => { const { t } = useTranslation() const { isShowVisionConfig, isAllowVideoUpload, readonly } = useContext(ConfigContext) - const file = useFeatures(s => s.features.file) + const file = useFeatures((s) => s.features.file) const featuresStore = useFeaturesStore() const isImageEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.image) ?? false - const handleChange = useCallback((value: boolean) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (value: boolean) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - if (value) { - draft.file!.allowed_file_types = Array.from(new Set([ - ...(draft.file?.allowed_file_types || []), - SupportUploadFileTypes.image, - ...(isAllowVideoUpload ? [SupportUploadFileTypes.video] : []), - ])) - } - else { - draft.file!.allowed_file_types = draft.file!.allowed_file_types?.filter( - type => type !== SupportUploadFileTypes.image && (isAllowVideoUpload ? type !== SupportUploadFileTypes.video : true), - ) - } + const newFeatures = produce(features, (draft) => { + if (value) { + draft.file!.allowed_file_types = Array.from( + new Set([ + ...(draft.file?.allowed_file_types || []), + SupportUploadFileTypes.image, + ...(isAllowVideoUpload ? [SupportUploadFileTypes.video] : []), + ]), + ) + } else { + draft.file!.allowed_file_types = draft.file!.allowed_file_types?.filter( + (type) => + type !== SupportUploadFileTypes.image && + (isAllowVideoUpload ? type !== SupportUploadFileTypes.video : true), + ) + } - if (draft.file) { - draft.file.enabled = (draft.file.allowed_file_types?.length ?? 0) > 0 - draft.file.image = { - ...draft.file.image, - enabled: value, + if (draft.file) { + draft.file.enabled = (draft.file.allowed_file_types?.length ?? 0) > 0 + draft.file.image = { + ...draft.file.image, + enabled: value, + } } - } - }) - setFeatures(newFeatures) - }, [featuresStore, isAllowVideoUpload]) + }) + setFeatures(newFeatures) + }, + [featuresStore, isAllowVideoUpload], + ) - if (!isShowVisionConfig || (readonly && !isImageEnabled)) - return null + if (!isShowVisionConfig || (readonly && !isImageEnabled)) return null return ( <div className="mt-2 flex items-center gap-2 rounded-xl border-t-[0.5px] border-l-[0.5px] border-effects-highlight bg-background-section-burn p-2"> @@ -69,63 +71,66 @@ const ConfigVision: FC = () => { </div> </div> <div className="flex grow items-center"> - <div className="mr-1 system-sm-semibold text-text-secondary">{t($ => $['vision.name'], { ns: 'appDebug' })}</div> + <div className="mr-1 system-sm-semibold text-text-secondary"> + {t(($) => $['vision.name'], { ns: 'appDebug' })} + </div> <Infotip - aria-label={t($ => $['vision.description'], { ns: 'appDebug' })} + aria-label={t(($) => $['vision.description'], { ns: 'appDebug' })} popupClassName="w-[180px]" > - {t($ => $['vision.description'], { ns: 'appDebug' })} + {t(($) => $['vision.description'], { ns: 'appDebug' })} </Infotip> </div> <div className="flex shrink-0 items-center"> - {readonly - ? ( - <> - <div className="mr-2 flex items-center gap-0.5"> - <div className="system-xs-medium-uppercase text-text-tertiary">{t($ => $['vision.visionSettings.resolution'], { ns: 'appDebug' })}</div> - <Infotip - aria-label={t($ => $['vision.visionSettings.resolutionTooltip'], { ns: 'appDebug' })} - popupClassName="w-[180px]" - > - {t($ => $['vision.visionSettings.resolutionTooltip'], { ns: 'appDebug' }).split('\n').map(item => ( - <div key={item}>{item}</div> - ))} - </Infotip> - </div> - <div className="flex items-center gap-1"> - <OptionCard - title={t($ => $['vision.visionSettings.high'], { ns: 'appDebug' })} - selected={file?.image?.detail === Resolution.high} - onSelect={noop} - className={cn( - 'cursor-not-allowed rounded-lg px-3 hover:shadow-none', - file?.image?.detail !== Resolution.high && 'hover:border-components-option-card-option-border', - )} - /> - <OptionCard - title={t($ => $['vision.visionSettings.low'], { ns: 'appDebug' })} - selected={file?.image?.detail === Resolution.low} - onSelect={noop} - className={cn( - 'cursor-not-allowed rounded-lg px-3 hover:shadow-none', - file?.image?.detail !== Resolution.low && 'hover:border-components-option-card-option-border', - )} - /> - </div> - </> - ) - : ( - <> - <ParamConfig /> - <div className="mr-3 ml-1 h-3.5 w-px bg-divider-regular"></div> - <Switch - checked={isImageEnabled} - onCheckedChange={handleChange} - size="md" - /> - </> - )} - + {readonly ? ( + <> + <div className="mr-2 flex items-center gap-0.5"> + <div className="system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['vision.visionSettings.resolution'], { ns: 'appDebug' })} + </div> + <Infotip + aria-label={t(($) => $['vision.visionSettings.resolutionTooltip'], { + ns: 'appDebug', + })} + popupClassName="w-[180px]" + > + {t(($) => $['vision.visionSettings.resolutionTooltip'], { ns: 'appDebug' }) + .split('\n') + .map((item) => ( + <div key={item}>{item}</div> + ))} + </Infotip> + </div> + <div className="flex items-center gap-1"> + <OptionCard + title={t(($) => $['vision.visionSettings.high'], { ns: 'appDebug' })} + selected={file?.image?.detail === Resolution.high} + onSelect={noop} + className={cn( + 'cursor-not-allowed rounded-lg px-3 hover:shadow-none', + file?.image?.detail !== Resolution.high && + 'hover:border-components-option-card-option-border', + )} + /> + <OptionCard + title={t(($) => $['vision.visionSettings.low'], { ns: 'appDebug' })} + selected={file?.image?.detail === Resolution.low} + onSelect={noop} + className={cn( + 'cursor-not-allowed rounded-lg px-3 hover:shadow-none', + file?.image?.detail !== Resolution.low && + 'hover:border-components-option-card-option-border', + )} + /> + </div> + </> + ) : ( + <> + <ParamConfig /> + <div className="mr-3 ml-1 h-3.5 w-px bg-divider-regular"></div> + <Switch checked={isImageEnabled} onCheckedChange={handleChange} size="md" /> + </> + )} </div> </div> ) diff --git a/web/app/components/app/configuration/config-vision/param-config-content.tsx b/web/app/components/app/configuration/config-vision/param-config-content.tsx index 3dce8ab95fea1f..ac1a5f374ea8a9 100644 --- a/web/app/components/app/configuration/config-vision/param-config-content.tsx +++ b/web/app/components/app/configuration/config-vision/param-config-content.tsx @@ -15,97 +15,129 @@ const MIN = 1 const MAX = 6 const ParamConfigContent: FC = () => { const { t } = useTranslation() - const file = useFeatures(s => s.features.file) + const file = useFeatures((s) => s.features.file) const featuresStore = useFeaturesStore() - const handleChange = useCallback((data: FileUpload) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (data: FileUpload) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - draft.file = { - ...draft.file, - allowed_file_upload_methods: data.allowed_file_upload_methods, - number_limits: data.number_limits, - image: { - enabled: data.enabled, - detail: data.image?.detail, - transfer_methods: data.allowed_file_upload_methods, + const newFeatures = produce(features, (draft) => { + draft.file = { + ...draft.file, + allowed_file_upload_methods: data.allowed_file_upload_methods, number_limits: data.number_limits, - }, - } - }) - setFeatures(newFeatures) - }, [featuresStore]) + image: { + enabled: data.enabled, + detail: data.image?.detail, + transfer_methods: data.allowed_file_upload_methods, + number_limits: data.number_limits, + }, + } + }) + setFeatures(newFeatures) + }, + [featuresStore], + ) return ( <div> - <div className="text-base/6 font-semibold text-text-primary">{t($ => $['vision.visionSettings.title'], { ns: 'appDebug' })}</div> + <div className="text-base/6 font-semibold text-text-primary"> + {t(($) => $['vision.visionSettings.title'], { ns: 'appDebug' })} + </div> <div className="space-y-6 pt-3"> <div> <div className="mb-2 flex items-center space-x-1"> - <div className="text-[13px] leading-[18px] font-semibold text-text-secondary">{t($ => $['vision.visionSettings.resolution'], { ns: 'appDebug' })}</div> + <div className="text-[13px] leading-[18px] font-semibold text-text-secondary"> + {t(($) => $['vision.visionSettings.resolution'], { ns: 'appDebug' })} + </div> <Infotip - aria-label={t($ => $['vision.visionSettings.resolutionTooltip'], { ns: 'appDebug' })} + aria-label={t(($) => $['vision.visionSettings.resolutionTooltip'], { + ns: 'appDebug', + })} popupClassName="w-[180px]" > - {t($ => $['vision.visionSettings.resolutionTooltip'], { ns: 'appDebug' }).split('\n').map(item => ( - <div key={item}>{item}</div> - ))} + {t(($) => $['vision.visionSettings.resolutionTooltip'], { ns: 'appDebug' }) + .split('\n') + .map((item) => ( + <div key={item}>{item}</div> + ))} </Infotip> </div> <div className="flex items-center gap-1"> <OptionCard className="grow" - title={t($ => $['vision.visionSettings.high'], { ns: 'appDebug' })} + title={t(($) => $['vision.visionSettings.high'], { ns: 'appDebug' })} selected={file?.image?.detail === Resolution.high} - onSelect={() => handleChange({ - ...file, - image: { detail: Resolution.high }, - })} + onSelect={() => + handleChange({ + ...file, + image: { detail: Resolution.high }, + }) + } /> <OptionCard className="grow" - title={t($ => $['vision.visionSettings.low'], { ns: 'appDebug' })} + title={t(($) => $['vision.visionSettings.low'], { ns: 'appDebug' })} selected={file?.image?.detail === Resolution.low} - onSelect={() => handleChange({ - ...file, - image: { detail: Resolution.low }, - })} + onSelect={() => + handleChange({ + ...file, + image: { detail: Resolution.low }, + }) + } /> </div> </div> <div> - <div className="mb-2 text-[13px] leading-[18px] font-semibold text-text-secondary">{t($ => $['vision.visionSettings.uploadMethod'], { ns: 'appDebug' })}</div> + <div className="mb-2 text-[13px] leading-[18px] font-semibold text-text-secondary"> + {t(($) => $['vision.visionSettings.uploadMethod'], { ns: 'appDebug' })} + </div> <div className="flex items-center gap-1"> <OptionCard className="grow" - title={t($ => $['vision.visionSettings.both'], { ns: 'appDebug' })} - selected={!!file?.allowed_file_upload_methods?.includes(TransferMethod.local_file) && !!file?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)} - onSelect={() => handleChange({ - ...file, - allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], - })} + title={t(($) => $['vision.visionSettings.both'], { ns: 'appDebug' })} + selected={ + !!file?.allowed_file_upload_methods?.includes(TransferMethod.local_file) && + !!file?.allowed_file_upload_methods?.includes(TransferMethod.remote_url) + } + onSelect={() => + handleChange({ + ...file, + allowed_file_upload_methods: [ + TransferMethod.local_file, + TransferMethod.remote_url, + ], + }) + } /> <OptionCard className="grow" - title={t($ => $['vision.visionSettings.localUpload'], { ns: 'appDebug' })} - selected={!!file?.allowed_file_upload_methods?.includes(TransferMethod.local_file) && file?.allowed_file_upload_methods?.length === 1} - onSelect={() => handleChange({ - ...file, - allowed_file_upload_methods: [TransferMethod.local_file], - })} + title={t(($) => $['vision.visionSettings.localUpload'], { ns: 'appDebug' })} + selected={ + !!file?.allowed_file_upload_methods?.includes(TransferMethod.local_file) && + file?.allowed_file_upload_methods?.length === 1 + } + onSelect={() => + handleChange({ + ...file, + allowed_file_upload_methods: [TransferMethod.local_file], + }) + } /> <OptionCard className="grow" - title={t($ => $['vision.visionSettings.url'], { ns: 'appDebug' })} - selected={!!file?.allowed_file_upload_methods?.includes(TransferMethod.remote_url) && file?.allowed_file_upload_methods?.length === 1} - onSelect={() => handleChange({ - ...file, - allowed_file_upload_methods: [TransferMethod.remote_url], - })} + title={t(($) => $['vision.visionSettings.url'], { ns: 'appDebug' })} + selected={ + !!file?.allowed_file_upload_methods?.includes(TransferMethod.remote_url) && + file?.allowed_file_upload_methods?.length === 1 + } + onSelect={() => + handleChange({ + ...file, + allowed_file_upload_methods: [TransferMethod.remote_url], + }) + } /> </div> </div> @@ -113,7 +145,7 @@ const ParamConfigContent: FC = () => { <ParamItem id="upload_limit" className="" - name={t($ => $['vision.visionSettings.uploadLimit'], { ns: 'appDebug' })} + name={t(($) => $['vision.visionSettings.uploadLimit'], { ns: 'appDebug' })} noTooltip {...{ default: 2, @@ -124,8 +156,7 @@ const ParamConfigContent: FC = () => { value={file?.number_limits || 3} enable={true} onChange={(_key: string, value: number) => { - if (!value) - return + if (!value) return handleChange({ ...file, diff --git a/web/app/components/app/configuration/config-vision/param-config.tsx b/web/app/components/app/configuration/config-vision/param-config.tsx index bf4f633387295b..9bfb56a00dc30c 100644 --- a/web/app/components/app/configuration/config-vision/param-config.tsx +++ b/web/app/components/app/configuration/config-vision/param-config.tsx @@ -13,17 +13,14 @@ const ParamsConfig: FC = () => { const [open, setOpen] = useState(false) return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <Button variant="ghost" size="small" className={cn('')}> <RiSettings2Line className="size-3.5" /> - <div className="ml-1">{t($ => $['voice.settings'], { ns: 'appDebug' })}</div> + <div className="ml-1">{t(($) => $['voice.settings'], { ns: 'appDebug' })}</div> </Button> - )} + } /> <PopoverContent placement="bottom-end" diff --git a/web/app/components/app/configuration/config/__tests__/agent-setting-button.spec.tsx b/web/app/components/app/configuration/config/__tests__/agent-setting-button.spec.tsx index a6f650b66710c9..2cae9727530567 100644 --- a/web/app/components/app/configuration/config/__tests__/agent-setting-button.spec.tsx +++ b/web/app/components/app/configuration/config/__tests__/agent-setting-button.spec.tsx @@ -15,9 +15,7 @@ vi.mock('../agent/agent-setting', () => ({ <button onClick={() => props.onSave({ ...props.payload, max_iteration: 9 })}> save-agent </button> - <button onClick={props.onCancel}> - cancel-agent - </button> + <button onClick={props.onCancel}>cancel-agent</button> </div> ) }, diff --git a/web/app/components/app/configuration/config/__tests__/config-audio.spec.tsx b/web/app/components/app/configuration/config/__tests__/config-audio.spec.tsx index e81f82b77611d1..a112983b4bc37d 100644 --- a/web/app/components/app/configuration/config/__tests__/config-audio.spec.tsx +++ b/web/app/components/app/configuration/config/__tests__/config-audio.spec.tsx @@ -48,14 +48,11 @@ const setupFeatureStore = (allowedTypes: SupportUploadFileTypes[] = []) => { } mockStore.getState.mockImplementation(() => mockFeatureStoreState) mockUseFeaturesStore.mockReturnValue(mockStore) - mockUseFeatures.mockImplementation(selector => selector(mockFeatureStoreState)) + mockUseFeatures.mockImplementation((selector) => selector(mockFeatureStoreState)) } const renderConfigAudio = (options: SetupOptions = {}) => { - const { - isVisible = true, - allowedTypes = [], - } = options + const { isVisible = true, allowedTypes = [] } = options setupFeatureStore(allowedTypes) mockUseContext.mockReturnValue({ isShowAudioConfig: isVisible, @@ -93,26 +90,32 @@ describe('ConfigAudio', () => { expect(toggle).toHaveAttribute('aria-checked', 'false') await user.click(toggle) - expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({ - file: expect.objectContaining({ - allowed_file_types: [SupportUploadFileTypes.audio], - enabled: true, + expect(setFeatures).toHaveBeenCalledWith( + expect.objectContaining({ + file: expect.objectContaining({ + allowed_file_types: [SupportUploadFileTypes.audio], + enabled: true, + }), }), - })) + ) }) it('should disable audio uploads and turn off file feature when last type is removed', async () => { - const { user, setFeatures } = renderConfigAudio({ allowedTypes: [SupportUploadFileTypes.audio] }) + const { user, setFeatures } = renderConfigAudio({ + allowedTypes: [SupportUploadFileTypes.audio], + }) const toggle = screen.getByRole('switch') expect(toggle).toHaveAttribute('aria-checked', 'true') await user.click(toggle) - expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({ - file: expect.objectContaining({ - allowed_file_types: [], - enabled: false, + expect(setFeatures).toHaveBeenCalledWith( + expect.objectContaining({ + file: expect.objectContaining({ + allowed_file_types: [], + enabled: false, + }), }), - })) + ) }) }) diff --git a/web/app/components/app/configuration/config/__tests__/config-document.spec.tsx b/web/app/components/app/configuration/config/__tests__/config-document.spec.tsx index a6f2742a80bc30..f9a774f080ed98 100644 --- a/web/app/components/app/configuration/config/__tests__/config-document.spec.tsx +++ b/web/app/components/app/configuration/config/__tests__/config-document.spec.tsx @@ -48,14 +48,11 @@ const setupFeatureStore = (allowedTypes: SupportUploadFileTypes[] = []) => { } mockStore.getState.mockImplementation(() => mockFeatureStoreState) mockUseFeaturesStore.mockReturnValue(mockStore) - mockUseFeatures.mockImplementation(selector => selector(mockFeatureStoreState)) + mockUseFeatures.mockImplementation((selector) => selector(mockFeatureStoreState)) } const renderConfigDocument = (options: SetupOptions = {}) => { - const { - isVisible = true, - allowedTypes = [], - } = options + const { isVisible = true, allowedTypes = [] } = options setupFeatureStore(allowedTypes) mockUseContext.mockReturnValue({ isShowDocumentConfig: isVisible, @@ -87,18 +84,22 @@ describe('ConfigDocument', () => { }) it('should add document type to allowed list when toggled on', async () => { - const { user, setFeatures } = renderConfigDocument({ allowedTypes: [SupportUploadFileTypes.audio] }) + const { user, setFeatures } = renderConfigDocument({ + allowedTypes: [SupportUploadFileTypes.audio], + }) const toggle = screen.getByRole('switch') expect(toggle).toHaveAttribute('aria-checked', 'false') await user.click(toggle) - expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({ - file: expect.objectContaining({ - allowed_file_types: [SupportUploadFileTypes.audio, SupportUploadFileTypes.document], - enabled: true, + expect(setFeatures).toHaveBeenCalledWith( + expect.objectContaining({ + file: expect.objectContaining({ + allowed_file_types: [SupportUploadFileTypes.audio, SupportUploadFileTypes.document], + enabled: true, + }), }), - })) + ) }) it('should remove document type but keep file feature enabled when other types remain', async () => { @@ -110,11 +111,13 @@ describe('ConfigDocument', () => { expect(toggle).toHaveAttribute('aria-checked', 'true') await user.click(toggle) - expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({ - file: expect.objectContaining({ - allowed_file_types: [SupportUploadFileTypes.audio], - enabled: true, + expect(setFeatures).toHaveBeenCalledWith( + expect.objectContaining({ + file: expect.objectContaining({ + allowed_file_types: [SupportUploadFileTypes.audio], + enabled: true, + }), }), - })) + ) }) }) diff --git a/web/app/components/app/configuration/config/__tests__/index.spec.tsx b/web/app/components/app/configuration/config/__tests__/index.spec.tsx index d50d35d5302a5e..6b82a6563bc78c 100644 --- a/web/app/components/app/configuration/config/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config/__tests__/index.spec.tsx @@ -211,12 +211,14 @@ describe('Config - Prompt Handling', () => { latestConfigPromptProps.onChange('Updated template', additions) expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs) - expect(contextValue.setModelConfig).toHaveBeenCalledWith(expect.objectContaining({ - configs: expect.objectContaining({ - prompt_template: 'Updated template', - prompt_variables: [...previousVariables, ...additions], + expect(contextValue.setModelConfig).toHaveBeenCalledWith( + expect.objectContaining({ + configs: expect.objectContaining({ + prompt_template: 'Updated template', + prompt_variables: [...previousVariables, ...additions], + }), }), - })) + ) expect(mockFormattingDispatcher).toHaveBeenCalledTimes(1) }) @@ -237,10 +239,12 @@ describe('Config - Prompt Handling', () => { latestConfigVarProps.onPromptVariablesChange(replacementVariables) expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs) - expect(contextValue.setModelConfig).toHaveBeenCalledWith(expect.objectContaining({ - configs: expect.objectContaining({ - prompt_variables: replacementVariables, + expect(contextValue.setModelConfig).toHaveBeenCalledWith( + expect.objectContaining({ + configs: expect.objectContaining({ + prompt_variables: replacementVariables, + }), }), - })) + ) }) }) diff --git a/web/app/components/app/configuration/config/agent-setting-button.tsx b/web/app/components/app/configuration/config/agent-setting-button.tsx index 21234c593c428d..dfaa665b7bcf9a 100644 --- a/web/app/components/app/configuration/config/agent-setting-button.tsx +++ b/web/app/components/app/configuration/config/agent-setting-button.tsx @@ -27,9 +27,13 @@ const AgentSettingButton: FC<Props> = ({ return ( <> - <Button onClick={() => setIsShowAgentSetting(true)} className="mr-2 shrink-0" disabled={disabled}> + <Button + onClick={() => setIsShowAgentSetting(true)} + className="mr-2 shrink-0" + disabled={disabled} + > <span className="mr-1 i-ri-settings-2-line size-4 text-text-tertiary" /> - {t($ => $['agent.setting.name'], { ns: 'appDebug' })} + {t(($) => $['agent.setting.name'], { ns: 'appDebug' })} </Button> {isShowAgentSetting && ( <AgentSetting diff --git a/web/app/components/app/configuration/config/agent/agent-setting/__tests__/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-setting/__tests__/index.spec.tsx index c9ce45a0e1fc37..e05d0af638fc57 100644 --- a/web/app/components/app/configuration/config/agent/agent-setting/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config/agent/agent-setting/__tests__/index.spec.tsx @@ -4,14 +4,20 @@ import { MAX_ITERATIONS_NUM } from '@/config' import { AgentSetting } from '../index' vi.mock('@langgenius/dify-ui/slider', () => ({ - Slider: (props: { className?: string, min?: number, max?: number, value: number, onValueChange: (value: number) => void }) => ( + Slider: (props: { + className?: string + min?: number + max?: number + value: number + onValueChange: (value: number) => void + }) => ( <input type="range" className={`slider ${props.className ?? ''}`} min={props.min} max={props.max} value={props.value} - onChange={e => props.onValueChange(Number(e.target.value))} + onChange={(e) => props.onValueChange(Number(e.target.value))} /> ), })) diff --git a/web/app/components/app/configuration/config/agent/agent-setting/index.tsx b/web/app/components/app/configuration/config/agent/agent-setting/index.tsx index 37213fa30db1a5..ecbbb9528fc215 100644 --- a/web/app/components/app/configuration/config/agent/agent-setting/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-setting/index.tsx @@ -21,16 +21,12 @@ type Props = Readonly<{ const maxIterationsMin = 1 -export function AgentSetting({ - isChatModel, - payload, - isFunctionCall, - onCancel, - onSave, -}: Props) { +export function AgentSetting({ isChatModel, payload, isFunctionCall, onCancel, onSave }: Props) { const { t } = useTranslation() const [tempPayload, setTempPayload] = useState(payload) - const maximumIterationsLabel = t($ => $['agent.setting.maximumIterations.name'], { ns: 'appDebug' }) + const maximumIterationsLabel = t(($) => $['agent.setting.maximumIterations.name'], { + ns: 'appDebug', + }) const handleSave = () => { onSave(tempPayload) @@ -40,18 +36,17 @@ export function AgentSetting({ <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DialogContent className="top-2 right-2 bottom-2 left-auto flex h-auto max-h-none w-[640px] max-w-[calc(100vw-1rem)] translate-x-0 translate-y-0 flex-col overflow-hidden rounded-xl p-0"> <div className="flex h-14 shrink-0 items-center justify-between border-b border-divider-regular pr-5 pl-6"> <DialogTitle className="text-base leading-6 font-semibold text-text-primary"> - {t($ => $['agent.setting.name'], { ns: 'appDebug' })} + {t(($) => $['agent.setting.name'], { ns: 'appDebug' })} </DialogTitle> <DialogCloseButton className="static z-auto size-6 shrink-0" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} /> </div> {/* Body */} @@ -64,22 +59,24 @@ export function AgentSetting({ {/* Agent Mode */} <ItemPanel className="mb-4" - icon={ - <CuteRobot className="size-4 text-indigo-600" /> - } - name={t($ => $['agent.agentMode'], { ns: 'appDebug' })} - description={t($ => $['agent.agentModeDes'], { ns: 'appDebug' })} + icon={<CuteRobot className="size-4 text-indigo-600" />} + name={t(($) => $['agent.agentMode'], { ns: 'appDebug' })} + description={t(($) => $['agent.agentModeDes'], { ns: 'appDebug' })} > - <div className="text-[13px] leading-[18px] font-medium text-text-primary">{isFunctionCall ? t($ => $['agent.agentModeType.functionCall'], { ns: 'appDebug' }) : t($ => $['agent.agentModeType.ReACT'], { ns: 'appDebug' })}</div> + <div className="text-[13px] leading-[18px] font-medium text-text-primary"> + {isFunctionCall + ? t(($) => $['agent.agentModeType.functionCall'], { ns: 'appDebug' }) + : t(($) => $['agent.agentModeType.ReACT'], { ns: 'appDebug' })} + </div> </ItemPanel> <ItemPanel className="mb-4" - icon={ - <Unblur className="h-4 w-4 text-[#FB6514]" /> - } + icon={<Unblur className="h-4 w-4 text-[#FB6514]" />} name={maximumIterationsLabel} - description={t($ => $['agent.setting.maximumIterations.description'], { ns: 'appDebug' })} + description={t(($) => $['agent.setting.maximumIterations.description'], { + ns: 'appDebug', + })} > <Fieldset className="flex items-center"> <FieldsetLegend className="sr-only">{maximumIterationsLabel}</FieldsetLegend> @@ -107,11 +104,9 @@ export function AgentSetting({ value={tempPayload.max_iteration} onChange={(e) => { let value = Number.parseInt(e.target.value, 10) - if (value < maxIterationsMin) - value = maxIterationsMin + if (value < maxIterationsMin) value = maxIterationsMin - if (value > MAX_ITERATIONS_NUM) - value = MAX_ITERATIONS_NUM + if (value > MAX_ITERATIONS_NUM) value = MAX_ITERATIONS_NUM setTempPayload({ ...tempPayload, max_iteration: value, @@ -123,33 +118,29 @@ export function AgentSetting({ {!isFunctionCall && ( <div className="rounded-xl bg-background-section-burn py-2 shadow-xs"> - <div className="flex h-8 items-center px-4 text-sm/6 font-semibold text-text-secondary">{t($ => $.builtInPromptTitle, { ns: 'tools' })}</div> + <div className="flex h-8 items-center px-4 text-sm/6 font-semibold text-text-secondary"> + {t(($) => $.builtInPromptTitle, { ns: 'tools' })} + </div> <div className="h-[396px] overflow-y-auto px-4 text-sm leading-5 font-normal whitespace-pre-line text-text-secondary"> {isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion} </div> <div className="px-4"> - <div className="inline-flex h-5 items-center rounded-md bg-components-input-bg-normal px-1 text-xs leading-[18px] font-medium text-text-tertiary">{(isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion).length}</div> + <div className="inline-flex h-5 items-center rounded-md bg-components-input-bg-normal px-1 text-xs leading-[18px] font-medium text-text-tertiary"> + { + (isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion) + .length + } + </div> </div> </div> )} - </div> - <div - className="sticky bottom-0 z-5 flex w-full justify-end border-t border-divider-regular bg-background-section-burn px-6 py-4" - > - <Button - type="button" - onClick={onCancel} - className="mr-2" - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <div className="sticky bottom-0 z-5 flex w-full justify-end border-t border-divider-regular bg-background-section-burn px-6 py-4"> + <Button type="button" onClick={onCancel} className="mr-2"> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button - type="button" - variant="primary" - onClick={handleSave} - > - {t($ => $['operation.save'], { ns: 'common' })} + <Button type="button" variant="primary" onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx b/web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx index b860a8fd86ea74..50b697af0cac93 100644 --- a/web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx +++ b/web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx @@ -12,15 +12,14 @@ type Props = Readonly<{ children: React.JSX.Element }> -const ItemPanel: FC<Props> = ({ - className, - icon, - name, - description, - children, -}) => { +const ItemPanel: FC<Props> = ({ className, icon, name, description, children }) => { return ( - <div className={cn(className, 'flex h-12 items-center justify-between rounded-lg bg-background-section-burn px-3')}> + <div + className={cn( + className, + 'flex h-12 items-center justify-between rounded-lg bg-background-section-burn px-3', + )} + > <div className="flex items-center"> {icon} <div className="mr-1 ml-3 text-sm/6 font-semibold text-text-secondary">{name}</div> @@ -28,9 +27,7 @@ const ItemPanel: FC<Props> = ({ {description} </Infotip> </div> - <div> - {children} - </div> + <div>{children}</div> </div> ) } diff --git a/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx index 79a1c480aec5c9..814e85b68d7213 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx @@ -1,7 +1,5 @@ /* eslint-disable ts/no-explicit-any */ -import type { - PropsWithChildren, -} from 'react' +import type { PropsWithChildren } from 'react' import type { Mock } from 'vitest' import type SettingBuiltInToolType from '../setting-built-in-tool' import type { Tool, ToolParameter } from '@/app/components/tools/types' @@ -13,11 +11,7 @@ import type { AgentTool } from '@/types/app' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import { - useEffect, - useMemo, - useState, -} from 'react' +import { useEffect, useMemo, useState } from 'react' import { CollectionType } from '@/app/components/tools/types' import { DEFAULT_AGENT_SETTING, @@ -36,13 +30,13 @@ vi.mock('@/app/components/app/configuration/debug/hooks', () => ({ let pluginInstallHandler: ((names: string[]) => void) | null = null const subscribeMock = vi.fn((event: string, handler: any) => { - if (event === 'plugin:install:success') - pluginInstallHandler = handler + if (event === 'plugin:install:success') pluginInstallHandler = handler }) vi.mock('@/context/mitt-context', () => ({ - useMittContextSelector: (selector: any) => selector({ - useSubscribe: subscribeMock, - }), + useMittContextSelector: (selector: any) => + selector({ + useSubscribe: subscribeMock, + }), })) let builtInTools: ToolWithProvider[] = [] @@ -68,10 +62,7 @@ const ToolPickerMock = (props: ToolPickerProps) => ( > pick-single </button> - <button - type="button" - onClick={() => props.onSelectMultiple(multipleToolSelection)} - > + <button type="button" onClick={() => props.onSelectMultiple(multipleToolSelection)}> pick-multiple </button> </div> @@ -89,9 +80,18 @@ const SettingBuiltInToolMock = (props: SettingBuiltInToolProps) => { return ( <div data-testid="setting-built-in-tool"> <span>{props.toolName}</span> - <button type="button" onClick={() => props.onSave?.(settingPanelSavePayload)}>save-from-panel</button> - <button type="button" onClick={() => props.onAuthorizationItemClick?.(settingPanelCredentialId)}>auth-from-panel</button> - <button type="button" onClick={props.onHide}>close-panel</button> + <button type="button" onClick={() => props.onSave?.(settingPanelSavePayload)}> + save-from-panel + </button> + <button + type="button" + onClick={() => props.onAuthorizationItemClick?.(settingPanelCredentialId)} + > + auth-from-panel + </button> + <button type="button" onClick={props.onHide}> + close-panel + </button> </div> ) } @@ -220,15 +220,14 @@ const renderAgentTools = (initialTools?: AgentTool[]) => { useEffect(() => { modelConfigRef.current = modelConfig }, [modelConfig]) - const value = useMemo(() => ({ - modelConfig, - setModelConfig, - }), [modelConfig]) - return ( - <ConfigContext.Provider value={value as any}> - {children} - </ConfigContext.Provider> + const value = useMemo( + () => ({ + modelConfig, + setModelConfig, + }), + [modelConfig], ) + return <ConfigContext.Provider value={value as any}>{children}</ConfigContext.Provider> } const renderResult = render( <Wrapper> @@ -244,8 +243,7 @@ const renderAgentTools = (initialTools?: AgentTool[]) => { const hoverInfoIcon = async (rowIndex = 0) => { const rows = document.querySelectorAll('.group') const infoTrigger = rows.item(rowIndex)?.querySelector('[aria-label="search"]') - if (!infoTrigger) - throw new Error('Info trigger not found') + if (!infoTrigger) throw new Error('Info trigger not found') await userEvent.hover(infoTrigger as HTMLElement) } @@ -257,24 +255,28 @@ describe('AgentTools', () => { createCollection({ id: 'provider-2', name: 'vendor/provider-2', - tools: [createToolDefinition({ - name: 'translate', - label: { - en_US: 'Translate', - zh_Hans: 'Translate', - }, - })], + tools: [ + createToolDefinition({ + name: 'translate', + label: { + en_US: 'Translate', + zh_Hans: 'Translate', + }, + }), + ], }), createCollection({ id: 'provider-3', name: 'vendor/provider-3', - tools: [createToolDefinition({ - name: 'summarize', - label: { - en_US: 'Summary', - zh_Hans: 'Summary', - }, - })], + tools: [ + createToolDefinition({ + name: 'summarize', + label: { + en_US: 'Summary', + zh_Hans: 'Summary', + }, + }), + ], }), ] customTools = [] @@ -339,7 +341,9 @@ describe('AgentTools', () => { }), ]) - const enabledText = screen.getByText(content => content.includes('appDebug.agent.tools.enabled')) + const enabledText = screen.getByText((content) => + content.includes('appDebug.agent.tools.enabled'), + ) expect(enabledText).toHaveTextContent('1/2') expect(screen.getByText('provider-1')).toBeInTheDocument() expect(screen.getByText('Translate Tool')).toBeInTheDocument() @@ -369,8 +373,11 @@ describe('AgentTools', () => { await userEvent.click(switchButton) await waitFor(() => { - const tools = getModelConfig().agentConfig.tools as Array<{ tool_name?: string, enabled?: boolean }> - const toggledTool = tools.find(tool => tool.tool_name === 'search') + const tools = getModelConfig().agentConfig.tools as Array<{ + tool_name?: string + enabled?: boolean + }> + const toggledTool = tools.find((tool) => tool.tool_name === 'search') expect(toggledTool?.enabled).toBe(false) }) expect(formattingDispatcherMock).toHaveBeenCalled() @@ -379,8 +386,7 @@ describe('AgentTools', () => { it('should remove tool when delete action is clicked', async () => { const { getModelConfig } = renderAgentTools() const deleteButton = screen.getByRole('button', { name: /operation\.delete/i }) - if (!deleteButton) - throw new Error('Delete button not found') + if (!deleteButton) throw new Error('Delete button not found') await userEvent.click(deleteButton) await waitFor(() => { expect(getModelConfig().agentConfig.tools).toHaveLength(0) @@ -434,7 +440,10 @@ describe('AgentTools', () => { await userEvent.click(screen.getByRole('button', { name: 'save-from-panel' })) await waitFor(() => { - expect((getModelConfig().agentConfig.tools[0] as { tool_parameters: Record<string, any> }).tool_parameters).toEqual({ api_key: 'updated' }) + expect( + (getModelConfig().agentConfig.tools[0] as { tool_parameters: Record<string, any> }) + .tool_parameters, + ).toEqual({ api_key: 'updated' }) }) }) @@ -449,7 +458,9 @@ describe('AgentTools', () => { await userEvent.click(screen.getByRole('button', { name: 'auth-from-panel' })) await waitFor(() => { - expect((getModelConfig().agentConfig.tools[0] as { credential_id: string }).credential_id).toBe('credential-123') + expect( + (getModelConfig().agentConfig.tools[0] as { credential_id: string }).credential_id, + ).toBe('credential-123') }) expect(formattingDispatcherMock).toHaveBeenCalled() }) @@ -469,15 +480,14 @@ describe('AgentTools', () => { ]) const authorizationButtons = screen.getAllByRole('button', { name: /tools.notAuthorized/ }) const secondAuthorizationButton = authorizationButtons[1] - if (!secondAuthorizationButton) - throw new Error('Second authorization button not found') + if (!secondAuthorizationButton) throw new Error('Second authorization button not found') await userEvent.click(secondAuthorizationButton) settingPanelCredentialId = 'credential-updated' await userEvent.click(screen.getByRole('button', { name: 'auth-from-panel' })) await waitFor(() => { const tools = getModelConfig().agentConfig.tools as AgentTool[] - expect(tools.map(tool => tool.credential_id)).toEqual([ + expect(tools.map((tool) => tool.credential_id)).toEqual([ 'credential-search', 'credential-updated', ]) @@ -494,15 +504,16 @@ describe('AgentTools', () => { isDeleted: true, }), ]) - if (!pluginInstallHandler) - throw new Error('Plugin handler not registered') + if (!pluginInstallHandler) throw new Error('Plugin handler not registered') await act(async () => { pluginInstallHandler?.(['provider-1']) }) await waitFor(() => { - expect((getModelConfig().agentConfig.tools[0] as { isDeleted: boolean }).isDeleted).toBe(false) + expect((getModelConfig().agentConfig.tools[0] as { isDeleted: boolean }).isDeleted).toBe( + false, + ) }) }) }) diff --git a/web/app/components/app/configuration/config/agent/agent-tools/__tests__/setting-built-in-tool.spec.tsx b/web/app/components/app/configuration/config/agent/agent-tools/__tests__/setting-built-in-tool.spec.tsx index cbd2b2e19d0fd2..550178ef9b9e38 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/__tests__/setting-built-in-tool.spec.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/__tests__/setting-built-in-tool.spec.tsx @@ -26,10 +26,7 @@ const FormMock = ({ value, onChange }: MockFormProps) => { return ( <div data-testid="mock-form"> <div data-testid="form-value">{JSON.stringify(value)}</div> - <button - type="button" - onClick={() => onChange({ ...value, ...nextFormValue })} - > + <button type="button" onClick={() => onChange({ ...value, ...nextFormValue })}> update-form </button> </div> @@ -52,7 +49,9 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({ })) vi.mock('@/app/components/plugins/readme-panel/entrance', () => ({ - ReadmeEntrance: ({ className }: { className?: string }) => <div className={className}>readme</div>, + ReadmeEntrance: ({ className }: { className?: string }) => ( + <div className={className}>readme</div> + ), })) const createParameter = (overrides?: Partial<ToolParameter>): ToolParameter => ({ @@ -239,9 +238,11 @@ describe('SettingBuiltInTool', () => { }) it('should load workflow tools when workflow collection is provided', async () => { - fetchWorkflowToolList.mockResolvedValueOnce([createTool({ - name: 'workflow-tool', - })]) + fetchWorkflowToolList.mockResolvedValueOnce([ + createTool({ + name: 'workflow-tool', + }), + ]) renderComponent({ collection: { ...baseCollection, diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx index 6ff193e68981fe..0a321e0cdaaafc 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx @@ -10,10 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Switch } from '@langgenius/dify-ui/switch' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiDeleteBinLine, - RiEqualizer2Line, -} from '@remixicon/react' +import { RiDeleteBinLine, RiEqualizer2Line } from '@remixicon/react' import { produce } from 'immer' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' @@ -26,18 +23,26 @@ import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' import { Infotip } from '@/app/components/base/infotip' import { CollectionType } from '@/app/components/tools/types' -import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' +import { + addDefaultValue, + toolParametersToFormSchemas, +} from '@/app/components/tools/utils/to-form-schema' import ToolPicker from '@/app/components/workflow/block-selector/tool-picker' import { MAX_TOOLS_NUM } from '@/config' import ConfigContext from '@/context/debug-configuration' import { useMittContextSelector } from '@/context/mitt-context' -import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' import { canFindTool } from '@/utils' import { writeTextToClipboard } from '@/utils/clipboard' import { useFormattingChangedDispatcher } from '../../../debug/hooks' import SettingBuiltInTool from './setting-built-in-tool' -type AgentToolWithMoreInfo = AgentTool & { icon: any, collection?: Collection } | null +type AgentToolWithMoreInfo = (AgentTool & { icon: any; collection?: Collection }) | null const AgentTools: FC = () => { const { t } = useTranslation() const [isShowChooseTool, setIsShowChooseTool] = useState(false) @@ -59,11 +64,10 @@ const AgentTools: FC = () => { const formattingChangedDispatcher = useFormattingChangedDispatcher() const [currentTool, setCurrentTool] = useState<AgentToolWithMoreInfo>(null) const [isShowSettingTool, setIsShowSettingTool] = useState(false) - const tools = (modelConfig?.agentConfig?.tools as AgentTool[] || []).map((item) => { + const tools = ((modelConfig?.agentConfig?.tools as AgentTool[]) || []).map((item) => { const collection = collectionList.find( - collection => - canFindTool(collection.id, item.provider_id) - && collection.type === item.provider_type, + (collection) => + canFindTool(collection.id, item.provider_id) && collection.type === item.provider_type, ) const icon = collection?.icon return { @@ -72,23 +76,29 @@ const AgentTools: FC = () => { collection, } }) - const useSubscribe = useMittContextSelector(s => s.useSubscribe) - const handleUpdateToolsWhenInstallToolSuccess = useCallback((installedPluginNames: string[]) => { - const newModelConfig = produce(modelConfig, (draft) => { - draft.agentConfig.tools.forEach((item: any) => { - if (item.isDeleted && installedPluginNames.includes(item.provider_id)) - item.isDeleted = false + const useSubscribe = useMittContextSelector((s) => s.useSubscribe) + const handleUpdateToolsWhenInstallToolSuccess = useCallback( + (installedPluginNames: string[]) => { + const newModelConfig = produce(modelConfig, (draft) => { + draft.agentConfig.tools.forEach((item: any) => { + if (item.isDeleted && installedPluginNames.includes(item.provider_id)) + item.isDeleted = false + }) }) - }) - setModelConfig(newModelConfig) - }, [modelConfig, setModelConfig]) + setModelConfig(newModelConfig) + }, + [modelConfig, setModelConfig], + ) useSubscribe('plugin:install:success', handleUpdateToolsWhenInstallToolSuccess as any) const handleToolSettingChange = (value: Record<string, any>) => { const newModelConfig = produce(modelConfig, (draft) => { - const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.collection?.id && item.tool_name === currentTool?.tool_name) - if (tool) - (tool as AgentTool).tool_parameters = value + const tool = draft.agentConfig.tools.find( + (item: any) => + item.provider_id === currentTool?.collection?.id && + item.tool_name === currentTool?.tool_name, + ) + if (tool) (tool as AgentTool).tool_parameters = value }) setModelConfig(newModelConfig) setIsShowSettingTool(false) @@ -96,11 +106,14 @@ const AgentTools: FC = () => { } const [isDeleting, setIsDeleting] = useState<number>(-1) - const getDeleteToolLabel = (tool: AgentTool) => `${t($ => $['operation.delete'], { ns: 'common' })} ${tool.tool_label || tool.tool_name}` + const getDeleteToolLabel = (tool: AgentTool) => + `${t(($) => $['operation.delete'], { ns: 'common' })} ${tool.tool_label || tool.tool_name}` const getToolValue = (tool: ToolDefaultValue) => { - const currToolInCollections = collectionList.find(c => c.id === tool.provider_id) - const currToolWithConfigs = currToolInCollections?.tools.find(t => t.name === tool.tool_name) - const formSchemas = currToolWithConfigs ? toolParametersToFormSchemas(currToolWithConfigs.parameters) : [] + const currToolInCollections = collectionList.find((c) => c.id === tool.provider_id) + const currToolWithConfigs = currToolInCollections?.tools.find((t) => t.name === tool.tool_name) + const formSchemas = currToolWithConfigs + ? toolParametersToFormSchemas(currToolWithConfigs.parameters) + : [] const paramsWithDefaultValue = addDefaultValue(tool.params, formSchemas) return { provider_id: tool.provider_id, @@ -129,46 +142,49 @@ const AgentTools: FC = () => { } const getProviderShowName = (item: AgentTool) => { const type = item.provider_type - if (type === CollectionType.builtIn) - return item.provider_name.split('/').pop() + if (type === CollectionType.builtIn) return item.provider_name.split('/').pop() return item.provider_name } - const handleAuthorizationItemClick = useCallback((credentialId: string) => { - const newModelConfig = produce(modelConfig, (draft) => { - const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id && item.tool_name === currentTool?.tool_name) - if (tool) - (tool as AgentTool).credential_id = credentialId - }) - setCurrentTool({ - ...currentTool, - credential_id: credentialId, - } as any) - setModelConfig(newModelConfig) - formattingChangedDispatcher() - }, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher]) + const handleAuthorizationItemClick = useCallback( + (credentialId: string) => { + const newModelConfig = produce(modelConfig, (draft) => { + const tool = draft.agentConfig.tools.find( + (item: any) => + item.provider_id === currentTool?.provider_id && + item.tool_name === currentTool?.tool_name, + ) + if (tool) (tool as AgentTool).credential_id = credentialId + }) + setCurrentTool({ + ...currentTool, + credential_id: credentialId, + } as any) + setModelConfig(newModelConfig) + formattingChangedDispatcher() + }, + [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher], + ) return ( <> <Panel className={cn('mt-2', tools.length === 0 && 'pb-2')} noBodySpacing={tools.length === 0} - title={( + title={ <div className="flex items-center"> - <div className="mr-1">{t($ => $['agent.tools.name'], { ns: 'appDebug' })}</div> - <Infotip aria-label={t($ => $['agent.tools.description'], { ns: 'appDebug' })}> - {t($ => $['agent.tools.description'], { ns: 'appDebug' })} + <div className="mr-1">{t(($) => $['agent.tools.name'], { ns: 'appDebug' })}</div> + <Infotip aria-label={t(($) => $['agent.tools.description'], { ns: 'appDebug' })}> + {t(($) => $['agent.tools.description'], { ns: 'appDebug' })} </Infotip> </div> - )} - headerRight={( + } + headerRight={ <div className="flex items-center"> <div className="text-xs leading-[18px] font-normal text-text-tertiary"> - {tools.filter(item => !!item.enabled).length} - / - {tools.length} + {tools.filter((item) => !!item.enabled).length}/{tools.length}   - {t($ => $['agent.tools.enabled'], { ns: 'appDebug' })} + {t(($) => $['agent.tools.enabled'], { ns: 'appDebug' })} </div> {tools.length < MAX_TOOLS_NUM && !readonly && ( <> @@ -186,32 +202,52 @@ const AgentTools: FC = () => { </> )} </div> - )} + } > - <div className={cn('grid grid-cols-1 items-center gap-1 2xl:grid-cols-2', readonly && 'cursor-not-allowed grid-cols-2')}> - {tools.map((item: AgentTool & { icon: any, collection?: Collection }, index) => ( + <div + className={cn( + 'grid grid-cols-1 items-center gap-1 2xl:grid-cols-2', + readonly && 'cursor-not-allowed grid-cols-2', + )} + > + {tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => ( <div key={index} className={cn( 'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', - isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover', + isDeleting === index && + 'border-state-destructive-border hover:bg-state-destructive-hover', )} > <div className="flex w-0 grow items-center"> {item.isDeleted && <DefaultToolIcon className="size-5" />} {!item.isDeleted && ( <div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}> - {typeof item.icon === 'string' && <div className="size-5 rounded-md bg-cover bg-center" style={{ backgroundImage: `url(${item.icon})` }} />} - {typeof item.icon !== 'string' && <AppIcon className="rounded-md" size="xs" icon={item.icon?.content} background={item.icon?.background} />} + {typeof item.icon === 'string' && ( + <div + className="size-5 rounded-md bg-cover bg-center" + style={{ backgroundImage: `url(${item.icon})` }} + /> + )} + {typeof item.icon !== 'string' && ( + <AppIcon + className="rounded-md" + size="xs" + icon={item.icon?.content} + background={item.icon?.background} + /> + )} </div> )} <div className={cn( 'ml-1.5 flex w-0 grow items-center truncate system-xs-regular', - (item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '', + item.isDeleted || item.notAuthor || !item.enabled ? 'opacity-50' : '', )} > - <span className="pr-1.5 system-xs-medium text-text-secondary">{getProviderShowName(item)}</span> + <span className="pr-1.5 system-xs-medium text-text-secondary"> + {getProviderShowName(item)} + </span> <span className="text-text-tertiary">{item.tool_label}</span> {!item.isDeleted && !readonly && ( <Infotip @@ -221,13 +257,15 @@ const AgentTools: FC = () => { > <div> <div className="mb-1.5 text-text-secondary">{item.tool_name}</div> - <div className="mb-1.5 text-text-tertiary">{t($ => $.toolNameUsageTip, { ns: 'tools' })}</div> + <div className="mb-1.5 text-text-tertiary"> + {t(($) => $.toolNameUsageTip, { ns: 'tools' })} + </div> <button type="button" className="cursor-pointer rounded-sm border-none bg-transparent p-0 text-left text-text-accent outline-hidden hover:underline focus-visible:ring-1 focus-visible:ring-components-input-border-hover" onClick={() => void writeTextToClipboard(item.tool_name)} > - {t($ => $.copyToolName, { ns: 'tools' })} + {t(($) => $.copyToolName, { ns: 'tools' })} </button> </div> </Infotip> @@ -240,18 +278,18 @@ const AgentTools: FC = () => { <Popover> <PopoverTrigger openOnHover - aria-label={t($ => $.toolRemoved, { ns: 'tools' })} - render={( + aria-label={t(($) => $.toolRemoved, { ns: 'tools' })} + render={ <button type="button" className="mr-1 cursor-pointer rounded-md p-1 outline-hidden hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-components-input-border-hover" > <AlertTriangle className="h-4 w-4 text-[#F79009]" /> </button> - )} + } /> <PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary"> - {t($ => $.toolRemoved, { ns: 'tools' })} + {t(($) => $.toolRemoved, { ns: 'tools' })} </PopoverContent> </Popover> <button @@ -277,11 +315,13 @@ const AgentTools: FC = () => { {!item.notAuthor && ( <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" className="cursor-pointer rounded-md p-1 outline-hidden hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-components-input-border-hover" - aria-label={t($ => $['setBuiltInTools.infoAndSetting'], { ns: 'tools' })} + aria-label={t(($) => $['setBuiltInTools.infoAndSetting'], { + ns: 'tools', + })} onClick={() => { setCurrentTool(item) setIsShowSettingTool(true) @@ -289,10 +329,10 @@ const AgentTools: FC = () => { > <RiEqualizer2Line className="size-4 text-text-tertiary" /> </button> - )} + } /> <TooltipContent> - {t($ => $['setBuiltInTools.infoAndSetting'], { ns: 'tools' })} + {t(($) => $['setBuiltInTools.infoAndSetting'], { ns: 'tools' })} </TooltipContent> </Tooltip> )} @@ -322,7 +362,7 @@ const AgentTools: FC = () => { size="md" onCheckedChange={(enabled) => { const newModelConfig = produce(modelConfig, (draft) => { - (draft.agentConfig.tools[index] as any).enabled = enabled + ;(draft.agentConfig.tools[index] as any).enabled = enabled }) setModelConfig(newModelConfig) formattingChangedDispatcher() @@ -339,7 +379,7 @@ const AgentTools: FC = () => { setIsShowSettingTool(true) }} > - {t($ => $.notAuthorized, { ns: 'tools' })} + {t(($) => $.notAuthorized, { ns: 'tools' })} <StatusDot className="ml-2" status="warning" /> </Button> )} diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx index 1537d42dba0a95..c7cfd8ca95145a 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx @@ -12,10 +12,7 @@ import { DrawerPortal, DrawerViewport, } from '@langgenius/dify-ui/drawer' -import { - RiArrowLeftLine, - RiCloseLine, -} from '@remixicon/react' +import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -26,16 +23,18 @@ import Form from '@/app/components/header/account-setting/model-provider-page/mo import Icon from '@/app/components/plugins/card/base/card-icon' import Description from '@/app/components/plugins/card/base/description' import OrgInfo from '@/app/components/plugins/card/base/org-info' -import { - AuthCategory, - PluginAuthInAgent, -} from '@/app/components/plugins/plugin-auth' +import { AuthCategory, PluginAuthInAgent } from '@/app/components/plugins/plugin-auth' import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { CollectionType } from '@/app/components/tools/types' import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { useLocale } from '@/context/i18n' import { getLanguage } from '@/i18n-config/language' -import { fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList, fetchWorkflowToolList } from '@/service/tools' +import { + fetchBuiltInToolList, + fetchCustomToolList, + fetchModelToolList, + fetchWorkflowToolList, +} from '@/service/tools' type Props = Readonly<{ showBackButton?: boolean @@ -71,63 +70,53 @@ const SettingBuiltInTool: FC<Props> = ({ const hasPassedTools = passedTools?.length > 0 const [isLoading, setIsLoading] = useState(!hasPassedTools) const [tools, setTools] = useState<Tool[]>(hasPassedTools ? passedTools : []) - const currTool = tools.find(tool => tool.name === toolName) + const currTool = tools.find((tool) => tool.name === toolName) const formSchemas = currTool ? toolParametersToFormSchemas(currTool.parameters) : [] - const infoSchemas = formSchemas.filter(item => item.form === 'llm') - const settingSchemas = formSchemas.filter(item => item.form !== 'llm') + const infoSchemas = formSchemas.filter((item) => item.form === 'llm') + const settingSchemas = formSchemas.filter((item) => item.form !== 'llm') const hasSetting = settingSchemas.length > 0 const [tempSetting, setTempSetting] = useState(setting) const [currType, setCurrType] = useState('info') const isInfoActive = currType === 'info' useEffect(() => { - if (!collection || hasPassedTools) - return + if (!collection || hasPassedTools) return - (async () => { + ;(async () => { setIsLoading(true) try { const list = await new Promise<Tool[]>((resolve) => { - (async function () { - if (isModel) - resolve(await fetchModelToolList(collection.name)) - else if (isBuiltIn) - resolve(await fetchBuiltInToolList(collection.name)) + ;(async function () { + if (isModel) resolve(await fetchModelToolList(collection.name)) + else if (isBuiltIn) resolve(await fetchBuiltInToolList(collection.name)) else if (collection.type === CollectionType.workflow) resolve(await fetchWorkflowToolList(collection.id)) - else - resolve(await fetchCustomToolList(collection.name)) - }()) + else resolve(await fetchCustomToolList(collection.name)) + })() }) setTools(list) - } - catch { } + } catch {} setIsLoading(false) })() // eslint-disable-next-line react-hooks/exhaustive-deps }, [collection?.name, collection?.id, collection?.type]) useEffect(() => { - setCurrType((!readonly && hasSetting) ? 'setting' : 'info') + setCurrType(!readonly && hasSetting ? 'setting' : 'info') }, [hasSetting]) const isValid = (() => { let valid = true settingSchemas.forEach((item) => { - if (item.required && !tempSetting[item.name]) - valid = false + if (item.required && !tempSetting[item.name]) valid = false }) return valid })() const getType = (type: string) => { - if (type === 'number-input') - return t($ => $['setBuiltInTools.number'], { ns: 'tools' }) - if (type === 'text-input') - return t($ => $['setBuiltInTools.string'], { ns: 'tools' }) - if (type === 'checkbox') - return 'boolean' - if (type === 'file') - return t($ => $['setBuiltInTools.file'], { ns: 'tools' }) + if (type === 'number-input') return t(($) => $['setBuiltInTools.number'], { ns: 'tools' }) + if (type === 'text-input') return t(($) => $['setBuiltInTools.string'], { ns: 'tools' }) + if (type === 'checkbox') return 'boolean' + if (type === 'file') return t(($) => $['setBuiltInTools.file'], { ns: 'tools' }) return type } @@ -139,11 +128,11 @@ const SettingBuiltInTool: FC<Props> = ({ <div key={index} className="py-1"> <div className="flex items-center gap-2"> <div className="code-sm-semibold text-text-secondary">{item.label[language]}</div> - <div className="system-xs-regular text-text-tertiary"> - {getType(item.type)} - </div> + <div className="system-xs-regular text-text-tertiary">{getType(item.type)}</div> {item.required && ( - <div className="system-xs-medium text-text-warning-secondary">{t($ => $['setBuiltInTools.required'], { ns: 'tools' })}</div> + <div className="system-xs-medium text-text-warning-secondary"> + {t(($) => $['setBuiltInTools.required'], { ns: 'tools' })} + </div> )} </div> {item.human_description && ( @@ -176,14 +165,17 @@ const SettingBuiltInTool: FC<Props> = ({ modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DrawerPortal> <DrawerBackdrop className="bg-background-overlay" /> <DrawerViewport> - <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-6 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-6 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[420px] data-[swipe-direction=right]:max-w-[420px] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerPopup + className={cn( + 'justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-6 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-6 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[420px] data-[swipe-direction=right]:max-w-[420px] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border', + )} + > <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> {isLoading && <Loading type="app" />} {!isLoading && ( @@ -201,7 +193,7 @@ const SettingBuiltInTool: FC<Props> = ({ onClick={onHide} > <RiArrowLeftLine className="size-4" /> - {t($ => $['detailPanel.operation.back'], { ns: 'plugin' })} + {t(($) => $['detailPanel.operation.back'], { ns: 'plugin' })} </div> )} <div className="flex items-center gap-1"> @@ -212,53 +204,75 @@ const SettingBuiltInTool: FC<Props> = ({ packageName={collection.name.split('/').pop() || ''} /> </div> - <div className="mt-1 system-md-semibold text-text-primary">{currTool?.label[language]}</div> + <div className="mt-1 system-md-semibold text-text-primary"> + {currTool?.label[language]} + </div> {!!currTool?.description[language] && ( - <Description className="mt-3 mb-2 h-auto" text={currTool.description[language]} descriptionLineRows={2}></Description> + <Description + className="mt-3 mb-2 h-auto" + text={currTool.description[language]} + descriptionLineRows={2} + ></Description> + )} + {collection.allow_delete && collection.type === CollectionType.builtIn && ( + <PluginAuthInAgent + pluginPayload={{ + provider: collection.name, + category: AuthCategory.tool, + providerType: collection.type, + detail: collection as any, + }} + credentialId={credentialId} + onAuthorizationItemClick={onAuthorizationItemClick} + /> )} - { - collection.allow_delete && collection.type === CollectionType.builtIn && ( - <PluginAuthInAgent - pluginPayload={{ - provider: collection.name, - category: AuthCategory.tool, - providerType: collection.type, - detail: collection as any, - }} - credentialId={credentialId} - onAuthorizationItemClick={onAuthorizationItemClick} - /> - ) - } </div> {/* form */} <div className="h-full"> <div className="flex h-full flex-col"> - {(hasSetting && !readonly) - ? ( - <TabSlider - className="mt-1 shrink-0 px-4" - itemClassName="py-3" - noBorderBottom - value={currType} - onChange={(value) => { - setCurrType(value) - }} - options={[ - { value: 'info', text: t($ => $['setBuiltInTools.parameters'], { ns: 'tools' })! }, - { value: 'setting', text: t($ => $['setBuiltInTools.setting'], { ns: 'tools' })! }, - ]} - /> - ) - : ( - <div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary">{t($ => $['setBuiltInTools.parameters'], { ns: 'tools' })}</div> - )} + {hasSetting && !readonly ? ( + <TabSlider + className="mt-1 shrink-0 px-4" + itemClassName="py-3" + noBorderBottom + value={currType} + onChange={(value) => { + setCurrType(value) + }} + options={[ + { + value: 'info', + text: t(($) => $['setBuiltInTools.parameters'], { ns: 'tools' })!, + }, + { + value: 'setting', + text: t(($) => $['setBuiltInTools.setting'], { ns: 'tools' })!, + }, + ]} + /> + ) : ( + <div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary"> + {t(($) => $['setBuiltInTools.parameters'], { ns: 'tools' })} + </div> + )} <div className="h-0 grow overflow-y-auto px-4"> {isInfoActive ? infoUI : settingUI} {!readonly && !isInfoActive && ( <div className="flex shrink-0 justify-end space-x-2 rounded-b-[10px] bg-components-panel-bg py-2"> - <Button className="flex h-8 items-center px-3! text-[13px]! font-medium" onClick={onHide}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button className="flex h-8 items-center px-3! text-[13px]! font-medium" variant="primary" disabled={!isValid} onClick={() => onSave?.(tempSetting)}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button + className="flex h-8 items-center px-3! text-[13px]! font-medium" + onClick={onHide} + > + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button + className="flex h-8 items-center px-3! text-[13px]! font-medium" + variant="primary" + disabled={!isValid} + onClick={() => onSave?.(tempSetting)} + > + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> )} </div> diff --git a/web/app/components/app/configuration/config/auto-gen-model-storage.ts b/web/app/components/app/configuration/config/auto-gen-model-storage.ts index 137e8bc556a7eb..7159118f061f2e 100644 --- a/web/app/components/app/configuration/config/auto-gen-model-storage.ts +++ b/web/app/components/app/configuration/config/auto-gen-model-storage.ts @@ -1,12 +1,7 @@ import type { Model } from '@/types/app' import { createLocalStorageState } from 'foxact/create-local-storage-state' -const [ - useAutoGenModel, - _useAutoGenModelValue, - _useSetAutoGenModel, -] = createLocalStorageState<Model>('auto-gen-model') +const [useAutoGenModel, _useAutoGenModelValue, _useSetAutoGenModel] = + createLocalStorageState<Model>('auto-gen-model') -export { - useAutoGenModel, -} +export { useAutoGenModel } diff --git a/web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx index 241e3d1b79cc16..1d45b712d2bbb7 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx @@ -37,29 +37,37 @@ vi.mock('@/service/debug', () => ({ generateRule: (...args: unknown[]) => mockGenerateRule(...args), })) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - default: ({ - setModel, - onCompletionParamsChange, - }: { - setModel: (value: { modelId: string, provider: string, mode?: string, features?: string[] }) => void - onCompletionParamsChange: (value: Record<string, unknown>) => void - }) => ( - <div> - <button onClick={() => setModel({ modelId: 'gpt-4o-mini', provider: 'openai', mode: 'chat' })}>change-model</button> - <button onClick={() => onCompletionParamsChange({ temperature: 0.3 })}>change-params</button> - </div> - ), -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + default: ({ + setModel, + onCompletionParamsChange, + }: { + setModel: (value: { + modelId: string + provider: string + mode?: string + features?: string[] + }) => void + onCompletionParamsChange: (value: Record<string, unknown>) => void + }) => ( + <div> + <button + onClick={() => setModel({ modelId: 'gpt-4o-mini', provider: 'openai', mode: 'chat' })} + > + change-model + </button> + <button onClick={() => onCompletionParamsChange({ temperature: 0.3 })}> + change-params + </button> + </div> + ), + }), +) vi.mock('../instruction-editor', () => ({ - default: ({ - value, - onChange, - }: { - value: string - onChange: (value: string) => void - }) => ( + default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( <div> <div data-testid="basic-editor">{value}</div> <button onClick={() => onChange('basic instruction')}>set-basic-instruction</button> @@ -68,13 +76,7 @@ vi.mock('../instruction-editor', () => ({ })) vi.mock('../instruction-editor-in-workflow', () => ({ - default: ({ - value, - onChange, - }: { - value: string - onChange: (value: string) => void - }) => ( + default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( <div> <div data-testid="workflow-editor">{value}</div> <button onClick={() => onChange('workflow instruction')}>set-workflow-instruction</button> @@ -83,13 +85,7 @@ vi.mock('../instruction-editor-in-workflow', () => ({ })) vi.mock('../idea-output', () => ({ - default: ({ - value, - onChange, - }: { - value: string - onChange: (value: string) => void - }) => ( + default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( <div> <div data-testid="idea-output">{value}</div> <button onClick={() => onChange('ideal output')}>set-idea-output</button> @@ -106,7 +102,7 @@ vi.mock('../result', () => ({ current, onApply, }: { - current: { modified?: string, prompt?: string } + current: { modified?: string; prompt?: string } onApply: () => void }) => ( <div data-testid="result-panel"> @@ -154,7 +150,9 @@ describe('GetAutomaticRes', () => { fireEvent.click(screen.getByText(/(?:^|\.)generate\.template\.pythonDebugger\.name(?=$|:)/)) await waitFor(() => { - expect(screen.getByTestId('basic-editor')).toHaveTextContent('generate.template.pythonDebugger.instruction') + expect(screen.getByTestId('basic-editor')).toHaveTextContent( + 'generate.template.pythonDebugger.instruction', + ) }) fireEvent.click(screen.getByText('change-model')) @@ -178,7 +176,9 @@ describe('GetAutomaticRes', () => { fireEvent.click(screen.getByText(/(?:^|\.)generate\.generate(?=$|:)/)) - expect(mockToastError).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)errorMsg\.fieldRequired(?=$|:)/)) + expect(mockToastError).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)errorMsg\.fieldRequired(?=$|:)/), + ) expect(mockGenerateBasicAppFirstTimeRule).not.toHaveBeenCalled() expect(screen.getByText('result-placeholder')).toBeInTheDocument() }) @@ -205,10 +205,12 @@ describe('GetAutomaticRes', () => { fireEvent.click(screen.getByText(/(?:^|\.)generate\.generate(?=$|:)/)) await waitFor(() => { - expect(mockGenerateBasicAppFirstTimeRule).toHaveBeenCalledWith(expect.objectContaining({ - instruction: 'basic instruction', - no_variable: false, - })) + expect(mockGenerateBasicAppFirstTimeRule).toHaveBeenCalledWith( + expect.objectContaining({ + instruction: 'basic instruction', + no_variable: false, + }), + ) }) await waitFor(() => { @@ -223,11 +225,13 @@ describe('GetAutomaticRes', () => { fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.confirm(?=$|:)/ })) - expect(mockOnFinished).toHaveBeenCalledWith(expect.objectContaining({ - modified: 'generated prompt', - variables: ['city'], - opening_statement: 'hello there', - })) + expect(mockOnFinished).toHaveBeenCalledWith( + expect.objectContaining({ + modified: 'generated prompt', + variables: ['city'], + opening_statement: 'hello there', + }), + ) }) it('should close overwrite confirmation without applying the generated result when cancelled', async () => { @@ -258,7 +262,9 @@ describe('GetAutomaticRes', () => { fireEvent.click(screen.getByText('apply-result')) const dialog = await screen.findByRole('alertdialog') - fireEvent.click(within(dialog).getByRole('button', { name: /(?:^|\.)operation\.cancel(?=$|:)/ })) + fireEvent.click( + within(dialog).getByRole('button', { name: /(?:^|\.)operation\.cancel(?=$|:)/ }), + ) await waitFor(() => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() @@ -291,13 +297,15 @@ describe('GetAutomaticRes', () => { fireEvent.click(screen.getByText(/(?:^|\.)generate\.generate(?=$|:)/)) await waitFor(() => { - expect(mockGenerateRule).toHaveBeenCalledWith(expect.objectContaining({ - flow_id: 'flow-1', - node_id: 'node-1', - current: 'current prompt', - instruction: 'workflow instruction', - ideal_output: 'ideal output', - })) + expect(mockGenerateRule).toHaveBeenCalledWith( + expect.objectContaining({ + flow_id: 'flow-1', + node_id: 'node-1', + current: 'current prompt', + instruction: 'workflow instruction', + ideal_output: 'ideal output', + }), + ) }) await waitFor(() => { diff --git a/web/app/components/app/configuration/config/automatic/__tests__/idea-output.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/idea-output.spec.tsx index ab46f1507f6c2e..361f95cce00007 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/idea-output.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/idea-output.spec.tsx @@ -6,7 +6,9 @@ describe('IdeaOutput', () => { const onChange = vi.fn() render(<IdeaOutput value="Initial idea" onChange={onChange} />) - expect(screen.queryByPlaceholderText(/(?:^|\.)generate\.idealOutputPlaceholder(?=$|:)/)).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText(/(?:^|\.)generate\.idealOutputPlaceholder(?=$|:)/), + ).not.toBeInTheDocument() fireEvent.click(screen.getByText(/(?:^|\.)generate\.idealOutput(?=$|:)/)) @@ -17,6 +19,8 @@ describe('IdeaOutput', () => { fireEvent.click(screen.getByText(/(?:^|\.)generate\.idealOutput(?=$|:)/)) - expect(screen.queryByPlaceholderText(/(?:^|\.)generate\.idealOutputPlaceholder(?=$|:)/)).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText(/(?:^|\.)generate\.idealOutputPlaceholder(?=$|:)/), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor-in-workflow.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor-in-workflow.spec.tsx index 85c55f7eaf586d..16730593a008d6 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor-in-workflow.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor-in-workflow.spec.tsx @@ -20,7 +20,10 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({ - default: (nodeId: string, options: { filterVar: (payload: { type: VarType }, selector: string[]) => boolean }) => { + default: ( + nodeId: string, + options: { filterVar: (payload: { type: VarType }, selector: string[]) => boolean }, + ) => { filterResults.push( options.filterVar({ type: VarType.string }, ['node-1']), options.filterVar({ type: VarType.file }, ['node-1']), @@ -66,16 +69,21 @@ describe('InstructionEditorInWorkflow', () => { expect(screen.getByTestId('instruction-editor')).toHaveTextContent('editor-1') expect(filterResults).toEqual([true, false, false, false]) - expect(mockUseAvailableVarList).toHaveBeenCalledWith('current-node', expect.objectContaining({ - onlyLeafNodeVar: false, - })) - expect(mockInstructionEditor).toHaveBeenCalledWith(expect.objectContaining({ - value: 'instruction', - availableVars: [{ variable: 'query' }], - availableNodes: [{ id: 'node-1', data: { title: 'Node 1', type: 'llm' } }], - getVarType: 'var-type-fn', - isShowCurrentBlock: true, - isShowLastRunBlock: true, - })) + expect(mockUseAvailableVarList).toHaveBeenCalledWith( + 'current-node', + expect.objectContaining({ + onlyLeafNodeVar: false, + }), + ) + expect(mockInstructionEditor).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'instruction', + availableVars: [{ variable: 'query' }], + availableNodes: [{ id: 'node-1', data: { title: 'Node 1', type: 'llm' } }], + getVarType: 'var-type-fn', + isShowCurrentBlock: true, + isShowLastRunBlock: true, + }), + ) }) }) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx index 2f26ceb7e0269a..0b1ad69ae89668 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx @@ -46,14 +46,11 @@ describe('InstructionEditor', () => { }) it('should render the prompt placeholder and forward text changes', () => { - render( - <InstructionEditor - {...baseProps} - generatorType={GeneratorType.prompt} - />, - ) + render(<InstructionEditor {...baseProps} generatorType={GeneratorType.prompt} />) - expect(screen.getByText(/(?:^|\.)generate\.instructionPlaceHolderTitle(?=$|:)/)).toBeInTheDocument() + expect( + screen.getByText(/(?:^|\.)generate\.instructionPlaceHolderTitle(?=$|:)/), + ).toBeInTheDocument() expect(screen.getByTestId('current-block')).toHaveTextContent('true') expect(screen.getByTestId('error-block')).toHaveTextContent('false') @@ -64,21 +61,21 @@ describe('InstructionEditor', () => { it('should render the code placeholder and emit quick insert events', () => { render( - <InstructionEditor - {...baseProps} - generatorType={GeneratorType.code} - isShowLastRunBlock - />, + <InstructionEditor {...baseProps} generatorType={GeneratorType.code} isShowLastRunBlock />, ) - expect(screen.getByText(/(?:^|\.)generate\.codeGenInstructionPlaceHolderLine(?=$|:)/)).toBeInTheDocument() + expect( + screen.getByText(/(?:^|\.)generate\.codeGenInstructionPlaceHolderLine(?=$|:)/), + ).toBeInTheDocument() expect(screen.getByTestId('error-block')).toHaveTextContent('true') expect(screen.getByTestId('last-run-block')).toHaveTextContent('true') fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)generate\.insertContext(?=$|:)/ })) - expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ - instanceId: 'editor-1', - })) + expect(mockEmit).toHaveBeenCalledWith( + expect.objectContaining({ + instanceId: 'editor-1', + }), + ) }) }) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/prompt-res-in-workflow.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/prompt-res-in-workflow.spec.tsx index cb2050276c0c4a..41c0e643971a4f 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/prompt-res-in-workflow.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/prompt-res-in-workflow.spec.tsx @@ -43,25 +43,30 @@ describe('PromptResInWorkflow', () => { render(<PromptResInWorkflow value="prompt" nodeId="node-a" />) expect(screen.getByTestId('prompt-res')).toHaveTextContent('prompt') - expect(mockUseAvailableVarList).toHaveBeenCalledWith('node-a', expect.objectContaining({ - onlyLeafNodeVar: false, - })) - expect(mockPromptRes).toHaveBeenCalledWith(expect.objectContaining({ - workflowVariableBlock: expect.objectContaining({ - show: true, - variables: [{ variable: 'query' }], - workflowNodesMap: expect.objectContaining({ - 'node-1': expect.objectContaining({ - title: 'Retriever', - type: BlockEnum.KnowledgeRetrieval, - }), - 'sys': expect.objectContaining({ - title: 'workflow.blocks.start', - type: BlockEnum.Start, + expect(mockUseAvailableVarList).toHaveBeenCalledWith( + 'node-a', + expect.objectContaining({ + onlyLeafNodeVar: false, + }), + ) + expect(mockPromptRes).toHaveBeenCalledWith( + expect.objectContaining({ + workflowVariableBlock: expect.objectContaining({ + show: true, + variables: [{ variable: 'query' }], + workflowNodesMap: expect.objectContaining({ + 'node-1': expect.objectContaining({ + title: 'Retriever', + type: BlockEnum.KnowledgeRetrieval, + }), + sys: expect.objectContaining({ + title: 'workflow.blocks.start', + type: BlockEnum.Start, + }), }), }), }), - })) + ) }) it('should fall back to an empty variable list when the workflow hook returns no variables', () => { @@ -72,11 +77,13 @@ describe('PromptResInWorkflow', () => { render(<PromptResInWorkflow value="fallback" nodeId="node-b" />) - expect(mockPromptRes).toHaveBeenCalledWith(expect.objectContaining({ - workflowVariableBlock: expect.objectContaining({ - variables: [], - workflowNodesMap: {}, + expect(mockPromptRes).toHaveBeenCalledWith( + expect.objectContaining({ + workflowVariableBlock: expect.objectContaining({ + variables: [], + workflowNodesMap: {}, + }), }), - })) + ) }) }) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/prompt-res.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/prompt-res.spec.tsx index 2e564b6bfabc9d..65399723765f64 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/prompt-res.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/prompt-res.spec.tsx @@ -2,7 +2,13 @@ import { render, screen } from '@testing-library/react' import PromptRes from '../prompt-res' vi.mock('@/app/components/base/prompt-editor', () => ({ - default: ({ value, workflowVariableBlock }: { value: string, workflowVariableBlock: { show: boolean } }) => ( + default: ({ + value, + workflowVariableBlock, + }: { + value: string + workflowVariableBlock: { show: boolean } + }) => ( <div data-testid="prompt-editor" data-show={String(workflowVariableBlock.show)}> {value} </div> @@ -11,26 +17,14 @@ vi.mock('@/app/components/base/prompt-editor', () => ({ describe('PromptRes', () => { it('should render the prompt value and remount when the value changes', () => { - const nowSpy = vi.spyOn(Date, 'now') - .mockReturnValueOnce(1000) - .mockReturnValueOnce(2000) + const nowSpy = vi.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000) - const { rerender } = render( - <PromptRes - value="alpha" - workflowVariableBlock={{ show: false }} - />, - ) + const { rerender } = render(<PromptRes value="alpha" workflowVariableBlock={{ show: false }} />) expect(screen.getByTestId('prompt-editor')).toHaveTextContent('alpha') expect(screen.getByTestId('prompt-editor')).toHaveAttribute('data-show', 'false') - rerender( - <PromptRes - value="beta" - workflowVariableBlock={{ show: true }} - />, - ) + rerender(<PromptRes value="beta" workflowVariableBlock={{ show: true }} />) expect(screen.getByTestId('prompt-editor')).toHaveTextContent('beta') expect(screen.getByTestId('prompt-editor')).toHaveAttribute('data-show', 'true') diff --git a/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx index 7c41a0b8c4a0e4..69053a6e2dc3d8 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx @@ -32,16 +32,27 @@ vi.mock('../prompt-res-in-workflow', () => ({ }, })) -vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor', () => ({ - default: ({ value }: { value: string }) => <div data-testid="code-editor">{value}</div>, -})) +vi.mock( + '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor', + () => ({ + default: ({ value }: { value: string }) => <div data-testid="code-editor">{value}</div>, + }), +) vi.mock('../prompt-toast', () => ({ default: ({ message }: { message: string }) => <div data-testid="prompt-toast">{message}</div>, })) vi.mock('../version-selector', () => ({ - default: ({ value, versionLen, onChange }: { value: number, versionLen: number, onChange: (index: number) => void }) => ( + default: ({ + value, + versionLen, + onChange, + }: { + value: number + versionLen: number + onChange: (index: number) => void + }) => ( <button data-testid="version-selector" onClick={() => onChange(versionLen - 1)}> version- {value} @@ -66,13 +77,7 @@ describe('Result', () => { }) it('should render the basic prompt result and support copying or applying it', () => { - render( - <Result - {...baseProps} - isBasicMode - generatorType={GeneratorType.prompt} - />, - ) + render(<Result {...baseProps} isBasicMode generatorType={GeneratorType.prompt} />) expect(screen.getByTestId('prompt-toast'))!.toHaveTextContent('optimization note') expect(screen.getByTestId('prompt-res'))!.toHaveTextContent('generated output') @@ -83,36 +88,31 @@ describe('Result', () => { expect(mockSetCurrentVersionIndex).toHaveBeenCalledWith(1) expect(mockCopy).toHaveBeenCalledWith('generated output') - expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)actionMsg\.copySuccessfully(?=$|:)/)) + expect(toast.success).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)actionMsg\.copySuccessfully(?=$|:)/), + ) expect(mockOnApply).toHaveBeenCalled() - expect(mockPromptRes).toHaveBeenCalledWith(expect.objectContaining({ - workflowVariableBlock: { show: false }, - })) + expect(mockPromptRes).toHaveBeenCalledWith( + expect.objectContaining({ + workflowVariableBlock: { show: false }, + }), + ) }) it('should render workflow prompt results through PromptResInWorkflow when basic mode is disabled', () => { - render( - <Result - {...baseProps} - nodeId="node-1" - generatorType={GeneratorType.prompt} - />, - ) + render(<Result {...baseProps} nodeId="node-1" generatorType={GeneratorType.prompt} />) expect(screen.getByTestId('prompt-res-in-workflow'))!.toHaveTextContent('generated output') - expect(mockPromptResInWorkflow).toHaveBeenCalledWith(expect.objectContaining({ - nodeId: 'node-1', - value: 'generated output', - })) + expect(mockPromptResInWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: 'node-1', + value: 'generated output', + }), + ) }) it('should render code results with the code editor for non-prompt generators', () => { - render( - <Result - {...baseProps} - generatorType={GeneratorType.code} - />, - ) + render(<Result {...baseProps} generatorType={GeneratorType.code} />) expect(screen.getByTestId('code-editor'))!.toHaveTextContent('generated output') }) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/version-selector.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/version-selector.spec.tsx index 47dd131eba5ea0..478d587f3814ae 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/version-selector.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/version-selector.spec.tsx @@ -5,13 +5,7 @@ describe('VersionSelector', () => { it('should not open the selector when only one version exists', () => { const onChange = vi.fn() - render( - <VersionSelector - versionLen={1} - value={0} - onChange={onChange} - />, - ) + render(<VersionSelector versionLen={1} value={0} onChange={onChange} />) fireEvent.click(screen.getByText(/(?:^|\.)generate\.version 1 · (?:.*\.)?generate\.latest$/)) @@ -22,13 +16,7 @@ describe('VersionSelector', () => { it('should open the selector and switch versions when multiple versions exist', async () => { const onChange = vi.fn() - render( - <VersionSelector - versionLen={3} - value={2} - onChange={onChange} - />, - ) + render(<VersionSelector versionLen={3} value={2} onChange={onChange} />) fireEvent.click(screen.getByText(/(?:^|\.)generate\.version 3 · (?:.*\.)?generate\.latest$/)) diff --git a/web/app/components/app/configuration/config/automatic/automatic-btn.tsx b/web/app/components/app/configuration/config/automatic/automatic-btn.tsx index ce7845a585ef8c..c5afbf127dff53 100644 --- a/web/app/components/app/configuration/config/automatic/automatic-btn.tsx +++ b/web/app/components/app/configuration/config/automatic/automatic-btn.tsx @@ -1,24 +1,20 @@ 'use client' import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' -import { - RiSparklingFill, -} from '@remixicon/react' +import { RiSparklingFill } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' type IAutomaticBtnProps = { onClick: () => void } -const AutomaticBtn: FC<IAutomaticBtnProps> = ({ - onClick, -}) => { +const AutomaticBtn: FC<IAutomaticBtnProps> = ({ onClick }) => { const { t } = useTranslation() return ( <Button variant="secondary-accent" size="small" onClick={onClick}> <RiSparklingFill className="mr-1 size-3.5" /> - <span className="">{t($ => $['operation.automatic'], { ns: 'appDebug' })}</span> + <span className="">{t(($) => $['operation.automatic'], { ns: 'appDebug' })}</span> </Button> ) } diff --git a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx index a21e735ff2fc5a..0d7237854810c0 100644 --- a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx +++ b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx @@ -33,7 +33,6 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Generator } from '@/app/components/base/icons/src/vender/other' import Loading from '@/app/components/base/loading' - import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' @@ -92,15 +91,17 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({ }) => { const { t } = useTranslation() const [storedModel, setStoredModel] = useAutoGenModel() - const [model, setModel] = React.useState<Model>(storedModel || { - name: '', - provider: '', - mode: mode as unknown as ModelModeType, - completion_params: {} as CompletionParams, - }) - const { - defaultModel, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) + const [model, setModel] = React.useState<Model>( + storedModel || { + name: '', + provider: '', + mode: mode as unknown as ModelModeType, + completion_params: {} as CompletionParams, + }, + ) + const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel( + ModelTypeEnum.textGeneration, + ) const tryList = [ { icon: RiTerminalBoxLine, @@ -140,52 +141,61 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({ }, ] as const - const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>(`improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`) + const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>( + `improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`, + ) const instruction = instructionFromSessionStorage || '' const [ideaOutput, setIdeaOutput] = useState<string>('') - type TemplateKey = typeof tryList[number]['key'] + type TemplateKey = (typeof tryList)[number]['key'] const [editorKey, setEditorKey] = useState(`${flowId}-0`) - const handleChooseTemplate = useCallback((key: TemplateKey) => { - return () => { - const template = t($ => $[`generate.template.${key}.instruction` as const], { ns: 'appDebug' }) - setInstruction(template) - setEditorKey(`${flowId}-${Date.now()}`) - } - }, [t]) + const handleChooseTemplate = useCallback( + (key: TemplateKey) => { + return () => { + const template = t(($) => $[`generate.template.${key}.instruction` as const], { + ns: 'appDebug', + }) + setInstruction(template) + setEditorKey(`${flowId}-${Date.now()}`) + } + }, + [t], + ) const { data: instructionTemplate } = useGenerateRuleTemplate(GeneratorType.prompt, isBasicMode) useEffect(() => { - if (!instruction && instructionTemplate) - setInstruction(instructionTemplate.data) + if (!instruction && instructionTemplate) setInstruction(instructionTemplate.data) setEditorKey(`${flowId}-${Date.now()}`) }, [instructionTemplate]) const isValid = () => { if (instruction.trim() === '') { - toast.error(t($ => $['errorMsg.fieldRequired'], { - ns: 'common', - field: t($ => $['generate.instruction'], { ns: 'appDebug' }), - })) + toast.error( + t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: t(($) => $['generate.instruction'], { ns: 'appDebug' }), + }), + ) return false } return true } const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false) const storageKey = `${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}` - const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenData({ - storageKey, - }) + const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenData( + { + storageKey, + }, + ) useEffect(() => { if (defaultModel) { if (storedModel) { setModel(storedModel) - } - else { - setModel(prev => ({ + } else { + setModel((prev) => ({ ...prev, name: defaultModel.model, provider: defaultModel.provider.provider, @@ -197,35 +207,41 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({ const renderLoading = ( <div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3"> <Loading /> - <div className="text-[13px] text-text-tertiary">{t($ => $['generate.loading'], { ns: 'appDebug' })}</div> + <div className="text-[13px] text-text-tertiary"> + {t(($) => $['generate.loading'], { ns: 'appDebug' })} + </div> </div> ) - const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => { - const newModel = { - ...model, - provider: newValue.provider, - name: newValue.modelId, - mode: newValue.mode as ModelModeType, - } - setModel(newModel) - setStoredModel(newModel) - }, [model, setModel, setStoredModel]) + const handleModelChange = useCallback( + (newValue: { modelId: string; provider: string; mode?: string; features?: string[] }) => { + const newModel = { + ...model, + provider: newValue.provider, + name: newValue.modelId, + mode: newValue.mode as ModelModeType, + } + setModel(newModel) + setStoredModel(newModel) + }, + [model, setModel, setStoredModel], + ) - const handleCompletionParamsChange = useCallback((newParams: FormValue) => { - const newModel = { - ...model, - completion_params: newParams as CompletionParams, - } - setModel(newModel) - setStoredModel(newModel) - }, [model, setModel, setStoredModel]) + const handleCompletionParamsChange = useCallback( + (newParams: FormValue) => { + const newModel = { + ...model, + completion_params: newParams as CompletionParams, + } + setModel(newModel) + setStoredModel(newModel) + }, + [model, setModel, setStoredModel], + ) const onGenerate = async () => { - if (!isValid()) - return - if (isLoading) - return + if (!isValid()) return + if (isLoading) return setLoadingTrue() try { let apiRes: GenRes @@ -244,8 +260,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({ hasError = true toast.error(error) } - } - else { + } else { const { error, ...res } = await generateRule({ flow_id: flowId, node_id: nodeId, @@ -260,18 +275,16 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({ toast.error(error) } } - if (!hasError) - addVersion(apiRes) - } - finally { + if (!hasError) addVersion(apiRes) + } finally { setLoadingFalse() } } - const [isShowConfirmOverwrite, { - setTrue: showConfirmOverwrite, - setFalse: hideShowConfirmOverwrite, - }] = useBoolean(false) + const [ + isShowConfirmOverwrite, + { setTrue: showConfirmOverwrite, setFalse: hideShowConfirmOverwrite }, + ] = useBoolean(false) const isShowAutoPromptResPlaceholder = () => { return !isLoading && !current @@ -281,17 +294,19 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({ <Dialog open={isShow} onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DialogContent className="h-[min(680px,calc(100dvh-2rem))] max-h-none! w-[1140px] max-w-none! min-w-[1140px] overflow-hidden! border-none p-0! text-left align-middle"> - <div className="flex h-full min-h-0 flex-wrap"> <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> <div className="mb-5"> - <div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t($ => $['generate.title'], { ns: 'appDebug' })}</div> - <div className="mt-1 text-[13px] font-normal text-text-tertiary">{t($ => $['generate.description'], { ns: 'appDebug' })}</div> + <div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}> + {t(($) => $['generate.title'], { ns: 'appDebug' })} + </div> + <div className="mt-1 text-[13px] font-normal text-text-tertiary"> + {t(($) => $['generate.description'], { ns: 'appDebug' })} + </div> </div> <div> <ModelParameterModal @@ -308,21 +323,23 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({ {isBasicMode && ( <div className="mt-4"> <div className="flex items-center"> - <div className="mr-3 shrink-0 text-xs leading-[18px] font-semibold text-text-tertiary uppercase">{t($ => $['generate.tryIt'], { ns: 'appDebug' })}</div> + <div className="mr-3 shrink-0 text-xs leading-[18px] font-semibold text-text-tertiary uppercase"> + {t(($) => $['generate.tryIt'], { ns: 'appDebug' })} + </div> <div className="h-px grow" style={{ - background: 'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0))', + background: + 'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0))', }} - > - </div> + ></div> </div> <div className="flex flex-wrap"> - {tryList.map(item => ( + {tryList.map((item) => ( <TryLabel key={item.key} Icon={item.icon} - text={t($ => $[`generate.template.${item.key}.name`], { ns: 'appDebug' })} + text={t(($) => $[`generate.template.${item.key}.name`], { ns: 'appDebug' })} onClick={handleChooseTemplate(item.key)} /> ))} @@ -333,38 +350,37 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({ {/* inputs */} <div className="mt-4"> <div> - <div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t($ => $['generate.instruction'], { ns: 'appDebug' })}</div> - {isBasicMode - ? ( - <InstructionEditorInBasic - editorKey={editorKey} - generatorType={GeneratorType.prompt} - value={instruction} - onChange={setInstruction} - availableVars={[]} - availableNodes={[]} - isShowCurrentBlock={!!currentPrompt} - isShowLastRunBlock={false} - /> - ) - : ( - <InstructionEditorInWorkflow - editorKey={editorKey} - generatorType={GeneratorType.prompt} - value={instruction} - onChange={setInstruction} - nodeId={nodeId || ''} - isShowCurrentBlock={!!currentPrompt} - /> - )} + <div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['generate.instruction'], { ns: 'appDebug' })} + </div> + {isBasicMode ? ( + <InstructionEditorInBasic + editorKey={editorKey} + generatorType={GeneratorType.prompt} + value={instruction} + onChange={setInstruction} + availableVars={[]} + availableNodes={[]} + isShowCurrentBlock={!!currentPrompt} + isShowLastRunBlock={false} + /> + ) : ( + <InstructionEditorInWorkflow + editorKey={editorKey} + generatorType={GeneratorType.prompt} + value={instruction} + onChange={setInstruction} + nodeId={nodeId || ''} + isShowCurrentBlock={!!currentPrompt} + /> + )} </div> - <IdeaOutput - value={ideaOutput} - onChange={setIdeaOutput} - /> + <IdeaOutput value={ideaOutput} onChange={setIdeaOutput} /> <div className="mt-7 flex justify-end space-x-2"> - <Button onClick={onClose}>{t($ => $[`${i18nPrefix}.dismiss`], { ns: 'appDebug' })}</Button> + <Button onClick={onClose}> + {t(($) => $[`${i18nPrefix}.dismiss`], { ns: 'appDebug' })} + </Button> <Button className="flex space-x-1" variant="primary" @@ -372,13 +388,15 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({ disabled={isLoading} > <Generator className="size-4" /> - <span className="text-xs font-semibold">{t($ => $['generate.generate'], { ns: 'appDebug' })}</span> + <span className="text-xs font-semibold"> + {t(($) => $['generate.generate'], { ns: 'appDebug' })} + </span> </Button> </div> </div> </div> - {(!isLoading && current) && ( + {!isLoading && current && ( <div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0"> <Result current={current!} @@ -394,25 +412,30 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({ )} {isLoading && renderLoading} {isShowAutoPromptResPlaceholder() && <ResPlaceholder />} - <AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideShowConfirmOverwrite()}> + <AlertDialog + open={isShowConfirmOverwrite} + onOpenChange={(open) => !open && hideShowConfirmOverwrite()} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['generate.overwriteTitle'], { ns: 'appDebug' })} + {t(($) => $['generate.overwriteTitle'], { ns: 'appDebug' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['generate.overwriteMessage'], { ns: 'appDebug' })} + {t(($) => $['generate.overwriteMessage'], { ns: 'appDebug' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={() => { hideShowConfirmOverwrite() onFinished(current!) }} > - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/app/configuration/config/automatic/idea-output.tsx b/web/app/components/app/configuration/config/automatic/idea-output.tsx index fae4378eb4e173..2054d2d523dad9 100644 --- a/web/app/components/app/configuration/config/automatic/idea-output.tsx +++ b/web/app/components/app/configuration/config/automatic/idea-output.tsx @@ -14,15 +14,10 @@ type Props = Readonly<{ onChange: (value: string) => void }> -const IdeaOutput: FC<Props> = ({ - value, - onChange, -}) => { +const IdeaOutput: FC<Props> = ({ value, onChange }) => { const { t } = useTranslation() - const [isFoldIdeaOutput, { - toggle: toggleFoldIdeaOutput, - }] = useBoolean(true) + const [isFoldIdeaOutput, { toggle: toggleFoldIdeaOutput }] = useBoolean(true) return ( <div className="mt-4 text-[0px]"> @@ -30,21 +25,26 @@ const IdeaOutput: FC<Props> = ({ className="mb-1.5 flex cursor-pointer items-center text-sm/5 font-medium text-text-primary" onClick={toggleFoldIdeaOutput} > - <div className="mr-1 system-sm-semibold-uppercase text-text-secondary">{t($ => $[`${i18nPrefix}.idealOutput`], { ns: 'appDebug' })}</div> + <div className="mr-1 system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $[`${i18nPrefix}.idealOutput`], { ns: 'appDebug' })} + </div> <div className="system-xs-regular text-text-tertiary"> - ( - {t($ => $[`${i18nPrefix}.optional`], { ns: 'appDebug' })} - ) + ({t(($) => $[`${i18nPrefix}.optional`], { ns: 'appDebug' })}) </div> - <ArrowDownRoundFill className={cn('size text-text-quaternary', isFoldIdeaOutput && 'relative top-px -rotate-90')} /> + <ArrowDownRoundFill + className={cn( + 'size text-text-quaternary', + isFoldIdeaOutput && 'relative top-px -rotate-90', + )} + /> </div> {!isFoldIdeaOutput && ( <Textarea - aria-label={t($ => $[`${i18nPrefix}.idealOutput`], { ns: 'appDebug' })} + aria-label={t(($) => $[`${i18nPrefix}.idealOutput`], { ns: 'appDebug' })} className="h-[80px]" - placeholder={t($ => $[`${i18nPrefix}.idealOutputPlaceholder`], { ns: 'appDebug' })} + placeholder={t(($) => $[`${i18nPrefix}.idealOutputPlaceholder`], { ns: 'appDebug' })} value={value} - onValueChange={value => onChange(value)} + onValueChange={(value) => onChange(value)} /> )} </div> diff --git a/web/app/components/app/configuration/config/automatic/instruction-editor-in-workflow.tsx b/web/app/components/app/configuration/config/automatic/instruction-editor-in-workflow.tsx index 92012ea8d3e173..d6e0724ee2eb90 100644 --- a/web/app/components/app/configuration/config/automatic/instruction-editor-in-workflow.tsx +++ b/web/app/components/app/configuration/config/automatic/instruction-editor-in-workflow.tsx @@ -28,15 +28,19 @@ const InstructionEditorInWorkflow: FC<Props> = ({ isShowCurrentBlock, }) => { const workflowStore = useWorkflowStore() - const filterVar = useCallback((payload: Var, selector: ValueSelector) => { - const { nodesWithInspectVars } = workflowStore.getState() - const nodeId = selector?.[0] - return !!nodesWithInspectVars.find(node => node.nodeId === nodeId) && payload.type !== VarType.file && payload.type !== VarType.arrayFile - }, [workflowStore]) - const { - availableVars, - availableNodes, - } = useAvailableVarList(nodeId, { + const filterVar = useCallback( + (payload: Var, selector: ValueSelector) => { + const { nodesWithInspectVars } = workflowStore.getState() + const nodeId = selector?.[0] + return ( + !!nodesWithInspectVars.find((node) => node.nodeId === nodeId) && + payload.type !== VarType.file && + payload.type !== VarType.arrayFile + ) + }, + [workflowStore], + ) + const { availableVars, availableNodes } = useAvailableVarList(nodeId, { onlyLeafNodeVar: false, filterVar, }) diff --git a/web/app/components/app/configuration/config/automatic/instruction-editor.tsx b/web/app/components/app/configuration/config/automatic/instruction-editor.tsx index 3a9c52e79eda0f..08b52f58436da9 100644 --- a/web/app/components/app/configuration/config/automatic/instruction-editor.tsx +++ b/web/app/components/app/configuration/config/automatic/instruction-editor.tsx @@ -19,10 +19,7 @@ type Props = Readonly<{ generatorType: GeneratorType availableVars: NodeOutPutVar[] availableNodes: Node[] - getVarType?: (params: { - nodeId: string - valueSelector: ValueSelector - }) => Type + getVarType?: (params: { nodeId: string; valueSelector: ValueSelector }) => Type isShowCurrentBlock: boolean isShowLastRunBlock: boolean }> @@ -44,22 +41,22 @@ const InstructionEditor: FC<Props> = ({ const { eventEmitter } = useEventEmitterContextContext() const isCode = generatorType === 'code' - const placeholder = isCode - ? ( - <div className="system-sm-regular leading-6! whitespace-break-spaces text-text-placeholder"> - {t($ => $[`${i18nPrefix}.codeGenInstructionPlaceHolderLine`], { ns: 'appDebug' })} - </div> - ) - : ( - <div className="system-sm-regular text-text-placeholder"> - <div className="leading-6">{t($ => $[`${i18nPrefix}.instructionPlaceHolderTitle`], { ns: 'appDebug' })}</div> - <div className="mt-2"> - <div>{t($ => $[`${i18nPrefix}.instructionPlaceHolderLine1`], { ns: 'appDebug' })}</div> - <div>{t($ => $[`${i18nPrefix}.instructionPlaceHolderLine2`], { ns: 'appDebug' })}</div> - <div>{t($ => $[`${i18nPrefix}.instructionPlaceHolderLine3`], { ns: 'appDebug' })}</div> - </div> - </div> - ) + const placeholder = isCode ? ( + <div className="system-sm-regular leading-6! whitespace-break-spaces text-text-placeholder"> + {t(($) => $[`${i18nPrefix}.codeGenInstructionPlaceHolderLine`], { ns: 'appDebug' })} + </div> + ) : ( + <div className="system-sm-regular text-text-placeholder"> + <div className="leading-6"> + {t(($) => $[`${i18nPrefix}.instructionPlaceHolderTitle`], { ns: 'appDebug' })} + </div> + <div className="mt-2"> + <div>{t(($) => $[`${i18nPrefix}.instructionPlaceHolderLine1`], { ns: 'appDebug' })}</div> + <div>{t(($) => $[`${i18nPrefix}.instructionPlaceHolderLine2`], { ns: 'appDebug' })}</div> + <div>{t(($) => $[`${i18nPrefix}.instructionPlaceHolderLine3`], { ns: 'appDebug' })}</div> + </div> + </div> + ) const handleInsertVariable = () => { eventEmitter?.emit({ type: PROMPT_EDITOR_INSERT_QUICKLY, instanceId: editorKey } as any) @@ -89,7 +86,7 @@ const InstructionEditor: FC<Props> = ({ } if (node.data.type === BlockEnum.Start) { acc.sys = { - title: t($ => $['blocks.start'], { ns: 'workflow' }), + title: t(($) => $['blocks.start'], { ns: 'workflow' }), type: BlockEnum.Start, } } @@ -111,15 +108,15 @@ const InstructionEditor: FC<Props> = ({ isSupportFileVar={false} /> <div className="absolute bottom-0 left-4 flex h-8 items-center space-x-0.5 system-xs-regular text-components-input-text-placeholder"> - <span>{t($ => $['generate.press'], { ns: 'appDebug' })}</span> + <span>{t(($) => $['generate.press'], { ns: 'appDebug' })}</span> <Kbd className="text-text-placeholder">/</Kbd> - <span>{t($ => $['generate.to'], { ns: 'appDebug' })}</span> + <span>{t(($) => $['generate.to'], { ns: 'appDebug' })}</span> <button type="button" className="ml-1! cursor-pointer border-none bg-transparent p-0 text-left hover:border-b hover:border-dotted hover:border-text-tertiary hover:text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={handleInsertVariable} > - {t($ => $['generate.insertContext'], { ns: 'appDebug' })} + {t(($) => $['generate.insertContext'], { ns: 'appDebug' })} </button> </div> </div> diff --git a/web/app/components/app/configuration/config/automatic/prompt-res-in-workflow.tsx b/web/app/components/app/configuration/config/automatic/prompt-res-in-workflow.tsx index dc11c9a0039d03..a35a3b378e105b 100644 --- a/web/app/components/app/configuration/config/automatic/prompt-res-in-workflow.tsx +++ b/web/app/components/app/configuration/config/automatic/prompt-res-in-workflow.tsx @@ -12,17 +12,11 @@ type Props = Readonly<{ nodeId: string }> -const PromptResInWorkflow: FC<Props> = ({ - value, - nodeId, -}) => { +const PromptResInWorkflow: FC<Props> = ({ value, nodeId }) => { const { t } = useTranslation() - const { - availableVars, - availableNodes, - } = useAvailableVarList(nodeId, { + const { availableVars, availableNodes } = useAvailableVarList(nodeId, { onlyLeafNodeVar: false, - filterVar: _payload => true, + filterVar: (_payload) => true, }) return ( <PromptRes @@ -41,15 +35,14 @@ const PromptResInWorkflow: FC<Props> = ({ } if (node.data.type === BlockEnum.Start) { acc.sys = { - title: t($ => $['blocks.start'], { ns: 'workflow' }), + title: t(($) => $['blocks.start'], { ns: 'workflow' }), type: BlockEnum.Start, } } return acc }, {} as any), }} - > - </PromptRes> + ></PromptRes> ) } export default React.memo(PromptResInWorkflow) diff --git a/web/app/components/app/configuration/config/automatic/prompt-res.tsx b/web/app/components/app/configuration/config/automatic/prompt-res.tsx index cdf5ad9b1381d4..ca4e8185c78878 100644 --- a/web/app/components/app/configuration/config/automatic/prompt-res.tsx +++ b/web/app/components/app/configuration/config/automatic/prompt-res.tsx @@ -11,10 +11,7 @@ type Props = Readonly<{ }> const keyIdPrefix = 'prompt-res-editor' -const PromptRes: FC<Props> = ({ - value, - workflowVariableBlock, -}) => { +const PromptRes: FC<Props> = ({ value, workflowVariableBlock }) => { const [editorKey, setEditorKey] = React.useState<string>('keyIdPrefix-0') useEffect(() => { setEditorKey(`${keyIdPrefix}-${Date.now()}`) diff --git a/web/app/components/app/configuration/config/automatic/prompt-toast.tsx b/web/app/components/app/configuration/config/automatic/prompt-toast.tsx index 907f335cba9738..3110e5cb999856 100644 --- a/web/app/components/app/configuration/config/automatic/prompt-toast.tsx +++ b/web/app/components/app/configuration/config/automatic/prompt-toast.tsx @@ -10,14 +10,9 @@ type Props = Readonly<{ message: string className?: string }> -const PromptToast = ({ - message, - className, -}: Props) => { +const PromptToast = ({ message, className }: Props) => { const { t } = useTranslation() - const [isFold, { - toggle: toggleFold, - }] = useBoolean(false) + const [isFold, { toggle: toggleFold }] = useBoolean(false) // const message = ` // list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1 // # h1 @@ -34,13 +29,23 @@ const PromptToast = ({ // \`\`\` // ` return ( - <div className={cn('rounded-xl border-[0.5px] border-components-panel-border bg-background-section-burn pl-4 shadow-xs', className)}> + <div + className={cn( + 'rounded-xl border-[0.5px] border-components-panel-border bg-background-section-burn pl-4 shadow-xs', + className, + )} + > <div className="my-3 flex h-4 items-center justify-between pr-3"> <div className="flex items-center space-x-1"> <RiSparklingFill className="size-3.5 text-components-input-border-active-prompt-1" /> - <span className={cn(s.optimizationNoteText, 'system-xs-semibold-uppercase')}>{t($ => $['generate.optimizationNote'], { ns: 'appDebug' })}</span> + <span className={cn(s.optimizationNoteText, 'system-xs-semibold-uppercase')}> + {t(($) => $['generate.optimizationNote'], { ns: 'appDebug' })} + </span> </div> - <RiArrowDownSLine className={cn('size-4 cursor-pointer text-text-tertiary', isFold && '-rotate-90')} onClick={toggleFold} /> + <RiArrowDownSLine + className={cn('size-4 cursor-pointer text-text-tertiary', isFold && '-rotate-90')} + onClick={toggleFold} + /> </div> {!isFold && ( <div className="pr-4 pb-4"> diff --git a/web/app/components/app/configuration/config/automatic/res-placeholder.tsx b/web/app/components/app/configuration/config/automatic/res-placeholder.tsx index 4ffbb8c31ba97c..2310ed2e454f4f 100644 --- a/web/app/components/app/configuration/config/automatic/res-placeholder.tsx +++ b/web/app/components/app/configuration/config/automatic/res-placeholder.tsx @@ -10,7 +10,7 @@ const ResPlaceholder: FC = () => { <div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8"> <Generator className="size-8 text-text-quaternary" /> <div className="text-center text-[13px] leading-5 font-normal text-text-tertiary"> - <div>{t($ => $['generate.newNoDataLine1'], { ns: 'appDebug' })}</div> + <div>{t(($) => $['generate.newNoDataLine1'], { ns: 'appDebug' })}</div> </div> </div> ) diff --git a/web/app/components/app/configuration/config/automatic/result.tsx b/web/app/components/app/configuration/config/automatic/result.tsx index af653dff9792ce..2abb29b53f709b 100644 --- a/web/app/components/app/configuration/config/automatic/result.tsx +++ b/web/app/components/app/configuration/config/automatic/result.tsx @@ -42,7 +42,9 @@ const Result: FC<Props> = ({ <div className="flex h-full flex-col"> <div className="mb-3 flex shrink-0 items-center justify-between"> <div> - <div className="shrink-0 text-base leading-[160%] font-semibold text-text-secondary">{t($ => $['generate.resTitle'], { ns: 'appDebug' })}</div> + <div className="shrink-0 text-base leading-[160%] font-semibold text-text-secondary"> + {t(($) => $['generate.resTitle'], { ns: 'appDebug' })} + </div> <VersionSelector versionLen={versions.length} value={currentVersionIndex} @@ -54,50 +56,39 @@ const Result: FC<Props> = ({ className="px-2" onClick={() => { copy(current.modified) - toast.success(t($ => $['actionMsg.copySuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.copySuccessfully'], { ns: 'common' })) }} > <RiClipboardLine className="size-4 text-text-secondary" /> </Button> <Button variant="primary" onClick={onApply}> - {t($ => $['generate.apply'], { ns: 'appDebug' })} + {t(($) => $['generate.apply'], { ns: 'appDebug' })} </Button> </div> </div> <div className="flex grow flex-col overflow-y-auto"> - { - current?.message && ( - <PromptToast message={current.message} className="mb-3 shrink-0" /> - ) - } + {current?.message && <PromptToast message={current.message} className="mb-3 shrink-0" />} <div className="grow pb-6"> - {isGeneratorPrompt - ? ( - isBasicMode - ? ( - <PromptRes - value={current?.modified} - workflowVariableBlock={{ - show: false, - }} - /> - ) - : ( - <PromptResInWorkflow - value={current?.modified || ''} - nodeId={nodeId!} - /> - ) - ) - : ( - <CodeEditor - editorWrapperClassName="h-full" - className="bg-transparent pt-0" - value={current?.modified} - readOnly - hideTopMenu - /> - )} + {isGeneratorPrompt ? ( + isBasicMode ? ( + <PromptRes + value={current?.modified} + workflowVariableBlock={{ + show: false, + }} + /> + ) : ( + <PromptResInWorkflow value={current?.modified || ''} nodeId={nodeId!} /> + ) + ) : ( + <CodeEditor + editorWrapperClassName="h-full" + className="bg-transparent pt-0" + value={current?.modified} + readOnly + hideTopMenu + /> + )} </div> </div> </div> diff --git a/web/app/components/app/configuration/config/automatic/style.module.css b/web/app/components/app/configuration/config/automatic/style.module.css index fa67b8519b7417..2d338ced34c7e0 100644 --- a/web/app/components/app/configuration/config/automatic/style.module.css +++ b/web/app/components/app/configuration/config/automatic/style.module.css @@ -1,12 +1,16 @@ .textGradient { - background: linear-gradient(92deg, #2250F2 -29.55%, #0EBCF3 75.22%); + background: linear-gradient(92deg, #2250f2 -29.55%, #0ebcf3 75.22%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .optimizationNoteText { - background: linear-gradient(263deg, rgba(21, 90, 239, 0.95) -20.92%, rgba(11, 165, 236, 0.95) 87.04%); + background: linear-gradient( + 263deg, + rgba(21, 90, 239, 0.95) -20.92%, + rgba(11, 165, 236, 0.95) 87.04% + ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; diff --git a/web/app/components/app/configuration/config/automatic/use-gen-data.ts b/web/app/components/app/configuration/config/automatic/use-gen-data.ts index 521d2ad7f02a0d..6bacfc12f898a5 100644 --- a/web/app/components/app/configuration/config/automatic/use-gen-data.ts +++ b/web/app/components/app/configuration/config/automatic/use-gen-data.ts @@ -7,22 +7,31 @@ type Params = { } const keyPrefix = 'gen-data-' const useGenData = ({ storageKey }: Params) => { - const [versions, setVersions] = useSessionStorageState<GenRes[]>(`${keyPrefix}${storageKey}-versions`, { - defaultValue: [], - }) + const [versions, setVersions] = useSessionStorageState<GenRes[]>( + `${keyPrefix}${storageKey}-versions`, + { + defaultValue: [], + }, + ) - const [currentVersionIndex, setCurrentVersionIndex] = useSessionStorageState<number>(`${keyPrefix}${storageKey}-version-index`, { - defaultValue: 0, - }) + const [currentVersionIndex, setCurrentVersionIndex] = useSessionStorageState<number>( + `${keyPrefix}${storageKey}-version-index`, + { + defaultValue: 0, + }, + ) const current = versions?.[currentVersionIndex || 0] - const addVersion = useCallback((version: GenRes) => { - setCurrentVersionIndex(() => versions?.length || 0) - setVersions((prev) => { - return [...prev!, version] - }) - }, [setVersions, setCurrentVersionIndex, versions?.length]) + const addVersion = useCallback( + (version: GenRes) => { + setCurrentVersionIndex(() => versions?.length || 0) + setVersions((prev) => { + return [...prev!, version] + }) + }, + [setVersions, setCurrentVersionIndex, versions?.length], + ) return { versions, diff --git a/web/app/components/app/configuration/config/automatic/version-selector.tsx b/web/app/components/app/configuration/config/automatic/version-selector.tsx index 0670e357a228e2..1f1cc64f09745b 100644 --- a/web/app/components/app/configuration/config/automatic/version-selector.tsx +++ b/web/app/components/app/configuration/config/automatic/version-selector.tsx @@ -20,7 +20,7 @@ const VersionSelector: React.FC<VersionSelectorProps> = ({ versionLen, value, on const { t } = useTranslation() const moreThanOneVersion = versionLen > 1 const versions = Array.from({ length: versionLen }, (_, index) => ({ - label: `${t($ => $['generate.version'], { ns: 'appDebug' })} ${index + 1}${index === versionLen - 1 ? ` · ${t($ => $['generate.latest'], { ns: 'appDebug' })}` : ''}`, + label: `${t(($) => $['generate.version'], { ns: 'appDebug' })} ${index + 1}${index === versionLen - 1 ? ` · ${t(($) => $['generate.latest'], { ns: 'appDebug' })}` : ''}`, value: index, })) @@ -32,14 +32,14 @@ const VersionSelector: React.FC<VersionSelectorProps> = ({ versionLen, value, on disabled={!moreThanOneVersion} className={cn( 'flex items-center border-none bg-transparent p-0 system-xs-medium text-text-tertiary', - moreThanOneVersion ? 'cursor-pointer data-popup-open:text-text-secondary' : 'cursor-default', + moreThanOneVersion + ? 'cursor-pointer data-popup-open:text-text-secondary' + : 'cursor-default', )} > <div> - {t($ => $['generate.version'], { ns: 'appDebug' })} - {' '} - {value + 1} - {isLatest && ` · ${t($ => $['generate.latest'], { ns: 'appDebug' })}`} + {t(($) => $['generate.version'], { ns: 'appDebug' })} {value + 1} + {isLatest && ` · ${t(($) => $['generate.latest'], { ns: 'appDebug' })}`} </div> {moreThanOneVersion && <RiArrowDownSLine className="size-3" />} </DropdownMenuTrigger> @@ -50,7 +50,7 @@ const VersionSelector: React.FC<VersionSelectorProps> = ({ versionLen, value, on popupClassName="w-[208px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1" > <div className="flex h-[22px] items-center px-3 pl-3 system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['generate.versions'], { ns: 'appDebug' })} + {t(($) => $['generate.versions'], { ns: 'appDebug' })} </div> <DropdownMenuRadioGroup value={value} @@ -58,7 +58,7 @@ const VersionSelector: React.FC<VersionSelectorProps> = ({ versionLen, value, on onChange(nextValue) }} > - {versions.map(option => ( + {versions.map((option) => ( <DropdownMenuRadioItem key={option.value} value={option.value} @@ -66,12 +66,10 @@ const VersionSelector: React.FC<VersionSelectorProps> = ({ versionLen, value, on className="h-7 rounded-lg px-2 system-sm-medium text-text-secondary" title={option.label} > - <div className="mr-1 grow truncate px-1 pl-1"> - {option.label} - </div> - { - value === option.value && <RiCheckLine className="size-4 shrink-0 text-text-accent" /> - } + <div className="mr-1 grow truncate px-1 pl-1">{option.label}</div> + {value === option.value && ( + <RiCheckLine className="size-4 shrink-0 text-text-accent" /> + )} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> diff --git a/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx b/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx index f5b43897d1dc19..292e68ce7e7d86 100644 --- a/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx +++ b/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx @@ -36,29 +36,37 @@ vi.mock('@/service/debug', () => ({ generateRule: (...args: unknown[]) => mockGenerateRule(...args), })) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - default: ({ - setModel, - onCompletionParamsChange, - }: { - setModel: (value: { modelId: string, provider: string, mode?: string, features?: string[] }) => void - onCompletionParamsChange: (value: Record<string, unknown>) => void - }) => ( - <div> - <button onClick={() => setModel({ modelId: 'gpt-4o-mini', provider: 'openai', mode: 'chat' })}>change-model</button> - <button onClick={() => onCompletionParamsChange({ temperature: 0.2 })}>change-params</button> - </div> - ), -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + default: ({ + setModel, + onCompletionParamsChange, + }: { + setModel: (value: { + modelId: string + provider: string + mode?: string + features?: string[] + }) => void + onCompletionParamsChange: (value: Record<string, unknown>) => void + }) => ( + <div> + <button + onClick={() => setModel({ modelId: 'gpt-4o-mini', provider: 'openai', mode: 'chat' })} + > + change-model + </button> + <button onClick={() => onCompletionParamsChange({ temperature: 0.2 })}> + change-params + </button> + </div> + ), + }), +) vi.mock('../../automatic/instruction-editor-in-workflow', () => ({ - default: ({ - value, - onChange, - }: { - value: string - onChange: (value: string) => void - }) => ( + default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( <div> <div data-testid="workflow-editor">{value}</div> <button onClick={() => onChange('code instruction')}>set-code-instruction</button> @@ -67,13 +75,7 @@ vi.mock('../../automatic/instruction-editor-in-workflow', () => ({ })) vi.mock('../../automatic/idea-output', () => ({ - default: ({ - value, - onChange, - }: { - value: string - onChange: (value: string) => void - }) => ( + default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( <div> <div data-testid="idea-output">{value}</div> <button onClick={() => onChange('code output')}>set-code-output</button> @@ -90,7 +92,7 @@ vi.mock('../../automatic/result', () => ({ current, onApply, }: { - current: { modified?: string, code?: string } + current: { modified?: string; code?: string } onApply: () => void }) => ( <div data-testid="code-result-panel"> @@ -160,7 +162,9 @@ describe('GetCodeGeneratorResModal', () => { fireEvent.click(screen.getByText(/(?:^|\.)codegen\.generate(?=$|:)/)) - expect(mockToastError).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)errorMsg\.fieldRequired(?=$|:)/)) + expect(mockToastError).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)errorMsg\.fieldRequired(?=$|:)/), + ) expect(mockGenerateRule).not.toHaveBeenCalled() expect(screen.getByText('code-result-placeholder')).toBeInTheDocument() }) @@ -188,14 +192,16 @@ describe('GetCodeGeneratorResModal', () => { fireEvent.click(screen.getByText(/(?:^|\.)codegen\.generate(?=$|:)/)) await waitFor(() => { - expect(mockGenerateRule).toHaveBeenCalledWith(expect.objectContaining({ - flow_id: 'flow-1', - node_id: 'node-1', - current: 'print(1)', - instruction: 'code instruction', - ideal_output: 'code output', - language: 'python', - })) + expect(mockGenerateRule).toHaveBeenCalledWith( + expect.objectContaining({ + flow_id: 'flow-1', + node_id: 'node-1', + current: 'print(1)', + instruction: 'code instruction', + ideal_output: 'code output', + language: 'python', + }), + ) }) await waitFor(() => { @@ -210,10 +216,12 @@ describe('GetCodeGeneratorResModal', () => { fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.confirm(?=$|:)/ })) - expect(mockOnFinished).toHaveBeenCalledWith(expect.objectContaining({ - modified: 'print("hello")', - code: 'print("hello")', - })) + expect(mockOnFinished).toHaveBeenCalledWith( + expect.objectContaining({ + modified: 'print("hello")', + code: 'print("hello")', + }), + ) }) it('should close overwrite confirmation without applying the generated code when cancelled', async () => { @@ -245,7 +253,9 @@ describe('GetCodeGeneratorResModal', () => { fireEvent.click(screen.getByText('apply-code-result')) const dialog = await screen.findByRole('alertdialog') - fireEvent.click(within(dialog).getByRole('button', { name: /(?:^|\.)operation\.cancel(?=$|:)/ })) + fireEvent.click( + within(dialog).getByRole('button', { name: /(?:^|\.)operation\.cancel(?=$|:)/ }), + ) await waitFor(() => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() diff --git a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx index 60bdde54f4ea2b..bb2a52dca9be68 100644 --- a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx +++ b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx @@ -15,10 +15,7 @@ import { import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' -import { - useBoolean, - useSessionStorageState, -} from 'ahooks' +import { useBoolean, useSessionStorageState } from 'ahooks' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -61,84 +58,93 @@ type IGetCodeGeneratorResProps = { onFinished: (res: GenRes) => void } -export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ( - { - flowId, - nodeId, - currentCode, - mode, - isShow, - codeLanguages, - onClose, - onFinished, - }, -) => { +export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ({ + flowId, + nodeId, + currentCode, + mode, + isShow, + codeLanguages, + onClose, + onFinished, +}) => { const { t } = useTranslation() const [storedModel, setStoredModel] = useAutoGenModel() - const [model, setModel] = React.useState<Model>(storedModel || { - name: '', - provider: '', - mode: mode as unknown as ModelModeType, - completion_params: defaultCompletionParams, - }) - const { - defaultModel, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) - const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>(`improve-instruction-${flowId}-${nodeId}`) + const [model, setModel] = React.useState<Model>( + storedModel || { + name: '', + provider: '', + mode: mode as unknown as ModelModeType, + completion_params: defaultCompletionParams, + }, + ) + const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel( + ModelTypeEnum.textGeneration, + ) + const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>( + `improve-instruction-${flowId}-${nodeId}`, + ) const instruction = instructionFromSessionStorage || '' const [ideaOutput, setIdeaOutput] = useState<string>('') const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false) const storageKey = `${flowId}-${nodeId}` - const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenData({ - storageKey, - }) + const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenData( + { + storageKey, + }, + ) const [editorKey, setEditorKey] = useState(`${flowId}-0`) const { data: instructionTemplate } = useGenerateRuleTemplate(GeneratorType.code) useEffect(() => { - if (!instruction && instructionTemplate) - setInstruction(instructionTemplate.data) + if (!instruction && instructionTemplate) setInstruction(instructionTemplate.data) setEditorKey(`${flowId}-${Date.now()}`) }, [instructionTemplate]) const isValid = () => { if (instruction.trim() === '') { - toast.error(t($ => $['errorMsg.fieldRequired'], { - ns: 'common', - field: t($ => $['code.instruction'], { ns: 'appDebug' }), - })) + toast.error( + t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: t(($) => $['code.instruction'], { ns: 'appDebug' }), + }), + ) return false } return true } - const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => { - const newModel = { - ...model, - provider: newValue.provider, - name: newValue.modelId, - mode: newValue.mode as ModelModeType, - } - setModel(newModel) - setStoredModel(newModel) - }, [model, setModel, setStoredModel]) + const handleModelChange = useCallback( + (newValue: { modelId: string; provider: string; mode?: string; features?: string[] }) => { + const newModel = { + ...model, + provider: newValue.provider, + name: newValue.modelId, + mode: newValue.mode as ModelModeType, + } + setModel(newModel) + setStoredModel(newModel) + }, + [model, setModel, setStoredModel], + ) - const handleCompletionParamsChange = useCallback((newParams: FormValue) => { - const newModel = { - ...model, - completion_params: newParams as CompletionParams, - } - setModel(newModel) - setStoredModel(newModel) - }, [model, setModel, setStoredModel]) + const handleCompletionParamsChange = useCallback( + (newParams: FormValue) => { + const newModel = { + ...model, + completion_params: newParams as CompletionParams, + } + setModel(newModel) + setStoredModel(newModel) + }, + [model, setModel, setStoredModel], + ) const onGenerate = async () => { - if (!isValid()) - return - if (isLoading) - return + if (!isValid()) return + if (isLoading) return setLoadingTrue() try { const { error, ...res } = await generateRule({ @@ -150,25 +156,24 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ( ideal_output: ideaOutput, language: languageMap[codeLanguages] || 'javascript', }) - if ((res as any).code) // not current or current is the same as the template would return a code field + if ((res as any).code) + // not current or current is the same as the template would return a code field res.modified = (res as any).code if (error) { toast.error(error) - } - else { + } else { addVersion(res) } - } - finally { + } finally { setLoadingFalse() } } - const [isShowConfirmOverwrite, { - setTrue: showConfirmOverwrite, - setFalse: hideShowConfirmOverwrite, - }] = useBoolean(false) + const [ + isShowConfirmOverwrite, + { setTrue: showConfirmOverwrite, setFalse: hideShowConfirmOverwrite }, + ] = useBoolean(false) useEffect(() => { if (defaultModel) { @@ -180,9 +185,8 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ( ...storedModel.completion_params, }, }) - } - else { - setModel(prev => ({ + } else { + setModel((prev) => ({ ...prev, name: defaultModel.model, provider: defaultModel.provider.provider, @@ -194,7 +198,9 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ( const renderLoading = ( <div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3"> <Loading /> - <div className="text-[13px] text-text-tertiary">{t($ => $['codegen.loading'], { ns: 'appDebug' })}</div> + <div className="text-[13px] text-text-tertiary"> + {t(($) => $['codegen.loading'], { ns: 'appDebug' })} + </div> </div> ) @@ -202,17 +208,19 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ( <Dialog open={isShow} onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DialogContent className="h-[min(680px,calc(100dvh-2rem))] max-h-none! w-full min-w-[1140px] overflow-hidden! border-none p-0! text-left align-middle"> - <div className="relative flex h-full min-h-0 flex-wrap"> <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> <div className="mb-5"> - <div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t($ => $['codegen.title'], { ns: 'appDebug' })}</div> - <div className="mt-1 text-[13px] font-normal text-text-tertiary">{t($ => $['codegen.description'], { ns: 'appDebug' })}</div> + <div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}> + {t(($) => $['codegen.title'], { ns: 'appDebug' })} + </div> + <div className="mt-1 text-[13px] font-normal text-text-tertiary"> + {t(($) => $['codegen.description'], { ns: 'appDebug' })} + </div> </div> <div className="mb-4"> <ModelParameterModal @@ -228,7 +236,9 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ( </div> <div> <div className="text-[0px]"> - <div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t($ => $['codegen.instruction'], { ns: 'appDebug' })}</div> + <div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['codegen.instruction'], { ns: 'appDebug' })} + </div> <InstructionEditor editorKey={editorKey} value={instruction} @@ -238,13 +248,12 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ( isShowCurrentBlock={!!currentCode} /> </div> - <IdeaOutput - value={ideaOutput} - onChange={setIdeaOutput} - /> + <IdeaOutput value={ideaOutput} onChange={setIdeaOutput} /> <div className="mt-7 flex justify-end space-x-2"> - <Button onClick={onClose}>{t($ => $[`${i18nPrefix}.dismiss`], { ns: 'appDebug' })}</Button> + <Button onClick={onClose}> + {t(($) => $[`${i18nPrefix}.dismiss`], { ns: 'appDebug' })} + </Button> <Button className="flex space-x-1" variant="primary" @@ -252,14 +261,16 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ( disabled={isLoading} > <Generator className="size-4" /> - <span className="text-xs font-semibold">{t($ => $['codegen.generate'], { ns: 'appDebug' })}</span> + <span className="text-xs font-semibold"> + {t(($) => $['codegen.generate'], { ns: 'appDebug' })} + </span> </Button> </div> </div> </div> {isLoading && renderLoading} {!isLoading && !current && <ResPlaceholder />} - {(!isLoading && current) && ( + {!isLoading && current && ( <div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0"> <Result current={current!} @@ -272,25 +283,30 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ( </div> )} </div> - <AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideShowConfirmOverwrite()}> + <AlertDialog + open={isShowConfirmOverwrite} + onOpenChange={(open) => !open && hideShowConfirmOverwrite()} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['codegen.overwriteConfirmTitle'], { ns: 'appDebug' })} + {t(($) => $['codegen.overwriteConfirmTitle'], { ns: 'appDebug' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['codegen.overwriteConfirmMessage'], { ns: 'appDebug' })} + {t(($) => $['codegen.overwriteConfirmMessage'], { ns: 'appDebug' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={() => { hideShowConfirmOverwrite() onFinished(current!) }} > - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/app/configuration/config/config-audio.tsx b/web/app/components/app/configuration/config/config-audio.tsx index 51588ca87df570..e8337c456257e5 100644 --- a/web/app/components/app/configuration/config/config-audio.tsx +++ b/web/app/components/app/configuration/config/config-audio.tsx @@ -5,7 +5,6 @@ import { produce } from 'immer' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' - import { useContext } from 'use-context-selector' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { Microphone01 } from '@/app/components/base/icons/src/vender/features' @@ -15,38 +14,34 @@ import ConfigContext from '@/context/debug-configuration' const ConfigAudio: FC = () => { const { t } = useTranslation() - const file = useFeatures(s => s.features.file) + const file = useFeatures((s) => s.features.file) const featuresStore = useFeaturesStore() const { isShowAudioConfig, readonly } = useContext(ConfigContext) const isAudioEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.audio) ?? false - const handleChange = useCallback((value: boolean) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (value: boolean) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - if (value) { - draft.file!.allowed_file_types = Array.from(new Set([ - ...(draft.file?.allowed_file_types || []), - SupportUploadFileTypes.audio, - ])) - } - else { - draft.file!.allowed_file_types = draft.file!.allowed_file_types?.filter( - type => type !== SupportUploadFileTypes.audio, - ) - } - if (draft.file) - draft.file.enabled = (draft.file.allowed_file_types?.length ?? 0) > 0 - }) - setFeatures(newFeatures) - }, [featuresStore]) + const newFeatures = produce(features, (draft) => { + if (value) { + draft.file!.allowed_file_types = Array.from( + new Set([...(draft.file?.allowed_file_types || []), SupportUploadFileTypes.audio]), + ) + } else { + draft.file!.allowed_file_types = draft.file!.allowed_file_types?.filter( + (type) => type !== SupportUploadFileTypes.audio, + ) + } + if (draft.file) draft.file.enabled = (draft.file.allowed_file_types?.length ?? 0) > 0 + }) + setFeatures(newFeatures) + }, + [featuresStore], + ) - if (!isShowAudioConfig || (readonly && !isAudioEnabled)) - return null + if (!isShowAudioConfig || (readonly && !isAudioEnabled)) return null return ( <div className="mt-2 flex items-center gap-2 rounded-xl border-t-[0.5px] border-l-[0.5px] bg-background-section-burn p-2"> @@ -56,22 +51,20 @@ const ConfigAudio: FC = () => { </div> </div> <div className="flex grow items-center"> - <div className="mr-1 system-sm-semibold text-text-secondary">{t($ => $['feature.audioUpload.title'], { ns: 'appDebug' })}</div> + <div className="mr-1 system-sm-semibold text-text-secondary"> + {t(($) => $['feature.audioUpload.title'], { ns: 'appDebug' })} + </div> <Infotip - aria-label={t($ => $['feature.audioUpload.description'], { ns: 'appDebug' })} + aria-label={t(($) => $['feature.audioUpload.description'], { ns: 'appDebug' })} popupClassName="w-[180px]" > - {t($ => $['feature.audioUpload.description'], { ns: 'appDebug' })} + {t(($) => $['feature.audioUpload.description'], { ns: 'appDebug' })} </Infotip> </div> {!readonly && ( <div className="flex shrink-0 items-center"> <div className="mr-3 ml-1 h-3.5 w-px bg-divider-subtle"></div> - <Switch - checked={isAudioEnabled} - onCheckedChange={handleChange} - size="md" - /> + <Switch checked={isAudioEnabled} onCheckedChange={handleChange} size="md" /> </div> )} </div> diff --git a/web/app/components/app/configuration/config/config-document.tsx b/web/app/components/app/configuration/config/config-document.tsx index 4c603b6689ab2b..47ee9ef4f3b53b 100644 --- a/web/app/components/app/configuration/config/config-document.tsx +++ b/web/app/components/app/configuration/config/config-document.tsx @@ -5,7 +5,6 @@ import { produce } from 'immer' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' - import { useContext } from 'use-context-selector' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { Document } from '@/app/components/base/icons/src/vender/features' @@ -15,38 +14,35 @@ import ConfigContext from '@/context/debug-configuration' const ConfigDocument: FC = () => { const { t } = useTranslation() - const file = useFeatures(s => s.features.file) + const file = useFeatures((s) => s.features.file) const featuresStore = useFeaturesStore() const { isShowDocumentConfig, readonly } = useContext(ConfigContext) - const isDocumentEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.document) ?? false + const isDocumentEnabled = + file?.allowed_file_types?.includes(SupportUploadFileTypes.document) ?? false - const handleChange = useCallback((value: boolean) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (value: boolean) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - if (value) { - draft.file!.allowed_file_types = Array.from(new Set([ - ...(draft.file?.allowed_file_types || []), - SupportUploadFileTypes.document, - ])) - } - else { - draft.file!.allowed_file_types = draft.file!.allowed_file_types?.filter( - type => type !== SupportUploadFileTypes.document, - ) - } - if (draft.file) - draft.file.enabled = (draft.file.allowed_file_types?.length ?? 0) > 0 - }) - setFeatures(newFeatures) - }, [featuresStore]) + const newFeatures = produce(features, (draft) => { + if (value) { + draft.file!.allowed_file_types = Array.from( + new Set([...(draft.file?.allowed_file_types || []), SupportUploadFileTypes.document]), + ) + } else { + draft.file!.allowed_file_types = draft.file!.allowed_file_types?.filter( + (type) => type !== SupportUploadFileTypes.document, + ) + } + if (draft.file) draft.file.enabled = (draft.file.allowed_file_types?.length ?? 0) > 0 + }) + setFeatures(newFeatures) + }, + [featuresStore], + ) - if (!isShowDocumentConfig || (readonly && !isDocumentEnabled)) - return null + if (!isShowDocumentConfig || (readonly && !isDocumentEnabled)) return null return ( <div className="mt-2 flex items-center gap-2 rounded-xl border-t-[0.5px] border-l-[0.5px] bg-background-section-burn p-2"> @@ -56,22 +52,20 @@ const ConfigDocument: FC = () => { </div> </div> <div className="flex grow items-center"> - <div className="mr-1 system-sm-semibold text-text-secondary">{t($ => $['feature.documentUpload.title'], { ns: 'appDebug' })}</div> + <div className="mr-1 system-sm-semibold text-text-secondary"> + {t(($) => $['feature.documentUpload.title'], { ns: 'appDebug' })} + </div> <Infotip - aria-label={t($ => $['feature.documentUpload.description'], { ns: 'appDebug' })} + aria-label={t(($) => $['feature.documentUpload.description'], { ns: 'appDebug' })} popupClassName="w-[180px]" > - {t($ => $['feature.documentUpload.description'], { ns: 'appDebug' })} + {t(($) => $['feature.documentUpload.description'], { ns: 'appDebug' })} </Infotip> </div> {!readonly && ( <div className="flex shrink-0 items-center"> <div className="mr-3 ml-1 h-3.5 w-px bg-divider-subtle"></div> - <Switch - checked={isDocumentEnabled} - onCheckedChange={handleChange} - size="md" - /> + <Switch checked={isDocumentEnabled} onCheckedChange={handleChange} size="md" /> </div> )} </div> diff --git a/web/app/components/app/configuration/config/index.tsx b/web/app/components/app/configuration/config/index.tsx index 3e2b20117291b4..37d45153a9cf37 100644 --- a/web/app/components/app/configuration/config/index.tsx +++ b/web/app/components/app/configuration/config/index.tsx @@ -30,7 +30,9 @@ const Config: FC = () => { setPrevPromptConfig, dataSets, } = useContext(ConfigContext) - const isChatApp = [AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes(mode) + const isChatApp = [AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes( + mode, + ) const formattingChangedDispatcher = useFormattingChangedDispatcher() const promptTemplate = modelConfig.configs.prompt_template @@ -41,8 +43,7 @@ const Config: FC = () => { draft.configs.prompt_template = newTemplate draft.configs.prompt_variables = [...draft.configs.prompt_variables, ...newVariables] }) - if (modelConfig.configs.prompt_template !== newTemplate) - formattingChangedDispatcher() + if (modelConfig.configs.prompt_template !== newTemplate) formattingChangedDispatcher() setPrevPromptConfig(modelConfig.configs) setModelConfig(newModelConfig) @@ -58,9 +59,7 @@ const Config: FC = () => { return ( <> - <div - className="relative h-0 grow overflow-y-auto px-6 pb-[50px]" - > + <div className="relative h-0 grow overflow-y-auto px-6 pb-[50px]"> {/* Template */} <ConfigPrompt mode={mode} @@ -81,15 +80,10 @@ const Config: FC = () => { {/* Dataset */} {!(readonly && dataSets.length === 0) && ( - <DatasetConfig - readonly={readonly} - hideMetadataFilter={readonly} - /> + <DatasetConfig readonly={readonly} hideMetadataFilter={readonly} /> )} {/* Tools */} - {isAgent && !(readonly && modelConfig.agentConfig.tools.length === 0) && ( - <AgentTools /> - )} + {isAgent && !(readonly && modelConfig.agentConfig.tools.length === 0) && <AgentTools />} <ConfigVision /> diff --git a/web/app/components/app/configuration/configuration-view.tsx b/web/app/components/app/configuration/configuration-view.tsx index 8a687a1c2e0e6e..67f6e2265594f5 100644 --- a/web/app/components/app/configuration/configuration-view.tsx +++ b/web/app/components/app/configuration/configuration-view.tsx @@ -21,11 +21,7 @@ import { DrawerPortal, DrawerViewport, } from '@langgenius/dify-ui/drawer' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useTranslation } from 'react-i18next' import AppPublisher from '@/app/components/app/app-publisher/features-wrapper' @@ -48,7 +44,7 @@ import { AppModeEnum, ModelModeType } from '@/types/app' function LegacyAgentBadge() { const { t } = useTranslation() - const description = t($ => $['legacyAgentBadge.description'], { ns: 'appDebug' }) + const description = t(($) => $['legacyAgentBadge.description'], { ns: 'appDebug' }) return ( <Popover> @@ -61,7 +57,7 @@ function LegacyAgentBadge() { className="inline-flex h-5 shrink-0 cursor-pointer items-center gap-0.5 rounded-[5px] border border-text-warning bg-components-badge-bg-dimm px-1.25 system-2xs-medium-uppercase whitespace-nowrap text-text-warning outline-hidden hover:bg-state-warning-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" > <span aria-hidden className="i-ri-alert-fill size-3 shrink-0" /> - {t($ => $['legacyAgentBadge.label'], { ns: 'appDebug' })} + {t(($) => $['legacyAgentBadge.label'], { ns: 'appDebug' })} </PopoverTrigger> <PopoverContent placement="bottom-start" @@ -75,7 +71,7 @@ function LegacyAgentBadge() { rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-0.5 rounded-md system-xs-medium text-text-accent outline-hidden hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid" > - <span>{t($ => $['legacyAgentBadge.action'], { ns: 'appDebug' })}</span> + <span>{t(($) => $['legacyAgentBadge.action'], { ns: 'appDebug' })}</span> <span aria-hidden className="i-ri-arrow-right-up-line size-3.5" /> </Link> </PopoverContent> @@ -139,11 +135,13 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({ <div className="bg-default-subtle absolute top-0 left-0 h-14 w-full"> <div className="flex h-14 items-center justify-between px-6"> <div className="flex items-center gap-2"> - <div className="system-xl-semibold text-text-primary">{t($ => $.orchestrate, { ns: 'appDebug' })}</div> + <div className="system-xl-semibold text-text-primary"> + {t(($) => $.orchestrate, { ns: 'appDebug' })} + </div> {showLegacyAgentBadge && <LegacyAgentBadge />} {isAdvancedMode && ( <div className="flex h-5 items-center rounded-md border border-components-button-secondary-border px-1.5 system-xs-medium-uppercase text-text-tertiary uppercase"> - {t($ => $['promptMode.advanced'], { ns: 'appDebug' })} + {t(($) => $['promptMode.advanced'], { ns: 'appDebug' })} </div> )} </div> @@ -174,8 +172,13 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({ </> )} {isMobile && ( - <Button className="mr-2 h-8! text-[13px]! font-medium" onClick={onOpenDebugPanel}> - <span className="mr-1">{t($ => $['operation.debugConfig'], { ns: 'appDebug' })}</span> + <Button + className="mr-2 h-8! text-[13px]! font-medium" + onClick={onOpenDebugPanel} + > + <span className="mr-1"> + {t(($) => $['operation.debugConfig'], { ns: 'appDebug' })} + </span> <CodeBracketIcon className="size-4 text-text-tertiary" /> </Button> )} @@ -183,11 +186,16 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({ </div> </div> </div> - <div className={`flex size-full shrink-0 flex-col sm:w-1/2 ${debugWithMultipleModel && 'max-w-[560px]'}`}> + <div + className={`flex size-full shrink-0 flex-col sm:w-1/2 ${debugWithMultipleModel && 'max-w-[560px]'}`} + > <Config /> </div> {!isMobile && ( - <div className="relative flex h-full w-1/2 grow flex-col overflow-y-auto" style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }}> + <div + className="relative flex h-full w-1/2 grow flex-col overflow-y-auto" + style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }} + > <div className="flex grow flex-col rounded-tl-2xl border-t-[0.5px] border-l-[0.5px] border-components-panel-border bg-chatbot-bg"> <Debug isAPIKeySet={contextValue.isAPIKeySet} @@ -207,22 +215,29 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({ </div> </div> - <AlertDialog open={showUseGPT4Confirm} onOpenChange={open => !open && setShowUseGPT4Confirm(false)}> + <AlertDialog + open={showUseGPT4Confirm} + onOpenChange={(open) => !open && setShowUseGPT4Confirm(false)} + > <AlertDialogContent> <div className="flex flex-col items-start gap-2 self-stretch px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary"> - {t($ => $['trailUseGPT4Info.title'], { ns: 'appDebug' })} + {t(($) => $['trailUseGPT4Info.title'], { ns: 'appDebug' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['trailUseGPT4Info.description'], { ns: 'appDebug' })} + {t(($) => $['trailUseGPT4Info.description'], { ns: 'appDebug' })} </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton tone="default"> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> - <AlertDialogConfirmButton variant="primary" tone="default" onClick={onConfirmUseGPT4}> - {t($ => $['operation.confirm'], { ns: 'common' })} + <AlertDialogConfirmButton + variant="primary" + tone="default" + onClick={onConfirmUseGPT4} + > + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> @@ -251,8 +266,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({ modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onHideDebugPanel() + if (!open) onHideDebugPanel() }} > <DrawerPortal> @@ -262,7 +276,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({ <DrawerContent className="flex min-h-0 flex-1 flex-col"> <div className="mb-4 flex shrink-0 justify-end"> <DrawerCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-6 rounded-md" /> </div> diff --git a/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx index 3fff312fea7319..33e7adac6b2847 100644 --- a/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx @@ -4,7 +4,10 @@ import type { DatasetConfigs } from '@/models/debug' import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useContext } from 'use-context-selector' -import { ComparisonOperator, LogicalOperator } from '@/app/components/workflow/nodes/knowledge-retrieval/types' +import { + ComparisonOperator, + LogicalOperator, +} from '@/app/components/workflow/nodes/knowledge-retrieval/types' import { getSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/utils' import { DatasetPermission, DataSourceType } from '@/models/datasets' import { AppModeEnum, ModelModeType, RETRIEVE_TYPE } from '@/types/app' @@ -79,7 +82,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -113,19 +117,15 @@ vi.mock('es-toolkit/compat', () => ({ intersectionBy: vi.fn((...arrays) => { // Mock realistic intersection behavior based on metadata name const validArrays = arrays.filter(Array.isArray) - if (validArrays.length === 0) - return [] + if (validArrays.length === 0) return [] // Start with first array and filter down return validArrays[0]!.filter((item: any) => { - if (!item || !item.name) - return false + if (!item || !item.name) return false // Only return items that exist in all arrays - return validArrays.every(array => - array.some((otherItem: any) => - otherItem && otherItem.name === item.name, - ), + return validArrays.every((array) => + array.some((otherItem: any) => otherItem && otherItem.name === item.name), ) }) }), @@ -149,67 +149,73 @@ vi.mock('../card-item', () => ({ vi.mock('../params-config', () => ({ default: ({ disabled, selectedDatasets }: any) => ( <button data-testid="params-config" disabled={disabled}> - Params ( - {selectedDatasets.length} - ) + Params ({selectedDatasets.length}) </button> ), })) vi.mock('../context-var', () => ({ default: ({ value, options, onChange }: any) => ( - <select data-testid="context-var" value={value} onChange={e => onChange(e.target.value)}> + <select data-testid="context-var" value={value} onChange={(e) => onChange(e.target.value)}> <option value="">Select context variable</option> {options.map((opt: any) => ( - <option key={opt.value} value={opt.value}>{opt.name}</option> + <option key={opt.value} value={opt.value}> + {opt.name} + </option> ))} </select> ), })) -vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter', () => ({ - default: ({ - metadataList, - metadataFilterMode, - handleMetadataFilterModeChange, - handleAddCondition, - handleRemoveCondition, - handleUpdateCondition, - handleToggleConditionLogicalOperator, - handleMetadataModelChange, - handleMetadataCompletionParamsChange, - }: any) => ( - <div data-testid="metadata-filter"> - <span data-testid="metadata-list-count">{metadataList.length}</span> - <select value={metadataFilterMode} onChange={e => handleMetadataFilterModeChange(e.target.value)}> - <option value="disabled">Disabled</option> - <option value="automatic">Automatic</option> - <option value="manual">Manual</option> - </select> - <button onClick={() => handleAddCondition({ name: 'test', type: 'string' })}> - Add Condition - </button> - <button onClick={() => handleAddCondition({ id: 'priority', name: 'priority', type: 'number' })}> - Add Number Condition - </button> - <button onClick={() => handleRemoveCondition('condition-id')}> - Remove Condition - </button> - <button onClick={() => handleUpdateCondition('condition-id', { name: 'updated' })}> - Update Condition - </button> - <button onClick={handleToggleConditionLogicalOperator}> - Toggle Operator - </button> - <button onClick={() => handleMetadataModelChange({ provider: 'openai', modelId: 'gpt-4o-mini' })}> - Change Metadata Model - </button> - <button onClick={() => handleMetadataCompletionParamsChange({ temperature: 0.3 })}> - Change Metadata Params - </button> - </div> - ), -})) +vi.mock( + '@/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter', + () => ({ + default: ({ + metadataList, + metadataFilterMode, + handleMetadataFilterModeChange, + handleAddCondition, + handleRemoveCondition, + handleUpdateCondition, + handleToggleConditionLogicalOperator, + handleMetadataModelChange, + handleMetadataCompletionParamsChange, + }: any) => ( + <div data-testid="metadata-filter"> + <span data-testid="metadata-list-count">{metadataList.length}</span> + <select + value={metadataFilterMode} + onChange={(e) => handleMetadataFilterModeChange(e.target.value)} + > + <option value="disabled">Disabled</option> + <option value="automatic">Automatic</option> + <option value="manual">Manual</option> + </select> + <button onClick={() => handleAddCondition({ name: 'test', type: 'string' })}> + Add Condition + </button> + <button + onClick={() => handleAddCondition({ id: 'priority', name: 'priority', type: 'number' })} + > + Add Number Condition + </button> + <button onClick={() => handleRemoveCondition('condition-id')}>Remove Condition</button> + <button onClick={() => handleUpdateCondition('condition-id', { name: 'updated' })}> + Update Condition + </button> + <button onClick={handleToggleConditionLogicalOperator}>Toggle Operator</button> + <button + onClick={() => handleMetadataModelChange({ provider: 'openai', modelId: 'gpt-4o-mini' })} + > + Change Metadata Model + </button> + <button onClick={() => handleMetadataCompletionParamsChange({ temperature: 0.3 })}> + Change Metadata Params + </button> + </div> + ), + }), +) // Mock context const mockConfigContext: any = { @@ -262,11 +268,7 @@ const mockConfigContext: any = { } vi.mock('@/context/debug-configuration', () => ({ - default: ({ children }: any) => ( - <div data-testid="config-context-provider"> - {children} - </div> - ), + default: ({ children }: any) => <div data-testid="config-context-provider">{children}</div>, })) vi.mock('use-context-selector', () => ({ @@ -534,10 +536,13 @@ describe('DatasetConfig', () => { expect(screen.getByTestId(`card-item-${dataset.id}`))!.toBeInTheDocument() expect(screen.queryByText('Edit')).not.toBeInTheDocument() - expect(getDatasetACLCapabilities).toHaveBeenCalledWith(dataset.permission_keys, expect.objectContaining({ - currentUserId: 'user-123', - resourceMaintainer: dataset.maintainer, - })) + expect(getDatasetACLCapabilities).toHaveBeenCalledWith( + dataset.permission_keys, + expect.objectContaining({ + currentUserId: 'user-123', + resourceMaintainer: dataset.maintainer, + }), + ) }) }) @@ -687,12 +692,14 @@ describe('DatasetConfig', () => { ...mockConfigContext.datasetConfigs, metadata_filtering_conditions: { logical_operator: LogicalOperator.and, - conditions: [{ - id: 'condition-id', - metadata_id: 'category', - name: 'category', - comparison_operator: ComparisonOperator.is, - }], + conditions: [ + { + id: 'condition-id', + metadata_id: 'category', + name: 'category', + comparison_operator: ComparisonOperator.is, + }, + ], }, }, datasetConfigsRef: { @@ -700,18 +707,22 @@ describe('DatasetConfig', () => { ...mockConfigContext.datasetConfigsRef.current, metadata_filtering_conditions: { logical_operator: LogicalOperator.and, - conditions: [{ - id: 'condition-id', - metadata_id: 'category', - name: 'category', - comparison_operator: ComparisonOperator.is, - }], + conditions: [ + { + id: 'condition-id', + metadata_id: 'category', + name: 'category', + comparison_operator: ComparisonOperator.is, + }, + ], }, }, }, }) - await userEvent.click(within(screen.getByTestId('metadata-filter')).getByText('Add Number Condition')) + await userEvent.click( + within(screen.getByTestId('metadata-filter')).getByText('Add Number Condition'), + ) expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith( expect.objectContaining({ @@ -749,7 +760,9 @@ describe('DatasetConfig', () => { // Update ref to match datasetConfigs mockConfigContext.datasetConfigsRef.current = datasetConfigsWithConditions - const removeButton = within(screen.getByTestId('metadata-filter')).getByText('Remove Condition') + const removeButton = within(screen.getByTestId('metadata-filter')).getByText( + 'Remove Condition', + ) await user.click(removeButton) expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith( @@ -782,7 +795,9 @@ describe('DatasetConfig', () => { mockConfigContext.datasetConfigsRef.current = datasetConfigsWithConditions - const updateButton = within(screen.getByTestId('metadata-filter')).getByText('Update Condition') + const updateButton = within(screen.getByTestId('metadata-filter')).getByText( + 'Update Condition', + ) await user.click(updateButton) expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith( @@ -819,7 +834,9 @@ describe('DatasetConfig', () => { mockConfigContext.datasetConfigsRef.current = datasetConfigsWithConditions - const toggleButton = within(screen.getByTestId('metadata-filter')).getByText('Toggle Operator') + const toggleButton = within(screen.getByTestId('metadata-filter')).getByText( + 'Toggle Operator', + ) await user.click(toggleButton) expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith( @@ -925,10 +942,7 @@ describe('DatasetConfig', () => { }) it('should integrate with params config component', () => { - const datasets = [ - createMockDataset(), - createMockDataset({ id: 'ds2' }), - ] + const datasets = [createMockDataset(), createMockDataset({ id: 'ds2' })] renderDatasetConfig({ dataSets: datasets, @@ -991,14 +1005,16 @@ describe('DatasetConfig', () => { fireEvent.click(within(metadataFilter).getByText('Change Metadata Model')) - expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith(expect.objectContaining({ - metadata_model_config: { - provider: 'openai', - name: 'gpt-4o-mini', - mode: AppModeEnum.CHAT, - completion_params: { temperature: 0.7 }, - }, - })) + expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith( + expect.objectContaining({ + metadata_model_config: { + provider: 'openai', + name: 'gpt-4o-mini', + mode: AppModeEnum.CHAT, + completion_params: { temperature: 0.7 }, + }, + }), + ) }) it('should handle metadata completion params change', () => { @@ -1022,11 +1038,13 @@ describe('DatasetConfig', () => { fireEvent.click(within(metadataFilter).getByText('Change Metadata Params')) - expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith(expect.objectContaining({ - metadata_model_config: { - completion_params: { temperature: 0.3 }, - }, - })) + expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith( + expect.objectContaining({ + metadata_model_config: { + completion_params: { temperature: 0.3 }, + }, + }), + ) }) }) @@ -1139,7 +1157,8 @@ describe('DatasetConfig', () => { { name: 'category', type: 'string' } as any, { name: 'priority', type: 'number' } as any, ], - })) + }), + ) renderDatasetConfig({ dataSets: manyDatasets, diff --git a/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx index cffed4b846380f..aa17d60c1a18da 100644 --- a/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx @@ -11,10 +11,20 @@ import { RETRIEVE_METHOD } from '@/types/app' import Item from '../index' vi.mock('../../settings-modal', () => ({ - default: ({ onSave, onCancel, currentDataset }: { currentDataset: DataSet, onCancel: () => void, onSave: (newDataset: DataSet) => void }) => ( + default: ({ + onSave, + onCancel, + currentDataset, + }: { + currentDataset: DataSet + onCancel: () => void + onSave: (newDataset: DataSet) => void + }) => ( <div> <div>Mock settings modal</div> - <button onClick={() => onSave({ ...currentDataset, name: 'Updated dataset' })}>Save changes</button> + <button onClick={() => onSave({ ...currentDataset, name: 'Updated dataset' })}> + Save changes + </button> <button onClick={onCancel}>Close</button> </div> ), @@ -45,12 +55,7 @@ const baseRetrievalConfig: RetrievalConfig = { const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType const createDataset = (overrides: Partial<DataSet> = {}): DataSet => { - const { - retrieval_model, - retrieval_model_dict, - icon_info, - ...restOverrides - } = overrides + const { retrieval_model, retrieval_model_dict, icon_info, ...restOverrides } = overrides const resolvedRetrievalModelDict = { ...baseRetrievalConfig, @@ -68,9 +73,7 @@ const createDataset = (overrides: Partial<DataSet> = {}): DataSet => { icon_url: '', } - const resolvedIconInfo = ('icon_info' in overrides) - ? icon_info - : defaultIconInfo + const resolvedIconInfo = 'icon_info' in overrides ? icon_info : defaultIconInfo return { id: 'dataset-id', @@ -125,14 +128,7 @@ const renderItem = (config: DataSet, props?: Partial<React.ComponentProps<typeof const onSave = vi.fn() const onRemove = vi.fn() - render( - <Item - config={config} - onSave={onSave} - onRemove={onRemove} - {...props} - />, - ) + render(<Item config={config} onSave={onSave} onRemove={onRemove} {...props} />) return { onSave, onRemove } } @@ -158,7 +154,11 @@ describe('dataset-config/card-item', () => { const actionButtons = within(card).getAllByRole('button', { hidden: true }) expect(screen.getByText(dataset.name))!.toBeInTheDocument() - expect(screen.getByText('dataset.indexingTechnique.high_quality · dataset.indexingMethod.semantic_search'))!.toBeInTheDocument() + expect( + screen.getByText( + 'dataset.indexingTechnique.high_quality · dataset.indexingMethod.semantic_search', + ), + )!.toBeInTheDocument() expect(screen.getByText('dataset.externalTag'))!.toBeInTheDocument() expect(actionButtons).toHaveLength(2) }) @@ -229,12 +229,12 @@ describe('dataset-config/card-item', () => { await user.click(editButton!) expect(screen.getByText('Mock settings modal'))!.toBeInTheDocument() - const overlay = [...document.querySelectorAll('[class]')] - .find(element => - element instanceof HTMLElement - && element.classList.contains('bg-background-overlay') - && !element.classList.contains('bg-transparent'), - ) + const overlay = [...document.querySelectorAll('[class]')].find( + (element) => + element instanceof HTMLElement && + element.classList.contains('bg-background-overlay') && + !element.classList.contains('bg-transparent'), + ) expect(overlay).toBeInTheDocument() }) diff --git a/web/app/components/app/configuration/dataset-config/card-item/index.tsx b/web/app/components/app/configuration/dataset-config/card-item/index.tsx index 592b1811fd9d94..8fdcd07a06c9b8 100644 --- a/web/app/components/app/configuration/dataset-config/card-item/index.tsx +++ b/web/app/components/app/configuration/dataset-config/card-item/index.tsx @@ -10,10 +10,7 @@ import { DrawerPortal, DrawerViewport, } from '@langgenius/dify-ui/drawer' -import { - RiDeleteBinLine, - RiEditLine, -} from '@remixicon/react' +import { RiDeleteBinLine, RiEditLine } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -33,13 +30,7 @@ type ItemProps = { editable?: boolean } -const Item: FC<ItemProps> = ({ - config, - onSave, - onRemove, - readonly = false, - editable = true, -}) => { +const Item: FC<ItemProps> = ({ config, onSave, onRemove, readonly = false, editable = true }) => { const media = useBreakpoints() const isMobile = media === MediaType.mobile const [showSettingsModal, setShowSettingsModal] = useState(false) @@ -61,11 +52,12 @@ const Item: FC<ItemProps> = ({ } return ( - <div className={cn( - 'group relative mb-1 flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover', - isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover', - readonly && 'cursor-not-allowed', - )} + <div + className={cn( + 'group relative mb-1 flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover', + isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover', + readonly && 'cursor-not-allowed', + )} > <div className="flex w-0 grow items-center space-x-1.5"> <AppIcon @@ -75,57 +67,58 @@ const Item: FC<ItemProps> = ({ background={iconInfo.icon_type === 'image' ? undefined : iconInfo.icon_background} imageUrl={iconInfo.icon_type === 'image' ? iconInfo.icon_url : undefined} /> - <div className="w-0 grow truncate system-sm-medium text-text-secondary" title={config.name}>{config.name}</div> + <div className="w-0 grow truncate system-sm-medium text-text-secondary" title={config.name}> + {config.name} + </div> </div> <div className="ml-2 hidden shrink-0 items-center space-x-1 group-hover:flex"> - { - editable && !readonly && ( - <ActionButton - onClick={(e) => { - e.stopPropagation() - setShowSettingsModal(true) - }} - > - <RiEditLine className="size-4 shrink-0 text-text-tertiary" /> - </ActionButton> - ) - } - { - !readonly && ( - <ActionButton - onClick={() => onRemove(config.id)} - state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default} - onMouseEnter={() => setIsDeleting(true)} - onMouseLeave={() => setIsDeleting(false)} - > - <RiDeleteBinLine className={cn('size-4 shrink-0 text-text-tertiary', isDeleting && 'text-text-destructive')} /> - </ActionButton> - ) - } + {editable && !readonly && ( + <ActionButton + onClick={(e) => { + e.stopPropagation() + setShowSettingsModal(true) + }} + > + <RiEditLine className="size-4 shrink-0 text-text-tertiary" /> + </ActionButton> + )} + {!readonly && ( + <ActionButton + onClick={() => onRemove(config.id)} + state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default} + onMouseEnter={() => setIsDeleting(true)} + onMouseLeave={() => setIsDeleting(false)} + > + <RiDeleteBinLine + className={cn( + 'size-4 shrink-0 text-text-tertiary', + isDeleting && 'text-text-destructive', + )} + /> + </ActionButton> + )} </div> - { - !!config.indexing_technique && ( - <Badge - className="shrink-0 group-hover:hidden" - text={formatIndexingTechniqueAndMethod(config.indexing_technique, config.retrieval_model_dict?.search_method)} - /> - ) - } - { - config.provider === 'external' && ( - <Badge - className="shrink-0 group-hover:hidden" - text={t($ => $.externalTag, { ns: 'dataset' }) as string} - /> - ) - } + {!!config.indexing_technique && ( + <Badge + className="shrink-0 group-hover:hidden" + text={formatIndexingTechniqueAndMethod( + config.indexing_technique, + config.retrieval_model_dict?.search_method, + )} + /> + )} + {config.provider === 'external' && ( + <Badge + className="shrink-0 group-hover:hidden" + text={t(($) => $.externalTag, { ns: 'dataset' }) as string} + /> + )} <Drawer open={showSettingsModal} modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - setShowSettingsModal(false) + if (!open) setShowSettingsModal(false) }} > <DrawerPortal> diff --git a/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx index f8017c35855146..e94bd12daa0839 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx @@ -30,25 +30,17 @@ vi.mock('@langgenius/dify-ui/popover', async () => { const isControlled = controlledOpen !== undefined const open = isControlled ? !!controlledOpen : uncontrolledOpen const setOpen = (nextOpen: boolean) => { - if (!isControlled) - setUncontrolledOpen(nextOpen) + if (!isControlled) setUncontrolledOpen(nextOpen) onOpenChange?.(nextOpen) } - return ( - <PopoverContext.Provider value={{ open, setOpen }}> - {children} - </PopoverContext.Provider> - ) + return <PopoverContext.Provider value={{ open, setOpen }}>{children}</PopoverContext.Provider> } const PopoverTrigger = ({ render }: { render: React.ReactNode }) => { const { open, setOpen } = React.useContext(PopoverContext) return ( - <div - data-testid="popover-trigger" - onClick={() => setOpen(!open)} - > + <div data-testid="popover-trigger" onClick={() => setOpen(!open)}> {render} </div> ) @@ -59,8 +51,7 @@ vi.mock('@langgenius/dify-ui/popover', async () => { ...props }: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => { const { open } = React.useContext(PopoverContext) - if (!open) - return null + if (!open) return null return ( <div data-testid="popover-content" {...props}> @@ -178,7 +169,9 @@ describe('ContextVar', () => { // Assert - Should show placeholder instead of variable // Assert - Should show placeholder instead of variable expect(screen.queryByText('var1')).not.toBeInTheDocument() - expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'))!.toBeInTheDocument() + expect( + screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'), + )!.toBeInTheDocument() }) it('should display custom tip message when notSelectedVarTip is provided', () => { @@ -272,7 +265,9 @@ describe('ContextVar', () => { // Assert // Assert expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title'))!.toBeInTheDocument() - expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'))!.toBeInTheDocument() + expect( + screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'), + )!.toBeInTheDocument() expect(screen.queryByText('var1')).not.toBeInTheDocument() }) @@ -290,7 +285,9 @@ describe('ContextVar', () => { // Assert // Assert expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title'))!.toBeInTheDocument() - expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'))!.toBeInTheDocument() + expect( + screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'), + )!.toBeInTheDocument() }) it('should handle null value without crashing', () => { @@ -306,7 +303,9 @@ describe('ContextVar', () => { // Assert // Assert expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title'))!.toBeInTheDocument() - expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'))!.toBeInTheDocument() + expect( + screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'), + )!.toBeInTheDocument() }) it('should handle options with different data types', () => { diff --git a/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx index 5b77134468b0c2..bb05166344917a 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx @@ -15,7 +15,10 @@ type PopoverProps = { open?: boolean onOpenChange?: (open: boolean) => void } -type PopoverTriggerProps = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode, asChild?: boolean } +type PopoverTriggerProps = React.HTMLAttributes<HTMLElement> & { + children?: React.ReactNode + asChild?: boolean +} type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode } vi.mock('@langgenius/dify-ui/popover', () => { @@ -34,8 +37,7 @@ vi.mock('@langgenius/dify-ui/popover', () => { const PopoverContent = ({ children, ...props }: PopoverContentProps) => { const { open } = React.useContext(PopoverContext) - if (!open) - return null + if (!open) return null return ( <div data-testid="popover-content" {...props}> {children} @@ -43,19 +45,23 @@ vi.mock('@langgenius/dify-ui/popover', () => { ) } - const PopoverTrigger = ({ children, asChild, render, ...props }: PopoverTriggerProps & { render?: React.ReactNode }) => { + const PopoverTrigger = ({ + children, + asChild, + render, + ...props + }: PopoverTriggerProps & { render?: React.ReactNode }) => { const { open, onOpenChange } = React.useContext(PopoverContext) const content = render ?? children const handleClick = (e: React.MouseEvent<HTMLElement>) => { props.onClick?.(e) - if (!props.onClick) - onOpenChange?.(!open) + if (!props.onClick) onOpenChange?.(!open) } if (React.isValidElement(content)) { return React.cloneElement(content, { ...props, - 'onClick': handleClick, + onClick: handleClick, 'data-testid': 'popover-trigger', } as React.HTMLAttributes<HTMLElement>) } @@ -63,7 +69,7 @@ vi.mock('@langgenius/dify-ui/popover', () => { if (asChild && React.isValidElement(children)) { return React.cloneElement(children, { ...props, - 'onClick': handleClick, + onClick: handleClick, 'data-testid': 'popover-trigger', } as React.HTMLAttributes<HTMLElement>) } @@ -173,7 +179,9 @@ describe('VarPicker', () => { // Assert // Assert expect(screen.queryByText('var1')).not.toBeInTheDocument() - expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'))!.toBeInTheDocument() + expect( + screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'), + )!.toBeInTheDocument() }) it('should display custom tip message when notSelectedVarTip is provided', () => { @@ -242,9 +250,7 @@ describe('VarPicker', () => { const props = { ...defaultProps, value: 'customVar', - options: [ - { name: 'Custom Variable', value: 'customVar', type: 'string' }, - ], + options: [{ name: 'Custom Variable', value: 'customVar', type: 'string' }], } // Act @@ -415,7 +421,9 @@ describe('VarPicker', () => { // Assert // Assert - expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'))!.toBeInTheDocument() + expect( + screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'), + )!.toBeInTheDocument() expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument() }) @@ -433,7 +441,9 @@ describe('VarPicker', () => { // Assert // Assert expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument() - expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'))!.toBeInTheDocument() + expect( + screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'), + )!.toBeInTheDocument() }) it('should handle null value without crashing', () => { @@ -448,7 +458,9 @@ describe('VarPicker', () => { // Assert // Assert - expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'))!.toBeInTheDocument() + expect( + screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder'), + )!.toBeInTheDocument() }) it('should handle variable names with special characters safely', () => { @@ -474,7 +486,11 @@ describe('VarPicker', () => { const props = { ...defaultProps, options: [ - { name: 'A very long variable name that should be truncated', value: 'longVar', type: 'string' }, + { + name: 'A very long variable name that should be truncated', + value: 'longVar', + type: 'string', + }, ], value: 'longVar', } diff --git a/web/app/components/app/configuration/dataset-config/context-var/index.tsx b/web/app/components/app/configuration/dataset-config/context-var/index.tsx index 7609d01f14cc27..29933eb27ff9d9 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/index.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/index.tsx @@ -11,20 +11,29 @@ import VarPicker from './var-picker' const ContextVar: FC<Props> = (props) => { const { t } = useTranslation() const { value, options } = props - const currItem = options.find(item => item.value === value) + const currItem = options.find((item) => item.value === value) const notSetVar = !currItem return ( - <div className={cn(notSetVar ? 'rounded-br-xl rounded-bl-xl border-[#FEF0C7] bg-[#FEF0C7]' : 'border-components-panel-border-subtle', 'flex h-12 items-center justify-between border-t px-3')}> + <div + className={cn( + notSetVar + ? 'rounded-br-xl rounded-bl-xl border-[#FEF0C7] bg-[#FEF0C7]' + : 'border-components-panel-border-subtle', + 'flex h-12 items-center justify-between border-t px-3', + )} + > <div className="flex shrink-0 items-center space-x-1"> <div className="p-1"> <BracketsX className="size-4 text-text-accent" /> </div> - <div className="mr-1 text-sm font-medium text-text-secondary">{t($ => $['feature.dataSet.queryVariable.title'], { ns: 'appDebug' })}</div> + <div className="mr-1 text-sm font-medium text-text-secondary"> + {t(($) => $['feature.dataSet.queryVariable.title'], { ns: 'appDebug' })} + </div> <Infotip - aria-label={t($ => $['feature.dataSet.queryVariable.tip'], { ns: 'appDebug' })} + aria-label={t(($) => $['feature.dataSet.queryVariable.tip'], { ns: 'appDebug' })} popupClassName="w-[180px]" > - {t($ => $['feature.dataSet.queryVariable.tip'], { ns: 'appDebug' })} + {t(($) => $['feature.dataSet.queryVariable.tip'], { ns: 'appDebug' })} </Infotip> </div> diff --git a/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx b/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx index 777ee1a9062b0a..c548f0e88633f7 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx @@ -3,17 +3,13 @@ import type { FC } from 'react' import type { IInputTypeIconProps } from '@/app/components/app/configuration/config-var/input-type-icon' import { ChevronDownIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import IconTypeIcon from '@/app/components/app/configuration/config-var/input-type-icon' -type Option = { name: string, value: string, type: string } +type Option = { name: string; value: string; type: string } export type Props = Readonly<{ triggerClassName?: string className?: string @@ -44,69 +40,72 @@ const VarPicker: FC<Props> = ({ }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) - const currItem = options.find(item => item.value === value) + const currItem = options.find((item) => item.value === value) const notSetVar = !currItem return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton={false} - render={( + render={ <div className={cn('group', triggerClassName)}> - <div className={cn( - className, - notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg', - 'bg-transparent group-data-popup-open:bg-components-button-secondary-bg', - ` - flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px] - font-medium shadow-xs - `, - )} + <div + className={cn( + className, + notSetVar + ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' + : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg', + 'bg-transparent group-data-popup-open:bg-components-button-secondary-bg', + `flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px] font-medium shadow-xs`, + )} > <div> - {currItem - ? ( - <VarItem item={currItem} /> - ) - : ( - <div> - {notSelectedVarTip || t($ => $['feature.dataSet.queryVariable.choosePlaceholder'], { ns: 'appDebug' })} - </div> - )} + {currItem ? ( + <VarItem item={currItem} /> + ) : ( + <div> + {notSelectedVarTip || + t(($) => $['feature.dataSet.queryVariable.choosePlaceholder'], { + ns: 'appDebug', + })} + </div> + )} </div> <ChevronDownIcon className="size-3.5 group-data-popup-open:rotate-180 group-data-popup-open:text-text-tertiary" /> </div> </div> - )} + } /> <PopoverContent placement="bottom-end" sideOffset={8} popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > - {options.length > 0 - ? ( - <div className="max-h-[50vh] w-[240px] overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg"> - {options.map(({ name, value, type }) => ( - <div - key={value} - className="flex cursor-pointer rounded-lg px-3 py-1 hover:bg-state-base-hover" - onClick={() => { - onChange(value) - setOpen(false) - }} - > - <VarItem item={{ name, value, type }} /> - </div> - ))} + {options.length > 0 ? ( + <div className="max-h-[50vh] w-[240px] overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg"> + {options.map(({ name, value, type }) => ( + <div + key={value} + className="flex cursor-pointer rounded-lg px-3 py-1 hover:bg-state-base-hover" + onClick={() => { + onChange(value) + setOpen(false) + }} + > + <VarItem item={{ name, value, type }} /> </div> - ) - : ( - <div className="w-[240px] rounded-lg border border-components-panel-border bg-components-panel-bg p-6 shadow-lg"> - <div className="mb-1 text-sm font-medium text-text-secondary">{t($ => $['feature.dataSet.queryVariable.noVar'], { ns: 'appDebug' })}</div> - <div className="text-xs/normal text-text-tertiary">{t($ => $['feature.dataSet.queryVariable.noVarTip'], { ns: 'appDebug' })}</div> - </div> - )} + ))} + </div> + ) : ( + <div className="w-[240px] rounded-lg border border-components-panel-border bg-components-panel-bg p-6 shadow-lg"> + <div className="mb-1 text-sm font-medium text-text-secondary"> + {t(($) => $['feature.dataSet.queryVariable.noVar'], { ns: 'appDebug' })} + </div> + <div className="text-xs/normal text-text-tertiary"> + {t(($) => $['feature.dataSet.queryVariable.noVarTip'], { ns: 'appDebug' })} + </div> + </div> + )} </PopoverContent> </Popover> ) diff --git a/web/app/components/app/configuration/dataset-config/index.tsx b/web/app/components/app/configuration/dataset-config/index.tsx index 3417de86b1e4d0..984319b13e9872 100644 --- a/web/app/components/app/configuration/dataset-config/index.tsx +++ b/web/app/components/app/configuration/dataset-config/index.tsx @@ -66,40 +66,40 @@ const DatasetConfig: FC<Props> = ({ readonly, hideMetadataFilter }) => { const hasData = dataSet.length > 0 - const { - currentModel: currentRerankModel, - currentProvider: currentRerankProvider, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) + const { currentModel: currentRerankModel, currentProvider: currentRerankProvider } = + useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) const onRemove = (id: string) => { - const filteredDataSets = dataSet.filter(item => item.id !== id) + const filteredDataSets = dataSet.filter((item) => item.id !== id) setDataSet(filteredDataSets) const { datasets, retrieval_model, score_threshold_enabled, ...restConfigs } = datasetConfigs - const { - top_k, - score_threshold, - reranking_model, - reranking_mode, - weights, - reranking_enable, - } = restConfigs + const { top_k, score_threshold, reranking_model, reranking_mode, weights, reranking_enable } = + restConfigs const oldRetrievalConfig = { top_k, score_threshold, - reranking_model: (reranking_model && reranking_model.reranking_provider_name && reranking_model.reranking_model_name) - ? { - provider: reranking_model.reranking_provider_name, - model: reranking_model.reranking_model_name, - } - : undefined, + reranking_model: + reranking_model && + reranking_model.reranking_provider_name && + reranking_model.reranking_model_name + ? { + provider: reranking_model.reranking_provider_name, + model: reranking_model.reranking_model_name, + } + : undefined, reranking_mode, weights, reranking_enable, } - const retrievalConfig = getMultipleRetrievalConfig(oldRetrievalConfig, filteredDataSets, dataSet, { - provider: currentRerankProvider?.provider, - model: currentRerankModel?.model, - }) + const retrievalConfig = getMultipleRetrievalConfig( + oldRetrievalConfig, + filteredDataSets, + dataSet, + { + provider: currentRerankProvider?.provider, + model: currentRerankModel?.model, + }, + ) setDatasetConfigs({ ...datasetConfigsRef.current, ...retrievalConfig, @@ -120,9 +120,9 @@ const DatasetConfig: FC<Props> = ({ readonly, hideMetadataFilter }) => { } = getSelectedDatasetsMode(filteredDataSets) if ( - (allInternal && (mixtureHighQualityAndEconomic || inconsistentEmbeddingModel)) - || mixtureInternalAndExternal - || allExternal + (allInternal && (mixtureHighQualityAndEconomic || inconsistentEmbeddingModel)) || + mixtureInternalAndExternal || + allExternal ) { setRerankSettingModalOpen(true) } @@ -130,7 +130,7 @@ const DatasetConfig: FC<Props> = ({ readonly, hideMetadataFilter }) => { } const handleSave = (newDataset: DataSet) => { - const index = dataSet.findIndex(item => item.id === newDataset.id) + const index = dataSet.findIndex((item) => item.id === newDataset.id) const newDatasets = [...dataSet.slice(0, index), newDataset, ...dataSet.slice(index + 1)] setDataSet(newDatasets) @@ -138,19 +138,19 @@ const DatasetConfig: FC<Props> = ({ readonly, hideMetadataFilter }) => { } const promptVariables = modelConfig.configs.prompt_variables - const promptVariablesToSelect = promptVariables.map(item => ({ + const promptVariablesToSelect = promptVariables.map((item) => ({ name: item.name, type: item.type, value: item.key, })) - const selectedContextVar = promptVariables?.find(item => item.is_context_var) + const selectedContextVar = promptVariables?.find((item) => item.is_context_var) const handleSelectContextVar = (selectedValue: string) => { const newModelConfig = produce(modelConfig, (draft) => { draft.configs.prompt_variables = modelConfig.configs.prompt_variables.map((item) => { - return ({ + return { ...item, is_context_var: item.key === selectedValue, - }) + } }) }) setModelConfig(newModelConfig) @@ -171,132 +171,156 @@ const DatasetConfig: FC<Props> = ({ readonly, hideMetadataFilter }) => { }, [currentUserId, dataSet, workspacePermissionKeys]) const metadataList = useMemo(() => { - return intersectionBy(...formattedDataset.filter((dataset) => { - return !!dataset.doc_metadata - }).map((dataset) => { - return dataset.doc_metadata! - }), 'name') + return intersectionBy( + ...formattedDataset + .filter((dataset) => { + return !!dataset.doc_metadata + }) + .map((dataset) => { + return dataset.doc_metadata! + }), + 'name', + ) }, [formattedDataset]) - const handleMetadataFilterModeChange = useCallback((newMode: MetadataFilteringModeEnum) => { - setDatasetConfigs(produce(datasetConfigsRef.current!, (draft) => { - draft.metadata_filtering_mode = newMode - })) - }, [setDatasetConfigs, datasetConfigsRef]) - - const handleAddCondition = useCallback<HandleAddCondition>(({ id, name, type }) => { - let operator: ComparisonOperator = ComparisonOperator.is + const handleMetadataFilterModeChange = useCallback( + (newMode: MetadataFilteringModeEnum) => { + setDatasetConfigs( + produce(datasetConfigsRef.current!, (draft) => { + draft.metadata_filtering_mode = newMode + }), + ) + }, + [setDatasetConfigs, datasetConfigsRef], + ) - if (type === MetadataFilteringVariableType.number) - operator = ComparisonOperator.equal + const handleAddCondition = useCallback<HandleAddCondition>( + ({ id, name, type }) => { + let operator: ComparisonOperator = ComparisonOperator.is - const newCondition = { - id: uuid4(), - metadata_id: id, // Save metadata.id for reliable reference - name, - comparison_operator: operator, - } + if (type === MetadataFilteringVariableType.number) operator = ComparisonOperator.equal - const newInputs = produce(datasetConfigsRef.current!, (draft) => { - if (draft.metadata_filtering_conditions) { - draft.metadata_filtering_conditions.conditions.push(newCondition) + const newCondition = { + id: uuid4(), + metadata_id: id, // Save metadata.id for reliable reference + name, + comparison_operator: operator, } - else { - draft.metadata_filtering_conditions = { - logical_operator: LogicalOperator.and, - conditions: [newCondition], + + const newInputs = produce(datasetConfigsRef.current!, (draft) => { + if (draft.metadata_filtering_conditions) { + draft.metadata_filtering_conditions.conditions.push(newCondition) + } else { + draft.metadata_filtering_conditions = { + logical_operator: LogicalOperator.and, + conditions: [newCondition], + } } - } - }) - setDatasetConfigs(newInputs) - }, [setDatasetConfigs, datasetConfigsRef]) + }) + setDatasetConfigs(newInputs) + }, + [setDatasetConfigs, datasetConfigsRef], + ) - const handleRemoveCondition = useCallback<HandleRemoveCondition>((id) => { - const conditions = datasetConfigsRef.current!.metadata_filtering_conditions?.conditions || [] - const index = conditions.findIndex(c => c.id === id) - const newInputs = produce(datasetConfigsRef.current!, (draft) => { - if (index > -1) - draft.metadata_filtering_conditions?.conditions.splice(index, 1) - }) - setDatasetConfigs(newInputs) - }, [setDatasetConfigs, datasetConfigsRef]) + const handleRemoveCondition = useCallback<HandleRemoveCondition>( + (id) => { + const conditions = datasetConfigsRef.current!.metadata_filtering_conditions?.conditions || [] + const index = conditions.findIndex((c) => c.id === id) + const newInputs = produce(datasetConfigsRef.current!, (draft) => { + if (index > -1) draft.metadata_filtering_conditions?.conditions.splice(index, 1) + }) + setDatasetConfigs(newInputs) + }, + [setDatasetConfigs, datasetConfigsRef], + ) - const handleUpdateCondition = useCallback<HandleUpdateCondition>((id, newCondition) => { - const conditions = datasetConfigsRef.current!.metadata_filtering_conditions?.conditions || [] - const index = conditions.findIndex(c => c.id === id) - const newInputs = produce(datasetConfigsRef.current!, (draft) => { - if (index > -1) - draft.metadata_filtering_conditions!.conditions[index] = newCondition - }) - setDatasetConfigs(newInputs) - }, [setDatasetConfigs, datasetConfigsRef]) + const handleUpdateCondition = useCallback<HandleUpdateCondition>( + (id, newCondition) => { + const conditions = datasetConfigsRef.current!.metadata_filtering_conditions?.conditions || [] + const index = conditions.findIndex((c) => c.id === id) + const newInputs = produce(datasetConfigsRef.current!, (draft) => { + if (index > -1) draft.metadata_filtering_conditions!.conditions[index] = newCondition + }) + setDatasetConfigs(newInputs) + }, + [setDatasetConfigs, datasetConfigsRef], + ) - const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>(() => { - const oldLogicalOperator = datasetConfigsRef.current!.metadata_filtering_conditions?.logical_operator - const newLogicalOperator = oldLogicalOperator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and - const newInputs = produce(datasetConfigsRef.current!, (draft) => { - draft.metadata_filtering_conditions!.logical_operator = newLogicalOperator - }) - setDatasetConfigs(newInputs) - }, [setDatasetConfigs, datasetConfigsRef]) + const handleToggleConditionLogicalOperator = + useCallback<HandleToggleConditionLogicalOperator>(() => { + const oldLogicalOperator = + datasetConfigsRef.current!.metadata_filtering_conditions?.logical_operator + const newLogicalOperator = + oldLogicalOperator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and + const newInputs = produce(datasetConfigsRef.current!, (draft) => { + draft.metadata_filtering_conditions!.logical_operator = newLogicalOperator + }) + setDatasetConfigs(newInputs) + }, [setDatasetConfigs, datasetConfigsRef]) - const handleMetadataModelChange = useCallback((model: { provider: string, modelId: string, mode?: string }) => { - const newInputs = produce(datasetConfigsRef.current!, (draft) => { - draft.metadata_model_config = { - provider: model.provider, - name: model.modelId, - mode: model.mode || AppModeEnum.CHAT, - completion_params: draft.metadata_model_config?.completion_params || { temperature: 0.7 }, - } - }) - setDatasetConfigs(newInputs) - }, [setDatasetConfigs, datasetConfigsRef]) + const handleMetadataModelChange = useCallback( + (model: { provider: string; modelId: string; mode?: string }) => { + const newInputs = produce(datasetConfigsRef.current!, (draft) => { + draft.metadata_model_config = { + provider: model.provider, + name: model.modelId, + mode: model.mode || AppModeEnum.CHAT, + completion_params: draft.metadata_model_config?.completion_params || { temperature: 0.7 }, + } + }) + setDatasetConfigs(newInputs) + }, + [setDatasetConfigs, datasetConfigsRef], + ) - const handleMetadataCompletionParamsChange = useCallback((newParams: Record<string, unknown>) => { - const newInputs = produce(datasetConfigsRef.current!, (draft) => { - draft.metadata_model_config = { - ...draft.metadata_model_config!, - completion_params: newParams, - } - }) - setDatasetConfigs(newInputs) - }, [setDatasetConfigs, datasetConfigsRef]) + const handleMetadataCompletionParamsChange = useCallback( + (newParams: Record<string, unknown>) => { + const newInputs = produce(datasetConfigsRef.current!, (draft) => { + draft.metadata_model_config = { + ...draft.metadata_model_config!, + completion_params: newParams, + } + }) + setDatasetConfigs(newInputs) + }, + [setDatasetConfigs, datasetConfigsRef], + ) return ( <FeaturePanel className="mt-2" - title={t($ => $['feature.dataSet.title'], { ns: 'appDebug' })} - headerRight={( + title={t(($) => $['feature.dataSet.title'], { ns: 'appDebug' })} + headerRight={ !readonly && ( <div className="flex items-center gap-1"> {!isAgent && <ParamsConfig disabled={!hasData} selectedDatasets={dataSet} />} <OperationBtn type="add" onClick={showSelectDataSet} /> </div> ) - )} + } hasHeaderBottomBorder={!hasData} noBodySpacing > - {hasData - ? ( - <div className={cn('mt-1 grid grid-cols-1 px-3 pb-3', readonly && 'grid-cols-2 gap-1')}> - {formattedDataset.map(item => ( - <CardItem - key={item.id} - config={item} - onRemove={onRemove} - onSave={handleSave} - editable={item.editable} - readonly={readonly} - /> - ))} - </div> - ) - : ( - <div className="mt-1 px-3 pb-3"> - <div className="pt-2 pb-1 text-xs text-text-tertiary">{t($ => $['feature.dataSet.noData'], { ns: 'appDebug' })}</div> - </div> - )} + {hasData ? ( + <div className={cn('mt-1 grid grid-cols-1 px-3 pb-3', readonly && 'grid-cols-2 gap-1')}> + {formattedDataset.map((item) => ( + <CardItem + key={item.id} + config={item} + onRemove={onRemove} + onSave={handleSave} + editable={item.editable} + readonly={readonly} + /> + ))} + </div> + ) : ( + <div className="mt-1 px-3 pb-3"> + <div className="pt-2 pb-1 text-xs text-text-tertiary"> + {t(($) => $['feature.dataSet.noData'], { ns: 'appDebug' })} + </div> + </div> + )} {!hideMetadataFilter && ( <div className="border-t border-t-divider-subtle py-2"> @@ -314,8 +338,14 @@ const DatasetConfig: FC<Props> = ({ readonly, hideMetadataFilter }) => { handleMetadataModelChange={handleMetadataModelChange} handleMetadataCompletionParamsChange={handleMetadataCompletionParamsChange} isCommonVariable - availableCommonStringVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.string || item.type === MetadataFilteringVariableType.select)} - availableCommonNumberVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.number)} + availableCommonStringVars={promptVariablesToSelect.filter( + (item) => + item.type === MetadataFilteringVariableType.string || + item.type === MetadataFilteringVariableType.select, + )} + availableCommonNumberVars={promptVariablesToSelect.filter( + (item) => item.type === MetadataFilteringVariableType.number, + )} /> </div> )} diff --git a/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx index 80b92395246e09..37bd31de029416 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx @@ -10,14 +10,20 @@ import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel, } from '@/app/components/header/account-setting/model-provider-page/hooks' -import { ChunkingMode, DatasetPermission, DataSourceType, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets' +import { + ChunkingMode, + DatasetPermission, + DataSourceType, + RerankingModeEnum, + WeightedScoreEnum, +} from '@/models/datasets' import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' import ConfigContent from '../config-content' vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => { type Props = { - defaultModel?: { provider: string, model: string } - onSelect?: (model: { provider: string, model: string }) => void + defaultModel?: { provider: string; model: string } + onSelect?: (model: { provider: string; model: string }) => void } const MockModelSelector = ({ defaultModel, onSelect }: Props) => ( @@ -34,17 +40,25 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec } }) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - default: () => <div data-testid="model-parameter-modal" />, -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + default: () => <div data-testid="model-parameter-modal" />, + }), +) vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(), useCurrentProviderAndModel: vi.fn(), })) -const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as MockedFunction<typeof useModelListAndDefaultModelAndCurrentProviderAndModel> -const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as MockedFunction<typeof useCurrentProviderAndModel> +const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = + useModelListAndDefaultModelAndCurrentProviderAndModel as MockedFunction< + typeof useModelListAndDefaultModelAndCurrentProviderAndModel + > +const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as MockedFunction< + typeof useCurrentProviderAndModel +> let toastErrorSpy: MockInstance @@ -63,12 +77,7 @@ const baseRetrievalConfig: RetrievalConfig = { const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType const createDataset = (overrides: Partial<DataSet> = {}): DataSet => { - const { - retrieval_model, - retrieval_model_dict, - icon_info, - ...restOverrides - } = overrides + const { retrieval_model, retrieval_model_dict, icon_info, ...restOverrides } = overrides const resolvedRetrievalModelDict = { ...baseRetrievalConfig, @@ -86,9 +95,7 @@ const createDataset = (overrides: Partial<DataSet> = {}): DataSet => { icon_url: '', } - const resolvedIconInfo = ('icon_info' in overrides) - ? icon_info - : defaultIconInfo + const resolvedIconInfo = 'icon_info' in overrides ? icon_info : defaultIconInfo return { id: 'dataset-id', diff --git a/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx index e9f535999b4259..ce4e445a95ba52 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx @@ -20,8 +20,8 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => { type Props = { - defaultModel?: { provider: string, model: string } - onSelect?: (model: { provider: string, model: string }) => void + defaultModel?: { provider: string; model: string } + onSelect?: (model: { provider: string; model: string }) => void } const MockModelSelector = ({ defaultModel, onSelect }: Props) => ( @@ -38,12 +38,20 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec } }) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - default: () => <div data-testid="model-parameter-modal" />, -})) - -const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as MockedFunction<typeof useModelListAndDefaultModelAndCurrentProviderAndModel> -const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as MockedFunction<typeof useCurrentProviderAndModel> +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + default: () => <div data-testid="model-parameter-modal" />, + }), +) + +const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = + useModelListAndDefaultModelAndCurrentProviderAndModel as MockedFunction< + typeof useModelListAndDefaultModelAndCurrentProviderAndModel + > +const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as MockedFunction< + typeof useCurrentProviderAndModel +> let toastErrorSpy: MockInstance const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetConfigs => { @@ -89,20 +97,10 @@ const renderParamsConfig = ({ }, } as unknown as React.ComponentProps<typeof ConfigContext.Provider>['value'] - return ( - <ConfigContext.Provider value={contextValue}> - {children} - </ConfigContext.Provider> - ) + return <ConfigContext.Provider value={contextValue}>{children}</ConfigContext.Provider> } - return render( - <ParamsConfig - disabled={disabled} - selectedDatasets={[]} - />, - { wrapper: Wrapper }, - ) + return render(<ParamsConfig disabled={disabled} selectedDatasets={[]} />, { wrapper: Wrapper }) } describe('dataset-config/params-config', () => { @@ -192,7 +190,9 @@ describe('dataset-config/params-config', () => { expect(topKInput)!.toHaveValue('5') }) - const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' }) + const cancelButton = await dialogScope.findByRole('button', { + name: 'common.operation.cancel', + }) await user.click(cancelButton) await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() diff --git a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx index a22b8d0a3995a6..cd94714c69dd69 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx @@ -3,12 +3,8 @@ import type { FC } from 'react' import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import type { ModelConfig } from '@/app/components/workflow/types' -import type { - DataSet, -} from '@/models/datasets' -import type { - DatasetConfigs, -} from '@/models/debug' +import type { DataSet } from '@/models/datasets' +import type { DatasetConfigs } from '@/models/debug' import { cn } from '@langgenius/dify-ui/cn' import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' @@ -19,7 +15,10 @@ import { Infotip } from '@/app/components/base/infotip' import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item' import TopKItem from '@/app/components/base/param-item/top-k-item' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { + useCurrentProviderAndModel, + useModelListAndDefaultModelAndCurrentProviderAndModel, +} from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import { useSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/hooks' @@ -55,10 +54,13 @@ const ConfigContent: FC<Props> = ({ useEffect(() => { if (type === RETRIEVE_TYPE.oneWay) { - onChange({ - ...datasetConfigs, - retrieval_model: RETRIEVE_TYPE.multiWay, - }, isInWorkflow) + onChange( + { + ...datasetConfigs, + retrieval_model: RETRIEVE_TYPE.multiWay, + }, + isInWorkflow, + ) } }, [type, datasetConfigs, isInWorkflow, onChange]) @@ -72,15 +74,14 @@ const ConfigContent: FC<Props> = ({ * If reranking model is set and is valid, use the reranking model * Otherwise, check if the default reranking model is valid */ - const { - currentModel: currentRerankModel, - } = useCurrentProviderAndModel( - rerankModelList, - { - provider: datasetConfigs.reranking_model?.reranking_provider_name || validDefaultRerankProvider?.provider || '', - model: datasetConfigs.reranking_model?.reranking_model_name || validDefaultRerankModel?.model || '', - }, - ) + const { currentModel: currentRerankModel } = useCurrentProviderAndModel(rerankModelList, { + provider: + datasetConfigs.reranking_model?.reranking_provider_name || + validDefaultRerankProvider?.provider || + '', + model: + datasetConfigs.reranking_model?.reranking_model_name || validDefaultRerankModel?.model || '', + }) const rerankModel = useMemo(() => { return { @@ -95,8 +96,7 @@ const ConfigContent: FC<Props> = ({ ...datasetConfigs, top_k: value, }) - } - else if (key === 'score_threshold') { + } else if (key === 'score_threshold') { onChange({ ...datasetConfigs, score_threshold: value, @@ -105,8 +105,7 @@ const ConfigContent: FC<Props> = ({ } const handleSwitch = (key: string, enable: boolean) => { - if (key === 'top_k') - return + if (key === 'top_k') return onChange({ ...datasetConfigs, @@ -132,11 +131,10 @@ const ConfigContent: FC<Props> = ({ } const handleRerankModeChange = (mode: RerankingModeEnum) => { - if (mode === datasetConfigs.reranking_mode) - return + if (mode === datasetConfigs.reranking_mode) return if (mode === RerankingModeEnum.RerankingModel && !currentRerankModel) - toast.error(t($ => $['errorMsg.rerankModelRequired'], { ns: 'workflow' })) + toast.error(t(($) => $['errorMsg.rerankModelRequired'], { ns: 'workflow' })) onChange({ ...datasetConfigs, @@ -149,176 +147,184 @@ const ConfigContent: FC<Props> = ({ const rerankingModeOptions = [ { value: RerankingModeEnum.WeightedScore, - label: t($ => $['weightedScore.title'], { ns: 'dataset' }), - tips: t($ => $['weightedScore.description'], { ns: 'dataset' }), + label: t(($) => $['weightedScore.title'], { ns: 'dataset' }), + tips: t(($) => $['weightedScore.description'], { ns: 'dataset' }), }, { value: RerankingModeEnum.RerankingModel, - label: t($ => $['modelProvider.rerankModel.key'], { ns: 'common' }), - tips: t($ => $['modelProvider.rerankModel.tip'], { ns: 'common' }), + label: t(($) => $['modelProvider.rerankModel.key'], { ns: 'common' }), + tips: t(($) => $['modelProvider.rerankModel.tip'], { ns: 'common' }), }, ] - const showWeightedScore = selectedDatasetsMode.allHighQuality - && !selectedDatasetsMode.inconsistentEmbeddingModel + const showWeightedScore = + selectedDatasetsMode.allHighQuality && !selectedDatasetsMode.inconsistentEmbeddingModel - const showWeightedScorePanel = showWeightedScore && datasetConfigs.reranking_mode === RerankingModeEnum.WeightedScore && datasetConfigs.weights + const showWeightedScorePanel = + showWeightedScore && + datasetConfigs.reranking_mode === RerankingModeEnum.WeightedScore && + datasetConfigs.weights const selectedRerankMode = datasetConfigs.reranking_mode || RerankingModeEnum.RerankingModel const canManuallyToggleRerank = useMemo(() => { - return (selectedDatasetsMode.allInternal && selectedDatasetsMode.allEconomic) - || selectedDatasetsMode.allExternal - }, [selectedDatasetsMode.allEconomic, selectedDatasetsMode.allExternal, selectedDatasetsMode.allInternal]) + return ( + (selectedDatasetsMode.allInternal && selectedDatasetsMode.allEconomic) || + selectedDatasetsMode.allExternal + ) + }, [ + selectedDatasetsMode.allEconomic, + selectedDatasetsMode.allExternal, + selectedDatasetsMode.allInternal, + ]) const showRerankModel = useMemo(() => { - if (!canManuallyToggleRerank) - return true + if (!canManuallyToggleRerank) return true return datasetConfigs.reranking_enable }, [datasetConfigs.reranking_enable, canManuallyToggleRerank]) - const handleManuallyToggleRerank = useCallback((enable: boolean) => { - if (!currentRerankModel && enable) - toast.error(t($ => $['errorMsg.rerankModelRequired'], { ns: 'workflow' })) - onChange({ - ...datasetConfigs, - reranking_enable: enable, - }) - }, [currentRerankModel, datasetConfigs, onChange]) + const handleManuallyToggleRerank = useCallback( + (enable: boolean) => { + if (!currentRerankModel && enable) + toast.error(t(($) => $['errorMsg.rerankModelRequired'], { ns: 'workflow' })) + onChange({ + ...datasetConfigs, + reranking_enable: enable, + }) + }, + [currentRerankModel, datasetConfigs, onChange], + ) return ( <div> - <div className="system-xl-semibold text-text-primary">{t($ => $.retrievalSettings, { ns: 'dataset' })}</div> + <div className="system-xl-semibold text-text-primary"> + {t(($) => $.retrievalSettings, { ns: 'dataset' })} + </div> <div className="system-xs-regular text-text-tertiary"> - {t($ => $.defaultRetrievalTip, { ns: 'dataset' })} + {t(($) => $.defaultRetrievalTip, { ns: 'dataset' })} </div> {type === RETRIEVE_TYPE.multiWay && ( <> <div className="my-2 flex flex-col items-center py-1"> <div className="mr-2 mb-2 shrink-0 system-xs-semibold-uppercase text-text-secondary"> - {t($ => $.rerankSettings, { ns: 'dataset' })} + {t(($) => $.rerankSettings, { ns: 'dataset' })} </div> <Divider bgStyle="gradient" className="m-0 h-px!" /> </div> - { - selectedDatasetsMode.inconsistentEmbeddingModel - && ( - <div className="mt-4 system-xs-medium text-text-warning"> - {t($ => $.inconsistentEmbeddingModelTip, { ns: 'dataset' })} - </div> - ) - } - { - selectedDatasetsMode.mixtureInternalAndExternal && ( - <div className="mt-4 system-xs-medium text-text-warning"> - {t($ => $.mixtureInternalAndExternalTip, { ns: 'dataset' })} - </div> - ) - } - { - selectedDatasetsMode.allExternal && ( - <div className="mt-4 system-xs-medium text-text-warning"> - {t($ => $.allExternalTip, { ns: 'dataset' })} - </div> - ) - } - { - selectedDatasetsMode.mixtureHighQualityAndEconomic - && ( - <div className="mt-4 system-xs-medium text-text-warning"> - {t($ => $.mixtureHighQualityAndEconomicTip, { ns: 'dataset' })} - </div> - ) - } - { - showWeightedScore && ( - <div className="flex items-center justify-between"> - { - rerankingModeOptions.map(option => ( - <div - key={option.value} - className={cn( - 'flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg system-sm-medium text-text-secondary', - selectedRerankMode === option.value && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary', - )} - onClick={() => handleRerankModeChange(option.value)} - > - <div className="truncate">{option.label}</div> - <Infotip - aria-label={option.tips} - className="ml-0.5 size-3.5" - popupClassName="w-[200px]" - > - {option.tips} - </Infotip> - </div> - )) - } - </div> - ) - } - { - !showWeightedScorePanel && ( - <div className="mt-2"> - <div className="flex items-center"> - { - canManuallyToggleRerank && ( - <Switch - size="md" - checked={showRerankModel ?? false} - onCheckedChange={handleManuallyToggleRerank} - /> - ) - } - <div className="ml-1 system-sm-semibold leading-[32px] text-text-secondary">{t($ => $['modelProvider.rerankModel.key'], { ns: 'common' })}</div> + {selectedDatasetsMode.inconsistentEmbeddingModel && ( + <div className="mt-4 system-xs-medium text-text-warning"> + {t(($) => $.inconsistentEmbeddingModelTip, { ns: 'dataset' })} + </div> + )} + {selectedDatasetsMode.mixtureInternalAndExternal && ( + <div className="mt-4 system-xs-medium text-text-warning"> + {t(($) => $.mixtureInternalAndExternalTip, { ns: 'dataset' })} + </div> + )} + {selectedDatasetsMode.allExternal && ( + <div className="mt-4 system-xs-medium text-text-warning"> + {t(($) => $.allExternalTip, { ns: 'dataset' })} + </div> + )} + {selectedDatasetsMode.mixtureHighQualityAndEconomic && ( + <div className="mt-4 system-xs-medium text-text-warning"> + {t(($) => $.mixtureHighQualityAndEconomicTip, { ns: 'dataset' })} + </div> + )} + {showWeightedScore && ( + <div className="flex items-center justify-between"> + {rerankingModeOptions.map((option) => ( + <div + key={option.value} + className={cn( + 'flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg system-sm-medium text-text-secondary', + selectedRerankMode === option.value && + 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary', + )} + onClick={() => handleRerankModeChange(option.value)} + > + <div className="truncate">{option.label}</div> <Infotip - aria-label={t($ => $['modelProvider.rerankModel.tip'], { ns: 'common' })} - className="ml-1" + aria-label={option.tips} + className="ml-0.5 size-3.5" popupClassName="w-[200px]" > - {t($ => $['modelProvider.rerankModel.tip'], { ns: 'common' })} + {option.tips} </Infotip> </div> - { - showRerankModel && ( - <div> - <ModelSelector - defaultModel={rerankModel && { provider: rerankModel?.provider_name, model: rerankModel?.model_name }} - onSelect={(v) => { - onChange({ - ...datasetConfigs, - reranking_model: { - reranking_provider_name: v.provider, - reranking_model_name: v.model, - }, - }) - }} - modelList={rerankModelList} - /> - </div> - ) - } + ))} + </div> + )} + {!showWeightedScorePanel && ( + <div className="mt-2"> + <div className="flex items-center"> + {canManuallyToggleRerank && ( + <Switch + size="md" + checked={showRerankModel ?? false} + onCheckedChange={handleManuallyToggleRerank} + /> + )} + <div className="ml-1 system-sm-semibold leading-[32px] text-text-secondary"> + {t(($) => $['modelProvider.rerankModel.key'], { ns: 'common' })} + </div> + <Infotip + aria-label={t(($) => $['modelProvider.rerankModel.tip'], { ns: 'common' })} + className="ml-1" + popupClassName="w-[200px]" + > + {t(($) => $['modelProvider.rerankModel.tip'], { ns: 'common' })} + </Infotip> </div> - ) - } - { - showWeightedScorePanel - && ( - <div className="mt-2 space-y-4"> - <WeightedScore - value={{ - value: [ - datasetConfigs.weights!.vector_setting.vector_weight, - datasetConfigs.weights!.keyword_setting.keyword_weight, - ], - }} - onChange={handleWeightedScoreChange} - /> - <TopKItem - value={datasetConfigs.top_k} - onChange={handleParamChange} - enable={true} - /> + {showRerankModel && ( + <div> + <ModelSelector + defaultModel={ + rerankModel && { + provider: rerankModel?.provider_name, + model: rerankModel?.model_name, + } + } + onSelect={(v) => { + onChange({ + ...datasetConfigs, + reranking_model: { + reranking_provider_name: v.provider, + reranking_model_name: v.model, + }, + }) + }} + modelList={rerankModelList} + /> + </div> + )} + </div> + )} + {showWeightedScorePanel && ( + <div className="mt-2 space-y-4"> + <WeightedScore + value={{ + value: [ + datasetConfigs.weights!.vector_setting.vector_weight, + datasetConfigs.weights!.keyword_setting.keyword_weight, + ], + }} + onChange={handleWeightedScoreChange} + /> + <TopKItem value={datasetConfigs.top_k} onChange={handleParamChange} enable={true} /> + <ScoreThresholdItem + value={datasetConfigs.score_threshold as number} + onChange={handleParamChange} + enable={datasetConfigs.score_threshold_enabled} + hasSwitch={true} + onSwitchChange={handleSwitch} + /> + </div> + )} + {!showWeightedScorePanel && ( + <div className="mt-4 space-y-4"> + <TopKItem value={datasetConfigs.top_k} onChange={handleParamChange} enable={true} /> + {showRerankModel && ( <ScoreThresholdItem value={datasetConfigs.score_threshold as number} onChange={handleParamChange} @@ -326,41 +332,22 @@ const ConfigContent: FC<Props> = ({ hasSwitch={true} onSwitchChange={handleSwitch} /> - </div> - ) - } - { - !showWeightedScorePanel - && ( - <div className="mt-4 space-y-4"> - <TopKItem - value={datasetConfigs.top_k} - onChange={handleParamChange} - enable={true} - /> - { - showRerankModel && ( - <ScoreThresholdItem - value={datasetConfigs.score_threshold as number} - onChange={handleParamChange} - enable={datasetConfigs.score_threshold_enabled} - hasSwitch={true} - onSwitchChange={handleSwitch} - /> - ) - } - </div> - ) - } + )} + </div> + )} </> )} {isInWorkflow && type === RETRIEVE_TYPE.oneWay && ( <div className="mt-4"> <div className="flex items-center space-x-0.5"> - <div className="text-[13px] leading-[32px] font-medium text-text-primary">{t($ => $['modelProvider.systemReasoningModel.key'], { ns: 'common' })}</div> - <Infotip aria-label={t($ => $['modelProvider.systemReasoningModel.tip'], { ns: 'common' })}> - {t($ => $['modelProvider.systemReasoningModel.tip'], { ns: 'common' })} + <div className="text-[13px] leading-[32px] font-medium text-text-primary"> + {t(($) => $['modelProvider.systemReasoningModel.key'], { ns: 'common' })} + </div> + <Infotip + aria-label={t(($) => $['modelProvider.systemReasoningModel.tip'], { ns: 'common' })} + > + {t(($) => $['modelProvider.systemReasoningModel.tip'], { ns: 'common' })} </Infotip> </div> <ModelParameterModal diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.tsx index eb5c3e2d763a5b..3b6b516a7c095d 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/index.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/index.tsx @@ -10,10 +10,11 @@ import { memo, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { - getMultipleRetrievalConfig, -} from '@/app/components/workflow/nodes/knowledge-retrieval/utils' + useCurrentProviderAndModel, + useModelListAndDefaultModelAndCurrentProviderAndModel, +} from '@/app/components/header/account-setting/model-provider-page/hooks' +import { getMultipleRetrievalConfig } from '@/app/components/workflow/nodes/knowledge-retrieval/utils' import ConfigContext from '@/context/debug-configuration' import { RerankingModeEnum } from '@/models/datasets' import { RETRIEVE_TYPE } from '@/types/app' @@ -23,17 +24,10 @@ type ParamsConfigProps = { disabled?: boolean selectedDatasets: DataSet[] } -const ParamsConfig = ({ - disabled, - selectedDatasets, -}: ParamsConfigProps) => { +const ParamsConfig = ({ disabled, selectedDatasets }: ParamsConfigProps) => { const { t } = useTranslation() - const { - datasetConfigs, - setDatasetConfigs, - rerankSettingModalOpen, - setRerankSettingModalOpen, - } = useContext(ConfigContext) + const { datasetConfigs, setDatasetConfigs, rerankSettingModalOpen, setRerankSettingModalOpen } = + useContext(ConfigContext) const [tempDataSetConfigs, setTempDataSetConfigs] = useState(datasetConfigs) useEffect(() => { @@ -46,23 +40,20 @@ const ParamsConfig = ({ currentProvider: rerankDefaultProvider, } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) - const { - currentModel: isCurrentRerankModelValid, - } = useCurrentProviderAndModel( - rerankModelList, - { - provider: tempDataSetConfigs.reranking_model?.reranking_provider_name ?? '', - model: tempDataSetConfigs.reranking_model?.reranking_model_name ?? '', - }, - ) + const { currentModel: isCurrentRerankModelValid } = useCurrentProviderAndModel(rerankModelList, { + provider: tempDataSetConfigs.reranking_model?.reranking_provider_name ?? '', + model: tempDataSetConfigs.reranking_model?.reranking_model_name ?? '', + }) const isValid = () => { let errMsg = '' if (tempDataSetConfigs.retrieval_model === RETRIEVE_TYPE.multiWay) { - if (tempDataSetConfigs.reranking_enable - && tempDataSetConfigs.reranking_mode === RerankingModeEnum.RerankingModel - && !isCurrentRerankModelValid) { - errMsg = t($ => $['datasetConfig.rerankModelRequired'], { ns: 'appDebug' }) + if ( + tempDataSetConfigs.reranking_enable && + tempDataSetConfigs.reranking_mode === RerankingModeEnum.RerankingModel && + !isCurrentRerankModelValid + ) { + errMsg = t(($) => $['datasetConfig.rerankModelRequired'], { ns: 'appDebug' }) } } if (errMsg) { @@ -71,8 +62,7 @@ const ParamsConfig = ({ return !errMsg } const handleSave = () => { - if (!isValid()) - return + if (!isValid()) return setDatasetConfigs(tempDataSetConfigs) setRerankSettingModalOpen(false) } @@ -80,20 +70,25 @@ const ParamsConfig = ({ const handleSetTempDataSetConfigs = (newDatasetConfigs: DatasetConfigs) => { const { datasets, retrieval_model, score_threshold_enabled, ...restConfigs } = newDatasetConfigs - const retrievalConfig = getMultipleRetrievalConfig({ - top_k: restConfigs.top_k, - score_threshold: restConfigs.score_threshold, - reranking_model: restConfigs.reranking_model && { - provider: restConfigs.reranking_model.reranking_provider_name, - model: restConfigs.reranking_model.reranking_model_name, + const retrievalConfig = getMultipleRetrievalConfig( + { + top_k: restConfigs.top_k, + score_threshold: restConfigs.score_threshold, + reranking_model: restConfigs.reranking_model && { + provider: restConfigs.reranking_model.reranking_provider_name, + model: restConfigs.reranking_model.reranking_model_name, + }, + reranking_mode: restConfigs.reranking_mode, + weights: restConfigs.weights, + reranking_enable: restConfigs.reranking_enable, }, - reranking_mode: restConfigs.reranking_mode, - weights: restConfigs.weights, - reranking_enable: restConfigs.reranking_enable, - }, selectedDatasets, selectedDatasets, { - provider: rerankDefaultProvider?.provider, - model: rerankDefaultModel?.model, - }) + selectedDatasets, + selectedDatasets, + { + provider: rerankDefaultProvider?.provider, + model: rerankDefaultModel?.model, + }, + ) setTempDataSetConfigs({ ...retrievalConfig, @@ -119,43 +114,41 @@ const ParamsConfig = ({ disabled={disabled} > <RiEqualizer2Line className="mr-1 size-3.5" /> - {t($ => $.retrievalSettings, { ns: 'dataset' })} + {t(($) => $.retrievalSettings, { ns: 'dataset' })} </Button> - { - rerankSettingModalOpen && ( - <Dialog - open={rerankSettingModalOpen} - onOpenChange={(open) => { - if (!open) { - setRerankSettingModalOpen(false) - } - }} - > - <DialogContent className="w-full max-w-[480px] border-none text-left align-middle sm:min-w-[528px]"> - - <ConfigContent - datasetConfigs={tempDataSetConfigs} - onChange={handleSetTempDataSetConfigs} - selectedDatasets={selectedDatasets} - /> - - <div className="mt-6 flex justify-end"> - <Button - className="mr-2 shrink-0" - onClick={() => { - setTempDataSetConfigs(datasetConfigs) - setRerankSettingModalOpen(false) - }} - > - {t($ => $['operation.cancel'], { ns: 'common' })} - </Button> - <Button variant="primary" className="shrink-0" onClick={handleSave}>{t($ => $['operation.save'], { ns: 'common' })}</Button> - </div> - </DialogContent> - </Dialog> - ) - } + {rerankSettingModalOpen && ( + <Dialog + open={rerankSettingModalOpen} + onOpenChange={(open) => { + if (!open) { + setRerankSettingModalOpen(false) + } + }} + > + <DialogContent className="w-full max-w-[480px] border-none text-left align-middle sm:min-w-[528px]"> + <ConfigContent + datasetConfigs={tempDataSetConfigs} + onChange={handleSetTempDataSetConfigs} + selectedDatasets={selectedDatasets} + /> + <div className="mt-6 flex justify-end"> + <Button + className="mr-2 shrink-0" + onClick={() => { + setTempDataSetConfigs(datasetConfigs) + setRerankSettingModalOpen(false) + }} + > + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button variant="primary" className="shrink-0" onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> + </div> + </DialogContent> + </Dialog> + )} </div> ) } diff --git a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx index c0af7391b91c0b..829b4d3039b4fb 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx @@ -9,10 +9,8 @@ const weightedScoreSliderSlotClassNames = { } const formatNumber = (value: number) => { - if (value > 0 && value < 1) - return `0.${value * 10}` - else if (value === 1) - return '1.0' + if (value > 0 && value < 1) return `0.${value * 10}` + else if (value === 1) return '1.0' return value } @@ -26,11 +24,7 @@ type WeightedScoreProps = { onChange: (value: Value) => void readonly?: boolean } -const WeightedScore = ({ - value, - onChange = noop, - readonly = false, -}: WeightedScoreProps) => { +const WeightedScore = ({ value, onChange = noop, readonly = false }: WeightedScoreProps) => { const { t } = useTranslation() return ( @@ -43,23 +37,29 @@ const WeightedScore = ({ min={0} step={0.1} value={value.value[0]} - onValueChange={v => !readonly && onChange({ value: [v, (10 - v * 10) / 10] })} + onValueChange={(v) => !readonly && onChange({ value: [v, (10 - v * 10) / 10] })} disabled={readonly} - aria-label={t($ => $['weightedScore.semantic'], { ns: 'dataset' })} + aria-label={t(($) => $['weightedScore.semantic'], { ns: 'dataset' })} slotClassNames={weightedScoreSliderSlotClassNames} /> </div> <div className="mt-3 flex justify-between"> <div className="flex w-[90px] shrink-0 items-center system-xs-semibold-uppercase text-util-colors-blue-light-blue-light-500"> - <div className="mr-1 truncate uppercase" title={t($ => $['weightedScore.semantic'], { ns: 'dataset' }) || ''}> - {t($ => $['weightedScore.semantic'], { ns: 'dataset' })} + <div + className="mr-1 truncate uppercase" + title={t(($) => $['weightedScore.semantic'], { ns: 'dataset' }) || ''} + > + {t(($) => $['weightedScore.semantic'], { ns: 'dataset' })} </div> {formatNumber(value.value[0]!)} </div> <div className="flex w-[90px] shrink-0 items-center justify-end system-xs-semibold-uppercase text-util-colors-teal-teal-500"> {formatNumber(value.value[1]!)} - <div className="ml-1 truncate uppercase" title={t($ => $['weightedScore.keyword'], { ns: 'dataset' }) || ''}> - {t($ => $['weightedScore.keyword'], { ns: 'dataset' })} + <div + className="ml-1 truncate uppercase" + title={t(($) => $['weightedScore.keyword'], { ns: 'dataset' }) || ''} + > + {t(($) => $['weightedScore.keyword'], { ns: 'dataset' })} </div> </div> </div> diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx index 731f55b9b5da6d..1ef0267629bcd5 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx @@ -2,7 +2,6 @@ import type { DataSet } from '@/models/datasets' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' - import { describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { DatasetPermission } from '@/models/datasets' @@ -72,7 +71,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -90,34 +90,35 @@ const baseProps = { onSelect: vi.fn(), } -const makeDataset = (overrides: Partial<DataSet>): DataSet => ({ - id: 'dataset-id', - name: 'Dataset Name', - provider: 'internal', - icon_info: { - icon_type: 'emoji', - icon: '💾', - icon_background: '#fff', - icon_url: '', - }, - embedding_available: true, - is_multimodal: false, - description: '', - permission: DatasetPermission.allTeamMembers, - indexing_technique: IndexingType.ECONOMICAL, - retrieval_model_dict: { - search_method: RETRIEVE_METHOD.fullText, - top_k: 5, - reranking_enable: false, - reranking_model: { - reranking_model_name: '', - reranking_provider_name: '', +const makeDataset = (overrides: Partial<DataSet>): DataSet => + ({ + id: 'dataset-id', + name: 'Dataset Name', + provider: 'internal', + icon_info: { + icon_type: 'emoji', + icon: '💾', + icon_background: '#fff', + icon_url: '', }, - score_threshold_enabled: false, - score_threshold: 0, - }, - ...overrides, -} as DataSet) + embedding_available: true, + is_multimodal: false, + description: '', + permission: DatasetPermission.allTeamMembers, + indexing_technique: IndexingType.ECONOMICAL, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.fullText, + top_k: 5, + reranking_enable: false, + reranking_model: { + reranking_model_name: '', + reranking_provider_name: '', + }, + score_threshold_enabled: false, + score_threshold: 0, + }, + ...overrides, + }) as DataSet describe('SelectDataSet', () => { beforeEach(() => { @@ -180,7 +181,10 @@ describe('SelectDataSet', () => { }) expect(screen.getByText('appDebug.feature.dataSet.noDataSet')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'appDebug.feature.dataSet.toCreate' })).toHaveAttribute('href', '/datasets/create') + expect(screen.getByRole('link', { name: 'appDebug.feature.dataSet.toCreate' })).toHaveAttribute( + 'href', + '/datasets/create', + ) expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled() }) @@ -199,7 +203,9 @@ describe('SelectDataSet', () => { }) expect(screen.getByText('appDebug.feature.dataSet.noDataSet')).toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'appDebug.feature.dataSet.toCreate' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'appDebug.feature.dataSet.toCreate' }), + ).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled() }) @@ -262,7 +268,9 @@ describe('SelectDataSet', () => { }) await act(async () => { - fireEvent.click(screen.getByText('Unavailable Dataset').parentElement?.parentElement as HTMLElement) + fireEvent.click( + screen.getByText('Unavailable Dataset').parentElement?.parentElement as HTMLElement, + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) }) @@ -283,7 +291,9 @@ describe('SelectDataSet', () => { render(<SelectDataSet {...baseProps} onSelect={vi.fn()} selectedIds={[]} />) }) - const loadMore = mockUseInfiniteScroll.mock.calls.at(-1)?.[0] as (() => Promise<{ list: never[] }>) + const loadMore = mockUseInfiniteScroll.mock.calls.at(-1)?.[0] as () => Promise<{ + list: never[] + }> await act(async () => { await loadMore() }) @@ -305,7 +315,9 @@ describe('SelectDataSet', () => { render(<SelectDataSet {...baseProps} onSelect={vi.fn()} selectedIds={[]} />) }) - const loadMore = mockUseInfiniteScroll.mock.calls.at(-1)?.[0] as (() => Promise<{ list: never[] }>) + const loadMore = mockUseInfiniteScroll.mock.calls.at(-1)?.[0] as () => Promise<{ + list: never[] + }> await act(async () => { await loadMore() }) diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx index 090ebab1a7d7bb..acc668ebad6edf 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx @@ -47,11 +47,13 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({ ) const datasets = useMemo(() => { const pages = data?.pages || [] - return pages.flatMap(page => page.data.filter(item => item.indexing_technique || item.provider === 'external')) + return pages.flatMap((page) => + page.data.filter((item) => item.indexing_technique || item.provider === 'external'), + ) }, [data]) - const datasetMap = useMemo(() => new Map(datasets.map(item => [item.id, item])), [datasets]) + const datasetMap = useMemo(() => new Map(datasets.map((item) => [item.id, item])), [datasets]) const selected = useMemo(() => { - return selectedIdsInModal.map(id => datasetMap.get(id) || ({ id } as DataSet)) + return selectedIdsInModal.map((id) => datasetMap.get(id) || ({ id } as DataSet)) }, [datasetMap, selectedIdsInModal]) const hasNoData = !isLoading && datasets.length === 0 @@ -60,8 +62,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({ useInfiniteScroll( async () => { - if (!hasNextPage || isFetchingNextPage) - return { list: [] } + if (!hasNextPage || isFetchingNextPage) return { list: [] } await fetchNextPage() return { list: [] } }, @@ -75,8 +76,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({ const toggleSelect = (dataSet: DataSet) => { setSelectedIdsInModal((prev) => { const isSelected = prev.includes(dataSet.id) - if (isSelected) - return prev.filter(id => id !== dataSet.id) + if (isSelected) return prev.filter((id) => id !== dataSet.id) return canSelectMulti ? [...prev, dataSet.id] : [dataSet.id] }) @@ -91,19 +91,21 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({ onClose() }, [onClose, selectedIds]) - const handleOpenChange = useCallback((open: boolean) => { - if (!open) - handleClose() - }, [handleClose]) + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) handleClose() + }, + [handleClose], + ) return ( <Dialog modal={modal} open={isShow} onOpenChange={handleOpenChange}> <DialogContent backdropProps={{ forceRender: true }} className="w-100 overflow-hidden"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['feature.dataSet.selectTitle'], { ns: 'appDebug' })} + {t(($) => $['feature.dataSet.selectTitle'], { ns: 'appDebug' })} </DialogTitle> - <DialogCloseButton aria-label={t($ => $['operation.close'], { ns: 'common' })} /> - {(isLoading && datasets.length === 0) && ( + <DialogCloseButton aria-label={t(($) => $['operation.close'], { ns: 'common' })} /> + {isLoading && datasets.length === 0 && ( <div className="flex h-50"> <Loading type="area" /> </div> @@ -111,9 +113,13 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({ {hasNoData && ( <div className="mt-6 flex h-32 items-center justify-center space-x-1 rounded-lg border border-divider-subtle bg-components-panel-on-panel-item-bg text-[13px]"> - <span className="text-text-tertiary">{t($ => $['feature.dataSet.noDataSet'], { ns: 'appDebug' })}</span> + <span className="text-text-tertiary"> + {t(($) => $['feature.dataSet.noDataSet'], { ns: 'appDebug' })} + </span> {canCreateDataset && ( - <Link href="/datasets/create" className="font-normal text-text-accent">{t($ => $['feature.dataSet.toCreate'], { ns: 'appDebug' })}</Link> + <Link href="/datasets/create" className="font-normal text-text-accent"> + {t(($) => $['feature.dataSet.toCreate'], { ns: 'appDebug' })} + </Link> )} </div> )} @@ -121,15 +127,17 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({ {datasets.length > 0 && ( <> <div ref={listRef} className="mt-7 max-h-71.5 space-y-1 overflow-y-auto"> - {datasets.map(item => ( + {datasets.map((item) => ( <button key={item.id} type="button" disabled={!item.embedding_available} className={cn( 'flex h-10 w-full cursor-pointer items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 text-left shadow-xs outline-hidden hover:border-components-panel-border hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm focus-visible:ring-2 focus-visible:ring-state-accent-solid', - selectedIdsInModal.includes(item.id) && 'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs', - !item.embedding_available && 'cursor-default hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg hover:shadow-xs', + selectedIdsInModal.includes(item.id) && + 'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs', + !item.embedding_available && + 'cursor-default hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg hover:shadow-xs', )} onClick={() => toggleSelect(item)} > @@ -139,13 +147,28 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({ size="tiny" iconType={item.icon_info.icon_type} icon={item.icon_info.icon} - background={item.icon_info.icon_type === 'image' ? undefined : item.icon_info.icon_background} - imageUrl={item.icon_info.icon_type === 'image' ? item.icon_info.icon_url : undefined} + background={ + item.icon_info.icon_type === 'image' + ? undefined + : item.icon_info.icon_background + } + imageUrl={ + item.icon_info.icon_type === 'image' ? item.icon_info.icon_url : undefined + } /> </div> - <div className={cn('max-w-50 truncate text-[13px] font-medium text-text-secondary', !item.embedding_available && 'max-w-30! opacity-30')}>{item.name}</div> + <div + className={cn( + 'max-w-50 truncate text-[13px] font-medium text-text-secondary', + !item.embedding_available && 'max-w-30! opacity-30', + )} + > + {item.name} + </div> {!item.embedding_available && ( - <span className="ml-1 shrink-0 rounded-md border border-divider-deep px-1 text-xs leading-[18px] font-normal text-text-tertiary">{t($ => $.unavailable, { ns: 'dataset' })}</span> + <span className="ml-1 shrink-0 rounded-md border border-divider-deep px-1 text-xs leading-[18px] font-normal text-text-tertiary"> + {t(($) => $.unavailable, { ns: 'dataset' })} + </span> )} </div> {item.is_multimodal && ( @@ -153,19 +176,18 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({ <FeatureIcon feature={ModelFeatureEnum.vision} /> </div> )} - { - !!item.indexing_technique && ( - <Badge - className="shrink-0" - text={formatIndexingTechniqueAndMethod(item.indexing_technique, item.retrieval_model_dict?.search_method)} - /> - ) - } - { - item.provider === 'external' && ( - <Badge className="shrink-0" text={t($ => $.externalTag, { ns: 'dataset' })} /> - ) - } + {!!item.indexing_technique && ( + <Badge + className="shrink-0" + text={formatIndexingTechniqueAndMethod( + item.indexing_technique, + item.retrieval_model_dict?.search_method, + )} + /> + )} + {item.provider === 'external' && ( + <Badge className="shrink-0" text={t(($) => $.externalTag, { ns: 'dataset' })} /> + )} </button> ))} {isFetchingNextPage && <Loading />} @@ -175,11 +197,16 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({ {!isLoading && ( <div className="mt-8 flex items-center justify-between"> <div className="text-sm font-medium text-text-secondary"> - {selected.length > 0 && `${selected.length} ${t($ => $['feature.dataSet.selected'], { ns: 'appDebug' })}`} + {selected.length > 0 && + `${selected.length} ${t(($) => $['feature.dataSet.selected'], { ns: 'appDebug' })}`} </div> <div className="flex space-x-2"> - <Button onClick={handleClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" onClick={handleSelect} disabled={hasNoData}>{t($ => $['operation.add'], { ns: 'common' })}</Button> + <Button onClick={handleClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button variant="primary" onClick={handleSelect} disabled={hasNoData}> + {t(($) => $['operation.add'], { ns: 'common' })} + </Button> </div> </div> )} diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx index 95bd3dec761bdc..3fd37828c57825 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx @@ -9,7 +9,12 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { defaultSystemFeatures } from '@/features/system-features/config' -import { ChunkingMode, DatasetPermission, DataSourceType, RerankingModeEnum } from '@/models/datasets' +import { + ChunkingMode, + DatasetPermission, + DataSourceType, + RerankingModeEnum, +} from '@/models/datasets' import { updateDatasetSetting } from '@/service/datasets' import { useMembers } from '@/service/use-common' import { RETRIEVE_METHOD } from '@/types/app' @@ -25,10 +30,18 @@ const toastMocks = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { - success: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'success', message, ...options })), - error: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'error', message, ...options })), - warning: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'warning', message, ...options })), - info: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'info', message, ...options })), + success: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'success', message, ...options }), + ), + error: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'info', message, ...options }), + ), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -99,7 +112,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => ( + default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( <div data-testid="model-selector"> {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'} </div> @@ -127,7 +140,10 @@ const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): Retrie ...overrides, }) -const createDataset = (overrides: Partial<DataSet> = {}, retrievalOverrides: Partial<RetrievalConfig> = {}): DataSet => { +const createDataset = ( + overrides: Partial<DataSet> = {}, + retrievalOverrides: Partial<RetrievalConfig> = {}, +): DataSet => { const retrievalConfig = createRetrievalConfig(retrievalOverrides) return { id: 'dataset-id', @@ -197,19 +213,12 @@ const renderWithProviders = (dataset: DataSet) => { return render( <QueryClientProvider client={queryClient}> - <SettingsModal - currentDataset={dataset} - onCancel={mockOnCancel} - onSave={mockOnSave} - /> + <SettingsModal currentDataset={dataset} onCancel={mockOnCancel} onSave={mockOnSave} /> </QueryClientProvider>, - ) } -const createMemberList = (): DataSet['partial_member_list'] => ([ - 'member-2', -]) +const createMemberList = (): DataSet['partial_member_list'] => ['member-2'] const renderSettingsModal = async (dataset: DataSet) => { renderWithProviders(dataset) @@ -257,7 +266,10 @@ describe('SettingsModal', () => { return { data: [{ provider: 'embed-provider', models: [{ model: 'embed-model' }] }] } }) mockUseModelListAndDefaultModel.mockReturnValue({ modelList: [], defaultModel: null }) - mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ defaultModel: null, currentModel: null }) + mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ + defaultModel: null, + currentModel: null, + }) mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null }) mockCheckShowMultiModalTip.mockReturnValue(false) mockUpdateDatasetSetting.mockResolvedValue(createDataset()) @@ -273,8 +285,12 @@ describe('SettingsModal', () => { await renderSettingsModal(dataset) // Assert - expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue('Test Dataset') - expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue('Description') + expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue( + 'Test Dataset', + ) + expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue( + 'Description', + ) }) it('should show external knowledge info when dataset is external', async () => { @@ -353,14 +369,18 @@ describe('SettingsModal', () => { await user.click(screen.getByText('datasetCreation.stepTwo.qualified')) // Assert - expect(await screen.findByText('appDebug.datasetConfig.retrieveChangeTip')).toBeInTheDocument() + expect( + await screen.findByText('appDebug.datasetConfig.retrieveChangeTip'), + ).toBeInTheDocument() // Act await user.click(screen.getByLabelText('close-retrieval-change-tip')) // Assert await waitFor(() => { - expect(screen.queryByText('appDebug.datasetConfig.retrieveChangeTip')).not.toBeInTheDocument() + expect( + screen.queryByText('appDebug.datasetConfig.retrieveChangeTip'), + ).not.toBeInTheDocument() }) }) @@ -370,10 +390,14 @@ describe('SettingsModal', () => { // Act await renderSettingsModal(createDataset()) - await user.click(screen.getByRole('button', { name: 'datasetSettings.form.embeddingModelTipLink' })) + await user.click( + screen.getByRole('button', { name: 'datasetSettings.form.embeddingModelTipLink' }), + ) // Assert - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.PROVIDER, + }) }) }) @@ -403,10 +427,12 @@ describe('SettingsModal', () => { await user.click(screen.getByRole('button', { name: 'common.operation.save' })) // Assert - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'datasetSettings.form.nameError', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'datasetSettings.form.nameError', + }), + ) expect(mockUpdateDatasetSetting).not.toHaveBeenCalled() }) @@ -414,23 +440,28 @@ describe('SettingsModal', () => { // Arrange const user = userEvent.setup() mockUseModelList.mockReturnValue({ data: [] }) - const dataset = createDataset({}, createRetrievalConfig({ - reranking_enable: true, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - })) + const dataset = createDataset( + {}, + createRetrievalConfig({ + reranking_enable: true, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + ) // Act await renderSettingsModal(dataset) await user.click(screen.getByRole('button', { name: 'common.operation.save' })) // Assert - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'appDebug.datasetConfig.rerankModelRequired', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'appDebug.datasetConfig.rerankModelRequired', + }), + ) expect(mockUpdateDatasetSetting).not.toHaveBeenCalled() }) }) @@ -463,40 +494,49 @@ describe('SettingsModal', () => { // Assert await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled()) - expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ - name: 'Updated Internal Dataset', - permission: DatasetPermission.allTeamMembers, + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + name: 'Updated Internal Dataset', + permission: DatasetPermission.allTeamMembers, + }), }), - })) - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - message: 'common.actionMsg.modifiedSuccessfully', - })) - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - name: 'Updated Internal Dataset', - retrieval_model_dict: expect.objectContaining({ - reranking_enable: true, + ) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + message: 'common.actionMsg.modifiedSuccessfully', + }), + ) + expect(mockOnSave).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Updated Internal Dataset', + retrieval_model_dict: expect.objectContaining({ + reranking_enable: true, + }), }), - })) + ) }) it('should save external dataset changes when partial members configured', async () => { // Arrange const user = userEvent.setup() - const dataset = createDataset({ - provider: 'external', - permission: DatasetPermission.partialMembers, - partial_member_list: createMemberList(), - external_retrieval_model: { - top_k: 5, - score_threshold: 0.3, + const dataset = createDataset( + { + provider: 'external', + permission: DatasetPermission.partialMembers, + partial_member_list: createMemberList(), + external_retrieval_model: { + top_k: 5, + score_threshold: 0.3, + score_threshold_enabled: true, + }, + }, + { score_threshold_enabled: true, + score_threshold: 0.8, }, - }, { - score_threshold_enabled: true, - score_threshold: 0.8, - }) + ) // Act await renderSettingsModal(dataset) @@ -505,32 +545,38 @@ describe('SettingsModal', () => { // Assert await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled()) - expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ - permission: DatasetPermission.partialMembers, - external_retrieval_model: expect.objectContaining({ - top_k: 5, + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + permission: DatasetPermission.partialMembers, + external_retrieval_model: expect.objectContaining({ + top_k: 5, + }), + partial_member_list: [ + { + user_id: 'member-2', + role: 'editor', + }, + ], }), - partial_member_list: [ - { - user_id: 'member-2', - role: 'editor', - }, - ], }), - })) - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - retrieval_model_dict: expect.objectContaining({ - score_threshold_enabled: true, - score_threshold: 0.8, + ) + expect(mockOnSave).toHaveBeenCalledWith( + expect.objectContaining({ + retrieval_model_dict: expect.objectContaining({ + score_threshold_enabled: true, + score_threshold: 0.8, + }), }), - })) + ) }) it('should disable save button while saving', async () => { // Arrange const user = userEvent.setup() - mockUpdateDatasetSetting.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) + mockUpdateDatasetSetting.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), + ) // Act await renderSettingsModal(createDataset()) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/retrieval-section.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/retrieval-section.spec.tsx index f584770b9e1cae..06d8c7e2a14c76 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/retrieval-section.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/retrieval-section.spec.tsx @@ -5,7 +5,12 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { ChunkingMode, DatasetPermission, DataSourceType, RerankingModeEnum } from '@/models/datasets' +import { + ChunkingMode, + DatasetPermission, + DataSourceType, + RerankingModeEnum, +} from '@/models/datasets' import { withSelectorKey } from '@/test/i18n-mock' import { RETRIEVE_METHOD } from '@/types/app' import { RetrievalChangeTip, RetrievalSection } from '../retrieval-section' @@ -44,7 +49,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => ( + default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( <div data-testid="model-selector"> {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'} </div> @@ -72,7 +77,10 @@ const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): Retrie ...overrides, }) -const createDataset = (overrides: Partial<DataSet> = {}, retrievalOverrides: Partial<RetrievalConfig> = {}): DataSet => { +const createDataset = ( + overrides: Partial<DataSet> = {}, + retrievalOverrides: Partial<RetrievalConfig> = {}, +): DataSet => { const retrievalConfig = createRetrievalConfig(retrievalOverrides) return { id: 'dataset-id', @@ -214,7 +222,10 @@ describe('RetrievalSection', () => { return { data: [] } }) mockUseModelListAndDefaultModel.mockReturnValue({ modelList: [], defaultModel: null }) - mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ defaultModel: null, currentModel: null }) + mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ + defaultModel: null, + currentModel: null, + }) mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null }) }) @@ -279,9 +290,16 @@ describe('RetrievalSection', () => { // Assert // Assert expect(screen.getByText('dataset.retrieval.semantic_search.title'))!.toBeInTheDocument() - const learnMoreLink = screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' }) - expect(learnMoreLink)!.toHaveAttribute('href', 'https://docs.example/use-dify/knowledge/create-knowledge/setting-indexing-methods') - expect(docLink).toHaveBeenCalledWith('/use-dify/knowledge/create-knowledge/setting-indexing-methods') + const learnMoreLink = screen.getByRole('link', { + name: 'datasetSettings.form.retrievalSetting.learnMore', + }) + expect(learnMoreLink)!.toHaveAttribute( + 'href', + 'https://docs.example/use-dify/knowledge/create-knowledge/setting-indexing-methods', + ) + expect(docLink).toHaveBeenCalledWith( + '/use-dify/knowledge/create-knowledge/setting-indexing-methods', + ) }) it('propagates retrieval config changes for economical indexing', async () => { @@ -299,7 +317,7 @@ describe('RetrievalSection', () => { retrievalConfig={createRetrievalConfig()} showMultiModalTip={false} onRetrievalConfigChange={handleRetrievalChange} - docLink={path => path || ''} + docLink={(path) => path || ''} />, ) const [topKIncrement] = screen.getAllByRole('button', { name: /increment/i }) @@ -308,8 +326,10 @@ describe('RetrievalSection', () => { // Assert // Assert expect(screen.getByText('dataset.retrieval.keyword_search.title'))!.toBeInTheDocument() - expect(handleRetrievalChange).toHaveBeenCalledWith(expect.objectContaining({ - top_k: 3, - })) + expect(handleRetrievalChange).toHaveBeenCalledWith( + expect.objectContaining({ + top_k: 3, + }), + ) }) }) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index a22457880d33d3..36a5bf540075e1 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -60,27 +60,39 @@ const SettingsModal: FC<SettingsModalProps> = ({ const [loading, setLoading] = useState(false) const [localeCurrentDataset, setLocaleCurrentDataset] = useState({ ...currentDataset }) const [topK, setTopK] = useState(localeCurrentDataset?.external_retrieval_model.top_k ?? 2) - const [scoreThreshold, setScoreThreshold] = useState(localeCurrentDataset?.external_retrieval_model.score_threshold ?? 0.5) - const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(localeCurrentDataset?.external_retrieval_model.score_threshold_enabled ?? false) - const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset.partial_member_list || []) + const [scoreThreshold, setScoreThreshold] = useState( + localeCurrentDataset?.external_retrieval_model.score_threshold ?? 0.5, + ) + const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState( + localeCurrentDataset?.external_retrieval_model.score_threshold_enabled ?? false, + ) + const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>( + currentDataset.partial_member_list || [], + ) const [memberList, setMemberList] = useState<Member[]>([]) const { data: membersData } = useMembers() const [indexMethod, setIndexMethod] = useState(currentDataset.indexing_technique) - const [retrievalConfig, setRetrievalConfig] = useState(localeCurrentDataset?.retrieval_model_dict as RetrievalConfig) + const [retrievalConfig, setRetrievalConfig] = useState( + localeCurrentDataset?.retrieval_model_dict as RetrievalConfig, + ) const [keywordNumber, setKeywordNumber] = useState(currentDataset.keyword_number ?? 10) const handleValueChange = (type: string, value: string) => { setLocaleCurrentDataset({ ...localeCurrentDataset, [type]: value }) } const [isHideChangedTip, setIsHideChangedTip] = useState(false) - const isRetrievalChanged = !isEqual(retrievalConfig, localeCurrentDataset?.retrieval_model_dict) || indexMethod !== localeCurrentDataset?.indexing_technique + const isRetrievalChanged = + !isEqual(retrievalConfig, localeCurrentDataset?.retrieval_model_dict) || + indexMethod !== localeCurrentDataset?.indexing_technique - const handleSettingsChange = (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => { - if (data.top_k !== undefined) - setTopK(data.top_k) - if (data.score_threshold !== undefined) - setScoreThreshold(data.score_threshold) + const handleSettingsChange = (data: { + top_k?: number + score_threshold?: number + score_threshold_enabled?: boolean + }) => { + if (data.top_k !== undefined) setTopK(data.top_k) + if (data.score_threshold !== undefined) setScoreThreshold(data.score_threshold) if (data.score_threshold_enabled !== undefined) setScoreThresholdEnabled(data.score_threshold_enabled) @@ -94,10 +106,9 @@ const SettingsModal: FC<SettingsModalProps> = ({ } const handleSave = async () => { - if (loading) - return + if (loading) return if (!localeCurrentDataset.name?.trim()) { - toast.error(t($ => $['form.nameError'], { ns: 'datasetSettings' })) + toast.error(t(($) => $['form.nameError'], { ns: 'datasetSettings' })) return } if ( @@ -107,7 +118,7 @@ const SettingsModal: FC<SettingsModalProps> = ({ indexMethod, }) ) { - toast.error(t($ => $['datasetConfig.rerankModelRequired'], { ns: 'appDebug' })) + toast.error(t(($) => $['datasetConfig.rerankModelRequired'], { ns: 'appDebug' })) return } try { @@ -123,13 +134,16 @@ const SettingsModal: FC<SettingsModalProps> = ({ keyword_number: keywordNumber, retrieval_model: { ...retrievalConfig, - score_threshold: retrievalConfig.score_threshold_enabled ? retrievalConfig.score_threshold : 0, + score_threshold: retrievalConfig.score_threshold_enabled + ? retrievalConfig.score_threshold + : 0, }, embedding_model: localeCurrentDataset.embedding_model, embedding_model_provider: localeCurrentDataset.embedding_model_provider, ...(isExternal && { external_knowledge_id: currentDataset!.external_knowledge_info.external_knowledge_id, - external_knowledge_api_id: currentDataset!.external_knowledge_info.external_knowledge_api_id, + external_knowledge_api_id: + currentDataset!.external_knowledge_info.external_knowledge_api_id, external_retrieval_model: { top_k: topK, score_threshold: scoreThreshold, @@ -142,31 +156,27 @@ const SettingsModal: FC<SettingsModalProps> = ({ requestParams.body.partial_member_list = selectedMemberIDs.map((id) => { return { user_id: id, - role: memberList.find(member => member.id === id)?.role, + role: memberList.find((member) => member.id === id)?.role, } }) } await updateDatasetSetting(requestParams) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) onSave({ ...localeCurrentDataset, indexing_technique: indexMethod, retrieval_model_dict: retrievalConfig, }) - } - catch { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - } - finally { + } catch { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } finally { setLoading(false) } } useEffect(() => { - if (!membersData?.accounts) - setMemberList([]) - else - setMemberList(membersData.accounts) + if (!membersData?.accounts) setMemberList([]) + else setMemberList(membersData.accounts) }, [membersData]) const showMultiModalTip = useMemo(() => { @@ -184,7 +194,15 @@ const SettingsModal: FC<SettingsModalProps> = ({ embeddingModelList, rerankModelList, }) - }, [localeCurrentDataset.embedding_model, localeCurrentDataset.embedding_model_provider, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, indexMethod, embeddingModelList, rerankModelList]) + }, [ + localeCurrentDataset.embedding_model, + localeCurrentDataset.embedding_model_provider, + retrievalConfig.reranking_enable, + retrievalConfig.reranking_model, + indexMethod, + embeddingModelList, + rerankModelList, + ]) return ( <div @@ -196,7 +214,7 @@ const SettingsModal: FC<SettingsModalProps> = ({ > <div className="flex h-14 shrink-0 items-center justify-between border-b border-divider-regular pr-5 pl-6"> <div className="flex flex-col text-base font-semibold text-text-primary"> - <div className="leading-6">{t($ => $.title, { ns: 'datasetSettings' })}</div> + <div className="leading-6">{t(($) => $.title, { ns: 'datasetSettings' })}</div> </div> <div className="flex items-center"> <div @@ -211,39 +229,45 @@ const SettingsModal: FC<SettingsModalProps> = ({ <div className="overflow-y-auto border-b border-divider-regular p-6 pt-5 pb-[68px]"> <div className={cn(rowClass, 'items-center')}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.name'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.name'], { ns: 'datasetSettings' })} + </div> </div> <Input value={localeCurrentDataset.name} - onChange={e => handleValueChange('name', e.target.value)} + onChange={(e) => handleValueChange('name', e.target.value)} className="block h-9" - placeholder={t($ => $['form.namePlaceholder'], { ns: 'datasetSettings' }) || ''} + placeholder={t(($) => $['form.namePlaceholder'], { ns: 'datasetSettings' }) || ''} /> </div> <div className={cn(rowClass)}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.desc'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.desc'], { ns: 'datasetSettings' })} + </div> </div> <div className="w-full"> <Textarea - aria-label={t($ => $['form.desc'], { ns: 'datasetSettings' })} + aria-label={t(($) => $['form.desc'], { ns: 'datasetSettings' })} value={localeCurrentDataset.description || ''} - onValueChange={value => handleValueChange('description', value)} + onValueChange={(value) => handleValueChange('description', value)} className="resize-none" - placeholder={t($ => $['form.descPlaceholder'], { ns: 'datasetSettings' }) || ''} + placeholder={t(($) => $['form.descPlaceholder'], { ns: 'datasetSettings' }) || ''} /> </div> </div> <div className={rowClass}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.permissions'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.permissions'], { ns: 'datasetSettings' })} + </div> </div> <div className="w-full"> <PermissionSelector disabled={!localeCurrentDataset?.embedding_available} permission={localeCurrentDataset.permission} value={selectedMemberIDs} - onChange={v => handleValueChange('permission', v!)} + onChange={(v) => handleValueChange('permission', v!)} onMemberSelect={setSelectedMemberIDs} memberList={memberList} /> @@ -252,7 +276,9 @@ const SettingsModal: FC<SettingsModalProps> = ({ {!!(currentDataset && currentDataset.indexing_technique) && ( <div className={cn(rowClass)}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.indexMethod'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.indexMethod'], { ns: 'datasetSettings' })} + </div> </div> <div className="grow"> <IndexMethod @@ -269,7 +295,9 @@ const SettingsModal: FC<SettingsModalProps> = ({ {indexMethod === IndexingType.QUALIFIED && ( <div className={cn(rowClass)}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.embeddingModel'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.embeddingModel'], { ns: 'datasetSettings' })} + </div> </div> <div className="w-full"> <div className="h-8 w-full rounded-lg bg-components-input-bg-normal opacity-60"> @@ -283,13 +311,13 @@ const SettingsModal: FC<SettingsModalProps> = ({ /> </div> <div className="mt-2 w-full text-xs/6 text-text-tertiary"> - {t($ => $['form.embeddingModelTip'], { ns: 'datasetSettings' })} + {t(($) => $['form.embeddingModelTip'], { ns: 'datasetSettings' })} <button type="button" className="cursor-pointer border-none bg-transparent p-0 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={() => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })} > - {t($ => $['form.embeddingModelTipLink'], { ns: 'datasetSettings' })} + {t(($) => $['form.embeddingModelTipLink'], { ns: 'datasetSettings' })} </button> </div> </div> @@ -297,55 +325,44 @@ const SettingsModal: FC<SettingsModalProps> = ({ )} {/* Retrieval Method Config */} - {isExternal - ? ( - <RetrievalSection - isExternal - rowClass={rowClass} - labelClass={labelClass} - t={translateRetrieval} - topK={topK} - scoreThreshold={scoreThreshold} - scoreThresholdEnabled={scoreThresholdEnabled} - onExternalSettingChange={handleSettingsChange} - currentDataset={currentDataset} - /> - ) - : ( - <RetrievalSection - isExternal={false} - rowClass={rowClass} - labelClass={labelClass} - t={translateRetrieval} - indexMethod={indexMethod} - retrievalConfig={retrievalConfig} - showMultiModalTip={showMultiModalTip} - onRetrievalConfigChange={setRetrievalConfig} - docLink={docLink} - /> - )} + {isExternal ? ( + <RetrievalSection + isExternal + rowClass={rowClass} + labelClass={labelClass} + t={translateRetrieval} + topK={topK} + scoreThreshold={scoreThreshold} + scoreThresholdEnabled={scoreThresholdEnabled} + onExternalSettingChange={handleSettingsChange} + currentDataset={currentDataset} + /> + ) : ( + <RetrievalSection + isExternal={false} + rowClass={rowClass} + labelClass={labelClass} + t={translateRetrieval} + indexMethod={indexMethod} + retrievalConfig={retrievalConfig} + showMultiModalTip={showMultiModalTip} + onRetrievalConfigChange={setRetrievalConfig} + docLink={docLink} + /> + )} </div> <RetrievalChangeTip visible={isRetrievalChanged && !isHideChangedTip} - message={t($ => $['datasetConfig.retrieveChangeTip'], { ns: 'appDebug' })} + message={t(($) => $['datasetConfig.retrieveChangeTip'], { ns: 'appDebug' })} onDismiss={() => setIsHideChangedTip(true)} /> - <div - className="sticky bottom-0 z-5 flex w-full justify-end border-t border-divider-regular bg-background-section px-6 py-4" - > - <Button - onClick={onCancel} - className="mr-2" - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <div className="sticky bottom-0 z-5 flex w-full justify-end border-t border-divider-regular bg-background-section px-6 py-4"> + <Button onClick={onCancel} className="mr-2"> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button - variant="primary" - disabled={loading} - onClick={handleSave} - > - {t($ => $['operation.save'], { ns: 'common' })} + <Button variant="primary" disabled={loading} onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </div> diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx index f2a19e955e51fe..182f9749f16919 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx @@ -28,7 +28,11 @@ type ExternalRetrievalSectionProps = CommonSectionProps & { topK: number scoreThreshold: number scoreThresholdEnabled: boolean - onExternalSettingChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void + onExternalSettingChange: (data: { + top_k?: number + score_threshold?: number + score_threshold_enabled?: boolean + }) => void currentDataset: DataSet } @@ -43,10 +47,14 @@ const ExternalRetrievalSection: FC<ExternalRetrievalSectionProps> = ({ currentDataset, }) => ( <> - <div className={rowClass}><Divider /></div> + <div className={rowClass}> + <Divider /> + </div> <div className={rowClass}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} + </div> </div> <RetrievalSettings topK={topK} @@ -56,10 +64,14 @@ const ExternalRetrievalSection: FC<ExternalRetrievalSectionProps> = ({ isInRetrievalSetting={true} /> </div> - <div className={rowClass}><Divider /></div> + <div className={rowClass}> + <Divider /> + </div> <div className={rowClass}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.externalKnowledgeAPI'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.externalKnowledgeAPI'], { ns: 'datasetSettings' })} + </div> </div> <div className="w-full max-w-[480px]"> <div className="flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2"> @@ -68,21 +80,29 @@ const ExternalRetrievalSection: FC<ExternalRetrievalSectionProps> = ({ {currentDataset?.external_knowledge_info.external_knowledge_api_name} </div> <div className="system-xs-regular text-text-tertiary">·</div> - <div className="system-xs-regular text-text-tertiary">{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}</div> + <div className="system-xs-regular text-text-tertiary"> + {currentDataset?.external_knowledge_info.external_knowledge_api_endpoint} + </div> </div> </div> </div> <div className={rowClass}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.externalKnowledgeID'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.externalKnowledgeID'], { ns: 'datasetSettings' })} + </div> </div> <div className="w-full max-w-[480px]"> <div className="flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2"> - <div className="system-xs-regular text-text-tertiary">{currentDataset?.external_knowledge_info.external_knowledge_id}</div> + <div className="system-xs-regular text-text-tertiary"> + {currentDataset?.external_knowledge_info.external_knowledge_id} + </div> </div> </div> </div> - <div className={rowClass}><Divider /></div> + <div className={rowClass}> + <Divider /> + </div> </> ) @@ -107,35 +127,42 @@ const InternalRetrievalSection: FC<InternalRetrievalSectionProps> = ({ <div className={rowClass}> <div className={cn(labelClass, 'w-auto min-w-[168px]')}> <div> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} + </div> <div className="text-xs leading-[18px] font-normal text-text-tertiary"> - <a target="_blank" rel="noopener noreferrer" href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')} className="text-text-accent">{t($ => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })}</a> - {t($ => $['form.retrievalSetting.description'], { ns: 'datasetSettings' })} + <a + target="_blank" + rel="noopener noreferrer" + href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')} + className="text-text-accent" + > + {t(($) => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })} + </a> + {t(($) => $['form.retrievalSetting.description'], { ns: 'datasetSettings' })} </div> </div> </div> <div> - {indexMethod === IndexingType.QUALIFIED - ? ( - <RetrievalMethodConfig - value={retrievalConfig} - onChange={onRetrievalConfigChange} - showMultiModalTip={showMultiModalTip} - /> - ) - : ( - <EconomicalRetrievalMethodConfig - value={retrievalConfig} - onChange={onRetrievalConfigChange} - /> - )} + {indexMethod === IndexingType.QUALIFIED ? ( + <RetrievalMethodConfig + value={retrievalConfig} + onChange={onRetrievalConfigChange} + showMultiModalTip={showMultiModalTip} + /> + ) : ( + <EconomicalRetrievalMethodConfig + value={retrievalConfig} + onChange={onRetrievalConfigChange} + /> + )} </div> </div> ) -type RetrievalSectionProps - = | (ExternalRetrievalSectionProps & { isExternal: true }) - | (InternalRetrievalSectionProps & { isExternal: false }) +type RetrievalSectionProps = + | (ExternalRetrievalSectionProps & { isExternal: true }) + | (InternalRetrievalSectionProps & { isExternal: false }) export const RetrievalSection: FC<RetrievalSectionProps> = (props) => { if (props.isExternal) { @@ -200,8 +227,7 @@ export const RetrievalChangeTip: FC<RetrievalChangeTipProps> = ({ message, onDismiss, }) => { - if (!visible) - return null + if (!visible) return null return ( <div className="absolute right-[30px] bottom-[76px] left-[30px] z-10 flex h-10 items-center justify-between rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 shadow-lg"> diff --git a/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx b/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx index 9d48c0b7d22947..401afe403dee2e 100644 --- a/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx +++ b/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx @@ -11,7 +11,15 @@ vi.mock('use-context-selector', () => ({ })) vi.mock('@/app/components/base/input', () => ({ - default: ({ value, onChange, placeholder, autoFocus, maxLength, readOnly, type }: { + default: ({ + value, + onChange, + placeholder, + autoFocus, + maxLength, + readOnly, + type, + }: { value: string onChange: (e: { target: { value: string } }) => void placeholder?: string @@ -41,7 +49,11 @@ vi.mock('@langgenius/dify-ui/select', async () => { }>({}) return { - Select: ({ children, disabled, onValueChange }: { + Select: ({ + children, + disabled, + onValueChange, + }: { children: React.ReactNode disabled?: boolean onValueChange?: (value: string) => void @@ -50,24 +62,38 @@ vi.mock('@langgenius/dify-ui/select', async () => { <div>{children}</div> </SelectContext.Provider> ), - SelectTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => { + SelectTrigger: ({ children, className }: { children: React.ReactNode; className?: string }) => { const context = React.useContext(SelectContext) return ( <div> - <button data-testid="select-input" type="button" disabled={context.disabled} className={className}> + <button + data-testid="select-input" + type="button" + disabled={context.disabled} + className={className} + > {children} </button> - <button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}> + <button + data-testid="select-empty" + type="button" + onClick={() => context.onValueChange?.('')} + > empty select value </button> </div> ) }, SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => { const context = React.useContext(SelectContext) return ( - <button data-testid={`select-${value}`} type="button" role="option" onClick={() => context.onValueChange?.(value)}> + <button + data-testid={`select-${value}`} + type="button" + role="option" + onClick={() => context.onValueChange?.(value)} + > {children} </button> ) @@ -78,7 +104,13 @@ vi.mock('@langgenius/dify-ui/select', async () => { }) vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({ - default: ({ name, value, required, onChange, readonly }: { + default: ({ + name, + value, + required, + onChange, + readonly, + }: { name: string value: boolean required?: boolean @@ -89,7 +121,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-i <input type="checkbox" checked={value} - onChange={e => onChange(e.target.checked)} + onChange={(e) => onChange(e.target.checked)} disabled={readonly} data-required={required} /> @@ -109,7 +141,9 @@ type ExtendedPromptVariable = { default?: string | null } -const createPromptVariable = (overrides: Partial<ExtendedPromptVariable> = {}): ExtendedPromptVariable => ({ +const createPromptVariable = ( + overrides: Partial<ExtendedPromptVariable> = {}, +): ExtendedPromptVariable => ({ key: 'test-key', name: 'Test Name', type: 'string', @@ -117,22 +151,25 @@ const createPromptVariable = (overrides: Partial<ExtendedPromptVariable> = {}): ...overrides, }) -const createModelConfig = (promptVariables: ExtendedPromptVariable[] = []): ModelConfig => ({ - provider: 'openai', - model_id: 'gpt-4', - mode: 'chat', - configs: { - prompt_template: '', - prompt_variables: promptVariables as PromptVariable[], - }, -} as ModelConfig) - -const createContextValue = (overrides: Partial<{ - modelConfig: ModelConfig - setInputs: (inputs: Inputs) => void - readonly: boolean - canTestAndRun: boolean -}> = {}) => ({ +const createModelConfig = (promptVariables: ExtendedPromptVariable[] = []): ModelConfig => + ({ + provider: 'openai', + model_id: 'gpt-4', + mode: 'chat', + configs: { + prompt_template: '', + prompt_variables: promptVariables as PromptVariable[], + }, + }) as ModelConfig + +const createContextValue = ( + overrides: Partial<{ + modelConfig: ModelConfig + setInputs: (inputs: Inputs) => void + readonly: boolean + canTestAndRun: boolean + }> = {}, +) => ({ modelConfig: createModelConfig(), setInputs: mockSetInputs, readonly: false, @@ -148,66 +185,83 @@ describe('ChatUserInput', () => { describe('Rendering', () => { it('should return null when no prompt variables exist', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([]), + }), + ) const { container } = render(<ChatUserInput inputs={{}} />) expect(container.firstChild).toBeNull() }) it('should return null when prompt variables have empty keys', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: '', name: 'Test' }), - createPromptVariable({ key: ' ', name: 'Test2' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: '', name: 'Test' }), + createPromptVariable({ key: ' ', name: 'Test2' }), + ]), + }), + ) const { container } = render(<ChatUserInput inputs={{}} />) expect(container.firstChild).toBeNull() }) it('should return null when prompt variables have empty names', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'key1', name: '' }), - createPromptVariable({ key: 'key2', name: ' ' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'key1', name: '' }), + createPromptVariable({ key: 'key2', name: ' ' }), + ]), + }), + ) const { container } = render(<ChatUserInput inputs={{}} />) expect(container.firstChild).toBeNull() }) it('should render string input type', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('input-Name')).toBeInTheDocument() }) it('should render paragraph input type', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'description', name: 'Description', type: 'paragraph' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'description', name: 'Description', type: 'paragraph' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument() }) it('should render select input type', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B', 'C'] }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'choice', + name: 'Choice', + type: 'select', + options: ['A', 'B', 'C'], + }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('select-input')).toBeInTheDocument() @@ -217,11 +271,13 @@ describe('ChatUserInput', () => { }) it('should render number input type', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) const input = screen.getByTestId('input-Count') @@ -230,24 +286,33 @@ describe('ChatUserInput', () => { }) it('should render checkbox input type', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('bool-input-Enabled')).toBeInTheDocument() }) it('should render multiple input types', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), - createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['X', 'Y'] }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), + createPromptVariable({ + key: 'choice', + name: 'Choice', + type: 'select', + options: ['X', 'Y'], + }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('input-Name')).toBeInTheDocument() @@ -256,33 +321,39 @@ describe('ChatUserInput', () => { }) it('should show optional label for non-required fields', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', required: false }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string', required: false }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByText(/(?:^|\.)panel\.optional(?=$|:)/)).toBeInTheDocument() }) it('should not show optional label for required fields', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', required: true }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string', required: true }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.queryByText(/(?:^|\.)panel\.optional(?=$|:)/)).not.toBeInTheDocument() }) it('should use key as label when name is not provided', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'myKey', name: '', type: 'string' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'myKey', name: '', type: 'string' }), + ]), + }), + ) // This should actually return null because name is empty const { container } = render(<ChatUserInput inputs={{}} />) @@ -292,33 +363,39 @@ describe('ChatUserInput', () => { describe('Input Values', () => { it('should display existing input values for string type', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }), + ) render(<ChatUserInput inputs={{ name: 'John' }} />) expect(screen.getByTestId('input-Name')).toHaveValue('John') }) it('should display existing input values for paragraph type', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), + ]), + }), + ) render(<ChatUserInput inputs={{ desc: 'Long text here' }} />) expect(screen.getByRole('textbox', { name: 'Description' })).toHaveValue('Long text here') }) it('should display existing input values for number type', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), + ]), + }), + ) render(<ChatUserInput inputs={{ count: 42 }} />) // Number type input still uses string value internally @@ -326,11 +403,13 @@ describe('ChatUserInput', () => { }) it('should display checkbox as checked when value is truthy', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), + ]), + }), + ) render(<ChatUserInput inputs={{ enabled: true }} />) const checkbox = screen.getByTestId('bool-input-Enabled').querySelector('input') @@ -338,11 +417,13 @@ describe('ChatUserInput', () => { }) it('should display checkbox as unchecked when value is falsy', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), + ]), + }), + ) render(<ChatUserInput inputs={{ enabled: false }} />) const checkbox = screen.getByTestId('bool-input-Enabled').querySelector('input') @@ -350,22 +431,26 @@ describe('ChatUserInput', () => { }) it('should handle empty string values', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }), + ) render(<ChatUserInput inputs={{ name: '' }} />) expect(screen.getByTestId('input-Name')).toHaveValue('') }) it('should handle undefined values', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('input-Name')).toHaveValue('') @@ -374,11 +459,13 @@ describe('ChatUserInput', () => { describe('User Interactions', () => { it('should call setInputs when string input changes', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) fireEvent.change(screen.getByTestId('input-Name'), { target: { value: 'New Value' } }) @@ -387,24 +474,35 @@ describe('ChatUserInput', () => { }) it('should call setInputs when paragraph input changes', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) - fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { target: { value: 'New Description' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { + target: { value: 'New Description' }, + }) expect(mockSetInputs).toHaveBeenCalledWith({ desc: 'New Description' }) }) it('should call setInputs when select input changes', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B', 'C'] }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'choice', + name: 'Choice', + type: 'select', + options: ['A', 'B', 'C'], + }), + ]), + }), + ) render(<ChatUserInput inputs={{ choice: 'A' }} />) fireEvent.click(screen.getByTestId('select-B')) @@ -413,11 +511,18 @@ describe('ChatUserInput', () => { }) it('should ignore empty select updates', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B', 'C'] }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'choice', + name: 'Choice', + type: 'select', + options: ['A', 'B', 'C'], + }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) fireEvent.click(screen.getByTestId('select-empty')) @@ -426,11 +531,13 @@ describe('ChatUserInput', () => { }) it('should call setInputs when number input changes', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) fireEvent.change(screen.getByTestId('input-Count'), { target: { value: '100' } }) @@ -439,11 +546,13 @@ describe('ChatUserInput', () => { }) it('should call setInputs when checkbox changes', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), + ]), + }), + ) render(<ChatUserInput inputs={{ enabled: false }} />) const checkbox = screen.getByTestId('bool-input-Enabled').querySelector('input')! @@ -460,17 +569,19 @@ describe('ChatUserInput', () => { callback(createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), 0), ], } - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: { - ...createModelConfig(), - configs: { - prompt_template: '', - prompt_variables: { - filter: () => filteredPromptVariables, - } as unknown as PromptVariable[], + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: { + ...createModelConfig(), + configs: { + prompt_template: '', + prompt_variables: { + filter: () => filteredPromptVariables, + } as unknown as PromptVariable[], + }, }, - }, - })) + }), + ) render(<ChatUserInput inputs={{}} />) @@ -482,78 +593,95 @@ describe('ChatUserInput', () => { describe('Debug Permission', () => { it('should keep string input editable when configuration is readonly but test/run is allowed', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - readonly: true, - canTestAndRun: true, - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + readonly: true, + canTestAndRun: true, + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('input-Name')).not.toHaveAttribute('readonly') }) it('should set string input as readonly when test/run is denied even if configuration is editable', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - readonly: false, - canTestAndRun: false, - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + readonly: false, + canTestAndRun: false, + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('input-Name')).toHaveAttribute('readonly') }) it('should set string input as readonly when configuration is readonly and test/run is denied', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - readonly: true, - canTestAndRun: false, - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + readonly: true, + canTestAndRun: false, + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('input-Name')).toHaveAttribute('readonly') }) it('should set paragraph input as readonly when configuration is readonly and test/run is denied', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), - ]), - readonly: true, - canTestAndRun: false, - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), + ]), + readonly: true, + canTestAndRun: false, + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByRole('textbox', { name: 'Description' })).toHaveAttribute('readonly') }) it('should disable select when configuration is readonly and test/run is denied', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B'] }), - ]), - readonly: true, - canTestAndRun: false, - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'choice', + name: 'Choice', + type: 'select', + options: ['A', 'B'], + }), + ]), + readonly: true, + canTestAndRun: false, + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('select-input')).toBeDisabled() }) it('should disable checkbox when configuration is readonly and test/run is denied', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), - ]), - readonly: true, - canTestAndRun: false, - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), + ]), + readonly: true, + canTestAndRun: false, + }), + ) render(<ChatUserInput inputs={{}} />) const checkbox = screen.getByTestId('bool-input-Enabled').querySelector('input') @@ -563,11 +691,18 @@ describe('ChatUserInput', () => { describe('Default Values', () => { it('should initialize inputs with default values when field is empty', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: 'Default Name' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'name', + name: 'Name', + type: 'string', + default: 'Default Name', + }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) @@ -575,11 +710,13 @@ describe('ChatUserInput', () => { }) it('should not override existing values with defaults', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: 'Default' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: 'Default' }), + ]), + }), + ) render(<ChatUserInput inputs={{ name: 'Existing Value' }} />) @@ -588,12 +725,19 @@ describe('ChatUserInput', () => { }) it('should handle multiple default values', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: 'Default Name' }), - createPromptVariable({ key: 'count', name: 'Count', type: 'number', default: '10' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'name', + name: 'Name', + type: 'string', + default: 'Default Name', + }), + createPromptVariable({ key: 'count', name: 'Count', type: 'number', default: '10' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) @@ -604,11 +748,13 @@ describe('ChatUserInput', () => { }) it('should not set default when default is empty string', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: '' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: '' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) @@ -616,11 +762,13 @@ describe('ChatUserInput', () => { }) it('should not set default when default is undefined', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) @@ -628,11 +776,18 @@ describe('ChatUserInput', () => { }) it('should not set default when default is null', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: null as unknown as string }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'name', + name: 'Name', + type: 'string', + default: null as unknown as string, + }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) @@ -642,12 +797,14 @@ describe('ChatUserInput', () => { describe('AutoFocus', () => { it('should set autoFocus on first string input', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'first', name: 'First', type: 'string' }), - createPromptVariable({ key: 'second', name: 'Second', type: 'string' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'first', name: 'First', type: 'string' }), + createPromptVariable({ key: 'second', name: 'Second', type: 'string' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('input-First')).toHaveAttribute('data-autofocus', 'true') @@ -655,12 +812,14 @@ describe('ChatUserInput', () => { }) it('should set autoFocus on first number input when it is the first field', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('input-Count')).toHaveAttribute('data-autofocus', 'true') @@ -669,22 +828,26 @@ describe('ChatUserInput', () => { describe('MaxLength', () => { it('should pass maxLength to string input', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', max_length: 50 }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string', max_length: 50 }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('input-Name')).toHaveAttribute('maxLength', '50') }) it('should pass maxLength to number input', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'count', name: 'Count', type: 'number', max_length: 10 }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'count', name: 'Count', type: 'number', max_length: 10 }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) expect(screen.getByTestId('input-Count')).toHaveAttribute('maxLength', '10') @@ -693,11 +856,13 @@ describe('ChatUserInput', () => { describe('Edge Cases', () => { it('should handle select with empty options', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: [] }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: [] }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) const select = screen.getByTestId('select-input') @@ -706,11 +871,13 @@ describe('ChatUserInput', () => { }) it('should handle select with undefined options', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'choice', name: 'Choice', type: 'select' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'choice', name: 'Choice', type: 'select' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) const select = screen.getByTestId('select-input') @@ -718,12 +885,14 @@ describe('ChatUserInput', () => { }) it('should preserve other input values when updating one field', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), + ]), + }), + ) render(<ChatUserInput inputs={{ name: 'Existing', desc: 'Also Existing' }} />) fireEvent.change(screen.getByTestId('input-Name'), { target: { value: 'Updated' } }) @@ -735,22 +904,26 @@ describe('ChatUserInput', () => { }) it('should convert non-string values to string for display', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'value', name: 'Value', type: 'string' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'value', name: 'Value', type: 'string' }), + ]), + }), + ) render(<ChatUserInput inputs={{ value: 123 as unknown as string }} />) expect(screen.getByTestId('input-Value')).toHaveValue('123') }) it('should not hide label for checkbox type', () => { - mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'enabled', name: 'Is Enabled', type: 'checkbox' }), - ]), - })) + mockUseContext.mockReturnValue( + createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'enabled', name: 'Is Enabled', type: 'checkbox' }), + ]), + }), + ) render(<ChatUserInput inputs={{}} />) // For checkbox, the label is rendered inside BoolInput, not in the header diff --git a/web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx b/web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx index 2ec1b2482e86c6..94a799d5a4738d 100644 --- a/web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx +++ b/web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx @@ -53,7 +53,9 @@ describe('configuration debug hooks', () => { expect(result.current.debugWithMultipleModel).toBe(true) expect(result.current.multipleModelConfigs).toHaveLength(1) - expect(JSON.parse(localStorage.getItem('app-debug-with-single-or-multiple-models') || '{}')).toEqual({ + expect( + JSON.parse(localStorage.getItem('app-debug-with-single-or-multiple-models') || '{}'), + ).toEqual({ 'app-1': { multiple: true, configs: [ @@ -111,13 +113,15 @@ describe('configuration debug hooks', () => { const { result } = renderHook(() => useConfigFromDebugContext()) - expect(result.current).toEqual(expect.objectContaining({ - appId: 'app-1', - dataset_query_variable: '', - opening_statement: 'hello', - pre_prompt: 'hello {{name}}', - suggested_questions: ['how are you?'], - })) + expect(result.current).toEqual( + expect.objectContaining({ + appId: 'app-1', + dataset_query_variable: '', + opening_statement: 'hello', + pre_prompt: 'hello {{name}}', + suggested_questions: ['how are you?'], + }), + ) expect(result.current.agent_mode?.strategy).toBe(AgentStrategy.functionCall) expect(result.current.dataset_configs?.datasets?.datasets).toEqual([ { dataset: { enabled: true, id: 'dataset-1' } }, diff --git a/web/app/components/app/configuration/debug/__tests__/index.spec.tsx b/web/app/components/app/configuration/debug/__tests__/index.spec.tsx index b9e1400c67a09b..7f2812f5aa4a62 100644 --- a/web/app/components/app/configuration/debug/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/debug/__tests__/index.spec.tsx @@ -32,7 +32,11 @@ const mockState = vi.hoisted(() => ({ moreLikeThis: { enabled: false }, moderation: { enabled: false }, text2speech: { enabled: false }, - file: { enabled: false, allowed_file_upload_methods: [] as string[], fileUploadConfig: undefined as { image_file_size_limit?: number } | undefined }, + file: { + enabled: false, + allowed_file_upload_methods: [] as string[], + fileUploadConfig: undefined as { image_file_size_limit?: number } | undefined, + }, }, mockProviderContext: { textGenerationModelList: [] as Array<{ @@ -49,13 +53,17 @@ const mockState = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(mockState.mockToastCall, { success: vi.fn((message: string, options?: Record<string, unknown>) => - mockState.mockToastCall({ type: 'success', message, ...options })), + mockState.mockToastCall({ type: 'success', message, ...options }), + ), error: vi.fn((message: string, options?: Record<string, unknown>) => - mockState.mockToastCall({ type: 'error', message, ...options })), + mockState.mockToastCall({ type: 'error', message, ...options }), + ), warning: vi.fn((message: string, options?: Record<string, unknown>) => - mockState.mockToastCall({ type: 'warning', message, ...options })), + mockState.mockToastCall({ type: 'warning', message, ...options }), + ), info: vi.fn((message: string, options?: Record<string, unknown>) => - mockState.mockToastCall({ type: 'info', message, ...options })), + mockState.mockToastCall({ type: 'info', message, ...options }), + ), dismiss: mockState.mockToastDismiss, update: mockState.mockToastUpdate, promise: mockState.mockToastPromise, @@ -67,12 +75,17 @@ vi.mock('@/app/components/app/configuration/debug/chat-user-input', () => ({ })) vi.mock('@/app/components/app/configuration/prompt-value-panel', () => ({ - default: ({ onSend, onVisionFilesChange }: { + default: ({ + onSend, + onVisionFilesChange, + }: { onSend: () => void onVisionFilesChange: (files: Array<Record<string, unknown>>) => void }) => ( <div data-testid="prompt-value-panel"> - <button type="button" data-testid="panel-send" onClick={onSend}>Send</button> + <button type="button" data-testid="panel-send" onClick={onSend}> + Send + </button> <button type="button" data-testid="panel-set-pending-file" @@ -83,14 +96,22 @@ vi.mock('@/app/components/app/configuration/prompt-value-panel', () => ({ <button type="button" data-testid="panel-set-uploaded-file" - onClick={() => onVisionFilesChange([{ transfer_method: TransferMethod.local_file, upload_file_id: 'file-id' }])} + onClick={() => + onVisionFilesChange([ + { transfer_method: TransferMethod.local_file, upload_file_id: 'file-id' }, + ]) + } > Uploaded File </button> <button type="button" data-testid="panel-set-remote-file" - onClick={() => onVisionFilesChange([{ transfer_method: TransferMethod.remote_url, url: 'https://example.com/file.png' }])} + onClick={() => + onVisionFilesChange([ + { transfer_method: TransferMethod.remote_url, url: 'https://example.com/file.png' }, + ]) + } > Remote File </button> @@ -99,18 +120,25 @@ vi.mock('@/app/components/app/configuration/prompt-value-panel', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { - currentLogItem: unknown - setCurrentLogItem: () => void - showPromptLogModal: boolean - setShowPromptLogModal: () => void - showAgentLogModal: boolean - setShowAgentLogModal: () => void - }) => unknown) => selector(mockState.mockStoreState), + useStore: ( + selector: (state: { + currentLogItem: unknown + setCurrentLogItem: () => void + showPromptLogModal: boolean + setShowPromptLogModal: () => void + showAgentLogModal: boolean + setShowAgentLogModal: () => void + }) => unknown, + ) => selector(mockState.mockStoreState), })) vi.mock('@/app/components/app/text-generate/item', () => ({ - default: ({ content, isLoading, isShowTextToSpeech, messageId }: { + default: ({ + content, + isLoading, + isShowTextToSpeech, + messageId, + }: { content: string isLoading: boolean isShowTextToSpeech: boolean @@ -128,7 +156,15 @@ vi.mock('@/app/components/app/text-generate/item', () => ({ })) vi.mock('@/app/components/base/action-button', () => ({ - default: ({ children, onClick, state }: { children: React.ReactNode, onClick?: () => void, state?: string }) => ( + default: ({ + children, + onClick, + state, + }: { + children: React.ReactNode + onClick?: () => void + state?: string + }) => ( <button type="button" data-testid="action-button" data-state={state} onClick={onClick}> {children} </button> @@ -141,18 +177,28 @@ vi.mock('@/app/components/base/action-button', () => ({ vi.mock('@/app/components/base/agent-log-modal', () => ({ default: ({ onCancel }: { onCancel: () => void }) => ( <div data-testid="agent-log-modal"> - <button type="button" data-testid="agent-log-cancel" onClick={onCancel}>Cancel</button> + <button type="button" data-testid="agent-log-cancel" onClick={onCancel}> + Cancel + </button> </div> ), })) vi.mock('@/app/components/base/features/hooks', () => ({ - useFeatures: (selector: (state: { features: { - moreLikeThis: { enabled: boolean } - moderation: { enabled: boolean } - text2speech: { enabled: boolean } - file: { enabled: boolean, allowed_file_upload_methods: string[], fileUploadConfig?: { image_file_size_limit?: number } } - } }) => unknown) => selector({ features: mockState.mockFeaturesState }), + useFeatures: ( + selector: (state: { + features: { + moreLikeThis: { enabled: boolean } + moderation: { enabled: boolean } + text2speech: { enabled: boolean } + file: { + enabled: boolean + allowed_file_upload_methods: string[] + fileUploadConfig?: { image_file_size_limit?: number } + } + } + }) => unknown, + ) => selector({ features: mockState.mockFeaturesState }), useFeaturesStore: () => ({ getState: () => ({ features: mockState.mockFeaturesState, @@ -164,7 +210,9 @@ vi.mock('@/app/components/base/features/hooks', () => ({ vi.mock('@/app/components/base/prompt-log-modal', () => ({ default: ({ onCancel }: { onCancel: () => void }) => ( <div data-testid="prompt-log-modal"> - <button type="button" data-testid="prompt-log-cancel" onClick={onCancel}>Cancel</button> + <button type="button" data-testid="prompt-log-cancel" onClick={onCancel}> + Cancel + </button> </div> ), })) @@ -194,16 +242,22 @@ vi.mock('../../base/group-name', () => ({ vi.mock('../../base/warning-mask/cannot-query-dataset', () => ({ default: ({ onConfirm }: { onConfirm: () => void }) => ( <div data-testid="cannot-query-dataset"> - <button type="button" data-testid="cannot-query-confirm" onClick={onConfirm}>Confirm</button> + <button type="button" data-testid="cannot-query-confirm" onClick={onConfirm}> + Confirm + </button> </div> ), })) vi.mock('../../base/warning-mask/formatting-changed', () => ({ - default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => ( + default: ({ onConfirm, onCancel }: { onConfirm: () => void; onCancel: () => void }) => ( <div data-testid="formatting-changed"> - <button type="button" data-testid="formatting-confirm" onClick={onConfirm}>Confirm</button> - <button type="button" data-testid="formatting-cancel" onClick={onCancel}>Cancel</button> + <button type="button" data-testid="formatting-confirm" onClick={onConfirm}> + Confirm + </button> + <button type="button" data-testid="formatting-cancel" onClick={onCancel}> + Cancel + </button> </div> ), })) @@ -214,19 +268,28 @@ vi.mock('../debug-with-multiple-model', () => ({ onDebugWithMultipleModelChange, }: { checkCanSend: () => boolean - onDebugWithMultipleModelChange: (item: { id: string, model: string, provider: string, parameters: Record<string, unknown> }) => void + onDebugWithMultipleModelChange: (item: { + id: string + model: string + provider: string + parameters: Record<string, unknown> + }) => void }) => ( <div data-testid="debug-with-multiple-model"> - <button type="button" data-testid="multiple-check-can-send" onClick={() => checkCanSend()}>Check</button> + <button type="button" data-testid="multiple-check-can-send" onClick={() => checkCanSend()}> + Check + </button> <button type="button" data-testid="multiple-switch-to-single" - onClick={() => onDebugWithMultipleModelChange({ - id: 'model-1', - model: 'vision-model', - provider: 'openai', - parameters: { temperature: 0.2 }, - })} + onClick={() => + onDebugWithMultipleModelChange({ + id: 'model-1', + model: 'vision-model', + provider: 'openai', + parameters: { temperature: 0.2 }, + }) + } > Switch </button> @@ -248,7 +311,9 @@ vi.mock('../debug-with-single-model', () => { return ( <div data-testid="debug-with-single-model"> - <button type="button" data-testid="single-check-can-send" onClick={() => checkCanSend()}>Check</button> + <button type="button" data-testid="single-check-can-send" onClick={() => checkCanSend()}> + Check + </button> </div> ) } @@ -399,10 +464,12 @@ const createContextValue = (overrides: Partial<DebugContextValue> = {}): DebugCo ...overrides, }) -const renderDebug = (options: { - contextValue?: Partial<DebugContextValue> - props?: Partial<DebugProps> -} = {}) => { +const renderDebug = ( + options: { + contextValue?: Partial<DebugContextValue> + props?: Partial<DebugProps> + } = {}, +) => { const onSetting = vi.fn() const props: ComponentProps<typeof Debug> = { isAPIKeySet: true, @@ -419,13 +486,10 @@ const renderDebug = (options: { } render( - React.createElement( - ConfigContext.Provider, - { - value: createContextValue(options.contextValue), - children: <Debug {...props} />, - }, - ), + React.createElement(ConfigContext.Provider, { + value: createContextValue(options.contextValue), + children: <Debug {...props} />, + }), ) return { onSetting, notify: mockState.mockToastCall, props } @@ -454,14 +518,18 @@ describe('Debug', () => { file: { enabled: false, allowed_file_upload_methods: [], fileUploadConfig: undefined }, } mockState.mockProviderContext = { - textGenerationModelList: [{ - provider: 'openai', - models: [{ - model: 'vision-model', - features: [ModelFeatureEnum.vision], - model_properties: { mode: 'chat' }, - }], - }], + textGenerationModelList: [ + { + provider: 'openai', + models: [ + { + model: 'vision-model', + features: [ModelFeatureEnum.vision], + model_properties: { mode: 'chat' }, + }, + ], + }, + ], } }) @@ -525,12 +593,14 @@ describe('Debug', () => { ...createContextValue().modelConfig, configs: { prompt_template: '', - prompt_variables: [{ - key: 'question', - name: 'Question', - type: 'string', - required: true, - }] as DebugContextValue['modelConfig']['configs']['prompt_variables'], + prompt_variables: [ + { + key: 'question', + name: 'Question', + type: 'string', + required: true, + }, + ] as DebugContextValue['modelConfig']['configs']['prompt_variables'], }, }, }, @@ -650,12 +720,14 @@ describe('Debug', () => { ...createContextValue().modelConfig, configs: { prompt_template: '', - prompt_variables: [{ - key: 'question', - name: 'Question', - type: 'string', - required: true, - }] as DebugContextValue['modelConfig']['configs']['prompt_variables'], + prompt_variables: [ + { + key: 'question', + name: 'Question', + type: 'string', + required: true, + }, + ] as DebugContextValue['modelConfig']['configs']['prompt_variables'], }, }, }, @@ -728,16 +800,22 @@ describe('Debug', () => { }, } - mockState.mockSendCompletionMessage.mockImplementation((_appId, _data, handlers: { - onData: (chunk: string, isFirst: boolean, payload: { messageId: string }) => void - onMessageReplace: (payload: { answer: string }) => void - onCompleted: () => void - onError: () => void - }) => { - handlers.onData('hello', true, { messageId: 'msg-1' }) - handlers.onMessageReplace({ answer: 'final answer' }) - handlers.onCompleted() - }) + mockState.mockSendCompletionMessage.mockImplementation( + ( + _appId, + _data, + handlers: { + onData: (chunk: string, isFirst: boolean, payload: { messageId: string }) => void + onMessageReplace: (payload: { answer: string }) => void + onCompleted: () => void + onError: () => void + }, + ) => { + handlers.onData('hello', true, { messageId: 'msg-1' }) + handlers.onMessageReplace({ answer: 'final answer' }) + handlers.onCompleted() + }, + ) renderDebug({ contextValue: { @@ -748,13 +826,15 @@ describe('Debug', () => { ...createContextValue().modelConfig, configs: { prompt_template: 'Prompt', - prompt_variables: [{ - key: 'question', - name: 'Question', - type: 'string', - required: true, - is_context_var: true, - }] as DebugContextValue['modelConfig']['configs']['prompt_variables'], + prompt_variables: [ + { + key: 'question', + name: 'Question', + type: 'string', + required: true, + is_context_var: true, + }, + ] as DebugContextValue['modelConfig']['configs']['prompt_variables'], }, }, }, @@ -766,7 +846,10 @@ describe('Debug', () => { fireEvent.click(screen.getByTestId('panel-send')) await waitFor(() => expect(mockState.mockSendCompletionMessage).toHaveBeenCalledTimes(1)) - const [, requestData] = (mockState.mockSendCompletionMessage.mock.calls[0] ?? []) as [unknown, any] + const [, requestData] = (mockState.mockSendCompletionMessage.mock.calls[0] ?? []) as [ + unknown, + any, + ] expect(requestData).toMatchObject({ inputs: { question: 'hello' }, model_config: { @@ -817,15 +900,23 @@ describe('Debug', () => { }, } - mockState.mockSendCompletionMessage.mockImplementation((_appId, data, handlers: { - onError: () => void - }) => { - expect(data.files).toEqual([{ - transfer_method: TransferMethod.remote_url, - url: 'https://example.com/file.png', - }]) - handlers.onError() - }) + mockState.mockSendCompletionMessage.mockImplementation( + ( + _appId, + data, + handlers: { + onError: () => void + }, + ) => { + expect(data.files).toEqual([ + { + transfer_method: TransferMethod.remote_url, + url: 'https://example.com/file.png', + }, + ]) + handlers.onError() + }, + ) renderDebug({ contextValue: { @@ -893,7 +984,9 @@ describe('Debug', () => { renderDebug({ props: { debugWithMultipleModel: true, - multipleModelConfigs: [{ id: 'model-1', model: 'vision-model', provider: 'openai', parameters: {} }], + multipleModelConfigs: [ + { id: 'model-1', model: 'vision-model', provider: 'openai', parameters: {} }, + ], onMultipleModelConfigsChange, }, }) @@ -918,7 +1011,9 @@ describe('Debug', () => { }, }) - expect(screen.getByRole('button', { name: 'common.modelProvider.addModel(4/4)' }))!.toBeDisabled() + expect( + screen.getByRole('button', { name: 'common.modelProvider.addModel(4/4)' }), + )!.toBeDisabled() }) it('should disable add-model button when test/run permission is missing', () => { @@ -930,12 +1025,16 @@ describe('Debug', () => { }, props: { debugWithMultipleModel: true, - multipleModelConfigs: [{ id: 'model-1', model: 'vision-model', provider: 'openai', parameters: {} }], + multipleModelConfigs: [ + { id: 'model-1', model: 'vision-model', provider: 'openai', parameters: {} }, + ], onMultipleModelConfigsChange, }, }) - const addModelButton = screen.getByRole('button', { name: 'common.modelProvider.addModel(1/4)' }) + const addModelButton = screen.getByRole('button', { + name: 'common.modelProvider.addModel(1/4)', + }) expect(addModelButton).toBeDisabled() fireEvent.click(addModelButton) expect(onMultipleModelConfigsChange).not.toHaveBeenCalled() @@ -955,7 +1054,9 @@ describe('Debug', () => { }, props: { debugWithMultipleModel: true, - multipleModelConfigs: [{ id: '1', model: 'vision-model', provider: 'openai', parameters: {} }], + multipleModelConfigs: [ + { id: '1', model: 'vision-model', provider: 'openai', parameters: {} }, + ], }, }) @@ -975,7 +1076,9 @@ describe('Debug', () => { renderDebug({ props: { debugWithMultipleModel: true, - multipleModelConfigs: [{ id: '1', model: 'vision-model', provider: 'openai', parameters: {} }], + multipleModelConfigs: [ + { id: '1', model: 'vision-model', provider: 'openai', parameters: {} }, + ], }, }) @@ -993,7 +1096,14 @@ describe('Debug', () => { renderDebug({ props: { debugWithMultipleModel: true, - multipleModelConfigs: [{ id: 'model-1', model: 'vision-model', provider: 'openai', parameters: { temperature: 0.2 } }], + multipleModelConfigs: [ + { + id: 'model-1', + model: 'vision-model', + provider: 'openai', + parameters: { temperature: 0.2 }, + }, + ], onMultipleModelConfigsChange, modelParameterParams: { setModel, @@ -1021,15 +1131,19 @@ describe('Debug', () => { }, props: { debugWithMultipleModel: true, - multipleModelConfigs: [{ id: '1', model: 'vision-model', provider: 'openai', parameters: {} }], + multipleModelConfigs: [ + { id: '1', model: 'vision-model', provider: 'openai', parameters: {} }, + ], }, }) - expect(mockState.mockSetFeatures).toHaveBeenCalledWith(expect.objectContaining({ - file: expect.objectContaining({ - enabled: true, + expect(mockState.mockSetFeatures).toHaveBeenCalledWith( + expect.objectContaining({ + file: expect.objectContaining({ + enabled: true, + }), }), - })) + ) }) it('should render prompt and agent log modals in multiple-model mode', () => { @@ -1042,7 +1156,9 @@ describe('Debug', () => { renderDebug({ props: { debugWithMultipleModel: true, - multipleModelConfigs: [{ id: '1', model: 'vision-model', provider: 'openai', parameters: {} }], + multipleModelConfigs: [ + { id: '1', model: 'vision-model', provider: 'openai', parameters: {} }, + ], }, }) @@ -1068,7 +1184,9 @@ describe('Debug', () => { renderDebug({ props: { debugWithMultipleModel: true, - multipleModelConfigs: [{ id: '1', model: 'vision-model', provider: 'openai', parameters: {} }], + multipleModelConfigs: [ + { id: '1', model: 'vision-model', provider: 'openai', parameters: {} }, + ], }, }) diff --git a/web/app/components/app/configuration/debug/chat-user-input.tsx b/web/app/components/app/configuration/debug/chat-user-input.tsx index 9a2a8030ca15ee..fd2679870fa375 100644 --- a/web/app/components/app/configuration/debug/chat-user-input.tsx +++ b/web/app/components/app/configuration/debug/chat-user-input.tsx @@ -1,6 +1,13 @@ import type { Inputs } from '@/models/debug' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { Textarea } from '@langgenius/dify-ui/textarea' import * as React from 'react' import { useEffect } from 'react' @@ -14,9 +21,7 @@ type Props = Readonly<{ inputs: Inputs }> -const ChatUserInput = ({ - inputs, -}: Props) => { +const ChatUserInput = ({ inputs }: Props) => { const { t } = useTranslation() const { modelConfig, setInputs, canTestAndRun = false } = useContext(ConfigContext) const debugInputReadonly = !canTestAndRun @@ -41,53 +46,60 @@ const ChatUserInput = ({ promptVariables.forEach((variable) => { const { key, default: defaultValue } = variable // Only set default value if the field is empty and a default exists - if (defaultValue !== undefined && defaultValue !== null && defaultValue !== '' && (inputs[key] === undefined || inputs[key] === null || inputs[key] === '')) { + if ( + defaultValue !== undefined && + defaultValue !== null && + defaultValue !== '' && + (inputs[key] === undefined || inputs[key] === null || inputs[key] === '') + ) { newInputs[key] = defaultValue hasChanges = true } }) - if (hasChanges) - setInputs(newInputs) + if (hasChanges) setInputs(newInputs) }, [promptVariables, inputs, setInputs]) const handleInputValueChange = (key: string, value: string | boolean) => { - if (debugInputReadonly) - return - if (!(key in promptVariableObj)) - return + if (debugInputReadonly) return + if (!(key in promptVariableObj)) return const newInputs = { ...inputs } promptVariables.forEach((input) => { - if (input.key === key) - newInputs[key] = value + if (input.key === key) newInputs[key] = value }) setInputs(newInputs) } - if (!promptVariables.length) - return null + if (!promptVariables.length) return null return ( - <div className={cn('z-1 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-xs')}> + <div + className={cn( + 'z-1 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-xs', + )} + > <div className="px-4 pt-3 pb-4"> {promptVariables.map(({ key, name, type, options, max_length, required }, index) => ( - <div - key={key} - className="mb-4 last-of-type:mb-0" - > + <div key={key} className="mb-4 last-of-type:mb-0"> <div> {type !== 'checkbox' && ( <div className="mb-1 flex h-6 items-center gap-1 system-sm-semibold text-text-secondary"> <div className="truncate">{name || key}</div> - {!required && <span className="system-xs-regular text-text-tertiary">{t($ => $['panel.optional'], { ns: 'workflow' })}</span>} + {!required && ( + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['panel.optional'], { ns: 'workflow' })} + </span> + )} </div> )} <div className="grow"> {type === 'string' && ( <Input value={inputs[key] ? `${inputs[key]}` : ''} - onChange={(e) => { handleInputValueChange(key, e.target.value) }} + onChange={(e) => { + handleInputValueChange(key, e.target.value) + }} placeholder={name} autoFocus={index === 0} maxLength={max_length} @@ -100,25 +112,30 @@ const ChatUserInput = ({ aria-label={name || key} placeholder={name} value={inputs[key] ? `${inputs[key]}` : ''} - onValueChange={(value) => { handleInputValueChange(key, value) }} + onValueChange={(value) => { + handleInputValueChange(key, value) + }} readOnly={debugInputReadonly} /> )} {type === 'select' && ( <Select<string> - value={typeof inputs[key] === 'string' && inputs[key] !== '' ? inputs[key] : null} + value={ + typeof inputs[key] === 'string' && inputs[key] !== '' ? inputs[key] : null + } disabled={debugInputReadonly} onValueChange={(nextValue) => { - if (nextValue == null || nextValue === '') - return + if (nextValue == null || nextValue === '') return handleInputValueChange(key, nextValue) }} > <SelectTrigger className="w-full"> - {typeof inputs[key] === 'string' && inputs[key] !== '' ? inputs[key] : t($ => $['placeholder.select'], { ns: 'common' })} + {typeof inputs[key] === 'string' && inputs[key] !== '' + ? inputs[key] + : t(($) => $['placeholder.select'], { ns: 'common' })} </SelectTrigger> <SelectContent> - {(options || []).map(option => ( + {(options || []).map((option) => ( <SelectItem key={option} value={option}> <SelectItemText>{option}</SelectItemText> <SelectItemIndicator /> @@ -131,7 +148,9 @@ const ChatUserInput = ({ <Input type="number" value={inputs[key] ? `${inputs[key]}` : ''} - onChange={(e) => { handleInputValueChange(key, e.target.value) }} + onChange={(e) => { + handleInputValueChange(key, e.target.value) + }} placeholder={name} autoFocus={index === 0} maxLength={max_length} @@ -143,7 +162,9 @@ const ChatUserInput = ({ name={name || key} value={!!inputs[key]} required={required} - onChange={(value) => { handleInputValueChange(key, value) }} + onChange={(value) => { + handleInputValueChange(key, value) + }} readonly={debugInputReadonly} /> )} diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx index 9591343a29fd18..e3d61603e36fce 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx @@ -27,7 +27,9 @@ let capturedChatProps: { allToolIcons: Record<string, string | undefined> } | null = null -let eventSubscriptionCallback: ((v: { type: string, payload?: Record<string, unknown> }) => void) | null = null +let eventSubscriptionCallback: + | ((v: { type: string; payload?: Record<string, unknown> }) => void) + | null = null vi.mock('@/context/debug-configuration', () => ({ useDebugConfigurationContext: () => mockUseDebugConfigurationContext(), @@ -43,7 +45,8 @@ vi.mock('@/app/components/base/features/hooks', () => ({ vi.mock('../../hooks', () => ({ useConfigFromDebugContext: () => mockUseConfigFromDebugContext(), - useFormattingChangedSubscription: (chatList: ChatItemType[]) => mockUseFormattingChangedSubscription(chatList), + useFormattingChangedSubscription: (chatList: ChatItemType[]) => + mockUseFormattingChangedSubscription(chatList), })) vi.mock('@/app/components/base/chat/chat/hooks', () => ({ @@ -61,7 +64,7 @@ vi.mock('@/service/debug', () => ({ })) vi.mock('@/app/components/base/chat/utils', () => ({ - getLastAnswer: (chatList: ChatItemType[]) => chatList.find(item => item.isAnswer), + getLastAnswer: (chatList: ChatItemType[]) => chatList.find((item) => item.isAnswer), })) vi.mock('@/utils', () => ({ @@ -77,7 +80,19 @@ vi.mock('@/app/components/base/chat/chat', () => ({ <span data-testid="is-responding">{props?.isResponding ? 'yes' : 'no'}</span> <button data-testid="send-button" - onClick={() => props?.onSend?.('test message', [{ id: 'file-1', name: 'test.txt', size: 100, type: 'text/plain', progress: 100, transferMethod: TransferMethod.local_file, supportFileType: 'document' }])} + onClick={() => + props?.onSend?.('test message', [ + { + id: 'file-1', + name: 'test.txt', + size: 100, + type: 'text/plain', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + }, + ]) + } > Send </button> @@ -90,7 +105,9 @@ vi.mock('@langgenius/dify-ui/avatar', () => ({ Avatar: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>, })) -const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({ +const createModelAndParameter = ( + overrides: Partial<ModelAndParameter> = {}, +): ModelAndParameter => ({ id: 'model-1', model: 'gpt-3.5-turbo', provider: 'openai', @@ -160,7 +177,9 @@ const createDefaultMocks = () => { mockUseEventEmitterContextContext.mockReturnValue({ eventEmitter: { - useSubscription: (callback: (v: { type: string, payload?: Record<string, unknown> }) => void) => { + useSubscription: ( + callback: (v: { type: string; payload?: Record<string, unknown> }) => void, + ) => { eventSubscriptionCallback = callback }, }, @@ -244,22 +263,24 @@ describe('ChatItem', () => { }) it('should use empty opening statement when disabled', () => { - mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => { - const state = { - features: { - moreLikeThis: { enabled: false }, - opening: { enabled: false, opening_statement: 'Should not appear' }, - moderation: { enabled: false }, - speech2text: { enabled: false }, - text2speech: { enabled: false }, - file: { enabled: false }, - suggested: { enabled: false }, - citation: { enabled: false }, - annotationReply: { enabled: false }, - }, - } - return selector(state) - }) + mockUseFeatures.mockImplementation( + (selector: (state: Record<string, unknown>) => unknown) => { + const state = { + features: { + moreLikeThis: { enabled: false }, + opening: { enabled: false, opening_statement: 'Should not appear' }, + moderation: { enabled: false }, + speech2text: { enabled: false }, + text2speech: { enabled: false }, + file: { enabled: false }, + suggested: { enabled: false }, + citation: { enabled: false }, + annotationReply: { enabled: false }, + }, + } + return selector(state) + }, + ) renderComponent() @@ -536,9 +557,7 @@ describe('ChatItem', () => { modelConfig: { configs: { prompt_variables: [] }, agentConfig: { - tools: [ - { tool_name: 'tool1', provider_id: 'nonexistent' }, - ], + tools: [{ tool_name: 'tool1', provider_id: 'nonexistent' }], }, }, appId: 'app-123', diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context-provider.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context-provider.spec.tsx index 6ed016ecc75c5f..1a1acc77c0383c 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context-provider.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context-provider.spec.tsx @@ -7,8 +7,12 @@ const ContextConsumer = () => { return ( <div> <div>{value.multipleModelConfigs.length}</div> - <button onClick={() => value.onMultipleModelConfigsChange(true, value.multipleModelConfigs)}>change-multiple</button> - <button onClick={() => value.onDebugWithMultipleModelChange(value.multipleModelConfigs[0]!)}>change-single</button> + <button onClick={() => value.onMultipleModelConfigsChange(true, value.multipleModelConfigs)}> + change-multiple + </button> + <button onClick={() => value.onDebugWithMultipleModelChange(value.multipleModelConfigs[0]!)}> + change-single + </button> <div>{String(value.checkCanSend?.())}</div> </div> ) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context.spec.tsx index e4b5fde98f0860..dab06595d1012a 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context.spec.tsx @@ -4,7 +4,9 @@ import { render, screen } from '@testing-library/react' import { useDebugWithMultipleModelContext } from '../context' import { DebugWithMultipleModelContextProvider } from '../context-provider' -const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({ +const createModelAndParameter = ( + overrides: Partial<ModelAndParameter> = {}, +): ModelAndParameter => ({ id: 'model-1', model: 'gpt-3.5-turbo', provider: 'openai', @@ -176,7 +178,10 @@ describe('DebugWithMultipleModelContext', () => { rerender( <DebugWithMultipleModelContextProvider - multipleModelConfigs={[createModelAndParameter(), createModelAndParameter({ id: 'model-2' })]} + multipleModelConfigs={[ + createModelAndParameter(), + createModelAndParameter({ id: 'model-2' }), + ]} onMultipleModelConfigsChange={vi.fn()} onDebugWithMultipleModelChange={vi.fn()} > @@ -201,7 +206,9 @@ describe('DebugWithMultipleModelContext', () => { <div> <span data-testid="configs">{JSON.stringify(context.multipleModelConfigs)}</span> <span data-testid="has-on-change">{typeof context.onMultipleModelConfigsChange}</span> - <span data-testid="has-on-debug-change">{typeof context.onDebugWithMultipleModelChange}</span> + <span data-testid="has-on-debug-change"> + {typeof context.onDebugWithMultipleModelChange} + </span> <span data-testid="has-check">{typeof context.checkCanSend}</span> </div> ) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/debug-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/debug-item.spec.tsx index 1d54ae17bbd11b..dfd85347859c97 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/debug-item.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/debug-item.spec.tsx @@ -28,13 +28,17 @@ vi.mock('@/context/provider-context', () => ({ vi.mock('../chat-item', () => ({ default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => ( - <div data-testid="chat-item" data-model-id={modelAndParameter.id}>ChatItem</div> + <div data-testid="chat-item" data-model-id={modelAndParameter.id}> + ChatItem + </div> ), })) vi.mock('../text-generation-item', () => ({ default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => ( - <div data-testid="text-generation-item" data-model-id={modelAndParameter.id}>TextGenerationItem</div> + <div data-testid="text-generation-item" data-model-id={modelAndParameter.id}> + TextGenerationItem + </div> ), })) @@ -45,7 +49,9 @@ vi.mock('../model-parameter-trigger', () => ({ }, })) -const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({ +const createModelAndParameter = ( + overrides: Partial<ModelAndParameter> = {}, +): ModelAndParameter => ({ id: 'model-1', model: 'gpt-3.5-turbo', provider: 'openai', @@ -53,8 +59,13 @@ const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): Mo ...overrides, }) -const createTextGenerationModelList = (models: Array<{ provider: string, model: string, status?: ModelStatusEnum }> = []) => { - const providers: Record<string, { provider: string, models: Array<{ model: string, status: ModelStatusEnum }> }> = {} +const createTextGenerationModelList = ( + models: Array<{ provider: string; model: string; status?: ModelStatusEnum }> = [], +) => { + const providers: Record< + string, + { provider: string; models: Array<{ model: string; status: ModelStatusEnum }> } + > = {} models.forEach(({ provider, model, status = ModelStatusEnum.active }) => { if (!providers[provider]) { @@ -127,7 +138,9 @@ describe('DebugItem', () => { onDebugWithMultipleModelChange: vi.fn(), }) - const { container } = renderComponent({ modelAndParameter: createModelAndParameter({ id: 'model-2' }) }) + const { container } = renderComponent({ + modelAndParameter: createModelAndParameter({ id: 'model-2' }), + }) // The index is displayed as "#2" in the component const indexElement = container.querySelector('.font-medium.italic') @@ -440,10 +453,7 @@ describe('DebugItem', () => { const user = await openMenu() await user.click(screen.getByText('common.operation.remove')) - expect(onMultipleModelConfigsChange).toHaveBeenCalledWith( - true, - [models[0], models[2]], - ) + expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(true, [models[0], models[2]]) }) it('should insert duplicated model at correct position', async () => { diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/index.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/index.spec.tsx index 6d678eebf13e06..996840abe8dbea 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/index.spec.tsx @@ -9,7 +9,11 @@ import type { Inputs, ModelConfig } from '@/models/debug' import type { PromptVariable } from '@/types/app' import { fireEvent, render, screen } from '@testing-library/react' import { useStore as useAppStore } from '@/app/components/app/store' -import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' +import { + DEFAULT_AGENT_SETTING, + DEFAULT_CHAT_PROMPT_CONFIG, + DEFAULT_COMPLETION_PROMPT_CONFIG, +} from '@/config' import { AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app' import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../../types' import DebugWithMultipleModel from '../index' @@ -59,7 +63,8 @@ vi.mock('@/context/debug-configuration', () => ({ })) vi.mock('@/app/components/base/features/hooks', () => ({ - useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeaturesSelector(selector), + useFeatures: (selector: (state: FeatureStoreState) => unknown) => + mockUseFeaturesSelector(selector), })) vi.mock('@/context/event-emitter', () => ({ @@ -93,8 +98,22 @@ vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({ capturedChatInputProps = props return ( <div data-testid="chat-input-area"> - <button type="button" disabled={props.disabled} onClick={() => props.onSend?.('test message', mockFiles)}>send</button> - {props.showFeatureBar && <button type="button" disabled={props.featureBarReadonly} onClick={() => props.onFeatureBarClick?.(true)}>feature</button>} + <button + type="button" + disabled={props.disabled} + onClick={() => props.onSend?.('test message', mockFiles)} + > + send + </button> + {props.showFeatureBar && ( + <button + type="button" + disabled={props.featureBarReadonly} + onClick={() => props.onFeatureBarClick?.(true)} + > + feature + </button> + )} </div> ) }, @@ -157,7 +176,9 @@ type DebugConfiguration = { canTestAndRun: boolean } -const createDebugConfiguration = (overrides: Partial<DebugConfiguration> = {}): DebugConfiguration => ({ +const createDebugConfiguration = ( + overrides: Partial<DebugConfiguration> = {}, +): DebugConfiguration => ({ mode: AppModeEnum.CHAT, inputs: {}, modelConfig: createModelConfig(), @@ -166,7 +187,9 @@ const createDebugConfiguration = (overrides: Partial<DebugConfiguration> = {}): ...overrides, }) -const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({ +const createModelAndParameter = ( + overrides: Partial<ModelAndParameter> = {}, +): ModelAndParameter => ({ id: `model-${++modelIdCounter}`, model: 'gpt-3.5-turbo', provider: 'openai', @@ -174,7 +197,9 @@ const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): Mo ...overrides, }) -const createProps = (overrides: Partial<DebugWithMultipleModelContextType> = {}): DebugWithMultipleModelContextType => ({ +const createProps = ( + overrides: Partial<DebugWithMultipleModelContextType> = {}, +): DebugWithMultipleModelContextType => ({ multipleModelConfigs: [createModelAndParameter()], onMultipleModelConfigsChange: vi.fn(), onDebugWithMultipleModelChange: vi.fn(), @@ -192,7 +217,7 @@ describe('DebugWithMultipleModel', () => { capturedChatInputProps = null modelIdCounter = 0 featureState = createFeatureState() - mockUseFeaturesSelector.mockImplementation(selector => selector(featureState)) + mockUseFeaturesSelector.mockImplementation((selector) => selector(featureState)) mockUseEventEmitterContext.mockReturnValue({ eventEmitter: mockEventEmitter }) mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration()) }) @@ -229,11 +254,15 @@ describe('DebugWithMultipleModel', () => { const modelConfig = createModelConfig() modelConfig.configs.prompt_variables = undefined as any - mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({ - modelConfig, - })) + mockUseDebugConfigurationContext.mockReturnValue( + createDebugConfiguration({ + modelConfig, + }), + ) - expect(() => renderComponent()).toThrow('Cannot read properties of undefined (reading \'filter\')') + expect(() => renderComponent()).toThrow( + "Cannot read properties of undefined (reading 'filter')", + ) }) it('should handle modelConfig with null prompt_variables', () => { @@ -242,11 +271,13 @@ describe('DebugWithMultipleModel', () => { const modelConfig = createModelConfig() modelConfig.configs.prompt_variables = null as any - mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({ - modelConfig, - })) + mockUseDebugConfigurationContext.mockReturnValue( + createDebugConfiguration({ + modelConfig, + }), + ) - expect(() => renderComponent()).toThrow('Cannot read properties of null (reading \'filter\')') + expect(() => renderComponent()).toThrow("Cannot read properties of null (reading 'filter')") }) it('should handle prompt_variables with missing required fields', () => { @@ -288,10 +319,14 @@ describe('DebugWithMultipleModel', () => { }) it('should not memoize when props change', () => { - const props1 = createProps({ multipleModelConfigs: [createModelAndParameter({ id: 'model-1' })] }) + const props1 = createProps({ + multipleModelConfigs: [createModelAndParameter({ id: 'model-1' })], + }) const { rerender } = renderComponent(props1) - const props2 = createProps({ multipleModelConfigs: [createModelAndParameter({ id: 'model-2' })] }) + const props2 = createProps({ + multipleModelConfigs: [createModelAndParameter({ id: 'model-2' })], + }) rerender(<DebugWithMultipleModel {...props2} />) const items = screen.getAllByTestId('debug-item') @@ -347,9 +382,7 @@ describe('DebugWithMultipleModel', () => { ]), ) expect(capturedChatInputProps?.inputsForm).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ label: 'API Var' }), - ]), + expect.arrayContaining([expect.objectContaining({ label: 'API Var' })]), ) }) @@ -383,8 +416,20 @@ describe('DebugWithMultipleModel', () => { it('should preserve original hide and required values', () => { const promptVariables: PromptVariableWithMeta[] = [ - { key: 'hidden-optional', name: 'Hidden Optional', type: 'string', hide: true, required: false }, - { key: 'visible-required', name: 'Visible Required', type: 'number', hide: false, required: true }, + { + key: 'hidden-optional', + name: 'Hidden Optional', + type: 'string', + hide: true, + required: false, + }, + { + key: 'visible-required', + name: 'Visible Required', + type: 'number', + hide: false, + required: true, + }, ] const debugConfiguration = createDebugConfiguration({ modelConfig: createModelConfig(promptVariables), @@ -435,8 +480,18 @@ describe('DebugWithMultipleModel', () => { expect(capturedChatInputProps?.inputs).toEqual({ audience: 'engineers' }) expect(capturedChatInputProps?.inputsForm).toEqual([ expect.objectContaining({ label: 'City', variable: 'city', hide: false, required: true }), - expect.objectContaining({ label: 'Audience', variable: 'audience', hide: false, required: false }), - expect.objectContaining({ label: 'Hidden', variable: 'hidden', hide: true, required: false }), + expect.objectContaining({ + label: 'Audience', + variable: 'audience', + hide: false, + required: false, + }), + expect.objectContaining({ + label: 'Hidden', + variable: 'hidden', + hide: true, + required: false, + }), ]) expect(capturedChatInputProps?.showFeatureBar).toBe(true) expect(capturedChatInputProps?.showFileUpload).toBe(false) @@ -446,10 +501,12 @@ describe('DebugWithMultipleModel', () => { }) it('should allow sending but disable feature configuration when configuration is readonly and test/run is allowed', () => { - mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({ - readonly: true, - canTestAndRun: true, - })) + mockUseDebugConfigurationContext.mockReturnValue( + createDebugConfiguration({ + readonly: true, + canTestAndRun: true, + }), + ) renderComponent() fireEvent.click(screen.getByRole('button', { name: /send/i })) @@ -459,15 +516,19 @@ describe('DebugWithMultipleModel', () => { expect(capturedChatInputProps?.showFeatureBar).toBe(true) expect(capturedChatInputProps?.featureBarReadonly).toBe(true) expect(screen.getByRole('button', { name: /feature/i })).toBeDisabled() - expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({ - type: APP_CHAT_WITH_MULTIPLE_MODEL, - })) + expect(mockEventEmitter.emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: APP_CHAT_WITH_MULTIPLE_MODEL, + }), + ) }) it('should block sending when test/run permission is missing', () => { - mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({ - canTestAndRun: false, - })) + mockUseDebugConfigurationContext.mockReturnValue( + createDebugConfiguration({ + canTestAndRun: false, + }), + ) renderComponent() fireEvent.click(screen.getByRole('button', { name: /send/i })) @@ -479,9 +540,11 @@ describe('DebugWithMultipleModel', () => { it('should render chat input in agent chat mode', () => { // Arrange - mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({ - mode: AppModeEnum.AGENT_CHAT, - })) + mockUseDebugConfigurationContext.mockReturnValue( + createDebugConfiguration({ + mode: AppModeEnum.AGENT_CHAT, + }), + ) // Act renderComponent() @@ -493,9 +556,11 @@ describe('DebugWithMultipleModel', () => { it('should hide chat input when not in chat mode', () => { // Arrange - mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({ - mode: AppModeEnum.COMPLETION, - })) + mockUseDebugConfigurationContext.mockReturnValue( + createDebugConfiguration({ + mode: AppModeEnum.COMPLETION, + }), + ) const multipleModelConfigs = [createModelAndParameter()] // Act @@ -624,9 +689,10 @@ describe('DebugWithMultipleModel', () => { // Change to 2 models rerender( - <DebugWithMultipleModel {...createProps({ - multipleModelConfigs: [createModelAndParameter(), createModelAndParameter()], - })} + <DebugWithMultipleModel + {...createProps({ + multipleModelConfigs: [createModelAndParameter(), createModelAndParameter()], + })} />, ) @@ -646,18 +712,14 @@ describe('DebugWithMultipleModel', () => { classes?: string[] }, ) => { - if (expectation.width !== undefined) - expect(element.style.width).toBe(expectation.width) - else - expect(element.style.width).toBe('') + if (expectation.width !== undefined) expect(element.style.width).toBe(expectation.width) + else expect(element.style.width).toBe('') - if (expectation.height !== undefined) - expect(element.style.height).toBe(expectation.height) - else - expect(element.style.height).toBe('') + if (expectation.height !== undefined) expect(element.style.height).toBe(expectation.height) + else expect(element.style.height).toBe('') expect(element.style.transform).toBe(expectation.transform) - expectation.classes?.forEach(cls => expect(element)!.toHaveClass(cls)) + expectation.classes?.forEach((cls) => expect(element)!.toHaveClass(cls)) } it('should arrange items in two-column layout for two models', () => { @@ -686,7 +748,11 @@ describe('DebugWithMultipleModel', () => { it('should arrange items in thirds for three models', () => { // Arrange - const multipleModelConfigs = [createModelAndParameter(), createModelAndParameter(), createModelAndParameter()] + const multipleModelConfigs = [ + createModelAndParameter(), + createModelAndParameter(), + createModelAndParameter(), + ] // Act renderComponent({ multipleModelConfigs }) @@ -772,18 +838,24 @@ describe('DebugWithMultipleModel', () => { it('should set scroll area height for chat modes', () => { const { container } = renderComponent() - const scrollArea = container.querySelector('.relative.mb-3.grow.overflow-auto.px-6') as HTMLElement + const scrollArea = container.querySelector( + '.relative.mb-3.grow.overflow-auto.px-6', + ) as HTMLElement expect(scrollArea)!.toBeInTheDocument() expect(scrollArea.style.height).toBe('calc(100% - 60px)') }) it('should set full height when chat input is hidden', () => { - mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({ - mode: AppModeEnum.COMPLETION, - })) + mockUseDebugConfigurationContext.mockReturnValue( + createDebugConfiguration({ + mode: AppModeEnum.COMPLETION, + }), + ) const { container } = renderComponent() - const scrollArea = container.querySelector('.relative.mb-3.grow.overflow-auto.px-6') as HTMLElement + const scrollArea = container.querySelector( + '.relative.mb-3.grow.overflow-auto.px-6', + ) as HTMLElement expect(scrollArea.style.height).toBe('100%') }) }) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/model-parameter-trigger.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/model-parameter-trigger.spec.tsx index b99a2034b65d83..c3da040530f621 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/model-parameter-trigger.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/model-parameter-trigger.spec.tsx @@ -25,7 +25,7 @@ const mockUseCredentialPanelState = vi.fn() type RenderTriggerProps = { open: boolean currentProvider: { provider: string } | null - currentModel: { model: string, status: ModelStatusEnum } | null + currentModel: { model: string; status: ModelStatusEnum } | null } let capturedModalProps: { @@ -34,7 +34,7 @@ let capturedModalProps: { modelId: string completionParams: FormValue onCompletionParamsChange: (params: FormValue) => void - setModel: (model: { modelId: string, provider: string }) => void + setModel: (model: { modelId: string; provider: string }) => void debugWithMultipleModel: boolean onDebugWithMultipleModelChange: () => void renderTrigger: (props: RenderTriggerProps) => ReactNode @@ -52,29 +52,31 @@ vi.mock('@/context/provider-context', () => ({ useProviderContext: () => mockUseProviderContext(), })) -vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({ - useCredentialPanelState: () => mockUseCredentialPanelState(), -})) - -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - default: (props: typeof capturedModalProps) => { - capturedModalProps = props - // Render the trigger that the component passes - const triggerContent = props?.renderTrigger({ - open: false, - currentProvider: null, - currentModel: null, - }) - return ( - <div data-testid="model-parameter-modal"> - {triggerContent} - </div> - ) - }, -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', + () => ({ + useCredentialPanelState: () => mockUseCredentialPanelState(), + }), +) + +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + default: (props: typeof capturedModalProps) => { + capturedModalProps = props + // Render the trigger that the component passes + const triggerContent = props?.renderTrigger({ + open: false, + currentProvider: null, + currentModel: null, + }) + return <div data-testid="model-parameter-modal">{triggerContent}</div> + }, + }), +) vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({ - default: ({ provider, modelName }: { provider: { provider: string }, modelName?: string }) => ( + default: ({ provider, modelName }: { provider: { provider: string }; modelName?: string }) => ( <div data-testid="model-icon" data-provider={provider?.provider} data-model={modelName}> ModelIcon </div> @@ -87,7 +89,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-name' ), })) -const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({ +const createModelAndParameter = ( + overrides: Partial<ModelAndParameter> = {}, +): ModelAndParameter => ({ id: 'model-1', model: 'gpt-3.5-turbo', provider: 'openai', @@ -152,9 +156,11 @@ describe('ModelParameterTrigger', () => { onMultipleModelConfigsChange: vi.fn(), onDebugWithMultipleModelChange: vi.fn(), }) - mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ - modelProviders: [createModelProvider()], - })) + mockUseProviderContext.mockReturnValue( + createMockProviderContextValue({ + modelProviders: [createModelProvider()], + }), + ) mockUseCredentialPanelState.mockReturnValue({ variant: 'api-active', priority: 'apiKey', @@ -385,7 +391,9 @@ describe('ModelParameterTrigger', () => { expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument() await userEvent.hover(screen.getByLabelText('common.modelProvider.selector.incompatibleTip')) - expect(await screen.findByText('common.modelProvider.selector.incompatibleTip')).toBeInTheDocument() + expect( + await screen.findByText('common.modelProvider.selector.incompatibleTip'), + ).toBeInTheDocument() }) it('should render configure required tooltip for no-configure status', async () => { @@ -399,8 +407,12 @@ describe('ModelParameterTrigger', () => { unmount() render(<>{triggerContent}</>) - await userEvent.hover(screen.getByLabelText('common.modelProvider.selector.configureRequired')) - expect(await screen.findByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument() + await userEvent.hover( + screen.getByLabelText('common.modelProvider.selector.configureRequired'), + ) + expect( + await screen.findByText('common.modelProvider.selector.configureRequired'), + ).toBeInTheDocument() }) it('should render disabled tooltip for disabled status', async () => { @@ -484,21 +496,20 @@ describe('ModelParameterTrigger', () => { it('should render trigger with provider info when available', () => { // Mock the modal to render trigger with provider - vi.doMock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - default: (props: typeof capturedModalProps) => { - capturedModalProps = props - const triggerContent = props?.renderTrigger({ - open: false, - currentProvider: { provider: 'openai' }, - currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.active }, - }) - return ( - <div data-testid="model-parameter-modal"> - {triggerContent} - </div> - ) - }, - })) + vi.doMock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + default: (props: typeof capturedModalProps) => { + capturedModalProps = props + const triggerContent = props?.renderTrigger({ + open: false, + currentProvider: { provider: 'openai' }, + currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.active }, + }) + return <div data-testid="model-parameter-modal">{triggerContent}</div> + }, + }), + ) renderComponent() diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/text-generation-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/text-generation-item.spec.tsx index 0ece35ff06b245..0fc7832ec0e8c3 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/text-generation-item.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/text-generation-item.spec.tsx @@ -19,7 +19,9 @@ let capturedTextGenerationProps: { className?: string } | null = null -let eventSubscriptionCallback: ((v: { type: string, payload?: Record<string, unknown> }) => void) | null = null +let eventSubscriptionCallback: + | ((v: { type: string; payload?: Record<string, unknown> }) => void) + | null = null vi.mock('@/context/debug-configuration', () => ({ useDebugConfigurationContext: () => mockUseDebugConfigurationContext(), @@ -42,7 +44,8 @@ vi.mock('@/context/event-emitter', () => ({ })) vi.mock('@/utils/model-config', () => ({ - promptVariablesToUserInputsForm: (...args: unknown[]) => mockPromptVariablesToUserInputsForm(...args), + promptVariablesToUserInputsForm: (...args: unknown[]) => + mockPromptVariablesToUserInputsForm(...args), })) vi.mock('@/app/components/app/text-generate/item', () => ({ @@ -59,7 +62,9 @@ vi.mock('@/app/components/app/text-generate/item', () => ({ }, })) -const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({ +const createModelAndParameter = ( + overrides: Partial<ModelAndParameter> = {}, +): ModelAndParameter => ({ id: 'model-1', model: 'gpt-3.5-turbo', provider: 'openai', @@ -73,9 +78,7 @@ const createDefaultMocks = () => { modelConfig: { configs: { prompt_template: 'Hello {{name}}', - prompt_variables: [ - { key: 'name', name: 'Name', type: 'string', is_context_var: false }, - ], + prompt_variables: [{ key: 'name', name: 'Name', type: 'string', is_context_var: false }], }, system_parameters: {}, }, @@ -128,7 +131,9 @@ const createDefaultMocks = () => { mockUseEventEmitterContextContext.mockReturnValue({ eventEmitter: { - useSubscription: (callback: (v: { type: string, payload?: Record<string, unknown> }) => void) => { + useSubscription: ( + callback: (v: { type: string; payload?: Record<string, unknown> }) => void, + ) => { eventSubscriptionCallback = callback }, }, @@ -457,8 +462,16 @@ describe('TextGenerationItem', () => { expect.any(String), expect.objectContaining({ files: [ - expect.objectContaining({ id: 'f1', transfer_method: TransferMethod.local_file, url: '' }), - expect.objectContaining({ id: 'f2', transfer_method: TransferMethod.remote_url, url: 'https://example.com/file' }), + expect.objectContaining({ + id: 'f1', + transfer_method: TransferMethod.local_file, + url: '', + }), + expect.objectContaining({ + id: 'f2', + transfer_method: TransferMethod.remote_url, + url: 'https://example.com/file', + }), ], }), ) @@ -473,17 +486,19 @@ describe('TextGenerationItem', () => { messageId: null, }) - mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => { - const state = { - features: { - moreLikeThis: { enabled: false }, - moderation: { enabled: false }, - text2speech: { enabled: false }, - file: { enabled: false }, - }, - } - return selector(state) - }) + mockUseFeatures.mockImplementation( + (selector: (state: Record<string, unknown>) => unknown) => { + const state = { + features: { + moreLikeThis: { enabled: false }, + moderation: { enabled: false }, + text2speech: { enabled: false }, + file: { enabled: false }, + }, + } + return selector(state) + }, + ) renderComponent() diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx index 286264fcba8fbf..61a4ecc7fba438 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx @@ -4,11 +4,7 @@ import type { InputForm } from '@/app/components/base/chat/chat/type' import type { ChatConfig, OnSend } from '@/app/components/base/chat/types' import { Avatar } from '@langgenius/dify-ui/avatar' import { useAtomValue } from 'jotai' -import { - memo, - useCallback, - useMemo, -} from 'react' +import { memo, useCallback, useMemo } from 'react' import Chat from '@/app/components/base/chat/chat' import { useChat } from '@/app/components/base/chat/chat/hooks' import { getLastAnswer } from '@/app/components/base/chat/utils' @@ -24,21 +20,13 @@ import { stopChatMessageResponding, } from '@/service/debug' import { canFindTool } from '@/utils' -import { - useConfigFromDebugContext, - useFormattingChangedSubscription, -} from '../hooks' -import { - APP_CHAT_WITH_MULTIPLE_MODEL, - APP_CHAT_WITH_MULTIPLE_MODEL_RESTART, -} from '../types' +import { useConfigFromDebugContext, useFormattingChangedSubscription } from '../hooks' +import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from '../types' type ChatItemProps = { modelAndParameter: ModelAndParameter } -const ChatItem: FC<ChatItemProps> = ({ - modelAndParameter, -}) => { +const ChatItem: FC<ChatItemProps> = ({ modelAndParameter }) => { const userProfile = useAtomValue(userProfileAtom) const { modelConfig, @@ -48,14 +36,16 @@ const ChatItem: FC<ChatItemProps> = ({ canTestAndRun = false, } = useDebugConfigurationContext() const { textGenerationModelList } = useProviderContext() - const features = useFeatures(s => s.features) + const features = useFeatures((s) => s.features) const configTemplate = useConfigFromDebugContext() const config = useMemo(() => { return { ...configTemplate, more_like_this: features.moreLikeThis, - opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '', - suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [], + opening_statement: features.opening?.enabled ? features.opening?.opening_statement || '' : '', + suggested_questions: features.opening?.enabled + ? features.opening?.suggested_questions || [] + : [], sensitive_word_avoidance: features.moderation, speech_to_text: features.speech2text, text_to_speech: features.text2speech, @@ -66,80 +56,89 @@ const ChatItem: FC<ChatItemProps> = ({ } as ChatConfig }, [configTemplate, features]) const inputsForm = useMemo(() => { - return modelConfig.configs.prompt_variables.filter(item => item.type !== 'api').map(item => ({ ...item, label: item.name, variable: item.key })) as InputForm[] + return modelConfig.configs.prompt_variables + .filter((item) => item.type !== 'api') + .map((item) => ({ ...item, label: item.name, variable: item.key })) as InputForm[] }, [modelConfig.configs.prompt_variables]) - const { - chatList, - isResponding, - handleSend, - suggestedQuestions, - handleRestart, - } = useChat( + const { chatList, isResponding, handleSend, suggestedQuestions, handleRestart } = useChat( config, { inputs, inputsForm, }, [], - taskId => stopChatMessageResponding(appId, taskId), + (taskId) => stopChatMessageResponding(appId, taskId), ) useFormattingChangedSubscription(chatList) - const doSend: OnSend = useCallback((message, files) => { - if (!canTestAndRun) - return - const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider) - const currentModel = currentProvider?.models.find(model => model.model === modelAndParameter.model) - const supportVision = currentModel?.features?.includes(ModelFeatureEnum.vision) + const doSend: OnSend = useCallback( + (message, files) => { + if (!canTestAndRun) return + const currentProvider = textGenerationModelList.find( + (item) => item.provider === modelAndParameter.provider, + ) + const currentModel = currentProvider?.models.find( + (model) => model.model === modelAndParameter.model, + ) + const supportVision = currentModel?.features?.includes(ModelFeatureEnum.vision) - const configData = { - ...config, - model: { - provider: modelAndParameter.provider, - name: modelAndParameter.model, - mode: currentModel?.model_properties.mode, - completion_params: modelAndParameter.parameters, - }, - } + const configData = { + ...config, + model: { + provider: modelAndParameter.provider, + name: modelAndParameter.model, + mode: currentModel?.model_properties.mode, + completion_params: modelAndParameter.parameters, + }, + } - const data: any = { - query: message, - inputs, - model_config: configData, - parent_message_id: getLastAnswer(chatList)?.id || null, - } + const data: any = { + query: message, + inputs, + model_config: configData, + parent_message_id: getLastAnswer(chatList)?.id || null, + } - if ((config.file_upload as any).enabled && files?.length && supportVision) - data.files = files + if ((config.file_upload as any).enabled && files?.length && supportVision) data.files = files - handleSend( - `apps/${appId}/chat-messages`, - data, - { - onGetConversationMessages: (conversationId, getAbortController) => fetchConversationMessages(appId, conversationId, getAbortController), - onGetSuggestedQuestions: (responseItemId, getAbortController) => fetchSuggestedQuestions(appId, responseItemId, getAbortController), - }, - ) - }, [appId, canTestAndRun, chatList, config, handleSend, inputs, modelAndParameter.model, modelAndParameter.parameters, modelAndParameter.provider, textGenerationModelList]) + handleSend(`apps/${appId}/chat-messages`, data, { + onGetConversationMessages: (conversationId, getAbortController) => + fetchConversationMessages(appId, conversationId, getAbortController), + onGetSuggestedQuestions: (responseItemId, getAbortController) => + fetchSuggestedQuestions(appId, responseItemId, getAbortController), + }) + }, + [ + appId, + canTestAndRun, + chatList, + config, + handleSend, + inputs, + modelAndParameter.model, + modelAndParameter.parameters, + modelAndParameter.provider, + textGenerationModelList, + ], + ) const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((v: any) => { - if (v.type === APP_CHAT_WITH_MULTIPLE_MODEL) - doSend(v.payload.message, v.payload.files) - if (v.type === APP_CHAT_WITH_MULTIPLE_MODEL_RESTART) - handleRestart() + if (v.type === APP_CHAT_WITH_MULTIPLE_MODEL) doSend(v.payload.message, v.payload.files) + if (v.type === APP_CHAT_WITH_MULTIPLE_MODEL_RESTART) handleRestart() }) const allToolIcons = useMemo(() => { const icons: Record<string, any> = {} modelConfig.agentConfig.tools?.forEach((item: any) => { - icons[item.tool_name] = collectionList.find((collection: any) => canFindTool(collection.id, item.provider_id))?.icon + icons[item.tool_name] = collectionList.find((collection: any) => + canFindTool(collection.id, item.provider_id), + )?.icon }) return icons }, [collectionList, modelConfig.agentConfig.tools]) - if (!chatList.length) - return null + if (!chatList.length) return null return ( <Chat diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/context-provider.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/context-provider.tsx index 74aed2d1e291a5..a734370e60b42f 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/context-provider.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/context-provider.tsx @@ -15,12 +15,13 @@ export const DebugWithMultipleModelContextProvider = ({ checkCanSend, }: DebugWithMultipleModelContextProviderProps) => { return ( - <DebugWithMultipleModelContext.Provider value={{ - onMultipleModelConfigsChange, - multipleModelConfigs, - onDebugWithMultipleModelChange, - checkCanSend, - }} + <DebugWithMultipleModelContext.Provider + value={{ + onMultipleModelConfigsChange, + multipleModelConfigs, + onDebugWithMultipleModelChange, + checkCanSend, + }} > {children} </DebugWithMultipleModelContext.Provider> diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx index cb3193e7d5077b..ac07675c3a876f 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx @@ -24,39 +24,32 @@ type DebugItemProps = { className?: string style?: CSSProperties } -const DebugItem: FC<DebugItemProps> = ({ - modelAndParameter, - className, - style, -}) => { +const DebugItem: FC<DebugItemProps> = ({ modelAndParameter, className, style }) => { const { t } = useTranslation() const { mode } = useDebugConfigurationContext() - const { - multipleModelConfigs, - onMultipleModelConfigsChange, - onDebugWithMultipleModelChange, - } = useDebugWithMultipleModelContext() + const { multipleModelConfigs, onMultipleModelConfigsChange, onDebugWithMultipleModelChange } = + useDebugWithMultipleModelContext() const { textGenerationModelList } = useProviderContext() - const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id) - const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider) - const currentModel = currentProvider?.models.find(item => item.model === modelAndParameter.model) + const index = multipleModelConfigs.findIndex((v) => v.id === modelAndParameter.id) + const currentProvider = textGenerationModelList.find( + (item) => item.provider === modelAndParameter.provider, + ) + const currentModel = currentProvider?.models.find( + (item) => item.model === modelAndParameter.model, + ) const handleDuplicate = () => { - if (multipleModelConfigs.length >= 4) - return + if (multipleModelConfigs.length >= 4) return - onMultipleModelConfigsChange( - true, - [ - ...multipleModelConfigs.slice(0, index + 1), - { - ...modelAndParameter, - id: `${Date.now()}`, - }, - ...multipleModelConfigs.slice(index + 1), - ], - ) + onMultipleModelConfigsChange(true, [ + ...multipleModelConfigs.slice(0, index + 1), + { + ...modelAndParameter, + id: `${Date.now()}`, + }, + ...multipleModelConfigs.slice(index + 1), + ]) } const handleDebugAsSingleModel = () => { @@ -66,7 +59,7 @@ const DebugItem: FC<DebugItemProps> = ({ const handleRemove = () => { onMultipleModelConfigsChange( true, - multipleModelConfigs.filter(item => item.id !== modelAndParameter.id), + multipleModelConfigs.filter((item) => item.id !== modelAndParameter.id), ) } @@ -81,43 +74,40 @@ const DebugItem: FC<DebugItemProps> = ({ > <div className="flex h-10 shrink-0 items-center justify-between border-b-[0.5px] border-divider-regular px-3"> <div className="flex h-5 w-6 items-center justify-center font-medium text-text-tertiary italic"> - # - {index + 1} + #{index + 1} </div> - <ModelParameterTrigger - modelAndParameter={modelAndParameter} - /> + <ModelParameterTrigger modelAndParameter={modelAndParameter} /> <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <ActionButton className="focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover" - aria-label={t($ => $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.more'], { ns: 'common' })} > <span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" /> </ActionButton> - )} + } /> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="min-w-[160px]" - > + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[160px]"> {showDuplicate && ( <DropdownMenuItem className="system-md-regular" onClick={handleDuplicate}> - {t($ => $.duplicateModel, { ns: 'appDebug' })} + {t(($) => $.duplicateModel, { ns: 'appDebug' })} </DropdownMenuItem> )} {showDebugAsSingleModel && ( <DropdownMenuItem className="system-md-regular" onClick={handleDebugAsSingleModel}> - {t($ => $.debugAsSingleModel, { ns: 'appDebug' })} + {t(($) => $.debugAsSingleModel, { ns: 'appDebug' })} </DropdownMenuItem> )} {showRemove && ( <> {(showDuplicate || showDebugAsSingleModel) && <DropdownMenuSeparator />} - <DropdownMenuItem variant="destructive" className="system-md-regular" onClick={handleRemove}> - {t($ => $['operation.remove'], { ns: 'common' })} + <DropdownMenuItem + variant="destructive" + className="system-md-regular" + onClick={handleRemove} + > + {t(($) => $['operation.remove'], { ns: 'common' })} </DropdownMenuItem> </> )} @@ -125,16 +115,18 @@ const DebugItem: FC<DebugItemProps> = ({ </DropdownMenu> </div> <div style={{ height: 'calc(100% - 40px)' }}> - { - (mode === AppModeEnum.CHAT || mode === AppModeEnum.AGENT_CHAT) && currentProvider && currentModel && currentModel.status === ModelStatusEnum.active && ( + {(mode === AppModeEnum.CHAT || mode === AppModeEnum.AGENT_CHAT) && + currentProvider && + currentModel && + currentModel.status === ModelStatusEnum.active && ( <ChatItem modelAndParameter={modelAndParameter} /> - ) - } - { - mode === AppModeEnum.COMPLETION && currentProvider && currentModel && currentModel.status === ModelStatusEnum.active && ( + )} + {mode === AppModeEnum.COMPLETION && + currentProvider && + currentModel && + currentModel.status === ModelStatusEnum.active && ( <TextGenerationItem modelAndParameter={modelAndParameter} /> - ) - } + )} </div> </div> ) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx index b219af3c6a3ca6..5fe91d7681d392 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx @@ -2,11 +2,7 @@ import type { FC } from 'react' import type { DebugWithMultipleModelContextType } from './context' import type { InputForm } from '@/app/components/base/chat/chat/type' import type { FileEntity } from '@/app/components/base/file-uploader/types' -import { - memo, - useCallback, - useMemo, -} from 'react' +import { memo, useCallback, useMemo } from 'react' import { useStore as useAppStore } from '@/app/components/app/store' import ChatInputArea from '@/app/components/base/chat/chat/chat-input-area' import { useFeatures } from '@/app/components/base/features/hooks' @@ -26,30 +22,28 @@ const DebugWithMultipleModel = () => { readonly, canTestAndRun = false, } = useDebugConfigurationContext() - const speech2text = useFeatures(s => s.features.speech2text) - const file = useFeatures(s => s.features.file) - const { - multipleModelConfigs, - checkCanSend, - } = useDebugWithMultipleModelContext() + const speech2text = useFeatures((s) => s.features.speech2text) + const file = useFeatures((s) => s.features.file) + const { multipleModelConfigs, checkCanSend } = useDebugWithMultipleModelContext() const { eventEmitter } = useEventEmitterContextContext() const isChatMode = mode === AppModeEnum.CHAT || mode === AppModeEnum.AGENT_CHAT - const handleSend = useCallback((message: string, files?: FileEntity[]) => { - if (!canTestAndRun) - return - if (checkCanSend && !checkCanSend()) - return + const handleSend = useCallback( + (message: string, files?: FileEntity[]) => { + if (!canTestAndRun) return + if (checkCanSend && !checkCanSend()) return - eventEmitter?.emit({ - type: APP_CHAT_WITH_MULTIPLE_MODEL, - payload: { - message, - files, - }, - } as any) - }, [canTestAndRun, eventEmitter, checkCanSend]) + eventEmitter?.emit({ + type: APP_CHAT_WITH_MULTIPLE_MODEL, + payload: { + message, + files, + }, + } as any) + }, + [canTestAndRun, eventEmitter, checkCanSend], + ) const twoLine = multipleModelConfigs.length === 2 const threeLine = multipleModelConfigs.length === 3 @@ -76,35 +70,33 @@ const DebugWithMultipleModel = () => { height, } }, [twoLine, threeLine, fourLine]) - const position = useCallback((idx: number) => { - let translateX = '0' - let translateY = '0' + const position = useCallback( + (idx: number) => { + let translateX = '0' + let translateY = '0' - if (twoLine && idx === 1) - translateX = 'calc(100% + 8px)' - if (threeLine && idx === 1) - translateX = 'calc(100% + 8px)' - if (threeLine && idx === 2) - translateX = 'calc(200% + 16px)' - if (fourLine && idx === 1) - translateX = 'calc(100% + 8px)' - if (fourLine && idx === 2) - translateY = 'calc(100% + 8px)' - if (fourLine && idx === 3) { - translateX = 'calc(100% + 8px)' - translateY = 'calc(100% + 8px)' - } + if (twoLine && idx === 1) translateX = 'calc(100% + 8px)' + if (threeLine && idx === 1) translateX = 'calc(100% + 8px)' + if (threeLine && idx === 2) translateX = 'calc(200% + 16px)' + if (fourLine && idx === 1) translateX = 'calc(100% + 8px)' + if (fourLine && idx === 2) translateY = 'calc(100% + 8px)' + if (fourLine && idx === 3) { + translateX = 'calc(100% + 8px)' + translateY = 'calc(100% + 8px)' + } - return { - translateX, - translateY, - } - }, [twoLine, threeLine, fourLine]) + return { + translateX, + translateY, + } + }, + [twoLine, threeLine, fourLine], + ) - const setShowAppConfigureFeaturesModal = useAppStore(s => s.setShowAppConfigureFeaturesModal) + const setShowAppConfigureFeaturesModal = useAppStore((s) => s.setShowAppConfigureFeaturesModal) const inputsForm = modelConfig.configs.prompt_variables - .filter(item => item.type !== 'api') - .map(item => ({ + .filter((item) => item.type !== 'api') + .map((item) => ({ ...item, label: item.name, variable: item.key, @@ -115,31 +107,21 @@ const DebugWithMultipleModel = () => { return ( <div className="flex h-full flex-col"> <div - className={` - relative mb-3 grow overflow-auto px-6 - `} + className={`relative mb-3 grow overflow-auto px-6`} style={{ height: isChatMode ? 'calc(100% - 60px)' : '100%' }} > - { - multipleModelConfigs.map((modelConfig, index) => ( - <DebugItem - key={modelConfig.id} - modelAndParameter={modelConfig} - className={` - absolute top-0 left-6 min-h-[200px] - ${twoLine && index === 0 && 'mr-2'} - ${threeLine && (index === 0 || index === 1) && 'mr-2'} - ${fourLine && (index === 0 || index === 2) && 'mr-2'} - ${fourLine && (index === 0 || index === 1) && 'mb-2'} - `} - style={{ - width: size.width, - height: size.height, - transform: `translateX(${position(index).translateX}) translateY(${position(index).translateY})`, - }} - /> - )) - } + {multipleModelConfigs.map((modelConfig, index) => ( + <DebugItem + key={modelConfig.id} + modelAndParameter={modelConfig} + className={`absolute top-0 left-6 min-h-[200px] ${twoLine && index === 0 && 'mr-2'} ${threeLine && (index === 0 || index === 1) && 'mr-2'} ${fourLine && (index === 0 || index === 2) && 'mr-2'} ${fourLine && (index === 0 || index === 1) && 'mb-2'} `} + style={{ + width: size.width, + height: size.height, + transform: `translateX(${position(index).translateX}) translateY(${position(index).translateY})`, + }} + /> + ))} </div> {isChatMode && ( <div className="shrink-0 px-6 pb-0"> diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx index 72731eb604b273..282d9f3a3c13c4 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx @@ -20,24 +20,19 @@ import { useDebugWithMultipleModelContext } from './context' type ModelParameterTriggerProps = { modelAndParameter: ModelAndParameter } -const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({ - modelAndParameter, -}) => { +const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({ modelAndParameter }) => { const { t } = useTranslation() - const { - isAdvancedMode, - } = useDebugConfigurationContext() - const { - multipleModelConfigs, - onMultipleModelConfigsChange, - onDebugWithMultipleModelChange, - } = useDebugWithMultipleModelContext() + const { isAdvancedMode } = useDebugConfigurationContext() + const { multipleModelConfigs, onMultipleModelConfigsChange, onDebugWithMultipleModelChange } = + useDebugWithMultipleModelContext() const { modelProviders } = useProviderContext() - const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id) - const providerMeta = modelProviders.find(provider => provider.provider === modelAndParameter.provider) + const index = multipleModelConfigs.findIndex((v) => v.id === modelAndParameter.id) + const providerMeta = modelProviders.find( + (provider) => provider.provider === modelAndParameter.provider, + ) const credentialState = useCredentialPanelState(providerMeta) - const handleSelectModel = ({ modelId, provider }: { modelId: string, provider: string }) => { + const handleSelectModel = ({ modelId, provider }: { modelId: string; provider: string }) => { const newModelConfigs = [...multipleModelConfigs] newModelConfigs[index] = { ...newModelConfigs[index]!, @@ -65,11 +60,7 @@ const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({ setModel={handleSelectModel} debugWithMultipleModel onDebugWithMultipleModelChange={() => onDebugWithMultipleModelChange(modelAndParameter)} - renderTrigger={({ - open, - currentProvider, - currentModel, - }) => { + renderTrigger={({ open, currentProvider, currentModel }) => { const status = deriveModelStatus( modelAndParameter.model, modelAndParameter.provider, @@ -78,75 +69,62 @@ const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({ credentialState, ) const iconProvider = currentProvider || providerMeta - const statusLabelKey = DERIVED_MODEL_STATUS_BADGE_I18N[status as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N] - const statusTooltipKey = DERIVED_MODEL_STATUS_TOOLTIP_I18N[status as keyof typeof DERIVED_MODEL_STATUS_TOOLTIP_I18N] + const statusLabelKey = + DERIVED_MODEL_STATUS_BADGE_I18N[status as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N] + const statusTooltipKey = + DERIVED_MODEL_STATUS_TOOLTIP_I18N[ + status as keyof typeof DERIVED_MODEL_STATUS_TOOLTIP_I18N + ] const isEmpty = status === 'empty' const isActive = status === 'active' return ( <div - className={` - flex h-8 max-w-[200px] cursor-pointer items-center rounded-lg px-2 - ${open && 'bg-state-base-hover'} - ${!isEmpty && !isActive && 'bg-[#FFFAEB]!'} - `} + className={`flex h-8 max-w-[200px] cursor-pointer items-center rounded-lg px-2 ${open && 'bg-state-base-hover'} ${!isEmpty && !isActive && 'bg-[#FFFAEB]!'} `} > - { - iconProvider && !isEmpty && ( - <ModelIcon - className="mr-1 size-4!" - provider={iconProvider} - modelName={currentModel?.model || modelAndParameter.model} - /> - ) - } - { - (!iconProvider || isEmpty) && ( - <div className="mr-1 flex size-4 items-center justify-center rounded-sm"> - <span className="i-custom-vender-line-shapes-cube-outline size-4 text-text-accent" /> - </div> - ) - } - { - currentModel && ( - <ModelName - className="mr-0.5 text-text-secondary" - modelItem={currentModel} + {iconProvider && !isEmpty && ( + <ModelIcon + className="mr-1 size-4!" + provider={iconProvider} + modelName={currentModel?.model || modelAndParameter.model} + /> + )} + {(!iconProvider || isEmpty) && ( + <div className="mr-1 flex size-4 items-center justify-center rounded-sm"> + <span className="i-custom-vender-line-shapes-cube-outline size-4 text-text-accent" /> + </div> + )} + {currentModel && ( + <ModelName className="mr-0.5 text-text-secondary" modelItem={currentModel} /> + )} + {!currentModel && !isEmpty && ( + <div className="mr-0.5 truncate text-[13px] font-medium text-text-secondary"> + {modelAndParameter.model} + </div> + )} + {isEmpty && ( + <div className="mr-0.5 truncate text-[13px] font-medium text-text-accent"> + {t(($) => $['modelProvider.selectModel'], { ns: 'common' })} + </div> + )} + <span + className={`i-ri-arrow-down-s-line size-3 ${isEmpty ? 'text-text-accent' : 'text-text-tertiary'}`} + /> + {!isEmpty && !isActive && statusLabelKey && ( + <Tooltip> + <TooltipTrigger + render={ + <span + aria-label={t(($) => $[statusTooltipKey || statusLabelKey], { ns: 'common' })} + className="i-custom-vender-line-alertsAndFeedback-alert-triangle h-4 w-4 text-[#F79009]" + /> + } /> - ) - } - { - !currentModel && !isEmpty && ( - <div className="mr-0.5 truncate text-[13px] font-medium text-text-secondary"> - {modelAndParameter.model} - </div> - ) - } - { - isEmpty && ( - <div className="mr-0.5 truncate text-[13px] font-medium text-text-accent"> - {t($ => $['modelProvider.selectModel'], { ns: 'common' })} - </div> - ) - } - <span className={`i-ri-arrow-down-s-line size-3 ${isEmpty ? 'text-text-accent' : 'text-text-tertiary'}`} /> - { - !isEmpty && !isActive && statusLabelKey && ( - <Tooltip> - <TooltipTrigger - render={( - <span - aria-label={t($ => $[statusTooltipKey || statusLabelKey], { ns: 'common' })} - className="i-custom-vender-line-alertsAndFeedback-alert-triangle h-4 w-4 text-[#F79009]" - /> - )} - /> - <TooltipContent> - {t($ => $[statusTooltipKey || statusLabelKey], { ns: 'common' })} - </TooltipContent> - </Tooltip> - ) - } + <TooltipContent> + {t(($) => $[statusTooltipKey || statusLabelKey], { ns: 'common' })} + </TooltipContent> + </Tooltip> + )} </div> ) }} diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx index eb18ca45b1ecc7..39719028d4a6fa 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx @@ -1,9 +1,6 @@ import type { FC } from 'react' import type { ModelAndParameter } from '../types' -import type { - OnSend, - TextGenerationConfig, -} from '@/app/components/base/text-generation/types' +import type { OnSend, TextGenerationConfig } from '@/app/components/base/text-generation/types' import { noop } from 'es-toolkit/function' import { cloneDeep } from 'es-toolkit/object' import { memo } from 'react' @@ -22,9 +19,7 @@ import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types' type TextGenerationItemProps = { modelAndParameter: ModelAndParameter } -const TextGenerationItem: FC<TextGenerationItemProps> = ({ - modelAndParameter, -}) => { +const TextGenerationItem: FC<TextGenerationItemProps> = ({ modelAndParameter }) => { const { isAdvancedMode, modelConfig, @@ -42,19 +37,21 @@ const TextGenerationItem: FC<TextGenerationItemProps> = ({ datasetConfigs, } = useDebugConfigurationContext() const { textGenerationModelList } = useProviderContext() - const features = useFeatures(s => s.features) + const features = useFeatures((s) => s.features) const postDatasets = dataSets.map(({ id }) => ({ dataset: { enabled: true, id, }, })) - const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key + const contextVar = modelConfig.configs.prompt_variables.find((item) => item.is_context_var)?.key const config: TextGenerationConfig = { pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '', prompt_type: promptMode, chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG), - completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG), + completion_prompt_config: isAdvancedMode + ? completionPromptConfig + : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG), user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables), dataset_query_variable: contextVar || '', // features @@ -79,16 +76,15 @@ const TextGenerationItem: FC<TextGenerationItemProps> = ({ }, system_parameters: modelConfig.system_parameters, } - const { - completion, - handleSend, - isResponding, - messageId, - } = useTextGeneration() + const { completion, handleSend, isResponding, messageId } = useTextGeneration() const doSend: OnSend = (message, files) => { - const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider) - const currentModel = currentProvider?.models.find(model => model.model === modelAndParameter.model) + const currentProvider = textGenerationModelList.find( + (item) => item.provider === modelAndParameter.provider, + ) + const currentModel = currentProvider?.models.find( + (model) => model.model === modelAndParameter.model, + ) const configData = { ...config, @@ -117,16 +113,12 @@ const TextGenerationItem: FC<TextGenerationItemProps> = ({ }) } - handleSend( - `apps/${appId}/completion-messages`, - data, - ) + handleSend(`apps/${appId}/completion-messages`, data) } const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((v: any) => { - if (v.type === APP_CHAT_WITH_MULTIPLE_MODEL) - doSend(v.payload.message, v.payload.files) + if (v.type === APP_CHAT_WITH_MULTIPLE_MODEL) doSend(v.payload.message, v.payload.files) }) return ( diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx index 60a26dcf16e9a8..5b31f175676af6 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx @@ -9,7 +9,12 @@ import type { DatasetConfigs, ModelConfig } from '@/models/debug' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { createRef } from 'react' import { useStore as useAppStore } from '@/app/components/app/store' -import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + ConfigurationMethodEnum, + ModelFeatureEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { CollectionType } from '@/app/components/tools/types' import { PromptMode } from '@/models/debug' import { AgentStrategy, AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app' @@ -29,9 +34,7 @@ function createMockModelConfig(overrides: Partial<ModelConfig> = {}): ModelConfi mode: ModelModeType.chat, configs: { prompt_template: 'Test template', - prompt_variables: [ - { key: 'var1', name: 'Variable 1', type: 'text', required: false }, - ], + prompt_variables: [{ key: 'var1', name: 'Variable 1', type: 'text', required: false }], }, chat_prompt_config: { prompt: [], @@ -76,19 +79,24 @@ function createMockModelConfig(overrides: Partial<ModelConfig> = {}): ModelConfi * Factory function for creating mock Collection list */ function createMockCollections(collections: Partial<Collection>[] = []): Collection[] { - return collections.map((collection, index) => ({ - id: `collection-${index}`, - name: `Collection ${index}`, - icon: 'icon-url', - type: 'tool', - ...collection, - } as Collection)) + return collections.map( + (collection, index) => + ({ + id: `collection-${index}`, + name: `Collection ${index}`, + icon: 'icon-url', + type: 'tool', + ...collection, + }) as Collection, + ) } /** * Factory function for creating mock Provider Context */ -function createMockProviderContext(overrides: Partial<ProviderContextState> = {}): ProviderContextState { +function createMockProviderContext( + overrides: Partial<ProviderContextState> = {}, +): ProviderContextState { return { textGenerationModelList: [ { @@ -144,7 +152,11 @@ vi.mock('@/service/fetch', () => ({ fetch: vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })), })) -const { mockFetchConversationMessages, mockFetchSuggestedQuestions, mockStopChatMessageResponding } = vi.hoisted(() => ({ +const { + mockFetchConversationMessages, + mockFetchSuggestedQuestions, + mockStopChatMessageResponding, +} = vi.hoisted(() => ({ mockFetchConversationMessages: vi.fn(), mockFetchSuggestedQuestions: vi.fn(), mockStopChatMessageResponding: vi.fn(), @@ -211,7 +223,12 @@ const mockDebugConfigContext = { citationConfig: { enabled: false }, setCitationConfig: vi.fn(), moderationConfig: { enabled: false }, - annotationConfig: { id: '', enabled: false, score_threshold: 0.7, embedding_model: { embedding_model_name: '', embedding_provider_name: '' } }, + annotationConfig: { + id: '', + enabled: false, + score_threshold: 0.7, + embedding_model: { embedding_model_name: '', embedding_provider_name: '' }, + }, setAnnotationConfig: vi.fn(), setModerationConfig: vi.fn(), externalDataToolsConfig: [], @@ -228,15 +245,17 @@ const mockDebugConfigContext = { agentConfig: { enabled: false, max_iteration: 5, - tools: [{ - tool_name: 'test-tool', - provider_id: 'test-provider', - provider_type: CollectionType.builtIn, - provider_name: 'test-provider', - tool_label: 'Test Tool', - tool_parameters: {}, - enabled: true, - }], + tools: [ + { + tool_name: 'test-tool', + provider_id: 'test-provider', + provider_type: CollectionType.builtIn, + provider_name: 'test-provider', + tool_label: 'Test Tool', + tool_parameters: {}, + enabled: true, + }, + ], strategy: AgentStrategy.react, }, }), @@ -309,7 +328,7 @@ mockUseAppContext.mockReturnValue(mockAppContext) type FeatureState = { moreLikeThis: { enabled: boolean } - opening: { enabled: boolean, opening_statement: string, suggested_questions: string[] } + opening: { enabled: boolean; opening_statement: string; suggested_questions: string[] } moderation: { enabled: boolean } speech2text: { enabled: boolean } text2speech: { enabled: boolean } @@ -407,12 +426,21 @@ type MockChatProps = { chatList?: ChatItem[] isResponding?: boolean onSend?: (message: string, files?: FileEntity[]) => void - onRegenerate?: (chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => void + onRegenerate?: ( + chatItem: ChatItem, + editedQuestion?: { message: string; files?: FileEntity[] }, + ) => void onStopResponding?: () => void suggestedQuestions?: string[] questionIcon?: ReactNode answerIcon?: ReactNode - onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void + onAnnotationAdded?: ( + annotationId: string, + authorName: string, + question: string, + answer: string, + index: number, + ) => void onAnnotationEdited?: (question: string, answer: string, index: number) => void onAnnotationRemoved?: (index: number) => void switchSibling?: (siblingMessageId: string) => void @@ -507,22 +535,21 @@ vi.mock('@/app/components/base/chat/chat', () => ({ {onRegenerate && ( <button data-testid="regenerate-button" - onClick={() => onRegenerate({ - id: 'msg-1', - content: 'Question', - isAnswer: false, - message_files: [], - parentMessageId: 'msg-0', - })} + onClick={() => + onRegenerate({ + id: 'msg-1', + content: 'Question', + isAnswer: false, + message_files: [], + parentMessageId: 'msg-0', + }) + } > Regenerate </button> )} {switchSibling && ( - <button - data-testid="switch-sibling-button" - onClick={() => switchSibling('sibling-1')} - > + <button data-testid="switch-sibling-button" onClick={() => switchSibling('sibling-1')}> Switch </button> )} @@ -552,10 +579,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({ </button> )} {onAnnotationRemoved && ( - <button - data-testid="remove-annotation-button" - onClick={() => onAnnotationRemoved(0)} - > + <button data-testid="remove-annotation-button" onClick={() => onAnnotationRemoved(0)}> Remove Annotation </button> )} @@ -583,8 +607,7 @@ describe('DebugWithSingleModel', () => { mockUseFormattingChangedSubscription.mockReturnValue(undefined) mockFeaturesState = { ...defaultFeatures } mockUseFeatures.mockImplementation((selector?: FeatureSelector) => { - if (typeof selector === 'function') - return selector({ features: mockFeaturesState }) + if (typeof selector === 'function') return selector({ features: mockFeaturesState }) return mockFeaturesState }) @@ -609,7 +632,12 @@ describe('DebugWithSingleModel', () => { it('should render with custom checkCanSend prop', () => { const checkCanSend = vi.fn(() => true) - render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} checkCanSend={checkCanSend} />) + render( + <DebugWithSingleModel + ref={ref as RefObject<DebugWithSingleModelRefType>} + checkCanSend={checkCanSend} + />, + ) expect(screen.getByTestId('chat-component'))!.toBeInTheDocument() }) @@ -620,7 +648,12 @@ describe('DebugWithSingleModel', () => { it('should respect checkCanSend returning true', async () => { const checkCanSend = vi.fn(() => true) - render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} checkCanSend={checkCanSend} />) + render( + <DebugWithSingleModel + ref={ref as RefObject<DebugWithSingleModelRefType>} + checkCanSend={checkCanSend} + />, + ) const sendButton = screen.getByTestId('send-button') fireEvent.click(sendButton) @@ -636,7 +669,12 @@ describe('DebugWithSingleModel', () => { it('should prevent send when checkCanSend returns false', async () => { const checkCanSend = vi.fn(() => false) - render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} checkCanSend={checkCanSend} />) + render( + <DebugWithSingleModel + ref={ref as RefObject<DebugWithSingleModelRefType>} + checkCanSend={checkCanSend} + />, + ) const sendButton = screen.getByTestId('send-button') fireEvent.click(sendButton) @@ -724,7 +762,11 @@ describe('DebugWithSingleModel', () => { it('should omit opening statement when feature is disabled', async () => { mockFeaturesState = { ...defaultFeatures, - opening: { enabled: false, opening_statement: 'Should not appear', suggested_questions: ['Q1'] }, + opening: { + enabled: false, + opening_statement: 'Should not appear', + suggested_questions: ['Q1'], + }, } render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />) @@ -741,29 +783,31 @@ describe('DebugWithSingleModel', () => { }) it('should handle model without vision support', () => { - mockUseProviderContext.mockReturnValue(createMockProviderContext({ - textGenerationModelList: [ - { - provider: 'openai', - label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - icon_small: { en_US: 'icon', zh_Hans: 'icon' }, - status: ModelStatusEnum.active, - models: [ - { - model: 'gpt-3.5-turbo', - label: { en_US: 'GPT-3.5', zh_Hans: 'GPT-3.5' }, - model_type: ModelTypeEnum.textGeneration, - features: [], // No vision support - fetch_from: ConfigurationMethodEnum.predefinedModel, - model_properties: {}, - deprecated: false, - status: ModelStatusEnum.active, - load_balancing_enabled: false, - }, - ], - }, - ], - })) + mockUseProviderContext.mockReturnValue( + createMockProviderContext({ + textGenerationModelList: [ + { + provider: 'openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + status: ModelStatusEnum.active, + models: [ + { + model: 'gpt-3.5-turbo', + label: { en_US: 'GPT-3.5', zh_Hans: 'GPT-3.5' }, + model_type: ModelTypeEnum.textGeneration, + features: [], // No vision support + fetch_from: ConfigurationMethodEnum.predefinedModel, + model_properties: {}, + deprecated: false, + status: ModelStatusEnum.active, + load_balancing_enabled: false, + }, + ], + }, + ], + }), + ) render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />) @@ -771,17 +815,19 @@ describe('DebugWithSingleModel', () => { }) it('should handle missing model in provider list', () => { - mockUseProviderContext.mockReturnValue(createMockProviderContext({ - textGenerationModelList: [ - { - provider: 'different-provider', - label: { en_US: 'Different Provider', zh_Hans: '不同提供商' }, - icon_small: { en_US: 'icon', zh_Hans: 'icon' }, - status: ModelStatusEnum.active, - models: [], - }, - ], - })) + mockUseProviderContext.mockReturnValue( + createMockProviderContext({ + textGenerationModelList: [ + { + provider: 'different-provider', + label: { en_US: 'Different Provider', zh_Hans: '不同提供商' }, + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + status: ModelStatusEnum.active, + models: [], + }, + ], + }), + ) render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />) @@ -863,15 +909,17 @@ describe('DebugWithSingleModel', () => { agentConfig: { enabled: false, max_iteration: 5, - tools: [{ - tool_name: 'unknown-tool', - provider_id: 'unknown-provider', - provider_type: CollectionType.builtIn, - provider_name: 'unknown-provider', - tool_label: 'Unknown Tool', - tool_parameters: {}, - enabled: true, - }], + tools: [ + { + tool_name: 'unknown-tool', + provider_id: 'unknown-provider', + provider_type: CollectionType.builtIn, + provider_name: 'unknown-provider', + tool_label: 'Unknown Tool', + tool_parameters: {}, + enabled: true, + }, + ], strategy: AgentStrategy.react, }, }), @@ -954,29 +1002,31 @@ describe('DebugWithSingleModel', () => { }), }) - mockUseProviderContext.mockReturnValue(createMockProviderContext({ - textGenerationModelList: [ - { - provider: 'openai', - label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - icon_small: { en_US: 'icon', zh_Hans: 'icon' }, - status: ModelStatusEnum.active, - models: [ - { - model: 'gpt-3.5-turbo', - label: { en_US: 'GPT-3.5', zh_Hans: 'GPT-3.5' }, - model_type: ModelTypeEnum.textGeneration, - features: [], // No vision - fetch_from: ConfigurationMethodEnum.predefinedModel, - model_properties: {}, - deprecated: false, - status: ModelStatusEnum.active, - load_balancing_enabled: false, - }, - ], - }, - ], - })) + mockUseProviderContext.mockReturnValue( + createMockProviderContext({ + textGenerationModelList: [ + { + provider: 'openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + status: ModelStatusEnum.active, + models: [ + { + model: 'gpt-3.5-turbo', + label: { en_US: 'GPT-3.5', zh_Hans: 'GPT-3.5' }, + model_type: ModelTypeEnum.textGeneration, + features: [], // No vision + fetch_from: ConfigurationMethodEnum.predefinedModel, + model_properties: {}, + deprecated: false, + status: ModelStatusEnum.active, + load_balancing_enabled: false, + }, + ], + }, + ], + }), + ) mockFeaturesState = { ...defaultFeatures, @@ -1003,29 +1053,31 @@ describe('DebugWithSingleModel', () => { }), }) - mockUseProviderContext.mockReturnValue(createMockProviderContext({ - textGenerationModelList: [ - { - provider: 'openai', - label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - icon_small: { en_US: 'icon', zh_Hans: 'icon' }, - status: ModelStatusEnum.active, - models: [ - { - model: 'gpt-4-vision', - label: { en_US: 'GPT-4 Vision', zh_Hans: 'GPT-4 Vision' }, - model_type: ModelTypeEnum.textGeneration, - features: [ModelFeatureEnum.vision], - fetch_from: ConfigurationMethodEnum.predefinedModel, - model_properties: {}, - deprecated: false, - status: ModelStatusEnum.active, - load_balancing_enabled: false, - }, - ], - }, - ], - })) + mockUseProviderContext.mockReturnValue( + createMockProviderContext({ + textGenerationModelList: [ + { + provider: 'openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + status: ModelStatusEnum.active, + models: [ + { + model: 'gpt-4-vision', + label: { en_US: 'GPT-4 Vision', zh_Hans: 'GPT-4 Vision' }, + model_type: ModelTypeEnum.textGeneration, + features: [ModelFeatureEnum.vision], + fetch_from: ConfigurationMethodEnum.predefinedModel, + model_properties: {}, + deprecated: false, + status: ModelStatusEnum.active, + load_balancing_enabled: false, + }, + ], + }, + ], + }), + ) mockFeaturesState = { ...defaultFeatures, diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx index 1a18cd3884100d..a9bbf6e6c80194 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx @@ -19,10 +19,7 @@ import { stopChatMessageResponding, } from '@/service/debug' import { canFindTool } from '@/utils' -import { - useConfigFromDebugContext, - useFormattingChangedSubscription, -} from '../hooks' +import { useConfigFromDebugContext, useFormattingChangedSubscription } from '../hooks' type DebugWithSingleModelProps = { checkCanSend?: () => boolean @@ -30,14 +27,12 @@ type DebugWithSingleModelProps = { export type DebugWithSingleModelRefType = { handleRestart: () => void } -const DebugWithSingleModel = ( - { - ref, - checkCanSend, - }: DebugWithSingleModelProps & { - ref: React.RefObject<DebugWithSingleModelRefType> - }, -) => { +const DebugWithSingleModel = ({ + ref, + checkCanSend, +}: DebugWithSingleModelProps & { + ref: React.RefObject<DebugWithSingleModelRefType> +}) => { const userProfile = useAtomValue(userProfileAtom) const { readonly, @@ -52,14 +47,16 @@ const DebugWithSingleModel = ( const debugInputReadonly = !canTestAndRun const canManageAnnotation = !readonly && canTestAndRun const { textGenerationModelList } = useProviderContext() - const features = useFeatures(s => s.features) + const features = useFeatures((s) => s.features) const configTemplate = useConfigFromDebugContext() const config = useMemo(() => { return { ...configTemplate, more_like_this: features.moreLikeThis, - opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '', - suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [], + opening_statement: features.opening?.enabled ? features.opening?.opening_statement || '' : '', + suggested_questions: features.opening?.enabled + ? features.opening?.suggested_questions || [] + : [], sensitive_word_avoidance: features.moderation, speech_to_text: features.speech2text, text_to_speech: features.text2speech, @@ -70,7 +67,9 @@ const DebugWithSingleModel = ( } as ChatConfig }, [configTemplate, features]) const inputsForm = useMemo(() => { - return modelConfig.configs.prompt_variables.filter(item => item.type !== 'api').map(item => ({ ...item, label: item.name, variable: item.key })) as InputForm[] + return modelConfig.configs.prompt_variables + .filter((item) => item.type !== 'api') + .map((item) => ({ ...item, label: item.name, variable: item.key })) as InputForm[] }, [modelConfig.configs.prompt_variables]) const { chatList, @@ -90,59 +89,86 @@ const DebugWithSingleModel = ( inputsForm, }, [], - taskId => stopChatMessageResponding(appId, taskId), + (taskId) => stopChatMessageResponding(appId, taskId), ) useFormattingChangedSubscription(chatList) - const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { - if (!canTestAndRun) - return - if (checkCanSend && !checkCanSend()) - return - const currentProvider = textGenerationModelList.find(item => item.provider === modelConfig.provider) - const currentModel = currentProvider?.models.find(model => model.model === modelConfig.model_id) - const supportVision = currentModel?.features?.includes(ModelFeatureEnum.vision) + const doSend: OnSend = useCallback( + (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { + if (!canTestAndRun) return + if (checkCanSend && !checkCanSend()) return + const currentProvider = textGenerationModelList.find( + (item) => item.provider === modelConfig.provider, + ) + const currentModel = currentProvider?.models.find( + (model) => model.model === modelConfig.model_id, + ) + const supportVision = currentModel?.features?.includes(ModelFeatureEnum.vision) - const configData = { - ...config, - model: { - provider: modelConfig.provider, - name: modelConfig.model_id, - mode: modelConfig.mode, - completion_params: completionParams, - }, - } + const configData = { + ...config, + model: { + provider: modelConfig.provider, + name: modelConfig.model_id, + mode: modelConfig.mode, + completion_params: completionParams, + }, + } - const data: any = { - query: message, - inputs, - model_config: configData, - parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, - } + const data: any = { + query: message, + inputs, + model_config: configData, + parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, + } - if ((config.file_upload as any)?.enabled && files?.length && supportVision) - data.files = files + if ((config.file_upload as any)?.enabled && files?.length && supportVision) data.files = files - handleSend( - `apps/${appId}/chat-messages`, - data, - { - onGetConversationMessages: (conversationId, getAbortController) => fetchConversationMessages(appId, conversationId, getAbortController), - onGetSuggestedQuestions: (responseItemId, getAbortController) => fetchSuggestedQuestions(appId, responseItemId, getAbortController), - }, - ) - }, [appId, canTestAndRun, chatList, checkCanSend, completionParams, config, handleSend, inputs, modelConfig.mode, modelConfig.model_id, modelConfig.provider, textGenerationModelList]) + handleSend(`apps/${appId}/chat-messages`, data, { + onGetConversationMessages: (conversationId, getAbortController) => + fetchConversationMessages(appId, conversationId, getAbortController), + onGetSuggestedQuestions: (responseItemId, getAbortController) => + fetchSuggestedQuestions(appId, responseItemId, getAbortController), + }) + }, + [ + appId, + canTestAndRun, + chatList, + checkCanSend, + completionParams, + config, + handleSend, + inputs, + modelConfig.mode, + modelConfig.model_id, + modelConfig.provider, + textGenerationModelList, + ], + ) - const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => { - const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! - const parentAnswer = chatList.find(item => item.id === question.parentMessageId) - doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) - }, [chatList, doSend]) + const doRegenerate = useCallback( + (chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => { + const question = editedQuestion + ? chatItem + : chatList.find((item) => item.id === chatItem.parentMessageId)! + const parentAnswer = chatList.find((item) => item.id === question.parentMessageId) + doSend( + editedQuestion ? editedQuestion.message : question.content, + editedQuestion ? editedQuestion.files : question.message_files, + true, + isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, + ) + }, + [chatList, doSend], + ) const allToolIcons = useMemo(() => { const icons: Record<string, any> = {} modelConfig.agentConfig.tools?.forEach((item: any) => { - icons[item.tool_name] = collectionList.find((collection: any) => canFindTool(collection.id, item.provider_id))?.icon + icons[item.tool_name] = collectionList.find((collection: any) => + canFindTool(collection.id, item.provider_id), + )?.icon }) return icons }, [collectionList, modelConfig.agentConfig.tools]) @@ -153,7 +179,7 @@ const DebugWithSingleModel = ( } }, [handleRestart]) - const setShowAppConfigureFeaturesModal = useAppStore(s => s.setShowAppConfigureFeaturesModal) + const setShowAppConfigureFeaturesModal = useAppStore((s) => s.setShowAppConfigureFeaturesModal) return ( <Chat @@ -173,7 +199,7 @@ const DebugWithSingleModel = ( inputs={inputs} inputsForm={inputsForm} onRegenerate={doRegenerate} - switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)} + switchSibling={(siblingMessageId) => setTargetMessageId(siblingMessageId)} onStopResponding={handleStop} showPromptLog questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />} diff --git a/web/app/components/app/configuration/debug/hooks.tsx b/web/app/components/app/configuration/debug/hooks.tsx index e5dba3640db22f..860c548f06f375 100644 --- a/web/app/components/app/configuration/debug/hooks.tsx +++ b/web/app/components/app/configuration/debug/hooks.tsx @@ -1,64 +1,55 @@ -import type { - DebugWithSingleOrMultipleModelConfigs, - ModelAndParameter, -} from './types' -import type { - ChatConfig, - ChatItem, -} from '@/app/components/base/chat/types' +import type { DebugWithSingleOrMultipleModelConfigs, ModelAndParameter } from './types' +import type { ChatConfig, ChatItem } from '@/app/components/base/chat/types' import { cloneDeep } from 'es-toolkit/object' -import { - useCallback, - useRef, - useState, -} from 'react' +import { useCallback, useRef, useState } from 'react' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' import { useDebugConfigurationContext } from '@/context/debug-configuration' import { useEventEmitterContextContext } from '@/context/event-emitter' -import { - AgentStrategy, -} from '@/types/app' +import { AgentStrategy } from '@/types/app' import { promptVariablesToUserInputsForm } from '@/utils/model-config' import { ORCHESTRATE_CHANGED } from './types' export const useDebugWithSingleOrMultipleModel = (appId: string) => { - const localeDebugWithSingleOrMultipleModelConfigs = localStorage.getItem('app-debug-with-single-or-multiple-models') + const localeDebugWithSingleOrMultipleModelConfigs = localStorage.getItem( + 'app-debug-with-single-or-multiple-models', + ) const debugWithSingleOrMultipleModelConfigs = useRef<DebugWithSingleOrMultipleModelConfigs>({}) if (localeDebugWithSingleOrMultipleModelConfigs) { try { - debugWithSingleOrMultipleModelConfigs.current = JSON.parse(localeDebugWithSingleOrMultipleModelConfigs) || {} - } - catch (e) { + debugWithSingleOrMultipleModelConfigs.current = + JSON.parse(localeDebugWithSingleOrMultipleModelConfigs) || {} + } catch (e) { console.error(e) } } - const [ - debugWithMultipleModel, - setDebugWithMultipleModel, - ] = useState(debugWithSingleOrMultipleModelConfigs.current[appId]?.multiple || false) + const [debugWithMultipleModel, setDebugWithMultipleModel] = useState( + debugWithSingleOrMultipleModelConfigs.current[appId]?.multiple || false, + ) - const [ - multipleModelConfigs, - setMultipleModelConfigs, - ] = useState(debugWithSingleOrMultipleModelConfigs.current[appId]?.configs || []) + const [multipleModelConfigs, setMultipleModelConfigs] = useState( + debugWithSingleOrMultipleModelConfigs.current[appId]?.configs || [], + ) - const handleMultipleModelConfigsChange = useCallback(( - multiple: boolean, - modelConfigs: ModelAndParameter[], - ) => { - const value = { - multiple, - configs: modelConfigs, - } - debugWithSingleOrMultipleModelConfigs.current[appId] = value - localStorage.setItem('app-debug-with-single-or-multiple-models', JSON.stringify(debugWithSingleOrMultipleModelConfigs.current)) - setDebugWithMultipleModel(value.multiple) - setMultipleModelConfigs(value.configs) - }, [appId]) + const handleMultipleModelConfigsChange = useCallback( + (multiple: boolean, modelConfigs: ModelAndParameter[]) => { + const value = { + multiple, + configs: modelConfigs, + } + debugWithSingleOrMultipleModelConfigs.current[appId] = value + localStorage.setItem( + 'app-debug-with-single-or-multiple-models', + JSON.stringify(debugWithSingleOrMultipleModelConfigs.current), + ) + setDebugWithMultipleModel(value.multiple) + setMultipleModelConfigs(value.configs) + }, + [appId], + ) return { debugWithMultipleModel, @@ -94,12 +85,14 @@ export const useConfigFromDebugContext = () => { id, }, })) - const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key + const contextVar = modelConfig.configs.prompt_variables.find((item) => item.is_context_var)?.key const config: ChatConfig = { pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '', prompt_type: promptMode, chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG), - completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG), + completion_prompt_config: isAdvancedMode + ? completionPromptConfig + : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG), user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables), dataset_query_variable: contextVar || '', opening_statement: introduction, @@ -150,15 +143,11 @@ export const useFormattingChangedDispatcher = () => { return dispatcher } export const useFormattingChangedSubscription = (chatList: ChatItem[]) => { - const { - formattingChanged, - setFormattingChanged, - } = useDebugConfigurationContext() + const { formattingChanged, setFormattingChanged } = useDebugConfigurationContext() const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((v: any) => { if (v.type === ORCHESTRATE_CHANGED) { - if (chatList.some(item => item.isAnswer) && !formattingChanged) - setFormattingChanged(true) + if (chatList.some((item) => item.isAnswer) && !formattingChanged) setFormattingChanged(true) } }) } diff --git a/web/app/components/app/configuration/debug/index.tsx b/web/app/components/app/configuration/debug/index.tsx index bca633a9017c79..8c3e29a03adaa8 100644 --- a/web/app/components/app/configuration/debug/index.tsx +++ b/web/app/components/app/configuration/debug/index.tsx @@ -7,16 +7,8 @@ import type { Inputs } from '@/models/debug' import type { ModelConfig as BackendModelConfig, VisionFile, VisionSettings } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { toast } from '@langgenius/dify-ui/toast' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' -import { - RiAddLine, - RiEqualizer2Line, - RiSparklingFill, -} from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { RiAddLine, RiEqualizer2Line, RiSparklingFill } from '@remixicon/react' import { useBoolean } from 'ahooks' import { noop } from 'es-toolkit/function' import { cloneDeep } from 'es-toolkit/object' @@ -35,7 +27,10 @@ import AgentLogModal from '@/app/components/base/agent-log-modal' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows' import PromptLogModal from '@/app/components/base/prompt-log-modal' -import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + ModelFeatureEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' import ConfigContext from '@/context/debug-configuration' @@ -51,10 +46,7 @@ import FormattingChanged from '../base/warning-mask/formatting-changed' import HasNotSetAPIKEY from '../base/warning-mask/has-not-set-api' import DebugWithMultipleModel from './debug-with-multiple-model' import DebugWithSingleModel from './debug-with-single-model' -import { - APP_CHAT_WITH_MULTIPLE_MODEL, - APP_CHAT_WITH_MULTIPLE_MODEL_RESTART, -} from './types' +import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from './types' type IDebug = { isAPIKeySet: boolean @@ -109,13 +101,13 @@ const Debug: FC<IDebug> = ({ } }, []) - const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false) + const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = + useBoolean(false) const [isShowFormattingChangeConfirm, setIsShowFormattingChangeConfirm] = useState(false) const [isShowCannotQueryDataset, setShowCannotQueryDataset] = useState(false) useEffect(() => { - if (formattingChanged) - setIsShowFormattingChangeConfirm(true) + if (formattingChanged) setIsShowFormattingChangeConfirm(true) }, [formattingChanged]) const debugWithSingleModelRef = React.useRef<DebugWithSingleModelRefType>(null!) @@ -152,37 +144,50 @@ const Debug: FC<IDebug> = ({ if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) { if (modelModeType === ModelModeType.completion) { if (!hasSetBlockStatus.history) { - toast.error(t($ => $['otherError.historyNoBeEmpty'], { ns: 'appDebug' })) + toast.error(t(($) => $['otherError.historyNoBeEmpty'], { ns: 'appDebug' })) return false } if (!hasSetBlockStatus.query) { - toast.error(t($ => $['otherError.queryNoBeEmpty'], { ns: 'appDebug' })) + toast.error(t(($) => $['otherError.queryNoBeEmpty'], { ns: 'appDebug' })) return false } } } let hasEmptyInput = '' - const requiredVars = modelConfig.configs.prompt_variables.filter(({ key, name, required, type }) => { - if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number') - return false - const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null) - return res - }) // compatible with old version + const requiredVars = modelConfig.configs.prompt_variables.filter( + ({ key, name, required, type }) => { + if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number') + return false + const res = + !key || + !key.trim() || + !name || + !name.trim() || + required || + required === undefined || + required === null + return res + }, + ) // compatible with old version requiredVars.forEach(({ key, name }) => { - if (hasEmptyInput) - return + if (hasEmptyInput) return - if (!inputs[key]) - hasEmptyInput = name + if (!inputs[key]) hasEmptyInput = name }) if (hasEmptyInput) { - logError(t($ => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: hasEmptyInput })) + logError( + t(($) => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: hasEmptyInput }), + ) return false } - if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) { - toast.info(t($ => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' })) + if ( + completionFiles.find( + (item) => item.transfer_method === TransferMethod.local_file && !item.upload_file_id, + ) + ) { + toast.info(t(($) => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' })) return false } return !hasEmptyInput @@ -201,12 +206,12 @@ const Debug: FC<IDebug> = ({ const [completionRes, setCompletionRes] = useState('') const [messageId, setMessageId] = useState<string | null>(null) - const features = useFeatures(s => s.features) + const features = useFeatures((s) => s.features) const featuresStore = useFeaturesStore() const sendTextCompletion = async () => { if (isResponding) { - toast.info(t($ => $['errorMessage.waitForResponse'], { ns: 'appDebug' })) + toast.info(t(($) => $['errorMessage.waitForResponse'], { ns: 'appDebug' })) return false } @@ -215,8 +220,7 @@ const Debug: FC<IDebug> = ({ return true } - if (!checkCanSend()) - return + if (!checkCanSend()) return const postDatasets = dataSets.map(({ id }) => ({ dataset: { @@ -224,13 +228,15 @@ const Debug: FC<IDebug> = ({ id, }, })) - const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key + const contextVar = modelConfig.configs.prompt_variables.find((item) => item.is_context_var)?.key const postModelConfig: BackendModelConfig = { pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '', prompt_type: promptMode, chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG), - completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG), + completion_prompt_config: isAdvancedMode + ? completionPromptConfig + : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG), user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables), dataset_query_variable: contextVar || '', dataset_configs: { @@ -303,8 +309,7 @@ const Debug: FC<IDebug> = ({ } const handleSendTextCompletion = () => { - if (!canTestAndRun) - return + if (!canTestAndRun) return if (debugWithMultipleModel) { eventEmitter?.emit({ @@ -329,8 +334,10 @@ const Debug: FC<IDebug> = ({ const { textGenerationModelList } = useProviderContext() const handleChangeToSingleModel = (item: ModelAndParameter) => { - const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === item.provider) - const currentModel = currentProvider?.models.find(model => model.model === item.model) + const currentProvider = textGenerationModelList.find( + (modelItem) => modelItem.provider === item.provider, + ) + const currentModel = currentProvider?.models.find((model) => model.model === item.model) modelParameterParams.setModel({ modelId: item.model, @@ -339,24 +346,22 @@ const Debug: FC<IDebug> = ({ features: currentModel?.features, }) modelParameterParams.onCompletionParamsChange(item.parameters) - onMultipleModelConfigsChange( - false, - [], - ) + onMultipleModelConfigsChange(false, []) } const handleVisionConfigInMultipleModel = useCallback(() => { if (debugWithMultipleModel && mode) { const supportedVision = multipleModelConfigs.some((modelConfig) => { - const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === modelConfig.provider) - const currentModel = currentProvider?.models.find(model => model.model === modelConfig.model) + const currentProvider = textGenerationModelList.find( + (modelItem) => modelItem.provider === modelConfig.provider, + ) + const currentModel = currentProvider?.models.find( + (model) => model.model === modelConfig.model, + ) return currentModel?.features?.includes(ModelFeatureEnum.vision) }) - const { - features, - setFeatures, - } = featuresStore!.getState() + const { features, setFeatures } = featuresStore!.getState() const newFeatures = produce(features, (draft) => { draft.file = { @@ -372,20 +377,28 @@ const Debug: FC<IDebug> = ({ handleVisionConfigInMultipleModel() }, [multipleModelConfigs, mode, handleVisionConfigInMultipleModel]) - const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({ - currentLogItem: state.currentLogItem, - setCurrentLogItem: state.setCurrentLogItem, - showPromptLogModal: state.showPromptLogModal, - setShowPromptLogModal: state.setShowPromptLogModal, - showAgentLogModal: state.showAgentLogModal, - setShowAgentLogModal: state.setShowAgentLogModal, - }))) + const { + currentLogItem, + setCurrentLogItem, + showPromptLogModal, + setShowPromptLogModal, + showAgentLogModal, + setShowAgentLogModal, + } = useAppStore( + useShallow((state) => ({ + currentLogItem: state.currentLogItem, + setCurrentLogItem: state.setCurrentLogItem, + showPromptLogModal: state.showPromptLogModal, + setShowPromptLogModal: state.setShowPromptLogModal, + showAgentLogModal: state.showAgentLogModal, + setShowAgentLogModal: state.setShowAgentLogModal, + })), + ) const [width, setWidth] = useState(0) const ref = useRef<HTMLDivElement>(null) const adjustModalWidth = () => { - if (ref.current) - setWidth(document.body.clientWidth - (ref.current?.clientWidth + 16) - 8) + if (ref.current) setWidth(document.body.clientWidth - (ref.current?.clientWidth + 16) - 8) } useEffect(() => { @@ -398,54 +411,70 @@ const Debug: FC<IDebug> = ({ <> <div className="shrink-0"> <div className="flex items-center justify-between px-4 pt-3 pb-2"> - <div className="system-xl-semibold text-text-primary">{t($ => $['inputs.title'], { ns: 'appDebug' })}</div> + <div className="system-xl-semibold text-text-primary"> + {t(($) => $['inputs.title'], { ns: 'appDebug' })} + </div> <div className="flex items-center"> - { - debugWithMultipleModel - ? ( - <> - <Button - variant="ghost-accent" - onClick={() => onMultipleModelConfigsChange(true, [...multipleModelConfigs, { id: `${Date.now()}`, model: '', provider: '', parameters: {} }])} - disabled={multipleModelConfigs.length >= 4 || !canTestAndRun} - > - <RiAddLine className="mr-1 size-3.5" /> - {t($ => $['modelProvider.addModel'], { ns: 'common' })} - ( - {multipleModelConfigs.length} - /4) - </Button> - <div className="mx-2 h-[14px] w-px bg-divider-regular" /> - </> - ) - : null - } + {debugWithMultipleModel ? ( + <> + <Button + variant="ghost-accent" + onClick={() => + onMultipleModelConfigsChange(true, [ + ...multipleModelConfigs, + { id: `${Date.now()}`, model: '', provider: '', parameters: {} }, + ]) + } + disabled={multipleModelConfigs.length >= 4 || !canTestAndRun} + > + <RiAddLine className="mr-1 size-3.5" /> + {t(($) => $['modelProvider.addModel'], { ns: 'common' })}( + {multipleModelConfigs.length} + /4) + </Button> + <div className="mx-2 h-[14px] w-px bg-divider-regular" /> + </> + ) : null} {mode !== AppModeEnum.COMPLETION && ( <> - { - canTestAndRun && ( + {canTestAndRun && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton onClick={clearConversation}> + <RefreshCcw01 className="size-4" /> + </ActionButton> + } + /> + <TooltipContent> + {t(($) => $['operation.refresh'], { ns: 'common' })} + </TooltipContent> + </Tooltip> + )} + + {varList.length > 0 && ( + <div className="relative mr-2 ml-1"> <Tooltip> - <TooltipTrigger render={<ActionButton onClick={clearConversation}><RefreshCcw01 className="size-4" /></ActionButton>} /> + <TooltipTrigger + render={ + <ActionButton + state={expanded ? ActionButtonState.Active : undefined} + disabled={!canTestAndRun} + onClick={() => setExpanded(!expanded)} + > + <RiEqualizer2Line className="size-4" /> + </ActionButton> + } + /> <TooltipContent> - {t($ => $['operation.refresh'], { ns: 'common' })} + {t(($) => $['panel.userInputField'], { ns: 'workflow' })} </TooltipContent> </Tooltip> - ) - } - - { - varList.length > 0 && ( - <div className="relative mr-2 ml-1"> - <Tooltip> - <TooltipTrigger render={<ActionButton state={expanded ? ActionButtonState.Active : undefined} disabled={!canTestAndRun} onClick={() => setExpanded(!expanded)}><RiEqualizer2Line className="size-4" /></ActionButton>} /> - <TooltipContent> - {t($ => $['panel.userInputField'], { ns: 'workflow' })} - </TooltipContent> - </Tooltip> - {expanded && <div className="absolute right-[5px] bottom-[-14px] z-10 h-3 w-3 rotate-45 border-t-[0.5px] border-l-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />} - </div> - ) - } + {expanded && ( + <div className="absolute right-[5px] bottom-[-14px] z-10 h-3 w-3 rotate-45 border-t-[0.5px] border-l-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" /> + )} + </div> + )} </> )} </div> @@ -455,142 +484,132 @@ const Debug: FC<IDebug> = ({ <ChatUserInput inputs={inputs} /> </div> )} - { - mode === AppModeEnum.COMPLETION && ( - <PromptValuePanel - appType={mode as AppModeEnum} - onSend={handleSendTextCompletion} - inputs={inputs} - visionConfig={{ - ...features.file! as VisionSettings, - transfer_methods: features.file!.allowed_file_upload_methods || [], - image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit, + {mode === AppModeEnum.COMPLETION && ( + <PromptValuePanel + appType={mode as AppModeEnum} + onSend={handleSendTextCompletion} + inputs={inputs} + visionConfig={{ + ...(features.file! as VisionSettings), + transfer_methods: features.file!.allowed_file_upload_methods || [], + image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit, + }} + onVisionFilesChange={setCompletionFiles} + /> + )} + </div> + {debugWithMultipleModel && ( + <div className="mt-3 grow overflow-hidden" ref={ref}> + <DebugWithMultipleModel + multipleModelConfigs={multipleModelConfigs} + onMultipleModelConfigsChange={onMultipleModelConfigsChange} + onDebugWithMultipleModelChange={handleChangeToSingleModel} + checkCanSend={checkCanSend} + /> + {showPromptLogModal && ( + <PromptLogModal + width={width} + currentLogItem={currentLogItem} + onCancel={() => { + setCurrentLogItem() + setShowPromptLogModal(false) }} - onVisionFilesChange={setCompletionFiles} /> - ) - } - </div> - { - debugWithMultipleModel && ( - <div className="mt-3 grow overflow-hidden" ref={ref}> - <DebugWithMultipleModel - multipleModelConfigs={multipleModelConfigs} - onMultipleModelConfigsChange={onMultipleModelConfigsChange} - onDebugWithMultipleModelChange={handleChangeToSingleModel} - checkCanSend={checkCanSend} + )} + {showAgentLogModal && ( + <AgentLogModal + width={width} + currentLogItem={currentLogItem} + onCancel={() => { + setCurrentLogItem() + setShowAgentLogModal(false) + }} /> - {showPromptLogModal && ( - <PromptLogModal - width={width} - currentLogItem={currentLogItem} - onCancel={() => { - setCurrentLogItem() - setShowPromptLogModal(false) - }} - /> - )} - {showAgentLogModal && ( - <AgentLogModal - width={width} - currentLogItem={currentLogItem} - onCancel={() => { - setCurrentLogItem() - setShowAgentLogModal(false) - }} - /> - )} - </div> - ) - } - { - !debugWithMultipleModel && ( - <div className="flex grow flex-col" ref={ref}> - {/* No model provider configured */} - {(!modelConfig.provider || !isAPIKeySet) && ( - <HasNotSetAPIKEY onSetting={onSetting} /> - )} - {/* No model selected */} - {modelConfig.provider && isAPIKeySet && !modelConfig.model_id && ( - <div className="flex grow flex-col items-center justify-center pb-[120px]"> - <div className="flex w-full max-w-[400px] flex-col gap-2 px-4 py-4"> - <div className="flex h-10 w-10 items-center justify-center rounded-[10px]"> - <div className="flex h-full w-full items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg backdrop-blur-[5px]"> - <span className="i-ri-brain-2-line size-5 text-text-tertiary" /> - </div> + )} + </div> + )} + {!debugWithMultipleModel && ( + <div className="flex grow flex-col" ref={ref}> + {/* No model provider configured */} + {(!modelConfig.provider || !isAPIKeySet) && <HasNotSetAPIKEY onSetting={onSetting} />} + {/* No model selected */} + {modelConfig.provider && isAPIKeySet && !modelConfig.model_id && ( + <div className="flex grow flex-col items-center justify-center pb-[120px]"> + <div className="flex w-full max-w-[400px] flex-col gap-2 px-4 py-4"> + <div className="flex h-10 w-10 items-center justify-center rounded-[10px]"> + <div className="flex h-full w-full items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg backdrop-blur-[5px]"> + <span className="i-ri-brain-2-line size-5 text-text-tertiary" /> + </div> + </div> + <div className="flex flex-col gap-1"> + <div className="system-md-semibold text-text-secondary"> + {t(($) => $.noModelSelected, { ns: 'appDebug' })} </div> - <div className="flex flex-col gap-1"> - <div className="system-md-semibold text-text-secondary">{t($ => $.noModelSelected, { ns: 'appDebug' })}</div> - <div className="system-xs-regular text-text-tertiary">{t($ => $.noModelSelectedTip, { ns: 'appDebug' })}</div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $.noModelSelectedTip, { ns: 'appDebug' })} </div> </div> </div> - )} - {/* Chat */} - {mode !== AppModeEnum.COMPLETION && ( - <div className="h-0 grow overflow-hidden"> - <DebugWithSingleModel - ref={debugWithSingleModelRef} - checkCanSend={checkCanSend} - /> - </div> - )} - {/* Text Generation */} - {mode === AppModeEnum.COMPLETION && ( - <> - {(completionRes || isResponding) && ( - <> - <div className="mx-4 mt-3"><GroupName name={t($ => $.result, { ns: 'appDebug' })} /></div> - <div className="mx-3 mb-8"> - <TextGeneration - appSourceType={AppSourceType.webApp} - className="mt-2" - content={completionRes} - isLoading={!completionRes && isResponding} - isShowTextToSpeech={textToSpeechConfig.enabled && !!text2speechDefaultModel} - isResponding={isResponding} - messageId={messageId} - isError={false} - onRetry={noop} - siteInfo={null} - /> - </div> - </> - )} - {!completionRes && !isResponding && ( - <div className="flex grow flex-col items-center justify-center gap-2"> - <RiSparklingFill className="size-12 text-text-empty-state-icon" /> - <div className="system-sm-regular text-text-quaternary">{t($ => $.noResult, { ns: 'appDebug' })}</div> + </div> + )} + {/* Chat */} + {mode !== AppModeEnum.COMPLETION && ( + <div className="h-0 grow overflow-hidden"> + <DebugWithSingleModel ref={debugWithSingleModelRef} checkCanSend={checkCanSend} /> + </div> + )} + {/* Text Generation */} + {mode === AppModeEnum.COMPLETION && ( + <> + {(completionRes || isResponding) && ( + <> + <div className="mx-4 mt-3"> + <GroupName name={t(($) => $.result, { ns: 'appDebug' })} /> </div> - )} - </> - )} - {mode === AppModeEnum.COMPLETION && showPromptLogModal && ( - <PromptLogModal - width={width} - currentLogItem={currentLogItem} - onCancel={() => { - setCurrentLogItem() - setShowPromptLogModal(false) - }} - /> - )} - {isShowCannotQueryDataset && ( - <CannotQueryDataset - onConfirm={() => setShowCannotQueryDataset(false)} - /> - )} - </div> - ) - } - { - isShowFormattingChangeConfirm && ( - <FormattingChanged - onConfirm={handleConfirm} - onCancel={handleCancel} - /> - ) - } + <div className="mx-3 mb-8"> + <TextGeneration + appSourceType={AppSourceType.webApp} + className="mt-2" + content={completionRes} + isLoading={!completionRes && isResponding} + isShowTextToSpeech={textToSpeechConfig.enabled && !!text2speechDefaultModel} + isResponding={isResponding} + messageId={messageId} + isError={false} + onRetry={noop} + siteInfo={null} + /> + </div> + </> + )} + {!completionRes && !isResponding && ( + <div className="flex grow flex-col items-center justify-center gap-2"> + <RiSparklingFill className="size-12 text-text-empty-state-icon" /> + <div className="system-sm-regular text-text-quaternary"> + {t(($) => $.noResult, { ns: 'appDebug' })} + </div> + </div> + )} + </> + )} + {mode === AppModeEnum.COMPLETION && showPromptLogModal && ( + <PromptLogModal + width={width} + currentLogItem={currentLogItem} + onCancel={() => { + setCurrentLogItem() + setShowPromptLogModal(false) + }} + /> + )} + {isShowCannotQueryDataset && ( + <CannotQueryDataset onConfirm={() => setShowCannotQueryDataset(false)} /> + )} + </div> + )} + {isShowFormattingChangeConfirm && ( + <FormattingChanged onConfirm={handleConfirm} onCancel={handleCancel} /> + )} </> ) } diff --git a/web/app/components/app/configuration/hooks/__tests__/use-advanced-prompt-config.spec.tsx b/web/app/components/app/configuration/hooks/__tests__/use-advanced-prompt-config.spec.tsx index e5ba1540098187..04840749482059 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-advanced-prompt-config.spec.tsx +++ b/web/app/components/app/configuration/hooks/__tests__/use-advanced-prompt-config.spec.tsx @@ -1,6 +1,11 @@ /* eslint-disable ts/no-explicit-any */ import { act, renderHook, waitFor } from '@testing-library/react' -import { CONTEXT_PLACEHOLDER_TEXT, HISTORY_PLACEHOLDER_TEXT, PRE_PROMPT_PLACEHOLDER_TEXT, QUERY_PLACEHOLDER_TEXT } from '@/app/components/base/prompt-editor/constants' +import { + CONTEXT_PLACEHOLDER_TEXT, + HISTORY_PLACEHOLDER_TEXT, + PRE_PROMPT_PLACEHOLDER_TEXT, + QUERY_PLACEHOLDER_TEXT, +} from '@/app/components/base/prompt-editor/constants' import { PromptMode, PromptRole } from '@/models/debug' import { fetchPromptTemplate } from '@/service/debug' import { AppModeEnum, ModelModeType } from '@/types/app' @@ -19,41 +24,50 @@ describe('useAdvancedPromptConfig', () => { it('should update the advanced chat prompt and mark user changes', () => { const handleUserChangedPrompt = vi.fn() - const { result } = renderHook(() => useAdvancedPromptConfig({ - appMode: AppModeEnum.CHAT, - modelModeType: ModelModeType.chat, - modelName: 'gpt-4o', - promptMode: PromptMode.advanced, - prePrompt: '', - onUserChangedPrompt: handleUserChangedPrompt, - hasSetDataSet: false, - completionParams: {}, - setCompletionParams: vi.fn(), - setStop: vi.fn(), - })) + const { result } = renderHook(() => + useAdvancedPromptConfig({ + appMode: AppModeEnum.CHAT, + modelModeType: ModelModeType.chat, + modelName: 'gpt-4o', + promptMode: PromptMode.advanced, + prePrompt: '', + onUserChangedPrompt: handleUserChangedPrompt, + hasSetDataSet: false, + completionParams: {}, + setCompletionParams: vi.fn(), + setStop: vi.fn(), + }), + ) act(() => { - result.current.setCurrentAdvancedPrompt([{ role: PromptRole.system, text: `hello ${QUERY_PLACEHOLDER_TEXT}` }], true) + result.current.setCurrentAdvancedPrompt( + [{ role: PromptRole.system, text: `hello ${QUERY_PLACEHOLDER_TEXT}` }], + true, + ) }) - expect(result.current.currentAdvancedPrompt).toEqual([{ role: PromptRole.system, text: `hello ${QUERY_PLACEHOLDER_TEXT}` }]) + expect(result.current.currentAdvancedPrompt).toEqual([ + { role: PromptRole.system, text: `hello ${QUERY_PLACEHOLDER_TEXT}` }, + ]) expect(result.current.hasSetBlockStatus.query).toBe(true) expect(handleUserChangedPrompt).toHaveBeenCalledTimes(1) }) it('should derive simple prompt block status from the pre-prompt', () => { - const { result } = renderHook(() => useAdvancedPromptConfig({ - appMode: AppModeEnum.COMPLETION, - modelModeType: ModelModeType.completion, - modelName: 'gpt-4o', - promptMode: PromptMode.simple, - prePrompt: `${CONTEXT_PLACEHOLDER_TEXT} ${QUERY_PLACEHOLDER_TEXT}`, - onUserChangedPrompt: vi.fn(), - hasSetDataSet: false, - completionParams: {}, - setCompletionParams: vi.fn(), - setStop: vi.fn(), - })) + const { result } = renderHook(() => + useAdvancedPromptConfig({ + appMode: AppModeEnum.COMPLETION, + modelModeType: ModelModeType.completion, + modelName: 'gpt-4o', + promptMode: PromptMode.simple, + prePrompt: `${CONTEXT_PLACEHOLDER_TEXT} ${QUERY_PLACEHOLDER_TEXT}`, + onUserChangedPrompt: vi.fn(), + hasSetDataSet: false, + completionParams: {}, + setCompletionParams: vi.fn(), + setStop: vi.fn(), + }), + ) expect(result.current.hasSetBlockStatus).toEqual({ context: true, @@ -64,18 +78,20 @@ describe('useAdvancedPromptConfig', () => { it('should ignore advanced prompt mutations when the prompt mode is simple', () => { const handleUserChangedPrompt = vi.fn() - const { result } = renderHook(() => useAdvancedPromptConfig({ - appMode: AppModeEnum.CHAT, - modelModeType: ModelModeType.chat, - modelName: 'gpt-4o', - promptMode: PromptMode.simple, - prePrompt: '', - onUserChangedPrompt: handleUserChangedPrompt, - hasSetDataSet: false, - completionParams: {}, - setCompletionParams: vi.fn(), - setStop: vi.fn(), - })) + const { result } = renderHook(() => + useAdvancedPromptConfig({ + appMode: AppModeEnum.CHAT, + modelModeType: ModelModeType.chat, + modelName: 'gpt-4o', + promptMode: PromptMode.simple, + prePrompt: '', + onUserChangedPrompt: handleUserChangedPrompt, + hasSetDataSet: false, + completionParams: {}, + setCompletionParams: vi.fn(), + setStop: vi.fn(), + }), + ) act(() => { result.current.setCurrentAdvancedPrompt([{ role: PromptRole.system, text: 'ignored' }], true) @@ -101,18 +117,20 @@ describe('useAdvancedPromptConfig', () => { stop: ['END'], } as any) - const { result } = renderHook(() => useAdvancedPromptConfig({ - appMode: AppModeEnum.COMPLETION, - modelModeType: ModelModeType.completion, - modelName: 'gpt-4o', - promptMode: PromptMode.simple, - prePrompt: 'custom prompt', - onUserChangedPrompt: vi.fn(), - hasSetDataSet: true, - completionParams: { temperature: 0.7 }, - setCompletionParams, - setStop: vi.fn(), - })) + const { result } = renderHook(() => + useAdvancedPromptConfig({ + appMode: AppModeEnum.COMPLETION, + modelModeType: ModelModeType.completion, + modelName: 'gpt-4o', + promptMode: PromptMode.simple, + prePrompt: 'custom prompt', + onUserChangedPrompt: vi.fn(), + hasSetDataSet: true, + completionParams: { temperature: 0.7 }, + setCompletionParams, + setStop: vi.fn(), + }), + ) await act(async () => { await result.current.migrateToDefaultPrompt() @@ -149,18 +167,20 @@ describe('useAdvancedPromptConfig', () => { stop: [], } as any) - const { result } = renderHook(() => useAdvancedPromptConfig({ - appMode: AppModeEnum.CHAT, - modelModeType: ModelModeType.chat, - modelName: 'gpt-4o', - promptMode: PromptMode.simple, - prePrompt: 'system prompt', - onUserChangedPrompt: vi.fn(), - hasSetDataSet: false, - completionParams: {}, - setCompletionParams: vi.fn(), - setStop: vi.fn(), - })) + const { result } = renderHook(() => + useAdvancedPromptConfig({ + appMode: AppModeEnum.CHAT, + modelModeType: ModelModeType.chat, + modelName: 'gpt-4o', + promptMode: PromptMode.simple, + prePrompt: 'system prompt', + onUserChangedPrompt: vi.fn(), + hasSetDataSet: false, + completionParams: {}, + setCompletionParams: vi.fn(), + setStop: vi.fn(), + }), + ) await act(async () => { await result.current.migrateToDefaultPrompt() @@ -190,18 +210,20 @@ describe('useAdvancedPromptConfig', () => { stop: ['DONE'], } as any) - const { result } = renderHook(() => useAdvancedPromptConfig({ - appMode: AppModeEnum.CHAT, - modelModeType: ModelModeType.completion, - modelName: 'gpt-4o', - promptMode: PromptMode.advanced, - prePrompt: `${HISTORY_PLACEHOLDER_TEXT} prompt`, - onUserChangedPrompt: vi.fn(), - hasSetDataSet: false, - completionParams: {}, - setCompletionParams, - setStop, - })) + const { result } = renderHook(() => + useAdvancedPromptConfig({ + appMode: AppModeEnum.CHAT, + modelModeType: ModelModeType.completion, + modelName: 'gpt-4o', + promptMode: PromptMode.advanced, + prePrompt: `${HISTORY_PLACEHOLDER_TEXT} prompt`, + onUserChangedPrompt: vi.fn(), + hasSetDataSet: false, + completionParams: {}, + setCompletionParams, + setStop, + }), + ) act(() => { result.current.setCurrentAdvancedPrompt({ @@ -217,7 +239,9 @@ describe('useAdvancedPromptConfig', () => { await result.current.migrateToDefaultPrompt(true, ModelModeType.completion) }) - expect(result.current.completionPromptConfig.prompt.text).toBe(`history ${HISTORY_PLACEHOLDER_TEXT} prompt`) + expect(result.current.completionPromptConfig.prompt.text).toBe( + `history ${HISTORY_PLACEHOLDER_TEXT} prompt`, + ) expect(result.current.completionPromptConfig.conversation_histories_role).toEqual({ user_prefix: 'me:', assistant_prefix: 'bot:', @@ -243,18 +267,20 @@ describe('useAdvancedPromptConfig', () => { stop: [], } as any) - const { result } = renderHook(() => useAdvancedPromptConfig({ - appMode: AppModeEnum.COMPLETION, - modelModeType: ModelModeType.completion, - modelName: 'gpt-4o', - promptMode: PromptMode.advanced, - prePrompt: 'converted prompt', - onUserChangedPrompt: vi.fn(), - hasSetDataSet: false, - completionParams: { stop: ['KEEP'] }, - setCompletionParams: vi.fn(), - setStop: vi.fn(), - })) + const { result } = renderHook(() => + useAdvancedPromptConfig({ + appMode: AppModeEnum.COMPLETION, + modelModeType: ModelModeType.completion, + modelName: 'gpt-4o', + promptMode: PromptMode.advanced, + prePrompt: 'converted prompt', + onUserChangedPrompt: vi.fn(), + hasSetDataSet: false, + completionParams: { stop: ['KEEP'] }, + setCompletionParams: vi.fn(), + setStop: vi.fn(), + }), + ) await act(async () => { await result.current.migrateToDefaultPrompt(true, ModelModeType.chat) @@ -266,18 +292,20 @@ describe('useAdvancedPromptConfig', () => { }) it('should exit early when no app mode is provided', async () => { - const { result } = renderHook(() => useAdvancedPromptConfig({ - appMode: undefined, - modelModeType: ModelModeType.chat, - modelName: 'gpt-4o', - promptMode: PromptMode.simple, - prePrompt: '', - onUserChangedPrompt: vi.fn(), - hasSetDataSet: false, - completionParams: {}, - setCompletionParams: vi.fn(), - setStop: vi.fn(), - })) + const { result } = renderHook(() => + useAdvancedPromptConfig({ + appMode: undefined, + modelModeType: ModelModeType.chat, + modelName: 'gpt-4o', + promptMode: PromptMode.simple, + prePrompt: '', + onUserChangedPrompt: vi.fn(), + hasSetDataSet: false, + completionParams: {}, + setCompletionParams: vi.fn(), + setStop: vi.fn(), + }), + ) await act(async () => { await result.current.migrateToDefaultPrompt() diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts b/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts index d13ea710b19d6d..df85089cebc59e 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts @@ -1,7 +1,14 @@ /* eslint-disable ts/no-explicit-any */ import type { VisionSettings } from '@/types/app' import { withSelectorKey } from '@/test/i18n-mock' -import { AgentStrategy, AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } from '@/types/app' +import { + AgentStrategy, + AppModeEnum, + ModelModeType, + Resolution, + RETRIEVE_TYPE, + TransferMethod, +} from '@/types/app' import { buildConfigurationDatasetConfigs, buildPublishBody, @@ -42,11 +49,14 @@ vi.mock('@/service/tools', () => ({ })) vi.mock('@/utils/completion-params', () => ({ - fetchAndMergeValidCompletionParams: (...args: unknown[]) => mockFetchAndMergeValidCompletionParams(...args), + fetchAndMergeValidCompletionParams: (...args: unknown[]) => + mockFetchAndMergeValidCompletionParams(...args), })) vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/utils', async () => { - const actual = await vi.importActual<any>('@/app/components/workflow/nodes/knowledge-retrieval/utils') + const actual = await vi.importActual<any>( + '@/app/components/workflow/nodes/knowledge-retrieval/utils', + ) return { ...actual, @@ -149,21 +159,25 @@ describe('useConfiguration utils', () => { }) expect(publishedConfig.completionParams).toEqual({ temperature: 0.7 }) - expect(publishedConfig.modelConfig).toEqual(expect.objectContaining({ - dataSets: [{ id: 'dataset-1' }], - mode: ModelModeType.chat, - model_id: 'gpt-4o', - more_like_this: { enabled: true }, - opening_statement: 'hello', - provider: 'langgenius/openai/openai', - suggested_questions: ['how are you?'], - })) + expect(publishedConfig.modelConfig).toEqual( + expect.objectContaining({ + dataSets: [{ id: 'dataset-1' }], + mode: ModelModeType.chat, + model_id: 'gpt-4o', + more_like_this: { enabled: true }, + opening_statement: 'hello', + provider: 'langgenius/openai/openai', + suggested_questions: ['how are you?'], + }), + ) expect(publishedConfig.modelConfig.configs.prompt_variables).toHaveLength(2) - expect(publishedConfig.modelConfig.agentConfig.tools[0]).toEqual(expect.objectContaining({ - isDeleted: true, - notAuthor: true, - tool_name: 'search', - })) + expect(publishedConfig.modelConfig.agentConfig.tools[0]).toEqual( + expect.objectContaining({ + isDeleted: true, + notAuthor: true, + tool_name: 'search', + }), + ) }) it('should build dataset configs with reranking defaults', () => { @@ -183,9 +197,11 @@ describe('useConfiguration utils', () => { }) expect(datasetConfigs.retrieval_model).toBe(RETRIEVE_TYPE.multiWay) - expect(datasetConfigs.reranking_model).toEqual(expect.objectContaining({ - reranking_model_name: 'rerank-1', - })) + expect(datasetConfigs.reranking_model).toEqual( + expect.objectContaining({ + reranking_model_name: 'rerank-1', + }), + ) }) it('should build a publish body for advanced prompts and dataset selections', () => { @@ -208,7 +224,11 @@ describe('useConfiguration utils', () => { externalDataToolsConfig: [], features: { moreLikeThis: { enabled: true }, - opening: { enabled: true, opening_statement: 'hello', suggested_questions: ['how are you?'] }, + opening: { + enabled: true, + opening_statement: 'hello', + suggested_questions: ['how are you?'], + }, moderation: { enabled: false }, speech2text: { enabled: false }, text2speech: { enabled: false, voice: '', language: '' }, @@ -250,24 +270,28 @@ describe('useConfiguration utils', () => { resolvedModelModeType: ModelModeType.chat, }) - expect(body).toEqual(expect.objectContaining({ - chat_prompt_config: { prompt: [{ role: 'system', text: 'hi' }] }, - dataset_query_variable: 'context', - opening_statement: 'hello', - pre_prompt: '', - prompt_type: 'advanced', - suggested_questions: ['how are you?'], - })) + expect(body).toEqual( + expect.objectContaining({ + chat_prompt_config: { prompt: [{ role: 'system', text: 'hi' }] }, + dataset_query_variable: 'context', + opening_statement: 'hello', + pre_prompt: '', + prompt_type: 'advanced', + suggested_questions: ['how are you?'], + }), + ) expect(body.agent_mode?.strategy).toBe(AgentStrategy.functionCall) expect(body.dataset_configs?.datasets?.datasets).toEqual([ { dataset: { enabled: true, id: 'dataset-1' } }, ]) - expect(body.model).toEqual(expect.objectContaining({ - completion_params: { temperature: 0.7 }, - mode: ModelModeType.chat, - name: 'gpt-4o', - provider: 'langgenius/openai/openai', - })) + expect(body.model).toEqual( + expect.objectContaining({ + completion_params: { temperature: 0.7 }, + mode: ModelModeType.chat, + name: 'gpt-4o', + provider: 'langgenius/openai/openai', + }), + ) }) it('should load and normalize the initial configuration state', async () => { @@ -350,12 +374,14 @@ describe('useConfiguration utils', () => { expect(state.collectionList[0]!.icon).toBe('/console/tool.svg') expect(state.promptMode).toBe('advanced') expect(state.nextDataSets).toEqual([{ id: 'dataset-1', name: 'Dataset One' }]) - expect(state.annotationConfig).toEqual(expect.objectContaining({ - enabled: true, - embedding_model: expect.objectContaining({ - embedding_provider_name: 'langgenius/openai/openai', + expect(state.annotationConfig).toEqual( + expect.objectContaining({ + enabled: true, + embedding_model: expect.objectContaining({ + embedding_provider_name: 'langgenius/openai/openai', + }), }), - })) + ) expect(state.publishedConfig.modelConfig.model_id).toBe('gpt-4o') }) @@ -426,9 +452,11 @@ describe('useConfiguration utils', () => { }, }) expect(state.nextDataSets).toEqual([{ id: 'dataset-from-tool', name: 'Dataset From Tool' }]) - expect(state.annotationConfig).toEqual(expect.objectContaining({ - enabled: false, - })) + expect(state.annotationConfig).toEqual( + expect.objectContaining({ + enabled: false, + }), + ) expect(state.chatPromptConfig).toEqual(expect.any(Object)) }) @@ -550,10 +578,7 @@ describe('useConfiguration utils', () => { setRerankSettingModalOpen: vi.fn(), }) - handleSelect([ - { id: 'dataset-1' }, - { id: 'dataset-2', name: 'Dataset Two' }, - ] as any) + handleSelect([{ id: 'dataset-1' }, { id: 'dataset-2', name: 'Dataset Two' }] as any) expect(setDataSets).toHaveBeenCalledWith([ { id: 'dataset-1', name: 'Dataset One' }, @@ -718,14 +743,16 @@ describe('useConfiguration utils', () => { } as any) expect(result).toBe(true) - expect(mockUpdateAppModelConfig).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ - agent_mode: expect.objectContaining({ - strategy: AgentStrategy.functionCall, + expect(mockUpdateAppModelConfig).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_mode: expect.objectContaining({ + strategy: AgentStrategy.functionCall, + }), }), + url: '/apps/app-1/model-config', }), - url: '/apps/app-1/model-config', - })) + ) expect(setPublishedConfig).toHaveBeenCalledTimes(1) expect(mockToastSuccess).toHaveBeenCalledWith('api.success') expect(setCanReturnToSimpleMode).toHaveBeenCalledWith(false) @@ -733,68 +760,76 @@ describe('useConfiguration utils', () => { it('should block publish when required prompt sections are missing', async () => { const mockUpdateAppModelConfig = vi.fn() - const createBasePublishHandler = (overrides: Record<string, unknown>) => createPublishHandler({ - appId: 'app-1', - chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] } as any, - citationConfig: { enabled: false } as any, - completionParamsState: { temperature: 0.7 }, - completionPromptConfig: { - prompt: { text: 'completion' }, - conversation_histories_role: { - assistant_prefix: 'assistant', - user_prefix: 'user', - }, - } as any, - contextVar: 'context', - contextVarEmpty: false, - dataSets: [] as any, - datasetConfigs: { datasets: { datasets: [] } } as any, - externalDataToolsConfig: [], - hasSetBlockStatus: { - history: true, - query: true, - }, - introduction: 'hello', - isAdvancedMode: true, - isFunctionCall: false, - mode: AppModeEnum.CHAT, - modelConfig: { - configs: { - prompt_template: 'hello', - prompt_variables: [], - }, - model_id: 'gpt-4o', - provider: 'langgenius/openai/openai', - system_parameters: { - audio_file_size_limit: 1, - file_size_limit: 1, - image_file_size_limit: 1, - video_file_size_limit: 1, - workflow_file_upload_limit: 1, - }, - } as any, - moreLikeThisConfig: { enabled: false }, - promptEmpty: false, - promptMode: 'advanced' as any, - resolvedModelModeType: ModelModeType.completion, - setCanReturnToSimpleMode: vi.fn(), - setPublishedConfig: vi.fn(), - speechToTextConfig: { enabled: false } as any, - suggestedQuestionsAfterAnswerConfig: { enabled: false } as any, - t, - textToSpeechConfig: { enabled: false, voice: '', language: '' } as any, - ...overrides, - }) + const createBasePublishHandler = (overrides: Record<string, unknown>) => + createPublishHandler({ + appId: 'app-1', + chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] } as any, + citationConfig: { enabled: false } as any, + completionParamsState: { temperature: 0.7 }, + completionPromptConfig: { + prompt: { text: 'completion' }, + conversation_histories_role: { + assistant_prefix: 'assistant', + user_prefix: 'user', + }, + } as any, + contextVar: 'context', + contextVarEmpty: false, + dataSets: [] as any, + datasetConfigs: { datasets: { datasets: [] } } as any, + externalDataToolsConfig: [], + hasSetBlockStatus: { + history: true, + query: true, + }, + introduction: 'hello', + isAdvancedMode: true, + isFunctionCall: false, + mode: AppModeEnum.CHAT, + modelConfig: { + configs: { + prompt_template: 'hello', + prompt_variables: [], + }, + model_id: 'gpt-4o', + provider: 'langgenius/openai/openai', + system_parameters: { + audio_file_size_limit: 1, + file_size_limit: 1, + image_file_size_limit: 1, + video_file_size_limit: 1, + workflow_file_upload_limit: 1, + }, + } as any, + moreLikeThisConfig: { enabled: false }, + promptEmpty: false, + promptMode: 'advanced' as any, + resolvedModelModeType: ModelModeType.completion, + setCanReturnToSimpleMode: vi.fn(), + setPublishedConfig: vi.fn(), + speechToTextConfig: { enabled: false } as any, + suggestedQuestionsAfterAnswerConfig: { enabled: false } as any, + t, + textToSpeechConfig: { enabled: false, voice: '', language: '' } as any, + ...overrides, + }) await createBasePublishHandler({ promptEmpty: true })(mockUpdateAppModelConfig) - await createBasePublishHandler({ hasSetBlockStatus: { history: false, query: true } })(mockUpdateAppModelConfig) - await createBasePublishHandler({ hasSetBlockStatus: { history: true, query: false } })(mockUpdateAppModelConfig) + await createBasePublishHandler({ hasSetBlockStatus: { history: false, query: true } })( + mockUpdateAppModelConfig, + ) + await createBasePublishHandler({ hasSetBlockStatus: { history: true, query: false } })( + mockUpdateAppModelConfig, + ) await createBasePublishHandler({ contextVarEmpty: true })(mockUpdateAppModelConfig) expect(mockToastError).toHaveBeenNthCalledWith(1, 'otherError.promptNoBeEmpty') expect(mockToastError).toHaveBeenNthCalledWith(2, 'otherError.historyNoBeEmpty') expect(mockToastError).toHaveBeenNthCalledWith(3, 'otherError.queryNoBeEmpty') - expect(mockToastError).toHaveBeenNthCalledWith(4, 'feature.dataSet.queryVariable.contextVarNotEmpty') + expect(mockToastError).toHaveBeenNthCalledWith( + 4, + 'feature.dataSet.queryVariable.contextVarNotEmpty', + ) expect(mockUpdateAppModelConfig).not.toHaveBeenCalled() }) @@ -843,10 +878,13 @@ describe('useConfiguration utils', () => { expect(migrateToDefaultPrompt).toHaveBeenCalledWith(true, ModelModeType.completion) expect(setModelConfig).toHaveBeenCalledTimes(1) - expect(handleSetVisionConfig).toHaveBeenCalledWith({ - ...baseVisionConfig, - enabled: true, - }, true) + expect(handleSetVisionConfig).toHaveBeenCalledWith( + { + ...baseVisionConfig, + enabled: true, + }, + true, + ) expect(setCompletionParams).toHaveBeenCalledWith({ temperature: 0.3 }) }) @@ -903,7 +941,9 @@ describe('useConfiguration utils', () => { expect(migrateToDefaultPrompt).toHaveBeenCalledWith(true, ModelModeType.completion) expect(migrateToDefaultPrompt).toHaveBeenCalledWith(true, ModelModeType.chat) - expect(mockToastWarning).toHaveBeenCalledWith('modelProvider.parametersInvalidRemoved: top_k (unsupported)') + expect(mockToastWarning).toHaveBeenCalledWith( + 'modelProvider.parametersInvalidRemoved: top_k (unsupported)', + ) expect(mockToastError).toHaveBeenCalledWith('error') expect(setCompletionParams).toHaveBeenCalledWith({}) }) diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx index d9cf48c8880c89..0a8edf49eec1c7 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx @@ -96,7 +96,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -114,18 +115,19 @@ vi.mock('@/context/provider-context', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({ - appDetail: { - id: 'app-1', - model_config: { - updated_at: 1710000000, + useStore: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + appDetail: { + id: 'app-1', + model_config: { + updated_at: 1710000000, + }, + mode: AppModeEnum.CHAT, + permission_keys: mockAppPermissionKeys, }, - mode: AppModeEnum.CHAT, - permission_keys: mockAppPermissionKeys, - }, - showAppConfigureFeaturesModal: false, - setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal, - }), + showAppConfigureFeaturesModal: false, + setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal, + }), })) vi.mock('@/app/components/detail-sidebar/storage', () => ({ @@ -215,7 +217,8 @@ vi.mock('@/service/datasets', () => ({ })) vi.mock('@/utils/completion-params', () => ({ - fetchAndMergeValidCompletionParams: (...args: unknown[]) => mockFetchAndMergeValidCompletionParams(...args), + fetchAndMergeValidCompletionParams: (...args: unknown[]) => + mockFetchAndMergeValidCompletionParams(...args), })) describe('useConfiguration', () => { @@ -323,9 +326,11 @@ describe('useConfiguration', () => { await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData) }) - expect(updateAppModelConfig).toHaveBeenCalledWith(expect.objectContaining({ - url: '/apps/app-1/model-config', - })) + expect(updateAppModelConfig).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/apps/app-1/model-config', + }), + ) }) it('should block publishing when app release permission is missing', async () => { @@ -409,9 +414,11 @@ describe('useConfiguration', () => { await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData) }) - expect(updateAppModelConfig).toHaveBeenCalledWith(expect.objectContaining({ - url: '/apps/app-1/model-config', - })) + expect(updateAppModelConfig).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/apps/app-1/model-config', + }), + ) }) it('should expose derived feature flags and imperative callbacks', async () => { @@ -492,8 +499,15 @@ describe('useConfiguration', () => { act(() => { result.current.onFeaturesChange(undefined as never) result.current.onFeaturesChange({ moreLikeThis: { enabled: true } } as never) - result.current.onAutoAddPromptVariable([{ key: 'city', name: 'City', type: 'string', required: true } as never]) - result.current.onAgentSettingChange({ enabled: true, max_iteration: 5, strategy: 'react', tools: [] } as never) + result.current.onAutoAddPromptVariable([ + { key: 'city', name: 'City', type: 'string', required: true } as never, + ]) + result.current.onAgentSettingChange({ + enabled: true, + max_iteration: 5, + strategy: 'react', + tools: [], + } as never) result.current.onEnableMultipleModelDebug() result.current.setShowUseGPT4Confirm(true) result.current.onConfirmUseGPT4() diff --git a/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts b/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts index 55b44653c9dae6..177347b386ad88 100644 --- a/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts +++ b/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts @@ -1,9 +1,19 @@ import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { ChatPromptConfig, CompletionPromptConfig, ConversationHistoriesRole, PromptItem } from '@/models/debug' +import type { + ChatPromptConfig, + CompletionPromptConfig, + ConversationHistoriesRole, + PromptItem, +} from '@/models/debug' import { clone } from 'es-toolkit/object' import { produce } from 'immer' import { useState } from 'react' -import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock, PRE_PROMPT_PLACEHOLDER_TEXT } from '@/app/components/base/prompt-editor/constants' +import { + checkHasContextBlock, + checkHasHistoryBlock, + checkHasQueryBlock, + PRE_PROMPT_PLACEHOLDER_TEXT, +} from '@/app/components/base/prompt-editor/constants' import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' import { PromptMode } from '@/models/debug' import { fetchPromptTemplate } from '@/service/debug' @@ -35,34 +45,36 @@ const useAdvancedPromptConfig = ({ setStop, }: Param) => { const isAdvancedPrompt = promptMode === PromptMode.advanced - const [chatPromptConfig, setChatPromptConfig] = useState<ChatPromptConfig>(() => clone(DEFAULT_CHAT_PROMPT_CONFIG)) - const [completionPromptConfig, setCompletionPromptConfig] = useState<CompletionPromptConfig>(() => clone(DEFAULT_COMPLETION_PROMPT_CONFIG)) + const [chatPromptConfig, setChatPromptConfig] = useState<ChatPromptConfig>(() => + clone(DEFAULT_CHAT_PROMPT_CONFIG), + ) + const [completionPromptConfig, setCompletionPromptConfig] = useState<CompletionPromptConfig>(() => + clone(DEFAULT_COMPLETION_PROMPT_CONFIG), + ) const currentAdvancedPrompt = (() => { - if (!isAdvancedPrompt) - return [] + if (!isAdvancedPrompt) return [] - return (modelModeType === ModelModeType.chat) ? chatPromptConfig.prompt : completionPromptConfig.prompt + return modelModeType === ModelModeType.chat + ? chatPromptConfig.prompt + : completionPromptConfig.prompt })() const setCurrentAdvancedPrompt = (prompt: PromptItem | PromptItem[], isUserChanged?: boolean) => { - if (!isAdvancedPrompt) - return + if (!isAdvancedPrompt) return if (modelModeType === ModelModeType.chat) { setChatPromptConfig({ ...chatPromptConfig, prompt: prompt as PromptItem[], }) - } - else { + } else { setCompletionPromptConfig({ ...completionPromptConfig, prompt: prompt as PromptItem, }) } - if (isUserChanged) - onUserChangedPrompt() + if (isUserChanged) onUserChangedPrompt() } const setConversationHistoriesRole = (conversationHistoriesRole: ConversationHistoriesRole) => { @@ -82,12 +94,11 @@ const useAdvancedPromptConfig = ({ } if (modelModeType === ModelModeType.chat) { return { - context: !!chatPromptConfig.prompt.find(p => checkHasContextBlock(p.text)), + context: !!chatPromptConfig.prompt.find((p) => checkHasContextBlock(p.text)), history: false, - query: !!chatPromptConfig.prompt.find(p => checkHasQueryBlock(p.text)), + query: !!chatPromptConfig.prompt.find((p) => checkHasQueryBlock(p.text)), } - } - else { + } else { const prompt = completionPromptConfig.prompt?.text return { context: checkHasContextBlock(prompt), @@ -98,14 +109,16 @@ const useAdvancedPromptConfig = ({ })() /* prompt: simple to advanced process, or chat model to completion model - * 1. migrate prompt - * 2. change promptMode to advanced - */ - const migrateToDefaultPrompt = async (isMigrateToCompetition?: boolean, toModelModeType?: ModelModeType) => { + * 1. migrate prompt + * 2. change promptMode to advanced + */ + const migrateToDefaultPrompt = async ( + isMigrateToCompetition?: boolean, + toModelModeType?: ModelModeType, + ) => { const mode = modelModeType const toReplacePrePrompt = prePrompt || '' - if (!appMode) - return + if (!appMode) return if (!isAdvancedPrompt) { const { chat_prompt_config, completion_prompt_config, stop } = await fetchPromptTemplate({ @@ -124,10 +137,12 @@ const useAdvancedPromptConfig = ({ }) }) setChatPromptConfig(newPromptConfig) - } - else { + } else { const newPromptConfig = produce(completion_prompt_config, (draft) => { - draft.prompt.text = draft.prompt.text.replace(PRE_PROMPT_PLACEHOLDER_TEXT, toReplacePrePrompt) + draft.prompt.text = draft.prompt.text.replace( + PRE_PROMPT_PLACEHOLDER_TEXT, + toReplacePrePrompt, + ) }) setCompletionPromptConfig(newPromptConfig) setCompletionParams({ @@ -149,12 +164,23 @@ const useAdvancedPromptConfig = ({ if (toModelModeType === ModelModeType.completion) { const newPromptConfig = produce(completion_prompt_config, (draft) => { if (!completionPromptConfig.prompt?.text) - draft.prompt.text = draft.prompt.text.replace(PRE_PROMPT_PLACEHOLDER_TEXT, toReplacePrePrompt) - + draft.prompt.text = draft.prompt.text.replace( + PRE_PROMPT_PLACEHOLDER_TEXT, + toReplacePrePrompt, + ) else - draft.prompt.text = completionPromptConfig.prompt?.text.replace(PRE_PROMPT_PLACEHOLDER_TEXT, toReplacePrePrompt) - - if ([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes(appMode) && completionPromptConfig.conversation_histories_role.assistant_prefix && completionPromptConfig.conversation_histories_role.user_prefix) + draft.prompt.text = completionPromptConfig.prompt?.text.replace( + PRE_PROMPT_PLACEHOLDER_TEXT, + toReplacePrePrompt, + ) + + if ( + [AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes( + appMode, + ) && + completionPromptConfig.conversation_histories_role.assistant_prefix && + completionPromptConfig.conversation_histories_role.user_prefix + ) draft.conversation_histories_role = completionPromptConfig.conversation_histories_role }) setCompletionPromptConfig(newPromptConfig) @@ -165,8 +191,7 @@ const useAdvancedPromptConfig = ({ }) } setStop(stop) // switch mode's params is async. It may override the stop value. - } - else { + } else { const newPromptConfig = produce(chat_prompt_config, (draft) => { draft.prompt = draft.prompt.map((p) => { return { diff --git a/web/app/components/app/configuration/hooks/use-configuration-utils.ts b/web/app/components/app/configuration/hooks/use-configuration-utils.ts index 4a1868cf6c1840..ef6388e0cf61b0 100644 --- a/web/app/components/app/configuration/hooks/use-configuration-utils.ts +++ b/web/app/components/app/configuration/hooks/use-configuration-utils.ts @@ -3,24 +3,40 @@ import type { Features as FeaturesData } from '@/app/components/base/features/ty import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Collection } from '@/app/components/tools/types' import type { DataSet } from '@/models/datasets' -import type { AnnotationReplyConfig, DatasetConfigs, ModelConfig, PromptVariable } from '@/models/debug' -import type { ModelConfig as BackendModelConfig, UserInputFormItem, VisionSettings } from '@/types/app' +import type { + AnnotationReplyConfig, + DatasetConfigs, + ModelConfig, + PromptVariable, +} from '@/models/debug' +import type { + ModelConfig as BackendModelConfig, + UserInputFormItem, + VisionSettings, +} from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' import { clone } from 'es-toolkit/object' import { produce } from 'immer' -import { getMultipleRetrievalConfig, getSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/utils' -import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' +import { + getMultipleRetrievalConfig, + getSelectedDatasetsMode, +} from '@/app/components/workflow/nodes/knowledge-retrieval/utils' +import { + DEFAULT_AGENT_SETTING, + DEFAULT_CHAT_PROMPT_CONFIG, + DEFAULT_COMPLETION_PROMPT_CONFIG, +} from '@/config' import { PromptMode } from '@/models/debug' import { fetchAppDetailDirect } from '@/service/apps' import { fetchDatasets } from '@/service/datasets' import { fetchCollectionList } from '@/service/tools' import { AgentStrategy, AppModeEnum, ModelModeType, RETRIEVE_TYPE } from '@/types/app' -import { - correctModelProvider, - correctToolProvider, -} from '@/utils' +import { correctModelProvider, correctToolProvider } from '@/utils' import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params' -import { promptVariablesToUserInputsForm, userInputsFormToPromptVariables } from '@/utils/model-config' +import { + promptVariablesToUserInputsForm, + userInputsFormToPromptVariables, +} from '@/utils/model-config' import { getStringSelectorTranslate, withCollectionIconBasePath } from '../utils' type BackendAgentTool = ModelConfig['agentConfig']['tools'][number] & { @@ -63,25 +79,23 @@ const buildPublishedModelConfig = ({ configs: { prompt_template: backendModelConfig.pre_prompt || '', prompt_variables: userInputsFormToPromptVariables( - ([ + [ ...backendModelConfig.user_input_form, - ...( - backendModelConfig.external_data_tools?.length - ? backendModelConfig.external_data_tools.map(item => ({ - external_data_tool: { - variable: item.variable as string, - label: item.label as string, - enabled: !!item.enabled, - type: item.type as string, - config: item.config, - required: true, - icon: item.icon, - icon_background: item.icon_background, - }, - })) - : [] - ), - ]) as unknown as UserInputFormItem[], + ...(backendModelConfig.external_data_tools?.length + ? backendModelConfig.external_data_tools.map((item) => ({ + external_data_tool: { + variable: item.variable as string, + label: item.label as string, + enabled: !!item.enabled, + type: item.type as string, + config: item.config, + required: true, + icon: item.icon, + icon_background: item.icon_background, + }, + })) + : []), + ] as unknown as UserInputFormItem[], backendModelConfig.dataset_query_variable, ), }, @@ -92,34 +106,52 @@ const buildPublishedModelConfig = ({ speech_to_text: backendModelConfig.speech_to_text, text_to_speech: backendModelConfig.text_to_speech, file_upload: backendModelConfig.file_upload ?? null, - suggested_questions_after_answer: backendModelConfig.suggested_questions_after_answer ?? { enabled: false }, + suggested_questions_after_answer: backendModelConfig.suggested_questions_after_answer ?? { + enabled: false, + }, retriever_resource: backendModelConfig.retriever_resource, annotation_reply: backendModelConfig.annotation_reply ?? null, external_data_tools: backendModelConfig.external_data_tools ?? [], system_parameters: backendModelConfig.system_parameters, dataSets: nextDataSets, - agentConfig: mode === AppModeEnum.AGENT_CHAT - ? { - max_iteration: DEFAULT_AGENT_SETTING.max_iteration, - ...backendModelConfig.agent_mode, - enabled: true, - tools: agentModeTools.filter(tool => !tool.dataset).map((tool) => { - const toolInCollectionList = collectionList.find(collection => collection.id === tool.provider_id) - return { - ...tool, - isDeleted: deletedTools?.some(deletedTool => (deletedTool.provider_id || deletedTool.id) === tool.provider_id && deletedTool.tool_name === tool.tool_name) ?? false, - notAuthor: toolInCollectionList?.is_team_authorization === false, - ...(tool.provider_type === 'builtin' - ? { - provider_id: correctToolProvider(tool.provider_name, !!toolInCollectionList), - provider_name: correctToolProvider(tool.provider_name, !!toolInCollectionList), - } - : {}), - } - }) as ModelConfig['agentConfig']['tools'], - strategy: backendModelConfig.agent_mode?.strategy ?? AgentStrategy.react, - } - : DEFAULT_AGENT_SETTING, + agentConfig: + mode === AppModeEnum.AGENT_CHAT + ? { + max_iteration: DEFAULT_AGENT_SETTING.max_iteration, + ...backendModelConfig.agent_mode, + enabled: true, + tools: agentModeTools + .filter((tool) => !tool.dataset) + .map((tool) => { + const toolInCollectionList = collectionList.find( + (collection) => collection.id === tool.provider_id, + ) + return { + ...tool, + isDeleted: + deletedTools?.some( + (deletedTool) => + (deletedTool.provider_id || deletedTool.id) === tool.provider_id && + deletedTool.tool_name === tool.tool_name, + ) ?? false, + notAuthor: toolInCollectionList?.is_team_authorization === false, + ...(tool.provider_type === 'builtin' + ? { + provider_id: correctToolProvider( + tool.provider_name, + !!toolInCollectionList, + ), + provider_name: correctToolProvider( + tool.provider_name, + !!toolInCollectionList, + ), + } + : {}), + } + }) as ModelConfig['agentConfig']['tools'], + strategy: backendModelConfig.agent_mode?.strategy ?? AgentStrategy.react, + } + : DEFAULT_AGENT_SETTING, } } @@ -157,16 +189,21 @@ export const buildConfigurationDatasetConfigs = ({ currentRerankProvider?: string nextDataSets: DataSet[] }): DatasetConfigs => { - const retrievalConfig = getMultipleRetrievalConfig({ - ...backendModelConfig.dataset_configs, - reranking_model: backendModelConfig.dataset_configs.reranking_model && { - provider: backendModelConfig.dataset_configs.reranking_model.reranking_provider_name, - model: backendModelConfig.dataset_configs.reranking_model.reranking_model_name, + const retrievalConfig = getMultipleRetrievalConfig( + { + ...backendModelConfig.dataset_configs, + reranking_model: backendModelConfig.dataset_configs.reranking_model && { + provider: backendModelConfig.dataset_configs.reranking_model.reranking_provider_name, + model: backendModelConfig.dataset_configs.reranking_model.reranking_model_name, + }, + }, + nextDataSets, + nextDataSets, + { + provider: currentRerankProvider, + model: currentRerankModel, }, - }, nextDataSets, nextDataSets, { - provider: currentRerankProvider, - model: currentRerankModel, - }) + ) const nextDatasetConfigs = { ...backendModelConfig.dataset_configs, @@ -237,12 +274,16 @@ export const buildPublishBody = ({ pre_prompt: !isAdvancedMode ? promptTemplate : '', prompt_type: promptMode, chat_prompt_config: isAdvancedMode ? chatPromptConfig : clone(DEFAULT_CHAT_PROMPT_CONFIG), - completion_prompt_config: isAdvancedMode ? completionPromptConfig : clone(DEFAULT_COMPLETION_PROMPT_CONFIG), + completion_prompt_config: isAdvancedMode + ? completionPromptConfig + : clone(DEFAULT_COMPLETION_PROMPT_CONFIG), user_input_form: promptVariablesToUserInputsForm(promptVariables), dataset_query_variable: contextVar || '', more_like_this: features?.moreLikeThis as never, - opening_statement: features?.opening?.enabled ? (features.opening?.opening_statement || '') : '', - suggested_questions: features?.opening?.enabled ? (features.opening?.suggested_questions || []) : [], + opening_statement: features?.opening?.enabled ? features.opening?.opening_statement || '' : '', + suggested_questions: features?.opening?.enabled + ? features.opening?.suggested_questions || [] + : [], sensitive_word_avoidance: features?.moderation as never, speech_to_text: features?.speech2text as never, text_to_speech: features?.text2speech as never, @@ -271,17 +312,17 @@ export const buildPublishBody = ({ } const normalizeAnnotationConfig = (annotationReply?: BackendModelConfig['annotation_reply']) => { - if (!annotationReply) - return undefined + if (!annotationReply) return undefined - if (!annotationReply.enabled) - return annotationReply as AnnotationReplyConfig + if (!annotationReply.enabled) return annotationReply as AnnotationReplyConfig return { ...annotationReply, embedding_model: { ...annotationReply.embedding_model, - embedding_provider_name: correctModelProvider(annotationReply.embedding_model.embedding_provider_name), + embedding_provider_name: correctModelProvider( + annotationReply.embedding_model.embedding_provider_name, + ), }, } as AnnotationReplyConfig } @@ -300,19 +341,22 @@ export const loadConfigurationState = async ({ const collectionList = withCollectionIconBasePath(await fetchCollectionList(), basePath) const response = await fetchAppDetailDirect({ url: '/apps', id: appId }) const backendModelConfig = response.model_config as BackendModelConfig - const nextPromptMode = backendModelConfig.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple + const nextPromptMode = + backendModelConfig.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple let nextDataSets: DataSet[] = [] - const agentModeTools = (backendModelConfig.agent_mode?.tools ?? []) as Array<{ dataset?: { enabled: boolean, id: string } }> + const agentModeTools = (backendModelConfig.agent_mode?.tools ?? []) as Array<{ + dataset?: { enabled: boolean; id: string } + }> - if (agentModeTools.find(tool => tool.dataset?.enabled)) + if (agentModeTools.find((tool) => tool.dataset?.enabled)) nextDataSets = agentModeTools as unknown as DataSet[] else if (backendModelConfig.dataset_configs.datasets?.datasets?.length) nextDataSets = backendModelConfig.dataset_configs.datasets.datasets as unknown as DataSet[] if (nextDataSets.length) { const datasetIds = (nextDataSets as Array<DataSet & { dataset?: { id: string } }>) - .map(item => item.dataset?.id || item.id) + .map((item) => item.dataset?.id || item.id) .filter((id): id is string => Boolean(id)) const { data } = await fetchDatasets({ @@ -330,7 +374,8 @@ export const loadConfigurationState = async ({ backendModelConfig, canReturnToSimpleMode: nextPromptMode !== PromptMode.advanced, collectionList, - completionPromptConfig: backendModelConfig.completion_prompt_config || clone(DEFAULT_COMPLETION_PROMPT_CONFIG), + completionPromptConfig: + backendModelConfig.completion_prompt_config || clone(DEFAULT_COMPLETION_PROMPT_CONFIG), datasetConfigs: buildConfigurationDatasetConfigs({ backendModelConfig, currentRerankModel, @@ -352,7 +397,9 @@ export const loadConfigurationState = async ({ response, speechToTextConfig: backendModelConfig.speech_to_text || { enabled: false }, suggestedQuestions: backendModelConfig.suggested_questions || [], - suggestedQuestionsAfterAnswerConfig: backendModelConfig.suggested_questions_after_answer || { enabled: false }, + suggestedQuestionsAfterAnswerConfig: backendModelConfig.suggested_questions_after_answer || { + enabled: false, + }, textToSpeechConfig: backendModelConfig.text_to_speech || { enabled: false, voice: '', @@ -360,332 +407,364 @@ export const loadConfigurationState = async ({ }, visionConfig: backendModelConfig.file_upload?.image, citationConfig: backendModelConfig.retriever_resource || { enabled: false }, - chatPromptConfig: backendModelConfig.chat_prompt_config && backendModelConfig.chat_prompt_config.prompt?.length > 0 - ? backendModelConfig.chat_prompt_config - : clone(DEFAULT_CHAT_PROMPT_CONFIG), + chatPromptConfig: + backendModelConfig.chat_prompt_config && + backendModelConfig.chat_prompt_config.prompt?.length > 0 + ? backendModelConfig.chat_prompt_config + : clone(DEFAULT_CHAT_PROMPT_CONFIG), introduction: backendModelConfig.opening_statement, moderationConfig: backendModelConfig.sensitive_word_avoidance, } } -export const createDatasetSelectHandler = ({ - currentRerankModel, - currentRerankProvider, - dataSets, - datasetConfigs, - datasetConfigsRef, - formattingChangedDispatcher, - hideSelectDataSet, - setDataSets, - setDatasetConfigs, - setRerankSettingModalOpen, -}: { - currentRerankModel?: string - currentRerankProvider?: string - dataSets: DataSet[] - datasetConfigs: DatasetConfigs - datasetConfigsRef: { current: DatasetConfigs } - formattingChangedDispatcher: () => void - hideSelectDataSet: () => void - setDataSets: (data: DataSet[]) => void - setDatasetConfigs: (configs: DatasetConfigs) => void - setRerankSettingModalOpen: (visible: boolean) => void -}) => (nextDataSets: DataSet[]) => { - if (nextDataSets.map(item => item.id).join(',') === dataSets.map(item => item.id).join(',')) { - hideSelectDataSet() - return - } - - formattingChangedDispatcher() - let mergedDataSets = nextDataSets +export const createDatasetSelectHandler = + ({ + currentRerankModel, + currentRerankProvider, + dataSets, + datasetConfigs, + datasetConfigsRef, + formattingChangedDispatcher, + hideSelectDataSet, + setDataSets, + setDatasetConfigs, + setRerankSettingModalOpen, + }: { + currentRerankModel?: string + currentRerankProvider?: string + dataSets: DataSet[] + datasetConfigs: DatasetConfigs + datasetConfigsRef: { current: DatasetConfigs } + formattingChangedDispatcher: () => void + hideSelectDataSet: () => void + setDataSets: (data: DataSet[]) => void + setDatasetConfigs: (configs: DatasetConfigs) => void + setRerankSettingModalOpen: (visible: boolean) => void + }) => + (nextDataSets: DataSet[]) => { + if ( + nextDataSets.map((item) => item.id).join(',') === dataSets.map((item) => item.id).join(',') + ) { + hideSelectDataSet() + return + } - if (nextDataSets.find(item => !item.name)) { - const hydrated = produce(nextDataSets, (draft) => { - nextDataSets.forEach((item, index) => { - if (!item.name) { - const originalItem = dataSets.find(existing => existing.id === item.id) - if (originalItem) - draft[index] = originalItem - } + formattingChangedDispatcher() + let mergedDataSets = nextDataSets + + if (nextDataSets.find((item) => !item.name)) { + const hydrated = produce(nextDataSets, (draft) => { + nextDataSets.forEach((item, index) => { + if (!item.name) { + const originalItem = dataSets.find((existing) => existing.id === item.id) + if (originalItem) draft[index] = originalItem + } + }) }) - }) - setDataSets(hydrated) - mergedDataSets = hydrated - } - else { - setDataSets(nextDataSets) - } - - hideSelectDataSet() - const { - allExternal, - allInternal, - mixtureInternalAndExternal, - mixtureHighQualityAndEconomic, - inconsistentEmbeddingModel, - } = getSelectedDatasetsMode(mergedDataSets) - - if ( - (allInternal && (mixtureHighQualityAndEconomic || inconsistentEmbeddingModel)) - || mixtureInternalAndExternal - || allExternal - ) { - setRerankSettingModalOpen(true) - } - - const { datasets, retrieval_model, score_threshold_enabled, ...restConfigs } = datasetConfigs - const { - top_k, - score_threshold, - reranking_model, - reranking_mode, - weights, - reranking_enable, - } = restConfigs - - const oldRetrievalConfig = { - top_k, - score_threshold, - reranking_model: (reranking_model?.reranking_provider_name && reranking_model?.reranking_model_name) - ? { - provider: reranking_model.reranking_provider_name, - model: reranking_model.reranking_model_name, - } - : undefined, - reranking_mode, - weights, - reranking_enable, - } - - const retrievalConfig = getMultipleRetrievalConfig(oldRetrievalConfig, mergedDataSets, dataSets, { - provider: currentRerankProvider, - model: currentRerankModel, - }) - - setDatasetConfigs({ - ...datasetConfigsRef.current, - ...retrievalConfig, - reranking_model: { - reranking_provider_name: retrievalConfig?.reranking_model?.provider || '', - reranking_model_name: retrievalConfig?.reranking_model?.model || '', - }, - retrieval_model, - score_threshold_enabled, - datasets, - }) -} - -export const createPublishHandler = ({ - appId, - chatPromptConfig, - citationConfig, - completionParamsState, - completionPromptConfig, - contextVar, - contextVarEmpty, - dataSets, - datasetConfigs, - externalDataToolsConfig, - hasSetBlockStatus, - introduction, - isAdvancedMode, - isFunctionCall, - mode, - modelConfig, - moreLikeThisConfig, - promptEmpty, - promptMode, - resolvedModelModeType, - setCanReturnToSimpleMode, - setPublishedConfig, - speechToTextConfig, - suggestedQuestionsAfterAnswerConfig, - t: rawTranslate, - textToSpeechConfig, -}: { - appId: string - chatPromptConfig: BackendModelConfig['chat_prompt_config'] - citationConfig: ModelConfig['retriever_resource'] - completionParamsState: FormValue - completionPromptConfig: BackendModelConfig['completion_prompt_config'] - contextVar?: string - contextVarEmpty: boolean - dataSets: DataSet[] - datasetConfigs: DatasetConfigs - externalDataToolsConfig: BackendModelConfig['external_data_tools'] - hasSetBlockStatus: { history: boolean, query: boolean } - introduction: string - isAdvancedMode: boolean - isFunctionCall: boolean - mode: AppModeEnum - modelConfig: ModelConfig - moreLikeThisConfig: ModelConfig['more_like_this'] - promptEmpty: boolean - promptMode: BackendModelConfig['prompt_type'] - resolvedModelModeType: ModelModeType - setCanReturnToSimpleMode: (value: boolean) => void - setPublishedConfig: (config: { modelConfig: ModelConfig, completionParams: FormValue }) => void - speechToTextConfig: ModelConfig['speech_to_text'] - suggestedQuestionsAfterAnswerConfig: ModelConfig['suggested_questions_after_answer'] - t: SelectorTranslate<'appDebug' | 'common'> - textToSpeechConfig: ModelConfig['text_to_speech'] -}) => async ( - updateAppModelConfig: (params: { url: string, body: BackendModelConfig }) => Promise<unknown>, - modelAndParameter?: { model: string, provider: string, parameters: FormValue }, - features?: FeaturesData, -) => { - const t = getStringSelectorTranslate(rawTranslate) - const modelId = modelAndParameter?.model || modelConfig.model_id - const promptTemplate = modelConfig.configs.prompt_template - const promptVariables = modelConfig.configs.prompt_variables - - if (promptEmpty) { - toast.error(t($ => $['otherError.promptNoBeEmpty'], { ns: 'appDebug' })) - return - } + setDataSets(hydrated) + mergedDataSets = hydrated + } else { + setDataSets(nextDataSets) + } - if (isAdvancedMode && mode !== AppModeEnum.COMPLETION && resolvedModelModeType === ModelModeType.completion) { - if (!hasSetBlockStatus.history) { - toast.error(t($ => $['otherError.historyNoBeEmpty'], { ns: 'appDebug' })) - return + hideSelectDataSet() + const { + allExternal, + allInternal, + mixtureInternalAndExternal, + mixtureHighQualityAndEconomic, + inconsistentEmbeddingModel, + } = getSelectedDatasetsMode(mergedDataSets) + + if ( + (allInternal && (mixtureHighQualityAndEconomic || inconsistentEmbeddingModel)) || + mixtureInternalAndExternal || + allExternal + ) { + setRerankSettingModalOpen(true) } - if (!hasSetBlockStatus.query) { - toast.error(t($ => $['otherError.queryNoBeEmpty'], { ns: 'appDebug' })) - return + const { datasets, retrieval_model, score_threshold_enabled, ...restConfigs } = datasetConfigs + const { top_k, score_threshold, reranking_model, reranking_mode, weights, reranking_enable } = + restConfigs + + const oldRetrievalConfig = { + top_k, + score_threshold, + reranking_model: + reranking_model?.reranking_provider_name && reranking_model?.reranking_model_name + ? { + provider: reranking_model.reranking_provider_name, + model: reranking_model.reranking_model_name, + } + : undefined, + reranking_mode, + weights, + reranking_enable, } - } - if (contextVarEmpty) { - toast.error(t($ => $['feature.dataSet.queryVariable.contextVarNotEmpty'], { ns: 'appDebug' })) - return + const retrievalConfig = getMultipleRetrievalConfig( + oldRetrievalConfig, + mergedDataSets, + dataSets, + { + provider: currentRerankProvider, + model: currentRerankModel, + }, + ) + + setDatasetConfigs({ + ...datasetConfigsRef.current, + ...retrievalConfig, + reranking_model: { + reranking_provider_name: retrievalConfig?.reranking_model?.provider || '', + reranking_model_name: retrievalConfig?.reranking_model?.model || '', + }, + retrieval_model, + score_threshold_enabled, + datasets, + }) } - const body = buildPublishBody({ +export const createPublishHandler = + ({ + appId, chatPromptConfig, - completionParams: modelAndParameter?.parameters || completionParamsState, + citationConfig, + completionParamsState, completionPromptConfig, contextVar, + contextVarEmpty, dataSets, datasetConfigs, externalDataToolsConfig, - features, + hasSetBlockStatus, + introduction, isAdvancedMode, isFunctionCall, + mode, modelConfig, - modelId, - modelProvider: modelAndParameter?.provider || modelConfig.provider, + moreLikeThisConfig, + promptEmpty, promptMode, - promptTemplate, - promptVariables, resolvedModelModeType, - }) - - await updateAppModelConfig({ url: `/apps/${appId}/model-config`, body }) - - const nextModelConfig = produce(modelConfig, (draft: ModelConfig) => { - draft.opening_statement = introduction - draft.more_like_this = moreLikeThisConfig - draft.suggested_questions_after_answer = suggestedQuestionsAfterAnswerConfig - draft.speech_to_text = speechToTextConfig - draft.text_to_speech = textToSpeechConfig - draft.retriever_resource = citationConfig - draft.dataSets = dataSets - }) - - setPublishedConfig({ - modelConfig: nextModelConfig, - completionParams: completionParamsState, - }) - toast.success(t($ => $['api.success'], { ns: 'common' })) - setCanReturnToSimpleMode(false) - return true -} - -export const createModelChangeHandler = ({ - chatPromptLength, - completionParamsState, - completionPromptConfig, - handleSetVisionConfig, - isAdvancedMode, - migrateToDefaultPrompt, - mode, - modelConfig, - resolvedModelModeType, - setCompletionParams, - setModelConfig, - t: rawTranslate, - visionConfig, -}: { - chatPromptLength: number - completionParamsState: FormValue - completionPromptConfig: { - conversation_histories_role: { - assistant_prefix: string - user_prefix: string - } - prompt?: { - text?: string + setCanReturnToSimpleMode, + setPublishedConfig, + speechToTextConfig, + suggestedQuestionsAfterAnswerConfig, + t: rawTranslate, + textToSpeechConfig, + }: { + appId: string + chatPromptConfig: BackendModelConfig['chat_prompt_config'] + citationConfig: ModelConfig['retriever_resource'] + completionParamsState: FormValue + completionPromptConfig: BackendModelConfig['completion_prompt_config'] + contextVar?: string + contextVarEmpty: boolean + dataSets: DataSet[] + datasetConfigs: DatasetConfigs + externalDataToolsConfig: BackendModelConfig['external_data_tools'] + hasSetBlockStatus: { history: boolean; query: boolean } + introduction: string + isAdvancedMode: boolean + isFunctionCall: boolean + mode: AppModeEnum + modelConfig: ModelConfig + moreLikeThisConfig: ModelConfig['more_like_this'] + promptEmpty: boolean + promptMode: BackendModelConfig['prompt_type'] + resolvedModelModeType: ModelModeType + setCanReturnToSimpleMode: (value: boolean) => void + setPublishedConfig: (config: { modelConfig: ModelConfig; completionParams: FormValue }) => void + speechToTextConfig: ModelConfig['speech_to_text'] + suggestedQuestionsAfterAnswerConfig: ModelConfig['suggested_questions_after_answer'] + t: SelectorTranslate<'appDebug' | 'common'> + textToSpeechConfig: ModelConfig['text_to_speech'] + }) => + async ( + updateAppModelConfig: (params: { url: string; body: BackendModelConfig }) => Promise<unknown>, + modelAndParameter?: { model: string; provider: string; parameters: FormValue }, + features?: FeaturesData, + ) => { + const t = getStringSelectorTranslate(rawTranslate) + const modelId = modelAndParameter?.model || modelConfig.model_id + const promptTemplate = modelConfig.configs.prompt_template + const promptVariables = modelConfig.configs.prompt_variables + + if (promptEmpty) { + toast.error(t(($) => $['otherError.promptNoBeEmpty'], { ns: 'appDebug' })) + return } - } - handleSetVisionConfig: (config: VisionSettings, notNoticeFormattingChanged?: boolean) => void - isAdvancedMode: boolean - migrateToDefaultPrompt: (force?: boolean, modelModeType?: ModelModeType) => Promise<void> - mode: AppModeEnum - modelConfig: ModelConfig - resolvedModelModeType: ModelModeType - setCompletionParams: (value: FormValue) => void - setModelConfig: (config: ModelConfig) => void - t: SelectorTranslate<'appDebug' | 'common'> - visionConfig: VisionSettings -}) => async ({ - features = [], - mode: nextModelMode = resolvedModelModeType, - modelId, - provider, -}: { modelId: string, provider: string, mode?: string, features?: string[] }) => { - const t = getStringSelectorTranslate(rawTranslate) - if (isAdvancedMode) { - if (nextModelMode === ModelModeType.completion) { - if (mode !== AppModeEnum.COMPLETION) { - if (!completionPromptConfig.prompt?.text || !completionPromptConfig.conversation_histories_role.assistant_prefix || !completionPromptConfig.conversation_histories_role.user_prefix) - await migrateToDefaultPrompt(true, ModelModeType.completion) + + if ( + isAdvancedMode && + mode !== AppModeEnum.COMPLETION && + resolvedModelModeType === ModelModeType.completion + ) { + if (!hasSetBlockStatus.history) { + toast.error(t(($) => $['otherError.historyNoBeEmpty'], { ns: 'appDebug' })) + return } - else if (!completionPromptConfig.prompt?.text) { - await migrateToDefaultPrompt(true, ModelModeType.completion) + + if (!hasSetBlockStatus.query) { + toast.error(t(($) => $['otherError.queryNoBeEmpty'], { ns: 'appDebug' })) + return } } - if (nextModelMode === ModelModeType.chat && chatPromptLength === 0) - await migrateToDefaultPrompt(true, ModelModeType.chat) + if (contextVarEmpty) { + toast.error( + t(($) => $['feature.dataSet.queryVariable.contextVarNotEmpty'], { ns: 'appDebug' }), + ) + return + } + + const body = buildPublishBody({ + chatPromptConfig, + completionParams: modelAndParameter?.parameters || completionParamsState, + completionPromptConfig, + contextVar, + dataSets, + datasetConfigs, + externalDataToolsConfig, + features, + isAdvancedMode, + isFunctionCall, + modelConfig, + modelId, + modelProvider: modelAndParameter?.provider || modelConfig.provider, + promptMode, + promptTemplate, + promptVariables, + resolvedModelModeType, + }) + + await updateAppModelConfig({ url: `/apps/${appId}/model-config`, body }) + + const nextModelConfig = produce(modelConfig, (draft: ModelConfig) => { + draft.opening_statement = introduction + draft.more_like_this = moreLikeThisConfig + draft.suggested_questions_after_answer = suggestedQuestionsAfterAnswerConfig + draft.speech_to_text = speechToTextConfig + draft.text_to_speech = textToSpeechConfig + draft.retriever_resource = citationConfig + draft.dataSets = dataSets + }) + + setPublishedConfig({ + modelConfig: nextModelConfig, + completionParams: completionParamsState, + }) + toast.success(t(($) => $['api.success'], { ns: 'common' })) + setCanReturnToSimpleMode(false) + return true } - setModelConfig(produce(modelConfig, (draft: ModelConfig) => { - draft.provider = provider - draft.model_id = modelId - draft.mode = nextModelMode as ModelModeType - })) +export const createModelChangeHandler = + ({ + chatPromptLength, + completionParamsState, + completionPromptConfig, + handleSetVisionConfig, + isAdvancedMode, + migrateToDefaultPrompt, + mode, + modelConfig, + resolvedModelModeType, + setCompletionParams, + setModelConfig, + t: rawTranslate, + visionConfig, + }: { + chatPromptLength: number + completionParamsState: FormValue + completionPromptConfig: { + conversation_histories_role: { + assistant_prefix: string + user_prefix: string + } + prompt?: { + text?: string + } + } + handleSetVisionConfig: (config: VisionSettings, notNoticeFormattingChanged?: boolean) => void + isAdvancedMode: boolean + migrateToDefaultPrompt: (force?: boolean, modelModeType?: ModelModeType) => Promise<void> + mode: AppModeEnum + modelConfig: ModelConfig + resolvedModelModeType: ModelModeType + setCompletionParams: (value: FormValue) => void + setModelConfig: (config: ModelConfig) => void + t: SelectorTranslate<'appDebug' | 'common'> + visionConfig: VisionSettings + }) => + async ({ + features = [], + mode: nextModelMode = resolvedModelModeType, + modelId, + provider, + }: { + modelId: string + provider: string + mode?: string + features?: string[] + }) => { + const t = getStringSelectorTranslate(rawTranslate) + if (isAdvancedMode) { + if (nextModelMode === ModelModeType.completion) { + if (mode !== AppModeEnum.COMPLETION) { + if ( + !completionPromptConfig.prompt?.text || + !completionPromptConfig.conversation_histories_role.assistant_prefix || + !completionPromptConfig.conversation_histories_role.user_prefix + ) + await migrateToDefaultPrompt(true, ModelModeType.completion) + } else if (!completionPromptConfig.prompt?.text) { + await migrateToDefaultPrompt(true, ModelModeType.completion) + } + } - handleSetVisionConfig({ - ...visionConfig, - enabled: !!features?.includes('vision'), - }, true) + if (nextModelMode === ModelModeType.chat && chatPromptLength === 0) + await migrateToDefaultPrompt(true, ModelModeType.chat) + } - try { - const { params: filtered, removedDetails } = await fetchAndMergeValidCompletionParams( - provider, - modelId, - completionParamsState, - isAdvancedMode, + setModelConfig( + produce(modelConfig, (draft: ModelConfig) => { + draft.provider = provider + draft.model_id = modelId + draft.mode = nextModelMode as ModelModeType + }), ) - if (Object.keys(removedDetails).length) - toast.warning(`${t($ => $['modelProvider.parametersInvalidRemoved'], { ns: 'common' })}: ${Object.entries(removedDetails).map(([key, reason]) => `${key} (${reason})`).join(', ')}`) + handleSetVisionConfig( + { + ...visionConfig, + enabled: !!features?.includes('vision'), + }, + true, + ) - setCompletionParams(filtered) - } - catch { - toast.error(t($ => $.error, { ns: 'common' })) - setCompletionParams({}) + try { + const { params: filtered, removedDetails } = await fetchAndMergeValidCompletionParams( + provider, + modelId, + completionParamsState, + isAdvancedMode, + ) + + if (Object.keys(removedDetails).length) + toast.warning( + `${t(($) => $['modelProvider.parametersInvalidRemoved'], { ns: 'common' })}: ${Object.entries( + removedDetails, + ) + .map(([key, reason]) => `${key} (${reason})`) + .join(', ')}`, + ) + + setCompletionParams(filtered) + } catch { + toast.error(t(($) => $.error, { ns: 'common' })) + setCompletionParams({}) + } } -} diff --git a/web/app/components/app/configuration/hooks/use-configuration.ts b/web/app/components/app/configuration/hooks/use-configuration.ts index 4fe11c8f67b277..a9326c3ae8aaf7 100644 --- a/web/app/components/app/configuration/hooks/use-configuration.ts +++ b/web/app/components/app/configuration/hooks/use-configuration.ts @@ -3,7 +3,10 @@ import type { ComponentProps } from 'react' import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher' import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper' import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types' -import type { Features as FeaturesData, OnFeaturesChange } from '@/app/components/base/features/types' +import type { + Features as FeaturesData, + OnFeaturesChange, +} from '@/app/components/base/features/types' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' import type ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import type { Collection } from '@/app/components/tools/types' @@ -37,13 +40,22 @@ import useAdvancedPromptConfig from '@/app/components/app/configuration/hooks/us import { useStore as useAppStore } from '@/app/components/app/store' import { useSetDetailSidebarMode } from '@/app/components/detail-sidebar/storage' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' -import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + ModelFeatureEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel, useTextGenerationCurrentProviderAndModelAndModelList, } from '@/app/components/header/account-setting/model-provider-page/hooks' import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting' -import { ANNOTATION_DEFAULT, DATASET_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' +import { + ANNOTATION_DEFAULT, + DATASET_DEFAULT, + DEFAULT_AGENT_SETTING, + DEFAULT_CHAT_PROMPT_CONFIG, + DEFAULT_COMPLETION_PROMPT_CONFIG, +} from '@/config' import { userProfileIdAtom } from '@/context/account-state' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useProviderContext } from '@/context/provider-context' @@ -57,10 +69,7 @@ import { AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } import { getAppACLCapabilities } from '@/utils/permission' import { supportFunctionCall } from '@/utils/tool-call' import { basePath } from '@/utils/var' -import { - buildConfigurationFeaturesData, - getConfigurationPublishingState, -} from '../utils' +import { buildConfigurationFeaturesData, getConfigurationPublishingState } from '../utils' import { createDatasetSelectHandler, createModelChangeHandler, @@ -101,7 +110,9 @@ export type ConfigurationViewModel = { onMultipleModelConfigsChange: (multiple: boolean, modelConfigs: ModelAndParameter[]) => void onOpenAccountSettings: () => void onOpenDebugPanel: () => void - onSaveHistory: (data: DebugConfigurationValue['completionPromptConfig']['conversation_histories_role']) => void + onSaveHistory: ( + data: DebugConfigurationValue['completionPromptConfig']['conversation_histories_role'], + ) => void onSelectDataSets: (data: DataSet[]) => void promptVariables: PromptVariable[] selectedIds: string[] @@ -119,20 +130,27 @@ export const useConfiguration = (): ConfigurationViewModel => { const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const openIntegrationsSetting = useIntegrationsSetting() - const { appDetail, showAppConfigureFeaturesModal, setShowAppConfigureFeaturesModal } = useAppStore(useShallow(state => ({ - appDetail: state.appDetail, - showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal, - setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal, - }))) + const { appDetail, showAppConfigureFeaturesModal, setShowAppConfigureFeaturesModal } = + useAppStore( + useShallow((state) => ({ + appDetail: state.appDetail, + showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal, + setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal, + })), + ) const setDetailSidebarMode = useSetDetailSidebarMode() const { data: fileUploadConfigResponse } = useFileUploadConfig() const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail]) - const appACLCapabilities = useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { - currentUserId, - resourceMaintainer: appDetail?.maintainer, - workspacePermissionKeys, - }), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys]) + const appACLCapabilities = useMemo( + () => + getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }), + [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], + ) const configurationReadonly = !appACLCapabilities.canEdit const [formattingChanged, setFormattingChanged] = useState(false) const [hasFetchedDetail, setHasFetchedDetail] = useState(false) @@ -145,7 +163,8 @@ export const useConfiguration = (): ConfigurationViewModel => { const media = useBreakpoints() const isMobile = media === MediaType.mobile - const [isShowDebugPanel, { setTrue: showDebugPanel, setFalse: hideDebugPanel }] = useBoolean(false) + const [isShowDebugPanel, { setTrue: showDebugPanel, setFalse: hideDebugPanel }] = + useBoolean(false) const [introduction, setIntroduction] = useState('') const [suggestedQuestions, setSuggestedQuestions] = useState<string[]>([]) @@ -154,9 +173,14 @@ export const useConfiguration = (): ConfigurationViewModel => { prompt_template: '', prompt_variables: [], }) - const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig>({ enabled: false }) - const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = useState<MoreLikeThisConfig>({ enabled: false }) - const [speechToTextConfig, setSpeechToTextConfig] = useState<MoreLikeThisConfig>({ enabled: false }) + const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig>({ + enabled: false, + }) + const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = + useState<MoreLikeThisConfig>({ enabled: false }) + const [speechToTextConfig, setSpeechToTextConfig] = useState<MoreLikeThisConfig>({ + enabled: false, + }) const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig>({ enabled: false, voice: '', @@ -173,11 +197,13 @@ export const useConfiguration = (): ConfigurationViewModel => { }, }) const formattingChangedDispatcher = useFormattingChangedDispatcher() - const setAnnotationConfig = useCallback((config: AnnotationReplyConfig, notSetFormatChanged?: boolean) => { - doSetAnnotationConfig(config) - if (!notSetFormatChanged) - formattingChangedDispatcher() - }, [formattingChangedDispatcher]) + const setAnnotationConfig = useCallback( + (config: AnnotationReplyConfig, notSetFormatChanged?: boolean) => { + doSetAnnotationConfig(config) + if (!notSetFormatChanged) formattingChangedDispatcher() + }, + [formattingChangedDispatcher], + ) const [moderationConfig, setModerationConfig] = useState<ModerationConfig>({ enabled: false }) const [externalDataToolsConfig, setExternalDataToolsConfig] = useState<ExternalDataTool[]>([]) @@ -220,14 +246,20 @@ export const useConfiguration = (): ConfigurationViewModel => { const modelModeType = modelConfig.mode const modeModeTypeRef = useRef(modelModeType) - const setCompletionParams = useCallback((value: FormValue) => { - const params = { ...value } - if ((!params.stop || params.stop.length === 0) && modeModeTypeRef.current === ModelModeType.completion) { - params.stop = getTempStop() - setTempStop([]) - } - doSetCompletionParams(params) - }, [getTempStop, setTempStop]) + const setCompletionParams = useCallback( + (value: FormValue) => { + const params = { ...value } + if ( + (!params.stop || params.stop.length === 0) && + modeModeTypeRef.current === ModelModeType.completion + ) { + params.stop = getTempStop() + setTempStop([]) + } + doSetCompletionParams(params) + }, + [getTempStop, setTempStop], + ) const setModelConfig = useCallback((newModelConfig: ModelConfig) => { doSetModelConfig(newModelConfig) @@ -256,44 +288,54 @@ export const useConfiguration = (): ConfigurationViewModel => { }, []) const [dataSets, setDataSets] = useState<DataSet[]>([]) - const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key + const contextVar = modelConfig.configs.prompt_variables.find((item) => item.is_context_var)?.key const hasSetContextVar = !!contextVar - const [isShowSelectDataSet, { setTrue: showSelectDataSet, setFalse: hideSelectDataSet }] = useBoolean(false) - const selectedIds = dataSets.map(item => item.id) + const [isShowSelectDataSet, { setTrue: showSelectDataSet, setFalse: hideSelectDataSet }] = + useBoolean(false) + const selectedIds = dataSets.map((item) => item.id) const [rerankSettingModalOpen, setRerankSettingModalOpen] = useState(false) - const [isShowHistoryModal, { setTrue: showHistoryModal, setFalse: hideHistoryModal }] = useBoolean(false) + const [isShowHistoryModal, { setTrue: showHistoryModal, setFalse: hideHistoryModal }] = + useBoolean(false) const [showUseGPT4Confirm, setShowUseGPT4Confirm] = useState(false) - const { - currentModel: currentRerankModel, - currentProvider: currentRerankProvider, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) - - const syncToPublishedConfig = useCallback((_publishedConfig: PublishConfig) => { - const publishedModelConfig = _publishedConfig.modelConfig - setModelConfig(publishedModelConfig) - setCompletionParams(_publishedConfig.completionParams) - setDataSets(publishedModelConfig.dataSets || []) - setIntroduction(publishedModelConfig.opening_statement || '') - setMoreLikeThisConfig(publishedModelConfig.more_like_this || { enabled: false }) - setSuggestedQuestionsAfterAnswerConfig(publishedModelConfig.suggested_questions_after_answer || { enabled: false }) - setSpeechToTextConfig(publishedModelConfig.speech_to_text || { enabled: false }) - setTextToSpeechConfig(publishedModelConfig.text_to_speech || { - enabled: false, - voice: '', - language: '', - }) - setCitationConfig(publishedModelConfig.retriever_resource || { enabled: false }) - }, [setCompletionParams, setModelConfig]) + const { currentModel: currentRerankModel, currentProvider: currentRerankProvider } = + useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) + + const syncToPublishedConfig = useCallback( + (_publishedConfig: PublishConfig) => { + const publishedModelConfig = _publishedConfig.modelConfig + setModelConfig(publishedModelConfig) + setCompletionParams(_publishedConfig.completionParams) + setDataSets(publishedModelConfig.dataSets || []) + setIntroduction(publishedModelConfig.opening_statement || '') + setMoreLikeThisConfig(publishedModelConfig.more_like_this || { enabled: false }) + setSuggestedQuestionsAfterAnswerConfig( + publishedModelConfig.suggested_questions_after_answer || { enabled: false }, + ) + setSpeechToTextConfig(publishedModelConfig.speech_to_text || { enabled: false }) + setTextToSpeechConfig( + publishedModelConfig.text_to_speech || { + enabled: false, + voice: '', + language: '', + }, + ) + setCitationConfig(publishedModelConfig.retriever_resource || { enabled: false }) + }, + [setCompletionParams, setModelConfig], + ) const { isAPIKeySet } = useProviderContext() - const { - currentModel: currModel, - } = useTextGenerationCurrentProviderAndModelAndModelList({ + const { currentModel: currModel } = useTextGenerationCurrentProviderAndModelAndModelList({ provider: modelConfig.provider, model: modelConfig.model_id, }) - const resolvedModelModeType = (modelModeType || (hasFetchedDetail ? currModel?.model_properties.mode as ModelModeType | undefined : undefined)) ?? ModelModeType.unset + const resolvedModelModeType = + (modelModeType || + (hasFetchedDetail + ? (currModel?.model_properties.mode as ModelModeType | undefined) + : undefined)) ?? + ModelModeType.unset const isFunctionCall = supportFunctionCall(currModel?.features) @@ -311,16 +353,18 @@ export const useConfiguration = (): ConfigurationViewModel => { transfer_methods: [TransferMethod.local_file], }) - const handleSetVisionConfig = useCallback((config: VisionSettings, notNoticeFormattingChanged?: boolean) => { - doSetVisionConfig({ - enabled: config.enabled || false, - number_limits: config.number_limits || 2, - detail: config.detail || Resolution.low, - transfer_methods: config.transfer_methods || [TransferMethod.local_file], - }) - if (!notNoticeFormattingChanged) - formattingChangedDispatcher() - }, [formattingChangedDispatcher]) + const handleSetVisionConfig = useCallback( + (config: VisionSettings, notNoticeFormattingChanged?: boolean) => { + doSetVisionConfig({ + enabled: config.enabled || false, + number_limits: config.number_limits || 2, + detail: config.detail || Resolution.low, + transfer_methods: config.transfer_methods || [TransferMethod.local_file], + }) + if (!notNoticeFormattingChanged) formattingChangedDispatcher() + }, + [formattingChangedDispatcher], + ) const { chatPromptConfig, @@ -347,86 +391,105 @@ export const useConfiguration = (): ConfigurationViewModel => { setStop: setTempStop, }) - const setPromptMode = useCallback(async (nextMode: PromptMode) => { - if (nextMode === PromptMode.advanced) { - await migrateToDefaultPrompt() - setCanReturnToSimpleMode(true) - } - doSetPromptMode(nextMode) - }, [migrateToDefaultPrompt]) - - const handleSelect = useCallback(createDatasetSelectHandler({ - currentRerankModel: currentRerankModel?.model, - currentRerankProvider: currentRerankProvider?.provider, - dataSets, - datasetConfigs, - datasetConfigsRef, - formattingChangedDispatcher, - hideSelectDataSet, - setDataSets, - setDatasetConfigs, - setRerankSettingModalOpen, - }), [ - currentRerankModel?.model, - currentRerankProvider?.provider, - dataSets, - datasetConfigs, - datasetConfigsRef, - formattingChangedDispatcher, - hideSelectDataSet, - setDataSets, - setDatasetConfigs, - setRerankSettingModalOpen, - ]) + const setPromptMode = useCallback( + async (nextMode: PromptMode) => { + if (nextMode === PromptMode.advanced) { + await migrateToDefaultPrompt() + setCanReturnToSimpleMode(true) + } + doSetPromptMode(nextMode) + }, + [migrateToDefaultPrompt], + ) - const setModel = useCallback(createModelChangeHandler({ - chatPromptLength: chatPromptConfig.prompt.length, - completionParamsState, - completionPromptConfig, - handleSetVisionConfig, - isAdvancedMode, - migrateToDefaultPrompt, - mode, - modelConfig, - resolvedModelModeType, - setCompletionParams, - setModelConfig, - t, - visionConfig, - }), [ - chatPromptConfig.prompt.length, - completionParamsState, - completionPromptConfig, - handleSetVisionConfig, - isAdvancedMode, - migrateToDefaultPrompt, - mode, - modelConfig, - resolvedModelModeType, - setCompletionParams, - setModelConfig, - t, - visionConfig, - ]) + const handleSelect = useCallback( + createDatasetSelectHandler({ + currentRerankModel: currentRerankModel?.model, + currentRerankProvider: currentRerankProvider?.provider, + dataSets, + datasetConfigs, + datasetConfigsRef, + formattingChangedDispatcher, + hideSelectDataSet, + setDataSets, + setDatasetConfigs, + setRerankSettingModalOpen, + }), + [ + currentRerankModel?.model, + currentRerankProvider?.provider, + dataSets, + datasetConfigs, + datasetConfigsRef, + formattingChangedDispatcher, + hideSelectDataSet, + setDataSets, + setDatasetConfigs, + setRerankSettingModalOpen, + ], + ) + + const setModel = useCallback( + createModelChangeHandler({ + chatPromptLength: chatPromptConfig.prompt.length, + completionParamsState, + completionPromptConfig, + handleSetVisionConfig, + isAdvancedMode, + migrateToDefaultPrompt, + mode, + modelConfig, + resolvedModelModeType, + setCompletionParams, + setModelConfig, + t, + visionConfig, + }), + [ + chatPromptConfig.prompt.length, + completionParamsState, + completionPromptConfig, + handleSetVisionConfig, + isAdvancedMode, + migrateToDefaultPrompt, + mode, + modelConfig, + resolvedModelModeType, + setCompletionParams, + setModelConfig, + t, + visionConfig, + ], + ) const isShowVisionConfig = !!currModel?.features?.includes(ModelFeatureEnum.vision) const isShowDocumentConfig = !!currModel?.features?.includes(ModelFeatureEnum.document) const isShowAudioConfig = !!currModel?.features?.includes(ModelFeatureEnum.audio) const isAllowVideoUpload = !!currModel?.features?.includes(ModelFeatureEnum.video) - const featuresData = useMemo(() => buildConfigurationFeaturesData(modelConfig, fileUploadConfigResponse), [fileUploadConfigResponse, modelConfig]) + const featuresData = useMemo( + () => buildConfigurationFeaturesData(modelConfig, fileUploadConfigResponse), + [fileUploadConfigResponse, modelConfig], + ) - const handleFeaturesChange = useCallback<OnFeaturesChange>((features) => { - setShowAppConfigureFeaturesModal(true) - if (features) - formattingChangedDispatcher() - }, [formattingChangedDispatcher, setShowAppConfigureFeaturesModal]) - - const handleAddPromptVariable = useCallback((variables: PromptVariable[]) => { - setModelConfig(produce(modelConfig, (draft: ModelConfig) => { - draft.configs.prompt_variables = [...draft.configs.prompt_variables, ...variables] - })) - }, [modelConfig, setModelConfig]) + const handleFeaturesChange = useCallback<OnFeaturesChange>( + (features) => { + setShowAppConfigureFeaturesModal(true) + if (features) formattingChangedDispatcher() + }, + [formattingChangedDispatcher, setShowAppConfigureFeaturesModal], + ) + + const handleAddPromptVariable = useCallback( + (variables: PromptVariable[]) => { + setModelConfig( + produce(modelConfig, (draft: ModelConfig) => { + draft.configs.prompt_variables = [...draft.configs.prompt_variables, ...variables] + }), + ) + }, + [modelConfig, setModelConfig], + ) useEffect(() => { void (async () => { @@ -456,8 +519,7 @@ export const useConfiguration = (): ConfigurationViewModel => { setChatPromptConfig(configurationState.chatPromptConfig) setCompletionPromptConfig(configurationState.completionPromptConfig as never) setCanReturnToSimpleMode(false) - } - else { + } else { setCanReturnToSimpleMode(configurationState.canReturnToSimpleMode) } @@ -473,37 +535,72 @@ export const useConfiguration = (): ConfigurationViewModel => { })() }, [appId]) - const { promptEmpty, cannotPublish, contextVarEmpty } = useMemo(() => getConfigurationPublishingState({ - chatPromptConfig, - completionPromptConfig, - hasSetBlockStatus, - hasSetContextVar, - hasSelectedDataSets: dataSets.length > 0, - isAdvancedMode, - mode, - modelModeType: resolvedModelModeType, - promptTemplate: modelConfig.configs.prompt_template, - }), [ - chatPromptConfig, - completionPromptConfig, - dataSets.length, - hasSetBlockStatus, - hasSetContextVar, - isAdvancedMode, - mode, - modelConfig.configs.prompt_template, - resolvedModelModeType, - ]) + const { promptEmpty, cannotPublish, contextVarEmpty } = useMemo( + () => + getConfigurationPublishingState({ + chatPromptConfig, + completionPromptConfig, + hasSetBlockStatus, + hasSetContextVar, + hasSelectedDataSets: dataSets.length > 0, + isAdvancedMode, + mode, + modelModeType: resolvedModelModeType, + promptTemplate: modelConfig.configs.prompt_template, + }), + [ + chatPromptConfig, + completionPromptConfig, + dataSets.length, + hasSetBlockStatus, + hasSetContextVar, + isAdvancedMode, + mode, + modelConfig.configs.prompt_template, + resolvedModelModeType, + ], + ) - const onPublish = useCallback(async (params?: AppPublisherPublishParams, features?: FeaturesData) => { - if (!appACLCapabilities.canReleaseAndVersion) - return + const onPublish = useCallback( + async (params?: AppPublisherPublishParams, features?: FeaturesData) => { + if (!appACLCapabilities.canReleaseAndVersion) return - const modelAndParameter = params && 'model' in params && 'provider' in params && 'parameters' in params - ? params - : undefined + const modelAndParameter = + params && 'model' in params && 'provider' in params && 'parameters' in params + ? params + : undefined - return createPublishHandler({ + return createPublishHandler({ + appId, + chatPromptConfig, + citationConfig, + completionParamsState, + completionPromptConfig, + contextVar, + contextVarEmpty, + dataSets, + datasetConfigs, + externalDataToolsConfig, + hasSetBlockStatus, + introduction, + isAdvancedMode, + isFunctionCall, + mode, + modelConfig, + moreLikeThisConfig, + promptEmpty, + promptMode, + resolvedModelModeType, + setCanReturnToSimpleMode, + setPublishedConfig, + speechToTextConfig, + suggestedQuestionsAfterAnswerConfig, + t, + textToSpeechConfig, + })(updateAppModelConfig, modelAndParameter, features) + }, + [ + appACLCapabilities.canReleaseAndVersion, appId, chatPromptConfig, citationConfig, @@ -530,59 +627,41 @@ export const useConfiguration = (): ConfigurationViewModel => { suggestedQuestionsAfterAnswerConfig, t, textToSpeechConfig, - })(updateAppModelConfig, modelAndParameter, features) - }, [ - appACLCapabilities.canReleaseAndVersion, - appId, - chatPromptConfig, - citationConfig, - completionParamsState, - completionPromptConfig, - contextVar, - contextVarEmpty, - dataSets, - datasetConfigs, - externalDataToolsConfig, - hasSetBlockStatus, - introduction, - isAdvancedMode, - isFunctionCall, - mode, - modelConfig, - moreLikeThisConfig, - promptEmpty, - promptMode, - resolvedModelModeType, - setCanReturnToSimpleMode, - setPublishedConfig, - speechToTextConfig, - suggestedQuestionsAfterAnswerConfig, - t, - textToSpeechConfig, - ]) + ], + ) - const { - debugWithMultipleModel, - multipleModelConfigs, - handleMultipleModelConfigsChange, - } = useDebugWithSingleOrMultipleModel(appId) + const { debugWithMultipleModel, multipleModelConfigs, handleMultipleModelConfigsChange } = + useDebugWithSingleOrMultipleModel(appId) const handleDebugWithMultipleModelChange = useCallback(() => { - handleMultipleModelConfigsChange( - true, - [ - { id: `${Date.now()}`, model: modelConfig.model_id, provider: modelConfig.provider, parameters: completionParamsState }, - { id: `${Date.now()}-no-repeat`, model: '', provider: '', parameters: {} }, - ], - ) + handleMultipleModelConfigsChange(true, [ + { + id: `${Date.now()}`, + model: modelConfig.model_id, + provider: modelConfig.provider, + parameters: completionParamsState, + }, + { id: `${Date.now()}-no-repeat`, model: '', provider: '', parameters: {} }, + ]) setDetailSidebarMode('collapse') - }, [completionParamsState, handleMultipleModelConfigsChange, modelConfig.model_id, modelConfig.provider, setDetailSidebarMode]) + }, [ + completionParamsState, + handleMultipleModelConfigsChange, + modelConfig.model_id, + modelConfig.provider, + setDetailSidebarMode, + ]) - const onAgentSettingChange = useCallback((config: ModelConfig['agentConfig']) => { - setModelConfig(produce(modelConfig, (draft: ModelConfig) => { - draft.agentConfig = config - })) - }, [modelConfig, setModelConfig]) + const onAgentSettingChange = useCallback( + (config: ModelConfig['agentConfig']) => { + setModelConfig( + produce(modelConfig, (draft: ModelConfig) => { + draft.agentConfig = config + }), + ) + }, + [modelConfig, setModelConfig], + ) const contextValue: DebugConfigurationValue = { readonly: configurationReadonly, diff --git a/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx b/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx index de12a55069ccc5..6d4014df650020 100644 --- a/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx @@ -33,14 +33,27 @@ vi.mock('@langgenius/dify-ui/button', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => selector({ - setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal, - }), + useStore: ( + selector: (state: { + setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal + }) => unknown, + ) => + selector({ + setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal, + }), })) // Use real store - global zustand mock will auto-reset between tests vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({ - default: ({ onFeatureBarClick, disabled, hideEditEntrance }: { onFeatureBarClick: () => void, disabled?: boolean, hideEditEntrance?: boolean }) => ( + default: ({ + onFeatureBarClick, + disabled, + hideEditEntrance, + }: { + onFeatureBarClick: () => void + disabled?: boolean + hideEditEntrance?: boolean + }) => ( <button type="button" disabled={disabled} @@ -59,7 +72,10 @@ vi.mock('@langgenius/dify-ui/select', async () => { }>({}) return { - Select: ({ children, onValueChange }: { + Select: ({ + children, + onValueChange, + }: { children: React.ReactNode onValueChange?: (value: string) => void }) => ( @@ -72,14 +88,18 @@ vi.mock('@langgenius/dify-ui/select', async () => { return ( <div> <button type="button">{children}</button> - <button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}> + <button + data-testid="select-empty" + type="button" + onClick={() => context.onValueChange?.('')} + > empty select value </button> </div> ) }, SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => { const context = React.useContext(SelectContext) return ( <button type="button" onClick={() => context.onValueChange?.(value)}> @@ -93,7 +113,7 @@ vi.mock('@langgenius/dify-ui/select', async () => { }) vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({ - default: ({ name, onChange }: { name: string, onChange: (value: boolean) => void }) => ( + default: ({ name, onChange }: { name: string; onChange: (value: boolean) => void }) => ( <button type="button" data-testid={`bool-input-${name}`} onClick={() => onChange(true)}> bool-input </button> @@ -101,13 +121,19 @@ vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-i })) vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => ({ - default: ({ onFilesChange }: { onFilesChange: (files: Array<Record<string, unknown>>) => void }) => ( + default: ({ + onFilesChange, + }: { + onFilesChange: (files: Array<Record<string, unknown>>) => void + }) => ( <button type="button" - onClick={() => onFilesChange([ - { progress: 100, type: 'local_file', url: 'https://example.com/a.png', fileId: 'file-1' }, - { progress: -1, type: 'remote_url', url: 'https://example.com/b.png', fileId: 'file-2' }, - ])} + onClick={() => + onFilesChange([ + { progress: 100, type: 'local_file', url: 'https://example.com/a.png', fileId: 'file-1' }, + { progress: -1, type: 'remote_url', url: 'https://example.com/b.png', fileId: 'file-2' }, + ]) + } > image-uploader </button> @@ -149,10 +175,12 @@ const defaultProps: IPromptValuePanelProps = { onVisionFilesChange: vi.fn(), } -const renderPanel = (options: { - context?: Partial<typeof baseContextValue> - props?: Partial<IPromptValuePanelProps> -} = {}) => { +const renderPanel = ( + options: { + context?: Partial<typeof baseContextValue> + props?: Partial<IPromptValuePanelProps> + } = {}, +) => { const contextValue = { ...baseContextValue, ...options.context } const props = { ...defaultProps, ...options.props } return render( @@ -230,7 +258,13 @@ describe('PromptValuePanel', () => { configs: { prompt_template: '', prompt_variables: [ - { key: 'textVar', name: 'Text Var', type: 'string', default: 'default text', required: true }, + { + key: 'textVar', + name: 'Text Var', + type: 'string', + default: 'default text', + required: true, + }, ], }, }, @@ -241,7 +275,10 @@ describe('PromptValuePanel', () => { }) expect(mockSetInputs).toHaveBeenCalledWith({ textVar: 'default text' }) - expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true') + expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute( + 'data-disabled', + 'true', + ) fireEvent.click(screen.getByText('feature bar')) expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalled() @@ -265,7 +302,10 @@ describe('PromptValuePanel', () => { }, }) - expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true') + expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute( + 'data-disabled', + 'true', + ) }) it('renders paragraph, select, number, checkbox, and vision inputs', () => { @@ -277,7 +317,13 @@ describe('PromptValuePanel', () => { prompt_template: 'prompt template', prompt_variables: [ { key: 'paragraphVar', name: 'Paragraph Var', type: 'paragraph', required: false }, - { key: 'selectVar', name: 'Select Var', type: 'select', options: ['a', 'b'], required: false }, + { + key: 'selectVar', + name: 'Select Var', + type: 'select', + options: ['a', 'b'], + required: false, + }, { key: 'numberVar', name: 'Number Var', type: 'number', required: true }, { key: 'boolVar', name: 'Boolean Var', type: 'checkbox', required: false }, ], @@ -301,13 +347,17 @@ describe('PromptValuePanel', () => { }, }) - fireEvent.change(screen.getByPlaceholderText('Paragraph Var'), { target: { value: 'updated paragraph' } }) + fireEvent.change(screen.getByPlaceholderText('Paragraph Var'), { + target: { value: 'updated paragraph' }, + }) fireEvent.click(screen.getByText('b')) fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } }) fireEvent.click(screen.getByText('bool-input')) fireEvent.click(screen.getByText('image-uploader')) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ paragraphVar: 'updated paragraph' })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ paragraphVar: 'updated paragraph' }), + ) expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ selectVar: 'b' })) expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ numberVar: '2' })) expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ boolVar: true })) @@ -328,7 +378,13 @@ describe('PromptValuePanel', () => { configs: { prompt_template: 'prompt template', prompt_variables: [ - { key: 'selectVar', name: 'Select Var', type: 'select', options: ['a', 'b'], required: false }, + { + key: 'selectVar', + name: 'Select Var', + type: 'select', + options: ['a', 'b'], + required: false, + }, ], }, }, @@ -349,9 +405,12 @@ describe('PromptValuePanel', () => { const filteredPromptVariables = { length: 1, forEach: vi.fn(), - map: (callback: (value: { key: string, name: string, type: string, required: boolean }, index: number) => unknown) => [ - callback({ key: 'textVar', name: 'Text Var', type: 'string', required: true }, 0), - ], + map: ( + callback: ( + value: { key: string; name: string; type: string; required: boolean }, + index: number, + ) => unknown, + ) => [callback({ key: 'textVar', name: 'Text Var', type: 'string', required: true }, 0)], } renderPanel({ @@ -405,9 +464,12 @@ describe('PromptValuePanel', () => { const filteredPromptVariables = { length: 1, forEach: vi.fn(), - map: (callback: (value: { key: string, name: string, type: string, required: boolean }, index: number) => unknown) => [ - callback({ key: 'boolVar', name: '', type: 'checkbox', required: false }, 0), - ], + map: ( + callback: ( + value: { key: string; name: string; type: string; required: boolean }, + index: number, + ) => unknown, + ) => [callback({ key: 'boolVar', name: '', type: 'checkbox', required: false }, 0)], } renderPanel({ @@ -439,10 +501,19 @@ describe('PromptValuePanel', () => { }, }) - expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute('data-disabled', 'false') - expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'false') + expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute( + 'data-disabled', + 'false', + ) + expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute( + 'data-disabled', + 'false', + ) expect(screen.getByRole('button', { name: 'feature bar' })).toBeDisabled() - expect(screen.getByRole('button', { name: 'feature bar' })).toHaveAttribute('data-hide-edit-entrance', 'true') + expect(screen.getByRole('button', { name: 'feature bar' })).toHaveAttribute( + 'data-hide-edit-entrance', + 'true', + ) }) it('marks debug inputs and actions as disabled when test/run permission is missing even if configuration is editable', () => { @@ -454,8 +525,14 @@ describe('PromptValuePanel', () => { }) expect(screen.getByPlaceholderText('Text Var')).toHaveAttribute('readonly') - expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute('data-disabled', 'true') - expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true') + expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute( + 'data-disabled', + 'true', + ) + expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute( + 'data-disabled', + 'true', + ) }) it('marks debug inputs and actions as disabled when configuration is readonly and test/run permission is missing', () => { @@ -467,8 +544,14 @@ describe('PromptValuePanel', () => { }) expect(screen.getByPlaceholderText('Text Var')).toHaveAttribute('readonly') - expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute('data-disabled', 'true') - expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true') + expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute( + 'data-disabled', + 'true', + ) + expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute( + 'data-disabled', + 'true', + ) }) it('collapses the user input panel and hides the clear and run actions', () => { diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx index cc99e760532e27..b516738e96069f 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -4,14 +4,17 @@ import type { Inputs } from '@/models/debug' import type { VisionFile, VisionSettings } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { Textarea } from '@langgenius/dify-ui/textarea' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiArrowDownSLine, - RiArrowRightSLine, - RiPlayLargeFill, -} from '@remixicon/react' +import { RiArrowDownSLine, RiArrowRightSLine, RiPlayLargeFill } from '@remixicon/react' import * as React from 'react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -40,7 +43,17 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({ onVisionFilesChange, }) => { const { t } = useTranslation() - const { readonly, canTestAndRun = false, modelModeType, modelConfig, setInputs, mode, isAdvancedMode, completionPromptConfig, chatPromptConfig } = useContext(ConfigContext) + const { + readonly, + canTestAndRun = false, + modelModeType, + modelConfig, + setInputs, + mode, + isAdvancedMode, + completionPromptConfig, + chatPromptConfig, + } = useContext(ConfigContext) const debugInputReadonly = !canTestAndRun const [userInputFieldCollapse, setUserInputFieldCollapse] = useState(false) const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => { @@ -63,46 +76,52 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({ promptVariables.forEach((variable) => { const { key, default: defaultValue } = variable // Only set default value if the field is empty and a default exists - if (defaultValue !== undefined && defaultValue !== null && defaultValue !== '' && (inputs[key] === undefined || inputs[key] === null || inputs[key] === '')) { + if ( + defaultValue !== undefined && + defaultValue !== null && + defaultValue !== '' && + (inputs[key] === undefined || inputs[key] === null || inputs[key] === '') + ) { newInputs[key] = defaultValue hasChanges = true } }) - if (hasChanges) - setInputs(newInputs) + if (hasChanges) setInputs(newInputs) }, [promptVariables, inputs, setInputs]) const canNotRun = useMemo(() => { - if (mode !== AppModeEnum.COMPLETION) - return true + if (mode !== AppModeEnum.COMPLETION) return true if (isAdvancedMode) { if (modelModeType === ModelModeType.chat) return chatPromptConfig?.prompt.every(({ text }) => !text) return !completionPromptConfig.prompt?.text + } else { + return !modelConfig.configs.prompt_template } - - else { return !modelConfig.configs.prompt_template } - }, [chatPromptConfig?.prompt, completionPromptConfig.prompt?.text, isAdvancedMode, mode, modelConfig.configs.prompt_template, modelModeType]) + }, [ + chatPromptConfig?.prompt, + completionPromptConfig.prompt?.text, + isAdvancedMode, + mode, + modelConfig.configs.prompt_template, + modelModeType, + ]) const handleInputValueChange = (key: string, value: string | boolean) => { - if (debugInputReadonly) - return - if (!(key in promptVariableObj)) - return + if (debugInputReadonly) return + if (!(key in promptVariableObj)) return const newInputs = { ...inputs } promptVariables.forEach((input) => { - if (input.key === key) - newInputs[key] = value + if (input.key === key) newInputs[key] = value }) setInputs(newInputs) } const onClear = () => { - if (debugInputReadonly) - return + if (debugInputReadonly) return const newInputs: Inputs = {} promptVariables.forEach((item) => { newInputs[item.key] = '' @@ -110,7 +129,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({ setInputs(newInputs) } - const setShowAppConfigureFeaturesModal = useAppStore(s => s.setShowAppConfigureFeaturesModal) + const setShowAppConfigureFeaturesModal = useAppStore((s) => s.setShowAppConfigureFeaturesModal) return ( <> @@ -121,33 +140,44 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({ className="flex cursor-pointer items-center gap-0.5 border-none bg-transparent px-0 py-0.5 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={() => setUserInputFieldCollapse(!userInputFieldCollapse)} > - <div className="system-md-semibold-uppercase text-text-secondary">{t($ => $['inputs.userInputField'], { ns: 'appDebug' })}</div> - {userInputFieldCollapse && <RiArrowRightSLine className="size-4 text-text-secondary" aria-hidden="true" />} - {!userInputFieldCollapse && <RiArrowDownSLine className="size-4 text-text-secondary" aria-hidden="true" />} + <div className="system-md-semibold-uppercase text-text-secondary"> + {t(($) => $['inputs.userInputField'], { ns: 'appDebug' })} + </div> + {userInputFieldCollapse && ( + <RiArrowRightSLine className="size-4 text-text-secondary" aria-hidden="true" /> + )} + {!userInputFieldCollapse && ( + <RiArrowDownSLine className="size-4 text-text-secondary" aria-hidden="true" /> + )} </button> {!userInputFieldCollapse && ( - <div className="mt-1 system-xs-regular text-text-tertiary">{t($ => $['inputs.completionVarTip'], { ns: 'appDebug' })}</div> + <div className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $['inputs.completionVarTip'], { ns: 'appDebug' })} + </div> )} </div> {!userInputFieldCollapse && promptVariables.length > 0 && ( <div className="px-4 pt-3 pb-4"> {promptVariables.map(({ key, name, type, options, max_length, required }, index) => ( - <div - key={key} - className="mb-4 last-of-type:mb-0" - > + <div key={key} className="mb-4 last-of-type:mb-0"> <div> {type !== 'checkbox' && ( <div className="mb-1 flex h-6 items-center gap-1 system-sm-semibold text-text-secondary"> <div className="truncate">{name || key}</div> - {!required && <span className="system-xs-regular text-text-tertiary">{t($ => $['panel.optional'], { ns: 'workflow' })}</span>} + {!required && ( + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['panel.optional'], { ns: 'workflow' })} + </span> + )} </div> )} <div className="grow"> {type === 'string' && ( <Input value={inputs[key] ? `${inputs[key]}` : ''} - onChange={(e) => { handleInputValueChange(key, e.target.value) }} + onChange={(e) => { + handleInputValueChange(key, e.target.value) + }} placeholder={name} autoFocus={index === 0} maxLength={max_length} @@ -160,25 +190,30 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({ className="h-[120px] grow" placeholder={name} value={inputs[key] ? `${inputs[key]}` : ''} - onValueChange={(value) => { handleInputValueChange(key, value) }} + onValueChange={(value) => { + handleInputValueChange(key, value) + }} readOnly={debugInputReadonly} /> )} {type === 'select' && ( <Select<string> - value={typeof inputs[key] === 'string' && inputs[key] !== '' ? inputs[key] : null} + value={ + typeof inputs[key] === 'string' && inputs[key] !== '' ? inputs[key] : null + } disabled={debugInputReadonly} onValueChange={(nextValue) => { - if (nextValue == null || nextValue === '') - return + if (nextValue == null || nextValue === '') return handleInputValueChange(key, nextValue) }} > <SelectTrigger className="w-full bg-gray-50"> - {typeof inputs[key] === 'string' && inputs[key] !== '' ? inputs[key] : t($ => $['placeholder.select'], { ns: 'common' })} + {typeof inputs[key] === 'string' && inputs[key] !== '' + ? inputs[key] + : t(($) => $['placeholder.select'], { ns: 'common' })} </SelectTrigger> <SelectContent> - {(options || []).map(option => ( + {(options || []).map((option) => ( <SelectItem key={option} value={option}> <SelectItemText>{option}</SelectItemText> <SelectItemIndicator /> @@ -191,7 +226,9 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({ <Input type="number" value={inputs[key] ? `${inputs[key]}` : ''} - onChange={(e) => { handleInputValueChange(key, e.target.value) }} + onChange={(e) => { + handleInputValueChange(key, e.target.value) + }} placeholder={name} autoFocus={index === 0} maxLength={max_length} @@ -203,7 +240,9 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({ name={name || key} value={!!inputs[key]} required={required} - onChange={(value) => { handleInputValueChange(key, value) }} + onChange={(value) => { + handleInputValueChange(key, value) + }} readonly={debugInputReadonly} /> )} @@ -213,16 +252,24 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({ ))} {visionConfig?.enabled && ( <div className="mt-3 justify-between xl:flex"> - <div className="mr-1 w-[120px] shrink-0 py-2 text-sm text-text-primary">{t($ => $['imageUploader.imageUpload'], { ns: 'common' })}</div> + <div className="mr-1 w-[120px] shrink-0 py-2 text-sm text-text-primary"> + {t(($) => $['imageUploader.imageUpload'], { ns: 'common' })} + </div> <div className="grow"> <TextGenerationImageUploader settings={visionConfig} - onFilesChange={files => onVisionFilesChange(files.filter(file => file.progress !== -1).map(fileItem => ({ - type: 'image', - transfer_method: fileItem.type, - url: fileItem.url, - upload_file_id: fileItem.fileId, - })))} + onFilesChange={(files) => + onVisionFilesChange( + files + .filter((file) => file.progress !== -1) + .map((fileItem) => ({ + type: 'image', + transfer_method: fileItem.type, + url: fileItem.url, + upload_file_id: fileItem.fileId, + })), + ) + } disabled={debugInputReadonly} /> </div> @@ -232,11 +279,13 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({ )} {!userInputFieldCollapse && ( <div className="flex justify-between border-t border-divider-subtle p-4 pt-3"> - <Button className="w-[72px]" disabled={debugInputReadonly} onClick={onClear}>{t($ => $['operation.clear'], { ns: 'common' })}</Button> + <Button className="w-[72px]" disabled={debugInputReadonly} onClick={onClear}> + {t(($) => $['operation.clear'], { ns: 'common' })} + </Button> {canNotRun && ( <Tooltip> <TooltipTrigger - render={( + render={ <Button variant="primary" disabled={canNotRun || !canTestAndRun} @@ -244,12 +293,12 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({ className="w-[96px]" > <RiPlayLargeFill className="mr-0.5 size-4 shrink-0" aria-hidden="true" /> - {t($ => $['inputs.run'], { ns: 'appDebug' })} + {t(($) => $['inputs.run'], { ns: 'appDebug' })} </Button> - )} + } /> <TooltipContent> - {t($ => $['otherError.promptNoBeEmpty'], { ns: 'appDebug' })} + {t(($) => $['otherError.promptNoBeEmpty'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> )} @@ -261,7 +310,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({ className="w-[96px]" > <RiPlayLargeFill className="mr-0.5 size-4 shrink-0" aria-hidden="true" /> - {t($ => $['inputs.run'], { ns: 'appDebug' })} + {t(($) => $['inputs.run'], { ns: 'appDebug' })} </Button> )} </div> diff --git a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal-utils.spec.ts b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal-utils.spec.ts index a28381cacc1e54..e832efacf6e423 100644 --- a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal-utils.spec.ts +++ b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal-utils.spec.ts @@ -10,8 +10,7 @@ import { } from '../external-data-tool-modal-utils' const t = withSelectorKey((key: string, options?: Record<string, unknown>) => { - if (options?.key) - return `${key}:${options.key as string}` + if (options?.key) return `${key}:${options.key as string}` return key }) @@ -71,15 +70,19 @@ describe('external-data-tool-modal-utils', () => { t, }) - const formatted = formatExternalDataTool({ - type: 'code-tool', - label: 'Search', - variable: 'search_api', - config: { - api_key: 'secret', - ignored: 'value', + const formatted = formatExternalDataTool( + { + type: 'code-tool', + label: 'Search', + variable: 'search_api', + config: { + api_key: 'secret', + ignored: 'value', + }, }, - }, providers[1], false) + providers[1], + false, + ) expect(formatted).toEqual({ type: 'code-tool', @@ -99,14 +102,18 @@ describe('external-data-tool-modal-utils', () => { t, }) - const formatted = formatExternalDataTool({ - type: 'api', - label: 'Search', - variable: 'search_api', - config: { - api_based_extension_id: 'ext-1', + const formatted = formatExternalDataTool( + { + type: 'api', + label: 'Search', + variable: 'search_api', + config: { + api_based_extension_id: 'ext-1', + }, }, - }, providers[0], true) + providers[0], + true, + ) expect(formatted).toEqual({ type: 'api', @@ -126,31 +133,35 @@ describe('external-data-tool-modal-utils', () => { t, }) - expect(getValidationError({ - currentProvider: providers[0], - locale: 'en-US', - localeData: { - type: 'api', - label: 'Search', - variable: '1-invalid', - config: { - api_based_extension_id: 'ext-1', + expect( + getValidationError({ + currentProvider: providers[0], + locale: 'en-US', + localeData: { + type: 'api', + label: 'Search', + variable: '1-invalid', + config: { + api_based_extension_id: 'ext-1', + }, }, - }, - t, - })).toBe('varKeyError.notValid:feature.tools.modal.variableName.title') - - expect(getValidationError({ - currentProvider: providers[1], - locale: 'en-US', - localeData: { - type: 'code-tool', - label: 'Search', - variable: 'search_api', - config: {}, - }, - t, - })).toBe('errorMessage.valueOfVarRequired:API Key') + t, + }), + ).toBe('varKeyError.notValid:feature.tools.modal.variableName.title') + + expect( + getValidationError({ + currentProvider: providers[1], + locale: 'en-US', + localeData: { + type: 'code-tool', + label: 'Search', + variable: 'search_api', + config: {}, + }, + t, + }), + ).toBe('errorMessage.valueOfVarRequired:API Key') }) it('should validate missing names, missing variables, api extensions, and accept valid configs', () => { @@ -160,72 +171,82 @@ describe('external-data-tool-modal-utils', () => { t, }) - expect(getValidationError({ - currentProvider: providers[0], - locale: 'en-US', - localeData: { - type: '', - label: 'Search', - variable: 'search_api', - config: { - api_based_extension_id: 'ext-1', + expect( + getValidationError({ + currentProvider: providers[0], + locale: 'en-US', + localeData: { + type: '', + label: 'Search', + variable: 'search_api', + config: { + api_based_extension_id: 'ext-1', + }, }, - }, - t, - })).toBe('errorMessage.valueOfVarRequired:feature.tools.modal.toolType.title') - - expect(getValidationError({ - currentProvider: providers[0], - locale: 'en-US', - localeData: { - type: 'api', - label: '', - variable: 'search_api', - config: { - api_based_extension_id: 'ext-1', + t, + }), + ).toBe('errorMessage.valueOfVarRequired:feature.tools.modal.toolType.title') + + expect( + getValidationError({ + currentProvider: providers[0], + locale: 'en-US', + localeData: { + type: 'api', + label: '', + variable: 'search_api', + config: { + api_based_extension_id: 'ext-1', + }, }, - }, - t, - })).toBe('errorMessage.valueOfVarRequired:feature.tools.modal.name.title') - - expect(getValidationError({ - currentProvider: providers[0], - locale: 'en-US', - localeData: { - type: 'api', - label: 'Search', - variable: '', - config: { - api_based_extension_id: 'ext-1', + t, + }), + ).toBe('errorMessage.valueOfVarRequired:feature.tools.modal.name.title') + + expect( + getValidationError({ + currentProvider: providers[0], + locale: 'en-US', + localeData: { + type: 'api', + label: 'Search', + variable: '', + config: { + api_based_extension_id: 'ext-1', + }, }, - }, - t, - })).toBe('errorMessage.valueOfVarRequired:feature.tools.modal.variableName.title') - - expect(getValidationError({ - currentProvider: providers[0], - locale: 'en-US', - localeData: { - type: 'api', - label: 'Search', - variable: 'search_api', - config: {}, - }, - t, - })).toBe('errorMessage.valueOfVarRequired:API Extension') - - expect(getValidationError({ - currentProvider: providers[0], - locale: 'en-US', - localeData: { - type: 'api', - label: 'Search', - variable: 'search_api', - config: { - api_based_extension_id: 'ext-1', + t, + }), + ).toBe('errorMessage.valueOfVarRequired:feature.tools.modal.variableName.title') + + expect( + getValidationError({ + currentProvider: providers[0], + locale: 'en-US', + localeData: { + type: 'api', + label: 'Search', + variable: 'search_api', + config: {}, }, - }, - t, - })).toBeNull() + t, + }), + ).toBe('errorMessage.valueOfVarRequired:API Extension') + + expect( + getValidationError({ + currentProvider: providers[0], + locale: 'en-US', + localeData: { + type: 'api', + label: 'Search', + variable: 'search_api', + config: { + api_based_extension_id: 'ext-1', + }, + }, + t, + }), + ).toBeNull() }) }) diff --git a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx index 1e0993c6a97d15..d0db735a4d4a73 100644 --- a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx +++ b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx @@ -46,19 +46,15 @@ vi.mock('@/service/use-common', () => ({ })) vi.mock('@/app/components/base/app-icon', () => ({ - default: ({ - onClick, - }: { - onClick: () => void - }) => <button onClick={onClick}>open-emoji-picker</button>, + default: ({ onClick }: { onClick: () => void }) => ( + <button onClick={onClick}>open-emoji-picker</button> + ), })) vi.mock('@/app/components/base/features/new-feature-panel/moderation/form-generation', () => ({ - default: ({ - onChange, - }: { - onChange: (value: Record<string, string>) => void - }) => <button onClick={() => onChange({ api_key: 'secret-key' })}>fill-form</button>, + default: ({ onChange }: { onChange: (value: Record<string, string>) => void }) => ( + <button onClick={() => onChange({ api_key: 'secret-key' })}>fill-form</button> + ), })) vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({ @@ -68,9 +64,7 @@ vi.mock('@/app/components/header/account-setting/api-based-extension-page/select }: { onChange: (value: string) => void value: string - }) => ( - <button onClick={() => onChange('extension-1')}>{value || 'pick-extension'}</button> - ), + }) => <button onClick={() => onChange('extension-1')}>{value || 'pick-extension'}</button>, })) describe('ExternalDataToolModal', () => { @@ -84,23 +78,27 @@ describe('ExternalDataToolModal', () => { }) it('should require an API extension before saving api-based tools', () => { - render( - <ExternalDataToolModal - data={{}} - onCancel={mockOnCancel} - onSave={mockOnSave} - />, - ) + render(<ExternalDataToolModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />) - fireEvent.change(screen.getByPlaceholderText(/(?:^|\.)feature\.tools\.modal\.name\.placeholder(?=$|:)/), { - target: { value: 'Search' }, - }) - fireEvent.change(screen.getByPlaceholderText(/(?:^|\.)feature\.tools\.modal\.variableName\.placeholder(?=$|:)/), { - target: { value: 'search_api' }, - }) + fireEvent.change( + screen.getByPlaceholderText(/(?:^|\.)feature\.tools\.modal\.name\.placeholder(?=$|:)/), + { + target: { value: 'Search' }, + }, + ) + fireEvent.change( + screen.getByPlaceholderText( + /(?:^|\.)feature\.tools\.modal\.variableName\.placeholder(?=$|:)/, + ), + { + target: { value: 'search_api' }, + }, + ) fireEvent.click(screen.getByText(/(?:^|\.)operation\.save(?=$|:)/)) - expect(mockToastError).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)errorMessage\.valueOfVarRequired(?=$|:)/)) + expect(mockToastError).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)errorMessage\.valueOfVarRequired(?=$|:)/), + ) expect(mockOnSave).not.toHaveBeenCalled() }) @@ -116,12 +114,20 @@ describe('ExternalDataToolModal', () => { />, ) - fireEvent.change(screen.getByPlaceholderText(/(?:^|\.)feature\.tools\.modal\.name\.placeholder(?=$|:)/), { - target: { value: 'Search' }, - }) - fireEvent.change(screen.getByPlaceholderText(/(?:^|\.)feature\.tools\.modal\.variableName\.placeholder(?=$|:)/), { - target: { value: 'search_api' }, - }) + fireEvent.change( + screen.getByPlaceholderText(/(?:^|\.)feature\.tools\.modal\.name\.placeholder(?=$|:)/), + { + target: { value: 'Search' }, + }, + ) + fireEvent.change( + screen.getByPlaceholderText( + /(?:^|\.)feature\.tools\.modal\.variableName\.placeholder(?=$|:)/, + ), + { + target: { value: 'search_api' }, + }, + ) fireEvent.click(screen.getByText('pick-extension')) fireEvent.click(screen.getByText('open-emoji-picker')) await waitFor(() => { @@ -135,7 +141,23 @@ describe('ExternalDataToolModal', () => { fireEvent.click(screen.getByText(/(?:^|\.)operation\.save(?=$|:)/)) await waitFor(() => { - expect(mockOnValidateBeforeSave).toHaveBeenCalledWith(expect.objectContaining({ + expect(mockOnValidateBeforeSave).toHaveBeenCalledWith( + expect.objectContaining({ + config: { + api_based_extension_id: 'extension-1', + }, + enabled: true, + icon: expect.any(String), + icon_background: '#E4FBCC', + label: 'Search', + type: 'api', + variable: 'search_api', + }), + ) + }) + + expect(mockOnSave).toHaveBeenCalledWith( + expect.objectContaining({ config: { api_based_extension_id: 'extension-1', }, @@ -145,22 +167,12 @@ describe('ExternalDataToolModal', () => { label: 'Search', type: 'api', variable: 'search_api', - })) - }) + }), + ) - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - config: { - api_based_extension_id: 'extension-1', - }, - enabled: true, - icon: expect.any(String), - icon_background: '#E4FBCC', - label: 'Search', - type: 'api', - variable: 'search_api', - })) - - expect(screen.getByRole('link', { name: /(?:^|\.)apiBasedExtension\.link(?=$|:)/ })).toHaveAttribute( + expect( + screen.getByRole('link', { name: /(?:^|\.)apiBasedExtension\.link(?=$|:)/ }), + ).toHaveAttribute( 'href', 'https://docs.example.com/use-dify/workspace/api-extension/api-extension', ) @@ -181,25 +193,35 @@ describe('ExternalDataToolModal', () => { />, ) - fireEvent.change(screen.getByPlaceholderText(/(?:^|\.)feature\.tools\.modal\.name\.placeholder(?=$|:)/), { - target: { value: 'Code Search' }, - }) - fireEvent.change(screen.getByPlaceholderText(/(?:^|\.)feature\.tools\.modal\.variableName\.placeholder(?=$|:)/), { - target: { value: 'code_search' }, - }) + fireEvent.change( + screen.getByPlaceholderText(/(?:^|\.)feature\.tools\.modal\.name\.placeholder(?=$|:)/), + { + target: { value: 'Code Search' }, + }, + ) + fireEvent.change( + screen.getByPlaceholderText( + /(?:^|\.)feature\.tools\.modal\.variableName\.placeholder(?=$|:)/, + ), + { + target: { value: 'code_search' }, + }, + ) fireEvent.click(screen.getByText('fill-form')) fireEvent.click(screen.getByText(/(?:^|\.)operation\.save(?=$|:)/)) await waitFor(() => { - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - config: { - api_key: 'secret-key', - }, - enabled: false, - label: 'Code Search', - type: 'code-tool', - variable: 'code_search', - })) + expect(mockOnSave).toHaveBeenCalledWith( + expect.objectContaining({ + config: { + api_key: 'secret-key', + }, + enabled: false, + label: 'Code Search', + type: 'code-tool', + variable: 'code_search', + }), + ) }) fireEvent.click(screen.getByText(/(?:^|\.)operation\.cancel(?=$|:)/)) diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal-utils.ts b/web/app/components/app/configuration/tools/external-data-tool-modal-utils.ts index 3bd9e12a0528f8..27557168d13e8c 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal-utils.ts +++ b/web/app/components/app/configuration/tools/external-data-tool-modal-utils.ts @@ -1,8 +1,5 @@ import type { SelectorTranslate } from '@/app/components/app/configuration/utils' -import type { - CodeBasedExtensionItem, - ExternalDataTool, -} from '@/models/common' +import type { CodeBasedExtensionItem, ExternalDataTool } from '@/models/common' import { getStringSelectorTranslate } from '@/app/components/app/configuration/utils' import { LanguagesSupported } from '@/i18n-config/language' @@ -38,10 +35,10 @@ export const buildProviders = ({ return [ { key: 'api', - name: t($ => $['apiBasedExtension.selector.title'], { ns: 'common' }), + name: t(($) => $['apiBasedExtension.selector.title'], { ns: 'common' }), }, ...(codeBasedExtensionList - ? codeBasedExtensionList.data.map(item => ({ + ? codeBasedExtensionList.data.map((item) => ({ key: item.name, name: locale === LanguagesSupported[1] ? item.label['zh-Hans'] : item.label['en-US'], form_schema: item.form_schema, @@ -51,8 +48,8 @@ export const buildProviders = ({ } export const getProviderDefaultConfig = (type: string, providers: Provider[]) => { - const currentProvider = providers.find(provider => provider.key === type) - if (systemTypes.includes(type as typeof systemTypes[number]) || !currentProvider?.form_schema) + const currentProvider = providers.find((provider) => provider.key === type) + if (systemTypes.includes(type as (typeof systemTypes)[number]) || !currentProvider?.form_schema) return undefined return currentProvider.form_schema.reduce<Record<string, string>>((prev, next) => { @@ -69,10 +66,9 @@ export const formatExternalDataTool = ( const { type, config } = originData const params: Record<string, string | undefined> = {} - if (type === 'api') - params.api_based_extension_id = config?.api_based_extension_id + if (type === 'api') params.api_based_extension_id = config?.api_based_extension_id - if (!systemTypes.includes(type as typeof systemTypes[number]) && currentProvider?.form_schema) { + if (!systemTypes.includes(type as (typeof systemTypes)[number]) && currentProvider?.form_schema) { currentProvider.form_schema.forEach((form) => { params[form.variable] = config?.[form.variable] }) @@ -94,30 +90,51 @@ export const getValidationError = ({ }: ValidationParams) => { const t = getStringSelectorTranslate(rawTranslate) if (!localeData.type) { - return t($ => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: t($ => $['feature.tools.modal.toolType.title'], { ns: 'appDebug' }) }) + return t(($) => $['errorMessage.valueOfVarRequired'], { + ns: 'appDebug', + key: t(($) => $['feature.tools.modal.toolType.title'], { ns: 'appDebug' }), + }) } if (!localeData.label) { - return t($ => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: t($ => $['feature.tools.modal.name.title'], { ns: 'appDebug' }) }) + return t(($) => $['errorMessage.valueOfVarRequired'], { + ns: 'appDebug', + key: t(($) => $['feature.tools.modal.name.title'], { ns: 'appDebug' }), + }) } if (!localeData.variable) { - return t($ => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: t($ => $['feature.tools.modal.variableName.title'], { ns: 'appDebug' }) }) + return t(($) => $['errorMessage.valueOfVarRequired'], { + ns: 'appDebug', + key: t(($) => $['feature.tools.modal.variableName.title'], { ns: 'appDebug' }), + }) } if (!/^[a-z_]\w{0,29}$/i.test(localeData.variable)) { - return t($ => $['varKeyError.notValid'], { ns: 'appDebug', key: t($ => $['feature.tools.modal.variableName.title'], { ns: 'appDebug' }) }) + return t(($) => $['varKeyError.notValid'], { + ns: 'appDebug', + key: t(($) => $['feature.tools.modal.variableName.title'], { ns: 'appDebug' }), + }) } if (localeData.type === 'api' && !localeData.config?.api_based_extension_id) { - return t($ => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: locale === LanguagesSupported[1] ? 'API 扩展' : 'API Extension' }) + return t(($) => $['errorMessage.valueOfVarRequired'], { + ns: 'appDebug', + key: locale === LanguagesSupported[1] ? 'API 扩展' : 'API Extension', + }) } - if (!systemTypes.includes(localeData.type as typeof systemTypes[number]) && currentProvider?.form_schema) { + if ( + !systemTypes.includes(localeData.type as (typeof systemTypes)[number]) && + currentProvider?.form_schema + ) { for (let i = 0; i < currentProvider.form_schema.length; i++) { const form = currentProvider.form_schema[i] if (!localeData.config?.[form!.variable] && form!.required) { - return t($ => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: locale === LanguagesSupported[1] ? form!.label['zh-Hans'] : form!.label['en-US'] }) + return t(($) => $['errorMessage.valueOfVarRequired'], { + ns: 'appDebug', + key: locale === LanguagesSupported[1] ? form!.label['zh-Hans'] : form!.label['en-US'], + }) } } } diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index 2cd48b34275f9d..6add0bf626067a 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -1,10 +1,16 @@ import type { FC } from 'react' -import type { - ExternalDataTool, -} from '@/models/common' +import type { ExternalDataTool } from '@/models/common' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, + SelectValue, +} from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' import { noop } from 'es-toolkit/function' import { useState } from 'react' @@ -47,7 +53,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({ locale, t, }) - const currentProvider = providers.find(provider => provider.key === localeData.type) + const currentProvider = providers.find((provider) => provider.key === localeData.type) const handleDataTypeChange = (type: string) => { setLocaleData({ @@ -98,36 +104,37 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({ const formattedData = formatExternalDataTool(localeData, currentProvider, !!data.type) - if (onValidateBeforeSave && !onValidateBeforeSave(formattedData)) - return + if (onValidateBeforeSave && !onValidateBeforeSave(formattedData)) return onSave(formattedData) } - const action = data.type ? t($ => $['operation.edit'], { ns: 'common' }) : t($ => $['operation.add'], { ns: 'common' }) + const action = data.type + ? t(($) => $['operation.edit'], { ns: 'common' }) + : t(($) => $['operation.add'], { ns: 'common' }) return ( - <Dialog - open - onOpenChange={noop} - > + <Dialog open onOpenChange={noop}> <DialogContent className="w-[640px]! max-w-none! p-8! pb-6!"> <div className="mb-2 text-xl font-semibold text-text-primary"> - {`${action} ${t($ => $['variableConfig.apiBasedVar'], { ns: 'appDebug' })}`} + {`${action} ${t(($) => $['variableConfig.apiBasedVar'], { ns: 'appDebug' })}`} </div> <div className="py-2"> <div className="text-sm/9 font-medium text-text-primary"> - {t($ => $['apiBasedExtension.type'], { ns: 'common' })} + {t(($) => $['apiBasedExtension.type'], { ns: 'common' })} </div> <Select defaultValue={localeData.type} - onValueChange={value => value && handleDataTypeChange(value)} + onValueChange={(value) => value && handleDataTypeChange(value)} > - <SelectTrigger className="w-full" aria-label={t($ => $['apiBasedExtension.type'], { ns: 'common' })}> + <SelectTrigger + className="w-full" + aria-label={t(($) => $['apiBasedExtension.type'], { ns: 'common' })} + > <SelectValue /> </SelectTrigger> <SelectContent popupClassName="w-[354px]"> - {providers.map(option => ( + {providers.map((option) => ( <SelectItem key={option.key} value={option.key}> <SelectItemText>{option.name}</SelectItemText> <SelectItemIndicator /> @@ -138,18 +145,22 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({ </div> <div className="py-2"> <div className="text-sm/9 font-medium text-text-primary"> - {t($ => $['feature.tools.modal.name.title'], { ns: 'appDebug' })} + {t(($) => $['feature.tools.modal.name.title'], { ns: 'appDebug' })} </div> <div className="flex items-center"> <input value={localeData.label || ''} - onChange={e => handleValueChange({ label: e.target.value })} + onChange={(e) => handleValueChange({ label: e.target.value })} className="mr-2 block h-9 grow appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-components-input-text-filled outline-hidden" - placeholder={t($ => $['feature.tools.modal.name.placeholder'], { ns: 'appDebug' }) || ''} + placeholder={ + t(($) => $['feature.tools.modal.name.placeholder'], { ns: 'appDebug' }) || '' + } /> <AppIcon size="large" - onClick={() => { setShowEmojiPicker(true) }} + onClick={() => { + setShowEmojiPicker(true) + }} className="h-9! w-9! cursor-pointer rounded-lg border-[0.5px] border-components-panel-border" icon={localeData.icon} background={localeData.icon_background} @@ -158,73 +169,61 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({ </div> <div className="py-2"> <div className="text-sm/9 font-medium text-text-primary"> - {t($ => $['feature.tools.modal.variableName.title'], { ns: 'appDebug' })} + {t(($) => $['feature.tools.modal.variableName.title'], { ns: 'appDebug' })} </div> <input value={localeData.variable || ''} - onChange={e => handleValueChange({ variable: e.target.value })} + onChange={(e) => handleValueChange({ variable: e.target.value })} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-components-input-text-filled outline-hidden" - placeholder={t($ => $['feature.tools.modal.variableName.placeholder'], { ns: 'appDebug' }) || ''} + placeholder={ + t(($) => $['feature.tools.modal.variableName.placeholder'], { ns: 'appDebug' }) || '' + } /> </div> - { - localeData.type === 'api' && ( - <div className="py-2"> - <div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary"> - {t($ => $['apiBasedExtension.selector.title'], { ns: 'common' })} - <a - href={docLink('/use-dify/workspace/api-extension/api-extension')} - target="_blank" - rel="noopener noreferrer" - className="group flex items-center text-xs font-normal text-text-tertiary hover:text-text-accent" - > - <BookOpen01 className="mr-1 size-3 text-text-tertiary group-hover:text-text-accent" /> - {t($ => $['apiBasedExtension.link'], { ns: 'common' })} - </a> - </div> - <ApiBasedExtensionSelector - value={localeData.config?.api_based_extension_id || ''} - onChange={handleDataApiBasedChange} - /> + {localeData.type === 'api' && ( + <div className="py-2"> + <div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary"> + {t(($) => $['apiBasedExtension.selector.title'], { ns: 'common' })} + <a + href={docLink('/use-dify/workspace/api-extension/api-extension')} + target="_blank" + rel="noopener noreferrer" + className="group flex items-center text-xs font-normal text-text-tertiary hover:text-text-accent" + > + <BookOpen01 className="mr-1 size-3 text-text-tertiary group-hover:text-text-accent" /> + {t(($) => $['apiBasedExtension.link'], { ns: 'common' })} + </a> </div> - ) - } - { - localeData.type !== 'api' - && currentProvider?.form_schema - && ( - <FormGeneration - forms={currentProvider?.form_schema} - value={localeData.config} - onChange={handleDataExtraChange} + <ApiBasedExtensionSelector + value={localeData.config?.api_based_extension_id || ''} + onChange={handleDataApiBasedChange} /> - ) - } + </div> + )} + {localeData.type !== 'api' && currentProvider?.form_schema && ( + <FormGeneration + forms={currentProvider?.form_schema} + value={localeData.config} + onChange={handleDataExtraChange} + /> + )} <div className="mt-6 flex items-center justify-end"> - <Button - onClick={onCancel} - className="mr-2" - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button onClick={onCancel} className="mr-2"> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button - variant="primary" - onClick={handleSave} - > - {t($ => $['operation.save'], { ns: 'common' })} + <Button variant="primary" onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> - { - showEmojiPicker && ( - <EmojiPicker - open={showEmojiPicker} - onOpenChange={setShowEmojiPicker} - onSelect={(icon, icon_background) => { - handleValueChange({ icon, icon_background }) - }} - /> - ) - } + {showEmojiPicker && ( + <EmojiPicker + open={showEmojiPicker} + onOpenChange={setShowEmojiPicker} + onSelect={(icon, icon_background) => { + handleValueChange({ icon, icon_background }) + }} + /> + )} </DialogContent> </Dialog> ) diff --git a/web/app/components/app/configuration/utils.ts b/web/app/components/app/configuration/utils.ts index e2237a2ae8216b..aed43503462e7e 100644 --- a/web/app/components/app/configuration/utils.ts +++ b/web/app/components/app/configuration/utils.ts @@ -1,7 +1,12 @@ import type { Namespace, SelectorParam, TFunction } from 'i18next' import type { Features as FeaturesData, FileUpload } from '@/app/components/base/features/types' import type { Collection } from '@/app/components/tools/types' -import type { BlockStatus, ChatPromptConfig, CompletionPromptConfig, ModelConfig } from '@/models/debug' +import type { + BlockStatus, + ChatPromptConfig, + CompletionPromptConfig, + ModelConfig, +} from '@/models/debug' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { AppModeEnum, ModelModeType, Resolution } from '@/types/app' @@ -35,8 +40,7 @@ export const getStringSelectorTranslate = <Ns extends Namespace>( } export const withCollectionIconBasePath = (collectionList: Collection[], prefix?: string) => { - if (!prefix) - return collectionList + if (!prefix) return collectionList return collectionList.map((item) => { if (typeof item.icon === 'string' && !item.icon.includes(prefix)) @@ -65,13 +69,25 @@ export const buildConfigurationFeaturesData = ( detail: modelConfig.file_upload?.image?.detail || Resolution.high, enabled: !!modelConfig.file_upload?.image?.enabled, number_limits: modelConfig.file_upload?.image?.number_limits || 3, - transfer_methods: modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + transfer_methods: modelConfig.file_upload?.image?.transfer_methods || [ + 'local_file', + 'remote_url', + ], }, enabled: !!(modelConfig.file_upload?.enabled || modelConfig.file_upload?.image?.enabled), allowed_file_types: modelConfig.file_upload?.allowed_file_types || [], - allowed_file_extensions: modelConfig.file_upload?.allowed_file_extensions || [...(FILE_EXTS[SupportUploadFileTypes.image] ?? []), ...(FILE_EXTS[SupportUploadFileTypes.video] ?? [])].map(ext => `.${ext}`), - allowed_file_upload_methods: modelConfig.file_upload?.allowed_file_upload_methods || modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], - number_limits: modelConfig.file_upload?.number_limits || modelConfig.file_upload?.image?.number_limits || 3, + allowed_file_extensions: + modelConfig.file_upload?.allowed_file_extensions || + [ + ...(FILE_EXTS[SupportUploadFileTypes.image] ?? []), + ...(FILE_EXTS[SupportUploadFileTypes.video] ?? []), + ].map((ext) => `.${ext}`), + allowed_file_upload_methods: modelConfig.file_upload?.allowed_file_upload_methods || + modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + number_limits: + modelConfig.file_upload?.number_limits || + modelConfig.file_upload?.image?.number_limits || + 3, fileUploadConfig: fileUploadConfigResponse, } as FileUpload, suggested: modelConfig.suggested_questions_after_answer || { enabled: false }, @@ -102,8 +118,7 @@ export const getConfigurationPublishingState = ({ promptTemplate: string }) => { const promptEmpty = (() => { - if (mode !== AppModeEnum.COMPLETION) - return false + if (mode !== AppModeEnum.COMPLETION) return false if (isAdvancedMode) { if (modelModeType === ModelModeType.chat) @@ -117,12 +132,10 @@ export const getConfigurationPublishingState = ({ const cannotPublish = (() => { if (mode !== AppModeEnum.COMPLETION) { - if (!isAdvancedMode) - return false + if (!isAdvancedMode) return false if (modelModeType === ModelModeType.completion) { - if (!hasSetBlockStatus.history || !hasSetBlockStatus.query) - return true + if (!hasSetBlockStatus.history || !hasSetBlockStatus.query) return true } return false diff --git a/web/app/components/app/create-app-dialog-shell.tsx b/web/app/components/app/create-app-dialog-shell.tsx index 4df4fb6d700801..508b0cedacaca0 100644 --- a/web/app/components/app/create-app-dialog-shell.tsx +++ b/web/app/components/app/create-app-dialog-shell.tsx @@ -23,8 +23,7 @@ export function CreateAppDialogShell({ <Dialog open={show} onOpenChange={(nextOpen) => { - if (!nextOpen) - onClose() + if (!nextOpen) onClose() }} > <DialogContent @@ -40,7 +39,10 @@ export function CreateAppDialogShell({ className="absolute top-3 right-3 z-50 flex h-9 w-9 cursor-pointer items-center justify-center rounded-[10px] bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover" onClick={onClose} > - <span aria-hidden="true" className="i-ri-close-large-line size-3.5 text-components-button-tertiary-text" /> + <span + aria-hidden="true" + className="i-ri-close-large-line size-3.5 text-components-button-tertiary-text" + /> </button> {children} </div> diff --git a/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx index b80e98f8d7e537..6741e058c92e1c 100644 --- a/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx @@ -145,7 +145,7 @@ describe('CreateAppTemplateDialog', () => { show={true} onSuccess={vi.fn()} onClose={vi.fn()} - // onCreateFromBlank is undefined + // onCreateFromBlank is undefined />, ) }).not.toThrow() diff --git a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx index 17194796f4fa38..a49de1bc5cc73b 100644 --- a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx @@ -10,7 +10,11 @@ import { AppModeEnum } from '@/types/app' import AppCard from '../index' vi.mock('@heroicons/react/20/solid', () => ({ - PlusIcon: ({ className }: any) => <div data-testid="plus-icon" className={className} aria-label="Add icon">+</div>, + PlusIcon: ({ className }: any) => ( + <div data-testid="plus-icon" className={className} aria-label="Add icon"> + + + </div> + ), })) vi.mock('@/app/components/base/amplitude', () => ({ @@ -155,7 +159,9 @@ describe('AppCard', () => { } render(<AppCard {...defaultProps} app={longNameApp} />) - const nameElement = screen.getByTitle('This is a very long app name that should be truncated with line-clamp-1') + const nameElement = screen.getByTitle( + 'This is a very long app name that should be truncated with line-clamp-1', + ) expect(nameElement).toBeInTheDocument() }) }) @@ -234,7 +240,10 @@ describe('AppCard', () => { } render(<AppCard {...defaultProps} app={appWithImageUrl} />) - expect(screen.getByRole('img', { name: 'app icon' })).toHaveAttribute('src', 'https://example.com/remote-icon.png') + expect(screen.getByRole('img', { name: 'app icon' })).toHaveAttribute( + 'src', + 'https://example.com/remote-icon.png', + ) }) }) @@ -281,7 +290,9 @@ describe('AppCard', () => { mockConfig.isCloudEdition = false renderWithProvider(<AppCard {...defaultProps} />) - expect(screen.queryByRole('button', { name: /explore\.appCard\.try/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /explore\.appCard\.try/ }), + ).not.toBeInTheDocument() }) }) @@ -291,7 +302,9 @@ describe('AppCard', () => { render(<AppCard {...defaultProps} onCreate={mockOnCreate} />) await userEvent.tab() - const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ }) as HTMLButtonElement + const button = screen.getByRole('button', { + name: /app\.newApp\.useTemplate/, + }) as HTMLButtonElement // Test that button can be focused expect(button).toHaveFocus() @@ -331,7 +344,8 @@ describe('AppCard', () => { }) it('should handle app with very long description', () => { - const longDescription = 'This is a very long description that should be truncated with line-clamp-3. '.repeat(5) + const longDescription = + 'This is a very long description that should be truncated with line-clamp-3. '.repeat(5) const appWithLongDesc = { ...mockApp, description: longDescription, @@ -351,7 +365,9 @@ describe('AppCard', () => { } render(<AppCard {...defaultProps} app={appWithSpecialChars} />) - expect(screen.getByText('App <script>alert("test")</script> & Special "Chars"')).toBeInTheDocument() + expect( + screen.getByText('App <script>alert("test")</script> & Special "Chars"'), + ).toBeInTheDocument() }) it('should handle onCreate function throwing error', async () => { @@ -368,14 +384,12 @@ describe('AppCard', () => { let capturedError: unknown try { await userEvent.click(button) - } - catch (err) { + } catch (err) { capturedError = err } expect(errorOnCreate).toHaveBeenCalledTimes(1) // expect(consoleSpy).toHaveBeenCalled() - if (capturedError instanceof Error) - expect(capturedError.message).toContain('Create failed') + if (capturedError instanceof Error) expect(capturedError.message).toContain('Create failed') consoleSpy.mockRestore() }) diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx index 844506342b879b..dd3fbd25f6e94c 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.tsx @@ -19,15 +19,11 @@ type AppCardProps = { onCreate: () => void } -const AppCard = ({ - app, - canCreate, - onCreate, -}: AppCardProps) => { +const AppCard = ({ app, canCreate, onCreate }: AppCardProps) => { const { t } = useTranslation() const { app: appBasicInfo } = app const canViewApp = IS_CLOUD_EDITION - const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel) + const setShowTryAppPanel = useContextSelector(AppListContext, (ctx) => ctx.setShowTryAppPanel) const handleShowTryAppPanel = useCallback(() => { trackEvent('preview_template', { template_id: app.app_id, @@ -39,7 +35,11 @@ const AppCard = ({ setShowTryAppPanel?.(true, { appId: app.app_id, app }) }, [setShowTryAppPanel, app, appBasicInfo]) return ( - <div className={cn('group relative flex h-[132px] cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 shadow-xs hover:shadow-lg')}> + <div + className={cn( + 'group relative flex h-[132px] cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 shadow-xs hover:shadow-lg', + )} + > <div className="flex shrink-0 grow-0 items-center gap-3 pb-2"> <div className="relative shrink-0"> <AppIcon @@ -57,29 +57,41 @@ const AppCard = ({ </div> <div className="flex grow flex-col gap-1"> <div className="line-clamp-1"> - <span className="system-md-semibold text-text-secondary" title={appBasicInfo.name}>{appBasicInfo.name}</span> + <span className="system-md-semibold text-text-secondary" title={appBasicInfo.name}> + {appBasicInfo.name} + </span> </div> - <AppTypeLabel className="system-2xs-medium-uppercase text-text-tertiary" type={app.app.mode} /> + <AppTypeLabel + className="system-2xs-medium-uppercase text-text-tertiary" + type={app.app.mode} + /> </div> </div> <div className="py-1 system-xs-regular text-text-tertiary"> - <div className="line-clamp-3"> - {app.description} - </div> + <div className="line-clamp-3">{app.description}</div> </div> {(canCreate || canViewApp) && ( - <div className={cn('absolute right-0 bottom-0 left-0 hidden bg-linear-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}> - <div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', canCreate && canViewApp && 'grid-cols-2')}> + <div + className={cn( + 'absolute right-0 bottom-0 left-0 hidden bg-linear-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex', + )} + > + <div + className={cn( + 'grid h-8 w-full grid-cols-1 items-center space-x-2', + canCreate && canViewApp && 'grid-cols-2', + )} + > {canCreate && ( <Button variant="primary" onClick={() => onCreate()}> <PlusIcon className="mr-1 size-4" /> - <span className="text-xs">{t($ => $['newApp.useTemplate'], { ns: 'app' })}</span> + <span className="text-xs">{t(($) => $['newApp.useTemplate'], { ns: 'app' })}</span> </Button> )} {canViewApp && ( <Button onClick={handleShowTryAppPanel}> <RiInformation2Line className="mr-1 size-4" /> - <span>{t($ => $['appCard.try'], { ns: 'explore' })}</span> + <span>{t(($) => $['appCard.try'], { ns: 'explore' })}</span> </Button> )} </div> diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 56737aab1d83da..76d8425b7ec10b 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -71,7 +71,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -82,18 +83,48 @@ vi.mock('@/service/use-explore', () => ({ useExploreAppList: () => mockUseExploreAppList(), })) vi.mock('@/app/components/app/type-selector', () => ({ - default: ({ value, onChange }: { value: AppModeEnum[], onChange: (value: AppModeEnum[]) => void }) => ( + default: ({ + value, + onChange, + }: { + value: AppModeEnum[] + onChange: (value: AppModeEnum[]) => void + }) => ( <div> - <button data-testid="type-selector-chat" onClick={() => onChange([AppModeEnum.CHAT])}>{value.join(',')}</button> - <button data-testid="type-selector-advanced" onClick={() => onChange([AppModeEnum.ADVANCED_CHAT])}>advanced</button> - <button data-testid="type-selector-agent" onClick={() => onChange([AppModeEnum.AGENT_CHAT])}>agent</button> - <button data-testid="type-selector-completion" onClick={() => onChange([AppModeEnum.COMPLETION])}>completion</button> - <button data-testid="type-selector-workflow" onClick={() => onChange([AppModeEnum.WORKFLOW])}>workflow</button> + <button data-testid="type-selector-chat" onClick={() => onChange([AppModeEnum.CHAT])}> + {value.join(',')} + </button> + <button + data-testid="type-selector-advanced" + onClick={() => onChange([AppModeEnum.ADVANCED_CHAT])} + > + advanced + </button> + <button data-testid="type-selector-agent" onClick={() => onChange([AppModeEnum.AGENT_CHAT])}> + agent + </button> + <button + data-testid="type-selector-completion" + onClick={() => onChange([AppModeEnum.COMPLETION])} + > + completion + </button> + <button data-testid="type-selector-workflow" onClick={() => onChange([AppModeEnum.WORKFLOW])}> + workflow + </button> </div> ), })) vi.mock('../../app-card', () => ({ - default: ({ app, canCreate, onCreate }: { app: { app: { name: string } }, canCreate: boolean, onCreate: () => void }) => ( + default: ({ + app, + canCreate, + onCreate, + }: { + app: { app: { name: string } } + canCreate: boolean + onCreate: () => void + }) => ( <button type="button" data-testid="app-card" @@ -106,7 +137,11 @@ vi.mock('../../app-card', () => ({ ), })) vi.mock('@/app/components/explore/create-app-modal', () => ({ - default: ({ onConfirm, onHide, show }: { + default: ({ + onConfirm, + onHide, + show, + }: { onConfirm: (payload: { name: string icon_type: string @@ -116,25 +151,28 @@ vi.mock('@/app/components/explore/create-app-modal', () => ({ }) => Promise<void> onHide: () => void show: boolean - }) => show - ? ( - <div data-testid="create-from-template-modal"> - <button - data-testid="confirm-create" - onClick={() => onConfirm({ + }) => + show ? ( + <div data-testid="create-from-template-modal"> + <button + data-testid="confirm-create" + onClick={() => + onConfirm({ name: 'Created App', icon_type: 'emoji', icon: '🙂', icon_background: '#fff', description: 'created from template', - })} - > - confirm-create - </button> - <button data-testid="hide-create-modal" onClick={onHide}>hide-create-modal</button> - </div> - ) - : null, + }) + } + > + confirm-create + </button> + <button data-testid="hide-create-modal" onClick={onHide}> + hide-create-modal + </button> + </div> + ) : null, })) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { @@ -156,7 +194,8 @@ vi.mock('@/service/explore', () => ({ })) vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ usePluginDependencies: () => ({ - handleCheckPluginDependencies: (...args: unknown[]) => mockHandleCheckPluginDependencies(...args), + handleCheckPluginDependencies: (...args: unknown[]) => + mockHandleCheckPluginDependencies(...args), }), })) vi.mock('@/utils/app-redirection', () => ({ @@ -314,10 +353,12 @@ describe('Apps', () => { await waitFor(() => { expect(mockFetchAppDetail).toHaveBeenCalledWith('Alpha') - expect(mockImportDSL).toHaveBeenCalledWith(expect.objectContaining({ - yaml_content: 'dsl', - name: 'Created App', - })) + expect(mockImportDSL).toHaveBeenCalledWith( + expect.objectContaining({ + yaml_content: 'dsl', + name: 'Created App', + }), + ) }) expect(mockTrackCreateApp).toHaveBeenCalledWith({ @@ -330,16 +371,20 @@ describe('Apps', () => { expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('created-app-id') expect(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY)).toBe('1') expect(mockInvalidateAppList).toHaveBeenCalledTimes(1) - expect(mockGetRedirection).toHaveBeenCalledWith({ - id: 'created-app-id', - mode: AppModeEnum.CHAT, - permission_keys: ['app.acl.view_layout'], - }, mockPush, { - currentUserId: 'user-1', - resourceMaintainer: 'user-1', - workspacePermissionKeys: ['app.create_and_management'], - isRbacEnabled: false, - }) + expect(mockGetRedirection).toHaveBeenCalledWith( + { + id: 'created-app-id', + mode: AppModeEnum.CHAT, + permission_keys: ['app.acl.view_layout'], + }, + mockPush, + { + currentUserId: 'user-1', + resourceMaintainer: 'user-1', + workspacePermissionKeys: ['app.create_and_management'], + isRbacEnabled: false, + }, + ) }) it('passes creator context when template import response has no permission keys', async () => { @@ -354,16 +399,20 @@ describe('Apps', () => { fireEvent.click(screen.getByTestId('confirm-create')) await waitFor(() => { - expect(mockGetRedirection).toHaveBeenCalledWith({ - id: 'created-without-permissions', - mode: AppModeEnum.WORKFLOW, - permission_keys: undefined, - }, mockPush, { - currentUserId: 'user-1', - resourceMaintainer: 'user-1', - workspacePermissionKeys: ['app.create_and_management'], - isRbacEnabled: false, - }) + expect(mockGetRedirection).toHaveBeenCalledWith( + { + id: 'created-without-permissions', + mode: AppModeEnum.WORKFLOW, + permission_keys: undefined, + }, + mockPush, + { + currentUserId: 'user-1', + resourceMaintainer: 'user-1', + workspacePermissionKeys: ['app.create_and_management'], + isRbacEnabled: false, + }, + ) }) }) diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 2921fb9276d9b2..5313b21d24e8de 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -44,16 +44,16 @@ type AppsProps = { // CREATE = 'create', // } -const Apps = ({ - onSuccess, - onCreateFromBlank, -}: AppsProps) => { +const Apps = ({ onSuccess, onCreateFromBlank }: AppsProps) => { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled - const canCreateAppFromTemplate = hasPermission(workspacePermissionKeys, 'app.create_and_management') + const canCreateAppFromTemplate = hasPermission( + workspacePermissionKeys, + 'app.create_and_management', + ) const { push } = useRouter() const invalidateAppList = useInvalidateAppList() const allCategoriesEn = AppCategories.RECOMMENDED @@ -63,9 +63,12 @@ const Apps = ({ const [keywords, setKeywords] = useState('') const [searchKeywords, setSearchKeywords] = useState('') - const { run: handleSearch } = useDebounceFn(() => { - setSearchKeywords(keywords) - }, { wait: 500 }) + const { run: handleSearch } = useDebounceFn( + () => { + setSearchKeywords(keywords) + }, + { wait: 500 }, + ) const handleKeywordsChange = (value: string) => { setKeywords(value) @@ -75,42 +78,35 @@ const Apps = ({ const [currentType, setCurrentType] = useState<AppModeEnum[]>([]) const [currCategory, setCurrCategory] = useState<AppCategories | string>(allCategoriesEn) - const { - data, - isLoading, - } = useExploreAppList() + const { data, isLoading } = useExploreAppList() const visibleCategories = useMemo(() => { - if (!data) - return [] + if (!data) return [] const categoriesWithApps = new Set<string>() data.allList.forEach((app) => { - app.categories.forEach(category => categoriesWithApps.add(category)) + app.categories.forEach((category) => categoriesWithApps.add(category)) }) - return data.categories.filter(category => categoriesWithApps.has(category)) + return data.categories.filter((category) => categoriesWithApps.has(category)) }, [data]) - const activeCategory = visibleCategories.includes(currCategory) - ? currCategory - : allCategoriesEn + const activeCategory = visibleCategories.includes(currCategory) ? currCategory : allCategoriesEn const filteredList = useMemo(() => { - if (!data) - return [] + if (!data) return [] const { allList } = data const filteredByCategory = allList.filter((item) => { - if (activeCategory === allCategoriesEn) - return true + if (activeCategory === allCategoriesEn) return true return item.categories?.includes(activeCategory) ?? false }) - if (currentType.length === 0) - return filteredByCategory + if (currentType.length === 0) return filteredByCategory return filteredByCategory.filter((item) => { - if (currentType.includes(AppModeEnum.CHAT) && item.app.mode === AppModeEnum.CHAT) - return true - if (currentType.includes(AppModeEnum.ADVANCED_CHAT) && item.app.mode === AppModeEnum.ADVANCED_CHAT) + if (currentType.includes(AppModeEnum.CHAT) && item.app.mode === AppModeEnum.CHAT) return true + if ( + currentType.includes(AppModeEnum.ADVANCED_CHAT) && + item.app.mode === AppModeEnum.ADVANCED_CHAT + ) return true if (currentType.includes(AppModeEnum.AGENT_CHAT) && item.app.mode === AppModeEnum.AGENT_CHAT) return true @@ -123,13 +119,13 @@ const Apps = ({ }, [currentType, activeCategory, allCategoriesEn, data]) const searchFilteredList = useMemo(() => { - if (!searchKeywords || !filteredList || filteredList.length === 0) - return filteredList + if (!searchKeywords || !filteredList || filteredList.length === 0) return filteredList const lowerCaseSearchKeywords = searchKeywords.toLowerCase() - return filteredList.filter(item => - item.app && item.app.name && item.app.name.toLowerCase().includes(lowerCaseSearchKeywords), + return filteredList.filter( + (item) => + item.app && item.app.name && item.app.name.toLowerCase().includes(lowerCaseSearchKeywords), ) }, [searchKeywords, filteredList]) @@ -143,9 +139,7 @@ const Apps = ({ icon_background, description, }) => { - const { export_data, mode } = await fetchAppDetail( - currApp?.app.id as string, - ) + const { export_data, mode } = await fetchAppDetail(currApp?.app.id as string) try { const app = await importDSL({ mode: DSLImportMode.YAML_CONTENT, @@ -159,24 +153,25 @@ const Apps = ({ trackCreateApp({ source: 'studio_template_list', appMode: mode, templateId: currApp?.app_id }) setIsShowCreateModal(false) - toast.success(t($ => $['newApp.appCreated'], { ns: 'app' })) - if (onSuccess) - onSuccess() - if (app.app_id) - await handleCheckPluginDependencies(app.app_id) + toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) + if (onSuccess) onSuccess() + if (app.app_id) await handleCheckPluginDependencies(app.app_id) setNeedRefresh('1') invalidateAppList() if (app.app_id) { - getRedirection({ id: app.app_id, mode: app.app_mode, permission_keys: app.permission_keys }, push, { - currentUserId, - resourceMaintainer: currentUserId, - workspacePermissionKeys, - isRbacEnabled, - }) + getRedirection( + { id: app.app_id, mode: app.app_mode, permission_keys: app.permission_keys }, + push, + { + currentUserId, + resourceMaintainer: currentUserId, + workspacePermissionKeys, + isRbacEnabled, + }, + ) } - } - catch { - toast.error(t($ => $['newApp.appCreateFailed'], { ns: 'app' })) + } catch { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) } } @@ -192,7 +187,9 @@ const Apps = ({ <div className="flex h-full flex-col"> <div className="flex items-center justify-between border-b border-divider-burn py-3"> <div className="min-w-[180px] pl-5"> - <span className="title-xl-semi-bold text-text-primary">{t($ => $['newApp.startFromTemplate'], { ns: 'app' })}</span> + <span className="title-xl-semi-bold text-text-primary"> + {t(($) => $['newApp.startFromTemplate'], { ns: 'app' })} + </span> </div> <div className="flex max-w-[548px] flex-1 items-center rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-1.5 shadow-md"> <AppTypeSelector value={currentType} onChange={setCurrentType} /> @@ -203,9 +200,11 @@ const Apps = ({ showClearIcon wrapperClassName="w-full flex-1" className="bg-transparent hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent focus:shadow-none" - placeholder={t($ => $['newAppFromTemplate.searchAllTemplate'], { ns: 'app' }) as string} + placeholder={ + t(($) => $['newAppFromTemplate.searchAllTemplate'], { ns: 'app' }) as string + } value={keywords} - onChange={e => handleKeywordsChange(e.target.value)} + onChange={(e) => handleKeywordsChange(e.target.value)} onClear={() => handleKeywordsChange('')} /> </div> @@ -214,27 +213,47 @@ const Apps = ({ <div className="relative flex flex-1 overflow-y-auto"> {!searchKeywords && ( <div className="h-full w-[200px] p-4"> - <Sidebar current={activeCategory as AppCategories} categories={visibleCategories} onClick={(category) => { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} /> + <Sidebar + current={activeCategory as AppCategories} + categories={visibleCategories} + onClick={(category) => { + setCurrCategory(category) + }} + onCreateFromBlank={onCreateFromBlank} + /> </div> )} <div className="h-full flex-1 shrink-0 grow overflow-auto border-l border-divider-burn p-6 pt-2"> {searchFilteredList && searchFilteredList.length > 0 && ( <> <div className="pt-4 pb-1"> - {searchKeywords - ? <p className="title-md-semi-bold text-text-tertiary">{searchFilteredList.length > 1 ? t($ => $['newApp.foundResults'], { ns: 'app', count: searchFilteredList.length }) : t($ => $['newApp.foundResult'], { ns: 'app', count: searchFilteredList.length })}</p> - : ( - <div className="flex h-[22px] items-center"> - <AppCategoryLabel category={activeCategory as AppCategories} className="title-md-semi-bold text-text-primary" /> - </div> - )} + {searchKeywords ? ( + <p className="title-md-semi-bold text-text-tertiary"> + {searchFilteredList.length > 1 + ? t(($) => $['newApp.foundResults'], { + ns: 'app', + count: searchFilteredList.length, + }) + : t(($) => $['newApp.foundResult'], { + ns: 'app', + count: searchFilteredList.length, + })} + </p> + ) : ( + <div className="flex h-[22px] items-center"> + <AppCategoryLabel + category={activeCategory as AppCategories} + className="title-md-semi-bold text-text-primary" + /> + </div> + )} </div> <div className={cn( 'grid shrink-0 grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3', )} > - {searchFilteredList.map(app => ( + {searchFilteredList.map((app) => ( <AppCard key={app.app_id} app={app} @@ -277,8 +296,12 @@ function NoTemplateFound() { <div className="mb-2 inline-flex size-8 items-center justify-center rounded-lg bg-components-card-bg shadow-lg"> <RiRobot2Line className="size-5 text-text-tertiary" /> </div> - <p className="title-md-semi-bold text-text-primary">{t($ => $['newApp.noTemplateFound'], { ns: 'app' })}</p> - <p className="system-sm-regular text-text-tertiary">{t($ => $['newApp.noTemplateFoundTip'], { ns: 'app' })}</p> + <p className="title-md-semi-bold text-text-primary"> + {t(($) => $['newApp.noTemplateFound'], { ns: 'app' })} + </p> + <p className="system-sm-regular text-text-tertiary"> + {t(($) => $['newApp.noTemplateFoundTip'], { ns: 'app' })} + </p> </div> ) } diff --git a/web/app/components/app/create-app-dialog/app-list/sidebar.tsx b/web/app/components/app/create-app-dialog/app-list/sidebar.tsx index 64246e91cb0294..e5ef0d483b8599 100644 --- a/web/app/components/app/create-app-dialog/app-list/sidebar.tsx +++ b/web/app/components/app/create-app-dialog/app-list/sidebar.tsx @@ -20,11 +20,24 @@ export default function Sidebar({ current, categories, onClick, onCreateFromBlan return ( <div className="flex size-full flex-col"> <ul className="pt-0.5"> - <CategoryItem category={AppCategories.RECOMMENDED} active={current === AppCategories.RECOMMENDED} onClick={onClick} /> + <CategoryItem + category={AppCategories.RECOMMENDED} + active={current === AppCategories.RECOMMENDED} + onClick={onClick} + /> </ul> - <div className="mt-3 mb-0.5 px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{t($ => $['newAppFromTemplate.byCategories'], { ns: 'app' })}</div> + <div className="mt-3 mb-0.5 px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['newAppFromTemplate.byCategories'], { ns: 'app' })} + </div> <ul className="flex grow flex-col gap-0.5"> - {categories.map(category => (<CategoryItem key={category} category={category} active={current === category} onClick={onClick} />))} + {categories.map((category) => ( + <CategoryItem + key={category} + category={category} + active={current === category} + onClick={onClick} + /> + ))} </ul> <Divider bgStyle="gradient" /> <button @@ -33,7 +46,9 @@ export default function Sidebar({ current, categories, onClick, onCreateFromBlan onClick={onCreateFromBlank} > <RiStickyNoteAddLine className="size-3.5" aria-hidden="true" /> - <span className="system-xs-regular">{t($ => $['newApp.startFromBlank'], { ns: 'app' })}</span> + <span className="system-xs-regular"> + {t(($) => $['newApp.startFromBlank'], { ns: 'app' })} + </span> </button> </div> ) @@ -49,12 +64,20 @@ function CategoryItem({ category, active, onClick }: CategoryItemProps) { <li> <button type="button" - className={cn('group flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent p-1 pl-3 text-left hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden [&.active]:bg-state-base-active', active && 'active')} - onClick={() => { onClick?.(category) }} + className={cn( + 'group flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent p-1 pl-3 text-left hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden [&.active]:bg-state-base-active', + active && 'active', + )} + onClick={() => { + onClick?.(category) + }} > <AppCategoryLabel category={category} - className={cn('system-sm-medium text-components-menu-item-text group-hover:text-components-menu-item-text-hover group-[.active]:text-components-menu-item-text-active', active && 'system-sm-semibold')} + className={cn( + 'system-sm-medium text-components-menu-item-text group-hover:text-components-menu-item-text-hover group-[.active]:text-components-menu-item-text-active', + active && 'system-sm-semibold', + )} /> </button> </li> @@ -67,5 +90,11 @@ type AppCategoryLabelProps = { } export function AppCategoryLabel({ category, className }: AppCategoryLabelProps) { const { t } = useTranslation() - return <span className={className}>{category === AppCategories.RECOMMENDED ? t($ => $['newAppFromTemplate.sidebar.Recommended'], { ns: 'app' }) : category}</span> + return ( + <span className={className}> + {category === AppCategories.RECOMMENDED + ? t(($) => $['newAppFromTemplate.sidebar.Recommended'], { ns: 'app' }) + : category} + </span> + ) } diff --git a/web/app/components/app/create-app-dialog/index.tsx b/web/app/components/app/create-app-dialog/index.tsx index 4dbac369d4b299..73ece1e3fefe71 100644 --- a/web/app/components/app/create-app-dialog/index.tsx +++ b/web/app/components/app/create-app-dialog/index.tsx @@ -10,11 +10,20 @@ type CreateAppDialogProps = { onCreateFromBlank?: () => void } -const CreateAppTemplateDialog = ({ show, onSuccess, onClose, onCreateFromBlank }: CreateAppDialogProps) => { +const CreateAppTemplateDialog = ({ + show, + onSuccess, + onClose, + onCreateFromBlank, +}: CreateAppDialogProps) => { const { t } = useTranslation() return ( - <CreateAppDialogShell show={show} title={t($ => $['newApp.startFromTemplate'], { ns: 'app' })} onClose={onClose}> + <CreateAppDialogShell + show={show} + title={t(($) => $['newApp.startFromTemplate'], { ns: 'app' })} + onClose={onClose} + > <AppList onCreateFromBlank={onCreateFromBlank} onSuccess={() => { diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index 014aef99e73d1c..a3736ecca09aef 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -1,7 +1,6 @@ import type { App } from '@/types/app' import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage' import { useProviderContext } from '@/context/provider-context' @@ -65,7 +64,9 @@ vi.mock('@/app/components/billing/apps-full-in-dialog', () => ({ })) vi.mock('@/app/components/base/app-icon', () => ({ default: ({ onClick }: { onClick: () => void }) => ( - <button type="button" onClick={onClick}>open-icon-picker</button> + <button type="button" onClick={onClick}> + open-icon-picker + </button> ), })) vi.mock('@/utils/app-redirection', () => ({ @@ -100,7 +101,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -182,7 +184,11 @@ describe('CreateAppModal', () => { }) it('creates an app, notifies success, and fires callbacks', async () => { - const mockApp: Partial<App> = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT, maintainer: 'user-1' } + const mockApp: Partial<App> = { + id: 'app-1', + mode: AppModeEnum.ADVANCED_CHAT, + maintainer: 'user-1', + } mockCreateApp.mockResolvedValue(mockApp as App) const { onClose, onSuccess } = renderModal() @@ -190,16 +196,21 @@ describe('CreateAppModal', () => { fireEvent.change(nameInput, { target: { value: 'My App' } }) fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ })) - await waitFor(() => expect(mockCreateApp).toHaveBeenCalledWith({ - name: 'My App', - description: '', - icon_type: 'emoji', - icon: '🤖', - icon_background: '#FFEAD5', - mode: AppModeEnum.ADVANCED_CHAT, - })) + await waitFor(() => + expect(mockCreateApp).toHaveBeenCalledWith({ + name: 'My App', + description: '', + icon_type: 'emoji', + icon: '🤖', + icon_background: '#FFEAD5', + mode: AppModeEnum.ADVANCED_CHAT, + }), + ) - expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_blank', appMode: AppModeEnum.ADVANCED_CHAT }) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + source: 'studio_blank', + appMode: AppModeEnum.ADVANCED_CHAT, + }) expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated') expect(onSuccess).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() @@ -216,12 +227,18 @@ describe('CreateAppModal', () => { }) it('waits for create_app tracking before redirecting after blank app creation', async () => { - const mockApp: Partial<App> = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT, maintainer: 'user-1' } + const mockApp: Partial<App> = { + id: 'app-1', + mode: AppModeEnum.ADVANCED_CHAT, + maintainer: 'user-1', + } let resolveTracking: (() => void) | undefined mockCreateApp.mockResolvedValue(mockApp as App) - mockTrackCreateApp.mockReturnValue(new Promise<void>((resolve) => { - resolveTracking = resolve - })) + mockTrackCreateApp.mockReturnValue( + new Promise<void>((resolve) => { + resolveTracking = resolve + }), + ) renderModal() fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { @@ -230,7 +247,10 @@ describe('CreateAppModal', () => { fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ })) await waitFor(() => { - expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_blank', appMode: AppModeEnum.ADVANCED_CHAT }) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + source: 'studio_blank', + appMode: AppModeEnum.ADVANCED_CHAT, + }) }) expect(mockGetRedirection).not.toHaveBeenCalled() @@ -369,18 +389,23 @@ describe('CreateAppModal', () => { fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ })) await waitFor(() => { - expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ - name: 'Completion App', - mode: AppModeEnum.COMPLETION, - })) + expect(mockCreateApp).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Completion App', + mode: AppModeEnum.COMPLETION, + }), + ) }) }) it('should ignore duplicate create clicks while a request is in flight', async () => { let resolveCreate: ((value: App) => void) | undefined - mockCreateApp.mockImplementation(() => new Promise((resolve) => { - resolveCreate = resolve as (value: App) => void - })) + mockCreateApp.mockImplementation( + () => + new Promise((resolve) => { + resolveCreate = resolve as (value: App) => void + }), + ) renderModal() fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index e1007368a810b8..5116559f2d5741 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -2,7 +2,6 @@ import type { AppIconSelection } from '../../base/app-icon-picker' import { Button } from '@langgenius/dify-ui/button' - import { cn } from '@langgenius/dify-ui/cn' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Textarea } from '@langgenius/dify-ui/textarea' @@ -17,7 +16,12 @@ import { useTranslation } from 'react-i18next' import { useSetNeedRefreshAppList } from '@/app/components/apps/storage' import AppIcon from '@/app/components/base/app-icon' import Divider from '@/app/components/base/divider' -import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication' +import { + BubbleTextMod, + ChatBot, + ListSparkle, + Logic, +} from '@/app/components/base/icons/src/vender/solid/communication' import Input from '@/app/components/base/input' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { userProfileIdAtom } from '@/context/account-state' @@ -44,7 +48,11 @@ type CreateAppProps = { } const shouldExpandBeginnerAppTypes = (appMode?: AppModeEnum) => { - return appMode === AppModeEnum.CHAT || appMode === AppModeEnum.AGENT_CHAT || appMode === AppModeEnum.COMPLETION + return ( + appMode === AppModeEnum.CHAT || + appMode === AppModeEnum.AGENT_CHAT || + appMode === AppModeEnum.COMPLETION + ) } function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppProps) { @@ -52,14 +60,20 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: const { push } = useRouter() const [appMode, setAppMode] = useState<AppModeEnum>(defaultAppMode || AppModeEnum.ADVANCED_CHAT) - const [appIcon, setAppIcon] = useState<AppIconSelection>({ type: 'emoji', icon: '🤖', background: '#FFEAD5' }) + const [appIcon, setAppIcon] = useState<AppIconSelection>({ + type: 'emoji', + icon: '🤖', + background: '#FFEAD5', + }) const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [name, setName] = useState('') const [description, setDescription] = useState('') - const [isAppTypeExpanded, setIsAppTypeExpanded] = useState(() => shouldExpandBeginnerAppTypes(defaultAppMode)) + const [isAppTypeExpanded, setIsAppTypeExpanded] = useState(() => + shouldExpandBeginnerAppTypes(defaultAppMode), + ) const { plan, enableBilling } = useProviderContext() - const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) + const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) @@ -72,19 +86,17 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: const setNeedRefresh = useSetNeedRefreshAppList() const onCreate = useCallback(async () => { - if (!canCreateApp) - return + if (!canCreateApp) return if (!appMode) { - toast.error(t($ => $['newApp.appTypeRequired'], { ns: 'app' })) + toast.error(t(($) => $['newApp.appTypeRequired'], { ns: 'app' })) return } if (!name.trim()) { - toast.error(t($ => $['newApp.nameNotEmpty'], { ns: 'app' })) + toast.error(t(($) => $['newApp.nameNotEmpty'], { ns: 'app' })) return } - if (isCreatingRef.current) - return + if (isCreatingRef.current) return isCreatingRef.current = true try { const app = await createApp({ @@ -98,12 +110,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: try { await trackCreateApp({ source: 'studio_blank', appMode: app.mode }) - } - catch { + } catch { // Analytics should not turn a successful app creation into a failed flow. } - toast.success(t($ => $['newApp.appCreated'], { ns: 'app' })) + toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) onSuccess() onClose() setNeedRefresh('1') @@ -114,21 +125,42 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: workspacePermissionKeys, isRbacEnabled, }) - } - catch (error) { - toast.error(error instanceof Error ? error.message : t($ => $['newApp.appCreateFailed'], { ns: 'app' })) + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : t(($) => $['newApp.appCreateFailed'], { ns: 'app' }), + ) } isCreatingRef.current = false - }, [canCreateApp, currentUserId, name, t, appMode, appIcon, description, onSuccess, onClose, push, workspacePermissionKeys, isRbacEnabled, setNeedRefresh, invalidateAppList]) + }, [ + canCreateApp, + currentUserId, + name, + t, + appMode, + appIcon, + description, + onSuccess, + onClose, + push, + workspacePermissionKeys, + isRbacEnabled, + setNeedRefresh, + invalidateAppList, + ]) const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) - useHotkey('Mod+Enter', () => { - if (isAppsFull || !canCreateApp) - return - handleCreateApp() - }, { - ignoreInputs: false, - }) + useHotkey( + 'Mod+Enter', + () => { + if (isAppsFull || !canCreateApp) return + handleCreateApp() + }, + { + ignoreInputs: false, + }, + ) return ( <> <div className="flex h-full justify-center overflow-x-hidden overflow-y-auto"> @@ -136,36 +168,40 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: <div className="px-10"> <div className="h-6 w-full 2xl:h-[139px]" /> <div className="pt-1 pb-6"> - <span className="title-2xl-semi-bold text-text-primary">{t($ => $['newApp.startFromBlank'], { ns: 'app' })}</span> + <span className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['newApp.startFromBlank'], { ns: 'app' })} + </span> </div> <div className="mb-2 leading-6"> - <span className="system-sm-semibold text-text-secondary">{t($ => $['newApp.chooseAppType'], { ns: 'app' })}</span> + <span className="system-sm-semibold text-text-secondary"> + {t(($) => $['newApp.chooseAppType'], { ns: 'app' })} + </span> </div> <div className="flex w-[660px] flex-col gap-4"> <div> <div className="flex flex-row gap-2"> <AppTypeCard active={appMode === AppModeEnum.WORKFLOW} - title={t($ => $['types.workflow'], { ns: 'app' })} - description={t($ => $['newApp.workflowShortDescription'], { ns: 'app' })} - icon={( + title={t(($) => $['types.workflow'], { ns: 'app' })} + description={t(($) => $['newApp.workflowShortDescription'], { ns: 'app' })} + icon={ <div className="flex size-6 items-center justify-center rounded-md bg-components-icon-bg-indigo-solid"> <RiExchange2Fill className="size-4 text-components-avatar-shape-fill-stop-100" /> </div> - )} + } onClick={() => { setAppMode(AppModeEnum.WORKFLOW) }} /> <AppTypeCard active={appMode === AppModeEnum.ADVANCED_CHAT} - title={t($ => $['types.advanced'], { ns: 'app' })} - description={t($ => $['newApp.advancedShortDescription'], { ns: 'app' })} - icon={( + title={t(($) => $['types.advanced'], { ns: 'app' })} + description={t(($) => $['newApp.advancedShortDescription'], { ns: 'app' })} + icon={ <div className="flex size-6 items-center justify-center rounded-md bg-components-icon-bg-blue-light-solid"> <BubbleTextMod className="size-4 text-components-avatar-shape-fill-stop-100" /> </div> - )} + } onClick={() => { setAppMode(AppModeEnum.ADVANCED_CHAT) }} @@ -179,47 +215,52 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: className="flex cursor-pointer items-center border-0 bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)} > - <span className="system-2xs-medium-uppercase text-text-tertiary">{t($ => $['newApp.forBeginners'], { ns: 'app' })}</span> - <RiArrowRightSLine className={`ml-1 size-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} aria-hidden="true" /> + <span className="system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['newApp.forBeginners'], { ns: 'app' })} + </span> + <RiArrowRightSLine + className={`ml-1 size-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} + aria-hidden="true" + /> </button> </div> {isAppTypeExpanded && ( <div className="flex flex-row gap-2"> <AppTypeCard active={appMode === AppModeEnum.CHAT} - title={t($ => $['types.chatbot'], { ns: 'app' })} - description={t($ => $['newApp.chatbotShortDescription'], { ns: 'app' })} - icon={( + title={t(($) => $['types.chatbot'], { ns: 'app' })} + description={t(($) => $['newApp.chatbotShortDescription'], { ns: 'app' })} + icon={ <div className="flex size-6 items-center justify-center rounded-md bg-components-icon-bg-blue-solid"> <ChatBot className="size-4 text-components-avatar-shape-fill-stop-100" /> </div> - )} + } onClick={() => { setAppMode(AppModeEnum.CHAT) }} /> <AppTypeCard active={appMode === AppModeEnum.AGENT_CHAT} - title={t($ => $['types.agent'], { ns: 'app' })} - description={t($ => $['newApp.agentShortDescription'], { ns: 'app' })} - icon={( + title={t(($) => $['types.agent'], { ns: 'app' })} + description={t(($) => $['newApp.agentShortDescription'], { ns: 'app' })} + icon={ <div className="flex size-6 items-center justify-center rounded-md bg-components-icon-bg-violet-solid"> <Logic className="size-4 text-components-avatar-shape-fill-stop-100" /> </div> - )} + } onClick={() => { setAppMode(AppModeEnum.AGENT_CHAT) }} /> <AppTypeCard active={appMode === AppModeEnum.COMPLETION} - title={t($ => $['newApp.completeApp'], { ns: 'app' })} - description={t($ => $['newApp.completionShortDescription'], { ns: 'app' })} - icon={( + title={t(($) => $['newApp.completeApp'], { ns: 'app' })} + description={t(($) => $['newApp.completionShortDescription'], { ns: 'app' })} + icon={ <div className="flex size-6 items-center justify-center rounded-md bg-components-icon-bg-teal-solid"> <ListSparkle className="size-4 text-components-avatar-shape-fill-stop-100" /> </div> - )} + } onClick={() => { setAppMode(AppModeEnum.COMPLETION) }} @@ -231,12 +272,14 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: <div className="flex items-center space-x-3"> <div className="flex-1"> <div className="mb-1 flex h-6 items-center"> - <label className="system-sm-semibold text-text-secondary">{t($ => $['newApp.captionName'], { ns: 'app' })}</label> + <label className="system-sm-semibold text-text-secondary"> + {t(($) => $['newApp.captionName'], { ns: 'app' })} + </label> </div> <Input value={name} - onChange={e => setName(e.target.value)} - placeholder={t($ => $['newApp.appNamePlaceholder'], { ns: 'app' }) || ''} + onChange={(e) => setName(e.target.value)} + placeholder={t(($) => $['newApp.appNamePlaceholder'], { ns: 'app' }) || ''} /> </div> <AppIcon @@ -246,14 +289,18 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} size="xxl" className="cursor-pointer rounded-2xl" - onClick={() => { setShowAppIconPicker(true) }} + onClick={() => { + setShowAppIconPicker(true) + }} /> {showAppIconPicker && ( <AppIconPicker open={showAppIconPicker} - initialEmoji={appIcon.type === 'emoji' - ? { icon: appIcon.icon, background: appIcon.background } - : undefined} + initialEmoji={ + appIcon.type === 'emoji' + ? { icon: appIcon.icon, background: appIcon.background } + : undefined + } onOpenChange={setShowAppIconPicker} onSelect={(payload) => { setAppIcon(payload) @@ -263,19 +310,19 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: </div> <div> <div className="mb-1 flex h-6 items-center"> - <label className="system-sm-semibold text-text-secondary">{t($ => $['newApp.captionDescription'], { ns: 'app' })}</label> + <label className="system-sm-semibold text-text-secondary"> + {t(($) => $['newApp.captionDescription'], { ns: 'app' })} + </label> <span className="ml-1 system-xs-regular text-text-tertiary"> - ( - {t($ => $['newApp.optional'], { ns: 'app' })} - ) + ({t(($) => $['newApp.optional'], { ns: 'app' })}) </span> </div> <Textarea - aria-label={t($ => $['newApp.captionDescription'], { ns: 'app' })} + aria-label={t(($) => $['newApp.captionDescription'], { ns: 'app' })} className="resize-none" - placeholder={t($ => $['newApp.appDescriptionPlaceholder'], { ns: 'app' }) || ''} + placeholder={t(($) => $['newApp.appDescriptionPlaceholder'], { ns: 'app' }) || ''} value={description} - onValueChange={value => setDescription(value)} + onValueChange={(value) => setDescription(value)} /> </div> </div> @@ -286,18 +333,25 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left system-xs-regular text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onCreateFromTemplate} > - <span>{t($ => $['newApp.noIdeaTip'], { ns: 'app' })}</span> + <span>{t(($) => $['newApp.noIdeaTip'], { ns: 'app' })}</span> <div className="p-px"> <RiArrowRightLine className="size-3.5" aria-hidden="true" /> </div> </button> <div className="flex gap-2"> - <Button onClick={onClose}>{t($ => $['newApp.Cancel'], { ns: 'app' })}</Button> - <Button disabled={!canCreateApp || isAppsFull || !name} className="gap-1" variant="primary" onClick={handleCreateApp}> - <span>{t($ => $['newApp.Create'], { ns: 'app' })}</span> + <Button onClick={onClose}>{t(($) => $['newApp.Cancel'], { ns: 'app' })}</Button> + <Button + disabled={!canCreateApp || isAppsFull || !name} + className="gap-1" + variant="primary" + onClick={handleCreateApp} + > + <span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span> <KbdGroup> - {['Mod', 'Enter'].map(key => ( - <Kbd key={key} color="white">{formatForDisplay(key)}</Kbd> + {['Mod', 'Enter'].map((key) => ( + <Kbd key={key} color="white"> + {formatForDisplay(key)} + </Kbd> ))} </KbdGroup> </Button> @@ -311,11 +365,26 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: <div className="h-6 2xl:h-[139px]" /> <AppPreview mode={appMode} /> <div className="absolute inset-x-0 border-b border-b-divider-subtle"></div> - <div className="flex h-[448px] w-[664px] items-center justify-center" style={{ background: 'repeating-linear-gradient(135deg, transparent, transparent 2px, rgba(16,24,40,0.04) 4px,transparent 3px, transparent 6px)' }}> + <div + className="flex h-[448px] w-[664px] items-center justify-center" + style={{ + background: + 'repeating-linear-gradient(135deg, transparent, transparent 2px, rgba(16,24,40,0.04) 4px,transparent 3px, transparent 6px)', + }} + > <AppScreenShot show={appMode === AppModeEnum.CHAT} mode={AppModeEnum.CHAT} /> - <AppScreenShot show={appMode === AppModeEnum.ADVANCED_CHAT} mode={AppModeEnum.ADVANCED_CHAT} /> - <AppScreenShot show={appMode === AppModeEnum.AGENT_CHAT} mode={AppModeEnum.AGENT_CHAT} /> - <AppScreenShot show={appMode === AppModeEnum.COMPLETION} mode={AppModeEnum.COMPLETION} /> + <AppScreenShot + show={appMode === AppModeEnum.ADVANCED_CHAT} + mode={AppModeEnum.ADVANCED_CHAT} + /> + <AppScreenShot + show={appMode === AppModeEnum.AGENT_CHAT} + mode={AppModeEnum.AGENT_CHAT} + /> + <AppScreenShot + show={appMode === AppModeEnum.COMPLETION} + mode={AppModeEnum.COMPLETION} + /> <AppScreenShot show={appMode === AppModeEnum.WORKFLOW} mode={AppModeEnum.WORKFLOW} /> </div> <div className="absolute inset-x-0 border-b border-b-divider-subtle"></div> @@ -328,17 +397,28 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: type CreateAppDialogProps = CreateAppProps & { show: boolean } -const CreateAppModal = ({ show, onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppDialogProps) => { +const CreateAppModal = ({ + show, + onClose, + onSuccess, + onCreateFromTemplate, + defaultAppMode, +}: CreateAppDialogProps) => { const { t } = useTranslation() return ( <CreateAppDialogShell show={show} - title={t($ => $['newApp.startFromBlank'], { ns: 'app' })} + title={t(($) => $['newApp.startFromBlank'], { ns: 'app' })} contentClassName="overflow-visible" onClose={onClose} > - <CreateApp onClose={onClose} onSuccess={onSuccess} onCreateFromTemplate={onCreateFromTemplate} defaultAppMode={defaultAppMode} /> + <CreateApp + onClose={onClose} + onSuccess={onSuccess} + onCreateFromTemplate={onCreateFromTemplate} + defaultAppMode={defaultAppMode} + /> </CreateAppDialogShell> ) } @@ -355,18 +435,19 @@ type AppTypeCardProps = { function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardProps) { return ( <div - className={ - cn(`relative box-content h-[84px] w-[191px] cursor-pointer rounded-xl - border-[0.5px] border-components-option-card-option-border - bg-components-panel-on-panel-item-bg p-3 shadow-xs hover:shadow-md`, active + className={cn( + `relative box-content h-[84px] w-[191px] cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3 shadow-xs hover:shadow-md`, + active ? 'shadow-md outline-[1.5px] outline-components-option-card-option-selected-border outline-solid' - : '') - } + : '', + )} onClick={onClick} > {icon} <div className="mt-2 mb-0.5 system-sm-semibold text-text-secondary">{title}</div> - <div className="line-clamp-2 system-xs-regular text-text-tertiary" title={description}>{description}</div> + <div className="line-clamp-2 system-xs-regular text-text-tertiary" title={description}> + {description} + </div> </div> ) } @@ -377,33 +458,33 @@ function AppPreview({ mode }: { mode: AppModeEnum }) { switch (mode) { case AppModeEnum.CHAT: return { - title: t($ => $['types.chatbot'], { ns: 'app' }), - description: t($ => $['newApp.chatbotUserDescription'], { ns: 'app' }), + title: t(($) => $['types.chatbot'], { ns: 'app' }), + description: t(($) => $['newApp.chatbotUserDescription'], { ns: 'app' }), } case AppModeEnum.ADVANCED_CHAT: return { - title: t($ => $['types.advanced'], { ns: 'app' }), - description: t($ => $['newApp.advancedUserDescription'], { ns: 'app' }), + title: t(($) => $['types.advanced'], { ns: 'app' }), + description: t(($) => $['newApp.advancedUserDescription'], { ns: 'app' }), } case AppModeEnum.AGENT_CHAT: return { - title: t($ => $['types.agent'], { ns: 'app' }), - description: t($ => $['newApp.agentUserDescription'], { ns: 'app' }), + title: t(($) => $['types.agent'], { ns: 'app' }), + description: t(($) => $['newApp.agentUserDescription'], { ns: 'app' }), } case AppModeEnum.COMPLETION: return { - title: t($ => $['newApp.completeApp'], { ns: 'app' }), - description: t($ => $['newApp.completionUserDescription'], { ns: 'app' }), + title: t(($) => $['newApp.completeApp'], { ns: 'app' }), + description: t(($) => $['newApp.completionUserDescription'], { ns: 'app' }), } case AppModeEnum.WORKFLOW: return { - title: t($ => $['types.workflow'], { ns: 'app' }), - description: t($ => $['newApp.workflowUserDescription'], { ns: 'app' }), + title: t(($) => $['types.workflow'], { ns: 'app' }), + description: t(($) => $['newApp.workflowUserDescription'], { ns: 'app' }), } default: return { - title: t($ => $['types.workflow'], { ns: 'app' }), - description: t($ => $['newApp.workflowUserDescription'], { ns: 'app' }), + title: t(($) => $['types.workflow'], { ns: 'app' }), + description: t(($) => $['newApp.workflowUserDescription'], { ns: 'app' }), } } })() @@ -417,7 +498,7 @@ function AppPreview({ mode }: { mode: AppModeEnum }) { ) } -function AppScreenShot({ mode, show }: { mode: AppModeEnum, show: boolean }) { +function AppScreenShot({ mode, show }: { mode: AppModeEnum; show: boolean }) { const { theme } = useTheme() const modeToImageMap = { [AppModeEnum.CHAT]: 'Chatbot', @@ -428,9 +509,18 @@ function AppScreenShot({ mode, show }: { mode: AppModeEnum, show: boolean }) { } return ( <picture> - <source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} /> - <source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@2x.png`} /> - <source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@3x.png`} /> + <source + media="(resolution: 1x)" + srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} + /> + <source + media="(resolution: 2x)" + srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@2x.png`} + /> + <source + media="(resolution: 3x)" + srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@3x.png`} + /> <img className={show ? '' : 'hidden'} src={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/dsl-confirm-modal.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/dsl-confirm-modal.spec.tsx index 424ddceeb310dc..03164422754733 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/dsl-confirm-modal.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/dsl-confirm-modal.spec.tsx @@ -23,12 +23,7 @@ describe('DSLConfirmModal', () => { const handleCancel = vi.fn() const handleConfirm = vi.fn() - render( - <DSLConfirmModal - onCancel={handleCancel} - onConfirm={handleConfirm} - />, - ) + render(<DSLConfirmModal onCancel={handleCancel} onConfirm={handleConfirm} />) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)newApp\.Cancel(?=$|:)/ })) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)newApp\.Confirm(?=$|:)/ })) diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index 84d249d9481367..87aaec1c803d49 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -1,10 +1,5 @@ /* eslint-disable ts/no-explicit-any */ -import { - act, - fireEvent, - screen, - waitFor, -} from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage' import { DSLImportMode, DSLImportStatus } from '@/models/app' @@ -25,7 +20,7 @@ const toastMocks = vi.hoisted(() => ({ warning: vi.fn(), })) const hotkeyMocks = vi.hoisted(() => ({ - handlers: new Map<string, { handler: () => void, options?: { enabled?: boolean } }>(), + handlers: new Map<string, { handler: () => void; options?: { enabled?: boolean } }>(), })) let mockPlanUsage = 0 let mockPlanTotal = 10 @@ -49,8 +44,7 @@ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { const triggerHotkey = (hotkey: string) => { const registration = hotkeyMocks.handlers.get(hotkey) - if (registration?.options?.enabled === false) - return + if (registration?.options?.enabled === false) return registration?.handler() } @@ -120,7 +114,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -180,8 +175,7 @@ describe('CreateFromDSLModal', () => { vi.useRealTimers() }) - const getCreateButton = () => - screen.getByRole('button', { name: /newApp\.Create/i }) + const getCreateButton = () => screen.getByRole('button', { name: /newApp\.Create/i }) it('should render the file tab and show the dropped file', async () => { render( @@ -215,10 +209,7 @@ describe('CreateFromDSLModal', () => { const closeTrigger = screen .getByText(/(?:^|\.)importApp(?=$|:)/) - .parentElement - ?.querySelector( - '.cursor-pointer.items-center', - ) as HTMLElement + .parentElement?.querySelector('.cursor-pointer.items-center') as HTMLElement fireEvent.click(closeTrigger) expect(handleClose).toHaveBeenCalledTimes(1) }) @@ -257,12 +248,9 @@ describe('CreateFromDSLModal', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText(/(?:^|\.)importFromDSLUrlPlaceholder(?=$|:)/), - { - target: { value: 'https://example.com/app.yml' }, - }, - ) + fireEvent.change(screen.getByPlaceholderText(/(?:^|\.)importFromDSLUrlPlaceholder(?=$|:)/), { + target: { value: 'https://example.com/app.yml' }, + }) await act(async () => { fireEvent.click(getCreateButton()) @@ -425,9 +413,7 @@ describe('CreateFromDSLModal', () => { )!.toBeInTheDocument() await act(async () => { - fireEvent.click( - screen.getAllByRole('button', { name: /(?:^|\.)newApp\.Confirm(?=$|:)/ })[0]!, - ) + fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)newApp\.Confirm(?=$|:)/ })[0]!) }) expect(mockImportDSLConfirm).toHaveBeenCalledWith({ @@ -476,9 +462,7 @@ describe('CreateFromDSLModal', () => { vi.advanceTimersByTime(300) }) - expect( - screen.getByText(/(?:^|\.)newApp\.appCreateDSLErrorTitle(?=$|:)/), - )!.toBeInTheDocument() + expect(screen.getByText(/(?:^|\.)newApp\.appCreateDSLErrorTitle(?=$|:)/))!.toBeInTheDocument() vi.useRealTimers() fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) @@ -516,9 +500,7 @@ describe('CreateFromDSLModal', () => { vi.advanceTimersByTime(300) }) - expect( - screen.getByText(/(?:^|\.)newApp\.appCreateDSLErrorTitle(?=$|:)/), - )!.toBeInTheDocument() + expect(screen.getByText(/(?:^|\.)newApp\.appCreateDSLErrorTitle(?=$|:)/))!.toBeInTheDocument() vi.useRealTimers() fireEvent.click( @@ -660,7 +642,9 @@ describe('CreateFromDSLModal', () => { fireEvent.click(getCreateButton()) }) expect(toastMocks.error).toHaveBeenCalledTimes(2) - expect(toastMocks.error).toHaveBeenLastCalledWith(expect.stringMatching(/(?:^|\.)newApp\.appCreateFailed(?=$|:)/)) + expect(toastMocks.error).toHaveBeenLastCalledWith( + expect.stringMatching(/(?:^|\.)newApp\.appCreateFailed(?=$|:)/), + ) }) it('should handle pending import confirmation failures and cancellation', async () => { @@ -704,18 +688,16 @@ describe('CreateFromDSLModal', () => { vi.advanceTimersByTime(300) }) await act(async () => { - fireEvent.click( - screen.getAllByRole('button', { name: /(?:^|\.)newApp\.Confirm(?=$|:)/ })[0]!, - ) + fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)newApp\.Confirm(?=$|:)/ })[0]!) }) expect(toastMocks.error).toHaveBeenCalledWith('Confirm failed') await act(async () => { - fireEvent.click( - screen.getAllByRole('button', { name: /(?:^|\.)newApp\.Confirm(?=$|:)/ })[0]!, - ) + fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)newApp\.Confirm(?=$|:)/ })[0]!) }) expect(toastMocks.error).toHaveBeenCalledTimes(2) - expect(toastMocks.error).toHaveBeenLastCalledWith(expect.stringMatching(/(?:^|\.)newApp\.appCreateFailed(?=$|:)/)) + expect(toastMocks.error).toHaveBeenLastCalledWith( + expect.stringMatching(/(?:^|\.)newApp\.appCreateFailed(?=$|:)/), + ) }) }) diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx index b69addfc4c51ea..d09610563c9766 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx @@ -13,7 +13,8 @@ describe('Uploader', () => { vi.clearAllMocks() }) - const getDropZone = (container: HTMLElement) => (container.firstChild as HTMLElement).querySelector('div') as HTMLElement + const getDropZone = (container: HTMLElement) => + (container.firstChild as HTMLElement).querySelector('div') as HTMLElement const getHiddenInput = () => document.getElementById('fileUploader') as HTMLInputElement @@ -21,12 +22,7 @@ describe('Uploader', () => { const updateFile = vi.fn() const file = new File(['name: demo'], 'demo.yml', { type: 'text/yaml' }) - const { container } = render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + const { container } = render(<Uploader file={undefined} updateFile={updateFile} />) const dropZone = getDropZone(container) fireEvent.drop(dropZone, { @@ -43,12 +39,7 @@ describe('Uploader', () => { const fileA = new File(['a'], 'a.yml', { type: 'text/yaml' }) const fileB = new File(['b'], 'b.yml', { type: 'text/yaml' }) - const { container } = render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + const { container } = render(<Uploader file={undefined} updateFile={updateFile} />) const dropZone = getDropZone(container) fireEvent.drop(dropZone, { @@ -57,7 +48,9 @@ describe('Uploader', () => { }, }) - expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)stepOne\.uploader\.validation\.count(?=$|:)/)) + expect(toast.error).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)stepOne\.uploader\.validation\.count(?=$|:)/), + ) expect(updateFile).not.toHaveBeenCalled() }) @@ -65,13 +58,7 @@ describe('Uploader', () => { const updateFile = vi.fn() const file = new File(['name: demo'], 'demo.yml', { type: 'text/yaml' }) - render( - <Uploader - file={file} - updateFile={updateFile} - displayName="DSL" - />, - ) + render(<Uploader file={file} updateFile={updateFile} displayName="DSL" />) expect(screen.getByText(/(?:^|\.)demo\.yml(?=$|:)/)).toBeInTheDocument() expect(screen.getByText('DSL')).toBeInTheDocument() @@ -84,12 +71,7 @@ describe('Uploader', () => { it('should ignore drops without dataTransfer', () => { const updateFile = vi.fn() - const { container } = render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + const { container } = render(<Uploader file={undefined} updateFile={updateFile} />) const dropZone = getDropZone(container) fireEvent.drop(dropZone) @@ -101,12 +83,7 @@ describe('Uploader', () => { const updateFile = vi.fn() const nextFile = new File(['next'], 'next.yml', { type: 'text/yaml' }) - render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + render(<Uploader file={undefined} updateFile={updateFile} />) fireEvent.change(getHiddenInput(), { target: { @@ -119,12 +96,7 @@ describe('Uploader', () => { it('should toggle drag styles and clear them when leaving the overlay', () => { const updateFile = vi.fn() - const { container } = render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + const { container } = render(<Uploader file={undefined} updateFile={updateFile} />) const dropZone = getDropZone(container) @@ -141,12 +113,7 @@ describe('Uploader', () => { it('should reopen the hidden input and restore the previous file when the picker is cancelled', () => { const updateFile = vi.fn() - render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + render(<Uploader file={undefined} updateFile={updateFile} />) const hiddenInput = getHiddenInput() const clickSpy = vi.spyOn(hiddenInput, 'click') @@ -163,12 +130,7 @@ describe('Uploader', () => { it('should clear the hidden input and remove the selected file', () => { const updateFile = vi.fn() const file = new File(['name: demo'], 'demo.yml', { type: 'text/yaml' }) - render( - <Uploader - file={file} - updateFile={updateFile} - />, - ) + render(<Uploader file={file} updateFile={updateFile} />) const hiddenInput = getHiddenInput() Object.defineProperty(hiddenInput, 'value', { @@ -186,12 +148,7 @@ describe('Uploader', () => { it('should clear the current file when the hidden uploader change event has no files', () => { const updateFile = vi.fn() - render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + render(<Uploader file={undefined} updateFile={updateFile} />) fireEvent.change(getHiddenInput(), { target: { diff --git a/web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx b/web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx index 41bedab678b29e..fb70d585059b11 100644 --- a/web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx +++ b/web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx @@ -30,30 +30,38 @@ const DSLConfirmModal = ({ <AlertDialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <AlertDialogContent className="w-[480px] overflow-hidden! border-none text-left align-middle shadow-xl"> <div className="flex flex-col items-start gap-2 self-stretch p-6 pb-4"> - <AlertDialogTitle className="title-2xl-semi-bold text-text-primary">{t($ => $['newApp.appCreateDSLErrorTitle'], { ns: 'app' })}</AlertDialogTitle> - <AlertDialogDescription render={<div />} className="flex grow flex-col system-md-regular text-text-secondary"> - <div>{t($ => $['newApp.appCreateDSLErrorPart1'], { ns: 'app' })}</div> - <div>{t($ => $['newApp.appCreateDSLErrorPart2'], { ns: 'app' })}</div> + <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['newApp.appCreateDSLErrorTitle'], { ns: 'app' })} + </AlertDialogTitle> + <AlertDialogDescription + render={<div />} + className="flex grow flex-col system-md-regular text-text-secondary" + > + <div>{t(($) => $['newApp.appCreateDSLErrorPart1'], { ns: 'app' })}</div> + <div>{t(($) => $['newApp.appCreateDSLErrorPart2'], { ns: 'app' })}</div> <br /> <div> - {t($ => $['newApp.appCreateDSLErrorPart3'], { ns: 'app' })} + {t(($) => $['newApp.appCreateDSLErrorPart3'], { ns: 'app' })} <span className="system-md-medium">{versions.importedVersion}</span> </div> <div> - {t($ => $['newApp.appCreateDSLErrorPart4'], { ns: 'app' })} + {t(($) => $['newApp.appCreateDSLErrorPart4'], { ns: 'app' })} <span className="system-md-medium">{versions.systemVersion}</span> </div> </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton variant="secondary">{t($ => $['newApp.Cancel'], { ns: 'app' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton onClick={onConfirm} disabled={confirmDisabled}>{t($ => $['newApp.Confirm'], { ns: 'app' })}</AlertDialogConfirmButton> + <AlertDialogCancelButton variant="secondary"> + {t(($) => $['newApp.Cancel'], { ns: 'app' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton onClick={onConfirm} disabled={confirmDisabled}> + {t(($) => $['newApp.Confirm'], { ns: 'app' })} + </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> </AlertDialog> diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index 9ddbc40339b71b..929646abac4919 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -20,15 +20,9 @@ import { userProfileIdAtom } from '@/context/account-state' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { - DSLImportMode, - DSLImportStatus, -} from '@/models/app' +import { DSLImportMode, DSLImportStatus } from '@/models/app' import { useRouter } from '@/next/navigation' -import { - importDSL, - importDSLConfirm, -} from '@/service/apps' +import { importDSL, importDSLConfirm } from '@/service/apps' import { useInvalidateAppList } from '@/service/use-apps' import { getRedirection } from '@/utils/app-redirection' import { trackCreateApp } from '@/utils/create-app-tracking' @@ -48,7 +42,14 @@ export enum CreateFromDSLModalTab { FROM_URL = 'from-url', } -const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', droppedFile }: CreateFromDSLModalProps) => { +const CreateFromDSLModal = ({ + show, + onSuccess, + onClose, + activeTab = CreateFromDSLModalTab.FROM_FILE, + dslUrl = '', + droppedFile, +}: CreateFromDSLModalProps) => { const { push } = useRouter() const { t } = useTranslation() const [currentFile, setCurrentFile] = useState<File | undefined>(droppedFile) @@ -56,7 +57,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS const [currentTab, setCurrentTab] = useState(activeTab) const [dslUrlValue, setDslUrlValue] = useState(dslUrl) const [showErrorModal, setShowErrorModal] = useState(false) - const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>() + const [versions, setVersions] = useState<{ importedVersion: string; systemVersion: string }>() const [importId, setImportId] = useState<string>() const { handleCheckPluginDependencies } = usePluginDependencies() const setNeedRefresh = useSetNeedRefreshAppList() @@ -74,32 +75,29 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS reader.readAsText(file) }, []) - const handleFile = useCallback((file?: File) => { - setCurrentFile(file) - if (file) - readFile(file) - if (!file) - setFileContent('') - }, [readFile]) + const handleFile = useCallback( + (file?: File) => { + setCurrentFile(file) + if (file) readFile(file) + if (!file) setFileContent('') + }, + [readFile], + ) const { plan, enableBilling } = useProviderContext() - const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) + const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps const invalidateAppList = useInvalidateAppList() const isCreatingRef = useRef(false) useEffect(() => { - if (droppedFile) - handleFile(droppedFile) + if (droppedFile) handleFile(droppedFile) }, [droppedFile, handleFile]) const onCreate = async (_e?: React.MouseEvent) => { - if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) - return - if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue) - return - if (isCreatingRef.current) - return + if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) return + if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue) return + if (isCreatingRef.current) return isCreatingRef.current = true try { let response @@ -117,23 +115,38 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS }) } - if (!response) - return - const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version, permission_keys } = response - if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { + if (!response) return + const { + id, + status, + app_id, + app_mode, + imported_dsl_version, + current_dsl_version, + permission_keys, + } = response + if ( + status === DSLImportStatus.COMPLETED || + status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ) { trackCreateApp({ source: 'studio_upload', appMode: app_mode }) - if (onSuccess) - onSuccess() - if (onClose) - onClose() + if (onSuccess) onSuccess() + if (onClose) onClose() - toast(t($ => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'], { ns: 'app' }), { - type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', - description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS - ? t($ => $['newApp.appCreateDSLWarning'], { ns: 'app' }) - : undefined, - }) + toast( + t( + ($) => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'], + { ns: 'app' }, + ), + { + type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', + description: + status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }) + : undefined, + }, + ) setNeedRefresh('1') invalidateAppList() if (app_id) { @@ -145,8 +158,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS isRbacEnabled, }) } - } - else if (status === DSLImportStatus.PENDING) { + } else if (status === DSLImportStatus.PENDING) { setVersions({ importedVersion: imported_dsl_version ?? '', systemVersion: current_dsl_version ?? '', @@ -155,30 +167,35 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS setShowErrorModal(true) }, 300) setImportId(id) + } else { + toast.error(response.error || t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) } - else { - toast.error(response.error || t($ => $['newApp.appCreateFailed'], { ns: 'app' })) - } - } - catch { - toast.error(t($ => $['newApp.appCreateFailed'], { ns: 'app' })) + } catch { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) } isCreatingRef.current = false } const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) - useHotkey('Mod+Enter', () => { - handleCreateApp(undefined) - }, { - enabled: show && !isAppsFull && ((currentTab === CreateFromDSLModalTab.FROM_FILE && !!currentFile) || (currentTab === CreateFromDSLModalTab.FROM_URL && !!dslUrlValue)), - ignoreInputs: false, - }) + useHotkey( + 'Mod+Enter', + () => { + handleCreateApp(undefined) + }, + { + enabled: + show && + !isAppsFull && + ((currentTab === CreateFromDSLModalTab.FROM_FILE && !!currentFile) || + (currentTab === CreateFromDSLModalTab.FROM_URL && !!dslUrlValue)), + ignoreInputs: false, + }, + ) const onDSLConfirm: MouseEventHandler = async () => { try { - if (!importId) - return + if (!importId) return const response = await importDSLConfirm({ import_id: importId, }) @@ -187,14 +204,11 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS if (status === DSLImportStatus.COMPLETED) { trackCreateApp({ source: 'studio_upload', appMode: app_mode }) - if (onSuccess) - onSuccess() - if (onClose) - onClose() + if (onSuccess) onSuccess() + if (onClose) onClose() - toast.success(t($ => $['newApp.appCreated'], { ns: 'app' })) - if (app_id) - await handleCheckPluginDependencies(app_id) + toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) + if (app_id) await handleCheckPluginDependencies(app_id) setNeedRefresh('1') invalidateAppList() if (app_id) { @@ -205,94 +219,73 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS isRbacEnabled, }) } + } else if (status === DSLImportStatus.FAILED) { + toast.error(response.error || t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) } - else if (status === DSLImportStatus.FAILED) { - toast.error(response.error || t($ => $['newApp.appCreateFailed'], { ns: 'app' })) - } - } - catch { - toast.error(t($ => $['newApp.appCreateFailed'], { ns: 'app' })) + } catch { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) } } const tabs = [ { key: CreateFromDSLModalTab.FROM_FILE, - label: t($ => $.importFromDSLFile, { ns: 'app' }), + label: t(($) => $.importFromDSLFile, { ns: 'app' }), }, { key: CreateFromDSLModalTab.FROM_URL, - label: t($ => $.importFromDSLUrl, { ns: 'app' }), + label: t(($) => $.importFromDSLUrl, { ns: 'app' }), }, ] const buttonDisabled = useMemo(() => { - if (isAppsFull) - return true - if (currentTab === CreateFromDSLModalTab.FROM_FILE) - return !currentFile - if (currentTab === CreateFromDSLModalTab.FROM_URL) - return !dslUrlValue + if (isAppsFull) return true + if (currentTab === CreateFromDSLModalTab.FROM_FILE) return !currentFile + if (currentTab === CreateFromDSLModalTab.FROM_URL) return !dslUrlValue return false }, [isAppsFull, currentTab, currentFile, dslUrlValue]) return ( <> - <Dialog open={show} onOpenChange={open => !open && !showErrorModal && onClose()}> + <Dialog open={show} onOpenChange={(open) => !open && !showErrorModal && onClose()}> <DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl"> - <div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary"> - {t($ => $.importApp, { ns: 'app' })} - <div - className="flex size-8 cursor-pointer items-center" - onClick={() => onClose()} - > + {t(($) => $.importApp, { ns: 'app' })} + <div className="flex size-8 cursor-pointer items-center" onClick={() => onClose()}> <span className="i-ri-close-line size-5 text-text-tertiary" /> </div> </div> <div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary"> - { - tabs.map(tab => ( - <div - key={tab.key} - className={cn( - 'relative flex h-full cursor-pointer items-center', - currentTab === tab.key && 'text-text-primary', - )} - onClick={() => setCurrentTab(tab.key)} - > - {tab.label} - { - currentTab === tab.key && ( - <div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div> - ) - } - </div> - )) - } + {tabs.map((tab) => ( + <div + key={tab.key} + className={cn( + 'relative flex h-full cursor-pointer items-center', + currentTab === tab.key && 'text-text-primary', + )} + onClick={() => setCurrentTab(tab.key)} + > + {tab.label} + {currentTab === tab.key && ( + <div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div> + )} + </div> + ))} </div> <div className="px-6 py-4"> - { - currentTab === CreateFromDSLModalTab.FROM_FILE && ( - <Uploader - className="mt-0" - file={currentFile} - updateFile={handleFile} + {currentTab === CreateFromDSLModalTab.FROM_FILE && ( + <Uploader className="mt-0" file={currentFile} updateFile={handleFile} /> + )} + {currentTab === CreateFromDSLModalTab.FROM_URL && ( + <div> + <div className="mb-1 system-md-semibold text-text-secondary">DSL URL</div> + <Input + placeholder={t(($) => $.importFromDSLUrlPlaceholder, { ns: 'app' }) || ''} + value={dslUrlValue} + onChange={(e) => setDslUrlValue(e.target.value)} /> - ) - } - { - currentTab === CreateFromDSLModalTab.FROM_URL && ( - <div> - <div className="mb-1 system-md-semibold text-text-secondary">DSL URL</div> - <Input - placeholder={t($ => $.importFromDSLUrlPlaceholder, { ns: 'app' }) || ''} - value={dslUrlValue} - onChange={e => setDslUrlValue(e.target.value)} - /> - </div> - ) - } + </div> + )} </div> {isAppsFull && ( <div className="px-6"> @@ -300,17 +293,21 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS </div> )} <div className="flex justify-end px-6 py-5"> - <Button className="mr-2" onClick={onClose}>{t($ => $['newApp.Cancel'], { ns: 'app' })}</Button> + <Button className="mr-2" onClick={onClose}> + {t(($) => $['newApp.Cancel'], { ns: 'app' })} + </Button> <Button disabled={buttonDisabled} variant="primary" onClick={handleCreateApp} className="gap-1" > - <span>{t($ => $['newApp.Create'], { ns: 'app' })}</span> + <span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span> <KbdGroup> - {['Mod', 'Enter'].map(key => ( - <Kbd key={key} color="white">{formatForDisplay(key)}</Kbd> + {['Mod', 'Enter'].map((key) => ( + <Kbd key={key} color="white"> + {formatForDisplay(key)} + </Kbd> ))} </KbdGroup> </Button> @@ -320,31 +317,35 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS <Dialog open={showErrorModal} onOpenChange={(open) => { - if (!open) - setShowErrorModal(false) + if (!open) setShowErrorModal(false) }} > <DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none text-left align-middle"> - <div className="flex flex-col items-start gap-2 self-stretch pb-4"> - <div className="title-2xl-semi-bold text-text-primary">{t($ => $['newApp.appCreateDSLErrorTitle'], { ns: 'app' })}</div> + <div className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['newApp.appCreateDSLErrorTitle'], { ns: 'app' })} + </div> <div className="flex grow flex-col system-md-regular text-text-secondary"> - <div>{t($ => $['newApp.appCreateDSLErrorPart1'], { ns: 'app' })}</div> - <div>{t($ => $['newApp.appCreateDSLErrorPart2'], { ns: 'app' })}</div> + <div>{t(($) => $['newApp.appCreateDSLErrorPart1'], { ns: 'app' })}</div> + <div>{t(($) => $['newApp.appCreateDSLErrorPart2'], { ns: 'app' })}</div> <br /> <div> - {t($ => $['newApp.appCreateDSLErrorPart3'], { ns: 'app' })} + {t(($) => $['newApp.appCreateDSLErrorPart3'], { ns: 'app' })} <span className="system-md-medium">{versions?.importedVersion}</span> </div> <div> - {t($ => $['newApp.appCreateDSLErrorPart4'], { ns: 'app' })} + {t(($) => $['newApp.appCreateDSLErrorPart4'], { ns: 'app' })} <span className="system-md-medium">{versions?.systemVersion}</span> </div> </div> </div> <div className="flex items-start justify-end gap-2 self-stretch pt-6"> - <Button variant="secondary" onClick={() => setShowErrorModal(false)}>{t($ => $['newApp.Cancel'], { ns: 'app' })}</Button> - <Button variant="primary" tone="destructive" onClick={onDSLConfirm}>{t($ => $['newApp.Confirm'], { ns: 'app' })}</Button> + <Button variant="secondary" onClick={() => setShowErrorModal(false)}> + {t(($) => $['newApp.Cancel'], { ns: 'app' })} + </Button> + <Button variant="primary" tone="destructive" onClick={onDSLConfirm}> + {t(($) => $['newApp.Confirm'], { ns: 'app' })} + </Button> </div> </DialogContent> </Dialog> diff --git a/web/app/components/app/create-from-dsl-modal/uploader.tsx b/web/app/components/app/create-from-dsl-modal/uploader.tsx index 36812d29d1f750..d5bd88f558a7f8 100644 --- a/web/app/components/app/create-from-dsl-modal/uploader.tsx +++ b/web/app/components/app/create-from-dsl-modal/uploader.tsx @@ -2,10 +2,7 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { - RiDeleteBinLine, - RiUploadCloud2Line, -} from '@remixicon/react' +import { RiDeleteBinLine, RiUploadCloud2Line } from '@remixicon/react' import * as React from 'react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -37,8 +34,7 @@ const Uploader: FC<Props> = ({ const handleDragEnter = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.target !== dragRef.current) - setDragging(true) + if (e.target !== dragRef.current) setDragging(true) } const handleDragOver = (e: DragEvent) => { e.preventDefault() @@ -47,18 +43,16 @@ const Uploader: FC<Props> = ({ const handleDragLeave = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.target === dragRef.current) - setDragging(false) + if (e.target === dragRef.current) setDragging(false) } const handleDrop = (e: DragEvent) => { e.preventDefault() e.stopPropagation() setDragging(false) - if (!e.dataTransfer) - return + if (!e.dataTransfer) return const files = Array.from(e.dataTransfer.files) if (files.length > 1) { - toast.error(t($ => $['stepOne.uploader.validation.count'], { ns: 'datasetCreation' })) + toast.error(t(($) => $['stepOne.uploader.validation.count'], { ns: 'datasetCreation' })) return } updateFile(files[0]) @@ -73,8 +67,7 @@ const Uploader: FC<Props> = ({ } } const removeFile = () => { - if (fileUploader.current) - fileUploader.current.value = '' + if (fileUploader.current) fileUploader.current.value = '' updateFile() } const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => { @@ -107,17 +100,23 @@ const Uploader: FC<Props> = ({ /> <div ref={dropRef}> {!file && ( - <div className={cn('flex h-12 items-center rounded-[10px] border border-dashed border-components-dropzone-border bg-components-dropzone-bg text-sm font-normal', dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent')}> + <div + className={cn( + 'flex h-12 items-center rounded-[10px] border border-dashed border-components-dropzone-border bg-components-dropzone-bg text-sm font-normal', + dragging && + 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent', + )} + > <div className="flex w-full items-center justify-center space-x-2"> <RiUploadCloud2Line className="size-6 text-text-tertiary" /> <div className="text-text-tertiary"> - {t($ => $['dslUploader.button'], { ns: 'app' })} + {t(($) => $['dslUploader.button'], { ns: 'app' })} <button type="button" className="inline cursor-pointer border-none bg-transparent p-0 pl-1 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={selectHandle} > - {t($ => $['dslUploader.browse'], { ns: 'app' })} + {t(($) => $['dslUploader.browse'], { ns: 'app' })} </button> </div> </div> @@ -125,12 +124,19 @@ const Uploader: FC<Props> = ({ </div> )} {file && ( - <div className={cn('group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', 'hover:bg-components-panel-on-panel-item-bg-hover')}> + <div + className={cn( + 'group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', + 'hover:bg-components-panel-on-panel-item-bg-hover', + )} + > <div className="flex items-center justify-center p-3"> <YamlIcon className="size-6 shrink-0" /> </div> <div className="flex grow flex-col items-start gap-0.5 py-1 pr-2"> - <span className="font-inter max-w-[calc(100%-30px)] overflow-hidden text-[12px] leading-4 font-medium text-ellipsis whitespace-nowrap text-text-secondary">{file.name}</span> + <span className="font-inter max-w-[calc(100%-30px)] overflow-hidden text-[12px] leading-4 font-medium text-ellipsis whitespace-nowrap text-text-secondary"> + {file.name} + </span> <div className="font-inter flex h-3 items-center gap-1 self-stretch text-[10px] leading-3 font-medium text-text-tertiary uppercase"> <span>{displayName}</span> <span className="text-text-quaternary">·</span> diff --git a/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx b/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx index a71a92abcf8370..3c839070e6a962 100644 --- a/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx @@ -21,7 +21,9 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ vi.mock('@/app/components/base/app-icon', () => ({ default: ({ onClick }: { onClick: () => void }) => ( - <button type="button" onClick={onClick}>open-icon-picker</button> + <button type="button" onClick={onClick}> + open-icon-picker + </button> ), })) @@ -31,7 +33,7 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({ onSelect, }: { onOpenChange: (open: boolean) => void - onSelect: (payload: { type: 'emoji', icon: string, background: string }) => void + onSelect: (payload: { type: 'emoji'; icon: string; background: string }) => void }) => { let selectedBackground = '#FFEAD5' return ( @@ -40,7 +42,13 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({ <button type="button" onClick={() => {}}> <em-emoji /> </button> - <button type="button" aria-label="#E4FBCC" onClick={() => { selectedBackground = '#E4FBCC' }} /> + <button + type="button" + aria-label="#E4FBCC" + onClick={() => { + selectedBackground = '#E4FBCC' + }} + /> <button type="button" onClick={() => { @@ -50,7 +58,9 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({ > iconPicker.ok </button> - <button type="button" onClick={() => onOpenChange(false)}>iconPicker.cancel</button> + <button type="button" onClick={() => onOpenChange(false)}> + iconPicker.cancel + </button> </div> ) }, @@ -86,7 +96,9 @@ describe('DuplicateAppModal', () => { await user.clear(input) await user.click(screen.getByRole('button', { name: /(?:^|\.)duplicate(?=$|:)/ })) - expect(toastErrorMock).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)appCustomize\.nameRequired(?=$|:)/)) + expect(toastErrorMock).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)appCustomize\.nameRequired(?=$|:)/), + ) expect(onConfirm).not.toHaveBeenCalled() expect(onHide).not.toHaveBeenCalled() }) @@ -187,11 +199,13 @@ describe('DuplicateAppModal', () => { }) await user.click(screen.getByRole('button', { name: /(?:^|\.)duplicate(?=$|:)/ })) - expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ - name: 'Image App', - icon_type: 'emoji', - icon: expect.any(String), - icon_background: '#E4FBCC', - })) + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Image App', + icon_type: 'emoji', + icon: expect.any(String), + icon_background: '#E4FBCC', + }), + ) }) }) diff --git a/web/app/components/app/duplicate-modal/index.tsx b/web/app/components/app/duplicate-modal/index.tsx index e5de63201c49e7..1cc62f2f29239e 100644 --- a/web/app/components/app/duplicate-modal/index.tsx +++ b/web/app/components/app/duplicate-modal/index.tsx @@ -51,11 +51,11 @@ const DuplicateAppModal = ({ ) const { plan, enableBilling } = useProviderContext() - const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) + const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps const submit = () => { if (!name.trim()) { - toast.error(t($ => $['appCustomize.nameRequired'], { ns: 'explore' })) + toast.error(t(($) => $['appCustomize.nameRequired'], { ns: 'explore' })) return } onConfirm({ @@ -71,48 +71,55 @@ const DuplicateAppModal = ({ <> <Dialog open={show}> <DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none px-8 text-left align-middle"> - <button type="button" className="absolute top-4 right-4 cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onHide} > <RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" /> </button> - <div className="relative mt-3 mb-9 text-xl leading-[30px] font-semibold text-text-primary">{t($ => $.duplicateTitle, { ns: 'app' })}</div> + <div className="relative mt-3 mb-9 text-xl leading-[30px] font-semibold text-text-primary"> + {t(($) => $.duplicateTitle, { ns: 'app' })} + </div> <div className="mb-9 system-sm-regular text-text-secondary"> - <div className="mb-2 system-md-medium">{t($ => $['appCustomize.subTitle'], { ns: 'explore' })}</div> + <div className="mb-2 system-md-medium"> + {t(($) => $['appCustomize.subTitle'], { ns: 'explore' })} + </div> <div className="flex items-center justify-between space-x-2"> <AppIcon size="large" - onClick={() => { setShowAppIconPicker(true) }} + onClick={() => { + setShowAppIconPicker(true) + }} className="cursor-pointer" iconType={appIcon.type} icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} background={appIcon.type === 'image' ? undefined : appIcon.background} imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} /> - <Input - value={name} - onChange={e => setName(e.target.value)} - className="h-10" - /> + <Input value={name} onChange={(e) => setName(e.target.value)} className="h-10" /> </div> {isAppsFull && <AppsFull className="mt-4" loc="app-duplicate-create" />} </div> <div className="flex flex-row-reverse"> - <Button disabled={isAppsFull} className="ml-2 w-24" variant="primary" onClick={submit}>{t($ => $.duplicate, { ns: 'app' })}</Button> - <Button className="w-24" onClick={onHide}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> + <Button disabled={isAppsFull} className="ml-2 w-24" variant="primary" onClick={submit}> + {t(($) => $.duplicate, { ns: 'app' })} + </Button> + <Button className="w-24" onClick={onHide}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> </div> </DialogContent> </Dialog> {showAppIconPicker && ( <AppIconPicker open={showAppIconPicker} - initialEmoji={appIcon.type === 'emoji' - ? { icon: appIcon.icon, background: appIcon.background } - : undefined} + initialEmoji={ + appIcon.type === 'emoji' + ? { icon: appIcon.icon, background: appIcon.background } + : undefined + } onOpenChange={setShowAppIconPicker} onSelect={(payload) => { setAppIcon(payload) @@ -120,7 +127,6 @@ const DuplicateAppModal = ({ /> )} </> - ) } diff --git a/web/app/components/app/in-site-message/__tests__/index.spec.tsx b/web/app/components/app/in-site-message/__tests__/index.spec.tsx index 2c13ea9cbf5788..983f62aeb0ad98 100644 --- a/web/app/components/app/in-site-message/__tests__/index.spec.tsx +++ b/web/app/components/app/in-site-message/__tests__/index.spec.tsx @@ -24,7 +24,10 @@ describe('InSiteMessage', () => { vi.unstubAllGlobals() }) - const renderComponent = (actions: InSiteMessageActionItem[], props?: Partial<ComponentProps<typeof InSiteMessage>>) => { + const renderComponent = ( + actions: InSiteMessageActionItem[], + props?: Partial<ComponentProps<typeof InSiteMessage>>, + ) => { return render( <InSiteMessage notificationId="test-notification-id" @@ -43,7 +46,13 @@ describe('InSiteMessage', () => { const actions: InSiteMessageActionItem[] = [ { action: 'close', action_name: 'dismiss', text: 'Close', type: 'default' }, { action: 'close', action_name: 'outline', text: 'Outline', type: 'outline' }, - { action: 'link', action_name: 'learn_more', text: 'Learn more', type: 'primary', data: 'https://example.com' }, + { + action: 'link', + action_name: 'learn_more', + text: 'Learn more', + type: 'primary', + data: 'https://example.com', + }, ] renderComponent(actions, { className: 'custom-message' }) @@ -66,7 +75,9 @@ describe('InSiteMessage', () => { }) it('should fallback to default header background when headerBgUrl is empty string', () => { - const actions: InSiteMessageActionItem[] = [{ action: 'close', action_name: 'dismiss', text: 'Close', type: 'default' }] + const actions: InSiteMessageActionItem[] = [ + { action: 'close', action_name: 'dismiss', text: 'Close', type: 'default' }, + ] const { container } = renderComponent(actions, { headerBgUrl: '' }) const header = container.querySelector('div[style]') @@ -78,7 +89,12 @@ describe('InSiteMessage', () => { describe('Actions', () => { it('should call onAction and hide component when close action is clicked', () => { const onAction = vi.fn() - const closeAction: InSiteMessageActionItem = { action: 'close', action_name: 'dismiss', text: 'Close', type: 'default' } + const closeAction: InSiteMessageActionItem = { + action: 'close', + action_name: 'dismiss', + text: 'Close', + type: 'default', + } renderComponent([closeAction], { onAction }) fireEvent.click(screen.getByRole('button', { name: 'Close' })) @@ -99,7 +115,11 @@ describe('InSiteMessage', () => { renderComponent([linkAction]) fireEvent.click(screen.getByRole('button', { name: 'Open link' })) - expect(window.open).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer') + expect(window.open).toHaveBeenCalledWith( + 'https://example.com', + '_blank', + 'noopener,noreferrer', + ) }) it('should navigate with location.assign when link action target is _self', () => { diff --git a/web/app/components/app/in-site-message/__tests__/notification.spec.tsx b/web/app/components/app/in-site-message/__tests__/notification.spec.tsx index 028b2538152c6e..0f69679a704f26 100644 --- a/web/app/components/app/in-site-message/__tests__/notification.spec.tsx +++ b/web/app/components/app/in-site-message/__tests__/notification.spec.tsx @@ -3,11 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import InSiteMessageNotification from '../notification' -const { - mockConfig, - mockNotification, - mockNotificationDismiss, -} = vi.hoisted(() => ({ +const { mockConfig, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({ mockConfig: { isCloudEdition: true, }, @@ -62,9 +58,7 @@ const createWrapper = () => { }) const Wrapper = ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) return Wrapper @@ -119,7 +113,12 @@ describe('InSiteMessageNotification', () => { body: JSON.stringify({ main: 'Parsed body main', actions: [ - { action: 'link', data: 'https://example.com/docs', text: 'Visit docs', type: 'primary' }, + { + action: 'link', + data: 'https://example.com/docs', + text: 'Visit docs', + type: 'primary', + }, { action: 'close', text: 'Outline close', type: 'outline' }, { action: 'close', text: 'Dismiss now', type: 'default' }, { action: 'link', data: 'https://example.com/invalid', text: 100, type: 'primary' }, diff --git a/web/app/components/app/in-site-message/index.tsx b/web/app/components/app/in-site-message/index.tsx index 9452218cf78e00..7abbe35889f4f5 100644 --- a/web/app/components/app/in-site-message/index.tsx +++ b/web/app/components/app/in-site-message/index.tsx @@ -34,16 +34,13 @@ function normalizeLineBreaks(text: string): string { return text.replace(LINE_BREAK_REGEX, '\n') } -function normalizeLinkData(data: unknown): { href: string, rel?: string, target?: string } | null { - if (typeof data === 'string') - return { href: data, target: '_blank' } +function normalizeLinkData(data: unknown): { href: string; rel?: string; target?: string } | null { + if (typeof data === 'string') return { href: data, target: '_blank' } - if (!data || typeof data !== 'object') - return null + if (!data || typeof data !== 'object') return null - const candidate = data as { href?: unknown, rel?: unknown, target?: unknown } - if (typeof candidate.href !== 'string' || !candidate.href) - return null + const candidate = data as { href?: unknown; rel?: unknown; target?: unknown } + if (typeof candidate.href !== 'string' || !candidate.href) return null return { href: candidate.href, @@ -55,10 +52,8 @@ function normalizeLinkData(data: unknown): { href: string, rel?: string, target? const DEFAULT_HEADER_BG_URL = '/in-site-message/header-bg.svg' function resolveButtonVariant(type: InSiteMessageButtonType) { - if (type === 'primary') - return 'primary' - if (type === 'outline') - return 'secondary' + if (type === 'primary') return 'primary' + if (type === 'outline') return 'secondary' return 'ghost' } @@ -101,8 +96,7 @@ function InSiteMessage({ } const linkData = normalizeLinkData(item.data) - if (!linkData) - return + if (!linkData) return const target = linkData.target ?? '_blank' if (target === '_self') { @@ -113,8 +107,7 @@ function InSiteMessage({ window.open(linkData.href, target, linkData.rel || 'noopener,noreferrer') } - if (!visible) - return null + if (!visible) return null return ( <div @@ -123,13 +116,12 @@ function InSiteMessage({ className, )} > - <div className="flex min-h-[128px] flex-col justify-end gap-0.5 bg-cover px-4 pt-6 pb-3 text-text-primary-on-surface" style={headerStyle}> - <div className="title-3xl-bold whitespace-pre-line"> - {normalizedTitle} - </div> - <div className="body-md-regular whitespace-pre-line"> - {normalizedSubtitle} - </div> + <div + className="flex min-h-[128px] flex-col justify-end gap-0.5 bg-cover px-4 pt-6 pb-3 text-text-primary-on-surface" + style={headerStyle} + > + <div className="title-3xl-bold whitespace-pre-line">{normalizedTitle}</div> + <div className="body-md-regular whitespace-pre-line">{normalizedSubtitle}</div> </div> <div className="px-4 pt-4 pb-2 body-md-regular text-text-secondary"> @@ -137,7 +129,7 @@ function InSiteMessage({ </div> <div className="flex items-center justify-end gap-2 p-4"> - {actions.map(item => ( + {actions.map((item) => ( <Button key={`${item.type}-${item.action}-${item.text}`} variant={resolveButtonVariant(item.type)} diff --git a/web/app/components/app/in-site-message/notification.tsx b/web/app/components/app/in-site-message/notification.tsx index 8ed75253a6c1c1..c39a701c9a7240 100644 --- a/web/app/components/app/in-site-message/notification.tsx +++ b/web/app/components/app/in-site-message/notification.tsx @@ -13,8 +13,7 @@ type NotificationBodyPayload = { } function isValidActionItem(value: unknown): value is InSiteMessageActionItem { - if (!value || typeof value !== 'object') - return false + if (!value || typeof value !== 'object') return false const candidate = value as { action?: unknown @@ -24,10 +23,12 @@ function isValidActionItem(value: unknown): value is InSiteMessageActionItem { } return ( - typeof candidate.text === 'string' - && (candidate.type === 'primary' || candidate.type === 'default' || candidate.type === 'outline') - && (candidate.action === 'link' || candidate.action === 'close') - && (candidate.data === undefined || typeof candidate.data !== 'function') + typeof candidate.text === 'string' && + (candidate.type === 'primary' || + candidate.type === 'default' || + candidate.type === 'outline') && + (candidate.action === 'link' || candidate.action === 'close') && + (candidate.data === undefined || typeof candidate.data !== 'function') ) } @@ -38,46 +39,44 @@ function parseNotificationBody(body: string): NotificationBodyPayload | null { main?: unknown } - if (!parsed || typeof parsed !== 'object') - return null + if (!parsed || typeof parsed !== 'object') return null - if (typeof parsed.main !== 'string') - return null + if (typeof parsed.main !== 'string') return null - const actions = Array.isArray(parsed.actions) - ? parsed.actions.filter(isValidActionItem) - : [] + const actions = Array.isArray(parsed.actions) ? parsed.actions.filter(isValidActionItem) : [] return { main: parsed.main, actions, } - } - catch { + } catch { return null } } function InSiteMessageNotification() { const { t } = useTranslation() - const dismissNotificationMutation = useMutation(consoleQuery.notification.dismiss.post.mutationOptions()) + const dismissNotificationMutation = useMutation( + consoleQuery.notification.dismiss.post.mutationOptions(), + ) - const { data } = useQuery(consoleQuery.notification.get.queryOptions({ - enabled: IS_CLOUD_EDITION, - })) + const { data } = useQuery( + consoleQuery.notification.get.queryOptions({ + enabled: IS_CLOUD_EDITION, + }), + ) const notification = data?.notifications?.[0] const parsedBody = notification ? parseNotificationBody(notification.body) : null - if (!IS_CLOUD_EDITION || !notification || !notification.notification_id) - return null + if (!IS_CLOUD_EDITION || !notification || !notification.notification_id) return null const notificationId = notification.notification_id const fallbackActions: InSiteMessageActionItem[] = [ { type: 'default', action_name: 'dismiss', - text: t($ => $['operation.close'], { ns: 'common' }), + text: t(($) => $['operation.close'], { ns: 'common' }), action: 'close', }, ] @@ -85,8 +84,7 @@ function InSiteMessageNotification() { const actions = parsedBody?.actions?.length ? parsedBody.actions : fallbackActions const main = parsedBody?.main ?? notification.body const handleAction = (action: InSiteMessageActionItem) => { - if (action.action !== 'close') - return + if (action.action !== 'close') return dismissNotificationMutation.mutate({ body: { diff --git a/web/app/components/app/log-annotation/index.tsx b/web/app/components/app/log-annotation/index.tsx index 16e1e70d5061c2..fe9eb4b45b0b36 100644 --- a/web/app/components/app/log-annotation/index.tsx +++ b/web/app/components/app/log-annotation/index.tsx @@ -13,10 +13,8 @@ type Props = Readonly<{ pageType: PageType }> -const LogAnnotation: FC<Props> = ({ - pageType, -}) => { - const appDetail = useAppStore(state => state.appDetail) +const LogAnnotation: FC<Props> = ({ pageType }) => { + const appDetail = useAppStore((state) => state.appDetail) if (!appDetail) { return ( @@ -29,9 +27,13 @@ const LogAnnotation: FC<Props> = ({ return ( <div className="flex h-full flex-col px-6 pt-3"> <div className="h-0 grow"> - {pageType === PageType.log && appDetail.mode !== AppModeEnum.WORKFLOW && (<Log appDetail={appDetail} />)} - {pageType === PageType.annotation && (<Annotation appDetail={appDetail} />)} - {pageType === PageType.log && appDetail.mode === AppModeEnum.WORKFLOW && (<WorkflowLog appDetail={appDetail} />)} + {pageType === PageType.log && appDetail.mode !== AppModeEnum.WORKFLOW && ( + <Log appDetail={appDetail} /> + )} + {pageType === PageType.annotation && <Annotation appDetail={appDetail} />} + {pageType === PageType.log && appDetail.mode === AppModeEnum.WORKFLOW && ( + <WorkflowLog appDetail={appDetail} /> + )} </div> </div> ) diff --git a/web/app/components/app/log-annotation/page-title.tsx b/web/app/components/app/log-annotation/page-title.tsx index e6816986ca1993..b3d94bafa884a7 100644 --- a/web/app/components/app/log-annotation/page-title.tsx +++ b/web/app/components/app/log-annotation/page-title.tsx @@ -7,12 +7,7 @@ type PageTitleProps = { learnMoreLabel?: ReactNode } -const PageTitle = ({ - title, - description, - learnMoreHref, - learnMoreLabel, -}: PageTitleProps) => { +const PageTitle = ({ title, description, learnMoreHref, learnMoreLabel }: PageTitleProps) => { const showLearnMore = !!learnMoreHref && learnMoreLabel !== undefined && learnMoreLabel !== null return ( diff --git a/web/app/components/app/log/__tests__/empty-element.spec.tsx b/web/app/components/app/log/__tests__/empty-element.spec.tsx index 6ca08adeb3f484..25f67066425023 100644 --- a/web/app/components/app/log/__tests__/empty-element.spec.tsx +++ b/web/app/components/app/log/__tests__/empty-element.spec.tsx @@ -6,22 +6,30 @@ import EmptyElement from '../empty-element' vi.mock('react-i18next', async () => { const { withSelectorKey, withSelectorKeyProps } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey((key: string) => key), }), - Trans: withSelectorKeyProps(({ i18nKey, components }: { i18nKey: string, components: Record<string, React.ReactNode> }) => ( - <span> - {i18nKey} - {components.shareLink} - {components.testLink} - </span> - )), - }) + Trans: withSelectorKeyProps( + ({ + i18nKey, + components, + }: { + i18nKey: string + components: Record<string, React.ReactNode> + }) => ( + <span> + {i18nKey} + {components.shareLink} + {components.testLink} + </span> + ), + ), + } }) vi.mock('@/utils/app-redirection', () => ({ - getRedirectionPath: (isTest: boolean, _app: App) => isTest ? '/test-path' : '/prod-path', + getRedirectionPath: (isTest: boolean, _app: App) => (isTest ? '/test-path' : '/prod-path'), })) vi.mock('@/utils/var', () => ({ @@ -29,22 +37,23 @@ vi.mock('@/utils/var', () => ({ })) describe('EmptyElement', () => { - const createMockAppDetail = (mode: AppModeEnum) => ({ - id: 'test-app-id', - name: 'Test App', - description: 'Test description', - mode, - icon_type: 'emoji', - icon: 'test-icon', - icon_background: '#ffffff', - enable_site: true, - enable_api: true, - created_at: Date.now(), - site: { - access_token: 'test-token', - app_base_url: 'https://app.example.com', - }, - }) as unknown as App + const createMockAppDetail = (mode: AppModeEnum) => + ({ + id: 'test-app-id', + name: 'Test App', + description: 'Test description', + mode, + icon_type: 'emoji', + icon: 'test-icon', + icon_background: '#ffffff', + enable_site: true, + enable_api: true, + created_at: Date.now(), + site: { + access_token: 'test-token', + app_base_url: 'https://app.example.com', + }, + }) as unknown as App describe('Rendering', () => { it('should render empty element with title', () => { diff --git a/web/app/components/app/log/__tests__/filter.spec.tsx b/web/app/components/app/log/__tests__/filter.spec.tsx index aba887fcc4f566..ee580d2cc0a16d 100644 --- a/web/app/components/app/log/__tests__/filter.spec.tsx +++ b/web/app/components/app/log/__tests__/filter.spec.tsx @@ -19,12 +19,12 @@ vi.mock('@/app/components/base/chip', () => ({ onSelect, onClear, }: { - items: Array<{ value: string, name: string }> + items: Array<{ value: string; name: string }> value?: string - onSelect: (item: { value: string, name: string }) => void + onSelect: (item: { value: string; name: string }) => void onClear: () => void }) => { - const currentItem = items.find(item => item.value === value) ?? items[0] + const currentItem = items.find((item) => item.value === value) ?? items[0] return ( <div> <div>{currentItem?.name}</div> @@ -36,11 +36,9 @@ vi.mock('@/app/components/base/chip', () => ({ })) vi.mock('@/app/components/base/sort', () => ({ - default: ({ - onSelect, - }: { - onSelect: (value: string) => void - }) => <button onClick={() => onSelect('-updated_at')}>select-sort</button>, + default: ({ onSelect }: { onSelect: (value: string) => void }) => ( + <button onClick={() => onSelect('-updated_at')}>select-sort</button> + ), })) describe('Filter', () => { @@ -91,7 +89,17 @@ describe('Filter', () => { describe('TIME_PERIOD_MAPPING', () => { it('should have correct period keys', () => { - expect(Object.keys(TIME_PERIOD_MAPPING)).toEqual(['1', '2', '3', '4', '5', '6', '7', '8', '9']) + expect(Object.keys(TIME_PERIOD_MAPPING)).toEqual([ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + ]) }) it('should have today period with value 0', () => { @@ -234,7 +242,9 @@ describe('Filter', () => { render(<Filter {...propsWithNotAnnotated} />) - expect(screen.getByText(/(?:^|\.)filter\.annotation\.not_annotated(?=$|:)/))!.toBeInTheDocument() + expect( + screen.getByText(/(?:^|\.)filter\.annotation\.not_annotated(?=$|:)/), + )!.toBeInTheDocument() }) it('should display all annotation status when annotation_status is all', () => { diff --git a/web/app/components/app/log/__tests__/index.spec.tsx b/web/app/components/app/log/__tests__/index.spec.tsx index 4c89fcd0d91590..457add7bae850e 100644 --- a/web/app/components/app/log/__tests__/index.spec.tsx +++ b/web/app/components/app/log/__tests__/index.spec.tsx @@ -41,7 +41,16 @@ vi.mock('../filter', () => ({ 9: { value: 0 }, }, default: ({ setQueryParams }: { setQueryParams: (next: Record<string, string>) => void }) => ( - <button onClick={() => setQueryParams({ period: '9', annotation_status: 'all', sort_by: '-created_at', keyword: 'hello' })}> + <button + onClick={() => + setQueryParams({ + period: '9', + annotation_status: 'all', + sort_by: '-created_at', + keyword: 'hello', + }) + } + > filter-controls </button> ), @@ -89,19 +98,25 @@ describe('Logs', () => { it('should request chat conversations and show a loading state before data arrives', () => { render( <Logs - appDetail={{ - id: 'app-1', - mode: AppModeEnum.CHAT, - } as any} + appDetail={ + { + id: 'app-1', + mode: AppModeEnum.CHAT, + } as any + } />, ) - expect(mockUseChatConversations).toHaveBeenCalledWith(expect.objectContaining({ - appId: 'app-1', - })) + expect(mockUseChatConversations).toHaveBeenCalledWith( + expect.objectContaining({ + appId: 'app-1', + }), + ) expect(screen.getByRole('heading', { name: /(?:^|\.)title(?=$|:)/ })).toBeInTheDocument() expect(screen.getByText(/(?:^|\.)description(?=$|:)/)).toBeInTheDocument() - expect(screen.getByRole('link', { name: /(?:^|\.)operation\.learnMore(?=$|:)/ })).toHaveAttribute('href', 'https://docs.example.com/use-dify/monitor/logs') + expect( + screen.getByRole('link', { name: /(?:^|\.)operation\.learnMore(?=$|:)/ }), + ).toHaveAttribute('href', 'https://docs.example.com/use-dify/monitor/logs') expect(screen.getByText('loading-logs')).toBeInTheDocument() }) @@ -113,16 +128,20 @@ describe('Logs', () => { render( <Logs - appDetail={{ - id: 'app-2', - mode: AppModeEnum.COMPLETION, - } as any} + appDetail={ + { + id: 'app-2', + mode: AppModeEnum.COMPLETION, + } as any + } />, ) - expect(mockUseCompletionConversations).toHaveBeenCalledWith(expect.objectContaining({ - appId: 'app-2', - })) + expect(mockUseCompletionConversations).toHaveBeenCalledWith( + expect.objectContaining({ + appId: 'app-2', + }), + ) expect(screen.getByText('empty-logs')).toBeInTheDocument() }) @@ -134,10 +153,12 @@ describe('Logs', () => { render( <Logs - appDetail={{ - id: 'app-3', - mode: AppModeEnum.CHAT, - } as any} + appDetail={ + { + id: 'app-3', + mode: AppModeEnum.CHAT, + } as any + } />, ) diff --git a/web/app/components/app/log/__tests__/list-utils.spec.ts b/web/app/components/app/log/__tests__/list-utils.spec.ts index 7492f4b6551d8a..0eb75cf32d2b3f 100644 --- a/web/app/components/app/log/__tests__/list-utils.spec.ts +++ b/web/app/components/app/log/__tests__/list-utils.spec.ts @@ -15,78 +15,95 @@ import { mergeUniqueChatItems, } from '../list-utils' -const createChatItems = (): IChatItem[] => ([ - { id: 'question-1', content: 'hello', isAnswer: false }, - { id: 'answer-1', content: 'world', isAnswer: true, parentMessageId: 'question-1' }, - { id: 'question-2', content: 'next', isAnswer: false, parentMessageId: 'answer-1' }, - { id: 'answer-2', content: 'reply', isAnswer: true, parentMessageId: 'question-2' }, -]) as IChatItem[] +const createChatItems = (): IChatItem[] => + [ + { id: 'question-1', content: 'hello', isAnswer: false }, + { id: 'answer-1', content: 'world', isAnswer: true, parentMessageId: 'question-1' }, + { id: 'question-2', content: 'next', isAnswer: false, parentMessageId: 'answer-1' }, + { id: 'answer-2', content: 'reply', isAnswer: true, parentMessageId: 'question-2' }, + ] as IChatItem[] describe('log list utils', () => { it('should format chat messages into paired question and answer items', () => { - const items = getFormattedChatList([ - { - id: 'message-1', - inputs: { query: 'hello' }, - query: 'hello', - answer: 'world', - created_at: 1710000000, - answer_tokens: 3, - message_tokens: 2, - message: [{ role: 'user', text: 'hello' }], - message_files: [ - { belongs_to: 'assistant', id: 'file-1' }, - { belongs_to: 'user', id: 'file-2' }, - ], - }, - ] as any, 'conversation-1', 'UTC', 'YYYY-MM-DD') + const items = getFormattedChatList( + [ + { + id: 'message-1', + inputs: { query: 'hello' }, + query: 'hello', + answer: 'world', + created_at: 1710000000, + answer_tokens: 3, + message_tokens: 2, + message: [{ role: 'user', text: 'hello' }], + message_files: [ + { belongs_to: 'assistant', id: 'file-1' }, + { belongs_to: 'user', id: 'file-2' }, + ], + }, + ] as any, + 'conversation-1', + 'UTC', + 'YYYY-MM-DD', + ) expect(items).toHaveLength(2) - expect(items[0]).toEqual(expect.objectContaining({ - id: 'question-message-1', - content: 'hello', - isAnswer: false, - })) - expect(items[1]).toEqual(expect.objectContaining({ - id: 'message-1', - content: 'world', - isAnswer: true, - conversationId: 'conversation-1', - })) + expect(items[0]).toEqual( + expect.objectContaining({ + id: 'question-message-1', + content: 'hello', + isAnswer: false, + }), + ) + expect(items[1]).toEqual( + expect.objectContaining({ + id: 'message-1', + content: 'world', + isAnswer: true, + conversationId: 'conversation-1', + }), + ) }) it('should preserve feedback and annotation hit history when formatting chat items', () => { - const items = getFormattedChatList([ - { - id: 'message-2', - inputs: { default_input: 'fallback prompt' }, - query: '', - answer: 'answer', - created_at: 1710000000, - answer_tokens: 1, - message_tokens: 1, - message: [{ role: 'assistant', text: 'answer' }], - feedbacks: [ - { from_source: 'user', rating: 'like' }, - { from_source: 'admin', rating: 'dislike' }, - ], - annotation_hit_history: { - annotation_id: 'annotation-1', - annotation_create_account: {}, - created_at: 123, + const items = getFormattedChatList( + [ + { + id: 'message-2', + inputs: { default_input: 'fallback prompt' }, + query: '', + answer: 'answer', + created_at: 1710000000, + answer_tokens: 1, + message_tokens: 1, + message: [{ role: 'assistant', text: 'answer' }], + feedbacks: [ + { from_source: 'user', rating: 'like' }, + { from_source: 'admin', rating: 'dislike' }, + ], + annotation_hit_history: { + annotation_id: 'annotation-1', + annotation_create_account: {}, + created_at: 123, + }, }, - }, - ] as any, 'conversation-2', 'UTC', 'YYYY-MM-DD') + ] as any, + 'conversation-2', + 'UTC', + 'YYYY-MM-DD', + ) - expect(items[1]).toEqual(expect.objectContaining({ - feedback: expect.objectContaining({ rating: 'like' }), - adminFeedback: expect.objectContaining({ rating: 'dislike' }), - annotation: expect.objectContaining({ - id: 'annotation-1', - authorName: 'N/A', - created_at: 123, + expect(items[1]).toEqual( + expect.objectContaining({ + feedback: expect.objectContaining({ rating: 'like' }), + adminFeedback: expect.objectContaining({ rating: 'dislike' }), + annotation: expect.objectContaining({ + id: 'annotation-1', + authorName: 'N/A', + created_at: 123, + }), }), - })) + ) }) it('should merge unique chat items and handle pagination retries', () => { @@ -96,24 +113,34 @@ describe('log list utils', () => { { id: 'answer-3', content: 'final', isAnswer: true } as IChatItem, ]) - expect(merged.map(item => item.id)).toEqual(['answer-3', 'question-1', 'answer-1', 'question-2', 'answer-2']) + expect(merged.map((item) => item.id)).toEqual([ + 'answer-3', + 'question-1', + 'answer-1', + 'question-2', + 'answer-2', + ]) - expect(mergePaginatedChatItems({ - maxRetryCount: 3, - newItems: [], - prevItems, - retryCount: 1, - })).toEqual({ + expect( + mergePaginatedChatItems({ + maxRetryCount: 3, + newItems: [], + prevItems, + retryCount: 1, + }), + ).toEqual({ items: prevItems, retryCount: 2, }) - expect(mergePaginatedChatItems({ - maxRetryCount: 3, - newItems: [], - prevItems, - retryCount: 3, - })).toEqual({ + expect( + mergePaginatedChatItems({ + maxRetryCount: 3, + newItems: [], + prevItems, + retryCount: 3, + }), + ).toEqual({ items: prevItems, retryCount: 0, }) @@ -126,19 +153,23 @@ describe('log list utils', () => { introduction: 'intro', }) - expect(state.chatItemTree[0]).toEqual(expect.objectContaining({ - id: 'introduction', - isOpeningStatement: true, - })) + expect(state.chatItemTree[0]).toEqual( + expect.objectContaining({ + id: 'introduction', + isOpeningStatement: true, + }), + ) expect(state.oldestAnswerId).toBe('answer-1') expect(getThreadChatItems(state.chatItemTree, 'answer-2').at(-1)?.id).toBe('answer-2') }) it('should return an empty thread state when there are no chat items', () => { - expect(buildChatThreadState({ - allChatItems: [], - hasMore: true, - })).toEqual({ + expect( + buildChatThreadState({ + allChatItems: [], + hasMore: true, + }), + ).toEqual({ chatItemTree: [], oldestAnswerId: undefined, threadChatItems: [], @@ -148,33 +179,43 @@ describe('log list utils', () => { it('should update annotation state helpers', () => { const items = createChatItems() - expect(applyAnnotationEdited(items, 'updated question', 'updated answer', 1)[0]!.content).toBe('updated question') - expect(applyAnnotationAdded(items, 'annotation-1', 'Dify', 'question', 'answer', 1)[1]!.annotation).toEqual(expect.objectContaining({ - id: 'annotation-1', - authorName: 'Dify', - })) + expect(applyAnnotationEdited(items, 'updated question', 'updated answer', 1)[0]!.content).toBe( + 'updated question', + ) + expect( + applyAnnotationAdded(items, 'annotation-1', 'Dify', 'question', 'answer', 1)[1]!.annotation, + ).toEqual( + expect.objectContaining({ + id: 'annotation-1', + authorName: 'Dify', + }), + ) expect(applyAnnotationRemoved(items, 1)[1]!.annotation).toBeUndefined() }) it('should derive urls, scroll thresholds, row values, and detail metadata', () => { - expect(isNearTopLoadMore({ - clientHeight: 200, - scrollHeight: 600, - scrollTop: -380, - })).toBe(true) - - expect(getConversationRowValues({ - isChatMode: false, - log: { - from_account_name: 'demo-user', - message: { - inputs: { query: 'hello' }, - answer: 'world', + expect( + isNearTopLoadMore({ + clientHeight: 200, + scrollHeight: 600, + scrollTop: -380, + }), + ).toBe(true) + + expect( + getConversationRowValues({ + isChatMode: false, + log: { + from_account_name: 'demo-user', + message: { + inputs: { query: 'hello' }, + answer: 'world', + }, }, - }, - noChatLabel: 'no chat', - noOutputLabel: 'no output', - })).toEqual({ + noChatLabel: 'no chat', + noOutputLabel: 'no output', + }), + ).toEqual({ endUser: 'demo-user', isLeftEmpty: false, isRightEmpty: false, @@ -182,46 +223,58 @@ describe('log list utils', () => { rightValue: 'world', }) - expect(getDetailVarList({ - model_config: { - user_input_form: [ - { - text_input: { - variable: 'query', - }, + expect( + getDetailVarList( + { + model_config: { + user_input_form: [ + { + text_input: { + variable: 'query', + }, + }, + ], }, - ], - }, - message: { - inputs: { query: 'fallback value' }, - }, - }, {})).toEqual([ + message: { + inputs: { query: 'fallback value' }, + }, + }, + {}, + ), + ).toEqual([ { label: 'query', value: 'fallback value', }, ]) - expect(getCompletionMessageFiles({ - message: { - message_files: [{ url: 'https://example.com/file-1' }], - }, - }, false)).toEqual(['https://example.com/file-1']) + expect( + getCompletionMessageFiles( + { + message: { + message_files: [{ url: 'https://example.com/file-1' }], + }, + }, + false, + ), + ).toEqual(['https://example.com/file-1']) }) it('should handle default inputs', () => { - expect(getConversationRowValues({ - isChatMode: false, - log: { - from_end_user_session_id: 'session-1', - message: { - inputs: { default_input: 'fallback input' }, - answer: 0, + expect( + getConversationRowValues({ + isChatMode: false, + log: { + from_end_user_session_id: 'session-1', + message: { + inputs: { default_input: 'fallback input' }, + answer: 0, + }, }, - }, - noChatLabel: 'no chat', - noOutputLabel: 'no output', - })).toEqual({ + noChatLabel: 'no chat', + noOutputLabel: 'no output', + }), + ).toEqual({ endUser: 'session-1', isLeftEmpty: false, isRightEmpty: true, diff --git a/web/app/components/app/log/__tests__/list.spec.tsx b/web/app/components/app/log/__tests__/list.spec.tsx index beecdc7dbfa106..ea74ea9c135fe5 100644 --- a/web/app/components/app/log/__tests__/list.spec.tsx +++ b/web/app/components/app/log/__tests__/list.spec.tsx @@ -1,7 +1,10 @@ /* eslint-disable ts/no-explicit-any */ import type { ReactNode } from 'react' import { act, fireEvent, screen, waitFor } from '@testing-library/react' -import { AccountProfileQueryProvider, createAccountProfileQueryClient } from '@/test/account-profile-query' +import { + AccountProfileQueryProvider, + createAccountProfileQueryClient, +} from '@/test/account-profile-query' import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' import ConversationList from '../list' @@ -59,17 +62,18 @@ vi.mock('@/service/annotation', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({ - currentLogItem: mockCurrentLogItem, - setCurrentLogItem: mockSetCurrentLogItem, - showMessageLogModal: mockShowMessageLogModal, - setShowPromptLogModal: mockSetShowPromptLogModal, - setShowAgentLogModal: mockSetShowAgentLogModal, - setShowMessageLogModal: mockSetShowMessageLogModal, - showPromptLogModal: mockShowPromptLogModal, - showAgentLogModal: mockShowAgentLogModal, - currentLogModalActiveTab: mockCurrentLogModalActiveTab, - }), + useStore: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + currentLogItem: mockCurrentLogItem, + setCurrentLogItem: mockSetCurrentLogItem, + showMessageLogModal: mockShowMessageLogModal, + setShowPromptLogModal: mockSetShowPromptLogModal, + setShowAgentLogModal: mockSetShowAgentLogModal, + setShowMessageLogModal: mockSetShowMessageLogModal, + showPromptLogModal: mockShowPromptLogModal, + showAgentLogModal: mockShowAgentLogModal, + currentLogModalActiveTab: mockCurrentLogModalActiveTab, + }), })) vi.mock('@/app/components/base/loading', () => ({ @@ -81,8 +85,10 @@ vi.mock('@/app/components/app/log/model-info', () => ({ })) vi.mock('@/app/components/app/log/var-panel', () => ({ - default: ({ varList }: { varList: Array<{ label: string, value: string }> }) => ( - <div data-testid="var-panel">{varList.map(item => `${item.label}:${item.value}`).join(',')}</div> + default: ({ varList }: { varList: Array<{ label: string; value: string }> }) => ( + <div data-testid="var-panel"> + {varList.map((item) => `${item.label}:${item.value}`).join(',')} + </div> ), })) @@ -96,11 +102,13 @@ vi.mock('@/app/components/app/text-generate/item', () => ({ onFeedback, }: { content: string - onFeedback: (value: { rating: string, content?: string }) => Promise<boolean> + onFeedback: (value: { rating: string; content?: string }) => Promise<boolean> }) => ( <div data-testid="text-generation"> <div>{content}</div> - <button onClick={() => void onFeedback({ rating: 'like', content: 'great' })}>completion-feedback</button> + <button onClick={() => void onFeedback({ rating: 'like', content: 'great' })}> + completion-feedback + </button> </div> ), })) @@ -116,8 +124,14 @@ vi.mock('@/app/components/base/chat/chat', () => ({ hideLogModal, }: { chatList: Array<{ id: string }> - onFeedback: (mid: string, value: { rating: string, content?: string }) => Promise<boolean> - onAnnotationAdded: (annotationId: string, authorName: string, query: string, answer: string, index: number) => void + onFeedback: (mid: string, value: { rating: string; content?: string }) => Promise<boolean> + onAnnotationAdded: ( + annotationId: string, + authorName: string, + query: string, + answer: string, + index: number, + ) => void onAnnotationEdited: (query: string, answer: string, index: number) => void onAnnotationRemoved: (index: number) => Promise<boolean> switchSibling: (siblingMessageId: string) => void @@ -125,9 +139,19 @@ vi.mock('@/app/components/base/chat/chat', () => ({ }) => ( <div data-testid="chat-panel" data-hide-log-modal={String(hideLogModal)}> <div>{chatList.length}</div> - <button onClick={() => void onFeedback('message-1', { rating: 'like', content: 'nice' })}>chat-feedback</button> - <button onClick={() => onAnnotationAdded('annotation-2', 'Admin', 'Edited question', 'Edited answer', 1)}>chat-add-annotation</button> - <button onClick={() => onAnnotationEdited('Updated question', 'Updated answer', 1)}>chat-edit-annotation</button> + <button onClick={() => void onFeedback('message-1', { rating: 'like', content: 'nice' })}> + chat-feedback + </button> + <button + onClick={() => + onAnnotationAdded('annotation-2', 'Admin', 'Edited question', 'Edited answer', 1) + } + > + chat-add-annotation + </button> + <button onClick={() => onAnnotationEdited('Updated question', 'Updated answer', 1)}> + chat-edit-annotation + </button> <button onClick={() => void onAnnotationRemoved(1)}>chat-remove-annotation</button> <button onClick={() => switchSibling('message-2')}>chat-switch-sibling</button> </div> @@ -135,7 +159,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({ })) vi.mock('@/app/components/base/agent-log-modal', () => ({ - default: ({ floating, onCancel }: { floating?: boolean, onCancel: () => void }) => ( + default: ({ floating, onCancel }: { floating?: boolean; onCancel: () => void }) => ( <div data-testid="agent-log-modal" data-floating={String(floating)}> <button onClick={onCancel}>close-agent-log-modal</button> </div> @@ -235,11 +259,7 @@ const renderConversationList = ({ const queryClient = createAccountProfileQueryClient({ timezone: 'Asia/Shanghai' }) return renderWithNuqs( <AccountProfileQueryProvider queryClient={queryClient}> - <ConversationList - appDetail={appDetail} - logs={logs} - onRefresh={mockOnRefresh} - /> + <ConversationList appDetail={appDetail} logs={logs} onRefresh={mockOnRefresh} /> </AccountProfileQueryProvider>, { searchParams }, ) diff --git a/web/app/components/app/log/__tests__/model-info.spec.tsx b/web/app/components/app/log/__tests__/model-info.spec.tsx index 7001c73a8f50d3..b3b201875796eb 100644 --- a/web/app/components/app/log/__tests__/model-info.spec.tsx +++ b/web/app/components/app/log/__tests__/model-info.spec.tsx @@ -15,13 +15,15 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({ - default: ({ modelName }: { provider: unknown, modelName: string }) => ( - <div data-testid="model-icon" data-model-name={modelName}>ModelIcon</div> + default: ({ modelName }: { provider: unknown; modelName: string }) => ( + <div data-testid="model-icon" data-model-name={modelName}> + ModelIcon + </div> ), })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-name', () => ({ - default: ({ modelItem, showMode }: { modelItem: { model: string }, showMode: boolean }) => ( + default: ({ modelItem, showMode }: { modelItem: { model: string }; showMode: boolean }) => ( <div data-testid="model-name" data-show-mode={showMode ? 'true' : 'false'}> {modelItem?.model} </div> @@ -30,17 +32,34 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-name' vi.mock('@langgenius/dify-ui/popover', async () => { const React = await import('react') - const PopoverContext = React.createContext<{ open: boolean, onOpenChange?: (open: boolean) => void } | null>(null) + const PopoverContext = React.createContext<{ + open: boolean + onOpenChange?: (open: boolean) => void + } | null>(null) return { - Popover: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + Popover: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( <PopoverContext.Provider value={{ open, onOpenChange }}> <div data-testid="popover-root" data-open={open ? 'true' : 'false'}> {children} </div> </PopoverContext.Provider> ), - PopoverTrigger: ({ children, render }: { children?: React.ReactNode, render?: React.ReactNode }) => { + PopoverTrigger: ({ + children, + render, + }: { + children?: React.ReactNode + render?: React.ReactNode + }) => { const context = React.useContext(PopoverContext) const content = render ?? children const handleClick = () => { @@ -52,12 +71,15 @@ vi.mock('@langgenius/dify-ui/popover', async () => { return React.cloneElement(element, { onClick: handleClick }) } - return <button type="button" data-testid="popover-trigger" onClick={handleClick}>{content}</button> + return ( + <button type="button" data-testid="popover-trigger" onClick={handleClick}> + {content} + </button> + ) }, PopoverContent: ({ children }: { children: React.ReactNode }) => { const context = React.useContext(PopoverContext) - if (!context?.open) - return null + if (!context?.open) return null return <div data-testid="popover-content">{children}</div> }, diff --git a/web/app/components/app/log/__tests__/var-panel.spec.tsx b/web/app/components/app/log/__tests__/var-panel.spec.tsx index fff1a2e5286153..7c6a55cf35c3ce 100644 --- a/web/app/components/app/log/__tests__/var-panel.spec.tsx +++ b/web/app/components/app/log/__tests__/var-panel.spec.tsx @@ -2,9 +2,11 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import VarPanel from '../var-panel' vi.mock('@/app/components/base/image-uploader/image-preview', () => ({ - default: ({ url, title, onCancel }: { url: string, title: string, onCancel: () => void }) => ( + default: ({ url, title, onCancel }: { url: string; title: string; onCancel: () => void }) => ( <div data-testid="image-preview" data-url={url} data-title={title}> - <button onClick={onCancel} data-testid="close-preview">Close</button> + <button onClick={onCancel} data-testid="close-preview"> + Close + </button> </div> ), })) @@ -152,7 +154,10 @@ describe('VarPanel', () => { fireEvent.click(thumbnail!) expect(screen.getByTestId('image-preview')).toBeInTheDocument() - expect(screen.getByTestId('image-preview')).toHaveAttribute('data-url', 'https://example.com/image1.jpg') + expect(screen.getByTestId('image-preview')).toHaveAttribute( + 'data-url', + 'https://example.com/image1.jpg', + ) }) it('should close image preview when close button is clicked', () => { diff --git a/web/app/components/app/log/empty-element.tsx b/web/app/components/app/log/empty-element.tsx index 6a693e5bc699f5..0557e81f717dd1 100644 --- a/web/app/components/app/log/empty-element.tsx +++ b/web/app/components/app/log/empty-element.tsx @@ -15,8 +15,21 @@ import { basePath } from '@/utils/var' const ThreeDotsIcon = ({ className }: SVGProps<SVGElement>) => { return ( - <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}> - <path d="M5 6.5V5M8.93934 7.56066L10 6.5M10.0103 11.5H11.5103" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> + <svg + width="16" + height="16" + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className={className ?? ''} + > + <path + d="M5 6.5V5M8.93934 7.56066L10 6.5M10.0103 11.5H11.5103" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> </svg> ) } @@ -38,12 +51,12 @@ const EmptyElement: FC<{ appDetail: App }> = ({ appDetail }) => { <div className="flex h-full items-center justify-center"> <div className="box-border h-fit w-[560px] rounded-2xl bg-background-section-burn px-5 py-4"> <span className="system-md-semibold text-text-secondary"> - {t($ => $['table.empty.element.title'], { ns: 'appLog' })} + {t(($) => $['table.empty.element.title'], { ns: 'appLog' })} <ThreeDotsIcon className="relative -top-3 -left-1.5 inline text-text-secondary" /> </span> <div className="mt-2 system-sm-regular text-text-tertiary"> <Trans - i18nKey={$ => $['table.empty.element.content']} + i18nKey={($) => $['table.empty.element.content']} ns="appLog" components={{ shareLink: ( diff --git a/web/app/components/app/log/filter.tsx b/web/app/components/app/log/filter.tsx index 24810a85174241..27c22ed13bf931 100644 --- a/web/app/components/app/log/filter.tsx +++ b/web/app/components/app/log/filter.tsx @@ -18,7 +18,7 @@ const today = dayjs() type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'> -export const TIME_PERIOD_MAPPING: { [key: string]: { value: number, name: TimePeriodName } } = { +export const TIME_PERIOD_MAPPING: { [key: string]: { value: number; name: TimePeriodName } } = { 1: { value: 0, name: 'today' }, 2: { value: 7, name: 'last7days' }, 3: { value: 28, name: 'last4weeks' }, @@ -37,11 +37,15 @@ type IFilterProps = { setQueryParams: (v: QueryParam) => void } -const Filter: FC<IFilterProps> = ({ isChatMode, appId, queryParams, setQueryParams }: IFilterProps) => { +const Filter: FC<IFilterProps> = ({ + isChatMode, + appId, + queryParams, + setQueryParams, +}: IFilterProps) => { const { data, isLoading } = useAnnotationsCount(appId) const { t } = useTranslation() - if (isLoading || !data) - return null + if (isLoading || !data) return null return ( <div className="mb-2 flex flex-row flex-wrap items-center gap-2"> <Chip @@ -53,7 +57,10 @@ const Filter: FC<IFilterProps> = ({ isChatMode, appId, queryParams, setQueryPara setQueryParams({ ...queryParams, period: item.value }) }} onClear={() => setQueryParams({ ...queryParams, period: '9' })} - items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t($ => $[`filter.period.${v.name}`], { ns: 'appLog' }) }))} + items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ + value: k, + name: t(($) => $[`filter.period.${v.name}`], { ns: 'appLog' }), + }))} /> <Chip className="min-w-[150px]" @@ -65,9 +72,15 @@ const Filter: FC<IFilterProps> = ({ isChatMode, appId, queryParams, setQueryPara }} onClear={() => setQueryParams({ ...queryParams, annotation_status: 'all' })} items={[ - { value: 'all', name: t($ => $['filter.annotation.all'], { ns: 'appLog' }) }, - { value: 'annotated', name: t($ => $['filter.annotation.annotated'], { ns: 'appLog', count: data?.count }) }, - { value: 'not_annotated', name: t($ => $['filter.annotation.not_annotated'], { ns: 'appLog' }) }, + { value: 'all', name: t(($) => $['filter.annotation.all'], { ns: 'appLog' }) }, + { + value: 'annotated', + name: t(($) => $['filter.annotation.annotated'], { ns: 'appLog', count: data?.count }), + }, + { + value: 'not_annotated', + name: t(($) => $['filter.annotation.not_annotated'], { ns: 'appLog' }), + }, ]} /> <Input @@ -75,7 +88,7 @@ const Filter: FC<IFilterProps> = ({ isChatMode, appId, queryParams, setQueryPara showLeftIcon showClearIcon value={queryParams.keyword} - placeholder={t($ => $['operation.search'], { ns: 'common' })!} + placeholder={t(($) => $['operation.search'], { ns: 'common' })!} onChange={(e) => { setQueryParams({ ...queryParams, keyword: e.target.value }) }} @@ -88,8 +101,11 @@ const Filter: FC<IFilterProps> = ({ isChatMode, appId, queryParams, setQueryPara order={queryParams.sort_by?.startsWith('-') ? '-' : ''} value={queryParams.sort_by?.replace('-', '') || 'created_at'} items={[ - { value: 'created_at', name: t($ => $['table.header.time'], { ns: 'appLog' }) }, - { value: 'updated_at', name: t($ => $['table.header.updatedTime'], { ns: 'appLog' }) }, + { value: 'created_at', name: t(($) => $['table.header.time'], { ns: 'appLog' }) }, + { + value: 'updated_at', + name: t(($) => $['table.header.updatedTime'], { ns: 'appLog' }), + }, ]} onSelect={(value) => { setQueryParams({ ...queryParams, sort_by: value as string }) diff --git a/web/app/components/app/log/index.tsx b/web/app/components/app/log/index.tsx index 38abb398d44adc..28b56078678804 100644 --- a/web/app/components/app/log/index.tsx +++ b/web/app/components/app/log/index.tsx @@ -36,11 +36,14 @@ const defaultQueryParams: QueryParam = { sort_by: '-created_at', } -const logsStateCache = new Map<string, { - queryParams: QueryParam - currPage: number - limit: number -}>() +const logsStateCache = new Map< + string, + { + queryParams: QueryParam + currPage: number + limit: number + } +>() const Logs: FC<ILogsProps> = ({ appDetail }) => { const { t } = useTranslation() @@ -50,19 +53,22 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => { const searchParams = useSearchParams() const getPageFromParams = useCallback(() => { const pageParam = Number.parseInt(searchParams.get('page') || '1', 10) - if (Number.isNaN(pageParam) || pageParam < 1) - return 0 + if (Number.isNaN(pageParam) || pageParam < 1) return 0 return pageParam - 1 }, [searchParams]) const cachedState = logsStateCache.get(appDetail.id) - const [queryParams, setQueryParams] = useState<QueryParam>(cachedState?.queryParams ?? defaultQueryParams) - const [currPage, setCurrPage] = React.useState<number>(() => cachedState?.currPage ?? getPageFromParams()) + const [queryParams, setQueryParams] = useState<QueryParam>( + cachedState?.queryParams ?? defaultQueryParams, + ) + const [currPage, setCurrPage] = React.useState<number>( + () => cachedState?.currPage ?? getPageFromParams(), + ) const [limit, setLimit] = React.useState<number>(cachedState?.limit ?? APP_PAGE_LIMIT) const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) useEffect(() => { const pageFromParams = getPageFromParams() - setCurrPage(prev => (prev === pageFromParams ? prev : pageFromParams)) + setCurrPage((prev) => (prev === pageFromParams ? prev : pageFromParams)) }, [getPageFromParams]) useEffect(() => { @@ -79,9 +85,12 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => { const query = { page: currPage + 1, limit, - ...((debouncedQueryParams.period !== '9') + ...(debouncedQueryParams.period !== '9' ? { - start: dayjs().subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day').startOf('day').format('YYYY-MM-DD HH:mm'), + start: dayjs() + .subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day') + .startOf('day') + .format('YYYY-MM-DD HH:mm'), end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'), } : {}), @@ -95,10 +104,11 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => { params: query, }) - const { data: completionConversations, refetch: mutateCompletionList } = useCompletionConversations({ - appId: !isChatMode ? appDetail.id : '', - params: query, - }) + const { data: completionConversations, refetch: mutateCompletionList } = + useCompletionConversations({ + appId: !isChatMode ? appDetail.id : '', + params: query, + }) const total = isChatMode ? chatConversations?.total : completionConversations?.total const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1 @@ -108,56 +118,67 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => { setQueryParams(next) }, []) - const handlePageChange = useCallback((page: number) => { - setCurrPage(page) - const params = new URLSearchParams(searchParams.toString()) - const nextPageValue = page + 1 - if (nextPageValue === 1) - params.delete('page') - else - params.set('page', String(nextPageValue)) - const queryString = params.toString() - router.replace(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false }) - }, [pathname, router, searchParams]) + const handlePageChange = useCallback( + (page: number) => { + setCurrPage(page) + const params = new URLSearchParams(searchParams.toString()) + const nextPageValue = page + 1 + if (nextPageValue === 1) params.delete('page') + else params.set('page', String(nextPageValue)) + const queryString = params.toString() + router.replace(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false }) + }, + [pathname, router, searchParams], + ) return ( <div className="flex h-full grow flex-col"> <PageTitle - title={t($ => $.title, { ns: 'appLog' })} - description={t($ => $.description, { ns: 'appLog' })} + title={t(($) => $.title, { ns: 'appLog' })} + description={t(($) => $.description, { ns: 'appLog' })} learnMoreHref={docLink('/use-dify/monitor/logs')} - learnMoreLabel={t($ => $['operation.learnMore'], { ns: 'common' })} + learnMoreLabel={t(($) => $['operation.learnMore'], { ns: 'common' })} /> <div className="flex min-h-0 flex-1 grow flex-col py-4"> - <Filter isChatMode={isChatMode} appId={appDetail.id} queryParams={queryParams} setQueryParams={handleQueryParamsChange} /> - {total === undefined - ? <Loading type="app" /> - : total > 0 - ? <List logs={isChatMode ? chatConversations : completionConversations} appDetail={appDetail} onRefresh={isChatMode ? mutateChatList : mutateCompletionList} /> - : <EmptyElement appDetail={appDetail} />} + <Filter + isChatMode={isChatMode} + appId={appDetail.id} + queryParams={queryParams} + setQueryParams={handleQueryParamsChange} + /> + {total === undefined ? ( + <Loading type="app" /> + ) : total > 0 ? ( + <List + logs={isChatMode ? chatConversations : completionConversations} + appDetail={appDetail} + onRefresh={isChatMode ? mutateChatList : mutateCompletionList} + /> + ) : ( + <EmptyElement appDetail={appDetail} /> + )} {/* Show Pagination only if the total is more than the limit */} - {(total && total > APP_PAGE_LIMIT) - ? ( - <Pagination - page={currPage + 1} - totalPages={totalPages} - onPageChange={page => handlePageChange(page - 1)} - labels={{ - previous: t($ => $['pagination.previous'], { ns: 'common' }), - next: t($ => $['pagination.next'], { ns: 'common' }), - editPageNumber: (page, totalPages) => t($ => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), - pageNumberInput: t($ => $['pagination.pageNumber'], { ns: 'common' }), - }} - pageSize={{ - value: limit, - options: [10, 25, 50], - onValueChange: setLimit, - label: t($ => $['pagination.perPage'], { ns: 'common' }), - ariaLabel: t($ => $['pagination.perPage'], { ns: 'common' }), - }} - /> - ) - : null} + {total && total > APP_PAGE_LIMIT ? ( + <Pagination + page={currPage + 1} + totalPages={totalPages} + onPageChange={(page) => handlePageChange(page - 1)} + labels={{ + previous: t(($) => $['pagination.previous'], { ns: 'common' }), + next: t(($) => $['pagination.next'], { ns: 'common' }), + editPageNumber: (page, totalPages) => + t(($) => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), + pageNumberInput: t(($) => $['pagination.pageNumber'], { ns: 'common' }), + }} + pageSize={{ + value: limit, + options: [10, 25, 50], + onValueChange: setLimit, + label: t(($) => $['pagination.perPage'], { ns: 'common' }), + ariaLabel: t(($) => $['pagination.perPage'], { ns: 'common' }), + }} + /> + ) : null} </div> </div> ) diff --git a/web/app/components/app/log/list-utils.ts b/web/app/components/app/log/list-utils.ts index b4ddd48526f8cf..c776ca9345ce03 100644 --- a/web/app/components/app/log/list-utils.ts +++ b/web/app/components/app/log/list-utils.ts @@ -52,11 +52,16 @@ const toFileResponse = (file: NonNullable<ChatMessage['message_files']>[number]) remote_url: file.url, }) -export const getFormattedChatList = (messages: ChatMessage[], conversationId: string, timezone: string, format: string) => { +export const getFormattedChatList = ( + messages: ChatMessage[], + conversationId: string, + timezone: string, + format: string, +) => { const newChatList: IChatItem[] = [] messages.forEach((item: ChatMessage) => { - const questionFiles = item.message_files?.filter(file => file.belongs_to === 'user') || [] + const questionFiles = item.message_files?.filter((file) => file.belongs_to === 'user') || [] newChatList.push({ id: `question-${item.id}`, content: item.inputs.query || item.inputs.default_input || item.query, @@ -65,24 +70,29 @@ export const getFormattedChatList = (messages: ChatMessage[], conversationId: st parentMessageId: item.parent_message_id || undefined, }) - const answerFiles = item.message_files?.filter(file => file.belongs_to === 'assistant') || [] + const answerFiles = item.message_files?.filter((file) => file.belongs_to === 'assistant') || [] newChatList.push({ id: item.id, content: item.answer, - agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files), - feedback: item.feedbacks?.find(feedback => feedback.from_source === 'user'), - adminFeedback: item.feedbacks?.find(feedback => feedback.from_source === 'admin'), + agent_thoughts: addFileInfos( + item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, + item.message_files, + ), + feedback: item.feedbacks?.find((feedback) => feedback.from_source === 'user'), + adminFeedback: item.feedbacks?.find((feedback) => feedback.from_source === 'admin'), feedbackDisabled: false, isAnswer: true, message_files: getProcessedFilesFromResponse(answerFiles.map(toFileResponse)), log: [ ...(item.message ?? []), ...(item.message?.[item.message.length - 1]?.role !== 'assistant' - ? [{ - role: 'assistant', - text: item.answer, - files: item.message_files?.filter(file => file.belongs_to === 'assistant') || [], - }] + ? [ + { + role: 'assistant', + text: item.answer, + files: item.message_files?.filter((file) => file.belongs_to === 'assistant') || [], + }, + ] : []), ] as IChatItem['log'], workflow_run_id: item.workflow_run_id, @@ -125,8 +135,8 @@ export const getFormattedChatList = (messages: ChatMessage[], conversationId: st } export const mergeUniqueChatItems = (prevItems: IChatItem[], newItems: IChatItem[]) => { - const existingIds = new Set(prevItems.map(item => item.id)) - const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) + const existingIds = new Set(prevItems.map((item) => item.id)) + const uniqueNewItems = newItems.filter((item) => !existingIds.has(item.id)) return [...uniqueNewItems, ...prevItems] } @@ -142,8 +152,8 @@ export const mergePaginatedChatItems = ({ prevItems: IChatItem[] retryCount: number }) => { - const existingIds = new Set(prevItems.map(item => item.id)) - const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) + const existingIds = new Set(prevItems.map((item) => item.id)) + const uniqueNewItems = newItems.filter((item) => !existingIds.has(item.id)) if (!uniqueNewItems.length) { if (retryCount < maxRetryCount && prevItems.length > 1) { @@ -184,19 +194,21 @@ export const buildChatThreadState = ({ let chatItemTree = buildChatItemTree(allChatItems) if (!hasMore && introduction) { - chatItemTree = [{ - id: 'introduction', - isAnswer: true, - isOpeningStatement: true, - content: introduction, - feedbackDisabled: true, - children: chatItemTree, - }] + chatItemTree = [ + { + id: 'introduction', + isAnswer: true, + isOpeningStatement: true, + content: introduction, + feedbackDisabled: true, + children: chatItemTree, + }, + ] } const lastMessageId = allChatItems[allChatItems.length - 1]?.id const threadChatItems = getThreadMessages(chatItemTree, lastMessageId) - const oldestAnswerId = allChatItems.find(item => item.isAnswer)?.id + const oldestAnswerId = allChatItems.find((item) => item.isAnswer)?.id return { chatItemTree, @@ -208,57 +220,69 @@ export const buildChatThreadState = ({ export const getThreadChatItems = (chatItemTree: ChatItemInTree[], siblingMessageId: string) => getThreadMessages(chatItemTree, siblingMessageId) -export const applyAnnotationEdited = (items: IChatItem[], query: string, answer: string, index: number) => items.map((item, currentIndex) => { - if (currentIndex === index - 1) - return { ...item, content: query } +export const applyAnnotationEdited = ( + items: IChatItem[], + query: string, + answer: string, + index: number, +) => + items.map((item, currentIndex) => { + if (currentIndex === index - 1) return { ...item, content: query } - if (currentIndex === index) { - return { - ...item, - annotation: { - ...item.annotation, - logAnnotation: { - ...item.annotation?.logAnnotation, - content: answer, - }, - } as Annotation, + if (currentIndex === index) { + return { + ...item, + annotation: { + ...item.annotation, + logAnnotation: { + ...item.annotation?.logAnnotation, + content: answer, + }, + } as Annotation, + } } - } - return item -}) - -export const applyAnnotationAdded = (items: IChatItem[], annotationId: string, authorName: string, query: string, answer: string, index: number) => items.map((item, currentIndex) => { - if (currentIndex === index - 1) - return { ...item, content: query } + return item + }) - if (currentIndex === index) { - return { - ...item, - annotation: { - id: annotationId, - authorName, - logAnnotation: { - content: answer, - account: { - id: '', - name: authorName, - email: '', +export const applyAnnotationAdded = ( + items: IChatItem[], + annotationId: string, + authorName: string, + query: string, + answer: string, + index: number, +) => + items.map((item, currentIndex) => { + if (currentIndex === index - 1) return { ...item, content: query } + + if (currentIndex === index) { + return { + ...item, + annotation: { + id: annotationId, + authorName, + logAnnotation: { + content: answer, + account: { + id: '', + name: authorName, + email: '', + }, }, - }, - } as Annotation, + } as Annotation, + } } - } - return item -}) + return item + }) -export const applyAnnotationRemoved = (items: IChatItem[], index: number) => items.map((item, currentIndex) => { - if (currentIndex === index) - return { ...item, annotation: undefined } +export const applyAnnotationRemoved = (items: IChatItem[], index: number) => + items.map((item, currentIndex) => { + if (currentIndex === index) return { ...item, annotation: undefined } - return item -}) + return item + }) export const isNearTopLoadMore = ({ clientHeight, scrollHeight, @@ -281,9 +305,10 @@ export const getConversationRowValues = ({ noOutputLabel: string }) => { const endUser = log.from_end_user_session_id || log.from_account_name - const leftValue = get(log, isChatMode ? 'name' : 'message.inputs.query') - || (!isChatMode ? (get(log, 'message.query') || get(log, 'message.inputs.default_input')) : '') - || '' + const leftValue = + get(log, isChatMode ? 'name' : 'message.inputs.query') || + (!isChatMode ? get(log, 'message.query') || get(log, 'message.inputs.default_input') : '') || + '' const rightValue = get(log, isChatMode ? 'message_count' : 'message.answer') return { @@ -291,33 +316,35 @@ export const getConversationRowValues = ({ isLeftEmpty: !leftValue, isRightEmpty: !rightValue, leftValue: leftValue || noChatLabel, - rightValue: rightValue === 0 ? 0 : (rightValue || noOutputLabel), + rightValue: rightValue === 0 ? 0 : rightValue || noOutputLabel, } } -export const getDetailVarList = (detail: ConversationLogDetail, varValues: Record<string, string>) => +export const getDetailVarList = ( + detail: ConversationLogDetail, + varValues: Record<string, string>, +) => detail.model_config?.user_input_form?.flatMap((item) => { const variable = getUserInputVariable(item) - if (!variable) - return [] + if (!variable) return [] const value = varValues[variable] ?? detail.message?.inputs?.[variable] - if (typeof value !== 'string') - return [] + if (typeof value !== 'string') return [] - return [{ - label: variable, - value, - }] + return [ + { + label: variable, + value, + }, + ] }) || [] export const getCompletionMessageFiles = (detail: ConversationLogDetail, isChatMode: boolean) => { const messageFiles = detail.message?.message_files - if (isChatMode || !messageFiles?.length) - return [] + if (isChatMode || !messageFiles?.length) return [] - return messageFiles.flatMap(item => item.url ? [item.url] : []) + return messageFiles.flatMap((item) => (item.url ? [item.url] : [])) } diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index a832f58ad3f4bd..f77795a9695573 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -1,13 +1,22 @@ 'use client' import type { FC } from 'react' import type { ChatItemInTree } from '../../base/chat/types' -import type { FeedbackFunc, FeedbackType, IChatItem, SubmitAnnotationFunc } from '@/app/components/base/chat/chat/type' -import type { ChatConversationGeneralDetail, ChatConversationsResponse, ChatMessagesRequest, CompletionConversationGeneralDetail, CompletionConversationsResponse, LogAnnotation } from '@/models/log' +import type { + FeedbackFunc, + FeedbackType, + IChatItem, + SubmitAnnotationFunc, +} from '@/app/components/base/chat/chat/type' +import type { + ChatConversationGeneralDetail, + ChatConversationsResponse, + ChatMessagesRequest, + CompletionConversationGeneralDetail, + CompletionConversationsResponse, + LogAnnotation, +} from '@/models/log' import type { App } from '@/types/app' -import { - HandThumbDownIcon, - HandThumbUpIcon, -} from '@heroicons/react/24/outline' +import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' import { Drawer, @@ -45,7 +54,11 @@ import { WorkflowContextProvider } from '@/app/components/workflow/context' import { userProfileQueryOptions } from '@/features/account-profile/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' -import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' +import { + fetchChatMessages, + updateLogMessageAnnotations, + updateLogMessageFeedbacks, +} from '@/service/log' import { AppSourceType } from '@/service/share' import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' import { AppModeEnum } from '@/types/app' @@ -68,7 +81,7 @@ import VarPanel from './var-panel' type AppStoreState = ReturnType<typeof useAppStore.getState> type ConversationListItem = ChatConversationGeneralDetail | CompletionConversationGeneralDetail -type ConversationSelection = ConversationListItem | { id: string, isPlaceholder?: true } +type ConversationSelection = ConversationListItem | { id: string; isPlaceholder?: true } dayjs.extend(utc) dayjs.extend(timezone) @@ -98,11 +111,16 @@ const DrawerContext = createContext<IDrawerContext>({} as IDrawerContext) /** * Icon component with numbers */ -const HandThumbIconWithCount: FC<{ count: number, iconType: 'up' | 'down' }> = ({ count, iconType }) => { +const HandThumbIconWithCount: FC<{ count: number; iconType: 'up' | 'down' }> = ({ + count, + iconType, +}) => { const classname = iconType === 'up' ? 'text-primary-600 bg-primary-50' : 'text-red-600 bg-red-50' const Icon = iconType === 'up' ? HandThumbUpIcon : HandThumbDownIcon return ( - <div className={`inline-flex w-fit items-center rounded-md p-1 text-xs ${classname} mr-1 last:mr-0`}> + <div + className={`inline-flex w-fit items-center rounded-md p-1 text-xs ${classname} mr-1 last:mr-0`} + > <Icon className="mr-0.5 size-3 rounded-md" /> {count > 0 ? count : null} </div> @@ -110,8 +128,7 @@ const HandThumbIconWithCount: FC<{ count: number, iconType: 'up' | 'down' }> = ( } const statusTdRender = (statusCount: StatusCount) => { - if (!statusCount) - return null + if (!statusCount) return null if (statusCount.paused > 0) { return ( @@ -120,31 +137,26 @@ const statusTdRender = (statusCount: StatusCount) => { <span className="text-util-colors-warning-warning-600">Pending</span> </div> ) - } - else if (statusCount.partial_success + statusCount.failed === 0) { + } else if (statusCount.partial_success + statusCount.failed === 0) { return ( <div className="inline-flex items-center gap-1 system-xs-semibold-uppercase"> <StatusDot status="success" /> <span className="text-util-colors-green-green-600">Success</span> </div> ) - } - else if (statusCount.failed === 0) { + } else if (statusCount.failed === 0) { return ( <div className="inline-flex items-center gap-1 system-xs-semibold-uppercase"> <StatusDot status="success" /> <span className="text-util-colors-green-green-600">Partial Success</span> </div> ) - } - else { + } else { return ( <div className="inline-flex items-center gap-1 system-xs-semibold-uppercase"> <StatusDot status="error" /> <span className="text-util-colors-red-red-600"> - {statusCount.failed} - {' '} - {`${statusCount.failed > 1 ? 'Failures' : 'Failure'}`} + {statusCount.failed} {`${statusCount.failed > 1 ? 'Failures' : 'Failure'}`} </span> </div> ) @@ -162,7 +174,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { const SCROLL_DEBOUNCE_MS = 200 const { data: timezone } = useQuery({ ...userProfileQueryOptions(), - select: data => data.profile.timezone ?? undefined, + select: (data) => data.profile.timezone ?? undefined, }) const { formatTime } = useTimestamp() const { onClose, appDetail } = useContext(DrawerContext) @@ -176,17 +188,19 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { showAgentLogModal, setShowAgentLogModal, currentLogModalActiveTab, - } = useAppStore(useShallow((state: AppStoreState) => ({ - currentLogItem: state.currentLogItem, - setCurrentLogItem: state.setCurrentLogItem, - showMessageLogModal: state.showMessageLogModal, - setShowMessageLogModal: state.setShowMessageLogModal, - showPromptLogModal: state.showPromptLogModal, - setShowPromptLogModal: state.setShowPromptLogModal, - showAgentLogModal: state.showAgentLogModal, - setShowAgentLogModal: state.setShowAgentLogModal, - currentLogModalActiveTab: state.currentLogModalActiveTab, - }))) + } = useAppStore( + useShallow((state: AppStoreState) => ({ + currentLogItem: state.currentLogItem, + setCurrentLogItem: state.setCurrentLogItem, + showMessageLogModal: state.showMessageLogModal, + setShowMessageLogModal: state.setShowMessageLogModal, + showPromptLogModal: state.showPromptLogModal, + setShowPromptLogModal: state.setShowPromptLogModal, + showAgentLogModal: state.showAgentLogModal, + setShowAgentLogModal: state.setShowAgentLogModal, + currentLogModalActiveTab: state.currentLogModalActiveTab, + })), + ) const { t } = useTranslation() const [hasMore, setHasMore] = useState(true) const [varValues, setVarValues] = useState<Record<string, string>>({}) @@ -203,8 +217,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { const [threadChatItems, setThreadChatItems] = useState<IChatItem[]>([]) const fetchData = useCallback(async () => { - if (isLoadingRef.current || !hasMore) - return + if (isLoadingRef.current || !hasMore) return // Cancel any in-flight request if (abortControllerRef.current) { @@ -223,8 +236,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { limit: 10, } // Use ref for pagination anchor to avoid stale closure issues - if (oldestAnswerIdRef.current) - params.first_id = oldestAnswerIdRef.current + if (oldestAnswerIdRef.current) params.first_id = oldestAnswerIdRef.current const messageRes = await fetchChatMessages({ url: `/apps/${appDetail?.id}/chat-messages`, @@ -232,35 +244,34 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { }) // Ignore stale responses - if (currentRequestId !== requestIdRef.current || controller.signal.aborted) - return + if (currentRequestId !== requestIdRef.current || controller.signal.aborted) return if (messageRes.data.length > 0) { const varValues = messageRes.data.at(-1)!.inputs setVarValues(varValues) } setHasMore(messageRes.has_more) - const newItems = getFormattedChatList(messageRes.data, detail.id, timezone!, t($ => $.dateTimeFormat, { ns: 'appLog' }) as string) + const newItems = getFormattedChatList( + messageRes.data, + detail.id, + timezone!, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + ) // Use functional update to avoid stale state issues setAllChatItems((prevItems: IChatItem[]) => mergeUniqueChatItems(prevItems, newItems)) - } - catch (err: unknown) { - if (err instanceof Error && err.name === 'AbortError') - return + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') return console.error('fetchData execution failed:', err) - } - finally { + } finally { isLoadingRef.current = false - if (abortControllerRef.current === controller) - abortControllerRef.current = null + if (abortControllerRef.current === controller) abortControllerRef.current = null } }, [detail.id, hasMore, timezone, t, appDetail]) // Derive chatItemTree, threadChatItems, and oldestAnswerIdRef from allChatItems useEffect(() => { - if (allChatItems.length === 0) - return + if (allChatItems.length === 0) return const nextThreadState = buildChatThreadState({ allChatItems, @@ -269,45 +280,62 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { }) setChatItemTree(nextThreadState.chatItemTree) setThreadChatItems(nextThreadState.threadChatItems) - if (nextThreadState.oldestAnswerId) - oldestAnswerIdRef.current = nextThreadState.oldestAnswerId + if (nextThreadState.oldestAnswerId) oldestAnswerIdRef.current = nextThreadState.oldestAnswerId }, [allChatItems, hasMore, detail?.model_config?.configs?.introduction]) - const switchSibling = useCallback((siblingMessageId: string) => { - setThreadChatItems(getThreadChatItems(chatItemTree, siblingMessageId)) - }, [chatItemTree]) - - const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => { - setAllChatItems(applyAnnotationEdited(allChatItems, query, answer, index)) - }, [allChatItems]) - const handleAnnotationAdded = useCallback((annotationId: string, authorName: string, query: string, answer: string, index: number) => { - setAllChatItems(applyAnnotationAdded(allChatItems, annotationId, authorName, query, answer, index)) - }, [allChatItems]) - const handleAnnotationRemoved = useCallback(async (index: number): Promise<boolean> => { - const annotation = allChatItems[index]?.annotation + const switchSibling = useCallback( + (siblingMessageId: string) => { + setThreadChatItems(getThreadChatItems(chatItemTree, siblingMessageId)) + }, + [chatItemTree], + ) - try { - if (annotation?.id) { - const { delAnnotation } = await import('@/service/annotation') - await delAnnotation(appDetail?.id || '', annotation.id) + const handleAnnotationEdited = useCallback( + (query: string, answer: string, index: number) => { + setAllChatItems(applyAnnotationEdited(allChatItems, query, answer, index)) + }, + [allChatItems], + ) + const handleAnnotationAdded = useCallback( + (annotationId: string, authorName: string, query: string, answer: string, index: number) => { + setAllChatItems( + applyAnnotationAdded(allChatItems, annotationId, authorName, query, answer, index), + ) + }, + [allChatItems], + ) + const handleAnnotationRemoved = useCallback( + async (index: number): Promise<boolean> => { + const annotation = allChatItems[index]?.annotation + + try { + if (annotation?.id) { + const { delAnnotation } = await import('@/service/annotation') + await delAnnotation(appDetail?.id || '', annotation.id) + } + + setAllChatItems(applyAnnotationRemoved(allChatItems, index)) + + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + return true + } catch { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + return false } - - setAllChatItems(applyAnnotationRemoved(allChatItems, index)) - - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - return true - } - catch { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - return false - } - }, [allChatItems, appDetail?.id, t]) + }, + [allChatItems, appDetail?.id, t], + ) const fetchInitiated = useRef(false) // Only load initial messages, don't auto-load more useEffect(() => { - if (appDetail?.id && detail.id && appDetail?.mode !== AppModeEnum.COMPLETION && !fetchInitiated.current) { + if ( + appDetail?.id && + detail.id && + appDetail?.mode !== AppModeEnum.COMPLETION && + !fetchInitiated.current + ) { // Mark as initialized, but don't auto-load more messages fetchInitiated.current = true // Still call fetchData to get initial messages @@ -318,13 +346,11 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { const [isLoading, setIsLoading] = useState(false) const loadMoreMessages = useCallback(async () => { - if (isLoading || !hasMore || !appDetail?.id || !detail.id) - return + if (isLoading || !hasMore || !appDetail?.id || !detail.id) return // Throttle using ref to persist across re-renders const now = Date.now() - if (now - lastLoadTimeRef.current < SCROLL_DEBOUNCE_MS) - return + if (now - lastLoadTimeRef.current < SCROLL_DEBOUNCE_MS) return lastLoadTimeRef.current = now setIsLoading(true) @@ -362,7 +388,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { messageRes.data, detail.id, timezone!, - t($ => $.dateTimeFormat, { ns: 'appLog' }) as string, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, ) // Use functional update to get latest state and avoid stale closures @@ -376,21 +402,18 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { retryCountRef.current = nextItems.retryCount return nextItems.items }) - } - catch (error) { + } catch (error) { console.error(error) setHasMore(false) retryCountRef.current = 0 - } - finally { + } finally { setIsLoading(false) } }, [detail.id, hasMore, isLoading, timezone, t, appDetail]) const handleScroll = useCallback(() => { const scrollableDiv = document.getElementById('scrollableDiv') - if (!scrollableDiv) - return + if (!scrollableDiv) return const clientHeight = scrollableDiv.clientHeight const scrollHeight = scrollableDiv.scrollHeight const currentScrollTop = scrollableDiv.scrollTop @@ -417,8 +440,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { const ref = useRef<HTMLDivElement>(null) const adjustModalWidth = () => { - if (ref.current) - setWidth(document.body.clientWidth - (ref.current?.clientWidth + 16) - 8) + if (ref.current) setWidth(document.body.clientWidth - (ref.current?.clientWidth + 16) - 8) } useEffect(() => { @@ -427,34 +449,44 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { }, []) return ( - <div ref={ref} className="flex h-full flex-col rounded-xl border-[0.5px] border-components-panel-border"> + <div + ref={ref} + className="flex h-full flex-col rounded-xl border-[0.5px] border-components-panel-border" + > {/* Panel Header */} <div className="flex shrink-0 items-center gap-2 rounded-t-xl bg-components-panel-bg pt-3 pr-3 pb-2 pl-4"> <div className="shrink-0"> - <div className="mb-0.5 system-xs-semibold-uppercase text-text-primary">{isChatMode ? t($ => $['detail.conversationId'], { ns: 'appLog' }) : t($ => $['detail.time'], { ns: 'appLog' })}</div> + <div className="mb-0.5 system-xs-semibold-uppercase text-text-primary"> + {isChatMode + ? t(($) => $['detail.conversationId'], { ns: 'appLog' }) + : t(($) => $['detail.time'], { ns: 'appLog' })} + </div> {isChatMode && ( <div className="flex items-center system-2xs-regular-uppercase text-text-secondary"> <Tooltip> - <TooltipTrigger - render={( - <div className="truncate">{detail.id}</div> - )} - /> - <TooltipContent> - {detail.id} - </TooltipContent> + <TooltipTrigger render={<div className="truncate">{detail.id}</div>} /> + <TooltipContent>{detail.id}</TooltipContent> </Tooltip> <CopyIcon content={detail.id} /> </div> )} {!isChatMode && ( - <div className="system-2xs-regular-uppercase text-text-secondary">{formatTime(detail.created_at, t($ => $.dateTimeFormat, { ns: 'appLog' }) as string)}</div> + <div className="system-2xs-regular-uppercase text-text-secondary"> + {formatTime( + detail.created_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + )} + </div> )} </div> <div className="flex grow flex-wrap items-center justify-end gap-y-1"> {!isAdvanced && <ModelInfo model={detail.model_config.model} />} </div> - <ActionButton size="l" aria-label={t($ => $['operation.close'], { ns: 'common' })} onClick={onClose}> + <ActionButton + size="l" + aria-label={t(($) => $['operation.close'], { ns: 'common' })} + onClick={onClose} + > <RiCloseLine className="size-4 text-text-tertiary" /> </ActionButton> </div> @@ -462,46 +494,44 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { <div className="shrink-0 px-1 pt-1"> <div className="rounded-t-xl bg-background-section-burn p-3 pb-2"> {(varList.length > 0 || (!isChatMode && message_files.length > 0)) && ( - <VarPanel - varList={varList} - message_files={message_files} - /> + <VarPanel varList={varList} message_files={message_files} /> )} </div> </div> <div className="mx-1 mb-1 grow overflow-auto rounded-b-xl bg-background-section-burn"> - {!isChatMode - ? ( - <div className="px-6 py-4"> - <div className="flex h-[18px] items-center space-x-3"> - <div className="system-xs-semibold-uppercase text-text-tertiary">{t($ => $['table.header.output'], { ns: 'appLog' })}</div> - <div - className="h-px grow" - style={{ - background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, rgb(243, 244, 246) 100%)', - }} - > - </div> - </div> - <TextGeneration - appSourceType={AppSourceType.webApp} - className="mt-2" - content={detail.message.answer} - messageId={detail.message.id} - isError={false} - onRetry={noop} - supportFeedback - feedback={detail.message.feedbacks.find((item: any) => item.from_source === 'admin')} - onFeedback={feedback => onFeedback(detail.message.id, feedback)} - isShowTextToSpeech - siteInfo={null} - /> + {!isChatMode ? ( + <div className="px-6 py-4"> + <div className="flex h-[18px] items-center space-x-3"> + <div className="system-xs-semibold-uppercase text-text-tertiary"> + {t(($) => $['table.header.output'], { ns: 'appLog' })} </div> - ) - : threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING ? ( - <div className="mb-4 pt-4"> - <Chat - config={{ + <div + className="h-px grow" + style={{ + background: + 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, rgb(243, 244, 246) 100%)', + }} + ></div> + </div> + <TextGeneration + appSourceType={AppSourceType.webApp} + className="mt-2" + content={detail.message.answer} + messageId={detail.message.id} + isError={false} + onRetry={noop} + supportFeedback + feedback={detail.message.feedbacks.find((item: any) => item.from_source === 'admin')} + onFeedback={(feedback) => onFeedback(detail.message.id, feedback)} + isShowTextToSpeech + siteInfo={null} + /> + </div> + ) : threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING ? ( + <div className="mb-4 pt-4"> + <Chat + config={ + { appId: appDetail?.id, text_to_speech: { enabled: true, @@ -512,7 +542,50 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { enabled: true, }, supportFeedback: true, - } as any} + } as any + } + chatList={threadChatItems} + onAnnotationAdded={handleAnnotationAdded} + onAnnotationEdited={handleAnnotationEdited} + onAnnotationRemoved={handleAnnotationRemoved} + onFeedback={onFeedback} + noChatInput + showPromptLog + hideProcessDetail + hideLogModal + chatContainerInnerClassName="px-3" + switchSibling={switchSibling} + /> + </div> + ) : ( + <div + className="py-4" + id="scrollableDiv" + style={{ + display: 'flex', + flexDirection: 'column-reverse', + height: '100%', + overflow: 'auto', + }} + onScroll={handleScroll} + > + {/* Put the scroll bar always on the bottom */} + <div className="flex w-full flex-col-reverse" style={{ position: 'relative' }}> + <Chat + config={ + { + appId: appDetail?.id, + text_to_speech: { + enabled: true, + }, + questionEditEnable: false, + supportAnnotation: true, + annotation_reply: { + enabled: true, + }, + supportFeedback: true, + } as any + } chatList={threadChatItems} onAnnotationAdded={handleAnnotationAdded} onAnnotationEdited={handleAnnotationEdited} @@ -526,56 +599,16 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { switchSibling={switchSibling} /> </div> - ) : ( - <div - className="py-4" - id="scrollableDiv" - style={{ - display: 'flex', - flexDirection: 'column-reverse', - height: '100%', - overflow: 'auto', - }} - onScroll={handleScroll} - > - {/* Put the scroll bar always on the bottom */} - <div className="flex w-full flex-col-reverse" style={{ position: 'relative' }}> - <Chat - config={{ - appId: appDetail?.id, - text_to_speech: { - enabled: true, - }, - questionEditEnable: false, - supportAnnotation: true, - annotation_reply: { - enabled: true, - }, - supportFeedback: true, - } as any} - chatList={threadChatItems} - onAnnotationAdded={handleAnnotationAdded} - onAnnotationEdited={handleAnnotationEdited} - onAnnotationRemoved={handleAnnotationRemoved} - onFeedback={onFeedback} - noChatInput - showPromptLog - hideProcessDetail - hideLogModal - chatContainerInnerClassName="px-3" - switchSibling={switchSibling} - /> - </div> - {hasMore && ( - <div className="py-3 text-center"> - <div className="system-xs-regular text-text-tertiary"> - {t($ => $['detail.loading'], { ns: 'appLog' })} - ... - </div> + {hasMore && ( + <div className="py-3 text-center"> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['detail.loading'], { ns: 'appLog' })} + ... </div> - )} - </div> - )} + </div> + )} + </div> + )} </div> {showMessageLogModal && ( <WorkflowContextProvider> @@ -618,42 +651,49 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { /** * Text App Conversation Detail Component */ -const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => { +const CompletionConversationDetailComp: FC<{ appId?: string; conversationId?: string }> = ({ + appId, + conversationId, +}) => { // Text Generator App Session Details Including Message List - const { data: conversationDetail, refetch: conversationDetailMutate } = useCompletionConversationDetail(appId, conversationId) + const { data: conversationDetail, refetch: conversationDetailMutate } = + useCompletionConversationDetail(appId, conversationId) const { t } = useTranslation() - const handleFeedback = async (mid: string, { rating, content }: FeedbackType): Promise<boolean> => { + const handleFeedback = async ( + mid: string, + { rating, content }: FeedbackType, + ): Promise<boolean> => { try { await updateLogMessageFeedbacks({ url: `/apps/${appId}/feedbacks`, body: { message_id: mid, rating, content: content ?? undefined }, }) conversationDetailMutate() - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) return true - } - catch { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } catch { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) return false } } const handleAnnotation = async (mid: string, value: string): Promise<boolean> => { try { - await updateLogMessageAnnotations({ url: `/apps/${appId}/annotations`, body: { message_id: mid, content: value } }) + await updateLogMessageAnnotations({ + url: `/apps/${appId}/annotations`, + body: { message_id: mid, content: value }, + }) conversationDetailMutate() - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) return true - } - catch { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } catch { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) return false } } - if (!conversationDetail) - return null + if (!conversationDetail) return null return ( <DetailPanel @@ -667,39 +707,45 @@ const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: st /** * Chat App Conversation Detail Component */ -const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => { +const ChatConversationDetailComp: FC<{ appId?: string; conversationId?: string }> = ({ + appId, + conversationId, +}) => { const { data: conversationDetail } = useChatConversationDetail(appId, conversationId) const { t } = useTranslation() - const handleFeedback = async (mid: string, { rating, content }: FeedbackType): Promise<boolean> => { + const handleFeedback = async ( + mid: string, + { rating, content }: FeedbackType, + ): Promise<boolean> => { try { await updateLogMessageFeedbacks({ url: `/apps/${appId}/feedbacks`, body: { message_id: mid, rating, content: content ?? undefined }, }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) return true - } - catch { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } catch { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) return false } } const handleAnnotation = async (mid: string, value: string): Promise<boolean> => { try { - await updateLogMessageAnnotations({ url: `/apps/${appId}/annotations`, body: { message_id: mid, content: value } }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + await updateLogMessageAnnotations({ + url: `/apps/${appId}/annotations`, + body: { message_id: mid, content: value }, + }) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) return true - } - catch { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } catch { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) return false } } - if (!conversationDetail) - return null + if (!conversationDetail) return null return ( <DetailPanel @@ -716,53 +762,59 @@ const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string } const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh }) => { const { t } = useTranslation() const { formatTime } = useTimestamp() - const [conversationIdInUrl, setConversationIdInUrl] = useQueryState('conversation_id', parseAsString) + const [conversationIdInUrl, setConversationIdInUrl] = useQueryState( + 'conversation_id', + parseAsString, + ) const media = useBreakpoints() const isMobile = media === MediaType.mobile const [showDrawer, setShowDrawer] = useState<boolean>(false) // Whether to display the chat details drawer - const [currentConversation, setCurrentConversation] = useState<ConversationSelection | undefined>() // Currently selected conversation + const [currentConversation, setCurrentConversation] = useState< + ConversationSelection | undefined + >() // Currently selected conversation const closingConversationIdRef = useRef<string | null>(null) const pendingConversationIdRef = useRef<string | null>(null) const pendingConversationCacheRef = useRef<ConversationSelection | undefined>(undefined) const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION // Whether the app is a chat app const isChatflow = appDetail.mode === AppModeEnum.ADVANCED_CHAT // Whether the app is a chatflow app - const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore(useShallow((state: AppStoreState) => ({ - setShowPromptLogModal: state.setShowPromptLogModal, - setShowAgentLogModal: state.setShowAgentLogModal, - setShowMessageLogModal: state.setShowMessageLogModal, - }))) + const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore( + useShallow((state: AppStoreState) => ({ + setShowPromptLogModal: state.setShowPromptLogModal, + setShowAgentLogModal: state.setShowAgentLogModal, + setShowMessageLogModal: state.setShowMessageLogModal, + })), + ) - const activeConversationId = conversationIdInUrl ?? pendingConversationIdRef.current ?? currentConversation?.id + const activeConversationId = + conversationIdInUrl ?? pendingConversationIdRef.current ?? currentConversation?.id - const handleRowClick = useCallback((log: ConversationListItem) => { - if (conversationIdInUrl === log.id) { - if (!showDrawer) - setShowDrawer(true) + const handleRowClick = useCallback( + (log: ConversationListItem) => { + if (conversationIdInUrl === log.id) { + if (!showDrawer) setShowDrawer(true) - if (!currentConversation || currentConversation.id !== log.id) - setCurrentConversation(log) - return - } + if (!currentConversation || currentConversation.id !== log.id) setCurrentConversation(log) + return + } - pendingConversationIdRef.current = log.id - pendingConversationCacheRef.current = log - if (!showDrawer) - setShowDrawer(true) + pendingConversationIdRef.current = log.id + pendingConversationCacheRef.current = log + if (!showDrawer) setShowDrawer(true) - if (currentConversation?.id !== log.id) - setCurrentConversation(undefined) + if (currentConversation?.id !== log.id) setCurrentConversation(undefined) - void setConversationIdInUrl(log.id, { history: 'push' }) - }, [conversationIdInUrl, currentConversation, setConversationIdInUrl, showDrawer]) + void setConversationIdInUrl(log.id, { history: 'push' }) + }, + [conversationIdInUrl, currentConversation, setConversationIdInUrl, showDrawer], + ) const currentConversationId = currentConversation?.id useEffect(() => { if (!conversationIdInUrl) { - if (pendingConversationIdRef.current) - return + if (pendingConversationIdRef.current) return if (showDrawer || currentConversationId) { setShowDrawer(false) @@ -773,21 +825,24 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh }) return } - if (closingConversationIdRef.current === conversationIdInUrl) - return + if (closingConversationIdRef.current === conversationIdInUrl) return if (pendingConversationIdRef.current === conversationIdInUrl) pendingConversationIdRef.current = null - const matchedConversation = logs?.data?.find((item: ConversationListItem) => item.id === conversationIdInUrl) - const nextConversation: ConversationSelection = matchedConversation - ?? pendingConversationCacheRef.current - ?? { id: conversationIdInUrl, isPlaceholder: true } + const matchedConversation = logs?.data?.find( + (item: ConversationListItem) => item.id === conversationIdInUrl, + ) + const nextConversation: ConversationSelection = matchedConversation ?? + pendingConversationCacheRef.current ?? { id: conversationIdInUrl, isPlaceholder: true } - if (!showDrawer) - setShowDrawer(true) + if (!showDrawer) setShowDrawer(true) - if (!currentConversation || currentConversation.id !== conversationIdInUrl || (!('created_at' in currentConversation) && matchedConversation)) + if ( + !currentConversation || + currentConversation.id !== conversationIdInUrl || + (!('created_at' in currentConversation) && matchedConversation) + ) setCurrentConversation(nextConversation) if (pendingConversationCacheRef.current?.id === conversationIdInUrl || matchedConversation) @@ -805,33 +860,49 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh }) pendingConversationCacheRef.current = undefined closingConversationIdRef.current = conversationIdInUrl ?? null - if (conversationIdInUrl) - void setConversationIdInUrl(null, { history: 'replace' }) - }, [conversationIdInUrl, onRefresh, setConversationIdInUrl, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal]) + if (conversationIdInUrl) void setConversationIdInUrl(null, { history: 'replace' }) + }, [ + conversationIdInUrl, + onRefresh, + setConversationIdInUrl, + setShowAgentLogModal, + setShowMessageLogModal, + setShowPromptLogModal, + ]) // Annotated data needs to be highlighted - const renderTdValue = (value: string | number | null, isEmptyStyle: boolean, isHighlight = false, annotation?: LogAnnotation) => { + const renderTdValue = ( + value: string | number | null, + isEmptyStyle: boolean, + isHighlight = false, + annotation?: LogAnnotation, + ) => { return ( <Tooltip> <TooltipTrigger - render={( - <div className={cn(isEmptyStyle ? 'text-text-quaternary' : 'text-text-secondary', !isHighlight ? '' : 'bg-orange-100', 'truncate system-sm-regular')}> + render={ + <div + className={cn( + isEmptyStyle ? 'text-text-quaternary' : 'text-text-secondary', + !isHighlight ? '' : 'bg-orange-100', + 'truncate system-sm-regular', + )} + > {value || '-'} </div> - )} + } /> - <TooltipContent className={(isHighlight && !isChatMode) ? '' : 'hidden!'}> + <TooltipContent className={isHighlight && !isChatMode ? '' : 'hidden!'}> <span className="inline-flex items-center text-xs text-text-tertiary"> <RiEditFill className="mr-1 size-3" /> - {`${t($ => $['detail.annotationTip'], { ns: 'appLog', user: annotation?.account?.name })} ${formatTime(annotation?.created_at || dayjs().unix(), 'MM-DD hh:mm A')}`} + {`${t(($) => $['detail.annotationTip'], { ns: 'appLog', user: annotation?.account?.name })} ${formatTime(annotation?.created_at || dayjs().unix(), 'MM-DD hh:mm A')}`} </span> </TooltipContent> </Tooltip> ) } - if (!logs) - return <Loading /> + if (!logs) return <Loading /> return ( <div className="relative mt-2 grow overflow-x-auto"> @@ -839,28 +910,54 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh }) <thead className="system-xs-medium-uppercase text-text-tertiary"> <tr> <td className="w-5 rounded-l-lg bg-background-section-burn pr-1 pl-2 whitespace-nowrap"></td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{isChatMode ? t($ => $['table.header.summary'], { ns: 'appLog' }) : t($ => $['table.header.input'], { ns: 'appLog' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.endUser'], { ns: 'appLog' })}</td> - {isChatflow && <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.status'], { ns: 'appLog' })}</td>} - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{isChatMode ? t($ => $['table.header.messageCount'], { ns: 'appLog' }) : t($ => $['table.header.output'], { ns: 'appLog' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.userRate'], { ns: 'appLog' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.adminRate'], { ns: 'appLog' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.updatedTime'], { ns: 'appLog' })}</td> - <td className="rounded-r-lg bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.time'], { ns: 'appLog' })}</td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {isChatMode + ? t(($) => $['table.header.summary'], { ns: 'appLog' }) + : t(($) => $['table.header.input'], { ns: 'appLog' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.endUser'], { ns: 'appLog' })} + </td> + {isChatflow && ( + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.status'], { ns: 'appLog' })} + </td> + )} + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {isChatMode + ? t(($) => $['table.header.messageCount'], { ns: 'appLog' }) + : t(($) => $['table.header.output'], { ns: 'appLog' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.userRate'], { ns: 'appLog' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.adminRate'], { ns: 'appLog' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.updatedTime'], { ns: 'appLog' })} + </td> + <td className="rounded-r-lg bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.time'], { ns: 'appLog' })} + </td> </tr> </thead> <tbody className="system-sm-regular text-text-secondary"> {logs.data.map((log: any) => { - const { endUser, isLeftEmpty, isRightEmpty, leftValue, rightValue } = getConversationRowValues({ - isChatMode, - log, - noChatLabel: t($ => $['table.empty.noChat'], { ns: 'appLog' }), - noOutputLabel: t($ => $['table.empty.noOutput'], { ns: 'appLog' }), - }) + const { endUser, isLeftEmpty, isRightEmpty, leftValue, rightValue } = + getConversationRowValues({ + isChatMode, + log, + noChatLabel: t(($) => $['table.empty.noChat'], { ns: 'appLog' }), + noOutputLabel: t(($) => $['table.empty.noOutput'], { ns: 'appLog' }), + }) return ( <tr key={log.id} - className={cn('cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover', activeConversationId !== log.id ? '' : 'bg-background-default-hover')} + className={cn( + 'cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover', + activeConversationId !== log.id ? '' : 'bg-background-default-hover', + )} onClick={() => handleRowClick(log)} > <td className="h-4"> @@ -880,30 +977,65 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh }) </td> )} <td className="p-3 pr-2" style={{ maxWidth: isChatMode ? 100 : 200 }}> - {renderTdValue(rightValue, isRightEmpty, !isChatMode && !!log.annotation?.content, log.annotation)} + {renderTdValue( + rightValue, + isRightEmpty, + !isChatMode && !!log.annotation?.content, + log.annotation, + )} </td> <td className="p-3 pr-2"> - {(!log.user_feedback_stats.like && !log.user_feedback_stats.dislike) - ? renderTdValue(defaultValue, true) - : ( - <> - {!!log.user_feedback_stats.like && <HandThumbIconWithCount iconType="up" count={log.user_feedback_stats.like} />} - {!!log.user_feedback_stats.dislike && <HandThumbIconWithCount iconType="down" count={log.user_feedback_stats.dislike} />} - </> + {!log.user_feedback_stats.like && !log.user_feedback_stats.dislike ? ( + renderTdValue(defaultValue, true) + ) : ( + <> + {!!log.user_feedback_stats.like && ( + <HandThumbIconWithCount + iconType="up" + count={log.user_feedback_stats.like} + /> )} + {!!log.user_feedback_stats.dislike && ( + <HandThumbIconWithCount + iconType="down" + count={log.user_feedback_stats.dislike} + /> + )} + </> + )} </td> <td className="p-3 pr-2"> - {(!log.admin_feedback_stats.like && !log.admin_feedback_stats.dislike) - ? renderTdValue(defaultValue, true) - : ( - <> - {!!log.admin_feedback_stats.like && <HandThumbIconWithCount iconType="up" count={log.admin_feedback_stats.like} />} - {!!log.admin_feedback_stats.dislike && <HandThumbIconWithCount iconType="down" count={log.admin_feedback_stats.dislike} />} - </> + {!log.admin_feedback_stats.like && !log.admin_feedback_stats.dislike ? ( + renderTdValue(defaultValue, true) + ) : ( + <> + {!!log.admin_feedback_stats.like && ( + <HandThumbIconWithCount + iconType="up" + count={log.admin_feedback_stats.like} + /> + )} + {!!log.admin_feedback_stats.dislike && ( + <HandThumbIconWithCount + iconType="down" + count={log.admin_feedback_stats.dislike} + /> )} + </> + )} + </td> + <td className="w-[160px] p-3 pr-2"> + {formatTime( + log.updated_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + )} + </td> + <td className="w-[160px] p-3 pr-2"> + {formatTime( + log.created_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + )} </td> - <td className="w-[160px] p-3 pr-2">{formatTime(log.updated_at, t($ => $.dateTimeFormat, { ns: 'appLog' }) as string)}</td> - <td className="w-[160px] p-3 pr-2">{formatTime(log.created_at, t($ => $.dateTimeFormat, { ns: 'appLog' }) as string)}</td> </tr> ) })} @@ -914,8 +1046,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh }) modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onCloseDrawer() + if (!open) onCloseDrawer() }} > <DrawerPortal> @@ -923,14 +1054,23 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh }) <DrawerViewport> <DrawerPopup className="bg-components-panel-bg p-0! data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-4 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-[640px] data-[swipe-direction=right]:rounded-xl"> <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> - <DrawerContext.Provider value={{ - onClose: onCloseDrawer, - appDetail, - }} + <DrawerContext.Provider + value={{ + onClose: onCloseDrawer, + appDetail, + }} > - {isChatMode - ? <ChatConversationDetailComp appId={appDetail.id} conversationId={currentConversation?.id} /> - : <CompletionConversationDetailComp appId={appDetail.id} conversationId={currentConversation?.id} />} + {isChatMode ? ( + <ChatConversationDetailComp + appId={appDetail.id} + conversationId={currentConversation?.id} + /> + ) : ( + <CompletionConversationDetailComp + appId={appDetail.id} + conversationId={currentConversation?.id} + /> + )} </DrawerContext.Provider> </DrawerContent> </DrawerPopup> diff --git a/web/app/components/app/log/model-info.tsx b/web/app/components/app/log/model-info.tsx index 1f5005f5224e78..93d846d127fbba 100644 --- a/web/app/components/app/log/model-info.tsx +++ b/web/app/components/app/log/model-info.tsx @@ -2,9 +2,7 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { - RiInformation2Line, -} from '@remixicon/react' +import { RiInformation2Line } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' @@ -24,28 +22,22 @@ type Props = Readonly<{ model: any }> -const ModelInfo: FC<Props> = ({ - model, -}) => { +const ModelInfo: FC<Props> = ({ model }) => { const { t } = useTranslation() const modelName = model.name const provideName = model.provider as any - const { - currentModel, - currentProvider, - } = useTextGenerationCurrentProviderAndModelAndModelList( - { provider: provideName, model: modelName }, - ) + const { currentModel, currentProvider } = useTextGenerationCurrentProviderAndModelAndModelList({ + provider: provideName, + model: modelName, + }) const [open, setOpen] = React.useState(false) const getParamValue = (param: string) => { const value = model.completion_params?.[param] || '-' if (param === 'stop') { - if (Array.isArray(value)) - return value.join(',') - else - return '-' + if (Array.isArray(value)) return value.join(',') + else return '-' } return value @@ -54,33 +46,24 @@ const ModelInfo: FC<Props> = ({ return ( <div className={cn('flex items-center rounded-lg')}> <div className="mr-px flex h-8 shrink-0 items-center gap-1 rounded-l-lg bg-components-input-bg-normal pr-2 pl-1.5"> - <ModelIcon - className="size-5!" - provider={currentProvider} - modelName={currentModel?.model} - /> - <ModelName - modelItem={currentModel!} - showMode - /> + <ModelIcon className="size-5!" provider={currentProvider} modelName={currentModel?.model} /> + <ModelName modelItem={currentModel!} showMode /> </div> - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <div className="relative"> <PopoverTrigger - render={( + render={ <button type="button" className="group block border-none bg-transparent p-0"> - <div className={cn( - 'cursor-pointer rounded-r-lg bg-components-button-tertiary-bg p-2 hover:bg-components-button-tertiary-bg-hover', - 'group-data-popup-open:bg-components-button-tertiary-bg-hover', - )} + <div + className={cn( + 'cursor-pointer rounded-r-lg bg-components-button-tertiary-bg p-2 hover:bg-components-button-tertiary-bg-hover', + 'group-data-popup-open:bg-components-button-tertiary-bg-hover', + )} > <RiInformation2Line className="size-4 text-text-tertiary" /> </div> </button> - )} + } /> <PopoverContent placement="bottom-end" @@ -88,16 +71,24 @@ const ModelInfo: FC<Props> = ({ popupClassName="border-none bg-transparent shadow-none" > <div className="relative w-[280px] overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-4 pt-3 pb-2 shadow-xl"> - <div className="mb-1 h-6 system-sm-semibold-uppercase text-text-secondary">{t($ => $['detail.modelParams'], { ns: 'appLog' })}</div> + <div className="mb-1 h-6 system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['detail.modelParams'], { ns: 'appLog' })} + </div> <div className="py-1"> - {['temperature', 'top_p', 'presence_penalty', 'max_tokens', 'stop'].map((param: string, index: number) => { - return ( - <div className="flex justify-between py-1.5" key={index}> - <span className="system-xs-medium-uppercase text-text-tertiary">{PARAM_MAP[param as keyof typeof PARAM_MAP]}</span> - <span className="system-xs-medium-uppercase text-text-secondary">{getParamValue(param)}</span> - </div> - ) - })} + {['temperature', 'top_p', 'presence_penalty', 'max_tokens', 'stop'].map( + (param: string, index: number) => { + return ( + <div className="flex justify-between py-1.5" key={index}> + <span className="system-xs-medium-uppercase text-text-tertiary"> + {PARAM_MAP[param as keyof typeof PARAM_MAP]} + </span> + <span className="system-xs-medium-uppercase text-text-secondary"> + {getParamValue(param)} + </span> + </div> + ) + }, + )} </div> </div> </PopoverContent> diff --git a/web/app/components/app/log/var-panel.tsx b/web/app/components/app/log/var-panel.tsx index 3cbc1a0d579983..1c96e5b06d7b15 100644 --- a/web/app/components/app/log/var-panel.tsx +++ b/web/app/components/app/log/var-panel.tsx @@ -1,10 +1,7 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - RiArrowDownSLine, - RiArrowRightSLine, -} from '@remixicon/react' +import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' import { useState } from 'react' @@ -13,14 +10,11 @@ import { Variable02 } from '@/app/components/base/icons/src/vender/solid/develop import ImagePreview from '@/app/components/base/image-uploader/image-preview' type Props = Readonly<{ - varList: { label: string, value: string }[] + varList: { label: string; value: string }[] message_files: string[] }> -const VarPanel: FC<Props> = ({ - varList, - message_files, -}) => { +const VarPanel: FC<Props> = ({ varList, message_files }) => { const { t } = useTranslation() const [isCollapse, { toggle: toggleCollapse }] = useBoolean(false) const [imagePreviewUrl, setImagePreviewUrl] = useState('') @@ -28,16 +22,21 @@ const VarPanel: FC<Props> = ({ return ( <div className="rounded-[10px] border border-divider-subtle bg-chat-bubble-bg"> <div - className={cn('flex cursor-pointer items-center gap-1 border-b border-divider-subtle px-3 pt-2.5 pb-2 text-text-secondary', isCollapse && 'border-0 pb-2.5')} + className={cn( + 'flex cursor-pointer items-center gap-1 border-b border-divider-subtle px-3 pt-2.5 pb-2 text-text-secondary', + isCollapse && 'border-0 pb-2.5', + )} onClick={toggleCollapse} > <Variable02 className="size-4" /> - <div className="grow system-md-medium">{t($ => $['detail.variables'], { ns: 'appLog' })}</div> - { - isCollapse - ? <RiArrowRightSLine className="size-4" /> - : <RiArrowDownSLine className="size-4" /> - } + <div className="grow system-md-medium"> + {t(($) => $['detail.variables'], { ns: 'appLog' })} + </div> + {isCollapse ? ( + <RiArrowRightSLine className="size-4" /> + ) : ( + <RiArrowDownSLine className="size-4" /> + )} </div> {!isCollapse && ( <div className="flex max-h-[500px] flex-col gap-2 overflow-y-auto p-3"> @@ -54,7 +53,9 @@ const VarPanel: FC<Props> = ({ {message_files.length > 0 && ( <div className="mt-1 flex py-2"> - <div className="w-[128px] shrink-0 system-xs-medium text-text-tertiary">{t($ => $['detail.uploadImages'], { ns: 'appLog' })}</div> + <div className="w-[128px] shrink-0 system-xs-medium text-text-tertiary"> + {t(($) => $['detail.uploadImages'], { ns: 'appLog' })} + </div> <div className="flex space-x-2"> {message_files.map((url, index) => ( <div @@ -69,15 +70,13 @@ const VarPanel: FC<Props> = ({ )} </div> )} - { - imagePreviewUrl && ( - <ImagePreview - url={imagePreviewUrl} - title={imagePreviewUrl} - onCancel={() => setImagePreviewUrl('')} - /> - ) - } + {imagePreviewUrl && ( + <ImagePreview + url={imagePreviewUrl} + title={imagePreviewUrl} + onCancel={() => setImagePreviewUrl('')} + /> + )} </div> ) } diff --git a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx index 804fefe3fc4ed1..a2f597bdcd87bd 100644 --- a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx @@ -5,7 +5,14 @@ import { InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { withSelectorKey } from '@/test/i18n-mock' import { AppModeEnum } from '@/types/app' -import { AppCardAccessControlSection, AppCardDialogs, AppCardOperations, AppCardUrlSection, createAppCardOperations, WorkflowLaunchDialog } from '../app-card-sections' +import { + AppCardAccessControlSection, + AppCardDialogs, + AppCardOperations, + AppCardUrlSection, + createAppCardOperations, + WorkflowLaunchDialog, +} from '../app-card-sections' vi.mock('../settings', () => ({ default: () => <div data-testid="settings-modal" />, @@ -20,10 +27,20 @@ vi.mock('../customize', () => ({ })) vi.mock('../../app-access-control', () => { - const MockAccessControl = ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => ( + const MockAccessControl = ({ + onClose, + onConfirm, + }: { + onClose: () => void + onConfirm: () => void + }) => ( <div data-testid="access-control"> - <button type="button" onClick={onClose}>close-access</button> - <button type="button" onClick={onConfirm}>confirm-access</button> + <button type="button" onClick={onClose}> + close-access + </button> + <button type="button" onClick={onConfirm}> + confirm-access + </button> </div> ) @@ -75,7 +92,9 @@ describe('app-card-sections', () => { fireEvent.click(screen.getByText(/(?:^|\.)publishApp\.notSet(?=$|:)/)) - expect(screen.getByText(/(?:^|\.)accessControlDialog\.accessItems\.specific(?=$|:)/)).toBeInTheDocument() + expect( + screen.getByText(/(?:^|\.)accessControlDialog\.accessItems\.specific(?=$|:)/), + ).toBeInTheDocument() expect(onClick).toHaveBeenCalledTimes(1) }) @@ -111,8 +130,12 @@ describe('app-card-sections', () => { expect(onLaunch).toHaveBeenCalledTimes(1) expect(onLaunchConfig).toHaveBeenCalledTimes(1) - expect(screen.getByText(/(?:^|\.)overview\.appInfo\.launch(?=$|:)/)).not.toHaveAttribute('title') - expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument() + expect(screen.getByText(/(?:^|\.)overview\.appInfo\.launch(?=$|:)/)).not.toHaveAttribute( + 'title', + ) + expect( + screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i }), + ).toBeInTheDocument() }) it('should expose native titles only for truncated operation labels', () => { @@ -140,10 +163,7 @@ describe('app-card-sections', () => { expect(screen.getByText(label)).not.toHaveAttribute('title') }) - const truncatedLabels = [ - 'overview.appInfo.customize.entry', - 'overview.appInfo.settings.entry', - ] + const truncatedLabels = ['overview.appInfo.customize.entry', 'overview.appInfo.settings.entry'] truncatedLabels.forEach((label) => { expect(screen.getByText(label)).toBeInTheDocument() expect(screen.getByRole('button', { name: label })).toHaveAttribute('title', label) @@ -163,14 +183,11 @@ describe('app-card-sections', () => { onDevelop: vi.fn(), }) - render( - <AppCardOperations - t={t as never} - operations={operations} - />, - ) + render(<AppCardOperations t={t as never} operations={operations} />) - expect(screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.customize\.entry(?=$|:)/ })).toHaveAttribute('title', 'overview.appInfo.customize.entry') + expect( + screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.customize\.entry(?=$|:)/ }), + ).toHaveAttribute('title', 'overview.appInfo.customize.entry') expect(AppModeEnum.CHAT).toBe('chat') }) @@ -229,13 +246,15 @@ describe('app-card-sections', () => { <WorkflowLaunchDialog t={t as never} open - hiddenVariables={[{ - variable: 'secret', - label: 'Secret', - type: InputVarType.textInput, - hide: true, - required: true, - }]} + hiddenVariables={[ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + }, + ]} unsupportedVariables={[]} values={{ secret: 'hello' }} onOpenChange={onOpenChange} @@ -244,8 +263,12 @@ describe('app-card-sections', () => { />, ) - expect(screen.getByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/)).toBeInTheDocument() - fireEvent.submit(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }).closest('form')!) + expect( + screen.getByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/), + ).toBeInTheDocument() + fireEvent.submit( + screen.getByRole('button', { name: /overview\.appInfo\.launch/i }).closest('form')!, + ) expect(onSubmit).toHaveBeenCalled() }) diff --git a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts index 694a9aec7baf61..27913f680a2390 100644 --- a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts +++ b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts @@ -33,56 +33,62 @@ describe('app-card-utils', () => { } as AppDetailResponse it('should detect whether the workflow includes a start node', () => { - expect(hasWorkflowStartNode({ - graph: { - nodes: [{ data: { type: BlockEnum.Start } }], - }, - })).toBe(true) + expect( + hasWorkflowStartNode({ + graph: { + nodes: [{ data: { type: BlockEnum.Start } }], + }, + }), + ).toBe(true) - expect(hasWorkflowStartNode({ - graph: { - nodes: [{ data: { type: BlockEnum.Answer } }], - }, - })).toBe(false) + expect( + hasWorkflowStartNode({ + graph: { + nodes: [{ data: { type: BlockEnum.Answer } }], + }, + }), + ).toBe(false) }) it('should return hidden workflow start variables and their initial launch values', () => { const hiddenVariables = getWorkflowHiddenStartVariables({ graph: { - nodes: [{ - data: { - type: BlockEnum.Start, - variables: [ - { - variable: 'visible', - label: 'Visible', - type: InputVarType.textInput, - hide: false, - required: false, - }, - { - variable: 'secret', - label: 'Secret', - type: InputVarType.textInput, - hide: true, - default: 'prefilled', - required: false, - }, - { - variable: 'enabled', - label: 'Enabled', - type: InputVarType.checkbox, - hide: true, - default: true, - required: false, - }, - ], + nodes: [ + { + data: { + type: BlockEnum.Start, + variables: [ + { + variable: 'visible', + label: 'Visible', + type: InputVarType.textInput, + hide: false, + required: false, + }, + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + default: 'prefilled', + required: false, + }, + { + variable: 'enabled', + label: 'Enabled', + type: InputVarType.checkbox, + hide: true, + default: true, + required: false, + }, + ], + }, }, - }], + ], }, }) - expect(hiddenVariables.map(variable => variable.variable)).toEqual(['secret', 'enabled']) + expect(hiddenVariables.map((variable) => variable.variable)).toEqual(['secret', 'enabled']) expect(createWorkflowLaunchInitialValues(hiddenVariables)).toEqual({ secret: 'prefilled', enabled: true, @@ -120,21 +126,23 @@ describe('app-card-utils', () => { } as AppDetailResponse, currentWorkflow: { graph: { - nodes: [{ - data: { - type: BlockEnum.Start, - variables: [ - { - variable: 'start_secret', - label: 'Start Secret', - type: InputVarType.textInput, - hide: true, - default: 'from-start', - required: false, - }, - ], + nodes: [ + { + data: { + type: BlockEnum.Start, + variables: [ + { + variable: 'start_secret', + label: 'Start Secret', + type: InputVarType.textInput, + hide: true, + default: 'from-start', + required: false, + }, + ], + }, }, - }], + ], }, }, }) @@ -190,42 +198,64 @@ describe('app-card-utils', () => { }) it('should require specific access subjects only for the specific access mode', () => { - expect(isAppAccessConfigured( - { ...baseAppInfo, access_mode: AccessMode.PUBLIC }, - { groups: [], members: [] }, - )).toBe(true) - - expect(isAppAccessConfigured( - { ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - { groups: [], members: [] }, - )).toBe(false) - - expect(isAppAccessConfigured( - { ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - { groups: [{ id: 'group-1' }], members: [] }, - )).toBe(true) + expect( + isAppAccessConfigured( + { ...baseAppInfo, access_mode: AccessMode.PUBLIC }, + { groups: [], members: [] }, + ), + ).toBe(true) + + expect( + isAppAccessConfigured( + { ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + { groups: [], members: [] }, + ), + ).toBe(false) + + expect( + isAppAccessConfigured( + { ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + { groups: [{ id: 'group-1' }], members: [] }, + ), + ).toBe(true) }) it('should derive operation keys for api and webapp cards', () => { - expect(getAppCardOperationKeys({ - cardType: 'api', - appMode: AppModeEnum.COMPLETION, - canManageSettings: true, - })).toEqual(['develop']) + expect( + getAppCardOperationKeys({ + cardType: 'api', + appMode: AppModeEnum.COMPLETION, + canManageSettings: true, + }), + ).toEqual(['develop']) - expect(getAppCardOperationKeys({ - cardType: 'webapp', - appMode: AppModeEnum.CHAT, - canManageSettings: false, - })).toEqual(['launch', 'embedded', 'customize']) + expect( + getAppCardOperationKeys({ + cardType: 'webapp', + appMode: AppModeEnum.CHAT, + canManageSettings: false, + }), + ).toEqual(['launch', 'embedded', 'customize']) }) it('should build a workflow launch URL with serialized parameters', async () => { const url = await buildWorkflowLaunchUrl({ accessibleUrl: 'https://example.com/app/workflow/token-1', variables: [ - { variable: 'name', label: 'Name', type: InputVarType.textInput, hide: true, required: false }, - { variable: 'enabled', label: 'Enabled', type: InputVarType.checkbox, hide: true, required: false }, + { + variable: 'name', + label: 'Name', + type: InputVarType.textInput, + hide: true, + required: false, + }, + { + variable: 'enabled', + label: 'Enabled', + type: InputVarType.checkbox, + hide: true, + required: false, + }, ], values: { name: 'Alice', enabled: true }, }) @@ -239,8 +269,20 @@ describe('app-card-utils', () => { const url = await buildWorkflowLaunchUrl({ accessibleUrl: 'https://example.com/app/workflow/token-1', variables: [ - { variable: 'flag', label: 'Flag', type: InputVarType.checkbox, hide: true, required: false }, - { variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false }, + { + variable: 'flag', + label: 'Flag', + type: InputVarType.checkbox, + hide: true, + required: false, + }, + { + variable: 'empty', + label: 'Empty', + type: InputVarType.textInput, + hide: true, + required: false, + }, ], values: { flag: false, empty: '' }, }) @@ -266,7 +308,7 @@ describe('app-card-utils', () => { inputValues: { name: 'Alice', count: '5' }, }) - expect(snippet).toContain('token: \'abc123\'') + expect(snippet).toContain("token: 'abc123'") expect(snippet).toContain('isDev: true') expect(snippet).toContain('name: "Alice"') expect(snippet).toContain('count: "5"') @@ -294,7 +336,7 @@ describe('app-card-utils', () => { inputValues: {}, }) - expect(snippet).toContain('routeSegment: \'agent\'') + expect(snippet).toContain("routeSegment: 'agent'") }) it('should compress and encode base64 using CompressionStream when available', async () => { @@ -315,22 +357,115 @@ describe('app-card-utils', () => { }) it('should identify supported workflow launch input types', () => { - expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.textInput, hide: true, required: false })).toBe(true) - expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.paragraph, hide: true, required: false })).toBe(true) - expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.select, hide: true, required: false })).toBe(true) - expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.number, hide: true, required: false })).toBe(true) - expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.checkbox, hide: true, required: false })).toBe(true) - expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.json, hide: true, required: false })).toBe(true) - expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.jsonObject, hide: true, required: false })).toBe(true) - expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.url, hide: true, required: false })).toBe(true) - expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.files, hide: true, required: false })).toBe(false) - expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.singleFile, hide: true, required: false })).toBe(false) + expect( + isWorkflowLaunchInputSupported({ + variable: 'v', + label: 'V', + type: InputVarType.textInput, + hide: true, + required: false, + }), + ).toBe(true) + expect( + isWorkflowLaunchInputSupported({ + variable: 'v', + label: 'V', + type: InputVarType.paragraph, + hide: true, + required: false, + }), + ).toBe(true) + expect( + isWorkflowLaunchInputSupported({ + variable: 'v', + label: 'V', + type: InputVarType.select, + hide: true, + required: false, + }), + ).toBe(true) + expect( + isWorkflowLaunchInputSupported({ + variable: 'v', + label: 'V', + type: InputVarType.number, + hide: true, + required: false, + }), + ).toBe(true) + expect( + isWorkflowLaunchInputSupported({ + variable: 'v', + label: 'V', + type: InputVarType.checkbox, + hide: true, + required: false, + }), + ).toBe(true) + expect( + isWorkflowLaunchInputSupported({ + variable: 'v', + label: 'V', + type: InputVarType.json, + hide: true, + required: false, + }), + ).toBe(true) + expect( + isWorkflowLaunchInputSupported({ + variable: 'v', + label: 'V', + type: InputVarType.jsonObject, + hide: true, + required: false, + }), + ).toBe(true) + expect( + isWorkflowLaunchInputSupported({ + variable: 'v', + label: 'V', + type: InputVarType.url, + hide: true, + required: false, + }), + ).toBe(true) + expect( + isWorkflowLaunchInputSupported({ + variable: 'v', + label: 'V', + type: InputVarType.files, + hide: true, + required: false, + }), + ).toBe(false) + expect( + isWorkflowLaunchInputSupported({ + variable: 'v', + label: 'V', + type: InputVarType.singleFile, + hide: true, + required: false, + }), + ).toBe(false) }) it('should coerce numeric defaults to string in createWorkflowLaunchInitialValues', () => { const result = createWorkflowLaunchInitialValues([ - { variable: 'count', label: 'Count', type: InputVarType.number, hide: true, required: false, default: 42 }, - { variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false }, + { + variable: 'count', + label: 'Count', + type: InputVarType.number, + hide: true, + required: false, + default: 42, + }, + { + variable: 'empty', + label: 'Empty', + type: InputVarType.textInput, + hide: true, + required: false, + }, ]) expect(result).toEqual({ count: '42', empty: '' }) diff --git a/web/app/components/app/overview/__tests__/app-card.spec.tsx b/web/app/components/app/overview/__tests__/app-card.spec.tsx index 3b579d8d9c4f42..a6ecc0d6829044 100644 --- a/web/app/components/app/overview/__tests__/app-card.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card.spec.tsx @@ -13,9 +13,10 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.com${path}`, })) -const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { - systemFeatures: { webapp_auth: { enabled: true } }, -}) +const render = (ui: ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { webapp_auth: { enabled: true } }, + }) const mockPush = vi.fn() const mockSetAppDetail = vi.fn() @@ -23,15 +24,25 @@ const mockOnChangeStatus = vi.fn() const mockOnGenerateCode = vi.fn() const mockFetchAppDetail = vi.fn() -let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string, variables?: Array<Record<string, unknown>> } }> } } | null = null -let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] } +let mockWorkflow: { + graph?: { + nodes?: Array<{ data?: { type?: string; variables?: Array<Record<string, unknown>> } }> + } +} | null = null +let mockAccessSubjects: { groups?: unknown[]; members?: unknown[] } = { groups: [], members: [] } let mockAppDetail: AppDetailResponse | undefined vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { appDetail: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({ - appDetail: mockAppDetail as AppDetailResponse, - setAppDetail: mockSetAppDetail, - }), + useStore: ( + selector: (state: { + appDetail: AppDetailResponse + setAppDetail: typeof mockSetAppDetail + }) => unknown, + ) => + selector({ + appDetail: mockAppDetail as AppDetailResponse, + setAppDetail: mockSetAppDetail, + }), })) vi.mock('@/next/navigation', () => ({ @@ -62,19 +73,40 @@ vi.mock('@/app/components/develop/secret-key/secret-key-button', () => ({ })) vi.mock('../settings', () => ({ - default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => isShow ? <button data-testid="settings-modal" onClick={onClose}>settings-modal</button> : null, + default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) => + isShow ? ( + <button data-testid="settings-modal" onClick={onClose}> + settings-modal + </button> + ) : null, })) vi.mock('../embedded', () => ({ - default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => isShow ? <button data-testid="embedded-modal" onClick={onClose}>embedded-modal</button> : null, + default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) => + isShow ? ( + <button data-testid="embedded-modal" onClick={onClose}> + embedded-modal + </button> + ) : null, })) vi.mock('../customize', () => ({ - default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => isShow ? <button data-testid="customize-modal" onClick={onClose}>customize-modal</button> : null, + default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) => + isShow ? ( + <button data-testid="customize-modal" onClick={onClose}> + customize-modal + </button> + ) : null, })) vi.mock('../../app-access-control', () => { - const MockAccessControl = ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => ( + const MockAccessControl = ({ + onConfirm, + onClose, + }: { + onConfirm: () => Promise<void> + onClose: () => void + }) => ( <div data-testid="access-control-modal"> <button onClick={() => void onConfirm()}>confirm-access-control</button> <button onClick={onClose}>close-access-control</button> @@ -139,36 +171,36 @@ describe('AppCard', () => { }) it('should open the published webapp when launch is clicked', () => { - render( - <AppCard - appInfo={appInfo} - onChangeStatus={mockOnChangeStatus} - />, - ) + render(<AppCard appInfo={appInfo} onChangeStatus={mockOnChangeStatus} />) fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.launch(?=$|:)/)) - expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com${basePath}/chat/access-token`, '_blank') + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/access-token`, + '_blank', + ) }) it('should open the workflow web app directly when launch is clicked even with hidden inputs', () => { mockWorkflow = { graph: { - nodes: [{ - data: { - type: 'start', - variables: [ - { - variable: 'secret', - label: 'Secret', - type: InputVarType.textInput, - hide: true, - required: true, - default: '', - }, - ], + nodes: [ + { + data: { + type: 'start', + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, }, - }], + ], }, } @@ -188,27 +220,31 @@ describe('AppCard', () => { `https://example.com${basePath}/workflow/access-token`, '_blank', ) - expect(screen.queryByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/)).not.toBeInTheDocument() + expect( + screen.queryByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/), + ).not.toBeInTheDocument() }) it('should collect hidden workflow inputs from the config action before launching the workflow web app', async () => { mockWorkflow = { graph: { - nodes: [{ - data: { - type: 'start', - variables: [ - { - variable: 'secret', - label: 'Secret', - type: InputVarType.textInput, - hide: true, - required: true, - default: '', - }, - ], + nodes: [ + { + data: { + type: 'start', + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, }, - }], + ], }, } @@ -224,12 +260,16 @@ describe('AppCard', () => { fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })) - expect(screen.getByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/)).toBeInTheDocument() + expect( + screen.getByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/), + ).toBeInTheDocument() fireEvent.change(screen.getByLabelText('Secret'), { target: { value: 'top-secret' }, }) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ })) + fireEvent.click( + screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ }), + ) await waitFor(() => { expect(mockWindowOpen).toHaveBeenCalledWith( @@ -242,30 +282,34 @@ describe('AppCard', () => { it('should open the chat web app directly when launch is clicked even with hidden inputs', () => { mockWorkflow = { graph: { - nodes: [{ - data: { - type: 'start', - variables: [ - { - variable: 'chat_secret', - label: 'Chat Secret', - type: InputVarType.textInput, - hide: true, - required: true, - default: '', - }, - ], + nodes: [ + { + data: { + type: 'start', + variables: [ + { + variable: 'chat_secret', + label: 'Chat Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, }, - }], + ], }, } render( <AppCard - appInfo={{ - ...appInfo, - mode: AppModeEnum.ADVANCED_CHAT, - } as AppDetailResponse} + appInfo={ + { + ...appInfo, + mode: AppModeEnum.ADVANCED_CHAT, + } as AppDetailResponse + } onChangeStatus={mockOnChangeStatus} />, ) @@ -276,48 +320,58 @@ describe('AppCard', () => { `https://example.com${basePath}/chat/access-token`, '_blank', ) - expect(screen.queryByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/)).not.toBeInTheDocument() + expect( + screen.queryByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/), + ).not.toBeInTheDocument() }) it('should collect hidden chatflow inputs from the config action before launching the chat web app', async () => { mockWorkflow = { graph: { - nodes: [{ - data: { - type: 'start', - variables: [ - { - variable: 'chat_secret', - label: 'Chat Secret', - type: InputVarType.textInput, - hide: true, - required: true, - default: '', - }, - ], + nodes: [ + { + data: { + type: 'start', + variables: [ + { + variable: 'chat_secret', + label: 'Chat Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, }, - }], + ], }, } render( <AppCard - appInfo={{ - ...appInfo, - mode: AppModeEnum.ADVANCED_CHAT, - } as AppDetailResponse} + appInfo={ + { + ...appInfo, + mode: AppModeEnum.ADVANCED_CHAT, + } as AppDetailResponse + } onChangeStatus={mockOnChangeStatus} />, ) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })) - expect(screen.getByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/)).toBeInTheDocument() + expect( + screen.getByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/), + ).toBeInTheDocument() fireEvent.change(screen.getByLabelText('Chat Secret'), { target: { value: 'chat-secret' }, }) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ })) + fireEvent.click( + screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ }), + ) await waitFor(() => { expect(mockWindowOpen).toHaveBeenCalledWith( @@ -328,12 +382,7 @@ describe('AppCard', () => { }) it('should show the access-control not-set badge when specific access has no subjects', () => { - render( - <AppCard - appInfo={appInfo} - onChangeStatus={mockOnChangeStatus} - />, - ) + render(<AppCard appInfo={appInfo} onChangeStatus={mockOnChangeStatus} />) expect(screen.getByText(/(?:^|\.)publishApp\.notSet(?=$|:)/)).toBeInTheDocument() }) @@ -366,7 +415,9 @@ describe('AppCard', () => { />, ) - expect(screen.queryByText(/(?:^|\.)overview\.appInfo\.accessibleAddress(?=$|:)/)).not.toBeInTheDocument() + expect( + screen.queryByText(/(?:^|\.)overview\.appInfo\.accessibleAddress(?=$|:)/), + ).not.toBeInTheDocument() expect(screen.queryByText(/(?:^|\.)overview\.appInfo\.launch(?=$|:)/)).not.toBeInTheDocument() expect(screen.getByText(/(?:^|\.)overview\.status\.disable(?=$|:)/)).toBeInTheDocument() }) @@ -391,12 +442,7 @@ describe('AppCard', () => { }) it('should open settings embedded and customize dialogs from webapp operations', () => { - render( - <AppCard - appInfo={appInfo} - onChangeStatus={mockOnChangeStatus} - />, - ) + render(<AppCard appInfo={appInfo} onChangeStatus={mockOnChangeStatus} />) fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.embedded\.entry(?=$|:)/)) expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() @@ -426,12 +472,17 @@ describe('AppCard', () => { await waitFor(() => { expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) }) - expect(setQueryDataSpy).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({ - access_mode: AccessMode.PUBLIC, - })) - expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({ - access_mode: AccessMode.PUBLIC, - })) + expect(setQueryDataSpy).toHaveBeenCalledWith( + ['apps', 'detail', 'app-1'], + expect.objectContaining({ + access_mode: AccessMode.PUBLIC, + }), + ) + expect(mockSetAppDetail).toHaveBeenCalledWith( + expect.objectContaining({ + access_mode: AccessMode.PUBLIC, + }), + ) }) it('should surface the learn-more tooltip action for workflows without a start node', () => { @@ -451,19 +502,21 @@ describe('AppCard', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.enableTooltip\.description(?=$|:)/ })) + fireEvent.click( + screen.getByRole('button', { + name: /(?:^|\.)overview\.appInfo\.enableTooltip\.description(?=$|:)/, + }), + ) fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.enableTooltip\.learnMore(?=$|:)/)) - expect(mockWindowOpen).toHaveBeenCalledWith('https://docs.example.com/use-dify/nodes/user-input', '_blank') + expect(mockWindowOpen).toHaveBeenCalledWith( + 'https://docs.example.com/use-dify/nodes/user-input', + '_blank', + ) }) it('should close the overview dialogs when their child callbacks are invoked', () => { - render( - <AppCard - appInfo={appInfo} - onChangeStatus={mockOnChangeStatus} - />, - ) + render(<AppCard appInfo={appInfo} onChangeStatus={mockOnChangeStatus} />) fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.embedded\.entry(?=$|:)/)) fireEvent.click(screen.getByTestId('embedded-modal')) @@ -483,15 +536,10 @@ describe('AppCard', () => { }) it('should report refresh failures from access control updates', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) mockFetchAppDetail.mockRejectedValueOnce(new Error('refresh failed')) - render( - <AppCard - appInfo={appInfo} - onChangeStatus={mockOnChangeStatus} - />, - ) + render(<AppCard appInfo={appInfo} onChangeStatus={mockOnChangeStatus} />) fireEvent.click(screen.getByText(/(?:^|\.)publishApp\.notSet(?=$|:)/)) fireEvent.click(screen.getByText('confirm-access-control')) @@ -504,22 +552,22 @@ describe('AppCard', () => { }) it('should close the regenerate confirmation even when no generator is configured', () => { - const { container } = render( - <AppCard - appInfo={appInfo} - onChangeStatus={mockOnChangeStatus} - />, - ) + const { container } = render(<AppCard appInfo={appInfo} onChangeStatus={mockOnChangeStatus} />) - const refreshButton = container.querySelector('[class*="refreshIcon"]')?.parentElement as HTMLElement + const refreshButton = container.querySelector('[class*="refreshIcon"]') + ?.parentElement as HTMLElement fireEvent.click(refreshButton) - expect(screen.getByText(/(?:^|\.)overview\.appInfo\.regenerateNotice(?=$|:)/)).toBeInTheDocument() + expect( + screen.getByText(/(?:^|\.)overview\.appInfo\.regenerateNotice(?=$|:)/), + ).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.confirm(?=$|:)/ })) expect(mockOnGenerateCode).not.toHaveBeenCalled() return waitFor(() => { - expect(screen.queryByText(/(?:^|\.)overview\.appInfo\.regenerateNotice(?=$|:)/)).not.toBeInTheDocument() + expect( + screen.queryByText(/(?:^|\.)overview\.appInfo\.regenerateNotice(?=$|:)/), + ).not.toBeInTheDocument() }) }) @@ -533,7 +581,8 @@ describe('AppCard', () => { />, ) - const refreshButton = container.querySelector('[class*="refreshIcon"]')?.parentElement as HTMLElement + const refreshButton = container.querySelector('[class*="refreshIcon"]') + ?.parentElement as HTMLElement fireEvent.click(refreshButton) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.confirm(?=$|:)/ })) diff --git a/web/app/components/app/overview/__tests__/app-chart-utils.spec.ts b/web/app/components/app/overview/__tests__/app-chart-utils.spec.ts index 4d51e201d21127..13dce6eaf695cc 100644 --- a/web/app/components/app/overview/__tests__/app-chart-utils.spec.ts +++ b/web/app/components/app/overview/__tests__/app-chart-utils.spec.ts @@ -1,5 +1,12 @@ /* eslint-disable ts/no-explicit-any */ -import { buildChartOptions, getChartValueField, getDefaultChartData, getSummaryValue, getTokenSummary, hasNonZeroChartData } from '../app-chart-utils' +import { + buildChartOptions, + getChartValueField, + getDefaultChartData, + getSummaryValue, + getTokenSummary, + hasNonZeroChartData, +} from '../app-chart-utils' describe('app-chart-utils', () => { describe('getDefaultChartData', () => { @@ -18,15 +25,25 @@ describe('app-chart-utils', () => { describe('hasNonZeroChartData', () => { it('should detect whether rows contain a non-zero chart value', () => { - expect(hasNonZeroChartData([ - { date: 'Jan 1, 2024', count: 0 }, - { date: 'Jan 2, 2024', count: '0' }, - ], 'count')).toBe(false) - - expect(hasNonZeroChartData([ - { date: 'Jan 1, 2024', count: 0 }, - { date: 'Jan 2, 2024', count: 1 }, - ], 'count')).toBe(true) + expect( + hasNonZeroChartData( + [ + { date: 'Jan 1, 2024', count: 0 }, + { date: 'Jan 2, 2024', count: '0' }, + ], + 'count', + ), + ).toBe(false) + + expect( + hasNonZeroChartData( + [ + { date: 'Jan 1, 2024', count: 0 }, + { date: 'Jan 2, 2024', count: 1 }, + ], + 'count', + ), + ).toBe(true) }) }) @@ -104,8 +121,11 @@ describe('app-chart-utils', () => { yMax: 100, }) - const dataset = options.dataset as { dimensions: string[], source: Array<Record<string, unknown>> } - const grid = options.grid as { top: number, left: number, right: number } + const dataset = options.dataset as { + dimensions: string[] + source: Array<Record<string, unknown>> + } + const grid = options.grid as { top: number; left: number; right: number } const yAxis = options.yAxis as { max: number } const series = options.series as Array<{ lineStyle: { color: string } }> @@ -132,19 +152,27 @@ describe('app-chart-utils', () => { const xAxis = options.xAxis as Array<Record<string, any>> const formatter = xAxis[0]!.axisLabel.formatter as (value: string) => string const outerInterval = xAxis[0]!.splitLine.interval as (index: number) => boolean - const innerInterval = xAxis[1]!.splitLine.interval as (_index: number, value: string) => boolean + const innerInterval = xAxis[1]!.splitLine.interval as ( + _index: number, + value: string, + ) => boolean const series = options.series as Array<Record<string, any>> - const tooltipFormatter = series[0]!.tooltip.formatter as (params: { name: string, data: { total_cost: number, total_price: string } }) => string + const tooltipFormatter = series[0]!.tooltip.formatter as (params: { + name: string + data: { total_cost: number; total_price: string } + }) => string expect(formatter('Jan 2, 2024')).toBe('Jan 2, 2024') expect(outerInterval(0)).toBe(true) expect(outerInterval(1)).toBe(false) expect(innerInterval(0, '')).toBe(false) expect(innerInterval(1, '1')).toBe(true) - expect(tooltipFormatter({ - name: 'Jan 2, 2024', - data: { total_cost: 10, total_price: '2.50' }, - })).toContain('~$2.50') + expect( + tooltipFormatter({ + name: 'Jan 2, 2024', + data: { total_cost: 10, total_price: '2.50' }, + }), + ).toContain('~$2.50') }) }) }) diff --git a/web/app/components/app/overview/__tests__/app-chart.spec.tsx b/web/app/components/app/overview/__tests__/app-chart.spec.tsx index 0385ed8bae46b6..92770460d5f28f 100644 --- a/web/app/components/app/overview/__tests__/app-chart.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-chart.spec.tsx @@ -3,7 +3,7 @@ import Chart, { MessagesChart } from '../app-chart' const reactEChartsMock = vi.fn() vi.mock('echarts-for-react', () => ({ - default: (props: { option: unknown, opts?: unknown }) => { + default: (props: { option: unknown; opts?: unknown }) => { reactEChartsMock(props) return <div data-testid="echarts" /> }, diff --git a/web/app/components/app/overview/__tests__/toggle-logic.test.ts b/web/app/components/app/overview/__tests__/toggle-logic.test.ts index 9127dca1040a63..fcb6f52cb9b985 100644 --- a/web/app/components/app/overview/__tests__/toggle-logic.test.ts +++ b/web/app/components/app/overview/__tests__/toggle-logic.test.ts @@ -25,22 +25,23 @@ describe('app-card-utils', () => { describe('hasWorkflowStartNode', () => { it('should detect a workflow start node', () => { - expect(hasWorkflowStartNode({ - graph: { - nodes: [ - { data: { type: 'llm' } }, - { data: { type: 'start' } }, - ], - }, - })).toBe(true) + expect( + hasWorkflowStartNode({ + graph: { + nodes: [{ data: { type: 'llm' } }, { data: { type: 'start' } }], + }, + }), + ).toBe(true) }) it('should return false when the workflow has no start node', () => { - expect(hasWorkflowStartNode({ - graph: { - nodes: [{ data: { type: 'llm' } }], - }, - })).toBe(false) + expect( + hasWorkflowStartNode({ + graph: { + nodes: [{ data: { type: 'llm' } }], + }, + }), + ).toBe(false) }) }) @@ -84,41 +85,49 @@ describe('app-card-utils', () => { describe('getAppCardOperationKeys', () => { it('should include embedded and settings actions for editable chat webapps', () => { - expect(getAppCardOperationKeys({ - cardType: 'webapp', - appMode: AppModeEnum.CHAT, - canManageSettings: true, - })).toEqual(['launch', 'embedded', 'customize', 'settings']) + expect( + getAppCardOperationKeys({ + cardType: 'webapp', + appMode: AppModeEnum.CHAT, + canManageSettings: true, + }), + ).toEqual(['launch', 'embedded', 'customize', 'settings']) }) it('should only expose the develop action for api cards', () => { - expect(getAppCardOperationKeys({ - cardType: 'api', - appMode: AppModeEnum.COMPLETION, - canManageSettings: false, - })).toEqual(['develop']) + expect( + getAppCardOperationKeys({ + cardType: 'api', + appMode: AppModeEnum.COMPLETION, + canManageSettings: false, + }), + ).toEqual(['develop']) }) }) describe('isAppAccessConfigured', () => { it('should require members or groups for specific access mode', () => { - expect(isAppAccessConfigured( - { - id: 'app-1', - access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, - } as unknown as AppDetailResponse, - { groups: [], members: [] }, - )).toBe(false) + expect( + isAppAccessConfigured( + { + id: 'app-1', + access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + } as unknown as AppDetailResponse, + { groups: [], members: [] }, + ), + ).toBe(false) }) it('should treat non-specific access modes as configured', () => { - expect(isAppAccessConfigured( - { - id: 'app-1', - access_mode: AccessMode.PUBLIC, - } as unknown as AppDetailResponse, - { groups: [], members: [] }, - )).toBe(true) + expect( + isAppAccessConfigured( + { + id: 'app-1', + access_mode: AccessMode.PUBLIC, + } as unknown as AppDetailResponse, + { groups: [], members: [] }, + ), + ).toBe(true) }) }) }) diff --git a/web/app/components/app/overview/__tests__/trigger-card.spec.tsx b/web/app/components/app/overview/__tests__/trigger-card.spec.tsx index 8379186850f065..6f81d3941ebf89 100644 --- a/web/app/components/app/overview/__tests__/trigger-card.spec.tsx +++ b/web/app/components/app/overview/__tests__/trigger-card.spec.tsx @@ -42,9 +42,7 @@ vi.mock('@/service/use-tools', () => ({ vi.mock('@/service/use-triggers', () => ({ useAllTriggerPlugins: () => ({ - data: [ - { id: 'plugin-1', name: 'Test Plugin', icon: 'test-icon' }, - ], + data: [{ id: 'plugin-1', name: 'Test Plugin', icon: 'test-icon' }], }), })) @@ -54,12 +52,22 @@ vi.mock('@/utils', () => ({ vi.mock('@/app/components/workflow/block-icon', () => ({ default: ({ type }: { type: string }) => ( - <div data-testid="block-icon" data-type={type}>BlockIcon</div> + <div data-testid="block-icon" data-type={type}> + BlockIcon + </div> ), })) vi.mock('@langgenius/dify-ui/switch', () => ({ - Switch: ({ checked, onCheckedChange, disabled }: { checked: boolean, onCheckedChange: (v: boolean) => void, disabled: boolean }) => ( + Switch: ({ + checked, + onCheckedChange, + disabled, + }: { + checked: boolean + onCheckedChange: (v: boolean) => void + disabled: boolean + }) => ( <button data-testid="switch" data-checked={checked ? 'true' : 'false'} @@ -115,7 +123,9 @@ describe('TriggerCard', () => { render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />) - expect(screen.getByText(/(?:^|\.)overview\.triggerInfo\.noTriggerAdded(?=$|:)/)).toBeInTheDocument() + expect( + screen.getByText(/(?:^|\.)overview\.triggerInfo\.noTriggerAdded(?=$|:)/), + ).toBeInTheDocument() }) it('should show trigger status description when no triggers', () => { @@ -123,7 +133,9 @@ describe('TriggerCard', () => { render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />) - expect(screen.getByText(/(?:^|\.)overview\.triggerInfo\.triggerStatusDescription(?=$|:)/)).toBeInTheDocument() + expect( + screen.getByText(/(?:^|\.)overview\.triggerInfo\.triggerStatusDescription(?=$|:)/), + ).toBeInTheDocument() }) it('should show learn more link when no triggers', () => { @@ -131,9 +143,14 @@ describe('TriggerCard', () => { render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />) - const learnMoreLink = screen.getByText(/(?:^|\.)overview\.triggerInfo\.learnAboutTriggers(?=$|:)/) + const learnMoreLink = screen.getByText( + /(?:^|\.)overview\.triggerInfo\.learnAboutTriggers(?=$|:)/, + ) expect(learnMoreLink).toBeInTheDocument() - expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/nodes/trigger/overview') + expect(learnMoreLink).toHaveAttribute( + 'href', + 'https://docs.example.com/use-dify/nodes/trigger/overview', + ) }) }) @@ -361,7 +378,9 @@ describe('TriggerCard', () => { permission_keys: [], } as AppDetailResponse - render(<TriggerCard appInfo={appInfoWithoutEditPermission} onToggleResult={mockOnToggleResult} />) + render( + <TriggerCard appInfo={appInfoWithoutEditPermission} onToggleResult={mockOnToggleResult} />, + ) const switchBtn = screen.getByTestId('switch') expect(switchBtn).toHaveAttribute('data-disabled', 'true') diff --git a/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx b/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx index 309df540a60c4c..6a926c3dd59524 100644 --- a/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx +++ b/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx @@ -12,13 +12,15 @@ describe('WorkflowHiddenInputFields', () => { it('should render a text input with label and placeholder', () => { render( <WorkflowHiddenInputFields - hiddenVariables={[{ - variable: 'name', - label: 'Full Name', - type: InputVarType.textInput, - hide: true, - required: true, - }]} + hiddenVariables={[ + { + variable: 'name', + label: 'Full Name', + type: InputVarType.textInput, + hide: true, + required: true, + }, + ]} values={{ name: 'Alice' }} onValueChange={onValueChange} />, @@ -34,13 +36,15 @@ describe('WorkflowHiddenInputFields', () => { it('should render a number input for number-typed variables', () => { render( <WorkflowHiddenInputFields - hiddenVariables={[{ - variable: 'count', - label: 'Count', - type: InputVarType.number, - hide: true, - required: false, - }]} + hiddenVariables={[ + { + variable: 'count', + label: 'Count', + type: InputVarType.number, + hide: true, + required: false, + }, + ]} values={{ count: '5' }} onValueChange={onValueChange} />, @@ -56,13 +60,15 @@ describe('WorkflowHiddenInputFields', () => { it('should render a checkbox input without a separate label element above', () => { render( <WorkflowHiddenInputFields - hiddenVariables={[{ - variable: 'enabled', - label: 'Enable Feature', - type: InputVarType.checkbox, - hide: true, - required: false, - }]} + hiddenVariables={[ + { + variable: 'enabled', + label: 'Enable Feature', + type: InputVarType.checkbox, + hide: true, + required: false, + }, + ]} values={{ enabled: true }} onValueChange={onValueChange} />, @@ -79,14 +85,16 @@ describe('WorkflowHiddenInputFields', () => { it('should render a select dropdown for select-typed variables', () => { render( <WorkflowHiddenInputFields - hiddenVariables={[{ - variable: 'color', - label: 'Color', - type: InputVarType.select, - hide: true, - required: false, - options: ['red', 'green', 'blue'], - }]} + hiddenVariables={[ + { + variable: 'color', + label: 'Color', + type: InputVarType.select, + hide: true, + required: false, + options: ['red', 'green', 'blue'], + }, + ]} values={{ color: 'red' }} onValueChange={onValueChange} />, @@ -98,14 +106,16 @@ describe('WorkflowHiddenInputFields', () => { it('should render a textarea for paragraph-typed variables', () => { render( <WorkflowHiddenInputFields - hiddenVariables={[{ - variable: 'description', - label: 'Description', - type: InputVarType.paragraph, - hide: true, - required: false, - max_length: 500, - }]} + hiddenVariables={[ + { + variable: 'description', + label: 'Description', + type: InputVarType.paragraph, + hide: true, + required: false, + max_length: 500, + }, + ]} values={{ description: 'Hello world' }} onValueChange={onValueChange} />, @@ -121,13 +131,15 @@ describe('WorkflowHiddenInputFields', () => { it('should render a textarea for json-typed variables', () => { render( <WorkflowHiddenInputFields - hiddenVariables={[{ - variable: 'config', - label: 'Config JSON', - type: InputVarType.json, - hide: true, - required: false, - }]} + hiddenVariables={[ + { + variable: 'config', + label: 'Config JSON', + type: InputVarType.json, + hide: true, + required: false, + }, + ]} values={{ config: '{"key": "value"}' }} onValueChange={onValueChange} />, @@ -140,13 +152,15 @@ describe('WorkflowHiddenInputFields', () => { it('should render a textarea for jsonObject-typed variables', () => { render( <WorkflowHiddenInputFields - hiddenVariables={[{ - variable: 'schema', - label: 'Schema', - type: InputVarType.jsonObject, - hide: true, - required: false, - }]} + hiddenVariables={[ + { + variable: 'schema', + label: 'Schema', + type: InputVarType.jsonObject, + hide: true, + required: false, + }, + ]} values={{ schema: '{}' }} onValueChange={onValueChange} />, @@ -159,13 +173,15 @@ describe('WorkflowHiddenInputFields', () => { it('should use the variable key as label when label is not a string', () => { render( <WorkflowHiddenInputFields - hiddenVariables={[{ - variable: 'my_var', - label: { nodeType: 'start' as never, nodeName: 'Start', variable: 'my_var' }, - type: InputVarType.textInput, - hide: true, - required: false, - }]} + hiddenVariables={[ + { + variable: 'my_var', + label: { nodeType: 'start' as never, nodeName: 'Start', variable: 'my_var' }, + type: InputVarType.textInput, + hide: true, + required: false, + }, + ]} values={{ my_var: '' }} onValueChange={onValueChange} />, @@ -177,13 +193,15 @@ describe('WorkflowHiddenInputFields', () => { it('should use the custom fieldIdPrefix for element ids', () => { const { container } = render( <WorkflowHiddenInputFields - hiddenVariables={[{ - variable: 'token', - label: 'Token', - type: InputVarType.textInput, - hide: true, - required: false, - }]} + hiddenVariables={[ + { + variable: 'token', + label: 'Token', + type: InputVarType.textInput, + hide: true, + required: false, + }, + ]} values={{ token: 'abc' }} onValueChange={onValueChange} fieldIdPrefix="custom-prefix" @@ -196,13 +214,15 @@ describe('WorkflowHiddenInputFields', () => { it('should render empty string for non-string fieldValue in text inputs', () => { render( <WorkflowHiddenInputFields - hiddenVariables={[{ - variable: 'flag', - label: 'Flag', - type: InputVarType.textInput, - hide: true, - required: false, - }]} + hiddenVariables={[ + { + variable: 'flag', + label: 'Flag', + type: InputVarType.textInput, + hide: true, + required: false, + }, + ]} values={{ flag: true as never }} onValueChange={onValueChange} />, diff --git a/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx b/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx index 689a91e466835b..960c24551b6bf9 100644 --- a/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx +++ b/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx @@ -65,7 +65,9 @@ describe('APIKeyInfoPanel - Cloud Edition', () => { it('should not render external link for cloud version', () => { const { container } = scenarios.withAPIKeyNotSet() - expect(container.querySelector('a[href="https://cloud.dify.ai/apps"]')).not.toBeInTheDocument() + expect( + container.querySelector('a[href="https://cloud.dify.ai/apps"]'), + ).not.toBeInTheDocument() }) it('should display set API button text', () => { diff --git a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx index 14919e28eb3aa7..33d32041bf3c66 100644 --- a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx +++ b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx @@ -4,8 +4,10 @@ import type { ModalContextState } from '@/context/modal-context' import { fireEvent, render, screen } from '@testing-library/react' import { noop } from 'es-toolkit/function' import { defaultPlan } from '@/app/components/billing/config' -import { useModalContext as actualUseModalContext, useModalContextSelector as actualUseModalContextSelector } from '@/context/modal-context' - +import { + useModalContext as actualUseModalContext, + useModalContextSelector as actualUseModalContextSelector, +} from '@/context/modal-context' import { useProviderContext as actualUseProviderContext } from '@/context/provider-context' import APIKeyInfoPanel from './index' @@ -30,9 +32,13 @@ vi.mock('@/next/navigation', () => ({ })) // Type casting for mocks -const mockUseProviderContext = actualUseProviderContext as MockedFunction<typeof actualUseProviderContext> +const mockUseProviderContext = actualUseProviderContext as MockedFunction< + typeof actualUseProviderContext +> const mockUseModalContext = actualUseModalContext as MockedFunction<typeof actualUseModalContext> -const mockUseModalContextSelector = actualUseModalContextSelector as MockedFunction<typeof actualUseModalContextSelector> +const mockUseModalContextSelector = actualUseModalContextSelector as MockedFunction< + typeof actualUseModalContextSelector +> // Default mock data const defaultProviderContext = { @@ -108,7 +114,7 @@ function setupMocks(overrides: MockOverrides = {}) { ...overrides.modalContext, }) - mockUseModalContextSelector.mockImplementation(selector => + mockUseModalContextSelector.mockImplementation((selector) => selector({ ...defaultModalContext, ...overrides.modalContext, @@ -204,8 +210,7 @@ export const interactions = { // Click the close button clickCloseButton: (container: HTMLElement) => { const closeButton = container.querySelector('.absolute.right-4.top-4') - if (closeButton) - fireEvent.click(closeButton) + if (closeButton) fireEvent.click(closeButton) return closeButton }, } diff --git a/web/app/components/app/overview/apikey-info-panel/index.tsx b/web/app/components/app/overview/apikey-info-panel/index.tsx index c68bfe07df87a3..f8186e26504799 100644 --- a/web/app/components/app/overview/apikey-info-panel/index.tsx +++ b/web/app/components/app/overview/apikey-info-panel/index.tsx @@ -22,36 +22,51 @@ const APIKeyInfoPanel: FC = () => { const [isShow, setIsShow] = useState(true) - if (isAPIKeySet) - return null + if (isAPIKeySet) return null - if (!(isShow)) - return null + if (!isShow) return null return ( - <div className={cn('border-components-panel-border bg-components-panel-bg', 'relative mb-6 rounded-2xl border p-8 shadow-md')}> - <div className={cn('text-[24px] font-semibold text-text-primary', isCloud ? 'flex h-8 items-center space-x-1' : 'mb-6 leading-8')}> + <div + className={cn( + 'border-components-panel-border bg-components-panel-bg', + 'relative mb-6 rounded-2xl border p-8 shadow-md', + )} + > + <div + className={cn( + 'text-[24px] font-semibold text-text-primary', + isCloud ? 'flex h-8 items-center space-x-1' : 'mb-6 leading-8', + )} + > {isCloud && <em-emoji id="😀" />} - {isCloud - ? ( - <div>{t($ => $['apiKeyInfo.cloud.trial.title'], { ns: 'appOverview', providerName: 'OpenAI' })}</div> - ) - : ( - <div> - <div>{t($ => $['apiKeyInfo.selfHost.title.row1'], { ns: 'appOverview' })}</div> - <div>{t($ => $['apiKeyInfo.selfHost.title.row2'], { ns: 'appOverview' })}</div> - </div> - )} + {isCloud ? ( + <div> + {t(($) => $['apiKeyInfo.cloud.trial.title'], { + ns: 'appOverview', + providerName: 'OpenAI', + })} + </div> + ) : ( + <div> + <div>{t(($) => $['apiKeyInfo.selfHost.title.row1'], { ns: 'appOverview' })}</div> + <div>{t(($) => $['apiKeyInfo.selfHost.title.row2'], { ns: 'appOverview' })}</div> + </div> + )} </div> {isCloud && ( - <div className="mt-1 text-sm font-normal text-text-tertiary">{t($ => $[`apiKeyInfo.cloud.${'trial'}.description`], { ns: 'appOverview' })}</div> + <div className="mt-1 text-sm font-normal text-text-tertiary"> + {t(($) => $[`apiKeyInfo.cloud.${'trial'}.description`], { ns: 'appOverview' })} + </div> )} <Button variant="primary" className="mt-2 space-x-2" onClick={() => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })} > - <div className="text-sm font-medium">{t($ => $['apiKeyInfo.setAPIBtn'], { ns: 'appOverview' })}</div> + <div className="text-sm font-medium"> + {t(($) => $['apiKeyInfo.setAPIBtn'], { ns: 'appOverview' })} + </div> <LinkExternal02 className="size-4" /> </Button> {!isCloud && ( @@ -61,7 +76,7 @@ const APIKeyInfoPanel: FC = () => { target="_blank" rel="noopener noreferrer" > - <div>{t($ => $['apiKeyInfo.tryCloud'], { ns: 'appOverview' })}</div> + <div>{t(($) => $['apiKeyInfo.tryCloud'], { ns: 'appOverview' })}</div> <LinkExternal02 className="size-3" /> </a> )} diff --git a/web/app/components/app/overview/app-card-sections.tsx b/web/app/components/app/overview/app-card-sections.tsx index 8ee2e1168fbca3..e66832af8c6219 100644 --- a/web/app/components/app/overview/app-card-sections.tsx +++ b/web/app/components/app/overview/app-card-sections.tsx @@ -20,18 +20,21 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, -} from '@langgenius/dify-ui/dialog' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' -import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiPaletteLine, RiSettings2Line, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react' + RiArrowRightSLine, + RiBookOpenLine, + RiBuildingLine, + RiExternalLinkLine, + RiGlobalLine, + RiLockLine, + RiPaintBrushLine, + RiPaletteLine, + RiSettings2Line, + RiVerifiedBadgeLine, + RiWindowLine, +} from '@remixicon/react' import { Trans } from 'react-i18next' import CopyFeedback from '@/app/components/base/copy-feedback' import Divider from '@/app/components/base/divider' @@ -79,10 +82,10 @@ const ACCESS_MODE_ICON_MAP: Record<AccessMode, OperationIcon> = { } const ACCESS_MODE_LABEL_MAP: Record<AccessMode, SelectorParam<'app'>> = { - [AccessMode.ORGANIZATION]: $ => $['accessControlDialog.accessItems.organization'], - [AccessMode.SPECIFIC_GROUPS_MEMBERS]: $ => $['accessControlDialog.accessItems.specific'], - [AccessMode.PUBLIC]: $ => $['accessControlDialog.accessItems.anyone'], - [AccessMode.EXTERNAL_MEMBERS]: $ => $['accessControlDialog.accessItems.external'], + [AccessMode.ORGANIZATION]: ($) => $['accessControlDialog.accessItems.organization'], + [AccessMode.SPECIFIC_GROUPS_MEMBERS]: ($) => $['accessControlDialog.accessItems.specific'], + [AccessMode.PUBLIC]: ($) => $['accessControlDialog.accessItems.anyone'], + [AccessMode.EXTERNAL_MEMBERS]: ($) => $['accessControlDialog.accessItems.external'], } const MaybeTooltip = ({ @@ -96,15 +99,12 @@ const MaybeTooltip = ({ tooltipClassName?: string show?: boolean }) => { - if (!show || !content) - return <>{children}</> + if (!show || !content) return <>{children}</> return ( <Tooltip> <TooltipTrigger render={<div>{children}</div>} /> - <TooltipContent className={tooltipClassName}> - {content} - </TooltipContent> + <TooltipContent className={tooltipClassName}>{content}</TooltipContent> </Tooltip> ) } @@ -128,19 +128,20 @@ export const WorkflowLaunchDialog = ({ onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void onSubmit: (event: FormEvent<HTMLFormElement>) => void }) => { - if (!hiddenVariables.length && !unsupportedVariables.length) - return null + if (!hiddenVariables.length && !unsupportedVariables.length) return null return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="w-[560px]! max-w-[calc(100vw-2rem)]! p-0!"> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['overview.appInfo.workflowLaunchHiddenInputs.title'], { ns: 'appOverview' })} + {t(($) => $['overview.appInfo.workflowLaunchHiddenInputs.title'], { + ns: 'appOverview', + })} </DialogTitle> <DialogDescription className="system-md-regular text-text-tertiary"> <Trans - i18nKey={$ => $['overview.appInfo.workflowLaunchHiddenInputs.description']} + i18nKey={($) => $['overview.appInfo.workflowLaunchHiddenInputs.description']} ns="appOverview" components={{ bold: <span className="system-md-medium" /> }} /> @@ -156,10 +157,10 @@ export const WorkflowLaunchDialog = ({ </div> <div className="flex items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4"> <Button onClick={() => onOpenChange(false)}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button type="submit" variant="primary"> - {t($ => $['overview.appInfo.launch'], { ns: 'appOverview' })} + {t(($) => $['overview.appInfo.launch'], { ns: 'appOverview' })} </Button> </div> </form> @@ -190,11 +191,11 @@ export const createAppCardOperations = ({ onDevelop: () => void }): AppCardOperation[] => { const labelMap: Record<OverviewOperationKey, string> = { - launch: t($ => $['overview.appInfo.launch'], { ns: 'appOverview' }), - embedded: t($ => $['overview.appInfo.embedded.entry'], { ns: 'appOverview' }), - customize: t($ => $['overview.appInfo.customize.entry'], { ns: 'appOverview' }), - settings: t($ => $['overview.appInfo.settings.entry'], { ns: 'appOverview' }), - develop: t($ => $['overview.apiInfo.doc'], { ns: 'appOverview' }), + launch: t(($) => $['overview.appInfo.launch'], { ns: 'appOverview' }), + embedded: t(($) => $['overview.appInfo.embedded.entry'], { ns: 'appOverview' }), + customize: t(($) => $['overview.appInfo.customize.entry'], { ns: 'appOverview' }), + settings: t(($) => $['overview.appInfo.settings.entry'], { ns: 'appOverview' }), + develop: t(($) => $['overview.apiInfo.doc'], { ns: 'appOverview' }), } const onClickMap: Record<OverviewOperationKey, () => void> = { launch: onLaunch, @@ -205,7 +206,7 @@ export const createAppCardOperations = ({ } return operationKeys.map((key) => { - const disabled = triggerModeDisabled ? true : (key === 'settings' ? false : !runningStatus) + const disabled = triggerModeDisabled ? true : key === 'settings' ? false : !runningStatus return { key, label: labelMap[key], @@ -240,45 +241,50 @@ export const AppCardUrlSection = ({ <div className="flex flex-col items-start justify-center self-stretch"> <div className="pb-1 system-xs-medium text-text-tertiary"> {isApp - ? t($ => $['overview.appInfo.accessibleAddress'], { ns: 'appOverview' }) - : t($ => $['overview.apiInfo.accessibleAddress'], { ns: 'appOverview' })} + ? t(($) => $['overview.appInfo.accessibleAddress'], { ns: 'appOverview' }) + : t(($) => $['overview.apiInfo.accessibleAddress'], { ns: 'appOverview' })} </div> <div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2"> <div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1"> - <div className="truncate text-xs font-medium text-text-secondary"> - {accessibleUrl} - </div> + <div className="truncate text-xs font-medium text-text-secondary">{accessibleUrl}</div> </div> <CopyFeedback content={accessibleUrl} className="size-6!" /> {isApp && <ShareQRCode content={accessibleUrl} />} {isApp && <Divider type="vertical" className="mx-0.5! h-3.5! shrink-0" />} - <AlertDialog open={showConfirmDelete} onOpenChange={open => !open && onHideRegenerateConfirm()}> + <AlertDialog + open={showConfirmDelete} + onOpenChange={(open) => !open && onHideRegenerateConfirm()} + > <AlertDialogContent> <div className="flex flex-col items-start gap-2 self-stretch px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary"> - {t($ => $['overview.appInfo.regenerate'], { ns: 'appOverview' })} + {t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['overview.appInfo.regenerateNotice'], { ns: 'appOverview' })} + {t(($) => $['overview.appInfo.regenerateNotice'], { ns: 'appOverview' })} </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton onClick={onHideRegenerateConfirm}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={onRegenerate}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> </AlertDialog> {isApp && canRegenerateUrl && ( - <MaybeTooltip content={t($ => $['overview.appInfo.regenerate'], { ns: 'appOverview' }) || ''}> + <MaybeTooltip + content={t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' }) || ''} + > <div className="size-6 cursor-pointer rounded-md hover:bg-state-base-hover" onClick={onShowRegenerateConfirm} > - <div className={`size-full ${style.refreshIcon} ${genLoading ? style.generateLogo : ''}`} /> + <div + className={`size-full ${style.refreshIcon} ${genLoading ? style.generateLogo : ''}`} + /> </div> </MaybeTooltip> )} @@ -302,7 +308,9 @@ export const AppCardAccessControlSection = ({ return ( <div className="flex flex-col items-start justify-center self-stretch"> - <div className="pb-1 system-xs-medium text-text-tertiary">{t($ => $['publishApp.title'], { ns: 'app' })}</div> + <div className="pb-1 system-xs-medium text-text-tertiary"> + {t(($) => $['publishApp.title'], { ns: 'app' })} + </div> <div className="flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pr-2 pl-2.5" onClick={onClick} @@ -311,7 +319,11 @@ export const AppCardAccessControlSection = ({ <Icon className="size-4 shrink-0 text-text-secondary" /> <p className="system-sm-medium text-text-secondary">{t(labelSelector, { ns: 'app' })}</p> </div> - {!isAppAccessSet && <p className="shrink-0 system-xs-regular text-text-tertiary">{t($ => $['publishApp.notSet'], { ns: 'app' })}</p>} + {!isAppAccessSet && ( + <p className="shrink-0 system-xs-regular text-text-tertiary"> + {t(($) => $['publishApp.notSet'], { ns: 'app' })} + </p> + )} <div className="flex size-4 shrink-0 items-center justify-center"> <RiArrowRightSLine className="size-4 text-text-quaternary" /> </div> @@ -333,7 +345,12 @@ export const AppCardOperations = ({ {operations.map(({ key, label, Icon, disabled, onClick }) => { const shouldTruncate = key === 'customize' || key === 'settings' const buttonContent = ( - <div className={cn('flex items-center justify-center gap-px', shouldTruncate && 'max-w-full min-w-0')}> + <div + className={cn( + 'flex items-center justify-center gap-px', + shouldTruncate && 'max-w-full min-w-0', + )} + > <Icon className="size-3.5 shrink-0" /> <div className={cn( @@ -351,7 +368,7 @@ export const AppCardOperations = ({ return ( <div key={key} className="mr-1 inline-flex shrink-0"> <MaybeTooltip - content={t($ => $['overview.appInfo.preUseReminder'], { ns: 'appOverview' }) ?? ''} + content={t(($) => $['overview.appInfo.preUseReminder'], { ns: 'appOverview' }) ?? ''} tooltipClassName="mt-[-8px]" show={disabled} > @@ -370,10 +387,7 @@ export const AppCardOperations = ({ </div> </Button> </MaybeTooltip> - <div - aria-hidden="true" - className="h-6 w-px shrink-0 bg-divider-regular opacity-100" - /> + <div aria-hidden="true" className="h-6 w-px shrink-0 bg-divider-regular opacity-100" /> <Button aria-label={launchConfigAction.label} className="w-8 rounded-l-none border-0 p-0 shadow-none backdrop-blur-none hover:bg-components-button-secondary-bg-hover" @@ -413,7 +427,7 @@ export const AppCardOperations = ({ return ( <MaybeTooltip key={key} - content={t($ => $['overview.appInfo.preUseReminder'], { ns: 'appOverview' }) ?? ''} + content={t(($) => $['overview.appInfo.preUseReminder'], { ns: 'appOverview' }) ?? ''} tooltipClassName="mt-[-8px]" > {actionButton} @@ -459,8 +473,7 @@ export const AppCardDialogs = ({ onConfirmAccessControl: () => Promise<void> hiddenInputs?: WorkflowHiddenStartVariable[] }) => { - if (!isApp) - return null + if (!isApp) return null return ( <> diff --git a/web/app/components/app/overview/app-card-utils.ts b/web/app/components/app/overview/app-card-utils.ts index acca7e162a082d..fd8603d5bbb09e 100644 --- a/web/app/components/app/overview/app-card-utils.ts +++ b/web/app/components/app/overview/app-card-utils.ts @@ -19,21 +19,27 @@ export type WorkflowHiddenStartVariable = Pick< type AppInfo = AppDetailResponse & Partial<AppSSO> -type WorkflowLike = { - graph?: { - nodes?: Array<{ - data?: { - type?: string - variables?: InputVar[] +type WorkflowLike = + | { + graph?: { + nodes?: Array<{ + data?: { + type?: string + variables?: InputVar[] + } + }> } - }> - } -} | null | undefined - -type AccessSubjectsLike = { - groups?: unknown[] - members?: unknown[] -} | null | undefined + } + | null + | undefined + +type AccessSubjectsLike = + | { + groups?: unknown[] + members?: unknown[] + } + | null + | undefined type AppCardDisplayState = { isApp: boolean @@ -48,7 +54,7 @@ type AppCardDisplayState = { } const getCardAppMode = (mode: AppModeEnum) => { - return (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : mode + return mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW ? AppModeEnum.CHAT : mode } const SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES = new Set<InputVarType>([ @@ -62,27 +68,31 @@ const SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES = new Set<InputVarType>([ InputVarType.url, ]) -const coerceWorkflowLaunchDefaultValue = (variable: WorkflowHiddenStartVariable): WorkflowLaunchInputValue => { +const coerceWorkflowLaunchDefaultValue = ( + variable: WorkflowHiddenStartVariable, +): WorkflowLaunchInputValue => { if (variable.type === InputVarType.checkbox) { - if (typeof variable.default === 'boolean') - return variable.default + if (typeof variable.default === 'boolean') return variable.default return String(variable.default).toLowerCase() === 'true' } - if (typeof variable.default === 'number') - return String(variable.default) + if (typeof variable.default === 'number') return String(variable.default) return String(variable.default ?? '') } export const hasWorkflowStartNode = (currentWorkflow: WorkflowLike) => { - return currentWorkflow?.graph?.nodes?.some(node => node.data?.type === BlockEnum.Start) ?? false + return currentWorkflow?.graph?.nodes?.some((node) => node.data?.type === BlockEnum.Start) ?? false } -export const getWorkflowHiddenStartVariables = (currentWorkflow: WorkflowLike): WorkflowHiddenStartVariable[] => { - const startNode = currentWorkflow?.graph?.nodes?.find(node => node.data?.type === BlockEnum.Start) - return (startNode?.data?.variables ?? []).filter(variable => variable.hide === true) +export const getWorkflowHiddenStartVariables = ( + currentWorkflow: WorkflowLike, +): WorkflowHiddenStartVariable[] => { + const startNode = currentWorkflow?.graph?.nodes?.find( + (node) => node.data?.type === BlockEnum.Start, + ) + return (startNode?.data?.variables ?? []).filter((variable) => variable.hide === true) } export const getAppHiddenLaunchVariables = ({ @@ -119,9 +129,8 @@ export const buildWorkflowLaunchUrl = async ({ const targetUrl = new URL(accessibleUrl, window.location.origin) variables.forEach((variable) => { const rawValue = values[variable.variable] - const serializedValue = variable.type === InputVarType.checkbox - ? String(Boolean(rawValue)) - : String(rawValue ?? '') + const serializedValue = + variable.type === InputVarType.checkbox ? String(Boolean(rawValue)) : String(rawValue ?? '') targetUrl.searchParams.set(variable.variable, serializedValue) }) @@ -171,16 +180,22 @@ export const getEmbeddedScriptSnippet = ({ }) => `<script> window.difyChatbotConfig = { - token: '${token}'${isTestEnv - ? `, + token: '${token}'${ + isTestEnv + ? `, isDev: true` - : ''}${IS_CE_EDITION - ? `, + : '' + }${ + IS_CE_EDITION + ? `, baseUrl: '${url}${basePath}'` - : ''}${webAppRoute !== 'chatbot' - ? `, + : '' + }${ + webAppRoute !== 'chatbot' + ? `, routeSegment: '${webAppRoute}'` - : ''}, + : '' + }, inputs: ${getScriptInputsContent(inputValues)}, systemVariables: { // user_id: 'YOU CAN DEFINE USER ID HERE', @@ -211,13 +226,10 @@ export const getChromePluginContent = (iframeUrl: string) => `ChatBot URL: ${ifr export const compressAndEncodeBase64 = async (input: string) => { const uint8Array = new TextEncoder().encode(input) - if (typeof CompressionStream === 'undefined') - return btoa(String.fromCharCode(...uint8Array)) + if (typeof CompressionStream === 'undefined') return btoa(String.fromCharCode(...uint8Array)) const compressedStream = new Response( - new Blob([uint8Array]) - .stream() - .pipeThrough(new CompressionStream('gzip')), + new Blob([uint8Array]).stream().pipeThrough(new CompressionStream('gzip')), ).arrayBuffer() const compressedUint8Array = new Uint8Array(await compressedStream) return btoa(String.fromCharCode(...compressedUint8Array)) @@ -243,8 +255,10 @@ export const getAppCardDisplayState = ({ const appUnpublished = isWorkflowApp && !currentWorkflow?.graph const missingStartNode = isWorkflowApp && !hasWorkflowStartNode(currentWorkflow) const hasInsufficientPermissions = isApp ? !canManageWebApp : !canManageApi - const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled - const runningStatus = (appUnpublished || missingStartNode) ? false : (isApp ? appInfo.enable_site : appInfo.enable_api) + const toggleDisabled = + hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled + const runningStatus = + appUnpublished || missingStartNode ? false : isApp ? appInfo.enable_site : appInfo.enable_api const appMode = getCardAppMode(appInfo.mode) const appBaseUrl = appInfo.site?.app_base_url ?? '' const accessToken = appInfo.site?.access_token ?? '' @@ -258,16 +272,19 @@ export const getAppCardDisplayState = ({ toggleDisabled, runningStatus, isMinimalState: appUnpublished || missingStartNode, - accessibleUrl: isApp ? `${appBaseUrl}${basePath}/${appMode}/${accessToken}` : (appInfo.api_base_url ?? ''), + accessibleUrl: isApp + ? `${appBaseUrl}${basePath}/${appMode}/${accessToken}` + : (appInfo.api_base_url ?? ''), } } -export const isAppAccessConfigured = (appDetail: AppDetailResponse | null | undefined, appAccessSubjects: AccessSubjectsLike) => { - if (!appDetail || !appAccessSubjects) - return true +export const isAppAccessConfigured = ( + appDetail: AppDetailResponse | null | undefined, + appAccessSubjects: AccessSubjectsLike, +) => { + if (!appDetail || !appAccessSubjects) return true - if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS) - return true + if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS) return true return Boolean(appAccessSubjects.groups?.length || appAccessSubjects.members?.length) } @@ -281,16 +298,14 @@ export const getAppCardOperationKeys = ({ appMode: AppModeEnum canManageSettings: boolean }): OverviewOperationKey[] => { - if (cardType === 'api') - return ['develop'] + if (cardType === 'api') return ['develop'] const operationKeys: OverviewOperationKey[] = ['launch'] if (appMode !== AppModeEnum.COMPLETION && appMode !== AppModeEnum.WORKFLOW) operationKeys.push('embedded') operationKeys.push('customize') - if (canManageSettings) - operationKeys.push('settings') + if (canManageSettings) operationKeys.push('settings') return operationKeys } diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index a347c0866e8303..b96a15ae4178c2 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -75,18 +75,23 @@ function AppCard({ const queryClient = useQueryClient() const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const appACLCapabilities = useMemo(() => getAppACLCapabilities(appInfo.permission_keys, { - currentUserId, - resourceMaintainer: appInfo.maintainer, - workspacePermissionKeys, - }), [appInfo.maintainer, appInfo.permission_keys, currentUserId, workspacePermissionKeys]) + const appACLCapabilities = useMemo( + () => + getAppACLCapabilities(appInfo.permission_keys, { + currentUserId, + resourceMaintainer: appInfo.maintainer, + workspacePermissionKeys, + }), + [appInfo.maintainer, appInfo.permission_keys, currentUserId, workspacePermissionKeys], + ) const canEditApp = appACLCapabilities.canEdit const canManageWebAppAccessControl = appACLCapabilities.canReleaseAndVersion - const shouldFetchWorkflow = appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT + const shouldFetchWorkflow = + appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '') const docLink = useDocLink() - const appDetail = useAppStore(state => state.appDetail) - const setAppDetail = useAppStore(state => state.setAppDetail) + const appDetail = useAppStore((state) => state.appDetail) + const setAppDetail = useAppStore((state) => state.setAppDetail) const [showSettingsModal, setShowSettingsModal] = useState(false) const [showEmbedded, setShowEmbedded] = useState(false) const [showCustomizeModal, setShowCustomizeModal] = useState(false) @@ -94,12 +99,16 @@ function AppCard({ const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showAccessControl, setShowAccessControl] = useState(false) const [showWorkflowLaunchDialog, setShowWorkflowLaunchDialog] = useState(false) - const [workflowLaunchValues, setWorkflowLaunchValues] = useState<Record<string, WorkflowLaunchInputValue>>({}) + const [workflowLaunchValues, setWorkflowLaunchValues] = useState< + Record<string, WorkflowLaunchInputValue> + >({}) const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: appAccessSubjects } = useAppWhiteListSubjects( appDetail?.id, - systemFeatures.webapp_auth.enabled && canManageWebAppAccessControl && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS, + systemFeatures.webapp_auth.enabled && + canManageWebAppAccessControl && + appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS, ) const cardState = getAppCardDisplayState({ @@ -113,18 +122,19 @@ function AppCard({ const isApp = cardState.isApp const basicName = isApp - ? t($ => $['overview.appInfo.title'], { ns: 'appOverview' }) - : t($ => $['overview.apiInfo.title'], { ns: 'appOverview' }) + ? t(($) => $['overview.appInfo.title'], { ns: 'appOverview' }) + : t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' }) const isAppAccessSet = useMemo( () => isAppAccessConfigured(appDetail, appAccessSubjects), [appAccessSubjects, appDetail], ) const hiddenLaunchVariables = useMemo( - () => getAppHiddenLaunchVariables({ - appInfo, - currentWorkflow, - }) || [], + () => + getAppHiddenLaunchVariables({ + appInfo, + currentWorkflow, + }) || [], [appInfo, currentWorkflow], ) const supportedWorkflowLaunchVariables = useMemo( @@ -132,7 +142,7 @@ function AppCard({ [hiddenLaunchVariables], ) const unsupportedWorkflowLaunchVariables = useMemo( - () => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)), + () => hiddenLaunchVariables.filter((variable) => !isWorkflowLaunchInputSupported(variable)), [hiddenLaunchVariables], ) const initialWorkflowLaunchValues = useMemo( @@ -141,8 +151,7 @@ function AppCard({ ) const onGenCode = async () => { - if (!onGenerateCode) - return + if (!onGenerateCode) return setGenLoading(true) await asyncRunSafe(onGenerateCode()) @@ -150,32 +159,33 @@ function AppCard({ } const handleClickAccessControl = useCallback(() => { - if (!appDetail || !canManageWebAppAccessControl) - return + if (!appDetail || !canManageWebAppAccessControl) return setShowAccessControl(true) }, [appDetail, canManageWebAppAccessControl]) const handleAccessControlUpdate = useCallback(async () => { - if (!appDetail) - return + if (!appDetail) return try { const res = await fetchAppDetail({ url: '/apps', id: appDetail.id }) queryClient.setQueryData([...appDetailQueryKeyPrefix, appDetail.id], res) setAppDetail({ ...res }) setShowAccessControl(false) - } - catch (error) { + } catch (error) { console.error('Failed to fetch app detail:', error) } }, [appDetail, queryClient, setAppDetail]) - const operationKeys = useMemo(() => getAppCardOperationKeys({ - cardType, - appMode: cardState.appMode, - canManageSettings: canEditApp, - }), [canEditApp, cardState.appMode, cardType]) + const operationKeys = useMemo( + () => + getAppCardOperationKeys({ + cardType, + appMode: cardState.appMode, + canManageSettings: canEditApp, + }), + [canEditApp, cardState.appMode, cardType], + ) const handleLaunch = useCallback(() => { window.open(cardState.accessibleUrl, '_blank') @@ -186,25 +196,31 @@ function AppCard({ setShowWorkflowLaunchDialog(true) }, [initialWorkflowLaunchValues]) - const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => { - setWorkflowLaunchValues(prev => ({ - ...prev, - [variable]: value, - })) - }, []) + const handleWorkflowLaunchValueChange = useCallback( + (variable: string, value: WorkflowLaunchInputValue) => { + setWorkflowLaunchValues((prev) => ({ + ...prev, + [variable]: value, + })) + }, + [], + ) - const handleWorkflowLaunchConfirm = useCallback(async (event: React.FormEvent<HTMLFormElement>) => { - event.preventDefault() + const handleWorkflowLaunchConfirm = useCallback( + async (event: React.FormEvent<HTMLFormElement>) => { + event.preventDefault() - const targetUrl = await buildWorkflowLaunchUrl({ - accessibleUrl: cardState.accessibleUrl, - variables: supportedWorkflowLaunchVariables, - values: workflowLaunchValues, - }) + const targetUrl = await buildWorkflowLaunchUrl({ + accessibleUrl: cardState.accessibleUrl, + variables: supportedWorkflowLaunchVariables, + values: workflowLaunchValues, + }) - window.open(targetUrl, '_blank') - setShowWorkflowLaunchDialog(false) - }, [cardState.accessibleUrl, supportedWorkflowLaunchVariables, workflowLaunchValues]) + window.open(targetUrl, '_blank') + setShowWorkflowLaunchDialog(false) + }, + [cardState.accessibleUrl, supportedWorkflowLaunchVariables, workflowLaunchValues], + ) const handleOpenCustomize = useCallback(() => { setShowCustomizeModal(true) @@ -224,78 +240,92 @@ function AppCard({ router.push(`${pathSegments.join('/')}/develop`) }, [pathname, router]) - const operations = useMemo(() => createAppCardOperations({ - operationKeys, - t, - runningStatus: cardState.runningStatus, - triggerModeDisabled, - onLaunch: handleLaunch, - onEmbedded: handleOpenEmbedded, - onCustomize: handleOpenCustomize, - onSettings: handleOpenSettings, - onDevelop: handleOpenDevelop, - }), [ - cardState.runningStatus, - handleLaunch, - handleOpenCustomize, - handleOpenDevelop, - handleOpenEmbedded, - handleOpenSettings, - operationKeys, - t, - triggerModeDisabled, - ]) + const operations = useMemo( + () => + createAppCardOperations({ + operationKeys, + t, + runningStatus: cardState.runningStatus, + triggerModeDisabled, + onLaunch: handleLaunch, + onEmbedded: handleOpenEmbedded, + onCustomize: handleOpenCustomize, + onSettings: handleOpenSettings, + onDevelop: handleOpenDevelop, + }), + [ + cardState.runningStatus, + handleLaunch, + handleOpenCustomize, + handleOpenDevelop, + handleOpenEmbedded, + handleOpenSettings, + operationKeys, + t, + triggerModeDisabled, + ], + ) - const missingStartNodeContent = cardState.appUnpublished || cardState.missingStartNode - ? ( - <> - <div className="mb-1 text-xs font-normal text-text-secondary"> - {t($ => $['overview.appInfo.enableTooltip.description'], { ns: 'appOverview' })} - </div> - <button - type="button" - className="cursor-pointer rounded-sm text-xs font-normal text-text-accent outline-hidden hover:underline focus-visible:ring-1 focus-visible:ring-components-input-border-hover" - onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')} - > - {t($ => $['overview.appInfo.enableTooltip.learnMore'], { ns: 'appOverview' })} - </button> - </> - ) - : '' + const missingStartNodeContent = + cardState.appUnpublished || cardState.missingStartNode ? ( + <> + <div className="mb-1 text-xs font-normal text-text-secondary"> + {t(($) => $['overview.appInfo.enableTooltip.description'], { ns: 'appOverview' })} + </div> + <button + type="button" + className="cursor-pointer rounded-sm text-xs font-normal text-text-accent outline-hidden hover:underline focus-visible:ring-1 focus-visible:ring-components-input-border-hover" + onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')} + > + {t(($) => $['overview.appInfo.enableTooltip.learnMore'], { ns: 'appOverview' })} + </button> + </> + ) : ( + '' + ) const statusPopoverContent = cardState.toggleDisabled - ? ( - triggerModeDisabled && triggerModeMessage - ? triggerModeMessage - : missingStartNodeContent - ) + ? triggerModeDisabled && triggerModeMessage + ? triggerModeMessage + : missingStartNodeContent : '' return ( <div className={`${isInPanel ? 'border-t border-l-[0.5px]' : 'border-[0.5px] shadow-xs'} w-full max-w-full rounded-xl border-effects-highlight ${className ?? ''} ${cardState.isMinimalState ? 'h-12' : ''}`} > - <div className={`${customBgColor ?? 'bg-background-default'} relative rounded-xl ${triggerModeDisabled ? 'opacity-60' : ''}`}> - {triggerModeDisabled && ( - triggerModeMessage - ? ( - <Popover> - <PopoverTrigger - openOnHover - aria-label={typeof triggerModeMessage === 'string' ? triggerModeMessage : basicName} - render={<button type="button" className="absolute inset-0 z-10 cursor-not-allowed rounded-xl outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-hover" />} + <div + className={`${customBgColor ?? 'bg-background-default'} relative rounded-xl ${triggerModeDisabled ? 'opacity-60' : ''}`} + > + {triggerModeDisabled && + (triggerModeMessage ? ( + <Popover> + <PopoverTrigger + openOnHover + aria-label={typeof triggerModeMessage === 'string' ? triggerModeMessage : basicName} + render={ + <button + type="button" + className="absolute inset-0 z-10 cursor-not-allowed rounded-xl outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-hover" /> - <PopoverContent - placement="right" - popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg" - > - {triggerModeMessage} - </PopoverContent> - </Popover> - ) - : <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true" /> - )} - <div className={`flex w-full flex-col items-start justify-center gap-3 self-stretch p-3 ${cardState.isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle'}`}> + } + /> + <PopoverContent + placement="right" + popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg" + > + {triggerModeMessage} + </PopoverContent> + </Popover> + ) : ( + <div + className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" + aria-hidden="true" + /> + ))} + <div + className={`flex w-full flex-col items-start justify-center gap-3 self-stretch p-3 ${cardState.isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle'}`} + > <div className="flex w-full items-center gap-3 self-stretch"> <AppBasic iconType={cardType} @@ -305,43 +335,57 @@ function AppCard({ hideType type={ isApp - ? t($ => $['overview.appInfo.explanation'], { ns: 'appOverview' }) - : t($ => $['overview.apiInfo.explanation'], { ns: 'appOverview' }) + ? t(($) => $['overview.appInfo.explanation'], { ns: 'appOverview' }) + : t(($) => $['overview.apiInfo.explanation'], { ns: 'appOverview' }) } /> <div className="flex shrink-0 items-center gap-1"> <StatusDot status={cardState.runningStatus ? 'success' : 'warning'} /> - <div className={`${cardState.runningStatus ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase`}> + <div + className={`${cardState.runningStatus ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase`} + > {cardState.runningStatus - ? t($ => $['overview.status.running'], { ns: 'appOverview' }) - : t($ => $['overview.status.disable'], { ns: 'appOverview' })} + ? t(($) => $['overview.status.running'], { ns: 'appOverview' }) + : t(($) => $['overview.status.disable'], { ns: 'appOverview' })} </div> </div> - {cardState.toggleDisabled && statusPopoverContent - ? ( - <Popover> - <PopoverTrigger - openOnHover - nativeButton={false} - aria-label={typeof statusPopoverContent === 'string' ? statusPopoverContent : t($ => $['overview.appInfo.enableTooltip.description'], { ns: 'appOverview' })} - render={( - <div> - <Switch checked={cardState.runningStatus} onCheckedChange={onChangeStatus} disabled={cardState.toggleDisabled} /> - </div> - )} - /> - <PopoverContent - placement="right" - sideOffset={24} - popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg" - > - {statusPopoverContent} - </PopoverContent> - </Popover> - ) - : ( - <Switch checked={cardState.runningStatus} onCheckedChange={onChangeStatus} disabled={cardState.toggleDisabled} /> - )} + {cardState.toggleDisabled && statusPopoverContent ? ( + <Popover> + <PopoverTrigger + openOnHover + nativeButton={false} + aria-label={ + typeof statusPopoverContent === 'string' + ? statusPopoverContent + : t(($) => $['overview.appInfo.enableTooltip.description'], { + ns: 'appOverview', + }) + } + render={ + <div> + <Switch + checked={cardState.runningStatus} + onCheckedChange={onChangeStatus} + disabled={cardState.toggleDisabled} + /> + </div> + } + /> + <PopoverContent + placement="right" + sideOffset={24} + popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg" + > + {statusPopoverContent} + </PopoverContent> + </Popover> + ) : ( + <Switch + checked={cardState.runningStatus} + onCheckedChange={onChangeStatus} + disabled={cardState.toggleDisabled} + /> + )} </div> {!cardState.isMinimalState && ( <AppCardUrlSection @@ -359,14 +403,18 @@ function AppCard({ onHideRegenerateConfirm={() => setShowConfirmDelete(false)} /> )} - {!cardState.isMinimalState && isApp && systemFeatures.webapp_auth.enabled && canManageWebAppAccessControl && appDetail && ( - <AppCardAccessControlSection - t={t} - appDetail={appDetail} - isAppAccessSet={isAppAccessSet} - onClick={handleClickAccessControl} - /> - )} + {!cardState.isMinimalState && + isApp && + systemFeatures.webapp_auth.enabled && + canManageWebAppAccessControl && + appDetail && ( + <AppCardAccessControlSection + t={t} + appDetail={appDetail} + isAppAccessSet={isAppAccessSet} + onClick={handleClickAccessControl} + /> + )} </div> {!cardState.isMinimalState && ( <div className="flex items-center gap-1 self-stretch p-3"> @@ -374,13 +422,15 @@ function AppCard({ <AppCardOperations t={t} operations={operations} - launchConfigAction={hiddenLaunchVariables.length > 0 - ? { - label: t($ => $['operation.config'], { ns: 'common' }), - disabled: triggerModeDisabled || !cardState.runningStatus, - onClick: handleOpenWorkflowLaunchDialog, - } - : undefined} + launchConfigAction={ + hiddenLaunchVariables.length > 0 + ? { + label: t(($) => $['operation.config'], { ns: 'common' }), + disabled: triggerModeDisabled || !cardState.runningStatus, + onClick: handleOpenWorkflowLaunchDialog, + } + : undefined + } /> </div> )} diff --git a/web/app/components/app/overview/app-chart-utils.ts b/web/app/components/app/overview/app-chart-utils.ts index f69065eeaecbbb..31024123b6524a 100644 --- a/web/app/components/app/overview/app-chart-utils.ts +++ b/web/app/components/app/overview/app-chart-utils.ts @@ -25,7 +25,7 @@ type TooltipParams = { const valueFormatter = (value: string | number) => value -const COLOR_TYPE_MAP: Record<ColorType, { lineColor: string, bgColor: [string, string] }> = { +const COLOR_TYPE_MAP: Record<ColorType, { lineColor: string; bgColor: [string, string] }> = { green: { lineColor: 'rgba(6, 148, 162, 1)', bgColor: ['rgba(6, 148, 162, 0.2)', 'rgba(67, 174, 185, 0.08)'], @@ -77,7 +77,8 @@ const sumValues = (values: Decimal.Value[]): number => Decimal.sum(...values).to const getRowValue = (row: ChartRow, field: string): Decimal.Value => row[field] ?? 0 -const getChartColors = (chartType: ChartType) => COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType] +const getChartColors = (chartType: ChartType) => + COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType] const getMarkLineSeedData = (statisticsLength: number) => { const markLineLength = statisticsLength >= 2 ? statisticsLength - 2 : statisticsLength @@ -101,10 +102,9 @@ const getTooltipContent = (chartType: ChartType, yField: string, params: Tooltip } export const getChartValueField = (statistics: ChartRow[], valueKey?: string) => { - if (valueKey) - return valueKey + if (valueKey) return valueKey - return Object.keys(statistics[0] ?? {}).find(name => name.includes('count')) ?? 'count' + return Object.keys(statistics[0] ?? {}).find((name) => name.includes('count')) ?? 'count' } export const hasNonZeroChartData = (statistics: ChartRow[], yField: string) => { @@ -127,14 +127,12 @@ export const getSummaryValue = ({ isAvg?: boolean unit?: string }) => { - const values = statistics.map(item => getRowValue(item, yField)) + const values = statistics.map((item) => getRowValue(item, yField)) const divisor = values.length || 1 - const sumData = isAvg ? (sumValues(values) / divisor) : sumValues(values) + const sumData = isAvg ? sumValues(values) / divisor : sumValues(values) if (chartType === 'costs' || chartType === 'workflowCosts') { - const formattedCost = sumData < 1000 - ? sumData - : `${formatNumber(Math.round(sumData / 1000))}k` + const formattedCost = sumData < 1000 ? sumData : `${formatNumber(Math.round(sumData / 1000))}k` return `${formattedCost}` } @@ -143,7 +141,9 @@ export const getSummaryValue = ({ } export const getTokenSummary = (statistics: ChartRow[]) => { - const totalPrice = sumValues(statistics.map(item => Number.parseFloat(String(get(item, 'total_price', '0'))))) + const totalPrice = sumValues( + statistics.map((item) => Number.parseFloat(String(get(item, 'total_price', '0')))), + ) return totalPrice.toLocaleString('en-US', { style: 'currency', currency: 'USD', @@ -177,47 +177,50 @@ export const buildChartOptions = ({ position: 'top', borderWidth: 0, }, - xAxis: [{ - type: 'category', - boundaryGap: false, - axisLabel: { - color: COMMON_COLOR_MAP.label, - hideOverlap: true, - overflow: 'break', - formatter(value) { - return dayjs(value).format(commonDateFormat) - }, - }, - axisLine: { show: false }, - axisTick: { show: false }, - splitLine: { - show: true, - lineStyle: { - color: COMMON_COLOR_MAP.splitLineLight, - width: 1, - type: [10, 10], + xAxis: [ + { + type: 'category', + boundaryGap: false, + axisLabel: { + color: COMMON_COLOR_MAP.label, + hideOverlap: true, + overflow: 'break', + formatter(value) { + return dayjs(value).format(commonDateFormat) + }, }, - interval(index) { - return index === 0 || index === xData.length - 1 + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { + show: true, + lineStyle: { + color: COMMON_COLOR_MAP.splitLineLight, + width: 1, + type: [10, 10], + }, + interval(index) { + return index === 0 || index === xData.length - 1 + }, }, }, - }, { - position: 'bottom', - boundaryGap: false, - data: markLineSeedData, - axisLabel: { show: false }, - axisLine: { show: false }, - axisTick: { show: false }, - splitLine: { - show: true, - lineStyle: { - color: COMMON_COLOR_MAP.splitLineDark, - }, - interval(_index, value) { - return !!value + { + position: 'bottom', + boundaryGap: false, + data: markLineSeedData, + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { + show: true, + lineStyle: { + color: COMMON_COLOR_MAP.splitLineDark, + }, + interval(_index, value) { + return !!value + }, }, }, - }], + ], yAxis: { max: yMax ?? 'dataMax', type: 'value', @@ -247,13 +250,16 @@ export const buildChartOptions = ({ y: 0, x2: 0, y2: 1, - colorStops: [{ - offset: 0, - color: chartColors.bgColor[0], - }, { - offset: 1, - color: chartColors.bgColor[1], - }], + colorStops: [ + { + offset: 0, + color: chartColors.bgColor[0], + }, + { + offset: 1, + color: chartColors.bgColor[1], + }, + ], global: false, }, }, @@ -268,10 +274,20 @@ export const buildChartOptions = ({ } } -export const getDefaultChartData = ({ start, end, key = 'count' }: { start: string, end: string, key?: string }) => { +export const getDefaultChartData = ({ + start, + end, + key = 'count', +}: { + start: string + end: string + key?: string +}) => { const diffDays = dayjs(end).diff(dayjs(start), 'day') - return Array.from({ length: diffDays || 1 }, () => ({ date: '', [key]: 0 })).map((item, index) => { - item.date = dayjs(start).add(index, 'day').format(commonDateFormat) - return item - }) + return Array.from({ length: diffDays || 1 }, () => ({ date: '', [key]: 0 })).map( + (item, index) => { + item.date = dayjs(start).add(index, 'day').format(commonDateFormat) + return item + }, + ) } diff --git a/web/app/components/app/overview/app-chart.tsx b/web/app/components/app/overview/app-chart.tsx index b5c6ff55107e24..281de19ae1197f 100644 --- a/web/app/components/app/overview/app-chart.tsx +++ b/web/app/components/app/overview/app-chart.tsx @@ -59,7 +59,7 @@ type IBizChartProps = { type IChartProps = { className?: string - basicInfo: { title: string, explanation: string, timePeriod: string } + basicInfo: { title: string; explanation: string; timePeriod: string } valueKey?: string isAvg?: boolean unit?: string @@ -97,11 +97,14 @@ const Chart: React.FC<IChartProps> = ({ unit, }) const tokenSummary = getTokenSummary(statistics) - const showTokenSummary = CHART_TYPE_CONFIG[chartType].showTokens && hasNonZeroChartData(statistics, 'total_price') + const showTokenSummary = + CHART_TYPE_CONFIG[chartType].showTokens && hasNonZeroChartData(statistics, 'total_price') const isZeroSummary = summaryValue === '0' || summaryValue === '0 ms' return ( - <div className={`flex h-[316px] w-full min-w-0 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg xl:min-w-[480px] ${className ?? ''}`}> + <div + className={`flex h-[316px] w-full min-w-0 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg xl:min-w-[480px] ${className ?? ''}`} + > <div className="flex h-11 shrink-0 items-center px-6 pt-6 pb-1"> <div className="flex min-w-0 items-center"> <div className="min-w-0 truncate system-sm-semibold-uppercase text-text-secondary"> @@ -115,26 +118,26 @@ const Chart: React.FC<IChartProps> = ({ </div> </div> <div className="flex h-8 shrink-0 items-baseline gap-1 px-6 py-1"> - <div className={`shrink-0 title-3xl-semi-bold ${isZeroSummary ? 'text-text-quaternary' : 'text-text-primary'}`}> + <div + className={`shrink-0 title-3xl-semi-bold ${isZeroSummary ? 'text-text-quaternary' : 'text-text-primary'}`} + > {summaryValue} </div> {showTokenSummary && ( <div className="min-w-0 truncate system-sm-medium text-text-tertiary"> - {t($ => $['analysis.tokenUsage.consumed'], { ns: 'appOverview' })} - {' '} - Tokens - {' '} + {t(($) => $['analysis.tokenUsage.consumed'], { ns: 'appOverview' })} Tokens{' '} <span>(</span> - <span className="text-orange-400"> - ~ - {tokenSummary} - </span> + <span className="text-orange-400">~{tokenSummary}</span> <span>)</span> </div> )} </div> <div className="h-[240px] shrink-0 px-6 pb-4"> - <ReactECharts option={options} opts={ECHARTS_RENDER_OPTIONS} style={{ height: '100%', width: '100%' }} /> + <ReactECharts + option={options} + opts={ECHARTS_RENDER_OPTIONS} + style={{ height: '100%', width: '100%' }} + /> </div> </div> ) @@ -144,32 +147,37 @@ type ChartResponse = { data: ChartRow[] } -type UseChartData = (id: string, query?: PeriodParams['query']) => { +type UseChartData = ( + id: string, + query?: PeriodParams['query'], +) => { data?: ChartResponse isLoading: boolean } const CHART_TRANSLATION_SELECTOR_MAP = { - 'analysis.activeUsers.explanation': $ => $['analysis.activeUsers.explanation'], - 'analysis.activeUsers.title': $ => $['analysis.activeUsers.title'], - 'analysis.avgResponseTime.explanation': $ => $['analysis.avgResponseTime.explanation'], - 'analysis.avgResponseTime.title': $ => $['analysis.avgResponseTime.title'], - 'analysis.avgSessionInteractions.explanation': $ => $['analysis.avgSessionInteractions.explanation'], - 'analysis.avgSessionInteractions.title': $ => $['analysis.avgSessionInteractions.title'], - 'analysis.avgUserInteractions.explanation': $ => $['analysis.avgUserInteractions.explanation'], - 'analysis.avgUserInteractions.title': $ => $['analysis.avgUserInteractions.title'], - 'analysis.ms': $ => $['analysis.ms'], - 'analysis.tokenPS': $ => $['analysis.tokenPS'], - 'analysis.tokenUsage.explanation': $ => $['analysis.tokenUsage.explanation'], - 'analysis.tokenUsage.title': $ => $['analysis.tokenUsage.title'], - 'analysis.totalConversations.explanation': $ => $['analysis.totalConversations.explanation'], - 'analysis.totalConversations.title': $ => $['analysis.totalConversations.title'], - 'analysis.totalMessages.explanation': $ => $['analysis.totalMessages.explanation'], - 'analysis.totalMessages.title': $ => $['analysis.totalMessages.title'], - 'analysis.tps.explanation': $ => $['analysis.tps.explanation'], - 'analysis.tps.title': $ => $['analysis.tps.title'], - 'analysis.userSatisfactionRate.explanation': $ => $['analysis.userSatisfactionRate.explanation'], - 'analysis.userSatisfactionRate.title': $ => $['analysis.userSatisfactionRate.title'], + 'analysis.activeUsers.explanation': ($) => $['analysis.activeUsers.explanation'], + 'analysis.activeUsers.title': ($) => $['analysis.activeUsers.title'], + 'analysis.avgResponseTime.explanation': ($) => $['analysis.avgResponseTime.explanation'], + 'analysis.avgResponseTime.title': ($) => $['analysis.avgResponseTime.title'], + 'analysis.avgSessionInteractions.explanation': ($) => + $['analysis.avgSessionInteractions.explanation'], + 'analysis.avgSessionInteractions.title': ($) => $['analysis.avgSessionInteractions.title'], + 'analysis.avgUserInteractions.explanation': ($) => $['analysis.avgUserInteractions.explanation'], + 'analysis.avgUserInteractions.title': ($) => $['analysis.avgUserInteractions.title'], + 'analysis.ms': ($) => $['analysis.ms'], + 'analysis.tokenPS': ($) => $['analysis.tokenPS'], + 'analysis.tokenUsage.explanation': ($) => $['analysis.tokenUsage.explanation'], + 'analysis.tokenUsage.title': ($) => $['analysis.tokenUsage.title'], + 'analysis.totalConversations.explanation': ($) => $['analysis.totalConversations.explanation'], + 'analysis.totalConversations.title': ($) => $['analysis.totalConversations.title'], + 'analysis.totalMessages.explanation': ($) => $['analysis.totalMessages.explanation'], + 'analysis.totalMessages.title': ($) => $['analysis.totalMessages.title'], + 'analysis.tps.explanation': ($) => $['analysis.tps.explanation'], + 'analysis.tps.title': ($) => $['analysis.tps.title'], + 'analysis.userSatisfactionRate.explanation': ($) => + $['analysis.userSatisfactionRate.explanation'], + 'analysis.userSatisfactionRate.title': ($) => $['analysis.userSatisfactionRate.title'], } satisfies Record<string, SelectorParam<'appOverview'>> type ChartTranslationKey = keyof typeof CHART_TRANSLATION_SELECTOR_MAP @@ -203,13 +211,13 @@ const createBizChartComponent = ({ const { t } = useTranslation() const { data: response, isLoading } = useChartData(id, period.query) - if (isLoading || !response) - return <Loading /> + if (isLoading || !response) return <Loading /> const noDataFlag = !response.data || response.data.length === 0 const fallbackKey = emptyValueKey ?? valueKey const titleSelector: SelectorParam<'appOverview'> = CHART_TRANSLATION_SELECTOR_MAP[titleKey] - const explanationSelector: SelectorParam<'appOverview'> = CHART_TRANSLATION_SELECTOR_MAP[explanationKey] + const explanationSelector: SelectorParam<'appOverview'> = + CHART_TRANSLATION_SELECTOR_MAP[explanationKey] const unitSelector: SelectorParam<'appOverview'> | undefined = unitKey ? CHART_TRANSLATION_SELECTOR_MAP[unitKey] : undefined @@ -231,7 +239,11 @@ const createBizChartComponent = ({ chartType={chartType} valueKey={valueKey} isAvg={isAvg} - unit={unitKey && unitSelector ? t(unitSelector, { ns: 'appOverview', defaultValue: unitKey }) : undefined} + unit={ + unitKey && unitSelector + ? t(unitSelector, { ns: 'appOverview', defaultValue: unitKey }) + : undefined + } className={className} {...(noDataFlag && { yMax: yMaxWhenEmpty })} /> diff --git a/web/app/components/app/overview/customize/__tests__/index.spec.tsx b/web/app/components/app/overview/customize/__tests__/index.spec.tsx index 94eb30a1c5085f..1006a14c0cbb18 100644 --- a/web/app/components/app/overview/customize/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/customize/__tests__/index.spec.tsx @@ -51,7 +51,9 @@ describe('CustomizeModal', () => { // Assert await waitFor(() => { - expect(screen.queryByText('appOverview.overview.appInfo.customize.title')).not.toBeInTheDocument() + expect( + screen.queryByText('appOverview.overview.appInfo.customize.title'), + ).not.toBeInTheDocument() }) }) @@ -64,7 +66,9 @@ describe('CustomizeModal', () => { // Assert await waitFor(() => { - expect(screen.getByText('appOverview.overview.appInfo.customize.explanation')).toBeInTheDocument() + expect( + screen.getByText('appOverview.overview.appInfo.customize.explanation'), + ).toBeInTheDocument() }) }) @@ -106,9 +110,15 @@ describe('CustomizeModal', () => { // Assert await waitFor(() => { - expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step1')).toBeInTheDocument() - expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step2')).toBeInTheDocument() - expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step3')).toBeInTheDocument() + expect( + screen.getByText('appOverview.overview.appInfo.customize.way1.step1'), + ).toBeInTheDocument() + expect( + screen.getByText('appOverview.overview.appInfo.customize.way1.step2'), + ).toBeInTheDocument() + expect( + screen.getByText('appOverview.overview.appInfo.customize.way1.step3'), + ).toBeInTheDocument() }) }) @@ -123,8 +133,8 @@ describe('CustomizeModal', () => { await waitFor(() => { const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre') expect(preElement).toBeInTheDocument() - expect(preElement?.textContent).toContain('NEXT_PUBLIC_APP_ID=\'test-app-id-123\'') - expect(preElement?.textContent).toContain('NEXT_PUBLIC_API_URL=\'https://api.example.com\'') + expect(preElement?.textContent).toContain("NEXT_PUBLIC_APP_ID='test-app-id-123'") + expect(preElement?.textContent).toContain("NEXT_PUBLIC_API_URL='https://api.example.com'") }) }) @@ -189,7 +199,10 @@ describe('CustomizeModal', () => { // Assert await waitFor(() => { const githubLink = getAnchorButton(/step1Operation/i) - expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation') + expect(githubLink).toHaveAttribute( + 'href', + 'https://github.com/langgenius/webapp-conversation', + ) }) }) @@ -203,7 +216,10 @@ describe('CustomizeModal', () => { // Assert await waitFor(() => { const githubLink = getAnchorButton(/step1Operation/i) - expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation') + expect(githubLink).toHaveAttribute( + 'href', + 'https://github.com/langgenius/webapp-conversation', + ) }) }) @@ -217,7 +233,10 @@ describe('CustomizeModal', () => { // Assert await waitFor(() => { const githubLink = getAnchorButton(/step1Operation/i) - expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator') + expect(githubLink).toHaveAttribute( + 'href', + 'https://github.com/langgenius/webapp-text-generator', + ) }) }) @@ -231,7 +250,10 @@ describe('CustomizeModal', () => { // Assert await waitFor(() => { const githubLink = getAnchorButton(/step1Operation/i) - expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator') + expect(githubLink).toHaveAttribute( + 'href', + 'https://github.com/langgenius/webapp-text-generator', + ) }) }) @@ -245,7 +267,10 @@ describe('CustomizeModal', () => { // Assert await waitFor(() => { const githubLink = getAnchorButton(/step1Operation/i) - expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator') + expect(githubLink).toHaveAttribute( + 'href', + 'https://github.com/langgenius/webapp-text-generator', + ) }) }) }) @@ -277,7 +302,10 @@ describe('CustomizeModal', () => { // Assert await waitFor(() => { const vercelLink = getAnchorButton(/step2Operation/i) - expect(vercelLink).toHaveAttribute('href', 'https://vercel.com/docs/concepts/deployments/git/vercel-for-github') + expect(vercelLink).toHaveAttribute( + 'href', + 'https://vercel.com/docs/concepts/deployments/git/vercel-for-github', + ) expect(vercelLink).toHaveAttribute('target', '_blank') expect(vercelLink).toHaveAttribute('rel', 'noopener noreferrer') }) @@ -294,11 +322,16 @@ describe('CustomizeModal', () => { render(<CustomizeModal {...props} />) await waitFor(() => { - expect(screen.getByText('appOverview.overview.appInfo.customize.way2.operation')).toBeInTheDocument() + expect( + screen.getByText('appOverview.overview.appInfo.customize.way2.operation'), + ).toBeInTheDocument() }) const way2Link = getAnchorButton(/way2\.operation/i) - expect(way2Link).toHaveAttribute('href', expect.stringContaining('/api-reference/guides/get-started')) + expect(way2Link).toHaveAttribute( + 'href', + expect.stringContaining('/api-reference/guides/get-started'), + ) expect(way2Link).toHaveAttribute('target', '_blank') expect(way2Link).toHaveAttribute('rel', 'noopener noreferrer') }) @@ -334,7 +367,7 @@ describe('CustomizeModal', () => { // Assert await waitFor(() => { const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre') - expect(preElement?.textContent).toContain('NEXT_PUBLIC_APP_ID=\'\'') + expect(preElement?.textContent).toContain("NEXT_PUBLIC_APP_ID=''") }) }) @@ -348,7 +381,7 @@ describe('CustomizeModal', () => { // Assert await waitFor(() => { const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre') - expect(preElement?.textContent).toContain('NEXT_PUBLIC_API_URL=\'\'') + expect(preElement?.textContent).toContain("NEXT_PUBLIC_API_URL=''") }) }) diff --git a/web/app/components/app/overview/customize/index.tsx b/web/app/components/app/overview/customize/index.tsx index c1764e7e78516b..6458db30e164f8 100644 --- a/web/app/components/app/overview/customize/index.tsx +++ b/web/app/components/app/overview/customize/index.tsx @@ -1,7 +1,13 @@ 'use client' import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useTranslation } from 'react-i18next' import Tag from '@/app/components/base/tag' @@ -25,8 +31,19 @@ const StepNum: FC<{ children: React.ReactNode }> = ({ children }) => ( const GithubIcon = ({ className }: { className: string }) => { return ( - <svg aria-hidden="true" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}> - <path d="M5.80078 13.7109C5.80078 13.6406 5.73047 13.5703 5.625 13.5703C5.51953 13.5703 5.44922 13.6406 5.44922 13.7109C5.44922 13.7812 5.51953 13.8516 5.625 13.8164C5.73047 13.8164 5.80078 13.7812 5.80078 13.7109ZM4.71094 13.5352C4.71094 13.6055 4.78125 13.7109 4.88672 13.7109C4.95703 13.7461 5.0625 13.7109 5.09766 13.6406C5.09766 13.5703 5.0625 13.5 4.95703 13.4648C4.85156 13.4297 4.74609 13.4648 4.71094 13.5352ZM6.29297 13.5C6.1875 13.5 6.11719 13.5703 6.11719 13.6758C6.11719 13.7461 6.22266 13.7812 6.32812 13.7461C6.43359 13.7109 6.50391 13.6758 6.46875 13.6055C6.46875 13.5352 6.36328 13.4648 6.29297 13.5ZM8.57812 0C3.72656 0 0 3.72656 0 8.57812C0 12.4805 2.42578 15.8203 5.94141 17.0156C6.39844 17.0859 6.53906 16.8047 6.53906 16.5938C6.53906 16.3477 6.53906 15.1523 6.53906 14.4141C6.53906 14.4141 4.07812 14.9414 3.55078 13.3594C3.55078 13.3594 3.16406 12.3398 2.60156 12.0938C2.60156 12.0938 1.79297 11.5312 2.63672 11.5312C2.63672 11.5312 3.51562 11.6016 4.00781 12.4453C4.78125 13.8164 6.04688 13.4297 6.57422 13.1836C6.64453 12.6211 6.85547 12.2344 7.13672 11.9883C5.16797 11.7773 3.16406 11.4961 3.16406 8.12109C3.16406 7.13672 3.44531 6.67969 4.00781 6.04688C3.90234 5.80078 3.62109 4.88672 4.11328 3.65625C4.81641 3.44531 6.53906 4.60547 6.53906 4.60547C7.24219 4.39453 7.98047 4.32422 8.71875 4.32422C9.49219 4.32422 10.2305 4.39453 10.9336 4.60547C10.9336 4.60547 12.6211 3.41016 13.3594 3.65625C13.8516 4.88672 13.5352 5.80078 13.4648 6.04688C14.0273 6.67969 14.3789 7.13672 14.3789 8.12109C14.3789 11.4961 12.3047 11.7773 10.3359 11.9883C10.6523 12.2695 10.9336 12.7969 10.9336 13.6406C10.9336 14.8008 10.8984 16.2773 10.8984 16.5586C10.8984 16.8047 11.0742 17.0859 11.5312 16.9805C15.0469 15.8203 17.4375 12.4805 17.4375 8.57812C17.4375 3.72656 13.4648 0 8.57812 0ZM3.41016 12.1289C3.33984 12.1641 3.375 12.2695 3.41016 12.3398C3.48047 12.375 3.55078 12.4102 3.62109 12.375C3.65625 12.3398 3.65625 12.2344 3.58594 12.1641C3.51562 12.1289 3.44531 12.0938 3.41016 12.1289ZM3.02344 11.8477C2.98828 11.918 3.02344 11.9531 3.09375 11.9883C3.16406 12.0234 3.23438 12.0234 3.26953 11.9531C3.26953 11.918 3.23438 11.8828 3.16406 11.8477C3.09375 11.8125 3.05859 11.8125 3.02344 11.8477ZM4.14844 13.1133C4.11328 13.1484 4.11328 13.2539 4.21875 13.3242C4.28906 13.3945 4.39453 13.4297 4.42969 13.3594C4.46484 13.3242 4.46484 13.2188 4.39453 13.1484C4.32422 13.0781 4.21875 13.043 4.14844 13.1133ZM3.76172 12.5859C3.69141 12.6211 3.69141 12.7266 3.76172 12.7969C3.83203 12.8672 3.90234 12.9023 3.97266 12.8672C4.00781 12.832 4.00781 12.7266 3.97266 12.6562C3.90234 12.5859 3.83203 12.5508 3.76172 12.5859Z" fill="#1F2A37" /> + <svg + aria-hidden="true" + width="18" + height="18" + viewBox="0 0 18 18" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className={className} + > + <path + d="M5.80078 13.7109C5.80078 13.6406 5.73047 13.5703 5.625 13.5703C5.51953 13.5703 5.44922 13.6406 5.44922 13.7109C5.44922 13.7812 5.51953 13.8516 5.625 13.8164C5.73047 13.8164 5.80078 13.7812 5.80078 13.7109ZM4.71094 13.5352C4.71094 13.6055 4.78125 13.7109 4.88672 13.7109C4.95703 13.7461 5.0625 13.7109 5.09766 13.6406C5.09766 13.5703 5.0625 13.5 4.95703 13.4648C4.85156 13.4297 4.74609 13.4648 4.71094 13.5352ZM6.29297 13.5C6.1875 13.5 6.11719 13.5703 6.11719 13.6758C6.11719 13.7461 6.22266 13.7812 6.32812 13.7461C6.43359 13.7109 6.50391 13.6758 6.46875 13.6055C6.46875 13.5352 6.36328 13.4648 6.29297 13.5ZM8.57812 0C3.72656 0 0 3.72656 0 8.57812C0 12.4805 2.42578 15.8203 5.94141 17.0156C6.39844 17.0859 6.53906 16.8047 6.53906 16.5938C6.53906 16.3477 6.53906 15.1523 6.53906 14.4141C6.53906 14.4141 4.07812 14.9414 3.55078 13.3594C3.55078 13.3594 3.16406 12.3398 2.60156 12.0938C2.60156 12.0938 1.79297 11.5312 2.63672 11.5312C2.63672 11.5312 3.51562 11.6016 4.00781 12.4453C4.78125 13.8164 6.04688 13.4297 6.57422 13.1836C6.64453 12.6211 6.85547 12.2344 7.13672 11.9883C5.16797 11.7773 3.16406 11.4961 3.16406 8.12109C3.16406 7.13672 3.44531 6.67969 4.00781 6.04688C3.90234 5.80078 3.62109 4.88672 4.11328 3.65625C4.81641 3.44531 6.53906 4.60547 6.53906 4.60547C7.24219 4.39453 7.98047 4.32422 8.71875 4.32422C9.49219 4.32422 10.2305 4.39453 10.9336 4.60547C10.9336 4.60547 12.6211 3.41016 13.3594 3.65625C13.8516 4.88672 13.5352 5.80078 13.4648 6.04688C14.0273 6.67969 14.3789 7.13672 14.3789 8.12109C14.3789 11.4961 12.3047 11.7773 10.3359 11.9883C10.6523 12.2695 10.9336 12.7969 10.9336 13.6406C10.9336 14.8008 10.8984 16.2773 10.8984 16.5586C10.8984 16.8047 11.0742 17.0859 11.5312 16.9805C15.0469 15.8203 17.4375 12.4805 17.4375 8.57812C17.4375 3.72656 13.4648 0 8.57812 0ZM3.41016 12.1289C3.33984 12.1641 3.375 12.2695 3.41016 12.3398C3.48047 12.375 3.55078 12.4102 3.62109 12.375C3.65625 12.3398 3.65625 12.2344 3.58594 12.1641C3.51562 12.1289 3.44531 12.0938 3.41016 12.1289ZM3.02344 11.8477C2.98828 11.918 3.02344 11.9531 3.09375 11.9883C3.16406 12.0234 3.23438 12.0234 3.26953 11.9531C3.26953 11.918 3.23438 11.8828 3.16406 11.8477C3.09375 11.8125 3.05859 11.8125 3.02344 11.8477ZM4.14844 13.1133C4.11328 13.1484 4.11328 13.2539 4.21875 13.3242C4.28906 13.3945 4.39453 13.4297 4.42969 13.3594C4.46484 13.3242 4.46484 13.2188 4.39453 13.1484C4.32422 13.0781 4.21875 13.043 4.14844 13.1133ZM3.76172 12.5859C3.69141 12.6211 3.69141 12.7266 3.76172 12.7969C3.83203 12.8672 3.90234 12.9023 3.97266 12.8672C4.00781 12.832 4.00781 12.7266 3.97266 12.6562C3.90234 12.5859 3.83203 12.5508 3.76172 12.5859Z" + fill="#1F2A37" + /> </svg> ) } @@ -44,84 +61,140 @@ const CustomizeModal: FC<IShareLinkProps> = ({ const { t } = useTranslation() const docLink = useDocLink() const isChatApp = mode === AppModeEnum.CHAT || mode === AppModeEnum.ADVANCED_CHAT - const repository = sourceCodeRepository ?? (isChatApp ? 'webapp-conversation' : 'webapp-text-generator') + const repository = + sourceCodeRepository ?? (isChatApp ? 'webapp-conversation' : 'webapp-text-generator') const apiDocLink = docLink('/api-reference/guides/get-started') return ( - <Dialog open={isShow} onOpenChange={open => !open && onClose()}> + <Dialog open={isShow} onOpenChange={(open) => !open && onClose()}> <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[640px] flex-col overflow-hidden!"> <DialogTitle className="shrink-0 title-2xl-semi-bold text-text-primary"> - {t($ => $[`${prefixCustomize}.title`], { ns: 'appOverview' })} + {t(($) => $[`${prefixCustomize}.title`], { ns: 'appOverview' })} </DialogTitle> <DialogDescription className="mt-2 shrink-0 body-md-regular text-text-secondary"> - {t($ => $[`${prefixCustomize}.explanation`], { ns: 'appOverview' })} + {t(($) => $[`${prefixCustomize}.explanation`], { ns: 'appOverview' })} </DialogDescription> <DialogCloseButton /> <div className="mt-4 min-h-0 flex-1 overflow-y-auto overscroll-contain"> <div className="w-full rounded-lg border-[0.5px] border-components-panel-border px-6 py-5"> - <Tag bordered={true} hideBg={true} className="border-text-accent-secondary text-text-accent-secondary uppercase"> - {t($ => $[`${prefixCustomize}.way`], { ns: 'appOverview' })} - {' '} - 1 + <Tag + bordered={true} + hideBg={true} + className="border-text-accent-secondary text-text-accent-secondary uppercase" + > + {t(($) => $[`${prefixCustomize}.way`], { ns: 'appOverview' })} 1 </Tag> - <p className="my-2 system-sm-medium text-text-secondary">{t($ => $[`${prefixCustomize}.way1.name`], { ns: 'appOverview' })}</p> + <p className="my-2 system-sm-medium text-text-secondary"> + {t(($) => $[`${prefixCustomize}.way1.name`], { ns: 'appOverview' })} + </p> <div className="flex py-4"> <StepNum>1</StepNum> <div className="flex flex-col"> - <div className="text-text-primary">{t($ => $[`${prefixCustomize}.way1.step1`], { ns: 'appOverview' })}</div> - <div className="mt-1 mb-2 text-xs text-text-tertiary">{t($ => $[`${prefixCustomize}.way1.step1Tip`], { ns: 'appOverview' })}</div> - <Button nativeButton={false} render={<a href={`https://github.com/langgenius/${repository}`} target="_blank" rel="noopener noreferrer" aria-label={t($ => $[`${prefixCustomize}.way1.step1Operation`], { ns: 'appOverview' })} />}> + <div className="text-text-primary"> + {t(($) => $[`${prefixCustomize}.way1.step1`], { ns: 'appOverview' })} + </div> + <div className="mt-1 mb-2 text-xs text-text-tertiary"> + {t(($) => $[`${prefixCustomize}.way1.step1Tip`], { ns: 'appOverview' })} + </div> + <Button + nativeButton={false} + render={ + <a + href={`https://github.com/langgenius/${repository}`} + target="_blank" + rel="noopener noreferrer" + aria-label={t(($) => $[`${prefixCustomize}.way1.step1Operation`], { + ns: 'appOverview', + })} + /> + } + > <GithubIcon className="mr-2 text-text-secondary" /> - {t($ => $[`${prefixCustomize}.way1.step1Operation`], { ns: 'appOverview' })} + {t(($) => $[`${prefixCustomize}.way1.step1Operation`], { ns: 'appOverview' })} </Button> </div> </div> <div className="flex pt-4"> <StepNum>2</StepNum> <div className="flex flex-col"> - <div className="text-text-primary">{t($ => $[`${prefixCustomize}.way1.step2`], { ns: 'appOverview' })}</div> - <div className="mt-1 mb-2 text-xs text-text-tertiary">{t($ => $[`${prefixCustomize}.way1.step2Tip`], { ns: 'appOverview' })}</div> - <Button nativeButton={false} render={<a href="https://vercel.com/docs/concepts/deployments/git/vercel-for-github" target="_blank" rel="noopener noreferrer" aria-label={t($ => $[`${prefixCustomize}.way1.step2Operation`], { ns: 'appOverview' })} />}> + <div className="text-text-primary"> + {t(($) => $[`${prefixCustomize}.way1.step2`], { ns: 'appOverview' })} + </div> + <div className="mt-1 mb-2 text-xs text-text-tertiary"> + {t(($) => $[`${prefixCustomize}.way1.step2Tip`], { ns: 'appOverview' })} + </div> + <Button + nativeButton={false} + render={ + <a + href="https://vercel.com/docs/concepts/deployments/git/vercel-for-github" + target="_blank" + rel="noopener noreferrer" + aria-label={t(($) => $[`${prefixCustomize}.way1.step2Operation`], { + ns: 'appOverview', + })} + /> + } + > <div className="mr-1.5 border-t-0 border-r-[7px] border-b-12 border-l-[7px] border-solid border-text-primary border-t-transparent border-r-transparent border-l-transparent"></div> - <span>{t($ => $[`${prefixCustomize}.way1.step2Operation`], { ns: 'appOverview' })}</span> + <span> + {t(($) => $[`${prefixCustomize}.way1.step2Operation`], { ns: 'appOverview' })} + </span> </Button> </div> </div> <div className="flex py-4"> <StepNum>3</StepNum> <div className="flex w-full flex-col overflow-hidden"> - <div className="text-text-primary">{t($ => $[`${prefixCustomize}.way1.step3`], { ns: 'appOverview' })}</div> - <div className="mt-1 mb-2 text-xs text-text-tertiary">{t($ => $[`${prefixCustomize}.way1.step3Tip`], { ns: 'appOverview' })}</div> + <div className="text-text-primary"> + {t(($) => $[`${prefixCustomize}.way1.step3`], { ns: 'appOverview' })} + </div> + <div className="mt-1 mb-2 text-xs text-text-tertiary"> + {t(($) => $[`${prefixCustomize}.way1.step3Tip`], { ns: 'appOverview' })} + </div> <pre className="box-border overflow-x-scroll rounded-lg border-[0.5px] border-components-panel-border bg-background-section px-4 py-3 text-xs font-medium text-text-secondary select-text"> NEXT_PUBLIC_APP_ID= - {`'${appId}'`} - {' '} - <br /> + {`'${appId}'`} <br /> NEXT_PUBLIC_APP_KEY= - {'\'<Web API Key From Dify>\''} - {' '} - <br /> + {"'<Web API Key From Dify>'"} <br /> NEXT_PUBLIC_API_URL= {`'${api_base_url}'`} </pre> </div> </div> - </div> <div className="mt-4 w-full rounded-lg border-[0.5px] border-components-panel-border px-6 py-5"> - <Tag bordered={true} hideBg={true} className="border-text-accent-secondary text-text-accent-secondary uppercase"> - {t($ => $[`${prefixCustomize}.way`], { ns: 'appOverview' })} - {' '} - 2 + <Tag + bordered={true} + hideBg={true} + className="border-text-accent-secondary text-text-accent-secondary uppercase" + > + {t(($) => $[`${prefixCustomize}.way`], { ns: 'appOverview' })} 2 </Tag> - <p className="my-2 system-sm-medium text-text-secondary">{t($ => $[`${prefixCustomize}.way2.name`], { ns: 'appOverview' })}</p> + <p className="my-2 system-sm-medium text-text-secondary"> + {t(($) => $[`${prefixCustomize}.way2.name`], { ns: 'appOverview' })} + </p> <Button nativeButton={false} - render={<a href={apiDocLink} target="_blank" rel="noopener noreferrer" aria-label={t($ => $[`${prefixCustomize}.way2.operation`], { ns: 'appOverview' })} />} + render={ + <a + href={apiDocLink} + target="_blank" + rel="noopener noreferrer" + aria-label={t(($) => $[`${prefixCustomize}.way2.operation`], { + ns: 'appOverview', + })} + /> + } className="mt-2" > - <span className="text-sm text-text-secondary">{t($ => $[`${prefixCustomize}.way2.operation`], { ns: 'appOverview' })}</span> - <span aria-hidden="true" className="ml-1 i-heroicons-arrow-top-right-on-square size-4 shrink-0 text-text-secondary" /> + <span className="text-sm text-text-secondary"> + {t(($) => $[`${prefixCustomize}.way2.operation`], { ns: 'appOverview' })} + </span> + <span + aria-hidden="true" + className="ml-1 i-heroicons-arrow-top-right-on-square size-4 shrink-0 text-text-secondary" + /> </Button> </div> </div> diff --git a/web/app/components/app/overview/embedded/__tests__/index.spec.tsx b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx index 653cb78d8fc11f..7ca3ace81a31dd 100644 --- a/web/app/components/app/overview/embedded/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx @@ -3,7 +3,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import copy from 'copy-to-clipboard' import * as React from 'react' import { act } from 'react' - import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { InputVarType } from '@/app/components/workflow/types' import Embedded from '../index' @@ -51,7 +50,7 @@ const baseProps = { const getCopyButton = () => { const buttons = screen.getAllByRole('button') - const actionButton = buttons.find(button => button.className.includes('action-btn')) + const actionButton = buttons.find((button) => button.className.includes('action-btn')) expect(actionButton).toBeDefined() return actionButton! } @@ -89,7 +88,12 @@ describe('Embedded', () => { }) await waitFor(() => { - expect(screen.getByText((content, node) => node?.tagName.toLowerCase() === 'pre' && content.includes('/chatbot/token'))).toBeInTheDocument() + expect( + screen.getByText( + (content, node) => + node?.tagName.toLowerCase() === 'pre' && content.includes('/chatbot/token'), + ), + ).toBeInTheDocument() }) const actionButton = getCopyButton() @@ -98,7 +102,10 @@ describe('Embedded', () => { fireEvent.click(innerDiv ?? actionButton) }) - expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith(siteInfo.chat_color_theme, siteInfo.chat_color_theme_inverted) + expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith( + siteInfo.chat_color_theme, + siteInfo.chat_color_theme_inverted, + ) await waitFor(() => { expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) }) @@ -145,21 +152,27 @@ describe('Embedded', () => { render( <Embedded {...baseProps} - hiddenInputs={[{ - variable: 'secret', - label: 'Secret', - type: InputVarType.textInput, - hide: true, - required: true, - default: '', - }]} + hiddenInputs={[ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ]} />, ) expect(screen.queryByLabelText('Secret')).not.toBeInTheDocument() await act(async () => { - fireEvent.click(screen.getByText('appOverview.overview.appInfo.embedded.hiddenInputs.title').closest('button')!) + fireEvent.click( + screen + .getByText('appOverview.overview.appInfo.embedded.hiddenInputs.title') + .closest('button')!, + ) }) await waitFor(() => { @@ -202,7 +215,7 @@ describe('Embedded', () => { await waitFor(() => { const codeBlock = document.querySelector('pre') - expect(codeBlock?.textContent ?? '').toContain('token: \'token\'') + expect(codeBlock?.textContent ?? '').toContain("token: 'token'") }) const actionButton = getCopyButton() @@ -212,7 +225,7 @@ describe('Embedded', () => { }) await waitFor(() => { - expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('token: \'token\'')) + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining("token: 'token'")) }) }) diff --git a/web/app/components/app/overview/embedded/index.tsx b/web/app/components/app/overview/embedded/index.tsx index 7705b252a3132e..216a58894f25fa 100644 --- a/web/app/components/app/overview/embedded/index.tsx +++ b/web/app/components/app/overview/embedded/index.tsx @@ -1,5 +1,9 @@ import type { MutableRefObject } from 'react' -import type { EmbeddedWebAppRoute, WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils' +import type { + EmbeddedWebAppRoute, + WorkflowHiddenStartVariable, + WorkflowLaunchInputValue, +} from '../app-card-utils' import type { SiteInfo } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' @@ -38,7 +42,7 @@ type Props = Readonly<{ const OPTION_KEYS = ['iframe', 'scripts', 'chromePlugin'] as const const prefixEmbedded = 'overview.appInfo.embedded' -type Option = typeof OPTION_KEYS[number] +type Option = (typeof OPTION_KEYS)[number] const optionIconClassName: Record<Option, string> = { iframe: style.iframeIcon!, @@ -51,8 +55,7 @@ const getSerializedHiddenInputValue = ( values: Record<string, WorkflowLaunchInputValue>, ) => { const rawValue = values[variable.variable] - if (variable.type === InputVarType.checkbox) - return String(Boolean(rawValue)) + if (variable.type === InputVarType.checkbox) return String(Boolean(rawValue)) return String(rawValue ?? '') } @@ -70,11 +73,19 @@ const buildEmbeddedIframeUrl = async ({ variables: WorkflowHiddenStartVariable[] values: Record<string, WorkflowLaunchInputValue> }) => { - const iframeUrl = new URL(`${appBaseUrl}${basePath}/${webAppRoute}/${accessToken}`, window.location.origin) + const iframeUrl = new URL( + `${appBaseUrl}${basePath}/${webAppRoute}/${accessToken}`, + window.location.origin, + ) - await Promise.all(variables.map(async (variable) => { - iframeUrl.searchParams.set(variable.variable, await compressAndEncodeBase64(getSerializedHiddenInputValue(variable, values))) - })) + await Promise.all( + variables.map(async (variable) => { + iframeUrl.searchParams.set( + variable.variable, + await compressAndEncodeBase64(getSerializedHiddenInputValue(variable, values)), + ) + }), + ) return iframeUrl.toString() } @@ -91,8 +102,7 @@ const AsyncEmbeddedOptionContent = ({ const iframeUrl = use(iframeUrlPromise) latestResolvedIframeUrlRef.current = iframeUrl - if (option === 'chromePlugin') - return getChromePluginContent(iframeUrl) + if (option === 'chromePlugin') return getChromePluginContent(iframeUrl) return getEmbeddedIframeSnippet(iframeUrl) } @@ -103,7 +113,8 @@ const EmbeddedContent = ({ accessToken, webAppRoute = 'chatbot', hiddenInputs, -}: Required<Pick<Props, 'accessToken' | 'appBaseUrl'>> & Pick<Props, 'siteInfo' | 'webAppRoute' | 'hiddenInputs'>) => { +}: Required<Pick<Props, 'accessToken' | 'appBaseUrl'>> & + Pick<Props, 'siteInfo' | 'webAppRoute' | 'hiddenInputs'>) => { const { t } = useTranslation() const supportedHiddenInputs = useMemo<WorkflowHiddenStartVariable[]>( () => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported), @@ -116,11 +127,11 @@ const EmbeddedContent = ({ const [option, setOption] = useState<Option>('iframe') const [copiedOption, setCopiedOption] = useState<Option | null>(null) const [hiddenInputsCollapsed, setHiddenInputsCollapsed] = useState(true) - const [hiddenInputValues, setHiddenInputValues] = useState<Record<string, WorkflowLaunchInputValue>>( - () => initialHiddenInputValues, - ) - const [previewIframeUrlPromise, setPreviewIframeUrlPromise] = useState<Promise<string>>( - () => buildEmbeddedIframeUrl({ + const [hiddenInputValues, setHiddenInputValues] = useState< + Record<string, WorkflowLaunchInputValue> + >(() => initialHiddenInputValues) + const [previewIframeUrlPromise, setPreviewIframeUrlPromise] = useState<Promise<string>>(() => + buildEmbeddedIframeUrl({ appBaseUrl, accessToken, webAppRoute, @@ -132,7 +143,9 @@ const EmbeddedContent = ({ const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) const themeBuilder = useThemeContext() - const isTestEnv = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT' + const isTestEnv = + langGeniusVersionInfo.current_env === 'TESTING' || + langGeniusVersionInfo.current_env === 'DEVELOPMENT' const handleHiddenInputValueChange = (variable: string, value: WorkflowLaunchInputValue) => { const nextHiddenInputValues = { @@ -142,22 +155,35 @@ const EmbeddedContent = ({ setCopiedOption(null) setHiddenInputValues(nextHiddenInputValues) - setPreviewIframeUrlPromise(buildEmbeddedIframeUrl({ - appBaseUrl, + setPreviewIframeUrlPromise( + buildEmbeddedIframeUrl({ + appBaseUrl, + accessToken, + webAppRoute, + variables: supportedHiddenInputs, + values: nextHiddenInputValues, + }), + ) + } + const scriptsContent = useMemo( + () => + getEmbeddedScriptSnippet({ + url: appBaseUrl, + token: accessToken, + webAppRoute, + primaryColor: themeBuilder.theme?.primaryColor ?? '#1C64F2', + isTestEnv, + inputValues: hiddenInputValues, + }), + [ accessToken, + appBaseUrl, + hiddenInputValues, + isTestEnv, + themeBuilder.theme?.primaryColor, webAppRoute, - variables: supportedHiddenInputs, - values: nextHiddenInputValues, - })) - } - const scriptsContent = useMemo(() => getEmbeddedScriptSnippet({ - url: appBaseUrl, - token: accessToken, - webAppRoute, - primaryColor: themeBuilder.theme?.primaryColor ?? '#1C64F2', - isTestEnv, - inputValues: hiddenInputValues, - }), [accessToken, appBaseUrl, hiddenInputValues, isTestEnv, themeBuilder.theme?.primaryColor, webAppRoute]) + ], + ) const onClickCopy = async () => { const latestIframeUrl = await buildEmbeddedIframeUrl({ @@ -170,54 +196,66 @@ const EmbeddedContent = ({ if (option === 'chromePlugin') { const splitUrl = getChromePluginContent(latestIframeUrl).split(': ') - if (splitUrl.length > 1) - copy(splitUrl[1]!) - } - else if (option === 'iframe') { + if (splitUrl.length > 1) copy(splitUrl[1]!) + } else if (option === 'iframe') { copy(getEmbeddedIframeSnippet(latestIframeUrl)) - } - else { + } else { copy(scriptsContent) } setCopiedOption(option) } const previewFallback = latestResolvedIframeUrlRef.current - ? (option === 'chromePlugin' - ? getChromePluginContent(latestResolvedIframeUrlRef.current) - : getEmbeddedIframeSnippet(latestResolvedIframeUrlRef.current)) + ? option === 'chromePlugin' + ? getChromePluginContent(latestResolvedIframeUrlRef.current) + : getEmbeddedIframeSnippet(latestResolvedIframeUrlRef.current) : '' const navigateToChromeUrl = () => { - window.open('https://chrome.google.com/webstore/detail/dify-chatbot/ceehdapohffmjmkdcifjofadiaoeggaf', '_blank', 'noopener,noreferrer') + window.open( + 'https://chrome.google.com/webstore/detail/dify-chatbot/ceehdapohffmjmkdcifjofadiaoeggaf', + '_blank', + 'noopener,noreferrer', + ) } useEffect(() => { - themeBuilder.buildTheme(siteInfo?.chat_color_theme ?? null, siteInfo?.chat_color_theme_inverted ?? false) + themeBuilder.buildTheme( + siteInfo?.chat_color_theme ?? null, + siteInfo?.chat_color_theme_inverted ?? false, + ) }, [siteInfo?.chat_color_theme, siteInfo?.chat_color_theme_inverted, themeBuilder]) return ( <> <div className="mt-8 mb-4 system-sm-medium text-text-primary"> - {t($ => $[`${prefixEmbedded}.explanation`], { ns: 'appOverview' })} + {t(($) => $[`${prefixEmbedded}.explanation`], { ns: 'appOverview' })} </div> {supportedHiddenInputs.length > 0 && ( <div className="mb-6 rounded-xl border-[0.5px] border-components-panel-border bg-background-section"> <button type="button" className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left" - onClick={() => setHiddenInputsCollapsed(prev => !prev)} + onClick={() => setHiddenInputsCollapsed((prev) => !prev)} > <div> <div className="system-sm-medium text-text-primary"> - {t($ => $[`${prefixEmbedded}.hiddenInputs.title`], { ns: 'appOverview' })} + {t(($) => $[`${prefixEmbedded}.hiddenInputs.title`], { ns: 'appOverview' })} </div> <div className="mt-1 system-xs-regular text-text-tertiary"> - {t($ => $[`${prefixEmbedded}.hiddenInputs.description`], { ns: 'appOverview' })} + {t(($) => $[`${prefixEmbedded}.hiddenInputs.description`], { ns: 'appOverview' })} </div> </div> - {hiddenInputsCollapsed - ? <span aria-hidden className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" /> - : <span aria-hidden className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-tertiary" />} + {hiddenInputsCollapsed ? ( + <span + aria-hidden + className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" + /> + ) : ( + <span + aria-hidden + className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-tertiary" + /> + )} </button> {!hiddenInputsCollapsed && ( <div className="max-h-72 space-y-4 overflow-y-auto border-t-[0.5px] border-divider-subtle px-4 py-4"> @@ -237,18 +275,13 @@ const EmbeddedContent = ({ <button type="button" key={v} - aria-label={t($ => $[`${prefixEmbedded}.${v}`], { ns: 'appOverview' }) || v} - className={cn( - style.option, - optionIconClassName[v], - option === v && style.active, - )} + aria-label={t(($) => $[`${prefixEmbedded}.${v}`], { ns: 'appOverview' }) || v} + className={cn(style.option, optionIconClassName[v], option === v && style.active)} onClick={() => { setOption(v) setCopiedOption(null) }} - > - </button> + ></button> ) })} </div> @@ -256,54 +289,70 @@ const EmbeddedContent = ({ <div className="mt-6 w-full"> <button type="button" - className={cn('inline-flex w-full items-center justify-center gap-2 rounded-lg py-3', 'shrink-0 bg-primary-600 text-white hover:bg-primary-600/75 hover:shadow-sm')} + className={cn( + 'inline-flex w-full items-center justify-center gap-2 rounded-lg py-3', + 'shrink-0 bg-primary-600 text-white hover:bg-primary-600/75 hover:shadow-sm', + )} onClick={navigateToChromeUrl} > <div className={`relative size-4 ${style.pluginInstallIcon}`}></div> - <div className="font-['Inter'] text-sm leading-tight font-medium text-white">{t($ => $[`${prefixEmbedded}.chromePlugin`], { ns: 'appOverview' })}</div> + <div className="font-['Inter'] text-sm leading-tight font-medium text-white"> + {t(($) => $[`${prefixEmbedded}.chromePlugin`], { ns: 'appOverview' })} + </div> </button> </div> )} - <div className={cn('inline-flex w-full flex-col items-start justify-start rounded-lg border-[0.5px] border-components-panel-border bg-background-section', 'mt-6')}> + <div + className={cn( + 'inline-flex w-full flex-col items-start justify-start rounded-lg border-[0.5px] border-components-panel-border bg-background-section', + 'mt-6', + )} + > <div className="inline-flex items-center justify-start gap-2 self-stretch rounded-t-lg bg-background-section-burn py-1 pr-1 pl-3"> <div className="shrink-0 grow system-sm-medium text-text-secondary"> - {t($ => $[`${prefixEmbedded}.${option}`], { ns: 'appOverview' })} + {t(($) => $[`${prefixEmbedded}.${option}`], { ns: 'appOverview' })} </div> <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton - aria-label={(copiedOption === option - ? t($ => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) - : t($ => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' })) || ''} + aria-label={ + (copiedOption === option + ? t(($) => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) + : t(($) => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' })) || '' + } onClick={() => void onClickCopy()} > - {copiedOption === option && <span aria-hidden="true" className="i-ri-clipboard-fill size-4" />} - {copiedOption !== option && <span aria-hidden="true" className="i-ri-clipboard-line size-4" />} + {copiedOption === option && ( + <span aria-hidden="true" className="i-ri-clipboard-fill size-4" /> + )} + {copiedOption !== option && ( + <span aria-hidden="true" className="i-ri-clipboard-line size-4" /> + )} </ActionButton> - )} + } /> <TooltipContent> {(copiedOption === option - ? t($ => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) - : t($ => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' })) || ''} + ? t(($) => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) + : t(($) => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' })) || ''} </TooltipContent> </Tooltip> </div> <div className="flex max-h-[clamp(180px,calc(100dvh-320px),360px)] w-full items-start justify-start gap-2 overflow-auto p-3"> <div className="shrink grow basis-0 font-mono text-[13px] leading-tight text-text-secondary"> <pre className="select-text"> - {option === 'scripts' - ? scriptsContent - : ( - <Suspense fallback={previewFallback}> - <AsyncEmbeddedOptionContent - option={option} - iframeUrlPromise={previewIframeUrlPromise} - latestResolvedIframeUrlRef={latestResolvedIframeUrlRef} - /> - </Suspense> - )} + {option === 'scripts' ? ( + scriptsContent + ) : ( + <Suspense fallback={previewFallback}> + <AsyncEmbeddedOptionContent + option={option} + iframeUrlPromise={previewIframeUrlPromise} + latestResolvedIframeUrlRef={latestResolvedIframeUrlRef} + /> + </Suspense> + )} </pre> </div> </div> @@ -312,21 +361,34 @@ const EmbeddedContent = ({ ) } -const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, webAppRoute = 'chatbot', hiddenInputs, className }: Props) => { +const Embedded = ({ + siteInfo, + isShow, + onClose, + appBaseUrl, + accessToken, + webAppRoute = 'chatbot', + hiddenInputs, + className, +}: Props) => { const { t } = useTranslation() return ( <Dialog open={isShow} onOpenChange={(open) => { - if (open) - return + if (open) return onClose() }} > - <DialogContent className={cn('flex max-h-[calc(100dvh-2rem)] w-[640px] flex-col overflow-hidden!', className)}> + <DialogContent + className={cn( + 'flex max-h-[calc(100dvh-2rem)] w-[640px] flex-col overflow-hidden!', + className, + )} + > <DialogTitle className="shrink-0 title-2xl-semi-bold text-text-primary"> - {t($ => $[`${prefixEmbedded}.title`], { ns: 'appOverview' })} + {t(($) => $[`${prefixEmbedded}.title`], { ns: 'appOverview' })} </DialogTitle> <DialogCloseButton /> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain"> diff --git a/web/app/components/app/overview/embedded/style.module.css b/web/app/components/app/overview/embedded/style.module.css index b80c9e3c7c5437..b825fe74eef6a7 100644 --- a/web/app/components/app/overview/embedded/style.module.css +++ b/web/app/components/app/overview/embedded/style.module.css @@ -3,7 +3,7 @@ .option { width: 188px; height: 128px; - @apply box-border cursor-pointer bg-auto bg-no-repeat bg-center rounded-md; + @apply box-border cursor-pointer rounded-md bg-auto bg-center bg-no-repeat; } .active { @apply border-[1.5px] border-[#2970FF]; @@ -21,12 +21,12 @@ background-image: url(../assets/chromeplugin-install.svg); } -:global(html[data-theme="dark"]) .iframeIcon, -:global(html[data-theme="dark"]) .scriptsIcon, -:global(html[data-theme="dark"]) .chromePluginIcon { +:global(html[data-theme='dark']) .iframeIcon, +:global(html[data-theme='dark']) .scriptsIcon, +:global(html[data-theme='dark']) .chromePluginIcon { filter: invert(0.86) hue-rotate(180deg) saturate(0.5) brightness(0.95); } -:global(html[data-theme="dark"]) .pluginInstallIcon { +:global(html[data-theme='dark']) .pluginInstallIcon { filter: invert(0.9); } diff --git a/web/app/components/app/overview/settings/__tests__/index.spec.tsx b/web/app/components/app/overview/settings/__tests__/index.spec.tsx index ef0d0185c7508e..0d3b88fc93f0cc 100644 --- a/web/app/components/app/overview/settings/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/settings/__tests__/index.spec.tsx @@ -39,10 +39,18 @@ const toastMocks = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { - success: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'success', message, ...options })), - error: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'error', message, ...options })), - warning: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'warning', message, ...options })), - info: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'info', message, ...options })), + success: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'success', message, ...options }), + ), + error: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'info', message, ...options }), + ), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -82,7 +90,9 @@ vi.mock('@/context/i18n', async () => { }) vi.mock('@/context/provider-context', async () => { - const actual = await vi.importActual<typeof import('@/context/provider-context')>('@/context/provider-context') + const actual = await vi.importActual<typeof import('@/context/provider-context')>( + '@/context/provider-context', + ) return { ...actual, useProviderContext: () => mockUseProviderContext(), @@ -111,15 +121,10 @@ const mockAppInfo = { enable_sso: false, } as unknown as AppDetailResponse & Partial<AppSSO> -const renderSettingsModal = (appInfo = mockAppInfo) => render( - <SettingsModal - isChat - isShow - appInfo={appInfo} - onClose={mockOnClose} - onSave={mockOnSave} - />, -) +const renderSettingsModal = (appInfo = mockAppInfo) => + render( + <SettingsModal isChat isShow appInfo={appInfo} onClose={mockOnClose} onSave={mockOnSave} />, + ) const inputPlaceholderName = 'appOverview.overview.appInfo.settings.more.inputPlaceholder' @@ -149,10 +154,20 @@ describe('SettingsModal', () => { renderSettingsModal() expect(screen.getByText('appOverview.overview.appInfo.settings.title')).toBeInTheDocument() - expect(screen.queryByText('appOverview.overview.appInfo.settings.more.entry')).not.toBeInTheDocument() + expect( + screen.queryByText('appOverview.overview.appInfo.settings.more.entry'), + ).not.toBeInTheDocument() expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toBeInTheDocument() - expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.copyRightPlaceholder')).toBeInTheDocument() - expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText( + 'appOverview.overview.appInfo.settings.more.copyRightPlaceholder', + ), + ).toBeInTheDocument() + expect( + screen.getByPlaceholderText( + 'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder', + ), + ).toBeInTheDocument() }) it('should notify the user when the name is empty', async () => { @@ -162,7 +177,9 @@ describe('SettingsModal', () => { fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ message: 'app.newApp.nameNotEmpty' })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ message: 'app.newApp.nameNotEmpty' }), + ) }) expect(mockOnSave).not.toHaveBeenCalled() }) @@ -174,24 +191,30 @@ describe('SettingsModal', () => { fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - message: 'appOverview.overview.appInfo.settings.invalidHexMessage', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'appOverview.overview.appInfo.settings.invalidHexMessage', + }), + ) }) expect(mockOnSave).not.toHaveBeenCalled() }) it('should validate the privacy policy URL', async () => { renderSettingsModal() - const privacyInput = screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder') + const privacyInput = screen.getByPlaceholderText( + 'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder', + ) fireEvent.change(privacyInput, { target: { value: 'ftp://invalid-url' } }) fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - message: 'appOverview.overview.appInfo.settings.invalidPrivacyPolicy', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'appOverview.overview.appInfo.settings.invalidPrivacyPolicy', + }), + ) }) expect(mockOnSave).not.toHaveBeenCalled() }) @@ -203,32 +226,40 @@ describe('SettingsModal', () => { fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => expect(mockOnSave).toHaveBeenCalled()) - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - title: mockAppInfo.site.title, - description: mockAppInfo.site.description, - default_language: mockAppInfo.site.default_language, - chat_color_theme: mockAppInfo.site.chat_color_theme, - chat_color_theme_inverted: mockAppInfo.site.chat_color_theme_inverted, - prompt_public: false, - copyright: mockAppInfo.site.copyright, - privacy_policy: mockAppInfo.site.privacy_policy, - custom_disclaimer: mockAppInfo.site.custom_disclaimer, - input_placeholder: mockAppInfo.site.input_placeholder, - icon_type: 'emoji', - icon: mockAppInfo.site.icon, - icon_background: mockAppInfo.site.icon_background, - show_workflow_steps: mockAppInfo.site.show_workflow_steps, - use_icon_as_answer_icon: mockAppInfo.site.use_icon_as_answer_icon, - enable_sso: mockAppInfo.enable_sso, - })) + expect(mockOnSave).toHaveBeenCalledWith( + expect.objectContaining({ + title: mockAppInfo.site.title, + description: mockAppInfo.site.description, + default_language: mockAppInfo.site.default_language, + chat_color_theme: mockAppInfo.site.chat_color_theme, + chat_color_theme_inverted: mockAppInfo.site.chat_color_theme_inverted, + prompt_public: false, + copyright: mockAppInfo.site.copyright, + privacy_policy: mockAppInfo.site.privacy_policy, + custom_disclaimer: mockAppInfo.site.custom_disclaimer, + input_placeholder: mockAppInfo.site.input_placeholder, + icon_type: 'emoji', + icon: mockAppInfo.site.icon, + icon_background: mockAppInfo.site.icon_background, + show_workflow_steps: mockAppInfo.site.show_workflow_steps, + use_icon_as_answer_icon: mockAppInfo.site.use_icon_as_answer_icon, + enable_sso: mockAppInfo.enable_sso, + }), + ) expect(mockOnClose).toHaveBeenCalled() }) it('should not render a show-more trigger', () => { renderSettingsModal() - expect(screen.queryByText('appOverview.overview.appInfo.settings.more.entry')).not.toBeInTheDocument() - expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).toBeInTheDocument() + expect( + screen.queryByText('appOverview.overview.appInfo.settings.more.entry'), + ).not.toBeInTheDocument() + expect( + screen.getByPlaceholderText( + 'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder', + ), + ).toBeInTheDocument() }) it('should reset local form state when the controlled dialog reopens', () => { @@ -241,7 +272,11 @@ describe('SettingsModal', () => { onSave={mockOnSave} />, ) - expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText( + 'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder', + ), + ).toBeInTheDocument() rerender( <SettingsModal @@ -262,8 +297,14 @@ describe('SettingsModal', () => { />, ) - expect(screen.queryByText('appOverview.overview.appInfo.settings.more.entry')).not.toBeInTheDocument() - expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).toBeInTheDocument() + expect( + screen.queryByText('appOverview.overview.appInfo.settings.more.entry'), + ).not.toBeInTheDocument() + expect( + screen.getByPlaceholderText( + 'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder', + ), + ).toBeInTheDocument() }) it('should reset the input placeholder when app info changes while open', () => { @@ -276,24 +317,30 @@ describe('SettingsModal', () => { onSave={mockOnSave} />, ) - expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue('Ask me anything') + expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue( + 'Ask me anything', + ) rerender( <SettingsModal isChat isShow={true} - appInfo={{ - ...mockAppInfo, - site: { - ...mockAppInfo.site, - input_placeholder: 'Updated prompt', - }, - } as typeof mockAppInfo} + appInfo={ + { + ...mockAppInfo, + site: { + ...mockAppInfo.site, + input_placeholder: 'Updated prompt', + }, + } as typeof mockAppInfo + } onClose={mockOnClose} onSave={mockOnSave} />, ) - expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue('Updated prompt') + expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue( + 'Updated prompt', + ) }) it('should display paid webapp settings as defaults for Cloud sandbox plans', async () => { @@ -313,15 +360,21 @@ describe('SettingsModal', () => { const inputPlaceholder = screen.getByRole('textbox', { name: inputPlaceholderName }) expect(inputPlaceholder).toBeDisabled() expect(inputPlaceholder).toHaveValue('') - expect(screen.queryByPlaceholderText('appOverview.overview.appInfo.settings.more.copyRightPlaceholder')).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText( + 'appOverview.overview.appInfo.settings.more.copyRightPlaceholder', + ), + ).not.toBeInTheDocument() fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => { - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - copyright: '', - input_placeholder: '', - })) + expect(mockOnSave).toHaveBeenCalledWith( + expect.objectContaining({ + copyright: '', + input_placeholder: '', + }), + ) }) }) @@ -345,10 +398,12 @@ describe('SettingsModal', () => { expect(inputPlaceholder).toBeEnabled() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() await waitFor(() => { - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - copyright: '', - input_placeholder: 'Self-hosted prompt', - })) + expect(mockOnSave).toHaveBeenCalledWith( + expect.objectContaining({ + copyright: '', + input_placeholder: 'Self-hosted prompt', + }), + ) }) }) @@ -417,17 +472,19 @@ describe('SettingsModal', () => { fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => { - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - description: 'Updated description', - chat_color_theme: '', - chat_color_theme_inverted: false, - copyright: '', - icon_type: 'image', - icon: 'file-1', - icon_background: undefined, - show_workflow_steps: false, - use_icon_as_answer_icon: false, - })) + expect(mockOnSave).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Updated description', + chat_color_theme: '', + chat_color_theme_inverted: false, + copyright: '', + icon_type: 'image', + icon: 'file-1', + icon_background: undefined, + show_workflow_steps: false, + use_icon_as_answer_icon: false, + }), + ) }) }) }) diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index e64357d281637c..5d7998e0055d9d 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -9,7 +9,14 @@ import { Field, FieldControl, FieldDescription, FieldLabel } from '@langgenius/d import { Form } from '@langgenius/dify-ui/form' import { Input } from '@langgenius/dify-ui/input' import { ScrollArea } from '@langgenius/dify-ui/scroll-area' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { Switch } from '@langgenius/dify-ui/switch' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' @@ -56,11 +63,13 @@ type SettingsSiteInfo = Pick< | 'use_icon_as_answer_icon' > -type SettingsAppIconSelection = AppIconSelection | { - type: 'link' - icon: string - url: string -} +type SettingsAppIconSelection = + | AppIconSelection + | { + type: 'link' + icon: string + url: string + } export type SettingsAppInfo = { id: string @@ -101,7 +110,7 @@ type SelectOption = { name: string } -const LANGUAGE_OPTIONS: SelectOption[] = languages.filter(item => item.supported) +const LANGUAGE_OPTIONS: SelectOption[] = languages.filter((item) => item.supported) const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => { const { @@ -136,34 +145,33 @@ const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => { const createAppIcon = (appInfo: ISettingsModalProps['appInfo']): SettingsAppIconSelection => { const { icon_type, icon, icon_background, icon_url } = appInfo.site - if (icon_type === 'image') - return { type: 'image', url: icon_url!, fileId: icon } + if (icon_type === 'image') return { type: 'image', url: icon_url!, fileId: icon } - if (icon_type === 'link') - return { type: 'link', icon, url: icon } + if (icon_type === 'link') return { type: 'link', icon, url: icon } return { type: 'emoji', icon, background: icon_background! } } -const getSettingsResetKey = (appInfo: ISettingsModalProps['appInfo']) => JSON.stringify([ - appInfo.id, - appInfo.enable_sso, - appInfo.site.title, - appInfo.site.description, - appInfo.site.chat_color_theme, - appInfo.site.chat_color_theme_inverted, - appInfo.site.copyright, - appInfo.site.privacy_policy, - appInfo.site.custom_disclaimer, - appInfo.site.input_placeholder, - appInfo.site.default_language, - appInfo.site.icon_type, - appInfo.site.icon, - appInfo.site.icon_background, - appInfo.site.icon_url, - appInfo.site.show_workflow_steps, - appInfo.site.use_icon_as_answer_icon, -]) +const getSettingsResetKey = (appInfo: ISettingsModalProps['appInfo']) => + JSON.stringify([ + appInfo.id, + appInfo.enable_sso, + appInfo.site.title, + appInfo.site.description, + appInfo.site.chat_color_theme, + appInfo.site.chat_color_theme_inverted, + appInfo.site.copyright, + appInfo.site.privacy_policy, + appInfo.site.custom_disclaimer, + appInfo.site.input_placeholder, + appInfo.site.default_language, + appInfo.site.icon_type, + appInfo.site.icon, + appInfo.site.icon_background, + appInfo.site.icon_url, + appInfo.site.show_workflow_steps, + appInfo.site.use_icon_as_answer_icon, + ]) const SettingsModal: FC<ISettingsModalProps> = ({ isChat, @@ -190,17 +198,20 @@ const SettingsModal: FC<ISettingsModalProps> = ({ const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext() const { setShowPricingModal } = useModalContext() const isCloudSandboxPlan = enableBilling && plan.type === Plan.sandbox - const selectedLanguage = LANGUAGE_OPTIONS.find(item => item.value === language) + const selectedLanguage = LANGUAGE_OPTIONS.find((item) => item.value === language) const inputPlaceholderLabelId = React.useId() const inputPlaceholderDescriptionId = React.useId() const inputPlaceholderValue = isCloudSandboxPlan ? '' : (inputInfo.inputPlaceholder ?? '') const copyrightSwitchValue = isCloudSandboxPlan ? false : inputInfo.copyrightSwitchValue - const showInputPlaceholderPreview = !isCloudSandboxPlan && inputPlaceholderValue.trim().length > 0 && !inputPlaceholderFocused + const showInputPlaceholderPreview = + !isCloudSandboxPlan && inputPlaceholderValue.trim().length > 0 && !inputPlaceholderFocused const inputPlaceholderField = ( <div className={cn( 'mt-2 flex h-10 items-center gap-2 rounded-lg border border-components-input-border-hover bg-components-input-bg-normal pr-1 pl-3 transition-colors', - !isCloudSandboxPlan && inputPlaceholderFocused && 'border-components-input-border-active bg-components-input-bg-active', + !isCloudSandboxPlan && + inputPlaceholderFocused && + 'border-components-input-border-active bg-components-input-bg-active', isCloudSandboxPlan && 'cursor-not-allowed opacity-60', )} > @@ -208,7 +219,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({ type="text" name="input_placeholder" value={inputPlaceholderValue} - onChange={e => setInputInfo(item => ({ ...item, inputPlaceholder: e.target.value }))} + onChange={(e) => setInputInfo((item) => ({ ...item, inputPlaceholder: e.target.value }))} onFocus={() => setInputPlaceholderFocused(true)} onBlur={() => setInputPlaceholderFocused(false)} disabled={isCloudSandboxPlan} @@ -216,7 +227,11 @@ const SettingsModal: FC<ISettingsModalProps> = ({ autoComplete="off" aria-labelledby={inputPlaceholderLabelId} aria-describedby={inputPlaceholderDescriptionId} - placeholder={t($ => $[`${prefixSettings}.more.inputPlaceholderPlaceholder`], { ns: 'appOverview' }) as string} + placeholder={ + t(($) => $[`${prefixSettings}.more.inputPlaceholderPlaceholder`], { + ns: 'appOverview', + }) as string + } className={cn( 'flex-1 bg-transparent body-md-regular outline-hidden', showInputPlaceholderPreview ? 'text-text-placeholder' : 'text-text-primary', @@ -233,15 +248,15 @@ const SettingsModal: FC<ISettingsModalProps> = ({ ) const handleLanguageChange = (nextValue: string | null) => { - const nextLanguage = LANGUAGE_OPTIONS.find(item => item.value === nextValue) - if (nextLanguage) - setLanguage(nextLanguage.value) + const nextLanguage = LANGUAGE_OPTIONS.find((item) => item.value === nextValue) + if (nextLanguage) setLanguage(nextLanguage.value) } const handlePlanClick = useCallback(() => { setShowPricingModal() }, [setShowPricingModal]) - const shouldResetForm = isShow && (!previousIsShow || settingsResetKey !== previousSettingsResetKey) + const shouldResetForm = + isShow && (!previousIsShow || settingsResetKey !== previousSettingsResetKey) if (isShow !== previousIsShow || shouldResetForm) { setPreviousIsShow(isShow) if (shouldResetForm) { @@ -258,13 +273,12 @@ const SettingsModal: FC<ISettingsModalProps> = ({ const handleFormSubmit = async () => { if (!inputInfo.title) { - toast.error(t($ => $['newApp.nameNotEmpty'], { ns: 'app' })) + toast.error(t(($) => $['newApp.nameNotEmpty'], { ns: 'app' })) return } const validateColorHex = (hex: string | null) => { - if (hex === null || hex?.length === 0) - return true + if (hex === null || hex?.length === 0) return true const regex = /#[A-F0-9]{6}/i const check = regex.test(hex) @@ -272,19 +286,18 @@ const SettingsModal: FC<ISettingsModalProps> = ({ } const validatePrivacyPolicy = (privacyPolicy: string | null) => { - if (privacyPolicy === null || privacyPolicy?.length === 0) - return true + if (privacyPolicy === null || privacyPolicy?.length === 0) return true return privacyPolicy.startsWith('http://') || privacyPolicy.startsWith('https://') } if (inputInfo !== null) { if (!validateColorHex(inputInfo.chatColorTheme)) { - toast.error(t($ => $[`${prefixSettings}.invalidHexMessage`], { ns: 'appOverview' })) + toast.error(t(($) => $[`${prefixSettings}.invalidHexMessage`], { ns: 'appOverview' })) return } if (!validatePrivacyPolicy(inputInfo.privacyPolicy)) { - toast.error(t($ => $[`${prefixSettings}.invalidPrivacyPolicy`], { ns: 'appOverview' })) + toast.error(t(($) => $[`${prefixSettings}.invalidPrivacyPolicy`], { ns: 'appOverview' })) return } } @@ -297,16 +310,18 @@ const SettingsModal: FC<ISettingsModalProps> = ({ chat_color_theme: inputInfo.chatColorTheme, chat_color_theme_inverted: inputInfo.chatColorThemeInverted, prompt_public: false, - copyright: (!webappCopyrightEnabled || isCloudSandboxPlan) - ? '' - : copyrightSwitchValue - ? inputInfo.copyright - : '', + copyright: + !webappCopyrightEnabled || isCloudSandboxPlan + ? '' + : copyrightSwitchValue + ? inputInfo.copyright + : '', privacy_policy: inputInfo.privacyPolicy, custom_disclaimer: inputInfo.customDisclaimer, - input_placeholder: (isCloudSandboxPlan || !INPUT_PLACEHOLDER_SUPPORTED_MODES.includes(appInfo.mode)) - ? '' - : (inputInfo.inputPlaceholder ?? '').slice(0, INPUT_PLACEHOLDER_MAX_LENGTH), + input_placeholder: + isCloudSandboxPlan || !INPUT_PLACEHOLDER_SUPPORTED_MODES.includes(appInfo.mode) + ? '' + : (inputInfo.inputPlaceholder ?? '').slice(0, INPUT_PLACEHOLDER_MAX_LENGTH), icon_type: appIcon.type, icon: appIcon.type === 'image' ? appIcon.fileId : appIcon.icon, icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, @@ -322,34 +337,37 @@ const SettingsModal: FC<ISettingsModalProps> = ({ const onChange = (field: string) => { return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { let value: string | boolean - if (e.target.type === 'checkbox') - value = (e.target as HTMLInputElement).checked - else - value = e.target.value + if (e.target.type === 'checkbox') value = (e.target as HTMLInputElement).checked + else value = e.target.value - setInputInfo(item => ({ ...item, [field]: value })) + setInputInfo((item) => ({ ...item, [field]: value })) } } const onDesChange = (value: string) => { - setInputInfo(item => ({ ...item, desc: value })) + setInputInfo((item) => ({ ...item, desc: value })) } return ( <> - <Dialog open={isShow} onOpenChange={open => !open && handleClose()} disablePointerDismissal> + <Dialog open={isShow} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal> <DialogContent className="grid max-h-[calc(100dvh-2rem)] w-[520px] grid-rows-[auto_minmax(0,1fr)] overflow-hidden p-0"> {/* header */} <div className="shrink-0 pt-5 pr-5 pb-3 pl-6"> <div className="flex items-center gap-1"> - <DialogTitle className="grow title-2xl-semi-bold text-text-primary">{t($ => $[`${prefixSettings}.title`], { ns: 'appOverview' })}</DialogTitle> + <DialogTitle className="grow title-2xl-semi-bold text-text-primary"> + {t(($) => $[`${prefixSettings}.title`], { ns: 'appOverview' })} + </DialogTitle> <DialogCloseButton className="relative top-auto right-auto shrink-0" /> </div> <div className="mt-0.5 system-xs-regular text-text-tertiary"> - <span>{t($ => $[`${prefixSettings}.modalTip`], { ns: 'appOverview' })}</span> + <span>{t(($) => $[`${prefixSettings}.modalTip`], { ns: 'appOverview' })}</span> </div> </div> - <Form className="grid min-h-0 grid-rows-[minmax(0,1fr)_auto]" onFormSubmit={handleFormSubmit}> + <Form + className="grid min-h-0 grid-rows-[minmax(0,1fr)_auto]" + onFormSubmit={handleFormSubmit} + > {/* form body */} <ScrollArea className="relative min-h-0" @@ -361,16 +379,20 @@ const SettingsModal: FC<ISettingsModalProps> = ({ {/* name & icon */} <div className="flex gap-4"> <Field name="title" className="grow"> - <FieldLabel>{t($ => $[`${prefixSettings}.webName`], { ns: 'appOverview' })}</FieldLabel> + <FieldLabel> + {t(($) => $[`${prefixSettings}.webName`], { ns: 'appOverview' })} + </FieldLabel> <FieldControl value={inputInfo.title} - onValueChange={value => setInputInfo(item => ({ ...item, title: value }))} - placeholder={t($ => $.appNamePlaceholder, { ns: 'app' }) || ''} + onValueChange={(value) => setInputInfo((item) => ({ ...item, title: value }))} + placeholder={t(($) => $.appNamePlaceholder, { ns: 'app' }) || ''} /> </Field> <AppIcon size="xxl" - onClick={() => { setShowAppIconPicker(true) }} + onClick={() => { + setShowAppIconPicker(true) + }} className="mt-2 cursor-pointer" iconType={appIcon.type === 'link' ? 'image' : appIcon.type} icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} @@ -380,44 +402,58 @@ const SettingsModal: FC<ISettingsModalProps> = ({ </div> {/* description */} <Field name="description"> - <FieldLabel>{t($ => $[`${prefixSettings}.webDesc`], { ns: 'appOverview' })}</FieldLabel> + <FieldLabel> + {t(($) => $[`${prefixSettings}.webDesc`], { ns: 'appOverview' })} + </FieldLabel> <Textarea value={inputInfo.desc} onValueChange={onDesChange} - placeholder={t($ => $[`${prefixSettings}.webDescPlaceholder`], { ns: 'appOverview' }) as string} + placeholder={ + t(($) => $[`${prefixSettings}.webDescPlaceholder`], { + ns: 'appOverview', + }) as string + } /> - <FieldDescription>{t($ => $[`${prefixSettings}.webDescTip`], { ns: 'appOverview' })}</FieldDescription> + <FieldDescription> + {t(($) => $[`${prefixSettings}.webDescTip`], { ns: 'appOverview' })} + </FieldDescription> </Field> <Divider className="my-0 h-px" /> {/* answer icon */} {isChat && ( <Field name="use_icon_as_answer_icon" className="w-full"> <div className="flex items-center justify-between gap-3"> - <FieldLabel>{t($ => $['answerIcon.title'], { ns: 'app' })}</FieldLabel> + <FieldLabel>{t(($) => $['answerIcon.title'], { ns: 'app' })}</FieldLabel> <Switch checked={inputInfo.use_icon_as_answer_icon} - onCheckedChange={v => setInputInfo({ ...inputInfo, use_icon_as_answer_icon: v })} + onCheckedChange={(v) => + setInputInfo({ ...inputInfo, use_icon_as_answer_icon: v }) + } /> </div> - <FieldDescription>{t($ => $['answerIcon.description'], { ns: 'app' })}</FieldDescription> + <FieldDescription> + {t(($) => $['answerIcon.description'], { ns: 'app' })} + </FieldDescription> </Field> )} {/* language */} <div className="flex items-center"> - <div className={cn('grow py-1 system-sm-semibold text-text-secondary')}>{t($ => $[`${prefixSettings}.language`], { ns: 'appOverview' })}</div> + <div className={cn('grow py-1 system-sm-semibold text-text-secondary')}> + {t(($) => $[`${prefixSettings}.language`], { ns: 'appOverview' })} + </div> <Select value={selectedLanguage?.value ?? null} onValueChange={handleLanguageChange} > <SelectTrigger - aria-label={t($ => $[`${prefixSettings}.language`], { ns: 'appOverview' })} + aria-label={t(($) => $[`${prefixSettings}.language`], { ns: 'appOverview' })} size="medium" className="w-[200px]" > - {selectedLanguage?.name ?? t($ => $['placeholder.select'], { ns: 'common' })} + {selectedLanguage?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })} </SelectTrigger> <SelectContent> - {LANGUAGE_OPTIONS.map(item => ( + {LANGUAGE_OPTIONS.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -430,19 +466,34 @@ const SettingsModal: FC<ISettingsModalProps> = ({ {isChat && ( <div className="flex items-center"> <div className="grow"> - <div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t($ => $[`${prefixSettings}.chatColorTheme`], { ns: 'appOverview' })}</div> - <div className="pb-0.5 body-xs-regular text-text-tertiary">{t($ => $[`${prefixSettings}.chatColorThemeDesc`], { ns: 'appOverview' })}</div> + <div className={cn('py-1 system-sm-semibold text-text-secondary')}> + {t(($) => $[`${prefixSettings}.chatColorTheme`], { ns: 'appOverview' })} + </div> + <div className="pb-0.5 body-xs-regular text-text-tertiary"> + {t(($) => $[`${prefixSettings}.chatColorThemeDesc`], { ns: 'appOverview' })} + </div> </div> <Field name="chat_color_theme" className="w-[200px] shrink-0"> <FieldControl className="mb-1" value={inputInfo.chatColorTheme ?? ''} - onValueChange={value => setInputInfo(item => ({ ...item, chatColorTheme: value }))} + onValueChange={(value) => + setInputInfo((item) => ({ ...item, chatColorTheme: value })) + } placeholder="E.g #A020F0" /> <div className="flex items-center justify-between gap-2 body-xs-regular text-text-tertiary"> - <span>{t($ => $[`${prefixSettings}.chatColorThemeInverted`], { ns: 'appOverview' })}</span> - <Switch checked={inputInfo.chatColorThemeInverted} onCheckedChange={v => setInputInfo({ ...inputInfo, chatColorThemeInverted: v })}></Switch> + <span> + {t(($) => $[`${prefixSettings}.chatColorThemeInverted`], { + ns: 'appOverview', + })} + </span> + <Switch + checked={inputInfo.chatColorThemeInverted} + onCheckedChange={(v) => + setInputInfo({ ...inputInfo, chatColorThemeInverted: v }) + } + ></Switch> </div> </Field> </div> @@ -450,14 +501,23 @@ const SettingsModal: FC<ISettingsModalProps> = ({ {/* workflow detail */} <Field name="show_workflow_steps" className="w-full"> <div className="flex items-center justify-between gap-3"> - <FieldLabel>{t($ => $[`${prefixSettings}.workflow.subTitle`], { ns: 'appOverview' })}</FieldLabel> + <FieldLabel> + {t(($) => $[`${prefixSettings}.workflow.subTitle`], { ns: 'appOverview' })} + </FieldLabel> <Switch - disabled={!(appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT)} + disabled={ + !( + appInfo.mode === AppModeEnum.WORKFLOW || + appInfo.mode === AppModeEnum.ADVANCED_CHAT + ) + } checked={inputInfo.show_workflow_steps} - onCheckedChange={v => setInputInfo({ ...inputInfo, show_workflow_steps: v })} + onCheckedChange={(v) => setInputInfo({ ...inputInfo, show_workflow_steps: v })} /> </div> - <FieldDescription>{t($ => $[`${prefixSettings}.workflow.showDesc`], { ns: 'appOverview' })}</FieldDescription> + <FieldDescription> + {t(($) => $[`${prefixSettings}.workflow.showDesc`], { ns: 'appOverview' })} + </FieldDescription> </Field> <Divider className="my-0 h-px" /> <div className="space-y-5"> @@ -465,14 +525,24 @@ const SettingsModal: FC<ISettingsModalProps> = ({ <div className="w-full"> <div className="flex items-center"> <div className="flex grow items-center"> - <div id={inputPlaceholderLabelId} className={cn('mr-1 py-1 system-sm-semibold text-text-secondary')}>{t($ => $[`${prefixSettings}.more.inputPlaceholder`], { ns: 'appOverview' })}</div> + <div + id={inputPlaceholderLabelId} + className={cn('mr-1 py-1 system-sm-semibold text-text-secondary')} + > + {t(($) => $[`${prefixSettings}.more.inputPlaceholder`], { + ns: 'appOverview', + })} + </div> {isCloudSandboxPlan && ( <div className="h-[18px] select-none"> <PremiumBadgeButton size="s" color="blue" onClick={handlePlanClick}> - <span aria-hidden="true" className="i-custom-public-common-sparkles-soft flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> + <span + aria-hidden="true" + className="i-custom-public-common-sparkles-soft flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" + /> <div className="system-xs-medium"> <span className="p-1"> - {t($ => $['upgradeBtn.encourageShort'], { ns: 'billing' })} + {t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })} </span> </div> </PremiumBadgeButton> @@ -480,17 +550,26 @@ const SettingsModal: FC<ISettingsModalProps> = ({ )} </div> </div> - <p id={inputPlaceholderDescriptionId} className="pb-0.5 body-xs-regular text-text-tertiary">{t($ => $[`${prefixSettings}.more.inputPlaceholderTip`], { ns: 'appOverview' })}</p> - {isCloudSandboxPlan - ? ( - <Tooltip> - <TooltipTrigger render={inputPlaceholderField} /> - <TooltipContent className="w-[180px]"> - {t($ => $[`${prefixSettings}.more.inputPlaceholderTooltip`], { ns: 'appOverview' })} - </TooltipContent> - </Tooltip> - ) - : inputPlaceholderField} + <p + id={inputPlaceholderDescriptionId} + className="pb-0.5 body-xs-regular text-text-tertiary" + > + {t(($) => $[`${prefixSettings}.more.inputPlaceholderTip`], { + ns: 'appOverview', + })} + </p> + {isCloudSandboxPlan ? ( + <Tooltip> + <TooltipTrigger render={inputPlaceholderField} /> + <TooltipContent className="w-[180px]"> + {t(($) => $[`${prefixSettings}.more.inputPlaceholderTooltip`], { + ns: 'appOverview', + })} + </TooltipContent> + </Tooltip> + ) : ( + inputPlaceholderField + )} {!isCloudSandboxPlan && ( <div className="mt-1 text-right body-xs-regular text-text-tertiary"> {`${inputInfo.inputPlaceholder?.length ?? 0} / ${INPUT_PLACEHOLDER_MAX_LENGTH}`} @@ -502,105 +581,163 @@ const SettingsModal: FC<ISettingsModalProps> = ({ <div className="w-full"> <div className="flex items-center"> <div className="flex grow items-center"> - <div className={cn('mr-1 py-1 system-sm-semibold text-text-secondary')}>{t($ => $[`${prefixSettings}.more.copyright`], { ns: 'appOverview' })}</div> + <div className={cn('mr-1 py-1 system-sm-semibold text-text-secondary')}> + {t(($) => $[`${prefixSettings}.more.copyright`], { ns: 'appOverview' })} + </div> {/* upgrade button */} {isCloudSandboxPlan && ( <div className="h-[18px] select-none"> <PremiumBadgeButton size="s" color="blue" onClick={handlePlanClick}> - <span aria-hidden="true" className="i-custom-public-common-sparkles-soft flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> + <span + aria-hidden="true" + className="i-custom-public-common-sparkles-soft flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" + /> <div className="system-xs-medium"> <span className="p-1"> - {t($ => $['upgradeBtn.encourageShort'], { ns: 'billing' })} + {t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })} </span> </div> </PremiumBadgeButton> </div> )} </div> - {webappCopyrightEnabled - ? ( - <Switch - aria-label={t($ => $[`${prefixSettings}.more.copyright`], { ns: 'appOverview' })} - checked={copyrightSwitchValue} - onCheckedChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })} - /> - ) - : ( - <Tooltip> - <TooltipTrigger - render={( - <div> - <Switch - aria-label={t($ => $[`${prefixSettings}.more.copyright`], { ns: 'appOverview' })} - disabled - checked={copyrightSwitchValue} - onCheckedChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })} - /> - </div> - )} - /> - <TooltipContent className="w-[180px]"> - {t($ => $[`${prefixSettings}.more.copyrightTooltip`], { ns: 'appOverview' })} - </TooltipContent> - </Tooltip> - )} + {webappCopyrightEnabled ? ( + <Switch + aria-label={t(($) => $[`${prefixSettings}.more.copyright`], { + ns: 'appOverview', + })} + checked={copyrightSwitchValue} + onCheckedChange={(v) => + setInputInfo({ ...inputInfo, copyrightSwitchValue: v }) + } + /> + ) : ( + <Tooltip> + <TooltipTrigger + render={ + <div> + <Switch + aria-label={t(($) => $[`${prefixSettings}.more.copyright`], { + ns: 'appOverview', + })} + disabled + checked={copyrightSwitchValue} + onCheckedChange={(v) => + setInputInfo({ ...inputInfo, copyrightSwitchValue: v }) + } + /> + </div> + } + /> + <TooltipContent className="w-[180px]"> + {t(($) => $[`${prefixSettings}.more.copyrightTooltip`], { + ns: 'appOverview', + })} + </TooltipContent> + </Tooltip> + )} </div> - <p className="pb-0.5 body-xs-regular text-text-tertiary">{t($ => $[`${prefixSettings}.more.copyrightTip`], { ns: 'appOverview' })}</p> + <p className="pb-0.5 body-xs-regular text-text-tertiary"> + {t(($) => $[`${prefixSettings}.more.copyrightTip`], { ns: 'appOverview' })} + </p> {copyrightSwitchValue && ( <Input - aria-label={t($ => $[`${prefixSettings}.more.copyright`], { ns: 'appOverview' })} + aria-label={t(($) => $[`${prefixSettings}.more.copyright`], { + ns: 'appOverview', + })} className="mt-2 h-10" value={inputInfo.copyright} onChange={onChange('copyright')} - placeholder={t($ => $[`${prefixSettings}.more.copyRightPlaceholder`], { ns: 'appOverview' }) as string} + placeholder={ + t(($) => $[`${prefixSettings}.more.copyRightPlaceholder`], { + ns: 'appOverview', + }) as string + } /> )} </div> {/* privacy policy */} <div className="w-full"> - <div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t($ => $[`${prefixSettings}.more.privacyPolicy`], { ns: 'appOverview' })}</div> + <div className={cn('py-1 system-sm-semibold text-text-secondary')}> + {t(($) => $[`${prefixSettings}.more.privacyPolicy`], { ns: 'appOverview' })} + </div> <p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}> <Trans - i18nKey={$ => $[`${prefixSettings}.more.privacyPolicyTip`]} + i18nKey={($) => $[`${prefixSettings}.more.privacyPolicyTip`]} ns="appOverview" - components={{ privacyPolicyLink: <Link href="https://dify.ai/privacy" target="_blank" rel="noopener noreferrer" className="text-text-accent" /> }} + components={{ + privacyPolicyLink: ( + <Link + href="https://dify.ai/privacy" + target="_blank" + rel="noopener noreferrer" + className="text-text-accent" + /> + ), + }} /> </p> <Input - aria-label={t($ => $[`${prefixSettings}.more.privacyPolicy`], { ns: 'appOverview' })} + aria-label={t(($) => $[`${prefixSettings}.more.privacyPolicy`], { + ns: 'appOverview', + })} className="mt-1" value={inputInfo.privacyPolicy} onChange={onChange('privacyPolicy')} - placeholder={t($ => $[`${prefixSettings}.more.privacyPolicyPlaceholder`], { ns: 'appOverview' }) as string} + placeholder={ + t(($) => $[`${prefixSettings}.more.privacyPolicyPlaceholder`], { + ns: 'appOverview', + }) as string + } /> </div> {/* custom disclaimer */} <div className="w-full"> - <div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t($ => $[`${prefixSettings}.more.customDisclaimer`], { ns: 'appOverview' })}</div> - <p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t($ => $[`${prefixSettings}.more.customDisclaimerTip`], { ns: 'appOverview' })}</p> + <div className={cn('py-1 system-sm-semibold text-text-secondary')}> + {t(($) => $[`${prefixSettings}.more.customDisclaimer`], { ns: 'appOverview' })} + </div> + <p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}> + {t(($) => $[`${prefixSettings}.more.customDisclaimerTip`], { + ns: 'appOverview', + })} + </p> <Textarea - aria-label={t($ => $[`${prefixSettings}.more.customDisclaimer`], { ns: 'appOverview' })} + aria-label={t(($) => $[`${prefixSettings}.more.customDisclaimer`], { + ns: 'appOverview', + })} className="mt-1" value={inputInfo.customDisclaimer} - onValueChange={value => setInputInfo(item => ({ ...item, customDisclaimer: value }))} - placeholder={t($ => $[`${prefixSettings}.more.customDisclaimerPlaceholder`], { ns: 'appOverview' }) as string} + onValueChange={(value) => + setInputInfo((item) => ({ ...item, customDisclaimer: value })) + } + placeholder={ + t(($) => $[`${prefixSettings}.more.customDisclaimerPlaceholder`], { + ns: 'appOverview', + }) as string + } /> </div> </div> </ScrollArea> {/* footer */} <div className="flex shrink-0 justify-end p-6 pt-5"> - <Button type="button" className="mr-2" onClick={handleClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button type="submit" variant="primary" loading={saveLoading}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button type="button" className="mr-2" onClick={handleClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button type="submit" variant="primary" loading={saveLoading}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> </Form> </DialogContent> </Dialog> <AppIconPicker open={showAppIconPicker} - initialEmoji={appIcon.type === 'emoji' - ? { icon: appIcon.icon, background: appIcon.background } - : undefined} + initialEmoji={ + appIcon.type === 'emoji' + ? { icon: appIcon.icon, background: appIcon.background } + : undefined + } onOpenChange={setShowAppIconPicker} onSelect={setAppIcon} /> diff --git a/web/app/components/app/overview/style.module.css b/web/app/components/app/overview/style.module.css index ce7cefe2ca4b80..4e11433048b32d 100644 --- a/web/app/components/app/overview/style.module.css +++ b/web/app/components/app/overview/style.module.css @@ -15,7 +15,7 @@ } .codeBrowserIcon { - @apply w-4 h-4 bg-center bg-no-repeat; + @apply h-4 w-4 bg-center bg-no-repeat; background-image: url(./assets/code-browser.svg); } @@ -30,4 +30,3 @@ background-position: center; background-repeat: no-repeat; } - diff --git a/web/app/components/app/overview/trigger-card.tsx b/web/app/components/app/overview/trigger-card.tsx index 4e440a58c538a8..0bef6ca1766ec4 100644 --- a/web/app/components/app/overview/trigger-card.tsx +++ b/web/app/components/app/overview/trigger-card.tsx @@ -17,7 +17,6 @@ import { useDocLink } from '@/context/i18n' import { workspacePermissionKeysAtom } from '@/context/permission-state' import Link from '@/next/link' import { - useAppTriggers, useInvalidateAppTriggers, useUpdateTriggerStatus, @@ -53,21 +52,18 @@ const getTriggerIcon = (trigger: AppTrigger, triggerPlugins: TriggerWithProvider let triggerIcon: string | undefined if (trigger_type === 'trigger-plugin' && provider_name) { const targetTriggers = triggerPlugins || [] - const foundTrigger = targetTriggers.find(triggerWithProvider => - canFindTool(triggerWithProvider.id, provider_name) - || triggerWithProvider.id.includes(provider_name) - || triggerWithProvider.name === provider_name, + const foundTrigger = targetTriggers.find( + (triggerWithProvider) => + canFindTool(triggerWithProvider.id, provider_name) || + triggerWithProvider.id.includes(provider_name) || + triggerWithProvider.name === provider_name, ) triggerIcon = typeof foundTrigger?.icon === 'string' ? foundTrigger.icon : undefined } return ( <div className="relative"> - <BlockIcon - type={blockType} - size="md" - toolIcon={triggerIcon} - /> + <BlockIcon type={blockType} size="md" toolIcon={triggerIcon} /> <StatusDot className="absolute -top-0.5 -left-0.5" size="small" @@ -83,11 +79,15 @@ function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) { const appId = appInfo.id const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const canEditApp = React.useMemo(() => getAppACLCapabilities(appInfo.permission_keys, { - currentUserId, - resourceMaintainer: appInfo.maintainer, - workspacePermissionKeys, - }).canEdit, [appInfo.maintainer, appInfo.permission_keys, currentUserId, workspacePermissionKeys]) + const canEditApp = React.useMemo( + () => + getAppACLCapabilities(appInfo.permission_keys, { + currentUserId, + resourceMaintainer: appInfo.maintainer, + workspacePermissionKeys, + }).canEdit, + [appInfo.maintainer, appInfo.permission_keys, currentUserId, workspacePermissionKeys], + ) const { data: triggersResponse, isLoading } = useAppTriggers(appId) const { mutateAsync: updateTriggerStatus } = useUpdateTriggerStatus() const invalidateAppTriggers = useInvalidateAppTriggers() @@ -102,11 +102,14 @@ function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) { // Sync trigger statuses to Zustand store when data loads initially or after API calls React.useEffect(() => { if (triggers.length > 0) { - const statusMap = triggers.reduce((acc, trigger) => { - // Map API status to EntryNodeStatus: only 'enabled' shows green, others show gray - acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled' - return acc - }, {} as Record<string, 'enabled' | 'disabled'>) + const statusMap = triggers.reduce( + (acc, trigger) => { + // Map API status to EntryNodeStatus: only 'enabled' shows green, others show gray + acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled' + return acc + }, + {} as Record<string, 'enabled' | 'disabled'>, + ) // Only update if there are actual changes to prevent overriding optimistic updates setTriggerStatuses(statusMap) @@ -114,8 +117,7 @@ function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) { }, [triggers, setTriggerStatuses]) const onToggleTrigger = async (trigger: AppTrigger, enabled: boolean) => { - if (!canEditApp) - return + if (!canEditApp) return try { // Immediately update Zustand store for real-time UI sync @@ -131,8 +133,7 @@ function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) { // Success toast notification onToggleResult?.(null) - } - catch (error) { + } catch (error) { // Rollback Zustand store state on error const rollbackStatus = enabled ? 'disabled' : 'enabled' setTriggerStatus(trigger.node_id, rollbackStatus) @@ -166,8 +167,11 @@ function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) { <div className="group w-full"> <div className="min-w-0 overflow-hidden system-md-semibold break-normal text-ellipsis text-text-secondary group-hover:text-text-primary"> {triggerCount > 0 - ? t($ => $['overview.triggerInfo.triggersAdded'], { ns: 'appOverview', count: triggerCount }) - : t($ => $['overview.triggerInfo.noTriggerAdded'], { ns: 'appOverview' })} + ? t(($) => $['overview.triggerInfo.triggersAdded'], { + ns: 'appOverview', + count: triggerCount, + }) + : t(($) => $['overview.triggerInfo.noTriggerAdded'], { ns: 'appOverview' })} </div> </div> </div> @@ -176,27 +180,27 @@ function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) { {triggerCount > 0 && ( <div className="flex flex-col gap-2 p-3"> - {triggers.map(trigger => ( + {triggers.map((trigger) => ( <div key={trigger.id} className="flex w-full items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-2"> - <div className="shrink-0"> - {getTriggerIcon(trigger, triggerPlugins || [])} - </div> + <div className="shrink-0">{getTriggerIcon(trigger, triggerPlugins || [])}</div> <div className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary"> {trigger.title} </div> </div> <div className="flex shrink-0 items-center"> - <div className={`${trigger.status === 'enabled' ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase whitespace-nowrap`}> + <div + className={`${trigger.status === 'enabled' ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase whitespace-nowrap`} + > {trigger.status === 'enabled' - ? t($ => $['overview.status.running'], { ns: 'appOverview' }) - : t($ => $['overview.status.disable'], { ns: 'appOverview' })} + ? t(($) => $['overview.status.running'], { ns: 'appOverview' }) + : t(($) => $['overview.status.disable'], { ns: 'appOverview' })} </div> </div> <div className="shrink-0"> <Switch checked={trigger.status === 'enabled'} - onCheckedChange={enabled => onToggleTrigger(trigger, enabled)} + onCheckedChange={(enabled) => onToggleTrigger(trigger, enabled)} disabled={!canEditApp} /> </div> @@ -208,15 +212,14 @@ function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) { {triggerCount === 0 && ( <div className="p-3"> <div className="system-xs-regular leading-4 text-text-tertiary"> - {t($ => $['overview.triggerInfo.triggerStatusDescription'], { ns: 'appOverview' })} - {' '} + {t(($) => $['overview.triggerInfo.triggerStatusDescription'], { ns: 'appOverview' })}{' '} <Link href={docLink('/use-dify/nodes/trigger/overview')} target="_blank" rel="noopener noreferrer" className="text-text-accent hover:underline" > - {t($ => $['overview.triggerInfo.learnAboutTriggers'], { ns: 'appOverview' })} + {t(($) => $['overview.triggerInfo.learnAboutTriggers'], { ns: 'appOverview' })} </Link> </div> </div> diff --git a/web/app/components/app/overview/workflow-hidden-input-fields.tsx b/web/app/components/app/overview/workflow-hidden-input-fields.tsx index 750c7b5a4055d4..3ad789bf789dfb 100644 --- a/web/app/components/app/overview/workflow-hidden-input-fields.tsx +++ b/web/app/components/app/overview/workflow-hidden-input-fields.tsx @@ -9,7 +9,6 @@ import { } from '@langgenius/dify-ui/select' import { Textarea } from '@langgenius/dify-ui/textarea' import Input from '@/app/components/base/input' - import { InputVarType } from '@/app/components/workflow/types' type WorkflowHiddenInputFieldsProps = { @@ -34,13 +33,13 @@ const WorkflowHiddenInputFields = ({ return ( <Select value={typeof fieldValue === 'string' ? fieldValue : ''} - onValueChange={value => onValueChange(variable.variable, value ?? '')} + onValueChange={(value) => onValueChange(variable.variable, value ?? '')} > <SelectTrigger className="w-full" aria-label={label}> <SelectValue placeholder={label} /> </SelectTrigger> <SelectContent> - {(variable.options ?? []).map(option => ( + {(variable.options ?? []).map((option) => ( <SelectItem key={option} value={option}> {option} </SelectItem> @@ -57,7 +56,9 @@ const WorkflowHiddenInputFields = ({ id={fieldId} type="checkbox" checked={Boolean(fieldValue)} - onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(variable.variable, event.target.checked)} + onChange={(event: ChangeEvent<HTMLInputElement>) => + onValueChange(variable.variable, event.target.checked) + } className="size-4 rounded border-divider-subtle" /> <span className="system-sm-regular text-text-secondary">{label}</span> @@ -66,15 +67,15 @@ const WorkflowHiddenInputFields = ({ } if ( - variable.type === InputVarType.paragraph - || variable.type === InputVarType.json - || variable.type === InputVarType.jsonObject + variable.type === InputVarType.paragraph || + variable.type === InputVarType.json || + variable.type === InputVarType.jsonObject ) { return ( <Textarea id={fieldId} value={typeof fieldValue === 'string' ? fieldValue : ''} - onValueChange={value => onValueChange(variable.variable, value)} + onValueChange={(value) => onValueChange(variable.variable, value)} placeholder={label} maxLength={variable.max_length} className="min-h-24" @@ -87,7 +88,9 @@ const WorkflowHiddenInputFields = ({ id={fieldId} type={variable.type === InputVarType.number ? 'number' : 'text'} value={typeof fieldValue === 'string' ? fieldValue : ''} - onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(variable.variable, event.target.value)} + onChange={(event: ChangeEvent<HTMLInputElement>) => + onValueChange(variable.variable, event.target.value) + } placeholder={label} maxLength={variable.max_length} /> @@ -96,7 +99,7 @@ const WorkflowHiddenInputFields = ({ return ( <> - {hiddenVariables.map(variable => ( + {hiddenVariables.map((variable) => ( <div key={variable.variable} className="space-y-1.5"> {variable.type !== InputVarType.checkbox && ( <label diff --git a/web/app/components/app/store.ts b/web/app/components/app/store.ts index 059bd160430028..85a4c6324a068f 100644 --- a/web/app/components/app/store.ts +++ b/web/app/components/app/store.ts @@ -22,29 +22,31 @@ type Action = { setShowAppConfigureFeaturesModal: (showAppConfigureFeaturesModal: boolean) => void } -export const useStore = create<State & Action>(set => ({ +export const useStore = create<State & Action>((set) => ({ appDetail: undefined, - setAppDetail: appDetail => set(() => ({ appDetail })), + setAppDetail: (appDetail) => set(() => ({ appDetail })), currentLogItem: undefined, currentLogModalActiveTab: 'DETAIL', - setCurrentLogItem: currentLogItem => set(() => ({ currentLogItem })), - setCurrentLogModalActiveTab: currentLogModalActiveTab => set(() => ({ currentLogModalActiveTab })), + setCurrentLogItem: (currentLogItem) => set(() => ({ currentLogItem })), + setCurrentLogModalActiveTab: (currentLogModalActiveTab) => + set(() => ({ currentLogModalActiveTab })), showPromptLogModal: false, - setShowPromptLogModal: showPromptLogModal => set(() => ({ showPromptLogModal })), + setShowPromptLogModal: (showPromptLogModal) => set(() => ({ showPromptLogModal })), showAgentLogModal: false, - setShowAgentLogModal: showAgentLogModal => set(() => ({ showAgentLogModal })), + setShowAgentLogModal: (showAgentLogModal) => set(() => ({ showAgentLogModal })), showMessageLogModal: false, - setShowMessageLogModal: showMessageLogModal => set(() => { - if (showMessageLogModal) { - return { showMessageLogModal } - } - else { - return { - showMessageLogModal, - currentLogModalActiveTab: 'DETAIL', + setShowMessageLogModal: (showMessageLogModal) => + set(() => { + if (showMessageLogModal) { + return { showMessageLogModal } + } else { + return { + showMessageLogModal, + currentLogModalActiveTab: 'DETAIL', + } } - } - }), + }), showAppConfigureFeaturesModal: false, - setShowAppConfigureFeaturesModal: showAppConfigureFeaturesModal => set(() => ({ showAppConfigureFeaturesModal })), + setShowAppConfigureFeaturesModal: (showAppConfigureFeaturesModal) => + set(() => ({ showAppConfigureFeaturesModal })), })) diff --git a/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx b/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx index 0964745c57b0ec..075c27955c62dd 100644 --- a/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx @@ -111,10 +111,14 @@ const toastMocks = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { - success: (message: string, options?: Record<string, unknown>) => toastMocks.notify({ type: 'success', message, ...options }), - error: (message: string, options?: Record<string, unknown>) => toastMocks.notify({ type: 'error', message, ...options }), - warning: (message: string, options?: Record<string, unknown>) => toastMocks.notify({ type: 'warning', message, ...options }), - info: (message: string, options?: Record<string, unknown>) => toastMocks.notify({ type: 'info', message, ...options }), + success: (message: string, options?: Record<string, unknown>) => + toastMocks.notify({ type: 'success', message, ...options }), + error: (message: string, options?: Record<string, unknown>) => + toastMocks.notify({ type: 'error', message, ...options }), + warning: (message: string, options?: Record<string, unknown>) => + toastMocks.notify({ type: 'warning', message, ...options }), + info: (message: string, options?: Record<string, unknown>) => + toastMocks.notify({ type: 'info', message, ...options }), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -134,7 +138,6 @@ const renderComponent = (overrides: Partial<React.ComponentProps<typeof SwitchAp onSuccess={onSuccess} {...overrides} />, - ) return { @@ -308,12 +311,14 @@ describe('SwitchAppModal', () => { await user.click(screen.getByRole('button', { name: 'app.switchStart' })) await waitFor(() => { - expect(mockSwitchApp).toHaveBeenCalledWith(expect.objectContaining({ - appID: appDetail.id, - icon_type: 'emoji', - icon: '🚀', - icon_background: '#E4FBCC', - })) + expect(mockSwitchApp).toHaveBeenCalledWith( + expect.objectContaining({ + appID: appDetail.id, + icon_type: 'emoji', + icon: '🚀', + icon_background: '#E4FBCC', + }), + ) }) }) @@ -335,7 +340,9 @@ describe('SwitchAppModal', () => { expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - expect(screen.queryByRole('button', { name: 'common.operation.confirm' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.confirm' }), + ).not.toBeInTheDocument() expect(screen.getByRole('checkbox')).not.toBeChecked() }) @@ -383,7 +390,10 @@ describe('SwitchAppModal', () => { // Assert await waitFor(() => { - expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' }) + expect(notify).toHaveBeenCalledWith({ + type: 'error', + message: 'app.newApp.appCreateFailed', + }) }) expect(onClose).not.toHaveBeenCalled() expect(onSuccess).not.toHaveBeenCalled() diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index e317449b5431e4..0f5b3dbfa31b7a 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -41,15 +41,21 @@ type SwitchAppModalProps = { inAppDetail?: boolean } -const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClose }: SwitchAppModalProps) => { +const SwitchAppModal = ({ + show, + appDetail, + inAppDetail = false, + onSuccess, + onClose, +}: SwitchAppModalProps) => { const { push, replace } = useRouter() const { t } = useTranslation() - const setAppDetail = useAppStore(s => s.setAppDetail) + const setAppDetail = useAppStore((s) => s.setAppDetail) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isRbacEnabled = systemFeatures.rbac_enabled const { plan, enableBilling } = useProviderContext() - const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) + const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [appIcon, setAppIcon] = useState( @@ -73,39 +79,35 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, }) - if (onSuccess) - onSuccess() - if (onClose) - onClose() - toast.success(t($ => $['newApp.appCreated'], { ns: 'app' })) - if (inAppDetail) - setAppDetail() - if (removeOriginal) - await deleteApp(appDetail.id) + if (onSuccess) onSuccess() + if (onClose) onClose() + toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) + if (inAppDetail) setAppDetail() + if (removeOriginal) await deleteApp(appDetail.id) setNeedRefresh('1') getRedirection( { id: newAppID, - mode: appDetail.mode === AppModeEnum.COMPLETION ? AppModeEnum.WORKFLOW : AppModeEnum.ADVANCED_CHAT, + mode: + appDetail.mode === AppModeEnum.COMPLETION + ? AppModeEnum.WORKFLOW + : AppModeEnum.ADVANCED_CHAT, permission_keys, }, removeOriginal ? replace : push, { isRbacEnabled }, ) - } - catch { - toast.error(t($ => $['newApp.appCreateFailed'], { ns: 'app' })) + } catch { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) } } useEffect(() => { - if (removeOriginal) - setShowConfirmDelete(true) + if (removeOriginal) setShowConfirmDelete(true) }, [removeOriginal]) const handleConfirmDeleteOpenChange = (open: boolean) => { - if (open) - return + if (open) return setShowConfirmDelete(false) setRemoveOriginal(false) @@ -114,12 +116,16 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo return ( <> <Dialog open={show}> - <DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', cn('w-[600px] max-w-[600px] p-8'))}> - + <DialogContent + className={cn( + 'w-full overflow-hidden! border-none text-left align-middle', + cn('w-[600px] max-w-[600px] p-8'), + )} + > <button type="button" className="absolute top-4 right-4 cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onClose} > <RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" /> @@ -127,18 +133,26 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo <div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-default-burn p-3 shadow-xl"> <AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" /> </div> - <div className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary">{t($ => $.switch, { ns: 'app' })}</div> + <div className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary"> + {t(($) => $.switch, { ns: 'app' })} + </div> <div className="my-1 text-sm/5 text-text-tertiary"> - <span>{t($ => $.switchTipStart, { ns: 'app' })}</span> - <span className="font-medium text-text-secondary">{t($ => $.switchTip, { ns: 'app' })}</span> - <span>{t($ => $.switchTipEnd, { ns: 'app' })}</span> + <span>{t(($) => $.switchTipStart, { ns: 'app' })}</span> + <span className="font-medium text-text-secondary"> + {t(($) => $.switchTip, { ns: 'app' })} + </span> + <span>{t(($) => $.switchTipEnd, { ns: 'app' })}</span> </div> <div className="pb-4"> - <div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t($ => $.switchLabel, { ns: 'app' })}</div> + <div className="py-2 text-sm leading-[20px] font-medium text-text-primary"> + {t(($) => $.switchLabel, { ns: 'app' })} + </div> <div className="flex items-center justify-between space-x-2"> <AppIcon size="large" - onClick={() => { setShowAppIconPicker(true) }} + onClick={() => { + setShowAppIconPicker(true) + }} className="cursor-pointer" iconType={appIcon.type} icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} @@ -147,17 +161,19 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo /> <Input value={name} - onChange={e => setName(e.target.value)} - placeholder={t($ => $['newApp.appNamePlaceholder'], { ns: 'app' }) || ''} + onChange={(e) => setName(e.target.value)} + placeholder={t(($) => $['newApp.appNamePlaceholder'], { ns: 'app' }) || ''} className="h-10 grow" /> </div> {showAppIconPicker && ( <AppIconPicker open={showAppIconPicker} - initialEmoji={appIcon.type === 'emoji' - ? { icon: appIcon.icon, background: appIcon.background } - : undefined} + initialEmoji={ + appIcon.type === 'emoji' + ? { icon: appIcon.icon, background: appIcon.background } + : undefined + } onOpenChange={setShowAppIconPicker} onSelect={(payload) => { setAppIcon(payload) @@ -169,38 +185,49 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo <div className="flex items-center justify-between pt-6"> <div className="flex items-center"> <label className="flex cursor-pointer items-center"> - <Checkbox className="shrink-0" checked={removeOriginal} onCheckedChange={setRemoveOriginal} /> + <Checkbox + className="shrink-0" + checked={removeOriginal} + onCheckedChange={setRemoveOriginal} + /> <span className="ml-2 text-left text-sm/5 text-text-secondary"> - {t($ => $.removeOriginal, { ns: 'app' })} + {t(($) => $.removeOriginal, { ns: 'app' })} </span> </label> </div> <div className="flex items-center"> - <Button className="mr-2" onClick={onClose}>{t($ => $['newApp.Cancel'], { ns: 'app' })}</Button> - <Button className="border-red-700" disabled={isAppsFull || !name} variant="primary" tone="destructive" onClick={goStart}>{t($ => $.switchStart, { ns: 'app' })}</Button> + <Button className="mr-2" onClick={onClose}> + {t(($) => $['newApp.Cancel'], { ns: 'app' })} + </Button> + <Button + className="border-red-700" + disabled={isAppsFull || !name} + variant="primary" + tone="destructive" + onClick={goStart} + > + {t(($) => $.switchStart, { ns: 'app' })} + </Button> </div> </div> </DialogContent> </Dialog> - <AlertDialog - open={showConfirmDelete} - onOpenChange={handleConfirmDeleteOpenChange} - > + <AlertDialog open={showConfirmDelete} onOpenChange={handleConfirmDeleteOpenChange}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $.deleteAppConfirmTitle, { ns: 'app' })} + {t(($) => $.deleteAppConfirmTitle, { ns: 'app' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $.deleteAppConfirmContent, { ns: 'app' })} + {t(($) => $.deleteAppConfirmContent, { ns: 'app' })} </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={() => setShowConfirmDelete(false)}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/app/text-generate/item/__tests__/action-groups.spec.tsx b/web/app/components/app/text-generate/item/__tests__/action-groups.spec.tsx index aa6d8aa264ab93..685ee23bb44e97 100644 --- a/web/app/components/app/text-generate/item/__tests__/action-groups.spec.tsx +++ b/web/app/components/app/text-generate/item/__tests__/action-groups.spec.tsx @@ -46,7 +46,9 @@ describe('GenerationActionGroups', () => { fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.copy(?=$|:)/ })) expect(mockCopy).toHaveBeenCalledWith('hello world') - expect(mockSuccess).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)actionMsg\.copySuccessfully(?=$|:)/)) + expect(mockSuccess).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)actionMsg\.copySuccessfully(?=$|:)/), + ) }) it('should handle more-like-this and feedback actions', () => { @@ -70,7 +72,9 @@ describe('GenerationActionGroups', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)feature\.moreLikeThis\.title(?=$|:)/ })) + fireEvent.click( + screen.getByRole('button', { name: /(?:^|\.)feature\.moreLikeThis\.title(?=$|:)/ }), + ) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.agree(?=$|:)/ })) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.save(?=$|:)/ })) @@ -96,7 +100,9 @@ describe('GenerationActionGroups', () => { />, ) - expect(screen.getByRole('button', { name: /(?:^|\.)feature\.moreLikeThis\.title(?=$|:)/ })).toBeDisabled() + expect( + screen.getByRole('button', { name: /(?:^|\.)feature\.moreLikeThis\.title(?=$|:)/ }), + ).toBeDisabled() }) it('should stringify non-string content before copying', () => { @@ -138,7 +144,9 @@ describe('GenerationActionGroups', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)generation\.batchFailed\.retry(?=$|:)/ })) + fireEvent.click( + screen.getByRole('button', { name: /(?:^|\.)generation\.batchFailed\.retry(?=$|:)/ }), + ) expect(mockOnRetry).toHaveBeenCalledTimes(1) }) @@ -201,7 +209,9 @@ describe('GenerationActionGroups', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.cancelDisagree(?=$|:)/ })) + fireEvent.click( + screen.getByRole('button', { name: /(?:^|\.)operation\.cancelDisagree(?=$|:)/ }), + ) expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null }) }) }) diff --git a/web/app/components/app/text-generate/item/__tests__/index.spec.tsx b/web/app/components/app/text-generate/item/__tests__/index.spec.tsx index 67a828720d01a0..771481f2eed699 100644 --- a/web/app/components/app/text-generate/item/__tests__/index.spec.tsx +++ b/web/app/components/app/text-generate/item/__tests__/index.spec.tsx @@ -36,10 +36,11 @@ vi.mock('@/service/debug', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({ - setCurrentLogItem: mockSetCurrentLogItem, - setShowPromptLogModal: mockSetShowPromptLogModal, - }), + useStore: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + setCurrentLogItem: mockSetCurrentLogItem, + setShowPromptLogModal: mockSetShowPromptLogModal, + }), })) vi.mock('@/app/components/base/chat/chat/context', () => ({ @@ -70,12 +71,21 @@ vi.mock('../workflow-body', () => ({ onSwitchTab, }: { currentTab: string - onSubmitHumanInputForm: (token: string, data: { inputs: Record<string, string>, action: string }) => Promise<void> + onSubmitHumanInputForm: ( + token: string, + data: { inputs: Record<string, string>; action: string }, + ) => Promise<void> onSwitchTab: (tab: string) => Promise<void> }) => ( <div> <div>{`workflow-body:${currentTab}`}</div> - <button onClick={() => void onSubmitHumanInputForm('token-1', { action: 'submit', inputs: { name: 'dify' } })}>submit-human-input</button> + <button + onClick={() => + void onSubmitHumanInputForm('token-1', { action: 'submit', inputs: { name: 'dify' } }) + } + > + submit-human-input + </button> <button onClick={() => void onSwitchTab('LOG')}>switch-workflow-tab</button> </div> ), @@ -111,7 +121,9 @@ describe('GenerationItem', () => { expect(screen.getByText('markdown:hello world')).toBeInTheDocument() await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)feature\.moreLikeThis\.title(?=$|:)/ })) + fireEvent.click( + screen.getByRole('button', { name: /(?:^|\.)feature\.moreLikeThis\.title(?=$|:)/ }), + ) }) await waitFor(() => { @@ -120,13 +132,19 @@ describe('GenerationItem', () => { expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('msg-1', AppSourceType.webApp, undefined) await act(async () => { - fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.agree(?=$|:)/ }).at(-1)!) + fireEvent.click( + screen.getAllByRole('button', { name: /(?:^|\.)operation\.agree(?=$|:)/ }).at(-1)!, + ) }) - expect(mockUpdateFeedback).toHaveBeenCalledWith({ - body: { rating: 'like' }, - url: '/messages/msg-2/feedbacks', - }, AppSourceType.webApp, undefined) + expect(mockUpdateFeedback).toHaveBeenCalledWith( + { + body: { rating: 'like' }, + url: '/messages/msg-2/feedbacks', + }, + AppSourceType.webApp, + undefined, + ) }) it('should open the prompt log modal with normalized log data', async () => { @@ -155,16 +173,18 @@ describe('GenerationItem', () => { appId: 'app-1', messageId: 'msg-1', }) - expect(mockSetCurrentLogItem).toHaveBeenCalledWith(expect.objectContaining({ - log: [ - { role: 'user', text: 'hello' }, - { - role: 'assistant', - text: 'assistant answer', - files: [{ belongs_to: 'assistant', id: 'file-1' }], - }, - ], - })) + expect(mockSetCurrentLogItem).toHaveBeenCalledWith( + expect.objectContaining({ + log: [ + { role: 'user', text: 'hello' }, + { + role: 'assistant', + text: 'assistant answer', + files: [{ belongs_to: 'assistant', id: 'file-1' }], + }, + ], + }), + ) expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true) }) @@ -178,9 +198,11 @@ describe('GenerationItem', () => { messageId="msg-1" onRetry={vi.fn()} siteInfo={null} - workflowProcessData={{ - resultText: 'workflow result', - } as any} + workflowProcessData={ + { + resultText: 'workflow result', + } as any + } />, ) @@ -206,9 +228,11 @@ describe('GenerationItem', () => { messageId="msg-1" onRetry={vi.fn()} siteInfo={null} - workflowProcessData={{ - resultText: 'workflow result', - } as any} + workflowProcessData={ + { + resultText: 'workflow result', + } as any + } />, ) @@ -248,7 +272,9 @@ describe('GenerationItem', () => { ) await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)feature\.moreLikeThis\.title(?=$|:)/ })) + fireEvent.click( + screen.getByRole('button', { name: /(?:^|\.)feature\.moreLikeThis\.title(?=$|:)/ }), + ) }) await waitFor(() => { @@ -306,10 +332,14 @@ describe('GenerationItem', () => { ) await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)feature\.moreLikeThis\.title(?=$|:)/ })) + fireEvent.click( + screen.getByRole('button', { name: /(?:^|\.)feature\.moreLikeThis\.title(?=$|:)/ }), + ) }) - expect(mockToastWarning).toHaveBeenCalledWith(expect.stringMatching(/(?:^|\.)errorMessage\.waitForResponse(?=$|:)/)) + expect(mockToastWarning).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)errorMessage\.waitForResponse(?=$|:)/), + ) expect(mockFetchMoreLikeThis).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx b/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx index 26a64bf2e7687b..6413a7a167078c 100644 --- a/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx +++ b/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx @@ -35,15 +35,17 @@ describe('ResultTab', () => { <ResultTab currentTab="RESULT" content="" - data={{ - resultText: 'Hello world', - files: [ - { - varName: 'attachments', - list: [{ id: 'file-1' }], - }, - ], - } as any} + data={ + { + resultText: 'Hello world', + files: [ + { + varName: 'attachments', + list: [{ id: 'file-1' }], + }, + ], + } as any + } />, ) @@ -53,12 +55,7 @@ describe('ResultTab', () => { }) it('should render the raw detail view on the detail tab', () => { - render( - <ResultTab - currentTab="DETAIL" - content='{"answer":"ok"}' - />, - ) + render(<ResultTab currentTab="DETAIL" content='{"answer":"ok"}' />) expect(screen.getByText('code-editor:{"answer":"ok"}')).toBeInTheDocument() }) diff --git a/web/app/components/app/text-generate/item/__tests__/utils.spec.ts b/web/app/components/app/text-generate/item/__tests__/utils.spec.ts index 5f5219351e0904..43f17b7e195594 100644 --- a/web/app/components/app/text-generate/item/__tests__/utils.spec.ts +++ b/web/app/components/app/text-generate/item/__tests__/utils.spec.ts @@ -9,31 +9,35 @@ import { describe('text generation utils', () => { it('should show workflow result tabs when workflow output is present', () => { - expect(shouldShowWorkflowResultTabs({ - resultText: 'done', - files: [], - } as any)).toBe(true) - expect(getDefaultGenerationTab({ - humanInputFormDataList: [{ formToken: 'token-1' }], - } as any)).toBe('RESULT') + expect( + shouldShowWorkflowResultTabs({ + resultText: 'done', + files: [], + } as any), + ).toBe(true) + expect( + getDefaultGenerationTab({ + humanInputFormDataList: [{ formToken: 'token-1' }], + } as any), + ).toBe('RESULT') }) it('should keep the detail tab when workflow output is empty', () => { - expect(shouldShowWorkflowResultTabs({ - files: [], - humanInputFilledFormDataList: [], - humanInputFormDataList: [], - resultText: '', - } as any)).toBe(false) + expect( + shouldShowWorkflowResultTabs({ + files: [], + humanInputFilledFormDataList: [], + humanInputFormDataList: [], + resultText: '', + } as any), + ).toBe(false) expect(getDefaultGenerationTab(undefined)).toBe('DETAIL') }) it('should build a prompt log item for array messages without duplicating assistant entries', () => { const logItem = buildPromptLogItem({ answer: 'final answer', - message: [ - { role: 'user', text: 'hello' }, - ], + message: [{ role: 'user', text: 'hello' }], message_files: [ { belongs_to: 'assistant', id: 'file-1' }, { belongs_to: 'user', id: 'file-2' }, @@ -62,10 +66,12 @@ describe('text generation utils', () => { it('should derive task labels and copy content', () => { expect(getGenerationTaskLabel('task-1', 1)).toBe('task-1') expect(getGenerationTaskLabel('task-1', 3)).toBe('task-1-2') - expect(getCopyContent({ - content: 'fallback', - isWorkflow: true, - workflowProcessData: { resultText: 'workflow-result' } as any, - })).toBe('workflow-result') + expect( + getCopyContent({ + content: 'fallback', + isWorkflow: true, + workflowProcessData: { resultText: 'workflow-result' } as any, + }), + ).toBe('workflow-result') }) }) diff --git a/web/app/components/app/text-generate/item/__tests__/workflow-body.spec.tsx b/web/app/components/app/text-generate/item/__tests__/workflow-body.spec.tsx index 391426a8443417..c24c8fdadf7190 100644 --- a/web/app/components/app/text-generate/item/__tests__/workflow-body.spec.tsx +++ b/web/app/components/app/text-generate/item/__tests__/workflow-body.spec.tsx @@ -10,7 +10,11 @@ vi.mock('@/app/components/base/chat/chat/answer/workflow-process', () => ({ vi.mock('@/app/components/base/chat/chat/answer/human-input-form-list', () => ({ default: ({ onHumanInputFormSubmit }: { onHumanInputFormSubmit: typeof mockSubmit }) => ( - <button onClick={() => onHumanInputFormSubmit('token-1', { inputs: { name: 'dify' }, action: 'submit' })}> + <button + onClick={() => + onHumanInputFormSubmit('token-1', { inputs: { name: 'dify' }, action: 'submit' }) + } + > submit-human-input </button> ), @@ -41,11 +45,13 @@ describe('WorkflowBody', () => { showResultTabs siteInfo={{ show_workflow_steps: true } as any} taskId="task-1" - workflowProcessData={{ - resultText: 'done', - humanInputFormDataList: [{ formToken: 'token-1' }], - humanInputFilledFormDataList: [{ id: 'filled-1' }], - } as any} + workflowProcessData={ + { + resultText: 'done', + humanInputFormDataList: [{ formToken: 'token-1' }], + humanInputFilledFormDataList: [{ id: 'filled-1' }], + } as any + } />, ) @@ -69,10 +75,12 @@ describe('WorkflowBody', () => { onSwitchTab={mockSwitchTab} showResultTabs siteInfo={null} - workflowProcessData={{ - resultText: 'done', - humanInputFormDataList: [{ formToken: 'token-1' }], - } as any} + workflowProcessData={ + { + resultText: 'done', + humanInputFormDataList: [{ formToken: 'token-1' }], + } as any + } />, ) diff --git a/web/app/components/app/text-generate/item/action-groups.tsx b/web/app/components/app/text-generate/item/action-groups.tsx index c2badcf70dd4b5..d8e89cc56904b0 100644 --- a/web/app/components/app/text-generate/item/action-groups.tsx +++ b/web/app/components/app/text-generate/item/action-groups.tsx @@ -71,12 +71,12 @@ const GenerationActionGroups: FC<GenerationActionGroupsProps> = ({ return ( <> - {!isInWebApp && (appSourceType !== AppSourceTypeEnum.installedApp) && !isResponding && ( + {!isInWebApp && appSourceType !== AppSourceTypeEnum.installedApp && !isResponding && ( <div className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs"> <ActionButton - aria-label={t($ => $['operation.log'], { ns: 'common' })} + aria-label={t(($) => $['operation.log'], { ns: 'common' })} disabled={isError || !messageId} - title={t($ => $['operation.log'], { ns: 'common' })} + title={t(($) => $['operation.log'], { ns: 'common' })} onClick={onOpenLogModal} > <RiFileList3Line className="size-4" /> @@ -86,33 +86,30 @@ const GenerationActionGroups: FC<GenerationActionGroupsProps> = ({ <div className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs"> {moreLikeThis && !isTryApp && ( <ActionButton - aria-label={t($ => $['feature.moreLikeThis.title'], { ns: 'appDebug' })} - state={depth === MAX_GENERATION_DEPTH ? ActionButtonState.Disabled : ActionButtonState.Default} + aria-label={t(($) => $['feature.moreLikeThis.title'], { ns: 'appDebug' })} + state={ + depth === MAX_GENERATION_DEPTH + ? ActionButtonState.Disabled + : ActionButtonState.Default + } disabled={depth === MAX_GENERATION_DEPTH} - title={t($ => $['feature.moreLikeThis.title'], { ns: 'appDebug' })} + title={t(($) => $['feature.moreLikeThis.title'], { ns: 'appDebug' })} onClick={onMoreLikeThis} > <RiSparklingLine className="size-4" /> </ActionButton> )} - {isShowTextToSpeech && !isTryApp && ( - <NewAudioButton - id={messageId!} - voice={voice} - /> - )} + {isShowTextToSpeech && !isTryApp && <NewAudioButton id={messageId!} voice={voice} />} {showCopyAction && ( <ActionButton - aria-label={t($ => $['operation.copy'], { ns: 'common' })} + aria-label={t(($) => $['operation.copy'], { ns: 'common' })} disabled={isError || !messageId} - title={t($ => $['operation.copy'], { ns: 'common' })} + title={t(($) => $['operation.copy'], { ns: 'common' })} onClick={() => { const copyContent = getCopyContent({ content, isWorkflow, workflowProcessData }) - if (typeof copyContent === 'string') - copy(copyContent) - else - copy(JSON.stringify(copyContent)) - toast.success(t($ => $['actionMsg.copySuccessfully'], { ns: 'common' })) + if (typeof copyContent === 'string') copy(copyContent) + else copy(JSON.stringify(copyContent)) + toast.success(t(($) => $['actionMsg.copySuccessfully'], { ns: 'common' })) }} > <RiClipboardLine className="size-4" /> @@ -120,8 +117,8 @@ const GenerationActionGroups: FC<GenerationActionGroupsProps> = ({ )} {isInWebApp && isError && ( <ActionButton - aria-label={t($ => $['generation.batchFailed.retry'], { ns: 'share' })} - title={t($ => $['generation.batchFailed.retry'], { ns: 'share' })} + aria-label={t(($) => $['generation.batchFailed.retry'], { ns: 'share' })} + title={t(($) => $['generation.batchFailed.retry'], { ns: 'share' })} onClick={onRetry} > <RiResetLeftLine className="size-4" /> @@ -129,10 +126,12 @@ const GenerationActionGroups: FC<GenerationActionGroupsProps> = ({ )} {isInWebApp && !isWorkflow && !isTryApp && ( <ActionButton - aria-label={t($ => $['operation.save'], { ns: 'common' })} + aria-label={t(($) => $['operation.save'], { ns: 'common' })} disabled={isError || !messageId} - title={t($ => $['operation.save'], { ns: 'common' })} - onClick={() => { onSave?.(messageId as string) }} + title={t(($) => $['operation.save'], { ns: 'common' })} + onClick={() => { + onSave?.(messageId as string) + }} > <RiBookmark3Line className="size-4" /> </ActionButton> @@ -143,15 +142,15 @@ const GenerationActionGroups: FC<GenerationActionGroupsProps> = ({ {!feedback?.rating && ( <> <ActionButton - aria-label={t($ => $['operation.agree'], { ns: 'appDebug' })} - title={t($ => $['operation.agree'], { ns: 'appDebug' })} + aria-label={t(($) => $['operation.agree'], { ns: 'appDebug' })} + title={t(($) => $['operation.agree'], { ns: 'appDebug' })} onClick={() => onFeedback?.({ rating: 'like' })} > <RiThumbUpLine className="size-4" /> </ActionButton> <ActionButton - aria-label={t($ => $['operation.disagree'], { ns: 'appDebug' })} - title={t($ => $['operation.disagree'], { ns: 'appDebug' })} + aria-label={t(($) => $['operation.disagree'], { ns: 'appDebug' })} + title={t(($) => $['operation.disagree'], { ns: 'appDebug' })} onClick={() => onFeedback?.({ rating: 'dislike' })} > <RiThumbDownLine className="size-4" /> @@ -160,9 +159,9 @@ const GenerationActionGroups: FC<GenerationActionGroupsProps> = ({ )} {feedback?.rating === 'like' && ( <ActionButton - aria-label={t($ => $['operation.cancelAgree'], { ns: 'appDebug' })} + aria-label={t(($) => $['operation.cancelAgree'], { ns: 'appDebug' })} state={ActionButtonState.Active} - title={t($ => $['operation.cancelAgree'], { ns: 'appDebug' })} + title={t(($) => $['operation.cancelAgree'], { ns: 'appDebug' })} onClick={() => onFeedback?.({ rating: null })} > <RiThumbUpLine className="size-4" /> @@ -170,9 +169,9 @@ const GenerationActionGroups: FC<GenerationActionGroupsProps> = ({ )} {feedback?.rating === 'dislike' && ( <ActionButton - aria-label={t($ => $['operation.cancelDisagree'], { ns: 'appDebug' })} + aria-label={t(($) => $['operation.cancelDisagree'], { ns: 'appDebug' })} state={ActionButtonState.Destructive} - title={t($ => $['operation.cancelDisagree'], { ns: 'appDebug' })} + title={t(($) => $['operation.cancelDisagree'], { ns: 'appDebug' })} onClick={() => onFeedback?.({ rating: null })} > <RiThumbDownLine className="size-4" /> diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index 8bd0c34a110330..5b63f80a7d378f 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -6,10 +6,7 @@ import type { WorkflowProcess } from '@/app/components/base/chat/types' import type { SiteInfo } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { - RiPlayList2Line, - RiSparklingFill, -} from '@remixicon/react' +import { RiPlayList2Line, RiSparklingFill } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' @@ -20,7 +17,12 @@ import Loading from '@/app/components/base/loading' import { Markdown } from '@/app/components/base/markdown' import { useParams } from '@/next/navigation' import { fetchTextGenerationMessage } from '@/service/debug' -import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share' +import { + AppSourceType, + fetchMoreLikeThis, + submitHumanInputForm, + updateFeedback, +} from '@/service/share' import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import GenerationActionGroups from './action-groups' import { @@ -96,15 +98,17 @@ const GenerationItem: FC<IGenerationItemProps> = ({ const [childFeedback, setChildFeedback] = useState<FeedbackType>({ rating: null, }) - const { - config, - } = useChatContext() + const { config } = useChatContext() - const setCurrentLogItem = useAppStore(s => s.setCurrentLogItem) - const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal) + const setCurrentLogItem = useAppStore((s) => s.setCurrentLogItem) + const setShowPromptLogModal = useAppStore((s) => s.setShowPromptLogModal) const handleFeedback = async (childFeedback: FeedbackType) => { - await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, appSourceType, installedAppId) + await updateFeedback( + { url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, + appSourceType, + installedAppId, + ) setChildFeedback(childFeedback) } @@ -132,7 +136,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({ const handleMoreLikeThis = async () => { if (isQuerying || !messageId) { - toast.warning(t($ => $['errorMessage.waitForResponse'], { ns: 'appDebug' })) + toast.warning(t(($) => $['errorMessage.waitForResponse'], { ns: 'appDebug' })) return } startQuerying() @@ -179,26 +183,36 @@ const GenerationItem: FC<IGenerationItemProps> = ({ // eslint-disable-next-line react/set-state-in-effect setCurrentTab(getDefaultGenerationTab(workflowProcessData)) }, [workflowProcessData]) - const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => { - if (appSourceType === AppSourceType.installedApp) - await submitHumanInputFormService(formToken, formData) - else - await submitHumanInputForm(formToken, formData) - }, [appSourceType]) + const handleSubmitHumanInputForm = useCallback( + async (formToken: string, formData: HumanInputFormSubmitData) => { + if (appSourceType === AppSourceType.installedApp) + await submitHumanInputFormService(formToken, formData) + else await submitHumanInputForm(formToken, formData) + }, + [appSourceType], + ) return ( <> <div className={cn('relative', !isTop && 'mt-3', className)}> {isLoading && ( - <div className={cn('flex h-10 items-center', !inSidePanel && 'rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg')}><Loading type="area" /></div> + <div + className={cn( + 'flex h-10 items-center', + !inSidePanel && 'rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg', + )} + > + <Loading type="area" /> + </div> )} {!isLoading && ( <> {/* result content */} - <div className={cn( - 'relative', - !inSidePanel && 'rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg', - )} + <div + className={cn( + 'relative', + !inSidePanel && 'rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg', + )} > <WorkflowBody content={content} @@ -214,33 +228,42 @@ const GenerationItem: FC<IGenerationItemProps> = ({ workflowProcessData={workflowProcessData} /> {!workflowProcessData && taskId && ( - <div className={cn('sticky top-0 left-0 flex w-full items-center rounded-t-2xl bg-components-actionbar-bg p-4 pb-3 system-2xs-medium-uppercase text-text-accent-secondary', isError && 'text-text-destructive')}> + <div + className={cn( + 'sticky top-0 left-0 flex w-full items-center rounded-t-2xl bg-components-actionbar-bg p-4 pb-3 system-2xs-medium-uppercase text-text-accent-secondary', + isError && 'text-text-destructive', + )} + > <RiPlayList2Line className="mr-1 size-3" /> - <span>{t($ => $['generation.execution'], { ns: 'share' })}</span> + <span>{t(($) => $['generation.execution'], { ns: 'share' })}</span> <span className="px-1">·</span> <span>{getGenerationTaskLabel(taskId, depth)}</span> </div> )} {isError && ( - <div className="p-4 pt-0 body-lg-regular text-text-quaternary">{t($ => $['generation.batchFailed.outputPlaceholder'], { ns: 'share' })}</div> + <div className="p-4 pt-0 body-lg-regular text-text-quaternary"> + {t(($) => $['generation.batchFailed.outputPlaceholder'], { ns: 'share' })} + </div> )} - {!workflowProcessData && !isError && (typeof content === 'string') && ( + {!workflowProcessData && !isError && typeof content === 'string' && ( <div className={cn('p-4', taskId && 'pt-0')}> <Markdown content={content} /> </div> )} </div> {/* meta data */} - <div className={cn( - 'relative mt-1 h-4 px-4 system-xs-regular text-text-quaternary', - isMobile && ((childMessageId || isQuerying) && depth < MAX_GENERATION_DEPTH) && 'pl-10', - )} + <div + className={cn( + 'relative mt-1 h-4 px-4 system-xs-regular text-text-quaternary', + isMobile && + (childMessageId || isQuerying) && + depth < MAX_GENERATION_DEPTH && + 'pl-10', + )} > {!isWorkflow && ( <span> - {content?.length} - {' '} - {t($ => $['unit.char'], { ns: 'common' })} + {content?.length} {t(($) => $['unit.char'], { ns: 'common' })} </span> )} {/* action buttons */} @@ -271,16 +294,18 @@ const GenerationItem: FC<IGenerationItemProps> = ({ </div> {/* more like this elements */} {!isTop && ( - <div className={cn( - 'absolute top-[-32px] flex h-[33px] w-4 justify-center', - isMobile ? 'left-[17px]' : 'left-[50%] translate-x-[-50%]', - )} + <div + className={cn( + 'absolute top-[-32px] flex h-[33px] w-4 justify-center', + isMobile ? 'left-[17px]' : 'left-[50%] translate-x-[-50%]', + )} > <div className="h-full w-0.5 bg-divider-regular"></div> - <div className={cn( - 'absolute left-0 flex h-4 w-4 items-center justify-center rounded-2xl border-[0.5px] border-divider-subtle bg-util-colors-blue-blue-500 shadow-xs', - isMobile ? 'top-[3.5px]' : 'top-2', - )} + <div + className={cn( + 'absolute left-0 flex h-4 w-4 items-center justify-center rounded-2xl border-[0.5px] border-divider-subtle bg-util-colors-blue-blue-500 shadow-xs', + isMobile ? 'top-[3.5px]' : 'top-2', + )} > <RiSparklingFill className="size-3 text-text-primary-on-surface" /> </div> @@ -289,8 +314,8 @@ const GenerationItem: FC<IGenerationItemProps> = ({ </> )} </div> - {((childMessageId || isQuerying) && depth < MAX_GENERATION_DEPTH) && ( - <GenerationItem {...childProps as any} /> + {(childMessageId || isQuerying) && depth < MAX_GENERATION_DEPTH && ( + <GenerationItem {...(childProps as any)} /> )} </> ) diff --git a/web/app/components/app/text-generate/item/result-tab.tsx b/web/app/components/app/text-generate/item/result-tab.tsx index 5e8a0d2b243027..8cf200453fd12c 100644 --- a/web/app/components/app/text-generate/item/result-tab.tsx +++ b/web/app/components/app/text-generate/item/result-tab.tsx @@ -1,7 +1,5 @@ import type { WorkflowProcess } from '@/app/components/base/chat/types' -import { - memo, -} from 'react' +import { memo } from 'react' import { FileList } from '@/app/components/base/file-uploader' import { Markdown } from '@/app/components/base/markdown' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' diff --git a/web/app/components/app/text-generate/item/utils.ts b/web/app/components/app/text-generate/item/utils.ts index 09f4a0be5fa145..f021c9d8b98a97 100644 --- a/web/app/components/app/text-generate/item/utils.ts +++ b/web/app/components/app/text-generate/item/utils.ts @@ -23,38 +23,43 @@ type PromptLogMessage = { export const MAX_GENERATION_DEPTH = 3 export const shouldShowWorkflowResultTabs = (workflowProcessData?: WorkflowProcess | null) => { - if (!workflowProcessData) - return false + if (!workflowProcessData) return false return Boolean( - workflowProcessData.resultText - || workflowProcessData.files?.length - || workflowProcessData.humanInputFormDataList?.length - || workflowProcessData.humanInputFilledFormDataList?.length, + workflowProcessData.resultText || + workflowProcessData.files?.length || + workflowProcessData.humanInputFormDataList?.length || + workflowProcessData.humanInputFilledFormDataList?.length, ) } -export const getDefaultGenerationTab = (workflowProcessData?: WorkflowProcess | null): GenerationTab => { - if (shouldShowWorkflowResultTabs(workflowProcessData)) - return 'RESULT' +export const getDefaultGenerationTab = ( + workflowProcessData?: WorkflowProcess | null, +): GenerationTab => { + if (shouldShowWorkflowResultTabs(workflowProcessData)) return 'RESULT' return 'DETAIL' } const getAssistantFiles = (messageFiles?: Array<Record<string, unknown>>) => - messageFiles?.filter(file => file.belongs_to === 'assistant') || [] + messageFiles?.filter((file) => file.belongs_to === 'assistant') || [] -export const buildPromptLogItem = <T extends PromptLogSource>(data: T): T & { log: PromptLogMessage[] } => { +export const buildPromptLogItem = <T extends PromptLogSource>( + data: T, +): T & { log: PromptLogMessage[] } => { if (Array.isArray(data.message)) { const messages = data.message as PromptLogMessage[] const lastMessage = messages[messages.length - 1] - const assistantMessage: PromptLogAssistantMessage[] = lastMessage?.role !== 'assistant' - ? [{ - role: 'assistant', - text: data.answer, - files: getAssistantFiles(data.message_files), - }] - : [] + const assistantMessage: PromptLogAssistantMessage[] = + lastMessage?.role !== 'assistant' + ? [ + { + role: 'assistant', + text: data.answer, + files: getAssistantFiles(data.message_files), + }, + ] + : [] return { ...data, @@ -64,11 +69,13 @@ export const buildPromptLogItem = <T extends PromptLogSource>(data: T): T & { lo return { ...data, - log: [typeof data.message === 'string' - ? { - text: data.message, - } - : data.message as PromptLogMessage], + log: [ + typeof data.message === 'string' + ? { + text: data.message, + } + : (data.message as PromptLogMessage), + ], } } @@ -83,4 +90,4 @@ export const getCopyContent = ({ content: unknown isWorkflow?: boolean workflowProcessData?: WorkflowProcess -}) => isWorkflow ? workflowProcessData?.resultText : content +}) => (isWorkflow ? workflowProcessData?.resultText : content) diff --git a/web/app/components/app/text-generate/item/workflow-body.tsx b/web/app/components/app/text-generate/item/workflow-body.tsx index 3c7748d6747777..864e74d1a37813 100644 --- a/web/app/components/app/text-generate/item/workflow-body.tsx +++ b/web/app/components/app/text-generate/item/workflow-body.tsx @@ -41,21 +41,20 @@ const WorkflowBody: FC<WorkflowBodyProps> = ({ }) => { const { t } = useTranslation() - if (!workflowProcessData) - return null + if (!workflowProcessData) return null return ( <> - <div - className={cn( - 'p-3', - showResultTabs && 'border-b border-divider-subtle', - )} - > + <div className={cn('p-3', showResultTabs && 'border-b border-divider-subtle')}> {taskId && ( - <div className={cn('mb-2 flex items-center system-2xs-medium-uppercase text-text-accent-secondary', isError && 'text-text-destructive')}> + <div + className={cn( + 'mb-2 flex items-center system-2xs-medium-uppercase text-text-accent-secondary', + isError && 'text-text-destructive', + )} + > <RiPlayList2Line className="mr-1 size-3" /> - <span>{t($ => $['generation.execution'], { ns: 'share' })}</span> + <span>{t(($) => $['generation.execution'], { ns: 'share' })}</span> <span className="px-1">·</span> <span>{getGenerationTaskLabel(taskId, depth)}</span> </div> @@ -74,41 +73,47 @@ const WorkflowBody: FC<WorkflowBodyProps> = ({ <div className={cn( 'cursor-pointer border-b-2 border-transparent py-3 system-sm-semibold-uppercase text-text-tertiary', - currentTab === 'RESULT' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary', + currentTab === 'RESULT' && + 'border-util-colors-blue-brand-blue-brand-600 text-text-primary', )} onClick={() => onSwitchTab('RESULT')} > - {t($ => $.result, { ns: 'runLog' })} + {t(($) => $.result, { ns: 'runLog' })} </div> <div className={cn( 'cursor-pointer border-b-2 border-transparent py-3 system-sm-semibold-uppercase text-text-tertiary', - currentTab === 'DETAIL' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary', + currentTab === 'DETAIL' && + 'border-util-colors-blue-brand-blue-brand-600 text-text-primary', )} onClick={() => onSwitchTab('DETAIL')} > - {t($ => $.detail, { ns: 'runLog' })} + {t(($) => $.detail, { ns: 'runLog' })} </div> </div> )} </div> {!isError && ( <> - {currentTab === 'RESULT' && workflowProcessData.humanInputFormDataList && workflowProcessData.humanInputFormDataList.length > 0 && ( - <div className="px-4 pt-4"> - <HumanInputFormList - humanInputFormDataList={workflowProcessData.humanInputFormDataList} - onHumanInputFormSubmit={onSubmitHumanInputForm} - /> - </div> - )} - {currentTab === 'RESULT' && workflowProcessData.humanInputFilledFormDataList && workflowProcessData.humanInputFilledFormDataList.length > 0 && ( - <div className="px-4 pt-4"> - <HumanInputFilledFormList - humanInputFilledFormDataList={workflowProcessData.humanInputFilledFormDataList} - /> - </div> - )} + {currentTab === 'RESULT' && + workflowProcessData.humanInputFormDataList && + workflowProcessData.humanInputFormDataList.length > 0 && ( + <div className="px-4 pt-4"> + <HumanInputFormList + humanInputFormDataList={workflowProcessData.humanInputFormDataList} + onHumanInputFormSubmit={onSubmitHumanInputForm} + /> + </div> + )} + {currentTab === 'RESULT' && + workflowProcessData.humanInputFilledFormDataList && + workflowProcessData.humanInputFilledFormDataList.length > 0 && ( + <div className="px-4 pt-4"> + <HumanInputFilledFormList + humanInputFilledFormDataList={workflowProcessData.humanInputFilledFormDataList} + /> + </div> + )} <ResultTab data={workflowProcessData} content={content} currentTab={currentTab} /> </> )} diff --git a/web/app/components/app/text-generate/saved-items/__tests__/index.spec.tsx b/web/app/components/app/text-generate/saved-items/__tests__/index.spec.tsx index 2ed6215a95819b..48fea0cfafb350 100644 --- a/web/app/components/app/text-generate/saved-items/__tests__/index.spec.tsx +++ b/web/app/components/app/text-generate/saved-items/__tests__/index.spec.tsx @@ -1,7 +1,6 @@ import type { ISavedItemsProps } from '../index' import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen } from '@testing-library/react' - import copy from 'copy-to-clipboard' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -19,9 +18,7 @@ const mockCopy = vi.mocked(copy) const toastSuccessSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success') const baseProps: ISavedItemsProps = { - list: [ - { id: '1', answer: 'hello world' }, - ], + list: [{ id: '1', answer: 'hello world' }], isShowTextToSpeech: true, onRemove: vi.fn(), onStartCreateContent: vi.fn(), diff --git a/web/app/components/app/text-generate/saved-items/index.tsx b/web/app/components/app/text-generate/saved-items/index.tsx index 40fc2b0e31ece1..794c20a83a1cd3 100644 --- a/web/app/components/app/text-generate/saved-items/index.tsx +++ b/web/app/components/app/text-generate/saved-items/index.tsx @@ -3,10 +3,7 @@ import type { FC } from 'react' import type { SavedMessage } from '@/models/debug' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { - RiClipboardLine, - RiDeleteBinLine, -} from '@remixicon/react' +import { RiClipboardLine, RiDeleteBinLine } from '@remixicon/react' import copy from 'copy-to-clipboard' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -34,50 +31,44 @@ const SavedItems: FC<ISavedItemsProps> = ({ return ( <div className={cn('space-y-4', className)}> - {list.length === 0 - ? ( - <NoData onStartCreateContent={onStartCreateContent} /> - ) - : ( - <> - {list.map(({ id, answer }) => ( - <div key={id} className="relative"> - <div className={cn( - 'rounded-2xl bg-background-section-burn p-4', - )} + {list.length === 0 ? ( + <NoData onStartCreateContent={onStartCreateContent} /> + ) : ( + <> + {list.map(({ id, answer }) => ( + <div key={id} className="relative"> + <div className={cn('rounded-2xl bg-background-section-burn p-4')}> + <Markdown content={answer} /> + </div> + <div className="mt-1 h-4 px-4 system-xs-regular text-text-quaternary"> + <span> + {answer.length} {t(($) => $['unit.char'], { ns: 'common' })} + </span> + </div> + <div className="absolute right-2 bottom-1"> + <div className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs"> + {isShowTextToSpeech && <NewAudioButton value={answer} />} + <ActionButton + onClick={() => { + copy(answer) + toast.success(t(($) => $['actionMsg.copySuccessfully'], { ns: 'common' })) + }} > - <Markdown content={answer} /> - </div> - <div className="mt-1 h-4 px-4 system-xs-regular text-text-quaternary"> - <span> - {answer.length} - {' '} - {t($ => $['unit.char'], { ns: 'common' })} - </span> - </div> - <div className="absolute right-2 bottom-1"> - <div className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs"> - {isShowTextToSpeech && <NewAudioButton value={answer} />} - <ActionButton onClick={() => { - copy(answer) - toast.success(t($ => $['actionMsg.copySuccessfully'], { ns: 'common' })) - }} - > - <RiClipboardLine className="size-4" /> - </ActionButton> - <ActionButton onClick={() => { - onRemove(id) - }} - > - <RiDeleteBinLine className="size-4" /> - </ActionButton> - </div> - </div> + <RiClipboardLine className="size-4" /> + </ActionButton> + <ActionButton + onClick={() => { + onRemove(id) + }} + > + <RiDeleteBinLine className="size-4" /> + </ActionButton> </div> - ))} - </> - )} - + </div> + </div> + ))} + </> + )} </div> ) } diff --git a/web/app/components/app/text-generate/saved-items/no-data/__tests__/index.spec.tsx b/web/app/components/app/text-generate/saved-items/no-data/__tests__/index.spec.tsx index 7f6a38a442f2a3..02a6f3775fbd85 100644 --- a/web/app/components/app/text-generate/saved-items/no-data/__tests__/index.spec.tsx +++ b/web/app/components/app/text-generate/saved-items/no-data/__tests__/index.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' - import NoData from '../index' describe('NoData', () => { @@ -10,7 +9,9 @@ describe('NoData', () => { const title = screen.getByText('share.generation.savedNoData.title') const description = screen.getByText('share.generation.savedNoData.description') - const button = screen.getByRole('button', { name: 'share.generation.savedNoData.startCreateContent' }) + const button = screen.getByRole('button', { + name: 'share.generation.savedNoData.startCreateContent', + }) expect(title).toBeInTheDocument() expect(description).toBeInTheDocument() diff --git a/web/app/components/app/text-generate/saved-items/no-data/index.tsx b/web/app/components/app/text-generate/saved-items/no-data/index.tsx index 1521b24ced3a7e..636dbac7c9a5d8 100644 --- a/web/app/components/app/text-generate/saved-items/no-data/index.tsx +++ b/web/app/components/app/text-generate/saved-items/no-data/index.tsx @@ -1,10 +1,7 @@ 'use client' import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' -import { - RiAddLine, - RiBookmark3Line, -} from '@remixicon/react' +import { RiAddLine, RiBookmark3Line } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -12,9 +9,7 @@ type INoDataProps = { onStartCreateContent: () => void } -const NoData: FC<INoDataProps> = ({ - onStartCreateContent, -}) => { +const NoData: FC<INoDataProps> = ({ onStartCreateContent }) => { const { t } = useTranslation() return ( @@ -23,18 +18,16 @@ const NoData: FC<INoDataProps> = ({ <RiBookmark3Line className="size-4 text-text-accent" /> </div> <div className="mt-3"> - <span className="system-xl-semibold text-text-secondary">{t($ => $['generation.savedNoData.title'], { ns: 'share' })}</span> + <span className="system-xl-semibold text-text-secondary"> + {t(($) => $['generation.savedNoData.title'], { ns: 'share' })} + </span> </div> <div className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $['generation.savedNoData.description'], { ns: 'share' })} + {t(($) => $['generation.savedNoData.description'], { ns: 'share' })} </div> - <Button - variant="primary" - className="mt-3" - onClick={onStartCreateContent} - > + <Button variant="primary" className="mt-3" onClick={onStartCreateContent}> <RiAddLine className="mr-1 size-4" /> - <span>{t($ => $['generation.savedNoData.startCreateContent'], { ns: 'share' })}</span> + <span>{t(($) => $['generation.savedNoData.startCreateContent'], { ns: 'share' })}</span> </Button> </div> ) diff --git a/web/app/components/app/type-selector/__tests__/index.spec.tsx b/web/app/components/app/type-selector/__tests__/index.spec.tsx index 7f2e0c3850d392..66d2930638744b 100644 --- a/web/app/components/app/type-selector/__tests__/index.spec.tsx +++ b/web/app/components/app/type-selector/__tests__/index.spec.tsx @@ -28,7 +28,9 @@ describe('AppTypeSelector', () => { }) it('should render icon-only trigger when multiple types are selected', () => { - render(<AppTypeSelector value={[AppModeEnum.CHAT, AppModeEnum.WORKFLOW]} onChange={vi.fn()} />) + render( + <AppTypeSelector value={[AppModeEnum.CHAT, AppModeEnum.WORKFLOW]} onChange={vi.fn()} />, + ) expect(screen.queryByText('app.typeSelector.all')).not.toBeInTheDocument() expect(screen.queryByText('app.typeSelector.chatbot')).not.toBeInTheDocument() @@ -59,7 +61,9 @@ describe('AppTypeSelector', () => { render(<AppTypeSelector value={[]} onChange={onChange} />) fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.all' })) - fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' })) + fireEvent.click( + within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' }), + ) expect(onChange).toHaveBeenCalledWith([AppModeEnum.WORKFLOW]) }) @@ -69,7 +73,9 @@ describe('AppTypeSelector', () => { render(<AppTypeSelector value={[AppModeEnum.WORKFLOW]} onChange={onChange} />) fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.workflow' })) - fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' })) + fireEvent.click( + within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' }), + ) expect(onChange).toHaveBeenCalledWith([]) }) @@ -79,7 +85,9 @@ describe('AppTypeSelector', () => { render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={onChange} />) fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.chatbot' })) - fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.agent' })) + fireEvent.click( + within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.agent' }), + ) expect(onChange).toHaveBeenCalledWith([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT]) }) diff --git a/web/app/components/app/type-selector/index.tsx b/web/app/components/app/type-selector/index.tsx index 3f748b943f8bbf..84f1927540a313 100644 --- a/web/app/components/app/type-selector/index.tsx +++ b/web/app/components/app/type-selector/index.tsx @@ -1,14 +1,20 @@ import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { RiArrowDownSLine, RiCloseCircleFill, RiExchange2Fill, RiFilter3Line } from '@remixicon/react' + RiArrowDownSLine, + RiCloseCircleFill, + RiExchange2Fill, + RiFilter3Line, +} from '@remixicon/react' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication' +import { + BubbleTextMod, + ChatBot, + ListSparkle, + Logic, +} from '@/app/components/base/icons/src/vender/solid/communication' import { AppModeEnum } from '@/types/app' type AppSelectorProps = { @@ -16,20 +22,24 @@ type AppSelectorProps = { onChange: (value: AppSelectorProps['value']) => void } -const allTypes: AppModeEnum[] = [AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT, AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION] +const allTypes: AppModeEnum[] = [ + AppModeEnum.WORKFLOW, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.CHAT, + AppModeEnum.AGENT_CHAT, + AppModeEnum.COMPLETION, +] const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => { const [open, setOpen] = useState(false) const { t } = useTranslation() - const triggerLabel = value.length === 0 - ? t($ => $['typeSelector.all'], { ns: 'app' }) - : value.map(type => getAppTypeLabel(type, t)).join(', ') + const triggerLabel = + value.length === 0 + ? t(($) => $['typeSelector.all'], { ns: 'app' }) + : value.map((type) => getAppTypeLabel(type, t)).join(', ') return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <div className="relative"> <PopoverTrigger aria-label={triggerLabel} @@ -43,13 +53,11 @@ const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => { {value.length > 0 && ( <button type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} className="group absolute top-1/2 right-2 size-4 -translate-y-1/2" onClick={() => onChange([])} > - <RiCloseCircleFill - className="size-3.5 text-text-quaternary group-hover:text-text-tertiary" - /> + <RiCloseCircleFill className="size-3.5 text-text-quaternary group-hover:text-text-tertiary" /> </button> )} <PopoverContent @@ -58,16 +66,14 @@ const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => { popupClassName="w-[240px] rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]" > <ul className="relative w-full p-1"> - {allTypes.map(mode => ( + {allTypes.map((mode) => ( <AppTypeSelectorItem key={mode} type={mode} checked={Boolean(value.length > 0 && value?.indexOf(mode) !== -1)} onClick={() => { - if (value?.indexOf(mode) !== -1) - onChange(value?.filter(v => v !== mode) ?? []) - else - onChange([...(value || []), mode]) + if (value?.indexOf(mode) !== -1) onChange(value?.filter((v) => v !== mode) ?? []) + else onChange([...(value || []), mode]) }} /> ))} @@ -87,80 +93,91 @@ type AppTypeIconProps = { wrapperClassName?: string } -export const AppTypeIcon = React.memo(({ type, className, wrapperClassName, style }: AppTypeIconProps) => { - const wrapperClassNames = cn('inline-flex size-5 items-center justify-center rounded-md border border-divider-regular', wrapperClassName) - const iconClassNames = cn('size-3.5 text-components-avatar-shape-fill-stop-100', className) - if (type === AppModeEnum.CHAT) { - return ( - <div style={style} className={cn(wrapperClassNames, 'bg-components-icon-bg-blue-solid')}> - <ChatBot className={iconClassNames} /> - </div> - ) - } - if (type === AppModeEnum.AGENT_CHAT) { - return ( - <div style={style} className={cn(wrapperClassNames, 'bg-components-icon-bg-violet-solid')}> - <Logic className={iconClassNames} /> - </div> - ) - } - if (type === AppModeEnum.ADVANCED_CHAT) { - return ( - <div style={style} className={cn(wrapperClassNames, 'bg-components-icon-bg-blue-light-solid')}> - <BubbleTextMod className={iconClassNames} /> - </div> - ) - } - if (type === AppModeEnum.WORKFLOW) { - return ( - <div style={style} className={cn(wrapperClassNames, 'bg-components-icon-bg-indigo-solid')}> - <RiExchange2Fill className={iconClassNames} /> - </div> +export const AppTypeIcon = React.memo( + ({ type, className, wrapperClassName, style }: AppTypeIconProps) => { + const wrapperClassNames = cn( + 'inline-flex size-5 items-center justify-center rounded-md border border-divider-regular', + wrapperClassName, ) - } - if (type === AppModeEnum.COMPLETION) { - return ( - <div style={style} className={cn(wrapperClassNames, 'bg-components-icon-bg-teal-solid')}> - <ListSparkle className={iconClassNames} /> - </div> - ) - } - return null -}) + const iconClassNames = cn('size-3.5 text-components-avatar-shape-fill-stop-100', className) + if (type === AppModeEnum.CHAT) { + return ( + <div style={style} className={cn(wrapperClassNames, 'bg-components-icon-bg-blue-solid')}> + <ChatBot className={iconClassNames} /> + </div> + ) + } + if (type === AppModeEnum.AGENT_CHAT) { + return ( + <div style={style} className={cn(wrapperClassNames, 'bg-components-icon-bg-violet-solid')}> + <Logic className={iconClassNames} /> + </div> + ) + } + if (type === AppModeEnum.ADVANCED_CHAT) { + return ( + <div + style={style} + className={cn(wrapperClassNames, 'bg-components-icon-bg-blue-light-solid')} + > + <BubbleTextMod className={iconClassNames} /> + </div> + ) + } + if (type === AppModeEnum.WORKFLOW) { + return ( + <div style={style} className={cn(wrapperClassNames, 'bg-components-icon-bg-indigo-solid')}> + <RiExchange2Fill className={iconClassNames} /> + </div> + ) + } + if (type === AppModeEnum.COMPLETION) { + return ( + <div style={style} className={cn(wrapperClassNames, 'bg-components-icon-bg-teal-solid')}> + <ListSparkle className={iconClassNames} /> + </div> + ) + } + return null + }, +) function AppTypeSelectTrigger({ values }: { readonly values: AppSelectorProps['value'] }) { const { t } = useTranslation() if (!values || values.length === 0) { return ( - <div className={cn( - 'flex h-8 items-center justify-between gap-1', - )} - > + <div className={cn('flex h-8 items-center justify-between gap-1')}> <RiFilter3Line className="size-4 text-text-tertiary" /> - <div className="min-w-[65px] grow text-center system-sm-medium text-text-tertiary">{t($ => $['typeSelector.all'], { ns: 'app' })}</div> + <div className="min-w-[65px] grow text-center system-sm-medium text-text-tertiary"> + {t(($) => $['typeSelector.all'], { ns: 'app' })} + </div> <RiArrowDownSLine className="size-4 text-text-tertiary" /> </div> ) } if (values.length === 1) { return ( - <div className={cn( - 'flex h-8 flex-nowrap items-center justify-between gap-1', - )} - > + <div className={cn('flex h-8 flex-nowrap items-center justify-between gap-1')}> <AppTypeIcon type={values[0]!} /> <div className="line-clamp-1 flex flex-1 items-center text-center"> - <AppTypeLabel type={values[0]!} className="system-sm-medium text-components-menu-item-text" /> + <AppTypeLabel + type={values[0]!} + className="system-sm-medium text-components-menu-item-text" + /> </div> </div> ) } return ( - <div className={cn( - 'relative flex h-8 items-center justify-between -space-x-2', - )} - > - {values.map((mode, index) => (<AppTypeIcon key={mode} type={mode} wrapperClassName="border border-components-panel-on-panel-item-bg" style={{ zIndex: 5 - index }} />))} + <div className={cn('relative flex h-8 items-center justify-between -space-x-2')}> + {values.map((mode, index) => ( + <AppTypeIcon + key={mode} + type={mode} + wrapperClassName="border border-components-panel-on-panel-item-bg" + style={{ zIndex: 5 - index }} + /> + ))} </div> ) } @@ -200,16 +217,11 @@ function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProp } function getAppTypeLabel(type: string, t: ReturnType<typeof useTranslation>['t']) { - if (type === AppModeEnum.CHAT) - return t($ => $['typeSelector.chatbot'], { ns: 'app' }) - if (type === AppModeEnum.AGENT_CHAT) - return t($ => $['typeSelector.agent'], { ns: 'app' }) - if (type === AppModeEnum.COMPLETION) - return t($ => $['typeSelector.completion'], { ns: 'app' }) - if (type === AppModeEnum.ADVANCED_CHAT) - return t($ => $['typeSelector.advanced'], { ns: 'app' }) - if (type === AppModeEnum.WORKFLOW) - return t($ => $['typeSelector.workflow'], { ns: 'app' }) + if (type === AppModeEnum.CHAT) return t(($) => $['typeSelector.chatbot'], { ns: 'app' }) + if (type === AppModeEnum.AGENT_CHAT) return t(($) => $['typeSelector.agent'], { ns: 'app' }) + if (type === AppModeEnum.COMPLETION) return t(($) => $['typeSelector.completion'], { ns: 'app' }) + if (type === AppModeEnum.ADVANCED_CHAT) return t(($) => $['typeSelector.advanced'], { ns: 'app' }) + if (type === AppModeEnum.WORKFLOW) return t(($) => $['typeSelector.workflow'], { ns: 'app' }) return '' } diff --git a/web/app/components/app/workflow-log/__tests__/detail.spec.tsx b/web/app/components/app/workflow-log/__tests__/detail.spec.tsx index 4abbfa24a557f4..2885c7fb917edf 100644 --- a/web/app/components/app/workflow-log/__tests__/detail.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/detail.spec.tsx @@ -27,7 +27,7 @@ vi.mock('@/next/navigation', () => ({ // Mock the Run component as it has complex dependencies vi.mock('@/app/components/workflow/run', () => ({ - default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string, tracingListUrl: string }) => ( + default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string; tracingListUrl: string }) => ( <div data-testid="workflow-run"> <span data-testid="run-detail-url">{runDetailUrl}</span> <span data-testid="tracing-list-url">{tracingListUrl}</span> @@ -128,8 +128,12 @@ describe('DetailPanel', () => { render(<DetailPanel runID="run-789" onClose={defaultOnClose} />) expect(screen.getByTestId('workflow-run')).toBeInTheDocument() - expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/app-456/workflow-runs/run-789') - expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('/apps/app-456/workflow-runs/run-789/node-executions') + expect(screen.getByTestId('run-detail-url')).toHaveTextContent( + '/apps/app-456/workflow-runs/run-789', + ) + expect(screen.getByTestId('tracing-list-url')).toHaveTextContent( + '/apps/app-456/workflow-runs/run-789/node-executions', + ) }) it('should render WorkflowContextProvider wrapper', () => { @@ -146,13 +150,17 @@ describe('DetailPanel', () => { it('should not render replay button when canReplay is false (default)', () => { render(<DetailPanel runID="run-123" onClose={defaultOnClose} />) - expect(screen.queryByRole('button', { name: 'appLog.runDetail.testWithParams' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'appLog.runDetail.testWithParams' }), + ).not.toBeInTheDocument() }) it('should render replay button when canReplay is true', () => { render(<DetailPanel runID="run-123" onClose={defaultOnClose} canReplay={true} />) - expect(screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }), + ).toBeInTheDocument() }) it('should use empty URL when runID is empty', () => { @@ -189,7 +197,9 @@ describe('DetailPanel', () => { const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) await user.click(replayButton) - expect(mockRouterPush).toHaveBeenCalledWith('/app/app-replay-test/workflow?replayRunId=run-to-replay') + expect(mockRouterPush).toHaveBeenCalledWith( + '/app/app-replay-test/workflow?replayRunId=run-to-replay', + ) }) it('should not navigate when replay clicked but appDetail is missing', async () => { @@ -214,7 +224,9 @@ describe('DetailPanel', () => { render(<DetailPanel runID="my-run" onClose={defaultOnClose} />) - expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/my-app/workflow-runs/my-run') + expect(screen.getByTestId('run-detail-url')).toHaveTextContent( + '/apps/my-app/workflow-runs/my-run', + ) }) it('should generate correct tracing list URL', () => { @@ -222,7 +234,9 @@ describe('DetailPanel', () => { render(<DetailPanel runID="my-run" onClose={defaultOnClose} />) - expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('/apps/my-app/workflow-runs/my-run/node-executions') + expect(screen.getByTestId('tracing-list-url')).toHaveTextContent( + '/apps/my-app/workflow-runs/my-run/node-executions', + ) }) it('should handle special characters in runID', () => { @@ -230,7 +244,9 @@ describe('DetailPanel', () => { render(<DetailPanel runID="run-with-special-123" onClose={defaultOnClose} />) - expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/app-id/workflow-runs/run-with-special-123') + expect(screen.getByTestId('run-detail-url')).toHaveTextContent( + '/apps/app-id/workflow-runs/run-with-special-123', + ) }) }) @@ -243,7 +259,9 @@ describe('DetailPanel', () => { render(<DetailPanel runID="run-123" onClose={defaultOnClose} />) - expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/store-app-id/workflow-runs/run-123') + expect(screen.getByTestId('run-detail-url')).toHaveTextContent( + '/apps/store-app-id/workflow-runs/run-123', + ) }) it('should handle undefined appDetail from store gracefully', () => { @@ -273,7 +291,9 @@ describe('DetailPanel', () => { render(<DetailPanel runID={longRunId} onClose={defaultOnClose} />) - expect(screen.getByTestId('run-detail-url')).toHaveTextContent(`/apps/app-id/workflow-runs/${longRunId}`) + expect(screen.getByTestId('run-detail-url')).toHaveTextContent( + `/apps/app-id/workflow-runs/${longRunId}`, + ) }) it('should render replay button with correct aria-label', () => { diff --git a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx index 1b9a0e5935f4c3..25b4893ab7ca7f 100644 --- a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx @@ -49,10 +49,7 @@ describe('Filter', () => { describe('Rendering', () => { it('should render without crashing', () => { render( - <Filter - queryParams={createDefaultQueryParams()} - setQueryParams={defaultSetQueryParams} - />, + <Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />, ) // Should render status chip, period chip, and search input @@ -63,10 +60,7 @@ describe('Filter', () => { it('should render all filter components', () => { render( - <Filter - queryParams={createDefaultQueryParams()} - setQueryParams={defaultSetQueryParams} - />, + <Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />, ) // Status chip @@ -102,10 +96,7 @@ describe('Filter', () => { const user = userEvent.setup() render( - <Filter - queryParams={createDefaultQueryParams()} - setQueryParams={defaultSetQueryParams} - />, + <Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />, ) await user.click(screen.getByText('All')) @@ -123,12 +114,7 @@ describe('Filter', () => { const user = userEvent.setup() const setQueryParams = vi.fn() - render( - <Filter - queryParams={createDefaultQueryParams()} - setQueryParams={setQueryParams} - />, - ) + render(<Filter queryParams={createDefaultQueryParams()} setQueryParams={setQueryParams} />) await user.click(screen.getByText('All')) await user.click(await screen.findByText('Success')) @@ -143,19 +129,15 @@ describe('Filter', () => { const user = userEvent.setup() render( - <Filter - queryParams={createDefaultQueryParams()} - setQueryParams={defaultSetQueryParams} - />, + <Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />, ) await user.click(screen.getByText('All')) await user.click(await screen.findByText('Fail')) - expect(mockTrackEvent).toHaveBeenCalledWith( - 'workflow_log_filter_status_selected', - { workflow_log_filter_status: 'failed' }, - ) + expect(mockTrackEvent).toHaveBeenCalledWith('workflow_log_filter_status_selected', { + workflow_log_filter_status: 'failed', + }) }) it('should reset to all when status is cleared', async () => { @@ -171,7 +153,9 @@ describe('Filter', () => { const statusTrigger = screen.getByRole('combobox', { name: 'Success' }) const statusChip = statusTrigger.parentElement! - const clearButton = within(statusChip).getByRole('button', { name: /common\.operation\.clear Success/ }) + const clearButton = within(statusChip).getByRole('button', { + name: /common\.operation\.clear Success/, + }) await user.click(clearButton) @@ -218,10 +202,7 @@ describe('Filter', () => { const user = userEvent.setup() render( - <Filter - queryParams={createDefaultQueryParams()} - setQueryParams={defaultSetQueryParams} - />, + <Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />, ) await user.click(screen.getByText('appLog.filter.period.last7days')) @@ -239,10 +220,7 @@ describe('Filter', () => { const user = userEvent.setup() render( - <Filter - queryParams={createDefaultQueryParams()} - setQueryParams={defaultSetQueryParams} - />, + <Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />, ) const periodTrigger = screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' }) @@ -257,12 +235,7 @@ describe('Filter', () => { const user = userEvent.setup() const setQueryParams = vi.fn() - render( - <Filter - queryParams={createDefaultQueryParams()} - setQueryParams={setQueryParams} - />, - ) + render(<Filter queryParams={createDefaultQueryParams()} setQueryParams={setQueryParams} />) await user.click(screen.getByText('appLog.filter.period.last7days')) await user.click(await screen.findByText('appLog.filter.period.allTime')) @@ -286,7 +259,9 @@ describe('Filter', () => { const periodTrigger = screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' }) const periodChip = periodTrigger.parentElement! - const clearButton = within(periodChip).getByRole('button', { name: /common\.operation\.clear appLog\.filter\.period\.last7days/ }) + const clearButton = within(periodChip).getByRole('button', { + name: /common\.operation\.clear appLog\.filter\.period\.last7days/, + }) await user.click(clearButton) expect(setQueryParams).toHaveBeenCalledWith({ @@ -321,12 +296,7 @@ describe('Filter', () => { setQueryParams(next) onSetQueryParams(next) } - return ( - <Filter - queryParams={queryParams} - setQueryParams={handleSetQueryParams} - /> - ) + return <Filter queryParams={queryParams} setQueryParams={handleSetQueryParams} /> } render(<Wrapper />) @@ -365,12 +335,7 @@ describe('Filter', () => { it('should update on direct input change', () => { const setQueryParams = vi.fn() - render( - <Filter - queryParams={createDefaultQueryParams()} - setQueryParams={setQueryParams} - />, - ) + render(<Filter queryParams={createDefaultQueryParams()} setQueryParams={setQueryParams} />) const input = screen.getByPlaceholderText('common.operation.search') fireEvent.change(input, { target: { value: 'new search' } }) @@ -412,14 +377,15 @@ describe('Filter', () => { ['2', 'last7days', 7], ['3', 'last4weeks', 28], ['9', 'allTime', -1], - ])('TIME_PERIOD_MAPPING[%s] should have name=%s and correct value', (key, name, expectedValue) => { - const mapping = TIME_PERIOD_MAPPING[key] - expect(mapping!.name).toBe(name) - if (expectedValue >= 0) - expect(mapping!.value).toBe(expectedValue) - else - expect(mapping!.value).toBe(-1) - }) + ])( + 'TIME_PERIOD_MAPPING[%s] should have name=%s and correct value', + (key, name, expectedValue) => { + const mapping = TIME_PERIOD_MAPPING[key] + expect(mapping!.name).toBe(name) + if (expectedValue >= 0) expect(mapping!.value).toBe(expectedValue) + else expect(mapping!.value).toBe(-1) + }, + ) }) // -------------------------------------------------------------------------- @@ -537,10 +503,7 @@ describe('Filter', () => { it('should have proper layout with flex and gap', () => { const { container } = render( - <Filter - queryParams={createDefaultQueryParams()} - setQueryParams={defaultSetQueryParams} - />, + <Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />, ) const filterContainer = container.firstChild as HTMLElement diff --git a/web/app/components/app/workflow-log/__tests__/index.spec.tsx b/web/app/components/app/workflow-log/__tests__/index.spec.tsx index 7e683c7ebab670..67a09c73492ab7 100644 --- a/web/app/components/app/workflow-log/__tests__/index.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/index.spec.tsx @@ -14,7 +14,6 @@ import type { UseQueryResult } from '@tanstack/react-query' * - detail.spec.tsx * - trigger-by-display.spec.tsx */ - import type { MockedFunction } from 'vitest' import type { ILogsProps } from '../index' import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log' @@ -54,12 +53,14 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/next/link', () => ({ - default: ({ children, href }: { children: React.ReactNode, href: string }) => <a href={href}>{children}</a>, + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + <a href={href}>{children}</a> + ), })) // Mock the Run component to avoid complex dependencies vi.mock('@/app/components/workflow/run', () => ({ - default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string, tracingListUrl: string }) => ( + default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string; tracingListUrl: string }) => ( <div data-testid="workflow-run"> <span data-testid="run-detail-url">{runDetailUrl}</span> <span data-testid="tracing-list-url">{tracingListUrl}</span> @@ -80,12 +81,12 @@ vi.mock('@/hooks/use-theme', () => ({ // Mock WorkflowContextProvider vi.mock('@/app/components/workflow/context', () => ({ - WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => ( - <>{children}</> - ), + WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, })) -const mockedUseWorkflowLogs = useLogModule.useWorkflowLogs as MockedFunction<typeof useLogModule.useWorkflowLogs> +const mockedUseWorkflowLogs = useLogModule.useWorkflowLogs as MockedFunction< + typeof useLogModule.useWorkflowLogs +> // ============================================================================ // Test Utilities @@ -100,7 +101,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => { // ============================================================================ const createMockQueryResult = <T,>( - overrides: { data?: T, isLoading?: boolean, error?: Error | null } = {}, + overrides: { data?: T; isLoading?: boolean; error?: Error | null } = {}, ): UseQueryResult<T, Error> => { const isLoading = overrides.isLoading ?? false const error = overrides.error ?? null @@ -184,7 +185,9 @@ const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): Work ...overrides, }) -const createMockWorkflowLog = (overrides: Partial<WorkflowAppLogDetail> = {}): WorkflowAppLogDetail => ({ +const createMockWorkflowLog = ( + overrides: Partial<WorkflowAppLogDetail> = {}, +): WorkflowAppLogDetail => ({ id: 'log-1', workflow_run: createMockWorkflowRun(), created_from: 'web-app', @@ -503,7 +506,8 @@ describe('Logs Container', () => { it('should render pagination when total exceeds limit', () => { // Arrange const logs = Array.from({ length: APP_PAGE_LIMIT }, (_, i) => - createMockWorkflowLog({ id: `log-${i}` })) + createMockWorkflowLog({ id: `log-${i}` }), + ) mockedUseWorkflowLogs.mockReturnValue( createMockQueryResult<WorkflowLogsResponse>({ @@ -542,14 +546,17 @@ describe('Logs Container', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( createMockQueryResult<WorkflowLogsResponse>({ - data: createMockLogsResponse([ - createMockWorkflowLog({ - workflow_run: createMockWorkflowRun({ - status: 'succeeded', - total_tokens: 500, + data: createMockLogsResponse( + [ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ + status: 'succeeded', + total_tokens: 500, + }), }), - }), - ], 1), + ], + 1, + ), }), ) diff --git a/web/app/components/app/workflow-log/__tests__/list.spec.tsx b/web/app/components/app/workflow-log/__tests__/list.spec.tsx index 2259ed30c508b0..17b8c104e42b28 100644 --- a/web/app/components/app/workflow-log/__tests__/list.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/list.spec.tsx @@ -48,7 +48,7 @@ vi.mock('@/hooks/use-breakpoints', () => ({ // Mock the Run component vi.mock('@/app/components/workflow/run', () => ({ - default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string, tracingListUrl: string }) => ( + default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string; tracingListUrl: string }) => ( <div data-testid="workflow-run"> <span data-testid="run-detail-url">{runDetailUrl}</span> <span data-testid="tracing-list-url">{tracingListUrl}</span> @@ -135,7 +135,9 @@ const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): Work ...overrides, }) -const createMockWorkflowLog = (overrides: Partial<WorkflowAppLogDetail> = {}): WorkflowAppLogDetail => ({ +const createMockWorkflowLog = ( + overrides: Partial<WorkflowAppLogDetail> = {}, +): WorkflowAppLogDetail => ({ id: 'log-1', workflow_run: createMockWorkflowRun(), created_from: 'web-app', @@ -178,7 +180,11 @@ describe('WorkflowAppLogList', () => { describe('Rendering', () => { it('should render loading state when logs are undefined', () => { const { container } = render( - <WorkflowAppLogList logs={undefined} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />, + <WorkflowAppLogList + logs={undefined} + appDetail={createMockApp()} + onRefresh={defaultOnRefresh} + />, ) expect(container.querySelector('.spin-animation'))!.toBeInTheDocument() @@ -233,9 +239,7 @@ describe('WorkflowAppLogList', () => { const logs = createMockLogsResponse([createMockWorkflowLog()]) const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum }) - render( - <WorkflowAppLogList logs={logs} appDetail={chatApp} onRefresh={defaultOnRefresh} />, - ) + render(<WorkflowAppLogList logs={logs} appDetail={chatApp} onRefresh={defaultOnRefresh} />) expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument() }) @@ -304,7 +308,9 @@ describe('WorkflowAppLogList', () => { it('should render partial-succeeded status correctly', () => { const logs = createMockLogsResponse([ createMockWorkflowLog({ - workflow_run: createMockWorkflowRun({ status: 'partial-succeeded' as WorkflowRunDetail['status'] }), + workflow_run: createMockWorkflowRun({ + status: 'partial-succeeded' as WorkflowRunDetail['status'], + }), }), ]) @@ -338,7 +344,12 @@ describe('WorkflowAppLogList', () => { it('should display end user session id when created by end user', () => { const logs = createMockLogsResponse([ createMockWorkflowLog({ - created_by_end_user: { id: 'user-1', type: 'browser', is_anonymous: false, session_id: 'session-abc-123' }, + created_by_end_user: { + id: 'user-1', + type: 'browser', + is_anonymous: false, + session_id: 'session-abc-123', + }, created_by_account: undefined, }), ]) @@ -453,9 +464,7 @@ describe('WorkflowAppLogList', () => { useAppStore.setState({ appDetail: createMockApp() }) const logs = createMockLogsResponse([createMockWorkflowLog()]) - render( - <WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={onRefresh} />, - ) + render(<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={onRefresh} />) // Open drawer const dataRows = screen.getAllByRole('row') @@ -555,7 +564,9 @@ describe('WorkflowAppLogList', () => { const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) await user.click(replayButton) - expect(mockRouterPush).toHaveBeenCalledWith('/app/app-replay/workflow?replayRunId=run-to-replay') + expect(mockRouterPush).toHaveBeenCalledWith( + '/app/app-replay/workflow?replayRunId=run-to-replay', + ) }) it('should allow replay when triggered from debugging', async () => { @@ -637,7 +648,9 @@ describe('WorkflowAppLogList', () => { // Replay button should not be present for webhook triggers // Replay button should not be present for webhook triggers // Replay button should not be present for webhook triggers - expect(screen.queryByRole('button', { name: 'appLog.runDetail.testWithParams' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'appLog.runDetail.testWithParams' }), + ).not.toBeInTheDocument() }) }) @@ -799,9 +812,7 @@ describe('WorkflowAppLogList', () => { ]) const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum }) - render( - <WorkflowAppLogList logs={logs} appDetail={chatApp} onRefresh={defaultOnRefresh} />, - ) + render(<WorkflowAppLogList logs={logs} appDetail={chatApp} onRefresh={defaultOnRefresh} />) // Should render without trigger column // Should render without trigger column diff --git a/web/app/components/app/workflow-log/__tests__/trigger-by-display.spec.tsx b/web/app/components/app/workflow-log/__tests__/trigger-by-display.spec.tsx index a10c4aa5c52cf4..5841f06cad5940 100644 --- a/web/app/components/app/workflow-log/__tests__/trigger-by-display.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/trigger-by-display.spec.tsx @@ -22,7 +22,7 @@ vi.mock('@/hooks/use-theme', () => ({ // Mock BlockIcon as it has complex dependencies vi.mock('@/app/components/workflow/block-icon', () => ({ - default: ({ type, toolIcon }: { type: string, toolIcon?: string }) => ( + default: ({ type, toolIcon }: { type: string; toolIcon?: string }) => ( <div data-testid="block-icon" data-type={type} data-tool-icon={toolIcon || ''}> BlockIcon </div> @@ -91,12 +91,7 @@ describe('TriggerByDisplay', () => { }) it('should hide text when showText is false', () => { - render( - <TriggerByDisplay - triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN} - showText={false} - />, - ) + render(<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN} showText={false} />) expect(screen.queryByText('appLog.triggerBy.appRun')).not.toBeInTheDocument() }) @@ -319,10 +314,7 @@ describe('TriggerByDisplay', () => { it('should handle empty className', () => { const { container } = render( - <TriggerByDisplay - triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN} - className="" - />, + <TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN} className="" />, ) const wrapper = container.firstChild as HTMLElement diff --git a/web/app/components/app/workflow-log/detail.tsx b/web/app/components/app/workflow-log/detail.tsx index 12e61c59c17987..f0d4b9de13adc8 100644 --- a/web/app/components/app/workflow-log/detail.tsx +++ b/web/app/components/app/workflow-log/detail.tsx @@ -16,12 +16,11 @@ type ILogDetail = { const DetailPanel: FC<ILogDetail> = ({ runID, onClose, canReplay = false }) => { const { t } = useTranslation() - const appDetail = useStore(state => state.appDetail) + const appDetail = useStore((state) => state.appDetail) const router = useRouter() const handleReplay = () => { - if (!appDetail?.id) - return + if (!appDetail?.id) return router.push(`/app/${appDetail.id}/workflow?replayRunId=${runID}`) } @@ -29,30 +28,32 @@ const DetailPanel: FC<ILogDetail> = ({ runID, onClose, canReplay = false }) => { <div className="relative flex grow flex-col pt-3"> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onClose} > <RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" /> </button> <div className="flex items-center bg-components-panel-bg"> - <h1 className="shrink-0 px-4 py-1 system-xl-semibold text-text-primary">{t($ => $['runDetail.workflowTitle'], { ns: 'appLog' })}</h1> + <h1 className="shrink-0 px-4 py-1 system-xl-semibold text-text-primary"> + {t(($) => $['runDetail.workflowTitle'], { ns: 'appLog' })} + </h1> {canReplay && ( <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" className="mr-1 flex size-6 items-center justify-center rounded-md border-none bg-transparent p-0 hover:bg-state-base-hover" - aria-label={t($ => $['runDetail.testWithParams'], { ns: 'appLog' })} + aria-label={t(($) => $['runDetail.testWithParams'], { ns: 'appLog' })} onClick={handleReplay} > <RiPlayLargeLine className="size-4 text-text-tertiary" aria-hidden="true" /> </button> - )} + } /> <TooltipContent className="rounded-xl"> - {t($ => $['runDetail.testWithParams'], { ns: 'appLog' })} + {t(($) => $['runDetail.testWithParams'], { ns: 'appLog' })} </TooltipContent> </Tooltip> )} @@ -60,7 +61,9 @@ const DetailPanel: FC<ILogDetail> = ({ runID, onClose, canReplay = false }) => { <WorkflowContextProvider> <Run runDetailUrl={runID ? `/apps/${appDetail?.id}/workflow-runs/${runID}` : ''} - tracingListUrl={runID ? `/apps/${appDetail?.id}/workflow-runs/${runID}/node-executions` : ''} + tracingListUrl={ + runID ? `/apps/${appDetail?.id}/workflow-runs/${runID}/node-executions` : '' + } /> </WorkflowContextProvider> </div> diff --git a/web/app/components/app/workflow-log/filter.tsx b/web/app/components/app/workflow-log/filter.tsx index ae8cdfff175f13..1c1b555377e2d5 100644 --- a/web/app/components/app/workflow-log/filter.tsx +++ b/web/app/components/app/workflow-log/filter.tsx @@ -17,7 +17,7 @@ const today = dayjs() type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'> -export const TIME_PERIOD_MAPPING: { [key: string]: { value: number, name: TimePeriodName } } = { +export const TIME_PERIOD_MAPPING: { [key: string]: { value: number; name: TimePeriodName } } = { 1: { value: 0, name: 'today' }, 2: { value: 7, name: 'last7days' }, 3: { value: 28, name: 'last4weeks' }, @@ -47,7 +47,13 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps) }) }} onClear={() => setQueryParams({ ...queryParams, status: 'all' })} - items={[{ value: 'all', name: 'All' }, { value: 'succeeded', name: 'Success' }, { value: 'failed', name: 'Fail' }, { value: 'stopped', name: 'Stop' }, { value: 'partial-succeeded', name: 'Partial Success' }]} + items={[ + { value: 'all', name: 'All' }, + { value: 'succeeded', name: 'Success' }, + { value: 'failed', name: 'Fail' }, + { value: 'stopped', name: 'Stop' }, + { value: 'partial-succeeded', name: 'Partial Success' }, + ]} /> <Chip className="min-w-[150px]" @@ -58,14 +64,17 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps) setQueryParams({ ...queryParams, period: item.value }) }} onClear={() => setQueryParams({ ...queryParams, period: '9' })} - items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t($ => $[`filter.period.${v.name}`], { ns: 'appLog' }) }))} + items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ + value: k, + name: t(($) => $[`filter.period.${v.name}`], { ns: 'appLog' }), + }))} /> <Input wrapperClassName="w-[200px]" showLeftIcon showClearIcon value={queryParams.keyword ?? ''} - placeholder={t($ => $['operation.search'], { ns: 'common' })!} + placeholder={t(($) => $['operation.search'], { ns: 'common' })!} onChange={(e) => { setQueryParams({ ...queryParams, keyword: e.target.value }) }} diff --git a/web/app/components/app/workflow-log/index.tsx b/web/app/components/app/workflow-log/index.tsx index d398adccb19379..36ff5c26b1ae94 100644 --- a/web/app/components/app/workflow-log/index.tsx +++ b/web/app/components/app/workflow-log/index.tsx @@ -37,7 +37,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => { const { t } = useTranslation() const { data: timezone } = useQuery({ ...userProfileQueryOptions(), - select: data => data.profile.timezone ?? undefined, + select: (data) => data.profile.timezone ?? undefined, }) const [queryParams, setQueryParams] = useState<QueryParam>({ status: 'all', period: '2' }) const [currPage, setCurrPage] = React.useState<number>(0) @@ -50,9 +50,13 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => { limit, ...(debouncedQueryParams.status !== 'all' ? { status: debouncedQueryParams.status } : {}), ...(debouncedQueryParams.keyword ? { keyword: debouncedQueryParams.keyword } : {}), - ...((debouncedQueryParams.period !== '9') + ...(debouncedQueryParams.period !== '9' ? { - created_at__after: dayjs().subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day').startOf('day').tz(timezone).format('YYYY-MM-DDTHH:mm:ssZ'), + created_at__after: dayjs() + .subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day') + .startOf('day') + .tz(timezone) + .format('YYYY-MM-DDTHH:mm:ssZ'), created_at__before: dayjs().endOf('day').tz(timezone).format('YYYY-MM-DDTHH:mm:ssZ'), } : {}), @@ -69,40 +73,41 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => { return ( <div className="flex h-full flex-col"> <PageTitle - title={t($ => $.workflowTitle, { ns: 'appLog' })} - description={t($ => $.workflowSubtitle, { ns: 'appLog' })} + title={t(($) => $.workflowTitle, { ns: 'appLog' })} + description={t(($) => $.workflowSubtitle, { ns: 'appLog' })} /> <div className="flex max-h-[calc(100%-16px)] flex-1 flex-col py-4"> <Filter queryParams={queryParams} setQueryParams={setQueryParams} /> {/* workflow log */} - {total === undefined - ? <Loading type="app" /> - : total > 0 - ? <List logs={workflowLogs} appDetail={appDetail} onRefresh={mutate} /> - : <EmptyElement appDetail={appDetail} />} + {total === undefined ? ( + <Loading type="app" /> + ) : total > 0 ? ( + <List logs={workflowLogs} appDetail={appDetail} onRefresh={mutate} /> + ) : ( + <EmptyElement appDetail={appDetail} /> + )} {/* Show Pagination only if the total is more than the limit */} - {(total && total > APP_PAGE_LIMIT) - ? ( - <Pagination - page={currPage + 1} - totalPages={totalPages} - onPageChange={page => setCurrPage(page - 1)} - labels={{ - previous: t($ => $['pagination.previous'], { ns: 'common' }), - next: t($ => $['pagination.next'], { ns: 'common' }), - editPageNumber: (page, totalPages) => t($ => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), - pageNumberInput: t($ => $['pagination.pageNumber'], { ns: 'common' }), - }} - pageSize={{ - value: limit, - options: [10, 25, 50], - onValueChange: setLimit, - label: t($ => $['pagination.perPage'], { ns: 'common' }), - ariaLabel: t($ => $['pagination.perPage'], { ns: 'common' }), - }} - /> - ) - : null} + {total && total > APP_PAGE_LIMIT ? ( + <Pagination + page={currPage + 1} + totalPages={totalPages} + onPageChange={(page) => setCurrPage(page - 1)} + labels={{ + previous: t(($) => $['pagination.previous'], { ns: 'common' }), + next: t(($) => $['pagination.next'], { ns: 'common' }), + editPageNumber: (page, totalPages) => + t(($) => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), + pageNumberInput: t(($) => $['pagination.pageNumber'], { ns: 'common' }), + }} + pageSize={{ + value: limit, + options: [10, 25, 50], + onValueChange: setLimit, + label: t(($) => $['pagination.perPage'], { ns: 'common' }), + ariaLabel: t(($) => $['pagination.perPage'], { ns: 'common' }), + }} + /> + ) : null} </div> </div> ) diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index ca8d25c93d0178..0fdd257c9d0df0 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -1,6 +1,10 @@ 'use client' import type { FC } from 'react' -import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunTriggeredFrom } from '@/models/log' +import type { + WorkflowAppLogDetail, + WorkflowLogsResponse, + WorkflowRunTriggeredFrom, +} from '@/models/log' import type { App } from '@/types/app' import { ArrowDownIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' @@ -120,8 +124,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => { setCurrentLog(undefined) } - if (!logs || !appDetail) - return <Loading /> + if (!logs || !appDetail) return <Loading /> return ( <div className="overflow-x-auto"> @@ -135,27 +138,55 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => { className="flex cursor-pointer items-center border-none bg-transparent p-0 text-left hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={handleSort} > - {t($ => $['table.header.startTime'], { ns: 'appLog' })} + {t(($) => $['table.header.startTime'], { ns: 'appLog' })} <ArrowDownIcon - className={cn('ml-0.5 size-3 stroke-current stroke-2 transition-all', 'text-text-tertiary', sortOrder === 'asc' ? 'rotate-180' : '')} + className={cn( + 'ml-0.5 size-3 stroke-current stroke-2 transition-all', + 'text-text-tertiary', + sortOrder === 'asc' ? 'rotate-180' : '', + )} aria-hidden="true" /> </button> </td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.status'], { ns: 'appLog' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.runtime'], { ns: 'appLog' })}</td> - <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.tokens'], { ns: 'appLog' })}</td> - <td className={cn('bg-background-section-burn py-1.5 pl-3 whitespace-nowrap', !isWorkflow ? 'rounded-r-lg' : '')}>{t($ => $['table.header.user'], { ns: 'appLog' })}</td> - {isWorkflow && <td className="rounded-r-lg bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t($ => $['table.header.triggered_from'], { ns: 'appLog' })}</td>} + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.status'], { ns: 'appLog' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.runtime'], { ns: 'appLog' })} + </td> + <td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.tokens'], { ns: 'appLog' })} + </td> + <td + className={cn( + 'bg-background-section-burn py-1.5 pl-3 whitespace-nowrap', + !isWorkflow ? 'rounded-r-lg' : '', + )} + > + {t(($) => $['table.header.user'], { ns: 'appLog' })} + </td> + {isWorkflow && ( + <td className="rounded-r-lg bg-background-section-burn py-1.5 pl-3 whitespace-nowrap"> + {t(($) => $['table.header.triggered_from'], { ns: 'appLog' })} + </td> + )} </tr> </thead> <tbody className="system-sm-regular text-text-secondary"> {localLogs.map((log: WorkflowAppLogDetail) => { - const endUser = log.created_by_end_user ? log.created_by_end_user.session_id : log.created_by_account ? log.created_by_account.name : defaultValue + const endUser = log.created_by_end_user + ? log.created_by_end_user.session_id + : log.created_by_account + ? log.created_by_account.name + : defaultValue return ( <tr key={log.id} - className={cn('cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover', currentLog?.id !== log.id ? '' : 'bg-background-default-hover')} + className={cn( + 'cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover', + currentLog?.id !== log.id ? '' : 'bg-background-default-hover', + )} onClick={() => { setCurrentLog(log) setShowDrawer(true) @@ -168,25 +199,37 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => { </div> )} </td> - <td className="w-[180px] p-3 pr-2">{formatTime(log.created_at, t($ => $.dateTimeFormat, { ns: 'appLog' }) as string)}</td> + <td className="w-[180px] p-3 pr-2"> + {formatTime( + log.created_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + )} + </td> <td className="p-3 pr-2">{statusTdRender(log.workflow_run.status)}</td> <td className="p-3 pr-2"> - <div className={cn( - log.workflow_run.elapsed_time === 0 && 'text-text-quaternary', - )} + <div + className={cn(log.workflow_run.elapsed_time === 0 && 'text-text-quaternary')} > {`${log.workflow_run.elapsed_time.toFixed(3)}s`} </div> </td> <td className="p-3 pr-2">{log.workflow_run.total_tokens}</td> <td className="p-3 pr-2"> - <div className={cn(endUser === defaultValue ? 'text-text-quaternary' : 'text-text-secondary', 'truncate')}> + <div + className={cn( + endUser === defaultValue ? 'text-text-quaternary' : 'text-text-secondary', + 'truncate', + )} + > {endUser} </div> </td> {isWorkflow && ( <td className="p-3 pr-2"> - <TriggerByDisplay triggeredFrom={log.workflow_run.triggered_from as WorkflowRunTriggeredFrom} triggerMetadata={log.details?.trigger_metadata} /> + <TriggerByDisplay + triggeredFrom={log.workflow_run.triggered_from as WorkflowRunTriggeredFrom} + triggerMetadata={log.details?.trigger_metadata} + /> </td> )} </tr> @@ -199,8 +242,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => { modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onCloseDrawer() + if (!open) onCloseDrawer() }} > <DrawerPortal> @@ -211,7 +253,10 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => { <DetailPanel onClose={onCloseDrawer} runID={currentLog?.workflow_run.id || ''} - canReplay={currentLog?.workflow_run.triggered_from === 'app-run' || currentLog?.workflow_run.triggered_from === 'debugging'} + canReplay={ + currentLog?.workflow_run.triggered_from === 'app-run' || + currentLog?.workflow_run.triggered_from === 'debugging' + } /> </DrawerContent> </DrawerPopup> diff --git a/web/app/components/app/workflow-log/trigger-by-display.tsx b/web/app/components/app/workflow-log/trigger-by-display.tsx index f5a0304c5e4e5c..97d6aa5caae995 100644 --- a/web/app/components/app/workflow-log/trigger-by-display.tsx +++ b/web/app/components/app/workflow-log/trigger-by-display.tsx @@ -24,44 +24,43 @@ type TriggerByDisplayProps = { triggerMetadata?: TriggerMetadata } -const getTriggerDisplayName = (triggeredFrom: WorkflowRunTriggeredFrom, t: TFunction, metadata?: TriggerMetadata) => { +const getTriggerDisplayName = ( + triggeredFrom: WorkflowRunTriggeredFrom, + t: TFunction, + metadata?: TriggerMetadata, +) => { if (triggeredFrom === WorkflowRunTriggeredFrom.PLUGIN && metadata?.event_name) return metadata.event_name const nameMap: Record<WorkflowRunTriggeredFrom, string> = { - 'debugging': t($ => $['triggerBy.debugging'], { ns: 'appLog' }), - 'app-run': t($ => $['triggerBy.appRun'], { ns: 'appLog' }), - 'webhook': t($ => $['triggerBy.webhook'], { ns: 'appLog' }), - 'schedule': t($ => $['triggerBy.schedule'], { ns: 'appLog' }), - 'plugin': t($ => $['triggerBy.plugin'], { ns: 'appLog' }), - 'rag-pipeline-run': t($ => $['triggerBy.ragPipelineRun'], { ns: 'appLog' }), - 'rag-pipeline-debugging': t($ => $['triggerBy.ragPipelineDebugging'], { ns: 'appLog' }), + debugging: t(($) => $['triggerBy.debugging'], { ns: 'appLog' }), + 'app-run': t(($) => $['triggerBy.appRun'], { ns: 'appLog' }), + webhook: t(($) => $['triggerBy.webhook'], { ns: 'appLog' }), + schedule: t(($) => $['triggerBy.schedule'], { ns: 'appLog' }), + plugin: t(($) => $['triggerBy.plugin'], { ns: 'appLog' }), + 'rag-pipeline-run': t(($) => $['triggerBy.ragPipelineRun'], { ns: 'appLog' }), + 'rag-pipeline-debugging': t(($) => $['triggerBy.ragPipelineDebugging'], { ns: 'appLog' }), } return nameMap[triggeredFrom] || triggeredFrom } const getPluginIcon = (metadata: TriggerMetadata | undefined, theme: Theme) => { - if (!metadata) - return null + if (!metadata) return null - const icon = theme === Theme.dark - ? metadata.icon_dark || metadata.icon - : metadata.icon || metadata.icon_dark + const icon = + theme === Theme.dark ? metadata.icon_dark || metadata.icon : metadata.icon || metadata.icon_dark - if (!icon) - return null + if (!icon) return null - return ( - <BlockIcon - type={BlockEnum.TriggerPlugin} - size="md" - toolIcon={icon} - /> - ) + return <BlockIcon type={BlockEnum.TriggerPlugin} size="md" toolIcon={icon} /> } -const getTriggerIcon = (triggeredFrom: WorkflowRunTriggeredFrom, metadata: TriggerMetadata | undefined, theme: Theme) => { +const getTriggerIcon = ( + triggeredFrom: WorkflowRunTriggeredFrom, + metadata: TriggerMetadata | undefined, + theme: Theme, +) => { switch (triggeredFrom) { case 'webhook': return ( @@ -76,11 +75,8 @@ const getTriggerIcon = (triggeredFrom: WorkflowRunTriggeredFrom, metadata: Trigg </div> ) case 'plugin': - return getPluginIcon(metadata, theme) || ( - <BlockIcon - type={BlockEnum.TriggerPlugin} - size="md" - /> + return ( + getPluginIcon(metadata, theme) || <BlockIcon type={BlockEnum.TriggerPlugin} size="md" /> ) case 'debugging': return ( @@ -120,14 +116,8 @@ const TriggerByDisplay: FC<TriggerByDisplayProps> = ({ return ( <div className={`flex items-center gap-1.5 ${className}`}> - <div className="flex items-center justify-center"> - {icon} - </div> - {showText && ( - <span className="system-sm-regular text-text-secondary"> - {displayName} - </span> - )} + <div className="flex items-center justify-center">{icon}</div> + {showText && <span className="system-sm-regular text-text-secondary">{displayName}</span>} </div> ) } diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 8f527412de1075..f382095804d720 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -19,13 +19,14 @@ const mockUserCanAccessApp = vi.hoisted(() => ({ isLoading: false, })) -const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, { - systemFeatures: { - webapp_auth: { enabled: mockWebappAuthEnabled }, - branding: { enabled: false }, - rbac_enabled: mockRbacEnabled, - }, -}) +const render = (ui: React.ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { + webapp_auth: { enabled: mockWebappAuthEnabled }, + branding: { enabled: false }, + rbac_enabled: mockRbacEnabled, + }, + }) // Mock next/navigation const mockPush = vi.fn() @@ -37,14 +38,24 @@ vi.mock('@/next/navigation', () => ({ const toastMocks = vi.hoisted(() => { const record = vi.fn() - const api = vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })) + const api = vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ message, ...options }), + ) return { record, api: Object.assign(api, { - success: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'success', message, ...options })), - error: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'error', message, ...options })), - warning: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'warning', message, ...options })), - info: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'info', message, ...options })), + success: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'success', message, ...options }), + ), + error: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'info', message, ...options }), + ), dismiss: vi.fn(), update: vi.fn(), promise: vi.fn(), @@ -63,9 +74,10 @@ vi.mock('use-context-selector', () => ({ useContext: () => ({ notify: toastMocks.api, }), - useContextSelector: (_context: unknown, selector: (state: Record<string, unknown>) => unknown) => selector({ - notify: toastMocks.api, - }), + useContextSelector: (_context: unknown, selector: (state: Record<string, unknown>) => unknown) => + selector({ + notify: toastMocks.api, + }), })) const mockAppContext = vi.hoisted(() => ({ @@ -103,7 +115,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -150,14 +163,20 @@ vi.mock('@/service/explore', () => ({ vi.mock('@/service/access-control', () => ({ useGetUserCanAccessApp: () => ({ - data: mockUserCanAccessApp.result === undefined ? undefined : { result: mockUserCanAccessApp.result }, + data: + mockUserCanAccessApp.result === undefined + ? undefined + : { result: mockUserCanAccessApp.result }, isLoading: mockUserCanAccessApp.isLoading, }), })) vi.mock('@/service/access-control/use-app-access-control', () => ({ useGetUserCanAccessApp: () => ({ - data: mockUserCanAccessApp.result === undefined ? undefined : { result: mockUserCanAccessApp.result }, + data: + mockUserCanAccessApp.result === undefined + ? undefined + : { result: mockUserCanAccessApp.result }, isLoading: mockUserCanAccessApp.isLoading, }), })) @@ -193,53 +212,158 @@ vi.mock('@/next/dynamic', () => ({ const fnString = importFn.toString() if (fnString.includes('create-app-modal') || fnString.includes('explore/create-app-modal')) { - return function MockEditAppModal({ show, onHide, onConfirm }: { show: boolean, onHide: () => void, onConfirm?: (data: Record<string, unknown>) => void }) { - if (!show) - return null - return React.createElement('div', { 'data-testid': 'edit-app-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-edit-modal' }, 'Close'), React.createElement('button', { - 'onClick': () => onConfirm?.({ - name: 'Updated App', - icon_type: 'emoji', - icon: '🎯', - icon_background: '#FFEAD5', - description: 'Updated description', - use_icon_as_answer_icon: false, - max_active_requests: null, - }), - 'data-testid': 'confirm-edit-modal', - }, 'Confirm')) + return function MockEditAppModal({ + show, + onHide, + onConfirm, + }: { + show: boolean + onHide: () => void + onConfirm?: (data: Record<string, unknown>) => void + }) { + if (!show) return null + return React.createElement( + 'div', + { 'data-testid': 'edit-app-modal' }, + React.createElement( + 'button', + { onClick: onHide, 'data-testid': 'close-edit-modal' }, + 'Close', + ), + React.createElement( + 'button', + { + onClick: () => + onConfirm?.({ + name: 'Updated App', + icon_type: 'emoji', + icon: '🎯', + icon_background: '#FFEAD5', + description: 'Updated description', + use_icon_as_answer_icon: false, + max_active_requests: null, + }), + 'data-testid': 'confirm-edit-modal', + }, + 'Confirm', + ), + ) } } if (fnString.includes('duplicate-modal')) { - return function MockDuplicateAppModal({ show, onHide, onConfirm }: { show: boolean, onHide: () => void, onConfirm?: (data: Record<string, unknown>) => void }) { - if (!show) - return null - return React.createElement('div', { 'data-testid': 'duplicate-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-duplicate-modal' }, 'Close'), React.createElement('button', { - 'onClick': () => onConfirm?.({ - name: 'Copied App', - icon_type: 'emoji', - icon: '📋', - icon_background: '#E4FBCC', - }), - 'data-testid': 'confirm-duplicate-modal', - }, 'Confirm')) + return function MockDuplicateAppModal({ + show, + onHide, + onConfirm, + }: { + show: boolean + onHide: () => void + onConfirm?: (data: Record<string, unknown>) => void + }) { + if (!show) return null + return React.createElement( + 'div', + { 'data-testid': 'duplicate-modal' }, + React.createElement( + 'button', + { onClick: onHide, 'data-testid': 'close-duplicate-modal' }, + 'Close', + ), + React.createElement( + 'button', + { + onClick: () => + onConfirm?.({ + name: 'Copied App', + icon_type: 'emoji', + icon: '📋', + icon_background: '#E4FBCC', + }), + 'data-testid': 'confirm-duplicate-modal', + }, + 'Confirm', + ), + ) } } if (fnString.includes('switch-app-modal')) { - return function MockSwitchAppModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) { - if (!show) - return null - return React.createElement('div', { 'data-testid': 'switch-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-switch-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'confirm-switch-modal' }, 'Switch')) + return function MockSwitchAppModal({ + show, + onClose, + onSuccess, + }: { + show: boolean + onClose: () => void + onSuccess: () => void + }) { + if (!show) return null + return React.createElement( + 'div', + { 'data-testid': 'switch-modal' }, + React.createElement( + 'button', + { onClick: onClose, 'data-testid': 'close-switch-modal' }, + 'Close', + ), + React.createElement( + 'button', + { onClick: onSuccess, 'data-testid': 'confirm-switch-modal' }, + 'Switch', + ), + ) } } if (fnString.includes('dsl-export-confirm-modal')) { - return function MockDSLExportModal({ onClose, onConfirm }: { onClose?: () => void, onConfirm?: (withSecrets: boolean) => void }) { - return React.createElement('div', { 'data-testid': 'dsl-export-modal' }, React.createElement('button', { 'onClick': () => onClose?.(), 'data-testid': 'close-dsl-export' }, 'Close'), React.createElement('button', { 'onClick': () => onConfirm?.(true), 'data-testid': 'confirm-dsl-export' }, 'Export with secrets'), React.createElement('button', { 'onClick': () => onConfirm?.(false), 'data-testid': 'confirm-dsl-export-no-secrets' }, 'Export without secrets')) + return function MockDSLExportModal({ + onClose, + onConfirm, + }: { + onClose?: () => void + onConfirm?: (withSecrets: boolean) => void + }) { + return React.createElement( + 'div', + { 'data-testid': 'dsl-export-modal' }, + React.createElement( + 'button', + { onClick: () => onClose?.(), 'data-testid': 'close-dsl-export' }, + 'Close', + ), + React.createElement( + 'button', + { onClick: () => onConfirm?.(true), 'data-testid': 'confirm-dsl-export' }, + 'Export with secrets', + ), + React.createElement( + 'button', + { onClick: () => onConfirm?.(false), 'data-testid': 'confirm-dsl-export-no-secrets' }, + 'Export without secrets', + ), + ) } } if (fnString.includes('app-access-control')) { - return function MockAccessControl({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) { - return React.createElement('div', { 'data-testid': 'access-control-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-access-control' }, 'Close'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-access-control' }, 'Confirm')) + return function MockAccessControl({ + onClose, + onConfirm, + }: { + onClose: () => void + onConfirm: () => void + }) { + return React.createElement( + 'div', + { 'data-testid': 'access-control-modal' }, + React.createElement( + 'button', + { onClick: onClose, 'data-testid': 'close-access-control' }, + 'Close', + ), + React.createElement( + 'button', + { onClick: onConfirm, 'data-testid': 'confirm-access-control' }, + 'Confirm', + ), + ) } } return () => null @@ -255,8 +379,7 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => { const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } @@ -310,11 +433,14 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => { popupClassName?: string }) => { const { isOpen } = useDropdownMenuContext() - if (!isOpen) - return null + if (!isOpen) return null return ( - <div data-testid="dropdown-menu-content" role="menu" className={[className, popupClassName].filter(Boolean).join(' ')}> + <div + data-testid="dropdown-menu-content" + role="menu" + className={[className, popupClassName].filter(Boolean).join(' ')} + > {children} </div> ) @@ -357,7 +483,7 @@ vi.mock('@/features/tag-management/components/app-card-tags', () => ({ tags, canBindOrUnbindTags, }: { - tags?: { id: string, name: string }[] + tags?: { id: string; name: string }[] canBindOrUnbindTags?: boolean }) => { return React.createElement( @@ -366,7 +492,9 @@ vi.mock('@/features/tag-management/components/app-card-tags', () => ({ 'aria-label': 'tag-selector', 'data-can-bind-or-unbind-tags': String(Boolean(canBindOrUnbindTags)), }, - tags?.map((tag: { id: string, name: string }) => React.createElement('span', { key: tag.id }, tag.name)), + tags?.map((tag: { id: string; name: string }) => + React.createElement('span', { key: tag.id }, tag.name), + ), ) }, })) @@ -376,32 +504,33 @@ vi.mock('@/app/components/app/type-selector', () => ({ AppTypeIcon: () => React.createElement('div', { 'data-testid': 'app-type-icon' }), })) -const createMockApp = (overrides: Partial<App> = {}): App => ({ - id: 'test-app-id', - name: 'Test App', - description: 'Test app description', - mode: AppModeEnum.CHAT, - icon: '🤖', - icon_type: 'emoji' as const, - icon_background: '#FFEAD5', - icon_url: null, - author_name: 'Test Author', - created_by: 'user-1', - maintainer: 'user-1', - created_at: 1704067200, - updated_at: 1704153600, - tags: [], - use_icon_as_answer_icon: false, - max_active_requests: null, - access_mode: AccessMode.PUBLIC, - has_draft_trigger: false, - enable_site: true, - enable_api: true, - api_rpm: 60, - api_rph: 3600, - is_demo: false, - ...overrides, -} as App) +const createMockApp = (overrides: Partial<App> = {}): App => + ({ + id: 'test-app-id', + name: 'Test App', + description: 'Test app description', + mode: AppModeEnum.CHAT, + icon: '🤖', + icon_type: 'emoji' as const, + icon_background: '#FFEAD5', + icon_url: null, + author_name: 'Test Author', + created_by: 'user-1', + maintainer: 'user-1', + created_at: 1704067200, + updated_at: 1704153600, + tags: [], + use_icon_as_answer_icon: false, + max_active_requests: null, + access_mode: AccessMode.PUBLIC, + has_draft_trigger: false, + enable_site: true, + enable_api: true, + api_rpm: 60, + api_rph: 3600, + is_demo: false, + ...overrides, + }) as App describe('AppCard', () => { const mockApp = createMockApp() @@ -434,7 +563,9 @@ describe('AppCard', () => { author_name: 'Readonly Author', created_by: 'another-user', maintainer: 'another-user', - tags: [{ id: 'tag-preview', name: 'Readonly Tag', type: 'app' as const, binding_count: '' }], + tags: [ + { id: 'tag-preview', name: 'Readonly Tag', type: 'app' as const, binding_count: '' }, + ], permission_keys: [AppACLPermission.Preview], }) @@ -450,7 +581,9 @@ describe('AppCard', () => { expect(tagSelector).toHaveAttribute('data-can-bind-or-unbind-tags', 'false') expect(screen.queryByRole('link', { name: 'Preview Only App' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'app.studio.starApp' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.operation.more' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.more' }), + ).not.toBeInTheDocument() fireEvent.click(tagSelector) @@ -479,9 +612,13 @@ describe('AppCard', () => { expect(card).toHaveClass('opacity-60') expect(card).toHaveAttribute('aria-disabled', 'true') expect(screen.getByText('Readonly Author')).toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'Preview Only Starred App' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'Preview Only Starred App' }), + ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'app.studio.starApp' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.operation.more' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.more' }), + ).not.toBeInTheDocument() fireEvent.click(card) @@ -571,7 +708,10 @@ describe('AppCard', () => { render(<AppCard app={editableApp} />) - expect(screen.getByLabelText('tag-selector')).toHaveAttribute('data-can-bind-or-unbind-tags', 'true') + expect(screen.getByLabelText('tag-selector')).toHaveAttribute( + 'data-can-bind-or-unbind-tags', + 'true', + ) }) it('should allow workspace app tag management permission to bind tags without app edit permission', () => { @@ -586,7 +726,10 @@ describe('AppCard', () => { render(<AppCard app={tagManageApp} />) - expect(screen.getByLabelText('tag-selector')).toHaveAttribute('data-can-bind-or-unbind-tags', 'true') + expect(screen.getByLabelText('tag-selector')).toHaveAttribute( + 'data-can-bind-or-unbind-tags', + 'true', + ) }) it('should render existing app tags as readonly without app edit or workspace tag management permission', () => { @@ -601,7 +744,10 @@ describe('AppCard', () => { render(<AppCard app={readonlyApp} />) - expect(screen.getByLabelText('tag-selector')).toHaveAttribute('data-can-bind-or-unbind-tags', 'false') + expect(screen.getByLabelText('tag-selector')).toHaveAttribute( + 'data-can-bind-or-unbind-tags', + 'false', + ) }) it('should render with onRefresh callback', () => { @@ -704,7 +850,9 @@ describe('AppCard', () => { it('should reveal operations trigger when card receives keyboard focus', () => { render(<AppCard app={mockApp} />) - const operationsTriggerWrapper = screen.getByTestId('dropdown-menu-trigger').closest('.absolute') + const operationsTriggerWrapper = screen + .getByTestId('dropdown-menu-trigger') + .closest('.absolute') expect(operationsTriggerWrapper).toHaveClass('top-2') expect(operationsTriggerWrapper).toHaveClass('right-2') @@ -712,7 +860,9 @@ describe('AppCard', () => { expect(operationsTriggerWrapper).toHaveClass('group-focus-within:opacity-100') expect(operationsTriggerWrapper).not.toHaveClass('w-[120px]') expect(screen.getByTestId('dropdown-menu-trigger')).toHaveClass('focus-visible:ring-2') - expect(screen.getByTestId('dropdown-menu-trigger')).toHaveClass('focus-visible:ring-state-accent-solid') + expect(screen.getByTestId('dropdown-menu-trigger')).toHaveClass( + 'focus-visible:ring-state-accent-solid', + ) }) it('should show edit option when dropdown menu is opened', async () => { @@ -1036,7 +1186,10 @@ describe('AppCard', () => { await waitFor(() => { expect(mockDeleteAppMutation).toHaveBeenCalled() - expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('Delete failed') }) + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: expect.stringContaining('Delete failed'), + }) }) }) @@ -1054,7 +1207,10 @@ describe('AppCard', () => { await waitFor(() => { expect(mockDeleteAppMutation).toHaveBeenCalled() - expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.appDeleteFailed' }) + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: 'app.appDeleteFailed', + }) }) }) @@ -1157,7 +1313,7 @@ describe('AppCard', () => { }) it('should handle copy failure', async () => { - (appsService.copyApp as Mock).mockRejectedValueOnce(new Error('Copy failed')) + ;(appsService.copyApp as Mock).mockRejectedValueOnce(new Error('Copy failed')) render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />) @@ -1174,7 +1330,10 @@ describe('AppCard', () => { await waitFor(() => { expect(appsService.copyApp).toHaveBeenCalled() - expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' }) + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: 'app.newApp.appCreateFailed', + }) }) }) @@ -1192,7 +1351,7 @@ describe('AppCard', () => { }) it('should handle export failure', async () => { - (appsService.exportAppConfig as Mock).mockRejectedValueOnce(new Error('Export failed')) + ;(appsService.exportAppConfig as Mock).mockRejectedValueOnce(new Error('Export failed')) render(<AppCard app={mockApp} />) @@ -1203,7 +1362,10 @@ describe('AppCard', () => { await waitFor(() => { expect(appsService.exportAppConfig).toHaveBeenCalled() - expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' }) + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: 'app.exportFailed', + }) }) }) }) @@ -1326,7 +1488,7 @@ describe('AppCard', () => { }) it('should show DSL export modal when workflow has secret variables', async () => { - (workflowService.fetchWorkflowDraft as Mock).mockResolvedValueOnce({ + ;(workflowService.fetchWorkflowDraft as Mock).mockResolvedValueOnce({ environment_variables: [{ value_type: 'secret', name: 'API_KEY' }], }) @@ -1344,7 +1506,7 @@ describe('AppCard', () => { }) it('should export workflow directly when environment_variables is undefined', async () => { - (workflowService.fetchWorkflowDraft as Mock).mockResolvedValueOnce({}) + ;(workflowService.fetchWorkflowDraft as Mock).mockResolvedValueOnce({}) const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } render(<AppCard app={workflowApp} />) @@ -1355,7 +1517,9 @@ describe('AppCard', () => { }) await waitFor(() => { - expect(workflowService.fetchWorkflowDraft).toHaveBeenCalledWith(`/apps/${workflowApp.id}/workflows/draft`) + expect(workflowService.fetchWorkflowDraft).toHaveBeenCalledWith( + `/apps/${workflowApp.id}/workflows/draft`, + ) expect(appsService.exportAppConfig).toHaveBeenCalledWith({ appID: workflowApp.id, include: false, @@ -1380,7 +1544,7 @@ describe('AppCard', () => { }) it('should close DSL export modal when onClose is called', async () => { - (workflowService.fetchWorkflowDraft as Mock).mockResolvedValueOnce({ + ;(workflowService.fetchWorkflowDraft as Mock).mockResolvedValueOnce({ environment_variables: [{ value_type: 'secret', name: 'API_KEY' }], }) @@ -1474,7 +1638,7 @@ describe('AppCard', () => { }) it('should handle edit failure', async () => { - (appsService.updateAppInfo as Mock).mockRejectedValueOnce(new Error('Edit failed')) + ;(appsService.updateAppInfo as Mock).mockRejectedValueOnce(new Error('Edit failed')) render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />) @@ -1491,12 +1655,15 @@ describe('AppCard', () => { await waitFor(() => { expect(appsService.updateAppInfo).toHaveBeenCalled() - expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('Edit failed') }) + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: expect.stringContaining('Edit failed'), + }) }) }) it('should fall back to the default edit failure message', async () => { - (appsService.updateAppInfo as Mock).mockRejectedValueOnce({ message: '' }) + ;(appsService.updateAppInfo as Mock).mockRejectedValueOnce({ message: '' }) render(<AppCard app={mockApp} />) @@ -1554,7 +1721,7 @@ describe('AppCard', () => { }) it('should handle workflow draft fetch failure during export', async () => { - (workflowService.fetchWorkflowDraft as Mock).mockRejectedValueOnce(new Error('Fetch failed')) + ;(workflowService.fetchWorkflowDraft as Mock).mockRejectedValueOnce(new Error('Fetch failed')) const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } render(<AppCard app={workflowApp} />) @@ -1566,7 +1733,10 @@ describe('AppCard', () => { await waitFor(() => { expect(workflowService.fetchWorkflowDraft).toHaveBeenCalled() - expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' }) + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: 'app.exportFailed', + }) }) }) }) @@ -1630,8 +1800,7 @@ describe('AppCard', () => { // Click on tag selector wrapper to trigger stopPropagation const tagSelectorWrapper = tagSelector.closest('div') - if (tagSelectorWrapper) - fireEvent.click(tagSelectorWrapper) + if (tagSelectorWrapper) fireEvent.click(tagSelectorWrapper) }) it('should close operations menu after selecting an item', async () => { @@ -1690,17 +1859,18 @@ describe('AppCard', () => { }) it('should handle open in explore API failure', async () => { - (exploreService.fetchInstalledAppList as Mock).mockRejectedValueOnce(new Error('API Error')) + ;(exploreService.fetchInstalledAppList as Mock).mockRejectedValueOnce(new Error('API Error')) // Configure mockOpenAsyncWindow to call the callback and trigger error - mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => { - try { - await callback() - } - catch (err) { - options?.onError?.(err) - } - }) + mockOpenAsyncWindow.mockImplementationOnce( + async (callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => { + try { + await callback() + } catch (err) { + options?.onError?.(err) + } + }, + ) render(<AppCard app={mockApp} />) @@ -1716,9 +1886,14 @@ describe('AppCard', () => { }) it('should show string errors from open in explore onError callback', async () => { - mockOpenAsyncWindow.mockImplementationOnce(async (_callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => { - options?.onError?.('Window failed') - }) + mockOpenAsyncWindow.mockImplementationOnce( + async ( + _callback: () => Promise<string>, + options?: { onError?: (err: unknown) => void }, + ) => { + options?.onError?.('Window failed') + }, + ) render(<AppCard app={mockApp} />) @@ -1747,7 +1922,10 @@ describe('AppCard', () => { }) await waitFor(() => { - expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'Window rejected' }) + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: 'Window rejected', + }) }) }) }) @@ -1790,17 +1968,18 @@ describe('AppCard', () => { }) it('should handle case when installed_apps is empty array', async () => { - (exploreService.fetchInstalledAppList as Mock).mockResolvedValueOnce({ installed_apps: [] }) + ;(exploreService.fetchInstalledAppList as Mock).mockResolvedValueOnce({ installed_apps: [] }) // Configure mockOpenAsyncWindow to call the callback and trigger error - mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => { - try { - await callback() - } - catch (err) { - options?.onError?.(err) - } - }) + mockOpenAsyncWindow.mockImplementationOnce( + async (callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => { + try { + await callback() + } catch (err) { + options?.onError?.(err) + } + }, + ) render(<AppCard app={mockApp} />) @@ -1820,7 +1999,9 @@ describe('AppCard', () => { }) it('should handle case when API throws in callback', async () => { - (exploreService.fetchInstalledAppList as Mock).mockRejectedValueOnce(new Error('Network error')) + ;(exploreService.fetchInstalledAppList as Mock).mockRejectedValueOnce( + new Error('Network error'), + ) // Configure mockOpenAsyncWindow to call the callback without catching mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>) => { @@ -2052,23 +2233,56 @@ describe('AppCard', () => { describe('Delete dialog guards', () => { const createMockAlertDialogModule = () => ({ - AlertDialog: ({ open, onOpenChange, children }: { open: boolean, onOpenChange?: (open: boolean) => void, children: React.ReactNode }) => ( - open - ? ( - <div role="alertdialog"> - <button type="button" data-testid="keep-open-dialog" onClick={() => onOpenChange?.(true)}>Keep open</button> - <button type="button" data-testid="force-close-dialog" onClick={() => onOpenChange?.(false)}>Force close</button> - {children} - </div> - ) - : null - ), + AlertDialog: ({ + open, + onOpenChange, + children, + }: { + open: boolean + onOpenChange?: (open: boolean) => void + children: React.ReactNode + }) => + open ? ( + <div role="alertdialog"> + <button + type="button" + data-testid="keep-open-dialog" + onClick={() => onOpenChange?.(true)} + > + Keep open + </button> + <button + type="button" + data-testid="force-close-dialog" + onClick={() => onOpenChange?.(false)} + > + Force close + </button> + {children} + </div> + ) : null, AlertDialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - AlertDialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + AlertDialogDescription: ({ children }: { children: React.ReactNode }) => ( + <div>{children}</div> + ), AlertDialogActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - AlertDialogCancelButton: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type="button" {...props}>{children}</button>, - AlertDialogConfirmButton: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean }) => <button type="button" {...props}>{children}</button>, + AlertDialogCancelButton: ({ + children, + ...props + }: React.ButtonHTMLAttributes<HTMLButtonElement>) => ( + <button type="button" {...props}> + {children} + </button> + ), + AlertDialogConfirmButton: ({ + children, + ...props + }: React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean }) => ( + <button type="button" {...props}> + {children} + </button> + ), }) it('should reset delete input when dialog closes', async () => { diff --git a/web/app/components/apps/__tests__/creators-filter.spec.tsx b/web/app/components/apps/__tests__/creators-filter.spec.tsx index e1446278fc399b..455d9566fbcfa9 100644 --- a/web/app/components/apps/__tests__/creators-filter.spec.tsx +++ b/web/app/components/apps/__tests__/creators-filter.spec.tsx @@ -40,7 +40,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -68,11 +69,13 @@ describe('CreatorsFilter', () => { fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.creators/i })) - const options = screen.getAllByRole('button').filter(button => - ['Alice', 'Bob', 'Zoe'].some(name => button.textContent?.includes(name)), - ) + const options = screen + .getAllByRole('button') + .filter((button) => + ['Alice', 'Bob', 'Zoe'].some((name) => button.textContent?.includes(name)), + ) - expect(options.map(option => option.textContent)).toEqual([ + expect(options.map((option) => option.textContent)).toEqual([ expect.stringContaining('Alice'), expect.stringContaining('Bob'), expect.stringContaining('Zoe'), @@ -102,7 +105,9 @@ describe('CreatorsFilter', () => { }) it('should remove selected creators from the trigger reset and menu reset controls', () => { - const { rerender } = render(<CreatorsFilter value={['member-2', 'member-3']} onChange={mockOnChange} />) + const { rerender } = render( + <CreatorsFilter value={['member-2', 'member-3']} onChange={mockOnChange} />, + ) const trigger = screen.getByRole('button', { name: /app\.studio\.filters\.creators/i }) fireEvent.click(within(trigger).getByRole('button', { name: 'app.studio.filters.reset' })) diff --git a/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx b/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx index 77faa314de10a4..3d02be678f3689 100644 --- a/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx +++ b/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx @@ -17,7 +17,8 @@ describe('ImportFromMarketplaceTemplateModal', () => { data: { id: 'human-input-writing', template_name: 'Human Input: Writing Assistant', - overview: 'Send your creative brief, get a high-quality draft, and review before publishing.', + overview: + 'Send your creative brief, get a high-quality draft, and review before publishing.', icon: 'technologist', icon_background: '#D1FAE5', icon_file_key: '', diff --git a/web/app/components/apps/__tests__/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx index 8f1a92ee6203d2..b20eabdf0c824e 100644 --- a/web/app/components/apps/__tests__/index.spec.tsx +++ b/web/app/components/apps/__tests__/index.spec.tsx @@ -106,7 +106,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -132,7 +133,7 @@ vi.mock('@/next/navigation', () => ({ vi.mock('../list', () => { const MockList = () => { - const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel) + const setShowTryAppPanel = useContextSelector(AppListContext, (ctx) => ctx.setShowTryAppPanel) return React.createElement( 'div', { 'data-testid': 'apps-list' }, @@ -141,10 +142,11 @@ vi.mock('../list', () => { 'button', { 'data-testid': 'open-preview', - 'onClick': () => setShowTryAppPanel(true, { - appId: mockTemplateApp.app_id, - app: mockTemplateApp, - }), + onClick: () => + setShowTryAppPanel(true, { + appId: mockTemplateApp.app_id, + app: mockTemplateApp, + }), }, 'Open Preview', ), @@ -155,51 +157,82 @@ vi.mock('../list', () => { }) vi.mock('../../explore/try-app', () => ({ - default: ({ onCreate, onClose }: { onCreate: () => void, onClose: () => void }) => ( + default: ({ onCreate, onClose }: { onCreate: () => void; onClose: () => void }) => ( <div data-testid="try-app-panel"> - <button data-testid="try-app-create" onClick={onCreate}>Create</button> - <button data-testid="try-app-close" onClick={onClose}>Close</button> + <button data-testid="try-app-create" onClick={onCreate}> + Create + </button> + <button data-testid="try-app-close" onClick={onClose}> + Close + </button> </div> ), })) vi.mock('../../explore/create-app-modal', () => ({ - default: ({ show, onConfirm, onHide }: { show: boolean, onConfirm: (payload: Record<string, string>) => Promise<void>, onHide: () => void }) => show - ? ( - <div data-testid="create-app-modal"> - <button - data-testid="confirm-create" - onClick={() => onConfirm({ + default: ({ + show, + onConfirm, + onHide, + }: { + show: boolean + onConfirm: (payload: Record<string, string>) => Promise<void> + onHide: () => void + }) => + show ? ( + <div data-testid="create-app-modal"> + <button + data-testid="confirm-create" + onClick={() => + onConfirm({ name: 'Created App', icon_type: 'emoji', icon: '🤖', icon_background: '#fff', description: 'created from preview', - })} - > - Confirm - </button> - <button data-testid="hide-create" onClick={onHide}>Hide</button> - </div> - ) - : null, + }) + } + > + Confirm + </button> + <button data-testid="hide-create" onClick={onHide}> + Hide + </button> + </div> + ) : null, })) vi.mock('../../app/create-from-dsl-modal/dsl-confirm-modal', () => ({ - default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => ( + default: ({ onConfirm, onCancel }: { onConfirm: () => void; onCancel: () => void }) => ( <div data-testid="dsl-confirm-modal"> - <button data-testid="confirm-dsl" onClick={onConfirm}>Confirm DSL</button> - <button data-testid="cancel-dsl" onClick={onCancel}>Cancel DSL</button> + <button data-testid="confirm-dsl" onClick={onConfirm}> + Confirm DSL + </button> + <button data-testid="cancel-dsl" onClick={onCancel}> + Cancel DSL + </button> </div> ), })) vi.mock('../import-from-marketplace-template-modal', () => ({ - default: ({ templateId, onClose, onConfirm }: { templateId: string, onClose: () => void, onConfirm: (dsl: string) => void }) => ( + default: ({ + templateId, + onClose, + onConfirm, + }: { + templateId: string + onClose: () => void + onConfirm: (dsl: string) => void + }) => ( <div data-testid="marketplace-template-modal"> <span data-testid="template-id">{templateId}</span> - <button data-testid="close-template" onClick={onClose}>Close Template</button> - <button data-testid="confirm-template" onClick={() => onConfirm('yaml-dsl-content')}>Confirm Template</button> + <button data-testid="close-template" onClick={onClose}> + Close Template + </button> + <button data-testid="confirm-template" onClick={() => onConfirm('yaml-dsl-content')}> + Confirm Template + </button> </div> ), })) @@ -213,13 +246,14 @@ vi.mock('@/utils/create-app-tracking', () => ({ })) describe('Apps', () => { - const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, + const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, }, - }, - }) + }) const renderWithClient = (ui: React.ReactElement) => { const queryClient = createQueryClient() @@ -306,9 +340,14 @@ describe('Apps', () => { }) it('should track template preview creation after a successful import', async () => { - mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { - options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) - }) + mockHandleImportDSL.mockImplementation( + async ( + _payload: unknown, + options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }, + ) => { + options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) + }, + ) renderWithClient(<Apps />) @@ -327,12 +366,16 @@ describe('Apps', () => { }) it('should track template preview creation after confirming a pending import', async () => { - mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { - options.onPending?.() - }) - mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { - options.onSuccess?.({ app_mode: AppModeEnum.WORKFLOW }) - }) + mockHandleImportDSL.mockImplementation( + async (_payload: unknown, options: { onPending?: () => void }) => { + options.onPending?.() + }, + ) + mockHandleImportDSLConfirm.mockImplementation( + async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { + options.onSuccess?.({ app_mode: AppModeEnum.WORKFLOW }) + }, + ) renderWithClient(<Apps />) @@ -353,9 +396,11 @@ describe('Apps', () => { }) it('should close the dsl confirm modal when the pending import is canceled', async () => { - mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { - options.onPending?.() - }) + mockHandleImportDSL.mockImplementation( + async (_payload: unknown, options: { onPending?: () => void }) => { + options.onPending?.() + }, + ) renderWithClient(<Apps />) @@ -421,9 +466,14 @@ describe('Apps', () => { }) it('should import DSL from marketplace template on confirm', async () => { - mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { - options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) - }) + mockHandleImportDSL.mockImplementation( + async ( + _payload: unknown, + options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }, + ) => { + options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) + }, + ) mockSearchParams = new URLSearchParams('template-id=tpl-42') renderWithClient(<Apps />) @@ -454,12 +504,16 @@ describe('Apps', () => { }) it('should track marketplace template creation after confirming a pending import', async () => { - mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { - options.onPending?.() - }) - mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { - options.onSuccess?.({ app_mode: AppModeEnum.WORKFLOW }) - }) + mockHandleImportDSL.mockImplementation( + async (_payload: unknown, options: { onPending?: () => void }) => { + options.onPending?.() + }, + ) + mockHandleImportDSLConfirm.mockImplementation( + async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { + options.onSuccess?.({ app_mode: AppModeEnum.WORKFLOW }) + }, + ) mockSearchParams = new URLSearchParams('template-id=tpl-42') renderWithClient(<Apps />) diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 43e59cfca610e3..0ef8d00eae8fb2 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -5,7 +5,6 @@ import * as React from 'react' import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' - import List from '../list' vi.mock('react-i18next', async () => { @@ -25,9 +24,11 @@ vi.mock('react-i18next', async () => { const mockAppListInfiniteOptions = vi.hoisted(() => vi.fn((options: unknown) => options)) const mockAppStarredListQueryOptions = vi.hoisted(() => vi.fn((options: unknown) => options)) -const mockUseWorkflowOnlineUsers = vi.hoisted(() => vi.fn((_options: unknown) => ({ - onlineUsersMap: {}, -}))) +const mockUseWorkflowOnlineUsers = vi.hoisted(() => + vi.fn((_options: unknown) => ({ + onlineUsersMap: {}, + })), +) const mockReplace = vi.fn() const mockRouter = { replace: mockReplace } @@ -113,7 +114,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -154,11 +156,23 @@ vi.mock('@/service/use-common', () => ({ })) vi.mock('@/features/tag-management/components/tag-filter', () => ({ - TagFilter: ({ value, onChange, onOpenTagManagement }: { value: string[], onChange: (value: string[]) => void, onOpenTagManagement: () => void }) => ( + TagFilter: ({ + value, + onChange, + onOpenTagManagement, + }: { + value: string[] + onChange: (value: string[]) => void + onOpenTagManagement: () => void + }) => ( <div> - <button type="button" onClick={() => onChange(['tag-1'])}>common.tag.placeholder</button> + <button type="button" onClick={() => onChange(['tag-1'])}> + common.tag.placeholder + </button> <span data-testid="tag-filter-value">{value.join(',')}</span> - <button type="button" onClick={onOpenTagManagement}>Manage tags</button> + <button type="button" onClick={onOpenTagManagement}> + Manage tags + </button> </div> ), })) @@ -189,37 +203,39 @@ const mockServiceState = { } const defaultAppData = { - pages: [{ - data: [ - { - id: 'app-1', - name: 'Test App 1', - description: 'Description 1', - mode: AppModeEnum.CHAT, - icon: '🤖', - icon_type: 'emoji', - icon_background: '#FFEAD5', - tags: [], - author_name: 'Author 1', - created_at: 1704067200, - updated_at: 1704153600, - }, - { - id: 'app-2', - name: 'Test App 2', - description: 'Description 2', - mode: AppModeEnum.WORKFLOW, - icon: '⚙️', - icon_type: 'emoji', - icon_background: '#E4FBCC', - tags: [], - author_name: 'Author 2', - created_at: 1704067200, - updated_at: 1704153600, - }, - ], - total: 2, - }], + pages: [ + { + data: [ + { + id: 'app-1', + name: 'Test App 1', + description: 'Description 1', + mode: AppModeEnum.CHAT, + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + tags: [], + author_name: 'Author 1', + created_at: 1704067200, + updated_at: 1704153600, + }, + { + id: 'app-2', + name: 'Test App 2', + description: 'Description 2', + mode: AppModeEnum.WORKFLOW, + icon: '⚙️', + icon_type: 'emoji', + icon_background: '#E4FBCC', + tags: [], + author_name: 'Author 2', + created_at: 1704067200, + updated_at: 1704153600, + }, + ], + total: 2, + }, + ], } let mockAppData = defaultAppData @@ -287,24 +303,98 @@ vi.mock('@/next/dynamic', () => ({ } } if (fnString.includes('create-from-dsl-modal')) { - return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) { - if (!show) - return null - return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success')) + return function MockCreateFromDSLModal({ + show, + onClose, + onSuccess, + }: { + show: boolean + onClose: () => void + onSuccess: () => void + }) { + if (!show) return null + return React.createElement( + 'div', + { 'data-testid': 'create-dsl-modal' }, + React.createElement( + 'button', + { onClick: onClose, 'data-testid': 'close-dsl-modal' }, + 'Close', + ), + React.createElement( + 'button', + { onClick: onSuccess, 'data-testid': 'success-dsl-modal' }, + 'Success', + ), + ) } } if (fnString.includes('create-app-modal')) { - return function MockCreateAppModal({ show, onClose, onSuccess, onCreateFromTemplate }: { show: boolean, onClose: () => void, onSuccess: () => void, onCreateFromTemplate: () => void }) { - if (!show) - return null - return React.createElement('div', { 'data-testid': 'create-app-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-create-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-create-modal' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromTemplate, 'data-testid': 'to-template-modal' }, 'To Template')) + return function MockCreateAppModal({ + show, + onClose, + onSuccess, + onCreateFromTemplate, + }: { + show: boolean + onClose: () => void + onSuccess: () => void + onCreateFromTemplate: () => void + }) { + if (!show) return null + return React.createElement( + 'div', + { 'data-testid': 'create-app-modal' }, + React.createElement( + 'button', + { onClick: onClose, 'data-testid': 'close-create-modal' }, + 'Close', + ), + React.createElement( + 'button', + { onClick: onSuccess, 'data-testid': 'success-create-modal' }, + 'Success', + ), + React.createElement( + 'button', + { onClick: onCreateFromTemplate, 'data-testid': 'to-template-modal' }, + 'To Template', + ), + ) } } if (fnString.includes('create-app-dialog')) { - return function MockCreateAppTemplateDialog({ show, onClose, onSuccess, onCreateFromBlank }: { show: boolean, onClose: () => void, onSuccess: () => void, onCreateFromBlank: () => void }) { - if (!show) - return null - return React.createElement('div', { 'data-testid': 'template-dialog' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-template-dialog' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-template-dialog' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromBlank, 'data-testid': 'to-blank-modal' }, 'To Blank')) + return function MockCreateAppTemplateDialog({ + show, + onClose, + onSuccess, + onCreateFromBlank, + }: { + show: boolean + onClose: () => void + onSuccess: () => void + onCreateFromBlank: () => void + }) { + if (!show) return null + return React.createElement( + 'div', + { 'data-testid': 'template-dialog' }, + React.createElement( + 'button', + { onClick: onClose, 'data-testid': 'close-template-dialog' }, + 'Close', + ), + React.createElement( + 'button', + { onClick: onSuccess, 'data-testid': 'success-template-dialog' }, + 'Success', + ), + React.createElement( + 'button', + { onClick: onCreateFromBlank, 'data-testid': 'to-blank-modal' }, + 'To Blank', + ), + ) } } return () => null @@ -312,24 +402,36 @@ vi.mock('@/next/dynamic', () => ({ })) vi.mock('../app-card', () => ({ - AppCard: ({ app }: { app: { id: string, name: string } }) => { - return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name) + AppCard: ({ app }: { app: { id: string; name: string } }) => { + return React.createElement( + 'div', + { 'data-testid': `app-card-${app.id}`, role: 'article' }, + app.name, + ) }, - AppCardActionBar: ({ app, onRefresh }: { app: { id: string }, onRefresh?: () => void }) => { + AppCardActionBar: ({ app, onRefresh }: { app: { id: string }; onRefresh?: () => void }) => { return React.createElement('button', { 'data-testid': `app-card-action-bar-${app.id}`, - 'type': 'button', - 'onClick': onRefresh, + type: 'button', + onClick: onRefresh, }) }, - default: ({ app }: { app: { id: string, name: string } }) => { - return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name) + default: ({ app }: { app: { id: string; name: string } }) => { + return React.createElement( + 'div', + { 'data-testid': `app-card-${app.id}`, role: 'article' }, + app.name, + ) }, })) vi.mock('../empty', () => ({ default: () => { - return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found') + return React.createElement( + 'div', + { 'data-testid': 'empty-state', role: 'status' }, + 'No apps found', + ) }, })) @@ -368,12 +470,17 @@ const renderList = (searchParams = '', options: RenderListOptions = {}) => { const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({ systemFeatures: { branding: { enabled: false }, ...options.systemFeatures }, }) - return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams }) + return renderWithNuqs( + <SystemFeaturesWrapper> + <List /> + </SystemFeaturesWrapper>, + { searchParams }, + ) } type AppListInfiniteOptions = { input: (pageParam: number) => { query: Record<string, unknown> } - getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined + getNextPageParam: (lastPage: { has_more: boolean; page: number }) => number | undefined } type AppStarredListQueryOptions = { @@ -433,16 +540,28 @@ describe('List', () => { expect(await screen.findByRole('menuitemradio', { name: 'All' }))!.toBeInTheDocument() expect(screen.queryByRole('menuitemradio', { name: 'Types' })).not.toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.types.workflow' }))!.toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.types.advanced' }))!.toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.types.chatbot' }))!.toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.types.agent' }))!.toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.newApp.completeApp' }))!.toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.workflow' }), + )!.toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.advanced' }), + )!.toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.chatbot' }), + )!.toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.agent' }), + )!.toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.newApp.completeApp' }), + )!.toBeInTheDocument() }) it('should render search input', () => { renderList() - expect(screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }))!.toBeInTheDocument() + expect( + screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }), + )!.toBeInTheDocument() }) it('should render tag filter', () => { @@ -464,16 +583,26 @@ describe('List', () => { renderList() const creatorsButton = screen.getByRole('button', { name: 'Creators' }) - const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }) + const searchInput = screen.getByRole('searchbox', { + name: 'app.gotoAnything.actions.searchApplications', + }) const sortButton = screen.getByRole('button', { name: 'Sort by Last modified' }) const snippetsLink = screen.getByRole('link', { name: 'app.studio.viewSnippets' }) const createButton = screen.getByRole('button', { name: 'common.operation.create' }) expect(snippetsLink).toHaveAttribute('href', '/snippets') - expect(creatorsButton.compareDocumentPosition(sortButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - expect(sortButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - expect(searchInput.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - expect(snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect( + creatorsButton.compareDocumentPosition(sortButton) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + expect( + sortButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + expect( + searchInput.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + expect( + snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() }) it('should render app cards when apps exist', () => { @@ -488,10 +617,7 @@ describe('List', () => { const grid = screen.getByTestId('app-card-app-1').parentElement - expect(grid).toHaveClass( - 'grid', - 'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', - ) + expect(grid).toHaveClass('grid', 'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]') }) it('should hide starred section when there are no starred apps', () => { @@ -503,20 +629,22 @@ describe('List', () => { it('should render starred apps before all app cards when starred apps exist', () => { mockStarredAppData = { - data: [{ - id: 'starred-app-1', - name: 'Starred App', - description: 'Starred description', - mode: AppModeEnum.CHAT, - icon: '⭐', - icon_type: 'emoji', - icon_background: '#FFEAD5', - icon_url: null, - tags: [], - author_name: 'Author 1', - created_at: 1704067200, - updated_at: 1704153600, - }], + data: [ + { + id: 'starred-app-1', + name: 'Starred App', + description: 'Starred description', + mode: AppModeEnum.CHAT, + icon: '⭐', + icon_type: 'emoji', + icon_background: '#FFEAD5', + icon_url: null, + tags: [], + author_name: 'Author 1', + created_at: 1704067200, + updated_at: 1704153600, + }, + ], total: 1, page: 1, limit: 100, @@ -533,9 +661,15 @@ describe('List', () => { expect(starredCard).toBeInTheDocument() expect(actionBar).toBeInTheDocument() - expect(starredLabel.compareDocumentPosition(starredCard) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - expect(starredCard.compareDocumentPosition(allAppsLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - expect(allAppsLabel.compareDocumentPosition(firstAppCard) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect( + starredLabel.compareDocumentPosition(starredCard) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + expect( + starredCard.compareDocumentPosition(allAppsLabel) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + expect( + allAppsLabel.compareDocumentPosition(firstAppCard) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() fireEvent.click(actionBar) @@ -570,18 +704,23 @@ describe('List', () => { mockAppData = { pages: [{ data: [], total: 0 }] } const { container } = renderList() - const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none')) - .find(element => element.className.includes('grid-rows-4')) + const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none')).find( + (element) => element.className.includes('grid-rows-4'), + ) - if (!placeholderGrid) - throw new Error('Expected first empty state placeholder grid to render') + if (!placeholderGrid) throw new Error('Expected first empty state placeholder grid to render') expect(placeholderGrid).toHaveClass( 'grid', 'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', 'grid-rows-4', ) - expect(placeholderGrid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'lg:grid-cols-3', 'xl:grid-cols-4') + expect(placeholderGrid).not.toHaveClass( + 'grid-cols-1', + 'sm:grid-cols-2', + 'lg:grid-cols-3', + 'xl:grid-cols-4', + ) }) it('should hide learn dify in first empty state when learn app is disabled', () => { @@ -673,13 +812,17 @@ describe('List', () => { describe('Search Functionality', () => { it('should render search input field', () => { renderList() - expect(screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }))!.toBeInTheDocument() + expect( + screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }), + )!.toBeInTheDocument() }) it('should handle search input change', () => { renderList() - const input = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }) + const input = screen.getByRole('searchbox', { + name: 'app.gotoAnything.actions.searchApplications', + }) fireEvent.change(input, { target: { value: 'test search' } }) expect(mockSetKeywords).toHaveBeenCalledWith('test search') @@ -692,8 +835,7 @@ describe('List', () => { const clearButton = document.querySelector('.i-ri-close-circle-fill')?.closest('button') expect(clearButton)!.toBeInTheDocument() - if (clearButton) - fireEvent.click(clearButton) + if (clearButton) fireEvent.click(clearButton) expect(mockSetKeywords).toHaveBeenCalledWith('') }) @@ -733,7 +875,9 @@ describe('List', () => { renderList() fireEvent.click(screen.getByText('common.tag.placeholder')) - const options = mockAppStarredListQueryOptions.mock.calls.at(-1)?.[0] as AppStarredListQueryOptions + const options = mockAppStarredListQueryOptions.mock.calls.at( + -1, + )?.[0] as AppStarredListQueryOptions expect(options.input).toEqual({ query: { @@ -816,7 +960,9 @@ describe('List', () => { renderList() - expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.create' }), + ).not.toBeInTheDocument() }) }) @@ -879,7 +1025,9 @@ describe('List', () => { it('should render with all filter options visible', () => { renderList() - expect(screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }))!.toBeInTheDocument() + expect( + screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }), + )!.toBeInTheDocument() expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Creators' }))!.toBeInTheDocument() }) @@ -904,11 +1052,21 @@ describe('List', () => { await openAppTypeSelect() expect(await screen.findByRole('menuitemradio', { name: 'All' }))!.toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.types.workflow' }))!.toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.types.advanced' }))!.toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.types.chatbot' }))!.toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.types.agent' }))!.toBeInTheDocument() - expect(await screen.findByRole('menuitemradio', { name: 'app.newApp.completeApp' }))!.toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.workflow' }), + )!.toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.advanced' }), + )!.toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.chatbot' }), + )!.toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.types.agent' }), + )!.toBeInTheDocument() + expect( + await screen.findByRole('menuitemradio', { name: 'app.newApp.completeApp' }), + )!.toBeInTheDocument() }) it('should update category for each app type option click', async () => { @@ -965,8 +1123,7 @@ describe('List', () => { const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) act(() => { - if (mockOnDSLFileDropped) - mockOnDSLFileDropped(mockFile) + if (mockOnDSLFileDropped) mockOnDSLFileDropped(mockFile) }) expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument() @@ -977,8 +1134,7 @@ describe('List', () => { const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) act(() => { - if (mockOnDSLFileDropped) - mockOnDSLFileDropped(mockFile) + if (mockOnDSLFileDropped) mockOnDSLFileDropped(mockFile) }) expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument() @@ -993,8 +1149,7 @@ describe('List', () => { const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) act(() => { - if (mockOnDSLFileDropped) - mockOnDSLFileDropped(mockFile) + if (mockOnDSLFileDropped) mockOnDSLFileDropped(mockFile) }) expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument() diff --git a/web/app/components/apps/app-card-skeleton.tsx b/web/app/components/apps/app-card-skeleton.tsx index 1c189103ee2e70..853575c03d19e3 100644 --- a/web/app/components/apps/app-card-skeleton.tsx +++ b/web/app/components/apps/app-card-skeleton.tsx @@ -16,7 +16,7 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps) return ( <> - {skeletonKeys.map(key => ( + {skeletonKeys.map((key) => ( <div key={key} className="h-[160px] overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4 shadow-xs" diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index aa96511ae890ad..c4bd32479d4ab2 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -25,11 +25,7 @@ import { } from '@langgenius/dify-ui/dropdown-menu' import { Field, FieldControl, FieldLabel } from '@langgenius/dify-ui/field' import { toast } from '@langgenius/dify-ui/toast' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useCallback, useId, useMemo, useState } from 'react' @@ -58,7 +54,11 @@ import { fetchWorkflowDraft } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { getRedirection, getRedirectionPath } from '@/utils/app-redirection' import { downloadBlob } from '@/utils/download' -import { getAppACLCapabilities, hasOnlyAppPreviewPermission, hasPermission } from '@/utils/permission' +import { + getAppACLCapabilities, + hasOnlyAppPreviewPermission, + hasPermission, +} from '@/utils/permission' import { formatTime } from '@/utils/time' import { basePath } from '@/utils/var' @@ -71,9 +71,12 @@ const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-m const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { ssr: false, }) -const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), { - ssr: false, -}) +const DSLExportConfirmModal = dynamic( + () => import('@/app/components/workflow/dsl-export-confirm-modal'), + { + ssr: false, + }, +) const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), { ssr: false, }) @@ -117,22 +120,26 @@ function requiresPublishedWorkflowInExplore(app: App) { function AppAccessModeIcon({ accessMode }: AppAccessModeIconProps) { const { t } = useTranslation() - if (!accessMode) - return null + if (!accessMode) return null const iconClassName = ACCESS_MODE_ICON_CLASS_NAMES[accessMode] const labelKey = ACCESS_MODE_LABEL_KEYS[accessMode] - if (!iconClassName || !labelKey) - return null + if (!iconClassName || !labelKey) return null - const label = t($ => $[labelKey], { ns: 'app' }) + const label = t(($) => $[labelKey], { ns: 'app' }) return ( <div className="absolute right-3 bottom-3 flex size-4 items-center justify-center"> <Tooltip> <TooltipTrigger - render={<span role="img" aria-label={label} className={cn(iconClassName, 'size-4 text-text-quaternary')} />} + render={ + <span + role="img" + aria-label={label} + className={cn(iconClassName, 'size-4 text-text-quaternary')} + /> + } /> <TooltipContent>{label}</TooltipContent> </Tooltip> @@ -182,7 +189,8 @@ function AppCardOperationsMenu({ const hasEditGroup = shouldShowEditOption const hasCreateExportGroup = shouldShowDuplicateOption || shouldShowExportOption const hasSwitchOrExploreGroup = shouldShowSwitchOption || shouldShowOpenInExploreOption - const hasAccessDeleteGroup = shouldShowAccessControlOption || shouldShowAccessConfigOption || shouldShowDeleteOption + const hasAccessDeleteGroup = + shouldShowAccessControlOption || shouldShowAccessConfigOption || shouldShowDeleteOption function handleMenuAction(e: MouseEvent<HTMLElement>, action: () => void) { e.stopPropagation() @@ -194,23 +202,25 @@ function AppCardOperationsMenu({ e.stopPropagation() e.preventDefault() if (requiresPublishedWorkflowInExplore(app) && !app.workflow?.id) { - toast.error(t($ => $.notPublishedYet, { ns: 'app' })) + toast.error(t(($) => $.notPublishedYet, { ns: 'app' })) return } try { - await openAsyncWindow(async () => { - const { installed_apps } = await fetchInstalledAppList(app.id) - if (installed_apps?.length > 0) - return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}` - throw new Error(t($ => $.notPublishedYet, { ns: 'app' })) - }, { - onError: (err) => { - toast.error(`${err.message || err}`) + await openAsyncWindow( + async () => { + const { installed_apps } = await fetchInstalledAppList(app.id) + if (installed_apps?.length > 0) + return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}` + throw new Error(t(($) => $.notPublishedYet, { ns: 'app' })) }, - }) - } - catch (e: unknown) { + { + onError: (err) => { + toast.error(`${err.message || err}`) + }, + }, + ) + } catch (e: unknown) { const message = e instanceof Error ? e.message : `${e}` toast.error(message) } @@ -219,60 +229,76 @@ function AppCardOperationsMenu({ return ( <> {shouldShowEditOption && ( - <DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onEdit)}> - <span className="system-sm-regular text-text-secondary">{t($ => $.editApp, { ns: 'app' })}</span> + <DropdownMenuItem className="gap-2 px-3" onClick={(e) => handleMenuAction(e, onEdit)}> + <span className="system-sm-regular text-text-secondary"> + {t(($) => $.editApp, { ns: 'app' })} + </span> </DropdownMenuItem> )} - {hasEditGroup && (hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( - <DropdownMenuSeparator /> - )} + {hasEditGroup && + (hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( + <DropdownMenuSeparator /> + )} {shouldShowDuplicateOption && ( - <DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onDuplicate)}> - <span className="system-sm-regular text-text-secondary">{t($ => $.duplicate, { ns: 'app' })}</span> + <DropdownMenuItem className="gap-2 px-3" onClick={(e) => handleMenuAction(e, onDuplicate)}> + <span className="system-sm-regular text-text-secondary"> + {t(($) => $.duplicate, { ns: 'app' })} + </span> </DropdownMenuItem> )} {shouldShowExportOption && ( - <DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onExport)}> - <span className="system-sm-regular text-text-secondary">{t($ => $.export, { ns: 'app' })}</span> + <DropdownMenuItem className="gap-2 px-3" onClick={(e) => handleMenuAction(e, onExport)}> + <span className="system-sm-regular text-text-secondary"> + {t(($) => $.export, { ns: 'app' })} + </span> </DropdownMenuItem> )} {hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( <DropdownMenuSeparator /> )} {shouldShowSwitchOption && ( - <DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onSwitch)}> - <span className="text-sm/5 text-text-secondary">{t($ => $.switch, { ns: 'app' })}</span> + <DropdownMenuItem className="gap-2 px-3" onClick={(e) => handleMenuAction(e, onSwitch)}> + <span className="text-sm/5 text-text-secondary">{t(($) => $.switch, { ns: 'app' })}</span> </DropdownMenuItem> )} {shouldShowOpenInExploreOption && ( <DropdownMenuItem className="gap-2 px-3" onClick={handleOpenInstalledApp}> - <span className="system-sm-regular text-text-secondary">{t($ => $.openInExplore, { ns: 'app' })}</span> + <span className="system-sm-regular text-text-secondary"> + {t(($) => $.openInExplore, { ns: 'app' })} + </span> </DropdownMenuItem> )} - {hasSwitchOrExploreGroup && hasAccessDeleteGroup && ( - <DropdownMenuSeparator /> - )} + {hasSwitchOrExploreGroup && hasAccessDeleteGroup && <DropdownMenuSeparator />} {shouldShowAccessControlOption && ( - <DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onAccessControl)}> - <span className="text-sm/5 text-text-secondary">{t($ => $.accessControl, { ns: 'app' })}</span> + <DropdownMenuItem + className="gap-2 px-3" + onClick={(e) => handleMenuAction(e, onAccessControl)} + > + <span className="text-sm/5 text-text-secondary"> + {t(($) => $.accessControl, { ns: 'app' })} + </span> </DropdownMenuItem> )} {shouldShowAccessConfigOption && ( - <DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onAccessConfig)}> - <span className="text-sm/5 text-text-secondary">{t($ => $['settings.resourceAccess'], { ns: 'common' })}</span> + <DropdownMenuItem + className="gap-2 px-3" + onClick={(e) => handleMenuAction(e, onAccessConfig)} + > + <span className="text-sm/5 text-text-secondary"> + {t(($) => $['settings.resourceAccess'], { ns: 'common' })} + </span> </DropdownMenuItem> )} - {(shouldShowAccessControlOption || shouldShowAccessConfigOption) && shouldShowDeleteOption && ( - <DropdownMenuSeparator /> - )} + {(shouldShowAccessControlOption || shouldShowAccessConfigOption) && + shouldShowDeleteOption && <DropdownMenuSeparator />} {shouldShowDeleteOption && ( <DropdownMenuItem variant="destructive" className="gap-2 px-3" - onClick={e => handleMenuAction(e, onDelete)} + onClick={(e) => handleMenuAction(e, onDelete)} > <span className="system-sm-regular"> - {t($ => $['operation.delete'], { ns: 'common' })} + {t(($) => $['operation.delete'], { ns: 'common' })} </span> </DropdownMenuItem> )} @@ -280,7 +306,10 @@ function AppCardOperationsMenu({ ) } -type AppCardOperationsMenuContentProps = Omit<AppCardOperationsMenuProps, 'shouldShowOpenInExploreOption'> +type AppCardOperationsMenuContentProps = Omit< + AppCardOperationsMenuProps, + 'shouldShowOpenInExploreOption' +> function AppCardOperationsMenuContent(props: AppCardOperationsMenuContentProps) { const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) @@ -288,14 +317,14 @@ function AppCardOperationsMenuContent(props: AppCardOperationsMenuContentProps) appId: props.app.id, enabled: systemFeatures.webapp_auth.enabled, }) - const needsPublishBeforeExplore = requiresPublishedWorkflowInExplore(props.app) && !props.app.workflow?.id + const needsPublishBeforeExplore = + requiresPublishedWorkflowInExplore(props.app) && !props.app.workflow?.id - const shouldShowOpenInExploreOption = !props.app.has_draft_trigger - && ( - needsPublishBeforeExplore - || !systemFeatures.webapp_auth.enabled - || (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result)) - ) + const shouldShowOpenInExploreOption = + !props.app.has_draft_trigger && + (needsPublishBeforeExplore || + !systemFeatures.webapp_auth.enabled || + (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result))) return ( <AppCardOperationsMenu @@ -331,48 +360,56 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { const { mutateAsync: mutateToggleAppStar, isPending: isTogglingStar } = useToggleAppStarMutation() const setNeedRefresh = useSetNeedRefreshAppList() const resourceMaintainer = getAppResourceMaintainer(app) - const maintainerPermissionOptions = useMemo(() => ({ - currentUserId, - resourceMaintainer, - workspacePermissionKeys, - isRbacEnabled, - }), [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys]) - const appACLCapabilities = useMemo(() => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), [app.permission_keys, maintainerPermissionOptions]) + const maintainerPermissionOptions = useMemo( + () => ({ + currentUserId, + resourceMaintainer, + workspacePermissionKeys, + isRbacEnabled, + }), + [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys], + ) + const appACLCapabilities = useMemo( + () => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), + [app.permission_keys, maintainerPermissionOptions], + ) const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') const onConfirmDelete = useCallback(async () => { try { await mutateDeleteApp(app.id) - toast.success(t($ => $.appDeleted, { ns: 'app' })) + toast.success(t(($) => $.appDeleted, { ns: 'app' })) onPlanInfoChanged() setShowConfirmDelete(false) setConfirmDeleteInput('') - } - catch (e) { + } catch (e) { const message = e instanceof Error ? e.message : '' - toast.error(`${t($ => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`) + toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`) } }, [app.id, mutateDeleteApp, onPlanInfoChanged, t]) - const onDeleteDialogOpenChange = useCallback((open: boolean) => { - if (isDeleting) - return + const onDeleteDialogOpenChange = useCallback( + (open: boolean) => { + if (isDeleting) return - setShowConfirmDelete(open) - if (!open) - setConfirmDeleteInput('') - }, [isDeleting]) + setShowConfirmDelete(open) + if (!open) setConfirmDeleteInput('') + }, + [isDeleting], + ) const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name - const onDeleteDialogSubmit: FormEventHandler<HTMLFormElement> = useCallback((e) => { - e.preventDefault() - if (isDeleteConfirmDisabled) - return + const onDeleteDialogSubmit: FormEventHandler<HTMLFormElement> = useCallback( + (e) => { + e.preventDefault() + if (isDeleteConfirmDisabled) return - void onConfirmDelete() - }, [isDeleteConfirmDisabled, onConfirmDelete]) + void onConfirmDelete() + }, + [isDeleteConfirmDisabled, onConfirmDelete], + ) const handleShowEditModal = useCallback(() => { setIsOperationsMenuOpen(false) @@ -414,36 +451,43 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { push(`/app/${app.id}/access-config`) }, [app.id, push]) - const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ + const onEdit: CreateAppModalProps['onConfirm'] = useCallback( + async ({ + name, + icon_type, + icon, + icon_background, + description, + use_icon_as_answer_icon, + max_active_requests, + }) => { + try { + await updateAppInfo({ + appID: app.id, + name, + icon_type, + icon, + icon_background, + description, + use_icon_as_answer_icon, + max_active_requests, + }) + setShowEditModal(false) + toast.success(t(($) => $.editDone, { ns: 'app' })) + onRefresh?.() + } catch (e) { + toast.error(e instanceof Error ? e.message : t(($) => $.editFailed, { ns: 'app' })) + } + }, + [app.id, onRefresh, t], + ) + + const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background, - description, - use_icon_as_answer_icon, - max_active_requests, }) => { - try { - await updateAppInfo({ - appID: app.id, - name, - icon_type, - icon, - icon_background, - description, - use_icon_as_answer_icon, - max_active_requests, - }) - setShowEditModal(false) - toast.success(t($ => $.editDone, { ns: 'app' })) - onRefresh?.() - } - catch (e) { - toast.error(e instanceof Error ? e.message : t($ => $.editFailed, { ns: 'app' })) - } - }, [app.id, onRefresh, t]) - - const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => { try { const newApp = await copyApp({ appID: app.id, @@ -454,7 +498,7 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { mode: app.mode, }) setShowDuplicateModal(false) - toast.success(t($ => $['newApp.appCreated'], { ns: 'app' })) + toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) setNeedRefresh('1') onRefresh?.() onPlanInfoChanged() @@ -464,9 +508,8 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { workspacePermissionKeys, isRbacEnabled, }) - } - catch { - toast.error(t($ => $['newApp.appCreateFailed'], { ns: 'app' })) + } catch { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) } } @@ -478,9 +521,8 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { }) const file = new Blob([data], { type: 'application/yaml' }) downloadBlob({ data: file, fileName: `${app.name}.yml` }) - } - catch { - toast.error(t($ => $.exportFailed, { ns: 'app' })) + } catch { + toast.error(t(($) => $.exportFailed, { ns: 'app' })) } } @@ -492,15 +534,16 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { } try { const workflowDraft = await fetchWorkflowDraft(`/apps/${app.id}/workflows/draft`) - const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret') + const list = (workflowDraft.environment_variables || []).filter( + (env) => env.value_type === 'secret', + ) if (list.length === 0) { onExport() return } setSecretEnvList(list) - } - catch { - toast.error(t($ => $.exportFailed, { ns: 'app' })) + } catch { + toast.error(t(($) => $.exportFailed, { ns: 'app' })) } } @@ -514,37 +557,51 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { setShowAccessControl(false) }, [onRefresh, setShowAccessControl]) - const handleToggleStar = useCallback(async (e: MouseEvent<HTMLButtonElement>) => { - e.stopPropagation() - e.preventDefault() - - if (isTogglingStar) - return - - try { - await mutateToggleAppStar({ - appId: app.id, - isStarred: Boolean(app.is_starred), - }) - onRefresh?.() - } - catch (error) { - toast.error(error instanceof Error ? error.message : t($ => $['studio.starFailed'], { ns: 'app' })) - } - }, [app.id, app.is_starred, isTogglingStar, mutateToggleAppStar, onRefresh, t]) + const handleToggleStar = useCallback( + async (e: MouseEvent<HTMLButtonElement>) => { + e.stopPropagation() + e.preventDefault() + + if (isTogglingStar) return + + try { + await mutateToggleAppStar({ + appId: app.id, + isStarred: Boolean(app.is_starred), + }) + onRefresh?.() + } catch (error) { + toast.error( + error instanceof Error ? error.message : t(($) => $['studio.starFailed'], { ns: 'app' }), + ) + } + }, + [app.id, app.is_starred, isTogglingStar, mutateToggleAppStar, onRefresh, t], + ) const shouldShowEditOption = appACLCapabilities.canEdit const shouldShowDuplicateOption = canCreateApp const shouldShowExportOption = appACLCapabilities.canImportExportDSL - const shouldShowSwitchOption = canCreateApp && appACLCapabilities.canEdit && (app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) - const shouldShowAccessControlOption = systemFeatures.webapp_auth.enabled && appACLCapabilities.canReleaseAndVersion + const shouldShowSwitchOption = + canCreateApp && + appACLCapabilities.canEdit && + (app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) + const shouldShowAccessControlOption = + systemFeatures.webapp_auth.enabled && appACLCapabilities.canReleaseAndVersion const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig const shouldShowDeleteOption = appACLCapabilities.canDelete - const shouldShowOperationsMenu = shouldShowEditOption || shouldShowDuplicateOption || shouldShowExportOption || shouldShowSwitchOption || shouldShowAccessControlOption || shouldShowAccessConfigOption || shouldShowDeleteOption + const shouldShowOperationsMenu = + shouldShowEditOption || + shouldShowDuplicateOption || + shouldShowExportOption || + shouldShowSwitchOption || + shouldShowAccessControlOption || + shouldShowAccessConfigOption || + shouldShowDeleteOption const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]' const starActionLabel = app.is_starred - ? t($ => $['studio.unstarApp'], { ns: 'app' }) - : t($ => $['studio.starApp'], { ns: 'app' }) + ? t(($) => $['studio.unstarApp'], { ns: 'app' }) + : t(($) => $['studio.starApp'], { ns: 'app' }) return ( <> @@ -559,7 +616,7 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { > <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={starActionLabel} @@ -575,14 +632,18 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { )} /> </button> - )} + } /> <TooltipContent>{starActionLabel}</TooltipContent> </Tooltip> {shouldShowOperationsMenu && ( - <DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}> + <DropdownMenu + modal={false} + open={isOperationsMenuOpen} + onOpenChange={setIsOperationsMenuOpen} + > <DropdownMenuTrigger - aria-label={t($ => $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.more'], { ns: 'common' })} className={cn( 'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover', @@ -592,7 +653,7 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { e.preventDefault() }} > - <span className="sr-only">{t($ => $['operation.more'], { ns: 'common' })}</span> + <span className="sr-only">{t(($) => $['operation.more'], { ns: 'common' })}</span> <span aria-hidden className="i-ri-more-fill h-[18px] w-[18px] text-text-tertiary" /> </DropdownMenuTrigger> <DropdownMenuContent @@ -600,46 +661,44 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { sideOffset={4} popupClassName={operationsMenuWidthClassName} > - {systemFeatures.webapp_auth.enabled - ? ( - <AppCardOperationsMenuContent - app={app} - shouldShowEditOption={shouldShowEditOption} - shouldShowDuplicateOption={shouldShowDuplicateOption} - shouldShowExportOption={shouldShowExportOption} - shouldShowSwitchOption={shouldShowSwitchOption} - shouldShowAccessControlOption={shouldShowAccessControlOption} - shouldShowAccessConfigOption={shouldShowAccessConfigOption} - shouldShowDeleteOption={shouldShowDeleteOption} - onEdit={handleShowEditModal} - onDuplicate={handleShowDuplicateModal} - onExport={exportCheck} - onSwitch={handleShowSwitchModal} - onDelete={handleShowDeleteConfirm} - onAccessControl={handleShowAccessControl} - onAccessConfig={handleOpenAccessConfig} - /> - ) - : ( - <AppCardOperationsMenu - app={app} - shouldShowEditOption={shouldShowEditOption} - shouldShowDuplicateOption={shouldShowDuplicateOption} - shouldShowExportOption={shouldShowExportOption} - shouldShowSwitchOption={shouldShowSwitchOption} - shouldShowOpenInExploreOption={!app.has_draft_trigger} - shouldShowAccessControlOption={shouldShowAccessControlOption} - shouldShowAccessConfigOption={shouldShowAccessConfigOption} - shouldShowDeleteOption={shouldShowDeleteOption} - onEdit={handleShowEditModal} - onDuplicate={handleShowDuplicateModal} - onExport={exportCheck} - onSwitch={handleShowSwitchModal} - onDelete={handleShowDeleteConfirm} - onAccessControl={handleShowAccessControl} - onAccessConfig={handleOpenAccessConfig} - /> - )} + {systemFeatures.webapp_auth.enabled ? ( + <AppCardOperationsMenuContent + app={app} + shouldShowEditOption={shouldShowEditOption} + shouldShowDuplicateOption={shouldShowDuplicateOption} + shouldShowExportOption={shouldShowExportOption} + shouldShowSwitchOption={shouldShowSwitchOption} + shouldShowAccessControlOption={shouldShowAccessControlOption} + shouldShowAccessConfigOption={shouldShowAccessConfigOption} + shouldShowDeleteOption={shouldShowDeleteOption} + onEdit={handleShowEditModal} + onDuplicate={handleShowDuplicateModal} + onExport={exportCheck} + onSwitch={handleShowSwitchModal} + onDelete={handleShowDeleteConfirm} + onAccessControl={handleShowAccessControl} + onAccessConfig={handleOpenAccessConfig} + /> + ) : ( + <AppCardOperationsMenu + app={app} + shouldShowEditOption={shouldShowEditOption} + shouldShowDuplicateOption={shouldShowDuplicateOption} + shouldShowExportOption={shouldShowExportOption} + shouldShowSwitchOption={shouldShowSwitchOption} + shouldShowOpenInExploreOption={!app.has_draft_trigger} + shouldShowAccessControlOption={shouldShowAccessControlOption} + shouldShowAccessConfigOption={shouldShowAccessConfigOption} + shouldShowDeleteOption={shouldShowDeleteOption} + onEdit={handleShowEditModal} + onDuplicate={handleShowDuplicateModal} + onExport={exportCheck} + onSwitch={handleShowSwitchModal} + onDelete={handleShowDeleteConfirm} + onAccessControl={handleShowAccessControl} + onAccessConfig={handleOpenAccessConfig} + /> + )} </DropdownMenuContent> </DropdownMenu> )} @@ -687,19 +746,21 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { <form className="flex flex-col" onSubmit={onDeleteDialogSubmit}> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $.deleteAppConfirmTitle, { ns: 'app' })} + {t(($) => $.deleteAppConfirmTitle, { ns: 'app' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $.deleteAppConfirmContent, { ns: 'app' })} + {t(($) => $.deleteAppConfirmContent, { ns: 'app' })} </AlertDialogDescription> <Field name="confirm-app-name" className="mt-2"> <FieldLabel className="mb-1 block py-0 system-sm-regular text-text-secondary"> <Trans - i18nKey={$ => $.deleteAppConfirmInputLabel} + i18nKey={($) => $.deleteAppConfirmInputLabel} ns="app" values={{ appName: app.name }} components={{ - appName: <span className="system-sm-semibold text-text-primary" translate="no" />, + appName: ( + <span className="system-sm-semibold text-text-primary" translate="no" /> + ), }} /> </FieldLabel> @@ -707,7 +768,7 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { type="text" autoComplete="off" spellCheck={false} - placeholder={t($ => $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} + placeholder={t(($) => $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} value={confirmDeleteInput} onValueChange={setConfirmDeleteInput} className="border-components-input-border-hover bg-components-input-bg-normal focus:border-components-input-border-active focus:bg-components-input-bg-active" @@ -716,14 +777,14 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { </div> <AlertDialogActions> <AlertDialogCancelButton type="button" disabled={isDeleting}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton type="submit" loading={isDeleting} disabled={isDeleteConfirmDisabled} > - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </form> @@ -737,13 +798,22 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { /> )} {showAccessControl && ( - <AccessControl app={app} onConfirm={onUpdateAccessControl} onClose={() => setShowAccessControl(false)} /> + <AccessControl + app={app} + onConfirm={onUpdateAccessControl} + onClose={() => setShowAccessControl(false)} + /> )} </> ) } -export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => {} }: AppCardProps) { +export function AppCard({ + app, + onlineUsers = [], + onRefresh, + onOpenTagManagement = () => {}, +}: AppCardProps) { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const currentUserId = useAtomValue(userProfileIdAtom) @@ -764,13 +834,19 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement const { mutateAsync: mutateToggleAppStar, isPending: isTogglingStar } = useToggleAppStarMutation() const setNeedRefresh = useSetNeedRefreshAppList() const resourceMaintainer = getAppResourceMaintainer(app) - const maintainerPermissionOptions = useMemo(() => ({ - currentUserId, - resourceMaintainer, - workspacePermissionKeys, - isRbacEnabled, - }), [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys]) - const appACLCapabilities = useMemo(() => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), [app.permission_keys, maintainerPermissionOptions]) + const maintainerPermissionOptions = useMemo( + () => ({ + currentUserId, + resourceMaintainer, + workspacePermissionKeys, + isRbacEnabled, + }), + [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys], + ) + const appACLCapabilities = useMemo( + () => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), + [app.permission_keys, maintainerPermissionOptions], + ) const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') const canManageAppTags = hasPermission(workspacePermissionKeys, 'app.tag.manage') @@ -778,32 +854,28 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement async function onConfirmDelete() { try { await mutateDeleteApp(app.id) - toast.success(t($ => $.appDeleted, { ns: 'app' })) + toast.success(t(($) => $.appDeleted, { ns: 'app' })) onPlanInfoChanged() setShowConfirmDelete(false) setConfirmDeleteInput('') - } - catch (e) { + } catch (e) { const message = e instanceof Error ? e.message : '' - toast.error(`${t($ => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`) + toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`) } } function onDeleteDialogOpenChange(open: boolean) { - if (isDeleting) - return + if (isDeleting) return setShowConfirmDelete(open) - if (!open) - setConfirmDeleteInput('') + if (!open) setConfirmDeleteInput('') } const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name function onDeleteDialogSubmit(event: FormEvent<HTMLFormElement>) { event.preventDefault() - if (isDeleteConfirmDisabled) - return + if (isDeleteConfirmDisabled) return void onConfirmDelete() } @@ -869,16 +941,19 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement max_active_requests, }) setShowEditModal(false) - toast.success(t($ => $.editDone, { ns: 'app' })) - if (onRefresh) - onRefresh() - } - catch (e) { - toast.error(e instanceof Error ? e.message : t($ => $.editFailed, { ns: 'app' })) + toast.success(t(($) => $.editDone, { ns: 'app' })) + if (onRefresh) onRefresh() + } catch (e) { + toast.error(e instanceof Error ? e.message : t(($) => $.editFailed, { ns: 'app' })) } } - const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => { + const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ + name, + icon_type, + icon, + icon_background, + }) => { try { const newApp = await copyApp({ appID: app.id, @@ -889,10 +964,9 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement mode: app.mode, }) setShowDuplicateModal(false) - toast.success(t($ => $['newApp.appCreated'], { ns: 'app' })) + toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) setNeedRefresh('1') - if (onRefresh) - onRefresh() + if (onRefresh) onRefresh() onPlanInfoChanged() getRedirection(newApp, push, { currentUserId, @@ -900,9 +974,8 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement workspacePermissionKeys, isRbacEnabled, }) - } - catch { - toast.error(t($ => $['newApp.appCreateFailed'], { ns: 'app' })) + } catch { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) } } @@ -914,9 +987,8 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement }) const file = new Blob([data], { type: 'application/yaml' }) downloadBlob({ data: file, fileName: `${app.name}.yml` }) - } - catch { - toast.error(t($ => $.exportFailed, { ns: 'app' })) + } catch { + toast.error(t(($) => $.exportFailed, { ns: 'app' })) } } @@ -928,80 +1000,92 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement } try { const workflowDraft = await fetchWorkflowDraft(`/apps/${app.id}/workflows/draft`) - const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret') + const list = (workflowDraft.environment_variables || []).filter( + (env) => env.value_type === 'secret', + ) if (list.length === 0) { onExport() return } setSecretEnvList(list) - } - catch { - toast.error(t($ => $.exportFailed, { ns: 'app' })) + } catch { + toast.error(t(($) => $.exportFailed, { ns: 'app' })) } } const onSwitch = () => { - if (onRefresh) - onRefresh() + if (onRefresh) onRefresh() setShowSwitchModal(false) } function onUpdateAccessControl() { - if (onRefresh) - onRefresh() + if (onRefresh) onRefresh() setShowAccessControl(false) } - const handleToggleStar = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => { - e.stopPropagation() - e.preventDefault() - - if (isTogglingStar) - return - - try { - await mutateToggleAppStar({ - appId: app.id, - isStarred: Boolean(app.is_starred), - }) - onRefresh?.() - } - catch (error) { - toast.error(error instanceof Error ? error.message : t($ => $['studio.starFailed'], { ns: 'app' })) - } - }, [app.id, app.is_starred, isTogglingStar, mutateToggleAppStar, onRefresh, t]) + const handleToggleStar = useCallback( + async (e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation() + e.preventDefault() + + if (isTogglingStar) return + + try { + await mutateToggleAppStar({ + appId: app.id, + isStarred: Boolean(app.is_starred), + }) + onRefresh?.() + } catch (error) { + toast.error( + error instanceof Error ? error.message : t(($) => $['studio.starFailed'], { ns: 'app' }), + ) + } + }, + [app.id, app.is_starred, isTogglingStar, mutateToggleAppStar, onRefresh, t], + ) const shouldShowEditOption = appACLCapabilities.canEdit const shouldShowDuplicateOption = canCreateApp const shouldShowExportOption = appACLCapabilities.canImportExportDSL - const shouldShowSwitchOption = appACLCapabilities.canEdit && (app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) - const shouldShowAccessControlOption = systemFeatures.webapp_auth.enabled && appACLCapabilities.canReleaseAndVersion + const shouldShowSwitchOption = + appACLCapabilities.canEdit && + (app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) + const shouldShowAccessControlOption = + systemFeatures.webapp_auth.enabled && appACLCapabilities.canReleaseAndVersion const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig const shouldShowDeleteOption = appACLCapabilities.canDelete - const shouldShowOperationsMenu = shouldShowEditOption || shouldShowDuplicateOption || shouldShowExportOption || shouldShowSwitchOption || shouldShowAccessControlOption || shouldShowAccessConfigOption || shouldShowDeleteOption + const shouldShowOperationsMenu = + shouldShowEditOption || + shouldShowDuplicateOption || + shouldShowExportOption || + shouldShowSwitchOption || + shouldShowAccessControlOption || + shouldShowAccessConfigOption || + shouldShowDeleteOption const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || appACLCapabilities.canEdit) const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]' const editTimeText = useMemo(() => { const timeText = formatTime({ date: (app.updated_at || app.created_at) * 1000, - dateFormat: `${t($ => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, + dateFormat: `${t(($) => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, }) - return `${t($ => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}` + return `${t(($) => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}` }, [app.updated_at, app.created_at, t]) const appModeLabel = useMemo(() => { switch (app.mode) { case AppModeEnum.CHAT: - return t($ => $['types.chatbot'], { ns: 'app' }) + return t(($) => $['types.chatbot'], { ns: 'app' }) case AppModeEnum.ADVANCED_CHAT: - return t($ => $['types.advanced'], { ns: 'app' }) + return t(($) => $['types.advanced'], { ns: 'app' }) case AppModeEnum.AGENT_CHAT: - return t($ => $['types.agent'], { ns: 'app' }) + return t(($) => $['types.agent'], { ns: 'app' }) case AppModeEnum.COMPLETION: - return t($ => $['types.completion'], { ns: 'app' }) + return t(($) => $['types.completion'], { ns: 'app' }) case AppModeEnum.WORKFLOW: - return t($ => $['types.workflow'], { ns: 'app' }) + return t(($) => $['types.workflow'], { ns: 'app' }) default: return app.mode } @@ -1018,7 +1102,7 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement avatar_url: user.avatar || null, } }) - .filter(user => Boolean(user.id)) + .filter((user) => Boolean(user.id)) }, [app.id, onlineUsers]) const appNameId = useId() const appDescriptionId = useId() @@ -1030,18 +1114,20 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement : 'cursor-pointer hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid', ) const starActionLabel = app.is_starred - ? t($ => $['studio.unstarApp'], { ns: 'app' }) - : t($ => $['studio.starApp'], { ns: 'app' }) + ? t(($) => $['studio.unstarApp'], { ns: 'app' }) + : t(($) => $['studio.starApp'], { ns: 'app' }) const showPreviewOnlyAccessWarning = useCallback(() => { - toast.warning(t($ => $.noAccessResourcePermission, { ns: 'app' })) + toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) }, [t]) - const handlePreviewOnlyCardKeyDown = useCallback((event: KeyboardEvent<HTMLElement>) => { - if (event.key !== 'Enter' && event.key !== ' ') - return - - event.preventDefault() - showPreviewOnlyAccessWarning() - }, [showPreviewOnlyAccessWarning]) + const handlePreviewOnlyCardKeyDown = useCallback( + (event: KeyboardEvent<HTMLElement>) => { + if (event.key !== 'Enter' && event.key !== ' ') return + + event.preventDefault() + showPreviewOnlyAccessWarning() + }, + [showPreviewOnlyAccessWarning], + ) const appCardContent = ( <> <div className="flex shrink-0 items-center gap-3 pt-4 pr-4 pb-2 pl-4"> @@ -1053,25 +1139,35 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement background={app.icon_background} imageUrl={app.icon_url} /> - <AppTypeIcon type={app.mode} wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm" className="size-3" /> + <AppTypeIcon + type={app.mode} + wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm" + className="size-3" + /> </div> <div className="flex w-0 grow flex-col gap-1 py-px"> <div className="flex items-center text-sm/5 font-semibold text-text-secondary"> - <div id={appNameId} className="truncate">{app.name}</div> + <div id={appNameId} className="truncate"> + {app.name} + </div> + </div> + <div className="truncate system-2xs-medium-uppercase text-text-tertiary"> + {appModeLabel} </div> - <div className="truncate system-2xs-medium-uppercase text-text-tertiary">{appModeLabel}</div> </div> {onlinePresenceUsers.length > 0 && ( <div className="ml-3 flex shrink-0 items-start"> - <UserAvatarList users={onlinePresenceUsers} size="xxs" maxVisible={3} className="justify-end" /> + <UserAvatarList + users={onlinePresenceUsers} + size="xxs" + maxVisible={3} + className="justify-end" + /> </div> )} </div> <div className="shrink-0 px-4 py-1 system-xs-regular text-text-tertiary"> - <div - id={appDescriptionId} - className="line-clamp-2 min-h-8" - > + <div id={appDescriptionId} className="line-clamp-2 min-h-8"> {app.description} </div> </div> @@ -1097,34 +1193,30 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement return ( <> - <div - className="group relative col-span-1 h-41.5" - > - {isPreviewOnly - ? ( - <article - role="button" - tabIndex={0} - aria-disabled="true" - aria-labelledby={appNameId} - aria-describedby={app.description ? appDescriptionId : undefined} - className={appCardClassName} - onClick={showPreviewOnlyAccessWarning} - onKeyDown={handlePreviewOnlyCardKeyDown} - > - {appCardContent} - </article> - ) - : ( - <Link - href={appHref} - aria-labelledby={appNameId} - aria-describedby={app.description ? appDescriptionId : undefined} - className={appCardClassName} - > - {appCardContent} - </Link> - )} + <div className="group relative col-span-1 h-41.5"> + {isPreviewOnly ? ( + <article + role="button" + tabIndex={0} + aria-disabled="true" + aria-labelledby={appNameId} + aria-describedby={app.description ? appDescriptionId : undefined} + className={appCardClassName} + onClick={showPreviewOnlyAccessWarning} + onKeyDown={handlePreviewOnlyCardKeyDown} + > + {appCardContent} + </article> + ) : ( + <Link + href={appHref} + aria-labelledby={appNameId} + aria-describedby={app.description ? appDescriptionId : undefined} + className={appCardClassName} + > + {appCardContent} + </Link> + )} <div className="absolute top-[104px] right-3 left-3 flex h-[26px] min-w-0 items-start" onClick={(e) => { @@ -1152,7 +1244,7 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement > <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={starActionLabel} @@ -1168,14 +1260,18 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement )} /> </button> - )} + } /> <TooltipContent>{starActionLabel}</TooltipContent> </Tooltip> {shouldShowOperationsMenu && ( - <DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}> + <DropdownMenu + modal={false} + open={isOperationsMenuOpen} + onOpenChange={setIsOperationsMenuOpen} + > <DropdownMenuTrigger - aria-label={t($ => $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.more'], { ns: 'common' })} className={cn( 'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover', @@ -1185,54 +1281,55 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement e.preventDefault() }} > - <span className="sr-only">{t($ => $['operation.more'], { ns: 'common' })}</span> - <span aria-hidden className="i-ri-more-fill h-[18px] w-[18px] text-text-tertiary" /> + <span className="sr-only">{t(($) => $['operation.more'], { ns: 'common' })}</span> + <span + aria-hidden + className="i-ri-more-fill h-[18px] w-[18px] text-text-tertiary" + /> </DropdownMenuTrigger> <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName={operationsMenuWidthClassName} > - {systemFeatures.webapp_auth.enabled - ? ( - <AppCardOperationsMenuContent - app={app} - shouldShowEditOption={shouldShowEditOption} - shouldShowDuplicateOption={shouldShowDuplicateOption} - shouldShowExportOption={shouldShowExportOption} - shouldShowSwitchOption={shouldShowSwitchOption} - shouldShowAccessControlOption={shouldShowAccessControlOption} - shouldShowAccessConfigOption={shouldShowAccessConfigOption} - shouldShowDeleteOption={shouldShowDeleteOption} - onEdit={handleShowEditModal} - onDuplicate={handleShowDuplicateModal} - onExport={exportCheck} - onSwitch={handleShowSwitchModal} - onDelete={handleShowDeleteConfirm} - onAccessControl={handleShowAccessControl} - onAccessConfig={handleOpenAccessConfig} - /> - ) - : ( - <AppCardOperationsMenu - app={app} - shouldShowEditOption={shouldShowEditOption} - shouldShowDuplicateOption={shouldShowDuplicateOption} - shouldShowExportOption={shouldShowExportOption} - shouldShowSwitchOption={shouldShowSwitchOption} - shouldShowOpenInExploreOption={!app.has_draft_trigger} - shouldShowAccessControlOption={shouldShowAccessControlOption} - shouldShowAccessConfigOption={shouldShowAccessConfigOption} - shouldShowDeleteOption={shouldShowDeleteOption} - onEdit={handleShowEditModal} - onDuplicate={handleShowDuplicateModal} - onExport={exportCheck} - onSwitch={handleShowSwitchModal} - onDelete={handleShowDeleteConfirm} - onAccessControl={handleShowAccessControl} - onAccessConfig={handleOpenAccessConfig} - /> - )} + {systemFeatures.webapp_auth.enabled ? ( + <AppCardOperationsMenuContent + app={app} + shouldShowEditOption={shouldShowEditOption} + shouldShowDuplicateOption={shouldShowDuplicateOption} + shouldShowExportOption={shouldShowExportOption} + shouldShowSwitchOption={shouldShowSwitchOption} + shouldShowAccessControlOption={shouldShowAccessControlOption} + shouldShowAccessConfigOption={shouldShowAccessConfigOption} + shouldShowDeleteOption={shouldShowDeleteOption} + onEdit={handleShowEditModal} + onDuplicate={handleShowDuplicateModal} + onExport={exportCheck} + onSwitch={handleShowSwitchModal} + onDelete={handleShowDeleteConfirm} + onAccessControl={handleShowAccessControl} + onAccessConfig={handleOpenAccessConfig} + /> + ) : ( + <AppCardOperationsMenu + app={app} + shouldShowEditOption={shouldShowEditOption} + shouldShowDuplicateOption={shouldShowDuplicateOption} + shouldShowExportOption={shouldShowExportOption} + shouldShowSwitchOption={shouldShowSwitchOption} + shouldShowOpenInExploreOption={!app.has_draft_trigger} + shouldShowAccessControlOption={shouldShowAccessControlOption} + shouldShowAccessConfigOption={shouldShowAccessConfigOption} + shouldShowDeleteOption={shouldShowDeleteOption} + onEdit={handleShowEditModal} + onDuplicate={handleShowDuplicateModal} + onExport={exportCheck} + onSwitch={handleShowSwitchModal} + onDelete={handleShowDeleteConfirm} + onAccessControl={handleShowAccessControl} + onAccessConfig={handleOpenAccessConfig} + /> + )} </DropdownMenuContent> </DropdownMenu> )} @@ -1281,19 +1378,21 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement <form className="flex flex-col" onSubmit={onDeleteDialogSubmit}> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $.deleteAppConfirmTitle, { ns: 'app' })} + {t(($) => $.deleteAppConfirmTitle, { ns: 'app' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $.deleteAppConfirmContent, { ns: 'app' })} + {t(($) => $.deleteAppConfirmContent, { ns: 'app' })} </AlertDialogDescription> <Field name="confirm-app-name" className="mt-2"> <FieldLabel className="mb-1 block py-0 system-sm-regular text-text-secondary"> <Trans - i18nKey={$ => $.deleteAppConfirmInputLabel} + i18nKey={($) => $.deleteAppConfirmInputLabel} ns="app" values={{ appName: app.name }} components={{ - appName: <span className="system-sm-semibold text-text-primary" translate="no" />, + appName: ( + <span className="system-sm-semibold text-text-primary" translate="no" /> + ), }} /> </FieldLabel> @@ -1302,7 +1401,7 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement type="text" autoComplete="off" spellCheck={false} - placeholder={t($ => $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} + placeholder={t(($) => $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} value={confirmDeleteInput} onValueChange={setConfirmDeleteInput} className="border-components-input-border-hover bg-components-input-bg-normal pr-20 focus:border-components-input-border-active focus:bg-components-input-bg-active" @@ -1312,21 +1411,21 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement onClick={() => setConfirmDeleteInput(app.name)} className="absolute top-1/2 right-2 -translate-y-1/2 rounded-full bg-black/[0.06] px-2.5 py-1 system-xs-medium text-text-secondary hover:bg-black/[0.1]" > - {t($ => $['operation.fill'], { ns: 'common' })} + {t(($) => $['operation.fill'], { ns: 'common' })} </button> </div> </Field> </div> <AlertDialogActions> <AlertDialogCancelButton type="button" disabled={isDeleting}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton type="submit" loading={isDeleting} disabled={isDeleteConfirmDisabled} > - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </form> @@ -1340,7 +1439,11 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement /> )} {showAccessControl && ( - <AccessControl app={app} onConfirm={onUpdateAccessControl} onClose={() => setShowAccessControl(false)} /> + <AccessControl + app={app} + onConfirm={onUpdateAccessControl} + onClose={() => setShowAccessControl(false)} + /> )} </> ) diff --git a/web/app/components/apps/app-list-creation-modals.tsx b/web/app/components/apps/app-list-creation-modals.tsx index de5675c864ee52..33358f721dee39 100644 --- a/web/app/components/apps/app-list-creation-modals.tsx +++ b/web/app/components/apps/app-list-creation-modals.tsx @@ -40,8 +40,7 @@ export function AppListCreationModals({ onSetShowNewAppModal: (show: boolean) => void onSetShowNewAppTemplateDialog: (show: boolean) => void }) { - if (!canCreateApp) - return null + if (!canCreateApp) return null return ( <> diff --git a/web/app/components/apps/app-list-header-filters.tsx b/web/app/components/apps/app-list-header-filters.tsx index b97acc6677ea68..7fcd42d5674e0e 100644 --- a/web/app/components/apps/app-list-header-filters.tsx +++ b/web/app/components/apps/app-list-header-filters.tsx @@ -4,7 +4,12 @@ import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen' import type { AppListCategory } from './app-type-filter-shared' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' import { TagFilter } from '@/features/tag-management/components/tag-filter' @@ -71,7 +76,7 @@ export function AppListHeaderFilters({ className="w-50 max-w-full" value={keywords} onValueChange={onKeywordsChange} - aria-label={t($ => $['gotoAnything.actions.searchApplications'], { ns: 'app' })} + aria-label={t(($) => $['gotoAnything.actions.searchApplications'], { ns: 'app' })} /> </div> <div className="ml-auto flex max-w-full min-w-0 flex-wrap items-center justify-end gap-2"> @@ -80,42 +85,48 @@ export function AppListHeaderFilters({ className="inline-flex h-8 cursor-pointer items-center justify-center gap-1 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 text-[13px] leading-4 font-medium whitespace-nowrap text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" > <span aria-hidden className="i-ri-braces-line size-4 shrink-0" /> - {t($ => $['studio.viewSnippets'], { ns: 'app' })} + {t(($) => $['studio.viewSnippets'], { ns: 'app' })} </Link> {showCreateButton && ( <DropdownMenu modal={false}> <DropdownMenuTrigger - render={( + render={ <Button variant="primary" size="medium" className="gap-0.5 px-2 whitespace-nowrap shadow-xs shadow-shadow-shadow-3" > <span aria-hidden className="i-ri-add-line size-4 shrink-0" /> - <span className="pl-1">{t($ => $['operation.create'], { ns: 'common' })}</span> + <span className="pl-1">{t(($) => $['operation.create'], { ns: 'common' })}</span> <span aria-hidden className="i-ri-arrow-down-s-line size-4 shrink-0" /> </Button> - )} + } /> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="w-70 p-0" - > + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-70 p-0"> <div className="py-1"> <DropdownMenuItem className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary" onClick={onCreateBlank} > - <span aria-hidden className="i-ri-sticky-note-add-line size-4 shrink-0 text-text-secondary" /> - <span className="min-w-0 flex-1 truncate px-1">{t($ => $['newApp.startFromBlank'], { ns: 'app' })}</span> + <span + aria-hidden + className="i-ri-sticky-note-add-line size-4 shrink-0 text-text-secondary" + /> + <span className="min-w-0 flex-1 truncate px-1"> + {t(($) => $['newApp.startFromBlank'], { ns: 'app' })} + </span> </DropdownMenuItem> <DropdownMenuItem className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary" onClick={onCreateTemplate} > - <span aria-hidden className="i-ri-apps-2-add-line size-4 shrink-0 text-text-secondary" /> - <span className="min-w-0 flex-1 truncate px-1">{t($ => $['newApp.startFromTemplate'], { ns: 'app' })}</span> + <span + aria-hidden + className="i-ri-apps-2-add-line size-4 shrink-0 text-text-secondary" + /> + <span className="min-w-0 flex-1 truncate px-1"> + {t(($) => $['newApp.startFromTemplate'], { ns: 'app' })} + </span> </DropdownMenuItem> </div> <div className="h-px bg-divider-subtle" /> @@ -128,11 +139,18 @@ export function AppListHeaderFilters({ onClick={onImportDSL} > <span className="flex h-5 shrink-0 items-center py-0.5"> - <span aria-hidden className="i-ri-file-upload-line size-4 text-text-secondary" /> + <span + aria-hidden + className="i-ri-file-upload-line size-4 text-text-secondary" + /> </span> <span className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-1"> - <span className="system-md-regular text-text-secondary">{t($ => $.importDSL, { ns: 'app' })}</span> - <span className="system-xs-regular text-text-tertiary">{t($ => $['newApp.dropDSLToCreateApp'], { ns: 'app' })}</span> + <span className="system-md-regular text-text-secondary"> + {t(($) => $.importDSL, { ns: 'app' })} + </span> + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['newApp.dropDSLToCreateApp'], { ns: 'app' })} + </span> </span> </DropdownMenuItem> </div> diff --git a/web/app/components/apps/app-list-tag-management-modal.tsx b/web/app/components/apps/app-list-tag-management-modal.tsx index 4da290dddf828a..a1fc1be9275e0c 100644 --- a/web/app/components/apps/app-list-tag-management-modal.tsx +++ b/web/app/components/apps/app-list-tag-management-modal.tsx @@ -2,9 +2,15 @@ import dynamic from '@/next/dynamic' -const TagManagementModal = dynamic(() => import('@/features/tag-management/components/tag-management-modal').then(mod => mod.TagManagementModal), { - ssr: false, -}) +const TagManagementModal = dynamic( + () => + import('@/features/tag-management/components/tag-management-modal').then( + (mod) => mod.TagManagementModal, + ), + { + ssr: false, + }, +) export function AppListTagManagementModal({ show, @@ -15,12 +21,5 @@ export function AppListTagManagementModal({ onClose: () => void onTagsChange: () => unknown }) { - return ( - <TagManagementModal - type="app" - show={show} - onClose={onClose} - onTagsChange={onTagsChange} - /> - ) + return <TagManagementModal type="app" show={show} onClose={onClose} onTagsChange={onTagsChange} /> } diff --git a/web/app/components/apps/app-sort-filter.tsx b/web/app/components/apps/app-sort-filter.tsx index 0e982ef602e1fe..01d596ba8578cc 100644 --- a/web/app/components/apps/app-sort-filter.tsx +++ b/web/app/components/apps/app-sort-filter.tsx @@ -20,26 +20,37 @@ type AppSortFilterProps = { onChange: (value: AppListSortBy) => void } -const appListSortByValues: AppListSortBy[] = ['last_modified', 'recently_created', 'earliest_created'] +const appListSortByValues: AppListSortBy[] = [ + 'last_modified', + 'recently_created', + 'earliest_created', +] function isAppListSortBy(value: string): value is AppListSortBy { return appListSortByValues.includes(value as AppListSortBy) } -export function AppSortFilter({ - value, - onChange, -}: AppSortFilterProps) { +export function AppSortFilter({ value, onChange }: AppSortFilterProps) { const { t } = useTranslation() - const options = useMemo(() => ([ - { value: 'last_modified', text: t($ => $['studio.sort.lastModified'], { ns: 'app' }) }, - { value: 'recently_created', text: t($ => $['studio.sort.recentlyCreated'], { ns: 'app' }) }, - { value: 'earliest_created', text: t($ => $['studio.sort.earliestCreated'], { ns: 'app' }) }, - ] satisfies Array<{ value: AppListSortBy, text: string }>), [t]) + const options = useMemo( + () => + [ + { value: 'last_modified', text: t(($) => $['studio.sort.lastModified'], { ns: 'app' }) }, + { + value: 'recently_created', + text: t(($) => $['studio.sort.recentlyCreated'], { ns: 'app' }), + }, + { + value: 'earliest_created', + text: t(($) => $['studio.sort.earliestCreated'], { ns: 'app' }), + }, + ] satisfies Array<{ value: AppListSortBy; text: string }>, + [t], + ) - const activeOption = options.find(option => option.value === value) ?? options[0]! - const sortByLabel = t($ => $['studio.sort.sortBy'], { ns: 'app' }) + const activeOption = options.find((option) => option.value === value) ?? options[0]! + const sortByLabel = t(($) => $['studio.sort.sortBy'], { ns: 'app' }) return ( <DropdownMenu> @@ -54,8 +65,11 @@ export function AppSortFilter({ <span aria-hidden className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-tertiary" /> </DropdownMenuTrigger> <DropdownMenuContent placement="bottom-start" sideOffset={4} popupClassName="w-[220px]"> - <DropdownMenuRadioGroup value={value} onValueChange={nextValue => isAppListSortBy(nextValue) && onChange(nextValue)}> - {options.map(option => ( + <DropdownMenuRadioGroup + value={value} + onValueChange={(nextValue) => isAppListSortBy(nextValue) && onChange(nextValue)} + > + {options.map((option) => ( <DropdownMenuRadioItem key={option.value} value={option.value} closeOnClick> <span className="min-w-0 flex-1 truncate">{option.text}</span> <DropdownMenuRadioItemIndicator /> diff --git a/web/app/components/apps/app-type-filter-shared.ts b/web/app/components/apps/app-type-filter-shared.ts index 26b279ae2fa90c..140ff55ba7c503 100644 --- a/web/app/components/apps/app-type-filter-shared.ts +++ b/web/app/components/apps/app-type-filter-shared.ts @@ -2,7 +2,7 @@ import { parseAsStringLiteral } from 'nuqs' import { AppModes } from '@/types/app' const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const -type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number] +type AppListCategory = (typeof APP_LIST_CATEGORY_VALUES)[number] export type { AppListCategory } const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES) diff --git a/web/app/components/apps/app-type-filter.tsx b/web/app/components/apps/app-type-filter.tsx index 3ae09b3f4f6020..10c021079256cc 100644 --- a/web/app/components/apps/app-type-filter.tsx +++ b/web/app/components/apps/app-type-filter.tsx @@ -15,36 +15,63 @@ import { useTranslation } from 'react-i18next' import { AppModeEnum } from '@/types/app' import { isAppListCategory } from './app-type-filter-shared' -const chipClassName = 'flex h-8 items-center whitespace-nowrap rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const chipClassName = + 'flex h-8 items-center whitespace-nowrap rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid' type AppTypeFilterProps = { value: AppListCategory onChange: (value: AppListCategory) => void } -export function AppTypeFilter({ - value, - onChange, -}: AppTypeFilterProps) { +export function AppTypeFilter({ value, onChange }: AppTypeFilterProps) { const { t } = useTranslation() - const options = useMemo(() => ([ - { value: 'all', text: t($ => $['types.all'], { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' }, - { value: AppModeEnum.WORKFLOW, text: t($ => $['types.workflow'], { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' }, - { value: AppModeEnum.ADVANCED_CHAT, text: t($ => $['types.advanced'], { ns: 'app' }), iconClassName: 'i-ri-message-3-line' }, - { value: AppModeEnum.CHAT, text: t($ => $['types.chatbot'], { ns: 'app' }), iconClassName: 'i-ri-message-3-line' }, - { value: AppModeEnum.AGENT_CHAT, text: t($ => $['types.agent'], { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' }, - { value: AppModeEnum.COMPLETION, text: t($ => $['newApp.completeApp'], { ns: 'app' }), iconClassName: 'i-ri-file-4-line' }, - ]), [t]) + const options = useMemo( + () => [ + { + value: 'all', + text: t(($) => $['types.all'], { ns: 'app' }), + iconClassName: 'i-ri-apps-2-line', + }, + { + value: AppModeEnum.WORKFLOW, + text: t(($) => $['types.workflow'], { ns: 'app' }), + iconClassName: 'i-ri-exchange-2-line', + }, + { + value: AppModeEnum.ADVANCED_CHAT, + text: t(($) => $['types.advanced'], { ns: 'app' }), + iconClassName: 'i-ri-message-3-line', + }, + { + value: AppModeEnum.CHAT, + text: t(($) => $['types.chatbot'], { ns: 'app' }), + iconClassName: 'i-ri-message-3-line', + }, + { + value: AppModeEnum.AGENT_CHAT, + text: t(($) => $['types.agent'], { ns: 'app' }), + iconClassName: 'i-ri-robot-3-line', + }, + { + value: AppModeEnum.COMPLETION, + text: t(($) => $['newApp.completeApp'], { ns: 'app' }), + iconClassName: 'i-ri-file-4-line', + }, + ], + [t], + ) - const activeOption = options.find(option => option.value === value) + const activeOption = options.find((option) => option.value === value) const isSelected = value !== 'all' - const triggerLabel = isSelected ? activeOption?.text : t($ => $['studio.filters.types'], { ns: 'app' }) + const triggerLabel = isSelected + ? activeOption?.text + : t(($) => $['studio.filters.types'], { ns: 'app' }) return ( <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <button type="button" className={cn( @@ -54,16 +81,22 @@ export function AppTypeFilter({ : 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover', )} /> - )} + } > <span className="px-1 text-text-tertiary">{triggerLabel}</span> <span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" /> </DropdownMenuTrigger> <DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]"> - <DropdownMenuRadioGroup value={value} onValueChange={nextValue => isAppListCategory(nextValue) && onChange(nextValue)}> - {options.map(option => ( + <DropdownMenuRadioGroup + value={value} + onValueChange={(nextValue) => isAppListCategory(nextValue) && onChange(nextValue)} + > + {options.map((option) => ( <DropdownMenuRadioItem key={option.value} value={option.value}> - <span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} /> + <span + aria-hidden + className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} + /> <span>{option.text}</span> <DropdownMenuRadioItemIndicator /> </DropdownMenuRadioItem> diff --git a/web/app/components/apps/constants.ts b/web/app/components/apps/constants.ts index 955c428266194a..578a2c24605a37 100644 --- a/web/app/components/apps/constants.ts +++ b/web/app/components/apps/constants.ts @@ -1,2 +1,3 @@ export const APP_LIST_SEARCH_DEBOUNCE_MS = 500 -export const APP_LIST_GRID_CLASS_NAME = 'grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5 px-8 pt-2' +export const APP_LIST_GRID_CLASS_NAME = + 'grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5 px-8 pt-2' diff --git a/web/app/components/apps/creators-filter.tsx b/web/app/components/apps/creators-filter.tsx index c32c68ac4e10ab..43ff5db496dee4 100644 --- a/web/app/components/apps/creators-filter.tsx +++ b/web/app/components/apps/creators-filter.tsx @@ -27,12 +27,10 @@ type CreatorOption = { isYou: boolean } -const baseChipClassName = 'flex h-8 items-center whitespace-nowrap rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const baseChipClassName = + 'flex h-8 items-center whitespace-nowrap rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid' -const CreatorsFilter = ({ - value, - onChange, -}: CreatorsFilterProps) => { +const CreatorsFilter = ({ value, onChange }: CreatorsFilterProps) => { const { t } = useTranslation() const currentUserId = useAtomValue(userProfileIdAtom) const { data: membersData } = useMembers() @@ -42,15 +40,13 @@ const CreatorsFilter = ({ const members = membersData?.accounts ?? [] return [...members] - .filter(member => member.status !== 'pending') + .filter((member) => member.status !== 'pending') .sort((left, right) => { - if (left.id === currentUserId) - return -1 - if (right.id === currentUserId) - return 1 + if (left.id === currentUserId) return -1 + if (right.id === currentUserId) return 1 return left.name.localeCompare(right.name) }) - .map(member => ({ + .map((member) => ({ id: member.id, name: member.name, avatarUrl: member.avatar_url, @@ -60,8 +56,7 @@ const CreatorsFilter = ({ const filteredCreators = useMemo(() => { const normalizedKeywords = keywords.trim().toLowerCase() - if (!normalizedKeywords) - return creatorOptions + if (!normalizedKeywords) return creatorOptions return creatorOptions.filter((creator) => { const keyword = normalizedKeywords @@ -70,20 +65,23 @@ const CreatorsFilter = ({ }, [creatorOptions, keywords]) const selectedCreators = useMemo(() => { - const creatorMap = new Map(creatorOptions.map(creator => [creator.id, creator])) + const creatorMap = new Map(creatorOptions.map((creator) => [creator.id, creator])) return value - .map(id => creatorMap.get(id)) + .map((id) => creatorMap.get(id)) .filter((creator): creator is CreatorOption => Boolean(creator)) }, [creatorOptions, value]) - const toggleCreator = useCallback((creatorId: string) => { - if (value.includes(creatorId)) { - onChange(value.filter(id => id !== creatorId)) - return - } + const toggleCreator = useCallback( + (creatorId: string) => { + if (value.includes(creatorId)) { + onChange(value.filter((id) => id !== creatorId)) + return + } - onChange([...value, creatorId]) - }, [onChange, value]) + onChange([...value, creatorId]) + }, + [onChange, value], + ) const resetCreators = useCallback(() => { onChange([]) @@ -97,7 +95,7 @@ const CreatorsFilter = ({ return ( <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <button type="button" className={cn( @@ -107,17 +105,24 @@ const CreatorsFilter = ({ : 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover', )} /> - )} + } > {!isSelected && ( <> - <span className="px-1 text-text-tertiary">{t($ => $['studio.filters.creators'], { ns: 'app' })}</span> - <span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" /> + <span className="px-1 text-text-tertiary"> + {t(($) => $['studio.filters.creators'], { ns: 'app' })} + </span> + <span + aria-hidden + className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" + /> </> )} {isSelected && ( <> - <span className="px-1 text-text-tertiary">{t($ => $['studio.filters.creators'], { ns: 'app' })}</span> + <span className="px-1 text-text-tertiary"> + {t(($) => $['studio.filters.creators'], { ns: 'app' })} + </span> <span className="flex items-center pr-1"> {selectedAvatarCreators.map((creator, index) => ( <Avatar @@ -125,10 +130,7 @@ const CreatorsFilter = ({ avatar={creator.avatarUrl} name={creator.name} size="xs" - className={cn( - 'border border-components-panel-bg', - index > 0 && '-ml-1', - )} + className={cn('border border-components-panel-bg', index > 0 && '-ml-1')} /> ))} </span> @@ -136,15 +138,14 @@ const CreatorsFilter = ({ <span role="button" tabIndex={0} - aria-label={t($ => $['studio.filters.reset'], { ns: 'app' })} + aria-label={t(($) => $['studio.filters.reset'], { ns: 'app' })} className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-xs text-text-quaternary outline-hidden hover:text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid" onClick={(event) => { event.stopPropagation() resetCreators() }} onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') - return + if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() event.stopPropagation() @@ -159,17 +160,20 @@ const CreatorsFilter = ({ <DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0"> <div className="flex items-center gap-1 p-2 pb-1"> <div className="relative min-w-0 grow"> - <span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" /> + <span + aria-hidden + className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" + /> <Input className={cn('pl-6.5', keywords && 'pr-6.5')} value={keywords} - onChange={e => setKeywords(e.target.value)} - placeholder={t($ => $['studio.filters.searchCreators'], { ns: 'app' })} + onChange={(e) => setKeywords(e.target.value)} + placeholder={t(($) => $['studio.filters.searchCreators'], { ns: 'app' })} /> {!!keywords && ( <button type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled" onClick={() => setKeywords('')} > @@ -183,7 +187,7 @@ const CreatorsFilter = ({ className="shrink-0 rounded-sm px-2 py-1 text-xs font-medium text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid" onClick={resetCreators} > - {t($ => $['studio.filters.reset'], { ns: 'app' })} + {t(($) => $['studio.filters.reset'], { ns: 'app' })} </button> )} </div> @@ -198,11 +202,7 @@ const CreatorsFilter = ({ className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" onClick={() => toggleCreator(creator.id)} > - <Checkbox - id={creator.id} - checked={checked} - className="shrink-0" - /> + <Checkbox id={creator.id} checked={checked} className="shrink-0" /> <div className="flex min-w-0 grow items-center gap-2 px-1"> <Avatar avatar={creator.avatarUrl} @@ -213,7 +213,9 @@ const CreatorsFilter = ({ <div className="flex min-w-0 grow items-center justify-between gap-2"> <span className="truncate text-sm text-text-secondary">{creator.name}</span> {creator.isYou && ( - <span className="shrink-0 text-sm text-text-quaternary">{t($ => $['studio.filters.you'], { ns: 'app' })}</span> + <span className="shrink-0 text-sm text-text-quaternary"> + {t(($) => $['studio.filters.you'], { ns: 'app' })} + </span> )} </div> </div> diff --git a/web/app/components/apps/empty.tsx b/web/app/components/apps/empty.tsx index 667798ed82938f..2d880ff23210b6 100644 --- a/web/app/components/apps/empty.tsx +++ b/web/app/components/apps/empty.tsx @@ -9,7 +9,7 @@ type EmptyProps = { const Empty = ({ message }: EmptyProps) => { const { t } = useTranslation() - return <FilterEmptyState title={message ?? t($ => $['filterEmpty.noApps'], { ns: 'app' })} /> + return <FilterEmptyState title={message ?? t(($) => $['filterEmpty.noApps'], { ns: 'app' })} /> } export default React.memo(Empty) diff --git a/web/app/components/apps/first-empty-state/action-card.tsx b/web/app/components/apps/first-empty-state/action-card.tsx index b3593157083682..5d44a715d419da 100644 --- a/web/app/components/apps/first-empty-state/action-card.tsx +++ b/web/app/components/apps/first-empty-state/action-card.tsx @@ -26,7 +26,8 @@ type LinkActionCardProps = BaseProps & { type FirstEmptyActionCardProps = ButtonActionCardProps | LinkActionCardProps -const baseCardClassName = 'relative flex rounded-xl bg-components-button-secondary-bg text-left shadow-xs transition-colors hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden' +const baseCardClassName = + 'relative flex rounded-xl bg-components-button-secondary-bg text-left shadow-xs transition-colors hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden' const compactBadgeClassName = { basic: { corner: 'text-util-colors-orange-orange-50', @@ -82,32 +83,48 @@ function ActionCardContent({ {icon} </span> <span className="flex min-w-0 flex-1 flex-col gap-0.5"> - <span className="truncate system-md-medium text-text-secondary" title={title}>{title}</span> - <span className="truncate system-xs-regular text-text-tertiary" title={description}>{description}</span> + <span className="truncate system-md-medium text-text-secondary" title={title}> + {title} + </span> + <span className="truncate system-xs-regular text-text-tertiary" title={description}> + {description} + </span> </span> </> ) } const isCompact = visualStyle === 'compact' - const badgeNode = badge - ? isCompact - ? <CompactBadge badge={badge} badgeVariant={badgeVariant} /> - : ( - <span className="absolute top-4 right-4 flex min-w-[18px] items-center justify-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px] system-2xs-medium-uppercase text-text-tertiary"> - {badge} - </span> - ) - : null + const badgeNode = badge ? ( + isCompact ? ( + <CompactBadge badge={badge} badgeVariant={badgeVariant} /> + ) : ( + <span className="absolute top-4 right-4 flex min-w-[18px] items-center justify-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px] system-2xs-medium-uppercase text-text-tertiary"> + {badge} + </span> + ) + ) : null return ( <> {badgeNode} - <span className={`${isCompact ? 'size-10 rounded-lg border border-divider-regular text-2xl/7' : 'size-12 rounded-xl text-2xl/8'} flex items-center justify-center bg-components-icon-bg-teal-soft text-text-accent`}> + <span + className={`${isCompact ? 'size-10 rounded-lg border border-divider-regular text-2xl/7' : 'size-12 rounded-xl text-2xl/8'} flex items-center justify-center bg-components-icon-bg-teal-soft text-text-accent`} + > {icon} </span> - <span className={`${isCompact ? 'mt-1 system-md-semibold text-text-secondary' : 'mt-5 system-md-semibold text-text-primary'} w-full truncate`} title={title}>{title}</span> - <span className={`${isCompact ? 'mt-3 line-clamp-3 system-xs-regular' : 'mt-2 system-sm-regular'} text-text-tertiary`} title={description}>{description}</span> + <span + className={`${isCompact ? 'mt-1 system-md-semibold text-text-secondary' : 'mt-5 system-md-semibold text-text-primary'} w-full truncate`} + title={title} + > + {title} + </span> + <span + className={`${isCompact ? 'mt-3 line-clamp-3 system-xs-regular' : 'mt-2 system-sm-regular'} text-text-tertiary`} + title={description} + > + {description} + </span> </> ) } diff --git a/web/app/components/apps/first-empty-state/index.tsx b/web/app/components/apps/first-empty-state/index.tsx index 360f82c6065386..fba20dc17cbd8f 100644 --- a/web/app/components/apps/first-empty-state/index.tsx +++ b/web/app/components/apps/first-empty-state/index.tsx @@ -5,7 +5,10 @@ import { useTranslation } from 'react-i18next' import LearnDify from '@/app/components/explore/learn-dify' import FirstEmptyActionCard from './action-card' -const EMPTY_PLACEHOLDER_CARD_IDS = Array.from({ length: 16 }, (_, index) => `placeholder-card-${index}`) +const EMPTY_PLACEHOLDER_CARD_IDS = Array.from( + { length: 16 }, + (_, index) => `placeholder-card-${index}`, +) type EmptyCreateAction = { id: string @@ -22,34 +25,29 @@ type Props = { showLearnDify: boolean } -function FirstEmptyState({ - onCreateBlank, - onCreateTemplate, - onImportDSL, - showLearnDify, -}: Props) { +function FirstEmptyState({ onCreateBlank, onCreateTemplate, onImportDSL, showLearnDify }: Props) { const { t } = useTranslation() const actions: EmptyCreateAction[] = [ { id: 'template', icon: <span aria-hidden className="i-ri-function-add-line size-4" />, - title: t($ => $['newApp.startFromTemplate'], { ns: 'app' }), - description: t($ => $['firstEmpty.templateDescription'], { ns: 'app' }), + title: t(($) => $['newApp.startFromTemplate'], { ns: 'app' }), + description: t(($) => $['firstEmpty.templateDescription'], { ns: 'app' }), onClick: onCreateTemplate, }, { id: 'blank', icon: <span aria-hidden className="i-ri-add-box-line size-4" />, - title: t($ => $['newApp.startFromBlank'], { ns: 'app' }), - description: t($ => $['firstEmpty.blankDescription'], { ns: 'app' }), + title: t(($) => $['newApp.startFromBlank'], { ns: 'app' }), + description: t(($) => $['firstEmpty.blankDescription'], { ns: 'app' }), onClick: onCreateBlank, }, { id: 'dsl', icon: <span aria-hidden className="i-ri-file-upload-line size-4" />, - title: t($ => $.importDSL, { ns: 'app' }), - description: t($ => $['firstEmpty.importDescription'], { ns: 'app' }), + title: t(($) => $.importDSL, { ns: 'app' }), + description: t(($) => $['firstEmpty.importDescription'], { ns: 'app' }), onClick: onImportDSL, }, ] @@ -58,12 +56,15 @@ function FirstEmptyState({ <div className="flex grow flex-col overflow-hidden"> <div className="relative min-h-[430px] flex-1 overflow-hidden"> <div className="pointer-events-none absolute inset-x-8 inset-y-2 grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] grid-rows-4 gap-3"> - {EMPTY_PLACEHOLDER_CARD_IDS.map(id => ( + {EMPTY_PLACEHOLDER_CARD_IDS.map((id) => ( <div key={id} className="rounded-xl bg-background-default-lighter opacity-75" /> ))} </div> <div className="pointer-events-none absolute inset-0 bg-linear-to-b from-background-body/0 to-background-body" /> - <section className="absolute inset-0 flex items-center justify-center overflow-hidden p-2" aria-labelledby="apps-first-empty-title"> + <section + className="absolute inset-0 flex items-center justify-center overflow-hidden p-2" + aria-labelledby="apps-first-empty-title" + > <div className="flex w-full max-w-[520px] flex-col items-center gap-6"> <div className="flex flex-col items-center gap-3"> <div className="flex size-14 items-center justify-center rounded-[10px]"> @@ -72,12 +73,12 @@ function FirstEmptyState({ </div> </div> <h2 id="apps-first-empty-title" className="system-sm-regular text-text-tertiary"> - {t($ => $['firstEmpty.title'], { ns: 'app' })} + {t(($) => $['firstEmpty.title'], { ns: 'app' })} </h2> </div> <div className="flex w-full flex-col gap-2"> <div className="flex flex-col gap-2"> - {actions.slice(0, 2).map(action => ( + {actions.slice(0, 2).map((action) => ( <FirstEmptyActionCard key={action.id} description={action.description} @@ -90,7 +91,9 @@ function FirstEmptyState({ </div> <div className="flex items-center gap-2 text-text-tertiary"> <div className="h-px min-w-0 flex-1 bg-linear-to-r from-background-body/0 to-divider-regular" /> - <span className="system-xs-medium-uppercase uppercase">{t($ => $['firstEmpty.or'], { ns: 'app' })}</span> + <span className="system-xs-medium-uppercase uppercase"> + {t(($) => $['firstEmpty.or'], { ns: 'app' })} + </span> <div className="h-px min-w-0 flex-1 bg-linear-to-r from-divider-regular to-background-body/0" /> </div> <FirstEmptyActionCard @@ -110,7 +113,7 @@ function FirstEmptyState({ dismissible={false} itemLimit={4} showDescription - title={t($ => $['firstEmpty.learnDifyTitle'], { ns: 'app' })} + title={t(($) => $['firstEmpty.learnDifyTitle'], { ns: 'app' })} /> )} </div> diff --git a/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx b/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx index adb48109b9766b..42bc6a5a9429b3 100644 --- a/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx +++ b/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx @@ -28,9 +28,7 @@ describe('useAppsQueryState', () => { }) it('should parse app list filters from URL', () => { - const { result } = renderWithAdapter( - '?category=workflow&tagIDs=tag1;tag2&keywords=search+term', - ) + const { result } = renderWithAdapter('?category=workflow&tagIDs=tag1;tag2&keywords=search+term') expect(result.current.query).toEqual({ category: AppModeEnum.WORKFLOW, @@ -85,8 +83,7 @@ describe('useAppsQueryState', () => { expect(onUrlUpdate).toHaveBeenCalled() const update = onUrlUpdate.mock.calls.at(-1)![0] expect(update.searchParams.get('keywords')).toBe('search') - } - finally { + } finally { vi.useRealTimers() } }) @@ -109,8 +106,7 @@ describe('useAppsQueryState', () => { expect(onUrlUpdate).toHaveBeenCalled() const update = onUrlUpdate.mock.calls.at(-1)![0] expect(update.searchParams.has('keywords')).toBe(false) - } - finally { + } finally { vi.useRealTimers() } }) diff --git a/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts b/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts index 5f9c9581172583..3781ff57dc99a8 100644 --- a/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts +++ b/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts @@ -38,8 +38,7 @@ vi.mock('@/service/client', () => ({ const getLastQueryOptions = () => { const lastCall = mockQueryOptions.mock.lastCall - if (!lastCall) - throw new Error('workflows.onlineUsers.post.queryOptions was not called') + if (!lastCall) throw new Error('workflows.onlineUsers.post.queryOptions was not called') return lastCall[0] as QueryOptions } @@ -51,82 +50,102 @@ describe('useWorkflowOnlineUsers', () => { describe('Query Options', () => { it('should disable query with a valid empty input when app ids are empty', () => { - renderHook(() => useWorkflowOnlineUsers({ - appIds: [], - enabled: true, - })) - - expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: { body: { app_ids: [] } }, - enabled: false, - refetchInterval: false, - })) - expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({ - input: { body: { app_ids: [] } }, - enabled: false, - })) + renderHook(() => + useWorkflowOnlineUsers({ + appIds: [], + enabled: true, + }), + ) + + expect(mockQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: { body: { app_ids: [] } }, + enabled: false, + refetchInterval: false, + }), + ) + expect(mockUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + input: { body: { app_ids: [] } }, + enabled: false, + }), + ) }) it('should enable query and polling when collaboration is enabled with app ids', () => { - renderHook(() => useWorkflowOnlineUsers({ - appIds: ['app-1', 'app-2'], - enabled: true, - })) - - expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: { body: { app_ids: ['app-1', 'app-2'] } }, - enabled: true, - refetchInterval: 10000, - })) + renderHook(() => + useWorkflowOnlineUsers({ + appIds: ['app-1', 'app-2'], + enabled: true, + }), + ) + + expect(mockQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: { body: { app_ids: ['app-1', 'app-2'] } }, + enabled: true, + refetchInterval: 10000, + }), + ) }) it('should disable query while preserving valid input when collaboration is disabled', () => { - renderHook(() => useWorkflowOnlineUsers({ - appIds: ['app-1'], - enabled: false, - })) - - expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: { body: { app_ids: ['app-1'] } }, - enabled: false, - refetchInterval: false, - })) + renderHook(() => + useWorkflowOnlineUsers({ + appIds: ['app-1'], + enabled: false, + }), + ) + + expect(mockQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: { body: { app_ids: ['app-1'] } }, + enabled: false, + refetchInterval: false, + }), + ) }) }) describe('Response Mapping', () => { it('should normalize array response data by app id', () => { - renderHook(() => useWorkflowOnlineUsers({ - appIds: ['app-1'], - enabled: true, - })) + renderHook(() => + useWorkflowOnlineUsers({ + appIds: ['app-1'], + enabled: true, + }), + ) const options = getLastQueryOptions() const user = { user_id: 'user-1', username: 'Alice' } - expect(options.select({ - data: [ - { app_id: 'app-1', users: [user] }, - ], - })).toEqual({ + expect( + options.select({ + data: [{ app_id: 'app-1', users: [user] }], + }), + ).toEqual({ 'app-1': [user], }) }) it('should normalize record response data without changing user arrays', () => { - renderHook(() => useWorkflowOnlineUsers({ - appIds: ['app-1'], - enabled: true, - })) + renderHook(() => + useWorkflowOnlineUsers({ + appIds: ['app-1'], + enabled: true, + }), + ) const options = getLastQueryOptions() const users = [{ user_id: 'user-1', username: 'Alice' }] - expect(options.select({ - data: { - 'app-1': users, - }, - })).toEqual({ + expect( + options.select({ + data: { + 'app-1': users, + }, + }), + ).toEqual({ 'app-1': users, }) }) @@ -134,10 +153,12 @@ describe('useWorkflowOnlineUsers', () => { describe('Return Value', () => { it('should return an empty map when query data is unavailable', () => { - const { result } = renderHook(() => useWorkflowOnlineUsers({ - appIds: ['app-1'], - enabled: true, - })) + const { result } = renderHook(() => + useWorkflowOnlineUsers({ + appIds: ['app-1'], + enabled: true, + }), + ) expect(result.current.onlineUsersMap).toEqual({}) }) diff --git a/web/app/components/apps/hooks/use-apps-query-state.ts b/web/app/components/apps/hooks/use-apps-query-state.ts index 890133fc46e992..702155a1898565 100644 --- a/web/app/components/apps/hooks/use-apps-query-state.ts +++ b/web/app/components/apps/hooks/use-apps-query-state.ts @@ -15,27 +15,39 @@ export function useAppsQueryState() { const [urlQuery, setUrlQuery] = useQueryStates(appListQueryParsers) const [creatorIDs, setCreatorIDs] = useState<string[]>([]) - const setCategory = useCallback((category: AppListCategory) => { - setUrlQuery({ category }) - }, [setUrlQuery]) + const setCategory = useCallback( + (category: AppListCategory) => { + setUrlQuery({ category }) + }, + [setUrlQuery], + ) - const setKeywords = useCallback((keywords: string) => { - setUrlQuery({ keywords }) - }, [setUrlQuery]) + const setKeywords = useCallback( + (keywords: string) => { + setUrlQuery({ keywords }) + }, + [setUrlQuery], + ) const handleSetCreatorIDs = useCallback((creatorIDs: string[]) => { setCreatorIDs(creatorIDs) }, []) - const query = useMemo(() => ({ - ...urlQuery, - creatorIDs, - }), [creatorIDs, urlQuery]) + const query = useMemo( + () => ({ + ...urlQuery, + creatorIDs, + }), + [creatorIDs, urlQuery], + ) - return useMemo(() => ({ - query, - setCategory, - setKeywords, - setCreatorIDs: handleSetCreatorIDs, - }), [handleSetCreatorIDs, query, setCategory, setKeywords]) + return useMemo( + () => ({ + query, + setCategory, + setKeywords, + setCreatorIDs: handleSetCreatorIDs, + }), + [handleSetCreatorIDs, query, setCategory, setKeywords], + ) } diff --git a/web/app/components/apps/hooks/use-dsl-drag-drop.ts b/web/app/components/apps/hooks/use-dsl-drag-drop.ts index 3e795018b0a85f..bfc53e4bea3667 100644 --- a/web/app/components/apps/hooks/use-dsl-drag-drop.ts +++ b/web/app/components/apps/hooks/use-dsl-drag-drop.ts @@ -6,14 +6,17 @@ type DSLDragDropHookProps = { enabled?: boolean } -export const useDSLDragDrop = ({ onDSLFileDropped, containerRef, enabled = true }: DSLDragDropHookProps) => { +export const useDSLDragDrop = ({ + onDSLFileDropped, + containerRef, + enabled = true, +}: DSLDragDropHookProps) => { const [dragging, setDragging] = useState(false) const handleDragEnter = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.dataTransfer?.types.includes('Files')) - setDragging(true) + if (e.dataTransfer?.types.includes('Files')) setDragging(true) } const handleDragOver = (e: DragEvent) => { @@ -33,12 +36,10 @@ export const useDSLDragDrop = ({ onDSLFileDropped, containerRef, enabled = true e.stopPropagation() setDragging(false) - if (!e.dataTransfer) - return + if (!e.dataTransfer) return const files = Array.from(e.dataTransfer.files) - if (files.length === 0) - return + if (files.length === 0) return const file = files[0] if (file!.name.toLowerCase().endsWith('.yaml') || file!.name.toLowerCase().endsWith('.yml')) @@ -46,8 +47,7 @@ export const useDSLDragDrop = ({ onDSLFileDropped, containerRef, enabled = true } useEffect(() => { - if (!enabled) - return + if (!enabled) return const current = containerRef.current if (current) { diff --git a/web/app/components/apps/hooks/use-workflow-online-users.ts b/web/app/components/apps/hooks/use-workflow-online-users.ts index 919e22785cf9bb..998f89896fa878 100644 --- a/web/app/components/apps/hooks/use-workflow-online-users.ts +++ b/web/app/components/apps/hooks/use-workflow-online-users.ts @@ -9,38 +9,36 @@ type UseWorkflowOnlineUsersParams = { enabled: boolean } -const normalizeWorkflowOnlineUsers = (response?: WorkflowOnlineUsersResponse): WorkflowOnlineUsersMap => { +const normalizeWorkflowOnlineUsers = ( + response?: WorkflowOnlineUsersResponse, +): WorkflowOnlineUsersMap => { const data = response?.data - if (!data) - return {} + if (!data) return {} if (Array.isArray(data)) { return data.reduce<WorkflowOnlineUsersMap>((acc, item) => { - if (item?.app_id) - acc[item.app_id] = item.users || [] + if (item?.app_id) acc[item.app_id] = item.users || [] return acc }, {}) } return Object.entries(data).reduce<WorkflowOnlineUsersMap>((acc, [appId, users]) => { - if (appId) - acc[appId] = users || [] + if (appId) acc[appId] = users || [] return acc }, {}) } -export const useWorkflowOnlineUsers = ({ - appIds, - enabled, -}: UseWorkflowOnlineUsersParams) => { +export const useWorkflowOnlineUsers = ({ appIds, enabled }: UseWorkflowOnlineUsersParams) => { const shouldFetch = enabled && appIds.length > 0 - const { data: onlineUsersMap = {} } = useQuery(consoleQuery.apps.workflows.onlineUsers.post.queryOptions({ - input: { body: { app_ids: appIds } }, - enabled: shouldFetch, - select: normalizeWorkflowOnlineUsers, - refetchInterval: shouldFetch ? 10000 : false, - })) + const { data: onlineUsersMap = {} } = useQuery( + consoleQuery.apps.workflows.onlineUsers.post.queryOptions({ + input: { body: { app_ids: appIds } }, + enabled: shouldFetch, + select: normalizeWorkflowOnlineUsers, + refetchInterval: shouldFetch ? 10000 : false, + }), + ) return { onlineUsersMap, diff --git a/web/app/components/apps/import-from-marketplace-template-modal.tsx b/web/app/components/apps/import-from-marketplace-template-modal.tsx index 68ea46b7097081..368e3e8bf3e48b 100644 --- a/web/app/components/apps/import-from-marketplace-template-modal.tsx +++ b/web/app/components/apps/import-from-marketplace-template-modal.tsx @@ -30,33 +30,36 @@ const ImportFromMarketplaceTemplateModal = ({ const [importing, setImporting] = useState(false) const isImportingRef = useRef(false) - const CATEGORY_I18N_MAP: Record<string, string> = useMemo(() => ({ - marketing: t($ => $['marketplace.template.category.marketing'], { ns: 'app' }), - sales: t($ => $['marketplace.template.category.sales'], { ns: 'app' }), - support: t($ => $['marketplace.template.category.support'], { ns: 'app' }), - operations: t($ => $['marketplace.template.category.operations'], { ns: 'app' }), - it: t($ => $['marketplace.template.category.it'], { ns: 'app' }), - knowledge: t($ => $['marketplace.template.category.knowledge'], { ns: 'app' }), - design: t($ => $['marketplace.template.category.design'], { ns: 'app' }), - }), [t]) + const CATEGORY_I18N_MAP: Record<string, string> = useMemo( + () => ({ + marketing: t(($) => $['marketplace.template.category.marketing'], { ns: 'app' }), + sales: t(($) => $['marketplace.template.category.sales'], { ns: 'app' }), + support: t(($) => $['marketplace.template.category.support'], { ns: 'app' }), + operations: t(($) => $['marketplace.template.category.operations'], { ns: 'app' }), + it: t(($) => $['marketplace.template.category.it'], { ns: 'app' }), + knowledge: t(($) => $['marketplace.template.category.knowledge'], { ns: 'app' }), + design: t(($) => $['marketplace.template.category.design'], { ns: 'app' }), + }), + [t], + ) - const translateCategory = useCallback((slug: string) => { - return CATEGORY_I18N_MAP[slug] ?? slug - }, [CATEGORY_I18N_MAP]) + const translateCategory = useCallback( + (slug: string) => { + return CATEGORY_I18N_MAP[slug] ?? slug + }, + [CATEGORY_I18N_MAP], + ) const handleConfirm = useCallback(async () => { - if (isImportingRef.current) - return + if (isImportingRef.current) return isImportingRef.current = true setImporting(true) try { const dsl = await fetchMarketplaceTemplateDSL(templateId) onConfirm(dsl) - } - catch { - toast.error(t($ => $['marketplace.template.importFailed'], { ns: 'app' })) - } - finally { + } catch { + toast.error(t(($) => $['marketplace.template.importFailed'], { ns: 'app' })) + } finally { setImporting(false) isImportingRef.current = false } @@ -66,19 +69,13 @@ const ImportFromMarketplaceTemplateModal = ({ <Dialog open onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > - <DialogContent - className="w-[520px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl" - > + <DialogContent className="w-[520px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl"> <div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary"> - {t($ => $['marketplace.template.modalTitle'], { ns: 'app' })} - <div - className="flex size-8 cursor-pointer items-center" - onClick={onClose} - > + {t(($) => $['marketplace.template.modalTitle'], { ns: 'app' })} + <div className="flex size-8 cursor-pointer items-center" onClick={onClose}> <RiCloseLine className="size-5 text-text-tertiary" /> </div> </div> @@ -93,7 +90,7 @@ const ImportFromMarketplaceTemplateModal = ({ {isError && ( <div className="flex items-center justify-center py-8"> <div className="system-md-regular text-text-destructive"> - {t($ => $['marketplace.template.fetchFailed'], { ns: 'app' })} + {t(($) => $['marketplace.template.fetchFailed'], { ns: 'app' })} </div> </div> )} @@ -106,20 +103,24 @@ const ImportFromMarketplaceTemplateModal = ({ iconType={template.icon_file_key ? 'image' : 'emoji'} icon={template.icon || 'page_facing_up'} background={template.icon_file_key ? undefined : template.icon_background} - imageUrl={template.icon_file_key ? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon` : undefined} + imageUrl={ + template.icon_file_key + ? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon` + : undefined + } /> <div className="flex flex-col"> - <div className="system-md-semibold text-text-primary">{template.template_name}</div> + <div className="system-md-semibold text-text-primary"> + {template.template_name} + </div> <div className="flex items-center gap-1 system-xs-regular text-text-tertiary"> <span> - {t($ => $['marketplace.template.publishedBy'], { ns: 'app' })} - {' '} + {t(($) => $['marketplace.template.publishedBy'], { ns: 'app' })}{' '} {template.publisher_unique_handle} </span> <span>·</span> <span> - {t($ => $['marketplace.template.usageCount'], { ns: 'app' })} - {' '} + {t(($) => $['marketplace.template.usageCount'], { ns: 'app' })}{' '} {template.usage_count} </span> </div> @@ -129,17 +130,15 @@ const ImportFromMarketplaceTemplateModal = ({ {template.overview && ( <div className="flex flex-col gap-1"> <div className="system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['marketplace.template.overview'], { ns: 'app' })} - </div> - <div className="system-sm-regular text-text-secondary"> - {template.overview} + {t(($) => $['marketplace.template.overview'], { ns: 'app' })} </div> + <div className="system-sm-regular text-text-secondary">{template.overview}</div> </div> )} {template.categories.length > 0 && ( <div className="flex flex-wrap items-center gap-2"> - {template.categories.map(cat => ( + {template.categories.map((cat) => ( <span key={cat} className="inline-flex items-center rounded-full bg-components-label-gray px-2.5 py-1 system-sm-regular text-text-secondary" @@ -155,7 +154,7 @@ const ImportFromMarketplaceTemplateModal = ({ <div className="flex justify-end px-6 py-5"> <Button className="mr-2" onClick={onClose}> - {t($ => $['newApp.Cancel'], { ns: 'app' })} + {t(($) => $['newApp.Cancel'], { ns: 'app' })} </Button> <Button variant="primary" @@ -163,7 +162,7 @@ const ImportFromMarketplaceTemplateModal = ({ loading={importing} onClick={handleConfirm} > - {t($ => $['marketplace.template.importConfirm'], { ns: 'app' })} + {t(($) => $['marketplace.template.importConfirm'], { ns: 'app' })} </Button> </div> </DialogContent> diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index 1b8a82ae8a3d26..4aed098a7ae854 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -18,10 +18,15 @@ import { trackCreateApp } from '@/utils/create-app-tracking' import { hasPermission } from '@/utils/permission' import List from './list' -const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false }) +const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { + ssr: false, +}) const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false }) const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false }) -const ImportFromMarketplaceTemplateModal = dynamic(() => import('./import-from-marketplace-template-modal'), { ssr: false }) +const ImportFromMarketplaceTemplateModal = dynamic( + () => import('./import-from-marketplace-template-modal'), + { ssr: false }, +) const Apps = () => { const { t } = useTranslation() @@ -32,29 +37,31 @@ const Apps = () => { const templateId = searchParams.get('template-id') const templateDismissedRef = useRef(false) - useDocumentTitle(t($ => $['menus.apps'], { ns: 'common' })) + useDocumentTitle(t(($) => $['menus.apps'], { ns: 'common' })) useEducationInit() - const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined) + const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>( + undefined, + ) const currentCreateAppModeRef = useRef<TryAppSelection['app']['app']['mode'] | null>(null) - const currentCreateAppTrackingRef = useRef<Pick<TrackCreateAppParams, 'source' | 'templateId'> | null>(null) + const currentCreateAppTrackingRef = useRef<Pick< + TrackCreateAppParams, + 'source' | 'templateId' + > | null>(null) const currApp = currentTryAppParams?.app const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false) const hideTryAppPanel = useCallback(() => { setIsShowTryAppPanel(false) }, []) const setShowTryAppPanel = (showTryAppPanel: boolean, params?: TryAppSelection) => { - if (showTryAppPanel) - setCurrentTryAppParams(params) - else - setCurrentTryAppParams(undefined) + if (showTryAppPanel) setCurrentTryAppParams(params) + else setCurrentTryAppParams(undefined) setIsShowTryAppPanel(showTryAppPanel) } const [isShowCreateModal, setIsShowCreateModal] = useState(false) const handleShowFromTryApp = useCallback(() => { - if (!canCreateApp) - return + if (!canCreateApp) return currentCreateAppTrackingRef.current = { source: 'studio_template_preview', @@ -62,25 +69,27 @@ const Apps = () => { } setIsShowCreateModal(true) }, [canCreateApp, currentTryAppParams?.app.app_id, currentTryAppParams?.appId]) - const trackCurrentCreateApp = useCallback((appMode?: TryAppSelection['app']['app']['mode'] | null) => { - const currentCreateAppTracking = currentCreateAppTrackingRef.current - const resolvedAppMode = appMode ?? currentCreateAppModeRef.current - if (!resolvedAppMode || !currentCreateAppTracking) - return - - trackCreateApp({ - ...currentCreateAppTracking, - appMode: resolvedAppMode, - }) - currentCreateAppTrackingRef.current = null - currentCreateAppModeRef.current = null - }, []) + const trackCurrentCreateApp = useCallback( + (appMode?: TryAppSelection['app']['app']['mode'] | null) => { + const currentCreateAppTracking = currentCreateAppTrackingRef.current + const resolvedAppMode = appMode ?? currentCreateAppModeRef.current + if (!resolvedAppMode || !currentCreateAppTracking) return + + trackCreateApp({ + ...currentCreateAppTracking, + appMode: resolvedAppMode, + }) + currentCreateAppTrackingRef.current = null + currentCreateAppModeRef.current = null + }, + [], + ) const [controlRefreshList, setControlRefreshList] = useState(0) const [controlHideCreateFromTemplatePanel, setControlHideCreateFromTemplatePanel] = useState(0) const onSuccess = useCallback(() => { - setControlRefreshList(prev => prev + 1) - setControlHideCreateFromTemplatePanel(prev => prev + 1) + setControlRefreshList((prev) => prev + 1) + setControlHideCreateFromTemplatePanel((prev) => prev + 1) }, []) const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) @@ -93,12 +102,7 @@ const Apps = () => { replace(query ? `?${query}` : window.location.pathname, { scroll: false }) }, [searchParams, replace]) - const { - handleImportDSL, - handleImportDSLConfirm, - versions, - isFetching, - } = useImportDSL() + const { handleImportDSL, handleImportDSLConfirm, versions, isFetching } = useImportDSL() const onConfirmDSL = useCallback(async () => { await handleImportDSLConfirm({ @@ -109,74 +113,81 @@ const Apps = () => { }) }, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp]) - const handleMarketplaceTemplateConfirm = useCallback(async (dslContent: string) => { - if (!canCreateApp) - return + const handleMarketplaceTemplateConfirm = useCallback( + async (dslContent: string) => { + if (!canCreateApp) return - currentCreateAppModeRef.current = null - currentCreateAppTrackingRef.current = { - source: 'external', - templateId: templateId || undefined, - } - await handleImportDSL({ - mode: DSLImportMode.YAML_CONTENT, - yaml_content: dslContent, - }, { - onSuccess: (response) => { - trackCurrentCreateApp(response.app_mode) - handleCloseTemplateModal() - onSuccess() - }, - onPending: () => { - handleCloseTemplateModal() - setShowDSLConfirmModal(true) - }, - }) - }, [canCreateApp, handleImportDSL, handleCloseTemplateModal, onSuccess, templateId, trackCurrentCreateApp]) - - const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({ - name, - icon_type, - icon, - icon_background, - description, - }) => { - if (!canCreateApp) - return - - hideTryAppPanel() - - const { export_data, mode } = await fetchAppDetail( - currApp?.app.id as string, - ) - currentCreateAppModeRef.current = mode - const payload = { - mode: DSLImportMode.YAML_CONTENT, - yaml_content: export_data, - name, - icon_type, - icon, - icon_background, - description, - } - await handleImportDSL(payload, { - onSuccess: (response) => { - trackCurrentCreateApp(response.app_mode) - setIsShowCreateModal(false) - }, - onPending: () => { - setShowDSLConfirmModal(true) - }, - }) - }, [canCreateApp, currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp]) + currentCreateAppModeRef.current = null + currentCreateAppTrackingRef.current = { + source: 'external', + templateId: templateId || undefined, + } + await handleImportDSL( + { + mode: DSLImportMode.YAML_CONTENT, + yaml_content: dslContent, + }, + { + onSuccess: (response) => { + trackCurrentCreateApp(response.app_mode) + handleCloseTemplateModal() + onSuccess() + }, + onPending: () => { + handleCloseTemplateModal() + setShowDSLConfirmModal(true) + }, + }, + ) + }, + [ + canCreateApp, + handleImportDSL, + handleCloseTemplateModal, + onSuccess, + templateId, + trackCurrentCreateApp, + ], + ) + + const onCreate: CreateAppModalProps['onConfirm'] = useCallback( + async ({ name, icon_type, icon, icon_background, description }) => { + if (!canCreateApp) return + + hideTryAppPanel() + + const { export_data, mode } = await fetchAppDetail(currApp?.app.id as string) + currentCreateAppModeRef.current = mode + const payload = { + mode: DSLImportMode.YAML_CONTENT, + yaml_content: export_data, + name, + icon_type, + icon, + icon_background, + description, + } + await handleImportDSL(payload, { + onSuccess: (response) => { + trackCurrentCreateApp(response.app_mode) + setIsShowCreateModal(false) + }, + onPending: () => { + setShowDSLConfirmModal(true) + }, + }) + }, + [canCreateApp, currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp], + ) return ( - <AppListContext.Provider value={{ - currentApp: currentTryAppParams, - isShowTryAppPanel, - setShowTryAppPanel, - controlHideCreateFromTemplatePanel, - }} + <AppListContext.Provider + value={{ + currentApp: currentTryAppParams, + isShowTryAppPanel, + setShowTryAppPanel, + controlHideCreateFromTemplatePanel, + }} > <div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body"> <List controlRefreshList={controlRefreshList} /> @@ -190,16 +201,14 @@ const Apps = () => { /> )} - { - showDSLConfirmModal && ( - <DSLConfirmModal - versions={versions} - onCancel={() => setShowDSLConfirmModal(false)} - onConfirm={onConfirmDSL} - confirmDisabled={isFetching} - /> - ) - } + {showDSLConfirmModal && ( + <DSLConfirmModal + versions={versions} + onCancel={() => setShowDSLConfirmModal(false)} + onConfirm={onConfirmDSL} + confirmDisabled={isFetching} + /> + )} {isShowCreateModal && ( <CreateAppModal diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 48e2a8c134c967..a459c1e3c77044 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -2,7 +2,12 @@ import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen' import { cn } from '@langgenius/dify-ui/cn' -import { keepPreviousData, useInfiniteQuery, useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { + keepPreviousData, + useInfiniteQuery, + useQuery, + useSuspenseQuery, +} from '@tanstack/react-query' import { useDebounce } from 'ahooks' import { useAtomValue } from 'jotai' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -39,9 +44,7 @@ type Props = Readonly<{ controlRefreshList?: number }> -function List({ - controlRefreshList = 0, -}: Props) { +function List({ controlRefreshList = 0 }: Props) { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) @@ -66,13 +69,15 @@ function List({ const [needsRefreshAppList, setNeedsRefreshAppList] = useNeedRefreshAppList() const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') - const handleDSLFileDropped = useCallback((file: File) => { - if (!canCreateApp) - return + const handleDSLFileDropped = useCallback( + (file: File) => { + if (!canCreateApp) return - setDroppedDSLFile(file) - setShowCreateFromDSLModal(true) - }, [canCreateApp]) + setDroppedDSLFile(file) + setShowCreateFromDSLModal(true) + }, + [canCreateApp], + ) const { dragging } = useDSLDragDrop({ onDSLFileDropped: handleDSLFileDropped, @@ -80,15 +85,18 @@ function List({ enabled: canCreateApp, }) - const appListQuery = useMemo<AppListQuery>(() => ({ - page: 1, - limit: 30, - name: debouncedKeywords, - sort_by: sortBy, - ...(tagIDs.length ? { tag_ids: tagIDs } : {}), - ...(creatorIDs.length ? { creator_ids: creatorIDs } : {}), - ...(category !== 'all' ? { mode: category } : {}), - }), [category, creatorIDs, debouncedKeywords, sortBy, tagIDs]) + const appListQuery = useMemo<AppListQuery>( + () => ({ + page: 1, + limit: 30, + name: debouncedKeywords, + sort_by: sortBy, + ...(tagIDs.length ? { tag_ids: tagIDs } : {}), + ...(creatorIDs.length ? { creator_ids: creatorIDs } : {}), + ...(category !== 'all' ? { mode: category } : {}), + }), + [category, creatorIDs, debouncedKeywords, sortBy, tagIDs], + ) const { data, @@ -101,33 +109,33 @@ function List({ refetch, } = useInfiniteQuery({ ...consoleQuery.apps.get.infiniteOptions({ - input: pageParam => ({ + input: (pageParam) => ({ query: { ...appListQuery, page: Number(pageParam), }, }), - getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), initialPageParam: 1, placeholderData: keepPreviousData, }), - select: data => ({ + select: (data) => ({ ...data, pages: data.pages.map(normalizeAppPagination), }), refetchInterval: systemFeatures.enable_collaboration_mode ? 10000 : false, }) - const starredAppListQuery = useMemo<AppListQuery>(() => ({ - ...appListQuery, - page: 1, - limit: STARRED_APP_LIMIT, - }), [appListQuery]) + const starredAppListQuery = useMemo<AppListQuery>( + () => ({ + ...appListQuery, + page: 1, + limit: STARRED_APP_LIMIT, + }), + [appListQuery], + ) - const { - data: starredAppList, - refetch: refetchStarredAppList, - } = useQuery({ + const { data: starredAppList, refetch: refetchStarredAppList } = useQuery({ ...consoleQuery.apps.starred.get.queryOptions({ input: { query: starredAppListQuery, @@ -142,8 +150,7 @@ function List({ }, [refetch, refetchStarredAppList]) useEffect(() => { - if (controlRefreshList > 0) - refetch() + if (controlRefreshList > 0) refetch() }, [controlRefreshList, refetch]) const anchorRef = useRef<HTMLDivElement>(null) @@ -160,8 +167,7 @@ function List({ let observer: IntersectionObserver | undefined if (error) { - if (observer) - observer.disconnect() + if (observer) observer.disconnect() return } @@ -169,14 +175,17 @@ function List({ const containerHeight = containerRef.current.clientHeight const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) - observer = new IntersectionObserver((entries) => { - if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore) - fetchNextPage() - }, { - root: containerRef.current, - rootMargin: `${dynamicMargin}px`, - threshold: 0.1, - }) + observer = new IntersectionObserver( + (entries) => { + if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore) + fetchNextPage() + }, + { + root: containerRef.current, + rootMargin: `${dynamicMargin}px`, + threshold: 0.1, + }, + ) observer.observe(anchorRef.current) } return () => observer?.disconnect() @@ -195,45 +204,50 @@ function List({ return Array.from(appIds) }, [apps]) - const { - onlineUsersMap: workflowOnlineUsersMap, - } = useWorkflowOnlineUsers({ + const { onlineUsersMap: workflowOnlineUsersMap } = useWorkflowOnlineUsers({ appIds: workflowOnlineUserAppIds, enabled: systemFeatures.enable_collaboration_mode, }) const hasResolvedFirstPage = pages.length > 0 const hasAnyApp = (pages[0]?.total ?? 0) > 0 - const hasActiveFilters = category !== 'all' || tagIDs.length > 0 || keywords.trim().length > 0 || debouncedKeywords.trim().length > 0 || creatorIDs.length > 0 + const hasActiveFilters = + category !== 'all' || + tagIDs.length > 0 || + keywords.trim().length > 0 || + debouncedKeywords.trim().length > 0 || + creatorIDs.length > 0 const showSkeleton = isLoading || (isFetching && pages.length === 0) - const showFirstEmptyState = !showSkeleton && !hasAnyApp && canCreateApp && hasResolvedFirstPage && !hasActiveFilters + const showFirstEmptyState = + !showSkeleton && !hasAnyApp && canCreateApp && hasResolvedFirstPage && !hasActiveFilters const openCreateBlankModal = useCallback(() => { - if (canCreateApp) - setShowNewAppModal(true) + if (canCreateApp) setShowNewAppModal(true) }, [canCreateApp]) const openCreateTemplateDialog = useCallback(() => { - if (canCreateApp) - setShowNewAppTemplateDialog(true) + if (canCreateApp) setShowNewAppTemplateDialog(true) }, [canCreateApp]) const openCreateFromDSLModal = useCallback(() => { - if (canCreateApp) - setShowCreateFromDSLModal(true) + if (canCreateApp) setShowCreateFromDSLModal(true) }, [canCreateApp]) return ( <> - <div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body"> + <div + ref={containerRef} + className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body" + > {dragging && ( - <div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2"> - </div> + <div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2"></div> )} <StudioListHeader - title={( + title={ <div className="flex items-center"> - <h1 className="text-[18px]/[21.6px] font-semibold text-text-primary">{t($ => $['menus.apps'], { ns: 'common' })}</h1> + <h1 className="text-[18px]/[21.6px] font-semibold text-text-primary"> + {t(($) => $['menus.apps'], { ns: 'common' })} + </h1> </div> - )} + } > <AppListHeaderFilters category={category} @@ -253,60 +267,60 @@ function List({ showCreateButton={canCreateApp} /> </StudioListHeader> - {showFirstEmptyState - ? ( - <FirstEmptyState - onCreateBlank={openCreateBlankModal} - onCreateTemplate={openCreateTemplateDialog} - onImportDSL={openCreateFromDSLModal} - showLearnDify={systemFeatures.enable_learn_app} - /> - ) - : ( - <> - {starredApps.length > 0 && ( - <StarredAppList - apps={starredApps} + {showFirstEmptyState ? ( + <FirstEmptyState + onCreateBlank={openCreateBlankModal} + onCreateTemplate={openCreateTemplateDialog} + onImportDSL={openCreateFromDSLModal} + showLearnDify={systemFeatures.enable_learn_app} + /> + ) : ( + <> + {starredApps.length > 0 && ( + <StarredAppList apps={starredApps} onRefresh={refreshAppLists} /> + )} + <div + className={cn( + `relative grow content-start ${APP_LIST_GRID_CLASS_NAME}`, + !hasAnyApp && 'overflow-hidden', + )} + > + {showSkeleton ? ( + <AppCardSkeleton count={6} /> + ) : hasAnyApp ? ( + apps.map((app) => ( + <AppCard + key={app.id} + app={app} + onlineUsers={workflowOnlineUsersMap[app.id] ?? []} onRefresh={refreshAppLists} + onOpenTagManagement={() => setShowTagManagementModal(true)} /> - )} - <div className={cn( - `relative grow content-start ${APP_LIST_GRID_CLASS_NAME}`, - !hasAnyApp && 'overflow-hidden', - )} - > - {showSkeleton - ? <AppCardSkeleton count={6} /> - : hasAnyApp - ? apps.map(app => ( - <AppCard - key={app.id} - app={app} - onlineUsers={workflowOnlineUsersMap[app.id] ?? []} - onRefresh={refreshAppLists} - onOpenTagManagement={() => setShowTagManagementModal(true)} - /> - )) - : <Empty />} - {isFetchingNextPage && ( - <AppCardSkeleton count={3} /> - )} - </div> - </> - )} + )) + ) : ( + <Empty /> + )} + {isFetchingNextPage && <AppCardSkeleton count={3} />} + </div> + </> + )} {canCreateApp && !showFirstEmptyState && ( <div className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`} role="region" - aria-label={t($ => $['newApp.dropDSLToCreateApp'], { ns: 'app' })} + aria-label={t(($) => $['newApp.dropDSLToCreateApp'], { ns: 'app' })} > <span className="i-ri-drag-drop-line size-4" /> - <span className="system-xs-regular">{t($ => $['newApp.dropDSLToCreateApp'], { ns: 'app' })}</span> + <span className="system-xs-regular"> + {t(($) => $['newApp.dropDSLToCreateApp'], { ns: 'app' })} + </span> </div> )} <CheckModal /> - <div ref={anchorRef} className="h-0"> </div> + <div ref={anchorRef} className="h-0"> + {' '} + </div> <AppListTagManagementModal show={showTagManagementModal} onClose={() => setShowTagManagementModal(false)} diff --git a/web/app/components/apps/starred-app-card.tsx b/web/app/components/apps/starred-app-card.tsx index ec308089ecbf9d..35368179ffe1f8 100644 --- a/web/app/components/apps/starred-app-card.tsx +++ b/web/app/components/apps/starred-app-card.tsx @@ -34,14 +34,13 @@ export function StarredAppCard({ app, onRefresh }: StarredAppCardProps) { const editTimeText = useMemo(() => { const timestamp = app.updated_at || app.created_at - if (!timestamp) - return '' + if (!timestamp) return '' const timeText = formatTime({ date: timestamp * 1000, - dateFormat: `${t($ => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, + dateFormat: `${t(($) => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, }) - return `${t($ => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}` + return `${t(($) => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}` }, [app.created_at, app.updated_at, t]) const href = getRedirectionPath(app, { currentUserId, @@ -56,15 +55,17 @@ export function StarredAppCard({ app, onRefresh }: StarredAppCardProps) { : 'hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid', ) const showPreviewOnlyAccessWarning = useCallback(() => { - toast.warning(t($ => $.noAccessResourcePermission, { ns: 'app' })) + toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) }, [t]) - const handlePreviewOnlyCardKeyDown = useCallback((event: KeyboardEvent<HTMLElement>) => { - if (event.key !== 'Enter' && event.key !== ' ') - return + const handlePreviewOnlyCardKeyDown = useCallback( + (event: KeyboardEvent<HTMLElement>) => { + if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - showPreviewOnlyAccessWarning() - }, [showPreviewOnlyAccessWarning]) + event.preventDefault() + showPreviewOnlyAccessWarning() + }, + [showPreviewOnlyAccessWarning], + ) const cardContent = ( <> <div className="relative shrink-0"> @@ -75,7 +76,11 @@ export function StarredAppCard({ app, onRefresh }: StarredAppCardProps) { background={app.icon_background} imageUrl={app.icon_url} /> - <AppTypeIcon type={app.mode} wrapperClassName="absolute -right-0.5 -bottom-0.5 h-4 w-4 shadow-sm" className="size-3" /> + <AppTypeIcon + type={app.mode} + wrapperClassName="absolute -right-0.5 -bottom-0.5 h-4 w-4 shadow-sm" + className="size-3" + /> </div> <div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px"> <div className="truncate system-md-semibold text-text-secondary">{app.name}</div> @@ -90,25 +95,23 @@ export function StarredAppCard({ app, onRefresh }: StarredAppCardProps) { return ( <div className="group relative"> - {isPreviewOnly - ? ( - <article - role="button" - tabIndex={0} - aria-disabled="true" - aria-label={app.name} - className={cardClassName} - onClick={showPreviewOnlyAccessWarning} - onKeyDown={handlePreviewOnlyCardKeyDown} - > - {cardContent} - </article> - ) - : ( - <Link href={href} className={cardClassName}> - {cardContent} - </Link> - )} + {isPreviewOnly ? ( + <article + role="button" + tabIndex={0} + aria-disabled="true" + aria-label={app.name} + className={cardClassName} + onClick={showPreviewOnlyAccessWarning} + onKeyDown={handlePreviewOnlyCardKeyDown} + > + {cardContent} + </article> + ) : ( + <Link href={href} className={cardClassName}> + {cardContent} + </Link> + )} {!isPreviewOnly && <AppCardActionBar app={app} onRefresh={onRefresh} />} </div> ) diff --git a/web/app/components/apps/starred-app-list.tsx b/web/app/components/apps/starred-app-list.tsx index 636a874e6566f1..c1670062cea883 100644 --- a/web/app/components/apps/starred-app-list.tsx +++ b/web/app/components/apps/starred-app-list.tsx @@ -20,28 +20,20 @@ function SectionDivider({ label }: { label: string }) { ) } -export function StarredAppList({ - apps, - onRefresh, -}: StarredAppListProps) { +export function StarredAppList({ apps, onRefresh }: StarredAppListProps) { const { t } = useTranslation() - if (apps.length === 0) - return null + if (apps.length === 0) return null return ( <> - <SectionDivider label={t($ => $['studio.starred'], { ns: 'app' })} /> + <SectionDivider label={t(($) => $['studio.starred'], { ns: 'app' })} /> <div className={APP_LIST_GRID_CLASS_NAME}> - {apps.map(app => ( - <StarredAppCard - key={app.id} - app={app} - onRefresh={onRefresh} - /> + {apps.map((app) => ( + <StarredAppCard key={app.id} app={app} onRefresh={onRefresh} /> ))} </div> - <SectionDivider label={t($ => $['studio.allApps'], { ns: 'app' })} /> + <SectionDivider label={t(($) => $['studio.allApps'], { ns: 'app' })} /> </> ) } diff --git a/web/app/components/apps/storage.ts b/web/app/components/apps/storage.ts index bb54f6155dcd1e..8857dc9e73dc0e 100644 --- a/web/app/components/apps/storage.ts +++ b/web/app/components/apps/storage.ts @@ -2,13 +2,7 @@ import { createLocalStorageState } from 'foxact/create-local-storage-state' export const NEED_REFRESH_APP_LIST_KEY = 'needRefreshAppList' -const [ - useNeedRefreshAppList, - _useNeedRefreshAppListValue, - useSetNeedRefreshAppList, -] = createLocalStorageState<string>(NEED_REFRESH_APP_LIST_KEY, '0', { raw: true }) +const [useNeedRefreshAppList, _useNeedRefreshAppListValue, useSetNeedRefreshAppList] = + createLocalStorageState<string>(NEED_REFRESH_APP_LIST_KEY, '0', { raw: true }) -export { - useNeedRefreshAppList, - useSetNeedRefreshAppList, -} +export { useNeedRefreshAppList, useSetNeedRefreshAppList } diff --git a/web/app/components/apps/studio-list-header.tsx b/web/app/components/apps/studio-list-header.tsx index 70acff4c4ba8bd..97f26c204d4e45 100644 --- a/web/app/components/apps/studio-list-header.tsx +++ b/web/app/components/apps/studio-list-header.tsx @@ -5,15 +5,10 @@ type StudioListHeaderProps = { children: ReactNode } -export function StudioListHeader({ - title, - children, -}: StudioListHeaderProps) { +export function StudioListHeader({ title, children }: StudioListHeaderProps) { return ( <div className="sticky top-0 z-10 flex flex-col gap-[14px] bg-background-body px-8 pt-4 pb-2"> - <div className="flex h-6 min-w-0 items-center"> - {title} - </div> + <div className="flex h-6 min-w-0 items-center">{title}</div> {children} </div> ) diff --git a/web/app/components/base/__tests__/badge.spec.tsx b/web/app/components/base/__tests__/badge.spec.tsx index 532e043768ca29..aa2611eb68aacc 100644 --- a/web/app/components/base/__tests__/badge.spec.tsx +++ b/web/app/components/base/__tests__/badge.spec.tsx @@ -9,7 +9,11 @@ describe('Badge', () => { }) it('should render with children instead of text', () => { - render(<Badge><span>child content</span></Badge>) + render( + <Badge> + <span>child content</span> + </Badge>, + ) expect(screen.getByText(/child content/i)).toBeInTheDocument() }) @@ -66,7 +70,11 @@ describe('Badge', () => { }) it('should prioritize children over text', () => { - render(<Badge text="text content"><span>child wins</span></Badge>) + render( + <Badge text="text content"> + <span>child wins</span> + </Badge>, + ) expect(screen.getByText(/child wins/i)).toBeInTheDocument() expect(screen.queryByText(/text content/i)).not.toBeInTheDocument() }) diff --git a/web/app/components/base/__tests__/theme-switcher.spec.tsx b/web/app/components/base/__tests__/theme-switcher.spec.tsx index d8ed427d95c0ac..9758c881f80e4c 100644 --- a/web/app/components/base/__tests__/theme-switcher.spec.tsx +++ b/web/app/components/base/__tests__/theme-switcher.spec.tsx @@ -60,25 +60,43 @@ describe('ThemeSwitcher', () => { it('should highlight system option when theme is system', () => { mockTheme = 'system' render(<ThemeSwitcher />) - expect(screen.getByTestId('system-theme-container')).toHaveClass('bg-components-segmented-control-item-active-bg') - expect(screen.getByTestId('light-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') - expect(screen.getByTestId('dark-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('system-theme-container')).toHaveClass( + 'bg-components-segmented-control-item-active-bg', + ) + expect(screen.getByTestId('light-theme-container')).not.toHaveClass( + 'bg-components-segmented-control-item-active-bg', + ) + expect(screen.getByTestId('dark-theme-container')).not.toHaveClass( + 'bg-components-segmented-control-item-active-bg', + ) }) it('should highlight light option when theme is light', () => { mockTheme = 'light' render(<ThemeSwitcher />) - expect(screen.getByTestId('light-theme-container')).toHaveClass('bg-components-segmented-control-item-active-bg') - expect(screen.getByTestId('system-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') - expect(screen.getByTestId('dark-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('light-theme-container')).toHaveClass( + 'bg-components-segmented-control-item-active-bg', + ) + expect(screen.getByTestId('system-theme-container')).not.toHaveClass( + 'bg-components-segmented-control-item-active-bg', + ) + expect(screen.getByTestId('dark-theme-container')).not.toHaveClass( + 'bg-components-segmented-control-item-active-bg', + ) }) it('should highlight dark option when theme is dark', () => { mockTheme = 'dark' render(<ThemeSwitcher />) - expect(screen.getByTestId('dark-theme-container')).toHaveClass('bg-components-segmented-control-item-active-bg') - expect(screen.getByTestId('system-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') - expect(screen.getByTestId('light-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('dark-theme-container')).toHaveClass( + 'bg-components-segmented-control-item-active-bg', + ) + expect(screen.getByTestId('system-theme-container')).not.toHaveClass( + 'bg-components-segmented-control-item-active-bg', + ) + expect(screen.getByTestId('light-theme-container')).not.toHaveClass( + 'bg-components-segmented-control-item-active-bg', + ) }) it('should show divider between system and light when dark is active', () => { diff --git a/web/app/components/base/action-button/__tests__/index.spec.tsx b/web/app/components/base/action-button/__tests__/index.spec.tsx index e9db157d0c26b0..b6336122baf7ba 100644 --- a/web/app/components/base/action-button/__tests__/index.spec.tsx +++ b/web/app/components/base/action-button/__tests__/index.spec.tsx @@ -68,7 +68,11 @@ describe('ActionButton', () => { }) it('forwards additional button props', () => { - render(<ActionButton disabled data-testid="test-button">Disabled Button</ActionButton>) + render( + <ActionButton disabled data-testid="test-button"> + Disabled Button + </ActionButton>, + ) const button = screen.getByRole('button', { name: 'Disabled Button' }) expect(button).toBeDisabled() expect(button).toHaveAttribute('data-testid', 'test-button') diff --git a/web/app/components/base/action-button/index.css b/web/app/components/base/action-button/index.css index a19eee75ad2c4e..0dd5d82d940042 100644 --- a/web/app/components/base/action-button/index.css +++ b/web/app/components/base/action-button/index.css @@ -1,8 +1,8 @@ @utility action-btn { - @apply inline-flex justify-center items-center cursor-pointer text-text-tertiary hover:text-text-secondary hover:bg-state-base-hover; + @apply inline-flex cursor-pointer items-center justify-center text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary; &.action-btn-active { - @apply text-text-accent bg-state-accent-active hover:bg-state-accent-active-alt; + @apply bg-state-accent-active text-text-accent hover:bg-state-accent-active-alt; } &.action-btn-disabled { @@ -10,7 +10,7 @@ } &.action-btn-destructive { - @apply text-text-destructive bg-state-destructive-hover; + @apply bg-state-destructive-hover text-text-destructive; } } @@ -27,34 +27,34 @@ } @utility action-btn-xl { - @apply p-2 w-9 h-9 rounded-lg; + @apply h-9 w-9 rounded-lg p-2; } @utility action-btn-l { - @apply p-1.5 w-8 h-8 rounded-lg; + @apply h-8 w-8 rounded-lg p-1.5; } @utility action-btn-m { /* m is for the regular button */ - @apply p-0.5 w-6 h-6 rounded-lg; + @apply h-6 w-6 rounded-lg p-0.5; } @utility action-btn-s { - @apply w-5 h-5 rounded-[6px]; + @apply h-5 w-5 rounded-[6px]; } @utility action-btn-xs { - @apply p-0 w-4 h-4 rounded-sm; + @apply h-4 w-4 rounded-sm p-0; } @utility action-btn-active { &.action-btn { - @apply text-text-accent bg-state-accent-active hover:bg-state-accent-active-alt; + @apply bg-state-accent-active text-text-accent hover:bg-state-accent-active-alt; } } @utility action-btn-destructive { &.action-btn { - @apply text-text-destructive bg-state-destructive-hover; + @apply bg-state-destructive-hover text-text-destructive; } } diff --git a/web/app/components/base/action-button/index.stories.tsx b/web/app/components/base/action-button/index.stories.tsx index 2de850c398283d..06b489b8b0de7f 100644 --- a/web/app/components/base/action-button/index.stories.tsx +++ b/web/app/components/base/action-button/index.stories.tsx @@ -1,5 +1,12 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShareLine } from '@remixicon/react' +import { + RiAddLine, + RiDeleteBinLine, + RiEditLine, + RiMore2Fill, + RiSaveLine, + RiShareLine, +} from '@remixicon/react' import ActionButton, { ActionButtonState } from '.' const meta = { @@ -9,7 +16,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Action button component with multiple sizes and states. Commonly used for toolbar actions and inline operations.', + component: + 'Action button component with multiple sizes and states. Commonly used for toolbar actions and inline operations.', }, }, }, diff --git a/web/app/components/base/action-button/index.tsx b/web/app/components/base/action-button/index.tsx index 86c8933a8a3766..f9a4d31c5301c1 100644 --- a/web/app/components/base/action-button/index.tsx +++ b/web/app/components/base/action-button/index.tsx @@ -12,30 +12,28 @@ enum ActionButtonState { Hover = 'hover', } -const actionButtonVariants = cva( - 'action-btn', - { - variants: { - size: { - xs: 'action-btn-xs', - s: 'action-btn-s', - m: 'action-btn-m', - l: 'action-btn-l', - xl: 'action-btn-xl', - }, - }, - defaultVariants: { - size: 'm', +const actionButtonVariants = cva('action-btn', { + variants: { + size: { + xs: 'action-btn-xs', + s: 'action-btn-s', + m: 'action-btn-m', + l: 'action-btn-l', + xl: 'action-btn-xl', }, }, -) + defaultVariants: { + size: 'm', + }, +}) type ActionButtonProps = { size?: 'xs' | 's' | 'm' | 'l' | 'xl' state?: ActionButtonState styleCss?: CSSProperties ref?: React.Ref<HTMLButtonElement> -} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof actionButtonVariants> +} & React.ButtonHTMLAttributes<HTMLButtonElement> & + VariantProps<typeof actionButtonVariants> function getActionButtonState(state: ActionButtonState) { switch (state) { @@ -52,14 +50,24 @@ function getActionButtonState(state: ActionButtonState) { } } -const ActionButton = ({ className, size, state = ActionButtonState.Default, styleCss, children, ref, disabled, ...props }: ActionButtonProps) => { +const ActionButton = ({ + className, + size, + state = ActionButtonState.Default, + styleCss, + children, + ref, + disabled, + ...props +}: ActionButtonProps) => { return ( <button type="button" className={cn( actionButtonVariants({ className, size }), getActionButtonState(state), - disabled && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled', + disabled && + 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled', )} disabled={disabled} ref={ref} diff --git a/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx index 4c8a1d24bf843e..7df22d0997f990 100644 --- a/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx @@ -28,17 +28,34 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: vi.fn(selector => selector({ appDetail: { id: 'app-id' } })), + useStore: vi.fn((selector) => selector({ appDetail: { id: 'app-id' } })), })) vi.mock('@/app/components/workflow/run/status', () => ({ - default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => ( - <div data-testid="status-panel" data-status={String(status)} data-time={String(time)} data-tokens={String(tokens)}>{error ? <span>{String(error)}</span> : null}</div> + default: ({ + status, + time, + tokens, + error, + }: { + status: string + time?: number + tokens?: number + error?: string + }) => ( + <div + data-testid="status-panel" + data-status={String(status)} + data-time={String(time)} + data-tokens={String(tokens)} + > + {error ? <span>{String(error)}</span> : null} + </div> ), })) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ title, value }: { title: ReactNode, value: string | object }) => ( + default: ({ title, value }: { title: ReactNode; value: string | object }) => ( <div data-testid="code-editor"> {title} {typeof value === 'string' ? value : JSON.stringify(value)} @@ -55,7 +72,9 @@ vi.mock('@/app/components/workflow/block-icon', () => ({ })) vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ - ChevronRight: (props: { className?: string }) => <div data-testid="chevron-right" className={props.className} />, + ChevronRight: (props: { className?: string }) => ( + <div data-testid="chevron-right" className={props.className} /> + ), })) const createMockLog = (overrides: Partial<IChatItem> = {}): IChatItem => ({ @@ -67,7 +86,9 @@ const createMockLog = (overrides: Partial<IChatItem> = {}): IChatItem => ({ ...overrides, }) -const createMockResponse = (overrides: Partial<AgentLogDetailResponse> = {}): AgentLogDetailResponse => ({ +const createMockResponse = ( + overrides: Partial<AgentLogDetailResponse> = {}, +): AgentLogDetailResponse => ({ meta: { status: 'succeeded', executor: 'User', @@ -84,7 +105,14 @@ const createMockResponse = (overrides: Partial<AgentLogDetailResponse> = {}): Ag thought: '', tokens: 0, tool_raw: { inputs: '', outputs: '' }, - tool_calls: [{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }], + tool_calls: [ + { + tool_name: 'tool1', + status: 'success', + tool_icon: null, + tool_label: { 'en-US': 'Tool 1' }, + }, + ], }, ], files: [], @@ -101,7 +129,9 @@ describe('AgentLogDetail', () => { return render(<AgentLogDetail {...defaultProps} {...props} />) } - const renderAndWaitForData = async (props: Partial<ComponentProps<typeof AgentLogDetail>> = {}) => { + const renderAndWaitForData = async ( + props: Partial<ComponentProps<typeof AgentLogDetail>> = {}, + ) => { const result = renderComponent(props) await waitFor(() => { expect(screen.queryByRole('status')).not.toBeInTheDocument() @@ -115,7 +145,7 @@ describe('AgentLogDetail', () => { describe('Rendering', () => { it('should show loading indicator while fetching data', async () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => { })) + vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) renderComponent() @@ -205,7 +235,9 @@ describe('AgentLogDetail', () => { describe('Edge Cases', () => { it('should not fetch data when app detail is unavailable', async () => { - vi.mocked(useAppStore).mockImplementationOnce(selector => selector({ appDetail: undefined } as never)) + vi.mocked(useAppStore).mockImplementationOnce((selector) => + selector({ appDetail: undefined } as never), + ) vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) renderComponent() @@ -237,9 +269,7 @@ describe('AgentLogDetail', () => { }) it('should handle response with empty iterations', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue( - createMockResponse({ iterations: [] }), - ) + vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse({ iterations: [] })) await renderAndWaitForData() }) @@ -254,8 +284,18 @@ describe('AgentLogDetail', () => { tokens: 0, tool_raw: { inputs: '', outputs: '' }, tool_calls: [ - { tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }, - { tool_name: 'tool2', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 2' } }, + { + tool_name: 'tool1', + status: 'success', + tool_icon: null, + tool_label: { 'en-US': 'Tool 1' }, + }, + { + tool_name: 'tool2', + status: 'success', + tool_icon: null, + tool_label: { 'en-US': 'Tool 2' }, + }, ], }, { @@ -265,7 +305,12 @@ describe('AgentLogDetail', () => { tokens: 0, tool_raw: { inputs: '', outputs: '' }, tool_calls: [ - { tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }, + { + tool_name: 'tool1', + status: 'success', + tool_icon: null, + tool_label: { 'en-US': 'Tool 1' }, + }, ], }, ], diff --git a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx index 63485501743850..250bd42acafcab 100644 --- a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx @@ -26,17 +26,34 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: vi.fn(selector => selector({ appDetail: { id: 'app-id' } })), + useStore: vi.fn((selector) => selector({ appDetail: { id: 'app-id' } })), })) vi.mock('@/app/components/workflow/run/status', () => ({ - default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => ( - <div data-testid="status-panel" data-status={String(status)} data-time={String(time)} data-tokens={String(tokens)}>{error ? <span>{String(error)}</span> : null}</div> + default: ({ + status, + time, + tokens, + error, + }: { + status: string + time?: number + tokens?: number + error?: string + }) => ( + <div + data-testid="status-panel" + data-status={String(status)} + data-time={String(time)} + data-tokens={String(tokens)} + > + {error ? <span>{String(error)}</span> : null} + </div> ), })) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( + default: ({ title, value }: { title: React.ReactNode; value: string | object }) => ( <div data-testid="code-editor"> {title} {typeof value === 'string' ? value : JSON.stringify(value)} @@ -53,7 +70,9 @@ vi.mock('@/app/components/workflow/block-icon', () => ({ })) vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ - ChevronRight: (props: { className?: string }) => <div data-testid="chevron-right" className={props.className} />, + ChevronRight: (props: { className?: string }) => ( + <div data-testid="chevron-right" className={props.className} /> + ), })) vi.mock('ahooks', () => ({ @@ -87,14 +106,23 @@ describe('AgentLogModal', () => { agent_mode: 'function_call', iterations: 1, }, - iterations: [{ - created_at: '', - files: [], - thought: '', - tokens: 0, - tool_raw: { inputs: '', outputs: '' }, - tool_calls: [{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }], - }], + iterations: [ + { + created_at: '', + files: [], + thought: '', + tokens: 0, + tool_raw: { inputs: '', outputs: '' }, + tool_calls: [ + { + tool_name: 'tool1', + status: 'success', + tool_icon: null, + tool_label: { 'en-US': 'Tool 1' }, + }, + ], + }, + ], files: [], }) }) @@ -105,7 +133,9 @@ describe('AgentLogModal', () => { }) it('should return null if no conversationId', () => { - const { container } = render(<AgentLogModal {...mockProps} currentLogItem={{ id: '1' } as unknown as IChatItem} />) + const { container } = render( + <AgentLogModal {...mockProps} currentLogItem={{ id: '1' } as unknown as IChatItem} />, + ) expect(container.firstChild).toBeNull() }) diff --git a/web/app/components/base/agent-log-modal/__tests__/iteration.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/iteration.spec.tsx index fdb1eaf470b52a..f124d4856a6513 100644 --- a/web/app/components/base/agent-log-modal/__tests__/iteration.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/iteration.spec.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import Iteration from '../iteration' vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( + default: ({ title, value }: { title: React.ReactNode; value: string | object }) => ( <div data-testid="code-editor"> <div data-testid="code-editor-title">{title}</div> <div data-testid="code-editor-value">{JSON.stringify(value)}</div> @@ -24,7 +24,7 @@ const mockIterationInfo: AgentIteration = { { status: 'success', tool_name: 'test_tool', - tool_label: { 'en': 'Test Tool', 'en-US': 'Test Tool' }, + tool_label: { en: 'Test Tool', 'en-US': 'Test Tool' }, tool_icon: null, }, ], diff --git a/web/app/components/base/agent-log-modal/__tests__/result.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/result.spec.tsx index ca2fcb9c5771e5..1996d243e0cd9e 100644 --- a/web/app/components/base/agent-log-modal/__tests__/result.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/result.spec.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import ResultPanel from '../result' vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( + default: ({ title, value }: { title: React.ReactNode; value: string | object }) => ( <div data-testid="code-editor"> <div data-testid="code-editor-title">{title}</div> <div data-testid="code-editor-value">{JSON.stringify(value)}</div> @@ -12,7 +12,17 @@ vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', ( })) vi.mock('@/app/components/workflow/run/status', () => ({ - default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => ( + default: ({ + status, + time, + tokens, + error, + }: { + status: string + time?: number + tokens?: number + error?: string + }) => ( <div data-testid="status-panel"> <span>{status}</span> <span>{time}</span> diff --git a/web/app/components/base/agent-log-modal/__tests__/tool-call.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/tool-call.spec.tsx index 9b2a2726c5aeda..1485513f883369 100644 --- a/web/app/components/base/agent-log-modal/__tests__/tool-call.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/tool-call.spec.tsx @@ -6,7 +6,7 @@ import { useLocale } from '@/context/i18n' import ToolCallItem from '../tool-call' vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( + default: ({ title, value }: { title: React.ReactNode; value: string | object }) => ( <div data-testid="code-editor"> <div data-testid="code-editor-title">{title}</div> <div data-testid="code-editor-value">{JSON.stringify(value)}</div> @@ -120,7 +120,7 @@ describe('ToolCallItem', () => { fireEvent.click(screen.getByText('LLM')) const titles = screen.getAllByTestId('code-editor-title') - const titleTexts = titles.map(t => t.textContent) + const titleTexts = titles.map((t) => t.textContent) expect(titleTexts).toContain('INPUT') expect(titleTexts).toContain('OUTPUT') diff --git a/web/app/components/base/agent-log-modal/__tests__/tracing.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/tracing.spec.tsx index 0e2bb384766d0f..bdf8d8cb2d07e3 100644 --- a/web/app/components/base/agent-log-modal/__tests__/tracing.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/tracing.spec.tsx @@ -8,11 +8,13 @@ vi.mock('@/app/components/workflow/block-icon', () => ({ })) vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ - ChevronRight: (props: { className?: string }) => <div data-testid="chevron-right" className={props.className} />, + ChevronRight: (props: { className?: string }) => ( + <div data-testid="chevron-right" className={props.className} /> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( + default: ({ title, value }: { title: React.ReactNode; value: string | object }) => ( <div data-testid="code-editor"> {title} {typeof value === 'string' ? value : JSON.stringify(value)} @@ -25,7 +27,9 @@ const createIteration = (thought: string, tokens: number): AgentIteration => ({ files: [], thought, tokens, - tool_calls: [{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }], + tool_calls: [ + { tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }, + ], tool_raw: { inputs: '', outputs: '' }, }) diff --git a/web/app/components/base/agent-log-modal/detail.tsx b/web/app/components/base/agent-log-modal/detail.tsx index aa11c8b6111a27..66149b5d855843 100644 --- a/web/app/components/base/agent-log-modal/detail.tsx +++ b/web/app/components/base/agent-log-modal/detail.tsx @@ -21,35 +21,46 @@ type AgentLogDetailProps = Readonly<{ log: IChatItem messageID: string }> -const AgentLogDetail: FC<AgentLogDetailProps> = ({ activeTab = 'DETAIL', conversationID, messageID, log }) => { +const AgentLogDetail: FC<AgentLogDetailProps> = ({ + activeTab = 'DETAIL', + conversationID, + messageID, + log, +}) => { const { t } = useTranslation() const [currentTab, setCurrentTab] = useState<string>(activeTab) - const appDetail = useAppStore(s => s.appDetail) + const appDetail = useAppStore((s) => s.appDetail) const [loading, setLoading] = useState<boolean>(true) const [runDetail, setRunDetail] = useState<AgentLogDetailResponse>() const [list, setList] = useState<AgentIteration[]>([]) const tools = useMemo(() => { - const res = uniq(flatten(runDetail?.iterations.map((iteration) => { - return iteration.tool_calls.map((tool: any) => tool.tool_name).filter(Boolean) - })).filter(Boolean)) + const res = uniq( + flatten( + runDetail?.iterations.map((iteration) => { + return iteration.tool_calls.map((tool: any) => tool.tool_name).filter(Boolean) + }), + ).filter(Boolean), + ) return res }, [runDetail]) - const getLogDetail = useCallback(async (appID: string, conversationID: string, messageID: string) => { - try { - const res = await fetchAgentLogDetail({ - appID, - params: { - conversation_id: conversationID, - message_id: messageID, - }, - }) - setRunDetail(res) - setList(res.iterations) - } - catch (err) { - toast.error(`${err}`) - } - }, []) + const getLogDetail = useCallback( + async (appID: string, conversationID: string, messageID: string) => { + try { + const res = await fetchAgentLogDetail({ + appID, + params: { + conversation_id: conversationID, + message_id: messageID, + }, + }) + setRunDetail(res) + setList(res.iterations) + } catch (err) { + toast.error(`${err}`) + } + }, + [], + ) const getData = async (appID: string, conversationID: string, messageID: string) => { setLoading(true) await getLogDetail(appID, conversationID, messageID) @@ -60,8 +71,7 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({ activeTab = 'DETAIL', convers } useEffect(() => { // fetch data - if (appDetail) - getData(appDetail.id, conversationID, messageID) + if (appDetail) getData(appDetail.id, conversationID, messageID) }, [appDetail, conversationID, messageID]) return ( <div className="relative flex grow flex-col"> @@ -69,30 +79,55 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({ activeTab = 'DETAIL', convers <div className="flex shrink-0 items-center border-b-[0.5px] border-divider-regular px-4"> <button type="button" - className={cn('mr-6 cursor-pointer border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-0 py-3 text-left text-[13px] leading-[18px] font-semibold text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', currentTab === 'DETAIL' && 'border-[rgb(21,94,239)]! text-text-secondary')} + className={cn( + 'mr-6 cursor-pointer border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-0 py-3 text-left text-[13px] leading-[18px] font-semibold text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + currentTab === 'DETAIL' && 'border-[rgb(21,94,239)]! text-text-secondary', + )} data-active={currentTab === 'DETAIL'} onClick={() => switchTab('DETAIL')} > - {t($ => $.detail, { ns: 'runLog' })} + {t(($) => $.detail, { ns: 'runLog' })} </button> <button type="button" - className={cn('mr-6 cursor-pointer border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-0 py-3 text-left text-[13px] leading-[18px] font-semibold text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', currentTab === 'TRACING' && 'border-[rgb(21,94,239)]! text-text-secondary')} + className={cn( + 'mr-6 cursor-pointer border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-0 py-3 text-left text-[13px] leading-[18px] font-semibold text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + currentTab === 'TRACING' && 'border-[rgb(21,94,239)]! text-text-secondary', + )} data-active={currentTab === 'TRACING'} onClick={() => switchTab('TRACING')} > - {t($ => $.tracing, { ns: 'runLog' })} + {t(($) => $.tracing, { ns: 'runLog' })} </button> </div> {/* panel detail */} - <div className={cn('h-0 grow overflow-y-auto rounded-b-2xl bg-components-panel-bg', currentTab !== 'DETAIL' && 'bg-background-section!')}> + <div + className={cn( + 'h-0 grow overflow-y-auto rounded-b-2xl bg-components-panel-bg', + currentTab !== 'DETAIL' && 'bg-background-section!', + )} + > {loading && ( <div className="flex h-full items-center justify-center bg-components-panel-bg"> <Loading /> </div> )} - {!loading && currentTab === 'DETAIL' && runDetail && (<ResultPanel inputs={log.input} outputs={log.content} status={runDetail.meta.status} error={runDetail.meta.error} elapsed_time={runDetail.meta.elapsed_time} total_tokens={runDetail.meta.total_tokens} created_at={runDetail.meta.start_time} created_by={runDetail.meta.executor} agentMode={runDetail.meta.agent_mode} tools={tools} iterations={runDetail.iterations.length} />)} - {!loading && currentTab === 'TRACING' && (<TracingPanel list={list} />)} + {!loading && currentTab === 'DETAIL' && runDetail && ( + <ResultPanel + inputs={log.input} + outputs={log.content} + status={runDetail.meta.status} + error={runDetail.meta.error} + elapsed_time={runDetail.meta.elapsed_time} + total_tokens={runDetail.meta.total_tokens} + created_at={runDetail.meta.start_time} + created_by={runDetail.meta.executor} + agentMode={runDetail.meta.agent_mode} + tools={tools} + iterations={runDetail.iterations.length} + /> + )} + {!loading && currentTab === 'TRACING' && <TracingPanel list={list} />} </div> </div> ) diff --git a/web/app/components/base/agent-log-modal/index.stories.tsx b/web/app/components/base/agent-log-modal/index.stories.tsx index 1ebb1944df8548..eb3f5a861d1215 100644 --- a/web/app/components/base/agent-log-modal/index.stories.tsx +++ b/web/app/components/base/agent-log-modal/index.stories.tsx @@ -64,13 +64,9 @@ const MOCK_CHAT_ITEM: IChatItem = { conversationId: 'conv-123', } -const AgentLogModalDemo = ({ - width = 960, -}: { - width?: number -}) => { +const AgentLogModalDemo = ({ width = 960 }: { width?: number }) => { const originalFetchRef = useRef<typeof globalThis.fetch>(null) - const setAppDetail = useAppStore(state => state.setAppDetail) + const setAppDetail = useAppStore((state) => state.setAppDetail) useEffect(() => { setAppDetail({ @@ -93,8 +89,7 @@ const AgentLogModalDemo = ({ }) } - if (originalFetchRef.current) - return originalFetchRef.current(request) + if (originalFetchRef.current) return originalFetchRef.current(request) throw new Error(`Unhandled request: ${url}`) } @@ -102,8 +97,7 @@ const AgentLogModalDemo = ({ globalThis.fetch = handler as typeof globalThis.fetch return () => { - if (originalFetchRef.current) - globalThis.fetch = originalFetchRef.current + if (originalFetchRef.current) globalThis.fetch = originalFetchRef.current setAppDetail(undefined) } }, [setAppDetail]) @@ -131,7 +125,8 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Agent execution viewer showing iterations, tool calls, and metadata. Fetch responses are mocked for Storybook.', + component: + 'Agent execution viewer showing iterations, tool calls, and metadata. Fetch responses are mocked for Storybook.', }, }, }, diff --git a/web/app/components/base/agent-log-modal/index.tsx b/web/app/components/base/agent-log-modal/index.tsx index 1e5e7efd52edb0..0067b5e42cafb4 100644 --- a/web/app/components/base/agent-log-modal/index.tsx +++ b/web/app/components/base/agent-log-modal/index.tsx @@ -14,27 +14,20 @@ type AgentLogModalProps = Readonly<{ floating?: boolean onCancel: () => void }> -const AgentLogModal: FC<AgentLogModalProps> = ({ - currentLogItem, - width, - floating, - onCancel, -}) => { +const AgentLogModal: FC<AgentLogModalProps> = ({ currentLogItem, width, floating, onCancel }) => { const { t } = useTranslation() const ref = useRef(null) const [mounted, setMounted] = useState(false) useClickAway(() => { - if (mounted && !floating) - onCancel() + if (mounted && !floating) onCancel() }, ref) useEffect(() => { setMounted(true) }, []) - if (!currentLogItem || !currentLogItem.conversationId) - return null + if (!currentLogItem || !currentLogItem.conversationId) return null const detailContent = ( <> @@ -51,18 +44,19 @@ const AgentLogModal: FC<AgentLogModalProps> = ({ <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DialogContent backdropClassName="bg-transparent!" className="top-16! bottom-4! left-[max(8px,calc(100vw-1136px))]! flex max-h-none! w-[480px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! flex-col overflow-hidden! rounded-xl! border-[0.5px]! border-components-panel-border! bg-components-panel-bg! p-0! pt-3! pb-3! shadow-xl!" > - <DialogTitle className="text-md shrink-0 px-4 py-1 font-semibold text-text-primary">{t($ => $['runDetail.workflowTitle'], { ns: 'appLog' })}</DialogTitle> + <DialogTitle className="text-md shrink-0 px-4 py-1 font-semibold text-text-primary"> + {t(($) => $['runDetail.workflowTitle'], { ns: 'appLog' })} + </DialogTitle> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onCancel} > @@ -76,7 +70,9 @@ const AgentLogModal: FC<AgentLogModalProps> = ({ return ( <div - className={cn('relative z-10 flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-3 shadow-xl')} + className={cn( + 'relative z-10 flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-3 shadow-xl', + )} style={{ width: 480, position: 'fixed', @@ -86,10 +82,12 @@ const AgentLogModal: FC<AgentLogModalProps> = ({ }} ref={ref} > - <h1 className="text-md shrink-0 px-4 py-1 font-semibold text-text-primary">{t($ => $['runDetail.workflowTitle'], { ns: 'appLog' })}</h1> + <h1 className="text-md shrink-0 px-4 py-1 font-semibold text-text-primary"> + {t(($) => $['runDetail.workflowTitle'], { ns: 'appLog' })} + </h1> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onCancel} > diff --git a/web/app/components/base/agent-log-modal/iteration.tsx b/web/app/components/base/agent-log-modal/iteration.tsx index 521ff37ca95f56..402e600c792ef7 100644 --- a/web/app/components/base/agent-log-modal/iteration.tsx +++ b/web/app/components/base/agent-log-modal/iteration.tsx @@ -19,10 +19,12 @@ const Iteration: FC<Props> = ({ iterationInfo, isFinal, index }) => { <div className={cn('px-4 py-2')}> <div className="flex items-center"> {isFinal && ( - <div className="mr-3 shrink-0 text-xs leading-[18px] font-semibold text-text-tertiary">{t($ => $['agentLogDetail.finalProcessing'], { ns: 'appLog' })}</div> + <div className="mr-3 shrink-0 text-xs leading-[18px] font-semibold text-text-tertiary"> + {t(($) => $['agentLogDetail.finalProcessing'], { ns: 'appLog' })} + </div> )} {!isFinal && ( - <div className="mr-3 shrink-0 text-xs leading-[18px] font-semibold text-text-tertiary">{`${t($ => $['agentLogDetail.iteration'], { ns: 'appLog' }).toUpperCase()} ${index}`}</div> + <div className="mr-3 shrink-0 text-xs leading-[18px] font-semibold text-text-tertiary">{`${t(($) => $['agentLogDetail.iteration'], { ns: 'appLog' }).toUpperCase()} ${index}`}</div> )} <Divider bgStyle="gradient" className="mx-0 h-px grow" /> </div> @@ -38,11 +40,7 @@ const Iteration: FC<Props> = ({ iterationInfo, isFinal, index }) => { }} /> {iterationInfo.tool_calls.map((toolCall, index) => ( - <ToolCall - isLLM={false} - key={index} - toolCall={toolCall} - /> + <ToolCall isLLM={false} key={index} toolCall={toolCall} /> ))} </div> ) diff --git a/web/app/components/base/agent-log-modal/result.tsx b/web/app/components/base/agent-log-modal/result.tsx index 07fa40c4c7abbb..6ced84be17010b 100644 --- a/web/app/components/base/agent-log-modal/result.tsx +++ b/web/app/components/base/agent-log-modal/result.tsx @@ -38,12 +38,7 @@ const ResultPanel: FC<ResultPanelProps> = ({ return ( <div className="bg-components-panel-bg py-2"> <div className="px-4 py-2"> - <StatusPanel - status="succeeded" - time={elapsed_time} - tokens={total_tokens} - error={error} - /> + <StatusPanel status="succeeded" time={elapsed_time} tokens={total_tokens} error={error} /> </div> <div className="flex flex-col gap-2 px-4 py-2"> <CodeEditor @@ -66,52 +61,79 @@ const ResultPanel: FC<ResultPanelProps> = ({ </div> <div className="px-4 py-2"> <div className="relative"> - <div className="h-6 text-xs/6 font-medium text-text-tertiary">{t($ => $['meta.title'], { ns: 'runLog' })}</div> + <div className="h-6 text-xs/6 font-medium text-text-tertiary"> + {t(($) => $['meta.title'], { ns: 'runLog' })} + </div> <div className="py-1"> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary">{t($ => $['meta.status'], { ns: 'runLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary"> + {t(($) => $['meta.status'], { ns: 'runLog' })} + </div> <div className="grow px-2 py-[5px] text-xs leading-[18px] text-text-primary"> <span>SUCCESS</span> </div> </div> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary">{t($ => $['meta.executor'], { ns: 'runLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary"> + {t(($) => $['meta.executor'], { ns: 'runLog' })} + </div> <div className="grow px-2 py-[5px] text-xs leading-[18px] text-text-primary"> <span>{created_by || 'N/A'}</span> </div> </div> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary">{t($ => $['meta.startTime'], { ns: 'runLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary"> + {t(($) => $['meta.startTime'], { ns: 'runLog' })} + </div> <div className="grow px-2 py-[5px] text-xs leading-[18px] text-text-primary"> - <span>{formatTime(Date.parse(created_at) / 1000, t($ => $.dateTimeFormat, { ns: 'appLog' }) as string)}</span> + <span> + {formatTime( + Date.parse(created_at) / 1000, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + )} + </span> </div> </div> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary">{t($ => $['meta.time'], { ns: 'runLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary"> + {t(($) => $['meta.time'], { ns: 'runLog' })} + </div> <div className="grow px-2 py-[5px] text-xs leading-[18px] text-text-primary"> <span>{`${elapsed_time?.toFixed(3)}s`}</span> </div> </div> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary">{t($ => $['meta.tokens'], { ns: 'runLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary"> + {t(($) => $['meta.tokens'], { ns: 'runLog' })} + </div> <div className="grow px-2 py-[5px] text-xs leading-[18px] text-text-primary"> <span>{`${total_tokens || 0} Tokens`}</span> </div> </div> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary">{t($ => $['agentLogDetail.agentMode'], { ns: 'appLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary"> + {t(($) => $['agentLogDetail.agentMode'], { ns: 'appLog' })} + </div> <div className="grow px-2 py-[5px] text-xs leading-[18px] text-text-primary"> - <span>{agentMode === 'function_call' ? t($ => $['agent.agentModeType.functionCall'], { ns: 'appDebug' }) : t($ => $['agent.agentModeType.ReACT'], { ns: 'appDebug' })}</span> + <span> + {agentMode === 'function_call' + ? t(($) => $['agent.agentModeType.functionCall'], { ns: 'appDebug' }) + : t(($) => $['agent.agentModeType.ReACT'], { ns: 'appDebug' })} + </span> </div> </div> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary">{t($ => $['agentLogDetail.toolUsed'], { ns: 'appLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary"> + {t(($) => $['agentLogDetail.toolUsed'], { ns: 'appLog' })} + </div> <div className="grow px-2 py-[5px] text-xs leading-[18px] text-text-primary"> <span>{tools?.length ? tools?.join(', ') : 'Null'}</span> </div> </div> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary">{t($ => $['agentLogDetail.iterations'], { ns: 'appLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary"> + {t(($) => $['agentLogDetail.iterations'], { ns: 'appLog' })} + </div> <div className="grow px-2 py-[5px] text-xs leading-[18px] text-text-primary"> <span>{iterations}</span> </div> diff --git a/web/app/components/base/agent-log-modal/tool-call.tsx b/web/app/components/base/agent-log-modal/tool-call.tsx index d00b9790f793b5..32fd6da6356201 100644 --- a/web/app/components/base/agent-log-modal/tool-call.tsx +++ b/web/app/components/base/agent-log-modal/tool-call.tsx @@ -2,16 +2,12 @@ import type { FC } from 'react' import type { ToolCall } from '@/models/log' import { cn } from '@langgenius/dify-ui/cn' -import { - RiCheckboxCircleLine, - RiErrorWarningLine, -} from '@remixicon/react' +import { RiCheckboxCircleLine, RiErrorWarningLine } from '@remixicon/react' import { useState } from 'react' import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' import BlockIcon from '@/app/components/workflow/block-icon' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' - import { BlockEnum } from '@/app/components/workflow/types' import { useLocale } from '@/context/i18n' @@ -24,31 +20,40 @@ type Props = Readonly<{ finalAnswer?: any }> -const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, observation, finalAnswer }) => { +const ToolCallItem: FC<Props> = ({ + toolCall, + isLLM = false, + isFinal, + tokens, + observation, + finalAnswer, +}) => { const [collapseState, setCollapseState] = useState<boolean>(true) const locale = useLocale() - const toolName = isLLM ? 'LLM' : (toolCall.tool_label[locale] || toolCall.tool_label[locale.replaceAll('-', '_')]) + const toolName = isLLM + ? 'LLM' + : toolCall.tool_label[locale] || toolCall.tool_label[locale.replaceAll('-', '_')] const getTime = (time: number) => { - if (time < 1) - return `${(time * 1000).toFixed(3)} ms` - if (time > 60) - return `${Math.floor(time / 60)} m ${(time % 60).toFixed(3)} s` + if (time < 1) return `${(time * 1000).toFixed(3)} ms` + if (time > 60) return `${Math.floor(time / 60)} m ${(time % 60).toFixed(3)} s` return `${time.toFixed(3)} s` } const getTokenCount = (tokens: number) => { - if (tokens < 1000) - return tokens + if (tokens < 1000) return tokens if (tokens >= 1000 && tokens < 1000000) return `${Number.parseFloat((tokens / 1000).toFixed(3))}K` - if (tokens >= 1000000) - return `${Number.parseFloat((tokens / 1000000).toFixed(3))}M` + if (tokens >= 1000000) return `${Number.parseFloat((tokens / 1000000).toFixed(3))}M` } return ( <div className={cn('py-1')}> - <div className={cn('group rounded-2xl border border-components-panel-border bg-background-default shadow-xs transition-all hover:shadow-md')}> + <div + className={cn( + 'group rounded-2xl border border-components-panel-border bg-background-default shadow-xs transition-all hover:shadow-md', + )} + > <div className={cn( 'flex cursor-pointer items-center py-3 pr-3 pl-[6px]', @@ -62,7 +67,11 @@ const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, obs !collapseState && 'rotate-90', )} /> - <BlockIcon className={cn('mr-2 shrink-0')} type={isLLM ? BlockEnum.LLM : BlockEnum.Tool} toolIcon={toolCall.tool_icon} /> + <BlockIcon + className={cn('mr-2 shrink-0')} + type={isLLM ? BlockEnum.LLM : BlockEnum.Tool} + toolIcon={toolCall.tool_icon} + /> <div className={cn( 'grow truncate text-[13px] leading-[16px] font-semibold text-text-secondary', @@ -72,12 +81,8 @@ const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, obs {toolName} </div> <div className="shrink-0 text-xs leading-[18px] text-text-tertiary"> - {!!toolCall.time_cost && ( - <span>{getTime(toolCall.time_cost || 0)}</span> - )} - {isLLM && ( - <span>{`${getTokenCount(tokens || 0)} tokens`}</span> - )} + {!!toolCall.time_cost && <span>{getTime(toolCall.time_cost || 0)}</span>} + {isLLM && <span>{`${getTokenCount(tokens || 0)} tokens`}</span>} </div> {toolCall.status === 'success' && ( <RiCheckboxCircleLine className="ml-2 h-3.5 w-3.5 shrink-0 text-[#12B76A]" /> @@ -90,7 +95,9 @@ const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, obs <div className="pb-2"> <div className={cn('px-[10px] py-1')}> {toolCall.status === 'error' && ( - <div className="rounded-lg border-[0.5px] border-[rbga(0,0,0,0.05)] bg-[#fef3f2] px-3 py-[10px] text-xs leading-[18px] text-[#d92d20] shadow-xs">{toolCall.error}</div> + <div className="rounded-lg border-[0.5px] border-[rbga(0,0,0,0.05)] bg-[#fef3f2] px-3 py-[10px] text-xs leading-[18px] text-[#d92d20] shadow-xs"> + {toolCall.error} + </div> )} </div> {toolCall.tool_input && ( diff --git a/web/app/components/base/alert.tsx b/web/app/components/base/alert.tsx index 6e26c53eeea04f..7b7de13773c0d7 100644 --- a/web/app/components/base/alert.tsx +++ b/web/app/components/base/alert.tsx @@ -1,8 +1,6 @@ import { cn } from '@langgenius/dify-ui/cn' import { cva } from 'class-variance-authority' -import { - memo, -} from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' type Props = Readonly<{ @@ -11,31 +9,26 @@ type Props = Readonly<{ onHide: () => void className?: string }> -const bgVariants = cva( - '', - { - variants: { - type: { - info: 'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent', - }, +const bgVariants = cva('', { + variants: { + type: { + info: 'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent', }, }, -) -const Alert: React.FC<Props> = ({ - type = 'info', - message, - onHide, - className, -}) => { +}) +const Alert: React.FC<Props> = ({ type = 'info', message, onHide, className }) => { const { t } = useTranslation() return ( <div className={cn('pointer-events-none w-full', className)}> - <div - className="relative flex space-x-1 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg" - > - <div className={cn('pointer-events-none absolute inset-0 bg-linear-to-r opacity-[0.4]', bgVariants({ type }))} data-testid="alert-gradient"> - </div> + <div className="relative flex space-x-1 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg"> + <div + className={cn( + 'pointer-events-none absolute inset-0 bg-linear-to-r opacity-[0.4]', + bgVariants({ type }), + )} + data-testid="alert-gradient" + ></div> <div className="flex size-6 items-center justify-center"> <span className="i-ri-information-2-fill text-text-accent" data-testid="info-icon" /> </div> @@ -46,7 +39,7 @@ const Alert: React.FC<Props> = ({ </div> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="pointer-events-auto flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-components-button-secondary-accent-border" onClick={onHide} > diff --git a/web/app/components/base/amplitude/AmplitudeProvider.tsx b/web/app/components/base/amplitude/AmplitudeProvider.tsx index 346cfaa7c4da3f..927c433770f7f7 100644 --- a/web/app/components/base/amplitude/AmplitudeProvider.tsx +++ b/web/app/components/base/amplitude/AmplitudeProvider.tsx @@ -8,9 +8,7 @@ import { ensureAmplitudeInitialized } from './init' export type IAmplitudeProps = AmplitudeInitializationOptions -const AmplitudeProvider: FC<IAmplitudeProps> = ({ - sessionReplaySampleRate = 0.5, -}) => { +const AmplitudeProvider: FC<IAmplitudeProps> = ({ sessionReplaySampleRate = 0.5 }) => { useEffect(() => { ensureAmplitudeInitialized({ sessionReplaySampleRate, diff --git a/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx b/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx index 9622ddee60e870..128323eef3fd8a 100644 --- a/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx +++ b/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx @@ -70,7 +70,9 @@ describe('AmplitudeProvider', () => { it('pageNameEnrichmentPlugin logic works as expected', async () => { render(<AmplitudeProvider />) - const plugin = vi.mocked(amplitude.add).mock.calls[0]?.[0] as amplitude.Types.EnrichmentPlugin | undefined + const plugin = vi.mocked(amplitude.add).mock.calls[0]?.[0] as + | amplitude.Types.EnrichmentPlugin + | undefined expect(plugin).toBeDefined() if (!plugin?.execute || !plugin.setup) throw new Error('Expected page-name-enrichment plugin with setup/execute') @@ -83,10 +85,7 @@ describe('AmplitudeProvider', () => { const getPageTitle = (evt: amplitude.Types.Event | null | undefined) => (evt?.event_properties as Record<string, unknown> | undefined)?.['[Amplitude] Page Title'] - await setup( - {} as Parameters<SetupFn>[0], - {} as Parameters<SetupFn>[1], - ) + await setup({} as Parameters<SetupFn>[0], {} as Parameters<SetupFn>[1]) const originalWindowLocation = window.location try { @@ -135,8 +134,7 @@ describe('AmplitudeProvider', () => { } as amplitude.Types.Event const noPropsResult = await execute(noPropsEvent) expect(noPropsResult?.event_properties).toBeUndefined() - } - finally { + } finally { Object.defineProperty(window, 'location', { value: originalWindowLocation, writable: true, diff --git a/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts b/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts index 27c48339f10526..1347cb2c16fc54 100644 --- a/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts +++ b/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts @@ -54,8 +54,7 @@ describe('registration tracking', () => { try { expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow() - } - finally { + } finally { vi.unstubAllGlobals() } }) @@ -126,8 +125,7 @@ describe('registration tracking', () => { try { expect(() => flushRegistrationSuccess()).not.toThrow() expect(mockTrackEvent).not.toHaveBeenCalled() - } - finally { + } finally { vi.unstubAllGlobals() } }) @@ -147,9 +145,10 @@ describe('registration tracking', () => { try { flushRegistrationSuccess() - expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', { method: 'email' }) - } - finally { + expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', { + method: 'email', + }) + } finally { vi.unstubAllGlobals() } }) @@ -165,8 +164,7 @@ describe('registration tracking', () => { expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow() expect(() => flushRegistrationSuccess()).not.toThrow() expect(mockTrackEvent).not.toHaveBeenCalled() - } - finally { + } finally { vi.unstubAllGlobals() } }) @@ -182,8 +180,7 @@ describe('registration tracking', () => { expect(() => rememberRegistrationSuccess({ method: 'oauth' })).not.toThrow() expect(() => flushRegistrationSuccess()).not.toThrow() expect(mockTrackEvent).not.toHaveBeenCalled() - } - finally { + } finally { vi.unstubAllGlobals() } }) diff --git a/web/app/components/base/amplitude/__tests__/utils.spec.ts b/web/app/components/base/amplitude/__tests__/utils.spec.ts index 859d4895c32707..0d071fe008d3e9 100644 --- a/web/app/components/base/amplitude/__tests__/utils.spec.ts +++ b/web/app/components/base/amplitude/__tests__/utils.spec.ts @@ -10,15 +10,16 @@ const mockSetUserId = vi.hoisted(() => vi.fn()) const mockIdentify = vi.hoisted(() => vi.fn()) const mockReset = vi.hoisted(() => vi.fn()) -const MockIdentify = vi.hoisted(() => - class { - setCalls: Array<[string, unknown]> = [] - - set(key: string, value: unknown) { - this.setCalls.push([key, value]) - return this - } - }, +const MockIdentify = vi.hoisted( + () => + class { + setCalls: Array<[string, unknown]> = [] + + set(key: string, value: unknown) { + this.setCalls.push([key, value]) + return this + } + }, ) vi.mock('@/config', () => ({ diff --git a/web/app/components/base/amplitude/init.ts b/web/app/components/base/amplitude/init.ts index d603d5b554c5d1..d88cea4416cad8 100644 --- a/web/app/components/base/amplitude/init.ts +++ b/web/app/components/base/amplitude/init.ts @@ -16,13 +16,13 @@ const getEnglishPageName = (pathname: string): string => { const pageNameMap: Record<string, string> = { '': 'Home', - 'apps': 'Studio', - 'datasets': 'Knowledge', - 'explore': 'Explore', - 'tools': 'Tools', - 'account': 'Account', - 'signin': 'Sign In', - 'signup': 'Sign Up', + apps: 'Studio', + datasets: 'Knowledge', + explore: 'Explore', + tools: 'Tools', + account: 'Account', + signin: 'Sign In', + signup: 'Sign Up', } return pageNameMap[firstSegment] || firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1) @@ -49,8 +49,7 @@ const createPageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => { export const ensureAmplitudeInitialized = ({ sessionReplaySampleRate = 0.5, }: AmplitudeInitializationOptions = {}) => { - if (!isAmplitudeEnabled || isAmplitudeInitialized) - return + if (!isAmplitudeEnabled || isAmplitudeInitialized) return isAmplitudeInitialized = true @@ -66,11 +65,12 @@ export const ensureAmplitudeInitialized = ({ }) amplitude.add(createPageNameEnrichmentPlugin()) - amplitude.add(sessionReplayPlugin({ - sampleRate: sessionReplaySampleRate, - })) - } - catch (error) { + amplitude.add( + sessionReplayPlugin({ + sampleRate: sessionReplaySampleRate, + }), + ) + } catch (error) { isAmplitudeInitialized = false throw error } diff --git a/web/app/components/base/amplitude/lazy-amplitude-provider.tsx b/web/app/components/base/amplitude/lazy-amplitude-provider.tsx index 5dfa0e7b539123..8449eae0c43421 100644 --- a/web/app/components/base/amplitude/lazy-amplitude-provider.tsx +++ b/web/app/components/base/amplitude/lazy-amplitude-provider.tsx @@ -6,6 +6,6 @@ import dynamic from '@/next/dynamic' const AmplitudeProvider = dynamic(() => import('./AmplitudeProvider'), { ssr: false }) -const LazyAmplitudeProvider: FC<IAmplitudeProps> = props => <AmplitudeProvider {...props} /> +const LazyAmplitudeProvider: FC<IAmplitudeProps> = (props) => <AmplitudeProvider {...props} /> export default LazyAmplitudeProvider diff --git a/web/app/components/base/amplitude/registration-tracking.ts b/web/app/components/base/amplitude/registration-tracking.ts index 19bc8fc5c23e80..8b9cff8baaf323 100644 --- a/web/app/components/base/amplitude/registration-tracking.ts +++ b/web/app/components/base/amplitude/registration-tracking.ts @@ -15,11 +15,9 @@ type PendingRegistrationSuccessEvent = { const getSessionStorage = (): Storage | null => { try { - if (typeof window === 'undefined') - return null + if (typeof window === 'undefined') return null return window.sessionStorage - } - catch { + } catch { return null } } @@ -33,12 +31,15 @@ const getSessionStorage = (): Storage | null => { * immediately records it under an anonymous profile. We persist the event here and * replay it once `setUserId` runs in the bootstrap effects after the redirect. */ -export const rememberRegistrationSuccess = ( - { method, utmInfo }: { method: RegistrationMethod, utmInfo?: Record<string, unknown> | null }, -) => { +export const rememberRegistrationSuccess = ({ + method, + utmInfo, +}: { + method: RegistrationMethod + utmInfo?: Record<string, unknown> | null +}) => { const storage = getSessionStorage() - if (!storage) - return + if (!storage) return const pending: PendingRegistrationSuccessEvent = { eventName: utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', @@ -47,8 +48,7 @@ export const rememberRegistrationSuccess = ( try { storage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, JSON.stringify(pending)) - } - catch {} + } catch {} } /** @@ -60,29 +60,23 @@ export const rememberRegistrationSuccess = ( */ export const flushRegistrationSuccess = () => { const storage = getSessionStorage() - if (!storage) - return + if (!storage) return let raw: string | null = null try { raw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY) - } - catch { + } catch { return } - if (!raw) - return + if (!raw) return try { storage.removeItem(REGISTRATION_SUCCESS_STORAGE_KEY) - } - catch {} + } catch {} try { const pending = JSON.parse(raw) as PendingRegistrationSuccessEvent - if (pending?.eventName) - trackEvent(pending.eventName, pending.properties) - } - catch {} + if (pending?.eventName) trackEvent(pending.eventName, pending.properties) + } catch {} } diff --git a/web/app/components/base/amplitude/utils.ts b/web/app/components/base/amplitude/utils.ts index 7aecf8c3cad1a4..c5d526d5de4d63 100644 --- a/web/app/components/base/amplitude/utils.ts +++ b/web/app/components/base/amplitude/utils.ts @@ -7,14 +7,12 @@ import { isAmplitudeEnabled } from '@/config' * @param eventProperties Event properties (optional) */ export const trackEvent = (eventName: string, eventProperties?: Record<string, unknown>) => { - if (!isAmplitudeEnabled) - return + if (!isAmplitudeEnabled) return return amplitude.track(eventName, eventProperties) } export const flushEvents = () => { - if (!isAmplitudeEnabled) - return + if (!isAmplitudeEnabled) return return amplitude.flush() } @@ -23,8 +21,7 @@ export const flushEvents = () => { * @param userId User ID */ export const setUserId = (userId: string) => { - if (!isAmplitudeEnabled) - return + if (!isAmplitudeEnabled) return amplitude.setUserId(userId) } @@ -32,9 +29,10 @@ export const setUserId = (userId: string) => { * Set user properties * @param properties User properties */ -export const setUserProperties = (properties: Record<string, amplitude.Types.ValidPropertyType>) => { - if (!isAmplitudeEnabled) - return +export const setUserProperties = ( + properties: Record<string, amplitude.Types.ValidPropertyType>, +) => { + if (!isAmplitudeEnabled) return const identifyEvent = new amplitude.Identify() Object.entries(properties).forEach(([key, value]) => { identifyEvent.set(key, value) @@ -46,7 +44,6 @@ export const setUserProperties = (properties: Record<string, amplitude.Types.Val * Reset user (e.g., when user logs out) */ export const resetUser = () => { - if (!isAmplitudeEnabled) - return + if (!isAmplitudeEnabled) return amplitude.reset() } diff --git a/web/app/components/base/answer-icon/index.stories.tsx b/web/app/components/base/answer-icon/index.stories.tsx index 110a11f77377fb..5a29fdc0d667b8 100644 --- a/web/app/components/base/answer-icon/index.stories.tsx +++ b/web/app/components/base/answer-icon/index.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { ReactNode } from 'react' import AnswerIcon from '.' -const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" rx="40" ry="40" fill="%23EEF2FF"/><text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-size="34" font-family="Arial" fill="%233256D4">AI</text></svg>' +const SAMPLE_IMAGE = + 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" rx="40" ry="40" fill="%23EEF2FF"/><text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-size="34" font-family="Arial" fill="%233256D4">AI</text></svg>' const meta = { title: 'Base/General/AnswerIcon', @@ -10,7 +11,8 @@ const meta = { parameters: { docs: { description: { - component: 'Circular avatar used for assistant answers. Supports emoji, solid background colour, or uploaded imagery.', + component: + 'Circular avatar used for assistant answers. Supports emoji, solid background colour, or uploaded imagery.', }, }, }, @@ -25,17 +27,16 @@ export default meta type Story = StoryObj<typeof meta> const StoryWrapper = (children: ReactNode) => ( - <div className="flex items-center gap-6"> - {children} - </div> + <div className="flex items-center gap-6">{children}</div> ) export const Default: Story = { - render: args => StoryWrapper( - <div className="size-16"> - <AnswerIcon {...args} /> - </div>, - ), + render: (args) => + StoryWrapper( + <div className="size-16"> + <AnswerIcon {...args} /> + </div>, + ), parameters: { docs: { source: { @@ -51,16 +52,17 @@ export const Default: Story = { } export const CustomEmoji: Story = { - render: args => StoryWrapper( - <> - <div className="size-16"> - <AnswerIcon {...args} icon="🧠" background="#FEE4E2" /> - </div> - <div className="size-16"> - <AnswerIcon {...args} icon="🛠️" background="#EEF2FF" /> - </div> - </>, - ), + render: (args) => + StoryWrapper( + <> + <div className="size-16"> + <AnswerIcon {...args} icon="🧠" background="#FEE4E2" /> + </div> + <div className="size-16"> + <AnswerIcon {...args} icon="🛠️" background="#EEF2FF" /> + </div> + </>, + ), parameters: { docs: { source: { @@ -81,16 +83,12 @@ export const CustomEmoji: Story = { } export const ImageIcon: Story = { - render: args => StoryWrapper( - <div className="size-16"> - <AnswerIcon - {...args} - iconType="image" - imageUrl={SAMPLE_IMAGE} - background={undefined} - /> - </div>, - ), + render: (args) => + StoryWrapper( + <div className="size-16"> + <AnswerIcon {...args} iconType="image" imageUrl={SAMPLE_IMAGE} background={undefined} /> + </div>, + ), parameters: { docs: { source: { diff --git a/web/app/components/base/answer-icon/index.tsx b/web/app/components/base/answer-icon/index.tsx index 58d41bbe509cc2..e68a837261face 100644 --- a/web/app/components/base/answer-icon/index.tsx +++ b/web/app/components/base/answer-icon/index.tsx @@ -15,22 +15,28 @@ type AnswerIconProps = { imageUrl?: string | null } -const AnswerIcon: FC<AnswerIconProps> = ({ - iconType, - icon, - background, - imageUrl, -}) => { - const wrapperClassName = cn('flex', 'items-center', 'justify-center', 'w-full', 'h-full', 'rounded-full', 'border-[0.5px]', 'border-black/5', 'text-xl') +const AnswerIcon: FC<AnswerIconProps> = ({ iconType, icon, background, imageUrl }) => { + const wrapperClassName = cn( + 'flex', + 'items-center', + 'justify-center', + 'w-full', + 'h-full', + 'rounded-full', + 'border-[0.5px]', + 'border-black/5', + 'text-xl', + ) const isValidImageIcon = iconType === 'image' && imageUrl return ( - <div - className={wrapperClassName} - style={{ background: background || '#D5F5F6' }} - > - {isValidImageIcon - ? <img src={imageUrl} className="size-full rounded-full" alt="answer icon" /> - : (icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id="🤖" />} + <div className={wrapperClassName} style={{ background: background || '#D5F5F6' }}> + {isValidImageIcon ? ( + <img src={imageUrl} className="size-full rounded-full" alt="answer icon" /> + ) : icon && icon !== '' ? ( + <em-emoji id={icon} /> + ) : ( + <em-emoji id="🤖" /> + )} </div> ) } diff --git a/web/app/components/base/app-icon-picker/ImageInput.tsx b/web/app/components/base/app-icon-picker/ImageInput.tsx index 59f36e6028ce2c..cff398888ad222 100644 --- a/web/app/components/base/app-icon-picker/ImageInput.tsx +++ b/web/app/components/base/app-icon-picker/ImageInput.tsx @@ -6,7 +6,6 @@ import { cn } from '@langgenius/dify-ui/cn' import { createRef, useEffect, useState } from 'react' import Cropper from 'react-easy-crop' import { useTranslation } from 'react-i18next' - import { ALLOW_FILE_EXTENSIONS } from '@/types/app' import { ImagePlus } from '../icons/src/vender/line/images' import { useDraggableUploader } from './hooks' @@ -23,18 +22,13 @@ type UploaderProps = { onImageInput?: OnImageInput } -const ImageInput: FC<UploaderProps> = ({ - className, - cropShape, - onImageInput, -}) => { +const ImageInput: FC<UploaderProps> = ({ className, cropShape, onImageInput }) => { const { t } = useTranslation() - const [inputImage, setInputImage] = useState<{ file: File, url: string }>() + const [inputImage, setInputImage] = useState<{ file: File; url: string }>() const [isAnimatedImage, setIsAnimatedImage] = useState<boolean>(false) useEffect(() => { return () => { - if (inputImage) - URL.revokeObjectURL(inputImage.url) + if (inputImage) URL.revokeObjectURL(inputImage.url) } }, [inputImage]) @@ -43,8 +37,7 @@ const ImageInput: FC<UploaderProps> = ({ const onCropComplete = async (_: Area, croppedAreaPixels: Area) => { /* v8 ignore next -- unreachable guard when Cropper is rendered @preserve */ - if (!inputImage) - return + if (!inputImage) return onImageInput?.(true, inputImage.url, croppedAreaPixels, inputImage.file.name) } @@ -54,28 +47,19 @@ const ImageInput: FC<UploaderProps> = ({ setInputImage({ file, url: URL.createObjectURL(file) }) checkIsAnimatedImage(file).then((isAnimatedImage) => { setIsAnimatedImage(!!isAnimatedImage) - if (isAnimatedImage) - onImageInput?.(false, file) + if (isAnimatedImage) onImageInput?.(false, file) }) } } - const { - isDragActive, - handleDragEnter, - handleDragOver, - handleDragLeave, - handleDrop, - } = useDraggableUploader((file: File) => setInputImage({ file, url: URL.createObjectURL(file) })) + const { isDragActive, handleDragEnter, handleDragOver, handleDragLeave, handleDrop } = + useDraggableUploader((file: File) => setInputImage({ file, url: URL.createObjectURL(file) })) const inputRef = createRef<HTMLInputElement>() const handleShowImage = () => { if (isAnimatedImage) { - return ( - - <img src={inputImage?.url} alt="" data-testid="animated-image" /> - ) + return <img src={inputImage?.url} alt="" data-testid="animated-image" /> } return ( @@ -95,38 +79,47 @@ const ImageInput: FC<UploaderProps> = ({ return ( <div className={cn(className, 'w-full px-3 py-1.5')}> <div - className={cn('relative flex aspect-square flex-col items-center justify-center rounded-lg border-[1.5px] border-dashed border-components-input-border-hover text-gray-500', isDragActive && 'border-primary-600')} + className={cn( + 'relative flex aspect-square flex-col items-center justify-center rounded-lg border-[1.5px] border-dashed border-components-input-border-hover text-gray-500', + isDragActive && 'border-primary-600', + )} onDragEnter={handleDragEnter} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} > - { - !inputImage - ? ( - <> - <ImagePlus className="pointer-events-none mb-3 h-[30px] w-[30px]" /> - <div className="mb-[2px] text-sm font-medium"> - <span className="pointer-events-none"> - {t($ => $['imageInput.dropImageHere'], { ns: 'common' })} -   - </span> - <button type="button" className="text-components-button-primary-bg" onClick={() => inputRef.current?.click()}>{t($ => $['imageInput.browse'], { ns: 'common' })}</button> - <input - ref={inputRef} - type="file" - className="hidden" - onClick={e => ((e.target as HTMLInputElement).value = '')} - accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')} - onChange={handleLocalFileInput} - data-testid="image-input" - /> - </div> - <div className="pointer-events-none">{t($ => $['imageInput.supportedFormats'], { ns: 'common' })}</div> - </> - ) - : handleShowImage() - } + {!inputImage ? ( + <> + <ImagePlus className="pointer-events-none mb-3 h-[30px] w-[30px]" /> + <div className="mb-[2px] text-sm font-medium"> + <span className="pointer-events-none"> + {t(($) => $['imageInput.dropImageHere'], { ns: 'common' })} +   + </span> + <button + type="button" + className="text-components-button-primary-bg" + onClick={() => inputRef.current?.click()} + > + {t(($) => $['imageInput.browse'], { ns: 'common' })} + </button> + <input + ref={inputRef} + type="file" + className="hidden" + onClick={(e) => ((e.target as HTMLInputElement).value = '')} + accept={ALLOW_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',')} + onChange={handleLocalFileInput} + data-testid="image-input" + /> + </div> + <div className="pointer-events-none"> + {t(($) => $['imageInput.supportedFormats'], { ns: 'common' })} + </div> + </> + ) : ( + handleShowImage() + )} </div> </div> ) diff --git a/web/app/components/base/app-icon-picker/__tests__/ImageInput.spec.tsx b/web/app/components/base/app-icon-picker/__tests__/ImageInput.spec.tsx index 19825b4a1cb486..a7cc4ccabca5a6 100644 --- a/web/app/components/base/app-icon-picker/__tests__/ImageInput.spec.tsx +++ b/web/app/components/base/app-icon-picker/__tests__/ImageInput.spec.tsx @@ -15,8 +15,7 @@ const waitForCropperContainer = async () => { const loadCropperImage = async () => { await waitForCropperContainer() const cropperImage = screen.getByTestId('container').querySelector('img') - if (!cropperImage) - throw new Error('Could not find cropper image') + if (!cropperImage) throw new Error('Could not find cropper image') fireEvent.load(cropperImage) } @@ -153,7 +152,9 @@ describe('ImageInput', () => { it('should apply active border class on drag enter', () => { render(<ImageInput />) - const dropZone = screen.getByText(/browse/i).closest('[class*="border-dashed"]') as HTMLElement + const dropZone = screen + .getByText(/browse/i) + .closest('[class*="border-dashed"]') as HTMLElement fireEvent.dragEnter(dropZone) expect(dropZone).toHaveClass('border-primary-600') @@ -162,7 +163,9 @@ describe('ImageInput', () => { it('should remove active border class on drag leave', () => { render(<ImageInput />) - const dropZone = screen.getByText(/browse/i).closest('[class*="border-dashed"]') as HTMLElement + const dropZone = screen + .getByText(/browse/i) + .closest('[class*="border-dashed"]') as HTMLElement fireEvent.dragEnter(dropZone) expect(dropZone).toHaveClass('border-primary-600') @@ -174,7 +177,9 @@ describe('ImageInput', () => { it('should show image after dropping a file', async () => { render(<ImageInput />) - const dropZone = screen.getByText(/browse/i).closest('[class*="border-dashed"]') as HTMLElement + const dropZone = screen + .getByText(/browse/i) + .closest('[class*="border-dashed"]') as HTMLElement const file = new File(['image-data'], 'dropped.png', { type: 'image/png' }) fireEvent.drop(dropZone, { diff --git a/web/app/components/base/app-icon-picker/__tests__/hooks.spec.tsx b/web/app/components/base/app-icon-picker/__tests__/hooks.spec.tsx index e2aa203d23aaef..ca9e22f4795c1c 100644 --- a/web/app/components/base/app-icon-picker/__tests__/hooks.spec.tsx +++ b/web/app/components/base/app-icon-picker/__tests__/hooks.spec.tsx @@ -5,12 +5,13 @@ type MockDragEventOverrides = { dataTransfer?: { files: File[] } } -const createDragEvent = (overrides: MockDragEventOverrides = {}): React.DragEvent<HTMLDivElement> => ({ - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - dataTransfer: { files: [] as unknown as FileList }, - ...overrides, -} as unknown as React.DragEvent<HTMLDivElement>) +const createDragEvent = (overrides: MockDragEventOverrides = {}): React.DragEvent<HTMLDivElement> => + ({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { files: [] as unknown as FileList }, + ...overrides, + }) as unknown as React.DragEvent<HTMLDivElement> describe('useDraggableUploader', () => { let setImageFn: ReturnType<typeof vi.fn<(file: File) => void>> diff --git a/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx b/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx index 3f6f205cfb2d40..4b627d5dc4a553 100644 --- a/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx @@ -19,18 +19,17 @@ class MockLoadedImage { private listeners: Record<string, EventListener[]> = {} addEventListener(type: string, listener: EventListenerOrEventListenerObject) { - const eventListener = typeof listener === 'function' ? listener : listener.handleEvent.bind(listener) - if (!this.listeners[type]) - this.listeners[type] = [] + const eventListener = + typeof listener === 'function' ? listener : listener.handleEvent.bind(listener) + if (!this.listeners[type]) this.listeners[type] = [] this.listeners[type].push(eventListener) } - setAttribute(_name: string, _value: string) { } + setAttribute(_name: string, _value: string) {} set src(_value: string) { queueMicrotask(() => { - for (const listener of this.listeners.load ?? []) - listener(new Event('load')) + for (const listener of this.listeners.load ?? []) listener(new Event('load')) }) } @@ -56,7 +55,10 @@ const createCanvasContextMock = (): CanvasRenderingContext2D => drawImage: vi.fn(), }) as unknown as CanvasRenderingContext2D -const createCanvasElementMock = (context: CanvasRenderingContext2D | null, blob: Blob | null = new Blob(['ok'], { type: 'image/png' })) => +const createCanvasElementMock = ( + context: CanvasRenderingContext2D | null, + blob: Blob | null = new Blob(['ok'], { type: 'image/png' }), +) => ({ width: 0, height: 0, @@ -78,15 +80,21 @@ vi.mock('@/config', () => ({ })) vi.mock('react-easy-crop', () => ({ - default: ({ onCropComplete }: { onCropComplete: (_area: Area, croppedAreaPixels: Area) => void }) => ( + default: ({ + onCropComplete, + }: { + onCropComplete: (_area: Area, croppedAreaPixels: Area) => void + }) => ( <div data-testid="mock-cropper"> <button type="button" data-testid="trigger-crop" - onClick={() => onCropComplete( - { x: 0, y: 0, width: 100, height: 100 }, - { x: 0, y: 0, width: 100, height: 100 }, - )} + onClick={() => + onCropComplete( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 0, y: 0, width: 100, height: 100 }, + ) + } > Trigger Crop </button> @@ -112,22 +120,25 @@ describe('AppIconPicker', () => { let originalImage: typeof Image const mockCanvasCreation = (canvases: HTMLCanvasElement[]) => { - vi.spyOn(document, 'createElement').mockImplementation((...args: Parameters<Document['createElement']>) => { - if (args[0] === 'canvas') { - const nextCanvas = canvases.shift() - if (!nextCanvas) - throw new Error('Unexpected canvas creation') - return nextCanvas as ReturnType<Document['createElement']> - } - return originalCreateElement(...args) - }) + vi.spyOn(document, 'createElement').mockImplementation( + (...args: Parameters<Document['createElement']>) => { + if (args[0] === 'canvas') { + const nextCanvas = canvases.shift() + if (!nextCanvas) throw new Error('Unexpected canvas creation') + return nextCanvas as ReturnType<Document['createElement']> + } + return originalCreateElement(...args) + }, + ) } const renderPicker = (props: Partial<ComponentProps<typeof AppIconPicker>> = {}) => { const onSelect = vi.fn() const onOpenChange = vi.fn() - const { container } = render(<AppIconPicker open onOpenChange={onOpenChange} onSelect={onSelect} {...props} />) + const { container } = render( + <AppIconPicker open onOpenChange={onOpenChange} onSelect={onSelect} {...props} />, + ) return { onSelect, onOpenChange, container } } @@ -138,8 +149,7 @@ describe('AppIconPicker', () => { mocks.uploadResult = createImageFile() mocks.onUpload = null mocks.handleLocalFileUpload.mockImplementation(() => { - if (mocks.uploadResult) - mocks.onUpload?.(mocks.uploadResult) + if (mocks.uploadResult) mocks.onUpload?.(mocks.uploadResult) }) originalImage = globalThis.Image @@ -200,18 +210,19 @@ describe('AppIconPicker', () => { }) const firstEmoji = document.querySelector('em-emoji')?.closest('button') - if (!firstEmoji) - throw new Error('Could not find emoji option') + if (!firstEmoji) throw new Error('Could not find emoji option') await userEvent.click(firstEmoji) await userEvent.click(screen.getByText(/ok/i)) await waitFor(() => { - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ - type: 'emoji', - icon: expect.any(String), - background: expect.any(String), - })) + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emoji', + icon: expect.any(String), + background: expect.any(String), + }), + ) }) }) @@ -269,10 +280,11 @@ describe('AppIconPicker', () => { await userEvent.click(screen.getByText(/image/i)) const input = screen.queryByTestId('image-input') - if (!input) - throw new Error('Could not find image input') + if (!input) throw new Error('Could not find image input') - fireEvent.change(input, { target: { files: [new File(['png'], 'avatar.png', { type: 'image/png' })] } }) + fireEvent.change(input, { + target: { files: [new File(['png'], 'avatar.png', { type: 'image/png' })] }, + }) await waitFor(() => { expect(screen.getByTestId('mock-cropper'))!.toBeInTheDocument() @@ -307,8 +319,7 @@ describe('AppIconPicker', () => { const gifFile = new File([gifBytes], 'animated.gif', { type: 'image/gif' }) const input = screen.queryByTestId('image-input') - if (!input) - throw new Error('Could not find image input') + if (!input) throw new Error('Could not find image input') fireEvent.change(input, { target: { files: [gifFile] } }) @@ -343,8 +354,7 @@ describe('AppIconPicker', () => { const gifFile = new File([gifBytes], 'no-file-id.gif', { type: 'image/gif' }) const input = screen.queryByTestId('image-input') - if (!input) - throw new Error('Could not find image input') + if (!input) throw new Error('Could not find image input') fireEvent.change(input, { target: { files: [gifFile] } }) diff --git a/web/app/components/base/app-icon-picker/__tests__/utils.spec.ts b/web/app/components/base/app-icon-picker/__tests__/utils.spec.ts index 6b706417cf783f..55dd2f5db2b1ab 100644 --- a/web/app/components/base/app-icon-picker/__tests__/utils.spec.ts +++ b/web/app/components/base/app-icon-picker/__tests__/utils.spec.ts @@ -1,4 +1,10 @@ -import getCroppedImg, { checkIsAnimatedImage, createImage, getMimeType, getRadianAngle, rotateSize } from '../utils' +import getCroppedImg, { + checkIsAnimatedImage, + createImage, + getMimeType, + getRadianAngle, + rotateSize, +} from '../utils' type ImageLoadEventType = 'load' | 'error' @@ -11,23 +17,21 @@ class MockImageElement { private listeners: Record<string, EventListener[]> = {} addEventListener(type: string, listener: EventListenerOrEventListenerObject) { - const eventListener = typeof listener === 'function' ? listener : listener.handleEvent.bind(listener) - if (!this.listeners[type]) - this.listeners[type] = [] + const eventListener = + typeof listener === 'function' ? listener : listener.handleEvent.bind(listener) + if (!this.listeners[type]) this.listeners[type] = [] this.listeners[type].push(eventListener) } setAttribute(name: string, value: string) { - if (name === 'crossOrigin') - this.crossOriginValue = value + if (name === 'crossOrigin') this.crossOriginValue = value } set src(value: string) { this.srcValue = value queueMicrotask(() => { const event = new Event(MockImageElement.nextEvent) - for (const listener of this.listeners[MockImageElement.nextEvent] ?? []) - listener(event) + for (const listener of this.listeners[MockImageElement.nextEvent] ?? []) listener(event) }) } @@ -42,7 +46,10 @@ type CanvasMock = { toBlobMock: ReturnType<typeof vi.fn> } -const createCanvasMock = (context: CanvasRenderingContext2D | null, blob: Blob | null = new Blob(['ok'])): CanvasMock => { +const createCanvasMock = ( + context: CanvasRenderingContext2D | null, + blob: Blob | null = new Blob(['ok']), +): CanvasMock => { const getContextMock = vi.fn(() => context) const toBlobMock = vi.fn((callback: BlobCallback) => callback(blob)) return { @@ -81,15 +88,16 @@ describe('utils', () => { }) const mockCanvasCreation = (canvases: HTMLCanvasElement[]) => { - vi.spyOn(document, 'createElement').mockImplementation((...args: Parameters<Document['createElement']>) => { - if (args[0] === 'canvas') { - const nextCanvas = canvases.shift() - if (!nextCanvas) - throw new Error('Unexpected canvas creation') - return nextCanvas as ReturnType<Document['createElement']> - } - return originalCreateElement(...args) - }) + vi.spyOn(document, 'createElement').mockImplementation( + (...args: Parameters<Document['createElement']>) => { + if (args[0] === 'canvas') { + const nextCanvas = canvases.shift() + if (!nextCanvas) throw new Error('Unexpected canvas creation') + return nextCanvas as ReturnType<Document['createElement']> + } + return originalCreateElement(...args) + }, + ) } describe('createImage', () => { @@ -249,7 +257,11 @@ describe('utils', () => { mockCanvasCreation([sourceCanvas.element]) await expect( - getCroppedImg('https://example.com/image.png', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.png'), + getCroppedImg( + 'https://example.com/image.png', + { x: 0, y: 0, width: 10, height: 10 }, + 'avatar.png', + ), ).rejects.toThrow('Could not create a canvas context') }) @@ -261,7 +273,11 @@ describe('utils', () => { mockCanvasCreation([sourceCanvas.element, croppedCanvas.element]) await expect( - getCroppedImg('https://example.com/image.png', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.png'), + getCroppedImg( + 'https://example.com/image.png', + { x: 0, y: 0, width: 10, height: 10 }, + 'avatar.png', + ), ).rejects.toThrow('Could not create a canvas context') }) @@ -273,7 +289,11 @@ describe('utils', () => { mockCanvasCreation([sourceCanvas.element, croppedCanvas.element]) await expect( - getCroppedImg('https://example.com/image.jpg', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.jpg'), + getCroppedImg( + 'https://example.com/image.jpg', + { x: 0, y: 0, width: 10, height: 10 }, + 'avatar.jpg', + ), ).rejects.toThrow('Could not create a blob') }) }) @@ -288,13 +308,17 @@ describe('utils', () => { globalThis.FileReader = originalFileReader }) it('should return true for .gif files', async () => { - const gifFile = new File([new Uint8Array([0x47, 0x49, 0x46])], 'animation.gif', { type: 'image/gif' }) + const gifFile = new File([new Uint8Array([0x47, 0x49, 0x46])], 'animation.gif', { + type: 'image/gif', + }) const result = await checkIsAnimatedImage(gifFile) expect(result).toBe(true) }) it('should return false for non-gif, non-webp files', async () => { - const pngFile = new File([new Uint8Array([0x89, 0x50, 0x4E, 0x47])], 'image.png', { type: 'image/png' }) + const pngFile = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], 'image.png', { + type: 'image/png', + }) const result = await checkIsAnimatedImage(pngFile) expect(result).toBe(false) }) @@ -315,9 +339,9 @@ describe('utils', () => { bytes[11] = 0x50 // P // ANIM chunk at offset 12 bytes[12] = 0x41 // A - bytes[13] = 0x4E // N + bytes[13] = 0x4e // N bytes[14] = 0x49 // I - bytes[15] = 0x4D // M + bytes[15] = 0x4d // M const webpFile = new File([bytes], 'animated.webp', { type: 'image/webp' }) const result = await checkIsAnimatedImage(webpFile) diff --git a/web/app/components/base/app-icon-picker/hooks.tsx b/web/app/components/base/app-icon-picker/hooks.tsx index b3f67c0dcae930..5fb7656c949ec5 100644 --- a/web/app/components/base/app-icon-picker/hooks.tsx +++ b/web/app/components/base/app-icon-picker/hooks.tsx @@ -20,18 +20,20 @@ export const useDraggableUploader = <T extends HTMLElement>(setImageFn: (file: F setIsDragActive(false) }, []) - const handleDrop = useCallback((e: React.DragEvent<T>) => { - e.preventDefault() - e.stopPropagation() - setIsDragActive(false) + const handleDrop = useCallback( + (e: React.DragEvent<T>) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(false) - const file = e.dataTransfer.files[0] + const file = e.dataTransfer.files[0] - if (!file) - return + if (!file) return - setImageFn(file) - }, [setImageFn]) + setImageFn(file) + }, + [setImageFn], + ) return { handleDragEnter, diff --git a/web/app/components/base/app-icon-picker/index.stories.tsx b/web/app/components/base/app-icon-picker/index.stories.tsx index 465428aa8a1a63..21f87fb3cec758 100644 --- a/web/app/components/base/app-icon-picker/index.stories.tsx +++ b/web/app/components/base/app-icon-picker/index.stories.tsx @@ -10,7 +10,8 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Modal workflow for choosing an application avatar. Users can switch between emoji selections and image uploads (when enabled).', + component: + 'Modal workflow for choosing an application avatar. Users can switch between emoji selections and image uploads (when enabled).', }, }, nextjs: { @@ -48,11 +49,7 @@ const AppIconPickerDemo = () => { </pre> </div> - <AppIconPicker - open={open} - onOpenChange={setOpen} - onSelect={setSelection} - /> + <AppIconPicker open={open} onOpenChange={setOpen} onSelect={setSelection} /> </div> ) } diff --git a/web/app/components/base/app-icon-picker/index.tsx b/web/app/components/base/app-icon-picker/index.tsx index 169a13a6276e17..d84ec468f8f8a8 100644 --- a/web/app/components/base/app-icon-picker/index.tsx +++ b/web/app/components/base/app-icon-picker/index.tsx @@ -52,18 +52,16 @@ function AppIconPicker({ }: AppIconPickerProps) { return ( <Dialog open={open} onOpenChange={onOpenChange}> - {open - ? ( - <AppIconPickerContent - key={`${initialEmoji?.icon ?? ''}:${initialEmoji?.background ?? ''}`} - initialEmoji={initialEmoji} - enableImageUpload={enableImageUpload} - className={className} - onOpenChange={onOpenChange} - onSelect={onSelect} - /> - ) - : null} + {open ? ( + <AppIconPickerContent + key={`${initialEmoji?.icon ?? ''}:${initialEmoji?.background ?? ''}`} + initialEmoji={initialEmoji} + enableImageUpload={enableImageUpload} + className={className} + onOpenChange={onOpenChange} + onSelect={onSelect} + /> + ) : null} </Dialog> ) } @@ -89,15 +87,22 @@ function AppIconPickerContent({ const { t } = useTranslation() const tabs = [ - { key: 'emoji', label: t($ => $['iconPicker.emoji'], { ns: 'app' }), icon: <span className="text-lg">🤖</span> }, - { key: 'image', label: t($ => $['iconPicker.image'], { ns: 'app' }), icon: <RiImageCircleAiLine className="size-4" /> }, + { + key: 'emoji', + label: t(($) => $['iconPicker.emoji'], { ns: 'app' }), + icon: <span className="text-lg">🤖</span>, + }, + { + key: 'image', + label: t(($) => $['iconPicker.image'], { ns: 'app' }), + icon: <RiImageCircleAiLine className="size-4" />, + }, ] const [activeTab, setActiveTab] = useState<AppIconType>('emoji') const showImageUpload = enableImageUpload && !DISABLE_UPLOAD_IMAGE_AS_ICON - const [emoji, setEmoji] = useState<{ emoji: string, background: string } | undefined>(() => { - if (!initialEmoji?.icon) - return undefined + const [emoji, setEmoji] = useState<{ emoji: string; background: string } | undefined>(() => { + if (!initialEmoji?.icon) return undefined return { emoji: initialEmoji.icon, @@ -123,13 +128,24 @@ function AppIconPickerContent({ }, }) - type InputImageInfo = { file: File } | { tempUrl: string, croppedAreaPixels: Area, fileName: string } + type InputImageInfo = + | { file: File } + | { tempUrl: string; croppedAreaPixels: Area; fileName: string } const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>() - const handleImageInput: OnImageInput = async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => { + const handleImageInput: OnImageInput = async ( + isCropped: boolean, + fileOrTempUrl: string | File, + croppedAreaPixels?: Area, + fileName?: string, + ) => { setInputImageInfo( isCropped - ? { tempUrl: fileOrTempUrl as string, croppedAreaPixels: croppedAreaPixels!, fileName: fileName! } + ? { + tempUrl: fileOrTempUrl as string, + croppedAreaPixels: croppedAreaPixels!, + fileName: fileName!, + } : { file: fileOrTempUrl as File }, ) } @@ -144,43 +160,51 @@ function AppIconPickerContent({ }) onOpenChange(false) } - } - else { - if (!inputImageInfo) - return + } else { + if (!inputImageInfo) return setUploading(true) if ('file' in inputImageInfo) { handleLocalFileUpload(inputImageInfo.file) return } - const blob = await getCroppedImg(inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName) + const blob = await getCroppedImg( + inputImageInfo.tempUrl, + inputImageInfo.croppedAreaPixels, + inputImageInfo.fileName, + ) const file = new File([blob], inputImageInfo.fileName, { type: blob.type }) handleLocalFileUpload(file) } } return ( - <DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', s.container, 'h-[min(462px,calc(100dvh-2rem))]! max-h-none! w-[362px]! p-0!', className)}> + <DialogContent + className={cn( + 'w-full overflow-hidden! border-none text-left align-middle', + s.container, + 'h-[min(462px,calc(100dvh-2rem))]! max-h-none! w-[362px]! p-0!', + className, + )} + > <DialogTitle className="sr-only"> - {t($ => $['iconPicker.emoji'], { ns: 'app' })} + {t(($) => $['iconPicker.emoji'], { ns: 'app' })} </DialogTitle> {showImageUpload && ( <div className="w-full p-2 pb-0"> <div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary"> - {tabs.map(tab => ( + {tabs.map((tab) => ( <button type="button" key={tab.key} className={cn( 'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary', - activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md', + activeTab === tab.key && + 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md', )} onClick={() => setActiveTab(tab.key as AppIconType)} > - {tab.icon} - {' '} -  + {tab.icon}   {tab.label} </button> ))} @@ -196,16 +220,24 @@ function AppIconPickerContent({ onSelect={(emoji, background) => setEmoji({ emoji, background })} /> )} - {activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />} + {activeTab === 'image' && ( + <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} /> + )} <Divider className="m-0" /> <div className="flex w-full items-center justify-center gap-2 p-3"> <Button className="w-full" onClick={() => onOpenChange(false)}> - {t($ => $['iconPicker.cancel'], { ns: 'app' })} + {t(($) => $['iconPicker.cancel'], { ns: 'app' })} </Button> - <Button variant="primary" className="w-full" disabled={uploading} loading={uploading} onClick={handleSelect}> - {t($ => $['iconPicker.ok'], { ns: 'app' })} + <Button + variant="primary" + className="w-full" + disabled={uploading} + loading={uploading} + onClick={handleSelect} + > + {t(($) => $['iconPicker.ok'], { ns: 'app' })} </Button> </div> </DialogContent> diff --git a/web/app/components/base/app-icon-picker/style.module.css b/web/app/components/base/app-icon-picker/style.module.css index 5ec199a23257c8..e995275846e2fe 100644 --- a/web/app/components/base/app-icon-picker/style.module.css +++ b/web/app/components/base/app-icon-picker/style.module.css @@ -1,9 +1,11 @@ .container { - display: flex; - flex-direction: column; - align-items: flex-start; - width: 362px; - max-height: 552px; - box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03); - border-radius: 12px; + display: flex; + flex-direction: column; + align-items: flex-start; + width: 362px; + max-height: 552px; + box-shadow: + 0px 12px 16px -4px rgba(16, 24, 40, 0.08), + 0px 4px 6px -2px rgba(16, 24, 40, 0.03); + border-radius: 12px; } diff --git a/web/app/components/base/app-icon-picker/utils.ts b/web/app/components/base/app-icon-picker/utils.ts index 99c2f3e9e352da..2819c6a741733f 100644 --- a/web/app/components/base/app-icon-picker/utils.ts +++ b/web/app/components/base/app-icon-picker/utils.ts @@ -2,7 +2,7 @@ export const createImage = (url: string) => new Promise<HTMLImageElement>((resolve, reject) => { const image = new Image() image.addEventListener('load', () => resolve(image)) - image.addEventListener('error', error => reject(error)) + image.addEventListener('error', (error) => reject(error)) image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox image.src = url }) @@ -35,10 +35,8 @@ export function rotateSize(width: number, height: number, rotation: number) { const rotRad = getRadianAngle(rotation) return { - width: - Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), - height: - Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), + width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), + height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), } } @@ -47,7 +45,7 @@ export function rotateSize(width: number, height: number, rotation: number) { */ export default async function getCroppedImg( imageSrc: string, - pixelCrop: { x: number, y: number, width: number, height: number }, + pixelCrop: { x: number; y: number; width: number; height: number }, fileName: string, rotation = 0, flip = { horizontal: false, vertical: false }, @@ -57,17 +55,12 @@ export default async function getCroppedImg( const ctx = canvas.getContext('2d') const mimeType = getMimeType(fileName) - if (!ctx) - throw new Error('Could not create a canvas context') + if (!ctx) throw new Error('Could not create a canvas context') const rotRad = getRadianAngle(rotation) // calculate bounding box of the rotated image - const { width: bBoxWidth, height: bBoxHeight } = rotateSize( - image.width, - image.height, - rotation, - ) + const { width: bBoxWidth, height: bBoxHeight } = rotateSize(image.width, image.height, rotation) // set canvas size to match the bounding box canvas.width = bBoxWidth @@ -86,8 +79,7 @@ export default async function getCroppedImg( const croppedCtx = croppedCanvas.getContext('2d') - if (!croppedCtx) - throw new Error('Could not create a canvas context') + if (!croppedCtx) throw new Error('Could not create a canvas context') // Set the size of the cropped canvas croppedCanvas.width = pixelCrop.width @@ -108,10 +100,8 @@ export default async function getCroppedImg( return new Promise((resolve, reject) => { croppedCanvas.toBlob((file) => { - if (file) - resolve(file) - else - reject(new Error('Could not create a blob')) + if (file) resolve(file) + else reject(new Error('Could not create a blob')) }, mimeType) }) } @@ -132,8 +122,7 @@ export function checkIsAnimatedImage(file: File): Promise<boolean> { // Check for WebP signature (RIFF and WEBP) else if (isWebP(arr)) { resolve(checkWebPAnimation(arr)) // Check if it's animated - } - else { + } else { resolve(false) // Not a GIF or WebP } } @@ -150,8 +139,14 @@ export function checkIsAnimatedImage(file: File): Promise<boolean> { // Function to check for WebP signature function isWebP(arr: Uint8Array) { return ( - arr[0] === 0x52 && arr[1] === 0x49 && arr[2] === 0x46 && arr[3] === 0x46 - && arr[8] === 0x57 && arr[9] === 0x45 && arr[10] === 0x42 && arr[11] === 0x50 + arr[0] === 0x52 && + arr[1] === 0x49 && + arr[2] === 0x46 && + arr[3] === 0x46 && + arr[8] === 0x57 && + arr[9] === 0x45 && + arr[10] === 0x42 && + arr[11] === 0x50 ) // "WEBP" } @@ -159,7 +154,7 @@ function isWebP(arr: Uint8Array) { function checkWebPAnimation(arr: Uint8Array) { // Search for the ANIM chunk in WebP to determine if it's animated for (let i = 12; i < arr.length - 4; i++) { - if (arr[i] === 0x41 && arr[i + 1] === 0x4E && arr[i + 2] === 0x49 && arr[i + 3] === 0x4D) + if (arr[i] === 0x41 && arr[i + 1] === 0x4e && arr[i + 2] === 0x49 && arr[i + 3] === 0x4d) return true // Found animation } return false // No animation chunk found diff --git a/web/app/components/base/app-icon/__tests__/index.spec.tsx b/web/app/components/base/app-icon/__tests__/index.spec.tsx index 84d7b039abec1d..ad7790f4114822 100644 --- a/web/app/components/base/app-icon/__tests__/index.spec.tsx +++ b/web/app/components/base/app-icon/__tests__/index.spec.tsx @@ -21,15 +21,18 @@ describe('AppIcon', () => { beforeEach(() => { // Mock custom element if (!customElements.get('em-emoji')) { - customElements.define('em-emoji', class extends HTMLElement { - constructor() { - super() - } - - connectedCallback() { - this.innerHTML = this.getAttribute('id') || '🤖' - } - }) + customElements.define( + 'em-emoji', + class extends HTMLElement { + constructor() { + super() + } + + connectedCallback() { + this.innerHTML = this.getAttribute('id') || '🤖' + } + }, + ) } // Reset mock hover value @@ -111,7 +114,9 @@ describe('AppIcon', () => { }) it('does not apply background style for image icons', () => { - const { container } = render(<AppIcon iconType="image" imageUrl="test.jpg" background="#FF5500" />) + const { container } = render( + <AppIcon iconType="image" imageUrl="test.jpg" background="#FF5500" />, + ) // Should not have the background style from the prop expect(container.firstChild).not.toHaveStyle('background: #FF5500') }) @@ -154,9 +159,7 @@ describe('AppIcon', () => { it('handles conditional isValidImageIcon check correctly', () => { // Case 1: Valid image icon - const { rerender } = render( - <AppIcon iconType="image" imageUrl="test.jpg" />, - ) + const { rerender } = render(<AppIcon iconType="image" imageUrl="test.jpg" />) expect(screen.getByAltText('app icon')).toBeInTheDocument() // Case 2: Invalid - missing image URL diff --git a/web/app/components/base/app-icon/index.stories.tsx b/web/app/components/base/app-icon/index.stories.tsx index 2d7209ba2f9d26..f4b16e9ad76e22 100644 --- a/web/app/components/base/app-icon/index.stories.tsx +++ b/web/app/components/base/app-icon/index.stories.tsx @@ -8,7 +8,8 @@ const meta = { parameters: { docs: { description: { - component: 'Reusable avatar for applications and workflows. Supports emoji or uploaded imagery, rounded-sm mode, edit overlays, and multiple sizes.', + component: + 'Reusable avatar for applications and workflows. Supports emoji or uploaded imagery, rounded-sm mode, edit overlays, and multiple sizes.', }, }, }, @@ -25,7 +26,7 @@ export default meta type Story = StoryObj<typeof meta> export const Default: Story = { - render: args => ( + render: (args) => ( <div className="flex items-center gap-4"> <AppIcon {...args} /> <AppIcon {...args} rounded icon="🧠" background="#E0F2FE" /> @@ -46,10 +47,18 @@ export const Default: Story = { export const Sizes: Story = { render: (args) => { - const sizes: Array<ComponentProps<typeof AppIcon>['size']> = ['xs', 'tiny', 'small', 'medium', 'large', 'xl', 'xxl'] + const sizes: Array<ComponentProps<typeof AppIcon>['size']> = [ + 'xs', + 'tiny', + 'small', + 'medium', + 'large', + 'xl', + 'xxl', + ] return ( <div className="flex flex-wrap items-end gap-4"> - {sizes.map(size => ( + {sizes.map((size) => ( <div key={size} className="flex flex-col items-center gap-2"> <AppIcon {...args} size={size} icon="🚀" background="#E5DEFF" /> <span className="text-xs text-text-tertiary uppercase">{size}</span> @@ -73,14 +82,9 @@ export const Sizes: Story = { } export const WithEditOverlay: Story = { - render: args => ( + render: (args) => ( <div className="flex items-center gap-4"> - <AppIcon - {...args} - icon="🛠️" - background="#E7F5FF" - showEditIcon - /> + <AppIcon {...args} icon="🛠️" background="#E7F5FF" showEditIcon /> <AppIcon {...args} iconType="image" diff --git a/web/app/components/base/app-icon/index.tsx b/web/app/components/base/app-icon/index.tsx index fa83560c0cef5e..048c9ad183e048 100644 --- a/web/app/components/base/app-icon/index.tsx +++ b/web/app/components/base/app-icon/index.tsx @@ -14,11 +14,12 @@ init({ data }) const subscribeHydrationState = () => () => {} -const useIsHydrated = () => useSyncExternalStore( - subscribeHydrationState, - () => true, - () => false, -) +const useIsHydrated = () => + useSyncExternalStore( + subscribeHydrationState, + () => true, + () => false, + ) type AppIconProps = { size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large' | 'xl' | 'xxl' @@ -79,25 +80,22 @@ const EditIconWrapperVariants = cva( }, }, ) -const EditIconVariants = cva( - 'text-text-primary-on-surface', - { - variants: { - size: { - xs: 'size-3', - tiny: 'size-3.5', - small: 'size-5', - medium: 'size-[22px]', - large: 'size-6', - xl: 'size-7', - xxl: 'size-8', - }, - }, - defaultVariants: { - size: 'medium', +const EditIconVariants = cva('text-text-primary-on-surface', { + variants: { + size: { + xs: 'size-3', + tiny: 'size-3.5', + small: 'size-5', + medium: 'size-[22px]', + large: 'size-6', + xl: 'size-7', + xxl: 'size-8', }, }, -) + defaultVariants: { + size: 'medium', + }, +}) const AppIcon: FC<AppIconProps> = ({ size = 'medium', rounded = false, @@ -112,17 +110,15 @@ const AppIcon: FC<AppIconProps> = ({ showEditIcon = false, }) => { const isValidImageIcon = iconType === 'image' && imageUrl - const emojiIcon = (icon && icon !== '') ? icon : '🤖' + const emojiIcon = icon && icon !== '' ? icon : '🤖' const isHydrated = useIsHydrated() const Icon = isHydrated ? <em-emoji key={emojiIcon} id={emojiIcon} /> : emojiIcon const wrapperRef = useRef<HTMLSpanElement>(null) const isHovering = useHover(wrapperRef) const handleKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => { - if (!onClick) - return + if (!onClick) return - if (event.key !== 'Enter' && event.key !== ' ') - return + if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() onClick() @@ -132,24 +128,22 @@ const AppIcon: FC<AppIconProps> = ({ <span ref={wrapperRef} className={cn(appIconVariants({ size, rounded }), className)} - style={{ background: isValidImageIcon ? undefined : (background || '#FFEAD5') }} + style={{ background: isValidImageIcon ? undefined : background || '#FFEAD5' }} onClick={onClick} onKeyDown={onClick ? handleKeyDown : undefined} role={onClick ? 'button' : undefined} tabIndex={onClick ? 0 : undefined} > - { - isValidImageIcon - ? <img src={imageUrl} className="size-full" alt="app icon" /> - : (innerIcon || Icon) - } - { - showEditIcon && isHovering && ( - <div className={EditIconWrapperVariants({ size, rounded })}> - <RiEditLine className={EditIconVariants({ size })} /> - </div> - ) - } + {isValidImageIcon ? ( + <img src={imageUrl} className="size-full" alt="app icon" /> + ) : ( + innerIcon || Icon + )} + {showEditIcon && isHovering && ( + <div className={EditIconWrapperVariants({ size, rounded })}> + <RiEditLine className={EditIconVariants({ size })} /> + </div> + )} {coverElement} </span> ) diff --git a/web/app/components/base/app-unavailable.tsx b/web/app/components/base/app-unavailable.tsx index e063aa9fb185b8..539a23a191b00f 100644 --- a/web/app/components/base/app-unavailable.tsx +++ b/web/app/components/base/app-unavailable.tsx @@ -29,7 +29,12 @@ const AppUnavailable: FC<IAppUnavailableProps> = ({ > {code} </h1> - <div className="text-sm">{unknownReason || (isUnknownReason ? t($ => $['common.appUnknownError'], { ns: 'share' }) : t($ => $['common.appUnavailable'], { ns: 'share' }))}</div> + <div className="text-sm"> + {unknownReason || + (isUnknownReason + ? t(($) => $['common.appUnknownError'], { ns: 'share' }) + : t(($) => $['common.appUnavailable'], { ns: 'share' }))} + </div> </div> ) } diff --git a/web/app/components/base/audio-btn/__tests__/audio.player.manager.spec.ts b/web/app/components/base/audio-btn/__tests__/audio.player.manager.spec.ts index 4a98524ed2d3ad..a93e7e50383647 100644 --- a/web/app/components/base/audio-btn/__tests__/audio.player.manager.spec.ts +++ b/web/app/components/base/audio-btn/__tests__/audio.player.manager.spec.ts @@ -15,9 +15,11 @@ type MockAudioPlayerInstance = { pauseAudio: ReturnType<typeof vi.fn> resetMsgId: ReturnType<typeof vi.fn> cacheBuffers: Array<ArrayBuffer> - sourceBuffer: { - abort: ReturnType<typeof vi.fn> - } | undefined + sourceBuffer: + | { + abort: ReturnType<typeof vi.fn> + } + | undefined } const mockState = vi.hoisted(() => ({ @@ -66,7 +68,14 @@ describe('AudioPlayerManager', () => { const manager = AudioPlayerManager.getInstance() const callback = vi.fn() - const result = manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', callback) + const result = manager.getAudioPlayer( + '/text-to-audio', + false, + 'msg-1', + 'hello', + 'en-US', + callback, + ) expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(1) expect(mockAudioPlayerConstructor).toHaveBeenCalledWith( @@ -85,8 +94,22 @@ describe('AudioPlayerManager', () => { const firstCallback = vi.fn() const secondCallback = vi.fn() - const first = manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', firstCallback) - const second = manager.getAudioPlayer('/ignored', true, 'msg-1', 'ignored', 'fr-FR', secondCallback) + const first = manager.getAudioPlayer( + '/text-to-audio', + false, + 'msg-1', + 'hello', + 'en-US', + firstCallback, + ) + const second = manager.getAudioPlayer( + '/ignored', + true, + 'msg-1', + 'ignored', + 'fr-FR', + secondCallback, + ) expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(1) expect(first).toBe(second) @@ -100,7 +123,14 @@ describe('AudioPlayerManager', () => { manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', callback) const previous = mockState.instances[0] - const next = manager.getAudioPlayer('/apps/1/text-to-audio', false, 'msg-2', 'world', 'en-US', callback) + const next = manager.getAudioPlayer( + '/apps/1/text-to-audio', + false, + 'msg-2', + 'world', + 'en-US', + callback, + ) expect(previous!.pauseAudio).toHaveBeenCalledTimes(1) expect(previous!.cacheBuffers).toEqual([]) diff --git a/web/app/components/base/audio-btn/__tests__/audio.spec.ts b/web/app/components/base/audio-btn/__tests__/audio.spec.ts index 85ce6763afafbe..95cd0ef97c99aa 100644 --- a/web/app/components/base/audio-btn/__tests__/audio.spec.ts +++ b/web/app/components/base/audio-btn/__tests__/audio.spec.ts @@ -20,7 +20,16 @@ vi.mock('@/service/share', () => ({ textToAudioStream: (...args: unknown[]) => mockTextToAudioStream(...args), })) -type AudioEventName = 'ended' | 'paused' | 'loaded' | 'play' | 'timeupdate' | 'loadeddate' | 'canplay' | 'error' | 'sourceopen' +type AudioEventName = + | 'ended' + | 'paused' + | 'loaded' + | 'play' + | 'timeupdate' + | 'loadeddate' + | 'canplay' + | 'error' + | 'sourceopen' type AudioEventListener = () => void @@ -150,7 +159,7 @@ const originalCreateObjectURL = globalThis.URL.createObjectURL const originalMediaSource = window.MediaSource const originalManagedMediaSource = window.ManagedMediaSource -const setMediaSourceSupport = (options: { mediaSource: boolean, managedMediaSource: boolean }) => { +const setMediaSourceSupport = (options: { mediaSource: boolean; managedMediaSource: boolean }) => { Object.defineProperty(window, 'MediaSource', { configurable: true, writable: true, @@ -455,8 +464,7 @@ describe('AudioPlayer', () => { expect(player.cacheBuffers).toHaveLength(0) expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1) expect(callback).not.toHaveBeenCalledWith('play') - } - finally { + } finally { vi.useRealTimers() } }) @@ -546,9 +554,10 @@ describe('AudioPlayer', () => { const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null) const finishStream = vi .spyOn(player as unknown as { finishStream: () => void }, 'finishStream') - .mockImplementation(() => { }) - - ; (player as unknown as { receiveAudioData: (data: Uint8Array | undefined) => void }).receiveAudioData(undefined) + .mockImplementation(() => {}) + ;( + player as unknown as { receiveAudioData: (data: Uint8Array | undefined) => void } + ).receiveAudioData(undefined) expect(finishStream).toHaveBeenCalledTimes(1) }) @@ -557,9 +566,10 @@ describe('AudioPlayer', () => { const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null) const finishStream = vi .spyOn(player as unknown as { finishStream: () => void }, 'finishStream') - .mockImplementation(() => { }) - - ; (player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(new Uint8Array(0)) + .mockImplementation(() => {}) + ;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData( + new Uint8Array(0), + ) expect(finishStream).toHaveBeenCalledTimes(1) }) @@ -569,8 +579,9 @@ describe('AudioPlayer', () => { const mediaSource = testState.mediaSources[0] mediaSource!.emit('sourceopen') mediaSource!.sourceBuffer.updating = true - - ; (player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(new Uint8Array([1, 2, 3])) + ;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData( + new Uint8Array([1, 2, 3]), + ) expect(player.cacheBuffers.length).toBe(1) }) @@ -583,8 +594,9 @@ describe('AudioPlayer', () => { const existingBuffer = new ArrayBuffer(2) player.cacheBuffers = [existingBuffer] mediaSource!.sourceBuffer.updating = false - - ; (player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(new Uint8Array([9])) + ;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData( + new Uint8Array([9]), + ) expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1) expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledWith(existingBuffer) @@ -598,8 +610,7 @@ describe('AudioPlayer', () => { mediaSource!.emit('sourceopen') mediaSource!.sourceBuffer.updating = false player.cacheBuffers = [new ArrayBuffer(3)] - - ; (player as unknown as { finishStream: () => void }).finishStream() + ;(player as unknown as { finishStream: () => void }).finishStream() vi.advanceTimersByTime(50) expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1) diff --git a/web/app/components/base/audio-btn/__tests__/index.spec.tsx b/web/app/components/base/audio-btn/__tests__/index.spec.tsx index ab11cb1bebc004..3b5a3d021d0655 100644 --- a/web/app/components/base/audio-btn/__tests__/index.spec.tsx +++ b/web/app/components/base/audio-btn/__tests__/index.spec.tsx @@ -162,17 +162,20 @@ describe('AudioBtn', () => { expect(getButton())!.toBeDisabled() }) - it.each(['ended', 'paused', 'error'])('should return to play tooltip when %s event is received', async (event) => { - render(<AudioBtn value="test" />) - await userEvent.click(getButton()) - - await act(() => { - getLatestAudioCallback()(event) - }) - - await hoverAndCheckTooltip('play') - expect(getButton()).not.toBeDisabled() - }) + it.each(['ended', 'paused', 'error'])( + 'should return to play tooltip when %s event is received', + async (event) => { + render(<AudioBtn value="test" />) + await userEvent.click(getButton()) + + await act(() => { + getLatestAudioCallback()(event) + }) + + await hoverAndCheckTooltip('play') + expect(getButton()).not.toBeDisabled() + }, + ) }) // Prop forwarding and minimal-input behavior. diff --git a/web/app/components/base/audio-btn/audio.player.manager.ts b/web/app/components/base/audio-btn/audio.player.manager.ts index 40d9dd744952e2..227eae6844b492 100644 --- a/web/app/components/base/audio-btn/audio.player.manager.ts +++ b/web/app/components/base/audio-btn/audio.player.manager.ts @@ -5,7 +5,6 @@ declare global { interface AudioPlayerManager { instance: AudioPlayerManager } - } export class AudioPlayerManager { @@ -22,20 +21,24 @@ export class AudioPlayerManager { return AudioPlayerManager.instance } - public getAudioPlayer(url: string, isPublic: boolean, id: string | undefined, msgContent: string | null | undefined, voice: string | undefined, callback: ((event: string) => void) | null): AudioPlayer { + public getAudioPlayer( + url: string, + isPublic: boolean, + id: string | undefined, + msgContent: string | null | undefined, + voice: string | undefined, + callback: ((event: string) => void) | null, + ): AudioPlayer { if (this.msgId && this.msgId === id && this.audioPlayers) { this.audioPlayers.setCallback(callback) return this.audioPlayers - } - else { + } else { if (this.audioPlayers) { try { this.audioPlayers.pauseAudio() this.audioPlayers.cacheBuffers = [] this.audioPlayers.sourceBuffer?.abort() - } - catch { - } + } catch {} } this.msgId = id diff --git a/web/app/components/base/audio-btn/audio.ts b/web/app/components/base/audio-btn/audio.ts index 9d524640fa9a60..2dc300c1f2ebd1 100644 --- a/web/app/components/base/audio-btn/audio.ts +++ b/web/app/components/base/audio-btn/audio.ts @@ -21,7 +21,14 @@ export default class AudioPlayer { url: string isPublic: boolean callback: ((event: string) => void) | null - constructor(streamUrl: string, isPublic: boolean, msgId: string | undefined, msgContent: string | null | undefined, voice: string | undefined, callback: ((event: string) => void) | null) { + constructor( + streamUrl: string, + isPublic: boolean, + msgId: string | undefined, + msgContent: string | null | undefined, + voice: string | undefined, + callback: ((event: string) => void) | null, + ) { this.audioContext = new AudioContext() this.msgId = msgId this.msgContent = msgContent @@ -32,12 +39,15 @@ export default class AudioPlayer { // Compatible with iphone ios17 ManagedMediaSource const MediaSource = window.ManagedMediaSource || window.MediaSource if (!MediaSource) { - toast.error('Your browser does not support audio streaming, if you are using an iPhone, please update to iOS 17.1 or later.') + toast.error( + 'Your browser does not support audio streaming, if you are using an iPhone, please update to iOS 17.1 or later.', + ) } this.mediaSource = MediaSource ? new MediaSource() : null this.audio = new Audio() this.setCallback(callback) - if (!window.MediaSource) { // if use ManagedMediaSource + if (!window.MediaSource) { + // if use ManagedMediaSource this.audio.disableRemotePlayback = true this.audio.controls = true } @@ -54,8 +64,7 @@ export default class AudioPlayer { private listenMediaSource(contentType: string) { this.mediaSource?.addEventListener('sourceopen', () => { - if (this.sourceBuffer) - return + if (this.sourceBuffer) return this.sourceBuffer = this.mediaSource?.addSourceBuffer(contentType) }) } @@ -63,45 +72,81 @@ export default class AudioPlayer { public setCallback(callback: ((event: string) => void) | null) { this.callback = callback if (callback) { - this.audio.addEventListener('ended', () => { - callback('ended') - }, false) - this.audio.addEventListener('paused', () => { - callback('paused') - }, true) - this.audio.addEventListener('loaded', () => { - callback('loaded') - }, true) - this.audio.addEventListener('play', () => { - callback('play') - }, true) - this.audio.addEventListener('timeupdate', () => { - callback('timeupdate') - }, true) - this.audio.addEventListener('loadeddate', () => { - callback('loadeddate') - }, true) - this.audio.addEventListener('canplay', () => { - callback('canplay') - }, true) - this.audio.addEventListener('error', () => { - callback('error') - }, true) + this.audio.addEventListener( + 'ended', + () => { + callback('ended') + }, + false, + ) + this.audio.addEventListener( + 'paused', + () => { + callback('paused') + }, + true, + ) + this.audio.addEventListener( + 'loaded', + () => { + callback('loaded') + }, + true, + ) + this.audio.addEventListener( + 'play', + () => { + callback('play') + }, + true, + ) + this.audio.addEventListener( + 'timeupdate', + () => { + callback('timeupdate') + }, + true, + ) + this.audio.addEventListener( + 'loadeddate', + () => { + callback('loadeddate') + }, + true, + ) + this.audio.addEventListener( + 'canplay', + () => { + callback('canplay') + }, + true, + ) + this.audio.addEventListener( + 'error', + () => { + callback('error') + }, + true, + ) } } private async loadAudio() { try { - const audioResponse: any = await textToAudioStream(this.url, this.isPublic ? AppSourceType.webApp : AppSourceType.installedApp, { content_type: 'audio/mpeg' }, { - message_id: this.msgId, - streaming: true, - voice: this.voice, - text: this.msgContent, - }) + const audioResponse: any = await textToAudioStream( + this.url, + this.isPublic ? AppSourceType.webApp : AppSourceType.installedApp, + { content_type: 'audio/mpeg' }, + { + message_id: this.msgId, + streaming: true, + voice: this.voice, + text: this.msgContent, + }, + ) if (audioResponse.status !== 200) { this.isLoadData = false - if (this.callback) - this.callback('error') + if (this.callback) this.callback('error') } const reader = audioResponse.body.getReader() while (true) { @@ -112,8 +157,7 @@ export default class AudioPlayer { } this.receiveAudioData(value) } - } - catch { + } catch { this.isLoadData = false this.callback?.('error') } @@ -127,14 +171,12 @@ export default class AudioPlayer { this.audio.play() this.callback?.('play') }) - } - else if (this.audio.ended) { + } else if (this.audio.ended) { this.audio.play() this.callback?.('play') } this.callback?.('play') - } - else { + } else { this.isLoadData = true this.audioContext.resume().then((_) => { this.audio.play() @@ -180,13 +222,12 @@ export default class AudioPlayer { this.audio.play() this.callback?.('play') }) - } - else if (this.audio.ended) { + } else if (this.audio.ended) { this.audio.play() this.callback?.('play') - } - else if (this.audio.played) { /* empty */ } - else { + } else if (this.audio.played) { + /* empty */ + } else { this.audio.play() this.callback?.('play') } @@ -206,20 +247,17 @@ export default class AudioPlayer { } const audioData = this.byteArrayToArrayBuffer(unit8Array) if (!audioData.byteLength) { - if (this.mediaSource?.readyState === 'open') - this.finishStream() + if (this.mediaSource?.readyState === 'open') this.finishStream() return } if (this.sourceBuffer?.updating) { this.cacheBuffers.push(audioData) - } - else { + } else { if (this.cacheBuffers.length && !this.sourceBuffer?.updating) { this.cacheBuffers.push(audioData) const cacheBuffer = this.cacheBuffers.shift()! this.sourceBuffer?.appendBuffer(cacheBuffer) - } - else { + } else { this.sourceBuffer?.appendBuffer(audioData) } } diff --git a/web/app/components/base/audio-btn/index.stories.tsx b/web/app/components/base/audio-btn/index.stories.tsx index c760e1366da2af..617b704716aa37 100644 --- a/web/app/components/base/audio-btn/index.stories.tsx +++ b/web/app/components/base/audio-btn/index.stories.tsx @@ -27,7 +27,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Audio playback toggle that streams assistant responses. The story uses a mocked audio player so you can inspect loading and playback states without calling the real API.', + component: + 'Audio playback toggle that streams assistant responses. The story uses a mocked audio player so you can inspect loading and playback states without calling the real API.', }, }, nextjs: { @@ -66,7 +67,7 @@ export default meta type Story = StoryObj<typeof meta> export const Default: Story = { - render: args => <StoryWrapper {...args} />, + render: (args) => <StoryWrapper {...args} />, args: { id: 'message-1', value: 'This is an audio preview for the current assistant response.', diff --git a/web/app/components/base/audio-btn/index.tsx b/web/app/components/base/audio-btn/index.tsx index 7bf19a0b6f2562..42df07142c3738 100644 --- a/web/app/components/base/audio-btn/index.tsx +++ b/web/app/components/base/audio-btn/index.tsx @@ -19,13 +19,7 @@ type AudioBtnProps = { type AudioState = 'initial' | 'loading' | 'playing' | 'paused' | 'ended' -const AudioBtn = ({ - id, - voice, - value, - className, - isAudition, -}: AudioBtnProps) => { +const AudioBtn = ({ id, voice, value, className, isAudition }: AudioBtnProps) => { const [audioState, setAudioState] = useState<AudioState>('initial') const params = useParams() @@ -55,37 +49,39 @@ const AudioBtn = ({ if (params.token) { url = '/text-to-audio' isPublic = true - } - else if (params.appId) { - if (isInstalledAppPath(pathname)) - url = `/installed-apps/${params.appId}/text-to-audio` - else - url = `/apps/${params.appId}/text-to-audio` + } else if (params.appId) { + if (isInstalledAppPath(pathname)) url = `/installed-apps/${params.appId}/text-to-audio` + else url = `/apps/${params.appId}/text-to-audio` } const handleToggle = async () => { if (audioState === 'playing' || audioState === 'loading') { setTimeout(() => setAudioState('paused'), 1) - AudioPlayerManager.getInstance().getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call).pauseAudio() - } - else { + AudioPlayerManager.getInstance() + .getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call) + .pauseAudio() + } else { setTimeout(() => setAudioState('loading'), 1) - AudioPlayerManager.getInstance().getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call).playAudio() + AudioPlayerManager.getInstance() + .getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call) + .playAudio() } } const tooltipContent = { - initial: t($ => $.play, { ns: 'appApi' }), - ended: t($ => $.play, { ns: 'appApi' }), - paused: t($ => $.pause, { ns: 'appApi' }), - playing: t($ => $.playing, { ns: 'appApi' }), - loading: t($ => $.loading, { ns: 'appApi' }), + initial: t(($) => $.play, { ns: 'appApi' }), + ended: t(($) => $.play, { ns: 'appApi' }), + paused: t(($) => $.pause, { ns: 'appApi' }), + playing: t(($) => $.playing, { ns: 'appApi' }), + loading: t(($) => $.loading, { ns: 'appApi' }), }[audioState] return ( - <div className={`inline-flex items-center justify-center ${(audioState === 'loading' || audioState === 'playing') ? 'mr-1' : className}`}> + <div + className={`inline-flex items-center justify-center ${audioState === 'loading' || audioState === 'playing' ? 'mr-1' : className}`} + > <Tooltip> <TooltipTrigger - render={( + render={ <span className="inline-flex"> <button type="button" @@ -94,24 +90,22 @@ const AudioBtn = ({ className={`box-border flex size-6 cursor-pointer items-center justify-center border-none bg-transparent ${isAudition ? 'p-0.5' : 'rounded-md bg-white p-0'}`} onClick={handleToggle} > - {audioState === 'loading' - ? ( - <div className="flex size-full items-center justify-center rounded-md"> - <Loading /> - </div> - ) - : ( - <div className="flex size-full items-center justify-center rounded-md hover:bg-gray-50"> - <div className={`size-4 ${(audioState === 'playing') ? s.pauseIcon : s.playIcon}`}></div> - </div> - )} + {audioState === 'loading' ? ( + <div className="flex size-full items-center justify-center rounded-md"> + <Loading /> + </div> + ) : ( + <div className="flex size-full items-center justify-center rounded-md hover:bg-gray-50"> + <div + className={`size-4 ${audioState === 'playing' ? s.pauseIcon : s.playIcon}`} + ></div> + </div> + )} </button> </span> - )} + } /> - <TooltipContent> - {tooltipContent} - </TooltipContent> + <TooltipContent>{tooltipContent}</TooltipContent> </Tooltip> </div> ) diff --git a/web/app/components/base/audio-gallery/AudioPlayer.tsx b/web/app/components/base/audio-gallery/AudioPlayer.tsx index 0aab759ce3d452..473357c3386239 100644 --- a/web/app/components/base/audio-gallery/AudioPlayer.tsx +++ b/web/app/components/base/audio-gallery/AudioPlayer.tsx @@ -23,12 +23,13 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { const [hoverTime, setHoverTime] = useState(0) const [isAudioAvailable, setIsAudioAvailable] = useState(true) const { theme } = useTheme() - const playPauseLabel = t($ => $[isPlaying ? 'operation.pause' : 'operation.play'], { ns: 'common' }) + const playPauseLabel = t(($) => $[isPlaying ? 'operation.pause' : 'operation.play'], { + ns: 'common', + }) useEffect(() => { const audio = audioRef.current /* v8 ignore next 2 - @preserve */ - if (!audio) - return + if (!audio) return const handleError = () => { setIsAudioAvailable(false) } @@ -39,8 +40,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { setCurrentTime(audio.currentTime) } const handleProgress = () => { - if (audio.buffered.length > 0) - setBufferedTime(audio.buffered.end(audio.buffered.length - 1)) + if (audio.buffered.length > 0) setBufferedTime(audio.buffered.end(audio.buffered.length - 1)) } const handleEnded = () => { setIsPlaying(false) @@ -76,7 +76,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { } const primarySrc = srcs?.[0] || src const url = primarySrc ? new URL(primarySrc) : null - const isHttp = url ? (url.protocol === 'http:' || url.protocol === 'https:') : false + const isHttp = url ? url.protocol === 'http:' || url.protocol === 'https:' : false if (!isHttp) { setIsAudioAvailable(false) return null @@ -96,18 +96,16 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { const waveformData: number[] = [] for (let i = 0; i < samples; i++) { let sum = 0 - for (let j = 0; j < blockSize; j++) - sum += Math.abs(channelData[i * blockSize + j]!) + for (let j = 0; j < blockSize; j++) sum += Math.abs(channelData[i * blockSize + j]!) // Apply nonlinear scaling to enhance small amplitudes waveformData.push((sum / blockSize) * 5) } // Normalized waveform data const maxAmplitude = Math.max(...waveformData) - const normalizedWaveform = waveformData.map(amp => amp / maxAmplitude) + const normalizedWaveform = waveformData.map((amp) => amp / maxAmplitude) setWaveformData(normalizedWaveform) setIsAudioAvailable(true) - } - catch { + } catch { const waveform: number[] = [] let prevValue = Math.random() for (let i = 0; i < samples; i++) { @@ -117,11 +115,10 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { prevValue = interpolatedValue } const maxAmplitude = Math.max(...waveform) - const randomWaveform = waveform.map(amp => amp / maxAmplitude) + const randomWaveform = waveform.map((amp) => amp / maxAmplitude) setWaveformData(randomWaveform) setIsAudioAvailable(true) - } - finally { + } finally { await audioContext.close() } } @@ -131,46 +128,45 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { if (isPlaying) { setHasStartedPlaying(false) audio.pause() - } - else { + } else { setHasStartedPlaying(true) - audio.play().catch(error => console.error('Error playing audio:', error)) + audio.play().catch((error) => console.error('Error playing audio:', error)) } setIsPlaying(!isPlaying) - } - else { + } else { toast.error('Audio element not found') setIsAudioAvailable(false) } }, [isAudioAvailable, isPlaying]) - const handleCanvasInteraction = useCallback((e: React.MouseEvent | React.TouchEvent) => { - e.preventDefault() - const getClientX = (event: React.MouseEvent | React.TouchEvent): number => { - if ('touches' in event) - return event.touches[0]!.clientX - return event.clientX - } - const updateProgress = (clientX: number) => { - const canvas = canvasRef.current - const audio = audioRef.current - if (!canvas || !audio) - return - const rect = canvas.getBoundingClientRect() - const percent = Math.min(Math.max(0, clientX - rect.left), rect.width) / rect.width - const newTime = percent * duration - // Removes the buffer check, allowing drag to any location - audio.currentTime = newTime - setCurrentTime(newTime) - if (!isPlaying) { - setIsPlaying(true) - audio.play().catch((error) => { - toast.error(`Error playing audio: ${error}`) - setIsPlaying(false) - }) + const handleCanvasInteraction = useCallback( + (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault() + const getClientX = (event: React.MouseEvent | React.TouchEvent): number => { + if ('touches' in event) return event.touches[0]!.clientX + return event.clientX } - } - updateProgress(getClientX(e)) - }, [duration, isPlaying]) + const updateProgress = (clientX: number) => { + const canvas = canvasRef.current + const audio = audioRef.current + if (!canvas || !audio) return + const rect = canvas.getBoundingClientRect() + const percent = Math.min(Math.max(0, clientX - rect.left), rect.width) / rect.width + const newTime = percent * duration + // Removes the buffer check, allowing drag to any location + audio.currentTime = newTime + setCurrentTime(newTime) + if (!isPlaying) { + setIsPlaying(true) + audio.play().catch((error) => { + toast.error(`Error playing audio: ${error}`) + setIsPlaying(false) + }) + } + } + updateProgress(getClientX(e)) + }, + [duration, isPlaying], + ) const formatTime = (time: number) => { const minutes = Math.floor(time / 60) const seconds = Math.floor(time % 60) @@ -179,11 +175,9 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { const drawWaveform = useCallback(() => { const canvas = canvasRef.current /* v8 ignore next 2 - @preserve */ - if (!canvas) - return + if (!canvas) return const ctx = canvas.getContext('2d') - if (!ctx) - return + if (!ctx) return const width = canvas.width const height = canvas.height const data = waveformData @@ -194,12 +188,10 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { // Draw waveform bars data.forEach((value, index) => { let color - if (index * barWidth <= playedWidth) - color = theme === Theme.light ? '#296DFF' : '#84ABFF' - else if ((index * barWidth / width) * duration <= hoverTime) + if (index * barWidth <= playedWidth) color = theme === Theme.light ? '#296DFF' : '#84ABFF' + else if (((index * barWidth) / width) * duration <= hoverTime) color = theme === Theme.light ? 'rgba(21,90,239,.40)' : 'rgba(200, 206, 218, 0.28)' - else - color = theme === Theme.light ? 'rgba(21,90,239,.20)' : 'rgba(200, 206, 218, 0.14)' + else color = theme === Theme.light ? 'rgba(21,90,239,.20)' : 'rgba(200, 206, 218, 0.14)' const barHeight = value * height const rectX = index * barWidth const rectY = (height - barHeight) / 2 @@ -211,8 +203,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { ctx.beginPath() ctx.roundRect(rectX, rectY, rectWidth, rectHeight, cornerRadius) ctx.fill() - } - else { + } else { ctx.fillRect(rectX, rectY, rectWidth, rectHeight) } }) @@ -220,47 +211,69 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => { useEffect(() => { drawWaveform() }, [drawWaveform, bufferedTime, hasStartedPlaying]) - const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => { - const canvas = canvasRef.current - const audio = audioRef.current - if (!canvas || !audio) - return - const clientX = 'touches' in e - ? e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX - : e.clientX - if (clientX === undefined) - return - const rect = canvas.getBoundingClientRect() - const percent = Math.min(Math.max(0, clientX - rect.left), rect.width) / rect.width - const time = percent * duration - // Check if the hovered position is within a buffered range before updating hoverTime - for (let i = 0; i < audio.buffered.length; i++) { - if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) { - setHoverTime(time) - break + const handleMouseMove = useCallback( + (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => { + const canvas = canvasRef.current + const audio = audioRef.current + if (!canvas || !audio) return + const clientX = + 'touches' in e ? (e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX) : e.clientX + if (clientX === undefined) return + const rect = canvas.getBoundingClientRect() + const percent = Math.min(Math.max(0, clientX - rect.left), rect.width) / rect.width + const time = percent * duration + // Check if the hovered position is within a buffered range before updating hoverTime + for (let i = 0; i < audio.buffered.length; i++) { + if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) { + setHoverTime(time) + break + } } - } - }, [duration]) + }, + [duration], + ) return ( <div className="flex h-9 max-w-[420px] min-w-[240px] items-center gap-2 rounded-[10px] border border-components-panel-border-subtle bg-components-chat-input-audio-bg-alt p-2 shadow-xs backdrop-blur-xs"> <audio ref={audioRef} src={src} preload="auto" data-testid="audio-player"> {/* If srcs array is provided, render multiple source elements */} - {srcs && srcs.map((srcUrl, index) => (<source key={index} src={srcUrl} />))} + {srcs && srcs.map((srcUrl, index) => <source key={index} src={srcUrl} />)} </audio> - <button type="button" aria-label={playPauseLabel} className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled" onClick={togglePlay} disabled={!isAudioAvailable}> - {isPlaying - ? (<div className="i-ri-pause-circle-fill size-5" aria-hidden="true" />) - : (<div className="i-ri-play-large-fill size-5" aria-hidden="true" />)} + <button + type="button" + aria-label={playPauseLabel} + className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled" + onClick={togglePlay} + disabled={!isAudioAvailable} + > + {isPlaying ? ( + <div className="i-ri-pause-circle-fill size-5" aria-hidden="true" /> + ) : ( + <div className="i-ri-play-large-fill size-5" aria-hidden="true" /> + )} </button> <div className={cn(isAudioAvailable && 'grow')} hidden={!isAudioAvailable}> <div className="flex h-8 items-center justify-center"> - <canvas ref={canvasRef} data-testid="waveform-canvas" className="relative flex h-6 w-full grow cursor-pointer items-center justify-center" onClick={handleCanvasInteraction} onMouseMove={handleMouseMove} onMouseDown={handleCanvasInteraction} onTouchMove={handleMouseMove} onTouchStart={handleCanvasInteraction} /> + <canvas + ref={canvasRef} + data-testid="waveform-canvas" + className="relative flex h-6 w-full grow cursor-pointer items-center justify-center" + onClick={handleCanvasInteraction} + onMouseMove={handleMouseMove} + onMouseDown={handleCanvasInteraction} + onTouchMove={handleMouseMove} + onTouchStart={handleCanvasInteraction} + /> <div className="inline-flex min-w-[50px] items-center justify-center system-xs-medium text-text-accent-secondary"> <span className="rounded-[10px] px-0.5 py-1">{formatTime(duration)}</span> </div> </div> </div> - <div className="absolute top-0 left-0 flex size-full items-center justify-center text-text-quaternary" hidden={isAudioAvailable}>{t($ => $['operation.audioSourceUnavailable'], { ns: 'common' })}</div> + <div + className="absolute top-0 left-0 flex size-full items-center justify-center text-text-quaternary" + hidden={isAudioAvailable} + > + {t(($) => $['operation.audioSourceUnavailable'], { ns: 'common' })} + </div> </div> ) } diff --git a/web/app/components/base/audio-gallery/__tests__/AudioPlayer.spec.tsx b/web/app/components/base/audio-gallery/__tests__/AudioPlayer.spec.tsx index 3d27286c763f0d..46a43b8fe12f25 100644 --- a/web/app/components/base/audio-gallery/__tests__/AudioPlayer.spec.tsx +++ b/web/app/components/base/audio-gallery/__tests__/AudioPlayer.spec.tsx @@ -19,7 +19,9 @@ function buildAudioContext(channelLength = 512) { return Promise.resolve({ getChannelData: (_ch: number) => arr }) } - close() { return Promise.resolve() } + close() { + return Promise.resolve() + } } } @@ -46,7 +48,7 @@ async function advanceWaveformTimer() { // eslint-disable-next-line ts/no-explicit-any type ReactEventHandler = ((...args: any[]) => void) | undefined function getReactProps<T extends Element>(el: T): Record<string, ReactEventHandler> { - const key = Object.keys(el).find(k => k.startsWith('__reactProps$')) + const key = Object.keys(el).find((k) => k.startsWith('__reactProps$')) return key ? (el as unknown as Record<string, Record<string, ReactEventHandler>>)[key]! : {} } @@ -58,7 +60,7 @@ const getPauseButton = () => screen.getByRole('button', { name: 'common.operatio beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() - ; (useThemeMock as ReturnType<typeof vi.fn>).mockReturnValue({ theme: Theme.light }) + ;(useThemeMock as ReturnType<typeof vi.fn>).mockReturnValue({ theme: Theme.light }) HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined) HTMLMediaElement.prototype.pause = vi.fn() HTMLMediaElement.prototype.load = vi.fn() @@ -250,8 +252,12 @@ describe('AudioPlayer — waveform generation', () => { it('should use fallback waveform when decodeAudioData rejects', async () => { class FailDecodeContext { - decodeAudioData() { return Promise.reject(new Error('decode error')) } - close() { return Promise.resolve() } + decodeAudioData() { + return Promise.reject(new Error('decode error')) + } + close() { + return Promise.resolve() + } } vi.stubGlobal('AudioContext', FailDecodeContext) vi.spyOn(globalThis, 'fetch').mockResolvedValue({ @@ -296,14 +302,16 @@ describe('AudioPlayer — waveform generation', () => { vi.stubGlobal('AudioContext', buildAudioContext(300)) const fetchSpy = stubFetchOk(256) - render(<AudioPlayer srcs={['https://cdn.example/first.mp3', 'https://cdn.example/second.mp3']} />) + render( + <AudioPlayer srcs={['https://cdn.example/first.mp3', 'https://cdn.example/second.mp3']} />, + ) await advanceWaveformTimer() expect(fetchSpy).toHaveBeenCalledWith('https://cdn.example/first.mp3', { mode: 'cors' }) }) it('should cover dark theme waveform draw branch', async () => { - ; (useThemeMock as ReturnType<typeof vi.fn>).mockReturnValue({ theme: Theme.dark }) + ;(useThemeMock as ReturnType<typeof vi.fn>).mockReturnValue({ theme: Theme.dark }) vi.stubGlobal('AudioContext', buildAudioContext(300)) stubFetchOk(256) @@ -380,13 +388,16 @@ describe('AudioPlayer — canvas seek interactions', () => { await act(async () => { fireEvent.click(canvas, { clientX: 50 }) }) - const callsAfterFirst = (HTMLMediaElement.prototype.play as ReturnType<typeof vi.fn>).mock.calls.length + const callsAfterFirst = (HTMLMediaElement.prototype.play as ReturnType<typeof vi.fn>).mock.calls + .length await act(async () => { fireEvent.click(canvas, { clientX: 80 }) }) - expect((HTMLMediaElement.prototype.play as ReturnType<typeof vi.fn>).mock.calls.length).toBe(callsAfterFirst) + expect((HTMLMediaElement.prototype.play as ReturnType<typeof vi.fn>).mock.calls.length).toBe( + callsAfterFirst, + ) }) it('should update hoverTime on mousemove within buffered range', async () => { @@ -439,7 +450,10 @@ describe('AudioPlayer — missing coverage', () => { // Note: React 18 / testing-library wraps updates automatically, but we still wait for advanceWaveformTimer const originalGetContext = HTMLCanvasElement.prototype.getContext let fillRectCalled = false - HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement, ...args: Parameters<typeof HTMLCanvasElement.prototype.getContext>) { + HTMLCanvasElement.prototype.getContext = function ( + this: HTMLCanvasElement, + ...args: Parameters<typeof HTMLCanvasElement.prototype.getContext> + ) { const ctx = originalGetContext.apply(this, args) as CanvasRenderingContext2D | null if (ctx) { Object.defineProperty(ctx, 'roundRect', { value: undefined, configurable: true }) @@ -463,7 +477,7 @@ describe('AudioPlayer — missing coverage', () => { }) it('should handle play error gracefully when togglePlay is clicked', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) vi.spyOn(HTMLMediaElement.prototype, 'play').mockRejectedValue(new Error('play failed')) render(<AudioPlayer src="https://example.com/audio.mp3" />) @@ -487,7 +501,8 @@ describe('AudioPlayer — missing coverage', () => { const canvas = screen.getByTestId('waveform-canvas') as HTMLCanvasElement const audio = document.querySelector('audio') as HTMLAudioElement Object.defineProperty(audio, 'duration', { value: 120, configurable: true }) - canvas.getBoundingClientRect = () => ({ left: 0, width: 200, top: 0, height: 10, right: 200, bottom: 10 }) as DOMRect + canvas.getBoundingClientRect = () => + ({ left: 0, width: 200, top: 0, height: 10, right: 200, bottom: 10 }) as DOMRect vi.spyOn(HTMLMediaElement.prototype, 'play').mockRejectedValue(new Error('play failed')) @@ -510,7 +525,8 @@ describe('AudioPlayer — missing coverage', () => { const canvas = screen.getByTestId('waveform-canvas') as HTMLCanvasElement const audio = document.querySelector('audio') as HTMLAudioElement Object.defineProperty(audio, 'duration', { value: 120, configurable: true }) - canvas.getBoundingClientRect = () => ({ left: 0, width: 200, top: 0, height: 10, right: 200, bottom: 10 }) as DOMRect + canvas.getBoundingClientRect = () => + ({ left: 0, width: 200, top: 0, height: 10, right: 200, bottom: 10 }) as DOMRect await act(async () => { // Use touch events @@ -644,7 +660,7 @@ describe('AudioPlayer — additional branch coverage', () => { }) it('should cover Dark theme waveform states', async () => { - ; (useThemeMock as ReturnType<typeof vi.fn>).mockReturnValue({ theme: Theme.dark }) + ;(useThemeMock as ReturnType<typeof vi.fn>).mockReturnValue({ theme: Theme.dark }) vi.stubGlobal('AudioContext', buildAudioContext(300)) stubFetchOk(128) diff --git a/web/app/components/base/audio-gallery/__tests__/index.spec.tsx b/web/app/components/base/audio-gallery/__tests__/index.spec.tsx index d1ecc96cc0e744..73f0499362fcaf 100644 --- a/web/app/components/base/audio-gallery/__tests__/index.spec.tsx +++ b/web/app/components/base/audio-gallery/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import AudioGallery from '../index' describe('AudioGallery', () => { beforeEach(() => { - vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => { }) + vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {}) }) it('returns null when srcs array is empty', () => { diff --git a/web/app/components/base/audio-gallery/index.stories.tsx b/web/app/components/base/audio-gallery/index.stories.tsx index cf22058c9a2d0c..beadea32e77e30 100644 --- a/web/app/components/base/audio-gallery/index.stories.tsx +++ b/web/app/components/base/audio-gallery/index.stories.tsx @@ -11,7 +11,8 @@ const meta = { parameters: { docs: { description: { - component: 'List of audio players that render waveform previews and playback controls for each source.', + component: + 'List of audio players that render waveform previews and playback controls for each source.', }, source: { language: 'tsx', diff --git a/web/app/components/base/audio-gallery/index.tsx b/web/app/components/base/audio-gallery/index.tsx index 9995f81c0b4569..73db8b42fc3395 100644 --- a/web/app/components/base/audio-gallery/index.tsx +++ b/web/app/components/base/audio-gallery/index.tsx @@ -6,9 +6,8 @@ type Props = Readonly<{ }> const AudioGallery: React.FC<Props> = ({ srcs }) => { - const validSrcs = srcs.filter(src => src) - if (validSrcs.length === 0) - return null + const validSrcs = srcs.filter((src) => src) + if (validSrcs.length === 0) return null return ( <div className="my-3"> diff --git a/web/app/components/base/auto-height-textarea/__tests__/index.spec.tsx b/web/app/components/base/auto-height-textarea/__tests__/index.spec.tsx index 08828d475235b7..77f5cae8ab5040 100644 --- a/web/app/components/base/auto-height-textarea/__tests__/index.spec.tsx +++ b/web/app/components/base/auto-height-textarea/__tests__/index.spec.tsx @@ -36,19 +36,25 @@ describe('AutoHeightTextarea', () => { describe('Props', () => { it('should apply custom className to textarea', () => { - const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} className="custom-class" />) + const { container } = render( + <AutoHeightTextarea value="" onChange={vi.fn()} className="custom-class" />, + ) const textarea = container.querySelector('textarea') expect(textarea).toHaveClass('custom-class') }) it('should apply custom wrapperClassName to wrapper div', () => { - const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} wrapperClassName="wrapper-class" />) + const { container } = render( + <AutoHeightTextarea value="" onChange={vi.fn()} wrapperClassName="wrapper-class" />, + ) const wrapper = container.querySelector('div.relative') expect(wrapper).toHaveClass('wrapper-class') }) it('should apply minHeight and maxHeight styles to hidden div', () => { - const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} minHeight={50} maxHeight={200} />) + const { container } = render( + <AutoHeightTextarea value="" onChange={vi.fn()} minHeight={50} maxHeight={200} />, + ) const hiddenDiv = container.querySelector('div.invisible') expect(hiddenDiv).toHaveStyle({ minHeight: '50px', maxHeight: '200px' }) }) @@ -137,7 +143,13 @@ describe('AutoHeightTextarea', () => { describe('Ref forwarding', () => { it('should accept ref and allow focusing', () => { const ref = { current: null as HTMLTextAreaElement | null } - render(<AutoHeightTextarea ref={ref as React.RefObject<HTMLTextAreaElement>} value="" onChange={vi.fn()} />) + render( + <AutoHeightTextarea + ref={ref as React.RefObject<HTMLTextAreaElement>} + value="" + onChange={vi.fn()} + />, + ) expect(ref.current).not.toBeNull() expect(ref.current?.tagName).toBe('TEXTAREA') @@ -147,7 +159,9 @@ describe('AutoHeightTextarea', () => { describe('controlFocus prop', () => { it('should call focus when controlFocus changes', () => { const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') - const { rerender } = render(<AutoHeightTextarea value="" onChange={vi.fn()} controlFocus={1} />) + const { rerender } = render( + <AutoHeightTextarea value="" onChange={vi.fn()} controlFocus={1} />, + ) expect(focusSpy).toHaveBeenCalledTimes(1) @@ -173,8 +187,7 @@ describe('AutoHeightTextarea', () => { let sleepCalls = 0 sleepMock.mockImplementation(async () => { sleepCalls += 1 - if (sleepCalls === 2) - exposedNode = assignedNode + if (sleepCalls === 2) exposedNode = assignedNode }) const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') diff --git a/web/app/components/base/auto-height-textarea/index.stories.tsx b/web/app/components/base/auto-height-textarea/index.stories.tsx index 3aa8af681637cf..3c971a86f5caf3 100644 --- a/web/app/components/base/auto-height-textarea/index.stories.tsx +++ b/web/app/components/base/auto-height-textarea/index.stories.tsx @@ -9,7 +9,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Auto-resizing textarea component that expands and contracts based on content, with configurable min/max height constraints.', + component: + 'Auto-resizing textarea component that expands and contracts based on content, with configurable min/max height constraints.', }, }, }, @@ -78,136 +79,150 @@ const AutoHeightTextareaDemo = (args: any) => { // Default state export const Default: Story = { - render: args => <AutoHeightTextareaDemo {...args} />, + render: (args) => <AutoHeightTextareaDemo {...args} />, args: { placeholder: 'Type something...', value: '', minHeight: 36, maxHeight: 96, - className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', + className: + 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', }, } // With initial value export const WithInitialValue: Story = { - render: args => <AutoHeightTextareaDemo {...args} />, + render: (args) => <AutoHeightTextareaDemo {...args} />, args: { placeholder: 'Type something...', value: 'This is a pre-filled textarea with some initial content.', minHeight: 36, maxHeight: 96, - className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', + className: + 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', }, } // With multiline content export const MultilineContent: Story = { - render: args => <AutoHeightTextareaDemo {...args} />, + render: (args) => <AutoHeightTextareaDemo {...args} />, args: { placeholder: 'Type something...', - value: 'Line 1\nLine 2\nLine 3\nLine 4\nThis textarea automatically expands to fit the content.', + value: + 'Line 1\nLine 2\nLine 3\nLine 4\nThis textarea automatically expands to fit the content.', minHeight: 36, maxHeight: 96, - className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', + className: + 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', }, } // Custom min height export const CustomMinHeight: Story = { - render: args => <AutoHeightTextareaDemo {...args} />, + render: (args) => <AutoHeightTextareaDemo {...args} />, args: { placeholder: 'Taller minimum height...', value: '', minHeight: 100, maxHeight: 200, - className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', + className: + 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', }, } // Small max height (scrollable) export const SmallMaxHeight: Story = { - render: args => <AutoHeightTextareaDemo {...args} />, + render: (args) => <AutoHeightTextareaDemo {...args} />, args: { placeholder: 'Type multiple lines...', - value: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nThis will become scrollable when it exceeds max height.', + value: + 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nThis will become scrollable when it exceeds max height.', minHeight: 36, maxHeight: 80, - className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', + className: + 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', }, } // Auto focus enabled export const AutoFocus: Story = { - render: args => <AutoHeightTextareaDemo {...args} />, + render: (args) => <AutoHeightTextareaDemo {...args} />, args: { placeholder: 'This textarea auto-focuses on mount', value: '', minHeight: 36, maxHeight: 96, autoFocus: true, - className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', + className: + 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', }, } // With custom styling export const CustomStyling: Story = { - render: args => <AutoHeightTextareaDemo {...args} />, + render: (args) => <AutoHeightTextareaDemo {...args} />, args: { placeholder: 'Custom styled textarea...', value: '', minHeight: 50, maxHeight: 150, - className: 'w-full p-3 bg-gray-50 border-2 border-blue-400 rounded-xl text-lg focus:outline-hidden focus:bg-white focus:border-blue-600', + className: + 'w-full p-3 bg-gray-50 border-2 border-blue-400 rounded-xl text-lg focus:outline-hidden focus:bg-white focus:border-blue-600', wrapperClassName: 'shadow-lg', }, } // Long content example export const LongContent: Story = { - render: args => <AutoHeightTextareaDemo {...args} />, + render: (args) => <AutoHeightTextareaDemo {...args} />, args: { placeholder: 'Type something...', - value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + value: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', minHeight: 36, maxHeight: 200, - className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', + className: + 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', }, } // Real-world example - Chat input export const ChatInput: Story = { - render: args => <AutoHeightTextareaDemo {...args} />, + render: (args) => <AutoHeightTextareaDemo {...args} />, args: { placeholder: 'Type your message...', value: '', minHeight: 40, maxHeight: 120, - className: 'w-full px-4 py-2 bg-gray-100 border border-gray-300 rounded-2xl text-sm focus:outline-hidden focus:bg-white focus:ring-2 focus:ring-blue-500', + className: + 'w-full px-4 py-2 bg-gray-100 border border-gray-300 rounded-2xl text-sm focus:outline-hidden focus:bg-white focus:ring-2 focus:ring-blue-500', }, } // Real-world example - Comment box export const CommentBox: Story = { - render: args => <AutoHeightTextareaDemo {...args} />, + render: (args) => <AutoHeightTextareaDemo {...args} />, args: { placeholder: 'Write a comment...', value: '', minHeight: 60, maxHeight: 200, - className: 'w-full p-3 border border-gray-300 rounded-lg text-sm focus:outline-hidden focus:ring-2 focus:ring-indigo-500', + className: + 'w-full p-3 border border-gray-300 rounded-lg text-sm focus:outline-hidden focus:ring-2 focus:ring-indigo-500', }, } // Interactive playground export const Playground: Story = { - render: args => <AutoHeightTextareaDemo {...args} />, + render: (args) => <AutoHeightTextareaDemo {...args} />, args: { placeholder: 'Type something...', value: '', minHeight: 36, maxHeight: 96, autoFocus: false, - className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', + className: + 'w-full p-2 border border-gray-300 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-500', wrapperClassName: '', }, } diff --git a/web/app/components/base/auto-height-textarea/index.tsx b/web/app/components/base/auto-height-textarea/index.tsx index e409b71e6953c8..4e24e89a11c1c6 100644 --- a/web/app/components/base/auto-height-textarea/index.tsx +++ b/web/app/components/base/auto-height-textarea/index.tsx @@ -16,24 +16,22 @@ type IProps = { onKeyUp?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void } -const AutoHeightTextarea = ( - { - ref: outerRef, - value, - onChange, - placeholder, - className, - wrapperClassName, - minHeight = 36, - maxHeight = 96, - autoFocus, - controlFocus, - onKeyDown, - onKeyUp, - }: IProps & { - ref?: React.RefObject<HTMLTextAreaElement> - }, -) => { +const AutoHeightTextarea = ({ + ref: outerRef, + value, + onChange, + placeholder, + className, + wrapperClassName, + minHeight = 36, + maxHeight = 96, + autoFocus, + controlFocus, + onKeyDown, + onKeyUp, +}: IProps & { + ref?: React.RefObject<HTMLTextAreaElement> +}) => { // eslint-disable-next-line react-hooks/rules-of-hooks const ref = outerRef || useRef<HTMLTextAreaElement>(null) @@ -51,48 +49,43 @@ const AutoHeightTextarea = ( let hasFocus = false await sleep(100) hasFocus = doFocus() - if (!hasFocus) - focus() + if (!hasFocus) focus() } } useEffect(() => { - if (autoFocus) - focus() + if (autoFocus) focus() }, []) useEffect(() => { - if (controlFocus) - focus() + if (controlFocus) focus() }, [controlFocus]) return ( - ( - <div className={`relative ${wrapperClassName}`}> - <div - className={cn(className, 'invisible overflow-y-auto break-all whitespace-pre-wrap')} - style={{ - minHeight, - maxHeight, - paddingRight: (value && value.trim().length > 10000) ? 140 : 130, - }} - > - {!value ? placeholder : value.replace(/\n$/, '\n ')} - </div> - <textarea - ref={ref} - autoFocus={autoFocus} - className={cn(className, 'absolute inset-0 resize-none overflow-auto')} - style={{ - paddingRight: (value && value.trim().length > 10000) ? 140 : 130, - }} - placeholder={placeholder} - onChange={onChange} - onKeyDown={onKeyDown} - onKeyUp={onKeyUp} - value={value} - /> + <div className={`relative ${wrapperClassName}`}> + <div + className={cn(className, 'invisible overflow-y-auto break-all whitespace-pre-wrap')} + style={{ + minHeight, + maxHeight, + paddingRight: value && value.trim().length > 10000 ? 140 : 130, + }} + > + {!value ? placeholder : value.replace(/\n$/, '\n ')} </div> - ) + <textarea + ref={ref} + autoFocus={autoFocus} + className={cn(className, 'absolute inset-0 resize-none overflow-auto')} + style={{ + paddingRight: value && value.trim().length > 10000 ? 140 : 130, + }} + placeholder={placeholder} + onChange={onChange} + onKeyDown={onKeyDown} + onKeyUp={onKeyUp} + value={value} + /> + </div> ) } diff --git a/web/app/components/base/badge.tsx b/web/app/components/base/badge.tsx index 7a61c1d9d98031..3a4745b8c9ac2a 100644 --- a/web/app/components/base/badge.tsx +++ b/web/app/components/base/badge.tsx @@ -32,8 +32,7 @@ const Badge = ({ )} > {hasRedCornerMark && ( - <div className="absolute top-[-2px] right-[-2px] h-1.5 w-1.5 rounded-xs border border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg shadow-sm"> - </div> + <div className="absolute top-[-2px] right-[-2px] h-1.5 w-1.5 rounded-xs border border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg shadow-sm"></div> )} {children || text} </div> diff --git a/web/app/components/base/badge/__tests__/index.spec.tsx b/web/app/components/base/badge/__tests__/index.spec.tsx index feb3b87e13e402..d2c29bed56458d 100644 --- a/web/app/components/base/badge/__tests__/index.spec.tsx +++ b/web/app/components/base/badge/__tests__/index.spec.tsx @@ -76,7 +76,11 @@ describe('Badge', () => { { size: 'l', iconOnly: false, label: 'large with text' }, { size: 'l', iconOnly: true, label: 'large icon-only' }, ] as const)('should render correctly for $label', ({ size, iconOnly }) => { - const { container } = render(<Badge size={size} iconOnly={iconOnly}>🔔</Badge>) + const { container } = render( + <Badge size={size} iconOnly={iconOnly}> + 🔔 + </Badge>, + ) const badge = screen.getByText('🔔') // Verify badge renders with correct size @@ -226,9 +230,11 @@ describe('Badge', () => { fireEvent.click(screen.getByText('Event Badge')) - expect(handleClick).toHaveBeenCalledWith(expect.objectContaining({ - type: 'click', - })) + expect(handleClick).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'click', + }), + ) }) }) @@ -248,7 +254,13 @@ describe('Badge', () => { ) const badge = screen.getByTestId('combined-badge') - expect(badge).toHaveClass('badge', 'badge-l', 'badge-warning', 'system-2xs-medium-uppercase', 'custom-badge') + expect(badge).toHaveClass( + 'badge', + 'badge-l', + 'badge-warning', + 'system-2xs-medium-uppercase', + 'custom-badge', + ) expect(badge).toHaveStyle({ backgroundColor: 'rgb(0, 0, 255)' }) expect(badge).toHaveTextContent('Full Featured') }) diff --git a/web/app/components/base/badge/index.css b/web/app/components/base/badge/index.css index 3f221447f04622..599390dd1341dc 100644 --- a/web/app/components/base/badge/index.css +++ b/web/app/components/base/badge/index.css @@ -1,36 +1,36 @@ @utility badge { - @apply inline-flex justify-center items-center text-text-tertiary border border-divider-deep; + @apply inline-flex items-center justify-center border border-divider-deep text-text-tertiary; &.badge-warning { - @apply text-text-warning border border-text-warning; + @apply border border-text-warning text-text-warning; } &.badge-accent { - @apply text-text-accent-secondary border border-text-accent-secondary; + @apply border border-text-accent-secondary text-text-accent-secondary; } } @utility badge-l { - @apply rounded-md gap-1 min-w-6; + @apply min-w-6 gap-1 rounded-md; } @utility badge-m { /* m is for the regular button */ - @apply rounded-md gap-[3px] min-w-5; + @apply min-w-5 gap-[3px] rounded-md; } @utility badge-s { - @apply rounded-[5px] gap-0.5 min-w-[18px]; + @apply min-w-[18px] gap-0.5 rounded-[5px]; } @utility badge-warning { &.badge { - @apply text-text-warning border border-text-warning; + @apply border border-text-warning text-text-warning; } } @utility badge-accent { &.badge { - @apply text-text-accent-secondary border border-text-accent-secondary; + @apply border border-text-accent-secondary text-text-accent-secondary; } } diff --git a/web/app/components/base/badge/index.stories.tsx b/web/app/components/base/badge/index.stories.tsx index c6726ad173aabe..0fd0b55a96de0a 100644 --- a/web/app/components/base/badge/index.stories.tsx +++ b/web/app/components/base/badge/index.stories.tsx @@ -7,7 +7,8 @@ const meta = { parameters: { docs: { description: { - component: 'Compact label used for statuses and counts. Supports uppercase styling and optional red corner marks.', + component: + 'Compact label used for statuses and counts. Supports uppercase styling and optional red corner marks.', }, source: { language: 'tsx', @@ -47,7 +48,7 @@ export const WithCornerMark: Story = { } export const CustomContent: Story = { - render: args => ( + render: (args) => ( <Badge {...args} uppercase={false}> <span className="flex items-center gap-1"> <span className="size-2 rounded-full bg-emerald-400" /> diff --git a/web/app/components/base/badge/index.tsx b/web/app/components/base/badge/index.tsx index e6f17d9aee7c1f..56095f25828a00 100644 --- a/web/app/components/base/badge/index.tsx +++ b/web/app/components/base/badge/index.tsx @@ -10,21 +10,18 @@ enum BadgeState { Default = '', } -const BadgeVariants = cva( - 'badge', - { - variants: { - size: { - s: 'badge-s', - m: 'badge-m', - l: 'badge-l', - }, - }, - defaultVariants: { - size: 'm', +const BadgeVariants = cva('badge', { + variants: { + size: { + s: 'badge-s', + m: 'badge-m', + l: 'badge-l', }, }, -) + defaultVariants: { + size: 'm', + }, +}) type BadgeProps = Readonly<{ size?: 's' | 'm' | 'l' @@ -33,7 +30,9 @@ type BadgeProps = Readonly<{ state?: BadgeState styleCss?: CSSProperties children?: ReactNode -}> & React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof BadgeVariants> +}> & + React.HTMLAttributes<HTMLDivElement> & + VariantProps<typeof BadgeVariants> function getBadgeState(state: BadgeState) { switch (state) { @@ -58,11 +57,22 @@ const Badge: React.FC<BadgeProps> = ({ }) => { return ( <div - className={cn(BadgeVariants({ size, className }), getBadgeState(state), size === 's' - ? (iconOnly ? 'p-[3px]' : 'px-[5px] py-[3px]') - : size === 'l' - ? (iconOnly ? 'p-1.5' : 'px-2 py-1') - : (iconOnly ? 'p-1' : 'px-[5px] py-[2px]'), uppercase ? 'system-2xs-medium-uppercase' : 'system-2xs-medium')} + className={cn( + BadgeVariants({ size, className }), + getBadgeState(state), + size === 's' + ? iconOnly + ? 'p-[3px]' + : 'px-[5px] py-[3px]' + : size === 'l' + ? iconOnly + ? 'p-1.5' + : 'px-2 py-1' + : iconOnly + ? 'p-1' + : 'px-[5px] py-[2px]', + uppercase ? 'system-2xs-medium-uppercase' : 'system-2xs-medium', + )} style={styleCss} {...props} > diff --git a/web/app/components/base/block-input/index.tsx b/web/app/components/base/block-input/index.tsx index 1ca4a38e55311d..9c26ffc9de0cf1 100644 --- a/web/app/components/base/block-input/index.tsx +++ b/web/app/components/base/block-input/index.tsx @@ -4,15 +4,15 @@ const regex = /\{\{([^}]+)\}\}/g export const getInputKeys = (value: string) => { - const keys = value.match(regex)?.map((item) => { - return item.replace('{{', '').replace('}}', '') - }) || [] + const keys = + value.match(regex)?.map((item) => { + return item.replace('{{', '').replace('}}', '') + }) || [] const keyObj: Record<string, boolean> = {} // remove duplicate keys const res: string[] = [] keys.forEach((key) => { - if (keyObj[key]) - return + if (keyObj[key]) return keyObj[key] = true res.push(key) diff --git a/web/app/components/base/carousel/__tests__/index.spec.tsx b/web/app/components/base/carousel/__tests__/index.spec.tsx index f448d02825fc55..40ac3816cf6b04 100644 --- a/web/app/components/base/carousel/__tests__/index.spec.tsx +++ b/web/app/components/base/carousel/__tests__/index.spec.tsx @@ -46,7 +46,7 @@ const createMockEmblaApi = (): MockEmblaApi => ({ listeners[event].push(callback) }), off: vi.fn<(event: EmblaEventName, callback: EmblaListener) => void>((event, callback) => { - listeners[event] = listeners[event].filter(listener => listener !== callback) + listeners[event] = listeners[event].filter((listener) => listener !== callback) }), }) @@ -89,9 +89,9 @@ describe('Carousel', () => { listeners = { reInit: [], select: [] } mockApi = createMockEmblaApi() - mockedUseEmblaCarousel.mockReturnValue( - [mockCarouselRef, mockApi] as unknown as ReturnType<typeof useEmblaCarousel>, - ) + mockedUseEmblaCarousel.mockReturnValue([mockCarouselRef, mockApi] as unknown as ReturnType< + typeof useEmblaCarousel + >) }) // Rendering and basic semantic structure. @@ -117,19 +117,13 @@ describe('Carousel', () => { </Carousel>, ) - expect(mockedUseEmblaCarousel).toHaveBeenCalledWith( - { loop: true, axis: 'x' }, - [plugin], - ) + expect(mockedUseEmblaCarousel).toHaveBeenCalledWith({ loop: true, axis: 'x' }, [plugin]) }) it('should configure embla with vertical axis and vertical content classes when orientation is vertical', () => { renderCarouselWithControls('vertical') - expect(mockedUseEmblaCarousel).toHaveBeenCalledWith( - { axis: 'y' }, - undefined, - ) + expect(mockedUseEmblaCarousel).toHaveBeenCalledWith({ axis: 'y' }, undefined) expect(screen.getByTestId('carousel-content'))!.toHaveClass('flex-col') }) }) @@ -137,11 +131,15 @@ describe('Carousel', () => { // Ref API exposes embla and controls. describe('Ref API', () => { it('should expose carousel API and controls via ref', () => { - type CarouselRef = { api: unknown, selectedIndex: number } + type CarouselRef = { api: unknown; selectedIndex: number } const ref = { current: null as CarouselRef | null } render( - <Carousel ref={(r) => { ref.current = r as unknown as CarouselRef }}> + <Carousel + ref={(r) => { + ref.current = r as unknown as CarouselRef + }} + > <Carousel.Content /> </Carousel>, ) @@ -200,7 +198,7 @@ describe('Carousel', () => { const { unmount } = renderCarouselWithControls() const selectCallback = mockApi.on.mock.calls.find( - call => call[0] === 'select', + (call) => call[0] === 'select', )?.[1] as EmblaListener expect(mockApi.on).toHaveBeenCalledWith('reInit', expect.any(Function)) @@ -226,9 +224,9 @@ describe('Carousel', () => { }) it('should render with disabled controls and no dots when embla api is undefined', () => { - mockedUseEmblaCarousel.mockReturnValue( - [mockCarouselRef, undefined] as unknown as ReturnType<typeof useEmblaCarousel>, - ) + mockedUseEmblaCarousel.mockReturnValue([mockCarouselRef, undefined] as unknown as ReturnType< + typeof useEmblaCarousel + >) renderCarouselWithControls() diff --git a/web/app/components/base/carousel/index.tsx b/web/app/components/base/carousel/index.tsx index e729f3c8a82435..4473c677541b9b 100644 --- a/web/app/components/base/carousel/index.tsx +++ b/web/app/components/base/carousel/index.tsx @@ -32,8 +32,7 @@ const CarouselContext = React.createContext<CarouselContextValue | null>(null) function useCarousel() { const context = React.useContext(CarouselContext) - if (!context) - throw new Error('useCarousel must be used within a <Carousel />') + if (!context) throw new Error('useCarousel must be used within a <Carousel />') return context } @@ -68,12 +67,10 @@ const Carousel: TCarousel = React.forwardRef( }, [api]) React.useEffect(() => { - if (!api) - return + if (!api) return const onSelect = (api: CarouselApi) => { - if (!api) - return + if (!api) return setSelectedIndex(api.selectedScrollSnap()) setCanScrollPrev(api.canScrollPrev()) @@ -163,7 +160,8 @@ CarouselItem.displayName = 'CarouselItem' type CarouselActionProps = Readonly<{ children?: React.ReactNode -}> & Omit<React.HTMLAttributes<HTMLButtonElement>, 'disabled' | 'onClick'> +}> & + Omit<React.HTMLAttributes<HTMLButtonElement>, 'disabled' | 'onClick'> const CarouselPrevious = React.forwardRef<HTMLButtonElement, CarouselActionProps>( ({ children, ...props }, ref) => { diff --git a/web/app/components/base/chat/__tests__/utils.spec.ts b/web/app/components/base/chat/__tests__/utils.spec.ts index e435b28c3f6344..fb48fab69f2e08 100644 --- a/web/app/components/base/chat/__tests__/utils.spec.ts +++ b/web/app/components/base/chat/__tests__/utils.spec.ts @@ -298,15 +298,23 @@ describe('chat utils - url params and answer helpers', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('DecompressionStream', MockDecompressionStream) - vi.stubGlobal('TextDecoder', class { - decode() { return 'decompressed_text' } - }) + vi.stubGlobal( + 'TextDecoder', + class { + decode() { + return 'decompressed_text' + } + }, + ) const mockPipeThrough = vi.fn().mockReturnValue({}) - vi.stubGlobal('Response', class { - body = { pipeThrough: mockPipeThrough } - arrayBuffer = vi.fn().mockResolvedValue(new ArrayBuffer(8)) - }) + vi.stubGlobal( + 'Response', + class { + body = { pipeThrough: mockPipeThrough } + arrayBuffer = vi.fn().mockResolvedValue(new ArrayBuffer(8)) + }, + ) setSearch('') }) @@ -370,13 +378,17 @@ describe('chat utils - url params and answer helpers', () => { }) it('getProcessedSystemVariablesFromUrlParams parses redirect_url without query string', async () => { - setSearch(`?redirect_url=${encodeURIComponent('http://example.com')}&sys.param=${encodeURIComponent('YWJjZA==')}`) + setSearch( + `?redirect_url=${encodeURIComponent('http://example.com')}&sys.param=${encodeURIComponent('YWJjZA==')}`, + ) const res = await getProcessedSystemVariablesFromUrlParams() expect(res).toEqual({ param: 'decompressed_text' }) }) it('getProcessedSystemVariablesFromUrlParams parses redirect_url', async () => { - setSearch(`?redirect_url=${encodeURIComponent(`http://example.com?sys.redirected=${encodeURIComponent('YWJjZA==')}`)}&sys.param=${encodeURIComponent('YWJjZA==')}`) + setSearch( + `?redirect_url=${encodeURIComponent(`http://example.com?sys.redirected=${encodeURIComponent('YWJjZA==')}`)}&sys.param=${encodeURIComponent('YWJjZA==')}`, + ) const res = await getProcessedSystemVariablesFromUrlParams() expect(res).toEqual({ param: 'decompressed_text', redirected: 'decompressed_text' }) }) @@ -408,19 +420,39 @@ describe('chat utils - url params and answer helpers', () => { describe('Answer Validation', () => { it('isValidGeneratedAnswer returns true for typical answers', () => { - expect(isValidGeneratedAnswer({ isAnswer: true, id: '123', isOpeningStatement: false } as ChatItem)).toBe(true) + expect( + isValidGeneratedAnswer({ + isAnswer: true, + id: '123', + isOpeningStatement: false, + } as ChatItem), + ).toBe(true) }) it('isValidGeneratedAnswer returns false for placeholders', () => { - expect(isValidGeneratedAnswer({ isAnswer: true, id: 'answer-placeholder-123', isOpeningStatement: false } as ChatItem)).toBe(false) + expect( + isValidGeneratedAnswer({ + isAnswer: true, + id: 'answer-placeholder-123', + isOpeningStatement: false, + } as ChatItem), + ).toBe(false) }) it('isValidGeneratedAnswer returns false for opening statements', () => { - expect(isValidGeneratedAnswer({ isAnswer: true, id: '123', isOpeningStatement: true } as ChatItem)).toBe(false) + expect( + isValidGeneratedAnswer({ isAnswer: true, id: '123', isOpeningStatement: true } as ChatItem), + ).toBe(false) }) it('isValidGeneratedAnswer returns false for questions', () => { - expect(isValidGeneratedAnswer({ isAnswer: false, id: '123', isOpeningStatement: false } as ChatItem)).toBe(false) + expect( + isValidGeneratedAnswer({ + isAnswer: false, + id: '123', + isOpeningStatement: false, + } as ChatItem), + ).toBe(false) }) it('isValidGeneratedAnswer returns false for falsy items', () => { @@ -527,103 +559,133 @@ describe('chat utils - url params and answer helpers', () => { }) it('getThreadMessages flat path logic', () => { - const tree = [{ - id: 'q1', - isAnswer: false, - children: [{ - id: 'a1', - isAnswer: true, - siblingIndex: 0, - children: [{ - id: 'q2', - isAnswer: false, - children: [{ - id: 'a2', + const tree = [ + { + id: 'q1', + isAnswer: false, + children: [ + { + id: 'a1', isAnswer: true, siblingIndex: 0, - children: [], - }], - }], - }], - }] + children: [ + { + id: 'q2', + isAnswer: false, + children: [ + { + id: 'a2', + isAnswer: true, + siblingIndex: 0, + children: [], + }, + ], + }, + ], + }, + ], + }, + ] const thread = getThreadMessages(tree as unknown as ChatItemInTree[]) expect(thread.length).toBe(4) - expect(thread.map(t => t.id)).toEqual(['q1', 'a1', 'q2', 'a2']) + expect(thread.map((t) => t.id)).toEqual(['q1', 'a1', 'q2', 'a2']) expect(thread[1]!.siblingCount).toBe(1) expect(thread[3]!.siblingCount).toBe(1) }) it('getThreadMessages to specific target', () => { - const tree = [{ - id: 'q1', - isAnswer: false, - children: [{ - id: 'a1', - isAnswer: true, - siblingIndex: 0, - children: [{ - id: 'q2', - isAnswer: false, - children: [{ - id: 'a2', + const tree = [ + { + id: 'q1', + isAnswer: false, + children: [ + { + id: 'a1', isAnswer: true, siblingIndex: 0, - children: [], - }], - }, { - id: 'q3', - isAnswer: false, - children: [{ - id: 'a3', - isAnswer: true, - siblingIndex: 1, - children: [], - }], - }], - }], - }] + children: [ + { + id: 'q2', + isAnswer: false, + children: [ + { + id: 'a2', + isAnswer: true, + siblingIndex: 0, + children: [], + }, + ], + }, + { + id: 'q3', + isAnswer: false, + children: [ + { + id: 'a3', + isAnswer: true, + siblingIndex: 1, + children: [], + }, + ], + }, + ], + }, + ], + }, + ] const thread = getThreadMessages(tree as unknown as ChatItemInTree[], 'a3') expect(thread.length).toBe(4) - expect(thread.map(t => t.id)).toEqual(['q1', 'a1', 'q3', 'a3']) + expect(thread.map((t) => t.id)).toEqual(['q1', 'a1', 'q3', 'a3']) expect(thread[3]!.prevSibling).toBe('a2') expect(thread[3]!.nextSibling).toBeUndefined() }) it('getThreadMessages targetNode has descendants', () => { - const tree = [{ - id: 'q1', - isAnswer: false, - children: [{ - id: 'a1', - isAnswer: true, - siblingIndex: 0, - children: [{ - id: 'q2', - isAnswer: false, - children: [{ - id: 'a2', + const tree = [ + { + id: 'q1', + isAnswer: false, + children: [ + { + id: 'a1', isAnswer: true, siblingIndex: 0, - children: [], - }], - }, { - id: 'q3', - isAnswer: false, - children: [{ - id: 'a3', - isAnswer: true, - siblingIndex: 1, - children: [], - }], - }], - }], - }] + children: [ + { + id: 'q2', + isAnswer: false, + children: [ + { + id: 'a2', + isAnswer: true, + siblingIndex: 0, + children: [], + }, + ], + }, + { + id: 'q3', + isAnswer: false, + children: [ + { + id: 'a3', + isAnswer: true, + siblingIndex: 1, + children: [], + }, + ], + }, + ], + }, + ], + }, + ] const thread = getThreadMessages(tree as unknown as ChatItemInTree[], 'a1') expect(thread.length).toBe(4) - expect(thread.map(t => t.id)).toEqual(['q1', 'a1', 'q3', 'a3']) + expect(thread.map((t) => t.id)).toEqual(['q1', 'a1', 'q3', 'a3']) expect(thread[3]!.prevSibling).toBe('a2') }) }) diff --git a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx index c8752a7a6e7510..01390834a7f0d4 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx @@ -13,7 +13,6 @@ import { } from '@/service/share' import { TransferMethod } from '@/types/app' import { useChat } from '../../chat/hooks' - import { isValidGeneratedAnswer } from '../../utils' import ChatWrapper from '../chat-wrapper' import { useChatWithHistoryContext } from '../context' @@ -98,10 +97,14 @@ const defaultContextValue: ChatWithHistoryContextValue = { currentConversationItem: { id: '1', name: 'Conv 1' } as unknown as ConversationItem, appPrevChatTree: [], newConversationInputs: {}, - newConversationInputsRef: { current: {} } as ChatWithHistoryContextValue['newConversationInputsRef'], + newConversationInputsRef: { + current: {}, + } as ChatWithHistoryContextValue['newConversationInputsRef'], inputsForms: [], isInstalledApp: false, - currentChatInstanceRef: { current: { handleStop: vi.fn() } } as ChatWithHistoryContextValue['currentChatInstanceRef'], + currentChatInstanceRef: { + current: { handleStop: vi.fn() }, + } as ChatWithHistoryContextValue['currentChatInstanceRef'], setIsResponding: vi.fn(), setClearChatList: vi.fn(), appChatListDataLoading: false, @@ -156,7 +159,9 @@ describe('ChatWrapper', () => { }) vi.mocked(useChat).mockReturnValue({ ...defaultChatHookReturn, - chatList: [{ id: '1', isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q1', 'Q2'] }], + chatList: [ + { id: '1', isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q1', 'Q2'] }, + ], handleSend, suggestedQuestions: ['Q1', 'Q2'], } as unknown as ChatHookReturn) @@ -242,7 +247,9 @@ describe('ChatWrapper', () => { rerender(<ChatWrapper />) - const stopButton = await screen.findByRole('button', { name: /appDebug.operation.stopResponding/i }) + const stopButton = await screen.findByRole('button', { + name: /appDebug.operation.stopResponding/i, + }) fireEvent.click(stopButton) expect(handleStop).toHaveBeenCalled() }) @@ -254,7 +261,15 @@ describe('ChatWrapper', () => { ...defaultChatHookReturn, chatList: [ { id: 'q1', content: 'Q1' }, - { id: 'a1', isAnswer: true, content: 'A1', parentMessageId: 'q1', siblingCount: 2, siblingIndex: 0, nextSibling: 'a2' }, + { + id: 'a1', + isAnswer: true, + content: 'A1', + parentMessageId: 'q1', + siblingCount: 2, + siblingIndex: 0, + nextSibling: 'a2', + }, ], handleSend, handleSwitchSibling, @@ -263,7 +278,9 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector('button .ri-reset-left-line')?.parentElement + const regenerateBtn = answerContainer?.querySelector( + 'button .ri-reset-left-line', + )?.parentElement if (regenerateBtn) { fireEvent.click(regenerateBtn) expect(handleSend).toHaveBeenCalled() @@ -293,7 +310,9 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector('button .ri-reset-left-line')?.parentElement + const regenerateBtn = answerContainer?.querySelector( + 'button .ri-reset-left-line', + )?.parentElement if (regenerateBtn) { fireEvent.click(regenerateBtn) expect(handleSend).toHaveBeenCalled() @@ -325,7 +344,9 @@ describe('ChatWrapper', () => { ...defaultContextValue, inputsForms: [{ variable: 'req', label: 'Required', type: 'text-input', required: true }], newConversationInputs: {}, - newConversationInputsRef: { current: {} } as ChatWithHistoryContextValue['newConversationInputsRef'], + newConversationInputsRef: { + current: {}, + } as ChatWithHistoryContextValue['newConversationInputsRef'], currentConversationId: '', }) @@ -342,7 +363,9 @@ describe('ChatWrapper', () => { ...defaultContextValue, inputsForms: [{ variable: 'req', label: 'Required', type: 'text-input', required: true }], newConversationInputs: { req: 'value' }, - newConversationInputsRef: { current: { req: 'value' } } as ChatWithHistoryContextValue['newConversationInputsRef'], + newConversationInputsRef: { + current: { req: 'value' }, + } as ChatWithHistoryContextValue['newConversationInputsRef'], currentConversationId: '', }) @@ -356,12 +379,14 @@ describe('ChatWrapper', () => { it('should disable input when file is uploading', () => { vi.mocked(useChatWithHistoryContext).mockReturnValue({ ...defaultContextValue, - inputsForms: [{ - variable: 'file', - label: 'File', - type: InputVarType.singleFile, - required: true, - }], + inputsForms: [ + { + variable: 'file', + label: 'File', + type: InputVarType.singleFile, + required: true, + }, + ], newConversationInputsRef: { current: { file: { transferMethod: TransferMethod.local_file, uploadedId: undefined }, @@ -380,12 +405,14 @@ describe('ChatWrapper', () => { it('should not disable input when file is fully uploaded', () => { vi.mocked(useChatWithHistoryContext).mockReturnValue({ ...defaultContextValue, - inputsForms: [{ - variable: 'file', - label: 'File', - type: InputVarType.singleFile, - required: true, - }], + inputsForms: [ + { + variable: 'file', + label: 'File', + type: InputVarType.singleFile, + required: true, + }, + ], newConversationInputsRef: { current: { file: { transferMethod: TransferMethod.local_file, uploadedId: '123' }, @@ -403,12 +430,14 @@ describe('ChatWrapper', () => { it('should disable input when multiple files are uploading', () => { vi.mocked(useChatWithHistoryContext).mockReturnValue({ ...defaultContextValue, - inputsForms: [{ - variable: 'files', - label: 'Files', - type: InputVarType.multiFiles, - required: true, - }], + inputsForms: [ + { + variable: 'files', + label: 'Files', + type: InputVarType.multiFiles, + required: true, + }, + ], newConversationInputsRef: { current: { files: [ @@ -430,12 +459,14 @@ describe('ChatWrapper', () => { it('should not disable when all files are uploaded', () => { vi.mocked(useChatWithHistoryContext).mockReturnValue({ ...defaultContextValue, - inputsForms: [{ - variable: 'files', - label: 'Files', - type: InputVarType.multiFiles, - required: true, - }], + inputsForms: [ + { + variable: 'files', + label: 'Files', + type: InputVarType.multiFiles, + required: true, + }, + ], newConversationInputsRef: { current: { files: [ @@ -477,7 +508,9 @@ describe('ChatWrapper', () => { ...defaultContextValue, inputsForms: [{ variable: 'req', label: 'Required', type: 'text-input', required: true }], newConversationInputs: {}, - newConversationInputsRef: { current: {} } as ChatWithHistoryContextValue['newConversationInputsRef'], + newConversationInputsRef: { + current: {}, + } as ChatWithHistoryContextValue['newConversationInputsRef'], currentConversationId: '', allInputsHidden: true, }) @@ -498,14 +531,16 @@ describe('ChatWrapper', () => { vi.mocked(useChatWithHistoryContext).mockReturnValue({ ...defaultContextValue, - appPrevChatTree: [{ - id: '1', - content: 'Answer', - isAnswer: true, - workflow_run_id: 'w1', - humanInputFormDataList: [{ label: 'test' }] as unknown as HumanInputFormData[], - children: [], - }], + appPrevChatTree: [ + { + id: '1', + content: 'Answer', + isAnswer: true, + workflow_run_id: 'w1', + humanInputFormDataList: [{ label: 'test' }] as unknown as HumanInputFormData[], + children: [], + }, + ], }) render(<ChatWrapper />) @@ -522,14 +557,16 @@ describe('ChatWrapper', () => { vi.mocked(useChatWithHistoryContext).mockReturnValue({ ...defaultContextValue, - appPrevChatTree: [{ - id: 'resume-node', - content: 'Paused answer', - isAnswer: true, - workflow_run_id: 'workflow-1', - humanInputFormDataList: [{ label: 'resume' }] as unknown as HumanInputFormData[], - children: [], - }], + appPrevChatTree: [ + { + id: 'resume-node', + content: 'Paused answer', + isAnswer: true, + workflow_run_id: 'workflow-1', + humanInputFormDataList: [{ label: 'resume' }] as unknown as HumanInputFormData[], + children: [], + }, + ], }) render(<ChatWrapper />) @@ -537,7 +574,11 @@ describe('ChatWrapper', () => { expect(handleSwitchSibling).toHaveBeenCalledWith('resume-node', expect.any(Object)) const resumeOptions = handleSwitchSibling.mock.calls[0]![1] resumeOptions.onGetSuggestedQuestions('response-from-resume') - expect(fetchSuggestedQuestions).toHaveBeenCalledWith('response-from-resume', 'webApp', 'test-app-id') + expect(fetchSuggestedQuestions).toHaveBeenCalledWith( + 'response-from-resume', + 'webApp', + 'test-app-id', + ) }) it('should handle workflow resumption with nested children (DFS)', () => { @@ -550,28 +591,30 @@ describe('ChatWrapper', () => { vi.mocked(useChatWithHistoryContext).mockReturnValue({ ...defaultContextValue, - appPrevChatTree: [{ - id: '1', - content: 'First', - isAnswer: true, - children: [ - { - id: '2', - content: 'Second', - isAnswer: false, - children: [ - { - id: '3', - content: 'Third', - isAnswer: true, - workflow_run_id: 'w2', - humanInputFormDataList: [{ label: 'third' }] as unknown as HumanInputFormData[], - children: [], - }, - ], - }, - ], - }], + appPrevChatTree: [ + { + id: '1', + content: 'First', + isAnswer: true, + children: [ + { + id: '2', + content: 'Second', + isAnswer: false, + children: [ + { + id: '3', + content: 'Third', + isAnswer: true, + workflow_run_id: 'w2', + humanInputFormDataList: [{ label: 'third' }] as unknown as HumanInputFormData[], + children: [], + }, + ], + }, + ], + }, + ], }) render(<ChatWrapper />) @@ -588,12 +631,14 @@ describe('ChatWrapper', () => { vi.mocked(useChatWithHistoryContext).mockReturnValue({ ...defaultContextValue, - appPrevChatTree: [{ - id: '1', - content: 'Answer', - isAnswer: true, - children: [], - }], + appPrevChatTree: [ + { + id: '1', + content: 'Answer', + isAnswer: true, + children: [], + }, + ], }) render(<ChatWrapper />) @@ -633,7 +678,12 @@ describe('ChatWrapper', () => { const onStopCallback = vi.mocked(useChat).mock.calls[0]![3] as (taskId: string) => void onStopCallback('taskId-123') - expect(stopChatMessageResponding).toHaveBeenCalledWith('', 'taskId-123', 'webApp', 'test-app-id') + expect(stopChatMessageResponding).toHaveBeenCalledWith( + '', + 'taskId-123', + 'webApp', + 'test-app-id', + ) }) it('should call fetchSuggestedQuestions in doSend options', async () => { @@ -641,7 +691,9 @@ describe('ChatWrapper', () => { vi.mocked(useChat).mockReturnValue({ ...defaultChatHookReturn, handleSend, - chatList: [{ id: '1', isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q1'] }], + chatList: [ + { id: '1', isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q1'] }, + ], suggestedQuestions: ['Q1'], } as unknown as ChatHookReturn) @@ -671,7 +723,9 @@ describe('ChatWrapper', () => { vi.mocked(useChat).mockReturnValue({ ...defaultChatHookReturn, handleSend, - chatList: [{ id: '1', isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q1'] }], + chatList: [ + { id: '1', isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q1'] }, + ], suggestedQuestions: ['Q1'], } as unknown as ChatHookReturn) @@ -696,7 +750,9 @@ describe('ChatWrapper', () => { vi.mocked(useChat).mockReturnValue({ ...defaultChatHookReturn, handleSend, - chatList: [{ id: '1', isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q1'] }], + chatList: [ + { id: '1', isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q1'] }, + ], suggestedQuestions: ['Q1'], } as unknown as ChatHookReturn) @@ -721,7 +777,15 @@ describe('ChatWrapper', () => { handleSwitchSibling, chatList: [ { id: 'q1', content: 'Q1' }, - { id: 'a1', isAnswer: true, content: 'A1', parentMessageId: 'q1', siblingCount: 2, siblingIndex: 0, nextSibling: 'a2' }, + { + id: 'a1', + isAnswer: true, + content: 'A1', + parentMessageId: 'q1', + siblingCount: 2, + siblingIndex: 0, + nextSibling: 'a2', + }, ], } as unknown as ChatHookReturn) @@ -760,7 +824,9 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector('button .ri-reset-left-line')?.parentElement + const regenerateBtn = answerContainer?.querySelector( + 'button .ri-reset-left-line', + )?.parentElement if (regenerateBtn) { fireEvent.click(regenerateBtn) @@ -792,7 +858,9 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector('button .ri-reset-left-line')?.parentElement + const regenerateBtn = answerContainer?.querySelector( + 'button .ri-reset-left-line', + )?.parentElement if (regenerateBtn) { fireEvent.click(regenerateBtn) @@ -819,17 +887,28 @@ describe('ChatWrapper', () => { id: 'a1', isAnswer: true, content: '', - humanInputFormDataList: [{ - id: 'node1', - form_id: 'form1', - form_token: 'token1', - node_id: 'node1', - node_title: 'Node 1', - display_in_ui: true, - form_content: '{{#$output.test#}}', - inputs: [{ variable: 'test', label: 'Test', type: 'paragraph', required: true, output_variable_name: 'test', default: { type: 'text', value: '' } }], - actions: [{ id: 'run', title: 'Run', button_style: 'primary' }], - }] as unknown as HumanInputFormData[], + humanInputFormDataList: [ + { + id: 'node1', + form_id: 'form1', + form_token: 'token1', + node_id: 'node1', + node_title: 'Node 1', + display_in_ui: true, + form_content: '{{#$output.test#}}', + inputs: [ + { + variable: 'test', + label: 'Test', + type: 'paragraph', + required: true, + output_variable_name: 'test', + default: { type: 'text', value: '' }, + }, + ], + actions: [{ id: 'run', title: 'Run', button_style: 'primary' }], + }, + ] as unknown as HumanInputFormData[], }, ], } as unknown as ChatHookReturn) @@ -837,7 +916,9 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) expect(await screen.findByText('Node 1'))!.toBeInTheDocument() - const input = screen.getAllByRole('textbox').find(el => el.closest('.chat-answer-container')) || screen.getAllByRole('textbox')[0] + const input = + screen.getAllByRole('textbox').find((el) => el.closest('.chat-answer-container')) || + screen.getAllByRole('textbox')[0] fireEvent.change(input!, { target: { value: 'test' } }) const runButton = screen.getByText('Run') @@ -862,17 +943,28 @@ describe('ChatWrapper', () => { id: 'a1', isAnswer: true, content: '', - humanInputFormDataList: [{ - id: 'node1', - form_id: 'form1', - form_token: 'token-web-1', - node_id: 'node1', - node_title: 'Node Web 1', - display_in_ui: true, - form_content: '{{#$output.test#}}', - inputs: [{ variable: 'test', label: 'Test', type: 'paragraph', required: true, output_variable_name: 'test', default: { type: 'text', value: '' } }], - actions: [{ id: 'run', title: 'Run', button_style: 'primary' }], - }] as unknown as HumanInputFormData[], + humanInputFormDataList: [ + { + id: 'node1', + form_id: 'form1', + form_token: 'token-web-1', + node_id: 'node1', + node_title: 'Node Web 1', + display_in_ui: true, + form_content: '{{#$output.test#}}', + inputs: [ + { + variable: 'test', + label: 'Test', + type: 'paragraph', + required: true, + output_variable_name: 'test', + default: { type: 'text', value: '' }, + }, + ], + actions: [{ id: 'run', title: 'Run', button_style: 'primary' }], + }, + ] as unknown as HumanInputFormData[], }, ], } as unknown as ChatHookReturn) @@ -880,7 +972,9 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) expect(await screen.findByText('Node Web 1'))!.toBeInTheDocument() - const input = screen.getAllByRole('textbox').find(el => el.closest('.chat-answer-container')) || screen.getAllByRole('textbox')[0] + const input = + screen.getAllByRole('textbox').find((el) => el.closest('.chat-answer-container')) || + screen.getAllByRole('textbox')[0] fireEvent.change(input!, { target: { value: 'web-test' } }) fireEvent.click(screen.getByText('Run')) @@ -976,8 +1070,7 @@ describe('ChatWrapper', () => { if (welcomeElement) { const welcomeContainer = welcomeElement.closest('.min-h-\\[50vh\\]') expect(welcomeContainer).toBeNull() - } - else { + } else { expect(welcomeElement).toBeNull() } }) @@ -1079,7 +1172,9 @@ describe('ChatWrapper', () => { it('should set handleStop on currentChatInstanceRef', () => { const handleStop = vi.fn() - const currentChatInstanceRef = { current: { handleStop: vi.fn() } } as ChatWithHistoryContextValue['currentChatInstanceRef'] + const currentChatInstanceRef = { + current: { handleStop: vi.fn() }, + } as ChatWithHistoryContextValue['currentChatInstanceRef'] vi.mocked(useChatWithHistoryContext).mockReturnValue({ ...defaultContextValue, @@ -1155,7 +1250,9 @@ describe('ChatWrapper', () => { { variable: 'check', label: 'Checkbox', type: InputVarType.checkbox, required: true }, ], newConversationInputs: { check: true }, - newConversationInputsRef: { current: { check: true } } as ChatWithHistoryContextValue['newConversationInputsRef'], + newConversationInputsRef: { + current: { check: true }, + } as ChatWithHistoryContextValue['newConversationInputsRef'], currentConversationId: '', }) @@ -1217,7 +1314,14 @@ describe('ChatWrapper', () => { vi.mocked(useChat).mockReturnValue({ ...defaultChatHookReturn, chatList: [ - { id: 'a1', isAnswer: true, content: 'A1', siblingCount: 2, siblingIndex: 0, nextSibling: 'a2' }, + { + id: 'a1', + isAnswer: true, + content: 'A1', + siblingCount: 2, + siblingIndex: 0, + nextSibling: 'a2', + }, ], handleSwitchSibling, } as unknown as ChatHookReturn) @@ -1279,7 +1383,13 @@ describe('ChatWrapper', () => { }) vi.mocked(useChat).mockReturnValue({ ...defaultChatHookReturn, - chatList: [{ id: '1', isOpeningStatement: true, content: 'Custom introduction from conversation item' }], + chatList: [ + { + id: '1', + isOpeningStatement: true, + content: 'Custom introduction from conversation item', + }, + ], } as unknown as ChatHookReturn) render(<ChatWrapper />) @@ -1296,7 +1406,9 @@ describe('ChatWrapper', () => { { variable: 'field2', label: 'Field 2', type: 'text-input', required: true }, ], newConversationInputs: {}, - newConversationInputsRef: { current: {} } as ChatWithHistoryContextValue['newConversationInputsRef'], + newConversationInputsRef: { + current: {}, + } as ChatWithHistoryContextValue['newConversationInputsRef'], currentConversationId: '', }) @@ -1425,7 +1537,9 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector('button .ri-reset-left-line')?.parentElement + const regenerateBtn = answerContainer?.querySelector( + 'button .ri-reset-left-line', + )?.parentElement if (regenerateBtn) { fireEvent.click(regenerateBtn) // This tests line 198-200 when parentAnswer is not valid @@ -1444,7 +1558,14 @@ describe('ChatWrapper', () => { vi.mocked(useChat).mockReturnValue({ ...defaultChatHookReturn, chatList: [ - { id: 'a1', isAnswer: true, content: 'A1', siblingCount: 2, siblingIndex: 0, nextSibling: 'a2' }, + { + id: 'a1', + isAnswer: true, + content: 'A1', + siblingCount: 2, + siblingIndex: 0, + nextSibling: 'a2', + }, ], handleSwitchSibling, } as unknown as ChatHookReturn) @@ -1458,9 +1579,12 @@ describe('ChatWrapper', () => { if (nextButton) { fireEvent.click(nextButton) // This tests line 205 with existing conversation - expect(handleSwitchSibling).toHaveBeenCalledWith('a2', expect.objectContaining({ - onConversationComplete: undefined, - })) + expect(handleSwitchSibling).toHaveBeenCalledWith( + 'a2', + expect.objectContaining({ + onConversationComplete: undefined, + }), + ) } } }) @@ -1538,14 +1662,16 @@ describe('ChatWrapper', () => { ...defaultContextValue, currentConversationId: '', handleNewConversationCompleted, - appPrevChatTree: [{ - id: '1', - content: 'Answer', - isAnswer: true, - workflow_run_id: 'w1', - humanInputFormDataList: [{ label: 'test' }] as unknown as HumanInputFormData[], - children: [], - }], + appPrevChatTree: [ + { + id: '1', + content: 'Answer', + isAnswer: true, + workflow_run_id: 'w1', + humanInputFormDataList: [{ label: 'test' }] as unknown as HumanInputFormData[], + children: [], + }, + ], }) vi.mocked(useChat).mockReturnValue({ @@ -1555,9 +1681,12 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) - expect(handleSwitchSibling).toHaveBeenCalledWith('1', expect.objectContaining({ - onConversationComplete: handleNewConversationCompleted, - })) + expect(handleSwitchSibling).toHaveBeenCalledWith( + '1', + expect.objectContaining({ + onConversationComplete: handleNewConversationCompleted, + }), + ) }) it('should handle workflow resumption in existing conversation', () => { @@ -1568,14 +1697,16 @@ describe('ChatWrapper', () => { ...defaultContextValue, currentConversationId: '123', handleNewConversationCompleted, - appPrevChatTree: [{ - id: '1', - content: 'Answer', - isAnswer: true, - workflow_run_id: 'w1', - humanInputFormDataList: [{ label: 'test' }] as unknown as HumanInputFormData[], - children: [], - }], + appPrevChatTree: [ + { + id: '1', + content: 'Answer', + isAnswer: true, + workflow_run_id: 'w1', + humanInputFormDataList: [{ label: 'test' }] as unknown as HumanInputFormData[], + children: [], + }, + ], }) vi.mocked(useChat).mockReturnValue({ @@ -1585,9 +1716,12 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) - expect(handleSwitchSibling).toHaveBeenCalledWith('1', expect.objectContaining({ - onConversationComplete: undefined, - })) + expect(handleSwitchSibling).toHaveBeenCalledWith( + '1', + expect.objectContaining({ + onConversationComplete: undefined, + }), + ) }) it('should handle null appPrevChatTree', () => { @@ -1636,16 +1770,17 @@ describe('ChatWrapper', () => { vi.mocked(useChat).mockReturnValue({ ...defaultChatHookReturn, - chatList: [ - { id: 'q1', content: 'Question' }, - ], + chatList: [{ id: 'q1', content: 'Question' }], handleSend, } as unknown as ChatHookReturn) render(<ChatWrapper />) // Simulate regenerate with no parent - this tests line 190 with null - const regenerateBtn = screen.getByText('Question').closest('.chat-answer-container')?.querySelector('button .ri-reset-left-line')?.parentElement + const regenerateBtn = screen + .getByText('Question') + .closest('.chat-answer-container') + ?.querySelector('button .ri-reset-left-line')?.parentElement if (regenerateBtn) { fireEvent.click(regenerateBtn) } @@ -1726,7 +1861,9 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector('button .ri-reset-left-line')?.parentElement + const regenerateBtn = answerContainer?.querySelector( + 'button .ri-reset-left-line', + )?.parentElement if (regenerateBtn) { fireEvent.click(regenerateBtn) // This tests line 200 - question.message_files branch @@ -1747,7 +1884,14 @@ describe('ChatWrapper', () => { vi.mocked(useChat).mockReturnValue({ ...defaultChatHookReturn, chatList: [ - { id: 'a1', isAnswer: true, content: 'A1', siblingCount: 2, siblingIndex: 0, nextSibling: 'a2' }, + { + id: 'a1', + isAnswer: true, + content: 'A1', + siblingCount: 2, + siblingIndex: 0, + nextSibling: 'a2', + }, ], handleSwitchSibling, } as unknown as ChatHookReturn) @@ -1787,7 +1931,9 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector('button .ri-reset-left-line')?.parentElement + const regenerateBtn = answerContainer?.querySelector( + 'button .ri-reset-left-line', + )?.parentElement if (regenerateBtn) { fireEvent.click(regenerateBtn) // This tests line 200 when isValidGeneratedAnswer returns false @@ -1813,7 +1959,9 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector('button .ri-reset-left-line')?.parentElement + const regenerateBtn = answerContainer?.querySelector( + 'button .ri-reset-left-line', + )?.parentElement if (regenerateBtn) { fireEvent.click(regenerateBtn) // This tests line 200 when isValidGeneratedAnswer returns true @@ -1844,7 +1992,9 @@ describe('ChatWrapper', () => { render(<ChatWrapper />) const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector('button .ri-reset-left-line')?.parentElement + const regenerateBtn = answerContainer?.querySelector( + 'button .ri-reset-left-line', + )?.parentElement if (regenerateBtn) { fireEvent.click(regenerateBtn) // This tests line 190 - the isRegenerate ? parentAnswer?.id branch @@ -1868,7 +2018,9 @@ describe('ChatWrapper', () => { { variable: 'optional', label: 'Optional', type: 'text-input', required: false }, ], newConversationInputs: {}, - newConversationInputsRef: { current: {} } as ChatWithHistoryContextValue['newConversationInputsRef'], + newConversationInputsRef: { + current: {}, + } as ChatWithHistoryContextValue['newConversationInputsRef'], currentConversationId: '', }) @@ -1919,7 +2071,9 @@ describe('ChatWrapper', () => { appParams: undefined as unknown as ChatConfig, appId: '', currentConversationId: '', - currentChatInstanceRef: { current: null } as unknown as ChatWithHistoryContextValue['currentChatInstanceRef'], + currentChatInstanceRef: { + current: null, + } as unknown as ChatWithHistoryContextValue['currentChatInstanceRef'], }) vi.mocked(useChat).mockReturnValue({ diff --git a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx index 9e5b27ae76ff9a..5ae279fac2b377 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx @@ -21,7 +21,9 @@ vi.mock('@/hooks/use-breakpoints', () => ({ vi.mock('../context', () => ({ useChatWithHistoryContext: vi.fn(), - ChatWithHistoryContext: { Provider: ({ children }: { children: React.ReactNode }) => <div>{children}</div> }, + ChatWithHistoryContext: { + Provider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + }, })) vi.mock('@/next/navigation', () => ({ @@ -46,17 +48,14 @@ vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip // Mock Dialog to avoid Base UI focus/portal behavior in tests vi.mock('@langgenius/dify-ui/dialog', () => ({ - Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => { - if (!open) - return null - return ( - <div data-testid="modal"> - {children} - </div> - ) + Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => { + if (!open) return null + return <div data-testid="modal">{children}</div> }, DialogContent: ({ children }: { children: React.ReactNode }) => ( - <div role="dialog" data-testid="modal-content">{children}</div> + <div role="dialog" data-testid="modal-content"> + {children} + </div> ), DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, })) @@ -91,7 +90,9 @@ const defaultContextValue: ChatWithHistoryContextValue = { pinnedConversationList: [], conversationList: [], isInstalledApp: false, - currentChatInstanceRef: { current: { handleStop: vi.fn() } } as ChatWithHistoryContextValue['currentChatInstanceRef'], + currentChatInstanceRef: { + current: { handleStop: vi.fn() }, + } as ChatWithHistoryContextValue['currentChatInstanceRef'], setIsResponding: vi.fn(), setClearChatList: vi.fn(), appParams: { @@ -569,7 +570,7 @@ describe('HeaderInMobile', () => { const handleDelete = vi.fn() const useTranslationSpy = vi.spyOn(ReactI18next, 'useTranslation') useTranslationSpy.mockReturnValue({ - t: withSelectorKey((key: string) => key === 'chat.deleteConversation.content' ? '' : key), + t: withSelectorKey((key: string) => (key === 'chat.deleteConversation.content' ? '' : key)), i18n: {} as unknown as i18n, ready: true, tReady: true, @@ -588,11 +589,16 @@ describe('HeaderInMobile', () => { fireEvent.click(await screen.findByText('Conv 1')) fireEvent.click(await screen.findByText(/sidebar\.action\.delete/i)) - expect(await screen.findByRole('button', { name: /common\.operation\.confirm|operation\.confirm/i }))!.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /common\.operation\.confirm|operation\.confirm/i })) + expect( + await screen.findByRole('button', { + name: /common\.operation\.confirm|operation\.confirm/i, + }), + )!.toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { name: /common\.operation\.confirm|operation\.confirm/i }), + ) expect(handleDelete).toHaveBeenCalledWith('1', expect.any(Object)) - } - finally { + } finally { useTranslationSpy.mockRestore() } }) @@ -608,9 +614,12 @@ describe('HeaderInMobile', () => { }) const { container } = render(<HeaderInMobile />) - const operationTrigger = container.querySelector('.system-md-semibold')?.parentElement as HTMLElement + const operationTrigger = container.querySelector('.system-md-semibold') + ?.parentElement as HTMLElement fireEvent.click(operationTrigger) - fireEvent.click(await screen.findByText(/explore\.sidebar\.action\.rename|sidebar\.action\.rename/i)) + fireEvent.click( + await screen.findByText(/explore\.sidebar\.action\.rename|sidebar\.action\.rename/i), + ) const input = await screen.findByRole('textbox') expect(input)!.toHaveValue('') diff --git a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx index 35478bae7b6e9b..e84f05ddbffe25 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx @@ -43,7 +43,8 @@ const useWebAppStoreMock = vi.fn((selector?: (state: typeof mockStoreState) => u }) vi.mock('@/context/web-app-context', () => ({ - useWebAppStore: (selector?: (state: typeof mockStoreState) => unknown) => useWebAppStoreMock(selector), + useWebAppStore: (selector?: (state: typeof mockStoreState) => unknown) => + useWebAppStoreMock(selector), })) vi.mock('../../utils', async () => { @@ -84,13 +85,14 @@ const mockUnpinConversation = vi.mocked(unpinConversation) const mockRenameConversation = vi.mocked(renameConversation) const mockUpdateFeedback = vi.mocked(updateFeedback) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, }, - }, -}) + }) const createWrapper = (queryClient: QueryClient) => { return ({ children }: { children: ReactNode }) => ( @@ -109,7 +111,7 @@ const renderWithClient = async <T,>(hook: () => T) => { await act(async () => { result = renderHook(hook, { wrapper }) // Wait for the microtasks queue to empty out the initial query settling - await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) }) return { queryClient, @@ -125,7 +127,9 @@ const createConversationItem = (overrides: Partial<ConversationItem> = {}): Conv ...overrides, }) -const createConversationData = (overrides: Partial<AppConversationData> = {}): AppConversationData => ({ +const createConversationData = ( + overrides: Partial<AppConversationData> = {}, +): AppConversationData => ({ data: [createConversationItem()], has_more: false, limit: 100, @@ -136,7 +140,7 @@ const setConversationIdInfo = (appId: string, conversationId: string) => { const value = { [appId]: { 'user-1': conversationId, - 'DEFAULT': conversationId, + DEFAULT: conversationId, }, } localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify(value)) @@ -178,9 +182,11 @@ describe('useChatWithHistory', () => { const listData = createConversationData({ data: [createConversationItem({ id: 'conversation-1', name: 'First' })], }) - mockFetchConversations.mockImplementation(async (_isInstalledApp, _appId, _lastId, pinned) => { - return pinned ? pinnedData : listData - }) + mockFetchConversations.mockImplementation( + async (_isInstalledApp, _appId, _lastId, pinned) => { + return pinned ? pinnedData : listData + }, + ) mockFetchChatList.mockResolvedValue({ data: [] }) // Act @@ -188,13 +194,29 @@ describe('useChatWithHistory', () => { // Assert await waitFor(() => { - expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', undefined, true, 100) + expect(mockFetchConversations).toHaveBeenCalledWith( + AppSourceType.webApp, + 'app-1', + undefined, + true, + 100, + ) }) await waitFor(() => { - expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', undefined, false, 100) + expect(mockFetchConversations).toHaveBeenCalledWith( + AppSourceType.webApp, + 'app-1', + undefined, + false, + 100, + ) }) await waitFor(() => { - expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1') + expect(mockFetchChatList).toHaveBeenCalledWith( + 'conversation-1', + AppSourceType.webApp, + 'app-1', + ) }) await waitFor(() => { expect(result!.current.pinnedConversationList).toEqual(pinnedData.data) @@ -230,7 +252,11 @@ describe('useChatWithHistory', () => { // Assert await waitFor(() => { - expect(mockGenerationConversationName).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', 'conversation-new') + expect(mockGenerationConversationName).toHaveBeenCalledWith( + AppSourceType.webApp, + 'app-1', + 'conversation-new', + ) }) await waitFor(() => { expect(result!.current.conversationList[0]).toEqual(generatedConversation) @@ -248,7 +274,9 @@ describe('useChatWithHistory', () => { }) mockFetchConversations.mockResolvedValue(listData) mockFetchChatList.mockResolvedValue({ data: [] }) - mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-1' })) + mockGenerationConversationName.mockResolvedValue( + createConversationItem({ id: 'conversation-1' }), + ) const { result } = await renderWithClient(() => useChatWithHistory()) @@ -278,7 +306,9 @@ describe('useChatWithHistory', () => { }) mockFetchConversations.mockResolvedValue(listData) mockFetchChatList.mockResolvedValue({ data: [] }) - mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' })) + mockGenerationConversationName.mockResolvedValue( + createConversationItem({ id: 'conversation-new' }), + ) const { result } = await renderWithClient(() => useChatWithHistory()) @@ -370,7 +400,11 @@ describe('useChatWithHistory', () => { }) // Assert - expect(mockPinConversation).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', 'conversation-1') + expect(mockPinConversation).toHaveBeenCalledWith( + AppSourceType.webApp, + 'app-1', + 'conversation-1', + ) expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations }) }) @@ -389,7 +423,11 @@ describe('useChatWithHistory', () => { }) // Assert - expect(mockUnpinConversation).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', 'conversation-1') + expect(mockUnpinConversation).toHaveBeenCalledWith( + AppSourceType.webApp, + 'app-1', + 'conversation-1', + ) expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations }) }) }) @@ -411,7 +449,11 @@ describe('useChatWithHistory', () => { }) // Assert - expect(mockDelConversation).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', 'other-conversation') + expect(mockDelConversation).toHaveBeenCalledWith( + AppSourceType.webApp, + 'app-1', + 'other-conversation', + ) expect(onSuccess).toHaveBeenCalledTimes(1) }) @@ -424,7 +466,9 @@ describe('useChatWithHistory', () => { mockFetchConversations.mockResolvedValue(createConversationData()) mockFetchChatList.mockResolvedValue({ data: [] }) // First call blocks, second call should be rejected by guard - mockDelConversation.mockReturnValueOnce(deletePromise as unknown as ReturnType<typeof mockDelConversation>) + mockDelConversation.mockReturnValueOnce( + deletePromise as unknown as ReturnType<typeof mockDelConversation>, + ) const onSuccess = vi.fn() const { result } = await renderWithClient(() => useChatWithHistory()) @@ -494,7 +538,12 @@ describe('useChatWithHistory', () => { }) // Assert - expect(mockRenameConversation).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', 'conversation-1', 'New Name') + expect(mockRenameConversation).toHaveBeenCalledWith( + AppSourceType.webApp, + 'app-1', + 'conversation-1', + 'New Name', + ) expect(onSuccess).toHaveBeenCalledTimes(1) await waitFor(() => { expect(result!.current.conversationList[0]!.name).toBe('New Name') @@ -527,7 +576,9 @@ describe('useChatWithHistory', () => { }) mockFetchConversations.mockResolvedValue(createConversationData()) mockFetchChatList.mockResolvedValue({ data: [] }) - mockRenameConversation.mockReturnValueOnce(renamePromise as unknown as ReturnType<typeof mockRenameConversation>) + mockRenameConversation.mockReturnValueOnce( + renamePromise as unknown as ReturnType<typeof mockRenameConversation>, + ) const onSuccess = vi.fn() const { result } = await renderWithClient(() => useChatWithHistory()) @@ -597,9 +648,11 @@ describe('useChatWithHistory', () => { it('should show new conversation item in the conversation list', async () => { // Arrange - mockFetchConversations.mockResolvedValue(createConversationData({ - data: [createConversationItem({ id: 'conversation-1', name: 'First' })], - })) + mockFetchConversations.mockResolvedValue( + createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }), + ) mockFetchChatList.mockResolvedValue({ data: [] }) const { result } = await renderWithClient(() => useChatWithHistory()) @@ -626,7 +679,9 @@ describe('useChatWithHistory', () => { // Arrange mockFetchConversations.mockResolvedValue(createConversationData()) mockFetchChatList.mockResolvedValue({ data: [] }) - mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' })) + mockGenerationConversationName.mockResolvedValue( + createConversationItem({ id: 'conversation-new' }), + ) const { result } = await renderWithClient(() => useChatWithHistory()) @@ -869,7 +924,7 @@ describe('useChatWithHistory', () => { mockStoreState.appParams = { user_input_form: [ { - 'external_data_tool': true, + external_data_tool: true, 'text-input': { variable: 'text_var', label: 'Text', @@ -1184,7 +1239,9 @@ describe('useChatWithHistory', () => { const answerNode = result!.current.appPrevChatTree[0]?.children?.[0] expect(answerNode?.humanInputFormDataList).toHaveLength(0) expect(answerNode?.humanInputFilledFormDataList).toHaveLength(1) - expect(answerNode?.humanInputFilledFormDataList?.[0]?.form_content).toBe('{{#$output.summary#}}') + expect(answerNode?.humanInputFilledFormDataList?.[0]?.form_content).toBe( + '{{#$output.summary#}}', + ) expect(answerNode?.humanInputFilledFormDataList?.[0]?.inputs).toEqual([]) expect(answerNode?.workflow_run_id).toBe('wf-run-status-agnostic') }) @@ -1356,9 +1413,7 @@ describe('useChatWithHistory', () => { // Set up an input that looks like a file being uploaded act(() => { result!.current.handleNewConversationInputsChange({ - file_upload_var: [ - { transferMethod: 'local_file', uploadedId: null }, - ], + file_upload_var: [{ transferMethod: 'local_file', uploadedId: null }], }) }) @@ -1447,7 +1502,9 @@ describe('useChatWithHistory', () => { it('should truncate text-input value that exceeds max_length', async () => { // Arrange const { getRawInputsFromUrlParams } = await import('../../utils') - vi.mocked(getRawInputsFromUrlParams).mockResolvedValue({ text_var: 'exceeds_max_length_value' }) + vi.mocked(getRawInputsFromUrlParams).mockResolvedValue({ + text_var: 'exceeds_max_length_value', + }) mockStoreState.appParams = { user_input_form: [ @@ -1653,9 +1710,11 @@ describe('useChatWithHistory', () => { describe('setShowNewConversationItemInList', () => { it('should not prepend empty item when showNewConversationItemInList is false', async () => { // Arrange - mockFetchConversations.mockResolvedValue(createConversationData({ - data: [createConversationItem({ id: 'conversation-1', name: 'First' })], - })) + mockFetchConversations.mockResolvedValue( + createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }), + ) mockFetchChatList.mockResolvedValue({ data: [] }) const { result } = await renderWithClient(() => useChatWithHistory()) @@ -1701,9 +1760,7 @@ describe('useChatWithHistory', () => { // Set the input value to an array with a file still being uploaded act(() => { result!.current.handleNewConversationInputsChange({ - files_var: [ - { transferMethod: 'local_file', uploadedId: null }, - ], + files_var: [{ transferMethod: 'local_file', uploadedId: null }], }) }) @@ -1783,9 +1840,7 @@ describe('useChatWithHistory', () => { // File has been fully uploaded act(() => { result!.current.handleNewConversationInputsChange({ - files_var: [ - { transferMethod: 'local_file', uploadedId: 'uploaded-id-123' }, - ], + files_var: [{ transferMethod: 'local_file', uploadedId: 'uploaded-id-123' }], }) }) @@ -1904,13 +1959,17 @@ describe('useChatWithHistory', () => { await waitFor(() => { expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0) }) - const messageWithFiles = result!.current.appPrevChatTree.find(item => item.id === 'question-msg-files') + const messageWithFiles = result!.current.appPrevChatTree.find( + (item) => item.id === 'question-msg-files', + ) expect(messageWithFiles?.message_files).toHaveLength(1) expect(messageWithFiles?.children?.[0]?.message_files).toHaveLength(1) expect(messageWithFiles?.children?.[0]?.agent_thoughts?.[0]?.message_files).toHaveLength(1) const normalAnswerNode = messageWithFiles?.children?.[0] - const pausedAnswerNode = result!.current.appPrevChatTree.find(item => item.id === 'question-msg-paused-branch')?.children?.[0] + const pausedAnswerNode = result!.current.appPrevChatTree.find( + (item) => item.id === 'question-msg-paused-branch', + )?.children?.[0] expect(normalAnswerNode?.humanInputFilledFormDataList).toHaveLength(1) expect(normalAnswerNode?.humanInputFormDataList).toHaveLength(0) @@ -1924,11 +1983,15 @@ describe('useChatWithHistory', () => { describe('newConversation merge replace path', () => { it('should replace an existing conversation when generated conversation id already exists', async () => { // Arrange - mockFetchConversations.mockResolvedValue(createConversationData({ - data: [createConversationItem({ id: 'conversation-new', name: 'Old Name' })], - })) + mockFetchConversations.mockResolvedValue( + createConversationData({ + data: [createConversationItem({ id: 'conversation-new', name: 'Old Name' })], + }), + ) mockFetchChatList.mockResolvedValue({ data: [] }) - mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new', name: 'Updated Name' })) + mockGenerationConversationName.mockResolvedValue( + createConversationItem({ id: 'conversation-new', name: 'Updated Name' }), + ) const { result } = await renderWithClient(() => useChatWithHistory()) @@ -1967,7 +2030,9 @@ describe('useChatWithHistory', () => { it('should write conversation id under DEFAULT key when user id is missing', async () => { // Arrange const { getProcessedSystemVariablesFromUrlParams } = await import('../../utils') - vi.mocked(getProcessedSystemVariablesFromUrlParams).mockResolvedValueOnce({ user_id: undefined as unknown as string }) + vi.mocked(getProcessedSystemVariablesFromUrlParams).mockResolvedValueOnce({ + user_id: undefined as unknown as string, + }) mockFetchConversations.mockResolvedValue(createConversationData()) mockFetchChatList.mockResolvedValue({ data: [] }) @@ -1993,18 +2058,20 @@ describe('useChatWithHistory', () => { // Arrange mockFetchConversations.mockResolvedValue(createConversationData()) mockFetchChatList.mockResolvedValue({ - data: [{ - id: 'msg-no-inputs', - query: 'Q', - answer: 'A', - message_files: [], - feedback: null, - retriever_resources: [], - agent_thoughts: null, - parent_message_id: null, - status: 'normal', - extra_contents: [], - }], + data: [ + { + id: 'msg-no-inputs', + query: 'Q', + answer: 'A', + message_files: [], + feedback: null, + retriever_resources: [], + agent_thoughts: null, + parent_message_id: null, + status: 'normal', + extra_contents: [], + }, + ], }) // Act @@ -2024,7 +2091,10 @@ describe('useChatWithHistory', () => { // Act act(() => { - result!.current.newConversationInputsRef.current = undefined as unknown as Record<string, unknown> + result!.current.newConversationInputsRef.current = undefined as unknown as Record< + string, + unknown + > result!.current.handleChangeConversation('') }) @@ -2105,9 +2175,7 @@ describe('useChatWithHistory', () => { act(() => { result!.current.handleNewConversationInputsChange({ - files_var: [ - { transferMethod: 'local_file', uploadedId: null }, - ], + files_var: [{ transferMethod: 'local_file', uploadedId: null }], required_text: '', }) }) diff --git a/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx index c9398ee9272131..9ce503fe2491d2 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx @@ -67,10 +67,22 @@ const defaultHookReturn: HookReturn = { appData: mockAppData, appParams: {} as ChatConfig, appMeta: {} as AppMeta, - appPinnedConversationData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, - appConversationData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appPinnedConversationData: { + data: [] as ConversationItem[], + has_more: false, + limit: 20, + } as AppConversationData, + appConversationData: { + data: [] as ConversationItem[], + has_more: false, + limit: 20, + } as AppConversationData, appConversationDataLoading: false, - appChatListData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appChatListData: { + data: [] as ConversationItem[], + has_more: false, + limit: 20, + } as AppConversationData, appChatListDataLoading: false, appPrevChatTree: [], pinnedConversationList: [], diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 57a2f548a5cbf4..a87941e0c77751 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -1,10 +1,5 @@ import type { FileEntity } from '../../file-uploader/types' -import type { - ChatConfig, - ChatItem, - ChatItemInTree, - OnSend, -} from '../types' +import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '../types' import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react' @@ -63,9 +58,10 @@ const ChatWrapper = () => { } = useChatWithHistoryContext() const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp - const timezone = appSourceType === AppSourceType.webApp - ? new Intl.DateTimeFormat().resolvedOptions().timeZone - : undefined + const timezone = + appSourceType === AppSourceType.webApp + ? new Intl.DateTimeFormat().resolvedOptions().timeZone + : undefined // Semantic variable for better code readability const isHistoryConversation = !!currentConversationId @@ -97,54 +93,62 @@ const ChatWrapper = () => { inputsForm: inputsForms, }, appPrevChatTree, - taskId => stopChatMessageResponding('', taskId, appSourceType, appId), + (taskId) => stopChatMessageResponding('', taskId, appSourceType, appId), clearChatList, setClearChatList, undefined, { isNewAgent, timezone }, ) - const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current + const inputsFormValue = currentConversationId + ? currentConversationInputs + : newConversationInputsRef?.current const inputDisabled = useMemo(() => { - if (allInputsHidden) - return false + if (allInputsHidden) return false let hasEmptyInput = '' let fileIsUploading = false - const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox) + const requiredVars = inputsForms.filter( + ({ required, type }) => required && type !== InputVarType.checkbox, + ) if (requiredVars.length) { requiredVars.forEach(({ variable, label, type }) => { - if (hasEmptyInput) - return + if (hasEmptyInput) return - if (fileIsUploading) - return + if (fileIsUploading) return - if (!inputsFormValue?.[variable]) - hasEmptyInput = label as string + if (!inputsFormValue?.[variable]) hasEmptyInput = label as string - if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputsFormValue?.[variable]) { + if ( + (type === InputVarType.singleFile || type === InputVarType.multiFiles) && + inputsFormValue?.[variable] + ) { const files = inputsFormValue[variable] if (Array.isArray(files)) - fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId) + fileIsUploading = files.find( + (item) => item.transferMethod === TransferMethod.local_file && !item.uploadedId, + ) else - fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId + fileIsUploading = + files.transferMethod === TransferMethod.local_file && !files.uploadedId } }) } - if (hasEmptyInput) - return true + if (hasEmptyInput) return true - if (fileIsUploading) - return true + if (fileIsUploading) return true - if (chatList.some(item => item.isAnswer && item.humanInputFormDataList && item.humanInputFormDataList.length > 0)) + if ( + chatList.some( + (item) => + item.isAnswer && item.humanInputFormDataList && item.humanInputFormDataList.length > 0, + ) + ) return true return false }, [allInputsHidden, inputsForms, chatList, inputsFormValue]) useEffect(() => { - if (currentChatInstanceRef.current) - currentChatInstanceRef.current.handleStop = handleStop + if (currentChatInstanceRef.current) currentChatInstanceRef.current.handleStop = handleStop }, []) useEffect(() => { @@ -153,19 +157,22 @@ const ChatWrapper = () => { // Resume paused workflows when chat history is loaded useEffect(() => { - if (!appPrevChatTree || appPrevChatTree.length === 0) - return + if (!appPrevChatTree || appPrevChatTree.length === 0) return // Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first) let lastPausedNode: ChatItemInTree | undefined const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => { nodes.forEach((node) => { // DFS: recurse to children first - if (node.children && node.children.length > 0) - findLastPausedWorkflow(node.children) + if (node.children && node.children.length > 0) findLastPausedWorkflow(node.children) // Track the last node with humanInputFormDataList - if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0) + if ( + node.isAnswer && + node.workflow_run_id && + node.humanInputFormDataList && + node.humanInputFormDataList.length > 0 + ) lastPausedNode = node }) } @@ -174,14 +181,12 @@ const ChatWrapper = () => { // Only resume the last paused workflow if (lastPausedNode) { - handleSwitchSibling( - lastPausedNode.id, - { - onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), - onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, - isPublicAPI: appSourceType === AppSourceType.webApp, - }, - ) + handleSwitchSibling(lastPausedNode.id, { + onGetSuggestedQuestions: (responseItemId) => + fetchSuggestedQuestions(responseItemId, appSourceType, appId), + onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, + isPublicAPI: appSourceType === AppSourceType.webApp, + }) } }, []) @@ -189,62 +194,95 @@ const ChatWrapper = () => { const [prevConversationId, setPrevConversationId] = useState(currentConversationId) if (prevConversationId !== currentConversationId) { setPrevConversationId(currentConversationId) - if (!currentConversationId) - setHasSent(false) + if (!currentConversationId) setHasSent(false) } - const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { - if (!currentConversationId) - setHasSent(true) - const data: any = { - query: message, - files, - inputs: formatBooleanInputs(inputsForms, currentConversationId ? currentConversationInputs : newConversationInputs), - conversation_id: currentConversationId, - parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, - } - - handleSend( - getUrl('chat-messages', appSourceType, appId || ''), - data, - { + const doSend: OnSend = useCallback( + (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { + if (!currentConversationId) setHasSent(true) + const data: any = { + query: message, + files, + inputs: formatBooleanInputs( + inputsForms, + currentConversationId ? currentConversationInputs : newConversationInputs, + ), + conversation_id: currentConversationId, + parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, + } + + handleSend(getUrl('chat-messages', appSourceType, appId || ''), data, { onGetConversationMessages: isNewAgent - ? conversationId => fetchChatList(conversationId, appSourceType, appId) + ? (conversationId) => fetchChatList(conversationId, appSourceType, appId) : undefined, - onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), + onGetSuggestedQuestions: (responseItemId) => + fetchSuggestedQuestions(responseItemId, appSourceType, appId), onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted, isPublicAPI: appSourceType === AppSourceType.webApp, - }, - ) - }, [inputsForms, currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, isHistoryConversation, handleNewConversationCompleted, isNewAgent]) - - const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => { - const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! - const parentAnswer = chatList.find(item => item.id === question.parentMessageId) - doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) - }, [chatList, doSend]) - - const doSwitchSibling = useCallback((siblingMessageId: string) => { - handleSwitchSibling(siblingMessageId, { - onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), - onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, - isPublicAPI: appSourceType === AppSourceType.webApp, - }) - }, [handleSwitchSibling, currentConversationId, handleNewConversationCompleted, appSourceType, appId]) + }) + }, + [ + inputsForms, + currentConversationId, + currentConversationInputs, + newConversationInputs, + chatList, + handleSend, + appSourceType, + appId, + isHistoryConversation, + handleNewConversationCompleted, + isNewAgent, + ], + ) + + const doRegenerate = useCallback( + (chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => { + const question = editedQuestion + ? chatItem + : chatList.find((item) => item.id === chatItem.parentMessageId)! + const parentAnswer = chatList.find((item) => item.id === question.parentMessageId) + doSend( + editedQuestion ? editedQuestion.message : question.content, + editedQuestion ? editedQuestion.files : question.message_files, + true, + isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, + ) + }, + [chatList, doSend], + ) + + const doSwitchSibling = useCallback( + (siblingMessageId: string) => { + handleSwitchSibling(siblingMessageId, { + onGetSuggestedQuestions: (responseItemId) => + fetchSuggestedQuestions(responseItemId, appSourceType, appId), + onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, + isPublicAPI: appSourceType === AppSourceType.webApp, + }) + }, + [ + handleSwitchSibling, + currentConversationId, + handleNewConversationCompleted, + appSourceType, + appId, + ], + ) const messageList = useMemo(() => { - if (currentConversationId || chatList.length > 1) - return chatList + if (currentConversationId || chatList.length > 1) return chatList // Without messages we are in the welcome screen, so hide the opening statement from chatlist - return chatList.filter(item => !item.isOpeningStatement) + return chatList.filter((item) => !item.isOpeningStatement) }, [chatList, currentConversationId]) - const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => { - if (isInstalledApp) - await submitHumanInputFormService(formToken, formData) - else - await submitHumanInputForm(formToken, formData) - }, [isInstalledApp]) + const handleSubmitHumanInputForm = useCallback( + async (formToken: string, formData: any) => { + if (isInstalledApp) await submitHumanInputFormService(formToken, formData) + else await submitHumanInputForm(formToken, formData) + }, + [isInstalledApp], + ) const [collapsed, setCollapsed] = useState(!!currentConversationId) const [descExpanded, setDescExpanded] = useState(false) @@ -256,8 +294,7 @@ const ChatWrapper = () => { }, []) const descriptionNode = useMemo(() => { - if (!description || currentConversationId || hasSent) - return null + if (!description || currentConversationId || hasSent) return null return ( <div className={cn('flex flex-col items-center px-4 pt-6', isMobile && 'pt-4')}> <div className="w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md"> @@ -279,21 +316,19 @@ const ChatWrapper = () => { <button type="button" className="mt-0.5 flex items-center gap-0.5 system-xs-regular text-text-accent hover:opacity-80" - onClick={() => setDescExpanded(v => !v)} + onClick={() => setDescExpanded((v) => !v)} > - {descExpanded - ? ( - <> - <RiArrowUpSLine className="size-3" /> - {t($ => $['chat.collapse'], { ns: 'share' })} - </> - ) - : ( - <> - <RiArrowDownSLine className="size-3" /> - {t($ => $['chat.expand'], { ns: 'share' })} - </> - )} + {descExpanded ? ( + <> + <RiArrowUpSLine className="size-3" /> + {t(($) => $['chat.collapse'], { ns: 'share' })} + </> + ) : ( + <> + <RiArrowDownSLine className="size-3" /> + {t(($) => $['chat.expand'], { ns: 'share' })} + </> + )} </button> )} </div> @@ -303,34 +338,22 @@ const ChatWrapper = () => { }, [description, isMobile, currentConversationId, hasSent, descExpanded, showDescToggle, t]) const chatNode = useMemo(() => { - if (allInputsHidden || !inputsForms.length) - return null + if (allInputsHidden || !inputsForms.length) return null if (isMobile) { if (!currentConversationId) return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} /> return null - } - else { + } else { return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} /> } - }, [ - inputsForms.length, - isMobile, - currentConversationId, - collapsed, - allInputsHidden, - ]) + }, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden]) const welcome = useMemo(() => { - const welcomeMessage = chatList.find(item => item.isOpeningStatement) - if (respondingState) - return null - if (currentConversationId) - return null - if (!welcomeMessage) - return null - if (!collapsed && inputsForms.length > 0 && !allInputsHidden) - return null + const welcomeMessage = chatList.find((item) => item.isOpeningStatement) + if (respondingState) return null + if (currentConversationId) return null + if (!welcomeMessage) return null + if (!collapsed && inputsForms.length > 0 && !allInputsHidden) return null if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) { return ( <div className="flex min-h-[50vh] items-center justify-center px-4 py-12"> @@ -362,7 +385,10 @@ const ChatWrapper = () => { imageUrl={appData?.site.icon_url} /> <div className="max-w-[768px] px-4"> - <Markdown className="body-2xl-regular! text-text-tertiary!" content={welcomeMessage.content} /> + <Markdown + className="body-2xl-regular! text-text-tertiary!" + content={welcomeMessage.content} + /> </div> </div> ) @@ -379,21 +405,18 @@ const ChatWrapper = () => { allInputsHidden, ]) - const answerIcon = (appData?.site && appData.site.use_icon_as_answer_icon) - ? ( - <AnswerIcon - iconType={appData.site.icon_type} - icon={appData.site.icon} - background={appData.site.icon_background} - imageUrl={appData.site.icon_url} - /> - ) - : null + const answerIcon = + appData?.site && appData.site.use_icon_as_answer_icon ? ( + <AnswerIcon + iconType={appData.site.icon_type} + icon={appData.site.icon} + background={appData.site.icon_background} + imageUrl={appData.site.icon_url} + /> + ) : null return ( - <div - className="h-full overflow-hidden bg-chatbot-bg" - > + <div className="h-full overflow-hidden bg-chatbot-bg"> <Chat appData={appData ?? undefined} config={appConfig} @@ -403,18 +426,18 @@ const ChatWrapper = () => { chatFooterClassName="pb-4" chatFooterInnerClassName={`mx-auto w-full max-w-[768px] ${isMobile ? 'px-2' : 'px-4'}`} onSend={doSend} - inputs={currentConversationId ? currentConversationInputs as any : newConversationInputs} + inputs={currentConversationId ? (currentConversationInputs as any) : newConversationInputs} inputsForm={inputsForms} onRegenerate={doRegenerate} onStopResponding={handleStop} onHumanInputFormSubmit={handleSubmitHumanInputForm} - chatNode={( + chatNode={ <> {descriptionNode} {chatNode} {welcome} </> - )} + } allToolIcons={appMeta?.tool_icons || {}} onFeedback={handleFeedback} suggestedQuestions={suggestedQuestions} @@ -426,15 +449,13 @@ const ChatWrapper = () => { sidebarCollapseState={sidebarCollapseState} renderAgentContent={renderAgentContent} questionIcon={ - initUserVariables?.avatar_url - ? ( - <Avatar - avatar={initUserVariables.avatar_url} - name={initUserVariables.name || 'user'} - size="xl" - /> - ) - : undefined + initUserVariables?.avatar_url ? ( + <Avatar + avatar={initUserVariables.avatar_url} + name={initUserVariables.name || 'user'} + size="xl" + /> + ) : undefined } /> </div> diff --git a/web/app/components/base/chat/chat-with-history/context.ts b/web/app/components/base/chat/chat-with-history/context.ts index c27b23553bf1db..0f34afbd2543a8 100644 --- a/web/app/components/base/chat/chat-with-history/context.ts +++ b/web/app/components/base/chat/chat-with-history/context.ts @@ -3,18 +3,8 @@ import type { RefObject } from 'react' import type { ChatProps } from '../chat' import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context' -import type { - Callback, - ChatConfig, - ChatItemInTree, - Feedback, -} from '../types' -import type { - AppConversationData, - AppData, - AppMeta, - ConversationItem, -} from '@/models/share' +import type { Callback, ChatConfig, ChatItemInTree, Feedback } from '../types' +import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' import { noop } from 'es-toolkit/function' import { createContext, useContext } from 'use-context-selector' diff --git a/web/app/components/base/chat/chat-with-history/header-in-mobile.tsx b/web/app/components/base/chat/chat-with-history/header-in-mobile.tsx index e6eaf19a0a0271..85f9fdbc0cd90d 100644 --- a/web/app/components/base/chat/chat-with-history/header-in-mobile.tsx +++ b/web/app/components/base/chat/chat-with-history/header-in-mobile.tsx @@ -34,38 +34,44 @@ const HeaderInMobile = () => { inputsForms, } = useChatWithHistoryContext() const { t } = useTranslation() - const isPin = pinnedConversationList.some(item => item.id === currentConversationId) + const isPin = pinnedConversationList.some((item) => item.id === currentConversationId) const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null) const [showRename, setShowRename] = useState<ConversationItem | null>(null) - const handleOperate = useCallback((type: string) => { - if (type === 'pin') - handlePinConversation(currentConversationId) + const handleOperate = useCallback( + (type: string) => { + if (type === 'pin') handlePinConversation(currentConversationId) - if (type === 'unpin') - handleUnpinConversation(currentConversationId) + if (type === 'unpin') handleUnpinConversation(currentConversationId) - if (type === 'delete') - setShowConfirm(currentConversationItem as any) + if (type === 'delete') setShowConfirm(currentConversationItem as any) - if (type === 'rename') - setShowRename(currentConversationItem as any) - }, [currentConversationId, currentConversationItem, handlePinConversation, handleUnpinConversation]) + if (type === 'rename') setShowRename(currentConversationItem as any) + }, + [ + currentConversationId, + currentConversationItem, + handlePinConversation, + handleUnpinConversation, + ], + ) const handleCancelConfirm = useCallback(() => { setShowConfirm(null) }, []) const handleDelete = useCallback(() => { /* v8 ignore next 2 -- @preserve */ - if (showConfirm) - handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm }) + if (showConfirm) handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm }) }, [showConfirm, handleDeleteConversation, handleCancelConfirm]) const handleCancelRename = useCallback(() => { setShowRename(null) }, []) - const handleRename = useCallback((newName: string) => { - /* v8 ignore next 2 -- @preserve */ - if (showRename) - handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename }) - }, [showRename, handleRenameConversation, handleCancelRename]) + const handleRename = useCallback( + (newName: string) => { + /* v8 ignore next 2 -- @preserve */ + if (showRename) + handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename }) + }, + [showRename, handleRenameConversation, handleCancelRename], + ) const [showSidebar, setShowSidebar] = useState(false) const [showChatSettings, setShowChatSettings] = useState(false) @@ -115,7 +121,11 @@ const HeaderInMobile = () => { onClick={() => setShowSidebar(false)} data-testid="mobile-sidebar-overlay" > - <div className="flex h-full w-[calc(100vw-40px)] rounded-xl bg-components-panel-bg shadow-lg backdrop-blur-xs" onClick={e => e.stopPropagation()} data-testid="sidebar-content"> + <div + className="flex h-full w-[calc(100vw-40px)] rounded-xl bg-components-panel-bg shadow-lg backdrop-blur-xs" + onClick={(e) => e.stopPropagation()} + data-testid="sidebar-content" + > <Sidebar /> </div> </div> @@ -126,10 +136,15 @@ const HeaderInMobile = () => { onClick={() => setShowChatSettings(false)} data-testid="mobile-chat-settings-overlay" > - <div className="flex h-full w-[calc(100vw-40px)] flex-col rounded-xl bg-components-panel-bg shadow-lg backdrop-blur-xs" onClick={e => e.stopPropagation()}> + <div + className="flex h-full w-[calc(100vw-40px)] flex-col rounded-xl bg-components-panel-bg shadow-lg backdrop-blur-xs" + onClick={(e) => e.stopPropagation()} + > <div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-4 py-3"> <div className="i-custom-public-other-message-3-fill size-6 shrink-0" /> - <div className="grow system-xl-semibold text-text-secondary">{t($ => $['chat.chatSettingsTitle'], { ns: 'share' })}</div> + <div className="grow system-xl-semibold text-text-secondary"> + {t(($) => $['chat.chatSettingsTitle'], { ns: 'share' })} + </div> </div> <div className="p-4"> <InputsFormContent /> @@ -137,20 +152,22 @@ const HeaderInMobile = () => { </div> </div> )} - <AlertDialog open={!!showConfirm} onOpenChange={open => !open && handleCancelConfirm()}> + <AlertDialog open={!!showConfirm} onOpenChange={(open) => !open && handleCancelConfirm()}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['chat.deleteConversation.title'], { ns: 'share' })} + {t(($) => $['chat.deleteConversation.title'], { ns: 'share' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['chat.deleteConversation.content'], { ns: 'share' }) || ''} + {t(($) => $['chat.deleteConversation.content'], { ns: 'share' }) || ''} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={handleDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/base/chat/chat-with-history/header/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/header/__tests__/index.spec.tsx index fb39d337621421..b1335a13981da1 100644 --- a/web/app/components/base/chat/chat-with-history/header/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/header/__tests__/index.spec.tsx @@ -21,17 +21,14 @@ vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip // Mock Dialog to avoid Base UI focus/portal behavior in tests vi.mock('@langgenius/dify-ui/dialog', () => ({ - Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => { - if (!open) - return null - return ( - <div data-testid="modal"> - {children} - </div> - ) + Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => { + if (!open) return null + return <div data-testid="modal">{children}</div> }, DialogContent: ({ children }: { children: React.ReactNode }) => ( - <div role="dialog" data-testid="modal-content">{children}</div> + <div role="dialog" data-testid="modal-content"> + {children} + </div> ), DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, })) @@ -215,7 +212,11 @@ describe('Header Component', () => { const saveBtn = await screen.findByText('common.operation.save') await userEvent.click(saveBtn) - expect(handleRenameConversation).toHaveBeenCalledWith('conv-1', 'New Name', expect.any(Object)) + expect(handleRenameConversation).toHaveBeenCalledWith( + 'conv-1', + 'New Name', + expect.any(Object), + ) const successCallback = handleRenameConversation.mock.calls[0]![2].onSuccess await act(async () => { @@ -398,7 +399,9 @@ describe('Header Component', () => { sidebarCollapseState: true, }) - const operationTrigger = container.querySelector('.flex.cursor-pointer.items-center.rounded-lg.p-1\\.5.pl-2.text-text-secondary.hover\\:bg-state-base-hover') as HTMLElement + const operationTrigger = container.querySelector( + '.flex.cursor-pointer.items-center.rounded-lg.p-1\\.5.pl-2.text-text-secondary.hover\\:bg-state-base-hover', + ) as HTMLElement await userEvent.click(operationTrigger) await userEvent.click(await screen.findByText('explore.sidebar.action.rename')) diff --git a/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx index 60fc1a2b03b8d1..fa89be3fd0731d 100644 --- a/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx @@ -48,11 +48,7 @@ describe('Operation Component', () => { it('handles rename and delete visibility correctly', async () => { const user = userEvent.setup() const { rerender } = render( - <Operation - {...defaultProps} - isShowRenameConversation={false} - isShowDelete={false} - />, + <Operation {...defaultProps} isShowRenameConversation={false} isShowDelete={false} />, ) await user.click(screen.getByText('Chat Title')) diff --git a/web/app/components/base/chat/chat-with-history/header/index.tsx b/web/app/components/base/chat/chat-with-history/header/index.tsx index 71de6706baf7c1..de87b49cf99e1a 100644 --- a/web/app/components/base/chat/chat-with-history/header/index.tsx +++ b/web/app/components/base/chat/chat-with-history/header/index.tsx @@ -10,20 +10,14 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiEditBoxLine, - RiLayoutRight2Line, - RiResetLeftLine, -} from '@remixicon/react' +import { RiEditBoxLine, RiLayoutRight2Line, RiResetLeftLine } from '@remixicon/react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' import AppIcon from '@/app/components/base/app-icon' import ViewFormDropdown from '@/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown' import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal' -import { - useChatWithHistoryContext, -} from '../context' +import { useChatWithHistoryContext } from '../context' import Operation from './operation' const Header = () => { @@ -46,45 +40,60 @@ const Header = () => { const { t } = useTranslation() const isSidebarCollapsed = sidebarCollapseState - const isPin = pinnedConversationList.some(item => item.id === currentConversationId) + const isPin = pinnedConversationList.some((item) => item.id === currentConversationId) const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null) const [showRename, setShowRename] = useState<ConversationItem | null>(null) - const handleOperate = useCallback((type: string) => { - if (type === 'pin') - handlePinConversation(currentConversationId) + const handleOperate = useCallback( + (type: string) => { + if (type === 'pin') handlePinConversation(currentConversationId) - if (type === 'unpin') - handleUnpinConversation(currentConversationId) + if (type === 'unpin') handleUnpinConversation(currentConversationId) - if (type === 'delete') - setShowConfirm(currentConversationItem as any) + if (type === 'delete') setShowConfirm(currentConversationItem as any) - if (type === 'rename') - setShowRename(currentConversationItem as any) - }, [currentConversationId, currentConversationItem, handlePinConversation, handleUnpinConversation]) + if (type === 'rename') setShowRename(currentConversationItem as any) + }, + [ + currentConversationId, + currentConversationItem, + handlePinConversation, + handleUnpinConversation, + ], + ) const handleCancelConfirm = useCallback(() => { setShowConfirm(null) }, []) const handleDelete = useCallback(() => { /* v8 ignore next -- defensive guard; onConfirm is only reachable when showConfirm is truthy. @preserve */ - if (showConfirm) - handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm }) + if (showConfirm) handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm }) }, [showConfirm, handleDeleteConversation, handleCancelConfirm]) const handleCancelRename = useCallback(() => { setShowRename(null) }, []) - const handleRename = useCallback((newName: string) => { - /* v8 ignore next -- defensive guard; onSave is only reachable when showRename is truthy. @preserve */ - if (showRename) - handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename }) - }, [showRename, handleRenameConversation, handleCancelRename]) + const handleRename = useCallback( + (newName: string) => { + /* v8 ignore next -- defensive guard; onSave is only reachable when showRename is truthy. @preserve */ + if (showRename) + handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename }) + }, + [showRename, handleRenameConversation, handleCancelRename], + ) return ( <> <div className="flex h-14 shrink-0 items-center justify-between p-3"> - <div className={cn('flex items-center gap-1 transition-all duration-200 ease-in-out', !isSidebarCollapsed && 'user-select-none opacity-0')}> - <ActionButton className={cn(!isSidebarCollapsed && 'cursor-default')} size="l" onClick={() => handleSidebarCollapse(false)}> + <div + className={cn( + 'flex items-center gap-1 transition-all duration-200 ease-in-out', + !isSidebarCollapsed && 'user-select-none opacity-0', + )} + > + <ActionButton + className={cn(!isSidebarCollapsed && 'cursor-default')} + size="l" + onClick={() => handleSidebarCollapse(false)} + > <RiLayoutRight2Line className="h-[18px] w-[18px]" /> </ActionButton> <div className="mr-1 shrink-0"> @@ -97,7 +106,9 @@ const Header = () => { /> </div> {!currentConversationId && ( - <div className={cn('grow truncate system-md-semibold text-text-secondary')}>{appData?.site.title}</div> + <div className={cn('grow truncate system-md-semibold text-text-secondary')}> + {appData?.site.title} + </div> )} {currentConversationId && currentConversationItem && isSidebarCollapsed && ( <> @@ -120,22 +131,24 @@ const Header = () => { <Tooltip> <TooltipTrigger disabled={!!currentConversationId} - render={( + render={ <div> <ActionButton size="l" - state={(!currentConversationId || isResponding) ? ActionButtonState.Disabled : ActionButtonState.Default} + state={ + !currentConversationId || isResponding + ? ActionButtonState.Disabled + : ActionButtonState.Default + } disabled={!currentConversationId || isResponding} onClick={handleNewConversation} > <RiEditBoxLine className="h-[18px] w-[18px]" /> </ActionButton> </div> - )} + } /> - <TooltipContent> - {t($ => $['chat.newChatTip'], { ns: 'share' })} - </TooltipContent> + <TooltipContent>{t(($) => $['chat.newChatTip'], { ns: 'share' })}</TooltipContent> </Tooltip> )} </div> @@ -143,36 +156,34 @@ const Header = () => { {currentConversationId && ( <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton size="l" onClick={handleNewConversation}> <RiResetLeftLine className="h-[18px] w-[18px]" /> </ActionButton> - )} + } /> - <TooltipContent> - {t($ => $['chat.resetChat'], { ns: 'share' })} - </TooltipContent> + <TooltipContent>{t(($) => $['chat.resetChat'], { ns: 'share' })}</TooltipContent> </Tooltip> )} - {currentConversationId && inputsForms.length > 0 && ( - <ViewFormDropdown /> - )} + {currentConversationId && inputsForms.length > 0 && <ViewFormDropdown />} </div> </div> - <AlertDialog open={!!showConfirm} onOpenChange={open => !open && handleCancelConfirm()}> + <AlertDialog open={!!showConfirm} onOpenChange={(open) => !open && handleCancelConfirm()}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['chat.deleteConversation.title'], { ns: 'share' })} + {t(($) => $['chat.deleteConversation.title'], { ns: 'share' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['chat.deleteConversation.content'], { ns: 'share' }) || ''} + {t(($) => $['chat.deleteConversation.content'], { ns: 'share' }) || ''} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={handleDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx b/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx index c43d008c64cb0e..bf7bb20f929b55 100644 --- a/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx +++ b/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx @@ -27,43 +27,35 @@ const MobileOperationDropdown = ({ }, []) return ( - <DropdownMenu - open={open} - onOpenChange={setOpen} - > + <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger - render={( + render={ <ActionButton - aria-label={t($ => $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.more'], { ns: 'common' })} size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default} > <div className="i-ri-more-fill h-[18px] w-[18px]" aria-hidden="true" /> </ActionButton> - )} + } /> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="min-w-[160px]" - > + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[160px]"> <DropdownMenuItem className="system-md-regular" onClick={() => handleMenuAction(handleResetChat)} > - <span className="grow">{t($ => $['chat.resetChat'], { ns: 'share' })}</span> + <span className="grow">{t(($) => $['chat.resetChat'], { ns: 'share' })}</span> </DropdownMenuItem> {!hideViewChatSettings && ( <DropdownMenuItem className="system-md-regular" onClick={() => handleMenuAction(handleViewChatSettings)} > - <span className="grow">{t($ => $['chat.viewChatSettings'], { ns: 'share' })}</span> + <span className="grow">{t(($) => $['chat.viewChatSettings'], { ns: 'share' })}</span> </DropdownMenuItem> )} </DropdownMenuContent> </DropdownMenu> - ) } diff --git a/web/app/components/base/chat/chat-with-history/header/operation.tsx b/web/app/components/base/chat/chat-with-history/header/operation.tsx index bd31b2ba5afaaf..c71849c6d9bc6f 100644 --- a/web/app/components/base/chat/chat-with-history/header/operation.tsx +++ b/web/app/components/base/chat/chat-with-history/header/operation.tsx @@ -39,26 +39,24 @@ const Operation: FC<Props> = ({ return ( <DropdownMenu> - <DropdownMenuTrigger - className="flex cursor-pointer items-center rounded-lg border-none bg-transparent p-1.5 pl-2 text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover" - > + <DropdownMenuTrigger className="flex cursor-pointer items-center rounded-lg border-none bg-transparent p-1.5 pl-2 text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"> <span className="system-md-semibold">{title}</span> <span aria-hidden className="i-ri-arrow-down-s-line size-4" /> </DropdownMenuTrigger> - <DropdownMenuContent - placement={placement} - sideOffset={4} - popupClassName="min-w-[120px]" - > + <DropdownMenuContent placement={placement} sideOffset={4} popupClassName="min-w-[120px]"> <DropdownMenuItem className="system-md-regular" onClick={togglePin}> - <span className="grow">{isPinned ? t($ => $['sidebar.action.unpin'], { ns: 'explore' }) : t($ => $['sidebar.action.pin'], { ns: 'explore' })}</span> + <span className="grow"> + {isPinned + ? t(($) => $['sidebar.action.unpin'], { ns: 'explore' }) + : t(($) => $['sidebar.action.pin'], { ns: 'explore' })} + </span> </DropdownMenuItem> {isShowRenameConversation && ( <DropdownMenuItem className="system-md-regular" onClick={() => onRenameConversation && deferAction(onRenameConversation)} > - <span className="grow">{t($ => $['sidebar.action.rename'], { ns: 'explore' })}</span> + <span className="grow">{t(($) => $['sidebar.action.rename'], { ns: 'explore' })}</span> </DropdownMenuItem> )} {isShowDelete && ( @@ -67,7 +65,7 @@ const Operation: FC<Props> = ({ className="system-md-regular" onClick={() => deferAction(onDelete)} > - <span className="grow">{t($ => $['sidebar.action.delete'], { ns: 'explore' })}</span> + <span className="grow">{t(($) => $['sidebar.action.delete'], { ns: 'explore' })}</span> </DropdownMenuItem> )} </DropdownMenuContent> diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index 3d71f95530f621..ef451464e089eb 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -8,68 +8,105 @@ import { noop } from 'es-toolkit/function' import { produce } from 'immer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useConversationIdInfo, useWebAppSidebarCollapseState } from '@/app/components/base/chat/storage' +import { + useConversationIdInfo, + useWebAppSidebarCollapseState, +} from '@/app/components/base/chat/storage' import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { InputVarType } from '@/app/components/workflow/types' import { useWebAppStore } from '@/context/web-app-context' import { useAppFavicon } from '@/hooks/use-app-favicon' import { changeLanguage } from '@/i18n-config/client' -import { AppSourceType, delConversation, pinConversation, renameConversation, unpinConversation, updateFeedback } from '@/service/share' -import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share' +import { + AppSourceType, + delConversation, + pinConversation, + renameConversation, + unpinConversation, + updateFeedback, +} from '@/service/share' +import { + useInvalidateShareConversations, + useShareChatList, + useShareConversationName, + useShareConversations, +} from '@/service/use-share' import { TransferMethod } from '@/types/app' import { addFileInfos, sortAgentSorts } from '../../../tools/utils' import { enrichSubmittedHumanInputFormData } from '../chat/answer/human-input-content/submitted-utils' -import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils' +import { + buildChatItemTree, + getProcessedSystemVariablesFromUrlParams, + getRawInputsFromUrlParams, + getRawUserVariablesFromUrlParams, +} from '../utils' function getFormattedChatList(messages: any[]) { const newChatList: ChatItem[] = [] messages.forEach((item) => { - const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || [] + const questionFiles = + item.message_files?.filter((file: any) => file.belongs_to === 'user') || [] newChatList.push({ id: `question-${item.id}`, content: item.query, isAnswer: false, - message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))), + message_files: getProcessedFilesFromResponse( + questionFiles.map((item: any) => ({ + ...item, + related_id: item.id, + upload_file_id: item.upload_file_id, + })), + ), parentMessageId: item.parent_message_id || undefined, }) - const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] + const answerFiles = + item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] const humanInputFormDataList: HumanInputFormData[] = [] const humanInputFilledFormDataList: HumanInputFilledFormData[] = [] let workflowRunId = '' item.extra_contents?.forEach((content: ExtraContent) => { - if (content.type !== 'human_input') - return + if (content.type !== 'human_input') return const formDefinition = 'form_definition' in content ? content.form_definition : undefined if (!content.submitted) { - if (!formDefinition) - return + if (!formDefinition) return humanInputFormDataList.push(formDefinition) workflowRunId = content.workflow_run_id || workflowRunId return } - if (!('form_submission_data' in content) || !content.form_submission_data) - return - const currentFormIndex = humanInputFormDataList.findIndex(item => item.node_id === content.form_submission_data.node_id) - const requiredFormData = formDefinition || (currentFormIndex > -1 - ? humanInputFormDataList[currentFormIndex] - : undefined) - if (currentFormIndex > -1) - humanInputFormDataList.splice(currentFormIndex, 1) + if (!('form_submission_data' in content) || !content.form_submission_data) return + const currentFormIndex = humanInputFormDataList.findIndex( + (item) => item.node_id === content.form_submission_data.node_id, + ) + const requiredFormData = + formDefinition || + (currentFormIndex > -1 ? humanInputFormDataList[currentFormIndex] : undefined) + if (currentFormIndex > -1) humanInputFormDataList.splice(currentFormIndex, 1) workflowRunId = content.workflow_run_id || workflowRunId - humanInputFilledFormDataList.push(enrichSubmittedHumanInputFormData(content.form_submission_data, requiredFormData)) + humanInputFilledFormDataList.push( + enrichSubmittedHumanInputFormData(content.form_submission_data, requiredFormData), + ) }) newChatList.push({ id: item.id, content: item.answer, - agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files), + agent_thoughts: addFileInfos( + item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, + item.message_files, + ), feedback: item.feedback, isAnswer: true, citation: item.retriever_resources, reasoningContent: item.metadata?.reasoning, reasoningFinished: true, - message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))), + message_files: getProcessedFilesFromResponse( + answerFiles.map((item: any) => ({ + ...item, + related_id: item.id, + upload_file_id: item.upload_file_id, + })), + ), parentMessageId: `question-${item.id}`, humanInputFormDataList, humanInputFilledFormDataList, @@ -81,9 +118,9 @@ function getFormattedChatList(messages: any[]) { export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp - const appInfo = useWebAppStore(s => s.appInfo) - const appParams = useWebAppStore(s => s.appParams) - const appMeta = useWebAppStore(s => s.appMeta) + const appInfo = useWebAppStore((s) => s.appInfo) + const appParams = useWebAppStore((s) => s.appParams) + const appMeta = useWebAppStore((s) => s.appMeta) useAppFavicon({ enable: !installedAppInfo, icon_type: appInfo?.site.icon_type, @@ -123,74 +160,94 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { }, []) useEffect(() => { const setLocaleFromProps = async () => { - if (appData?.site.default_language) - await changeLanguage(appData.site.default_language) + if (appData?.site.default_language) await changeLanguage(appData.site.default_language) } setLocaleFromProps() }, [appData]) - const [storedSidebarCollapseState, setStoredSidebarCollapseState] = useWebAppSidebarCollapseState() + const [storedSidebarCollapseState, setStoredSidebarCollapseState] = + useWebAppSidebarCollapseState() const sidebarCollapseState = storedSidebarCollapseState === 'collapsed' - const handleSidebarCollapse = useCallback((state: boolean) => { - if (appId) - setStoredSidebarCollapseState(state ? 'collapsed' : 'expanded') - }, [appId, setStoredSidebarCollapseState]) + const handleSidebarCollapse = useCallback( + (state: boolean) => { + if (appId) setStoredSidebarCollapseState(state ? 'collapsed' : 'expanded') + }, + [appId, setStoredSidebarCollapseState], + ) const [conversationIdInfo, setConversationIdInfo] = useConversationIdInfo() - const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || '', [appId, conversationIdInfo, userId]) - const handleConversationIdInfoChange = useCallback((changeConversationId: string) => { - if (appId) { - let prevValue = conversationIdInfo?.[appId || ''] - if (typeof prevValue === 'string') - prevValue = {} - setConversationIdInfo({ - ...conversationIdInfo, - [appId || '']: { - ...prevValue, - [userId || 'DEFAULT']: changeConversationId, - }, - }) - } - }, [appId, conversationIdInfo, setConversationIdInfo, userId]) + const currentConversationId = useMemo( + () => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || '', + [appId, conversationIdInfo, userId], + ) + const handleConversationIdInfoChange = useCallback( + (changeConversationId: string) => { + if (appId) { + let prevValue = conversationIdInfo?.[appId || ''] + if (typeof prevValue === 'string') prevValue = {} + setConversationIdInfo({ + ...conversationIdInfo, + [appId || '']: { + ...prevValue, + [userId || 'DEFAULT']: changeConversationId, + }, + }) + } + }, + [appId, conversationIdInfo, setConversationIdInfo, userId], + ) const [newConversationId, setNewConversationId] = useState('') const chatShouldReloadKey = useMemo(() => { - if (currentConversationId === newConversationId) - return '' + if (currentConversationId === newConversationId) return '' return currentConversationId }, [currentConversationId, newConversationId]) - const { data: appPinnedConversationData } = useShareConversations({ - appSourceType, - appId, - pinned: true, - limit: 100, - }, { - enabled: !!appId, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }) - const { data: appConversationData, isLoading: appConversationDataLoading } = useShareConversations({ - appSourceType, - appId, - pinned: false, - limit: 100, - }, { - enabled: !!appId, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }) - const { data: appChatListData, isLoading: appChatListDataLoading } = useShareChatList({ - conversationId: chatShouldReloadKey, - appSourceType, - appId, - }, { - enabled: !!chatShouldReloadKey, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }) + const { data: appPinnedConversationData } = useShareConversations( + { + appSourceType, + appId, + pinned: true, + limit: 100, + }, + { + enabled: !!appId, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ) + const { data: appConversationData, isLoading: appConversationDataLoading } = + useShareConversations( + { + appSourceType, + appId, + pinned: false, + limit: 100, + }, + { + enabled: !!appId, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ) + const { data: appChatListData, isLoading: appChatListDataLoading } = useShareChatList( + { + conversationId: chatShouldReloadKey, + appSourceType, + appId, + }, + { + enabled: !!chatShouldReloadKey, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ) const invalidateShareConversations = useInvalidateShareConversations() const [clearChatList, setClearChatList] = useState(false) const [isResponding, setIsResponding] = useState(false) - const appPrevChatTree = useMemo(() => (currentConversationId && appChatListData?.data.length) - ? buildChatItemTree(getFormattedChatList(appChatListData.data)) - : [], [appChatListData, currentConversationId]) + const appPrevChatTree = useMemo( + () => + currentConversationId && appChatListData?.data.length + ? buildChatItemTree(getFormattedChatList(appChatListData.data)) + : [], + [appChatListData, currentConversationId], + ) const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false) const pinnedConversationList = useMemo(() => { return appPinnedConversationData?.data || [] @@ -206,75 +263,79 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { setNewConversationInputs(newInputs) }, []) const inputsForms = useMemo(() => { - return (appParams?.user_input_form || []).filter((item: any) => !item.external_data_tool).map((item: any) => { - if (item.paragraph) { - let value = initInputs[item.paragraph.variable] - if (value && item.paragraph.max_length && value.length > item.paragraph.max_length) - value = value.slice(0, item.paragraph.max_length) - return { - ...item.paragraph, - default: value || item.default || item.paragraph.default, - type: 'paragraph', + return (appParams?.user_input_form || []) + .filter((item: any) => !item.external_data_tool) + .map((item: any) => { + if (item.paragraph) { + let value = initInputs[item.paragraph.variable] + if (value && item.paragraph.max_length && value.length > item.paragraph.max_length) + value = value.slice(0, item.paragraph.max_length) + return { + ...item.paragraph, + default: value || item.default || item.paragraph.default, + type: 'paragraph', + } } - } - if (item.number) { - const convertedNumber = Number(initInputs[item.number.variable]) - return { - ...item.number, - default: convertedNumber || item.default || item.number.default, - type: 'number', + if (item.number) { + const convertedNumber = Number(initInputs[item.number.variable]) + return { + ...item.number, + default: convertedNumber || item.default || item.number.default, + type: 'number', + } } - } - if (item.checkbox) { - const preset = initInputs[item.checkbox.variable] === true - return { - ...item.checkbox, - default: preset || item.default || item.checkbox.default, - type: 'checkbox', + if (item.checkbox) { + const preset = initInputs[item.checkbox.variable] === true + return { + ...item.checkbox, + default: preset || item.default || item.checkbox.default, + type: 'checkbox', + } } - } - if (item.select) { - const isInputInOptions = item.select.options.includes(initInputs[item.select.variable]) - return { - ...item.select, - default: (isInputInOptions ? initInputs[item.select.variable] : undefined) || item.select.default, - type: 'select', + if (item.select) { + const isInputInOptions = item.select.options.includes(initInputs[item.select.variable]) + return { + ...item.select, + default: + (isInputInOptions ? initInputs[item.select.variable] : undefined) || + item.select.default, + type: 'select', + } } - } - if (item['file-list']) { - return { - ...item['file-list'], - type: 'file-list', + if (item['file-list']) { + return { + ...item['file-list'], + type: 'file-list', + } } - } - if (item.file) { - return { - ...item.file, - type: 'file', + if (item.file) { + return { + ...item.file, + type: 'file', + } } - } - if (item.json_object) { + if (item.json_object) { + return { + ...item.json_object, + type: 'json_object', + } + } + let value = initInputs[item['text-input'].variable] + if (value && item['text-input'].max_length && value.length > item['text-input'].max_length) + value = value.slice(0, item['text-input'].max_length) return { - ...item.json_object, - type: 'json_object', + ...item['text-input'], + default: value || item.default || item['text-input'].default, + type: 'text-input', } - } - let value = initInputs[item['text-input'].variable] - if (value && item['text-input'].max_length && value.length > item['text-input'].max_length) - value = value.slice(0, item['text-input'].max_length) - return { - ...item['text-input'], - default: value || item.default || item['text-input'].default, - type: 'text-input', - } - }) + }) }, [initInputs, appParams]) const allInputsHidden = useMemo(() => { - return inputsForms.length > 0 && inputsForms.every(item => item.hide === true) + return inputsForms.length > 0 && inputsForms.every((item) => item.hide === true) }, [inputsForms]) useEffect(() => { // init inputs from url params - (async () => { + ;(async () => { const inputs = await getRawInputsFromUrlParams() const userVariables = await getRawUserVariablesFromUrlParams() setInitInputs(inputs) @@ -288,14 +349,17 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { }) handleNewConversationInputsChange(conversationInputs) }, [handleNewConversationInputsChange, inputsForms]) - const { data: newConversation } = useShareConversationName({ - conversationId: newConversationId, - appSourceType, - appId, - }, { - refetchOnWindowFocus: false, - enabled: !!newConversationId, - }) + const { data: newConversation } = useShareConversationName( + { + conversationId: newConversationId, + appSourceType, + appId, + }, + { + refetchOnWindowFocus: false, + enabled: !!newConversationId, + }, + ) const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([]) useEffect(() => { if (appConversationData?.data && !appConversationDataLoading) @@ -307,7 +371,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { if (showNewConversationItemInList && data[0]?.id !== '') { data.unshift({ id: '', - name: t($ => $['chat.newChatDefaultName'], { ns: 'share' }), + name: t(($) => $['chat.newChatDefaultName'], { ns: 'share' }), inputs: {}, introduction: '', }) @@ -317,19 +381,19 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { useEffect(() => { if (newConversation) { // eslint-disable-next-line react/set-state-in-effect -- Newly resolved conversation names intentionally patch the local list cache. - setOriginConversationList(produce((draft) => { - const index = draft.findIndex(item => item.id === newConversation.id) - if (index > -1) - draft[index] = newConversation - else - draft.unshift(newConversation) - })) + setOriginConversationList( + produce((draft) => { + const index = draft.findIndex((item) => item.id === newConversation.id) + if (index > -1) draft[index] = newConversation + else draft.unshift(newConversation) + }), + ) } }, [newConversation]) const currentConversationItem = useMemo(() => { - let conversationItem = conversationList.find(item => item.id === currentConversationId) + let conversationItem = conversationList.find((item) => item.id === currentConversationId) if (!conversationItem && pinnedConversationList.length) - conversationItem = pinnedConversationList.find(item => item.id === currentConversationId) + conversationItem = pinnedConversationList.find((item) => item.id === currentConversationId) return conversationItem }, [conversationList, currentConversationId, pinnedConversationList]) const currentConversationLatestInputs = useMemo(() => { @@ -337,61 +401,79 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { return newConversationInputsRef.current || {} return appChatListData.data.slice().pop().inputs || {} }, [appChatListData, currentConversationId]) - const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>(currentConversationLatestInputs || {}) + const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>( + currentConversationLatestInputs || {}, + ) useEffect(() => { if (currentConversationItem) // eslint-disable-next-line react/set-state-in-effect -- Selected conversation changes intentionally resync the editable input snapshot. setCurrentConversationInputs(currentConversationLatestInputs || {}) }, [currentConversationItem, currentConversationLatestInputs]) - const checkInputsRequired = useCallback((silent?: boolean) => { - if (allInputsHidden) + const checkInputsRequired = useCallback( + (silent?: boolean) => { + if (allInputsHidden) return true + let hasEmptyInput = '' + let fileIsUploading = false + const requiredVars = inputsForms.filter( + ({ required, type }) => required && type !== InputVarType.checkbox, + ) + if (requiredVars.length) { + requiredVars.forEach(({ variable, label, type }) => { + if (hasEmptyInput) return + if (fileIsUploading) return + if (!newConversationInputsRef.current[variable] && !silent) + hasEmptyInput = label as string + if ( + (type === InputVarType.singleFile || type === InputVarType.multiFiles) && + newConversationInputsRef.current[variable] && + !silent + ) { + const files = newConversationInputsRef.current[variable] + if (Array.isArray(files)) + fileIsUploading = files.find( + (item) => item.transferMethod === TransferMethod.local_file && !item.uploadedId, + ) + else + fileIsUploading = + files.transferMethod === TransferMethod.local_file && !files.uploadedId + } + }) + } + if (hasEmptyInput) { + toast.error( + t(($) => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: hasEmptyInput }), + ) + return false + } + if (fileIsUploading) { + toast.info(t(($) => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' })) + return + } return true - let hasEmptyInput = '' - let fileIsUploading = false - const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox) - if (requiredVars.length) { - requiredVars.forEach(({ variable, label, type }) => { - if (hasEmptyInput) - return - if (fileIsUploading) - return - if (!newConversationInputsRef.current[variable] && !silent) - hasEmptyInput = label as string - if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) { - const files = newConversationInputsRef.current[variable] - if (Array.isArray(files)) - fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId) - else - fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId - } - }) - } - if (hasEmptyInput) { - toast.error(t($ => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: hasEmptyInput })) - return false - } - if (fileIsUploading) { - toast.info(t($ => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' })) - return - } - return true - }, [inputsForms, t, allInputsHidden]) - const handleStartChat = useCallback((callback: any) => { - if (checkInputsRequired()) { - setShowNewConversationItemInList(true) - callback?.() - } - }, [setShowNewConversationItemInList, checkInputsRequired]) + }, + [inputsForms, t, allInputsHidden], + ) + const handleStartChat = useCallback( + (callback: any) => { + if (checkInputsRequired()) { + setShowNewConversationItemInList(true) + callback?.() + } + }, + [setShowNewConversationItemInList, checkInputsRequired], + ) const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: noop }) - const handleChangeConversation = useCallback((conversationId: string) => { - currentChatInstanceRef.current.handleStop() - setNewConversationId('') - handleConversationIdInfoChange(conversationId) - if (conversationId) - setClearChatList(false) - }, [handleConversationIdInfoChange, setClearChatList]) + const handleChangeConversation = useCallback( + (conversationId: string) => { + currentChatInstanceRef.current.handleStop() + setNewConversationId('') + handleConversationIdInfoChange(conversationId) + if (conversationId) setClearChatList(false) + }, + [handleConversationIdInfoChange, setClearChatList], + ) const handleNewConversation = useCallback(async () => { currentChatInstanceRef.current.handleStop() setShowNewConversationItemInList(true) @@ -402,73 +484,109 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { }) handleNewConversationInputsChange(conversationInputs) setClearChatList(true) - }, [handleChangeConversation, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList, inputsForms]) + }, [ + handleChangeConversation, + setShowNewConversationItemInList, + handleNewConversationInputsChange, + setClearChatList, + inputsForms, + ]) const handleUpdateConversationList = useCallback(() => { invalidateShareConversations() }, [invalidateShareConversations]) - const handlePinConversation = useCallback(async (conversationId: string) => { - await pinConversation(appSourceType, appId, conversationId) - toast.success(t($ => $['api.success'], { ns: 'common' })) - handleUpdateConversationList() - }, [appSourceType, appId, t, handleUpdateConversationList]) - const handleUnpinConversation = useCallback(async (conversationId: string) => { - await unpinConversation(appSourceType, appId, conversationId) - toast.success(t($ => $['api.success'], { ns: 'common' })) - handleUpdateConversationList() - }, [appSourceType, appId, t, handleUpdateConversationList]) + const handlePinConversation = useCallback( + async (conversationId: string) => { + await pinConversation(appSourceType, appId, conversationId) + toast.success(t(($) => $['api.success'], { ns: 'common' })) + handleUpdateConversationList() + }, + [appSourceType, appId, t, handleUpdateConversationList], + ) + const handleUnpinConversation = useCallback( + async (conversationId: string) => { + await unpinConversation(appSourceType, appId, conversationId) + toast.success(t(($) => $['api.success'], { ns: 'common' })) + handleUpdateConversationList() + }, + [appSourceType, appId, t, handleUpdateConversationList], + ) const [conversationDeleting, setConversationDeleting] = useState(false) - const handleDeleteConversation = useCallback(async (conversationId: string, { onSuccess }: Callback) => { - if (conversationDeleting) - return - try { - setConversationDeleting(true) - await delConversation(appSourceType, appId, conversationId) - toast.success(t($ => $['api.success'], { ns: 'common' })) - onSuccess() - } - finally { - setConversationDeleting(false) - } - if (conversationId === currentConversationId) - handleNewConversation() - handleUpdateConversationList() - }, [isInstalledApp, appId, t, handleUpdateConversationList, handleNewConversation, currentConversationId, conversationDeleting]) + const handleDeleteConversation = useCallback( + async (conversationId: string, { onSuccess }: Callback) => { + if (conversationDeleting) return + try { + setConversationDeleting(true) + await delConversation(appSourceType, appId, conversationId) + toast.success(t(($) => $['api.success'], { ns: 'common' })) + onSuccess() + } finally { + setConversationDeleting(false) + } + if (conversationId === currentConversationId) handleNewConversation() + handleUpdateConversationList() + }, + [ + isInstalledApp, + appId, + t, + handleUpdateConversationList, + handleNewConversation, + currentConversationId, + conversationDeleting, + ], + ) const [conversationRenaming, setConversationRenaming] = useState(false) - const handleRenameConversation = useCallback(async (conversationId: string, newName: string, { onSuccess }: Callback) => { - if (conversationRenaming) - return - if (!newName.trim()) { - toast.error(t($ => $['chat.conversationNameCanNotEmpty'], { ns: 'common' })) - return - } - setConversationRenaming(true) - try { - await renameConversation(appSourceType, appId, conversationId, newName) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - setOriginConversationList(produce((draft) => { - const index = originConversationList.findIndex(item => item.id === conversationId) - const item = draft[index]! - draft[index] = { - ...item, - name: newName, - } - })) - onSuccess() - } - finally { - setConversationRenaming(false) - } - }, [isInstalledApp, appId, t, conversationRenaming, originConversationList]) - const handleNewConversationCompleted = useCallback((newConversationId: string) => { - setNewConversationId(newConversationId) - handleConversationIdInfoChange(newConversationId) - setShowNewConversationItemInList(false) - invalidateShareConversations() - }, [handleConversationIdInfoChange, invalidateShareConversations]) - const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => { - await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId) - toast.success(t($ => $['api.success'], { ns: 'common' })) - }, [appSourceType, appId, t]) + const handleRenameConversation = useCallback( + async (conversationId: string, newName: string, { onSuccess }: Callback) => { + if (conversationRenaming) return + if (!newName.trim()) { + toast.error(t(($) => $['chat.conversationNameCanNotEmpty'], { ns: 'common' })) + return + } + setConversationRenaming(true) + try { + await renameConversation(appSourceType, appId, conversationId, newName) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + setOriginConversationList( + produce((draft) => { + const index = originConversationList.findIndex((item) => item.id === conversationId) + const item = draft[index]! + draft[index] = { + ...item, + name: newName, + } + }), + ) + onSuccess() + } finally { + setConversationRenaming(false) + } + }, + [isInstalledApp, appId, t, conversationRenaming, originConversationList], + ) + const handleNewConversationCompleted = useCallback( + (newConversationId: string) => { + setNewConversationId(newConversationId) + handleConversationIdInfoChange(newConversationId) + setShowNewConversationItemInList(false) + invalidateShareConversations() + }, + [handleConversationIdInfoChange, invalidateShareConversations], + ) + const handleFeedback = useCallback( + async (messageId: string, feedback: Feedback) => { + await updateFeedback( + { + url: `/messages/${messageId}/feedbacks`, + body: { rating: feedback.rating, content: feedback.content }, + }, + appSourceType, + appId, + ) + toast.success(t(($) => $['api.success'], { ns: 'common' })) + }, + [appSourceType, appId, t], + ) return { isInstalledApp, appId, @@ -476,7 +594,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { currentConversationItem, handleConversationIdInfoChange, appData, - appParams: appParams || {} as ChatConfig, + appParams: appParams || ({} as ChatConfig), appMeta, appPinnedConversationData, appConversationData, diff --git a/web/app/components/base/chat/chat-with-history/index.tsx b/web/app/components/base/chat/chat-with-history/index.tsx index 9e003889c5a239..49dd216657cbcc 100644 --- a/web/app/components/base/chat/chat-with-history/index.tsx +++ b/web/app/components/base/chat/chat-with-history/index.tsx @@ -3,19 +3,13 @@ import type { FC } from 'react' import type { ChatProps } from '../chat' import type { InstalledApp } from '@/models/explore' import { cn } from '@langgenius/dify-ui/cn' -import { - useEffect, - useState, -} from 'react' +import { useEffect, useState } from 'react' import Loading from '@/app/components/base/loading' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { useThemeContext } from '../embedded-chatbot/theme/theme-context' import ChatWrapper from './chat-wrapper' -import { - ChatWithHistoryContext, - useChatWithHistoryContext, -} from './context' +import { ChatWithHistoryContext, useChatWithHistoryContext } from './context' import Header from './header' import HeaderInMobile from './header-in-mobile' import { useChatWithHistory } from './hooks' @@ -24,9 +18,7 @@ import Sidebar from './sidebar' type ChatWithHistoryProps = { className?: string } -const ChatWithHistory: FC<ChatWithHistoryProps> = ({ - className, -}) => { +const ChatWithHistory: FC<ChatWithHistoryProps> = ({ className }) => { const { appData, appChatListDataLoading, @@ -46,31 +38,26 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({ }, [site, customConfig, themeBuilder]) useEffect(() => { - if (!isSidebarCollapsed) - setShowSidePanel(false) + if (!isSidebarCollapsed) setShowSidePanel(false) }, [isSidebarCollapsed]) useDocumentTitle(site?.title || 'Chat') return ( - <div className={cn( - 'flex h-full bg-background-default-burn', - isMobile && 'flex-col', - className, - )} + <div + className={cn('flex h-full bg-background-default-burn', isMobile && 'flex-col', className)} > {!isMobile && ( - <div className={cn( - 'flex w-[236px] flex-col p-1 pr-0 transition-all duration-200 ease-in-out', - isSidebarCollapsed && 'w-0 overflow-hidden p-0!', - )} + <div + className={cn( + 'flex w-[236px] flex-col p-1 pr-0 transition-all duration-200 ease-in-out', + isSidebarCollapsed && 'w-0 overflow-hidden p-0!', + )} > <Sidebar /> </div> )} - {isMobile && ( - <HeaderInMobile /> - )} + {isMobile && <HeaderInMobile />} <div className={cn('relative grow p-2', isMobile && 'h-[calc(100%-56px)] p-0')}> {isSidebarCollapsed && ( <div @@ -84,14 +71,15 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({ <Sidebar isPanel panelVisible={showSidePanel} /> </div> )} - <div className={cn('flex h-full flex-col overflow-hidden border-[0,5px] border-components-panel-border-subtle bg-chatbot-bg', isMobile ? 'rounded-t-2xl' : 'rounded-2xl')}> - {!isMobile && <Header />} - {appChatListDataLoading && ( - <Loading type="app" /> - )} - {!appChatListDataLoading && ( - <ChatWrapper key={chatShouldReloadKey} /> + <div + className={cn( + 'flex h-full flex-col overflow-hidden border-[0,5px] border-components-panel-border-subtle bg-chatbot-bg', + isMobile ? 'rounded-t-2xl' : 'rounded-2xl', )} + > + {!isMobile && <Header />} + {appChatListDataLoading && <Loading type="app" />} + {!appChatListDataLoading && <ChatWrapper key={chatShouldReloadKey} />} </div> </div> </div> @@ -155,49 +143,50 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({ } = useChatWithHistory(installedAppInfo) return ( - <ChatWithHistoryContext.Provider value={{ - appData, - appParams, - appMeta, - appChatListDataLoading, - currentConversationId, - currentConversationItem, - appPrevChatTree, - pinnedConversationList, - conversationList, - newConversationInputs, - newConversationInputsRef, - handleNewConversationInputsChange, - inputsForms, - handleNewConversation, - handleStartChat, - handleChangeConversation, - handlePinConversation, - handleUnpinConversation, - handleDeleteConversation, - conversationRenaming, - handleRenameConversation, - handleNewConversationCompleted, - chatShouldReloadKey, - isMobile, - isInstalledApp, - appId, - handleFeedback, - currentChatInstanceRef, - themeBuilder, - sidebarCollapseState, - handleSidebarCollapse, - clearChatList, - setClearChatList, - isResponding, - setIsResponding, - currentConversationInputs, - setCurrentConversationInputs, - allInputsHidden, - initUserVariables, - isNewAgent, - renderAgentContent, - }} + <ChatWithHistoryContext.Provider + value={{ + appData, + appParams, + appMeta, + appChatListDataLoading, + currentConversationId, + currentConversationItem, + appPrevChatTree, + pinnedConversationList, + conversationList, + newConversationInputs, + newConversationInputsRef, + handleNewConversationInputsChange, + inputsForms, + handleNewConversation, + handleStartChat, + handleChangeConversation, + handlePinConversation, + handleUnpinConversation, + handleDeleteConversation, + conversationRenaming, + handleRenameConversation, + handleNewConversationCompleted, + chatShouldReloadKey, + isMobile, + isInstalledApp, + appId, + handleFeedback, + currentChatInstanceRef, + themeBuilder, + sidebarCollapseState, + handleSidebarCollapse, + clearChatList, + setClearChatList, + isResponding, + setIsResponding, + currentConversationInputs, + setCurrentConversationInputs, + allInputsHidden, + initUserVariables, + isNewAgent, + renderAgentContent, + }} > <ChatWithHistory className={className} /> </ChatWithHistoryContext.Provider> diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx index a53bdc3b938f64..be9a85ec8412e6 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx @@ -8,20 +8,47 @@ import InputsFormContent from '../content' // Keep lightweight mocks for non-base project components vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({ - default: ({ value, onChange, name }: { value: boolean, onChange: (v: boolean) => void, name: string }) => ( - <div data-testid="mock-bool-input" role="checkbox" aria-checked={value} onClick={() => onChange(!value)}> + default: ({ + value, + onChange, + name, + }: { + value: boolean + onChange: (v: boolean) => void + name: string + }) => ( + <div + data-testid="mock-bool-input" + role="checkbox" + aria-checked={value} + onClick={() => onChange(!value)} + > {name} </div> ), })) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ onChange, value, placeholder }: { onChange: (v: string) => void, value: string, placeholder?: React.ReactNode }) => ( + default: ({ + onChange, + value, + placeholder, + }: { + onChange: (v: string) => void + value: string + placeholder?: React.ReactNode + }) => ( <div> - <textarea data-testid="mock-code-editor" value={value} onChange={e => onChange(e.target.value)} /> + <textarea + data-testid="mock-code-editor" + value={value} + onChange={(e) => onChange(e.target.value)} + /> {!!placeholder && ( <div data-testid="mock-code-editor-placeholder"> - {React.isValidElement<{ children?: React.ReactNode }>(placeholder) ? placeholder.props.children : ''} + {React.isValidElement<{ children?: React.ReactNode }>(placeholder) + ? placeholder.props.children + : ''} </div> )} </div> @@ -30,10 +57,22 @@ vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', ( // MOCK: file-uploader (stable, deterministic for unit tests) vi.mock('@/app/components/base/file-uploader', () => ({ - FileUploaderInAttachmentWrapper: ({ onChange, value }: { onChange: (files: unknown[]) => void, value?: unknown[] }) => ( + FileUploaderInAttachmentWrapper: ({ + onChange, + value, + }: { + onChange: (files: unknown[]) => void + value?: unknown[] + }) => ( <div data-testid="mock-file-uploader" - onClick={() => onChange(value && value.length > 0 ? [...value, `uploaded-file-${(value.length || 0) + 1}`] : ['uploaded-file-1'])} + onClick={() => + onChange( + value && value.length > 0 + ? [...value, `uploaded-file-${(value.length || 0) + 1}`] + : ['uploaded-file-1'], + ) + } data-value-count={value?.length ?? 0} /> ), @@ -50,14 +89,22 @@ const defaultSystemParameters = { workflow_file_upload_limit: 1, } -const createMockContext = (overrides: Partial<ChatWithHistoryContextValue> = {}): ChatWithHistoryContextValue => { +const createMockContext = ( + overrides: Partial<ChatWithHistoryContextValue> = {}, +): ChatWithHistoryContextValue => { const base: ChatWithHistoryContextValue = { - appParams: { system_parameters: defaultSystemParameters } as unknown as ChatWithHistoryContextValue['appParams'], - inputsForms: [{ variable: 'text_var', type: InputVarType.textInput, label: 'Text Label', required: true }], + appParams: { + system_parameters: defaultSystemParameters, + } as unknown as ChatWithHistoryContextValue['appParams'], + inputsForms: [ + { variable: 'text_var', type: InputVarType.textInput, label: 'Text Label', required: true }, + ], currentConversationId: '123', currentConversationInputs: { text_var: 'current-value' }, newConversationInputs: { text_var: 'new-value' }, - newConversationInputsRef: { current: { text_var: 'ref-value' } } as React.RefObject<Record<string, unknown>>, + newConversationInputsRef: { current: { text_var: 'ref-value' } } as React.RefObject< + Record<string, unknown> + >, setCurrentConversationInputs: mockSetCurrentConversationInputs, handleNewConversationInputsChange: mockHandleNewConversationInputsChange, allInputsHidden: false, @@ -77,7 +124,9 @@ const createMockContext = (overrides: Partial<ChatWithHistoryContextValue> = {}) isMobile: false, isInstalledApp: false, handleFeedback: vi.fn(), - currentChatInstanceRef: { current: { handleStop: vi.fn() } } as React.RefObject<{ handleStop: () => void }>, + currentChatInstanceRef: { current: { handleStop: vi.fn() } } as React.RefObject<{ + handleStop: () => void + }>, sidebarCollapseState: false, handleSidebarCollapse: vi.fn(), setClearChatList: vi.fn(), @@ -94,7 +143,13 @@ vi.mock('../../context', () => ({ useChatWithHistoryContext: () => React.useContext(MockContext), })) -const MockContextProvider = ({ children, value }: { children: React.ReactNode, value: ChatWithHistoryContextValue }) => { +const MockContextProvider = ({ + children, + value, +}: { + children: React.ReactNode + value: ChatWithHistoryContextValue +}) => { // We need to manage state locally to support controlled components const [currentInputs, setCurrentInputs] = React.useState(value.currentConversationInputs) const [newInputs, setNewInputs] = React.useState(value.newConversationInputs) @@ -125,12 +180,11 @@ describe('InputsFormContent', () => { vi.clearAllMocks() }) - const renderWithContext = (component: React.ReactNode, contextValue: ChatWithHistoryContextValue) => { - return render( - <MockContextProvider value={contextValue}> - {component} - </MockContextProvider>, - ) + const renderWithContext = ( + component: React.ReactNode, + contextValue: ChatWithHistoryContextValue, + ) => { + return render(<MockContextProvider value={contextValue}>{component}</MockContextProvider>) } it('renders only visible forms and ignores hidden ones', () => { @@ -149,7 +203,9 @@ describe('InputsFormContent', () => { it('shows optional label when required is false', () => { const context = createMockContext({ - inputsForms: [{ variable: 'opt', type: InputVarType.textInput, label: 'Opt', required: false }], + inputsForms: [ + { variable: 'opt', type: InputVarType.textInput, label: 'Opt', required: false }, + ], }) renderWithContext(<InputsFormContent />, context) @@ -184,8 +240,12 @@ describe('InputsFormContent', () => { await user.clear(input) await user.type(input, 'updated') - expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(expect.objectContaining({ text_var: 'updated' })) - expect(mockHandleNewConversationInputsChange).toHaveBeenLastCalledWith(expect.objectContaining({ text_var: 'updated' })) + expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith( + expect.objectContaining({ text_var: 'updated' }), + ) + expect(mockHandleNewConversationInputsChange).toHaveBeenLastCalledWith( + expect.objectContaining({ text_var: 'updated' }), + ) }) it('renders and handles number input updates', async () => { @@ -201,7 +261,9 @@ describe('InputsFormContent', () => { await user.type(input, '123') - expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(expect.objectContaining({ num: '123' })) + expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith( + expect.objectContaining({ num: '123' }), + ) }) it('renders and handles paragraph input updates', async () => { @@ -215,7 +277,9 @@ describe('InputsFormContent', () => { const textarea = screen.getByPlaceholderText('Para') as HTMLTextAreaElement await user.type(textarea, 'hello') - expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(expect.objectContaining({ para: 'hello' })) + expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith( + expect.objectContaining({ para: 'hello' }), + ) }) it('renders and handles checkbox input updates (uses mocked BoolInput)', async () => { @@ -233,7 +297,15 @@ describe('InputsFormContent', () => { it('handles select input with default value and updates', async () => { const user = userEvent.setup() const context = createMockContext({ - inputsForms: [{ variable: 'sel', type: InputVarType.select, label: 'Sel', options: ['A', 'B'], default: 'B' }], + inputsForms: [ + { + variable: 'sel', + type: InputVarType.select, + label: 'Sel', + options: ['A', 'B'], + default: 'B', + }, + ], currentConversationInputs: {}, }) @@ -245,13 +317,23 @@ describe('InputsFormContent', () => { const optionA = screen.getByText('A') await user.click(optionA) - expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith(expect.objectContaining({ sel: 'A' })) + expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith( + expect.objectContaining({ sel: 'A' }), + ) }) it('renders select dropdown on the shared dify-ui overlay layer', async () => { const user = userEvent.setup() const context = createMockContext({ - inputsForms: [{ variable: 'sel', type: InputVarType.select, label: 'Sel', options: ['A', 'B'], default: 'B' }], + inputsForms: [ + { + variable: 'sel', + type: InputVarType.select, + label: 'Sel', + options: ['A', 'B'], + default: 'B', + }, + ], currentConversationInputs: {}, }) @@ -263,7 +345,15 @@ describe('InputsFormContent', () => { it('handles select input with existing value (value not in options -> shows placeholder)', () => { const context = createMockContext({ - inputsForms: [{ variable: 'sel', type: InputVarType.select, label: 'Sel', options: ['A'], default: undefined }], + inputsForms: [ + { + variable: 'sel', + type: InputVarType.select, + label: 'Sel', + options: ['A'], + default: undefined, + }, + ], currentConversationInputs: { sel: 'existing' }, }) @@ -275,7 +365,15 @@ describe('InputsFormContent', () => { it('handles select input empty branches (no current value -> show placeholder)', () => { const context = createMockContext({ - inputsForms: [{ variable: 'sel', type: InputVarType.select, label: 'Sel', options: ['A'], default: undefined }], + inputsForms: [ + { + variable: 'sel', + type: InputVarType.select, + label: 'Sel', + options: ['A'], + default: undefined, + }, + ], currentConversationInputs: {}, }) @@ -287,7 +385,14 @@ describe('InputsFormContent', () => { it('renders and handles JSON object updates (uses mocked CodeEditor)', async () => { const user = userEvent.setup() const context = createMockContext({ - inputsForms: [{ variable: 'json', type: InputVarType.jsonObject, label: 'Json', json_schema: '{ "a": 1 }' }], + inputsForms: [ + { + variable: 'json', + type: InputVarType.jsonObject, + label: 'Json', + json_schema: '{ "a": 1 }', + }, + ], currentConversationInputs: {}, }) @@ -297,12 +402,23 @@ describe('InputsFormContent', () => { const jsonEditor = screen.getByTestId('mock-code-editor') as HTMLTextAreaElement await user.clear(jsonEditor) await user.paste('{"a":2}') - expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(expect.objectContaining({ json: '{"a":2}' })) + expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith( + expect.objectContaining({ json: '{"a":2}' }), + ) }) it('handles single file uploader with existing value (using mocked uploader)', () => { const context = createMockContext({ - inputsForms: [{ variable: 'single', type: InputVarType.singleFile, label: 'Single', allowed_file_types: [], allowed_file_extensions: [], allowed_file_upload_methods: [] }], + inputsForms: [ + { + variable: 'single', + type: InputVarType.singleFile, + label: 'Single', + allowed_file_types: [], + allowed_file_extensions: [], + allowed_file_upload_methods: [], + }, + ], currentConversationInputs: { single: 'file1' }, }) @@ -313,7 +429,16 @@ describe('InputsFormContent', () => { it('handles single file uploader with no value and updates (using mocked uploader)', async () => { const user = userEvent.setup() const context = createMockContext({ - inputsForms: [{ variable: 'single', type: InputVarType.singleFile, label: 'Single', allowed_file_types: [], allowed_file_extensions: [], allowed_file_upload_methods: [] }], + inputsForms: [ + { + variable: 'single', + type: InputVarType.singleFile, + label: 'Single', + allowed_file_types: [], + allowed_file_extensions: [], + allowed_file_upload_methods: [], + }, + ], currentConversationInputs: {}, }) @@ -322,13 +447,17 @@ describe('InputsFormContent', () => { const uploader = screen.getByTestId('mock-file-uploader') await user.click(uploader) - expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith(expect.objectContaining({ single: 'uploaded-file-1' })) + expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith( + expect.objectContaining({ single: 'uploaded-file-1' }), + ) }) it('renders and handles multi files uploader updates (using mocked uploader)', async () => { const user = userEvent.setup() const context = createMockContext({ - inputsForms: [{ variable: 'multi', type: InputVarType.multiFiles, label: 'Multi', max_length: 3 }], + inputsForms: [ + { variable: 'multi', type: InputVarType.multiFiles, label: 'Multi', max_length: 3 }, + ], currentConversationInputs: {}, }) @@ -336,7 +465,9 @@ describe('InputsFormContent', () => { const uploader = screen.getByTestId('mock-file-uploader') await user.click(uploader) - expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith(expect.objectContaining({ multi: ['uploaded-file-1'] })) + expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith( + expect.objectContaining({ multi: ['uploaded-file-1'] }), + ) }) it('renders footer tip only when showTip prop is true', () => { diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/index.spec.tsx index 0ba41afd8a2509..5ba3988ab8c7ba 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/index.spec.tsx @@ -9,7 +9,7 @@ import InputsFormNode from '../index' // Mocks for components used by InputsFormContent (the real sibling) vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({ - default: ({ value, name }: { value: boolean, name: string }) => ( + default: ({ value, name }: { value: boolean; name: string }) => ( <div data-testid="mock-bool-input" role="checkbox" aria-checked={value}> {name} </div> @@ -17,7 +17,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-i })) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ value, placeholder }: { value: string, placeholder?: React.ReactNode }) => ( + default: ({ value, placeholder }: { value: string; placeholder?: React.ReactNode }) => ( <div data-testid="mock-code-editor"> <span>{value}</span> {placeholder} @@ -36,8 +36,7 @@ vi.mock('../../context', () => ({ })) const mockHandleStartChat = vi.fn((cb?: () => void) => { - if (cb) - cb() + if (cb) cb() }) const defaultContextValues: Partial<ChatWithHistoryContextValue> = { @@ -135,14 +134,17 @@ describe('InputsFormNode', () => { // Prefer selecting by a test id if the component exposes it. Fallback to queries that // don't rely on internal DOM structure so tests are less brittle. - const outerDiv = screen.queryByTestId('inputs-form-node') ?? (container.firstChild as HTMLElement) + const outerDiv = + screen.queryByTestId('inputs-form-node') ?? (container.firstChild as HTMLElement) expect(outerDiv).toBeTruthy() // Check for mobile-specific layout classes (pt-4) expect(outerDiv).toHaveClass('pt-4') // Check padding in expanded content (p-4 for mobile) // Prefer a test id for the content wrapper; fallback to finding the label's closest ancestor - const contentWrapper = screen.queryByTestId('inputs-form-content-wrapper') ?? screen.getByText('Test Label').closest('.p-4') + const contentWrapper = + screen.queryByTestId('inputs-form-content-wrapper') ?? + screen.getByText('Test Label').closest('.p-4') expect(contentWrapper).toBeInTheDocument() }) }) diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/view-form-dropdown.spec.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/view-form-dropdown.spec.tsx index 64d3e67b8fa145..47bfd16513b9ee 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/view-form-dropdown.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/view-form-dropdown.spec.tsx @@ -9,7 +9,7 @@ import ViewFormDropdown from '../view-form-dropdown' // Mocks for components used by InputsFormContent (the real sibling) vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({ - default: ({ value, name }: { value: boolean, name: string }) => ( + default: ({ value, name }: { value: boolean; name: string }) => ( <div data-testid="mock-bool-input" role="checkbox" aria-checked={value}> {name} </div> @@ -17,7 +17,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-i })) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ value, placeholder }: { value: string, placeholder?: React.ReactNode }) => ( + default: ({ value, placeholder }: { value: string; placeholder?: React.ReactNode }) => ( <div data-testid="mock-code-editor"> <span>{value}</span> {placeholder} diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx index ac95d4f7b46c03..36d4423f4bfffa 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx @@ -1,4 +1,11 @@ -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { Textarea } from '@langgenius/dify-ui/textarea' import * as React from 'react' import { memo, useCallback } from 'react' @@ -29,35 +36,45 @@ const InputsFormContent = ({ showTip }: Props) => { } = useChatWithHistoryContext() const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputs - const handleFormChange = useCallback((variable: string, value: any) => { - setCurrentConversationInputs({ - ...currentConversationInputs, - [variable]: value, - }) - handleNewConversationInputsChange({ - ...newConversationInputsRef.current, - [variable]: value, - }) - }, [newConversationInputsRef, handleNewConversationInputsChange, currentConversationInputs, setCurrentConversationInputs]) + const handleFormChange = useCallback( + (variable: string, value: any) => { + setCurrentConversationInputs({ + ...currentConversationInputs, + [variable]: value, + }) + handleNewConversationInputsChange({ + ...newConversationInputsRef.current, + [variable]: value, + }) + }, + [ + newConversationInputsRef, + handleNewConversationInputsChange, + currentConversationInputs, + setCurrentConversationInputs, + ], + ) - const visibleInputsForms = inputsForms.filter(form => form.hide !== true) + const visibleInputsForms = inputsForms.filter((form) => form.hide !== true) return ( <div className="space-y-4"> - {visibleInputsForms.map(form => ( + {visibleInputsForms.map((form) => ( <div key={form.variable} className="space-y-1"> {form.type !== InputVarType.checkbox && ( <div className="flex h-6 items-center gap-1"> <div className="system-md-semibold text-text-secondary">{form.label}</div> {!form.required && ( - <div className="system-xs-regular text-text-tertiary">{t($ => $['panel.optional'], { ns: 'workflow' })}</div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['panel.optional'], { ns: 'workflow' })} + </div> )} </div> )} {form.type === InputVarType.textInput && ( <Input value={inputsFormValue?.[form.variable] || ''} - onChange={e => handleFormChange(form.variable, e.target.value)} + onChange={(e) => handleFormChange(form.variable, e.target.value)} placeholder={form.label} /> )} @@ -65,7 +82,7 @@ const InputsFormContent = ({ showTip }: Props) => { <Input type="number" value={inputsFormValue?.[form.variable] || ''} - onChange={e => handleFormChange(form.variable, e.target.value)} + onChange={(e) => handleFormChange(form.variable, e.target.value)} placeholder={form.label} /> )} @@ -73,7 +90,7 @@ const InputsFormContent = ({ showTip }: Props) => { <Textarea aria-label={form.label} value={inputsFormValue?.[form.variable] || ''} - onValueChange={value => handleFormChange(form.variable, value)} + onValueChange={(value) => handleFormChange(form.variable, value)} placeholder={form.label} /> )} @@ -82,13 +99,13 @@ const InputsFormContent = ({ showTip }: Props) => { name={form.label} value={!!inputsFormValue?.[form.variable]} required={form.required} - onChange={value => handleFormChange(form.variable, value)} + onChange={(value) => handleFormChange(form.variable, value)} /> )} {form.type === InputVarType.select && ( <Select value={(inputsFormValue?.[form.variable] ?? form.default ?? '') || null} - onValueChange={value => value && handleFormChange(form.variable, value)} + onValueChange={(value) => value && handleFormChange(form.variable, value)} > <SelectTrigger className="w-full"> {String(inputsFormValue?.[form.variable] ?? form.default ?? form.label)} @@ -106,7 +123,7 @@ const InputsFormContent = ({ showTip }: Props) => { {form.type === InputVarType.singleFile && ( <FileUploaderInAttachmentWrapper value={inputsFormValue?.[form.variable] ? [inputsFormValue?.[form.variable]] : []} - onChange={files => handleFormChange(form.variable, files[0])} + onChange={(files) => handleFormChange(form.variable, files[0])} fileConfig={{ allowed_file_types: form.allowed_file_types, allowed_file_extensions: form.allowed_file_extensions, @@ -119,7 +136,7 @@ const InputsFormContent = ({ showTip }: Props) => { {form.type === InputVarType.multiFiles && ( <FileUploaderInAttachmentWrapper value={inputsFormValue?.[form.variable] || []} - onChange={files => handleFormChange(form.variable, files)} + onChange={(files) => handleFormChange(form.variable, files)} fileConfig={{ allowed_file_types: form.allowed_file_types, allowed_file_extensions: form.allowed_file_extensions, @@ -133,18 +150,18 @@ const InputsFormContent = ({ showTip }: Props) => { <CodeEditor language={CodeLanguage.json} value={inputsFormValue?.[form.variable] || ''} - onChange={v => handleFormChange(form.variable, v)} + onChange={(v) => handleFormChange(form.variable, v)} noWrapper className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" - placeholder={ - <div className="whitespace-pre">{form.json_schema}</div> - } + placeholder={<div className="whitespace-pre">{form.json_schema}</div>} /> )} </div> ))} {showTip && ( - <div className="system-xs-regular text-text-tertiary">{t($ => $['chat.chatFormTip'], { ns: 'share' })}</div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['chat.chatFormTip'], { ns: 'share' })} + </div> )} </div> ) diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/index.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/index.tsx index 292aa6e199a4cb..ce761e554a2e0f 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/index.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/index.tsx @@ -12,10 +12,7 @@ type Props = Readonly<{ setCollapsed: (collapsed: boolean) => void }> -const InputsFormNode = ({ - collapsed, - setCollapsed, -}: Props) => { +const InputsFormNode = ({ collapsed, setCollapsed }: Props) => { const { t } = useTranslation() const { isMobile, @@ -26,29 +23,46 @@ const InputsFormNode = ({ inputsForms, } = useChatWithHistoryContext() - if (allInputsHidden || inputsForms.length === 0) - return null + if (allInputsHidden || inputsForms.length === 0) return null return ( <div className={cn('flex flex-col items-center px-4 pt-6', isMobile && 'pt-4')}> - <div className={cn( - 'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md', - collapsed && 'border border-components-card-border bg-components-card-bg shadow-none', - )} - > - <div className={cn( - 'flex items-center gap-3 rounded-t-2xl px-6 py-4', - !collapsed && 'border-b border-divider-subtle', - isMobile && 'px-4 py-3', + <div + className={cn( + 'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md', + collapsed && 'border border-components-card-border bg-components-card-bg shadow-none', )} + > + <div + className={cn( + 'flex items-center gap-3 rounded-t-2xl px-6 py-4', + !collapsed && 'border-b border-divider-subtle', + isMobile && 'px-4 py-3', + )} > <Message3Fill className="size-6 shrink-0" /> - <div className="grow system-xl-semibold text-text-secondary">{t($ => $['chat.chatSettingsTitle'], { ns: 'share' })}</div> + <div className="grow system-xl-semibold text-text-secondary"> + {t(($) => $['chat.chatSettingsTitle'], { ns: 'share' })} + </div> {collapsed && ( - <Button className="text-text-tertiary uppercase" size="small" variant="ghost" onClick={() => setCollapsed(false)}>{t($ => $['operation.edit'], { ns: 'common' })}</Button> + <Button + className="text-text-tertiary uppercase" + size="small" + variant="ghost" + onClick={() => setCollapsed(false)} + > + {t(($) => $['operation.edit'], { ns: 'common' })} + </Button> )} {!collapsed && currentConversationId && ( - <Button className="text-text-tertiary uppercase" size="small" variant="ghost" onClick={() => setCollapsed(true)}>{t($ => $['operation.close'], { ns: 'common' })}</Button> + <Button + className="text-text-tertiary uppercase" + size="small" + variant="ghost" + onClick={() => setCollapsed(true)} + > + {t(($) => $['operation.close'], { ns: 'common' })} + </Button> )} </div> {!collapsed && ( @@ -70,7 +84,7 @@ const InputsFormNode = ({ : {} } > - {t($ => $['chat.startChat'], { ns: 'share' })} + {t(($) => $['chat.startChat'], { ns: 'share' })} </Button> </div> )} diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx index 718c466989003f..e08ed97e575593 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx @@ -1,7 +1,5 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { - RiChatSettingsLine, -} from '@remixicon/react' +import { RiChatSettingsLine } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' @@ -13,16 +11,13 @@ const ViewFormDropdown = () => { const [open, setOpen] = useState(false) return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}> <RiChatSettingsLine className="h-[18px] w-[18px]" /> </ActionButton> - )} + } /> <PopoverContent placement="bottom-end" @@ -33,7 +28,9 @@ const ViewFormDropdown = () => { <div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs"> <div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4"> <Message3Fill className="size-6 shrink-0" /> - <div className="grow system-xl-semibold text-text-secondary">{t($ => $['chat.chatSettingsTitle'], { ns: 'share' })}</div> + <div className="grow system-xl-semibold text-text-secondary"> + {t(($) => $['chat.chatSettingsTitle'], { ns: 'share' })} + </div> </div> <div className="p-6"> <InputsFormContent /> @@ -41,7 +38,6 @@ const ViewFormDropdown = () => { </div> </PopoverContent> </Popover> - ) } diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx index c0aca650496f69..5214051b01f88d 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx @@ -11,10 +11,14 @@ import { useChatWithHistoryContext } from '../../context' import Sidebar from '../index' import RenameModal from '../rename-modal' -let mockBranding: { enabled: boolean, workspace_logo: string } = { enabled: false, workspace_logo: '' } -const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { - systemFeatures: { branding: { ...mockBranding } }, -}) +let mockBranding: { enabled: boolean; workspace_logo: string } = { + enabled: false, + workspace_logo: '', +} +const render = (ui: ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { branding: { ...mockBranding } }, + }) function mockUseTranslationWithEmptyKeys(emptyKeys: string[]) { const originalUseTranslation = ReactI18next.useTranslation @@ -26,8 +30,7 @@ function mockUseTranslationWithEmptyKeys(emptyKeys: string[]) { return { ...translation, t: withSelectorKey((key: string, options?: Record<string, unknown>) => { - if (emptyKeys.includes(key)) - return '' + if (emptyKeys.includes(key)) return '' const ns = (options?.ns as string | undefined) ?? defaultNs return ns ? `${ns}.${key}` : key }) as typeof translation.t, @@ -37,16 +40,34 @@ function mockUseTranslationWithEmptyKeys(emptyKeys: string[]) { // Mock List to allow us to trigger operations vi.mock('../list', () => ({ - default: ({ list, onOperate, title, isPin }: { list: Array<{ id: string, name: string }>, onOperate: (type: string, item: { id: string, name: string }) => void, title?: string, isPin?: boolean }) => ( + default: ({ + list, + onOperate, + title, + isPin, + }: { + list: Array<{ id: string; name: string }> + onOperate: (type: string, item: { id: string; name: string }) => void + title?: string + isPin?: boolean + }) => ( <div data-testid={isPin ? 'pinned-list' : 'conversation-list'}> {title && <div data-testid="list-title">{title}</div>} - {list.map(item => ( + {list.map((item) => ( <div key={item.id} data-testid={`list-item-${item.id}`}> <div>{item.name}</div> - <button data-testid={`pin-${item.id}`} onClick={() => onOperate('pin', item)}>Pin</button> - <button data-testid={`unpin-${item.id}`} onClick={() => onOperate('unpin', item)}>Unpin</button> - <button data-testid={`delete-${item.id}`} onClick={() => onOperate('delete', item)}>Delete</button> - <button data-testid={`rename-${item.id}`} onClick={() => onOperate('rename', item)}>Rename</button> + <button data-testid={`pin-${item.id}`} onClick={() => onOperate('pin', item)}> + Pin + </button> + <button data-testid={`unpin-${item.id}`} onClick={() => onOperate('unpin', item)}> + Unpin + </button> + <button data-testid={`delete-${item.id}`} onClick={() => onOperate('delete', item)}> + Delete + </button> + <button data-testid={`rename-${item.id}`} onClick={() => onOperate('rename', item)}> + Rename + </button> </div> ))} </div> @@ -65,7 +86,7 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@langgenius/dify-ui/dialog', () => ({ - Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => + Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => open === false ? null : <>{children}</>, DialogContent: ({ children }: { children: React.ReactNode }) => ( <div data-testid="modal">{children}</div> @@ -90,9 +111,7 @@ describe('Sidebar Index', () => { }, handleNewConversation: vi.fn(), pinnedConversationList: [], - conversationList: [ - { id: '1', name: 'Conv 1', inputs: {}, introduction: '' }, - ], + conversationList: [{ id: '1', name: 'Conv 1', inputs: {}, introduction: '' }], currentConversationId: '0', handleChangeConversation: vi.fn(), handlePinConversation: vi.fn(), @@ -444,9 +463,12 @@ describe('Sidebar Index', () => { await user.click(screen.getByTestId('delete-1')) await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - expect(handleDeleteConversation).toHaveBeenCalledWith('1', expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect(handleDeleteConversation).toHaveBeenCalledWith( + '1', + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) }) it('should close delete confirmation when cancel is clicked', async () => { @@ -833,8 +855,7 @@ describe('Sidebar Index', () => { render(<Sidebar />) expect(screen.getByTestId('pinned-list')).toBeInTheDocument() expect(screen.queryByTestId('list-title')).not.toBeInTheDocument() - } - finally { + } finally { useTranslationSpy.mockRestore() } }) @@ -847,8 +868,7 @@ describe('Sidebar Index', () => { await user.click(screen.getByTestId('delete-1')) expect(screen.getByText('share.chat.deleteConversation.title')).toBeInTheDocument() expect(screen.queryByText('share.chat.deleteConversation.content')).not.toBeInTheDocument() - } - finally { + } finally { useTranslationSpy.mockRestore() } }) @@ -899,8 +919,7 @@ describe('RenameModal', () => { />, ) expect(screen.getByPlaceholderText('')).toBeInTheDocument() - } - finally { + } finally { useTranslationSpy.mockRestore() } }) diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/item.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/item.spec.tsx index afd4178d56722e..698044cec3646d 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/item.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/item.spec.tsx @@ -5,14 +5,34 @@ import Item from '../item' // Mock Operation to verify its usage vi.mock('@/app/components/base/chat/chat-with-history/sidebar/operation', () => ({ - default: ({ togglePin, onRenameConversation, onDelete, isItemHovering, isActive, isPinned }: { togglePin: () => void, onRenameConversation: () => void, onDelete: () => void, isItemHovering: boolean, isActive: boolean, isPinned: boolean }) => ( + default: ({ + togglePin, + onRenameConversation, + onDelete, + isItemHovering, + isActive, + isPinned, + }: { + togglePin: () => void + onRenameConversation: () => void + onDelete: () => void + isItemHovering: boolean + isActive: boolean + isPinned: boolean + }) => ( <div data-testid="mock-operation"> <button onClick={togglePin}>Pin</button> <button onClick={onRenameConversation}>Rename</button> <button onClick={onDelete}>Delete</button> - <span data-hovering={isItemHovering} data-testid="hover-indicator">Hovering</span> - <span data-active={isActive} data-testid="active-indicator">Active</span> - <span data-pinned={isPinned} data-testid="pinned-indicator">Pinned</span> + <span data-hovering={isItemHovering} data-testid="hover-indicator"> + Hovering + </span> + <span data-active={isActive} data-testid="active-indicator"> + Active + </span> + <span data-pinned={isPinned} data-testid="pinned-indicator"> + Pinned + </span> </div> ), })) @@ -310,9 +330,7 @@ describe('Item', () => { const user = userEvent.setup() const onOperate = vi.fn() - const { rerender } = render( - <Item {...defaultProps} onOperate={onOperate} isPin={false} />, - ) + const { rerender } = render(<Item {...defaultProps} onOperate={onOperate} isPin={false} />) await user.click(screen.getByRole('button', { name: 'Pin' })) expect(onOperate).toHaveBeenCalledWith('pin', mockItem) @@ -380,9 +398,7 @@ describe('Item', () => { }) it('should update when currentConversationId changes', () => { - const { container, rerender } = render( - <Item {...defaultProps} currentConversationId="0" />, - ) + const { container, rerender } = render(<Item {...defaultProps} currentConversationId="0" />) expect(container.firstChild).not.toHaveClass('bg-state-accent-active') @@ -420,23 +436,11 @@ describe('Item', () => { it('should update when multiple props change together', () => { const { rerender } = render( - <Item - {...defaultProps} - item={mockItem} - currentConversationId="0" - isPin={false} - />, + <Item {...defaultProps} item={mockItem} currentConversationId="0" isPin={false} />, ) const newItem = { ...mockItem, name: 'New Name', id: '2' } - rerender( - <Item - {...defaultProps} - item={newItem} - currentConversationId="2" - isPin={true} - />, - ) + rerender(<Item {...defaultProps} item={newItem} currentConversationId="2" isPin={true} />) expect(screen.getByText('New Name')).toBeInTheDocument() diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/list.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/list.spec.tsx index a0d04fb271d9e2..8d0ce9cd04417e 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/list.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/list.spec.tsx @@ -5,11 +5,7 @@ import List from '../list' // Mock Item to verify its usage vi.mock('../item', () => ({ - default: ({ item }: { item: { name: string } }) => ( - <div data-testid="mock-item"> - {item.name} - </div> - ), + default: ({ item }: { item: { name: string } }) => <div data-testid="mock-item">{item.name}</div>, })) describe('List', () => { diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/rename-modal.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/rename-modal.spec.tsx index 32e2990b65f8fc..3a9c3c97e6732c 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/rename-modal.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/rename-modal.spec.tsx @@ -7,11 +7,9 @@ import { withSelectorKey } from '@/test/i18n-mock' import RenameModal from '../rename-modal' vi.mock('@langgenius/dify-ui/dialog', () => ({ - Dialog: ({ children, open }: { children: ReactNode, open?: boolean }) => + Dialog: ({ children, open }: { children: ReactNode; open?: boolean }) => open === false ? null : <>{children}</>, - DialogContent: ({ children }: { children: ReactNode }) => ( - <div role="dialog">{children}</div> - ), + DialogContent: ({ children }: { children: ReactNode }) => <div role="dialog">{children}</div>, DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>, })) @@ -33,7 +31,9 @@ describe('RenameModal', () => { expect(screen.getByText('common.chat.renameConversation')).toBeInTheDocument() expect(screen.getByText('common.chat.conversationName')).toBeInTheDocument() - expect(screen.getByPlaceholderText('common.chat.conversationNamePlaceholder')).toHaveValue('Original Name') + expect(screen.getByPlaceholderText('common.chat.conversationNamePlaceholder')).toHaveValue( + 'Original Name', + ) expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() expect(screen.getByText('common.operation.save')).toBeInTheDocument() }) @@ -114,24 +114,24 @@ describe('RenameModal', () => { it('uses empty placeholder fallback when translation returns empty string', () => { const originalUseTranslation = ReactI18next.useTranslation - const useTranslationSpy = vi.spyOn(ReactI18next, 'useTranslation').mockImplementation((...args) => { - const translation = originalUseTranslation(...args) - return { - ...translation, - t: withSelectorKey((key: string, options?: Record<string, unknown>) => { - if (key === 'chat.conversationNamePlaceholder') - return '' - const ns = options?.ns as string | undefined - return ns ? `${ns}.${key}` : key - }) as typeof translation.t, - } - }) + const useTranslationSpy = vi + .spyOn(ReactI18next, 'useTranslation') + .mockImplementation((...args) => { + const translation = originalUseTranslation(...args) + return { + ...translation, + t: withSelectorKey((key: string, options?: Record<string, unknown>) => { + if (key === 'chat.conversationNamePlaceholder') return '' + const ns = options?.ns as string | undefined + return ns ? `${ns}.${key}` : key + }) as typeof translation.t, + } + }) try { render(<RenameModal {...defaultProps} />) expect(screen.getByPlaceholderText('')).toBeInTheDocument() - } - finally { + } finally { useTranslationSpy.mockRestore() } }) diff --git a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx index a318424c0f6a1f..f62ec1d4ab5e48 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx @@ -11,10 +11,7 @@ import { import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' -import { - useCallback, - useState, -} from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import AppIcon from '@/app/components/base/app-icon' @@ -55,46 +52,47 @@ const Sidebar = ({ isPanel }: Props) => { const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null) const [showRename, setShowRename] = useState<ConversationItem | null>(null) - const handleOperate = useCallback((type: string, item: ConversationItem) => { - if (type === 'pin') - handlePinConversation(item.id) + const handleOperate = useCallback( + (type: string, item: ConversationItem) => { + if (type === 'pin') handlePinConversation(item.id) - if (type === 'unpin') - handleUnpinConversation(item.id) + if (type === 'unpin') handleUnpinConversation(item.id) - if (type === 'delete') - setShowConfirm(item) + if (type === 'delete') setShowConfirm(item) - if (type === 'rename') - setShowRename(item) - }, [handlePinConversation, handleUnpinConversation]) + if (type === 'rename') setShowRename(item) + }, + [handlePinConversation, handleUnpinConversation], + ) const handleCancelConfirm = useCallback(() => { setShowConfirm(null) }, []) const handleDelete = useCallback(() => { - if (showConfirm) - handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm }) + if (showConfirm) handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm }) }, [showConfirm, handleDeleteConversation, handleCancelConfirm]) const handleCancelRename = useCallback(() => { setShowRename(null) }, []) - const handleRename = useCallback((newName: string) => { - if (showRename) - handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename }) - }, [showRename, handleRenameConversation, handleCancelRename]) - const pinnedTitle = t($ => $['chat.pinnedTitle'], { ns: 'share' }) || '' - const deleteConversationContent = t($ => $['chat.deleteConversation.content'], { ns: 'share' }) || '' + const handleRename = useCallback( + (newName: string) => { + if (showRename) + handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename }) + }, + [showRename, handleRenameConversation, handleCancelRename], + ) + const pinnedTitle = t(($) => $['chat.pinnedTitle'], { ns: 'share' }) || '' + const deleteConversationContent = + t(($) => $['chat.deleteConversation.content'], { ns: 'share' }) || '' return ( - <div className={cn( - 'flex w-full grow flex-col', - isPanel && 'rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-bg shadow-lg', - )} - > - <div className={cn( - 'flex shrink-0 items-center gap-3 p-3 pr-2', + <div + className={cn( + 'flex w-full grow flex-col', + isPanel && + 'rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-bg shadow-lg', )} - > + > + <div className={cn('flex shrink-0 items-center gap-3 p-3 pr-2')}> <div className="shrink-0"> <AppIcon size="large" @@ -104,7 +102,9 @@ const Sidebar = ({ isPanel }: Props) => { imageUrl={appData?.site.icon_url} /> </div> - <div className={cn('grow truncate system-md-semibold text-text-secondary')}>{appData?.site.title}</div> + <div className={cn('grow truncate system-md-semibold text-text-secondary')}> + {appData?.site.title} + </div> {!isMobile && isSidebarCollapsed && ( <ActionButton size="l" onClick={() => handleSidebarCollapse(false)}> <span aria-hidden className="i-ri-expand-right-line h-[18px] w-[18px]" /> @@ -117,9 +117,14 @@ const Sidebar = ({ isPanel }: Props) => { )} </div> <div className="shrink-0 px-3 py-4"> - <Button variant="secondary-accent" disabled={isResponding} className="w-full justify-center" onClick={handleNewConversation}> + <Button + variant="secondary-accent" + disabled={isResponding} + className="w-full justify-center" + onClick={handleNewConversation} + > <span aria-hidden className="mr-1 i-ri-edit-box-line size-4" /> - {t($ => $['chat.newChat'], { ns: 'share' })} + {t(($) => $['chat.newChat'], { ns: 'share' })} </Button> </div> <div className="h-0 grow space-y-2 overflow-y-auto px-3 pt-4"> @@ -138,7 +143,11 @@ const Sidebar = ({ isPanel }: Props) => { )} {!!conversationList.length && ( <List - title={(pinnedConversationList.length && t($ => $['chat.unpinnedTitle'], { ns: 'share' })) || ''} + title={ + (pinnedConversationList.length && + t(($) => $['chat.unpinnedTitle'], { ns: 'share' })) || + '' + } list={conversationList} onChangeConversation={handleChangeConversation} onOperate={handleOperate} @@ -147,43 +156,48 @@ const Sidebar = ({ isPanel }: Props) => { )} </div> <div className="flex shrink-0 items-center justify-between p-3"> - <MenuDropdown - hideLogout={isInstalledApp} - placement="top-start" - data={appData?.site} - /> + <MenuDropdown hideLogout={isInstalledApp} placement="top-start" data={appData?.site} /> {/* powered by */} <div className="shrink-0"> {!appData?.custom_config?.remove_webapp_brand && ( - <div className={cn( - 'flex shrink-0 items-center gap-1.5 px-1', - )} - > - <div className="system-2xs-medium-uppercase text-text-tertiary">{t($ => $['chat.poweredBy'], { ns: 'share' })}</div> - { - systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo - ? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" /> - : appData?.custom_config?.replace_webapp_logo - ? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt="logo" className="block h-5 w-auto" /> - : <DifyLogo size="small" /> - } + <div className={cn('flex shrink-0 items-center gap-1.5 px-1')}> + <div className="system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['chat.poweredBy'], { ns: 'share' })} + </div> + {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo ? ( + <img + src={systemFeatures.branding.workspace_logo} + alt="logo" + className="block h-5 w-auto" + /> + ) : appData?.custom_config?.replace_webapp_logo ? ( + <img + src={`${appData?.custom_config?.replace_webapp_logo}`} + alt="logo" + className="block h-5 w-auto" + /> + ) : ( + <DifyLogo size="small" /> + )} </div> )} </div> - <AlertDialog open={!!showConfirm} onOpenChange={open => !open && handleCancelConfirm()}> + <AlertDialog open={!!showConfirm} onOpenChange={(open) => !open && handleCancelConfirm()}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['chat.deleteConversation.title'], { ns: 'share' })} + {t(($) => $['chat.deleteConversation.title'], { ns: 'share' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> {deleteConversationContent} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={handleDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/base/chat/chat-with-history/sidebar/item.tsx b/web/app/components/base/chat/chat-with-history/sidebar/item.tsx index 54eb6bd6d6315a..f4afa8b203db62 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/item.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/item.tsx @@ -2,10 +2,7 @@ import type { FC } from 'react' import type { ConversationItem } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' import { useHover } from 'ahooks' -import { - memo, - useRef, -} from 'react' +import { memo, useRef } from 'react' import Operation from '@/app/components/base/chat/chat-with-history/sidebar/operation' type ItemProps = { @@ -36,9 +33,11 @@ const Item: FC<ItemProps> = ({ )} onClick={() => onChangeConversation(item.id)} > - <div className="grow truncate p-1 pl-0" title={item.name}>{item.name}</div> + <div className="grow truncate p-1 pl-0" title={item.name}> + {item.name} + </div> {item.id !== '' && ( - <div className="shrink-0" onClick={e => e.stopPropagation()}> + <div className="shrink-0" onClick={(e) => e.stopPropagation()}> <Operation isActive={isSelected} isPinned={!!isPin} diff --git a/web/app/components/base/chat/chat-with-history/sidebar/list.tsx b/web/app/components/base/chat/chat-with-history/sidebar/list.tsx index fda1ceea0fcc9d..201df7b9c513b9 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/list.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/list.tsx @@ -23,7 +23,7 @@ const List: FC<ListProps> = ({ {title && ( <div className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{title}</div> )} - {list.map(item => ( + {list.map((item) => ( <Item key={item.id} isPin={isPin} diff --git a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx index 22ebb864275155..710f6b2e069ea4 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx @@ -38,25 +38,23 @@ const Operation: FC<Props> = ({ const [open, setOpen] = useState(false) const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false) useEffect(() => { - if (!isItemHovering && !isHovering) - setOpen(false) + if (!isItemHovering && !isHovering) setOpen(false) }, [isItemHovering, isHovering]) const handleDeferredAction = useCallback((action?: () => void) => { - if (!action) - return + if (!action) return setOpen(false) queueMicrotask(action) }, []) return ( - <DropdownMenu - modal={false} - open={open} - onOpenChange={setOpen} - > + <DropdownMenu modal={false} open={open} onOpenChange={setOpen}> <DropdownMenuTrigger - render={( + render={ <ActionButton - className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')} + className={cn( + isItemHovering || open + ? 'pointer-events-auto opacity-100' + : 'pointer-events-none opacity-0', + )} state={ isActive ? ActionButtonState.Active @@ -67,8 +65,8 @@ const Operation: FC<Props> = ({ > <span aria-hidden className="i-ri-more-fill size-4" /> </ActionButton> - )} - onClick={e => e.stopPropagation()} + } + onClick={(e) => e.stopPropagation()} /> <DropdownMenuContent placement="bottom-end" @@ -77,7 +75,7 @@ const Operation: FC<Props> = ({ popupProps={{ onMouseEnter: setIsHovering, onMouseLeave: setNotHovering, - onClick: e => e.stopPropagation(), + onClick: (e) => e.stopPropagation(), }} > <DropdownMenuItem @@ -87,9 +85,17 @@ const Operation: FC<Props> = ({ togglePin() }} > - {isPinned && <span aria-hidden className="i-ri-unpin-line size-4 shrink-0 text-text-tertiary" />} - {!isPinned && <span aria-hidden className="i-ri-pushpin-line size-4 shrink-0 text-text-tertiary" />} - <span className="grow">{isPinned ? t($ => $['sidebar.action.unpin'], { ns: 'explore' }) : t($ => $['sidebar.action.pin'], { ns: 'explore' })}</span> + {isPinned && ( + <span aria-hidden className="i-ri-unpin-line size-4 shrink-0 text-text-tertiary" /> + )} + {!isPinned && ( + <span aria-hidden className="i-ri-pushpin-line size-4 shrink-0 text-text-tertiary" /> + )} + <span className="grow"> + {isPinned + ? t(($) => $['sidebar.action.unpin'], { ns: 'explore' }) + : t(($) => $['sidebar.action.pin'], { ns: 'explore' })} + </span> </DropdownMenuItem> {isShowRenameConversation && ( <DropdownMenuItem @@ -100,7 +106,7 @@ const Operation: FC<Props> = ({ }} > <span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" /> - <span className="grow">{t($ => $['sidebar.action.rename'], { ns: 'explore' })}</span> + <span className="grow">{t(($) => $['sidebar.action.rename'], { ns: 'explore' })}</span> </DropdownMenuItem> )} {isShowDelete && ( @@ -113,7 +119,7 @@ const Operation: FC<Props> = ({ }} > <span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" /> - <span className="grow">{t($ => $['sidebar.action.delete'], { ns: 'explore' })}</span> + <span className="grow">{t(($) => $['sidebar.action.delete'], { ns: 'explore' })}</span> </DropdownMenuItem> )} </DropdownMenuContent> diff --git a/web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx b/web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx index e0ea989bca6ba5..746d9f0bce7b88 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx @@ -1,11 +1,7 @@ 'use client' import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' -import { - Dialog, - DialogContent, - DialogTitle, -} from '@langgenius/dify-ui/dialog' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -19,37 +15,40 @@ type IRenameModalProps = { onSave: (name: string) => void } -const RenameModal: FC<IRenameModalProps> = ({ - isShow, - saveLoading, - name, - onClose, - onSave, -}) => { +const RenameModal: FC<IRenameModalProps> = ({ isShow, saveLoading, name, onClose, onSave }) => { const { t } = useTranslation() const [tempName, setTempName] = useState(name) - const conversationNamePlaceholder = t($ => $['chat.conversationNamePlaceholder'], { ns: 'common' }) || '' + const conversationNamePlaceholder = + t(($) => $['chat.conversationNamePlaceholder'], { ns: 'common' }) || '' return ( - <Dialog - open={isShow} - onOpenChange={open => !open && onClose()} - > + <Dialog open={isShow} onOpenChange={(open) => !open && onClose()}> <DialogContent> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['chat.renameConversation'], { ns: 'common' })} + {t(($) => $['chat.renameConversation'], { ns: 'common' })} </DialogTitle> - <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t($ => $['chat.conversationName'], { ns: 'common' })}</div> + <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary"> + {t(($) => $['chat.conversationName'], { ns: 'common' })} + </div> <Input className="mt-2 h-10 w-full" value={tempName} - onChange={e => setTempName(e.target.value)} + onChange={(e) => setTempName(e.target.value)} placeholder={conversationNamePlaceholder} /> <div className="mt-10 flex justify-end"> - <Button className="mr-2 shrink-0" onClick={onClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" className="shrink-0" onClick={() => onSave(tempName)} loading={saveLoading}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button className="mr-2 shrink-0" onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button + variant="primary" + className="shrink-0" + onClick={() => onSave(tempName)} + loading={saveLoading} + > + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> </DialogContent> </Dialog> diff --git a/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx b/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx index d888fbce98450e..c6aeeaeeca671c 100644 --- a/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx @@ -12,7 +12,9 @@ vi.mock('@/service/log', () => ({ describe('ChatLogModals', () => { beforeEach(() => { vi.clearAllMocks() - useAppStore.setState({ appDetail: { id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail'] }) + useAppStore.setState({ + appDetail: { id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail'], + }) }) // Modal visibility should follow the two booleans unless log modals are globally hidden. @@ -23,14 +25,16 @@ describe('ChatLogModals', () => { render( <ChatLogModals width={480} - currentLogItem={{ - id: 'log-1', - isAnswer: true, - content: 'reply', - input: { question: 'hello' }, - log: [{ role: 'user', text: 'Prompt body' }], - conversationId: 'conversation-1', - } as IChatItem} + currentLogItem={ + { + id: 'log-1', + isAnswer: true, + content: 'reply', + input: { question: 'hello' }, + log: [{ role: 'user', text: 'Prompt body' }], + conversationId: 'conversation-1', + } as IChatItem + } showPromptLogModal={true} showAgentLogModal={true} setCurrentLogItem={vi.fn()} @@ -43,7 +47,9 @@ describe('ChatLogModals', () => { expect(screen.getByText('Prompt body')).toBeInTheDocument() await waitFor(() => { - expect(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }), + ).toBeInTheDocument() }) }) @@ -51,13 +57,15 @@ describe('ChatLogModals', () => { render( <ChatLogModals width={320} - currentLogItem={{ - id: 'log-2', - isAnswer: true, - content: 'reply', - log: [{ role: 'user', text: 'Prompt body' }], - conversationId: 'conversation-2', - } as IChatItem} + currentLogItem={ + { + id: 'log-2', + isAnswer: true, + content: 'reply', + log: [{ role: 'user', text: 'Prompt body' }], + conversationId: 'conversation-2', + } as IChatItem + } showPromptLogModal={true} showAgentLogModal={true} hideLogModal={true} @@ -68,7 +76,9 @@ describe('ChatLogModals', () => { ) expect(screen.queryByText('PROMPT LOG')).not.toBeInTheDocument() - expect(screen.queryByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: /appLog.runDetail.workflowTitle/i }), + ).not.toBeInTheDocument() }) }) @@ -83,13 +93,15 @@ describe('ChatLogModals', () => { render( <ChatLogModals width={480} - currentLogItem={{ - id: 'log-3', - isAnswer: true, - content: 'reply', - input: { question: 'hello' }, - log: [{ role: 'user', text: 'Prompt body' }], - } as IChatItem} + currentLogItem={ + { + id: 'log-3', + isAnswer: true, + content: 'reply', + input: { question: 'hello' }, + log: [{ role: 'user', text: 'Prompt body' }], + } as IChatItem + } showPromptLogModal={true} showAgentLogModal={false} setCurrentLogItem={setCurrentLogItem} @@ -115,14 +127,16 @@ describe('ChatLogModals', () => { render( <ChatLogModals width={480} - currentLogItem={{ - id: 'log-4', - isAnswer: true, - content: 'reply', - input: { question: 'hello' }, - log: [{ role: 'user', text: 'Prompt body' }], - conversationId: 'conversation-4', - } as IChatItem} + currentLogItem={ + { + id: 'log-4', + isAnswer: true, + content: 'reply', + input: { question: 'hello' }, + log: [{ role: 'user', text: 'Prompt body' }], + conversationId: 'conversation-4', + } as IChatItem + } showPromptLogModal={false} showAgentLogModal={true} setCurrentLogItem={setCurrentLogItem} @@ -132,9 +146,14 @@ describe('ChatLogModals', () => { ) await waitFor(() => { - expect(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }), + ).toBeInTheDocument() }) - await user.click(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }).nextElementSibling as HTMLElement) + await user.click( + screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }) + .nextElementSibling as HTMLElement, + ) expect(setCurrentLogItem).toHaveBeenCalled() expect(setShowAgentLogModal).toHaveBeenCalledWith(false) diff --git a/web/app/components/base/chat/chat/__tests__/check-input-forms-hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/check-input-forms-hooks.spec.tsx index 28cc3b9eeff8c2..a9c3a7af1b3191 100644 --- a/web/app/components/base/chat/chat/__tests__/check-input-forms-hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/check-input-forms-hooks.spec.tsx @@ -30,7 +30,14 @@ describe('useCheckInputsForms', () => { it('should return false and notify when a required input is missing', () => { const { result } = renderHook(() => useCheckInputsForms()) - const inputsForm = [{ variable: 'test_var', label: 'Test Variable', required: true, type: InputVarType.textInput as string }] + const inputsForm = [ + { + variable: 'test_var', + label: 'Test Variable', + required: true, + type: InputVarType.textInput as string, + }, + ] const isValid = result.current.checkInputsForm({}, inputsForm as InputForm[]) expect(isValid).toBe(false) @@ -44,7 +51,14 @@ describe('useCheckInputsForms', () => { it('should ignore missing but not required inputs', () => { const { result } = renderHook(() => useCheckInputsForms()) - const inputsForm = [{ variable: 'test_var', label: 'Test Variable', required: false, type: InputVarType.textInput as string }] + const inputsForm = [ + { + variable: 'test_var', + label: 'Test Variable', + required: false, + type: InputVarType.textInput as string, + }, + ] const isValid = result.current.checkInputsForm({}, inputsForm as InputForm[]) expect(isValid).toBe(true) @@ -53,37 +67,62 @@ describe('useCheckInputsForms', () => { it('should notify and return undefined when a file is still uploading (singleFile)', () => { const { result } = renderHook(() => useCheckInputsForms()) - const inputsForm = [{ variable: 'test_file', label: 'Test File', required: true, type: InputVarType.singleFile as string }] + const inputsForm = [ + { + variable: 'test_file', + label: 'Test File', + required: true, + type: InputVarType.singleFile as string, + }, + ] const inputs = { test_file: { transferMethod: TransferMethod.local_file }, // no uploadedId means still uploading } const isValid = result.current.checkInputsForm(inputs, inputsForm as InputForm[]) expect(isValid).toBeUndefined() - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'info', - message: 'appDebug.errorMessage.waitForFileUpload', - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + message: 'appDebug.errorMessage.waitForFileUpload', + }), + ) }) it('should notify and return undefined when a file is still uploading (multiFiles)', () => { const { result } = renderHook(() => useCheckInputsForms()) - const inputsForm = [{ variable: 'test_files', label: 'Test Files', required: true, type: InputVarType.multiFiles as string }] + const inputsForm = [ + { + variable: 'test_files', + label: 'Test Files', + required: true, + type: InputVarType.multiFiles as string, + }, + ] const inputs = { test_files: [{ transferMethod: TransferMethod.local_file }], // no uploadedId } const isValid = result.current.checkInputsForm(inputs, inputsForm as InputForm[]) expect(isValid).toBeUndefined() - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'info', - message: 'appDebug.errorMessage.waitForFileUpload', - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + message: 'appDebug.errorMessage.waitForFileUpload', + }), + ) }) it('should return true when all files are uploaded and required variables are present', () => { const { result } = renderHook(() => useCheckInputsForms()) - const inputsForm = [{ variable: 'test_file', label: 'Test File', required: true, type: InputVarType.singleFile as string }] + const inputsForm = [ + { + variable: 'test_file', + label: 'Test File', + required: true, + type: InputVarType.singleFile as string, + }, + ] const inputs = { test_file: { transferMethod: TransferMethod.local_file, uploadedId: '123' }, // uploaded } @@ -96,8 +135,18 @@ describe('useCheckInputsForms', () => { it('should short-circuit remaining fields after first required input is missing', () => { const { result } = renderHook(() => useCheckInputsForms()) const inputsForm = [ - { variable: 'missing_text', label: 'Missing Text', required: true, type: InputVarType.textInput as string }, - { variable: 'later_file', label: 'Later File', required: true, type: InputVarType.singleFile as string }, + { + variable: 'missing_text', + label: 'Missing Text', + required: true, + type: InputVarType.textInput as string, + }, + { + variable: 'later_file', + label: 'Later File', + required: true, + type: InputVarType.singleFile as string, + }, ] const inputs = { later_file: { transferMethod: TransferMethod.local_file }, @@ -107,17 +156,29 @@ describe('useCheckInputsForms', () => { expect(isValid).toBe(false) expect(mockNotify).toHaveBeenCalledTimes(1) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: expect.stringContaining('appDebug.errorMessage.valueOfVarRequired'), - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: expect.stringContaining('appDebug.errorMessage.valueOfVarRequired'), + }), + ) }) it('should short-circuit remaining fields after detecting file upload in progress', () => { const { result } = renderHook(() => useCheckInputsForms()) const inputsForm = [ - { variable: 'uploading_file', label: 'Uploading File', required: true, type: InputVarType.singleFile as string }, - { variable: 'later_required_text', label: 'Later Required Text', required: true, type: InputVarType.textInput as string }, + { + variable: 'uploading_file', + label: 'Uploading File', + required: true, + type: InputVarType.singleFile as string, + }, + { + variable: 'later_required_text', + label: 'Later Required Text', + required: true, + type: InputVarType.textInput as string, + }, ] const inputs = { uploading_file: { transferMethod: TransferMethod.local_file }, // still uploading @@ -128,9 +189,11 @@ describe('useCheckInputsForms', () => { expect(isValid).toBeUndefined() expect(mockNotify).toHaveBeenCalledTimes(1) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'info', - message: 'appDebug.errorMessage.waitForFileUpload', - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + message: 'appDebug.errorMessage.waitForFileUpload', + }), + ) }) }) diff --git a/web/app/components/base/chat/chat/__tests__/context.spec.tsx b/web/app/components/base/chat/chat/__tests__/context.spec.tsx index aeba073a7bbd3e..0d4ff025a12f4f 100644 --- a/web/app/components/base/chat/chat/__tests__/context.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/context.spec.tsx @@ -16,7 +16,9 @@ const TestConsumer = () => { <div data-testid="chatListCount">{context.chatList.length}</div> <div data-testid="questionIcon">{context.questionIcon}</div> <button onClick={() => context.onSend?.('test message', [])}>Send Button</button> - <button onClick={() => context.onRegenerate?.({ id: '1' } as ChatItem, { message: 'retry' })}>Regenerate Button</button> + <button onClick={() => context.onRegenerate?.({ id: '1' } as ChatItem, { message: 'retry' })}> + Regenerate Button + </button> </div> ) } diff --git a/web/app/components/base/chat/chat/__tests__/hooks.multimodal.spec.ts b/web/app/components/base/chat/chat/__tests__/hooks.multimodal.spec.ts index 2975d62887bc34..6055d109ba10b2 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.multimodal.spec.ts +++ b/web/app/components/base/chat/chat/__tests__/hooks.multimodal.spec.ts @@ -29,7 +29,9 @@ describe('Multimodal File Handling', () => { it('should map unknown to application/octet-stream', () => { const fileType: string = 'unknown' const expectedMime = 'application/octet-stream' - const mimeType = ['image', 'video', 'audio'].includes(fileType) ? 'image/png' : 'application/octet-stream' + const mimeType = ['image', 'video', 'audio'].includes(fileType) + ? 'image/png' + : 'application/octet-stream' expect(mimeType).toBe(expectedMime) }) }) @@ -97,25 +99,53 @@ describe('Multimodal File Handling', () => { describe('SupportFileType mapping', () => { it('should map image type to image supportFileType', () => { const fileType: string = 'image' - const supportFileType = fileType === 'image' ? 'image' : fileType === 'video' ? 'video' : fileType === 'audio' ? 'audio' : 'document' + const supportFileType = + fileType === 'image' + ? 'image' + : fileType === 'video' + ? 'video' + : fileType === 'audio' + ? 'audio' + : 'document' expect(supportFileType).toBe('image') }) it('should map video type to video supportFileType', () => { const fileType: string = 'video' - const supportFileType = fileType === 'image' ? 'image' : fileType === 'video' ? 'video' : fileType === 'audio' ? 'audio' : 'document' + const supportFileType = + fileType === 'image' + ? 'image' + : fileType === 'video' + ? 'video' + : fileType === 'audio' + ? 'audio' + : 'document' expect(supportFileType).toBe('video') }) it('should map audio type to audio supportFileType', () => { const fileType: string = 'audio' - const supportFileType = fileType === 'image' ? 'image' : fileType === 'video' ? 'video' : fileType === 'audio' ? 'audio' : 'document' + const supportFileType = + fileType === 'image' + ? 'image' + : fileType === 'video' + ? 'video' + : fileType === 'audio' + ? 'audio' + : 'document' expect(supportFileType).toBe('audio') }) it('should map unknown type to document supportFileType', () => { const fileType: string = 'unknown' - const supportFileType = fileType === 'image' ? 'image' : fileType === 'video' ? 'video' : fileType === 'audio' ? 'audio' : 'document' + const supportFileType = + fileType === 'image' + ? 'image' + : fileType === 'video' + ? 'video' + : fileType === 'audio' + ? 'audio' + : 'document' expect(supportFileType).toBe('document') }) }) @@ -165,13 +195,15 @@ describe('Multimodal File Handling', () => { const nonAgentResponse: { agent_thoughts?: Array<Record<string, unknown>> } = { agent_thoughts: [], } - const isAgentMode = nonAgentResponse.agent_thoughts && nonAgentResponse.agent_thoughts.length > 0 + const isAgentMode = + nonAgentResponse.agent_thoughts && nonAgentResponse.agent_thoughts.length > 0 expect(isAgentMode).toBe(false) }) it('should detect non-agent mode when agent_thoughts is undefined', () => { const nonAgentResponse: { agent_thoughts?: Array<Record<string, unknown>> } = {} - const isAgentMode = nonAgentResponse.agent_thoughts && nonAgentResponse.agent_thoughts.length > 0 + const isAgentMode = + nonAgentResponse.agent_thoughts && nonAgentResponse.agent_thoughts.length > 0 expect(isAgentMode).toBeFalsy() }) }) diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx index ccfcd3c78ff3af..8bbbb3b4c73557 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -72,7 +72,9 @@ type HookCallbacks = { onWorkflowPaused: (workflowPaused: Record<string, unknown>) => void onTTSChunk: (messageId: string, audio: string) => void onTTSEnd: (messageId: string, audio: string) => void - onReasoning: (chunk: { data: { message_id?: string, reasoning: string, node_id?: string, is_final?: boolean } }) => void + onReasoning: (chunk: { + data: { message_id?: string; reasoning: string; node_id?: string; is_final?: boolean } + }) => void } type UseChatFormSettings = NonNullable<Parameters<typeof useChat>[1]> @@ -96,16 +98,11 @@ describe('useChat', () => { }) it('should pass timestamp options to timestamp formatter', () => { - renderHook(() => useChat( - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { timezone: 'UTC' }, - )) + renderHook(() => + useChat(undefined, undefined, undefined, undefined, undefined, undefined, undefined, { + timezone: 'UTC', + }), + ) expect(useTimestampMock).toHaveBeenCalledWith({ timezone: 'UTC' }) }) @@ -131,15 +128,19 @@ describe('useChat', () => { opening_statement: 'Hello updated', suggested_questions: [''], } - const prevChatTree = [{ - id: 'opening-statement', - content: 'old', - isAnswer: true, - isOpeningStatement: true, - suggestedQuestions: [], - }] - - const { result } = renderHook(() => useChat(config as ChatConfig, undefined, prevChatTree as ChatItemInTree[])) + const prevChatTree = [ + { + id: 'opening-statement', + content: 'old', + isAnswer: true, + isOpeningStatement: true, + suggestedQuestions: [], + }, + ] + + const { result } = renderHook(() => + useChat(config as ChatConfig, undefined, prevChatTree as ChatItemInTree[]), + ) expect(result.current.chatList).toHaveLength(1) expect(result.current.chatList[0]!.content).toBe('Hello updated') }) @@ -153,15 +154,23 @@ describe('useChat', () => { inputs: { name: 'Bob' }, inputsForm: [], } - const prevChatTree = [{ - id: 'opening-statement', - content: 'old', - isAnswer: true, - isOpeningStatement: true, - suggestedQuestions: [], - }] + const prevChatTree = [ + { + id: 'opening-statement', + content: 'old', + isAnswer: true, + isOpeningStatement: true, + suggestedQuestions: [], + }, + ] - const { result } = renderHook(() => useChat(config as ChatConfig, formSettings as UseChatFormSettings, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat( + config as ChatConfig, + formSettings as UseChatFormSettings, + prevChatTree as ChatItemInTree[], + ), + ) expect(result.current.chatList[0]!.content).toBe('Hello Bob') expect(result.current.chatList[0]!.suggestedQuestions).toEqual(['Ask Bob']) @@ -195,7 +204,11 @@ describe('useChat', () => { expect(result.current.chatList[0]).toBe(openerInitial) act(() => { - callbacks.onData('chunk-1 ', true, { messageId: 'm-1', conversationId: 'c-1', taskId: 't-1' }) + callbacks.onData('chunk-1 ', true, { + messageId: 'm-1', + conversationId: 'c-1', + taskId: 't-1', + }) }) expect(result.current.chatList.length).toBeGreaterThan(1) expect(result.current.chatList[0]).toBe(openerInitial) @@ -266,13 +279,15 @@ describe('useChat', () => { opening_statement: 'Hello updated', suggested_questions: ['S1'], } - const prevChatTree = [{ - id: 'opening-statement', - content: 'old', - isAnswer: true, - isOpeningStatement: true, - suggestedQuestions: [], - }] + const prevChatTree = [ + { + id: 'opening-statement', + content: 'old', + isAnswer: true, + isOpeningStatement: true, + suggestedQuestions: [], + }, + ] const { result } = renderHook(() => useChat(config as ChatConfig, undefined, prevChatTree as ChatItemInTree[]), @@ -299,9 +314,7 @@ describe('useChat', () => { }) it('should use a stable id of "opening-statement"', () => { - const { result } = renderHook(() => - useChat({ opening_statement: 'Hi' } as ChatConfig), - ) + const { result } = renderHook(() => useChat({ opening_statement: 'Hi' } as ChatConfig)) expect(result.current.chatList[0]!.id).toBe('opening-statement') }) }) @@ -355,31 +368,41 @@ describe('useChat', () => { it('should process inputs with the per-send input form override', () => { vi.mocked(ssePost).mockImplementation(async () => undefined) - const { result } = renderHook(() => useChat(undefined, { - inputs: {}, - inputsForm: [{ - type: InputVarType.textInput, - label: 'City', - variable: 'city', - required: true, - hide: false, - }], - })) - - act(() => { - result.current.handleSend('test-url', { - query: 'hello', - inputs: { - enabled: undefined, + const { result } = renderHook(() => + useChat(undefined, { + inputs: {}, + inputsForm: [ + { + type: InputVarType.textInput, + label: 'City', + variable: 'city', + required: true, + hide: false, + }, + ], + }), + ) + + act(() => { + result.current.handleSend( + 'test-url', + { + query: 'hello', + inputs: { + enabled: undefined, + }, + overrideInputsForm: [ + { + type: InputVarType.checkbox, + label: 'Enabled', + variable: 'enabled', + required: true, + hide: false, + }, + ], }, - overrideInputsForm: [{ - type: InputVarType.checkbox, - label: 'Enabled', - variable: 'enabled', - required: true, - hide: false, - }], - }, {}) + {}, + ) }) expect(ssePost).toHaveBeenCalledWith( @@ -406,9 +429,13 @@ describe('useChat', () => { const { result } = renderHook(() => useChat()) act(() => { - result.current.handleSend('test-url', { query: 'hello' }, { - onSendSettled, - }) + result.current.handleSend( + 'test-url', + { query: 'hello' }, + { + onSendSettled, + }, + ) }) act(() => { @@ -436,19 +463,33 @@ describe('useChat', () => { act(() => { // onWorkflowStarted - callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1', message_id: 'm-2', conversation_id: 'c-1' }) + callbacks.onWorkflowStarted({ + workflow_run_id: 'wr-1', + task_id: 't-1', + message_id: 'm-2', + conversation_id: 'c-1', + }) // onNodeStarted callbacks.onNodeStarted({ data: { node_id: 'n-1', id: 'n-1', title: 'Node 1' } }) // onThought - callbacks.onThought({ id: 'th-1', message_id: 'm-2', thought: 'thinking...', message_files: [] }) + callbacks.onThought({ + id: 'th-1', + message_id: 'm-2', + thought: 'thinking...', + message_files: [], + }) // onData appends to answer content, not agent thought. callbacks.onData(' detailed', false, { messageId: 'm-2' }) // onThought (update same thought) - callbacks.onThought({ id: 'th-1', message_id: 'm-2', thought: 'thinking... detailed updated' }) + callbacks.onThought({ + id: 'th-1', + message_id: 'm-2', + thought: 'thinking... detailed updated', + }) // onThought (new thought) callbacks.onThought({ id: 'th-2', message_id: 'm-2', thought: 'second thought' }) @@ -499,7 +540,14 @@ describe('useChat', () => { // Human input required callbacks.onHumanInputRequired({ data: { node_id: 'n-human' } }) - callbacks.onHumanInputRequired({ data: { node_id: 'n-human', updated: true, form_content: '{{#$output.answer#}}', inputs: [] } }) // update existing + callbacks.onHumanInputRequired({ + data: { + node_id: 'n-human', + updated: true, + form_content: '{{#$output.answer#}}', + inputs: [], + }, + }) // update existing // setTimeout for timeout form callbacks.onHumanInputFormTimeout({ data: { node_id: 'n-human', expiration_time: 123456 } }) @@ -516,7 +564,12 @@ describe('useChat', () => { callbacks.onTTSEnd('m-3', 'base64audio') // Message end with annotation and files - callbacks.onMessageEnd({ id: 'm-3', metadata: { annotation_reply: { id: 'anno-1', account: { id: 'admin-id', name: 'admin' } } } }) + callbacks.onMessageEnd({ + id: 'm-3', + metadata: { + annotation_reply: { id: 'anno-1', account: { id: 'admin-id', name: 'admin' } }, + }, + }) callbacks.onMessageReplace({ answer: 'Replaced content' }) callbacks.onError() @@ -525,10 +578,12 @@ describe('useChat', () => { const lastResponse = result.current.chatList[1] expect(lastResponse!.humanInputFormDataList).toHaveLength(0) // Removed when filled expect(lastResponse!.humanInputFilledFormDataList).toHaveLength(2) - expect(lastResponse!.humanInputFilledFormDataList![0]).toEqual(expect.objectContaining({ - form_content: '{{#$output.answer#}}', - inputs: [], - })) + expect(lastResponse!.humanInputFilledFormDataList![0]).toEqual( + expect.objectContaining({ + form_content: '{{#$output.answer#}}', + inputs: [], + }), + ) expect(sseGet).toHaveBeenCalled() // from workflowPaused expect(lastResponse!.annotation?.id).toBe('anno-1') expect(lastResponse!.content).toBe('Replaced content') @@ -554,7 +609,12 @@ describe('useChat', () => { // agent thought file callbacks.onThought({ id: 'th-1', message_id: 'm-4', thought: 'thinking' }) - callbacks.onFile({ id: 'f-2', type: 'document', url: 'doc.pdf', transferMethod: 'local_file' }) + callbacks.onFile({ + id: 'f-2', + type: 'document', + url: 'doc.pdf', + transferMethod: 'local_file', + }) }) const lastResponse = result.current.chatList[1] @@ -570,19 +630,21 @@ describe('useChat', () => { }) const onGetConversationMessages = vi.fn().mockResolvedValue({ - data: [{ - id: 'm-5', - answer: 'Updated answer from history', - message: [{ role: 'user', text: 'hi' }], - message_files: [{ id: 'assistant-file', belongs_to: 'assistant' }], - created_at: Date.now(), - answer_tokens: 10, - message_tokens: 5, - provider_response_latency: 0.5, - workflow_run_id: 'workflow-run-from-history', - inputs: {}, - query: 'hi', - }], + data: [ + { + id: 'm-5', + answer: 'Updated answer from history', + message: [{ role: 'user', text: 'hi' }], + message_files: [{ id: 'assistant-file', belongs_to: 'assistant' }], + created_at: Date.now(), + answer_tokens: 10, + message_tokens: 5, + provider_response_latency: 0.5, + workflow_run_id: 'workflow-run-from-history', + inputs: {}, + query: 'hi', + }, + ], }) const onGetSuggestedQuestions = vi.fn().mockResolvedValue({ @@ -598,11 +660,15 @@ describe('useChat', () => { const { result } = renderHook(() => useChat(config as ChatConfig)) act(() => { - result.current.handleSend('test-url', { query: 'fetch test' }, { - onGetConversationMessages, - onGetSuggestedQuestions, - onConversationComplete, - }) + result.current.handleSend( + 'test-url', + { query: 'fetch test' }, + { + onGetConversationMessages, + onGetSuggestedQuestions, + onConversationComplete, + }, + ) }) await act(async () => { @@ -633,9 +699,13 @@ describe('useChat', () => { const { result } = renderHook(() => useChat()) act(() => { - result.current.handleSend('test-url', { query: 'error test' }, { - onConversationComplete, - }) + result.current.handleSend( + 'test-url', + { query: 'error test' }, + { + onConversationComplete, + }, + ) }) act(() => { @@ -666,9 +736,17 @@ describe('useChat', () => { callbacks.onNodeStarted({ data: { node_id: 'n-1', id: 'n-1', title: 'Node 1 Updated' } }) // Start an iteration - callbacks.onIterationStart({ data: { node_id: 'iter-1', execution_metadata: { parallel_id: 'p-1' } } }) + callbacks.onIterationStart({ + data: { node_id: 'iter-1', execution_metadata: { parallel_id: 'p-1' } }, + }) // Finish iteration - callbacks.onIterationFinish({ data: { node_id: 'iter-1', execution_metadata: { parallel_id: 'p-1' }, status: 'succeeded' } }) + callbacks.onIterationFinish({ + data: { + node_id: 'iter-1', + execution_metadata: { parallel_id: 'p-1' }, + status: 'succeeded', + }, + }) // Start a loop callbacks.onLoopStart({ data: { node_id: 'loop-1' } }) @@ -694,20 +772,26 @@ describe('useChat', () => { callbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-1', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-3', - content: 'initial', - isAnswer: true, - workflowProcess: { status: 'running', tracing: [] }, // Provide existing tracking - siblingIndex: 0, - }], - }] + const prevChatTree = [ + { + id: 'q-1', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-3', + content: 'initial', + isAnswer: true, + workflowProcess: { status: 'running', tracing: [] }, // Provide existing tracking + siblingIndex: 0, + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) // Simulate resume which triggers another handleSend essentially (if we test via callbacks directly) act(() => { @@ -722,7 +806,9 @@ describe('useChat', () => { callbacks.onNodeFinished({ data: { id: 'n-1', iteration_id: 'iter-1' } }) }) - const traceLen1 = result.current.chatList[result.current.chatList.length - 1]!.workflowProcess?.tracing?.length + const traceLen1 = + result.current.chatList[result.current.chatList.length - 1]!.workflowProcess?.tracing + ?.length expect(traceLen1).toBe(0) // None added due to iteration early hits }) @@ -736,7 +822,11 @@ describe('useChat', () => { const { result } = renderHook(() => useChat()) act(() => { - result.current.handleSend('test-url', { query: 'non-public api trace' }, { isPublicAPI: false }) + result.current.handleSend( + 'test-url', + { query: 'non-public api trace' }, + { isPublicAPI: false }, + ) }) act(() => { @@ -747,10 +837,18 @@ describe('useChat', () => { callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' }) // Trigger onIterationStart - callbacks.onIterationStart({ data: { node_id: 'iter-2', execution_metadata: { parallel_id: 'p-1' } } }) + callbacks.onIterationStart({ + data: { node_id: 'iter-2', execution_metadata: { parallel_id: 'p-1' } }, + }) // Trigger onIterationFinish - callbacks.onIterationFinish({ data: { node_id: 'iter-2', execution_metadata: { parallel_id: 'p-1' }, status: 'succeeded' } }) + callbacks.onIterationFinish({ + data: { + node_id: 'iter-2', + execution_metadata: { parallel_id: 'p-1' }, + status: 'succeeded', + }, + }) // Trigger onNodeStarted when it does not exist callbacks.onNodeStarted({ data: { node_id: 'n-2', id: 'n-2', title: 'Node 2' } }) @@ -804,32 +902,48 @@ describe('useChat', () => { callbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-root', - content: 'root question', - isAnswer: false, - children: [{ - id: 'a-root', - content: 'root answer', - isAnswer: true, - siblingIndex: 0, - children: [], - }], - }] + const prevChatTree = [ + { + id: 'q-root', + content: 'root question', + isAnswer: false, + children: [ + { + id: 'a-root', + content: 'root answer', + isAnswer: true, + siblingIndex: 0, + children: [], + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { - result.current.handleSend('test-url', { query: 'child question', parent_message_id: 'a-root' }, {}) + result.current.handleSend( + 'test-url', + { query: 'child question', parent_message_id: 'a-root' }, + {}, + ) }) act(() => { - callbacks.onData('child answer', true, { messageId: 'm-child', conversationId: 'c-child', taskId: 't-child' }) + callbacks.onData('child answer', true, { + messageId: 'm-child', + conversationId: 'c-child', + taskId: 't-child', + }) }) - expect(result.current.chatList.some(item => item.id === 'question-m-child')).toBe(true) - expect(result.current.chatList.some(item => item.id === 'm-child')).toBe(true) - expect(result.current.chatList[result.current.chatList.length - 1]!.content).toBe('child answer') + expect(result.current.chatList.some((item) => item.id === 'question-m-child')).toBe(true) + expect(result.current.chatList.some((item) => item.id === 'm-child')).toBe(true) + expect(result.current.chatList[result.current.chatList.length - 1]!.content).toBe( + 'child answer', + ) }) it('should strip local file urls before sending payload', () => { @@ -859,7 +973,11 @@ describe('useChat', () => { const { result } = renderHook(() => useChat()) act(() => { - result.current.handleSend('test-url', { query: 'file payload', files: [localFile as FileEntity, remoteFile as FileEntity] }, {}) + result.current.handleSend( + 'test-url', + { query: 'file payload', files: [localFile as FileEntity, remoteFile as FileEntity] }, + {}, + ) }) const payload = vi.mocked(ssePost).mock.calls[0]![1] as { @@ -870,8 +988,8 @@ describe('useChat', () => { }> } } - const localPayload = payload.body.files.find(item => item.transfer_method === 'local_file') - const remotePayload = payload.body.files.find(item => item.transfer_method === 'remote_url') + const localPayload = payload.body.files.find((item) => item.transfer_method === 'local_file') + const remotePayload = payload.body.files.find((item) => item.transfer_method === 'remote_url') expect(localPayload).toBeDefined() expect(remotePayload).toBeDefined() @@ -918,16 +1036,26 @@ describe('useChat', () => { const { result } = renderHook(() => useChat()) act(() => { - result.current.handleSend('test-url', { query: 'history mismatch' }, { onGetConversationMessages }) + result.current.handleSend( + 'test-url', + { query: 'history mismatch' }, + { onGetConversationMessages }, + ) }) await act(async () => { - callbacks.onData('streamed content', true, { messageId: 'm-history', conversationId: 'c-history', taskId: 't-history' }) + callbacks.onData('streamed content', true, { + messageId: 'm-history', + conversationId: 'c-history', + taskId: 't-history', + }) await callbacks.onCompleted() }) expect(onGetConversationMessages).toHaveBeenCalled() - expect(result.current.chatList[result.current.chatList.length - 1]!.content).toBe('streamed content') + expect(result.current.chatList[result.current.chatList.length - 1]!.content).toBe( + 'streamed content', + ) }) it('should clear suggested questions when suggestion fetch fails after completion', async () => { @@ -943,11 +1071,19 @@ describe('useChat', () => { const { result } = renderHook(() => useChat(config as ChatConfig)) act(() => { - result.current.handleSend('test-url', { query: 'suggestion failure' }, { onGetSuggestedQuestions }) + result.current.handleSend( + 'test-url', + { query: 'suggestion failure' }, + { onGetSuggestedQuestions }, + ) }) await act(async () => { - callbacks.onData('answer', true, { messageId: 'm-suggest', conversationId: 'c-suggest', taskId: 't-suggest' }) + callbacks.onData('answer', true, { + messageId: 'm-suggest', + conversationId: 'c-suggest', + taskId: 't-suggest', + }) await callbacks.onCompleted() }) @@ -964,11 +1100,19 @@ describe('useChat', () => { const { result } = renderHook(() => useChat()) act(() => { - result.current.handleSend('test-url', { query: 'loop node guards', loop_id: 'loop-parent' }, {}) + result.current.handleSend( + 'test-url', + { query: 'loop node guards', loop_id: 'loop-parent' }, + {}, + ) }) act(() => { - callbacks.onWorkflowStarted({ workflow_run_id: 'wr-loop', task_id: 't-loop', message_id: 'm-loop' }) + callbacks.onWorkflowStarted({ + workflow_run_id: 'wr-loop', + task_id: 't-loop', + message_id: 'm-loop', + }) callbacks.onNodeStarted({ data: { node_id: 'n-loop', id: 'n-loop' } }) callbacks.onNodeFinished({ data: { node_id: 'n-loop', id: 'n-loop' } }) }) @@ -996,7 +1140,12 @@ describe('useChat', () => { callbacks.onHumanInputRequired({ data: { node_id: 'human-node-2' } }) callbacks.onWorkflowPaused({ data: { workflow_run_id: 'wr-rich' } }) callbacks.onWorkflowFinished({ data: { status: 'succeeded' } }) - callbacks.onThought({ id: 'th-bind', message_id: 'm-th-bind', conversation_id: 'c-th-bind', thought: 'thought text' }) + callbacks.onThought({ + id: 'th-bind', + message_id: 'm-th-bind', + conversation_id: 'c-th-bind', + thought: 'thought text', + }) callbacks.onTTSChunk('m-th-bind', '') }) @@ -1004,8 +1153,14 @@ describe('useChat', () => { expect(latestResponse!.id).toBe('m-th-bind') expect(latestResponse!.conversationId).toBe('c-th-bind') expect(latestResponse!.workflowProcess?.status).toBe('succeeded') - expect(latestResponse!.humanInputFormDataList?.map(item => item.node_id)).toEqual(['human-node', 'human-node-2']) - expect(latestResponse!.workflowProcess?.tracing?.find(item => item.node_id === 'human-node')?.status).toBe('paused') + expect(latestResponse!.humanInputFormDataList?.map((item) => item.node_id)).toEqual([ + 'human-node', + 'human-node-2', + ]) + expect( + latestResponse!.workflowProcess?.tracing?.find((item) => item.node_id === 'human-node') + ?.status, + ).toBe('paused') }) }) @@ -1017,31 +1172,39 @@ describe('useChat', () => { callbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-1', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-1', - content: 'initial', - isAnswer: true, - agent_thoughts: [{ - id: 'th-1', - tool: '', - tool_input: '', - message_id: 'm-1', - conversation_id: 'c-1', - observation: '', - position: 1, - thought: 'thinking', - message_files: [], - }], - message_files: [], - siblingIndex: 0, - }], - }] + const prevChatTree = [ + { + id: 'q-1', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-1', + content: 'initial', + isAnswer: true, + agent_thoughts: [ + { + id: 'th-1', + tool: '', + tool_input: '', + message_id: 'm-1', + conversation_id: 'c-1', + observation: '', + position: 1, + thought: 'thinking', + message_files: [], + }, + ], + message_files: [], + siblingIndex: 0, + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true }) @@ -1054,15 +1217,29 @@ describe('useChat', () => { ) act(() => { - callbacks.onData(' resumed', true, { messageId: 'm-1', conversationId: 'c-1', taskId: 't-1' }) + callbacks.onData(' resumed', true, { + messageId: 'm-1', + conversationId: 'c-1', + taskId: 't-1', + }) callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' }) callbacks.onNodeStarted({ data: { node_id: 'n-1', id: 'n-1', title: 'Node 1' } }) callbacks.onFile({ id: 'f-1', url: 'test.jpg', type: 'image' }) - callbacks.onThought({ id: 'th-1', message_id: 'm-1', thought: 'thinking updated', message_files: [] }) - callbacks.onThought({ id: 'th-2', message_id: 'm-1', thought: 'second thought', message_files: [] }) + callbacks.onThought({ + id: 'th-1', + message_id: 'm-1', + thought: 'thinking updated', + message_files: [], + }) + callbacks.onThought({ + id: 'th-2', + message_id: 'm-1', + thought: 'second thought', + message_files: [], + }) callbacks.onLoopStart({ data: { node_id: 'loop-1' } }) callbacks.onLoopFinish({ data: { node_id: 'loop-1', status: 'succeeded' } }) @@ -1081,7 +1258,10 @@ describe('useChat', () => { callbacks.onTTSChunk('m-1', 'audio1') callbacks.onTTSEnd('m-1', 'audio1') - callbacks.onMessageEnd({ id: 'm-1', metadata: { annotation_reply: { id: 'anno-3', account: { name: 'sys' } } } }) + callbacks.onMessageEnd({ + id: 'm-1', + metadata: { annotation_reply: { id: 'anno-3', account: { name: 'sys' } } }, + }) callbacks.onMessageReplace({ answer: 'replaced resume' }) callbacks.onWorkflowPaused({ data: { workflow_run_id: 'wr-1' } }) @@ -1110,19 +1290,25 @@ describe('useChat', () => { callbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-1', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-2', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - }], - }] + const prevChatTree = [ + { + id: 'q-1', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-2', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-2', 'wr-1', { isPublicAPI: true }) @@ -1145,19 +1331,25 @@ describe('useChat', () => { const onConversationComplete = vi.fn() const onGetSuggestedQuestions = vi.fn() const config = { suggested_questions_after_answer: { enabled: true } } - const prevChatTree = [{ - id: 'q-1', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-resume-error', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - }], - }] + const prevChatTree = [ + { + id: 'q-1', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-resume-error', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + }, + ], + }, + ] - const { result } = renderHook(() => useChat(config as ChatConfig, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(config as ChatConfig, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-resume-error', 'wr-error', { @@ -1182,19 +1374,25 @@ describe('useChat', () => { callbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-1', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-resume-error', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - }], - }] + const prevChatTree = [ + { + id: 'q-1', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-resume-error', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-resume-error', 'wr-error', { @@ -1219,19 +1417,25 @@ describe('useChat', () => { callbacksList.push(options as HookCallbacks) }) - const prevChatTree = [{ - id: 'q-1', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-resume', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - }], - }] + const prevChatTree = [ + { + id: 'q-1', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-resume', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) const previousWorkflowAbort = createAbortControllerMock() act(() => { @@ -1253,19 +1457,25 @@ describe('useChat', () => { callbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-1', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-guard', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - }], - }] + const prevChatTree = [ + { + id: 'q-1', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-guard', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-guard', 'wr-1', { isPublicAPI: true }) @@ -1293,21 +1503,29 @@ describe('useChat', () => { const config = { suggested_questions_after_answer: { enabled: true }, } - const onGetSuggestedQuestions = vi.fn().mockRejectedValue(new Error('resume suggestion failed')) + const onGetSuggestedQuestions = vi + .fn() + .mockRejectedValue(new Error('resume suggestion failed')) const onConversationComplete = vi.fn() - const prevChatTree = [{ - id: 'q-1', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-suggest-resume', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - }], - }] + const prevChatTree = [ + { + id: 'q-1', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-suggest-resume', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + }, + ], + }, + ] - const { result } = renderHook(() => useChat(config as ChatConfig, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(config as ChatConfig, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-suggest-resume', 'wr-1', { @@ -1318,7 +1536,11 @@ describe('useChat', () => { }) await act(async () => { - callbacks.onData(' resumed', true, { messageId: 'm-suggest-resume', conversationId: 'c-resume', taskId: 't-resume' }) + callbacks.onData(' resumed', true, { + messageId: 'm-suggest-resume', + conversationId: 'c-resume', + taskId: 't-resume', + }) await callbacks.onCompleted() }) @@ -1333,19 +1555,25 @@ describe('useChat', () => { callbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-1', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-human-resume', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - }], - }] + const prevChatTree = [ + { + id: 'q-1', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-human-resume', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-human-resume', 'wr-1', { isPublicAPI: true }) @@ -1361,9 +1589,14 @@ describe('useChat', () => { }) const lastResponse = result.current.chatList[1] - expect(lastResponse!.humanInputFormDataList?.map(item => item.node_id)).toEqual(['node-2']) - expect(lastResponse!.humanInputFilledFormDataList?.map(item => item.node_id)).toEqual(['node-1', 'node-3']) - expect(lastResponse!.workflowProcess?.tracing?.find(item => item.node_id === 'node-1')?.status).toBe('paused') + expect(lastResponse!.humanInputFormDataList?.map((item) => item.node_id)).toEqual(['node-2']) + expect(lastResponse!.humanInputFilledFormDataList?.map((item) => item.node_id)).toEqual([ + 'node-1', + 'node-3', + ]) + expect( + lastResponse!.workflowProcess?.tracing?.find((item) => item.node_id === 'node-1')?.status, + ).toBe('paused') }) it('should handle resume non-annotation lifecycle branches and parallel node finish', () => { @@ -1372,38 +1605,59 @@ describe('useChat', () => { callbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-1', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-resume-branches', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - workflowProcess: { status: 'running' }, - }], - }] + const prevChatTree = [ + { + id: 'q-1', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-resume-branches', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + workflowProcess: { status: 'running' }, + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-resume-branches', 'wr-branches', { isPublicAPI: true }) }) act(() => { callbacks.onFile({ id: 'f-before-thought', type: 'image', url: 'img.png' }) - callbacks.onThought({ id: 'th-1', message_id: 'm-resume-branches', conversation_id: 'c-resume-branches', thought: 'thinking' }) + callbacks.onThought({ + id: 'th-1', + message_id: 'm-resume-branches', + conversation_id: 'c-resume-branches', + thought: 'thinking', + }) callbacks.onMessageEnd({ metadata: { retriever_resources: [{ id: 'r-1' }] }, files: [] }) callbacks.onLoopStart({ data: { node_id: 'loop-init' } }) callbacks.onIterationStart({ data: { node_id: 'iter-init' } }) - callbacks.onNodeStarted({ data: { node_id: 'n-iter', id: 'n-iter', iteration_id: 'iter-skip' } }) + callbacks.onNodeStarted({ + data: { node_id: 'n-iter', id: 'n-iter', iteration_id: 'iter-skip' }, + }) callbacks.onNodeFinished({ data: { id: 'n-iter', iteration_id: 'iter-skip' } }) callbacks.onNodeStarted({ data: { node_id: 'n-1', id: 'n-1' } }) callbacks.onNodeStarted({ data: { node_id: 'n-1', id: 'n-1', title: 'updated' } }) - callbacks.onNodeStarted({ data: { node_id: 'n-parallel', id: 'n-parallel', execution_metadata: { parallel_id: 'p-1' } } }) - callbacks.onNodeFinished({ data: { id: 'n-parallel', execution_metadata: { parallel_id: 'p-1' } } }) + callbacks.onNodeStarted({ + data: { + node_id: 'n-parallel', + id: 'n-parallel', + execution_metadata: { parallel_id: 'p-1' }, + }, + }) + callbacks.onNodeFinished({ + data: { id: 'n-parallel', execution_metadata: { parallel_id: 'p-1' } }, + }) callbacks.onWorkflowStarted({ workflow_run_id: 'wr-branches', task_id: 't-branches' }) callbacks.onWorkflowFinished({ data: { status: 'succeeded' } }) @@ -1414,7 +1668,9 @@ describe('useChat', () => { expect(lastResponse!.conversationId).toBe('c-resume-branches') expect(lastResponse!.citation).toEqual([{ id: 'r-1' }]) expect(lastResponse!.workflowProcess?.status).toBe('succeeded') - expect(lastResponse!.workflowProcess?.tracing?.some(item => item.id === 'n-parallel')).toBe(true) + expect(lastResponse!.workflowProcess?.tracing?.some((item) => item.id === 'n-parallel')).toBe( + true, + ) }) }) @@ -1546,37 +1802,63 @@ describe('useChat', () => { const conversationAbort = createAbortControllerMock() const suggestedAbort = createAbortControllerMock() const config = { suggested_questions_after_answer: { enabled: true } } - const onGetConversationMessages = vi.fn().mockImplementation(async (_conversationId: string, setAbortController: (abortController: AbortController) => void) => { - setAbortController(conversationAbort) - return { - data: [{ - id: 'm-stop', - answer: 'done', - message: [{ role: 'assistant', text: 'done' }], - created_at: Date.now(), - answer_tokens: 3, - message_tokens: 2, - provider_response_latency: 1, - inputs: {}, - query: 'q', - }], - } - }) - const onGetSuggestedQuestions = vi.fn().mockImplementation(async (_messageId: string, setAbortController: (abortController: AbortController) => void) => { - setAbortController(suggestedAbort) - return { data: ['s1'] } - }) + const onGetConversationMessages = vi + .fn() + .mockImplementation( + async ( + _conversationId: string, + setAbortController: (abortController: AbortController) => void, + ) => { + setAbortController(conversationAbort) + return { + data: [ + { + id: 'm-stop', + answer: 'done', + message: [{ role: 'assistant', text: 'done' }], + created_at: Date.now(), + answer_tokens: 3, + message_tokens: 2, + provider_response_latency: 1, + inputs: {}, + query: 'q', + }, + ], + } + }, + ) + const onGetSuggestedQuestions = vi + .fn() + .mockImplementation( + async ( + _messageId: string, + setAbortController: (abortController: AbortController) => void, + ) => { + setAbortController(suggestedAbort) + return { data: ['s1'] } + }, + ) - const { result } = renderHook(() => useChat(config as ChatConfig, undefined, undefined, stopChat)) + const { result } = renderHook(() => + useChat(config as ChatConfig, undefined, undefined, stopChat), + ) act(() => { - result.current.handleSend('url', { query: 'stop with aborts' }, { onGetConversationMessages, onGetSuggestedQuestions }) + result.current.handleSend( + 'url', + { query: 'stop with aborts' }, + { onGetConversationMessages, onGetSuggestedQuestions }, + ) }) act(() => { callbacks.getAbortController(workflowAbort) }) await act(async () => { - callbacks.onData('part', true, { messageId: 'm-stop', conversationId: 'c-stop', taskId: 'task-stop' }) + callbacks.onData('part', true, { + messageId: 'm-stop', + conversationId: 'c-stop', + taskId: 'task-stop', + }) await callbacks.onCompleted() }) act(() => { @@ -1592,7 +1874,9 @@ describe('useChat', () => { it('should clear chat list when clearChatList flag is true and reset flag via callback', () => { const clearChatListCallback = vi.fn() - renderHook(() => useChat(undefined, undefined, undefined, undefined, true, clearChatListCallback)) + renderHook(() => + useChat(undefined, undefined, undefined, undefined, true, clearChatListCallback), + ) expect(clearChatListCallback).toHaveBeenCalledWith(false) }) @@ -1602,14 +1886,9 @@ describe('useChat', () => { const clearChatListCallback = vi.fn((nextClearChatList: boolean) => { clearChatList = nextClearChatList }) - const { rerender, result } = renderHook(() => useChat( - undefined, - undefined, - undefined, - undefined, - clearChatList, - clearChatListCallback, - )) + const { rerender, result } = renderHook(() => + useChat(undefined, undefined, undefined, undefined, clearChatList, clearChatListCallback), + ) expect(clearChatListCallback).toHaveBeenCalledWith(false) @@ -1628,33 +1907,41 @@ describe('useChat', () => { }), expect.any(Object), ) - expect(result.current.chatList).toEqual(expect.arrayContaining([ - expect.objectContaining({ - content: 'first after reset', - isAnswer: false, - }), - ])) + expect(result.current.chatList).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + content: 'first after reset', + isAnswer: false, + }), + ]), + ) }) }) describe('annotations and siblings', () => { - const prevChatTree = [{ - id: 'q-1', - content: 'query', - isAnswer: false, - children: [{ - id: 'a-1', - content: 'answer 1', - isAnswer: true, - workflow_run_id: 'wr-1', - humanInputFormDataList: [{ node_id: 'n-1' }], - siblingIndex: 0, - annotation: { id: 'anno-old', authorName: 'user' }, - }], - }] + const prevChatTree = [ + { + id: 'q-1', + content: 'query', + isAnswer: false, + children: [ + { + id: 'a-1', + content: 'answer 1', + isAnswer: true, + workflow_run_id: 'wr-1', + humanInputFormDataList: [{ node_id: 'n-1' }], + siblingIndex: 0, + annotation: { id: 'anno-old', authorName: 'user' }, + }, + ], + }, + ] it('should handle annotation events', () => { - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) // Edited act(() => { @@ -1678,7 +1965,9 @@ describe('useChat', () => { }) it('should handle switch sibling and trigger handleResume if human input', () => { - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleSwitchSibling('a-1', { isPublicAPI: true }) @@ -1693,30 +1982,40 @@ describe('useChat', () => { }) it('should walk nested siblings without resuming when no pending human input exists', () => { - const nestedTree = [{ - id: 'q-root', - content: 'query', - isAnswer: false, - children: [{ - id: 'a-root', - content: 'answer', - isAnswer: true, - siblingIndex: 0, - children: [{ - id: 'q-deep', - content: 'deep question', - isAnswer: false, - children: [{ - id: 'a-deep', - content: 'deep answer', + const nestedTree = [ + { + id: 'q-root', + content: 'query', + isAnswer: false, + children: [ + { + id: 'a-root', + content: 'answer', isAnswer: true, siblingIndex: 0, - }], - }], - }], - }] + children: [ + { + id: 'q-deep', + content: 'deep question', + isAnswer: false, + children: [ + { + id: 'a-deep', + content: 'deep answer', + isAnswer: true, + siblingIndex: 0, + }, + ], + }, + ], + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, nestedTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, nestedTree as ChatItemInTree[]), + ) act(() => { result.current.handleSwitchSibling('a-deep', { isPublicAPI: true }) }) @@ -1736,7 +2035,11 @@ describe('useChat', () => { }) act(() => { - callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1', message_id: 'm-files' }) + callbacks.onWorkflowStarted({ + workflow_run_id: 'wr-1', + task_id: 't-1', + message_id: 'm-files', + }) // No transferMethod, type: video callbacks.onFile({ id: 'f-vid', type: 'video', url: 'vid.mp4' }) @@ -1768,7 +2071,11 @@ describe('useChat', () => { }) act(() => { - callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1', message_id: 'm-cite' }) + callbacks.onWorkflowStarted({ + workflow_run_id: 'wr-1', + task_id: 't-1', + message_id: 'm-cite', + }) callbacks.onMessageEnd({ id: 'm-cite', metadata: {} }) // No retriever_resources or annotation_reply }) @@ -1782,20 +2089,26 @@ describe('useChat', () => { callbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-trace', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-trace', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - workflowProcess: { status: WorkflowRunningStatus.Running }, // Omit tracing array to test fallback - }], - }] + const prevChatTree = [ + { + id: 'q-trace', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-trace', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + workflowProcess: { status: WorkflowRunningStatus.Running }, // Omit tracing array to test fallback + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-trace', 'wr-trace', { isPublicAPI: true }) }) @@ -1805,20 +2118,26 @@ describe('useChat', () => { callbacks.onIterationStart({ data: { node_id: 'iter-1' } }) }) - const prevChatTree2 = [{ - id: 'q-trace2', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-trace', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - workflowProcess: { status: WorkflowRunningStatus.Running }, // Omit tracing array to test fallback - }], - }] + const prevChatTree2 = [ + { + id: 'q-trace2', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-trace', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + workflowProcess: { status: WorkflowRunningStatus.Running }, // Omit tracing array to test fallback + }, + ], + }, + ] - const { result: result2 } = renderHook(() => useChat(undefined, undefined, prevChatTree2 as ChatItemInTree[])) + const { result: result2 } = renderHook(() => + useChat(undefined, undefined, prevChatTree2 as ChatItemInTree[]), + ) act(() => { result2.current.handleResume('m-trace', 'wr-trace2', { isPublicAPI: true }) }) @@ -1828,20 +2147,26 @@ describe('useChat', () => { callbacks.onNodeStarted({ data: { node_id: 'n-1', id: 'n-1' } }) }) - const prevChatTree3 = [{ - id: 'q-trace3', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-trace', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - workflowProcess: { status: WorkflowRunningStatus.Running }, // Omit tracing array to test fallback - }], - }] + const prevChatTree3 = [ + { + id: 'q-trace3', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-trace', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + workflowProcess: { status: WorkflowRunningStatus.Running }, // Omit tracing array to test fallback + }, + ], + }, + ] - const { result: result3 } = renderHook(() => useChat(undefined, undefined, prevChatTree3 as ChatItemInTree[])) + const { result: result3 } = renderHook(() => + useChat(undefined, undefined, prevChatTree3 as ChatItemInTree[]), + ) act(() => { result3.current.handleResume('m-trace', 'wr-trace3', { isPublicAPI: true }) }) @@ -1865,25 +2190,31 @@ describe('useChat', () => { }) const onGetConversationMessages = vi.fn().mockResolvedValue({ - data: [{ - id: 'm-completed', - answer: 'final answer', - message: [{ role: 'user', text: 'hi' }], - agent_thoughts: [{ thought: 'thinking different from answer' }], - created_at: Date.now(), - answer_tokens: 10, - message_tokens: 5, - provider_response_latency: 0, - inputs: {}, - query: 'hi', - }], + data: [ + { + id: 'm-completed', + answer: 'final answer', + message: [{ role: 'user', text: 'hi' }], + agent_thoughts: [{ thought: 'thinking different from answer' }], + created_at: Date.now(), + answer_tokens: 10, + message_tokens: 5, + provider_response_latency: 0, + inputs: {}, + query: 'hi', + }, + ], }) const { result } = renderHook(() => useChat()) act(() => { - result.current.handleSend('test-url', { query: 'fetch test latency zero' }, { - onGetConversationMessages, - }) + result.current.handleSend( + 'test-url', + { query: 'fetch test latency zero' }, + { + onGetConversationMessages, + }, + ) }) await act(async () => { @@ -1904,35 +2235,46 @@ describe('useChat', () => { }) const onGetConversationMessages = vi.fn().mockResolvedValue({ - data: [{ - id: 'm-without-log', - answer: 'final answer', - agent_thoughts: [], - created_at: Date.now(), - inputs: {}, - query: 'hi', - }], + data: [ + { + id: 'm-without-log', + answer: 'final answer', + agent_thoughts: [], + created_at: Date.now(), + inputs: {}, + query: 'hi', + }, + ], }) const { result } = renderHook(() => useChat()) act(() => { - result.current.handleSend('test-url', { query: 'fetch test missing log' }, { - onGetConversationMessages, - }) + result.current.handleSend( + 'test-url', + { query: 'fetch test missing log' }, + { + onGetConversationMessages, + }, + ) }) await act(async () => { - callbacks.onData(' data', true, { messageId: 'm-without-log', conversationId: 'c-without-log' }) + callbacks.onData(' data', true, { + messageId: 'm-without-log', + conversationId: 'c-without-log', + }) await callbacks.onCompleted() }) const lastResponse = result.current.chatList[1] expect(lastResponse!.content).toBe('final answer') - expect(lastResponse!.log).toEqual([{ - role: 'assistant', - text: 'final answer', - files: [], - }]) + expect(lastResponse!.log).toEqual([ + { + role: 'assistant', + text: 'final answer', + files: [], + }, + ]) expect(lastResponse!.more?.tokens).toBe(0) expect(lastResponse!.more?.latency).toBe('0.00') expect(lastResponse!.more?.tokens_per_second).toBeUndefined() @@ -1945,65 +2287,83 @@ describe('useChat', () => { }) const onGetConversationMessages = vi.fn().mockResolvedValue({ - data: [{ - id: 'm-new-agent-history', - answer: 'history top-level answer', - message: [{ role: 'user', text: 'hi' }], - agent_thoughts: [ - { - id: 'history-thought', - thought: 'history thought', - answer: 'history agent answer', - tool: '', - tool_input: '', - observation: '', - position: 1, - }, - ], - message_files: [{ - id: 'history-file', - belongs_to: 'assistant', - type: 'image', - url: 'history.png', - }], - retriever_resources: [{ id: 'history-citation', content: 'history citation' }], - metadata: { reasoning: { history: 'history reasoning' } }, - created_at: Date.now(), - answer_tokens: 10, - message_tokens: 5, - provider_response_latency: 0.5, - workflow_run_id: 'history-workflow-run', - feedback: { rating: 'like' }, - inputs: {}, - query: 'hi', - }], + data: [ + { + id: 'm-new-agent-history', + answer: 'history top-level answer', + message: [{ role: 'user', text: 'hi' }], + agent_thoughts: [ + { + id: 'history-thought', + thought: 'history thought', + answer: 'history agent answer', + tool: '', + tool_input: '', + observation: '', + position: 1, + }, + ], + message_files: [ + { + id: 'history-file', + belongs_to: 'assistant', + type: 'image', + url: 'history.png', + }, + ], + retriever_resources: [{ id: 'history-citation', content: 'history citation' }], + metadata: { reasoning: { history: 'history reasoning' } }, + created_at: Date.now(), + answer_tokens: 10, + message_tokens: 5, + provider_response_latency: 0.5, + workflow_run_id: 'history-workflow-run', + feedback: { rating: 'like' }, + inputs: {}, + query: 'hi', + }, + ], }) - const { result } = renderHook(() => useChat( - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { isNewAgent: true }, - )) + const { result } = renderHook(() => + useChat(undefined, undefined, undefined, undefined, undefined, undefined, undefined, { + isNewAgent: true, + }), + ) act(() => { - result.current.handleSend('test-url', { query: 'new agent history' }, { - onGetConversationMessages, - }) + result.current.handleSend( + 'test-url', + { query: 'new agent history' }, + { + onGetConversationMessages, + }, + ) }) await act(async () => { - callbacks.onWorkflowStarted({ workflow_run_id: 'stream-workflow-run', task_id: 'stream-task' }) + callbacks.onWorkflowStarted({ + workflow_run_id: 'stream-workflow-run', + task_id: 'stream-task', + }) callbacks.onThought({ id: 'stream-thought', thought: 'stream thought' }) - callbacks.onData(' stream answer', true, { event: 'agent_message', messageId: 'm-new-agent-history', conversationId: 'c-new-agent-history' }) - callbacks.onReasoning({ data: { message_id: 'm-new-agent-history', node_id: 'stream', reasoning: 'stream reasoning' } }) + callbacks.onData(' stream answer', true, { + event: 'agent_message', + messageId: 'm-new-agent-history', + conversationId: 'c-new-agent-history', + }) + callbacks.onReasoning({ + data: { + message_id: 'm-new-agent-history', + node_id: 'stream', + reasoning: 'stream reasoning', + }, + }) callbacks.onMessageEnd({ id: 'm-new-agent-history', - metadata: { retriever_resources: [{ id: 'stream-citation', content: 'stream citation' }] }, + metadata: { + retriever_resources: [{ id: 'stream-citation', content: 'stream citation' }], + }, files: [{ id: 'stream-file', type: 'image', url: 'stream.png' }], }) await callbacks.onCompleted() @@ -2014,7 +2374,9 @@ describe('useChat', () => { expect(lastResponse!.agent_response_parts).toBeUndefined() expect(lastResponse!.workflow_run_id).toBe('history-workflow-run') expect(lastResponse!.workflowProcess).toBeUndefined() - expect(lastResponse!.citation).toEqual([{ id: 'history-citation', content: 'history citation' }]) + expect(lastResponse!.citation).toEqual([ + { id: 'history-citation', content: 'history citation' }, + ]) expect(lastResponse!.reasoningContent).toEqual({ history: 'history reasoning' }) expect(lastResponse!.message_files).toEqual([expect.objectContaining({ id: 'history-file' })]) expect(lastResponse!.allFiles).toBeUndefined() @@ -2035,25 +2397,31 @@ describe('useChat', () => { }) const onGetConversationMessages = vi.fn().mockResolvedValue({ - data: [{ - id: 'm-matched', - answer: 'matched thought', - message: [{ role: 'user', text: 'hi' }], - agent_thoughts: [{ thought: 'matched thought' }], - created_at: Date.now(), - answer_tokens: 10, - message_tokens: 5, - provider_response_latency: 0.5, - inputs: {}, - query: 'hi', - }], + data: [ + { + id: 'm-matched', + answer: 'matched thought', + message: [{ role: 'user', text: 'hi' }], + agent_thoughts: [{ thought: 'matched thought' }], + created_at: Date.now(), + answer_tokens: 10, + message_tokens: 5, + provider_response_latency: 0.5, + inputs: {}, + query: 'hi', + }, + ], }) const { result } = renderHook(() => useChat()) act(() => { - result.current.handleSend('test-url', { query: 'fetch test match thought' }, { - onGetConversationMessages, - }) + result.current.handleSend( + 'test-url', + { query: 'fetch test match thought' }, + { + onGetConversationMessages, + }, + ) }) await act(async () => { @@ -2071,21 +2439,27 @@ describe('useChat', () => { callbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-pause', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-pause', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - workflowProcess: { status: WorkflowRunningStatus.Running }, // Omit tracing - }], - }] + const prevChatTree = [ + { + id: 'q-pause', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-pause', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + workflowProcess: { status: WorkflowRunningStatus.Running }, // Omit tracing + }, + ], + }, + ] // Setup test for workflow paused + finished - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-pause', 'wr-1', { isPublicAPI: true }) }) @@ -2141,16 +2515,11 @@ describe('useChat', () => { callbacks = options as HookCallbacks }) - const { result } = renderHook(() => useChat( - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { isNewAgent: true }, - )) + const { result } = renderHook(() => + useChat(undefined, undefined, undefined, undefined, undefined, undefined, undefined, { + isNewAgent: true, + }), + ) act(() => { result.current.handleSend('url', { query: 'agent onThought' }, {}) }) @@ -2161,7 +2530,10 @@ describe('useChat', () => { callbacks.onData(' first answer', false, { event: 'agent_message', messageId: 'm-thought' }) callbacks.onData(' continued answer', false, { event: 'message', messageId: 'm-thought' }) callbacks.onThought({ id: 'th-2', thought: 'second thought' }) - callbacks.onData(' second answer', false, { event: 'agent_message', messageId: 'm-thought' }) + callbacks.onData(' second answer', false, { + event: 'agent_message', + messageId: 'm-thought', + }) }) const lastResponse = result.current.chatList[result.current.chatList.length - 1] @@ -2169,42 +2541,58 @@ describe('useChat', () => { expect(lastResponse!.agent_thoughts).toHaveLength(2) expect(lastResponse!.agent_thoughts![0]!.thought).toBe('initial thought') expect(lastResponse!.agent_response_parts).toEqual([ - { type: 'thought', thought: expect.objectContaining({ id: 'th-1', thought: 'initial thought' }) }, + { + type: 'thought', + thought: expect.objectContaining({ id: 'th-1', thought: 'initial thought' }), + }, { type: 'message', content: ' first answer continued answer' }, - { type: 'thought', thought: expect.objectContaining({ id: 'th-2', thought: 'second thought' }) }, + { + type: 'thought', + thought: expect.objectContaining({ id: 'th-2', thought: 'second thought' }), + }, { type: 'message', content: ' second answer' }, ]) }) }) it('should cover produceChatTreeNode traversing deeply nested child nodes to find the target item', () => { - vi.mocked(sseGet).mockImplementation(async (_url, _params, _options) => { }) - - const nestedTree = [{ - id: 'q-root', - content: 'query', - isAnswer: false, - children: [{ - id: 'a-root', - content: 'answer root', - isAnswer: true, - siblingIndex: 0, - children: [{ - id: 'q-deep', - content: 'deep question', - isAnswer: false, - children: [{ - id: 'a-deep', - content: 'deep answer to find', + vi.mocked(sseGet).mockImplementation(async (_url, _params, _options) => {}) + + const nestedTree = [ + { + id: 'q-root', + content: 'query', + isAnswer: false, + children: [ + { + id: 'a-root', + content: 'answer root', isAnswer: true, siblingIndex: 0, - }], - }], - }], - }] + children: [ + { + id: 'q-deep', + content: 'deep question', + isAnswer: false, + children: [ + { + id: 'a-deep', + content: 'deep answer to find', + isAnswer: true, + siblingIndex: 0, + }, + ], + }, + ], + }, + ], + }, + ] // Render the chat with the nested tree - const { result } = renderHook(() => useChat(undefined, undefined, nestedTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, nestedTree as ChatItemInTree[]), + ) // Setting TargetNodeId triggers state update using produceChatTreeNode internally act(() => { @@ -2232,18 +2620,24 @@ describe('useChat', () => { result.current.handleSend('url', { query: 'test base file' }, {}) }) - const prevChatTree = [{ - id: 'q-resume', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-resume', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - }], - }] - const { result: resumeResult } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const prevChatTree = [ + { + id: 'q-resume', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-resume', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + }, + ], + }, + ] + const { result: resumeResult } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { resumeResult.current.handleResume('m-resume', 'wr-1', { isPublicAPI: true }) }) @@ -2284,8 +2678,16 @@ describe('useChat', () => { sendCallbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' }) // parallel_id in execution_metadata - sendCallbacks.onIterationStart({ data: { node_id: 'iter-1', execution_metadata: { parallel_id: 'pid-1' } } }) - sendCallbacks.onIterationFinish({ data: { node_id: 'iter-1', execution_metadata: { parallel_id: 'pid-1' }, status: 'succeeded' } }) + sendCallbacks.onIterationStart({ + data: { node_id: 'iter-1', execution_metadata: { parallel_id: 'pid-1' } }, + }) + sendCallbacks.onIterationFinish({ + data: { + node_id: 'iter-1', + execution_metadata: { parallel_id: 'pid-1' }, + status: 'succeeded', + }, + }) // no parallel_id sendCallbacks.onLoopStart({ data: { node_id: 'loop-1' } }) @@ -2293,7 +2695,9 @@ describe('useChat', () => { // parallel_id in root item but finish has it in execution_metadata sendCallbacks.onNodeStarted({ data: { node_id: 'n-1', id: 'n-1', parallel_id: 'pid-2' } }) - sendCallbacks.onNodeFinished({ data: { node_id: 'n-1', id: 'n-1', execution_metadata: { parallel_id: 'pid-2' } } }) + sendCallbacks.onNodeFinished({ + data: { node_id: 'n-1', id: 'n-1', execution_metadata: { parallel_id: 'pid-2' } }, + }) }) const lastResponse = result.current.chatList[1] @@ -2341,18 +2745,24 @@ describe('useChat', () => { resumeCallbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-data', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-data', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - }], - }] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const prevChatTree = [ + { + id: 'q-data', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-data', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + }, + ], + }, + ] + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-data', 'wr-1', { isPublicAPI: true }) }) @@ -2370,7 +2780,10 @@ describe('useChat', () => { resumeCallbacks.onMessageEnd({ id: 'm-end', metadata: {} } as Record<string, unknown>) // onThought fallback missing message_id - resumeCallbacks.onThought({ thought: 'missing message id', message_files: [] } as Record<string, unknown>) + resumeCallbacks.onThought({ thought: 'missing message id', message_files: [] } as Record< + string, + unknown + >) // onHumanInputFormTimeout missing length resumeCallbacks.onHumanInputFormTimeout({ data: { node_id: 'timeout-id' } }) @@ -2392,32 +2805,46 @@ describe('useChat', () => { resumeCallbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-index', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-index', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - workflowProcess: { status: WorkflowRunningStatus.Running, tracing: [] }, - }], - }] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const prevChatTree = [ + { + id: 'q-index', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-index', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + workflowProcess: { status: WorkflowRunningStatus.Running, tracing: [] }, + }, + ], + }, + ] + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-index', 'wr-1', { isPublicAPI: true }) }) act(() => { // ID doesn't exist in tracing - resumeCallbacks.onNodeFinished({ data: { id: 'missing', execution_metadata: { parallel_id: 'missing-pid' } } }) + resumeCallbacks.onNodeFinished({ + data: { id: 'missing', execution_metadata: { parallel_id: 'missing-pid' } }, + }) // Node ID doesn't exist in tracing resumeCallbacks.onLoopFinish({ data: { node_id: 'missing-loop', status: 'succeeded' } }) // Parallel ID doesn't match - resumeCallbacks.onIterationFinish({ data: { node_id: 'missing-iter', execution_metadata: { parallel_id: 'missing-pid' }, status: 'succeeded' } }) + resumeCallbacks.onIterationFinish({ + data: { + node_id: 'missing-iter', + execution_metadata: { parallel_id: 'missing-pid' }, + status: 'succeeded', + }, + }) }) const lastResponse = result.current.chatList[1] @@ -2472,13 +2899,20 @@ describe('useChat', () => { sendCallbacks.onNodeFinished({ data: { id: 'missing-idx' } } as Record<string, unknown>) // onIterationFinish parallel_id matching - sendCallbacks.onIterationFinish({ data: { node_id: 'missing-iter', status: 'succeeded' } } as Record<string, unknown>) + sendCallbacks.onIterationFinish({ + data: { node_id: 'missing-iter', status: 'succeeded' }, + } as Record<string, unknown>) // onLoopFinish parallel_id matching - sendCallbacks.onLoopFinish({ data: { node_id: 'missing-loop', status: 'succeeded' } } as Record<string, unknown>) + sendCallbacks.onLoopFinish({ + data: { node_id: 'missing-loop', status: 'succeeded' }, + } as Record<string, unknown>) // Timeout missing form data - sendCallbacks.onHumanInputFormTimeout({ data: { node_id: 'timeout' } } as Record<string, unknown>) + sendCallbacks.onHumanInputFormTimeout({ data: { node_id: 'timeout' } } as Record< + string, + unknown + >) }) expect(result.current.chatList[1]!.message_files).toBeDefined() @@ -2555,7 +2989,8 @@ describe('useChat', () => { resumeCallbacks = options as HookCallbacks }) - const onGetSuggestedQuestions = vi.fn() + const onGetSuggestedQuestions = vi + .fn() .mockImplementationOnce((_id, getAbort) => { if (getAbort) { getAbort({ abort: vi.fn() } as unknown as AbortController) @@ -2573,15 +3008,19 @@ describe('useChat', () => { suggested_questions_after_answer: { enabled: true }, } - const prevChatTree = [{ - id: 'q', - content: 'query', - isAnswer: false, - children: [{ id: 'm-1', content: 'initial', isAnswer: true, siblingIndex: 0 }], - }] + const prevChatTree = [ + { + id: 'q', + content: 'query', + isAnswer: false, + children: [{ id: 'm-1', content: 'initial', isAnswer: true, siblingIndex: 0 }], + }, + ] // Success branch - const { result } = renderHook(() => useChat(config as ChatConfig, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(config as ChatConfig, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true, onGetSuggestedQuestions }) }) @@ -2633,20 +3072,26 @@ describe('useChat', () => { resumeCallbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-1', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - humanInputFormDataList: [{ node_id: 'n-1', expiration_time: 100 }], - }], - }] + const prevChatTree = [ + { + id: 'q', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-1', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + humanInputFormDataList: [{ node_id: 'n-1', expiration_time: 100 }], + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true }) }) @@ -2670,23 +3115,29 @@ describe('useChat', () => { resumeCallbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-1', - content: 'initial', - isAnswer: true, - siblingIndex: 0, - workflowProcess: { - status: WorkflowRunningStatus.Running, - // tracing: undefined - }, - }], - }] + const prevChatTree = [ + { + id: 'q', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-1', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + workflowProcess: { + status: WorkflowRunningStatus.Running, + // tracing: undefined + }, + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true }) }) @@ -2720,13 +3171,17 @@ describe('useChat', () => { }) it('should cover handleAnnotationAdded updating node', async () => { - const prevChatTree = [{ - id: 'q-1', - content: 'q', - isAnswer: false, - children: [{ id: 'a-1', content: 'a', isAnswer: true, siblingIndex: 0 }], - }] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const prevChatTree = [ + { + id: 'q-1', + content: 'q', + isAnswer: false, + children: [{ id: 'a-1', content: 'a', isAnswer: true, siblingIndex: 0 }], + }, + ] + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) await act(async () => { // (annotationId, authorName, query, answer, index) result.current.handleAnnotationAdded('anno-id', 'author', 'q-new', 'a-new', 1) @@ -2738,13 +3193,17 @@ describe('useChat', () => { }) it('should cover handleAnnotationEdited updating node', async () => { - const prevChatTree = [{ - id: 'q-1', - content: 'q', - isAnswer: false, - children: [{ id: 'a-1', content: 'a', isAnswer: true, siblingIndex: 0 }], - }] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const prevChatTree = [ + { + id: 'q-1', + content: 'q', + isAnswer: false, + children: [{ id: 'a-1', content: 'a', isAnswer: true, siblingIndex: 0 }], + }, + ] + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) await act(async () => { // (query, answer, index) result.current.handleAnnotationEdited('q-edit', 'a-edit', 1) @@ -2754,19 +3213,25 @@ describe('useChat', () => { }) it('should cover handleAnnotationRemoved updating node', () => { - const prevChatTree = [{ - id: 'q-1', - content: 'q', - isAnswer: false, - children: [{ - id: 'a-1', - content: 'a', - isAnswer: true, - siblingIndex: 0, - annotation: { id: 'anno-old' }, - }], - }] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const prevChatTree = [ + { + id: 'q-1', + content: 'q', + isAnswer: false, + children: [ + { + id: 'a-1', + content: 'a', + isAnswer: true, + siblingIndex: 0, + annotation: { id: 'anno-old' }, + }, + ], + }, + ] + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleAnnotationRemoved(1) }) @@ -2801,7 +3266,9 @@ describe('useChat', () => { expect(responseItem.content).toBe('answer') act(() => { - callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: '', node_id: 'llm', is_final: true } }) + callbacks.onReasoning({ + data: { message_id: 'm-1', reasoning: '', node_id: 'llm', is_final: true }, + }) }) expect(result.current.chatList[1]!.reasoningContent).toEqual({ llm: 'let me think' }) expect(result.current.chatList[1]!.reasoningFinished).toBe(true) @@ -2828,7 +3295,11 @@ describe('useChat', () => { callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'c' } }) }) - expect(result.current.chatList[1]!.reasoningContent).toEqual({ 'llm-1': 'a', 'llm-2': 'b', '_': 'c' }) + expect(result.current.chatList[1]!.reasoningContent).toEqual({ + 'llm-1': 'a', + 'llm-2': 'b', + _: 'c', + }) }) it('accumulates reasoning onto an existing answer node on resume (handleResume / sseGet)', () => { @@ -2837,31 +3308,41 @@ describe('useChat', () => { callbacks = options as HookCallbacks }) - const prevChatTree = [{ - id: 'q-1', - content: 'query', - isAnswer: false, - children: [{ - id: 'm-1', - content: 'initial', - isAnswer: true, - message_files: [], - siblingIndex: 0, - }], - }] + const prevChatTree = [ + { + id: 'q-1', + content: 'query', + isAnswer: false, + children: [ + { + id: 'm-1', + content: 'initial', + isAnswer: true, + message_files: [], + siblingIndex: 0, + }, + ], + }, + ] - const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + const { result } = renderHook(() => + useChat(undefined, undefined, prevChatTree as ChatItemInTree[]), + ) act(() => { result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true }) }) act(() => { - callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'resumed ', node_id: 'llm' } }) - callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'thought', node_id: 'llm', is_final: true } }) + callbacks.onReasoning({ + data: { message_id: 'm-1', reasoning: 'resumed ', node_id: 'llm' }, + }) + callbacks.onReasoning({ + data: { message_id: 'm-1', reasoning: 'thought', node_id: 'llm', is_final: true }, + }) }) - const responseItem = result.current.chatList.find(item => item.id === 'm-1')! + const responseItem = result.current.chatList.find((item) => item.id === 'm-1')! expect(responseItem.reasoningContent).toEqual({ llm: 'resumed thought' }) expect(responseItem.reasoningFinished).toBe(true) }) diff --git a/web/app/components/base/chat/chat/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/__tests__/index.spec.tsx index 01c45e7bbb72d9..69f982df2af7cb 100644 --- a/web/app/components/base/chat/chat/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/index.spec.tsx @@ -24,12 +24,8 @@ import Chat from '../index' // ───────────────────────────────────────────────────────────────────────────── vi.mock('../answer', () => ({ - default: ({ item, responding }: { item: ChatItem, responding?: boolean }) => ( - <div - data-testid="answer-item" - data-id={item.id} - data-responding={String(!!responding)} - > + default: ({ item, responding }: { item: ChatItem; responding?: boolean }) => ( + <div data-testid="answer-item" data-id={item.id} data-responding={String(!!responding)}> {item.content} </div> ), @@ -37,7 +33,9 @@ vi.mock('../answer', () => ({ vi.mock('../question', () => ({ default: ({ item }: { item: ChatItem }) => ( - <div data-testid="question-item" data-id={item.id}>{item.content}</div> + <div data-testid="question-item" data-id={item.id}> + {item.content} + </div> ), })) @@ -67,7 +65,9 @@ vi.mock('../chat-input-area', () => ({ vi.mock('@/app/components/base/prompt-log-modal', () => ({ default: ({ onCancel }: { onCancel: () => void }) => ( <div data-testid="prompt-log-modal"> - <button data-testid="prompt-log-cancel" onClick={onCancel}>cancel</button> + <button data-testid="prompt-log-cancel" onClick={onCancel}> + cancel + </button> </div> ), })) @@ -75,7 +75,9 @@ vi.mock('@/app/components/base/prompt-log-modal', () => ({ vi.mock('@/app/components/base/agent-log-modal', () => ({ default: ({ onCancel }: { onCancel: () => void }) => ( <div data-testid="agent-log-modal"> - <button data-testid="agent-log-cancel" onClick={onCancel}>cancel</button> + <button data-testid="agent-log-cancel" onClick={onCancel}> + cancel + </button> </div> ), })) @@ -123,8 +125,7 @@ const baseStoreState = { setShowAgentLogModal: mockSetShowAgentLogModal, } -const renderChat = (props: Partial<ChatProps> = {}) => - render(<Chat chatList={[]} {...props} />) +const renderChat = (props: Partial<ChatProps> = {}) => render(<Chat chatList={[]} {...props} />) // ─── Suite ──────────────────────────────────────────────────────────────────── @@ -138,17 +139,20 @@ describe('Chat', () => { return 0 }) - vi.stubGlobal('ResizeObserver', class { - private cb: ResizeCallback - constructor(cb: ResizeCallback) { - this.cb = cb - capturedResizeCallbacks.push(cb) - } + vi.stubGlobal( + 'ResizeObserver', + class { + private cb: ResizeCallback + constructor(cb: ResizeCallback) { + this.cb = cb + capturedResizeCallbacks.push(cb) + } - observe() { } - unobserve() { } - disconnect() { } - }) + observe() {} + unobserve() {} + disconnect() {} + }, + ) useAppStore.setState(baseStoreState) }) @@ -239,14 +243,15 @@ describe('Chat', () => { makeChatItem({ id: 'a2', isAnswer: true }), ], }) - screen.getAllByTestId('answer-item').forEach(el => - expect(el).toHaveAttribute('data-responding', 'false'), - ) + screen + .getAllByTestId('answer-item') + .forEach((el) => expect(el).toHaveAttribute('data-responding', 'false')) }) it('should render correct counts for a long mixed chatList', () => { const chatList = Array.from({ length: 6 }, (_, i) => - makeChatItem({ id: `item-${i}`, isAnswer: i % 2 === 1 })) + makeChatItem({ id: `item-${i}`, isAnswer: i % 2 === 1 }), + ) renderChat({ chatList }) expect(screen.getAllByTestId('question-item')).toHaveLength(3) expect(screen.getAllByTestId('answer-item')).toHaveLength(3) @@ -506,11 +511,14 @@ describe('Chat', () => { it('should disconnect both observers on unmount', () => { const disconnectSpy = vi.fn() - vi.stubGlobal('ResizeObserver', class { - observe() { } - unobserve() { } - disconnect = disconnectSpy - }) + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect = disconnectSpy + }, + ) const { unmount } = renderChat() unmount() expect(disconnectSpy).toHaveBeenCalled() @@ -562,7 +570,11 @@ describe('Chat', () => { chatList: [makeChatItem({ id: 'first' }), makeChatItem({ id: 'second' })], }) expect(() => - rerender(<Chat chatList={[makeChatItem({ id: 'new-first' }), makeChatItem({ id: 'new-second' })]} />), + rerender( + <Chat + chatList={[makeChatItem({ id: 'new-first' }), makeChatItem({ id: 'new-second' })]} + />, + ), ).not.toThrow() }) @@ -602,7 +614,11 @@ describe('Chat', () => { }) it('should render no modals when both modal flags are false', () => { - useAppStore.setState({ ...baseStoreState, showPromptLogModal: false, showAgentLogModal: false }) + useAppStore.setState({ + ...baseStoreState, + showPromptLogModal: false, + showAgentLogModal: false, + }) renderChat() expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument() expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument() @@ -843,10 +859,15 @@ describe('Chat', () => { it('should pass appData.site.input_placeholder as customPlaceholder to ChatInputArea', () => { renderChat({ - appData: { site: { input_placeholder: 'Ask the assistant' } } as unknown as ChatProps['appData'], + appData: { + site: { input_placeholder: 'Ask the assistant' }, + } as unknown as ChatProps['appData'], noChatInput: false, }) - expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-custom-placeholder', 'Ask the assistant') + expect(screen.getByTestId('chat-input-area')).toHaveAttribute( + 'data-custom-placeholder', + 'Ask the assistant', + ) }) it('should pass Bot as default botName when appData.site.title is missing', () => { @@ -898,7 +919,9 @@ describe('Chat', () => { footerNotice: 'Agent runs in a Linux sandbox.', }) - expect(screen.getByTestId('chat-input-area')).toHaveTextContent('Agent runs in a Linux sandbox.') + expect(screen.getByTestId('chat-input-area')).toHaveTextContent( + 'Agent runs in a Linux sandbox.', + ) }) it('should pass inputs and inputsForm to ChatInputArea', () => { @@ -981,7 +1004,9 @@ describe('Chat', () => { noStopResponding: false, noChatInput: true, }) - expect(screen.getByRole('button', { name: /stopResponding/i })).toHaveClass('pointer-events-auto') + expect(screen.getByRole('button', { name: /stopResponding/i })).toHaveClass( + 'pointer-events-auto', + ) }) it('should apply chatFooterClassName when footer has content', () => { @@ -1040,7 +1065,8 @@ describe('Chat', () => { describe('Multiple Items and Index Handling', () => { it('should correctly identify last answer in a 10-item chat list', () => { const chatList = Array.from({ length: 10 }, (_, i) => - makeChatItem({ id: `item-${i}`, isAnswer: i % 2 === 1 })) + makeChatItem({ id: `item-${i}`, isAnswer: i % 2 === 1 }), + ) renderChat({ isResponding: true, chatList }) const answers = screen.getAllByTestId('answer-item') expect(answers[answers.length - 1]).toHaveAttribute('data-responding', 'true') diff --git a/web/app/components/base/chat/chat/__tests__/question.spec.tsx b/web/app/components/base/chat/chat/__tests__/question.spec.tsx index 5c18ac96ba306e..abd2eb5b15112d 100644 --- a/web/app/components/base/chat/chat/__tests__/question.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/question.spec.tsx @@ -15,15 +15,20 @@ vi.mock('@react-aria/interactions', () => ({ useFocusVisible: () => ({ isFocusVisible: false }), })) vi.mock('../content-switch', () => ({ - default: ({ count, currentIndex, switchSibling, prevDisabled, nextDisabled }: { + default: ({ + count, + currentIndex, + switchSibling, + prevDisabled, + nextDisabled, + }: { count?: number currentIndex?: number switchSibling: (direction: 'prev' | 'next') => void prevDisabled: boolean nextDisabled: boolean }) => { - if (!(count && count > 1 && currentIndex !== undefined)) - return null + if (!(count && count > 1 && currentIndex !== undefined)) return null return ( <div data-testid="content-switch"> @@ -78,16 +83,17 @@ type RenderProps = { answerIcon?: React.ReactNode } -const makeItem = (overrides: Partial<ChatItem> = {}): ChatItem => ({ - id: 'q-1', - content: 'This is the question content', - message_files: [], - siblingCount: 3, - siblingIndex: 0, - prevSibling: null, - nextSibling: 'q-2', - ...overrides, -} as unknown as ChatItem) +const makeItem = (overrides: Partial<ChatItem> = {}): ChatItem => + ({ + id: 'q-1', + content: 'This is the question content', + message_files: [], + siblingCount: 3, + siblingIndex: 0, + prevSibling: null, + nextSibling: 'q-2', + ...overrides, + }) as unknown as ChatItem const renderWithProvider = ( item: ChatItem, @@ -96,7 +102,7 @@ const renderWithProvider = ( ) => { return render( <ChatContextProvider - config={{} as unknown as (ChatConfig | undefined)} + config={{} as unknown as ChatConfig | undefined} isResponding={false} chatList={[]} showPromptLog={false} @@ -135,12 +141,15 @@ describe('Question component', () => { const markdown = container.querySelector('.markdown-body') expect(markdown).toBeInTheDocument() - const avatar = container.querySelector('.size-10') || container.querySelector('.size-10.shrink-0') + const avatar = + container.querySelector('.size-10') || container.querySelector('.size-10.shrink-0') expect(avatar).toBeTruthy() }) it('should hide avatar when hideAvatar is true', () => { - const { container } = renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { hideAvatar: true }) + const { container } = renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { + hideAvatar: true, + }) const avatar = container.querySelector('.size-10') expect(avatar).toBeNull() }) @@ -152,7 +161,10 @@ describe('Question component', () => { expect(resizeCallback).not.toBeNull() // Mock HTML element clientWidth to trigger logic mapping line coverage - const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') + const originalClientWidth = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'clientWidth', + ) Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 }) act(() => { @@ -259,7 +271,10 @@ describe('Question component', () => { fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' }) await waitFor(() => { - expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { message: 'Edited with Enter', files: [] }) + expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { + message: 'Edited with Enter', + files: [], + }) }) }) @@ -312,7 +327,12 @@ describe('Question component', () => { vi.advanceTimersByTime(50) - const blockedEnterEvent = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }) + const blockedEnterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + cancelable: true, + }) textbox.dispatchEvent(blockedEnterEvent) expect(onRegenerate).not.toHaveBeenCalled() expect(blockedEnterEvent.defaultPrevented).toBe(true) @@ -322,9 +342,11 @@ describe('Question component', () => { vi.advanceTimersByTime(50) fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' }) - expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { message: 'IME guard text', files: [] }) - } - finally { + expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { + message: 'IME guard text', + files: [], + }) + } finally { vi.useRealTimers() } }) @@ -348,7 +370,12 @@ describe('Question component', () => { }) it('should render prev disabled when no prevSibling is provided', () => { - const item = makeItem({ prevSibling: undefined, nextSibling: 'q-next', siblingIndex: 0, siblingCount: 2 }) + const item = makeItem({ + prevSibling: undefined, + nextSibling: 'q-next', + siblingIndex: 0, + siblingCount: 2, + }) renderWithProvider(item, vi.fn() as unknown as OnRegenerate) const prevBtn = screen.getByRole('button', { name: /previous/i }) @@ -416,7 +443,7 @@ describe('Question component', () => { expect(onRegenerate).not.toHaveBeenCalled() // Let setTimeout finish its 50ms interval to clear isComposing - await new Promise(r => setTimeout(r, 60)) + await new Promise((r) => setTimeout(r, 60)) // Now press Enter after composition is fully cleared fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' }) @@ -502,21 +529,16 @@ describe('Question component', () => { }) it('should render default question avatar icon when questionIcon is not provided', () => { - const { container } = renderWithProvider( - makeItem(), - vi.fn() as unknown as OnRegenerate, - ) + const { container } = renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate) const defaultIcon = container.querySelector('.question-default-user-icon') expect(defaultIcon).toBeInTheDocument() }) it('should render custom questionIcon when provided', () => { - const { container } = renderWithProvider( - makeItem(), - vi.fn() as unknown as OnRegenerate, - { questionIcon: <div data-testid="custom-question-icon">CustomIcon</div> }, - ) + const { container } = renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { + questionIcon: <div data-testid="custom-question-icon">CustomIcon</div>, + }) expect(screen.getByTestId('custom-question-icon')).toBeInTheDocument() const defaultIcon = container.querySelector('.question-default-user-icon') @@ -526,7 +548,12 @@ describe('Question component', () => { it('should call switchSibling with next sibling ID when next button clicked and nextSibling exists', async () => { const user = userEvent.setup() const switchSibling = vi.fn() - const item = makeItem({ prevSibling: 'q-0', nextSibling: 'q-2', siblingIndex: 1, siblingCount: 3 }) + const item = makeItem({ + prevSibling: 'q-0', + nextSibling: 'q-2', + siblingIndex: 1, + siblingCount: 3, + }) renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling }) @@ -540,7 +567,12 @@ describe('Question component', () => { it('should not call switchSibling when next button clicked but nextSibling is null', async () => { const user = userEvent.setup() const switchSibling = vi.fn() - const item = makeItem({ prevSibling: 'q-0', nextSibling: undefined, siblingIndex: 2, siblingCount: 3 }) + const item = makeItem({ + prevSibling: 'q-0', + nextSibling: undefined, + siblingIndex: 2, + siblingCount: 3, + }) renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling }) @@ -554,7 +586,12 @@ describe('Question component', () => { it('should not call switchSibling when prev button clicked but prevSibling is null', async () => { const user = userEvent.setup() const switchSibling = vi.fn() - const item = makeItem({ prevSibling: undefined, nextSibling: 'q-2', siblingIndex: 0, siblingCount: 3 }) + const item = makeItem({ + prevSibling: undefined, + nextSibling: 'q-2', + siblingIndex: 0, + siblingCount: 3, + }) renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling }) @@ -566,7 +603,12 @@ describe('Question component', () => { }) it('should render next button disabled when nextSibling is null', () => { - const item = makeItem({ prevSibling: 'q-0', nextSibling: undefined, siblingIndex: 2, siblingCount: 3 }) + const item = makeItem({ + prevSibling: 'q-0', + nextSibling: undefined, + siblingIndex: 2, + siblingCount: 3, + }) renderWithProvider(item, vi.fn() as unknown as OnRegenerate) const nextBtn = screen.getByRole('button', { name: /next/i }) @@ -574,7 +616,12 @@ describe('Question component', () => { }) it('should handle both prev and next siblings being null (only one message)', () => { - const item = makeItem({ prevSibling: undefined, nextSibling: undefined, siblingIndex: 0, siblingCount: 1 }) + const item = makeItem({ + prevSibling: undefined, + nextSibling: undefined, + siblingIndex: 0, + siblingCount: 1, + }) renderWithProvider(item, vi.fn() as unknown as OnRegenerate) const prevBtn = screen.queryByRole('button', { name: /previous/i }) @@ -623,8 +670,14 @@ describe('Question component', () => { renderWithProvider(makeItem()) // Mock clientWidth at different values - const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') - Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 300 }) + const originalClientWidth = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'clientWidth', + ) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + value: 300, + }) act(() => { if (resizeCallback) { @@ -637,7 +690,10 @@ describe('Question component', () => { expect(actionContainer).toHaveStyle({ right: '308px' }) // Change width and trigger resize again - Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 250 }) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + value: 250, + }) act(() => { if (resizeCallback) { @@ -652,8 +708,7 @@ describe('Question component', () => { if (originalClientWidth) { Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth) } - } - finally { + } finally { vi.useRealTimers() } }) @@ -672,7 +727,12 @@ describe('Question component', () => { }) it('should not render content switch when no siblings exist', () => { - const item = makeItem({ siblingCount: 1, siblingIndex: 0, prevSibling: undefined, nextSibling: undefined }) + const item = makeItem({ + siblingCount: 1, + siblingIndex: 0, + prevSibling: undefined, + nextSibling: undefined, + }) renderWithProvider(item) // ContentSwitch should not render when count is 1 @@ -740,8 +800,17 @@ describe('Question component', () => { const switchSibling = vi.fn() // Test first message - const firstItem = makeItem({ prevSibling: undefined, nextSibling: 'q-2', siblingIndex: 0, siblingCount: 3 }) - const { unmount: unmount1 } = renderWithProvider(firstItem, vi.fn() as unknown as OnRegenerate, { switchSibling }) + const firstItem = makeItem({ + prevSibling: undefined, + nextSibling: 'q-2', + siblingIndex: 0, + siblingCount: 3, + }) + const { unmount: unmount1 } = renderWithProvider( + firstItem, + vi.fn() as unknown as OnRegenerate, + { switchSibling }, + ) let prevBtn = screen.getByRole('button', { name: /previous/i }) let nextBtn = screen.getByRole('button', { name: /next/i }) @@ -753,8 +822,15 @@ describe('Question component', () => { vi.clearAllMocks() // Test last message - const lastItem = makeItem({ prevSibling: 'q-0', nextSibling: undefined, siblingIndex: 2, siblingCount: 3 }) - const { unmount: unmount2 } = renderWithProvider(lastItem, vi.fn() as unknown as OnRegenerate, { switchSibling }) + const lastItem = makeItem({ + prevSibling: 'q-0', + nextSibling: undefined, + siblingIndex: 2, + siblingCount: 3, + }) + const { unmount: unmount2 } = renderWithProvider(lastItem, vi.fn() as unknown as OnRegenerate, { + switchSibling, + }) prevBtn = screen.getByRole('button', { name: /previous/i }) nextBtn = screen.getByRole('button', { name: /next/i }) @@ -781,7 +857,7 @@ describe('Question component', () => { fireEvent.compositionEnd(textbox) // Press Enter after final composition end - await new Promise(r => setTimeout(r, 60)) + await new Promise((r) => setTimeout(r, 60)) fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' }) expect(onRegenerate).toHaveBeenCalled() @@ -830,10 +906,7 @@ describe('Question component', () => { fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' }) await waitFor(() => { - expect(onRegenerate).toHaveBeenCalledWith( - item, - { message: 'Modified with files', files }, - ) + expect(onRegenerate).toHaveBeenCalledWith(item, { message: 'Modified with files', files }) }) }) @@ -887,7 +960,12 @@ describe('Question component', () => { const switchSibling = vi.fn() // Test with all siblings - const allItem = makeItem({ prevSibling: 'q-0', nextSibling: 'q-2', siblingIndex: 1, siblingCount: 3 }) + const allItem = makeItem({ + prevSibling: 'q-0', + nextSibling: 'q-2', + siblingIndex: 1, + siblingCount: 3, + }) renderWithProvider(allItem, vi.fn() as unknown as OnRegenerate, { switchSibling }) await user.click(screen.getByRole('button', { name: /previous/i })) @@ -925,7 +1003,12 @@ describe('Question component', () => { it('should handle missing switchSibling prop', async () => { const user = userEvent.setup() - const item = makeItem({ prevSibling: 'prev', nextSibling: 'next', siblingIndex: 1, siblingCount: 3 }) + const item = makeItem({ + prevSibling: 'prev', + nextSibling: 'next', + siblingIndex: 1, + siblingCount: 3, + }) renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling: undefined }) const prevBtn = screen.getByRole('button', { name: /previous/i }) @@ -953,7 +1036,12 @@ describe('Question component', () => { it('should handle handleSwitchSibling call when siblings are missing', async () => { const user = userEvent.setup() const switchSibling = vi.fn() - const item = makeItem({ prevSibling: undefined, nextSibling: undefined, siblingIndex: 0, siblingCount: 2 }) + const item = makeItem({ + prevSibling: undefined, + nextSibling: undefined, + siblingIndex: 0, + siblingCount: 2, + }) renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling }) const prevBtn = screen.getByRole('button', { name: /previous/i }) @@ -979,7 +1067,12 @@ describe('Question component', () => { it('should handle handleSwitchSibling with no siblings and missing switchSibling prop', async () => { const user = userEvent.setup() - const item = makeItem({ prevSibling: undefined, nextSibling: undefined, siblingIndex: 0, siblingCount: 2 }) + const item = makeItem({ + prevSibling: undefined, + nextSibling: undefined, + siblingIndex: 0, + siblingCount: 2, + }) renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling: undefined }) const prevBtn = screen.getByRole('button', { name: /previous/i }) diff --git a/web/app/components/base/chat/chat/__tests__/try-to-ask.spec.tsx b/web/app/components/base/chat/chat/__tests__/try-to-ask.spec.tsx index e59e0865c12401..f48d1a5c63e5f4 100644 --- a/web/app/components/base/chat/chat/__tests__/try-to-ask.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/try-to-ask.spec.tsx @@ -11,24 +11,14 @@ describe('TryToAsk', () => { }) it('renders the component with header text', () => { - render( - <TryToAsk - suggestedQuestions={['Question 1']} - onSend={mockOnSend} - />, - ) + render(<TryToAsk suggestedQuestions={['Question 1']} onSend={mockOnSend} />) expect(screen.getByText(/tryToAsk/i)).toBeInTheDocument() }) it('renders all suggested questions as buttons', () => { const questions = ['What is AI?', 'How does it work?', 'Tell me more'] - render( - <TryToAsk - suggestedQuestions={questions} - onSend={mockOnSend} - />, - ) + render(<TryToAsk suggestedQuestions={questions} onSend={mockOnSend} />) questions.forEach((question) => { expect(screen.getByRole('button', { name: question })).toBeInTheDocument() @@ -39,12 +29,7 @@ describe('TryToAsk', () => { const user = userEvent.setup() const questions = ['Question 1', 'Question 2', 'Question 3'] - render( - <TryToAsk - suggestedQuestions={questions} - onSend={mockOnSend} - />, - ) + render(<TryToAsk suggestedQuestions={questions} onSend={mockOnSend} />) await user.click(screen.getByRole('button', { name: 'Question 2' })) @@ -56,12 +41,7 @@ describe('TryToAsk', () => { const user = userEvent.setup() const questions = ['First', 'Second', 'Third'] - render( - <TryToAsk - suggestedQuestions={questions} - onSend={mockOnSend} - />, - ) + render(<TryToAsk suggestedQuestions={questions} onSend={mockOnSend} />) await user.click(screen.getByRole('button', { name: 'First' })) await user.click(screen.getByRole('button', { name: 'Third' })) @@ -72,12 +52,7 @@ describe('TryToAsk', () => { }) it('renders no buttons when suggestedQuestions is empty', () => { - render( - <TryToAsk - suggestedQuestions={[]} - onSend={mockOnSend} - />, - ) + render(<TryToAsk suggestedQuestions={[]} onSend={mockOnSend} />) expect(screen.queryAllByRole('button')).toHaveLength(0) }) @@ -86,12 +61,7 @@ describe('TryToAsk', () => { const user = userEvent.setup() const question = 'Single question' - render( - <TryToAsk - suggestedQuestions={[question]} - onSend={mockOnSend} - />, - ) + render(<TryToAsk suggestedQuestions={[question]} onSend={mockOnSend} />) const button = screen.getByRole('button', { name: question }) expect(button).toBeInTheDocument() diff --git a/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx b/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx index cf7f63075d434f..e4b896b257264d 100644 --- a/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx @@ -1,13 +1,6 @@ import type { ChatItem } from '../../types' import { act, fireEvent, render, screen } from '@testing-library/react' -import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useChatLayout } from '../use-chat-layout' type ResizeCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void @@ -31,7 +24,11 @@ const makeResizeEntry = (blockSize: number, inlineSize: number): ResizeObserverE target: document.createElement('div'), }) -const assignMetric = (node: HTMLElement, key: 'clientWidth' | 'clientHeight' | 'scrollHeight', value: number) => { +const assignMetric = ( + node: HTMLElement, + key: 'clientWidth' | 'clientHeight' | 'scrollHeight', + value: number, +) => { Object.defineProperty(node, key, { configurable: true, value, @@ -47,13 +44,8 @@ const LayoutHarness = ({ sidebarCollapseState?: boolean attachRefs?: boolean }) => { - const { - width, - chatContainerRef, - chatContainerInnerRef, - chatFooterRef, - chatFooterInnerRef, - } = useChatLayout({ chatList, sidebarCollapseState }) + const { width, chatContainerRef, chatContainerInnerRef, chatFooterRef, chatFooterInnerRef } = + useChatLayout({ chatList, sidebarCollapseState }) return ( <> @@ -76,8 +68,7 @@ const LayoutHarness = ({ data-testid="chat-container-inner" ref={(node) => { chatContainerInnerRef.current = attachRefs ? node : null - if (node && attachRefs) - assignMetric(node, 'clientWidth', 360) + if (node && attachRefs) assignMetric(node, 'clientWidth', 360) }} /> </div> @@ -102,7 +93,7 @@ const LayoutHarness = ({ const flushAnimationFrames = () => { const queuedCallbacks = [...rafCallbacks] rafCallbacks = [] - queuedCallbacks.forEach(callback => callback(0)) + queuedCallbacks.forEach((callback) => callback(0)) } describe('useChatLayout', () => { @@ -123,15 +114,18 @@ describe('useChatLayout', () => { return rafCallbacks.length }) - vi.stubGlobal('ResizeObserver', class { - constructor(cb: ResizeCallback) { - capturedResizeCallbacks.push(cb) - } - - observe() { } - unobserve() { } - disconnect = disconnectSpy - }) + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(cb: ResizeCallback) { + capturedResizeCallbacks.push(cb) + } + + observe() {} + unobserve() {} + disconnect = disconnectSpy + }, + ) }) afterEach(() => { @@ -146,10 +140,7 @@ describe('useChatLayout', () => { render( <LayoutHarness - chatList={[ - makeChatItem({ id: 'q1' }), - makeChatItem({ id: 'a1', isAnswer: true }), - ]} + chatList={[makeChatItem({ id: 'q1' }), makeChatItem({ id: 'a1', isAnswer: true })]} sidebarCollapseState={false} />, ) @@ -173,10 +164,7 @@ describe('useChatLayout', () => { const removeSpy = vi.spyOn(window, 'removeEventListener') const { unmount } = render( <LayoutHarness - chatList={[ - makeChatItem({ id: 'q1' }), - makeChatItem({ id: 'a1', isAnswer: true }), - ]} + chatList={[makeChatItem({ id: 'q1' }), makeChatItem({ id: 'a1', isAnswer: true })]} />, ) @@ -201,10 +189,7 @@ describe('useChatLayout', () => { it('should respect manual scrolling until a new first message arrives and safely ignore missing refs', () => { const { rerender } = render( <LayoutHarness - chatList={[ - makeChatItem({ id: 'q1' }), - makeChatItem({ id: 'a1', isAnswer: true }), - ]} + chatList={[makeChatItem({ id: 'q1' }), makeChatItem({ id: 'a1', isAnswer: true })]} />, ) @@ -242,10 +227,7 @@ describe('useChatLayout', () => { rerender( <LayoutHarness - chatList={[ - makeChatItem({ id: 'q2' }), - makeChatItem({ id: 'a3', isAnswer: true }), - ]} + chatList={[makeChatItem({ id: 'q2' }), makeChatItem({ id: 'a3', isAnswer: true })]} />, ) @@ -258,10 +240,7 @@ describe('useChatLayout', () => { rerender( <LayoutHarness - chatList={[ - makeChatItem({ id: 'q2' }), - makeChatItem({ id: 'a3', isAnswer: true }), - ]} + chatList={[makeChatItem({ id: 'q2' }), makeChatItem({ id: 'a3', isAnswer: true })]} attachRefs={false} />, ) diff --git a/web/app/components/base/chat/chat/__tests__/utils.spec.ts b/web/app/components/base/chat/chat/__tests__/utils.spec.ts index bcdbbcab14f1f8..41d24ddda1dbf8 100644 --- a/web/app/components/base/chat/chat/__tests__/utils.spec.ts +++ b/web/app/components/base/chat/chat/__tests__/utils.spec.ts @@ -18,7 +18,9 @@ describe('chat/chat/utils.ts', () => { }) it('replaces variables with labels when input value is not available but form has variable', () => { - const result = processOpeningStatement('Hello {{user_name}}', {}, [{ variable: 'user_name', label: 'Name Label', type: InputVarType.textInput }] as InputForm[]) + const result = processOpeningStatement('Hello {{user_name}}', {}, [ + { variable: 'user_name', label: 'Name Label', type: InputVarType.textInput }, + ] as InputForm[]) expect(result).toBe('Hello {{Name Label}}') }) diff --git a/web/app/components/base/chat/chat/answer/__tests__/agent-content.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/agent-content.spec.tsx index 66d7bc9301c819..1450d234f91cdb 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/agent-content.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/agent-content.spec.tsx @@ -8,7 +8,11 @@ import AgentContent from '../agent-content' // Mock Markdown component used only in tests vi.mock('@/app/components/base/markdown', () => ({ Markdown: (props: MarkdownProps & { 'data-testid'?: string }) => ( - <div data-testid={props['data-testid'] || 'markdown'} data-content={String(props.content)} className={props.className}> + <div + data-testid={props['data-testid'] || 'markdown'} + data-content={String(props.content)} + className={props.className} + > {String(props.content)} </div> ), @@ -26,14 +30,13 @@ vi.mock('@/app/components/base/chat/chat/thought', () => ({ // Mock FileList and Utils vi.mock('@/app/components/base/file-uploader', () => ({ FileList: ({ files }: { files: FileEntity[] }) => ( - <div data-testid="file-list-component"> - {files.map(f => f.name).join(', ')} - </div> + <div data-testid="file-list-component">{files.map((f) => f.name).join(', ')}</div> ), })) vi.mock('@/app/components/base/file-uploader/utils', () => ({ - getProcessedFilesFromResponse: (files: FileEntity[]) => files.map(f => ({ ...f, name: `processed-${f.id}` })), + getProcessedFilesFromResponse: (files: FileEntity[]) => + files.map((f) => ({ ...f, name: `processed-${f.id}` })), })) describe('AgentContent', () => { @@ -82,10 +85,7 @@ describe('AgentContent', () => { it('renders agent_thoughts if content is absent', () => { const itemWithThoughts = { ...mockItem, - agent_thoughts: [ - { thought: 'Thought 1', tool: 'tool1' }, - { thought: 'Thought 2' }, - ], + agent_thoughts: [{ thought: 'Thought 1', tool: 'tool1' }, { thought: 'Thought 2' }], } render(<AgentContent item={itemWithThoughts as ChatItem} responding={false} />) const items = screen.getAllByTestId('agent-thought-item') @@ -104,7 +104,9 @@ describe('AgentContent', () => { { thought: 'T2', tool: 'tool2' }, // finished by responding=false ], } - const { rerender } = render(<AgentContent item={itemWithThoughts as ChatItem} responding={true} />) + const { rerender } = render( + <AgentContent item={itemWithThoughts as ChatItem} responding={true} />, + ) const thoughts = screen.getAllByTestId('thought-component') expect(thoughts[0]).toHaveAttribute('data-finished', 'true') expect(thoughts[1]).toHaveAttribute('data-finished', 'false') @@ -124,7 +126,9 @@ describe('AgentContent', () => { ], } render(<AgentContent item={itemWithFiles as ChatItem} />) - expect(screen.getByTestId('file-list-component')).toHaveTextContent('processed-file1, processed-file2') + expect(screen.getByTestId('file-list-component')).toHaveTextContent( + 'processed-file1, processed-file2', + ) }) it('renders nothing if no annotation, content, or thoughts', () => { diff --git a/web/app/components/base/chat/chat/answer/__tests__/agent-roster-response-content.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/agent-roster-response-content.spec.tsx index 524e52aa92aae9..859127055ac8e5 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/agent-roster-response-content.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/agent-roster-response-content.spec.tsx @@ -43,7 +43,9 @@ describe('AgentRosterResponseContent', () => { fireEvent.click(screen.getByRole('button', { name: /workFinished/i })) await waitFor(() => { - expect(screen.getByTestId('agent-roster-response-content')).toHaveTextContent('history answer') + expect(screen.getByTestId('agent-roster-response-content')).toHaveTextContent( + 'history answer', + ) }) expect(screen.queryByText('internal thought should not render')).not.toBeInTheDocument() diff --git a/web/app/components/base/chat/chat/answer/__tests__/human-input-form-list.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/human-input-form-list.spec.tsx index cc8fdfffd001f9..4fa3fc7c50d832 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/human-input-form-list.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/human-input-form-list.spec.tsx @@ -5,7 +5,7 @@ import HumanInputFormList from '../human-input-form-list' // Mock child components vi.mock('../human-input-content/content-wrapper', () => ({ - default: ({ children, nodeTitle }: { children: React.ReactNode, nodeTitle: string }) => ( + default: ({ children, nodeTitle }: { children: React.ReactNode; nodeTitle: string }) => ( <div data-testid="content-wrapper" data-nodetitle={nodeTitle}> {children} </div> @@ -13,7 +13,15 @@ vi.mock('../human-input-content/content-wrapper', () => ({ })) vi.mock('../human-input-content/unsubmitted', () => ({ - UnsubmittedHumanInputContent: ({ showEmailTip, isEmailDebugMode, showDebugModeTip }: { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }) => ( + UnsubmittedHumanInputContent: ({ + showEmailTip, + isEmailDebugMode, + showDebugModeTip, + }: { + showEmailTip: boolean + isEmailDebugMode: boolean + showDebugModeTip: boolean + }) => ( <div data-testid="unsubmitted-content"> <span data-testid="email-tip">{showEmailTip ? 'true' : 'false'}</span> <span data-testid="email-debug">{isEmailDebugMode ? 'true' : 'false'}</span> diff --git a/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx index 1216c734100dc1..70e9f7ad4907e5 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx @@ -59,10 +59,12 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - workflowProcess: { status: 'running', tracing: [], steps: [] }, - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + workflowProcess: { status: 'running', tracing: [], steps: [] }, + } as unknown as ChatItem + } />, ) expect(screen.getByTestId('chat-answer-container')).toBeInTheDocument() @@ -72,10 +74,12 @@ describe('Answer Component', () => { const { container } = render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - agent_thoughts: [{ id: '1', thought: 'Thinking...' }], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + agent_thoughts: [{ id: '1', thought: 'Thinking...' }], + } as unknown as ChatItem + } />, ) expect(container.querySelector('.group')).toBeInTheDocument() @@ -85,12 +89,14 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - id: 'msg-with-parts', - content: '', - isAnswer: true, - agent_response_parts: [{ type: 'message', content: 'streamed answer' }], - } as ChatItem} + item={ + { + id: 'msg-with-parts', + content: '', + isAnswer: true, + agent_response_parts: [{ type: 'message', content: 'streamed answer' }], + } as ChatItem + } renderAgentContent={() => <div data-testid="custom-agent-content">streamed answer</div>} />, ) @@ -102,11 +108,13 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - allFiles: [{ id: 'f1', type: 'image', name: 'test.png' }], - message_files: [{ id: 'f2', type: 'document', name: 'doc.pdf' }], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + allFiles: [{ id: 'f1', type: 'image', name: 'test.png' }], + message_files: [{ id: 'f2', type: 'document', name: 'doc.pdf' }], + } as unknown as ChatItem + } />, ) expect(screen.getAllByTestId('file-list')).toHaveLength(2) @@ -116,10 +124,12 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - annotation: { id: 'a1', authorName: 'John Doe' }, - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + annotation: { id: 'a1', authorName: 'John Doe' }, + } as unknown as ChatItem + } />, ) expect(await screen.findByText(/John Doe/i)).toBeInTheDocument() @@ -129,10 +139,12 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - citation: [{ id: 'c1', title: 'Source 1' }], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + citation: [{ id: 'c1', title: 'Source 1' }], + } as unknown as ChatItem + } />, ) expect(screen.getByTestId('citation-title')).toBeInTheDocument() @@ -144,10 +156,12 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - humanInputFormDataList: [{ id: 'form1' }], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + humanInputFormDataList: [{ id: 'form1' }], + } as unknown as ChatItem + } />, ) expect(screen.getByTestId('chat-answer-container')).toBeInTheDocument() @@ -157,10 +171,12 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - humanInputFilledFormDataList: [{ id: 'form1_filled' }], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + humanInputFilledFormDataList: [{ id: 'form1_filled' }], + } as unknown as ChatItem + } />, ) expect(screen.getByTestId('chat-answer-container')).toBeInTheDocument() @@ -176,12 +192,14 @@ describe('Answer Component', () => { <Answer {...defaultProps} responding={true} - item={{ - ...defaultProps.item, - // Thinking ⇒ the answer has not started yet, so content must be empty. - content: '', - reasoningContent: { llm: 'deep thought' }, - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + // Thinking ⇒ the answer has not started yet, so content must be empty. + content: '', + reasoningContent: { llm: 'deep thought' }, + } as unknown as ChatItem + } />, ) expect(screen.getByText(/chat\.thinking/)).toBeInTheDocument() @@ -191,11 +209,13 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - reasoningContent: { llm: 'recalled reasoning' }, - reasoningFinished: true, - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + reasoningContent: { llm: 'recalled reasoning' }, + reasoningFinished: true, + } as unknown as ChatItem + } />, ) expect(screen.getByText(/chat\.thought/)).toBeInTheDocument() @@ -205,11 +225,13 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - reasoningContent: { llm: 'human-input reasoning' }, - humanInputFormDataList: [{ id: 'form1' }], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + reasoningContent: { llm: 'human-input reasoning' }, + humanInputFormDataList: [{ id: 'form1' }], + } as unknown as ChatItem + } />, ) // hasHumanInputs is true, so this can only come from the human-input slot @@ -222,13 +244,15 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - content: '', - reasoningContent: { llm: 'reload reasoning' }, - reasoningFinished: true, - humanInputFilledFormDataList: [{ id: 'form1' }], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + content: '', + reasoningContent: { llm: 'reload reasoning' }, + reasoningFinished: true, + humanInputFilledFormDataList: [{ id: 'form1' }], + } as unknown as ChatItem + } />, ) expect(screen.getByText(/chat\.thought/)).toBeInTheDocument() @@ -243,10 +267,12 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - reasoningContent: {}, - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + reasoningContent: {}, + } as unknown as ChatItem + } />, ) expect(screen.queryByText(/chat\.(thinking|thought)/)).not.toBeInTheDocument() @@ -259,13 +285,15 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - siblingCount: 3, - siblingIndex: 1, - prevSibling: 'msg-0', - nextSibling: 'msg-2', - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + siblingCount: 3, + siblingIndex: 1, + prevSibling: 'msg-0', + nextSibling: 'msg-2', + } as unknown as ChatItem + } switchSibling={mockSwitchSibling} />, ) @@ -303,10 +331,12 @@ describe('Answer Component', () => { {...defaultProps} hideProcessDetail={true} appData={{ site: { show_workflow_steps: false } } as unknown as AppData} - item={{ - ...defaultProps.item, - workflowProcess: { status: 'running', tracing: [], steps: [] }, - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + workflowProcess: { status: 'running', tracing: [], steps: [] }, + } as unknown as ChatItem + } />, ) expect(screen.getByTestId('chat-answer-container')).toBeInTheDocument() @@ -316,10 +346,12 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - more: { messages: [{ text: 'more content' }] }, - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + more: { messages: [{ text: 'more content' }] }, + } as unknown as ChatItem + } />, ) expect(screen.getByTestId('more-container')).toBeInTheDocument() @@ -329,11 +361,13 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - content: '', - humanInputFormDataList: [{ id: 'form1' }], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + content: '', + humanInputFormDataList: [{ id: 'form1' }], + } as unknown as ChatItem + } />, ) expect(screen.getByTestId('chat-answer-container-humaninput')).toBeInTheDocument() @@ -343,14 +377,16 @@ describe('Answer Component', () => { render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - content: '', - siblingCount: 2, - siblingIndex: 1, - prevSibling: 'msg-0', - humanInputFormDataList: [{ id: 'form1' }], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + content: '', + siblingCount: 2, + siblingIndex: 1, + prevSibling: 'msg-0', + humanInputFormDataList: [{ id: 'form1' }], + } as unknown as ChatItem + } />, ) expect(screen.getByTestId('chat-answer-container-humaninput')).toBeInTheDocument() @@ -361,11 +397,13 @@ describe('Answer Component', () => { <Answer {...defaultProps} responding={true} - item={{ - ...defaultProps.item, - content: '', - humanInputFormDataList: [{ id: 'form1' }], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + content: '', + humanInputFormDataList: [{ id: 'form1' }], + } as unknown as ChatItem + } />, ) expect(container).toBeInTheDocument() @@ -373,15 +411,15 @@ describe('Answer Component', () => { it('should handle ResizeObserver callback', () => { const originalResizeObserver = globalThis.ResizeObserver - let triggerResize = () => { } + let triggerResize = () => {} globalThis.ResizeObserver = class ResizeObserver { constructor(callback: unknown) { triggerResize = callback as () => void } - observe() { } - unobserve() { } - disconnect() { } + observe() {} + unobserve() {} + disconnect() {} } as unknown as typeof ResizeObserver render(<Answer {...defaultProps} />) @@ -400,21 +438,36 @@ describe('Answer Component', () => { const { container } = render( <Answer {...defaultProps} - item={{ - ...defaultProps.item, - humanInputFilledFormDataList: [{ id: 'form1' } as unknown as Record<string, unknown>], - humanInputFormDataList: [], // hits length > 0 false branch - agent_thoughts: [{ id: 'thought1', thought: 'thinking' }], - allFiles: [{ _id: 'file1', name: 'file1.txt', type: 'document' } as unknown as Record<string, unknown>], - message_files: [{ id: 'file2', url: 'http://test.com', type: 'image/png' } as unknown as Record<string, unknown>], - annotation: { id: 'anno1', authorName: 'Author' } as unknown as Record<string, unknown>, - citation: [{ item: { title: 'cite 1' } }] as unknown as Record<string, unknown>[], - siblingCount: 2, - siblingIndex: 1, - prevSibling: 'msg-0', - nextSibling: 'msg-2', - more: { messages: [{ text: 'more content' }] }, - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + humanInputFilledFormDataList: [{ id: 'form1' } as unknown as Record<string, unknown>], + humanInputFormDataList: [], // hits length > 0 false branch + agent_thoughts: [{ id: 'thought1', thought: 'thinking' }], + allFiles: [ + { _id: 'file1', name: 'file1.txt', type: 'document' } as unknown as Record< + string, + unknown + >, + ], + message_files: [ + { id: 'file2', url: 'http://test.com', type: 'image/png' } as unknown as Record< + string, + unknown + >, + ], + annotation: { id: 'anno1', authorName: 'Author' } as unknown as Record< + string, + unknown + >, + citation: [{ item: { title: 'cite 1' } }] as unknown as Record<string, unknown>[], + siblingCount: 2, + siblingIndex: 1, + prevSibling: 'msg-0', + nextSibling: 'msg-2', + more: { messages: [{ text: 'more content' }] }, + } as unknown as ChatItem + } />, ) expect(container).toBeInTheDocument() @@ -426,10 +479,12 @@ describe('Answer Component', () => { {...defaultProps} hideProcessDetail={true} appData={undefined} - item={{ - ...defaultProps.item, - workflowProcess: { status: 'running', tracing: [], steps: [] }, - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + workflowProcess: { status: 'running', tracing: [], steps: [] }, + } as unknown as ChatItem + } />, ) expect(screen.getByTestId('chat-answer-container')).toBeInTheDocument() @@ -442,11 +497,13 @@ describe('Answer Component', () => { {...defaultProps} hideProcessDetail={true} appData={undefined} - item={{ - ...defaultProps.item, - workflowProcess: { status: 'running', tracing: [], steps: [] }, - humanInputFormDataList: [{ id: 'form1' } as unknown as Record<string, unknown>], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + workflowProcess: { status: 'running', tracing: [], steps: [] }, + humanInputFormDataList: [{ id: 'form1' } as unknown as Record<string, unknown>], + } as unknown as ChatItem + } />, ) @@ -456,11 +513,13 @@ describe('Answer Component', () => { {...defaultProps} hideProcessDetail={true} appData={{ site: { show_workflow_steps: false } } as unknown as AppData} - item={{ - ...defaultProps.item, - workflowProcess: { status: 'running', tracing: [], steps: [] }, - humanInputFormDataList: [{ id: 'form1' } as unknown as Record<string, unknown>], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + workflowProcess: { status: 'running', tracing: [], steps: [] }, + humanInputFormDataList: [{ id: 'form1' } as unknown as Record<string, unknown>], + } as unknown as ChatItem + } />, ) @@ -470,11 +529,13 @@ describe('Answer Component', () => { {...defaultProps} hideProcessDetail={false} appData={{ site: { show_workflow_steps: true } } as unknown as AppData} - item={{ - ...defaultProps.item, - workflowProcess: { status: 'running', tracing: [], steps: [] }, - humanInputFormDataList: [{ id: 'form1' } as unknown as Record<string, unknown>], - } as unknown as ChatItem} + item={ + { + ...defaultProps.item, + workflowProcess: { status: 'running', tracing: [], steps: [] }, + humanInputFormDataList: [{ id: 'form1' } as unknown as Record<string, unknown>], + } as unknown as ChatItem + } />, ) diff --git a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx index f145e6a4224487..e9d9fd4d72300f 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx @@ -7,25 +7,21 @@ import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import Operation from '../operation' -const { - mockSetShowAnnotationFullModal, - mockProviderContext, - mockT, - mockAddAnnotation, -} = vi.hoisted(() => { - return { - mockAddAnnotation: vi.fn(), - mockSetShowAnnotationFullModal: vi.fn(), - mockT: vi.fn((key: string): string => key), - mockProviderContext: { - plan: { - usage: { annotatedResponse: 0 }, - total: { annotatedResponse: 100 }, +const { mockSetShowAnnotationFullModal, mockProviderContext, mockT, mockAddAnnotation } = + vi.hoisted(() => { + return { + mockAddAnnotation: vi.fn(), + mockSetShowAnnotationFullModal: vi.fn(), + mockT: vi.fn((key: string): string => key), + mockProviderContext: { + plan: { + usage: { annotatedResponse: 0 }, + total: { annotatedResponse: 100 }, + }, + enableBilling: false, }, - enableBilling: false, - }, - } -}) + } + }) vi.mock('copy-to-clipboard', () => ({ default: vi.fn() })) @@ -59,56 +55,85 @@ vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ })) vi.mock('@/app/components/app/annotation/edit-annotation-modal', () => ({ - default: ({ isShow, onHide, onEdited, onAdded, onRemove }: { + default: ({ + isShow, + onHide, + onEdited, + onAdded, + onRemove, + }: { isShow: boolean onHide: () => void onEdited: (q: string, a: string) => void onAdded: (id: string, name: string, q: string, a: string) => void onRemove: () => void }) => - isShow - ? ( - <div data-testid="edit-reply-modal"> - <button data-testid="modal-hide" onClick={onHide}>Close</button> - <button data-testid="modal-edit" onClick={() => onEdited('eq', 'ea')}>Edit</button> - <button data-testid="modal-add" onClick={() => onAdded('a1', 'author', 'eq', 'ea')}>Add</button> - <button data-testid="modal-remove" onClick={onRemove}>Remove</button> - </div> - ) - : null, + isShow ? ( + <div data-testid="edit-reply-modal"> + <button data-testid="modal-hide" onClick={onHide}> + Close + </button> + <button data-testid="modal-edit" onClick={() => onEdited('eq', 'ea')}> + Edit + </button> + <button data-testid="modal-add" onClick={() => onAdded('a1', 'author', 'eq', 'ea')}> + Add + </button> + <button data-testid="modal-remove" onClick={onRemove}> + Remove + </button> + </div> + ) : null, })) -vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button', () => ({ - default: function AnnotationCtrlMock({ onAdded, onEdit, cached }: { - onAdded: (id: string, authorName: string) => void - onEdit: () => void - cached: boolean - }) { - const { setShowAnnotationFullModal } = useModalContext() - const { plan, enableBilling } = useProviderContext() - const handleAdd = () => { - if (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse) { - setShowAnnotationFullModal() - return +vi.mock( + '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button', + () => ({ + default: function AnnotationCtrlMock({ + onAdded, + onEdit, + cached, + }: { + onAdded: (id: string, authorName: string) => void + onEdit: () => void + cached: boolean + }) { + const { setShowAnnotationFullModal } = useModalContext() + const { plan, enableBilling } = useProviderContext() + const handleAdd = () => { + if (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse) { + setShowAnnotationFullModal() + return + } + onAdded('ann-new', 'Test User') } - onAdded('ann-new', 'Test User') - } - return ( - <div data-testid="annotation-ctrl"> - {cached - ? (<button data-testid="annotation-edit-btn" onClick={onEdit}>Edit</button>) - : (<button data-testid="annotation-add-btn" onClick={handleAdd}>Add</button>)} - </div> - ) - }, -})) + return ( + <div data-testid="annotation-ctrl"> + {cached ? ( + <button data-testid="annotation-edit-btn" onClick={onEdit}> + Edit + </button> + ) : ( + <button data-testid="annotation-add-btn" onClick={handleAdd}> + Add + </button> + )} + </div> + ) + }, + }), +) vi.mock('@/app/components/base/new-audio-button', () => ({ default: () => <button data-testid="audio-btn">Play</button>, })) vi.mock('@/app/components/base/chat/chat/log', () => ({ - default: () => <button data-testid="log-btn"><div className="i-ri-file-list-3-line" /></button>, + default: () => ( + <button data-testid="log-btn"> + <div className="i-ri-file-list-3-line" /> + </button> + ), })) vi.mock('@/next/navigation', () => ({ @@ -116,31 +141,32 @@ vi.mock('@/next/navigation', () => ({ usePathname: vi.fn(() => '/apps/test-app'), })) -const makeChatConfig = (overrides: Partial<ChatConfig> = {}): ChatConfig => ({ - opening_statement: '', - pre_prompt: '', - prompt_type: 'simple' as ChatConfig['prompt_type'], - user_input_form: [], - dataset_query_variable: '', - more_like_this: { enabled: false }, - suggested_questions_after_answer: { enabled: false }, - speech_to_text: { enabled: false }, - text_to_speech: { enabled: false }, - retriever_resource: { enabled: false }, - sensitive_word_avoidance: { enabled: false }, - agent_mode: { enabled: false, tools: [] }, - dataset_configs: { retrieval_model: 'single' } as ChatConfig['dataset_configs'], - system_parameters: { - audio_file_size_limit: 10, - file_size_limit: 10, - image_file_size_limit: 10, - video_file_size_limit: 10, - workflow_file_upload_limit: 10, - }, - supportFeedback: false, - supportAnnotation: false, - ...overrides, -} as ChatConfig) +const makeChatConfig = (overrides: Partial<ChatConfig> = {}): ChatConfig => + ({ + opening_statement: '', + pre_prompt: '', + prompt_type: 'simple' as ChatConfig['prompt_type'], + user_input_form: [], + dataset_query_variable: '', + more_like_this: { enabled: false }, + suggested_questions_after_answer: { enabled: false }, + speech_to_text: { enabled: false }, + text_to_speech: { enabled: false }, + retriever_resource: { enabled: false }, + sensitive_word_avoidance: { enabled: false }, + agent_mode: { enabled: false, tools: [] }, + dataset_configs: { retrieval_model: 'single' } as ChatConfig['dataset_configs'], + system_parameters: { + audio_file_size_limit: 10, + file_size_limit: 10, + image_file_size_limit: 10, + video_file_size_limit: 10, + workflow_file_upload_limit: 10, + }, + supportFeedback: false, + supportAnnotation: false, + ...overrides, + }) as ChatConfig const mockContextValue: ChatContextValue = { chatList: [], @@ -159,11 +185,11 @@ vi.mock('../../context', () => ({ vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey(mockT), }), - }) + } }) type OperationProps = { @@ -242,7 +268,12 @@ describe('Operation', () => { it('should show annotation button when config supports it', () => { mockContextValue.config = makeChatConfig({ supportAnnotation: true, - annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true }, + annotation_reply: { + id: 'ar-1', + score_threshold: 0.5, + embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, + enabled: true, + }, }) renderOperation() expect(screen.getByTestId('annotation-ctrl'))!.toBeInTheDocument() @@ -252,7 +283,12 @@ describe('Operation', () => { mockContextValue.readonly = true mockContextValue.config = makeChatConfig({ supportAnnotation: true, - annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true }, + annotation_reply: { + id: 'ar-1', + score_threshold: 0.5, + embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, + enabled: true, + }, }) renderOperation() @@ -264,7 +300,12 @@ describe('Operation', () => { mockContextValue.onAnnotationEdited = undefined mockContextValue.config = makeChatConfig({ supportAnnotation: true, - annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true }, + annotation_reply: { + id: 'ar-1', + score_threshold: 0.5, + embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, + enabled: true, + }, }) renderOperation() @@ -280,8 +321,12 @@ describe('Operation', () => { it('should keep hover-only controls visible when a descendant popup is open', () => { renderOperation({ ...baseProps, showPromptLog: true }) - expect(screen.getByTestId('operation-actions')).toHaveClass('group-has-[[data-popup-open]]:flex') - expect(screen.getByTestId('log-btn').parentElement).toHaveClass('group-has-[[data-popup-open]]:block') + expect(screen.getByTestId('operation-actions')).toHaveClass( + 'group-has-[[data-popup-open]]:flex', + ) + expect(screen.getByTestId('log-btn').parentElement).toHaveClass( + 'group-has-[[data-popup-open]]:block', + ) }) it('should not show prompt log for opening statements', () => { @@ -305,8 +350,26 @@ describe('Operation', () => { ...baseItem, content: 'ignored', agent_thoughts: [ - { id: '1', thought: 'Hello ', tool: '', tool_input: '', observation: '', message_id: '', conversation_id: '', position: 0 }, - { id: '2', thought: 'World', tool: '', tool_input: '', observation: '', message_id: '', conversation_id: '', position: 1 }, + { + id: '1', + thought: 'Hello ', + tool: '', + tool_input: '', + observation: '', + message_id: '', + conversation_id: '', + position: 0, + }, + { + id: '2', + thought: 'World', + tool: '', + tool_input: '', + observation: '', + message_id: '', + conversation_id: '', + position: 1, + }, ], } renderOperation({ ...baseProps, item }) @@ -330,7 +393,12 @@ describe('Operation', () => { supportFeedback: false, text_to_speech: { enabled: true }, supportAnnotation: true, - annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true }, + annotation_reply: { + id: 'ar-1', + score_threshold: 0.5, + embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, + enabled: true, + }, }) const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem renderOperation({ ...baseProps, item }) @@ -355,15 +423,24 @@ describe('Operation', () => { it('should call onFeedback with like on like click', async () => { const user = userEvent.setup() renderOperation() - const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')! + const thumbUp = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-up-line')! + .closest('button')! await user.click(thumbUp) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: 'like', + content: undefined, + }) }) it('should open feedback modal on dislike click', async () => { const user = userEvent.setup() renderOperation() - const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + const thumbDown = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-down-line')! + .closest('button')! await user.click(thumbDown) expect(screen.getByRole('textbox'))!.toBeInTheDocument() }) @@ -371,19 +448,28 @@ describe('Operation', () => { it('should submit dislike feedback from modal', async () => { const user = userEvent.setup() renderOperation() - const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + const thumbDown = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-down-line')! + .closest('button')! await user.click(thumbDown) const textarea = screen.getByRole('textbox') await user.type(textarea, 'Bad response') const confirmBtn = screen.getByText(/submit/i) await user.click(confirmBtn) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: 'Bad response' }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: 'dislike', + content: 'Bad response', + }) }) it('should cancel feedback modal', async () => { const user = userEvent.setup() renderOperation() - const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + const thumbDown = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-down-line')! + .closest('button')! await user.click(thumbDown) expect(screen.getByRole('textbox'))!.toBeInTheDocument() const cancelBtn = screen.getByText(/cancel/i) @@ -395,47 +481,83 @@ describe('Operation', () => { const user = userEvent.setup() const item = { ...baseItem, feedback: { rating: 'like' as const } } renderOperation({ ...baseProps, item }) - const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')! + const thumbUp = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-up-line')! + .closest('button')! await user.click(thumbUp) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: null, + content: undefined, + }) }) it('should show existing dislike feedback and allow undo', async () => { const user = userEvent.setup() const item = { ...baseItem, feedback: { rating: 'dislike' as const, content: 'bad' } } renderOperation({ ...baseProps, item }) - const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + const thumbDown = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-down-line')! + .closest('button')! await user.click(thumbDown) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: null, + content: undefined, + }) }) it('should undo like when already liked', async () => { const user = userEvent.setup() renderOperation() // First click to like - const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')! + const thumbUp = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-up-line')! + .closest('button')! await user.click(thumbUp) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: 'like', + content: undefined, + }) // Second click to undo - re-query as it might be a different node - const thumbUpUndo = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')! + const thumbUpUndo = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-up-line')! + .closest('button')! await user.click(thumbUpUndo) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: null, + content: undefined, + }) }) it('should undo dislike when already disliked', async () => { const user = userEvent.setup() renderOperation() - const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + const thumbDown = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-down-line')! + .closest('button')! await user.click(thumbDown) const submitBtn = screen.getByText(/submit/i) await user.click(submitBtn) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: '' }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: 'dislike', + content: '', + }) // Re-query for undo - const thumbDownUndo = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + const thumbDownUndo = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-down-line')! + .closest('button')! await user.click(thumbDownUndo) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: null, + content: undefined, + }) }) it('should show tooltip with dislike and content', () => { @@ -455,13 +577,17 @@ describe('Operation', () => { it('should not show feedback bar for opening statements', () => { const item = { ...baseItem, isOpeningStatement: true } renderOperation({ ...baseProps, item }) - expect(screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument() + expect( + screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line'), + ).not.toBeInTheDocument() }) it('should not show user feedback bar when humanInputFormDataList is present', () => { const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem renderOperation({ ...baseProps, item }) - expect(screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument() + expect( + screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line'), + ).not.toBeInTheDocument() }) it('should not call feedback when supportFeedback is disabled', async () => { @@ -476,11 +602,14 @@ describe('Operation', () => { const user = userEvent.setup() mockT.mockImplementation((_key: string): string => '') renderOperation() - const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + const thumbDown = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-down-line')! + .closest('button')! await user.click(thumbDown) expect(screen.getByRole('dialog', { name: 'Provide Feedback' }))!.toBeInTheDocument() expect(screen.getByLabelText('Feedback Content'))!.toBeInTheDocument() - mockT.mockImplementation(key => key) + mockT.mockImplementation((key) => key) }) }) @@ -502,7 +631,10 @@ describe('Operation', () => { const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line') const adminThumb = thumbs[thumbs.length - 1]!.closest('button')! await user.click(adminThumb) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: 'like', + content: undefined, + }) }) it('should open feedback modal on admin dislike click', async () => { @@ -532,18 +664,30 @@ describe('Operation', () => { const user = userEvent.setup() const item = { ...baseItem, adminFeedback: { rating: 'like' as const } } renderOperation({ ...baseProps, item }) - const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')! + const thumbUp = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-up-line')! + .closest('button')! await user.click(thumbUp) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: null, + content: undefined, + }) }) it('should show existing admin dislike and allow undo', async () => { const user = userEvent.setup() const item = { ...baseItem, adminFeedback: { rating: 'dislike' as const } } renderOperation({ ...baseProps, item }) - const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + const thumbDown = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-down-line')! + .closest('button')! await user.click(thumbDown) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: null, + content: undefined, + }) }) it('should undo admin like when already liked', async () => { @@ -552,12 +696,18 @@ describe('Operation', () => { const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line') const adminThumb = thumbs[thumbs.length - 1]!.closest('button')! await user.click(adminThumb) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: 'like', + content: undefined, + }) const thumbsUndo = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line') const adminThumbUndo = thumbsUndo[thumbsUndo.length - 1]!.closest('button')! await user.click(adminThumbUndo) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: null, + content: undefined, + }) }) it('should undo admin dislike when already disliked', async () => { @@ -569,16 +719,23 @@ describe('Operation', () => { const submitBtn = screen.getByText(/submit/i) await user.click(submitBtn) - const thumbsUndo = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line') + const thumbsUndo = screen + .getByTestId('operation-bar') + .querySelectorAll('.i-ri-thumb-down-line') const adminThumbUndo = thumbsUndo[thumbsUndo.length - 1]!.closest('button')! await user.click(adminThumbUndo) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: null, + content: undefined, + }) }) it('should not show admin feedback bar when humanInputFormDataList is present', () => { const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem renderOperation({ ...baseProps, item }) - expect(screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line').length).toBe(0) + expect( + screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line').length, + ).toBe(0) }) it('should render action buttons with Default state when feedback rating is undefined', () => { @@ -620,10 +777,19 @@ describe('Operation', () => { mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true }, supportAnnotation: true, - annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true }, + annotation_reply: { + id: 'ar-1', + score_threshold: 0.5, + embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, + enabled: true, + }, supportFeedback: true, }) - const item = { ...baseItem, feedback: { rating: 'like' as const }, adminFeedback: { rating: 'dislike' as const } } + const item = { + ...baseItem, + feedback: { rating: 'like' as const }, + adminFeedback: { rating: 'dislike' as const }, + } renderOperation({ ...baseProps, item, showPromptLog: true }) const bar = screen.getByTestId('operation-bar') expect(bar)!.toBeInTheDocument() @@ -640,8 +806,7 @@ describe('Operation', () => { it('should handle missing translation fallbacks in buildFeedbackTooltip', () => { // Mock t to return null for specific keys mockT.mockImplementation((key: string): string => { - if (key.includes('Rate') || key.includes('like')) - return '' // Safe string fallback + if (key.includes('Rate') || key.includes('like')) return '' // Safe string fallback return key }) @@ -650,23 +815,27 @@ describe('Operation', () => { expect(screen.getByTestId('operation-bar'))!.toBeInTheDocument() // Reset to default behavior - mockT.mockImplementation(key => key) + mockT.mockImplementation((key) => key) }) it('should handle buildFeedbackTooltip with empty translation fallbacks', () => { // Mock t to return empty string for 'like' and 'dislike' to hit fallback branches: mockT.mockImplementation((key: string): string => { - if (key.includes('operation.like')) - return '' - if (key.includes('operation.dislike')) - return '' + if (key.includes('operation.like')) return '' + if (key.includes('operation.dislike')) return '' return key }) - const itemLike = { ...baseItem, feedback: { rating: 'like' as const, content: 'test content' } } + const itemLike = { + ...baseItem, + feedback: { rating: 'like' as const, content: 'test content' }, + } const { rerender } = renderOperation({ ...baseProps, item: itemLike }) expect(screen.getByTestId('operation-bar'))!.toBeInTheDocument() - const itemDislike = { ...baseItem, feedback: { rating: 'dislike' as const, content: 'test content' } } + const itemDislike = { + ...baseItem, + feedback: { rating: 'dislike' as const, content: 'test content' }, + } rerender( <div className="group"> <Operation {...baseProps} item={itemDislike} /> @@ -674,12 +843,15 @@ describe('Operation', () => { ) expect(screen.getByTestId('operation-bar'))!.toBeInTheDocument() - mockT.mockImplementation(key => key) + mockT.mockImplementation((key) => key) }) it('should handle buildFeedbackTooltip without rating', () => { // Mock tooltip display without rating to hit: 'if (!feedbackData?.rating) return label' - const item = { ...baseItem, feedback: { rating: null } as unknown as Record<string, unknown> } as unknown as ChatItem + const item = { + ...baseItem, + feedback: { rating: null } as unknown as Record<string, unknown>, + } as unknown as ChatItem renderOperation({ ...baseProps, item }) const bar = screen.getByTestId('operation-bar') expect(bar)!.toBeInTheDocument() @@ -692,7 +864,10 @@ describe('Operation', () => { mockContextValue.onFeedback = vi.fn() const { rerender } = renderOperation() - const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')! + const thumbUp = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-up-line')! + .closest('button')! // Then, disable the context callback to hit the `if (!onFeedback) return` early exit internally upon rerender/click mockContextValue.onFeedback = undefined @@ -712,7 +887,12 @@ describe('Operation', () => { beforeEach(() => { mockContextValue.config = makeChatConfig({ supportAnnotation: true, - annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true }, + annotation_reply: { + id: 'ar-1', + score_threshold: 0.5, + embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, + enabled: true, + }, appId: 'test-app', }) }) @@ -722,7 +902,13 @@ describe('Operation', () => { renderOperation() const addBtn = screen.getByTestId('annotation-add-btn') await user.click(addBtn) - expect(mockContextValue.onAnnotationAdded).toHaveBeenCalledWith('ann-new', 'Test User', 'What is this?', 'Hello world', 0) + expect(mockContextValue.onAnnotationAdded).toHaveBeenCalledWith( + 'ann-new', + 'Test User', + 'What is this?', + 'Hello world', + 0, + ) }) it('should show annotation full modal when limit reached', async () => { @@ -738,7 +924,10 @@ describe('Operation', () => { it('should open edit reply modal when cached annotation exists', async () => { const user = userEvent.setup() - const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } } + const item = { + ...baseItem, + annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' }, + } renderOperation({ ...baseProps, item }) const editBtn = screen.getByTestId('annotation-edit-btn') await user.click(editBtn) @@ -747,7 +936,10 @@ describe('Operation', () => { it('should call onAnnotationEdited from edit reply modal', async () => { const user = userEvent.setup() - const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } } + const item = { + ...baseItem, + annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' }, + } renderOperation({ ...baseProps, item }) const editBtn = screen.getByTestId('annotation-edit-btn') await user.click(editBtn) @@ -757,7 +949,10 @@ describe('Operation', () => { it('should call onAnnotationAdded from edit reply modal', async () => { const user = userEvent.setup() - const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } } + const item = { + ...baseItem, + annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' }, + } renderOperation({ ...baseProps, item }) const editBtn = screen.getByTestId('annotation-edit-btn') await user.click(editBtn) @@ -767,7 +962,10 @@ describe('Operation', () => { it('should call onAnnotationRemoved from edit reply modal', async () => { const user = userEvent.setup() - const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } } + const item = { + ...baseItem, + annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' }, + } renderOperation({ ...baseProps, item }) const editBtn = screen.getByTestId('annotation-edit-btn') await user.click(editBtn) @@ -777,7 +975,10 @@ describe('Operation', () => { it('should close edit reply modal via onHide', async () => { const user = userEvent.setup() - const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } } + const item = { + ...baseItem, + annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' }, + } renderOperation({ ...baseProps, item }) const editBtn = screen.getByTestId('annotation-edit-btn') await user.click(editBtn) @@ -789,7 +990,9 @@ describe('Operation', () => { describe('TTS audio button', () => { beforeEach(() => { - mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true, voice: 'test-voice' } }) + mockContextValue.config = makeChatConfig({ + text_to_speech: { enabled: true, voice: 'test-voice' }, + }) }) it('should show audio play button when TTS enabled', () => { @@ -809,13 +1012,19 @@ describe('Operation', () => { const user = userEvent.setup() mockContextValue.config = makeChatConfig({ supportFeedback: true }) renderOperation() - const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')! + const thumbDown = screen + .getByTestId('operation-bar') + .querySelector('.i-ri-thumb-down-line')! + .closest('button')! await user.click(thumbDown) const textarea = screen.getByRole('textbox') await user.type(textarea, ' ') const confirmBtn = screen.getByText(/submit/i) await user.click(confirmBtn) - expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: ' ' }) + expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { + rating: 'dislike', + content: ' ', + }) }) it('should handle missing onFeedback callback gracefully', async () => { @@ -838,10 +1047,22 @@ describe('Operation', () => { mockContextValue.readonly = true mockContextValue.config = makeChatConfig({ supportAnnotation: true, - annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true }, + annotation_reply: { + id: 'ar-1', + score_threshold: 0.5, + embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, + enabled: true, + }, appId: 'test-app', }) - const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } as unknown as Record<string, unknown> } as unknown as ChatItem + const item = { + ...baseItem, + annotation: { + id: 'ann-1', + created_at: 123, + authorName: 'test author', + } as unknown as Record<string, unknown>, + } as unknown as ChatItem renderOperation({ ...baseProps, item }) @@ -852,11 +1073,23 @@ describe('Operation', () => { it('should hide cached annotation edit controls when mutation callbacks are missing', () => { mockContextValue.config = makeChatConfig({ supportAnnotation: true, - annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true }, + annotation_reply: { + id: 'ar-1', + score_threshold: 0.5, + embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, + enabled: true, + }, appId: 'test-app', }) mockContextValue.onAnnotationRemoved = undefined - const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } as unknown as Record<string, unknown> } as unknown as ChatItem + const item = { + ...baseItem, + annotation: { + id: 'ann-1', + created_at: 123, + authorName: 'test author', + } as unknown as Record<string, unknown>, + } as unknown as ChatItem renderOperation({ ...baseProps, item }) diff --git a/web/app/components/base/chat/chat/answer/__tests__/reasoning-panel.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/reasoning-panel.spec.tsx index 4d3343ec5d6a58..1f47d47909066a 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/reasoning-panel.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/reasoning-panel.spec.tsx @@ -6,7 +6,9 @@ import ReasoningPanel from '../reasoning-panel' // Mock the heavy Markdown renderer to a simple passthrough. vi.mock('@/app/components/base/markdown', () => ({ - Markdown: ({ content }: { content: string }) => <div data-testid="reasoning-markdown">{content}</div>, + Markdown: ({ content }: { content: string }) => ( + <div data-testid="reasoning-markdown">{content}</div> + ), })) describe('ReasoningPanel', () => { diff --git a/web/app/components/base/chat/chat/answer/__tests__/suggested-questions.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/suggested-questions.spec.tsx index 6fe7c959cbd48d..6ada610546f949 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/suggested-questions.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/suggested-questions.spec.tsx @@ -14,9 +14,9 @@ describe('SuggestedQuestions', () => { const mockOnSend = vi.fn() beforeEach(() => { - vi.clearAllMocks(); + vi.clearAllMocks() // Use 'as Mock' instead of 'as any' - (useChatContext as Mock).mockReturnValue({ + ;(useChatContext as Mock).mockReturnValue({ onSend: mockOnSend, readonly: false, }) @@ -59,14 +59,16 @@ describe('SuggestedQuestions', () => { expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument() // Use 'as IChatItem' instead of 'as any' - render(<SuggestedQuestions item={{ ...mockItem, suggestedQuestions: undefined } as IChatItem} />) + render( + <SuggestedQuestions item={{ ...mockItem, suggestedQuestions: undefined } as IChatItem} />, + ) expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument() }) it('should be disabled and not call onSend when readonly is true', async () => { - const user = userEvent.setup(); + const user = userEvent.setup() // Use 'as Mock' instead of 'as any' - (useChatContext as Mock).mockReturnValue({ + ;(useChatContext as Mock).mockReturnValue({ onSend: mockOnSend, readonly: true, }) diff --git a/web/app/components/base/chat/chat/answer/__tests__/workflow-process.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/workflow-process.spec.tsx index a1d49280a195bd..44a3af66837446 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/workflow-process.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/workflow-process.spec.tsx @@ -27,11 +27,13 @@ describe('WorkflowProcessItem', () => { it('should render workflow error message as collapsed title when failed without tracing', () => { render( <WorkflowProcessItem - data={{ - status: WorkflowRunningStatus.Failed, - tracing: [], - error: 'Invalid upload file', - } as WorkflowProcess} + data={ + { + status: WorkflowRunningStatus.Failed, + tracing: [], + error: 'Invalid upload file', + } as WorkflowProcess + } expand={false} />, ) @@ -49,11 +51,13 @@ describe('WorkflowProcessItem', () => { it('should render workflow error message when failed without node tracing details', () => { render( <WorkflowProcessItem - data={{ - status: WorkflowRunningStatus.Failed, - tracing: [], - error: 'Invalid upload file', - } as WorkflowProcess} + data={ + { + status: WorkflowRunningStatus.Failed, + tracing: [], + error: 'Invalid upload file', + } as WorkflowProcess + } expand={true} />, ) @@ -79,60 +83,118 @@ describe('WorkflowProcessItem', () => { }) it('should render nothing if readonly is true', () => { - const { container } = render(<WorkflowProcessItem data={mockData as WorkflowProcess} readonly={true} />) + const { container } = render( + <WorkflowProcessItem data={mockData as WorkflowProcess} readonly={true} />, + ) expect(container.firstChild).toBeNull() }) describe('Status Icons', () => { it('should show running spinner when status is Running', () => { - render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Running } as WorkflowProcess} />) + render( + <WorkflowProcessItem + data={{ ...mockData, status: WorkflowRunningStatus.Running } as WorkflowProcess} + />, + ) expect(screen.getByRole('img', { name: /workflowProcessRunning/ })).toBeInTheDocument() }) it('should show success circle when status is Succeeded', () => { - render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} />) + render( + <WorkflowProcessItem + data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} + />, + ) expect(screen.getByRole('img', { name: /workflowProcessSucceeded/ })).toBeInTheDocument() }) it('should show error warning when status is Failed', () => { - render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} />) + render( + <WorkflowProcessItem + data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} + />, + ) expect(screen.getByRole('img', { name: /workflowProcessFailed/ })).toBeInTheDocument() }) it('should show error warning when status is Stopped', () => { - render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Stopped } as WorkflowProcess} />) + render( + <WorkflowProcessItem + data={{ ...mockData, status: WorkflowRunningStatus.Stopped } as WorkflowProcess} + />, + ) expect(screen.getByRole('img', { name: /workflowProcessFailed/ })).toBeInTheDocument() }) it('should show pause circle when status is Paused', () => { - render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} />) + render( + <WorkflowProcessItem + data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} + />, + ) expect(screen.getByRole('img', { name: /workflowProcessPaused/ })).toBeInTheDocument() }) }) describe('Background Colors', () => { it('should apply correct background when collapsed for different statuses', () => { - const { rerender } = render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} />) + const { rerender } = render( + <WorkflowProcessItem + data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} + />, + ) expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-bg') - rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} />) - expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-paused-bg') - - rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} />) - expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-[var(--color-workflow-process-failed-bg)]') + rerender( + <WorkflowProcessItem + data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} + />, + ) + expect(screen.getByTestId('workflow-process-item')).toHaveClass( + 'bg-workflow-process-paused-bg', + ) + + rerender( + <WorkflowProcessItem + data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} + />, + ) + expect(screen.getByTestId('workflow-process-item')).toHaveClass( + 'bg-[var(--color-workflow-process-failed-bg)]', + ) }) it('should apply correct background when expanded for different statuses', () => { - const { rerender } = render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Running } as WorkflowProcess} expand={true} />) + const { rerender } = render( + <WorkflowProcessItem + data={{ ...mockData, status: WorkflowRunningStatus.Running } as WorkflowProcess} + expand={true} + />, + ) expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-background-section-burn') - rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} expand={true} />) + rerender( + <WorkflowProcessItem + data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} + expand={true} + />, + ) expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-success-hover') - rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} expand={true} />) + rerender( + <WorkflowProcessItem + data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} + expand={true} + />, + ) expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-destructive-hover') - rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} expand={true} />) + rerender( + <WorkflowProcessItem + data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} + expand={true} + />, + ) expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-warning-hover') }) }) diff --git a/web/app/components/base/chat/chat/answer/agent-content.tsx b/web/app/components/base/chat/chat/answer/agent-content.tsx index 579c1836e9d791..fe9568319c457f 100644 --- a/web/app/components/base/chat/chat/answer/agent-content.tsx +++ b/web/app/components/base/chat/chat/answer/agent-content.tsx @@ -1,7 +1,5 @@ import type { FC } from 'react' -import type { - ChatItem, -} from '../../types' +import type { ChatItem } from '../../types' import { memo } from 'react' import Thought from '@/app/components/base/chat/chat/thought' import { FileList } from '@/app/components/base/file-uploader' @@ -13,15 +11,8 @@ type AgentContentProps = { responding?: boolean content?: string } -const AgentContent: FC<AgentContentProps> = ({ - item, - responding, - content, -}) => { - const { - annotation, - agent_thoughts, - } = item +const AgentContent: FC<AgentContentProps> = ({ item, responding, content }) => { + const { annotation, agent_thoughts } = item if (annotation?.logAnnotation) { return ( @@ -35,39 +26,32 @@ const AgentContent: FC<AgentContentProps> = ({ return ( <div data-testid="agent-content-container"> {content ? ( - <Markdown - content={content} - data-testid="agent-content-markdown" - /> - ) : agent_thoughts?.map((thought, index) => ( - <div key={index} className="px-2 py-1" data-testid="agent-thought-item"> - {thought.thought && ( - <Markdown - content={thought.thought} - data-testid="agent-thought-markdown" - /> - )} - {/* {item.tool} */} - {/* perhaps not use tool */} - {!!thought.tool && ( - <Thought - thought={thought} - isFinished={!!thought.observation || !responding} - /> - )} + <Markdown content={content} data-testid="agent-content-markdown" /> + ) : ( + agent_thoughts?.map((thought, index) => ( + <div key={index} className="px-2 py-1" data-testid="agent-thought-item"> + {thought.thought && ( + <Markdown content={thought.thought} data-testid="agent-thought-markdown" /> + )} + {/* {item.tool} */} + {/* perhaps not use tool */} + {!!thought.tool && ( + <Thought thought={thought} isFinished={!!thought.observation || !responding} /> + )} - { - !!thought.message_files?.length && ( + {!!thought.message_files?.length && ( <FileList - files={getProcessedFilesFromResponse(thought.message_files.map((item: any) => ({ ...item, related_id: item.id })))} + files={getProcessedFilesFromResponse( + thought.message_files.map((item: any) => ({ ...item, related_id: item.id })), + )} showDeleteAction={false} showDownloadAction={true} canPreview={true} /> - ) - } - </div> - ))} + )} + </div> + )) + )} </div> ) } diff --git a/web/app/components/base/chat/chat/answer/agent-roster-response-content.tsx b/web/app/components/base/chat/chat/answer/agent-roster-response-content.tsx index 505e67913ae14d..9a08f5aa05003c 100644 --- a/web/app/components/base/chat/chat/answer/agent-roster-response-content.tsx +++ b/web/app/components/base/chat/chat/answer/agent-roster-response-content.tsx @@ -22,14 +22,12 @@ type ToolProcess = { } function readIndexedValue(value: string, isArray: boolean, index: number) { - if (!isArray) - return value + if (!isArray) return value try { const parsed = JSON.parse(value) as unknown return Array.isArray(parsed) ? String(parsed[index] ?? '') : value - } - catch { + } catch { return value } } @@ -41,68 +39,69 @@ function getToolProcesses(thought: ThoughtItem, responding?: boolean): ToolProce try { const parsed = JSON.parse(thought.tool) as unknown if (Array.isArray(parsed)) { - toolNames = parsed.map(item => String(item)) + toolNames = parsed.map((item) => String(item)) isArray = true } - } - catch { - } - - return toolNames - .filter(Boolean) - .map((name, index) => ({ - name, - label: thought.tool_labels?.toolName?.en_US ?? name, - input: readIndexedValue(thought.tool_input, isArray, index), - output: readIndexedValue(thought.observation, isArray, index), - isFinished: !!thought.observation || !responding, - })) + } catch {} + + return toolNames.filter(Boolean).map((name, index) => ({ + name, + label: thought.tool_labels?.toolName?.en_US ?? name, + input: readIndexedValue(thought.tool_input, isArray, index), + output: readIndexedValue(thought.observation, isArray, index), + isFinished: !!thought.observation || !responding, + })) } function formatDuration(seconds: number, t: ReturnType<typeof useTranslation<'agentV2'>>['t']) { const safeSeconds = Math.max(0, seconds) if (safeSeconds < 60) - return t($ => $['agentDetail.configure.answer.duration.second'], { count: Number(safeSeconds.toFixed(2)) }) + return t(($) => $['agentDetail.configure.answer.duration.second'], { + count: Number(safeSeconds.toFixed(2)), + }) const minutes = Math.floor(safeSeconds / 60) const remainingSeconds = Math.floor(safeSeconds % 60) return [ - t($ => $['agentDetail.configure.answer.duration.minute'], { count: minutes }), - t($ => $['agentDetail.configure.answer.duration.second'], { count: remainingSeconds }), + t(($) => $['agentDetail.configure.answer.duration.minute'], { count: minutes }), + t(($) => $['agentDetail.configure.answer.duration.second'], { count: remainingSeconds }), ].join(' ') } -function formatLatencyDuration(latency: NonNullable<ChatItem['more']>['latency'], t: ReturnType<typeof useTranslation<'agentV2'>>['t']) { +function formatLatencyDuration( + latency: NonNullable<ChatItem['more']>['latency'], + t: ReturnType<typeof useTranslation<'agentV2'>>['t'], +) { const numericLatency = Number(latency) - if (!Number.isNaN(numericLatency)) - return formatDuration(numericLatency, t) + if (!Number.isNaN(numericLatency)) return formatDuration(numericLatency, t) return String(latency) } -function getCompletedTitle(latency: NonNullable<ChatItem['more']>['latency'] | undefined, t: ReturnType<typeof useTranslation<'agentV2'>>['t']) { +function getCompletedTitle( + latency: NonNullable<ChatItem['more']>['latency'] | undefined, + t: ReturnType<typeof useTranslation<'agentV2'>>['t'], +) { const numericLatency = Number(latency) if (latency != null && !Number.isNaN(numericLatency) && numericLatency > 0) { - return t($ => $['agentDetail.configure.answer.workedFor'], { + return t(($) => $['agentDetail.configure.answer.workedFor'], { duration: formatLatencyDuration(latency, t), }) } - return t($ => $['agentDetail.configure.answer.workFinished']) + return t(($) => $['agentDetail.configure.answer.workFinished']) } function hashString(value: string) { let hash = 5381 - for (let i = 0; i < value.length; i++) - hash = ((hash << 5) + hash) ^ value.charCodeAt(i) + for (let i = 0; i < value.length; i++) hash = ((hash << 5) + hash) ^ value.charCodeAt(i) return (hash >>> 0).toString(36) } function getAgentResponsePartBaseKey(part: NonNullable<ChatItem['agent_response_parts']>[number]) { - if (part.type === 'message') - return `message-${part.content.length}-${hashString(part.content)}` + if (part.type === 'message') return `message-${part.content.length}-${hashString(part.content)}` return `thought-${part.thought.id || `${part.thought.message_id}-${part.thought.position}`}` } @@ -112,8 +111,7 @@ function useWorkingDuration(enabled?: boolean) { const [now, setNow] = useState(0) useEffect(() => { - if (!enabled) - return + if (!enabled) return startedAtRef.current = Date.now() const timer = window.setInterval(() => { @@ -123,9 +121,10 @@ function useWorkingDuration(enabled?: boolean) { return () => window.clearInterval(timer) }, [enabled]) - const elapsedSeconds = enabled && startedAtRef.current !== null - ? Math.max(0, Math.floor((now - startedAtRef.current) / 1000)) - : 0 + const elapsedSeconds = + enabled && startedAtRef.current !== null + ? Math.max(0, Math.floor((now - startedAtRef.current) / 1000)) + : 0 return elapsedSeconds } @@ -150,53 +149,54 @@ function ProcessShell({ const expanded = canExpand && open && !collapsed return ( - <div className={cn( - 'rounded-xl p-2', - expanded ? 'bg-background-default' : 'bg-background-default-subtle', - )} + <div + className={cn( + 'rounded-xl p-2', + expanded ? 'bg-background-default' : 'bg-background-default-subtle', + )} > <button type="button" className="flex w-full items-center gap-1 text-left" - onClick={() => canExpand && setOpen(value => !value)} + onClick={() => canExpand && setOpen((value) => !value)} > <span className="flex size-5 shrink-0 items-center justify-center text-text-secondary"> {icon} </span> - <span className={cn( - 'min-w-0 flex-1 truncate text-text-secondary', - expanded ? 'system-xs-medium-uppercase' : 'system-sm-regular', - )} + <span + className={cn( + 'min-w-0 flex-1 truncate text-text-secondary', + expanded ? 'system-xs-medium-uppercase' : 'system-sm-regular', + )} > {expanded ? (expandedTitle ?? title) : title} </span> - {canExpand && ( - expanded - ? <span className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary" aria-hidden /> - : <span className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-quaternary" aria-hidden /> - )} + {canExpand && + (expanded ? ( + <span + className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary" + aria-hidden + /> + ) : ( + <span + className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-quaternary" + aria-hidden + /> + ))} </button> {expanded && ( <div className="mt-1 flex gap-1"> <div className="w-5 shrink-0 border-l border-divider-subtle" /> - <div className="min-w-0 flex-1 py-1 body-sm-regular text-text-tertiary"> - {children} - </div> + <div className="min-w-0 flex-1 py-1 body-sm-regular text-text-tertiary">{children}</div> </div> )} </div> ) } -function ThoughtProcess({ - thought, - defaultOpen, -}: { - thought: ThoughtItem - defaultOpen?: boolean -}) { +function ThoughtProcess({ thought, defaultOpen }: { thought: ThoughtItem; defaultOpen?: boolean }) { const { t } = useTranslation() - const thoughtTitle = t($ => $['chat.thought'], { ns: 'common' }) + const thoughtTitle = t(($) => $['chat.thought'], { ns: 'common' }) const summary = thought.thought.trim() || thoughtTitle return ( @@ -211,11 +211,7 @@ function ThoughtProcess({ ) } -function ToolProcessCard({ - tool, -}: { - tool: ToolProcess -}) { +function ToolProcessCard({ tool }: { tool: ToolProcess }) { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -224,31 +220,47 @@ function ToolProcessCard({ <button type="button" className="flex w-full items-center gap-1 text-left" - onClick={() => setOpen(value => !value)} + onClick={() => setOpen((value) => !value)} > <span className="flex size-5 shrink-0 items-center justify-center rounded-md border border-divider-subtle bg-components-icon-bg-midnight-solid p-[3px] text-text-primary-on-surface shadow-xs"> - {tool.isFinished - ? <span className="i-ri-terminal-box-line size-3.5" aria-hidden /> - : <span className="i-ri-loader-2-line size-3.5 animate-spin" aria-hidden />} + {tool.isFinished ? ( + <span className="i-ri-terminal-box-line size-3.5" aria-hidden /> + ) : ( + <span className="i-ri-loader-2-line size-3.5 animate-spin" aria-hidden /> + )} </span> - <span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">{tool.label}</span> - {open - ? <span className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary" aria-hidden /> - : <span className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-quaternary" aria-hidden />} + <span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary"> + {tool.label} + </span> + {open ? ( + <span + className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary" + aria-hidden + /> + ) : ( + <span + className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-quaternary" + aria-hidden + /> + )} </button> {open && ( <div className="mt-2 space-y-1"> <div className="rounded-[10px] bg-components-panel-on-panel-item-bg px-3 py-2"> <div className="system-xs-semibold-uppercase text-text-tertiary"> - {t($ => $['thought.requestTitle'], { ns: 'tools' })} + {t(($) => $['thought.requestTitle'], { ns: 'tools' })} + </div> + <div className="mt-1 code-xs-regular wrap-break-word text-text-secondary"> + {tool.input} </div> - <div className="mt-1 code-xs-regular wrap-break-word text-text-secondary">{tool.input}</div> </div> <div className="rounded-[10px] bg-components-panel-on-panel-item-bg px-3 py-2"> <div className="system-xs-semibold-uppercase text-text-tertiary"> - {t($ => $['thought.responseTitle'], { ns: 'tools' })} + {t(($) => $['thought.responseTitle'], { ns: 'tools' })} + </div> + <div className="mt-1 code-xs-regular wrap-break-word text-text-secondary"> + {tool.output} </div> - <div className="mt-1 code-xs-regular wrap-break-word text-text-secondary">{tool.output}</div> </div> </div> )} @@ -256,13 +268,7 @@ function ToolProcessCard({ ) } -function AgentThoughtsProcessList({ - item, - responding, -}: { - item: ChatItem - responding?: boolean -}) { +function AgentThoughtsProcessList({ item, responding }: { item: ChatItem; responding?: boolean }) { return ( <div className="mt-2 flex flex-col gap-1"> {item.agent_thoughts?.map((thought, index) => ( @@ -292,21 +298,16 @@ function AgentThoughtProcessItem({ return ( <div className="flex flex-col gap-1"> {answer && ( - <div className="px-2 py-2 body-md-regular text-text-primary" data-testid="agent-content-markdown"> + <div + className="px-2 py-2 body-md-regular text-text-primary" + data-testid="agent-content-markdown" + > <Markdown content={thought.answer || ''} /> </div> )} - {!answer && thought.thought && ( - <ThoughtProcess - thought={thought} - defaultOpen={defaultOpen} - /> - )} - {tools.map(tool => ( - <ToolProcessCard - key={`${thought.id}-${tool.name}`} - tool={tool} - /> + {!answer && thought.thought && <ThoughtProcess thought={thought} defaultOpen={defaultOpen} />} + {tools.map((tool) => ( + <ToolProcessCard key={`${thought.id}-${tool.name}`} tool={tool} /> ))} {!!thought.message_files?.length && ( <FileList @@ -321,13 +322,7 @@ function AgentThoughtProcessItem({ ) } -function AgentResponsePartList({ - item, - responding, -}: { - item: ChatItem - responding?: boolean -}) { +function AgentResponsePartList({ item, responding }: { item: ChatItem; responding?: boolean }) { const keyOccurrences = new Map<string, number>() return ( @@ -339,11 +334,14 @@ function AgentResponsePartList({ const partKey = occurrence ? `${baseKey}-${occurrence}` : baseKey if (part.type === 'message') { - if (!part.content) - return null + if (!part.content) return null return ( - <div key={partKey} className="px-2 py-2 body-md-regular text-text-primary" data-testid="agent-content-markdown"> + <div + key={partKey} + className="px-2 py-2 body-md-regular text-text-primary" + data-testid="agent-content-markdown" + > <Markdown content={part.content} /> </div> ) @@ -362,13 +360,7 @@ function AgentResponsePartList({ ) } -function AgentThoughtsProcessGroup({ - item, - responding, -}: { - item: ChatItem - responding?: boolean -}) { +function AgentThoughtsProcessGroup({ item, responding }: { item: ChatItem; responding?: boolean }) { const { t } = useTranslation('agentV2') const [open, setOpen] = useState(false) const workingDuration = formatDuration(useWorkingDuration(responding), t) @@ -379,13 +371,10 @@ function AgentThoughtsProcessGroup({ <div className="flex flex-col"> <div className="flex h-9 w-full items-center border-b border-divider-subtle text-left"> <span className="system-md-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.answer.workingFor'], { duration: workingDuration })} + {t(($) => $['agentDetail.configure.answer.workingFor'], { duration: workingDuration })} </span> </div> - <AgentThoughtsProcessList - item={item} - responding={responding} - /> + <AgentThoughtsProcessList item={item} responding={responding} /> </div> ) } @@ -396,21 +385,16 @@ function AgentThoughtsProcessGroup({ type="button" className="flex h-9 w-full items-center gap-1 border-b border-divider-subtle text-left" aria-expanded={open} - onClick={() => setOpen(value => !value)} + onClick={() => setOpen((value) => !value)} > - <span className="system-md-regular text-text-tertiary"> - {completedTitle} - </span> - {open - ? <span className="i-ri-arrow-down-s-line size-4 text-text-tertiary" aria-hidden /> - : <span className="i-ri-arrow-right-s-line size-4 text-text-tertiary" aria-hidden />} + <span className="system-md-regular text-text-tertiary">{completedTitle}</span> + {open ? ( + <span className="i-ri-arrow-down-s-line size-4 text-text-tertiary" aria-hidden /> + ) : ( + <span className="i-ri-arrow-right-s-line size-4 text-text-tertiary" aria-hidden /> + )} </button> - {open && ( - <AgentThoughtsProcessList - item={item} - responding={responding} - /> - )} + {open && <AgentThoughtsProcessList item={item} responding={responding} />} </div> ) } @@ -420,10 +404,7 @@ export function AgentRosterResponseContent({ responding, content, }: AgentRosterResponseContentProps) { - const { - annotation, - agent_thoughts, - } = item + const { annotation, agent_thoughts } = item if (annotation?.logAnnotation) { return ( @@ -437,21 +418,18 @@ export function AgentRosterResponseContent({ return ( <div className="flex w-full flex-col gap-1" data-testid="agent-roster-response-content"> {!!item.agent_response_parts?.length && ( - <AgentResponsePartList - item={item} - responding={responding} - /> + <AgentResponsePartList item={item} responding={responding} /> )} {!item.agent_response_parts?.length && ( <> {!!agent_thoughts?.length && ( - <AgentThoughtsProcessGroup - item={item} - responding={responding} - /> + <AgentThoughtsProcessGroup item={item} responding={responding} /> )} {content && ( - <div className="px-2 py-2 body-md-regular text-text-primary" data-testid="agent-content-markdown"> + <div + className="px-2 py-2 body-md-regular text-text-primary" + data-testid="agent-content-markdown" + > <Markdown content={content} /> </div> )} diff --git a/web/app/components/base/chat/chat/answer/basic-content.tsx b/web/app/components/base/chat/chat/answer/basic-content.tsx index 962966c8d5a591..2a2b6d0943cc85 100644 --- a/web/app/components/base/chat/chat/answer/basic-content.tsx +++ b/web/app/components/base/chat/chat/answer/basic-content.tsx @@ -7,13 +7,8 @@ import { Markdown } from '@/app/components/base/markdown' type BasicContentProps = { item: ChatItem } -const BasicContent: FC<BasicContentProps> = ({ - item, -}) => { - const { - annotation, - content, - } = item +const BasicContent: FC<BasicContentProps> = ({ item }) => { + const { annotation, content } = item if (annotation?.logAnnotation) { return ( @@ -33,9 +28,7 @@ const BasicContent: FC<BasicContentProps> = ({ return ( <Markdown - className={cn( - item.isError && 'text-[#F04438]!', - )} + className={cn(item.isError && 'text-[#F04438]!')} content={displayContent} data-testid="basic-content-markdown" /> diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/content-item.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/content-item.spec.tsx index db5da11777946f..51e5e7d8143cd0 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/content-item.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/content-item.spec.tsx @@ -12,13 +12,7 @@ vi.mock('@/app/components/base/markdown', () => ({ vi.mock('../field-renderer', () => ({ __esModule: true, - default: ({ - field, - onChange, - }: { - field: FormInputItem - onChange: (value: unknown) => void - }) => ( + default: ({ field, onChange }: { field: FormInputItem; onChange: (value: unknown) => void }) => ( <button type="button" data-testid={`renderer-${field.type}`} @@ -160,15 +154,17 @@ describe('ContentItem', () => { it('should delegate file-list fields to the shared renderer', async () => { const user = userEvent.setup() - const existingFiles: FileEntity[] = [{ - id: 'file-1', - name: 'brief.pdf', - size: 128, - type: 'document', - progress: 100, - transferMethod: TransferMethod.local_file, - supportFileType: 'document', - }] + const existingFiles: FileEntity[] = [ + { + id: 'file-1', + name: 'brief.pdf', + size: 128, + type: 'document', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + }, + ] render( <ContentItem diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/executed-action.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/executed-action.spec.tsx index fd94db486979f5..7c2e80000044a4 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/executed-action.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/executed-action.spec.tsx @@ -15,9 +15,14 @@ describe('ExecutedAction', () => { // Trans component mock from i18n-mock.ts renders a span with data-i18n-key const trans = screen.getByTestId('executed-action').querySelector('span') - expect(trans).toHaveAttribute('data-i18n-key', 'workflow.nodes.humanInput.userActions.triggered') + expect(trans).toHaveAttribute( + 'data-i18n-key', + 'workflow.nodes.humanInput.userActions.triggered', + ) // Check for the trigger icon class - expect(screen.getByTestId('executed-action').querySelector('.i-custom-vender-workflow-trigger-all')).toBeInTheDocument() + expect( + screen.getByTestId('executed-action').querySelector('.i-custom-vender-workflow-trigger-all'), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/expiration-time.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/expiration-time.spec.tsx index b2ed03e71c074b..7ddc828eb3e9ee 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/expiration-time.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/expiration-time.spec.tsx @@ -21,7 +21,9 @@ describe('ExpirationTime', () => { const { container } = render(<ExpirationTime expirationTime={1234567890} />) expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-tertiary') - expect(screen.getByText('share.humanInput.expirationTimeNowOrFuture:{"relativeTime":"in 2 hours"}')).toBeInTheDocument() + expect( + screen.getByText('share.humanInput.expirationTimeNowOrFuture:{"relativeTime":"in 2 hours"}'), + ).toBeInTheDocument() expect(container.querySelector('.i-ri-time-line')).toBeInTheDocument() }) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/field-renderer.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/field-renderer.spec.tsx index 9dc4fecf715b9e..1ea31ba7d107a0 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/field-renderer.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/field-renderer.spec.tsx @@ -33,14 +33,36 @@ vi.mock('@langgenius/dify-ui/textarea', () => ({ })) vi.mock('@langgenius/dify-ui/select', () => ({ - Select: ({ children, onValueChange }: { children: React.ReactNode, onValueChange: (value: string | null) => void }) => ( + Select: ({ + children, + onValueChange, + }: { + children: React.ReactNode + onValueChange: (value: string | null) => void + }) => ( <div> - <button type="button" data-testid="content-item-select-root" onClick={() => onValueChange('alice')}>select alice</button> - <button type="button" data-testid="content-item-select-null" onClick={() => onValueChange(null)}>select null</button> + <button + type="button" + data-testid="content-item-select-root" + onClick={() => onValueChange('alice')} + > + select alice + </button> + <button + type="button" + data-testid="content-item-select-null" + onClick={() => onValueChange(null)} + > + select null + </button> {children} </div> ), - SelectTrigger: ({ children }: { children: React.ReactNode }) => <button type="button" data-testid="content-item-select">{children}</button>, + SelectTrigger: ({ children }: { children: React.ReactNode }) => ( + <button type="button" data-testid="content-item-select"> + {children} + </button> + ), SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, SelectItemText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>, @@ -48,7 +70,11 @@ vi.mock('@langgenius/dify-ui/select', () => ({ })) vi.mock('@/app/components/base/file-uploader', () => ({ - FileUploaderInAttachmentWrapper: ({ value, onChange, fileConfig }: { + FileUploaderInAttachmentWrapper: ({ + value, + onChange, + fileConfig, + }: { value?: FileEntity[] onChange: (files: FileEntity[]) => void fileConfig: { number_limits?: number } @@ -57,9 +83,21 @@ vi.mock('@/app/components/base/file-uploader', () => ({ <button type="button" data-testid={`content-item-file-${fileConfig.number_limits ?? 0}`} - onClick={() => onChange([{ id: 'file-1', name: 'report.pdf', size: 1, type: 'document', progress: 100, transferMethod: TransferMethod.local_file, supportFileType: 'document' }])} + onClick={() => + onChange([ + { + id: 'file-1', + name: 'report.pdf', + size: 1, + type: 'document', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + }, + ]) + } > - {(value || []).map(file => file.name).join(',')} + {(value || []).map((file) => file.name).join(',')} </button> <button type="button" @@ -207,7 +245,15 @@ describe('HumanInputFieldRenderer', () => { allowed_file_types: [SupportUploadFileTypes.document], allowed_file_upload_methods: [TransferMethod.local_file], }} - value={{ id: 'file-2', name: 'existing.pdf', size: 1, type: 'document', progress: 100, transferMethod: TransferMethod.local_file, supportFileType: 'document' }} + value={{ + id: 'file-2', + name: 'existing.pdf', + size: 1, + type: 'document', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + }} onChange={onChange} />, ) @@ -264,10 +310,12 @@ describe('HumanInputFieldRenderer', () => { it('renders nothing for unsupported input types', () => { const { container } = render( <HumanInputFieldRenderer - field={{ - type: 'unsupported', - output_variable_name: 'unknown', - } as unknown as Parameters<typeof HumanInputFieldRenderer>[0]['field']} + field={ + { + type: 'unsupported', + output_variable_name: 'unknown', + } as unknown as Parameters<typeof HumanInputFieldRenderer>[0]['field'] + } value="" onChange={vi.fn()} />, diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx index 56953c3c9111ef..b25e8e3983838e 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx @@ -9,66 +9,88 @@ import { TransferMethod } from '@/types/app' import HumanInputForm from '../human-input-form' vi.mock('../content-item', () => ({ - default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => ( + default: ({ + content, + onInputChange, + }: { + content: string + onInputChange: (name: string, value: unknown) => void + }) => ( <div data-testid="mock-content-item"> {content} - <button data-testid="update-input" onClick={() => onInputChange('field1', 'new value')}>Update</button> - <button data-testid="update-select" onClick={() => onInputChange('field2', 'approved')}>Update Select</button> + <button data-testid="update-input" onClick={() => onInputChange('field1', 'new value')}> + Update + </button> + <button data-testid="update-select" onClick={() => onInputChange('field2', 'approved')}> + Update Select + </button> <button data-testid="update-single-file" - onClick={() => onInputChange('field4', { - id: 'file-2', - name: 'main.png', - size: 256, - type: 'image/png', - progress: 100, - transferMethod: TransferMethod.local_file, - supportFileType: 'image', - uploadedId: 'upload-file-2', - })} + onClick={() => + onInputChange('field4', { + id: 'file-2', + name: 'main.png', + size: 256, + type: 'image/png', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'image', + uploadedId: 'upload-file-2', + }) + } > Update Single File </button> <button data-testid="update-pending-single-file" - onClick={() => onInputChange('field4', { - id: 'file-2', - name: 'main.png', - size: 256, - type: 'image/png', - progress: 50, - transferMethod: TransferMethod.local_file, - supportFileType: 'image', - })} + onClick={() => + onInputChange('field4', { + id: 'file-2', + name: 'main.png', + size: 256, + type: 'image/png', + progress: 50, + transferMethod: TransferMethod.local_file, + supportFileType: 'image', + }) + } > Update Pending Single File </button> <button data-testid="update-input-file" - onClick={() => onInputChange('field3', [{ - id: 'file-1', - name: 'avatar.png', - size: 128, - type: 'image/png', - progress: 100, - transferMethod: TransferMethod.local_file, - supportFileType: 'image', - uploadedId: 'upload-file-1', - }])} + onClick={() => + onInputChange('field3', [ + { + id: 'file-1', + name: 'avatar.png', + size: 128, + type: 'image/png', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'image', + uploadedId: 'upload-file-1', + }, + ]) + } > Update File </button> <button data-testid="update-pending-input-file" - onClick={() => onInputChange('field3', [{ - id: 'file-1', - name: 'avatar.png', - size: 128, - type: 'image/png', - progress: 50, - transferMethod: TransferMethod.local_file, - supportFileType: 'image', - }])} + onClick={() => + onInputChange('field3', [ + { + id: 'file-1', + name: 'avatar.png', + size: 128, + type: 'image/png', + progress: 50, + transferMethod: TransferMethod.local_file, + supportFileType: 'image', + }, + ]) + } > Update Pending File </button> @@ -168,12 +190,14 @@ describe('HumanInputForm', () => { action: 'action_1', inputs: { field1: 'new value', - field3: [{ - type: 'image', - transfer_method: TransferMethod.local_file, - url: '', - upload_file_id: 'upload-file-1', - }], + field3: [ + { + type: 'image', + transfer_method: TransferMethod.local_file, + url: '', + upload_file_id: 'upload-file-1', + }, + ], }, }) }) @@ -212,7 +236,9 @@ describe('HumanInputForm', () => { ] as FormInputItem[], } - render(<HumanInputForm formData={formDataWithRequiredInteractiveFields} onSubmit={mockOnSubmit} />) + render( + <HumanInputForm formData={formDataWithRequiredInteractiveFields} onSubmit={mockOnSubmit} />, + ) const submitButton = screen.getByRole('button', { name: 'Submit' }) expect(submitButton).toBeDisabled() @@ -235,12 +261,14 @@ describe('HumanInputForm', () => { action: 'action_1', inputs: { field2: 'approved', - field3: [{ - type: 'image', - transfer_method: TransferMethod.local_file, - url: '', - upload_file_id: 'upload-file-1', - }], + field3: [ + { + type: 'image', + transfer_method: TransferMethod.local_file, + url: '', + upload_file_id: 'upload-file-1', + }, + ], field4: { type: 'image', transfer_method: TransferMethod.local_file, diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted-content-item.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted-content-item.spec.tsx index 14ccf498ea2130..fbc01f281ef776 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted-content-item.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted-content-item.spec.tsx @@ -84,7 +84,10 @@ describe('SubmittedContentItem', () => { ) expect(screen.getByTestId('submitted-field-evidence')).toBeInTheDocument() - expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute('src', 'https://example.com/evidence.png') + expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute( + 'src', + 'https://example.com/evidence.png', + ) expect(screen.getByText('evidence.pdf')).toBeInTheDocument() }) @@ -168,10 +171,12 @@ describe('SubmittedContentItem', () => { const { container } = render( <SubmittedContentItem content="{{#$output.unknown#}}" - formInputFields={[{ - type: 'unsupported', - output_variable_name: 'unknown', - } as unknown as FormInputItem]} + formInputFields={[ + { + type: 'unsupported', + output_variable_name: 'unknown', + } as unknown as FormInputItem, + ]} values={{ unknown: 'value' }} />, ) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted-field-values.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted-field-values.spec.tsx index bd131cb7be354f..f9156216854e8b 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted-field-values.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted-field-values.spec.tsx @@ -93,7 +93,10 @@ describe('SubmittedFieldValues', () => { render(<SubmittedFieldValues fields={fields} values={values} />) expect(screen.getByTestId('submitted-field-attachment')).toHaveTextContent('decision.pdf') - expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute('src', 'https://example.com/evidence-1.png') + expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute( + 'src', + 'https://example.com/evidence-1.png', + ) expect(screen.getByText('evidence-2.pdf')).toBeInTheDocument() expect(screen.getAllByTestId('file-list')).toHaveLength(2) }) @@ -119,7 +122,10 @@ describe('SubmittedFieldValues', () => { expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Unstructured summary') expect(screen.getByTestId('submitted-field-attachment')).toHaveTextContent('decision.pdf') - expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute('src', 'https://example.com/evidence-1.png') + expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute( + 'src', + 'https://example.com/evidence-1.png', + ) expect(screen.getByText('evidence-2.pdf')).toBeInTheDocument() }) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted.spec.tsx index 27cd7e8df598e2..da225577430382 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted.spec.tsx @@ -33,18 +33,21 @@ describe('SubmittedHumanInputContent Integration', () => { it('should prefer structured form data over rendered markdown when available', () => { render( - <SubmittedHumanInputContent formData={{ - ...mockFormData, - form_content: 'Decision: {{#$output.answer#}}', - inputs: [{ - type: InputVarType.paragraph, - output_variable_name: 'answer', - default: { type: 'constant', value: '', selector: [] }, - }], - submitted_data: { - answer: 'approved', - }, - }} + <SubmittedHumanInputContent + formData={{ + ...mockFormData, + form_content: 'Decision: {{#$output.answer#}}', + inputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'answer', + default: { type: 'constant', value: '', selector: [] }, + }, + ], + submitted_data: { + answer: 'approved', + }, + }} />, ) @@ -55,39 +58,40 @@ describe('SubmittedHumanInputContent Integration', () => { it('should render submitted select and file fields with the original form layout', () => { render( - <SubmittedHumanInputContent formData={{ - ...mockFormData, - form_content: '{{#$output.decision#}} {{#$output.attachment#}}', - inputs: [ - { - type: InputVarType.select, - output_variable_name: 'decision', - option_source: { type: 'constant', value: ['approve', 'reject'], selector: [] }, - }, - { - type: InputVarType.singleFile, - output_variable_name: 'attachment', - allowed_file_extensions: [], - allowed_file_types: [], - allowed_file_upload_methods: [], + <SubmittedHumanInputContent + formData={{ + ...mockFormData, + form_content: '{{#$output.decision#}} {{#$output.attachment#}}', + inputs: [ + { + type: InputVarType.select, + output_variable_name: 'decision', + option_source: { type: 'constant', value: ['approve', 'reject'], selector: [] }, + }, + { + type: InputVarType.singleFile, + output_variable_name: 'attachment', + allowed_file_extensions: [], + allowed_file_types: [], + allowed_file_upload_methods: [], + }, + ], + submitted_data: { + decision: 'approve', + attachment: { + related_id: 'file-1', + upload_file_id: 'upload-1', + filename: 'decision.pdf', + extension: 'pdf', + size: 128, + mime_type: 'application/pdf', + transfer_method: TransferMethod.local_file, + type: 'document', + url: 'https://example.com/decision.pdf', + remote_url: '', + }, }, - ], - submitted_data: { - decision: 'approve', - attachment: { - related_id: 'file-1', - upload_file_id: 'upload-1', - filename: 'decision.pdf', - extension: 'pdf', - size: 128, - mime_type: 'application/pdf', - transfer_method: TransferMethod.local_file, - type: 'document', - url: 'https://example.com/decision.pdf', - remote_url: '', - }, - }, - }} + }} />, ) @@ -98,10 +102,11 @@ describe('SubmittedHumanInputContent Integration', () => { it('should fallback to rendered markdown when structured form data is empty', () => { render( - <SubmittedHumanInputContent formData={{ - ...mockFormData, - submitted_data: {}, - }} + <SubmittedHumanInputContent + formData={{ + ...mockFormData, + submitted_data: {}, + }} />, ) @@ -112,12 +117,13 @@ describe('SubmittedHumanInputContent Integration', () => { it('should render submitted field values when original form layout is unavailable', () => { render( - <SubmittedHumanInputContent formData={{ - ...mockFormData, - submitted_data: { - answer: 'approved', - }, - }} + <SubmittedHumanInputContent + formData={{ + ...mockFormData, + submitted_data: { + answer: 'approved', + }, + }} />, ) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/tips.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/tips.spec.tsx index fbcfb1e90c1d01..74a4a4e3dd2ded 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/tips.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/tips.spec.tsx @@ -35,7 +35,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -47,13 +48,7 @@ describe('Tips', () => { }) it('should render email tip in normal mode', () => { - render( - <Tips - showEmailTip={true} - isEmailDebugMode={false} - showDebugModeTip={false} - />, - ) + render(<Tips showEmailTip={true} isEmailDebugMode={false} showDebugModeTip={false} />) expect(screen.getByText('workflow.common.humanInputEmailTip')).toBeInTheDocument() expect(screen.queryByText('common.humanInputEmailTipInDebugMode')).not.toBeInTheDocument() @@ -61,26 +56,14 @@ describe('Tips', () => { }) it('should render email tip in debug mode', () => { - render( - <Tips - showEmailTip={true} - isEmailDebugMode={true} - showDebugModeTip={false} - />, - ) + render(<Tips showEmailTip={true} isEmailDebugMode={true} showDebugModeTip={false} />) expect(screen.getByText('workflow.common.humanInputEmailTipInDebugMode')).toBeInTheDocument() expect(screen.queryByText('workflow.common.humanInputEmailTip')).not.toBeInTheDocument() }) it('should render debug mode tip', () => { - render( - <Tips - showEmailTip={false} - isEmailDebugMode={false} - showDebugModeTip={true} - />, - ) + render(<Tips showEmailTip={false} isEmailDebugMode={false} showDebugModeTip={true} />) expect(screen.getByText('workflow.common.humanInputWebappTip')).toBeInTheDocument() expect(screen.queryByText('workflow.common.humanInputEmailTip')).not.toBeInTheDocument() @@ -88,11 +71,7 @@ describe('Tips', () => { it('should render nothing when all flags are false', () => { const { container } = render( - <Tips - showEmailTip={false} - isEmailDebugMode={false} - showDebugModeTip={false} - />, + <Tips showEmailTip={false} isEmailDebugMode={false} showDebugModeTip={false} />, ) expect(screen.queryByTestId('tips')).toBeEmptyDOMElement() diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/unsubmitted.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/unsubmitted.spec.tsx index 7aafa43bfa4594..5fe1c4c56cefec 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/unsubmitted.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/unsubmitted.spec.tsx @@ -10,31 +10,30 @@ describe('UnsubmittedHumanInputContent Integration', () => { const user = userEvent.setup() // Helper to create valid form data - const createMockFormData = (overrides = {}): HumanInputFormData => ({ - form_id: 'form_123', - node_id: 'node_456', - node_title: 'Input Form', - form_content: 'Fill this out: {{#$output.user_name#}}', - inputs: [ - { - type: 'paragraph' as InputVarType, - output_variable_name: 'user_name', - default: { - type: 'constant', - value: 'Default value', - selector: [], + const createMockFormData = (overrides = {}): HumanInputFormData => + ({ + form_id: 'form_123', + node_id: 'node_456', + node_title: 'Input Form', + form_content: 'Fill this out: {{#$output.user_name#}}', + inputs: [ + { + type: 'paragraph' as InputVarType, + output_variable_name: 'user_name', + default: { + type: 'constant', + value: 'Default value', + selector: [], + }, }, - }, - ], - actions: [ - { id: 'btn_1', title: 'Submit', button_style: UserActionButtonType.Primary }, - ], - form_token: 'token_123', - resolved_default_values: {}, - expiration_time: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now - display_in_ui: true, - ...overrides, - } as unknown as HumanInputFormData) + ], + actions: [{ id: 'btn_1', title: 'Submit', button_style: UserActionButtonType.Primary }], + form_token: 'token_123', + resolved_default_values: {}, + expiration_time: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + display_in_ui: true, + ...overrides, + }) as unknown as HumanInputFormData beforeEach(() => { vi.clearAllMocks() @@ -131,9 +130,12 @@ describe('UnsubmittedHumanInputContent Integration', () => { it('should handle loading state during submission', async () => { let resolveSubmit: (value: void | PromiseLike<void>) => void - const handleSubmit = vi.fn().mockImplementation(() => new Promise<void>((resolve) => { - resolveSubmit = resolve - })) + const handleSubmit = vi.fn().mockImplementation( + () => + new Promise<void>((resolve) => { + resolveSubmit = resolve + }), + ) const data = createMockFormData() render(<UnsubmittedHumanInputContent formData={data} onSubmit={handleSubmit} />) @@ -164,7 +166,9 @@ describe('UnsubmittedHumanInputContent Integration', () => { form_content: '{{#$output.unknown_field#}}', inputs: [], }) - const { container } = render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />) + const { container } = render( + <UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />, + ) // The form will be empty (except for buttons) because unknown_field is not in inputs expect(container.querySelector('textarea')).not.toBeInTheDocument() }) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/utils.spec.ts b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/utils.spec.ts index 8539cf1f97d077..a570ace8083040 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/utils.spec.ts +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/utils.spec.ts @@ -16,7 +16,9 @@ import { splitByOutputVar, } from '../utils' -const paragraphInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.paragraph }>> = {}): FormInputItem => ({ +const paragraphInput = ( + overrides: Partial<Extract<FormInputItem, { type: InputVarType.paragraph }>> = {}, +): FormInputItem => ({ type: InputVarType.paragraph, output_variable_name: 'field', default: { @@ -27,7 +29,9 @@ const paragraphInput = (overrides: Partial<Extract<FormInputItem, { type: InputV ...overrides, }) -const selectInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.select }>> = {}): FormInputItem => ({ +const selectInput = ( + overrides: Partial<Extract<FormInputItem, { type: InputVarType.select }>> = {}, +): FormInputItem => ({ type: InputVarType.select, output_variable_name: 'field', option_source: { @@ -38,7 +42,9 @@ const selectInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarT ...overrides, }) -const fileInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.singleFile }>> = {}): FormInputItem => ({ +const fileInput = ( + overrides: Partial<Extract<FormInputItem, { type: InputVarType.singleFile }>> = {}, +): FormInputItem => ({ type: InputVarType.singleFile, output_variable_name: 'field', allowed_file_extensions: [], @@ -47,7 +53,9 @@ const fileInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarTyp ...overrides, }) -const fileListInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.multiFiles }>> = {}): FormInputItem => ({ +const fileListInput = ( + overrides: Partial<Extract<FormInputItem, { type: InputVarType.multiFiles }>> = {}, +): FormInputItem => ({ type: InputVarType.multiFiles, output_variable_name: 'field', allowed_file_extensions: [], @@ -109,13 +117,13 @@ describe('human-input utils', () => { paragraphInput({ output_variable_name: 'visible_paragraph' }), fileInput({ output_variable_name: 'stale_file' }), ] - const content = 'Select {{#$output.visible_select#}} and write {{#$output.visible_paragraph#}}' + const content = + 'Select {{#$output.visible_select#}} and write {{#$output.visible_paragraph#}}' expect(getFormContentInputNames(content)).toEqual(['visible_select', 'visible_paragraph']) - expect(getRenderedFormInputs(formInputs, content).map(input => input.output_variable_name)).toEqual([ - 'visible_select', - 'visible_paragraph', - ]) + expect( + getRenderedFormInputs(formInputs, content).map((input) => input.output_variable_name), + ).toEqual(['visible_select', 'visible_paragraph']) }) }) @@ -195,20 +203,22 @@ describe('human-input utils', () => { }), ] - expect(initializeInputs(formInputs, { - summary: { - related_id: 'file-1', - size: 128, - extension: '.pdf', - filename: 'brief.pdf', - mime_type: 'application/pdf', - transfer_method: TransferMethod.local_file, - type: 'document', - url: 'https://example.com/brief.pdf', - upload_file_id: 'upload-file-1', - remote_url: '', - }, - })).toEqual({ + expect( + initializeInputs(formInputs, { + summary: { + related_id: 'file-1', + size: 128, + extension: '.pdf', + filename: 'brief.pdf', + mime_type: 'application/pdf', + transfer_method: TransferMethod.local_file, + type: 'document', + url: 'https://example.com/brief.pdf', + upload_file_id: 'upload-file-1', + remote_url: '', + }, + }), + ).toEqual({ summary: '', }) }) @@ -226,33 +236,37 @@ describe('human-input utils', () => { }), ] - expect(initializeInputs(formInputs, { - role: 'maintainer', - avatar: { - related_id: 'file-2', - size: 64, - extension: '.png', - filename: 'avatar.png', - mime_type: 'image/png', - transfer_method: TransferMethod.local_file, - type: 'image', - url: 'https://example.com/avatar.png', - upload_file_id: 'upload-file-2', - remote_url: '', - }, - attachments: [{ - related_id: 'file-3', - size: 16, - extension: '.txt', - filename: 'notes.txt', - mime_type: 'text/plain', - transfer_method: TransferMethod.local_file, - type: 'document', - url: 'https://example.com/notes.txt', - upload_file_id: 'upload-file-3', - remote_url: '', - }], - })).toEqual({ + expect( + initializeInputs(formInputs, { + role: 'maintainer', + avatar: { + related_id: 'file-2', + size: 64, + extension: '.png', + filename: 'avatar.png', + mime_type: 'image/png', + transfer_method: TransferMethod.local_file, + type: 'image', + url: 'https://example.com/avatar.png', + upload_file_id: 'upload-file-2', + remote_url: '', + }, + attachments: [ + { + related_id: 'file-3', + size: 16, + extension: '.txt', + filename: 'notes.txt', + mime_type: 'text/plain', + transfer_method: TransferMethod.local_file, + type: 'document', + url: 'https://example.com/notes.txt', + upload_file_id: 'upload-file-3', + remote_url: '', + }, + ], + }), + ).toEqual({ role: '', avatar: null, attachments: [], @@ -260,12 +274,14 @@ describe('human-input utils', () => { }) it('should ignore unsupported input types', () => { - expect(initializeInputs([ - { - type: 'unsupported', - output_variable_name: 'unknown', - } as unknown as FormInputItem, - ])).toEqual({}) + expect( + initializeInputs([ + { + type: 'unsupported', + output_variable_name: 'unknown', + } as unknown as FormInputItem, + ]), + ).toEqual({}) }) }) @@ -285,43 +301,53 @@ describe('human-input utils', () => { }) it('should detect empty select values and unuploaded file values', () => { - expect(hasInvalidSelectOrFileInput([ - selectInput({ output_variable_name: 'decision' }), - ], { - decision: '', - })).toBe(true) - expect(hasInvalidRequiredHumanInput([ - paragraphInput({ output_variable_name: 'summary' }), - ], { - summary: ' ', - })).toBe(true) - expect(hasInvalidSelectOrFileInput([ - fileInput({ output_variable_name: 'attachment' }), - ], { - attachment: uploadingFile, - })).toBe(true) - expect(hasInvalidRequiredHumanInput([ - fileListInput({ output_variable_name: 'attachments' }), - ], { - attachments: [uploadedFile, uploadingFile], - })).toBe(true) + expect( + hasInvalidSelectOrFileInput([selectInput({ output_variable_name: 'decision' })], { + decision: '', + }), + ).toBe(true) + expect( + hasInvalidRequiredHumanInput([paragraphInput({ output_variable_name: 'summary' })], { + summary: ' ', + }), + ).toBe(true) + expect( + hasInvalidSelectOrFileInput([fileInput({ output_variable_name: 'attachment' })], { + attachment: uploadingFile, + }), + ).toBe(true) + expect( + hasInvalidRequiredHumanInput([fileListInput({ output_variable_name: 'attachments' })], { + attachments: [uploadedFile, uploadingFile], + }), + ).toBe(true) }) it('should accept uploaded single and multiple file values', () => { - expect(hasInvalidSelectOrFileInput([ - fileInput({ output_variable_name: 'attachment' }), - fileListInput({ output_variable_name: 'attachments' }), - ], { - attachment: [uploadedFile], - attachments: [uploadedFile], - })).toBe(false) - expect(hasInvalidRequiredHumanInput([ - fileInput({ output_variable_name: 'attachment' }), - fileListInput({ output_variable_name: 'attachments' }), - ], { - attachment: [uploadedFile], - attachments: [uploadedFile], - })).toBe(false) + expect( + hasInvalidSelectOrFileInput( + [ + fileInput({ output_variable_name: 'attachment' }), + fileListInput({ output_variable_name: 'attachments' }), + ], + { + attachment: [uploadedFile], + attachments: [uploadedFile], + }, + ), + ).toBe(false) + expect( + hasInvalidRequiredHumanInput( + [ + fileInput({ output_variable_name: 'attachment' }), + fileListInput({ output_variable_name: 'attachments' }), + ], + { + attachment: [uploadedFile], + attachments: [uploadedFile], + }, + ), + ).toBe(false) }) it('should treat unsupported input types as valid by default', () => { @@ -330,12 +356,16 @@ describe('human-input utils', () => { output_variable_name: 'unknown', } as unknown as FormInputItem - expect(hasInvalidSelectOrFileInput([unsupportedInput], { - unknown: 'value', - })).toBe(false) - expect(hasInvalidRequiredHumanInput([unsupportedInput], { - unknown: 'value', - })).toBe(false) + expect( + hasInvalidSelectOrFileInput([unsupportedInput], { + unknown: 'value', + }), + ).toBe(false) + expect( + hasInvalidRequiredHumanInput([unsupportedInput], { + unknown: 'value', + }), + ).toBe(false) }) }) @@ -345,19 +375,24 @@ describe('human-input utils', () => { }) it('should process file values and fallback invalid file values', () => { - expect(getProcessedHumanInputFormInputs([ - fileInput({ output_variable_name: 'attachment' }), - fileInput({ output_variable_name: 'attachmentFromArray' }), - fileInput({ output_variable_name: 'emptyAttachment' }), - fileListInput({ output_variable_name: 'attachments' }), - fileListInput({ output_variable_name: 'emptyAttachments' }), - ], { - attachment: uploadedFile, - attachmentFromArray: [uploadedFile], - emptyAttachment: '', - attachments: [uploadedFile], - emptyAttachments: null, - })).toEqual({ + expect( + getProcessedHumanInputFormInputs( + [ + fileInput({ output_variable_name: 'attachment' }), + fileInput({ output_variable_name: 'attachmentFromArray' }), + fileInput({ output_variable_name: 'emptyAttachment' }), + fileListInput({ output_variable_name: 'attachments' }), + fileListInput({ output_variable_name: 'emptyAttachments' }), + ], + { + attachment: uploadedFile, + attachmentFromArray: [uploadedFile], + emptyAttachment: '', + attachments: [uploadedFile], + emptyAttachments: null, + }, + ), + ).toEqual({ attachment: { type: 'image', transfer_method: TransferMethod.local_file, @@ -371,12 +406,14 @@ describe('human-input utils', () => { upload_file_id: 'upload-file-1', }, emptyAttachment: undefined, - attachments: [{ - type: 'image', - transfer_method: TransferMethod.local_file, - url: '', - upload_file_id: 'upload-file-1', - }], + attachments: [ + { + type: 'image', + transfer_method: TransferMethod.local_file, + url: '', + upload_file_id: 'upload-file-1', + }, + ], emptyAttachments: [], }) }) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx index a1c3e26111ecc8..9bb968cf2482d7 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx @@ -4,12 +4,7 @@ import { useMemo } from 'react' import { Markdown } from '@/app/components/base/markdown' import HumanInputFieldRenderer from './field-renderer' -const ContentItem = ({ - content, - formInputFields, - inputs, - onInputChange, -}: ContentItemProps) => { +const ContentItem = ({ content, formInputFields, inputs, onInputChange }: ContentItemProps) => { const isInputField = (field: string) => { const outputVarRegex = /\{\{#\$output\.[^#]+#\}\}/ return outputVarRegex.test(field) @@ -26,24 +21,21 @@ const ContentItem = ({ }, [content]) const formInputField = useMemo(() => { - return formInputFields.find(field => field.output_variable_name === fieldName) + return formInputFields.find((field) => field.output_variable_name === fieldName) }, [formInputFields, fieldName]) if (!isInputField(content)) { - return ( - <Markdown content={content} /> - ) + return <Markdown content={content} /> } - if (!formInputField) - return null + if (!formInputField) return null return ( <div className="py-3"> <HumanInputFieldRenderer field={formInputField} value={inputs[fieldName]} - onChange={value => onInputChange(fieldName, value)} + onChange={(value) => onInputChange(fieldName, value)} /> </div> ) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx index e5449ba84f2fce..73eac8fed19b81 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx @@ -26,7 +26,10 @@ const ContentWrapper = ({ return ( <div - className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)} + className={cn( + 'rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', + className, + )} data-testid="content-wrapper" > <div className="flex items-center gap-2 p-2"> @@ -45,15 +48,11 @@ const ContentWrapper = ({ onClick={handleToggleExpand} data-testid="expand-icon" > - { - isExpanded - ? ( - <div className="i-ri-arrow-down-s-line size-4" /> - ) - : ( - <div className="i-ri-arrow-right-s-line size-4" /> - ) - } + {isExpanded ? ( + <div className="i-ri-arrow-down-s-line size-4" /> + ) : ( + <div className="i-ri-arrow-right-s-line size-4" /> + )} </div> )} </div> diff --git a/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx index 11956ea4fc99d7..26097c9feb9797 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx @@ -7,16 +7,14 @@ type ExecutedActionProps = { executedAction: ExecutedActionType } -const ExecutedAction = ({ - executedAction, -}: ExecutedActionProps) => { +const ExecutedAction = ({ executedAction }: ExecutedActionProps) => { return ( <div className="flex flex-col gap-y-1 py-1" data-testid="executed-action"> <Divider className="mt-1 mb-2 w-[30px]" /> <div className="flex items-center gap-x-1 system-xs-regular text-text-tertiary"> <div className="i-custom-vender-workflow-trigger-all size-3.5 shrink-0" /> <Trans - i18nKey={$ => $['nodes.humanInput.userActions.triggered']} + i18nKey={($) => $['nodes.humanInput.userActions.triggered']} ns="workflow" components={{ strong: <span className="system-xs-medium text-text-secondary"></span> }} values={{ actionName: executedAction.id }} diff --git a/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx b/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx index 59a8af1bc64b1c..6748de4b2ef788 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx @@ -8,9 +8,7 @@ type ExpirationTimeProps = { expirationTime: number } -const ExpirationTime = ({ - expirationTime, -}: ExpirationTimeProps) => { +const ExpirationTime = ({ expirationTime }: ExpirationTimeProps) => { const { t } = useTranslation() const locale = useLocale() const relativeTime = getRelativeTime(expirationTime, locale) @@ -24,21 +22,19 @@ const ExpirationTime = ({ !isSameOrAfter && 'text-text-warning', )} > - { - isSameOrAfter - ? ( - <> - <div className="i-ri-time-line size-3.5" /> - <span>{t($ => $['humanInput.expirationTimeNowOrFuture'], { relativeTime, ns: 'share' })}</span> - </> - ) - : ( - <> - <div className="i-ri-alert-fill size-3.5" /> - <span>{t($ => $['humanInput.expiredTip'], { ns: 'share' })}</span> - </> - ) - } + {isSameOrAfter ? ( + <> + <div className="i-ri-time-line size-3.5" /> + <span> + {t(($) => $['humanInput.expirationTimeNowOrFuture'], { relativeTime, ns: 'share' })} + </span> + </> + ) : ( + <> + <div className="i-ri-alert-fill size-3.5" /> + <span>{t(($) => $['humanInput.expiredTip'], { ns: 'share' })}</span> + </> + )} </div> ) } diff --git a/web/app/components/base/chat/chat/answer/human-input-content/field-renderer.tsx b/web/app/components/base/chat/chat/answer/human-input-content/field-renderer.tsx index b76df433d5b674..275485421c5c08 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/field-renderer.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/field-renderer.tsx @@ -26,25 +26,21 @@ type Props = Readonly<{ onChange: (value: HumanInputFieldValue) => void }> -const HumanInputFieldRenderer = ({ - field, - value, - onChange, -}: Props) => { +const HumanInputFieldRenderer = ({ field, value, onChange }: Props) => { if (isParagraphFormInput(field)) { return ( <Textarea aria-label={field.output_variable_name} className="h-[104px] sm:text-xs" value={typeof value === 'string' ? value : ''} - onValueChange={nextValue => onChange(nextValue)} + onValueChange={(nextValue) => onChange(nextValue)} data-testid="content-item-textarea" /> ) } if (isSelectFormInput(field)) { - const options = field.option_source.value.map(option => ({ + const options = field.option_source.value.map((option) => ({ name: option, value: option, })) @@ -53,8 +49,7 @@ const HumanInputFieldRenderer = ({ <Select value={typeof value === 'string' ? value : ''} onValueChange={(nextValue) => { - if (nextValue == null) - return + if (nextValue == null) return onChange(nextValue) }} > @@ -62,7 +57,7 @@ const HumanInputFieldRenderer = ({ {typeof value === 'string' ? value : ''} </SelectTrigger> <SelectContent listClassName="max-h-[140px] overflow-y-auto"> - {options.map(option => ( + {options.map((option) => ( <SelectItem key={option.value} value={option.value}> <SelectItemText>{option.name}</SelectItemText> <SelectItemIndicator /> @@ -74,14 +69,13 @@ const HumanInputFieldRenderer = ({ } if (isFileFormInput(field)) { - const singleFileValue = value && !Array.isArray(value) && typeof value !== 'string' - ? [value] - : [] + const singleFileValue = + value && !Array.isArray(value) && typeof value !== 'string' ? [value] : [] return ( <FileUploaderInAttachmentWrapper value={singleFileValue} - onChange={files => onChange(files[0] || null)} + onChange={(files) => onChange(files[0] || null)} fileConfig={{ allowed_file_types: field.allowed_file_types, allowed_file_extensions: field.allowed_file_extensions, @@ -96,7 +90,7 @@ const HumanInputFieldRenderer = ({ return ( <FileUploaderInAttachmentWrapper value={Array.isArray(value) ? value : []} - onChange={files => onChange(files)} + onChange={(files) => onChange(files)} fileConfig={{ allowed_file_types: field.allowed_file_types, allowed_file_extensions: field.allowed_file_extensions, diff --git a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx index 7d43894889dbeb..edf240df2b344d 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx @@ -7,12 +7,16 @@ import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useCallback, useState } from 'react' import ContentItem from './content-item' -import { getButtonStyle, getProcessedHumanInputFormInputs, getRenderedFormInputs, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from './utils' +import { + getButtonStyle, + getProcessedHumanInputFormInputs, + getRenderedFormInputs, + hasInvalidSelectOrFileInput, + initializeInputs, + splitByOutputVar, +} from './utils' -const HumanInputForm = ({ - formData, - onSubmit, -}: HumanInputFormProps) => { +const HumanInputForm = ({ formData, onSubmit }: HumanInputFormProps) => { const formToken = formData.form_token const contentList = splitByOutputVar(formData.form_content) const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content) @@ -21,13 +25,17 @@ const HumanInputForm = ({ const [isSubmitting, setIsSubmitting] = useState(false) const handleInputsChange = useCallback((name: string, value: HumanInputFieldValue) => { - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, [name]: value, })) }, []) - const submit = async (formToken: string, actionID: string, inputs: Record<string, HumanInputFieldValue>) => { + const submit = async ( + formToken: string, + actionID: string, + inputs: Record<string, HumanInputFieldValue>, + ) => { setIsSubmitting(true) await onSubmit?.(formToken, { inputs: getProcessedHumanInputFormInputs(renderedFormInputs, inputs) || {}, @@ -36,7 +44,8 @@ const HumanInputForm = ({ setIsSubmitting(false) } - const isActionDisabled = isSubmitting || !formToken || hasInvalidSelectOrFileInput(renderedFormInputs, inputs) + const isActionDisabled = + isSubmitting || !formToken || hasInvalidSelectOrFileInput(renderedFormInputs, inputs) return ( <> diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted-content-item.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content-item.tsx index df1aaaeb51b563..40b4ee95a50fd4 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/submitted-content-item.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content-item.tsx @@ -34,23 +34,16 @@ const extractFieldName = (content: string) => { return match ? match[1]! : '' } -const SubmittedContentItem = ({ - content, - formInputFields, - values, -}: SubmittedContentItemProps) => { +const SubmittedContentItem = ({ content, formInputFields, values }: SubmittedContentItemProps) => { if (!isOutputField(content)) { - return ( - <Markdown content={content} /> - ) + return <Markdown content={content} /> } const fieldName = extractFieldName(content) - const field = formInputFields.find(field => field.output_variable_name === fieldName) + const field = formInputFields.find((field) => field.output_variable_name === fieldName) const value = values[fieldName] - if (!field || value == null) - return null + if (!field || value == null) return null if (isParagraphFormInput(field)) { return ( @@ -69,11 +62,16 @@ const SubmittedContentItem = ({ return ( <div className="py-3" data-testid={`submitted-field-${fieldName}`}> <Select value={selectedValue} disabled> - <SelectTrigger size="large" className="w-full" aria-label={field.output_variable_name} disabled> + <SelectTrigger + size="large" + className="w-full" + aria-label={field.output_variable_name} + disabled + > {selectedValue} </SelectTrigger> <SelectContent listClassName="max-h-[140px] overflow-y-auto"> - {field.option_source.value.map(option => ( + {field.option_source.value.map((option) => ( <SelectItem key={option} value={option}> <SelectItemText>{option}</SelectItemText> <SelectItemIndicator /> @@ -86,8 +84,7 @@ const SubmittedContentItem = ({ } if (isFileFormInput(field)) { - if (typeof value === 'string' || Array.isArray(value)) - return null + if (typeof value === 'string' || Array.isArray(value)) return null return ( <div className="py-3" data-testid={`submitted-field-${fieldName}`}> @@ -101,8 +98,7 @@ const SubmittedContentItem = ({ } if (isFileListFormInput(field)) { - if (typeof value === 'string' || !Array.isArray(value)) - return null + if (typeof value === 'string' || !Array.isArray(value)) return null return ( <div className="py-3" data-testid={`submitted-field-${fieldName}`}> diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx index d56ca4676d9f0d..823216526a01d4 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx @@ -5,9 +5,7 @@ type SubmittedContentProps = { content: string } -const SubmittedContent = ({ - content, -}: SubmittedContentProps) => { +const SubmittedContent = ({ content }: SubmittedContentProps) => { return ( <div data-testid="submitted-content"> <Markdown content={content} /> diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted-field-values.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted-field-values.tsx index 3e059bb6352f7d..8f7a9483e9ad84 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/submitted-field-values.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted-field-values.tsx @@ -13,12 +13,9 @@ type SubmittedFieldValuesProps = { values: Record<string, HumanInputFormValue> } -const SubmittedFieldValues = ({ - fields, - values, -}: SubmittedFieldValuesProps) => { - const fieldNames = fields?.map(field => field.output_variable_name) ?? Object.keys(values) - const fieldMap = new Map(fields?.map(field => [field.output_variable_name, field]) ?? []) +const SubmittedFieldValues = ({ fields, values }: SubmittedFieldValuesProps) => { + const fieldNames = fields?.map((field) => field.output_variable_name) ?? Object.keys(values) + const fieldMap = new Map(fields?.map((field) => [field.output_variable_name, field]) ?? []) return ( <div className="flex flex-col gap-3" data-testid="submitted-field-values"> @@ -26,24 +23,17 @@ const SubmittedFieldValues = ({ const field = fieldMap.get(fieldName) const value = values[fieldName] - if (value == null) - return null + if (value == null) return null let valueKind: 'text' | 'file' | 'file-list' = 'text' - if (field && isFileFormInput(field)) - valueKind = 'file' - else if (field && isFileListFormInput(field)) - valueKind = 'file-list' - else if (typeof value === 'string') - valueKind = 'text' - else if (Array.isArray(value)) - valueKind = 'file-list' - else - valueKind = 'file' + if (field && isFileFormInput(field)) valueKind = 'file' + else if (field && isFileListFormInput(field)) valueKind = 'file-list' + else if (typeof value === 'string') valueKind = 'text' + else if (Array.isArray(value)) valueKind = 'file-list' + else valueKind = 'file' if (valueKind === 'file') { - if (typeof value === 'string' || Array.isArray(value)) - return null + if (typeof value === 'string' || Array.isArray(value)) return null return ( <div key={fieldName} data-testid={`submitted-field-${fieldName}`}> @@ -57,8 +47,7 @@ const SubmittedFieldValues = ({ } if (valueKind === 'file-list') { - if (typeof value === 'string' || !Array.isArray(value)) - return null + if (typeof value === 'string' || !Array.isArray(value)) return null return ( <div key={fieldName} data-testid={`submitted-field-${fieldName}`}> diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted-utils.ts b/web/app/components/base/chat/chat/answer/human-input-content/submitted-utils.ts index c11ebd9ab5260c..922ba28ff89873 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/submitted-utils.ts +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted-utils.ts @@ -4,8 +4,7 @@ export const enrichSubmittedHumanInputFormData = ( filledFormData: HumanInputFilledFormData, requiredFormData?: Pick<HumanInputFormData, 'form_content' | 'inputs'>, ): HumanInputFilledFormData => { - if (!requiredFormData) - return filledFormData + if (!requiredFormData) return filledFormData return { ...filledFormData, diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx index 64a14aa5ef41a6..e5987f83464aad 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx @@ -5,10 +5,9 @@ import SubmittedContent from './submitted-content' import SubmittedFieldValues from './submitted-field-values' import SubmittedFormContent from './submitted-form-content' -export const SubmittedHumanInputContent = ({ - formData, -}: SubmittedHumanInputContentProps) => { - const { rendered_content, action_id, action_text, form_content, submitted_data, inputs } = formData +export const SubmittedHumanInputContent = ({ formData }: SubmittedHumanInputContentProps) => { + const { rendered_content, action_id, action_text, form_content, submitted_data, inputs } = + formData const executedAction = useMemo(() => { return { @@ -17,17 +16,18 @@ export const SubmittedHumanInputContent = ({ } }, [action_id, action_text]) - const content = form_content && inputs && submitted_data && Object.keys(submitted_data).length > 0 - ? ( - <SubmittedFormContent - formContent={form_content} - formInputFields={inputs} - values={submitted_data} - /> - ) - : submitted_data && Object.keys(submitted_data).length > 0 - ? <SubmittedFieldValues values={submitted_data} /> - : <SubmittedContent content={rendered_content} /> + const content = + form_content && inputs && submitted_data && Object.keys(submitted_data).length > 0 ? ( + <SubmittedFormContent + formContent={form_content} + formInputFields={inputs} + values={submitted_data} + /> + ) : submitted_data && Object.keys(submitted_data).length > 0 ? ( + <SubmittedFieldValues values={submitted_data} /> + ) : ( + <SubmittedContent content={rendered_content} /> + ) return ( <> diff --git a/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx b/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx index d532684503f543..058d736d94e184 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx @@ -10,11 +10,7 @@ type TipsProps = { showDebugModeTip: boolean } -const Tips = ({ - showEmailTip, - isEmailDebugMode, - showDebugModeTip, -}: TipsProps) => { +const Tips = ({ showEmailTip, isEmailDebugMode, showDebugModeTip }: TipsProps) => { const { t } = useTranslation() const email = useAtomValue(userProfileEmailAtom) @@ -23,19 +19,25 @@ const Tips = ({ <Divider className="my-2! w-[30px]" /> <div className="space-y-1 pt-1" data-testid="tips"> {showEmailTip && !isEmailDebugMode && ( - <div className="system-xs-regular text-text-secondary">{t($ => $['common.humanInputEmailTip'], { ns: 'workflow' })}</div> + <div className="system-xs-regular text-text-secondary"> + {t(($) => $['common.humanInputEmailTip'], { ns: 'workflow' })} + </div> )} {showEmailTip && isEmailDebugMode && ( <div className="system-xs-regular text-text-secondary"> <Trans - i18nKey={$ => $['common.humanInputEmailTipInDebugMode']} + i18nKey={($) => $['common.humanInputEmailTipInDebugMode']} ns="workflow" components={{ email: <span className="system-xs-semibold"></span> }} values={{ email }} /> </div> )} - {showDebugModeTip && <div className="system-xs-medium text-text-warning">{t($ => $['common.humanInputWebappTip'], { ns: 'workflow' })}</div>} + {showDebugModeTip && ( + <div className="system-xs-medium text-text-warning"> + {t(($) => $['common.humanInputWebappTip'], { ns: 'workflow' })} + </div> + )} </div> </> ) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.tsx b/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.tsx index b8b21459239237..5d52e2ff57227a 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.tsx @@ -15,10 +15,7 @@ export const UnsubmittedHumanInputContent = ({ return ( <> {/* Form */} - <HumanInputForm - formData={formData} - onSubmit={onSubmit} - /> + <HumanInputForm formData={formData} onSubmit={onSubmit} /> {/* Tips */} {(showEmailTip || showDebugModeTip) && ( <Tips diff --git a/web/app/components/base/chat/chat/answer/human-input-content/utils.ts b/web/app/components/base/chat/chat/answer/human-input-content/utils.ts index 92584588368386..9978878afc13f7 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/utils.ts +++ b/web/app/components/base/chat/chat/answer/human-input-content/utils.ts @@ -25,40 +25,40 @@ dayjs.extend(relativeTime) dayjs.extend(isSameOrAfter) export const getButtonStyle = (style: UserActionButtonType) => { - if (style === UserActionButtonType.Primary) - return 'primary' - if (style === UserActionButtonType.Default) - return 'secondary' - if (style === UserActionButtonType.Accent) - return 'secondary-accent' - if (style === UserActionButtonType.Ghost) - return 'ghost' + if (style === UserActionButtonType.Primary) return 'primary' + if (style === UserActionButtonType.Default) return 'secondary' + if (style === UserActionButtonType.Accent) return 'secondary-accent' + if (style === UserActionButtonType.Ghost) return 'ghost' } export const splitByOutputVar = (content: string): string[] => { const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g const parts = content.split(outputVarRegex) - return parts.filter(part => part.length > 0) + return parts.filter((part) => part.length > 0) } export const getFormContentInputNames = (content: string) => { const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/g - return [...content.matchAll(outputVarRegex)].map(match => match[1]!) + return [...content.matchAll(outputVarRegex)].map((match) => match[1]!) } export const getRenderedFormInputs = (formInputs: FormInputItem[], content: string) => { const inputNames = new Set(getFormContentInputNames(content)) - return formInputs.filter(input => inputNames.has(input.output_variable_name)) + return formInputs.filter((input) => inputNames.has(input.output_variable_name)) } -export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, HumanInputResolvedValue> = {}) => { +export const initializeInputs = ( + formInputs: FormInputItem[], + defaultValues: Record<string, HumanInputResolvedValue> = {}, +) => { const initialInputs: Record<string, HumanInputFieldValue> = {} formInputs.forEach((item) => { if (isParagraphFormInput(item)) { const resolvedValue = defaultValues[item.output_variable_name] - initialInputs[item.output_variable_name] = item.default.type === 'variable' && typeof resolvedValue === 'string' - ? resolvedValue - : item.default.value + initialInputs[item.output_variable_name] = + item.default.type === 'variable' && typeof resolvedValue === 'string' + ? resolvedValue + : item.default.value return } @@ -80,16 +80,16 @@ export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Rec } const isHumanInputFileUploaded = (value: HumanInputFieldValue | undefined) => { - return !!value - && !Array.isArray(value) - && typeof value !== 'string' - && !!fileIsUploaded(value as FileEntity) + return ( + !!value && + !Array.isArray(value) && + typeof value !== 'string' && + !!fileIsUploaded(value as FileEntity) + ) } const hasUploadedHumanInputFiles = (value: HumanInputFieldValue | undefined) => { - return Array.isArray(value) - && value.length > 0 - && value.every(file => !!fileIsUploaded(file)) + return Array.isArray(value) && value.length > 0 && value.every((file) => !!fileIsUploaded(file)) } export const hasInvalidSelectOrFileInput = ( @@ -97,19 +97,18 @@ export const hasInvalidSelectOrFileInput = ( values: Record<string, HumanInputFieldValue>, ) => { return formInputs.some((input) => { - if (!(input.output_variable_name in values)) - return false + if (!(input.output_variable_name in values)) return false const value = values[input.output_variable_name] - if (isSelectFormInput(input)) - return typeof value !== 'string' || value.length === 0 + if (isSelectFormInput(input)) return typeof value !== 'string' || value.length === 0 if (isFileFormInput(input)) - return Array.isArray(value) ? !hasUploadedHumanInputFiles(value) : !isHumanInputFileUploaded(value) + return Array.isArray(value) + ? !hasUploadedHumanInputFiles(value) + : !isHumanInputFileUploaded(value) - if (isFileListFormInput(input)) - return !hasUploadedHumanInputFiles(value) + if (isFileListFormInput(input)) return !hasUploadedHumanInputFiles(value) return false }) @@ -120,22 +119,20 @@ export const hasInvalidRequiredHumanInput = ( values: Record<string, HumanInputFieldValue>, ) => { return formInputs.some((input) => { - if (!(input.output_variable_name in values)) - return false + if (!(input.output_variable_name in values)) return false const value = values[input.output_variable_name] - if (isParagraphFormInput(input)) - return typeof value !== 'string' || value.trim().length === 0 + if (isParagraphFormInput(input)) return typeof value !== 'string' || value.trim().length === 0 - if (isSelectFormInput(input)) - return typeof value !== 'string' || value.length === 0 + if (isSelectFormInput(input)) return typeof value !== 'string' || value.length === 0 if (isFileFormInput(input)) - return Array.isArray(value) ? !hasUploadedHumanInputFiles(value) : !isHumanInputFileUploaded(value) + return Array.isArray(value) + ? !hasUploadedHumanInputFiles(value) + : !isHumanInputFileUploaded(value) - if (isFileListFormInput(input)) - return !hasUploadedHumanInputFiles(value) + if (isFileListFormInput(input)) return !hasUploadedHumanInputFiles(value) return false }) @@ -145,8 +142,7 @@ export const getProcessedHumanInputFormInputs = ( formInputs: FormInputItem[], values: Record<string, HumanInputFieldValue> | undefined, ) => { - if (!values) - return undefined + if (!values) return undefined const processedInputs: Record<string, unknown> = { ...values } @@ -166,9 +162,8 @@ export const getProcessedHumanInputFormInputs = ( return } - processedInputs[input.output_variable_name] = value && typeof value !== 'string' - ? getProcessedFiles([value as FileEntity])[0] - : undefined + processedInputs[input.output_variable_name] = + value && typeof value !== 'string' ? getProcessedFiles([value as FileEntity])[0] : undefined } }) @@ -182,16 +177,10 @@ const localeMap: Record<string, string> = { 'nl-NL': 'nl', } -export const getRelativeTime = ( - utcTimestamp: string | number, - locale: Locale = 'en-US', -) => { +export const getRelativeTime = (utcTimestamp: string | number, locale: Locale = 'en-US') => { const dayjsLocale = localeMap[locale] ?? 'en' - return dayjs - .utc(utcTimestamp) - .locale(dayjsLocale) - .fromNow() + return dayjs.utc(utcTimestamp).locale(dayjsLocale).fromNow() } export const isRelativeTimeSameOrAfter = (utcTimestamp: string | number) => { diff --git a/web/app/components/base/chat/chat/answer/human-input-filled-form-list.tsx b/web/app/components/base/chat/chat/answer/human-input-filled-form-list.tsx index 4dd82b13a7690c..51c50adc20b0e7 100644 --- a/web/app/components/base/chat/chat/answer/human-input-filled-form-list.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-filled-form-list.tsx @@ -11,20 +11,11 @@ const HumanInputFilledFormList = ({ }: HumanInputFilledFormListProps) => { return ( <div className="mt-2 flex flex-col gap-y-2"> - { - humanInputFilledFormDataList.map(formData => ( - <ContentWrapper - key={formData.node_id} - nodeTitle={formData.node_title} - showExpandIcon - > - <SubmittedHumanInputContent - key={formData.node_id} - formData={formData} - /> - </ContentWrapper> - )) - } + {humanInputFilledFormDataList.map((formData) => ( + <ContentWrapper key={formData.node_id} nodeTitle={formData.node_title} showExpandIcon> + <SubmittedHumanInputContent key={formData.node_id} formData={formData} /> + </ContentWrapper> + ))} </div> ) } diff --git a/web/app/components/base/chat/chat/answer/human-input-form-list.tsx b/web/app/components/base/chat/chat/answer/human-input-form-list.tsx index 6e9957039d7a58..282d364b61bdb4 100644 --- a/web/app/components/base/chat/chat/answer/human-input-form-list.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-form-list.tsx @@ -1,5 +1,8 @@ import type { HumanInputFormSubmitData } from './human-input-content/type' -import type { DeliveryMethod, HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types' +import type { + DeliveryMethod, + HumanInputNodeType, +} from '@/app/components/workflow/nodes/human-input/types' import type { Node } from '@/app/components/workflow/types' import type { HumanInputFormData } from '@/types/workflow' import { useMemo } from 'react' @@ -18,58 +21,66 @@ const HumanInputFormList = ({ onHumanInputFormSubmit, getHumanInputNodeData, }: HumanInputFormListProps) => { - const deliveryMethodsConfig = useMemo((): Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }> => { - if (!humanInputFormDataList.length) - return {} - return humanInputFormDataList.reduce((acc, formData) => { - const deliveryMethodsConfig = getHumanInputNodeData?.(formData.node_id)?.data.delivery_methods || [] - if (!deliveryMethodsConfig.length) { + const deliveryMethodsConfig = useMemo((): Record< + string, + { showEmailTip: boolean; isEmailDebugMode: boolean; showDebugModeTip: boolean } + > => { + if (!humanInputFormDataList.length) return {} + return humanInputFormDataList.reduce( + (acc, formData) => { + const deliveryMethodsConfig = + getHumanInputNodeData?.(formData.node_id)?.data.delivery_methods || [] + if (!deliveryMethodsConfig.length) { + acc[formData.node_id] = { + showEmailTip: false, + isEmailDebugMode: false, + showDebugModeTip: false, + } + return acc + } + const isWebappEnabled = deliveryMethodsConfig.some( + (method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled, + ) + const isEmailEnabled = deliveryMethodsConfig.some( + (method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled, + ) + const isEmailDebugMode = deliveryMethodsConfig.some( + (method: DeliveryMethod) => + method.type === DeliveryMethodType.Email && method.config?.debug_mode, + ) acc[formData.node_id] = { - showEmailTip: false, - isEmailDebugMode: false, - showDebugModeTip: false, + showEmailTip: isEmailEnabled, + isEmailDebugMode, + showDebugModeTip: !isWebappEnabled, } return acc - } - const isWebappEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled) - const isEmailEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled) - const isEmailDebugMode = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.config?.debug_mode) - acc[formData.node_id] = { - showEmailTip: isEmailEnabled, - isEmailDebugMode, - showDebugModeTip: !isWebappEnabled, - } - return acc - }, {} as Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }>) + }, + {} as Record< + string, + { showEmailTip: boolean; isEmailDebugMode: boolean; showDebugModeTip: boolean } + >, + ) }, [getHumanInputNodeData, humanInputFormDataList]) - const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui) + const filteredHumanInputFormDataList = humanInputFormDataList.filter( + (formData) => formData.display_in_ui, + ) return ( - <div - className="mt-2 flex flex-col gap-y-2" - data-testid="human-input-form-list" - > - { - filteredHumanInputFormDataList.map(formData => ( - <div - key={formData.form_id} - data-testid="human-input-form-item" - > - <ContentWrapper - nodeTitle={formData.node_title} - > - <UnsubmittedHumanInputContent - formData={formData} - showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip} - isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode} - showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip} - onSubmit={onHumanInputFormSubmit} - /> - </ContentWrapper> - </div> - )) - } + <div className="mt-2 flex flex-col gap-y-2" data-testid="human-input-form-list"> + {filteredHumanInputFormDataList.map((formData) => ( + <div key={formData.form_id} data-testid="human-input-form-item"> + <ContentWrapper nodeTitle={formData.node_title}> + <UnsubmittedHumanInputContent + formData={formData} + showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip} + isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode} + showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip} + onSubmit={onHumanInputFormSubmit} + /> + </ContentWrapper> + </div> + ))} </div> ) } diff --git a/web/app/components/base/chat/chat/answer/index.stories.tsx b/web/app/components/base/chat/chat/answer/index.stories.tsx index f39746efb22b55..77c9513141a260 100644 --- a/web/app/components/base/chat/chat/answer/index.stories.tsx +++ b/web/app/components/base/chat/chat/answer/index.stories.tsx @@ -13,9 +13,16 @@ const meta = { }, tags: ['autodocs'], argTypes: { - noChatInput: { control: 'boolean', description: 'If set to true, some buttons that are supposed to be shown on hover will not be displayed.' }, + noChatInput: { + control: 'boolean', + description: + 'If set to true, some buttons that are supposed to be shown on hover will not be displayed.', + }, responding: { control: 'boolean', description: 'Indicates if the answer is being generated.' }, - showPromptLog: { control: 'boolean', description: 'If set to true, the prompt log button will be shown on hover.' }, + showPromptLog: { + control: 'boolean', + description: 'If set to true, the prompt log button will be shown on hover.', + }, }, args: { noChatInput: false, diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx index 7ba17100fef5e4..1c28228d9653fc 100644 --- a/web/app/components/base/chat/chat/answer/index.tsx +++ b/web/app/components/base/chat/chat/answer/index.tsx @@ -1,11 +1,5 @@ -import type { - FC, - ReactNode, -} from 'react' -import type { - ChatConfig, - ChatItem, -} from '../../types' +import type { FC, ReactNode } from 'react' +import type { ChatConfig, ChatItem } from '../../types' import type { HumanInputFormSubmitData } from './human-input-content/type' import type { AppData } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' @@ -94,26 +88,21 @@ const Answer: FC<AnswerProps> = ({ const contentRef = useRef<HTMLDivElement>(null) const humanInputFormContainerRef = useRef<HTMLDivElement>(null) - const { - getHumanInputNodeData, - } = useChatContext() + const { getHumanInputNodeData } = useChatContext() const getContainerWidth = () => { - if (containerRef.current) - setContainerWidth(containerRef.current?.clientWidth + 16) + if (containerRef.current) setContainerWidth(containerRef.current?.clientWidth + 16) } useEffect(() => { getContainerWidth() }, []) const getContentWidth = () => { - if (contentRef.current) - setContentWidth(contentRef.current?.clientWidth) + if (contentRef.current) setContentWidth(contentRef.current?.clientWidth) } useEffect(() => { - if (!responding) - getContentWidth() + if (!responding) getContentWidth() }, [responding]) const getHumanInputFormContainerWidth = () => { @@ -122,14 +111,12 @@ const Answer: FC<AnswerProps> = ({ } useEffect(() => { - if (hasHumanInputs) - getHumanInputFormContainerWidth() + if (hasHumanInputs) getHumanInputFormContainerWidth() }, [hasHumanInputs]) // Recalculate contentWidth when content changes (e.g., SVG preview/source toggle) useEffect(() => { - if (!containerRef.current) - return + if (!containerRef.current) return const resizeObserver = new ResizeObserver(() => { getContentWidth() getHumanInputFormContainerWidth() @@ -140,27 +127,23 @@ const Answer: FC<AnswerProps> = ({ } }, []) - const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => { - if (direction === 'prev') { - if (item.prevSibling) - switchSibling?.(item.prevSibling) - } - else { - if (item.nextSibling) - switchSibling?.(item.nextSibling) - } - }, [switchSibling, item.prevSibling, item.nextSibling]) + const handleSwitchSibling = useCallback( + (direction: 'prev' | 'next') => { + if (direction === 'prev') { + if (item.prevSibling) switchSibling?.(item.prevSibling) + } else { + if (item.nextSibling) switchSibling?.(item.nextSibling) + } + }, + [switchSibling, item.prevSibling, item.nextSibling], + ) const contentIsEmpty = typeof content === 'string' && content.trim() === '' - const agentContentNode = renderAgentContent - ? renderAgentContent({ item, responding, content }) - : ( - <AgentContent - item={item} - responding={responding} - content={content} - /> - ) + const agentContentNode = renderAgentContent ? ( + renderAgentContent({ item, responding, content }) + ) : ( + <AgentContent item={item} responding={responding} content={content} /> + ) // Reasoning is "done" — freeze the elapsed timer and collapse the panel — as soon as ANY of: // ① the answer has begun streaming (first text delta): the only signal that fires // mid-node, so it drives the normal think→answer handoff; @@ -183,62 +166,63 @@ const Answer: FC<AnswerProps> = ({ )} </div> )} - <div className="chat-answer-container group ml-4 w-0 grow pb-4" ref={containerRef} data-testid="chat-answer-container"> + <div + className="chat-answer-container group ml-4 w-0 grow pb-4" + ref={containerRef} + data-testid="chat-answer-container" + > {/* Block 1: Workflow Process + Human Input Forms */} {hasHumanInputs && ( - <div className={cn('group relative pr-10', chatAnswerContainerInner)} data-testid="chat-answer-container-humaninput"> + <div + className={cn('group relative pr-10', chatAnswerContainerInner)} + data-testid="chat-answer-container-humaninput" + > <div ref={humanInputFormContainerRef} - className={cn('relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 body-lg-regular text-text-primary')} + className={cn( + 'relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 body-lg-regular text-text-primary', + )} > - { - !responding && contentIsEmpty && !hasAgentContent && ( - <Operation - hasWorkflowProcess={!!workflowProcess} - maxSize={containerWidth - humanInputFormContainerWidth - 4} - contentWidth={humanInputFormContainerWidth} - item={item} - question={question} - index={index} - showPromptLog={showPromptLog} - noChatInput={noChatInput} - /> - ) - } + {!responding && contentIsEmpty && !hasAgentContent && ( + <Operation + hasWorkflowProcess={!!workflowProcess} + maxSize={containerWidth - humanInputFormContainerWidth - 4} + contentWidth={humanInputFormContainerWidth} + item={item} + question={question} + index={index} + showPromptLog={showPromptLog} + noChatInput={noChatInput} + /> + )} {/** Render workflow process */} - { - workflowProcess && ( - <WorkflowProcessItem - data={workflowProcess} - item={item} - hideProcessDetail={hideProcessDetail} - readonly={hideProcessDetail && appData ? !appData.site.show_workflow_steps : undefined} - /> - ) - } - { - humanInputFormDataList && humanInputFormDataList.length > 0 && ( - <HumanInputFormList - humanInputFormDataList={humanInputFormDataList} - onHumanInputFormSubmit={onHumanInputFormSubmit} - getHumanInputNodeData={getHumanInputNodeData} - /> - ) - } - { - humanInputFilledFormDataList && humanInputFilledFormDataList.length > 0 && ( - <HumanInputFilledFormList - humanInputFilledFormDataList={humanInputFilledFormDataList} - /> - ) - } - { - typeof item.siblingCount === 'number' - && item.siblingCount > 1 - && !responding - && contentIsEmpty - && !hasAgentContent - && ( + {workflowProcess && ( + <WorkflowProcessItem + data={workflowProcess} + item={item} + hideProcessDetail={hideProcessDetail} + readonly={ + hideProcessDetail && appData ? !appData.site.show_workflow_steps : undefined + } + /> + )} + {humanInputFormDataList && humanInputFormDataList.length > 0 && ( + <HumanInputFormList + humanInputFormDataList={humanInputFormDataList} + onHumanInputFormSubmit={onHumanInputFormSubmit} + getHumanInputNodeData={getHumanInputNodeData} + /> + )} + {humanInputFilledFormDataList && humanInputFilledFormDataList.length > 0 && ( + <HumanInputFilledFormList + humanInputFilledFormDataList={humanInputFilledFormDataList} + /> + )} + {typeof item.siblingCount === 'number' && + item.siblingCount > 1 && + !responding && + contentIsEmpty && + !hasAgentContent && ( <ContentSwitch count={item.siblingCount} currentIndex={item.siblingIndex} @@ -246,8 +230,7 @@ const Answer: FC<AnswerProps> = ({ nextDisabled={!item.nextSibling} switchSibling={handleSwitchSibling} /> - ) - } + )} </div> </div> )} @@ -260,203 +243,152 @@ const Answer: FC<AnswerProps> = ({ ref={contentRef} className="relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 body-lg-regular text-text-primary" > - { - !responding && ( - <Operation - hasWorkflowProcess={!!workflowProcess} - maxSize={containerWidth - contentWidth - 4} - contentWidth={contentWidth} - item={item} - question={question} - index={index} - showPromptLog={showPromptLog} - noChatInput={noChatInput} - /> - ) - } - { - hasReasoning && ( - <ReasoningPanel - content={item.reasoningContent ?? {}} - done={reasoningDone} - /> - ) - } - { - responding && contentIsEmpty && !hasAgentContent && !hasReasoning && ( - <div className="flex h-5 w-6 items-center justify-center"> - <LoadingAnim type="text" /> - </div> - ) - } - { - !contentIsEmpty && !hasAgentContent && ( - <BasicContent item={item} /> - ) - } - { - hasAgentContent && ( - agentContentNode - ) - } - { - !!allFiles?.length && ( - <FileList - className="my-1" - files={allFiles} - showDeleteAction={false} - showDownloadAction - canPreview - /> - ) - } - { - !!message_files?.length && ( - <FileList - className="my-1" - files={message_files} - showDeleteAction={false} - showDownloadAction - canPreview - /> - ) - } - { - annotation?.id && annotation.authorName && ( - <EditTitle - className="mt-1" - title={t($ => $.editBy, { ns: 'appAnnotation', author: annotation.authorName })} - /> - ) - } + {!responding && ( + <Operation + hasWorkflowProcess={!!workflowProcess} + maxSize={containerWidth - contentWidth - 4} + contentWidth={contentWidth} + item={item} + question={question} + index={index} + showPromptLog={showPromptLog} + noChatInput={noChatInput} + /> + )} + {hasReasoning && ( + <ReasoningPanel content={item.reasoningContent ?? {}} done={reasoningDone} /> + )} + {responding && contentIsEmpty && !hasAgentContent && !hasReasoning && ( + <div className="flex h-5 w-6 items-center justify-center"> + <LoadingAnim type="text" /> + </div> + )} + {!contentIsEmpty && !hasAgentContent && <BasicContent item={item} />} + {hasAgentContent && agentContentNode} + {!!allFiles?.length && ( + <FileList + className="my-1" + files={allFiles} + showDeleteAction={false} + showDownloadAction + canPreview + /> + )} + {!!message_files?.length && ( + <FileList + className="my-1" + files={message_files} + showDeleteAction={false} + showDownloadAction + canPreview + /> + )} + {annotation?.id && annotation.authorName && ( + <EditTitle + className="mt-1" + title={t(($) => $.editBy, { ns: 'appAnnotation', author: annotation.authorName })} + /> + )} <SuggestedQuestions item={item} /> - { - !!citation?.length && !responding && ( - <Citation data={citation} showHitInfo={config?.supportCitationHitInfo} /> - ) - } - { - typeof item.siblingCount === 'number' - && item.siblingCount > 1 - && ( - <ContentSwitch - count={item.siblingCount} - currentIndex={item.siblingIndex} - prevDisabled={!item.prevSibling} - nextDisabled={!item.nextSibling} - switchSibling={handleSwitchSibling} - /> - ) - } + {!!citation?.length && !responding && ( + <Citation data={citation} showHitInfo={config?.supportCitationHitInfo} /> + )} + {typeof item.siblingCount === 'number' && item.siblingCount > 1 && ( + <ContentSwitch + count={item.siblingCount} + currentIndex={item.siblingIndex} + prevDisabled={!item.prevSibling} + nextDisabled={!item.nextSibling} + switchSibling={handleSwitchSibling} + /> + )} </div> </div> )} {/* Original single block layout (when no human inputs) */} {!hasHumanInputs && ( - <div className={cn('group relative pr-10', chatAnswerContainerInner)} data-testid="chat-answer-container-inner"> + <div + className={cn('group relative pr-10', chatAnswerContainerInner)} + data-testid="chat-answer-container-inner" + > <div ref={contentRef} - className={cn('relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 body-lg-regular text-text-primary', workflowProcess && 'w-full')} + className={cn( + 'relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 body-lg-regular text-text-primary', + workflowProcess && 'w-full', + )} > - { - !responding && ( - <Operation - hasWorkflowProcess={!!workflowProcess} - maxSize={containerWidth - contentWidth - 4} - contentWidth={contentWidth} - item={item} - question={question} - index={index} - showPromptLog={showPromptLog} - noChatInput={noChatInput} - /> - ) - } + {!responding && ( + <Operation + hasWorkflowProcess={!!workflowProcess} + maxSize={containerWidth - contentWidth - 4} + contentWidth={contentWidth} + item={item} + question={question} + index={index} + showPromptLog={showPromptLog} + noChatInput={noChatInput} + /> + )} {/** Render workflow process */} - { - workflowProcess && ( - <WorkflowProcessItem - data={workflowProcess} - item={item} - hideProcessDetail={hideProcessDetail} - readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined} - /> - ) - } - { - hasReasoning && ( - <ReasoningPanel - content={item.reasoningContent ?? {}} - done={reasoningDone} - /> - ) - } - { - responding && contentIsEmpty && !hasAgentContent && !hasReasoning && ( - <div className="flex h-5 w-6 items-center justify-center"> - <LoadingAnim type="text" /> - </div> - ) - } - { - !contentIsEmpty && !hasAgentContent && ( - <BasicContent item={item} /> - ) - } - { - hasAgentContent && ( - agentContentNode - ) - } - { - !!allFiles?.length && ( - <FileList - className="my-1" - files={allFiles} - showDeleteAction={false} - showDownloadAction - canPreview - /> - ) - } - { - !!message_files?.length && ( - <FileList - className="my-1" - files={message_files} - showDeleteAction={false} - showDownloadAction - canPreview - /> - ) - } - { - annotation?.id && annotation.authorName && ( - <EditTitle - className="mt-1" - title={t($ => $.editBy, { ns: 'appAnnotation', author: annotation.authorName })} - /> - ) - } + {workflowProcess && ( + <WorkflowProcessItem + data={workflowProcess} + item={item} + hideProcessDetail={hideProcessDetail} + readonly={ + hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined + } + /> + )} + {hasReasoning && ( + <ReasoningPanel content={item.reasoningContent ?? {}} done={reasoningDone} /> + )} + {responding && contentIsEmpty && !hasAgentContent && !hasReasoning && ( + <div className="flex h-5 w-6 items-center justify-center"> + <LoadingAnim type="text" /> + </div> + )} + {!contentIsEmpty && !hasAgentContent && <BasicContent item={item} />} + {hasAgentContent && agentContentNode} + {!!allFiles?.length && ( + <FileList + className="my-1" + files={allFiles} + showDeleteAction={false} + showDownloadAction + canPreview + /> + )} + {!!message_files?.length && ( + <FileList + className="my-1" + files={message_files} + showDeleteAction={false} + showDownloadAction + canPreview + /> + )} + {annotation?.id && annotation.authorName && ( + <EditTitle + className="mt-1" + title={t(($) => $.editBy, { ns: 'appAnnotation', author: annotation.authorName })} + /> + )} <SuggestedQuestions item={item} /> - { - !!citation?.length && !responding && ( - <Citation data={citation} showHitInfo={config?.supportCitationHitInfo} /> - ) - } - { - typeof item.siblingCount === 'number' - && item.siblingCount > 1 && ( - <ContentSwitch - count={item.siblingCount} - currentIndex={item.siblingIndex} - prevDisabled={!item.prevSibling} - nextDisabled={!item.nextSibling} - switchSibling={handleSwitchSibling} - /> - ) - } + {!!citation?.length && !responding && ( + <Citation data={citation} showHitInfo={config?.supportCitationHitInfo} /> + )} + {typeof item.siblingCount === 'number' && item.siblingCount > 1 && ( + <ContentSwitch + count={item.siblingCount} + currentIndex={item.siblingIndex} + prevDisabled={!item.prevSibling} + nextDisabled={!item.nextSibling} + switchSibling={handleSwitchSibling} + /> + )} </div> </div> )} diff --git a/web/app/components/base/chat/chat/answer/more.tsx b/web/app/components/base/chat/chat/answer/more.tsx index 3cd94e3807976d..2e6749fdb4a19c 100644 --- a/web/app/components/base/chat/chat/answer/more.tsx +++ b/web/app/components/base/chat/chat/answer/more.tsx @@ -7,9 +7,7 @@ import { formatNumber } from '@/utils/format' type MoreProps = { more: ChatItem['more'] } -const More: FC<MoreProps> = ({ - more, -}) => { +const More: FC<MoreProps> = ({ more }) => { const { t } = useTranslation() return ( @@ -17,43 +15,37 @@ const More: FC<MoreProps> = ({ className="mt-1 flex items-center system-xs-regular text-text-quaternary opacity-0 group-hover:opacity-100" data-testid="more-container" > - { - more && ( - <> + {more && ( + <> + <div + className="mr-2 max-w-[25%] shrink-0 truncate" + title={`${t(($) => $['detail.timeConsuming'], { ns: 'appLog' })} ${more.latency}${t(($) => $['detail.second'], { ns: 'appLog' })}`} + data-testid="more-latency" + > + {`${t(($) => $['detail.timeConsuming'], { ns: 'appLog' })} ${more.latency}${t(($) => $['detail.second'], { ns: 'appLog' })}`} + </div> + <div + className="mr-2 max-w-[25%] shrink-0 truncate" + title={`${t(($) => $['detail.tokenCost'], { ns: 'appLog' })} ${formatNumber(more.tokens)}`} + data-testid="more-tokens" + > + {`${t(($) => $['detail.tokenCost'], { ns: 'appLog' })} ${formatNumber(more.tokens)}`} + </div> + {!!more.tokens_per_second && ( <div className="mr-2 max-w-[25%] shrink-0 truncate" - title={`${t($ => $['detail.timeConsuming'], { ns: 'appLog' })} ${more.latency}${t($ => $['detail.second'], { ns: 'appLog' })}`} - data-testid="more-latency" + title={`${more.tokens_per_second} tokens/s`} + data-testid="more-tps" > - {`${t($ => $['detail.timeConsuming'], { ns: 'appLog' })} ${more.latency}${t($ => $['detail.second'], { ns: 'appLog' })}`} + {`${more.tokens_per_second} tokens/s`} </div> - <div - className="mr-2 max-w-[25%] shrink-0 truncate" - title={`${t($ => $['detail.tokenCost'], { ns: 'appLog' })} ${formatNumber(more.tokens)}`} - data-testid="more-tokens" - > - {`${t($ => $['detail.tokenCost'], { ns: 'appLog' })} ${formatNumber(more.tokens)}`} - </div> - {!!more.tokens_per_second && ( - <div - className="mr-2 max-w-[25%] shrink-0 truncate" - title={`${more.tokens_per_second} tokens/s`} - data-testid="more-tps" - > - {`${more.tokens_per_second} tokens/s`} - </div> - )} - <div className="mx-2 shrink-0">·</div> - <div - className="max-w-[25%] shrink-0 truncate" - title={more.time} - data-testid="more-time" - > - {more.time} - </div> - </> - ) - } + )} + <div className="mx-2 shrink-0">·</div> + <div className="max-w-[25%] shrink-0 truncate" title={more.time} data-testid="more-time"> + {more.time} + </div> + </> + )} </div> ) } diff --git a/web/app/components/base/chat/chat/answer/operation.tsx b/web/app/components/base/chat/chat/answer/operation.tsx index 254c0b98af8fe3..99c649912278d4 100644 --- a/web/app/components/base/chat/chat/answer/operation.tsx +++ b/web/app/components/base/chat/chat/answer/operation.tsx @@ -1,21 +1,19 @@ import type { ReactElement, ReactNode } from 'react' -import type { - ChatItem, - Feedback, -} from '../../types' +import type { ChatItem, Feedback } from '../../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import copy from 'copy-to-clipboard' -import { - memo, - useId, - useMemo, - useState, -} from 'react' +import { memo, useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' @@ -48,9 +46,7 @@ const FeedbackTooltip = ({ content, children }: FeedbackTooltipProps) => { return ( <Tooltip> <TooltipTrigger render={children} /> - <TooltipContent className={feedbackTooltipClassName}> - {content} - </TooltipContent> + <TooltipContent className={feedbackTooltipClassName}>{content}</TooltipContent> </Tooltip> ) } @@ -96,8 +92,7 @@ function Operation({ const userFeedback = feedback const content = useMemo(() => { - if (agent_thoughts?.length) - return agent_thoughts.reduce((acc, cur) => acc + cur.thought, '') + if (agent_thoughts?.length) return agent_thoughts.reduce((acc, cur) => acc + cur.thought, '') return messageContent }, [agent_thoughts, messageContent]) @@ -107,46 +102,55 @@ function Operation({ const hasUserFeedback = !!displayUserFeedback?.rating const hasAdminFeedback = !!adminLocalFeedback?.rating - const shouldShowUserFeedbackBar = !isOpeningStatement && config?.supportFeedback && !!onFeedback && !config?.supportAnnotation - const shouldShowAdminFeedbackBar = !isOpeningStatement && config?.supportFeedback && !!onFeedback && !!config?.supportAnnotation - const canManageAnnotation = !readonly && !!onAnnotationAdded && !!onAnnotationEdited && !!onAnnotationRemoved - const shouldShowAnnotationAction = canManageAnnotation && !!config?.supportAnnotation && !!config.annotation_reply?.enabled && !humanInputFormDataList?.length + const shouldShowUserFeedbackBar = + !isOpeningStatement && config?.supportFeedback && !!onFeedback && !config?.supportAnnotation + const shouldShowAdminFeedbackBar = + !isOpeningStatement && config?.supportFeedback && !!onFeedback && !!config?.supportAnnotation + const canManageAnnotation = + !readonly && !!onAnnotationAdded && !!onAnnotationEdited && !!onAnnotationRemoved + const shouldShowAnnotationAction = + canManageAnnotation && + !!config?.supportAnnotation && + !!config.annotation_reply?.enabled && + !humanInputFormDataList?.length - const userFeedbackLabel = t($ => $['table.header.userRate'], { ns: 'appLog' }) || 'User feedback' - const adminFeedbackLabel = t($ => $['table.header.adminRate'], { ns: 'appLog' }) || 'Admin feedback' - const likeLabel = t($ => $['detail.operation.like'], { ns: 'appLog' }) || 'Like' - const dislikeLabel = t($ => $['detail.operation.dislike'], { ns: 'appLog' }) || 'Dislike' - const removeFeedbackLabel = t($ => $['operation.remove'], { ns: 'common' }) || 'Remove' - const copyLabel = t($ => $['operation.copy'], { ns: 'common' }) || 'Copy' - const regenerateLabel = t($ => $['operation.regenerate'], { ns: 'common' }) || 'Regenerate' + const userFeedbackLabel = + t(($) => $['table.header.userRate'], { ns: 'appLog' }) || 'User feedback' + const adminFeedbackLabel = + t(($) => $['table.header.adminRate'], { ns: 'appLog' }) || 'Admin feedback' + const likeLabel = t(($) => $['detail.operation.like'], { ns: 'appLog' }) || 'Like' + const dislikeLabel = t(($) => $['detail.operation.dislike'], { ns: 'appLog' }) || 'Dislike' + const removeFeedbackLabel = t(($) => $['operation.remove'], { ns: 'common' }) || 'Remove' + const copyLabel = t(($) => $['operation.copy'], { ns: 'common' }) || 'Copy' + const regenerateLabel = t(($) => $['operation.regenerate'], { ns: 'common' }) || 'Regenerate' const buildFeedbackTooltip = (feedbackData?: Feedback | null, label = userFeedbackLabel) => { - if (!feedbackData?.rating) - return label + if (!feedbackData?.rating) return label - const ratingLabel = feedbackData.rating === 'like' - ? (t($ => $['detail.operation.like'], { ns: 'appLog' }) || 'like') - : (t($ => $['detail.operation.dislike'], { ns: 'appLog' }) || 'dislike') + const ratingLabel = + feedbackData.rating === 'like' + ? t(($) => $['detail.operation.like'], { ns: 'appLog' }) || 'like' + : t(($) => $['detail.operation.dislike'], { ns: 'appLog' }) || 'dislike' const feedbackText = feedbackData.content?.trim() - if (feedbackText) - return `${label}: ${ratingLabel} - ${feedbackText}` + if (feedbackText) return `${label}: ${ratingLabel} - ${feedbackText}` return `${label}: ${ratingLabel}` } - const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string, target: 'user' | 'admin' = 'user') => { - if (!config?.supportFeedback || !onFeedback) - return + const handleFeedback = async ( + rating: 'like' | 'dislike' | null, + content?: string, + target: 'user' | 'admin' = 'user', + ) => { + if (!config?.supportFeedback || !onFeedback) return await onFeedback?.(id, { rating, content }) const nextFeedback = rating === null ? { rating: null } : { rating, content } - if (target === 'admin') - setAdminLocalFeedback(nextFeedback) - else - setUserLocalFeedback(nextFeedback) + if (target === 'admin') setAdminLocalFeedback(nextFeedback) + else setUserLocalFeedback(nextFeedback) } const handleLikeClick = (target: 'user' | 'admin') => { @@ -171,21 +175,25 @@ function Operation({ const operationWidth = useMemo(() => { let width = 0 - if (!isOpeningStatement) - width += 26 - if (!isOpeningStatement && showPromptLog) - width += 28 + 8 - if (!isOpeningStatement && config?.text_to_speech?.enabled) - width += 26 - if (!isOpeningStatement && shouldShowAnnotationAction) - width += 26 - if (shouldShowUserFeedbackBar) - width += hasUserFeedback ? 28 + 8 : 60 + 8 + if (!isOpeningStatement) width += 26 + if (!isOpeningStatement && showPromptLog) width += 28 + 8 + if (!isOpeningStatement && config?.text_to_speech?.enabled) width += 26 + if (!isOpeningStatement && shouldShowAnnotationAction) width += 26 + if (shouldShowUserFeedbackBar) width += hasUserFeedback ? 28 + 8 : 60 + 8 if (shouldShowAdminFeedbackBar) width += (hasAdminFeedback ? 28 : 60) + 8 + (hasUserFeedback ? 28 : 0) return width - }, [config?.text_to_speech?.enabled, hasAdminFeedback, hasUserFeedback, isOpeningStatement, shouldShowAdminFeedbackBar, shouldShowAnnotationAction, shouldShowUserFeedbackBar, showPromptLog]) + }, [ + config?.text_to_speech?.enabled, + hasAdminFeedback, + hasUserFeedback, + isOpeningStatement, + shouldShowAdminFeedbackBar, + shouldShowAnnotationAction, + shouldShowUserFeedbackBar, + showPromptLog, + ]) const positionRight = useMemo(() => operationWidth < maxSize, [operationWidth, maxSize]) @@ -198,118 +206,150 @@ function Operation({ !positionRight && 'right-2 -bottom-4', !hasWorkflowProcess && positionRight && 'top-[9px]!', )} - style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}} + style={!hasWorkflowProcess && positionRight ? { left: contentWidth + 8 } : {}} data-testid="operation-bar" > {shouldShowUserFeedbackBar && !humanInputFormDataList?.length && ( - <div className={cn( - 'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs', - hasUserFeedback ? 'flex' : `hidden ${answerActiveFlexClassName}`, - )} + <div + className={cn( + 'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs', + hasUserFeedback ? 'flex' : `hidden ${answerActiveFlexClassName}`, + )} > - {hasUserFeedback - ? ( - <FeedbackTooltip - content={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)} - > - <ActionButton - aria-label={`${userFeedbackLabel}: ${removeFeedbackLabel}`} - state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive} - onClick={() => handleFeedback(null, undefined, 'user')} - > - {displayUserFeedback?.rating === 'like' - ? <span aria-hidden="true" className="i-ri-thumb-up-line size-4" /> - : <span aria-hidden="true" className="i-ri-thumb-down-line size-4" />} - </ActionButton> - </FeedbackTooltip> - ) - : ( - <> - <ActionButton - aria-label={`${userFeedbackLabel}: ${likeLabel}`} - state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default} - onClick={() => handleLikeClick('user')} - > - <span aria-hidden="true" className="i-ri-thumb-up-line size-4" /> - </ActionButton> - <ActionButton - aria-label={`${userFeedbackLabel}: ${dislikeLabel}`} - state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default} - onClick={() => handleDislikeClick('user')} - > - <span aria-hidden="true" className="i-ri-thumb-down-line size-4" /> - </ActionButton> - </> - )} + {hasUserFeedback ? ( + <FeedbackTooltip + content={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)} + > + <ActionButton + aria-label={`${userFeedbackLabel}: ${removeFeedbackLabel}`} + state={ + displayUserFeedback?.rating === 'like' + ? ActionButtonState.Active + : ActionButtonState.Destructive + } + onClick={() => handleFeedback(null, undefined, 'user')} + > + {displayUserFeedback?.rating === 'like' ? ( + <span aria-hidden="true" className="i-ri-thumb-up-line size-4" /> + ) : ( + <span aria-hidden="true" className="i-ri-thumb-down-line size-4" /> + )} + </ActionButton> + </FeedbackTooltip> + ) : ( + <> + <ActionButton + aria-label={`${userFeedbackLabel}: ${likeLabel}`} + state={ + displayUserFeedback?.rating === 'like' + ? ActionButtonState.Active + : ActionButtonState.Default + } + onClick={() => handleLikeClick('user')} + > + <span aria-hidden="true" className="i-ri-thumb-up-line size-4" /> + </ActionButton> + <ActionButton + aria-label={`${userFeedbackLabel}: ${dislikeLabel}`} + state={ + displayUserFeedback?.rating === 'dislike' + ? ActionButtonState.Destructive + : ActionButtonState.Default + } + onClick={() => handleDislikeClick('user')} + > + <span aria-hidden="true" className="i-ri-thumb-down-line size-4" /> + </ActionButton> + </> + )} </div> )} {shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && ( - <div className={cn( - 'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs', - (hasAdminFeedback || hasUserFeedback) ? 'flex' : `hidden ${answerActiveFlexClassName}`, - )} + <div + className={cn( + 'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs', + hasAdminFeedback || hasUserFeedback ? 'flex' : `hidden ${answerActiveFlexClassName}`, + )} > {displayUserFeedback?.rating && ( <FeedbackTooltip content={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)} > - {displayUserFeedback.rating === 'like' - ? ( - <ActionButton aria-label={`${userFeedbackLabel}: ${likeLabel}`} state={ActionButtonState.Active}> - <span aria-hidden="true" className="i-ri-thumb-up-line size-4" /> - </ActionButton> - ) - : ( - <ActionButton aria-label={`${userFeedbackLabel}: ${dislikeLabel}`} state={ActionButtonState.Destructive}> - <span aria-hidden="true" className="i-ri-thumb-down-line size-4" /> - </ActionButton> - )} + {displayUserFeedback.rating === 'like' ? ( + <ActionButton + aria-label={`${userFeedbackLabel}: ${likeLabel}`} + state={ActionButtonState.Active} + > + <span aria-hidden="true" className="i-ri-thumb-up-line size-4" /> + </ActionButton> + ) : ( + <ActionButton + aria-label={`${userFeedbackLabel}: ${dislikeLabel}`} + state={ActionButtonState.Destructive} + > + <span aria-hidden="true" className="i-ri-thumb-down-line size-4" /> + </ActionButton> + )} </FeedbackTooltip> )} - {displayUserFeedback?.rating && <div className="mx-1 h-3 w-[0.5px] bg-components-actionbar-border" />} - {hasAdminFeedback - ? ( - <FeedbackTooltip - content={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)} + {displayUserFeedback?.rating && ( + <div className="mx-1 h-3 w-[0.5px] bg-components-actionbar-border" /> + )} + {hasAdminFeedback ? ( + <FeedbackTooltip + content={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)} + > + <ActionButton + aria-label={`${adminFeedbackLabel}: ${removeFeedbackLabel}`} + state={ + adminLocalFeedback?.rating === 'like' + ? ActionButtonState.Active + : ActionButtonState.Destructive + } + onClick={() => handleFeedback(null, undefined, 'admin')} + > + {adminLocalFeedback?.rating === 'like' ? ( + <span aria-hidden="true" className="i-ri-thumb-up-line size-4" /> + ) : ( + <span aria-hidden="true" className="i-ri-thumb-down-line size-4" /> + )} + </ActionButton> + </FeedbackTooltip> + ) : ( + <> + <FeedbackTooltip + content={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)} + > + <ActionButton + aria-label={`${adminFeedbackLabel}: ${likeLabel}`} + state={ + adminLocalFeedback?.rating === 'like' + ? ActionButtonState.Active + : ActionButtonState.Default + } + onClick={() => handleLikeClick('admin')} > - <ActionButton - aria-label={`${adminFeedbackLabel}: ${removeFeedbackLabel}`} - state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive} - onClick={() => handleFeedback(null, undefined, 'admin')} - > - {adminLocalFeedback?.rating === 'like' - ? <span aria-hidden="true" className="i-ri-thumb-up-line size-4" /> - : <span aria-hidden="true" className="i-ri-thumb-down-line size-4" />} - </ActionButton> - </FeedbackTooltip> - ) - : ( - <> - <FeedbackTooltip - content={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)} - > - <ActionButton - aria-label={`${adminFeedbackLabel}: ${likeLabel}`} - state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default} - onClick={() => handleLikeClick('admin')} - > - <span aria-hidden="true" className="i-ri-thumb-up-line size-4" /> - </ActionButton> - </FeedbackTooltip> - <FeedbackTooltip - content={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)} - > - <ActionButton - aria-label={`${adminFeedbackLabel}: ${dislikeLabel}`} - state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default} - onClick={() => handleDislikeClick('admin')} - > - <span aria-hidden="true" className="i-ri-thumb-down-line size-4" /> - </ActionButton> - </FeedbackTooltip> - </> - )} + <span aria-hidden="true" className="i-ri-thumb-up-line size-4" /> + </ActionButton> + </FeedbackTooltip> + <FeedbackTooltip + content={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)} + > + <ActionButton + aria-label={`${adminFeedbackLabel}: ${dislikeLabel}`} + state={ + adminLocalFeedback?.rating === 'dislike' + ? ActionButtonState.Destructive + : ActionButtonState.Default + } + onClick={() => handleDislikeClick('admin')} + > + <span aria-hidden="true" className="i-ri-thumb-down-line size-4" /> + </ActionButton> + </FeedbackTooltip> + </> + )} </div> )} {showPromptLog && !isOpeningStatement && ( @@ -318,20 +358,22 @@ function Operation({ </div> )} {!isOpeningStatement && ( - <div className={cn('ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs', answerActiveFlexClassName)} data-testid="operation-actions"> - {(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && ( - <NewAudioButton - id={id} - value={content} - voice={config?.text_to_speech?.voice} - /> + <div + className={cn( + 'ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs', + answerActiveFlexClassName, + )} + data-testid="operation-actions" + > + {config?.text_to_speech?.enabled && !humanInputFormDataList?.length && ( + <NewAudioButton id={id} value={content} voice={config?.text_to_speech?.voice} /> )} {!humanInputFormDataList?.length && ( <ActionButton aria-label={copyLabel} onClick={() => { copy(content) - toast.success(t($ => $['actionMsg.copySuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.copySuccessfully'], { ns: 'common' })) }} > <span aria-hidden="true" className="i-ri-clipboard-line size-4" /> @@ -349,7 +391,9 @@ function Operation({ cached={!!annotation?.id} query={question} answer={content} - onAdded={(id, authorName) => onAnnotationAdded?.(id, authorName, question, content, index)} + onAdded={(id, authorName) => + onAnnotationAdded?.(id, authorName, question, content, index) + } onEdit={() => setIsShowReplyModal(true)} /> )} @@ -362,8 +406,12 @@ function Operation({ onHide={() => setIsShowReplyModal(false)} query={question} answer={content} - onEdited={(editedQuery, editedAnswer) => onAnnotationEdited?.(editedQuery, editedAnswer, index)} - onAdded={(annotationId, authorName, editedQuery, editedAnswer) => onAnnotationAdded?.(annotationId, authorName, editedQuery, editedAnswer, index)} + onEdited={(editedQuery, editedAnswer) => + onAnnotationEdited?.(editedQuery, editedAnswer, index) + } + onAdded={(annotationId, authorName, editedQuery, editedAnswer) => + onAnnotationAdded?.(annotationId, authorName, editedQuery, editedAnswer, index) + } appId={config?.appId || ''} messageId={id} annotationId={annotation?.id || ''} @@ -375,48 +423,47 @@ function Operation({ <Dialog open onOpenChange={(open) => { - if (!open) - handleFeedbackCancel() + if (!open) handleFeedbackCancel() }} > - <DialogContent - backdropProps={{ forceRender: true }} - className="p-0" - > + <DialogContent backdropProps={{ forceRender: true }} className="p-0"> <div className="flex max-h-[80dvh] flex-col"> <div className="relative shrink-0 p-6 pr-14 pb-3"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['feedback.title'], { ns: 'common' }) || 'Provide Feedback'} + {t(($) => $['feedback.title'], { ns: 'common' }) || 'Provide Feedback'} </DialogTitle> <DialogDescription className="mt-1 system-xs-regular text-text-tertiary"> - {t($ => $['feedback.subtitle'], { ns: 'common' }) || 'Please tell us what went wrong with this response'} + {t(($) => $['feedback.subtitle'], { ns: 'common' }) || + 'Please tell us what went wrong with this response'} </DialogDescription> <DialogCloseButton className="top-5 right-5 size-8 rounded-lg" /> </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> - <label htmlFor={feedbackTextareaId} className="mb-2 block system-sm-semibold text-text-secondary"> - {t($ => $['feedback.content'], { ns: 'common' }) || 'Feedback Content'} + <label + htmlFor={feedbackTextareaId} + className="mb-2 block system-sm-semibold text-text-secondary" + > + {t(($) => $['feedback.content'], { ns: 'common' }) || 'Feedback Content'} </label> <Textarea id={feedbackTextareaId} name="feedback-content" value={feedbackContent} - onValueChange={value => setFeedbackContent(value)} - placeholder={t($ => $['feedback.placeholder'], { ns: 'common' }) || 'Please describe what went wrong or how we can improve…'} + onValueChange={(value) => setFeedbackContent(value)} + placeholder={ + t(($) => $['feedback.placeholder'], { ns: 'common' }) || + 'Please describe what went wrong or how we can improve…' + } rows={4} className="w-full" /> </div> <div className="flex shrink-0 justify-end p-6 pt-5"> <Button onClick={handleFeedbackCancel}> - {t($ => $['operation.cancel'], { ns: 'common' }) || 'Cancel'} + {t(($) => $['operation.cancel'], { ns: 'common' }) || 'Cancel'} </Button> - <Button - className="ml-2" - variant="primary" - onClick={handleFeedbackSubmit} - > - {t($ => $['operation.submit'], { ns: 'common' }) || 'Submit'} + <Button className="ml-2" variant="primary" onClick={handleFeedbackSubmit}> + {t(($) => $['operation.submit'], { ns: 'common' }) || 'Submit'} </Button> </div> </div> diff --git a/web/app/components/base/chat/chat/answer/reasoning-panel.tsx b/web/app/components/base/chat/chat/answer/reasoning-panel.tsx index a51f073f2e2671..6a712becf40f4b 100644 --- a/web/app/components/base/chat/chat/answer/reasoning-panel.tsx +++ b/web/app/components/base/chat/chat/answer/reasoning-panel.tsx @@ -18,8 +18,7 @@ const ReasoningPanel: FC<ReasoningPanelProps> = ({ content, done }) => { const text = Object.values(content).filter(Boolean).join('\n\n') const { elapsedTime, isComplete } = useElapsedTimer(done) - if (!text) - return null + if (!text) return null return ( <ThinkingDetails className="my-2" isComplete={isComplete} elapsedTime={elapsedTime}> diff --git a/web/app/components/base/chat/chat/answer/suggested-questions.tsx b/web/app/components/base/chat/chat/answer/suggested-questions.tsx index e102936b8cdf70..336232c7684b9f 100644 --- a/web/app/components/base/chat/chat/answer/suggested-questions.tsx +++ b/web/app/components/base/chat/chat/answer/suggested-questions.tsx @@ -7,35 +7,30 @@ import { useChatContext } from '../context' type SuggestedQuestionsProps = { item: ChatItem } -const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({ - item, -}) => { +const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({ item }) => { const { onSend, readonly } = useChatContext() - const { - isOpeningStatement, - suggestedQuestions, - } = item + const { isOpeningStatement, suggestedQuestions } = item - if (!isOpeningStatement || !suggestedQuestions?.length) - return null + if (!isOpeningStatement || !suggestedQuestions?.length) return null return ( <div className="flex flex-wrap"> - {suggestedQuestions.filter(q => !!q && q.trim()).map((question, index) => ( - <div - key={index} - className={cn( - 'mt-1 mr-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 system-sm-medium text-components-button-secondary-accent-text shadow-xs last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover', - readonly && 'pointer-events-none opacity-50', - )} - onClick={() => !readonly && onSend?.(question)} - data-testid="suggested-question" - > - {question} - </div> - ), - )} + {suggestedQuestions + .filter((q) => !!q && q.trim()) + .map((question, index) => ( + <div + key={index} + className={cn( + 'mt-1 mr-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 system-sm-medium text-components-button-secondary-accent-text shadow-xs last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover', + readonly && 'pointer-events-none opacity-50', + )} + onClick={() => !readonly && onSend?.(question)} + data-testid="suggested-question" + > + {question} + </div> + ))} </div> ) } diff --git a/web/app/components/base/chat/chat/answer/tool-detail.tsx b/web/app/components/base/chat/chat/answer/tool-detail.tsx index 278580f73413c1..d56d26a65fc100 100644 --- a/web/app/components/base/chat/chat/answer/tool-detail.tsx +++ b/web/app/components/base/chat/chat/answer/tool-detail.tsx @@ -1,23 +1,16 @@ import type { ToolInfoInThought } from '../type' import { cn } from '@langgenius/dify-ui/cn' -import { - RiArrowDownSLine, - RiArrowRightSLine, - RiHammerFill, - RiLoader2Line, -} from '@remixicon/react' +import { RiArrowDownSLine, RiArrowRightSLine, RiHammerFill, RiLoader2Line } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' type ToolDetailProps = { payload: ToolInfoInThought } -const ToolDetail = ({ - payload, -}: ToolDetailProps) => { +const ToolDetail = ({ payload }: ToolDetailProps) => { const { t } = useTranslation() const { name, label, input, isFinished, output } = payload - const toolLabel = name.startsWith('dataset_') ? t($ => $.knowledge, { ns: 'dataset' }) : label + const toolLabel = name.startsWith('dataset_') ? t(($) => $.knowledge, { ns: 'dataset' }) : label const [expand, setExpand] = useState(false) return ( @@ -37,33 +30,27 @@ const ToolDetail = ({ > {isFinished && <RiHammerFill className="mr-1 size-3.5" />} {!isFinished && <RiLoader2Line className="mr-1 size-3.5 animate-spin" />} - {t($ => $[`thought.${isFinished ? 'used' : 'using'}`], { ns: 'tools' })} + {t(($) => $[`thought.${isFinished ? 'used' : 'using'}`], { ns: 'tools' })} <div className="mx-1 text-text-secondary">{toolLabel}</div> {!expand && <RiArrowRightSLine className="size-4" />} {expand && <RiArrowDownSLine className="ml-auto size-4" />} </div> - { - expand && ( - <> - <div className="mx-1 mb-0.5 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary"> - <div className="flex h-7 items-center justify-between px-2 pt-1 system-xs-semibold-uppercase"> - {t($ => $['thought.requestTitle'], { ns: 'tools' })} - </div> - <div className="px-3 pt-1 pb-2 code-xs-regular wrap-break-word"> - {input} - </div> + {expand && ( + <> + <div className="mx-1 mb-0.5 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary"> + <div className="flex h-7 items-center justify-between px-2 pt-1 system-xs-semibold-uppercase"> + {t(($) => $['thought.requestTitle'], { ns: 'tools' })} </div> - <div className="mx-1 mb-1 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary"> - <div className="flex h-7 items-center justify-between px-2 pt-1 system-xs-semibold-uppercase"> - {t($ => $['thought.responseTitle'], { ns: 'tools' })} - </div> - <div className="px-3 pt-1 pb-2 code-xs-regular wrap-break-word"> - {output} - </div> + <div className="px-3 pt-1 pb-2 code-xs-regular wrap-break-word">{input}</div> + </div> + <div className="mx-1 mb-1 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary"> + <div className="flex h-7 items-center justify-between px-2 pt-1 system-xs-semibold-uppercase"> + {t(($) => $['thought.responseTitle'], { ns: 'tools' })} </div> - </> - ) - } + <div className="px-3 pt-1 pb-2 code-xs-regular wrap-break-word">{output}</div> + </div> + </> + )} </div> ) } diff --git a/web/app/components/base/chat/chat/answer/workflow-process.tsx b/web/app/components/base/chat/chat/answer/workflow-process.tsx index 465240ecbae3d3..6ec2d2b917dd45 100644 --- a/web/app/components/base/chat/chat/answer/workflow-process.tsx +++ b/web/app/components/base/chat/chat/answer/workflow-process.tsx @@ -1,10 +1,6 @@ import type { ChatItem, WorkflowProcess } from '../../types' - import { cn } from '@langgenius/dify-ui/cn' -import { - useEffect, - useState, -} from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import TracingPanel from '@/app/components/workflow/run/tracing-panel' import { WorkflowRunningStatus } from '@/app/components/workflow/types' @@ -28,18 +24,19 @@ const WorkflowProcessItem = ({ const [collapse, setCollapse] = useState(!expand) const running = data.status === WorkflowRunningStatus.Running const succeeded = data.status === WorkflowRunningStatus.Succeeded - const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped + const failed = + data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped const paused = data.status === WorkflowRunningStatus.Paused const latestNode = data.tracing[data.tracing.length - 1] - const fallbackTitle = t($ => $['common.workflowProcess'], { ns: 'workflow' }) + const fallbackTitle = t(($) => $['common.workflowProcess'], { ns: 'workflow' }) const statusLabel = running - ? t($ => $['common.workflowProcessRunning'], { ns: 'workflow' }) + ? t(($) => $['common.workflowProcessRunning'], { ns: 'workflow' }) : succeeded - ? t($ => $['common.workflowProcessSucceeded'], { ns: 'workflow' }) + ? t(($) => $['common.workflowProcessSucceeded'], { ns: 'workflow' }) : failed - ? t($ => $['common.workflowProcessFailed'], { ns: 'workflow' }) + ? t(($) => $['common.workflowProcessFailed'], { ns: 'workflow' }) : paused - ? t($ => $['common.workflowProcessPaused'], { ns: 'workflow' }) + ? t(($) => $['common.workflowProcessPaused'], { ns: 'workflow' }) : undefined const collapsedTitle = failed ? data.error || latestNode?.error || latestNode?.title || fallbackTitle @@ -49,14 +46,15 @@ const WorkflowProcessItem = ({ setCollapse(!expand) }, [expand]) - if (readonly) - return null + if (readonly) return null return ( <div className={cn( '-mx-1 rounded-xl px-2.5', - collapse ? 'border-l-[0.25px] border-components-panel-border py-[7px]' : 'border-[0.5px] border-components-panel-border-subtle px-1 pt-[7px] pb-1', + collapse + ? 'border-l-[0.25px] border-components-panel-border py-[7px]' + : 'border-[0.5px] border-components-panel-border-subtle px-1 pt-[7px] pb-1', running && !collapse && 'bg-background-section-burn', succeeded && !collapse && 'bg-state-success-hover', failed && !collapse && 'bg-state-destructive-hover', @@ -69,46 +67,41 @@ const WorkflowProcessItem = ({ > <button type="button" - className={cn('flex w-full cursor-pointer items-center border-0 bg-transparent p-0 text-left', !collapse && 'px-1.5')} + className={cn( + 'flex w-full cursor-pointer items-center border-0 bg-transparent p-0 text-left', + !collapse && 'px-1.5', + )} aria-expanded={!collapse} onClick={() => setCollapse(!collapse)} > - { - running && ( - <span - role="img" - aria-label={statusLabel} - className="mr-1 i-ri-loader-2-line size-3.5 shrink-0 animate-spin text-text-tertiary" - /> - ) - } - { - succeeded && ( - <span - role="img" - aria-label={statusLabel} - className="mr-1 i-custom-vender-solid-general-check-circle size-3.5 shrink-0 text-text-success" - /> - ) - } - { - failed && ( - <span - role="img" - aria-label={statusLabel} - className="mr-1 i-ri-error-warning-fill size-3.5 shrink-0 text-text-destructive" - /> - ) - } - { - paused && ( - <span - role="img" - aria-label={statusLabel} - className="mr-1 i-ri-pause-circle-fill size-3.5 shrink-0 text-text-warning-secondary" - /> - ) - } + {running && ( + <span + role="img" + aria-label={statusLabel} + className="mr-1 i-ri-loader-2-line size-3.5 shrink-0 animate-spin text-text-tertiary" + /> + )} + {succeeded && ( + <span + role="img" + aria-label={statusLabel} + className="mr-1 i-custom-vender-solid-general-check-circle size-3.5 shrink-0 text-text-success" + /> + )} + {failed && ( + <span + role="img" + aria-label={statusLabel} + className="mr-1 i-ri-error-warning-fill size-3.5 shrink-0 text-text-destructive" + /> + )} + {paused && ( + <span + role="img" + aria-label={statusLabel} + className="mr-1 i-ri-pause-circle-fill size-3.5 shrink-0 text-text-warning-secondary" + /> + )} <div className={cn( 'min-w-0 grow truncate system-xs-medium', @@ -117,33 +110,33 @@ const WorkflowProcessItem = ({ > {!collapse ? fallbackTitle : collapsedTitle} </div> - <span aria-hidden className={cn('ml-1 i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary', !collapse && 'rotate-90')} /> + <span + aria-hidden + className={cn( + 'ml-1 i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary', + !collapse && 'rotate-90', + )} + /> </button> - { - !collapse && ( - <div className="mt-1.5"> - { - failed && data.error && ( - <div - role="alert" - className="mb-1.5 rounded-lg border-[0.5px] border-state-destructive-border bg-state-destructive-hover px-2 py-1.5 system-xs-regular text-text-destructive" - > - {data.error} - </div> - ) - } - { - data.tracing.length > 0 && ( - <TracingPanel - list={data.tracing} - hideNodeInfo={hideInfo} - hideNodeProcessDetail={hideProcessDetail} - /> - ) - } - </div> - ) - } + {!collapse && ( + <div className="mt-1.5"> + {failed && data.error && ( + <div + role="alert" + className="mb-1.5 rounded-lg border-[0.5px] border-state-destructive-border bg-state-destructive-hover px-2 py-1.5 system-xs-regular text-text-destructive" + > + {data.error} + </div> + )} + {data.tracing.length > 0 && ( + <TracingPanel + list={data.tracing} + hideNodeInfo={hideInfo} + hideNodeProcessDetail={hideProcessDetail} + /> + )} + </div> + )} </div> ) } diff --git a/web/app/components/base/chat/chat/chat-input-area/__tests__/hooks.spec.ts b/web/app/components/base/chat/chat/chat-input-area/__tests__/hooks.spec.ts index 5865fa2fb01351..1d695932459431 100644 --- a/web/app/components/base/chat/chat/chat-input-area/__tests__/hooks.spec.ts +++ b/web/app/components/base/chat/chat/chat-input-area/__tests__/hooks.spec.ts @@ -3,10 +3,7 @@ import { useTextAreaHeight } from '../hooks' describe('useTextAreaHeight', () => { // Mock getBoundingClientRect for all ref elements - const mockGetBoundingClientRect = ( - width: number = 0, - height: number = 0, - ) => ({ + const mockGetBoundingClientRect = (width: number = 0, height: number = 0) => ({ width, height, top: 0, diff --git a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx index d51a810751aa62..f5ecb3a45f08d1 100644 --- a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx @@ -30,7 +30,9 @@ vi.mock('js-audio-recorder', () => ({ stop = vi.fn() getWAVBlob = vi.fn().mockReturnValue(new Blob([''], { type: 'audio/wav' })) getRecordAnalyseData = vi.fn().mockReturnValue(new Uint8Array(128)) - getChannelData = vi.fn().mockReturnValue({ left: new Float32Array(0), right: new Float32Array(0) }) + getChannelData = vi + .fn() + .mockReturnValue({ left: new Float32Array(0), right: new Float32Array(0) }) getWAV = vi.fn().mockReturnValue(new ArrayBuffer(0)) destroy = vi.fn() }, @@ -82,7 +84,9 @@ vi.mock('@/app/components/base/voice-input', () => { } }) -vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16)) +vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => + setTimeout(() => cb(Date.now()), 16), +) vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id)) vi.stubGlobal('devicePixelRatio', 1) @@ -168,8 +172,7 @@ vi.mock('@/app/components/base/file-uploader/hooks', () => ({ // --------------------------------------------------------------------------- vi.mock('@/app/components/base/features/hooks', () => ({ - useFeatures: (selector: (s: typeof mockFeaturesState) => unknown) => - selector(mockFeaturesState), + useFeatures: (selector: (s: typeof mockFeaturesState) => unknown) => selector(mockFeaturesState), })) // --------------------------------------------------------------------------- @@ -250,24 +253,24 @@ const mockVisionConfig: FileUpload = { }, } -const makeFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({ - id: 'file-1', - name: 'photo.png', - type: 'image/png', - size: 1024, - progress: 100, - transferMethod: TransferMethod.local_file, - uploadedId: 'uploaded-ok', - ...overrides, -} as FileEntity) +const makeFile = (overrides: Partial<FileEntity> = {}): FileEntity => + ({ + id: 'file-1', + name: 'photo.png', + type: 'image/png', + size: 1024, + progress: 100, + transferMethod: TransferMethod.local_file, + uploadedId: 'uploaded-ok', + ...overrides, + }) as FileEntity // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -const getTextarea = () => ( - screen.queryByPlaceholderText(/inputPlaceholder/i) - || screen.queryByPlaceholderText(/inputDisabledPlaceholder/i) -) as HTMLTextAreaElement | null +const getTextarea = () => + (screen.queryByPlaceholderText(/inputPlaceholder/i) || + screen.queryByPlaceholderText(/inputDisabledPlaceholder/i)) as HTMLTextAreaElement | null // --------------------------------------------------------------------------- // Tests @@ -300,12 +303,20 @@ describe('ChatInputArea', () => { }) it('should render the custom placeholder when provided', () => { - render(<ChatInputArea visionConfig={mockVisionConfig} customPlaceholder="Ask the assistant" />) + render( + <ChatInputArea visionConfig={mockVisionConfig} customPlaceholder="Ask the assistant" />, + ) expect(screen.getByPlaceholderText('Ask the assistant')).toBeInTheDocument() }) it('should fall back to the readonly placeholder when readonly has a custom placeholder', () => { - render(<ChatInputArea visionConfig={mockVisionConfig} customPlaceholder="Ask the assistant" readonly />) + render( + <ChatInputArea + visionConfig={mockVisionConfig} + customPlaceholder="Ask the assistant" + readonly + />, + ) expect(screen.getByPlaceholderText(/inputDisabledPlaceholder/i)).toBeInTheDocument() }) @@ -338,7 +349,9 @@ describe('ChatInputArea', () => { it('should render a custom send button label when provided', () => { render(<ChatInputArea visionConfig={mockVisionConfig} sendButtonLabel="Start build" />) expect(screen.getByRole('button', { name: 'Start build' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.operation.send' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.send' }), + ).not.toBeInTheDocument() }) it('should render the send button loading state when provided', async () => { @@ -420,7 +433,11 @@ describe('ChatInputArea', () => { const textarea = getTextarea()! await user.type(textarea, 'Hello world') - fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', nativeEvent: { isComposing: false } }) + fireEvent.keyDown(textarea, { + key: 'Enter', + code: 'Enter', + nativeEvent: { isComposing: false }, + }) expect(onSend).toHaveBeenCalledWith('Hello world', []) expect(textarea).toHaveValue('') @@ -520,13 +537,17 @@ describe('ChatInputArea', () => { // ------------------------------------------------------------------------- describe('Voice Input', () => { it('should render the voice input button when enabled', () => { - render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />) + render( + <ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />, + ) expect(screen.getByRole('button', { name: 'common.voiceInput.start' })).toBeTruthy() }) it('should handle stop recording in VoiceInput', async () => { const user = userEvent.setup({ delay: null }) - render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />) + render( + <ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />, + ) await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' })) // Wait for VoiceInput to show speaking @@ -544,7 +565,9 @@ describe('ChatInputArea', () => { it('should handle cancel in VoiceInput', async () => { const user = userEvent.setup({ delay: null }) - render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />) + render( + <ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />, + ) await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' })) await screen.findByText(/voiceInput.speaking/i) @@ -564,15 +587,15 @@ describe('ChatInputArea', () => { const user = userEvent.setup({ delay: null }) mockGetPermissionConfig.shouldReject = true - render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />) + render( + <ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />, + ) await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' })) // Permission denied should trigger error toast await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) mockGetPermissionConfig.shouldReject = false @@ -584,7 +607,9 @@ describe('ChatInputArea', () => { const { audioToText } = await import('@/service/share') vi.mocked(audioToText).mockResolvedValueOnce({ text: '' }) - render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />) + render( + <ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />, + ) await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' })) await screen.findByText(/voiceInput.speaking/i) @@ -713,7 +738,11 @@ describe('ChatInputArea', () => { fireEvent.compositionStart(textarea) fireEvent.change(textarea, { target: { value: 'Composing' } }) - fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', nativeEvent: { isComposing: true } }) + fireEvent.keyDown(textarea, { + key: 'Enter', + code: 'Enter', + nativeEvent: { isComposing: true }, + }) expect(onSend).not.toHaveBeenCalled() @@ -721,7 +750,11 @@ describe('ChatInputArea', () => { // Wait for the 50ms delay in handleCompositionEnd vi.advanceTimersByTime(60) - fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', nativeEvent: { isComposing: false } }) + fireEvent.keyDown(textarea, { + key: 'Enter', + code: 'Enter', + nativeEvent: { isComposing: false }, + }) expect(onSend).toHaveBeenCalled() vi.useRealTimers() @@ -731,7 +764,9 @@ describe('ChatInputArea', () => { // ------------------------------------------------------------------------- describe('Layout & Styles', () => { it('should toggle opacity class based on disabled prop', () => { - const { container, rerender } = render(<ChatInputArea visionConfig={mockVisionConfig} disabled={false} />) + const { container, rerender } = render( + <ChatInputArea visionConfig={mockVisionConfig} disabled={false} />, + ) expect(container.firstChild).not.toHaveClass('opacity-50') rerender(<ChatInputArea visionConfig={mockVisionConfig} disabled={true} />) @@ -759,9 +794,16 @@ describe('ChatInputArea', () => { it('should render footer notice with an accessible infotip', async () => { const user = userEvent.setup({ delay: null }) const footerNotice = 'Agent runs in a Linux sandbox.' - const footerNoticeTooltip = 'For Dify Community Edition, each of your agents runs in a Linux 7.0.0-10060-aws sandbox environment within your docker. Your edits to the environment via Build Chats are persistent.' + const footerNoticeTooltip = + 'For Dify Community Edition, each of your agents runs in a Linux 7.0.0-10060-aws sandbox environment within your docker. Your edits to the environment via Build Chats are persistent.' const accessibleName = `common.operation.learnMore: ${footerNotice}` - render(<ChatInputArea visionConfig={mockVisionConfig} footerNotice={footerNotice} footerNoticeTooltip={footerNoticeTooltip} />) + render( + <ChatInputArea + visionConfig={mockVisionConfig} + footerNotice={footerNotice} + footerNoticeTooltip={footerNoticeTooltip} + />, + ) expect(screen.getByText(footerNotice)).toBeInTheDocument() expect(screen.queryByText(footerNoticeTooltip)).not.toBeInTheDocument() diff --git a/web/app/components/base/chat/chat/chat-input-area/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat/chat-input-area/__tests__/operation.spec.tsx index 84787371eaebfc..b026634bb80152 100644 --- a/web/app/components/base/chat/chat/chat-input-area/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/__tests__/operation.spec.tsx @@ -32,12 +32,7 @@ describe('Operation', () => { it('should render file uploader when fileConfig.enabled is true', () => { const fileConfig: FileUpload = { enabled: true } as FileUpload - render( - <Operation - onSend={vi.fn()} - fileConfig={fileConfig} - />, - ) + render(<Operation onSend={vi.fn()} fileConfig={fileConfig} />) expect(screen.getByTestId('file-uploader'))!.toBeInTheDocument() }) @@ -51,12 +46,7 @@ describe('Operation', () => { it('should render voice input button when speechToTextConfig.enabled is true', () => { const speechConfig: EnableType = { enabled: true } - render( - <Operation - onSend={vi.fn()} - speechToTextConfig={speechConfig} - />, - ) + render(<Operation onSend={vi.fn()} speechToTextConfig={speechConfig} />) expect(screen.getByRole('button', { name: 'common.voiceInput.start' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.send' })).toBeInTheDocument() @@ -67,28 +57,21 @@ describe('Operation', () => { const speechConfig: EnableType = { enabled: true } render( - <Operation - onSend={vi.fn()} - fileConfig={fileConfig} - speechToTextConfig={speechConfig} - />, + <Operation onSend={vi.fn()} fileConfig={fileConfig} speechToTextConfig={speechConfig} />, ) const fileUploader = screen.getByTestId('file-uploader') const voiceButton = screen.getByRole('button', { name: 'common.voiceInput.start' }) - expect(fileUploader.compareDocumentPosition(voiceButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect( + fileUploader.compareDocumentPosition(voiceButton) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() }) it('should not render voice input button when speechToTextConfig.enabled is false', () => { const speechConfig: EnableType = { enabled: false } - render( - <Operation - onSend={vi.fn()} - speechToTextConfig={speechConfig} - />, - ) + render(<Operation onSend={vi.fn()} speechToTextConfig={speechConfig} />) expect(screen.getAllByRole('button')).toHaveLength(1) }) @@ -118,12 +101,7 @@ describe('Operation', () => { }) it('should apply theme primaryColor as background style when theme is provided', () => { - render( - <Operation - onSend={vi.fn()} - theme={createMockTheme()} - />, - ) + render(<Operation onSend={vi.fn()} theme={createMockTheme()} />) expect(screen.getByRole('button'))!.toHaveStyle({ backgroundColor: 'rgb(255, 0, 0)', @@ -131,12 +109,7 @@ describe('Operation', () => { }) it('should not apply background style when theme is null', () => { - render( - <Operation - onSend={vi.fn()} - theme={null} - />, - ) + render(<Operation onSend={vi.fn()} theme={null} />) expect(screen.getByRole('button').style.backgroundColor).toBe('') }) diff --git a/web/app/components/base/chat/chat/chat-input-area/hooks.ts b/web/app/components/base/chat/chat/chat-input-area/hooks.ts index 6b6e80107b0a92..055b131127327a 100644 --- a/web/app/components/base/chat/chat/chat-input-area/hooks.ts +++ b/web/app/components/base/chat/chat/chat-input-area/hooks.ts @@ -1,8 +1,4 @@ -import { - useCallback, - useRef, - useState, -} from 'react' +import { useCallback, useRef, useState } from 'react' export const useTextAreaHeight = () => { const wrapperRef = useRef<HTMLDivElement>(null) @@ -21,12 +17,9 @@ export const useTextAreaHeight = () => { const { width: holdSpaceWidth } = holdSpaceRef.current.getBoundingClientRect() if (textareaHeight > 32) { setIsMultipleLine(true) - } - else { - if (textValueWidth + holdSpaceWidth >= wrapperWidth) - setIsMultipleLine(true) - else - setIsMultipleLine(false) + } else { + if (textValueWidth + holdSpaceWidth >= wrapperWidth) setIsMultipleLine(true) + else setIsMultipleLine(false) } } }, []) diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx index cdab4314e19d3c..74f6cf0bc91b0e 100644 --- a/web/app/components/base/chat/chat/chat-input-area/index.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx @@ -58,57 +58,99 @@ type ChatInputAreaProps = { */ sendOnEnter?: boolean } -const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendButtonLabel, sendButtonLoading, footerNotice, footerNoticeTooltip, autoFocus = true, sendOnEnter = true }: ChatInputAreaProps) => { +const ChatInputArea = ({ + readonly, + botName, + customPlaceholder, + showFeatureBar, + showFileUpload, + featureBarReadonly = readonly, + featureBarDisabled, + onFeatureBarClick, + visionConfig, + speechToTextConfig = { enabled: true }, + onSend, + inputs = {}, + inputsForm = [], + theme, + isResponding, + disabled, + sendButtonLabel, + sendButtonLoading, + footerNotice, + footerNoticeTooltip, + autoFocus = true, + sendOnEnter = true, +}: ChatInputAreaProps) => { const { t } = useTranslation() - const { wrapperRef, textareaRef, textValueRef, holdSpaceRef, handleTextareaResize, isMultipleLine } = useTextAreaHeight() + const { + wrapperRef, + textareaRef, + textValueRef, + holdSpaceRef, + handleTextareaResize, + isMultipleLine, + } = useTextAreaHeight() const [query, setQuery] = useState('') const canSend = !!query.trim() const [showVoiceInput, setShowVoiceInput] = useState(false) const filesStore = useFileStore() - const { handleDragFileEnter, handleDragFileLeave, handleDragFileOver, handleDropFile, handleClipboardPasteFile, isDragActive } = useFile(visionConfig!, false) + const { + handleDragFileEnter, + handleDragFileLeave, + handleDragFileOver, + handleDropFile, + handleClipboardPasteFile, + isDragActive, + } = useFile(visionConfig!, false) const { checkInputsForm } = useCheckInputsForms() const historyRef = useRef(['']) const [currentIndex, setCurrentIndex] = useState(-1) const isComposingRef = useRef(false) const queryRef = useRef('') - const handleQueryChange = useCallback((value: string) => { - queryRef.current = value - setQuery(value) - setTimeout(handleTextareaResize, 0) - }, [handleTextareaResize]) - const resetAcceptedMessage = useCallback((acceptedQuery: string, acceptedFiles: ReturnType<typeof filesStore.getState>['files']) => { - const { files, setFiles } = filesStore.getState() - if (queryRef.current === acceptedQuery) - handleQueryChange('') - if (files === acceptedFiles) - setFiles([]) - }, [filesStore, handleQueryChange]) + const handleQueryChange = useCallback( + (value: string) => { + queryRef.current = value + setQuery(value) + setTimeout(handleTextareaResize, 0) + }, + [handleTextareaResize], + ) + const resetAcceptedMessage = useCallback( + (acceptedQuery: string, acceptedFiles: ReturnType<typeof filesStore.getState>['files']) => { + const { files, setFiles } = filesStore.getState() + if (queryRef.current === acceptedQuery) handleQueryChange('') + if (files === acceptedFiles) setFiles([]) + }, + [filesStore, handleQueryChange], + ) const handleSend = () => { - if (!canSend) - return + if (!canSend) return if (isResponding) { - toast.info(t($ => $['errorMessage.waitForResponse'], { ns: 'appDebug' })) + toast.info(t(($) => $['errorMessage.waitForResponse'], { ns: 'appDebug' })) return } if (onSend) { const { files } = filesStore.getState() - if (files.some(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) { - toast.info(t($ => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' })) + if ( + files.some((item) => item.transferMethod === TransferMethod.local_file && !item.uploadedId) + ) { + toast.info(t(($) => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' })) return } if (checkInputsForm(inputs, inputsForm)) { const sendResult = onSend(query, files) as SendAcceptance if (sendResult instanceof Promise) { - sendResult.then((accepted) => { - if (accepted !== false) - resetAcceptedMessage(query, files) - }).catch(noop) + sendResult + .then((accepted) => { + if (accepted !== false) resetAcceptedMessage(query, files) + }) + .catch(noop) return } - if (sendResult !== false) - resetAcceptedMessage(query, files) + if (sendResult !== false) resetAcceptedMessage(query, files) } } } @@ -128,32 +170,28 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s // sendOnEnter=true (default): Enter sends, Shift+Enter inserts newline // sendOnEnter=false: Shift+Enter sends, Enter inserts newline const isSendCombo = sendOnEnter - ? (e.key === 'Enter' && !e.shiftKey) - : (e.key === 'Enter' && e.shiftKey) + ? e.key === 'Enter' && !e.shiftKey + : e.key === 'Enter' && e.shiftKey if (isSendCombo && !e.nativeEvent.isComposing) { // if isComposing, exit - if (isComposingRef.current) - return + if (isComposingRef.current) return e.preventDefault() setQuery(query.replace(/\n$/, '')) historyRef.current.push(query) setCurrentIndex(historyRef.current.length) handleSend() - } - else if (e.key === 'ArrowUp' && !e.shiftKey && !e.nativeEvent.isComposing && e.metaKey) { + } else if (e.key === 'ArrowUp' && !e.shiftKey && !e.nativeEvent.isComposing && e.metaKey) { // When the cmd + up key is pressed, output the previous element if (currentIndex > 0) { setCurrentIndex(currentIndex - 1) handleQueryChange(historyRef.current[currentIndex - 1]!) } - } - else if (e.key === 'ArrowDown' && !e.shiftKey && !e.nativeEvent.isComposing && e.metaKey) { + } else if (e.key === 'ArrowDown' && !e.shiftKey && !e.nativeEvent.isComposing && e.metaKey) { // When the cmd + down key is pressed, output the next element if (currentIndex < historyRef.current.length - 1) { setCurrentIndex(currentIndex + 1) handleQueryChange(historyRef.current[currentIndex + 1]!) - } - else if (currentIndex === historyRef.current.length - 1) { + } else if (currentIndex === historyRef.current.length - 1) { // If it is the last element, clear the input box setCurrentIndex(historyRef.current.length) handleQueryChange('') @@ -161,41 +199,80 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s } } const handleShowVoiceInput = useCallback(() => { - ;(Recorder as AudioRecorderWithPermission).getPermission().then(() => { - setShowVoiceInput(true) - }, () => { - toast.error(t($ => $['voiceInput.notAllow'], { ns: 'common' })) - }) + ;(Recorder as AudioRecorderWithPermission).getPermission().then( + () => { + setShowVoiceInput(true) + }, + () => { + toast.error(t(($) => $['voiceInput.notAllow'], { ns: 'common' })) + }, + ) }, [t]) - const operation = (<Operation ref={holdSpaceRef} readonly={readonly} fileConfig={visionConfig} speechToTextConfig={speechToTextConfig} onShowVoiceInput={handleShowVoiceInput} onSend={handleSend} sendButtonLabel={sendButtonLabel} sendButtonLoading={sendButtonLoading} disabled={!canSend} theme={theme} />) + const operation = ( + <Operation + ref={holdSpaceRef} + readonly={readonly} + fileConfig={visionConfig} + speechToTextConfig={speechToTextConfig} + onShowVoiceInput={handleShowVoiceInput} + onSend={handleSend} + sendButtonLabel={sendButtonLabel} + sendButtonLoading={sendButtonLoading} + disabled={!canSend} + theme={theme} + /> + ) const shouldShowFooterNotice = footerNotice !== undefined && footerNotice !== null - const shouldShowFooterNoticeTooltip = footerNoticeTooltip !== undefined && footerNoticeTooltip !== null + const shouldShowFooterNoticeTooltip = + footerNoticeTooltip !== undefined && footerNoticeTooltip !== null const footerNoticeText = typeof footerNotice === 'string' ? footerNotice.trim() : '' const footerNoticeAriaLabel = footerNoticeText - ? `${t($ => $['operation.learnMore'], { ns: 'common' })}: ${footerNoticeText}` - : t($ => $['operation.learnMore'], { ns: 'common' }) + ? `${t(($) => $['operation.learnMore'], { ns: 'common' })}: ${footerNoticeText}` + : t(($) => $['operation.learnMore'], { ns: 'common' }) return ( <> - <div className={cn('pointer-events-auto relative z-10 overflow-hidden rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md', isDragActive && 'border border-dashed border-components-option-card-option-selected-border', disabled && 'pointer-events-none border-components-panel-border opacity-50 shadow-none')}> + <div + className={cn( + 'pointer-events-auto relative z-10 overflow-hidden rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md', + isDragActive && + 'border border-dashed border-components-option-card-option-selected-border', + disabled && 'pointer-events-none border-components-panel-border opacity-50 shadow-none', + )} + > <div className="relative max-h-[158px] overflow-x-hidden overflow-y-auto px-[9px] pt-[9px]"> <FileListInChatInput fileConfig={visionConfig!} /> <div ref={wrapperRef} className="flex items-center justify-between"> <div className="relative flex w-full grow items-center"> - <div ref={textValueRef} className="pointer-events-none invisible absolute size-auto p-1 body-lg-regular leading-6 whitespace-pre"> + <div + ref={textValueRef} + className="pointer-events-none invisible absolute size-auto p-1 body-lg-regular leading-6 whitespace-pre" + > {query} </div> <Textarea ref={(ref) => { textareaRef.current = ref ?? undefined }} - className={cn('w-full resize-none bg-transparent p-1 body-lg-regular leading-6 text-text-primary outline-hidden')} - placeholder={!readonly && customPlaceholder?.trim() ? customPlaceholder : decode(t($ => $[readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder'], { ns: 'common', botName }) || '')} + className={cn( + 'w-full resize-none bg-transparent p-1 body-lg-regular leading-6 text-text-primary outline-hidden', + )} + placeholder={ + !readonly && customPlaceholder?.trim() + ? customPlaceholder + : decode( + t( + ($) => + $[readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder'], + { ns: 'common', botName }, + ) || '', + ) + } // Existing chat behavior focuses the composer as soon as it opens. // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus={autoFocus} minRows={1} value={query} - onChange={e => handleQueryChange(e.target.value)} + onChange={(e) => handleQueryChange(e.target.value)} onKeyDown={handleKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} @@ -209,9 +286,14 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s </div> {!isMultipleLine && operation} </div> - {showVoiceInput && (<VoiceInput onCancel={() => setShowVoiceInput(false)} onConverted={text => handleQueryChange(text)} />)} + {showVoiceInput && ( + <VoiceInput + onCancel={() => setShowVoiceInput(false)} + onConverted={(text) => handleQueryChange(text)} + /> + )} </div> - {isMultipleLine && (<div className="px-[9px]">{operation}</div>)} + {isMultipleLine && <div className="px-[9px]">{operation}</div>} </div> {shouldShowFooterNotice && ( <div className="m-1 mt-0 -translate-y-2 rounded-b-[10px] border-r border-b border-l border-components-panel-border-subtle bg-util-colors-indigo-indigo-50 px-2.5 py-2 pt-4"> @@ -232,7 +314,12 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s )} {showFeatureBar && ( <div className="pointer-events-auto"> - <FeatureBar showFileUpload={showFileUpload} disabled={featureBarDisabled} onFeatureBarClick={featureBarReadonly ? noop : onFeatureBarClick} hideEditEntrance={featureBarReadonly} /> + <FeatureBar + showFileUpload={showFileUpload} + disabled={featureBarDisabled} + onFeatureBarClick={featureBarReadonly ? noop : onFeatureBarClick} + hideEditEntrance={featureBarReadonly} + /> </div> )} </> diff --git a/web/app/components/base/chat/chat/chat-input-area/operation.tsx b/web/app/components/base/chat/chat/chat-input-area/operation.tsx index 197b3ac7b8993b..eb80ef85a62f28 100644 --- a/web/app/components/base/chat/chat/chat-input-area/operation.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/operation.tsx @@ -1,8 +1,6 @@ import type { FC, Ref } from 'react' import type { Theme } from '../../embedded-chatbot/theme/theme-context' -import type { - EnableType, -} from '../../types' +import type { EnableType } from '../../types' import type { FileUpload } from '@/app/components/base/features/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' @@ -38,36 +36,26 @@ const Operation: FC<OperationProps> = ({ const { t } = useTranslation() return ( - <div - className={cn( - 'flex shrink-0 items-center justify-end', - )} - > - <div - className="flex items-center pl-1" - ref={ref} - > + <div className={cn('flex shrink-0 items-center justify-end')}> + <div className="flex items-center pl-1" ref={ref}> <div className="flex items-center gap-1"> - {fileConfig?.enabled && <FileUploaderInChatInput readonly={readonly} fileConfig={fileConfig} />} - { - speechToTextConfig?.enabled && ( - <ActionButton - size="l" - aria-label={t($ => $['voiceInput.start'], { ns: 'common' })} - disabled={readonly} - onClick={onShowVoiceInput} - > - <span className="i-ri-mic-line size-5" aria-hidden="true" /> - </ActionButton> - ) - } + {fileConfig?.enabled && ( + <FileUploaderInChatInput readonly={readonly} fileConfig={fileConfig} /> + )} + {speechToTextConfig?.enabled && ( + <ActionButton + size="l" + aria-label={t(($) => $['voiceInput.start'], { ns: 'common' })} + disabled={readonly} + onClick={onShowVoiceInput} + > + <span className="i-ri-mic-line size-5" aria-hidden="true" /> + </ActionButton> + )} </div> <Button - aria-label={sendButtonLabel ? undefined : t($ => $['operation.send'], { ns: 'common' })} - className={cn( - 'ml-3 focus-visible:ring-inset', - sendButtonLabel ? 'px-3' : 'w-8 px-0', - )} + aria-label={sendButtonLabel ? undefined : t(($) => $['operation.send'], { ns: 'common' })} + className={cn('ml-3 focus-visible:ring-inset', sendButtonLabel ? 'px-3' : 'w-8 px-0')} variant="primary" disabled={readonly || disabled} loading={sendButtonLoading} diff --git a/web/app/components/base/chat/chat/chat-log-modals.tsx b/web/app/components/base/chat/chat/chat-log-modals.tsx index d1bf43b81c7ebe..74f715ec61abfe 100644 --- a/web/app/components/base/chat/chat/chat-log-modals.tsx +++ b/web/app/components/base/chat/chat/chat-log-modals.tsx @@ -24,8 +24,7 @@ const ChatLogModals: FC<ChatLogModalsProps> = ({ setShowPromptLogModal, setShowAgentLogModal, }) => { - if (hideLogModal) - return null + if (hideLogModal) return null return ( <> diff --git a/web/app/components/base/chat/chat/check-input-forms-hooks.ts b/web/app/components/base/chat/chat/check-input-forms-hooks.ts index b64574c7a8bdf3..1230b15ccd69ab 100644 --- a/web/app/components/base/chat/chat/check-input-forms-hooks.ts +++ b/web/app/components/base/chat/chat/check-input-forms-hooks.ts @@ -7,37 +7,47 @@ import { TransferMethod } from '@/types/app' export const useCheckInputsForms = () => { const { t } = useTranslation() - const checkInputsForm = useCallback((inputs: Record<string, any>, inputsForm: InputForm[]) => { - let hasEmptyInput = '' - let fileIsUploading = false - const requiredVars = inputsForm.filter(({ required, type }) => required && type !== InputVarType.checkbox) // boolean can be not checked - if (requiredVars?.length) { - requiredVars.forEach(({ variable, label, type }) => { - if (hasEmptyInput) - return - if (fileIsUploading) - return - if (!inputs[variable]) - hasEmptyInput = label as string - if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputs[variable]) { - const files = inputs[variable] - if (Array.isArray(files)) - fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId) - else - fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId - } - }) - } - if (hasEmptyInput) { - toast.error(t($ => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: hasEmptyInput })) - return false - } - if (fileIsUploading) { - toast.info(t($ => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' })) - return - } - return true - }, [t]) + const checkInputsForm = useCallback( + (inputs: Record<string, any>, inputsForm: InputForm[]) => { + let hasEmptyInput = '' + let fileIsUploading = false + const requiredVars = inputsForm.filter( + ({ required, type }) => required && type !== InputVarType.checkbox, + ) // boolean can be not checked + if (requiredVars?.length) { + requiredVars.forEach(({ variable, label, type }) => { + if (hasEmptyInput) return + if (fileIsUploading) return + if (!inputs[variable]) hasEmptyInput = label as string + if ( + (type === InputVarType.singleFile || type === InputVarType.multiFiles) && + inputs[variable] + ) { + const files = inputs[variable] + if (Array.isArray(files)) + fileIsUploading = files.find( + (item) => item.transferMethod === TransferMethod.local_file && !item.uploadedId, + ) + else + fileIsUploading = + files.transferMethod === TransferMethod.local_file && !files.uploadedId + } + }) + } + if (hasEmptyInput) { + toast.error( + t(($) => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: hasEmptyInput }), + ) + return false + } + if (fileIsUploading) { + toast.info(t(($) => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' })) + return + } + return true + }, + [t], + ) return { checkInputsForm, } diff --git a/web/app/components/base/chat/chat/citation/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/citation/__tests__/index.spec.tsx index affd473538b68c..a95845bce67ec0 100644 --- a/web/app/components/base/chat/chat/citation/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/citation/__tests__/index.spec.tsx @@ -5,14 +5,17 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import Citation from '../index' vi.mock('../popup', () => ({ - default: ({ data, showHitInfo }: { data: { documentName: string }, showHitInfo?: boolean }) => ( + default: ({ data, showHitInfo }: { data: { documentName: string }; showHitInfo?: boolean }) => ( <div data-testid="popup" data-show-hit-info={String(!!showHitInfo)}> {data.documentName} </div> ), })) -const originalClientWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') +const originalClientWidthDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'clientWidth', +) type ClientWidthConfig = { container: number @@ -23,10 +26,12 @@ const mockClientWidths = ({ container, item }: ClientWidthConfig) => { Object.defineProperty(HTMLElement.prototype, 'clientWidth', { get() { const el = this as HTMLElement - if (el.className?.includes?.('chat-answer-container') || el.className?.includes?.('my-custom-container')) + if ( + el.className?.includes?.('chat-answer-container') || + el.className?.includes?.('my-custom-container') + ) return container - if (el.dataset?.testid === 'citation-measurement-item') - return item + if (el.dataset?.testid === 'citation-measurement-item') return item return 0 }, configurable: true, @@ -84,10 +89,11 @@ describe('Citation', () => { mockClientWidths({ container: 500, item: 50 }) setupContainer() render( - <Citation data={[ - makeCitationItem({ document_id: 'doc-1', document_name: 'Alpha' }), - makeCitationItem({ document_id: 'doc-2', document_name: 'Beta' }), - ]} + <Citation + data={[ + makeCitationItem({ document_id: 'doc-1', document_name: 'Alpha' }), + makeCitationItem({ document_id: 'doc-2', document_name: 'Beta' }), + ]} />, ) expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(2) @@ -104,10 +110,11 @@ describe('Citation', () => { mockClientWidths({ container: 840, item: 50 }) setupContainer() render( - <Citation data={[ - makeCitationItem({ document_id: 'doc-1' }), - makeCitationItem({ document_id: 'doc-2' }), - ]} + <Citation + data={[ + makeCitationItem({ document_id: 'doc-1' }), + makeCitationItem({ document_id: 'doc-2' }), + ]} />, ) expect(screen.getAllByTestId('popup')).toHaveLength(2) @@ -148,18 +155,18 @@ describe('Citation', () => { showHitInfo={true} />, ) - screen.getAllByTestId('popup').forEach(p => - expect(p).toHaveAttribute('data-show-hit-info', 'true'), - ) + screen + .getAllByTestId('popup') + .forEach((p) => expect(p).toHaveAttribute('data-show-hit-info', 'true')) }) it('should forward showHitInfo=false when prop is omitted', () => { mockClientWidths({ container: 840, item: 50 }) setupContainer() render(<Citation data={[makeCitationItem({ document_id: 'doc-1' })]} />) - screen.getAllByTestId('popup').forEach(p => - expect(p).toHaveAttribute('data-show-hit-info', 'false'), - ) + screen + .getAllByTestId('popup') + .forEach((p) => expect(p).toHaveAttribute('data-show-hit-info', 'false')) }) }) @@ -168,10 +175,11 @@ describe('Citation', () => { mockClientWidths({ container: 500, item: 50 }) setupContainer() render( - <Citation data={[ - makeCitationItem({ document_id: 'shared', segment_id: 'seg-1' }), - makeCitationItem({ document_id: 'shared', segment_id: 'seg-2' }), - ]} + <Citation + data={[ + makeCitationItem({ document_id: 'shared', segment_id: 'seg-1' }), + makeCitationItem({ document_id: 'shared', segment_id: 'seg-2' }), + ]} />, ) expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1) @@ -181,11 +189,12 @@ describe('Citation', () => { mockClientWidths({ container: 500, item: 50 }) setupContainer() render( - <Citation data={[ - makeCitationItem({ document_id: 'doc-a' }), - makeCitationItem({ document_id: 'doc-b' }), - makeCitationItem({ document_id: 'doc-c' }), - ]} + <Citation + data={[ + makeCitationItem({ document_id: 'doc-a' }), + makeCitationItem({ document_id: 'doc-b' }), + makeCitationItem({ document_id: 'doc-c' }), + ]} />, ) expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(3) @@ -195,11 +204,12 @@ describe('Citation', () => { mockClientWidths({ container: 500, item: 50 }) setupContainer() render( - <Citation data={[ - makeCitationItem({ document_id: 'doc-x', segment_id: 'seg-1' }), - makeCitationItem({ document_id: 'doc-y', segment_id: 'seg-2' }), - makeCitationItem({ document_id: 'doc-x', segment_id: 'seg-3' }), - ]} + <Citation + data={[ + makeCitationItem({ document_id: 'doc-x', segment_id: 'seg-1' }), + makeCitationItem({ document_id: 'doc-y', segment_id: 'seg-2' }), + makeCitationItem({ document_id: 'doc-x', segment_id: 'seg-3' }), + ]} />, ) expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(2) @@ -212,11 +222,12 @@ describe('Citation', () => { mockClientWidths({ container: 840, item: 50 }) setupContainer() render( - <Citation data={[ - makeCitationItem({ document_id: 'doc-1' }), - makeCitationItem({ document_id: 'doc-2' }), - makeCitationItem({ document_id: 'doc-3' }), - ]} + <Citation + data={[ + makeCitationItem({ document_id: 'doc-1' }), + makeCitationItem({ document_id: 'doc-2' }), + makeCitationItem({ document_id: 'doc-3' }), + ]} />, ) expect(screen.getAllByTestId('popup')).toHaveLength(3) @@ -233,10 +244,11 @@ describe('Citation', () => { mockClientWidths({ container: 140, item: 80 }) setupContainer() render( - <Citation data={[ - makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }), - makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }), - ]} + <Citation + data={[ + makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }), + makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }), + ]} />, ) expect(screen.getByTestId('citation-more-toggle')).toBeInTheDocument() @@ -253,11 +265,12 @@ describe('Citation', () => { mockClientWidths({ container: 240, item: 80 }) setupContainer() render( - <Citation data={[ - makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }), - makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }), - makeCitationItem({ document_id: 'doc-3', document_name: 'Doc C' }), - ]} + <Citation + data={[ + makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }), + makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }), + makeCitationItem({ document_id: 'doc-3', document_name: 'Doc C' }), + ]} />, ) expect(screen.getByTestId('citation-more-toggle')).toBeInTheDocument() @@ -274,11 +287,12 @@ describe('Citation', () => { mockClientWidths({ container: 140, item: 80 }) setupContainer() render( - <Citation data={[ - makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }), - makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }), - makeCitationItem({ document_id: 'doc-3', document_name: 'Doc C' }), - ]} + <Citation + data={[ + makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }), + makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }), + makeCitationItem({ document_id: 'doc-3', document_name: 'Doc C' }), + ]} />, ) return screen.getByTestId('citation-more-toggle') @@ -342,11 +356,12 @@ describe('Citation', () => { mockClientWidths({ container: 500, item: 50 }) setupContainer() render( - <Citation data={[ - makeCitationItem({ document_id: 'only', segment_id: 's1' }), - makeCitationItem({ document_id: 'only', segment_id: 's2' }), - makeCitationItem({ document_id: 'only', segment_id: 's3' }), - ]} + <Citation + data={[ + makeCitationItem({ document_id: 'only', segment_id: 's1' }), + makeCitationItem({ document_id: 'only', segment_id: 's2' }), + makeCitationItem({ document_id: 'only', segment_id: 's3' }), + ]} />, ) expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1) @@ -356,7 +371,8 @@ describe('Citation', () => { mockClientWidths({ container: 5000, item: 50 }) setupContainer() const data = Array.from({ length: 20 }, (_, i) => - makeCitationItem({ document_id: `doc-${i}`, document_name: `Document ${i}` })) + makeCitationItem({ document_id: `doc-${i}`, document_name: `Document ${i}` }), + ) expect(() => render(<Citation data={data} />)).not.toThrow() expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(20) }) diff --git a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx index d861014305c8c8..38d758c0989b98 100644 --- a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx +++ b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx @@ -2,7 +2,6 @@ import type { Resources } from '../index' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useDocumentDownload } from '@/service/knowledge/use-document' - import { downloadUrl } from '@/utils/download' import Popup from '../popup' @@ -25,8 +24,10 @@ vi.mock('../progress-tooltip', () => ({ })) vi.mock('../tooltip', () => ({ - default: ({ text, data }: { text: string, data: number | string }) => ( - <div data-testid="citation-tooltip" data-text={text}>{data}</div> + default: ({ text, data }: { text: string; data: number | string }) => ( + <div data-testid="citation-tooltip" data-text={text}> + {data} + </div> ), })) @@ -34,21 +35,24 @@ const mockDownloadDocument = vi.fn() const mockUseDocumentDownload = vi.mocked(useDocumentDownload) const mockDownloadUrl = vi.mocked(downloadUrl) -const makeSource = (overrides: Partial<Resources['sources'][number]> = {}): Resources['sources'][number] => ({ - dataset_id: 'ds-1', - dataset_name: 'Test Dataset', - document_id: 'doc-1', - segment_id: 'seg-1', - segment_position: 1, - content: 'Source content here', - word_count: 120, - hit_count: 3, - index_node_hash: 'abcdef1234567', - score: 0.85, - data_source_type: 'upload_file', - document_name: 'test.pdf', - ...overrides, -} as Resources['sources'][number]) +const makeSource = ( + overrides: Partial<Resources['sources'][number]> = {}, +): Resources['sources'][number] => + ({ + dataset_id: 'ds-1', + dataset_name: 'Test Dataset', + document_id: 'doc-1', + segment_id: 'seg-1', + segment_position: 1, + content: 'Source content here', + word_count: 120, + hit_count: 3, + index_node_hash: 'abcdef1234567', + score: 0.85, + data_source_type: 'upload_file', + document_name: 'test.pdf', + ...overrides, + }) as Resources['sources'][number] const makeData = (overrides: Partial<Resources> = {}): Resources => ({ documentId: 'doc-1', @@ -85,7 +89,9 @@ describe('Popup', () => { }) it('should pass the extracted file extension to FileIcon for non-notion sources', () => { - render(<Popup data={makeData({ documentName: 'report.pdf', dataSourceType: 'upload_file' })} />) + render( + <Popup data={makeData({ documentName: 'report.pdf', dataSourceType: 'upload_file' })} />, + ) expect(screen.getAllByTestId('file-icon')[0])!.toHaveAttribute('data-type', 'pdf') }) @@ -95,7 +101,9 @@ describe('Popup', () => { }) it('should pass empty string as fileType when document has no extension', () => { - render(<Popup data={makeData({ documentName: 'nodotfile', dataSourceType: 'upload_file' })} />) + render( + <Popup data={makeData({ documentName: 'nodotfile', dataSourceType: 'upload_file' })} />, + ) expect(screen.getAllByTestId('file-icon')[0])!.toHaveAttribute('data-type', '') }) @@ -150,10 +158,11 @@ describe('Popup', () => { it('should render download button in header for file dataSourceType with dataset_id', async () => { const user = userEvent.setup() render( - <Popup data={makeData({ - dataSourceType: 'file', - sources: [makeSource({ data_source_type: 'file', dataset_id: 'ds-1' })], - })} + <Popup + data={makeData({ + dataSourceType: 'file', + sources: [makeSource({ data_source_type: 'file', dataset_id: 'ds-1' })], + })} />, ) @@ -165,11 +174,12 @@ describe('Popup', () => { it('should render plain document name in header (no button) for notion type', async () => { const user = userEvent.setup() render( - <Popup data={makeData({ - documentName: 'Notion Doc', - dataSourceType: 'notion', - sources: [makeSource({ dataset_id: 'ds-1' })], - })} + <Popup + data={makeData({ + documentName: 'Notion Doc', + dataSourceType: 'notion', + sources: [makeSource({ dataset_id: 'ds-1' })], + })} />, ) @@ -181,10 +191,11 @@ describe('Popup', () => { it('should render plain document name in header when dataset_id is absent', async () => { const user = userEvent.setup() render( - <Popup data={makeData({ - dataSourceType: 'upload_file', - sources: [makeSource({ dataset_id: '' })], - })} + <Popup + data={makeData({ + dataSourceType: 'upload_file', + sources: [makeSource({ dataset_id: '' })], + })} />, ) @@ -210,7 +221,9 @@ describe('Popup', () => { describe('Source Items', () => { it('should render one source item per source entry', async () => { const user = userEvent.setup() - render(<Popup data={makeData({ sources: [makeSource(), makeSource({ segment_id: 'seg-2' })] })} />) + render( + <Popup data={makeData({ sources: [makeSource(), makeSource({ segment_id: 'seg-2' })] })} />, + ) await openPopup(user) @@ -219,7 +232,9 @@ describe('Popup', () => { it('should render source content text', async () => { const user = userEvent.setup() - render(<Popup data={makeData({ sources: [makeSource({ content: 'Unique content text' })] })} />) + render( + <Popup data={makeData({ sources: [makeSource({ content: 'Unique content text' })] })} />, + ) await openPopup(user) @@ -249,9 +264,14 @@ describe('Popup', () => { it('should render a divider between multiple sources', async () => { const user = userEvent.setup() render( - <Popup data={makeData({ - sources: [makeSource(), makeSource({ segment_id: 'seg-2' }), makeSource({ segment_id: 'seg-3' })], - })} + <Popup + data={makeData({ + sources: [ + makeSource(), + makeSource({ segment_id: 'seg-2' }), + makeSource({ segment_id: 'seg-3' }), + ], + })} />, ) @@ -272,14 +292,15 @@ describe('Popup', () => { it('should render exactly n-1 dividers for n sources', async () => { const user = userEvent.setup() render( - <Popup data={makeData({ - sources: [ - makeSource({ segment_id: 's1' }), - makeSource({ segment_id: 's2' }), - makeSource({ segment_id: 's3' }), - makeSource({ segment_id: 's4' }), - ], - })} + <Popup + data={makeData({ + sources: [ + makeSource({ segment_id: 's1' }), + makeSource({ segment_id: 's2' }), + makeSource({ segment_id: 's3' }), + makeSource({ segment_id: 's4' }), + ], + })} />, ) @@ -380,7 +401,9 @@ describe('Popup', () => { it('should render ProgressTooltip when source score is greater than 0', async () => { const user = userEvent.setup() - render(<Popup data={makeData({ sources: [makeSource({ score: 0.9 })] })} showHitInfo={true} />) + render( + <Popup data={makeData({ sources: [makeSource({ score: 0.9 })] })} showHitInfo={true} />, + ) await openPopup(user) @@ -398,7 +421,9 @@ describe('Popup', () => { it('should pass score rounded-sm to 2 decimal places to ProgressTooltip', async () => { const user = userEvent.setup() - render(<Popup data={makeData({ sources: [makeSource({ score: 0.856 })] })} showHitInfo={true} />) + render( + <Popup data={makeData({ sources: [makeSource({ score: 0.856 })] })} showHitInfo={true} />, + ) await openPopup(user) @@ -407,7 +432,12 @@ describe('Popup', () => { it('should pass word_count to the characters Tooltip', async () => { const user = userEvent.setup() - render(<Popup data={makeData({ sources: [makeSource({ word_count: 250 })] })} showHitInfo={true} />) + render( + <Popup + data={makeData({ sources: [makeSource({ word_count: 250 })] })} + showHitInfo={true} + />, + ) await openPopup(user) @@ -417,7 +447,9 @@ describe('Popup', () => { it('should pass hit_count to the hitCount Tooltip', async () => { const user = userEvent.setup() - render(<Popup data={makeData({ sources: [makeSource({ hit_count: 7 })] })} showHitInfo={true} />) + render( + <Popup data={makeData({ sources: [makeSource({ hit_count: 7 })] })} showHitInfo={true} />, + ) await openPopup(user) @@ -427,7 +459,12 @@ describe('Popup', () => { it('should pass truncated index_node_hash (first 7 chars) to vectorHash Tooltip', async () => { const user = userEvent.setup() - render(<Popup data={makeData({ sources: [makeSource({ index_node_hash: 'abcdef1234567' })] })} showHitInfo={true} />) + render( + <Popup + data={makeData({ sources: [makeSource({ index_node_hash: 'abcdef1234567' })] })} + showHitInfo={true} + />, + ) await openPopup(user) @@ -462,8 +499,14 @@ describe('Popup', () => { await user.click(getDownloadButton()) await waitFor(() => { - expect(mockDownloadDocument).toHaveBeenCalledWith({ datasetId: 'ds-1', documentId: 'doc-1' }) - expect(mockDownloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/file.pdf', fileName: 'report.pdf' }) + expect(mockDownloadDocument).toHaveBeenCalledWith({ + datasetId: 'ds-1', + documentId: 'doc-1', + }) + expect(mockDownloadUrl).toHaveBeenCalledWith({ + url: 'https://example.com/file.pdf', + fileName: 'report.pdf', + }) }) }) @@ -482,10 +525,11 @@ describe('Popup', () => { it('should not call downloadDocument when dataSourceType is not upload_file or file', async () => { const user = userEvent.setup() render( - <Popup data={makeData({ - dataSourceType: 'notion', - sources: [makeSource({ dataset_id: 'ds-1' })], - })} + <Popup + data={makeData({ + dataSourceType: 'notion', + sources: [makeSource({ dataset_id: 'ds-1' })], + })} />, ) @@ -513,11 +557,12 @@ describe('Popup', () => { mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' }) const user = userEvent.setup() render( - <Popup data={makeData({ - documentId: 'primary-doc-id', - dataSourceType: 'upload_file', - sources: [makeSource({ document_id: 'fallback-doc-id', dataset_id: 'ds-1' })], - })} + <Popup + data={makeData({ + documentId: 'primary-doc-id', + dataSourceType: 'upload_file', + sources: [makeSource({ document_id: 'fallback-doc-id', dataset_id: 'ds-1' })], + })} />, ) @@ -525,7 +570,10 @@ describe('Popup', () => { await user.click(getDownloadButton()) await waitFor(() => { - expect(mockDownloadDocument).toHaveBeenCalledWith({ datasetId: 'ds-1', documentId: 'primary-doc-id' }) + expect(mockDownloadDocument).toHaveBeenCalledWith({ + datasetId: 'ds-1', + documentId: 'primary-doc-id', + }) }) }) @@ -533,10 +581,11 @@ describe('Popup', () => { mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' }) const user = userEvent.setup() render( - <Popup data={makeData({ - dataSourceType: 'file', - sources: [makeSource({ data_source_type: 'file', dataset_id: 'ds-1' })], - })} + <Popup + data={makeData({ + dataSourceType: 'file', + sources: [makeSource({ data_source_type: 'file', dataset_id: 'ds-1' })], + })} />, ) @@ -552,11 +601,12 @@ describe('Popup', () => { it('should not call downloadDocument when both data.documentId and sources[0].document_id are empty', async () => { const user = userEvent.setup() render( - <Popup data={makeData({ - documentId: '', - dataSourceType: 'upload_file', - sources: [makeSource({ document_id: '', dataset_id: 'ds-1' })], - })} + <Popup + data={makeData({ + documentId: '', + dataSourceType: 'upload_file', + sources: [makeSource({ document_id: '', dataset_id: 'ds-1' })], + })} />, ) @@ -639,7 +689,7 @@ describe('Popup', () => { expect(screen.getByTestId('popup-source-item'))!.toBeInTheDocument() }) - it('should fallback to \'doc\' when all ids are missing', async () => { + it("should fallback to 'doc' when all ids are missing", async () => { const user = userEvent.setup() render( <Popup diff --git a/web/app/components/base/chat/chat/citation/__tests__/tooltip.spec.tsx b/web/app/components/base/chat/chat/citation/__tests__/tooltip.spec.tsx index 58a3c5c6540f1a..27a286de916c02 100644 --- a/web/app/components/base/chat/chat/citation/__tests__/tooltip.spec.tsx +++ b/web/app/components/base/chat/chat/citation/__tests__/tooltip.spec.tsx @@ -3,8 +3,11 @@ import userEvent from '@testing-library/user-event' import { describe, expect, it } from 'vitest' import Tooltip from '../tooltip' -const renderTooltip = (data: number | string = 42, text = 'Characters', icon = <span data-testid="mock-icon">icon</span>) => - render(<Tooltip data={data} text={text} icon={icon} />) +const renderTooltip = ( + data: number | string = 42, + text = 'Characters', + icon = <span data-testid="mock-icon">icon</span>, +) => render(<Tooltip data={data} text={text} icon={icon} />) describe('Tooltip', () => { describe('Rendering', () => { diff --git a/web/app/components/base/chat/chat/citation/index.tsx b/web/app/components/base/chat/chat/citation/index.tsx index 726783513a3dd5..ec51c13f417670 100644 --- a/web/app/components/base/chat/chat/citation/index.tsx +++ b/web/app/components/base/chat/chat/citation/index.tsx @@ -25,26 +25,29 @@ const Citation: FC<CitationProps> = ({ const elesRef = useRef<HTMLDivElement[]>([]) const [limitNumberInOneLine, setLimitNumberInOneLine] = useState(0) const [showMore, setShowMore] = useState(false) - const resources = useMemo(() => data.reduce((prev: Resources[], next) => { - const documentId = next.document_id - const documentName = next.document_name - const dataSourceType = next.data_source_type - const documentIndex = prev.findIndex(i => i.documentId === documentId) + const resources = useMemo( + () => + data.reduce((prev: Resources[], next) => { + const documentId = next.document_id + const documentName = next.document_name + const dataSourceType = next.data_source_type + const documentIndex = prev.findIndex((i) => i.documentId === documentId) - if (documentIndex > -1) { - prev[documentIndex]!.sources.push(next) - } - else { - prev.push({ - documentId, - documentName, - dataSourceType, - sources: [next], - }) - } + if (documentIndex > -1) { + prev[documentIndex]!.sources.push(next) + } else { + prev.push({ + documentId, + documentName, + dataSourceType, + sources: [next], + }) + } - return prev - }, []), [data]) + return prev + }, []), + [data], + ) useEffect(() => { const containerWidth = document.querySelector(`.${containerClassName}`)!.clientWidth - 40 @@ -56,14 +59,11 @@ const Citation: FC<CitationProps> = ({ if (totalWidth + i * 4 > containerWidth) { totalWidth -= elesRef.current[i]!.clientWidth - if (totalWidth + 34 > containerWidth) - limit = i - 1 - else - limit = i + if (totalWidth + 34 > containerWidth) limit = i - 1 + else limit = i break - } - else { + } else { limit = i + 1 } } @@ -75,48 +75,44 @@ const Citation: FC<CitationProps> = ({ return ( <div className="mt-3 -mb-1"> - <div data-testid="citation-title" className="mb-2 flex items-center system-xs-medium text-text-tertiary"> - {t($ => $['chat.citation.title'], { ns: 'common' })} + <div + data-testid="citation-title" + className="mb-2 flex items-center system-xs-medium text-text-tertiary" + > + {t(($) => $['chat.citation.title'], { ns: 'common' })} <div className="ml-2 h-px grow bg-divider-regular" /> </div> <div className="relative flex flex-wrap"> - { - resources.map((res, index) => ( - <div - key={res.documentId} - data-testid="citation-measurement-item" - className="absolute top-0 left-0 -z-10 mr-1 mb-1 h-7 w-auto max-w-[240px] pr-2 pl-7 text-xs whitespace-nowrap opacity-0" - ref={(ele: HTMLDivElement | null) => { elesRef.current[index] = ele! }} - > - {res.documentName} - </div> - )) - } - { - resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map(res => ( - <div key={res.documentId} className="mr-1 mb-1 cursor-pointer"> - <Popup - data={res} - showHitInfo={showHitInfo} - /> - </div> - )) - } - { - limitNumberInOneLine < resourcesLength && ( - <div - data-testid="citation-more-toggle" - className="flex h-7 cursor-pointer items-center rounded-lg bg-components-panel-bg px-2 system-xs-medium text-text-tertiary" - onClick={() => setShowMore(v => !v)} - > - { - !showMore - ? `+ ${resourcesLength - limitNumberInOneLine}` - : <div className="i-ri-arrow-down-s-line size-4 rotate-180 text-text-tertiary" /> - } - </div> - ) - } + {resources.map((res, index) => ( + <div + key={res.documentId} + data-testid="citation-measurement-item" + className="absolute top-0 left-0 -z-10 mr-1 mb-1 h-7 w-auto max-w-[240px] pr-2 pl-7 text-xs whitespace-nowrap opacity-0" + ref={(ele: HTMLDivElement | null) => { + elesRef.current[index] = ele! + }} + > + {res.documentName} + </div> + ))} + {resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map((res) => ( + <div key={res.documentId} className="mr-1 mb-1 cursor-pointer"> + <Popup data={res} showHitInfo={showHitInfo} /> + </div> + ))} + {limitNumberInOneLine < resourcesLength && ( + <div + data-testid="citation-more-toggle" + className="flex h-7 cursor-pointer items-center rounded-lg bg-components-panel-bg px-2 system-xs-medium text-text-tertiary" + onClick={() => setShowMore((v) => !v)} + > + {!showMore ? ( + `+ ${resourcesLength - limitNumberInOneLine}` + ) : ( + <div className="i-ri-arrow-down-s-line size-4 rotate-180 text-text-tertiary" /> + )} + </div> + )} </div> </div> ) diff --git a/web/app/components/base/chat/chat/citation/popup.tsx b/web/app/components/base/chat/chat/citation/popup.tsx index f92b82d1e668de..ed915df25ef496 100644 --- a/web/app/components/base/chat/chat/citation/popup.tsx +++ b/web/app/components/base/chat/chat/citation/popup.tsx @@ -15,15 +15,11 @@ type PopupProps = { showHitInfo?: boolean } -const Popup: FC<PopupProps> = ({ - data, - showHitInfo = false, -}) => { +const Popup: FC<PopupProps> = ({ data, showHitInfo = false }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) - const fileType = data.dataSourceType !== 'notion' - ? (/\.([^.]*)$/.exec(data.documentName)?.[1] || '') - : 'notion' + const fileType = + data.dataSourceType !== 'notion' ? /\.([^.]*)$/.exec(data.documentName)?.[1] || '' : 'notion' const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload() @@ -34,27 +30,25 @@ const Popup: FC<PopupProps> = ({ const isUploadFile = data.dataSourceType === 'upload_file' || data.dataSourceType === 'file' const datasetId = data.sources?.[0]?.dataset_id const documentId = data.documentId || data.sources?.[0]?.document_id - if (!isUploadFile || !datasetId || !documentId || isDownloading) - return + if (!isUploadFile || !datasetId || !documentId || isDownloading) return const res = await downloadDocument({ datasetId, documentId }) - if (res?.url) - downloadUrl({ url: res.url, fileName: data.documentName }) + if (res?.url) downloadUrl({ url: res.url, fileName: data.documentName }) } return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton={false} - render={( - <div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2"> + render={ + <div + data-testid="popup-trigger" + className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2" + > <FileIcon type={fileType} className="mr-1 size-4 shrink-0" /> <div className="truncate text-xs text-text-tertiary">{data.documentName}</div> </div> - )} + } /> <PopoverContent placement="top-start" @@ -62,95 +56,124 @@ const Popup: FC<PopupProps> = ({ alignOffset={-2} popupClassName="border-none bg-transparent shadow-none" > - <div data-testid="popup-content" className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]"> + <div + data-testid="popup-content" + className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]" + > <div className="px-4 pt-3 pb-2"> <div className="flex h-[18px] items-center"> <FileIcon type={fileType} className="mr-1 size-4 shrink-0" /> <div className="truncate system-xs-medium text-text-tertiary"> - {(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id - ? ( - <button - type="button" - className="cursor-pointer truncate border-none bg-transparent p-0 text-left text-text-tertiary hover:underline" - onClick={handleDownloadUploadFile} - disabled={isDownloading} - > - {data.documentName} - </button> - ) - : data.documentName} + {(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && + !!data.sources?.[0]?.dataset_id ? ( + <button + type="button" + className="cursor-pointer truncate border-none bg-transparent p-0 text-left text-text-tertiary hover:underline" + onClick={handleDownloadUploadFile} + disabled={isDownloading} + > + {data.documentName} + </button> + ) : ( + data.documentName + )} </div> </div> </div> <div className="max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5"> <div className="w-full"> - { - data.sources.map((source, index) => { - const itemKey = source.document_id - ? `${source.document_id}-${source.segment_position ?? index}` - : source.index_node_hash ?? `${data.documentId ?? 'doc'}-${index}` + {data.sources.map((source, index) => { + const itemKey = source.document_id + ? `${source.document_id}-${source.segment_position ?? index}` + : (source.index_node_hash ?? `${data.documentId ?? 'doc'}-${index}`) - return ( - <Fragment key={itemKey}> - <div data-testid="popup-source-item" className="group py-3"> - <div className="mb-2 flex items-center justify-between"> - <div className="flex h-5 items-center rounded-md border border-divider-subtle px-1.5"> - {/* replaced svg component with tailwind icon class per lint rule */} - <i className="mr-0.5 i-custom-vender-line-general-hash-02 size-3 text-text-quaternary" aria-hidden /> - <div data-testid="popup-segment-position" className="text-[11px] font-medium text-text-tertiary"> - {source.segment_position || index + 1} - </div> + return ( + <Fragment key={itemKey}> + <div data-testid="popup-source-item" className="group py-3"> + <div className="mb-2 flex items-center justify-between"> + <div className="flex h-5 items-center rounded-md border border-divider-subtle px-1.5"> + {/* replaced svg component with tailwind icon class per lint rule */} + <i + className="mr-0.5 i-custom-vender-line-general-hash-02 size-3 text-text-quaternary" + aria-hidden + /> + <div + data-testid="popup-segment-position" + className="text-[11px] font-medium text-text-tertiary" + > + {source.segment_position || index + 1} </div> - { - showHitInfo && ( - <Link - data-testid="popup-dataset-link" - href={`/datasets/${source.dataset_id}/documents/${source.document_id}`} - className="hidden h-[18px] items-center text-xs text-text-accent group-hover:flex" - > - {t($ => $['chat.citation.linkToDataset'], { ns: 'common' })} - <i className="ml-1 i-custom-vender-line-arrows-arrow-up-right size-3" aria-hidden /> - </Link> - ) - } </div> - <div data-testid="popup-source-content" className="text-[13px] wrap-break-word text-text-secondary">{source.content}</div> - { - showHitInfo && ( - <div data-testid="popup-hit-info" className="mt-2 flex flex-wrap items-center system-xs-medium text-text-quaternary"> - <Tooltip - text={t($ => $['chat.citation.characters'], { ns: 'common' })} - data={source.word_count} - icon={<i className="mr-1 i-custom-vender-line-editor-type-square size-3" aria-hidden />} + {showHitInfo && ( + <Link + data-testid="popup-dataset-link" + href={`/datasets/${source.dataset_id}/documents/${source.document_id}`} + className="hidden h-[18px] items-center text-xs text-text-accent group-hover:flex" + > + {t(($) => $['chat.citation.linkToDataset'], { ns: 'common' })} + <i + className="ml-1 i-custom-vender-line-arrows-arrow-up-right size-3" + aria-hidden + /> + </Link> + )} + </div> + <div + data-testid="popup-source-content" + className="text-[13px] wrap-break-word text-text-secondary" + > + {source.content} + </div> + {showHitInfo && ( + <div + data-testid="popup-hit-info" + className="mt-2 flex flex-wrap items-center system-xs-medium text-text-quaternary" + > + <Tooltip + text={t(($) => $['chat.citation.characters'], { ns: 'common' })} + data={source.word_count} + icon={ + <i + className="mr-1 i-custom-vender-line-editor-type-square size-3" + aria-hidden /> - <Tooltip - text={t($ => $['chat.citation.hitCount'], { ns: 'common' })} - data={source.hit_count} - icon={<i className="mr-1 i-custom-vender-line-general-target-04 size-3" aria-hidden />} + } + /> + <Tooltip + text={t(($) => $['chat.citation.hitCount'], { ns: 'common' })} + data={source.hit_count} + icon={ + <i + className="mr-1 i-custom-vender-line-general-target-04 size-3" + aria-hidden /> - <Tooltip - text={t($ => $['chat.citation.vectorHash'], { ns: 'common' })} - data={source.index_node_hash?.substring(0, 7)} - icon={<i className="mr-1 i-custom-vender-line-editor-bezier-curve-03 size-3" aria-hidden />} + } + /> + <Tooltip + text={t(($) => $['chat.citation.vectorHash'], { ns: 'common' })} + data={source.index_node_hash?.substring(0, 7)} + icon={ + <i + className="mr-1 i-custom-vender-line-editor-bezier-curve-03 size-3" + aria-hidden /> - { - !!source.score && ( - <ProgressTooltip data={Number(source.score.toFixed(2))} /> - ) - } - </div> - ) - } - </div> - { - index !== data.sources.length - 1 && ( - <div data-testid="popup-source-divider" className="my-1 h-px bg-divider-regular" /> - ) - } - </Fragment> - ) - }) - } + } + /> + {!!source.score && ( + <ProgressTooltip data={Number(source.score.toFixed(2))} /> + )} + </div> + )} + </div> + {index !== data.sources.length - 1 && ( + <div + data-testid="popup-source-divider" + className="my-1 h-px bg-divider-regular" + /> + )} + </Fragment> + ) + })} </div> </div> </div> diff --git a/web/app/components/base/chat/chat/citation/progress-tooltip.tsx b/web/app/components/base/chat/chat/citation/progress-tooltip.tsx index 31ff84dcce873b..1da46e91e26c65 100644 --- a/web/app/components/base/chat/chat/citation/progress-tooltip.tsx +++ b/web/app/components/base/chat/chat/citation/progress-tooltip.tsx @@ -1,18 +1,12 @@ import type { FC } from 'react' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' type ProgressTooltipProps = { data: number } -const ProgressTooltip: FC<ProgressTooltipProps> = ({ - data, -}) => { +const ProgressTooltip: FC<ProgressTooltipProps> = ({ data }) => { const { t } = useTranslation() return ( @@ -26,8 +20,7 @@ const ProgressTooltip: FC<ProgressTooltipProps> = ({ data-testid="progress-bar-fill" className="h-full bg-components-progress-gray-progress" style={{ width: `${data * 100}%` }} - > - </div> + ></div> </div> {data} </TooltipTrigger> @@ -36,9 +29,7 @@ const ProgressTooltip: FC<ProgressTooltipProps> = ({ placement="top-start" className="rounded-lg bg-components-tooltip-bg p-3 system-xs-medium text-text-quaternary shadow-lg" > - {t($ => $['chat.citation.hitScore'], { ns: 'common' })} - {' '} - {data} + {t(($) => $['chat.citation.hitScore'], { ns: 'common' })} {data} </TooltipContent> </Tooltip> ) diff --git a/web/app/components/base/chat/chat/citation/tooltip.tsx b/web/app/components/base/chat/chat/citation/tooltip.tsx index f3460abd22c155..941a8989d9c58b 100644 --- a/web/app/components/base/chat/chat/citation/tooltip.tsx +++ b/web/app/components/base/chat/chat/citation/tooltip.tsx @@ -1,9 +1,5 @@ import type { FC } from 'react' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import * as React from 'react' type CitationTooltipProps = { @@ -12,11 +8,7 @@ type CitationTooltipProps = { icon: React.ReactNode } -const CitationTooltip: FC<CitationTooltipProps> = ({ - data, - text, - icon, -}) => { +const CitationTooltip: FC<CitationTooltipProps> = ({ data, text, icon }) => { return ( <Tooltip> <TooltipTrigger @@ -31,9 +23,7 @@ const CitationTooltip: FC<CitationTooltipProps> = ({ placement="top-start" className="rounded-lg bg-components-tooltip-bg p-3 system-xs-medium text-text-quaternary shadow-lg" > - {text} - {' '} - {data} + {text} {data} </TooltipContent> </Tooltip> ) diff --git a/web/app/components/base/chat/chat/content-switch.tsx b/web/app/components/base/chat/chat/content-switch.tsx index ee735c61fd9956..b56d5655e69836 100644 --- a/web/app/components/base/chat/chat/content-switch.tsx +++ b/web/app/components/base/chat/chat/content-switch.tsx @@ -14,7 +14,9 @@ export default function ContentSwitch({ switchSibling: (direction: 'prev' | 'next') => void }) { return ( - count && count > 1 && currentIndex !== undefined && ( + count && + count > 1 && + currentIndex !== undefined && ( <div className="flex items-center justify-center pt-3.5 text-sm"> <button type="button" @@ -26,10 +28,7 @@ export default function ContentSwitch({ <ChevronRight className="h-[14px] w-[14px] rotate-180 text-text-primary" /> </button> <span className="px-2 text-xs text-text-primary"> - {currentIndex + 1} - {' '} - / - {count} + {currentIndex + 1} /{count} </span> <button type="button" diff --git a/web/app/components/base/chat/chat/context-provider.tsx b/web/app/components/base/chat/chat/context-provider.tsx index 02503521e5d3a5..fcf622a5eea7af 100644 --- a/web/app/components/base/chat/chat/context-provider.tsx +++ b/web/app/components/base/chat/chat/context-provider.tsx @@ -27,23 +27,24 @@ export const ChatContextProvider = ({ getHumanInputNodeData, }: ChatContextProviderProps) => { return ( - <ChatContext.Provider value={{ - config, - readonly, - isResponding, - chatList: chatList || [], - showPromptLog, - questionIcon, - answerIcon, - onSend, - onRegenerate, - onAnnotationEdited, - onAnnotationAdded, - onAnnotationRemoved, - disableFeedback, - onFeedback, - getHumanInputNodeData, - }} + <ChatContext.Provider + value={{ + config, + readonly, + isResponding, + chatList: chatList || [], + showPromptLog, + questionIcon, + answerIcon, + onSend, + onRegenerate, + onAnnotationEdited, + onAnnotationAdded, + onAnnotationRemoved, + disableFeedback, + onFeedback, + getHumanInputNodeData, + }} > {children} </ChatContext.Provider> diff --git a/web/app/components/base/chat/chat/context.ts b/web/app/components/base/chat/chat/context.ts index c4fbf9dd3e54ef..25525b9f9e9e19 100644 --- a/web/app/components/base/chat/chat/context.ts +++ b/web/app/components/base/chat/chat/context.ts @@ -3,7 +3,9 @@ import type { ChatProps } from './index' import { createContext, useContext } from 'use-context-selector' -export type ChatContextValue = Pick<ChatProps, 'config' +export type ChatContextValue = Pick< + ChatProps, + | 'config' | 'isResponding' | 'chatList' | 'showPromptLog' @@ -16,9 +18,10 @@ export type ChatContextValue = Pick<ChatProps, 'config' | 'onAnnotationRemoved' | 'disableFeedback' | 'onFeedback' - | 'getHumanInputNodeData'> & { - readonly?: boolean - } + | 'getHumanInputNodeData' +> & { + readonly?: boolean +} export const ChatContext = createContext<ChatContextValue>({ chatList: [], diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index 18385ffe83adab..338658a3316487 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -1,30 +1,16 @@ -import type { - ChatConfig, - ChatItem, - ChatItemInTree, - Inputs, -} from '../types' +import type { ChatConfig, ChatItem, ChatItemInTree, Inputs } from '../types' import type { InputForm, ThoughtItem } from './type' import type AudioPlayer from '@/app/components/base/audio-btn/audio' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { Annotation } from '@/models/log' -import type { - IOnDataMoreInfo, - IOtherOptions, -} from '@/service/base' +import type { IOnDataMoreInfo, IOtherOptions } from '@/service/base' import type { VisionFile } from '@/types/app' import type { FileResponse, ReasoningChunkResponse } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' import { uniqBy } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function' import { produce, setAutoFreeze } from 'immer' -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { v4 as uuidV4 } from 'uuid' import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager' @@ -38,16 +24,10 @@ import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types' import useTimestamp from '@/hooks/use-timestamp' import { useParams, usePathname } from '@/next/navigation' -import { - sseGet, - ssePost, -} from '@/service/base' +import { sseGet, ssePost } from '@/service/base' import { TransferMethod } from '@/types/app' import { getThreadMessages } from '../utils' -import { - getProcessedInputs, - processOpeningStatement, -} from './utils' +import { getProcessedInputs, processOpeningStatement } from './utils' type GetAbortController = (abortController: AbortController) => void type HistoryMessageFile = Partial<FileResponse> & { @@ -57,7 +37,7 @@ type HistoryMessageFile = Partial<FileResponse> & { type HistoryConversationMessage = { id: string answer?: string - message?: { role: string, text: string, files?: FileEntity[] }[] + message?: { role: string; text: string; files?: FileEntity[] }[] message_files?: HistoryMessageFile[] agent_thoughts?: ThoughtItem[] | null retriever_resources?: ChatItem['citation'] @@ -77,8 +57,14 @@ type ConversationMessagesResponse = { data: HistoryConversationMessage[] } type SendCallback = { - onGetConversationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise<unknown> - onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<unknown> + onGetConversationMessages?: ( + conversationId: string, + getAbortController: GetAbortController, + ) => Promise<unknown> + onGetSuggestedQuestions?: ( + responseItemId: string, + getAbortController: GetAbortController, + ) => Promise<unknown> onConversationComplete?: (conversationId: string, workflowRunId?: string) => void onUnhandledEvent?: IOtherOptions['onUnhandledEvent'] onSendSettled?: (hasError?: boolean) => void @@ -93,19 +79,19 @@ type UseChatOptions = { function mergeStreamingThought(currentThought: ThoughtItem, nextThought: ThoughtItem): ThoughtItem { return { ...nextThought, - message_files: nextThought.message_files?.length ? nextThought.message_files : currentThought.message_files, + message_files: nextThought.message_files?.length + ? nextThought.message_files + : currentThought.message_files, } } function appendAgentResponseMessagePart(responseItem: ChatItemInTree, message: string) { - if (!responseItem.agent_response_parts) - responseItem.agent_response_parts = [] + if (!responseItem.agent_response_parts) responseItem.agent_response_parts = [] const lastPart = responseItem.agent_response_parts.at(-1) if (lastPart?.type === 'message') { lastPart.content += message - } - else { + } else { responseItem.agent_response_parts.push({ type: 'message', content: message, @@ -114,11 +100,10 @@ function appendAgentResponseMessagePart(responseItem: ChatItemInTree, message: s } function upsertAgentResponseThoughtPart(responseItem: ChatItemInTree, thought: ThoughtItem) { - if (!responseItem.agent_response_parts) - responseItem.agent_response_parts = [] + if (!responseItem.agent_response_parts) responseItem.agent_response_parts = [] - const partIndex = responseItem.agent_response_parts.findIndex(part => - part.type === 'thought' && part.thought.id === thought.id, + const partIndex = responseItem.agent_response_parts.findIndex( + (part) => part.type === 'thought' && part.thought.id === thought.id, ) if (partIndex > -1) { responseItem.agent_response_parts[partIndex] = { @@ -135,17 +120,17 @@ function upsertAgentResponseThoughtPart(responseItem: ChatItemInTree, thought: T } function getHistoryAgentThoughts(responseItem: HistoryConversationMessage) { - if (!Array.isArray(responseItem.agent_thoughts)) - return [] - - const messageFiles: VisionFile[] = responseItem.message_files?.map(file => ({ - id: file.id, - type: file.type || '', - transfer_method: file.transfer_method || TransferMethod.remote_url, - url: file.url || '', - upload_file_id: file.upload_file_id || '', - belongs_to: file.belongs_to, - })) || [] + if (!Array.isArray(responseItem.agent_thoughts)) return [] + + const messageFiles: VisionFile[] = + responseItem.message_files?.map((file) => ({ + id: file.id, + type: file.type || '', + transfer_method: file.transfer_method || TransferMethod.remote_url, + url: file.url || '', + upload_file_id: file.upload_file_id || '', + belongs_to: file.belongs_to, + })) || [] return addFileInfos(sortAgentSorts(responseItem.agent_thoughts), messageFiles) } @@ -166,22 +151,30 @@ function toHistoryFileResponse(file: HistoryMessageFile): FileResponse { } function getHistoryAnswerFiles(responseItem: HistoryConversationMessage) { - const answerFiles = responseItem.message_files?.filter(file => file.belongs_to === 'assistant') || [] - - return getProcessedFilesFromResponse(answerFiles.map(file => toHistoryFileResponse({ - ...file, - related_id: file.related_id || file.id || '', - upload_file_id: file.upload_file_id || '', - }))) + const answerFiles = + responseItem.message_files?.filter((file) => file.belongs_to === 'assistant') || [] + + return getProcessedFilesFromResponse( + answerFiles.map((file) => + toHistoryFileResponse({ + ...file, + related_id: file.related_id || file.id || '', + upload_file_id: file.upload_file_id || '', + }), + ), + ) } function isHistoryConversationMessage(value: unknown): value is HistoryConversationMessage { - return typeof value === 'object' && value !== null && typeof (value as { id?: unknown }).id === 'string' + return ( + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' + ) } function getConversationMessagesData(response: unknown): ConversationMessagesResponse['data'] { - if (typeof response !== 'object' || response === null) - return [] + if (typeof response !== 'object' || response === null) return [] const data = (response as { data?: unknown }).data return Array.isArray(data) ? data.filter(isHistoryConversationMessage) : [] @@ -219,41 +212,49 @@ export const useChat = ( const [chatTree, setChatTree] = useState<ChatItemInTree[]>(prevChatTree || []) const chatTreeRef = useRef<ChatItemInTree[]>(chatTree) const [targetMessageId, setTargetMessageId] = useState<string>() - const threadMessages = useMemo(() => getThreadMessages(chatTree, targetMessageId), [chatTree, targetMessageId]) + const threadMessages = useMemo( + () => getThreadMessages(chatTree, targetMessageId), + [chatTree, targetMessageId], + ) - const getIntroduction = useCallback((str: string) => { - return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || []) - }, [formSettings?.inputs, formSettings?.inputsForm]) + const getIntroduction = useCallback( + (str: string) => { + return processOpeningStatement( + str, + formSettings?.inputs || {}, + formSettings?.inputsForm || [], + ) + }, + [formSettings?.inputs, formSettings?.inputsForm], + ) const processedOpeningContent = config?.opening_statement ? getIntroduction(config.opening_statement) : undefined const processedSuggestionsKey = config?.suggested_questions - ? JSON.stringify(config.suggested_questions.map(q => getIntroduction(q))) + ? JSON.stringify(config.suggested_questions.map((q) => getIntroduction(q))) : undefined const openingStatementItem = useMemo<ChatItemInTree | null>(() => { - if (!processedOpeningContent) - return null + if (!processedOpeningContent) return null return { id: 'opening-statement', content: processedOpeningContent, isAnswer: true, isOpeningStatement: true, suggestedQuestions: processedSuggestionsKey - ? JSON.parse(processedSuggestionsKey) as string[] + ? (JSON.parse(processedSuggestionsKey) as string[]) : undefined, } }, [processedOpeningContent, processedSuggestionsKey]) const threadOpener = useMemo( - () => threadMessages.find(item => item.isOpeningStatement) ?? null, + () => threadMessages.find((item) => item.isOpeningStatement) ?? null, [threadMessages], ) const mergedOpeningItem = useMemo<ChatItemInTree | null>(() => { - if (!threadOpener || !openingStatementItem) - return null + if (!threadOpener || !openingStatementItem) return null return { ...threadOpener, content: openingStatementItem.content, @@ -265,11 +266,9 @@ export const useChat = ( const chatList = useMemo(() => { const ret = [...threadMessages] if (openingStatementItem) { - const index = threadMessages.findIndex(item => item.isOpeningStatement) - if (index > -1 && mergedOpeningItem) - ret[index] = mergedOpeningItem - else if (index === -1) - ret.unshift(openingStatementItem) + const index = threadMessages.findIndex((item) => item.isOpeningStatement) + if (index > -1 && mergedOpeningItem) ret[index] = mergedOpeningItem + else if (index === -1) ret.unshift(openingStatementItem) } return ret }, [threadMessages, openingStatementItem, mergedOpeningItem]) @@ -288,43 +287,44 @@ export const useChat = ( }, [initialConversationId]) /** Find the target node by bfs and then operate on it */ - const produceChatTreeNode = useCallback((targetId: string, operation: (node: ChatItemInTree) => void) => { - return produce(chatTreeRef.current, (draft) => { - const queue: ChatItemInTree[] = [...draft] - while (queue.length > 0) { - const current = queue.shift()! - if (current.id === targetId) { - operation(current) - break + const produceChatTreeNode = useCallback( + (targetId: string, operation: (node: ChatItemInTree) => void) => { + return produce(chatTreeRef.current, (draft) => { + const queue: ChatItemInTree[] = [...draft] + while (queue.length > 0) { + const current = queue.shift()! + if (current.id === targetId) { + operation(current) + break + } + if (current.children) queue.push(...current.children) } - if (current.children) - queue.push(...current.children) - } - }) - }, []) + }) + }, + [], + ) type UpdateChatTreeNode = { (id: string, fields: Partial<ChatItemInTree>): void (id: string, update: (node: ChatItemInTree) => void): void } - const updateChatTreeNode: UpdateChatTreeNode = useCallback(( - id: string, - fieldsOrUpdate: Partial<ChatItemInTree> | ((node: ChatItemInTree) => void), - ) => { - const nextState = produceChatTreeNode(id, (node) => { - if (typeof fieldsOrUpdate === 'function') { - fieldsOrUpdate(node) - } - else { - Object.keys(fieldsOrUpdate).forEach((key) => { - (node as any)[key] = (fieldsOrUpdate as any)[key] - }) - } - }) - setChatTree(nextState) - chatTreeRef.current = nextState - }, [produceChatTreeNode]) + const updateChatTreeNode: UpdateChatTreeNode = useCallback( + (id: string, fieldsOrUpdate: Partial<ChatItemInTree> | ((node: ChatItemInTree) => void)) => { + const nextState = produceChatTreeNode(id, (node) => { + if (typeof fieldsOrUpdate === 'function') { + fieldsOrUpdate(node) + } else { + Object.keys(fieldsOrUpdate).forEach((key) => { + ;(node as any)[key] = (fieldsOrUpdate as any)[key] + }) + } + }) + setChatTree(nextState) + chatTreeRef.current = nextState + }, + [produceChatTreeNode], + ) const handleResponding = useCallback((isResponding: boolean) => { setIsResponding(isResponding) @@ -334,24 +334,25 @@ export const useChat = ( const handleStop = useCallback(() => { hasStopRespondedRef.current = true handleResponding(false) - if (stopChat && taskIdRef.current && !pausedStateRef.current) - stopChat(taskIdRef.current) + if (stopChat && taskIdRef.current && !pausedStateRef.current) stopChat(taskIdRef.current) if (conversationMessagesAbortControllerRef.current) conversationMessagesAbortControllerRef.current.abort() if (suggestedQuestionsAbortControllerRef.current) suggestedQuestionsAbortControllerRef.current.abort() - if (workflowEventsAbortControllerRef.current) - workflowEventsAbortControllerRef.current.abort() + if (workflowEventsAbortControllerRef.current) workflowEventsAbortControllerRef.current.abort() }, [stopChat, handleResponding]) - const handleRestart = useCallback((cb?: any) => { - conversationIdRef.current = initialConversationIdRef.current - taskIdRef.current = '' - handleStop() - setChatTree([]) - setSuggestedQuestions([]) - cb?.() - }, [handleStop]) + const handleRestart = useCallback( + (cb?: any) => { + conversationIdRef.current = initialConversationIdRef.current + taskIdRef.current = '' + handleStop() + setChatTree([]) + setSuggestedQuestions([]) + cb?.() + }, + [handleStop], + ) const createAudioPlayerManager = useCallback(() => { let ttsUrl = '' @@ -359,18 +360,22 @@ export const useChat = ( if (params.token) { ttsUrl = '/text-to-audio' ttsIsPublic = true - } - else if (params.appId) { - if (isInstalledAppPath(pathname)) - ttsUrl = `/installed-apps/${params.appId}/text-to-audio` - else - ttsUrl = `/apps/${params.appId}/text-to-audio` + } else if (params.appId) { + if (isInstalledAppPath(pathname)) ttsUrl = `/installed-apps/${params.appId}/text-to-audio` + else ttsUrl = `/apps/${params.appId}/text-to-audio` } let player: AudioPlayer | null = null const getOrCreatePlayer = () => { if (!player) - player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', noop) + player = AudioPlayerManager.getInstance().getAudioPlayer( + ttsUrl, + ttsIsPublic, + uuidV4(), + 'none', + 'none', + noop, + ) return player } @@ -378,193 +383,883 @@ export const useChat = ( return getOrCreatePlayer }, [params.token, params.appId, pathname]) - const handleResume = useCallback(async ( - messageId: string, - workflowRunId: string, - { - onGetSuggestedQuestions, - onConversationComplete, - onSendSettled, - isPublicAPI, - }: SendCallback, - ) => { - const getOrCreatePlayer = createAudioPlayerManager() - let hasSettled = false - const settleSend = (hasError?: boolean) => { - if (hasSettled) - return - - hasSettled = true - onSendSettled?.(hasError) - } - // Re-subscribe to workflow events for the specific message - const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true` + const handleResume = useCallback( + async ( + messageId: string, + workflowRunId: string, + { onGetSuggestedQuestions, onConversationComplete, onSendSettled, isPublicAPI }: SendCallback, + ) => { + const getOrCreatePlayer = createAudioPlayerManager() + let hasSettled = false + const settleSend = (hasError?: boolean) => { + if (hasSettled) return + + hasSettled = true + onSendSettled?.(hasError) + } + // Re-subscribe to workflow events for the specific message + const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true` + + const otherOptions: IOtherOptions = { + isPublicAPI, + getAbortController: (abortController) => { + workflowEventsAbortControllerRef.current = abortController + }, + onData: ( + message: string, + isFirstMessage: boolean, + { event, conversationId: newConversationId, messageId, taskId }: IOnDataMoreInfo, + ) => { + updateChatTreeNode(messageId, (responseItem) => { + const agentThoughts = responseItem.agent_thoughts ?? [] + const isNewAgentMessage = + options.isNewAgent && (event === 'agent_message' || event === 'message') + if (isNewAgentMessage) { + appendAgentResponseMessagePart(responseItem, message) + } else if (!agentThoughts.length || options.isNewAgent) { + responseItem.content = responseItem.content + message + } else { + const lastThought = agentThoughts[agentThoughts.length - 1] + if (lastThought) lastThought.thought = lastThought.thought + message + } + if (messageId) responseItem.id = messageId + }) + + if (isFirstMessage && newConversationId) conversationIdRef.current = newConversationId + + if (taskId) taskIdRef.current = taskId + }, + onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => { + const { message_id, reasoning, node_id, is_final } = reasoningData + updateChatTreeNode(message_id, (responseItem) => { + const reasoningContent = + responseItem.reasoningContent || (responseItem.reasoningContent = {}) + const key = node_id || '_' + if (reasoning) reasoningContent[key] = (reasoningContent[key] || '') + reasoning + if (is_final) responseItem.reasoningFinished = true + }) + }, + async onCompleted(hasError?: boolean) { + handleResponding(false) + + try { + if (hasError) return + + if (onConversationComplete) + onConversationComplete(conversationIdRef.current, workflowRunId) + + if ( + config?.suggested_questions_after_answer?.enabled && + !hasStopRespondedRef.current && + onGetSuggestedQuestions + ) { + try { + const { data }: any = await onGetSuggestedQuestions( + messageId, + (newAbortController) => + (suggestedQuestionsAbortControllerRef.current = newAbortController), + ) + setSuggestedQuestions(data) + } catch { + setSuggestedQuestions([]) + } + } + } finally { + settleSend(hasError) + } + }, + onFile(file) { + // Convert simple file type to MIME type for non-agent mode + // Backend sends: { id, type: "image", belongs_to, url } + // Frontend expects: { id, type: "image/png", transferMethod, url, uploadedId, supportFileType, name, size } + + // Determine file type for MIME conversion + const fileType = (file as { type?: string }).type || 'image' + + // If file already has transferMethod, use it as base and ensure all required fields exist + // Otherwise, create a new complete file object + const baseFile = 'transferMethod' in file ? (file as Partial<FileEntity>) : null + + const convertedFile: FileEntity = { + id: baseFile?.id || (file as { id: string }).id, + type: + baseFile?.type || + (fileType === 'image' + ? 'image/png' + : fileType === 'video' + ? 'video/mp4' + : fileType === 'audio' + ? 'audio/mpeg' + : 'application/octet-stream'), + transferMethod: + (baseFile?.transferMethod as FileEntity['transferMethod']) || + (fileType === 'image' ? 'remote_url' : 'local_file'), + uploadedId: baseFile?.uploadedId || (file as { id: string }).id, + supportFileType: + baseFile?.supportFileType || + (fileType === 'image' + ? 'image' + : fileType === 'video' + ? 'video' + : fileType === 'audio' + ? 'audio' + : 'document'), + progress: baseFile?.progress ?? 100, + name: + baseFile?.name || + `generated_${fileType}.${fileType === 'image' ? 'png' : fileType === 'video' ? 'mp4' : fileType === 'audio' ? 'mp3' : 'bin'}`, + url: baseFile?.url || (file as { url?: string }).url, + size: baseFile?.size ?? 0, // Generated files don't have a known size + } + updateChatTreeNode(messageId, (responseItem) => { + const lastThought = + responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1] + if (lastThought) { + responseItem.agent_thoughts!.at(-1)!.message_files = [ + ...(lastThought as any).message_files, + convertedFile, + ] + } else { + const currentFiles = (responseItem.message_files as FileEntity[] | undefined) ?? [] + responseItem.message_files = [...currentFiles, convertedFile] + } + }) + }, + onThought(thought) { + updateChatTreeNode(messageId, (responseItem) => { + if (thought.message_id) responseItem.id = thought.message_id + if (thought.conversation_id) responseItem.conversationId = thought.conversation_id + + if (!responseItem.agent_thoughts) responseItem.agent_thoughts = [] + + if (responseItem.agent_thoughts.length === 0) { + responseItem.agent_thoughts.push(thought) + } else { + const lastThought = responseItem.agent_thoughts.at(-1) + if (lastThought?.id === thought.id) { + responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1] = + mergeStreamingThought(lastThought, thought) + } else { + responseItem.agent_thoughts.push(thought) + } + } + if (options.isNewAgent) { + const currentThought = + responseItem.agent_thoughts.find((item) => item.id === thought.id) ?? thought + upsertAgentResponseThoughtPart(responseItem, currentThought) + } + }) + }, + onMessageEnd: (messageEnd) => { + updateChatTreeNode(messageId, (responseItem) => { + if (messageEnd.metadata?.annotation_reply) { + responseItem.annotation = { + id: messageEnd.metadata.annotation_reply.id, + authorName: messageEnd.metadata.annotation_reply.account.name, + } + return + } + responseItem.citation = messageEnd.metadata?.retriever_resources || [] + const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || []) + responseItem.allFiles = uniqBy( + [...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], + 'id', + ) + }) + }, + onMessageReplace: (messageReplace) => { + updateChatTreeNode(messageId, (responseItem) => { + responseItem.content = messageReplace.answer + }) + }, + onError() { + handleResponding(false) + settleSend(true) + }, + onWorkflowStarted: ({ workflow_run_id, task_id }) => { + handleResponding(true) + hasStopRespondedRef.current = false + updateChatTreeNode(messageId, (responseItem) => { + if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) { + responseItem.workflowProcess = { + ...responseItem.workflowProcess, + status: WorkflowRunningStatus.Running, + error: undefined, + } + } else { + taskIdRef.current = task_id + responseItem.workflow_run_id = workflow_run_id + responseItem.workflowProcess = { + status: WorkflowRunningStatus.Running, + tracing: [], + } + } + }) + }, + onWorkflowFinished: ({ data: workflowFinishedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (responseItem.workflowProcess) { + responseItem.workflowProcess = { + ...responseItem.workflowProcess, + status: workflowFinishedData.status as WorkflowRunningStatus, + error: workflowFinishedData.error, + } + } + }) + }, + onIterationStart: ({ data: iterationStartedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.workflowProcess) return + if (!responseItem.workflowProcess.tracing) responseItem.workflowProcess.tracing = [] + responseItem.workflowProcess.tracing.push({ + ...iterationStartedData, + status: WorkflowRunningStatus.Running, + }) + }) + }, + onIterationFinish: ({ data: iterationFinishedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.workflowProcess?.tracing) return + const tracing = responseItem.workflowProcess.tracing + const iterationIndex = tracing.findIndex( + (item) => + item.node_id === iterationFinishedData.node_id && + (item.execution_metadata?.parallel_id === + iterationFinishedData.execution_metadata?.parallel_id || + item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id), + )! + if (iterationIndex > -1) { + tracing[iterationIndex] = { + ...tracing[iterationIndex], + ...iterationFinishedData, + status: WorkflowRunningStatus.Succeeded, + } + } + }) + }, + onNodeStarted: ({ data: nodeStartedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.workflowProcess) return + if (!responseItem.workflowProcess.tracing) responseItem.workflowProcess.tracing = [] + + const currentIndex = responseItem.workflowProcess.tracing.findIndex( + (item) => item.node_id === nodeStartedData.node_id, + ) + // if the node is already started, update the node + if (currentIndex > -1) { + responseItem.workflowProcess.tracing[currentIndex] = { + ...nodeStartedData, + status: NodeRunningStatus.Running, + } + } else { + if (nodeStartedData.iteration_id) return + + responseItem.workflowProcess.tracing.push({ + ...nodeStartedData, + status: WorkflowRunningStatus.Running, + }) + } + }) + }, + onNodeFinished: ({ data: nodeFinishedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.workflowProcess?.tracing) return + + if (nodeFinishedData.iteration_id) return + + const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => { + if (!item.execution_metadata?.parallel_id) return item.id === nodeFinishedData.id + + return ( + item.id === nodeFinishedData.id && + item.execution_metadata?.parallel_id === + nodeFinishedData.execution_metadata?.parallel_id + ) + }) + if (currentIndex > -1) + responseItem.workflowProcess.tracing[currentIndex] = nodeFinishedData as any + }) + }, + onTTSChunk: (messageId: string, audio: string) => { + if (!audio || audio === '') return + const audioPlayer = getOrCreatePlayer() + if (audioPlayer) { + audioPlayer.playAudioWithAudio(audio, true) + AudioPlayerManager.getInstance().resetMsgId(messageId) + } + }, + onTTSEnd: (messageId: string, audio: string) => { + const audioPlayer = getOrCreatePlayer() + if (audioPlayer) audioPlayer.playAudioWithAudio(audio, false) + }, + onLoopStart: ({ data: loopStartedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.workflowProcess) return + if (!responseItem.workflowProcess.tracing) responseItem.workflowProcess.tracing = [] + responseItem.workflowProcess.tracing.push({ + ...loopStartedData, + status: WorkflowRunningStatus.Running, + }) + }) + }, + onLoopFinish: ({ data: loopFinishedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.workflowProcess?.tracing) return + const tracing = responseItem.workflowProcess.tracing + const loopIndex = tracing.findIndex( + (item) => + item.node_id === loopFinishedData.node_id && + (item.execution_metadata?.parallel_id === + loopFinishedData.execution_metadata?.parallel_id || + item.parallel_id === loopFinishedData.execution_metadata?.parallel_id), + )! + if (loopIndex > -1) { + tracing[loopIndex] = { + ...tracing[loopIndex], + ...loopFinishedData, + status: WorkflowRunningStatus.Succeeded, + } + } + }) + }, + onHumanInputRequired: ({ data: humanInputRequiredData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.humanInputFormDataList) { + responseItem.humanInputFormDataList = [humanInputRequiredData] + } else { + const currentFormIndex = responseItem.humanInputFormDataList.findIndex( + (item) => item.node_id === humanInputRequiredData.node_id, + ) + if (currentFormIndex > -1) { + responseItem.humanInputFormDataList[currentFormIndex] = humanInputRequiredData + } else { + responseItem.humanInputFormDataList.push(humanInputRequiredData) + } + } + if (responseItem.workflowProcess?.tracing) { + const currentTracingIndex = responseItem.workflowProcess.tracing.findIndex( + (item) => item.node_id === humanInputRequiredData.node_id, + ) + if (currentTracingIndex > -1) + responseItem.workflowProcess.tracing[currentTracingIndex]!.status = + NodeRunningStatus.Paused + } + }) + }, + onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => { + updateChatTreeNode(messageId, (responseItem) => { + let requiredFormData: + | NonNullable<ChatItem['humanInputFormDataList']>[number] + | undefined + if (responseItem.humanInputFormDataList?.length) { + const currentFormIndex = responseItem.humanInputFormDataList.findIndex( + (item) => item.node_id === humanInputFilledFormData.node_id, + ) + if (currentFormIndex > -1) { + requiredFormData = responseItem.humanInputFormDataList[currentFormIndex] + responseItem.humanInputFormDataList.splice(currentFormIndex, 1) + } + } + const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData( + humanInputFilledFormData, + requiredFormData, + ) + if (!responseItem.humanInputFilledFormDataList) { + responseItem.humanInputFilledFormDataList = [enrichedHumanInputFilledFormData] + } else { + responseItem.humanInputFilledFormDataList.push(enrichedHumanInputFilledFormData) + } + }) + }, + onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (responseItem.humanInputFormDataList?.length) { + const currentFormIndex = responseItem.humanInputFormDataList.findIndex( + (item) => item.node_id === humanInputFormTimeoutData.node_id, + ) + responseItem.humanInputFormDataList[currentFormIndex]!.expiration_time = + humanInputFormTimeoutData.expiration_time + } + }) + }, + onWorkflowPaused: ({ data: workflowPausedData }) => { + const resumeUrl = `/workflow/${workflowPausedData.workflow_run_id}/events` + pausedStateRef.current = true + sseGet(resumeUrl, {}, otherOptions) + updateChatTreeNode(messageId, (responseItem) => { + responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused + }) + }, + } + + if (workflowEventsAbortControllerRef.current) workflowEventsAbortControllerRef.current.abort() + + sseGet(url, {}, otherOptions) + }, + [ + updateChatTreeNode, + handleResponding, + createAudioPlayerManager, + config?.suggested_questions_after_answer, + options.isNewAgent, + ], + ) + + const updateCurrentQAOnTree = useCallback( + ({ + parentId, + responseItem, + placeholderQuestionId, + questionItem, + }: { + parentId?: string + responseItem: ChatItem + placeholderQuestionId: string + questionItem: ChatItem + }) => { + let nextState: ChatItemInTree[] + const currentQA = { ...questionItem, children: [{ ...responseItem, children: [] }] } + if ( + !parentId && + !chatTree.some((item) => [placeholderQuestionId, questionItem.id].includes(item.id)) + ) { + // QA whose parent is not provided is considered as a first message of the conversation, + // and it should be a root node of the chat tree + nextState = produce(chatTree, (draft) => { + draft.push(currentQA) + }) + } else { + // find the target QA in the tree and update it; if not found, insert it to its parent node + nextState = produceChatTreeNode(parentId!, (parentNode) => { + const questionNodeIndex = parentNode.children!.findIndex((item) => + [placeholderQuestionId, questionItem.id].includes(item.id), + ) + if (questionNodeIndex === -1) parentNode.children!.push(currentQA) + else parentNode.children![questionNodeIndex] = currentQA + }) + } + setChatTree(nextState) + chatTreeRef.current = nextState + }, + [chatTree, produceChatTreeNode], + ) - const otherOptions: IOtherOptions = { - isPublicAPI, - getAbortController: (abortController) => { - workflowEventsAbortControllerRef.current = abortController + const handleSend = useCallback( + async ( + url: string, + data: { + query: string + files?: FileEntity[] + parent_message_id?: string + [key: string]: any }, - onData: (message: string, isFirstMessage: boolean, { event, conversationId: newConversationId, messageId, taskId }: IOnDataMoreInfo) => { - updateChatTreeNode(messageId, (responseItem) => { - const agentThoughts = responseItem.agent_thoughts ?? [] - const isNewAgentMessage = options.isNewAgent && (event === 'agent_message' || event === 'message') + { + onGetConversationMessages, + onGetSuggestedQuestions, + onConversationComplete, + onUnhandledEvent, + onSendSettled, + isPublicAPI, + }: SendCallback, + ) => { + setSuggestedQuestions([]) + + if (isRespondingRef.current) { + toast.info(t(($) => $['errorMessage.waitForResponse'], { ns: 'appDebug' })) + return false + } + + const parentMessage = threadMessages.find((item) => item.id === data.parent_message_id) + + const placeholderQuestionId = `question-${Date.now()}` + const questionItem = { + id: placeholderQuestionId, + content: data.query, + isAnswer: false, + message_files: data.files, + parentMessageId: data.parent_message_id, + } + + const placeholderAnswerId = `answer-placeholder-${Date.now()}` + const placeholderAnswerItem = { + id: placeholderAnswerId, + content: '', + isAnswer: true, + parentMessageId: questionItem.id, + siblingIndex: parentMessage?.children?.length ?? chatTree.length, + } + + setTargetMessageId(parentMessage?.id) + updateCurrentQAOnTree({ + parentId: data.parent_message_id, + responseItem: placeholderAnswerItem, + placeholderQuestionId, + questionItem, + }) + + // answer + const responseItem: ChatItemInTree = { + id: placeholderAnswerId, + content: '', + agent_thoughts: [], + message_files: [], + isAnswer: true, + parentMessageId: questionItem.id, + siblingIndex: parentMessage?.children?.length ?? chatTree.length, + } + + handleResponding(true) + hasStopRespondedRef.current = false + + const { query, files, inputs, overrideInputsForm, ...restData } = data + const requestInputsForm = overrideInputsForm ?? formSettings?.inputsForm ?? [] + const bodyParams = { + response_mode: 'streaming', + conversation_id: conversationIdRef.current, + files: getProcessedFiles(files || []), + query, + inputs: getProcessedInputs(inputs || {}, requestInputsForm), + ...restData, + } + if (bodyParams?.files?.length) { + bodyParams.files = bodyParams.files.map((item) => { + if (item.transfer_method === TransferMethod.local_file) { + return { + ...item, + url: '', + } + } + return item + }) + } + + let isAgentMode = false + let hasSetResponseId = false + let hasSettled = false + const settleSend = (hasError?: boolean) => { + if (hasSettled) return + + hasSettled = true + onSendSettled?.(hasError) + } + + const getOrCreatePlayer = createAudioPlayerManager() + + const otherOptions: IOtherOptions = { + isPublicAPI, + onUnhandledEvent, + getAbortController: (abortController) => { + workflowEventsAbortControllerRef.current = abortController + }, + onData: ( + message: string, + isFirstMessage: boolean, + { event, conversationId: newConversationId, messageId, taskId }: any, + ) => { + const isNewAgentMessage = + options.isNewAgent && (event === 'agent_message' || event === 'message') if (isNewAgentMessage) { appendAgentResponseMessagePart(responseItem, message) - } - else if (!agentThoughts.length || options.isNewAgent) { + } else if (!isAgentMode || options.isNewAgent) { responseItem.content = responseItem.content + message + } else { + const lastThought = + responseItem.agent_thoughts?.[responseItem.agent_thoughts.length - 1] + if (lastThought) lastThought.thought = lastThought.thought + message // need immer setAutoFreeze } - else { - const lastThought = agentThoughts[agentThoughts.length - 1] - if (lastThought) - lastThought.thought = lastThought.thought + message - } - if (messageId) + + if (messageId && !hasSetResponseId) { + questionItem.id = `question-${messageId}` responseItem.id = messageId - }) + responseItem.parentMessageId = questionItem.id + hasSetResponseId = true + } - if (isFirstMessage && newConversationId) - conversationIdRef.current = newConversationId + if (isFirstMessage && newConversationId) conversationIdRef.current = newConversationId - if (taskId) taskIdRef.current = taskId - }, - onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => { - const { message_id, reasoning, node_id, is_final } = reasoningData - updateChatTreeNode(message_id, (responseItem) => { - const reasoningContent = responseItem.reasoningContent || (responseItem.reasoningContent = {}) + if (messageId) responseItem.id = messageId + + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => { + const { reasoning, node_id, is_final } = reasoningData + const reasoningContent = + responseItem.reasoningContent || (responseItem.reasoningContent = {}) const key = node_id || '_' - if (reasoning) - reasoningContent[key] = (reasoningContent[key] || '') + reasoning - if (is_final) - responseItem.reasoningFinished = true - }) - }, - async onCompleted(hasError?: boolean) { - handleResponding(false) + if (reasoning) reasoningContent[key] = (reasoningContent[key] || '') + reasoning + if (is_final) responseItem.reasoningFinished = true - try { - if (hasError) - return + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + async onCompleted(hasError?: boolean) { + handleResponding(false) + + try { + if (hasError) return - if (onConversationComplete) - onConversationComplete(conversationIdRef.current, workflowRunId) + let completedWorkflowRunId = responseItem.workflow_run_id - if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) { - try { - const { data }: any = await onGetSuggestedQuestions( - messageId, - newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController, + if ( + conversationIdRef.current && + !hasStopRespondedRef.current && + onGetConversationMessages + ) { + const conversationMessagesResponse = await onGetConversationMessages( + conversationIdRef.current, + (newAbortController) => + (conversationMessagesAbortControllerRef.current = newAbortController), ) - setSuggestedQuestions(data) + const data = getConversationMessagesData(conversationMessagesResponse) + const newResponseItem = data.find((item) => item.id === responseItem.id) + completedWorkflowRunId = newResponseItem?.workflow_run_id ?? completedWorkflowRunId + if (!newResponseItem) + return onConversationComplete?.(conversationIdRef.current, completedWorkflowRunId) + + const historyAgentThoughts = getHistoryAgentThoughts(newResponseItem) + const lastHistoryAgentThought = historyAgentThoughts.at(-1) + const historyAnswer = newResponseItem.answer || '' + const isUseAgentThought = + !options.isNewAgent && lastHistoryAgentThought?.thought === historyAnswer + const messageLog = Array.isArray(newResponseItem.message) + ? newResponseItem.message + : [] + const answerTokens = newResponseItem.answer_tokens ?? 0 + const messageTokens = newResponseItem.message_tokens ?? 0 + const providerResponseLatency = newResponseItem.provider_response_latency ?? 0 + const historyAnswerFiles = getHistoryAnswerFiles(newResponseItem) + updateChatTreeNode(responseItem.id, { + content: isUseAgentThought ? '' : historyAnswer, + agent_thoughts: historyAgentThoughts, + agent_response_parts: undefined, + citation: newResponseItem.retriever_resources, + reasoningContent: newResponseItem.metadata?.reasoning, + reasoningFinished: true, + message_files: historyAnswerFiles, + allFiles: undefined, + workflowProcess: undefined, + workflow_run_id: newResponseItem.workflow_run_id ?? completedWorkflowRunId, + feedback: newResponseItem.feedback, + log: [ + ...messageLog, + ...(messageLog.at(-1)?.role !== 'assistant' + ? [ + { + role: 'assistant', + text: historyAnswer, + files: historyAnswerFiles, + }, + ] + : []), + ], + more: { + time: formatTime(newResponseItem.created_at ?? Date.now(), 'hh:mm A'), + tokens: answerTokens + messageTokens, + latency: providerResponseLatency.toFixed(2), + tokens_per_second: + providerResponseLatency > 0 + ? (answerTokens / providerResponseLatency).toFixed(2) + : undefined, + }, + // for agent log + conversationId: conversationIdRef.current, + input: { + inputs: newResponseItem.inputs, + query: newResponseItem.query, + }, + }) } - catch { - setSuggestedQuestions([]) + + onConversationComplete?.(conversationIdRef.current, completedWorkflowRunId) + + if ( + config?.suggested_questions_after_answer?.enabled && + !hasStopRespondedRef.current && + onGetSuggestedQuestions + ) { + try { + const { data }: any = await onGetSuggestedQuestions( + responseItem.id, + (newAbortController) => + (suggestedQuestionsAbortControllerRef.current = newAbortController), + ) + setSuggestedQuestions(data) + } catch { + setSuggestedQuestions([]) + } } + } finally { + settleSend(hasError) } - } - finally { - settleSend(hasError) - } - }, - onFile(file) { - // Convert simple file type to MIME type for non-agent mode - // Backend sends: { id, type: "image", belongs_to, url } - // Frontend expects: { id, type: "image/png", transferMethod, url, uploadedId, supportFileType, name, size } - - // Determine file type for MIME conversion - const fileType = (file as { type?: string }).type || 'image' - - // If file already has transferMethod, use it as base and ensure all required fields exist - // Otherwise, create a new complete file object - const baseFile = ('transferMethod' in file) ? (file as Partial<FileEntity>) : null - - const convertedFile: FileEntity = { - id: baseFile?.id || (file as { id: string }).id, - type: baseFile?.type || (fileType === 'image' ? 'image/png' : fileType === 'video' ? 'video/mp4' : fileType === 'audio' ? 'audio/mpeg' : 'application/octet-stream'), - transferMethod: (baseFile?.transferMethod as FileEntity['transferMethod']) || (fileType === 'image' ? 'remote_url' : 'local_file'), - uploadedId: baseFile?.uploadedId || (file as { id: string }).id, - supportFileType: baseFile?.supportFileType || (fileType === 'image' ? 'image' : fileType === 'video' ? 'video' : fileType === 'audio' ? 'audio' : 'document'), - progress: baseFile?.progress ?? 100, - name: baseFile?.name || `generated_${fileType}.${fileType === 'image' ? 'png' : fileType === 'video' ? 'mp4' : fileType === 'audio' ? 'mp3' : 'bin'}`, - url: baseFile?.url || (file as { url?: string }).url, - size: baseFile?.size ?? 0, // Generated files don't have a known size - } - updateChatTreeNode(messageId, (responseItem) => { + }, + onFile(file) { + // Convert simple file type to MIME type for non-agent mode + // Backend sends: { id, type: "image", belongs_to, url } + // Frontend expects: { id, type: "image/png", transferMethod, url, uploadedId, supportFileType, name, size } + + // Determine file type for MIME conversion + const fileType = (file as { type?: string }).type || 'image' + + // If file already has transferMethod, use it as base and ensure all required fields exist + // Otherwise, create a new complete file object + const baseFile = 'transferMethod' in file ? (file as Partial<FileEntity>) : null + + const convertedFile: FileEntity = { + id: baseFile?.id || (file as { id: string }).id, + type: + baseFile?.type || + (fileType === 'image' + ? 'image/png' + : fileType === 'video' + ? 'video/mp4' + : fileType === 'audio' + ? 'audio/mpeg' + : 'application/octet-stream'), + transferMethod: + (baseFile?.transferMethod as FileEntity['transferMethod']) || + (fileType === 'image' ? 'remote_url' : 'local_file'), + uploadedId: baseFile?.uploadedId || (file as { id: string }).id, + supportFileType: + baseFile?.supportFileType || + (fileType === 'image' + ? 'image' + : fileType === 'video' + ? 'video' + : fileType === 'audio' + ? 'audio' + : 'document'), + progress: baseFile?.progress ?? 100, + name: + baseFile?.name || + `generated_${fileType}.${fileType === 'image' ? 'png' : fileType === 'video' ? 'mp4' : fileType === 'audio' ? 'mp3' : 'bin'}`, + url: baseFile?.url || (file as { url?: string }).url, + size: baseFile?.size ?? 0, // Generated files don't have a known size + } + + // For agent mode, add files to the last thought const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1] if (lastThought) { - responseItem.agent_thoughts!.at(-1)!.message_files = [...(lastThought as any).message_files, convertedFile] + const thought = lastThought as { message_files?: FileEntity[] } + responseItem.agent_thoughts!.at(-1)!.message_files = [ + ...(thought.message_files ?? []), + convertedFile, + ] } + // For non-agent mode, add files directly to responseItem.message_files else { const currentFiles = (responseItem.message_files as FileEntity[] | undefined) ?? [] responseItem.message_files = [...currentFiles, convertedFile] } - }) - }, - onThought(thought) { - updateChatTreeNode(messageId, (responseItem) => { - if (thought.message_id) - responseItem.id = thought.message_id - if (thought.conversation_id) - responseItem.conversationId = thought.conversation_id - - if (!responseItem.agent_thoughts) - responseItem.agent_thoughts = [] - - if (responseItem.agent_thoughts.length === 0) { - responseItem.agent_thoughts.push(thought) - } - else { - const lastThought = responseItem.agent_thoughts.at(-1) - if (lastThought?.id === thought.id) { - responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1] = mergeStreamingThought(lastThought, thought) - } - else { - responseItem.agent_thoughts.push(thought) + + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onThought(thought) { + isAgentMode = true + const response = responseItem as any + if (thought.message_id && !hasSetResponseId) response.id = thought.message_id + if (thought.conversation_id) response.conversationId = thought.conversation_id + + if (response.agent_thoughts.length === 0) { + response.agent_thoughts.push(thought) + } else { + const lastThought = response.agent_thoughts.at(-1) + // thought changed but still the same thought, so update. + if (lastThought.id === thought.id) { + responseItem.agent_thoughts![response.agent_thoughts.length - 1] = + mergeStreamingThought(lastThought, thought) + } else { + responseItem.agent_thoughts!.push(thought) } } if (options.isNewAgent) { - const currentThought = responseItem.agent_thoughts.find(item => item.id === thought.id) ?? thought + const currentThought = + responseItem.agent_thoughts?.find((item) => item.id === thought.id) ?? thought upsertAgentResponseThoughtPart(responseItem, currentThought) } - }) - }, - onMessageEnd: (messageEnd) => { - updateChatTreeNode(messageId, (responseItem) => { + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onMessageEnd: (messageEnd) => { if (messageEnd.metadata?.annotation_reply) { - responseItem.annotation = ({ + responseItem.id = messageEnd.id + responseItem.annotation = { id: messageEnd.metadata.annotation_reply.id, authorName: messageEnd.metadata.annotation_reply.account.name, + } + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, }) + handleResponding(false) return } responseItem.citation = messageEnd.metadata?.retriever_resources || [] const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || []) - responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id') - }) - }, - onMessageReplace: (messageReplace) => { - updateChatTreeNode(messageId, (responseItem) => { + responseItem.allFiles = uniqBy( + [...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], + 'id', + ) + + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onMessageReplace: (messageReplace) => { responseItem.content = messageReplace.answer - }) - }, - onError() { - handleResponding(false) - settleSend(true) - }, - onWorkflowStarted: ({ workflow_run_id, task_id }) => { - handleResponding(true) - hasStopRespondedRef.current = false - updateChatTreeNode(messageId, (responseItem) => { + }, + onError() { + handleResponding(false) + settleSend(true) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onWorkflowStarted: ({ workflow_run_id, task_id, conversation_id, message_id }) => { + // If there are no streaming messages, we still need to set the conversation_id to avoid create a new conversation when regeneration in chat-flow. + if (conversation_id) { + conversationIdRef.current = conversation_id + } + if (message_id && !hasSetResponseId) { + questionItem.id = `question-${message_id}` + responseItem.id = message_id + responseItem.parentMessageId = questionItem.id + hasSetResponseId = true + } + if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) { responseItem.workflowProcess = { ...responseItem.workflowProcess, status: WorkflowRunningStatus.Running, error: undefined, } - } - else { + } else { taskIdRef.current = task_id responseItem.workflow_run_id = workflow_run_id responseItem.workflowProcess = { @@ -572,963 +1267,364 @@ export const useChat = ( tracing: [], } } - }) - }, - onWorkflowFinished: ({ data: workflowFinishedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (responseItem.workflowProcess) { - responseItem.workflowProcess = { - ...responseItem.workflowProcess, - status: workflowFinishedData.status as WorkflowRunningStatus, - error: workflowFinishedData.error, - } + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onWorkflowFinished: ({ data: workflowFinishedData }) => { + if (pausedStateRef.current) pausedStateRef.current = false + responseItem.workflowProcess = { + ...responseItem.workflowProcess!, + status: workflowFinishedData.status as WorkflowRunningStatus, + error: workflowFinishedData.error, } - }) - }, - onIterationStart: ({ data: iterationStartedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.workflowProcess) - return - if (!responseItem.workflowProcess.tracing) - responseItem.workflowProcess.tracing = [] - responseItem.workflowProcess.tracing.push({ + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onIterationStart: ({ data: iterationStartedData }) => { + responseItem.workflowProcess!.tracing!.push({ ...iterationStartedData, status: WorkflowRunningStatus.Running, }) - }) - }, - onIterationFinish: ({ data: iterationFinishedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.workflowProcess?.tracing) - return - const tracing = responseItem.workflowProcess.tracing - const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id - && (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))! - if (iterationIndex > -1) { - tracing[iterationIndex] = { - ...tracing[iterationIndex], - ...iterationFinishedData, - status: WorkflowRunningStatus.Succeeded, - } + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onIterationFinish: ({ data: iterationFinishedData }) => { + const tracing = responseItem.workflowProcess!.tracing! + const iterationIndex = tracing.findIndex( + (item) => + item.node_id === iterationFinishedData.node_id && + (item.execution_metadata?.parallel_id === + iterationFinishedData.execution_metadata?.parallel_id || + item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id), + )! + tracing[iterationIndex] = { + ...tracing[iterationIndex], + ...iterationFinishedData, + status: WorkflowRunningStatus.Succeeded, } - }) - }, - onNodeStarted: ({ data: nodeStartedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.workflowProcess) - return - if (!responseItem.workflowProcess.tracing) - responseItem.workflowProcess.tracing = [] - const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id) - // if the node is already started, update the node + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onNodeStarted: ({ data: nodeStartedData }) => { + if (!responseItem.workflowProcess) return + if (!responseItem.workflowProcess.tracing) responseItem.workflowProcess.tracing = [] + + const currentIndex = responseItem.workflowProcess.tracing.findIndex( + (item) => item.node_id === nodeStartedData.node_id, + ) if (currentIndex > -1) { responseItem.workflowProcess.tracing[currentIndex] = { ...nodeStartedData, status: NodeRunningStatus.Running, } - } - else { - if (nodeStartedData.iteration_id) - return + } else { + if (nodeStartedData.iteration_id) return + + if (data.loop_id) return responseItem.workflowProcess.tracing.push({ ...nodeStartedData, status: WorkflowRunningStatus.Running, }) } - }) - }, - onNodeFinished: ({ data: nodeFinishedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.workflowProcess?.tracing) - return + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onNodeFinished: ({ data: nodeFinishedData }) => { + if (nodeFinishedData.iteration_id) return - if (nodeFinishedData.iteration_id) - return + if (data.loop_id) return - const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => { - if (!item.execution_metadata?.parallel_id) - return item.id === nodeFinishedData.id + const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => { + if (!item.execution_metadata?.parallel_id) return item.id === nodeFinishedData.id - return item.id === nodeFinishedData.id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata?.parallel_id) + return ( + item.id === nodeFinishedData.id && + item.execution_metadata?.parallel_id === + nodeFinishedData.execution_metadata?.parallel_id + ) }) - if (currentIndex > -1) - responseItem.workflowProcess.tracing[currentIndex] = nodeFinishedData as any - }) - }, - onTTSChunk: (messageId: string, audio: string) => { - if (!audio || audio === '') - return - const audioPlayer = getOrCreatePlayer() - if (audioPlayer) { - audioPlayer.playAudioWithAudio(audio, true) - AudioPlayerManager.getInstance().resetMsgId(messageId) - } - }, - onTTSEnd: (messageId: string, audio: string) => { - const audioPlayer = getOrCreatePlayer() - if (audioPlayer) - audioPlayer.playAudioWithAudio(audio, false) - }, - onLoopStart: ({ data: loopStartedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.workflowProcess) - return - if (!responseItem.workflowProcess.tracing) - responseItem.workflowProcess.tracing = [] - responseItem.workflowProcess.tracing.push({ + responseItem.workflowProcess!.tracing[currentIndex] = nodeFinishedData as any + + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onTTSChunk: (messageId: string, audio: string) => { + if (!audio || audio === '') return + const audioPlayer = getOrCreatePlayer() + if (audioPlayer) { + audioPlayer.playAudioWithAudio(audio, true) + AudioPlayerManager.getInstance().resetMsgId(messageId) + } + }, + onTTSEnd: (messageId: string, audio: string) => { + const audioPlayer = getOrCreatePlayer() + if (audioPlayer) audioPlayer.playAudioWithAudio(audio, false) + }, + onLoopStart: ({ data: loopStartedData }) => { + responseItem.workflowProcess!.tracing!.push({ ...loopStartedData, status: WorkflowRunningStatus.Running, }) - }) - }, - onLoopFinish: ({ data: loopFinishedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.workflowProcess?.tracing) - return - const tracing = responseItem.workflowProcess.tracing - const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id - && (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))! - if (loopIndex > -1) { - tracing[loopIndex] = { - ...tracing[loopIndex], - ...loopFinishedData, - status: WorkflowRunningStatus.Succeeded, - } + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onLoopFinish: ({ data: loopFinishedData }) => { + const tracing = responseItem.workflowProcess!.tracing! + const loopIndex = tracing.findIndex( + (item) => + item.node_id === loopFinishedData.node_id && + (item.execution_metadata?.parallel_id === + loopFinishedData.execution_metadata?.parallel_id || + item.parallel_id === loopFinishedData.execution_metadata?.parallel_id), + )! + tracing[loopIndex] = { + ...tracing[loopIndex], + ...loopFinishedData, + status: WorkflowRunningStatus.Succeeded, } - }) - }, - onHumanInputRequired: ({ data: humanInputRequiredData }) => { - updateChatTreeNode(messageId, (responseItem) => { + + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onHumanInputRequired: ({ data: humanInputRequiredData }) => { if (!responseItem.humanInputFormDataList) { responseItem.humanInputFormDataList = [humanInputRequiredData] - } - else { - const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputRequiredData.node_id) + } else { + const currentFormIndex = responseItem.humanInputFormDataList!.findIndex( + (item) => item.node_id === humanInputRequiredData.node_id, + ) if (currentFormIndex > -1) { responseItem.humanInputFormDataList[currentFormIndex] = humanInputRequiredData - } - else { + } else { responseItem.humanInputFormDataList.push(humanInputRequiredData) } } - if (responseItem.workflowProcess?.tracing) { - const currentTracingIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === humanInputRequiredData.node_id) - if (currentTracingIndex > -1) - responseItem.workflowProcess.tracing[currentTracingIndex]!.status = NodeRunningStatus.Paused + const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex( + (item) => item.node_id === humanInputRequiredData.node_id, + ) + if (currentTracingIndex > -1) { + responseItem.workflowProcess!.tracing[currentTracingIndex]!.status = + NodeRunningStatus.Paused + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) } - }) - }, - onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => { - updateChatTreeNode(messageId, (responseItem) => { + }, + onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => { let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined if (responseItem.humanInputFormDataList?.length) { - const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFilledFormData.node_id) + const currentFormIndex = responseItem.humanInputFormDataList!.findIndex( + (item) => item.node_id === humanInputFilledFormData.node_id, + ) if (currentFormIndex > -1) { requiredFormData = responseItem.humanInputFormDataList[currentFormIndex] responseItem.humanInputFormDataList.splice(currentFormIndex, 1) } } - const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData(humanInputFilledFormData, requiredFormData) + const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData( + humanInputFilledFormData, + requiredFormData, + ) if (!responseItem.humanInputFilledFormDataList) { responseItem.humanInputFilledFormDataList = [enrichedHumanInputFilledFormData] - } - else { + } else { responseItem.humanInputFilledFormDataList.push(enrichedHumanInputFilledFormData) } - }) - }, - onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => { - updateChatTreeNode(messageId, (responseItem) => { + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, + onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => { if (responseItem.humanInputFormDataList?.length) { - const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFormTimeoutData.node_id) - responseItem.humanInputFormDataList[currentFormIndex]!.expiration_time = humanInputFormTimeoutData.expiration_time - } - }) - }, - onWorkflowPaused: ({ data: workflowPausedData }) => { - const resumeUrl = `/workflow/${workflowPausedData.workflow_run_id}/events` - pausedStateRef.current = true - sseGet( - resumeUrl, - {}, - otherOptions, - ) - updateChatTreeNode(messageId, (responseItem) => { - responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused - }) - }, - } - - if (workflowEventsAbortControllerRef.current) - workflowEventsAbortControllerRef.current.abort() - - sseGet( - url, - {}, - otherOptions, - ) - }, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer, options.isNewAgent]) - - const updateCurrentQAOnTree = useCallback(({ - parentId, - responseItem, - placeholderQuestionId, - questionItem, - }: { - parentId?: string - responseItem: ChatItem - placeholderQuestionId: string - questionItem: ChatItem - }) => { - let nextState: ChatItemInTree[] - const currentQA = { ...questionItem, children: [{ ...responseItem, children: [] }] } - if (!parentId && !chatTree.some(item => [placeholderQuestionId, questionItem.id].includes(item.id))) { - // QA whose parent is not provided is considered as a first message of the conversation, - // and it should be a root node of the chat tree - nextState = produce(chatTree, (draft) => { - draft.push(currentQA) - }) - } - else { - // find the target QA in the tree and update it; if not found, insert it to its parent node - nextState = produceChatTreeNode(parentId!, (parentNode) => { - const questionNodeIndex = parentNode.children!.findIndex(item => [placeholderQuestionId, questionItem.id].includes(item.id)) - if (questionNodeIndex === -1) - parentNode.children!.push(currentQA) - else - parentNode.children![questionNodeIndex] = currentQA - }) - } - setChatTree(nextState) - chatTreeRef.current = nextState - }, [chatTree, produceChatTreeNode]) - - const handleSend = useCallback(async ( - url: string, - data: { - query: string - files?: FileEntity[] - parent_message_id?: string - [key: string]: any - }, - { - onGetConversationMessages, - onGetSuggestedQuestions, - onConversationComplete, - onUnhandledEvent, - onSendSettled, - isPublicAPI, - }: SendCallback, - ) => { - setSuggestedQuestions([]) - - if (isRespondingRef.current) { - toast.info(t($ => $['errorMessage.waitForResponse'], { ns: 'appDebug' })) - return false - } - - const parentMessage = threadMessages.find(item => item.id === data.parent_message_id) - - const placeholderQuestionId = `question-${Date.now()}` - const questionItem = { - id: placeholderQuestionId, - content: data.query, - isAnswer: false, - message_files: data.files, - parentMessageId: data.parent_message_id, - } - - const placeholderAnswerId = `answer-placeholder-${Date.now()}` - const placeholderAnswerItem = { - id: placeholderAnswerId, - content: '', - isAnswer: true, - parentMessageId: questionItem.id, - siblingIndex: parentMessage?.children?.length ?? chatTree.length, - } - - setTargetMessageId(parentMessage?.id) - updateCurrentQAOnTree({ - parentId: data.parent_message_id, - responseItem: placeholderAnswerItem, - placeholderQuestionId, - questionItem, - }) - - // answer - const responseItem: ChatItemInTree = { - id: placeholderAnswerId, - content: '', - agent_thoughts: [], - message_files: [], - isAnswer: true, - parentMessageId: questionItem.id, - siblingIndex: parentMessage?.children?.length ?? chatTree.length, - } - - handleResponding(true) - hasStopRespondedRef.current = false - - const { query, files, inputs, overrideInputsForm, ...restData } = data - const requestInputsForm = overrideInputsForm ?? formSettings?.inputsForm ?? [] - const bodyParams = { - response_mode: 'streaming', - conversation_id: conversationIdRef.current, - files: getProcessedFiles(files || []), - query, - inputs: getProcessedInputs(inputs || {}, requestInputsForm), - ...restData, - } - if (bodyParams?.files?.length) { - bodyParams.files = bodyParams.files.map((item) => { - if (item.transfer_method === TransferMethod.local_file) { - return { - ...item, - url: '', - } - } - return item - }) - } - - let isAgentMode = false - let hasSetResponseId = false - let hasSettled = false - const settleSend = (hasError?: boolean) => { - if (hasSettled) - return - - hasSettled = true - onSendSettled?.(hasError) - } - - const getOrCreatePlayer = createAudioPlayerManager() - - const otherOptions: IOtherOptions = { - isPublicAPI, - onUnhandledEvent, - getAbortController: (abortController) => { - workflowEventsAbortControllerRef.current = abortController - }, - onData: (message: string, isFirstMessage: boolean, { event, conversationId: newConversationId, messageId, taskId }: any) => { - const isNewAgentMessage = options.isNewAgent && (event === 'agent_message' || event === 'message') - if (isNewAgentMessage) { - appendAgentResponseMessagePart(responseItem, message) - } - else if (!isAgentMode || options.isNewAgent) { - responseItem.content = responseItem.content + message - } - else { - const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts.length - 1] - if (lastThought) - lastThought.thought = lastThought.thought + message // need immer setAutoFreeze - } - - if (messageId && !hasSetResponseId) { - questionItem.id = `question-${messageId}` - responseItem.id = messageId - responseItem.parentMessageId = questionItem.id - hasSetResponseId = true - } - - if (isFirstMessage && newConversationId) - conversationIdRef.current = newConversationId - - taskIdRef.current = taskId - if (messageId) - responseItem.id = messageId - - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => { - const { reasoning, node_id, is_final } = reasoningData - const reasoningContent = responseItem.reasoningContent || (responseItem.reasoningContent = {}) - const key = node_id || '_' - if (reasoning) - reasoningContent[key] = (reasoningContent[key] || '') + reasoning - if (is_final) - responseItem.reasoningFinished = true - - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - async onCompleted(hasError?: boolean) { - handleResponding(false) - - try { - if (hasError) - return - - let completedWorkflowRunId = responseItem.workflow_run_id - - if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) { - const conversationMessagesResponse = await onGetConversationMessages( - conversationIdRef.current, - newAbortController => conversationMessagesAbortControllerRef.current = newAbortController, + const currentFormIndex = responseItem.humanInputFormDataList!.findIndex( + (item) => item.node_id === humanInputFormTimeoutData.node_id, ) - const data = getConversationMessagesData(conversationMessagesResponse) - const newResponseItem = data.find(item => item.id === responseItem.id) - completedWorkflowRunId = newResponseItem?.workflow_run_id ?? completedWorkflowRunId - if (!newResponseItem) - return onConversationComplete?.(conversationIdRef.current, completedWorkflowRunId) - - const historyAgentThoughts = getHistoryAgentThoughts(newResponseItem) - const lastHistoryAgentThought = historyAgentThoughts.at(-1) - const historyAnswer = newResponseItem.answer || '' - const isUseAgentThought = !options.isNewAgent && lastHistoryAgentThought?.thought === historyAnswer - const messageLog = Array.isArray(newResponseItem.message) ? newResponseItem.message : [] - const answerTokens = newResponseItem.answer_tokens ?? 0 - const messageTokens = newResponseItem.message_tokens ?? 0 - const providerResponseLatency = newResponseItem.provider_response_latency ?? 0 - const historyAnswerFiles = getHistoryAnswerFiles(newResponseItem) - updateChatTreeNode(responseItem.id, { - content: isUseAgentThought ? '' : historyAnswer, - agent_thoughts: historyAgentThoughts, - agent_response_parts: undefined, - citation: newResponseItem.retriever_resources, - reasoningContent: newResponseItem.metadata?.reasoning, - reasoningFinished: true, - message_files: historyAnswerFiles, - allFiles: undefined, - workflowProcess: undefined, - workflow_run_id: newResponseItem.workflow_run_id ?? completedWorkflowRunId, - feedback: newResponseItem.feedback, - log: [ - ...messageLog, - ...(messageLog.at(-1)?.role !== 'assistant' - ? [ - { - role: 'assistant', - text: historyAnswer, - files: historyAnswerFiles, - }, - ] - : []), - ], - more: { - time: formatTime(newResponseItem.created_at ?? Date.now(), 'hh:mm A'), - tokens: answerTokens + messageTokens, - latency: providerResponseLatency.toFixed(2), - tokens_per_second: providerResponseLatency > 0 ? (answerTokens / providerResponseLatency).toFixed(2) : undefined, - }, - // for agent log - conversationId: conversationIdRef.current, - input: { - inputs: newResponseItem.inputs, - query: newResponseItem.query, - }, - }) + responseItem.humanInputFormDataList[currentFormIndex]!.expiration_time = + humanInputFormTimeoutData.expiration_time } - - onConversationComplete?.(conversationIdRef.current, completedWorkflowRunId) - - if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) { - try { - const { data }: any = await onGetSuggestedQuestions( - responseItem.id, - newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController, - ) - setSuggestedQuestions(data) - } - catch { - setSuggestedQuestions([]) - } - } - } - finally { - settleSend(hasError) - } - }, - onFile(file) { - // Convert simple file type to MIME type for non-agent mode - // Backend sends: { id, type: "image", belongs_to, url } - // Frontend expects: { id, type: "image/png", transferMethod, url, uploadedId, supportFileType, name, size } - - // Determine file type for MIME conversion - const fileType = (file as { type?: string }).type || 'image' - - // If file already has transferMethod, use it as base and ensure all required fields exist - // Otherwise, create a new complete file object - const baseFile = ('transferMethod' in file) ? (file as Partial<FileEntity>) : null - - const convertedFile: FileEntity = { - id: baseFile?.id || (file as { id: string }).id, - type: baseFile?.type || (fileType === 'image' ? 'image/png' : fileType === 'video' ? 'video/mp4' : fileType === 'audio' ? 'audio/mpeg' : 'application/octet-stream'), - transferMethod: (baseFile?.transferMethod as FileEntity['transferMethod']) || (fileType === 'image' ? 'remote_url' : 'local_file'), - uploadedId: baseFile?.uploadedId || (file as { id: string }).id, - supportFileType: baseFile?.supportFileType || (fileType === 'image' ? 'image' : fileType === 'video' ? 'video' : fileType === 'audio' ? 'audio' : 'document'), - progress: baseFile?.progress ?? 100, - name: baseFile?.name || `generated_${fileType}.${fileType === 'image' ? 'png' : fileType === 'video' ? 'mp4' : fileType === 'audio' ? 'mp3' : 'bin'}`, - url: baseFile?.url || (file as { url?: string }).url, - size: baseFile?.size ?? 0, // Generated files don't have a known size - } - - // For agent mode, add files to the last thought - const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1] - if (lastThought) { - const thought = lastThought as { message_files?: FileEntity[] } - responseItem.agent_thoughts!.at(-1)!.message_files = [...(thought.message_files ?? []), convertedFile] - } - // For non-agent mode, add files directly to responseItem.message_files - else { - const currentFiles = (responseItem.message_files as FileEntity[] | undefined) ?? [] - responseItem.message_files = [...currentFiles, convertedFile] - } - - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onThought(thought) { - isAgentMode = true - const response = responseItem as any - if (thought.message_id && !hasSetResponseId) - response.id = thought.message_id - if (thought.conversation_id) - response.conversationId = thought.conversation_id - - if (response.agent_thoughts.length === 0) { - response.agent_thoughts.push(thought) - } - else { - const lastThought = response.agent_thoughts.at(-1) - // thought changed but still the same thought, so update. - if (lastThought.id === thought.id) { - responseItem.agent_thoughts![response.agent_thoughts.length - 1] = mergeStreamingThought(lastThought, thought) - } - else { - responseItem.agent_thoughts!.push(thought) - } - } - if (options.isNewAgent) { - const currentThought = responseItem.agent_thoughts?.find(item => item.id === thought.id) ?? thought - upsertAgentResponseThoughtPart(responseItem, currentThought) - } - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onMessageEnd: (messageEnd) => { - if (messageEnd.metadata?.annotation_reply) { - responseItem.id = messageEnd.id - responseItem.annotation = ({ - id: messageEnd.metadata.annotation_reply.id, - authorName: messageEnd.metadata.annotation_reply.account.name, - }) updateCurrentQAOnTree({ placeholderQuestionId, questionItem, responseItem, parentId: data.parent_message_id, }) - handleResponding(false) - return - } - responseItem.citation = messageEnd.metadata?.retriever_resources || [] - const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || []) - responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id') - - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onMessageReplace: (messageReplace) => { - responseItem.content = messageReplace.answer - }, - onError() { - handleResponding(false) - settleSend(true) - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onWorkflowStarted: ({ workflow_run_id, task_id, conversation_id, message_id }) => { - // If there are no streaming messages, we still need to set the conversation_id to avoid create a new conversation when regeneration in chat-flow. - if (conversation_id) { - conversationIdRef.current = conversation_id - } - if (message_id && !hasSetResponseId) { - questionItem.id = `question-${message_id}` - responseItem.id = message_id - responseItem.parentMessageId = questionItem.id - hasSetResponseId = true - } - - if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) { - responseItem.workflowProcess = { - ...responseItem.workflowProcess, - status: WorkflowRunningStatus.Running, - error: undefined, - } - } - else { - taskIdRef.current = task_id - responseItem.workflow_run_id = workflow_run_id - responseItem.workflowProcess = { - status: WorkflowRunningStatus.Running, - tracing: [], - } - } - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onWorkflowFinished: ({ data: workflowFinishedData }) => { - if (pausedStateRef.current) - pausedStateRef.current = false - responseItem.workflowProcess = { - ...responseItem.workflowProcess!, - status: workflowFinishedData.status as WorkflowRunningStatus, - error: workflowFinishedData.error, - } - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onIterationStart: ({ data: iterationStartedData }) => { - responseItem.workflowProcess!.tracing!.push({ - ...iterationStartedData, - status: WorkflowRunningStatus.Running, - }) - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onIterationFinish: ({ data: iterationFinishedData }) => { - const tracing = responseItem.workflowProcess!.tracing! - const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id - && (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))! - tracing[iterationIndex] = { - ...tracing[iterationIndex], - ...iterationFinishedData, - status: WorkflowRunningStatus.Succeeded, - } - - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onNodeStarted: ({ data: nodeStartedData }) => { - if (!responseItem.workflowProcess) - return - if (!responseItem.workflowProcess.tracing) - responseItem.workflowProcess.tracing = [] - - const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id) - if (currentIndex > -1) { - responseItem.workflowProcess.tracing[currentIndex] = { - ...nodeStartedData, - status: NodeRunningStatus.Running, - } - } - else { - if (nodeStartedData.iteration_id) - return - - if (data.loop_id) - return - - responseItem.workflowProcess.tracing.push({ - ...nodeStartedData, - status: WorkflowRunningStatus.Running, - }) - } - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onNodeFinished: ({ data: nodeFinishedData }) => { - if (nodeFinishedData.iteration_id) - return - - if (data.loop_id) - return - - const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => { - if (!item.execution_metadata?.parallel_id) - return item.id === nodeFinishedData.id - - return item.id === nodeFinishedData.id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata?.parallel_id) - }) - responseItem.workflowProcess!.tracing[currentIndex] = nodeFinishedData as any - - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onTTSChunk: (messageId: string, audio: string) => { - if (!audio || audio === '') - return - const audioPlayer = getOrCreatePlayer() - if (audioPlayer) { - audioPlayer.playAudioWithAudio(audio, true) - AudioPlayerManager.getInstance().resetMsgId(messageId) - } - }, - onTTSEnd: (messageId: string, audio: string) => { - const audioPlayer = getOrCreatePlayer() - if (audioPlayer) - audioPlayer.playAudioWithAudio(audio, false) - }, - onLoopStart: ({ data: loopStartedData }) => { - responseItem.workflowProcess!.tracing!.push({ - ...loopStartedData, - status: WorkflowRunningStatus.Running, - }) - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onLoopFinish: ({ data: loopFinishedData }) => { - const tracing = responseItem.workflowProcess!.tracing! - const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id - && (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))! - tracing[loopIndex] = { - ...tracing[loopIndex], - ...loopFinishedData, - status: WorkflowRunningStatus.Succeeded, - } - - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onHumanInputRequired: ({ data: humanInputRequiredData }) => { - if (!responseItem.humanInputFormDataList) { - responseItem.humanInputFormDataList = [humanInputRequiredData] - } - else { - const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === humanInputRequiredData.node_id) - if (currentFormIndex > -1) { - responseItem.humanInputFormDataList[currentFormIndex] = humanInputRequiredData - } - else { - responseItem.humanInputFormDataList.push(humanInputRequiredData) - } - } - const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === humanInputRequiredData.node_id) - if (currentTracingIndex > -1) { - responseItem.workflowProcess!.tracing[currentTracingIndex]!.status = NodeRunningStatus.Paused + }, + onWorkflowPaused: ({ data: workflowPausedData }) => { + const url = `/workflow/${workflowPausedData.workflow_run_id}/events` + pausedStateRef.current = true + sseGet(url, {}, otherOptions) + responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused updateCurrentQAOnTree({ placeholderQuestionId, questionItem, responseItem, parentId: data.parent_message_id, }) - } - }, - onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => { - let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined - if (responseItem.humanInputFormDataList?.length) { - const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === humanInputFilledFormData.node_id) - if (currentFormIndex > -1) { - requiredFormData = responseItem.humanInputFormDataList[currentFormIndex] - responseItem.humanInputFormDataList.splice(currentFormIndex, 1) - } - } - const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData(humanInputFilledFormData, requiredFormData) - if (!responseItem.humanInputFilledFormDataList) { - responseItem.humanInputFilledFormDataList = [enrichedHumanInputFilledFormData] - } - else { - responseItem.humanInputFilledFormDataList.push(enrichedHumanInputFilledFormData) - } - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => { - if (responseItem.humanInputFormDataList?.length) { - const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === humanInputFormTimeoutData.node_id) - responseItem.humanInputFormDataList[currentFormIndex]!.expiration_time = humanInputFormTimeoutData.expiration_time - } - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - onWorkflowPaused: ({ data: workflowPausedData }) => { - const url = `/workflow/${workflowPausedData.workflow_run_id}/events` - pausedStateRef.current = true - sseGet( - url, - {}, - otherOptions, - ) - responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused - updateCurrentQAOnTree({ - placeholderQuestionId, - questionItem, - responseItem, - parentId: data.parent_message_id, - }) - }, - } + }, + } - // Abort the previous workflow events SSE request - if (workflowEventsAbortControllerRef.current) - workflowEventsAbortControllerRef.current.abort() + // Abort the previous workflow events SSE request + if (workflowEventsAbortControllerRef.current) workflowEventsAbortControllerRef.current.abort() - ssePost( - url, - { - body: bodyParams, - }, - otherOptions, - ) - return true - }, [ - t, - chatTree.length, - threadMessages, - config?.suggested_questions_after_answer, - updateCurrentQAOnTree, - updateChatTreeNode, - handleResponding, - formatTime, - createAudioPlayerManager, - formSettings, - options.isNewAgent, - ]) - - const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => { - const targetQuestionId = chatList[index - 1]!.id - const targetAnswerId = chatList[index]!.id - - updateChatTreeNode(targetQuestionId, { - content: query, - }) - updateChatTreeNode(targetAnswerId, { - content: answer, - annotation: { - ...chatList[index]!.annotation, - logAnnotation: undefined, - } as any, - }) - }, [chatList, updateChatTreeNode]) + ssePost( + url, + { + body: bodyParams, + }, + otherOptions, + ) + return true + }, + [ + t, + chatTree.length, + threadMessages, + config?.suggested_questions_after_answer, + updateCurrentQAOnTree, + updateChatTreeNode, + handleResponding, + formatTime, + createAudioPlayerManager, + formSettings, + options.isNewAgent, + ], + ) - const handleAnnotationAdded = useCallback((annotationId: string, authorName: string, query: string, answer: string, index: number) => { - const targetQuestionId = chatList[index - 1]!.id - const targetAnswerId = chatList[index]!.id + const handleAnnotationEdited = useCallback( + (query: string, answer: string, index: number) => { + const targetQuestionId = chatList[index - 1]!.id + const targetAnswerId = chatList[index]!.id - updateChatTreeNode(targetQuestionId, { - content: query, - }) + updateChatTreeNode(targetQuestionId, { + content: query, + }) + updateChatTreeNode(targetAnswerId, { + content: answer, + annotation: { + ...chatList[index]!.annotation, + logAnnotation: undefined, + } as any, + }) + }, + [chatList, updateChatTreeNode], + ) + + const handleAnnotationAdded = useCallback( + (annotationId: string, authorName: string, query: string, answer: string, index: number) => { + const targetQuestionId = chatList[index - 1]!.id + const targetAnswerId = chatList[index]!.id - updateChatTreeNode(targetAnswerId, { - content: chatList[index]!.content, - annotation: { - id: annotationId, - authorName, - logAnnotation: { - content: answer, - account: { - id: '', - name: authorName, - email: '', + updateChatTreeNode(targetQuestionId, { + content: query, + }) + + updateChatTreeNode(targetAnswerId, { + content: chatList[index]!.content, + annotation: { + id: annotationId, + authorName, + logAnnotation: { + content: answer, + account: { + id: '', + name: authorName, + email: '', + }, }, - }, - } as Annotation, - }) - }, [chatList, updateChatTreeNode]) + } as Annotation, + }) + }, + [chatList, updateChatTreeNode], + ) - const handleAnnotationRemoved = useCallback((index: number) => { - const targetAnswerId = chatList[index]!.id + const handleAnnotationRemoved = useCallback( + (index: number) => { + const targetAnswerId = chatList[index]!.id - updateChatTreeNode(targetAnswerId, { - content: chatList[index]!.content, - annotation: { - ...chatList[index]!.annotation, - id: '', - } as Annotation, - }) - }, [chatList, updateChatTreeNode]) - - const handleSwitchSibling = useCallback(( - siblingMessageId: string, - callbacks: SendCallback, - ) => { - setTargetMessageId(siblingMessageId) - - // Helper to find message in tree - const findMessageInTree = (nodes: ChatItemInTree[], targetId: string): ChatItemInTree | undefined => { - for (const node of nodes) { - if (node.id === targetId) - return node - if (node.children) { - const found = findMessageInTree(node.children, targetId) - if (found) - return found + updateChatTreeNode(targetAnswerId, { + content: chatList[index]!.content, + annotation: { + ...chatList[index]!.annotation, + id: '', + } as Annotation, + }) + }, + [chatList, updateChatTreeNode], + ) + + const handleSwitchSibling = useCallback( + (siblingMessageId: string, callbacks: SendCallback) => { + setTargetMessageId(siblingMessageId) + + // Helper to find message in tree + const findMessageInTree = ( + nodes: ChatItemInTree[], + targetId: string, + ): ChatItemInTree | undefined => { + for (const node of nodes) { + if (node.id === targetId) return node + if (node.children) { + const found = findMessageInTree(node.children, targetId) + if (found) return found + } } + return undefined } - return undefined - } - const targetMessage = findMessageInTree(chatTreeRef.current, siblingMessageId) - if (targetMessage?.workflow_run_id && targetMessage.humanInputFormDataList && targetMessage.humanInputFormDataList.length > 0) { - handleResume( - targetMessage.id, - targetMessage.workflow_run_id, - callbacks, - ) - } - }, [setTargetMessageId, handleResume]) + const targetMessage = findMessageInTree(chatTreeRef.current, siblingMessageId) + if ( + targetMessage?.workflow_run_id && + targetMessage.humanInputFormDataList && + targetMessage.humanInputFormDataList.length > 0 + ) { + handleResume(targetMessage.id, targetMessage.workflow_run_id, callbacks) + } + }, + [setTargetMessageId, handleResume], + ) useEffect(() => { - if (clearChatList) - handleRestart(() => clearChatListCallback?.(false)) + if (clearChatList) handleRestart(() => clearChatListCallback?.(false)) }, [clearChatList, clearChatListCallback, handleRestart]) return { diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx index 7bd9c9989b9c51..00c9e7505a2fa0 100644 --- a/web/app/components/base/chat/chat/index.tsx +++ b/web/app/components/base/chat/chat/index.tsx @@ -1,15 +1,6 @@ -import type { - FC, - ReactNode, -} from 'react' +import type { FC, ReactNode } from 'react' import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context' -import type { - ChatConfig, - ChatItem, - Feedback, - OnRegenerate, - OnSend, -} from '../types' +import type { ChatConfig, ChatItem, Feedback, OnRegenerate, OnSend } from '../types' import type { HumanInputFormSubmitData } from './answer/human-input-content/type' import type { InputForm } from './type' import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types' @@ -53,7 +44,13 @@ export type ChatProps = { answerIcon?: ReactNode allToolIcons?: Record<string, ToolIcon> onAnnotationEdited?: (question: string, answer: string, index: number) => void - onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void + onAnnotationAdded?: ( + annotationId: string, + authorName: string, + question: string, + answer: string, + index: number, + ) => void onAnnotationRemoved?: (index: number) => void chatNode?: ReactNode disableFeedback?: boolean @@ -140,26 +137,31 @@ const Chat: FC<ChatProps> = ({ getHumanInputNodeData, }) => { const { t } = useTranslation() - const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({ - currentLogItem: state.currentLogItem, - setCurrentLogItem: state.setCurrentLogItem, - showPromptLogModal: state.showPromptLogModal, - setShowPromptLogModal: state.setShowPromptLogModal, - showAgentLogModal: state.showAgentLogModal, - setShowAgentLogModal: state.setShowAgentLogModal, - }))) const { - width, - chatContainerRef, - chatContainerInnerRef, - chatFooterRef, - chatFooterInnerRef, - } = useChatLayout({ - chatList, - sidebarCollapseState, - }) + currentLogItem, + setCurrentLogItem, + showPromptLogModal, + setShowPromptLogModal, + showAgentLogModal, + setShowAgentLogModal, + } = useAppStore( + useShallow((state) => ({ + currentLogItem: state.currentLogItem, + setCurrentLogItem: state.setCurrentLogItem, + showPromptLogModal: state.showPromptLogModal, + setShowPromptLogModal: state.setShowPromptLogModal, + showAgentLogModal: state.showAgentLogModal, + setShowAgentLogModal: state.setShowAgentLogModal, + })), + ) + const { width, chatContainerRef, chatContainerInnerRef, chatFooterRef, chatFooterInnerRef } = + useChatLayout({ + chatList, + sidebarCollapseState, + }) - const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend + const hasTryToAsk = + config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend return ( <ChatContextProvider @@ -183,51 +185,58 @@ const Chat: FC<ChatProps> = ({ <div data-testid="chat-container" ref={chatContainerRef} - className={cn('relative h-full overflow-x-hidden overflow-y-auto', isTryApp && 'h-0 grow', chatContainerClassName)} + className={cn( + 'relative h-full overflow-x-hidden overflow-y-auto', + isTryApp && 'h-0 grow', + chatContainerClassName, + )} > {chatNode} <div ref={chatContainerInnerRef} - className={cn('w-full', !noSpacing && 'px-8', chatContainerInnerClassName, isTryApp && 'px-0')} + className={cn( + 'w-full', + !noSpacing && 'px-8', + chatContainerInnerClassName, + isTryApp && 'px-0', + )} > - { - chatList.map((item, index) => { - if (item.isAnswer) { - const isLast = item.id === chatList.at(-1)?.id - return ( - <Answer - appData={appData} - key={item.id} - item={item} - question={chatList[index - 1]?.content ?? ''} - index={index} - config={config} - answerIcon={answerIcon} - responding={isLast && isResponding} - showPromptLog={showPromptLog} - chatAnswerContainerInner={chatAnswerContainerInner} - hideProcessDetail={hideProcessDetail} - noChatInput={noChatInput} - switchSibling={switchSibling} - hideAvatar={hideAvatar} - renderAgentContent={renderAgentContent} - onHumanInputFormSubmit={onHumanInputFormSubmit} - /> - ) - } + {chatList.map((item, index) => { + if (item.isAnswer) { + const isLast = item.id === chatList.at(-1)?.id return ( - <Question + <Answer + appData={appData} key={item.id} item={item} - questionIcon={questionIcon} - theme={themeBuilder?.theme} - enableEdit={config?.questionEditEnable} + question={chatList[index - 1]?.content ?? ''} + index={index} + config={config} + answerIcon={answerIcon} + responding={isLast && isResponding} + showPromptLog={showPromptLog} + chatAnswerContainerInner={chatAnswerContainerInner} + hideProcessDetail={hideProcessDetail} + noChatInput={noChatInput} switchSibling={switchSibling} hideAvatar={hideAvatar} + renderAgentContent={renderAgentContent} + onHumanInputFormSubmit={onHumanInputFormSubmit} /> ) - }) - } + } + return ( + <Question + key={item.id} + item={item} + questionIcon={questionIcon} + theme={themeBuilder?.theme} + enableEdit={config?.questionEditEnable} + switchSibling={switchSibling} + hideAvatar={hideAvatar} + /> + ) + })} </div> </div> <div @@ -240,53 +249,51 @@ const Chat: FC<ChatProps> = ({ > <div ref={chatFooterInnerRef} - className={cn('pointer-events-none relative', chatFooterInnerClassName, isTryApp && 'px-0')} + className={cn( + 'pointer-events-none relative', + chatFooterInnerClassName, + isTryApp && 'px-0', + )} > - { - !noStopResponding && isResponding && ( - <div data-testid="stop-responding-container" className="mb-2 flex justify-center"> - <Button className="pointer-events-auto border-components-panel-border bg-components-panel-bg text-components-button-secondary-text" onClick={onStopResponding}> - <div className="mr-[5px] i-custom-vender-solid-mediaAndDevices-stop-circle h-3.5 w-3.5" /> - <span className="text-xs font-normal">{t($ => $['operation.stopResponding'], { ns: 'appDebug' })}</span> - </Button> - </div> - ) - } - { - hasTryToAsk && ( - <TryToAsk - suggestedQuestions={suggestedQuestions} - onSend={onSend} - /> - ) - } - { - !noChatInput && ( - <ChatInputArea - botName={inputPlaceholderBotName || appData?.site?.title || 'Bot'} - customPlaceholder={inputPlaceholder ?? appData?.site?.input_placeholder} - disabled={inputDisabled} - showFeatureBar={showFeatureBar} - showFileUpload={showFileUpload} - featureBarReadonly={featureBarReadonly} - featureBarDisabled={isResponding} - onFeatureBarClick={onFeatureBarClick} - visionConfig={config?.file_upload} - speechToTextConfig={config?.speech_to_text} - onSend={onSend} - inputs={inputs} - inputsForm={inputsForm} - theme={themeBuilder?.theme} - isResponding={isResponding} - readonly={readonly} - sendButtonLabel={sendButtonLabel} - sendButtonLoading={sendButtonLoading} - footerNotice={footerNotice} - footerNoticeTooltip={footerNoticeTooltip} - sendOnEnter={sendOnEnter} - /> - ) - } + {!noStopResponding && isResponding && ( + <div data-testid="stop-responding-container" className="mb-2 flex justify-center"> + <Button + className="pointer-events-auto border-components-panel-border bg-components-panel-bg text-components-button-secondary-text" + onClick={onStopResponding} + > + <div className="mr-[5px] i-custom-vender-solid-mediaAndDevices-stop-circle h-3.5 w-3.5" /> + <span className="text-xs font-normal"> + {t(($) => $['operation.stopResponding'], { ns: 'appDebug' })} + </span> + </Button> + </div> + )} + {hasTryToAsk && <TryToAsk suggestedQuestions={suggestedQuestions} onSend={onSend} />} + {!noChatInput && ( + <ChatInputArea + botName={inputPlaceholderBotName || appData?.site?.title || 'Bot'} + customPlaceholder={inputPlaceholder ?? appData?.site?.input_placeholder} + disabled={inputDisabled} + showFeatureBar={showFeatureBar} + showFileUpload={showFileUpload} + featureBarReadonly={featureBarReadonly} + featureBarDisabled={isResponding} + onFeatureBarClick={onFeatureBarClick} + visionConfig={config?.file_upload} + speechToTextConfig={config?.speech_to_text} + onSend={onSend} + inputs={inputs} + inputsForm={inputsForm} + theme={themeBuilder?.theme} + isResponding={isResponding} + readonly={readonly} + sendButtonLabel={sendButtonLabel} + sendButtonLoading={sendButtonLoading} + footerNotice={footerNotice} + footerNoticeTooltip={footerNoticeTooltip} + sendOnEnter={sendOnEnter} + /> + )} </div> </div> <ChatLogModals diff --git a/web/app/components/base/chat/chat/loading-anim/index.tsx b/web/app/components/base/chat/chat/loading-anim/index.tsx index a352e1ec7d720c..5ad7309f83327d 100644 --- a/web/app/components/base/chat/chat/loading-anim/index.tsx +++ b/web/app/components/base/chat/chat/loading-anim/index.tsx @@ -8,11 +8,7 @@ type ILoadingAnimProps = { type: 'text' | 'avatar' } -const LoadingAnim: FC<ILoadingAnimProps> = ({ - type, -}) => { - return ( - <div className={cn(s['dot-flashing'], s[type])} /> - ) +const LoadingAnim: FC<ILoadingAnimProps> = ({ type }) => { + return <div className={cn(s['dot-flashing'], s[type])} /> } export default React.memo(LoadingAnim) diff --git a/web/app/components/base/chat/chat/loading-anim/style.module.css b/web/app/components/base/chat/chat/loading-anim/style.module.css index d5a373df6ffb86..243bd3f4808593 100644 --- a/web/app/components/base/chat/chat/loading-anim/style.module.css +++ b/web/app/components/base/chat/chat/loading-anim/style.module.css @@ -6,7 +6,7 @@ .dot-flashing::before, .dot-flashing::after { - content: ""; + content: ''; display: inline-block; position: absolute; top: 0; @@ -34,7 +34,7 @@ @keyframes dot-flashing-avatar { 0% { - background-color: #155EEF; + background-color: #155eef; } 50%, @@ -74,8 +74,8 @@ width: 2px; height: 2px; border-radius: 50%; - background-color: #155EEF; - color: #155EEF; + background-color: #155eef; + color: #155eef; animation: dot-flashing-avatar 1s infinite linear alternate; } diff --git a/web/app/components/base/chat/chat/log/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/log/__tests__/index.spec.tsx index 2235c9db1d91f2..ac1906f111ae9e 100644 --- a/web/app/components/base/chat/chat/log/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/log/__tests__/index.spec.tsx @@ -26,20 +26,22 @@ describe('Log', () => { }) beforeEach(() => { - vi.mocked(useAppStore).mockImplementation(selector => selector({ - // State properties - currentLogModalActiveTab: 'question', - showPromptLogModal: false, - showAgentLogModal: false, - showMessageLogModal: false, - showAppConfigureFeaturesModal: false, // Fixed: Added missing required property - currentLogItem: null, - // Action functions - setCurrentLogItem: mockSetCurrentLogItem, - setShowPromptLogModal: mockSetShowPromptLogModal, - setShowAgentLogModal: mockSetShowAgentLogModal, - setShowMessageLogModal: mockSetShowMessageLogModal, - } as unknown as Parameters<typeof selector>[0])) // Fixed: Double cast to avoid overlap error + vi.mocked(useAppStore).mockImplementation((selector) => + selector({ + // State properties + currentLogModalActiveTab: 'question', + showPromptLogModal: false, + showAgentLogModal: false, + showMessageLogModal: false, + showAppConfigureFeaturesModal: false, // Fixed: Added missing required property + currentLogItem: null, + // Action functions + setCurrentLogItem: mockSetCurrentLogItem, + setShowPromptLogModal: mockSetShowPromptLogModal, + setShowAgentLogModal: mockSetShowAgentLogModal, + setShowMessageLogModal: mockSetShowMessageLogModal, + } as unknown as Parameters<typeof selector>[0]), + ) // Fixed: Double cast to avoid overlap error }) it('should render correctly', () => { @@ -53,8 +55,7 @@ describe('Log', () => { render(<Log logItem={logItem} />) const container = screen.getByRole('button').parentElement - if (container) - await user.click(container) + if (container) await user.click(container) expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem) expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(true) @@ -79,8 +80,7 @@ describe('Log', () => { render(<Log logItem={logItem} />) const container = screen.getByRole('button').parentElement - if (container) - await user.click(container) + if (container) await user.click(container) expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem) expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(true) @@ -95,8 +95,7 @@ describe('Log', () => { render(<Log logItem={logItem} />) const container = screen.getByRole('button').parentElement - if (container) - await user.click(container) + if (container) await user.click(container) expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem) expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true) @@ -114,8 +113,7 @@ describe('Log', () => { // Find the container div that has the onClick handler const container = screen.getByRole('button').parentElement - if (container) - await user.click(container) + if (container) await user.click(container) // 2. Assert that both were called expect(stopPropagationSpy).toHaveBeenCalled() diff --git a/web/app/components/base/chat/chat/log/index.tsx b/web/app/components/base/chat/chat/log/index.tsx index 2e13a6996aee7d..a67fa064505dc9 100644 --- a/web/app/components/base/chat/chat/log/index.tsx +++ b/web/app/components/base/chat/chat/log/index.tsx @@ -7,13 +7,11 @@ import ActionButton from '@/app/components/base/action-button' type LogProps = { logItem: IChatItem } -const Log: FC<LogProps> = ({ - logItem, -}) => { - const setCurrentLogItem = useAppStore(s => s.setCurrentLogItem) - const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal) - const setShowAgentLogModal = useAppStore(s => s.setShowAgentLogModal) - const setShowMessageLogModal = useAppStore(s => s.setShowMessageLogModal) +const Log: FC<LogProps> = ({ logItem }) => { + const setCurrentLogItem = useAppStore((s) => s.setCurrentLogItem) + const setShowPromptLogModal = useAppStore((s) => s.setShowPromptLogModal) + const setShowAgentLogModal = useAppStore((s) => s.setShowAgentLogModal) + const setShowMessageLogModal = useAppStore((s) => s.setShowMessageLogModal) const { workflow_run_id: runID, agent_thoughts } = logItem const isAgent = agent_thoughts && agent_thoughts.length > 0 @@ -24,12 +22,9 @@ const Log: FC<LogProps> = ({ e.stopPropagation() e.nativeEvent.stopImmediatePropagation() setCurrentLogItem(logItem) - if (runID) - setShowMessageLogModal(true) - else if (isAgent) - setShowAgentLogModal(true) - else - setShowPromptLogModal(true) + if (runID) setShowMessageLogModal(true) + else if (isAgent) setShowAgentLogModal(true) + else setShowPromptLogModal(true) }} > <ActionButton> diff --git a/web/app/components/base/chat/chat/question.stories.tsx b/web/app/components/base/chat/chat/question.stories.tsx index fa3d3ef540d30d..3090e8f30108be 100644 --- a/web/app/components/base/chat/chat/question.stories.tsx +++ b/web/app/components/base/chat/chat/question.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' - import type { ChatItem } from '../types' import { User } from '@/app/components/base/icons/src/public/avatar' import Question from './question' diff --git a/web/app/components/base/chat/chat/question.tsx b/web/app/components/base/chat/chat/question.tsx index 42ddd6a8875df8..88fa754a6541c5 100644 --- a/web/app/components/base/chat/chat/question.tsx +++ b/web/app/components/base/chat/chat/question.tsx @@ -1,20 +1,11 @@ -import type { - FC, - ReactNode, -} from 'react' +import type { FC, ReactNode } from 'react' import type { Theme } from '../embedded-chatbot/theme/theme-context' import type { ChatItem } from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import copy from 'copy-to-clipboard' -import { - memo, - useCallback, - useEffect, - useRef, - useState, -} from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Textarea from 'react-textarea-autosize' import { FileList } from '@/app/components/base/file-uploader' @@ -44,16 +35,11 @@ const Question: FC<QuestionProps> = ({ }) => { const { t } = useTranslation() - const { - content, - message_files, - } = item + const { content, message_files } = item - const { - onRegenerate, - } = useChatContext() - const copyLabel = t($ => $['operation.copy'], { ns: 'common' }) - const editLabel = t($ => $['operation.edit'], { ns: 'common' }) + const { onRegenerate } = useChatContext() + const copyLabel = t(($) => $['operation.copy'], { ns: 'common' }) + const editLabel = t(($) => $['operation.edit'], { ns: 'common' }) const [isEditing, setIsEditing] = useState(false) const [editedContent, setEditedContent] = useState(content) @@ -87,25 +73,25 @@ const Question: FC<QuestionProps> = ({ setEditedContent(content) }, [content]) - const handleEditInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => { - if (e.key !== 'Enter' || e.shiftKey) - return + const handleEditInputKeyDown = useCallback( + (e: React.KeyboardEvent<HTMLTextAreaElement>) => { + if (e.key !== 'Enter' || e.shiftKey) return - if (e.nativeEvent.isComposing) - return + if (e.nativeEvent.isComposing) return - if (isComposingRef.current) { - e.preventDefault() - return - } + if (isComposingRef.current) { + e.preventDefault() + return + } - e.preventDefault() - handleResend() - }, [handleResend]) + e.preventDefault() + handleResend() + }, + [handleResend], + ) const clearCompositionEndTimer = useCallback(() => { - if (!compositionEndTimerRef.current) - return + if (!compositionEndTimerRef.current) return clearTimeout(compositionEndTimerRef.current) compositionEndTimerRef.current = null @@ -124,27 +110,25 @@ const Question: FC<QuestionProps> = ({ }, 50) }, [clearCompositionEndTimer]) - const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => { - if (direction === 'prev') { - if (item.prevSibling) - switchSibling?.(item.prevSibling) - } - else { - if (item.nextSibling) - switchSibling?.(item.nextSibling) - } - }, [switchSibling, item.prevSibling, item.nextSibling]) + const handleSwitchSibling = useCallback( + (direction: 'prev' | 'next') => { + if (direction === 'prev') { + if (item.prevSibling) switchSibling?.(item.prevSibling) + } else { + if (item.nextSibling) switchSibling?.(item.nextSibling) + } + }, + [switchSibling, item.prevSibling, item.nextSibling], + ) const getContentWidth = () => { /* v8 ignore next 2 -- @preserve */ - if (contentRef.current) - setContentWidth(contentRef.current?.clientWidth) + if (contentRef.current) setContentWidth(contentRef.current?.clientWidth) } useEffect(() => { /* v8 ignore next 2 -- @preserve */ - if (!contentRef.current) - return + if (!contentRef.current) return const resizeObserver = new ResizeObserver(() => { getContentWidth() }) @@ -162,7 +146,12 @@ const Question: FC<QuestionProps> = ({ return ( <div className="mb-2 flex justify-end last:mb-0"> - <div className={cn('group relative mr-4 flex max-w-full items-start overflow-x-hidden pl-14', isEditing && 'flex-1')}> + <div + className={cn( + 'group relative mr-4 flex max-w-full items-start overflow-x-hidden pl-14', + isEditing && 'flex-1', + )} + > <div className={cn('mr-2 gap-1', isEditing ? 'hidden' : 'flex')}> <div data-testid="action-container" @@ -173,7 +162,7 @@ const Question: FC<QuestionProps> = ({ aria-label={copyLabel} onClick={() => { copy(content) - toast.success(t($ => $['actionMsg.copySuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.copySuccessfully'], { ns: 'common' })) }} > <div className="i-ri-clipboard-line size-4" aria-hidden="true" /> @@ -190,45 +179,53 @@ const Question: FC<QuestionProps> = ({ data-testid="question-content" className={cn( 'w-full px-4 py-3 text-sm', - !isEditing && 'rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 text-text-primary', - isEditing && 'rounded-[24px] border-[3px] border-components-option-card-option-selected-border bg-components-panel-bg-blur shadow-lg', + !isEditing && + 'rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 text-text-primary', + isEditing && + 'rounded-[24px] border-[3px] border-components-option-card-option-selected-border bg-components-panel-bg-blur shadow-lg', )} - style={(!isEditing && theme?.chatBubbleColorStyle) ? CssTransform(theme.chatBubbleColorStyle) : {}} - > - { - !!message_files?.length && ( - <FileList - className={cn(isEditing ? 'mb-3' : 'mb-2')} - files={message_files} - showDeleteAction={false} - showDownloadAction={true} - /> - ) + style={ + !isEditing && theme?.chatBubbleColorStyle + ? CssTransform(theme.chatBubbleColorStyle) + : {} } - {!isEditing - ? <Markdown content={content} /> - : ( - <div className="flex flex-col gap-4"> - <div className="max-h-[158px] overflow-x-hidden overflow-y-auto pr-1"> - <Textarea - className={cn( - 'w-full resize-none bg-transparent p-0 body-lg-regular leading-7 text-text-primary outline-hidden', - )} - autoFocus - minRows={1} - value={editedContent} - onChange={e => setEditedContent(e.target.value)} - onKeyDown={handleEditInputKeyDown} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - /> - </div> - <div className="flex items-center justify-end gap-2"> - <Button className="min-w-24" onClick={handleCancelEditing}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button className="min-w-24" variant="primary" onClick={handleResend}>{t($ => $['operation.save'], { ns: 'common' })}</Button> - </div> - </div> - )} + > + {!!message_files?.length && ( + <FileList + className={cn(isEditing ? 'mb-3' : 'mb-2')} + files={message_files} + showDeleteAction={false} + showDownloadAction={true} + /> + )} + {!isEditing ? ( + <Markdown content={content} /> + ) : ( + <div className="flex flex-col gap-4"> + <div className="max-h-[158px] overflow-x-hidden overflow-y-auto pr-1"> + <Textarea + className={cn( + 'w-full resize-none bg-transparent p-0 body-lg-regular leading-7 text-text-primary outline-hidden', + )} + autoFocus + minRows={1} + value={editedContent} + onChange={(e) => setEditedContent(e.target.value)} + onKeyDown={handleEditInputKeyDown} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + /> + </div> + <div className="flex items-center justify-end gap-2"> + <Button className="min-w-24" onClick={handleCancelEditing}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button className="min-w-24" variant="primary" onClick={handleResend}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> + </div> + </div> + )} {!isEditing && ( <ContentSwitch count={item.siblingCount} @@ -243,13 +240,11 @@ const Question: FC<QuestionProps> = ({ </div> {!hideAvatar && ( <div className="size-10 shrink-0"> - { - questionIcon || ( - <div className="h-full w-full rounded-full border-[0.5px] border-black/5"> - <User className="question-default-user-icon size-full" /> - </div> - ) - } + {questionIcon || ( + <div className="h-full w-full rounded-full border-[0.5px] border-black/5"> + <User className="question-default-user-icon size-full" /> + </div> + )} </div> )} </div> diff --git a/web/app/components/base/chat/chat/thought/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/thought/__tests__/index.spec.tsx index dc1ba746743a91..8234b78e3bdec2 100644 --- a/web/app/components/base/chat/chat/thought/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/thought/__tests__/index.spec.tsx @@ -4,13 +4,14 @@ import userEvent from '@testing-library/user-event' import Thought from '../index' describe('Thought', () => { - const createThought = (overrides?: Partial<ThoughtItem>): ThoughtItem => ({ - id: 'test-id', - tool: 'test-tool', - tool_input: 'test input', - observation: 'test output', - ...overrides, - } as ThoughtItem) + const createThought = (overrides?: Partial<ThoughtItem>): ThoughtItem => + ({ + id: 'test-id', + tool: 'test-tool', + tool_input: 'test input', + observation: 'test output', + ...overrides, + }) as ThoughtItem beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/base/chat/chat/thought/index.tsx b/web/app/components/base/chat/chat/thought/index.tsx index 00b8676cd84bc7..f4fa36341c69db 100644 --- a/web/app/components/base/chat/chat/thought/index.tsx +++ b/web/app/components/base/chat/chat/thought/index.tsx @@ -13,24 +13,16 @@ function getValue(value: string, isValueArray: boolean, index: number) { if (isValueArray) { try { return JSON.parse(value)[index] - } - catch { - } + } catch {} } return value } -const Thought: FC<IThoughtProps> = ({ - thought, - isFinished, -}) => { +const Thought: FC<IThoughtProps> = ({ thought, isFinished }) => { const [toolNames, isValueArray]: [string[], boolean] = (() => { try { - if (Array.isArray(JSON.parse(thought.tool))) - return [JSON.parse(thought.tool), true] - } - catch { - } + if (Array.isArray(JSON.parse(thought.tool))) return [JSON.parse(thought.tool), true] + } catch {} return [[thought.tool], false] })() @@ -47,10 +39,7 @@ const Thought: FC<IThoughtProps> = ({ return ( <div className="my-2 space-y-2"> {toolThoughtList.map((item: ToolInfoInThought, index) => ( - <ToolDetail - key={index} - payload={item} - /> + <ToolDetail key={index} payload={item} /> ))} </div> ) diff --git a/web/app/components/base/chat/chat/try-to-ask.tsx b/web/app/components/base/chat/chat/try-to-ask.tsx index 0e99f3c2387e06..e42259bae6a9e0 100644 --- a/web/app/components/base/chat/chat/try-to-ask.tsx +++ b/web/app/components/base/chat/chat/try-to-ask.tsx @@ -9,33 +9,30 @@ type TryToAskProps = { suggestedQuestions: string[] onSend: OnSend } -const TryToAsk: FC<TryToAskProps> = ({ - suggestedQuestions, - onSend, -}) => { +const TryToAsk: FC<TryToAskProps> = ({ suggestedQuestions, onSend }) => { const { t } = useTranslation() return ( <div className="mb-2 py-2"> <div className="mb-2.5 flex items-center justify-between gap-2"> <Divider bgStyle="gradient" className="h-px w-auto! grow rotate-180" /> - <div className="shrink-0 system-xs-medium-uppercase text-text-tertiary">{t($ => $['feature.suggestedQuestionsAfterAnswer.tryToAsk'], { ns: 'appDebug' })}</div> + <div className="shrink-0 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['feature.suggestedQuestionsAfterAnswer.tryToAsk'], { ns: 'appDebug' })} + </div> <Divider bgStyle="gradient" className="h-px w-auto! grow" /> </div> <div className="flex flex-wrap justify-center"> - { - suggestedQuestions.map((suggestQuestion, index) => ( - <Button - size="small" - key={index} - variant="secondary-accent" - className="pointer-events-auto mr-1 mb-1 last:mr-0" - onClick={() => onSend(suggestQuestion)} - > - {suggestQuestion} - </Button> - )) - } + {suggestedQuestions.map((suggestQuestion, index) => ( + <Button + size="small" + key={index} + variant="secondary-accent" + className="pointer-events-auto mr-1 mb-1 last:mr-0" + onClick={() => onSend(suggestQuestion)} + > + {suggestQuestion} + </Button> + ))} </div> </div> ) diff --git a/web/app/components/base/chat/chat/type.ts b/web/app/components/base/chat/chat/type.ts index 4c0c80f975b92b..76d62bf8e8d7ca 100644 --- a/web/app/components/base/chat/chat/type.ts +++ b/web/app/components/base/chat/chat/type.ts @@ -2,11 +2,7 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { InputVarType } from '@/app/components/workflow/types' import type { Annotation, MessageRating } from '@/models/log' -import type { - FileResponse, - HumanInputFilledFormData, - HumanInputFormData, -} from '@/types/workflow' +import type { FileResponse, HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow' type MessageMore = { time: string @@ -20,14 +16,8 @@ export type FeedbackType = { content?: string | null } -export type FeedbackFunc = ( - messageId: string, - feedback: FeedbackType, -) => Promise<any> -export type SubmitAnnotationFunc = ( - messageId: string, - content: string, -) => Promise<any> +export type FeedbackFunc = (messageId: string, feedback: FeedbackType) => Promise<any> +export type SubmitAnnotationFunc = (messageId: string, content: string) => Promise<any> export type ToolInfoInThought = { name: string @@ -52,15 +42,15 @@ export type ThoughtItem = { message_files?: FileEntity[] } -type AgentResponsePart - = | { - type: 'thought' - thought: ThoughtItem - } +type AgentResponsePart = | { - type: 'message' - content: string - } + type: 'thought' + thought: ThoughtItem + } + | { + type: 'message' + content: string + } export type CitationItem = { content: string @@ -122,7 +112,7 @@ export type IChatItem = { useCurrentUserAvatar?: boolean isOpeningStatement?: boolean suggestedQuestions?: string[] - log?: { role: string, text: string, files?: FileEntity[] }[] + log?: { role: string; text: string; files?: FileEntity[] }[] agent_thoughts?: ThoughtItem[] agent_response_parts?: AgentResponsePart[] // for LLM reasoning (chain-of-thought) in "separated" mode, keyed by LLM node id diff --git a/web/app/components/base/chat/chat/use-chat-layout.ts b/web/app/components/base/chat/chat/use-chat-layout.ts index 1983a928ca9e41..0bea24a3495314 100644 --- a/web/app/components/base/chat/chat/use-chat-layout.ts +++ b/web/app/components/base/chat/chat/use-chat-layout.ts @@ -1,20 +1,18 @@ import type { ChatItem } from '../types' import { debounce } from 'es-toolkit/compat' -import { - useCallback, - useEffect, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' type UseChatLayoutOptions = { chatList: ChatItem[] sidebarCollapseState?: boolean } -const setStyleValue = (element: HTMLElement, property: 'paddingBottom' | 'width', value: string) => { - if (element.style[property] !== value) - element.style[property] = value +const setStyleValue = ( + element: HTMLElement, + property: 'paddingBottom' | 'width', + value: string, +) => { + if (element.style[property] !== value) element.style[property] = value } export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => { @@ -44,19 +42,22 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO const handleWindowResize = useCallback(() => { if (chatContainerRef.current) { const nextWidth = document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8 - setWidth(currentWidth => currentWidth === nextWidth ? currentWidth : nextWidth) + setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth)) } if (chatContainerRef.current && chatFooterRef.current) setStyleValue(chatFooterRef.current, 'width', `${chatContainerRef.current.clientWidth}px`) if (chatContainerInnerRef.current && chatFooterInnerRef.current) - setStyleValue(chatFooterInnerRef.current, 'width', `${chatContainerInnerRef.current.clientWidth}px`) + setStyleValue( + chatFooterInnerRef.current, + 'width', + `${chatContainerInnerRef.current.clientWidth}px`, + ) }, []) const scheduleResizeObserverUpdate = useCallback(() => { - if (resizeObserverFrameRef.current !== null) - return + if (resizeObserverFrameRef.current !== null) return resizeObserverFrameRef.current = requestAnimationFrame(() => { resizeObserverFrameRef.current = null @@ -137,10 +138,8 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO useEffect(() => { const setUserScrolled = () => { const container = chatContainerRef.current - if (!container) - return - if (isAutoScrollingRef.current) - return + if (!container) return + if (isAutoScrollingRef.current) return const distanceToBottom = container.scrollHeight - container.clientHeight - container.scrollTop const scrollUpThreshold = 100 @@ -149,8 +148,7 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO } const container = chatContainerRef.current - if (!container) - return + if (!container) return container.addEventListener('scroll', setUserScrolled) return () => container.removeEventListener('scroll', setUserScrolled) @@ -158,7 +156,10 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO useEffect(() => { const firstMessageId = chatList[0]?.id - if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId)) + if ( + chatList.length <= 1 || + (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId) + ) userScrolledRef.current = false prevFirstMessageIdRef.current = firstMessageId }, [chatList]) diff --git a/web/app/components/base/chat/chat/utils.ts b/web/app/components/base/chat/chat/utils.ts index a64c8162dcee39..840ed386d3470a 100644 --- a/web/app/components/base/chat/chat/utils.ts +++ b/web/app/components/base/chat/chat/utils.ts @@ -2,17 +2,21 @@ import type { InputForm } from './type' import { getProcessedFiles } from '@/app/components/base/file-uploader/utils' import { InputVarType } from '@/app/components/workflow/types' -export const processOpeningStatement = (openingStatement: string, inputs: Record<string, any>, inputsForm: InputForm[]) => { - if (!openingStatement) - return openingStatement +export const processOpeningStatement = ( + openingStatement: string, + inputs: Record<string, any>, + inputsForm: InputForm[], +) => { + if (!openingStatement) return openingStatement return openingStatement.replace(/\{\{([^}]+)\}\}/g, (match, key) => { const name = inputs[key] - if (name) { // has set value + if (name) { + // has set value return name } - const valueObj = inputsForm.find(v => v.variable === key) + const valueObj = inputsForm.find((v) => v.variable === key) return valueObj ? `{{${valueObj.label}}}` : match }) } @@ -37,31 +41,23 @@ export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: Inpu return } - if (inputValue == null) - return + if (inputValue == null) return if (item.type === InputVarType.singleFile) { if ('transfer_method' in inputValue) processedInputs[item.variable] = processInputFileFromServer(inputValue) - else - processedInputs[item.variable] = getProcessedFiles([inputValue])[0] - } - else if (item.type === InputVarType.multiFiles) { + else processedInputs[item.variable] = getProcessedFiles([inputValue])[0] + } else if (item.type === InputVarType.multiFiles) { if ('transfer_method' in inputValue[0]) processedInputs[item.variable] = inputValue.map(processInputFileFromServer) - else - processedInputs[item.variable] = getProcessedFiles(inputValue) - } - else if (item.type === InputVarType.jsonObject) { + else processedInputs[item.variable] = getProcessedFiles(inputValue) + } else if (item.type === InputVarType.jsonObject) { // Prefer sending an object if the user entered valid JSON; otherwise keep the raw string. try { const v = typeof inputValue === 'string' ? JSON.parse(inputValue) : inputValue - if (v && typeof v === 'object' && !Array.isArray(v)) - processedInputs[item.variable] = v - else - processedInputs[item.variable] = inputValue - } - catch { + if (v && typeof v === 'object' && !Array.isArray(v)) processedInputs[item.variable] = v + else processedInputs[item.variable] = inputValue + } catch { // keep original string; backend will parse/validate processedInputs[item.variable] = inputValue } diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx index 1f530f81ffd8dd..0416d8c0533011 100644 --- a/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx @@ -3,19 +3,9 @@ import type { HumanInputFieldValue } from '../../chat/answer/human-input-content import type { ChatConfig, ChatItem, ChatItemInTree } from '../../types' import type { EmbeddedChatbotContextValue } from '../context' import type { ConversationItem } from '@/models/share' -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { InputVarType } from '@/app/components/workflow/types' -import { - AppSourceType, - fetchSuggestedQuestions, - submitHumanInputForm, -} from '@/service/share' +import { AppSourceType, fetchSuggestedQuestions, submitHumanInputForm } from '@/service/share' import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import { useChat } from '../../chat/hooks' import ChatWrapper from '../chat-wrapper' @@ -58,28 +48,58 @@ vi.mock('../../chat', () => ({ questionIcon?: React.ReactNode answerIcon?: React.ReactNode onSend: (message: string) => void - onRegenerate: (chatItem: ChatItem, editedQuestion?: { message: string, files?: never[] }) => void + onRegenerate: ( + chatItem: ChatItem, + editedQuestion?: { message: string; files?: never[] }, + ) => void switchSibling: (siblingMessageId: string) => void - onHumanInputFormSubmit: (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void> + onHumanInputFormSubmit: ( + formToken: string, + formData: { inputs: Record<string, HumanInputFieldValue>; action: string }, + ) => Promise<void> onStopResponding: () => void }) => ( <div> <div>{chatNode}</div> {answerIcon} - {chatList.map(item => <div key={item.id}>{item.content}</div>)} - <div> - chat count: - {' '} - {chatList.length} - </div> + {chatList.map((item) => ( + <div key={item.id}>{item.content}</div> + ))} + <div>chat count: {chatList.length}</div> {questionIcon} <button onClick={() => onSend('hello world')}>send through chat</button> - <button onClick={() => onRegenerate({ id: 'answer-1', isAnswer: true, content: 'answer', parentMessageId: 'question-1' })}>regenerate answer</button> - <button onClick={() => onRegenerate({ id: 'answer-1', isAnswer: true, content: 'answer', parentMessageId: 'question-1' }, { message: 'new query' })}>regenerate edited</button> + <button + onClick={() => + onRegenerate({ + id: 'answer-1', + isAnswer: true, + content: 'answer', + parentMessageId: 'question-1', + }) + } + > + regenerate answer + </button> + <button + onClick={() => + onRegenerate( + { id: 'answer-1', isAnswer: true, content: 'answer', parentMessageId: 'question-1' }, + { message: 'new query' }, + ) + } + > + regenerate edited + </button> <button onClick={() => switchSibling('sibling-2')}>switch sibling</button> <button disabled={inputDisabled}>send message</button> <button onClick={onStopResponding}>stop responding</button> - <button onClick={() => onHumanInputFormSubmit('form-token', { inputs: { answer: 'ok' }, action: 'approve' })}>submit human input</button> + <button + onClick={() => + onHumanInputFormSubmit('form-token', { inputs: { answer: 'ok' }, action: 'approve' }) + } + > + submit human input + </button> </div> ), })) @@ -106,7 +126,9 @@ vi.mock('../utils', () => ({ type UseChatReturn = ReturnType<typeof useChat> -const createContextValue = (overrides: Partial<EmbeddedChatbotContextValue> = {}): EmbeddedChatbotContextValue => ({ +const createContextValue = ( + overrides: Partial<EmbeddedChatbotContextValue> = {}, +): EmbeddedChatbotContextValue => ({ appMeta: { tool_icons: {} }, appData: { app_id: 'app-1', @@ -200,49 +222,83 @@ describe('EmbeddedChatbot chat-wrapper', () => { describe('Welcome behavior', () => { it('should show opening message and suggested question for a new chat', () => { const handleSwitchSibling = vi.fn() - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - handleSwitchSibling, - chatList: [{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome to the app', suggestedQuestions: ['How does it work?'] }], - })) - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - appPrevChatList: [ - { - id: 'parent-node', - content: 'parent', - isAnswer: true, - children: [ - { - id: 'paused-workflow', - content: 'paused', - isAnswer: true, - workflow_run_id: 'run-1', - humanInputFormDataList: [{ label: 'Need info' }], - } as unknown as ChatItem, - ], - } as unknown as ChatItem, - ], - })) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + handleSwitchSibling, + chatList: [ + { + id: 'opening-1', + isAnswer: true, + isOpeningStatement: true, + content: 'Welcome to the app', + suggestedQuestions: ['How does it work?'], + }, + ], + }), + ) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + appPrevChatList: [ + { + id: 'parent-node', + content: 'parent', + isAnswer: true, + children: [ + { + id: 'paused-workflow', + content: 'paused', + isAnswer: true, + workflow_run_id: 'run-1', + humanInputFormDataList: [{ label: 'Need info' }], + } as unknown as ChatItem, + ], + } as unknown as ChatItem, + ], + }), + ) render(<ChatWrapper />) expect(screen.getByText('How does it work?')).toBeInTheDocument() - expect(handleSwitchSibling).toHaveBeenCalledWith('paused-workflow', expect.objectContaining({ - isPublicAPI: true, - })) - const resumeOptions = handleSwitchSibling.mock.calls[0]?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void } + expect(handleSwitchSibling).toHaveBeenCalledWith( + 'paused-workflow', + expect.objectContaining({ + isPublicAPI: true, + }), + ) + const resumeOptions = handleSwitchSibling.mock.calls[0]?.[1] as { + onGetSuggestedQuestions: (responseItemId: string) => void + } resumeOptions.onGetSuggestedQuestions('resume-1') - expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resume-1', AppSourceType.webApp, 'app-1') + expect(fetchSuggestedQuestions).toHaveBeenCalledWith( + 'resume-1', + AppSourceType.webApp, + 'app-1', + ) }) it('should hide or show welcome content based on chat state', () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - inputsForms: [{ variable: 'name', label: 'Name', required: true, type: InputVarType.textInput }], - currentConversationId: '', - allInputsHidden: false, - })) - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - chatList: [{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome to the app' }], - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + inputsForms: [ + { variable: 'name', label: 'Name', required: true, type: InputVarType.textInput }, + ], + currentConversationId: '', + allInputsHidden: false, + }), + ) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + chatList: [ + { + id: 'opening-1', + isAnswer: true, + isOpeningStatement: true, + content: 'Welcome to the app', + }, + ], + }), + ) render(<ChatWrapper />) @@ -250,36 +306,67 @@ describe('EmbeddedChatbot chat-wrapper', () => { expect(screen.getByText('inputs form')).toBeInTheDocument() cleanup() - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - inputsForms: [], - currentConversationId: '', - allInputsHidden: true, - })) - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - chatList: [{ id: 'opening-2', isAnswer: true, isOpeningStatement: true, content: 'Fallback welcome' }], - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + inputsForms: [], + currentConversationId: '', + allInputsHidden: true, + }), + ) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + chatList: [ + { + id: 'opening-2', + isAnswer: true, + isOpeningStatement: true, + content: 'Fallback welcome', + }, + ], + }), + ) render(<ChatWrapper />) expect(screen.queryByText('inputs form')).not.toBeInTheDocument() cleanup() - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - appData: null, - })) - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - isResponding: false, - chatList: [{ id: 'opening-3', isAnswer: true, isOpeningStatement: true, content: 'Should be hidden' }], - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + appData: null, + }), + ) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + isResponding: false, + chatList: [ + { + id: 'opening-3', + isAnswer: true, + isOpeningStatement: true, + content: 'Should be hidden', + }, + ], + }), + ) render(<ChatWrapper />) expect(screen.queryByText('Should be hidden')).not.toBeInTheDocument() cleanup() vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue()) - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - isResponding: true, - chatList: [{ id: 'opening-4', isAnswer: true, isOpeningStatement: true, content: 'Should be hidden while responding' }], - })) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + isResponding: true, + chatList: [ + { + id: 'opening-4', + isAnswer: true, + isOpeningStatement: true, + content: 'Should be hidden while responding', + }, + ], + }), + ) render(<ChatWrapper />) expect(screen.queryByText('Should be hidden while responding')).not.toBeInTheDocument() }) @@ -287,54 +374,73 @@ describe('EmbeddedChatbot chat-wrapper', () => { describe('Input and avatar behavior', () => { it('should disable sending when required fields are incomplete or uploading', () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - inputsForms: [{ variable: 'email', label: 'Email', required: true, type: InputVarType.textInput }], - newConversationInputsRef: { current: {} }, - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + inputsForms: [ + { variable: 'email', label: 'Email', required: true, type: InputVarType.textInput }, + ], + newConversationInputsRef: { current: {} }, + }), + ) render(<ChatWrapper />) expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled() cleanup() - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - inputsForms: [{ variable: 'file', label: 'File', required: true, type: InputVarType.multiFiles }], - newConversationInputsRef: { - current: { - file: [ - { - transferMethod: 'local_file', - }, - ], + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + inputsForms: [ + { variable: 'file', label: 'File', required: true, type: InputVarType.multiFiles }, + ], + newConversationInputsRef: { + current: { + file: [ + { + transferMethod: 'local_file', + }, + ], + }, }, - }, - })) + }), + ) render(<ChatWrapper />) expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled() cleanup() - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - inputsForms: [{ variable: 'singleFile', label: 'Single file', required: true, type: InputVarType.singleFile }], - newConversationInputsRef: { - current: { - singleFile: { - transferMethod: 'local_file', + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + inputsForms: [ + { + variable: 'singleFile', + label: 'Single file', + required: true, + type: InputVarType.singleFile, + }, + ], + newConversationInputsRef: { + current: { + singleFile: { + transferMethod: 'local_file', + }, }, }, - }, - })) + }), + ) render(<ChatWrapper />) expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled() }) it('should show the user avatar fallback when avatar data is provided', () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - initUserVariables: { - avatar_url: 'https://example.com/avatar.png', - name: 'Alice', - }, - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + initUserVariables: { + avatar_url: 'https://example.com/avatar.png', + name: 'Alice', + }, + }), + ) render(<ChatWrapper />) @@ -344,15 +450,20 @@ describe('EmbeddedChatbot chat-wrapper', () => { describe('Human input submit behavior', () => { it('should submit via installed app service when the app is installed', async () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - isInstalledApp: true, - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + isInstalledApp: true, + }), + ) render(<ChatWrapper />) fireEvent.click(screen.getByRole('button', { name: 'submit human input' })) await waitFor(() => { - expect(submitHumanInputFormService).toHaveBeenCalledWith('form-token', { inputs: { answer: 'ok' }, action: 'approve' }) + expect(submitHumanInputFormService).toHaveBeenCalledWith('form-token', { + inputs: { answer: 'ok' }, + action: 'approve', + }) }) expect(submitHumanInputForm).not.toHaveBeenCalled() }) @@ -361,23 +472,29 @@ describe('EmbeddedChatbot chat-wrapper', () => { const handleSend = vi.fn() const handleSwitchSibling = vi.fn() const handleStop = vi.fn() - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - handleSend, - handleSwitchSibling, - handleStop, - chatList: [ - { id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome' }, - { id: 'question-1', isAnswer: false, content: 'Question' }, - { id: 'answer-1', isAnswer: true, content: 'Answer', parentMessageId: 'question-1' }, - ] as ChatItemInTree[], - })) - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - isInstalledApp: false, - appSourceType: AppSourceType.tryApp, - isMobile: true, - inputsForms: [{ variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }], - currentConversationId: 'conversation-1', - })) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + handleSend, + handleSwitchSibling, + handleStop, + chatList: [ + { id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome' }, + { id: 'question-1', isAnswer: false, content: 'Question' }, + { id: 'answer-1', isAnswer: true, content: 'Answer', parentMessageId: 'question-1' }, + ] as ChatItemInTree[], + }), + ) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + isInstalledApp: false, + appSourceType: AppSourceType.tryApp, + isMobile: true, + inputsForms: [ + { variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }, + ], + currentConversationId: 'conversation-1', + }), + ) mockIsDify.mockReturnValue(true) render(<ChatWrapper />) @@ -392,15 +509,25 @@ describe('EmbeddedChatbot chat-wrapper', () => { fireEvent.click(screen.getByRole('button', { name: 'submit human input' })) await waitFor(() => { - expect(submitHumanInputForm).toHaveBeenCalledWith('form-token', { inputs: { answer: 'ok' }, action: 'approve' }) + expect(submitHumanInputForm).toHaveBeenCalledWith('form-token', { + inputs: { answer: 'ok' }, + action: 'approve', + }) }) expect(handleSend).toHaveBeenCalledTimes(2) - const sendOptions = handleSend.mock.calls[0]?.[2] as { onGetSuggestedQuestions: (responseItemId: string) => void } + const sendOptions = handleSend.mock.calls[0]?.[2] as { + onGetSuggestedQuestions: (responseItemId: string) => void + } sendOptions.onGetSuggestedQuestions('resp-1') - expect(handleSwitchSibling).toHaveBeenCalledWith('sibling-2', expect.objectContaining({ - isPublicAPI: false, - })) - const switchOptions = handleSwitchSibling.mock.calls.find(call => call[0] === 'sibling-2')?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void } + expect(handleSwitchSibling).toHaveBeenCalledWith( + 'sibling-2', + expect.objectContaining({ + isPublicAPI: false, + }), + ) + const switchOptions = handleSwitchSibling.mock.calls.find( + (call) => call[0] === 'sibling-2', + )?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void } switchOptions.onGetSuggestedQuestions('resp-2') expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resp-1', AppSourceType.tryApp, 'app-1') expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resp-2', AppSourceType.tryApp, 'app-1') @@ -408,66 +535,98 @@ describe('EmbeddedChatbot chat-wrapper', () => { expect(screen.queryByRole('img', { name: 'Alice' })).not.toBeInTheDocument() cleanup() - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - isMobile: true, - currentConversationId: '', - inputsForms: [{ variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }], - })) - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - chatList: [{ id: 'opening-mobile', isAnswer: true, isOpeningStatement: true, content: 'Mobile welcome' }], - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + isMobile: true, + currentConversationId: '', + inputsForms: [ + { variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }, + ], + }), + ) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + chatList: [ + { + id: 'opening-mobile', + isAnswer: true, + isOpeningStatement: true, + content: 'Mobile welcome', + }, + ], + }), + ) render(<ChatWrapper />) expect(screen.getByText('inputs form')).toBeInTheDocument() }) it('should not disable sending when a required checkbox is not checked', () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - inputsForms: [{ variable: 'agree', label: 'Agree', required: true, type: InputVarType.checkbox }], - newConversationInputsRef: { current: { agree: false } }, - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + inputsForms: [ + { variable: 'agree', label: 'Agree', required: true, type: InputVarType.checkbox }, + ], + newConversationInputsRef: { current: { agree: false } }, + }), + ) render(<ChatWrapper />) expect(screen.getByRole('button', { name: 'send message' })).not.toBeDisabled() }) it('should return null for chatNode when all inputs are hidden', () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - allInputsHidden: true, - inputsForms: [{ variable: 'test', label: 'Test', type: InputVarType.textInput }], - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + allInputsHidden: true, + inputsForms: [{ variable: 'test', label: 'Test', type: InputVarType.textInput }], + }), + ) render(<ChatWrapper />) expect(screen.queryByText('inputs form')).not.toBeInTheDocument() }) it('should render simple welcome message when suggested questions are absent', () => { - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - chatList: [{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Simple Welcome' }] as ChatItem[], - })) - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - currentConversationId: '', - })) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + chatList: [ + { + id: 'opening-1', + isAnswer: true, + isOpeningStatement: true, + content: 'Simple Welcome', + }, + ] as ChatItem[], + }), + ) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + currentConversationId: '', + }), + ) render(<ChatWrapper />) expect(screen.getByText('Simple Welcome')).toBeInTheDocument() }) it('should use icon as answer icon when enabled in site config', () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - appData: { - app_id: 'app-1', - can_replace_logo: true, - custom_config: { remove_webapp_brand: false, replace_webapp_logo: '' }, - enable_site: true, - end_user_id: 'user-1', - site: { - title: 'Embedded App', - icon_type: 'emoji', - icon: 'bot', - icon_background: '#000000', - icon_url: '', - use_icon_as_answer_icon: true, + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + appData: { + app_id: 'app-1', + can_replace_logo: true, + custom_config: { remove_webapp_brand: false, replace_webapp_logo: '' }, + enable_site: true, + end_user_id: 'user-1', + site: { + title: 'Embedded App', + icon_type: 'emoji', + icon: 'bot', + icon_background: '#000000', + icon_url: '', + use_icon_as_answer_icon: true, + }, }, - }, - })) + }), + ) render(<ChatWrapper />) }) }) @@ -480,10 +639,12 @@ describe('EmbeddedChatbot chat-wrapper', () => { { id: 'question-1', isAnswer: false, content: 'Old question' }, { id: 'answer-1', isAnswer: true, content: 'Old answer', parentMessageId: 'question-1' }, ] - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - handleSend, - chatList: chatList as ChatItem[], - })) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + handleSend, + chatList: chatList as ChatItem[], + }), + ) render(<ChatWrapper />) const regenBtn = screen.getByRole('button', { name: 'regenerate answer' }) @@ -493,70 +654,94 @@ describe('EmbeddedChatbot chat-wrapper', () => { }) it('should use opening statement from currentConversationItem if available', () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - appParams: { opening_statement: 'Global opening' } as ChatConfig, - currentConversationItem: { - id: 'conv-1', - name: 'Conversation 1', - inputs: {}, - introduction: 'Conversation specific opening', - } as ConversationItem, - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + appParams: { opening_statement: 'Global opening' } as ChatConfig, + currentConversationItem: { + id: 'conv-1', + name: 'Conversation 1', + inputs: {}, + introduction: 'Conversation specific opening', + } as ConversationItem, + }), + ) render(<ChatWrapper />) }) it('should handle mobile chatNode variants', () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - isMobile: true, - currentConversationId: 'conv-1', - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + isMobile: true, + currentConversationId: 'conv-1', + }), + ) render(<ChatWrapper />) }) it('should initialize collapsed based on currentConversationId and isTryApp', () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - currentConversationId: 'conv-1', - appSourceType: AppSourceType.tryApp, - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + currentConversationId: 'conv-1', + appSourceType: AppSourceType.tryApp, + }), + ) render(<ChatWrapper />) }) it('should resume paused workflows when chat history is loaded', () => { const handleSwitchSibling = vi.fn() - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - handleSwitchSibling, - })) - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - appPrevChatList: [ - { - id: 'node-1', - isAnswer: true, - content: '', - workflow_run_id: 'run-1', - humanInputFormDataList: [{ label: 'text', variable: 'v', required: true, type: InputVarType.textInput, hide: false }], - children: [], - } as unknown as ChatItemInTree, - ], - })) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + handleSwitchSibling, + }), + ) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + appPrevChatList: [ + { + id: 'node-1', + isAnswer: true, + content: '', + workflow_run_id: 'run-1', + humanInputFormDataList: [ + { + label: 'text', + variable: 'v', + required: true, + type: InputVarType.textInput, + hide: false, + }, + ], + children: [], + } as unknown as ChatItemInTree, + ], + }), + ) render(<ChatWrapper />) expect(handleSwitchSibling).toHaveBeenCalled() }) it('should handle conversation completion and suggested questions in chat actions', async () => { const handleSend = vi.fn() - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - handleSend, - })) - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - currentConversationId: 'conv-id', // index 0 true target - appSourceType: AppSourceType.webApp, - })) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + handleSend, + }), + ) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + currentConversationId: 'conv-id', // index 0 true target + appSourceType: AppSourceType.webApp, + }), + ) render(<ChatWrapper />) fireEvent.click(screen.getByRole('button', { name: 'send through chat' })) expect(handleSend).toHaveBeenCalled() - const options = handleSend.mock.calls[0]?.[2] as { onConversationComplete?: (id: string) => void } + const options = handleSend.mock.calls[0]?.[2] as { + onConversationComplete?: (id: string) => void + } expect(options.onConversationComplete).toBeUndefined() }) @@ -564,84 +749,124 @@ describe('EmbeddedChatbot chat-wrapper', () => { const handleSend = vi.fn() const chatList = [ { id: 'question-1', isAnswer: false, content: 'Q1' }, - { id: 'answer-1', isAnswer: true, content: 'A1', parentMessageId: 'question-1', metadata: { usage: { total_tokens: 10 } } }, + { + id: 'answer-1', + isAnswer: true, + content: 'A1', + parentMessageId: 'question-1', + metadata: { usage: { total_tokens: 10 } }, + }, ] - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - handleSend, - chatList: chatList as ChatItem[], - })) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + handleSend, + chatList: chatList as ChatItem[], + }), + ) render(<ChatWrapper />) fireEvent.click(screen.getByRole('button', { name: 'regenerate edited' })) - expect(handleSend).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ query: 'new query' }), expect.any(Object)) + expect(handleSend).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ query: 'new query' }), + expect.any(Object), + ) }) it('should handle fallback values for config and user data', () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - appParams: null, - appId: undefined, - initUserVariables: { avatar_url: 'url' }, // name is missing - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + appParams: null, + appId: undefined, + initUserVariables: { avatar_url: 'url' }, // name is missing + }), + ) render(<ChatWrapper />) }) it('should handle mobile view for welcome screens', () => { // Complex welcome mobile - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - chatList: [{ id: 'o-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q?'] }] as ChatItem[], - })) - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - isMobile: true, - currentConversationId: '', - })) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + chatList: [ + { + id: 'o-1', + isAnswer: true, + isOpeningStatement: true, + content: 'Welcome', + suggestedQuestions: ['Q?'], + }, + ] as ChatItem[], + }), + ) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + isMobile: true, + currentConversationId: '', + }), + ) render(<ChatWrapper />) cleanup() // Simple welcome mobile - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - chatList: [{ id: 'o-2', isAnswer: true, isOpeningStatement: true, content: 'Welcome' }] as ChatItem[], - })) - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - isMobile: true, - currentConversationId: '', - })) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + chatList: [ + { id: 'o-2', isAnswer: true, isOpeningStatement: true, content: 'Welcome' }, + ] as ChatItem[], + }), + ) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + isMobile: true, + currentConversationId: '', + }), + ) render(<ChatWrapper />) }) it('should handle loop early returns in input validation', () => { // hasEmptyInput early return (line 103) - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - inputsForms: [ - { variable: 'v1', label: 'V1', required: true, type: InputVarType.textInput }, - { variable: 'v2', label: 'V2', required: true, type: InputVarType.textInput }, - ], - newConversationInputsRef: { current: { v1: '', v2: '' } }, - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + inputsForms: [ + { variable: 'v1', label: 'V1', required: true, type: InputVarType.textInput }, + { variable: 'v2', label: 'V2', required: true, type: InputVarType.textInput }, + ], + newConversationInputsRef: { current: { v1: '', v2: '' } }, + }), + ) render(<ChatWrapper />) cleanup() // fileIsUploading early return (line 106) - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - inputsForms: [ - { variable: 'f1', label: 'F1', required: true, type: InputVarType.singleFile }, - { variable: 'v2', label: 'V2', required: true, type: InputVarType.textInput }, - ], - newConversationInputsRef: { - current: { - f1: { transferMethod: 'local_file', uploadedId: '' }, - v2: '', + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + inputsForms: [ + { variable: 'f1', label: 'F1', required: true, type: InputVarType.singleFile }, + { variable: 'v2', label: 'V2', required: true, type: InputVarType.textInput }, + ], + newConversationInputsRef: { + current: { + f1: { transferMethod: 'local_file', uploadedId: '' }, + v2: '', + }, }, - }, - })) + }), + ) render(<ChatWrapper />) }) it('should handle null/undefined refs and config fallbacks', () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({ - currentChatInstanceRef: { current: null } as unknown as RefObject<{ handleStop: () => void }>, - appParams: null, - appMeta: null, - })) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + currentChatInstanceRef: { current: null } as unknown as RefObject<{ + handleStop: () => void + }>, + appParams: null, + appMeta: null, + }), + ) render(<ChatWrapper />) }) @@ -650,12 +875,20 @@ describe('EmbeddedChatbot chat-wrapper', () => { // A valid generated answer needs metadata with usage const chatList = [ { id: 'question-1', isAnswer: false, content: 'Q' }, - { id: 'answer-1', isAnswer: true, content: 'A', metadata: { usage: { total_tokens: 10 } }, parentMessageId: 'question-1' }, + { + id: 'answer-1', + isAnswer: true, + content: 'A', + metadata: { usage: { total_tokens: 10 } }, + parentMessageId: 'question-1', + }, ] - vi.mocked(useChat).mockReturnValue(createUseChatReturn({ - handleSend, - chatList: chatList as ChatItem[], - })) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + handleSend, + chatList: chatList as ChatItem[], + }), + ) render(<ChatWrapper />) fireEvent.click(screen.getByRole('button', { name: 'regenerate answer' })) expect(handleSend).toHaveBeenCalled() diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx index a6a35f4c7b3369..0c9082bfc4962c 100644 --- a/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx @@ -50,7 +50,8 @@ const useWebAppStoreMock = vi.fn((selector?: (state: typeof mockStoreState) => u }) vi.mock('@/context/web-app-context', () => ({ - useWebAppStore: (selector?: (state: typeof mockStoreState) => unknown) => useWebAppStoreMock(selector), + useWebAppStore: (selector?: (state: typeof mockStoreState) => unknown) => + useWebAppStoreMock(selector), })) const { @@ -98,13 +99,14 @@ const mockFetchConversations = vi.mocked(fetchConversations) const mockFetchChatList = vi.mocked(fetchChatList) const mockGenerationConversationName = vi.mocked(generationConversationName) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, }, - }, -}) + }) const createWrapper = (queryClient: QueryClient) => { return ({ children }: { children: ReactNode }) => ( @@ -122,10 +124,12 @@ const renderWithClient = async <T,>(hook: () => T) => { act(() => { result = renderHook(hook, { wrapper }) }) - await waitFor(() => { - if (queryClient.isFetching() > 0) - throw new Error('Queries are still fetching') - }, { timeout: 2000 }) + await waitFor( + () => { + if (queryClient.isFetching() > 0) throw new Error('Queries are still fetching') + }, + { timeout: 2000 }, + ) return { queryClient, ...result!, @@ -140,7 +144,9 @@ const createConversationItem = (overrides: Partial<ConversationItem> = {}): Conv ...overrides, }) -const createConversationData = (overrides: Partial<AppConversationData> = {}): AppConversationData => ({ +const createConversationData = ( + overrides: Partial<AppConversationData> = {}, +): AppConversationData => ({ data: [createConversationItem()], has_more: false, limit: 100, @@ -188,9 +194,11 @@ describe('useEmbeddedChatbot', () => { const listData = createConversationData({ data: [createConversationItem({ id: 'conversation-1', name: 'First' })], }) - mockFetchConversations.mockImplementation(async (_isInstalledApp, _appId, _lastId, pinned) => { - return pinned ? pinnedData : listData - }) + mockFetchConversations.mockImplementation( + async (_isInstalledApp, _appId, _lastId, pinned) => { + return pinned ? pinnedData : listData + }, + ) mockFetchChatList.mockResolvedValue({ data: [] }) // Act @@ -198,13 +206,29 @@ describe('useEmbeddedChatbot', () => { // Assert await waitFor(() => { - expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', undefined, true, 100) + expect(mockFetchConversations).toHaveBeenCalledWith( + AppSourceType.webApp, + 'app-1', + undefined, + true, + 100, + ) }) await waitFor(() => { - expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', undefined, false, 100) + expect(mockFetchConversations).toHaveBeenCalledWith( + AppSourceType.webApp, + 'app-1', + undefined, + false, + 100, + ) }) await waitFor(() => { - expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1') + expect(mockFetchChatList).toHaveBeenCalledWith( + 'conversation-1', + AppSourceType.webApp, + 'app-1', + ) }) await waitFor(() => { expect(result.current.pinnedConversationList).toEqual(pinnedData.data) @@ -215,23 +239,34 @@ describe('useEmbeddedChatbot', () => { it('should format chat list history correctly into appPrevChatList', async () => { // Provide a currentConversationId by rendering successfully mockStoreState.embeddedConversationId = 'conversation-1' - mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({ conversation_id: 'conversation-1' }) + mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({ + conversation_id: 'conversation-1', + }) mockFetchChatList.mockResolvedValue({ - data: [{ - id: 'msg-1', - query: 'Hello', - answer: 'Hi there!', - message_files: [{ belongs_to: 'user', id: 'mf-1' }, { belongs_to: 'assistant', id: 'mf-2' }], - agent_thoughts: [{ id: 'at-1' }], - feedback: { rating: 'like' }, - }], + data: [ + { + id: 'msg-1', + query: 'Hello', + answer: 'Hi there!', + message_files: [ + { belongs_to: 'user', id: 'mf-1' }, + { belongs_to: 'assistant', id: 'mf-2' }, + ], + agent_thoughts: [{ id: 'at-1' }], + feedback: { rating: 'like' }, + }, + ], }) const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) // Wait for the mock to be called await waitFor(() => { - expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1') + expect(mockFetchChatList).toHaveBeenCalledWith( + 'conversation-1', + AppSourceType.webApp, + 'app-1', + ) }) // Wait for the chat list to be populated @@ -242,7 +277,9 @@ describe('useEmbeddedChatbot', () => { // We expect the formatting logic to split the message into question and answer ChatItems const chatList = result.current.appPrevChatList - const userMsg = chatList.find((msg: unknown) => (msg as Record<string, unknown>).id === 'question-msg-1') + const userMsg = chatList.find( + (msg: unknown) => (msg as Record<string, unknown>).id === 'question-msg-1', + ) expect(userMsg).toBeDefined() expect((userMsg as Record<string, unknown>)?.content).toBe('Hello') expect((userMsg as Record<string, unknown>)?.isAnswer).toBe(false) @@ -252,7 +289,9 @@ describe('useEmbeddedChatbot', () => { expect((assistantMsg as Record<string, unknown>)?.id).toBe('msg-1') expect((assistantMsg as Record<string, unknown>)?.content).toBe('Hi there!') expect((assistantMsg as Record<string, unknown>)?.isAnswer).toBe(true) - expect(((assistantMsg as Record<string, unknown>)?.feedback as Record<string, unknown>)?.rating).toBe('like') + expect( + ((assistantMsg as Record<string, unknown>)?.feedback as Record<string, unknown>)?.rating, + ).toBe('like') }) }) @@ -271,7 +310,9 @@ describe('useEmbeddedChatbot', () => { mockFetchChatList.mockResolvedValue({ data: [] }) mockGenerationConversationName.mockResolvedValue(generatedConversation) - const { result, queryClient } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) + const { result, queryClient } = await renderWithClient(() => + useEmbeddedChatbot(AppSourceType.webApp), + ) const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') // Act @@ -281,7 +322,11 @@ describe('useEmbeddedChatbot', () => { // Assert await waitFor(() => { - expect(mockGenerationConversationName).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', 'conversation-new') + expect(mockGenerationConversationName).toHaveBeenCalledWith( + AppSourceType.webApp, + 'app-1', + 'conversation-new', + ) }) await waitFor(() => { expect(result.current.conversationList[0]).toEqual(generatedConversation) @@ -299,7 +344,9 @@ describe('useEmbeddedChatbot', () => { }) mockFetchConversations.mockResolvedValue(listData) mockFetchChatList.mockResolvedValue({ data: [] }) - mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-1' })) + mockGenerationConversationName.mockResolvedValue( + createConversationItem({ id: 'conversation-1' }), + ) const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) @@ -329,7 +376,9 @@ describe('useEmbeddedChatbot', () => { }) mockFetchConversations.mockResolvedValue(listData) mockFetchChatList.mockResolvedValue({ data: [] }) - mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' })) + mockGenerationConversationName.mockResolvedValue( + createConversationItem({ id: 'conversation-new' }), + ) const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) @@ -354,13 +403,17 @@ describe('useEmbeddedChatbot', () => { it('should use tryApp source type and skip URL overrides and user fetch', async () => { // Arrange const { useGetTryAppInfo } = await import('@/service/use-try-app') - const mockTryAppInfo = { app_id: 'try-app-1', site: { title: 'Try App' } }; - (useGetTryAppInfo as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ data: mockTryAppInfo }) + const mockTryAppInfo = { app_id: 'try-app-1', site: { title: 'Try App' } } + ;(useGetTryAppInfo as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ + data: mockTryAppInfo, + }) mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({}) // Act - const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.tryApp, 'try-app-1')) + const { result } = await renderWithClient(() => + useEmbeddedChatbot(AppSourceType.tryApp, 'try-app-1'), + ) // Assert expect(result.current.isInstalledApp).toBe(false) @@ -377,7 +430,10 @@ describe('useEmbeddedChatbot', () => { describe('removeConversationIdInfo', () => { it('should successfully remove a stored conversation ID info by appId', async () => { // Setup some initial info - localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({ 'app-1': { 'user-1': 'conv-id' } })) + localStorage.setItem( + CONVERSATION_ID_INFO, + JSON.stringify({ 'app-1': { 'user-1': 'conv-id' } }), + ) const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) @@ -445,9 +501,7 @@ describe('useEmbeddedChatbot', () => { describe('checkInputsRequired and handleStartChat', () => { it('should return undefined and notify when file is still uploading', async () => { mockStoreState.appParams = { - user_input_form: [ - { file: { variable: 'file_var', required: true } }, - ], + user_input_form: [{ file: { variable: 'file_var', required: true } }], } as unknown as ChatConfig const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) @@ -462,7 +516,9 @@ describe('useEmbeddedChatbot', () => { const onStart = vi.fn() let checkResult: boolean | undefined act(() => { - checkResult = (result.current as unknown as { handleStartChat: (onStart?: () => void) => boolean }).handleStartChat(onStart) + checkResult = ( + result.current as unknown as { handleStartChat: (onStart?: () => void) => boolean } + ).handleStartChat(onStart) }) expect(checkResult).toBeUndefined() @@ -471,9 +527,7 @@ describe('useEmbeddedChatbot', () => { it('should fail checkInputsRequired when required fields are missing', async () => { mockStoreState.appParams = { - user_input_form: [ - { 'text-input': { variable: 't1', required: true, label: 'T1' } }, - ], + user_input_form: [{ 'text-input': { variable: 't1', required: true, label: 'T1' } }], } as unknown as ChatConfig const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) @@ -485,7 +539,9 @@ describe('useEmbeddedChatbot', () => { }) const onStart = vi.fn() act(() => { - (result.current as unknown as { handleStartChat: (cb?: () => void) => void }).handleStartChat(onStart) + ;( + result.current as unknown as { handleStartChat: (cb?: () => void) => void } + ).handleStartChat(onStart) }) expect(onStart).not.toHaveBeenCalled() @@ -502,7 +558,9 @@ describe('useEmbeddedChatbot', () => { const callback = vi.fn() act(() => { - (result.current as unknown as { handleStartChat: (cb?: () => void) => void }).handleStartChat(callback) + ;( + result.current as unknown as { handleStartChat: (cb?: () => void) => void } + ).handleStartChat(callback) }) expect(callback).toHaveBeenCalled() @@ -522,7 +580,9 @@ describe('useEmbeddedChatbot', () => { }) it('handleNewConversation sets clearChatList to true for tryApp without complex parsing', async () => { - const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.tryApp, 'app-try-1')) + const { result } = await renderWithClient(() => + useEmbeddedChatbot(AppSourceType.tryApp, 'app-try-1'), + ) await act(async () => { await result.current.handleNewConversation() @@ -543,7 +603,11 @@ describe('useEmbeddedChatbot', () => { expect(result.current.currentConversationId).toBe('another-convo') }) await waitFor(() => { - expect(mockFetchChatList).toHaveBeenCalledWith('another-convo', AppSourceType.webApp, 'app-1') + expect(mockFetchChatList).toHaveBeenCalledWith( + 'another-convo', + AppSourceType.webApp, + 'app-1', + ) }) expect(result.current.newConversationId).toBe('') expect(result.current.clearChatList).toBe(false) @@ -551,9 +615,12 @@ describe('useEmbeddedChatbot', () => { // Scenario: URL-provided conversation_id should take precedence over localStorage value. it('should prioritize URL conversation_id over localStorage', async () => { - localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({ - 'app-1': { 'embedded-user-1': 'stored-conv-id' }, - })) + localStorage.setItem( + CONVERSATION_ID_INFO, + JSON.stringify({ + 'app-1': { 'embedded-user-1': 'stored-conv-id' }, + }), + ) mockStoreState.embeddedConversationId = 'url-conv-id' mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({ user_id: 'embedded-user-1', @@ -569,9 +636,12 @@ describe('useEmbeddedChatbot', () => { // Scenario: When no URL conversation_id is provided, fall back to localStorage. it('should fall back to localStorage when no URL conversation_id is provided', async () => { - localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({ - 'app-1': { DEFAULT: 'stored-conv-id' }, - })) + localStorage.setItem( + CONVERSATION_ID_INFO, + JSON.stringify({ + 'app-1': { DEFAULT: 'stored-conv-id' }, + }), + ) mockStoreState.embeddedConversationId = null mockStoreState.embeddedUserId = null @@ -664,15 +734,13 @@ describe('useEmbeddedChatbot', () => { const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) const forms = result.current.inputsForms - expect(forms.find(f => f.variable === 'n1')?.default).toBe(10) - expect(forms.find(f => f.variable === 'c1')?.default).toBe(false) + expect(forms.find((f) => f.variable === 'n1')?.default).toBe(10) + expect(forms.find((f) => f.variable === 'c1')?.default).toBe(false) }) it('should handle select with invalid option and file-list/json types', async () => { mockStoreState.appParams = { - user_input_form: [ - { select: { variable: 's1', options: ['A'], default: 'A' } }, - ], + user_input_form: [{ select: { variable: 's1', options: ['A'], default: 'A' } }], } as unknown as ChatConfig mockGetProcessedInputsFromUrlParams.mockResolvedValue({ s1: 'INVALID', @@ -779,7 +847,9 @@ describe('useEmbeddedChatbot', () => { it('should handle multi-file uploading status', async () => { mockStoreState.appParams = { - user_input_form: [{ 'file-list': { variable: 'files', required: true, type: InputVarType.multiFiles } }], + user_input_form: [ + { 'file-list': { variable: 'files', required: true, type: InputVarType.multiFiles } }, + ], } as unknown as ChatConfig const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) @@ -802,7 +872,9 @@ describe('useEmbeddedChatbot', () => { it('should detect single-file upload still in progress', async () => { mockStoreState.appParams = { - user_input_form: [{ 'file-list': { variable: 'f1', required: true, type: InputVarType.singleFile } }], + user_input_form: [ + { 'file-list': { variable: 'f1', required: true, type: InputVarType.singleFile } }, + ], } as unknown as ChatConfig const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) @@ -848,24 +920,33 @@ describe('useEmbeddedChatbot', () => { describe('getFormattedChatList edge cases', () => { it('should handle messages with no message_files and no agent_thoughts', async () => { // Ensure a currentConversationId is set so appChatListData is fetched - localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({ 'app-1': { DEFAULT: 'conversation-1' } })) + localStorage.setItem( + CONVERSATION_ID_INFO, + JSON.stringify({ 'app-1': { DEFAULT: 'conversation-1' } }), + ) mockFetchConversations.mockResolvedValue( createConversationData({ data: [createConversationItem({ id: 'conversation-1' })] }), ) mockFetchChatList.mockResolvedValue({ - data: [{ - id: 'msg-no-files', - query: 'Q', - answer: 'A', - // no message_files, no agent_thoughts — exercises the || [] fallback branches - }], + data: [ + { + id: 'msg-no-files', + query: 'Q', + answer: 'A', + // no message_files, no agent_thoughts — exercises the || [] fallback branches + }, + ], }) const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) - await waitFor(() => expect(result.current.appPrevChatList.length).toBeGreaterThan(0), { timeout: 3000 }) + await waitFor(() => expect(result.current.appPrevChatList.length).toBeGreaterThan(0), { + timeout: 3000, + }) const chatList = result.current.appPrevChatList - const question = chatList.find((m: unknown) => (m as Record<string, unknown>).id === 'question-msg-no-files') + const question = chatList.find( + (m: unknown) => (m as Record<string, unknown>).id === 'question-msg-no-files', + ) expect(question).toBeDefined() }) }) @@ -875,27 +956,41 @@ describe('useEmbeddedChatbot', () => { const pinnedData = createConversationData({ data: [createConversationItem({ id: 'pinned-conv', name: 'Pinned' })], }) - mockFetchConversations.mockImplementation(async (_a: unknown, _b: unknown, _c: unknown, pinned?: boolean) => { - return pinned ? pinnedData : createConversationData({ data: [] }) - }) + mockFetchConversations.mockImplementation( + async (_a: unknown, _b: unknown, _c: unknown, pinned?: boolean) => { + return pinned ? pinnedData : createConversationData({ data: [] }) + }, + ) mockFetchChatList.mockResolvedValue({ data: [] }) - localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({ 'app-1': { DEFAULT: 'pinned-conv' } })) + localStorage.setItem( + CONVERSATION_ID_INFO, + JSON.stringify({ 'app-1': { DEFAULT: 'pinned-conv' } }), + ) const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) - await waitFor(() => { - expect(result.current.pinnedConversationList.length).toBeGreaterThan(0) - }, { timeout: 3000 }) - await waitFor(() => { - expect(result.current.currentConversationItem?.id).toBe('pinned-conv') - }, { timeout: 3000 }) + await waitFor( + () => { + expect(result.current.pinnedConversationList.length).toBeGreaterThan(0) + }, + { timeout: 3000 }, + ) + await waitFor( + () => { + expect(result.current.currentConversationItem?.id).toBe('pinned-conv') + }, + { timeout: 3000 }, + ) }) }) describe('newConversation updates existing item', () => { it('should update an existing conversation in the list when its id matches', async () => { const initialItem = createConversationItem({ id: 'conversation-1', name: 'Old Name' }) - const renamedItem = createConversationItem({ id: 'conversation-1', name: 'New Generated Name' }) + const renamedItem = createConversationItem({ + id: 'conversation-1', + name: 'New Generated Name', + }) mockFetchConversations.mockResolvedValue(createConversationData({ data: [initialItem] })) mockGenerationConversationName.mockResolvedValue(renamedItem) @@ -908,7 +1003,7 @@ describe('useEmbeddedChatbot', () => { }) await waitFor(() => { - const match = result.current.conversationList.find(c => c.id === 'conversation-1') + const match = result.current.conversationList.find((c) => c.id === 'conversation-1') expect(match?.name).toBe('New Generated Name') }) }) @@ -927,9 +1022,13 @@ describe('useEmbeddedChatbot', () => { const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) - await waitFor(() => expect(result.current.currentConversationItem?.id).toBe(convId), { timeout: 3000 }) + await waitFor(() => expect(result.current.currentConversationItem?.id).toBe(convId), { + timeout: 3000, + }) // After item is resolved, currentConversationInputs should be populated - await waitFor(() => expect(result.current.currentConversationInputs).toBeDefined(), { timeout: 3000 }) + await waitFor(() => expect(result.current.currentConversationInputs).toBeDefined(), { + timeout: 3000, + }) }) }) }) diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx index 0cd22c97be716f..073998c2a532a7 100644 --- a/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx @@ -9,11 +9,12 @@ import { useEmbeddedChatbot } from '../hooks' import EmbeddedChatbot from '../index' let mockBrandingWorkspaceLogo = '' -const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { - systemFeatures: { - branding: { enabled: true, workspace_logo: mockBrandingWorkspaceLogo }, - }, -}) +const render = (ui: ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { + branding: { enabled: true, workspace_logo: mockBrandingWorkspaceLogo }, + }, + }) vi.mock('../hooks', () => ({ useEmbeddedChatbot: vi.fn(), @@ -58,7 +59,9 @@ vi.mock('../utils', () => ({ type EmbeddedChatbotHookReturn = ReturnType<typeof useEmbeddedChatbot> -const createHookReturn = (overrides: Partial<EmbeddedChatbotHookReturn> = {}): EmbeddedChatbotHookReturn => { +const createHookReturn = ( + overrides: Partial<EmbeddedChatbotHookReturn> = {}, +): EmbeddedChatbotHookReturn => { const appData: AppData = { app_id: 'app-1', can_replace_logo: true, @@ -134,7 +137,9 @@ describe('EmbeddedChatbot index', () => { describe('Loading and chat content', () => { it('should show loading state before chat content', () => { - vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({ appChatListDataLoading: true })) + vi.mocked(useEmbeddedChatbot).mockReturnValue( + createHookReturn({ appChatListDataLoading: true }), + ) render(<EmbeddedChatbot />) @@ -156,52 +161,62 @@ describe('EmbeddedChatbot index', () => { render(<EmbeddedChatbot />) expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument() - expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png') + expect(screen.getByAltText('logo')).toHaveAttribute( + 'src', + 'https://example.com/workspace-logo.png', + ) }) it('should show custom logo when workspace branding logo is unavailable', () => { - vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({ - appData: { - app_id: 'app-1', - can_replace_logo: true, - custom_config: { - remove_webapp_brand: false, - replace_webapp_logo: 'https://example.com/custom-logo.png', - }, - enable_site: true, - end_user_id: 'user-1', - site: { - title: 'Embedded App', - chat_color_theme: 'blue', - chat_color_theme_inverted: false, + vi.mocked(useEmbeddedChatbot).mockReturnValue( + createHookReturn({ + appData: { + app_id: 'app-1', + can_replace_logo: true, + custom_config: { + remove_webapp_brand: false, + replace_webapp_logo: 'https://example.com/custom-logo.png', + }, + enable_site: true, + end_user_id: 'user-1', + site: { + title: 'Embedded App', + chat_color_theme: 'blue', + chat_color_theme_inverted: false, + }, }, - }, - })) + }), + ) render(<EmbeddedChatbot />) expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument() - expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/custom-logo.png') + expect(screen.getByAltText('logo')).toHaveAttribute( + 'src', + 'https://example.com/custom-logo.png', + ) }) it('should hide powered by section when branding is removed', () => { - vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({ - appData: { - app_id: 'app-1', - can_replace_logo: true, - custom_config: { - remove_webapp_brand: true, - replace_webapp_logo: '', - }, - enable_site: true, - end_user_id: 'user-1', - site: { - title: 'Embedded App', - chat_color_theme: 'blue', - chat_color_theme_inverted: false, + vi.mocked(useEmbeddedChatbot).mockReturnValue( + createHookReturn({ + appData: { + app_id: 'app-1', + can_replace_logo: true, + custom_config: { + remove_webapp_brand: true, + replace_webapp_logo: '', + }, + enable_site: true, + end_user_id: 'user-1', + site: { + title: 'Embedded App', + chat_color_theme: 'blue', + chat_color_theme_inverted: false, + }, }, - }, - })) + }), + ) render(<EmbeddedChatbot />) diff --git a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx index f60827c1db0f10..aaffc1331f103c 100644 --- a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx +++ b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx @@ -1,11 +1,6 @@ import type { FileEntity } from '../../file-uploader/types' import type { HumanInputFormSubmitData } from '../chat/answer/human-input-content/type' -import type { - ChatConfig, - ChatItem, - ChatItemInTree, - OnSend, -} from '../types' +import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '../types' import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react' @@ -64,8 +59,7 @@ const ChatWrapper = () => { // Read sendOnEnter from URL params (e.g., ?sendOnEnter=false) const sendOnEnter = useMemo(() => { - if (typeof window === 'undefined') - return true + if (typeof window === 'undefined') return true const urlParams = new URLSearchParams(window.location.search) const param = urlParams.get('sendOnEnter') return param !== 'false' @@ -84,9 +78,10 @@ const ChatWrapper = () => { opening_statement: currentConversationItem?.introduction || (config as any).opening_statement, } as ChatConfig }, [appParams, currentConversationItem?.introduction]) - const timezone = appSourceType === AppSourceType.webApp - ? new Intl.DateTimeFormat().resolvedOptions().timeZone - : undefined + const timezone = + appSourceType === AppSourceType.webApp + ? new Intl.DateTimeFormat().resolvedOptions().timeZone + : undefined const { chatList, handleSend, @@ -101,51 +96,54 @@ const ChatWrapper = () => { inputsForm: inputsForms, }, appPrevChatList, - taskId => stopChatMessageResponding('', taskId, appSourceType, appId), + (taskId) => stopChatMessageResponding('', taskId, appSourceType, appId), clearChatList, setClearChatList, undefined, { timezone }, ) - const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current + const inputsFormValue = currentConversationId + ? currentConversationInputs + : newConversationInputsRef?.current const inputDisabled = useMemo(() => { - if (allInputsHidden) - return false + if (allInputsHidden) return false let hasEmptyInput = '' let fileIsUploading = false - const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox) // boolean can be not checked + const requiredVars = inputsForms.filter( + ({ required, type }) => required && type !== InputVarType.checkbox, + ) // boolean can be not checked if (requiredVars.length) { requiredVars.forEach(({ variable, label, type }) => { - if (hasEmptyInput) - return + if (hasEmptyInput) return - if (fileIsUploading) - return + if (fileIsUploading) return - if (!inputsFormValue?.[variable]) - hasEmptyInput = label as string + if (!inputsFormValue?.[variable]) hasEmptyInput = label as string - if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputsFormValue?.[variable]) { + if ( + (type === InputVarType.singleFile || type === InputVarType.multiFiles) && + inputsFormValue?.[variable] + ) { const files = inputsFormValue[variable] if (Array.isArray(files)) - fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId) + fileIsUploading = files.find( + (item) => item.transferMethod === TransferMethod.local_file && !item.uploadedId, + ) else - fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId + fileIsUploading = + files.transferMethod === TransferMethod.local_file && !files.uploadedId } }) } - if (hasEmptyInput) - return true + if (hasEmptyInput) return true - if (fileIsUploading) - return true + if (fileIsUploading) return true return false }, [inputsFormValue, inputsForms, allInputsHidden]) useEffect(() => { - if (currentChatInstanceRef.current) - currentChatInstanceRef.current.handleStop = handleStop + if (currentChatInstanceRef.current) currentChatInstanceRef.current.handleStop = handleStop }, [currentChatInstanceRef, handleStop]) useEffect(() => { setIsResponding(respondingState) @@ -153,19 +151,22 @@ const ChatWrapper = () => { // Resume paused workflows when chat history is loaded useEffect(() => { - if (!appPrevChatList || appPrevChatList.length === 0) - return + if (!appPrevChatList || appPrevChatList.length === 0) return // Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first) let lastPausedNode: ChatItemInTree | undefined const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => { nodes.forEach((node) => { // DFS: recurse to children first - if (node.children && node.children.length > 0) - findLastPausedWorkflow(node.children) + if (node.children && node.children.length > 0) findLastPausedWorkflow(node.children) // Track the last node with humanInputFormDataList - if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0) + if ( + node.isAnswer && + node.workflow_run_id && + node.humanInputFormDataList && + node.humanInputFormDataList.length > 0 + ) lastPausedNode = node }) } @@ -174,14 +175,12 @@ const ChatWrapper = () => { // Only resume the last paused workflow if (lastPausedNode) { - handleSwitchSibling( - lastPausedNode.id, - { - onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), - onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, - isPublicAPI: appSourceType === AppSourceType.webApp, - }, - ) + handleSwitchSibling(lastPausedNode.id, { + onGetSuggestedQuestions: (responseItemId) => + fetchSuggestedQuestions(responseItemId, appSourceType, appId), + onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, + isPublicAPI: appSourceType === AppSourceType.webApp, + }) } }, []) @@ -189,50 +188,76 @@ const ChatWrapper = () => { const [prevConversationId, setPrevConversationId] = useState(currentConversationId) if (prevConversationId !== currentConversationId) { setPrevConversationId(currentConversationId) - if (!currentConversationId) - setHasSent(false) + if (!currentConversationId) setHasSent(false) } - const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { - if (!currentConversationId) - setHasSent(true) - const data: any = { - query: message, - files, - inputs: currentConversationId ? currentConversationInputs : newConversationInputs, - conversation_id: currentConversationId, - parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, - } - handleSend( - getUrl('chat-messages', appSourceType, appId || ''), - data, - { - onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), + const doSend: OnSend = useCallback( + (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { + if (!currentConversationId) setHasSent(true) + const data: any = { + query: message, + files, + inputs: currentConversationId ? currentConversationInputs : newConversationInputs, + conversation_id: currentConversationId, + parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, + } + handleSend(getUrl('chat-messages', appSourceType, appId || ''), data, { + onGetSuggestedQuestions: (responseItemId) => + fetchSuggestedQuestions(responseItemId, appSourceType, appId), onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, isPublicAPI: appSourceType === AppSourceType.webApp, - }, - ) - }, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, handleNewConversationCompleted]) + }) + }, + [ + currentConversationId, + currentConversationInputs, + newConversationInputs, + chatList, + handleSend, + appSourceType, + appId, + handleNewConversationCompleted, + ], + ) - const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => { - const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! - const parentAnswer = chatList.find(item => item.id === question.parentMessageId) - doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) - }, [chatList, doSend]) + const doRegenerate = useCallback( + (chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => { + const question = editedQuestion + ? chatItem + : chatList.find((item) => item.id === chatItem.parentMessageId)! + const parentAnswer = chatList.find((item) => item.id === question.parentMessageId) + doSend( + editedQuestion ? editedQuestion.message : question.content, + editedQuestion ? editedQuestion.files : question.message_files, + true, + isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, + ) + }, + [chatList, doSend], + ) - const doSwitchSibling = useCallback((siblingMessageId: string) => { - handleSwitchSibling(siblingMessageId, { - onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), - onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, - isPublicAPI: appSourceType === AppSourceType.webApp, - }) - }, [handleSwitchSibling, appSourceType, appId, currentConversationId, handleNewConversationCompleted]) + const doSwitchSibling = useCallback( + (siblingMessageId: string) => { + handleSwitchSibling(siblingMessageId, { + onGetSuggestedQuestions: (responseItemId) => + fetchSuggestedQuestions(responseItemId, appSourceType, appId), + onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, + isPublicAPI: appSourceType === AppSourceType.webApp, + }) + }, + [ + handleSwitchSibling, + appSourceType, + appId, + currentConversationId, + handleNewConversationCompleted, + ], + ) const messageList = useMemo(() => { - if (currentConversationId || chatList.length > 1) - return chatList + if (currentConversationId || chatList.length > 1) return chatList // Without messages we are in the welcome screen, so hide the opening statement from chatlist - return chatList.filter(item => !item.isOpeningStatement) + return chatList.filter((item) => !item.isOpeningStatement) }, [chatList, currentConversationId]) const isTryApp = appSourceType === AppSourceType.tryApp @@ -244,15 +269,13 @@ const ChatWrapper = () => { const descRef = useRef<HTMLDivElement>(null) useEffect(() => { const el = descRef.current - if (!el) - return + if (!el) return if (el.offsetWidth > 0) { setShowDescToggle(el.scrollHeight > el.clientHeight) return } const observer = new ResizeObserver((entries) => { - if (!entries[0] || entries[0].contentRect.width === 0) - return + if (!entries[0] || entries[0].contentRect.width === 0) return setShowDescToggle(el.scrollHeight > el.clientHeight) observer.disconnect() }) @@ -261,8 +284,7 @@ const ChatWrapper = () => { }, [description]) const descriptionNode = useMemo(() => { - if (!description || currentConversationId || hasSent) - return null + if (!description || currentConversationId || hasSent) return null return ( <div className={cn('flex flex-col items-center px-4 pt-6', isMobile && 'pt-4')}> <div className="w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md"> @@ -284,21 +306,19 @@ const ChatWrapper = () => { <button type="button" className="mt-0.5 flex items-center gap-0.5 system-xs-regular text-text-accent hover:opacity-80" - onClick={() => setDescExpanded(v => !v)} + onClick={() => setDescExpanded((v) => !v)} > - {descExpanded - ? ( - <> - <RiArrowUpSLine className="size-3" /> - {t($ => $['chat.collapse'], { ns: 'share' })} - </> - ) - : ( - <> - <RiArrowDownSLine className="size-3" /> - {t($ => $['chat.expand'], { ns: 'share' })} - </> - )} + {descExpanded ? ( + <> + <RiArrowUpSLine className="size-3" /> + {t(($) => $['chat.collapse'], { ns: 'share' })} + </> + ) : ( + <> + <RiArrowDownSLine className="size-3" /> + {t(($) => $['chat.expand'], { ns: 'share' })} + </> + )} </button> )} </div> @@ -308,40 +328,39 @@ const ChatWrapper = () => { }, [description, isMobile, currentConversationId, hasSent, descExpanded, showDescToggle, t]) const chatNode = useMemo(() => { - if (allInputsHidden || !inputsForms.length) - return null + if (allInputsHidden || !inputsForms.length) return null if (isMobile) { if (!currentConversationId) return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} /> return <div className="mb-4"></div> - } - else { + } else { return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} /> } }, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden]) - const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => { - if (isInstalledApp) - await submitHumanInputFormService(formToken, formData) - else - await submitHumanInputForm(formToken, formData) - }, [isInstalledApp]) + const handleSubmitHumanInputForm = useCallback( + async (formToken: string, formData: HumanInputFormSubmitData) => { + if (isInstalledApp) await submitHumanInputFormService(formToken, formData) + else await submitHumanInputForm(formToken, formData) + }, + [isInstalledApp], + ) const welcome = useMemo(() => { - const welcomeMessage = chatList.find(item => item.isOpeningStatement) - if (respondingState) - return null - if (currentConversationId) - return null - if (!welcomeMessage) - return null - if (!collapsed && inputsForms.length > 0 && !allInputsHidden) - return null - if (!appData?.site) - return null + const welcomeMessage = chatList.find((item) => item.isOpeningStatement) + if (respondingState) return null + if (currentConversationId) return null + if (!welcomeMessage) return null + if (!collapsed && inputsForms.length > 0 && !allInputsHidden) return null + if (!appData?.site) return null if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) { return ( - <div className={cn('flex items-center justify-center px-4 py-12', isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]')}> + <div + className={cn( + 'flex items-center justify-center px-4 py-12', + isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]', + )} + > <div className="flex max-w-[720px] grow gap-4"> <AppIcon size="xl" @@ -359,7 +378,12 @@ const ChatWrapper = () => { ) } return ( - <div className={cn('flex min-h-[50vh] flex-col items-center justify-center gap-3 py-12', isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]')}> + <div + className={cn( + 'flex min-h-[50vh] flex-col items-center justify-center gap-3 py-12', + isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]', + )} + > <AppIcon size="xl" iconType={appData?.site.icon_type} @@ -368,24 +392,34 @@ const ChatWrapper = () => { imageUrl={appData?.site.icon_url} /> <div className="max-w-[768px] px-4"> - <Markdown className="body-2xl-regular! text-text-tertiary!" content={welcomeMessage.content} /> + <Markdown + className="body-2xl-regular! text-text-tertiary!" + content={welcomeMessage.content} + /> </div> </div> ) - }, [chatList, respondingState, currentConversationId, collapsed, inputsForms.length, allInputsHidden, appData?.site, isMobile]) + }, [ + chatList, + respondingState, + currentConversationId, + collapsed, + inputsForms.length, + allInputsHidden, + appData?.site, + isMobile, + ]) - const answerIcon = isDify() - ? <LogoAvatar className="relative shrink-0" /> - : (appData?.site && appData.site.use_icon_as_answer_icon) - ? ( - <AnswerIcon - iconType={appData.site.icon_type} - icon={appData.site.icon} - background={appData.site.icon_background} - imageUrl={appData.site.icon_url} - /> - ) - : null + const answerIcon = isDify() ? ( + <LogoAvatar className="relative shrink-0" /> + ) : appData?.site && appData.site.use_icon_as_answer_icon ? ( + <AnswerIcon + iconType={appData.site.icon_type} + icon={appData.site.icon} + background={appData.site.icon_background} + imageUrl={appData.site.icon_url} + /> + ) : null return ( <Chat @@ -394,22 +428,25 @@ const ChatWrapper = () => { config={appConfig} chatList={messageList} isResponding={respondingState} - chatContainerInnerClassName={cn('mx-auto w-full max-w-full px-4', messageList.length && 'pt-4')} + chatContainerInnerClassName={cn( + 'mx-auto w-full max-w-full px-4', + messageList.length && 'pt-4', + )} chatFooterClassName={cn('pb-4', !isMobile && 'rounded-b-2xl')} chatFooterInnerClassName={cn('mx-auto w-full max-w-full px-4', isMobile && 'px-2')} onSend={doSend} - inputs={currentConversationId ? currentConversationInputs as any : newConversationInputs} + inputs={currentConversationId ? (currentConversationInputs as any) : newConversationInputs} inputsForm={inputsForms} onRegenerate={doRegenerate} onStopResponding={handleStop} onHumanInputFormSubmit={handleSubmitHumanInputForm} - chatNode={( + chatNode={ <> {descriptionNode} {chatNode} {welcome} </> - )} + } allToolIcons={appMeta?.tool_icons || {}} disableFeedback={disableFeedback} onFeedback={handleFeedback} @@ -421,15 +458,13 @@ const ChatWrapper = () => { inputDisabled={inputDisabled} sendOnEnter={sendOnEnter} questionIcon={ - initUserVariables?.avatar_url - ? ( - <Avatar - avatar={initUserVariables.avatar_url} - name={initUserVariables.name || 'user'} - size="xl" - /> - ) - : undefined + initUserVariables?.avatar_url ? ( + <Avatar + avatar={initUserVariables.avatar_url} + name={initUserVariables.name || 'user'} + size="xl" + /> + ) : undefined } /> ) diff --git a/web/app/components/base/chat/embedded-chatbot/context.ts b/web/app/components/base/chat/embedded-chatbot/context.ts index ef08fc3a58c86c..d9c9812ba2a641 100644 --- a/web/app/components/base/chat/embedded-chatbot/context.ts +++ b/web/app/components/base/chat/embedded-chatbot/context.ts @@ -1,18 +1,9 @@ 'use client' import type { RefObject } from 'react' -import type { - ChatConfig, - ChatItem, - Feedback, -} from '../types' +import type { ChatConfig, ChatItem, Feedback } from '../types' import type { ThemeBuilder } from './theme/theme-context' -import type { - AppConversationData, - AppData, - AppMeta, - ConversationItem, -} from '@/models/share' +import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' import { noop } from 'es-toolkit/function' import { createContext, useContext } from 'use-context-selector' import { AppSourceType } from '@/service/share' diff --git a/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx index 7bb7e9b26c007a..ae09f159095533 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx @@ -8,9 +8,10 @@ import { useEmbeddedChatbotContext } from '../../context' import Header from '../index' let mockBranding = { enabled: true, workspace_logo: '' } -const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { - systemFeatures: { branding: { ...mockBranding } }, -}) +const render = (ui: ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { branding: { ...mockBranding } }, + }) vi.mock('../../context', () => ({ useEmbeddedChatbotContext: vi.fn(), @@ -54,21 +55,28 @@ describe('EmbeddedChatbot Header', () => { beforeEach(() => { vi.clearAllMocks() mockBranding = { enabled: true, workspace_logo: '' } - vi.mocked(useEmbeddedChatbotContext).mockReturnValue(defaultContext as EmbeddedChatbotContextValue) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + defaultContext as EmbeddedChatbotContextValue, + ) Object.defineProperty(window, 'self', { value: window, configurable: true }) Object.defineProperty(window, 'top', { value: window, configurable: true }) }) - const dispatchChatbotConfigMessage = async (origin: string, payload: { isToggledByButton: boolean, isDraggable: boolean }) => { + const dispatchChatbotConfigMessage = async ( + origin: string, + payload: { isToggledByButton: boolean; isDraggable: boolean }, + ) => { await act(async () => { - window.dispatchEvent(new MessageEvent('message', { - origin, - data: { - type: 'dify-chatbot-config', - payload, - }, - })) + window.dispatchEvent( + new MessageEvent('message', { + origin, + data: { + type: 'dify-chatbot-config', + payload, + }, + }), + ) }) } @@ -137,7 +145,9 @@ describe('EmbeddedChatbot Header', () => { }) it('should render divider only when currentConversationId is present', () => { - vi.mocked(useEmbeddedChatbotContext).mockReturnValue({ ...defaultContext } as EmbeddedChatbotContextValue) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue({ + ...defaultContext, + } as EmbeddedChatbotContextValue) const { unmount } = render(<Header title="Test Chatbot" />) expect(screen.getByTestId('divider')).toBeInTheDocument() unmount() @@ -159,7 +169,9 @@ describe('EmbeddedChatbot Header', () => { it('should call onCreateNewChat when reset button is clicked', async () => { const user = userEvent.setup() const onCreateNewChat = vi.fn() - render(<Header title="Test Chatbot" allowResetChat={true} onCreateNewChat={onCreateNewChat} />) + render( + <Header title="Test Chatbot" allowResetChat={true} onCreateNewChat={onCreateNewChat} />, + ) await user.click(screen.getByRole('button', { name: 'share.chat.resetChat' })) expect(onCreateNewChat).toHaveBeenCalled() @@ -210,7 +222,9 @@ describe('EmbeddedChatbot Header', () => { }) it('should render customer icon in mobile header', () => { - render(<Header title="Mobile Chatbot" isMobile customerIcon={<div data-testid="custom-icon" />} />) + render( + <Header title="Mobile Chatbot" isMobile customerIcon={<div data-testid="custom-icon" />} />, + ) expect(screen.getByTestId('custom-icon')).toBeInTheDocument() }) @@ -245,7 +259,10 @@ describe('EmbeddedChatbot Header', () => { const mockPostMessage = setupIframe() render(<Header title="Mobile Chatbot" isMobile />) - await dispatchChatbotConfigMessage('https://parent.com', { isToggledByButton: true, isDraggable: false }) + await dispatchChatbotConfigMessage('https://parent.com', { + isToggledByButton: true, + isDraggable: false, + }) const expandBtn = await screen.findByRole('button', { name: 'share.chat.expand' }) expect(expandBtn).toBeInTheDocument() @@ -263,10 +280,7 @@ describe('EmbeddedChatbot Header', () => { const mockPostMessage = setupIframe() render(<Header title="Iframe" />) - expect(mockPostMessage).toHaveBeenCalledWith( - { type: 'dify-chatbot-iframe-ready' }, - '*', - ) + expect(mockPostMessage).toHaveBeenCalledWith({ type: 'dify-chatbot-iframe-ready' }, '*') }) it('should update expand button visibility and handle click', async () => { @@ -274,7 +288,10 @@ describe('EmbeddedChatbot Header', () => { const mockPostMessage = setupIframe() render(<Header title="Iframe" />) - await dispatchChatbotConfigMessage('https://parent.com', { isToggledByButton: true, isDraggable: false }) + await dispatchChatbotConfigMessage('https://parent.com', { + isToggledByButton: true, + isDraggable: false, + }) const expandBtn = await screen.findByRole('button', { name: 'share.chat.expand' }) expect(expandBtn).toBeInTheDocument() @@ -292,7 +309,10 @@ describe('EmbeddedChatbot Header', () => { setupIframe() render(<Header title="Iframe" />) - await dispatchChatbotConfigMessage('https://parent.com', { isToggledByButton: true, isDraggable: true }) + await dispatchChatbotConfigMessage('https://parent.com', { + isToggledByButton: true, + isDraggable: true, + }) await waitFor(() => { expect(screen.queryByRole('button', { name: 'share.chat.expand' })).not.toBeInTheDocument() @@ -303,11 +323,17 @@ describe('EmbeddedChatbot Header', () => { setupIframe() render(<Header title="Iframe" />) - await dispatchChatbotConfigMessage('https://secure.com', { isToggledByButton: true, isDraggable: false }) + await dispatchChatbotConfigMessage('https://secure.com', { + isToggledByButton: true, + isDraggable: false, + }) await screen.findByRole('button', { name: 'share.chat.expand' }) - await dispatchChatbotConfigMessage('https://malicious.com', { isToggledByButton: false, isDraggable: false }) + await dispatchChatbotConfigMessage('https://malicious.com', { + isToggledByButton: false, + isDraggable: false, + }) // Should still be visible (not hidden by the malicious message) expect(screen.getByRole('button', { name: 'share.chat.expand' })).toBeInTheDocument() @@ -318,13 +344,18 @@ describe('EmbeddedChatbot Header', () => { render(<Header title="Iframe" />) await act(async () => { - window.dispatchEvent(new MessageEvent('message', { - origin: 'https://first.com', - data: { type: 'other-type' }, - })) + window.dispatchEvent( + new MessageEvent('message', { + origin: 'https://first.com', + data: { type: 'other-type' }, + }), + ) }) - await dispatchChatbotConfigMessage('https://second.com', { isToggledByButton: true, isDraggable: false }) + await dispatchChatbotConfigMessage('https://second.com', { + isToggledByButton: true, + isDraggable: false, + }) // Should lock to second.com const expandBtn = await screen.findByRole('button', { name: 'share.chat.expand' }) @@ -345,13 +376,13 @@ describe('EmbeddedChatbot Header', () => { describe('Edge Cases', () => { it('should handle document.referrer for targetOrigin', () => { const mockPostMessage = setupIframe() - Object.defineProperty(document, 'referrer', { value: 'https://referrer.com', configurable: true }) + Object.defineProperty(document, 'referrer', { + value: 'https://referrer.com', + configurable: true, + }) render(<Header title="Referrer" />) - expect(mockPostMessage).toHaveBeenCalledWith( - expect.anything(), - 'https://referrer.com', - ) + expect(mockPostMessage).toHaveBeenCalledWith(expect.anything(), 'https://referrer.com') }) it('should NOT add message listener if not in iframe', () => { diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.tsx index e8f9ec00aef5a5..cd68b120a49b4e 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/index.tsx @@ -12,9 +12,7 @@ import Divider from '@/app/components/base/divider' import DifyLogo from '@/app/components/base/logo/dify-logo' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { isClient } from '@/utils/client' -import { - useEmbeddedChatbotContext, -} from '../context' +import { useEmbeddedChatbotContext } from '../context' import { CssTransform } from '../theme/utils' type IHeaderProps = { @@ -34,12 +32,8 @@ const Header: FC<IHeaderProps> = ({ onCreateNewChat, }) => { const { t } = useTranslation() - const { - appData, - currentConversationId, - inputsForms, - allInputsHidden, - } = useEmbeddedChatbotContext() + const { appData, currentConversationId, inputsForms, allInputsHidden } = + useEmbeddedChatbotContext() const isIframe = isClient ? window.self !== window.top : false const [parentOrigin, setParentOrigin] = useState('') @@ -47,21 +41,24 @@ const Header: FC<IHeaderProps> = ({ const [expanded, setExpanded] = useState(false) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const handleMessageReceived = useCallback((event: MessageEvent) => { - let currentParentOrigin = parentOrigin - if (!currentParentOrigin && event.data.type === 'dify-chatbot-config') { - currentParentOrigin = event.origin - setParentOrigin(event.origin) - } - if (event.origin !== currentParentOrigin) - return - if (event.data.type === 'dify-chatbot-config') - setShowToggleExpandButton(event.data.payload.isToggledByButton && !event.data.payload.isDraggable) - }, [parentOrigin]) + const handleMessageReceived = useCallback( + (event: MessageEvent) => { + let currentParentOrigin = parentOrigin + if (!currentParentOrigin && event.data.type === 'dify-chatbot-config') { + currentParentOrigin = event.origin + setParentOrigin(event.origin) + } + if (event.origin !== currentParentOrigin) return + if (event.data.type === 'dify-chatbot-config') + setShowToggleExpandButton( + event.data.payload.isToggledByButton && !event.data.payload.isDraggable, + ) + }, + [parentOrigin], + ) useEffect(() => { - if (!isIframe) - return + if (!isIframe) return const listener = (event: MessageEvent) => handleMessageReceived(event) window.addEventListener('message', listener) @@ -74,12 +71,14 @@ const Header: FC<IHeaderProps> = ({ }, [isIframe, handleMessageReceived]) const handleToggleExpand = useCallback(() => { - if (!isIframe || !showToggleExpandButton) - return + if (!isIframe || !showToggleExpandButton) return setExpanded(!expanded) - window.parent.postMessage({ - type: 'dify-chatbot-expand-change', - }, parentOrigin) + window.parent.postMessage( + { + type: 'dify-chatbot-expand-change', + }, + parentOrigin, + ) }, [isIframe, parentOrigin, showToggleExpandButton, expanded]) if (!isMobile) { @@ -90,65 +89,79 @@ const Header: FC<IHeaderProps> = ({ <div className="shrink-0"> {!appData?.custom_config?.remove_webapp_brand && ( <div - className={cn( - 'flex shrink-0 items-center gap-1.5 px-2', - )} + className={cn('flex shrink-0 items-center gap-1.5 px-2')} data-testid="webapp-brand" > - <div className="system-2xs-medium-uppercase text-text-tertiary">{t($ => $['chat.poweredBy'], { ns: 'share' })}</div> - { - systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo - ? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" /> - : appData?.custom_config?.replace_webapp_logo - ? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt="logo" className="block h-5 w-auto" /> - : <DifyLogo size="small" /> - } + <div className="system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['chat.poweredBy'], { ns: 'share' })} + </div> + {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo ? ( + <img + src={systemFeatures.branding.workspace_logo} + alt="logo" + className="block h-5 w-auto" + /> + ) : appData?.custom_config?.replace_webapp_logo ? ( + <img + src={`${appData?.custom_config?.replace_webapp_logo}`} + alt="logo" + className="block h-5 w-auto" + /> + ) : ( + <DifyLogo size="small" /> + )} </div> )} </div> - {currentConversationId && ( - <Divider type="vertical" className="h-3.5" /> + {currentConversationId && <Divider type="vertical" className="h-3.5" />} + {showToggleExpandButton && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton + size="l" + aria-label={ + expanded + ? t(($) => $['chat.collapse'], { ns: 'share' }) + : t(($) => $['chat.expand'], { ns: 'share' }) + } + onClick={handleToggleExpand} + > + {expanded ? ( + <div + className="i-ri-collapse-diagonal-2-line h-[18px] w-[18px]" + aria-hidden="true" + /> + ) : ( + <div + className="i-ri-expand-diagonal-2-line h-[18px] w-[18px]" + aria-hidden="true" + /> + )} + </ActionButton> + } + /> + <TooltipContent> + {expanded + ? t(($) => $['chat.collapse'], { ns: 'share' }) + : t(($) => $['chat.expand'], { ns: 'share' })} + </TooltipContent> + </Tooltip> )} - { - showToggleExpandButton && ( - <Tooltip> - <TooltipTrigger - render={( - <ActionButton - size="l" - aria-label={expanded ? t($ => $['chat.collapse'], { ns: 'share' }) : t($ => $['chat.expand'], { ns: 'share' })} - onClick={handleToggleExpand} - > - { - expanded - ? <div className="i-ri-collapse-diagonal-2-line h-[18px] w-[18px]" aria-hidden="true" /> - : <div className="i-ri-expand-diagonal-2-line h-[18px] w-[18px]" aria-hidden="true" /> - } - </ActionButton> - )} - /> - <TooltipContent> - {expanded ? t($ => $['chat.collapse'], { ns: 'share' }) : t($ => $['chat.expand'], { ns: 'share' })} - </TooltipContent> - </Tooltip> - ) - } {currentConversationId && allowResetChat && ( <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton size="l" - aria-label={t($ => $['chat.resetChat'], { ns: 'share' })} + aria-label={t(($) => $['chat.resetChat'], { ns: 'share' })} onClick={onCreateNewChat} > <div className="i-ri-reset-left-line h-[18px] w-[18px]" aria-hidden="true" /> </ActionButton> - )} + } /> - <TooltipContent> - {t($ => $['chat.resetChat'], { ns: 'share' })} - </TooltipContent> + <TooltipContent>{t(($) => $['chat.resetChat'], { ns: 'share' })}</TooltipContent> </Tooltip> )} {currentConversationId && inputsForms.length > 0 && !allInputsHidden && ( @@ -174,48 +187,68 @@ const Header: FC<IHeaderProps> = ({ </div> </div> <div className="flex items-center gap-1"> - { - showToggleExpandButton && ( - <Tooltip> - <TooltipTrigger - render={( - <ActionButton - size="l" - aria-label={expanded ? t($ => $['chat.collapse'], { ns: 'share' }) : t($ => $['chat.expand'], { ns: 'share' })} - onClick={handleToggleExpand} - > - { - expanded - ? <div className={cn('i-ri-collapse-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} aria-hidden="true" /> - : <div className={cn('i-ri-expand-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} aria-hidden="true" /> - } - </ActionButton> - )} - /> - <TooltipContent> - {expanded ? t($ => $['chat.collapse'], { ns: 'share' }) : t($ => $['chat.expand'], { ns: 'share' })} - </TooltipContent> - </Tooltip> - ) - } - {currentConversationId && allowResetChat && ( + {showToggleExpandButton && ( <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton size="l" - aria-label={t($ => $['chat.resetChat'], { ns: 'share' })} - onClick={onCreateNewChat} + aria-label={ + expanded + ? t(($) => $['chat.collapse'], { ns: 'share' }) + : t(($) => $['chat.expand'], { ns: 'share' }) + } + onClick={handleToggleExpand} > - <div className={cn('i-ri-reset-left-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} aria-hidden="true" /> + {expanded ? ( + <div + className={cn( + 'i-ri-collapse-diagonal-2-line h-[18px] w-[18px]', + theme?.colorPathOnHeader, + )} + aria-hidden="true" + /> + ) : ( + <div + className={cn( + 'i-ri-expand-diagonal-2-line h-[18px] w-[18px]', + theme?.colorPathOnHeader, + )} + aria-hidden="true" + /> + )} </ActionButton> - )} + } /> <TooltipContent> - {t($ => $['chat.resetChat'], { ns: 'share' })} + {expanded + ? t(($) => $['chat.collapse'], { ns: 'share' }) + : t(($) => $['chat.expand'], { ns: 'share' })} </TooltipContent> </Tooltip> )} + {currentConversationId && allowResetChat && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton + size="l" + aria-label={t(($) => $['chat.resetChat'], { ns: 'share' })} + onClick={onCreateNewChat} + > + <div + className={cn( + 'i-ri-reset-left-line h-[18px] w-[18px]', + theme?.colorPathOnHeader, + )} + aria-hidden="true" + /> + </ActionButton> + } + /> + <TooltipContent>{t(($) => $['chat.resetChat'], { ns: 'share' })}</TooltipContent> + </Tooltip> + )} {currentConversationId && inputsForms.length > 0 && !allInputsHidden && ( <ViewFormDropdown iconColor={theme?.colorPathOnHeader} /> )} diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx index 2d8f7d4702a19a..b49b7e5b6f4d2c 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx @@ -14,34 +14,53 @@ import { InputVarType } from '@/app/components/workflow/types' import { useWebAppStore } from '@/context/web-app-context' import { changeLanguage } from '@/i18n-config/client' import { AppSourceType, updateFeedback } from '@/service/share' -import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share' +import { + useInvalidateShareConversations, + useShareChatList, + useShareConversationName, + useShareConversations, +} from '@/service/use-share' import { useGetTryAppInfo, useGetTryAppParams } from '@/service/use-try-app' import { TransferMethod } from '@/types/app' import { getProcessedFilesFromResponse } from '../../file-uploader/utils' -import { buildChatItemTree, getProcessedInputsFromUrlParams, getProcessedSystemVariablesFromUrlParams, getProcessedUserVariablesFromUrlParams } from '../utils' +import { + buildChatItemTree, + getProcessedInputsFromUrlParams, + getProcessedSystemVariablesFromUrlParams, + getProcessedUserVariablesFromUrlParams, +} from '../utils' function getFormattedChatList(messages: any[]) { const newChatList: ChatItem[] = [] messages.forEach((item) => { - const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || [] + const questionFiles = + item.message_files?.filter((file: any) => file.belongs_to === 'user') || [] newChatList.push({ id: `question-${item.id}`, content: item.query, isAnswer: false, - message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id }))), + message_files: getProcessedFilesFromResponse( + questionFiles.map((item: any) => ({ ...item, related_id: item.id })), + ), parentMessageId: item.parent_message_id || undefined, }) - const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] + const answerFiles = + item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] newChatList.push({ id: item.id, content: item.answer, - agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files), + agent_thoughts: addFileInfos( + item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, + item.message_files, + ), feedback: item.feedback, isAnswer: true, citation: item.retriever_resources, reasoningContent: item.metadata?.reasoning, reasoningFinished: true, - message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))), + message_files: getProcessedFilesFromResponse( + answerFiles.map((item: any) => ({ ...item, related_id: item.id })), + ), parentMessageId: `question-${item.id}`, }) }) @@ -51,22 +70,21 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri const isInstalledApp = false // just can be webapp and try app const isTryApp = appSourceType === AppSourceType.tryApp const { data: tryAppInfo } = useGetTryAppInfo(isTryApp ? tryAppId! : '') - const webAppInfo = useWebAppStore(s => s.appInfo) + const webAppInfo = useWebAppStore((s) => s.appInfo) const appInfo = isTryApp ? tryAppInfo : webAppInfo - const appMeta = useWebAppStore(s => s.appMeta) + const appMeta = useWebAppStore((s) => s.appMeta) const { data: tryAppParams } = useGetTryAppParams(isTryApp ? tryAppId! : '') - const webAppParams = useWebAppStore(s => s.appParams) + const webAppParams = useWebAppStore((s) => s.appParams) const appParams = isTryApp ? tryAppParams : webAppParams const appId = useMemo(() => { return isTryApp ? tryAppId : (appInfo as any)?.app_id }, [appInfo, isTryApp, tryAppId]) - const embeddedConversationId = useWebAppStore(s => s.embeddedConversationId) - const embeddedUserId = useWebAppStore(s => s.embeddedUserId) + const embeddedConversationId = useWebAppStore((s) => s.embeddedConversationId) + const embeddedUserId = useWebAppStore((s) => s.embeddedUserId) const [userId, setUserId] = useState<string>() const [conversationId, setConversationId] = useState<string>() useEffect(() => { - if (isTryApp) - return + if (isTryApp) return getProcessedSystemVariablesFromUrlParams().then(({ user_id, conversation_id }) => { setUserId(user_id) setConversationId(conversation_id) @@ -79,8 +97,7 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri setConversationId(embeddedConversationId || undefined) }, [embeddedConversationId]) useEffect(() => { - if (isTryApp) - return + if (isTryApp) return const setLanguageFromParams = async () => { // Check URL parameters for language override const urlParams = new URLSearchParams(window.location.search) @@ -91,12 +108,10 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri if (localeParam) { // If locale parameter exists in URL, use it instead of default await changeLanguage(localeParam as Locale) - } - else if (localeFromSysVar) { + } else if (localeFromSysVar) { // If locale is set as a system variable, use that await changeLanguage(localeFromSysVar) - } - else if ((appInfo as unknown as AppData)?.site?.default_language) { + } else if ((appInfo as unknown as AppData)?.site?.default_language) { // Otherwise use the default from app config await changeLanguage((appInfo as unknown as AppData).site?.default_language) } @@ -104,33 +119,40 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri setLanguageFromParams() }, [appInfo]) const [conversationIdInfo, setConversationIdInfo] = useConversationIdInfo() - const removeConversationIdInfo = useCallback((appId: string) => { - setConversationIdInfo((prev) => { - const newInfo = { ...prev } - delete newInfo[appId] - return newInfo - }) - }, [setConversationIdInfo]) - const allowResetChat = !conversationId - const currentConversationId = useMemo(() => conversationId || conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || '', [appId, conversationIdInfo, userId, conversationId]) - const handleConversationIdInfoChange = useCallback((changeConversationId: string) => { - if (appId) { - let prevValue = conversationIdInfo?.[appId || ''] - if (typeof prevValue === 'string') - prevValue = {} - setConversationIdInfo({ - ...conversationIdInfo, - [appId || '']: { - ...prevValue, - [userId || 'DEFAULT']: changeConversationId, - }, + const removeConversationIdInfo = useCallback( + (appId: string) => { + setConversationIdInfo((prev) => { + const newInfo = { ...prev } + delete newInfo[appId] + return newInfo }) - } - }, [appId, conversationIdInfo, setConversationIdInfo, userId]) + }, + [setConversationIdInfo], + ) + const allowResetChat = !conversationId + const currentConversationId = useMemo( + () => conversationId || conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || '', + [appId, conversationIdInfo, userId, conversationId], + ) + const handleConversationIdInfoChange = useCallback( + (changeConversationId: string) => { + if (appId) { + let prevValue = conversationIdInfo?.[appId || ''] + if (typeof prevValue === 'string') prevValue = {} + setConversationIdInfo({ + ...conversationIdInfo, + [appId || '']: { + ...prevValue, + [userId || 'DEFAULT']: changeConversationId, + }, + }) + } + }, + [appId, conversationIdInfo, setConversationIdInfo, userId], + ) const [newConversationId, setNewConversationId] = useState('') const chatShouldReloadKey = useMemo(() => { - if (currentConversationId === newConversationId) - return '' + if (currentConversationId === newConversationId) return '' return currentConversationId }, [currentConversationId, newConversationId]) const { data: appPinnedConversationData } = useShareConversations({ @@ -139,12 +161,13 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri pinned: true, limit: 100, }) - const { data: appConversationData, isLoading: appConversationDataLoading } = useShareConversations({ - appSourceType, - appId, - pinned: false, - limit: 100, - }) + const { data: appConversationData, isLoading: appConversationDataLoading } = + useShareConversations({ + appSourceType, + appId, + pinned: false, + limit: 100, + }) const { data: appChatListData, isLoading: appChatListDataLoading } = useShareChatList({ conversationId: chatShouldReloadKey, appSourceType, @@ -153,9 +176,13 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri const invalidateShareConversations = useInvalidateShareConversations() const [clearChatList, setClearChatList] = useState(false) const [isResponding, setIsResponding] = useState(false) - const appPrevChatList = useMemo(() => (currentConversationId && appChatListData?.data.length) - ? buildChatItemTree(getFormattedChatList(appChatListData.data)) - : [], [appChatListData, currentConversationId]) + const appPrevChatList = useMemo( + () => + currentConversationId && appChatListData?.data.length + ? buildChatItemTree(getFormattedChatList(appChatListData.data)) + : [], + [appChatListData, currentConversationId], + ) const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false) const pinnedConversationList = useMemo(() => { return appPinnedConversationData?.data || [] @@ -171,77 +198,80 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri setNewConversationInputs(newInputs) }, []) const inputsForms = useMemo(() => { - return (appParams?.user_input_form || []).filter((item: any) => !item.external_data_tool).map((item: any) => { - if (item.paragraph) { - let value = initInputs[item.paragraph.variable] - if (value && item.paragraph.max_length && value.length > item.paragraph.max_length) - value = value.slice(0, item.paragraph.max_length) - return { - ...item.paragraph, - default: value || item.default || item.paragraph.default, - type: 'paragraph', + return (appParams?.user_input_form || []) + .filter((item: any) => !item.external_data_tool) + .map((item: any) => { + if (item.paragraph) { + let value = initInputs[item.paragraph.variable] + if (value && item.paragraph.max_length && value.length > item.paragraph.max_length) + value = value.slice(0, item.paragraph.max_length) + return { + ...item.paragraph, + default: value || item.default || item.paragraph.default, + type: 'paragraph', + } } - } - if (item.number) { - const convertedNumber = Number(initInputs[item.number.variable]) - return { - ...item.number, - default: convertedNumber || item.default || item.number.default, - type: 'number', + if (item.number) { + const convertedNumber = Number(initInputs[item.number.variable]) + return { + ...item.number, + default: convertedNumber || item.default || item.number.default, + type: 'number', + } } - } - if (item.checkbox) { - const preset = initInputs[item.checkbox.variable] === true - return { - ...item.checkbox, - default: preset || item.default || item.checkbox.default, - type: 'checkbox', + if (item.checkbox) { + const preset = initInputs[item.checkbox.variable] === true + return { + ...item.checkbox, + default: preset || item.default || item.checkbox.default, + type: 'checkbox', + } } - } - if (item.select) { - const isInputInOptions = item.select.options.includes(initInputs[item.select.variable]) - return { - ...item.select, - default: (isInputInOptions ? initInputs[item.select.variable] : undefined) || item.select.default, - type: 'select', + if (item.select) { + const isInputInOptions = item.select.options.includes(initInputs[item.select.variable]) + return { + ...item.select, + default: + (isInputInOptions ? initInputs[item.select.variable] : undefined) || + item.select.default, + type: 'select', + } } - } - if (item['file-list']) { - return { - ...item['file-list'], - type: 'file-list', + if (item['file-list']) { + return { + ...item['file-list'], + type: 'file-list', + } } - } - if (item.file) { - return { - ...item.file, - type: 'file', + if (item.file) { + return { + ...item.file, + type: 'file', + } } - } - if (item.json_object) { + if (item.json_object) { + return { + ...item.json_object, + type: 'json_object', + } + } + let value = initInputs[item['text-input'].variable] + if (value && item['text-input'].max_length && value.length > item['text-input'].max_length) + value = value.slice(0, item['text-input'].max_length) return { - ...item.json_object, - type: 'json_object', + ...item['text-input'], + default: value || item.default || item['text-input'].default, + type: 'text-input', } - } - let value = initInputs[item['text-input'].variable] - if (value && item['text-input'].max_length && value.length > item['text-input'].max_length) - value = value.slice(0, item['text-input'].max_length) - return { - ...item['text-input'], - default: value || item.default || item['text-input'].default, - type: 'text-input', - } - }) + }) }, [initInputs, appParams]) const allInputsHidden = useMemo(() => { - return inputsForms.length > 0 && inputsForms.every(item => item.hide === true) + return inputsForms.length > 0 && inputsForms.every((item) => item.hide === true) }, [inputsForms]) useEffect(() => { // init inputs from url params - (async () => { - if (isTryApp) - return + ;(async () => { + if (isTryApp) return const inputs = await getProcessedInputsFromUrlParams() const userVariables = await getProcessedUserVariablesFromUrlParams() setInitInputs(inputs) @@ -255,18 +285,21 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri }) handleNewConversationInputsChange(conversationInputs) }, [handleNewConversationInputsChange, inputsForms]) - const { data: newConversation } = useShareConversationName({ - conversationId: newConversationId, - appSourceType, - appId, - }, { - refetchOnWindowFocus: false, - enabled: !isTryApp, - }) + const { data: newConversation } = useShareConversationName( + { + conversationId: newConversationId, + appSourceType, + appId, + }, + { + refetchOnWindowFocus: false, + enabled: !isTryApp, + }, + ) const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([]) useEffect(() => { if (appConversationData?.data && !appConversationDataLoading) - // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect setOriginConversationList(appConversationData?.data) }, [appConversationData, appConversationDataLoading]) const conversationList = useMemo(() => { @@ -274,7 +307,7 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri if (showNewConversationItemInList && data[0]?.id !== '') { data.unshift({ id: '', - name: t($ => $['chat.newChatDefaultName'], { ns: 'share' }), + name: t(($) => $['chat.newChatDefaultName'], { ns: 'share' }), inputs: {}, introduction: '', }) @@ -283,19 +316,19 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri }, [originConversationList, showNewConversationItemInList, t]) useEffect(() => { if (newConversation) { - setOriginConversationList(produce((draft) => { - const index = draft.findIndex(item => item.id === newConversation.id) - if (index > -1) - draft[index] = newConversation - else - draft.unshift(newConversation) - })) + setOriginConversationList( + produce((draft) => { + const index = draft.findIndex((item) => item.id === newConversation.id) + if (index > -1) draft[index] = newConversation + else draft.unshift(newConversation) + }), + ) } }, [newConversation]) const currentConversationItem = useMemo(() => { - let conversationItem = conversationList.find(item => item.id === currentConversationId) + let conversationItem = conversationList.find((item) => item.id === currentConversationId) if (!conversationItem && pinnedConversationList.length) - conversationItem = pinnedConversationList.find(item => item.id === currentConversationId) + conversationItem = pinnedConversationList.find((item) => item.id === currentConversationId) return conversationItem }, [conversationList, currentConversationId, pinnedConversationList]) const currentConversationLatestInputs = useMemo(() => { @@ -303,61 +336,79 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri return newConversationInputsRef.current || {} return appChatListData.data.slice().pop().inputs || {} }, [appChatListData, currentConversationId]) - const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>(currentConversationLatestInputs || {}) + const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>( + currentConversationLatestInputs || {}, + ) useEffect(() => { if (currentConversationItem && !isTryApp) - // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect setCurrentConversationInputs(currentConversationLatestInputs || {}) }, [currentConversationItem, currentConversationLatestInputs]) - const checkInputsRequired = useCallback((silent?: boolean) => { - if (allInputsHidden) + const checkInputsRequired = useCallback( + (silent?: boolean) => { + if (allInputsHidden) return true + let hasEmptyInput = '' + let fileIsUploading = false + const requiredVars = inputsForms.filter( + ({ required, type }) => required && type !== InputVarType.checkbox, + ) + if (requiredVars.length) { + requiredVars.forEach(({ variable, label, type }) => { + if (hasEmptyInput) return + if (fileIsUploading) return + if (!newConversationInputsRef.current[variable] && !silent) + hasEmptyInput = label as string + if ( + (type === InputVarType.singleFile || type === InputVarType.multiFiles) && + newConversationInputsRef.current[variable] && + !silent + ) { + const files = newConversationInputsRef.current[variable] + if (Array.isArray(files)) + fileIsUploading = files.find( + (item) => item.transferMethod === TransferMethod.local_file && !item.uploadedId, + ) + else + fileIsUploading = + files.transferMethod === TransferMethod.local_file && !files.uploadedId + } + }) + } + if (hasEmptyInput) { + toast.error( + t(($) => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: hasEmptyInput }), + ) + return false + } + if (fileIsUploading) { + toast.info(t(($) => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' })) + return + } return true - let hasEmptyInput = '' - let fileIsUploading = false - const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox) - if (requiredVars.length) { - requiredVars.forEach(({ variable, label, type }) => { - if (hasEmptyInput) - return - if (fileIsUploading) - return - if (!newConversationInputsRef.current[variable] && !silent) - hasEmptyInput = label as string - if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) { - const files = newConversationInputsRef.current[variable] - if (Array.isArray(files)) - fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId) - else - fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId - } - }) - } - if (hasEmptyInput) { - toast.error(t($ => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: hasEmptyInput })) - return false - } - if (fileIsUploading) { - toast.info(t($ => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' })) - return - } - return true - }, [inputsForms, t, allInputsHidden]) - const handleStartChat = useCallback((callback?: () => void) => { - if (checkInputsRequired()) { - setShowNewConversationItemInList(true) - callback?.() - } - }, [setShowNewConversationItemInList, checkInputsRequired]) + }, + [inputsForms, t, allInputsHidden], + ) + const handleStartChat = useCallback( + (callback?: () => void) => { + if (checkInputsRequired()) { + setShowNewConversationItemInList(true) + callback?.() + } + }, + [setShowNewConversationItemInList, checkInputsRequired], + ) const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: noop }) - const handleChangeConversation = useCallback((conversationId: string) => { - currentChatInstanceRef.current.handleStop() - setNewConversationId('') - handleConversationIdInfoChange(conversationId) - if (conversationId) - setClearChatList(false) - }, [handleConversationIdInfoChange, setClearChatList]) + const handleChangeConversation = useCallback( + (conversationId: string) => { + currentChatInstanceRef.current.handleStop() + setNewConversationId('') + handleConversationIdInfoChange(conversationId) + if (conversationId) setClearChatList(false) + }, + [handleConversationIdInfoChange, setClearChatList], + ) const handleNewConversation = useCallback(async () => { if (isTryApp) { setClearChatList(true) @@ -368,17 +419,35 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri handleChangeConversation('') handleNewConversationInputsChange(await getProcessedInputsFromUrlParams()) setClearChatList(true) - }, [isTryApp, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList]) - const handleNewConversationCompleted = useCallback((newConversationId: string) => { - setNewConversationId(newConversationId) - handleConversationIdInfoChange(newConversationId) - setShowNewConversationItemInList(false) - invalidateShareConversations() - }, [handleConversationIdInfoChange, invalidateShareConversations]) - const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => { - await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId) - toast.success(t($ => $['api.success'], { ns: 'common' })) - }, [appSourceType, appId, t]) + }, [ + isTryApp, + setShowNewConversationItemInList, + handleNewConversationInputsChange, + setClearChatList, + ]) + const handleNewConversationCompleted = useCallback( + (newConversationId: string) => { + setNewConversationId(newConversationId) + handleConversationIdInfoChange(newConversationId) + setShowNewConversationItemInList(false) + invalidateShareConversations() + }, + [handleConversationIdInfoChange, invalidateShareConversations], + ) + const handleFeedback = useCallback( + async (messageId: string, feedback: Feedback) => { + await updateFeedback( + { + url: `/messages/${messageId}/feedbacks`, + body: { rating: feedback.rating, content: feedback.content }, + }, + appSourceType, + appId, + ) + toast.success(t(($) => $['api.success'], { ns: 'common' })) + }, + [appSourceType, appId, t], + ) return { appSourceType, isInstalledApp, @@ -389,7 +458,7 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri removeConversationIdInfo, handleConversationIdInfoChange, appData: appInfo, - appParams: appParams || {} as ChatConfig, + appParams: appParams || ({} as ChatConfig), appMeta, appPinnedConversationData, appConversationData, diff --git a/web/app/components/base/chat/embedded-chatbot/index.tsx b/web/app/components/base/chat/embedded-chatbot/index.tsx index e2f603c5be79d9..d2b23db31a7542 100644 --- a/web/app/components/base/chat/embedded-chatbot/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/index.tsx @@ -2,9 +2,7 @@ import type { AppData } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' -import { - useEffect, -} from 'react' +import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper' import Header from '@/app/components/base/chat/embedded-chatbot/header' @@ -15,10 +13,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { AppSourceType } from '@/service/share' -import { - EmbeddedChatbotContext, - useEmbeddedChatbotContext, -} from './context' +import { EmbeddedChatbotContext, useEmbeddedChatbotContext } from './context' import { useEmbeddedChatbot } from './hooks' import { useThemeContext } from './theme/theme-context' import { CssTransform } from './theme/utils' @@ -55,7 +50,11 @@ const Chatbot = () => { 'flex flex-col rounded-2xl', isMobile ? 'h-[calc(100vh-60px)] shadow-xs' : 'h-screen bg-chatbot-bg', )} - style={isMobile ? Object.assign({}, CssTransform(themeBuilder?.theme?.backgroundHeaderColorStyle ?? '')) : {}} + style={ + isMobile + ? Object.assign({}, CssTransform(themeBuilder?.theme?.backgroundHeaderColorStyle ?? '')) + : {} + } > <Header isMobile={isMobile} @@ -65,31 +64,39 @@ const Chatbot = () => { theme={themeBuilder?.theme} onCreateNewChat={handleNewConversation} /> - <div className={cn('flex grow flex-col overflow-y-auto', isMobile && 'm-[0.5px] h-[calc(100vh-3rem)]! rounded-2xl bg-chatbot-bg')}> - {appChatListDataLoading && ( - <Loading type="app" /> - )} - {!appChatListDataLoading && ( - <ChatWrapper key={chatShouldReloadKey} /> + <div + className={cn( + 'flex grow flex-col overflow-y-auto', + isMobile && 'm-[0.5px] h-[calc(100vh-3rem)]! rounded-2xl bg-chatbot-bg', )} + > + {appChatListDataLoading && <Loading type="app" />} + {!appChatListDataLoading && <ChatWrapper key={chatShouldReloadKey} />} </div> </div> {/* powered by */} {isMobile && ( <div className="flex h-[60px] shrink-0 items-center pl-2"> {!appData?.custom_config?.remove_webapp_brand && ( - <div className={cn( - 'flex shrink-0 items-center gap-1.5 px-2', - )} - > - <div className="system-2xs-medium-uppercase text-text-tertiary">{t($ => $['chat.poweredBy'], { ns: 'share' })}</div> - { - systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo - ? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" /> - : appData?.custom_config?.replace_webapp_logo - ? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt="logo" className="block h-5 w-auto" /> - : <DifyLogo size="small" /> - } + <div className={cn('flex shrink-0 items-center gap-1.5 px-2')}> + <div className="system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['chat.poweredBy'], { ns: 'share' })} + </div> + {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo ? ( + <img + src={systemFeatures.branding.workspace_logo} + alt="logo" + className="block h-5 w-auto" + /> + ) : appData?.custom_config?.replace_webapp_logo ? ( + <img + src={`${appData?.custom_config?.replace_webapp_logo}`} + alt="logo" + className="block h-5 w-auto" + /> + ) : ( + <DifyLogo size="small" /> + )} </div> )} </div> @@ -138,42 +145,43 @@ const EmbeddedChatbotWrapper = () => { } = useEmbeddedChatbot(AppSourceType.webApp) return ( - <EmbeddedChatbotContext.Provider value={{ - appSourceType: AppSourceType.webApp, - appData: (appData as AppData) || null, - appParams, - appMeta, - appChatListDataLoading, - currentConversationId, - currentConversationItem, - appPrevChatList, - pinnedConversationList, - conversationList, - newConversationInputs, - newConversationInputsRef, - handleNewConversationInputsChange, - inputsForms, - handleNewConversation, - handleStartChat, - handleChangeConversation, - handleNewConversationCompleted, - chatShouldReloadKey, - isMobile, - isInstalledApp, - allowResetChat, - appId, - handleFeedback, - currentChatInstanceRef, - themeBuilder, - clearChatList, - setClearChatList, - isResponding, - setIsResponding, - currentConversationInputs, - setCurrentConversationInputs, - allInputsHidden, - initUserVariables, - }} + <EmbeddedChatbotContext.Provider + value={{ + appSourceType: AppSourceType.webApp, + appData: (appData as AppData) || null, + appParams, + appMeta, + appChatListDataLoading, + currentConversationId, + currentConversationItem, + appPrevChatList, + pinnedConversationList, + conversationList, + newConversationInputs, + newConversationInputsRef, + handleNewConversationInputsChange, + inputsForms, + handleNewConversation, + handleStartChat, + handleChangeConversation, + handleNewConversationCompleted, + chatShouldReloadKey, + isMobile, + isInstalledApp, + allowResetChat, + appId, + handleFeedback, + currentChatInstanceRef, + themeBuilder, + clearChatList, + setClearChatList, + isResponding, + setIsResponding, + currentConversationInputs, + setCurrentConversationInputs, + allInputsHidden, + initUserVariables, + }} > <Chatbot /> </EmbeddedChatbotContext.Provider> diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx index 10bdf5ecb05d14..b0f6c587923148 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx @@ -16,17 +16,23 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: () => new URLSearchParams(), })) -vi.mock('@langgenius/dify-ui/toast', () => ({ - -})) +vi.mock('@langgenius/dify-ui/toast', () => ({})) // Mock CodeEditor to trigger onChange easily vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ value, onChange, placeholder }: { value: string, onChange: (v: string) => void, placeholder: string | React.ReactNode }) => ( + default: ({ + value, + onChange, + placeholder, + }: { + value: string + onChange: (v: string) => void + placeholder: string | React.ReactNode + }) => ( <textarea data-testid="mock-code-editor" value={value} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} placeholder={typeof placeholder === 'string' ? placeholder : 'json-placeholder'} /> ), @@ -34,10 +40,17 @@ vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', ( // Mock FileUploaderInAttachmentWrapper to trigger onChange easily vi.mock('@/app/components/base/file-uploader', () => ({ - - FileUploaderInAttachmentWrapper: ({ value, onChange }: { value: any[], onChange: (v: any[]) => void }) => ( + FileUploaderInAttachmentWrapper: ({ + value, + onChange, + }: { + value: any[] + onChange: (v: any[]) => void + }) => ( <div data-testid="mock-file-uploader"> - <button onClick={() => onChange([new File([''], 'test.png', { type: 'image/png' })])}>Upload</button> + <button onClick={() => onChange([new File([''], 'test.png', { type: 'image/png' })])}> + Upload + </button> <span>{value.length > 0 ? value[0].name : 'no file'}</span> </div> ), @@ -188,9 +201,8 @@ describe('InputsFormContent', () => { it('should handle select input changes', async () => { render(<InputsFormContent />) - const selectTrigger = screen.getAllByText(/Select Label/i).find(el => el.tagName === 'SPAN') - if (!selectTrigger) - throw new Error('Select trigger not found') + const selectTrigger = screen.getAllByText(/Select Label/i).find((el) => el.tagName === 'SPAN') + if (!selectTrigger) throw new Error('Select trigger not found') await user.click(selectTrigger) const option = screen.getByText('Option 1') @@ -202,9 +214,8 @@ describe('InputsFormContent', () => { it('should render select dropdown on the shared dify-ui overlay layer', async () => { render(<InputsFormContent />) - const selectTrigger = screen.getAllByText(/Select Label/i).find(el => el.tagName === 'SPAN') - if (!selectTrigger) - throw new Error('Select trigger not found') + const selectTrigger = screen.getAllByText(/Select Label/i).find((el) => el.tagName === 'SPAN') + if (!selectTrigger) throw new Error('Select trigger not found') await user.click(selectTrigger) diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/index.spec.tsx index e688ebe17cd833..dcc2a8a38a6cd7 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/index.spec.tsx @@ -83,7 +83,7 @@ describe('InputsFormNode', () => { }) it('should handle start chat button click', async () => { - const handleStartChat = vi.fn(cb => cb()) + const handleStartChat = vi.fn((cb) => cb()) vi.mocked(useEmbeddedChatbotContext).mockReturnValue({ ...mockContextValue, @@ -138,7 +138,9 @@ describe('InputsFormNode', () => { expect(screen.getByTestId('mock-inputs-form-content').parentElement).toHaveClass('p-4') // Start chat button container - expect(screen.getByRole('button', { name: 'share.chat.startChat' }).parentElement).toHaveClass('p-4') + expect(screen.getByRole('button', { name: 'share.chat.startChat' }).parentElement).toHaveClass( + 'p-4', + ) // Collapsed state mobile styles rerender(<InputsFormNode collapsed={true} setCollapsed={setCollapsed} />) diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/view-form-dropdown.spec.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/view-form-dropdown.spec.tsx index 4368228070eafb..75ee7d2fed9910 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/view-form-dropdown.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/view-form-dropdown.spec.tsx @@ -47,7 +47,9 @@ describe('ViewFormDropdown', () => { render(<ViewFormDropdown iconColor="text-red-500" />) await user.click(screen.getByTestId('view-form-dropdown-trigger')) - const icon = screen.getByTestId('view-form-dropdown-trigger').querySelector('.i-ri-chat-settings-line') + const icon = screen + .getByTestId('view-form-dropdown-trigger') + .querySelector('.i-ri-chat-settings-line') expect(icon).toHaveClass('text-red-500') }) }) diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx index e78adc8e92caa7..61e9fbc6c13cd0 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx @@ -1,4 +1,11 @@ -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { Textarea } from '@langgenius/dify-ui/textarea' import * as React from 'react' import { memo, useCallback } from 'react' @@ -29,35 +36,49 @@ const InputsFormContent = ({ showTip }: Props) => { } = useEmbeddedChatbotContext() const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputs - const handleFormChange = useCallback((variable: string, value: any) => { - setCurrentConversationInputs({ - ...currentConversationInputs, - [variable]: value, - }) - handleNewConversationInputsChange({ - ...newConversationInputsRef.current, - [variable]: value, - }) - }, [newConversationInputsRef, handleNewConversationInputsChange, currentConversationInputs, setCurrentConversationInputs]) + const handleFormChange = useCallback( + (variable: string, value: any) => { + setCurrentConversationInputs({ + ...currentConversationInputs, + [variable]: value, + }) + handleNewConversationInputsChange({ + ...newConversationInputsRef.current, + [variable]: value, + }) + }, + [ + newConversationInputsRef, + handleNewConversationInputsChange, + currentConversationInputs, + setCurrentConversationInputs, + ], + ) - const visibleInputsForms = inputsForms.filter(form => form.hide !== true) + const visibleInputsForms = inputsForms.filter((form) => form.hide !== true) return ( <div className="space-y-4"> - {visibleInputsForms.map(form => ( - <div key={form.variable} className="space-y-1" data-testid={`inputs-form-item-${form.variable}`}> + {visibleInputsForms.map((form) => ( + <div + key={form.variable} + className="space-y-1" + data-testid={`inputs-form-item-${form.variable}`} + > {form.type !== InputVarType.checkbox && ( <div className="flex h-6 items-center gap-1"> <div className="system-md-semibold text-text-secondary">{form.label}</div> {!form.required && ( - <div className="system-xs-regular text-text-tertiary">{t($ => $['panel.optional'], { ns: 'workflow' })}</div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['panel.optional'], { ns: 'workflow' })} + </div> )} </div> )} {form.type === InputVarType.textInput && ( <Input value={inputsFormValue?.[form.variable] || ''} - onChange={e => handleFormChange(form.variable, e.target.value)} + onChange={(e) => handleFormChange(form.variable, e.target.value)} placeholder={form.label} /> )} @@ -65,7 +86,7 @@ const InputsFormContent = ({ showTip }: Props) => { <Input type="number" value={inputsFormValue?.[form.variable] || ''} - onChange={e => handleFormChange(form.variable, e.target.value)} + onChange={(e) => handleFormChange(form.variable, e.target.value)} placeholder={form.label} /> )} @@ -73,7 +94,7 @@ const InputsFormContent = ({ showTip }: Props) => { <Textarea aria-label={form.label} value={inputsFormValue?.[form.variable] || ''} - onValueChange={value => handleFormChange(form.variable, value)} + onValueChange={(value) => handleFormChange(form.variable, value)} placeholder={form.label} /> )} @@ -82,13 +103,13 @@ const InputsFormContent = ({ showTip }: Props) => { name={form.label} value={inputsFormValue?.[form.variable]} required={form.required} - onChange={value => handleFormChange(form.variable, value)} + onChange={(value) => handleFormChange(form.variable, value)} /> )} {form.type === InputVarType.select && ( <Select value={(inputsFormValue?.[form.variable] ?? form.default ?? '') || null} - onValueChange={value => value && handleFormChange(form.variable, value)} + onValueChange={(value) => value && handleFormChange(form.variable, value)} > <SelectTrigger className="w-full"> {String(inputsFormValue?.[form.variable] ?? form.default ?? form.label)} @@ -106,7 +127,7 @@ const InputsFormContent = ({ showTip }: Props) => { {form.type === InputVarType.singleFile && ( <FileUploaderInAttachmentWrapper value={inputsFormValue?.[form.variable] ? [inputsFormValue?.[form.variable]] : []} - onChange={files => handleFormChange(form.variable, files[0])} + onChange={(files) => handleFormChange(form.variable, files[0])} fileConfig={{ allowed_file_types: form.allowed_file_types, allowed_file_extensions: form.allowed_file_extensions, @@ -119,7 +140,7 @@ const InputsFormContent = ({ showTip }: Props) => { {form.type === InputVarType.multiFiles && ( <FileUploaderInAttachmentWrapper value={inputsFormValue?.[form.variable] || []} - onChange={files => handleFormChange(form.variable, files)} + onChange={(files) => handleFormChange(form.variable, files)} fileConfig={{ allowed_file_types: form.allowed_file_types, allowed_file_extensions: form.allowed_file_extensions, @@ -133,18 +154,18 @@ const InputsFormContent = ({ showTip }: Props) => { <CodeEditor language={CodeLanguage.json} value={inputsFormValue?.[form.variable] || ''} - onChange={v => handleFormChange(form.variable, v)} + onChange={(v) => handleFormChange(form.variable, v)} noWrapper className="h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" - placeholder={ - <div className="whitespace-pre">{form.json_schema}</div> - } + placeholder={<div className="whitespace-pre">{form.json_schema}</div>} /> )} </div> ))} {showTip && ( - <div className="system-xs-regular text-text-tertiary">{t($ => $['chat.chatFormTip'], { ns: 'share' })}</div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['chat.chatFormTip'], { ns: 'share' })} + </div> )} </div> ) diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx index 71802069f77daa..fddaaa2f4c2b39 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx @@ -12,10 +12,7 @@ type Props = Readonly<{ setCollapsed: (collapsed: boolean) => void }> -const InputsFormNode = ({ - collapsed, - setCollapsed, -}: Props) => { +const InputsFormNode = ({ collapsed, setCollapsed }: Props) => { const { t } = useTranslation() const { appSourceType, @@ -28,28 +25,35 @@ const InputsFormNode = ({ } = useEmbeddedChatbotContext() const isTryApp = appSourceType === AppSourceType.tryApp - if (allInputsHidden || inputsForms.length === 0) - return null + if (allInputsHidden || inputsForms.length === 0) return null return ( <div data-testid="inputs-form-node" - className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4', isTryApp && 'mb-0 px-0')} - > - <div className={cn( - 'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md', - collapsed && 'border border-components-card-border bg-components-card-bg shadow-none', - isTryApp && 'max-w-[auto]', + className={cn( + 'mb-6 flex flex-col items-center px-4 pt-6', + isMobile && 'mb-4 pt-4', + isTryApp && 'mb-0 px-0', )} - > - <div className={cn( - 'flex items-center gap-3 rounded-t-2xl px-6 py-4', - !collapsed && 'border-b border-divider-subtle', - isMobile && 'px-4 py-3', + > + <div + className={cn( + 'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md', + collapsed && 'border border-components-card-border bg-components-card-bg shadow-none', + isTryApp && 'max-w-[auto]', )} + > + <div + className={cn( + 'flex items-center gap-3 rounded-t-2xl px-6 py-4', + !collapsed && 'border-b border-divider-subtle', + isMobile && 'px-4 py-3', + )} > <div className="i-custom-public-other-message-3-fill size-6 shrink-0" /> - <div className="grow system-xl-semibold text-text-secondary">{t($ => $['chat.chatSettingsTitle'], { ns: 'share' })}</div> + <div className="grow system-xl-semibold text-text-secondary"> + {t(($) => $['chat.chatSettingsTitle'], { ns: 'share' })} + </div> {collapsed && ( <Button className="text-text-tertiary uppercase" @@ -57,7 +61,7 @@ const InputsFormNode = ({ variant="ghost" onClick={() => setCollapsed(false)} > - {t($ => $['operation.edit'], { ns: 'common' })} + {t(($) => $['operation.edit'], { ns: 'common' })} </Button> )} {!collapsed && currentConversationId && ( @@ -67,7 +71,7 @@ const InputsFormNode = ({ variant="ghost" onClick={() => setCollapsed(true)} > - {t($ => $['operation.close'], { ns: 'common' })} + {t(($) => $['operation.close'], { ns: 'common' })} </Button> )} </div> @@ -90,7 +94,7 @@ const InputsFormNode = ({ : {} } > - {t($ => $['chat.startChat'], { ns: 'share' })} + {t(($) => $['chat.startChat'], { ns: 'share' })} </Button> </div> )} diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx index 0f0b4b2a556338..676dd56ffabdec 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx @@ -10,19 +10,14 @@ type Props = Readonly<{ iconColor?: string }> -const ViewFormDropdown = ({ - iconColor, -}: Props) => { +const ViewFormDropdown = ({ iconColor }: Props) => { const { t } = useTranslation() const [open, setOpen] = useState(false) return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default} @@ -30,7 +25,7 @@ const ViewFormDropdown = ({ > <div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} /> </ActionButton> - )} + } /> <PopoverContent placement="bottom-end" @@ -44,7 +39,9 @@ const ViewFormDropdown = ({ > <div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4"> <div className="i-custom-public-other-message-3-fill size-6 shrink-0" /> - <div className="grow system-xl-semibold text-text-secondary">{t($ => $['chat.chatSettingsTitle'], { ns: 'share' })}</div> + <div className="grow system-xl-semibold text-text-secondary"> + {t(($) => $['chat.chatSettingsTitle'], { ns: 'share' })} + </div> </div> <div className="p-6"> <InputsFormContent /> diff --git a/web/app/components/base/chat/embedded-chatbot/theme/theme-context.ts b/web/app/components/base/chat/embedded-chatbot/theme/theme-context.ts index 321997ab1d14a3..64d4253754b9f6 100644 --- a/web/app/components/base/chat/embedded-chatbot/theme/theme-context.ts +++ b/web/app/components/base/chat/embedded-chatbot/theme/theme-context.ts @@ -49,8 +49,7 @@ export class ThemeBuilder { if (this._theme === undefined) { this._theme = new Theme() return this._theme - } - else { + } else { return this._theme } } @@ -59,9 +58,11 @@ export class ThemeBuilder { if (!this.buildChecker) { this._theme = new Theme(chatColorTheme, chatColorThemeInverted) this.buildChecker = true - } - else { - if (this.theme?.chatColorTheme !== chatColorTheme || this.theme?.chatColorThemeInverted !== chatColorThemeInverted) { + } else { + if ( + this.theme?.chatColorTheme !== chatColorTheme || + this.theme?.chatColorThemeInverted !== chatColorThemeInverted + ) { this._theme = new Theme(chatColorTheme, chatColorThemeInverted) this.buildChecker = true } diff --git a/web/app/components/base/chat/embedded-chatbot/theme/utils.ts b/web/app/components/base/chat/embedded-chatbot/theme/utils.ts index a76caa60fbec05..4cb1ef156edc4c 100644 --- a/web/app/components/base/chat/embedded-chatbot/theme/utils.ts +++ b/web/app/components/base/chat/embedded-chatbot/theme/utils.ts @@ -16,8 +16,7 @@ export function hexToRGBA(hex: string, opacity: number): string { * this method transforms the string into an object representation of the styles. */ export function CssTransform(cssString: string): CSSProperties { - if (cssString.length === 0) - return {} + if (cssString.length === 0) return {} const style: CSSProperties = {} const propertyValuePairs = cssString.split(';') diff --git a/web/app/components/base/chat/storage.ts b/web/app/components/base/chat/storage.ts index d31b5778615577..5b1bd7c0e750da 100644 --- a/web/app/components/base/chat/storage.ts +++ b/web/app/components/base/chat/storage.ts @@ -1,11 +1,8 @@ import { createLocalStorageState } from 'foxact/create-local-storage-state' import { CONVERSATION_ID_INFO } from './constants' -const [ - useConversationIdInfo, - _useConversationIdInfoValue, - _useSetConversationIdInfo, -] = createLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {}) +const [useConversationIdInfo, _useConversationIdInfoValue, _useSetConversationIdInfo] = + createLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {}) const [ useWebAppSidebarCollapseState, @@ -13,7 +10,4 @@ const [ _useSetWebAppSidebarCollapseState, ] = createLocalStorageState<string>('webappSidebarCollapse', undefined, { raw: true }) -export { - useConversationIdInfo, - useWebAppSidebarCollapseState, -} +export { useConversationIdInfo, useWebAppSidebarCollapseState } diff --git a/web/app/components/base/chat/types.ts b/web/app/components/base/chat/types.ts index 02e3113d679dd5..cad33bddbfe726 100644 --- a/web/app/components/base/chat/types.ts +++ b/web/app/components/base/chat/types.ts @@ -1,15 +1,10 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { WorkflowRunningStatus } from '@/app/components/workflow/types' -import type { - ModelConfig, -} from '@/types/app' +import type { ModelConfig } from '@/types/app' import type { HumanInputFilledFormData, HumanInputFormData, NodeTracing } from '@/types/workflow' -export type { - Inputs, - -} from '@/models/debug' +export type { Inputs } from '@/models/debug' export { TransferMethod } from '@/types/app' @@ -59,10 +54,18 @@ export type ChatItemInTree = { export type OnSend = { (message: string, files?: FileEntity[]): void - (message: string, files: FileEntity[] | undefined, isRegenerate: boolean, lastAnswer?: ChatItem | null): void + ( + message: string, + files: FileEntity[] | undefined, + isRegenerate: boolean, + lastAnswer?: ChatItem | null, + ): void } -export type OnRegenerate = (chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => void +export type OnRegenerate = ( + chatItem: ChatItem, + editedQuestion?: { message: string; files?: FileEntity[] }, +) => void export type Callback = { onSuccess: () => void diff --git a/web/app/components/base/chat/utils.ts b/web/app/components/base/chat/utils.ts index c6574b2dc8f589..552fd6454219a7 100644 --- a/web/app/components/base/chat/utils.ts +++ b/web/app/components/base/chat/utils.ts @@ -5,12 +5,13 @@ import { UUID_NIL } from './constants' async function decodeBase64AndDecompress(base64String: string) { try { const binaryString = atob(base64String) - const compressedUint8Array = Uint8Array.from(binaryString, char => char.charCodeAt(0)) - const decompressedStream = new Response(compressedUint8Array).body?.pipeThrough(new DecompressionStream('gzip')) + const compressedUint8Array = Uint8Array.from(binaryString, (char) => char.charCodeAt(0)) + const decompressedStream = new Response(compressedUint8Array).body?.pipeThrough( + new DecompressionStream('gzip'), + ) const decompressedArrayBuffer = await new Response(decompressedStream).arrayBuffer() return new TextDecoder().decode(decompressedArrayBuffer) - } - catch { + } catch { return undefined } } @@ -19,13 +20,14 @@ async function getRawInputsFromUrlParams(): Promise<Record<string, any>> { const urlParams = new URLSearchParams(window.location.search) const inputs: Record<string, any> = {} const entriesArray = Array.from(urlParams.entries()) - await Promise.all(entriesArray.map(async ([key, value]) => { - const prefixArray = ['sys.', 'user.'] - if (prefixArray.some(prefix => key.startsWith(prefix))) - return + await Promise.all( + entriesArray.map(async ([key, value]) => { + const prefixArray = ['sys.', 'user.'] + if (prefixArray.some((prefix) => key.startsWith(prefix))) return - inputs[key] = decodeURIComponent(value) - })) + inputs[key] = decodeURIComponent(value) + }), + ) return inputs } @@ -36,7 +38,7 @@ async function getProcessedInputsFromUrlParams(): Promise<Record<string, any>> { await Promise.all( entriesArray.map(async ([key, value]) => { const prefixArray = ['sys.', 'user.'] - if (!prefixArray.some(prefix => key.startsWith(prefix))) + if (!prefixArray.some((prefix) => key.startsWith(prefix))) inputs[key] = await decodeBase64AndDecompress(decodeURIComponent(value)) }), ) @@ -51,8 +53,7 @@ async function getProcessedSystemVariablesFromUrlParams(): Promise<Record<string const queryString = decodedRedirectUrl.split('?')[1] if (queryString) { const redirectParams = new URLSearchParams(queryString) - for (const [key, value] of redirectParams.entries()) - urlParams.set(key, value) + for (const [key, value] of redirectParams.entries()) urlParams.set(key, value) } } const systemVariables: Record<string, any> = {} @@ -83,24 +84,29 @@ async function getRawUserVariablesFromUrlParams(): Promise<Record<string, any>> const urlParams = new URLSearchParams(window.location.search) const userVariables: Record<string, any> = {} const entriesArray = Array.from(urlParams.entries()) - await Promise.all(entriesArray.map(async ([key, value]) => { - if (!key.startsWith('user.')) - return + await Promise.all( + entriesArray.map(async ([key, value]) => { + if (!key.startsWith('user.')) return - userVariables[key.slice(5)] = decodeURIComponent(value) - })) + userVariables[key.slice(5)] = decodeURIComponent(value) + }), + ) return userVariables } function isValidGeneratedAnswer(item?: ChatItem | ChatItemInTree): boolean { - return !!item && item.isAnswer && !item.id.startsWith('answer-placeholder-') && !item.isOpeningStatement + return ( + !!item && + item.isAnswer && + !item.id.startsWith('answer-placeholder-') && + !item.isOpeningStatement + ) } function getLastAnswer<T extends ChatItem | ChatItemInTree>(chatList: T[]): T | null { for (let i = chatList.length - 1; i >= 0; i--) { const item = chatList[i]! - if (isValidGeneratedAnswer(item)) - return item + if (isValidGeneratedAnswer(item)) return item } return null } @@ -114,12 +120,11 @@ function buildChatItemTree(allMessages: IChatItem[]): ChatItemInTree[] { const map: Record<string, ChatItemInTree> = {} const rootNodes: ChatItemInTree[] = [] const childrenCount: Record<string, number> = {} - const messageIds = new Set(allMessages.map(item => item.id)) + const messageIds = new Set(allMessages.map((item) => item.id)) const pendingChildren: Record<string, ChatItemInTree[]> = {} const appendPendingChildren = (parentId: string) => { const children = pendingChildren[parentId] - if (!children) - return + if (!children) return map[parentId]?.children?.push(...children) delete pendingChildren[parentId] @@ -132,8 +137,8 @@ function buildChatItemTree(allMessages: IChatItem[]): ChatItemInTree[] { const isLegacy = question.parentMessageId === UUID_NIL const parentMessageId = isLegacy - ? (lastAppendedLegacyAnswer?.id || '') - : (question.parentMessageId || '') + ? lastAppendedLegacyAnswer?.id || '' + : question.parentMessageId || '' // Process question childrenCount[parentMessageId] = (childrenCount[parentMessageId] || 0) + 1 @@ -159,25 +164,20 @@ function buildChatItemTree(allMessages: IChatItem[]): ChatItemInTree[] { // Append to parent or add to root if (isLegacy) { - if (!lastAppendedLegacyAnswer) - rootNodes.push(questionNode) - else - lastAppendedLegacyAnswer.children!.push(questionNode) + if (!lastAppendedLegacyAnswer) rootNodes.push(questionNode) + else lastAppendedLegacyAnswer.children!.push(questionNode) lastAppendedLegacyAnswer = answerNode - } - else { + } else { if ( - !parentMessageId - || !messageIds.has(parentMessageId) // parent message might not be fetched yet, in this case we will append the question to the root nodes + !parentMessageId || + !messageIds.has(parentMessageId) // parent message might not be fetched yet, in this case we will append the question to the root nodes ) { rootNodes.push(questionNode) - } - else if (!map[parentMessageId]) { + } else if (!map[parentMessageId]) { pendingChildren[parentMessageId] = pendingChildren[parentMessageId] || [] pendingChildren[parentMessageId]!.push(questionNode) - } - else { + } else { map[parentMessageId]!.children!.push(questionNode) } } @@ -191,25 +191,31 @@ function getThreadMessages(tree: ChatItemInTree[], targetMessageId?: string): Ch let targetNode: ChatItemInTree | undefined // find path to the target message - const stack = tree.slice().reverse().map(rootNode => ({ - node: rootNode, - path: [rootNode], - })) + const stack = tree + .slice() + .reverse() + .map((rootNode) => ({ + node: rootNode, + path: [rootNode], + })) while (stack.length > 0) { const { node, path } = stack.pop()! if ( - node.id === targetMessageId - || (!targetMessageId && !node.children?.length && !stack.length) // if targetMessageId is not provided, we use the last message in the tree as the target + node.id === targetMessageId || + (!targetMessageId && !node.children?.length && !stack.length) // if targetMessageId is not provided, we use the last message in the tree as the target ) { targetNode = node ret = path.map((item, index) => { - if (!item.isAnswer) - return item + if (!item.isAnswer) return item const parentAnswer = path[index - 2] const siblingCount = !parentAnswer ? tree.length : parentAnswer.children!.length - const prevSibling = !parentAnswer ? tree[item.siblingIndex! - 1]?.children?.[0]?.id : parentAnswer.children![item.siblingIndex! - 1]?.children?.[0]!.id - const nextSibling = !parentAnswer ? tree[item.siblingIndex! + 1]?.children?.[0]?.id : parentAnswer.children![item.siblingIndex! + 1]?.children?.[0]!.id + const prevSibling = !parentAnswer + ? tree[item.siblingIndex! - 1]?.children?.[0]?.id + : parentAnswer.children![item.siblingIndex! - 1]?.children?.[0]!.id + const nextSibling = !parentAnswer + ? tree[item.siblingIndex! + 1]?.children?.[0]?.id + : parentAnswer.children![item.siblingIndex! + 1]?.children?.[0]!.id return { ...item, siblingCount, prevSibling, nextSibling } }) @@ -230,8 +236,7 @@ function getThreadMessages(tree: ChatItemInTree[], targetMessageId?: string): Ch const stack = [targetNode] while (stack.length > 0) { const node = stack.pop()! - if (node !== targetNode) - ret.push(node) + if (node !== targetNode) ret.push(node) if (node.children?.length) { const lastChild = node.children.at(-1)! diff --git a/web/app/components/base/checkbox-list/__tests__/index.spec.tsx b/web/app/components/base/checkbox-list/__tests__/index.spec.tsx index 06dea9225e8636..9ee395d2dfeac4 100644 --- a/web/app/components/base/checkbox-list/__tests__/index.spec.tsx +++ b/web/app/components/base/checkbox-list/__tests__/index.spec.tsx @@ -13,16 +13,12 @@ describe('checkbox list component', () => { ] it('renders with title, description and options', () => { - render( - <CheckboxList - title="Test Title" - description="Test Description" - options={options} - />, - ) + render(<CheckboxList title="Test Title" description="Test Description" options={options} />) expect(screen.getByText('Test Title'))!.toBeInTheDocument() expect(screen.getByText('Test Description'))!.toBeInTheDocument() - expect(screen.getByRole('group', { name: 'Test Title' }))!.toHaveAccessibleDescription('Test Description') + expect(screen.getByRole('group', { name: 'Test Title' }))!.toHaveAccessibleDescription( + 'Test Description', + ) options.forEach((option) => { expect(screen.getByText(option.label))!.toBeInTheDocument() }) @@ -47,14 +43,7 @@ describe('checkbox list component', () => { it('selects all options when select-all is clicked', async () => { const onChange = vi.fn() - render( - <CheckboxList - options={options} - value={[]} - onChange={onChange} - showSelectAll - />, - ) + render(<CheckboxList options={options} value={[]} onChange={onChange} showSelectAll />) const selectAll = screen.getByRole('checkbox', { name: selectAllName }) await userEvent.click(selectAll) @@ -65,15 +54,7 @@ describe('checkbox list component', () => { it('does not select all options when select-all is clicked when disabled', async () => { const onChange = vi.fn() - render( - <CheckboxList - options={options} - value={[]} - disabled - showSelectAll - onChange={onChange} - />, - ) + render(<CheckboxList options={options} value={[]} disabled showSelectAll onChange={onChange} />) const selectAll = screen.getByRole('checkbox', { name: selectAllName }) await userEvent.click(selectAll) @@ -124,14 +105,7 @@ describe('checkbox list component', () => { it('selects options when checkbox is clicked', async () => { const onChange = vi.fn() - render( - <CheckboxList - options={options} - value={[]} - onChange={onChange} - showSelectAll={false} - />, - ) + render(<CheckboxList options={options} value={[]} onChange={onChange} showSelectAll={false} />) const selectOption = screen.getByRole('checkbox', { name: 'Option 1' }) await userEvent.click(selectOption) @@ -158,14 +132,7 @@ describe('checkbox list component', () => { it('does not select options when checkbox is clicked', async () => { const onChange = vi.fn() - render( - <CheckboxList - options={options} - value={[]} - onChange={onChange} - disabled - />, - ) + render(<CheckboxList options={options} value={[]} onChange={onChange} disabled />) const selectOption = screen.getByRole('checkbox', { name: 'Option 1' }) await userEvent.click(selectOption) @@ -175,13 +142,7 @@ describe('checkbox list component', () => { it('Reset button works', async () => { const onChange = vi.fn() - render( - <CheckboxList - options={options} - value={[]} - onChange={onChange} - />, - ) + render(<CheckboxList options={options} value={[]} onChange={onChange} />) const input = getSearchInput() await userEvent.type(input, 'ban') @@ -196,13 +157,7 @@ describe('checkbox list component', () => { { label: 'Disabled', value: 'disabled', disabled: true }, ] - render( - <CheckboxList - options={disabledOptions} - value={[]} - onChange={onChange} - />, - ) + render(<CheckboxList options={disabledOptions} value={[]} onChange={onChange} />) const disabledCheckbox = screen.getByRole('checkbox', { name: 'Disabled' }) await userEvent.click(disabledCheckbox) @@ -212,38 +167,21 @@ describe('checkbox list component', () => { it('does not toggle option when component is disabled and option label is clicked', async () => { const onChange = vi.fn() - render( - <CheckboxList - options={options} - value={[]} - onChange={onChange} - disabled - />, - ) + render(<CheckboxList options={options} value={[]} onChange={onChange} disabled />) await userEvent.click(screen.getByText('Option 1')) expect(onChange).not.toHaveBeenCalled() }) it('renders with label prop', () => { - render( - <CheckboxList - options={options} - label="Test Label" - />, - ) + render(<CheckboxList options={options} label="Test Label" />) expect(screen.getByText('Test Label'))!.toBeInTheDocument() expect(screen.getByRole('group', { name: 'Test Label' }))!.toBeInTheDocument() }) it('renders without showSelectAll, showCount, showSearch', () => { render( - <CheckboxList - options={options} - showSelectAll={false} - showCount={false} - showSearch={false} - />, + <CheckboxList options={options} showSelectAll={false} showCount={false} showSearch={false} />, ) expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument() options.forEach((option) => { @@ -253,21 +191,13 @@ describe('checkbox list component', () => { it('renders with custom containerClassName', () => { const { container } = render( - <CheckboxList - options={options} - containerClassName="custom-class" - />, + <CheckboxList options={options} containerClassName="custom-class" />, ) expect(container.querySelector('.custom-class'))!.toBeInTheDocument() }) it('applies maxHeight style to options container', () => { - render( - <CheckboxList - options={options} - maxHeight="200px" - />, - ) + render(<CheckboxList options={options} maxHeight="200px" />) const optionsContainer = screen.getByTestId('options-container') expect(optionsContainer)!.toHaveStyle({ maxHeight: '200px', overflowY: 'auto' }) }) @@ -315,14 +245,7 @@ describe('checkbox list component', () => { it('toggles option by clicking option row', async () => { const onChange = vi.fn() - render( - <CheckboxList - options={options} - value={[]} - onChange={onChange} - showSelectAll={false} - />, - ) + render(<CheckboxList options={options} value={[]} onChange={onChange} showSelectAll={false} />) const optionLabel = screen.getByText('Option 1') const optionRow = optionLabel.closest('label[data-testid="option-item"]') @@ -334,17 +257,9 @@ describe('checkbox list component', () => { it('does not toggle when clicking disabled option row', async () => { const onChange = vi.fn() - const disabledOptions = [ - { label: 'Option 1', value: 'option1', disabled: true }, - ] + const disabledOptions = [{ label: 'Option 1', value: 'option1', disabled: true }] - render( - <CheckboxList - options={disabledOptions} - value={[]} - onChange={onChange} - />, - ) + render(<CheckboxList options={disabledOptions} value={[]} onChange={onChange} />) const optionRow = screen.getByText('Option 1').closest('label[data-testid="option-item"]') expect(optionRow)!.toBeInTheDocument() @@ -354,24 +269,13 @@ describe('checkbox list component', () => { }) it('renders without title and description', () => { - render( - <CheckboxList - options={options} - title="" - description="" - />, - ) + render(<CheckboxList options={options} title="" description="" />) expect(screen.queryByText(/Test Title/)).not.toBeInTheDocument() expect(screen.queryByText(/Test Description/)).not.toBeInTheDocument() }) it('shows correct filtered count message when searching', async () => { - render( - <CheckboxList - options={options} - title="Items" - />, - ) + render(<CheckboxList options={options} title="Items" />) const input = getSearchInput() await userEvent.type(input, 'opt') @@ -380,28 +284,15 @@ describe('checkbox list component', () => { }) it('shows no data message when no options are provided', () => { - render( - <CheckboxList - options={[]} - />, - ) + render(<CheckboxList options={[]} />) expect(screen.getByText('common.noData'))!.toBeInTheDocument() }) it('does not toggle option when component is disabled even with enabled option', async () => { const onChange = vi.fn() - const disabledOptions = [ - { label: 'Option', value: 'option' }, - ] + const disabledOptions = [{ label: 'Option', value: 'option' }] - render( - <CheckboxList - options={disabledOptions} - value={[]} - onChange={onChange} - disabled - />, - ) + render(<CheckboxList options={disabledOptions} value={[]} onChange={onChange} disabled />) const checkbox = screen.getByRole('checkbox', { name: 'Option' }) await userEvent.click(checkbox) diff --git a/web/app/components/base/checkbox-list/index.tsx b/web/app/components/base/checkbox-list/index.tsx index 9fceb435001e15..9f2e768d0c5588 100644 --- a/web/app/components/base/checkbox-list/index.tsx +++ b/web/app/components/base/checkbox-list/index.tsx @@ -52,41 +52,37 @@ export const CheckboxList = ({ const [searchQuery, setSearchQuery] = useState('') const filteredOptions = useMemo(() => { - if (!searchQuery?.trim()) - return options + if (!searchQuery?.trim()) return options const query = searchQuery.toLowerCase() - return options.filter(option => - option.label.toLowerCase().includes(query) || option.value.toLowerCase().includes(query), + return options.filter( + (option) => + option.label.toLowerCase().includes(query) || option.value.toLowerCase().includes(query), ) }, [options, searchQuery]) const selectedCount = value.length const selectableOptionValues = useMemo( - () => options.filter(option => !option.disabled).map(option => option.value), + () => options.filter((option) => !option.disabled).map((option) => option.value), [options], ) return ( <Field name={name} className={cn('flex w-full flex-col gap-1', containerClassName)}> <Fieldset - render={( + render={ <CheckboxGroup aria-label={!label && title ? title : undefined} value={value} - onValueChange={nextValue => onChange?.(nextValue)} + onValueChange={(nextValue) => onChange?.(nextValue)} allValues={selectableOptionValues} disabled={disabled} className="flex flex-col gap-1" /> - )} + } > - {label && ( - <FieldsetLegend className="mb-0"> - {label} - </FieldsetLegend> - )} + {label && <FieldsetLegend className="mb-0">{label}</FieldsetLegend>} {description && ( <FieldDescription className="body-xs-regular text-text-tertiary"> {description} @@ -98,44 +94,45 @@ export const CheckboxList = ({ <div className="relative flex items-center gap-2 border-b border-divider-subtle px-3 py-2"> {!searchQuery && showSelectAll && ( <FieldItem disabled={disabled} className="shrink-0 gap-0"> - <FieldLabel className={cn('flex items-center p-0', !disabled && 'cursor-pointer')}> - <Checkbox - parent - disabled={disabled} - /> - <span className="sr-only">{t($ => $['operation.selectAll'], { ns: 'common' })}</span> + <FieldLabel + className={cn('flex items-center p-0', !disabled && 'cursor-pointer')} + > + <Checkbox parent disabled={disabled} /> + <span className="sr-only"> + {t(($) => $['operation.selectAll'], { ns: 'common' })} + </span> </FieldLabel> </FieldItem> )} - {!searchQuery - ? ( - <div className="flex min-w-0 flex-1 items-center gap-1"> - {title && ( - <span className="truncate system-xs-semibold-uppercase leading-5 text-text-secondary"> - {title} - </span> - )} - {showCount && selectedCount > 0 && ( - <Badge uppercase> - {t($ => $['operation.selectCount'], { ns: 'common', count: selectedCount })} - </Badge> - )} - </div> - ) - : ( - <div className="flex-1 system-sm-medium-uppercase leading-6 text-text-secondary"> - { - filteredOptions.length > 0 - ? t($ => $['operation.searchCount'], { ns: 'common', count: filteredOptions.length, content: title }) - : t($ => $['operation.noSearchCount'], { ns: 'common', content: title }) - } - </div> + {!searchQuery ? ( + <div className="flex min-w-0 flex-1 items-center gap-1"> + {title && ( + <span className="truncate system-xs-semibold-uppercase leading-5 text-text-secondary"> + {title} + </span> + )} + {showCount && selectedCount > 0 && ( + <Badge uppercase> + {t(($) => $['operation.selectCount'], { ns: 'common', count: selectedCount })} + </Badge> )} + </div> + ) : ( + <div className="flex-1 system-sm-medium-uppercase leading-6 text-text-secondary"> + {filteredOptions.length > 0 + ? t(($) => $['operation.searchCount'], { + ns: 'common', + count: filteredOptions.length, + content: title, + }) + : t(($) => $['operation.noSearchCount'], { ns: 'common', content: title })} + </div> + )} {showSearch && ( <SearchInput value={searchQuery} onValueChange={setSearchQuery} - placeholder={t($ => $['placeholder.search'], { ns: 'common' })} + placeholder={t(($) => $['placeholder.search'], { ns: 'common' })} className="w-40" /> )} @@ -147,48 +144,51 @@ export const CheckboxList = ({ style={maxHeight ? { maxHeight, overflowY: 'auto' } : {}} data-testid="options-container" > - {!filteredOptions.length - ? ( - <div className="px-3 py-6 text-center text-sm text-text-tertiary"> - {searchQuery - ? ( - <div className="flex flex-col items-center justify-center gap-2"> - <img alt="search menu" src={SearchMenu.src} width={32} /> - <span className="system-sm-regular text-text-secondary">{t($ => $['operation.noSearchResults'], { ns: 'common', content: title })}</span> - <Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t($ => $['operation.resetKeywords'], { ns: 'common' })}</Button> - </div> - ) - : t($ => $.noData, { ns: 'common' })} - </div> - ) - : ( - filteredOptions.map(option => ( - <FieldItem - key={option.value} - disabled={option.disabled || disabled} - className="gap-0" + {!filteredOptions.length ? ( + <div className="px-3 py-6 text-center text-sm text-text-tertiary"> + {searchQuery ? ( + <div className="flex flex-col items-center justify-center gap-2"> + <img alt="search menu" src={SearchMenu.src} width={32} /> + <span className="system-sm-regular text-text-secondary"> + {t(($) => $['operation.noSearchResults'], { ns: 'common', content: title })} + </span> + <Button + variant="secondary-accent" + size="small" + onClick={() => setSearchQuery('')} > - <FieldLabel - data-testid="option-item" - className={cn( - 'flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover', - (option.disabled || disabled) && 'cursor-not-allowed opacity-50', - )} - > - <Checkbox - value={option.value} - disabled={option.disabled || disabled} - /> - <span - className="flex-1 truncate system-sm-medium text-text-secondary" - title={option.label} - > - {option.label} - </span> - </FieldLabel> - </FieldItem> - )) + {t(($) => $['operation.resetKeywords'], { ns: 'common' })} + </Button> + </div> + ) : ( + t(($) => $.noData, { ns: 'common' }) )} + </div> + ) : ( + filteredOptions.map((option) => ( + <FieldItem + key={option.value} + disabled={option.disabled || disabled} + className="gap-0" + > + <FieldLabel + data-testid="option-item" + className={cn( + 'flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover', + (option.disabled || disabled) && 'cursor-not-allowed opacity-50', + )} + > + <Checkbox value={option.value} disabled={option.disabled || disabled} /> + <span + className="flex-1 truncate system-sm-medium text-text-secondary" + title={option.label} + > + {option.label} + </span> + </FieldLabel> + </FieldItem> + )) + )} </div> </div> </Fieldset> diff --git a/web/app/components/base/chip/__tests__/index.spec.tsx b/web/app/components/base/chip/__tests__/index.spec.tsx index d4268ec6b99d2c..f7b09032bba336 100644 --- a/web/app/components/base/chip/__tests__/index.spec.tsx +++ b/web/app/components/base/chip/__tests__/index.spec.tsx @@ -32,13 +32,7 @@ describe('Chip', () => { return { user, ...render( - <Chip - value="all" - items={items} - onSelect={onSelect} - onClear={onClear} - {...props} - />, + <Chip value="all" items={items} onSelect={onSelect} onClear={onClear} {...props} />, ), } } @@ -91,14 +85,7 @@ describe('Chip', () => { const { rerender } = renderChip({ value: 'all' }) expect(screen.getByText('All Items'))!.toBeInTheDocument() - rerender( - <Chip - value="archived" - items={items} - onSelect={onSelect} - onClear={onClear} - />, - ) + rerender(<Chip value="archived" items={items} onSelect={onSelect} onClear={onClear} />) expect(screen.getByRole('combobox', { name: 'Archived' }))!.toBeInTheDocument() }) @@ -178,8 +165,7 @@ describe('Chip', () => { expect(trigger).toHaveAttribute('data-popup-open') expect(within(listbox).getByRole('option', { name: 'All Items' })).toBeInTheDocument() - if (trigger) - await user.click(trigger) + if (trigger) await user.click(trigger) await expectPanelClosed(trigger) }) @@ -240,17 +226,14 @@ describe('Chip', () => { const trigger = getTrigger(container) - if (trigger) - await user.click(trigger) + if (trigger) await user.click(trigger) expect(await screen.findByRole('listbox')).toBeInTheDocument() expect(trigger).toHaveAttribute('data-popup-open') - if (trigger) - await user.click(trigger) + if (trigger) await user.click(trigger) await expectPanelClosed(trigger) - if (trigger) - await user.click(trigger) + if (trigger) await user.click(trigger) expect(await screen.findByRole('listbox')).toBeInTheDocument() expect(trigger).toHaveAttribute('data-popup-open') }) @@ -300,7 +283,10 @@ describe('Chip', () => { const listbox = await openPanel(user, container) - expect(within(listbox).getByRole('option', { name: 'Active' })).toHaveAttribute('aria-selected', 'true') + expect(within(listbox).getByRole('option', { name: 'Active' })).toHaveAttribute( + 'aria-selected', + 'true', + ) }) it('should render all items in dropdown when open', async () => { @@ -331,7 +317,9 @@ describe('Chip', () => { // The trigger should not display any item name text expect(trigger?.textContent?.trim()).toBeFalsy() - expect(screen.queryByRole('button', { name: /common\.operation\.clear/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /common\.operation\.clear/ }), + ).not.toBeInTheDocument() }) it('should allow selecting already selected item', async () => { diff --git a/web/app/components/base/chip/index.stories.tsx b/web/app/components/base/chip/index.stories.tsx index 5812f97d981ab7..74f3b0ab31c32b 100644 --- a/web/app/components/base/chip/index.stories.tsx +++ b/web/app/components/base/chip/index.stories.tsx @@ -16,7 +16,8 @@ const meta = { parameters: { docs: { description: { - component: 'Filter chip with dropdown panel and optional left icon. Commonly used for status pickers in toolbars.', + component: + 'Filter chip with dropdown panel and optional left icon. Commonly used for status pickers in toolbars.', }, }, }, @@ -42,20 +43,18 @@ const ChipDemo = (props: React.ComponentProps<typeof Chip>) => { <Chip {...props} value={selection} - onSelect={item => setSelection(item.value)} + onSelect={(item) => setSelection(item.value)} onClear={() => setSelection('all')} /> <div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs text-text-secondary"> - Current value: - {' '} - <span className="font-mono text-text-primary">{selection}</span> + Current value: <span className="font-mono text-text-primary">{selection}</span> </div> </div> ) } export const Playground: Story = { - render: args => <ChipDemo {...args} />, + render: (args) => <ChipDemo {...args} />, parameters: { docs: { source: { @@ -83,12 +82,7 @@ export const WithoutLeftIcon: Story = { onClear: () => {}, }, - render: args => ( - <ChipDemo - {...args} - showLeftIcon={false} - /> - ), + render: (args) => <ChipDemo {...args} showLeftIcon={false} />, parameters: { docs: { source: { diff --git a/web/app/components/base/chip/index.tsx b/web/app/components/base/chip/index.tsx index bba97d0e20c84b..8656291b88bbb7 100644 --- a/web/app/components/base/chip/index.tsx +++ b/web/app/components/base/chip/index.tsx @@ -42,32 +42,33 @@ function Chip<T extends ItemValue>({ onClear, }: Props<T>) { const { t } = useTranslation() - const selectedItem = items.find(item => Object.is(item.value, value)) + const selectedItem = items.find((item) => Object.is(item.value, value)) const triggerContent = selectedItem?.triggerName || selectedItem?.name || '' const hasValue = selectedItem !== undefined && value !== '' const clearLabel = triggerContent - ? `${t($ => $['operation.clear'], { ns: 'common' })} ${triggerContent}` - : t($ => $['operation.clear'], { ns: 'common' }) + ? `${t(($) => $['operation.clear'], { ns: 'common' })} ${triggerContent}` + : t(($) => $['operation.clear'], { ns: 'common' }) return ( <Select value={selectedItem?.value ?? null} - itemToStringLabel={(itemValue: T) => items.find(item => Object.is(item.value, itemValue))?.name ?? ''} - itemToStringValue={itemValue => String(itemValue)} + itemToStringLabel={(itemValue: T) => + items.find((item) => Object.is(item.value, itemValue))?.name ?? '' + } + itemToStringValue={(itemValue) => String(itemValue)} onValueChange={(nextValue) => { - if (nextValue === null) - return - const selected = items.find(item => Object.is(item.value, nextValue)) - if (selected) - onSelect(selected) + if (nextValue === null) return + const selected = items.find((item) => Object.is(item.value, nextValue)) + if (selected) onSelect(selected) }} > <div className="relative w-fit max-w-full"> <SelectTrigger - aria-label={triggerContent || t($ => $['placeholder.select'], { ns: 'common' })} + aria-label={triggerContent || t(($) => $['placeholder.select'], { ns: 'common' })} className={cn( 'h-auto min-h-8 w-fit max-w-full cursor-pointer items-center rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt! data-popup-open:hover:bg-state-base-hover-alt [&>*:last-child]:hidden', - hasValue && 'border-components-button-secondary-border! bg-components-button-secondary-bg! pr-6 shadow-xs hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover! data-popup-open:border-components-button-secondary-border-hover! data-popup-open:bg-components-button-secondary-bg-hover! data-popup-open:hover:border-components-button-secondary-border-hover data-popup-open:hover:bg-components-button-secondary-bg-hover!', + hasValue && + 'border-components-button-secondary-border! bg-components-button-secondary-bg! pr-6 shadow-xs hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover! data-popup-open:border-components-button-secondary-border-hover! data-popup-open:bg-components-button-secondary-bg-hover! data-popup-open:hover:border-components-button-secondary-border-hover data-popup-open:hover:bg-components-button-secondary-bg-hover!', className, )} > @@ -75,16 +76,32 @@ function Chip<T extends ItemValue>({ {showLeftIcon && ( <span aria-hidden="true" className="p-0.5"> {leftIcon || ( - <span aria-hidden className={cn('i-ri-filter-3-line block size-4 text-text-tertiary', hasValue && 'text-text-secondary')} /> + <span + aria-hidden + className={cn( + 'i-ri-filter-3-line block size-4 text-text-tertiary', + hasValue && 'text-text-secondary', + )} + /> )} </span> )} <span className="flex grow items-center gap-0.5 first-line:p-1"> - <span className={cn('system-sm-regular text-text-tertiary', hasValue && 'text-text-secondary')}> + <span + className={cn( + 'system-sm-regular text-text-tertiary', + hasValue && 'text-text-secondary', + )} + > {triggerContent} </span> </span> - {!hasValue && <span aria-hidden className="i-ri-arrow-down-s-line block size-4 text-text-tertiary" />} + {!hasValue && ( + <span + aria-hidden + className="i-ri-arrow-down-s-line block size-4 text-text-tertiary" + /> + )} </span> </SelectTrigger> {hasValue && ( @@ -94,7 +111,10 @@ function Chip<T extends ItemValue>({ className="group/clear absolute top-1/2 right-1.5 flex size-5 -translate-y-1/2 cursor-pointer touch-manipulation items-center justify-center rounded-md border-none bg-transparent p-0 outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid" onClick={onClear} > - <span aria-hidden className="i-ri-close-circle-fill block size-3.5 text-text-quaternary group-hover/clear:text-text-tertiary" /> + <span + aria-hidden + className="i-ri-close-circle-fill block size-3.5 text-text-quaternary group-hover/clear:text-text-tertiary" + /> </button> )} <SelectContent @@ -106,11 +126,8 @@ function Chip<T extends ItemValue>({ )} listClassName="max-h-72 p-1" > - {items.map(item => ( - <SelectItem - key={item.value} - value={item.value} - > + {items.map((item) => ( + <SelectItem key={item.value} value={item.value}> <SelectItemText title={item.name}>{item.name}</SelectItemText> {showItemIndicator && <SelectItemIndicator />} </SelectItem> @@ -118,7 +135,6 @@ function Chip<T extends ItemValue>({ </SelectContent> </div> </Select> - ) } diff --git a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx index f92925ae89d73a..fa530b75c23f95 100644 --- a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx +++ b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx @@ -35,14 +35,18 @@ describe('CopyFeedback', () => { describe('User Interactions', () => { it('calls copy with content when clicked', () => { render(<CopyFeedback content="test content" />) - const button = screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }) + const button = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.embedded.copy', + }) fireEvent.click(button) expect(mockCopy).toHaveBeenCalledWith('test content') }) it('does not reset on mouse leave (relies on hook timeout)', () => { render(<CopyFeedback content="test content" />) - const button = screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }) + const button = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.embedded.copy', + }) fireEvent.mouseLeave(button) expect(mockReset).not.toHaveBeenCalled() }) @@ -58,11 +62,15 @@ describe('CopyFeedbackNew', () => { describe('Rendering', () => { it('renders the component', () => { render(<CopyFeedbackNew content="test content" />) - expect(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }), + ).toBeInTheDocument() }) it('renders with custom className', () => { - const { container } = render(<CopyFeedbackNew content="test content" className="test-class" />) + const { container } = render( + <CopyFeedbackNew content="test content" className="test-class" />, + ) expect(container.querySelector('.test-class')).toBeInTheDocument() }) @@ -83,13 +91,17 @@ describe('CopyFeedbackNew', () => { describe('User Interactions', () => { it('calls copy with content when clicked', () => { render(<CopyFeedbackNew content="test content" />) - fireEvent.click(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' })) + fireEvent.click( + screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }), + ) expect(mockCopy).toHaveBeenCalledWith('test content') }) it('does not reset on mouse leave (relies on hook timeout)', () => { render(<CopyFeedbackNew content="test content" />) - fireEvent.mouseLeave(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' })) + fireEvent.mouseLeave( + screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }), + ) expect(mockReset).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/base/copy-feedback/index.stories.tsx b/web/app/components/base/copy-feedback/index.stories.tsx index 52bc0991f85f03..4deeea4273c3f1 100644 --- a/web/app/components/base/copy-feedback/index.stories.tsx +++ b/web/app/components/base/copy-feedback/index.stories.tsx @@ -8,7 +8,8 @@ const meta = { parameters: { docs: { description: { - component: 'Copy-to-clipboard button that shows instant feedback and a tooltip. Includes the original ActionButton wrapper and the newer ghost-button variant.', + component: + 'Copy-to-clipboard button that shows instant feedback and a tooltip. Includes the original ActionButton wrapper and the newer ghost-button variant.', }, }, }, @@ -27,7 +28,9 @@ const CopyDemo = ({ content }: { content: string }) => { <div className="flex flex-col gap-4"> <div className="flex items-center gap-2 text-sm text-text-secondary"> <span>Client ID:</span> - <span className="rounded-sm bg-background-default-subtle px-2 py-1 font-mono text-xs text-text-primary">{value}</span> + <span className="rounded-sm bg-background-default-subtle px-2 py-1 font-mono text-xs text-text-primary"> + {value} + </span> <CopyFeedback content={value} /> </div> <div className="flex items-center gap-2 text-sm text-text-secondary"> @@ -39,7 +42,7 @@ const CopyDemo = ({ content }: { content: string }) => { } export const Playground: Story = { - render: args => <CopyDemo content={args.content} />, + render: (args) => <CopyDemo content={args.content} />, parameters: { docs: { source: { diff --git a/web/app/components/base/copy-feedback/index.tsx b/web/app/components/base/copy-feedback/index.tsx index e37870e687f5f5..feba7932557e59 100644 --- a/web/app/components/base/copy-feedback/index.tsx +++ b/web/app/components/base/copy-feedback/index.tsx @@ -1,9 +1,6 @@ 'use client' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiClipboardFill, - RiClipboardLine, -} from '@remixicon/react' +import { RiClipboardFill, RiClipboardLine } from '@remixicon/react' import { useClipboard } from 'foxact/use-clipboard' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -25,8 +22,8 @@ const CopyFeedback = ({ content }: Props) => { const { copied, copy } = useClipboard({ timeout: 2000 }) const tooltipText = copied - ? t($ => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) - : t($ => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' }) + ? t(($) => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) + : t(($) => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' }) /* v8 ignore next -- i18n test mock always returns a non-empty string; runtime fallback is defensive. -- @preserve */ const safeText = tooltipText || '' @@ -37,16 +34,14 @@ const CopyFeedback = ({ content }: Props) => { return ( <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton aria-label={safeText} onClick={handleCopy}> {copied && <RiClipboardFill className="size-4" aria-hidden="true" />} {!copied && <RiClipboardLine className="size-4" aria-hidden="true" />} </ActionButton> - )} + } /> - <TooltipContent> - {safeText} - </TooltipContent> + <TooltipContent>{safeText}</TooltipContent> </Tooltip> ) } @@ -58,8 +53,8 @@ export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className' const { copied, copy } = useClipboard({ timeout: 2000 }) const tooltipText = copied - ? t($ => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) - : t($ => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' }) + ? t(($) => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) + : t(($) => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' }) /* v8 ignore next -- i18n test mock always returns a non-empty string; runtime fallback is defensive. -- @preserve */ const safeText = tooltipText || '' @@ -70,7 +65,7 @@ export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className' return ( <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={safeText} @@ -79,14 +74,11 @@ export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className' > <div className={`size-full ${copyStyle.copyIcon} ${copied ? copyStyle.copied : ''}`} - > - </div> + ></div> </button> - )} + } /> - <TooltipContent> - {safeText} - </TooltipContent> + <TooltipContent>{safeText}</TooltipContent> </Tooltip> ) } diff --git a/web/app/components/base/copy-icon/__tests__/index.spec.tsx b/web/app/components/base/copy-icon/__tests__/index.spec.tsx index 568d6c92843aaf..c6c790a4019b25 100644 --- a/web/app/components/base/copy-icon/__tests__/index.spec.tsx +++ b/web/app/components/base/copy-icon/__tests__/index.spec.tsx @@ -21,24 +21,32 @@ describe('copy icon component', () => { it('renders normally', () => { render(<CopyIcon content="this is some test content for the copy icon component" />) - expect(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }), + ).toBeInTheDocument() }) it('shows copy check icon when copied', () => { copied = true render(<CopyIcon content="this is some test content for the copy icon component" />) - expect(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copied' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copied' }), + ).toBeInTheDocument() }) it('handles copy when clicked', () => { render(<CopyIcon content="this is some test content for the copy icon component" />) - fireEvent.click(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' })) + fireEvent.click( + screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }), + ) expect(copy).toBeCalledTimes(1) }) it('resets on mouse leave', () => { render(<CopyIcon content="this is some test content for the copy icon component" />) - fireEvent.mouseLeave(screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' })) + fireEvent.mouseLeave( + screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }), + ) expect(reset).toBeCalledTimes(1) }) }) diff --git a/web/app/components/base/copy-icon/index.stories.tsx b/web/app/components/base/copy-icon/index.stories.tsx index 5393cba5974d2b..f4c2bffc963684 100644 --- a/web/app/components/base/copy-icon/index.stories.tsx +++ b/web/app/components/base/copy-icon/index.stories.tsx @@ -7,7 +7,8 @@ const meta = { parameters: { docs: { description: { - component: 'Interactive copy-to-clipboard glyph that swaps to a checkmark once the content has been copied. Tooltips rely on the app locale.', + component: + 'Interactive copy-to-clipboard glyph that swaps to a checkmark once the content has been copied. Tooltips rely on the app locale.', }, }, }, @@ -21,7 +22,7 @@ export default meta type Story = StoryObj<typeof meta> export const Default: Story = { - render: args => ( + render: (args) => ( <div className="flex items-center gap-2 rounded-lg border border-divider-subtle bg-components-panel-bg p-4 text-sm text-text-secondary"> <span>Hover or click to copy the app link:</span> <CopyIcon {...args} /> @@ -43,14 +44,17 @@ export const Default: Story = { } export const InlineUsage: Story = { - render: args => ( + render: (args) => ( <div className="space-y-3 text-sm text-text-secondary"> <p> - Use the copy icon inline with labels or metadata. Clicking the icon copies the value to the clipboard and shows a success tooltip. + Use the copy icon inline with labels or metadata. Clicking the icon copies the value to the + clipboard and shows a success tooltip. </p> <div className="flex items-center gap-1"> <span className="font-medium text-text-primary">Client ID</span> - <span className="rounded-sm bg-background-default-subtle px-2 py-1 font-mono text-xs text-text-secondary">acc-3f92fa</span> + <span className="rounded-sm bg-background-default-subtle px-2 py-1 font-mono text-xs text-text-secondary"> + acc-3f92fa + </span> <CopyIcon {...args} content="acc-3f92fa" /> </div> </div> diff --git a/web/app/components/base/copy-icon/index.tsx b/web/app/components/base/copy-icon/index.tsx index d6d722b529446f..59b6067b0eb41d 100644 --- a/web/app/components/base/copy-icon/index.tsx +++ b/web/app/components/base/copy-icon/index.tsx @@ -19,15 +19,15 @@ const CopyIcon = ({ content }: Props) => { }, [copy, content]) const tooltipText = copied - ? t($ => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) - : t($ => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' }) + ? t(($) => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) + : t(($) => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' }) /* v8 ignore next -- i18n test mock always returns a non-empty string; runtime fallback is defensive. -- @preserve */ const safeTooltipText = tooltipText || '' return ( <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={safeTooltipText} @@ -35,15 +35,15 @@ const CopyIcon = ({ content }: Props) => { onClick={handleCopy} onMouseLeave={reset} > - {!copied - ? (<span aria-hidden className="i-custom-vender-line-files-copy size-3.5" />) - : (<span aria-hidden className="i-custom-vender-line-files-copy-check size-3.5" />)} + {!copied ? ( + <span aria-hidden className="i-custom-vender-line-files-copy size-3.5" /> + ) : ( + <span aria-hidden className="i-custom-vender-line-files-copy-check size-3.5" /> + )} </button> - )} + } /> - <TooltipContent> - {safeTooltipText} - </TooltipContent> + <TooltipContent>{safeTooltipText}</TooltipContent> </Tooltip> ) } diff --git a/web/app/components/base/corner-label/__tests__/index.spec.tsx b/web/app/components/base/corner-label/__tests__/index.spec.tsx index 11a2c0c877ca6a..fcee00fa423396 100644 --- a/web/app/components/base/corner-label/__tests__/index.spec.tsx +++ b/web/app/components/base/corner-label/__tests__/index.spec.tsx @@ -8,7 +8,13 @@ describe('CornerLabel', () => { }) it('applies custom class names', () => { - const { container } = render(<CornerLabel label="Test Label" className="custom-class" labelClassName="custom-label-class" />) + const { container } = render( + <CornerLabel + label="Test Label" + className="custom-class" + labelClassName="custom-label-class" + />, + ) expect(container.querySelector('.custom-class')).toBeInTheDocument() expect(container.querySelector('.custom-label-class')).toBeInTheDocument() expect(screen.getByText('Test Label')).toBeInTheDocument() diff --git a/web/app/components/base/corner-label/index.stories.tsx b/web/app/components/base/corner-label/index.stories.tsx index ce2ff6d277d9ae..46040c19bd7376 100644 --- a/web/app/components/base/corner-label/index.stories.tsx +++ b/web/app/components/base/corner-label/index.stories.tsx @@ -7,7 +7,8 @@ const meta = { parameters: { docs: { description: { - component: 'Decorative label that anchors to card corners. Useful for marking “beta”, “deprecated”, or similar callouts.', + component: + 'Decorative label that anchors to card corners. Useful for marking “beta”, “deprecated”, or similar callouts.', }, source: { language: 'tsx', @@ -29,11 +30,12 @@ type Story = StoryObj<typeof meta> export const Default: Story = {} export const OnCard: Story = { - render: args => ( + render: (args) => ( <div className="relative w-80 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6"> <CornerLabel {...args} className="absolute -top-px -right-px" /> <div className="text-sm text-text-secondary"> - Showcase how the label sits on a card header. Pair with contextual text or status information. + Showcase how the label sits on a card header. Pair with contextual text or status + information. </div> </div> ), diff --git a/web/app/components/base/corner-label/index.tsx b/web/app/components/base/corner-label/index.tsx index 7ba520968b911c..5fb433f1d60fdf 100644 --- a/web/app/components/base/corner-label/index.tsx +++ b/web/app/components/base/corner-label/index.tsx @@ -9,12 +9,25 @@ type CornerLabelProps = { textClassName?: string } -const CornerLabel: React.FC<CornerLabelProps> = ({ label, className, cornerClassName, labelClassName, textClassName }) => { +const CornerLabel: React.FC<CornerLabelProps> = ({ + label, + className, + cornerClassName, + labelClassName, + textClassName, +}) => { return ( <div className={cn('group/corner-label inline-flex items-start', className)}> <Corner className={cn('h-5 w-[13px] text-background-section-burn', cornerClassName)} /> - <div className={cn('flex items-center gap-0.5 bg-background-section-burn py-1 pr-2', labelClassName)}> - <div className={cn('system-2xs-medium-uppercase text-text-tertiary', textClassName)}>{label}</div> + <div + className={cn( + 'flex items-center gap-0.5 bg-background-section-burn py-1 pr-2', + labelClassName, + )} + > + <div className={cn('system-2xs-medium-uppercase text-text-tertiary', textClassName)}> + {label} + </div> </div> </div> ) diff --git a/web/app/components/base/date-and-time-picker/calendar/__tests__/item.spec.tsx b/web/app/components/base/date-and-time-picker/calendar/__tests__/item.spec.tsx index 8bf8ac68b545b8..fd8a78a1d58dc5 100644 --- a/web/app/components/base/date-and-time-picker/calendar/__tests__/item.spec.tsx +++ b/web/app/components/base/date-and-time-picker/calendar/__tests__/item.spec.tsx @@ -39,7 +39,10 @@ describe('CalendarItem', () => { render(<Item {...props} />) const button = screen.getByRole('button', { name: '15' }) - expect(button).toHaveClass('bg-components-button-primary-bg', 'text-components-button-primary-text') + expect(button).toHaveClass( + 'bg-components-button-primary-bg', + 'text-components-button-primary-text', + ) }) it('should not have selected styles when date does not match selectedDate', () => { @@ -48,7 +51,10 @@ describe('CalendarItem', () => { render(<Item {...props} />) const button = screen.getByRole('button', { name: '15' }) - expect(button).not.toHaveClass('bg-components-button-primary-bg', 'text-components-button-primary-text') + expect(button).not.toHaveClass( + 'bg-components-button-primary-bg', + 'text-components-button-primary-text', + ) }) it('should have different styles when day is not in current month', () => { diff --git a/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx b/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx index e9bdd70e62090d..b6e53b3024a6ce 100644 --- a/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx +++ b/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx @@ -6,7 +6,7 @@ export const DaysOfWeek = () => { return ( <div className="grid grid-cols-7 gap-x-0.5 border-b-[0.5px] border-divider-regular p-2"> - {daysOfWeek.map(day => ( + {daysOfWeek.map((day) => ( <div key={day} className="flex items-center justify-center system-2xs-medium text-text-tertiary" diff --git a/web/app/components/base/date-and-time-picker/calendar/index.tsx b/web/app/components/base/date-and-time-picker/calendar/index.tsx index 5a1492866baebe..914aceed61e956 100644 --- a/web/app/components/base/date-and-time-picker/calendar/index.tsx +++ b/web/app/components/base/date-and-time-picker/calendar/index.tsx @@ -14,17 +14,15 @@ const Calendar: FC<CalendarProps> = ({ <div className={wrapperClassName}> <DaysOfWeek /> <div className="grid grid-cols-7 gap-0.5 p-2"> - { - days.map(day => ( - <CalendarItem - key={day.date.format('YYYY-MM-DD')} - day={day} - selectedDate={selectedDate} - onClick={onDateClick} - isDisabled={getIsDateDisabled ? getIsDateDisabled(day.date) : false} - /> - )) - } + {days.map((day) => ( + <CalendarItem + key={day.date.format('YYYY-MM-DD')} + day={day} + selectedDate={selectedDate} + onClick={onDateClick} + isDisabled={getIsDateDisabled ? getIsDateDisabled(day.date) : false} + /> + ))} </div> </div> ) diff --git a/web/app/components/base/date-and-time-picker/calendar/item.tsx b/web/app/components/base/date-and-time-picker/calendar/item.tsx index 513a389ccf25bd..8d001a81aba9be 100644 --- a/web/app/components/base/date-and-time-picker/calendar/item.tsx +++ b/web/app/components/base/date-and-time-picker/calendar/item.tsx @@ -4,12 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import dayjs from '../utils/dayjs' -const Item: FC<CalendarItemProps> = ({ - day, - selectedDate, - onClick, - isDisabled, -}) => { +const Item: FC<CalendarItemProps> = ({ day, selectedDate, onClick, isDisabled }) => { const { date, isCurrentMonth } = day const isSelected = selectedDate?.isSame(date, 'date') const isToday = date.isSame(dayjs(), 'date') @@ -21,12 +16,16 @@ const Item: FC<CalendarItemProps> = ({ className={cn( 'relative flex items-center justify-center rounded-lg px-1 py-2 system-sm-medium', isCurrentMonth ? 'text-text-secondary' : 'text-text-quaternary hover:text-text-secondary', - isSelected ? 'bg-components-button-primary-bg system-sm-medium text-components-button-primary-text' : 'hover:bg-state-base-hover', + isSelected + ? 'bg-components-button-primary-bg system-sm-medium text-components-button-primary-text' + : 'hover:bg-state-base-hover', isDisabled && 'cursor-not-allowed text-text-quaternary hover:bg-transparent', )} > {date.date()} - {isToday && <div className="absolute bottom-1 mx-auto size-1 rounded-full bg-components-button-primary-bg" />} + {isToday && ( + <div className="absolute bottom-1 mx-auto size-1 rounded-full bg-components-button-primary-bg" /> + )} </button> ) } diff --git a/web/app/components/base/date-and-time-picker/common/option-list-item.tsx b/web/app/components/base/date-and-time-picker/common/option-list-item.tsx index e6f41a8bf9b3f4..85d515543849cd 100644 --- a/web/app/components/base/date-and-time-picker/common/option-list-item.tsx +++ b/web/app/components/base/date-and-time-picker/common/option-list-item.tsx @@ -19,20 +19,19 @@ const OptionListItem: FC<OptionListItemProps> = ({ const listItemRef = useRef<HTMLLIElement>(null) useEffect(() => { - if (isSelected && !noAutoScroll) - listItemRef.current?.scrollIntoView({ behavior: 'instant' }) + if (isSelected && !noAutoScroll) listItemRef.current?.scrollIntoView({ behavior: 'instant' }) }, []) return ( - <li - ref={listItemRef} - > + <li ref={listItemRef}> <button type="button" className={cn( 'flex w-full cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text outline-hidden', 'focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-hover', - isSelected ? 'bg-components-button-ghost-bg-hover' : 'hover:bg-components-button-ghost-bg-hover', + isSelected + ? 'bg-components-button-ghost-bg-hover' + : 'hover:bg-components-button-ghost-bg-hover', )} onClick={() => { listItemRef.current?.scrollIntoView({ behavior: 'smooth' }) diff --git a/web/app/components/base/date-and-time-picker/common/option-list.tsx b/web/app/components/base/date-and-time-picker/common/option-list.tsx index b3e4d9fe5a3108..9e23e5d776a93a 100644 --- a/web/app/components/base/date-and-time-picker/common/option-list.tsx +++ b/web/app/components/base/date-and-time-picker/common/option-list.tsx @@ -11,11 +11,7 @@ const optionListClassName = cn( 'scrollbar-none [&::-webkit-scrollbar]:hidden', ) -const OptionList = ({ - children, - className, - ...props -}: OptionListProps) => { +const OptionList = ({ children, className, ...props }: OptionListProps) => { return ( <ul className={cn(optionListClassName, className)} {...props}> {children} diff --git a/web/app/components/base/date-and-time-picker/date-picker/__tests__/footer.spec.tsx b/web/app/components/base/date-and-time-picker/date-picker/__tests__/footer.spec.tsx index b7ada71ca23998..75cecb732b8f1c 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/__tests__/footer.spec.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/__tests__/footer.spec.tsx @@ -4,7 +4,9 @@ import { ViewType } from '../../types' import Footer from '../footer' // Factory for Footer props -const createFooterProps = (overrides: Partial<DatePickerFooterProps> = {}): DatePickerFooterProps => ({ +const createFooterProps = ( + overrides: Partial<DatePickerFooterProps> = {}, +): DatePickerFooterProps => ({ needTimePicker: true, displayTime: '02:30 PM', view: ViewType.date, diff --git a/web/app/components/base/date-and-time-picker/date-picker/__tests__/header.spec.tsx b/web/app/components/base/date-and-time-picker/date-picker/__tests__/header.spec.tsx index ce30157ae6cc90..fc7e7a2e36ba93 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/__tests__/header.spec.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/__tests__/header.spec.tsx @@ -4,7 +4,9 @@ import dayjs from '../../utils/dayjs' import Header from '../header' // Factory for Header props -const createHeaderProps = (overrides: Partial<DatePickerHeaderProps> = {}): DatePickerHeaderProps => ({ +const createHeaderProps = ( + overrides: Partial<DatePickerHeaderProps> = {}, +): DatePickerHeaderProps => ({ handleOpenYearMonthPicker: vi.fn(), currentDate: dayjs('2024-06-15'), onClickNextMonth: vi.fn(), diff --git a/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx b/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx index 6163d7015180da..6b079a8c78bca8 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx @@ -5,13 +5,22 @@ import DatePicker from '../index' vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick, disabled, className }: { + Button: ({ + children, + onClick, + disabled, + className, + }: { children?: React.ReactNode onClick?: () => void disabled?: boolean className?: string }) => ( - <button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}> + <button + onClick={onClick as (() => void) | undefined} + disabled={disabled as boolean | undefined} + className={className as string | undefined} + > {children} </button> ), @@ -188,7 +197,10 @@ describe('DatePicker', () => { }) it('should render time picker options in time view', () => { - const props = createDatePickerProps({ needTimePicker: true, value: dayjs('2024-06-15T14:30:00') }) + const props = createDatePickerProps({ + needTimePicker: true, + value: dayjs('2024-06-15T14:30:00'), + }) render(<DatePicker {...props} />) openPicker() @@ -203,7 +215,10 @@ describe('DatePicker', () => { }) it('should update selected time when hour is selected in time view', () => { - const props = createDatePickerProps({ needTimePicker: true, value: dayjs('2024-06-15T14:30:00') }) + const props = createDatePickerProps({ + needTimePicker: true, + value: dayjs('2024-06-15T14:30:00'), + }) render(<DatePicker {...props} />) openPicker() @@ -222,7 +237,10 @@ describe('DatePicker', () => { }) it('should update selected time when minute is selected in time view', () => { - const props = createDatePickerProps({ needTimePicker: true, value: dayjs('2024-06-15T14:30:00') }) + const props = createDatePickerProps({ + needTimePicker: true, + value: dayjs('2024-06-15T14:30:00'), + }) render(<DatePicker {...props} />) openPicker() @@ -239,7 +257,10 @@ describe('DatePicker', () => { }) it('should update selected time when period is changed in time view', () => { - const props = createDatePickerProps({ needTimePicker: true, value: dayjs('2024-06-15T14:30:00') }) + const props = createDatePickerProps({ + needTimePicker: true, + value: dayjs('2024-06-15T14:30:00'), + }) render(<DatePicker {...props} />) openPicker() diff --git a/web/app/components/base/date-and-time-picker/date-picker/footer.tsx b/web/app/components/base/date-and-time-picker/date-picker/footer.tsx index c30efed5d4bb5a..050ba25338368b 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/footer.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/footer.tsx @@ -18,22 +18,24 @@ const Footer: FC<DatePickerFooterProps> = ({ const { t } = useTranslation() return ( - <div className={cn( - 'flex items-center justify-between border-t-[0.5px] border-divider-regular p-2', - !needTimePicker && 'justify-end', - )} + <div + className={cn( + 'flex items-center justify-between border-t-[0.5px] border-divider-regular p-2', + !needTimePicker && 'justify-end', + )} > {/* Time Picker */} {needTimePicker && ( <button type="button" - className="flex items-center gap-x-px rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 py-1 - system-xs-medium text-components-button-secondary-accent-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]" + className="flex items-center gap-x-px rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 py-1 system-xs-medium text-components-button-secondary-accent-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]" onClick={handleClickTimePicker} > <RiTimeLine className="size-3.5" /> {view === ViewType.date && <span>{displayTime}</span>} - {view === ViewType.time && <span>{t($ => $['operation.pickDate'], { ns: 'time' })}</span>} + {view === ViewType.time && ( + <span>{t(($) => $['operation.pickDate'], { ns: 'time' })}</span> + )} </button> )} <div className="flex items-center gap-x-1"> @@ -43,7 +45,7 @@ const Footer: FC<DatePickerFooterProps> = ({ className="flex items-center justify-center px-1.5 py-1 system-xs-medium text-components-button-secondary-accent-text" onClick={handleSelectCurrentDate} > - <span className="px-[3px]">{t($ => $['operation.now'], { ns: 'time' })}</span> + <span className="px-[3px]">{t(($) => $['operation.now'], { ns: 'time' })}</span> </button> {/* Confirm Button */} <Button @@ -52,7 +54,7 @@ const Footer: FC<DatePickerFooterProps> = ({ className="w-16 px-1.5 py-1" onClick={handleConfirmDate} > - {t($ => $['operation.ok'], { ns: 'time' })} + {t(($) => $['operation.ok'], { ns: 'time' })} </Button> </div> </div> diff --git a/web/app/components/base/date-and-time-picker/date-picker/index.tsx b/web/app/components/base/date-and-time-picker/date-picker/index.tsx index 37f6bf45e96928..3633cf3ba8366c 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/index.tsx @@ -41,8 +41,7 @@ const DatePicker = ({ // Normalize the value to ensure that all subsequent uses are Day.js objects. const normalizedValue = useMemo(() => { - if (!value) - return undefined + if (!value) return undefined return dayjs.isDayjs(value) ? value.tz(timezone) : dayjs(value).tz(timezone) }, [value, timezone]) @@ -68,24 +67,26 @@ const DatePicker = ({ // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value. setSelectedDate(newValue) onChange(newValue) - } - else { + } else { // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state. - setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone })) + setCurrentDate((prev) => getDateWithTimezone({ date: prev, timezone })) // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value. - setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined) + setSelectedDate((prev) => (prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)) } // eslint-disable-next-line react/exhaustive-deps -- this effect intentionally runs only when timezone changes. }, [timezone]) - const handleOpenChange = useCallback((nextOpen: boolean) => { - setIsOpen(nextOpen) - setView(ViewType.date) - if (nextOpen && normalizedValue) { - setCurrentDate(normalizedValue) - setSelectedDate(normalizedValue) - } - }, [normalizedValue]) + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setIsOpen(nextOpen) + setView(ViewType.date) + if (nextOpen && normalizedValue) { + setCurrentDate(normalizedValue) + setSelectedDate(normalizedValue) + } + }, + [normalizedValue], + ) const handleClickTrigger = (e: React.MouseEvent) => { e.preventDefault() @@ -96,8 +97,7 @@ const DatePicker = ({ const handleClear = (e: React.MouseEvent) => { e.stopPropagation() setSelectedDate(undefined) - if (!isOpen) - onClear() + if (!isOpen) onClear() } const days = useMemo(() => { @@ -112,20 +112,25 @@ const DatePicker = ({ setCurrentDate(currentDate.clone().subtract(1, 'month')) }, [currentDate]) - const handleConfirmDate = useCallback((passedInSelectedDate?: Dayjs) => { - // passedInSelectedDate may be a click event when noConfirm is false - const nextDate = (dayjs.isDayjs(passedInSelectedDate) ? passedInSelectedDate : selectedDate) - onChange(nextDate ? nextDate.tz(timezone) : undefined) - setIsOpen(false) - }, [selectedDate, onChange, timezone]) + const handleConfirmDate = useCallback( + (passedInSelectedDate?: Dayjs) => { + // passedInSelectedDate may be a click event when noConfirm is false + const nextDate = dayjs.isDayjs(passedInSelectedDate) ? passedInSelectedDate : selectedDate + onChange(nextDate ? nextDate.tz(timezone) : undefined) + setIsOpen(false) + }, + [selectedDate, onChange, timezone], + ) - const handleDateSelect = useCallback((day: Dayjs) => { - const newDate = cloneTime(day, selectedDate || getDateWithTimezone({ timezone })) - setCurrentDate(newDate) - setSelectedDate(newDate) - if (noConfirm) - handleConfirmDate(newDate) - }, [selectedDate, timezone, noConfirm, handleConfirmDate]) + const handleDateSelect = useCallback( + (day: Dayjs) => { + const newDate = cloneTime(day, selectedDate || getDateWithTimezone({ timezone })) + setCurrentDate(newDate) + setSelectedDate(newDate) + if (noConfirm) handleConfirmDate(newDate) + }, + [selectedDate, timezone, noConfirm, handleConfirmDate], + ) const handleSelectCurrentDate = () => { const newDate = getDateWithTimezone({ timezone }) @@ -140,8 +145,7 @@ const DatePicker = ({ setView(ViewType.time) return } - if (view === ViewType.time) - setView(ViewType.date) + if (view === ViewType.time) setView(ViewType.date) } const handleTimeSelect = (hour: string, minute: string, period: Period) => { @@ -151,20 +155,41 @@ const DatePicker = ({ }) } - const handleSelectHour = useCallback((hour: string) => { - const selectedTime = selectedDate || getDateWithTimezone({ timezone }) - handleTimeSelect(hour, selectedTime.minute().toString().padStart(2, '0'), selectedTime.format('A') as Period) - }, [selectedDate, timezone]) + const handleSelectHour = useCallback( + (hour: string) => { + const selectedTime = selectedDate || getDateWithTimezone({ timezone }) + handleTimeSelect( + hour, + selectedTime.minute().toString().padStart(2, '0'), + selectedTime.format('A') as Period, + ) + }, + [selectedDate, timezone], + ) - const handleSelectMinute = useCallback((minute: string) => { - const selectedTime = selectedDate || getDateWithTimezone({ timezone }) - handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), minute, selectedTime.format('A') as Period) - }, [selectedDate, timezone]) + const handleSelectMinute = useCallback( + (minute: string) => { + const selectedTime = selectedDate || getDateWithTimezone({ timezone }) + handleTimeSelect( + getHourIn12Hour(selectedTime).toString().padStart(2, '0'), + minute, + selectedTime.format('A') as Period, + ) + }, + [selectedDate, timezone], + ) - const handleSelectPeriod = useCallback((period: Period) => { - const selectedTime = selectedDate || getDateWithTimezone({ timezone }) - handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), selectedTime.minute().toString().padStart(2, '0'), period) - }, [selectedDate, timezone]) + const handleSelectPeriod = useCallback( + (period: Period) => { + const selectedTime = selectedDate || getDateWithTimezone({ timezone }) + handleTimeSelect( + getHourIn12Hour(selectedTime).toString().padStart(2, '0'), + selectedTime.minute().toString().padStart(2, '0'), + period, + ) + }, + [selectedDate, timezone], + ) const handleOpenYearMonthPicker = () => { setSelectedMonth(currentDate.month()) @@ -189,55 +214,67 @@ const DatePicker = ({ }, []) const handleYearMonthConfirm = () => { - setCurrentDate(prev => prev.clone().month(selectedMonth).year(selectedYear)) + setCurrentDate((prev) => prev.clone().month(selectedMonth).year(selectedYear)) setView(ViewType.date) } - const timeFormat = needTimePicker ? t($ => $['dateFormats.displayWithTime'], { ns: 'time' }) : t($ => $['dateFormats.display'], { ns: 'time' }) + const timeFormat = needTimePicker + ? t(($) => $['dateFormats.displayWithTime'], { ns: 'time' }) + : t(($) => $['dateFormats.display'], { ns: 'time' }) const displayValue = normalizedValue?.format(timeFormat) || '' const displayTime = selectedDate?.format('hh:mm A') || '--:-- --' - const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t($ => $.defaultPlaceholder, { ns: 'time' })) + const placeholderDate = + isOpen && selectedDate + ? selectedDate.format(timeFormat) + : placeholder || t(($) => $.defaultPlaceholder, { ns: 'time' }) return ( - <Popover - open={isOpen} - onOpenChange={handleOpenChange} - > + <Popover open={isOpen} onOpenChange={handleOpenChange}> <PopoverTrigger nativeButton={false} className={triggerWrapClassName} - render={renderTrigger - ? renderTrigger({ + render={ + renderTrigger ? ( + renderTrigger({ value: normalizedValue, selectedDate, isOpen, handleClear, handleClickTrigger, }) - : ( - <div - className="group flex w-[252px] cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt" - onClick={handleClickTrigger} - data-testid="date-picker-trigger" + ) : ( + <div + className="group flex w-[252px] cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt" + onClick={handleClickTrigger} + data-testid="date-picker-trigger" + > + <input + className="flex-1 cursor-pointer appearance-none truncate bg-transparent p-1 system-xs-regular text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder" + readOnly + value={isOpen ? '' : displayValue} + placeholder={placeholderDate} + /> + <span + className={cn( + 'i-ri-calendar-line size-4 shrink-0 text-text-quaternary', + isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', + (displayValue || (isOpen && selectedDate)) && 'group-hover:hidden', + )} + /> + <button + type="button" + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} + className={cn( + 'hidden size-4 shrink-0 border-none bg-transparent p-0 text-text-quaternary hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + (displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block', + )} + onClick={handleClear} > - <input - className="flex-1 cursor-pointer appearance-none truncate bg-transparent p-1 system-xs-regular - text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder" - readOnly - value={isOpen ? '' : displayValue} - placeholder={placeholderDate} - /> - <span className={cn('i-ri-calendar-line size-4 shrink-0 text-text-quaternary', isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', (displayValue || (isOpen && selectedDate)) && 'group-hover:hidden')} /> - <button - type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} - className={cn('hidden size-4 shrink-0 border-none bg-transparent p-0 text-text-quaternary hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', (displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block')} - onClick={handleClear} - > - <span className="i-ri-close-circle-fill size-4" aria-hidden="true" /> - </button> - </div> - )} + <span className="i-ri-close-circle-fill size-4" aria-hidden="true" /> + </button> + </div> + ) + } /> <PopoverContent placement="bottom-end" @@ -246,78 +283,64 @@ const DatePicker = ({ > <div className="mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5"> {/* Header */} - {view === ViewType.date - ? ( - <DatePickerHeader - handleOpenYearMonthPicker={handleOpenYearMonthPicker} - currentDate={currentDate} - onClickNextMonth={handleClickNextMonth} - onClickPrevMonth={handleClickPrevMonth} - /> - ) - : view === ViewType.yearMonth - ? ( - <YearAndMonthPickerHeader - selectedYear={selectedYear} - selectedMonth={selectedMonth} - onClick={handleCloseYearMonthPicker} - /> - ) - : ( - <TimePickerHeader /> - )} + {view === ViewType.date ? ( + <DatePickerHeader + handleOpenYearMonthPicker={handleOpenYearMonthPicker} + currentDate={currentDate} + onClickNextMonth={handleClickNextMonth} + onClickPrevMonth={handleClickPrevMonth} + /> + ) : view === ViewType.yearMonth ? ( + <YearAndMonthPickerHeader + selectedYear={selectedYear} + selectedMonth={selectedMonth} + onClick={handleCloseYearMonthPicker} + /> + ) : ( + <TimePickerHeader /> + )} {/* Content */} - { - view === ViewType.date - ? ( - <Calendar - days={days} - selectedDate={selectedDate} - onDateClick={handleDateSelect} - getIsDateDisabled={getIsDateDisabled} - /> - ) - : view === ViewType.yearMonth - ? ( - <YearAndMonthPickerOptions - selectedMonth={selectedMonth} - selectedYear={selectedYear} - handleMonthSelect={handleMonthSelect} - handleYearSelect={handleYearSelect} - /> - ) - : ( - <TimePickerOptions - selectedTime={selectedDate} - handleSelectHour={handleSelectHour} - handleSelectMinute={handleSelectMinute} - handleSelectPeriod={handleSelectPeriod} - /> - ) - } + {view === ViewType.date ? ( + <Calendar + days={days} + selectedDate={selectedDate} + onDateClick={handleDateSelect} + getIsDateDisabled={getIsDateDisabled} + /> + ) : view === ViewType.yearMonth ? ( + <YearAndMonthPickerOptions + selectedMonth={selectedMonth} + selectedYear={selectedYear} + handleMonthSelect={handleMonthSelect} + handleYearSelect={handleYearSelect} + /> + ) : ( + <TimePickerOptions + selectedTime={selectedDate} + handleSelectHour={handleSelectHour} + handleSelectMinute={handleSelectMinute} + handleSelectPeriod={handleSelectPeriod} + /> + )} {/* Footer */} - { - [ViewType.date, ViewType.time].includes(view) && !noConfirm && ( - <DatePickerFooter - needTimePicker={needTimePicker} - displayTime={displayTime} - view={view} - handleClickTimePicker={handleClickTimePicker} - handleSelectCurrentDate={handleSelectCurrentDate} - handleConfirmDate={handleConfirmDate} - /> - ) - } - { - ![ViewType.date, ViewType.time].includes(view) && ( - <YearAndMonthPickerFooter - handleYearMonthCancel={handleYearMonthCancel} - handleYearMonthConfirm={handleYearMonthConfirm} - /> - ) - } + {[ViewType.date, ViewType.time].includes(view) && !noConfirm && ( + <DatePickerFooter + needTimePicker={needTimePicker} + displayTime={displayTime} + view={view} + handleClickTimePicker={handleClickTimePicker} + handleSelectCurrentDate={handleSelectCurrentDate} + handleConfirmDate={handleConfirmDate} + /> + )} + {![ViewType.date, ViewType.time].includes(view) && ( + <YearAndMonthPickerFooter + handleYearMonthCancel={handleYearMonthCancel} + handleYearMonthConfirm={handleYearMonthConfirm} + /> + )} </div> </PopoverContent> </Popover> diff --git a/web/app/components/base/date-and-time-picker/hooks.ts b/web/app/components/base/date-and-time-picker/hooks.ts index adde3928662fa9..58db75cea6e7e3 100644 --- a/web/app/components/base/date-and-time-picker/hooks.ts +++ b/web/app/components/base/date-and-time-picker/hooks.ts @@ -8,7 +8,7 @@ const daysInWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const export const useDaysOfWeek = () => { const { t } = useTranslation() - return daysInWeek.map(day => t($ => $[`daysInWeek.${day}`], { ns: 'time' })) + return daysInWeek.map((day) => t(($) => $[`daysInWeek.${day}`], { ns: 'time' })) } const monthNames = [ @@ -28,7 +28,7 @@ const monthNames = [ export const useMonths = () => { const { t } = useTranslation() - return monthNames.map(month => t($ => $[`months.${month}`], { ns: 'time' })) + return monthNames.map((month) => t(($) => $[`months.${month}`], { ns: 'time' })) } export const useYearOptions = () => { diff --git a/web/app/components/base/date-and-time-picker/index.stories.tsx b/web/app/components/base/date-and-time-picker/index.stories.tsx index 430688d0e82e5b..46f020cd9720f3 100644 --- a/web/app/components/base/date-and-time-picker/index.stories.tsx +++ b/web/app/components/base/date-and-time-picker/index.stories.tsx @@ -11,7 +11,8 @@ const meta = { parameters: { docs: { description: { - component: 'Combined date and time picker with timezone support. Includes shortcuts for “now”, year-month navigation, and optional time selection.', + component: + 'Combined date and time picker with timezone support. Includes shortcuts for “now”, year-month navigation, and optional time selection.', }, }, }, @@ -41,8 +42,7 @@ const DatePickerPlayground = (props: DatePickerProps) => { onClear={() => setValue(undefined)} /> <div className="w-[252px] rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs text-text-secondary"> - Selected datetime: - {' '} + Selected datetime:{' '} <span className="font-mono text-text-primary">{value ? value.format() : 'undefined'}</span> </div> </div> @@ -50,7 +50,7 @@ const DatePickerPlayground = (props: DatePickerProps) => { } export const Playground: Story = { - render: args => <DatePickerPlayground {...args} />, + render: (args) => <DatePickerPlayground {...args} />, args: { ...meta.args, needTimePicker: false, @@ -76,12 +76,8 @@ const [value, setValue] = useState(getDateWithTimezone({})) } export const DateOnly: Story = { - render: args => ( - <DatePickerPlayground - {...args} - needTimePicker={false} - placeholder="Select due date" - /> + render: (args) => ( + <DatePickerPlayground {...args} needTimePicker={false} placeholder="Select due date" /> ), args: { ...meta.args, diff --git a/web/app/components/base/date-and-time-picker/time-picker/__tests__/footer.spec.tsx b/web/app/components/base/date-and-time-picker/time-picker/__tests__/footer.spec.tsx index d1060ffcfc95b4..46965df2364cd6 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/__tests__/footer.spec.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/__tests__/footer.spec.tsx @@ -3,7 +3,9 @@ import { fireEvent, render, screen } from '@testing-library/react' import Footer from '../footer' // Factory for TimePickerFooter props -const createFooterProps = (overrides: Partial<TimePickerFooterProps> = {}): TimePickerFooterProps => ({ +const createFooterProps = ( + overrides: Partial<TimePickerFooterProps> = {}, +): TimePickerFooterProps => ({ handleSelectCurrentTime: vi.fn(), handleConfirm: vi.fn(), ...overrides, diff --git a/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx b/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx index 0d02b3b5d5859f..3dc988be700cfb 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx @@ -5,13 +5,22 @@ import TimePicker from '../index' vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick, disabled, className }: { + Button: ({ + children, + onClick, + disabled, + className, + }: { children?: React.ReactNode onClick?: () => void disabled?: boolean className?: string }) => ( - <button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}> + <button + onClick={onClick as (() => void) | undefined} + disabled={disabled as boolean | undefined} + className={className as string | undefined} + > {children} </button> ), @@ -34,25 +43,13 @@ describe('TimePicker', () => { }) it('renders formatted value for string input (Issue #26692 regression)', () => { - render( - <TimePicker - {...baseProps} - value="18:45" - timezone="UTC" - />, - ) + render(<TimePicker {...baseProps} value="18:45" timezone="UTC" />) expect(screen.getByDisplayValue('06:45 PM'))!.toBeInTheDocument() }) it('confirms cleared value when confirming without selection', () => { - render( - <TimePicker - {...baseProps} - value={dayjs('2024-01-01T03:30:00Z')} - timezone="UTC" - />, - ) + render(<TimePicker {...baseProps} value={dayjs('2024-01-01T03:30:00Z')} timezone="UTC" />) const input = screen.getByRole('textbox') fireEvent.click(input) @@ -70,13 +67,7 @@ describe('TimePicker', () => { it('selecting current time emits timezone-aware value', () => { const onChange = vi.fn() - render( - <TimePicker - {...baseProps} - onChange={onChange} - timezone="America/New_York" - />, - ) + render(<TimePicker {...baseProps} onChange={onChange} timezone="America/New_York" />) // Open the picker first to access content fireEvent.click(screen.getByRole('textbox')) @@ -125,14 +116,7 @@ describe('TimePicker', () => { it('should call onClear when clear is clicked while picker is closed', () => { const onClear = vi.fn() - render( - <TimePicker - {...baseProps} - onClear={onClear} - value="10:00 AM" - timezone="UTC" - />, - ) + render(<TimePicker {...baseProps} onClear={onClear} value="10:00 AM" timezone="UTC" />) const clearButton = screen.getByRole('button', { name: /operation\.clear/i }) fireEvent.click(clearButton) @@ -142,14 +126,7 @@ describe('TimePicker', () => { it('should not call onClear when clear is clicked while picker is open', () => { const onClear = vi.fn() - render( - <TimePicker - {...baseProps} - onClear={onClear} - value="10:00 AM" - timezone="UTC" - />, - ) + render(<TimePicker {...baseProps} onClear={onClear} value="10:00 AM" timezone="UTC" />) // Open picker first fireEvent.click(screen.getByRole('textbox')) @@ -162,14 +139,7 @@ describe('TimePicker', () => { it('should sync selectedTime from value when opening with stale state', () => { const onChange = vi.fn() - render( - <TimePicker - {...baseProps} - onChange={onChange} - value="10:00 AM" - timezone="UTC" - />, - ) + render(<TimePicker {...baseProps} onChange={onChange} value="10:00 AM" timezone="UTC" />) const input = screen.getByRole('textbox') // Open - this triggers handleClickTrigger which syncs selectedTime from value @@ -227,24 +197,14 @@ describe('TimePicker', () => { // Props tests describe('Props', () => { it('should show custom placeholder when provided', () => { - render( - <TimePicker - {...baseProps} - placeholder="Select time" - />, - ) + render(<TimePicker {...baseProps} placeholder="Select time" />) const input = screen.getByRole('textbox') expect(input)!.toHaveAttribute('placeholder', 'Select time') }) it('should render with triggerFullWidth prop without errors', () => { - render( - <TimePicker - {...baseProps} - triggerFullWidth={true} - />, - ) + render(<TimePicker {...baseProps} triggerFullWidth={true} />) // Verify the component renders successfully with triggerFullWidth // Verify the component renders successfully with triggerFullWidth @@ -258,26 +218,14 @@ describe('TimePicker', () => { </div> )) - render( - <TimePicker - {...baseProps} - renderTrigger={renderTrigger} - />, - ) + render(<TimePicker {...baseProps} renderTrigger={renderTrigger} />) expect(screen.getByTestId('custom-trigger'))!.toBeInTheDocument() expect(renderTrigger).toHaveBeenCalled() }) it('should render with notClearable prop without errors', () => { - render( - <TimePicker - {...baseProps} - notClearable={true} - value="10:00 AM" - timezone="UTC" - />, - ) + render(<TimePicker {...baseProps} notClearable={true} value="10:00 AM" timezone="UTC" />) // In test env the icon stays in DOM, but must remain hidden when notClearable is set // In test env the icon stays in DOM, but must remain hidden when notClearable is set @@ -320,13 +268,15 @@ describe('TimePicker', () => { const getHourAndMinuteLists = () => { const allLists = screen.getAllByRole('list') - const hourList = allLists.find(list => - within(list).queryByText('01') - && within(list).queryByText('12') - && !within(list).queryByText('59')) - const minuteList = allLists.find(list => - within(list).queryByText('00') - && within(list).queryByText('59')) + const hourList = allLists.find( + (list) => + within(list).queryByText('01') && + within(list).queryByText('12') && + !within(list).queryByText('59'), + ) + const minuteList = allLists.find( + (list) => within(list).queryByText('00') && within(list).queryByText('59'), + ) expect(hourList).toBeTruthy() expect(minuteList).toBeTruthy() @@ -419,13 +369,7 @@ describe('TimePicker', () => { it('should create new time when selecting hour without prior selectedTime', () => { const onChange = vi.fn() - render( - <TimePicker - {...baseProps} - onChange={onChange} - timezone="UTC" - />, - ) + render(<TimePicker {...baseProps} onChange={onChange} timezone="UTC" />) openPicker() @@ -445,13 +389,7 @@ describe('TimePicker', () => { it('should handle minute selection without prior selectedTime', () => { const onChange = vi.fn() - render( - <TimePicker - {...baseProps} - onChange={onChange} - timezone="UTC" - />, - ) + render(<TimePicker {...baseProps} onChange={onChange} timezone="UTC" />) openPicker() @@ -470,13 +408,7 @@ describe('TimePicker', () => { it('should handle period selection without prior selectedTime', () => { const onChange = vi.fn() - render( - <TimePicker - {...baseProps} - onChange={onChange} - timezone="UTC" - />, - ) + render(<TimePicker {...baseProps} onChange={onChange} timezone="UTC" />) openPicker() @@ -520,22 +452,10 @@ describe('TimePicker', () => { const onChangeB = vi.fn() const { rerender } = render( - <TimePicker - {...baseProps} - onChange={onChangeA} - value={value} - timezone="UTC" - />, + <TimePicker {...baseProps} onChange={onChangeA} value={value} timezone="UTC" />, ) - rerender( - <TimePicker - {...baseProps} - onChange={onChangeB} - value={value} - timezone="UTC" - />, - ) + rerender(<TimePicker {...baseProps} onChange={onChangeB} value={value} timezone="UTC" />) expect(onChangeA).not.toHaveBeenCalled() expect(onChangeB).not.toHaveBeenCalled() @@ -555,12 +475,7 @@ describe('TimePicker', () => { ) rerender( - <TimePicker - {...baseProps} - onChange={onChange} - value={invalidValue} - timezone="UTC" - />, + <TimePicker {...baseProps} onChange={onChange} value={invalidValue} timezone="UTC" />, ) expect(onChange).not.toHaveBeenCalled() @@ -571,22 +486,12 @@ describe('TimePicker', () => { const onChange = vi.fn() const value = dayjs('2024-01-01T10:30:00Z') const { rerender } = render( - <TimePicker - {...baseProps} - onChange={onChange} - value={value} - timezone="UTC" - />, + <TimePicker {...baseProps} onChange={onChange} value={value} timezone="UTC" />, ) // Change timezone without changing value (same reference) rerender( - <TimePicker - {...baseProps} - onChange={onChange} - value={value} - timezone="America/New_York" - />, + <TimePicker {...baseProps} onChange={onChange} value={value} timezone="America/New_York" />, ) expect(onChange).toHaveBeenCalledTimes(1) @@ -629,22 +534,10 @@ describe('TimePicker', () => { it('should handle timezone change when value is undefined', () => { const onChange = vi.fn() - const { rerender } = render( - <TimePicker - {...baseProps} - onChange={onChange} - timezone="UTC" - />, - ) + const { rerender } = render(<TimePicker {...baseProps} onChange={onChange} timezone="UTC" />) // Change timezone without a value - rerender( - <TimePicker - {...baseProps} - onChange={onChange} - timezone="America/New_York" - />, - ) + rerender(<TimePicker {...baseProps} onChange={onChange} timezone="America/New_York" />) // onChange should not be called when value is undefined expect(onChange).not.toHaveBeenCalled() @@ -688,12 +581,7 @@ describe('TimePicker', () => { ) rerender( - <TimePicker - {...baseProps} - onChange={onChange} - value={undefined} - timezone={undefined} - />, + <TimePicker {...baseProps} onChange={onChange} value={undefined} timezone={undefined} />, ) fireEvent.click(screen.getByRole('textbox')) @@ -710,23 +598,11 @@ describe('TimePicker', () => { const onChange = vi.fn() const value = dayjs('2024-01-01T10:30:00Z') const { rerender } = render( - <TimePicker - {...baseProps} - onChange={onChange} - value={value} - timezone="UTC" - />, + <TimePicker {...baseProps} onChange={onChange} value={value} timezone="UTC" />, ) // Rerender with same props - rerender( - <TimePicker - {...baseProps} - onChange={onChange} - value={value} - timezone="UTC" - />, - ) + rerender(<TimePicker {...baseProps} onChange={onChange} value={value} timezone="UTC" />) expect(onChange).not.toHaveBeenCalled() }) @@ -770,38 +646,20 @@ describe('TimePicker', () => { }) it('should format dayjs value correctly', () => { - render( - <TimePicker - {...baseProps} - value={dayjs('2024-01-01T14:30:00Z')} - timezone="UTC" - />, - ) + render(<TimePicker {...baseProps} value={dayjs('2024-01-01T14:30:00Z')} timezone="UTC" />) expect(screen.getByDisplayValue('02:30 PM'))!.toBeInTheDocument() }) it('should format string value correctly', () => { - render( - <TimePicker - {...baseProps} - value="09:15" - timezone="UTC" - />, - ) + render(<TimePicker {...baseProps} value="09:15" timezone="UTC" />) expect(screen.getByDisplayValue('09:15 AM'))!.toBeInTheDocument() }) it('should return empty display value for an unparsable truthy string', () => { const invalidValue = 123 as unknown as TimePickerProps['value'] - render( - <TimePicker - {...baseProps} - value={invalidValue} - timezone="UTC" - />, - ) + render(<TimePicker {...baseProps} value={invalidValue} timezone="UTC" />) expect(screen.getByRole('textbox'))!.toHaveValue('') }) @@ -809,13 +667,7 @@ describe('TimePicker', () => { describe('Timezone Label Integration', () => { it('should not display timezone label by default', () => { - render( - <TimePicker - {...baseProps} - value="12:00 AM" - timezone="Asia/Shanghai" - />, - ) + render(<TimePicker {...baseProps} value="12:00 AM" timezone="Asia/Shanghai" />) expect(screen.queryByTitle(/Timezone: Asia\/Shanghai/)).not.toBeInTheDocument() }) @@ -835,12 +687,7 @@ describe('TimePicker', () => { it('should display timezone label when showTimezone is true', () => { render( - <TimePicker - {...baseProps} - value="12:00 AM" - timezone="Asia/Shanghai" - showTimezone={true} - />, + <TimePicker {...baseProps} value="12:00 AM" timezone="Asia/Shanghai" showTimezone={true} />, ) const timezoneLabel = screen.getByTitle(/Timezone: Asia\/Shanghai/) @@ -849,13 +696,7 @@ describe('TimePicker', () => { }) it('should not display timezone label when showTimezone is true but timezone is not provided', () => { - render( - <TimePicker - {...baseProps} - value="12:00 AM" - showTimezone={true} - />, - ) + render(<TimePicker {...baseProps} value="12:00 AM" showTimezone={true} />) expect(screen.queryByTitle(/Timezone:/)).not.toBeInTheDocument() }) diff --git a/web/app/components/base/date-and-time-picker/time-picker/__tests__/options.spec.tsx b/web/app/components/base/date-and-time-picker/time-picker/__tests__/options.spec.tsx index 1bf3a52a8b4d0b..7127a8a0eafc26 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/__tests__/options.spec.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/__tests__/options.spec.tsx @@ -50,7 +50,7 @@ describe('TimePickerOptions', () => { describe('Minute Filter', () => { it('should apply minuteFilter when provided', () => { - const minuteFilter = (minutes: string[]) => minutes.filter(m => Number(m) % 15 === 0) + const minuteFilter = (minutes: string[]) => minutes.filter((m) => Number(m) % 15 === 0) const props = createOptionsProps({ minuteFilter }) render(<Options {...props} />) @@ -64,13 +64,13 @@ describe('TimePickerOptions', () => { it('should render selected hour in the list', () => { const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') }) render(<Options {...props} />) - const selectedHour = screen.getAllByRole('button').find(item => item.textContent === '05') + const selectedHour = screen.getAllByRole('button').find((item) => item.textContent === '05') expect(selectedHour)!.toHaveClass('bg-components-button-ghost-bg-hover') }) it('should render selected minute in the list', () => { const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') }) render(<Options {...props} />) - const selectedMinute = screen.getAllByRole('button').find(item => item.textContent === '30') + const selectedMinute = screen.getAllByRole('button').find((item) => item.textContent === '30') expect(selectedMinute)!.toHaveClass('bg-components-button-ghost-bg-hover') }) diff --git a/web/app/components/base/date-and-time-picker/time-picker/footer.tsx b/web/app/components/base/date-and-time-picker/time-picker/footer.tsx index 88b0d7a9b1536d..fd2b7b7a4a8c02 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/footer.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/footer.tsx @@ -4,10 +4,7 @@ import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' -const Footer: FC<TimePickerFooterProps> = ({ - handleSelectCurrentTime, - handleConfirm, -}) => { +const Footer: FC<TimePickerFooterProps> = ({ handleSelectCurrentTime, handleConfirm }) => { const { t } = useTranslation() return ( @@ -19,7 +16,7 @@ const Footer: FC<TimePickerFooterProps> = ({ className="mr-1 flex-1" onClick={handleSelectCurrentTime} > - {t($ => $['operation.now'], { ns: 'time' })} + {t(($) => $['operation.now'], { ns: 'time' })} </Button> {/* Confirm Button */} <Button @@ -28,7 +25,7 @@ const Footer: FC<TimePickerFooterProps> = ({ className="ml-1 flex-1" onClick={handleConfirm.bind(null)} > - {t($ => $['operation.ok'], { ns: 'time' })} + {t(($) => $['operation.ok'], { ns: 'time' })} </Button> </div> ) diff --git a/web/app/components/base/date-and-time-picker/time-picker/header.tsx b/web/app/components/base/date-and-time-picker/time-picker/header.tsx index 59043612b48200..defd3aa699a23e 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/header.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/header.tsx @@ -4,15 +4,13 @@ import { useTranslation } from 'react-i18next' type Props = Readonly<{ title?: string }> -const Header = ({ - title, -}: Props) => { +const Header = ({ title }: Props) => { const { t } = useTranslation() return ( <div className="flex flex-col border-b-[0.5px] border-divider-regular"> <div className="flex items-center px-2 py-1.5 system-md-semibold text-text-primary"> - {title || t($ => $['title.pickTime'], { ns: 'time' })} + {title || t(($) => $['title.pickTime'], { ns: 'time' })} </div> </div> ) diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.tsx index 0e8214f7381cfa..a07d16e6363b53 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/index.tsx @@ -7,12 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import TimezoneLabel from '@/app/components/base/timezone-label' import { Period } from '../types' -import dayjs, { - getDateWithTimezone, - getHourIn12Hour, - isDayjsObject, - toDayjs, -} from '../utils/dayjs' +import dayjs, { getDateWithTimezone, getHourIn12Hour, isDayjsObject, toDayjs } from '../utils/dayjs' import Footer from './footer' import Header from './header' import Options from './options' @@ -68,49 +63,43 @@ const TimePicker = ({ prevTimezoneRef.current = timezone // Skip if neither timezone changed nor value changed - if (!timezoneChanged && !valueChanged) - return + if (!timezoneChanged && !valueChanged) return if (value !== undefined && value !== null) { const dayjsValue = toDayjs(value, { timezone }) - if (!dayjsValue) - return + if (!dayjsValue) return // eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time. setSelectedTime(dayjsValue) - if (timezoneChanged && !valueChanged) - onChange(dayjsValue) + if (timezoneChanged && !valueChanged) onChange(dayjsValue) return } // eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time. setSelectedTime((prev) => { - if (!isDayjsObject(prev)) - return undefined + if (!isDayjsObject(prev)) return undefined return timezone ? getDateWithTimezone({ date: prev, timezone }) : prev }) }, [timezone, value, onChange]) const syncSelectedTimeFromValue = useCallback(() => { - if (!value) - return + if (!value) return const dayjsValue = toDayjs(value, { timezone }) - const needsUpdate = dayjsValue && ( - !selectedTime - || !isDayjsObject(selectedTime) - || !dayjsValue.isSame(selectedTime, 'minute') - ) - if (needsUpdate) - setSelectedTime(dayjsValue) + const needsUpdate = + dayjsValue && + (!selectedTime || !isDayjsObject(selectedTime) || !dayjsValue.isSame(selectedTime, 'minute')) + if (needsUpdate) setSelectedTime(dayjsValue) }, [selectedTime, timezone, value]) - const handleOpenChange = useCallback((nextOpen: boolean) => { - setIsOpen(nextOpen) - if (nextOpen) - syncSelectedTimeFromValue() - }, [syncSelectedTimeFromValue]) + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setIsOpen(nextOpen) + if (nextOpen) syncSelectedTimeFromValue() + }, + [syncSelectedTimeFromValue], + ) const handleClickTrigger = (e: React.MouseEvent) => { e.preventDefault() @@ -121,45 +110,63 @@ const TimePicker = ({ const handleClear = (e: React.MouseEvent) => { e.stopPropagation() setSelectedTime(undefined) - if (!isOpen) - onClear() + if (!isOpen) onClear() } - const handleTimeSelect = useCallback((hour: string, minute: string, period: Period) => { - const periodAdjustedHour = to24Hour(hour, period) - const nextMinute = Number.parseInt(minute, 10) - setSelectedTime((prev) => { - const reference = isDayjsObject(prev) - ? prev - : (timezone ? getDateWithTimezone({ timezone }) : dayjs()).startOf('minute') - return reference - .set('hour', periodAdjustedHour) - .set('minute', nextMinute) - .set('second', 0) - .set('millisecond', 0) - }) - }, [timezone]) + const handleTimeSelect = useCallback( + (hour: string, minute: string, period: Period) => { + const periodAdjustedHour = to24Hour(hour, period) + const nextMinute = Number.parseInt(minute, 10) + setSelectedTime((prev) => { + const reference = isDayjsObject(prev) + ? prev + : (timezone ? getDateWithTimezone({ timezone }) : dayjs()).startOf('minute') + return reference + .set('hour', periodAdjustedHour) + .set('minute', nextMinute) + .set('second', 0) + .set('millisecond', 0) + }) + }, + [timezone], + ) const getSafeTimeObject = useCallback(() => { - if (isDayjsObject(selectedTime)) - return selectedTime + if (isDayjsObject(selectedTime)) return selectedTime return (timezone ? getDateWithTimezone({ timezone }) : dayjs()).startOf('day') }, [selectedTime, timezone]) - const handleSelectHour = useCallback((hour: string) => { - const time = getSafeTimeObject() - handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period) - }, [getSafeTimeObject, handleTimeSelect]) + const handleSelectHour = useCallback( + (hour: string) => { + const time = getSafeTimeObject() + handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period) + }, + [getSafeTimeObject, handleTimeSelect], + ) - const handleSelectMinute = useCallback((minute: string) => { - const time = getSafeTimeObject() - handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period) - }, [getSafeTimeObject, handleTimeSelect]) + const handleSelectMinute = useCallback( + (minute: string) => { + const time = getSafeTimeObject() + handleTimeSelect( + getHourIn12Hour(time).toString().padStart(2, '0'), + minute, + time.format('A') as Period, + ) + }, + [getSafeTimeObject, handleTimeSelect], + ) - const handleSelectPeriod = useCallback((period: Period) => { - const time = getSafeTimeObject() - handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period) - }, [getSafeTimeObject, handleTimeSelect]) + const handleSelectPeriod = useCallback( + (period: Period) => { + const time = getSafeTimeObject() + handleTimeSelect( + getHourIn12Hour(time).toString().padStart(2, '0'), + time.minute().toString().padStart(2, '0'), + period, + ) + }, + [getSafeTimeObject, handleTimeSelect], + ) const handleSelectCurrentTime = useCallback(() => { const newDate = getDateWithTimezone({ timezone }) @@ -176,67 +183,85 @@ const TimePicker = ({ const timeFormat = 'hh:mm A' - const formatTimeValue = useCallback((timeValue: string | Dayjs | undefined): string => { - if (!timeValue) - return '' + const formatTimeValue = useCallback( + (timeValue: string | Dayjs | undefined): string => { + if (!timeValue) return '' - const dayjsValue = toDayjs(timeValue, { timezone }) - return dayjsValue?.format(timeFormat) || '' - }, [timezone]) + const dayjsValue = toDayjs(timeValue, { timezone }) + return dayjsValue?.format(timeFormat) || '' + }, + [timezone], + ) const displayValue = formatTimeValue(value) - const placeholderDate = isOpen && isDayjsObject(selectedTime) - ? selectedTime.format(timeFormat) - : (placeholder || t($ => $.defaultPlaceholder, { ns: 'time' })) + const placeholderDate = + isOpen && isDayjsObject(selectedTime) + ? selectedTime.format(timeFormat) + : placeholder || t(($) => $.defaultPlaceholder, { ns: 'time' }) const inputElem = ( <input - className="flex-1 cursor-pointer appearance-none truncate bg-transparent p-1 system-xs-regular text-components-input-text-filled - outline-hidden select-none placeholder:text-components-input-text-placeholder" + className="flex-1 cursor-pointer appearance-none truncate bg-transparent p-1 system-xs-regular text-components-input-text-filled outline-hidden select-none placeholder:text-components-input-text-placeholder" readOnly value={isOpen ? '' : displayValue} placeholder={placeholderDate} /> ) return ( - <Popover - open={isOpen} - onOpenChange={handleOpenChange} - > + <Popover open={isOpen} onOpenChange={handleOpenChange}> <PopoverTrigger nativeButton={false} className={triggerFullWidth ? 'flex! w-full' : undefined} - render={renderTrigger - ? renderTrigger({ + render={ + renderTrigger ? ( + renderTrigger({ inputElem, onClick: handleClickTrigger, isOpen, }) - : ( - <div + ) : ( + <div + className={cn( + 'group flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', + triggerFullWidth ? 'w-full min-w-0' : 'w-[252px]', + )} + onClick={handleClickTrigger} + data-testid="time-picker-trigger" + > + {inputElem} + {showTimezone && timezone && ( + <TimezoneLabel + timezone={timezone} + inline + className="shrink-0 text-xs select-none" + /> + )} + <span className={cn( - 'group flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', - triggerFullWidth ? 'w-full min-w-0' : 'w-[252px]', + 'i-ri-time-line size-4 shrink-0 text-text-quaternary', + isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', + (displayValue || (isOpen && selectedTime)) && + !notClearable && + 'group-hover:hidden', )} - onClick={handleClickTrigger} - data-testid="time-picker-trigger" - > - {inputElem} - {showTimezone && timezone && ( - <TimezoneLabel timezone={timezone} inline className="shrink-0 text-xs select-none" /> + /> + <button + type="button" + className={cn( + 'hidden size-4 shrink-0 border-none bg-transparent p-0 text-text-quaternary hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + (displayValue || (isOpen && selectedTime)) && + !notClearable && + 'group-hover:inline-block', )} - <span className={cn('i-ri-time-line size-4 shrink-0 text-text-quaternary', isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', (displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:hidden')} /> - <button - type="button" - className={cn('hidden size-4 shrink-0 border-none bg-transparent p-0 text-text-quaternary hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', (displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:inline-block')} - aria-label={t($ => $['operation.clear'], { ns: 'common' })} - onClick={handleClear} - > - <span className="i-ri-close-circle-fill size-4" aria-hidden="true" /> - </button> - </div> - )} + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} + onClick={handleClear} + > + <span className="i-ri-close-circle-fill size-4" aria-hidden="true" /> + </button> + </div> + ) + } /> <PopoverContent placement={placement} @@ -258,11 +283,7 @@ const TimePicker = ({ /> {/* Footer */} - <Footer - handleSelectCurrentTime={handleSelectCurrentTime} - handleConfirm={handleConfirm} - /> - + <Footer handleSelectCurrentTime={handleSelectCurrentTime} handleConfirm={handleConfirm} /> </div> </PopoverContent> </Popover> diff --git a/web/app/components/base/date-and-time-picker/time-picker/options.tsx b/web/app/components/base/date-and-time-picker/time-picker/options.tsx index 10e94f983d64eb..c89fdb803cc4b5 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/options.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/options.tsx @@ -18,55 +18,49 @@ const Options: FC<TimeOptionsProps> = ({ <div className="grid grid-cols-3 gap-x-1 p-2"> {/* Hour */} <OptionList> - { - hourOptions.map((hour) => { - const isSelected = selectedTime?.format('hh') === hour - return ( - <OptionListItem - key={hour} - isSelected={isSelected} - onClick={handleSelectHour.bind(null, hour)} - > - {hour} - </OptionListItem> - ) - }) - } + {hourOptions.map((hour) => { + const isSelected = selectedTime?.format('hh') === hour + return ( + <OptionListItem + key={hour} + isSelected={isSelected} + onClick={handleSelectHour.bind(null, hour)} + > + {hour} + </OptionListItem> + ) + })} </OptionList> {/* Minute */} <OptionList> - { - (minuteFilter ? minuteFilter(minuteOptions) : minuteOptions).map((minute) => { - const isSelected = selectedTime?.format('mm') === minute - return ( - <OptionListItem - key={minute} - isSelected={isSelected} - onClick={handleSelectMinute.bind(null, minute)} - > - {minute} - </OptionListItem> - ) - }) - } + {(minuteFilter ? minuteFilter(minuteOptions) : minuteOptions).map((minute) => { + const isSelected = selectedTime?.format('mm') === minute + return ( + <OptionListItem + key={minute} + isSelected={isSelected} + onClick={handleSelectMinute.bind(null, minute)} + > + {minute} + </OptionListItem> + ) + })} </OptionList> {/* Period */} <OptionList> - { - periodOptions.map((period) => { - const isSelected = selectedTime?.format('A') === period - return ( - <OptionListItem - key={period} - isSelected={isSelected} - onClick={handleSelectPeriod.bind(null, period)} - noAutoScroll // if choose PM which would hide(scrolled) AM that may make user confused that there's no am. - > - {period} - </OptionListItem> - ) - }) - } + {periodOptions.map((period) => { + const isSelected = selectedTime?.format('A') === period + return ( + <OptionListItem + key={period} + isSelected={isSelected} + onClick={handleSelectPeriod.bind(null, period)} + noAutoScroll // if choose PM which would hide(scrolled) AM that may make user confused that there's no am. + > + {period} + </OptionListItem> + ) + })} </OptionList> </div> ) diff --git a/web/app/components/base/date-and-time-picker/utils/__tests__/dayjs-extended.spec.ts b/web/app/components/base/date-and-time-picker/utils/__tests__/dayjs-extended.spec.ts index aa32609a76f8b2..230c246cdb8f27 100644 --- a/web/app/components/base/date-and-time-picker/utils/__tests__/dayjs-extended.spec.ts +++ b/web/app/components/base/date-and-time-picker/utils/__tests__/dayjs-extended.spec.ts @@ -55,8 +55,8 @@ describe('dayjs extended utilities', () => { const date = dayjs('2024-06-15') // June 2024 starts on Saturday const days = getDaysInMonth(date) - const prevMonthDays = days.filter(d => !d.isCurrentMonth && d.date.month() < date.month()) - const nextMonthDays = days.filter(d => !d.isCurrentMonth && d.date.month() > date.month()) + const prevMonthDays = days.filter((d) => !d.isCurrentMonth && d.date.month() < date.month()) + const nextMonthDays = days.filter((d) => !d.isCurrentMonth && d.date.month() > date.month()) // June 2024 starts on Saturday (6), so there are 6 days from previous month expect(prevMonthDays.length).toBeGreaterThan(0) @@ -67,7 +67,7 @@ describe('dayjs extended utilities', () => { const date = dayjs('2024-06-15') const days = getDaysInMonth(date) - const currentMonthDays = days.filter(d => d.isCurrentMonth) + const currentMonthDays = days.filter((d) => d.isCurrentMonth) // June has 30 days expect(currentMonthDays).toHaveLength(30) }) diff --git a/web/app/components/base/date-and-time-picker/utils/__tests__/dayjs.spec.ts b/web/app/components/base/date-and-time-picker/utils/__tests__/dayjs.spec.ts index 3582a84c144588..02bd91b5243cf8 100644 --- a/web/app/components/base/date-and-time-picker/utils/__tests__/dayjs.spec.ts +++ b/web/app/components/base/date-and-time-picker/utils/__tests__/dayjs.spec.ts @@ -36,8 +36,8 @@ describe('getDaysInMonth', () => { const date = dayjs('2024-01-01') const days = getDaysInMonth(date) expect(days.length).toBeGreaterThanOrEqual(28) - expect(days.some(d => d.isCurrentMonth)).toBe(true) - expect(days.some(d => !d.isCurrentMonth)).toBe(true) + expect(days.some((d) => d.isCurrentMonth)).toBe(true) + expect(days.some((d) => !d.isCurrentMonth)).toBe(true) }) it('returns cached result on second call', () => { diff --git a/web/app/components/base/date-and-time-picker/utils/dayjs.ts b/web/app/components/base/date-and-time-picker/utils/dayjs.ts index 36f1266782d03b..16df8289da69e8 100644 --- a/web/app/components/base/date-and-time-picker/utils/dayjs.ts +++ b/web/app/components/base/date-and-time-picker/utils/dayjs.ts @@ -33,16 +33,13 @@ const COMMON_PARSE_FORMATS = [ ] export const cloneTime = (targetDate: Dayjs, sourceDate: Dayjs) => { - return targetDate.clone() - .set('hour', sourceDate.hour()) - .set('minute', sourceDate.minute()) + return targetDate.clone().set('hour', sourceDate.hour()).set('minute', sourceDate.minute()) } export const getDaysInMonth = (currentDate: Dayjs) => { const key = currentDate.format('YYYY-MM') // return the cached days - if (monthMaps[key]) - return monthMaps[key] + if (monthMaps[key]) return monthMaps[key] const daysInCurrentMonth = currentDate.daysInMonth() const firstDay = currentDate.startOf('month').day() @@ -72,9 +69,14 @@ export const getDaysInMonth = (currentDate: Dayjs) => { } // Add cells for days after the last day of the month - const totalLinesOfCurrentMonth = Math.ceil((daysInCurrentMonth - ((daysInOneWeek - firstDay) + lastDay + 1)) / 7) + 2 + const totalLinesOfCurrentMonth = + Math.ceil((daysInCurrentMonth - (daysInOneWeek - firstDay + lastDay + 1)) / 7) + 2 const needAdditionalLine = totalLinesOfCurrentMonth < totalLines - for (let i = 0; lastDay + i < (needAdditionalLine ? 2 * daysInOneWeek - 1 : daysInOneWeek - 1); i++) { + for ( + let i = 0; + lastDay + i < (needAdditionalLine ? 2 * daysInOneWeek - 1 : daysInOneWeek - 1); + i++ + ) { const date = cloneTime(firstDayInNextMonth.add(i, 'day'), currentDate) days.push({ date, @@ -88,8 +90,7 @@ export const getDaysInMonth = (currentDate: Dayjs) => { } export const clearMonthMapCache = () => { - for (const key in monthMaps) - delete monthMaps[key] + for (const key in monthMaps) delete monthMaps[key] } export const getHourIn12Hour = (date: Dayjs) => { @@ -97,24 +98,20 @@ export const getHourIn12Hour = (date: Dayjs) => { return hour === 0 ? 12 : hour >= 12 ? hour - 12 : hour } -export const getDateWithTimezone = ({ date, timezone }: { date?: Dayjs, timezone?: string }) => { - if (!timezone) - return (date ?? dayjs()).clone() +export const getDateWithTimezone = ({ date, timezone }: { date?: Dayjs; timezone?: string }) => { + if (!timezone) return (date ?? dayjs()).clone() return date ? dayjs.tz(date, timezone) : dayjs().tz(timezone) } export const convertTimezoneToOffsetStr = (timezone?: string) => { - if (!timezone) - return DEFAULT_OFFSET_STR - const tzItem = tz.find(item => item.value === timezone) - if (!tzItem) - return DEFAULT_OFFSET_STR + if (!timezone) return DEFAULT_OFFSET_STR + const tzItem = tz.find((item) => item.value === timezone) + if (!tzItem) return DEFAULT_OFFSET_STR // Extract offset from name format like "-11:00 Niue Time" or "+05:30 India Time" // Name format is always "{offset}:{minutes} {timezone name}" const offsetMatch = /^([+-]?\d{1,2}):(\d{2})/.exec(tzItem.name) /* v8 ignore next 2 -- timezone.json entries are normalized to "{offset} {name}"; this protects against malformed data only. */ - if (!offsetMatch) - return DEFAULT_OFFSET_STR + if (!offsetMatch) return DEFAULT_OFFSET_STR // Parse hours and minutes separately const hours = Number.parseInt(offsetMatch[1]!, 10) const minutes = Number.parseInt(offsetMatch[2]!, 10) @@ -133,18 +130,14 @@ type ToDayjsOptions = { } const warnParseFailure = (value: string) => { - if (!IS_PROD) - console.warn('[TimePicker] Failed to parse time value', value) + if (!IS_PROD) console.warn('[TimePicker] Failed to parse time value', value) } const normalizeMillisecond = (value: string | undefined) => { - if (!value) - return 0 - if (value.length === 3) - return Number(value) + if (!value) return 0 + if (value.length === 3) return Number(value) /* v8 ignore next 2 -- TIME_ONLY_REGEX allows at most 3 fractional digits, so >3 can only occur after future regex changes. */ - if (value.length > 3) - return Number(value.slice(0, 3)) + if (value.length > 3) return Number(value.slice(0, 3)) return Number(value.padEnd(3, '0')) } @@ -152,17 +145,17 @@ const applyTimezone = (date: Dayjs, timezone?: string) => { return timezone ? getDateWithTimezone({ date, timezone }) : date } -export const toDayjs = (value: string | Dayjs | undefined, options: ToDayjsOptions = {}): Dayjs | undefined => { - if (!value) - return undefined +export const toDayjs = ( + value: string | Dayjs | undefined, + options: ToDayjsOptions = {}, +): Dayjs | undefined => { + if (!value) return undefined const { timezone: tzName, format, formats } = options - if (isDayjsObject(value)) - return applyTimezone(value, tzName) + if (isDayjsObject(value)) return applyTimezone(value, tzName) - if (typeof value !== 'string') - return undefined + if (typeof value !== 'string') return undefined const trimmed = value.trim() @@ -170,8 +163,7 @@ export const toDayjs = (value: string | Dayjs | undefined, options: ToDayjsOptio const parsedWithFormat = tzName ? dayjs(trimmed, format, true).tz(tzName, true) : dayjs(trimmed, format, true) - if (parsedWithFormat.isValid()) - return parsedWithFormat + if (parsedWithFormat.isValid()) return parsedWithFormat } const timeMatch = TIME_ONLY_REGEX.exec(trimmed) @@ -194,44 +186,37 @@ export const toDayjs = (value: string | Dayjs | undefined, options: ToDayjsOptio const base = applyTimezone(dayjs(), tzName).startOf('day') let hour = Number(timeMatch12h[1]) % 12 const isPM = timeMatch12h[4]?.toUpperCase() === 'PM' - if (isPM) - hour += 12 + if (isPM) hour += 12 const minute = Number(timeMatch12h[2]) const second = timeMatch12h[3] ? Number(timeMatch12h[3]) : 0 - return base - .set('hour', hour) - .set('minute', minute) - .set('second', second) - .set('millisecond', 0) + return base.set('hour', hour).set('minute', minute).set('second', second).set('millisecond', 0) } const candidateFormats = formats ?? COMMON_PARSE_FORMATS for (const fmt of candidateFormats) { - const parsed = tzName - ? dayjs(trimmed, fmt, true).tz(tzName, true) - : dayjs(trimmed, fmt, true) - if (parsed.isValid()) - return parsed + const parsed = tzName ? dayjs(trimmed, fmt, true).tz(tzName, true) : dayjs(trimmed, fmt, true) + if (parsed.isValid()) return parsed } const fallbackParsed = tzName ? dayjs.tz(trimmed, tzName) : dayjs(trimmed) - if (fallbackParsed.isValid()) - return fallbackParsed + if (fallbackParsed.isValid()) return fallbackParsed warnParseFailure(value) return undefined } // Format date output with localization support -export const formatDateForOutput = (date: Dayjs, includeTime: boolean = false, _locale: string = 'en-US'): string => { - if (!date || !date.isValid()) - return '' +export const formatDateForOutput = ( + date: Dayjs, + includeTime: boolean = false, + _locale: string = 'en-US', +): string => { + if (!date || !date.isValid()) return '' if (includeTime) { // Output format with time return date.format('YYYY-MM-DDTHH:mm:ss.SSSZ') - } - else { + } else { // Date-only output format without timezone return date.format('YYYY-MM-DD') } diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/__tests__/footer.spec.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/__tests__/footer.spec.tsx index f3d69461c91692..185c94d769add4 100644 --- a/web/app/components/base/date-and-time-picker/year-and-month-picker/__tests__/footer.spec.tsx +++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/__tests__/footer.spec.tsx @@ -3,7 +3,9 @@ import { fireEvent, render, screen } from '@testing-library/react' import Footer from '../footer' // Factory for Footer props -const createFooterProps = (overrides: Partial<YearAndMonthPickerFooterProps> = {}): YearAndMonthPickerFooterProps => ({ +const createFooterProps = ( + overrides: Partial<YearAndMonthPickerFooterProps> = {}, +): YearAndMonthPickerFooterProps => ({ handleYearMonthCancel: vi.fn(), handleYearMonthConfirm: vi.fn(), ...overrides, diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/__tests__/header.spec.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/__tests__/header.spec.tsx index fc30c8f22111b6..8b2d95b1dcbf24 100644 --- a/web/app/components/base/date-and-time-picker/year-and-month-picker/__tests__/header.spec.tsx +++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/__tests__/header.spec.tsx @@ -3,7 +3,9 @@ import { fireEvent, render, screen } from '@testing-library/react' import Header from '../header' // Factory for Header props -const createHeaderProps = (overrides: Partial<YearAndMonthPickerHeaderProps> = {}): YearAndMonthPickerHeaderProps => ({ +const createHeaderProps = ( + overrides: Partial<YearAndMonthPickerHeaderProps> = {}, +): YearAndMonthPickerHeaderProps => ({ selectedYear: 2024, selectedMonth: 5, // June (0-indexed) onClick: vi.fn(), diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/__tests__/options.spec.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/__tests__/options.spec.tsx index 977462dfb62b09..5eb1e80fcaced0 100644 --- a/web/app/components/base/date-and-time-picker/year-and-month-picker/__tests__/options.spec.tsx +++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/__tests__/options.spec.tsx @@ -6,7 +6,9 @@ beforeAll(() => { Element.prototype.scrollIntoView = vi.fn() }) -const createOptionsProps = (overrides: Partial<YearAndMonthPickerOptionsProps> = {}): YearAndMonthPickerOptionsProps => ({ +const createOptionsProps = ( + overrides: Partial<YearAndMonthPickerOptionsProps> = {}, +): YearAndMonthPickerOptionsProps => ({ selectedMonth: 5, selectedYear: 2024, handleMonthSelect: vi.fn(), diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx index 1856763d2b4a2c..a8325fcfb28135 100644 --- a/web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx +++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx @@ -13,10 +13,10 @@ const Footer: FC<YearAndMonthPickerFooterProps> = ({ return ( <div className="grid grid-cols-2 gap-x-1 p-2"> <Button size="small" onClick={handleYearMonthCancel}> - {t($ => $['operation.cancel'], { ns: 'time' })} + {t(($) => $['operation.cancel'], { ns: 'time' })} </Button> <Button variant="primary" size="small" onClick={handleYearMonthConfirm}> - {t($ => $['operation.ok'], { ns: 'time' })} + {t(($) => $['operation.ok'], { ns: 'time' })} </Button> </div> ) diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx index 5d5d7ca470a3cb..e27bf46d90d7b3 100644 --- a/web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx +++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx @@ -4,11 +4,7 @@ import { RiArrowUpSLine } from '@remixicon/react' import * as React from 'react' import { useMonths } from '../hooks' -const Header: FC<YearAndMonthPickerHeaderProps> = ({ - selectedYear, - selectedMonth, - onClick, -}) => { +const Header: FC<YearAndMonthPickerHeaderProps> = ({ selectedYear, selectedMonth, onClick }) => { const months = useMonths() return ( diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx index 2e162472f43372..f4efb267364ac6 100644 --- a/web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx +++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx @@ -18,37 +18,33 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({ <div className="grid grid-cols-2 gap-x-1 p-2"> {/* Month Picker */} <OptionList> - { - months.map((month, index) => { - const isSelected = selectedMonth === index - return ( - <OptionListItem - key={month} - isSelected={isSelected} - onClick={handleMonthSelect.bind(null, index)} - > - {month} - </OptionListItem> - ) - }) - } + {months.map((month, index) => { + const isSelected = selectedMonth === index + return ( + <OptionListItem + key={month} + isSelected={isSelected} + onClick={handleMonthSelect.bind(null, index)} + > + {month} + </OptionListItem> + ) + })} </OptionList> {/* Year Picker */} <OptionList> - { - yearOptions.map((year) => { - const isSelected = selectedYear === year - return ( - <OptionListItem - key={year} - isSelected={isSelected} - onClick={handleYearSelect.bind(null, year)} - > - {year} - </OptionListItem> - ) - }) - } + {yearOptions.map((year) => { + const isSelected = selectedYear === year + return ( + <OptionListItem + key={year} + isSelected={isSelected} + onClick={handleYearSelect.bind(null, year)} + > + {year} + </OptionListItem> + ) + })} </OptionList> </div> ) diff --git a/web/app/components/base/divider/__tests__/index.spec.tsx b/web/app/components/base/divider/__tests__/index.spec.tsx index 50973243c44113..a451630fc62020 100644 --- a/web/app/components/base/divider/__tests__/index.spec.tsx +++ b/web/app/components/base/divider/__tests__/index.spec.tsx @@ -27,14 +27,18 @@ describe('Divider', () => { const { container } = render(<Divider type="horizontal" bgStyle="gradient" />) const divider = container.firstChild as HTMLElement expect(divider).toHaveClass('w-full h-[0.5px] my-2') - expect(divider).toHaveClass('bg-linear-to-r from-divider-regular to-background-gradient-mask-transparent') + expect(divider).toHaveClass( + 'bg-linear-to-r from-divider-regular to-background-gradient-mask-transparent', + ) }) it('renders vertical gradient divider correctly', () => { const { container } = render(<Divider type="vertical" bgStyle="gradient" />) const divider = container.firstChild as HTMLElement expect(divider).toHaveClass('w-px h-full mx-2') - expect(divider).toHaveClass('bg-linear-to-r from-divider-regular to-background-gradient-mask-transparent') + expect(divider).toHaveClass( + 'bg-linear-to-r from-divider-regular to-background-gradient-mask-transparent', + ) }) it('applies custom className correctly', () => { diff --git a/web/app/components/base/divider/index.stories.tsx b/web/app/components/base/divider/index.stories.tsx index 2ae00eca47698c..5f4c26994c0b2c 100644 --- a/web/app/components/base/divider/index.stories.tsx +++ b/web/app/components/base/divider/index.stories.tsx @@ -7,7 +7,8 @@ const meta = { parameters: { docs: { description: { - component: 'Lightweight separator supporting horizontal and vertical orientations with gradient or solid backgrounds.', + component: + 'Lightweight separator supporting horizontal and vertical orientations with gradient or solid backgrounds.', }, source: { language: 'tsx', @@ -26,7 +27,7 @@ type Story = StoryObj<typeof meta> export const Horizontal: Story = {} export const Vertical: Story = { - render: args => ( + render: (args) => ( <div className="flex h-20 items-center gap-4 rounded-lg border border-divider-subtle bg-components-panel-bg p-4"> <span className="text-sm text-text-secondary">Filters</span> <Divider {...args} type="vertical" /> diff --git a/web/app/components/base/divider/index.tsx b/web/app/components/base/divider/index.tsx index 50c719e923531f..def63a844a818d 100644 --- a/web/app/components/base/divider/index.tsx +++ b/web/app/components/base/divider/index.tsx @@ -28,7 +28,11 @@ type DividerProps = { const Divider: FC<DividerProps> = ({ type, bgStyle, className = '', style }) => { return ( - <div className={cn(dividerVariants({ type, bgStyle }), 'shrink-0', className)} style={style} data-testid="divider"></div> + <div + className={cn(dividerVariants({ type, bgStyle }), 'shrink-0', className)} + style={style} + data-testid="divider" + ></div> ) } diff --git a/web/app/components/base/effect/index.stories.tsx b/web/app/components/base/effect/index.stories.tsx index a631e30166eaa1..2c1b21de0a6258 100644 --- a/web/app/components/base/effect/index.stories.tsx +++ b/web/app/components/base/effect/index.stories.tsx @@ -7,7 +7,8 @@ const meta = { parameters: { docs: { description: { - component: 'Blurred circular glow used as a decorative background accent. Combine with relatively positioned containers.', + component: + 'Blurred circular glow used as a decorative background accent. Combine with relatively positioned containers.', }, source: { language: 'tsx', diff --git a/web/app/components/base/effect/index.tsx b/web/app/components/base/effect/index.tsx index f3c2e3c3decc48..931c7491647d10 100644 --- a/web/app/components/base/effect/index.tsx +++ b/web/app/components/base/effect/index.tsx @@ -5,12 +5,13 @@ type EffectProps = { className?: string } -const Effect = ({ - className, -}: EffectProps) => { +const Effect = ({ className }: EffectProps) => { return ( <div - className={cn('absolute size-[112px] rounded-full bg-util-colors-blue-brand-blue-brand-500 blur-[80px]', className)} + className={cn( + 'absolute size-[112px] rounded-full bg-util-colors-blue-brand-blue-brand-500 blur-[80px]', + className, + )} /> ) } diff --git a/web/app/components/base/emoji-picker/Inner.stories.tsx b/web/app/components/base/emoji-picker/Inner.stories.tsx index be0e993cceff07..6aabfffbfb703d 100644 --- a/web/app/components/base/emoji-picker/Inner.stories.tsx +++ b/web/app/components/base/emoji-picker/Inner.stories.tsx @@ -9,7 +9,8 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Core emoji grid with search and style swatches. Use this when embedding the selector inline without a modal frame.', + component: + 'Core emoji grid with search and style swatches. Use this when embedding the selector inline without a modal frame.', }, }, }, @@ -20,7 +21,7 @@ export default meta type Story = StoryObj<typeof meta> const InnerDemo = () => { - const [selection, setSelection] = useState<{ emoji: string, background: string } | null>(null) + const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null) return ( <div className="flex h-[520px] flex-col gap-4 rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-lg"> @@ -31,7 +32,9 @@ const InnerDemo = () => { <div className="rounded-lg border border-divider-subtle bg-background-default-subtle p-3 text-xs text-text-secondary"> <div className="font-medium text-text-primary">Latest selection</div> <pre className="mt-1 max-h-40 overflow-auto font-mono"> - {selection ? JSON.stringify(selection, null, 2) : 'Tap an emoji to set background options.'} + {selection + ? JSON.stringify(selection, null, 2) + : 'Tap an emoji to set background options.'} </pre> </div> </div> diff --git a/web/app/components/base/emoji-picker/Inner.tsx b/web/app/components/base/emoji-picker/Inner.tsx index 5a41d4d9d0ed90..c9981dc20a0388 100644 --- a/web/app/components/base/emoji-picker/Inner.tsx +++ b/web/app/components/base/emoji-picker/Inner.tsx @@ -2,9 +2,7 @@ import type { EmojiMartData } from '@emoji-mart/data' import type { ChangeEvent } from 'react' import data from '@emoji-mart/data' -import { - MagnifyingGlassIcon, -} from '@heroicons/react/24/outline' +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' import { init } from 'emoji-mart' import * as React from 'react' @@ -23,12 +21,7 @@ type IEmojiPickerInnerProps = { className?: string } -function EmojiPickerInner({ - emoji, - background, - onSelect, - className, -}: IEmojiPickerInnerProps) { +function EmojiPickerInner({ emoji, background, onSelect, className }: IEmojiPickerInnerProps) { const { categories } = data as EmojiMartData const [selectedEmoji, setSelectedEmoji] = useState(emoji || '') const [selectedBackground, setSelectedBackground] = useState(background || defaultEmojiBackground) @@ -46,8 +39,7 @@ function EmojiPickerInner({ const handleBackgroundSelect = (background: string) => { setSelectedBackground(background) - if (selectedEmoji) - onSelect?.(selectedEmoji, background) + if (selectedEmoji) onSelect?.(selectedEmoji, background) } return ( @@ -65,8 +57,7 @@ function EmojiPickerInner({ onChange={async (e: ChangeEvent<HTMLInputElement>) => { if (e.target.value === '') { setIsSearching(false) - } - else { + } else { setIsSearching(true) const emojis = await searchEmoji(e.target.value) setSearchedEmojis(emojis) @@ -127,7 +118,6 @@ function EmojiPickerInner({ </button> ) })} - </div> </div> ) @@ -136,26 +126,26 @@ function EmojiPickerInner({ {/* Color Select */} <div className={cn('flex items-center justify-between p-3 pb-0')}> - <p id={styleColorsLabelId} className="mb-2 system-xs-medium-uppercase text-text-primary">Choose Style</p> - {showStyleColors - ? ( - <button - type="button" - aria-labelledby={styleColorsLabelId} - aria-expanded="true" - className="i-heroicons-chevron-down size-4 cursor-pointer border-none bg-transparent p-0 text-text-quaternary" - onClick={() => setShowStyleColors(!showStyleColors)} - /> - ) - : ( - <button - type="button" - aria-labelledby={styleColorsLabelId} - aria-expanded="false" - className="i-heroicons-chevron-up size-4 cursor-pointer border-none bg-transparent p-0 text-text-quaternary" - onClick={() => setShowStyleColors(!showStyleColors)} - /> - )} + <p id={styleColorsLabelId} className="mb-2 system-xs-medium-uppercase text-text-primary"> + Choose Style + </p> + {showStyleColors ? ( + <button + type="button" + aria-labelledby={styleColorsLabelId} + aria-expanded="true" + className="i-heroicons-chevron-down size-4 cursor-pointer border-none bg-transparent p-0 text-text-quaternary" + onClick={() => setShowStyleColors(!showStyleColors)} + /> + ) : ( + <button + type="button" + aria-labelledby={styleColorsLabelId} + aria-expanded="false" + className="i-heroicons-chevron-up size-4 cursor-pointer border-none bg-transparent p-0 text-text-quaternary" + onClick={() => setShowStyleColors(!showStyleColors)} + /> + )} </div> {showStyleColors && ( <div className="grid w-full grid-cols-8 gap-1 px-3"> @@ -165,23 +155,19 @@ function EmojiPickerInner({ type="button" key={color} aria-label={color} - className={ - cn( - 'cursor-pointer', - 'border-none bg-transparent p-0', - 'ring-components-input-border-hover ring-offset-1 hover:ring-1', - 'inline-flex size-10 items-center justify-center rounded-lg', - color === selectedBackground ? 'ring-1 ring-components-input-border-hover' : '', - ) - } + className={cn( + 'cursor-pointer', + 'border-none bg-transparent p-0', + 'ring-components-input-border-hover ring-offset-1 hover:ring-1', + 'inline-flex size-10 items-center justify-center rounded-lg', + color === selectedBackground ? 'ring-1 ring-components-input-border-hover' : '', + )} onClick={() => { handleBackgroundSelect(color) }} > <span - className={cn( - 'flex size-8 items-center justify-center rounded-lg p-1', - )} + className={cn('flex size-8 items-center justify-center rounded-lg p-1')} style={{ background: color }} > {selectedEmoji !== '' && <em-emoji id={selectedEmoji} />} diff --git a/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx b/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx index df3a6102b07680..c5321def68181f 100644 --- a/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx +++ b/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx @@ -31,9 +31,14 @@ describe('EmojiPickerInner', () => { vi.clearAllMocks() // Define the custom element to avoid "Unknown custom element" warnings if (!customElements.get('em-emoji')) { - customElements.define('em-emoji', class extends HTMLElement { - static get observedAttributes() { return ['id'] } - }) + customElements.define( + 'em-emoji', + class extends HTMLElement { + static get observedAttributes() { + return ['id'] + } + }, + ) } }) diff --git a/web/app/components/base/emoji-picker/__tests__/index.spec.tsx b/web/app/components/base/emoji-picker/__tests__/index.spec.tsx index 45b12352c9021f..2e24b97e0a603a 100644 --- a/web/app/components/base/emoji-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/emoji-picker/__tests__/index.spec.tsx @@ -34,17 +34,13 @@ describe('EmojiPicker', () => { describe('Rendering', () => { it('renders nothing when closed', () => { - const { container } = render( - <EmojiPicker open={false} onOpenChange={mockOnOpenChange} />, - ) + const { container } = render(<EmojiPicker open={false} onOpenChange={mockOnOpenChange} />) expect(container.firstChild).toBeNull() }) it('renders modal when open', async () => { await act(async () => { - render( - <EmojiPicker open onOpenChange={mockOnOpenChange} />, - ) + render(<EmojiPicker open onOpenChange={mockOnOpenChange} />) }) expect(screen.getByRole('dialog', { name: /Emoji/i }))!.toBeInTheDocument() expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument() @@ -54,9 +50,7 @@ describe('EmojiPicker', () => { it('OK button is disabled initially', async () => { await act(async () => { - render( - <EmojiPicker open onOpenChange={mockOnOpenChange} />, - ) + render(<EmojiPicker open onOpenChange={mockOnOpenChange} />) }) const okButton = screen.getByText(/OK/i).closest('button') expect(okButton)!.toBeDisabled() @@ -65,9 +59,7 @@ describe('EmojiPicker', () => { it('applies custom className to modal wrapper', async () => { const customClass = 'custom-wrapper-class' await act(async () => { - render( - <EmojiPicker open onOpenChange={mockOnOpenChange} className={customClass} />, - ) + render(<EmojiPicker open onOpenChange={mockOnOpenChange} className={customClass} />) }) const dialog = screen.getByRole('dialog') expect(dialog)!.toHaveClass(customClass) @@ -77,9 +69,7 @@ describe('EmojiPicker', () => { describe('User Interactions', () => { it('calls onSelect with selected emoji and background when OK is clicked', async () => { await act(async () => { - render( - <EmojiPicker open onOpenChange={mockOnOpenChange} onSelect={mockOnSelect} />, - ) + render(<EmojiPicker open onOpenChange={mockOnOpenChange} onSelect={mockOnSelect} />) }) await act(async () => { @@ -98,9 +88,7 @@ describe('EmojiPicker', () => { it('closes when Cancel is clicked', async () => { await act(async () => { - render( - <EmojiPicker open onOpenChange={mockOnOpenChange} />, - ) + render(<EmojiPicker open onOpenChange={mockOnOpenChange} />) }) const cancelButton = screen.getByText(/Cancel/i) diff --git a/web/app/components/base/emoji-picker/index.stories.tsx b/web/app/components/base/emoji-picker/index.stories.tsx index 68ada2269aa83d..30ee161792d078 100644 --- a/web/app/components/base/emoji-picker/index.stories.tsx +++ b/web/app/components/base/emoji-picker/index.stories.tsx @@ -9,7 +9,8 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Modal-based emoji selector that powers the icon picker. Supports search, background swatches, and confirmation callbacks.', + component: + 'Modal-based emoji selector that powers the icon picker. Supports search, background swatches, and confirmation callbacks.', }, }, nextjs: { @@ -28,7 +29,7 @@ type Story = StoryObj<typeof meta> const EmojiPickerDemo = () => { const [open, setOpen] = useState(false) - const [selection, setSelection] = useState<{ emoji: string, background: string } | null>(null) + const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null) return ( <div className="flex min-h-[320px] flex-col items-start gap-4 px-6 py-8 md:px-12"> diff --git a/web/app/components/base/emoji-picker/index.tsx b/web/app/components/base/emoji-picker/index.tsx index a7db3b92a2cef9..80a0488e362b7a 100644 --- a/web/app/components/base/emoji-picker/index.tsx +++ b/web/app/components/base/emoji-picker/index.tsx @@ -14,23 +14,12 @@ type EmojiPickerProps = { className?: string } -function EmojiPicker({ - open, - onOpenChange, - onSelect, - className, -}: EmojiPickerProps) { +function EmojiPicker({ open, onOpenChange, onSelect, className }: EmojiPickerProps) { return ( <Dialog open={open} onOpenChange={onOpenChange}> - {open - ? ( - <EmojiPickerContent - className={className} - onOpenChange={onOpenChange} - onSelect={onSelect} - /> - ) - : null} + {open ? ( + <EmojiPickerContent className={className} onOpenChange={onOpenChange} onSelect={onSelect} /> + ) : null} </Dialog> ) } @@ -41,11 +30,7 @@ type EmojiPickerContentProps = { onSelect?: (emoji: string, background: string) => void } -function EmojiPickerContent({ - className, - onOpenChange, - onSelect, -}: EmojiPickerContentProps) { +function EmojiPickerContent({ className, onOpenChange, onSelect }: EmojiPickerContentProps) { const { t } = useTranslation() const [selectedEmoji, setSelectedEmoji] = useState('') const [selectedBackground, setSelectedBackground] = useState<string>() @@ -59,7 +44,7 @@ function EmojiPickerContent({ )} > <DialogTitle className="sr-only"> - {t($ => $['iconPicker.emoji'], { ns: 'app' })} + {t(($) => $['iconPicker.emoji'], { ns: 'app' })} </DialogTitle> <EmojiPickerInner @@ -71,11 +56,8 @@ function EmojiPickerContent({ /> <Divider className="mt-3 mb-0" /> <div className="flex w-full items-center justify-center gap-2 p-3"> - <Button - className="w-full" - onClick={() => onOpenChange(false)} - > - {t($ => $['iconPicker.cancel'], { ns: 'app' })} + <Button className="w-full" onClick={() => onOpenChange(false)}> + {t(($) => $['iconPicker.cancel'], { ns: 'app' })} </Button> <Button disabled={selectedEmoji === '' || !selectedBackground} @@ -86,7 +68,7 @@ function EmojiPickerContent({ onOpenChange(false) }} > - {t($ => $['iconPicker.ok'], { ns: 'app' })} + {t(($) => $['iconPicker.ok'], { ns: 'app' })} </Button> </div> </DialogContent> diff --git a/web/app/components/base/encrypted-bottom/__tests__/index.spec.tsx b/web/app/components/base/encrypted-bottom/__tests__/index.spec.tsx index 914cc7fcdaefb1..65293905d43c23 100644 --- a/web/app/components/base/encrypted-bottom/__tests__/index.spec.tsx +++ b/web/app/components/base/encrypted-bottom/__tests__/index.spec.tsx @@ -8,7 +8,12 @@ describe('EncryptedBottom', () => { }) it('passes keys', async () => { - render(<EncryptedBottom frontTextKey="provider.encrypted.front" backTextKey="provider.encrypted.back" />) + render( + <EncryptedBottom + frontTextKey="provider.encrypted.front" + backTextKey="provider.encrypted.back" + />, + ) expect(await screen.findByText(/provider.encrypted.front/i)).toBeInTheDocument() expect(await screen.findByText(/provider.encrypted.back/i)).toBeInTheDocument() }) diff --git a/web/app/components/base/encrypted-bottom/index.tsx b/web/app/components/base/encrypted-bottom/index.tsx index 9f9332e05bc281..3b484499030f66 100644 --- a/web/app/components/base/encrypted-bottom/index.tsx +++ b/web/app/components/base/encrypted-bottom/index.tsx @@ -20,9 +20,14 @@ export const EncryptedBottom = (props: Props) => { const { frontTextKey = DEFAULT_FRONT_KEY, backTextKey = DEFAULT_BACK_KEY, className } = props return ( - <div className={cn('flex items-center justify-center rounded-b-2xl border-t-[0.5px] border-divider-subtle bg-background-soft px-2 py-3 system-xs-regular text-text-tertiary', className)}> + <div + className={cn( + 'flex items-center justify-center rounded-b-2xl border-t-[0.5px] border-divider-subtle bg-background-soft px-2 py-3 system-xs-regular text-text-tertiary', + className, + )} + > <RiLock2Fill className="mx-1 size-3 text-text-quaternary" /> - {t($ => $[frontTextKey], { ns: 'common' })} + {t(($) => $[frontTextKey], { ns: 'common' })} <Link className="mx-1 text-text-accent" target="_blank" @@ -31,7 +36,7 @@ export const EncryptedBottom = (props: Props) => { > PKCS1_OAEP </Link> - {t($ => $[backTextKey], { ns: 'common' })} + {t(($) => $[backTextKey], { ns: 'common' })} </div> ) } diff --git a/web/app/components/base/error-boundary/__tests__/index.spec.tsx b/web/app/components/base/error-boundary/__tests__/index.spec.tsx index eeaee2a65244e2..0e328b2debd07a 100644 --- a/web/app/components/base/error-boundary/__tests__/index.spec.tsx +++ b/web/app/components/base/error-boundary/__tests__/index.spec.tsx @@ -14,18 +14,20 @@ vi.mock('@/config', () => ({ }, })) -vi.mock('react-i18next', () => createReactI18nextMock({ - 'error': 'Error', - 'errorBoundary.componentStack': 'Component Stack:', - 'errorBoundary.details': 'Error Details (Development Only)', - 'errorBoundary.errorCount': 'This error has occurred {{count}} times', - 'errorBoundary.fallbackTitle': 'Oops! Something went wrong', - 'errorBoundary.message': 'An unexpected error occurred while rendering this component.', - 'errorBoundary.reloadPage': 'Reload Page', - 'errorBoundary.title': 'Something went wrong', - 'errorBoundary.tryAgain': 'Try Again', - 'errorBoundary.tryAgainCompact': 'Try again', -})) +vi.mock('react-i18next', () => + createReactI18nextMock({ + error: 'Error', + 'errorBoundary.componentStack': 'Component Stack:', + 'errorBoundary.details': 'Error Details (Development Only)', + 'errorBoundary.errorCount': 'This error has occurred {{count}} times', + 'errorBoundary.fallbackTitle': 'Oops! Something went wrong', + 'errorBoundary.message': 'An unexpected error occurred while rendering this component.', + 'errorBoundary.reloadPage': 'Reload Page', + 'errorBoundary.title': 'Something went wrong', + 'errorBoundary.tryAgain': 'Try Again', + 'errorBoundary.tryAgainCompact': 'Try again', + }), +) type ThrowOnRenderProps = { message?: string @@ -33,8 +35,7 @@ type ThrowOnRenderProps = { } const ThrowOnRender = ({ shouldThrow, message = 'render boom' }: ThrowOnRenderProps) => { - if (shouldThrow) - throw new Error(message) + if (shouldThrow) throw new Error(message) return <div>Child content rendered</div> } @@ -72,7 +73,9 @@ describe('ErrorBoundary', () => { ) expect(await screen.findByText('Something went wrong')).toBeInTheDocument() - expect(screen.getByText('An unexpected error occurred while rendering this component.')).toBeInTheDocument() + expect( + screen.getByText('An unexpected error occurred while rendering this component.'), + ).toBeInTheDocument() }) it('should render custom title, message, and className in fallback', async () => { @@ -110,20 +113,14 @@ describe('ErrorBoundary', () => { it('should render function fallback with error message when fallback prop is a function', async () => { render( - <ErrorBoundary - fallback={error => ( - <div> - Function fallback: - {' '} - {error.message} - </div> - )} - > + <ErrorBoundary fallback={(error) => <div>Function fallback: {error.message}</div>}> <ThrowOnRender message="function fallback boom" shouldThrow={true} /> </ErrorBoundary>, ) - expect(await screen.findByText('Function fallback: function fallback boom')).toBeInTheDocument() + expect( + await screen.findByText('Function fallback: function fallback boom'), + ).toBeInTheDocument() }) }) @@ -348,8 +345,7 @@ describe('ErrorBoundary utility exports', () => { } const WrappedTarget = ({ shouldThrow }: WrappedProps) => { - if (shouldThrow) - throw new Error('wrapped boom') + if (shouldThrow) throw new Error('wrapped boom') return <div>Wrapped content</div> } diff --git a/web/app/components/base/error-boundary/index.tsx b/web/app/components/base/error-boundary/index.tsx index 19815a23969027..938699489f1902 100644 --- a/web/app/components/base/error-boundary/index.tsx +++ b/web/app/components/base/error-boundary/index.tsx @@ -73,13 +73,12 @@ class ErrorBoundaryInner extends React.Component< console.error('Error Info:', errorInfo) } - this.setState(prevState => ({ + this.setState((prevState) => ({ errorInfo, errorCount: prevState.errorCount + 1, })) - if (this.props.onError) - this.props.onError(error, errorInfo) + if (this.props.onError) this.props.onError(error, errorInfo) } override componentDidUpdate(prevProps: any) { @@ -94,8 +93,7 @@ class ErrorBoundaryInner extends React.Component< if (hasError && resetOnPropsChange && prevProps.children !== this.props.children) this.props.resetErrorBoundary() - if (prevProps.resetKeys !== resetKeys) - this.props.onResetKeysChange(prevProps.resetKeys) + if (prevProps.resetKeys !== resetKeys) this.props.onResetKeysChange(prevProps.resetKeys) } override render() { @@ -115,8 +113,7 @@ class ErrorBoundaryInner extends React.Component< if (hasError && error) { if (fallback) { - if (typeof fallback === 'function') - return fallback(error, resetErrorBoundary) + if (typeof fallback === 'function') return fallback(error, resetErrorBoundary) return fallback } @@ -131,14 +128,10 @@ class ErrorBoundaryInner extends React.Component< > <div className="mb-4 flex items-center gap-2"> <RiAlertLine className="text-state-critical-solid size-8" /> - <h2 className="text-xl font-semibold text-text-primary"> - {customTitle || copy.title} - </h2> + <h2 className="text-xl font-semibold text-text-primary">{customTitle || copy.title}</h2> </div> - <p className="mb-6 text-center text-text-secondary"> - {customMessage || copy.message} - </p> + <p className="mb-6 text-center text-text-secondary">{customMessage || copy.message}</p> {showDetails && errorInfo && ( <details className="mb-6 w-full max-w-2xl"> @@ -150,14 +143,18 @@ class ErrorBoundaryInner extends React.Component< </summary> <div className="rounded-lg bg-gray-100 p-4"> <div className="mb-2"> - <span className="font-mono text-xs font-semibold text-gray-600">{copy.error}</span> + <span className="font-mono text-xs font-semibold text-gray-600"> + {copy.error} + </span> <pre className="mt-1 overflow-auto font-mono text-xs whitespace-pre-wrap text-gray-800"> {error.toString()} </pre> </div> {errorInfo && ( <div> - <span className="font-mono text-xs font-semibold text-gray-600">{copy.componentStack}</span> + <span className="font-mono text-xs font-semibold text-gray-600"> + {copy.componentStack} + </span> <pre className="mt-1 max-h-40 overflow-auto font-mono text-xs whitespace-pre-wrap text-gray-700"> {errorInfo.componentStack} </pre> @@ -174,18 +171,10 @@ class ErrorBoundaryInner extends React.Component< {enableRecovery && ( <div className="flex gap-3"> - <Button - variant="primary" - size="small" - onClick={resetErrorBoundary} - > + <Button variant="primary" size="small" onClick={resetErrorBoundary}> {copy.tryAgain} </Button> - <Button - variant="secondary" - size="small" - onClick={() => window.location.reload()} - > + <Button variant="secondary" size="small" onClick={() => window.location.reload()}> {copy.reload} </Button> </div> @@ -205,18 +194,19 @@ const ErrorBoundary: React.FC<ErrorBoundaryProps> = (props) => { const resetKeysRef = useRef(props.resetKeys) const prevResetKeysRef = useRef<Array<string | number> | undefined>(undefined) const copy = { - componentStack: t($ => $['errorBoundary.componentStack'], { ns: 'common' }), - details: t($ => $['errorBoundary.details'], { ns: 'common' }), - error: `${t($ => $.error, { ns: 'common' })}:`, - formatErrorCount: (count: number) => t($ => $['errorBoundary.errorCount'], { ns: 'common', count }), - message: t($ => $['errorBoundary.message'], { ns: 'common' }), - reload: t($ => $['errorBoundary.reloadPage'], { ns: 'common' }), - title: t($ => $['errorBoundary.title'], { ns: 'common' }), - tryAgain: t($ => $['errorBoundary.tryAgain'], { ns: 'common' }), + componentStack: t(($) => $['errorBoundary.componentStack'], { ns: 'common' }), + details: t(($) => $['errorBoundary.details'], { ns: 'common' }), + error: `${t(($) => $.error, { ns: 'common' })}:`, + formatErrorCount: (count: number) => + t(($) => $['errorBoundary.errorCount'], { ns: 'common', count }), + message: t(($) => $['errorBoundary.message'], { ns: 'common' }), + reload: t(($) => $['errorBoundary.reloadPage'], { ns: 'common' }), + title: t(($) => $['errorBoundary.title'], { ns: 'common' }), + tryAgain: t(($) => $['errorBoundary.tryAgain'], { ns: 'common' }), } const resetErrorBoundary = useCallback(() => { - setErrorBoundaryKey(prev => prev + 1) + setErrorBoundaryKey((prev) => prev + 1) props.onReset?.() }, [props]) @@ -225,8 +215,7 @@ const ErrorBoundary: React.FC<ErrorBoundaryProps> = (props) => { }, []) useEffect(() => { - if (prevResetKeysRef.current !== props.resetKeys) - resetKeysRef.current = props.resetKeys + if (prevResetKeysRef.current !== props.resetKeys) resetKeysRef.current = props.resetKeys }, [props.resetKeys]) return ( @@ -238,7 +227,7 @@ const ErrorBoundary: React.FC<ErrorBoundaryProps> = (props) => { onResetKeysChange={onResetKeysChange} /> ) -}// HOC for wrapping components with error boundary +} // HOC for wrapping components with error boundary export function withErrorBoundary<P extends object>( Component: React.ComponentType<P>, errorBoundaryProps?: Omit<ErrorBoundaryProps, 'children'>, diff --git a/web/app/components/base/features/__tests__/context.spec.tsx b/web/app/components/base/features/__tests__/context.spec.tsx index 4be4e00d2666bf..485b28dfb22826 100644 --- a/web/app/components/base/features/__tests__/context.spec.tsx +++ b/web/app/components/base/features/__tests__/context.spec.tsx @@ -5,8 +5,7 @@ import { FeaturesContext, FeaturesProvider } from '../context' const TestConsumer = () => { const store = use(FeaturesContext) - if (!store) - return <div>no store</div> + if (!store) return <div>no store</div> const { features } = store.getState() return <div role="status">{features.moreLikeThis?.enabled ? 'enabled' : 'disabled'}</div> diff --git a/web/app/components/base/features/__tests__/hooks.spec.ts b/web/app/components/base/features/__tests__/hooks.spec.ts index 52f3e846ced807..d76efcbb32487b 100644 --- a/web/app/components/base/features/__tests__/hooks.spec.ts +++ b/web/app/components/base/features/__tests__/hooks.spec.ts @@ -13,10 +13,9 @@ describe('useFeatures', () => { const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(FeaturesContext.Provider, { value: store }, children) - const { result } = renderHook( - () => useFeatures(s => s.features.moreLikeThis?.enabled), - { wrapper }, - ) + const { result } = renderHook(() => useFeatures((s) => s.features.moreLikeThis?.enabled), { + wrapper, + }) expect(result.current).toBe(true) }) @@ -24,7 +23,7 @@ describe('useFeatures', () => { it('should throw error when used outside FeaturesContext.Provider', () => { // Act & Assert expect(() => { - renderHook(() => useFeatures(s => s.features)) + renderHook(() => useFeatures((s) => s.features)) }).toThrow('Missing FeaturesContext.Provider in the tree') }) @@ -35,7 +34,10 @@ describe('useFeatures', () => { React.createElement(FeaturesContext.Provider, { value: store }, children) const { result } = renderHook( - () => useFeatures(s => (s.features as Record<string, unknown>).nonexistent as boolean | undefined), + () => + useFeatures( + (s) => (s.features as Record<string, unknown>).nonexistent as boolean | undefined, + ), { wrapper }, ) diff --git a/web/app/components/base/features/context.tsx b/web/app/components/base/features/context.tsx index 0d6f39a9e7eaa6..2454c015d3b722 100644 --- a/web/app/components/base/features/context.tsx +++ b/web/app/components/base/features/context.tsx @@ -1,11 +1,5 @@ -import type { - FeaturesState, - FeaturesStore, -} from './store' -import { - createContext, - useRef, -} from 'react' +import type { FeaturesState, FeaturesStore } from './store' +import { createContext, useRef } from 'react' import { createFeaturesStore } from './store' export const FeaturesContext = createContext<FeaturesStore | null>(null) @@ -16,12 +10,7 @@ type FeaturesProviderProps = { export const FeaturesProvider = ({ children, ...props }: FeaturesProviderProps) => { const storeRef = useRef<FeaturesStore | undefined>(undefined) - if (!storeRef.current) - storeRef.current = createFeaturesStore(props) + if (!storeRef.current) storeRef.current = createFeaturesStore(props) - return ( - <FeaturesContext.Provider value={storeRef.current}> - {children} - </FeaturesContext.Provider> - ) + return <FeaturesContext.Provider value={storeRef.current}>{children}</FeaturesContext.Provider> } diff --git a/web/app/components/base/features/hooks.ts b/web/app/components/base/features/hooks.ts index f1e91621d8fbee..6d5a15f743100d 100644 --- a/web/app/components/base/features/hooks.ts +++ b/web/app/components/base/features/hooks.ts @@ -5,8 +5,7 @@ import { FeaturesContext } from './context' export function useFeatures<T>(selector: (state: FeatureStoreState) => T): T { const store = use(FeaturesContext) - if (!store) - throw new Error('Missing FeaturesContext.Provider in the tree') + if (!store) throw new Error('Missing FeaturesContext.Provider in the tree') return useStore(store, selector) } diff --git a/web/app/components/base/features/index.stories.tsx b/web/app/components/base/features/index.stories.tsx index 99d8df097b855b..60de7bfd1b146a 100644 --- a/web/app/components/base/features/index.stories.tsx +++ b/web/app/components/base/features/index.stories.tsx @@ -23,7 +23,8 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Zustand-backed provider used for feature toggles. Paired with `NewFeaturePanel` for workflow settings.', + component: + 'Zustand-backed provider used for feature toggles. Paired with `NewFeaturePanel` for workflow settings.', }, }, }, @@ -58,7 +59,7 @@ const FeaturesDemo = () => { show={show} isChatMode disabled={false} - onChange={next => setFeatures(prev => ({ ...prev, ...next }))} + onChange={(next) => setFeatures((prev) => ({ ...prev, ...next }))} onClose={() => setShow(false)} /> </FeaturesProvider> diff --git a/web/app/components/base/features/new-feature-panel/__tests__/citation.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/citation.spec.tsx index a6ad8630718519..847cbcd71d09bc 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/citation.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/citation.spec.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import { FeaturesProvider } from '../../context' import Citation from '../citation' -const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => { +const renderWithProvider = (props: { disabled?: boolean; onChange?: OnFeaturesChange } = {}) => { return render( <FeaturesProvider> <Citation disabled={props.disabled} onChange={props.onChange} /> diff --git a/web/app/components/base/features/new-feature-panel/__tests__/feature-bar.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/feature-bar.spec.tsx index 48b91c4836ecd3..21b42be2e14efe 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/feature-bar.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/feature-bar.spec.tsx @@ -53,25 +53,34 @@ describe('FeatureBar', () => { describe('Enabled Features', () => { it('should show enabled text when moreLikeThis is enabled', () => { - renderWithProvider({}, { - moreLikeThis: { enabled: true }, - }) + renderWithProvider( + {}, + { + moreLikeThis: { enabled: true }, + }, + ) expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() }) it('should show manage button when features are enabled', () => { - renderWithProvider({}, { - moreLikeThis: { enabled: true }, - }) + renderWithProvider( + {}, + { + moreLikeThis: { enabled: true }, + }, + ) expect(screen.getByText(/feature\.bar\.manage/)).toBeInTheDocument() }) it('should hide manage button when hideEditEntrance is true', () => { - renderWithProvider({ hideEditEntrance: true }, { - moreLikeThis: { enabled: true }, - }) + renderWithProvider( + { hideEditEntrance: true }, + { + moreLikeThis: { enabled: true }, + }, + ) expect(screen.queryByText(/feature\.bar\.manage/)).not.toBeInTheDocument() }) @@ -79,9 +88,12 @@ describe('FeatureBar', () => { it('should call onFeatureBarClick when manage button is clicked', () => { const onFeatureBarClick = vi.fn() - renderWithProvider({ onFeatureBarClick }, { - moreLikeThis: { enabled: true }, - }) + renderWithProvider( + { onFeatureBarClick }, + { + moreLikeThis: { enabled: true }, + }, + ) fireEvent.click(screen.getByText(/feature\.bar\.manage/)) expect(onFeatureBarClick).toHaveBeenCalledWith(true) @@ -90,81 +102,111 @@ describe('FeatureBar', () => { describe('Chat Mode Features', () => { it('should show enabled text when citation is enabled in chat mode', () => { - renderWithProvider({ isChatMode: true }, { - citation: { enabled: true }, - }) + renderWithProvider( + { isChatMode: true }, + { + citation: { enabled: true }, + }, + ) expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() }) it('should show empty state when citation is enabled but not in chat mode', () => { - renderWithProvider({ isChatMode: false }, { - citation: { enabled: true }, - }) + renderWithProvider( + { isChatMode: false }, + { + citation: { enabled: true }, + }, + ) expect(screen.getByText(/feature\.bar\.empty/)).toBeInTheDocument() }) it('should show enabled text when opening is enabled in chat mode', () => { - renderWithProvider({ isChatMode: true }, { - opening: { enabled: true }, - }) + renderWithProvider( + { isChatMode: true }, + { + opening: { enabled: true }, + }, + ) expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() }) it('should show enabled text when file is enabled with showFileUpload', () => { - renderWithProvider({ showFileUpload: true }, { - file: { enabled: true }, - }) + renderWithProvider( + { showFileUpload: true }, + { + file: { enabled: true }, + }, + ) expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() }) it('should show empty state when file is enabled but showFileUpload is false', () => { - renderWithProvider({ showFileUpload: false }, { - file: { enabled: true }, - }) + renderWithProvider( + { showFileUpload: false }, + { + file: { enabled: true }, + }, + ) expect(screen.getByText(/feature\.bar\.empty/)).toBeInTheDocument() }) it('should show enabled text when speech2text is enabled in chat mode', () => { - renderWithProvider({ isChatMode: true }, { - speech2text: { enabled: true }, - }) + renderWithProvider( + { isChatMode: true }, + { + speech2text: { enabled: true }, + }, + ) expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() }) it('should show enabled text when text2speech is enabled', () => { - renderWithProvider({ isChatMode: true }, { - text2speech: { enabled: true }, - }) + renderWithProvider( + { isChatMode: true }, + { + text2speech: { enabled: true }, + }, + ) expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() }) it('should show enabled text when moderation is enabled', () => { - renderWithProvider({ isChatMode: true }, { - moderation: { enabled: true }, - }) + renderWithProvider( + { isChatMode: true }, + { + moderation: { enabled: true }, + }, + ) expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() }) it('should show enabled text when suggested is enabled', () => { - renderWithProvider({ isChatMode: true }, { - suggested: { enabled: true }, - }) + renderWithProvider( + { isChatMode: true }, + { + suggested: { enabled: true }, + }, + ) expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() }) it('should show enabled text when annotationReply is enabled in chat mode', () => { - renderWithProvider({ isChatMode: true }, { - annotationReply: { enabled: true }, - }) + renderWithProvider( + { isChatMode: true }, + { + annotationReply: { enabled: true }, + }, + ) expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() }) diff --git a/web/app/components/base/features/new-feature-panel/__tests__/follow-up-setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/follow-up-setting-modal.spec.tsx index 9437a19824e677..e975ff2a5b5b09 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/follow-up-setting-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/follow-up-setting-modal.spec.tsx @@ -14,23 +14,20 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () }), })) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - default: ({ provider, modelId }: { provider: string, modelId: string }) => ( - <div data-testid="model-parameter-modal">{`${provider}:${modelId}`}</div> - ), -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + default: ({ provider, modelId }: { provider: string; modelId: string }) => ( + <div data-testid="model-parameter-modal">{`${provider}:${modelId}`}</div> + ), + }), +) const renderModal = (data: SuggestedQuestionsAfterAnswer = { enabled: true }) => { const onSave = vi.fn() const onCancel = vi.fn() - render( - <FollowUpSettingModal - data={data} - onSave={onSave} - onCancel={onCancel} - />, - ) + render(<FollowUpSettingModal data={data} onSave={onSave} onCancel={onCancel} />) return { onSave, @@ -48,18 +45,28 @@ describe('FollowUpSettingModal', () => { const user = userEvent.setup() const { onSave } = renderModal() - expect(screen.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption')).toBeInTheDocument() - expect(screen.getByText(/Please predict the three most likely follow-up questions a user would ask/)).toBeInTheDocument() + expect( + screen.getByText( + 'appDebug.feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption', + ), + ).toBeInTheDocument() + expect( + screen.getByText( + /Please predict the three most likely follow-up questions a user would ask/, + ), + ).toBeInTheDocument() await user.click(screen.getByText(/common\.operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - prompt: undefined, - model: expect.objectContaining({ - provider: 'openai', - name: 'gpt-4o-mini', + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: undefined, + model: expect.objectContaining({ + provider: 'openai', + name: 'gpt-4o-mini', + }), }), - })) + ) }) }) @@ -68,28 +75,37 @@ describe('FollowUpSettingModal', () => { const user = userEvent.setup() const { onSave } = renderModal() - await user.click(screen.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.customPromptOption').closest('button')!) + await user.click( + screen + .getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.customPromptOption') + .closest('button')!, + ) - const textarea = screen.getByPlaceholderText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder') + const textarea = screen.getByPlaceholderText( + 'appDebug.feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', + ) expect(textarea).toHaveAttribute('maxLength', '1000') - fireEvent.change( - textarea, - { target: { value: 'Use a custom follow-up prompt.' } }, - ) + fireEvent.change(textarea, { target: { value: 'Use a custom follow-up prompt.' } }) await user.click(screen.getByText(/common\.operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - prompt: 'Use a custom follow-up prompt.', - })) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'Use a custom follow-up prompt.', + }), + ) }) it('should disable save when custom prompt is selected but empty', async () => { const user = userEvent.setup() renderModal() - await user.click(screen.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.customPromptOption').closest('button')!) + await user.click( + screen + .getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.customPromptOption') + .closest('button')!, + ) expect(screen.getByText(/common\.operation\.save/).closest('button')).toBeDisabled() }) diff --git a/web/app/components/base/features/new-feature-panel/__tests__/follow-up.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/follow-up.spec.tsx index 323032249dfd69..df97de19bd3c35 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/follow-up.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/follow-up.spec.tsx @@ -1,39 +1,46 @@ -import type { - OnFeaturesChange, - SuggestedQuestionsAfterAnswer, -} from '../../types' +import type { OnFeaturesChange, SuggestedQuestionsAfterAnswer } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { FeaturesProvider } from '../../context' import FollowUp from '../follow-up' vi.mock('../follow-up-setting-modal', () => ({ - default: ({ onSave, onCancel }: { onSave: (newState: unknown) => void, onCancel: () => void }) => ( + default: ({ + onSave, + onCancel, + }: { + onSave: (newState: unknown) => void + onCancel: () => void + }) => ( <div data-testid="follow-up-setting-modal"> <button type="button" - onClick={() => onSave({ - enabled: true, - prompt: 'test prompt', - model: { - provider: 'openai', - name: 'gpt-4o-mini', - mode: 'chat', - completion_params: { - temperature: 0.7, - max_tokens: 0, - top_p: 0, - echo: false, - stop: [], - presence_penalty: 0, - frequency_penalty: 0, + onClick={() => + onSave({ + enabled: true, + prompt: 'test prompt', + model: { + provider: 'openai', + name: 'gpt-4o-mini', + mode: 'chat', + completion_params: { + temperature: 0.7, + max_tokens: 0, + top_p: 0, + echo: false, + stop: [], + presence_penalty: 0, + frequency_penalty: 0, + }, }, - }, - })} + }) + } > save-settings </button> - <button type="button" onClick={onCancel}>cancel-settings</button> + <button type="button" onClick={onCancel}> + cancel-settings + </button> </div> ), })) @@ -46,9 +53,10 @@ const renderWithProvider = ( } = {}, ) => { return render( - <FeaturesProvider features={{ - suggested: props.suggested || { enabled: false }, - }} + <FeaturesProvider + features={{ + suggested: props.suggested || { enabled: false }, + }} > <FollowUp disabled={props.disabled} onChange={props.onChange} /> </FeaturesProvider>, @@ -65,7 +73,9 @@ describe('FollowUp', () => { it('should render description text', () => { renderWithProvider() - expect(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.description/)).toBeInTheDocument() + expect( + screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.description/), + ).toBeInTheDocument() }) it('should render a switch toggle', () => { @@ -96,7 +106,9 @@ describe('FollowUp', () => { }, }) - fireEvent.mouseEnter(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/).closest('[class]')!) + fireEvent.mouseEnter( + screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/).closest('[class]')!, + ) expect(screen.getByText(/operation\.settings/)).toBeInTheDocument() }) @@ -110,22 +122,26 @@ describe('FollowUp', () => { }, }) - fireEvent.mouseEnter(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/).closest('[class]')!) + fireEvent.mouseEnter( + screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/).closest('[class]')!, + ) fireEvent.click(screen.getByText(/operation\.settings/)) expect(screen.getByTestId('follow-up-setting-modal')).toBeInTheDocument() fireEvent.click(screen.getByText('save-settings')) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - suggested: expect.objectContaining({ - enabled: true, - prompt: 'test prompt', - model: expect.objectContaining({ - provider: 'openai', - name: 'gpt-4o-mini', + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + suggested: expect.objectContaining({ + enabled: true, + prompt: 'test prompt', + model: expect.objectContaining({ + provider: 'openai', + name: 'gpt-4o-mini', + }), }), }), - })) + ) }) }) diff --git a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx index d221a5c075a162..a8404eb8cdaa97 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx @@ -15,7 +15,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () return { data: null } }, useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ - modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }], + modelList: [ + { provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }, + ], defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' }, currentModel: true, }), @@ -74,16 +76,18 @@ const defaultFeatures: Features = { annotationReply: { enabled: false }, } -const renderPanel = (props: Partial<{ - show: boolean - isChatMode: boolean - disabled: boolean - onChange: () => void - onClose: () => void - inWorkflow: boolean - showFileUpload: boolean - showAnnotationReply: boolean -}> = {}) => { +const renderPanel = ( + props: Partial<{ + show: boolean + isChatMode: boolean + disabled: boolean + onChange: () => void + onClose: () => void + inWorkflow: boolean + showFileUpload: boolean + showAnnotationReply: boolean + }> = {}, +) => { return render( <FeaturesProvider features={defaultFeatures}> <NewFeaturePanel diff --git a/web/app/components/base/features/new-feature-panel/__tests__/more-like-this.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/more-like-this.spec.tsx index 1d39b62f109b0f..a497dc365a10fa 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/more-like-this.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/more-like-this.spec.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import { FeaturesProvider } from '../../context' import MoreLikeThis from '../more-like-this' -const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => { +const renderWithProvider = (props: { disabled?: boolean; onChange?: OnFeaturesChange } = {}) => { return render( <FeaturesProvider> <MoreLikeThis disabled={props.disabled} onChange={props.onChange} /> diff --git a/web/app/components/base/features/new-feature-panel/__tests__/speech-to-text.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/speech-to-text.spec.tsx index 75e09627457b65..b2c1c0a673a7c6 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/speech-to-text.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/speech-to-text.spec.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import { FeaturesProvider } from '../../context' import SpeechToText from '../speech-to-text' -const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => { +const renderWithProvider = (props: { disabled?: boolean; onChange?: OnFeaturesChange } = {}) => { return render( <FeaturesProvider> <SpeechToText disabled={props.disabled ?? false} onChange={props.onChange} /> diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/annotation-ctrl-button.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/annotation-ctrl-button.spec.tsx index 7d39cf24e71aa5..0a6a06d250cfd6 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/annotation-ctrl-button.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/annotation-ctrl-button.spec.tsx @@ -12,7 +12,11 @@ let mockAnnotatedResponseUsage = 5 vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ plan: { - usage: { get annotatedResponse() { return mockAnnotatedResponseUsage } }, + usage: { + get annotatedResponse() { + return mockAnnotatedResponseUsage + }, + }, total: { annotatedResponse: 100 }, }, enableBilling: true, diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx index 39c12449804ab7..c162d5ef571f82 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx @@ -3,8 +3,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import ConfigParamModal from '../config-param-modal' let mockHooksReturn: { - modelList: { provider: { provider: string }, models: { model: string }[] }[] - defaultModel: { provider: { provider: string }, model: string } | undefined + modelList: { provider: { provider: string }; models: { model: string }[] }[] + defaultModel: { provider: { provider: string }; model: string } | undefined currentModel: boolean | undefined } = { modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }], @@ -23,10 +23,25 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/declaration })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ defaultModel, onSelect }: { defaultModel?: { provider: string, model: string }, onSelect: (val: { provider: string, model: string }) => void }) => ( - <div data-testid="model-selector" data-provider={defaultModel?.provider} data-model={defaultModel?.model}> + default: ({ + defaultModel, + onSelect, + }: { + defaultModel?: { provider: string; model: string } + onSelect: (val: { provider: string; model: string }) => void + }) => ( + <div + data-testid="model-selector" + data-provider={defaultModel?.provider} + data-model={defaultModel?.model} + > Model Selector - <button data-testid="select-model" onClick={() => onSelect({ provider: 'cohere', model: 'embed-english' })}>Select</button> + <button + data-testid="select-model" + onClick={() => onSelect({ provider: 'cohere', model: 'embed-english' })} + > + Select + </button> </div> ), })) @@ -36,14 +51,14 @@ vi.mock('@/config', () => ({ })) vi.mock('../score-slider', () => ({ - default: ({ value, onChange }: { value: number, onChange: (value: number) => void }) => ( + default: ({ value, onChange }: { value: number; onChange: (value: number) => void }) => ( <input role="slider" type="range" min={0} max={100} value={value} - onChange={e => onChange(Number((e.target as HTMLInputElement).value))} + onChange={(e) => onChange(Number((e.target as HTMLInputElement).value))} /> ), })) @@ -65,7 +80,9 @@ describe('ConfigParamModal', () => { vi.clearAllMocks() toastErrorSpy.mockClear() mockHooksReturn = { - modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }], + modelList: [ + { provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }, + ], defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' }, currentModel: true, } @@ -202,7 +219,7 @@ describe('ConfigParamModal', () => { // Click the confirm/save button const buttons = screen.getAllByRole('button') - const saveBtn = buttons.find(b => b.textContent?.includes('initSetup')) + const saveBtn = buttons.find((b) => b.textContent?.includes('initSetup')) fireEvent.click(saveBtn!) await waitFor(() => { @@ -237,7 +254,7 @@ describe('ConfigParamModal', () => { ) const buttons = screen.getAllByRole('button') - const saveBtn = buttons.find(b => b.textContent?.includes('initSetup')) + const saveBtn = buttons.find((b) => b.textContent?.includes('initSetup')) fireEvent.click(saveBtn!) expect(toastErrorSpy).toHaveBeenCalledWith('common.modelProvider.embeddingModel.required') @@ -313,7 +330,7 @@ describe('ConfigParamModal', () => { // Save const buttons = screen.getAllByRole('button') - const saveBtn = buttons.find(b => b.textContent?.includes('initSetup')) + const saveBtn = buttons.find((b) => b.textContent?.includes('initSetup')) fireEvent.click(saveBtn!) await waitFor(() => { @@ -341,7 +358,7 @@ describe('ConfigParamModal', () => { // Save const buttons = screen.getAllByRole('button') - const saveBtn = buttons.find(b => b.textContent?.includes('initSetup')) + const saveBtn = buttons.find((b) => b.textContent?.includes('initSetup')) fireEvent.click(saveBtn!) await waitFor(() => { @@ -369,7 +386,10 @@ describe('ConfigParamModal', () => { // Model selector should be initialized with the default model expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'openai') - expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'text-embedding-ada-002') + expect(screen.getByTestId('model-selector')).toHaveAttribute( + 'data-model', + 'text-embedding-ada-002', + ) }) it('should use ANNOTATION_DEFAULT score_threshold when config has no score_threshold', () => { @@ -408,7 +428,7 @@ describe('ConfigParamModal', () => { expect(screen.getByRole('slider')).toHaveValue('0') const buttons = screen.getAllByRole('button') - const saveBtn = buttons.find(b => b.textContent?.includes('initSetup')) + const saveBtn = buttons.find((b) => b.textContent?.includes('initSetup')) fireEvent.click(saveBtn!) await waitFor(() => { @@ -421,9 +441,12 @@ describe('ConfigParamModal', () => { it('should set loading state while saving', async () => { let resolveOnSave: () => void - const onSave = vi.fn().mockImplementation(() => new Promise<void>((resolve) => { - resolveOnSave = resolve - })) + const onSave = vi.fn().mockImplementation( + () => + new Promise<void>((resolve) => { + resolveOnSave = resolve + }), + ) const onHide = vi.fn() render( @@ -438,7 +461,7 @@ describe('ConfigParamModal', () => { // Click save const buttons = screen.getAllByRole('button') - const saveBtn = buttons.find(b => b.textContent?.includes('initSetup')) + const saveBtn = buttons.find((b) => b.textContent?.includes('initSetup')) fireEvent.click(saveBtn!) // While loading, clicking cancel should not call onHide @@ -467,7 +490,7 @@ describe('ConfigParamModal', () => { fireEvent.change(screen.getByRole('slider'), { target: { value: '96' } }) const buttons = screen.getAllByRole('button') - const saveBtn = buttons.find(b => b.textContent?.includes('initSetup')) + const saveBtn = buttons.find((b) => b.textContent?.includes('initSetup')) fireEvent.click(saveBtn!) await waitFor(() => { diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx index 4aae34387ec518..3178e109f7039d 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx @@ -25,30 +25,40 @@ const mockSetIsShowAnnotationFullModal = vi.fn((v: boolean) => { let capturedSetAnnotationConfig: ((config: Record<string, unknown>) => void) | null = null -vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config', () => ({ - default: ({ setAnnotationConfig }: { setAnnotationConfig: (config: Record<string, unknown>) => void }) => { - capturedSetAnnotationConfig = setAnnotationConfig - return { - handleEnableAnnotation: mockHandleEnableAnnotation, - handleDisableAnnotation: mockHandleDisableAnnotation, - get isShowAnnotationConfigInit() { return mockIsShowAnnotationConfigInit }, - setIsShowAnnotationConfigInit: mockSetIsShowAnnotationConfigInit, - get isShowAnnotationFullModal() { return mockIsShowAnnotationFullModal }, - setIsShowAnnotationFullModal: mockSetIsShowAnnotationFullModal, - } - }, -})) +vi.mock( + '@/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config', + () => ({ + default: ({ + setAnnotationConfig, + }: { + setAnnotationConfig: (config: Record<string, unknown>) => void + }) => { + capturedSetAnnotationConfig = setAnnotationConfig + return { + handleEnableAnnotation: mockHandleEnableAnnotation, + handleDisableAnnotation: mockHandleDisableAnnotation, + get isShowAnnotationConfigInit() { + return mockIsShowAnnotationConfigInit + }, + setIsShowAnnotationConfigInit: mockSetIsShowAnnotationConfigInit, + get isShowAnnotationFullModal() { + return mockIsShowAnnotationFullModal + }, + setIsShowAnnotationFullModal: mockSetIsShowAnnotationFullModal, + } + }, + }), +) vi.mock('@/app/components/billing/annotation-full/modal', () => ({ - default: ({ show, onHide }: { show: boolean, onHide: () => void }) => ( - show - ? ( - <div data-testid="annotation-full-modal"> - <button data-testid="full-hide" onClick={onHide}>Hide</button> - </div> - ) - : null - ), + default: ({ show, onHide }: { show: boolean; onHide: () => void }) => + show ? ( + <div data-testid="annotation-full-modal"> + <button data-testid="full-hide" onClick={onHide}> + Hide + </button> + </div> + ) : null, })) vi.mock('@/config', () => ({ @@ -57,7 +67,9 @@ vi.mock('@/config', () => ({ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ - modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }], + modelList: [ + { provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }, + ], defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' }, currentModel: true, }), @@ -70,9 +82,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/declaration })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: () => ( - <div data-testid="model-selector">Model Selector</div> - ), + default: () => <div data-testid="model-selector">Model Selector</div>, })) const defaultFeatures: Features = { @@ -88,7 +98,7 @@ const defaultFeatures: Features = { } const renderWithProvider = ( - props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}, + props: { disabled?: boolean; onChange?: OnFeaturesChange } = {}, featureOverrides?: Partial<Features>, ) => { const features = { ...defaultFeatures, ...featureOverrides } @@ -103,12 +113,14 @@ describe('AnnotationReply', () => { beforeEach(() => { vi.clearAllMocks() vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { - const message = args.map(arg => String(arg)).join(' ') - if (message.includes('A props object containing a "key" prop is being spread into JSX') - || message.includes('React keys must be passed directly to JSX without using spread')) { + const message = args.map((arg) => String(arg)).join(' ') + if ( + message.includes('A props object containing a "key" prop is being spread into JSX') || + message.includes('React keys must be passed directly to JSX without using spread') + ) { return } - originalConsoleError(...args as Parameters<typeof console.error>) + originalConsoleError(...(args as Parameters<typeof console.error>)) }) mockPathname = '/app/test-app-id/configuration' mockIsShowAnnotationConfigInit = false @@ -143,16 +155,19 @@ describe('AnnotationReply', () => { }) it('should call handleDisableAnnotation when switch is toggled off', () => { - renderWithProvider({}, { - annotationReply: { - enabled: true, - score_threshold: 0.9, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) fireEvent.click(screen.getByRole('switch')) @@ -160,62 +175,74 @@ describe('AnnotationReply', () => { }) it('should show score threshold and embedding model when enabled', () => { - renderWithProvider({}, { - annotationReply: { - enabled: true, - score_threshold: 0.9, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) expect(screen.getByText('0.9')).toBeInTheDocument() expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument() }) it('should show zero score threshold when enabled', () => { - renderWithProvider({}, { - annotationReply: { - enabled: true, - score_threshold: 0, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + score_threshold: 0, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) expect(screen.getByText('0')).toBeInTheDocument() expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument() }) it('should show dash when score threshold is not set', () => { - renderWithProvider({}, { - annotationReply: { - enabled: true, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) expect(screen.getByText('-')).toBeInTheDocument() }) it('should show buttons when hovering over enabled feature', () => { - renderWithProvider({}, { - annotationReply: { - enabled: true, - score_threshold: 0.9, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -225,16 +252,19 @@ describe('AnnotationReply', () => { }) it('should call setIsShowAnnotationConfigInit when params button is clicked', () => { - renderWithProvider({}, { - annotationReply: { - enabled: true, - score_threshold: 0.9, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -244,16 +274,19 @@ describe('AnnotationReply', () => { }) it('should navigate to annotations page when cache management is clicked', () => { - renderWithProvider({}, { - annotationReply: { - enabled: true, - score_threshold: 0.9, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -264,16 +297,19 @@ describe('AnnotationReply', () => { it('should fallback appId to empty string when pathname does not match', () => { mockPathname = '/apps/no-match' - renderWithProvider({}, { - annotationReply: { - enabled: true, - score_threshold: 0.9, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -309,16 +345,19 @@ describe('AnnotationReply', () => { it('should call handleEnableAnnotation when config save is clicked', async () => { mockIsShowAnnotationConfigInit = true - renderWithProvider({}, { - annotationReply: { - enabled: true, - score_threshold: 0.9, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) await act(async () => { fireEvent.click(screen.getByText(/initSetup\.confirmBtn/)) @@ -346,16 +385,19 @@ describe('AnnotationReply', () => { it('should call handleEnableAnnotation and hide config modal on save', async () => { mockIsShowAnnotationConfigInit = true - renderWithProvider({}, { - annotationReply: { - enabled: true, - score_threshold: 0.9, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) await act(async () => { fireEvent.click(screen.getByText(/initSetup\.confirmBtn/)) @@ -376,16 +418,19 @@ describe('AnnotationReply', () => { it('should update features and call onChange when updateAnnotationReply is invoked', () => { const onChange = vi.fn() - renderWithProvider({ onChange }, { - annotationReply: { - enabled: true, - score_threshold: 0.9, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + { onChange }, + { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) // The captured setAnnotationConfig is the component's updateAnnotationReply callback expect(capturedSetAnnotationConfig).not.toBeNull() @@ -404,38 +449,46 @@ describe('AnnotationReply', () => { }) it('should update features without calling onChange when onChange is not provided', () => { - renderWithProvider({}, { - annotationReply: { - enabled: true, - score_threshold: 0.9, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) // Should not throw when onChange is not provided expect(capturedSetAnnotationConfig).not.toBeNull() - expect(() => act(() => { - capturedSetAnnotationConfig!({ - enabled: true, - score_threshold: 0.7, - }) - })).not.toThrow() + expect(() => + act(() => { + capturedSetAnnotationConfig!({ + enabled: true, + score_threshold: 0.7, + }) + }), + ).not.toThrow() }) it('should hide info display when hovering over enabled feature', () => { - renderWithProvider({}, { - annotationReply: { - enabled: true, - score_threshold: 0.9, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) // Before hover, info is visible expect(screen.getByText('0.9')).toBeInTheDocument() @@ -449,16 +502,19 @@ describe('AnnotationReply', () => { }) it('should show info display again when mouse leaves', () => { - renderWithProvider({}, { - annotationReply: { - enabled: true, - score_threshold: 0.9, - embedding_model: { - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-ada-002', + renderWithProvider( + {}, + { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, }, }, - }) + ) const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')! fireEvent.mouseEnter(card) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/use-annotation-config.spec.ts b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/use-annotation-config.spec.ts index 6f6deb7ae1732d..b001f0542c3709 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/use-annotation-config.spec.ts +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/use-annotation-config.spec.ts @@ -42,11 +42,13 @@ describe('useAnnotationConfig', () => { it('should initialize with annotation config init hidden', () => { const setAnnotationConfig = vi.fn() - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: defaultConfig, - setAnnotationConfig, - })) + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + }), + ) expect(result.current.isShowAnnotationConfigInit).toBe(false) expect(result.current.isShowAnnotationFullModal).toBe(false) @@ -54,11 +56,13 @@ describe('useAnnotationConfig', () => { it('should show annotation config init modal', () => { const setAnnotationConfig = vi.fn() - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: defaultConfig, - setAnnotationConfig, - })) + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + }), + ) act(() => { result.current.setIsShowAnnotationConfigInit(true) @@ -69,11 +73,13 @@ describe('useAnnotationConfig', () => { it('should hide annotation config init modal', () => { const setAnnotationConfig = vi.fn() - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: defaultConfig, - setAnnotationConfig, - })) + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + }), + ) act(() => { result.current.setIsShowAnnotationConfigInit(true) @@ -87,17 +93,22 @@ describe('useAnnotationConfig', () => { it('should enable annotation and update config', async () => { const setAnnotationConfig = vi.fn() - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: defaultConfig, - setAnnotationConfig, - })) + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + }), + ) await act(async () => { - await result.current.handleEnableAnnotation({ - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-3-small', - }, 0.95) + await result.current.handleEnableAnnotation( + { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-small', + }, + 0.95, + ) }) expect(setAnnotationConfig).toHaveBeenCalled() @@ -109,11 +120,13 @@ describe('useAnnotationConfig', () => { it('should disable annotation and update config', async () => { const enabledConfig = { ...defaultConfig, enabled: true } const setAnnotationConfig = vi.fn() - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: enabledConfig, - setAnnotationConfig, - })) + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: enabledConfig, + setAnnotationConfig, + }), + ) await act(async () => { await result.current.handleDisableAnnotation({ @@ -129,11 +142,13 @@ describe('useAnnotationConfig', () => { it('should not disable when already disabled', async () => { const setAnnotationConfig = vi.fn() - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: defaultConfig, - setAnnotationConfig, - })) + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + }), + ) await act(async () => { await result.current.handleDisableAnnotation({ @@ -147,11 +162,13 @@ describe('useAnnotationConfig', () => { it('should set score threshold', () => { const setAnnotationConfig = vi.fn() - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: defaultConfig, - setAnnotationConfig, - })) + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + }), + ) act(() => { result.current.setScore(0.85) @@ -165,17 +182,22 @@ describe('useAnnotationConfig', () => { it('should preserve zero score threshold when enabling annotation', async () => { const zeroScoreConfig = { ...defaultConfig, score_threshold: 0 } const setAnnotationConfig = vi.fn() - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: zeroScoreConfig, - setAnnotationConfig, - })) + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: zeroScoreConfig, + setAnnotationConfig, + }), + ) await act(async () => { - await result.current.handleEnableAnnotation({ - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-3-small', - }, 0) + await result.current.handleEnableAnnotation( + { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-small', + }, + 0, + ) }) expect(updateAnnotationStatus).toHaveBeenCalledWith( @@ -193,11 +215,13 @@ describe('useAnnotationConfig', () => { it('should set score and embedding model together', () => { const setAnnotationConfig = vi.fn() - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: defaultConfig, - setAnnotationConfig, - })) + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + }), + ) act(() => { result.current.setScore(0.95, { @@ -215,11 +239,13 @@ describe('useAnnotationConfig', () => { it('should show annotation full modal instead of config init when annotation is full', () => { mockIsAnnotationFull = true const setAnnotationConfig = vi.fn() - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: defaultConfig, - setAnnotationConfig, - })) + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + }), + ) act(() => { result.current.setIsShowAnnotationConfigInit(true) @@ -232,11 +258,13 @@ describe('useAnnotationConfig', () => { it('should not enable annotation when annotation is full', async () => { mockIsAnnotationFull = true const setAnnotationConfig = vi.fn() - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: defaultConfig, - setAnnotationConfig, - })) + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + }), + ) await act(async () => { await result.current.handleEnableAnnotation({ @@ -249,19 +277,27 @@ describe('useAnnotationConfig', () => { }) it('should set default score_threshold when enabling without one', async () => { - const configWithoutThreshold = { ...defaultConfig, score_threshold: undefined as unknown as number } + const configWithoutThreshold = { + ...defaultConfig, + score_threshold: undefined as unknown as number, + } const setAnnotationConfig = vi.fn() - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: configWithoutThreshold, - setAnnotationConfig, - })) + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: configWithoutThreshold, + setAnnotationConfig, + }), + ) await act(async () => { - await result.current.handleEnableAnnotation({ - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-3-small', - }, 0.95) + await result.current.handleEnableAnnotation( + { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-small', + }, + 0.95, + ) }) expect(setAnnotationConfig).toHaveBeenCalled() @@ -276,20 +312,29 @@ describe('useAnnotationConfig', () => { const sleepMock = vi.mocked(sleep) queryJobStatusMock - .mockResolvedValueOnce({ job_status: 'pending' } as unknown as Awaited<ReturnType<typeof queryAnnotationJobStatus>>) - .mockResolvedValueOnce({ job_status: 'completed' } as unknown as Awaited<ReturnType<typeof queryAnnotationJobStatus>>) - - const { result } = renderHook(() => useAnnotationConfig({ - appId: 'test-app', - annotationConfig: defaultConfig, - setAnnotationConfig, - })) + .mockResolvedValueOnce({ job_status: 'pending' } as unknown as Awaited< + ReturnType<typeof queryAnnotationJobStatus> + >) + .mockResolvedValueOnce({ job_status: 'completed' } as unknown as Awaited< + ReturnType<typeof queryAnnotationJobStatus> + >) + + const { result } = renderHook(() => + useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + }), + ) await act(async () => { - await result.current.handleEnableAnnotation({ - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-3-small', - }, 0.95) + await result.current.handleEnableAnnotation( + { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-small', + }, + 0.95, + ) }) expect(queryJobStatusMock).toHaveBeenCalledTimes(2) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx index 5c9277962bbd3b..ccf136ec7d8cdb 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx @@ -19,10 +19,19 @@ type Props = Readonly<{ onAdded: (annotationId: string, authorName: string) => void onEdit: () => void }> -const AnnotationCtrlButton: FC<Props> = ({ cached, query, answer, appId, messageId, onAdded, onEdit }) => { +const AnnotationCtrlButton: FC<Props> = ({ + cached, + query, + answer, + appId, + messageId, + onAdded, + onEdit, +}) => { const { t } = useTranslation() const { plan, enableBilling } = useProviderContext() - const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse) + const isAnnotationFull = + enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse const { setShowAnnotationFullModal } = useModalContext() const handleAdd = async () => { if (isAnnotationFull) { @@ -34,7 +43,7 @@ const AnnotationCtrlButton: FC<Props> = ({ cached, query, answer, appId, message question: query, answer, }) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' }) as string) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }) as string) onAdded(res.id, res.account?.name ?? '') } return ( @@ -42,28 +51,28 @@ const AnnotationCtrlButton: FC<Props> = ({ cached, query, answer, appId, message {cached && ( <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton onClick={onEdit}> <RiEditLine className="size-4" /> </ActionButton> - )} + } /> <TooltipContent> - {t($ => $['feature.annotation.edit'], { ns: 'appDebug' })} + {t(($) => $['feature.annotation.edit'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> )} {!cached && answer && ( <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton onClick={handleAdd}> <RiFileEditLine className="size-4" /> </ActionButton> - )} + } /> <TooltipContent> - {t($ => $['feature.annotation.add'], { ns: 'appDebug' })} + {t(($) => $['feature.annotation.add'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> )} diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx index f5f95de7255921..9652ce9c13cd3b 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx @@ -18,61 +18,85 @@ type Props = Readonly<{ appId: string isShow: boolean onHide: () => void - onSave: (embeddingModel: { - embedding_provider_name: string - embedding_model_name: string - }, score: number) => void + onSave: ( + embeddingModel: { + embedding_provider_name: string + embedding_model_name: string + }, + score: number, + ) => void isInit?: boolean annotationConfig: AnnotationReplyConfig }> -const ConfigParamModal: FC<Props> = ({ isShow, onHide: doHide, onSave, isInit, annotationConfig: oldAnnotationConfig }) => { +const ConfigParamModal: FC<Props> = ({ + isShow, + onHide: doHide, + onSave, + isInit, + annotationConfig: oldAnnotationConfig, +}) => { const { t } = useTranslation() - const { modelList: embeddingsModelList, defaultModel: embeddingsDefaultModel, currentModel: isEmbeddingsDefaultModelValid } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textEmbedding) + const { + modelList: embeddingsModelList, + defaultModel: embeddingsDefaultModel, + currentModel: isEmbeddingsDefaultModelValid, + } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textEmbedding) const [annotationConfig, setAnnotationConfig] = useState(oldAnnotationConfig) const [isLoading, setLoading] = useState(false) - const [embeddingModel, setEmbeddingModel] = useState(oldAnnotationConfig.embedding_model - ? { - providerName: oldAnnotationConfig.embedding_model.embedding_provider_name, - modelName: oldAnnotationConfig.embedding_model.embedding_model_name, - } - : (embeddingsDefaultModel + const [embeddingModel, setEmbeddingModel] = useState( + oldAnnotationConfig.embedding_model + ? { + providerName: oldAnnotationConfig.embedding_model.embedding_provider_name, + modelName: oldAnnotationConfig.embedding_model.embedding_model_name, + } + : embeddingsDefaultModel ? { providerName: embeddingsDefaultModel.provider.provider, modelName: embeddingsDefaultModel.model, } - : undefined)) + : undefined, + ) const onHide = () => { - if (!isLoading) - doHide() + if (!isLoading) doHide() } const handleSave = async () => { - if (!embeddingModel || !embeddingModel.modelName || (embeddingModel.modelName === embeddingsDefaultModel?.model && !isEmbeddingsDefaultModelValid)) { - toast.error(t($ => $['modelProvider.embeddingModel.required'], { ns: 'common' })) + if ( + !embeddingModel || + !embeddingModel.modelName || + (embeddingModel.modelName === embeddingsDefaultModel?.model && !isEmbeddingsDefaultModelValid) + ) { + toast.error(t(($) => $['modelProvider.embeddingModel.required'], { ns: 'common' })) return } setLoading(true) - await onSave({ - embedding_provider_name: embeddingModel.providerName, - embedding_model_name: embeddingModel.modelName, - }, annotationConfig.score_threshold) + await onSave( + { + embedding_provider_name: embeddingModel.providerName, + embedding_model_name: embeddingModel.modelName, + }, + annotationConfig.score_threshold, + ) setLoading(false) } return ( <Dialog open={isShow} onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DialogContent className="mt-14! w-[640px]! max-w-none! border-none p-6! text-left align-middle"> - <div className="mb-2 title-2xl-semi-bold text-text-primary"> - {t($ => $[`initSetup.${isInit ? 'title' : 'configTitle'}`], { ns: 'appAnnotation' })} + {t(($) => $[`initSetup.${isInit ? 'title' : 'configTitle'}`], { ns: 'appAnnotation' })} </div> <div className="mt-6 space-y-3"> - <Item title={t($ => $['feature.annotation.scoreThreshold.title'], { ns: 'appDebug' })} tooltip={t($ => $['feature.annotation.scoreThreshold.description'], { ns: 'appDebug' })}> + <Item + title={t(($) => $['feature.annotation.scoreThreshold.title'], { ns: 'appDebug' })} + tooltip={t(($) => $['feature.annotation.scoreThreshold.description'], { + ns: 'appDebug', + })} + > <ScoreSlider className="mt-1" value={(annotationConfig.score_threshold ?? ANNOTATION_DEFAULT.score_threshold) * 100} @@ -85,13 +109,18 @@ const ConfigParamModal: FC<Props> = ({ isShow, onHide: doHide, onSave, isInit, a /> </Item> - <Item title={t($ => $['modelProvider.embeddingModel.key'], { ns: 'common' })} tooltip={t($ => $.embeddingModelSwitchTip, { ns: 'appAnnotation' })}> + <Item + title={t(($) => $['modelProvider.embeddingModel.key'], { ns: 'common' })} + tooltip={t(($) => $.embeddingModelSwitchTip, { ns: 'appAnnotation' })} + > <div className="pt-1"> <ModelSelector - defaultModel={embeddingModel && { - provider: embeddingModel.providerName, - model: embeddingModel.modelName, - }} + defaultModel={ + embeddingModel && { + provider: embeddingModel.providerName, + model: embeddingModel.modelName, + } + } modelList={embeddingsModelList} onSelect={(val) => { setEmbeddingModel({ @@ -105,10 +134,14 @@ const ConfigParamModal: FC<Props> = ({ isShow, onHide: doHide, onSave, isInit, a </div> <div className="mt-6 flex justify-end gap-2"> - <Button onClick={onHide}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> + <Button onClick={onHide}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> <Button variant="primary" onClick={handleSave} loading={isLoading}> <div></div> - <div>{t($ => $[`initSetup.${isInit ? 'confirmBtn' : 'configConfirmBtn'}`], { ns: 'appAnnotation' })}</div> + <div> + {t(($) => $[`initSetup.${isInit ? 'confirmBtn' : 'configConfirmBtn'}`], { + ns: 'appAnnotation', + })} + </div> </Button> </div> </DialogContent> diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.tsx index 16cbefe87a3587..bfe172438c4438 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import * as React from 'react' import { Infotip } from '@/app/components/base/infotip' -export const Item: FC<{ title: string, tooltip: string, children: React.JSX.Element }> = ({ +export const Item: FC<{ title: string; tooltip: string; children: React.JSX.Element }> = ({ title, tooltip, children, @@ -12,7 +12,10 @@ export const Item: FC<{ title: string, tooltip: string, children: React.JSX.Elem <div> <div className="mb-1 flex items-center space-x-1"> <div className="py-1 system-sm-semibold text-text-secondary">{title}</div> - <Infotip aria-label={tooltip} popupClassName="max-w-[200px] system-sm-regular text-text-secondary"> + <Infotip + aria-label={tooltip} + popupClassName="max-w-[200px] system-sm-regular text-text-secondary" + > {tooltip} </Infotip> </div> diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx index e4a5400f57270a..c3149562e3d2ae 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx @@ -20,30 +20,26 @@ type Props = Readonly<{ onChange?: OnFeaturesChange }> -const AnnotationReply = ({ - disabled, - onChange, -}: Props) => { +const AnnotationReply = ({ disabled, onChange }: Props) => { const { t } = useTranslation() const router = useRouter() const pathname = usePathname() const matched = /\/app\/([^/]+)/.exec(pathname) - const appId = (matched?.length && matched[1]) ? matched[1] : '' + const appId = matched?.length && matched[1] ? matched[1] : '' const featuresStore = useFeaturesStore() - const annotationReply = useFeatures(s => s.features.annotationReply) + const annotationReply = useFeatures((s) => s.features.annotationReply) - const updateAnnotationReply = useCallback((newConfig: AnnotationReplyConfig) => { - const { - features, - setFeatures, - } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - draft.annotationReply = newConfig - }) - setFeatures(newFeatures) - if (onChange) - onChange(newFeatures) - }, [featuresStore, onChange]) + const updateAnnotationReply = useCallback( + (newConfig: AnnotationReplyConfig) => { + const { features, setFeatures } = featuresStore!.getState() + const newFeatures = produce(features, (draft) => { + draft.annotationReply = newConfig + }) + setFeatures(newFeatures) + if (onChange) onChange(newFeatures) + }, + [featuresStore, onChange], + ) const { handleEnableAnnotation, @@ -54,7 +50,7 @@ const AnnotationReply = ({ setIsShowAnnotationFullModal, } = useAnnotationConfig({ appId, - annotationConfig: annotationReply as any || { + annotationConfig: (annotationReply as any) || { id: '', enabled: false, score_threshold: ANNOTATION_DEFAULT.score_threshold, @@ -66,54 +62,69 @@ const AnnotationReply = ({ setAnnotationConfig: updateAnnotationReply, }) - const handleSwitch = useCallback((enabled: boolean) => { - if (enabled) - setIsShowAnnotationConfigInit(true) - else - handleDisableAnnotation(annotationReply?.embedding_model as any) - }, [annotationReply?.embedding_model, handleDisableAnnotation, setIsShowAnnotationConfigInit]) + const handleSwitch = useCallback( + (enabled: boolean) => { + if (enabled) setIsShowAnnotationConfigInit(true) + else handleDisableAnnotation(annotationReply?.embedding_model as any) + }, + [annotationReply?.embedding_model, handleDisableAnnotation, setIsShowAnnotationConfigInit], + ) const [isHovering, setIsHovering] = useState(false) return ( <> <FeatureCard - icon={( + icon={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-indigo-indigo-600 p-1 shadow-xs"> <MessageFast className="size-4 text-text-primary-on-surface" /> </div> - )} - title={t($ => $['feature.annotation.title'], { ns: 'appDebug' })} + } + title={t(($) => $['feature.annotation.title'], { ns: 'appDebug' })} value={!!annotationReply?.enabled} - onChange={state => handleSwitch(state)} + onChange={(state) => handleSwitch(state)} onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} disabled={disabled} > <> {!annotationReply?.enabled && ( - <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">{t($ => $['feature.annotation.description'], { ns: 'appDebug' })}</div> + <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"> + {t(($) => $['feature.annotation.description'], { ns: 'appDebug' })} + </div> )} {!!annotationReply?.enabled && ( <> {!isHovering && ( <div className="flex items-center gap-4 pt-0.5"> <div className=""> - <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['feature.annotation.scoreThreshold.title'], { ns: 'appDebug' })}</div> - <div className="system-xs-regular text-text-secondary">{annotationReply.score_threshold ?? '-'}</div> + <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['feature.annotation.scoreThreshold.title'], { ns: 'appDebug' })} + </div> + <div className="system-xs-regular text-text-secondary"> + {annotationReply.score_threshold ?? '-'} + </div> </div> <div className="h-[27px] w-px rotate-12 bg-divider-subtle"></div> <div className=""> - <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['modelProvider.embeddingModel.key'], { ns: 'common' })}</div> - <div className="system-xs-regular text-text-secondary">{annotationReply.embedding_model?.embedding_model_name}</div> + <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['modelProvider.embeddingModel.key'], { ns: 'common' })} + </div> + <div className="system-xs-regular text-text-secondary"> + {annotationReply.embedding_model?.embedding_model_name} + </div> </div> </div> )} {isHovering && ( <div className="flex items-center justify-between"> - <Button className="w-[178px]" onClick={() => setIsShowAnnotationConfigInit(true)} disabled={disabled}> + <Button + className="w-[178px]" + onClick={() => setIsShowAnnotationConfigInit(true)} + disabled={disabled} + > <RiEqualizer2Line className="mr-1 size-4" /> - {t($ => $['operation.params'], { ns: 'common' })} + {t(($) => $['operation.params'], { ns: 'common' })} </Button> <Button className="w-[178px]" @@ -122,7 +133,7 @@ const AnnotationReply = ({ }} > <RiExternalLinkLine className="mr-1 size-4" /> - {t($ => $['feature.annotation.cacheManagement'], { ns: 'appDebug' })} + {t(($) => $['feature.annotation.cacheManagement'], { ns: 'appDebug' })} </Button> </div> )} diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/__tests__/index.spec.tsx index 97493ab4418760..3d455d2b75c673 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/__tests__/index.spec.tsx @@ -2,7 +2,8 @@ import { render, screen } from '@testing-library/react' import ScoreSlider from '../index' describe('ScoreSlider', () => { - const getSliderInput = () => screen.getByLabelText('appDebug.feature.annotation.scoreThreshold.title') + const getSliderInput = () => + screen.getByLabelText('appDebug.feature.annotation.scoreThreshold.title') beforeEach(() => { vi.clearAllMocks() @@ -20,11 +21,15 @@ describe('ScoreSlider', () => { expect(screen.getByText('0.0')).toBeInTheDocument() expect(screen.getByText('1.0')).toBeInTheDocument() expect(screen.getByText(/feature\.annotation\.scoreThreshold\.easyMatch/)).toBeInTheDocument() - expect(screen.getByText(/feature\.annotation\.scoreThreshold\.accurateMatch/)).toBeInTheDocument() + expect( + screen.getByText(/feature\.annotation\.scoreThreshold\.accurateMatch/), + ).toBeInTheDocument() }) it('should render with custom className', () => { - const { container } = render(<ScoreSlider className="custom-class" value={90} onChange={vi.fn()} />) + const { container } = render( + <ScoreSlider className="custom-class" value={90} onChange={vi.fn()} />, + ) expect(getSliderInput()).toBeInTheDocument() expect(container.firstChild).toHaveClass('custom-class') diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.tsx index 6a1786b9e975f9..5d5f27cfa8acde 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.tsx @@ -11,8 +11,7 @@ type Props = Readonly<{ }> const clamp = (value: number, min: number, max: number) => { - if (!Number.isFinite(value)) - return min + if (!Number.isFinite(value)) return min return Math.min(Math.max(value, min), max) } @@ -20,11 +19,7 @@ const clamp = (value: number, min: number, max: number) => { const SCORE_MIN = 0 const SCORE_MAX = 100 -const ScoreSlider: FC<Props> = ({ - className, - value, - onChange, -}) => { +const ScoreSlider: FC<Props> = ({ className, value, onChange }) => { const { t } = useTranslation() const safeValue = clamp(value, SCORE_MIN, SCORE_MAX) @@ -38,7 +33,7 @@ const ScoreSlider: FC<Props> = ({ max={SCORE_MAX} step={1} onValueChange={onChange} - aria-label={t($ => $['feature.annotation.scoreThreshold.title'], { ns: 'appDebug' })} + aria-label={t(($) => $['feature.annotation.scoreThreshold.title'], { ns: 'appDebug' })} /> <div className="pointer-events-none absolute top-[-16px] system-sm-semibold text-text-primary" @@ -54,12 +49,16 @@ const ScoreSlider: FC<Props> = ({ <div className="flex space-x-1 text-util-colors-cyan-cyan-500"> <div>0.0</div> <div>·</div> - <div>{t($ => $['feature.annotation.scoreThreshold.easyMatch'], { ns: 'appDebug' })}</div> + <div> + {t(($) => $['feature.annotation.scoreThreshold.easyMatch'], { ns: 'appDebug' })} + </div> </div> <div className="flex space-x-1 text-util-colors-blue-blue-500"> <div>1.0</div> <div>·</div> - <div>{t($ => $['feature.annotation.scoreThreshold.accurateMatch'], { ns: 'appDebug' })}</div> + <div> + {t(($) => $['feature.annotation.scoreThreshold.accurateMatch'], { ns: 'appDebug' })} + </div> </div> </div> </div> diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts b/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts index 64c714df4e43bc..8cde3021d1f2ab 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts @@ -14,13 +14,10 @@ type Params = { annotationConfig: AnnotationReplyConfig setAnnotationConfig: (annotationConfig: AnnotationReplyConfig) => void } -const useAnnotationConfig = ({ - appId, - annotationConfig, - setAnnotationConfig, -}: Params) => { +const useAnnotationConfig = ({ appId, annotationConfig, setAnnotationConfig }: Params) => { const { plan, enableBilling } = useProviderContext() - const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse) + const isAnnotationFull = + enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse const [isShowAnnotationFullModal, setIsShowAnnotationFullModal] = useState(false) const [isShowAnnotationConfigInit, doSetIsShowAnnotationConfigInit] = React.useState(false) const setIsShowAnnotationConfigInit = (isShow: boolean) => { @@ -37,43 +34,50 @@ const useAnnotationConfig = ({ while (!isCompleted) { const res: any = await queryAnnotationJobStatus(appId, status, jobId) isCompleted = res.job_status === JobStatus.completed - if (isCompleted) - break + if (isCompleted) break await sleep(2000) } } const handleEnableAnnotation = async (embeddingModel: EmbeddingModelConfig, score?: number) => { - if (isAnnotationFull) - return + if (isAnnotationFull) return - const { job_id: jobId }: any = await updateAnnotationStatus(appId, AnnotationEnableStatus.enable, embeddingModel, score) + const { job_id: jobId }: any = await updateAnnotationStatus( + appId, + AnnotationEnableStatus.enable, + embeddingModel, + score, + ) await ensureJobCompleted(jobId, AnnotationEnableStatus.enable) - setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => { - draft.enabled = true - draft.embedding_model = embeddingModel - if (draft.score_threshold === undefined || draft.score_threshold === null) - draft.score_threshold = ANNOTATION_DEFAULT.score_threshold - })) + setAnnotationConfig( + produce(annotationConfig, (draft: AnnotationReplyConfig) => { + draft.enabled = true + draft.embedding_model = embeddingModel + if (draft.score_threshold === undefined || draft.score_threshold === null) + draft.score_threshold = ANNOTATION_DEFAULT.score_threshold + }), + ) } const setScore = (score: number, embeddingModel?: EmbeddingModelConfig) => { - setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => { - draft.score_threshold = score - if (embeddingModel) - draft.embedding_model = embeddingModel - })) + setAnnotationConfig( + produce(annotationConfig, (draft: AnnotationReplyConfig) => { + draft.score_threshold = score + if (embeddingModel) draft.embedding_model = embeddingModel + }), + ) } const handleDisableAnnotation = async (embeddingModel: EmbeddingModelConfig) => { - if (!annotationConfig.enabled) - return + if (!annotationConfig.enabled) return await updateAnnotationStatus(appId, AnnotationEnableStatus.disable, embeddingModel) - setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => { - draft.enabled = false - })) + setAnnotationConfig( + produce(annotationConfig, (draft: AnnotationReplyConfig) => { + draft.enabled = false + }), + ) } return { diff --git a/web/app/components/base/features/new-feature-panel/citation.tsx b/web/app/components/base/features/new-feature-panel/citation.tsx index 7c925069329733..88979d5aa32cab 100644 --- a/web/app/components/base/features/new-feature-panel/citation.tsx +++ b/web/app/components/base/features/new-feature-panel/citation.tsx @@ -13,42 +13,38 @@ type Props = Readonly<{ onChange?: OnFeaturesChange }> -const Citation = ({ - disabled, - onChange, -}: Props) => { +const Citation = ({ disabled, onChange }: Props) => { const { t } = useTranslation() - const features = useFeatures(s => s.features) + const features = useFeatures((s) => s.features) const featuresStore = useFeaturesStore() - const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (type: FeatureEnum, enabled: boolean) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - draft[type] = { - ...draft[type], - enabled, - } - }) - setFeatures(newFeatures) - if (onChange) - onChange(newFeatures) - }, [featuresStore, onChange]) + const newFeatures = produce(features, (draft) => { + draft[type] = { + ...draft[type], + enabled, + } + }) + setFeatures(newFeatures) + if (onChange) onChange(newFeatures) + }, + [featuresStore, onChange], + ) return ( <FeatureCard - icon={( + icon={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-warning-warning-500 p-1 shadow-xs"> <Citations className="size-4 text-text-primary-on-surface" /> </div> - )} - title={t($ => $['feature.citation.title'], { ns: 'appDebug' })} + } + title={t(($) => $['feature.citation.title'], { ns: 'appDebug' })} value={!!features.citation?.enabled} - description={t($ => $['feature.citation.description'], { ns: 'appDebug' })!} - onChange={state => handleChange(FeatureEnum.citation, state)} + description={t(($) => $['feature.citation.description'], { ns: 'appDebug' })!} + onChange={(state) => handleChange(FeatureEnum.citation, state)} disabled={disabled} /> ) diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/index.spec.tsx index 9e74a1d0df583c..a095e18097742f 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/index.spec.tsx @@ -24,7 +24,7 @@ const defaultFeatures: Features = { } const renderWithProvider = ( - props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}, + props: { disabled?: boolean; onChange?: OnFeaturesChange } = {}, featureOverrides?: Partial<Features>, ) => { const features = { ...defaultFeatures, ...featureOverrides } @@ -68,25 +68,34 @@ describe('ConversationOpener', () => { }) it('should show opening statement when enabled and not hovering', () => { - renderWithProvider({}, { - opening: { enabled: true, opening_statement: 'Welcome to the app!' }, - }) + renderWithProvider( + {}, + { + opening: { enabled: true, opening_statement: 'Welcome to the app!' }, + }, + ) expect(screen.getByText('Welcome to the app!'))!.toBeInTheDocument() }) it('should show placeholder when enabled but no opening statement', () => { - renderWithProvider({}, { - opening: { enabled: true, opening_statement: '' }, - }) + renderWithProvider( + {}, + { + opening: { enabled: true, opening_statement: '' }, + }, + ) expect(screen.getByText(/openingStatement\.placeholder/))!.toBeInTheDocument() }) it('should show edit button when hovering over enabled feature', () => { - renderWithProvider({}, { - opening: { enabled: true, opening_statement: 'Hello' }, - }) + renderWithProvider( + {}, + { + opening: { enabled: true, opening_statement: 'Hello' }, + }, + ) const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -95,9 +104,12 @@ describe('ConversationOpener', () => { }) it('should open modal when edit button is clicked', () => { - renderWithProvider({}, { - opening: { enabled: true, opening_statement: 'Hello' }, - }) + renderWithProvider( + {}, + { + opening: { enabled: true, opening_statement: 'Hello' }, + }, + ) const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -107,9 +119,12 @@ describe('ConversationOpener', () => { }) it('should not open modal when disabled', () => { - renderWithProvider({ disabled: true }, { - opening: { enabled: true, opening_statement: 'Hello' }, - }) + renderWithProvider( + { disabled: true }, + { + opening: { enabled: true, opening_statement: 'Hello' }, + }, + ) const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -119,9 +134,12 @@ describe('ConversationOpener', () => { }) it('should pass opening data to modal', () => { - renderWithProvider({}, { - opening: { enabled: true, opening_statement: 'Hello' }, - }) + renderWithProvider( + {}, + { + opening: { enabled: true, opening_statement: 'Hello' }, + }, + ) const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -135,9 +153,12 @@ describe('ConversationOpener', () => { it('should invoke onSaveCallback and update features', () => { const onChange = vi.fn() - renderWithProvider({ onChange }, { - opening: { enabled: true, opening_statement: 'Hello' }, - }) + renderWithProvider( + { onChange }, + { + opening: { enabled: true, opening_statement: 'Hello' }, + }, + ) const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -153,9 +174,12 @@ describe('ConversationOpener', () => { it('should invoke onCancelCallback', () => { const onChange = vi.fn() - renderWithProvider({ onChange }, { - opening: { enabled: true, opening_statement: 'Hello' }, - }) + renderWithProvider( + { onChange }, + { + opening: { enabled: true, opening_statement: 'Hello' }, + }, + ) const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -168,9 +192,12 @@ describe('ConversationOpener', () => { }) it('should show info and hide when hovering over enabled feature', () => { - renderWithProvider({}, { - opening: { enabled: true, opening_statement: 'Welcome!' }, - }) + renderWithProvider( + {}, + { + opening: { enabled: true, opening_statement: 'Welcome!' }, + }, + ) // Before hover, opening statement visible // Before hover, opening statement visible @@ -191,9 +218,12 @@ describe('ConversationOpener', () => { }) it('should return early from opener handler when disabled and hovered', () => { - renderWithProvider({ disabled: true }, { - opening: { enabled: true, opening_statement: 'Hello' }, - }) + renderWithProvider( + { disabled: true }, + { + opening: { enabled: true, opening_statement: 'Hello' }, + }, + ) const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -203,9 +233,12 @@ describe('ConversationOpener', () => { }) it('should run save and cancel callbacks without onChange', () => { - renderWithProvider({}, { - opening: { enabled: true, opening_statement: 'Hello' }, - }) + renderWithProvider( + {}, + { + opening: { enabled: true, opening_statement: 'Hello' }, + }, + ) const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! fireEvent.mouseEnter(card) diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx index e309c25310443a..79f5ce778b1df0 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx @@ -17,15 +17,23 @@ vi.mock('@/utils/var', () => ({ })) vi.mock('@/app/components/app/configuration/config-prompt/confirm-add-var', () => ({ - default: ({ varNameArr, onConfirm, onCancel }: { + default: ({ + varNameArr, + onConfirm, + onCancel, + }: { varNameArr: string[] onConfirm: () => void onCancel: () => void }) => ( <div data-testid="confirm-add-var"> <span>{varNameArr.join(',')}</span> - <button data-testid="confirm-add" onClick={onConfirm}>Confirm</button> - <button data-testid="cancel-add" onClick={onCancel}>Cancel</button> + <button data-testid="confirm-add" onClick={onConfirm}> + Confirm + </button> + <button data-testid="cancel-add" onClick={onCancel}> + Cancel + </button> </div> ), })) @@ -37,14 +45,11 @@ vi.mock('react-sortablejs', () => ({ setList, }: { children: React.ReactNode - list: Array<{ id: number, name: string }> - setList: (list: Array<{ id: number, name: string }>) => void + list: Array<{ id: number; name: string }> + setList: (list: Array<{ id: number; name: string }>) => void }) => ( <div> - <button - data-testid="mock-sortable-apply" - onClick={() => setList([...list].reverse())} - > + <button data-testid="mock-sortable-apply" onClick={() => setList([...list].reverse())}> Apply Sort </button> {children} @@ -72,50 +77,26 @@ describe('OpeningSettingModal', () => { }) it('should render the modal title', async () => { - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={vi.fn()} />) expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument() }) it('should render the opening statement in the editor', async () => { - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={vi.fn()} />) expect(getPromptEditor()).toHaveTextContent('Hello, how can I help?') }) it('should render suggested questions', async () => { - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={vi.fn()} />) expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument() expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument() }) it('should render cancel and save buttons', async () => { - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={vi.fn()} />) expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument() expect(screen.getByText(/operation\.save/)).toBeInTheDocument() @@ -123,13 +104,7 @@ describe('OpeningSettingModal', () => { it('should call onCancel when cancel is clicked', async () => { const onCancel = vi.fn() - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={onCancel} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={onCancel} />) await userEvent.click(screen.getByText(/operation\.cancel/)) @@ -138,13 +113,7 @@ describe('OpeningSettingModal', () => { it('should call onCancel when close icon is clicked', async () => { const onCancel = vi.fn() - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={onCancel} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={onCancel} />) const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) await userEvent.click(closeButton) @@ -154,13 +123,7 @@ describe('OpeningSettingModal', () => { it('should call onCancel when close icon receives Enter key', async () => { const onCancel = vi.fn() - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={onCancel} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={onCancel} />) const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) closeButton.focus() @@ -171,13 +134,7 @@ describe('OpeningSettingModal', () => { it('should call onCancel when close icon receives Space key', async () => { const onCancel = vi.fn() - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={onCancel} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={onCancel} />) const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) closeButton.focus() @@ -188,13 +145,7 @@ describe('OpeningSettingModal', () => { it('should call onCancel when Escape is pressed on the dialog close control', async () => { const onCancel = vi.fn() - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={onCancel} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={onCancel} />) const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) closeButton.focus() @@ -205,20 +156,16 @@ describe('OpeningSettingModal', () => { it('should call onSave with updated data when save is clicked', async () => { const onSave = vi.fn() - await render( - <OpeningSettingModal - data={defaultData} - onSave={onSave} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={onSave} onCancel={vi.fn()} />) await userEvent.click(screen.getByText(/operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - opening_statement: 'Hello, how can I help?', - suggested_questions: ['Question 1', 'Question 2'], - })) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + opening_statement: 'Hello, how can I help?', + suggested_questions: ['Question 1', 'Question 2'], + }), + ) }) it('should disable save when opening statement is empty', async () => { @@ -235,13 +182,7 @@ describe('OpeningSettingModal', () => { }) it('should add a new suggested question', async () => { - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={vi.fn()} />) // Before adding: 2 existing questions expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument() @@ -258,18 +199,13 @@ describe('OpeningSettingModal', () => { }) it('should focus a new suggested question without destructive styling', async () => { - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={vi.fn()} />) await userEvent.click(screen.getByText(/variableConfig\.addOption/)) - const newInput = screen.getAllByPlaceholderText('appDebug.openingStatement.openingQuestionPlaceholder') - .find(input => (input as HTMLInputElement).value === '') as HTMLInputElement + const newInput = screen + .getAllByPlaceholderText('appDebug.openingStatement.openingQuestionPlaceholder') + .find((input) => (input as HTMLInputElement).value === '') as HTMLInputElement const questionRow = newInput.parentElement expect(newInput).toHaveFocus() @@ -299,13 +235,7 @@ describe('OpeningSettingModal', () => { }) it('should update a suggested question value', async () => { - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={vi.fn()} />) const input = screen.getByDisplayValue('Question 1') await userEvent.clear(input) @@ -345,46 +275,36 @@ describe('OpeningSettingModal', () => { }) it('should show question count', async () => { - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={vi.fn()} />) // Count is displayed as "2/10" across child elements expect(screen.getByText('appDebug.openingStatement.openingQuestion')).toBeInTheDocument() }) it('should render separate opener and question sections', async () => { - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={vi.fn()} />) expect(screen.getByTestId('opener-input-section')).toBeInTheDocument() expect(screen.getByTestId('opener-questions-section')).toBeInTheDocument() expect(screen.getByText(/openingStatement\.editorTitle/)).toBeInTheDocument() - expect(screen.getByRole('button', { name: /openingStatement\.openingQuestionDescription/ })).toBeInTheDocument() - expect(screen.queryByText(/openingStatement\.openingQuestionDescription/)).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: /openingStatement\.openingQuestionDescription/ }), + ).toBeInTheDocument() + expect( + screen.queryByText(/openingStatement\.openingQuestionDescription/), + ).not.toBeInTheDocument() }) it('should show the opening questions description in an infotip', async () => { - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={vi.fn()} />) - await userEvent.hover(screen.getByRole('button', { name: /openingStatement\.openingQuestionDescription/ })) + await userEvent.hover( + screen.getByRole('button', { name: /openingStatement\.openingQuestionDescription/ }), + ) - expect(await screen.findByText(/openingStatement\.openingQuestionDescription/)).toBeInTheDocument() + expect( + await screen.findByText(/openingStatement\.openingQuestionDescription/), + ).toBeInTheDocument() }) it('should call onAutoAddPromptVariable when confirm add is clicked', async () => { @@ -411,25 +331,13 @@ describe('OpeningSettingModal', () => { opening_statement: 'Hello', suggested_questions: Array.from({ length: 10 }, (_, i) => `Q${i + 1}`), } - await render( - <OpeningSettingModal - data={questionsAtMax} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={questionsAtMax} onSave={vi.fn()} onCancel={vi.fn()} />) expect(screen.queryByText(/variableConfig\.addOption/)).not.toBeInTheDocument() }) it('should apply and remove focused styling on question input focus/blur', async () => { - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={vi.fn()} />) const input = screen.getByDisplayValue('Question 1') as HTMLInputElement const questionRow = input.parentElement @@ -446,13 +354,7 @@ describe('OpeningSettingModal', () => { }) it('should apply and remove deleting styling on delete icon hover', async () => { - await render( - <OpeningSettingModal - data={defaultData} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={vi.fn()} onCancel={vi.fn()} />) const questionInput = screen.getByDisplayValue('Question 1') as HTMLInputElement const questionRow = questionInput.parentElement @@ -480,9 +382,11 @@ describe('OpeningSettingModal', () => { await userEvent.click(screen.getByText(/operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - suggested_questions: [], - })) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + suggested_questions: [], + }), + ) }) it('should not save when opening statement is only whitespace', async () => { @@ -554,11 +458,7 @@ describe('OpeningSettingModal', () => { it('should use updated opening statement after prop changes', async () => { const onSave = vi.fn() const view = await render( - <OpeningSettingModal - data={defaultData} - onSave={onSave} - onCancel={vi.fn()} - />, + <OpeningSettingModal data={defaultData} onSave={onSave} onCancel={vi.fn()} />, ) await act(async () => { @@ -570,13 +470,15 @@ describe('OpeningSettingModal', () => { />, ) await Promise.resolve() - await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) }) await userEvent.click(screen.getByText(/operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - opening_statement: 'New greeting!', - })) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + opening_statement: 'New greeting!', + }), + ) }) it('should render empty opening statement with placeholder in editor', async () => { @@ -623,20 +525,16 @@ describe('OpeningSettingModal', () => { it('should save reordered suggested questions after sortable setList', async () => { const onSave = vi.fn() - await render( - <OpeningSettingModal - data={defaultData} - onSave={onSave} - onCancel={vi.fn()} - />, - ) + await render(<OpeningSettingModal data={defaultData} onSave={onSave} onCancel={vi.fn()} />) await userEvent.click(screen.getByTestId('mock-sortable-apply')) await userEvent.click(screen.getByText(/operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - suggested_questions: ['Question 2', 'Question 1'], - })) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + suggested_questions: ['Question 2', 'Question 1'], + }), + ) }) it('should not save when confirm dialog action runs with empty opening statement', async () => { diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx index 87e2b91bba34ce..46fb7c015f0ea0 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx @@ -30,17 +30,13 @@ const ConversationOpener = ({ }: Props) => { const { t } = useTranslation() const { setShowOpeningModal } = useModalContext() - const opening = useFeatures(s => s.features.opening) + const opening = useFeatures((s) => s.features.opening) const featuresStore = useFeaturesStore() const [isHovering, setIsHovering] = useState(false) const handleOpenOpeningModal = useCallback(() => { /* v8 ignore next -- guarded path is not reachable in tests with a real disabled button because click is prevented at DOM level. @preserve */ - if (disabled) - return - const { - features, - setFeatures, - } = featuresStore!.getState() + if (disabled) return + const { features, setFeatures } = featuresStore!.getState() setShowOpeningModal({ payload: { ...opening, @@ -53,62 +49,70 @@ const ConversationOpener = ({ draft.opening = newOpening }) setFeatures(newFeatures) - if (onChange) - onChange() + if (onChange) onChange() }, onCancelCallback: () => { - if (onChange) - onChange() + if (onChange) onChange() }, }) - }, [disabled, featuresStore, onAutoAddPromptVariable, onChange, opening, promptVariables, setShowOpeningModal]) + }, [ + disabled, + featuresStore, + onAutoAddPromptVariable, + onChange, + opening, + promptVariables, + setShowOpeningModal, + ]) - const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (type: FeatureEnum, enabled: boolean) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - draft[type] = { - ...draft[type], - enabled, - } - }) - setFeatures(newFeatures) - if (onChange) - onChange() - }, [featuresStore, onChange]) + const newFeatures = produce(features, (draft) => { + draft[type] = { + ...draft[type], + enabled, + } + }) + setFeatures(newFeatures) + if (onChange) onChange() + }, + [featuresStore, onChange], + ) return ( <FeatureCard - icon={( + icon={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs"> <LoveMessage className="size-4 text-text-primary-on-surface" /> </div> - )} - title={t($ => $['feature.conversationOpener.title'], { ns: 'appDebug' })} + } + title={t(($) => $['feature.conversationOpener.title'], { ns: 'appDebug' })} value={!!opening?.enabled} - onChange={state => handleChange(FeatureEnum.opening, state)} + onChange={(state) => handleChange(FeatureEnum.opening, state)} onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} disabled={disabled} > <> {!opening?.enabled && ( - <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">{t($ => $['feature.conversationOpener.description'], { ns: 'appDebug' })}</div> + <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"> + {t(($) => $['feature.conversationOpener.description'], { ns: 'appDebug' })} + </div> )} {!!opening?.enabled && ( <> {!isHovering && ( <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"> - {opening.opening_statement || t($ => $['openingStatement.placeholderLine1'], { ns: 'appDebug' })} + {opening.opening_statement || + t(($) => $['openingStatement.placeholderLine1'], { ns: 'appDebug' })} </div> )} {isHovering && ( <Button className="w-full" onClick={handleOpenOpeningModal} disabled={disabled}> <RiEditLine className="mr-1 size-4" /> - {t($ => $['openingStatement.writeOpener'], { ns: 'appDebug' })} + {t(($) => $['openingStatement.writeOpener'], { ns: 'appDebug' })} </Button> )} </> diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx index fe1ce715a3527c..281e0b23a0b6ae 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx @@ -42,50 +42,62 @@ const OpeningSettingModal = ({ // eslint-disable-next-line react/set-state-in-effect setTempValue(data.opening_statement || '') }, [data.opening_statement]) - const [tempSuggestedQuestions, setTempSuggestedQuestions] = useState(data.suggested_questions || []) - const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = useBoolean(false) + const [tempSuggestedQuestions, setTempSuggestedQuestions] = useState( + data.suggested_questions || [], + ) + const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = + useBoolean(false) const [notIncludeKeys, setNotIncludeKeys] = useState<string[]>([]) const isSaveDisabled = useMemo(() => !tempValue.trim(), [tempValue]) - const handleSave = useCallback((ignoreVariablesCheck?: boolean) => { - // Prevent saving if opening statement is empty - if (isSaveDisabled) - return + const handleSave = useCallback( + (ignoreVariablesCheck?: boolean) => { + // Prevent saving if opening statement is empty + if (isSaveDisabled) return - if (!ignoreVariablesCheck) { - const keys = getInputKeys(tempValue)?.filter((key) => { - const { isValid } = checkKeys([key], true) - return isValid - }) - const promptKeys = promptVariables.map(item => item.key) - const workflowVariableKeys = workflowVariables.map(item => item.variable) - let notIncludeKeys: string[] = [] + if (!ignoreVariablesCheck) { + const keys = getInputKeys(tempValue)?.filter((key) => { + const { isValid } = checkKeys([key], true) + return isValid + }) + const promptKeys = promptVariables.map((item) => item.key) + const workflowVariableKeys = workflowVariables.map((item) => item.variable) + let notIncludeKeys: string[] = [] - if (promptKeys.length === 0 && workflowVariables.length === 0) { - if (keys.length > 0) - notIncludeKeys = keys - } - else { - if (workflowVariables.length > 0) - notIncludeKeys = keys.filter(key => !workflowVariableKeys.includes(key)) - else notIncludeKeys = keys.filter(key => !promptKeys.includes(key)) - } + if (promptKeys.length === 0 && workflowVariables.length === 0) { + if (keys.length > 0) notIncludeKeys = keys + } else { + if (workflowVariables.length > 0) + notIncludeKeys = keys.filter((key) => !workflowVariableKeys.includes(key)) + else notIncludeKeys = keys.filter((key) => !promptKeys.includes(key)) + } - if (notIncludeKeys.length > 0) { - setNotIncludeKeys(notIncludeKeys) - showConfirmAddVar() - return + if (notIncludeKeys.length > 0) { + setNotIncludeKeys(notIncludeKeys) + showConfirmAddVar() + return + } } - } - const newOpening = produce(data, (draft) => { - if (draft) { - draft.opening_statement = tempValue - draft.suggested_questions = tempSuggestedQuestions - } - }) - onSave(newOpening) - }, [data, onSave, promptVariables, workflowVariables, showConfirmAddVar, tempSuggestedQuestions, tempValue, isSaveDisabled]) + const newOpening = produce(data, (draft) => { + if (draft) { + draft.opening_statement = tempValue + draft.suggested_questions = tempSuggestedQuestions + } + }) + onSave(newOpening) + }, + [ + data, + onSave, + promptVariables, + workflowVariables, + showConfirmAddVar, + tempSuggestedQuestions, + tempValue, + isSaveDisabled, + ], + ) const cancelAutoAddVar = useCallback(() => { hideConfirmAddVar() @@ -93,7 +105,7 @@ const OpeningSettingModal = ({ }, [handleSave, hideConfirmAddVar]) const autoAddVar = useCallback(() => { - onAutoAddPromptVariable?.(notIncludeKeys.map(key => getNewVar(key, 'string'))) + onAutoAddPromptVariable?.(notIncludeKeys.map((key) => getNewVar(key, 'string'))) hideConfirmAddVar() handleSave(true) }, [handleSave, hideConfirmAddVar, notIncludeKeys, onAutoAddPromptVariable]) @@ -103,9 +115,9 @@ const OpeningSettingModal = ({ const [autoFocusQuestionID, setAutoFocusQuestionID] = useState<number | null>(null) const openerPlaceholder = ( <span className="block wrap-break-word whitespace-pre-wrap"> - {t($ => $['openingStatement.placeholderLine1'], { ns: 'appDebug' })} + {t(($) => $['openingStatement.placeholderLine1'], { ns: 'appDebug' })} <br /> - {t($ => $['openingStatement.placeholderLine2'], { ns: 'appDebug' })} + {t(($) => $['openingStatement.placeholderLine2'], { ns: 'appDebug' })} </span> ) @@ -115,20 +127,20 @@ const OpeningSettingModal = ({ <div className="mb-3 flex items-center justify-between"> <div className="flex items-center gap-1"> <div className="text-sm font-medium text-text-primary"> - {t($ => $['openingStatement.openingQuestion'], { ns: 'appDebug' })} + {t(($) => $['openingStatement.openingQuestion'], { ns: 'appDebug' })} </div> <Infotip - aria-label={t($ => $['openingStatement.openingQuestionDescription'], { ns: 'appDebug' })} + aria-label={t(($) => $['openingStatement.openingQuestionDescription'], { + ns: 'appDebug', + })} className="size-3.5" popupClassName="max-w-[220px] system-sm-regular text-text-secondary" > - {t($ => $['openingStatement.openingQuestionDescription'], { ns: 'appDebug' })} + {t(($) => $['openingStatement.openingQuestionDescription'], { ns: 'appDebug' })} </Infotip> </div> <div className="text-xs leading-[18px] font-medium text-text-tertiary"> - {tempSuggestedQuestions.length} - / - {MAX_QUESTION_NUM} + {tempSuggestedQuestions.length}/{MAX_QUESTION_NUM} </div> </div> <Divider bgStyle="gradient" className="mb-3 h-px" /> @@ -140,7 +152,7 @@ const OpeningSettingModal = ({ name, } })} - setList={list => setTempSuggestedQuestions(list.map(item => item.name))} + setList={(list) => setTempSuggestedQuestions(list.map((item) => item.name))} handle=".handle" ghostClass="opacity-50" animation={150} @@ -150,8 +162,10 @@ const OpeningSettingModal = ({ <div className={cn( 'group relative flex items-center rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 hover:bg-components-panel-on-panel-item-bg-hover', - deletingID === index && 'border-components-input-border-destructive bg-state-destructive-hover hover:border-components-input-border-destructive hover:bg-state-destructive-hover', - focusID === index && 'border-components-input-border-active bg-components-input-bg-active hover:border-components-input-border-active hover:bg-components-input-bg-active', + deletingID === index && + 'border-components-input-border-destructive bg-state-destructive-hover hover:border-components-input-border-destructive hover:bg-state-destructive-hover', + focusID === index && + 'border-components-input-border-active bg-components-input-bg-active hover:border-components-input-border-active hover:bg-components-input-bg-active', )} key={index} > @@ -159,22 +173,26 @@ const OpeningSettingModal = ({ <input type="input" value={question || ''} - placeholder={t($ => $['openingStatement.openingQuestionPlaceholder'], { ns: 'appDebug' }) as string} + placeholder={ + t(($) => $['openingStatement.openingQuestionPlaceholder'], { + ns: 'appDebug', + }) as string + } onChange={(e) => { const value = e.target.value - setTempSuggestedQuestions(tempSuggestedQuestions.map((item, i) => { - if (index === i) - return value + setTempSuggestedQuestions( + tempSuggestedQuestions.map((item, i) => { + if (index === i) return value - return item - })) + return item + }), + ) }} autoFocus={autoFocusQuestionID === index} className="h-9 w-full grow cursor-pointer overflow-x-auto rounded-lg border-0 bg-transparent pr-8 pl-1.5 text-sm/9 text-text-secondary focus:outline-hidden" onFocus={() => { setFocusID(index) - if (autoFocusQuestionID === index) - setAutoFocusQuestionID(null) + if (autoFocusQuestionID === index) setAutoFocusQuestionID(null) }} onBlur={() => setFocusID(null)} /> @@ -187,7 +205,10 @@ const OpeningSettingModal = ({ onMouseEnter={() => setDeletingID(index)} onMouseLeave={() => setDeletingID(null)} > - <span className="i-ri-delete-bin-line size-3.5" data-testid={`delete-question-${question}`} /> + <span + className="i-ri-delete-bin-line size-3.5" + data-testid={`delete-question-${question}`} + /> </div> </div> ) @@ -204,7 +225,9 @@ const OpeningSettingModal = ({ className="mt-1 flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-components-button-tertiary-bg px-3 text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover" > <span className="i-ri-add-line size-4" /> - <div className="system-sm-medium text-[13px]">{t($ => $['variableConfig.addOption'], { ns: 'appDebug' })}</div> + <div className="system-sm-medium text-[13px]"> + {t(($) => $['variableConfig.addOption'], { ns: 'appDebug' })} + </div> </div> )} </div> @@ -212,13 +235,15 @@ const OpeningSettingModal = ({ } return ( - <Dialog open onOpenChange={open => !open && onCancel()} disablePointerDismissal> + <Dialog open onOpenChange={(open) => !open && onCancel()} disablePointerDismissal> <DialogContent className="mt-14 w-[640px] max-w-none rounded-2xl bg-components-panel-bg-blur p-6"> <div className="mb-6 flex items-center justify-between"> - <div className="title-2xl-semi-bold text-text-primary">{t($ => $['feature.conversationOpener.title'], { ns: 'appDebug' })}</div> + <div className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['feature.conversationOpener.title'], { ns: 'appDebug' })} + </div> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onCancel} > @@ -226,12 +251,9 @@ const OpeningSettingModal = ({ </button> </div> <div className="mb-8 space-y-4"> - <div - data-testid="opener-input-section" - className="py-2" - > + <div data-testid="opener-input-section" className="py-2"> <div className="mb-3 text-sm font-medium text-text-primary"> - {t($ => $['openingStatement.editorTitle'], { ns: 'appDebug' })} + {t(($) => $['openingStatement.editorTitle'], { ns: 'appDebug' })} </div> <div className="relative min-h-[80px] rounded-lg bg-components-input-bg-normal px-3 py-2"> <PromptEditor @@ -243,12 +265,12 @@ const OpeningSettingModal = ({ show: true, variables: [ // Prompt variables - ...promptVariables.map(item => ({ + ...promptVariables.map((item) => ({ name: item.name || item.key, value: item.key, })), // Workflow variables - ...workflowVariables.map(item => ({ + ...workflowVariables.map((item) => ({ name: item.variable, value: item.variable, })), @@ -257,26 +279,16 @@ const OpeningSettingModal = ({ /> </div> </div> - <div - data-testid="opener-questions-section" - className="py-2" - > + <div data-testid="opener-questions-section" className="py-2"> {renderQuestions()} </div> </div> <div className="flex items-center justify-end"> - <Button - onClick={onCancel} - className="mr-2" - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button onClick={onCancel} className="mr-2"> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button - variant="primary" - onClick={() => handleSave()} - disabled={isSaveDisabled} - > - {t($ => $['operation.save'], { ns: 'common' })} + <Button variant="primary" onClick={() => handleSave()} disabled={isSaveDisabled}> + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> {isShowConfirmAddVar && ( diff --git a/web/app/components/base/features/new-feature-panel/feature-bar.tsx b/web/app/components/base/features/new-feature-panel/feature-bar.tsx index a890da61d1063e..5eb97bebdc497f 100644 --- a/web/app/components/base/features/new-feature-panel/feature-bar.tsx +++ b/web/app/components/base/features/new-feature-panel/feature-bar.tsx @@ -7,7 +7,16 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useFeatures } from '@/app/components/base/features/hooks' import VoiceSettings from '@/app/components/base/features/new-feature-panel/text-to-speech/voice-settings' -import { Citations, ContentModeration, FolderUpload, LoveMessage, MessageFast, Microphone01, TextToAudio, VirtualAssistant } from '@/app/components/base/icons/src/vender/features' +import { + Citations, + ContentModeration, + FolderUpload, + LoveMessage, + MessageFast, + Microphone01, + TextToAudio, + VirtualAssistant, +} from '@/app/components/base/icons/src/vender/features' type Props = Readonly<{ isChatMode?: boolean @@ -25,7 +34,7 @@ const FeatureBar = ({ hideEditEntrance = false, }: Props) => { const { t } = useTranslation() - const features = useFeatures(s => s.features) + const features = useFeatures((s) => s.features) const [modalOpen, setModalOpen] = useState(false) const noFeatureEnabled = useMemo(() => { @@ -35,15 +44,20 @@ const FeatureBar = ({ citation: { enabled: isChatMode ? features.citation?.enabled : false }, file: showFileUpload ? features.file! : { enabled: false }, } - return !Object.values(data).some(f => f.enabled) + return !Object.values(data).some((f) => f.enabled) }, [features, isChatMode, showFileUpload]) return ( <div className="m-1 mt-0 -translate-y-2 rounded-b-[10px] border-r border-b border-l border-components-panel-border-subtle bg-util-colors-indigo-indigo-50 px-2.5 py-2 pt-4"> {noFeatureEnabled && ( - <div className="flex cursor-pointer items-end gap-1" onClick={() => onFeatureBarClick?.(true)}> + <div + className="flex cursor-pointer items-end gap-1" + onClick={() => onFeatureBarClick?.(true)} + > <RiApps2AddLine className="size-3.5 text-text-accent" /> - <div className="body-xs-medium text-text-accent">{t($ => $['feature.bar.empty'], { ns: 'appDebug' })}</div> + <div className="body-xs-medium text-text-accent"> + {t(($) => $['feature.bar.empty'], { ns: 'appDebug' })} + </div> <RiArrowRightLine className="size-3.5 text-text-accent" /> </div> )} @@ -53,71 +67,80 @@ const FeatureBar = ({ {!!features.moreLikeThis?.enabled && ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs"> <RiSparklingFill className="size-3.5 text-text-primary-on-surface" /> </div> - )} + } /> <TooltipContent> - {t($ => $['feature.moreLikeThis.title'], { ns: 'appDebug' })} + {t(($) => $['feature.moreLikeThis.title'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> )} {!!features.opening?.enabled && ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs"> <LoveMessage className="size-3.5 text-text-primary-on-surface" /> </div> - )} + } /> <TooltipContent> - {t($ => $['feature.conversationOpener.title'], { ns: 'appDebug' })} + {t(($) => $['feature.conversationOpener.title'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> )} {!!features.moderation?.enabled && ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-text-success p-1 shadow-xs"> <ContentModeration className="size-3.5 text-text-primary-on-surface" /> </div> - )} + } /> <TooltipContent> - {t($ => $['feature.moderation.title'], { ns: 'appDebug' })} + {t(($) => $['feature.moderation.title'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> )} {!!features.speech2text?.enabled && ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-violet-violet-600 p-1 shadow-xs"> <Microphone01 className="size-3.5 text-text-primary-on-surface" /> </div> - )} + } /> <TooltipContent> - {t($ => $['feature.speechToText.title'], { ns: 'appDebug' })} + {t(($) => $['feature.speechToText.title'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> )} {!!features.text2speech?.enabled && ( - <VoiceSettings placementLeft={false} open={modalOpen && !disabled} onOpen={setModalOpen}> + <VoiceSettings + placementLeft={false} + open={modalOpen && !disabled} + onOpen={setModalOpen} + > <Tooltip> <TooltipTrigger - render={( - <div className={cn('shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-violet-violet-600 p-1 shadow-xs', !disabled && 'cursor-pointer')}> + render={ + <div + className={cn( + 'shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-violet-violet-600 p-1 shadow-xs', + !disabled && 'cursor-pointer', + )} + > <TextToAudio className="size-3.5 text-text-primary-on-surface" /> </div> - )} + } /> <TooltipContent> - {t($ => $['feature.textToSpeech.title'], { ns: 'appDebug' })} + {t(($) => $['feature.textToSpeech.title'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> </VoiceSettings> @@ -125,69 +148,74 @@ const FeatureBar = ({ {showFileUpload && !!features.file?.enabled && ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-blue-600 p-1 shadow-xs"> <FolderUpload className="size-3.5 text-text-primary-on-surface" /> </div> - )} + } /> <TooltipContent> - {t($ => $['feature.fileUpload.title'], { ns: 'appDebug' })} + {t(($) => $['feature.fileUpload.title'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> )} {!!features.suggested?.enabled && ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs"> <VirtualAssistant className="size-3.5 text-text-primary-on-surface" /> </div> - )} + } /> <TooltipContent> - {t($ => $['feature.suggestedQuestionsAfterAnswer.title'], { ns: 'appDebug' })} + {t(($) => $['feature.suggestedQuestionsAfterAnswer.title'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> )} {isChatMode && !!features.citation?.enabled && ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-warning-warning-500 p-1 shadow-xs"> <Citations className="size-4 text-text-primary-on-surface" /> </div> - )} + } /> <TooltipContent> - {t($ => $['feature.citation.title'], { ns: 'appDebug' })} + {t(($) => $['feature.citation.title'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> )} {isChatMode && !!features.annotationReply?.enabled && ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-indigo-indigo-600 p-1 shadow-xs"> <MessageFast className="size-3.5 text-text-primary-on-surface" /> </div> - )} + } /> <TooltipContent> - {t($ => $['feature.annotation.title'], { ns: 'appDebug' })} + {t(($) => $['feature.annotation.title'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> )} </div> - <div className="grow body-xs-regular text-text-tertiary">{t($ => $['feature.bar.enableText'], { ns: 'appDebug' })}</div> - { - !hideEditEntrance && ( - <Button className="shrink-0" variant="ghost-accent" size="small" onClick={() => onFeatureBarClick?.(true)}> - <div className="mx-1">{t($ => $['feature.bar.manage'], { ns: 'appDebug' })}</div> - <RiArrowRightLine className="size-3.5 text-text-accent" /> - </Button> - ) - } + <div className="grow body-xs-regular text-text-tertiary"> + {t(($) => $['feature.bar.enableText'], { ns: 'appDebug' })} + </div> + {!hideEditEntrance && ( + <Button + className="shrink-0" + variant="ghost-accent" + size="small" + onClick={() => onFeatureBarClick?.(true)} + > + <div className="mx-1">{t(($) => $['feature.bar.manage'], { ns: 'appDebug' })}</div> + <RiArrowRightLine className="size-3.5 text-text-accent" /> + </Button> + )} </div> )} </div> diff --git a/web/app/components/base/features/new-feature-panel/feature-card.tsx b/web/app/components/base/features/new-feature-panel/feature-card.tsx index e9e88fd1e483f2..a9e4413f9daf91 100644 --- a/web/app/components/base/features/new-feature-panel/feature-card.tsx +++ b/web/app/components/base/features/new-feature-panel/feature-card.tsx @@ -46,10 +46,17 @@ const FeatureCard = ({ </Infotip> )} </div> - <Switch disabled={disabled} className="shrink-0" onCheckedChange={state => onChange?.(state)} checked={value} /> + <Switch + disabled={disabled} + className="shrink-0" + onCheckedChange={(state) => onChange?.(state)} + checked={value} + /> </div> {description && ( - <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">{description}</div> + <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"> + {description} + </div> )} {children} </div> diff --git a/web/app/components/base/features/new-feature-panel/feature-panel-drawer.tsx b/web/app/components/base/features/new-feature-panel/feature-panel-drawer.tsx index 19c6ff5d3faa3e..b36f46a1d4ec82 100644 --- a/web/app/components/base/features/new-feature-panel/feature-panel-drawer.tsx +++ b/web/app/components/base/features/new-feature-panel/feature-panel-drawer.tsx @@ -32,8 +32,7 @@ export function FeaturePanelDrawer({ open={show} swipeDirection="right" onOpenChange={(open) => { - if (!open) - close() + if (!open) close() }} > <DrawerPortal> diff --git a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx index 8038ffe883c5ba..63cb6a8174c1fc 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx @@ -21,7 +21,7 @@ const defaultFeatures: Features = { } const renderWithProvider = ( - props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}, + props: { disabled?: boolean; onChange?: OnFeaturesChange } = {}, featureOverrides?: Partial<Features>, ) => { const features = { ...defaultFeatures, ...featureOverrides } @@ -73,43 +73,55 @@ describe('FileUpload', () => { }) it('should show supported types when enabled', () => { - renderWithProvider({}, { - file: { - enabled: true, - allowed_file_types: ['image', 'document'], - number_limits: 5, + renderWithProvider( + {}, + { + file: { + enabled: true, + allowed_file_types: ['image', 'document'], + number_limits: 5, + }, }, - }) + ) expect(screen.getByText('image,document')).toBeInTheDocument() }) it('should show number limits when enabled', () => { - renderWithProvider({}, { - file: { - enabled: true, - allowed_file_types: ['image'], - number_limits: 3, + renderWithProvider( + {}, + { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, }, - }) + ) expect(screen.getByText('3')).toBeInTheDocument() }) it('should show dash when no allowed file types', () => { - renderWithProvider({}, { - file: { - enabled: true, + renderWithProvider( + {}, + { + file: { + enabled: true, + }, }, - }) + ) expect(screen.getByText('-')).toBeInTheDocument() }) it('should show settings button when hovering', () => { - renderWithProvider({}, { - file: { enabled: true }, - }) + renderWithProvider( + {}, + { + file: { enabled: true }, + }, + ) const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -118,9 +130,12 @@ describe('FileUpload', () => { }) it('should open setting modal when settings is clicked', async () => { - renderWithProvider({}, { - file: { enabled: true }, - }) + renderWithProvider( + {}, + { + file: { enabled: true }, + }, + ) const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -132,26 +147,32 @@ describe('FileUpload', () => { }) it('should show supported types label when enabled', () => { - renderWithProvider({}, { - file: { - enabled: true, - allowed_file_types: ['image'], - number_limits: 3, + renderWithProvider( + {}, + { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, }, - }) + ) expect(screen.getByText(/feature\.fileUpload\.supportedTypes/)).toBeInTheDocument() expect(screen.getByText(/feature\.fileUpload\.numberLimit/)).toBeInTheDocument() }) it('should hide info display when hovering over enabled feature', () => { - renderWithProvider({}, { - file: { - enabled: true, - allowed_file_types: ['image'], - number_limits: 3, + renderWithProvider( + {}, + { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, }, - }) + ) const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -162,13 +183,16 @@ describe('FileUpload', () => { }) it('should show info display again when mouse leaves', () => { - renderWithProvider({}, { - file: { - enabled: true, - allowed_file_types: ['image'], - number_limits: 3, + renderWithProvider( + {}, + { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, }, - }) + ) const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -178,9 +202,12 @@ describe('FileUpload', () => { }) it('should close setting modal when cancel is clicked', async () => { - renderWithProvider({}, { - file: { enabled: true }, - }) + renderWithProvider( + {}, + { + file: { enabled: true }, + }, + ) const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')! fireEvent.mouseEnter(card) diff --git a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-content.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-content.spec.tsx index 92841a2e048828..24ce4e80538b16 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-content.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-content.spec.tsx @@ -7,24 +7,34 @@ import { FeaturesProvider } from '../../../context' import SettingContent from '../setting-content' vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({ - default: ({ payload, onChange }: { payload: Record<string, unknown>, onChange: (p: Record<string, unknown>) => void }) => ( + default: ({ + payload, + onChange, + }: { + payload: Record<string, unknown> + onChange: (p: Record<string, unknown>) => void + }) => ( <div data-testid="file-upload-setting"> <span data-testid="payload">{JSON.stringify(payload)}</span> <button data-testid="change-setting" - onClick={() => onChange({ - ...payload, - allowed_file_types: ['document'], - })} + onClick={() => + onChange({ + ...payload, + allowed_file_types: ['document'], + }) + } > Change </button> <button data-testid="clear-file-types" - onClick={() => onChange({ - ...payload, - allowed_file_types: [], - })} + onClick={() => + onChange({ + ...payload, + allowed_file_types: [], + }) + } > Clear </button> @@ -57,7 +67,7 @@ const defaultFeatures: Features = { } const renderWithProvider = ( - props: { imageUpload?: boolean, onClose?: () => void, onChange?: OnFeaturesChange } = {}, + props: { imageUpload?: boolean; onClose?: () => void; onChange?: OnFeaturesChange } = {}, featureOverrides?: Partial<Features>, ) => { const features = { ...defaultFeatures, ...featureOverrides } @@ -104,7 +114,9 @@ describe('SettingContent', () => { renderWithProvider({}, { file: undefined }) const payload = screen.getByTestId('payload') - expect(payload.textContent).toContain('"allowed_file_upload_methods":["local_file","remote_url"]') + expect(payload.textContent).toContain( + '"allowed_file_upload_methods":["local_file","remote_url"]', + ) expect(payload.textContent).toContain('"allowed_file_types":["image"]') expect(payload.textContent).toContain('"max_length":3') }) @@ -122,8 +134,7 @@ describe('SettingContent', () => { const closeIconButton = screen.getByRole('button', { name: 'common.operation.close' }) expect(closeIconButton).toBeInTheDocument() - if (!closeIconButton) - throw new Error('Close icon button should exist') + if (!closeIconButton) throw new Error('Close icon button should exist') fireEvent.click(closeIconButton) diff --git a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx index 6259c7cb4f58a4..68b18fe209a08b 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx @@ -27,11 +27,7 @@ const defaultFeatures: Features = { } const renderWithProvider = (ui: React.ReactNode) => { - return render( - <FeaturesProvider features={defaultFeatures}> - {ui} - </FeaturesProvider>, - ) + return render(<FeaturesProvider features={defaultFeatures}>{ui}</FeaturesProvider>) } describe('FileUploadSettings (setting-modal)', () => { diff --git a/web/app/components/base/features/new-feature-panel/file-upload/index.tsx b/web/app/components/base/features/new-feature-panel/file-upload/index.tsx index c43897f22df822..d4f5925473a2d4 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/index.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/index.tsx @@ -16,12 +16,9 @@ type Props = Readonly<{ onChange?: OnFeaturesChange }> -const FileUpload = ({ - disabled, - onChange, -}: Props) => { +const FileUpload = ({ disabled, onChange }: Props) => { const { t } = useTranslation() - const file = useFeatures(s => s.features.file) + const file = useFeatures((s) => s.features.file) const featuresStore = useFeaturesStore() const [modalOpen, setModalOpen] = useState(false) const [isHovering, setIsHovering] = useState(false) @@ -30,53 +27,58 @@ const FileUpload = ({ return file?.allowed_file_types?.join(',') || '-' }, [file?.allowed_file_types]) - const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (type: FeatureEnum, enabled: boolean) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - draft[type] = { - ...draft[type], - enabled, - image: { enabled }, - } - }) - setFeatures(newFeatures) - if (onChange) - onChange() - }, [featuresStore, onChange]) + const newFeatures = produce(features, (draft) => { + draft[type] = { + ...draft[type], + enabled, + image: { enabled }, + } + }) + setFeatures(newFeatures) + if (onChange) onChange() + }, + [featuresStore, onChange], + ) return ( <FeatureCard - icon={( + icon={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-blue-600 p-1 shadow-xs"> <FolderUpload className="size-4 text-text-primary-on-surface" /> </div> - )} - title={t($ => $['feature.fileUpload.title'], { ns: 'appDebug' })} + } + title={t(($) => $['feature.fileUpload.title'], { ns: 'appDebug' })} value={file?.enabled} - onChange={state => handleChange(FeatureEnum.file, state)} + onChange={(state) => handleChange(FeatureEnum.file, state)} onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} disabled={disabled} > <> {!file?.enabled && ( - <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">{t($ => $['feature.fileUpload.description'], { ns: 'appDebug' })}</div> + <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"> + {t(($) => $['feature.fileUpload.description'], { ns: 'appDebug' })} + </div> )} {file?.enabled && ( <> {!isHovering && !modalOpen && ( <div className="flex items-center gap-4 pt-0.5"> <div className=""> - <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['feature.fileUpload.supportedTypes'], { ns: 'appDebug' })}</div> + <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['feature.fileUpload.supportedTypes'], { ns: 'appDebug' })} + </div> <div className="system-xs-regular text-text-secondary">{supportedTypes}</div> </div> <div className="h-[27px] w-px rotate-12 bg-divider-subtle"></div> <div className=""> - <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['feature.fileUpload.numberLimit'], { ns: 'appDebug' })}</div> + <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['feature.fileUpload.numberLimit'], { ns: 'appDebug' })} + </div> <div className="system-xs-regular text-text-secondary">{file?.number_limits}</div> </div> </div> @@ -92,7 +94,7 @@ const FileUpload = ({ > <Button className="w-full" disabled={disabled}> <RiEqualizer2Line className="mr-1 size-4" /> - {t($ => $['operation.settings'], { ns: 'common' })} + {t(($) => $['operation.settings'], { ns: 'common' })} </Button> </SettingModal> )} diff --git a/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx b/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx index fc96d47d130d31..10373949d31df8 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx @@ -15,29 +15,26 @@ type SettingContentProps = { onClose: () => void onChange?: OnFeaturesChange } -const SettingContent = ({ - imageUpload, - onClose, - onChange, -}: SettingContentProps) => { +const SettingContent = ({ imageUpload, onClose, onChange }: SettingContentProps) => { const { t } = useTranslation() const featuresStore = useFeaturesStore() - const file = useFeatures(state => state.features.file) + const file = useFeatures((state) => state.features.file) const fileSettingPayload = useMemo(() => { return { - allowed_file_upload_methods: file?.allowed_file_upload_methods || ['local_file', 'remote_url'], + allowed_file_upload_methods: file?.allowed_file_upload_methods || [ + 'local_file', + 'remote_url', + ], allowed_file_types: file?.allowed_file_types || [SupportUploadFileTypes.image], - allowed_file_extensions: file?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image], + allowed_file_extensions: + file?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image], max_length: file?.number_limits || 3, } as UploadFileSetting }, [file]) const [tempPayload, setTempPayload] = useState<UploadFileSetting>(fileSettingPayload) const handleChange = useCallback(() => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const { features, setFeatures } = featuresStore!.getState() const newFeatures = produce(features, (draft) => { draft.file = { @@ -50,17 +47,20 @@ const SettingContent = ({ }) setFeatures(newFeatures) - if (onChange) - onChange() + if (onChange) onChange() }, [featuresStore, onChange, tempPayload]) return ( <> <div className="mb-4 flex items-center justify-between"> - <div className="system-xl-semibold text-text-primary">{!imageUpload ? t($ => $['feature.fileUpload.modalTitle'], { ns: 'appDebug' }) : t($ => $['feature.imageUpload.modalTitle'], { ns: 'appDebug' })}</div> + <div className="system-xl-semibold text-text-primary"> + {!imageUpload + ? t(($) => $['feature.fileUpload.modalTitle'], { ns: 'appDebug' }) + : t(($) => $['feature.imageUpload.modalTitle'], { ns: 'appDebug' })} + </div> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onClose} > @@ -75,18 +75,15 @@ const SettingContent = ({ onChange={(p: UploadFileSetting) => setTempPayload(p)} /> <div className="mt-4 flex items-center justify-end"> - <Button - onClick={onClose} - className="mr-2" - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button onClick={onClose} className="mr-2"> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button variant="primary" onClick={handleChange} disabled={tempPayload.allowed_file_types.length === 0} > - {t($ => $['operation.save'], { ns: 'common' })} + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </> diff --git a/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx b/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx index a1c6bffbe05678..74c63e0d8c4112 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx @@ -1,10 +1,6 @@ 'use client' import type { OnFeaturesChange } from '@/app/components/base/features/types' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { memo } from 'react' import SettingContent from '@/app/components/base/features/new-feature-panel/file-upload/setting-content' @@ -28,19 +24,11 @@ const FileUploadSettings = ({ <Popover open={open} onOpenChange={(nextOpen) => { - if (disabled) - return + if (disabled) return onOpen(nextOpen) }} > - <PopoverTrigger - nativeButton={false} - render={( - <div className="flex"> - {children} - </div> - )} - /> + <PopoverTrigger nativeButton={false} render={<div className="flex">{children}</div>} /> <PopoverContent placement="left" sideOffset={32} diff --git a/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx index 3dfe3af1f72de0..529eb42f805f7f 100644 --- a/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx @@ -1,10 +1,6 @@ import type { SuggestedQuestionsAfterAnswer } from '@/app/components/base/features/types' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { - CompletionParams, - Model, - ModelModeType, -} from '@/types/app' +import type { CompletionParams, Model, ModelModeType } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' @@ -54,26 +50,22 @@ const PROMPT_MODE = { custom: 'custom', } as const -type PromptMode = typeof PROMPT_MODE[keyof typeof PROMPT_MODE] +type PromptMode = (typeof PROMPT_MODE)[keyof typeof PROMPT_MODE] -const FollowUpSettingModal = ({ - data, - onSave, - onCancel, -}: FollowUpSettingModalProps) => { +const FollowUpSettingModal = ({ data, onSave, onCancel }: FollowUpSettingModalProps) => { const { t } = useTranslation() const [model, setModel] = useState<Model>(() => getInitialModel(data.model)) const [prompt, setPrompt] = useState(data.prompt || '') const [promptMode, setPromptMode] = useState<PromptMode>( data.prompt ? PROMPT_MODE.custom : PROMPT_MODE.default, ) - const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) + const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel( + ModelTypeEnum.textGeneration, + ) const selectedModel = useMemo<Model>(() => { - if (model.provider && model.name) - return model + if (model.provider && model.name) return model - if (!defaultModel) - return model + if (!defaultModel) return model return { ...model, @@ -82,36 +74,38 @@ const FollowUpSettingModal = ({ } }, [defaultModel, model]) - const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => { - setModel(prev => ({ - ...prev, - provider: newValue.provider, - name: newValue.modelId, - mode: (newValue.mode as ModelModeType) || prev.mode || ModelModeTypeEnum.chat, - })) - }, []) - - const handleCompletionParamsChange = useCallback((newParams: FormValue) => { - setModel({ - ...selectedModel, - completion_params: { - ...DEFAULT_COMPLETION_PARAMS, - ...(newParams as Partial<CompletionParams>), - }, - }) - }, [selectedModel]) + const handleModelChange = useCallback( + (newValue: { modelId: string; provider: string; mode?: string; features?: string[] }) => { + setModel((prev) => ({ + ...prev, + provider: newValue.provider, + name: newValue.modelId, + mode: (newValue.mode as ModelModeType) || prev.mode || ModelModeTypeEnum.chat, + })) + }, + [], + ) + + const handleCompletionParamsChange = useCallback( + (newParams: FormValue) => { + setModel({ + ...selectedModel, + completion_params: { + ...DEFAULT_COMPLETION_PARAMS, + ...(newParams as Partial<CompletionParams>), + }, + }) + }, + [selectedModel], + ) const handleSave = useCallback(() => { const trimmedPrompt = prompt.trim() const nextFollowUpState = produce(data, (draft) => { - if (selectedModel.provider && selectedModel.name) - draft.model = selectedModel - else - draft.model = undefined - - draft.prompt = promptMode === PROMPT_MODE.custom - ? (trimmedPrompt || undefined) - : undefined + if (selectedModel.provider && selectedModel.name) draft.model = selectedModel + else draft.model = undefined + + draft.prompt = promptMode === PROMPT_MODE.custom ? trimmedPrompt || undefined : undefined }) onSave(nextFollowUpState) }, [data, onSave, prompt, promptMode, selectedModel]) @@ -122,19 +116,20 @@ const FollowUpSettingModal = ({ <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DialogContent className="w-[640px]! max-w-none! p-8! pb-6!"> <DialogCloseButton className="top-8 right-8" /> <DialogTitle className="pr-8 text-xl font-semibold text-text-primary"> - {t($ => $['feature.suggestedQuestionsAfterAnswer.modal.title'], { ns: 'appDebug' })} + {t(($) => $['feature.suggestedQuestionsAfterAnswer.modal.title'], { ns: 'appDebug' })} </DialogTitle> <div className="mt-6 space-y-4"> <div> <div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['feature.suggestedQuestionsAfterAnswer.modal.modelLabel'], { ns: 'appDebug' })} + {t(($) => $['feature.suggestedQuestionsAfterAnswer.modal.modelLabel'], { + ns: 'appDebug', + })} </div> <ModelParameterModal popupClassName="w-[520px]!" @@ -149,16 +144,18 @@ const FollowUpSettingModal = ({ </div> <Field name="follow_up_prompt_mode" className="contents"> <Fieldset - render={( + render={ <RadioGroup<PromptMode> className="flex-col items-stretch gap-3" value={promptMode} onValueChange={setPromptMode} /> - )} + } > <FieldsetLegend className="mb-1.5 py-0 system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['feature.suggestedQuestionsAfterAnswer.modal.promptLabel'], { ns: 'appDebug' })} + {t(($) => $['feature.suggestedQuestionsAfterAnswer.modal.promptLabel'], { + ns: 'appDebug', + })} </FieldsetLegend> <FieldItem> <RadioItem<PromptMode> @@ -175,10 +172,20 @@ const FollowUpSettingModal = ({ <div className="flex items-start justify-between gap-3"> <div> <div className="system-sm-semibold text-text-primary"> - {t($ => $['feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption'], { ns: 'appDebug' })} + {t( + ($) => + $['feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption'], + { ns: 'appDebug' }, + )} </div> <div className="mt-1 system-xs-regular text-text-tertiary"> - {t($ => $['feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOptionDescription'], { ns: 'appDebug' })} + {t( + ($) => + $[ + 'feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOptionDescription' + ], + { ns: 'appDebug' }, + )} </div> </div> <RadioControl aria-hidden="true" /> @@ -207,22 +214,40 @@ const FollowUpSettingModal = ({ <div className="flex items-start justify-between gap-3"> <div> <div className="system-sm-semibold text-text-primary"> - {t($ => $['feature.suggestedQuestionsAfterAnswer.modal.customPromptOption'], { ns: 'appDebug' })} + {t( + ($) => + $['feature.suggestedQuestionsAfterAnswer.modal.customPromptOption'], + { ns: 'appDebug' }, + )} </div> <div className="mt-1 system-xs-regular text-text-tertiary"> - {t($ => $['feature.suggestedQuestionsAfterAnswer.modal.customPromptOptionDescription'], { ns: 'appDebug' })} + {t( + ($) => + $[ + 'feature.suggestedQuestionsAfterAnswer.modal.customPromptOptionDescription' + ], + { ns: 'appDebug' }, + )} </div> </div> <RadioControl aria-hidden="true" /> </div> {promptMode === PROMPT_MODE.custom && ( <Textarea - aria-label={t($ => $['feature.suggestedQuestionsAfterAnswer.modal.customPromptOption'], { ns: 'appDebug' })} + aria-label={t( + ($) => $['feature.suggestedQuestionsAfterAnswer.modal.customPromptOption'], + { ns: 'appDebug' }, + )} className="mt-3 min-h-32 resize-y border-components-input-border-active bg-components-input-bg-normal" value={prompt} - onValueChange={value => setPrompt(value)} + onValueChange={(value) => setPrompt(value)} maxLength={CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH} - placeholder={t($ => $['feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder'], { ns: 'appDebug' }) || ''} + placeholder={ + t( + ($) => $['feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder'], + { ns: 'appDebug' }, + ) || '' + } /> )} </RadioItem> @@ -231,15 +256,9 @@ const FollowUpSettingModal = ({ </Field> </div> <div className="mt-6 flex items-center justify-end gap-2"> - <Button onClick={onCancel}> - {t($ => $['operation.cancel'], { ns: 'common' })} - </Button> - <Button - variant="primary" - disabled={isCustomPromptInvalid} - onClick={handleSave} - > - {t($ => $['operation.save'], { ns: 'common' })} + <Button onClick={onCancel}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> + <Button variant="primary" disabled={isCustomPromptInvalid} onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/app/components/base/features/new-feature-panel/follow-up.tsx b/web/app/components/base/features/new-feature-panel/follow-up.tsx index afcfb396f119ec..ae2585067e38c0 100644 --- a/web/app/components/base/features/new-feature-panel/follow-up.tsx +++ b/web/app/components/base/features/new-feature-panel/follow-up.tsx @@ -19,68 +19,62 @@ type Props = Readonly<{ onChange?: OnFeaturesChange }> -const FollowUp = ({ - disabled, - onChange, -}: Props) => { +const FollowUp = ({ disabled, onChange }: Props) => { const { t } = useTranslation() - const suggested = useFeatures(s => s.features.suggested) + const suggested = useFeatures((s) => s.features.suggested) const featuresStore = useFeaturesStore() const [isHovering, setIsHovering] = useState(false) const [isShowSettingModal, setIsShowSettingModal] = useState(false) - const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (type: FeatureEnum, enabled: boolean) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - draft[type] = { - ...draft[type], - enabled, - } - }) - setFeatures(newFeatures) - if (onChange) - onChange(newFeatures) - }, [featuresStore, onChange]) + const newFeatures = produce(features, (draft) => { + draft[type] = { + ...draft[type], + enabled, + } + }) + setFeatures(newFeatures) + if (onChange) onChange(newFeatures) + }, + [featuresStore, onChange], + ) - const handleSave = useCallback((newSuggested: SuggestedQuestionsAfterAnswer) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleSave = useCallback( + (newSuggested: SuggestedQuestionsAfterAnswer) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - draft.suggested = { - ...newSuggested, - enabled: true, - } - }) - setFeatures(newFeatures) - setIsShowSettingModal(false) - if (onChange) - onChange(newFeatures) - }, [featuresStore, onChange]) + const newFeatures = produce(features, (draft) => { + draft.suggested = { + ...newSuggested, + enabled: true, + } + }) + setFeatures(newFeatures) + setIsShowSettingModal(false) + if (onChange) onChange(newFeatures) + }, + [featuresStore, onChange], + ) const handleOpenSettingModal = useCallback(() => { - if (disabled) - return + if (disabled) return setIsShowSettingModal(true) }, [disabled]) return ( <> <FeatureCard - icon={( + icon={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs"> <VirtualAssistant className="size-4 text-text-primary-on-surface" /> </div> - )} - title={t($ => $['feature.suggestedQuestionsAfterAnswer.title'], { ns: 'appDebug' })} + } + title={t(($) => $['feature.suggestedQuestionsAfterAnswer.title'], { ns: 'appDebug' })} value={!!suggested?.enabled} - onChange={state => handleChange(FeatureEnum.suggested, state)} + onChange={(state) => handleChange(FeatureEnum.suggested, state)} onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} disabled={disabled} @@ -88,20 +82,23 @@ const FollowUp = ({ <> {!suggested?.enabled && ( <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"> - {t($ => $['feature.suggestedQuestionsAfterAnswer.description'], { ns: 'appDebug' })} + {t(($) => $['feature.suggestedQuestionsAfterAnswer.description'], { ns: 'appDebug' })} </div> )} {!!suggested?.enabled && ( <> {!isHovering && ( <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"> - {suggested.model?.name || t($ => $['feature.suggestedQuestionsAfterAnswer.modal.defaultModel'], { ns: 'appDebug' })} + {suggested.model?.name || + t(($) => $['feature.suggestedQuestionsAfterAnswer.modal.defaultModel'], { + ns: 'appDebug', + })} </div> )} {isHovering && ( <Button className="w-full" onClick={handleOpenSettingModal} disabled={disabled}> <RiEqualizer2Line className="mr-1 size-4" /> - {t($ => $['operation.settings'], { ns: 'common' })} + {t(($) => $['operation.settings'], { ns: 'common' })} </Button> )} </> diff --git a/web/app/components/base/features/new-feature-panel/image-upload/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/image-upload/__tests__/index.spec.tsx index 74c5f275516804..bcddf3ea5cadb9 100644 --- a/web/app/components/base/features/new-feature-panel/image-upload/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/image-upload/__tests__/index.spec.tsx @@ -21,7 +21,7 @@ const defaultFeatures: Features = { } const renderWithProvider = ( - props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}, + props: { disabled?: boolean; onChange?: OnFeaturesChange } = {}, featureOverrides?: Partial<Features>, ) => { const features = { ...defaultFeatures, ...featureOverrides } @@ -79,33 +79,42 @@ describe('ImageUpload', () => { }) it('should show supported types when enabled', () => { - renderWithProvider({}, { - file: { - enabled: true, - allowed_file_types: ['image'], - number_limits: 3, + renderWithProvider( + {}, + { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, }, - }) + ) expect(screen.getByText('image')).toBeInTheDocument() }) it('should show number limits when enabled', () => { - renderWithProvider({}, { - file: { - enabled: true, - allowed_file_types: ['image'], - number_limits: 3, + renderWithProvider( + {}, + { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, }, - }) + ) expect(screen.getByText('3')).toBeInTheDocument() }) it('should show settings button when hovering', () => { - renderWithProvider({}, { - file: { enabled: true }, - }) + renderWithProvider( + {}, + { + file: { enabled: true }, + }, + ) const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -114,9 +123,12 @@ describe('ImageUpload', () => { }) it('should open image upload setting modal when settings is clicked', async () => { - renderWithProvider({}, { - file: { enabled: true }, - }) + renderWithProvider( + {}, + { + file: { enabled: true }, + }, + ) const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -128,26 +140,32 @@ describe('ImageUpload', () => { }) it('should show supported types and number limit labels when enabled', () => { - renderWithProvider({}, { - file: { - enabled: true, - allowed_file_types: ['image'], - number_limits: 3, + renderWithProvider( + {}, + { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, }, - }) + ) expect(screen.getByText(/feature\.imageUpload\.supportedTypes/)).toBeInTheDocument() expect(screen.getByText(/feature\.imageUpload\.numberLimit/)).toBeInTheDocument() }) it('should hide info display when hovering', () => { - renderWithProvider({}, { - file: { - enabled: true, - allowed_file_types: ['image'], - number_limits: 3, + renderWithProvider( + {}, + { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, }, - }) + ) const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -157,13 +175,16 @@ describe('ImageUpload', () => { }) it('should show info display again when mouse leaves', () => { - renderWithProvider({}, { - file: { - enabled: true, - allowed_file_types: ['image'], - number_limits: 3, + renderWithProvider( + {}, + { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, }, - }) + ) const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -173,17 +194,23 @@ describe('ImageUpload', () => { }) it('should show dash when no file types configured', () => { - renderWithProvider({}, { - file: { enabled: true }, - }) + renderWithProvider( + {}, + { + file: { enabled: true }, + }, + ) expect(screen.getByText('-')).toBeInTheDocument() }) it('should close setting modal when cancel is clicked', async () => { - renderWithProvider({}, { - file: { enabled: true }, - }) + renderWithProvider( + {}, + { + file: { enabled: true }, + }, + ) const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')! fireEvent.mouseEnter(card) diff --git a/web/app/components/base/features/new-feature-panel/image-upload/index.tsx b/web/app/components/base/features/new-feature-panel/image-upload/index.tsx index dbe571716271f4..40e182b5a9521a 100644 --- a/web/app/components/base/features/new-feature-panel/image-upload/index.tsx +++ b/web/app/components/base/features/new-feature-panel/image-upload/index.tsx @@ -16,12 +16,9 @@ type Props = Readonly<{ onChange?: OnFeaturesChange }> -const FileUpload = ({ - disabled, - onChange, -}: Props) => { +const FileUpload = ({ disabled, onChange }: Props) => { const { t } = useTranslation() - const file = useFeatures(s => s.features.file) + const file = useFeatures((s) => s.features.file) const featuresStore = useFeaturesStore() const [modalOpen, setModalOpen] = useState(false) const [isHovering, setIsHovering] = useState(false) @@ -30,61 +27,66 @@ const FileUpload = ({ return file?.allowed_file_types?.join(',') || '-' }, [file?.allowed_file_types]) - const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (type: FeatureEnum, enabled: boolean) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - draft[type] = { - ...draft[type], - enabled, - image: { enabled }, - } - }) - setFeatures(newFeatures) - if (onChange) - onChange() - }, [featuresStore, onChange]) + const newFeatures = produce(features, (draft) => { + draft[type] = { + ...draft[type], + enabled, + image: { enabled }, + } + }) + setFeatures(newFeatures) + if (onChange) onChange() + }, + [featuresStore, onChange], + ) return ( <FeatureCard - icon={( + icon={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-indigo-indigo-600 p-1 shadow-xs"> <RiImage2Fill className="size-4 text-text-primary-on-surface" /> </div> - )} - title={( + } + title={ <div className="flex items-center"> - {t($ => $['feature.imageUpload.title'], { ns: 'appDebug' })} + {t(($) => $['feature.imageUpload.title'], { ns: 'appDebug' })} <Badge text="LEGACY" className="mx-1 shrink-0 border-text-accent-secondary text-text-accent-secondary" /> </div> - )} + } value={file?.enabled} - onChange={state => handleChange(FeatureEnum.file, state)} + onChange={(state) => handleChange(FeatureEnum.file, state)} onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} disabled={disabled} > <> {!file?.enabled && ( - <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">{t($ => $['feature.imageUpload.description'], { ns: 'appDebug' })}</div> + <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"> + {t(($) => $['feature.imageUpload.description'], { ns: 'appDebug' })} + </div> )} {file?.enabled && ( <> {!isHovering && !modalOpen && ( <div className="flex items-center gap-4 pt-0.5"> <div className=""> - <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['feature.imageUpload.supportedTypes'], { ns: 'appDebug' })}</div> + <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['feature.imageUpload.supportedTypes'], { ns: 'appDebug' })} + </div> <div className="system-xs-regular text-text-secondary">{supportedTypes}</div> </div> <div className="h-[27px] w-px rotate-12 bg-divider-subtle"></div> <div className=""> - <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['feature.imageUpload.numberLimit'], { ns: 'appDebug' })}</div> + <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['feature.imageUpload.numberLimit'], { ns: 'appDebug' })} + </div> <div className="system-xs-regular text-text-secondary">{file?.number_limits}</div> </div> </div> @@ -101,7 +103,7 @@ const FileUpload = ({ > <Button className="w-full" disabled={disabled}> <RiEqualizer2Line className="mr-1 size-4" /> - {t($ => $['operation.settings'], { ns: 'common' })} + {t(($) => $['operation.settings'], { ns: 'common' })} </Button> </SettingModal> )} diff --git a/web/app/components/base/features/new-feature-panel/index.tsx b/web/app/components/base/features/new-feature-panel/index.tsx index 3b18d1b6aeef08..67343c7834c6ba 100644 --- a/web/app/components/base/features/new-feature-panel/index.tsx +++ b/web/app/components/base/features/new-feature-panel/index.tsx @@ -5,7 +5,6 @@ import type { PromptVariable } from '@/models/debug' import { DrawerCloseButton } from '@langgenius/dify-ui/drawer' import { useTranslation } from 'react-i18next' import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply' - import Citation from '@/app/components/base/features/new-feature-panel/citation' import ConversationOpener from '@/app/components/base/features/new-feature-panel/conversation-opener' import { FeaturePanelDrawer } from '@/app/components/base/features/new-feature-panel/feature-panel-drawer' @@ -69,19 +68,21 @@ const NewFeaturePanel = ({ {/* header */} <div className="flex shrink-0 justify-between p-4 pb-3"> <div> - <div className="system-xl-semibold text-text-primary">{title ?? t($ => $['common.features'], { ns: 'workflow' })}</div> - <div className="body-xs-regular text-text-tertiary">{description ?? t($ => $['common.featuresDescription'], { ns: 'workflow' })}</div> + <div className="system-xl-semibold text-text-primary"> + {title ?? t(($) => $['common.features'], { ns: 'workflow' })} + </div> + <div className="body-xs-regular text-text-tertiary"> + {description ?? t(($) => $['common.featuresDescription'], { ns: 'workflow' })} + </div> </div> <DrawerCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-8 p-2" /> </div> {/* list */} <div className="grow basis-0 overflow-y-auto px-4 pb-4"> - {!isChatMode && !inWorkflow && ( - <MoreLikeThis disabled={disabled} onChange={onChange} /> - )} + {!isChatMode && !inWorkflow && <MoreLikeThis disabled={disabled} onChange={onChange} />} {isChatMode && ( <ConversationOpener disabled={disabled} @@ -91,9 +92,7 @@ const NewFeaturePanel = ({ onAutoAddPromptVariable={onAutoAddPromptVariable} /> )} - {isChatMode && ( - <FollowUp disabled={disabled} onChange={onChange} /> - )} + {isChatMode && <FollowUp disabled={disabled} onChange={onChange} />} {text2speechDefaultModel && (isChatMode || !inWorkflow) && ( <TextToSpeech disabled={disabled} onChange={onChange} /> )} @@ -102,10 +101,10 @@ const NewFeaturePanel = ({ )} {showFileUpload && isChatMode && <FileUpload disabled={disabled} onChange={onChange} />} {showFileUpload && !isChatMode && <ImageUpload disabled={disabled} onChange={onChange} />} - {isChatMode && ( - <Citation disabled={disabled} onChange={onChange} /> + {isChatMode && <Citation disabled={disabled} onChange={onChange} />} + {showModeration && (isChatMode || !inWorkflow) && ( + <Moderation disabled={disabled} onChange={onChange} /> )} - {showModeration && (isChatMode || !inWorkflow) && <Moderation disabled={disabled} onChange={onChange} />} {showAnnotationReply && !inWorkflow && isChatMode && ( <AnnotationReply disabled={disabled} onChange={onChange} /> )} diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/form-generation.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/form-generation.spec.tsx index a131180be087f2..409ec5073951bb 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/form-generation.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/form-generation.spec.tsx @@ -91,13 +91,7 @@ describe('FormGeneration', () => { it('should display existing values', () => { const form = createForm() - render( - <FormGeneration - forms={[form]} - value={{ api_key: 'existing-key' }} - onChange={vi.fn()} - />, - ) + render(<FormGeneration forms={[form]} value={{ api_key: 'existing-key' }} onChange={vi.fn()} />) expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument() }) diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/index.spec.tsx index b2fc193644f85b..5a2e5f80d3b426 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ import { FeaturesProvider } from '../../../context' import Moderation from '../index' const { mockCodeBasedExtensionData } = vi.hoisted(() => ({ - mockCodeBasedExtensionData: [] as Array<{ name: string, label: Record<string, string> }>, + mockCodeBasedExtensionData: [] as Array<{ name: string; label: Record<string, string> }>, })) const mockSetShowModerationSettingModal = vi.fn() @@ -31,7 +31,7 @@ const defaultFeatures: Features = { } const renderWithProvider = ( - props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}, + props: { disabled?: boolean; onChange?: OnFeaturesChange } = {}, featureOverrides?: Partial<Features>, ) => { const features = { ...defaultFeatures, ...featureOverrides } @@ -75,90 +75,108 @@ describe('Moderation', () => { }) it('should show provider info when enabled with openai_moderation type', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'openai_moderation', - config: { - inputs_config: { enabled: true, preset_response: '' }, - outputs_config: { enabled: false, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'openai_moderation', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, }, }, - }) + ) expect(screen.getByText(/feature\.moderation\.modal\.provider\.openai/))!.toBeInTheDocument() }) it('should show provider info when enabled with keywords type', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, - outputs_config: { enabled: false, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, }, }, - }) + ) expect(screen.getByText(/feature\.moderation\.modal\.provider\.keywords/))!.toBeInTheDocument() }) it('should show allEnabled when both inputs and outputs are enabled', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, - outputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) expect(screen.getByText(/feature\.moderation\.allEnabled/))!.toBeInTheDocument() }) it('should show inputEnabled when only inputs are enabled', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, - outputs_config: { enabled: false, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, }, }, - }) + ) expect(screen.getByText(/feature\.moderation\.inputEnabled/))!.toBeInTheDocument() }) it('should show outputEnabled when only outputs are enabled', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: false, preset_response: '' }, - outputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: false, preset_response: '' }, + outputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) expect(screen.getByText(/feature\.moderation\.outputEnabled/))!.toBeInTheDocument() }) it('should show settings button when hovering over enabled feature', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -167,15 +185,18 @@ describe('Moderation', () => { }) it('should open moderation modal when settings button is clicked', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -185,15 +206,18 @@ describe('Moderation', () => { }) it('should not open modal when disabled', () => { - renderWithProvider({ disabled: true }, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + { disabled: true }, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -203,30 +227,36 @@ describe('Moderation', () => { }) it('should show api provider label when type is api', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'api', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'api', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) expect(screen.getByText(/apiBasedExtension\.selector\.title/))!.toBeInTheDocument() }) it('should disable moderation and call onChange when switch is toggled off', () => { const onChange = vi.fn() - renderWithProvider({ onChange }, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + { onChange }, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) fireEvent.click(screen.getByRole('switch')) @@ -262,15 +292,18 @@ describe('Moderation', () => { it('should invoke onCancelCallback from settings modal', () => { const onChange = vi.fn() - renderWithProvider({ onChange }, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + { onChange }, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -283,15 +316,18 @@ describe('Moderation', () => { }) it('should invoke onCancelCallback from settings modal without onChange', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -303,15 +339,18 @@ describe('Moderation', () => { it('should invoke onSaveCallback from settings modal', () => { const onChange = vi.fn() - renderWithProvider({ onChange }, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + { onChange }, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -324,34 +363,42 @@ describe('Moderation', () => { }) it('should invoke onSaveCallback from settings modal without onChange', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! fireEvent.mouseEnter(card) fireEvent.click(screen.getByText(/operation\.settings/)) const modalCall = mockSetShowModerationSettingModal.mock.calls[0]![0] - expect(() => modalCall.onSaveCallback({ enabled: true, type: 'keywords', config: {} })).not.toThrow() + expect(() => + modalCall.onSaveCallback({ enabled: true, type: 'keywords', config: {} }), + ).not.toThrow() }) it('should show code-based extension label for custom type', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'custom-ext', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'custom-ext', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) // For unknown types, falls back to codeBasedExtensionList label or '-' // For unknown types, falls back to codeBasedExtensionList label or '-' @@ -364,45 +411,56 @@ describe('Moderation', () => { label: { 'en-US': 'Custom Moderation', 'zh-Hans': '自定义审核' }, }) - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'custom-ext', - config: { - inputs_config: { enabled: true, preset_response: '' }, - outputs_config: { enabled: false, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'custom-ext', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, }, }, - }) + ) expect(screen.getByText('Custom Moderation'))!.toBeInTheDocument() }) it('should not show enable content text when both input and output moderation are disabled', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: false, preset_response: '' }, - outputs_config: { enabled: false, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: false, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, }, }, - }) + ) - expect(screen.queryByText(/feature\.moderation\.(allEnabled|inputEnabled|outputEnabled)/)).not.toBeInTheDocument() + expect( + screen.queryByText(/feature\.moderation\.(allEnabled|inputEnabled|outputEnabled)/), + ).not.toBeInTheDocument() }) it('should not open setting modal when clicking settings button while disabled', () => { - renderWithProvider({ disabled: true }, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + { disabled: true }, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -431,7 +489,9 @@ describe('Moderation', () => { fireEvent.click(screen.getByRole('switch')) const modalCall = mockSetShowModerationSettingModal.mock.calls[0]![0] - expect(() => modalCall.onSaveCallback({ enabled: true, type: 'keywords', config: {} })).not.toThrow() + expect(() => + modalCall.onSaveCallback({ enabled: true, type: 'keywords', config: {} }), + ).not.toThrow() }) it('should invoke onCancelCallback from enable modal and set enabled false', () => { @@ -457,15 +517,18 @@ describe('Moderation', () => { }) it('should disable moderation when toggled off without onChange', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) expect(() => { fireEvent.click(screen.getByRole('switch')) @@ -473,15 +536,18 @@ describe('Moderation', () => { }) it('should not show modal when enabling with existing type', () => { - renderWithProvider({}, { - moderation: { - enabled: false, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: false, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, }, }, - }) + ) fireEvent.click(screen.getByRole('switch')) @@ -492,16 +558,19 @@ describe('Moderation', () => { }) it('should hide info display when hovering over enabled feature', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, - outputs_config: { enabled: false, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, }, }, - }) + ) const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! @@ -517,16 +586,19 @@ describe('Moderation', () => { }) it('should show info display again when mouse leaves', () => { - renderWithProvider({}, { - moderation: { - enabled: true, - type: 'keywords', - config: { - inputs_config: { enabled: true, preset_response: '' }, - outputs_config: { enabled: false, preset_response: '' }, + renderWithProvider( + {}, + { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, }, }, - }) + ) const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! fireEvent.mouseEnter(card) diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-content.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-content.spec.tsx index 81017c4b5ee25a..823c51f4eb32f3 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-content.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-content.spec.tsx @@ -9,13 +9,15 @@ const defaultConfig: ModerationContentConfig = { preset_response: '', } -const renderComponent = (props: Partial<{ - title: string - info: string - showPreset: boolean - config: ModerationContentConfig - onConfigChange: (config: ModerationContentConfig) => void -}> = {}) => { +const renderComponent = ( + props: Partial<{ + title: string + info: string + showPreset: boolean + config: ModerationContentConfig + onConfigChange: (config: ModerationContentConfig) => void + }> = {}, +) => { const onConfigChange = props.onConfigChange ?? vi.fn() return render( <ModerationContent @@ -129,7 +131,9 @@ describe('ModerationContent', () => { it('should fallback to empty placeholder when translation is empty', () => { const useTranslationSpy = vi.spyOn(i18n, 'useTranslation').mockReturnValue({ - t: withSelectorKey((key: string) => key === 'feature.moderation.modal.content.placeholder' ? '' : key), + t: withSelectorKey((key: string) => + key === 'feature.moderation.modal.content.placeholder' ? '' : key, + ), i18n: { language: 'en-US' }, } as unknown as ReturnType<typeof i18n.useTranslation>) diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx index 34ce09275fbe1a..ea5da9db85747d 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx @@ -26,15 +26,17 @@ let mockModelProvidersData: { refetch: ReturnType<typeof vi.fn> } = { data: { - data: [{ - provider: 'langgenius/openai/openai', - system_configuration: { - enabled: true, - current_quota_type: 'paid', - quota_configurations: [{ quota_type: 'paid', is_valid: true }], + data: [ + { + provider: 'langgenius/openai/openai', + system_configuration: { + enabled: true, + current_quota_type: 'paid', + quota_configurations: [{ quota_type: 'paid', is_valid: true }], + }, + custom_configuration: { status: 'active' }, }, - custom_configuration: { status: 'active' }, - }], + ], }, isPending: false, refetch: vi.fn(), @@ -54,9 +56,11 @@ vi.mock('@/app/components/header/account-setting/constants', () => ({ })) vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({ - ApiBasedExtensionSelector: ({ onChange }: { value: string, onChange: (v: string) => void }) => ( + ApiBasedExtensionSelector: ({ onChange }: { value: string; onChange: (v: string) => void }) => ( <div data-testid="api-selector"> - <button data-testid="select-api" onClick={() => onChange('api-ext-1')}>Select API</button> + <button data-testid="select-api" onClick={() => onChange('api-ext-1')}> + Select API + </button> </div> ), })) @@ -85,15 +89,17 @@ describe('ModerationSettingModal', () => { mockCodeBasedExtensions = { data: { data: [] } } mockModelProvidersData = { data: { - data: [{ - provider: 'langgenius/openai/openai', - system_configuration: { - enabled: true, - current_quota_type: 'paid', - quota_configurations: [{ quota_type: 'paid', is_valid: true }], + data: [ + { + provider: 'langgenius/openai/openai', + system_configuration: { + enabled: true, + current_quota_type: 'paid', + quota_configurations: [{ quota_type: 'paid', is_valid: true }], + }, + custom_configuration: { status: 'active' }, }, - custom_configuration: { status: 'active' }, - }], + ], }, isPending: false, refetch: vi.fn(), @@ -106,11 +112,7 @@ describe('ModerationSettingModal', () => { it('should render the modal title', async () => { await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) expect(screen.getByText(/feature\.moderation\.modal\.title/))!.toBeInTheDocument() @@ -118,40 +120,32 @@ describe('ModerationSettingModal', () => { it('should render provider options', async () => { await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) expect(screen.getByText(/feature\.moderation\.modal\.provider\.openai/))!.toBeInTheDocument() // Keywords text appears both as provider option and section label - expect(screen.getAllByText(/feature\.moderation\.modal\.provider\.keywords/).length).toBeGreaterThanOrEqual(1) + expect( + screen.getAllByText(/feature\.moderation\.modal\.provider\.keywords/).length, + ).toBeGreaterThanOrEqual(1) expect(screen.getByText(/apiBasedExtension\.selector\.title/))!.toBeInTheDocument() }) it('should show keywords textarea when keywords type is selected', async () => { await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) - const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement + const textarea = screen.getByPlaceholderText( + /feature\.moderation\.modal\.keywords\.placeholder/, + ) as HTMLTextAreaElement expect(textarea)!.toBeInTheDocument() expect(textarea)!.toHaveValue('bad\nword') }) it('should render cancel and save buttons', async () => { await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) expect(screen.getByText(/operation\.cancel/))!.toBeInTheDocument() @@ -161,11 +155,7 @@ describe('ModerationSettingModal', () => { it('should call onCancel when cancel is clicked', async () => { const onCancel = vi.fn() await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={onCancel} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={onCancel} onSave={onSave} />, ) fireEvent.click(screen.getByText(/operation\.cancel/)) @@ -176,11 +166,7 @@ describe('ModerationSettingModal', () => { it('should call onCancel when close icon receives Enter key', async () => { const onCancel = vi.fn() await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={onCancel} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={onCancel} onSave={onSave} />, ) const user = userEvent.setup() @@ -194,11 +180,7 @@ describe('ModerationSettingModal', () => { it('should call onCancel when close icon receives Space key', async () => { const onCancel = vi.fn() await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={onCancel} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={onCancel} onSave={onSave} />, ) const user = userEvent.setup() @@ -212,11 +194,7 @@ describe('ModerationSettingModal', () => { it('should not call onCancel when close icon receives non-action key', async () => { const onCancel = vi.fn() await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={onCancel} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={onCancel} onSave={onSave} />, ) const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) @@ -235,19 +213,11 @@ describe('ModerationSettingModal', () => { outputs_config: { enabled: false, preset_response: '' }, }, } - await renderModal( - <ModerationSettingModal - data={data} - onCancel={vi.fn()} - onSave={onSave} - />, - ) + await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />) fireEvent.click(screen.getByText(/operation\.save/)) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should show error when keywords type has no keywords', async () => { @@ -259,19 +229,11 @@ describe('ModerationSettingModal', () => { outputs_config: { enabled: false, preset_response: '' }, }, } - await renderModal( - <ModerationSettingModal - data={data} - onCancel={vi.fn()} - onSave={onSave} - />, - ) + await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />) fireEvent.click(screen.getByText(/operation\.save/)) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should call onSave with formatted data when valid', async () => { @@ -283,24 +245,20 @@ describe('ModerationSettingModal', () => { outputs_config: { enabled: false, preset_response: '' }, }, } - await renderModal( - <ModerationSettingModal - data={data} - onCancel={vi.fn()} - onSave={onSave} - />, - ) + await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />) fireEvent.click(screen.getByText(/operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - type: 'keywords', - enabled: true, - config: expect.objectContaining({ - keywords: 'bad\nword', - inputs_config: expect.objectContaining({ enabled: true }), + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'keywords', + enabled: true, + config: expect.objectContaining({ + keywords: 'bad\nword', + inputs_config: expect.objectContaining({ enabled: true }), + }), }), - })) + ) }) it('should save the latest preset response when content textarea changes', async () => { @@ -312,32 +270,35 @@ describe('ModerationSettingModal', () => { outputs_config: { enabled: false, preset_response: '' }, }, } - await renderModal( - <ModerationSettingModal - data={data} - onCancel={vi.fn()} - onSave={onSave} - />, - ) + await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />) - fireEvent.change(screen.getByRole('textbox', { name: /feature\.moderation\.modal\.content\.preset/ }), { - target: { value: 'updated blocked response' }, - }) + fireEvent.change( + screen.getByRole('textbox', { name: /feature\.moderation\.modal\.content\.preset/ }), + { + target: { value: 'updated blocked response' }, + }, + ) fireEvent.click(screen.getByText(/operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - config: expect.objectContaining({ - inputs_config: expect.objectContaining({ - preset_response: 'updated blocked response', + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + inputs_config: expect.objectContaining({ + preset_response: 'updated blocked response', + }), }), }), - })) + ) }) it('should show api selector when api type is selected', async () => { await renderModal( <ModerationSettingModal - data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }} + data={{ + ...defaultData, + type: 'api', + config: { inputs_config: { enabled: true, preset_response: '' } }, + }} onCancel={vi.fn()} onSave={onSave} />, @@ -348,11 +309,7 @@ describe('ModerationSettingModal', () => { it('should switch provider type when clicked', async () => { await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) // Click on openai_moderation provider @@ -390,19 +347,19 @@ describe('ModerationSettingModal', () => { // The keywords textarea should no longer be visible since type changed // The keywords textarea should no longer be visible since type changed // The keywords textarea should no longer be visible since type changed - expect(screen.queryByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/), + ).not.toBeInTheDocument() }) it('should update keywords on textarea change', async () => { await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) - const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement + const textarea = screen.getByPlaceholderText( + /feature\.moderation\.modal\.keywords\.placeholder/, + ) as HTMLTextAreaElement fireEvent.change(textarea, { target: { value: 'new\nkeywords' } }) expect(textarea)!.toHaveValue('new\nkeywords') @@ -410,11 +367,7 @@ describe('ModerationSettingModal', () => { it('should render moderation content sections', async () => { await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) expect(screen.getByText(/feature\.moderation\.modal\.content\.input/))!.toBeInTheDocument() @@ -430,19 +383,11 @@ describe('ModerationSettingModal', () => { outputs_config: { enabled: false, preset_response: '' }, }, } - await renderModal( - <ModerationSettingModal - data={data} - onCancel={vi.fn()} - onSave={onSave} - />, - ) + await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />) fireEvent.click(screen.getByText(/operation\.save/)) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should show error when api type has no api_based_extension_id', async () => { @@ -454,19 +399,11 @@ describe('ModerationSettingModal', () => { outputs_config: { enabled: false, preset_response: '' }, }, } - await renderModal( - <ModerationSettingModal - data={data} - onCancel={vi.fn()} - onSave={onSave} - />, - ) + await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />) fireEvent.click(screen.getByText(/operation\.save/)) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should save with api_based_extension_id in formatted data for api type', async () => { @@ -479,23 +416,19 @@ describe('ModerationSettingModal', () => { outputs_config: { enabled: false, preset_response: '' }, }, } - await renderModal( - <ModerationSettingModal - data={data} - onCancel={vi.fn()} - onSave={onSave} - />, - ) + await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />) // api type doesn't require preset_response, so save should succeed fireEvent.click(screen.getByText(/operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - type: 'api', - config: expect.objectContaining({ - api_based_extension_id: 'ext-1', + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'api', + config: expect.objectContaining({ + api_based_extension_id: 'ext-1', + }), }), - })) + ) }) it('should show error when outputs enabled but no preset_response for keywords type', async () => { @@ -507,59 +440,55 @@ describe('ModerationSettingModal', () => { outputs_config: { enabled: true, preset_response: '' }, }, } - await renderModal( - <ModerationSettingModal - data={data} - onCancel={vi.fn()} - onSave={onSave} - />, - ) + await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />) fireEvent.click(screen.getByText(/operation\.save/)) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should toggle input moderation content', async () => { await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) const switches = screen.getAllByRole('switch') - expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(1) + expect( + screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/), + ).toHaveLength(1) fireEvent.click(switches[0]!) - expect(screen.queryAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(0) + expect( + screen.queryAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/), + ).toHaveLength(0) }) it('should toggle output moderation content', async () => { await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) const switches = screen.getAllByRole('switch') - expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(1) + expect( + screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/), + ).toHaveLength(1) fireEvent.click(switches[1]!) - expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(2) + expect( + screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/), + ).toHaveLength(2) }) it('should select api extension via api selector', async () => { await renderModal( <ModerationSettingModal - data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }} + data={{ + ...defaultData, + type: 'api', + config: { inputs_config: { enabled: true, preset_response: '' } }, + }} onCancel={vi.fn()} onSave={onSave} />, @@ -594,21 +523,21 @@ describe('ModerationSettingModal', () => { fireEvent.click(screen.getByText(/operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - type: 'openai_moderation', - })) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'openai_moderation', + }), + ) }) it('should handle keyword truncation to 100 chars per line and 100 lines', async () => { await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) - const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) + const textarea = screen.getByPlaceholderText( + /feature\.moderation\.modal\.keywords\.placeholder/, + ) // Create a long keyword that exceeds 100 chars const longWord = 'a'.repeat(150) fireEvent.change(textarea, { target: { value: longWord } }) @@ -626,31 +555,23 @@ describe('ModerationSettingModal', () => { outputs_config: { enabled: true, preset_response: 'output blocked' }, }, } - await renderModal( - <ModerationSettingModal - data={data} - onCancel={vi.fn()} - onSave={onSave} - />, - ) + await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />) fireEvent.click(screen.getByText(/operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - config: expect.objectContaining({ - inputs_config: expect.objectContaining({ enabled: true }), - outputs_config: expect.objectContaining({ enabled: true }), + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + inputs_config: expect.objectContaining({ enabled: true }), + outputs_config: expect.objectContaining({ enabled: true }), + }), }), - })) + ) }) it('should switch from keywords to api type', async () => { await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) // Click api provider @@ -659,19 +580,19 @@ describe('ModerationSettingModal', () => { // API selector should now be visible, keywords textarea should be hidden // API selector should now be visible, keywords textarea should be hidden expect(screen.getByTestId('api-selector'))!.toBeInTheDocument() - expect(screen.queryByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/), + ).not.toBeInTheDocument() }) it('should handle empty lines in keywords', async () => { await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) - const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement + const textarea = screen.getByPlaceholderText( + /feature\.moderation\.modal\.keywords\.placeholder/, + ) as HTMLTextAreaElement fireEvent.change(textarea, { target: { value: 'word1\n\nword2\n\n' } }) expect(textarea.value).toBe('word1\n\nword2\n') @@ -680,15 +601,17 @@ describe('ModerationSettingModal', () => { it('should show OpenAI not configured warning when OpenAI provider is not set up', async () => { mockModelProvidersData = { data: { - data: [{ - provider: 'langgenius/openai/openai', - system_configuration: { - enabled: false, - current_quota_type: 'free', - quota_configurations: [], + data: [ + { + provider: 'langgenius/openai/openai', + system_configuration: { + enabled: false, + current_quota_type: 'free', + quota_configurations: [], + }, + custom_configuration: { status: 'no-configure' }, }, - custom_configuration: { status: 'no-configure' }, - }], + ], }, isPending: false, refetch: vi.fn(), @@ -696,27 +619,35 @@ describe('ModerationSettingModal', () => { await renderModal( <ModerationSettingModal - data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: '' } } }} + data={{ + ...defaultData, + type: 'openai_moderation', + config: { inputs_config: { enabled: true, preset_response: '' } }, + }} onCancel={vi.fn()} onSave={onSave} />, ) - expect(screen.getByText(/feature\.moderation\.modal\.openaiNotConfig\.before/))!.toBeInTheDocument() + expect( + screen.getByText(/feature\.moderation\.modal\.openaiNotConfig\.before/), + )!.toBeInTheDocument() }) it('should open settings modal when provider link is clicked in OpenAI warning', async () => { mockModelProvidersData = { data: { - data: [{ - provider: 'langgenius/openai/openai', - system_configuration: { - enabled: false, - current_quota_type: 'free', - quota_configurations: [], + data: [ + { + provider: 'langgenius/openai/openai', + system_configuration: { + enabled: false, + current_quota_type: 'free', + quota_configurations: [], + }, + custom_configuration: { status: 'no-configure' }, }, - custom_configuration: { status: 'no-configure' }, - }], + ], }, isPending: false, refetch: vi.fn(), @@ -724,7 +655,11 @@ describe('ModerationSettingModal', () => { await renderModal( <ModerationSettingModal - data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: '' } } }} + data={{ + ...defaultData, + type: 'openai_moderation', + config: { inputs_config: { enabled: true, preset_response: '' } }, + }} onCancel={vi.fn()} onSave={onSave} />, @@ -743,15 +678,17 @@ describe('ModerationSettingModal', () => { it('should not save when OpenAI type is selected but not configured', async () => { mockModelProvidersData = { data: { - data: [{ - provider: 'langgenius/openai/openai', - system_configuration: { - enabled: false, - current_quota_type: 'free', - quota_configurations: [], + data: [ + { + provider: 'langgenius/openai/openai', + system_configuration: { + enabled: false, + current_quota_type: 'free', + quota_configurations: [], + }, + custom_configuration: { status: 'no-configure' }, }, - custom_configuration: { status: 'no-configure' }, - }], + ], }, isPending: false, refetch: vi.fn(), @@ -759,7 +696,14 @@ describe('ModerationSettingModal', () => { await renderModal( <ModerationSettingModal - data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: 'blocked' }, outputs_config: { enabled: false, preset_response: '' } } }} + data={{ + ...defaultData, + type: 'openai_moderation', + config: { + inputs_config: { enabled: true, preset_response: 'blocked' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + }} onCancel={vi.fn()} onSave={onSave} />, @@ -773,22 +717,29 @@ describe('ModerationSettingModal', () => { it('should render code-based extension providers', async () => { mockCodeBasedExtensions = { data: { - data: [{ - name: 'custom-ext', - label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, - form_schema: [ - { variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: 'Enter URL', options: [], max_length: 200 }, - ], - }], + data: [ + { + name: 'custom-ext', + label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, + form_schema: [ + { + variable: 'api_url', + label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, + type: 'text-input', + required: true, + default: '', + placeholder: 'Enter URL', + options: [], + max_length: 200, + }, + ], + }, + ], }, } await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) expect(screen.getByText('Custom Extension'))!.toBeInTheDocument() @@ -797,19 +748,34 @@ describe('ModerationSettingModal', () => { it('should show form generation when code-based extension is selected', async () => { mockCodeBasedExtensions = { data: { - data: [{ - name: 'custom-ext', - label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, - form_schema: [ - { variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: 'Enter URL', options: [], max_length: 200 }, - ], - }], + data: [ + { + name: 'custom-ext', + label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, + form_schema: [ + { + variable: 'api_url', + label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, + type: 'text-input', + required: true, + default: '', + placeholder: 'Enter URL', + options: [], + max_length: 200, + }, + ], + }, + ], }, } await renderModal( <ModerationSettingModal - data={{ ...defaultData, type: 'custom-ext', config: { inputs_config: { enabled: true, preset_response: '' } } }} + data={{ + ...defaultData, + type: 'custom-ext', + config: { inputs_config: { enabled: true, preset_response: '' } }, + }} onCancel={vi.fn()} onSave={onSave} />, @@ -822,22 +788,29 @@ describe('ModerationSettingModal', () => { it('should initialize config from form schema when switching to code-based extension', async () => { mockCodeBasedExtensions = { data: { - data: [{ - name: 'custom-ext', - label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, - form_schema: [ - { variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: 'https://default.com', placeholder: '', options: [], max_length: 200 }, - ], - }], + data: [ + { + name: 'custom-ext', + label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, + form_schema: [ + { + variable: 'api_url', + label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, + type: 'text-input', + required: true, + default: 'https://default.com', + placeholder: '', + options: [], + max_length: 200, + }, + ], + }, + ], }, } await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) // Click on the custom extension provider @@ -851,19 +824,34 @@ describe('ModerationSettingModal', () => { it('should show error when required form schema field is empty on save', async () => { mockCodeBasedExtensions = { data: { - data: [{ - name: 'custom-ext', - label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, - form_schema: [ - { variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: '', options: [], max_length: 200 }, - ], - }], + data: [ + { + name: 'custom-ext', + label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, + form_schema: [ + { + variable: 'api_url', + label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, + type: 'text-input', + required: true, + default: '', + placeholder: '', + options: [], + max_length: 200, + }, + ], + }, + ], }, } await renderModal( <ModerationSettingModal - data={{ ...defaultData, type: 'custom-ext', config: { inputs_config: { enabled: true, preset_response: 'blocked' } } }} + data={{ + ...defaultData, + type: 'custom-ext', + config: { inputs_config: { enabled: true, preset_response: 'blocked' } }, + }} onCancel={vi.fn()} onSave={onSave} />, @@ -871,27 +859,44 @@ describe('ModerationSettingModal', () => { fireEvent.click(screen.getByText(/operation\.save/)) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should save with code-based extension config when valid', async () => { mockCodeBasedExtensions = { data: { - data: [{ - name: 'custom-ext', - label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, - form_schema: [ - { variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: '', options: [], max_length: 200 }, - ], - }], + data: [ + { + name: 'custom-ext', + label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, + form_schema: [ + { + variable: 'api_url', + label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, + type: 'text-input', + required: true, + default: '', + placeholder: '', + options: [], + max_length: 200, + }, + ], + }, + ], }, } await renderModal( <ModerationSettingModal - data={{ ...defaultData, type: 'custom-ext', config: { api_url: 'https://example.com', inputs_config: { enabled: true, preset_response: 'blocked' }, outputs_config: { enabled: false, preset_response: '' } } }} + data={{ + ...defaultData, + type: 'custom-ext', + config: { + api_url: 'https://example.com', + inputs_config: { enabled: true, preset_response: 'blocked' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + }} onCancel={vi.fn()} onSave={onSave} />, @@ -899,50 +904,78 @@ describe('ModerationSettingModal', () => { fireEvent.click(screen.getByText(/operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - type: 'custom-ext', - config: expect.objectContaining({ - api_url: 'https://example.com', + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'custom-ext', + config: expect.objectContaining({ + api_url: 'https://example.com', + }), }), - })) + ) }) it('should update code-based extension form value and save updated config', async () => { mockCodeBasedExtensions = { data: { - data: [{ - name: 'custom-ext', - label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, - form_schema: [ - { variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: 'Enter URL', options: [], max_length: 200 }, - ], - }], + data: [ + { + name: 'custom-ext', + label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, + form_schema: [ + { + variable: 'api_url', + label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, + type: 'text-input', + required: true, + default: '', + placeholder: 'Enter URL', + options: [], + max_length: 200, + }, + ], + }, + ], }, } await renderModal( <ModerationSettingModal - data={{ ...defaultData, type: 'custom-ext', config: { inputs_config: { enabled: true, preset_response: 'blocked' }, outputs_config: { enabled: false, preset_response: '' } } }} + data={{ + ...defaultData, + type: 'custom-ext', + config: { + inputs_config: { enabled: true, preset_response: 'blocked' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + }} onCancel={vi.fn()} onSave={onSave} />, ) - fireEvent.change(screen.getByPlaceholderText('Enter URL'), { target: { value: 'https://changed.com' } }) + fireEvent.change(screen.getByPlaceholderText('Enter URL'), { + target: { value: 'https://changed.com' }, + }) fireEvent.click(screen.getByText(/operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - type: 'custom-ext', - config: expect.objectContaining({ - api_url: 'https://changed.com', + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'custom-ext', + config: expect.objectContaining({ + api_url: 'https://changed.com', + }), }), - })) + ) }) it('should show doc link for api type', async () => { await renderModal( <ModerationSettingModal - data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }} + data={{ + ...defaultData, + type: 'api', + config: { inputs_config: { enabled: true, preset_response: '' } }, + }} onCancel={vi.fn()} onSave={onSave} />, @@ -969,33 +1002,33 @@ describe('ModerationSettingModal', () => { fireEvent.click(screen.getByText(/operation\.save/)) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - type: 'api', - config: expect.objectContaining({ - inputs_config: expect.objectContaining({ enabled: false }), - outputs_config: expect.objectContaining({ enabled: true }), + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'api', + config: expect.objectContaining({ + inputs_config: expect.objectContaining({ enabled: false }), + outputs_config: expect.objectContaining({ enabled: true }), + }), }), - })) + ) }) it('should fallback to empty translated strings for optional placeholders and titles', async () => { const useTranslationSpy = vi.spyOn(i18n, 'useTranslation').mockReturnValue({ - t: withSelectorKey((key: string) => [ - 'feature.moderation.modal.keywords.placeholder', - 'feature.moderation.modal.content.input', - 'feature.moderation.modal.content.output', - ].includes(key) - ? '' - : key), + t: withSelectorKey((key: string) => + [ + 'feature.moderation.modal.keywords.placeholder', + 'feature.moderation.modal.content.input', + 'feature.moderation.modal.content.output', + ].includes(key) + ? '' + : key, + ), i18n: { language: 'en-US' }, } as unknown as ReturnType<typeof i18n.useTranslation>) await renderModal( - <ModerationSettingModal - data={defaultData} - onCancel={vi.fn()} - onSave={onSave} - />, + <ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />, ) const textarea = screen.getAllByRole('textbox')[0] diff --git a/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx b/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx index afd0acf793d7e0..70e0379930ae9c 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx @@ -1,7 +1,14 @@ import type { FC } from 'react' import type { CodeBasedExtensionForm } from '@/models/common' import type { ModerationConfig } from '@/models/debug' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { Textarea } from '@langgenius/dify-ui/textarea' import { useLocale } from '@/context/i18n' @@ -10,11 +17,7 @@ type FormGenerationProps = { value: ModerationConfig['config'] onChange: (v: Record<string, string>) => void } -const FormGeneration: FC<FormGenerationProps> = ({ - forms, - value, - onChange, -}) => { +const FormGeneration: FC<FormGenerationProps> = ({ forms, value, onChange }) => { const locale = useLocale() const handleFormChange = (type: string, v: string) => { @@ -23,68 +26,64 @@ const FormGeneration: FC<FormGenerationProps> = ({ return ( <> - { - forms.map((form, index) => { - const selectOptions = form.type === 'select' - ? form.options.map(option => ({ + {forms.map((form, index) => { + const selectOptions = + form.type === 'select' + ? form.options.map((option) => ({ name: option.label[locale === 'zh-Hans' ? 'zh-Hans' : 'en-US'], value: option.value, })) : [] - const selectedOption = selectOptions.find(option => option.value === value?.[form.variable]) ?? null + const selectedOption = + selectOptions.find((option) => option.value === value?.[form.variable]) ?? null - return ( - <div - key={index} - className="py-2" - > - <div className="flex h-9 items-center text-sm font-medium text-text-primary"> - {locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']} - </div> - { - form.type === 'text-input' && ( - <input - value={value?.[form.variable] || ''} - className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" - placeholder={form.placeholder} - onChange={e => handleFormChange(form.variable, e.target.value)} - /> - ) - } - { - form.type === 'paragraph' && ( - <div className="relative"> - <Textarea - aria-label={locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']} - className="resize-none" - value={value?.[form.variable] || ''} - placeholder={form.placeholder} - onValueChange={value => handleFormChange(form.variable, value)} - /> - </div> - ) - } - { - form.type === 'select' && ( - <Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && handleFormChange(form.variable, nextValue)}> - <SelectTrigger className="w-full"> - {selectedOption?.name ?? form.placeholder} - </SelectTrigger> - <SelectContent> - {selectOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - <SelectItemText>{option.name}</SelectItemText> - <SelectItemIndicator /> - </SelectItem> - ))} - </SelectContent> - </Select> - ) - } + return ( + <div key={index} className="py-2"> + <div className="flex h-9 items-center text-sm font-medium text-text-primary"> + {locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']} </div> - ) - }) - } + {form.type === 'text-input' && ( + <input + value={value?.[form.variable] || ''} + className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" + placeholder={form.placeholder} + onChange={(e) => handleFormChange(form.variable, e.target.value)} + /> + )} + {form.type === 'paragraph' && ( + <div className="relative"> + <Textarea + aria-label={locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']} + className="resize-none" + value={value?.[form.variable] || ''} + placeholder={form.placeholder} + onValueChange={(value) => handleFormChange(form.variable, value)} + /> + </div> + )} + {form.type === 'select' && ( + <Select + value={selectedOption?.value ?? null} + onValueChange={(nextValue) => + nextValue && handleFormChange(form.variable, nextValue) + } + > + <SelectTrigger className="w-full"> + {selectedOption?.name ?? form.placeholder} + </SelectTrigger> + <SelectContent> + {selectOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + <SelectItemText>{option.name}</SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </div> + ) + })} </> ) } diff --git a/web/app/components/base/features/new-feature-panel/moderation/index.tsx b/web/app/components/base/features/new-feature-panel/moderation/index.tsx index 2e3e4c2f116fae..e251bc12b93241 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/index.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/index.tsx @@ -17,27 +17,20 @@ type Props = Readonly<{ onChange?: OnFeaturesChange }> -const Moderation = ({ - disabled, - onChange, -}: Props) => { +const Moderation = ({ disabled, onChange }: Props) => { const { t } = useTranslation() const { setShowModerationSettingModal } = useModalContext() const locale = useLocale() const featuresStore = useFeaturesStore() - const moderation = useFeatures(s => s.features.moderation) + const moderation = useFeatures((s) => s.features.moderation) const { data: codeBasedExtensionList } = useCodeBasedExtensions('moderation') const [isHovering, setIsHovering] = useState(false) const handleOpenModerationSettingModal = () => { /* v8 ignore next -- guarded path is not reachable in tests with a real disabled button because click is prevented at DOM level. @preserve */ - if (disabled) - return + if (disabled) return - const { - features, - setFeatures, - } = featuresStore!.getState() + const { features, setFeatures } = featuresStore!.getState() setShowModerationSettingModal({ payload: moderation as any, onSaveCallback: (newModeration) => { @@ -45,121 +38,130 @@ const Moderation = ({ draft.moderation = newModeration }) setFeatures(newFeatures) - if (onChange) - onChange(newFeatures) + if (onChange) onChange(newFeatures) }, onCancelCallback: () => { - if (onChange) - onChange() + if (onChange) onChange() }, }) } - const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (type: FeatureEnum, enabled: boolean) => { + const { features, setFeatures } = featuresStore!.getState() - if (enabled && !features.moderation?.type && type === FeatureEnum.moderation) { - setShowModerationSettingModal({ - payload: { - enabled: true, - type: 'keywords', - config: { - keywords: '', - inputs_config: { - enabled: true, - preset_response: '', + if (enabled && !features.moderation?.type && type === FeatureEnum.moderation) { + setShowModerationSettingModal({ + payload: { + enabled: true, + type: 'keywords', + config: { + keywords: '', + inputs_config: { + enabled: true, + preset_response: '', + }, }, }, - }, - onSaveCallback: (newModeration) => { - const newFeatures = produce(features, (draft) => { - draft.moderation = newModeration - }) - setFeatures(newFeatures) - if (onChange) - onChange(newFeatures) - }, - onCancelCallback: () => { - const newFeatures = produce(features, (draft) => { - draft.moderation = { enabled: false } - }) - setFeatures(newFeatures) - if (onChange) - onChange() - }, - }) - } + onSaveCallback: (newModeration) => { + const newFeatures = produce(features, (draft) => { + draft.moderation = newModeration + }) + setFeatures(newFeatures) + if (onChange) onChange(newFeatures) + }, + onCancelCallback: () => { + const newFeatures = produce(features, (draft) => { + draft.moderation = { enabled: false } + }) + setFeatures(newFeatures) + if (onChange) onChange() + }, + }) + } - if (!enabled) { - const newFeatures = produce(features, (draft) => { - draft.moderation = { enabled: false } - }) - setFeatures(newFeatures) - if (onChange) - onChange(newFeatures) - } - }, [featuresStore, onChange, setShowModerationSettingModal]) + if (!enabled) { + const newFeatures = produce(features, (draft) => { + draft.moderation = { enabled: false } + }) + setFeatures(newFeatures) + if (onChange) onChange(newFeatures) + } + }, + [featuresStore, onChange, setShowModerationSettingModal], + ) const providerContent = useMemo(() => { if (moderation?.type === 'openai_moderation') - return t($ => $['feature.moderation.modal.provider.openai'], { ns: 'appDebug' }) + return t(($) => $['feature.moderation.modal.provider.openai'], { ns: 'appDebug' }) else if (moderation?.type === 'keywords') - return t($ => $['feature.moderation.modal.provider.keywords'], { ns: 'appDebug' }) + return t(($) => $['feature.moderation.modal.provider.keywords'], { ns: 'appDebug' }) else if (moderation?.type === 'api') - return t($ => $['apiBasedExtension.selector.title'], { ns: 'common' }) + return t(($) => $['apiBasedExtension.selector.title'], { ns: 'common' }) else - return codeBasedExtensionList?.data.find(item => item.name === moderation?.type)?.label[locale] || '-' + return ( + codeBasedExtensionList?.data.find((item) => item.name === moderation?.type)?.label[ + locale + ] || '-' + ) }, [codeBasedExtensionList?.data, locale, moderation?.type, t]) const enableContent = useMemo(() => { if (moderation?.config?.inputs_config?.enabled && moderation.config?.outputs_config?.enabled) - return t($ => $['feature.moderation.allEnabled'], { ns: 'appDebug' }) + return t(($) => $['feature.moderation.allEnabled'], { ns: 'appDebug' }) else if (moderation?.config?.inputs_config?.enabled) - return t($ => $['feature.moderation.inputEnabled'], { ns: 'appDebug' }) + return t(($) => $['feature.moderation.inputEnabled'], { ns: 'appDebug' }) else if (moderation?.config?.outputs_config?.enabled) - return t($ => $['feature.moderation.outputEnabled'], { ns: 'appDebug' }) + return t(($) => $['feature.moderation.outputEnabled'], { ns: 'appDebug' }) }, [moderation?.config?.inputs_config?.enabled, moderation?.config?.outputs_config?.enabled, t]) return ( <FeatureCard - icon={( + icon={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-text-success p-1 shadow-xs"> <ContentModeration className="size-4 text-text-primary-on-surface" /> </div> - )} - title={t($ => $['feature.moderation.title'], { ns: 'appDebug' })} + } + title={t(($) => $['feature.moderation.title'], { ns: 'appDebug' })} value={!!moderation?.enabled} - onChange={state => handleChange(FeatureEnum.moderation, state)} + onChange={(state) => handleChange(FeatureEnum.moderation, state)} onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} disabled={disabled} > <> {!moderation?.enabled && ( - <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">{t($ => $['feature.moderation.description'], { ns: 'appDebug' })}</div> + <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"> + {t(($) => $['feature.moderation.description'], { ns: 'appDebug' })} + </div> )} {!!moderation?.enabled && ( <> {!isHovering && ( <div className="flex items-center gap-4 pt-0.5"> <div className=""> - <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['feature.moderation.modal.provider.title'], { ns: 'appDebug' })}</div> + <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['feature.moderation.modal.provider.title'], { ns: 'appDebug' })} + </div> <div className="system-xs-regular text-text-secondary">{providerContent}</div> </div> <div className="h-[27px] w-px rotate-12 bg-divider-subtle"></div> <div className=""> - <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['feature.moderation.contentEnableLabel'], { ns: 'appDebug' })}</div> + <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['feature.moderation.contentEnableLabel'], { ns: 'appDebug' })} + </div> <div className="system-xs-regular text-text-secondary">{enableContent}</div> </div> </div> )} {isHovering && ( - <Button className="w-full" onClick={handleOpenModerationSettingModal} disabled={disabled}> + <Button + className="w-full" + onClick={handleOpenModerationSettingModal} + disabled={disabled} + > <RiEqualizer2Line className="mr-1 size-4" /> - {t($ => $['operation.settings'], { ns: 'common' })} + {t(($) => $['operation.settings'], { ns: 'common' })} </Button> )} </> diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-content.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-content.tsx index c2fd40d1db50eb..f393feefbfd320 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-content.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-content.tsx @@ -23,12 +23,11 @@ const ModerationContent: FC<ModerationContentProps> = ({ const [presetResponse, setPresetResponse] = useState(config.preset_response || '') const handleConfigChange = (field: string, value: boolean | string) => { - if (field === 'preset_response' && typeof value === 'string') - value = value.slice(0, 100) + if (field === 'preset_response' && typeof value === 'string') value = value.slice(0, 100) onConfigChange({ ...config, - preset_response: field === 'preset_response' ? value as string : presetResponse, + preset_response: field === 'preset_response' ? (value as string) : presetResponse, [field]: value, }) } @@ -47,48 +46,49 @@ const ModerationContent: FC<ModerationContentProps> = ({ <div className="flex min-h-10 items-center gap-2 px-3 py-2"> <div className="min-w-0 flex-1 system-sm-medium text-text-secondary">{title}</div> <div className="flex min-w-0 shrink-0 items-center justify-end"> - { - info && ( - <div className="mr-2 truncate system-xs-regular text-text-tertiary" title={info}>{info}</div> - ) - } + {info && ( + <div className="mr-2 truncate system-xs-regular text-text-tertiary" title={info}> + {info} + </div> + )} <Switch checked={config.enabled} - onCheckedChange={v => handleConfigChange('enabled', v)} + onCheckedChange={(v) => handleConfigChange('enabled', v)} aria-label={title} /> </div> </div> - { - config.enabled && showPreset && ( - <div className="px-3 pt-0.5 pb-3"> - <div className="flex h-8 items-center justify-between gap-2"> - <span className="system-2xs-medium-uppercase text-text-secondary"> - {t($ => $['feature.moderation.modal.content.preset'], { ns: 'appDebug' })} - </span> - <span className="flex shrink-0 items-center gap-0.5 rounded bg-background-section px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> - <span className="i-ri-markdown-line size-3" aria-hidden /> - {t($ => $['feature.moderation.modal.content.supportMarkdown'], { ns: 'appDebug' })} - </span> - </div> - {/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */} - <div className="relative h-20"> - <Textarea - aria-label={t($ => $['feature.moderation.modal.content.preset'], { ns: 'appDebug' }) as string} - value={presetResponse} - className="size-full resize-none pb-8" - placeholder={t($ => $['feature.moderation.modal.content.placeholder'], { ns: 'appDebug' }) || ''} - onValueChange={handlePresetResponseChange} - /> - <div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 system-2xs-medium-uppercase text-text-quaternary"> - <span>{presetResponse.length}</span> - / - <span className="text-text-tertiary">100</span> - </div> + {config.enabled && showPreset && ( + <div className="px-3 pt-0.5 pb-3"> + <div className="flex h-8 items-center justify-between gap-2"> + <span className="system-2xs-medium-uppercase text-text-secondary"> + {t(($) => $['feature.moderation.modal.content.preset'], { ns: 'appDebug' })} + </span> + <span className="flex shrink-0 items-center gap-0.5 rounded bg-background-section px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> + <span className="i-ri-markdown-line size-3" aria-hidden /> + {t(($) => $['feature.moderation.modal.content.supportMarkdown'], { ns: 'appDebug' })} + </span> + </div> + {/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */} + <div className="relative h-20"> + <Textarea + aria-label={ + t(($) => $['feature.moderation.modal.content.preset'], { ns: 'appDebug' }) as string + } + value={presetResponse} + className="size-full resize-none pb-8" + placeholder={ + t(($) => $['feature.moderation.modal.content.placeholder'], { ns: 'appDebug' }) || + '' + } + onValueChange={handlePresetResponseChange} + /> + <div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 system-2xs-medium-uppercase text-text-quaternary"> + <span>{presetResponse.length}</span>/<span className="text-text-tertiary">100</span> </div> </div> - ) - } + </div> + )} </section> ) } diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx index 0fd19688e12cb1..65b6c97096acfa 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx @@ -40,9 +40,7 @@ function ProviderIcon({ type }: { type: string }) { function LabeledDivider({ children }: { children: ReactNode }) { return ( <div className="flex w-full items-center gap-2"> - <span className="shrink-0 system-xs-medium-uppercase text-text-tertiary"> - {children} - </span> + <span className="shrink-0 system-xs-medium-uppercase text-text-tertiary">{children}</span> <Divider bgStyle="gradient" className="my-0 h-px flex-1" /> </div> ) @@ -54,31 +52,31 @@ type ModerationSettingModalProps = { onSave: (moderationConfig: ModerationConfig) => void } -const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ - data, - onCancel, - onSave, -}) => { +const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ data, onCancel, onSave }) => { const { t } = useTranslation() const docLink = useDocLink() const locale = useLocale() - const { data: modelProviders, isPending: isLoading, refetch: refetchModelProviders } = useModelProviders() + const { + data: modelProviders, + isPending: isLoading, + refetch: refetchModelProviders, + } = useModelProviders() const localeDataRef = useRef<ModerationConfig>(data) const [localeData, setLocaleData] = useState<ModerationConfig>(data) const openIntegrationsSetting = useIntegrationsSetting() - const updateLocaleData = useCallback(( - update: ModerationConfig | ((current: ModerationConfig) => ModerationConfig), - options: { render?: boolean } = {}, - ) => { - const nextLocaleData = typeof update === 'function' - ? update(localeDataRef.current) - : update - - localeDataRef.current = nextLocaleData - - if (options.render !== false) - setLocaleData(nextLocaleData) - }, []) + const updateLocaleData = useCallback( + ( + update: ModerationConfig | ((current: ModerationConfig) => ModerationConfig), + options: { render?: boolean } = {}, + ) => { + const nextLocaleData = typeof update === 'function' ? update(localeDataRef.current) : update + + localeDataRef.current = nextLocaleData + + if (options.render !== false) setLocaleData(nextLocaleData) + }, + [], + ) const handleOpenSettingsModal = () => { openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER, @@ -86,51 +84,59 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ }) } const { data: codeBasedExtensionList } = useCodeBasedExtensions('moderation') - const openaiProvider = modelProviders?.data.find(item => item.provider === 'langgenius/openai/openai') + const openaiProvider = modelProviders?.data.find( + (item) => item.provider === 'langgenius/openai/openai', + ) const systemOpenaiProviderEnabled = openaiProvider?.system_configuration.enabled - const systemOpenaiProviderQuota = systemOpenaiProviderEnabled ? openaiProvider?.system_configuration.quota_configurations.find(item => item.quota_type === openaiProvider.system_configuration.current_quota_type) : undefined + const systemOpenaiProviderQuota = systemOpenaiProviderEnabled + ? openaiProvider?.system_configuration.quota_configurations.find( + (item) => item.quota_type === openaiProvider.system_configuration.current_quota_type, + ) + : undefined const systemOpenaiProviderCanUse = systemOpenaiProviderQuota?.is_valid - const customOpenaiProvidersCanUse = openaiProvider?.custom_configuration.status === CustomConfigurationStatusEnum.active + const customOpenaiProvidersCanUse = + openaiProvider?.custom_configuration.status === CustomConfigurationStatusEnum.active const isOpenAIProviderConfigured = customOpenaiProvidersCanUse || systemOpenaiProviderCanUse const providers: Provider[] = [ { key: 'openai_moderation', - name: t($ => $['feature.moderation.modal.provider.openai'], { ns: 'appDebug' }), + name: t(($) => $['feature.moderation.modal.provider.openai'], { ns: 'appDebug' }), }, { key: 'keywords', - name: t($ => $['feature.moderation.modal.provider.keywords'], { ns: 'appDebug' }), + name: t(($) => $['feature.moderation.modal.provider.keywords'], { ns: 'appDebug' }), }, { key: 'api', - name: t($ => $['apiBasedExtension.selector.title'], { ns: 'common' }), + name: t(($) => $['apiBasedExtension.selector.title'], { ns: 'common' }), }, - ...( - codeBasedExtensionList - ? codeBasedExtensionList.data.map((item) => { - return { - key: item.name, - name: locale === 'zh-Hans' ? item.label['zh-Hans'] : item.label['en-US'], - form_schema: item.form_schema, - } - }) - : [] - ), + ...(codeBasedExtensionList + ? codeBasedExtensionList.data.map((item) => { + return { + key: item.name, + name: locale === 'zh-Hans' ? item.label['zh-Hans'] : item.label['en-US'], + form_schema: item.form_schema, + } + }) + : []), ] - const currentProvider = providers.find(provider => provider.key === localeData.type) + const currentProvider = providers.find((provider) => provider.key === localeData.type) const handleDataTypeChange = (type: string) => { let config: undefined | Record<string, string> - const currProvider = providers.find(provider => provider.key === type) - - if (systemTypes.findIndex(t => t === type) < 0 && currProvider?.form_schema) { - config = currProvider?.form_schema.reduce((prev, next) => { - prev[next.variable] = next.default - return prev - }, {} as Record<string, string>) + const currProvider = providers.find((provider) => provider.key === type) + + if (systemTypes.findIndex((t) => t === type) < 0 && currProvider?.form_schema) { + config = currProvider?.form_schema.reduce( + (prev, next) => { + prev[next.variable] = next.default + return prev + }, + {} as Record<string, string>, + ) } - updateLocaleData(current => ({ + updateLocaleData((current) => ({ ...current, type, config, @@ -139,15 +145,13 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ const handleDataKeywordsChange = (value: string) => { const arr = value.split('\n').reduce((prev: string[], next: string) => { - if (next !== '') - prev.push(next.slice(0, 100)) - if (next === '' && prev[prev.length - 1] !== '') - prev.push(next) + if (next !== '') prev.push(next.slice(0, 100)) + if (next === '' && prev[prev.length - 1] !== '') prev.push(next) return prev }, []) - updateLocaleData(current => ({ + updateLocaleData((current) => ({ ...current, config: { ...current.config, @@ -157,20 +161,25 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ } const handleDataContentChange = (contentType: string, contentConfig: ModerationContentConfig) => { - const previousContentConfig = localeDataRef.current.config?.[contentType] as ModerationContentConfig | undefined + const previousContentConfig = localeDataRef.current.config?.[contentType] as + | ModerationContentConfig + | undefined const shouldRender = previousContentConfig?.enabled !== contentConfig.enabled - updateLocaleData(current => ({ - ...current, - config: { - ...current.config, - [contentType]: contentConfig, - }, - }), { render: shouldRender }) + updateLocaleData( + (current) => ({ + ...current, + config: { + ...current.config, + [contentType]: contentConfig, + }, + }), + { render: shouldRender }, + ) } const handleDataApiBasedChange = (apiBasedExtensionId: string) => { - updateLocaleData(current => ({ + updateLocaleData((current) => ({ ...current, config: { ...current.config, @@ -180,7 +189,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ } const handleDataExtraChange = (extraValue: Record<string, string>) => { - updateLocaleData(current => ({ + updateLocaleData((current) => ({ ...current, config: { ...current.config, @@ -194,13 +203,11 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ const { inputs_config, outputs_config } = config! const params: Record<string, string | undefined> = {} - if (type === 'keywords') - params.keywords = config?.keywords + if (type === 'keywords') params.keywords = config?.keywords - if (type === 'api') - params.api_based_extension_id = config?.api_based_extension_id + if (type === 'api') params.api_based_extension_id = config?.api_based_extension_id - if (systemTypes.findIndex(t => t === type) < 0 && currentProvider?.form_schema) { + if (systemTypes.findIndex((t) => t === type) < 0 && currentProvider?.form_schema) { currentProvider.form_schema.forEach((form) => { params[form.variable] = config?.[form.variable] }) @@ -219,43 +226,77 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ const handleSave = () => { const currentLocaleData = localeDataRef.current - const providerForSave = providers.find(provider => provider.key === currentLocaleData.type) + const providerForSave = providers.find((provider) => provider.key === currentLocaleData.type) /* v8 ignore next -- UI-invariant guard: same condition is used in Save button disabled logic, so when true handleSave has no user-triggerable invocation path. @preserve */ - if (currentLocaleData.type === 'openai_moderation' && !isOpenAIProviderConfigured) - return + if (currentLocaleData.type === 'openai_moderation' && !isOpenAIProviderConfigured) return - if (!currentLocaleData.config?.inputs_config?.enabled && !currentLocaleData.config?.outputs_config?.enabled) { - toast.error(t($ => $['feature.moderation.modal.content.condition'], { ns: 'appDebug' })) + if ( + !currentLocaleData.config?.inputs_config?.enabled && + !currentLocaleData.config?.outputs_config?.enabled + ) { + toast.error(t(($) => $['feature.moderation.modal.content.condition'], { ns: 'appDebug' })) return } if (currentLocaleData.type === 'keywords' && !currentLocaleData.config.keywords) { - toast.error(t($ => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? 'keywords' : '关键词' })) + toast.error( + t(($) => $['errorMessage.valueOfVarRequired'], { + ns: 'appDebug', + key: locale !== LanguagesSupported[1] ? 'keywords' : '关键词', + }), + ) return } if (currentLocaleData.type === 'api' && !currentLocaleData.config.api_based_extension_id) { - toast.error(t($ => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? 'API Extension' : 'API 扩展' })) + toast.error( + t(($) => $['errorMessage.valueOfVarRequired'], { + ns: 'appDebug', + key: locale !== LanguagesSupported[1] ? 'API Extension' : 'API 扩展', + }), + ) return } - if (systemTypes.findIndex(t => t === currentLocaleData.type) < 0 && providerForSave?.form_schema) { + if ( + systemTypes.findIndex((t) => t === currentLocaleData.type) < 0 && + providerForSave?.form_schema + ) { for (let i = 0; i < providerForSave.form_schema.length; i++) { - if (!currentLocaleData.config?.[providerForSave.form_schema[i]!.variable] && providerForSave.form_schema[i]!.required) { - toast.error(t($ => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? providerForSave.form_schema[i]!.label['en-US'] : providerForSave.form_schema[i]!.label['zh-Hans'] })) + if ( + !currentLocaleData.config?.[providerForSave.form_schema[i]!.variable] && + providerForSave.form_schema[i]!.required + ) { + toast.error( + t(($) => $['errorMessage.valueOfVarRequired'], { + ns: 'appDebug', + key: + locale !== LanguagesSupported[1] + ? providerForSave.form_schema[i]!.label['en-US'] + : providerForSave.form_schema[i]!.label['zh-Hans'], + }), + ) return } } } - if (currentLocaleData.config.inputs_config?.enabled && !currentLocaleData.config.inputs_config.preset_response && currentLocaleData.type !== 'api') { - toast.error(t($ => $['feature.moderation.modal.content.errorMessage'], { ns: 'appDebug' })) + if ( + currentLocaleData.config.inputs_config?.enabled && + !currentLocaleData.config.inputs_config.preset_response && + currentLocaleData.type !== 'api' + ) { + toast.error(t(($) => $['feature.moderation.modal.content.errorMessage'], { ns: 'appDebug' })) return } - if (currentLocaleData.config.outputs_config?.enabled && !currentLocaleData.config.outputs_config.preset_response && currentLocaleData.type !== 'api') { - toast.error(t($ => $['feature.moderation.modal.content.errorMessage'], { ns: 'appDebug' })) + if ( + currentLocaleData.config.outputs_config?.enabled && + !currentLocaleData.config.outputs_config.preset_response && + currentLocaleData.type !== 'api' + ) { + toast.error(t(($) => $['feature.moderation.modal.content.errorMessage'], { ns: 'appDebug' })) return } @@ -267,11 +308,11 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ <DialogContent className="mt-14! w-[600px]! max-w-none! overflow-hidden border-[0.5px]! border-components-panel-border! p-0! text-left align-middle"> <div className="flex items-start gap-2 px-6 pt-6 pr-14 pb-3"> <div className="title-2xl-semi-bold text-text-primary"> - {t($ => $['feature.moderation.modal.title'], { ns: 'appDebug' })} + {t(($) => $['feature.moderation.modal.title'], { ns: 'appDebug' })} </div> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-5 right-5 flex size-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={onCancel} > @@ -281,68 +322,91 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ <div className="flex flex-col gap-4 px-6 py-3"> <div className="flex flex-col gap-1"> <div className="system-sm-medium text-text-secondary"> - {t($ => $['feature.moderation.modal.provider.title'], { ns: 'appDebug' })} + {t(($) => $['feature.moderation.modal.provider.title'], { ns: 'appDebug' })} </div> <div className="grid grid-cols-3 gap-2"> - {providers.map(provider => ( + {providers.map((provider) => ( <button type="button" key={provider.key} className={cn( 'flex min-h-[68px] flex-col items-start justify-center gap-1.5 rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg px-3 py-2 text-left text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', - localeData.type !== provider.key && 'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', - localeData.type === provider.key && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs', - localeData.type === 'openai_moderation' && provider.key === 'openai_moderation' && !isOpenAIProviderConfigured && 'text-text-disabled', + localeData.type !== provider.key && + 'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', + localeData.type === provider.key && + 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs', + localeData.type === 'openai_moderation' && + provider.key === 'openai_moderation' && + !isOpenAIProviderConfigured && + 'text-text-disabled', )} onClick={() => handleDataTypeChange(provider.key)} > <div className="flex size-8 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-background-default-dodge"> <ProviderIcon type={provider.key} /> </div> - <span className="w-full truncate system-xs-regular"> - {provider.name} - </span> + <span className="w-full truncate system-xs-regular">{provider.name}</span> </button> ))} </div> - {!isLoading && !isOpenAIProviderConfigured && localeData.type === 'openai_moderation' && ( - <div className="mt-2 flex items-center rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 py-2"> - <span className="mr-1 i-custom-vender-line-general-info-circle h-4 w-4 text-[#F79009]" /> - <div className="flex items-center text-xs font-medium text-gray-700"> - {t($ => $['feature.moderation.modal.openaiNotConfig.before'], { ns: 'appDebug' })} - <button - type="button" - className="cursor-pointer text-primary-600" - onClick={handleOpenSettingsModal} - > -   - {t($ => $['settings.provider'], { ns: 'common' })} -   - </button> - {t($ => $['feature.moderation.modal.openaiNotConfig.after'], { ns: 'appDebug' })} + {!isLoading && + !isOpenAIProviderConfigured && + localeData.type === 'openai_moderation' && ( + <div className="mt-2 flex items-center rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 py-2"> + <span className="mr-1 i-custom-vender-line-general-info-circle h-4 w-4 text-[#F79009]" /> + <div className="flex items-center text-xs font-medium text-gray-700"> + {t(($) => $['feature.moderation.modal.openaiNotConfig.before'], { + ns: 'appDebug', + })} + <button + type="button" + className="cursor-pointer text-primary-600" + onClick={handleOpenSettingsModal} + > +   + {t(($) => $['settings.provider'], { ns: 'common' })} +   + </button> + {t(($) => $['feature.moderation.modal.openaiNotConfig.after'], { + ns: 'appDebug', + })} + </div> </div> - </div> - )} + )} </div> {localeData.type === 'keywords' && ( <div className="flex flex-col gap-1"> - <div className="system-sm-medium text-text-secondary">{t($ => $['feature.moderation.modal.provider.keywords'], { ns: 'appDebug' })}</div> - <div className="system-xs-regular text-text-tertiary">{t($ => $['feature.moderation.modal.keywords.tip'], { ns: 'appDebug' })}</div> + <div className="system-sm-medium text-text-secondary"> + {t(($) => $['feature.moderation.modal.provider.keywords'], { ns: 'appDebug' })} + </div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['feature.moderation.modal.keywords.tip'], { ns: 'appDebug' })} + </div> {/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */} <div className="relative h-[88px]"> <Textarea - aria-label={t($ => $['feature.moderation.modal.provider.keywords'], { ns: 'appDebug' }) as string} + aria-label={ + t(($) => $['feature.moderation.modal.provider.keywords'], { + ns: 'appDebug', + }) as string + } value={localeData.config?.keywords || ''} onValueChange={handleDataKeywordsChange} className="size-full resize-none pb-8" - placeholder={t($ => $['feature.moderation.modal.keywords.placeholder'], { ns: 'appDebug' }) || ''} + placeholder={ + t(($) => $['feature.moderation.modal.keywords.placeholder'], { + ns: 'appDebug', + }) || '' + } /> <div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 system-2xs-medium-uppercase text-text-quaternary"> - <span>{(localeData.config?.keywords || '').split('\n').filter(Boolean).length}</span> + <span> + {(localeData.config?.keywords || '').split('\n').filter(Boolean).length} + </span> / <span className="text-text-tertiary"> 100 - {t($ => $['feature.moderation.modal.keywords.line'], { ns: 'appDebug' })} + {t(($) => $['feature.moderation.modal.keywords.line'], { ns: 'appDebug' })} </span> </div> </div> @@ -351,7 +415,9 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ {localeData.type === 'api' && ( <div className="flex flex-col gap-1"> <div className="flex h-6 items-center justify-between"> - <div className="system-sm-medium text-text-secondary">{t($ => $['apiBasedExtension.selector.title'], { ns: 'common' })}</div> + <div className="system-sm-medium text-text-secondary"> + {t(($) => $['apiBasedExtension.selector.title'], { ns: 'common' })} + </div> <a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" @@ -359,7 +425,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ className="group flex items-center system-xs-regular text-text-tertiary hover:text-primary-600" > <span className="mr-1 i-custom-vender-line-education-book-open-01 size-3 text-text-tertiary group-hover:text-primary-600" /> - {t($ => $['apiBasedExtension.link'], { ns: 'common' })} + {t(($) => $['apiBasedExtension.link'], { ns: 'common' })} </a> </div> <ApiBasedExtensionSelector @@ -368,9 +434,8 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ /> </div> )} - {systemTypes.findIndex(t => t === localeData.type) < 0 - && currentProvider?.form_schema - && ( + {systemTypes.findIndex((t) => t === localeData.type) < 0 && + currentProvider?.form_schema && ( <FormGeneration forms={currentProvider?.form_schema} value={localeData.config} @@ -378,33 +443,45 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ /> )} <div className="flex flex-col gap-2"> - <LabeledDivider>{t($ => $['feature.moderation.title'], { ns: 'appDebug' })}</LabeledDivider> + <LabeledDivider> + {t(($) => $['feature.moderation.title'], { ns: 'appDebug' })} + </LabeledDivider> <ModerationContent key={`inputs-${localeData.type}-${localeData.config?.inputs_config?.preset_response ?? ''}`} - title={t($ => $['feature.moderation.modal.content.input'], { ns: 'appDebug' }) || ''} + title={ + t(($) => $['feature.moderation.modal.content.input'], { ns: 'appDebug' }) || '' + } config={localeData.config?.inputs_config || { enabled: false, preset_response: '' }} - onConfigChange={config => handleDataContentChange('inputs_config', config)} - info={(localeData.type === 'api' && t($ => $['feature.moderation.modal.content.fromApi'], { ns: 'appDebug' })) || ''} + onConfigChange={(config) => handleDataContentChange('inputs_config', config)} + info={ + (localeData.type === 'api' && + t(($) => $['feature.moderation.modal.content.fromApi'], { ns: 'appDebug' })) || + '' + } showPreset={localeData.type !== 'api'} /> <ModerationContent key={`outputs-${localeData.type}-${localeData.config?.outputs_config?.preset_response ?? ''}`} - title={t($ => $['feature.moderation.modal.content.output'], { ns: 'appDebug' }) || ''} + title={ + t(($) => $['feature.moderation.modal.content.output'], { ns: 'appDebug' }) || '' + } config={localeData.config?.outputs_config || { enabled: false, preset_response: '' }} - onConfigChange={config => handleDataContentChange('outputs_config', config)} - info={(localeData.type === 'api' && t($ => $['feature.moderation.modal.content.fromApi'], { ns: 'appDebug' })) || ''} + onConfigChange={(config) => handleDataContentChange('outputs_config', config)} + info={ + (localeData.type === 'api' && + t(($) => $['feature.moderation.modal.content.fromApi'], { ns: 'appDebug' })) || + '' + } showPreset={localeData.type !== 'api'} /> - <div className="py-0.5 system-xs-regular text-text-tertiary">{t($ => $['feature.moderation.modal.content.condition'], { ns: 'appDebug' })}</div> + <div className="py-0.5 system-xs-regular text-text-tertiary"> + {t(($) => $['feature.moderation.modal.content.condition'], { ns: 'appDebug' })} + </div> </div> </div> <div className="flex h-[76px] items-center justify-end gap-2 px-6 pt-5 pb-6"> - <Button - onClick={onCancel} - size="medium" - className="min-w-[72px]" - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button onClick={onCancel} size="medium" className="min-w-[72px]"> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button variant="primary" @@ -413,7 +490,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ disabled={localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured} className="min-w-[72px]" > - {t($ => $['operation.save'], { ns: 'common' })} + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/app/components/base/features/new-feature-panel/more-like-this.tsx b/web/app/components/base/features/new-feature-panel/more-like-this.tsx index 33485154584432..b5f9c9a8a18ac5 100644 --- a/web/app/components/base/features/new-feature-panel/more-like-this.tsx +++ b/web/app/components/base/features/new-feature-panel/more-like-this.tsx @@ -13,43 +13,39 @@ type Props = Readonly<{ onChange?: OnFeaturesChange }> -const MoreLikeThis = ({ - disabled, - onChange, -}: Props) => { +const MoreLikeThis = ({ disabled, onChange }: Props) => { const { t } = useTranslation() - const features = useFeatures(s => s.features) + const features = useFeatures((s) => s.features) const featuresStore = useFeaturesStore() - const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (type: FeatureEnum, enabled: boolean) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - draft[type] = { - ...draft[type], - enabled, - } - }) - setFeatures(newFeatures) - if (onChange) - onChange() - }, [featuresStore, onChange]) + const newFeatures = produce(features, (draft) => { + draft[type] = { + ...draft[type], + enabled, + } + }) + setFeatures(newFeatures) + if (onChange) onChange() + }, + [featuresStore, onChange], + ) return ( <FeatureCard - icon={( + icon={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs"> <RiSparklingFill className="size-4 text-text-primary-on-surface" /> </div> - )} - title={t($ => $['feature.moreLikeThis.title'], { ns: 'appDebug' })} - tooltip={t($ => $['feature.moreLikeThis.tip'], { ns: 'appDebug' })} + } + title={t(($) => $['feature.moreLikeThis.title'], { ns: 'appDebug' })} + tooltip={t(($) => $['feature.moreLikeThis.tip'], { ns: 'appDebug' })} value={!!features.moreLikeThis?.enabled} - description={t($ => $['feature.moreLikeThis.description'], { ns: 'appDebug' })!} - onChange={state => handleChange(FeatureEnum.moreLikeThis, state)} + description={t(($) => $['feature.moreLikeThis.description'], { ns: 'appDebug' })!} + onChange={(state) => handleChange(FeatureEnum.moreLikeThis, state)} disabled={disabled} /> ) diff --git a/web/app/components/base/features/new-feature-panel/speech-to-text.tsx b/web/app/components/base/features/new-feature-panel/speech-to-text.tsx index ceffae592bd2ed..258d00000edbc0 100644 --- a/web/app/components/base/features/new-feature-panel/speech-to-text.tsx +++ b/web/app/components/base/features/new-feature-panel/speech-to-text.tsx @@ -13,42 +13,38 @@ type Props = Readonly<{ onChange?: OnFeaturesChange }> -const SpeechToText = ({ - disabled, - onChange, -}: Props) => { +const SpeechToText = ({ disabled, onChange }: Props) => { const { t } = useTranslation() - const features = useFeatures(s => s.features) + const features = useFeatures((s) => s.features) const featuresStore = useFeaturesStore() - const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (type: FeatureEnum, enabled: boolean) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - draft[type] = { - ...draft[type], - enabled, - } - }) - setFeatures(newFeatures) - if (onChange) - onChange() - }, [featuresStore, onChange]) + const newFeatures = produce(features, (draft) => { + draft[type] = { + ...draft[type], + enabled, + } + }) + setFeatures(newFeatures) + if (onChange) onChange() + }, + [featuresStore, onChange], + ) return ( <FeatureCard - icon={( + icon={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-violet-violet-600 p-1 shadow-xs"> <Microphone01 className="size-4 text-text-primary-on-surface" /> </div> - )} - title={t($ => $['feature.speechToText.title'], { ns: 'appDebug' })} + } + title={t(($) => $['feature.speechToText.title'], { ns: 'appDebug' })} value={!!features.speech2text?.enabled} - description={t($ => $['feature.speechToText.description'], { ns: 'appDebug' })!} - onChange={state => handleChange(FeatureEnum.speech2text, state)} + description={t(($) => $['feature.speechToText.description'], { ns: 'appDebug' })!} + onChange={(state) => handleChange(FeatureEnum.speech2text, state)} disabled={disabled} /> ) diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/index.spec.tsx index 75c420adfc5729..6e4d2961df8729 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/index.spec.tsx @@ -24,7 +24,9 @@ vi.mock('../voice-settings', () => ({ children: ReactNode }) => ( <div data-testid="voice-settings" data-open={open ? 'true' : 'false'}> - <button data-testid="open-voice-settings" onClick={() => onOpen(true)}>open-voice-settings</button> + <button data-testid="open-voice-settings" onClick={() => onOpen(true)}> + open-voice-settings + </button> {children} </div> ), @@ -43,7 +45,7 @@ const defaultFeatures: Features = { } const renderWithProvider = ( - props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}, + props: { disabled?: boolean; onChange?: OnFeaturesChange } = {}, featureOverrides?: Partial<Features>, ) => { const features = { ...defaultFeatures, ...featureOverrides } @@ -93,26 +95,35 @@ describe('TextToSpeech', () => { }) it('should show language and voice info when enabled and not hovering', () => { - renderWithProvider({}, { - text2speech: { enabled: true, language: 'en-US', voice: 'alloy' }, - }) + renderWithProvider( + {}, + { + text2speech: { enabled: true, language: 'en-US', voice: 'alloy' }, + }, + ) expect(screen.getByText('English')).toBeInTheDocument() expect(screen.getByText('alloy')).toBeInTheDocument() }) it('should show default display text when voice is not set', () => { - renderWithProvider({}, { - text2speech: { enabled: true, language: 'en-US' }, - }) + renderWithProvider( + {}, + { + text2speech: { enabled: true, language: 'en-US' }, + }, + ) expect(screen.getByText(/voice\.defaultDisplay/)).toBeInTheDocument() }) it('should show voice settings button when hovering', () => { - renderWithProvider({}, { - text2speech: { enabled: true }, - }) + renderWithProvider( + {}, + { + text2speech: { enabled: true }, + }, + ) // Simulate mouse enter on the feature card const card = screen.getByText(/feature\.textToSpeech\.title/).closest('[class]')! @@ -122,9 +133,12 @@ describe('TextToSpeech', () => { }) it('should hide voice settings button after mouse leave', () => { - renderWithProvider({}, { - text2speech: { enabled: true }, - }) + renderWithProvider( + {}, + { + text2speech: { enabled: true }, + }, + ) const card = screen.getByText(/feature\.textToSpeech\.title/).closest('[class]')! fireEvent.mouseEnter(card) @@ -135,25 +149,34 @@ describe('TextToSpeech', () => { }) it('should show autoPlay enabled text when autoPlay is enabled', () => { - renderWithProvider({}, { - text2speech: { enabled: true, language: 'en-US', autoPlay: TtsAutoPlay.enabled }, - }) + renderWithProvider( + {}, + { + text2speech: { enabled: true, language: 'en-US', autoPlay: TtsAutoPlay.enabled }, + }, + ) expect(screen.getByText(/voice\.voiceSettings\.autoPlayEnabled/)).toBeInTheDocument() }) it('should show autoPlay disabled text when autoPlay is not enabled', () => { - renderWithProvider({}, { - text2speech: { enabled: true, language: 'en-US' }, - }) + renderWithProvider( + {}, + { + text2speech: { enabled: true, language: 'en-US' }, + }, + ) expect(screen.getByText(/voice\.voiceSettings\.autoPlayDisabled/)).toBeInTheDocument() }) it('should pass open false to voice settings when disabled and modal is opened', () => { - renderWithProvider({ disabled: true }, { - text2speech: { enabled: true }, - }) + renderWithProvider( + { disabled: true }, + { + text2speech: { enabled: true }, + }, + ) const card = screen.getByText(/feature\.textToSpeech\.title/).closest('[class]')! fireEvent.mouseEnter(card) diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx index 06ed515efebecd..db00810c07a460 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx @@ -13,7 +13,7 @@ let mockLanguages = [ let mockPathname = '/app/test-app-id/configuration' -let mockVoiceItems: { value: string, name: string }[] | undefined = [ +let mockVoiceItems: { value: string; name: string }[] | undefined = [ { value: 'alloy', name: 'Alloy' }, { value: 'echo', name: 'Echo' }, ] @@ -50,21 +50,19 @@ const defaultFeatures: Features = { } const renderWithProvider = ( - props: { onClose?: () => void, onChange?: OnFeaturesChange } = {}, + props: { onClose?: () => void; onChange?: OnFeaturesChange } = {}, featureOverrides?: Partial<Features>, ) => { const features = { ...defaultFeatures, ...featureOverrides } return render( <FeaturesProvider features={features}> - <ParamConfigContent - onClose={props.onClose ?? vi.fn()} - onChange={props.onChange} - /> + <ParamConfigContent onClose={props.onClose ?? vi.fn()} onChange={props.onChange} /> </FeaturesProvider>, ) } -const getLanguageSelect = () => screen.getByRole('combobox', { name: /voice\.voiceSettings\.language/ }) +const getLanguageSelect = () => + screen.getByRole('combobox', { name: /voice\.voiceSettings\.language/ }) const getVoiceSelect = () => screen.getByRole('combobox', { name: /voice\.voiceSettings\.voice/ }) describe('ParamConfigContent', () => { @@ -113,7 +111,9 @@ describe('ParamConfigContent', () => { const languageLabel = screen.getByText(/voice\.voiceSettings\.language/) expect(languageLabel)!.toBeInTheDocument() - expect(screen.getByRole('button', { name: /voice\.voiceSettings\.resolutionTooltip/ }))!.toBeInTheDocument() + expect( + screen.getByRole('button', { name: /voice\.voiceSettings\.resolutionTooltip/ }), + )!.toBeInTheDocument() }) it('should display language listbox button', () => { @@ -148,17 +148,28 @@ describe('ParamConfigContent', () => { }) it('should render with no language set and use first as default', () => { - renderWithProvider({}, { - text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled }, - }) + renderWithProvider( + {}, + { + text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled }, + }, + ) expect(getLanguageSelect()).toBeInTheDocument() }) it('should render with no voice set and use first as default', () => { - renderWithProvider({}, { - text2speech: { enabled: true, language: 'en-US', voice: 'nonexistent', autoPlay: TtsAutoPlay.disabled }, - }) + renderWithProvider( + {}, + { + text2speech: { + enabled: true, + language: 'en-US', + voice: 'nonexistent', + autoPlay: TtsAutoPlay.disabled, + }, + }, + ) expect(getVoiceSelect()).toHaveTextContent('Alloy') }) @@ -213,7 +224,14 @@ describe('ParamConfigContent', () => { const onChange = vi.fn() renderWithProvider( { onChange }, - { text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.enabled } }, + { + text2speech: { + enabled: true, + language: 'en-US', + voice: 'alloy', + autoPlay: TtsAutoPlay.enabled, + }, + }, ) const autoPlaySwitch = screen.getByRole('switch') @@ -271,7 +289,7 @@ describe('ParamConfigContent', () => { const options = await screen.findAllByRole('option') expect(options.length).toBeGreaterThanOrEqual(1) - const selectedOption = options.find(opt => opt.textContent?.includes('voice.language.enUS')) + const selectedOption = options.find((opt) => opt.textContent?.includes('voice.language.enUS')) expect(selectedOption).toBeDefined() expect(selectedOption)!.toHaveAttribute('aria-selected', 'true') }) @@ -283,7 +301,7 @@ describe('ParamConfigContent', () => { const options = await screen.findAllByRole('option') expect(options.length).toBeGreaterThanOrEqual(1) - const selectedOption = options.find(opt => opt.textContent?.includes('Alloy')) + const selectedOption = options.find((opt) => opt.textContent?.includes('Alloy')) expect(selectedOption).toBeDefined() expect(selectedOption)!.toHaveAttribute('aria-selected', 'true') }) @@ -295,9 +313,17 @@ describe('ParamConfigContent', () => { mockLanguages = [] mockVoiceItems = undefined - renderWithProvider({}, { - text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.disabled }, - }) + renderWithProvider( + {}, + { + text2speech: { + enabled: true, + language: 'en-US', + voice: 'alloy', + autoPlay: TtsAutoPlay.disabled, + }, + }, + ) const placeholderTexts = screen.getAllByText(/placeholder\.select/) expect(placeholderTexts.length).toBeGreaterThanOrEqual(2) @@ -316,9 +342,12 @@ describe('ParamConfigContent', () => { it('should render language text when selected language value is empty string', () => { mockLanguages = [{ value: '' as string, name: 'Unknown Language', example: '' }] - renderWithProvider({}, { - text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled }, - }) + renderWithProvider( + {}, + { + text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled }, + }, + ) expect(screen.getByText(/voice\.language\./))!.toBeInTheDocument() }) diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx index bd2f7e477da165..5f7c2eb927467b 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx @@ -57,11 +57,7 @@ const defaultFeatures: Features = { } const renderWithProvider = (ui: ReactNode) => { - return render( - <FeaturesProvider features={defaultFeatures}> - {ui} - </FeaturesProvider>, - ) + return render(<FeaturesProvider features={defaultFeatures}>{ui}</FeaturesProvider>) } describe('VoiceSettings', () => { diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/index.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/index.tsx index 09a4513a6f2d13..0d6c1f7723aa4c 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/index.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/index.tsx @@ -18,78 +18,95 @@ type Props = Readonly<{ onChange?: OnFeaturesChange }> -const TextToSpeech = ({ - disabled, - onChange, -}: Props) => { +const TextToSpeech = ({ disabled, onChange }: Props) => { const { t } = useTranslation() - const textToSpeech = useFeatures(s => s.features.text2speech) // .language .voice .autoPlay - const languageInfo = languages.find(i => i.value === textToSpeech?.language) + const textToSpeech = useFeatures((s) => s.features.text2speech) // .language .voice .autoPlay + const languageInfo = languages.find((i) => i.value === textToSpeech?.language) const [modalOpen, setModalOpen] = useState(false) const [isHovering, setIsHovering] = useState(false) - const features = useFeatures(s => s.features) + const features = useFeatures((s) => s.features) const featuresStore = useFeaturesStore() - const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const handleChange = useCallback( + (type: FeatureEnum, enabled: boolean) => { + const { features, setFeatures } = featuresStore!.getState() - const newFeatures = produce(features, (draft) => { - draft[type] = { - ...draft[type], - enabled, - } - }) - setFeatures(newFeatures) - if (onChange) - onChange() - }, [featuresStore, onChange]) + const newFeatures = produce(features, (draft) => { + draft[type] = { + ...draft[type], + enabled, + } + }) + setFeatures(newFeatures) + if (onChange) onChange() + }, + [featuresStore, onChange], + ) return ( <FeatureCard - icon={( + icon={ <div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-violet-violet-600 p-1 shadow-xs"> <TextToAudio className="size-4 text-text-primary-on-surface" /> </div> - )} - title={t($ => $['feature.textToSpeech.title'], { ns: 'appDebug' })} + } + title={t(($) => $['feature.textToSpeech.title'], { ns: 'appDebug' })} value={!!features.text2speech?.enabled} - onChange={state => handleChange(FeatureEnum.text2speech, state)} + onChange={(state) => handleChange(FeatureEnum.text2speech, state)} onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} disabled={disabled} > <> {!features.text2speech?.enabled && ( - <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">{t($ => $['feature.textToSpeech.description'], { ns: 'appDebug' })}</div> + <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"> + {t(($) => $['feature.textToSpeech.description'], { ns: 'appDebug' })} + </div> )} {!!features.text2speech?.enabled && ( <> {!isHovering && !modalOpen && ( <div className="flex items-center gap-4 pt-0.5"> <div className=""> - <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['voice.voiceSettings.language'], { ns: 'appDebug' })}</div> - <div className="system-xs-regular text-text-secondary">{languageInfo?.name || '-'}</div> + <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['voice.voiceSettings.language'], { ns: 'appDebug' })} + </div> + <div className="system-xs-regular text-text-secondary"> + {languageInfo?.name || '-'} + </div> </div> <div className="h-[27px] w-px rotate-12 bg-divider-subtle"></div> <div className=""> - <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['voice.voiceSettings.voice'], { ns: 'appDebug' })}</div> - <div className="system-xs-regular text-text-secondary">{features.text2speech?.voice || t($ => $['voice.defaultDisplay'], { ns: 'appDebug' })}</div> + <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['voice.voiceSettings.voice'], { ns: 'appDebug' })} + </div> + <div className="system-xs-regular text-text-secondary"> + {features.text2speech?.voice || + t(($) => $['voice.defaultDisplay'], { ns: 'appDebug' })} + </div> </div> <div className="h-[27px] w-px rotate-12 bg-divider-subtle"></div> <div className=""> - <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['voice.voiceSettings.autoPlay'], { ns: 'appDebug' })}</div> - <div className="system-xs-regular text-text-secondary">{features.text2speech?.autoPlay === TtsAutoPlay.enabled ? t($ => $['voice.voiceSettings.autoPlayEnabled'], { ns: 'appDebug' }) : t($ => $['voice.voiceSettings.autoPlayDisabled'], { ns: 'appDebug' })}</div> + <div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['voice.voiceSettings.autoPlay'], { ns: 'appDebug' })} + </div> + <div className="system-xs-regular text-text-secondary"> + {features.text2speech?.autoPlay === TtsAutoPlay.enabled + ? t(($) => $['voice.voiceSettings.autoPlayEnabled'], { ns: 'appDebug' }) + : t(($) => $['voice.voiceSettings.autoPlayDisabled'], { ns: 'appDebug' })} + </div> </div> </div> )} {(isHovering || modalOpen) && ( - <VoiceSettings open={modalOpen && !disabled} onOpen={setModalOpen} onChange={onChange}> + <VoiceSettings + open={modalOpen && !disabled} + onOpen={setModalOpen} + onChange={onChange} + > <Button className="w-full" disabled={disabled}> <RiEqualizer2Line className="mr-1 size-4" /> - {t($ => $['voice.voiceSettings.title'], { ns: 'appDebug' })} + {t(($) => $['voice.voiceSettings.title'], { ns: 'appDebug' })} </Button> </VoiceSettings> )} diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx index bb1bfce451dd46..5ce4d94f59b090 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx @@ -30,38 +30,35 @@ type VoiceParamConfigProps = { onClose: () => void onChange?: OnFeaturesChange } -const VoiceParamConfig = ({ - onClose, - onChange, -}: VoiceParamConfigProps) => { +const VoiceParamConfig = ({ onClose, onChange }: VoiceParamConfigProps) => { const { t } = useTranslation() const pathname = usePathname() const matched = /\/app\/([^/]+)/.exec(pathname) - const appId = (matched?.length && matched[1]) ? matched[1] : '' - const text2speech = useFeatures(state => state.features.text2speech) + const appId = matched?.length && matched[1] ? matched[1] : '' + const text2speech = useFeatures((state) => state.features.text2speech) const featuresStore = useFeaturesStore() const formatLanguageName = (item: SelectOption) => { - const key = `voice.language.${replace(item.value, '-', '')}` as I18nKeysWithPrefix<'common', 'voice.language.'> - return t($ => $[key], { ns: 'common', defaultValue: item.name }) + const key = `voice.language.${replace(item.value, '-', '')}` as I18nKeysWithPrefix< + 'common', + 'voice.language.' + > + return t(($) => $[key], { ns: 'common', defaultValue: item.name }) } - let languageItem = languages.find(item => item.value === text2speech?.language) - if (languages && !languageItem) - languageItem = languages[0] - const localLanguagePlaceholder = languageItem?.name || t($ => $['placeholder.select'], { ns: 'common' }) + let languageItem = languages.find((item) => item.value === text2speech?.language) + if (languages && !languageItem) languageItem = languages[0] + const localLanguagePlaceholder = + languageItem?.name || t(($) => $['placeholder.select'], { ns: 'common' }) const language = languageItem?.value const { data: voiceItems } = useAppVoices(appId, language) - let voiceItem = voiceItems?.find(item => item.value === text2speech?.voice) - if (voiceItems && !voiceItem) - voiceItem = voiceItems[0] - const localVoicePlaceholder = voiceItem?.name || t($ => $['placeholder.select'], { ns: 'common' }) + let voiceItem = voiceItems?.find((item) => item.value === text2speech?.voice) + if (voiceItems && !voiceItem) voiceItem = voiceItems[0] + const localVoicePlaceholder = + voiceItem?.name || t(($) => $['placeholder.select'], { ns: 'common' }) const handleChange = (value: Record<string, string>) => { - const { - features, - setFeatures, - } = featuresStore!.getState() + const { features, setFeatures } = featuresStore!.getState() const newFeatures = produce(features, (draft) => { draft.text2speech = { @@ -71,18 +68,19 @@ const VoiceParamConfig = ({ }) setFeatures(newFeatures) - if (onChange) - onChange() + if (onChange) onChange() } return ( <> <div className="mb-4 flex items-center justify-between"> - <div className="system-xl-semibold text-text-primary">{t($ => $['voice.voiceSettings.title'], { ns: 'appDebug' })}</div> + <div className="system-xl-semibold text-text-primary"> + {t(($) => $['voice.voiceSettings.title'], { ns: 'appDebug' })} + </div> <button type="button" className="rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onClose} > <span aria-hidden className="i-ri-close-line size-4 text-text-tertiary" /> @@ -90,37 +88,37 @@ const VoiceParamConfig = ({ </div> <div className="mb-3"> <div className="mb-1 flex items-center py-1 system-sm-semibold text-text-secondary"> - {t($ => $['voice.voiceSettings.language'], { ns: 'appDebug' })} + {t(($) => $['voice.voiceSettings.language'], { ns: 'appDebug' })} <Infotip - aria-label={t($ => $['voice.voiceSettings.resolutionTooltip'], { ns: 'appDebug' })} + aria-label={t(($) => $['voice.voiceSettings.resolutionTooltip'], { ns: 'appDebug' })} popupClassName="w-[180px]" > - {t($ => $['voice.voiceSettings.resolutionTooltip'], { ns: 'appDebug' }).split('\n').map(item => ( - <div key={item}> - {item} - </div> - ))} + {t(($) => $['voice.voiceSettings.resolutionTooltip'], { ns: 'appDebug' }) + .split('\n') + .map((item) => ( + <div key={item}>{item}</div> + ))} </Infotip> </div> <Select value={languageItem?.value ?? null} onValueChange={(nextValue) => { - if (nextValue == null) - return + if (nextValue == null) return handleChange({ language: nextValue, }) }} > - <SelectTrigger aria-label={t($ => $['voice.voiceSettings.language'], { ns: 'appDebug' })} className="w-full"> + <SelectTrigger + aria-label={t(($) => $['voice.voiceSettings.language'], { ns: 'appDebug' })} + className="w-full" + > {languageItem ? formatLanguageName(languageItem) : localLanguagePlaceholder} </SelectTrigger> <SelectContent listClassName="max-h-60"> - {languages.map(item => ( + {languages.map((item) => ( <SelectItem key={item.value} value={item.value}> - <SelectItemText> - {formatLanguageName(item)} - </SelectItemText> + <SelectItemText>{formatLanguageName(item)}</SelectItemText> <SelectItemIndicator /> </SelectItem> ))} @@ -129,30 +127,30 @@ const VoiceParamConfig = ({ </div> <div className="mb-3"> <div className="mb-1 py-1 system-sm-semibold text-text-secondary"> - {t($ => $['voice.voiceSettings.voice'], { ns: 'appDebug' })} + {t(($) => $['voice.voiceSettings.voice'], { ns: 'appDebug' })} </div> <div className="flex items-center gap-1"> <Select value={voiceItem?.value ?? null} disabled={!languageItem} onValueChange={(nextValue) => { - if (nextValue == null || nextValue === '') - return + if (nextValue == null || nextValue === '') return handleChange({ voice: nextValue, }) }} > <div className="grow"> - <SelectTrigger aria-label={t($ => $['voice.voiceSettings.voice'], { ns: 'appDebug' })} className="w-full"> + <SelectTrigger + aria-label={t(($) => $['voice.voiceSettings.voice'], { ns: 'appDebug' })} + className="w-full" + > {voiceItem?.name ?? localVoicePlaceholder} </SelectTrigger> <SelectContent listClassName="max-h-60"> {voiceItems?.map((item: SelectOption) => ( <SelectItem key={item.value} value={item.value}> - <SelectItemText> - {item.name} - </SelectItemText> + <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> </SelectItem> ))} @@ -163,7 +161,7 @@ const VoiceParamConfig = ({ <div className="h-8 shrink-0 rounded-lg bg-components-button-tertiary-bg p-1" role="group" - aria-label={t($ => $.play, { ns: 'appApi', defaultValue: 'Play' })} + aria-label={t(($) => $.play, { ns: 'appApi', defaultValue: 'Play' })} > <AudioBtn value={languageItem?.example} @@ -177,7 +175,7 @@ const VoiceParamConfig = ({ </div> <div> <div className="mb-1 py-1 system-sm-semibold text-text-secondary"> - {t($ => $['voice.voiceSettings.autoPlay'], { ns: 'appDebug' })} + {t(($) => $['voice.voiceSettings.autoPlay'], { ns: 'appDebug' })} </div> <Switch className="shrink-0" diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx index 7375733299531c..cfd4bc373694b1 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx @@ -1,10 +1,6 @@ 'use client' import type { OnFeaturesChange } from '@/app/components/base/features/types' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { memo } from 'react' import ParamConfigContent from '@/app/components/base/features/new-feature-panel/text-to-speech/param-config-content' @@ -28,19 +24,11 @@ const VoiceSettings = ({ <Popover open={open} onOpenChange={(nextOpen) => { - if (disabled) - return + if (disabled) return onOpen(nextOpen) }} > - <PopoverTrigger - nativeButton={false} - render={( - <div className="flex"> - {children} - </div> - )} - /> + <PopoverTrigger nativeButton={false} render={<div className="flex">{children}</div>} /> <PopoverContent placement={placementLeft ? 'left' : 'top'} sideOffset={placementLeft ? 32 : 4} diff --git a/web/app/components/base/features/store.ts b/web/app/components/base/features/store.ts index e1f6ee413a58ce..eba825bebb43a7 100644 --- a/web/app/components/base/features/store.ts +++ b/web/app/components/base/features/store.ts @@ -56,11 +56,11 @@ export const createFeaturesStore = (initProps?: Partial<FeaturesState>) => { }, }, } - return createStore<FeatureStoreState>()(set => ({ + return createStore<FeatureStoreState>()((set) => ({ ...DEFAULT_PROPS, ...initProps, - setFeatures: features => set(() => ({ features })), + setFeatures: (features) => set(() => ({ features })), showFeaturesModal: false, - setShowFeaturesModal: showFeaturesModal => set(() => ({ showFeaturesModal })), + setShowFeaturesModal: (showFeaturesModal) => set(() => ({ showFeaturesModal })), })) } diff --git a/web/app/components/base/features/types.ts b/web/app/components/base/features/types.ts index 82c9ed2178b027..10b0e8e412801e 100644 --- a/web/app/components/base/features/types.ts +++ b/web/app/components/base/features/types.ts @@ -1,10 +1,5 @@ import type { FileUploadConfigResponse } from '@/models/common' -import type { - Model, - Resolution, - TransferMethod, - TtsAutoPlay, -} from '@/types/app' +import type { Model, Resolution, TransferMethod, TtsAutoPlay } from '@/types/app' type EnabledOrDisabled = { enabled?: boolean diff --git a/web/app/components/base/file-icon/index.stories.tsx b/web/app/components/base/file-icon/index.stories.tsx index d7486a26b71882..f67b913841e673 100644 --- a/web/app/components/base/file-icon/index.stories.tsx +++ b/web/app/components/base/file-icon/index.stories.tsx @@ -7,7 +7,8 @@ const meta = { parameters: { docs: { description: { - component: 'Maps a file extension to the appropriate SVG icon used across upload and attachment surfaces.', + component: + 'Maps a file extension to the appropriate SVG icon used across upload and attachment surfaces.', }, }, }, @@ -32,7 +33,7 @@ export default meta type Story = StoryObj<typeof meta> export const Default: Story = { - render: args => ( + render: (args) => ( <div className="flex items-center gap-4 rounded-lg border border-divider-subtle bg-components-panel-bg p-4"> <FileIcon {...args} /> <span className="text-sm text-text-secondary"> @@ -55,10 +56,21 @@ export const Default: Story = { export const Gallery: Story = { render: () => { - const examples = ['pdf', 'docx', 'xlsx', 'csv', 'json', 'md', 'txt', 'html', 'notion', 'unknown'] + const examples = [ + 'pdf', + 'docx', + 'xlsx', + 'csv', + 'json', + 'md', + 'txt', + 'html', + 'notion', + 'unknown', + ] return ( <div className="grid grid-cols-5 gap-4 rounded-lg border border-divider-subtle bg-components-panel-bg p-4"> - {examples.map(type => ( + {examples.map((type) => ( <div key={type} className="flex flex-col items-center gap-1"> <FileIcon type={type} className="size-9" /> <span className="text-xs text-text-tertiary uppercase">{type}</span> diff --git a/web/app/components/base/file-icon/index.tsx b/web/app/components/base/file-icon/index.tsx index 810deb09bb06e5..7720d277b8e44c 100644 --- a/web/app/components/base/file-icon/index.tsx +++ b/web/app/components/base/file-icon/index.tsx @@ -18,10 +18,7 @@ type FileIconProps = { className?: string } -const FileIcon: FC<FileIconProps> = ({ - type, - className, -}) => { +const FileIcon: FC<FileIconProps> = ({ type, className }) => { switch (type) { case 'csv': return <Csv className={className} /> diff --git a/web/app/components/base/file-thumb/image-render.tsx b/web/app/components/base/file-thumb/image-render.tsx index 213f1af84d1b65..f30da04f35d0d4 100644 --- a/web/app/components/base/file-thumb/image-render.tsx +++ b/web/app/components/base/file-thumb/image-render.tsx @@ -5,17 +5,10 @@ type ImageRenderProps = { name: string } -const ImageRender = ({ - sourceUrl, - name, -}: ImageRenderProps) => { +const ImageRender = ({ sourceUrl, name }: ImageRenderProps) => { return ( <div className="size-full border-2 border-effects-image-frame shadow-xs"> - <img - className="size-full object-cover" - src={sourceUrl} - alt={name} - /> + <img className="size-full object-cover" src={sourceUrl} alt={name} /> </div> ) } diff --git a/web/app/components/base/file-thumb/index.tsx b/web/app/components/base/file-thumb/index.tsx index cf34c5fd887c62..686cd156c3a89c 100644 --- a/web/app/components/base/file-thumb/index.tsx +++ b/web/app/components/base/file-thumb/index.tsx @@ -8,20 +8,17 @@ import { FileTypeIcon } from '../file-uploader' import { getFileAppearanceType } from '../file-uploader/utils' import ImageRender from './image-render' -const FileThumbVariants = cva( - 'flex cursor-pointer items-center justify-center', - { - variants: { - size: { - sm: 'size-6', - md: 'size-8', - }, - }, - defaultVariants: { - size: 'sm', +const FileThumbVariants = cva('flex cursor-pointer items-center justify-center', { + variants: { + size: { + sm: 'size-6', + md: 'size-8', }, }, -) + defaultVariants: { + size: 'sm', + }, +}) export type FileEntity = { name: string @@ -37,25 +34,23 @@ type FileThumbProps = { onClick?: (file: FileEntity) => void } & VariantProps<typeof FileThumbVariants> -const FileThumb = ({ - file, - size, - className, - onClick, -}: FileThumbProps) => { +const FileThumb = ({ file, size, className, onClick }: FileThumbProps) => { const { name, mimeType, sourceUrl } = file const isImage = mimeType.startsWith('image/') - const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => { - e.stopPropagation() - e.preventDefault() - onClick?.(file) - }, [onClick, file]) + const handleClick = useCallback( + (e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation() + e.preventDefault() + onClick?.(file) + }, + [onClick, file], + ) return ( <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={name} @@ -68,25 +63,18 @@ const FileThumb = ({ )} onClick={handleClick} > - { - isImage - ? ( - <ImageRender - sourceUrl={sourceUrl} - name={name} - /> - ) - : ( - <FileTypeIcon - type={getFileAppearanceType(name, mimeType)} - size="sm" - /> - ) - } + {isImage ? ( + <ImageRender sourceUrl={sourceUrl} name={name} /> + ) : ( + <FileTypeIcon type={getFileAppearanceType(name, mimeType)} size="sm" /> + )} </button> - )} + } /> - <TooltipContent placement="top" className="rounded-lg p-1.5 system-xs-medium text-text-secondary"> + <TooltipContent + placement="top" + className="rounded-lg p-1.5 system-xs-medium text-text-secondary" + > {name} </TooltipContent> </Tooltip> diff --git a/web/app/components/base/file-uploader/__tests__/audio-preview.spec.tsx b/web/app/components/base/file-uploader/__tests__/audio-preview.spec.tsx index 9313efa071da31..5a27fc58646f95 100644 --- a/web/app/components/base/file-uploader/__tests__/audio-preview.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/audio-preview.spec.tsx @@ -7,7 +7,9 @@ describe('AudioPreview', () => { }) it('should render audio element with correct source', () => { - render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />) + render( + <AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />, + ) const audio = document.querySelector('audio') expect(audio).toBeInTheDocument() @@ -15,7 +17,9 @@ describe('AudioPreview', () => { }) it('should render source element with correct src and type', () => { - render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />) + render( + <AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />, + ) const source = document.querySelector('source') expect(source).toHaveAttribute('src', 'https://example.com/audio.mp3') @@ -23,14 +27,18 @@ describe('AudioPreview', () => { }) it('should render close button with icon', () => { - render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />) + render( + <AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />, + ) expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) it('should call onCancel when close button is clicked', () => { const onCancel = vi.fn() - render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />) + render( + <AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />, + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) @@ -39,7 +47,9 @@ describe('AudioPreview', () => { it('should not close when backdrop is clicked', () => { const onCancel = vi.fn() - render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />) + render( + <AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />, + ) const dialog = screen.getByRole('dialog') fireEvent.click(dialog) @@ -50,7 +60,9 @@ describe('AudioPreview', () => { it('should call onCancel when Escape key is pressed', () => { const onCancel = vi.fn() - render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />) + render( + <AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />, + ) fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) @@ -58,7 +70,9 @@ describe('AudioPreview', () => { }) it('should render in a portal attached to document.body', () => { - render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />) + render( + <AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />, + ) const audio = document.querySelector('audio') expect(audio?.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body) diff --git a/web/app/components/base/file-uploader/__tests__/dynamic-pdf-preview.spec.tsx b/web/app/components/base/file-uploader/__tests__/dynamic-pdf-preview.spec.tsx index 868f153dbcb098..6cf5411114d160 100644 --- a/web/app/components/base/file-uploader/__tests__/dynamic-pdf-preview.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/dynamic-pdf-preview.spec.tsx @@ -36,9 +36,7 @@ const mockDynamic = vi.hoisted(() => }), ) -const mockPdfPreview = vi.hoisted(() => - vi.fn(() => null), -) +const mockPdfPreview = vi.hoisted(() => vi.fn(() => null)) vi.mock('@/next/dynamic', () => ({ default: mockDynamic, @@ -93,8 +91,7 @@ describe('dynamic-pdf-preview', () => { try { const loaded = mockState.loader?.() expect(loaded).toBeUndefined() - } - finally { + } finally { Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, diff --git a/web/app/components/base/file-uploader/__tests__/file-input.spec.tsx b/web/app/components/base/file-uploader/__tests__/file-input.spec.tsx index ac0070f9df5b95..203633e25fb37b 100644 --- a/web/app/components/base/file-uploader/__tests__/file-input.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/file-input.spec.tsx @@ -12,24 +12,29 @@ vi.mock('../hooks', () => ({ }), })) -const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({ - enabled: true, - allowed_file_types: ['image'], - allowed_file_extensions: [], - number_limits: 5, - ...overrides, -} as FileUpload) +const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => + ({ + enabled: true, + allowed_file_types: ['image'], + allowed_file_extensions: [], + number_limits: 5, + ...overrides, + }) as FileUpload function createStubFile(id: string): FileEntity { - return { id, name: `${id}.txt`, size: 0, type: '', progress: 100, transferMethod: 'local_file' as FileEntity['transferMethod'], supportFileType: 'document' } + return { + id, + name: `${id}.txt`, + size: 0, + type: '', + progress: 100, + transferMethod: 'local_file' as FileEntity['transferMethod'], + supportFileType: 'document', + } } function renderWithProvider(ui: React.ReactElement, fileIds: string[] = []) { - return render( - <FileContextProvider value={fileIds.map(createStubFile)}> - {ui} - </FileContextProvider>, - ) + return render(<FileContextProvider value={fileIds.map(createStubFile)}>{ui}</FileContextProvider>) } describe('FileInput', () => { @@ -45,7 +50,9 @@ describe('FileInput', () => { }) it('should set accept attribute based on allowed file types', () => { - renderWithProvider(<FileInput fileConfig={createFileConfig({ allowed_file_types: ['image'] })} />) + renderWithProvider( + <FileInput fileConfig={createFileConfig({ allowed_file_types: ['image'] })} />, + ) const input = document.querySelector('input[type="file"]') as HTMLInputElement expect(input.accept).toBe('.JPG,.JPEG,.PNG,.GIF,.WEBP,.SVG') @@ -53,10 +60,11 @@ describe('FileInput', () => { it('should use custom extensions when file type is custom', () => { renderWithProvider( - <FileInput fileConfig={createFileConfig({ - allowed_file_types: ['custom'] as unknown as FileUpload['allowed_file_types'], - allowed_file_extensions: ['.csv', '.xlsx'], - })} + <FileInput + fileConfig={createFileConfig({ + allowed_file_types: ['custom'] as unknown as FileUpload['allowed_file_types'], + allowed_file_extensions: ['.csv', '.xlsx'], + })} />, ) @@ -79,20 +87,18 @@ describe('FileInput', () => { }) it('should be disabled when file limit is reached', () => { - renderWithProvider( - <FileInput fileConfig={createFileConfig({ number_limits: 3 })} />, - ['1', '2', '3'], - ) + renderWithProvider(<FileInput fileConfig={createFileConfig({ number_limits: 3 })} />, [ + '1', + '2', + '3', + ]) const input = document.querySelector('input[type="file"]') as HTMLInputElement expect(input.disabled).toBe(true) }) it('should not be disabled when file limit is not reached', () => { - renderWithProvider( - <FileInput fileConfig={createFileConfig({ number_limits: 3 })} />, - ['1'], - ) + renderWithProvider(<FileInput fileConfig={createFileConfig({ number_limits: 3 })} />, ['1']) const input = document.querySelector('input[type="file"]') as HTMLInputElement expect(input.disabled).toBe(false) @@ -109,10 +115,10 @@ describe('FileInput', () => { }) it('should respect number_limits when uploading multiple files', () => { - renderWithProvider( - <FileInput fileConfig={createFileConfig({ number_limits: 3 })} />, - ['1', '2'], - ) + renderWithProvider(<FileInput fileConfig={createFileConfig({ number_limits: 3 })} />, [ + '1', + '2', + ]) const input = document.querySelector('input[type="file"]') as HTMLInputElement const file1 = new File(['content'], 'test1.jpg', { type: 'image/jpeg' }) @@ -148,7 +154,9 @@ describe('FileInput', () => { }) it('should handle empty allowed_file_types', () => { - renderWithProvider(<FileInput fileConfig={createFileConfig({ allowed_file_types: undefined })} />) + renderWithProvider( + <FileInput fileConfig={createFileConfig({ allowed_file_types: undefined })} />, + ) const input = document.querySelector('input[type="file"]') as HTMLInputElement expect(input.accept).toBe('') @@ -156,10 +164,11 @@ describe('FileInput', () => { it('should handle custom type with undefined allowed_file_extensions', () => { renderWithProvider( - <FileInput fileConfig={createFileConfig({ - allowed_file_types: ['custom'] as unknown as FileUpload['allowed_file_types'], - allowed_file_extensions: undefined, - })} + <FileInput + fileConfig={createFileConfig({ + allowed_file_types: ['custom'] as unknown as FileUpload['allowed_file_types'], + allowed_file_extensions: undefined, + })} />, ) diff --git a/web/app/components/base/file-uploader/__tests__/file-list-in-log.spec.tsx b/web/app/components/base/file-uploader/__tests__/file-list-in-log.spec.tsx index 9d9482ca293155..3b621748dd3cc0 100644 --- a/web/app/components/base/file-uploader/__tests__/file-list-in-log.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/file-list-in-log.spec.tsx @@ -53,14 +53,18 @@ describe('FileListInLog', () => { }) it('should render image files with an img element in collapsed view', () => { - const fileList = [{ - varName: 'files', - list: [createFile({ - name: 'photo.png', - supportFileType: 'image', - url: 'https://example.com/photo.png', - })], - }] + const fileList = [ + { + varName: 'files', + list: [ + createFile({ + name: 'photo.png', + supportFileType: 'image', + url: 'https://example.com/photo.png', + }), + ], + }, + ] render(<FileListInLog fileList={fileList} />) const img = screen.getByRole('img') @@ -69,13 +73,17 @@ describe('FileListInLog', () => { }) it('should render non-image files with an SVG icon in collapsed view', () => { - const fileList = [{ - varName: 'files', - list: [createFile({ - name: 'doc.pdf', - supportFileType: 'document', - })], - }] + const fileList = [ + { + varName: 'files', + list: [ + createFile({ + name: 'doc.pdf', + supportFileType: 'document', + }), + ], + }, + ] render(<FileListInLog fileList={fileList} />) expect(screen.queryByRole('img')).not.toBeInTheDocument() @@ -115,15 +123,19 @@ describe('FileListInLog', () => { }) it('should render image file with empty url when both base64Url and url are undefined', () => { - const fileList = [{ - varName: 'files', - list: [createFile({ - name: 'photo.png', - supportFileType: 'image', - base64Url: undefined, - url: undefined, - })], - }] + const fileList = [ + { + varName: 'files', + list: [ + createFile({ + name: 'photo.png', + supportFileType: 'image', + base64Url: undefined, + url: undefined, + }), + ], + }, + ] render(<FileListInLog fileList={fileList} />) const img = screen.getByRole('img') diff --git a/web/app/components/base/file-uploader/__tests__/file-type-icon.spec.tsx b/web/app/components/base/file-uploader/__tests__/file-type-icon.spec.tsx index 1da38048787177..80843279ae033e 100644 --- a/web/app/components/base/file-uploader/__tests__/file-type-icon.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/file-type-icon.spec.tsx @@ -8,7 +8,7 @@ describe('FileTypeIcon', () => { }) describe('icon rendering per file type', () => { - const fileTypeToColor: Array<{ type: keyof typeof FileAppearanceTypeEnum, color: string }> = [ + const fileTypeToColor: Array<{ type: keyof typeof FileAppearanceTypeEnum; color: string }> = [ { type: 'pdf', color: 'text-[#EA3434]' }, { type: 'image', color: 'text-[#00B2EA]' }, { type: 'video', color: 'text-[#844FDA]' }, @@ -23,20 +23,19 @@ describe('FileTypeIcon', () => { { type: 'gif', color: 'text-[#00B2EA]' }, ] - it.each(fileTypeToColor)( - 'should render $type icon with correct color', - ({ type, color }) => { - const { container } = render(<FileTypeIcon type={type} />) + it.each(fileTypeToColor)('should render $type icon with correct color', ({ type, color }) => { + const { container } = render(<FileTypeIcon type={type} />) - const icon = container.querySelector('svg') - expect(icon).toBeInTheDocument() - expect(icon).toHaveClass(color) - }, - ) + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass(color) + }) }) it('should render document icon when type is unknown', () => { - const { container } = render(<FileTypeIcon type={'nonexistent' as unknown as keyof typeof FileAppearanceTypeEnum} />) + const { container } = render( + <FileTypeIcon type={'nonexistent' as unknown as keyof typeof FileAppearanceTypeEnum} />, + ) const icon = container.querySelector('svg') expect(icon).toBeInTheDocument() @@ -44,7 +43,7 @@ describe('FileTypeIcon', () => { }) describe('size variants', () => { - const sizeMap: Array<{ size: 'sm' | 'md' | 'lg' | 'xl', expectedClass: string }> = [ + const sizeMap: Array<{ size: 'sm' | 'md' | 'lg' | 'xl'; expectedClass: string }> = [ { size: 'sm', expectedClass: 'size-4' }, { size: 'md', expectedClass: 'size-[18px]' }, { size: 'lg', expectedClass: 'size-5' }, diff --git a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts index 278c3f328b8c87..eb3ed279ffa76e 100644 --- a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts @@ -51,7 +51,8 @@ const mockUploadHumanInputFormLocalFile = vi.fn() const mockUploadHumanInputFormRemoteFileInfo = vi.fn() vi.mock('@/service/share', () => ({ uploadHumanInputFormLocalFile: (...args: unknown[]) => mockUploadHumanInputFormLocalFile(...args), - uploadHumanInputFormRemoteFileInfo: (...args: unknown[]) => mockUploadHumanInputFormRemoteFileInfo(...args), + uploadHumanInputFormRemoteFileInfo: (...args: unknown[]) => + mockUploadHumanInputFormRemoteFileInfo(...args), })) vi.mock('uuid', () => ({ @@ -197,16 +198,18 @@ describe('useFile', () => { describe('handleReUploadFile', () => { it('should re-upload a file and call fileUpload', () => { const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - mockStoreFiles = [{ - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 100, - progress: -1, - transferMethod: 'local_file', - supportFileType: 'document', - originalFile, - }] as FileEntity[] + mockStoreFiles = [ + { + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 100, + progress: -1, + transferMethod: 'local_file', + supportFileType: 'document', + originalFile, + }, + ] as FileEntity[] const { result } = renderHook(() => useFile(defaultFileConfig)) @@ -219,24 +222,28 @@ describe('useFile', () => { mockNavigationState.params = { token: 'form-token' } mockNavigationState.pathname = '/form/form-token' const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - mockStoreFiles = [{ - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 100, - progress: -1, - transferMethod: 'local_file', - supportFileType: 'document', - originalFile, - }] as FileEntity[] + mockStoreFiles = [ + { + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 100, + progress: -1, + transferMethod: 'local_file', + supportFileType: 'document', + originalFile, + }, + ] as FileEntity[] const { result } = renderHook(() => useFile(defaultFileConfig)) result.current.handleReUploadFile('file-1') - expect(mockUploadHumanInputFormLocalFile).toHaveBeenCalledWith(expect.objectContaining({ - formToken: 'form-token', - file: originalFile, - })) + expect(mockUploadHumanInputFormLocalFile).toHaveBeenCalledWith( + expect.objectContaining({ + formToken: 'form-token', + file: originalFile, + }), + ) expect(mockFileUpload).not.toHaveBeenCalled() }) @@ -250,16 +257,18 @@ describe('useFile', () => { it('should handle progress callback during re-upload', () => { const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - mockStoreFiles = [{ - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 100, - progress: -1, - transferMethod: 'local_file', - supportFileType: 'document', - originalFile, - }] as FileEntity[] + mockStoreFiles = [ + { + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 100, + progress: -1, + transferMethod: 'local_file', + supportFileType: 'document', + originalFile, + }, + ] as FileEntity[] const { result } = renderHook(() => useFile(defaultFileConfig)) result.current.handleReUploadFile('file-1') @@ -271,16 +280,18 @@ describe('useFile', () => { it('should handle success callback during re-upload', () => { const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - mockStoreFiles = [{ - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 100, - progress: -1, - transferMethod: 'local_file', - supportFileType: 'document', - originalFile, - }] as FileEntity[] + mockStoreFiles = [ + { + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 100, + progress: -1, + transferMethod: 'local_file', + supportFileType: 'document', + originalFile, + }, + ] as FileEntity[] const { result } = renderHook(() => useFile(defaultFileConfig)) result.current.handleReUploadFile('file-1') @@ -292,16 +303,18 @@ describe('useFile', () => { it('should handle error callback during re-upload', () => { const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - mockStoreFiles = [{ - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 100, - progress: -1, - transferMethod: 'local_file', - supportFileType: 'document', - originalFile, - }] as FileEntity[] + mockStoreFiles = [ + { + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 100, + progress: -1, + transferMethod: 'local_file', + supportFileType: 'document', + originalFile, + }, + ] as FileEntity[] const { result } = renderHook(() => useFile(defaultFileConfig)) result.current.handleReUploadFile('file-1') @@ -318,15 +331,17 @@ describe('useFile', () => { mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) // never resolves // Set up a file in the store that has progress 0 - mockStoreFiles = [{ - id: 'mock-uuid', - name: 'https://example.com/file.txt', - type: '', - size: 0, - progress: 0, - transferMethod: 'remote_url', - supportFileType: '', - }] as FileEntity[] + mockStoreFiles = [ + { + id: 'mock-uuid', + name: 'https://example.com/file.txt', + type: '', + size: 0, + progress: 0, + transferMethod: 'remote_url', + supportFileType: '', + }, + ] as FileEntity[] const { result } = renderHook(() => useFile(defaultFileConfig)) result.current.handleLoadFileFromLink('https://example.com/file.txt') @@ -368,7 +383,10 @@ describe('useFile', () => { const { result } = renderHook(() => useFile(defaultFileConfig)) result.current.handleLoadFileFromLink('https://example.com/file.txt') - expect(mockUploadHumanInputFormRemoteFileInfo).toHaveBeenCalledWith('form-token', 'https://example.com/file.txt') + expect(mockUploadHumanInputFormRemoteFileInfo).toHaveBeenCalledWith( + 'form-token', + 'https://example.com/file.txt', + ) expect(mockUploadRemoteFileInfo).not.toHaveBeenCalled() }) @@ -472,15 +490,17 @@ describe('useFile', () => { mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) // Set up a file already at 80% progress - mockStoreFiles = [{ - id: 'mock-uuid', - name: 'https://example.com/file.txt', - type: '', - size: 0, - progress: 80, - transferMethod: 'remote_url', - supportFileType: '', - }] as FileEntity[] + mockStoreFiles = [ + { + id: 'mock-uuid', + name: 'https://example.com/file.txt', + type: '', + size: 0, + progress: 80, + transferMethod: 'remote_url', + supportFileType: '', + }, + ] as FileEntity[] const { result } = renderHook(() => useFile(defaultFileConfig)) result.current.handleLoadFileFromLink('https://example.com/file.txt') @@ -496,15 +516,17 @@ describe('useFile', () => { mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) // Set up a file with negative progress (error state) - mockStoreFiles = [{ - id: 'mock-uuid', - name: 'https://example.com/file.txt', - type: '', - size: 0, - progress: -1, - transferMethod: 'remote_url', - supportFileType: '', - }] as FileEntity[] + mockStoreFiles = [ + { + id: 'mock-uuid', + name: 'https://example.com/file.txt', + type: '', + size: 0, + progress: -1, + transferMethod: 'remote_url', + supportFileType: '', + }, + ] as FileEntity[] const { result } = renderHook(() => useFile(defaultFileConfig)) result.current.handleLoadFileFromLink('https://example.com/file.txt') @@ -526,14 +548,13 @@ describe('useFile', () => { class MockFileReader { result: string | null = null addEventListener(event: string, handler: () => void) { - if (!capturedListeners[event]) - capturedListeners[event] = [] + if (!capturedListeners[event]) capturedListeners[event] = [] capturedListeners[event].push(handler) } readAsDataURL() { this.result = mockReaderResult - capturedListeners.load?.forEach(handler => handler()) + capturedListeners.load?.forEach((handler) => handler()) } } vi.stubGlobal('FileReader', MockFileReader) @@ -591,7 +612,9 @@ describe('useFile', () => { it('should reject image file exceeding size limit', () => { mockGetSupportFileType.mockReturnValue('image') - const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.png', { type: 'image/png' }) + const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.png', { + type: 'image/png', + }) Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 }) const { result } = renderHook(() => useFile(defaultFileConfig)) @@ -767,10 +790,12 @@ describe('useFile', () => { const { result } = renderHook(() => useFile(defaultFileConfig)) result.current.handleLocalFileUpload(file) - expect(mockUploadHumanInputFormLocalFile).toHaveBeenCalledWith(expect.objectContaining({ - formToken: 'form-token', - file, - })) + expect(mockUploadHumanInputFormLocalFile).toHaveBeenCalledWith( + expect.objectContaining({ + formToken: 'form-token', + file, + }), + ) expect(mockFileUpload).not.toHaveBeenCalled() }) @@ -793,16 +818,14 @@ describe('useFile', () => { class ErrorFileReader { result: string | null = null addEventListener(event: string, handler: () => void) { - if (event === 'error') - errorListeners.push(handler) - if (!capturedListeners[event]) - capturedListeners[event] = [] + if (event === 'error') errorListeners.push(handler) + if (!capturedListeners[event]) capturedListeners[event] = [] capturedListeners[event].push(handler) } readAsDataURL() { // Simulate error instead of load - errorListeners.forEach(handler => handler()) + errorListeners.forEach((handler) => handler()) } } vi.stubGlobal('FileReader', ErrorFileReader) @@ -853,7 +876,10 @@ describe('useFile', () => { it('should set isDragActive on drag enter', () => { const { result } = renderHook(() => useFile(defaultFileConfig)) - const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement> + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as React.DragEvent<HTMLElement> act(() => { result.current.handleDragFileEnter(event) }) @@ -864,7 +890,10 @@ describe('useFile', () => { it('should call preventDefault on drag over', () => { const { result } = renderHook(() => useFile(defaultFileConfig)) - const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement> + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as React.DragEvent<HTMLElement> result.current.handleDragFileOver(event) expect(event.preventDefault).toHaveBeenCalled() @@ -873,13 +902,19 @@ describe('useFile', () => { it('should unset isDragActive on drag leave', () => { const { result } = renderHook(() => useFile(defaultFileConfig)) - const enterEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement> + const enterEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as React.DragEvent<HTMLElement> act(() => { result.current.handleDragFileEnter(enterEvent) }) expect(result.current.isDragActive).toBe(true) - const leaveEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement> + const leaveEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as React.DragEvent<HTMLElement> act(() => { result.current.handleDragFileLeave(leaveEvent) }) diff --git a/web/app/components/base/file-uploader/__tests__/pdf-preview.spec.tsx b/web/app/components/base/file-uploader/__tests__/pdf-preview.spec.tsx index 3aa0f9e6d659fd..da41495fc3db44 100644 --- a/web/app/components/base/file-uploader/__tests__/pdf-preview.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/pdf-preview.spec.tsx @@ -3,13 +3,25 @@ import { fireEvent, render, screen } from '@testing-library/react' import PdfPreview from '../pdf-preview' vi.mock('../pdf-highlighter-adapter', () => ({ - PdfLoader: ({ children, beforeLoad }: { children: (doc: unknown) => ReactNode, beforeLoad: ReactNode }) => ( + PdfLoader: ({ + children, + beforeLoad, + }: { + children: (doc: unknown) => ReactNode + beforeLoad: ReactNode + }) => ( <div data-testid="pdf-loader"> {beforeLoad} {children({ numPages: 1 })} </div> ), - PdfHighlighter: ({ enableAreaSelection, highlightTransform, scrollRef, onScrollChange, onSelectionFinished }: { + PdfHighlighter: ({ + enableAreaSelection, + highlightTransform, + scrollRef, + onScrollChange, + onSelectionFinished, + }: { enableAreaSelection?: (event: MouseEvent) => boolean highlightTransform?: () => ReactNode scrollRef?: (ref: unknown) => void @@ -35,7 +47,9 @@ describe('PdfPreview', () => { } const getControl = (rightClass: 'right-24' | 'right-16' | 'right-6') => { - const control = document.querySelector(`button.absolute.${rightClass}.top-6`) as HTMLButtonElement | null + const control = document.querySelector( + `button.absolute.${rightClass}.top-6`, + ) as HTMLButtonElement | null expect(control).toBeInTheDocument() return control! } diff --git a/web/app/components/base/file-uploader/__tests__/store.spec.tsx b/web/app/components/base/file-uploader/__tests__/store.spec.tsx index 93231dbd1c580a..64e1af4cb1211f 100644 --- a/web/app/components/base/file-uploader/__tests__/store.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/store.spec.tsx @@ -69,7 +69,7 @@ describe('useStore', () => { const files = [createMockFile()] const store = createFileStore(files) - const { result } = renderHook(() => useStore(s => s.files), { + const { result } = renderHook(() => useStore((s) => s.files), { wrapper: ({ children }) => ( <FileContext.Provider value={store}>{children}</FileContext.Provider> ), @@ -82,7 +82,7 @@ describe('useStore', () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(() => { - renderHook(() => useStore(s => s.files)) + renderHook(() => useStore((s) => s.files)) }).toThrow('Missing FileContext.Provider in the tree') consoleError.mockRestore() @@ -121,7 +121,7 @@ describe('FileContextProvider', () => { it('should provide a store to children', () => { const TestChild = () => { - const files = useStore(s => s.files) + const files = useStore((s) => s.files) return <div data-testid="files">{files.length}</div> } @@ -137,7 +137,7 @@ describe('FileContextProvider', () => { it('should initialize store with value prop', () => { const files = [createMockFile()] const TestChild = () => { - const storeFiles = useStore(s => s.files) + const storeFiles = useStore((s) => s.files) return <div data-testid="files">{storeFiles.length}</div> } @@ -152,7 +152,7 @@ describe('FileContextProvider', () => { it('should reuse store on re-render instead of creating a new one', () => { const TestChild = () => { - const storeFiles = useStore(s => s.files) + const storeFiles = useStore((s) => s.files) return <div data-testid="files">{storeFiles.length}</div> } diff --git a/web/app/components/base/file-uploader/__tests__/utils.spec.ts b/web/app/components/base/file-uploader/__tests__/utils.spec.ts index c48b7dc1bdbe81..982d7ebb4c35e9 100644 --- a/web/app/components/base/file-uploader/__tests__/utils.spec.ts +++ b/web/app/components/base/file-uploader/__tests__/utils.spec.ts @@ -30,7 +30,10 @@ describe('file-uploader utils', () => { }) describe('getFileUploadErrorMessage', () => { - const createMockT = () => withSelectorKey((key: string, _options?: Record<string, unknown>) => key) as unknown as TFunction + const createMockT = () => + withSelectorKey( + (key: string, _options?: Record<string, unknown>) => key, + ) as unknown as TFunction it('should return forbidden message when error code is forbidden', () => { const error = { response: { code: 'forbidden', message: 'Access denied' } } @@ -39,7 +42,9 @@ describe('file-uploader utils', () => { it('should return file_extension_blocked translation when error code matches', () => { const error = { response: { code: 'file_extension_blocked' } } - expect(getFileUploadErrorMessage(error, 'default', createMockT())).toBe('fileUploader.fileExtensionBlocked') + expect(getFileUploadErrorMessage(error, 'default', createMockT())).toBe( + 'fileUploader.fileExtensionBlocked', + ) }) it('should return default message for other errors', () => { @@ -108,7 +113,10 @@ describe('file-uploader utils', () => { vi.mocked(upload).mockImplementation(({ onprogress }) => { // Simulate a progress event if (onprogress) - onprogress.call({} as XMLHttpRequest, { lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent) + onprogress.call( + {} as XMLHttpRequest, + { lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent, + ) return Promise.resolve({ id: '123' }) }) @@ -131,7 +139,10 @@ describe('file-uploader utils', () => { vi.mocked(upload).mockImplementation(({ onprogress }) => { if (onprogress) - onprogress.call({} as XMLHttpRequest, { lengthComputable: false, loaded: 0, total: 0 } as ProgressEvent) + onprogress.call( + {} as XMLHttpRequest, + { lengthComputable: false, loaded: 0, total: 0 } as ProgressEvent, + ) return Promise.resolve({ id: '123' }) }) @@ -185,138 +196,143 @@ describe('file-uploader utils', () => { describe('getFileAppearanceType', () => { it('should identify gif files', () => { - expect(getFileAppearanceType('image.gif', 'image/gif')) - .toBe(FileAppearanceTypeEnum.gif) + expect(getFileAppearanceType('image.gif', 'image/gif')).toBe(FileAppearanceTypeEnum.gif) }) it('should identify image files', () => { - expect(getFileAppearanceType('image.jpg', 'image/jpeg')) - .toBe(FileAppearanceTypeEnum.image) - expect(getFileAppearanceType('image.jpeg', 'image/jpeg')) - .toBe(FileAppearanceTypeEnum.image) - expect(getFileAppearanceType('image.png', 'image/png')) - .toBe(FileAppearanceTypeEnum.image) - expect(getFileAppearanceType('image.webp', 'image/webp')) - .toBe(FileAppearanceTypeEnum.image) - expect(getFileAppearanceType('image.svg', 'image/svg+xml')) - .toBe(FileAppearanceTypeEnum.image) + expect(getFileAppearanceType('image.jpg', 'image/jpeg')).toBe(FileAppearanceTypeEnum.image) + expect(getFileAppearanceType('image.jpeg', 'image/jpeg')).toBe(FileAppearanceTypeEnum.image) + expect(getFileAppearanceType('image.png', 'image/png')).toBe(FileAppearanceTypeEnum.image) + expect(getFileAppearanceType('image.webp', 'image/webp')).toBe(FileAppearanceTypeEnum.image) + expect(getFileAppearanceType('image.svg', 'image/svg+xml')).toBe(FileAppearanceTypeEnum.image) }) it('should identify video files', () => { - expect(getFileAppearanceType('video.mp4', 'video/mp4')) - .toBe(FileAppearanceTypeEnum.video) - expect(getFileAppearanceType('video.mov', 'video/quicktime')) - .toBe(FileAppearanceTypeEnum.video) - expect(getFileAppearanceType('video.mpeg', 'video/mpeg')) - .toBe(FileAppearanceTypeEnum.video) - expect(getFileAppearanceType('video.webm', 'video/webm')) - .toBe(FileAppearanceTypeEnum.video) + expect(getFileAppearanceType('video.mp4', 'video/mp4')).toBe(FileAppearanceTypeEnum.video) + expect(getFileAppearanceType('video.mov', 'video/quicktime')).toBe( + FileAppearanceTypeEnum.video, + ) + expect(getFileAppearanceType('video.mpeg', 'video/mpeg')).toBe(FileAppearanceTypeEnum.video) + expect(getFileAppearanceType('video.webm', 'video/webm')).toBe(FileAppearanceTypeEnum.video) }) it('should identify audio files', () => { - expect(getFileAppearanceType('audio.mp3', 'audio/mpeg')) - .toBe(FileAppearanceTypeEnum.audio) - expect(getFileAppearanceType('audio.m4a', 'audio/mp4')) - .toBe(FileAppearanceTypeEnum.audio) - expect(getFileAppearanceType('audio.wav', 'audio/wav')) - .toBe(FileAppearanceTypeEnum.audio) - expect(getFileAppearanceType('audio.amr', 'audio/AMR')) - .toBe(FileAppearanceTypeEnum.audio) - expect(getFileAppearanceType('audio.mpga', 'audio/mpeg')) - .toBe(FileAppearanceTypeEnum.audio) + expect(getFileAppearanceType('audio.mp3', 'audio/mpeg')).toBe(FileAppearanceTypeEnum.audio) + expect(getFileAppearanceType('audio.m4a', 'audio/mp4')).toBe(FileAppearanceTypeEnum.audio) + expect(getFileAppearanceType('audio.wav', 'audio/wav')).toBe(FileAppearanceTypeEnum.audio) + expect(getFileAppearanceType('audio.amr', 'audio/AMR')).toBe(FileAppearanceTypeEnum.audio) + expect(getFileAppearanceType('audio.mpga', 'audio/mpeg')).toBe(FileAppearanceTypeEnum.audio) }) it('should identify code files', () => { - expect(getFileAppearanceType('index.html', 'text/html')) - .toBe(FileAppearanceTypeEnum.code) + expect(getFileAppearanceType('index.html', 'text/html')).toBe(FileAppearanceTypeEnum.code) }) it('should identify PDF files', () => { - expect(getFileAppearanceType('doc.pdf', 'application/pdf')) - .toBe(FileAppearanceTypeEnum.pdf) + expect(getFileAppearanceType('doc.pdf', 'application/pdf')).toBe(FileAppearanceTypeEnum.pdf) }) it('should identify markdown files', () => { - expect(getFileAppearanceType('file.md', 'text/markdown')) - .toBe(FileAppearanceTypeEnum.markdown) - expect(getFileAppearanceType('file.markdown', 'text/markdown')) - .toBe(FileAppearanceTypeEnum.markdown) - expect(getFileAppearanceType('file.mdx', 'text/mdx')) - .toBe(FileAppearanceTypeEnum.markdown) + expect(getFileAppearanceType('file.md', 'text/markdown')).toBe( + FileAppearanceTypeEnum.markdown, + ) + expect(getFileAppearanceType('file.markdown', 'text/markdown')).toBe( + FileAppearanceTypeEnum.markdown, + ) + expect(getFileAppearanceType('file.mdx', 'text/mdx')).toBe(FileAppearanceTypeEnum.markdown) }) it('should identify excel files', () => { - expect(getFileAppearanceType('doc.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')) - .toBe(FileAppearanceTypeEnum.excel) - expect(getFileAppearanceType('doc.xls', 'application/vnd.ms-excel')) - .toBe(FileAppearanceTypeEnum.excel) + expect( + getFileAppearanceType( + 'doc.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ), + ).toBe(FileAppearanceTypeEnum.excel) + expect(getFileAppearanceType('doc.xls', 'application/vnd.ms-excel')).toBe( + FileAppearanceTypeEnum.excel, + ) }) it('should identify word files', () => { - expect(getFileAppearanceType('doc.doc', 'application/msword')) - .toBe(FileAppearanceTypeEnum.word) - expect(getFileAppearanceType('doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')) - .toBe(FileAppearanceTypeEnum.word) + expect(getFileAppearanceType('doc.doc', 'application/msword')).toBe( + FileAppearanceTypeEnum.word, + ) + expect( + getFileAppearanceType( + 'doc.docx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ), + ).toBe(FileAppearanceTypeEnum.word) }) it('should identify ppt files', () => { - expect(getFileAppearanceType('doc.ppt', 'application/vnd.ms-powerpoint')) - .toBe(FileAppearanceTypeEnum.ppt) - expect(getFileAppearanceType('doc.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation')) - .toBe(FileAppearanceTypeEnum.ppt) + expect(getFileAppearanceType('doc.ppt', 'application/vnd.ms-powerpoint')).toBe( + FileAppearanceTypeEnum.ppt, + ) + expect( + getFileAppearanceType( + 'doc.pptx', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ), + ).toBe(FileAppearanceTypeEnum.ppt) }) it('should identify document files', () => { - expect(getFileAppearanceType('file.txt', 'text/plain')) - .toBe(FileAppearanceTypeEnum.document) - expect(getFileAppearanceType('file.csv', 'text/csv')) - .toBe(FileAppearanceTypeEnum.document) - expect(getFileAppearanceType('file.msg', 'application/vnd.ms-outlook')) - .toBe(FileAppearanceTypeEnum.document) - expect(getFileAppearanceType('file.eml', 'message/rfc822')) - .toBe(FileAppearanceTypeEnum.document) - expect(getFileAppearanceType('file.xml', 'application/xml')) - .toBe(FileAppearanceTypeEnum.document) - expect(getFileAppearanceType('file.epub', 'application/epub+zip')) - .toBe(FileAppearanceTypeEnum.document) + expect(getFileAppearanceType('file.txt', 'text/plain')).toBe(FileAppearanceTypeEnum.document) + expect(getFileAppearanceType('file.csv', 'text/csv')).toBe(FileAppearanceTypeEnum.document) + expect(getFileAppearanceType('file.msg', 'application/vnd.ms-outlook')).toBe( + FileAppearanceTypeEnum.document, + ) + expect(getFileAppearanceType('file.eml', 'message/rfc822')).toBe( + FileAppearanceTypeEnum.document, + ) + expect(getFileAppearanceType('file.xml', 'application/xml')).toBe( + FileAppearanceTypeEnum.document, + ) + expect(getFileAppearanceType('file.epub', 'application/epub+zip')).toBe( + FileAppearanceTypeEnum.document, + ) }) it('should fall back to filename extension for unknown mimetype', () => { - expect(getFileAppearanceType('file.txt', 'application/unknown')) - .toBe(FileAppearanceTypeEnum.document) + expect(getFileAppearanceType('file.txt', 'application/unknown')).toBe( + FileAppearanceTypeEnum.document, + ) }) it('should return custom type for unrecognized extensions', () => { - expect(getFileAppearanceType('file.xyz', 'application/xyz')) - .toBe(FileAppearanceTypeEnum.custom) + expect(getFileAppearanceType('file.xyz', 'application/xyz')).toBe( + FileAppearanceTypeEnum.custom, + ) }) }) describe('getSupportFileType', () => { it('should return custom type when isCustom is true', () => { - expect(getSupportFileType('file.txt', '', true)) - .toBe(SupportUploadFileTypes.custom) + expect(getSupportFileType('file.txt', '', true)).toBe(SupportUploadFileTypes.custom) }) it('should return file type when isCustom is false', () => { - expect(getSupportFileType('file.txt', 'text/plain')) - .toBe(SupportUploadFileTypes.document) + expect(getSupportFileType('file.txt', 'text/plain')).toBe(SupportUploadFileTypes.document) }) }) describe('getProcessedFiles', () => { it('should process files correctly', () => { - const files = [{ - id: '123', - name: 'test.txt', - size: 1024, - type: 'text/plain', - progress: 100, - supportFileType: 'document', - transferMethod: TransferMethod.remote_url, - url: 'http://example.com', - uploadedId: '123', - }] + const files = [ + { + id: '123', + name: 'test.txt', + size: 1024, + type: 'text/plain', + progress: 100, + supportFileType: 'document', + transferMethod: TransferMethod.remote_url, + url: 'http://example.com', + uploadedId: '123', + }, + ] const result = getProcessedFiles(files) expect(result[0]).toEqual({ @@ -328,34 +344,38 @@ describe('file-uploader utils', () => { }) it('should fallback to empty string when url is missing', () => { - const files = [{ - id: '123', - name: 'test.txt', - size: 1024, - type: 'text/plain', - progress: 100, - supportFileType: 'document', - transferMethod: TransferMethod.local_file, - url: undefined, - uploadedId: '123', - }] as unknown as FileEntity[] + const files = [ + { + id: '123', + name: 'test.txt', + size: 1024, + type: 'text/plain', + progress: 100, + supportFileType: 'document', + transferMethod: TransferMethod.local_file, + url: undefined, + uploadedId: '123', + }, + ] as unknown as FileEntity[] const result = getProcessedFiles(files) expect(result[0]!.url).toBe('') }) it('should fallback to empty string when uploadedId is missing', () => { - const files = [{ - id: '123', - name: 'test.txt', - size: 1024, - type: 'text/plain', - progress: 100, - supportFileType: 'document', - transferMethod: TransferMethod.local_file, - url: 'http://example.com', - uploadedId: undefined, - }] as unknown as FileEntity[] + const files = [ + { + id: '123', + name: 'test.txt', + size: 1024, + type: 'text/plain', + progress: 100, + supportFileType: 'document', + transferMethod: TransferMethod.local_file, + url: 'http://example.com', + uploadedId: undefined, + }, + ] as unknown as FileEntity[] const result = getProcessedFiles(files) expect(result[0]!.upload_file_id).toBe('') @@ -391,18 +411,20 @@ describe('file-uploader utils', () => { describe('getProcessedFilesFromResponse', () => { it('should process files correctly without type correction', () => { - const files = [{ - related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9', - extension: '.jpeg', - filename: 'test.jpeg', - size: 2881761, - mime_type: 'image/jpeg', - transfer_method: TransferMethod.local_file, - type: 'image', - url: 'https://upload.dify.dev/files/xxx/file-preview', - upload_file_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9', - remote_url: '', - }] + const files = [ + { + related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9', + extension: '.jpeg', + filename: 'test.jpeg', + size: 2881761, + mime_type: 'image/jpeg', + transfer_method: TransferMethod.local_file, + type: 'image', + url: 'https://upload.dify.dev/files/xxx/file-preview', + upload_file_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9', + remote_url: '', + }, + ] const result = getProcessedFilesFromResponse(files) expect(result[0]).toEqual({ @@ -419,162 +441,180 @@ describe('file-uploader utils', () => { }) it('should correct image file misclassified as document', () => { - const files = [{ - related_id: '123', - extension: '.jpg', - filename: 'image.jpg', - size: 1024, - mime_type: 'image/jpeg', - transfer_method: TransferMethod.local_file, - type: 'document', - url: 'https://example.com/image.jpg', - upload_file_id: '123', - remote_url: '', - }] + const files = [ + { + related_id: '123', + extension: '.jpg', + filename: 'image.jpg', + size: 1024, + mime_type: 'image/jpeg', + transfer_method: TransferMethod.local_file, + type: 'document', + url: 'https://example.com/image.jpg', + upload_file_id: '123', + remote_url: '', + }, + ] const result = getProcessedFilesFromResponse(files) expect(result[0]!.supportFileType).toBe('image') }) it('should correct video file misclassified as document', () => { - const files = [{ - related_id: '123', - extension: '.mp4', - filename: 'video.mp4', - size: 1024, - mime_type: 'video/mp4', - transfer_method: TransferMethod.local_file, - type: 'document', - url: 'https://example.com/video.mp4', - upload_file_id: '123', - remote_url: '', - }] + const files = [ + { + related_id: '123', + extension: '.mp4', + filename: 'video.mp4', + size: 1024, + mime_type: 'video/mp4', + transfer_method: TransferMethod.local_file, + type: 'document', + url: 'https://example.com/video.mp4', + upload_file_id: '123', + remote_url: '', + }, + ] const result = getProcessedFilesFromResponse(files) expect(result[0]!.supportFileType).toBe('video') }) it('should correct audio file misclassified as document', () => { - const files = [{ - related_id: '123', - extension: '.mp3', - filename: 'audio.mp3', - size: 1024, - mime_type: 'audio/mpeg', - transfer_method: TransferMethod.local_file, - type: 'document', - url: 'https://example.com/audio.mp3', - upload_file_id: '123', - remote_url: '', - }] + const files = [ + { + related_id: '123', + extension: '.mp3', + filename: 'audio.mp3', + size: 1024, + mime_type: 'audio/mpeg', + transfer_method: TransferMethod.local_file, + type: 'document', + url: 'https://example.com/audio.mp3', + upload_file_id: '123', + remote_url: '', + }, + ] const result = getProcessedFilesFromResponse(files) expect(result[0]!.supportFileType).toBe('audio') }) it('should correct document file misclassified as image', () => { - const files = [{ - related_id: '123', - extension: '.pdf', - filename: 'document.pdf', - size: 1024, - mime_type: 'application/pdf', - transfer_method: TransferMethod.local_file, - type: 'image', - url: 'https://example.com/document.pdf', - upload_file_id: '123', - remote_url: '', - }] + const files = [ + { + related_id: '123', + extension: '.pdf', + filename: 'document.pdf', + size: 1024, + mime_type: 'application/pdf', + transfer_method: TransferMethod.local_file, + type: 'image', + url: 'https://example.com/document.pdf', + upload_file_id: '123', + remote_url: '', + }, + ] const result = getProcessedFilesFromResponse(files) expect(result[0]!.supportFileType).toBe('document') }) it('should NOT correct when filename and MIME type conflict', () => { - const files = [{ - related_id: '123', - extension: '.pdf', - filename: 'document.pdf', - size: 1024, - mime_type: 'image/jpeg', - transfer_method: TransferMethod.local_file, - type: 'document', - url: 'https://example.com/document.pdf', - upload_file_id: '123', - remote_url: '', - }] + const files = [ + { + related_id: '123', + extension: '.pdf', + filename: 'document.pdf', + size: 1024, + mime_type: 'image/jpeg', + transfer_method: TransferMethod.local_file, + type: 'document', + url: 'https://example.com/document.pdf', + upload_file_id: '123', + remote_url: '', + }, + ] const result = getProcessedFilesFromResponse(files) expect(result[0]!.supportFileType).toBe('document') }) it('should NOT correct when filename and MIME type both point to same type', () => { - const files = [{ - related_id: '123', - extension: '.jpg', - filename: 'image.jpg', - size: 1024, - mime_type: 'image/jpeg', - transfer_method: TransferMethod.local_file, - type: 'image', - url: 'https://example.com/image.jpg', - upload_file_id: '123', - remote_url: '', - }] + const files = [ + { + related_id: '123', + extension: '.jpg', + filename: 'image.jpg', + size: 1024, + mime_type: 'image/jpeg', + transfer_method: TransferMethod.local_file, + type: 'image', + url: 'https://example.com/image.jpg', + upload_file_id: '123', + remote_url: '', + }, + ] const result = getProcessedFilesFromResponse(files) expect(result[0]!.supportFileType).toBe('image') }) it('should handle files with missing filename', () => { - const files = [{ - related_id: '123', - extension: '', - filename: '', - size: 1024, - mime_type: 'image/jpeg', - transfer_method: TransferMethod.local_file, - type: 'document', - url: 'https://example.com/file', - upload_file_id: '123', - remote_url: '', - }] + const files = [ + { + related_id: '123', + extension: '', + filename: '', + size: 1024, + mime_type: 'image/jpeg', + transfer_method: TransferMethod.local_file, + type: 'document', + url: 'https://example.com/file', + upload_file_id: '123', + remote_url: '', + }, + ] const result = getProcessedFilesFromResponse(files) expect(result[0]!.supportFileType).toBe('document') }) it('should handle files with missing MIME type', () => { - const files = [{ - related_id: '123', - extension: '.jpg', - filename: 'image.jpg', - size: 1024, - mime_type: '', - transfer_method: TransferMethod.local_file, - type: 'document', - url: 'https://example.com/image.jpg', - upload_file_id: '123', - remote_url: '', - }] + const files = [ + { + related_id: '123', + extension: '.jpg', + filename: 'image.jpg', + size: 1024, + mime_type: '', + transfer_method: TransferMethod.local_file, + type: 'document', + url: 'https://example.com/image.jpg', + upload_file_id: '123', + remote_url: '', + }, + ] const result = getProcessedFilesFromResponse(files) expect(result[0]!.supportFileType).toBe('document') }) it('should handle files with unknown extensions', () => { - const files = [{ - related_id: '123', - extension: '.unknown', - filename: 'file.unknown', - size: 1024, - mime_type: 'application/unknown', - transfer_method: TransferMethod.local_file, - type: 'document', - url: 'https://example.com/file.unknown', - upload_file_id: '123', - remote_url: '', - }] + const files = [ + { + related_id: '123', + extension: '.unknown', + filename: 'file.unknown', + size: 1024, + mime_type: 'application/unknown', + transfer_method: TransferMethod.local_file, + type: 'document', + url: 'https://example.com/file.unknown', + upload_file_id: '123', + remote_url: '', + }, + ] const result = getProcessedFilesFromResponse(files) expect(result[0]!.supportFileType).toBe('document') @@ -648,10 +688,7 @@ describe('file-uploader utils', () => { const originalFileExts = { ...FILE_EXTS } Object.assign(FILE_EXTS, mockFileExts) - const result = getSupportFileExtensionList( - ['image', 'document'], - [], - ) + const result = getSupportFileExtensionList(['image', 'document'], []) expect(result).toEqual(['JPG', 'PNG', 'PDF', 'TXT']) // Restore original FILE_EXTS @@ -685,12 +722,9 @@ describe('file-uploader utils', () => { describe('isAllowedFileExtension', () => { it('should validate allowed file extensions', () => { - expect(isAllowedFileExtension( - 'test.pdf', - 'application/pdf', - ['document'], - ['.pdf'], - )).toBe(true) + expect(isAllowedFileExtension('test.pdf', 'application/pdf', ['document'], ['.pdf'])).toBe( + true, + ) }) }) @@ -717,20 +751,24 @@ describe('file-uploader utils', () => { file1: mockFileData, } - const expected = [{ - varName: 'file1', - list: [{ - id: '123', - name: 'test.pdf', - size: 1024, - type: 'application/pdf', - progress: 100, - transferMethod: 'local_file', - supportFileType: 'document', - uploadedId: '123', - url: 'http://example.com/test.pdf', - }], - }] + const expected = [ + { + varName: 'file1', + list: [ + { + id: '123', + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: '123', + url: 'http://example.com/test.pdf', + }, + ], + }, + ] expect(getFilesInLogs(input)).toEqual(expected) }) @@ -740,33 +778,35 @@ describe('file-uploader utils', () => { files: [mockFileData, mockFileData], } - const expected = [{ - varName: 'files', - list: [ - { - id: '123', - name: 'test.pdf', - size: 1024, - type: 'application/pdf', - progress: 100, - transferMethod: 'local_file', - supportFileType: 'document', - uploadedId: '123', - url: 'http://example.com/test.pdf', - }, - { - id: '123', - name: 'test.pdf', - size: 1024, - type: 'application/pdf', - progress: 100, - transferMethod: 'local_file', - supportFileType: 'document', - uploadedId: '123', - url: 'http://example.com/test.pdf', - }, - ], - }] + const expected = [ + { + varName: 'files', + list: [ + { + id: '123', + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: '123', + url: 'http://example.com/test.pdf', + }, + { + id: '123', + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: '123', + url: 'http://example.com/test.pdf', + }, + ], + }, + ] expect(getFilesInLogs(input)).toEqual(expected) }) @@ -780,71 +820,73 @@ describe('file-uploader utils', () => { file: mockFileData, } - const expected = [{ - varName: 'file', - list: [{ - id: '123', - name: 'test.pdf', - size: 1024, - type: 'application/pdf', - progress: 100, - transferMethod: 'local_file', - supportFileType: 'document', - uploadedId: '123', - url: 'http://example.com/test.pdf', - }], - }] + const expected = [ + { + varName: 'file', + list: [ + { + id: '123', + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: '123', + url: 'http://example.com/test.pdf', + }, + ], + }, + ] expect(getFilesInLogs(input)).toEqual(expected) }) it('should handle mixed file types in array', () => { const input = { - mixedFiles: [ - mockFileData, - { notAFile: true }, - mockFileData, - ], + mixedFiles: [mockFileData, { notAFile: true }, mockFileData], } - const expected = [{ - varName: 'mixedFiles', - list: [ - { - id: '123', - name: 'test.pdf', - size: 1024, - type: 'application/pdf', - progress: 100, - transferMethod: 'local_file', - supportFileType: 'document', - uploadedId: '123', - url: 'http://example.com/test.pdf', - }, - { - id: undefined, - name: undefined, - progress: 100, - size: 0, - supportFileType: undefined, - transferMethod: undefined, - type: undefined, - uploadedId: undefined, - url: undefined, - }, - { - id: '123', - name: 'test.pdf', - size: 1024, - type: 'application/pdf', - progress: 100, - transferMethod: 'local_file', - supportFileType: 'document', - uploadedId: '123', - url: 'http://example.com/test.pdf', - }, - ], - }] + const expected = [ + { + varName: 'mixedFiles', + list: [ + { + id: '123', + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: '123', + url: 'http://example.com/test.pdf', + }, + { + id: undefined, + name: undefined, + progress: 100, + size: 0, + supportFileType: undefined, + transferMethod: undefined, + type: undefined, + uploadedId: undefined, + url: undefined, + }, + { + id: '123', + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: '123', + url: 'http://example.com/test.pdf', + }, + ], + }, + ] expect(getFilesInLogs(input)).toEqual(expected) }) @@ -852,17 +894,21 @@ describe('file-uploader utils', () => { describe('fileIsUploaded', () => { it('should identify uploaded files', () => { - expect(fileIsUploaded({ - uploadedId: '123', - progress: 100, - } as Partial<FileEntity> as FileEntity)).toBe(true) + expect( + fileIsUploaded({ + uploadedId: '123', + progress: 100, + } as Partial<FileEntity> as FileEntity), + ).toBe(true) }) it('should identify remote files as uploaded', () => { - expect(fileIsUploaded({ - transferMethod: TransferMethod.remote_url, - progress: 100, - } as Partial<FileEntity> as FileEntity)).toBe(true) + expect( + fileIsUploaded({ + transferMethod: TransferMethod.remote_url, + progress: 100, + } as Partial<FileEntity> as FileEntity), + ).toBe(true) }) }) }) diff --git a/web/app/components/base/file-uploader/__tests__/video-preview.spec.tsx b/web/app/components/base/file-uploader/__tests__/video-preview.spec.tsx index fc6a9d6f791598..2fbf8c2f7834d1 100644 --- a/web/app/components/base/file-uploader/__tests__/video-preview.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/video-preview.spec.tsx @@ -7,7 +7,9 @@ describe('VideoPreview', () => { }) it('should render video element with correct title', () => { - render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />) + render( + <VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />, + ) const video = document.querySelector('video') expect(video).toBeInTheDocument() @@ -15,7 +17,9 @@ describe('VideoPreview', () => { }) it('should render source element with correct src and type', () => { - render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />) + render( + <VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />, + ) const source = document.querySelector('source') expect(source).toHaveAttribute('src', 'https://example.com/video.mp4') @@ -23,14 +27,18 @@ describe('VideoPreview', () => { }) it('should render close button with icon', () => { - render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />) + render( + <VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />, + ) expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) it('should call onCancel when close button is clicked', () => { const onCancel = vi.fn() - render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />) + render( + <VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />, + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) @@ -39,7 +47,9 @@ describe('VideoPreview', () => { it('should not close when backdrop is clicked', () => { const onCancel = vi.fn() - render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />) + render( + <VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />, + ) const dialog = screen.getByRole('dialog') fireEvent.click(dialog) @@ -50,7 +60,9 @@ describe('VideoPreview', () => { it('should call onCancel when Escape key is pressed', () => { const onCancel = vi.fn() - render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />) + render( + <VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />, + ) fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) @@ -58,7 +70,9 @@ describe('VideoPreview', () => { }) it('should render in a portal attached to document.body', () => { - render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />) + render( + <VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />, + ) const video = document.querySelector('video') expect(video?.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body) diff --git a/web/app/components/base/file-uploader/audio-preview.tsx b/web/app/components/base/file-uploader/audio-preview.tsx index 5b4e7909976f58..2f731f85344311 100644 --- a/web/app/components/base/file-uploader/audio-preview.tsx +++ b/web/app/components/base/file-uploader/audio-preview.tsx @@ -7,19 +7,14 @@ type AudioPreviewProps = { title: string onCancel: () => void } -const AudioPreview: FC<AudioPreviewProps> = ({ - url, - title, - onCancel, -}) => { +const AudioPreview: FC<AudioPreviewProps> = ({ url, title, onCancel }) => { const { t } = useTranslation() return ( <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} disablePointerDismissal > @@ -27,22 +22,14 @@ const AudioPreview: FC<AudioPreviewProps> = ({ className="inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 p-8! shadow-none!" backdropClassName="bg-transparent!" > - <div - aria-label={title} - tabIndex={-1} - onClick={e => e.stopPropagation()} - > + <div aria-label={title} tabIndex={-1} onClick={(e) => e.stopPropagation()}> <audio controls title={title} autoPlay={false} preload="metadata"> - <source - type="audio/mpeg" - src={url} - className="max-h-full max-w-full" - /> + <source type="audio/mpeg" src={url} className="max-h-full max-w-full" /> </audio> </div> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-white/8 p-0 backdrop-blur-[2px]" onClick={onCancel} > diff --git a/web/app/components/base/file-uploader/dynamic-pdf-preview.tsx b/web/app/components/base/file-uploader/dynamic-pdf-preview.tsx index 225d5664c2c383..7d230ede7f1822 100644 --- a/web/app/components/base/file-uploader/dynamic-pdf-preview.tsx +++ b/web/app/components/base/file-uploader/dynamic-pdf-preview.tsx @@ -8,8 +8,7 @@ type DynamicPdfPreviewProps = { } const DynamicPdfPreview = dynamic<DynamicPdfPreviewProps>( (() => { - if (typeof window !== 'undefined') - return import('./pdf-preview') + if (typeof window !== 'undefined') return import('./pdf-preview') }) as any, { ssr: false }, // This will prevent the module from being loaded on the server-side ) diff --git a/web/app/components/base/file-uploader/file-from-link-or-local/__tests__/index.spec.tsx b/web/app/components/base/file-uploader/file-from-link-or-local/__tests__/index.spec.tsx index bdd43343e7fe40..6cd2e8e3cb3430 100644 --- a/web/app/components/base/file-uploader/file-from-link-or-local/__tests__/index.spec.tsx +++ b/web/app/components/base/file-uploader/file-from-link-or-local/__tests__/index.spec.tsx @@ -7,7 +7,15 @@ import FileFromLinkOrLocal from '../index' let mockFiles: FileEntity[] = [] function createStubFile(id: string): FileEntity { - return { id, name: `${id}.txt`, size: 0, type: '', progress: 100, transferMethod: 'local_file' as FileEntity['transferMethod'], supportFileType: 'document' } + return { + id, + name: `${id}.txt`, + size: 0, + type: '', + progress: 100, + transferMethod: 'local_file' as FileEntity['transferMethod'], + supportFileType: 'document', + } } const mockHandleLoadFileFromLink = vi.fn() @@ -17,16 +25,19 @@ vi.mock('../../hooks', () => ({ }), })) -const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({ - enabled: true, - allowed_file_types: ['image'], - allowed_file_extensions: [], - number_limits: 5, - ...overrides, -} as FileUpload) +const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => + ({ + enabled: true, + allowed_file_types: ['image'], + allowed_file_extensions: [], + number_limits: 5, + ...overrides, + }) as FileUpload function renderAndOpen(props: Partial<React.ComponentProps<typeof FileFromLinkOrLocal>> = {}) { - const trigger = props.trigger ?? ((open: boolean) => <button data-testid="trigger">{open ? 'Close' : 'Open'}</button>) + const trigger = + props.trigger ?? + ((open: boolean) => <button data-testid="trigger">{open ? 'Close' : 'Open'}</button>) const result = render( <FileContextProvider value={mockFiles}> <FileFromLinkOrLocal @@ -65,7 +76,9 @@ describe('FileFromLinkOrLocal', () => { it('should render URL input when showFromLink is true', () => { renderAndOpen({ showFromLink: true }) - expect(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)).toBeInTheDocument() + expect( + screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/), + ).toBeInTheDocument() }) it('should render upload button when showFromLocal is true', () => { @@ -120,7 +133,11 @@ describe('FileFromLinkOrLocal', () => { it('should disable inputs when file limit is reached', () => { mockFiles = ['1', '2', '3', '4', '5'].map(createStubFile) - renderAndOpen({ fileConfig: createFileConfig({ number_limits: 5 }), showFromLink: true, showFromLocal: true }) + renderAndOpen({ + fileConfig: createFileConfig({ number_limits: 5 }), + showFromLink: true, + showFromLocal: true, + }) const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) expect(input).toBeDisabled() @@ -146,7 +163,9 @@ describe('FileFromLinkOrLocal', () => { it('should clear URL input after successful submission', () => { renderAndOpen({ showFromLink: true }) - const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) as HTMLInputElement + const input = screen.getByPlaceholderText( + /fileUploader\.pasteFileLinkInputPlaceholder/, + ) as HTMLInputElement fireEvent.change(input, { target: { value: 'https://example.com/file.pdf' } }) fireEvent.click(screen.getByText(/operation\.ok/)) @@ -154,7 +173,9 @@ describe('FileFromLinkOrLocal', () => { }) it('should toggle open state when trigger is clicked', () => { - const trigger = (open: boolean) => <button data-testid="trigger">{open ? 'Close' : 'Open'}</button> + const trigger = (open: boolean) => ( + <button data-testid="trigger">{open ? 'Close' : 'Open'}</button> + ) render( <FileContextProvider value={mockFiles}> <FileFromLinkOrLocal trigger={trigger} fileConfig={createFileConfig()} showFromLink /> diff --git a/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx b/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx index 857a90284d789e..68411bd4cd2bdf 100644 --- a/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx +++ b/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx @@ -1,16 +1,9 @@ import type { FileUpload } from '@/app/components/base/features/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiUploadCloud2Line } from '@remixicon/react' -import { - memo, - useState, -} from 'react' +import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' import { FILE_URL_REGEX } from '../constants' import FileInput from '../file-input' @@ -30,20 +23,21 @@ const FileFromLinkOrLocal = ({ fileConfig, }: FileFromLinkOrLocalProps) => { const { t } = useTranslation() - const files = useStore(s => s.files) + const files = useStore((s) => s.files) const [open, setOpen] = useState(false) const [url, setUrl] = useState('') const [showError, setShowError] = useState(false) const { handleLoadFileFromLink } = useFile(fileConfig) const disabled = !!fileConfig.number_limits && files.length >= fileConfig.number_limits - const fileLinkPlaceholder = t($ => $['fileUploader.pasteFileLinkInputPlaceholder'], { ns: 'common' }) + const fileLinkPlaceholder = t(($) => $['fileUploader.pasteFileLinkInputPlaceholder'], { + ns: 'common', + }) /* v8 ignore next -- fallback for a missing i18n key is not reliably testable under the current global translation mocks in the test DOM runtime. @preserve */ const fileLinkPlaceholderText = fileLinkPlaceholder || '' const handleSaveUrl = () => { /* v8 ignore next -- guarded by UI-level disabled state (`disabled={!url || disabled}`), not reachable in the current test click flow. @preserve */ - if (!url) - return + if (!url) return if (!FILE_URL_REGEX.test(url)) { setShowError(true) @@ -54,10 +48,7 @@ const FileFromLinkOrLocal = ({ } return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger render={trigger(open) as React.ReactElement} /> <PopoverContent placement="top" @@ -65,66 +56,55 @@ const FileFromLinkOrLocal = ({ popupClassName="border-none bg-transparent shadow-none" > <div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg"> - { - showFromLink && ( - <> - <div className={cn( + {showFromLink && ( + <> + <div + className={cn( 'flex h-8 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-active p-1 shadow-xs', showError && 'border-components-input-border-destructive', )} + > + <input + className="mr-0.5 block grow appearance-none bg-transparent px-1 system-sm-regular outline-hidden" + placeholder={fileLinkPlaceholderText} + value={url} + onChange={(e) => { + setShowError(false) + setUrl(e.target.value.trim()) + }} + disabled={disabled} + /> + <Button + className="shrink-0" + size="small" + variant="primary" + disabled={!url || disabled} + onClick={handleSaveUrl} > - <input - className="mr-0.5 block grow appearance-none bg-transparent px-1 system-sm-regular outline-hidden" - placeholder={fileLinkPlaceholderText} - value={url} - onChange={(e) => { - setShowError(false) - setUrl(e.target.value.trim()) - }} - disabled={disabled} - /> - <Button - className="shrink-0" - size="small" - variant="primary" - disabled={!url || disabled} - onClick={handleSaveUrl} - > - {t($ => $['operation.ok'], { ns: 'common' })} - </Button> - </div> - { - showError && ( - <div className="mt-0.5 body-xs-regular text-text-destructive"> - {t($ => $['fileUploader.pasteFileLinkInvalid'], { ns: 'common' })} - </div> - ) - } - </> - ) - } - { - showFromLink && showFromLocal && ( - <div className="flex h-7 items-center p-2 system-2xs-medium-uppercase text-text-quaternary"> - <div className="mr-2 h-px w-[93px] bg-linear-to-l from-[rgba(16,24,40,0.08)]" /> - OR - <div className="ml-2 h-px w-[93px] bg-linear-to-r from-[rgba(16,24,40,0.08)]" /> + {t(($) => $['operation.ok'], { ns: 'common' })} + </Button> </div> - ) - } - { - showFromLocal && ( - <Button - className="relative w-full" - variant="secondary-accent" - disabled={disabled} - > - <RiUploadCloud2Line className="mr-1 size-4" /> - {t($ => $['fileUploader.uploadFromComputer'], { ns: 'common' })} - <FileInput fileConfig={fileConfig} /> - </Button> - ) - } + {showError && ( + <div className="mt-0.5 body-xs-regular text-text-destructive"> + {t(($) => $['fileUploader.pasteFileLinkInvalid'], { ns: 'common' })} + </div> + )} + </> + )} + {showFromLink && showFromLocal && ( + <div className="flex h-7 items-center p-2 system-2xs-medium-uppercase text-text-quaternary"> + <div className="mr-2 h-px w-[93px] bg-linear-to-l from-[rgba(16,24,40,0.08)]" /> + OR + <div className="ml-2 h-px w-[93px] bg-linear-to-r from-[rgba(16,24,40,0.08)]" /> + </div> + )} + {showFromLocal && ( + <Button className="relative w-full" variant="secondary-accent" disabled={disabled}> + <RiUploadCloud2Line className="mr-1 size-4" /> + {t(($) => $['fileUploader.uploadFromComputer'], { ns: 'common' })} + <FileInput fileConfig={fileConfig} /> + </Button> + )} </div> </PopoverContent> </Popover> diff --git a/web/app/components/base/file-uploader/file-image-render.stories.tsx b/web/app/components/base/file-uploader/file-image-render.stories.tsx index ca051e4b276046..52d17869107b18 100644 --- a/web/app/components/base/file-uploader/file-image-render.stories.tsx +++ b/web/app/components/base/file-uploader/file-image-render.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' import FileImageRender from './file-image-render' -const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'320\' height=\'180\'><defs><linearGradient id=\'grad\' x1=\'0%\' y1=\'0%\' x2=\'100%\' y2=\'100%\'><stop offset=\'0%\' stop-color=\'#FEE2FF\'/><stop offset=\'100%\' stop-color=\'#E0EAFF\'/></linearGradient></defs><rect width=\'320\' height=\'180\' rx=\'18\' fill=\'url(#grad)\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'24\' fill=\'#1F2937\'>Preview</text></svg>' +const SAMPLE_IMAGE = + "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='320' height='180'><defs><linearGradient id='grad' x1='0%' y1='0%' x2='100%' y2='100%'><stop offset='0%' stop-color='#FEE2FF'/><stop offset='100%' stop-color='#E0EAFF'/></linearGradient></defs><rect width='320' height='180' rx='18' fill='url(#grad)'/><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='24' fill='#1F2937'>Preview</text></svg>" const meta = { title: 'Base/General/FileImageRender', @@ -9,7 +10,8 @@ const meta = { parameters: { docs: { description: { - component: 'Renders image previews inside a bordered frame. Often used in upload galleries and logs.', + component: + 'Renders image previews inside a bordered frame. Often used in upload galleries and logs.', }, source: { language: 'tsx', diff --git a/web/app/components/base/file-uploader/file-input.tsx b/web/app/components/base/file-uploader/file-input.tsx index f4cd4514e71501..cf54be7ddad423 100644 --- a/web/app/components/base/file-uploader/file-input.tsx +++ b/web/app/components/base/file-uploader/file-input.tsx @@ -7,10 +7,8 @@ import { useStore } from './store' type FileInputProps = { fileConfig: FileUpload } -const FileInput = ({ - fileConfig, -}: FileInputProps) => { - const files = useStore(s => s.files) +const FileInput = ({ fileConfig }: FileInputProps) => { + const files = useStore((s) => s.files) const { handleLocalFileUpload } = useFile(fileConfig) const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const targetFiles = e.target.files @@ -21,8 +19,7 @@ const FileInput = ({ if (i + 1 + files.length <= fileConfig.number_limits) handleLocalFileUpload(targetFiles[i]!) } - } - else { + } else { handleLocalFileUpload(targetFiles[0]!) } } @@ -30,13 +27,15 @@ const FileInput = ({ const allowedFileTypes = fileConfig.allowed_file_types const isCustom = allowedFileTypes?.includes(SupportUploadFileTypes.custom) - const exts = isCustom ? (fileConfig.allowed_file_extensions || []) : (allowedFileTypes?.map(type => FILE_EXTS[type]) || []).flat().map(item => `.${item}`) + const exts = isCustom + ? fileConfig.allowed_file_extensions || [] + : (allowedFileTypes?.map((type) => FILE_EXTS[type]) || []).flat().map((item) => `.${item}`) const accept = exts.join(',') return ( <input className="absolute inset-0 block w-full cursor-pointer text-[0] opacity-0 disabled:cursor-not-allowed" - onClick={e => ((e.target as HTMLInputElement).value = '')} + onClick={(e) => ((e.target as HTMLInputElement).value = '')} type="file" onChange={handleChange} accept={accept} diff --git a/web/app/components/base/file-uploader/file-list-in-log.tsx b/web/app/components/base/file-uploader/file-list-in-log.tsx index 24c27136b18f8f..724dc028a69ad6 100644 --- a/web/app/components/base/file-uploader/file-list-in-log.tsx +++ b/web/app/components/base/file-uploader/file-list-in-log.tsx @@ -9,9 +9,7 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types' import FileImageRender from './file-image-render' import FileTypeIcon from './file-type-icon' import FileItem from './file-uploader-in-attachment/file-item' -import { - getFileAppearanceType, -} from './utils' +import { getFileAppearanceType } from './utils' type Props = Readonly<{ fileList: { @@ -23,7 +21,12 @@ type Props = Readonly<{ noPadding?: boolean }> -const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPadding = false }: Props) => { +const FileListInLog = ({ + fileList, + isExpanded = false, + noBorder = false, + noPadding = false, +}: Props) => { const { t } = useTranslation() const [expanded, setExpanded] = useState(isExpanded) const fullList = useMemo(() => { @@ -32,11 +35,17 @@ const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPaddi }, []) }, [fileList]) - if (!fileList.length) - return null + if (!fileList.length) return null return ( - <div className={cn('px-3 py-2', expanded && 'py-3', !noBorder && 'border-t border-divider-subtle', noPadding && 'p-0!')}> + <div + className={cn( + 'px-3 py-2', + expanded && 'py-3', + !noBorder && 'border-t border-divider-subtle', + noPadding && 'p-0!', + )} + > <div className="flex justify-between gap-1"> {expanded && ( <button @@ -44,7 +53,7 @@ const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPaddi className="grow cursor-pointer border-none bg-transparent px-0 py-1 text-left system-xs-semibold-uppercase text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={() => setExpanded(!expanded)} > - {t($ => $['runDetail.fileListLabel'], { ns: 'appLog' })} + {t(($) => $['runDetail.fileListLabel'], { ns: 'appLog' })} </button> )} {!expanded && ( @@ -57,35 +66,28 @@ const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPaddi {isImageFile && ( <Tooltip> <TooltipTrigger - render={( + render={ <div key={id}> - <FileImageRender - className="size-8" - imageUrl={base64Url || url || ''} - /> + <FileImageRender className="size-8" imageUrl={base64Url || url || ''} /> </div> - )} + } /> - <TooltipContent> - {name} - </TooltipContent> + <TooltipContent>{name}</TooltipContent> </Tooltip> )} {!isImageFile && ( <Tooltip> <TooltipTrigger - render={( - <div key={id} className="rounded-md border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1.5 shadow-xs"> - <FileTypeIcon - type={getFileAppearanceType(name, type)} - size="lg" - /> + render={ + <div + key={id} + className="rounded-md border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1.5 shadow-xs" + > + <FileTypeIcon type={getFileAppearanceType(name, type)} size="lg" /> </div> - )} + } /> - <TooltipContent> - {name} - </TooltipContent> + <TooltipContent>{name}</TooltipContent> </Tooltip> )} </> @@ -95,20 +97,27 @@ const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPaddi )} <button type="button" - aria-label={t($ => $['runDetail.fileListDetail'], { ns: 'appLog' })} + aria-label={t(($) => $['runDetail.fileListDetail'], { ns: 'appLog' })} className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={() => setExpanded(!expanded)} > - {!expanded && <div className="system-xs-medium-uppercase text-text-tertiary">{t($ => $['runDetail.fileListDetail'], { ns: 'appLog' })}</div>} - <RiArrowRightSLine className={cn('size-4 text-text-tertiary', expanded && 'rotate-90')} aria-hidden="true" /> + {!expanded && ( + <div className="system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['runDetail.fileListDetail'], { ns: 'appLog' })} + </div> + )} + <RiArrowRightSLine + className={cn('size-4 text-text-tertiary', expanded && 'rotate-90')} + aria-hidden="true" + /> </button> </div> {expanded && ( <div className="flex flex-col gap-3"> - {fileList.map(item => ( + {fileList.map((item) => ( <div key={item.varName} className="flex flex-col gap-1 system-xs-regular"> <div className="py-1 text-text-tertiary">{item.varName}</div> - {item.list.map(file => ( + {item.list.map((file) => ( <FileItem key={file.id} file={file} diff --git a/web/app/components/base/file-uploader/file-list.stories.tsx b/web/app/components/base/file-uploader/file-list.stories.tsx index 560202779ab535..aa600bcbd4da57 100644 --- a/web/app/components/base/file-uploader/file-list.stories.tsx +++ b/web/app/components/base/file-uploader/file-list.stories.tsx @@ -5,7 +5,8 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' import { FileList } from './file-uploader-in-chat-input/file-list' -const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'160\' height=\'160\'><rect width=\'160\' height=\'160\' rx=\'16\' fill=\'#D1E9FF\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'20\' fill=\'#1F2937\'>IMG</text></svg>' +const SAMPLE_IMAGE = + "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><rect width='160' height='160' rx='16' fill='#D1E9FF'/><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='20' fill='#1F2937'>IMG</text></svg>" const filesSample: FileEntity[] = [ { @@ -46,7 +47,8 @@ const meta = { parameters: { docs: { description: { - component: 'Renders a responsive gallery of uploaded files, handling icons, previews, and progress states.', + component: + 'Renders a responsive gallery of uploaded files, handling icons, previews, and progress states.', }, }, }, @@ -67,14 +69,14 @@ const FileListPlayground = (args: React.ComponentProps<typeof FileList>) => { <FileList {...args} files={items} - onRemove={fileId => setItems(list => list.filter(file => file.id !== fileId))} + onRemove={(fileId) => setItems((list) => list.filter((file) => file.id !== fileId))} /> </div> ) } export const Playground: Story = { - render: args => <FileListPlayground {...args} />, + render: (args) => <FileListPlayground {...args} />, parameters: { docs: { source: { @@ -91,6 +93,6 @@ const [files, setFiles] = useState(initialFiles) export const UploadStates: Story = { args: { - files: filesSample.map(file => ({ ...file, progress: file.id === '3' ? 45 : 100 })), + files: filesSample.map((file) => ({ ...file, progress: file.id === '3' ? 45 : 100 })), }, } diff --git a/web/app/components/base/file-uploader/file-type-icon.stories.tsx b/web/app/components/base/file-uploader/file-type-icon.stories.tsx index 6a6df069b159c8..d9f3b19c0263ba 100644 --- a/web/app/components/base/file-uploader/file-type-icon.stories.tsx +++ b/web/app/components/base/file-uploader/file-type-icon.stories.tsx @@ -8,7 +8,8 @@ const meta = { parameters: { docs: { description: { - component: 'Displays the appropriate icon and accent colour for a file appearance type. Useful in lists and attachments.', + component: + 'Displays the appropriate icon and accent colour for a file appearance type. Useful in lists and attachments.', }, }, }, @@ -27,7 +28,7 @@ export const Playground: Story = {} export const Gallery: Story = { render: () => ( <div className="grid grid-cols-4 gap-6 rounded-xl border border-divider-subtle bg-components-panel-bg p-6"> - {Object.values(FileAppearanceTypeEnum).map(type => ( + {Object.values(FileAppearanceTypeEnum).map((type) => ( <div key={type} className="flex flex-col items-center gap-2 text-xs text-text-secondary"> <FileTypeIcon type={type} size="xl" /> <span className="capitalize">{type}</span> diff --git a/web/app/components/base/file-uploader/file-type-icon.tsx b/web/app/components/base/file-uploader/file-type-icon.tsx index 54f3f294c6d881..8c0fb6f98b1f44 100644 --- a/web/app/components/base/file-uploader/file-type-icon.tsx +++ b/web/app/components/base/file-uploader/file-type-icon.tsx @@ -78,13 +78,12 @@ const SizeMap = { lg: 'size-5', xl: 'size-6', } -const FileTypeIcon = ({ - type, - size = 'sm', - className, -}: FileTypeIconProps) => { - const Icon = FILE_TYPE_ICON_MAP[type]?.component || FILE_TYPE_ICON_MAP[FileAppearanceTypeEnum.document].component - const color = FILE_TYPE_ICON_MAP[type]?.color || FILE_TYPE_ICON_MAP[FileAppearanceTypeEnum.document].color +const FileTypeIcon = ({ type, size = 'sm', className }: FileTypeIconProps) => { + const Icon = + FILE_TYPE_ICON_MAP[type]?.component || + FILE_TYPE_ICON_MAP[FileAppearanceTypeEnum.document].component + const color = + FILE_TYPE_ICON_MAP[type]?.color || FILE_TYPE_ICON_MAP[FileAppearanceTypeEnum.document].color return <Icon className={cn('shrink-0', SizeMap[size], color, className)} /> } diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx index 83ab265084e56d..1f6be2ffe7338b 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx @@ -51,10 +51,11 @@ describe('FileInAttachmentItem', () => { it('should render FileImageRender for image files', () => { render( - <FileInAttachmentItem file={createFile({ - supportFileType: 'image', - base64Url: 'data:image/png;base64,abc', - })} + <FileInAttachmentItem + file={createFile({ + supportFileType: 'image', + base64Url: 'data:image/png;base64,abc', + })} />, ) @@ -82,7 +83,14 @@ describe('FileInAttachmentItem', () => { it('should call onRemove when delete button is clicked', () => { const onRemove = vi.fn() // Disable download to isolate the delete button - render(<FileInAttachmentItem file={createFile()} showDeleteAction showDownloadAction={false} onRemove={onRemove} />) + render( + <FileInAttachmentItem + file={createFile()} + showDeleteAction + showDownloadAction={false} + onRemove={onRemove} + />, + ) const deleteBtn = screen.getByRole('button') fireEvent.click(deleteBtn) @@ -98,7 +106,9 @@ describe('FileInAttachmentItem', () => { }) it('should render progress circle when file is uploading', () => { - const { container } = render(<FileInAttachmentItem file={createFile({ progress: 50, uploadedId: undefined })} />) + const { container } = render( + <FileInAttachmentItem file={createFile({ progress: 50, uploadedId: undefined })} />, + ) // ProgressCircle renders an SVG with a <circle> and <path> element const svg = container.querySelector('svg') @@ -117,7 +127,9 @@ describe('FileInAttachmentItem', () => { it('should call onReUpload when replay icon is clicked', () => { const onReUpload = vi.fn() - const { container } = render(<FileInAttachmentItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />) + const { container } = render( + <FileInAttachmentItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />, + ) const replayIcon = container.querySelector('[data-icon="ReplayLine"]') const replayBtn = replayIcon!.closest('button') @@ -237,9 +249,11 @@ describe('FileInAttachmentItem', () => { const downloadBtn = screen.getByRole('button') fireEvent.click(downloadBtn) - expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ - fileName: expect.stringMatching(/document\.pdf/i), - })) + expect(downloadUrl).toHaveBeenCalledWith( + expect.objectContaining({ + fileName: expect.stringMatching(/document\.pdf/i), + }), + ) }) it('should open new page when previewMode is NewPage and clicked', () => { @@ -294,11 +308,7 @@ describe('FileInAttachmentItem', () => { it('should not open new page when previewMode is not NewPage', () => { const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) render( - <FileInAttachmentItem - file={createFile()} - canPreview - previewMode={PreviewMode.CurrentPage} - />, + <FileInAttachmentItem file={createFile()} canPreview previewMode={PreviewMode.CurrentPage} />, ) fireEvent.click(screen.getByText(/document\.pdf/i)) @@ -309,11 +319,12 @@ describe('FileInAttachmentItem', () => { it('should use url for image render fallback when base64Url is empty', () => { render( - <FileInAttachmentItem file={createFile({ - supportFileType: 'image', - base64Url: undefined, - url: 'https://example.com/img.png', - })} + <FileInAttachmentItem + file={createFile({ + supportFileType: 'image', + base64Url: undefined, + url: 'https://example.com/img.png', + })} />, ) @@ -323,11 +334,12 @@ describe('FileInAttachmentItem', () => { it('should render image element even when both urls are empty', () => { render( - <FileInAttachmentItem file={createFile({ - supportFileType: 'image', - base64Url: undefined, - url: undefined, - })} + <FileInAttachmentItem + file={createFile({ + supportFileType: 'image', + base64Url: undefined, + url: undefined, + })} />, ) @@ -363,9 +375,11 @@ describe('FileInAttachmentItem', () => { const downloadBtn = screen.getByRole('button') fireEvent.click(downloadBtn) - expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ - url: 'data:application/pdf;base64,abc', - })) + expect(downloadUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'data:application/pdf;base64,abc', + }), + ) }) it('should not render file size when size is 0', () => { @@ -477,25 +491,26 @@ describe('FileInAttachmentItem', () => { const downloadBtn = screen.getByRole('button') fireEvent.click(downloadBtn) - expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ - url: '', - })) + expect(downloadUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: '', + }), + ) }) it('should call downloadUrl with empty url when both url and base64Url are falsy', async () => { const { downloadUrl } = await import('@/utils/download') render( - <FileInAttachmentItem - file={createFile({ url: '', base64Url: '' })} - showDownloadAction - />, + <FileInAttachmentItem file={createFile({ url: '', base64Url: '' })} showDownloadAction />, ) const downloadBtn = screen.getByRole('button') fireEvent.click(downloadBtn) - expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ - url: '', - })) + expect(downloadUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: '', + }), + ) }) }) diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/index.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/index.spec.tsx index d909deacc60bd5..fe8ad0884fe45e 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/index.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/index.spec.tsx @@ -21,14 +21,15 @@ vi.mock('@/utils/download', () => ({ downloadUrl: vi.fn(), })) -const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({ - enabled: true, - allowed_file_types: ['image'], - allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], - allowed_file_extensions: [], - number_limits: 5, - ...overrides, -} as unknown as FileUpload) +const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => + ({ + enabled: true, + allowed_file_types: ['image'], + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + allowed_file_extensions: [], + number_limits: 5, + ...overrides, + }) as unknown as FileUpload const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({ id: 'file-1', @@ -47,24 +48,14 @@ describe('FileUploaderInAttachmentWrapper', () => { }) it('should render without crashing', () => { - render( - <FileUploaderInAttachmentWrapper - onChange={vi.fn()} - fileConfig={createFileConfig()} - />, - ) + render(<FileUploaderInAttachmentWrapper onChange={vi.fn()} fileConfig={createFileConfig()} />) // FileContextProvider wraps children with a Zustand context — verify children render expect(screen.getAllByRole('button').length).toBeGreaterThan(0) }) it('should render upload buttons when not disabled', () => { - render( - <FileUploaderInAttachmentWrapper - onChange={vi.fn()} - fileConfig={createFileConfig()} - />, - ) + render(<FileUploaderInAttachmentWrapper onChange={vi.fn()} fileConfig={createFileConfig()} />) const buttons = screen.getAllByRole('button') expect(buttons.length).toBeGreaterThan(0) @@ -83,10 +74,7 @@ describe('FileUploaderInAttachmentWrapper', () => { }) it('should render file items for each file', () => { - const files = [ - createFile({ id: 'f1', name: 'a.txt' }), - createFile({ id: 'f2', name: 'b.txt' }), - ] + const files = [createFile({ id: 'f1', name: 'a.txt' }), createFile({ id: 'f2', name: 'b.txt' })] render( <FileUploaderInAttachmentWrapper @@ -165,12 +153,7 @@ describe('FileUploaderInAttachmentWrapper', () => { }) it('should keep upload button layout stable when remote_url popup opens', () => { - render( - <FileUploaderInAttachmentWrapper - onChange={vi.fn()} - fileConfig={createFileConfig()} - />, - ) + render(<FileUploaderInAttachmentWrapper onChange={vi.fn()} fileConfig={createFileConfig()} />) const localButton = screen.getByText(/fileUploader\.uploadFromComputer/).closest('button') const linkButton = screen.getByText(/fileUploader\.pasteFileLink/).closest('button') @@ -205,7 +188,7 @@ describe('FileUploaderInAttachmentWrapper', () => { ) const buttons = screen.getAllByRole('button') - const disabledButtons = buttons.filter(btn => btn.hasAttribute('disabled')) + const disabledButtons = buttons.filter((btn) => btn.hasAttribute('disabled')) expect(disabledButtons.length).toBeGreaterThan(0) }) diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx index fdabcd0b1e545f..7de7f1b21b154b 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx @@ -1,15 +1,8 @@ import type { FileEntity } from '../types' import { cn } from '@langgenius/dify-ui/cn' import { ProgressCircle } from '@langgenius/dify-ui/progress' -import { - RiDeleteBinLine, - RiDownloadLine, - RiEyeLine, -} from '@remixicon/react' -import { - memo, - useState, -} from 'react' +import { RiDeleteBinLine, RiDownloadLine, RiEyeLine } from '@remixicon/react' +import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { PreviewMode } from '@/app/components/base/features/types' @@ -20,11 +13,7 @@ import { downloadUrl } from '@/utils/download' import { formatFileSize } from '@/utils/format' import FileImageRender from '../file-image-render' import FileTypeIcon from '../file-type-icon' -import { - fileIsUploaded, - getFileAppearanceType, - getFileExtension, -} from '../utils' +import { fileIsUploaded, getFileAppearanceType, getFileExtension } from '../utils' type FileInAttachmentItemProps = { file: FileEntity @@ -63,22 +52,8 @@ const FileInAttachmentItem = ({ }} > <div className="flex size-12 items-center justify-center"> - { - isImageFile && ( - <FileImageRender - className="size-8" - imageUrl={base64Url || url || ''} - /> - ) - } - { - !isImageFile && ( - <FileTypeIcon - type={getFileAppearanceType(name, type)} - size="xl" - /> - ) - } + {isImageFile && <FileImageRender className="size-8" imageUrl={base64Url || url || ''} />} + {!isImageFile && <FileTypeIcon type={getFileAppearanceType(name, type)} size="xl" />} </div> <div className="mr-1 w-0 grow"> <div @@ -88,79 +63,49 @@ const FileInAttachmentItem = ({ <div className="truncate">{name}</div> </div> <div className="flex items-center system-2xs-medium-uppercase text-text-tertiary"> - { - ext && ( - <span>{ext.toLowerCase()}</span> - ) - } - { - ext && ( - <span className="mx-1 system-2xs-medium">•</span> - ) - } - { - !!file.size && ( - <span>{formatFileSize(file.size)}</span> - ) - } + {ext && <span>{ext.toLowerCase()}</span>} + {ext && <span className="mx-1 system-2xs-medium">•</span>} + {!!file.size && <span>{formatFileSize(file.size)}</span>} </div> </div> <div className="flex shrink-0 items-center"> - { - progress >= 0 && !fileIsUploaded(file) && ( - <ProgressCircle - className="mr-2.5" - value={progress} - aria-label={t($ => $.uploading, { ns: 'custom' })} - /> - ) - } - { - progress === -1 && ( - <ActionButton - className="mr-1" - onClick={() => onReUpload?.(id)} - > - <ReplayLine className="size-4 text-text-tertiary" /> - </ActionButton> - ) - } - { - showDeleteAction && ( - <ActionButton onClick={() => onRemove?.(id)}> - <RiDeleteBinLine className="size-4" /> - </ActionButton> - ) - } - { - canPreview && isImageFile && ( - <ActionButton className="mr-1" onClick={() => setImagePreviewUrl(url || '')}> - <RiEyeLine className="size-4" /> - </ActionButton> - ) - } - { - showDownloadAction && ( - <ActionButton onClick={(e) => { + {progress >= 0 && !fileIsUploaded(file) && ( + <ProgressCircle + className="mr-2.5" + value={progress} + aria-label={t(($) => $.uploading, { ns: 'custom' })} + /> + )} + {progress === -1 && ( + <ActionButton className="mr-1" onClick={() => onReUpload?.(id)}> + <ReplayLine className="size-4 text-text-tertiary" /> + </ActionButton> + )} + {showDeleteAction && ( + <ActionButton onClick={() => onRemove?.(id)}> + <RiDeleteBinLine className="size-4" /> + </ActionButton> + )} + {canPreview && isImageFile && ( + <ActionButton className="mr-1" onClick={() => setImagePreviewUrl(url || '')}> + <RiEyeLine className="size-4" /> + </ActionButton> + )} + {showDownloadAction && ( + <ActionButton + onClick={(e) => { e.stopPropagation() downloadUrl({ url: url || base64Url || '', fileName: name, target: '_blank' }) }} - > - <RiDownloadLine className="size-4" /> - </ActionButton> - ) - } + > + <RiDownloadLine className="size-4" /> + </ActionButton> + )} </div> </div> - { - imagePreviewUrl && canPreview && ( - <ImagePreview - title={name} - url={imagePreviewUrl} - onCancel={() => setImagePreviewUrl('')} - /> - ) - } + {imagePreviewUrl && canPreview && ( + <ImagePreview title={name} url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} /> + )} </> ) } diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.stories.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.stories.tsx index e7e280d2f5bd8e..626a175e448e18 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.stories.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.stories.tsx @@ -9,7 +9,8 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' import FileUploaderInAttachmentWrapper from './index' -const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'128\' height=\'128\'><rect width=\'128\' height=\'128\' rx=\'16\' fill=\'#E0F2FE\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'18\' fill=\'#1F2937\'>IMG</text></svg>' +const SAMPLE_IMAGE = + "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='128' height='128'><rect width='128' height='128' rx='16' fill='#E0F2FE'/><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='18' fill='#1F2937'>IMG</text></svg>" const mockFiles: FileEntity[] = [ { @@ -59,7 +60,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Attachment-style uploader that supports local files and remote links. Demonstrates upload progress, re-upload, and preview actions.', + component: + 'Attachment-style uploader that supports local files and remote links. Demonstrates upload progress, re-upload, and preview actions.', }, }, nextjs: { @@ -86,25 +88,21 @@ const AttachmentDemo = (props: React.ComponentProps<typeof FileUploaderInAttachm <> <ToastHost /> <div className="w-[320px] rounded-2xl border border-divider-subtle bg-components-panel-bg p-4 shadow-xs"> - <FileUploaderInAttachmentWrapper - {...props} - value={files} - onChange={setFiles} - /> + <FileUploaderInAttachmentWrapper {...props} value={files} onChange={setFiles} /> </div> </> ) } export const Playground: Story = { - render: args => <AttachmentDemo {...args} />, + render: (args) => <AttachmentDemo {...args} />, args: { onChange: fn(), }, } export const Disabled: Story = { - render: args => <AttachmentDemo {...args} isDisabled />, + render: (args) => <AttachmentDemo {...args} isDisabled />, args: { onChange: fn(), }, diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx index 690ea551012659..f484e4a20db3b6 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx @@ -2,22 +2,14 @@ import type { FileEntity } from '../types' import type { FileUpload } from '@/app/components/base/features/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - RiLink, - RiUploadCloud2Line, -} from '@remixicon/react' -import { - useCallback, -} from 'react' +import { RiLink, RiUploadCloud2Line } from '@remixicon/react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { TransferMethod } from '@/types/app' import FileFromLinkOrLocal from '../file-from-link-or-local' import FileInput from '../file-input' import { useFile } from '../hooks' -import { - FileContextProvider, - useStore, -} from '../store' +import { FileContextProvider, useStore } from '../store' import FileItem from './file-item' type Option = { @@ -29,93 +21,95 @@ type FileUploaderInAttachmentProps = { isDisabled?: boolean fileConfig: FileUpload } -const FileUploaderInAttachment = ({ - isDisabled, - fileConfig, -}: FileUploaderInAttachmentProps) => { +const FileUploaderInAttachment = ({ isDisabled, fileConfig }: FileUploaderInAttachmentProps) => { const { t } = useTranslation() - const files = useStore(s => s.files) - const { - handleRemoveFile, - handleReUploadFile, - } = useFile(fileConfig) + const files = useStore((s) => s.files) + const { handleRemoveFile, handleReUploadFile } = useFile(fileConfig) const options = [ { value: TransferMethod.local_file, - label: t($ => $['fileUploader.uploadFromComputer'], { ns: 'common' }), + label: t(($) => $['fileUploader.uploadFromComputer'], { ns: 'common' }), icon: <RiUploadCloud2Line className="size-4" />, }, { value: TransferMethod.remote_url, - label: t($ => $['fileUploader.pasteFileLink'], { ns: 'common' }), + label: t(($) => $['fileUploader.pasteFileLink'], { ns: 'common' }), icon: <RiLink className="size-4" />, }, ] - const renderButton = useCallback((option: Option, open?: boolean) => { - return ( - <Button - variant="tertiary" - className={cn('relative w-full min-w-0', open && 'bg-components-button-tertiary-bg-hover')} - disabled={!!(fileConfig.number_limits && files.length >= fileConfig.number_limits)} - > - <span className="shrink-0">{option.icon}</span> - <span className="ml-1 min-w-0 truncate">{option.label}</span> - { - option.value === TransferMethod.local_file && ( - <FileInput fileConfig={fileConfig} /> - ) - } - </Button> - ) - }, [fileConfig, files.length]) - const renderTrigger = useCallback((option: Option) => { - return (open: boolean) => renderButton(option, open) - }, [renderButton]) - const renderOption = useCallback((option: Option) => { - if (option.value === TransferMethod.local_file && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file)) { + const renderButton = useCallback( + (option: Option, open?: boolean) => { return ( - <div key={option.value} className="min-w-0 flex-1"> - {renderButton(option)} - </div> + <Button + variant="tertiary" + className={cn( + 'relative w-full min-w-0', + open && 'bg-components-button-tertiary-bg-hover', + )} + disabled={!!(fileConfig.number_limits && files.length >= fileConfig.number_limits)} + > + <span className="shrink-0">{option.icon}</span> + <span className="ml-1 min-w-0 truncate">{option.label}</span> + {option.value === TransferMethod.local_file && <FileInput fileConfig={fileConfig} />} + </Button> ) - } + }, + [fileConfig, files.length], + ) + const renderTrigger = useCallback( + (option: Option) => { + return (open: boolean) => renderButton(option, open) + }, + [renderButton], + ) + const renderOption = useCallback( + (option: Option) => { + if ( + option.value === TransferMethod.local_file && + fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file) + ) { + return ( + <div key={option.value} className="min-w-0 flex-1"> + {renderButton(option)} + </div> + ) + } - if (option.value === TransferMethod.remote_url && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)) { - return ( - <div key={option.value} className="min-w-0 flex-1"> - <FileFromLinkOrLocal - showFromLocal={false} - trigger={renderTrigger(option)} - fileConfig={fileConfig} - /> - </div> - ) - } - }, [renderButton, renderTrigger, fileConfig]) + if ( + option.value === TransferMethod.remote_url && + fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url) + ) { + return ( + <div key={option.value} className="min-w-0 flex-1"> + <FileFromLinkOrLocal + showFromLocal={false} + trigger={renderTrigger(option)} + fileConfig={fileConfig} + /> + </div> + ) + } + }, + [renderButton, renderTrigger, fileConfig], + ) return ( <div> - {!isDisabled && ( - <div className="flex items-center gap-1"> - {options.map(renderOption)} - </div> - )} + {!isDisabled && <div className="flex items-center gap-1">{options.map(renderOption)}</div>} <div className="mt-1 space-y-1"> - { - files.map(file => ( - <FileItem - key={file.id} - file={file} - showDeleteAction={!isDisabled} - showDownloadAction={false} - onRemove={() => handleRemoveFile(file.id)} - onReUpload={() => handleReUploadFile(file.id)} - canPreview={fileConfig.preview_config?.file_type_list?.includes(file.type)} - previewMode={fileConfig.preview_config?.mode} - /> - )) - } + {files.map((file) => ( + <FileItem + key={file.id} + file={file} + showDeleteAction={!isDisabled} + showDownloadAction={false} + onRemove={() => handleRemoveFile(file.id)} + onReUpload={() => handleReUploadFile(file.id)} + canPreview={fileConfig.preview_config?.file_type_list?.includes(file.type)} + previewMode={fileConfig.preview_config?.mode} + /> + ))} </div> </div> ) @@ -134,10 +128,7 @@ const FileUploaderInAttachmentWrapper = ({ isDisabled, }: FileUploaderInAttachmentWrapperProps) => { return ( - <FileContextProvider - value={value} - onChange={onChange} - > + <FileContextProvider value={value} onChange={onChange}> <FileUploaderInAttachment isDisabled={isDisabled} fileConfig={fileConfig} /> </FileContextProvider> ) diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.spec.tsx index 983bdf93938bd2..c9f0c5c2e3a828 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.spec.tsx @@ -62,7 +62,7 @@ describe('FileImageItem', () => { ) const svgs = container.querySelectorAll('svg') - const progressSvg = Array.from(svgs).find(svg => svg.querySelector('circle')) + const progressSvg = Array.from(svgs).find((svg) => svg.querySelector('circle')) expect(progressSvg)!.toBeInTheDocument() }) @@ -74,9 +74,7 @@ describe('FileImageItem', () => { it('should call onReUpload when replay icon is clicked', () => { const onReUpload = vi.fn() - render( - <FileImageItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />, - ) + render(<FileImageItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />) fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' })) @@ -141,7 +139,11 @@ describe('FileImageItem', () => { }) it('should use url when both base64Url and url fallback for image render', () => { - render(<FileImageItem file={createFile({ base64Url: undefined, url: 'https://example.com/img.png' })} />) + render( + <FileImageItem + file={createFile({ base64Url: undefined, url: 'https://example.com/img.png' })} + />, + ) const img = screen.getByRole('img') expect(img)!.toHaveAttribute('src', 'https://example.com/img.png') @@ -160,9 +162,11 @@ describe('FileImageItem', () => { render(<FileImageItem file={file} showDownloadAction />) fireEvent.click(screen.getByRole('button', { name: 'common.operation.download' })) - expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('as_attachment=true'), - })) + expect(downloadUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining('as_attachment=true'), + }), + ) }) it('should use base64Url for download_url when url is not available', async () => { @@ -172,13 +176,23 @@ describe('FileImageItem', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.download' })) - expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ - url: 'data:image/png;base64,abc', - })) + expect(downloadUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'data:image/png;base64,abc', + }), + ) }) it('should set preview url using base64Url when available', () => { - render(<FileImageItem file={createFile({ base64Url: 'data:image/png;base64,abc', url: 'https://example.com/photo.png' })} canPreview />) + render( + <FileImageItem + file={createFile({ + base64Url: 'data:image/png;base64,abc', + url: 'https://example.com/photo.png', + })} + canPreview + />, + ) const img = screen.getByRole('img') fireEvent.click(img.parentElement!) @@ -187,7 +201,12 @@ describe('FileImageItem', () => { }) it('should set preview url using url when base64Url is not available', () => { - render(<FileImageItem file={createFile({ base64Url: undefined, url: 'https://example.com/photo.png' })} canPreview />) + render( + <FileImageItem + file={createFile({ base64Url: undefined, url: 'https://example.com/photo.png' })} + canPreview + />, + ) const img = screen.getByRole('img') fireEvent.click(img.parentElement!) @@ -212,9 +231,11 @@ describe('FileImageItem', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.download' })) - expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('as_attachment=true'), - fileName: 'photo.png', - })) + expect(downloadUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining('as_attachment=true'), + fileName: 'photo.png', + }), + ) }) }) diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx index 0a1902c9c56ac5..c9553c4b8059f4 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx @@ -12,9 +12,11 @@ vi.mock('@/utils/format', () => ({ })) vi.mock('../../dynamic-pdf-preview', () => ({ - default: ({ url, onCancel }: { url: string, onCancel: () => void }) => ( + default: ({ url, onCancel }: { url: string; onCancel: () => void }) => ( <div data-testid="pdf-preview" data-url={url}> - <button data-testid="pdf-close" onClick={onCancel}>Close PDF</button> + <button data-testid="pdf-close" onClick={onCancel}> + Close PDF + </button> </div> ), })) @@ -89,9 +91,7 @@ describe('FileItem (chat-input)', () => { it('should call onReUpload when replay icon is clicked', () => { const onReUpload = vi.fn() - render( - <FileItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />, - ) + render(<FileItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />) const replayIcon = screen.getByRole('button', { name: 'common.operation.retry' }) fireEvent.click(replayIcon!) @@ -313,10 +313,7 @@ describe('FileItem (chat-input)', () => { it('should not render download button when download_url is falsy', () => { render( - <FileItem - file={createFile({ url: undefined, base64Url: undefined })} - showDownloadAction - />, + <FileItem file={createFile({ url: undefined, base64Url: undefined })} showDownloadAction />, ) const buttons = screen.queryAllByRole('button') diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-list.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-list.spec.tsx index de0ae72e35801c..6ecb70872f1a12 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-list.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-list.spec.tsx @@ -37,22 +37,26 @@ describe('FileList', () => { }) it('should render FileImageItem for image files', () => { - const files = [createFile({ - name: 'photo.png', - type: 'image/png', - supportFileType: 'image', - base64Url: 'data:image/png;base64,abc', - })] + const files = [ + createFile({ + name: 'photo.png', + type: 'image/png', + supportFileType: 'image', + base64Url: 'data:image/png;base64,abc', + }), + ] render(<FileList files={files} />) expect(screen.getByRole('img')).toBeInTheDocument() }) it('should render FileItem for non-image files', () => { - const files = [createFile({ - name: 'document.pdf', - supportFileType: 'document', - })] + const files = [ + createFile({ + name: 'document.pdf', + supportFileType: 'document', + }), + ] render(<FileList files={files} />) expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument() diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/index.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/index.spec.tsx index 3fad1a7754bd39..a3712284533ff5 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/index.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/index.spec.tsx @@ -21,21 +21,18 @@ vi.mock('../../hooks', () => ({ })) function renderWithProvider(ui: React.ReactElement) { - return render( - <FileContextProvider> - {ui} - </FileContextProvider>, - ) + return render(<FileContextProvider>{ui}</FileContextProvider>) } -const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({ - enabled: true, - allowed_file_types: ['image'], - allowed_file_upload_methods: ['local_file', 'remote_url'], - allowed_file_extensions: [], - number_limits: 5, - ...overrides, -} as unknown as FileUpload) +const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => + ({ + enabled: true, + allowed_file_types: ['image'], + allowed_file_upload_methods: ['local_file', 'remote_url'], + allowed_file_extensions: [], + number_limits: 5, + ...overrides, + }) as unknown as FileUpload describe('FileUploaderInChatInput', () => { beforeEach(() => { @@ -45,7 +42,9 @@ describe('FileUploaderInChatInput', () => { it('should render a named attachment trigger', () => { renderWithProvider(<FileUploaderInChatInput fileConfig={createFileConfig()} />) - expect(screen.getByRole('button', { name: /fileUploader\.uploadFromComputer/ })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /fileUploader\.uploadFromComputer/ }), + ).toBeInTheDocument() }) it('should render FileFromLinkOrLocal when not readonly', () => { @@ -65,9 +64,10 @@ describe('FileUploaderInChatInput', () => { it('should render button with attachment icon for local_file upload method', () => { renderWithProvider( - <FileUploaderInChatInput fileConfig={createFileConfig({ - allowed_file_upload_methods: ['local_file'], - } as unknown as Partial<FileUpload>)} + <FileUploaderInChatInput + fileConfig={createFileConfig({ + allowed_file_upload_methods: ['local_file'], + } as unknown as Partial<FileUpload>)} />, ) @@ -77,9 +77,10 @@ describe('FileUploaderInChatInput', () => { it('should render button with attachment icon for remote_url upload method', () => { renderWithProvider( - <FileUploaderInChatInput fileConfig={createFileConfig({ - allowed_file_upload_methods: ['remote_url'], - } as unknown as Partial<FileUpload>)} + <FileUploaderInChatInput + fileConfig={createFileConfig({ + allowed_file_upload_methods: ['remote_url'], + } as unknown as Partial<FileUpload>)} />, ) diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx index b1df79bad6708e..d6732d6708e0db 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx @@ -1,19 +1,14 @@ import type { FileEntity } from '../types' import { Button } from '@langgenius/dify-ui/button' import { ProgressCircle } from '@langgenius/dify-ui/progress' -import { - RiCloseLine, - RiDownloadLine, -} from '@remixicon/react' +import { RiCloseLine, RiDownloadLine } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { ReplayLine } from '@/app/components/base/icons/src/vender/other' import ImagePreview from '@/app/components/base/image-uploader/image-preview' import { downloadUrl } from '@/utils/download' import FileImageRender from '../file-image-render' -import { - fileIsUploaded, -} from '../utils' +import { fileIsUploaded } from '../utils' type FileImageItemProps = { file: FileEntity @@ -42,80 +37,69 @@ const FileImageItem = ({ className="group/file-image relative cursor-pointer" onClick={() => canPreview && setImagePreviewUrl(base64Url || url || '')} > - { - showDeleteAction && ( - <Button - aria-label={t($ => $['operation.remove'], { ns: 'common' })} - className="absolute -top-1.5 -right-1.5 z-11 hidden size-5 rounded-full p-0 group-hover/file-image:flex" - onClick={(e) => { - e.stopPropagation() - onRemove?.(id) - }} - > - <RiCloseLine className="size-4 text-components-button-secondary-text" aria-hidden="true" /> - </Button> - ) - } + {showDeleteAction && ( + <Button + aria-label={t(($) => $['operation.remove'], { ns: 'common' })} + className="absolute -top-1.5 -right-1.5 z-11 hidden size-5 rounded-full p-0 group-hover/file-image:flex" + onClick={(e) => { + e.stopPropagation() + onRemove?.(id) + }} + > + <RiCloseLine + className="size-4 text-components-button-secondary-text" + aria-hidden="true" + /> + </Button> + )} <FileImageRender className="h-[68px] w-[68px] shadow-md" imageUrl={base64Url || url || ''} showDownloadAction={showDownloadAction} /> - { - progress >= 0 && !fileIsUploaded(file) && ( - <div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-effects-image-frame bg-background-overlay-alt"> - <ProgressCircle - value={progress} - color="white" - aria-label={t($ => $.uploading, { ns: 'custom' })} - /> - </div> - ) - } - { - progress === -1 && ( - <div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-state-destructive-border bg-background-overlay-destructive"> - <button - type="button" - aria-label={t($ => $['operation.retry'], { ns: 'common' })} - className="size-5 border-none bg-transparent p-0" - onClick={(e) => { - e.stopPropagation() - onReUpload?.(id) - }} - > - <ReplayLine className="size-5" aria-hidden="true" /> - </button> - </div> - ) - } - { - showDownloadAction && ( - <div className="absolute inset-0.5 z-10 hidden bg-background-overlay-alt group-hover/file-image:block"> - <button - type="button" - aria-label={t($ => $['operation.download'], { ns: 'common' })} - className="absolute right-0.5 bottom-0.5 flex size-6 items-center justify-center rounded-lg border-none bg-components-actionbar-bg p-0 shadow-md" - onClick={(e) => { - e.stopPropagation() - downloadUrl({ url: download_url || '', fileName: name, target: '_blank' }) - }} - > - <RiDownloadLine className="size-4 text-text-tertiary" aria-hidden="true" /> - </button> - </div> - ) - } + {progress >= 0 && !fileIsUploaded(file) && ( + <div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-effects-image-frame bg-background-overlay-alt"> + <ProgressCircle + value={progress} + color="white" + aria-label={t(($) => $.uploading, { ns: 'custom' })} + /> + </div> + )} + {progress === -1 && ( + <div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-state-destructive-border bg-background-overlay-destructive"> + <button + type="button" + aria-label={t(($) => $['operation.retry'], { ns: 'common' })} + className="size-5 border-none bg-transparent p-0" + onClick={(e) => { + e.stopPropagation() + onReUpload?.(id) + }} + > + <ReplayLine className="size-5" aria-hidden="true" /> + </button> + </div> + )} + {showDownloadAction && ( + <div className="absolute inset-0.5 z-10 hidden bg-background-overlay-alt group-hover/file-image:block"> + <button + type="button" + aria-label={t(($) => $['operation.download'], { ns: 'common' })} + className="absolute right-0.5 bottom-0.5 flex size-6 items-center justify-center rounded-lg border-none bg-components-actionbar-bg p-0 shadow-md" + onClick={(e) => { + e.stopPropagation() + downloadUrl({ url: download_url || '', fileName: name, target: '_blank' }) + }} + > + <RiDownloadLine className="size-4 text-text-tertiary" aria-hidden="true" /> + </button> + </div> + )} </div> - { - imagePreviewUrl && canPreview && ( - <ImagePreview - title={name} - url={imagePreviewUrl} - onCancel={() => setImagePreviewUrl('')} - /> - ) - } + {imagePreviewUrl && canPreview && ( + <ImagePreview title={name} url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} /> + )} </> ) } diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx index 564e0562b4f912..2aecb6cad15336 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx @@ -11,11 +11,7 @@ import VideoPreview from '@/app/components/base/file-uploader/video-preview' import { downloadUrl } from '@/utils/download' import { formatFileSize } from '@/utils/format' import FileTypeIcon from '../file-type-icon' -import { - fileIsUploaded, - getFileAppearanceType, - getFileExtension, -} from '../utils' +import { fileIsUploaded, getFileAppearanceType, getFileExtension } from '../utils' type FileItemProps = { file: FileEntity @@ -52,20 +48,22 @@ const FileItem = ({ 'group/file-item relative h-[68px] w-[144px] rounded-lg border-[0.5px] border-components-panel-border bg-components-card-bg p-2 shadow-xs', !uploadError && 'hover:bg-components-card-bg-alt', uploadError && 'border border-state-destructive-border bg-state-destructive-hover', - uploadError && 'bg-state-destructive-hover-alt hover:border-[0.5px] hover:border-state-destructive-border', + uploadError && + 'bg-state-destructive-hover-alt hover:border-[0.5px] hover:border-state-destructive-border', )} > - { - showDeleteAction && ( - <Button - aria-label={t($ => $['operation.remove'], { ns: 'common' })} - className="absolute -top-1.5 -right-1.5 z-11 hidden size-5 rounded-full p-0 group-hover/file-item:flex" - onClick={() => onRemove?.(id)} - > - <span className="i-ri-close-line size-4 text-components-button-secondary-text" aria-hidden="true" /> - </Button> - ) - } + {showDeleteAction && ( + <Button + aria-label={t(($) => $['operation.remove'], { ns: 'common' })} + className="absolute -top-1.5 -right-1.5 z-11 hidden size-5 rounded-full p-0 group-hover/file-item:flex" + onClick={() => onRemove?.(id)} + > + <span + className="i-ri-close-line size-4 text-components-button-secondary-text" + aria-hidden="true" + /> + </Button> + )} <div className="mb-1 line-clamp-2 h-8 cursor-pointer system-xs-medium break-all text-text-tertiary" title={name} @@ -75,84 +73,61 @@ const FileItem = ({ </div> <div className="relative flex items-center justify-between"> <div className="flex items-center system-2xs-medium-uppercase text-text-tertiary"> - <FileTypeIcon - size="sm" - type={getFileAppearanceType(name, type)} - className="mr-1" - /> - { - ext && ( - <> - {ext} - <div className="mx-1">·</div> - </> - ) - } - { - !!file.size && formatFileSize(file.size) - } + <FileTypeIcon size="sm" type={getFileAppearanceType(name, type)} className="mr-1" /> + {ext && ( + <> + {ext} + <div className="mx-1">·</div> + </> + )} + {!!file.size && formatFileSize(file.size)} </div> - { - showDownloadAction && download_url && ( - <ActionButton - aria-label={t($ => $['operation.download'], { ns: 'common' })} - size="m" - className="absolute -top-1 -right-1 hidden group-hover/file-item:flex" - onClick={(e) => { - e.stopPropagation() - downloadUrl({ url: download_url || '', fileName: name, target: '_blank' }) - }} - > - <span className="i-ri-download-line size-3.5 text-text-tertiary" aria-hidden="true" /> - </ActionButton> - ) - } - { - progress >= 0 && !fileIsUploaded(file) && ( - <ProgressCircle - value={progress} - className="shrink-0" - aria-label={t($ => $.uploading, { ns: 'custom' })} - /> - ) - } - { - uploadError && ( - <button - type="button" - aria-label={t($ => $['operation.retry'], { ns: 'common' })} - className="size-4 cursor-pointer border-none bg-transparent p-0 text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={() => onReUpload?.(id)} - > - <span className="i-custom-vender-other-replay-line block size-4" aria-hidden="true" /> - </button> - ) - } + {showDownloadAction && download_url && ( + <ActionButton + aria-label={t(($) => $['operation.download'], { ns: 'common' })} + size="m" + className="absolute -top-1 -right-1 hidden group-hover/file-item:flex" + onClick={(e) => { + e.stopPropagation() + downloadUrl({ url: download_url || '', fileName: name, target: '_blank' }) + }} + > + <span className="i-ri-download-line size-3.5 text-text-tertiary" aria-hidden="true" /> + </ActionButton> + )} + {progress >= 0 && !fileIsUploaded(file) && ( + <ProgressCircle + value={progress} + className="shrink-0" + aria-label={t(($) => $.uploading, { ns: 'custom' })} + /> + )} + {uploadError && ( + <button + type="button" + aria-label={t(($) => $['operation.retry'], { ns: 'common' })} + className="size-4 cursor-pointer border-none bg-transparent p-0 text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => onReUpload?.(id)} + > + <span className="i-custom-vender-other-replay-line block size-4" aria-hidden="true" /> + </button> + )} </div> </div> - { - typeCategory === 'audio' && canPreview && previewUrl && ( - <AudioPreview - title={name} - url={previewUrl} - onCancel={() => setPreviewUrl('')} - /> - ) - } - { - typeCategory === 'video' && canPreview && previewUrl && ( - <VideoPreview - title={name} - url={previewUrl} - onCancel={() => setPreviewUrl('')} - /> - ) - } - { - typeSubtype === 'pdf' && canPreview && previewUrl && ( - <PdfPreview url={previewUrl} onCancel={() => { setPreviewUrl('') }} /> - ) - } + {typeCategory === 'audio' && canPreview && previewUrl && ( + <AudioPreview title={name} url={previewUrl} onCancel={() => setPreviewUrl('')} /> + )} + {typeCategory === 'video' && canPreview && previewUrl && ( + <VideoPreview title={name} url={previewUrl} onCancel={() => setPreviewUrl('')} /> + )} + {typeSubtype === 'pdf' && canPreview && previewUrl && ( + <PdfPreview + url={previewUrl} + onCancel={() => { + setPreviewUrl('') + }} + /> + )} </> ) } diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.tsx index 207aaf3c3f084f..87c20043fcc2b9 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.tsx @@ -27,24 +27,10 @@ export const FileList = ({ }: FileListProps) => { return ( <div className={cn('flex flex-wrap gap-2', className)} data-testid="file-list"> - { - files.map((file) => { - if (file.supportFileType === SupportUploadFileTypes.image) { - return ( - <FileImageItem - key={file.id} - file={file} - showDeleteAction={showDeleteAction} - showDownloadAction={showDownloadAction} - onRemove={onRemove} - onReUpload={onReUpload} - canPreview={canPreview} - /> - ) - } - + {files.map((file) => { + if (file.supportFileType === SupportUploadFileTypes.image) { return ( - <FileItem + <FileImageItem key={file.id} file={file} showDeleteAction={showDeleteAction} @@ -54,8 +40,20 @@ export const FileList = ({ canPreview={canPreview} /> ) - }) - } + } + + return ( + <FileItem + key={file.id} + file={file} + showDeleteAction={showDeleteAction} + showDownloadAction={showDownloadAction} + onRemove={onRemove} + onReUpload={onReUpload} + canPreview={canPreview} + /> + ) + })} </div> ) } @@ -63,20 +61,9 @@ export const FileList = ({ type FileListInChatInputProps = { fileConfig: FileUpload } -export const FileListInChatInput = ({ - fileConfig, -}: FileListInChatInputProps) => { - const files = useStore(s => s.files) - const { - handleRemoveFile, - handleReUploadFile, - } = useFile(fileConfig) +export const FileListInChatInput = ({ fileConfig }: FileListInChatInputProps) => { + const files = useStore((s) => s.files) + const { handleRemoveFile, handleReUploadFile } = useFile(fileConfig) - return ( - <FileList - files={files} - onReUpload={handleReUploadFile} - onRemove={handleRemoveFile} - /> - ) + return <FileList files={files} onReUpload={handleReUploadFile} onRemove={handleRemoveFile} /> } diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.stories.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.stories.tsx index 538f397a11fe8a..1ed0fc7a6dd6f0 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.stories.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.stories.tsx @@ -43,7 +43,9 @@ const ChatInputDemo = ({ initialFiles = mockFiles, ...props }: ChatInputDemoProp <div className="mb-3 text-xs text-text-secondary">Simulated chat input</div> <div className="flex items-center gap-2"> <FileUploaderInChatInput {...props} /> - <div className="flex-1 rounded-lg border border-divider-subtle bg-background-default-subtle p-2 text-xs text-text-tertiary">Type a message...</div> + <div className="flex-1 rounded-lg border border-divider-subtle bg-background-default-subtle p-2 text-xs text-text-tertiary"> + Type a message... + </div> </div> <div className="mt-4"> <FileList files={files} /> @@ -60,7 +62,8 @@ const meta = { parameters: { docs: { description: { - component: 'Attachment trigger suited for chat inputs. Demonstrates integration with the shared file store and preview list.', + component: + 'Attachment trigger suited for chat inputs. Demonstrates integration with the shared file store and preview list.', }, }, nextjs: { @@ -82,7 +85,7 @@ export default meta type Story = StoryObj<typeof meta> export const Playground: Story = { - render: args => <ChatInputDemo {...args} />, + render: (args) => <ChatInputDemo {...args} />, } export const RemoteOnly: Story = { diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx index 9da9d1feaf88d6..b40aa751641ae2 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx @@ -1,9 +1,6 @@ import type { FileUpload } from '@/app/components/base/features/types' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { TransferMethod } from '@/types/app' import FileFromLinkOrLocal from '../file-from-link-or-local' @@ -12,44 +9,46 @@ type FileUploaderInChatInputProps = { fileConfig: FileUpload readonly?: boolean } -const FileUploaderInChatInput = ({ - fileConfig, - readonly, -}: FileUploaderInChatInputProps) => { +const FileUploaderInChatInput = ({ fileConfig, readonly }: FileUploaderInChatInputProps) => { const { t } = useTranslation() - const renderTrigger = useCallback((_open: boolean) => { - return ( - <button - type="button" - aria-label={t($ => $['fileUploader.uploadFromComputer'], { ns: 'common' })} - className={cn( - 'inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg p-1.5 text-text-tertiary outline-hidden', - 'hover:bg-state-base-hover hover:text-text-secondary', - 'focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid', - 'data-popup-open:bg-state-base-hover', - 'disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled', - )} - disabled={readonly} - > - <span className="i-ri-attachment-line size-5" aria-hidden="true" /> - </button> - ) - }, [readonly, t]) + const renderTrigger = useCallback( + (_open: boolean) => { + return ( + <button + type="button" + aria-label={t(($) => $['fileUploader.uploadFromComputer'], { ns: 'common' })} + className={cn( + 'inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg p-1.5 text-text-tertiary outline-hidden', + 'hover:bg-state-base-hover hover:text-text-secondary', + 'focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid', + 'data-popup-open:bg-state-base-hover', + 'disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled', + )} + disabled={readonly} + > + <span className="i-ri-attachment-line size-5" aria-hidden="true" /> + </button> + ) + }, + [readonly, t], + ) return ( <span className="inline-flex size-8 shrink-0 items-center justify-center"> - { - readonly - ? renderTrigger(false) - : ( - <FileFromLinkOrLocal - trigger={renderTrigger} - fileConfig={fileConfig} - showFromLocal={fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file)} - showFromLink={fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)} - /> - ) - } + {readonly ? ( + renderTrigger(false) + ) : ( + <FileFromLinkOrLocal + trigger={renderTrigger} + fileConfig={fileConfig} + showFromLocal={fileConfig?.allowed_file_upload_methods?.includes( + TransferMethod.local_file, + )} + showFromLink={fileConfig?.allowed_file_upload_methods?.includes( + TransferMethod.remote_url, + )} + /> + )} </span> ) } diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index 5d9759bac7585f..89da65db598140 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -5,10 +5,7 @@ import type { FileUploadConfigResponse } from '@/models/common' import { toast } from '@langgenius/dify-ui/toast' import { noop } from 'es-toolkit/function' import { produce } from 'immer' -import { - useCallback, - useState, -} from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { v4 as uuid4 } from 'uuid' import { @@ -21,10 +18,7 @@ import { import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { useParams, usePathname } from '@/next/navigation' import { uploadRemoteFileInfo } from '@/service/common' -import { - uploadHumanInputFormLocalFile, - uploadHumanInputFormRemoteFileInfo, -} from '@/service/share' +import { uploadHumanInputFormLocalFile, uploadHumanInputFormRemoteFileInfo } from '@/service/share' import { TransferMethod } from '@/types/app' import { formatFileSize } from '@/utils/format' import { useFileStore } from './store' @@ -36,11 +30,15 @@ import { } from './utils' export const useFileSizeLimit = (fileUploadConfig?: FileUploadConfigResponse) => { - const imgSizeLimit = Number(fileUploadConfig?.image_file_size_limit) * 1024 * 1024 || IMG_SIZE_LIMIT + const imgSizeLimit = + Number(fileUploadConfig?.image_file_size_limit) * 1024 * 1024 || IMG_SIZE_LIMIT const docSizeLimit = Number(fileUploadConfig?.file_size_limit) * 1024 * 1024 || FILE_SIZE_LIMIT - const audioSizeLimit = Number(fileUploadConfig?.audio_file_size_limit) * 1024 * 1024 || AUDIO_SIZE_LIMIT - const videoSizeLimit = Number(fileUploadConfig?.video_file_size_limit) * 1024 * 1024 || VIDEO_SIZE_LIMIT - const maxFileUploadLimit = Number(fileUploadConfig?.workflow_file_upload_limit) || MAX_FILE_UPLOAD_LIMIT + const audioSizeLimit = + Number(fileUploadConfig?.audio_file_size_limit) * 1024 * 1024 || AUDIO_SIZE_LIMIT + const videoSizeLimit = + Number(fileUploadConfig?.video_file_size_limit) * 1024 * 1024 || VIDEO_SIZE_LIMIT + const maxFileUploadLimit = + Number(fileUploadConfig?.workflow_file_upload_limit) || MAX_FILE_UPLOAD_LIMIT return { imgSizeLimit, @@ -56,242 +54,125 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { const fileStore = useFileStore() const params = useParams() const pathname = usePathname() - const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = useFileSizeLimit(fileConfig.fileUploadConfig) + const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = useFileSizeLimit( + fileConfig.fileUploadConfig, + ) const formToken = typeof params.token === 'string' ? params.token : undefined const isHumanInputFormPage = !!formToken && /(?:^|\/)form\/[^/]+$/.test(pathname) - const checkSizeLimit = useCallback((fileType: string, fileSize: number) => { - switch (fileType) { - case SupportUploadFileTypes.image: { - if (fileSize > imgSizeLimit) { - toast.error(t($ => $['fileUploader.uploadFromComputerLimit'], { - ns: 'common', - type: SupportUploadFileTypes.image, - size: formatFileSize(imgSizeLimit), - })) - return false + const checkSizeLimit = useCallback( + (fileType: string, fileSize: number) => { + switch (fileType) { + case SupportUploadFileTypes.image: { + if (fileSize > imgSizeLimit) { + toast.error( + t(($) => $['fileUploader.uploadFromComputerLimit'], { + ns: 'common', + type: SupportUploadFileTypes.image, + size: formatFileSize(imgSizeLimit), + }), + ) + return false + } + return true } - return true - } - case SupportUploadFileTypes.custom: - case SupportUploadFileTypes.document: { - if (fileSize > docSizeLimit) { - toast.error(t($ => $['fileUploader.uploadFromComputerLimit'], { - ns: 'common', - type: SupportUploadFileTypes.document, - size: formatFileSize(docSizeLimit), - })) - return false + case SupportUploadFileTypes.custom: + case SupportUploadFileTypes.document: { + if (fileSize > docSizeLimit) { + toast.error( + t(($) => $['fileUploader.uploadFromComputerLimit'], { + ns: 'common', + type: SupportUploadFileTypes.document, + size: formatFileSize(docSizeLimit), + }), + ) + return false + } + return true } - return true - } - case SupportUploadFileTypes.audio: { - if (fileSize > audioSizeLimit) { - toast.error(t($ => $['fileUploader.uploadFromComputerLimit'], { - ns: 'common', - type: SupportUploadFileTypes.audio, - size: formatFileSize(audioSizeLimit), - })) - return false + case SupportUploadFileTypes.audio: { + if (fileSize > audioSizeLimit) { + toast.error( + t(($) => $['fileUploader.uploadFromComputerLimit'], { + ns: 'common', + type: SupportUploadFileTypes.audio, + size: formatFileSize(audioSizeLimit), + }), + ) + return false + } + return true } - return true - } - case SupportUploadFileTypes.video: { - if (fileSize > videoSizeLimit) { - toast.error(t($ => $['fileUploader.uploadFromComputerLimit'], { - ns: 'common', - type: SupportUploadFileTypes.video, - size: formatFileSize(videoSizeLimit), - })) - return false + case SupportUploadFileTypes.video: { + if (fileSize > videoSizeLimit) { + toast.error( + t(($) => $['fileUploader.uploadFromComputerLimit'], { + ns: 'common', + type: SupportUploadFileTypes.video, + size: formatFileSize(videoSizeLimit), + }), + ) + return false + } + return true + } + default: { + return true } - return true - } - default: { - return true } - } - }, [audioSizeLimit, docSizeLimit, imgSizeLimit, t, videoSizeLimit]) - - const handleAddFile = useCallback((newFile: FileEntity) => { - const { - files, - setFiles, - } = fileStore.getState() - - const newFiles = produce(files, (draft) => { - draft.push(newFile) - }) - setFiles(newFiles) - }, [fileStore]) - - const handleUpdateFile = useCallback((newFile: FileEntity) => { - const { - files, - setFiles, - } = fileStore.getState() - - const newFiles = produce(files, (draft) => { - const index = draft.findIndex(file => file.id === newFile.id) - - if (index > -1) - draft[index] = newFile - }) - setFiles(newFiles) - }, [fileStore]) - - const handleRemoveFile = useCallback((fileId: string) => { - const { - files, - setFiles, - } = fileStore.getState() - - const newFiles = files.filter(file => file.id !== fileId) - setFiles(newFiles) - }, [fileStore]) + }, + [audioSizeLimit, docSizeLimit, imgSizeLimit, t, videoSizeLimit], + ) - const handleReUploadFile = useCallback((fileId: string) => { - const { - files, - setFiles, - } = fileStore.getState() - const index = files.findIndex(file => file.id === fileId) + const handleAddFile = useCallback( + (newFile: FileEntity) => { + const { files, setFiles } = fileStore.getState() - if (index > -1) { - const uploadingFile = files[index]! const newFiles = produce(files, (draft) => { - draft[index]!.progress = 0 + draft.push(newFile) }) setFiles(newFiles) - const uploadParams: Parameters<typeof fileUpload>[0] = { - file: uploadingFile!.originalFile!, - onProgressCallback: (progress) => { - handleUpdateFile({ ...uploadingFile, progress }) - }, - onSuccessCallback: (res) => { - handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) - }, - onErrorCallback: (error?: unknown) => { - const errorMessage = getFileUploadErrorMessage(error, t($ => $['fileUploader.uploadFromComputerUploadError'], { ns: 'common' }), t) - toast.error(errorMessage) - handleUpdateFile({ ...uploadingFile, progress: -1 }) - }, - } + }, + [fileStore], + ) - if (isHumanInputFormPage) { - uploadHumanInputFormLocalFile({ - formToken: formToken!, - ...uploadParams, - }) - } - else { - fileUpload(uploadParams, !!params.token) - } - } - }, [fileStore, t, handleUpdateFile, isHumanInputFormPage, formToken, params.token]) - - const startProgressTimer = useCallback((fileId: string) => { - const timer = setInterval(() => { - const files = fileStore.getState().files - const file = files.find(file => file.id === fileId) - - if (file && file.progress < 80 && file.progress >= 0) - handleUpdateFile({ ...file, progress: file.progress + 20 }) - else - clearTimeout(timer) - }, 200) - }, [fileStore, handleUpdateFile]) - const handleLoadFileFromLink = useCallback((url: string) => { - const allowedFileTypes = fileConfig.allowed_file_types - - const uploadingFile = { - id: uuid4(), - name: url, - type: '', - size: 0, - progress: 0, - transferMethod: TransferMethod.remote_url, - supportFileType: '', - url, - isRemote: true, - } - handleAddFile(uploadingFile) - startProgressTimer(uploadingFile.id) - - const remoteUpload = isHumanInputFormPage - ? uploadHumanInputFormRemoteFileInfo(formToken!, url) - : uploadRemoteFileInfo(url, !!params.token) - - remoteUpload.then((res) => { - const newFile = { - ...uploadingFile, - type: res.mime_type, - size: res.size, - progress: 100, - supportFileType: getSupportFileType(res.name, res.mime_type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)), - uploadedId: res.id, - url: res.url, - } - if (!isAllowedFileExtension(res.name, res.mime_type, fileConfig.allowed_file_types || [], fileConfig.allowed_file_extensions || [])) { - toast.error(`${t($ => $['fileUploader.fileExtensionNotSupport'], { ns: 'common' })} ${newFile.type}`) - handleRemoveFile(uploadingFile.id) - } - if (!checkSizeLimit(newFile.supportFileType, newFile.size)) - handleRemoveFile(uploadingFile.id) - else - handleUpdateFile(newFile) - }).catch(() => { - toast.error(t($ => $['fileUploader.pasteFileLinkInvalid'], { ns: 'common' })) - handleRemoveFile(uploadingFile.id) - }) - }, [checkSizeLimit, handleAddFile, handleUpdateFile, t, handleRemoveFile, fileConfig?.allowed_file_types, fileConfig.allowed_file_extensions, startProgressTimer, isHumanInputFormPage, formToken, params.token]) + const handleUpdateFile = useCallback( + (newFile: FileEntity) => { + const { files, setFiles } = fileStore.getState() - const handleLoadFileFromLinkSuccess = useCallback(noop, []) + const newFiles = produce(files, (draft) => { + const index = draft.findIndex((file) => file.id === newFile.id) - const handleLoadFileFromLinkError = useCallback(noop, []) + if (index > -1) draft[index] = newFile + }) + setFiles(newFiles) + }, + [fileStore], + ) - const handleClearFiles = useCallback(() => { - const { - setFiles, - } = fileStore.getState() - setFiles([]) - }, [fileStore]) + const handleRemoveFile = useCallback( + (fileId: string) => { + const { files, setFiles } = fileStore.getState() - const handleLocalFileUpload = useCallback((file: File) => { - // Check file upload enabled - if (!noNeedToCheckEnable && !fileConfig.enabled) { - toast.error(t($ => $['fileUploader.uploadDisabled'], { ns: 'common' })) - return - } - if (!isAllowedFileExtension(file.name, file.type, fileConfig.allowed_file_types || [], fileConfig.allowed_file_extensions || [])) { - toast.error(`${t($ => $['fileUploader.fileExtensionNotSupport'], { ns: 'common' })} ${file.type}`) - return - } - const allowedFileTypes = fileConfig.allowed_file_types - const fileType = getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)) - if (!checkSizeLimit(fileType, file.size)) - return - - const reader = new FileReader() - const isImage = file.type.startsWith('image') - - reader.addEventListener( - 'load', - () => { - const uploadingFile = { - id: uuid4(), - name: file.name, - type: file.type, - size: file.size, - progress: 0, - transferMethod: TransferMethod.local_file, - supportFileType: getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)), - originalFile: file, - base64Url: isImage ? reader.result as string : '', - } - handleAddFile(uploadingFile) + const newFiles = files.filter((file) => file.id !== fileId) + setFiles(newFiles) + }, + [fileStore], + ) + + const handleReUploadFile = useCallback( + (fileId: string) => { + const { files, setFiles } = fileStore.getState() + const index = files.findIndex((file) => file.id === fileId) + + if (index > -1) { + const uploadingFile = files[index]! + const newFiles = produce(files, (draft) => { + draft[index]!.progress = 0 + }) + setFiles(newFiles) const uploadParams: Parameters<typeof fileUpload>[0] = { - file: uploadingFile.originalFile, + file: uploadingFile!.originalFile!, onProgressCallback: (progress) => { handleUpdateFile({ ...uploadingFile, progress }) }, @@ -299,7 +180,11 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) }, onErrorCallback: (error?: unknown) => { - const errorMessage = getFileUploadErrorMessage(error, t($ => $['fileUploader.uploadFromComputerUploadError'], { ns: 'common' }), t) + const errorMessage = getFileUploadErrorMessage( + error, + t(($) => $['fileUploader.uploadFromComputerUploadError'], { ns: 'common' }), + t, + ) toast.error(errorMessage) handleUpdateFile({ ...uploadingFile, progress: -1 }) }, @@ -310,31 +195,225 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { formToken: formToken!, ...uploadParams, }) - } - else { + } else { fileUpload(uploadParams, !!params.token) } - }, - false, - ) - reader.addEventListener( - 'error', - () => { - toast.error(t($ => $['fileUploader.uploadFromComputerReadError'], { ns: 'common' })) - }, - false, - ) - reader.readAsDataURL(file) - }, [noNeedToCheckEnable, checkSizeLimit, t, handleAddFile, handleUpdateFile, isHumanInputFormPage, formToken, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions, fileConfig?.enabled]) - - const handleClipboardPasteFile = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => { - const file = e.clipboardData?.files[0] - const text = e.clipboardData?.getData('text/plain') - if (file && !text) { - e.preventDefault() - handleLocalFileUpload(file) - } - }, [handleLocalFileUpload]) + } + }, + [fileStore, t, handleUpdateFile, isHumanInputFormPage, formToken, params.token], + ) + + const startProgressTimer = useCallback( + (fileId: string) => { + const timer = setInterval(() => { + const files = fileStore.getState().files + const file = files.find((file) => file.id === fileId) + + if (file && file.progress < 80 && file.progress >= 0) + handleUpdateFile({ ...file, progress: file.progress + 20 }) + else clearTimeout(timer) + }, 200) + }, + [fileStore, handleUpdateFile], + ) + const handleLoadFileFromLink = useCallback( + (url: string) => { + const allowedFileTypes = fileConfig.allowed_file_types + + const uploadingFile = { + id: uuid4(), + name: url, + type: '', + size: 0, + progress: 0, + transferMethod: TransferMethod.remote_url, + supportFileType: '', + url, + isRemote: true, + } + handleAddFile(uploadingFile) + startProgressTimer(uploadingFile.id) + + const remoteUpload = isHumanInputFormPage + ? uploadHumanInputFormRemoteFileInfo(formToken!, url) + : uploadRemoteFileInfo(url, !!params.token) + + remoteUpload + .then((res) => { + const newFile = { + ...uploadingFile, + type: res.mime_type, + size: res.size, + progress: 100, + supportFileType: getSupportFileType( + res.name, + res.mime_type, + allowedFileTypes?.includes(SupportUploadFileTypes.custom), + ), + uploadedId: res.id, + url: res.url, + } + if ( + !isAllowedFileExtension( + res.name, + res.mime_type, + fileConfig.allowed_file_types || [], + fileConfig.allowed_file_extensions || [], + ) + ) { + toast.error( + `${t(($) => $['fileUploader.fileExtensionNotSupport'], { ns: 'common' })} ${newFile.type}`, + ) + handleRemoveFile(uploadingFile.id) + } + if (!checkSizeLimit(newFile.supportFileType, newFile.size)) + handleRemoveFile(uploadingFile.id) + else handleUpdateFile(newFile) + }) + .catch(() => { + toast.error(t(($) => $['fileUploader.pasteFileLinkInvalid'], { ns: 'common' })) + handleRemoveFile(uploadingFile.id) + }) + }, + [ + checkSizeLimit, + handleAddFile, + handleUpdateFile, + t, + handleRemoveFile, + fileConfig?.allowed_file_types, + fileConfig.allowed_file_extensions, + startProgressTimer, + isHumanInputFormPage, + formToken, + params.token, + ], + ) + + const handleLoadFileFromLinkSuccess = useCallback(noop, []) + + const handleLoadFileFromLinkError = useCallback(noop, []) + + const handleClearFiles = useCallback(() => { + const { setFiles } = fileStore.getState() + setFiles([]) + }, [fileStore]) + + const handleLocalFileUpload = useCallback( + (file: File) => { + // Check file upload enabled + if (!noNeedToCheckEnable && !fileConfig.enabled) { + toast.error(t(($) => $['fileUploader.uploadDisabled'], { ns: 'common' })) + return + } + if ( + !isAllowedFileExtension( + file.name, + file.type, + fileConfig.allowed_file_types || [], + fileConfig.allowed_file_extensions || [], + ) + ) { + toast.error( + `${t(($) => $['fileUploader.fileExtensionNotSupport'], { ns: 'common' })} ${file.type}`, + ) + return + } + const allowedFileTypes = fileConfig.allowed_file_types + const fileType = getSupportFileType( + file.name, + file.type, + allowedFileTypes?.includes(SupportUploadFileTypes.custom), + ) + if (!checkSizeLimit(fileType, file.size)) return + + const reader = new FileReader() + const isImage = file.type.startsWith('image') + + reader.addEventListener( + 'load', + () => { + const uploadingFile = { + id: uuid4(), + name: file.name, + type: file.type, + size: file.size, + progress: 0, + transferMethod: TransferMethod.local_file, + supportFileType: getSupportFileType( + file.name, + file.type, + allowedFileTypes?.includes(SupportUploadFileTypes.custom), + ), + originalFile: file, + base64Url: isImage ? (reader.result as string) : '', + } + handleAddFile(uploadingFile) + const uploadParams: Parameters<typeof fileUpload>[0] = { + file: uploadingFile.originalFile, + onProgressCallback: (progress) => { + handleUpdateFile({ ...uploadingFile, progress }) + }, + onSuccessCallback: (res) => { + handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) + }, + onErrorCallback: (error?: unknown) => { + const errorMessage = getFileUploadErrorMessage( + error, + t(($) => $['fileUploader.uploadFromComputerUploadError'], { ns: 'common' }), + t, + ) + toast.error(errorMessage) + handleUpdateFile({ ...uploadingFile, progress: -1 }) + }, + } + + if (isHumanInputFormPage) { + uploadHumanInputFormLocalFile({ + formToken: formToken!, + ...uploadParams, + }) + } else { + fileUpload(uploadParams, !!params.token) + } + }, + false, + ) + reader.addEventListener( + 'error', + () => { + toast.error(t(($) => $['fileUploader.uploadFromComputerReadError'], { ns: 'common' })) + }, + false, + ) + reader.readAsDataURL(file) + }, + [ + noNeedToCheckEnable, + checkSizeLimit, + t, + handleAddFile, + handleUpdateFile, + isHumanInputFormPage, + formToken, + params.token, + fileConfig?.allowed_file_types, + fileConfig?.allowed_file_extensions, + fileConfig?.enabled, + ], + ) + + const handleClipboardPasteFile = useCallback( + (e: ClipboardEvent<HTMLTextAreaElement>) => { + const file = e.clipboardData?.files[0] + const text = e.clipboardData?.getData('text/plain') + if (file && !text) { + e.preventDefault() + handleLocalFileUpload(file) + } + }, + [handleLocalFileUpload], + ) const [isDragActive, setIsDragActive] = useState(false) const handleDragFileEnter = useCallback((e: React.DragEvent<HTMLElement>) => { @@ -354,16 +433,18 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { setIsDragActive(false) }, []) - const handleDropFile = useCallback((e: React.DragEvent<HTMLElement>) => { - e.preventDefault() - e.stopPropagation() - setIsDragActive(false) + const handleDropFile = useCallback( + (e: React.DragEvent<HTMLElement>) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(false) - const file = e.dataTransfer.files[0] + const file = e.dataTransfer.files[0] - if (file) - handleLocalFileUpload(file) - }, [handleLocalFileUpload]) + if (file) handleLocalFileUpload(file) + }, + [handleLocalFileUpload], + ) return { handleAddFile, diff --git a/web/app/components/base/file-uploader/pdf-highlighter-adapter.tsx b/web/app/components/base/file-uploader/pdf-highlighter-adapter.tsx index c2fb780ca813d1..bd337615666fc7 100644 --- a/web/app/components/base/file-uploader/pdf-highlighter-adapter.tsx +++ b/web/app/components/base/file-uploader/pdf-highlighter-adapter.tsx @@ -1,7 +1,4 @@ import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter' import 'react-pdf-highlighter/dist/style.css' -export { - PdfHighlighter, - PdfLoader, -} +export { PdfHighlighter, PdfLoader } diff --git a/web/app/components/base/file-uploader/pdf-preview.tsx b/web/app/components/base/file-uploader/pdf-preview.tsx index a791a4ef4ad242..fe4f77c2346fe0 100644 --- a/web/app/components/base/file-uploader/pdf-preview.tsx +++ b/web/app/components/base/file-uploader/pdf-preview.tsx @@ -15,10 +15,7 @@ type PdfPreviewProps = { onCancel: () => void } -const PdfPreview: FC<PdfPreviewProps> = ({ - url, - onCancel, -}) => { +const PdfPreview: FC<PdfPreviewProps> = ({ url, onCancel }) => { const { t } = useTranslation() const media = useBreakpoints() const [scale, setScale] = useState(1) @@ -26,17 +23,15 @@ const PdfPreview: FC<PdfPreviewProps> = ({ const isMobile = media === MediaType.mobile const zoomIn = () => { - setScale(prevScale => Math.min(prevScale * 1.2, 15)) + setScale((prevScale) => Math.min(prevScale * 1.2, 15)) setPosition({ x: position.x - 50, y: position.y - 50 }) } const zoomOut = () => { setScale((prevScale) => { const newScale = Math.max(prevScale / 1.2, 0.5) - if (newScale === 1) - setPosition({ x: 0, y: 0 }) - else - setPosition({ x: position.x + 50, y: position.y + 50 }) + if (newScale === 1) setPosition({ x: 0, y: 0 }) + else setPosition({ x: position.x + 50, y: position.y + 50 }) return newScale }) @@ -45,16 +40,15 @@ const PdfPreview: FC<PdfPreviewProps> = ({ useHotkey('ArrowUp', zoomIn) useHotkey('ArrowDown', zoomOut) - const zoomOutLabel = t($ => $['operation.zoomOut'], { ns: 'common' }) - const zoomInLabel = t($ => $['operation.zoomIn'], { ns: 'common' }) - const cancelLabel = t($ => $['operation.cancel'], { ns: 'common' }) + const zoomOutLabel = t(($) => $['operation.zoomOut'], { ns: 'common' }) + const zoomInLabel = t(($) => $['operation.zoomIn'], { ns: 'common' }) + const cancelLabel = t(($) => $['operation.cancel'], { ns: 'common' }) return ( <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} disablePointerDismissal > @@ -65,24 +59,35 @@ const PdfPreview: FC<PdfPreviewProps> = ({ <div aria-label={url} tabIndex={-1} - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} className="h-[95vh] max-h-full w-screen max-w-full overflow-hidden" - style={{ transform: `scale(${scale})`, transformOrigin: 'center', scrollbarWidth: 'none', msOverflowStyle: 'none' }} + style={{ + transform: `scale(${scale})`, + transformOrigin: 'center', + scrollbarWidth: 'none', + msOverflowStyle: 'none', + }} > <PdfLoader workerSrc="/pdf.worker.min.mjs" url={url} - beforeLoad={<div className="flex h-64 items-center justify-center"><Loading type="app" /></div>} + beforeLoad={ + <div className="flex h-64 items-center justify-center"> + <Loading type="app" /> + </div> + } > {(pdfDocument) => { return ( <PdfHighlighter pdfDocument={pdfDocument} - enableAreaSelection={event => event.altKey} + enableAreaSelection={(event) => event.altKey} scrollRef={noop} onScrollChange={noop} onSelectionFinished={() => null} - highlightTransform={() => { return <div /> }} + highlightTransform={() => { + return <div /> + }} highlights={[]} /> ) @@ -91,7 +96,7 @@ const PdfPreview: FC<PdfPreviewProps> = ({ </div> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={zoomOutLabel} @@ -100,15 +105,13 @@ const PdfPreview: FC<PdfPreviewProps> = ({ > <RiZoomOutLine className="size-4 text-gray-500" /> </button> - )} + } /> - <TooltipContent> - {zoomOutLabel} - </TooltipContent> + <TooltipContent>{zoomOutLabel}</TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={zoomInLabel} @@ -117,15 +120,13 @@ const PdfPreview: FC<PdfPreviewProps> = ({ > <RiZoomInLine className="size-4 text-gray-500" /> </button> - )} + } /> - <TooltipContent> - {zoomInLabel} - </TooltipContent> + <TooltipContent>{zoomInLabel}</TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={cancelLabel} @@ -134,11 +135,9 @@ const PdfPreview: FC<PdfPreviewProps> = ({ > <RiCloseLine className="size-4 text-gray-500" /> </button> - )} + } /> - <TooltipContent> - {cancelLabel} - </TooltipContent> + <TooltipContent>{cancelLabel}</TooltipContent> </Tooltip> </DialogContent> </Dialog> diff --git a/web/app/components/base/file-uploader/store.tsx b/web/app/components/base/file-uploader/store.tsx index d6df5ee4caf2d9..0fd7ee38e6844e 100644 --- a/web/app/components/base/file-uploader/store.tsx +++ b/web/app/components/base/file-uploader/store.tsx @@ -1,15 +1,6 @@ -import type { - FileEntity, -} from './types' -import { - createContext, - use, - useRef, -} from 'react' -import { - create, - useStore as useZustandStore, -} from 'zustand' +import type { FileEntity } from './types' +import { createContext, use, useRef } from 'react' +import { create, useStore as useZustandStore } from 'zustand' type Shape = { files: FileEntity[] @@ -20,7 +11,7 @@ export const createFileStore = ( value: FileEntity[] = [], onChange?: (files: FileEntity[]) => void, ) => { - return create<Shape>(set => ({ + return create<Shape>((set) => ({ files: value ? [...value] : [], setFiles: (files) => { set({ files }) @@ -34,8 +25,7 @@ export const FileContext = createContext<FileStore | null>(null) export function useStore<T>(selector: (state: Shape) => T): T { const store = use(FileContext) - if (!store) - throw new Error('Missing FileContext.Provider in the tree') + if (!store) throw new Error('Missing FileContext.Provider in the tree') return useZustandStore(store, selector) } @@ -49,19 +39,10 @@ type FileProviderProps = { value?: FileEntity[] onChange?: (files: FileEntity[]) => void } -export const FileContextProvider = ({ - children, - value, - onChange, -}: FileProviderProps) => { +export const FileContextProvider = ({ children, value, onChange }: FileProviderProps) => { const storeRef = useRef<FileStore | undefined>(undefined) - if (!storeRef.current) - storeRef.current = createFileStore(value, onChange) + if (!storeRef.current) storeRef.current = createFileStore(value, onChange) - return ( - <FileContext.Provider value={storeRef.current}> - {children} - </FileContext.Provider> - ) + return <FileContext.Provider value={storeRef.current}>{children}</FileContext.Provider> } diff --git a/web/app/components/base/file-uploader/utils.ts b/web/app/components/base/file-uploader/utils.ts index 4c00c0aac7bcec..0147f06ee47b2b 100644 --- a/web/app/components/base/file-uploader/utils.ts +++ b/web/app/components/base/file-uploader/utils.ts @@ -15,14 +15,17 @@ import { FileAppearanceTypeEnum } from './types' * @param t - Translation function * @returns Localized error message */ -export const getFileUploadErrorMessage = (error: any, defaultMessage: string, t: TFunction): string => { +export const getFileUploadErrorMessage = ( + error: any, + defaultMessage: string, + t: TFunction, +): string => { const errorCode = error?.response?.code - if (errorCode === 'forbidden') - return error?.response?.message + if (errorCode === 'forbidden') return error?.response?.message if (errorCode === 'file_extension_blocked') - return t($ => $['fileUploader.fileExtensionBlocked'], { ns: 'common' }) + return t(($) => $['fileUploader.fileExtensionBlocked'], { ns: 'common' }) return defaultMessage } @@ -45,26 +48,29 @@ type FileUploadParams = { onErrorCallback: (error?: any) => void } type FileUpload = (v: FileUploadParams, isPublic?: boolean, url?: string) => void -export const fileUpload: FileUpload = ({ - file, - onProgressCallback, - onSuccessCallback, - onErrorCallback, -}, isPublic, url) => { +export const fileUpload: FileUpload = ( + { file, onProgressCallback, onSuccessCallback, onErrorCallback }, + isPublic, + url, +) => { const formData = new FormData() formData.append('file', file) const onProgress = (e: ProgressEvent) => { if (e.lengthComputable) { - const percent = Math.floor(e.loaded / e.total * 100) + const percent = Math.floor((e.loaded / e.total) * 100) onProgressCallback(percent) } } - upload({ - xhr: new XMLHttpRequest(), - data: formData, - onprogress: onProgress, - }, isPublic, url) + upload( + { + xhr: new XMLHttpRequest(), + data: formData, + onprogress: onProgress, + }, + isPublic, + url, + ) .then((res) => { onSuccessCallback(res as FileUploadResponse) }) @@ -73,9 +79,7 @@ export const fileUpload: FileUpload = ({ }) } -const additionalExtensionMap = new Map<string, string[]>([ - ['text/x-markdown', ['md']], -]) +const additionalExtensionMap = new Map<string, string[]>([['text/x-markdown', ['md']]]) export const getFileExtension = (fileName: string, fileMimetype: string, isRemote?: boolean) => { let extension = '' @@ -83,10 +87,7 @@ export const getFileExtension = (fileName: string, fileMimetype: string, isRemot if (fileMimetype) { const extensionsFromMimeType = mime.getAllExtensions(fileMimetype) || new Set<string>() const additionalExtensions = additionalExtensionMap.get(fileMimetype) || [] - extensions = new Set<string>([ - ...extensionsFromMimeType, - ...additionalExtensions, - ]) + extensions = new Set<string>([...extensionsFromMimeType, ...additionalExtensions]) } let extensionInFileName = '' @@ -96,22 +97,19 @@ export const getFileExtension = (fileName: string, fileMimetype: string, isRemot if (fileNamePairLength > 1) { extensionInFileName = fileNamePair[fileNamePairLength - 1]!.toLowerCase() - if (extensions.has(extensionInFileName)) - extension = extensionInFileName + if (extensions.has(extensionInFileName)) extension = extensionInFileName } } if (!extension) { if (extensions.size > 0) { const firstExtension = extensions.values().next().value extension = firstExtension ? firstExtension.toLowerCase() : '' - } - else { + } else { extension = extensionInFileName } } - if (isRemote) - extension = '' + if (isRemote) extension = '' return extension } @@ -119,62 +117,52 @@ export const getFileExtension = (fileName: string, fileMimetype: string, isRemot export const getFileAppearanceType = (fileName: string, fileMimetype: string) => { const extension = getFileExtension(fileName, fileMimetype) - if (extension === 'gif') - return FileAppearanceTypeEnum.gif + if (extension === 'gif') return FileAppearanceTypeEnum.gif - if (FILE_EXTS.image!.includes(extension.toUpperCase())) - return FileAppearanceTypeEnum.image + if (FILE_EXTS.image!.includes(extension.toUpperCase())) return FileAppearanceTypeEnum.image - if (FILE_EXTS.video!.includes(extension.toUpperCase())) - return FileAppearanceTypeEnum.video + if (FILE_EXTS.video!.includes(extension.toUpperCase())) return FileAppearanceTypeEnum.video - if (FILE_EXTS.audio!.includes(extension.toUpperCase())) - return FileAppearanceTypeEnum.audio + if (FILE_EXTS.audio!.includes(extension.toUpperCase())) return FileAppearanceTypeEnum.audio - if (extension === 'html') - return FileAppearanceTypeEnum.code + if (extension === 'html') return FileAppearanceTypeEnum.code - if (extension === 'pdf') - return FileAppearanceTypeEnum.pdf + if (extension === 'pdf') return FileAppearanceTypeEnum.pdf if (extension === 'md' || extension === 'markdown' || extension === 'mdx') return FileAppearanceTypeEnum.markdown - if (extension === 'xlsx' || extension === 'xls') - return FileAppearanceTypeEnum.excel + if (extension === 'xlsx' || extension === 'xls') return FileAppearanceTypeEnum.excel - if (extension === 'docx' || extension === 'doc') - return FileAppearanceTypeEnum.word + if (extension === 'docx' || extension === 'doc') return FileAppearanceTypeEnum.word - if (extension === 'pptx' || extension === 'ppt') - return FileAppearanceTypeEnum.ppt + if (extension === 'pptx' || extension === 'ppt') return FileAppearanceTypeEnum.ppt - if (FILE_EXTS.document!.includes(extension.toUpperCase())) - return FileAppearanceTypeEnum.document + if (FILE_EXTS.document!.includes(extension.toUpperCase())) return FileAppearanceTypeEnum.document return FileAppearanceTypeEnum.custom } export const getSupportFileType = (fileName: string, fileMimetype: string, isCustom?: boolean) => { - if (isCustom) - return SupportUploadFileTypes.custom + if (isCustom) return SupportUploadFileTypes.custom const extension = getFileExtension(fileName, fileMimetype) for (const key in FILE_EXTS) { - if ((FILE_EXTS[key])!.includes(extension.toUpperCase())) - return key + if (FILE_EXTS[key]!.includes(extension.toUpperCase())) return key } return '' } export const getProcessedFiles = (files: FileEntity[]) => { - return files.filter(file => file.progress !== -1).map(fileItem => ({ - type: fileItem.supportFileType, - transfer_method: fileItem.transferMethod, - url: fileItem.url || '', - upload_file_id: fileItem.uploadedId || '', - })) + return files + .filter((file) => file.progress !== -1) + .map((fileItem) => ({ + type: fileItem.supportFileType, + transfer_method: fileItem.transferMethod, + url: fileItem.url || '', + upload_file_id: fileItem.uploadedId || '', + })) } export const getProcessedFilesFromResponse = (files: FileResponse[]) => { @@ -185,10 +173,12 @@ export const getProcessedFilesFromResponse = (files: FileResponse[]) => { const detectedTypeFromFileName = getSupportFileType(fileItem.filename, '') const detectedTypeFromMime = getSupportFileType('', fileItem.mime_type) - if (detectedTypeFromFileName - && detectedTypeFromMime - && detectedTypeFromFileName === detectedTypeFromMime - && detectedTypeFromFileName !== fileItem.type) { + if ( + detectedTypeFromFileName && + detectedTypeFromMime && + detectedTypeFromFileName === detectedTypeFromMime && + detectedTypeFromFileName !== fileItem.type + ) { supportFileType = detectedTypeFromFileName } } @@ -206,40 +196,56 @@ export const getProcessedFilesFromResponse = (files: FileResponse[]) => { } }) } -export const getSupportFileExtensionList = (allowFileTypes: string[], allowFileExtensions: string[]) => { +export const getSupportFileExtensionList = ( + allowFileTypes: string[], + allowFileExtensions: string[], +) => { if (allowFileTypes.includes(SupportUploadFileTypes.custom)) - return allowFileExtensions.map(item => item.slice(1).toUpperCase()) + return allowFileExtensions.map((item) => item.slice(1).toUpperCase()) - return allowFileTypes.map(type => FILE_EXTS[type]).flat() + return allowFileTypes.map((type) => FILE_EXTS[type]).flat() } -export const isAllowedFileExtension = (fileName: string, fileMimetype: string, allowFileTypes: string[], allowFileExtensions: string[]) => { - return getSupportFileExtensionList(allowFileTypes, allowFileExtensions).includes(getFileExtension(fileName, fileMimetype).toUpperCase()) +export const isAllowedFileExtension = ( + fileName: string, + fileMimetype: string, + allowFileTypes: string[], + allowFileExtensions: string[], +) => { + return getSupportFileExtensionList(allowFileTypes, allowFileExtensions).includes( + getFileExtension(fileName, fileMimetype).toUpperCase(), + ) } export const getFilesInLogs = (rawData: any) => { - const result = Object.keys(rawData || {}).map((key) => { - if (typeof rawData[key] === 'object' && rawData[key]?.dify_model_identity === '__dify__file__') { - return { - varName: key, - list: getProcessedFilesFromResponse([rawData[key]]), + const result = Object.keys(rawData || {}) + .map((key) => { + if ( + typeof rawData[key] === 'object' && + rawData[key]?.dify_model_identity === '__dify__file__' + ) { + return { + varName: key, + list: getProcessedFilesFromResponse([rawData[key]]), + } } - } - if (Array.isArray(rawData[key]) && rawData[key].some(item => item?.dify_model_identity === '__dify__file__')) { - return { - varName: key, - list: getProcessedFilesFromResponse(rawData[key]), + if ( + Array.isArray(rawData[key]) && + rawData[key].some((item) => item?.dify_model_identity === '__dify__file__') + ) { + return { + varName: key, + list: getProcessedFilesFromResponse(rawData[key]), + } } - } - return undefined - }).filter(Boolean) + return undefined + }) + .filter(Boolean) return result } export const fileIsUploaded = (file: FileEntity) => { - if (file.uploadedId) - return true + if (file.uploadedId) return true - if (file.transferMethod === TransferMethod.remote_url && file.progress === 100) - return true + if (file.transferMethod === TransferMethod.remote_url && file.progress === 100) return true } diff --git a/web/app/components/base/file-uploader/video-preview.tsx b/web/app/components/base/file-uploader/video-preview.tsx index 65b0959bd71efa..31696d085cec55 100644 --- a/web/app/components/base/file-uploader/video-preview.tsx +++ b/web/app/components/base/file-uploader/video-preview.tsx @@ -7,19 +7,14 @@ type VideoPreviewProps = { title: string onCancel: () => void } -const VideoPreview: FC<VideoPreviewProps> = ({ - url, - title, - onCancel, -}) => { +const VideoPreview: FC<VideoPreviewProps> = ({ url, title, onCancel }) => { const { t } = useTranslation() return ( <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} disablePointerDismissal > @@ -27,22 +22,14 @@ const VideoPreview: FC<VideoPreviewProps> = ({ className="inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 p-8! shadow-none!" backdropClassName="bg-transparent!" > - <div - aria-label={title} - tabIndex={-1} - onClick={e => e.stopPropagation()} - > + <div aria-label={title} tabIndex={-1} onClick={(e) => e.stopPropagation()}> <video controls title={title} autoPlay={false} preload="metadata"> - <source - type="video/mp4" - src={url} - className="max-h-full max-w-full" - /> + <source type="video/mp4" src={url} className="max-h-full max-w-full" /> </video> </div> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-white/8 p-0 backdrop-blur-[2px]" onClick={onCancel} > diff --git a/web/app/components/base/filter-empty-state/index.tsx b/web/app/components/base/filter-empty-state/index.tsx index 00f892d73d5a3c..1881981406b237 100644 --- a/web/app/components/base/filter-empty-state/index.tsx +++ b/web/app/components/base/filter-empty-state/index.tsx @@ -8,12 +8,14 @@ type FilterEmptyStateProps = { const CARD_COUNT = 16 -const FilterEmptyState = ({ - title, - className, -}: FilterEmptyStateProps) => { +const FilterEmptyState = ({ title, className }: FilterEmptyStateProps) => { return ( - <div className={cn('pointer-events-none absolute inset-0 z-20 grid grid-cols-4 grid-rows-4 gap-3 px-8 pt-2', className)}> + <div + className={cn( + 'pointer-events-none absolute inset-0 z-20 grid grid-cols-4 grid-rows-4 gap-3 px-8 pt-2', + className, + )} + > {Array.from({ length: CARD_COUNT }).map((_, index) => ( <div key={index} className="rounded-xl bg-background-default-lighter opacity-75" /> ))} @@ -25,9 +27,7 @@ const FilterEmptyState = ({ <span aria-hidden className="i-ri-robot-2-line size-6 text-text-tertiary" /> </div> </div> - <p className="system-sm-regular whitespace-nowrap text-text-tertiary"> - {title} - </p> + <p className="system-sm-regular whitespace-nowrap text-text-tertiary">{title}</p> </div> </div> </div> diff --git a/web/app/components/base/float-right-container/__tests__/index.spec.tsx b/web/app/components/base/float-right-container/__tests__/index.spec.tsx index 6f84e5afd85bcd..ea249a9e13e82c 100644 --- a/web/app/components/base/float-right-container/__tests__/index.spec.tsx +++ b/web/app/components/base/float-right-container/__tests__/index.spec.tsx @@ -11,12 +11,7 @@ describe('FloatRightContainer', () => { describe('Rendering', () => { it('should render content in drawer when isMobile is true and isOpen is true', async () => { render( - <FloatRightContainer - isMobile={true} - isOpen={true} - onClose={vi.fn()} - title="Mobile panel" - > + <FloatRightContainer isMobile={true} isOpen={true} onClose={vi.fn()} title="Mobile panel"> <div>Mobile content</div> </FloatRightContainer>, ) @@ -28,11 +23,7 @@ describe('FloatRightContainer', () => { it('should not render content when isMobile is true and isOpen is false', () => { render( - <FloatRightContainer - isMobile={true} - isOpen={false} - onClose={vi.fn()} - > + <FloatRightContainer isMobile={true} isOpen={false} onClose={vi.fn()}> <div>Closed mobile content</div> </FloatRightContainer>, ) @@ -60,11 +51,7 @@ describe('FloatRightContainer', () => { it('should render nothing when isMobile is false and isOpen is false', () => { const { container } = render( - <FloatRightContainer - isMobile={false} - isOpen={false} - onClose={vi.fn()} - > + <FloatRightContainer isMobile={false} isOpen={false} onClose={vi.fn()}> <div>Hidden desktop content</div> </FloatRightContainer>, ) @@ -79,12 +66,7 @@ describe('FloatRightContainer', () => { it('should call onClose when close icon is clicked in mobile drawer mode', async () => { const onClose = vi.fn() render( - <FloatRightContainer - isMobile={true} - isOpen={true} - onClose={onClose} - showClose={true} - > + <FloatRightContainer isMobile={true} isOpen={true} onClose={onClose} showClose={true}> <div>Closable mobile content</div> </FloatRightContainer>, ) diff --git a/web/app/components/base/float-right-container/index.stories.tsx b/web/app/components/base/float-right-container/index.stories.tsx index ec26bb7be0fe6f..2d539cce4eab27 100644 --- a/web/app/components/base/float-right-container/index.stories.tsx +++ b/web/app/components/base/float-right-container/index.stories.tsx @@ -10,7 +10,8 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Wrapper that renders content in a drawer on mobile and inline on desktop. Useful for responsive settings panels.', + component: + 'Wrapper that renders content in a drawer on mobile and inline on desktop. Useful for responsive settings panels.', }, }, }, @@ -38,7 +39,7 @@ const ContainerDemo = () => { <input type="checkbox" checked={isMobile} - onChange={e => setIsMobile(e.target.checked)} + onChange={(e) => setIsMobile(e.target.checked)} /> Simulate mobile </label> @@ -54,7 +55,8 @@ const ContainerDemo = () => { <div className="rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary"> <p className="mb-2 text-sm text-text-primary">Panel Content</p> <p> - On desktop, this block renders inline when `isOpen` is true. On mobile it appears inside the drawer wrapper. + On desktop, this block renders inline when `isOpen` is true. On mobile it appears inside + the drawer wrapper. </p> </div> </FloatRightContainer> diff --git a/web/app/components/base/float-right-container/index.tsx b/web/app/components/base/float-right-container/index.tsx index d5215184d5265b..7c9e0ce7ea0568 100644 --- a/web/app/components/base/float-right-container/index.tsx +++ b/web/app/components/base/float-right-container/index.tsx @@ -43,14 +43,18 @@ const FloatRightContainer = ({ modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DrawerPortal> <DrawerBackdrop className={cn(!mask && 'bg-transparent')} /> <DrawerViewport> - <DrawerPopup className={cn('data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-sm', panelClassName)}> + <DrawerPopup + className={cn( + 'data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-sm', + panelClassName, + )} + > <DrawerContent className="flex min-h-0 flex-1 flex-col"> {(title || showClose) && ( <div className="mb-4 flex shrink-0 items-center justify-between"> @@ -61,7 +65,7 @@ const FloatRightContainer = ({ )} {showClose && ( <DrawerCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-6 rounded-md" /> )} @@ -74,9 +78,7 @@ const FloatRightContainer = ({ </DrawerPortal> </Drawer> )} - {(!isMobile && isOpen) && ( - <>{children}</> - )} + {!isMobile && isOpen && <>{children}</>} </> ) } diff --git a/web/app/components/base/form/__tests__/index.spec.tsx b/web/app/components/base/form/__tests__/index.spec.tsx index 683c43a8dbf1a5..11283fdb97950b 100644 --- a/web/app/components/base/form/__tests__/index.spec.tsx +++ b/web/app/components/base/form/__tests__/index.spec.tsx @@ -9,10 +9,7 @@ const FormHarness = ({ onSubmit }: { onSubmit: (value: Record<string, unknown>) return ( <form> - <form.AppField - name="title" - children={field => <field.TextField label="Title" />} - /> + <form.AppField name="title" children={(field) => <field.TextField label="Title" />} /> <form.AppForm> <button type="button" onClick={() => form.handleSubmit()}> Submit @@ -26,10 +23,7 @@ const InlinePreview = withForm({ defaultValues: { title: '' }, render: ({ form }) => { return ( - <form.AppField - name="title" - children={field => <field.TextField label="Preview Title" />} - /> + <form.AppField name="title" children={(field) => <field.TextField label="Preview Title" />} /> ) }, }) diff --git a/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx b/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx index 7978f6981d9030..698595f9aac6c2 100644 --- a/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx +++ b/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx @@ -9,7 +9,8 @@ import BaseField from '../base-field' const mockDynamicOptions = vi.fn() vi.mock('@/hooks/use-i18n', () => ({ - useRenderI18nObject: () => (content: Record<string, string>) => content.en_US ?? Object.values(content)[0] ?? '', + useRenderI18nObject: () => (content: Record<string, string>) => + content.en_US ?? Object.values(content)[0] ?? '', })) vi.mock('@/service/use-triggers', () => ({ @@ -36,13 +37,13 @@ const renderBaseField = ({ const TestComponent = () => { const form = useForm({ defaultValues: defaultValues ?? { [formSchema.name]: '' }, - onSubmit: async () => { }, + onSubmit: async () => {}, }) return ( <> <form.Field name={formSchema.name}> - {field => ( + {(field) => ( <BaseField field={field as unknown as AnyFieldApi} formSchema={formSchema} @@ -52,8 +53,8 @@ const renderBaseField = ({ )} </form.Field> {showCurrentValue && ( - <form.Subscribe selector={state => state.values[formSchema.name]}> - {value => <div data-testid="field-value">{String(value)}</div>} + <form.Subscribe selector={(state) => state.values[formSchema.name]}> + {(value) => <div data-testid="field-value">{String(value)}</div>} </form.Subscribe> )} </> @@ -134,7 +135,9 @@ describe('BaseField', () => { }) expect(screen.getByRole('combobox', { name: 'Mode' })).not.toHaveTextContent('beta') - expect(screen.getByRole('combobox', { name: 'Mode' })).toHaveTextContent('common.placeholder.input') + expect(screen.getByRole('combobox', { name: 'Mode' })).toHaveTextContent( + 'common.placeholder.input', + ) }) it('should render dynamic select loading state', () => { @@ -217,7 +220,10 @@ describe('BaseField', () => { }) expect(screen.getByText('Read the description')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'Open help docs' })).toHaveAttribute('href', 'https://example.com/help') + expect(screen.getByRole('link', { name: 'Open help docs' })).toHaveAttribute( + 'href', + 'https://example.com/help', + ) }) it('should render secret input with password type', () => { @@ -266,9 +272,7 @@ describe('BaseField', () => { const user = userEvent.setup() mockDynamicOptions.mockReturnValue({ data: { - options: [ - { label: { en_US: 'Option A', zh_Hans: '选项A' }, value: 'a' }, - ], + options: [{ label: { en_US: 'Option A', zh_Hans: '选项A' }, value: 'a' }], }, isLoading: false, error: null, @@ -314,7 +318,9 @@ describe('BaseField', () => { showCurrentValue: true, }) - expect(screen.getByRole('combobox', { name: 'Plugin options' })).toHaveTextContent('common.dynamicSelect.selected') + expect(screen.getByRole('combobox', { name: 'Plugin options' })).toHaveTextContent( + 'common.dynamicSelect.selected', + ) await user.click(screen.getByRole('combobox', { name: 'Plugin options' })) await user.click(screen.getByRole('option', { name: 'Option B' })) diff --git a/web/app/components/base/form/components/base/__tests__/base-form.spec.tsx b/web/app/components/base/form/components/base/__tests__/base-form.spec.tsx index e89b0e6271f663..c2cc1d18eb8bf9 100644 --- a/web/app/components/base/form/components/base/__tests__/base-form.spec.tsx +++ b/web/app/components/base/form/components/base/__tests__/base-form.spec.tsx @@ -15,8 +15,7 @@ vi.mock('@tanstack/react-form', async (importOriginal) => { // If the store is a mock with state, use it; otherwise provide a default try { return selector(store?.state || { values: {} }) - } - catch { + } catch { return {} } } @@ -81,11 +80,7 @@ describe('BaseForm', () => { expect(event.defaultPrevented).toBe(true) }) const { container } = render( - <BaseForm - formSchemas={baseSchemas} - onSubmit={onSubmit} - preventDefaultSubmit - />, + <BaseForm formSchemas={baseSchemas} onSubmit={onSubmit} preventDefaultSubmit />, ) await act(async () => { @@ -98,12 +93,7 @@ describe('BaseForm', () => { it('should expose ref API for updating values and field states', async () => { const formRef = { current: null } as { current: FormRefObject | null } - render( - <BaseForm - formSchemas={baseSchemas} - ref={formRef} - />, - ) + render(<BaseForm formSchemas={baseSchemas} ref={formRef} />) expect(formRef.current).not.toBeNull() @@ -125,12 +115,7 @@ describe('BaseForm', () => { it('should derive warning status when setFields receives warnings only', async () => { const formRef = { current: null } as { current: FormRefObject | null } - render( - <BaseForm - formSchemas={baseSchemas} - ref={formRef} - />, - ) + render(<BaseForm formSchemas={baseSchemas} ref={formRef} />) await act(async () => { formRef.current?.setFields([ @@ -152,11 +137,21 @@ describe('BaseForm', () => { vi.mocked(useStore).mockReturnValueOnce(mockState.values) const mockForm = { store: mockStore, - Field: ({ children, name }: { children: (field: AnyFieldApi) => React.ReactNode, name: string }) => children({ + Field: ({ + children, name, - state: { value: mockState.values[name as keyof typeof mockState.values], meta: { isTouched: false, errorMap: {} } }, - form: { store: mockStore }, - } as unknown as AnyFieldApi), + }: { + children: (field: AnyFieldApi) => React.ReactNode + name: string + }) => + children({ + name, + state: { + value: mockState.values[name as keyof typeof mockState.values], + meta: { isTouched: false, errorMap: {} }, + }, + form: { store: mockStore }, + } as unknown as AnyFieldApi), setFieldValue: vi.fn(), } render(<BaseForm formSchemas={baseSchemas} formFromProps={mockForm as unknown as AnyFormApi} />) @@ -168,11 +163,13 @@ describe('BaseForm', () => { render(<BaseForm formSchemas={baseSchemas} ref={formRef} />) await act(async () => { - formRef.current?.setFields([{ - name: 'kind', - validateStatus: FormItemValidateStatusEnum.Error, - errors: ['Explicit error'], - }]) + formRef.current?.setFields([ + { + name: 'kind', + validateStatus: FormItemValidateStatusEnum.Error, + errors: ['Explicit error'], + }, + ]) }) expect(screen.getByText('Explicit error'))!.toBeInTheDocument() }) @@ -182,10 +179,12 @@ describe('BaseForm', () => { render(<BaseForm formSchemas={baseSchemas} ref={formRef} />) await act(async () => { - formRef.current?.setFields([{ - name: 'kind', - errors: ['Error only'], - }]) + formRef.current?.setFields([ + { + name: 'kind', + errors: ['Error only'], + }, + ]) }) expect(screen.getByText('Error only'))!.toBeInTheDocument() }) @@ -212,11 +211,12 @@ describe('BaseForm', () => { vi.mocked(useStore).mockReturnValueOnce(mockState.values) const mockForm = { store: mockStore, - Field: ({ children }: { children: (field: AnyFieldApi) => React.ReactNode }) => children({ - name: 'unknown', // field name not in baseSchemas - state: { value: 'value', meta: { isTouched: false, errorMap: {} } }, - form: { store: mockStore }, - } as unknown as AnyFieldApi), + Field: ({ children }: { children: (field: AnyFieldApi) => React.ReactNode }) => + children({ + name: 'unknown', // field name not in baseSchemas + state: { value: 'value', meta: { isTouched: false, errorMap: {} } }, + form: { store: mockStore }, + } as unknown as AnyFieldApi), setFieldValue: vi.fn(), } render(<BaseForm formSchemas={baseSchemas} formFromProps={mockForm as unknown as AnyFormApi} />) @@ -234,11 +234,13 @@ describe('BaseForm', () => { }) it('should fallback to schema class names if props are missing', () => { - const schemaWithClasses: FormSchema[] = [{ - ...baseSchemas[0]!, - fieldClassName: 'schema-field', - labelClassName: 'schema-label', - }] + const schemaWithClasses: FormSchema[] = [ + { + ...baseSchemas[0]!, + fieldClassName: 'schema-field', + labelClassName: 'schema-label', + }, + ] render(<BaseForm formSchemas={schemaWithClasses} />) expect(screen.getByText('Kind'))!.toHaveClass('schema-label') expect(screen.getByText('Kind').parentElement)!.toHaveClass('schema-field') @@ -247,11 +249,7 @@ describe('BaseForm', () => { it('should handle preventDefaultSubmit', async () => { const onSubmit = vi.fn() const { container } = render( - <BaseForm - formSchemas={baseSchemas} - onSubmit={onSubmit} - preventDefaultSubmit={true} - />, + <BaseForm formSchemas={baseSchemas} onSubmit={onSubmit} preventDefaultSubmit={true} />, ) const event = new Event('submit', { cancelable: true, bubbles: true }) const spy = vi.spyOn(event, 'preventDefault') @@ -287,21 +285,25 @@ describe('BaseForm', () => { render(<BaseForm formSchemas={baseSchemas} ref={formRef} />) await act(async () => { - formRef.current?.setFields([{ - name: 'kind', - value: 'new-show', - }]) + formRef.current?.setFields([ + { + name: 'kind', + value: 'new-show', + }, + ]) }) expect(screen.getByDisplayValue('new-show'))!.toBeInTheDocument() }) it('should handle schema without show_on in showOnValues', () => { - const schemaNoShowOn: FormSchema[] = [{ - type: FormTypeEnum.textInput, - name: 'test', - label: 'Test', - required: false, - }] + const schemaNoShowOn: FormSchema[] = [ + { + type: FormTypeEnum.textInput, + name: 'test', + label: 'Test', + required: false, + }, + ] // Simply rendering should trigger showOnValues selector render(<BaseForm formSchemas={schemaNoShowOn} />) expect(screen.getByText('Test'))!.toBeInTheDocument() diff --git a/web/app/components/base/form/components/base/base-field.tsx b/web/app/components/base/form/components/base/base-field.tsx index c8bf0d954ff8c6..1dd6895a289935 100644 --- a/web/app/components/base/form/components/base/base-field.tsx +++ b/web/app/components/base/form/components/base/base-field.tsx @@ -14,12 +14,7 @@ import { SelectValue, } from '@langgenius/dify-ui/select' import { useStore } from '@tanstack/react-form' -import { - isValidElement, - memo, - useCallback, - useMemo, -} from 'react' +import { isValidElement, memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { CheckboxList } from '@/app/components/base/checkbox-list' import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types' @@ -39,12 +34,20 @@ const getExtraProps = (type: FormTypeEnum) => { } } -const getTranslatedContent = ({ content, render }: { - content: React.ReactNode | string | null | undefined | TypeWithI18N<string> | Record<string, string> +const getTranslatedContent = ({ + content, + render, +}: { + content: + | React.ReactNode + | string + | null + | undefined + | TypeWithI18N<string> + | Record<string, string> render: (content: TypeWithI18N<string> | Record<string, string>) => string }): string => { - if (isValidElement(content) || typeof content === 'string') - return content as string + if (isValidElement(content) || typeof content === 'string') return content as string if (typeof content === 'object' && content !== null) return render(content as TypeWithI18N<string>) @@ -58,21 +61,30 @@ type SelectOption = { } const getSingleSelectValue = (value: unknown, options: SelectOption[]) => { - return options.find(option => option.value === value)?.value ?? null + return options.find((option) => option.value === value)?.value ?? null } -const getSingleSelectLabel = (value: unknown, options: SelectOption[], placeholder: string | undefined) => { - return options.find(option => option.value === value)?.label ?? placeholder +const getSingleSelectLabel = ( + value: unknown, + options: SelectOption[], + placeholder: string | undefined, +) => { + return options.find((option) => option.value === value)?.label ?? placeholder } -const VALIDATE_STATUS_STYLE_MAP: Record<FormItemValidateStatusEnum, { componentClassName: string, textClassName: string, infoFieldName: string }> = { +const VALIDATE_STATUS_STYLE_MAP: Record< + FormItemValidateStatusEnum, + { componentClassName: string; textClassName: string; infoFieldName: string } +> = { [FormItemValidateStatusEnum.Error]: { - componentClassName: 'border-components-input-border-destructive focus:border-components-input-border-destructive', + componentClassName: + 'border-components-input-border-destructive focus:border-components-input-border-destructive', textClassName: 'text-text-destructive', infoFieldName: 'errors', }, [FormItemValidateStatusEnum.Warning]: { - componentClassName: 'border-components-input-border-warning focus:border-components-input-border-warning', + componentClassName: + 'border-components-input-border-warning focus:border-components-input-border-warning', textClassName: 'text-text-warning', infoFieldName: 'warnings', }, @@ -132,16 +144,17 @@ const BaseField = ({ } = formSchema const disabled = propsDisabled || formSchemaDisabled - const [translatedLabel, translatedPlaceholder, translatedTooltip, translatedDescription, translatedHelp] = useMemo(() => { - const results = [ - label, - placeholder, - tooltip, - description, - help, - ].map(v => getTranslatedContent({ content: v, render: renderI18nObject })) - if (!results[1]) - results[1] = t($ => $['placeholder.input'], { ns: 'common' }) + const [ + translatedLabel, + translatedPlaceholder, + translatedTooltip, + translatedDescription, + translatedHelp, + ] = useMemo(() => { + const results = [label, placeholder, tooltip, description, help].map((v) => + getTranslatedContent({ content: v, render: renderI18nObject }), + ) + if (!results[1]) results[1] = t(($) => $['placeholder.input'], { ns: 'common' }) return results }, [label, placeholder, tooltip, description, help, renderI18nObject, t]) @@ -149,8 +162,7 @@ const BaseField = ({ const variables = new Set<string>() for (const option of options || []) { - for (const condition of option.show_on || []) - variables.add(condition.variable) + for (const condition of option.show_on || []) variables.add(condition.variable) } return Array.from(variables) @@ -158,33 +170,39 @@ const BaseField = ({ const watchedValues = useStore(field.form.store, (s) => { const result: Record<string, unknown> = {} - for (const variable of watchedVariables) - result[variable] = s.values[variable] + for (const variable of watchedVariables) result[variable] = s.values[variable] return result }) const memorizedOptions = useMemo(() => { - return options?.filter((option) => { - if (!option.show_on?.length) - return true + return ( + options + ?.filter((option) => { + if (!option.show_on?.length) return true - return option.show_on.every((condition) => { - return watchedValues[condition.variable] === condition.value - }) - }).map((option) => { - return { - label: getTranslatedContent({ content: option.label, render: renderI18nObject }), - value: option.value, - } - }) || [] + return option.show_on.every((condition) => { + return watchedValues[condition.variable] === condition.value + }) + }) + .map((option) => { + return { + label: getTranslatedContent({ content: option.label, render: renderI18nObject }), + value: option.value, + } + }) || [] + ) }, [options, renderI18nObject, watchedValues]) - const value = useStore(field.form.store, s => s.values[field.name]) + const value = useStore(field.form.store, (s) => s.values[field.name]) const stringValue = typeof value === 'string' ? value : undefined const booleanValue = typeof value === 'boolean' ? value : undefined - const { data: dynamicOptionsData, isLoading: isDynamicOptionsLoading, error: dynamicOptionsError } = useTriggerPluginDynamicOptions( + const { + data: dynamicOptionsData, + isLoading: isDynamicOptionsLoading, + error: dynamicOptionsError, + } = useTriggerPluginDynamicOptions( dynamicSelectParams || { plugin_id: '', provider: '', @@ -196,24 +214,28 @@ const BaseField = ({ ) const dynamicOptions = useMemo(() => { - if (!dynamicOptionsData?.options) - return [] - return dynamicOptionsData.options.map(option => ({ + if (!dynamicOptionsData?.options) return [] + return dynamicOptionsData.options.map((option) => ({ label: getTranslatedContent({ content: option.label, render: renderI18nObject }), value: option.value, })) }, [dynamicOptionsData, renderI18nObject]) - const handleChange = useCallback((value: unknown) => { - field.handleChange(value) - onChange?.(field.name, value) - }, [field, onChange]) + const handleChange = useCallback( + (value: unknown) => { + field.handleChange(value) + onChange?.(field.name, value) + }, + [field, onChange], + ) const dynamicPlaceholder = isDynamicOptionsLoading - ? t($ => $['dynamicSelect.loading'], { ns: 'common' }) + ? t(($) => $['dynamicSelect.loading'], { ns: 'common' }) : translatedPlaceholder const dynamicNoticeTitle = dynamicOptionsError - ? t($ => $['dynamicSelect.error'], { ns: 'common' }) - : (!dynamicOptions.length ? t($ => $['dynamicSelect.noData'], { ns: 'common' }) : null) + ? t(($) => $['dynamicSelect.error'], { ns: 'common' }) + : !dynamicOptions.length + ? t(($) => $['dynamicSelect.noData'], { ns: 'common' }) + : null const dynamicNoticeClassName = dynamicOptionsError ? 'text-text-destructive-secondary' : undefined return ( @@ -221,11 +243,9 @@ const BaseField = ({ <div className={cn(fieldClassName)}> <div className={cn(labelClassName, formLabelClassName)}> {translatedLabel} - { - required && !isValidElement(label) && ( - <span className="ml-1 text-text-destructive-secondary">*</span> - ) - } + {required && !isValidElement(label) && ( + <span className="ml-1 text-text-destructive-secondary">*</span> + )} {translatedTooltip && ( <Infotip aria-label={translatedTooltip} className="ml-0.5" popupClassName="w-[200px]"> {translatedTooltip} @@ -233,274 +253,288 @@ const BaseField = ({ )} </div> <div className={cn(inputContainerClassName)}> - { - [FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes(formItemType) && ( - <Input - id={field.name} - name={field.name} - className={cn(inputClassName, VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus as FormItemValidateStatusEnum]?.componentClassName)} - value={value || ''} - onChange={(e) => { - handleChange(e.target.value) - }} - onBlur={field.handleBlur} + {[FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes( + formItemType, + ) && ( + <Input + id={field.name} + name={field.name} + className={cn( + inputClassName, + VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus as FormItemValidateStatusEnum] + ?.componentClassName, + )} + value={value || ''} + onChange={(e) => { + handleChange(e.target.value) + }} + onBlur={field.handleBlur} + disabled={disabled} + placeholder={translatedPlaceholder} + {...getExtraProps(formItemType)} + showCopyIcon={showCopy} + /> + )} + {formItemType === FormTypeEnum.select && + (multiple ? ( + <Select + multiple + items={memorizedOptions} + value={Array.isArray(value) ? value : []} disabled={disabled} - placeholder={translatedPlaceholder} - {...getExtraProps(formItemType)} - showCopyIcon={showCopy} - /> - ) - } - { - formItemType === FormTypeEnum.select && (multiple - ? ( - <Select - multiple - items={memorizedOptions} - value={Array.isArray(value) ? value : []} - disabled={disabled} - onValueChange={handleChange} - > - <SelectTrigger id={field.name} aria-label={translatedLabel || field.name} className="px-2"> - <SelectValue placeholder={translatedPlaceholder}> - {(selectedValue: string[]) => selectedValue.length - ? t($ => $['dynamicSelect.selected'], { ns: 'common', count: selectedValue.length }) - : translatedPlaceholder} - </SelectValue> - </SelectTrigger> - <SelectContent popupClassName="max-h-[320px] bg-components-panel-bg-blur"> - {memorizedOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - <SelectItemText>{option.label}</SelectItemText> - <SelectItemIndicator /> - </SelectItem> - ))} - </SelectContent> - </Select> - ) - : ( - <Select - items={memorizedOptions} - value={getSingleSelectValue(value, memorizedOptions)} - disabled={disabled} - onValueChange={(next) => { - if (next == null) - return - handleChange(next) - }} - > - <SelectTrigger id={field.name} aria-label={translatedLabel || field.name} className="px-2"> - <SelectValue placeholder={translatedPlaceholder}> - {nextValue => getSingleSelectLabel(nextValue, memorizedOptions, translatedPlaceholder)} - </SelectValue> - </SelectTrigger> - <SelectContent popupClassName="max-h-[320px] bg-components-panel-bg-blur"> - {memorizedOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - <SelectItemText>{option.label}</SelectItemText> - <SelectItemIndicator /> - </SelectItem> - ))} - </SelectContent> - </Select> - )) - } - { - formItemType === FormTypeEnum.checkbox /* && multiple */ && ( - <CheckboxList - name={field.name} - title={name} - value={value} - onChange={v => field.handleChange(v)} - options={memorizedOptions} - maxHeight="200px" - /> - ) - } - { - formItemType === FormTypeEnum.dynamicSelect && (multiple - ? ( - <Select - multiple - items={dynamicOptions} - value={Array.isArray(value) ? value : []} - disabled={disabled || isDynamicOptionsLoading} - onValueChange={field.handleChange} - > - <SelectTrigger id={field.name} aria-label={translatedLabel || field.name} className="px-2"> - <SelectValue placeholder={dynamicPlaceholder}> - {(selectedValue: string[]) => selectedValue.length - ? t($ => $['dynamicSelect.selected'], { ns: 'common', count: selectedValue.length }) - : dynamicPlaceholder} - </SelectValue> - </SelectTrigger> - <SelectContent popupClassName="bg-components-panel-bg-blur"> - {dynamicNoticeTitle && ( - <div className={cn( - 'flex h-[22px] items-center px-3 system-xs-medium-uppercase text-text-tertiary', - dynamicNoticeClassName, - )} - > - {dynamicNoticeTitle} - </div> - )} - {dynamicOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - <SelectItemText>{option.label}</SelectItemText> - <SelectItemIndicator /> - </SelectItem> - ))} - </SelectContent> - </Select> - ) - : ( - <Select - items={dynamicOptions} - value={getSingleSelectValue(value, dynamicOptions)} - disabled={disabled || isDynamicOptionsLoading} - onValueChange={(next) => { - if (next == null) - return - field.handleChange(next) - }} - > - <SelectTrigger id={field.name} aria-label={translatedLabel || field.name} className="px-2"> - <SelectValue placeholder={dynamicPlaceholder}> - {nextValue => getSingleSelectLabel(nextValue, dynamicOptions, dynamicPlaceholder)} - </SelectValue> - </SelectTrigger> - <SelectContent popupClassName="bg-components-panel-bg-blur"> - {dynamicNoticeTitle && ( - <div className={cn( - 'flex h-[22px] items-center px-3 system-xs-medium-uppercase text-text-tertiary', - dynamicNoticeClassName, - )} - > - {dynamicNoticeTitle} - </div> - )} - {dynamicOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - <SelectItemText>{option.label}</SelectItemText> - <SelectItemIndicator /> - </SelectItem> - ))} - </SelectContent> - </Select> - )) - } - { - formItemType === FormTypeEnum.radio && ( - <Field name={name} className="contents"> - <Fieldset - render={( - <RadioGroup - value={stringValue} - onValueChange={optionValue => handleChange(optionValue)} + onValueChange={handleChange} + > + <SelectTrigger + id={field.name} + aria-label={translatedLabel || field.name} + className="px-2" + > + <SelectValue placeholder={translatedPlaceholder}> + {(selectedValue: string[]) => + selectedValue.length + ? t(($) => $['dynamicSelect.selected'], { + ns: 'common', + count: selectedValue.length, + }) + : translatedPlaceholder + } + </SelectValue> + </SelectTrigger> + <SelectContent popupClassName="max-h-[320px] bg-components-panel-bg-blur"> + {memorizedOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + <SelectItemText>{option.label}</SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <Select + items={memorizedOptions} + value={getSingleSelectValue(value, memorizedOptions)} + disabled={disabled} + onValueChange={(next) => { + if (next == null) return + handleChange(next) + }} + > + <SelectTrigger + id={field.name} + aria-label={translatedLabel || field.name} + className="px-2" + > + <SelectValue placeholder={translatedPlaceholder}> + {(nextValue) => + getSingleSelectLabel(nextValue, memorizedOptions, translatedPlaceholder) + } + </SelectValue> + </SelectTrigger> + <SelectContent popupClassName="max-h-[320px] bg-components-panel-bg-blur"> + {memorizedOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + <SelectItemText>{option.label}</SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ))} + </SelectContent> + </Select> + ))} + {formItemType === FormTypeEnum.checkbox /* && multiple */ && ( + <CheckboxList + name={field.name} + title={name} + value={value} + onChange={(v) => field.handleChange(v)} + options={memorizedOptions} + maxHeight="200px" + /> + )} + {formItemType === FormTypeEnum.dynamicSelect && + (multiple ? ( + <Select + multiple + items={dynamicOptions} + value={Array.isArray(value) ? value : []} + disabled={disabled || isDynamicOptionsLoading} + onValueChange={field.handleChange} + > + <SelectTrigger + id={field.name} + aria-label={translatedLabel || field.name} + className="px-2" + > + <SelectValue placeholder={dynamicPlaceholder}> + {(selectedValue: string[]) => + selectedValue.length + ? t(($) => $['dynamicSelect.selected'], { + ns: 'common', + count: selectedValue.length, + }) + : dynamicPlaceholder + } + </SelectValue> + </SelectTrigger> + <SelectContent popupClassName="bg-components-panel-bg-blur"> + {dynamicNoticeTitle && ( + <div className={cn( - memorizedOptions.length >= 3 && 'flex-col items-stretch', + 'flex h-[22px] items-center px-3 system-xs-medium-uppercase text-text-tertiary', + dynamicNoticeClassName, )} - /> + > + {dynamicNoticeTitle} + </div> )} + {dynamicOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + <SelectItemText>{option.label}</SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <Select + items={dynamicOptions} + value={getSingleSelectValue(value, dynamicOptions)} + disabled={disabled || isDynamicOptionsLoading} + onValueChange={(next) => { + if (next == null) return + field.handleChange(next) + }} + > + <SelectTrigger + id={field.name} + aria-label={translatedLabel || field.name} + className="px-2" > - <FieldsetLegend className="sr-only">{translatedLabel || name}</FieldsetLegend> - { - memorizedOptions.map(option => ( - <FieldItem key={option.value} className={cn('min-w-0', memorizedOptions.length < 3 && 'flex-1 grow')}> - <FieldLabel - className={cn( - 'hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary', - value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs', - disabled && 'cursor-not-allowed opacity-50', - inputClassName, - )} - > - { - formSchema.showRadioUI && ( - <Radio - className="mr-2" - value={option.value} - disabled={disabled} - /> - ) - } - {!formSchema.showRadioUI && ( - <Radio - className="sr-only" - value={option.value} - disabled={disabled} - /> - )} - {option.label} - </FieldLabel> - </FieldItem> - )) - } - </Fieldset> - </Field> - ) - } - { - formItemType === FormTypeEnum.boolean && ( - <Field name={name} className="contents"> - <Fieldset - render={( - <RadioGroup<boolean> - className="w-fit gap-3" - value={booleanValue} - onValueChange={v => field.handleChange(v)} - /> + <SelectValue placeholder={dynamicPlaceholder}> + {(nextValue) => + getSingleSelectLabel(nextValue, dynamicOptions, dynamicPlaceholder) + } + </SelectValue> + </SelectTrigger> + <SelectContent popupClassName="bg-components-panel-bg-blur"> + {dynamicNoticeTitle && ( + <div + className={cn( + 'flex h-[22px] items-center px-3 system-xs-medium-uppercase text-text-tertiary', + dynamicNoticeClassName, + )} + > + {dynamicNoticeTitle} + </div> )} - > - <FieldsetLegend className="sr-only">{translatedLabel || name}</FieldsetLegend> - <FieldItem> - <FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary"> - <Radio value={true} /> - True - </FieldLabel> - </FieldItem> - <FieldItem> - <FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary"> - <Radio value={false} /> - False + {dynamicOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + <SelectItemText>{option.label}</SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ))} + </SelectContent> + </Select> + ))} + {formItemType === FormTypeEnum.radio && ( + <Field name={name} className="contents"> + <Fieldset + render={ + <RadioGroup + value={stringValue} + onValueChange={(optionValue) => handleChange(optionValue)} + className={cn(memorizedOptions.length >= 3 && 'flex-col items-stretch')} + /> + } + > + <FieldsetLegend className="sr-only">{translatedLabel || name}</FieldsetLegend> + {memorizedOptions.map((option) => ( + <FieldItem + key={option.value} + className={cn('min-w-0', memorizedOptions.length < 3 && 'flex-1 grow')} + > + <FieldLabel + className={cn( + 'hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary', + value === option.value && + 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs', + disabled && 'cursor-not-allowed opacity-50', + inputClassName, + )} + > + {formSchema.showRadioUI && ( + <Radio className="mr-2" value={option.value} disabled={disabled} /> + )} + {!formSchema.showRadioUI && ( + <Radio className="sr-only" value={option.value} disabled={disabled} /> + )} + {option.label} </FieldLabel> </FieldItem> - </Fieldset> - </Field> - ) - } - {fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && ( - <div className={cn( - 'mt-1 px-0 py-[2px] system-xs-regular', - VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus].textClassName, - )} - > - {fieldState?.[VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus].infoFieldName as keyof FieldState]} - </div> + ))} + </Fieldset> + </Field> )} + {formItemType === FormTypeEnum.boolean && ( + <Field name={name} className="contents"> + <Fieldset + render={ + <RadioGroup<boolean> + className="w-fit gap-3" + value={booleanValue} + onValueChange={(v) => field.handleChange(v)} + /> + } + > + <FieldsetLegend className="sr-only">{translatedLabel || name}</FieldsetLegend> + <FieldItem> + <FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary"> + <Radio value={true} /> + True + </FieldLabel> + </FieldItem> + <FieldItem> + <FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary"> + <Radio value={false} /> + False + </FieldLabel> + </FieldItem> + </Fieldset> + </Field> + )} + {fieldState?.validateStatus && + [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes( + fieldState?.validateStatus, + ) && ( + <div + className={cn( + 'mt-1 px-0 py-[2px] system-xs-regular', + VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus].textClassName, + )} + > + { + fieldState?.[ + VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus] + .infoFieldName as keyof FieldState + ] + } + </div> + )} </div> </div> {description && ( - <div className="mt-4 system-xs-regular text-text-tertiary"> - {translatedDescription} - </div> + <div className="mt-4 system-xs-regular text-text-tertiary">{translatedDescription}</div> + )} + {url && ( + <a + className="mt-4 flex items-center system-xs-regular text-text-accent" + href={url} + target="_blank" + > + <span className="break-all">{translatedHelp}</span> + <div className="ml-1 i-ri-external-link-line size-3 shrink-0" /> + </a> )} - { - url && ( - <a - className="mt-4 flex items-center system-xs-regular text-text-accent" - href={url} - target="_blank" - > - <span className="break-all"> - {translatedHelp} - </span> - <div className="ml-1 i-ri-external-link-line size-3 shrink-0" /> - </a> - ) - } </> - ) } diff --git a/web/app/components/base/form/components/base/base-form.tsx b/web/app/components/base/form/components/base/base-form.tsx index 84374b11b0650b..22122f8aeb3612 100644 --- a/web/app/components/base/form/components/base/base-form.tsx +++ b/web/app/components/base/form/components/base/base-form.tsx @@ -1,35 +1,17 @@ +import type { AnyFieldApi, AnyFormApi } from '@tanstack/react-form' +import type { BaseFieldProps } from '.' import type { - AnyFieldApi, - AnyFormApi, -} from '@tanstack/react-form' -import type { - BaseFieldProps, -} from '.' -import type { FieldState, FormRef, FormSchema, SetFieldsParam } from '@/app/components/base/form/types' -import { cn } from '@langgenius/dify-ui/cn' -import { - useForm, - useStore, -} from '@tanstack/react-form' -import { - memo, - useCallback, - useImperativeHandle, - useMemo, - useState, -} from 'react' -import { - useGetFormValues, - useGetValidators, -} from '@/app/components/base/form/hooks' -import { - - FormItemValidateStatusEnum, - + FieldState, + FormRef, + FormSchema, + SetFieldsParam, } from '@/app/components/base/form/types' -import { - BaseField, -} from '.' +import { cn } from '@langgenius/dify-ui/cn' +import { useForm, useStore } from '@tanstack/react-form' +import { memo, useCallback, useImperativeHandle, useMemo, useState } from 'react' +import { useGetFormValues, useGetValidators } from '@/app/components/base/form/hooks' +import { FormItemValidateStatusEnum } from '@/app/components/base/form/types' +import { BaseField } from '.' export type BaseFormProps = { formSchemas?: FormSchema[] @@ -41,7 +23,10 @@ export type BaseFormProps = { onChange?: (field: string, value: any) => void onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void preventDefaultSubmit?: boolean -} & Pick<BaseFieldProps, 'fieldClassName' | 'labelClassName' | 'inputContainerClassName' | 'inputClassName'> +} & Pick< + BaseFieldProps, + 'fieldClassName' | 'labelClassName' | 'inputContainerClassName' | 'inputClassName' +> const BaseForm = ({ formSchemas = [], @@ -59,14 +44,15 @@ const BaseForm = ({ preventDefaultSubmit = false, }: BaseFormProps) => { const initialDefaultValues = useMemo(() => { - if (defaultValues) - return defaultValues - - return formSchemas.reduce((acc, schema) => { - if (schema.default) - acc[schema.name] = schema.default - return acc - }, {} as Record<string, any>) + if (defaultValues) return defaultValues + + return formSchemas.reduce( + (acc, schema) => { + if (schema.default) acc[schema.name] = schema.default + return acc + }, + {} as Record<string, any>, + ) }, [defaultValues]) const formFromHook = useForm({ defaultValues: initialDefaultValues, @@ -90,33 +76,34 @@ const BaseForm = ({ return result }) - const setFields = useCallback((fields: SetFieldsParam[]) => { - const newFieldStates: Record<string, FieldState> = { ...fieldStates } + const setFields = useCallback( + (fields: SetFieldsParam[]) => { + const newFieldStates: Record<string, FieldState> = { ...fieldStates } - for (const field of fields) { - const { name, value, errors, warnings, validateStatus, help } = field + for (const field of fields) { + const { name, value, errors, warnings, validateStatus, help } = field - if (value !== undefined) - form.setFieldValue(name, value) + if (value !== undefined) form.setFieldValue(name, value) - let finalValidateStatus = validateStatus - if (!finalValidateStatus) { - if (errors && errors.length > 0) - finalValidateStatus = FormItemValidateStatusEnum.Error - else if (warnings && warnings.length > 0) - finalValidateStatus = FormItemValidateStatusEnum.Warning - } + let finalValidateStatus = validateStatus + if (!finalValidateStatus) { + if (errors && errors.length > 0) finalValidateStatus = FormItemValidateStatusEnum.Error + else if (warnings && warnings.length > 0) + finalValidateStatus = FormItemValidateStatusEnum.Warning + } - newFieldStates[name] = { - validateStatus: finalValidateStatus, - help, - errors, - warnings, + newFieldStates[name] = { + validateStatus: finalValidateStatus, + help, + errors, + warnings, + } } - } - setFieldStates(newFieldStates) - }, [form, fieldStates]) + setFieldStates(newFieldStates) + }, + [form, fieldStates], + ) useImperativeHandle(ref, () => { return { @@ -130,56 +117,62 @@ const BaseForm = ({ } }, [form, getFormValues, setFields]) - const renderField = useCallback((field: AnyFieldApi) => { - const formSchema = formSchemas?.find(schema => schema.name === field.name) - - if (formSchema) { - return ( - <BaseField - field={field} - formSchema={formSchema} - fieldClassName={fieldClassName ?? formSchema.fieldClassName} - labelClassName={labelClassName ?? formSchema.labelClassName} - inputContainerClassName={inputContainerClassName} - inputClassName={inputClassName} - disabled={disabled} - onChange={onChange} - fieldState={fieldStates[field.name]} - /> - ) - } + const renderField = useCallback( + (field: AnyFieldApi) => { + const formSchema = formSchemas?.find((schema) => schema.name === field.name) + + if (formSchema) { + return ( + <BaseField + field={field} + formSchema={formSchema} + fieldClassName={fieldClassName ?? formSchema.fieldClassName} + labelClassName={labelClassName ?? formSchema.labelClassName} + inputContainerClassName={inputContainerClassName} + inputClassName={inputClassName} + disabled={disabled} + onChange={onChange} + fieldState={fieldStates[field.name]} + /> + ) + } - return null - }, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange, fieldStates]) + return null + }, + [ + formSchemas, + fieldClassName, + labelClassName, + inputContainerClassName, + inputClassName, + disabled, + onChange, + fieldStates, + ], + ) - const renderFieldWrapper = useCallback((formSchema: FormSchema) => { - const validators = getValidators(formSchema) - const { - name, - show_on = [], - } = formSchema + const renderFieldWrapper = useCallback( + (formSchema: FormSchema) => { + const validators = getValidators(formSchema) + const { name, show_on = [] } = formSchema - const show = show_on?.every((condition) => { - const conditionValue = showOnValues[condition.variable] - return conditionValue === condition.value - }) + const show = show_on?.every((condition) => { + const conditionValue = showOnValues[condition.variable] + return conditionValue === condition.value + }) - if (!show) - return null + if (!show) return null - return ( - <form.Field - key={name} - name={name} - validators={validators} - > - {renderField} - </form.Field> - ) - }, [renderField, form, getValidators, showOnValues]) + return ( + <form.Field key={name} name={name} validators={validators}> + {renderField} + </form.Field> + ) + }, + [renderField, form, getValidators, showOnValues], + ) - if (!formSchemas?.length) - return null + if (!formSchemas?.length) return null const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { if (preventDefaultSubmit) { @@ -190,10 +183,7 @@ const BaseForm = ({ } return ( - <form - className={cn(formClassName)} - onSubmit={handleSubmit} - > + <form className={cn(formClassName)} onSubmit={handleSubmit}> {formSchemas.map(renderFieldWrapper)} </form> ) diff --git a/web/app/components/base/form/components/field/__tests__/file-types.spec.tsx b/web/app/components/base/form/components/field/__tests__/file-types.spec.tsx index 971e04f2580328..5af4275239c985 100644 --- a/web/app/components/base/form/components/field/__tests__/file-types.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/file-types.spec.tsx @@ -40,9 +40,14 @@ vi.mock('@/app/components/workflow/nodes/_base/components/file-type-item', () => <input aria-label="custom file extensions" value={customFileTypes.join(',')} - onChange={e => onCustomFileTypesChange( - e.target.value.split(',').map(v => v.trim()).filter(Boolean), - )} + onChange={(e) => + onCustomFileTypesChange( + e.target.value + .split(',') + .map((v) => v.trim()) + .filter(Boolean), + ) + } /> )} </div> @@ -62,7 +67,9 @@ describe('FileTypesField', () => { render(<FileTypesField label="Allowed file types" />) expect(screen.getByText('Allowed file types')).toBeInTheDocument() - expect(screen.getByRole('button', { name: SupportUploadFileTypes.document })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: SupportUploadFileTypes.document }), + ).toBeInTheDocument() expect(screen.getByRole('button', { name: SupportUploadFileTypes.image })).toBeInTheDocument() expect(screen.getByRole('button', { name: SupportUploadFileTypes.audio })).toBeInTheDocument() expect(screen.getByRole('button', { name: SupportUploadFileTypes.video })).toBeInTheDocument() diff --git a/web/app/components/base/form/components/field/__tests__/number-slider.spec.tsx b/web/app/components/base/form/components/field/__tests__/number-slider.spec.tsx index c95301c105c3d8..1f0dff7d99c492 100644 --- a/web/app/components/base/form/components/field/__tests__/number-slider.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/number-slider.spec.tsx @@ -22,11 +22,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/input-number-with-slid label: string value: number onChange: (value: number) => void - }) => ( - <button onClick={() => onChange(value + 1)}> - {`${label}-slider-value-${value}`} - </button> - ), + }) => <button onClick={() => onChange(value + 1)}>{`${label}-slider-value-${value}`}</button>, })) describe('NumberSliderField', () => { diff --git a/web/app/components/base/form/components/field/__tests__/options.spec.tsx b/web/app/components/base/form/components/field/__tests__/options.spec.tsx index 93f956a4c56f91..b51e93b406c031 100644 --- a/web/app/components/base/form/components/field/__tests__/options.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/options.spec.tsx @@ -4,7 +4,7 @@ import OptionsField from '../options' const mockField = { name: 'options-field', state: { - value: [] as { label: string, value: string }[], + value: [] as { label: string; value: string }[], }, handleChange: vi.fn(), } @@ -14,14 +14,8 @@ vi.mock('../../..', () => ({ })) vi.mock('@/app/components/app/configuration/config-var/config-select', () => ({ - default: ({ - onChange, - }: { - onChange: (value: { label: string, value: string }[]) => void - }) => ( - <button onClick={() => onChange([{ label: 'A', value: 'a' }])}> - apply-options - </button> + default: ({ onChange }: { onChange: (value: { label: string; value: string }[]) => void }) => ( + <button onClick={() => onChange([{ label: 'A', value: 'a' }])}>apply-options</button> ), })) diff --git a/web/app/components/base/form/components/field/__tests__/text-area.spec.tsx b/web/app/components/base/form/components/field/__tests__/text-area.spec.tsx index 6a43ec48611ce1..ba4c8253ef9d49 100644 --- a/web/app/components/base/form/components/field/__tests__/text-area.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/text-area.spec.tsx @@ -38,7 +38,9 @@ describe('TextAreaField', () => { render( <TextAreaField label="Note" - {...({ onValueChange: externalOnValueChange } as Partial<ComponentProps<typeof TextAreaField>>)} + {...({ onValueChange: externalOnValueChange } as Partial< + ComponentProps<typeof TextAreaField> + >)} />, ) diff --git a/web/app/components/base/form/components/field/__tests__/upload-method.spec.tsx b/web/app/components/base/form/components/field/__tests__/upload-method.spec.tsx index 652f1e91710ff1..0aca250090248b 100644 --- a/web/app/components/base/form/components/field/__tests__/upload-method.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/upload-method.spec.tsx @@ -40,7 +40,9 @@ describe('UploadMethodField', () => { render(<UploadMethodField label="Upload methods" />) expect(screen.getByText('Upload methods')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'appDebug.variableConfig.localUpload' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'appDebug.variableConfig.localUpload' }), + ).toBeInTheDocument() expect(screen.getByRole('button', { name: 'URL' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'appDebug.variableConfig.both' })).toBeInTheDocument() }) diff --git a/web/app/components/base/form/components/field/__tests__/variable-selector.spec.tsx b/web/app/components/base/form/components/field/__tests__/variable-selector.spec.tsx index df6f6b2531f37f..ebe7b1acb5f333 100644 --- a/web/app/components/base/form/components/field/__tests__/variable-selector.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/variable-selector.spec.tsx @@ -3,9 +3,7 @@ import VariableSelectorField from '../variable-selector' vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ default: ({ onChange }: { onChange?: () => void }) => ( - <button onClick={() => onChange?.()}> - Variable picker - </button> + <button onClick={() => onChange?.()}>Variable picker</button> ), })) diff --git a/web/app/components/base/form/components/field/checkbox.tsx b/web/app/components/base/form/components/field/checkbox.tsx index 9125b713b26779..4cc62c2e00ac05 100644 --- a/web/app/components/base/form/components/field/checkbox.tsx +++ b/web/app/components/base/form/components/field/checkbox.tsx @@ -7,10 +7,7 @@ type CheckboxFieldProps = { labelClassName?: string } -const CheckboxField = ({ - label, - labelClassName, -}: CheckboxFieldProps) => { +const CheckboxField = ({ label, labelClassName }: CheckboxFieldProps) => { const field = useFieldContext<boolean>() return ( @@ -18,15 +15,10 @@ const CheckboxField = ({ <span className="flex h-6 shrink-0 items-center"> <Checkbox checked={field.state.value} - onCheckedChange={checked => field.handleChange(checked)} + onCheckedChange={(checked) => field.handleChange(checked)} /> </span> - <span - className={cn( - 'grow pt-1 system-sm-medium text-text-secondary', - labelClassName, - )} - > + <span className={cn('grow pt-1 system-sm-medium text-text-secondary', labelClassName)}> {label} </span> </label> diff --git a/web/app/components/base/form/components/field/file-types.tsx b/web/app/components/base/form/components/field/file-types.tsx index a87f495735177d..4ae17be1e0230b 100644 --- a/web/app/components/base/form/components/field/file-types.tsx +++ b/web/app/components/base/form/components/field/file-types.tsx @@ -17,58 +17,62 @@ type FileTypesFieldProps = { className?: string } -const FileTypesField = ({ - label, - labelOptions, - className, -}: FileTypesFieldProps) => { +const FileTypesField = ({ label, labelOptions, className }: FileTypesFieldProps) => { const field = useFieldContext<FieldValue>() - const handleSupportFileTypeChange = useCallback((type: SupportUploadFileTypes) => { - let newAllowFileTypes = [...field.state.value.allowedFileTypes] - if (type === SupportUploadFileTypes.custom) { - if (!newAllowFileTypes.includes(SupportUploadFileTypes.custom)) - newAllowFileTypes = [SupportUploadFileTypes.custom] - else - newAllowFileTypes = newAllowFileTypes.filter(v => v !== type) - } - else { - newAllowFileTypes = newAllowFileTypes.filter(v => v !== SupportUploadFileTypes.custom) - if (newAllowFileTypes.includes(type)) - newAllowFileTypes = newAllowFileTypes.filter(v => v !== type) - else - newAllowFileTypes.push(type) - } - field.handleChange({ - ...field.state.value, - allowedFileTypes: newAllowFileTypes, - }) - }, [field]) + const handleSupportFileTypeChange = useCallback( + (type: SupportUploadFileTypes) => { + let newAllowFileTypes = [...field.state.value.allowedFileTypes] + if (type === SupportUploadFileTypes.custom) { + if (!newAllowFileTypes.includes(SupportUploadFileTypes.custom)) + newAllowFileTypes = [SupportUploadFileTypes.custom] + else newAllowFileTypes = newAllowFileTypes.filter((v) => v !== type) + } else { + newAllowFileTypes = newAllowFileTypes.filter((v) => v !== SupportUploadFileTypes.custom) + if (newAllowFileTypes.includes(type)) + newAllowFileTypes = newAllowFileTypes.filter((v) => v !== type) + else newAllowFileTypes.push(type) + } + field.handleChange({ + ...field.state.value, + allowedFileTypes: newAllowFileTypes, + }) + }, + [field], + ) - const handleCustomFileTypesChange = useCallback((customFileTypes: string[]) => { - field.handleChange({ - ...field.state.value, - allowedFileExtensions: customFileTypes, - }) - }, [field]) + const handleCustomFileTypesChange = useCallback( + (customFileTypes: string[]) => { + field.handleChange({ + ...field.state.value, + allowedFileExtensions: customFileTypes, + }) + }, + [field], + ) return ( <div className={cn('flex flex-col gap-y-0.5', className)}> - <Label - htmlFor={field.name} - label={label} - {...(labelOptions ?? {})} - /> - { - [SupportUploadFileTypes.document, SupportUploadFileTypes.image, SupportUploadFileTypes.audio, SupportUploadFileTypes.video].map((type: SupportUploadFileTypes) => ( - <FileTypeItem - key={type} - type={type as SupportUploadFileTypes.image | SupportUploadFileTypes.document | SupportUploadFileTypes.audio | SupportUploadFileTypes.video} - selected={field.state.value.allowedFileTypes.includes(type)} - onToggle={handleSupportFileTypeChange} - /> - )) - } + <Label htmlFor={field.name} label={label} {...(labelOptions ?? {})} /> + {[ + SupportUploadFileTypes.document, + SupportUploadFileTypes.image, + SupportUploadFileTypes.audio, + SupportUploadFileTypes.video, + ].map((type: SupportUploadFileTypes) => ( + <FileTypeItem + key={type} + type={ + type as + | SupportUploadFileTypes.image + | SupportUploadFileTypes.document + | SupportUploadFileTypes.audio + | SupportUploadFileTypes.video + } + selected={field.state.value.allowedFileTypes.includes(type)} + onToggle={handleSupportFileTypeChange} + /> + ))} <FileTypeItem type={SupportUploadFileTypes.custom} selected={field.state.value.allowedFileTypes.includes(SupportUploadFileTypes.custom)} diff --git a/web/app/components/base/form/components/field/file-uploader.tsx b/web/app/components/base/form/components/field/file-uploader.tsx index 1e835768c52035..a4aa9f48cc3482 100644 --- a/web/app/components/base/form/components/field/file-uploader.tsx +++ b/web/app/components/base/form/components/field/file-uploader.tsx @@ -23,14 +23,10 @@ const FileUploaderField = ({ return ( <div className={cn('flex flex-col gap-y-0.5', className)}> - <Label - htmlFor={field.name} - label={label} - {...(labelOptions ?? {})} - /> + <Label htmlFor={field.name} label={label} {...(labelOptions ?? {})} /> <FileUploaderInAttachmentWrapper value={field.state.value} - onChange={value => field.handleChange(value)} + onChange={(value) => field.handleChange(value)} {...inputProps} /> </div> diff --git a/web/app/components/base/form/components/field/input-type-select/__tests__/hooks.spec.tsx b/web/app/components/base/form/components/field/input-type-select/__tests__/hooks.spec.tsx index 236bcc95ea7187..34e9ecc0a46b3d 100644 --- a/web/app/components/base/form/components/field/input-type-select/__tests__/hooks.spec.tsx +++ b/web/app/components/base/form/components/field/input-type-select/__tests__/hooks.spec.tsx @@ -4,7 +4,7 @@ import { useInputTypeOptions } from '../hooks' describe('useInputTypeOptions', () => { it('should include file options when supportFile is true', () => { const { result } = renderHook(() => useInputTypeOptions(true)) - const values = result.current.map(item => item.value) + const values = result.current.map((item) => item.value) expect(values).toContain('file') expect(values).toContain('file-list') @@ -12,7 +12,7 @@ describe('useInputTypeOptions', () => { it('should exclude file options when supportFile is false', () => { const { result } = renderHook(() => useInputTypeOptions(false)) - const values = result.current.map(item => item.value) + const values = result.current.map((item) => item.value) expect(values).not.toContain('file') expect(values).not.toContain('file-list') diff --git a/web/app/components/base/form/components/field/input-type-select/__tests__/index.spec.tsx b/web/app/components/base/form/components/field/input-type-select/__tests__/index.spec.tsx index f666315ddf5872..852a2f72551106 100644 --- a/web/app/components/base/form/components/field/input-type-select/__tests__/index.spec.tsx +++ b/web/app/components/base/form/components/field/input-type-select/__tests__/index.spec.tsx @@ -26,7 +26,12 @@ describe('InputTypeSelectField', () => { expect(screen.getByText('Input type')).toBeInTheDocument() expect(screen.getByText('appDebug.variableConfig.text-input')).toBeInTheDocument() expect(container.querySelector('[role="combobox"] span > div')).not.toBeInTheDocument() - expect(container.querySelector('[role="combobox"] > span > span')).toHaveClass('flex', 'min-w-0', 'items-center', 'gap-x-0.5') + expect(container.querySelector('[role="combobox"] > span > span')).toHaveClass( + 'flex', + 'min-w-0', + 'items-center', + 'gap-x-0.5', + ) }) it('should update value when users choose another input type', async () => { diff --git a/web/app/components/base/form/components/field/input-type-select/__tests__/trigger.spec.tsx b/web/app/components/base/form/components/field/input-type-select/__tests__/trigger.spec.tsx index a7a1f2a2947fc1..31275dcf0a0fa8 100644 --- a/web/app/components/base/form/components/field/input-type-select/__tests__/trigger.spec.tsx +++ b/web/app/components/base/form/components/field/input-type-select/__tests__/trigger.spec.tsx @@ -37,6 +37,11 @@ describe('InputTypeSelect Trigger', () => { />, ) - expect(screen.getByText('Text Input').parentElement).toHaveClass('flex', 'min-w-0', 'items-center', 'gap-x-0.5') + expect(screen.getByText('Text Input').parentElement).toHaveClass( + 'flex', + 'min-w-0', + 'items-center', + 'gap-x-0.5', + ) }) }) diff --git a/web/app/components/base/form/components/field/input-type-select/hooks.tsx b/web/app/components/base/form/components/field/input-type-select/hooks.tsx index 6323bf4eb29062..74dce50e1280a2 100644 --- a/web/app/components/base/form/components/field/input-type-select/hooks.tsx +++ b/web/app/components/base/form/components/field/input-type-select/hooks.tsx @@ -17,11 +17,11 @@ type VariableConfigKeySuffix = I18nKeysByPrefix<'appDebug', 'variableConfig.'> const i18nFileTypeMap = { 'text-input': 'text-input', - 'paragraph': 'paragraph', - 'number': 'number', - 'select': 'select', - 'checkbox': 'checkbox', - 'file': 'single-file', + paragraph: 'paragraph', + number: 'number', + select: 'select', + checkbox: 'checkbox', + file: 'single-file', 'file-list': 'multi-files', } satisfies Record<InputType, VariableConfigKeySuffix> @@ -47,12 +47,14 @@ const DATA_TYPE = { export const useInputTypeOptions = (supportFile: boolean) => { const { t } = useTranslation() - const options = supportFile ? InputTypeEnum.options : InputTypeEnum.exclude(['file', 'file-list']).options + const options = supportFile + ? InputTypeEnum.options + : InputTypeEnum.exclude(['file', 'file-list']).options return options.map((value) => { return { value, - label: t($ => $[`variableConfig.${i18nFileTypeMap[value]}`], { ns: 'appDebug' }), + label: t(($) => $[`variableConfig.${i18nFileTypeMap[value]}`], { ns: 'appDebug' }), Icon: INPUT_TYPE_ICON[value], type: DATA_TYPE[value], } diff --git a/web/app/components/base/form/components/field/input-type-select/index.tsx b/web/app/components/base/form/components/field/input-type-select/index.tsx index 176d82a4929af5..b63c965292c0c9 100644 --- a/web/app/components/base/form/components/field/input-type-select/index.tsx +++ b/web/app/components/base/form/components/field/input-type-select/index.tsx @@ -1,12 +1,7 @@ import type { LabelProps } from '../../label' import type { FileTypeSelectOption, InputType } from './types' import { cn } from '@langgenius/dify-ui/cn' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from '@langgenius/dify-ui/select' +import { Select, SelectContent, SelectItem, SelectTrigger } from '@langgenius/dify-ui/select' import { useFieldContext } from '../../..' import Label from '../../label' import { useInputTypeOptions } from './hooks' @@ -31,21 +26,16 @@ const InputTypeSelectField = ({ }: InputTypeSelectFieldProps) => { const field = useFieldContext<InputType>() const inputTypeOptions = useInputTypeOptions(supportFile) - const selected = inputTypeOptions.find(option => option.value === field.state.value) + const selected = inputTypeOptions.find((option) => option.value === field.state.value) const handleInputTypeChange = (next: string | null) => { const inputType = InputTypeEnum.safeParse(next) - if (inputType.success) - field.handleChange(inputType.data) + if (inputType.success) field.handleChange(inputType.data) } return ( <div className={cn('flex flex-col gap-y-0.5', className)}> - <Label - htmlFor={field.name} - label={label} - {...(labelOptions ?? {})} - /> + <Label htmlFor={field.name} label={label} {...(labelOptions ?? {})} /> <Select items={inputTypeOptions} value={field.state.value ?? null} @@ -57,11 +47,7 @@ const InputTypeSelectField = ({ </SelectTrigger> <SelectContent popupClassName="w-[368px] bg-components-panel-bg-blur shadow-shadow-shadow-5"> {inputTypeOptions.map((option: FileTypeSelectOption) => ( - <SelectItem - key={option.value} - value={option.value} - className="gap-x-1" - > + <SelectItem key={option.value} value={option.value} className="gap-x-1"> <Option option={option} /> </SelectItem> ))} diff --git a/web/app/components/base/form/components/field/input-type-select/option.tsx b/web/app/components/base/form/components/field/input-type-select/option.tsx index b998c59acd10c6..46bae45c4c90dd 100644 --- a/web/app/components/base/form/components/field/input-type-select/option.tsx +++ b/web/app/components/base/form/components/field/input-type-select/option.tsx @@ -6,9 +6,7 @@ type OptionProps = { option: FileTypeSelectOption } -const Option = ({ - option, -}: OptionProps) => { +const Option = ({ option }: OptionProps) => { return ( <> <option.Icon className="size-4 shrink-0 text-text-tertiary" /> diff --git a/web/app/components/base/form/components/field/input-type-select/trigger.tsx b/web/app/components/base/form/components/field/input-type-select/trigger.tsx index 0a1016268dd8aa..959bcf98a32e85 100644 --- a/web/app/components/base/form/components/field/input-type-select/trigger.tsx +++ b/web/app/components/base/form/components/field/input-type-select/trigger.tsx @@ -6,13 +6,11 @@ type TriggerProps = { option: FileTypeSelectOption | undefined } -const Trigger = ({ - option, -}: TriggerProps) => { +const Trigger = ({ option }: TriggerProps) => { const { t } = useTranslation() if (!option) - return <span className="grow p-1">{t($ => $['placeholder.select'], { ns: 'common' })}</span> + return <span className="grow p-1">{t(($) => $['placeholder.select'], { ns: 'common' })}</span> return ( <span className="flex min-w-0 items-center gap-x-0.5"> diff --git a/web/app/components/base/form/components/field/number-input.tsx b/web/app/components/base/form/components/field/number-input.tsx index 245bbeca1cbf7f..a257176db1b14c 100644 --- a/web/app/components/base/form/components/field/number-input.tsx +++ b/web/app/components/base/form/components/field/number-input.tsx @@ -1,4 +1,8 @@ -import type { NumberFieldInputProps, NumberFieldProps, NumberFieldSize } from '@langgenius/dify-ui/number-field' +import type { + NumberFieldInputProps, + NumberFieldProps, + NumberFieldSize, +} from '@langgenius/dify-ui/number-field' import type { ReactNode } from 'react' import type { LabelProps } from '../label' import { cn } from '@langgenius/dify-ui/cn' @@ -22,7 +26,11 @@ type NumberInputFieldProps = { inputClassName?: string unit?: ReactNode size?: NumberFieldSize -} & Omit<NumberFieldProps, 'children' | 'className' | 'id' | 'value' | 'defaultValue' | 'onValueChange'> & Omit<NumberFieldInputProps, 'children' | 'size' | 'onBlur' | 'className' | 'onChange'> +} & Omit< + NumberFieldProps, + 'children' | 'className' | 'id' | 'value' | 'defaultValue' | 'onValueChange' +> & + Omit<NumberFieldInputProps, 'children' | 'size' | 'onBlur' | 'className' | 'onChange'> const NumberInputField = ({ label, @@ -50,11 +58,7 @@ const NumberInputField = ({ return ( <div className={cn('flex flex-col gap-y-0.5', className)}> - <Label - htmlFor={field.name} - label={label} - {...(labelOptions ?? {})} - /> + <Label htmlFor={field.name} label={label} {...(labelOptions ?? {})} /> <NumberField name={field.name} value={field.state.value} @@ -64,7 +68,7 @@ const NumberInputField = ({ disabled={disabled} readOnly={readOnly} required={required} - onValueChange={value => field.handleChange(value ?? emptyValue)} + onValueChange={(value) => field.handleChange(value ?? emptyValue)} > <NumberFieldGroup size={size}> <NumberFieldInput @@ -74,11 +78,7 @@ const NumberInputField = ({ className={inputClassName} onBlur={field.handleBlur} /> - {Boolean(unit) && ( - <NumberFieldUnit size={size}> - {unit} - </NumberFieldUnit> - )} + {Boolean(unit) && <NumberFieldUnit size={size}>{unit}</NumberFieldUnit>} <NumberFieldControls> <NumberFieldIncrement size={size} /> <NumberFieldDecrement size={size} /> diff --git a/web/app/components/base/form/components/field/number-slider.tsx b/web/app/components/base/form/components/field/number-slider.tsx index acdbc2973cc2ee..4846d27756848e 100644 --- a/web/app/components/base/form/components/field/number-slider.tsx +++ b/web/app/components/base/form/components/field/number-slider.tsx @@ -24,21 +24,15 @@ const NumberSliderField = ({ return ( <div className={cn('flex flex-col gap-y-0.5', className)}> <div> - <Label - htmlFor={field.name} - label={label} - {...(labelOptions ?? {})} - /> + <Label htmlFor={field.name} label={label} {...(labelOptions ?? {})} /> {description && ( - <div className="pb-0.5 body-xs-regular text-text-tertiary"> - {description} - </div> + <div className="pb-0.5 body-xs-regular text-text-tertiary">{description}</div> )} </div> <InputNumberWithSlider label={label} value={field.state.value} - onChange={value => field.handleChange(value)} + onChange={(value) => field.handleChange(value)} {...InputNumberWithSliderProps} /> </div> diff --git a/web/app/components/base/form/components/field/options.tsx b/web/app/components/base/form/components/field/options.tsx index 8cd8c1c6f7680e..90f53f04fdf3ea 100644 --- a/web/app/components/base/form/components/field/options.tsx +++ b/web/app/components/base/form/components/field/options.tsx @@ -11,24 +11,13 @@ type OptionsFieldProps = { className?: string } -const OptionsField = ({ - label, - className, - labelOptions, -}: OptionsFieldProps) => { +const OptionsField = ({ label, className, labelOptions }: OptionsFieldProps) => { const field = useFieldContext<Options>() return ( <div className={cn('flex flex-col gap-y-0.5', className)}> - <Label - htmlFor={field.name} - label={label} - {...(labelOptions ?? {})} - /> - <ConfigSelect - options={field.state.value} - onChange={value => field.handleChange(value)} - /> + <Label htmlFor={field.name} label={label} {...(labelOptions ?? {})} /> + <ConfigSelect options={field.state.value} onChange={(value) => field.handleChange(value)} /> </div> ) } diff --git a/web/app/components/base/form/components/field/select.tsx b/web/app/components/base/form/components/field/select.tsx index 4f35b5d2c70206..c4602a32c9e019 100644 --- a/web/app/components/base/form/components/field/select.tsx +++ b/web/app/components/base/form/components/field/select.tsx @@ -19,11 +19,11 @@ export type Option = { } const getSelectedValue = (value: string | undefined, options: Option[]) => { - return options.some(option => option.value === value) ? value : null + return options.some((option) => option.value === value) ? value : null } const getDisplayLabel = (value: string | null, options: Option[], placeholder: string) => { - return options.find(option => option.value === value)?.label ?? placeholder + return options.find((option) => option.value === value)?.label ?? placeholder } type SelectFieldPopupProps = { @@ -55,22 +55,17 @@ const SelectField = ({ }: SelectFieldProps) => { const { t } = useTranslation() const field = useFieldContext<string>() - const placeholderText = placeholder || t($ => $['placeholder.select'], { ns: 'common' }) + const placeholderText = placeholder || t(($) => $['placeholder.select'], { ns: 'common' }) return ( <div className={cn('flex flex-col gap-y-0.5', className)}> - <Label - htmlFor={field.name} - label={label} - {...(labelOptions ?? {})} - /> + <Label htmlFor={field.name} label={label} {...(labelOptions ?? {})} /> <Select items={options} value={getSelectedValue(field.state.value, options)} disabled={disabled} onValueChange={(next) => { - if (next == null) - return + if (next == null) return field.handleChange(next) onChange?.(next) }} @@ -91,7 +86,7 @@ const SelectField = ({ {popupProps.title} </div> )} - {options.map(option => ( + {options.map((option) => ( <SelectItem key={option.value} value={option.value}> <SelectItemText>{option.label}</SelectItemText> <SelectItemIndicator /> diff --git a/web/app/components/base/form/components/field/text-area.tsx b/web/app/components/base/form/components/field/text-area.tsx index 8bb4c9b5e68fd9..73958d076e285f 100644 --- a/web/app/components/base/form/components/field/text-area.tsx +++ b/web/app/components/base/form/components/field/text-area.tsx @@ -12,26 +12,17 @@ type TextAreaFieldProps = { className?: string } & Omit<TextareaProps, 'className' | 'defaultValue' | 'onBlur' | 'onValueChange' | 'value' | 'id'> -const TextAreaField = ({ - label, - labelOptions, - className, - ...inputProps -}: TextAreaFieldProps) => { +const TextAreaField = ({ label, labelOptions, className, ...inputProps }: TextAreaFieldProps) => { const field = useFieldContext<string>() return ( <div className={cn('flex flex-col gap-y-0.5', className)}> - <Label - htmlFor={field.name} - label={label} - {...(labelOptions ?? {})} - /> + <Label htmlFor={field.name} label={label} {...(labelOptions ?? {})} /> <Textarea {...inputProps} id={field.name} value={field.state.value} - onValueChange={value => field.handleChange(value)} + onValueChange={(value) => field.handleChange(value)} onBlur={field.handleBlur} /> </div> diff --git a/web/app/components/base/form/components/field/text.tsx b/web/app/components/base/form/components/field/text.tsx index 3e75e19c226977..28a4919cee63d8 100644 --- a/web/app/components/base/form/components/field/text.tsx +++ b/web/app/components/base/form/components/field/text.tsx @@ -12,25 +12,16 @@ type TextFieldProps = { className?: string } & Omit<InputProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'> -const TextField = ({ - label, - labelOptions, - className, - ...inputProps -}: TextFieldProps) => { +const TextField = ({ label, labelOptions, className, ...inputProps }: TextFieldProps) => { const field = useFieldContext<string>() return ( <div className={cn('flex flex-col gap-y-0.5', className)}> - <Label - htmlFor={field.name} - label={label} - {...(labelOptions ?? {})} - /> + <Label htmlFor={field.name} label={label} {...(labelOptions ?? {})} /> <Input id={field.name} value={field.state.value} - onChange={e => field.handleChange(e.target.value)} + onChange={(e) => field.handleChange(e.target.value)} onBlur={field.handleBlur} {...inputProps} /> diff --git a/web/app/components/base/form/components/field/upload-method.tsx b/web/app/components/base/form/components/field/upload-method.tsx index 88c5ef0fc549ed..e6419ffbb1025f 100644 --- a/web/app/components/base/form/components/field/upload-method.tsx +++ b/web/app/components/base/form/components/field/upload-method.tsx @@ -13,30 +13,29 @@ type UploadMethodFieldProps = { className?: string } -const UploadMethodField = ({ - label, - labelOptions, - className, -}: UploadMethodFieldProps) => { +const UploadMethodField = ({ label, labelOptions, className }: UploadMethodFieldProps) => { const { t } = useTranslation() const field = useFieldContext<TransferMethod[]>() const { value } = field.state - const handleUploadMethodChange = useCallback((method: TransferMethod) => { - field.handleChange(method === TransferMethod.all ? [TransferMethod.local_file, TransferMethod.remote_url] : [method]) - }, [field]) + const handleUploadMethodChange = useCallback( + (method: TransferMethod) => { + field.handleChange( + method === TransferMethod.all + ? [TransferMethod.local_file, TransferMethod.remote_url] + : [method], + ) + }, + [field], + ) return ( <div className={cn('flex flex-col gap-y-0.5', className)}> - <Label - htmlFor={field.name} - label={label} - {...(labelOptions ?? {})} - /> + <Label htmlFor={field.name} label={label} {...(labelOptions ?? {})} /> <div className="grid grid-cols-3 gap-2"> <OptionCard - title={t($ => $['variableConfig.localUpload'], { ns: 'appDebug' })} + title={t(($) => $['variableConfig.localUpload'], { ns: 'appDebug' })} selected={value.length === 1 && value.includes(TransferMethod.local_file)} onSelect={handleUploadMethodChange.bind(null, TransferMethod.local_file)} /> @@ -46,8 +45,10 @@ const UploadMethodField = ({ onSelect={handleUploadMethodChange.bind(null, TransferMethod.remote_url)} /> <OptionCard - title={t($ => $['variableConfig.both'], { ns: 'appDebug' })} - selected={value.includes(TransferMethod.local_file) && value.includes(TransferMethod.remote_url)} + title={t(($) => $['variableConfig.both'], { ns: 'appDebug' })} + selected={ + value.includes(TransferMethod.local_file) && value.includes(TransferMethod.remote_url) + } onSelect={handleUploadMethodChange.bind(null, TransferMethod.all)} /> </div> diff --git a/web/app/components/base/form/components/field/variable-selector.tsx b/web/app/components/base/form/components/field/variable-selector.tsx index aeb687c0048a2a..d2d272d216e597 100644 --- a/web/app/components/base/form/components/field/variable-selector.tsx +++ b/web/app/components/base/form/components/field/variable-selector.tsx @@ -20,11 +20,7 @@ const VariableOrConstantInputField = ({ return ( <div className={cn('flex flex-col gap-y-0.5', className)}> - <Label - htmlFor="variable-or-constant" - label={label} - {...(labelOptions ?? {})} - /> + <Label htmlFor="variable-or-constant" label={label} {...(labelOptions ?? {})} /> <div className="flex items-center"> <VarReferencePicker className="grow" diff --git a/web/app/components/base/form/components/form/__tests__/actions.spec.tsx b/web/app/components/base/form/components/form/__tests__/actions.spec.tsx index 6e9aa58b96ae1a..1254c852128e68 100644 --- a/web/app/components/base/form/components/form/__tests__/actions.spec.tsx +++ b/web/app/components/base/form/components/form/__tests__/actions.spec.tsx @@ -11,10 +11,12 @@ const mockFormState = vi.hoisted(() => ({ })) vi.mock('@tanstack/react-form', async () => { - const actual = await vi.importActual<typeof import('@tanstack/react-form')>('@tanstack/react-form') + const actual = + await vi.importActual<typeof import('@tanstack/react-form')>('@tanstack/react-form') return { ...actual, - useStore: (_store: unknown, selector: (state: typeof mockFormState) => unknown) => selector(mockFormState), + useStore: (_store: unknown, selector: (state: typeof mockFormState) => unknown) => + selector(mockFormState), } }) @@ -71,21 +73,23 @@ describe('Actions', () => { it('should render custom actions when provided', () => { const customActionsSpy = vi.fn(({ isSubmitting, canSubmit }: CustomActionsProps) => ( - <div> - {`custom-${String(isSubmitting)}-${String(canSubmit)}`} - </div> + <div>{`custom-${String(isSubmitting)}-${String(canSubmit)}`}</div> )) renderWithForm({ CustomActions: customActionsSpy, }) - expect(screen.queryByRole('button', { name: 'common.operation.submit' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.submit' }), + ).not.toBeInTheDocument() expect(screen.getByText('custom-false-true')).toBeInTheDocument() - expect(customActionsSpy).toHaveBeenCalledWith(expect.objectContaining({ - form: expect.any(Object), - isSubmitting: false, - canSubmit: true, - })) + expect(customActionsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + form: expect.any(Object), + isSubmitting: false, + canSubmit: true, + }), + ) }) }) diff --git a/web/app/components/base/form/components/form/actions.tsx b/web/app/components/base/form/components/form/actions.tsx index 89fa7ced026176..7462fb067b453c 100644 --- a/web/app/components/base/form/components/form/actions.tsx +++ b/web/app/components/base/form/components/form/actions.tsx @@ -14,19 +14,16 @@ type ActionsProps = { CustomActions?: (props: CustomActionsProps) => React.ReactNode | React.JSX.Element } -const Actions = ({ - CustomActions, -}: ActionsProps) => { +const Actions = ({ CustomActions }: ActionsProps) => { const { t } = useTranslation() const form = useFormContext() - const [isSubmitting, canSubmit] = useStore(form.store, state => [ + const [isSubmitting, canSubmit] = useStore(form.store, (state) => [ state.isSubmitting, state.canSubmit, ]) - if (CustomActions) - return CustomActions({ form, isSubmitting, canSubmit }) + if (CustomActions) return CustomActions({ form, isSubmitting, canSubmit }) return ( <Button @@ -35,7 +32,7 @@ const Actions = ({ loading={isSubmitting} onClick={() => form.handleSubmit()} > - {t($ => $['operation.submit'], { ns: 'common' })} + {t(($) => $['operation.submit'], { ns: 'common' })} </Button> ) } diff --git a/web/app/components/base/form/components/label.tsx b/web/app/components/base/form/components/label.tsx index 64766521e00d1b..612bada5642e08 100644 --- a/web/app/components/base/form/components/label.tsx +++ b/web/app/components/base/form/components/label.tsx @@ -11,14 +11,7 @@ export type LabelProps = { className?: string } -const Label = ({ - htmlFor, - label, - isRequired, - showOptional, - tooltip, - className, -}: LabelProps) => { +const Label = ({ htmlFor, label, isRequired, showOptional, tooltip, className }: LabelProps) => { const { t } = useTranslation() return ( @@ -30,8 +23,14 @@ const Label = ({ > {label} </label> - {!isRequired && showOptional && <div className="ml-1 system-xs-regular text-text-tertiary">{t($ => $['label.optional'], { ns: 'common' })}</div>} - {isRequired && <div className="ml-1 system-xs-regular text-text-destructive-secondary">*</div>} + {!isRequired && showOptional && ( + <div className="ml-1 system-xs-regular text-text-tertiary"> + {t(($) => $['label.optional'], { ns: 'common' })} + </div> + )} + {isRequired && ( + <div className="ml-1 system-xs-regular text-text-destructive-secondary">*</div> + )} {tooltip && ( <Infotip aria-label={tooltip} className="ml-0.5 size-4" popupClassName="w-[200px]"> {tooltip} diff --git a/web/app/components/base/form/form-scenarios/auth/__tests__/index.spec.tsx b/web/app/components/base/form/form-scenarios/auth/__tests__/index.spec.tsx index e12397ed604e14..f16e38708a6b63 100644 --- a/web/app/components/base/form/form-scenarios/auth/__tests__/index.spec.tsx +++ b/web/app/components/base/form/form-scenarios/auth/__tests__/index.spec.tsx @@ -3,12 +3,14 @@ import { render, screen } from '@testing-library/react' import { FormTypeEnum } from '@/app/components/base/form/types' import AuthForm from '../index' -const formSchemas = [{ - type: FormTypeEnum.textInput, - name: 'apiKey', - label: 'API Key', - required: true, -}] as const +const formSchemas = [ + { + type: FormTypeEnum.textInput, + name: 'apiKey', + label: 'API Key', + required: true, + }, +] as const const renderWithQueryClient = (ui: Parameters<typeof render>[0]) => { const queryClient = new QueryClient({ @@ -19,11 +21,7 @@ const renderWithQueryClient = (ui: Parameters<typeof render>[0]) => { }, }) - return render( - <QueryClientProvider client={queryClient}> - {ui} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>) } describe('AuthForm', () => { @@ -35,7 +33,9 @@ describe('AuthForm', () => { }) it('should use provided default values', () => { - renderWithQueryClient(<AuthForm formSchemas={[...formSchemas]} defaultValues={{ apiKey: 'value-123' }} />) + renderWithQueryClient( + <AuthForm formSchemas={[...formSchemas]} defaultValues={{ apiKey: 'value-123' }} />, + ) expect(screen.getByDisplayValue('value-123')).toBeInTheDocument() }) diff --git a/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx b/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx index ee8065fc50d653..eed0e9643bf9e2 100644 --- a/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx +++ b/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx @@ -37,27 +37,39 @@ const FieldHarness = ({ config, initialData = {} }: FieldHarnessProps) => { describe('BaseField', () => { it('should render a text input field when configured as text input', () => { - render(<FieldHarness config={createConfig({ label: 'Username' })} initialData={{ fieldA: '' }} />) + render( + <FieldHarness config={createConfig({ label: 'Username' })} initialData={{ fieldA: '' }} />, + ) expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByText('Username')).toBeInTheDocument() }) it('should render a number input when configured as number input', () => { - render(<FieldHarness config={createConfig({ type: BaseFieldType.numberInput, label: 'Age' })} initialData={{ fieldA: 20 }} />) + render( + <FieldHarness + config={createConfig({ type: BaseFieldType.numberInput, label: 'Age' })} + initialData={{ fieldA: 20 }} + />, + ) expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByText('Age')).toBeInTheDocument() }) it('should render a checkbox when configured as checkbox', () => { - render(<FieldHarness config={createConfig({ type: BaseFieldType.checkbox, label: 'Agree' })} initialData={{ fieldA: false }} />) + render( + <FieldHarness + config={createConfig({ type: BaseFieldType.checkbox, label: 'Agree' })} + initialData={{ fieldA: false }} + />, + ) expect(screen.getByText('Agree')).toBeInTheDocument() }) it('should render paragraph and select fields based on configuration', () => { - const scenarios: Array<{ config: BaseConfiguration, initialData: Record<string, unknown> }> = [ + const scenarios: Array<{ config: BaseConfiguration; initialData: Record<string, unknown> }> = [ { config: createConfig({ type: BaseFieldType.paragraph, @@ -76,14 +88,16 @@ describe('BaseField', () => { ] for (const scenario of scenarios) { - const { unmount } = render(<FieldHarness config={scenario.config} initialData={scenario.initialData} />) + const { unmount } = render( + <FieldHarness config={scenario.config} initialData={scenario.initialData} />, + ) expect(screen.getByText(scenario.config.label)).toBeInTheDocument() unmount() } }) it('should render file uploader when configured as file', () => { - const scenarios: Array<{ config: BaseConfiguration, initialData: Record<string, unknown> }> = [ + const scenarios: Array<{ config: BaseConfiguration; initialData: Record<string, unknown> }> = [ { config: createConfig({ type: BaseFieldType.file, @@ -108,7 +122,9 @@ describe('BaseField', () => { ] for (const scenario of scenarios) { - const { unmount } = render(<FieldHarness config={scenario.config} initialData={scenario.initialData} />) + const { unmount } = render( + <FieldHarness config={scenario.config} initialData={scenario.initialData} />, + ) expect(screen.getByText(scenario.config.label)).toBeInTheDocument() unmount() } diff --git a/web/app/components/base/form/form-scenarios/base/__tests__/utils.spec.ts b/web/app/components/base/form/form-scenarios/base/__tests__/utils.spec.ts index 812968ad903678..923fa27766a128 100644 --- a/web/app/components/base/form/form-scenarios/base/__tests__/utils.spec.ts +++ b/web/app/components/base/form/form-scenarios/base/__tests__/utils.spec.ts @@ -3,14 +3,16 @@ import { generateZodSchema } from '../utils' describe('base scenario schema generator', () => { it('should validate required text fields with max length', () => { - const schema = generateZodSchema([{ - type: BaseFieldType.textInput, - variable: 'name', - label: 'Name', - required: true, - maxLength: 3, - showConditions: [], - }]) + const schema = generateZodSchema([ + { + type: BaseFieldType.textInput, + variable: 'name', + label: 'Name', + required: true, + maxLength: 3, + showConditions: [], + }, + ]) expect(schema.safeParse({ name: 'abc' }).success).toBe(true) expect(schema.safeParse({ name: '' }).success).toBe(false) @@ -18,15 +20,17 @@ describe('base scenario schema generator', () => { }) it('should validate number bounds', () => { - const schema = generateZodSchema([{ - type: BaseFieldType.numberInput, - variable: 'age', - label: 'Age', - required: true, - min: 18, - max: 30, - showConditions: [], - }]) + const schema = generateZodSchema([ + { + type: BaseFieldType.numberInput, + variable: 'age', + label: 'Age', + required: true, + min: 18, + max: 30, + showConditions: [], + }, + ]) expect(schema.safeParse({ age: 20 }).success).toBe(true) expect(schema.safeParse({ age: 17 }).success).toBe(false) @@ -34,27 +38,31 @@ describe('base scenario schema generator', () => { }) it('should allow optional fields to be undefined or null', () => { - const schema = generateZodSchema([{ - type: BaseFieldType.select, - variable: 'mode', - label: 'Mode', - required: false, - showConditions: [], - options: [{ value: 'safe', label: 'Safe' }], - }]) + const schema = generateZodSchema([ + { + type: BaseFieldType.select, + variable: 'mode', + label: 'Mode', + required: false, + showConditions: [], + options: [{ value: 'safe', label: 'Safe' }], + }, + ]) expect(schema.safeParse({}).success).toBe(true) expect(schema.safeParse({ mode: null }).success).toBe(true) }) it('should validate required checkbox values as booleans', () => { - const schema = generateZodSchema([{ - type: BaseFieldType.checkbox, - variable: 'accepted', - label: 'Accepted', - required: true, - showConditions: [], - }]) + const schema = generateZodSchema([ + { + type: BaseFieldType.checkbox, + variable: 'accepted', + label: 'Accepted', + required: true, + showConditions: [], + }, + ]) expect(schema.safeParse({ accepted: true }).success).toBe(true) expect(schema.safeParse({ accepted: false }).success).toBe(true) @@ -63,16 +71,18 @@ describe('base scenario schema generator', () => { }) it('should fallback to any schema for unsupported field types', () => { - const schema = generateZodSchema([{ - type: BaseFieldType.file, - variable: 'attachment', - label: 'Attachment', - required: false, - showConditions: [], - allowedFileTypes: [], - allowedFileExtensions: [], - allowedFileUploadMethods: [], - }]) + const schema = generateZodSchema([ + { + type: BaseFieldType.file, + variable: 'attachment', + label: 'Attachment', + required: false, + showConditions: [], + allowedFileTypes: [], + allowedFileExtensions: [], + allowedFileUploadMethods: [], + }, + ]) expect(schema.safeParse({ attachment: { id: 'file-1' } }).success).toBe(true) expect(schema.safeParse({ attachment: 'raw-string' }).success).toBe(true) @@ -81,16 +91,18 @@ describe('base scenario schema generator', () => { }) it('should ignore numeric and text constraints for non-applicable field types', () => { - const schema = generateZodSchema([{ - type: BaseFieldType.checkbox, - variable: 'toggle', - label: 'Toggle', - required: true, - showConditions: [], - maxLength: 1, - min: 10, - max: 20, - }]) + const schema = generateZodSchema([ + { + type: BaseFieldType.checkbox, + variable: 'toggle', + label: 'Toggle', + required: true, + showConditions: [], + maxLength: 1, + min: 10, + max: 20, + }, + ]) expect(schema.safeParse({ toggle: true }).success).toBe(true) expect(schema.safeParse({ toggle: false }).success).toBe(true) diff --git a/web/app/components/base/form/form-scenarios/base/field.tsx b/web/app/components/base/form/form-scenarios/base/field.tsx index 6fc59d906266ff..c88196b3be2665 100644 --- a/web/app/components/base/form/form-scenarios/base/field.tsx +++ b/web/app/components/base/form/form-scenarios/base/field.tsx @@ -9,191 +9,181 @@ type BaseFieldProps = { config: BaseConfiguration } -const BaseField = ({ - initialData, - config, -}: BaseFieldProps) => withForm({ - defaultValues: initialData, - render: function Render({ - form, - }) { - const { - type, - label, - placeholder, - variable, - tooltip, - showConditions, - max, - min, - options, - required, - showOptional, - popupProps, - allowedFileExtensions, - allowedFileTypes, - allowedFileUploadMethods, - maxLength, - unit, - } = config +const BaseField = ({ initialData, config }: BaseFieldProps) => + withForm({ + defaultValues: initialData, + render: function Render({ form }) { + const { + type, + label, + placeholder, + variable, + tooltip, + showConditions, + max, + min, + options, + required, + showOptional, + popupProps, + allowedFileExtensions, + allowedFileTypes, + allowedFileUploadMethods, + maxLength, + unit, + } = config - const isAllConditionsMet = useStore(form.store, (state) => { - const fieldValues = state.values - if (!showConditions.length) - return true - return showConditions.every((condition) => { - const { variable, value } = condition - const fieldValue = fieldValues[variable as keyof typeof fieldValues] - return fieldValue === value + const isAllConditionsMet = useStore(form.store, (state) => { + const fieldValues = state.values + if (!showConditions.length) return true + return showConditions.every((condition) => { + const { variable, value } = condition + const fieldValue = fieldValues[variable as keyof typeof fieldValues] + return fieldValue === value + }) }) - }) - if (!isAllConditionsMet) - return <></> + if (!isAllConditionsMet) return <></> - if (type === BaseFieldType.textInput) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.TextField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - placeholder={placeholder} - /> - )} - /> - ) - } + if (type === BaseFieldType.textInput) { + return ( + <form.AppField + name={variable} + children={(field) => ( + <field.TextField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + placeholder={placeholder} + /> + )} + /> + ) + } - if (type === BaseFieldType.paragraph) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.TextAreaField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - placeholder={placeholder} - /> - )} - /> - ) - } + if (type === BaseFieldType.paragraph) { + return ( + <form.AppField + name={variable} + children={(field) => ( + <field.TextAreaField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + placeholder={placeholder} + /> + )} + /> + ) + } - if (type === BaseFieldType.numberInput) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.NumberInputField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - placeholder={placeholder} - max={max} - min={min} - unit={unit} - /> - )} - /> - ) - } + if (type === BaseFieldType.numberInput) { + return ( + <form.AppField + name={variable} + children={(field) => ( + <field.NumberInputField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + placeholder={placeholder} + max={max} + min={min} + unit={unit} + /> + )} + /> + ) + } - if (type === BaseFieldType.checkbox) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.CheckboxField - label={label} - /> - )} - /> - ) - } + if (type === BaseFieldType.checkbox) { + return ( + <form.AppField + name={variable} + children={(field) => <field.CheckboxField label={label} />} + /> + ) + } - if (type === BaseFieldType.select) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.SelectField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - options={options!} - popupProps={popupProps} - /> - )} - /> - ) - } + if (type === BaseFieldType.select) { + return ( + <form.AppField + name={variable} + children={(field) => ( + <field.SelectField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + options={options!} + popupProps={popupProps} + /> + )} + /> + ) + } - if (type === BaseFieldType.file) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.FileUploaderField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - fileConfig={{ - allowed_file_extensions: allowedFileExtensions, - allowed_file_types: allowedFileTypes, - allowed_file_upload_methods: allowedFileUploadMethods, - number_limits: 1, - }} - /> - )} - /> - ) - } + if (type === BaseFieldType.file) { + return ( + <form.AppField + name={variable} + children={(field) => ( + <field.FileUploaderField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + fileConfig={{ + allowed_file_extensions: allowedFileExtensions, + allowed_file_types: allowedFileTypes, + allowed_file_upload_methods: allowedFileUploadMethods, + number_limits: 1, + }} + /> + )} + /> + ) + } - if (type === BaseFieldType.fileList) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.FileUploaderField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - fileConfig={{ - allowed_file_extensions: allowedFileExtensions, - allowed_file_types: allowedFileTypes, - allowed_file_upload_methods: allowedFileUploadMethods, - number_limits: maxLength, - }} - /> - )} - /> - ) - } + if (type === BaseFieldType.fileList) { + return ( + <form.AppField + name={variable} + children={(field) => ( + <field.FileUploaderField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + fileConfig={{ + allowed_file_extensions: allowedFileExtensions, + allowed_file_types: allowedFileTypes, + allowed_file_upload_methods: allowedFileUploadMethods, + number_limits: maxLength, + }} + /> + )} + /> + ) + } - return <></> - }, -}) + return <></> + }, + }) export default BaseField diff --git a/web/app/components/base/form/form-scenarios/base/types.ts b/web/app/components/base/form/form-scenarios/base/types.ts index 5540b6968b6b0e..26e13fddbc5d93 100644 --- a/web/app/components/base/form/form-scenarios/base/types.ts +++ b/web/app/components/base/form/form-scenarios/base/types.ts @@ -48,6 +48,6 @@ export type BaseConfiguration = { showConditions: ShowCondition[] // Show this field only when all conditions are met type: BaseFieldType tooltip?: string // Tooltip for this field -} & NumberConfiguration -& Partial<SelectConfiguration> -& Partial<FileConfiguration> +} & NumberConfiguration & + Partial<SelectConfiguration> & + Partial<FileConfiguration> diff --git a/web/app/components/base/form/form-scenarios/base/utils.ts b/web/app/components/base/form/form-scenarios/base/utils.ts index 221d43e0004c69..48f0e70341ee68 100644 --- a/web/app/components/base/form/form-scenarios/base/utils.ts +++ b/web/app/components/base/form/form-scenarios/base/utils.ts @@ -30,24 +30,32 @@ export const generateZodSchema = (fields: BaseConfiguration[]) => { if (field.maxLength) { if ([BaseFieldType.textInput, BaseFieldType.paragraph].includes(field.type)) - zodType = (zodType as ZodString).max(field.maxLength, `${field.label} exceeds max length of ${field.maxLength}`) + zodType = (zodType as ZodString).max( + field.maxLength, + `${field.label} exceeds max length of ${field.maxLength}`, + ) } if (field.min) { if ([BaseFieldType.numberInput].includes(field.type)) - zodType = (zodType as ZodNumber).min(field.min, `${field.label} must be at least ${field.min}`) + zodType = (zodType as ZodNumber).min( + field.min, + `${field.label} must be at least ${field.min}`, + ) } if (field.max) { if ([BaseFieldType.numberInput].includes(field.type)) - zodType = (zodType as ZodNumber).max(field.max, `${field.label} exceeds max value of ${field.max}`) + zodType = (zodType as ZodNumber).max( + field.max, + `${field.label} exceeds max value of ${field.max}`, + ) } if (field.required) { if ([BaseFieldType.textInput, BaseFieldType.paragraph].includes(field.type)) zodType = (zodType as ZodString).nonempty(`${field.label} is required`) - } - else { + } else { zodType = zodType.optional().nullable() } diff --git a/web/app/components/base/form/form-scenarios/input-field/__tests__/field.spec.tsx b/web/app/components/base/form/form-scenarios/input-field/__tests__/field.spec.tsx index 04ce7ea0ca4d43..07ba8c775e844e 100644 --- a/web/app/components/base/form/form-scenarios/input-field/__tests__/field.spec.tsx +++ b/web/app/components/base/form/form-scenarios/input-field/__tests__/field.spec.tsx @@ -5,7 +5,9 @@ import { useAppForm } from '../../..' import InputField from '../field' import { InputFieldType } from '../types' -const createConfig = (overrides: Partial<InputFieldConfiguration> = {}): InputFieldConfiguration => ({ +const createConfig = ( + overrides: Partial<InputFieldConfiguration> = {}, +): InputFieldConfiguration => ({ type: InputFieldType.textInput, variable: 'fieldA', label: 'Field A', @@ -30,7 +32,9 @@ const FieldHarness = ({ config, initialData = {} }: FieldHarnessProps) => { } const getVisibleText = (text: string) => { - const element = screen.getAllByText(text).find(element => !element.classList.contains('sr-only')) + const element = screen + .getAllByText(text) + .find((element) => !element.classList.contains('sr-only')) expect(element).toBeDefined() return element! } @@ -105,7 +109,10 @@ describe('InputField', () => { }) it('should render remaining field types and fallback for unsupported type', () => { - const scenarios: Array<{ config: InputFieldConfiguration, initialData: Record<string, unknown> }> = [ + const scenarios: Array<{ + config: InputFieldConfiguration + initialData: Record<string, unknown> + }> = [ { config: createConfig({ type: InputFieldType.numberInput, label: 'Count', min: 1, max: 5 }), initialData: { fieldA: 2 }, @@ -115,7 +122,11 @@ describe('InputField', () => { initialData: { fieldA: false }, }, { - config: createConfig({ type: InputFieldType.inputTypeSelect, label: 'Input Type', supportFile: true }), + config: createConfig({ + type: InputFieldType.inputTypeSelect, + label: 'Input Type', + supportFile: true, + }), initialData: { fieldA: 'text' }, }, { @@ -129,7 +140,9 @@ describe('InputField', () => { ] for (const scenario of scenarios) { - const { unmount } = render(<FieldHarness config={scenario.config} initialData={scenario.initialData} />) + const { unmount } = render( + <FieldHarness config={scenario.config} initialData={scenario.initialData} />, + ) expect(screen.getByText(scenario.config.label)).toBeInTheDocument() unmount() } diff --git a/web/app/components/base/form/form-scenarios/input-field/field.tsx b/web/app/components/base/form/form-scenarios/input-field/field.tsx index e82ae8f914f932..33edcc016614b3 100644 --- a/web/app/components/base/form/form-scenarios/input-field/field.tsx +++ b/web/app/components/base/form/form-scenarios/input-field/field.tsx @@ -9,215 +9,206 @@ type InputFieldProps = { config: InputFieldConfiguration } -const InputField = ({ - initialData, - config, -}: InputFieldProps) => withForm({ - defaultValues: initialData, - render: function Render({ - form, - }) { - const { - type, - label, - placeholder, - variable, - tooltip, - showConditions, - max, - min, - required, - showOptional, - supportFile, - description, - options, - listeners, - popupProps, - } = config +const InputField = ({ initialData, config }: InputFieldProps) => + withForm({ + defaultValues: initialData, + render: function Render({ form }) { + const { + type, + label, + placeholder, + variable, + tooltip, + showConditions, + max, + min, + required, + showOptional, + supportFile, + description, + options, + listeners, + popupProps, + } = config - const isAllConditionsMet = useStore(form.store, (state) => { - const fieldValues = state.values - return showConditions.every((condition) => { - const { variable, value } = condition - const fieldValue = fieldValues[variable as keyof typeof fieldValues] - return fieldValue === value + const isAllConditionsMet = useStore(form.store, (state) => { + const fieldValues = state.values + return showConditions.every((condition) => { + const { variable, value } = condition + const fieldValue = fieldValues[variable as keyof typeof fieldValues] + return fieldValue === value + }) }) - }) - if (!isAllConditionsMet) - return <></> + if (!isAllConditionsMet) return <></> - if (type === InputFieldType.textInput) { - return ( - <form.AppField - name={variable} - listeners={listeners} - children={field => ( - <field.TextField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - placeholder={placeholder} - /> - )} - /> - ) - } + if (type === InputFieldType.textInput) { + return ( + <form.AppField + name={variable} + listeners={listeners} + children={(field) => ( + <field.TextField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + placeholder={placeholder} + /> + )} + /> + ) + } - if (type === InputFieldType.numberInput) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.NumberInputField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - placeholder={placeholder} - max={max} - min={min} - /> - )} - /> - ) - } + if (type === InputFieldType.numberInput) { + return ( + <form.AppField + name={variable} + children={(field) => ( + <field.NumberInputField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + placeholder={placeholder} + max={max} + min={min} + /> + )} + /> + ) + } - if (type === InputFieldType.numberSlider) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.NumberSliderField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - description={description} - max={max} - min={min} - /> - )} - /> - ) - } + if (type === InputFieldType.numberSlider) { + return ( + <form.AppField + name={variable} + children={(field) => ( + <field.NumberSliderField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + description={description} + max={max} + min={min} + /> + )} + /> + ) + } - if (type === InputFieldType.checkbox) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.CheckboxField - label={label} - /> - )} - /> - ) - } + if (type === InputFieldType.checkbox) { + return ( + <form.AppField + name={variable} + children={(field) => <field.CheckboxField label={label} />} + /> + ) + } - if (type === InputFieldType.select) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.SelectField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - options={options!} - popupProps={popupProps} - /> - )} - /> - ) - } + if (type === InputFieldType.select) { + return ( + <form.AppField + name={variable} + children={(field) => ( + <field.SelectField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + options={options!} + popupProps={popupProps} + /> + )} + /> + ) + } - if (type === InputFieldType.inputTypeSelect) { - return ( - <form.AppField - name={variable} - listeners={listeners} - children={field => ( - <field.InputTypeSelectField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - supportFile={!!supportFile} - /> - )} - /> - ) - } + if (type === InputFieldType.inputTypeSelect) { + return ( + <form.AppField + name={variable} + listeners={listeners} + children={(field) => ( + <field.InputTypeSelectField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + supportFile={!!supportFile} + /> + )} + /> + ) + } - if (type === InputFieldType.uploadMethod) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.UploadMethodField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - /> - )} - /> - ) - } + if (type === InputFieldType.uploadMethod) { + return ( + <form.AppField + name={variable} + children={(field) => ( + <field.UploadMethodField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + /> + )} + /> + ) + } - if (type === InputFieldType.fileTypes) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.FileTypesField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - /> - )} - /> - ) - } + if (type === InputFieldType.fileTypes) { + return ( + <form.AppField + name={variable} + children={(field) => ( + <field.FileTypesField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + /> + )} + /> + ) + } - if (type === InputFieldType.options) { - return ( - <form.AppField - name={variable} - children={field => ( - <field.OptionsField - label={label} - labelOptions={{ - tooltip, - isRequired: required, - showOptional, - }} - /> - )} - /> - ) - } + if (type === InputFieldType.options) { + return ( + <form.AppField + name={variable} + children={(field) => ( + <field.OptionsField + label={label} + labelOptions={{ + tooltip, + isRequired: required, + showOptional, + }} + /> + )} + /> + ) + } - return <></> - }, -}) + return <></> + }, + }) export default InputField diff --git a/web/app/components/base/form/form-scenarios/input-field/types.ts b/web/app/components/base/form/form-scenarios/input-field/types.ts index e8832db4ba2979..0d6820b3971342 100644 --- a/web/app/components/base/form/form-scenarios/input-field/types.ts +++ b/web/app/components/base/form/form-scenarios/input-field/types.ts @@ -34,6 +34,7 @@ export type InputFieldConfiguration = { type: InputFieldType tooltip?: string // Tooltip for this field listeners?: FieldListeners<Record<string, any>, DeepKeys<Record<string, any>>> // Listener for this field -} & NumberConfiguration & Partial<InputTypeSelectConfiguration> -& Partial<NumberSliderConfiguration> -& Partial<SelectConfiguration> +} & NumberConfiguration & + Partial<InputTypeSelectConfiguration> & + Partial<NumberSliderConfiguration> & + Partial<SelectConfiguration> diff --git a/web/app/components/base/form/hooks/__tests__/use-check-validated.spec.ts b/web/app/components/base/form/hooks/__tests__/use-check-validated.spec.ts index 0bebab6a5b4f25..96f7aa0a83b404 100644 --- a/web/app/components/base/form/hooks/__tests__/use-check-validated.spec.ts +++ b/web/app/components/base/form/hooks/__tests__/use-check-validated.spec.ts @@ -37,31 +37,36 @@ describe('useCheckValidated', () => { it.each([ { fieldName: 'name', label: 'Name', message: 'Name is required' }, { fieldName: 'field1', label: 'Field 1', message: 'Field is required' }, - ])('should notify and return false when visible field has errors (show_on: []) for $fieldName', ({ fieldName, label, message }) => { - const form = { - getAllErrors: () => ({ - fields: { - [fieldName]: { errors: [message] }, + ])( + 'should notify and return false when visible field has errors (show_on: []) for $fieldName', + ({ fieldName, label, message }) => { + const form = { + getAllErrors: () => ({ + fields: { + [fieldName]: { errors: [message] }, + }, + }), + state: { values: {} }, + } + const schemas = [ + { + name: fieldName, + label, + required: true, + type: FormTypeEnum.textInput, + show_on: [], }, - }), - state: { values: {} }, - } - const schemas = [{ - name: fieldName, - label, - required: true, - type: FormTypeEnum.textInput, - show_on: [], - }] + ] - const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas)) + const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas)) - expect(result.current.checkValidated()).toBe(false) - expect(mockNotify).toHaveBeenCalledWith({ - type: 'error', - message, - }) - }) + expect(result.current.checkValidated()).toBe(false) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message, + }) + }, + ) it('should ignore hidden field errors and return true', () => { const form = { @@ -72,13 +77,15 @@ describe('useCheckValidated', () => { }), state: { values: { enabled: 'false' } }, } - const schemas = [{ - name: 'secret', - label: 'Secret', - required: true, - type: FormTypeEnum.textInput, - show_on: [{ variable: 'enabled', value: 'true' }], - }] + const schemas = [ + { + name: 'secret', + label: 'Secret', + required: true, + type: FormTypeEnum.textInput, + show_on: [{ variable: 'enabled', value: 'true' }], + }, + ] const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas)) @@ -95,13 +102,15 @@ describe('useCheckValidated', () => { }), state: { values: { enabled: 'true' } }, } - const schemas = [{ - name: 'secret', - label: 'Secret', - required: true, - type: FormTypeEnum.textInput, - show_on: [{ variable: 'enabled', value: 'true' }], - }] + const schemas = [ + { + name: 'secret', + label: 'Secret', + required: true, + type: FormTypeEnum.textInput, + show_on: [{ variable: 'enabled', value: 'true' }], + }, + ] const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas)) @@ -158,16 +167,18 @@ describe('useCheckValidated', () => { }), state: { values: { enabled: 'true', level: 'advanced' } }, } - const schemas = [{ - name: 'advancedOption', - label: 'Advanced Option', - required: true, - type: FormTypeEnum.textInput, - show_on: [ - { variable: 'enabled', value: 'true' }, - { variable: 'level', value: 'advanced' }, - ], - }] + const schemas = [ + { + name: 'advancedOption', + label: 'Advanced Option', + required: true, + type: FormTypeEnum.textInput, + show_on: [ + { variable: 'enabled', value: 'true' }, + { variable: 'level', value: 'advanced' }, + ], + }, + ] const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas)) @@ -187,16 +198,18 @@ describe('useCheckValidated', () => { }), state: { values: { enabled: 'true', level: 'basic' } }, } - const schemas = [{ - name: 'advancedOption', - label: 'Advanced Option', - required: true, - type: FormTypeEnum.textInput, - show_on: [ - { variable: 'enabled', value: 'true' }, - { variable: 'level', value: 'advanced' }, - ], - }] + const schemas = [ + { + name: 'advancedOption', + label: 'Advanced Option', + required: true, + type: FormTypeEnum.textInput, + show_on: [ + { variable: 'enabled', value: 'true' }, + { variable: 'level', value: 'advanced' }, + ], + }, + ] const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas)) @@ -213,13 +226,15 @@ describe('useCheckValidated', () => { }), state: { values: {} }, } - const schemas = [{ - name: 'knownField', - label: 'Known Field', - required: true, - type: FormTypeEnum.textInput, - show_on: [], - }] + const schemas = [ + { + name: 'knownField', + label: 'Known Field', + required: true, + type: FormTypeEnum.textInput, + show_on: [], + }, + ] const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas)) @@ -240,13 +255,15 @@ describe('useCheckValidated', () => { }), state: { values: {} }, } - const schemas = [{ - name: 'field1', - label: 'Field 1', - required: true, - type: FormTypeEnum.textInput, - show_on: [], - }] + const schemas = [ + { + name: 'field1', + label: 'Field 1', + required: true, + type: FormTypeEnum.textInput, + show_on: [], + }, + ] const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas)) @@ -299,13 +316,15 @@ describe('useCheckValidated', () => { }), state: { values: { threshold: '100' } }, } - const schemas = [{ - name: 'numericField', - label: 'Numeric Field', - required: true, - type: FormTypeEnum.textInput, - show_on: [{ variable: 'threshold', value: '100' }], - }] + const schemas = [ + { + name: 'numericField', + label: 'Numeric Field', + required: true, + type: FormTypeEnum.textInput, + show_on: [{ variable: 'threshold', value: '100' }], + }, + ] const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas)) diff --git a/web/app/components/base/form/hooks/__tests__/use-get-form-values.spec.ts b/web/app/components/base/form/hooks/__tests__/use-get-form-values.spec.ts index 2f0300a79445bf..4ee3b76be08986 100644 --- a/web/app/components/base/form/hooks/__tests__/use-get-form-values.spec.ts +++ b/web/app/components/base/form/hooks/__tests__/use-get-form-values.spec.ts @@ -38,21 +38,25 @@ describe('useGetFormValues', () => { const form = { store: { state: { values: { password: 'abc123' } } }, } - const schemas = [{ - name: 'password', - label: 'Password', - required: true, - type: FormTypeEnum.secretInput, - }] + const schemas = [ + { + name: 'password', + label: 'Password', + required: true, + type: FormTypeEnum.secretInput, + }, + ] mockCheckValidated.mockReturnValue(true) mockTransform.mockReturnValue({ password: '[__HIDDEN__]' }) const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, schemas)) - expect(result.current.getFormValues({ - needCheckValidatedValues: true, - needTransformWhenSecretFieldIsPristine: true, - })).toEqual({ + expect( + result.current.getFormValues({ + needCheckValidatedValues: true, + needTransformWhenSecretFieldIsPristine: true, + }), + ).toEqual({ values: { password: '[__HIDDEN__]' }, isCheckValidated: true, }) @@ -76,20 +80,24 @@ describe('useGetFormValues', () => { const form = { store: { state: { values: { email: 'test@example.com' } } }, } - const schemas = [{ - name: 'email', - label: 'Email', - required: true, - type: FormTypeEnum.textInput, - }] + const schemas = [ + { + name: 'email', + label: 'Email', + required: true, + type: FormTypeEnum.textInput, + }, + ] mockCheckValidated.mockReturnValue(true) const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, schemas)) - expect(result.current.getFormValues({ - needCheckValidatedValues: true, - needTransformWhenSecretFieldIsPristine: false, - })).toEqual({ + expect( + result.current.getFormValues({ + needCheckValidatedValues: true, + needTransformWhenSecretFieldIsPristine: false, + }), + ).toEqual({ values: { email: 'test@example.com' }, isCheckValidated: true, }) @@ -100,20 +108,24 @@ describe('useGetFormValues', () => { const form = { store: { state: { values: { username: 'john_doe' } } }, } - const schemas = [{ - name: 'username', - label: 'Username', - required: true, - type: FormTypeEnum.textInput, - }] + const schemas = [ + { + name: 'username', + label: 'Username', + required: true, + type: FormTypeEnum.textInput, + }, + ] mockCheckValidated.mockReturnValue(true) const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, schemas)) - expect(result.current.getFormValues({ - needCheckValidatedValues: true, - needTransformWhenSecretFieldIsPristine: undefined, - })).toEqual({ + expect( + result.current.getFormValues({ + needCheckValidatedValues: true, + needTransformWhenSecretFieldIsPristine: undefined, + }), + ).toEqual({ values: { username: 'john_doe' }, isCheckValidated: true, }) @@ -151,12 +163,14 @@ describe('useGetFormValues', () => { const form = { store: { state: { values: { password: 'secret' } } }, } - const schemas = [{ - name: 'password', - label: 'Password', - required: true, - type: FormTypeEnum.secretInput, - }] + const schemas = [ + { + name: 'password', + label: 'Password', + required: true, + type: FormTypeEnum.secretInput, + }, + ] mockCheckValidated.mockReturnValue(true) mockTransform.mockReturnValue({ password: 'encrypted' }) @@ -174,20 +188,24 @@ describe('useGetFormValues', () => { const form = { store: { state: { values: { password: 'secret' } } }, } - const schemas = [{ - name: 'password', - label: 'Password', - required: true, - type: FormTypeEnum.secretInput, - }] + const schemas = [ + { + name: 'password', + label: 'Password', + required: true, + type: FormTypeEnum.secretInput, + }, + ] mockCheckValidated.mockReturnValue(false) const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, schemas)) - expect(result.current.getFormValues({ - needCheckValidatedValues: true, - needTransformWhenSecretFieldIsPristine: true, - })).toEqual({ + expect( + result.current.getFormValues({ + needCheckValidatedValues: true, + needTransformWhenSecretFieldIsPristine: true, + }), + ).toEqual({ values: {}, isCheckValidated: false, }) diff --git a/web/app/components/base/form/hooks/use-check-validated.ts b/web/app/components/base/form/hooks/use-check-validated.ts index f7d81263efc0b3..6aa59df1126c0c 100644 --- a/web/app/components/base/form/hooks/use-check-validated.ts +++ b/web/app/components/base/form/hooks/use-check-validated.ts @@ -10,12 +10,15 @@ export const useCheckValidated = (form: AnyFormApi, FormSchemas: FormSchema[]) = if (allError) { const fields = allError.fields const errorArray = Object.keys(fields).reduce((acc: string[], key: string) => { - const currentSchema = FormSchemas.find(schema => schema.name === key) + const currentSchema = FormSchemas.find((schema) => schema.name === key) const { show_on = [] } = currentSchema || {} - const showOnValues = show_on.reduce((acc, condition) => { - acc[condition.variable] = values[condition.variable] - return acc - }, {} as Record<string, any>) + const showOnValues = show_on.reduce( + (acc, condition) => { + acc[condition.variable] = values[condition.variable] + return acc + }, + {} as Record<string, any>, + ) const show = show_on?.every((condition) => { const conditionValue = showOnValues[condition.variable] return conditionValue === condition.value diff --git a/web/app/components/base/form/hooks/use-get-form-values.ts b/web/app/components/base/form/hooks/use-get-form-values.ts index 3dd2eceb30aa9b..6fca75f7ff14d5 100644 --- a/web/app/components/base/form/hooks/use-get-form-values.ts +++ b/web/app/components/base/form/hooks/use-get-form-values.ts @@ -1,8 +1,5 @@ import type { AnyFormApi } from '@tanstack/react-form' -import type { - FormSchema, - GetValuesOptions, -} from '../types' +import type { FormSchema, GetValuesOptions } from '../types' import { useCallback } from 'react' import { getTransformedValuesWhenSecretInputPristine } from '../utils/secret-input' import { useCheckValidated } from './use-check-validated' @@ -10,33 +7,35 @@ import { useCheckValidated } from './use-check-validated' export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) => { const { checkValidated } = useCheckValidated(form, formSchemas) - const getFormValues = useCallback(( - { + const getFormValues = useCallback( + ({ needCheckValidatedValues = true, needTransformWhenSecretFieldIsPristine, - }: GetValuesOptions, - ) => { - const values = form?.store.state.values || {} - if (!needCheckValidatedValues) { - return { - values, - isCheckValidated: true, + }: GetValuesOptions) => { + const values = form?.store.state.values || {} + if (!needCheckValidatedValues) { + return { + values, + isCheckValidated: true, + } } - } - if (checkValidated()) { - return { - values: needTransformWhenSecretFieldIsPristine ? getTransformedValuesWhenSecretInputPristine(formSchemas, form) : values, - isCheckValidated: true, + if (checkValidated()) { + return { + values: needTransformWhenSecretFieldIsPristine + ? getTransformedValuesWhenSecretInputPristine(formSchemas, form) + : values, + isCheckValidated: true, + } + } else { + return { + values: {}, + isCheckValidated: false, + } } - } - else { - return { - values: {}, - isCheckValidated: false, - } - } - }, [form, checkValidated, formSchemas]) + }, + [form, checkValidated, formSchemas], + ) return { getFormValues, diff --git a/web/app/components/base/form/hooks/use-get-validators.ts b/web/app/components/base/form/hooks/use-get-validators.ts index 62d0a0579e53dc..3d1fa230546f16 100644 --- a/web/app/components/base/form/hooks/use-get-validators.ts +++ b/web/app/components/base/form/hooks/use-get-validators.ts @@ -1,9 +1,6 @@ import type { ReactNode } from 'react' import type { FormSchema } from '../types' -import { - isValidElement, - useCallback, -} from 'react' +import { isValidElement, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useRenderI18nObject } from '@/hooks/use-i18n' @@ -11,42 +8,44 @@ export const useGetValidators = () => { const { t } = useTranslation() const renderI18nObject = useRenderI18nObject() const getLabel = useCallback((label: string | Record<string, string> | ReactNode) => { - if (isValidElement(label)) - return '' + if (isValidElement(label)) return '' - if (typeof label === 'string') - return label + if (typeof label === 'string') return label if (typeof label === 'object' && label !== null) return renderI18nObject(label as Record<string, string>) }, []) - const getValidators = useCallback((formSchema: FormSchema) => { - const { - name, - validators, - required, - label, - } = formSchema - let mergedValidators = validators - const memorizedLabel = getLabel(label) - if (required && !validators) { - mergedValidators = { - onMount: ({ value }: any) => { - if (!value) - return t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: memorizedLabel || name }) - }, - onChange: ({ value }: any) => { - if (!value) - return t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: memorizedLabel || name }) - }, - onBlur: ({ value }: any) => { - if (!value) - return t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: memorizedLabel }) - }, + const getValidators = useCallback( + (formSchema: FormSchema) => { + const { name, validators, required, label } = formSchema + let mergedValidators = validators + const memorizedLabel = getLabel(label) + if (required && !validators) { + mergedValidators = { + onMount: ({ value }: any) => { + if (!value) + return t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: memorizedLabel || name, + }) + }, + onChange: ({ value }: any) => { + if (!value) + return t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: memorizedLabel || name, + }) + }, + onBlur: ({ value }: any) => { + if (!value) + return t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: memorizedLabel }) + }, + } } - } - return mergedValidators - }, [t, getLabel]) + return mergedValidators + }, + [t, getLabel], + ) return { getValidators, diff --git a/web/app/components/base/form/index.tsx b/web/app/components/base/form/index.tsx index 46dbf06b7c12fd..1107a756d5cf72 100644 --- a/web/app/components/base/form/index.tsx +++ b/web/app/components/base/form/index.tsx @@ -13,8 +13,7 @@ import UploadMethodField from './components/field/upload-method' import VariableOrConstantInputField from './components/field/variable-selector' import Actions from './components/form/actions' -const { fieldContext, useFieldContext, formContext, useFormContext } - = createFormHookContexts() +const { fieldContext, useFieldContext, formContext, useFormContext } = createFormHookContexts() export { formContext, useFieldContext, useFormContext } diff --git a/web/app/components/base/form/types.ts b/web/app/components/base/form/types.ts index f0fd92925006e9..8a1742074a2909 100644 --- a/web/app/components/base/form/types.ts +++ b/web/app/components/base/form/types.ts @@ -1,11 +1,5 @@ -import type { - AnyFormApi, - FieldValidators, -} from '@tanstack/react-form' -import type { - ForwardedRef, - ReactNode, -} from 'react' +import type { AnyFormApi, FieldValidators } from '@tanstack/react-form' +import type { ForwardedRef, ReactNode } from 'react' import type { Locale } from '@/i18n-config' export type TypeWithI18N<T = string> = { diff --git a/web/app/components/base/form/utils/__tests__/zod-submit-validator.spec.ts b/web/app/components/base/form/utils/__tests__/zod-submit-validator.spec.ts index 4e828dada1bf62..6ab082b9c9936f 100644 --- a/web/app/components/base/form/utils/__tests__/zod-submit-validator.spec.ts +++ b/web/app/components/base/form/utils/__tests__/zod-submit-validator.spec.ts @@ -3,18 +3,22 @@ import { zodSubmitValidator } from '../zod-submit-validator' describe('zodSubmitValidator', () => { it('should return undefined for valid values', () => { - const validator = zodSubmitValidator(z.object({ - name: z.string().min(2), - })) + const validator = zodSubmitValidator( + z.object({ + name: z.string().min(2), + }), + ) expect(validator({ value: { name: 'Alice' } })).toBeUndefined() }) it('should return first error message per field for invalid values', () => { - const validator = zodSubmitValidator(z.object({ - name: z.string().min(3, 'Name too short'), - age: z.number().min(18, 'Must be adult'), - })) + const validator = zodSubmitValidator( + z.object({ + name: z.string().min(3, 'Name too short'), + age: z.number().min(18, 'Must be adult'), + }), + ) expect(validator({ value: { name: 'Al', age: 15 } })).toEqual({ fields: { diff --git a/web/app/components/base/form/utils/secret-input/__tests__/index.spec.ts b/web/app/components/base/form/utils/secret-input/__tests__/index.spec.ts index c19c92ca213cb1..6af2f5056708f6 100644 --- a/web/app/components/base/form/utils/secret-input/__tests__/index.spec.ts +++ b/web/app/components/base/form/utils/secret-input/__tests__/index.spec.ts @@ -1,14 +1,19 @@ import type { AnyFormApi } from '@tanstack/react-form' import { FormTypeEnum } from '../../../types' -import { getTransformedValuesWhenSecretInputPristine, transformFormSchemasSecretInput } from '../index' +import { + getTransformedValuesWhenSecretInputPristine, + transformFormSchemasSecretInput, +} from '../index' describe('secret input utilities', () => { it('should mask only selected truthy values in transformFormSchemasSecretInput', () => { - expect(transformFormSchemasSecretInput(['apiKey'], { - apiKey: 'secret', - token: 'token-value', - emptyValue: '', - })).toEqual({ + expect( + transformFormSchemasSecretInput(['apiKey'], { + apiKey: 'secret', + token: 'token-value', + emptyValue: '', + }), + ).toEqual({ apiKey: '[__HIDDEN__]', token: 'token-value', emptyValue: '', @@ -32,7 +37,9 @@ describe('secret input utilities', () => { getFieldMeta: (name: string) => ({ isPristine: name === 'apiKey' }), } - expect(getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi)).toEqual({ + expect( + getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi), + ).toEqual({ apiKey: '[__HIDDEN__]', name: 'Alice', }) @@ -47,24 +54,30 @@ describe('secret input utilities', () => { getFieldMeta: () => ({ isPristine: false }), } - expect(getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi)).toEqual({ + expect( + getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi), + ).toEqual({ apiKey: 'secret', }) }) it('should not mask when secret name is not in the values object', () => { - expect(transformFormSchemasSecretInput(['missing'], { - apiKey: 'secret', - })).toEqual({ + expect( + transformFormSchemasSecretInput(['missing'], { + apiKey: 'secret', + }), + ).toEqual({ apiKey: 'secret', }) }) it('should not mask falsy values like 0 or null', () => { - expect(transformFormSchemasSecretInput(['zeroVal', 'nullVal'], { - zeroVal: 0, - nullVal: null, - })).toEqual({ + expect( + transformFormSchemasSecretInput(['zeroVal', 'nullVal'], { + zeroVal: 0, + nullVal: null, + }), + ).toEqual({ zeroVal: 0, nullVal: null, }) @@ -79,7 +92,9 @@ describe('secret input utilities', () => { getFieldMeta: () => ({ isPristine: true }), } - expect(getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi)).toEqual({}) + expect( + getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi), + ).toEqual({}) }) it('should handle fieldMeta being undefined', () => { @@ -91,7 +106,9 @@ describe('secret input utilities', () => { getFieldMeta: () => undefined, } - expect(getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi)).toEqual({ + expect( + getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi), + ).toEqual({ apiKey: 'secret', }) }) @@ -106,7 +123,9 @@ describe('secret input utilities', () => { getFieldMeta: () => ({ isPristine: true }), } - expect(getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi)).toEqual({ + expect( + getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi), + ).toEqual({ name: 'Alice', desc: 'Test', }) diff --git a/web/app/components/base/form/utils/secret-input/index.ts b/web/app/components/base/form/utils/secret-input/index.ts index ba0eafce27d084..7cb2d2a02735c2 100644 --- a/web/app/components/base/form/utils/secret-input/index.ts +++ b/web/app/components/base/form/utils/secret-input/index.ts @@ -2,26 +2,30 @@ import type { AnyFormApi } from '@tanstack/react-form' import type { FormSchema } from '@/app/components/base/form/types' import { FormTypeEnum } from '@/app/components/base/form/types' -export const transformFormSchemasSecretInput = (isPristineSecretInputNames: string[], values: Record<string, any>) => { +export const transformFormSchemasSecretInput = ( + isPristineSecretInputNames: string[], + values: Record<string, any>, +) => { const transformedValues: Record<string, any> = { ...values } isPristineSecretInputNames.forEach((name) => { - if (transformedValues[name]) - transformedValues[name] = '[__HIDDEN__]' + if (transformedValues[name]) transformedValues[name] = '[__HIDDEN__]' }) return transformedValues } -export const getTransformedValuesWhenSecretInputPristine = (formSchemas: FormSchema[], form: AnyFormApi) => { +export const getTransformedValuesWhenSecretInputPristine = ( + formSchemas: FormSchema[], + form: AnyFormApi, +) => { const values = form?.store.state.values || {} const isPristineSecretInputNames: string[] = [] for (let i = 0; i < formSchemas.length; i++) { const schema = formSchemas[i] if (schema!.type === FormTypeEnum.secretInput) { const fieldMeta = form?.getFieldMeta(schema!.name) - if (fieldMeta?.isPristine) - isPristineSecretInputNames.push(schema!.name) + if (fieldMeta?.isPristine) isPristineSecretInputNames.push(schema!.name) } } diff --git a/web/app/components/base/form/utils/zod-submit-validator.ts b/web/app/components/base/form/utils/zod-submit-validator.ts index 23eacaf8a43f4d..247702e838a83a 100644 --- a/web/app/components/base/form/utils/zod-submit-validator.ts +++ b/web/app/components/base/form/utils/zod-submit-validator.ts @@ -1,6 +1,10 @@ import type { ZodSchema } from 'zod' -type SubmitValidator<T> = ({ value }: { value: T }) => { fields: Record<string, string> } | undefined +type SubmitValidator<T> = ({ + value, +}: { + value: T +}) => { fields: Record<string, string> } | undefined export const zodSubmitValidator = <T>(schema: ZodSchema<T>): SubmitValidator<T> => { return ({ value }) => { @@ -9,11 +13,9 @@ export const zodSubmitValidator = <T>(schema: ZodSchema<T>): SubmitValidator<T> const fieldErrors: Record<string, string> = {} for (const issue of result.error.issues) { const path = issue.path[0] - if (path === undefined) - continue + if (path === undefined) continue const key = String(path) - if (!fieldErrors[key]) - fieldErrors[key] = issue.message + if (!fieldErrors[key]) fieldErrors[key] = issue.message } return { fields: fieldErrors } } diff --git a/web/app/components/base/ga/__tests__/index.spec.tsx b/web/app/components/base/ga/__tests__/index.spec.tsx index 4ce9677a7af6a3..59d5b007631754 100644 --- a/web/app/components/base/ga/__tests__/index.spec.tsx +++ b/web/app/components/base/ga/__tests__/index.spec.tsx @@ -11,10 +11,10 @@ type GoogleAnalyticsScriptsRenderFn = () => Promise<ReactNode> const { mockHeaders, mockHeadersGet, configState } = vi.hoisted(() => ({ mockHeaders: vi.fn(), mockHeadersGet: vi.fn(), - configState: ({ + configState: { isCloudEdition: true, isProd: true, - }) as ConfigState, + } as ConfigState, })) vi.mock('@/config', () => ({ @@ -66,8 +66,7 @@ const loadComponent = async () => { const renderGoogleAnalyticsScripts = async () => { const { renderer } = await loadComponent() const element = await renderer() - if (!element) - return { element } + if (!element) return { element } render(element as ReactElement) return { element } @@ -81,7 +80,7 @@ describe('GA', () => { configState.isCloudEdition = true configState.isProd = true - mockHeadersGet.mockImplementation((name: string) => name === 'x-nonce' ? 'test-nonce' : null) + mockHeadersGet.mockImplementation((name: string) => (name === 'x-nonce' ? 'test-nonce' : null)) mockHeaders.mockResolvedValue({ get: mockHeadersGet, }) @@ -115,20 +114,35 @@ describe('GA', () => { expect(scripts[0]).toHaveAttribute('data-id', 'google-consent-defaults') expect(scripts[0]).toHaveAttribute('data-strategy', 'afterInteractive') - expect(scripts[0]).toHaveAttribute('data-inline', expect.stringContaining(`window.gtag('consent', 'default'`)) - expect(scripts[0]).toHaveAttribute('data-inline', expect.stringContaining(`analytics_storage: 'denied'`)) + expect(scripts[0]).toHaveAttribute( + 'data-inline', + expect.stringContaining(`window.gtag('consent', 'default'`), + ) + expect(scripts[0]).toHaveAttribute( + 'data-inline', + expect.stringContaining(`analytics_storage: 'denied'`), + ) expect(scripts[1]).toHaveAttribute('data-id', 'cookieyes') expect(scripts[1]).toHaveAttribute('data-strategy', 'afterInteractive') - expect(scripts[1]).toHaveAttribute('data-src', 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js') + expect(scripts[1]).toHaveAttribute( + 'data-src', + 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js', + ) expect(scripts[2]).toHaveAttribute('data-id', 'google-analytics') expect(scripts[2]).toHaveAttribute('data-strategy', 'afterInteractive') - expect(scripts[2]).toHaveAttribute('data-src', 'https://www.googletagmanager.com/gtag/js?id=G-DM9497FN4V') + expect(scripts[2]).toHaveAttribute( + 'data-src', + 'https://www.googletagmanager.com/gtag/js?id=G-DM9497FN4V', + ) expect(scripts[3]).toHaveAttribute('data-id', 'google-analytics-init') expect(scripts[3]).toHaveAttribute('data-strategy', 'afterInteractive') - expect(scripts[3]).toHaveAttribute('data-inline', expect.stringContaining(`window.gtag('config', 'G-DM9497FN4V');`)) + expect(scripts[3]).toHaveAttribute( + 'data-inline', + expect.stringContaining(`window.gtag('config', 'G-DM9497FN4V');`), + ) scripts.forEach((script) => { expect(script).toHaveAttribute('data-nonce', 'test-nonce') diff --git a/web/app/components/base/ga/index.tsx b/web/app/components/base/ga/index.tsx index 2626306bad9a04..81f77982e45d7c 100644 --- a/web/app/components/base/ga/index.tsx +++ b/web/app/components/base/ga/index.tsx @@ -4,21 +4,17 @@ import Script from '@/next/script' const GOOGLE_ANALYTICS_ID = 'G-DM9497FN4V' const GOOGLE_TAG_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GOOGLE_ANALYTICS_ID}` -const COOKIEYES_SCRIPT_SRC = 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js' +const COOKIEYES_SCRIPT_SRC = + 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js' export async function GoogleAnalyticsScripts() { - if (!IS_CLOUD_EDITION || !IS_PROD) - return null + if (!IS_CLOUD_EDITION || !IS_PROD) return null const nonce = (await headers()).get('x-nonce') ?? undefined return ( <> - <Script - id="google-consent-defaults" - strategy="afterInteractive" - nonce={nonce} - > + <Script id="google-consent-defaults" strategy="afterInteractive" nonce={nonce}> {` window.dataLayer = window.dataLayer || []; window.gtag = window.gtag || function gtag(){window.dataLayer.push(arguments);}; @@ -30,23 +26,14 @@ export async function GoogleAnalyticsScripts() { }); `} </Script> - <Script - id="cookieyes" - strategy="afterInteractive" - src={COOKIEYES_SCRIPT_SRC} - nonce={nonce} - /> + <Script id="cookieyes" strategy="afterInteractive" src={COOKIEYES_SCRIPT_SRC} nonce={nonce} /> <Script id="google-analytics" strategy="afterInteractive" src={GOOGLE_TAG_SCRIPT_SRC} nonce={nonce} /> - <Script - id="google-analytics-init" - strategy="afterInteractive" - nonce={nonce} - > + <Script id="google-analytics-init" strategy="afterInteractive" nonce={nonce}> {` window.gtag('js', new Date()); window.gtag('config', '${GOOGLE_ANALYTICS_ID}'); diff --git a/web/app/components/base/grid-mask/__tests__/index.spec.tsx b/web/app/components/base/grid-mask/__tests__/index.spec.tsx index 267e454ef058b4..4ed32cc84422fe 100644 --- a/web/app/components/base/grid-mask/__tests__/index.spec.tsx +++ b/web/app/components/base/grid-mask/__tests__/index.spec.tsx @@ -2,7 +2,10 @@ import { render, screen } from '@testing-library/react' import GridMask from '../index' import Style from '../style.module.css' -function renderGridMask(props: Partial<React.ComponentProps<typeof GridMask>> = {}, children: React.ReactNode = <span>Child</span>) { +function renderGridMask( + props: Partial<React.ComponentProps<typeof GridMask>> = {}, + children: React.ReactNode = <span>Child</span>, +) { const { container } = render(<GridMask {...props}>{children}</GridMask>) const wrapper = container.firstElementChild as HTMLElement const canvasLayer = wrapper.children[0] as HTMLElement @@ -19,7 +22,10 @@ describe('GridMask', () => { }) it('should render correctly without optional className props', () => { - const { wrapper, canvasLayer, gradientLayer, contentLayer } = renderGridMask({}, <span>Plain child</span>) + const { wrapper, canvasLayer, gradientLayer, contentLayer } = renderGridMask( + {}, + <span>Plain child</span>, + ) expect(wrapper)!.toHaveClass('bg-saas-background') expect(canvasLayer)!.toHaveClass('absolute') @@ -28,7 +34,10 @@ describe('GridMask', () => { }) it('should render wrapper, canvas, gradient and content layers in order', () => { - const { wrapper, canvasLayer, gradientLayer, contentLayer } = renderGridMask({}, <span>Content</span>) + const { wrapper, canvasLayer, gradientLayer, contentLayer } = renderGridMask( + {}, + <span>Content</span>, + ) expect(wrapper)!.toBeInTheDocument() expect(wrapper.children).toHaveLength(3) expect(canvasLayer)!.toHaveClass('z-0') @@ -46,14 +55,20 @@ describe('GridMask', () => { }) it('should apply canvasClassName and grid background class to canvas layer', () => { - const { canvasLayer } = renderGridMask({ canvasClassName: 'custom-canvas' }, <span>Child</span>) + const { canvasLayer } = renderGridMask( + { canvasClassName: 'custom-canvas' }, + <span>Child</span>, + ) expect(canvasLayer)!.toHaveClass('custom-canvas') expect(canvasLayer)!.toHaveClass(Style.gridBg!) }) it('should apply gradientClassName to gradient layer', () => { - const { gradientLayer } = renderGridMask({ gradientClassName: 'custom-gradient' }, <span>Child</span>) + const { gradientLayer } = renderGridMask( + { gradientClassName: 'custom-gradient' }, + <span>Child</span>, + ) expect(gradientLayer)!.toHaveClass('custom-gradient') expect(gradientLayer)!.toHaveClass('bg-grid-mask-background') diff --git a/web/app/components/base/grid-mask/index.stories.tsx b/web/app/components/base/grid-mask/index.stories.tsx index 13477b97ca5793..af5327de4aff9a 100644 --- a/web/app/components/base/grid-mask/index.stories.tsx +++ b/web/app/components/base/grid-mask/index.stories.tsx @@ -8,7 +8,8 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Displays a soft grid overlay with gradient mask, useful for framing hero sections or marketing callouts.', + component: + 'Displays a soft grid overlay with gradient mask, useful for framing hero sections or marketing callouts.', }, }, }, @@ -19,9 +20,12 @@ const meta = { children: ( <div className="relative z-10 flex flex-col gap-3 text-left text-white"> <span className="text-xs tracking-[0.16em] text-white/70 uppercase">Grid Mask Demo</span> - <span className="text-2xl/tight font-semibold">Beautiful backgrounds for feature highlights</span> + <span className="text-2xl/tight font-semibold"> + Beautiful backgrounds for feature highlights + </span> <p className="max-w-md text-sm text-white/80"> - Place any content inside the mask. On dark backgrounds the grid and soft gradient add depth without distracting from the main message. + Place any content inside the mask. On dark backgrounds the grid and soft gradient add + depth without distracting from the main message. </p> </div> ), @@ -43,7 +47,8 @@ export const CustomBackground: Story = { <span className="text-sm font-medium text-white/80">Custom gradient</span> <span className="text-3xl/tight font-semibold">Use your own colors</span> <p className="max-w-md text-sm text-white/70"> - Override gradient and canvas classes to match brand palettes while keeping the grid texture. + Override gradient and canvas classes to match brand palettes while keeping the grid + texture. </p> </div> ), diff --git a/web/app/components/base/grid-mask/index.tsx b/web/app/components/base/grid-mask/index.tsx index 73ffc2b017dcee..c5211d48e2b69d 100644 --- a/web/app/components/base/grid-mask/index.tsx +++ b/web/app/components/base/grid-mask/index.tsx @@ -16,8 +16,15 @@ const GridMask: FC<GridMaskProps> = ({ }) => { return ( <div className={cn('relative bg-saas-background', wrapperClassName)}> - <div className={cn('absolute inset-0 z-0 size-full opacity-70', canvasClassName, Style.gridBg)} /> - <div className={cn('absolute z-1 size-full rounded-lg bg-grid-mask-background', gradientClassName)} /> + <div + className={cn('absolute inset-0 z-0 size-full opacity-70', canvasClassName, Style.gridBg)} + /> + <div + className={cn( + 'absolute z-1 size-full rounded-lg bg-grid-mask-background', + gradientClassName, + )} + /> <div className="relative z-2">{children}</div> </div> ) diff --git a/web/app/components/base/grid-mask/style.module.css b/web/app/components/base/grid-mask/style.module.css index e051271fab92f1..3442a6a5fc7a1e 100644 --- a/web/app/components/base/grid-mask/style.module.css +++ b/web/app/components/base/grid-mask/style.module.css @@ -1,4 +1,4 @@ -.gridBg{ +.gridBg { background-image: url(./Grid.svg); background-repeat: repeat; background-position: 0 0; diff --git a/web/app/components/base/icons/IconBase.tsx b/web/app/components/base/icons/IconBase.tsx index 2de0f3239ed3f3..3c9e04dabbce16 100644 --- a/web/app/components/base/icons/IconBase.tsx +++ b/web/app/components/base/icons/IconBase.tsx @@ -13,14 +13,12 @@ type IconBaseProps = { style?: React.CSSProperties } -const IconBase = ( - { - ref, - ...props - }: IconBaseProps & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => { +const IconBase = ({ + ref, + ...props +}: IconBaseProps & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => { const { data, className, onClick, style, ...restProps } = props return generate(data.icon, `svg-${data.name}`, { @@ -30,7 +28,7 @@ const IconBase = ( 'data-icon': data.name, 'aria-hidden': 'true', ...restProps, - 'ref': ref, + ref, }) } diff --git a/web/app/components/base/icons/__tests__/IconBase.spec.tsx b/web/app/components/base/icons/__tests__/IconBase.spec.tsx index 81aa3fcbdf69eb..1f72c47b3a5e36 100644 --- a/web/app/components/base/icons/__tests__/IconBase.spec.tsx +++ b/web/app/components/base/icons/__tests__/IconBase.spec.tsx @@ -7,11 +7,7 @@ import * as utils from '../utils' // Mock the utils module vi.mock('../utils', () => ({ generate: vi.fn((icon, key, props) => ( - <svg - data-testid={key} - key={key} - {...props} - > + <svg data-testid={key} key={key} {...props}> mocked svg content </svg> )), diff --git a/web/app/components/base/icons/__tests__/utils.spec.ts b/web/app/components/base/icons/__tests__/utils.spec.ts index f8534038bfab4b..fca97acec8816d 100644 --- a/web/app/components/base/icons/__tests__/utils.spec.ts +++ b/web/app/components/base/icons/__tests__/utils.spec.ts @@ -33,8 +33,8 @@ describe('generate icon base utils', () => { 'xmlns-inkscape': 'http...', 'xmlns-sodipodi': 'http...', 'xmlns-svg': 'http...', - 'dataName': 'Layer 1', - 'valid': 'value', + dataName: 'Layer 1', + valid: 'value', } expect(normalizeAttrs(attrs)).toEqual({ valid: 'value' }) }) diff --git a/web/app/components/base/icons/icon-gallery.stories.tsx b/web/app/components/base/icons/icon-gallery.stories.tsx index d91e5672c3a3b3..28a00c7e0bd41f 100644 --- a/web/app/components/base/icons/icon-gallery.stories.tsx +++ b/web/app/components/base/icons/icon-gallery.stories.tsx @@ -18,8 +18,7 @@ const iconEntries: IconEntry[] = Object.entries(iconModules) .filter(([key]) => !key.endsWith('.stories.tsx') && !key.endsWith('.spec.tsx')) .map(([key, mod]) => { const Component = mod.default - if (!Component) - return null + if (!Component) return null const relativePath = key.replace(/^\.\/src\//, '') const path = `app/components/base/icons/src/${relativePath}` @@ -38,30 +37,32 @@ const iconEntries: IconEntry[] = Object.entries(iconModules) .filter(Boolean) as IconEntry[] const sortedEntries = [...iconEntries].sort((a, b) => { - if (a.category === b.category) - return a.name.localeCompare(b.name) + if (a.category === b.category) return a.name.localeCompare(b.name) return a.category.localeCompare(b.category) }) const filterEntries = (entries: IconEntry[], query: string) => { const normalized = query.trim().toLowerCase() - if (!normalized) - return entries + if (!normalized) return entries - return entries.filter(entry => - entry.name.toLowerCase().includes(normalized) - || entry.path.toLowerCase().includes(normalized) - || entry.category.toLowerCase().includes(normalized), + return entries.filter( + (entry) => + entry.name.toLowerCase().includes(normalized) || + entry.path.toLowerCase().includes(normalized) || + entry.category.toLowerCase().includes(normalized), ) } -const groupByCategory = (entries: IconEntry[]) => entries.reduce((acc, entry) => { - if (!acc[entry.category]) - acc[entry.category] = [] +const groupByCategory = (entries: IconEntry[]) => + entries.reduce( + (acc, entry) => { + if (!acc[entry.category]) acc[entry.category] = [] - acc[entry.category]!.push(entry) - return acc -}, {} as Record<string, IconEntry[]>) + acc[entry.category]!.push(entry) + return acc + }, + {} as Record<string, IconEntry[]>, + ) const containerStyle: React.CSSProperties = { padding: 24, @@ -158,8 +159,7 @@ const IconGalleryStory = () => { ) React.useEffect(() => { - if (!copiedPath) - return undefined + if (!copiedPath) return undefined const timerId = window.setTimeout(() => { setCopiedPath(null) @@ -169,7 +169,8 @@ const IconGalleryStory = () => { }, [copiedPath]) const handleCopy = React.useCallback((text: string) => { - navigator.clipboard?.writeText(text) + navigator.clipboard + ?.writeText(text) .then(() => { setCopiedPath(text) }) @@ -183,45 +184,34 @@ const IconGalleryStory = () => { <header style={headerStyle}> <h1 style={{ margin: 0 }}>Icon Gallery</h1> <p style={{ margin: 0, color: '#5f5f66' }}> - Browse all icon components sourced from - {' '} - <code>app/components/base/icons/src</code> - . Use the search bar - to filter by name or path. + Browse all icon components sourced from <code>app/components/base/icons/src</code>. Use + the search bar to filter by name or path. </p> <div style={controlsStyle}> <input style={searchInputStyle} placeholder="Search icons" value={query} - onChange={event => setQuery(event.target.value)} + onChange={(event) => setQuery(event.target.value)} /> - <span style={{ color: '#5f5f66' }}> - {filtered.length} - {' '} - icons - </span> + <span style={{ color: '#5f5f66' }}>{filtered.length} icons</span> <button type="button" - onClick={() => setPreviewTheme(prev => (prev === 'light' ? 'dark' : 'light'))} + onClick={() => setPreviewTheme((prev) => (prev === 'light' ? 'dark' : 'light'))} style={toggleButtonStyle} > - Toggle - {' '} - {previewTheme === 'light' ? 'dark' : 'light'} - {' '} - preview + Toggle {previewTheme === 'light' ? 'dark' : 'light'} preview </button> </div> </header> {categoryOrder.length === 0 && ( <p style={emptyTextStyle}>No icons match the current filter.</p> )} - {categoryOrder.map(category => ( + {categoryOrder.map((category) => ( <section key={category} style={sectionStyle}> <h2 style={{ margin: 0, fontSize: 18 }}>{category}</h2> <div style={gridStyle}> - {grouped[category]!.map(entry => ( + {grouped[category]!.map((entry) => ( <div key={entry.path} style={cardStyle}> <div style={{ diff --git a/web/app/components/base/icons/src/public/avatar/Robot.tsx b/web/app/components/base/icons/src/public/avatar/Robot.tsx index eb047313983792..5fe2083bc34f0f 100644 --- a/web/app/components/base/icons/src/public/avatar/Robot.tsx +++ b/web/app/components/base/icons/src/public/avatar/Robot.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Robot.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Robot' diff --git a/web/app/components/base/icons/src/public/avatar/User.tsx b/web/app/components/base/icons/src/public/avatar/User.tsx index 161210629132b1..82ccb1acc35bee 100644 --- a/web/app/components/base/icons/src/public/avatar/User.tsx +++ b/web/app/components/base/icons/src/public/avatar/User.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './User.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'User' diff --git a/web/app/components/base/icons/src/public/billing/AwsMarketplaceDark.tsx b/web/app/components/base/icons/src/public/billing/AwsMarketplaceDark.tsx index d1cd0709ee3d60..f146c5daaff7d4 100644 --- a/web/app/components/base/icons/src/public/billing/AwsMarketplaceDark.tsx +++ b/web/app/components/base/icons/src/public/billing/AwsMarketplaceDark.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './AwsMarketplaceDark.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'AwsMarketplaceDark' diff --git a/web/app/components/base/icons/src/public/billing/AwsMarketplaceLight.tsx b/web/app/components/base/icons/src/public/billing/AwsMarketplaceLight.tsx index 7fa02aff173f9e..3b640201341ad6 100644 --- a/web/app/components/base/icons/src/public/billing/AwsMarketplaceLight.tsx +++ b/web/app/components/base/icons/src/public/billing/AwsMarketplaceLight.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './AwsMarketplaceLight.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'AwsMarketplaceLight' diff --git a/web/app/components/base/icons/src/public/billing/Azure.tsx b/web/app/components/base/icons/src/public/billing/Azure.tsx index fcf8ecbc8e7a55..1ac6b3a640e113 100644 --- a/web/app/components/base/icons/src/public/billing/Azure.tsx +++ b/web/app/components/base/icons/src/public/billing/Azure.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Azure.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Azure' diff --git a/web/app/components/base/icons/src/public/billing/GoogleCloud.tsx b/web/app/components/base/icons/src/public/billing/GoogleCloud.tsx index c22fe65adcc464..4fd2fd9bea230d 100644 --- a/web/app/components/base/icons/src/public/billing/GoogleCloud.tsx +++ b/web/app/components/base/icons/src/public/billing/GoogleCloud.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './GoogleCloud.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'GoogleCloud' diff --git a/web/app/components/base/icons/src/public/common/EnterKey.tsx b/web/app/components/base/icons/src/public/common/EnterKey.tsx index 2bf2d4fb1912a4..6dd00576d734e0 100644 --- a/web/app/components/base/icons/src/public/common/EnterKey.tsx +++ b/web/app/components/base/icons/src/public/common/EnterKey.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './EnterKey.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'EnterKey' diff --git a/web/app/components/base/icons/src/public/common/Gdpr.tsx b/web/app/components/base/icons/src/public/common/Gdpr.tsx index 147322ee210b4d..58a1bc8df205cc 100644 --- a/web/app/components/base/icons/src/public/common/Gdpr.tsx +++ b/web/app/components/base/icons/src/public/common/Gdpr.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Gdpr.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Gdpr' diff --git a/web/app/components/base/icons/src/public/common/Github.tsx b/web/app/components/base/icons/src/public/common/Github.tsx index a321116efcdc2f..4a797bb9c6280a 100644 --- a/web/app/components/base/icons/src/public/common/Github.tsx +++ b/web/app/components/base/icons/src/public/common/Github.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Github.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Github' diff --git a/web/app/components/base/icons/src/public/common/Highlight.tsx b/web/app/components/base/icons/src/public/common/Highlight.tsx index 0613a4ce8f5da8..13b56bd16ee272 100644 --- a/web/app/components/base/icons/src/public/common/Highlight.tsx +++ b/web/app/components/base/icons/src/public/common/Highlight.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Highlight.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Highlight' diff --git a/web/app/components/base/icons/src/public/common/Iso.tsx b/web/app/components/base/icons/src/public/common/Iso.tsx index baf0a7b199ef20..61b89b7978a97b 100644 --- a/web/app/components/base/icons/src/public/common/Iso.tsx +++ b/web/app/components/base/icons/src/public/common/Iso.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Iso.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Iso' diff --git a/web/app/components/base/icons/src/public/common/Line3.tsx b/web/app/components/base/icons/src/public/common/Line3.tsx index b2ca99ed9930fe..bfe311a44e923d 100644 --- a/web/app/components/base/icons/src/public/common/Line3.tsx +++ b/web/app/components/base/icons/src/public/common/Line3.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Line3.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Line3' diff --git a/web/app/components/base/icons/src/public/common/Notion.tsx b/web/app/components/base/icons/src/public/common/Notion.tsx index 11cdfc37c71eeb..c2b94d9fada524 100644 --- a/web/app/components/base/icons/src/public/common/Notion.tsx +++ b/web/app/components/base/icons/src/public/common/Notion.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Notion.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Notion' diff --git a/web/app/components/base/icons/src/public/common/Soc2.tsx b/web/app/components/base/icons/src/public/common/Soc2.tsx index 66c401f073b1c5..834991bc33c565 100644 --- a/web/app/components/base/icons/src/public/common/Soc2.tsx +++ b/web/app/components/base/icons/src/public/common/Soc2.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Soc2.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Soc2' diff --git a/web/app/components/base/icons/src/public/common/SparklesSoft.tsx b/web/app/components/base/icons/src/public/common/SparklesSoft.tsx index 2ec563939dbe34..2d801ce3a73c18 100644 --- a/web/app/components/base/icons/src/public/common/SparklesSoft.tsx +++ b/web/app/components/base/icons/src/public/common/SparklesSoft.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './SparklesSoft.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'SparklesSoft' diff --git a/web/app/components/base/icons/src/public/common/SparklesSoftAccent.tsx b/web/app/components/base/icons/src/public/common/SparklesSoftAccent.tsx index ce32bf234d1f22..56b43d7b90475c 100644 --- a/web/app/components/base/icons/src/public/common/SparklesSoftAccent.tsx +++ b/web/app/components/base/icons/src/public/common/SparklesSoftAccent.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './SparklesSoftAccent.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'SparklesSoftAccent' diff --git a/web/app/components/base/icons/src/public/education/Triangle.tsx b/web/app/components/base/icons/src/public/education/Triangle.tsx index b1df37233c4ec6..dd9d84f7c626cf 100644 --- a/web/app/components/base/icons/src/public/education/Triangle.tsx +++ b/web/app/components/base/icons/src/public/education/Triangle.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Triangle.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Triangle' diff --git a/web/app/components/base/icons/src/public/files/Csv.tsx b/web/app/components/base/icons/src/public/files/Csv.tsx index c07070dd20aece..32bffdfa2ff25c 100644 --- a/web/app/components/base/icons/src/public/files/Csv.tsx +++ b/web/app/components/base/icons/src/public/files/Csv.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Csv.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Csv' diff --git a/web/app/components/base/icons/src/public/files/Doc.tsx b/web/app/components/base/icons/src/public/files/Doc.tsx index 5073bcbd2b592c..2916498ecc3d90 100644 --- a/web/app/components/base/icons/src/public/files/Doc.tsx +++ b/web/app/components/base/icons/src/public/files/Doc.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Doc.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Doc' diff --git a/web/app/components/base/icons/src/public/files/Docx.tsx b/web/app/components/base/icons/src/public/files/Docx.tsx index e72f04215622d4..eaa3f23c738547 100644 --- a/web/app/components/base/icons/src/public/files/Docx.tsx +++ b/web/app/components/base/icons/src/public/files/Docx.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Docx.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Docx' diff --git a/web/app/components/base/icons/src/public/files/Html.tsx b/web/app/components/base/icons/src/public/files/Html.tsx index 0545aa3b172603..651e50a91d1962 100644 --- a/web/app/components/base/icons/src/public/files/Html.tsx +++ b/web/app/components/base/icons/src/public/files/Html.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Html.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Html' diff --git a/web/app/components/base/icons/src/public/files/Json.tsx b/web/app/components/base/icons/src/public/files/Json.tsx index 215dfc02e1692d..0239aa0341b9b3 100644 --- a/web/app/components/base/icons/src/public/files/Json.tsx +++ b/web/app/components/base/icons/src/public/files/Json.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Json.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Json' diff --git a/web/app/components/base/icons/src/public/files/Md.tsx b/web/app/components/base/icons/src/public/files/Md.tsx index 94e2390abe13b0..75c6a5ba183848 100644 --- a/web/app/components/base/icons/src/public/files/Md.tsx +++ b/web/app/components/base/icons/src/public/files/Md.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Md.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Md' diff --git a/web/app/components/base/icons/src/public/files/Pdf.tsx b/web/app/components/base/icons/src/public/files/Pdf.tsx index 7bec4670fc4ace..c3679574b69541 100644 --- a/web/app/components/base/icons/src/public/files/Pdf.tsx +++ b/web/app/components/base/icons/src/public/files/Pdf.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Pdf.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Pdf' diff --git a/web/app/components/base/icons/src/public/files/Txt.tsx b/web/app/components/base/icons/src/public/files/Txt.tsx index d2aeade2801eb3..7eac1fb7454a73 100644 --- a/web/app/components/base/icons/src/public/files/Txt.tsx +++ b/web/app/components/base/icons/src/public/files/Txt.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Txt.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Txt' diff --git a/web/app/components/base/icons/src/public/files/Unknown.tsx b/web/app/components/base/icons/src/public/files/Unknown.tsx index 3f59ec6a583c05..910fd4d1193588 100644 --- a/web/app/components/base/icons/src/public/files/Unknown.tsx +++ b/web/app/components/base/icons/src/public/files/Unknown.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Unknown.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Unknown' diff --git a/web/app/components/base/icons/src/public/files/Xlsx.tsx b/web/app/components/base/icons/src/public/files/Xlsx.tsx index 71d80f624ddc1a..e6ebf51ad525e0 100644 --- a/web/app/components/base/icons/src/public/files/Xlsx.tsx +++ b/web/app/components/base/icons/src/public/files/Xlsx.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Xlsx.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Xlsx' diff --git a/web/app/components/base/icons/src/public/files/Yaml.tsx b/web/app/components/base/icons/src/public/files/Yaml.tsx index 096d6afba678ed..5869aaa8068d95 100644 --- a/web/app/components/base/icons/src/public/files/Yaml.tsx +++ b/web/app/components/base/icons/src/public/files/Yaml.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Yaml.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Yaml' diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlue.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlue.tsx index 65bf90c4a948c3..e51dd48d8265e2 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlue.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlue.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './OptionCardEffectBlue.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'OptionCardEffectBlue' diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlueLight.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlueLight.tsx index ad83e82da7536b..216dc8715f6959 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlueLight.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlueLight.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './OptionCardEffectBlueLight.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'OptionCardEffectBlueLight' diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectOrange.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectOrange.tsx index b080a0bfe396d9..8a85c1a664d538 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectOrange.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectOrange.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './OptionCardEffectOrange.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'OptionCardEffectOrange' diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectPurple.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectPurple.tsx index b5842892493759..794be9d25dc330 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectPurple.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectPurple.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './OptionCardEffectPurple.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'OptionCardEffectPurple' diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectTeal.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectTeal.tsx index c614b3bd6bead5..098c66b5b8a1a7 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectTeal.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectTeal.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './OptionCardEffectTeal.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'OptionCardEffectTeal' diff --git a/web/app/components/base/icons/src/public/knowledge/SelectionMod.tsx b/web/app/components/base/icons/src/public/knowledge/SelectionMod.tsx index c880dd0e0068da..d9eb4b220623e8 100644 --- a/web/app/components/base/icons/src/public/knowledge/SelectionMod.tsx +++ b/web/app/components/base/icons/src/public/knowledge/SelectionMod.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './SelectionMod.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'SelectionMod' diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/ExternalKnowledgeBase.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/ExternalKnowledgeBase.tsx index 1612f216b2d37e..ac1cdc12c80003 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/ExternalKnowledgeBase.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/ExternalKnowledgeBase.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ExternalKnowledgeBase.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ExternalKnowledgeBase' diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/General.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/General.tsx index b93fbe02b22b27..645b929ff98f93 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/General.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/General.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './General.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'General' diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/ParentChild.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/ParentChild.tsx index 1114783c387e2c..0e35ba4a27e9cf 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/ParentChild.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/ParentChild.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ParentChild.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ParentChild' diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/Qa.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/Qa.tsx index 6d4b488156d76c..6d8961d3fd4e43 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/Qa.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/Qa.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Qa.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Qa' diff --git a/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsBlue.tsx b/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsBlue.tsx index 4b9d5e643064b3..3b8c7200e78434 100644 --- a/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsBlue.tsx +++ b/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsBlue.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './BucketsBlue.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'BucketsBlue' diff --git a/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsGray.tsx b/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsGray.tsx index 5c560def2c5c7c..a1b9146b77e404 100644 --- a/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsGray.tsx +++ b/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsGray.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './BucketsGray.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'BucketsGray' diff --git a/web/app/components/base/icons/src/public/knowledge/online-drive/Folder.tsx b/web/app/components/base/icons/src/public/knowledge/online-drive/Folder.tsx index 5d656dee6166e5..86542222cdb736 100644 --- a/web/app/components/base/icons/src/public/knowledge/online-drive/Folder.tsx +++ b/web/app/components/base/icons/src/public/knowledge/online-drive/Folder.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Folder.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Folder' diff --git a/web/app/components/base/icons/src/public/llm/AnthropicDark.tsx b/web/app/components/base/icons/src/public/llm/AnthropicDark.tsx index eb1dca392ba515..50bc8427e33fb6 100644 --- a/web/app/components/base/icons/src/public/llm/AnthropicDark.tsx +++ b/web/app/components/base/icons/src/public/llm/AnthropicDark.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './AnthropicDark.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'AnthropicDark' diff --git a/web/app/components/base/icons/src/public/llm/AnthropicLight.tsx b/web/app/components/base/icons/src/public/llm/AnthropicLight.tsx index 5ee6734ae1d112..24a58f37ba3888 100644 --- a/web/app/components/base/icons/src/public/llm/AnthropicLight.tsx +++ b/web/app/components/base/icons/src/public/llm/AnthropicLight.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './AnthropicLight.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'AnthropicLight' diff --git a/web/app/components/base/icons/src/public/llm/AnthropicShortLight.tsx b/web/app/components/base/icons/src/public/llm/AnthropicShortLight.tsx index 2bd21f48da9ebc..66bceb0549b759 100644 --- a/web/app/components/base/icons/src/public/llm/AnthropicShortLight.tsx +++ b/web/app/components/base/icons/src/public/llm/AnthropicShortLight.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './AnthropicShortLight.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'AnthropicShortLight' diff --git a/web/app/components/base/icons/src/public/llm/Deepseek.tsx b/web/app/components/base/icons/src/public/llm/Deepseek.tsx index b19beb8b8f4b86..b6396ee7b0c3c8 100644 --- a/web/app/components/base/icons/src/public/llm/Deepseek.tsx +++ b/web/app/components/base/icons/src/public/llm/Deepseek.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Deepseek.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Deepseek' diff --git a/web/app/components/base/icons/src/public/llm/Gemini.tsx b/web/app/components/base/icons/src/public/llm/Gemini.tsx index f5430036bb1a68..b4e2a7f7af1510 100644 --- a/web/app/components/base/icons/src/public/llm/Gemini.tsx +++ b/web/app/components/base/icons/src/public/llm/Gemini.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Gemini.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Gemini' diff --git a/web/app/components/base/icons/src/public/llm/Grok.tsx b/web/app/components/base/icons/src/public/llm/Grok.tsx index 8b378de4904cb2..b9a5890dc56fdc 100644 --- a/web/app/components/base/icons/src/public/llm/Grok.tsx +++ b/web/app/components/base/icons/src/public/llm/Grok.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Grok.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Grok' diff --git a/web/app/components/base/icons/src/public/llm/OpenaiSmall.tsx b/web/app/components/base/icons/src/public/llm/OpenaiSmall.tsx index 6307091e0bd8ad..5b03cb23539d56 100644 --- a/web/app/components/base/icons/src/public/llm/OpenaiSmall.tsx +++ b/web/app/components/base/icons/src/public/llm/OpenaiSmall.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './OpenaiSmall.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'OpenaiSmall' diff --git a/web/app/components/base/icons/src/public/llm/OpenaiYellow.tsx b/web/app/components/base/icons/src/public/llm/OpenaiYellow.tsx index 006dcf7ab8f099..2f2ec22dce6e92 100644 --- a/web/app/components/base/icons/src/public/llm/OpenaiYellow.tsx +++ b/web/app/components/base/icons/src/public/llm/OpenaiYellow.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './OpenaiYellow.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'OpenaiYellow' diff --git a/web/app/components/base/icons/src/public/llm/Tongyi.tsx b/web/app/components/base/icons/src/public/llm/Tongyi.tsx index 9934dee856ae1e..d7c1c5710604e4 100644 --- a/web/app/components/base/icons/src/public/llm/Tongyi.tsx +++ b/web/app/components/base/icons/src/public/llm/Tongyi.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Tongyi.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Tongyi' diff --git a/web/app/components/base/icons/src/public/other/Comment.tsx b/web/app/components/base/icons/src/public/other/Comment.tsx index 887754e48e7806..1abd15c3f221d0 100644 --- a/web/app/components/base/icons/src/public/other/Comment.tsx +++ b/web/app/components/base/icons/src/public/other/Comment.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Comment.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Comment' diff --git a/web/app/components/base/icons/src/public/other/DefaultToolIcon.tsx b/web/app/components/base/icons/src/public/other/DefaultToolIcon.tsx index 4f874ffc2d8c7c..9126e62c9fbbb5 100644 --- a/web/app/components/base/icons/src/public/other/DefaultToolIcon.tsx +++ b/web/app/components/base/icons/src/public/other/DefaultToolIcon.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './DefaultToolIcon.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'DefaultToolIcon' diff --git a/web/app/components/base/icons/src/public/other/Message3Fill.tsx b/web/app/components/base/icons/src/public/other/Message3Fill.tsx index e2b9143cd5a747..025312dc0ac892 100644 --- a/web/app/components/base/icons/src/public/other/Message3Fill.tsx +++ b/web/app/components/base/icons/src/public/other/Message3Fill.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Message3Fill.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Message3Fill' diff --git a/web/app/components/base/icons/src/public/other/RowStruct.tsx b/web/app/components/base/icons/src/public/other/RowStruct.tsx index 32346156bb42ce..be6926455a8ea0 100644 --- a/web/app/components/base/icons/src/public/other/RowStruct.tsx +++ b/web/app/components/base/icons/src/public/other/RowStruct.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './RowStruct.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'RowStruct' diff --git a/web/app/components/base/icons/src/public/other/Slack.tsx b/web/app/components/base/icons/src/public/other/Slack.tsx index 3150598cdbb51d..59ca6ed2ab2e9c 100644 --- a/web/app/components/base/icons/src/public/other/Slack.tsx +++ b/web/app/components/base/icons/src/public/other/Slack.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Slack.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Slack' diff --git a/web/app/components/base/icons/src/public/other/Teams.tsx b/web/app/components/base/icons/src/public/other/Teams.tsx index 4559b50793b33e..ae436055b50e0a 100644 --- a/web/app/components/base/icons/src/public/other/Teams.tsx +++ b/web/app/components/base/icons/src/public/other/Teams.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Teams.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Teams' diff --git a/web/app/components/base/icons/src/public/plugins/PartnerDark.tsx b/web/app/components/base/icons/src/public/plugins/PartnerDark.tsx index 22218c70b0204c..e691c67aa17d57 100644 --- a/web/app/components/base/icons/src/public/plugins/PartnerDark.tsx +++ b/web/app/components/base/icons/src/public/plugins/PartnerDark.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './PartnerDark.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'PartnerDark' diff --git a/web/app/components/base/icons/src/public/plugins/PartnerLight.tsx b/web/app/components/base/icons/src/public/plugins/PartnerLight.tsx index 9d4e81312ca6f0..eec7d415fd1b81 100644 --- a/web/app/components/base/icons/src/public/plugins/PartnerLight.tsx +++ b/web/app/components/base/icons/src/public/plugins/PartnerLight.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './PartnerLight.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'PartnerLight' diff --git a/web/app/components/base/icons/src/public/plugins/VerifiedDark.tsx b/web/app/components/base/icons/src/public/plugins/VerifiedDark.tsx index 5752cc9d63507a..f0a4575d715e65 100644 --- a/web/app/components/base/icons/src/public/plugins/VerifiedDark.tsx +++ b/web/app/components/base/icons/src/public/plugins/VerifiedDark.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './VerifiedDark.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'VerifiedDark' diff --git a/web/app/components/base/icons/src/public/plugins/VerifiedLight.tsx b/web/app/components/base/icons/src/public/plugins/VerifiedLight.tsx index f3ef443496f33d..9cac2fc4901d1b 100644 --- a/web/app/components/base/icons/src/public/plugins/VerifiedLight.tsx +++ b/web/app/components/base/icons/src/public/plugins/VerifiedLight.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './VerifiedLight.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'VerifiedLight' diff --git a/web/app/components/base/icons/src/public/thought/Loading.tsx b/web/app/components/base/icons/src/public/thought/Loading.tsx index 339dff7166217b..416d3ec6e94fa4 100644 --- a/web/app/components/base/icons/src/public/thought/Loading.tsx +++ b/web/app/components/base/icons/src/public/thought/Loading.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Loading.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Loading' diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx b/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx index 0b1c587a3cfb37..4ed4e305297c12 100644 --- a/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './AliyunIcon.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'AliyunIcon' diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx index fe908025574ac7..055f4c3a0b7615 100644 --- a/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx +++ b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './AliyunIconBig.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'AliyunIconBig' diff --git a/web/app/components/base/icons/src/public/tracing/ArizeIcon.tsx b/web/app/components/base/icons/src/public/tracing/ArizeIcon.tsx index 2f0f9417a510b6..4250e33e67b6e0 100644 --- a/web/app/components/base/icons/src/public/tracing/ArizeIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/ArizeIcon.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ArizeIcon.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ArizeIcon' diff --git a/web/app/components/base/icons/src/public/tracing/ArizeIconBig.tsx b/web/app/components/base/icons/src/public/tracing/ArizeIconBig.tsx index add615e6854cbf..49bceafac70308 100644 --- a/web/app/components/base/icons/src/public/tracing/ArizeIconBig.tsx +++ b/web/app/components/base/icons/src/public/tracing/ArizeIconBig.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ArizeIconBig.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ArizeIconBig' diff --git a/web/app/components/base/icons/src/public/tracing/DatabricksIcon.tsx b/web/app/components/base/icons/src/public/tracing/DatabricksIcon.tsx index a1e45d8bdfd5ca..8e2d2fafa4aae3 100644 --- a/web/app/components/base/icons/src/public/tracing/DatabricksIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/DatabricksIcon.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './DatabricksIcon.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'DatabricksIcon' diff --git a/web/app/components/base/icons/src/public/tracing/DatabricksIconBig.tsx b/web/app/components/base/icons/src/public/tracing/DatabricksIconBig.tsx index ef21c05a2306d0..cdb58c80cc64ac 100644 --- a/web/app/components/base/icons/src/public/tracing/DatabricksIconBig.tsx +++ b/web/app/components/base/icons/src/public/tracing/DatabricksIconBig.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './DatabricksIconBig.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'DatabricksIconBig' diff --git a/web/app/components/base/icons/src/public/tracing/LangfuseIcon.tsx b/web/app/components/base/icons/src/public/tracing/LangfuseIcon.tsx index 1f1c557c0f0a27..a2a9c69c650e89 100644 --- a/web/app/components/base/icons/src/public/tracing/LangfuseIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/LangfuseIcon.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './LangfuseIcon.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'LangfuseIcon' diff --git a/web/app/components/base/icons/src/public/tracing/LangfuseIconBig.tsx b/web/app/components/base/icons/src/public/tracing/LangfuseIconBig.tsx index b4846277fa6033..18392bf306b435 100644 --- a/web/app/components/base/icons/src/public/tracing/LangfuseIconBig.tsx +++ b/web/app/components/base/icons/src/public/tracing/LangfuseIconBig.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './LangfuseIconBig.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'LangfuseIconBig' diff --git a/web/app/components/base/icons/src/public/tracing/LangsmithIcon.tsx b/web/app/components/base/icons/src/public/tracing/LangsmithIcon.tsx index 42108ee6559561..d872444688533e 100644 --- a/web/app/components/base/icons/src/public/tracing/LangsmithIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/LangsmithIcon.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './LangsmithIcon.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'LangsmithIcon' diff --git a/web/app/components/base/icons/src/public/tracing/LangsmithIconBig.tsx b/web/app/components/base/icons/src/public/tracing/LangsmithIconBig.tsx index c74fda5a90d96c..987fb4aa9c4189 100644 --- a/web/app/components/base/icons/src/public/tracing/LangsmithIconBig.tsx +++ b/web/app/components/base/icons/src/public/tracing/LangsmithIconBig.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './LangsmithIconBig.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'LangsmithIconBig' diff --git a/web/app/components/base/icons/src/public/tracing/MlflowIcon.tsx b/web/app/components/base/icons/src/public/tracing/MlflowIcon.tsx index 09a31882c96f60..747eb48dec10e7 100644 --- a/web/app/components/base/icons/src/public/tracing/MlflowIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/MlflowIcon.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './MlflowIcon.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'MlflowIcon' diff --git a/web/app/components/base/icons/src/public/tracing/MlflowIconBig.tsx b/web/app/components/base/icons/src/public/tracing/MlflowIconBig.tsx index 03fef4499151de..fbb303d6a0c20d 100644 --- a/web/app/components/base/icons/src/public/tracing/MlflowIconBig.tsx +++ b/web/app/components/base/icons/src/public/tracing/MlflowIconBig.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './MlflowIconBig.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'MlflowIconBig' diff --git a/web/app/components/base/icons/src/public/tracing/OpikIcon.tsx b/web/app/components/base/icons/src/public/tracing/OpikIcon.tsx index d5d7570e69bed7..37da4954060591 100644 --- a/web/app/components/base/icons/src/public/tracing/OpikIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/OpikIcon.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './OpikIcon.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'OpikIcon' diff --git a/web/app/components/base/icons/src/public/tracing/OpikIconBig.tsx b/web/app/components/base/icons/src/public/tracing/OpikIconBig.tsx index affc4f5714778a..e2dac4c68b3826 100644 --- a/web/app/components/base/icons/src/public/tracing/OpikIconBig.tsx +++ b/web/app/components/base/icons/src/public/tracing/OpikIconBig.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './OpikIconBig.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'OpikIconBig' diff --git a/web/app/components/base/icons/src/public/tracing/PhoenixIcon.tsx b/web/app/components/base/icons/src/public/tracing/PhoenixIcon.tsx index 181502ecae4f18..f1082effcac2d2 100644 --- a/web/app/components/base/icons/src/public/tracing/PhoenixIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/PhoenixIcon.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './PhoenixIcon.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'PhoenixIcon' diff --git a/web/app/components/base/icons/src/public/tracing/PhoenixIconBig.tsx b/web/app/components/base/icons/src/public/tracing/PhoenixIconBig.tsx index 5604d6d375fb0b..26964d8a3f6d8e 100644 --- a/web/app/components/base/icons/src/public/tracing/PhoenixIconBig.tsx +++ b/web/app/components/base/icons/src/public/tracing/PhoenixIconBig.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './PhoenixIconBig.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'PhoenixIconBig' diff --git a/web/app/components/base/icons/src/public/tracing/TencentIcon.tsx b/web/app/components/base/icons/src/public/tracing/TencentIcon.tsx index c305fd3257718d..5e887e05f7a8ae 100644 --- a/web/app/components/base/icons/src/public/tracing/TencentIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/TencentIcon.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './TencentIcon.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'TencentIcon' diff --git a/web/app/components/base/icons/src/public/tracing/TencentIconBig.tsx b/web/app/components/base/icons/src/public/tracing/TencentIconBig.tsx index 5b91ca9c94f7a4..e1f37ae4aa328f 100644 --- a/web/app/components/base/icons/src/public/tracing/TencentIconBig.tsx +++ b/web/app/components/base/icons/src/public/tracing/TencentIconBig.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './TencentIconBig.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'TencentIconBig' diff --git a/web/app/components/base/icons/src/public/tracing/TracingIcon.tsx b/web/app/components/base/icons/src/public/tracing/TracingIcon.tsx index 3e2653bdddd62f..37b3f5ca74d4b5 100644 --- a/web/app/components/base/icons/src/public/tracing/TracingIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/TracingIcon.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './TracingIcon.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'TracingIcon' diff --git a/web/app/components/base/icons/src/public/tracing/WeaveIcon.tsx b/web/app/components/base/icons/src/public/tracing/WeaveIcon.tsx index ef05c7939fbc55..aa76d497411512 100644 --- a/web/app/components/base/icons/src/public/tracing/WeaveIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/WeaveIcon.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './WeaveIcon.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'WeaveIcon' diff --git a/web/app/components/base/icons/src/public/tracing/WeaveIconBig.tsx b/web/app/components/base/icons/src/public/tracing/WeaveIconBig.tsx index 4774cdea31e80e..2e82e2da46423c 100644 --- a/web/app/components/base/icons/src/public/tracing/WeaveIconBig.tsx +++ b/web/app/components/base/icons/src/public/tracing/WeaveIconBig.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './WeaveIconBig.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'WeaveIconBig' diff --git a/web/app/components/base/icons/src/vender/Annotations.tsx b/web/app/components/base/icons/src/vender/Annotations.tsx index 67a85e5d16f0cf..cf1376f6fd4c49 100644 --- a/web/app/components/base/icons/src/vender/Annotations.tsx +++ b/web/app/components/base/icons/src/vender/Annotations.tsx @@ -3,14 +3,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Annotations.json' -const Annotations = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Annotations = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Annotations.displayName = 'Annotations' diff --git a/web/app/components/base/icons/src/vender/SidebarLeftArrowIcon.tsx b/web/app/components/base/icons/src/vender/SidebarLeftArrowIcon.tsx index 46305adb1499d3..447cdd5f8723ba 100644 --- a/web/app/components/base/icons/src/vender/SidebarLeftArrowIcon.tsx +++ b/web/app/components/base/icons/src/vender/SidebarLeftArrowIcon.tsx @@ -3,14 +3,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './SidebarLeftArrowIcon.json' -const SidebarLeftArrowIcon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const SidebarLeftArrowIcon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> SidebarLeftArrowIcon.displayName = 'SidebarLeftArrowIcon' diff --git a/web/app/components/base/icons/src/vender/Star.tsx b/web/app/components/base/icons/src/vender/Star.tsx index 057046e9a0228d..3d47784cb60254 100644 --- a/web/app/components/base/icons/src/vender/Star.tsx +++ b/web/app/components/base/icons/src/vender/Star.tsx @@ -3,14 +3,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Star.json' -const Star = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Star = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Star.displayName = 'Star' diff --git a/web/app/components/base/icons/src/vender/features/Citations.tsx b/web/app/components/base/icons/src/vender/features/Citations.tsx index 991aedf8e4a6dd..0cca83353c82d2 100644 --- a/web/app/components/base/icons/src/vender/features/Citations.tsx +++ b/web/app/components/base/icons/src/vender/features/Citations.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Citations.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Citations' diff --git a/web/app/components/base/icons/src/vender/features/ContentModeration.tsx b/web/app/components/base/icons/src/vender/features/ContentModeration.tsx index 6975375acfac26..6ae2a131e8f727 100644 --- a/web/app/components/base/icons/src/vender/features/ContentModeration.tsx +++ b/web/app/components/base/icons/src/vender/features/ContentModeration.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ContentModeration.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ContentModeration' diff --git a/web/app/components/base/icons/src/vender/features/Document.tsx b/web/app/components/base/icons/src/vender/features/Document.tsx index 10c889f1867393..2f187f00ac1cc4 100644 --- a/web/app/components/base/icons/src/vender/features/Document.tsx +++ b/web/app/components/base/icons/src/vender/features/Document.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Document.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Document' diff --git a/web/app/components/base/icons/src/vender/features/FolderUpload.tsx b/web/app/components/base/icons/src/vender/features/FolderUpload.tsx index 1dbbc899628a10..aecce45a06083a 100644 --- a/web/app/components/base/icons/src/vender/features/FolderUpload.tsx +++ b/web/app/components/base/icons/src/vender/features/FolderUpload.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './FolderUpload.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'FolderUpload' diff --git a/web/app/components/base/icons/src/vender/features/LoveMessage.tsx b/web/app/components/base/icons/src/vender/features/LoveMessage.tsx index 484d1fb008ab30..424f2fca94e8a0 100644 --- a/web/app/components/base/icons/src/vender/features/LoveMessage.tsx +++ b/web/app/components/base/icons/src/vender/features/LoveMessage.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './LoveMessage.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'LoveMessage' diff --git a/web/app/components/base/icons/src/vender/features/MessageFast.tsx b/web/app/components/base/icons/src/vender/features/MessageFast.tsx index 1f0374c2dc5ca7..51d67e4e2fce70 100644 --- a/web/app/components/base/icons/src/vender/features/MessageFast.tsx +++ b/web/app/components/base/icons/src/vender/features/MessageFast.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './MessageFast.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'MessageFast' diff --git a/web/app/components/base/icons/src/vender/features/Microphone01.tsx b/web/app/components/base/icons/src/vender/features/Microphone01.tsx index 3c9dd52a67c24b..2a4cce7d787062 100644 --- a/web/app/components/base/icons/src/vender/features/Microphone01.tsx +++ b/web/app/components/base/icons/src/vender/features/Microphone01.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Microphone01.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Microphone01' diff --git a/web/app/components/base/icons/src/vender/features/TextToAudio.tsx b/web/app/components/base/icons/src/vender/features/TextToAudio.tsx index bfcfa3031e1085..bc138878bcdd35 100644 --- a/web/app/components/base/icons/src/vender/features/TextToAudio.tsx +++ b/web/app/components/base/icons/src/vender/features/TextToAudio.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './TextToAudio.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'TextToAudio' diff --git a/web/app/components/base/icons/src/vender/features/VirtualAssistant.tsx b/web/app/components/base/icons/src/vender/features/VirtualAssistant.tsx index 0e1486906a029d..e51fff42154103 100644 --- a/web/app/components/base/icons/src/vender/features/VirtualAssistant.tsx +++ b/web/app/components/base/icons/src/vender/features/VirtualAssistant.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './VirtualAssistant.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'VirtualAssistant' diff --git a/web/app/components/base/icons/src/vender/features/Vision.tsx b/web/app/components/base/icons/src/vender/features/Vision.tsx index 8f680451b80aac..e260f727297291 100644 --- a/web/app/components/base/icons/src/vender/features/Vision.tsx +++ b/web/app/components/base/icons/src/vender/features/Vision.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Vision.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Vision' diff --git a/web/app/components/base/icons/src/vender/knowledge/AddChunks.tsx b/web/app/components/base/icons/src/vender/knowledge/AddChunks.tsx index 0209761c8c6a15..1fe0a87cb0ef26 100644 --- a/web/app/components/base/icons/src/vender/knowledge/AddChunks.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/AddChunks.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './AddChunks.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'AddChunks' diff --git a/web/app/components/base/icons/src/vender/knowledge/ApiAggregate.tsx b/web/app/components/base/icons/src/vender/knowledge/ApiAggregate.tsx index ccc987f423e0b1..0542bb6380e557 100644 --- a/web/app/components/base/icons/src/vender/knowledge/ApiAggregate.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/ApiAggregate.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ApiAggregate.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ApiAggregate' diff --git a/web/app/components/base/icons/src/vender/knowledge/ArrowShape.tsx b/web/app/components/base/icons/src/vender/knowledge/ArrowShape.tsx index d32bd130aaca33..afc183937adbbe 100644 --- a/web/app/components/base/icons/src/vender/knowledge/ArrowShape.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/ArrowShape.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ArrowShape.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ArrowShape' diff --git a/web/app/components/base/icons/src/vender/knowledge/Chunk.tsx b/web/app/components/base/icons/src/vender/knowledge/Chunk.tsx index c9ee9d0e961909..6952ec1fe6339b 100644 --- a/web/app/components/base/icons/src/vender/knowledge/Chunk.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/Chunk.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Chunk.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Chunk' diff --git a/web/app/components/base/icons/src/vender/knowledge/Collapse.tsx b/web/app/components/base/icons/src/vender/knowledge/Collapse.tsx index f2cd10f1e748db..fac14f56c422dd 100644 --- a/web/app/components/base/icons/src/vender/knowledge/Collapse.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/Collapse.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Collapse.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Collapse' diff --git a/web/app/components/base/icons/src/vender/knowledge/Economic.tsx b/web/app/components/base/icons/src/vender/knowledge/Economic.tsx index c3005912d27bf0..3207b003f3bb4b 100644 --- a/web/app/components/base/icons/src/vender/knowledge/Economic.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/Economic.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Economic.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Economic' diff --git a/web/app/components/base/icons/src/vender/knowledge/FullTextSearch.tsx b/web/app/components/base/icons/src/vender/knowledge/FullTextSearch.tsx index 401c0835eb39c6..7ea1a6ff0e7c83 100644 --- a/web/app/components/base/icons/src/vender/knowledge/FullTextSearch.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/FullTextSearch.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './FullTextSearch.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'FullTextSearch' diff --git a/web/app/components/base/icons/src/vender/knowledge/GeneralChunk.tsx b/web/app/components/base/icons/src/vender/knowledge/GeneralChunk.tsx index f419ee41dedfd5..0881ffd40a3018 100644 --- a/web/app/components/base/icons/src/vender/knowledge/GeneralChunk.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/GeneralChunk.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './GeneralChunk.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'GeneralChunk' diff --git a/web/app/components/base/icons/src/vender/knowledge/HighQuality.tsx b/web/app/components/base/icons/src/vender/knowledge/HighQuality.tsx index 97ad0dce0057be..da0e476e57483f 100644 --- a/web/app/components/base/icons/src/vender/knowledge/HighQuality.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/HighQuality.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './HighQuality.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'HighQuality' diff --git a/web/app/components/base/icons/src/vender/knowledge/HybridSearch.tsx b/web/app/components/base/icons/src/vender/knowledge/HybridSearch.tsx index 2c9c8b9f07ecf5..9e1545000c1633 100644 --- a/web/app/components/base/icons/src/vender/knowledge/HybridSearch.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/HybridSearch.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './HybridSearch.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'HybridSearch' diff --git a/web/app/components/base/icons/src/vender/knowledge/ParentChildChunk.tsx b/web/app/components/base/icons/src/vender/knowledge/ParentChildChunk.tsx index 631fb68c388875..fdec740fed6d61 100644 --- a/web/app/components/base/icons/src/vender/knowledge/ParentChildChunk.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/ParentChildChunk.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ParentChildChunk.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ParentChildChunk' diff --git a/web/app/components/base/icons/src/vender/knowledge/QuestionAndAnswer.tsx b/web/app/components/base/icons/src/vender/knowledge/QuestionAndAnswer.tsx index a1f967955e9a20..2ff410077cebef 100644 --- a/web/app/components/base/icons/src/vender/knowledge/QuestionAndAnswer.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/QuestionAndAnswer.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './QuestionAndAnswer.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'QuestionAndAnswer' diff --git a/web/app/components/base/icons/src/vender/knowledge/SearchLinesSparkle.tsx b/web/app/components/base/icons/src/vender/knowledge/SearchLinesSparkle.tsx index 3ae90b5fa1676f..59b2a68fd341af 100644 --- a/web/app/components/base/icons/src/vender/knowledge/SearchLinesSparkle.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/SearchLinesSparkle.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './SearchLinesSparkle.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'SearchLinesSparkle' diff --git a/web/app/components/base/icons/src/vender/knowledge/SearchMenu.tsx b/web/app/components/base/icons/src/vender/knowledge/SearchMenu.tsx index 36b3e6ab961fbd..8beeadb3d82fc4 100644 --- a/web/app/components/base/icons/src/vender/knowledge/SearchMenu.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/SearchMenu.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './SearchMenu.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'SearchMenu' diff --git a/web/app/components/base/icons/src/vender/knowledge/VectorSearch.tsx b/web/app/components/base/icons/src/vender/knowledge/VectorSearch.tsx index 6670ae2edf5a3a..4261d026277244 100644 --- a/web/app/components/base/icons/src/vender/knowledge/VectorSearch.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/VectorSearch.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './VectorSearch.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'VectorSearch' diff --git a/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.tsx b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.tsx index 5e94cacf8d5ed5..e12ac4ae9df36a 100644 --- a/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.tsx +++ b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Warning.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Warning' diff --git a/web/app/components/base/icons/src/vender/line/arrows/ArrowNarrowLeft.tsx b/web/app/components/base/icons/src/vender/line/arrows/ArrowNarrowLeft.tsx index a9feb4a31d3648..c478dd8216abe3 100644 --- a/web/app/components/base/icons/src/vender/line/arrows/ArrowNarrowLeft.tsx +++ b/web/app/components/base/icons/src/vender/line/arrows/ArrowNarrowLeft.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ArrowNarrowLeft.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ArrowNarrowLeft' diff --git a/web/app/components/base/icons/src/vender/line/arrows/ArrowUpRight.tsx b/web/app/components/base/icons/src/vender/line/arrows/ArrowUpRight.tsx index 02b0201fa68bdf..9249a4325ffc7f 100644 --- a/web/app/components/base/icons/src/vender/line/arrows/ArrowUpRight.tsx +++ b/web/app/components/base/icons/src/vender/line/arrows/ArrowUpRight.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ArrowUpRight.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ArrowUpRight' diff --git a/web/app/components/base/icons/src/vender/line/arrows/ChevronRight.tsx b/web/app/components/base/icons/src/vender/line/arrows/ChevronRight.tsx index 6f818af78069c0..d786e8ae6acc30 100644 --- a/web/app/components/base/icons/src/vender/line/arrows/ChevronRight.tsx +++ b/web/app/components/base/icons/src/vender/line/arrows/ChevronRight.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ChevronRight.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ChevronRight' diff --git a/web/app/components/base/icons/src/vender/line/arrows/ChevronSelectorVertical.tsx b/web/app/components/base/icons/src/vender/line/arrows/ChevronSelectorVertical.tsx index 6f0482adfb76f8..3bf425d2757c1c 100644 --- a/web/app/components/base/icons/src/vender/line/arrows/ChevronSelectorVertical.tsx +++ b/web/app/components/base/icons/src/vender/line/arrows/ChevronSelectorVertical.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ChevronSelectorVertical.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ChevronSelectorVertical' diff --git a/web/app/components/base/icons/src/vender/line/arrows/RefreshCcw01.tsx b/web/app/components/base/icons/src/vender/line/arrows/RefreshCcw01.tsx index 4495b6ab38a787..04962b0483666c 100644 --- a/web/app/components/base/icons/src/vender/line/arrows/RefreshCcw01.tsx +++ b/web/app/components/base/icons/src/vender/line/arrows/RefreshCcw01.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './RefreshCcw01.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'RefreshCcw01' diff --git a/web/app/components/base/icons/src/vender/line/communication/ChatBotSlim.tsx b/web/app/components/base/icons/src/vender/line/communication/ChatBotSlim.tsx index bcb98575a96d40..8e6c289dd00cc6 100644 --- a/web/app/components/base/icons/src/vender/line/communication/ChatBotSlim.tsx +++ b/web/app/components/base/icons/src/vender/line/communication/ChatBotSlim.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ChatBotSlim.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ChatBotSlim' diff --git a/web/app/components/base/icons/src/vender/line/communication/MessageCheckRemove.tsx b/web/app/components/base/icons/src/vender/line/communication/MessageCheckRemove.tsx index adf2bc18ba65bd..d427d491720ee2 100644 --- a/web/app/components/base/icons/src/vender/line/communication/MessageCheckRemove.tsx +++ b/web/app/components/base/icons/src/vender/line/communication/MessageCheckRemove.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './MessageCheckRemove.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'MessageCheckRemove' diff --git a/web/app/components/base/icons/src/vender/line/communication/MessageFastPlus.tsx b/web/app/components/base/icons/src/vender/line/communication/MessageFastPlus.tsx index b3411fbb61f474..36c253e4473e0d 100644 --- a/web/app/components/base/icons/src/vender/line/communication/MessageFastPlus.tsx +++ b/web/app/components/base/icons/src/vender/line/communication/MessageFastPlus.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './MessageFastPlus.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'MessageFastPlus' diff --git a/web/app/components/base/icons/src/vender/line/development/BracketsX.tsx b/web/app/components/base/icons/src/vender/line/development/BracketsX.tsx index ac8a18a3a4d828..af3ddbe69fb6c8 100644 --- a/web/app/components/base/icons/src/vender/line/development/BracketsX.tsx +++ b/web/app/components/base/icons/src/vender/line/development/BracketsX.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './BracketsX.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'BracketsX' diff --git a/web/app/components/base/icons/src/vender/line/editor/ImageIndentLeft.tsx b/web/app/components/base/icons/src/vender/line/editor/ImageIndentLeft.tsx index b413063eb5570d..191c42033b88bc 100644 --- a/web/app/components/base/icons/src/vender/line/editor/ImageIndentLeft.tsx +++ b/web/app/components/base/icons/src/vender/line/editor/ImageIndentLeft.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ImageIndentLeft.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ImageIndentLeft' diff --git a/web/app/components/base/icons/src/vender/line/education/BookOpen01.tsx b/web/app/components/base/icons/src/vender/line/education/BookOpen01.tsx index 2d4364482c3059..2b911e818d045c 100644 --- a/web/app/components/base/icons/src/vender/line/education/BookOpen01.tsx +++ b/web/app/components/base/icons/src/vender/line/education/BookOpen01.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './BookOpen01.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'BookOpen01' diff --git a/web/app/components/base/icons/src/vender/line/files/Copy.tsx b/web/app/components/base/icons/src/vender/line/files/Copy.tsx index 78ca6df539255a..c35512ba3d7358 100644 --- a/web/app/components/base/icons/src/vender/line/files/Copy.tsx +++ b/web/app/components/base/icons/src/vender/line/files/Copy.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Copy.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Copy' diff --git a/web/app/components/base/icons/src/vender/line/files/CopyCheck.tsx b/web/app/components/base/icons/src/vender/line/files/CopyCheck.tsx index d03c72899969bc..e4a0b7a11e32d5 100644 --- a/web/app/components/base/icons/src/vender/line/files/CopyCheck.tsx +++ b/web/app/components/base/icons/src/vender/line/files/CopyCheck.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './CopyCheck.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'CopyCheck' diff --git a/web/app/components/base/icons/src/vender/line/files/FileArrow01.tsx b/web/app/components/base/icons/src/vender/line/files/FileArrow01.tsx index d34787502bf722..92051b4b1e2872 100644 --- a/web/app/components/base/icons/src/vender/line/files/FileArrow01.tsx +++ b/web/app/components/base/icons/src/vender/line/files/FileArrow01.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './FileArrow01.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'FileArrow01' diff --git a/web/app/components/base/icons/src/vender/line/files/Folder.tsx b/web/app/components/base/icons/src/vender/line/files/Folder.tsx index 5d656dee6166e5..86542222cdb736 100644 --- a/web/app/components/base/icons/src/vender/line/files/Folder.tsx +++ b/web/app/components/base/icons/src/vender/line/files/Folder.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Folder.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Folder' diff --git a/web/app/components/base/icons/src/vender/line/financeAndECommerce/Balance.tsx b/web/app/components/base/icons/src/vender/line/financeAndECommerce/Balance.tsx index a2618ecbcbd967..8763e2ef9840d6 100644 --- a/web/app/components/base/icons/src/vender/line/financeAndECommerce/Balance.tsx +++ b/web/app/components/base/icons/src/vender/line/financeAndECommerce/Balance.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Balance.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Balance' diff --git a/web/app/components/base/icons/src/vender/line/financeAndECommerce/CreditsCoin.tsx b/web/app/components/base/icons/src/vender/line/financeAndECommerce/CreditsCoin.tsx index 77b44e78304a28..3332ab227cb15e 100644 --- a/web/app/components/base/icons/src/vender/line/financeAndECommerce/CreditsCoin.tsx +++ b/web/app/components/base/icons/src/vender/line/financeAndECommerce/CreditsCoin.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './CreditsCoin.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'CreditsCoin' diff --git a/web/app/components/base/icons/src/vender/line/financeAndECommerce/Tag03.tsx b/web/app/components/base/icons/src/vender/line/financeAndECommerce/Tag03.tsx index a792bfebc5466f..f87ca7ccc1d429 100644 --- a/web/app/components/base/icons/src/vender/line/financeAndECommerce/Tag03.tsx +++ b/web/app/components/base/icons/src/vender/line/financeAndECommerce/Tag03.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Tag03.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Tag03' diff --git a/web/app/components/base/icons/src/vender/line/general/Check.tsx b/web/app/components/base/icons/src/vender/line/general/Check.tsx index e4a3fa77f0f3ec..6fb947b2f092aa 100644 --- a/web/app/components/base/icons/src/vender/line/general/Check.tsx +++ b/web/app/components/base/icons/src/vender/line/general/Check.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Check.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Check' diff --git a/web/app/components/base/icons/src/vender/line/general/CodeAssistant.tsx b/web/app/components/base/icons/src/vender/line/general/CodeAssistant.tsx index 0243208d0d6dac..734efda03eecfe 100644 --- a/web/app/components/base/icons/src/vender/line/general/CodeAssistant.tsx +++ b/web/app/components/base/icons/src/vender/line/general/CodeAssistant.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './CodeAssistant.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'CodeAssistant' diff --git a/web/app/components/base/icons/src/vender/line/general/Link03.tsx b/web/app/components/base/icons/src/vender/line/general/Link03.tsx index e106a31f4ca462..cf4574fda513a1 100644 --- a/web/app/components/base/icons/src/vender/line/general/Link03.tsx +++ b/web/app/components/base/icons/src/vender/line/general/Link03.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Link03.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Link03' diff --git a/web/app/components/base/icons/src/vender/line/general/LinkExternal02.tsx b/web/app/components/base/icons/src/vender/line/general/LinkExternal02.tsx index c93cfabdf51d38..fee4813bafbf2c 100644 --- a/web/app/components/base/icons/src/vender/line/general/LinkExternal02.tsx +++ b/web/app/components/base/icons/src/vender/line/general/LinkExternal02.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './LinkExternal02.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'LinkExternal02' diff --git a/web/app/components/base/icons/src/vender/line/general/MagicEdit.tsx b/web/app/components/base/icons/src/vender/line/general/MagicEdit.tsx index 43d9e8545a1174..6dd2c9445ed16a 100644 --- a/web/app/components/base/icons/src/vender/line/general/MagicEdit.tsx +++ b/web/app/components/base/icons/src/vender/line/general/MagicEdit.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './MagicEdit.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'MagicEdit' diff --git a/web/app/components/base/icons/src/vender/line/general/Pin02.tsx b/web/app/components/base/icons/src/vender/line/general/Pin02.tsx index fe50ae1939db8d..5fbb51b5e0b602 100644 --- a/web/app/components/base/icons/src/vender/line/general/Pin02.tsx +++ b/web/app/components/base/icons/src/vender/line/general/Pin02.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Pin02.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Pin02' diff --git a/web/app/components/base/icons/src/vender/line/general/Plus02.tsx b/web/app/components/base/icons/src/vender/line/general/Plus02.tsx index 53dd9d27c67467..6cf217bb739bfa 100644 --- a/web/app/components/base/icons/src/vender/line/general/Plus02.tsx +++ b/web/app/components/base/icons/src/vender/line/general/Plus02.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Plus02.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Plus02' diff --git a/web/app/components/base/icons/src/vender/line/general/SearchMenu.tsx b/web/app/components/base/icons/src/vender/line/general/SearchMenu.tsx index 36b3e6ab961fbd..8beeadb3d82fc4 100644 --- a/web/app/components/base/icons/src/vender/line/general/SearchMenu.tsx +++ b/web/app/components/base/icons/src/vender/line/general/SearchMenu.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './SearchMenu.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'SearchMenu' diff --git a/web/app/components/base/icons/src/vender/line/general/Settings01.tsx b/web/app/components/base/icons/src/vender/line/general/Settings01.tsx index 6d6dbcbf00ec8b..1dbccd21dfd8ae 100644 --- a/web/app/components/base/icons/src/vender/line/general/Settings01.tsx +++ b/web/app/components/base/icons/src/vender/line/general/Settings01.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Settings01.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Settings01' diff --git a/web/app/components/base/icons/src/vender/line/general/X.tsx b/web/app/components/base/icons/src/vender/line/general/X.tsx index e9541e9adbc5df..3ad993190b8bbb 100644 --- a/web/app/components/base/icons/src/vender/line/general/X.tsx +++ b/web/app/components/base/icons/src/vender/line/general/X.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './X.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'X' diff --git a/web/app/components/base/icons/src/vender/line/images/ImagePlus.tsx b/web/app/components/base/icons/src/vender/line/images/ImagePlus.tsx index 424d2b1b60f11e..d3cec8ea27cc01 100644 --- a/web/app/components/base/icons/src/vender/line/images/ImagePlus.tsx +++ b/web/app/components/base/icons/src/vender/line/images/ImagePlus.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ImagePlus.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ImagePlus' diff --git a/web/app/components/base/icons/src/vender/line/mediaAndDevices/Stop.tsx b/web/app/components/base/icons/src/vender/line/mediaAndDevices/Stop.tsx index 0de4ec444a943e..71a3d6caa97f6c 100644 --- a/web/app/components/base/icons/src/vender/line/mediaAndDevices/Stop.tsx +++ b/web/app/components/base/icons/src/vender/line/mediaAndDevices/Stop.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Stop.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Stop' diff --git a/web/app/components/base/icons/src/vender/line/mediaAndDevices/StopCircle.tsx b/web/app/components/base/icons/src/vender/line/mediaAndDevices/StopCircle.tsx index 996fe08104ff12..6e3bb43df73cd2 100644 --- a/web/app/components/base/icons/src/vender/line/mediaAndDevices/StopCircle.tsx +++ b/web/app/components/base/icons/src/vender/line/mediaAndDevices/StopCircle.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './StopCircle.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'StopCircle' diff --git a/web/app/components/base/icons/src/vender/line/others/BubbleX.tsx b/web/app/components/base/icons/src/vender/line/others/BubbleX.tsx index 0ecec0b958120d..e53728673689b1 100644 --- a/web/app/components/base/icons/src/vender/line/others/BubbleX.tsx +++ b/web/app/components/base/icons/src/vender/line/others/BubbleX.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './BubbleX.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'BubbleX' diff --git a/web/app/components/base/icons/src/vender/line/others/DragHandle.tsx b/web/app/components/base/icons/src/vender/line/others/DragHandle.tsx index 05ad3c1037b255..012b24628a7b24 100644 --- a/web/app/components/base/icons/src/vender/line/others/DragHandle.tsx +++ b/web/app/components/base/icons/src/vender/line/others/DragHandle.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './DragHandle.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'DragHandle' diff --git a/web/app/components/base/icons/src/vender/line/others/Env.tsx b/web/app/components/base/icons/src/vender/line/others/Env.tsx index aa4974d947eab4..0bfb8428379a16 100644 --- a/web/app/components/base/icons/src/vender/line/others/Env.tsx +++ b/web/app/components/base/icons/src/vender/line/others/Env.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Env.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Env' diff --git a/web/app/components/base/icons/src/vender/line/others/GlobalVariable.tsx b/web/app/components/base/icons/src/vender/line/others/GlobalVariable.tsx index c263e8ec9d1b99..adff35233c8a63 100644 --- a/web/app/components/base/icons/src/vender/line/others/GlobalVariable.tsx +++ b/web/app/components/base/icons/src/vender/line/others/GlobalVariable.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './GlobalVariable.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'GlobalVariable' diff --git a/web/app/components/base/icons/src/vender/line/others/Icon3Dots.tsx b/web/app/components/base/icons/src/vender/line/others/Icon3Dots.tsx index bd0bf9d4501c74..2ec4286c1a65f4 100644 --- a/web/app/components/base/icons/src/vender/line/others/Icon3Dots.tsx +++ b/web/app/components/base/icons/src/vender/line/others/Icon3Dots.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Icon3Dots.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Icon3Dots' diff --git a/web/app/components/base/icons/src/vender/line/others/LongArrowLeft.tsx b/web/app/components/base/icons/src/vender/line/others/LongArrowLeft.tsx index 55997419e43eca..779252c07a12e2 100644 --- a/web/app/components/base/icons/src/vender/line/others/LongArrowLeft.tsx +++ b/web/app/components/base/icons/src/vender/line/others/LongArrowLeft.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './LongArrowLeft.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'LongArrowLeft' diff --git a/web/app/components/base/icons/src/vender/line/others/LongArrowRight.tsx b/web/app/components/base/icons/src/vender/line/others/LongArrowRight.tsx index 6c8bd988fed70f..995e6af0116478 100644 --- a/web/app/components/base/icons/src/vender/line/others/LongArrowRight.tsx +++ b/web/app/components/base/icons/src/vender/line/others/LongArrowRight.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './LongArrowRight.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'LongArrowRight' diff --git a/web/app/components/base/icons/src/vender/line/time/ClockFastForward.tsx b/web/app/components/base/icons/src/vender/line/time/ClockFastForward.tsx index beee5e3a83b053..341b652fbd71b3 100644 --- a/web/app/components/base/icons/src/vender/line/time/ClockFastForward.tsx +++ b/web/app/components/base/icons/src/vender/line/time/ClockFastForward.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ClockFastForward.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ClockFastForward' diff --git a/web/app/components/base/icons/src/vender/line/time/ClockPlay.tsx b/web/app/components/base/icons/src/vender/line/time/ClockPlay.tsx index df3d365b22e4f7..2574389d21cf96 100644 --- a/web/app/components/base/icons/src/vender/line/time/ClockPlay.tsx +++ b/web/app/components/base/icons/src/vender/line/time/ClockPlay.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ClockPlay.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ClockPlay' diff --git a/web/app/components/base/icons/src/vender/other/Generator.tsx b/web/app/components/base/icons/src/vender/other/Generator.tsx index 305b49fa20b1dd..ab85b77d1a57c4 100644 --- a/web/app/components/base/icons/src/vender/other/Generator.tsx +++ b/web/app/components/base/icons/src/vender/other/Generator.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Generator.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Generator' diff --git a/web/app/components/base/icons/src/vender/other/Group.tsx b/web/app/components/base/icons/src/vender/other/Group.tsx index c3c09d1a0cadc5..c7de5812db3cdb 100644 --- a/web/app/components/base/icons/src/vender/other/Group.tsx +++ b/web/app/components/base/icons/src/vender/other/Group.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Group.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Group' diff --git a/web/app/components/base/icons/src/vender/other/HourglassShape.tsx b/web/app/components/base/icons/src/vender/other/HourglassShape.tsx index dd59b8807776b5..a83fbc63902eac 100644 --- a/web/app/components/base/icons/src/vender/other/HourglassShape.tsx +++ b/web/app/components/base/icons/src/vender/other/HourglassShape.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './HourglassShape.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'HourglassShape' diff --git a/web/app/components/base/icons/src/vender/other/Mcp.tsx b/web/app/components/base/icons/src/vender/other/Mcp.tsx index 28732160552e09..19699e0cafc936 100644 --- a/web/app/components/base/icons/src/vender/other/Mcp.tsx +++ b/web/app/components/base/icons/src/vender/other/Mcp.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Mcp.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Mcp' diff --git a/web/app/components/base/icons/src/vender/other/NoToolPlaceholder.tsx b/web/app/components/base/icons/src/vender/other/NoToolPlaceholder.tsx index a60f57c3e7a8e8..53b06bb0d3295a 100644 --- a/web/app/components/base/icons/src/vender/other/NoToolPlaceholder.tsx +++ b/web/app/components/base/icons/src/vender/other/NoToolPlaceholder.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './NoToolPlaceholder.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'NoToolPlaceholder' diff --git a/web/app/components/base/icons/src/vender/other/Openai.tsx b/web/app/components/base/icons/src/vender/other/Openai.tsx index 953b26bd119392..64e74ba29cc452 100644 --- a/web/app/components/base/icons/src/vender/other/Openai.tsx +++ b/web/app/components/base/icons/src/vender/other/Openai.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Openai.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Openai' diff --git a/web/app/components/base/icons/src/vender/other/ReplayLine.tsx b/web/app/components/base/icons/src/vender/other/ReplayLine.tsx index a6aa654dc4a3aa..83b74c1b261a3e 100644 --- a/web/app/components/base/icons/src/vender/other/ReplayLine.tsx +++ b/web/app/components/base/icons/src/vender/other/ReplayLine.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ReplayLine.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ReplayLine' diff --git a/web/app/components/base/icons/src/vender/other/SquareChecklist.tsx b/web/app/components/base/icons/src/vender/other/SquareChecklist.tsx index 99403f551b1f71..4d0a53b758e9cc 100644 --- a/web/app/components/base/icons/src/vender/other/SquareChecklist.tsx +++ b/web/app/components/base/icons/src/vender/other/SquareChecklist.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './SquareChecklist.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'SquareChecklist' diff --git a/web/app/components/base/icons/src/vender/pipeline/InputField.tsx b/web/app/components/base/icons/src/vender/pipeline/InputField.tsx index dfeebc00772507..2d4215df6ac181 100644 --- a/web/app/components/base/icons/src/vender/pipeline/InputField.tsx +++ b/web/app/components/base/icons/src/vender/pipeline/InputField.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './InputField.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'InputField' diff --git a/web/app/components/base/icons/src/vender/pipeline/PipelineFill.tsx b/web/app/components/base/icons/src/vender/pipeline/PipelineFill.tsx index ac28c2cc7a110f..ebee7134d6e4d0 100644 --- a/web/app/components/base/icons/src/vender/pipeline/PipelineFill.tsx +++ b/web/app/components/base/icons/src/vender/pipeline/PipelineFill.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './PipelineFill.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'PipelineFill' diff --git a/web/app/components/base/icons/src/vender/pipeline/PipelineLine.tsx b/web/app/components/base/icons/src/vender/pipeline/PipelineLine.tsx index 2a18c6bad7fac3..ea053a4e02ded6 100644 --- a/web/app/components/base/icons/src/vender/pipeline/PipelineLine.tsx +++ b/web/app/components/base/icons/src/vender/pipeline/PipelineLine.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './PipelineLine.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'PipelineLine' diff --git a/web/app/components/base/icons/src/vender/plugin/BoxSparkleFill.tsx b/web/app/components/base/icons/src/vender/plugin/BoxSparkleFill.tsx index b37297aa510918..6a22682c2ba497 100644 --- a/web/app/components/base/icons/src/vender/plugin/BoxSparkleFill.tsx +++ b/web/app/components/base/icons/src/vender/plugin/BoxSparkleFill.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './BoxSparkleFill.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'BoxSparkleFill' diff --git a/web/app/components/base/icons/src/vender/plugin/LeftCorner.tsx b/web/app/components/base/icons/src/vender/plugin/LeftCorner.tsx index ed2924ec3869a4..ae647ec12bbaf7 100644 --- a/web/app/components/base/icons/src/vender/plugin/LeftCorner.tsx +++ b/web/app/components/base/icons/src/vender/plugin/LeftCorner.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './LeftCorner.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'LeftCorner' diff --git a/web/app/components/base/icons/src/vender/plugin/Plugin.tsx b/web/app/components/base/icons/src/vender/plugin/Plugin.tsx index 72f22561bf07cc..181fba6d654075 100644 --- a/web/app/components/base/icons/src/vender/plugin/Plugin.tsx +++ b/web/app/components/base/icons/src/vender/plugin/Plugin.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Plugin.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Plugin' diff --git a/web/app/components/base/icons/src/vender/plugin/Trigger.tsx b/web/app/components/base/icons/src/vender/plugin/Trigger.tsx index aa748da66d2783..0db03c598369a1 100644 --- a/web/app/components/base/icons/src/vender/plugin/Trigger.tsx +++ b/web/app/components/base/icons/src/vender/plugin/Trigger.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Trigger.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Trigger' diff --git a/web/app/components/base/icons/src/vender/solid/FinanceAndECommerce/Scales02.tsx b/web/app/components/base/icons/src/vender/solid/FinanceAndECommerce/Scales02.tsx index f9c6a487786491..ec927109133a90 100644 --- a/web/app/components/base/icons/src/vender/solid/FinanceAndECommerce/Scales02.tsx +++ b/web/app/components/base/icons/src/vender/solid/FinanceAndECommerce/Scales02.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Scales02.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Scales02' diff --git a/web/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle.tsx b/web/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle.tsx index 7f07c8b258033c..8953df8dffa98d 100644 --- a/web/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle.tsx +++ b/web/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './AlertTriangle.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'AlertTriangle' diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx index 27c0012012de34..9ee4105a10321b 100644 --- a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ArrowDownRoundFill.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ArrowDownRoundFill' diff --git a/web/app/components/base/icons/src/vender/solid/communication/BubbleTextMod.tsx b/web/app/components/base/icons/src/vender/solid/communication/BubbleTextMod.tsx index 0f834a05f89d4c..780b017dac1e8a 100644 --- a/web/app/components/base/icons/src/vender/solid/communication/BubbleTextMod.tsx +++ b/web/app/components/base/icons/src/vender/solid/communication/BubbleTextMod.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './BubbleTextMod.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'BubbleTextMod' diff --git a/web/app/components/base/icons/src/vender/solid/communication/ChatBot.tsx b/web/app/components/base/icons/src/vender/solid/communication/ChatBot.tsx index 391670d718647a..0f976e45fb66a3 100644 --- a/web/app/components/base/icons/src/vender/solid/communication/ChatBot.tsx +++ b/web/app/components/base/icons/src/vender/solid/communication/ChatBot.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ChatBot.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ChatBot' diff --git a/web/app/components/base/icons/src/vender/solid/communication/CuteRobot.tsx b/web/app/components/base/icons/src/vender/solid/communication/CuteRobot.tsx index 9bba738c692600..917fa13504f622 100644 --- a/web/app/components/base/icons/src/vender/solid/communication/CuteRobot.tsx +++ b/web/app/components/base/icons/src/vender/solid/communication/CuteRobot.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './CuteRobot.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'CuteRobot' diff --git a/web/app/components/base/icons/src/vender/solid/communication/ListSparkle.tsx b/web/app/components/base/icons/src/vender/solid/communication/ListSparkle.tsx index 0ec9f865f5df57..d91534c5364871 100644 --- a/web/app/components/base/icons/src/vender/solid/communication/ListSparkle.tsx +++ b/web/app/components/base/icons/src/vender/solid/communication/ListSparkle.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ListSparkle.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ListSparkle' diff --git a/web/app/components/base/icons/src/vender/solid/communication/Logic.tsx b/web/app/components/base/icons/src/vender/solid/communication/Logic.tsx index 85707c0a05932c..8e46cea4d14692 100644 --- a/web/app/components/base/icons/src/vender/solid/communication/Logic.tsx +++ b/web/app/components/base/icons/src/vender/solid/communication/Logic.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Logic.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Logic' diff --git a/web/app/components/base/icons/src/vender/solid/communication/MessageFast.tsx b/web/app/components/base/icons/src/vender/solid/communication/MessageFast.tsx index 1f0374c2dc5ca7..51d67e4e2fce70 100644 --- a/web/app/components/base/icons/src/vender/solid/communication/MessageFast.tsx +++ b/web/app/components/base/icons/src/vender/solid/communication/MessageFast.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './MessageFast.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'MessageFast' diff --git a/web/app/components/base/icons/src/vender/solid/development/ApiConnection.tsx b/web/app/components/base/icons/src/vender/solid/development/ApiConnection.tsx index d341591f1e5c38..48bbcda4680430 100644 --- a/web/app/components/base/icons/src/vender/solid/development/ApiConnection.tsx +++ b/web/app/components/base/icons/src/vender/solid/development/ApiConnection.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ApiConnection.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ApiConnection' diff --git a/web/app/components/base/icons/src/vender/solid/development/ApiConnectionMod.tsx b/web/app/components/base/icons/src/vender/solid/development/ApiConnectionMod.tsx index a8617b2ec0445a..9abd5802ac742a 100644 --- a/web/app/components/base/icons/src/vender/solid/development/ApiConnectionMod.tsx +++ b/web/app/components/base/icons/src/vender/solid/development/ApiConnectionMod.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ApiConnectionMod.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ApiConnectionMod' diff --git a/web/app/components/base/icons/src/vender/solid/development/TerminalSquare.tsx b/web/app/components/base/icons/src/vender/solid/development/TerminalSquare.tsx index 91cd54a137f3bb..d4d36792905e46 100644 --- a/web/app/components/base/icons/src/vender/solid/development/TerminalSquare.tsx +++ b/web/app/components/base/icons/src/vender/solid/development/TerminalSquare.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './TerminalSquare.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'TerminalSquare' diff --git a/web/app/components/base/icons/src/vender/solid/development/Variable02.tsx b/web/app/components/base/icons/src/vender/solid/development/Variable02.tsx index 5a46cee35d61ec..6a36d0725f2324 100644 --- a/web/app/components/base/icons/src/vender/solid/development/Variable02.tsx +++ b/web/app/components/base/icons/src/vender/solid/development/Variable02.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Variable02.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Variable02' diff --git a/web/app/components/base/icons/src/vender/solid/editor/Brush01.tsx b/web/app/components/base/icons/src/vender/solid/editor/Brush01.tsx index a9c466da4f8ae8..cd827264a4ff3d 100644 --- a/web/app/components/base/icons/src/vender/solid/editor/Brush01.tsx +++ b/web/app/components/base/icons/src/vender/solid/editor/Brush01.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Brush01.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Brush01' diff --git a/web/app/components/base/icons/src/vender/solid/education/Beaker02.tsx b/web/app/components/base/icons/src/vender/solid/education/Beaker02.tsx index 5a2ecf68dc180d..69fd8f088a4930 100644 --- a/web/app/components/base/icons/src/vender/solid/education/Beaker02.tsx +++ b/web/app/components/base/icons/src/vender/solid/education/Beaker02.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Beaker02.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Beaker02' diff --git a/web/app/components/base/icons/src/vender/solid/education/Unblur.tsx b/web/app/components/base/icons/src/vender/solid/education/Unblur.tsx index 9db06af4df2cac..dc7bffdb96c522 100644 --- a/web/app/components/base/icons/src/vender/solid/education/Unblur.tsx +++ b/web/app/components/base/icons/src/vender/solid/education/Unblur.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Unblur.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Unblur' diff --git a/web/app/components/base/icons/src/vender/solid/files/File05.tsx b/web/app/components/base/icons/src/vender/solid/files/File05.tsx index 5775bfb830afc3..738c082f701310 100644 --- a/web/app/components/base/icons/src/vender/solid/files/File05.tsx +++ b/web/app/components/base/icons/src/vender/solid/files/File05.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './File05.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'File05' diff --git a/web/app/components/base/icons/src/vender/solid/files/FileZip.tsx b/web/app/components/base/icons/src/vender/solid/files/FileZip.tsx index b404e7213002c4..fcad4e4cf422aa 100644 --- a/web/app/components/base/icons/src/vender/solid/files/FileZip.tsx +++ b/web/app/components/base/icons/src/vender/solid/files/FileZip.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './FileZip.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'FileZip' diff --git a/web/app/components/base/icons/src/vender/solid/files/Folder.tsx b/web/app/components/base/icons/src/vender/solid/files/Folder.tsx index 5d656dee6166e5..86542222cdb736 100644 --- a/web/app/components/base/icons/src/vender/solid/files/Folder.tsx +++ b/web/app/components/base/icons/src/vender/solid/files/Folder.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Folder.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Folder' diff --git a/web/app/components/base/icons/src/vender/solid/general/ArrowDownRoundFill.tsx b/web/app/components/base/icons/src/vender/solid/general/ArrowDownRoundFill.tsx index 27c0012012de34..9ee4105a10321b 100644 --- a/web/app/components/base/icons/src/vender/solid/general/ArrowDownRoundFill.tsx +++ b/web/app/components/base/icons/src/vender/solid/general/ArrowDownRoundFill.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ArrowDownRoundFill.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ArrowDownRoundFill' diff --git a/web/app/components/base/icons/src/vender/solid/general/CheckCircle.tsx b/web/app/components/base/icons/src/vender/solid/general/CheckCircle.tsx index cf4d1c6edaa27b..08b960e6ce3762 100644 --- a/web/app/components/base/icons/src/vender/solid/general/CheckCircle.tsx +++ b/web/app/components/base/icons/src/vender/solid/general/CheckCircle.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './CheckCircle.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'CheckCircle' diff --git a/web/app/components/base/icons/src/vender/solid/general/Download02.tsx b/web/app/components/base/icons/src/vender/solid/general/Download02.tsx index 2435f87c7a964f..16a1d54eb9f815 100644 --- a/web/app/components/base/icons/src/vender/solid/general/Download02.tsx +++ b/web/app/components/base/icons/src/vender/solid/general/Download02.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Download02.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Download02' diff --git a/web/app/components/base/icons/src/vender/solid/general/Edit03.tsx b/web/app/components/base/icons/src/vender/solid/general/Edit03.tsx index a9d2635e2e61b2..387c92d5454ae8 100644 --- a/web/app/components/base/icons/src/vender/solid/general/Edit03.tsx +++ b/web/app/components/base/icons/src/vender/solid/general/Edit03.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Edit03.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Edit03' diff --git a/web/app/components/base/icons/src/vender/solid/general/Eye.tsx b/web/app/components/base/icons/src/vender/solid/general/Eye.tsx index bea4a7536e63bb..194cc5aad42d29 100644 --- a/web/app/components/base/icons/src/vender/solid/general/Eye.tsx +++ b/web/app/components/base/icons/src/vender/solid/general/Eye.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Eye.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Eye' diff --git a/web/app/components/base/icons/src/vender/solid/general/Github.tsx b/web/app/components/base/icons/src/vender/solid/general/Github.tsx index a321116efcdc2f..4a797bb9c6280a 100644 --- a/web/app/components/base/icons/src/vender/solid/general/Github.tsx +++ b/web/app/components/base/icons/src/vender/solid/general/Github.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Github.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Github' diff --git a/web/app/components/base/icons/src/vender/solid/general/MessageClockCircle.tsx b/web/app/components/base/icons/src/vender/solid/general/MessageClockCircle.tsx index b397bbfd801089..cdfd13266250f9 100644 --- a/web/app/components/base/icons/src/vender/solid/general/MessageClockCircle.tsx +++ b/web/app/components/base/icons/src/vender/solid/general/MessageClockCircle.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './MessageClockCircle.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'MessageClockCircle' diff --git a/web/app/components/base/icons/src/vender/solid/general/Target04.tsx b/web/app/components/base/icons/src/vender/solid/general/Target04.tsx index b2025a823ec779..5bd60a914d8a3a 100644 --- a/web/app/components/base/icons/src/vender/solid/general/Target04.tsx +++ b/web/app/components/base/icons/src/vender/solid/general/Target04.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Target04.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Target04' diff --git a/web/app/components/base/icons/src/vender/solid/general/Tool03.tsx b/web/app/components/base/icons/src/vender/solid/general/Tool03.tsx index 0ee0708672c3da..5ad20f5a400ad6 100644 --- a/web/app/components/base/icons/src/vender/solid/general/Tool03.tsx +++ b/web/app/components/base/icons/src/vender/solid/general/Tool03.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Tool03.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Tool03' diff --git a/web/app/components/base/icons/src/vender/solid/general/XCircle.tsx b/web/app/components/base/icons/src/vender/solid/general/XCircle.tsx index 158182fc82ecce..5b0698144a7bdd 100644 --- a/web/app/components/base/icons/src/vender/solid/general/XCircle.tsx +++ b/web/app/components/base/icons/src/vender/solid/general/XCircle.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './XCircle.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'XCircle' diff --git a/web/app/components/base/icons/src/vender/solid/general/ZapFast.tsx b/web/app/components/base/icons/src/vender/solid/general/ZapFast.tsx index b3b2615e6238c7..a4a91a5c62db36 100644 --- a/web/app/components/base/icons/src/vender/solid/general/ZapFast.tsx +++ b/web/app/components/base/icons/src/vender/solid/general/ZapFast.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ZapFast.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ZapFast' diff --git a/web/app/components/base/icons/src/vender/solid/mediaAndDevices/MagicBox.tsx b/web/app/components/base/icons/src/vender/solid/mediaAndDevices/MagicBox.tsx index 78049a53463787..ea8ce238590787 100644 --- a/web/app/components/base/icons/src/vender/solid/mediaAndDevices/MagicBox.tsx +++ b/web/app/components/base/icons/src/vender/solid/mediaAndDevices/MagicBox.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './MagicBox.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'MagicBox' diff --git a/web/app/components/base/icons/src/vender/solid/mediaAndDevices/StopCircle.tsx b/web/app/components/base/icons/src/vender/solid/mediaAndDevices/StopCircle.tsx index 996fe08104ff12..6e3bb43df73cd2 100644 --- a/web/app/components/base/icons/src/vender/solid/mediaAndDevices/StopCircle.tsx +++ b/web/app/components/base/icons/src/vender/solid/mediaAndDevices/StopCircle.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './StopCircle.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'StopCircle' diff --git a/web/app/components/base/icons/src/vender/solid/security/Lock01.tsx b/web/app/components/base/icons/src/vender/solid/security/Lock01.tsx index 39c650c666e980..8b13a759fc0f21 100644 --- a/web/app/components/base/icons/src/vender/solid/security/Lock01.tsx +++ b/web/app/components/base/icons/src/vender/solid/security/Lock01.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Lock01.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Lock01' diff --git a/web/app/components/base/icons/src/vender/solid/shapes/Corner.tsx b/web/app/components/base/icons/src/vender/solid/shapes/Corner.tsx index a9916fed5c7833..509966e7b26171 100644 --- a/web/app/components/base/icons/src/vender/solid/shapes/Corner.tsx +++ b/web/app/components/base/icons/src/vender/solid/shapes/Corner.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Corner.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Corner' diff --git a/web/app/components/base/icons/src/vender/solid/users/UserEdit02.tsx b/web/app/components/base/icons/src/vender/solid/users/UserEdit02.tsx index b6415fa1d3e58c..6d91c004eca279 100644 --- a/web/app/components/base/icons/src/vender/solid/users/UserEdit02.tsx +++ b/web/app/components/base/icons/src/vender/solid/users/UserEdit02.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './UserEdit02.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'UserEdit02' diff --git a/web/app/components/base/icons/src/vender/system/AutoUpdateLine.tsx b/web/app/components/base/icons/src/vender/system/AutoUpdateLine.tsx index 2f06d1c16a6ebf..3afc995274f6d8 100644 --- a/web/app/components/base/icons/src/vender/system/AutoUpdateLine.tsx +++ b/web/app/components/base/icons/src/vender/system/AutoUpdateLine.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './AutoUpdateLine.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'AutoUpdateLine' diff --git a/web/app/components/base/icons/src/vender/workflow/Agent.tsx b/web/app/components/base/icons/src/vender/workflow/Agent.tsx index 1a037986ec3bfa..68dc4cd6af3c84 100644 --- a/web/app/components/base/icons/src/vender/workflow/Agent.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Agent.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Agent.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Agent' diff --git a/web/app/components/base/icons/src/vender/workflow/Answer.tsx b/web/app/components/base/icons/src/vender/workflow/Answer.tsx index 315d483e619b35..158559069ece1b 100644 --- a/web/app/components/base/icons/src/vender/workflow/Answer.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Answer.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Answer.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Answer' diff --git a/web/app/components/base/icons/src/vender/workflow/ApiAggregate.tsx b/web/app/components/base/icons/src/vender/workflow/ApiAggregate.tsx index ccc987f423e0b1..0542bb6380e557 100644 --- a/web/app/components/base/icons/src/vender/workflow/ApiAggregate.tsx +++ b/web/app/components/base/icons/src/vender/workflow/ApiAggregate.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ApiAggregate.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ApiAggregate' diff --git a/web/app/components/base/icons/src/vender/workflow/Assigner.tsx b/web/app/components/base/icons/src/vender/workflow/Assigner.tsx index cda3abf93eaeb2..fee94f35803ef7 100644 --- a/web/app/components/base/icons/src/vender/workflow/Assigner.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Assigner.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Assigner.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Assigner' diff --git a/web/app/components/base/icons/src/vender/workflow/Asterisk.tsx b/web/app/components/base/icons/src/vender/workflow/Asterisk.tsx index ec1a1112dd3180..2ee18fa40b6624 100644 --- a/web/app/components/base/icons/src/vender/workflow/Asterisk.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Asterisk.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Asterisk.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Asterisk' diff --git a/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.tsx b/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.tsx index 6aec766526b39a..f0b62f161f2744 100644 --- a/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.tsx +++ b/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './CalendarCheckLine.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'CalendarCheckLine' diff --git a/web/app/components/base/icons/src/vender/workflow/Code.tsx b/web/app/components/base/icons/src/vender/workflow/Code.tsx index d6cee2ce188e40..058658076b9758 100644 --- a/web/app/components/base/icons/src/vender/workflow/Code.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Code.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Code.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Code' diff --git a/web/app/components/base/icons/src/vender/workflow/Datasource.tsx b/web/app/components/base/icons/src/vender/workflow/Datasource.tsx index 2c062b7d45fc89..67cd8934511e3d 100644 --- a/web/app/components/base/icons/src/vender/workflow/Datasource.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Datasource.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Datasource.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Datasource' diff --git a/web/app/components/base/icons/src/vender/workflow/DocsExtractor.tsx b/web/app/components/base/icons/src/vender/workflow/DocsExtractor.tsx index fa4c675bace895..a9f3bc1f5c5bb6 100644 --- a/web/app/components/base/icons/src/vender/workflow/DocsExtractor.tsx +++ b/web/app/components/base/icons/src/vender/workflow/DocsExtractor.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './DocsExtractor.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'DocsExtractor' diff --git a/web/app/components/base/icons/src/vender/workflow/End.tsx b/web/app/components/base/icons/src/vender/workflow/End.tsx index e98b2beabae8a2..f6400d3a65c090 100644 --- a/web/app/components/base/icons/src/vender/workflow/End.tsx +++ b/web/app/components/base/icons/src/vender/workflow/End.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './End.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'End' diff --git a/web/app/components/base/icons/src/vender/workflow/Home.tsx b/web/app/components/base/icons/src/vender/workflow/Home.tsx index 00f84a79f36355..48f81133a0396a 100644 --- a/web/app/components/base/icons/src/vender/workflow/Home.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Home.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Home.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Home' diff --git a/web/app/components/base/icons/src/vender/workflow/Http.tsx b/web/app/components/base/icons/src/vender/workflow/Http.tsx index 7d1546d37da7e0..c2393618d4f48b 100644 --- a/web/app/components/base/icons/src/vender/workflow/Http.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Http.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Http.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Http' diff --git a/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx b/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx index 8c8864247659f2..f0832dac1f3c46 100644 --- a/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx +++ b/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './HumanInLoop.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'HumanInLoop' diff --git a/web/app/components/base/icons/src/vender/workflow/IfElse.tsx b/web/app/components/base/icons/src/vender/workflow/IfElse.tsx index 9cd32e977a4e13..734b22ab9566ac 100644 --- a/web/app/components/base/icons/src/vender/workflow/IfElse.tsx +++ b/web/app/components/base/icons/src/vender/workflow/IfElse.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './IfElse.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'IfElse' diff --git a/web/app/components/base/icons/src/vender/workflow/Iteration.tsx b/web/app/components/base/icons/src/vender/workflow/Iteration.tsx index 5758e540c511fa..da78eb0a4afd46 100644 --- a/web/app/components/base/icons/src/vender/workflow/Iteration.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Iteration.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Iteration.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Iteration' diff --git a/web/app/components/base/icons/src/vender/workflow/Jinja.tsx b/web/app/components/base/icons/src/vender/workflow/Jinja.tsx index 1b5d0d3745603c..1e7ed58123e74f 100644 --- a/web/app/components/base/icons/src/vender/workflow/Jinja.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Jinja.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Jinja.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Jinja' diff --git a/web/app/components/base/icons/src/vender/workflow/KnowledgeBase.tsx b/web/app/components/base/icons/src/vender/workflow/KnowledgeBase.tsx index 15aa9097bc4520..20bd0f4234fe7a 100644 --- a/web/app/components/base/icons/src/vender/workflow/KnowledgeBase.tsx +++ b/web/app/components/base/icons/src/vender/workflow/KnowledgeBase.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './KnowledgeBase.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'KnowledgeBase' diff --git a/web/app/components/base/icons/src/vender/workflow/KnowledgeRetrieval.tsx b/web/app/components/base/icons/src/vender/workflow/KnowledgeRetrieval.tsx index 2509d6bdd44c84..60371a6be865ab 100644 --- a/web/app/components/base/icons/src/vender/workflow/KnowledgeRetrieval.tsx +++ b/web/app/components/base/icons/src/vender/workflow/KnowledgeRetrieval.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './KnowledgeRetrieval.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'KnowledgeRetrieval' diff --git a/web/app/components/base/icons/src/vender/workflow/ListFilter.tsx b/web/app/components/base/icons/src/vender/workflow/ListFilter.tsx index 9b1f790759f59e..07ec1c13fd38b9 100644 --- a/web/app/components/base/icons/src/vender/workflow/ListFilter.tsx +++ b/web/app/components/base/icons/src/vender/workflow/ListFilter.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ListFilter.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ListFilter' diff --git a/web/app/components/base/icons/src/vender/workflow/Llm.tsx b/web/app/components/base/icons/src/vender/workflow/Llm.tsx index 543627bdec16a9..18410a4597525b 100644 --- a/web/app/components/base/icons/src/vender/workflow/Llm.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Llm.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Llm.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Llm' diff --git a/web/app/components/base/icons/src/vender/workflow/Loop.tsx b/web/app/components/base/icons/src/vender/workflow/Loop.tsx index b72ccec25c6cd8..daf5401c921d78 100644 --- a/web/app/components/base/icons/src/vender/workflow/Loop.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Loop.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Loop.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Loop' diff --git a/web/app/components/base/icons/src/vender/workflow/LoopEnd.tsx b/web/app/components/base/icons/src/vender/workflow/LoopEnd.tsx index b015a86362886e..e4fa824c743a18 100644 --- a/web/app/components/base/icons/src/vender/workflow/LoopEnd.tsx +++ b/web/app/components/base/icons/src/vender/workflow/LoopEnd.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './LoopEnd.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'LoopEnd' diff --git a/web/app/components/base/icons/src/vender/workflow/ParameterExtractor.tsx b/web/app/components/base/icons/src/vender/workflow/ParameterExtractor.tsx index b027f58f55aa0f..06ec11524c6c6d 100644 --- a/web/app/components/base/icons/src/vender/workflow/ParameterExtractor.tsx +++ b/web/app/components/base/icons/src/vender/workflow/ParameterExtractor.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './ParameterExtractor.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'ParameterExtractor' diff --git a/web/app/components/base/icons/src/vender/workflow/QuestionClassifier.tsx b/web/app/components/base/icons/src/vender/workflow/QuestionClassifier.tsx index f1d6af1390b07d..c37dcf74cf6724 100644 --- a/web/app/components/base/icons/src/vender/workflow/QuestionClassifier.tsx +++ b/web/app/components/base/icons/src/vender/workflow/QuestionClassifier.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './QuestionClassifier.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'QuestionClassifier' diff --git a/web/app/components/base/icons/src/vender/workflow/Schedule.tsx b/web/app/components/base/icons/src/vender/workflow/Schedule.tsx index 277eacff2b28ab..b79e577ac27926 100644 --- a/web/app/components/base/icons/src/vender/workflow/Schedule.tsx +++ b/web/app/components/base/icons/src/vender/workflow/Schedule.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './Schedule.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'Schedule' diff --git a/web/app/components/base/icons/src/vender/workflow/TemplatingTransform.tsx b/web/app/components/base/icons/src/vender/workflow/TemplatingTransform.tsx index 5bec3f0c31b746..8d65460d4befb7 100644 --- a/web/app/components/base/icons/src/vender/workflow/TemplatingTransform.tsx +++ b/web/app/components/base/icons/src/vender/workflow/TemplatingTransform.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './TemplatingTransform.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'TemplatingTransform' diff --git a/web/app/components/base/icons/src/vender/workflow/TriggerAll.tsx b/web/app/components/base/icons/src/vender/workflow/TriggerAll.tsx index 78b3b371fda8ad..3dc9dbf2e40ea4 100644 --- a/web/app/components/base/icons/src/vender/workflow/TriggerAll.tsx +++ b/web/app/components/base/icons/src/vender/workflow/TriggerAll.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './TriggerAll.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'TriggerAll' diff --git a/web/app/components/base/icons/src/vender/workflow/VariableX.tsx b/web/app/components/base/icons/src/vender/workflow/VariableX.tsx index 08a4e44e1442aa..66498eace2d10e 100644 --- a/web/app/components/base/icons/src/vender/workflow/VariableX.tsx +++ b/web/app/components/base/icons/src/vender/workflow/VariableX.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './VariableX.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'VariableX' diff --git a/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx b/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx index 2067946fff22ee..d9e685d74e3c7f 100644 --- a/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx +++ b/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './WebhookLine.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'WebhookLine' diff --git a/web/app/components/base/icons/src/vender/workflow/WindowCursor.tsx b/web/app/components/base/icons/src/vender/workflow/WindowCursor.tsx index 5dadad3e3876b9..eb0c0dcab1f091 100644 --- a/web/app/components/base/icons/src/vender/workflow/WindowCursor.tsx +++ b/web/app/components/base/icons/src/vender/workflow/WindowCursor.tsx @@ -6,14 +6,12 @@ import * as React from 'react' import IconBase from '@/app/components/base/icons/IconBase' import data from './WindowCursor.json' -const Icon = ( - { - ref, - ...props - }: React.SVGProps<SVGSVGElement> & { - ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> - }, -) => <IconBase {...props} ref={ref} data={data as IconData} /> +const Icon = ({ + ref, + ...props +}: React.SVGProps<SVGSVGElement> & { + ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>> +}) => <IconBase {...props} ref={ref} data={data as IconData} /> Icon.displayName = 'WindowCursor' diff --git a/web/app/components/base/icons/utils.ts b/web/app/components/base/icons/utils.ts index a26b276513f85b..a1cebdcd98be2d 100644 --- a/web/app/components/base/icons/utils.ts +++ b/web/app/components/base/icons/utils.ts @@ -15,27 +15,30 @@ type Attrs = { export function normalizeAttrs(attrs: Attrs = {}): Attrs { return Object.keys(attrs).reduce((acc: Attrs, key) => { // Filter out editor metadata attributes before processing - if (key.startsWith('inkscape:') - || key.startsWith('sodipodi:') - || key.startsWith('xmlns:inkscape') - || key.startsWith('xmlns:sodipodi') - || key.startsWith('xmlns:svg') - || key === 'data-name') { + if ( + key.startsWith('inkscape:') || + key.startsWith('sodipodi:') || + key.startsWith('xmlns:inkscape') || + key.startsWith('xmlns:sodipodi') || + key.startsWith('xmlns:svg') || + key === 'data-name' + ) { return acc } const val = attrs[key] - if (val === undefined) - return acc + if (val === undefined) return acc key = key.replace(/(-\w)/g, (g: string) => g[1]!.toUpperCase()) key = key.replace(/(:\w)/g, (g: string) => g[1]!.toUpperCase()) // Additional filter after camelCase conversion - if (key === 'xmlnsInkscape' - || key === 'xmlnsSodipodi' - || key === 'xmlnsSvg' - || key === 'dataName') { + if ( + key === 'xmlnsInkscape' || + key === 'xmlnsSodipodi' || + key === 'xmlnsSvg' || + key === 'dataName' + ) { return acc } @@ -45,7 +48,7 @@ export function normalizeAttrs(attrs: Attrs = {}): Attrs { delete acc.class break case 'style': - (acc.style as any) = val.split(';').reduce((prev, next) => { + ;(acc.style as any) = val.split(';').reduce((prev, next) => { const pairs = next?.split(':') if (pairs[0] && pairs[1]) { diff --git a/web/app/components/base/image-gallery/__tests__/index.spec.tsx b/web/app/components/base/image-gallery/__tests__/index.spec.tsx index 381f0293fcdcb2..a481bbcc010d4b 100644 --- a/web/app/components/base/image-gallery/__tests__/index.spec.tsx +++ b/web/app/components/base/image-gallery/__tests__/index.spec.tsx @@ -15,7 +15,11 @@ describe('ImageGallery', () => { }) it('should render multiple images', () => { - const srcs = ['https://example.com/1.png', 'https://example.com/2.png', 'https://example.com/3.png'] + const srcs = [ + 'https://example.com/1.png', + 'https://example.com/2.png', + 'https://example.com/3.png', + ] const { container } = render(<ImageGallery srcs={srcs} />) expect(getImages(container)).toHaveLength(3) @@ -50,37 +54,45 @@ describe('ImageGallery', () => { }) it('should apply calc(50% - 4px) width for 2 images', () => { - const { container } = render(<ImageGallery srcs={['https://example.com/1.png', 'https://example.com/2.png']} />) + const { container } = render( + <ImageGallery srcs={['https://example.com/1.png', 'https://example.com/2.png']} />, + ) - getImages(container).forEach(img => expect(img.style.width).toBe('calc(50% - 4px)')) + getImages(container).forEach((img) => expect(img.style.width).toBe('calc(50% - 4px)')) }) it('should apply calc(50% - 4px) width for 4 images', () => { const srcs = Array.from({ length: 4 }, (_, i) => `https://example.com/${i}.png`) const { container } = render(<ImageGallery srcs={srcs} />) - getImages(container).forEach(img => expect(img.style.width).toBe('calc(50% - 4px)')) + getImages(container).forEach((img) => expect(img.style.width).toBe('calc(50% - 4px)')) }) it('should apply calc(33.3333% - 5.3333px) width for 3 images', () => { const srcs = Array.from({ length: 3 }, (_, i) => `https://example.com/${i}.png`) const { container } = render(<ImageGallery srcs={srcs} />) - getImages(container).forEach(img => expect(img.style.width).toBe('calc(33.3333% - 5.3333px)')) + getImages(container).forEach((img) => + expect(img.style.width).toBe('calc(33.3333% - 5.3333px)'), + ) }) it('should apply calc(33.3333% - 5.3333px) width for 5 images', () => { const srcs = Array.from({ length: 5 }, (_, i) => `https://example.com/${i}.png`) const { container } = render(<ImageGallery srcs={srcs} />) - getImages(container).forEach(img => expect(img.style.width).toBe('calc(33.3333% - 5.3333px)')) + getImages(container).forEach((img) => + expect(img.style.width).toBe('calc(33.3333% - 5.3333px)'), + ) }) it('should apply calc(33.3333% - 5.3333px) width for 6 images', () => { const srcs = Array.from({ length: 6 }, (_, i) => `https://example.com/${i}.png`) const { container } = render(<ImageGallery srcs={srcs} />) - getImages(container).forEach(img => expect(img.style.width).toBe('calc(33.3333% - 5.3333px)')) + getImages(container).forEach((img) => + expect(img.style.width).toBe('calc(33.3333% - 5.3333px)'), + ) }) }) @@ -92,7 +104,10 @@ describe('ImageGallery', () => { const previewContainer = screen.queryByTestId('image-preview-container') expect(previewContainer)!.toBeInTheDocument() - expect(previewContainer?.querySelector('img'))!.toHaveAttribute('src', 'https://example.com/img1.png') + expect(previewContainer?.querySelector('img'))!.toHaveAttribute( + 'src', + 'https://example.com/img1.png', + ) }) it('should show preview for the specific clicked image', async () => { @@ -103,7 +118,10 @@ describe('ImageGallery', () => { await user.click(getImages(container)[1]!) const previewContainer = screen.queryByTestId('image-preview-container') - expect(previewContainer?.querySelector('img'))!.toHaveAttribute('src', 'https://example.com/2.png') + expect(previewContainer?.querySelector('img'))!.toHaveAttribute( + 'src', + 'https://example.com/2.png', + ) }) it('should hide ImagePreview when Escape is pressed', async () => { diff --git a/web/app/components/base/image-gallery/index.stories.tsx b/web/app/components/base/image-gallery/index.stories.tsx index d3be60fd56fc08..2c46e47dc9aca0 100644 --- a/web/app/components/base/image-gallery/index.stories.tsx +++ b/web/app/components/base/image-gallery/index.stories.tsx @@ -2,10 +2,10 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' import ImageGallery from '.' const IMAGE_SOURCES = [ - 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'600\' height=\'400\'><rect width=\'600\' height=\'400\' fill=\'%23E0EAFF\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'48\' fill=\'%23455675\'>Dataset</text></svg>', - 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'600\' height=\'400\'><rect width=\'600\' height=\'400\' fill=\'%23FEF7C3\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'48\' fill=\'%237A5B00\'>Playground</text></svg>', - 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'600\' height=\'400\'><rect width=\'600\' height=\'400\' fill=\'%23D5F5F6\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'48\' fill=\'%23045C63\'>Workflow</text></svg>', - 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'600\' height=\'400\'><rect width=\'600\' height=\'400\' fill=\'%23FCE7F6\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'48\' fill=\'%238E2F63\'>Prompts</text></svg>', + "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='600' height='400'><rect width='600' height='400' fill='%23E0EAFF'/><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='48' fill='%23455675'>Dataset</text></svg>", + "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='600' height='400'><rect width='600' height='400' fill='%23FEF7C3'/><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='48' fill='%237A5B00'>Playground</text></svg>", + "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='600' height='400'><rect width='600' height='400' fill='%23D5F5F6'/><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='48' fill='%23045C63'>Workflow</text></svg>", + "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='600' height='400'><rect width='600' height='400' fill='%23FCE7F6'/><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='48' fill='%238E2F63'>Prompts</text></svg>", ] const meta = { diff --git a/web/app/components/base/image-gallery/index.tsx b/web/app/components/base/image-gallery/index.tsx index d801758c13763a..98659a1b7eced1 100644 --- a/web/app/components/base/image-gallery/index.tsx +++ b/web/app/components/base/image-gallery/index.tsx @@ -28,40 +28,30 @@ const getWidthStyle = (imgNum: number) => { } } -const ImageGallery: FC<Props> = ({ - srcs, -}) => { +const ImageGallery: FC<Props> = ({ srcs }) => { const [imagePreviewUrl, setImagePreviewUrl] = useState('') const imgNum = srcs.length const imgStyle = getWidthStyle(imgNum) return ( <div className={cn(s[`img-${imgNum}`], 'flex flex-wrap')} data-testid="image-gallery"> - {srcs.map((src, index) => ( - !src - ? null - : ( - <img - key={index} - className={s.item} - style={imgStyle} - src={src} - alt="" - data-testid="gallery-image" // Added for testing - onClick={() => setImagePreviewUrl(src)} - onError={e => e.currentTarget.remove()} - /> - ) - ))} - { - imagePreviewUrl && ( - <ImagePreview - url={imagePreviewUrl} - onCancel={() => setImagePreviewUrl('')} - title="" + {srcs.map((src, index) => + !src ? null : ( + <img + key={index} + className={s.item} + style={imgStyle} + src={src} + alt="" + data-testid="gallery-image" // Added for testing + onClick={() => setImagePreviewUrl(src)} + onError={(e) => e.currentTarget.remove()} /> - ) - } + ), + )} + {imagePreviewUrl && ( + <ImagePreview url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} title="" /> + )} </div> ) } diff --git a/web/app/components/base/image-uploader/__tests__/hooks.spec.ts b/web/app/components/base/image-uploader/__tests__/hooks.spec.ts index ce4b30683ac563..586ffc9335762d 100644 --- a/web/app/components/base/image-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/image-uploader/__tests__/hooks.spec.ts @@ -301,7 +301,7 @@ describe('useImageFiles', () => { }) expect(result.current.files).toHaveLength(2) - expect(result.current.files.map(f => f._id)).toEqual(['file-1', 'file-3']) + expect(result.current.files.map((f) => f._id)).toEqual(['file-1', 'file-3']) }) }) @@ -312,9 +312,7 @@ describe('useLocalFileUploader', () => { it('should return disabled status and handleLocalFileUpload function', () => { const onUpload = vi.fn() - const { result } = renderHook(() => - useLocalFileUploader({ onUpload, limit: 10 }), - ) + const { result } = renderHook(() => useLocalFileUploader({ onUpload, limit: 10 })) expect(result.current.disabled).toBe(false) expect(result.current.handleLocalFileUpload).toBeInstanceOf(Function) @@ -322,9 +320,7 @@ describe('useLocalFileUploader', () => { it('should not upload when disabled', () => { const onUpload = vi.fn() - const { result } = renderHook(() => - useLocalFileUploader({ onUpload, disabled: true }), - ) + const { result } = renderHook(() => useLocalFileUploader({ onUpload, disabled: true })) const file = new File(['test'], 'test.png', { type: 'image/png' }) @@ -337,9 +333,7 @@ describe('useLocalFileUploader', () => { it('should reject files with disallowed extensions', () => { const onUpload = vi.fn() - const { result } = renderHook(() => - useLocalFileUploader({ onUpload }), - ) + const { result } = renderHook(() => useLocalFileUploader({ onUpload })) const file = new File(['test'], 'test.svg', { type: 'image/svg+xml' }) @@ -352,8 +346,8 @@ describe('useLocalFileUploader', () => { it('should reject files exceeding size limit', () => { const onUpload = vi.fn() - const { result } = renderHook(() => - useLocalFileUploader({ onUpload, limit: 1 }), // 1MB limit + const { result } = renderHook( + () => useLocalFileUploader({ onUpload, limit: 1 }), // 1MB limit ) // Create a file larger than 1MB @@ -365,16 +359,12 @@ describe('useLocalFileUploader', () => { }) expect(onUpload).not.toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should read file and call onUpload on successful FileReader load', async () => { const onUpload = vi.fn() - const { result } = renderHook(() => - useLocalFileUploader({ onUpload }), - ) + const { result } = renderHook(() => useLocalFileUploader({ onUpload })) const file = new File(['test'], 'test.png', { type: 'image/png' }) @@ -401,9 +391,7 @@ describe('useLocalFileUploader', () => { it('should call onUpload with progress during imageUpload', async () => { const onUpload = vi.fn() - const { result } = renderHook(() => - useLocalFileUploader({ onUpload }), - ) + const { result } = renderHook(() => useLocalFileUploader({ onUpload })) const file = new File(['test'], 'test.png', { type: 'image/png' }) @@ -421,16 +409,12 @@ describe('useLocalFileUploader', () => { uploadCall.onProgressCallback(75) }) - expect(onUpload).toHaveBeenCalledWith( - expect.objectContaining({ progress: 75 }), - ) + expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({ progress: 75 })) }) it('should call onUpload with fileId and progress 100 on upload success', async () => { const onUpload = vi.fn() - const { result } = renderHook(() => - useLocalFileUploader({ onUpload }), - ) + const { result } = renderHook(() => useLocalFileUploader({ onUpload })) const file = new File(['test'], 'test.png', { type: 'image/png' }) @@ -455,9 +439,7 @@ describe('useLocalFileUploader', () => { it('should notify error and call onUpload with progress -1 on upload failure', async () => { const onUpload = vi.fn() - const { result } = renderHook(() => - useLocalFileUploader({ onUpload }), - ) + const { result } = renderHook(() => useLocalFileUploader({ onUpload })) const file = new File(['test'], 'test.png', { type: 'image/png' }) @@ -475,11 +457,7 @@ describe('useLocalFileUploader', () => { uploadCall.onErrorCallback(new Error('fail')) }) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) - expect(onUpload).toHaveBeenCalledWith( - expect.objectContaining({ progress: -1 }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({ progress: -1 })) }) }) diff --git a/web/app/components/base/image-uploader/__tests__/image-link-input.spec.tsx b/web/app/components/base/image-uploader/__tests__/image-link-input.spec.tsx index 4d0540111bfd7f..4c50b2bbbd7861 100644 --- a/web/app/components/base/image-uploader/__tests__/image-link-input.spec.tsx +++ b/web/app/components/base/image-uploader/__tests__/image-link-input.spec.tsx @@ -115,9 +115,7 @@ describe('ImageLinkInput', () => { await user.type(screen.getByRole('textbox'), 'http://example.com/img.jpg') await user.click(screen.getByRole('button')) - expect(onUpload).toHaveBeenCalledWith( - expect.objectContaining({ progress: 0 }), - ) + expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({ progress: 0 })) }) it('should set progress 0 for ftp:// URLs', async () => { @@ -128,9 +126,7 @@ describe('ImageLinkInput', () => { await user.type(screen.getByRole('textbox'), 'ftp://files.example.com/img.png') await user.click(screen.getByRole('button')) - expect(onUpload).toHaveBeenCalledWith( - expect.objectContaining({ progress: 0 }), - ) + expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({ progress: 0 })) }) it('should not call onUpload when disabled and button is clicked', async () => { @@ -155,9 +151,7 @@ describe('ImageLinkInput', () => { render(<ImageLinkInput onUpload={onUpload} />) await user.type(screen.getByRole('textbox'), 'https://example.com/img.png') await user.click(screen.getByRole('button')) - expect(onUpload).toHaveBeenCalledWith( - expect.objectContaining({ _id: '1234567890' }), - ) + expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({ _id: '1234567890' })) dateNowSpy.mockRestore() }) }) @@ -178,9 +172,7 @@ describe('ImageLinkInput', () => { await user.type(screen.getByRole('textbox'), 'example.com/image.png') await user.click(screen.getByRole('button')) - expect(onUpload).toHaveBeenCalledWith( - expect.objectContaining({ progress: -1 }), - ) + expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({ progress: -1 })) }) }) }) diff --git a/web/app/components/base/image-uploader/__tests__/image-list.spec.tsx b/web/app/components/base/image-uploader/__tests__/image-list.spec.tsx index feb4294643ec67..f9ff9e51f81b55 100644 --- a/web/app/components/base/image-uploader/__tests__/image-list.spec.tsx +++ b/web/app/components/base/image-uploader/__tests__/image-list.spec.tsx @@ -31,14 +31,13 @@ describe('ImageList', () => { describe('Rendering', () => { it('should render without crashing with empty list', () => { render(<ImageList list={[]} />) - expect(screen.getByRole('list', { name: 'common.imageUploader.imageList' })).toBeInTheDocument() + expect( + screen.getByRole('list', { name: 'common.imageUploader.imageList' }), + ).toBeInTheDocument() }) it('should render images for each item in the list', () => { - const list = [ - createLocalFile({ _id: 'file-1' }), - createLocalFile({ _id: 'file-2' }), - ] + const list = [createLocalFile({ _id: 'file-1' }), createLocalFile({ _id: 'file-2' })] render(<ImageList list={list} />) const images = screen.getAllByRole('img') @@ -156,7 +155,9 @@ describe('ImageList', () => { it('should open image preview when clicking a completed image', async () => { const user = userEvent.setup() - const list = [createRemoteFile({ _id: 'file-1', progress: 100, url: 'https://example.com/img.png' })] + const list = [ + createRemoteFile({ _id: 'file-1', progress: 100, url: 'https://example.com/img.png' }), + ] render(<ImageList list={list} />) await user.click(screen.getByRole('img')) @@ -192,7 +193,13 @@ describe('ImageList', () => { it('should open preview with base64Url for completed local file', async () => { const user = userEvent.setup() - const list = [createLocalFile({ _id: 'file-1', progress: 100, base64Url: 'data:image/png;base64,localdata' })] + const list = [ + createLocalFile({ + _id: 'file-1', + progress: 100, + base64Url: 'data:image/png;base64,localdata', + }), + ] render(<ImageList list={list} />) await user.click(screen.getByRole('img')) @@ -261,10 +268,7 @@ describe('ImageList', () => { describe('Edge Cases', () => { it('should handle list with mixed local and remote files', () => { - const list = [ - createLocalFile({ _id: 'local-1' }), - createRemoteFile({ _id: 'remote-1' }), - ] + const list = [createLocalFile({ _id: 'local-1' }), createRemoteFile({ _id: 'remote-1' })] render(<ImageList list={list} />) expect(screen.getAllByRole('img')).toHaveLength(2) diff --git a/web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx b/web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx index b82075fdd72dc6..ae1018bb19fc69 100644 --- a/web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx +++ b/web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx @@ -28,12 +28,18 @@ vi.mock('@/utils/download', () => ({ })) const getOverlay = () => screen.getByTestId('image-preview-container') as HTMLDivElement -const getCloseButton = () => screen.getByRole('button', { name: 'common.operation.cancel' }) as HTMLButtonElement -const getCopyButton = () => screen.getByRole('button', { name: 'common.operation.copyImage' }) as HTMLButtonElement -const getZoomOutButton = () => screen.getByRole('button', { name: 'common.operation.zoomOut' }) as HTMLButtonElement -const getZoomInButton = () => screen.getByRole('button', { name: 'common.operation.zoomIn' }) as HTMLButtonElement -const getDownloadButton = () => screen.getByRole('button', { name: 'common.operation.download' }) as HTMLButtonElement -const getOpenInTabButton = () => screen.getByRole('button', { name: 'common.operation.openInNewTab' }) as HTMLButtonElement +const getCloseButton = () => + screen.getByRole('button', { name: 'common.operation.cancel' }) as HTMLButtonElement +const getCopyButton = () => + screen.getByRole('button', { name: 'common.operation.copyImage' }) as HTMLButtonElement +const getZoomOutButton = () => + screen.getByRole('button', { name: 'common.operation.zoomOut' }) as HTMLButtonElement +const getZoomInButton = () => + screen.getByRole('button', { name: 'common.operation.zoomIn' }) as HTMLButtonElement +const getDownloadButton = () => + screen.getByRole('button', { name: 'common.operation.download' }) as HTMLButtonElement +const getOpenInTabButton = () => + screen.getByRole('button', { name: 'common.operation.openInNewTab' }) as HTMLButtonElement const base64Image = 'aGVsbG8=' const dataImage = `data:image/png;base64,${base64Image}` @@ -53,19 +59,23 @@ describe('ImagePreview', () => { configurable: true, }) } - const clipboardTarget = navigator.clipboard as { write: (items: ClipboardItem[]) => Promise<void> } + const clipboardTarget = navigator.clipboard as { + write: (items: ClipboardItem[]) => Promise<void> + } // In some test environments `write` lives on the prototype rather than // the clipboard instance itself; locate the actual owner so vi.spyOn // patches the right object. const writeOwner = Object.prototype.hasOwnProperty.call(clipboardTarget, 'write') ? clipboardTarget - : (Object.getPrototypeOf(clipboardTarget) as { write: (items: ClipboardItem[]) => Promise<void> }) + : (Object.getPrototypeOf(clipboardTarget) as { + write: (items: ClipboardItem[]) => Promise<void> + }) vi.spyOn(writeOwner, 'write').mockImplementation((items: ClipboardItem[]) => { return mocks.clipboardWrite(items) }) globalThis.ClipboardItem = class { - constructor(public readonly data: Record<string, Blob>) { } + constructor(public readonly data: Record<string, Blob>) {} } as unknown as typeof ClipboardItem vi.spyOn(window, 'open').mockImplementation((...args: Parameters<Window['open']>) => { return mocks.windowOpen(...args) @@ -90,17 +100,14 @@ describe('ImagePreview', () => { const overlay = getOverlay() expect(overlay).toBeInTheDocument() expect(overlay.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body) - expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute('src', 'https://example.com/image.png') + expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute( + 'src', + 'https://example.com/image.png', + ) }) it('should convert plain base64 string into data image src', () => { - render( - <ImagePreview - url={base64Image} - title="Preview Image" - onCancel={vi.fn()} - />, - ) + render(<ImagePreview url={base64Image} title="Preview Image" onCancel={vi.fn()} />) expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute('src', dataImage) }) @@ -209,8 +216,7 @@ describe('ImagePreview', () => { const overlay = getOverlay() const image = screen.getByRole('img', { name: 'Preview Image' }) as HTMLImageElement const imageParent = image.parentElement - if (!imageParent) - throw new Error('Image parent element not found') + if (!imageParent) throw new Error('Image parent element not found') vi.spyOn(image, 'getBoundingClientRect').mockReturnValue({ width: 200, @@ -239,14 +245,18 @@ describe('ImagePreview', () => { await user.click(zoomInButton) act(() => { - overlay.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 10, clientY: 10 })) + overlay.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, clientX: 10, clientY: 10 }), + ) }) await waitFor(() => { expect(image.style.transition).toBe('none') }) act(() => { - overlay.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 200, clientY: -100 })) + overlay.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 200, clientY: -100 }), + ) }) await waitFor(() => { @@ -288,13 +298,7 @@ describe('ImagePreview', () => { }, } as unknown as Window) - render( - <ImagePreview - url={dataImage} - title="Preview Image" - onCancel={vi.fn()} - />, - ) + render(<ImagePreview url={dataImage} title="Preview Image" onCancel={vi.fn()} />) const openInTabButton = getOpenInTabButton() await user.click(openInTabButton) @@ -305,13 +309,7 @@ describe('ImagePreview', () => { it('should show error toast when opening unsupported url', async () => { const user = userEvent.setup() - render( - <ImagePreview - url="file:///tmp/image.png" - title="Preview Image" - onCancel={vi.fn()} - />, - ) + render(<ImagePreview url="file:///tmp/image.png" title="Preview Image" onCancel={vi.fn()} />) const openInTabButton = getOpenInTabButton() await user.click(openInTabButton) @@ -324,26 +322,25 @@ describe('ImagePreview', () => { it('should fall back to download and show info toast when clipboard copy fails', async () => { const user = userEvent.setup() - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) mocks.clipboardWrite.mockRejectedValue(new Error('copy failed')) - render( - <ImagePreview - url={dataImage} - title="Preview Image" - onCancel={vi.fn()} - />, - ) + render(<ImagePreview url={dataImage} title="Preview Image" onCancel={vi.fn()} />) const copyButton = getCopyButton() await user.click(copyButton) await waitFor(() => { - expect(mocks.downloadUrl).toHaveBeenCalledWith({ url: dataImage, fileName: 'Preview Image.png' }) + expect(mocks.downloadUrl).toHaveBeenCalledWith({ + url: dataImage, + fileName: 'Preview Image.png', + }) }) - expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'info', - })) + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + }), + ) expect(consoleErrorSpy).toHaveBeenCalled() consoleErrorSpy.mockRestore() }) @@ -351,13 +348,7 @@ describe('ImagePreview', () => { it('should copy image and show success toast', async () => { const user = userEvent.setup() mocks.clipboardWrite.mockResolvedValue() - render( - <ImagePreview - url={dataImage} - title="Preview Image" - onCancel={vi.fn()} - />, - ) + render(<ImagePreview url={dataImage} title="Preview Image" onCancel={vi.fn()} />) const copyButton = getCopyButton() await user.click(copyButton) @@ -365,9 +356,11 @@ describe('ImagePreview', () => { await waitFor(() => { expect(mocks.clipboardWrite).toHaveBeenCalledTimes(1) }) - expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - })) + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + }), + ) }) it('should call download action for valid url', async () => { @@ -391,13 +384,7 @@ describe('ImagePreview', () => { it('should show error toast for invalid download url', async () => { const user = userEvent.setup() - render( - <ImagePreview - url="invalid://image.png" - title="Preview Image" - onCancel={vi.fn()} - />, - ) + render(<ImagePreview url="invalid://image.png" title="Preview Image" onCancel={vi.fn()} />) const downloadButton = getDownloadButton() await user.click(downloadButton) @@ -461,16 +448,21 @@ describe('ImagePreview', () => { const overlay = getOverlay() const image = screen.getByRole('img', { name: 'Preview Image' }) as HTMLImageElement const imageParent = image.parentElement - if (!imageParent) - throw new Error('Image parent element not found') + if (!imageParent) throw new Error('Image parent element not found') vi.spyOn(image, 'getBoundingClientRect').mockReturnValue(undefined as unknown as DOMRect) - vi.spyOn(imageParent, 'getBoundingClientRect').mockReturnValue(undefined as unknown as DOMRect) + vi.spyOn(imageParent, 'getBoundingClientRect').mockReturnValue( + undefined as unknown as DOMRect, + ) await user.click(getZoomInButton()) act(() => { - overlay.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 10, clientY: 10 })) - overlay.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 120, clientY: 60 })) + overlay.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, clientX: 10, clientY: 10 }), + ) + overlay.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 120, clientY: 60 }), + ) }) expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' }) diff --git a/web/app/components/base/image-uploader/__tests__/text-generation-image-uploader.spec.tsx b/web/app/components/base/image-uploader/__tests__/text-generation-image-uploader.spec.tsx index 90f5a96169604f..51ac3e328d432b 100644 --- a/web/app/components/base/image-uploader/__tests__/text-generation-image-uploader.spec.tsx +++ b/web/app/components/base/image-uploader/__tests__/text-generation-image-uploader.spec.tsx @@ -123,13 +123,7 @@ describe('TextGenerationImageUploader', () => { const settings = createSettings({ transfer_methods: [TransferMethod.local_file], }) - render( - <TextGenerationImageUploader - settings={settings} - onFilesChange={vi.fn()} - disabled - />, - ) + render(<TextGenerationImageUploader settings={settings} onFilesChange={vi.fn()} disabled />) const fileInput = screen.getByTestId('local-file-input') expect(fileInput).toBeDisabled() @@ -142,13 +136,15 @@ describe('TextGenerationImageUploader', () => { number_limits: 1, transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url], }) - mocks.files = [{ - type: TransferMethod.remote_url, - _id: 'file-1', - fileId: 'id-1', - progress: 100, - url: 'https://example.com/image.png', - }] + mocks.files = [ + { + type: TransferMethod.remote_url, + _id: 'file-1', + fileId: 'id-1', + progress: 100, + url: 'https://example.com/image.png', + }, + ] render(<TextGenerationImageUploader settings={settings} onFilesChange={vi.fn()} />) const fileInput = screen.getByTestId('local-file-input') @@ -156,7 +152,9 @@ describe('TextGenerationImageUploader', () => { expect(mocks.localUploaderArgs?.disabled).toBe(true) await user.click(screen.getByText('common.imageUploader.pasteImageLink')) - expect(screen.queryByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder')).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder'), + ).not.toBeInTheDocument() }) }) @@ -183,18 +181,24 @@ describe('TextGenerationImageUploader', () => { render(<TextGenerationImageUploader settings={settings} onFilesChange={vi.fn()} />) await user.click(screen.getByText('common.imageUploader.pasteImageLink')) - const input = await screen.findByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder') + const input = await screen.findByPlaceholderText( + 'common.imageUploader.pasteImageLinkInputPlaceholder', + ) await user.type(input, 'https://example.com/remote.png') await user.click(screen.getByRole('button', { name: 'common.operation.ok' })) - expect(mocks.onUpload).toHaveBeenCalledWith(expect.objectContaining({ - type: TransferMethod.remote_url, - url: 'https://example.com/remote.png', - progress: 0, - })) + expect(mocks.onUpload).toHaveBeenCalledWith( + expect.objectContaining({ + type: TransferMethod.remote_url, + url: 'https://example.com/remote.png', + progress: 0, + }), + ) await waitFor(() => { - expect(screen.queryByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder')).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder'), + ).not.toBeInTheDocument() }) }) }) @@ -204,16 +208,20 @@ describe('TextGenerationImageUploader', () => { const onFilesChange = vi.fn() const settings = createSettings() - const { rerender } = render(<TextGenerationImageUploader settings={settings} onFilesChange={onFilesChange} />) + const { rerender } = render( + <TextGenerationImageUploader settings={settings} onFilesChange={onFilesChange} />, + ) expect(onFilesChange).toHaveBeenCalledWith([]) - const updatedFiles: ImageFile[] = [{ - type: TransferMethod.remote_url, - _id: 'new-file', - fileId: '', - progress: 0, - url: 'https://example.com/new.png', - }] + const updatedFiles: ImageFile[] = [ + { + type: TransferMethod.remote_url, + _id: 'new-file', + fileId: '', + progress: 0, + url: 'https://example.com/new.png', + }, + ] mocks.files = updatedFiles rerender(<TextGenerationImageUploader settings={settings} onFilesChange={onFilesChange} />) diff --git a/web/app/components/base/image-uploader/__tests__/uploader.spec.tsx b/web/app/components/base/image-uploader/__tests__/uploader.spec.tsx index 416a56fec54274..ef2e3762b3cea2 100644 --- a/web/app/components/base/image-uploader/__tests__/uploader.spec.tsx +++ b/web/app/components/base/image-uploader/__tests__/uploader.spec.tsx @@ -34,13 +34,7 @@ const renderUploader = (props: Partial<ComponentProps<typeof Uploader>> = {}) => )) const result = render( - <Uploader - onUpload={onUpload} - closePopover={closePopover} - limit={3} - disabled={false} - {...props} - > + <Uploader onUpload={onUpload} closePopover={closePopover} limit={3} disabled={false} {...props}> {childRenderer} </Uploader>, ) @@ -71,7 +65,7 @@ describe('Uploader', () => { it('should set accept attribute from allowed file extensions', () => { renderUploader() const input = getInput() - const expectedAccept = ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',') + const expectedAccept = ALLOW_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',') expect(input).toHaveAttribute('accept', expectedAccept) }) diff --git a/web/app/components/base/image-uploader/__tests__/utils.spec.ts b/web/app/components/base/image-uploader/__tests__/utils.spec.ts index 3bb8e0cfd40649..309841e37ba663 100644 --- a/web/app/components/base/image-uploader/__tests__/utils.spec.ts +++ b/web/app/components/base/image-uploader/__tests__/utils.spec.ts @@ -113,10 +113,12 @@ describe('image-uploader utils', () => { it('should report progress percentage when progress is computable', () => { const file = new File(['hello'], 'image.png', { type: 'image/png' }) const callbacks = createCallbacks() - vi.mocked(upload).mockImplementation((options: { onprogress?: (e: ProgressEvent) => void }) => { - options.onprogress?.({ lengthComputable: true, loaded: 5, total: 8 } as ProgressEvent) - return Promise.resolve({ id: 'uploaded-id' }) - }) + vi.mocked(upload).mockImplementation( + (options: { onprogress?: (e: ProgressEvent) => void }) => { + options.onprogress?.({ lengthComputable: true, loaded: 5, total: 8 } as ProgressEvent) + return Promise.resolve({ id: 'uploaded-id' }) + }, + ) imageUpload({ file, ...callbacks }) @@ -126,10 +128,12 @@ describe('image-uploader utils', () => { it('should not report progress when length is not computable', () => { const file = new File(['hello'], 'image.png', { type: 'image/png' }) const callbacks = createCallbacks() - vi.mocked(upload).mockImplementation((options: { onprogress?: (e: ProgressEvent) => void }) => { - options.onprogress?.({ lengthComputable: false, loaded: 5, total: 8 } as ProgressEvent) - return Promise.resolve({ id: 'uploaded-id' }) - }) + vi.mocked(upload).mockImplementation( + (options: { onprogress?: (e: ProgressEvent) => void }) => { + options.onprogress?.({ lengthComputable: false, loaded: 5, total: 8 } as ProgressEvent) + return Promise.resolve({ id: 'uploaded-id' }) + }, + ) imageUpload({ file, ...callbacks }) diff --git a/web/app/components/base/image-uploader/hooks.ts b/web/app/components/base/image-uploader/hooks.ts index b9572440cc8bf4..0f363f8b1dd4d4 100644 --- a/web/app/components/base/image-uploader/hooks.ts +++ b/web/app/components/base/image-uploader/hooks.ts @@ -13,14 +13,17 @@ export const useImageFiles = () => { const filesRef = useRef<ImageFile[]>([]) const handleUpload = (imageFile: ImageFile) => { const files = filesRef.current - const index = files.findIndex(file => file._id === imageFile._id) + const index = files.findIndex((file) => file._id === imageFile._id) if (index > -1) { const currentFile = files[index] - const newFiles = [...files.slice(0, index), { ...currentFile, ...imageFile }, ...files.slice(index + 1)] + const newFiles = [ + ...files.slice(0, index), + { ...currentFile, ...imageFile }, + ...files.slice(index + 1), + ] setFiles(newFiles) filesRef.current = newFiles - } - else { + } else { const newFiles = [...files, imageFile] setFiles(newFiles) filesRef.current = newFiles @@ -28,59 +31,90 @@ export const useImageFiles = () => { } const handleRemove = (imageFileId: string) => { const files = filesRef.current - const index = files.findIndex(file => file._id === imageFileId) + const index = files.findIndex((file) => file._id === imageFileId) if (index > -1) { const currentFile = files[index]! - const newFiles = [...files.slice(0, index), { ...currentFile, deleted: true }, ...files.slice(index + 1)] + const newFiles = [ + ...files.slice(0, index), + { ...currentFile, deleted: true }, + ...files.slice(index + 1), + ] setFiles(newFiles) filesRef.current = newFiles } } const handleImageLinkLoadError = (imageFileId: string) => { const files = filesRef.current - const index = files.findIndex(file => file._id === imageFileId) + const index = files.findIndex((file) => file._id === imageFileId) if (index > -1) { const currentFile = files[index]! - const newFiles = [...files.slice(0, index), { ...currentFile, progress: -1 }, ...files.slice(index + 1)] + const newFiles = [ + ...files.slice(0, index), + { ...currentFile, progress: -1 }, + ...files.slice(index + 1), + ] filesRef.current = newFiles setFiles(newFiles) } } const handleImageLinkLoadSuccess = (imageFileId: string) => { const files = filesRef.current - const index = files.findIndex(file => file._id === imageFileId) + const index = files.findIndex((file) => file._id === imageFileId) if (index > -1) { const currentImageFile = files[index]! - const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: 100 }, ...files.slice(index + 1)] + const newFiles = [ + ...files.slice(0, index), + { ...currentImageFile, progress: 100 }, + ...files.slice(index + 1), + ] filesRef.current = newFiles setFiles(newFiles) } } const handleReUpload = (imageFileId: string) => { const files = filesRef.current - const index = files.findIndex(file => file._id === imageFileId) + const index = files.findIndex((file) => file._id === imageFileId) if (index > -1) { const currentImageFile = files[index]! - imageUpload({ - file: currentImageFile!.file!, - onProgressCallback: (progress) => { - const newFiles = [...files.slice(0, index), { ...currentImageFile, progress }, ...files.slice(index + 1)] - filesRef.current = newFiles - setFiles(newFiles) - }, - onSuccessCallback: (res) => { - const newFiles = [...files.slice(0, index), { ...currentImageFile, fileId: res.id, progress: 100 }, ...files.slice(index + 1)] - filesRef.current = newFiles - setFiles(newFiles) + imageUpload( + { + file: currentImageFile!.file!, + onProgressCallback: (progress) => { + const newFiles = [ + ...files.slice(0, index), + { ...currentImageFile, progress }, + ...files.slice(index + 1), + ] + filesRef.current = newFiles + setFiles(newFiles) + }, + onSuccessCallback: (res) => { + const newFiles = [ + ...files.slice(0, index), + { ...currentImageFile, fileId: res.id, progress: 100 }, + ...files.slice(index + 1), + ] + filesRef.current = newFiles + setFiles(newFiles) + }, + onErrorCallback: (error?: any) => { + const errorMessage = getImageUploadErrorMessage( + error, + t(($) => $['imageUploader.uploadFromComputerUploadError'], { ns: 'common' }), + t, + ) + toast.error(errorMessage) + const newFiles = [ + ...files.slice(0, index), + { ...currentImageFile, progress: -1 }, + ...files.slice(index + 1), + ] + filesRef.current = newFiles + setFiles(newFiles) + }, }, - onErrorCallback: (error?: any) => { - const errorMessage = getImageUploadErrorMessage(error, t($ => $['imageUploader.uploadFromComputerUploadError'], { ns: 'common' }), t) - toast.error(errorMessage) - const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: -1 }, ...files.slice(index + 1)] - filesRef.current = newFiles - setFiles(newFiles) - }, - }, !!params?.token) + !!params?.token, + ) } } const handleClear = () => { @@ -88,7 +122,7 @@ export const useImageFiles = () => { filesRef.current = [] } const filteredFiles = useMemo(() => { - return files.filter(file => !file.deleted) + return files.filter((file) => !file.deleted) }, [files]) return { files: filteredFiles, @@ -105,51 +139,74 @@ type useLocalUploaderProps = { limit?: number onUpload: (imageFile: ImageFile) => void } -export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useLocalUploaderProps) => { +export const useLocalFileUploader = ({ + limit, + disabled = false, + onUpload, +}: useLocalUploaderProps) => { const params = useParams() const { t } = useTranslation() - const handleLocalFileUpload = useCallback((file: File) => { - if (disabled) { - // TODO: leave some warnings? - return - } - if (!ALLOW_FILE_EXTENSIONS.includes(file.type.split('/')[1]!)) - return - if (limit && file.size > limit * 1024 * 1024) { - toast.error(t($ => $['imageUploader.uploadFromComputerLimit'], { ns: 'common', size: limit })) - return - } - const reader = new FileReader() - reader.addEventListener('load', () => { - const imageFile = { - type: TransferMethod.local_file, - _id: `${Date.now()}`, - fileId: '', - file, - url: reader.result as string, - base64Url: reader.result as string, - progress: 0, + const handleLocalFileUpload = useCallback( + (file: File) => { + if (disabled) { + // TODO: leave some warnings? + return } - onUpload(imageFile) - imageUpload({ - file: imageFile.file, - onProgressCallback: (progress) => { - onUpload({ ...imageFile, progress }) - }, - onSuccessCallback: (res) => { - onUpload({ ...imageFile, fileId: res.id, progress: 100 }) + if (!ALLOW_FILE_EXTENSIONS.includes(file.type.split('/')[1]!)) return + if (limit && file.size > limit * 1024 * 1024) { + toast.error( + t(($) => $['imageUploader.uploadFromComputerLimit'], { ns: 'common', size: limit }), + ) + return + } + const reader = new FileReader() + reader.addEventListener( + 'load', + () => { + const imageFile = { + type: TransferMethod.local_file, + _id: `${Date.now()}`, + fileId: '', + file, + url: reader.result as string, + base64Url: reader.result as string, + progress: 0, + } + onUpload(imageFile) + imageUpload( + { + file: imageFile.file, + onProgressCallback: (progress) => { + onUpload({ ...imageFile, progress }) + }, + onSuccessCallback: (res) => { + onUpload({ ...imageFile, fileId: res.id, progress: 100 }) + }, + onErrorCallback: (error?: any) => { + const errorMessage = getImageUploadErrorMessage( + error, + t(($) => $['imageUploader.uploadFromComputerUploadError'], { ns: 'common' }), + t, + ) + toast.error(errorMessage) + onUpload({ ...imageFile, progress: -1 }) + }, + }, + !!params?.token, + ) }, - onErrorCallback: (error?: any) => { - const errorMessage = getImageUploadErrorMessage(error, t($ => $['imageUploader.uploadFromComputerUploadError'], { ns: 'common' }), t) - toast.error(errorMessage) - onUpload({ ...imageFile, progress: -1 }) + false, + ) + reader.addEventListener( + 'error', + () => { + toast.error(t(($) => $['imageUploader.uploadFromComputerReadError'], { ns: 'common' })) }, - }, !!params?.token) - }, false) - reader.addEventListener('error', () => { - toast.error(t($ => $['imageUploader.uploadFromComputerReadError'], { ns: 'common' })) - }, false) - reader.readAsDataURL(file) - }, [disabled, limit, t, onUpload, params?.token]) + false, + ) + reader.readAsDataURL(file) + }, + [disabled, limit, t, onUpload, params?.token], + ) return { disabled, handleLocalFileUpload } } diff --git a/web/app/components/base/image-uploader/image-link-input.tsx b/web/app/components/base/image-uploader/image-link-input.tsx index 8b8c9dcae631d7..0bdf89915dba1c 100644 --- a/web/app/components/base/image-uploader/image-link-input.tsx +++ b/web/app/components/base/image-uploader/image-link-input.tsx @@ -10,21 +10,17 @@ type ImageLinkInputProps = { disabled?: boolean } const regex = /^(https?|ftp):\/\// -const ImageLinkInput: FC<ImageLinkInputProps> = ({ - onUpload, - disabled, -}) => { +const ImageLinkInput: FC<ImageLinkInputProps> = ({ onUpload, disabled }) => { const { t } = useTranslation() const [imageLink, setImageLink] = useState('') - const placeholder = t($ => $['imageUploader.pasteImageLinkInputPlaceholder'], { ns: 'common' }) + const placeholder = t(($) => $['imageUploader.pasteImageLinkInputPlaceholder'], { ns: 'common' }) /* v8 ignore next -- defensive i18n fallback; translation key resolves to non-empty text in normal runtime/test setup, so empty-placeholder branch is not exercised without forcing i18n internals. @preserve */ const safeText = placeholder || '' const handleClick = () => { /* v8 ignore next 2 -- same condition drives Button.disabled; when true, click does not invoke onClick in user-level flow. @preserve */ - if (disabled) - return + if (disabled) return const imageFile = { type: TransferMethod.remote_url, @@ -43,7 +39,7 @@ const ImageLinkInput: FC<ImageLinkInputProps> = ({ type="text" className="mr-0.5 h-[18px] grow appearance-none bg-transparent px-1 text-[13px] text-text-primary outline-hidden" value={imageLink} - onChange={e => setImageLink(e.target.value)} + onChange={(e) => setImageLink(e.target.value)} placeholder={safeText} data-testid="image-link-input" /> @@ -53,7 +49,7 @@ const ImageLinkInput: FC<ImageLinkInputProps> = ({ disabled={!imageLink || disabled} onClick={handleClick} > - {t($ => $['operation.ok'], { ns: 'common' })} + {t(($) => $['operation.ok'], { ns: 'common' })} </Button> </div> ) diff --git a/web/app/components/base/image-uploader/image-list.stories.tsx b/web/app/components/base/image-uploader/image-list.stories.tsx index 0c27211d1638b1..d8f3f115647b19 100644 --- a/web/app/components/base/image-uploader/image-list.stories.tsx +++ b/web/app/components/base/image-uploader/image-list.stories.tsx @@ -5,14 +5,10 @@ import { TransferMethod } from '@/types/app' import ImageLinkInput from './image-link-input' import ImageList from './image-list' -const SAMPLE_BASE64 - = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAAsSAAALEgHS3X78AAABbElEQVR4nO3SsQkAIBDARMT+V20sTg6LXhWEATnnMHDx4sWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFu2r/H3n4BG518Gr4AAAAASUVORK5CYII=' - -const createRemoteImage = ( - id: string, - progress: number, - url: string, -): ImageFile => ({ +const SAMPLE_BASE64 = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAAsSAAALEgHS3X78AAABbElEQVR4nO3SsQkAIBDARMT+V20sTg6LXhWEATnnMHDx4sWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFu2r/H3n4BG518Gr4AAAAASUVORK5CYII=' + +const createRemoteImage = (id: string, progress: number, url: string): ImageFile => ({ type: TransferMethod.remote_url, _id: id, fileId: `remote-${id}`, @@ -37,11 +33,7 @@ const initialImages: ImageFile[] = [ 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=300&q=80', ), { - ...createRemoteImage( - 'remote-error', - -1, - 'https://example.com/not-an-image.jpg', - ), + ...createRemoteImage('remote-error', -1, 'https://example.com/not-an-image.jpg'), url: 'https://example.com/not-an-image.jpg', }, ] @@ -53,7 +45,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Renders thumbnails for uploaded images and manages their states like uploading, error, and deletion.', + component: + 'Renders thumbnails for uploaded images and manages their states like uploading, error, and deletion.', }, }, }, @@ -73,46 +66,48 @@ type Story = StoryObj<typeof meta> const ImageUploaderPlayground = ({ readonly }: Story['args']) => { const [images, setImages] = useState<ImageFile[]>(() => initialImages) - const activeImages = useMemo(() => images.filter(item => !item.deleted), [images]) + const activeImages = useMemo(() => images.filter((item) => !item.deleted), [images]) const handleRemove = (id: string) => { - setImages(prev => prev.map(item => (item._id === id ? { ...item, deleted: true } : item))) + setImages((prev) => prev.map((item) => (item._id === id ? { ...item, deleted: true } : item))) } const handleReUpload = (id: string) => { - setImages(prev => prev.map((item) => { - if (item._id !== id) - return item - - return { - ...item, - progress: 60, - } - })) - - setTimeout(() => { - setImages(prev => prev.map((item) => { - if (item._id !== id) - return item + setImages((prev) => + prev.map((item) => { + if (item._id !== id) return item return { ...item, - progress: 100, + progress: 60, } - })) + }), + ) + + setTimeout(() => { + setImages((prev) => + prev.map((item) => { + if (item._id !== id) return item + + return { + ...item, + progress: 100, + } + }), + ) }, 1200) } const handleImageLinkLoadSuccess = (id: string) => { - setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: 100 } : item))) + setImages((prev) => prev.map((item) => (item._id === id ? { ...item, progress: 100 } : item))) } const handleImageLinkLoadError = (id: string) => { - setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: -1 } : item))) + setImages((prev) => prev.map((item) => (item._id === id ? { ...item, progress: -1 } : item))) } const handleUploadFromLink = (imageFile: ImageFile) => { - setImages(prev => [ + setImages((prev) => [ ...prev, { ...imageFile, @@ -123,16 +118,15 @@ const ImageUploaderPlayground = ({ readonly }: Story['args']) => { const handleAddLocalImage = () => { const id = `local-${Date.now()}` - setImages(prev => [ - ...prev, - createLocalImage(id, 100), - ]) + setImages((prev) => [...prev, createLocalImage(id, 100)]) } return ( <div className="flex w-[360px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-4"> <div className="flex flex-col gap-2"> - <span className="text-xs font-medium tracking-[0.18em] text-text-tertiary uppercase">Add images</span> + <span className="text-xs font-medium tracking-[0.18em] text-text-tertiary uppercase"> + Add images + </span> <div className="flex items-center gap-2"> <ImageLinkInput onUpload={handleUploadFromLink} disabled={readonly} /> <button @@ -168,14 +162,14 @@ const ImageUploaderPlayground = ({ readonly }: Story['args']) => { } export const Playground: Story = { - render: args => <ImageUploaderPlayground {...args} />, + render: (args) => <ImageUploaderPlayground {...args} />, args: { list: [], }, } export const ReadonlyList: Story = { - render: args => <ImageUploaderPlayground {...args} />, + render: (args) => <ImageUploaderPlayground {...args} />, args: { list: [], }, diff --git a/web/app/components/base/image-uploader/image-list.tsx b/web/app/components/base/image-uploader/image-list.tsx index 3ac4938dce64f1..85b2dfe2ed1024 100644 --- a/web/app/components/base/image-uploader/image-list.tsx +++ b/web/app/components/base/image-uploader/image-list.tsx @@ -29,11 +29,7 @@ const ImageList: FC<ImageListProps> = ({ const [imagePreviewUrl, setImagePreviewUrl] = useState('') const handleImageLinkLoadSuccess = (item: ImageFile) => { - if ( - item.type === TransferMethod.remote_url - && onImageLinkLoadSuccess - && item.progress !== -1 - ) { + if (item.type === TransferMethod.remote_url && onImageLinkLoadSuccess && item.progress !== -1) { onImageLinkLoadSuccess(item._id) } } @@ -43,8 +39,12 @@ const ImageList: FC<ImageListProps> = ({ } return ( - <div className="flex flex-wrap" role="list" aria-label={t($ => $['imageUploader.imageList'], { ns: 'common' })}> - {list.map(item => ( + <div + className="flex flex-wrap" + role="list" + aria-label={t(($) => $['imageUploader.imageList'], { ns: 'common' })} + > + {list.map((item) => ( <div key={item._id} role="listitem" @@ -59,45 +59,44 @@ const ImageList: FC<ImageListProps> = ({ {item.progress === -1 && ( <button type="button" - aria-label={t($ => $['operation.retry'], { ns: 'common' })} + aria-label={t(($) => $['operation.retry'], { ns: 'common' })} className="size-5 border-none bg-transparent p-0 text-white focus-visible:ring-1 focus-visible:ring-white focus-visible:outline-hidden" onClick={() => onReUpload?.(item._id)} > - <span className="i-custom-vender-line-arrows-refresh-ccw-01 size-5" aria-hidden="true" /> + <span + className="i-custom-vender-line-arrows-refresh-ccw-01 size-5" + aria-hidden="true" + /> </button> )} </div> {item.progress > -1 && ( <span className="absolute top-[50%] left-[50%] z-1 translate-x-[-50%] translate-y-[-50%] text-sm text-white mix-blend-lighten"> - {item.progress} - % + {item.progress}% </span> )} </> )} {item.type === TransferMethod.remote_url && item.progress !== 100 && ( <div - className={` - absolute inset-0 z-1 flex items-center justify-center rounded-lg border - ${item.progress === -1 - ? 'border-[#DC6803] bg-[#FEF0C7]' - : 'border-transparent bg-black/16' - } - `} + className={`absolute inset-0 z-1 flex items-center justify-center rounded-lg border ${ + item.progress === -1 + ? 'border-[#DC6803] bg-[#FEF0C7]' + : 'border-transparent bg-black/16' + } `} data-testid="image-error-container" > {item.progress > -1 && ( - <span className="i-ri-loader-2-line size-5 animate-spin text-white" data-testid="image-loader" /> + <span + className="i-ri-loader-2-line size-5 animate-spin text-white" + data-testid="image-loader" + /> )} {item.progress === -1 && ( <Tooltip> - <TooltipTrigger - render={( - <AlertTriangle className="h-4 w-4 text-[#DC6803]" /> - )} - /> + <TooltipTrigger render={<AlertTriangle className="h-4 w-4 text-[#DC6803]" />} /> <TooltipContent> - {t($ => $['imageUploader.pasteImageLinkInvalid'], { ns: 'common' })} + {t(($) => $['imageUploader.pasteImageLinkInvalid'], { ns: 'common' })} </TooltipContent> </Tooltip> )} @@ -108,18 +107,13 @@ const ImageList: FC<ImageListProps> = ({ alt={item.file?.name} onLoad={() => handleImageLinkLoadSuccess(item)} onError={() => handleImageLinkLoadError(item)} - src={ - item.type === TransferMethod.remote_url - ? item.url - : item.base64Url - } + src={item.type === TransferMethod.remote_url ? item.url : item.base64Url} onClick={() => - item.progress === 100 - && setImagePreviewUrl( - (item.type === TransferMethod.remote_url - ? item.url - : item.base64Url) as string, - )} + item.progress === 100 && + setImagePreviewUrl( + (item.type === TransferMethod.remote_url ? item.url : item.base64Url) as string, + ) + } /> {!readonly && ( <button @@ -130,7 +124,7 @@ const ImageList: FC<ImageListProps> = ({ item.progress === -1 ? 'flex' : 'hidden group-hover:flex', )} onClick={() => onRemove?.(item._id)} - aria-label={t($ => $['operation.remove'], { ns: 'common' })} + aria-label={t(($) => $['operation.remove'], { ns: 'common' })} > <span className="i-ri-close-line size-3 text-text-tertiary" aria-hidden="true" /> </button> @@ -138,11 +132,7 @@ const ImageList: FC<ImageListProps> = ({ </div> ))} {imagePreviewUrl && ( - <ImagePreview - url={imagePreviewUrl} - onCancel={() => setImagePreviewUrl('')} - title="" - /> + <ImagePreview url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} title="" /> )} </div> ) diff --git a/web/app/components/base/image-uploader/image-preview.tsx b/web/app/components/base/image-uploader/image-preview.tsx index 2300a4e6cdf930..401b877e0081c8 100644 --- a/web/app/components/base/image-uploader/image-preview.tsx +++ b/web/app/components/base/image-uploader/image-preview.tsx @@ -20,19 +20,12 @@ type ImagePreviewProps = { const isBase64 = (str: string): boolean => { try { return btoa(atob(str)) === str - } - catch { + } catch { return false } } -const ImagePreview: FC<ImagePreviewProps> = ({ - url, - title, - onCancel, - onPrev, - onNext, -}) => { +const ImagePreview: FC<ImagePreviewProps> = ({ url, title, onCancel, onPrev, onNext }) => { const { t } = useTranslation() const [scale, setScale] = useState(1) const [position, setPosition] = useState({ x: 0, y: 0 }) @@ -45,13 +38,11 @@ const ImagePreview: FC<ImagePreviewProps> = ({ // Open in a new window, considering the case when the page is inside an iframe if (url.startsWith('http') || url.startsWith('https')) { window.open(url, '_blank') - } - else if (url.startsWith('data:image')) { + } else if (url.startsWith('data:image')) { // Base64 image const win = window.open() win?.document.write(`<img src="${url}" alt="${title}" />`) - } - else { + } else { toast.error(`Unable to open image: ${url}`) } } @@ -66,14 +57,13 @@ const ImagePreview: FC<ImagePreviewProps> = ({ } const zoomIn = () => { - setScale(prevScale => Math.min(prevScale * 1.2, 15)) + setScale((prevScale) => Math.min(prevScale * 1.2, 15)) } const zoomOut = () => { setScale((prevScale) => { const newScale = Math.max(prevScale / 1.2, 0.5) - if (newScale === 1) - setPosition({ x: 0, y: 0 }) // Reset position when fully zoomed out + if (newScale === 1) setPosition({ x: 0, y: 0 }) // Reset position when fully zoomed out return newScale }) @@ -86,8 +76,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({ for (let offset = 0; offset < byteCharacters.length; offset += 512) { const slice = byteCharacters.slice(offset, offset + 512) const byteNumbers = Array.from({ length: slice.length }) - for (let i = 0; i < slice.length; i++) - byteNumbers[i] = slice.charCodeAt(i) + for (let i = 0; i < slice.length; i++) byteNumbers[i] = slice.charCodeAt(i) const byteArray = new Uint8Array(byteNumbers as any) byteArrays.push(byteArray) @@ -109,53 +98,56 @@ const ImagePreview: FC<ImagePreviewProps> = ({ ]) setIsCopied(true) - toast.success(t($ => $['operation.imageCopied'], { ns: 'common' })) - } - catch (err) { + toast.success(t(($) => $['operation.imageCopied'], { ns: 'common' })) + } catch (err) { console.error('Failed to copy image:', err) downloadUrl({ url, fileName: `${title}.png` }) - toast.info(t($ => $['operation.imageDownloaded'], { ns: 'common' })) + toast.info(t(($) => $['operation.imageDownloaded'], { ns: 'common' })) } } shareImage() }, [t, title, url]) const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => { - if (e.deltaY < 0) - zoomIn() - else - zoomOut() + if (e.deltaY < 0) zoomIn() + else zoomOut() }, []) - const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => { - if (scale > 1) { - setIsDragging(true) - dragStartRef.current = { x: e.clientX - position.x, y: e.clientY - position.y } - } - }, [scale, position]) + const handleMouseDown = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + if (scale > 1) { + setIsDragging(true) + dragStartRef.current = { x: e.clientX - position.x, y: e.clientY - position.y } + } + }, + [scale, position], + ) - const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => { - if (isDragging && scale > 1) { - const deltaX = e.clientX - dragStartRef.current.x - const deltaY = e.clientY - dragStartRef.current.y + const handleMouseMove = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + if (isDragging && scale > 1) { + const deltaX = e.clientX - dragStartRef.current.x + const deltaY = e.clientY - dragStartRef.current.y - // Calculate boundaries - const imgRect = imgRef.current?.getBoundingClientRect() - const containerRect = imgRef.current?.parentElement?.getBoundingClientRect() + // Calculate boundaries + const imgRect = imgRef.current?.getBoundingClientRect() + const containerRect = imgRef.current?.parentElement?.getBoundingClientRect() - if (imgRect && containerRect) { - const maxX = (imgRect.width * scale - containerRect.width) / 2 - const maxY = (imgRect.height * scale - containerRect.height) / 2 + if (imgRect && containerRect) { + const maxX = (imgRect.width * scale - containerRect.width) / 2 + const maxY = (imgRect.height * scale - containerRect.height) / 2 - setPosition({ - x: Math.max(-maxX, Math.min(maxX, deltaX)), - y: Math.max(-maxY, Math.min(maxY, deltaY)), - }) + setPosition({ + x: Math.max(-maxX, Math.min(maxX, deltaX)), + y: Math.max(-maxY, Math.min(maxY, deltaY)), + }) + } } - } - }, [isDragging, scale]) + }, + [isDragging, scale], + ) const handleMouseUp = useCallback(() => { setIsDragging(false) @@ -173,19 +165,18 @@ const ImagePreview: FC<ImagePreviewProps> = ({ useHotkey('ArrowLeft', onPrev || noop) useHotkey('ArrowRight', onNext || noop) - const copyImageLabel = t($ => $['operation.copyImage'], { ns: 'common' }) - const zoomOutLabel = t($ => $['operation.zoomOut'], { ns: 'common' }) - const zoomInLabel = t($ => $['operation.zoomIn'], { ns: 'common' }) - const downloadLabel = t($ => $['operation.download'], { ns: 'common' }) - const openInNewTabLabel = t($ => $['operation.openInNewTab'], { ns: 'common' }) - const cancelLabel = t($ => $['operation.cancel'], { ns: 'common' }) + const copyImageLabel = t(($) => $['operation.copyImage'], { ns: 'common' }) + const zoomOutLabel = t(($) => $['operation.zoomOut'], { ns: 'common' }) + const zoomInLabel = t(($) => $['operation.zoomIn'], { ns: 'common' }) + const downloadLabel = t(($) => $['operation.download'], { ns: 'common' }) + const openInNewTabLabel = t(($) => $['operation.openInNewTab'], { ns: 'common' }) + const cancelLabel = t(($) => $['operation.cancel'], { ns: 'common' }) return ( <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} disablePointerDismissal > @@ -198,7 +189,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({ data-testid="image-preview-container" tabIndex={-1} className="flex size-full items-center justify-center" - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} onWheel={handleWheel} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} @@ -219,26 +210,26 @@ const ImagePreview: FC<ImagePreviewProps> = ({ </div> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={copyImageLabel} className="absolute top-6 right-48 flex size-8 cursor-pointer items-center justify-center rounded-lg" onClick={imageCopy} > - {isCopied - ? <span className="i-ri-file-copy-line size-4 text-green-500" aria-hidden="true" /> - : <span className="i-ri-file-copy-line size-4 text-gray-500" aria-hidden="true" />} + {isCopied ? ( + <span className="i-ri-file-copy-line size-4 text-green-500" aria-hidden="true" /> + ) : ( + <span className="i-ri-file-copy-line size-4 text-gray-500" aria-hidden="true" /> + )} </button> - )} + } /> - <TooltipContent> - {copyImageLabel} - </TooltipContent> + <TooltipContent>{copyImageLabel}</TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={zoomOutLabel} @@ -247,15 +238,13 @@ const ImagePreview: FC<ImagePreviewProps> = ({ > <span className="i-ri-zoom-out-line size-4 text-gray-500" aria-hidden="true" /> </button> - )} + } /> - <TooltipContent> - {zoomOutLabel} - </TooltipContent> + <TooltipContent>{zoomOutLabel}</TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={zoomInLabel} @@ -264,32 +253,31 @@ const ImagePreview: FC<ImagePreviewProps> = ({ > <span className="i-ri-zoom-in-line size-4 text-gray-500" aria-hidden="true" /> </button> - )} + } /> - <TooltipContent> - {zoomInLabel} - </TooltipContent> + <TooltipContent>{zoomInLabel}</TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={downloadLabel} className="absolute top-6 right-24 flex size-8 cursor-pointer items-center justify-center rounded-lg" onClick={downloadImage} > - <span className="i-ri-download-cloud-2-line size-4 text-gray-500" aria-hidden="true" /> + <span + className="i-ri-download-cloud-2-line size-4 text-gray-500" + aria-hidden="true" + /> </button> - )} + } /> - <TooltipContent> - {downloadLabel} - </TooltipContent> + <TooltipContent>{downloadLabel}</TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={openInNewTabLabel} @@ -298,15 +286,13 @@ const ImagePreview: FC<ImagePreviewProps> = ({ > <span className="i-ri-add-box-line size-4 text-gray-500" aria-hidden="true" /> </button> - )} + } /> - <TooltipContent> - {openInNewTabLabel} - </TooltipContent> + <TooltipContent>{openInNewTabLabel}</TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={cancelLabel} @@ -315,11 +301,9 @@ const ImagePreview: FC<ImagePreviewProps> = ({ > <span className="i-ri-close-line size-4 text-gray-500" aria-hidden="true" /> </button> - )} + } /> - <TooltipContent> - {cancelLabel} - </TooltipContent> + <TooltipContent>{cancelLabel}</TooltipContent> </Tooltip> </DialogContent> </Dialog> diff --git a/web/app/components/base/image-uploader/text-generation-image-uploader.tsx b/web/app/components/base/image-uploader/text-generation-image-uploader.tsx index f6df88c3ad6657..8788b9347e1ffb 100644 --- a/web/app/components/base/image-uploader/text-generation-image-uploader.tsx +++ b/web/app/components/base/image-uploader/text-generation-image-uploader.tsx @@ -1,15 +1,7 @@ import type { FC } from 'react' import type { ImageFile, VisionSettings } from '@/types/app' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - Fragment, - useEffect, - useState, -} from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { Fragment, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link03 } from '@/app/components/base/icons/src/vender/line/general' import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images' @@ -23,10 +15,7 @@ type PasteImageLinkButtonProps = { onUpload: (imageFile: ImageFile) => void disabled?: boolean } -const PasteImageLinkButton: FC<PasteImageLinkButtonProps> = ({ - onUpload, - disabled, -}) => { +const PasteImageLinkButton: FC<PasteImageLinkButtonProps> = ({ onUpload, disabled }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -39,23 +28,19 @@ const PasteImageLinkButton: FC<PasteImageLinkButtonProps> = ({ <Popover open={open} onOpenChange={(nextOpen) => { - if (disabled) - return + if (disabled) return setOpen(nextOpen) }} > <PopoverTrigger - render={( + render={ <div - className={` - relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover - ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'} - `} + className={`relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'} `} > <Link03 className="mr-2 size-4" /> - {t($ => $['imageUploader.pasteImageLink'], { ns: 'common' })} + {t(($) => $['imageUploader.pasteImageLink'], { ns: 'common' })} </div> - )} + } /> <PopoverContent placement="top-start" @@ -82,14 +67,8 @@ const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({ }) => { const { t } = useTranslation() - const { - files, - onUpload, - onRemove, - onImageLinkLoadError, - onImageLinkLoadSuccess, - onReUpload, - } = useImageFiles() + const { files, onUpload, onRemove, onImageLinkLoadError, onImageLinkLoadSuccess, onReUpload } = + useImageFiles() useEffect(() => { onFilesChange(files) @@ -101,19 +80,14 @@ const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({ disabled={files.length >= settings.number_limits || disabled} limit={+settings.image_file_size_limit!} > - { - hovering => ( - <div className={` - flex h-8 cursor-pointer items-center justify-center rounded-lg - bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary - ${hovering && 'hover:bg-components-button-tertiary-bg-hover'} - `} - > - <ImagePlus className="mr-2 size-4" /> - {t($ => $['imageUploader.uploadFromComputer'], { ns: 'common' })} - </div> - ) - } + {(hovering) => ( + <div + className={`flex h-8 cursor-pointer items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary ${hovering && 'hover:bg-components-button-tertiary-bg-hover'} `} + > + <ImagePlus className="mr-2 size-4" /> + {t(($) => $['imageUploader.uploadFromComputer'], { ns: 'common' })} + </div> + )} </Uploader> ) @@ -135,18 +109,19 @@ const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({ onImageLinkLoadSuccess={onImageLinkLoadSuccess} /> </div> - <div className={`grid gap-1 ${settings.transfer_methods.length === 2 ? 'grid-cols-2' : 'grid-cols-1'}`} data-testid="upload-actions"> - { - settings.transfer_methods.map((method) => { - if (method === TransferMethod.local_file) - return <Fragment key={TransferMethod.local_file}>{localUpload}</Fragment> + <div + className={`grid gap-1 ${settings.transfer_methods.length === 2 ? 'grid-cols-2' : 'grid-cols-1'}`} + data-testid="upload-actions" + > + {settings.transfer_methods.map((method) => { + if (method === TransferMethod.local_file) + return <Fragment key={TransferMethod.local_file}>{localUpload}</Fragment> - if (method === TransferMethod.remote_url) - return <Fragment key={TransferMethod.remote_url}>{urlUpload}</Fragment> + if (method === TransferMethod.remote_url) + return <Fragment key={TransferMethod.remote_url}>{urlUpload}</Fragment> - return null - }) - } + return null + })} </div> </div> ) diff --git a/web/app/components/base/image-uploader/uploader.tsx b/web/app/components/base/image-uploader/uploader.tsx index c4dd292320262e..8d9fba6dad7adb 100644 --- a/web/app/components/base/image-uploader/uploader.tsx +++ b/web/app/components/base/image-uploader/uploader.tsx @@ -12,13 +12,7 @@ type UploaderProps = { disabled?: boolean } -const Uploader: FC<UploaderProps> = ({ - children, - onUpload, - closePopover, - limit, - disabled, -}) => { +const Uploader: FC<UploaderProps> = ({ children, onUpload, closePopover, limit, disabled }) => { const [hovering, setHovering] = useState(false) const { handleLocalFileUpload } = useLocalFileUploader({ limit, @@ -29,8 +23,7 @@ const Uploader: FC<UploaderProps> = ({ const handleChange = (e: ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0] - if (!file) - return + if (!file) return handleLocalFileUpload(file) closePopover?.() @@ -46,9 +39,9 @@ const Uploader: FC<UploaderProps> = ({ <input data-testid="local-file-input" className="absolute inset-0 block w-full cursor-pointer text-[0] opacity-0 disabled:cursor-not-allowed" - onClick={e => ((e.target as HTMLInputElement).value = '')} + onClick={(e) => ((e.target as HTMLInputElement).value = '')} type="file" - accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')} + accept={ALLOW_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',')} onChange={handleChange} disabled={disabled} /> diff --git a/web/app/components/base/image-uploader/utils.ts b/web/app/components/base/image-uploader/utils.ts index 7dc5b119856e45..aeaa112f86d3e7 100644 --- a/web/app/components/base/image-uploader/utils.ts +++ b/web/app/components/base/image-uploader/utils.ts @@ -8,14 +8,17 @@ import { upload } from '@/service/base' * @param t - Translation function * @returns Localized error message */ -export const getImageUploadErrorMessage = (error: any, defaultMessage: string, t: TFunction): string => { +export const getImageUploadErrorMessage = ( + error: any, + defaultMessage: string, + t: TFunction, +): string => { const errorCode = error?.response?.code - if (errorCode === 'forbidden') - return error?.response?.message + if (errorCode === 'forbidden') return error?.response?.message if (errorCode === 'file_extension_blocked') - return t($ => $['fileUploader.fileExtensionBlocked'], { ns: 'common' }) + return t(($) => $['fileUploader.fileExtensionBlocked'], { ns: 'common' }) return defaultMessage } @@ -27,26 +30,29 @@ type ImageUploadParams = { onErrorCallback: (error?: any) => void } type ImageUpload = (v: ImageUploadParams, isPublic?: boolean, url?: string) => void -export const imageUpload: ImageUpload = ({ - file, - onProgressCallback, - onSuccessCallback, - onErrorCallback, -}, isPublic, url) => { +export const imageUpload: ImageUpload = ( + { file, onProgressCallback, onSuccessCallback, onErrorCallback }, + isPublic, + url, +) => { const formData = new FormData() formData.append('file', file) const onProgress = (e: ProgressEvent) => { if (e.lengthComputable) { - const percent = Math.floor(e.loaded / e.total * 100) + const percent = Math.floor((e.loaded / e.total) * 100) onProgressCallback(percent) } } - upload({ - xhr: new XMLHttpRequest(), - data: formData, - onprogress: onProgress, - }, isPublic, url) + upload( + { + xhr: new XMLHttpRequest(), + data: formData, + onprogress: onProgress, + }, + isPublic, + url, + ) .then((res: { id: string }) => { onSuccessCallback(res) }) diff --git a/web/app/components/base/infotip/__tests__/index.spec.tsx b/web/app/components/base/infotip/__tests__/index.spec.tsx index 31b035b5692e73..94505531f93533 100644 --- a/web/app/components/base/infotip/__tests__/index.spec.tsx +++ b/web/app/components/base/infotip/__tests__/index.spec.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event' import { createPortal } from 'react-dom' import { Infotip } from '../index' -function ClickBoundary({ children, onClick }: { children: ReactNode, onClick: () => void }) { +function ClickBoundary({ children, onClick }: { children: ReactNode; onClick: () => void }) { return ( <button type="button" aria-label="Parent action" onClick={onClick}> {createPortal(children, document.body)} diff --git a/web/app/components/base/infotip/index.tsx b/web/app/components/base/infotip/index.tsx index 3b5927ea83c28a..b19174a93927f0 100644 --- a/web/app/components/base/infotip/index.tsx +++ b/web/app/components/base/infotip/index.tsx @@ -44,17 +44,17 @@ type InfotipIconSize = keyof typeof iconSizeClassNames type InfotipProps = { /** Popup content. Rich nodes are allowed. */ - 'children': ReactNode + children: ReactNode /** Accessible name for the icon-only trigger. */ 'aria-label': string /** Extra classes on the trigger for contextual layout and color. */ - 'className'?: string + className?: string /** Icon glyph. Defaults to `question`. */ - 'iconVariant'?: InfotipIconVariant + iconVariant?: InfotipIconVariant /** Icon size. Defaults to `medium` (14px). */ - 'iconSize'?: InfotipIconSize + iconSize?: InfotipIconSize /** Extra classes on the popup body (width / padding / whitespace overrides). */ - 'popupClassName'?: string + popupClassName?: string } export function Infotip({ @@ -82,11 +82,17 @@ export function Infotip({ className, )} > - <span aria-hidden className={cn(iconClassNames[iconVariant], iconSizeClassNames[iconSize])} /> + <span + aria-hidden + className={cn(iconClassNames[iconVariant], iconSizeClassNames[iconSize])} + /> </PopoverTrigger> <PopoverContent placement="top" - popupClassName={cn('max-w-[300px] rounded-md px-3 py-2 system-xs-regular text-text-tertiary', popupClassName)} + popupClassName={cn( + 'max-w-[300px] rounded-md px-3 py-2 system-xs-regular text-text-tertiary', + popupClassName, + )} > {children} </PopoverContent> diff --git a/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx b/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx index 176aa87786834e..16397400af028d 100644 --- a/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx +++ b/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx @@ -4,12 +4,14 @@ import { createReactI18nextMock } from '@/test/i18n-mock' import InlineDeleteConfirm from '../index' // Mock react-i18next with custom translations for test assertions -vi.mock('react-i18next', () => createReactI18nextMock({ - 'operation.deleteConfirmTitle': 'Delete?', - 'operation.yes': 'Yes', - 'operation.no': 'No', - 'operation.confirmAction': 'Please confirm your action.', -})) +vi.mock('react-i18next', () => + createReactI18nextMock({ + 'operation.deleteConfirmTitle': 'Delete?', + 'operation.yes': 'Yes', + 'operation.no': 'No', + 'operation.confirmAction': 'Please confirm your action.', + }), +) afterEach(cleanup) @@ -89,11 +91,7 @@ describe('InlineDeleteConfirm', () => { const onConfirm = vi.fn() const onCancel = vi.fn() const { container } = render( - <InlineDeleteConfirm - className="custom-class" - onConfirm={onConfirm} - onCancel={onCancel} - />, + <InlineDeleteConfirm className="custom-class" onConfirm={onConfirm} onCancel={onCancel} />, ) const wrapper = container.firstChild as HTMLElement diff --git a/web/app/components/base/inline-delete-confirm/index.stories.tsx b/web/app/components/base/inline-delete-confirm/index.stories.tsx index c352c512a544c1..66d8cbde70e16c 100644 --- a/web/app/components/base/inline-delete-confirm/index.stories.tsx +++ b/web/app/components/base/inline-delete-confirm/index.stories.tsx @@ -10,7 +10,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compact confirmation prompt that appears inline, commonly used near delete buttons or destructive controls.', + component: + 'Compact confirmation prompt that appears inline, commonly used near delete buttons or destructive controls.', }, }, }, @@ -63,11 +64,11 @@ const InlineDeleteConfirmDemo = (args: Story['args']) => { } export const Playground: Story = { - render: args => <InlineDeleteConfirmDemo {...args} />, + render: (args) => <InlineDeleteConfirmDemo {...args} />, } export const WarningVariant: Story = { - render: args => <InlineDeleteConfirmDemo {...args} />, + render: (args) => <InlineDeleteConfirmDemo {...args} />, args: { variant: 'warning', title: 'Archive conversation?', @@ -77,7 +78,7 @@ export const WarningVariant: Story = { } export const InfoVariant: Story = { - render: args => <InlineDeleteConfirmDemo {...args} />, + render: (args) => <InlineDeleteConfirmDemo {...args} />, args: { variant: 'info', title: 'Remove collaborator?', diff --git a/web/app/components/base/inline-delete-confirm/index.tsx b/web/app/components/base/inline-delete-confirm/index.tsx index 04ac2a6b0f690f..c9b683693e9d61 100644 --- a/web/app/components/base/inline-delete-confirm/index.tsx +++ b/web/app/components/base/inline-delete-confirm/index.tsx @@ -25,9 +25,11 @@ const InlineDeleteConfirm: FC<InlineDeleteConfirmProps> = ({ }) => { const { t } = useTranslation() - const titleText = title || t($ => $['operation.deleteConfirmTitle'], { ns: 'common', defaultValue: 'Delete?' }) - const confirmTxt = confirmText || t($ => $['operation.yes'], { ns: 'common', defaultValue: 'Yes' }) - const cancelTxt = cancelText || t($ => $['operation.no'], { ns: 'common', defaultValue: 'No' }) + const titleText = + title || t(($) => $['operation.deleteConfirmTitle'], { ns: 'common', defaultValue: 'Delete?' }) + const confirmTxt = + confirmText || t(($) => $['operation.yes'], { ns: 'common', defaultValue: 'Yes' }) + const cancelTxt = cancelText || t(($) => $['operation.no'], { ns: 'common', defaultValue: 'No' }) return ( <div @@ -42,10 +44,7 @@ const InlineDeleteConfirm: FC<InlineDeleteConfirmProps> = ({ className, )} > - <div - id="inline-delete-confirm-title" - className="system-xs-semibold text-text-primary" - > + <div id="inline-delete-confirm-title" className="system-xs-semibold text-text-primary"> {titleText} </div> @@ -72,7 +71,10 @@ const InlineDeleteConfirm: FC<InlineDeleteConfirmProps> = ({ </div> <span id="inline-delete-confirm-description" className="sr-only"> - {t($ => $['operation.confirmAction'], { ns: 'common', defaultValue: 'Please confirm your action.' })} + {t(($) => $['operation.confirmAction'], { + ns: 'common', + defaultValue: 'Please confirm your action.', + })} </span> </div> ) diff --git a/web/app/components/base/input-with-copy/__tests__/index.spec.tsx b/web/app/components/base/input-with-copy/__tests__/index.spec.tsx index 2b9d64a39acac2..aff33c5cc64109 100644 --- a/web/app/components/base/input-with-copy/__tests__/index.spec.tsx +++ b/web/app/components/base/input-with-copy/__tests__/index.spec.tsx @@ -52,7 +52,9 @@ describe('InputWithCopy component', () => { it('calls copy function with custom value when copyValue prop is provided', () => { const mockOnChange = vi.fn() - render(<InputWithCopy value="display value" onChange={mockOnChange} copyValue="custom copy value" />) + render( + <InputWithCopy value="display value" onChange={mockOnChange} copyValue="custom copy value" />, + ) const copyButton = screen.getByRole('button') fireEvent.click(copyButton) @@ -163,7 +165,9 @@ describe('InputWithCopy component', () => { const mockOnChange = vi.fn() render(<InputWithCopy value="test value" onChange={mockOnChange} />) - const copyButton = screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copied' }) + const copyButton = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.embedded.copied', + }) expect(copyButton).toBeInTheDocument() }) @@ -172,7 +176,9 @@ describe('InputWithCopy component', () => { const mockOnChange = vi.fn() render(<InputWithCopy value="test value" onChange={mockOnChange} />) - const copyButton = screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }) + const copyButton = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.embedded.copy', + }) expect(copyButton).toBeInTheDocument() }) @@ -198,9 +204,7 @@ describe('InputWithCopy component', () => { it('copies copyValue over non-string input value when both provided', () => { const mockOnChange = vi.fn() - render( - <InputWithCopy value={42} onChange={mockOnChange} copyValue="override-copy" />, - ) + render(<InputWithCopy value={42} onChange={mockOnChange} copyValue="override-copy" />) const copyButton = screen.getByRole('button') fireEvent.click(copyButton) @@ -212,7 +216,12 @@ describe('InputWithCopy component', () => { const onCopyMock = vi.fn() const mockOnChange = vi.fn() render( - <InputWithCopy value="display" onChange={mockOnChange} copyValue="custom" onCopy={onCopyMock} />, + <InputWithCopy + value="display" + onChange={mockOnChange} + copyValue="custom" + onCopy={onCopyMock} + />, ) const copyButton = screen.getByRole('button') diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx index 71fa779a4f6ed8..89f7d3ba84464b 100644 --- a/web/app/components/base/input-with-copy/index.tsx +++ b/web/app/components/base/input-with-copy/index.tsx @@ -15,78 +15,75 @@ type InputWithCopyProps = { const prefixEmbedded = 'overview.appInfo.embedded' -const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>(( - { - showCopyButton = true, - copyValue, - onCopy, - value, - wrapperClassName, - ...inputProps - }, - ref, -) => { - const { t } = useTranslation() - // Determine what value to copy - const valueToString = typeof value === 'string' ? value : String(value || '') - const finalCopyValue = copyValue || valueToString +const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>( + ({ showCopyButton = true, copyValue, onCopy, value, wrapperClassName, ...inputProps }, ref) => { + const { t } = useTranslation() + // Determine what value to copy + const valueToString = typeof value === 'string' ? value : String(value || '') + const finalCopyValue = copyValue || valueToString - const { copied, copy, reset } = useClipboard() + const { copied, copy, reset } = useClipboard() - const handleCopy = () => { - copy(finalCopyValue) - onCopy?.(finalCopyValue) - } + const handleCopy = () => { + copy(finalCopyValue) + onCopy?.(finalCopyValue) + } - const tooltipText = copied - ? t($ => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) - : t($ => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' }) - /* v8 ignore next -- i18n test mock always returns a non-empty string; runtime fallback is defensive. -- @preserve */ - const safeTooltipText = tooltipText || '' + const tooltipText = copied + ? t(($) => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) + : t(($) => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' }) + /* v8 ignore next -- i18n test mock always returns a non-empty string; runtime fallback is defensive. -- @preserve */ + const safeTooltipText = tooltipText || '' - return ( - <div className={cn('relative w-full', wrapperClassName)}> - <input - ref={ref} - className={cn( - 'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs', - 'rounded-lg px-3 system-sm-regular', - showCopyButton && 'pr-8', - inputProps.disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled', - inputProps.className, + return ( + <div className={cn('relative w-full', wrapperClassName)}> + <input + ref={ref} + className={cn( + 'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs', + 'rounded-lg px-3 system-sm-regular', + showCopyButton && 'pr-8', + inputProps.disabled && + 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled', + inputProps.className, + )} + value={value} + {...(({ size: _size, ...rest }) => rest)(inputProps)} + /> + {showCopyButton && ( + <div className="absolute top-1/2 right-2 -translate-y-1/2"> + <Tooltip> + <TooltipTrigger + render={ + <ActionButton + size="xs" + aria-label={safeTooltipText} + onClick={handleCopy} + onMouseLeave={reset} + className="hover:bg-components-button-ghost-bg-hover" + > + {copied ? ( + <span + className="i-ri-clipboard-fill size-3.5 text-text-tertiary" + aria-hidden="true" + /> + ) : ( + <span + className="i-ri-clipboard-line size-3.5 text-text-tertiary" + aria-hidden="true" + /> + )} + </ActionButton> + } + /> + <TooltipContent>{safeTooltipText}</TooltipContent> + </Tooltip> + </div> )} - value={value} - {...(({ size: _size, ...rest }) => rest)(inputProps)} - /> - {showCopyButton && ( - <div - className="absolute top-1/2 right-2 -translate-y-1/2" - > - <Tooltip> - <TooltipTrigger - render={( - <ActionButton - size="xs" - aria-label={safeTooltipText} - onClick={handleCopy} - onMouseLeave={reset} - className="hover:bg-components-button-ghost-bg-hover" - > - {copied - ? (<span className="i-ri-clipboard-fill size-3.5 text-text-tertiary" aria-hidden="true" />) - : (<span className="i-ri-clipboard-line size-3.5 text-text-tertiary" aria-hidden="true" />)} - </ActionButton> - )} - /> - <TooltipContent> - {safeTooltipText} - </TooltipContent> - </Tooltip> - </div> - )} - </div> - ) -}) + </div> + ) + }, +) InputWithCopy.displayName = 'InputWithCopy' diff --git a/web/app/components/base/input/__tests__/index.spec.tsx b/web/app/components/base/input/__tests__/index.spec.tsx index 90bac0095f10c0..117d1edadf6eac 100644 --- a/web/app/components/base/input/__tests__/index.spec.tsx +++ b/web/app/components/base/input/__tests__/index.spec.tsx @@ -4,10 +4,12 @@ import { createReactI18nextMock } from '@/test/i18n-mock' import Input, { inputVariants } from '../index' // Mock the i18n hook with custom translations for test assertions -vi.mock('react-i18next', () => createReactI18nextMock({ - 'operation.search': 'Search', - 'placeholder.input': 'Please input', -})) +vi.mock('react-i18next', () => + createReactI18nextMock({ + 'operation.search': 'Search', + 'placeholder.input': 'Please input', + }), +) describe('Input component', () => { describe('Variants', () => { diff --git a/web/app/components/base/input/index.stories.tsx b/web/app/components/base/input/index.stories.tsx index 860f65dfc7ff3d..eb1b64d0fbdf4b 100644 --- a/web/app/components/base/input/index.stories.tsx +++ b/web/app/components/base/input/index.stories.tsx @@ -9,7 +9,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Input component with support for icons, clear button, validation states, and units. Includes automatic leading zero removal for number inputs.', + component: + 'Input component with support for icons, clear button, validation states, and units. Includes automatic leading zero removal for number inputs.', }, }, }, @@ -79,7 +80,7 @@ const InputDemo = (args: any) => { // Default state export const Default: Story = { - render: args => <InputDemo {...args} />, + render: (args) => <InputDemo {...args} />, args: { size: 'regular', placeholder: 'Enter text...', @@ -89,7 +90,7 @@ export const Default: Story = { // Large size export const LargeSize: Story = { - render: args => <InputDemo {...args} />, + render: (args) => <InputDemo {...args} />, args: { size: 'large', placeholder: 'Enter text...', @@ -99,7 +100,7 @@ export const LargeSize: Story = { // With search icon export const WithSearchIcon: Story = { - render: args => <InputDemo {...args} />, + render: (args) => <InputDemo {...args} />, args: { size: 'regular', showLeftIcon: true, @@ -110,7 +111,7 @@ export const WithSearchIcon: Story = { // With clear button export const WithClearButton: Story = { - render: args => <InputDemo {...args} />, + render: (args) => <InputDemo {...args} />, args: { size: 'regular', showClearIcon: true, @@ -122,7 +123,7 @@ export const WithClearButton: Story = { // Search input (icon + clear) export const SearchInput: Story = { - render: args => <InputDemo {...args} />, + render: (args) => <InputDemo {...args} />, args: { size: 'regular', showLeftIcon: true, @@ -135,7 +136,7 @@ export const SearchInput: Story = { // Disabled state export const Disabled: Story = { - render: args => <InputDemo {...args} />, + render: (args) => <InputDemo {...args} />, args: { size: 'regular', value: 'Disabled input', @@ -146,7 +147,7 @@ export const Disabled: Story = { // Destructive/error state export const DestructiveState: Story = { - render: args => <InputDemo {...args} />, + render: (args) => <InputDemo {...args} />, args: { size: 'regular', value: 'invalid@email', @@ -158,7 +159,7 @@ export const DestructiveState: Story = { // Number input export const NumberInput: Story = { - render: args => <InputDemo {...args} />, + render: (args) => <InputDemo {...args} />, args: { size: 'regular', type: 'number', @@ -169,7 +170,7 @@ export const NumberInput: Story = { // With unit export const WithUnit: Story = { - render: args => <InputDemo {...args} />, + render: (args) => <InputDemo {...args} />, args: { size: 'regular', type: 'number', @@ -181,7 +182,7 @@ export const WithUnit: Story = { // Email input export const EmailInput: Story = { - render: args => <InputDemo {...args} />, + render: (args) => <InputDemo {...args} />, args: { size: 'regular', type: 'email', @@ -192,7 +193,7 @@ export const EmailInput: Story = { // Password input export const PasswordInput: Story = { - render: args => <InputDemo {...args} />, + render: (args) => <InputDemo {...args} />, args: { size: 'regular', type: 'password', @@ -213,7 +214,7 @@ const SizeComparisonDemo = () => { <Input size="regular" value={regularValue} - onChange={e => setRegularValue(e.target.value)} + onChange={(e) => setRegularValue(e.target.value)} placeholder="Regular input..." showClearIcon onClear={() => setRegularValue('')} @@ -224,7 +225,7 @@ const SizeComparisonDemo = () => { <Input size="large" value={largeValue} - onChange={e => setLargeValue(e.target.value)} + onChange={(e) => setLargeValue(e.target.value)} placeholder="Large input..." showClearIcon onClear={() => setLargeValue('')} @@ -249,26 +250,18 @@ const StateComparisonDemo = () => { <label className="text-sm font-medium text-gray-700">Normal</label> <Input value={normalValue} - onChange={e => setNormalValue(e.target.value)} + onChange={(e) => setNormalValue(e.target.value)} showClearIcon onClear={() => setNormalValue('')} /> </div> <div className="flex flex-col gap-2"> <label className="text-sm font-medium text-gray-700">Destructive</label> - <Input - value={errorValue} - onChange={e => setErrorValue(e.target.value)} - destructive - /> + <Input value={errorValue} onChange={(e) => setErrorValue(e.target.value)} destructive /> </div> <div className="flex flex-col gap-2"> <label className="text-sm font-medium text-gray-700">Disabled</label> - <Input - value="Disabled input" - onChange={() => undefined} - disabled - /> + <Input value="Disabled input" onChange={() => undefined} disabled /> </div> </div> ) @@ -303,7 +296,7 @@ const FormExampleDemo = () => { <label className="text-sm font-medium text-gray-700">Name</label> <Input value={formData.name} - onChange={e => setFormData({ ...formData, name: e.target.value })} + onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="Enter your name..." showClearIcon onClear={() => setFormData({ ...formData, name: '' })} @@ -316,7 +309,10 @@ const FormExampleDemo = () => { value={formData.email} onChange={(e) => { setFormData({ ...formData, email: e.target.value }) - setErrors({ ...errors, email: e.target.value ? !validateEmail(e.target.value) : false }) + setErrors({ + ...errors, + email: e.target.value ? !validateEmail(e.target.value) : false, + }) }} placeholder="Enter your email..." destructive={errors.email} @@ -343,16 +339,14 @@ const FormExampleDemo = () => { destructive={errors.age} unit="years" /> - {errors.age && ( - <span className="text-xs text-red-600">Must be 18 or older</span> - )} + {errors.age && <span className="text-xs text-red-600">Must be 18 or older</span>} </div> <div className="flex flex-col gap-2"> <label className="text-sm font-medium text-gray-700">Website</label> <Input type="url" value={formData.website} - onChange={e => setFormData({ ...formData, website: e.target.value })} + onChange={(e) => setFormData({ ...formData, website: e.target.value })} placeholder="https://example.com" showClearIcon onClear={() => setFormData({ ...formData, website: '' })} @@ -371,7 +365,7 @@ export const FormExample: Story = { const SearchExampleDemo = () => { const [searchQuery, setSearchQuery] = useState('') const items = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape'] - const filteredItems = items.filter(item => + const filteredItems = items.filter((item) => item.toLowerCase().includes(searchQuery.toLowerCase()), ) @@ -382,20 +376,18 @@ const SearchExampleDemo = () => { showLeftIcon showClearIcon value={searchQuery} - onChange={e => setSearchQuery(e.target.value)} + onChange={(e) => setSearchQuery(e.target.value)} onClear={() => setSearchQuery('')} placeholder="Search fruits..." /> {searchQuery && ( <div className="rounded-lg bg-gray-50 p-4"> <div className="mb-2 text-xs text-gray-500"> - {filteredItems.length} - {' '} - result + {filteredItems.length} result {filteredItems.length !== 1 ? 's' : ''} </div> <div className="flex flex-col gap-1"> - {filteredItems.map(item => ( + {filteredItems.map((item) => ( <div key={item} className="text-sm text-gray-700"> {item} </div> @@ -413,7 +405,7 @@ export const SearchExample: Story = { // Interactive playground export const Playground: Story = { - render: args => <InputDemo {...args} />, + render: (args) => <InputDemo {...args} />, args: { size: 'regular', type: 'text', diff --git a/web/app/components/base/input/index.tsx b/web/app/components/base/input/index.tsx index 48f3ed95dfd4ce..d6814815068547 100644 --- a/web/app/components/base/input/index.tsx +++ b/web/app/components/base/input/index.tsx @@ -7,20 +7,17 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { CopyFeedbackNew } from '../copy-feedback' -export const inputVariants = cva( - '', - { - variants: { - size: { - regular: 'rounded-lg px-3 system-sm-regular', - large: 'rounded-[10px] px-4 system-md-regular', - }, - }, - defaultVariants: { - size: 'regular', +export const inputVariants = cva('', { + variants: { + size: { + regular: 'rounded-lg px-3 system-sm-regular', + large: 'rounded-[10px] px-4 system-md-regular', }, }, -) + defaultVariants: { + size: 'regular', + }, +}) /** * @deprecated Use `@langgenius/dify-ui/input` for primitive inputs and @@ -37,7 +34,8 @@ export type InputProps = { wrapperClassName?: string styleCss?: CSSProperties unit?: string -} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & VariantProps<typeof inputVariants> +} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & + VariantProps<typeof inputVariants> const removeLeadingZeros = (value: string) => value.replace(/^(-?)0+(?=\d)/, '$1') @@ -46,109 +44,127 @@ const removeLeadingZeros = (value: string) => value.replace(/^(-?)0+(?=\d)/, '$1 * `@langgenius/dify-ui/field` for form composition. Search inputs should use * a dedicated composition built on the primitive input. */ -const Input = React.forwardRef<HTMLInputElement, InputProps>(({ - size, - disabled, - destructive, - showLeftIcon, - showClearIcon, - showCopyIcon, - onClear, - wrapperClassName, - className, - styleCss, - value, - placeholder, - onChange = noop, - onBlur = noop, - unit, - ...props -}, ref) => { - const { t } = useTranslation() - const handleNumberChange: ChangeEventHandler<HTMLInputElement> = (e) => { - if (value === 0) { +const Input = React.forwardRef<HTMLInputElement, InputProps>( + ( + { + size, + disabled, + destructive, + showLeftIcon, + showClearIcon, + showCopyIcon, + onClear, + wrapperClassName, + className, + styleCss, + value, + placeholder, + onChange = noop, + onBlur = noop, + unit, + ...props + }, + ref, + ) => { + const { t } = useTranslation() + const handleNumberChange: ChangeEventHandler<HTMLInputElement> = (e) => { + if (value === 0) { + // remove leading zeros + const formattedValue = removeLeadingZeros(e.target.value) + if (e.target.value !== formattedValue) e.target.value = formattedValue + } + onChange(e) + } + const handleNumberBlur: FocusEventHandler<HTMLInputElement> = (e) => { // remove leading zeros const formattedValue = removeLeadingZeros(e.target.value) - if (e.target.value !== formattedValue) + if (e.target.value !== formattedValue) { e.target.value = formattedValue + onChange({ + ...e, + type: 'change', + target: { + ...e.target, + value: formattedValue, + }, + }) + } + onBlur(e) } - onChange(e) - } - const handleNumberBlur: FocusEventHandler<HTMLInputElement> = (e) => { - // remove leading zeros - const formattedValue = removeLeadingZeros(e.target.value) - if (e.target.value !== formattedValue) { - e.target.value = formattedValue - onChange({ - ...e, - type: 'change', - target: { - ...e.target, - value: formattedValue, - }, - }) - } - onBlur(e) - } - return ( - <div className={cn('relative w-full', wrapperClassName)}> - {showLeftIcon && <span className={cn('absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder')} />} - <input - ref={ref} - style={styleCss} - className={cn( - 'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs', - inputVariants({ size }), - showLeftIcon && 'pl-[26px]', - showLeftIcon && size === 'large' && 'pl-7', - showClearIcon && value && 'pr-[26px]', - showClearIcon && value && size === 'large' && 'pr-7', - (destructive || showCopyIcon) && 'pr-[26px]', - (destructive || showCopyIcon) && size === 'large' && 'pr-7', - disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled', - destructive && 'border-components-input-border-destructive bg-components-input-bg-destructive text-components-input-text-filled hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive', - className, - )} - placeholder={placeholder ?? (showLeftIcon - ? (t($ => $['operation.search'], { ns: 'common' }) || '') - : (t($ => $['placeholder.input'], { ns: 'common' }) || ''))} - value={value} - onChange={props.type === 'number' ? handleNumberChange : onChange} - onBlur={props.type === 'number' ? handleNumberBlur : onBlur} - disabled={disabled} - {...props} - /> - {!!(showClearIcon && value && !disabled && !destructive) && ( - <button - type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} - className={cn('group absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer border-none bg-transparent p-px')} - onClick={onClear} - > - <span className="i-ri-close-circle-fill size-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary" aria-hidden="true" /> - </button> - )} - {destructive && ( - <span className="absolute top-1/2 right-2 i-ri-error-warning-line size-4 -translate-y-1/2 text-text-destructive-secondary" /> - )} - {showCopyIcon && ( - <div className={cn('group absolute top-1/2 right-0 -translate-y-1/2 cursor-pointer')}> - <CopyFeedbackNew - content={String(value ?? '')} - className="size-7! hover:bg-transparent" + return ( + <div className={cn('relative w-full', wrapperClassName)}> + {showLeftIcon && ( + <span + className={cn( + 'absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder', + )} /> - </div> - )} - { - unit && ( + )} + <input + ref={ref} + style={styleCss} + className={cn( + 'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs', + inputVariants({ size }), + showLeftIcon && 'pl-[26px]', + showLeftIcon && size === 'large' && 'pl-7', + showClearIcon && value && 'pr-[26px]', + showClearIcon && value && size === 'large' && 'pr-7', + (destructive || showCopyIcon) && 'pr-[26px]', + (destructive || showCopyIcon) && size === 'large' && 'pr-7', + disabled && + 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled', + destructive && + 'border-components-input-border-destructive bg-components-input-bg-destructive text-components-input-text-filled hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive', + className, + )} + placeholder={ + placeholder ?? + (showLeftIcon + ? t(($) => $['operation.search'], { ns: 'common' }) || '' + : t(($) => $['placeholder.input'], { ns: 'common' }) || '') + } + value={value} + onChange={props.type === 'number' ? handleNumberChange : onChange} + onBlur={props.type === 'number' ? handleNumberBlur : onBlur} + disabled={disabled} + {...props} + /> + {!!(showClearIcon && value && !disabled && !destructive) && ( + <button + type="button" + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} + className={cn( + 'group absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer border-none bg-transparent p-px', + )} + onClick={onClear} + > + <span + className="i-ri-close-circle-fill size-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary" + aria-hidden="true" + /> + </button> + )} + {destructive && ( + <span className="absolute top-1/2 right-2 i-ri-error-warning-line size-4 -translate-y-1/2 text-text-destructive-secondary" /> + )} + {showCopyIcon && ( + <div className={cn('group absolute top-1/2 right-0 -translate-y-1/2 cursor-pointer')}> + <CopyFeedbackNew + content={String(value ?? '')} + className="size-7! hover:bg-transparent" + /> + </div> + )} + {unit && ( <div className="absolute top-1/2 right-2 -translate-y-1/2 system-sm-regular text-text-tertiary"> {unit} </div> - ) - } - </div> - ) -}) + )} + </div> + ) + }, +) Input.displayName = 'Input' diff --git a/web/app/components/base/linked-apps-panel/__tests__/index.spec.tsx b/web/app/components/base/linked-apps-panel/__tests__/index.spec.tsx index 5576fb289ea052..692d55c82e3413 100644 --- a/web/app/components/base/linked-apps-panel/__tests__/index.spec.tsx +++ b/web/app/components/base/linked-apps-panel/__tests__/index.spec.tsx @@ -5,7 +5,15 @@ import { AppModeEnum } from '@/types/app' import LinkedAppsPanel from '../index' vi.mock('@/next/link', () => ({ - default: ({ children, href, className }: { children: React.ReactNode, href: string, className: string }) => ( + default: ({ + children, + href, + className, + }: { + children: React.ReactNode + href: string + className: string + }) => ( <a href={href} className={className} data-testid="link-item"> {children} </a> diff --git a/web/app/components/base/linked-apps-panel/index.stories.tsx b/web/app/components/base/linked-apps-panel/index.stories.tsx index fb9d7c4fba6da3..0b76b63ac2d53b 100644 --- a/web/app/components/base/linked-apps-panel/index.stories.tsx +++ b/web/app/components/base/linked-apps-panel/index.stories.tsx @@ -40,7 +40,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Shows a curated list of related applications, pairing each app icon with quick navigation links.', + component: + 'Shows a curated list of related applications, pairing each app icon with quick navigation links.', }, }, }, diff --git a/web/app/components/base/linked-apps-panel/index.tsx b/web/app/components/base/linked-apps-panel/index.tsx index 579f548291f1ae..a0fe82800cd0d8 100644 --- a/web/app/components/base/linked-apps-panel/index.tsx +++ b/web/app/components/base/linked-apps-panel/index.tsx @@ -22,19 +22,34 @@ const appTypeMap = { [AppModeEnum.WORKFLOW]: 'Workflow', } -const LikedItem = ({ - detail, - isMobile, -}: ILikedItemProps) => { +const LikedItem = ({ detail, isMobile }: ILikedItemProps) => { return ( - <Link className={cn('group/link-item flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-2 hover:bg-state-base-hover', isMobile && 'justify-center')} href={`/app/${detail?.id}/overview`}> + <Link + className={cn( + 'group/link-item flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-2 hover:bg-state-base-hover', + isMobile && 'justify-center', + )} + href={`/app/${detail?.id}/overview`} + > <div className="flex items-center"> <div className={cn('relative size-6 rounded-md')}> - <AppIcon size="tiny" iconType={detail.icon_type} icon={detail.icon} background={detail.icon_background} imageUrl={detail.icon_url} /> + <AppIcon + size="tiny" + iconType={detail.icon_type} + icon={detail.icon} + background={detail.icon_background} + imageUrl={detail.icon_url} + /> </div> - {!isMobile && <div className={cn('ml-2 truncate system-sm-medium text-text-primary')}>{detail?.name || '--'}</div>} + {!isMobile && ( + <div className={cn('ml-2 truncate system-sm-medium text-text-primary')}> + {detail?.name || '--'} + </div> + )} + </div> + <div className="shrink-0 system-2xs-medium-uppercase text-text-tertiary group-hover/link-item:hidden"> + {appTypeMap[detail.mode]} </div> - <div className="shrink-0 system-2xs-medium-uppercase text-text-tertiary group-hover/link-item:hidden">{appTypeMap[detail.mode]}</div> <RiArrowRightUpLine className="hidden size-4 text-text-tertiary group-hover/link-item:block" /> </Link> ) @@ -45,10 +60,7 @@ type Props = Readonly<{ isMobile: boolean }> -const LinkedAppsPanel: FC<Props> = ({ - relatedApps, - isMobile, -}) => { +const LinkedAppsPanel: FC<Props> = ({ relatedApps, isMobile }) => { return ( <div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]"> {relatedApps.map((item, index) => ( diff --git a/web/app/components/base/list-empty/horizontal-line.tsx b/web/app/components/base/list-empty/horizontal-line.tsx index 24f17a9cc5033f..d9089212dd7a98 100644 --- a/web/app/components/base/list-empty/horizontal-line.tsx +++ b/web/app/components/base/list-empty/horizontal-line.tsx @@ -1,14 +1,26 @@ type HorizontalLineProps = { className?: string } -const HorizontalLine = ({ - className, -}: HorizontalLineProps) => { +const HorizontalLine = ({ className }: HorizontalLineProps) => { return ( - <svg xmlns="http://www.w3.org/2000/svg" width="240" height="2" viewBox="0 0 240 2" fill="none" className={className}> + <svg + xmlns="http://www.w3.org/2000/svg" + width="240" + height="2" + viewBox="0 0 240 2" + fill="none" + className={className} + > <path d="M0 1H240" stroke="url(#paint0_linear_8619_59125)" /> <defs> - <linearGradient id="paint0_linear_8619_59125" x1="240" y1="9.99584" x2="3.95539e-05" y2="9.88094" gradientUnits="userSpaceOnUse"> + <linearGradient + id="paint0_linear_8619_59125" + x1="240" + y1="9.99584" + x2="3.95539e-05" + y2="9.88094" + gradientUnits="userSpaceOnUse" + > <stop stopColor="white" stopOpacity="0.01" /> <stop offset="0.9031" stopColor="#101828" stopOpacity="0.04" /> <stop offset="1" stopColor="white" stopOpacity="0.01" /> diff --git a/web/app/components/base/list-empty/index.stories.tsx b/web/app/components/base/list-empty/index.stories.tsx index 85808bc8799430..13ed6c4787d8a0 100644 --- a/web/app/components/base/list-empty/index.stories.tsx +++ b/web/app/components/base/list-empty/index.stories.tsx @@ -8,7 +8,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Large empty state card used in panels and drawers to hint at the next action for the user.', + component: + 'Large empty state card used in panels and drawers to hint at the next action for the user.', }, }, }, @@ -16,7 +17,8 @@ const meta = { title: 'No items yet', description: ( <p className="text-xs/5 text-text-tertiary"> - Add your first entry to see it appear here. Empty states help users discover what happens next. + Add your first entry to see it appear here. Empty states help users discover what happens + next. </p> ), }, diff --git a/web/app/components/base/list-empty/index.tsx b/web/app/components/base/list-empty/index.tsx index 71a7c29eb99779..3d585a1489562b 100644 --- a/web/app/components/base/list-empty/index.tsx +++ b/web/app/components/base/list-empty/index.tsx @@ -10,17 +10,11 @@ type ListEmptyProps = { icon?: ReactNode } -const ListEmpty = ({ - title, - description, - icon, -}: ListEmptyProps) => { +const ListEmpty = ({ title, description, icon }: ListEmptyProps) => { return ( <div className="flex w-[320px] flex-col items-start gap-2 rounded-[10px] bg-workflow-process-bg p-4"> <div className="flex h-10 w-10 items-center justify-center gap-2 rounded-[10px]"> - <div className="relative flex grow items-center justify-center gap-2 self-stretch rounded-[10px] border-[0.5px] - border-components-card-border bg-components-card-bg p-1 shadow-lg" - > + <div className="relative flex grow items-center justify-center gap-2 self-stretch rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg"> {icon || <Variable02 className="size-5 shrink-0 text-text-accent" />} <VerticalLine className="absolute top-1/2 -right-px -translate-y-1/4" /> <VerticalLine className="absolute top-1/2 -left-px -translate-y-1/4" /> diff --git a/web/app/components/base/list-empty/vertical-line.tsx b/web/app/components/base/list-empty/vertical-line.tsx index 3a0ad1b85da317..f6d3eeae604135 100644 --- a/web/app/components/base/list-empty/vertical-line.tsx +++ b/web/app/components/base/list-empty/vertical-line.tsx @@ -1,14 +1,26 @@ type VerticalLineProps = { className?: string } -const VerticalLine = ({ - className, -}: VerticalLineProps) => { +const VerticalLine = ({ className }: VerticalLineProps) => { return ( - <svg xmlns="http://www.w3.org/2000/svg" width="2" height="132" viewBox="0 0 2 132" fill="none" className={className}> + <svg + xmlns="http://www.w3.org/2000/svg" + width="2" + height="132" + viewBox="0 0 2 132" + fill="none" + className={className} + > <path d="M1 0L1 132" stroke="url(#paint0_linear_8619_59128)" /> <defs> - <linearGradient id="paint0_linear_8619_59128" x1="-7.99584" y1="132" x2="-7.96108" y2="6.4974e-07" gradientUnits="userSpaceOnUse"> + <linearGradient + id="paint0_linear_8619_59128" + x1="-7.99584" + y1="132" + x2="-7.96108" + y2="6.4974e-07" + gradientUnits="userSpaceOnUse" + > <stop stopColor="white" stopOpacity="0.01" /> <stop offset="0.877606" stopColor="#101828" stopOpacity="0.04" /> <stop offset="1" stopColor="white" stopOpacity="0.01" /> diff --git a/web/app/components/base/loading/index.stories.tsx b/web/app/components/base/loading/index.stories.tsx index 643ef553399d11..bf0e29a5a064b3 100644 --- a/web/app/components/base/loading/index.stories.tsx +++ b/web/app/components/base/loading/index.stories.tsx @@ -8,7 +8,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Spinner used while fetching data (`area`) or bootstrapping the full application shell (`app`).', + component: + 'Spinner used while fetching data (`area`) or bootstrapping the full application shell (`app`).', }, }, }, diff --git a/web/app/components/base/loading/index.tsx b/web/app/components/base/loading/index.tsx index 108d840ec4b332..df4e4394e25805 100644 --- a/web/app/components/base/loading/index.tsx +++ b/web/app/components/base/loading/index.tsx @@ -22,14 +22,36 @@ const Loading = (props?: ILoadingProps) => { )} role="status" aria-live="polite" - aria-label={t($ => $.loading, { ns: 'appApi' })} + aria-label={t(($) => $.loading, { ns: 'appApi' })} > - <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="spin-animation"> + <svg + width="16" + height="16" + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className="spin-animation" + > <g clipPath="url(#clip0_324_2488)"> - <path d="M15 0H10C9.44772 0 9 0.447715 9 1V6C9 6.55228 9.44772 7 10 7H15C15.5523 7 16 6.55228 16 6V1C16 0.447715 15.5523 0 15 0Z" fill="#1C64F2" /> - <path opacity="0.5" d="M15 9H10C9.44772 9 9 9.44772 9 10V15C9 15.5523 9.44772 16 10 16H15C15.5523 16 16 15.5523 16 15V10C16 9.44772 15.5523 9 15 9Z" fill="#1C64F2" /> - <path opacity="0.1" d="M6 9H1C0.447715 9 0 9.44772 0 10V15C0 15.5523 0.447715 16 1 16H6C6.55228 16 7 15.5523 7 15V10C7 9.44772 6.55228 9 6 9Z" fill="#1C64F2" /> - <path opacity="0.2" d="M6 0H1C0.447715 0 0 0.447715 0 1V6C0 6.55228 0.447715 7 1 7H6C6.55228 7 7 6.55228 7 6V1C7 0.447715 6.55228 0 6 0Z" fill="#1C64F2" /> + <path + d="M15 0H10C9.44772 0 9 0.447715 9 1V6C9 6.55228 9.44772 7 10 7H15C15.5523 7 16 6.55228 16 6V1C16 0.447715 15.5523 0 15 0Z" + fill="#1C64F2" + /> + <path + opacity="0.5" + d="M15 9H10C9.44772 9 9 9.44772 9 10V15C9 15.5523 9.44772 16 10 16H15C15.5523 16 16 15.5523 16 15V10C16 9.44772 15.5523 9 15 9Z" + fill="#1C64F2" + /> + <path + opacity="0.1" + d="M6 9H1C0.447715 9 0 9.44772 0 10V15C0 15.5523 0.447715 16 1 16H6C6.55228 16 7 15.5523 7 15V10C7 9.44772 6.55228 9 6 9Z" + fill="#1C64F2" + /> + <path + opacity="0.2" + d="M6 0H1C0.447715 0 0 0.447715 0 1V6C0 6.55228 0.447715 7 1 7H6C6.55228 7 7 6.55228 7 6V1C7 0.447715 6.55228 0 6 0Z" + fill="#1C64F2" + /> </g> <defs> <clipPath id="clip0_324_2488"> @@ -37,7 +59,6 @@ const Loading = (props?: ILoadingProps) => { </clipPath> </defs> </svg> - </div> ) } diff --git a/web/app/components/base/loading/style.css b/web/app/components/base/loading/style.css index b5a5ef980f271c..688cfb31777f01 100644 --- a/web/app/components/base/loading/style.css +++ b/web/app/components/base/loading/style.css @@ -1,41 +1,41 @@ .spin-animation path { - animation: custom 2s linear infinite; + animation: custom 2s linear infinite; } @keyframes custom { - 0% { - opacity: 0; - } + 0% { + opacity: 0; + } - 25% { - opacity: 0.1; - } + 25% { + opacity: 0.1; + } - 50% { - opacity: 0.2; - } + 50% { + opacity: 0.2; + } - 75% { - opacity: 0.5; - } + 75% { + opacity: 0.5; + } - 100% { - opacity: 1; - } + 100% { + opacity: 1; + } } .spin-animation path:nth-child(1) { - animation-delay: 0s; + animation-delay: 0s; } .spin-animation path:nth-child(2) { - animation-delay: 0.5s; + animation-delay: 0.5s; } .spin-animation path:nth-child(3) { - animation-delay: 1s; + animation-delay: 1s; } .spin-animation path:nth-child(4) { - animation-delay: 1.5s; + animation-delay: 1.5s; } diff --git a/web/app/components/base/logo/dify-logo.tsx b/web/app/components/base/logo/dify-logo.tsx index 37ff47adc5efe2..99d479fa15623e 100644 --- a/web/app/components/base/logo/dify-logo.tsx +++ b/web/app/components/base/logo/dify-logo.tsx @@ -33,7 +33,7 @@ const DifyLogo: FC<DifyLogoProps> = ({ alt = 'Dify', }) => { const { theme } = useTheme() - const themedStyle = (theme === 'dark' && style === 'default') ? 'monochromeWhite' : style + const themedStyle = theme === 'dark' && style === 'default' ? 'monochromeWhite' : style return ( <img diff --git a/web/app/components/base/logo/logo-embedded-chat-avatar.tsx b/web/app/components/base/logo/logo-embedded-chat-avatar.tsx index 16308280a2acdd..eda9517555357c 100644 --- a/web/app/components/base/logo/logo-embedded-chat-avatar.tsx +++ b/web/app/components/base/logo/logo-embedded-chat-avatar.tsx @@ -4,9 +4,7 @@ import { basePath } from '@/utils/var' type LogoEmbeddedChatAvatarProps = { className?: string } -const LogoEmbeddedChatAvatar: FC<LogoEmbeddedChatAvatarProps> = ({ - className, -}) => { +const LogoEmbeddedChatAvatar: FC<LogoEmbeddedChatAvatarProps> = ({ className }) => { return ( <img src={`${basePath}/logo/logo-embedded-chat-avatar.png`} diff --git a/web/app/components/base/logo/logo-embedded-chat-header.tsx b/web/app/components/base/logo/logo-embedded-chat-header.tsx index 311cf912d2033a..96aca645c9d186 100644 --- a/web/app/components/base/logo/logo-embedded-chat-header.tsx +++ b/web/app/components/base/logo/logo-embedded-chat-header.tsx @@ -6,9 +6,7 @@ type LogoEmbeddedChatHeaderProps = { className?: string } -const LogoEmbeddedChatHeader: FC<LogoEmbeddedChatHeaderProps> = ({ - className, -}) => { +const LogoEmbeddedChatHeader: FC<LogoEmbeddedChatHeaderProps> = ({ className }) => { return ( <picture> <source media="(resolution: 1x)" srcSet="/logo/logo-embedded-chat-header.png" /> diff --git a/web/app/components/base/markdown-blocks/__tests__/audio-block.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/audio-block.spec.tsx index b24bc9a074af6c..41573126827c46 100644 --- a/web/app/components/base/markdown-blocks/__tests__/audio-block.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/audio-block.spec.tsx @@ -1,10 +1,8 @@ import type { NamedExoticComponent } from 'react' import { render, screen } from '@testing-library/react' import * as React from 'react' - // AudioBlock.integration.spec.tsx import { beforeEach, describe, expect, it, vi } from 'vitest' - import AudioBlock from '../audio-block' // Mock the nested AudioPlayer used by AudioGallery (do not mock AudioGallery itself) diff --git a/web/app/components/base/markdown-blocks/__tests__/button.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/button.spec.tsx index 54bd3a9cdc377d..68d51a8894e14e 100644 --- a/web/app/components/base/markdown-blocks/__tests__/button.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/button.spec.tsx @@ -6,7 +6,6 @@ import userEvent from '@testing-library/user-event' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChatContextProvider } from '@/app/components/base/chat/chat/context-provider' - import MarkdownButton from '../button' // Only mock the URL utility so behavior is deterministic diff --git a/web/app/components/base/markdown-blocks/__tests__/code-block.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/code-block.spec.tsx index a3d28a3b5e94cf..9cf7624ddd3808 100644 --- a/web/app/components/base/markdown-blocks/__tests__/code-block.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/code-block.spec.tsx @@ -2,7 +2,6 @@ import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as echarts from 'echarts' import { Theme } from '@/types/app' - import CodeBlock from '../code-block' const { mockHighlightCode } = vi.hoisted(() => ({ @@ -18,10 +17,9 @@ const mockEcharts = vi.hoisted(() => { const state = { finishedHandler: undefined as undefined | ((event?: unknown) => void), echartsInstance: { - resize: vi.fn<(opts?: { width?: string, height?: string }) => void>(), + resize: vi.fn<(opts?: { width?: string; height?: string }) => void>(), trigger: vi.fn((eventName: string, event?: unknown) => { - if (eventName === 'finished') - state.finishedHandler?.(event) + if (eventName === 'finished') state.finishedHandler?.(event) }), }, getInstanceByDom: vi.fn(() => state.echartsInstance), @@ -85,28 +83,33 @@ vi.mock('echarts', () => ({ vi.mock('echarts-for-react', async () => { const React = await vi.importActual<typeof import('react')>('react') - const MockReactEcharts = React.forwardRef(({ - onChartReady, - onEvents, - }: { - onChartReady?: (instance: typeof mockEcharts.echartsInstance) => void - onEvents?: { finished?: (event?: unknown) => void } - }, ref: React.ForwardedRef<{ getEchartsInstance: () => typeof mockEcharts.echartsInstance }>) => { - React.useImperativeHandle(ref, () => ({ - getEchartsInstance: () => mockEcharts.echartsInstance, - })) - - React.useEffect(() => { - mockEcharts.finishedHandler = onEvents?.finished - onChartReady?.(mockEcharts.echartsInstance) - onEvents?.finished?.({}) - return () => { - mockEcharts.finishedHandler = undefined - } - }, [onChartReady, onEvents]) + const MockReactEcharts = React.forwardRef( + ( + { + onChartReady, + onEvents, + }: { + onChartReady?: (instance: typeof mockEcharts.echartsInstance) => void + onEvents?: { finished?: (event?: unknown) => void } + }, + ref: React.ForwardedRef<{ getEchartsInstance: () => typeof mockEcharts.echartsInstance }>, + ) => { + React.useImperativeHandle(ref, () => ({ + getEchartsInstance: () => mockEcharts.echartsInstance, + })) + + React.useEffect(() => { + mockEcharts.finishedHandler = onEvents?.finished + onChartReady?.(mockEcharts.echartsInstance) + onEvents?.finished?.({}) + return () => { + mockEcharts.finishedHandler = undefined + } + }, [onChartReady, onEvents]) - return <div className="echarts-for-react" /> - }) + return <div className="echarts-for-react" /> + }, + ) return { __esModule: true, @@ -116,7 +119,9 @@ vi.mock('echarts-for-react', async () => { vi.mock('@/app/components/base/mermaid', () => ({ __esModule: true, - default: ({ PrimitiveCode }: { PrimitiveCode: string }) => <div data-testid="mock-mermaid">{PrimitiveCode}</div>, + default: ({ PrimitiveCode }: { PrimitiveCode: string }) => ( + <div data-testid="mock-mermaid">{PrimitiveCode}</div> + ), })) const findEchartsHost = async () => { @@ -174,15 +179,12 @@ describe('CodeBlock', () => { offsetHeightSpy = null const windowWithLegacyAudio = window as WindowWithLegacyAudio - if (originalAudioContext) - windowWithLegacyAudio.AudioContext = originalAudioContext - else - delete windowWithLegacyAudio.AudioContext + if (originalAudioContext) windowWithLegacyAudio.AudioContext = originalAudioContext + else delete windowWithLegacyAudio.AudioContext if (originalWebkitAudioContext) windowWithLegacyAudio.webkitAudioContext = originalWebkitAudioContext - else - delete windowWithLegacyAudio.webkitAudioContext + else delete windowWithLegacyAudio.webkitAudioContext delete windowWithLegacyAudio.abcjsAudioContext originalAudioContext = undefined @@ -192,7 +194,11 @@ describe('CodeBlock', () => { // Base rendering behaviors for inline and language labels. describe('Rendering', () => { it('should render inline code element when inline prop is true', () => { - const { container } = render(<CodeBlock inline className="language-javascript">const a=1;</CodeBlock>) + const { container } = render( + <CodeBlock inline className="language-javascript"> + const a=1; + </CodeBlock>, + ) const code = container.querySelector('code') expect(code).toBeTruthy() @@ -216,7 +222,9 @@ describe('CodeBlock', () => { expect(screen.getByText('JavaScript'))!.toBeInTheDocument() await waitFor(() => { - expect(document.querySelector('code.language-javascript')?.textContent).toContain('const x = 1;') + expect(document.querySelector('code.language-javascript')?.textContent).toContain( + 'const x = 1;', + ) }) }) @@ -264,7 +272,9 @@ describe('CodeBlock', () => { expect(screen.getByText('JavaScript'))!.toBeInTheDocument() await waitFor(() => { - expect(document.querySelector('code.language-javascript')?.textContent).toContain('const y = 2;') + expect(document.querySelector('code.language-javascript')?.textContent).toContain( + 'const y = 2;', + ) }) }) @@ -361,7 +371,9 @@ describe('CodeBlock', () => { it('should stop processing extra finished events when chart finished callback fires repeatedly', async () => { render(<CodeBlock className="language-echarts">{'{"series":[]}'}</CodeBlock>) const chart = await findEchartsInstance() - const chartWithTrigger = chart as unknown as { trigger?: (eventName: string, event?: unknown) => void } + const chartWithTrigger = chart as unknown as { + trigger?: (eventName: string, event?: unknown) => void + } act(() => { for (let i = 0; i < 8; i++) { @@ -371,7 +383,7 @@ describe('CodeBlock', () => { }) await act(async () => { - await new Promise(resolve => setTimeout(resolve, 500)) + await new Promise((resolve) => setTimeout(resolve, 500)) }) expect(await findEchartsHost())!.toBeInTheDocument() @@ -423,7 +435,9 @@ describe('CodeBlock', () => { }) it('should wire resize listener when echarts view re-enters with a ready chart instance', async () => { - const { rerender, unmount } = render(<CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>) + const { rerender, unmount } = render( + <CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>, + ) await findEchartsHost() rerender(<CodeBlock className="language-javascript">const x = 1;</CodeBlock>) @@ -445,7 +459,9 @@ describe('CodeBlock', () => { }) it('should cleanup echarts resize listener when no debounce timer is pending', async () => { - const { rerender, unmount } = render(<CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>) + const { rerender, unmount } = render( + <CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>, + ) await findEchartsHost() rerender(<CodeBlock className="language-javascript">const x = 1;</CodeBlock>) diff --git a/web/app/components/base/markdown-blocks/__tests__/form.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/form.spec.tsx index 3bc3fa774f7c5c..d631385e123980 100644 --- a/web/app/components/base/markdown-blocks/__tests__/form.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/form.spec.tsx @@ -39,9 +39,9 @@ vi.mock('@/app/components/base/chat/chat/context', () => ({ })) vi.mock('@/app/components/base/date-and-time-picker/utils/dayjs', async () => { - const actual = await vi.importActual<typeof import('@/app/components/base/date-and-time-picker/utils/dayjs')>( - '@/app/components/base/date-and-time-picker/utils/dayjs', - ) + const actual = await vi.importActual< + typeof import('@/app/components/base/date-and-time-picker/utils/dayjs') + >('@/app/components/base/date-and-time-picker/utils/dayjs') return { ...actual, formatDateForOutput: mockFormatDateForOutput, @@ -122,7 +122,12 @@ describe('MarkdownForm', () => { it('should submit updated text input and textarea values after user typing', async () => { const user = userEvent.setup() const node = createRootNode([ - createElementNode('input', { type: 'text', name: 'name', value: '', placeholder: 'Name input' }), + createElementNode('input', { + type: 'text', + name: 'name', + value: '', + placeholder: 'Name input', + }), createElementNode('textarea', { name: 'bio', value: '', placeholder: 'Bio input' }), createElementNode('button', {}, [createTextNode('Submit')]), ]) @@ -148,7 +153,12 @@ describe('MarkdownForm', () => { const node = createRootNode( [ createElementNode('input', { type: 'hidden', name: 'token', value: 'secret-token' }), - createElementNode('input', { type: 'select', name: 'color', value: 'red', dataOptions: ['red', 'blue'] }), + createElementNode('input', { + type: 'select', + name: 'color', + value: 'red', + dataOptions: ['red', 'blue'], + }), createElementNode('button', {}, [createTextNode('Send JSON')]), ], { dataFormat: 'json' }, @@ -189,9 +199,9 @@ describe('MarkdownForm', () => { const user = userEvent.setup() const node = createRootNode([ createElementNode('input', { - 'type': 'select', - 'name': 'city', - 'value': 'Paris', + type: 'select', + name: 'city', + value: 'Paris', 'data-options': '["Paris","Tokyo"]', }), createElementNode('button', {}, [createTextNode('Submit')]), @@ -207,12 +217,12 @@ describe('MarkdownForm', () => { }) it('should handle invalid data-options string without crashing', () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const node = createRootNode([ createElementNode('input', { - 'type': 'select', - 'name': 'city', - 'value': 'Paris', + type: 'select', + name: 'city', + value: 'Paris', 'data-options': 'not-json', }), createElementNode('button', {}, [createTextNode('Submit')]), @@ -223,8 +233,7 @@ describe('MarkdownForm', () => { expect(screen.getByRole('button', { name: 'Submit' }))!.toBeInTheDocument() expect(consoleErrorSpy).toHaveBeenCalled() - } - finally { + } finally { consoleErrorSpy.mockRestore() } }) @@ -260,8 +269,16 @@ describe('MarkdownForm', () => { const user = userEvent.setup() const node = createRootNode( [ - createElementNode('input', { type: 'date', name: 'startDate', value: dayjs('2026-01-10') }), - createElementNode('input', { type: 'datetime', name: 'runAt', value: dayjs('2026-01-10T08:30:00') }), + createElementNode('input', { + type: 'date', + name: 'startDate', + value: dayjs('2026-01-10'), + }), + createElementNode('input', { + type: 'datetime', + name: 'runAt', + value: dayjs('2026-01-10T08:30:00'), + }), createElementNode('button', {}, [createTextNode('Submit')]), ], { dataFormat: 'json' }, @@ -275,7 +292,9 @@ describe('MarkdownForm', () => { expect(mockFormatDateForOutput).toHaveBeenCalledTimes(2) expect(mockFormatDateForOutput).toHaveBeenNthCalledWith(1, expect.anything(), false) expect(mockFormatDateForOutput).toHaveBeenNthCalledWith(2, expect.anything(), true) - expect(mockOnSend).toHaveBeenCalledWith('{"startDate":"formatted-date","runAt":"formatted-datetime"}') + expect(mockOnSend).toHaveBeenCalledWith( + '{"startDate":"formatted-date","runAt":"formatted-datetime"}', + ) }) }) }) @@ -285,7 +304,12 @@ describe('MarkdownForm', () => { it('should toggle checkbox value and submit updated value', async () => { const user = userEvent.setup() const node = createRootNode([ - createElementNode('input', { type: 'checkbox', name: 'acceptTerms', value: false, dataTip: 'Accept terms' }), + createElementNode('input', { + type: 'checkbox', + name: 'acceptTerms', + value: false, + dataTip: 'Accept terms', + }), createElementNode('button', {}, [createTextNode('Submit')]), ]) @@ -334,8 +358,7 @@ describe('MarkdownForm', () => { const form = container.querySelector('form') expect(form).not.toBeNull() - if (!form) - throw new Error('Form element not found') + if (!form) throw new Error('Form element not found') fireEvent.submit(form) expect(parentOnSubmit).not.toHaveBeenCalled() @@ -379,7 +402,11 @@ describe('MarkdownForm', () => { const user = userEvent.setup() const node = createRootNode( [ - createElementNode('input', { type: 'date', name: 'startDate', value: dayjs('2026-01-10') }), + createElementNode('input', { + type: 'date', + name: 'startDate', + value: dayjs('2026-01-10'), + }), createElementNode('button', {}, [createTextNode('Submit')]), ], { dataFormat: 'json' }, @@ -409,19 +436,19 @@ describe('MarkdownForm', () => { render(<MarkdownForm node={node} />) // The real TimePicker renders a trigger with a readonly input showing the formatted time - const timeInput = screen.getByTestId('time-picker-trigger').querySelector('input[readonly]') as HTMLInputElement + const timeInput = screen + .getByTestId('time-picker-trigger') + .querySelector('input[readonly]') as HTMLInputElement expect(timeInput).not.toBeNull() expect(timeInput.value).toBe('09:00 AM') }) it('should update form value when time is picked via onChange', async () => { const user = userEvent.setup() - const node = createRootNode( - [ - createElementNode('input', { type: 'time', name: 'meetingTime', value: '' }), - createElementNode('button', {}, [createTextNode('Submit')]), - ], - ) + const node = createRootNode([ + createElementNode('input', { type: 'time', name: 'meetingTime', value: '' }), + createElementNode('button', {}, [createTextNode('Submit')]), + ]) render(<MarkdownForm node={node} />) @@ -538,7 +565,11 @@ describe('MarkdownForm', () => { const user = userEvent.setup() const node = createRootNode( [ - createElementNode('input', { type: 'hidden', name: '字段()!*&-', value: 'full-width' }), + createElementNode('input', { + type: 'hidden', + name: '字段()!*&-', + value: 'full-width', + }), createElementNode('input', { type: 'hidden', name: 'field()!*&-', value: 'half-width' }), createElementNode('button', {}, [createTextNode('Submit')]), ], @@ -550,7 +581,9 @@ describe('MarkdownForm', () => { await user.click(screen.getByRole('button', { name: 'Submit' })) await waitFor(() => { - expect(mockOnSend).toHaveBeenCalledWith('{"字段()!*&-":"full-width","field()!*&-":"half-width"}') + expect(mockOnSend).toHaveBeenCalledWith( + '{"字段()!*&-":"full-width","field()!*&-":"half-width"}', + ) }) }) @@ -569,11 +602,27 @@ describe('MarkdownForm', () => { createElementNode('label', { for: '时间' }, [createTextNode('Time:')]), createElementNode('input', { type: 'time', name: '时间', value: '09:00' }), createElementNode('label', { for: '日期时间' }, [createTextNode('Datetime:')]), - createElementNode('input', { type: 'datetime', name: '日期时间', value: dayjs('2026-01-10T08:30:00') }), + createElementNode('input', { + type: 'datetime', + name: '日期时间', + value: dayjs('2026-01-10T08:30:00'), + }), createElementNode('label', { for: 'café' }, [createTextNode('Select:')]), - createElementNode('input', { type: 'select', name: 'café', value: 'hello', dataOptions: ['hello', 'world'] }), - createElementNode('input', { type: 'checkbox', name: '同意条款', value: true, dataTip: 'By checking this means you agreed' }), - createElementNode('button', { dataSize: 'small', dataVariant: 'primary' }, [createTextNode('Login')]), + createElementNode('input', { + type: 'select', + name: 'café', + value: 'hello', + dataOptions: ['hello', 'world'], + }), + createElementNode('input', { + type: 'checkbox', + name: '同意条款', + value: true, + dataTip: 'By checking this means you agreed', + }), + createElementNode('button', { dataSize: 'small', dataVariant: 'primary' }, [ + createTextNode('Login'), + ]), ], { dataFormat: 'json' }, ) @@ -666,7 +715,9 @@ describe('MarkdownForm', () => { describe('Button variant and size', () => { it('should render button with valid variant and size', () => { const node = createRootNode([ - createElementNode('button', { dataVariant: 'primary', dataSize: 'large' }, [createTextNode('Go')]), + createElementNode('button', { dataVariant: 'primary', dataSize: 'large' }, [ + createTextNode('Go'), + ]), ]) render(<MarkdownForm node={node} />) @@ -677,7 +728,9 @@ describe('MarkdownForm', () => { it('should ignore invalid variant and size values', () => { const node = createRootNode([ - createElementNode('button', { dataVariant: 'danger', dataSize: 'xl' }, [createTextNode('Go')]), + createElementNode('button', { dataVariant: 'danger', dataSize: 'xl' }, [ + createTextNode('Go'), + ]), ]) render(<MarkdownForm node={node} />) @@ -743,9 +796,7 @@ describe('MarkdownForm', () => { // Inputs whose type is not in SUPPORTED_TYPES_SET should not render. describe('Unsupported input type', () => { it('should not render input with unsupported type like range', () => { - const node = createRootNode([ - createElementNode('input', { type: 'range', name: 'slider' }), - ]) + const node = createRootNode([createElementNode('input', { type: 'range', name: 'slider' })]) render(<MarkdownForm node={node} />) @@ -757,9 +808,7 @@ describe('MarkdownForm', () => { // Fallback branches for edge cases in tag rendering. describe('Fallback branches', () => { it('should render label with empty text when children array is empty', () => { - const node = createRootNode([ - createElementNode('label', { for: 'field' }, []), - ]) + const node = createRootNode([createElementNode('label', { for: 'field' }, [])]) render(<MarkdownForm node={node} />) @@ -791,9 +840,7 @@ describe('MarkdownForm', () => { }) it('should render button with empty text when children array is empty', () => { - const node = createRootNode([ - createElementNode('button', {}, []), - ]) + const node = createRootNode([createElementNode('button', {}, [])]) render(<MarkdownForm node={node} />) diff --git a/web/app/components/base/markdown-blocks/__tests__/img.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/img.spec.tsx index 0bd2d7e29dcb78..7b67c27692fb11 100644 --- a/web/app/components/base/markdown-blocks/__tests__/img.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/img.spec.tsx @@ -33,10 +33,16 @@ describe('Img', () => { it('should render with different src values', () => { const { rerender } = render(<Img src="https://example.com/first.png" />) - expect(screen.getByTestId('gallery-image')).toHaveAttribute('src', 'https://example.com/first.png') + expect(screen.getByTestId('gallery-image')).toHaveAttribute( + 'src', + 'https://example.com/first.png', + ) rerender(<Img src="https://example.com/second.jpg" />) - expect(screen.getByTestId('gallery-image')).toHaveAttribute('src', 'https://example.com/second.jpg') + expect(screen.getByTestId('gallery-image')).toHaveAttribute( + 'src', + 'https://example.com/second.jpg', + ) }) }) @@ -51,7 +57,9 @@ describe('Img', () => { expect(container2.querySelector('.markdown-img-wrapper')).toBeInTheDocument() // Test with data URL - const { container: container3 } = render(<Img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" />) + const { container: container3 } = render( + <Img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" />, + ) expect(container3.querySelector('.markdown-img-wrapper')).toBeInTheDocument() // Test with relative URL diff --git a/web/app/components/base/markdown-blocks/__tests__/music.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/music.spec.tsx index b22dcf1cf7fcbf..498f3511b951eb 100644 --- a/web/app/components/base/markdown-blocks/__tests__/music.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/music.spec.tsx @@ -41,7 +41,11 @@ describe('MarkdownMusic', () => { describe('Rendering', () => { it('should render wrapper and two internal container nodes', async () => { const MarkdownMusic = await loadMarkdownMusic() - const { container } = render(<MarkdownMusic><span>child</span></MarkdownMusic>) + const { container } = render( + <MarkdownMusic> + <span>child</span> + </MarkdownMusic>, + ) const topLevel = container.firstElementChild as HTMLElement | null expect(topLevel).toBeTruthy() @@ -66,7 +70,11 @@ describe('MarkdownMusic', () => { it('should not render fallback when children is not a string', async () => { const MarkdownMusic = await loadMarkdownMusic() - render(<MarkdownMusic><span>not a string</span></MarkdownMusic>) + render( + <MarkdownMusic> + <span>not a string</span> + </MarkdownMusic>, + ) expect(mockRenderAbc).not.toHaveBeenCalled() expect(mockLoad).not.toHaveBeenCalled() expect(mockInit).not.toHaveBeenCalled() diff --git a/web/app/components/base/markdown-blocks/__tests__/paragraph.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/paragraph.spec.tsx index a220b5acfa7d79..a6499dd1963bbf 100644 --- a/web/app/components/base/markdown-blocks/__tests__/paragraph.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/paragraph.spec.tsx @@ -98,10 +98,7 @@ describe('Paragraph', () => { it('should render div instead of p when image is not the first child', () => { renderParagraph({ node: { - children: [ - { tagName: 'span' }, - { tagName: 'img', properties: { src: 'test.png' } }, - ], + children: [{ tagName: 'span' }, { tagName: 'img', properties: { src: 'test.png' } }], }, children: [<span key="0">Text before</span>, <img key="1" src="test.png" alt="" />], }) @@ -121,7 +118,11 @@ describe('Paragraph', () => { }, ], }, - children: <a href="#"><img src="nested.png" alt="" /></a>, + children: ( + <a href="#"> + <img src="nested.png" alt="" /> + </a> + ), }) const wrapper = screen.getByRole('link').closest('.markdown-p') diff --git a/web/app/components/base/markdown-blocks/__tests__/plugin-img.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/plugin-img.spec.tsx index a16699ccf4bd14..0db40f4c8d851d 100644 --- a/web/app/components/base/markdown-blocks/__tests__/plugin-img.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/plugin-img.spec.tsx @@ -1,16 +1,13 @@ import { cleanup, render, screen } from '@testing-library/react' import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - import { PluginImg } from '../plugin-img' /* -------------------- Mocks -------------------- */ vi.mock('@/app/components/base/image-gallery', () => ({ __esModule: true, - default: ({ srcs }: { srcs: string[] }) => ( - <div data-testid="image-gallery">{srcs[0]}</div> - ), + default: ({ srcs }: { srcs: string[] }) => <div data-testid="image-gallery">{srcs[0]}</div>, })) const mockUsePluginReadmeAsset = vi.fn() @@ -20,8 +17,7 @@ vi.mock('@/service/use-plugins', () => ({ const mockGetMarkdownImageURL = vi.fn() vi.mock('../utils', () => ({ - getMarkdownImageURL: (src: string, pluginId?: string) => - mockGetMarkdownImageURL(src, pluginId), + getMarkdownImageURL: (src: string, pluginId?: string) => mockGetMarkdownImageURL(src, pluginId), })) /* -------------------- Tests -------------------- */ @@ -42,17 +38,12 @@ describe('PluginImg', () => { mockUsePluginReadmeAsset.mockReturnValue({ data: fakeBlob }) mockGetMarkdownImageURL.mockReturnValue('fallback-url') - const createSpy = vi - .spyOn(URL, 'createObjectURL') - .mockReturnValue(fakeObjectUrl) + const createSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue(fakeObjectUrl) const revokeSpy = vi.spyOn(URL, 'revokeObjectURL') const { unmount } = render( - <PluginImg - src="file.png" - pluginInfo={{ pluginUniqueIdentifier: 'abc', pluginId: '123' }} - />, + <PluginImg src="file.png" pluginInfo={{ pluginUniqueIdentifier: 'abc', pluginId: '123' }} />, ) const gallery = screen.getByTestId('image-gallery') @@ -70,10 +61,7 @@ describe('PluginImg', () => { mockGetMarkdownImageURL.mockReturnValue('computed-url') render( - <PluginImg - src="file.png" - pluginInfo={{ pluginUniqueIdentifier: 'abc', pluginId: '123' }} - />, + <PluginImg src="file.png" pluginInfo={{ pluginUniqueIdentifier: 'abc', pluginId: '123' }} />, ) const gallery = screen.getByTestId('image-gallery') diff --git a/web/app/components/base/markdown-blocks/__tests__/plugin-paragraph.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/plugin-paragraph.spec.tsx index 1d3a1c34f05896..c3dbffeee03014 100644 --- a/web/app/components/base/markdown-blocks/__tests__/plugin-paragraph.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/plugin-paragraph.spec.tsx @@ -17,10 +17,12 @@ vi.mock('../utils', () => ({ })) vi.mock('@/app/components/base/image-uploader/image-preview', () => ({ - default: ({ url, onCancel }: { url: string, onCancel: () => void }) => ( + default: ({ url, onCancel }: { url: string; onCancel: () => void }) => ( <div data-testid="image-preview-modal"> <span>{url}</span> - <button onClick={onCancel} type="button">Close</button> + <button onClick={onCancel} type="button"> + Close + </button> </div> ), })) @@ -30,10 +32,15 @@ vi.mock('@/app/components/base/image-uploader/image-preview', () => ({ * The runtime code only reads `node.children[*].tagName` and `.properties.src`, * so we keep the mock minimal and cast to satisfy the full hast Element type. */ -type MockChild = { tagName?: string, properties?: { src?: string } } +type MockChild = { tagName?: string; properties?: { src?: string } } function mockNode(children: MockChild[]): ExtraProps['node'] { - return { type: 'element', tagName: 'p', properties: {}, children } as unknown as ExtraProps['node'] + return { + type: 'element', + tagName: 'p', + properties: {}, + children, + } as unknown as ExtraProps['node'] } type HookReturn = { @@ -67,11 +74,7 @@ describe('PluginParagraph', () => { it('should render a standard paragraph when not an image', () => { const node = mockNode([{ tagName: 'span' }]) - render( - <PluginParagraph node={node}> - Hello World - </PluginParagraph>, - ) + render(<PluginParagraph node={node}>Hello World</PluginParagraph>) expect(screen.getByTestId('standard-paragraph')).toHaveTextContent('Hello World') }) @@ -159,8 +162,7 @@ describe('PluginParagraph', () => { ) const img = container.querySelector('img') - if (img) - await user.click(img) + if (img) await user.click(img) // ImageGallery is not mocked, so it should trigger the preview expect(screen.getByTestId('image-preview-modal')).toBeInTheDocument() diff --git a/web/app/components/base/markdown-blocks/__tests__/think-block.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/think-block.spec.tsx index 519d84478d04ad..df52b40f62be76 100644 --- a/web/app/components/base/markdown-blocks/__tests__/think-block.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/think-block.spec.tsx @@ -6,10 +6,7 @@ import ThinkBlock from '../think-block' // Mock react-i18next // Helper to wrap component with ChatContextProvider -const renderWithContext = ( - children: React.ReactNode, - isResponding: boolean = true, -) => { +const renderWithContext = (children: React.ReactNode, isResponding: boolean = true) => { return render( <ChatContextProvider config={undefined} @@ -194,9 +191,7 @@ describe('ThinkBlock', () => { it('should detect ENDTHINKFLAG in array children', () => { renderWithContext( - <ThinkBlock data-think={true}> - {['Part 1', 'Part 2[ENDTHINKFLAG]']} - </ThinkBlock>, + <ThinkBlock data-think={true}>{['Part 1', 'Part 2[ENDTHINKFLAG]']}</ThinkBlock>, true, ) @@ -206,21 +201,13 @@ describe('ThinkBlock', () => { describe('Edge cases', () => { it('should handle empty children', () => { - renderWithContext( - <ThinkBlock data-think={true}></ThinkBlock>, - true, - ) + renderWithContext(<ThinkBlock data-think={true}></ThinkBlock>, true) expect(screen.getByText(/chat\.thinking/)).toBeInTheDocument() }) it('should handle null children gracefully', () => { - renderWithContext( - <ThinkBlock data-think={true}> - {null} - </ThinkBlock>, - true, - ) + renderWithContext(<ThinkBlock data-think={true}>{null}</ThinkBlock>, true) expect(screen.getByText(/chat\.thinking/)).toBeInTheDocument() }) diff --git a/web/app/components/base/markdown-blocks/__tests__/utils.spec.ts b/web/app/components/base/markdown-blocks/__tests__/utils.spec.ts index 48cc0c2d080554..c19e70fa91e01c 100644 --- a/web/app/components/base/markdown-blocks/__tests__/utils.spec.ts +++ b/web/app/components/base/markdown-blocks/__tests__/utils.spec.ts @@ -69,7 +69,9 @@ describe('utils', () => { describe('getMarkdownImageURL', () => { it('should return the original URL when it does not match the asset regex', () => { - expect(getMarkdownImageURL('https://example.com/image.png')).toBe('https://example.com/image.png') + expect(getMarkdownImageURL('https://example.com/image.png')).toBe( + 'https://example.com/image.png', + ) }) it('should transform ./_assets URL without pathname', () => { @@ -93,8 +95,9 @@ describe('utils', () => { }) it('should not transform URLs that contain _assets in the middle', () => { - expect(getMarkdownImageURL('https://cdn.example.com/_assets/image.png')) - .toBe('https://cdn.example.com/_assets/image.png') + expect(getMarkdownImageURL('https://cdn.example.com/_assets/image.png')).toBe( + 'https://cdn.example.com/_assets/image.png', + ) }) it('should use empty string for pathname when undefined', () => { diff --git a/web/app/components/base/markdown-blocks/__tests__/video-block.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/video-block.spec.tsx index eabeb1fca15d3f..0e970103b471bf 100644 --- a/web/app/components/base/markdown-blocks/__tests__/video-block.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/video-block.spec.tsx @@ -1,7 +1,6 @@ import { render } from '@testing-library/react' import * as React from 'react' import { describe, expect, it } from 'vitest' - import VideoGallery from '../../video-gallery' import VideoBlock from '../video-block' @@ -21,10 +20,7 @@ type BlockNode = { describe('VideoBlock', () => { it('renders multiple video sources from node.children', () => { const node: BlockNode = { - children: [ - { properties: { src: 'a.mp4' } }, - { properties: { src: 'b.mp4' } }, - ], + children: [{ properties: { src: 'a.mp4' } }, { properties: { src: 'b.mp4' } }], } render(<VideoBlock node={node} />) diff --git a/web/app/components/base/markdown-blocks/audio-block.tsx b/web/app/components/base/markdown-blocks/audio-block.tsx index 8633c908d5729a..5745671a79064f 100644 --- a/web/app/components/base/markdown-blocks/audio-block.tsx +++ b/web/app/components/base/markdown-blocks/audio-block.tsx @@ -8,11 +8,12 @@ import { memo } from 'react' import AudioGallery from '@/app/components/base/audio-gallery' const AudioBlock: any = memo(({ node }: any) => { - const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src) + const srcs = node.children + .filter((child: any) => 'properties' in child) + .map((child: any) => (child as any).properties.src) if (srcs.length === 0) { const src = node.properties?.src - if (src) - return <AudioGallery key={src} srcs={[src]} /> + if (src) return <AudioGallery key={src} srcs={[src]} /> return null } return <AudioGallery key={srcs.join()} srcs={srcs} /> diff --git a/web/app/components/base/markdown-blocks/button.tsx b/web/app/components/base/markdown-blocks/button.tsx index 99398fd6de15ac..1eafd34c3d30b2 100644 --- a/web/app/components/base/markdown-blocks/button.tsx +++ b/web/app/components/base/markdown-blocks/button.tsx @@ -20,8 +20,7 @@ const MarkdownButton = ({ node }: any) => { window.open(link, '_blank') return } - if (!message) - return + if (!message) return onSend?.(message) }} > diff --git a/web/app/components/base/markdown-blocks/code-block.stories.tsx b/web/app/components/base/markdown-blocks/code-block.stories.tsx index 5e797dc34843e3..50d27bce65c9fa 100644 --- a/web/app/components/base/markdown-blocks/code-block.stories.tsx +++ b/web/app/components/base/markdown-blocks/code-block.stories.tsx @@ -7,19 +7,11 @@ const SAMPLE_CODE = `const greet = (name: string) => { console.log(greet('Dify'))` -const CodeBlockDemo = ({ - language = 'typescript', -}: { - language?: string -}) => { +const CodeBlockDemo = ({ language = 'typescript' }: { language?: string }) => { return ( <div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6"> <div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">Code block</div> - <CodeBlock - className={`language-${language}`} - > - {SAMPLE_CODE} - </CodeBlock> + <CodeBlock className={`language-${language}`}>{SAMPLE_CODE}</CodeBlock> </div> ) } diff --git a/web/app/components/base/markdown-blocks/code-block.tsx b/web/app/components/base/markdown-blocks/code-block.tsx index 67fa11da000f5f..0ec68ce96a0b88 100644 --- a/web/app/components/base/markdown-blocks/code-block.tsx +++ b/web/app/components/base/markdown-blocks/code-block.tsx @@ -39,11 +39,9 @@ const capitalizationLanguageNameMap: Record<string, string> = { abc: 'ABC', } const getCorrectCapitalizationLanguageName = (language: string) => { - if (!language) - return 'Plain' + if (!language) return 'Plain' - if (language in capitalizationLanguageNameMap) - return capitalizationLanguageNameMap[language] + if (language in capitalizationLanguageNameMap) return capitalizationLanguageNameMap[language] return language.charAt(0).toUpperCase() + language.substring(1) } @@ -61,59 +59,72 @@ const getCorrectCapitalizationLanguageName = (language: string) => { // visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message // or use the non-minified dev environment for full errors and additional helpful warnings. -const ShikiCodeBlock = memo(({ code, language, theme, initial }: { code: string, language: string, theme: BundledTheme, initial?: JSX.Element }) => { - const [nodes, setNodes] = useState(initial) - - useLayoutEffect(() => { - let cancelled = false - - void highlightCode({ - code, - language: language as BundledLanguage, - theme, - }).then((result) => { - if (!cancelled) - setNodes(result) - }).catch((error) => { - console.error('Shiki highlighting failed:', error) - if (!cancelled) - setNodes(undefined) - }) +const ShikiCodeBlock = memo( + ({ + code, + language, + theme, + initial, + }: { + code: string + language: string + theme: BundledTheme + initial?: JSX.Element + }) => { + const [nodes, setNodes] = useState(initial) + + useLayoutEffect(() => { + let cancelled = false + + void highlightCode({ + code, + language: language as BundledLanguage, + theme, + }) + .then((result) => { + if (!cancelled) setNodes(result) + }) + .catch((error) => { + console.error('Shiki highlighting failed:', error) + if (!cancelled) setNodes(undefined) + }) + + return () => { + cancelled = true + } + }, [code, language, theme]) - return () => { - cancelled = true + if (!nodes) { + return ( + <pre + style={{ + paddingLeft: 12, + borderBottomLeftRadius: '10px', + borderBottomRightRadius: '10px', + backgroundColor: 'var(--color-components-input-bg-normal)', + margin: 0, + overflow: 'auto', + }} + > + <code>{code}</code> + </pre> + ) } - }, [code, language, theme]) - if (!nodes) { return ( - <pre style={{ - paddingLeft: 12, - borderBottomLeftRadius: '10px', - borderBottomRightRadius: '10px', - backgroundColor: 'var(--color-components-input-bg-normal)', - margin: 0, - overflow: 'auto', - }} + <div + style={{ + borderBottomLeftRadius: '10px', + borderBottomRightRadius: '10px', + overflow: 'auto', + }} + className="shiki-line-numbers [&_pre]:m-0! [&_pre]:rounded-t-none! [&_pre]:rounded-b-[10px]! [&_pre]:bg-components-input-bg-normal! [&_pre]:py-2!" > - <code>{code}</code> - </pre> + {nodes} + </div> ) - } - - return ( - <div - style={{ - borderBottomLeftRadius: '10px', - borderBottomRightRadius: '10px', - overflow: 'auto', - }} - className="shiki-line-numbers [&_pre]:m-0! [&_pre]:rounded-t-none! [&_pre]:rounded-b-[10px]! [&_pre]:bg-components-input-bg-normal! [&_pre]:py-2!" - > - {nodes} - </div> - ) -}) + }, +) ShikiCodeBlock.displayName = 'ShikiCodeBlock' // Define ECharts event parameter types @@ -146,76 +157,84 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any const isDarkMode = theme === Theme.dark const clearResizeTimer = useCallback(() => { - if (!resizeTimerRef.current) - return + if (!resizeTimerRef.current) return clearTimeout(resizeTimerRef.current) resizeTimerRef.current = null }, []) const clearChartReadyTimer = useCallback(() => { - if (!chartReadyTimerRef.current) - return + if (!chartReadyTimerRef.current) return clearTimeout(chartReadyTimerRef.current) chartReadyTimerRef.current = null }, []) - const echartsStyle = useMemo(() => ({ - height: '350px', - width: '100%', - }), []) + const echartsStyle = useMemo( + () => ({ + height: '350px', + width: '100%', + }), + [], + ) - const echartsOpts = useMemo(() => ({ - renderer: 'canvas', - width: 'auto', - }) as any, []) + const echartsOpts = useMemo( + () => + ({ + renderer: 'canvas', + width: 'auto', + }) as any, + [], + ) // Debounce resize operations const debouncedResize = useCallback(() => { clearResizeTimer() resizeTimerRef.current = setTimeout(() => { - if (chartInstanceRef.current) - chartInstanceRef.current.resize() + if (chartInstanceRef.current) chartInstanceRef.current.resize() resizeTimerRef.current = null }, 200) }, [clearResizeTimer]) // Handle ECharts instance initialization - const handleChartReady = useCallback((instance: any) => { - chartInstanceRef.current = instance + const handleChartReady = useCallback( + (instance: any) => { + chartInstanceRef.current = instance - // Force resize to ensure timeline displays correctly - clearChartReadyTimer() - chartReadyTimerRef.current = setTimeout(() => { - if (chartInstanceRef.current) - chartInstanceRef.current.resize() - chartReadyTimerRef.current = null - }, 200) - }, [clearChartReadyTimer]) + // Force resize to ensure timeline displays correctly + clearChartReadyTimer() + chartReadyTimerRef.current = setTimeout(() => { + if (chartInstanceRef.current) chartInstanceRef.current.resize() + chartReadyTimerRef.current = null + }, 200) + }, + [clearChartReadyTimer], + ) // Store event handlers in useMemo to avoid recreating them - const echartsEvents = useMemo(() => ({ - finished: (_params: EChartsEventParams) => { - // Limit finished event frequency to avoid infinite loops - finishedEventCountRef.current++ - if (finishedEventCountRef.current > 3) { - // Stop processing after 3 times to avoid infinite loops - return - } + const echartsEvents = useMemo( + () => ({ + finished: (_params: EChartsEventParams) => { + // Limit finished event frequency to avoid infinite loops + finishedEventCountRef.current++ + if (finishedEventCountRef.current > 3) { + // Stop processing after 3 times to avoid infinite loops + return + } - if (chartInstanceRef.current) { - // Use debounced resize - debouncedResize() - } - }, - }), [debouncedResize]) + if (chartInstanceRef.current) { + // Use debounced resize + debouncedResize() + } + }, + }), + [debouncedResize], + ) // Handle container resize for echarts useEffect(() => { - if (language !== 'echarts' || !chartInstanceRef.current) - return + if (language !== 'echarts' || !chartInstanceRef.current) return const handleResize = () => { if (chartInstanceRef.current) @@ -244,8 +263,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any // Process chart data when content changes useEffect(() => { // Only process echarts content - if (language !== 'echarts') - return + if (language !== 'echarts') return // Reset state when new content is detected if (!contentRef.current) { @@ -256,21 +274,21 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any const newContent = String(children).replace(/\n$/, '') // Skip if content hasn't changed - if (contentRef.current === newContent) - return + if (contentRef.current === newContent) return contentRef.current = newContent const trimmedContent = newContent.trim() - if (!trimmedContent) - return + if (!trimmedContent) return // Detect if this is historical data (already complete) // Historical data typically comes as a complete code block with complete JSON - const isCompleteJson - = (trimmedContent.startsWith('{') && trimmedContent.endsWith('}') - && trimmedContent.split('{').length === trimmedContent.split('}').length) - || (trimmedContent.startsWith('[') && trimmedContent.endsWith(']') - && trimmedContent.split('[').length === trimmedContent.split(']').length) + const isCompleteJson = + (trimmedContent.startsWith('{') && + trimmedContent.endsWith('}') && + trimmedContent.split('{').length === trimmedContent.split('}').length) || + (trimmedContent.startsWith('[') && + trimmedContent.endsWith(']') && + trimmedContent.split('[').length === trimmedContent.split(']').length) // If the JSON structure looks complete, try to parse it right away if (isCompleteJson && !processedRef.current) { @@ -282,8 +300,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any processedRef.current = true return } - } - catch { + } catch { // Avoid executing arbitrary code; require valid JSON for chart options. setChartState('error') processedRef.current = true @@ -293,16 +310,16 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any // If we get here, either the JSON isn't complete yet, or we failed to parse it // Check more conditions for streaming data - const isIncomplete - = trimmedContent.length < 5 - || (trimmedContent.startsWith('{') - && (!trimmedContent.endsWith('}') - || trimmedContent.split('{').length !== trimmedContent.split('}').length)) - || (trimmedContent.startsWith('[') - && (!trimmedContent.endsWith(']') - || trimmedContent.split('[').length !== trimmedContent.split('}').length)) - || (trimmedContent.split('"').length % 2 !== 1) - || (trimmedContent.includes('{"') && !trimmedContent.includes('"}')) + const isIncomplete = + trimmedContent.length < 5 || + (trimmedContent.startsWith('{') && + (!trimmedContent.endsWith('}') || + trimmedContent.split('{').length !== trimmedContent.split('}').length)) || + (trimmedContent.startsWith('[') && + (!trimmedContent.endsWith(']') || + trimmedContent.split('[').length !== trimmedContent.split('}').length)) || + trimmedContent.split('"').length % 2 !== 1 || + (trimmedContent.includes('{"') && !trimmedContent.includes('"}')) // Only try to parse streaming data if it looks complete and hasn't been processed if (!isIncomplete && !processedRef.current) { @@ -314,8 +331,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any setFinalChartOption(parsed) isValidOption = true } - } - catch { + } catch { // Only accept JSON to avoid executing arbitrary code from the message. setChartState('error') processedRef.current = true @@ -338,27 +354,38 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any // Loading state: show loading indicator if (chartState === 'loading') { return ( - <div style={{ - minHeight: '350px', - width: '100%', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - borderBottomLeftRadius: '10px', - borderBottomRightRadius: '10px', - backgroundColor: isDarkMode ? 'var(--color-components-input-bg-normal)' : 'transparent', - color: 'var(--color-text-secondary)', - }} - > - <div style={{ - marginBottom: '12px', - width: '24px', - height: '24px', + <div + style={{ + minHeight: '350px', + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + borderBottomLeftRadius: '10px', + borderBottomRightRadius: '10px', + backgroundColor: isDarkMode + ? 'var(--color-components-input-bg-normal)' + : 'transparent', + color: 'var(--color-text-secondary)', }} + > + <div + style={{ + marginBottom: '12px', + width: '24px', + height: '24px', + }} > {/* Rotating spinner that works in both light and dark modes */} - <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ animation: 'spin 1.5s linear infinite' }}> + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + style={{ animation: 'spin 1.5s linear infinite' }} + > <style> {` @keyframes spin { @@ -367,14 +394,27 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any } `} </style> - <circle opacity="0.2" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" /> - <path d="M12 2C6.47715 2 2 6.47715 2 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> + <circle + opacity="0.2" + cx="12" + cy="12" + r="10" + stroke="currentColor" + strokeWidth="2" + /> + <path + d="M12 2C6.47715 2 2 6.47715 2 12" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + /> </svg> </div> - <div style={{ - fontFamily: 'var(--font-family)', - fontSize: '14px', - }} + <div + style={{ + fontFamily: 'var(--font-family)', + fontSize: '14px', + }} > Chart loading... </div> @@ -388,15 +428,16 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any finishedEventCountRef.current = 0 return ( - <div style={{ - minWidth: '300px', - minHeight: '350px', - width: '100%', - overflowX: 'auto', - borderBottomLeftRadius: '10px', - borderBottomRightRadius: '10px', - transition: 'background-color 0.3s ease', - }} + <div + style={{ + minWidth: '300px', + minHeight: '350px', + width: '100%', + overflowX: 'auto', + borderBottomLeftRadius: '10px', + borderBottomRightRadius: '10px', + transition: 'background-color 0.3s ease', + }} > <ErrorBoundary> <ReactEcharts @@ -428,15 +469,16 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any } return ( - <div style={{ - minWidth: '300px', - minHeight: '350px', - width: '100%', - overflowX: 'auto', - borderBottomLeftRadius: '10px', - borderBottomRightRadius: '10px', - transition: 'background-color 0.3s ease', - }} + <div + style={{ + minWidth: '300px', + minHeight: '350px', + width: '100%', + overflowX: 'auto', + borderBottomLeftRadius: '10px', + borderBottomRightRadius: '10px', + transition: 'background-color 0.3s ease', + }} > <ErrorBoundary> <ReactEcharts @@ -475,10 +517,28 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any /> ) } - }, [children, language, isSVG, finalChartOption, props, theme, match, chartState, isDarkMode, echartsStyle, echartsOpts, handleChartReady, echartsEvents]) + }, [ + children, + language, + isSVG, + finalChartOption, + props, + theme, + match, + chartState, + isDarkMode, + echartsStyle, + echartsOpts, + handleChartReady, + echartsEvents, + ]) if (inline || !match) - return <code {...props} className={className}>{children}</code> + return ( + <code {...props} className={className}> + {children} + </code> + ) return ( <div className="relative"> diff --git a/web/app/components/base/markdown-blocks/form.tsx b/web/app/components/base/markdown-blocks/form.tsx index 1d1825dc7effe1..3e652e1e24647d 100644 --- a/web/app/components/base/markdown-blocks/form.tsx +++ b/web/app/components/base/markdown-blocks/form.tsx @@ -2,14 +2,25 @@ import type { ButtonProps } from '@langgenius/dify-ui/button' import type { Dayjs } from 'dayjs' import { Button } from '@langgenius/dify-ui/button' import { Checkbox } from '@langgenius/dify-ui/checkbox' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, + SelectValue, +} from '@langgenius/dify-ui/select' import { Textarea } from '@langgenius/dify-ui/textarea' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useChatContext } from '@/app/components/base/chat/chat/context' import DatePicker from '@/app/components/base/date-and-time-picker/date-picker' import TimePicker from '@/app/components/base/date-and-time-picker/time-picker' -import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-time-picker/utils/dayjs' +import { + formatDateForOutput, + toDayjs, +} from '@/app/components/base/date-and-time-picker/utils/dayjs' import Input from '@/app/components/base/input' const DATA_FORMAT = { @@ -37,15 +48,14 @@ const SUPPORTED_TYPES = { HIDDEN: 'hidden', } as const -type SupportedType = typeof SUPPORTED_TYPES[keyof typeof SUPPORTED_TYPES] +type SupportedType = (typeof SUPPORTED_TYPES)[keyof typeof SUPPORTED_TYPES] const SUPPORTED_TYPES_SET = new Set<string>(Object.values(SUPPORTED_TYPES)) const SAFE_NAME_RE = (() => { try { return new RegExp('^\\p{L}[\\p{L}\\p{M}\\p{N}_()!*&()!*&--]*$', 'u') - } - catch { + } catch { // Fallback for browsers without Unicode property escape support. return /^[a-z][\w-]*$/i } @@ -53,11 +63,13 @@ const SAFE_NAME_RE = (() => { const PROTOTYPE_POISON_KEYS = new Set(['__proto__', 'constructor', 'prototype']) function isSafeName(name: unknown): name is string { - return typeof name === 'string' - && name.length > 0 - && name.length <= 128 - && SAFE_NAME_RE.test(name) - && !PROTOTYPE_POISON_KEYS.has(name) + return ( + typeof name === 'string' && + name.length > 0 && + name.length <= 128 && + SAFE_NAME_RE.test(name) && + !PROTOTYPE_POISON_KEYS.has(name) + ) } const VALID_BUTTON_VARIANTS = new Set<string>([ @@ -100,8 +112,7 @@ function getLabelTarget(node: HastElement): string { } function str(val: unknown): string { - if (val == null) - return '' + if (val == null) return '' return String(val) } @@ -111,23 +122,23 @@ function computeInitialFormValues(children: HastElement[]): FormValues { if (child.tagName !== SUPPORTED_TAGS.INPUT && child.tagName !== SUPPORTED_TAGS.TEXTAREA) continue const name = child.properties.name - if (!isSafeName(name)) - continue + if (!isSafeName(name)) continue const type = child.tagName === SUPPORTED_TAGS.INPUT ? str(child.properties.type) : '' if (type === SUPPORTED_TYPES.HIDDEN) { init[name] = str(child.properties.value) - } - else if (type === SUPPORTED_TYPES.DATE || type === SUPPORTED_TYPES.DATETIME || type === SUPPORTED_TYPES.TIME) { + } else if ( + type === SUPPORTED_TYPES.DATE || + type === SUPPORTED_TYPES.DATETIME || + type === SUPPORTED_TYPES.TIME + ) { const raw = child.properties.value init[name] = raw != null ? toDayjs(String(raw)) : undefined - } - else if (type === SUPPORTED_TYPES.CHECKBOX) { + } else if (type === SUPPORTED_TYPES.CHECKBOX) { const { checked, value } = child.properties init[name] = !!checked || value === true || value === 'true' - } - else { + } else { init[name] = child.properties.value != null ? str(child.properties.value) : undefined } } @@ -140,14 +151,10 @@ function getElementKey(child: HastElement, index: number): string { const htmlFor = str(child.properties.htmlFor) const type = str(child.properties.type) - if (tag === SUPPORTED_TAGS.LABEL) - return `label-${index}-${htmlFor || name}` - if (tag === SUPPORTED_TAGS.INPUT) - return `input-${index}-${type}-${name}` - if (tag === SUPPORTED_TAGS.TEXTAREA) - return `textarea-${index}-${name}` - if (tag === SUPPORTED_TAGS.BUTTON) - return `button-${index}-${getTextContent(child)}` + if (tag === SUPPORTED_TAGS.LABEL) return `label-${index}-${htmlFor || name}` + if (tag === SUPPORTED_TAGS.INPUT) return `input-${index}-${type}-${name}` + if (tag === SUPPORTED_TAGS.TEXTAREA) return `textarea-${index}-${name}` + if (tag === SUPPORTED_TAGS.BUTTON) return `button-${index}-${getTextContent(child)}` return `${tag}-${index}` } @@ -161,10 +168,7 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { [typedNode.children], ) - const baseFormValues = useMemo( - () => computeInitialFormValues(elementChildren), - [elementChildren], - ) + const baseFormValues = useMemo(() => computeInitialFormValues(elementChildren), [elementChildren]) const [editState, setEditState] = useState<EditState>(() => ({ source: elementChildren, @@ -172,22 +176,23 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { })) const formValues = useMemo<FormValues>(() => { - if (editState.source === elementChildren) - return { ...baseFormValues, ...editState.edits } + if (editState.source === elementChildren) return { ...baseFormValues, ...editState.edits } return baseFormValues }, [editState, baseFormValues, elementChildren]) - const updateValue = useCallback((name: string, value: FormValue) => { - if (!isSafeName(name)) - return - setEditState(prev => ({ - source: elementChildren, - edits: { - ...(prev.source === elementChildren ? prev.edits : {}), - [name]: value, - }, - })) - }, [elementChildren]) + const updateValue = useCallback( + (name: string, value: FormValue) => { + if (!isSafeName(name)) return + setEditState((prev) => ({ + source: elementChildren, + edits: { + ...(prev.source === elementChildren ? prev.edits : {}), + [name]: value, + }, + })) + }, + [elementChildren], + ) const getFormOutput = useCallback((): Record<string, string | boolean | undefined> => { const out = Object.create(null) as Record<string, string | boolean | undefined> @@ -195,49 +200,47 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { if (child.tagName !== SUPPORTED_TAGS.INPUT && child.tagName !== SUPPORTED_TAGS.TEXTAREA) continue const name = child.properties.name - if (!isSafeName(name)) - continue + if (!isSafeName(name)) continue let value: FormValue = formValues[name] if ( - child.tagName === SUPPORTED_TAGS.INPUT - && (child.properties.type === SUPPORTED_TYPES.DATE || child.properties.type === SUPPORTED_TYPES.DATETIME) - && value != null - && typeof value === 'object' - && 'format' in value + child.tagName === SUPPORTED_TAGS.INPUT && + (child.properties.type === SUPPORTED_TYPES.DATE || + child.properties.type === SUPPORTED_TYPES.DATETIME) && + value != null && + typeof value === 'object' && + 'format' in value ) { const includeTime = child.properties.type === SUPPORTED_TYPES.DATETIME value = formatDateForOutput(value as Dayjs, includeTime) } - if (typeof value === 'boolean') - out[name] = value - else - out[name] = value != null ? String(value) : undefined + if (typeof value === 'boolean') out[name] = value + else out[name] = value != null ? String(value) : undefined } return out }, [elementChildren, formValues]) - const onSubmit = useCallback((e: React.MouseEvent) => { - e.preventDefault() - if (isSubmitting) - return - setIsSubmitting(true) - try { - const format = str(typedNode.properties.dataFormat) || DATA_FORMAT.TEXT - const result = getFormOutput() - if (format === DATA_FORMAT.JSON) { - onSend?.(JSON.stringify(result)) - } - else { - const textResult = Object.entries(result) - .map(([key, value]) => `${key}: ${value}`) - .join('\n') - onSend?.(textResult) + const onSubmit = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + if (isSubmitting) return + setIsSubmitting(true) + try { + const format = str(typedNode.properties.dataFormat) || DATA_FORMAT.TEXT + const result = getFormOutput() + if (format === DATA_FORMAT.JSON) { + onSend?.(JSON.stringify(result)) + } else { + const textResult = Object.entries(result) + .map(([key, value]) => `${key}: ${value}`) + .join('\n') + onSend?.(textResult) + } + } catch { + setIsSubmitting(false) } - } - catch { - setIsSubmitting(false) - } - }, [isSubmitting, typedNode.properties.dataFormat, getFormOutput, onSend]) + }, + [isSubmitting, typedNode.properties.dataFormat, getFormOutput, onSend], + ) return ( <form @@ -264,10 +267,12 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { ) } - if (child.tagName === SUPPORTED_TAGS.INPUT && SUPPORTED_TYPES_SET.has(str(child.properties.type))) { + if ( + child.tagName === SUPPORTED_TAGS.INPUT && + SUPPORTED_TYPES_SET.has(str(child.properties.type)) + ) { const name = str(child.properties.name) - if (!isSafeName(name)) - return null + if (!isSafeName(name)) return null const type = str(child.properties.type) as SupportedType @@ -277,7 +282,7 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { key={key} value={formValues[name] as Dayjs | undefined} needTimePicker={type === SUPPORTED_TYPES.DATETIME} - onChange={date => updateValue(name, date)} + onChange={(date) => updateValue(name, date)} onClear={() => updateValue(name, undefined)} /> ) @@ -287,15 +292,15 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { <TimePicker key={key} value={formValues[name] as Dayjs | string | undefined} - onChange={time => updateValue(name, time)} + onChange={(time) => updateValue(name, time)} onClear={() => updateValue(name, undefined)} /> ) } if (type === SUPPORTED_TYPES.CHECKBOX) { const label = str(child.properties.dataTip || child.properties['data-tip']) - const hasExternalLabel = elementChildren.some(node => - node.tagName === SUPPORTED_TAGS.LABEL && getLabelTarget(node) === name, + const hasExternalLabel = elementChildren.some( + (node) => node.tagName === SUPPORTED_TAGS.LABEL && getLabelTarget(node) === name, ) const checkboxAriaLabel = label || (hasExternalLabel ? undefined : name) return ( @@ -304,27 +309,26 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { id={name} checked={!!formValues[name]} aria-label={checkboxAriaLabel} - onCheckedChange={checked => updateValue(name, checked)} + onCheckedChange={(checked) => updateValue(name, checked)} /> {label && <span>{label}</span>} </div> ) } if (type === SUPPORTED_TYPES.SELECT) { - const rawOptions = child.properties.dataOptions || child.properties['data-options'] || [] + const rawOptions = + child.properties.dataOptions || child.properties['data-options'] || [] let options: string[] = [] if (typeof rawOptions === 'string') { try { const parsed: unknown = JSON.parse(rawOptions) if (Array.isArray(parsed)) options = parsed.filter((o): o is string => typeof o === 'string') - } - catch (error) { + } catch (error) { console.error('Failed to parse data-options JSON:', rawOptions, error) options = [] } - } - else if (Array.isArray(rawOptions)) { + } else if (Array.isArray(rawOptions)) { options = rawOptions.filter((o): o is string => typeof o === 'string') } return ( @@ -332,15 +336,14 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { key={key} defaultValue={formValues[name] as string | undefined} onValueChange={(val) => { - if (val != null) - updateValue(name, val) + if (val != null) updateValue(name, val) }} > <SelectTrigger className="w-full"> <SelectValue /> </SelectTrigger> <SelectContent> - {options.map(option => ( + {options.map((option) => ( <SelectItem key={option} value={option}> <SelectItemText>{option}</SelectItemText> <SelectItemIndicator /> @@ -369,15 +372,14 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { name={name} placeholder={str(child.properties.placeholder)} value={str(formValues[name])} - onChange={e => updateValue(name, e.target.value)} + onChange={(e) => updateValue(name, e.target.value)} /> ) } if (child.tagName === SUPPORTED_TAGS.TEXTAREA) { const name = str(child.properties.name) - if (!isSafeName(name)) - return null + if (!isSafeName(name)) return null return ( <Textarea aria-label={name} @@ -385,7 +387,7 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { name={name} placeholder={str(child.properties.placeholder)} value={str(formValues[name])} - onValueChange={value => updateValue(name, value)} + onValueChange={(value) => updateValue(name, value)} /> ) } @@ -394,10 +396,10 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { const rawVariant = str(child.properties.dataVariant) const rawSize = str(child.properties.dataSize) const variant = VALID_BUTTON_VARIANTS.has(rawVariant) - ? rawVariant as ButtonProps['variant'] + ? (rawVariant as ButtonProps['variant']) : undefined const size = VALID_BUTTON_SIZES.has(rawSize) - ? rawSize as ButtonProps['size'] + ? (rawSize as ButtonProps['size']) : undefined return ( diff --git a/web/app/components/base/markdown-blocks/img.tsx b/web/app/components/base/markdown-blocks/img.tsx index 4be31d34297a6b..db63e0da725799 100644 --- a/web/app/components/base/markdown-blocks/img.tsx +++ b/web/app/components/base/markdown-blocks/img.tsx @@ -8,7 +8,11 @@ import ImageGallery from '@/app/components/base/image-gallery' const Img = memo(({ src }: { src: string }) => { const srcs = useMemo(() => [src], [src]) - return <div className="markdown-img-wrapper"><ImageGallery srcs={srcs} /></div> + return ( + <div className="markdown-img-wrapper"> + <ImageGallery srcs={srcs} /> + </div> + ) }) export default Img diff --git a/web/app/components/base/markdown-blocks/link.tsx b/web/app/components/base/markdown-blocks/link.tsx index cf7bdf6c08c69f..ce8958f1c1f546 100644 --- a/web/app/components/base/markdown-blocks/link.tsx +++ b/web/app/components/base/markdown-blocks/link.tsx @@ -11,8 +11,7 @@ const filePreviewPathPattern = /\/files\/[^/?#]+\/file-preview(?:[?#]|$)/ const asAttachmentParamPattern = /[?&]as_attachment=/ const withFilePreviewAttachment = (href: string) => { - if (!filePreviewPathPattern.test(href) || asAttachmentParamPattern.test(href)) - return href + if (!filePreviewPathPattern.test(href) || asAttachmentParamPattern.test(href)) return href const url = new URL(href, 'http://dify.local') url.searchParams.set('as_attachment', 'true') @@ -26,9 +25,16 @@ const Link = ({ node, children, ...props }: any) => { if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) { const hidden_text = decodeURIComponent(node.properties.href.toString().split('abbr:')[1]) - return <abbr className={commonClassName} onClick={() => onSend?.(hidden_text)} title={node.children[0]?.value || ''}>{node.children[0]?.value || ''}</abbr> - } - else { + return ( + <abbr + className={commonClassName} + onClick={() => onSend?.(hidden_text)} + title={node.children[0]?.value || ''} + > + {node.children[0]?.value || ''} + </abbr> + ) + } else { const rawHref = props.href || node.properties?.href const href = rawHref?.toString() if (href && /^#[\w-]+$/.test(href)) { @@ -40,17 +46,28 @@ const Link = ({ node, children, ...props }: any) => { if (answerContainer) { const targetId = CSS.escape(href.toString().substring(1)) const targetElement = answerContainer.querySelector(`[id="${targetId}"]`) - if (targetElement) - targetElement.scrollIntoView({ behavior: 'smooth' }) + if (targetElement) targetElement.scrollIntoView({ behavior: 'smooth' }) } } - return <a href={href} onClick={handleClick} className={commonClassName}>{children || 'ScrollView'}</a> + return ( + <a href={href} onClick={handleClick} className={commonClassName}> + {children || 'ScrollView'} + </a> + ) } - if (!href || !isValidUrl(href)) - return <span>{children}</span> + if (!href || !isValidUrl(href)) return <span>{children}</span> - return <a href={withFilePreviewAttachment(href)} target="_blank" rel="noopener noreferrer" className={commonClassName}>{children || 'Download'}</a> + return ( + <a + href={withFilePreviewAttachment(href)} + target="_blank" + rel="noopener noreferrer" + className={commonClassName} + > + {children || 'Download'} + </a> + ) } } diff --git a/web/app/components/base/markdown-blocks/paragraph.tsx b/web/app/components/base/markdown-blocks/paragraph.tsx index af51d4ad0fc4fc..5a1a30a46f27fe 100644 --- a/web/app/components/base/markdown-blocks/paragraph.tsx +++ b/web/app/components/base/markdown-blocks/paragraph.tsx @@ -11,9 +11,9 @@ const Paragraph = (paragraph: any) => { return ( <div className="markdown-img-wrapper"> <ImageGallery srcs={[children_node[0].properties.src]} /> - {Array.isArray(paragraph.children) && paragraph.children.length > 1 - ? <div className="mt-2">{paragraph.children.slice(1)}</div> - : null} + {Array.isArray(paragraph.children) && paragraph.children.length > 1 ? ( + <div className="mt-2">{paragraph.children.slice(1)}</div> + ) : null} </div> ) } diff --git a/web/app/components/base/markdown-blocks/plugin-img.tsx b/web/app/components/base/markdown-blocks/plugin-img.tsx index 288aec7ea18032..f8cf3b81a91672 100644 --- a/web/app/components/base/markdown-blocks/plugin-img.tsx +++ b/web/app/components/base/markdown-blocks/plugin-img.tsx @@ -16,7 +16,10 @@ type ImgProps = { export const PluginImg = memo<ImgProps>(({ src, pluginInfo }) => { const { pluginUniqueIdentifier, pluginId } = pluginInfo || {} - const { data: assetData } = usePluginReadmeAsset({ plugin_unique_identifier: pluginUniqueIdentifier, file_name: src }) + const { data: assetData } = usePluginReadmeAsset({ + plugin_unique_identifier: pluginUniqueIdentifier, + file_name: src, + }) const [blobUrl, setBlobUrl] = useState<string>() useEffect(() => { @@ -34,8 +37,7 @@ export const PluginImg = memo<ImgProps>(({ src, pluginInfo }) => { }, [assetData]) const imageUrl = useMemo(() => { - if (blobUrl) - return blobUrl + if (blobUrl) return blobUrl return getMarkdownImageURL(src, pluginId) }, [blobUrl, pluginId, src]) diff --git a/web/app/components/base/markdown-blocks/plugin-paragraph.tsx b/web/app/components/base/markdown-blocks/plugin-paragraph.tsx index 4ecf5bb4bc7a57..e4de9e633d63a7 100644 --- a/web/app/components/base/markdown-blocks/plugin-paragraph.tsx +++ b/web/app/components/base/markdown-blocks/plugin-paragraph.tsx @@ -8,7 +8,7 @@ import { getMarkdownImageURL, hasImageChild } from './utils' type HastChildNode = { tagName?: string - properties?: { src?: string, [key: string]: unknown } + properties?: { src?: string; [key: string]: unknown } } type PluginParagraphProps = { @@ -46,29 +46,34 @@ export const PluginParagraph: React.FC<PluginParagraphProps> = ({ pluginInfo, no }, [assetData]) const imageUrl = useMemo(() => { - if (blobUrl) - return blobUrl + if (blobUrl) return blobUrl - if (isImageParagraph && imageSrc) - return getMarkdownImageURL(imageSrc, pluginId) + if (isImageParagraph && imageSrc) return getMarkdownImageURL(imageSrc, pluginId) return '' }, [blobUrl, imageSrc, isImageParagraph, pluginId]) if (isImageParagraph) { - const remainingChildren = Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined + const remainingChildren = + Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined return ( <div className="markdown-img-wrapper" data-testid="image-paragraph-wrapper"> <ImageGallery srcs={[imageUrl]} /> {remainingChildren && ( - <div className="mt-2" data-testid="remaining-children">{remainingChildren}</div> + <div className="mt-2" data-testid="remaining-children"> + {remainingChildren} + </div> )} </div> ) } if (hasImageChild(childrenNode)) - return <div className="markdown-p" data-testid="image-fallback-paragraph">{children}</div> + return ( + <div className="markdown-p" data-testid="image-fallback-paragraph"> + {children} + </div> + ) return <p data-testid="standard-paragraph">{children}</p> } diff --git a/web/app/components/base/markdown-blocks/think-block.stories.tsx b/web/app/components/base/markdown-blocks/think-block.stories.tsx index 85b8ec8a5d71a1..b8572de4dc77d5 100644 --- a/web/app/components/base/markdown-blocks/think-block.stories.tsx +++ b/web/app/components/base/markdown-blocks/think-block.stories.tsx @@ -9,11 +9,7 @@ Score snippets against query. [ENDTHINKFLAG] ` -const ThinkBlockDemo = ({ - responding = false, -}: { - responding?: boolean -}) => { +const ThinkBlockDemo = ({ responding = false }: { responding?: boolean }) => { const [isResponding, setIsResponding] = useState(responding) return ( @@ -37,15 +33,13 @@ const ThinkBlockDemo = ({ <button type="button" className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover" - onClick={() => setIsResponding(prev => !prev)} + onClick={() => setIsResponding((prev) => !prev)} > {isResponding ? 'Mark complete' : 'Simulate thinking'} </button> </div> <ThinkBlock data-think> - <pre className="text-sm whitespace-pre-wrap text-text-secondary"> - {THOUGHT_TEXT} - </pre> + <pre className="text-sm whitespace-pre-wrap text-text-secondary">{THOUGHT_TEXT}</pre> </ThinkBlock> </div> </ChatContextProvider> @@ -59,7 +53,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Expandable chain-of-thought block used in chat responses. Toggles between “thinking” and completed states.', + component: + 'Expandable chain-of-thought block used in chat responses. Toggles between “thinking” and completed states.', }, }, }, diff --git a/web/app/components/base/markdown-blocks/think-block.tsx b/web/app/components/base/markdown-blocks/think-block.tsx index 2a72e4a38f72ca..509d67b9f71be9 100644 --- a/web/app/components/base/markdown-blocks/think-block.tsx +++ b/web/app/components/base/markdown-blocks/think-block.tsx @@ -4,33 +4,25 @@ import ThinkingDetails from './thinking-details' import { useElapsedTimer } from './use-elapsed-timer' const hasEndThink = (children: any): boolean => { - if (typeof children === 'string') - return children.includes('[ENDTHINKFLAG]') + if (typeof children === 'string') return children.includes('[ENDTHINKFLAG]') - if (Array.isArray(children)) - return children.some(child => hasEndThink(child)) + if (Array.isArray(children)) return children.some((child) => hasEndThink(child)) - if (children?.props?.children) - return hasEndThink(children.props.children) + if (children?.props?.children) return hasEndThink(children.props.children) return false } const removeEndThink = (children: any): any => { - if (typeof children === 'string') - return children.replace('[ENDTHINKFLAG]', '') + if (typeof children === 'string') return children.replace('[ENDTHINKFLAG]', '') - if (Array.isArray(children)) - return children.map(child => removeEndThink(child)) + if (Array.isArray(children)) return children.map((child) => removeEndThink(child)) if (children?.props?.children) { - return React.cloneElement( - children, - { - ...children.props, - children: removeEndThink(children.props.children), - }, - ) + return React.cloneElement(children, { + ...children.props, + children: removeEndThink(children.props.children), + }) } return children @@ -53,8 +45,7 @@ const ThinkBlock = ({ children, ...props }: ThinkBlockProps) => { const displayContent = removeEndThink(children) const { 'data-think': isThink = false, className, open, ...rest } = props - if (!isThink) - return (<details {...props}>{children}</details>) + if (!isThink) return <details {...props}>{children}</details> return ( <ThinkingDetails diff --git a/web/app/components/base/markdown-blocks/thinking-details.tsx b/web/app/components/base/markdown-blocks/thinking-details.tsx index 87d0d273f62c90..31f5ff2fbdf8da 100644 --- a/web/app/components/base/markdown-blocks/thinking-details.tsx +++ b/web/app/components/base/markdown-blocks/thinking-details.tsx @@ -12,15 +12,18 @@ type ThinkingDetailsProps = ComponentProps<'details'> & { * "Thinking…/Thought (Xs)" label and the bordered content body. Driver-agnostic * — callers compute `isComplete`/`elapsedTime` and pass the body as children. */ -const ThinkingDetails = ({ isComplete, elapsedTime, className, open, children, ...rest }: ThinkingDetailsProps) => { +const ThinkingDetails = ({ + isComplete, + elapsedTime, + className, + open, + children, + ...rest +}: ThinkingDetailsProps) => { const { t } = useTranslation() return ( - <details - {...rest} - className={cn('group', className)} - open={isComplete ? open : true} - > + <details {...rest} className={cn('group', className)} open={isComplete ? open : true}> <summary className="flex cursor-pointer list-none items-center pl-2 font-bold whitespace-nowrap text-text-secondary select-none"> <div className="flex shrink-0 items-center"> <svg @@ -29,14 +32,11 @@ const ThinkingDetails = ({ isComplete, elapsedTime, className, open, children, . stroke="currentColor" viewBox="0 0 24 24" > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M9 5l7 7-7 7" - /> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> </svg> - {isComplete ? `${t($ => $['chat.thought'], { ns: 'common' })}(${elapsedTime.toFixed(1)}s)` : `${t($ => $['chat.thinking'], { ns: 'common' })}(${elapsedTime.toFixed(1)}s)`} + {isComplete + ? `${t(($) => $['chat.thought'], { ns: 'common' })}(${elapsedTime.toFixed(1)}s)` + : `${t(($) => $['chat.thinking'], { ns: 'common' })}(${elapsedTime.toFixed(1)}s)`} </div> </summary> <div className="ml-2 border-l border-components-panel-border bg-components-panel-bg-alt p-3 text-text-secondary"> diff --git a/web/app/components/base/markdown-blocks/use-elapsed-timer.ts b/web/app/components/base/markdown-blocks/use-elapsed-timer.ts index 9098d1c1a3a9c3..56fa18031fe803 100644 --- a/web/app/components/base/markdown-blocks/use-elapsed-timer.ts +++ b/web/app/components/base/markdown-blocks/use-elapsed-timer.ts @@ -11,22 +11,19 @@ export const useElapsedTimer = (complete: boolean) => { const [elapsedTime, setElapsedTime] = useState(0) // Latch completion so a transient flip back to "not complete" never restarts the timer. const completedRef = useRef(complete) - if (complete) - completedRef.current = true + if (complete) completedRef.current = true const isComplete = completedRef.current const timerRef = useRef<NodeJS.Timeout | null>(null) useEffect(() => { - if (isComplete) - return + if (isComplete) return timerRef.current = setInterval(() => { setElapsedTime(Math.floor((Date.now() - startTime) / 100) / 10) }, 100) return () => { - if (timerRef.current) - clearInterval(timerRef.current) + if (timerRef.current) clearInterval(timerRef.current) } }, [startTime, isComplete]) diff --git a/web/app/components/base/markdown-blocks/utils.ts b/web/app/components/base/markdown-blocks/utils.ts index 1d087b3895a7ce..1bbed2a26de1c0 100644 --- a/web/app/components/base/markdown-blocks/utils.ts +++ b/web/app/components/base/markdown-blocks/utils.ts @@ -7,18 +7,18 @@ type MdastNode = { } export const hasImageChild = (children: MdastNode[] | undefined): boolean => { - return children?.some((child) => { - if (child.tagName === 'img') - return true - return child.children ? hasImageChild(child.children) : false - }) ?? false + return ( + children?.some((child) => { + if (child.tagName === 'img') return true + return child.children ? hasImageChild(child.children) : false + }) ?? false + ) } export const isValidUrl = (url: string): boolean => { const validPrefixes = ['http:', 'https:', '//', 'mailto:'] - if (ALLOW_UNSAFE_DATA_SCHEME) - validPrefixes.push('data:') - return validPrefixes.some(prefix => url.startsWith(prefix)) + if (ALLOW_UNSAFE_DATA_SCHEME) validPrefixes.push('data:') + return validPrefixes.some((prefix) => url.startsWith(prefix)) } export const getMarkdownImageURL = (url: string, pathname?: string) => { diff --git a/web/app/components/base/markdown-blocks/video-block.tsx b/web/app/components/base/markdown-blocks/video-block.tsx index 13ea490499bdbb..ab8cb30c8ba58c 100644 --- a/web/app/components/base/markdown-blocks/video-block.tsx +++ b/web/app/components/base/markdown-blocks/video-block.tsx @@ -8,11 +8,12 @@ import { memo } from 'react' import VideoGallery from '@/app/components/base/video-gallery' const VideoBlock: any = memo(({ node }: any) => { - const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src) + const srcs = node.children + .filter((child: any) => 'properties' in child) + .map((child: any) => (child as any).properties.src) if (srcs.length === 0) { const src = node.properties?.src - if (src) - return <VideoGallery key={src} srcs={[src]} /> + if (src) return <VideoGallery key={src} srcs={[src]} /> return null } return <VideoGallery key={srcs.join()} srcs={srcs} /> diff --git a/web/app/components/base/markdown-with-directive/__tests__/index.spec.tsx b/web/app/components/base/markdown-with-directive/__tests__/index.spec.tsx index d2b2d2a249f7f6..b2f7be17b4ffe0 100644 --- a/web/app/components/base/markdown-with-directive/__tests__/index.spec.tsx +++ b/web/app/components/base/markdown-with-directive/__tests__/index.spec.tsx @@ -27,7 +27,9 @@ describe('markdown-with-directive', () => { }) it('should return true when withiconcarditem props are valid', () => { - expect(validateDirectiveProps('withiconcarditem', { icon: 'https://example.com/icon.png' })).toBe(true) + expect( + validateDirectiveProps('withiconcarditem', { icon: 'https://example.com/icon.png' }), + ).toBe(true) }) it('should return false and log when directive name is unknown', () => { @@ -48,7 +50,9 @@ describe('markdown-with-directive', () => { it('should return false and log when withiconcarditem icon is not http/https', () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const isValid = validateDirectiveProps('withiconcarditem', { icon: 'ftp://example.com/icon.png' }) + const isValid = validateDirectiveProps('withiconcarditem', { + icon: 'ftp://example.com/icon.png', + }) expect(isValid).toBe(false) expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -174,7 +178,8 @@ describe('markdown-with-directive', () => { }) it('should call sanitizer and render based on sanitized markdown', () => { - const sanitizeSpy = vi.spyOn(DOMPurify, 'sanitize') + const sanitizeSpy = vi + .spyOn(DOMPurify, 'sanitize') .mockReturnValue(':withiconcarditem[Sanitized]{icon="https://example.com/safe.png"}') const { container } = render(<MarkdownWithDirective markdown="<script>alert(1)</script>" />) diff --git a/web/app/components/base/markdown-with-directive/components/__tests__/markdown-with-directive-schema.spec.ts b/web/app/components/base/markdown-with-directive/components/__tests__/markdown-with-directive-schema.spec.ts index c69bdf4987a0b1..9a53471e3e597e 100644 --- a/web/app/components/base/markdown-with-directive/components/__tests__/markdown-with-directive-schema.spec.ts +++ b/web/app/components/base/markdown-with-directive/components/__tests__/markdown-with-directive-schema.spec.ts @@ -12,7 +12,9 @@ describe('markdown-with-directive-schema', () => { }) it('should return true for withiconcarditem when icon is https URL', () => { - expect(validateDirectiveProps('withiconcarditem', { icon: 'https://example.com/icon.png' })).toBe(true) + expect( + validateDirectiveProps('withiconcarditem', { icon: 'https://example.com/icon.png' }), + ).toBe(true) }) }) @@ -36,7 +38,9 @@ describe('markdown-with-directive-schema', () => { it('should return false and log error for non-http icon URL', () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const isValid = validateDirectiveProps('withiconcarditem', { icon: 'ftp://example.com/icon.png' }) + const isValid = validateDirectiveProps('withiconcarditem', { + icon: 'ftp://example.com/icon.png', + }) expect(isValid).toBe(false) expect(consoleErrorSpy).toHaveBeenCalledWith( diff --git a/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts index 6dbde998d457ed..7bdd5cf2b01f2b 100644 --- a/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts +++ b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts @@ -7,13 +7,16 @@ const withIconCardListPropsSchema = z.object(commonSchema).strict() const HTTP_URL_REGEX = /^https?:\/\//i -const withIconCardItemPropsSchema = z.object({ - ...commonSchema, - icon: z.string().trim().url().refine( - value => HTTP_URL_REGEX.test(value), - 'icon must be a http/https URL', - ), -}).strict() +const withIconCardItemPropsSchema = z + .object({ + ...commonSchema, + icon: z + .string() + .trim() + .url() + .refine((value) => HTTP_URL_REGEX.test(value), 'icon must be a http/https URL'), + }) + .strict() const directivePropsSchemas = { withiconcardlist: withIconCardListPropsSchema, @@ -40,7 +43,7 @@ export function validateDirectiveProps(name: string, attributes: Record<string, console.error('[markdown-with-directive] Invalid directive props.', { attributes, directive: name, - issues: parsed.error.issues.map(issue => ({ + issues: parsed.error.issues.map((issue) => ({ code: issue.code, message: issue.message, path: issue.path.join('.'), diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx index bb785a3ea10e4f..55ffb65823b935 100644 --- a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx @@ -9,7 +9,12 @@ type WithIconItemProps = WithIconCardItemProps & { function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) { return ( - <div className={cn('flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2', className)}> + <div + className={cn( + 'flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2', + className, + )} + > <img src={icon} className="border-none! object-contain" diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-list.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-list.tsx index 1d2b0e806479fe..389698d209be8a 100644 --- a/web/app/components/base/markdown-with-directive/components/with-icon-card-list.tsx +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-list.tsx @@ -7,11 +7,7 @@ type WithIconListProps = WithIconCardListProps & { } function WithIconCardList({ children, className }: WithIconListProps) { - return ( - <div className={cn('space-y-1', className)}> - {children} - </div> - ) + return <div className={cn('space-y-1', className)}>{children}</div> } export default WithIconCardList diff --git a/web/app/components/base/markdown-with-directive/index.tsx b/web/app/components/base/markdown-with-directive/index.tsx index e8feca39a3c8e0..8baec8977e9886 100644 --- a/web/app/components/base/markdown-with-directive/index.tsx +++ b/web/app/components/base/markdown-with-directive/index.tsx @@ -48,21 +48,21 @@ type MdastRoot = { type: 'root' children: Array<{ type: string - children?: Array<{ type: string, value?: string }> + children?: Array<{ type: string; value?: string }> value?: string }> } function isMdastRoot(node: Parameters<typeof visit>[0]): node is MdastRoot { - if (typeof node !== 'object' || node === null) - return false + if (typeof node !== 'object' || node === null) return false - const candidate = node as { type?: unknown, children?: unknown } + const candidate = node as { type?: unknown; children?: unknown } return candidate.type === 'root' && Array.isArray(candidate.children) } // Move the regex to module scope to avoid recompilation -const DIRECTIVE_ATTRIBUTE_BLOCK_REGEX = /^(\s*:+[a-z][\w-]*(?:\[[^\]\n]*\])?)\s+((?:\{[^}\n]*\}\s*)+)$/i +const DIRECTIVE_ATTRIBUTE_BLOCK_REGEX = + /^(\s*:+[a-z][\w-]*(?:\[[^\]\n]*\])?)\s+((?:\{[^}\n]*\}\s*)+)$/i const ATTRIBUTE_BLOCK_REGEX = /\{([^}\n]*)\}/g type PluggableList = NonNullable<StreamdownProps['rehypePlugins']> type Pluggable = PluggableList[number] @@ -82,8 +82,10 @@ const DIRECTIVE_ALLOWED_TAGS: Record<string, AttributeDefinition[]> = { } function buildDirectiveRehypePlugins(): PluggableList { - const [sanitizePlugin, defaultSanitizeSchema] - = defaultRehypePlugins.sanitize as [Pluggable, SanitizeSchema] + const [sanitizePlugin, defaultSanitizeSchema] = defaultRehypePlugins.sanitize as [ + Pluggable, + SanitizeSchema, + ] const tagNames = new Set([ ...(defaultSanitizeSchema.tagNames ?? []), @@ -115,37 +117,35 @@ const directiveRehypePlugins = buildDirectiveRehypePlugins() function normalizeDirectiveAttributeBlocks(markdown: string): string { const lines = markdown.split('\n') - return lines.map((line) => { - const match = line.match(DIRECTIVE_ATTRIBUTE_BLOCK_REGEX) - if (!match) - return line - - const directivePrefix = match[1] - const attributeBlocks = match[2] - const attrMatches = [...attributeBlocks!.matchAll(ATTRIBUTE_BLOCK_REGEX)] - if (attrMatches.length === 0) - return line - - const mergedAttributes = attrMatches - .map(result => result[1]!.trim()) - .filter(Boolean) - .join(' ') - - return mergedAttributes - ? `${directivePrefix}{${mergedAttributes}}` - : directivePrefix - }).join('\n') + return lines + .map((line) => { + const match = line.match(DIRECTIVE_ATTRIBUTE_BLOCK_REGEX) + if (!match) return line + + const directivePrefix = match[1] + const attributeBlocks = match[2] + const attrMatches = [...attributeBlocks!.matchAll(ATTRIBUTE_BLOCK_REGEX)] + if (attrMatches.length === 0) return line + + const mergedAttributes = attrMatches + .map((result) => result[1]!.trim()) + .filter(Boolean) + .join(' ') + + return mergedAttributes ? `${directivePrefix}{${mergedAttributes}}` : directivePrefix + }) + .join('\n') } -function normalizeDirectiveAttributes(attributes?: Record<string, unknown>): Record<string, string> { +function normalizeDirectiveAttributes( + attributes?: Record<string, unknown>, +): Record<string, string> { const normalized: Record<string, string> = {} - if (!attributes) - return normalized + if (!attributes) return normalized for (const [key, value] of Object.entries(attributes)) { - if (typeof value === 'string') - normalized[key] = value + if (typeof value === 'string') normalized[key] = value } return normalized @@ -154,25 +154,19 @@ function normalizeDirectiveAttributes(attributes?: Record<string, unknown>): Rec function isValidDirectiveAst(tree: Parameters<typeof visit>[0]): boolean { let isValid = true - visit( - tree, - ['textDirective', 'leafDirective', 'containerDirective'], - (node) => { - if (!isValid) - return + visit(tree, ['textDirective', 'leafDirective', 'containerDirective'], (node) => { + if (!isValid) return - const directiveNode = node as DirectiveNode - const directiveName = directiveNode.name?.toLowerCase() - if (!directiveName) { - isValid = false - return - } + const directiveNode = node as DirectiveNode + const directiveName = directiveNode.name?.toLowerCase() + if (!directiveName) { + isValid = false + return + } - const attributes = normalizeDirectiveAttributes(directiveNode.attributes) - if (!validateDirectiveProps(directiveName, attributes)) - isValid = false - }, - ) + const attributes = normalizeDirectiveAttributes(directiveNode.attributes) + if (!validateDirectiveProps(directiveName, attributes)) isValid = false + }) return isValid } @@ -183,21 +177,18 @@ function hasUnparsedDirectiveLikeText(tree: Parameters<typeof visit>[0]): boolea let hasInvalidText = false visit(tree, 'text', (node) => { - if (hasInvalidText) - return + if (hasInvalidText) return const textNode = node as { value?: string } const value = textNode.value || '' - if (UNPARSED_DIRECTIVE_LIKE_TEXT_REGEX.test(value)) - hasInvalidText = true + if (UNPARSED_DIRECTIVE_LIKE_TEXT_REGEX.test(value)) hasInvalidText = true }) return hasInvalidText } function replaceWithInvalidContent(tree: Parameters<typeof visit>[0]) { - if (!isMdastRoot(tree)) - return + if (!isMdastRoot(tree)) return const root = tree root.children = [ @@ -220,30 +211,27 @@ function directivePlugin() { return } - visit( - tree, - ['textDirective', 'leafDirective', 'containerDirective'], - (node) => { - const directiveNode = node as DirectiveNode - const attributes = normalizeDirectiveAttributes(directiveNode.attributes) - const hProperties: Record<string, string> = { ...attributes } - - if (hProperties.class) { - hProperties.className = hProperties.class - delete hProperties.class - } - - const data = directiveNode.data || (directiveNode.data = {}) - data.hName = directiveNode.name?.toLowerCase() - data.hProperties = hProperties - }, - ) + visit(tree, ['textDirective', 'leafDirective', 'containerDirective'], (node) => { + const directiveNode = node as DirectiveNode + const attributes = normalizeDirectiveAttributes(directiveNode.attributes) + const hProperties: Record<string, string> = { ...attributes } + + if (hProperties.class) { + hProperties.className = hProperties.class + delete hProperties.class + } + + const data = directiveNode.data || (directiveNode.data = {}) + data.hName = directiveNode.name?.toLowerCase() + data.hProperties = hProperties + }) } } const directiveComponents: Components = { a: (props) => { - const { children, href } = props as ClassAttributes<HTMLAnchorElement> & AnchorHTMLAttributes<HTMLAnchorElement> + const { children, href } = props as ClassAttributes<HTMLAnchorElement> & + AnchorHTMLAttributes<HTMLAnchorElement> return ( <a @@ -266,8 +254,7 @@ type MarkdownWithDirectiveProps = { } function sanitizeMarkdownInput(markdown: string): string { - if (!markdown) - return '' + if (!markdown) return '' if (typeof DOMPurify.sanitize === 'function') { return DOMPurify.sanitize(markdown, { @@ -283,8 +270,7 @@ export function MarkdownWithDirective({ markdown }: MarkdownWithDirectiveProps) const sanitizedMarkdown = sanitizeMarkdownInput(markdown) const normalizedMarkdown = normalizeDirectiveAttributeBlocks(sanitizedMarkdown) - if (!normalizedMarkdown) - return null + if (!normalizedMarkdown) return null return ( <div className="markdown-body"> @@ -297,6 +283,5 @@ export function MarkdownWithDirective({ markdown }: MarkdownWithDirectiveProps) {normalizedMarkdown} </Streamdown> </div> - ) } diff --git a/web/app/components/base/markdown/__tests__/error-boundary.spec.tsx b/web/app/components/base/markdown/__tests__/error-boundary.spec.tsx index f9afed4e94e670..eaaecbaae9680f 100644 --- a/web/app/components/base/markdown/__tests__/error-boundary.spec.tsx +++ b/web/app/components/base/markdown/__tests__/error-boundary.spec.tsx @@ -8,7 +8,7 @@ describe('ErrorBoundary', () => { let consoleErrorSpy: ReturnType<typeof vi.spyOn> beforeEach(() => { - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { @@ -39,9 +39,7 @@ describe('ErrorBoundary', () => { </ErrorBoundary>, ) - expect( - screen.getByText(/Oops! An error occurred/i), - ).toBeInTheDocument() + expect(screen.getByText(/Oops! An error occurred/i)).toBeInTheDocument() expect(consoleErrorSpy).toHaveBeenCalled() diff --git a/web/app/components/base/markdown/__tests__/markdown-utils.spec.ts b/web/app/components/base/markdown/__tests__/markdown-utils.spec.ts index ce3bfc559efcd5..9baf126d2d1063 100644 --- a/web/app/components/base/markdown/__tests__/markdown-utils.spec.ts +++ b/web/app/components/base/markdown/__tests__/markdown-utils.spec.ts @@ -46,7 +46,7 @@ describe('preprocessLaTeX', () => { const input = [ 'Some text before', '```js', - 'const s = \'$insideCode$\'', + "const s = '$insideCode$'", '```', 'And outside $math$', ].join('\n') @@ -54,7 +54,7 @@ describe('preprocessLaTeX', () => { const out = mod.preprocessLaTeX(input) // code block should be preserved exactly (including $ inside) - expect(out).toContain('```js\nconst s = \'$insideCode$\'\n```') + expect(out).toContain("```js\nconst s = '$insideCode$'\n```") // outside inline $math$ should remain intact (function keeps inline $...$) expect(out).toContain('$math$') }) @@ -163,6 +163,8 @@ describe('customUrlTransform', () => { expect(modFalse.customUrlTransform('data:text/plain;base64,SGVsbG8=')).toBeUndefined() const modTrue = await loadModuleWithConfig(true) - expect(modTrue.customUrlTransform('data:text/plain;base64,SGVsbG8=')).toBe('data:text/plain;base64,SGVsbG8=') + expect(modTrue.customUrlTransform('data:text/plain;base64,SGVsbG8=')).toBe( + 'data:text/plain;base64,SGVsbG8=', + ) }) }) diff --git a/web/app/components/base/markdown/__tests__/streamdown-wrapper.spec.tsx b/web/app/components/base/markdown/__tests__/streamdown-wrapper.spec.tsx index 2db4d9218c843e..fd2e85d3092a96 100644 --- a/web/app/components/base/markdown/__tests__/streamdown-wrapper.spec.tsx +++ b/web/app/components/base/markdown/__tests__/streamdown-wrapper.spec.tsx @@ -7,12 +7,16 @@ const TILDE_RANGE_RE = /0\.3~8mm/ vi.mock('@/app/components/base/markdown-blocks', () => ({ AudioBlock: ({ children }: PropsWithChildren) => <div data-testid="audio-block">{children}</div>, Img: ({ alt }: { alt?: string }) => <span data-testid="img">{alt}</span>, - Link: ({ children, href }: { children?: ReactNode, href?: string }) => <a href={href}>{children}</a>, + Link: ({ children, href }: { children?: ReactNode; href?: string }) => ( + <a href={href}>{children}</a> + ), MarkdownButton: ({ children }: PropsWithChildren) => <button>{children}</button>, MarkdownForm: ({ children }: PropsWithChildren) => <form>{children}</form>, Paragraph: ({ children }: PropsWithChildren) => <p data-testid="paragraph">{children}</p>, PluginImg: ({ alt }: { alt?: string }) => <span data-testid="plugin-img">{alt}</span>, - PluginParagraph: ({ children }: PropsWithChildren) => <p data-testid="plugin-paragraph">{children}</p>, + PluginParagraph: ({ children }: PropsWithChildren) => ( + <p data-testid="plugin-paragraph">{children}</p> + ), ScriptBlock: () => null, ThinkBlock: ({ children }: PropsWithChildren) => <details>{children}</details>, VideoBlock: ({ children }: PropsWithChildren) => <div data-testid="video-block">{children}</div>, @@ -164,7 +168,12 @@ describe('StreamdownWrapper', () => { } // Act - render(<StreamdownWrapper latexContent="[link](https://example.com)" customComponents={customComponents} />) + render( + <StreamdownWrapper + latexContent="[link](https://example.com)" + customComponents={customComponents} + />, + ) // Assert // Assert diff --git a/web/app/components/base/markdown/error-boundary.tsx b/web/app/components/base/markdown/error-boundary.tsx index 8331e726222d65..6da0d4ef7c2d7a 100644 --- a/web/app/components/base/markdown/error-boundary.tsx +++ b/web/app/components/base/markdown/error-boundary.tsx @@ -28,7 +28,8 @@ export default class ErrorBoundary extends Component { if (this.state.hasError) { return ( <div> - Oops! An error occurred. This could be due to an ECharts runtime error or invalid SVG content. + Oops! An error occurred. This could be due to an ECharts runtime error or invalid SVG + content. <br /> (see the browser console for more information) </div> diff --git a/web/app/components/base/markdown/index.stories.tsx b/web/app/components/base/markdown/index.stories.tsx index cfab49b6c00853..4e56d1f3e6ef60 100644 --- a/web/app/components/base/markdown/index.stories.tsx +++ b/web/app/components/base/markdown/index.stories.tsx @@ -38,20 +38,15 @@ const reply = await client.chat({ \`\`\` ` -const MarkdownDemo = ({ - compact = false, -}: { - compact?: boolean -}) => { +const MarkdownDemo = ({ compact = false }: { compact?: boolean }) => { const [content] = useState(SAMPLE_MD.trim()) return ( <div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6"> - <div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">Markdown renderer</div> - <Markdown - content={content} - className={compact ? 'text-sm! leading-relaxed' : ''} - /> + <div className="text-xs tracking-[0.18em] text-text-tertiary uppercase"> + Markdown renderer + </div> + <Markdown content={content} className={compact ? 'text-sm! leading-relaxed' : ''} /> </div> ) } @@ -63,7 +58,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Markdown wrapper with GitHub-flavored markdown, Mermaid diagrams, math, and custom blocks (details, audio, etc.).', + component: + 'Markdown wrapper with GitHub-flavored markdown, Mermaid diagrams, math, and custom blocks (details, audio, etc.).', }, }, }, diff --git a/web/app/components/base/markdown/index.tsx b/web/app/components/base/markdown/index.tsx index 56ea08b7f91054..ef1ecec8d5c862 100644 --- a/web/app/components/base/markdown/index.tsx +++ b/web/app/components/base/markdown/index.tsx @@ -24,7 +24,12 @@ export type MarkdownProps = { pluginInfo?: SimplePluginInfo } & Pick< StreamdownWrapperProps, - 'customComponents' | 'customDisallowedElements' | 'remarkPlugins' | 'rehypePlugins' | 'isAnimating' | 'mode' + | 'customComponents' + | 'customDisallowedElements' + | 'remarkPlugins' + | 'rehypePlugins' + | 'isAnimating' + | 'mode' > export const Markdown = memo((props: MarkdownProps) => { @@ -42,7 +47,10 @@ export const Markdown = memo((props: MarkdownProps) => { const latexContent = useMemo(() => preprocess(content), [content]) return ( - <div className={cn('markdown-body', 'text-text-primary!', className)} data-testid="markdown-body"> + <div + className={cn('markdown-body', 'text-text-primary!', className)} + data-testid="markdown-body" + > <StreamdownWrapper pluginInfo={pluginInfo} latexContent={latexContent} diff --git a/web/app/components/base/markdown/markdown-utils.ts b/web/app/components/base/markdown/markdown-utils.ts index 94ad31d1ded65a..5c7dd591fcd083 100644 --- a/web/app/components/base/markdown/markdown-utils.ts +++ b/web/app/components/base/markdown/markdown-utils.ts @@ -7,8 +7,7 @@ import { flow } from 'es-toolkit/compat' import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config' export const preprocessLaTeX = (content: string) => { - if (typeof content !== 'string') - return content + if (typeof content !== 'string') return content const codeBlockRegex = /```[\s\S]*?```/g const codeBlocks = content.match(codeBlockRegex) || [] @@ -19,7 +18,8 @@ export const preprocessLaTeX = (content: string) => { (str: string) => str.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`), (str: string) => str.replace(/\\\[([\s\S]*?)\\\]/g, (_, equation) => `$$${equation}$$`), (str: string) => str.replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`), - (str: string) => str.replace(/(^|[^\\])\$(.+?)\$/g, (_, prefix, equation) => `${prefix}$${equation}$`), + (str: string) => + str.replace(/(^|[^\\])\$(.+?)\$/g, (_, prefix, equation) => `${prefix}$${equation}$`), ])(processedContent) codeBlocks.forEach((block) => { @@ -61,35 +61,30 @@ export const preprocessThinkTag = (content: string) => { export const customUrlTransform = (uri: string): string | undefined => { const PERMITTED_SCHEME_REGEX = /^(https?|ircs?|mailto|xmpp|abbr):$/i - if (uri.startsWith('#')) - return uri + if (uri.startsWith('#')) return uri - if (uri.startsWith('//')) - return uri + if (uri.startsWith('//')) return uri const colonIndex = uri.indexOf(':') - if (colonIndex === -1) - return uri + if (colonIndex === -1) return uri const slashIndex = uri.indexOf('/') const questionMarkIndex = uri.indexOf('?') const hashIndex = uri.indexOf('#') if ( - (slashIndex !== -1 && colonIndex > slashIndex) - || (questionMarkIndex !== -1 && colonIndex > questionMarkIndex) - || (hashIndex !== -1 && colonIndex > hashIndex) + (slashIndex !== -1 && colonIndex > slashIndex) || + (questionMarkIndex !== -1 && colonIndex > questionMarkIndex) || + (hashIndex !== -1 && colonIndex > hashIndex) ) { return uri } const scheme = uri.substring(0, colonIndex + 1).toLowerCase() - if (PERMITTED_SCHEME_REGEX.test(scheme)) - return uri + if (PERMITTED_SCHEME_REGEX.test(scheme)) return uri - if (ALLOW_UNSAFE_DATA_SCHEME && scheme === 'data:') - return uri + if (ALLOW_UNSAFE_DATA_SCHEME && scheme === 'data:') return uri return undefined } diff --git a/web/app/components/base/markdown/streamdown-wrapper.tsx b/web/app/components/base/markdown/streamdown-wrapper.tsx index e566ffc6116dbf..2e7d43577ef884 100644 --- a/web/app/components/base/markdown/streamdown-wrapper.tsx +++ b/web/app/components/base/markdown/streamdown-wrapper.tsx @@ -35,7 +35,9 @@ type SanitizeSchema = { [key: string]: unknown } -const CodeBlock = dynamic(() => import('@/app/components/base/markdown-blocks/code-block'), { ssr: false }) +const CodeBlock = dynamic(() => import('@/app/components/base/markdown-blocks/code-block'), { + ssr: false, +}) const mathPlugin = createMathPlugin({ singleDollarTextMath: ENABLE_SINGLE_DOLLAR_LATEX, @@ -77,8 +79,10 @@ const ALLOWED_TAGS: Record<string, string[]> = { * when `rehypePlugins` is the exact default reference (identity check). */ function buildRehypePlugins(extraPlugins?: PluggableList): PluggableList { - const [sanitizePlugin, defaultSanitizeSchema] - = defaultRehypePlugins.sanitize as [Pluggable, SanitizeSchema] + const [sanitizePlugin, defaultSanitizeSchema] = defaultRehypePlugins.sanitize as [ + Pluggable, + SanitizeSchema, + ] const tagNamesSet = new Set([ ...(defaultSanitizeSchema.tagNames ?? []), @@ -102,21 +106,19 @@ function buildRehypePlugins(extraPlugins?: PluggableList): PluggableList { return !overrideNames.has(name as string) }) mergedAttributes[tag] = [...filtered, ...(ALLOWED_TAGS[tag] ?? [])] - } - else { + } else { mergedAttributes[tag] = ALLOWED_TAGS[tag]! } } // The default schema forces `input` to be `{disabled:true, type:'checkbox'}` // via `required`. Drop that so form inputs keep their original attributes. - const { input: _inputRequired, ...requiredRest } - = (defaultSanitizeSchema.required ?? {}) + const { input: _inputRequired, ...requiredRest } = defaultSanitizeSchema.required ?? {} // `name` is in the default `clobber` list, which prefixes every `name` value // with `user-content-`. Form fields need the original `name`, and our form // component validates names with `isSafeName()`, so remove it. - const clobber = (defaultSanitizeSchema.clobber ?? []).filter(k => k !== 'name') + const clobber = (defaultSanitizeSchema.clobber ?? []).filter((k) => k !== 'name') if (ALLOW_INLINE_STYLES) { const globalAttrs = mergedAttributes['*'] ?? [] @@ -168,7 +170,12 @@ const StreamdownWrapper = (props: StreamdownWrapperProps) => { const remarkPlugins = useMemo( () => [ - [Array.isArray(defaultRemarkPlugins.gfm) ? defaultRemarkPlugins.gfm[0] : defaultRemarkPlugins.gfm, { singleTilde: false }] as Pluggable, + [ + Array.isArray(defaultRemarkPlugins.gfm) + ? defaultRemarkPlugins.gfm[0] + : defaultRemarkPlugins.gfm, + { singleTilde: false }, + ] as Pluggable, RemarkBreaks, ...(props.remarkPlugins ?? []), ], @@ -188,18 +195,37 @@ const StreamdownWrapper = (props: StreamdownWrapperProps) => { ) const disallowedElements = useMemo( - () => ['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])], + () => [ + 'iframe', + 'head', + 'html', + 'meta', + 'link', + 'style', + 'body', + ...(props.customDisallowedElements || []), + ], [props.customDisallowedElements], ) const components: Components = useMemo( () => ({ code: CodeBlock, - img: imgProps => pluginInfo ? <PluginImg src={String(imgProps.src ?? '')} pluginInfo={pluginInfo} /> : <Img src={String(imgProps.src ?? '')} />, + img: (imgProps) => + pluginInfo ? ( + <PluginImg src={String(imgProps.src ?? '')} pluginInfo={pluginInfo} /> + ) : ( + <Img src={String(imgProps.src ?? '')} /> + ), video: VideoBlock, audio: AudioBlock, a: Link, - p: pProps => pluginInfo ? <PluginParagraph {...pProps} pluginInfo={pluginInfo} /> : <Paragraph {...pProps} />, + p: (pProps) => + pluginInfo ? ( + <PluginParagraph {...pProps} pluginInfo={pluginInfo} /> + ) : ( + <Paragraph {...pProps} /> + ), button: MarkdownButton, form: MarkdownForm as ComponentType, details: ThinkBlock as ComponentType, diff --git a/web/app/components/base/mermaid/__tests__/index.spec.tsx b/web/app/components/base/mermaid/__tests__/index.spec.tsx index 14c178e41868b6..30a4f712ab399a 100644 --- a/web/app/components/base/mermaid/__tests__/index.spec.tsx +++ b/web/app/components/base/mermaid/__tests__/index.spec.tsx @@ -13,15 +13,21 @@ const UNKNOWN_ERROR_RE = /Unknown error\. Please check the console\./i vi.mock('mermaid', () => ({ default: { initialize: vi.fn(), - render: vi.fn().mockResolvedValue({ svg: '<svg id="mermaid-chart">test-svg</svg>', diagramType: 'flowchart' }), + render: vi.fn().mockResolvedValue({ + svg: '<svg id="mermaid-chart">test-svg</svg>', + diagramType: 'flowchart', + }), mermaidAPI: { - render: vi.fn().mockResolvedValue({ svg: '<svg id="mermaid-chart">test-svg-api</svg>', diagramType: 'flowchart' }), + render: vi.fn().mockResolvedValue({ + svg: '<svg id="mermaid-chart">test-svg-api</svg>', + diagramType: 'flowchart', + }), }, }, })) vi.mock('../utils', async (importOriginal) => { - const actual = await importOriginal() as Record<string, unknown> + const actual = (await importOriginal()) as Record<string, unknown> return { ...actual, svgToBase64: vi.fn().mockResolvedValue('data:image/svg+xml;base64,dGVzdC1zdmc='), @@ -34,8 +40,11 @@ describe('Mermaid Flowchart Component', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(mermaid.initialize).mockImplementation(() => { }) - vi.mocked(mermaid.render).mockResolvedValue({ svg: '<svg id="mermaid-chart">test-svg</svg>', diagramType: 'flowchart' }) + vi.mocked(mermaid.initialize).mockImplementation(() => {}) + vi.mocked(mermaid.render).mockResolvedValue({ + svg: '<svg id="mermaid-chart">test-svg</svg>', + diagramType: 'flowchart', + }) }) afterEach(() => { @@ -55,9 +64,12 @@ describe('Mermaid Flowchart Component', () => { render(<Flowchart PrimitiveCode={mockCode} />) }) - await waitFor(() => { - expect(screen.getByText('test-svg')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('test-svg')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) }) it('should render gantt charts with specific formatting', async () => { @@ -66,9 +78,12 @@ describe('Mermaid Flowchart Component', () => { render(<Flowchart PrimitiveCode={ganttCode} />) }) - await waitFor(() => { - expect(screen.getByText('test-svg')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('test-svg')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) }) it('should render mindmap and sequenceDiagram charts', async () => { @@ -76,9 +91,12 @@ describe('Mermaid Flowchart Component', () => { const { unmount } = await act(async () => { return render(<Flowchart PrimitiveCode={mindmapCode} />) }) - await waitFor(() => { - expect(screen.getByText('test-svg')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('test-svg')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) unmount() @@ -86,18 +104,24 @@ describe('Mermaid Flowchart Component', () => { await act(async () => { render(<Flowchart PrimitiveCode={sequenceCode} />) }) - await waitFor(() => { - expect(screen.getByText('test-svg')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('test-svg')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) }) it('should handle dark theme configuration', async () => { await act(async () => { render(<Flowchart PrimitiveCode={mockCode} theme="dark" />) }) - await waitFor(() => { - expect(screen.getByText('test-svg')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('test-svg')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) }) }) @@ -114,18 +138,24 @@ describe('Mermaid Flowchart Component', () => { fireEvent.click(handDrawnBtn) }) - await waitFor(() => { - expect(screen.getByText('test-svg-api')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('test-svg-api')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) const classicBtn = screen.getByText(CLASSIC_RE) await act(async () => { fireEvent.click(classicBtn) }) - await waitFor(() => { - expect(screen.getByText('test-svg')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('test-svg')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) }) it('should toggle theme manually', async () => { @@ -140,9 +170,12 @@ describe('Mermaid Flowchart Component', () => { fireEvent.click(toggleBtn) }) - await waitFor(() => { - expect(mermaid.initialize).toHaveBeenCalled() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(mermaid.initialize).toHaveBeenCalled() + }, + { timeout: 3000 }, + ) }) it('should keep selected look unchanged when clicking an already-selected look button', async () => { @@ -164,39 +197,59 @@ describe('Mermaid Flowchart Component', () => { await act(async () => { fireEvent.click(screen.getByText(HAND_DRAWN_RE)) }) - await waitFor(() => { - expect(screen.getByText('test-svg-api')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('test-svg-api')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) const afterFirstHandDrawnApiCalls = vi.mocked(mermaid.mermaidAPI.render).mock.calls.length await act(async () => { fireEvent.click(screen.getByText(HAND_DRAWN_RE)) }) - expect(vi.mocked(mermaid.mermaidAPI.render).mock.calls.length).toBe(afterFirstHandDrawnApiCalls) + expect(vi.mocked(mermaid.mermaidAPI.render).mock.calls.length).toBe( + afterFirstHandDrawnApiCalls, + ) }) it('should toggle theme from light to dark and back to light', async () => { await act(async () => { render(<Flowchart PrimitiveCode={mockCode} theme="light" />) }) - await waitFor(() => { - expect(screen.getByText('test-svg')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('test-svg')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) const toggleBtn = screen.getByRole('button') await act(async () => { fireEvent.click(toggleBtn) }) - await waitFor(() => { - expect(screen.getByRole('button')).toHaveAttribute('title', expect.stringMatching(SWITCH_LIGHT_RE)) - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByRole('button')).toHaveAttribute( + 'title', + expect.stringMatching(SWITCH_LIGHT_RE), + ) + }, + { timeout: 3000 }, + ) await act(async () => { fireEvent.click(screen.getByRole('button')) }) - await waitFor(() => { - expect(screen.getByRole('button')).toHaveAttribute('title', expect.stringMatching(SWITCH_DARK_RE)) - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByRole('button')).toHaveAttribute( + 'title', + expect.stringMatching(SWITCH_DARK_RE), + ) + }, + { timeout: 3000 }, + ) }) it('should configure handDrawn mode for dark non-flowchart diagrams', async () => { @@ -205,24 +258,32 @@ describe('Mermaid Flowchart Component', () => { render(<Flowchart PrimitiveCode={sequenceCode} theme="dark" />) }) - await waitFor(() => { - expect(screen.getByText('test-svg')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('test-svg')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) await act(async () => { fireEvent.click(screen.getByText(HAND_DRAWN_RE)) }) - await waitFor(() => { - expect(screen.getByText('test-svg-api')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('test-svg-api')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) - expect(mermaid.initialize).toHaveBeenCalledWith(expect.objectContaining({ - theme: 'default', - themeVariables: expect.objectContaining({ - primaryBorderColor: '#60a5fa', + expect(mermaid.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + theme: 'default', + themeVariables: expect.objectContaining({ + primaryBorderColor: '#60a5fa', + }), }), - })) + ) }) it('should open image preview when clicking the chart', async () => { @@ -236,9 +297,12 @@ describe('Mermaid Flowchart Component', () => { await act(async () => { fireEvent.click(chartDiv!) }) - await waitFor(() => { - expect(screen.getByTestId('image-preview-container')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByTestId('image-preview-container')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) }) }) @@ -253,7 +317,7 @@ describe('Mermaid Flowchart Component', () => { }) it('should handle rendering errors gracefully', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const errorMsg = 'Syntax error' vi.mocked(mermaid.render).mockRejectedValue(new Error(errorMsg)) @@ -263,21 +327,19 @@ describe('Mermaid Flowchart Component', () => { const errorMessage = await screen.findByText(RENDERING_FAILED_RE) expect(errorMessage).toBeInTheDocument() - } - finally { + } finally { consoleSpy.mockRestore() } }) it('should show unknown-error fallback when render fails without an error message', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) vi.mocked(mermaid.render).mockRejectedValue({} as Error) try { render(<Flowchart PrimitiveCode={'graph TD\n P-->Q\n Q-->R'} />) expect(await screen.findByText(UNKNOWN_ERROR_RE)).toBeInTheDocument() - } - finally { + } finally { consoleSpy.mockRestore() } }) @@ -286,9 +348,12 @@ describe('Mermaid Flowchart Component', () => { const { rerender } = render(<Flowchart PrimitiveCode={mockCode} />) // Wait for initial render to complete - await waitFor(() => { - expect(vi.mocked(mermaid.render)).toHaveBeenCalled() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(vi.mocked(mermaid.render)).toHaveBeenCalled() + }, + { timeout: 3000 }, + ) const initialCallCount = vi.mocked(mermaid.render).mock.calls.length // Rerender with same code @@ -296,29 +361,38 @@ describe('Mermaid Flowchart Component', () => { rerender(<Flowchart PrimitiveCode={mockCode} />) }) - await waitFor(() => { - expect(vi.mocked(mermaid.render).mock.calls.length).toBe(initialCallCount) - }, { timeout: 3000 }) + await waitFor( + () => { + expect(vi.mocked(mermaid.render).mock.calls.length).toBe(initialCallCount) + }, + { timeout: 3000 }, + ) // Call count should not increase (cache was used) expect(vi.mocked(mermaid.render).mock.calls.length).toBe(initialCallCount) }) it('should keep previous svg visible while next render is loading', async () => { - let resolveSecondRender: ((value: { svg: string, diagramType: string }) => void) | null = null - const secondRenderPromise = new Promise<{ svg: string, diagramType: string }>((resolve) => { + let resolveSecondRender: ((value: { svg: string; diagramType: string }) => void) | null = null + const secondRenderPromise = new Promise<{ svg: string; diagramType: string }>((resolve) => { resolveSecondRender = resolve }) vi.mocked(mermaid.render) - .mockResolvedValueOnce({ svg: '<svg id="mermaid-chart">initial-svg</svg>', diagramType: 'flowchart' }) + .mockResolvedValueOnce({ + svg: '<svg id="mermaid-chart">initial-svg</svg>', + diagramType: 'flowchart', + }) .mockImplementationOnce(() => secondRenderPromise) const { rerender } = render(<Flowchart PrimitiveCode="graph TD\n A-->B" />) - await waitFor(() => { - expect(screen.getByText('initial-svg')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('initial-svg')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) await act(async () => { rerender(<Flowchart PrimitiveCode="graph TD\n C-->D" />) @@ -326,10 +400,16 @@ describe('Mermaid Flowchart Component', () => { expect(screen.getByText('initial-svg')).toBeInTheDocument() - resolveSecondRender!({ svg: '<svg id="mermaid-chart">second-svg</svg>', diagramType: 'flowchart' }) - await waitFor(() => { - expect(screen.getByText('second-svg')).toBeInTheDocument() - }, { timeout: 3000 }) + resolveSecondRender!({ + svg: '<svg id="mermaid-chart">second-svg</svg>', + diagramType: 'flowchart', + }) + await waitFor( + () => { + expect(screen.getByText('second-svg')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) }) it('should handle invalid mermaid code completion', async () => { @@ -338,9 +418,12 @@ describe('Mermaid Flowchart Component', () => { render(<Flowchart PrimitiveCode={invalidCode} />) }) - await waitFor(() => { - expect(screen.getByText('Diagram code is not complete or invalid.')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('Diagram code is not complete or invalid.')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) }) it('should keep single "after" gantt dependency formatting unchanged', async () => { @@ -354,9 +437,12 @@ describe('Mermaid Flowchart Component', () => { render(<Flowchart PrimitiveCode={singleAfterGantt} />) }) - await waitFor(() => { - expect(mermaid.render).toHaveBeenCalled() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(mermaid.render).toHaveBeenCalled() + }, + { timeout: 3000 }, + ) const lastRenderArgs = vi.mocked(mermaid.render).mock.calls.at(-1) expect(lastRenderArgs?.[1]).toContain('Single task :after task1, 2024-01-01, 1d') @@ -368,9 +454,12 @@ describe('Mermaid Flowchart Component', () => { const { rerender } = render(<Flowchart PrimitiveCode={firstCode} />) // Wait for initial render - await waitFor(() => { - expect(vi.mocked(mermaid.render)).toHaveBeenCalled() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(vi.mocked(mermaid.render)).toHaveBeenCalled() + }, + { timeout: 3000 }, + ) const firstRenderCallCount = vi.mocked(mermaid.render).mock.calls.length // Change to different code @@ -379,9 +468,12 @@ describe('Mermaid Flowchart Component', () => { }) // Wait for second render - await waitFor(() => { - expect(vi.mocked(mermaid.render).mock.calls.length).toBeGreaterThan(firstRenderCallCount) - }, { timeout: 3000 }) + await waitFor( + () => { + expect(vi.mocked(mermaid.render).mock.calls.length).toBeGreaterThan(firstRenderCallCount) + }, + { timeout: 3000 }, + ) const afterSecondRenderCallCount = vi.mocked(mermaid.render).mock.calls.length // Change back to first code - should use cache @@ -389,9 +481,12 @@ describe('Mermaid Flowchart Component', () => { rerender(<Flowchart PrimitiveCode={firstCode} />) }) - await waitFor(() => { - expect(vi.mocked(mermaid.render).mock.calls.length).toBe(afterSecondRenderCallCount) - }, { timeout: 3000 }) + await waitFor( + () => { + expect(vi.mocked(mermaid.render).mock.calls.length).toBe(afterSecondRenderCallCount) + }, + { timeout: 3000 }, + ) // Call count should not increase (cache was used) expect(vi.mocked(mermaid.render).mock.calls.length).toBe(afterSecondRenderCallCount) @@ -403,10 +498,13 @@ describe('Mermaid Flowchart Component', () => { }) // Wait for SVG to be rendered - await waitFor(() => { - const svgElement = screen.queryByText('test-svg') - expect(svgElement).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + const svgElement = screen.queryByText('test-svg') + expect(svgElement).toBeInTheDocument() + }, + { timeout: 3000 }, + ) const mermaidDiv = screen.getByText('test-svg').closest('.mermaid') await act(async () => { @@ -423,12 +521,14 @@ describe('Mermaid Flowchart Component', () => { await waitFor(() => { expect(screen.queryByTestId('image-preview-container')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.cancel' }), + ).not.toBeInTheDocument() }) }) it('should handle configuration failure during configureMermaid', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const originalMock = vi.mocked(mermaid.initialize).getMockImplementation() vi.mocked(mermaid.initialize).mockImplementation(() => { throw new Error('Config fail') @@ -441,14 +541,12 @@ describe('Mermaid Flowchart Component', () => { await waitFor(() => { expect(consoleSpy).toHaveBeenCalledWith('Config error:', expect.any(Error)) }) - } - finally { + } finally { consoleSpy.mockRestore() if (originalMock) { vi.mocked(mermaid.initialize).mockImplementation(originalMock) - } - else { - vi.mocked(mermaid.initialize).mockImplementation(() => { }) + } else { + vi.mocked(mermaid.initialize).mockImplementation(() => {}) } } }) @@ -477,21 +575,20 @@ describe('Mermaid Flowchart Component Module Isolation', () => { } const restoreWindowDescriptor = (descriptor?: PropertyDescriptor) => { - if (descriptor) - Object.defineProperty(globalThis, 'window', descriptor) + if (descriptor) Object.defineProperty(globalThis, 'window', descriptor) } beforeEach(async () => { vi.resetModules() vi.clearAllMocks() - const mod = await import('mermaid') as unknown as { default: typeof mermaid } | typeof mermaid + const mod = (await import('mermaid')) as unknown as { default: typeof mermaid } | typeof mermaid mermaidFresh = 'default' in mod ? mod.default : mod - vi.mocked(mermaidFresh.initialize).mockImplementation(() => { }) + vi.mocked(mermaidFresh.initialize).mockImplementation(() => {}) }) describe('Error Handling', () => { it('should handle initialization failure', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { default: FlowchartFresh } = await import('../index') vi.mocked(mermaidFresh.initialize).mockImplementationOnce(() => { @@ -507,7 +604,7 @@ describe('Mermaid Flowchart Component Module Isolation', () => { }) it('should handle mermaidAPI missing fallback', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const originalMermaidAPI = mermaidFresh.mermaidAPI // @ts-expect-error need to set undefined for testing mermaidFresh.mermaidAPI = undefined @@ -517,9 +614,12 @@ describe('Mermaid Flowchart Component Module Isolation', () => { const { container } = render(<FlowchartFresh PrimitiveCode={mockCode} />) // Wait for initial render to complete - await waitFor(() => { - expect(screen.getByText(HAND_DRAWN_EXACT_RE)).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText(HAND_DRAWN_EXACT_RE)).toBeInTheDocument() + }, + { timeout: 3000 }, + ) const handDrawnBtn = screen.getByText(HAND_DRAWN_EXACT_RE) await act(async () => { @@ -530,11 +630,14 @@ describe('Mermaid Flowchart Component Module Isolation', () => { // The module captures mermaidAPI at import time, so setting it to undefined on // the mocked object may not affect the module's internal reference. // Verify that the rendering completes (either with svg or error) - await waitFor(() => { - const hasSvg = container.querySelector('.mermaid div') - const hasError = container.querySelector('.text-red-500') - expect(hasSvg || hasError).toBeTruthy() - }, { timeout: 5000 }) + await waitFor( + () => { + const hasSvg = container.querySelector('.mermaid div') + const hasError = container.querySelector('.text-red-500') + expect(hasSvg || hasError).toBeTruthy() + }, + { timeout: 5000 }, + ) mermaidFresh.mermaidAPI = originalMermaidAPI consoleSpy.mockRestore() @@ -544,7 +647,7 @@ describe('Mermaid Flowchart Component Module Isolation', () => { vi.mocked(mermaidFresh.initialize).mockImplementation(() => { throw new Error('Config fail') }) - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { default: FlowchartFresh } = await import('../index') await act(async () => { @@ -563,8 +666,7 @@ describe('Mermaid Flowchart Component Module Isolation', () => { vi.resetModules() const { default: FlowchartFresh } = await import('../index') expect(FlowchartFresh).toBeDefined() - } - finally { + } finally { restoreWindowDescriptor(descriptor) } }) @@ -587,10 +689,8 @@ describe('Mermaid Flowchart Component Module Isolation', () => { await vi.advanceTimersByTimeAsync(350) expect(mermaidFresh.render).not.toHaveBeenCalled() - } - finally { - if (descriptor) - Object.defineProperty(globalThis, 'window', descriptor) + } finally { + if (descriptor) Object.defineProperty(globalThis, 'window', descriptor) vi.useRealTimers() } }) @@ -603,19 +703,20 @@ describe('Mermaid Flowchart Component Module Isolation', () => { let patchedContainerRef = false const mockedUseRef = ((initialValue: unknown) => { const ref = reactActual.useRef(initialValue as never) - if (!patchedContainerRef && initialValue === null) - pendingContainerRef = ref - - if (!patchedContainerRef - && pendingContainerRef - && typeof initialValue === 'string' - && initialValue.startsWith('mermaid-chart-')) { + if (!patchedContainerRef && initialValue === null) pendingContainerRef = ref + + if ( + !patchedContainerRef && + pendingContainerRef && + typeof initialValue === 'string' && + initialValue.startsWith('mermaid-chart-') + ) { Object.defineProperty(pendingContainerRef, 'current', { configurable: true, get() { return null }, - set(_value: HTMLDivElement | null) { }, + set(_value: HTMLDivElement | null) {}, }) patchedContainerRef = true pendingContainerRef = null @@ -633,8 +734,7 @@ describe('Mermaid Flowchart Component Module Isolation', () => { const { default: FlowchartFresh } = await import('../index') render(<FlowchartFresh PrimitiveCode={mockCode} />) expect(await screen.findByText('Container element not found')).toBeInTheDocument() - } - finally { + } finally { vi.doUnmock('react') } }) @@ -652,8 +752,7 @@ describe('Mermaid Flowchart Component Module Isolation', () => { }) expect(vi.mocked(mermaidFresh.render)).not.toHaveBeenCalled() - } - finally { + } finally { vi.useRealTimers() } }) @@ -662,9 +761,12 @@ describe('Mermaid Flowchart Component Module Isolation', () => { const { default: FlowchartFresh } = await import('../index') const { unmount } = render(<FlowchartFresh PrimitiveCode={mockCode} />) - await waitFor(() => { - expect(screen.getByText('test-svg')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByText('test-svg')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) const initialHandDrawnCalls = vi.mocked(mermaidFresh.mermaidAPI.render).mock.calls.length @@ -679,9 +781,10 @@ describe('Mermaid Flowchart Component Module Isolation', () => { await vi.advanceTimersByTimeAsync(350) }) - expect(vi.mocked(mermaidFresh.mermaidAPI.render).mock.calls.length).toBe(initialHandDrawnCalls) - } - finally { + expect(vi.mocked(mermaidFresh.mermaidAPI.render).mock.calls.length).toBe( + initialHandDrawnCalls, + ) + } finally { vi.useRealTimers() } }) diff --git a/web/app/components/base/mermaid/__tests__/utils.spec.ts b/web/app/components/base/mermaid/__tests__/utils.spec.ts index 1c8070e3f4dd08..338cbec903ff51 100644 --- a/web/app/components/base/mermaid/__tests__/utils.spec.ts +++ b/web/app/components/base/mermaid/__tests__/utils.spec.ts @@ -1,4 +1,12 @@ -import { cleanUpSvgCode, isMermaidCodeComplete, prepareMermaidCode, processSvgForTheme, sanitizeMermaidCode, svgToBase64, waitForDOMElement } from '../utils' +import { + cleanUpSvgCode, + isMermaidCodeComplete, + prepareMermaidCode, + processSvgForTheme, + sanitizeMermaidCode, + svgToBase64, + waitForDOMElement, +} from '../utils' const FILL_HEX_RE = /fill="#[a-fA-F0-9]{6}"/g @@ -43,11 +51,7 @@ describe('sanitizeMermaidCode', () => { }) it('should remove Mermaid init directives to prevent config overrides', () => { - const input = [ - '%%{init: {"securityLevel":"loose"}}%%', - 'graph TD', - 'A-->B', - ].join('\n') + const input = ['%%{init: {"securityLevel":"loose"}}%%', 'graph TD', 'A-->B'].join('\n') const result = sanitizeMermaidCode(input) @@ -67,11 +71,9 @@ describe('prepareMermaidCode', () => { describe('Sanitization', () => { it('should sanitize click directives in flowcharts', () => { const unsafeProtocol = ['java', 'script:'].join('') - const input = [ - 'graph TD', - 'A[Click]-->B', - `click A href "${unsafeProtocol}alert(1)"`, - ].join('\n') + const input = ['graph TD', 'A[Click]-->B', `click A href "${unsafeProtocol}alert(1)"`].join( + '\n', + ) const result = prepareMermaidCode(input, 'classic') @@ -128,11 +130,16 @@ describe('svgToBase64', () => { describe('Edge Cases', () => { it('should handle errors gracefully', async () => { - const encoderSpy = vi.spyOn(globalThis, 'TextEncoder').mockImplementation(() => ({ - encoding: 'utf-8', - encode: () => { throw new Error('Encoder fail') }, - encodeInto: () => ({ read: 0, written: 0 }), - } as unknown as TextEncoder)) + const encoderSpy = vi.spyOn(globalThis, 'TextEncoder').mockImplementation( + () => + ({ + encoding: 'utf-8', + encode: () => { + throw new Error('Encoder fail') + }, + encodeInto: () => ({ read: 0, written: 0 }), + }) as unknown as TextEncoder, + ) const result = await svgToBase64('<svg>fail</svg>') expect(result).toBe('') @@ -171,7 +178,8 @@ describe('processSvgForTheme', () => { describe('Dark Theme', () => { it('should process dark theme node colors and general elements', () => { - const svg = '<rect fill="#ffffff" class="node-1"/><path stroke="#ffffff"/><rect fill="#ffffff" style="fill: #000000; stroke: #000000"/>' + const svg = + '<rect fill="#ffffff" class="node-1"/><path stroke="#ffffff"/><rect fill="#ffffff" style="fill: #000000; stroke: #000000"/>' const result = processSvgForTheme(svg, true, false, themes) expect(result).toContain('fill="#121212"') expect(result).toContain('fill="#1e293b"') // Generic rect replacement @@ -179,12 +187,13 @@ describe('processSvgForTheme', () => { }) it('should handle multiple node colors in cyclic manner', () => { - const svg = '<rect fill="#ffffff" class="node-1"/><rect fill="#ffffff" class="node-2"/><rect fill="#ffffff" class="node-3"/>' + const svg = + '<rect fill="#ffffff" class="node-1"/><rect fill="#ffffff" class="node-2"/><rect fill="#ffffff" class="node-3"/>' const result = processSvgForTheme(svg, true, false, themes) const fillMatches = result.match(FILL_HEX_RE) expect(fillMatches).toContain('fill="#121212"') expect(fillMatches).toContain('fill="#222222"') - expect(fillMatches?.filter(f => f === 'fill="#121212"').length).toBe(2) + expect(fillMatches?.filter((f) => f === 'fill="#121212"').length).toBe(2) }) it('should process handDrawn style for dark theme', () => { @@ -210,7 +219,7 @@ describe('isMermaidCodeComplete', () => { }) it('should handle validation error gracefully', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const startsWithSpy = vi.spyOn(String.prototype, 'startsWith').mockImplementation(() => { throw new Error('Start fail') }) @@ -251,9 +260,7 @@ describe('waitForDOMElement', () => { }) it('should retry on failure', async () => { - const cb = vi.fn() - .mockRejectedValueOnce(new Error('fail')) - .mockResolvedValue('success') + const cb = vi.fn().mockRejectedValueOnce(new Error('fail')).mockResolvedValue('success') const result = await waitForDOMElement(cb, 3, 10) expect(result).toBe('success') expect(cb).toHaveBeenCalledTimes(2) diff --git a/web/app/components/base/mermaid/index.stories.tsx b/web/app/components/base/mermaid/index.stories.tsx index e27a9f633dba41..591ba8ff60509a 100644 --- a/web/app/components/base/mermaid/index.stories.tsx +++ b/web/app/components/base/mermaid/index.stories.tsx @@ -11,11 +11,7 @@ flowchart LR D --> E[Send response] ` -const MermaidDemo = ({ - theme = 'light', -}: { - theme?: 'light' | 'dark' -}) => { +const MermaidDemo = ({ theme = 'light' }: { theme?: 'light' | 'dark' }) => { const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(theme) return ( @@ -25,7 +21,7 @@ const MermaidDemo = ({ <button type="button" className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover" - onClick={() => setCurrentTheme(prev => (prev === 'light' ? 'dark' : 'light'))} + onClick={() => setCurrentTheme((prev) => (prev === 'light' ? 'dark' : 'light'))} > Toggle theme </button> @@ -42,7 +38,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Mermaid renderer with custom theme toggle and caching. Useful for visualizing agent flows.', + component: + 'Mermaid renderer with custom theme toggle and caching. Useful for visualizing agent flows.', }, }, }, diff --git a/web/app/components/base/mermaid/index.tsx b/web/app/components/base/mermaid/index.tsx index 2a9224d4f13200..b7a351f73f683c 100644 --- a/web/app/components/base/mermaid/index.tsx +++ b/web/app/components/base/mermaid/index.tsx @@ -22,8 +22,7 @@ let isMermaidInitialized = false const diagramCache = new Map<string, string>() let mermaidAPI: typeof mermaid.mermaidAPI | null = null -if (typeof window !== 'undefined') - mermaidAPI = mermaid.mermaidAPI +if (typeof window !== 'undefined') mermaidAPI = mermaid.mermaidAPI // Theme configurations const THEMES = { @@ -99,8 +98,7 @@ const initMermaid = () => { } mermaid.initialize(config) isMermaidInitialized = true - } - catch (error) { + } catch (error) { console.error('Mermaid initialization error:', error) return null } @@ -134,27 +132,23 @@ const Flowchart = (props: FlowchartProps) => { if (style === 'handDrawn') { // Special handling for hand-drawn style /* v8 ignore next */ - if (containerRef.current) - containerRef.current.innerHTML = `<div id="${chartId}"></div>` - await new Promise(resolve => setTimeout(resolve, 30)) + if (containerRef.current) containerRef.current.innerHTML = `<div id="${chartId}"></div>` + await new Promise((resolve) => setTimeout(resolve, 30)) if (typeof window !== 'undefined' && mermaidAPI) { // Prefer using mermaidAPI directly for hand-drawn style return await mermaidAPI.render(chartId, code) - } - else { + } else { // Fall back to standard rendering if mermaidAPI is not available const { svg } = await mermaid.render(chartId, code) return { svg } } - } - else { + } else { // Standard rendering for classic style - using the extracted waitForDOMElement function const renderWithRetry = async () => { /* v8 ignore next */ - if (containerRef.current) - containerRef.current.innerHTML = `<div id="${chartId}"></div>` - await new Promise(resolve => setTimeout(resolve, 30)) + if (containerRef.current) containerRef.current.innerHTML = `<div id="${chartId}"></div>` + await new Promise((resolve) => setTimeout(resolve, 30)) const { svg } = await mermaid.render(chartId, code) return { svg } } @@ -173,20 +167,20 @@ const Flowchart = (props: FlowchartProps) => { diagramCache.clear() // Clear cache to prevent using potentially corrupted SVGs isMermaidInitialized = false // <-- THE FIX: Force re-initialization initMermaid() // Re-initialize with the default safe configuration - } - catch (reinitError) { + } catch (reinitError) { console.error('Failed to re-initialize Mermaid after error:', reinitError) } - setErrMsg(`Rendering failed: ${(error as Error).message || 'Unknown error. Please check the console.'}`) + setErrMsg( + `Rendering failed: ${(error as Error).message || 'Unknown error. Please check the console.'}`, + ) setIsLoading(false) } // Initialize mermaid useEffect(() => { const api = initMermaid() - if (api) - setIsInitialized(true) + if (api) setIsInitialized(true) }, []) // Update theme when prop changes, but allow internal override. @@ -206,188 +200,192 @@ const Flowchart = (props: FlowchartProps) => { prevThemeRef.current = props.theme }, [props.theme]) - const renderFlowchart = useCallback(async (primitiveCode: string) => { - /* v8 ignore next */ - if (!isInitialized || !containerRef.current) { + const renderFlowchart = useCallback( + async (primitiveCode: string) => { /* v8 ignore next */ - setIsLoading(false) - /* v8 ignore next */ - setErrMsg(!isInitialized ? 'Mermaid initialization failed' : 'Container element not found') - return - } + if (!isInitialized || !containerRef.current) { + /* v8 ignore next */ + setIsLoading(false) + /* v8 ignore next */ + setErrMsg(!isInitialized ? 'Mermaid initialization failed' : 'Container element not found') + return + } - const cacheKey = `${primitiveCode}-${look}-${currentTheme}` + const cacheKey = `${primitiveCode}-${look}-${currentTheme}` - setIsLoading(true) - setErrMsg('') + setIsLoading(true) + setErrMsg('') - try { - let finalCode: string - - const trimmedCode = primitiveCode.trim() - const isGantt = trimmedCode.startsWith('gantt') - const isMindMap = trimmedCode.startsWith('mindmap') - const isSequence = trimmedCode.startsWith('sequenceDiagram') - - if (isGantt || isMindMap || isSequence) { - if (isGantt) { - finalCode = trimmedCode - .split('\n') - .map((line) => { - // Gantt charts have specific syntax needs. - const taskMatch = /^\s*([^:]+?)\s*:\s*(.*)/.exec(line) - if (!taskMatch) - return line // Not a task line, return as is. - - const taskName = taskMatch[1]!.trim() - let paramsStr = taskMatch[2]!.trim() - - // Rule 1: Correct multiple "after" dependencies ONLY if they exist. - // This is a common mistake, e.g., "..., after task1, after task2, ..." - paramsStr = paramsStr.replace(/,\s*after\s+/g, ' ') - - // Rule 2: Normalize spacing between parameters for consistency. - const finalParams = paramsStr.replace(/\s*,\s*/g, ', ').trim() - return `${taskName} :${finalParams}` - }) - .join('\n') - } - else { - // For mindmap and sequence charts, which are sensitive to syntax, - // pass the code through directly. - finalCode = trimmedCode + try { + let finalCode: string + + const trimmedCode = primitiveCode.trim() + const isGantt = trimmedCode.startsWith('gantt') + const isMindMap = trimmedCode.startsWith('mindmap') + const isSequence = trimmedCode.startsWith('sequenceDiagram') + + if (isGantt || isMindMap || isSequence) { + if (isGantt) { + finalCode = trimmedCode + .split('\n') + .map((line) => { + // Gantt charts have specific syntax needs. + const taskMatch = /^\s*([^:]+?)\s*:\s*(.*)/.exec(line) + if (!taskMatch) return line // Not a task line, return as is. + + const taskName = taskMatch[1]!.trim() + let paramsStr = taskMatch[2]!.trim() + + // Rule 1: Correct multiple "after" dependencies ONLY if they exist. + // This is a common mistake, e.g., "..., after task1, after task2, ..." + paramsStr = paramsStr.replace(/,\s*after\s+/g, ' ') + + // Rule 2: Normalize spacing between parameters for consistency. + const finalParams = paramsStr.replace(/\s*,\s*/g, ', ').trim() + return `${taskName} :${finalParams}` + }) + .join('\n') + } else { + // For mindmap and sequence charts, which are sensitive to syntax, + // pass the code through directly. + finalCode = trimmedCode + } + } else { + // Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function + // This function handles flowcharts appropriately. + finalCode = prepareMermaidCode(primitiveCode, look) } - } - else { - // Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function - // This function handles flowcharts appropriately. - finalCode = prepareMermaidCode(primitiveCode, look) - } - finalCode = sanitizeMermaidCode(finalCode) + finalCode = sanitizeMermaidCode(finalCode) - // Step 2: Render chart - const svgGraph = await renderMermaidChart(finalCode, look) + // Step 2: Render chart + const svgGraph = await renderMermaidChart(finalCode, look) - // Step 3: Apply theme to SVG using the extracted processSvgForTheme function - const processedSvg = processSvgForTheme( - svgGraph.svg, - currentTheme === Theme.dark, - look === 'handDrawn', - THEMES, - ) + // Step 3: Apply theme to SVG using the extracted processSvgForTheme function + const processedSvg = processSvgForTheme( + svgGraph.svg, + currentTheme === Theme.dark, + look === 'handDrawn', + THEMES, + ) - // Step 4: Clean up SVG code - const cleanedSvg = cleanUpSvgCode(processedSvg) + // Step 4: Clean up SVG code + const cleanedSvg = cleanUpSvgCode(processedSvg) - diagramCache.set(cacheKey, cleanedSvg as string) - setSvgString(cleanedSvg as string) + diagramCache.set(cacheKey, cleanedSvg as string) + setSvgString(cleanedSvg as string) - setIsLoading(false) - } - catch (error) { - // Error handling - handleRenderError(error) - } - }, [chartId, isInitialized, look, currentTheme, t]) - - const configureMermaid = useCallback((primitiveCode: string) => { - if (typeof window !== 'undefined' && isInitialized) { - const themeVars = THEMES[currentTheme] - const config: MermaidConfig = { - startOnLoad: false, - securityLevel: 'strict', - fontFamily: 'sans-serif', - maxTextSize: 50000, - gantt: { - titleTopMargin: 25, - barHeight: 20, - barGap: 4, - topPadding: 50, - leftPadding: 75, - gridLineStartPadding: 35, - fontSize: 11, - numberSectionStyles: 4, - axisFormat: '%Y-%m-%d', - }, - mindmap: { - useMaxWidth: true, - padding: 10, - }, + setIsLoading(false) + } catch (error) { + // Error handling + handleRenderError(error) } + }, + [chartId, isInitialized, look, currentTheme, t], + ) - const isFlowchart = primitiveCode.trim().startsWith('graph') || primitiveCode.trim().startsWith('flowchart') - - if (look === 'classic') { - config.theme = currentTheme === 'dark' ? 'dark' : 'neutral' - - if (isFlowchart) { - type FlowchartConfigWithRanker = NonNullable<MermaidConfig['flowchart']> & { ranker?: string } - const flowchartConfig: FlowchartConfigWithRanker = { - htmlLabels: true, + const configureMermaid = useCallback( + (primitiveCode: string) => { + if (typeof window !== 'undefined' && isInitialized) { + const themeVars = THEMES[currentTheme] + const config: MermaidConfig = { + startOnLoad: false, + securityLevel: 'strict', + fontFamily: 'sans-serif', + maxTextSize: 50000, + gantt: { + titleTopMargin: 25, + barHeight: 20, + barGap: 4, + topPadding: 50, + leftPadding: 75, + gridLineStartPadding: 35, + fontSize: 11, + numberSectionStyles: 4, + axisFormat: '%Y-%m-%d', + }, + mindmap: { useMaxWidth: true, - nodeSpacing: 60, - rankSpacing: 80, - curve: 'linear', - ranker: 'tight-tree', - } - config.flowchart = flowchartConfig as unknown as MermaidConfig['flowchart'] + padding: 10, + }, } - if (currentTheme === 'dark') { - config.themeVariables = { - background: themeVars.background, - primaryColor: themeVars.primaryColor, - primaryBorderColor: themeVars.primaryBorderColor, - primaryTextColor: themeVars.primaryTextColor, - secondaryColor: themeVars.secondaryColor, - tertiaryColor: themeVars.tertiaryColor, + const isFlowchart = + primitiveCode.trim().startsWith('graph') || primitiveCode.trim().startsWith('flowchart') + + if (look === 'classic') { + config.theme = currentTheme === 'dark' ? 'dark' : 'neutral' + + if (isFlowchart) { + type FlowchartConfigWithRanker = NonNullable<MermaidConfig['flowchart']> & { + ranker?: string + } + const flowchartConfig: FlowchartConfigWithRanker = { + htmlLabels: true, + useMaxWidth: true, + nodeSpacing: 60, + rankSpacing: 80, + curve: 'linear', + ranker: 'tight-tree', + } + config.flowchart = flowchartConfig as unknown as MermaidConfig['flowchart'] } - } - } - else { // look === 'handDrawn' - config.theme = 'default' - config.themeCSS = ` + + if (currentTheme === 'dark') { + config.themeVariables = { + background: themeVars.background, + primaryColor: themeVars.primaryColor, + primaryBorderColor: themeVars.primaryBorderColor, + primaryTextColor: themeVars.primaryTextColor, + secondaryColor: themeVars.secondaryColor, + tertiaryColor: themeVars.tertiaryColor, + } + } + } else { + // look === 'handDrawn' + config.theme = 'default' + config.themeCSS = ` .node rect { fill-opacity: 0.85; } .edgePath .path { stroke-width: 1.5px; } .label { font-family: 'sans-serif'; } .edgeLabel { font-family: 'sans-serif'; } .cluster rect { rx: 5px; ry: 5px; } ` - config.themeVariables = { - fontSize: '14px', - fontFamily: 'sans-serif', - primaryBorderColor: currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor, - } + config.themeVariables = { + fontSize: '14px', + fontFamily: 'sans-serif', + primaryBorderColor: + currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor, + } - if (isFlowchart) { - config.flowchart = { - htmlLabels: true, - useMaxWidth: true, - nodeSpacing: 40, - rankSpacing: 60, - curve: 'basis', + if (isFlowchart) { + config.flowchart = { + htmlLabels: true, + useMaxWidth: true, + nodeSpacing: 40, + rankSpacing: 60, + curve: 'basis', + } } } - } - try { - mermaid.initialize(config) - return true - } - catch (error) { - console.error('Config error:', error) - return false + try { + mermaid.initialize(config) + return true + } catch (error) { + console.error('Config error:', error) + return false + } } - } - return false - }, [currentTheme, isInitialized, look]) + return false + }, + [currentTheme, isInitialized, look], + ) // This is the main rendering effect. // It triggers whenever the code, theme, or style changes. useEffect(() => { - if (!isInitialized) - return + if (!isInitialized) return // Don't render if code is too short if (!props.PrimitiveCode || props.PrimitiveCode.length < 10) { @@ -397,8 +395,7 @@ const Flowchart = (props: FlowchartProps) => { } // Use a timeout to handle streaming code and debounce rendering - if (renderTimeoutRef.current) - clearTimeout(renderTimeoutRef.current) + if (renderTimeoutRef.current) clearTimeout(renderTimeoutRef.current) setIsLoading(true) @@ -418,8 +415,7 @@ const Flowchart = (props: FlowchartProps) => { return } - if (configureMermaid(props.PrimitiveCode)) - renderFlowchart(props.PrimitiveCode) + if (configureMermaid(props.PrimitiveCode)) renderFlowchart(props.PrimitiveCode) }, 300) // 300ms debounce return () => { @@ -430,14 +426,12 @@ const Flowchart = (props: FlowchartProps) => { // Cleanup on unmount useEffect(() => { return () => { - if (renderTimeoutRef.current) - clearTimeout(renderTimeoutRef.current) + if (renderTimeoutRef.current) clearTimeout(renderTimeoutRef.current) } }, []) const handlePreviewClick = async () => { - if (!svgString) - return + if (!svgString) return const base64 = await svgToBase64(svgString) setImagePreviewUrl(base64) } @@ -472,24 +466,30 @@ const Flowchart = (props: FlowchartProps) => { 'text-gray-700': currentTheme === Theme.light, 'text-gray-300': currentTheme === Theme.dark, }), - themeToggle: cn('flex size-10 items-center justify-center rounded-full shadow-md backdrop-blur-xs transition-all duration-300', { - 'border border-gray-200 bg-white/80 text-gray-700 hover:bg-white hover:shadow-lg': currentTheme === Theme.light, - 'border border-slate-600 bg-slate-800/80 text-yellow-300 hover:bg-slate-700 hover:shadow-lg': currentTheme === Theme.dark, - }), + themeToggle: cn( + 'flex size-10 items-center justify-center rounded-full shadow-md backdrop-blur-xs transition-all duration-300', + { + 'border border-gray-200 bg-white/80 text-gray-700 hover:bg-white hover:shadow-lg': + currentTheme === Theme.light, + 'border border-slate-600 bg-slate-800/80 text-yellow-300 hover:bg-slate-700 hover:shadow-lg': + currentTheme === Theme.dark, + }, + ), } // Style classes for look options const getLookButtonClass = (lookType: 'classic' | 'handDrawn') => { return cn( 'mb-4 flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg system-sm-medium text-text-secondary', - look === lookType && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary', + look === lookType && + 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary', currentTheme === Theme.dark && 'border-slate-600 bg-slate-800 text-slate-300', look === lookType && currentTheme === Theme.dark && 'border-blue-500 bg-slate-700 text-white', ) } const themeToggleTitleByTheme = { - light: t($ => $['theme.switchDark'], { ns: 'app' }), - dark: t($ => $['theme.switchLight'], { ns: 'app' }), + light: t(($) => $['theme.switchDark'], { ns: 'app' }), + dark: t(($) => $['theme.switchLight'], { ns: 'app' }), } as const return ( @@ -508,7 +508,9 @@ const Flowchart = (props: FlowchartProps) => { } }} > - <div className="msh-segmented-item-label">{t($ => $['mermaid.classic'], { ns: 'app' })}</div> + <div className="msh-segmented-item-label"> + {t(($) => $['mermaid.classic'], { ns: 'app' })} + </div> </div> <div key="handDrawn" @@ -521,25 +523,34 @@ const Flowchart = (props: FlowchartProps) => { } }} > - <div className="msh-segmented-item-label">{t($ => $['mermaid.handDrawn'], { ns: 'app' })}</div> + <div className="msh-segmented-item-label"> + {t(($) => $['mermaid.handDrawn'], { ns: 'app' })} + </div> </div> </label> </div> </div> - <div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} /> + <div + ref={containerRef} + style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} + /> {isLoading && !svgString && ( <div className="px-[26px] py-4"> <LoadingAnim type="text" /> <div className="mt-2 text-sm text-gray-500"> - {t($ => $['mermaid.waitForCompletion'], { ns: 'app' })} + {t(($) => $['mermaid.waitForCompletion'], { ns: 'app' })} </div> </div> )} {svgString && ( - <div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={handlePreviewClick}> + <div + className={themeClasses.mermaidDiv} + style={{ objectFit: 'cover' }} + onClick={handlePreviewClick} + > <div className="absolute bottom-2 left-2"> <button type="button" @@ -551,14 +562,15 @@ const Flowchart = (props: FlowchartProps) => { title={themeToggleTitleByTheme[currentTheme] || ''} style={{ transform: 'translate3d(0, 0, 0)' }} > - {currentTheme === Theme.light ? <span className="i-heroicons-moon-solid size-5" /> : <span className="i-heroicons-sun-solid size-5" />} + {currentTheme === Theme.light ? ( + <span className="i-heroicons-moon-solid size-5" /> + ) : ( + <span className="i-heroicons-sun-solid size-5" /> + )} </button> </div> - <div - style={{ maxWidth: '100%' }} - dangerouslySetInnerHTML={{ __html: svgString }} - /> + <div style={{ maxWidth: '100%' }} dangerouslySetInnerHTML={{ __html: svgString }} /> </div> )} @@ -572,7 +584,11 @@ const Flowchart = (props: FlowchartProps) => { )} {imagePreviewUrl && ( - <ImagePreview title="mermaid_chart" url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} /> + <ImagePreview + title="mermaid_chart" + url={imagePreviewUrl} + onCancel={() => setImagePreviewUrl('')} + /> )} </div> ) diff --git a/web/app/components/base/mermaid/utils.ts b/web/app/components/base/mermaid/utils.ts index c66858ac5b869c..8acf5c71d25e21 100644 --- a/web/app/components/base/mermaid/utils.ts +++ b/web/app/components/base/mermaid/utils.ts @@ -3,8 +3,7 @@ export function cleanUpSvgCode(svgCode: string): string { } export const sanitizeMermaidCode = (mermaidCode: string): string => { - if (!mermaidCode || typeof mermaidCode !== 'string') - return '' + if (!mermaidCode || typeof mermaidCode !== 'string') return '' return mermaidCode .split('\n') @@ -12,12 +11,10 @@ export const sanitizeMermaidCode = (mermaidCode: string): string => { const trimmed = line.trimStart() // Mermaid directives can override config; treat as untrusted in chat context. - if (trimmed.startsWith('%%{')) - return false + if (trimmed.startsWith('%%{')) return false // Mermaid click directives can create JS callbacks/links inside rendered SVG. - if (trimmed.startsWith('click ')) - return false + if (trimmed.startsWith('click ')) return false return true }) @@ -31,8 +28,7 @@ export const sanitizeMermaidCode = (mermaidCode: string): string => { * @returns {string} - The prepared mermaid code */ export const prepareMermaidCode = (mermaidCode: string, style: 'classic' | 'handDrawn'): string => { - if (!mermaidCode || typeof mermaidCode !== 'string') - return '' + if (!mermaidCode || typeof mermaidCode !== 'string') return '' let code = sanitizeMermaidCode(mermaidCode.trim()) @@ -63,23 +59,22 @@ export const prepareMermaidCode = (mermaidCode: string, style: 'classic' | 'hand * Converts SVG to base64 string for image rendering */ export function svgToBase64(svgGraph: string): Promise<string> { - if (!svgGraph) - return Promise.resolve('') + if (!svgGraph) return Promise.resolve('') try { // Ensure SVG has correct XML declaration - if (!svgGraph.includes('<?xml')) - svgGraph = `<?xml version="1.0" encoding="UTF-8"?>${svgGraph}` + if (!svgGraph.includes('<?xml')) svgGraph = `<?xml version="1.0" encoding="UTF-8"?>${svgGraph}` - const blob = new Blob([new TextEncoder().encode(svgGraph)], { type: 'image/svg+xml;charset=utf-8' }) + const blob = new Blob([new TextEncoder().encode(svgGraph)], { + type: 'image/svg+xml;charset=utf-8', + }) return new Promise((resolve, reject) => { const reader = new FileReader() reader.onloadend = () => resolve(reader.result as string) reader.onerror = reject reader.readAsDataURL(blob) }) - } - catch { + } catch { return Promise.resolve('') } } @@ -109,40 +104,55 @@ export function processSvgForTheme( .replace(/fill="#[a-fA-F0-9]{6}"/g, `fill="${themes.dark.nodeColors[0].bg}"`) .replace(/stroke="#[a-fA-F0-9]{6}"/g, `stroke="${themes.dark.connectionColor}"`) .replace(/stroke-width="1"/g, 'stroke-width="1.5"') - } - else { + } else { let i = 0 const nodeColorRegex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g processedSvg = processedSvg.replace(nodeColorRegex, (match: string) => { const colorIndex = i % themes.dark.nodeColors.length i++ - return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.dark.nodeColors[colorIndex].bg}"`) + return match.replace( + /fill="#[a-fA-F0-9]{6}"/, + `fill="${themes.dark.nodeColors[colorIndex].bg}"`, + ) }) processedSvg = processedSvg - .replace(/<path [^>]*stroke="#[a-fA-F0-9]{6}"/g, `<path stroke="${themes.dark.connectionColor}" stroke-width="1.5"`) - .replace(/<(line|polyline) [^>]*stroke="#[a-fA-F0-9]{6}"/g, `<$1 stroke="${themes.dark.connectionColor}" stroke-width="1.5"`) + .replace( + /<path [^>]*stroke="#[a-fA-F0-9]{6}"/g, + `<path stroke="${themes.dark.connectionColor}" stroke-width="1.5"`, + ) + .replace( + /<(line|polyline) [^>]*stroke="#[a-fA-F0-9]{6}"/g, + `<$1 stroke="${themes.dark.connectionColor}" stroke-width="1.5"`, + ) } - } - else { + } else { if (isHandDrawn) { processedSvg = processedSvg .replace(/fill="#[a-fA-F0-9]{6}"/g, `fill="${themes.light.nodeColors[0].bg}"`) .replace(/stroke="#[a-fA-F0-9]{6}"/g, `stroke="${themes.light.connectionColor}"`) .replace(/stroke-width="1"/g, 'stroke-width="1.5"') - } - else { + } else { let i = 0 const nodeColorRegex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g processedSvg = processedSvg.replace(nodeColorRegex, (match: string) => { const colorIndex = i % themes.light.nodeColors.length i++ - return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.light.nodeColors[colorIndex].bg}"`) + return match.replace( + /fill="#[a-fA-F0-9]{6}"/, + `fill="${themes.light.nodeColors[colorIndex].bg}"`, + ) }) processedSvg = processedSvg - .replace(/<path [^>]*stroke="#[a-fA-F0-9]{6}"/g, `<path stroke="${themes.light.connectionColor}"`) - .replace(/<(line|polyline) [^>]*stroke="#[a-fA-F0-9]{6}"/g, `<$1 stroke="${themes.light.connectionColor}"`) + .replace( + /<path [^>]*stroke="#[a-fA-F0-9]{6}"/g, + `<path stroke="${themes.light.connectionColor}"`, + ) + .replace( + /<(line|polyline) [^>]*stroke="#[a-fA-F0-9]{6}"/g, + `<$1 stroke="${themes.light.connectionColor}"`, + ) } } @@ -153,8 +163,7 @@ export function processSvgForTheme( * Checks if mermaid code is complete and valid */ export function isMermaidCodeComplete(code: string): boolean { - if (!code || code.trim().length === 0) - return false + if (!code || code.trim().length === 0) return false try { const trimmedCode = code.trim() @@ -162,19 +171,22 @@ export function isMermaidCodeComplete(code: string): boolean { // Special handling for gantt charts if (trimmedCode.startsWith('gantt')) { // For gantt charts, check if it has at least a title and one task - const lines = trimmedCode.split('\n').filter(line => line.trim().length > 0) + const lines = trimmedCode.split('\n').filter((line) => line.trim().length > 0) return lines.length >= 3 } // Special handling for mindmaps if (trimmedCode.startsWith('mindmap')) { // For mindmaps, check if it has at least a root node - const lines = trimmedCode.split('\n').filter(line => line.trim().length > 0) + const lines = trimmedCode.split('\n').filter((line) => line.trim().length > 0) return lines.length >= 2 } // Check for basic syntax structure - const hasValidStart = /^(graph|flowchart|sequenceDiagram|classDiagram|classDef|class|stateDiagram|gantt|pie|er|journey|requirementDiagram|mindmap)/.test(trimmedCode) + const hasValidStart = + /^(graph|flowchart|sequenceDiagram|classDiagram|classDef|class|stateDiagram|gantt|pie|er|journey|requirementDiagram|mindmap)/.test( + trimmedCode, + ) // The balanced bracket check was too strict and produced false negatives for valid // mermaid syntax like the asymmetric shape `A>B]`. Relying on Mermaid's own @@ -182,14 +194,15 @@ export function isMermaidCodeComplete(code: string): boolean { const isBalanced = true // Check for common syntax errors - const hasNoSyntaxErrors = !trimmedCode.includes('undefined') - && !trimmedCode.includes('[object Object]') - && trimmedCode.split('\n').every(line => - !(line.includes('-->') && !/\S+\s*-->\s*\S+/.exec(line))) + const hasNoSyntaxErrors = + !trimmedCode.includes('undefined') && + !trimmedCode.includes('[object Object]') && + trimmedCode + .split('\n') + .every((line) => !(line.includes('-->') && !/\S+\s*-->\s*\S+/.exec(line))) return hasValidStart && isBalanced && hasNoSyntaxErrors - } - catch (error) { + } catch (error) { console.error('Mermaid code validation error:', error) return false } @@ -198,19 +211,20 @@ export function isMermaidCodeComplete(code: string): boolean { /** * Helper to wait for DOM element with retry mechanism */ -export function waitForDOMElement(callback: () => Promise<any>, maxAttempts = 3, delay = 100): Promise<any> { +export function waitForDOMElement( + callback: () => Promise<any>, + maxAttempts = 3, + delay = 100, +): Promise<any> { return new Promise((resolve, reject) => { let attempts = 0 const tryRender = async () => { try { resolve(await callback()) - } - catch (error) { + } catch (error) { attempts++ - if (attempts < maxAttempts) - setTimeout(tryRender, delay) - else - reject(error) + if (attempts < maxAttempts) setTimeout(tryRender, delay) + else reject(error) } } tryRender() diff --git a/web/app/components/base/message-log-modal/__tests__/index.spec.tsx b/web/app/components/base/message-log-modal/__tests__/index.spec.tsx index f7e0158896ba39..bb77c3942818ea 100644 --- a/web/app/components/base/message-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/message-log-modal/__tests__/index.spec.tsx @@ -15,7 +15,15 @@ vi.mock('@/app/components/app/store', () => ({ })) vi.mock('@/app/components/workflow/run', () => ({ - default: ({ activeTab, runDetailUrl, tracingListUrl }: { activeTab: string, runDetailUrl: string, tracingListUrl: string }) => ( + default: ({ + activeTab, + runDetailUrl, + tracingListUrl, + }: { + activeTab: string + runDetailUrl: string + tracingListUrl: string + }) => ( <div data-testid="workflow-run" data-active-tab={activeTab} @@ -39,9 +47,11 @@ describe('MessageLogModal', () => { vi.clearAllMocks() clickAwayHandler = null // eslint-disable-next-line ts/no-explicit-any - vi.mocked(useStore).mockImplementation((selector: any) => selector({ - appDetail: { id: 'app-1' }, - })) + vi.mocked(useStore).mockImplementation((selector: any) => + selector({ + appDetail: { id: 'app-1' }, + }), + ) }) describe('Render', () => { @@ -51,7 +61,13 @@ describe('MessageLogModal', () => { }) it('renders nothing if currentLogItem.workflow_run_id is missing', () => { - const { container } = render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={{ id: '1' } as IChatItem} />) + const { container } = render( + <MessageLogModal + width={800} + onCancel={onCancel} + currentLogItem={{ id: '1' } as IChatItem} + />, + ) expect(container.firstChild).toBeNull() }) @@ -64,23 +80,53 @@ describe('MessageLogModal', () => { describe('Props', () => { it('passes correct props to Run component', () => { - render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} defaultTab="TRACING" />) + render( + <MessageLogModal + width={800} + onCancel={onCancel} + currentLogItem={mockLog} + defaultTab="TRACING" + />, + ) const runComponent = screen.getByTestId('workflow-run') expect(runComponent.getAttribute('data-active-tab')).toBe('TRACING') - expect(runComponent.getAttribute('data-run-detail-url')).toBe('/apps/app-1/workflow-runs/run-1') - expect(runComponent.getAttribute('data-tracing-list-url')).toBe('/apps/app-1/workflow-runs/run-1/node-executions') + expect(runComponent.getAttribute('data-run-detail-url')).toBe( + '/apps/app-1/workflow-runs/run-1', + ) + expect(runComponent.getAttribute('data-tracing-list-url')).toBe( + '/apps/app-1/workflow-runs/run-1/node-executions', + ) }) it('sets fixed style when fixedWidth is false (floating)', () => { - const { container } = render(<MessageLogModal width={1000} onCancel={onCancel} currentLogItem={mockLog} fixedWidth={false} />) + const { container } = render( + <MessageLogModal + width={1000} + onCancel={onCancel} + currentLogItem={mockLog} + fixedWidth={false} + />, + ) const modal = screen.getByRole('dialog') expect(container).not.toContainElement(modal) expect(document.body).toContainElement(modal) - expect(modal).toHaveClass('fixed', 'z-50', 'w-[480px]!', 'left-[max(8px,calc(100vw-1136px))]!') + expect(modal).toHaveClass( + 'fixed', + 'z-50', + 'w-[480px]!', + 'left-[max(8px,calc(100vw-1136px))]!', + ) }) it('sets fixed width when fixedWidth is true', () => { - const { container } = render(<MessageLogModal width={1000} onCancel={onCancel} currentLogItem={mockLog} fixedWidth={true} />) + const { container } = render( + <MessageLogModal + width={1000} + onCancel={onCancel} + currentLogItem={mockLog} + fixedWidth={true} + />, + ) const panel = container.firstElementChild as HTMLElement expect(panel).toHaveClass('relative', 'z-10') expect(panel.style.width).toBe('1000px') @@ -97,7 +143,9 @@ describe('MessageLogModal', () => { }) it('calls onCancel when clicked away', () => { - render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} fixedWidth />) + render( + <MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} fixedWidth />, + ) expect(clickAwayHandler).toBeTruthy() clickAwayHandler!() expect(onCancel).toHaveBeenCalledTimes(1) diff --git a/web/app/components/base/message-log-modal/index.stories.tsx b/web/app/components/base/message-log-modal/index.stories.tsx index e370bd3338f697..7d6366671c75d4 100644 --- a/web/app/components/base/message-log-modal/index.stories.tsx +++ b/web/app/components/base/message-log-modal/index.stories.tsx @@ -102,28 +102,24 @@ const useMessageLogMocks = () => { const originalFetch = globalThis.fetch?.bind(globalThis) ?? null const handle = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url if (url.includes('/workflow-runs/run-demo-1/') && url.endsWith('/node-executions')) { - return new Response( - JSON.stringify(mockTracingList), - { headers: { 'Content-Type': 'application/json' }, status: 200 }, - ) + return new Response(JSON.stringify(mockTracingList), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }) } if (url.endsWith('/workflow-runs/run-demo-1')) { - return new Response( - JSON.stringify(mockRunDetail), - { headers: { 'Content-Type': 'application/json' }, status: 200 }, - ) + return new Response(JSON.stringify(mockRunDetail), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }) } - if (originalFetch) - return originalFetch(input, init) + if (originalFetch) return originalFetch(input, init) throw new Error(`Unmocked fetch call for ${url}`) } @@ -145,10 +141,7 @@ const MessageLogPreview = (props: MessageLogModalProps) => { return ( <div className="relative min-h-[640px] w-full bg-background-default-subtle p-6"> <WorkflowContextProvider> - <MessageLogModal - {...props} - currentLogItem={mockCurrentLogItem} - /> + <MessageLogModal {...props} currentLogItem={mockCurrentLogItem} /> </WorkflowContextProvider> </div> ) @@ -161,7 +154,8 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Workflow run inspector presented alongside chat transcripts. This Storybook mock provides canned run details and tracing metadata.', + component: + 'Workflow run inspector presented alongside chat transcripts. This Storybook mock provides canned run details and tracing metadata.', }, }, }, diff --git a/web/app/components/base/message-log-modal/index.tsx b/web/app/components/base/message-log-modal/index.tsx index c20123513d63bc..9a57f4e1c5312a 100644 --- a/web/app/components/base/message-log-modal/index.tsx +++ b/web/app/components/base/message-log-modal/index.tsx @@ -29,23 +29,23 @@ const MessageLogModal: FC<MessageLogModalProps> = ({ }) => { const { t } = useTranslation() const ref = useRef(null) - const appDetail = useStore(state => state.appDetail) + const appDetail = useStore((state) => state.appDetail) useClickAway(() => { - if (fixedWidth) - onCancel() + if (fixedWidth) onCancel() }, ref) - if (!currentLogItem || !currentLogItem.workflow_run_id) - return null + if (!currentLogItem || !currentLogItem.workflow_run_id) return null const activeTab = isRunActiveTab(defaultTab) ? defaultTab : 'DETAIL' const modalContent = ( <> - <DialogTitle className="shrink-0 px-4 py-1 system-xl-semibold text-text-primary">{t($ => $['runDetail.title'], { ns: 'appLog' })}</DialogTitle> + <DialogTitle className="shrink-0 px-4 py-1 system-xl-semibold text-text-primary"> + {t(($) => $['runDetail.title'], { ns: 'appLog' })} + </DialogTitle> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onCancel} > @@ -65,8 +65,7 @@ const MessageLogModal: FC<MessageLogModalProps> = ({ <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DialogContent @@ -91,10 +90,12 @@ const MessageLogModal: FC<MessageLogModalProps> = ({ }} ref={ref} > - <h1 className="shrink-0 px-4 py-1 system-xl-semibold text-text-primary">{t($ => $['runDetail.title'], { ns: 'appLog' })}</h1> + <h1 className="shrink-0 px-4 py-1 system-xl-semibold text-text-primary"> + {t(($) => $['runDetail.title'], { ns: 'appLog' })} + </h1> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onCancel} > diff --git a/web/app/components/base/new-audio-button/__tests__/index.spec.tsx b/web/app/components/base/new-audio-button/__tests__/index.spec.tsx index 44ae319ead7976..ea2f6a41a6e9f9 100644 --- a/web/app/components/base/new-audio-button/__tests__/index.spec.tsx +++ b/web/app/components/base/new-audio-button/__tests__/index.spec.tsx @@ -33,9 +33,13 @@ describe('AudioBtn', () => { const getAudioCallback = () => { const lastCall = mockGetAudioPlayer.mock.calls[mockGetAudioPlayer.mock.calls.length - 1] - const callback = lastCall?.find((arg: unknown) => typeof arg === 'function') as ((event: string) => void) | undefined + const callback = lastCall?.find((arg: unknown) => typeof arg === 'function') as + | ((event: string) => void) + | undefined if (!callback) - throw new Error('Audio callback not found - ensure mockGetAudioPlayer was called with a callback argument') + throw new Error( + 'Audio callback not found - ensure mockGetAudioPlayer was called with a callback argument', + ) return callback } @@ -49,13 +53,13 @@ describe('AudioBtn', () => { playAudio: mockPlayAudio, pauseAudio: mockPauseAudio, }) - ; (useParams as ReturnType<typeof vi.fn>).mockReturnValue({}) - ; (usePathname as ReturnType<typeof vi.fn>).mockReturnValue('/') + ;(useParams as ReturnType<typeof vi.fn>).mockReturnValue({}) + ;(usePathname as ReturnType<typeof vi.fn>).mockReturnValue('/') }) describe('URL Routing', () => { it('should generate public URL when token is present', async () => { - ; (useParams as ReturnType<typeof vi.fn>).mockReturnValue({ token: 'test-token' }) + ;(useParams as ReturnType<typeof vi.fn>).mockReturnValue({ token: 'test-token' }) render(<AudioBtn value="test" />) await userEvent.click(getButton()) @@ -66,8 +70,8 @@ describe('AudioBtn', () => { }) it('should generate app URL when appId is present', async () => { - ; (useParams as ReturnType<typeof vi.fn>).mockReturnValue({ appId: '123' }) - ; (usePathname as ReturnType<typeof vi.fn>).mockReturnValue('/apps/123/chat') + ;(useParams as ReturnType<typeof vi.fn>).mockReturnValue({ appId: '123' }) + ;(usePathname as ReturnType<typeof vi.fn>).mockReturnValue('/apps/123/chat') render(<AudioBtn value="test" />) await userEvent.click(getButton()) @@ -78,8 +82,8 @@ describe('AudioBtn', () => { }) it('should generate installed app URL correctly', async () => { - ; (useParams as ReturnType<typeof vi.fn>).mockReturnValue({ appId: '456' }) - ; (usePathname as ReturnType<typeof vi.fn>).mockReturnValue('/installed/456') + ;(useParams as ReturnType<typeof vi.fn>).mockReturnValue({ appId: '456' }) + ;(usePathname as ReturnType<typeof vi.fn>).mockReturnValue('/installed/456') render(<AudioBtn value="test" />) await userEvent.click(getButton()) diff --git a/web/app/components/base/new-audio-button/index.stories.tsx b/web/app/components/base/new-audio-button/index.stories.tsx index 44a7e2616a048f..17524625a3995d 100644 --- a/web/app/components/base/new-audio-button/index.stories.tsx +++ b/web/app/components/base/new-audio-button/index.stories.tsx @@ -27,7 +27,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Updated audio playback trigger styled with `ActionButton`. Behaves like the legacy audio button but adopts the new button design system.', + component: + 'Updated audio playback trigger styled with `ActionButton`. Behaves like the legacy audio button but adopts the new button design system.', }, }, nextjs: { @@ -58,7 +59,7 @@ export default meta type Story = StoryObj<typeof meta> export const Default: Story = { - render: args => <StoryWrapper {...args} />, + render: (args) => <StoryWrapper {...args} />, args: { id: 'message-1', value: 'Listen to the latest assistant message.', diff --git a/web/app/components/base/new-audio-button/index.tsx b/web/app/components/base/new-audio-button/index.tsx index a131d37513f4e2..bc4772d6b5e3f4 100644 --- a/web/app/components/base/new-audio-button/index.tsx +++ b/web/app/components/base/new-audio-button/index.tsx @@ -1,8 +1,6 @@ 'use client' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiVolumeUpLine, -} from '@remixicon/react' +import { RiVolumeUpLine } from '@remixicon/react' import { t } from 'i18next' import { useState } from 'react' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' @@ -18,11 +16,7 @@ type AudioBtnProps = Readonly<{ type AudioState = 'initial' | 'loading' | 'playing' | 'paused' | 'ended' -const AudioBtn = ({ - id, - voice, - value, -}: AudioBtnProps) => { +const AudioBtn = ({ id, voice, value }: AudioBtnProps) => { const [audioState, setAudioState] = useState<AudioState>('initial') const params = useParams() @@ -52,36 +46,36 @@ const AudioBtn = ({ if (params.token) { url = '/text-to-audio' isPublic = true - } - else if (params.appId) { - if (isInstalledAppPath(pathname)) - url = `/installed-apps/${params.appId}/text-to-audio` - else - url = `/apps/${params.appId}/text-to-audio` + } else if (params.appId) { + if (isInstalledAppPath(pathname)) url = `/installed-apps/${params.appId}/text-to-audio` + else url = `/apps/${params.appId}/text-to-audio` } const handleToggle = async () => { if (audioState === 'playing' || audioState === 'loading') { setTimeout(() => setAudioState('paused'), 1) - AudioPlayerManager.getInstance().getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call).pauseAudio() - } - else { + AudioPlayerManager.getInstance() + .getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call) + .pauseAudio() + } else { setTimeout(() => setAudioState('loading'), 1) - AudioPlayerManager.getInstance().getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call).playAudio() + AudioPlayerManager.getInstance() + .getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call) + .playAudio() } } const tooltipContent = { - initial: t($ => $.play, { ns: 'appApi' }), - ended: t($ => $.play, { ns: 'appApi' }), - paused: t($ => $.pause, { ns: 'appApi' }), - playing: t($ => $.playing, { ns: 'appApi' }), - loading: t($ => $.loading, { ns: 'appApi' }), + initial: t(($) => $.play, { ns: 'appApi' }), + ended: t(($) => $.play, { ns: 'appApi' }), + paused: t(($) => $.pause, { ns: 'appApi' }), + playing: t(($) => $.playing, { ns: 'appApi' }), + loading: t(($) => $.loading, { ns: 'appApi' }), }[audioState] return ( <Tooltip> <TooltipTrigger - render={( + render={ <span className="inline-flex"> <ActionButton state={ @@ -96,11 +90,9 @@ const AudioBtn = ({ <RiVolumeUpLine className="size-4" aria-hidden="true" /> </ActionButton> </span> - )} + } /> - <TooltipContent> - {tooltipContent} - </TooltipContent> + <TooltipContent>{tooltipContent}</TooltipContent> </Tooltip> ) } diff --git a/web/app/components/base/node-status/index.tsx b/web/app/components/base/node-status/index.tsx index 6b50a3470a0d02..ea200999653a35 100644 --- a/web/app/components/base/node-status/index.tsx +++ b/web/app/components/base/node-status/index.tsx @@ -12,31 +12,30 @@ export enum NodeStatusEnum { error = 'error', } -const nodeStatusVariants = cva( - 'flex items-center gap-1 rounded-md px-2 py-1 system-xs-medium', - { - variants: { - status: { - [NodeStatusEnum.warning]: 'bg-state-warning-hover text-text-warning', - [NodeStatusEnum.error]: 'bg-state-destructive-hover text-text-destructive', - }, - }, - defaultVariants: { - status: NodeStatusEnum.warning, +const nodeStatusVariants = cva('flex items-center gap-1 rounded-md px-2 py-1 system-xs-medium', { + variants: { + status: { + [NodeStatusEnum.warning]: 'bg-state-warning-hover text-text-warning', + [NodeStatusEnum.error]: 'bg-state-destructive-hover text-text-destructive', }, }, -) + defaultVariants: { + status: NodeStatusEnum.warning, + }, +}) -const StatusIconMap: Record<NodeStatusEnum, { IconComponent: React.ElementType, message: string }> = { - [NodeStatusEnum.warning]: { IconComponent: AlertTriangle, message: 'Warning' }, - [NodeStatusEnum.error]: { IconComponent: RiErrorWarningFill, message: 'Error' }, -} +const StatusIconMap: Record<NodeStatusEnum, { IconComponent: React.ElementType; message: string }> = + { + [NodeStatusEnum.warning]: { IconComponent: AlertTriangle, message: 'Warning' }, + [NodeStatusEnum.error]: { IconComponent: RiErrorWarningFill, message: 'Error' }, + } type NodeStatusProps = { message?: string styleCss?: CSSProperties iconClassName?: string -} & React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof nodeStatusVariants> +} & React.HTMLAttributes<HTMLDivElement> & + VariantProps<typeof nodeStatusVariants> const NodeStatus = ({ className, @@ -51,14 +50,8 @@ const NodeStatus = ({ const defaultMessage = StatusIconMap[status ?? NodeStatusEnum.warning].message return ( - <div - className={cn(nodeStatusVariants({ status, className }))} - style={styleCss} - {...props} - > - <Icon - className={cn('size-3.5 shrink-0', iconClassName)} - /> + <div className={cn(nodeStatusVariants({ status, className }))} style={styleCss} {...props}> + <Icon className={cn('size-3.5 shrink-0', iconClassName)} /> <span>{message ?? defaultMessage}</span> {children} </div> diff --git a/web/app/components/base/notion-connector/index.stories.tsx b/web/app/components/base/notion-connector/index.stories.tsx index d43e4a2ae60941..3a86524cc4fa04 100644 --- a/web/app/components/base/notion-connector/index.stories.tsx +++ b/web/app/components/base/notion-connector/index.stories.tsx @@ -8,7 +8,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Call-to-action card inviting users to connect a Notion workspace. Shows the product icon, copy, and primary button.', + component: + 'Call-to-action card inviting users to connect a Notion workspace. Shows the product icon, copy, and primary button.', }, }, }, diff --git a/web/app/components/base/notion-connector/index.tsx b/web/app/components/base/notion-connector/index.tsx index 14206e80c18d49..813c2dbaf950ed 100644 --- a/web/app/components/base/notion-connector/index.tsx +++ b/web/app/components/base/notion-connector/index.tsx @@ -18,12 +18,16 @@ const NotionConnector = ({ onSetting }: NotionConnectorProps) => { </div> <div className="mb-1 flex flex-col gap-y-1 pt-1 pb-3"> <span className="system-md-semibold text-text-secondary"> - {t($ => $['stepOne.notionSyncTitle'], { ns: 'datasetCreation' })} + {t(($) => $['stepOne.notionSyncTitle'], { ns: 'datasetCreation' })} <Icon3Dots className="relative -top-2.5 -left-1.5 inline size-4 text-text-secondary" /> </span> - <div className="system-sm-regular text-text-tertiary">{t($ => $['stepOne.notionSyncTip'], { ns: 'datasetCreation' })}</div> + <div className="system-sm-regular text-text-tertiary"> + {t(($) => $['stepOne.notionSyncTip'], { ns: 'datasetCreation' })} + </div> </div> - <Button variant="primary" onClick={onSetting}>{t($ => $['stepOne.connect'], { ns: 'datasetCreation' })}</Button> + <Button variant="primary" onClick={onSetting}> + {t(($) => $['stepOne.connect'], { ns: 'datasetCreation' })} + </Button> </div> ) } diff --git a/web/app/components/base/notion-icon/__tests__/index.spec.tsx b/web/app/components/base/notion-icon/__tests__/index.spec.tsx index 26a2c7640e17d7..34d8df74d81876 100644 --- a/web/app/components/base/notion-icon/__tests__/index.spec.tsx +++ b/web/app/components/base/notion-icon/__tests__/index.spec.tsx @@ -9,12 +9,18 @@ describe('Notion Icon', () => { it('renders image on http url', () => { render(<NotionIcon src="http://example.com/image.png" />) - expect(screen.getByAltText('workspace icon')).toHaveAttribute('src', 'http://example.com/image.png') + expect(screen.getByAltText('workspace icon')).toHaveAttribute( + 'src', + 'http://example.com/image.png', + ) }) it('renders image on https url', () => { render(<NotionIcon src="https://example.com/image.png" />) - expect(screen.getByAltText('workspace icon')).toHaveAttribute('src', 'https://example.com/image.png') + expect(screen.getByAltText('workspace icon')).toHaveAttribute( + 'src', + 'https://example.com/image.png', + ) }) it('renders div on non-http url', () => { @@ -28,7 +34,12 @@ describe('Notion Icon', () => { }) it('renders image on type url for page', () => { - render(<NotionIcon type="page" src={{ type: 'url', url: 'https://example.com/image.png', emoji: null }} />) + render( + <NotionIcon + type="page" + src={{ type: 'url', url: 'https://example.com/image.png', emoji: null }} + />, + ) expect(screen.getByAltText('page icon')).toHaveAttribute('src', 'https://example.com/image.png') }) diff --git a/web/app/components/base/notion-icon/index.stories.tsx b/web/app/components/base/notion-icon/index.stories.tsx index 68f400f363cfa8..f00d79fb75f685 100644 --- a/web/app/components/base/notion-icon/index.stories.tsx +++ b/web/app/components/base/notion-icon/index.stories.tsx @@ -7,7 +7,8 @@ const meta = { parameters: { docs: { description: { - component: 'Renders workspace and page icons returned from Notion APIs, falling back to text initials or the default document glyph.', + component: + 'Renders workspace and page icons returned from Notion APIs, falling back to text initials or the default document glyph.', }, }, }, @@ -23,7 +24,7 @@ export default meta type Story = StoryObj<typeof meta> export const WorkspaceIcon: Story = { - render: args => ( + render: (args) => ( <div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4"> <NotionIcon {...args} /> <span className="text-sm text-text-secondary">Workspace icon pulled from a remote URL.</span> @@ -38,18 +39,19 @@ export const WorkspaceIcon: Story = { type="workspace" name="Knowledge Base" src="https://cloud.dify.ai/logo/logo.svg" -/>` - .trim(), +/>`.trim(), }, }, }, } export const WorkspaceInitials: Story = { - render: args => ( + render: (args) => ( <div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4"> <NotionIcon {...args} src={null} name="Operations" /> - <span className="text-sm text-text-secondary">Fallback initial rendered when no icon URL is available.</span> + <span className="text-sm text-text-secondary"> + Fallback initial rendered when no icon URL is available. + </span> </div> ), parameters: { @@ -57,18 +59,19 @@ export const WorkspaceInitials: Story = { source: { language: 'tsx', code: ` -<NotionIcon type="workspace" name="Operations" src={null} />` - .trim(), +<NotionIcon type="workspace" name="Operations" src={null} />`.trim(), }, }, }, } export const PageEmoji: Story = { - render: args => ( + render: (args) => ( <div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4"> <NotionIcon {...args} type="page" src={{ type: 'emoji', emoji: '🧠', url: '' }} /> - <span className="text-sm text-text-secondary">Page-level emoji icon returned by the API.</span> + <span className="text-sm text-text-secondary"> + Page-level emoji icon returned by the API. + </span> </div> ), parameters: { @@ -76,20 +79,23 @@ export const PageEmoji: Story = { source: { language: 'tsx', code: ` -<NotionIcon type="page" src={{ type: 'emoji', emoji: '🧠' }} />` - .trim(), +<NotionIcon type="page" src={{ type: 'emoji', emoji: '🧠' }} />`.trim(), }, }, }, } export const PageImage: Story = { - render: args => ( + render: (args) => ( <div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4"> <NotionIcon {...args} type="page" - src={{ type: 'url', url: 'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=80&q=60', emoji: '' }} + src={{ + type: 'url', + url: 'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=80&q=60', + emoji: '', + }} /> <span className="text-sm text-text-secondary">Page icon resolved from an image URL.</span> </div> @@ -102,18 +108,19 @@ export const PageImage: Story = { <NotionIcon type="page" src={{ type: 'url', url: 'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=80&q=60' }} -/>` - .trim(), +/>`.trim(), }, }, }, } export const DefaultIcon: Story = { - render: args => ( + render: (args) => ( <div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4"> <NotionIcon {...args} type="page" src={undefined} /> - <span className="text-sm text-text-secondary">When neither emoji nor URL is provided, the generic document icon is shown.</span> + <span className="text-sm text-text-secondary"> + When neither emoji nor URL is provided, the generic document icon is shown. + </span> </div> ), parameters: { @@ -121,8 +128,7 @@ export const DefaultIcon: Story = { source: { language: 'tsx', code: ` -<NotionIcon type="page" src={undefined} />` - .trim(), +<NotionIcon type="page" src={undefined} />`.trim(), }, }, }, diff --git a/web/app/components/base/notion-icon/index.tsx b/web/app/components/base/notion-icon/index.tsx index 2ed73ccf139aa3..17f68590060334 100644 --- a/web/app/components/base/notion-icon/index.tsx +++ b/web/app/components/base/notion-icon/index.tsx @@ -9,12 +9,7 @@ type NotionIconProps = { className?: string src?: string | null | DataSourceNotionPage['page_icon'] } -const NotionIcon = ({ - type = 'workspace', - src, - name, - className, -}: NotionIconProps) => { +const NotionIcon = ({ type = 'workspace', src, name, className }: NotionIconProps) => { if (type === 'workspace') { if (typeof src === 'string') { if (src.startsWith('https://') || src.startsWith('http://')) { @@ -26,12 +21,17 @@ const NotionIcon = ({ /> ) } - return ( - <div className={cn('flex size-5 items-center justify-center', className)}>{src}</div> - ) + return <div className={cn('flex size-5 items-center justify-center', className)}>{src}</div> } return ( - <div className={cn('flex size-5 items-center justify-center rounded-sm bg-gray-200 text-xs font-medium text-gray-500', className)}>{name?.[0]!.toLocaleUpperCase()}</div> + <div + className={cn( + 'flex size-5 items-center justify-center rounded-sm bg-gray-200 text-xs font-medium text-gray-500', + className, + )} + > + {name?.[0]!.toLocaleUpperCase()} + </div> ) } @@ -50,9 +50,7 @@ const NotionIcon = ({ ) } - return ( - <RiFileTextLine className={cn('size-5 text-text-tertiary', className)} /> - ) + return <RiFileTextLine className={cn('size-5 text-text-tertiary', className)} /> } export default NotionIcon diff --git a/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx b/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx index ec736c527dc822..1c2ea7db0fe36e 100644 --- a/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx +++ b/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx @@ -6,7 +6,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' import { useModalContext, useModalContextSelector } from '@/context/modal-context' -import { useInvalidPreImportNotionPages, usePreImportNotionPages } from '@/service/knowledge/use-import' +import { + useInvalidPreImportNotionPages, + usePreImportNotionPages, +} from '@/service/knowledge/use-import' import NotionPageSelector from '../base' vi.mock('@tanstack/react-virtual') @@ -48,9 +51,30 @@ const mockNotionWorkspaces: DataSourceNotionWorkspace[] = [ workspace_icon: '', workspace_name: 'Workspace 1', pages: [ - { page_id: 'root-1', page_name: 'Root 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false }, - { page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1', page_icon: null, type: 'page', is_bound: false }, - { page_id: 'bound-1', page_name: 'Bound 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: true }, + { + page_id: 'root-1', + page_name: 'Root 1', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, + { + page_id: 'child-1', + page_name: 'Child 1', + parent_id: 'root-1', + page_icon: null, + type: 'page', + is_bound: false, + }, + { + page_id: 'bound-1', + page_name: 'Bound 1', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: true, + }, ], }, { @@ -58,7 +82,14 @@ const mockNotionWorkspaces: DataSourceNotionWorkspace[] = [ workspace_icon: '', workspace_name: 'Workspace 2', pages: [ - { page_id: 'external-1', page_name: 'External 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false }, + { + page_id: 'external-1', + page_name: 'External 1', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, ], }, ] @@ -89,7 +120,9 @@ describe('NotionPageSelector Base', () => { } as unknown as ReturnType<typeof useModalContext>) vi.mocked(useModalContextSelector).mockImplementation((selector) => { // Execute the selector to get branch/func coverage for the inline function - selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal } as unknown as Parameters<Parameters<typeof useModalContextSelector>[0]>[0]) + selector({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + } as unknown as Parameters<Parameters<typeof useModalContextSelector>[0]>[0]) return mockSetShowAccountSettingModal }) vi.mocked(useInvalidPreImportNotionPages).mockReturnValue(mockInvalidPreImportNotionPages) @@ -112,7 +145,9 @@ describe('NotionPageSelector Base', () => { const connectButton = screen.getByRole('button', { name: 'datasetCreation.stepOne.connect' }) await user.click(connectButton) - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, + }) }) it('should render page selector and allow selecting a page tree', async () => { @@ -128,11 +163,13 @@ describe('NotionPageSelector Base', () => { await user.click(checkbox) expect(handleSelect).toHaveBeenCalled() - expect(handleSelect).toHaveBeenLastCalledWith(expect.arrayContaining([ - expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }), - expect.objectContaining({ page_id: 'child-1', workspace_id: 'w1' }), - expect.objectContaining({ page_id: 'bound-1', workspace_id: 'w1' }), - ])) + expect(handleSelect).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }), + expect.objectContaining({ page_id: 'child-1', workspace_id: 'w1' }), + expect.objectContaining({ page_id: 'bound-1', workspace_id: 'w1' }), + ]), + ) }) it('should keep bound pages disabled and selected by default', async () => { @@ -179,7 +216,10 @@ describe('NotionPageSelector Base', () => { const item2 = screen.getByTestId('notion-credential-item-c2') await user.click(item2) - expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-1', credentialId: 'c2' }) + expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ + datasetId: 'dataset-1', + credentialId: 'c2', + }) expect(handleSelect).toHaveBeenCalledWith([]) expect(onSelectCredential).toHaveBeenLastCalledWith('c2') }) @@ -189,8 +229,12 @@ describe('NotionPageSelector Base', () => { const user = userEvent.setup() render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />) - await user.click(screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' })) - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) + await user.click( + screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' }), + ) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, + }) }) it('should preview a page and call onPreview when callback is provided', async () => { @@ -208,7 +252,9 @@ describe('NotionPageSelector Base', () => { const previewBtn = screen.getByTestId('notion-page-preview-root-1') await user.click(previewBtn) - expect(onPreview).toHaveBeenCalledWith(expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' })) + expect(onPreview).toHaveBeenCalledWith( + expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }), + ) }) it('should handle preview click without onPreview callback', async () => { @@ -256,7 +302,10 @@ describe('NotionPageSelector Base', () => { ) await waitFor(() => { - expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-fallback', credentialId: 'c3' }) + expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ + datasetId: 'dataset-fallback', + credentialId: 'c3', + }) expect(onSelect).toHaveBeenCalledWith([]) expect(onSelectCredential).toHaveBeenLastCalledWith('c3') }) @@ -265,17 +314,32 @@ describe('NotionPageSelector Base', () => { it('should update selected page state when controlled value changes', () => { vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) const { rerender } = render( - <NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} value={['root-1']} />, + <NotionPageSelector + credentialList={mockCredentialList} + onSelect={vi.fn()} + value={['root-1']} + />, ) expect(screen.getByRole('checkbox', { name: 'Root 1' })).toHaveAttribute('aria-checked', 'true') - rerender(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} value={[]} />) - expect(screen.getByRole('checkbox', { name: 'Root 1' })).toHaveAttribute('aria-checked', 'false') + rerender( + <NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} value={[]} />, + ) + expect(screen.getByRole('checkbox', { name: 'Root 1' })).toHaveAttribute( + 'aria-checked', + 'false', + ) }) it('should hide preview actions when canPreview is false', () => { vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) - render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} canPreview={false} />) + render( + <NotionPageSelector + credentialList={mockCredentialList} + onSelect={vi.fn()} + canPreview={false} + />, + ) expect(screen.queryByTestId('notion-page-preview-root-1')).not.toBeInTheDocument() }) @@ -315,10 +379,7 @@ describe('NotionPageSelector Base', () => { it('should run credential effect fallback when onSelectCredential is not provided', () => { vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) const { rerender } = render( - <NotionPageSelector - credentialList={mockCredentialList} - onSelect={vi.fn()} - />, + <NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />, ) // Rerender with a new credentialList but same credential to hit the else block without onSelectCredential diff --git a/web/app/components/base/notion-page-selector/base.tsx b/web/app/components/base/notion-page-selector/base.tsx index 9a66ed1b5ebdb4..18b51120dca2e8 100644 --- a/web/app/components/base/notion-page-selector/base.tsx +++ b/web/app/components/base/notion-page-selector/base.tsx @@ -1,11 +1,18 @@ import type { DataSourceCredential } from '../../header/account-setting/data-source-page-new/types' import type { NotionCredential } from './credential-selector' -import type { DataSourceNotionPageMap, DataSourceNotionWorkspace, NotionPage } from '@/models/common' +import type { + DataSourceNotionPageMap, + DataSourceNotionWorkspace, + NotionPage, +} from '@/models/common' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting' -import { useInvalidPreImportNotionPages, usePreImportNotionPages } from '@/service/knowledge/use-import' +import { + useInvalidPreImportNotionPages, + usePreImportNotionPages, +} from '@/service/knowledge/use-import' import Header from '../../datasets/create/website/base/header' import Loading from '../loading' import NotionConnector from '../notion-connector' @@ -50,9 +57,15 @@ const NotionPageSelector = ({ } }) }, [credentialList]) - const [selectedCredentialId, setSelectedCredentialId] = useState(() => notionCredentials[0]?.credentialId ?? '') + const [selectedCredentialId, setSelectedCredentialId] = useState( + () => notionCredentials[0]?.credentialId ?? '', + ) const currentCredential = useMemo(() => { - return notionCredentials.find(item => item.credentialId === selectedCredentialId) ?? notionCredentials[0] ?? null + return ( + notionCredentials.find((item) => item.credentialId === selectedCredentialId) ?? + notionCredentials[0] ?? + null + ) }, [notionCredentials, selectedCredentialId]) const currentCredentialId = currentCredential?.credentialId ?? '' @@ -66,12 +79,18 @@ const NotionPageSelector = ({ return } - if (!selectedCredentialId || selectedCredentialId === currentCredentialId) - return + if (!selectedCredentialId || selectedCredentialId === currentCredentialId) return invalidPreImportNotionPages({ datasetId, credentialId: currentCredentialId }) onSelect([]) - }, [currentCredentialId, datasetId, invalidPreImportNotionPages, notionCredentials.length, onSelect, selectedCredentialId]) + }, [ + currentCredentialId, + datasetId, + invalidPreImportNotionPages, + notionCredentials.length, + onSelect, + selectedCredentialId, + ]) const { data: notionsPages, @@ -79,26 +98,30 @@ const NotionPageSelector = ({ isError: isFetchingNotionPagesError, } = usePreImportNotionPages({ datasetId, credentialId: currentCredentialId }) - const pagesMapAndSelectedPagesId: [DataSourceNotionPageMap, Set<string>, Set<string>] = useMemo(() => { - const selectedPagesId = new Set<string>() - const boundPagesId = new Set<string>() - const notionWorkspaces = notionsPages?.notion_info || [] - const pagesMap = notionWorkspaces.reduce((prev: DataSourceNotionPageMap, cur: DataSourceNotionWorkspace) => { - cur.pages.forEach((page) => { - if (page.is_bound) { - selectedPagesId.add(page.page_id) - boundPagesId.add(page.page_id) - } - prev[page.page_id] = { - ...page, - workspace_id: cur.workspace_id, - } - }) - - return prev - }, {}) - return [pagesMap, selectedPagesId, boundPagesId] - }, [notionsPages?.notion_info]) + const pagesMapAndSelectedPagesId: [DataSourceNotionPageMap, Set<string>, Set<string>] = + useMemo(() => { + const selectedPagesId = new Set<string>() + const boundPagesId = new Set<string>() + const notionWorkspaces = notionsPages?.notion_info || [] + const pagesMap = notionWorkspaces.reduce( + (prev: DataSourceNotionPageMap, cur: DataSourceNotionWorkspace) => { + cur.pages.forEach((page) => { + if (page.is_bound) { + selectedPagesId.add(page.page_id) + boundPagesId.add(page.page_id) + } + prev[page.page_id] = { + ...page, + workspace_id: cur.workspace_id, + } + }) + + return prev + }, + {}, + ) + return [pagesMap, selectedPagesId, boundPagesId] + }, [notionsPages?.notion_info]) const defaultSelectedPagesId = useMemo(() => { return [...Array.from(pagesMapAndSelectedPagesId[1]), ...(value || [])] @@ -109,45 +132,50 @@ const NotionPageSelector = ({ setSearchValue(value) }, []) - const handleSelectCredential = useCallback((credentialId: string) => { - if (credentialId === currentCredentialId) - return + const handleSelectCredential = useCallback( + (credentialId: string) => { + if (credentialId === currentCredentialId) return - invalidPreImportNotionPages({ datasetId, credentialId }) - setSelectedCredentialId(credentialId) - onSelect([]) // Clear selected pages when changing credential - }, [currentCredentialId, datasetId, invalidPreImportNotionPages, onSelect]) + invalidPreImportNotionPages({ datasetId, credentialId }) + setSelectedCredentialId(credentialId) + onSelect([]) // Clear selected pages when changing credential + }, + [currentCredentialId, datasetId, invalidPreImportNotionPages, onSelect], + ) - const handleSelectPages = useCallback((newSelectedPagesId: Set<string>) => { - const selectedPages = Array.from(newSelectedPagesId).map(pageId => pagesMapAndSelectedPagesId[0][pageId]!) + const handleSelectPages = useCallback( + (newSelectedPagesId: Set<string>) => { + const selectedPages = Array.from(newSelectedPagesId).map( + (pageId) => pagesMapAndSelectedPagesId[0][pageId]!, + ) - onSelect(selectedPages) - }, [pagesMapAndSelectedPagesId, onSelect]) + onSelect(selectedPages) + }, + [pagesMapAndSelectedPagesId, onSelect], + ) - const handlePreviewPage = useCallback((previewPageId: string) => { - if (onPreview) - onPreview(pagesMapAndSelectedPagesId[0][previewPageId]!) - }, [pagesMapAndSelectedPagesId, onPreview]) + const handlePreviewPage = useCallback( + (previewPageId: string) => { + if (onPreview) onPreview(pagesMapAndSelectedPagesId[0][previewPageId]!) + }, + [pagesMapAndSelectedPagesId, onPreview], + ) const handleConfigureNotion = useCallback(() => { openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) }, [openIntegrationsSetting]) if (isFetchingNotionPagesError) { - return ( - <NotionConnector - onSetting={handleConfigureNotion} - /> - ) + return <NotionConnector onSetting={handleConfigureNotion} /> } return ( <div className="flex flex-col gap-y-2" data-testid="notion-page-selector-base"> <Header onClickConfiguration={handleConfigureNotion} - title={t($ => $['dataSource.notion.selector.headerTitle'], { ns: 'common' })} - buttonText={t($ => $['dataSource.notion.selector.configure'], { ns: 'common' })} - docTitle={t($ => $['dataSource.notion.selector.docs'], { ns: 'common' })} + title={t(($) => $['dataSource.notion.selector.headerTitle'], { ns: 'common' })} + buttonText={t(($) => $['dataSource.notion.selector.configure'], { ns: 'common' })} + docTitle={t(($) => $['dataSource.notion.selector.docs'], { ns: 'common' })} docLink="https://www.notion.so/docs" /> <div className="rounded-xl border border-components-panel-border bg-background-default-subtle"> @@ -159,32 +187,30 @@ const NotionPageSelector = ({ onSelect={handleSelectCredential} /> </div> - <SearchInput - value={searchValue} - onChange={handleSearchValueChange} - /> + <SearchInput value={searchValue} onChange={handleSearchValueChange} /> </div> <div className="overflow-hidden rounded-b-xl"> - {isFetchingNotionPages - ? ( - <div className="flex h-[296px] items-center justify-center" data-testid="notion-page-selector-loading"> - <Loading /> - </div> - ) - : ( - <PageSelector - key={currentCredentialId || 'default'} - value={selectedPagesId} - disabledValue={pagesMapAndSelectedPagesId[2]} - searchValue={searchValue} - list={notionsPages!.notion_info?.[0]!.pages || []} - pagesMap={pagesMapAndSelectedPagesId[0]} - onSelect={handleSelectPages} - canPreview={canPreview} - previewPageId={previewPageId} - onPreview={handlePreviewPage} - /> - )} + {isFetchingNotionPages ? ( + <div + className="flex h-[296px] items-center justify-center" + data-testid="notion-page-selector-loading" + > + <Loading /> + </div> + ) : ( + <PageSelector + key={currentCredentialId || 'default'} + value={selectedPagesId} + disabledValue={pagesMapAndSelectedPagesId[2]} + searchValue={searchValue} + list={notionsPages!.notion_info?.[0]!.pages || []} + pagesMap={pagesMapAndSelectedPagesId[0]} + onSelect={handleSelectPages} + canPreview={canPreview} + previewPageId={previewPageId} + onPreview={handlePreviewPage} + /> + )} </div> </div> </div> diff --git a/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx b/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx index 5ad5f63840b677..d8b36b7bbff136 100644 --- a/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx +++ b/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx @@ -25,7 +25,9 @@ describe('CredentialSelector', () => { it('should render current workspace name', () => { render(<CredentialSelector value="1" items={mockItems} onSelect={vi.fn()} />) - expect(screen.getByTestId('notion-credential-selector-name')).toHaveTextContent('Notion Workspace 1') + expect(screen.getByTestId('notion-credential-selector-name')).toHaveTextContent( + 'Notion Workspace 1', + ) }) it('should show all workspaces when menu is clicked', async () => { @@ -62,6 +64,8 @@ describe('CredentialSelector', () => { ] render(<CredentialSelector value="1" items={itemsWithoutWorkspaceName} onSelect={vi.fn()} />) - expect(screen.getByTestId('notion-credential-selector-name')).toHaveTextContent('Credential Name 1') + expect(screen.getByTestId('notion-credential-selector-name')).toHaveTextContent( + 'Credential Name 1', + ) }) }) diff --git a/web/app/components/base/notion-page-selector/credential-selector/index.tsx b/web/app/components/base/notion-page-selector/credential-selector/index.tsx index 6820720c076b4f..97e2ce4166bc8e 100644 --- a/web/app/components/base/notion-page-selector/credential-selector/index.tsx +++ b/web/app/components/base/notion-page-selector/credential-selector/index.tsx @@ -26,20 +26,16 @@ const getDisplayName = (item?: NotionCredential) => { return item?.workspaceName || item?.credentialName || '' } -const CredentialSelector = ({ - value, - items, - onSelect, -}: CredentialSelectorProps) => { - const currentCredential = items.find(item => item.credentialId === value) ?? items[0] +const CredentialSelector = ({ value, items, onSelect }: CredentialSelectorProps) => { + const currentCredential = items.find((item) => item.credentialId === value) ?? items[0] const currentDisplayName = getDisplayName(currentCredential) return ( - <Select value={currentCredential?.credentialId ?? null} onValueChange={nextValue => nextValue && onSelect(nextValue)}> - <SelectTrigger - aria-label={currentDisplayName} - className="w-[168px]" - > + <Select + value={currentCredential?.credentialId ?? null} + onValueChange={(nextValue) => nextValue && onSelect(nextValue)} + > + <SelectTrigger aria-label={currentDisplayName} className="w-[168px]"> <span className="flex min-w-0 items-center"> <CredentialIcon className="mr-2 shrink-0" @@ -72,9 +68,7 @@ const CredentialSelector = ({ name={displayName} size={20} /> - <SelectItemText title={displayName}> - {displayName} - </SelectItemText> + <SelectItemText title={displayName}>{displayName}</SelectItemText> <SelectItemIndicator /> </SelectItem> ) diff --git a/web/app/components/base/notion-page-selector/index.stories.tsx b/web/app/components/base/notion-page-selector/index.stories.tsx index 71bd7201a88fac..30457bdd8d1d9f 100644 --- a/web/app/components/base/notion-page-selector/index.stories.tsx +++ b/web/app/components/base/notion-page-selector/index.stories.tsx @@ -101,20 +101,24 @@ type NotionApiResponse = typeof marketingPages const emptyNotionResponse: NotionApiResponse = { notion_info: [] } const useMockNotionApi = () => { - const responseMap = useMemo(() => ({ - [`${DATASET_ID}:cred-1`]: marketingPages, - [`${DATASET_ID}:cred-2`]: productPages, - }) satisfies Record<`${typeof DATASET_ID}:${typeof CREDENTIALS[number]['id']}`, NotionApiResponse>, []) + const responseMap = useMemo( + () => + ({ + [`${DATASET_ID}:cred-1`]: marketingPages, + [`${DATASET_ID}:cred-2`]: productPages, + }) satisfies Record< + `${typeof DATASET_ID}:${(typeof CREDENTIALS)[number]['id']}`, + NotionApiResponse + >, + [], + ) useEffect(() => { const originalFetch = globalThis.fetch?.bind(globalThis) const handler = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url if (url.includes('/notion/pre-import/pages')) { const parsed = new URL(url, globalThis.location.origin) @@ -123,21 +127,20 @@ const useMockNotionApi = () => { let payload: NotionApiResponse = emptyNotionResponse if (datasetId === DATASET_ID) { - const credential = CREDENTIALS.find(item => item.id === credentialId) + const credential = CREDENTIALS.find((item) => item.id === credentialId) if (credential) { const mapKey = `${DATASET_ID}:${credential.id}` as keyof typeof responseMap payload = responseMap[mapKey] } } - return new Response( - JSON.stringify(payload), - { headers: { 'Content-Type': 'application/json' }, status: 200 }, - ) + return new Response(JSON.stringify(payload), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }) } - if (originalFetch) - return originalFetch(input, init) + if (originalFetch) return originalFetch(input, init) throw new Error(`Unmocked fetch call for ${url}`) } @@ -145,8 +148,7 @@ const useMockNotionApi = () => { globalThis.fetch = handler as typeof globalThis.fetch return () => { - if (originalFetch) - globalThis.fetch = originalFetch + if (originalFetch) globalThis.fetch = originalFetch } }, [responseMap]) } @@ -162,7 +164,7 @@ const NotionSelectorPreview = () => { <NotionPageSelector datasetId={DATASET_ID} credentialList={CREDENTIALS} - value={selectedPages.map(page => page.page_id)} + value={selectedPages.map((page) => page.page_id)} onSelect={setSelectedPages} onSelectCredential={setCredentialId} canPreview @@ -190,7 +192,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Credential-aware selector that fetches Notion pages and lets users choose which ones to sync.', + component: + 'Credential-aware selector that fetches Notion pages and lets users choose which ones to sync.', }, }, }, diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/index.spec.tsx b/web/app/components/base/notion-page-selector/page-selector/__tests__/index.spec.tsx index 8ceb6d73b60624..4a2fb6e57a255f 100644 --- a/web/app/components/base/notion-page-selector/page-selector/__tests__/index.spec.tsx +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/index.spec.tsx @@ -37,7 +37,16 @@ describe('PageSelector', () => { }) it('should render root level pages initially', () => { - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + />, + ) expect(screen.getByText('Root 1'))!.toBeInTheDocument() expect(screen.queryByText('Child 1')).not.toBeInTheDocument() @@ -45,7 +54,16 @@ describe('PageSelector', () => { it('should expand child pages when toggle is clicked', async () => { const user = userEvent.setup() - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + />, + ) const toggle = screen.getByTestId('notion-page-toggle-root-1') await user.click(toggle) @@ -56,7 +74,16 @@ describe('PageSelector', () => { it('should call onSelect with descendants when parent is selected', async () => { const handleSelect = vi.fn() const user = userEvent.setup() - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={[mockList[0]!, mockList[1]!, mockList[2]!]} onSelect={handleSelect} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={[mockList[0]!, mockList[1]!, mockList[2]!]} + onSelect={handleSelect} + />, + ) const checkbox = screen.getByRole('checkbox', { name: 'Root 1' }) await user.click(checkbox) @@ -67,7 +94,16 @@ describe('PageSelector', () => { it('should call onSelect with empty set when parent is deselected', async () => { const handleSelect = vi.fn() const user = userEvent.setup() - render(<PageSelector value={new Set(['root-1', 'child-1', 'grandchild-1'])} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />) + render( + <PageSelector + value={new Set(['root-1', 'child-1', 'grandchild-1'])} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={handleSelect} + />, + ) const checkbox = screen.getByRole('checkbox', { name: 'Root 1' }) await user.click(checkbox) @@ -76,7 +112,16 @@ describe('PageSelector', () => { }) it('should show breadcrumbs when searching', () => { - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="Grandchild" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="Grandchild" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + />, + ) expect(screen.getByText('Root 1 / Child 1 / Grandchild 1'))!.toBeInTheDocument() }) @@ -84,7 +129,17 @@ describe('PageSelector', () => { it('should call onPreview when preview button is clicked', async () => { const handlePreview = vi.fn() const user = userEvent.setup() - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} onPreview={handlePreview} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + onPreview={handlePreview} + />, + ) const previewBtn = screen.getByTestId('notion-page-preview-root-1') await user.click(previewBtn) @@ -93,15 +148,35 @@ describe('PageSelector', () => { }) it('should show no result message when search returns nothing', () => { - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="nonexistent" pagesMap={mockPagesMap} list={[]} onSelect={vi.fn()} />) - - expect(screen.getByText('common.dataSource.notion.selector.noSearchResult'))!.toBeInTheDocument() + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="nonexistent" + pagesMap={mockPagesMap} + list={[]} + onSelect={vi.fn()} + />, + ) + + expect( + screen.getByText('common.dataSource.notion.selector.noSearchResult'), + )!.toBeInTheDocument() }) it('should handle selection when searchValue is present', async () => { const handleSelect = vi.fn() const user = userEvent.setup() - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="Child" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="Child" + pagesMap={mockPagesMap} + list={mockList} + onSelect={handleSelect} + />, + ) const checkbox = screen.getByRole('checkbox', { name: 'Child 1' }) await user.click(checkbox) @@ -111,7 +186,16 @@ describe('PageSelector', () => { it('should handle preview when onPreview is not provided', async () => { const user = userEvent.setup() - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + />, + ) const previewBtn = screen.getByTestId('notion-page-preview-root-1') await user.click(previewBtn) @@ -120,7 +204,16 @@ describe('PageSelector', () => { it('should handle toggle when item is already expanded', async () => { const user = userEvent.setup() - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + />, + ) const toggleBtn = screen.getByTestId('notion-page-toggle-root-1') await user.click(toggleBtn) // Expand @@ -133,7 +226,16 @@ describe('PageSelector', () => { it('should disable checkbox when page is in disabledValue', async () => { const handleSelect = vi.fn() const user = userEvent.setup() - render(<PageSelector value={new Set()} disabledValue={new Set(['root-1'])} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set(['root-1'])} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={handleSelect} + />, + ) const checkbox = screen.getByRole('checkbox', { name: 'Root 1' }) await user.click(checkbox) @@ -141,24 +243,64 @@ describe('PageSelector', () => { }) it('should not render preview button when canPreview is false', () => { - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} canPreview={false} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + canPreview={false} + />, + ) expect(screen.queryByTestId('notion-page-preview-root-1')).not.toBeInTheDocument() }) it('should render preview button when canPreview is true', () => { - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} canPreview={true} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + canPreview={true} + />, + ) expect(screen.getByTestId('notion-page-preview-root-1'))!.toBeInTheDocument() }) it('should use previewPageId prop when provided', () => { - const { rerender } = render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} previewPageId="root-1" />) + const { rerender } = render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + previewPageId="root-1" + />, + ) let row = screen.getByTestId('notion-page-row-root-1') expect(row)!.toHaveClass('bg-state-base-hover') - rerender(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} previewPageId="root-2" />) + rerender( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + previewPageId="root-2" + />, + ) row = screen.getByTestId('notion-page-row-root-1') expect(row).not.toHaveClass('bg-state-base-hover') @@ -167,7 +309,16 @@ describe('PageSelector', () => { it('should handle selection of multiple pages independently when searching', async () => { const handleSelect = vi.fn() const user = userEvent.setup() - const { rerender } = render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="Child" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />) + const { rerender } = render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="Child" + pagesMap={mockPagesMap} + list={mockList} + onSelect={handleSelect} + />, + ) const checkbox1 = screen.getByRole('checkbox', { name: 'Child 1' }) const checkbox2 = screen.getByRole('checkbox', { name: 'Child 2' }) @@ -176,7 +327,16 @@ describe('PageSelector', () => { expect(handleSelect).toHaveBeenCalledWith(new Set(['child-1'])) // Simulate parent component updating the value prop - rerender(<PageSelector value={new Set(['child-1'])} disabledValue={new Set()} searchValue="Child" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />) + rerender( + <PageSelector + value={new Set(['child-1'])} + disabledValue={new Set()} + searchValue="Child" + pagesMap={mockPagesMap} + list={mockList} + onSelect={handleSelect} + />, + ) await user.click(checkbox2) expect(handleSelect).toHaveBeenLastCalledWith(new Set(['child-1', 'child-2'])) @@ -184,7 +344,16 @@ describe('PageSelector', () => { it('should expand and show all children when parent is selected', async () => { const user = userEvent.setup() - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + />, + ) const toggle = screen.getByTestId('notion-page-toggle-root-1') await user.click(toggle) @@ -197,7 +366,16 @@ describe('PageSelector', () => { it('should expand nested children when toggling parent', async () => { const user = userEvent.setup() - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + />, + ) // Expand root-1 let toggle = screen.getByTestId('notion-page-toggle-root-1') @@ -217,7 +395,16 @@ describe('PageSelector', () => { it('should deselect all descendants when parent is deselected with descendants', async () => { const handleSelect = vi.fn() const user = userEvent.setup() - render(<PageSelector value={new Set(['root-1', 'child-1', 'grandchild-1', 'child-2'])} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />) + render( + <PageSelector + value={new Set(['root-1', 'child-1', 'grandchild-1', 'child-2'])} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={handleSelect} + />, + ) const checkbox = screen.getByRole('checkbox', { name: 'Root 1' }) await user.click(checkbox) @@ -228,7 +415,16 @@ describe('PageSelector', () => { it('should only select the item when searching (no descendants)', async () => { const handleSelect = vi.fn() const user = userEvent.setup() - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="Child" pagesMap={mockPagesMap} list={[mockList[1]!]} onSelect={handleSelect} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="Child" + pagesMap={mockPagesMap} + list={[mockList[1]!]} + onSelect={handleSelect} + />, + ) const checkbox = screen.getByRole('checkbox', { name: 'Child 1' }) await user.click(checkbox) @@ -240,7 +436,16 @@ describe('PageSelector', () => { it('should deselect only the item when searching (no descendants)', async () => { const handleSelect = vi.fn() const user = userEvent.setup() - render(<PageSelector value={new Set(['child-1'])} disabledValue={new Set()} searchValue="Child" pagesMap={mockPagesMap} list={[mockList[1]!]} onSelect={handleSelect} />) + render( + <PageSelector + value={new Set(['child-1'])} + disabledValue={new Set()} + searchValue="Child" + pagesMap={mockPagesMap} + list={[mockList[1]!]} + onSelect={handleSelect} + />, + ) const checkbox = screen.getByRole('checkbox', { name: 'Child 1' }) await user.click(checkbox) @@ -249,7 +454,16 @@ describe('PageSelector', () => { }) it('should handle multiple root pages', async () => { - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + />, + ) expect(screen.getByText('Root 1'))!.toBeInTheDocument() expect(screen.getByText('Root 2'))!.toBeInTheDocument() @@ -258,7 +472,18 @@ describe('PageSelector', () => { it('should update preview when clicking preview button with onPreview provided', async () => { const handlePreview = vi.fn() const user = userEvent.setup() - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} canPreview={true} onPreview={handlePreview} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + canPreview={true} + onPreview={handlePreview} + />, + ) const previewBtn = screen.getByTestId('notion-page-preview-root-2') await user.click(previewBtn) @@ -268,33 +493,82 @@ describe('PageSelector', () => { it('should update local preview state when preview button clicked', async () => { const user = userEvent.setup() - const { rerender } = render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} canPreview={true} />) + const { rerender } = render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + canPreview={true} + />, + ) const previewBtn1 = screen.getByTestId('notion-page-preview-root-1') await user.click(previewBtn1) // The preview should now show the hover state for root-1 - rerender(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} canPreview={true} />) + rerender( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + canPreview={true} + />, + ) const row = screen.getByTestId('notion-page-row-root-1') expect(row)!.toHaveClass('bg-state-base-hover') }) it('should render page name with correct title attribute', () => { - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + />, + ) const pageName = screen.getByTestId('notion-page-name-root-1') expect(pageName)!.toHaveAttribute('title', 'Root 1') }) it('should handle empty list gracefully', () => { - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={[]} onSelect={vi.fn()} />) - - expect(screen.getByText('common.dataSource.notion.selector.noSearchResult'))!.toBeInTheDocument() + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="" + pagesMap={mockPagesMap} + list={[]} + onSelect={vi.fn()} + />, + ) + + expect( + screen.getByText('common.dataSource.notion.selector.noSearchResult'), + )!.toBeInTheDocument() }) it('should filter search results correctly with partial matches', () => { - render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="1" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set()} + searchValue="1" + pagesMap={mockPagesMap} + list={mockList} + onSelect={vi.fn()} + />, + ) // Should show Root 1, Child 1, and Grandchild 1 // Should show Root 1, Child 1, and Grandchild 1 @@ -340,7 +614,16 @@ describe('PageSelector', () => { it('should handle disabled parent when selecting child', async () => { const handleSelect = vi.fn() const user = userEvent.setup() - render(<PageSelector value={new Set()} disabledValue={new Set(['root-1'])} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />) + render( + <PageSelector + value={new Set()} + disabledValue={new Set(['root-1'])} + searchValue="" + pagesMap={mockPagesMap} + list={mockList} + onSelect={handleSelect} + />, + ) const toggle = screen.getByTestId('notion-page-toggle-root-1') await user.click(toggle) diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts b/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts index 04166f1d83985a..c30e9d758b890c 100644 --- a/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts @@ -48,14 +48,14 @@ describe('usePageSelectorModel', () => { it('should build visible rows from the expanded tree state', async () => { const { result } = renderHook(() => usePageSelectorModel(createProps())) - expect(result.current.rows.map(row => row.page.page_id)).toEqual(['root-1']) + expect(result.current.rows.map((row) => row.page.page_id)).toEqual(['root-1']) act(() => { result.current.handleToggle('root-1') }) await waitFor(() => { - expect(result.current.rows.map(row => row.page.page_id)).toEqual([ + expect(result.current.rows.map((row) => row.page.page_id)).toEqual([ 'root-1', 'child-1', 'child-2', @@ -67,7 +67,7 @@ describe('usePageSelectorModel', () => { }) await waitFor(() => { - expect(result.current.rows.map(row => row.page.page_id)).toEqual([ + expect(result.current.rows.map((row) => row.page.page_id)).toEqual([ 'root-1', 'child-1', 'grandchild-1', @@ -84,20 +84,14 @@ describe('usePageSelectorModel', () => { result.current.handleSelect('root-1') }) - expect(onSelect).toHaveBeenCalledWith(new Set([ - 'root-1', - 'child-1', - 'grandchild-1', - 'child-2', - ])) + expect(onSelect).toHaveBeenCalledWith(new Set(['root-1', 'child-1', 'grandchild-1', 'child-2'])) }) it('should update local preview and respect the controlled previewPageId when provided', () => { const onPreview = vi.fn() - const { result, rerender } = renderHook( - props => usePageSelectorModel(props), - { initialProps: createProps({ onPreview }) }, - ) + const { result, rerender } = renderHook((props) => usePageSelectorModel(props), { + initialProps: createProps({ onPreview }), + }) act(() => { result.current.handlePreview('child-1') @@ -112,16 +106,15 @@ describe('usePageSelectorModel', () => { }) it('should expose filtered rows when the deferred search value changes', async () => { - const { result, rerender } = renderHook( - props => usePageSelectorModel(props), - { initialProps: createProps() }, - ) + const { result, rerender } = renderHook((props) => usePageSelectorModel(props), { + initialProps: createProps(), + }) rerender(createProps({ searchValue: 'Grandchild' })) await waitFor(() => { expect(result.current.effectiveSearchValue).toBe('Grandchild') - expect(result.current.rows.map(row => row.page.page_id)).toEqual(['grandchild-1']) + expect(result.current.rows.map((row) => row.page.page_id)).toEqual(['grandchild-1']) }) }) }) diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts b/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts index b0ad0b05b685fb..53bf57e28f32fc 100644 --- a/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts @@ -58,7 +58,7 @@ describe('page-selector utils', () => { expandedIds: new Set(['root-1', 'child-1']), }) - expect(rows.map(row => row.page.page_id)).toEqual([ + expect(rows.map((row) => row.page.page_id)).toEqual([ 'root-1', 'child-1', 'grandchild-1', @@ -91,28 +91,34 @@ describe('page-selector utils', () => { it('should toggle selected ids correctly in single and multiple mode', () => { const treeMap = buildNotionPageTree(list, pagesMap) - expect(getNextSelectedPageIds({ - checkedIds: new Set(['root-1']), - pageId: 'child-1', - searchValue: '', - selectionMode: 'single', - treeMap, - })).toEqual(new Set(['child-1'])) - - expect(getNextSelectedPageIds({ - checkedIds: new Set<string>(), - pageId: 'root-1', - searchValue: '', - selectionMode: 'multiple', - treeMap, - })).toEqual(new Set(['root-1', 'child-1', 'grandchild-1', 'child-2'])) - - expect(getNextSelectedPageIds({ - checkedIds: new Set(['child-1']), - pageId: 'child-1', - searchValue: 'Child', - selectionMode: 'multiple', - treeMap, - })).toEqual(new Set<string>()) + expect( + getNextSelectedPageIds({ + checkedIds: new Set(['root-1']), + pageId: 'child-1', + searchValue: '', + selectionMode: 'single', + treeMap, + }), + ).toEqual(new Set(['child-1'])) + + expect( + getNextSelectedPageIds({ + checkedIds: new Set<string>(), + pageId: 'root-1', + searchValue: '', + selectionMode: 'multiple', + treeMap, + }), + ).toEqual(new Set(['root-1', 'child-1', 'grandchild-1', 'child-2'])) + + expect( + getNextSelectedPageIds({ + checkedIds: new Set(['child-1']), + pageId: 'child-1', + searchValue: 'Child', + selectionMode: 'multiple', + treeMap, + }), + ).toEqual(new Set<string>()) }) }) diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx b/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx index 7ad4f29d3e9f0c..d82684756bb32b 100644 --- a/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx @@ -94,25 +94,31 @@ describe('VirtualPageList', () => { expect(screen.getByTestId('virtual-list')).toBeInTheDocument() expect(screen.getByTestId('page-row-page-1')).toBeInTheDocument() expect(screen.getByTestId('page-row-page-2')).toBeInTheDocument() - expect(pageRowPropsSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({ - checked: true, - disabled: false, - isPreviewed: false, - searchValue: '', - selectionMode: 'multiple', - showPreview: true, - row: rows[0], - style: expect.objectContaining({ - height: '28px', - width: 'calc(100% - 16px)', + expect(pageRowPropsSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + checked: true, + disabled: false, + isPreviewed: false, + searchValue: '', + selectionMode: 'multiple', + showPreview: true, + row: rows[0], + style: expect.objectContaining({ + height: '28px', + width: 'calc(100% - 16px)', + }), }), - })) - expect(pageRowPropsSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ - checked: false, - disabled: true, - isPreviewed: true, - row: rows[1], - })) + ) + expect(pageRowPropsSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + checked: false, + disabled: true, + isPreviewed: true, + row: rows[1], + }), + ) }) it('should size the virtual container using the row estimate', () => { diff --git a/web/app/components/base/notion-page-selector/page-selector/index.tsx b/web/app/components/base/notion-page-selector/page-selector/index.tsx index 686ce33a062ae3..b33217e1b4aede 100644 --- a/web/app/components/base/notion-page-selector/page-selector/index.tsx +++ b/web/app/components/base/notion-page-selector/page-selector/index.tsx @@ -48,7 +48,7 @@ const PageSelector = ({ if (!rows.length) { return ( <div className="flex h-[296px] items-center justify-center text-[13px] text-text-tertiary"> - {t($ => $['dataSource.notion.selector.noSearchResult'], { ns: 'common' })} + {t(($) => $['dataSource.notion.selector.noSearchResult'], { ns: 'common' })} </div> ) } diff --git a/web/app/components/base/notion-page-selector/page-selector/page-row.tsx b/web/app/components/base/notion-page-selector/page-selector/page-row.tsx index 709cb9ebf298c3..1b03c33c3ca0da 100644 --- a/web/app/components/base/notion-page-selector/page-selector/page-row.tsx +++ b/web/app/components/base/notion-page-selector/page-selector/page-row.tsx @@ -37,32 +37,35 @@ const NotionPageRow = ({ }: NotionPageRowProps) => { const { t } = useTranslation() const pageId = row.page.page_id - const breadcrumbs = row.ancestors.length ? [...row.ancestors, row.page.page_name] : [row.page.page_name] + const breadcrumbs = row.ancestors.length + ? [...row.ancestors, row.page.page_name] + : [row.page.page_name] return ( <div - className={cn('group flex cursor-pointer items-center rounded-md pr-[2px] pl-2 hover:bg-state-base-hover', isPreviewed && 'bg-state-base-hover')} + className={cn( + 'group flex cursor-pointer items-center rounded-md pr-[2px] pl-2 hover:bg-state-base-hover', + isPreviewed && 'bg-state-base-hover', + )} style={style} data-testid={`notion-page-row-${pageId}`} > - {selectionMode === 'multiple' - ? ( - <Checkbox - className="mr-2 shrink-0" - checked={checked} - disabled={disabled} - aria-label={row.page.page_name} - onCheckedChange={() => onSelect(pageId)} - /> - ) - : ( - <Radio - className="mr-2 shrink-0" - value={pageId} - disabled={disabled} - aria-label={row.page.page_name} - /> - )} + {selectionMode === 'multiple' ? ( + <Checkbox + className="mr-2 shrink-0" + checked={checked} + disabled={disabled} + aria-label={row.page.page_name} + onCheckedChange={() => onSelect(pageId)} + /> + ) : ( + <Radio + className="mr-2 shrink-0" + value={pageId} + disabled={disabled} + aria-label={row.page.page_name} + /> + )} {!searchValue && row.hasChild && ( <div className="mr-1 flex size-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover" @@ -70,19 +73,17 @@ const NotionPageRow = ({ onClick={() => onToggle(pageId)} data-testid={`notion-page-toggle-${pageId}`} > - {row.expand - ? <RiArrowDownSLine className="size-4 text-text-tertiary" /> - : <RiArrowRightSLine className="size-4 text-text-tertiary" />} + {row.expand ? ( + <RiArrowDownSLine className="size-4 text-text-tertiary" /> + ) : ( + <RiArrowRightSLine className="size-4 text-text-tertiary" /> + )} </div> )} {!searchValue && !row.hasChild && row.parentExists && ( <div className="mr-1 size-5 shrink-0" style={{ marginLeft: row.depth * 8 }} /> )} - <NotionIcon - className="mr-1 shrink-0" - type="page" - src={row.page.page_icon} - /> + <NotionIcon className="mr-1 shrink-0" type="page" src={row.page.page_icon} /> <div className="grow truncate text-[13px] leading-4 font-medium text-text-secondary" title={row.page.page_name} @@ -92,13 +93,11 @@ const NotionPageRow = ({ </div> {showPreview && ( <div - className="ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs - leading-4 font-medium text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px] - group-hover:flex hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover" + className="ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs leading-4 font-medium text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px] group-hover:flex hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover" onClick={() => onPreview(pageId)} data-testid={`notion-page-preview-${pageId}`} > - {t($ => $['dataSource.notion.selector.preview'], { ns: 'common' })} + {t(($) => $['dataSource.notion.selector.preview'], { ns: 'common' })} </div> )} {searchValue && ( diff --git a/web/app/components/base/notion-page-selector/page-selector/use-page-selector-model.ts b/web/app/components/base/notion-page-selector/page-selector/use-page-selector-model.ts index ec35ee05f2642c..cfc47df6283f9b 100644 --- a/web/app/components/base/notion-page-selector/page-selector/use-page-selector-model.ts +++ b/web/app/components/base/notion-page-selector/page-selector/use-page-selector-model.ts @@ -1,7 +1,12 @@ import type { NotionPageSelectionMode } from './types' import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' import { startTransition, useCallback, useDeferredValue, useMemo, useState } from 'react' -import { buildNotionPageTree, getNextSelectedPageIds, getRootPageIds, getVisiblePageRows } from './utils' +import { + buildNotionPageTree, + getNextSelectedPageIds, + getRootPageIds, + getVisiblePageRows, +} from './utils' type UsePageSelectorModelProps = { checkedIds: Set<string> @@ -44,38 +49,50 @@ export const usePageSelectorModel = ({ const currentPreviewPageId = previewPageId ?? localPreviewPageId - const handleToggle = useCallback((pageId: string) => { - startTransition(() => { - setExpandedIds((currentExpandedIds) => { - const nextExpandedIds = new Set(currentExpandedIds) + const handleToggle = useCallback( + (pageId: string) => { + startTransition(() => { + setExpandedIds((currentExpandedIds) => { + const nextExpandedIds = new Set(currentExpandedIds) - if (nextExpandedIds.has(pageId)) { - nextExpandedIds.delete(pageId) - treeMap[pageId]?.descendants.forEach(descendantId => nextExpandedIds.delete(descendantId)) - } - else { - nextExpandedIds.add(pageId) - } + if (nextExpandedIds.has(pageId)) { + nextExpandedIds.delete(pageId) + treeMap[pageId]?.descendants.forEach((descendantId) => + nextExpandedIds.delete(descendantId), + ) + } else { + nextExpandedIds.add(pageId) + } - return nextExpandedIds + return nextExpandedIds + }) }) - }) - }, [treeMap]) + }, + [treeMap], + ) - const handleSelect = useCallback((pageId: string) => { - onSelect(getNextSelectedPageIds({ - checkedIds, - pageId, - searchValue: deferredSearchValue, - selectionMode, - treeMap, - })) - }, [checkedIds, deferredSearchValue, onSelect, selectionMode, treeMap]) + const handleSelect = useCallback( + (pageId: string) => { + onSelect( + getNextSelectedPageIds({ + checkedIds, + pageId, + searchValue: deferredSearchValue, + selectionMode, + treeMap, + }), + ) + }, + [checkedIds, deferredSearchValue, onSelect, selectionMode, treeMap], + ) - const handlePreview = useCallback((pageId: string) => { - setLocalPreviewPageId(pageId) - onPreview?.(pageId) - }, [onPreview]) + const handlePreview = useCallback( + (pageId: string) => { + setLocalPreviewPageId(pageId) + onPreview?.(pageId) + }, + [onPreview], + ) return { currentPreviewPageId, diff --git a/web/app/components/base/notion-page-selector/page-selector/utils.ts b/web/app/components/base/notion-page-selector/page-selector/utils.ts index d9327ddb0f97b1..25541fe94a7365 100644 --- a/web/app/components/base/notion-page-selector/page-selector/utils.ts +++ b/web/app/components/base/notion-page-selector/page-selector/utils.ts @@ -1,4 +1,9 @@ -import type { NotionPageRow, NotionPageSelectionMode, NotionPageTreeItem, NotionPageTreeMap } from './types' +import type { + NotionPageRow, + NotionPageSelectionMode, + NotionPageTreeItem, + NotionPageTreeMap, +} from './types' import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' export const recursivePushInParentDescendants = ( @@ -10,8 +15,7 @@ export const recursivePushInParentDescendants = ( const parentId = current.parent_id const pageId = current.page_id - if (!parentId || !pageId) - return + if (!parentId || !pageId) return if (parentId !== 'root' && pagesMap[parentId]) { if (!listTreeMap[parentId]) { @@ -24,8 +28,7 @@ export const recursivePushInParentDescendants = ( depth: 0, ancestors: [], } - } - else { + } else { listTreeMap[parentId].children.add(pageId) listTreeMap[parentId].descendants.add(pageId) listTreeMap[parentId].descendants.add(leafItem.page_id) @@ -46,20 +49,23 @@ export const buildNotionPageTree = ( return list.reduce((prev: NotionPageTreeMap, next) => { const pageId = next.page_id if (!prev[pageId]) - prev[pageId] = { ...next, children: new Set(), descendants: new Set(), depth: 0, ancestors: [] } + prev[pageId] = { + ...next, + children: new Set(), + descendants: new Set(), + depth: 0, + ancestors: [], + } recursivePushInParentDescendants(pagesMap, prev, prev[pageId], prev[pageId]) return prev }, {}) } -export const getRootPageIds = ( - list: DataSourceNotionPage[], - pagesMap: DataSourceNotionPageMap, -) => { +export const getRootPageIds = (list: DataSourceNotionPage[], pagesMap: DataSourceNotionPageMap) => { return list - .filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]) - .map(item => item.page_id) + .filter((item) => item.parent_id === 'root' || !pagesMap[item.parent_id]) + .map((item) => item.page_id) } export const getVisiblePageRows = ({ @@ -79,8 +85,8 @@ export const getVisiblePageRows = ({ }): NotionPageRow[] => { if (searchValue) { return list - .filter(item => item.page_name.includes(searchValue)) - .map(item => ({ + .filter((item) => item.page_name.includes(searchValue)) + .map((item) => ({ page: item, parentExists: item.parent_id !== 'root' && Boolean(pagesMap[item.parent_id]), depth: treeMap[item.page_id]?.depth ?? 0, @@ -94,8 +100,7 @@ export const getVisiblePageRows = ({ const visit = (pageId: string) => { const current = treeMap[pageId] - if (!current) - return + if (!current) return const expand = expandedIds.has(pageId) rows.push({ @@ -107,8 +112,7 @@ export const getVisiblePageRows = ({ ancestors: current.ancestors, }) - if (!expand) - return + if (!expand) return current.children.forEach(visit) } @@ -137,8 +141,7 @@ export const getNextSelectedPageIds = ({ if (selectionMode === 'single') { if (nextCheckedIds.has(pageId)) { nextCheckedIds.delete(pageId) - } - else { + } else { nextCheckedIds.clear() nextCheckedIds.add(pageId) } @@ -147,15 +150,13 @@ export const getNextSelectedPageIds = ({ } if (nextCheckedIds.has(pageId)) { - if (!searchValue) - descendants.forEach(item => nextCheckedIds.delete(item)) + if (!searchValue) descendants.forEach((item) => nextCheckedIds.delete(item)) nextCheckedIds.delete(pageId) return nextCheckedIds } - if (!searchValue) - descendants.forEach(item => nextCheckedIds.add(item)) + if (!searchValue) descendants.forEach((item) => nextCheckedIds.add(item)) nextCheckedIds.add(pageId) diff --git a/web/app/components/base/notion-page-selector/page-selector/virtual-page-list.tsx b/web/app/components/base/notion-page-selector/page-selector/virtual-page-list.tsx index 4ea277d75f15fa..f0076a8fe9076b 100644 --- a/web/app/components/base/notion-page-selector/page-selector/virtual-page-list.tsx +++ b/web/app/components/base/notion-page-selector/page-selector/virtual-page-list.tsx @@ -78,29 +78,25 @@ const VirtualPageList = ({ }) return ( - <div - ref={scrollRef} - className="h-[296px] overflow-auto" - data-testid="virtual-list" - > + <div ref={scrollRef} className="h-[296px] overflow-auto" data-testid="virtual-list"> <div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative', }} > - {selectionMode === 'single' - ? ( - <RadioGroup - aria-label={t($ => $['dataSource.notion.selector.headerTitle'], { ns: 'common' })} - value={selectedPageId} - onValueChange={onSelect} - className="contents" - > - {rowNodes} - </RadioGroup> - ) - : rowNodes} + {selectionMode === 'single' ? ( + <RadioGroup + aria-label={t(($) => $['dataSource.notion.selector.headerTitle'], { ns: 'common' })} + value={selectedPageId} + onValueChange={onSelect} + className="contents" + > + {rowNodes} + </RadioGroup> + ) : ( + rowNodes + )} </div> </div> ) diff --git a/web/app/components/base/notion-page-selector/search-input/__tests__/index.spec.tsx b/web/app/components/base/notion-page-selector/search-input/__tests__/index.spec.tsx index e68409d3ef273d..ab0c7ad3546206 100644 --- a/web/app/components/base/notion-page-selector/search-input/__tests__/index.spec.tsx +++ b/web/app/components/base/notion-page-selector/search-input/__tests__/index.spec.tsx @@ -7,7 +7,9 @@ describe('SearchInput', () => { it('should render with placeholder', () => { render(<SearchInput value="" onChange={vi.fn()} />) - expect(screen.getByPlaceholderText('common.dataSource.notion.selector.searchPages')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('common.dataSource.notion.selector.searchPages'), + ).toBeInTheDocument() expect(screen.getByTestId('notion-search-input-container')).toBeInTheDocument() }) diff --git a/web/app/components/base/notion-page-selector/search-input/index.tsx b/web/app/components/base/notion-page-selector/search-input/index.tsx index 20835608eacc24..e3dcbb26064d5b 100644 --- a/web/app/components/base/notion-page-selector/search-input/index.tsx +++ b/web/app/components/base/notion-page-selector/search-input/index.tsx @@ -7,17 +7,14 @@ type SearchInputProps = { value: string onChange: (v: string) => void } -const SearchInput = ({ - value, - onChange, -}: SearchInputProps) => { +const SearchInput = ({ value, onChange }: SearchInputProps) => { const { t } = useTranslation() const handleClear = useCallback(() => { onChange('') }, [onChange]) - const placeholderText = t($ => $['dataSource.notion.selector.searchPages'], { ns: 'common' }) + const placeholderText = t(($) => $['dataSource.notion.selector.searchPages'], { ns: 'common' }) /* v8 ignore next -- i18n test mock always returns a non-empty string; runtime fallback is defensive. -- @preserve */ const safePlaceholderText = placeholderText || '' @@ -34,20 +31,19 @@ const SearchInput = ({ placeholder={safePlaceholderText} data-testid="notion-search-input" /> - { - value - ? ( - <button - type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} - className="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent p-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-active" - onClick={handleClear} - > - <span className="i-ri-close-circle-fill size-4 text-components-input-text-placeholder" aria-hidden="true" /> - </button> - ) - : null - } + {value ? ( + <button + type="button" + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} + className="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent p-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-active" + onClick={handleClear} + > + <span + className="i-ri-close-circle-fill size-4 text-components-input-text-placeholder" + aria-hidden="true" + /> + </button> + ) : null} </div> ) } diff --git a/web/app/components/base/page-unavailable.tsx b/web/app/components/base/page-unavailable.tsx index 44ba77b6e5208d..17dbd46f169feb 100644 --- a/web/app/components/base/page-unavailable.tsx +++ b/web/app/components/base/page-unavailable.tsx @@ -20,7 +20,7 @@ const PageUnavailable = ({ className }: PageUnavailableProps) => { > 404 </h1> - <div className="text-sm">{t($ => $.pageUnavailable, { ns: 'common' })}</div> + <div className="text-sm">{t(($) => $.pageUnavailable, { ns: 'common' })}</div> </div> ) } diff --git a/web/app/components/base/param-item/__tests__/index-slider.spec.tsx b/web/app/components/base/param-item/__tests__/index-slider.spec.tsx index 2974ecbe29cec0..d6d5d503dece19 100644 --- a/web/app/components/base/param-item/__tests__/index-slider.spec.tsx +++ b/web/app/components/base/param-item/__tests__/index-slider.spec.tsx @@ -14,9 +14,10 @@ describe('ParamItem Slider onChange', () => { vi.clearAllMocks() }) - const getSlider = () => screen.getByLabelText('Test Param', { - selector: 'input[type="range"]', - }) + const getSlider = () => + screen.getByLabelText('Test Param', { + selector: 'input[type="range"]', + }) it('should divide slider value by 100 when max < 5', async () => { const user = userEvent.setup() diff --git a/web/app/components/base/param-item/__tests__/index.spec.tsx b/web/app/components/base/param-item/__tests__/index.spec.tsx index 952aad1b95a25e..f20ad8da762357 100644 --- a/web/app/components/base/param-item/__tests__/index.spec.tsx +++ b/web/app/components/base/param-item/__tests__/index.spec.tsx @@ -17,9 +17,10 @@ describe('ParamItem', () => { vi.clearAllMocks() }) - const getSlider = () => screen.getByLabelText('Test Param', { - selector: 'input[type="range"]', - }) + const getSlider = () => + screen.getByLabelText('Test Param', { + selector: 'input[type="range"]', + }) describe('Rendering', () => { it('should render the parameter name', () => { diff --git a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx index 04019b74da7529..4bee9fd10c1107 100644 --- a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx @@ -14,15 +14,18 @@ describe('ScoreThresholdItem', () => { vi.clearAllMocks() }) - const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.score_threshold', { - selector: 'input[type="range"]', - }) + const getSlider = () => + screen.getByLabelText('appDebug.datasetConfig.score_threshold', { + selector: 'input[type="range"]', + }) describe('Rendering', () => { it('should render the translated parameter name', () => { render(<ScoreThresholdItem {...defaultProps} />) - expect(screen.getByText('appDebug.datasetConfig.score_threshold', { selector: 'span' })).toBeInTheDocument() + expect( + screen.getByText('appDebug.datasetConfig.score_threshold', { selector: 'span' }), + ).toBeInTheDocument() }) it('should render tooltip trigger', () => { diff --git a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx index 8cdd4e65171641..0f19aef8117815 100644 --- a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx @@ -19,15 +19,18 @@ describe('TopKItem', () => { vi.clearAllMocks() }) - const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.top_k', { - selector: 'input[type="range"]', - }) + const getSlider = () => + screen.getByLabelText('appDebug.datasetConfig.top_k', { + selector: 'input[type="range"]', + }) describe('Rendering', () => { it('should render the translated parameter name', () => { render(<TopKItem {...defaultProps} />) - expect(screen.getByText('appDebug.datasetConfig.top_k', { selector: 'span' })).toBeInTheDocument() + expect( + screen.getByText('appDebug.datasetConfig.top_k', { selector: 'span' }), + ).toBeInTheDocument() }) it('should render tooltip trigger', () => { diff --git a/web/app/components/base/param-item/index.stories.tsx b/web/app/components/base/param-item/index.stories.tsx index adad09dfefc6c5..12ae20676bc0bc 100644 --- a/web/app/components/base/param-item/index.stories.tsx +++ b/web/app/components/base/param-item/index.stories.tsx @@ -45,15 +45,18 @@ const PARAMS: ParamConfig[] = [ ] const ParamItemPlayground = () => { - const [state, setState] = useState<Record<string, { value: number, enabled: boolean }>>(() => { - return PARAMS.reduce((acc, item) => { - acc[item.id] = { value: item.value, enabled: true } - return acc - }, {} as Record<string, { value: number, enabled: boolean }>) + const [state, setState] = useState<Record<string, { value: number; enabled: boolean }>>(() => { + return PARAMS.reduce( + (acc, item) => { + acc[item.id] = { value: item.value, enabled: true } + return acc + }, + {} as Record<string, { value: number; enabled: boolean }>, + ) }) const handleChange = (id: string, value: number) => { - setState(prev => ({ + setState((prev) => ({ ...prev, [id]: { ...prev[id]!, @@ -63,7 +66,7 @@ const ParamItemPlayground = () => { } const handleToggle = (id: string, enabled: boolean) => { - setState(prev => ({ + setState((prev) => ({ ...prev, [id]: { ...prev[id]!, @@ -80,7 +83,7 @@ const ParamItemPlayground = () => { {JSON.stringify(state, null, 0)} </code> </div> - {PARAMS.map(param => ( + {PARAMS.map((param) => ( <ParamItem key={param.id} className="rounded-xl border border-transparent px-3 py-2 hover:border-divider-subtle hover:bg-background-default-subtle" @@ -108,7 +111,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Slider + numeric input pairing used for model parameter tuning. Supports optional enable toggles per parameter.', + component: + 'Slider + numeric input pairing used for model parameter tuning. Supports optional enable toggles per parameter.', }, }, }, diff --git a/web/app/components/base/param-item/index.tsx b/web/app/components/base/param-item/index.tsx index cbb35755b69d22..a8c1de12676ca2 100644 --- a/web/app/components/base/param-item/index.tsx +++ b/web/app/components/base/param-item/index.tsx @@ -30,7 +30,22 @@ type Props = Readonly<{ onSwitchChange?: (key: string, enable: boolean) => void }> -const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1, min = 0, max, value, enable, onChange, disabled = false, hasSwitch, onSwitchChange }) => { +const ParamItem: FC<Props> = ({ + className, + id, + name, + noTooltip, + tip, + step = 0.1, + min = 0, + max, + value, + enable, + onChange, + disabled = false, + hasSwitch, + onSwitchChange, +}) => { return ( <Fieldset className={className}> <FieldsetLegend className="sr-only">{name}</FieldsetLegend> @@ -63,7 +78,7 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1, max={max} step={step} value={value} - onValueChange={nextValue => onChange(id, nextValue ?? min)} + onValueChange={(nextValue) => onChange(id, nextValue ?? min)} > <NumberFieldGroup> <NumberFieldInput aria-label={name} className="w-18" /> @@ -81,7 +96,7 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1, value={max < 5 ? value * 100 : value} min={min < 1 ? min * 100 : min} max={max < 5 ? max * 100 : max} - onValueChange={value => onChange(id, value / (max < 5 ? 100 : 1))} + onValueChange={(value) => onChange(id, value / (max < 5 ? 100 : 1))} aria-label={name} /> </div> diff --git a/web/app/components/base/param-item/score-threshold-item.tsx b/web/app/components/base/param-item/score-threshold-item.tsx index 4895b2b6d4df20..8db21dcd19ac15 100644 --- a/web/app/components/base/param-item/score-threshold-item.tsx +++ b/web/app/components/base/param-item/score-threshold-item.tsx @@ -22,15 +22,11 @@ const VALUE_LIMIT = { } const normalizeScoreThreshold = (value?: number): number => { - const normalizedValue = typeof value === 'number' && Number.isFinite(value) - ? value - : VALUE_LIMIT.default + const normalizedValue = + typeof value === 'number' && Number.isFinite(value) ? value : VALUE_LIMIT.default const roundedValue = Number.parseFloat(normalizedValue.toFixed(2)) - return Math.min( - VALUE_LIMIT.max, - Math.max(VALUE_LIMIT.min, roundedValue), - ) + return Math.min(VALUE_LIMIT.max, Math.max(VALUE_LIMIT.min, roundedValue)) } const ScoreThresholdItem: FC<Props> = ({ @@ -52,8 +48,8 @@ const ScoreThresholdItem: FC<Props> = ({ <ParamItem className={className} id="score_threshold" - name={t($ => $['datasetConfig.score_threshold'], { ns: 'appDebug' })} - tip={t($ => $['datasetConfig.score_thresholdTip'], { ns: 'appDebug' }) as string} + name={t(($) => $['datasetConfig.score_threshold'], { ns: 'appDebug' })} + tip={t(($) => $['datasetConfig.score_thresholdTip'], { ns: 'appDebug' }) as string} {...VALUE_LIMIT} value={safeValue} enable={enable} diff --git a/web/app/components/base/param-item/top-k-item.tsx b/web/app/components/base/param-item/top-k-item.tsx index b5a97c3b0daf16..8c23563de983a1 100644 --- a/web/app/components/base/param-item/top-k-item.tsx +++ b/web/app/components/base/param-item/top-k-item.tsx @@ -21,13 +21,7 @@ const VALUE_LIMIT = { max: maxTopK, } -const TopKItem: FC<Props> = ({ - className, - value, - enable, - onChange, - disabled = false, -}) => { +const TopKItem: FC<Props> = ({ className, value, enable, onChange, disabled = false }) => { const { t } = useTranslation() const handleParamChange = (key: string, value: number) => { let notOutRangeValue = Number.parseInt(value.toFixed(0)) @@ -39,8 +33,8 @@ const TopKItem: FC<Props> = ({ <ParamItem className={className} id="top_k" - name={t($ => $['datasetConfig.top_k'], { ns: 'appDebug' })} - tip={t($ => $['datasetConfig.top_kTip'], { ns: 'appDebug' }) as string} + name={t(($) => $['datasetConfig.top_k'], { ns: 'appDebug' })} + tip={t(($) => $['datasetConfig.top_kTip'], { ns: 'appDebug' }) as string} {...VALUE_LIMIT} value={value} enable={enable} diff --git a/web/app/components/base/permission-selector/index.tsx b/web/app/components/base/permission-selector/index.tsx index 4a1a4400949105..38e8161d226c45 100644 --- a/web/app/components/base/permission-selector/index.tsx +++ b/web/app/components/base/permission-selector/index.tsx @@ -1,11 +1,7 @@ import type { Member } from '@/models/common' import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowDownSLine, RiGroup2Line, RiLock2Line } from '@remixicon/react' import { useDebounceFn } from 'ahooks' import { useAtomValue } from 'jotai' @@ -52,33 +48,47 @@ const PermissionSelector = ({ const [keywords, setKeywords] = useState('') const [searchKeywords, setSearchKeywords] = useState('') - const { run: handleSearch } = useDebounceFn(() => { - setSearchKeywords(keywords) - }, { wait: 500 }) + const { run: handleSearch } = useDebounceFn( + () => { + setSearchKeywords(keywords) + }, + { wait: 500 }, + ) const handleKeywordsChange = (value: string) => { setKeywords(value) handleSearch() } - const selectMember = useCallback((member: Member) => { - if (value.includes(member.id)) - onMemberSelect(value.filter(v => v !== member.id)) - else - onMemberSelect([...value, member.id]) - }, [value, onMemberSelect]) + const selectMember = useCallback( + (member: Member) => { + if (value.includes(member.id)) onMemberSelect(value.filter((v) => v !== member.id)) + else onMemberSelect([...value, member.id]) + }, + [value, onMemberSelect], + ) const selectedMembers = useMemo(() => { return [ userProfile, - ...memberList.filter(member => member.id !== userProfile.id).filter(member => value.includes(member.id)), + ...memberList + .filter((member) => member.id !== userProfile.id) + .filter((member) => value.includes(member.id)), ] }, [userProfile, value, memberList]) const showMe = useMemo(() => { - return (userProfile.name ?? '').includes(searchKeywords) || (userProfile.email ?? '').includes(searchKeywords) + return ( + (userProfile.name ?? '').includes(searchKeywords) || + (userProfile.email ?? '').includes(searchKeywords) + ) }, [searchKeywords, userProfile]) const filteredMemberList = useMemo(() => { - return memberList.filter(member => (member.name.includes(searchKeywords) || member.email.includes(searchKeywords)) && member.id !== userProfile.id && ['owner', 'admin', 'editor', 'dataset_operator'].includes(member.role)) + return memberList.filter( + (member) => + (member.name.includes(searchKeywords) || member.email.includes(searchKeywords)) && + member.id !== userProfile.id && + ['owner', 'admin', 'editor', 'dataset_operator'].includes(member.role), + ) }, [memberList, searchKeywords, userProfile]) const onSelectOnlyMe = useCallback(() => { @@ -99,88 +109,79 @@ const PermissionSelector = ({ const isOnlyMe = permission === PermissionLevel.onlyMe const isAllTeamMembers = permission === PermissionLevel.allTeamMembers const isPartialMembers = permission === PermissionLevel.partialMembers - const selectedMemberNames = selectedMembers.map(member => member.name).join(', ') + const selectedMemberNames = selectedMembers.map((member) => member.name).join(', ') return ( <Popover open={open} onOpenChange={setOpen}> <div className="relative"> <PopoverTrigger disabled={disabled} - render={( + render={ <div className={cn( 'flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', - disabled && 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!', + disabled && + 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!', )} /> - )} - > - { - isOnlyMe && ( - <> - <div className="flex size-6 shrink-0 items-center justify-center"> - <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" /> - </div> - <div className="grow p-1 system-sm-regular text-components-input-text-filled"> - {t($ => $['form.permissionsOnlyMe'], { ns: i18nNamespace })} - </div> - </> - ) - } - { - isAllTeamMembers && ( - <> - <div className="flex size-6 shrink-0 items-center justify-center"> - <RiGroup2Line className="size-4 text-text-secondary" /> - </div> - <div className="grow p-1 system-sm-regular text-components-input-text-filled"> - {t($ => $['form.permissionsAllMember'], { ns: i18nNamespace })} - </div> - </> - ) - } - { - isPartialMembers && ( - <> - <div className="relative flex size-6 shrink-0 items-center justify-center"> - { - selectedMembers.length === 1 && ( - <Avatar - avatar={selectedMembers[0]!.avatar_url} - name={selectedMembers[0]!.name} - size="xs" - /> - ) - } - { - selectedMembers.length >= 2 && ( - <> - <Avatar - avatar={selectedMembers[0]!.avatar_url} - name={selectedMembers[0]!.name} - className="absolute top-0 left-0 z-0" - size="xxs" - /> - <Avatar - avatar={selectedMembers[1]!.avatar_url} - name={selectedMembers[1]!.name} - className="absolute right-0 bottom-0 z-10" - size="xxs" - /> - </> - ) - } - </div> - <div - title={selectedMemberNames} - className="grow truncate p-1 system-sm-regular text-components-input-text-filled" - > - {selectedMemberNames} - </div> - </> - ) } + > + {isOnlyMe && ( + <> + <div className="flex size-6 shrink-0 items-center justify-center"> + <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" /> + </div> + <div className="grow p-1 system-sm-regular text-components-input-text-filled"> + {t(($) => $['form.permissionsOnlyMe'], { ns: i18nNamespace })} + </div> + </> + )} + {isAllTeamMembers && ( + <> + <div className="flex size-6 shrink-0 items-center justify-center"> + <RiGroup2Line className="size-4 text-text-secondary" /> + </div> + <div className="grow p-1 system-sm-regular text-components-input-text-filled"> + {t(($) => $['form.permissionsAllMember'], { ns: i18nNamespace })} + </div> + </> + )} + {isPartialMembers && ( + <> + <div className="relative flex size-6 shrink-0 items-center justify-center"> + {selectedMembers.length === 1 && ( + <Avatar + avatar={selectedMembers[0]!.avatar_url} + name={selectedMembers[0]!.name} + size="xs" + /> + )} + {selectedMembers.length >= 2 && ( + <> + <Avatar + avatar={selectedMembers[0]!.avatar_url} + name={selectedMembers[0]!.name} + className="absolute top-0 left-0 z-0" + size="xxs" + /> + <Avatar + avatar={selectedMembers[1]!.avatar_url} + name={selectedMembers[1]!.name} + className="absolute right-0 bottom-0 z-10" + size="xxs" + /> + </> + )} + </div> + <div + title={selectedMemberNames} + className="grow truncate p-1 system-sm-regular text-components-input-text-filled" + > + {selectedMemberNames} + </div> + </> + )} <RiArrowDownSLine className={cn( 'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary', @@ -194,32 +195,37 @@ const PermissionSelector = ({ {/* Only me */} <Item leftIcon={ - <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className="shrink-0" size="sm" /> + <Avatar + avatar={userProfile.avatar_url} + name={userProfile.name} + className="shrink-0" + size="sm" + /> } - text={t($ => $['form.permissionsOnlyMe'], { ns: i18nNamespace })} + text={t(($) => $['form.permissionsOnlyMe'], { ns: i18nNamespace })} onClick={onSelectOnlyMe} isSelected={isOnlyMe} /> {/* All team members */} <Item - leftIcon={( + leftIcon={ <div className="flex size-6 shrink-0 items-center justify-center"> <RiGroup2Line className="size-4 text-text-secondary" /> </div> - )} - text={t($ => $['form.permissionsAllMember'], { ns: i18nNamespace })} + } + text={t(($) => $['form.permissionsAllMember'], { ns: i18nNamespace })} onClick={onSelectAllMembers} isSelected={isAllTeamMembers} /> {/* Partial members */} {!hidePartialMembers && ( <Item - leftIcon={( + leftIcon={ <div className="flex size-6 shrink-0 items-center justify-center"> <RiLock2Line className="size-4 text-text-secondary" /> </div> - )} - text={t($ => $['form.permissionsInvitedMembers'], { ns: i18nNamespace })} + } + text={t(($) => $['form.permissionsInvitedMembers'], { ns: i18nNamespace })} onClick={onSelectPartialMembers} isSelected={isPartialMembers} /> @@ -232,7 +238,7 @@ const PermissionSelector = ({ showLeftIcon showClearIcon value={keywords} - onChange={e => handleKeywordsChange(e.target.value)} + onChange={(e) => handleKeywordsChange(e.target.value)} onClear={() => handleKeywordsChange('')} /> </div> @@ -240,7 +246,12 @@ const PermissionSelector = ({ {showMe && ( <MemberItem leftIcon={ - <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className="shrink-0" size="sm" /> + <Avatar + avatar={userProfile.avatar_url} + name={userProfile.name} + className="shrink-0" + size="sm" + /> } name={userProfile.name} email={userProfile.email} @@ -249,11 +260,16 @@ const PermissionSelector = ({ i18nNamespace={i18nNamespace} /> )} - {filteredMemberList.map(member => ( + {filteredMemberList.map((member) => ( <MemberItem key={member.id} leftIcon={ - <Avatar avatar={member.avatar_url} name={member.name} className="shrink-0" size="sm" /> + <Avatar + avatar={member.avatar_url} + name={member.name} + className="shrink-0" + size="sm" + /> } name={member.name} email={member.email} @@ -262,13 +278,11 @@ const PermissionSelector = ({ i18nNamespace={i18nNamespace} /> ))} - { - !showMe && filteredMemberList.length === 0 && ( - <div className="flex items-center justify-center px-1 py-6 text-center system-xs-regular whitespace-pre-wrap text-text-tertiary"> - {t($ => $['form.onSearchResults'], { ns: i18nNamespace })} - </div> - ) - } + {!showMe && filteredMemberList.length === 0 && ( + <div className="flex items-center justify-center px-1 py-6 text-center system-xs-regular whitespace-pre-wrap text-text-tertiary"> + {t(($) => $['form.onSearchResults'], { ns: i18nNamespace })} + </div> + )} </div> </div> )} diff --git a/web/app/components/base/permission-selector/member-item.tsx b/web/app/components/base/permission-selector/member-item.tsx index a4b8bb2533b98d..b8f28a697ae546 100644 --- a/web/app/components/base/permission-selector/member-item.tsx +++ b/web/app/components/base/permission-selector/member-item.tsx @@ -35,13 +35,15 @@ const MemberItem = ({ {name} {isMe && ( <span className="system-xs-regular text-text-tertiary"> - {t($ => $['form.me'], { ns: i18nNamespace })} + {t(($) => $['form.me'], { ns: i18nNamespace })} </span> )} </div> <div className="truncate system-xs-regular text-text-tertiary">{email}</div> </div> - {isSelected && <RiCheckLine className={cn('size-4 shrink-0 text-text-accent', isMe && 'opacity-30')} />} + {isSelected && ( + <RiCheckLine className={cn('size-4 shrink-0 text-text-accent', isMe && 'opacity-30')} /> + )} </div> ) } diff --git a/web/app/components/base/permission-selector/permission-item.tsx b/web/app/components/base/permission-selector/permission-item.tsx index 28e1c78be2063e..0cea3a79707d74 100644 --- a/web/app/components/base/permission-selector/permission-item.tsx +++ b/web/app/components/base/permission-selector/permission-item.tsx @@ -8,21 +8,14 @@ type PermissionItemProps = { isSelected: boolean } -const PermissionItem = ({ - leftIcon, - text, - onClick, - isSelected, -}: PermissionItemProps) => { +const PermissionItem = ({ leftIcon, text, onClick, isSelected }: PermissionItemProps) => { return ( <div className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1 hover:bg-state-base-hover" onClick={onClick} > {leftIcon} - <div className="grow px-1 system-md-regular text-text-secondary"> - {text} - </div> + <div className="grow px-1 system-md-regular text-text-secondary">{text}</div> {isSelected && <RiCheckLine className="size-4 text-text-accent" />} </div> ) diff --git a/web/app/components/base/premium-badge/__tests__/index.spec.tsx b/web/app/components/base/premium-badge/__tests__/index.spec.tsx index ccbe4dfc92c5cf..2cd246c53dd646 100644 --- a/web/app/components/base/premium-badge/__tests__/index.spec.tsx +++ b/web/app/components/base/premium-badge/__tests__/index.spec.tsx @@ -24,22 +24,14 @@ describe('PremiumBadge', () => { }) it('applies allowHover class when allowHover is true', () => { - render( - <PremiumBadgeButton> - Premium - </PremiumBadgeButton>, - ) + render(<PremiumBadgeButton>Premium</PremiumBadgeButton>) const badge = screen.getByText('Premium') expect(badge).toBeInTheDocument() expect(badge).toHaveClass('pb-allow-hover') }) it('applies custom styles', () => { - render( - <PremiumBadge styleCss={{ backgroundColor: 'red' }}> - Premium - </PremiumBadge>, - ) + render(<PremiumBadge styleCss={{ backgroundColor: 'red' }}>Premium</PremiumBadge>) const badge = screen.getByText('Premium') expect(badge).toBeInTheDocument() expect(badge).toHaveStyle('background-color: red') diff --git a/web/app/components/base/premium-badge/index.css b/web/app/components/base/premium-badge/index.css index 0796bf3cf6f559..51f9ab774e2b7c 100644 --- a/web/app/components/base/premium-badge/index.css +++ b/web/app/components/base/premium-badge/index.css @@ -1,5 +1,5 @@ @utility premium-badge { - @apply shrink-0 relative inline-flex justify-center items-center rounded-md box-border border border-transparent text-white shadow-xs hover:shadow-lg bg-origin-border overflow-hidden transition-[background-color,background-image,box-shadow] duration-100 ease-out motion-reduce:transition-none; + @apply relative box-border inline-flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-transparent bg-origin-border text-white shadow-xs transition-[background-color,background-image,box-shadow] duration-100 ease-out hover:shadow-lg motion-reduce:transition-none; background-clip: padding-box, border-box; } @@ -9,71 +9,47 @@ @apply bg-util-colors-blue-blue-300; background-image: linear-gradient(90deg, #296dffe6 0%, #004aebe6 100%), - linear-gradient( - 135deg, - var(--color-premium-badge-border-highlight-color) 0%, - #00329e 100% - ); + linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #00329e 100%); } &.premium-badge-indigo:hover { @apply bg-util-colors-indigo-indigo-300; background-image: linear-gradient(90deg, #6172f3e6 0%, #2d31a6e6 100%), - linear-gradient( - 135deg, - var(--color-premium-badge-border-highlight-color) 0%, - #2d31a6 100% - ); + linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #2d31a6 100%); } &.premium-badge-gray:hover { @apply bg-util-colors-gray-gray-300; background-image: linear-gradient(90deg, #676f83e6 0%, #354052e6 100%), - linear-gradient( - 135deg, - var(--color-premium-badge-border-highlight-color) 0%, - #354052 100% - ); + linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #354052 100%); } &.premium-badge-orange:hover { @apply bg-util-colors-orange-orange-300; background-image: linear-gradient(90deg, #ff4405e6 0%, #b93815e6 100%), - linear-gradient( - 135deg, - var(--color-premium-badge-border-highlight-color) 0%, - #e62e05 100% - ); + linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #e62e05 100%); } } @utility premium-badge-m { /* m is for the regular button */ - @apply p-1! h-6 w-auto; + @apply h-6 w-auto p-1!; } @utility premium-badge-s { - @apply border-[0.5px] px-1! py-[3px]! h-[18px] w-auto; + @apply h-[18px] w-auto border-[0.5px] px-1! py-[3px]!; } @utility premium-badge-blue { @apply bg-util-colors-blue-blue-200; background-image: linear-gradient(90deg, #5289ffe6 0%, #155aefe6 100%), - linear-gradient( - 135deg, - var(--color-premium-badge-border-highlight-color) 0%, - #155aef 100% - ); + linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #155aef 100%); &.pb-allow-hover:hover { @apply bg-util-colors-blue-blue-300; background-image: linear-gradient(90deg, #296dffe6 0%, #004aebe6 100%), - linear-gradient( - 135deg, - var(--color-premium-badge-border-highlight-color) 0%, - #00329e 100% - ); + linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #00329e 100%); } } @@ -81,20 +57,12 @@ @apply bg-util-colors-indigo-indigo-200; background-image: linear-gradient(90deg, #8098f9e6 0%, #444ce7e6 100%), - linear-gradient( - 135deg, - var(--color-premium-badge-border-highlight-color) 0%, - #6172f3 100% - ); + linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #6172f3 100%); &.pb-allow-hover:hover { @apply bg-util-colors-indigo-indigo-300; background-image: linear-gradient(90deg, #6172f3e6 0%, #2d31a6e6 100%), - linear-gradient( - 135deg, - var(--color-premium-badge-border-highlight-color) 0%, - #2d31a6 100% - ); + linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #2d31a6 100%); } } @@ -102,20 +70,12 @@ @apply bg-util-colors-gray-gray-200; background-image: linear-gradient(90deg, #98a2b2e6 0%, #676f83e6 100%), - linear-gradient( - 135deg, - var(--color-premium-badge-border-highlight-color) 0%, - #676f83 100% - ); + linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #676f83 100%); &.pb-allow-hover:hover { @apply bg-util-colors-gray-gray-300; background-image: linear-gradient(90deg, #676f83e6 0%, #354052e6 100%), - linear-gradient( - 135deg, - var(--color-premium-badge-border-highlight-color) 0%, - #354052 100% - ); + linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #354052 100%); } } @@ -123,19 +83,11 @@ @apply bg-util-colors-orange-orange-200; background-image: linear-gradient(90deg, #ff692ee6 0%, #e04f16e6 100%), - linear-gradient( - 135deg, - var(--color-premium-badge-border-highlight-color) 0%, - #e62e05 100% - ); + linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #e62e05 100%); &.pb-allow-hover:hover { @apply bg-util-colors-orange-orange-300; background-image: linear-gradient(90deg, #ff4405e6 0%, #b93815e6 100%), - linear-gradient( - 135deg, - var(--color-premium-badge-border-highlight-color) 0%, - #e62e05 100% - ); + linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #e62e05 100%); } } diff --git a/web/app/components/base/premium-badge/index.stories.tsx b/web/app/components/base/premium-badge/index.stories.tsx index a0c16c19aa2b3c..3be62dceecbfa8 100644 --- a/web/app/components/base/premium-badge/index.stories.tsx +++ b/web/app/components/base/premium-badge/index.stories.tsx @@ -1,23 +1,31 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' import PremiumBadge, { PremiumBadgeButton } from '.' -const colors: Array<NonNullable<React.ComponentProps<typeof PremiumBadge>['color']>> = ['blue', 'indigo', 'gray', 'orange'] +const colors: Array<NonNullable<React.ComponentProps<typeof PremiumBadge>['color']>> = [ + 'blue', + 'indigo', + 'gray', + 'orange', +] -const PremiumBadgeGallery = ({ - size = 'm', -}: { - size?: 's' | 'm' -}) => { +const PremiumBadgeGallery = ({ size = 'm' }: { size?: 's' | 'm' }) => { return ( <div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6"> <p className="text-xs tracking-[0.18em] text-text-tertiary uppercase">Brand badge variants</p> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> - {colors.map(color => ( - <div key={color} className="flex flex-col items-center gap-2 rounded-xl border border-transparent px-2 py-4 hover:border-divider-subtle hover:bg-background-default-subtle"> + {colors.map((color) => ( + <div + key={color} + className="flex flex-col items-center gap-2 rounded-xl border border-transparent px-2 py-4 hover:border-divider-subtle hover:bg-background-default-subtle" + > <PremiumBadge color={color} size={size}> - <span className="px-2 text-xs font-semibold tracking-[0.14em] uppercase">Premium</span> + <span className="px-2 text-xs font-semibold tracking-[0.14em] uppercase"> + Premium + </span> </PremiumBadge> - <span className="text-[11px] tracking-[0.16em] text-text-tertiary uppercase">{color}</span> + <span className="text-[11px] tracking-[0.16em] text-text-tertiary uppercase"> + {color} + </span> </div> ))} </div> @@ -32,7 +40,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Gradient badge used for premium features and upsell prompts. Hover animations can be toggled per instance.', + component: + 'Gradient badge used for premium features and upsell prompts. Hover animations can be toggled per instance.', }, }, }, diff --git a/web/app/components/base/premium-badge/index.tsx b/web/app/components/base/premium-badge/index.tsx index 55628ea0e92f26..246f6da19ef465 100644 --- a/web/app/components/base/premium-badge/index.tsx +++ b/web/app/components/base/premium-badge/index.tsx @@ -4,33 +4,30 @@ import { cn } from '@langgenius/dify-ui/cn' import { cva } from 'class-variance-authority' import { Highlight } from '@/app/components/base/icons/src/public/common' -const PremiumBadgeVariants = cva( - 'premium-badge', - { - variants: { - size: { - s: 'premium-badge-s', - m: 'premium-badge-m', - custom: '', - }, - color: { - blue: 'premium-badge-blue', - indigo: 'premium-badge-indigo', - gray: 'premium-badge-gray', - orange: 'premium-badge-orange', - }, - allowHover: { - true: 'pb-allow-hover', - false: '', - }, +const PremiumBadgeVariants = cva('premium-badge', { + variants: { + size: { + s: 'premium-badge-s', + m: 'premium-badge-m', + custom: '', }, - defaultVariants: { - size: 'm', - color: 'blue', - allowHover: false, + color: { + blue: 'premium-badge-blue', + indigo: 'premium-badge-indigo', + gray: 'premium-badge-gray', + orange: 'premium-badge-orange', }, + allowHover: { + true: 'pb-allow-hover', + false: '', + }, + }, + defaultVariants: { + size: 'm', + color: 'blue', + allowHover: false, }, -) +}) type PremiumBadgeProps = { size?: 's' | 'm' | 'custom' @@ -41,15 +38,19 @@ type PremiumBadgeProps = { children?: ReactNode } & VariantProps<typeof PremiumBadgeVariants> -type PremiumBadgeButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'color'> & Omit<PremiumBadgeProps, 'styleCss'> & { - style?: CSSProperties -} +type PremiumBadgeButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'color'> & + Omit<PremiumBadgeProps, 'styleCss'> & { + style?: CSSProperties + } function BadgeHighlight({ size }: { size?: PremiumBadgeProps['size'] }) { return ( <Highlight aria-hidden="true" - className={cn('absolute top-0 right-1/2 translate-x-[20%] opacity-50 transition-[opacity,transform] duration-100 ease-out hover:translate-x-[30%] hover:opacity-80 motion-reduce:transition-none', size === 's' ? 'h-[18px] w-12' : 'h-6 w-12')} + className={cn( + 'absolute top-0 right-1/2 translate-x-[20%] opacity-50 transition-[opacity,transform] duration-100 ease-out hover:translate-x-[30%] hover:opacity-80 motion-reduce:transition-none', + size === 's' ? 'h-[18px] w-12' : 'h-6 w-12', + )} /> ) } @@ -64,7 +65,10 @@ function PremiumBadge({ }: PremiumBadgeProps) { return ( <span - className={cn(PremiumBadgeVariants({ size, color, allowHover, className }), 'relative text-nowrap')} + className={cn( + PremiumBadgeVariants({ size, color, allowHover, className }), + 'relative text-nowrap', + )} style={styleCss} > {children} diff --git a/web/app/components/base/prompt-editor/__tests__/constants.spec.tsx b/web/app/components/base/prompt-editor/__tests__/constants.spec.tsx index 9abdb166e3d8dd..6da4b071447029 100644 --- a/web/app/components/base/prompt-editor/__tests__/constants.spec.tsx +++ b/web/app/components/base/prompt-editor/__tests__/constants.spec.tsx @@ -81,17 +81,13 @@ describe('prompt-editor constants', () => { it('should strip numeric node id for sys selector vars', () => { const text = 'value {{#1711617514996.sys.query#}}' - expect(getInputVars(text)).toEqual([ - ['sys', 'query'], - ]) + expect(getInputVars(text)).toEqual([['sys', 'query']]) }) it('should keep selector unchanged when sys prefix is not numeric id', () => { const text = 'value {{#abc.sys.query#}}' - expect(getInputVars(text)).toEqual([ - ['abc', 'sys', 'query'], - ]) + expect(getInputVars(text)).toEqual([['abc', 'sys', 'query']]) }) }) diff --git a/web/app/components/base/prompt-editor/__tests__/hooks.spec.tsx b/web/app/components/base/prompt-editor/__tests__/hooks.spec.tsx index 13c0c058037587..5a519a5fbaaa18 100644 --- a/web/app/components/base/prompt-editor/__tests__/hooks.spec.tsx +++ b/web/app/components/base/prompt-editor/__tests__/hooks.spec.tsx @@ -9,9 +9,7 @@ import { useSelectOrDelete, useTrigger, } from '../hooks' -import { - DELETE_CONTEXT_BLOCK_COMMAND, -} from '../plugins/context-block' +import { DELETE_CONTEXT_BLOCK_COMMAND } from '../plugins/context-block' import { ContextBlockNode } from '../plugins/context-block/node' import { DELETE_HISTORY_BLOCK_COMMAND } from '../plugins/history-block' import { HistoryBlockNode } from '../plugins/history-block/node' @@ -53,7 +51,7 @@ const mockState = vi.hoisted(() => { node: null as MockNode | null, mergeRegister: vi.fn((...cleanups: Array<() => void>) => { return () => { - cleanups.forEach(cleanup => cleanup()) + cleanups.forEach((cleanup) => cleanup()) } }), removePlainTextTransform: vi.fn(), @@ -88,7 +86,10 @@ vi.mock('lexical', async (importOriginal) => { } }) -const SelectOrDeleteHarness = ({ nodeKey, command }: { +const SelectOrDeleteHarness = ({ + nodeKey, + command, +}: { nodeKey: string command?: SelectOrDeleteCommand }) => { @@ -104,7 +105,10 @@ const SelectOrDeleteHarness = ({ nodeKey, command }: { ) } -const SelectOrDeleteNoRefHarness = ({ nodeKey, command }: { +const SelectOrDeleteNoRefHarness = ({ + nodeKey, + command, +}: { nodeKey: string command?: SelectOrDeleteCommand }) => { @@ -116,7 +120,9 @@ const TriggerHarness = () => { const [ref, open] = useTrigger() return ( <div> - <div ref={ref} data-testid="trigger-target">toggle</div> + <div ref={ref} data-testid="trigger-target"> + toggle + </div> <span>{open ? 'open' : 'closed'}</span> </div> ) @@ -157,12 +163,7 @@ describe('prompt-editor/hooks', () => { describe('useSelectOrDelete', () => { it('should register delete and backspace commands and select node on click', async () => { const user = userEvent.setup() - render( - <SelectOrDeleteHarness - nodeKey="node-1" - command={DELETE_CONTEXT_BLOCK_COMMAND} - />, - ) + render(<SelectOrDeleteHarness nodeKey="node-1" command={DELETE_CONTEXT_BLOCK_COMMAND} />) expect(mockState.editor.registerCommand).toHaveBeenCalledWith( KEY_DELETE_COMMAND, @@ -188,12 +189,7 @@ describe('prompt-editor/hooks', () => { isNodeSelection: false, } - render( - <SelectOrDeleteHarness - nodeKey="node-1" - command={DELETE_CONTEXT_BLOCK_COMMAND} - />, - ) + render(<SelectOrDeleteHarness nodeKey="node-1" command={DELETE_CONTEXT_BLOCK_COMMAND} />) const deleteHandler = mockState.commandHandlers.get(KEY_DELETE_COMMAND) expect(deleteHandler).toBeDefined() @@ -201,7 +197,10 @@ describe('prompt-editor/hooks', () => { const handled = deleteHandler?.(new KeyboardEvent('keydown')) expect(handled).toBe(false) - expect(mockState.editor.dispatchCommand).toHaveBeenCalledWith(DELETE_CONTEXT_BLOCK_COMMAND, undefined) + expect(mockState.editor.dispatchCommand).toHaveBeenCalledWith( + DELETE_CONTEXT_BLOCK_COMMAND, + undefined, + ) }) it('should dispatch delete command when unselected history block is focused', () => { @@ -211,18 +210,16 @@ describe('prompt-editor/hooks', () => { isNodeSelection: false, } - render( - <SelectOrDeleteHarness - nodeKey="node-1" - command={DELETE_HISTORY_BLOCK_COMMAND} - />, - ) + render(<SelectOrDeleteHarness nodeKey="node-1" command={DELETE_HISTORY_BLOCK_COMMAND} />) const deleteHandler = mockState.commandHandlers.get(KEY_DELETE_COMMAND) const handled = deleteHandler?.(new KeyboardEvent('keydown')) expect(handled).toBe(false) - expect(mockState.editor.dispatchCommand).toHaveBeenCalledWith(DELETE_HISTORY_BLOCK_COMMAND, undefined) + expect(mockState.editor.dispatchCommand).toHaveBeenCalledWith( + DELETE_HISTORY_BLOCK_COMMAND, + undefined, + ) }) it('should dispatch delete command when unselected query block is focused', () => { @@ -232,18 +229,16 @@ describe('prompt-editor/hooks', () => { isNodeSelection: false, } - render( - <SelectOrDeleteHarness - nodeKey="node-1" - command={DELETE_QUERY_BLOCK_COMMAND} - />, - ) + render(<SelectOrDeleteHarness nodeKey="node-1" command={DELETE_QUERY_BLOCK_COMMAND} />) const deleteHandler = mockState.commandHandlers.get(KEY_DELETE_COMMAND) const handled = deleteHandler?.(new KeyboardEvent('keydown')) expect(handled).toBe(false) - expect(mockState.editor.dispatchCommand).toHaveBeenCalledWith(DELETE_QUERY_BLOCK_COMMAND, undefined) + expect(mockState.editor.dispatchCommand).toHaveBeenCalledWith( + DELETE_QUERY_BLOCK_COMMAND, + undefined, + ) }) it('should prevent default and remove selected decorator node on delete', () => { @@ -259,12 +254,7 @@ describe('prompt-editor/hooks', () => { remove, } - render( - <SelectOrDeleteHarness - nodeKey="node-1" - command={DELETE_QUERY_BLOCK_COMMAND} - />, - ) + render(<SelectOrDeleteHarness nodeKey="node-1" command={DELETE_QUERY_BLOCK_COMMAND} />) const backspaceHandler = mockState.commandHandlers.get(KEY_BACKSPACE_COMMAND) expect(backspaceHandler).toBeDefined() @@ -273,7 +263,10 @@ describe('prompt-editor/hooks', () => { expect(handled).toBe(true) expect(preventDefault).toHaveBeenCalled() - expect(mockState.editor.dispatchCommand).toHaveBeenCalledWith(DELETE_QUERY_BLOCK_COMMAND, undefined) + expect(mockState.editor.dispatchCommand).toHaveBeenCalledWith( + DELETE_QUERY_BLOCK_COMMAND, + undefined, + ) expect(remove).toHaveBeenCalled() }) @@ -306,9 +299,7 @@ describe('prompt-editor/hooks', () => { } mockState.node = { isDecorator: false, remove: vi.fn() } - render( - <SelectOrDeleteHarness nodeKey="node-1" command={DELETE_QUERY_BLOCK_COMMAND} />, - ) + render(<SelectOrDeleteHarness nodeKey="node-1" command={DELETE_QUERY_BLOCK_COMMAND} />) const deleteHandler = mockState.commandHandlers.get(KEY_DELETE_COMMAND) const handled = deleteHandler?.({ preventDefault } as unknown as KeyboardEvent) @@ -316,9 +307,7 @@ describe('prompt-editor/hooks', () => { }) it('should not select when metaKey is pressed on click', () => { - render( - <SelectOrDeleteHarness nodeKey="node-1" command={DELETE_CONTEXT_BLOCK_COMMAND} />, - ) + render(<SelectOrDeleteHarness nodeKey="node-1" command={DELETE_CONTEXT_BLOCK_COMMAND} />) const node = screen.getByTestId('select-or-delete-node') node.dispatchEvent(new MouseEvent('click', { bubbles: true, metaKey: true })) @@ -328,9 +317,7 @@ describe('prompt-editor/hooks', () => { }) it('should not select when ctrlKey is pressed on click', () => { - render( - <SelectOrDeleteHarness nodeKey="node-1" command={DELETE_CONTEXT_BLOCK_COMMAND} />, - ) + render(<SelectOrDeleteHarness nodeKey="node-1" command={DELETE_CONTEXT_BLOCK_COMMAND} />) const node = screen.getByTestId('select-or-delete-node') node.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true })) @@ -344,7 +331,9 @@ describe('prompt-editor/hooks', () => { <SelectOrDeleteNoRefHarness nodeKey="node-1" command={DELETE_CONTEXT_BLOCK_COMMAND} />, ) - screen.getByTestId('select-or-delete-no-ref').dispatchEvent(new MouseEvent('click', { bubbles: true })) + screen + .getByTestId('select-or-delete-no-ref') + .dispatchEvent(new MouseEvent('click', { bubbles: true })) expect(mockState.clearSelection).not.toHaveBeenCalled() expect(mockState.setSelected).not.toHaveBeenCalled() @@ -384,7 +373,7 @@ describe('prompt-editor/hooks', () => { // Lexical entity hook should register and cleanup transforms. describe('useLexicalTextEntity', () => { it('should register lexical text entity transforms and cleanup on unmount', () => { - class MockTargetNode { } + class MockTargetNode {} const getMatch: LexicalTextEntityGetMatch = vi.fn(() => null) const createNode: LexicalTextEntityCreateNode = vi.fn((textNode: TextNode) => textNode) @@ -418,10 +407,12 @@ describe('prompt-editor/hooks', () => { // Regex trigger matcher behavior for typeahead text detection. describe('useBasicTypeaheadTriggerMatch', () => { it('should return match details when input satisfies trigger and length rules', () => { - const { result } = renderHook(() => useBasicTypeaheadTriggerMatch('@', { - minLength: 2, - maxLength: 5, - })) + const { result } = renderHook(() => + useBasicTypeaheadTriggerMatch('@', { + minLength: 2, + maxLength: 5, + }), + ) const match = result.current('prefix @ab', {} as LexicalEditor) expect(match).toEqual({ @@ -432,27 +423,33 @@ describe('prompt-editor/hooks', () => { }) it('should return null when matching text is shorter than minLength', () => { - const { result } = renderHook(() => useBasicTypeaheadTriggerMatch('@', { - minLength: 2, - maxLength: 5, - })) + const { result } = renderHook(() => + useBasicTypeaheadTriggerMatch('@', { + minLength: 2, + maxLength: 5, + }), + ) expect(result.current('prefix @a', {} as LexicalEditor)).toBeNull() }) it('should return null when matching text exceeds maxLength', () => { - const { result } = renderHook(() => useBasicTypeaheadTriggerMatch('@', { - minLength: 1, - maxLength: 2, - })) + const { result } = renderHook(() => + useBasicTypeaheadTriggerMatch('@', { + minLength: 1, + maxLength: 2, + }), + ) expect(result.current('prefix @abc', {} as LexicalEditor)).toBeNull() }) it('should return null when text has no trigger character', () => { - const { result } = renderHook(() => useBasicTypeaheadTriggerMatch('@', { - minLength: 1, - maxLength: 75, - })) + const { result } = renderHook(() => + useBasicTypeaheadTriggerMatch('@', { + minLength: 1, + maxLength: 75, + }), + ) expect(result.current('no trigger here', {} as LexicalEditor)).toBeNull() }) }) diff --git a/web/app/components/base/prompt-editor/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/__tests__/index.spec.tsx index ee8fc04ef6d7ad..c17b609a553cce 100644 --- a/web/app/components/base/prompt-editor/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/__tests__/index.spec.tsx @@ -4,10 +4,7 @@ import type { ContextBlockType, HistoryBlockType } from '../types' import { render, screen, waitFor } from '@testing-library/react' import { BLUR_COMMAND, FOCUS_COMMAND } from 'lexical' import * as React from 'react' -import { - UPDATE_DATASETS_EVENT_EMITTER, - UPDATE_HISTORY_EVENT_EMITTER, -} from '../constants' +import { UPDATE_DATASETS_EVENT_EMITTER, UPDATE_HISTORY_EVENT_EMITTER } from '../constants' import PromptEditor from '../index' import { CustomTextNode } from '../plugins/custom-text/node' @@ -63,7 +60,7 @@ vi.mock('@/context/event-emitter', () => ({ })) vi.mock('@lexical/code', () => ({ - CodeNode: class CodeNode { }, + CodeNode: class CodeNode {}, })) vi.mock('@lexical/react/LexicalComposerContext', () => ({ @@ -75,9 +72,10 @@ vi.mock('lexical', async (importOriginal) => { return { ...actual, $getRoot: () => ({ - getChildren: () => mocks.rootLines.map(line => ({ - getTextContent: () => line, - })), + getChildren: () => + mocks.rootLines.map((line) => ({ + getTextContent: () => line, + })), getAllTextNodes: () => [], }), $nodesOfType: () => [], @@ -91,7 +89,10 @@ vi.mock('lexical', async (importOriginal) => { }) vi.mock('@lexical/react/LexicalComposer', () => ({ - LexicalComposer: ({ initialConfig, children }: { + LexicalComposer: ({ + initialConfig, + children, + }: { initialConfig: { onError?: (error: Error) => void nodes?: unknown[] @@ -101,8 +102,7 @@ vi.mock('@lexical/react/LexicalComposer', () => ({ if (initialConfig?.onError) { try { initialConfig.onError(new Error('test error')) - } - catch { + } catch { // Ignore the intentional throw from the mocked error boundary path. } } @@ -118,13 +118,17 @@ vi.mock('@lexical/react/LexicalComposer', () => ({ })) vi.mock('../plugins/shortcuts-popup-plugin', () => ({ - default: ({ children }: { children: (closePortal: () => void, onInsert: () => void) => ReactNode }) => ( - <div data-testid="shortcuts-popup-plugin">{children(vi.fn(), vi.fn())}</div> - ), + default: ({ + children, + }: { + children: (closePortal: () => void, onInsert: () => void) => ReactNode + }) => <div data-testid="shortcuts-popup-plugin">{children(vi.fn(), vi.fn())}</div>, })) vi.mock('@lexical/react/LexicalContentEditable', () => ({ - ContentEditable: (props: React.HTMLAttributes<HTMLDivElement>) => <div data-testid="content-editable" {...props} />, + ContentEditable: (props: React.HTMLAttributes<HTMLDivElement>) => ( + <div data-testid="content-editable" {...props} /> + ), })) vi.mock('@lexical/react/LexicalErrorBoundary', () => ({ @@ -136,7 +140,11 @@ vi.mock('@lexical/react/LexicalHistoryPlugin', () => ({ })) vi.mock('@lexical/react/LexicalOnChangePlugin', () => ({ - OnChangePlugin: ({ onChange }: { onChange: (editorState: { read: (fn: () => void) => void }) => void }) => { + OnChangePlugin: ({ + onChange, + }: { + onChange: (editorState: { read: (fn: () => void) => void }) => void + }) => { React.useEffect(() => { onChange({ read: (fn: () => void) => fn(), @@ -147,7 +155,13 @@ vi.mock('@lexical/react/LexicalOnChangePlugin', () => ({ })) vi.mock('@lexical/react/LexicalRichTextPlugin', () => ({ - RichTextPlugin: ({ contentEditable, placeholder }: { contentEditable: ReactNode, placeholder: ReactNode }) => ( + RichTextPlugin: ({ + contentEditable, + placeholder, + }: { + contentEditable: ReactNode + placeholder: ReactNode + }) => ( <div data-testid="rich-text-plugin"> {contentEditable} {placeholder} @@ -166,7 +180,10 @@ vi.mock('@lexical/react/LexicalTypeaheadMenuPlugin', () => ({ })) vi.mock('@lexical/react/LexicalDraggableBlockPlugin', () => ({ - DraggableBlockPlugin_EXPERIMENTAL: ({ menuComponent, targetLineComponent }: { + DraggableBlockPlugin_EXPERIMENTAL: ({ + menuComponent, + targetLineComponent, + }: { menuComponent: ReactNode targetLineComponent: ReactNode }) => ( @@ -211,7 +228,10 @@ describe('PromptEditor', () => { expect(screen.getByText('Type prompt')).toBeInTheDocument() expect(screen.getByTestId('content-editable')).toHaveClass('editor-class') expect(screen.getByTestId('content-editable')).toHaveClass('text-[13px]') - expect(screen.getByTestId('content-editable')).toHaveAttribute('aria-labelledby', 'prompt-label') + expect(screen.getByTestId('content-editable')).toHaveAttribute( + 'aria-labelledby', + 'prompt-label', + ) await waitFor(() => { expect(onChange).toHaveBeenCalledWith('first line\nsecond line') @@ -232,10 +252,7 @@ describe('PromptEditor', () => { } const { rerender } = render( - <PromptEditor - contextBlock={contextBlock} - historyBlock={historyBlock} - />, + <PromptEditor contextBlock={contextBlock} historyBlock={historyBlock} />, ) expect(mocks.emit).toHaveBeenCalledWith({ @@ -277,12 +294,7 @@ describe('PromptEditor', () => { const onFocus = vi.fn() const onBlur = vi.fn() - render( - <PromptEditor - onFocus={onFocus} - onBlur={onBlur} - />, - ) + render(<PromptEditor onFocus={onFocus} onBlur={onBlur} />) const focusHandler = mocks.commandHandlers.get(FOCUS_COMMAND) const blurHandler = mocks.commandHandlers.get(BLUR_COMMAND) @@ -301,16 +313,22 @@ describe('PromptEditor', () => { // Prop typing guard for shortcut popup shape without any-casts. describe('Props Typing', () => { it('should accept typed shortcut popup configuration', () => { - const Popup: NonNullable<PromptEditorProps['shortcutPopups']>[number]['Popup'] = ({ onClose }) => ( - <button type="button" onClick={onClose}>close</button> + const Popup: NonNullable<PromptEditorProps['shortcutPopups']>[number]['Popup'] = ({ + onClose, + }) => ( + <button type="button" onClick={onClose}> + close + </button> ) render( <PromptEditor - shortcutPopups={[{ - hotkey: ['mod', '/'], - Popup, - }]} + shortcutPopups={[ + { + hotkey: ['mod', '/'], + Popup, + }, + ]} />, ) @@ -318,11 +336,19 @@ describe('PromptEditor', () => { }) it('should render multiple shortcutPopups', () => { - const PopupA: NonNullable<PromptEditorProps['shortcutPopups']>[number]['Popup'] = ({ onClose }) => ( - <button data-testid="popup-a" onClick={onClose}>A</button> + const PopupA: NonNullable<PromptEditorProps['shortcutPopups']>[number]['Popup'] = ({ + onClose, + }) => ( + <button data-testid="popup-a" onClick={onClose}> + A + </button> ) - const PopupB: NonNullable<PromptEditorProps['shortcutPopups']>[number]['Popup'] = ({ onClose }) => ( - <button data-testid="popup-b" onClick={onClose}>B</button> + const PopupB: NonNullable<PromptEditorProps['shortcutPopups']>[number]['Popup'] = ({ + onClose, + }) => ( + <button data-testid="popup-b" onClick={onClose}> + B + </button> ) render( @@ -338,9 +364,7 @@ describe('PromptEditor', () => { }) it('should render without onChange and not crash', () => { - expect(() => - render(<PromptEditor compact={false} placeholder="Empty" />), - ).not.toThrow() + expect(() => render(<PromptEditor compact={false} placeholder="Empty" />)).not.toThrow() }) it('should render with editable=false', () => { @@ -375,7 +399,11 @@ describe('PromptEditor', () => { historyBlock={{ show: true, history: { user: 'u', assistant: 'a' } }} variableBlock={{ show: true }} workflowVariableBlock={{ show: true }} - currentBlock={{ show: true, generatorType: 'summarize' as unknown as import('../types').CurrentBlockType['generatorType'] }} + currentBlock={{ + show: true, + generatorType: + 'summarize' as unknown as import('../types').CurrentBlockType['generatorType'], + }} requestURLBlock={{ show: true }} errorMessageBlock={{ show: true }} lastRunBlock={{ show: true }} @@ -385,12 +413,7 @@ describe('PromptEditor', () => { }) it('should render externalToolBlock when variableBlock is not shown', () => { - render( - <PromptEditor - variableBlock={{ show: false }} - externalToolBlock={{ show: true }} - />, - ) + render(<PromptEditor variableBlock={{ show: false }} externalToolBlock={{ show: true }} />) expect(screen.getByTestId('lexical-composer')).toBeInTheDocument() }) diff --git a/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx b/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx index 7146fc09332692..e9568d0b318cfd 100644 --- a/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx +++ b/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx @@ -63,8 +63,7 @@ const setSelectionOnEditable = (editable: HTMLElement) => { if (lexicalTextNode) { range.setStart(lexicalTextNode, 0) range.setEnd(lexicalTextNode, 1) - } - else { + } else { range.selectNodeContents(editable) } @@ -92,7 +91,7 @@ const PromptEditorContentHarness = ({ captures, initialText = '', ...props -}: ComponentProps<typeof PromptEditorContent> & { captures: Captures, initialText?: string }) => ( +}: ComponentProps<typeof PromptEditorContent> & { captures: Captures; initialText?: string }) => ( <EventEmitterContextProvider> <LexicalComposer initialConfig={{ @@ -136,7 +135,9 @@ describe('PromptEditorContent', () => { Range.prototype.getClientRects = vi.fn(() => { const rectList = [mockDOMRect] as unknown as DOMRectList Object.defineProperty(rectList, 'length', { value: 1 }) - Object.defineProperty(rectList, 'item', { value: (index: number) => index === 0 ? mockDOMRect : null }) + Object.defineProperty(rectList, 'item', { + value: (index: number) => (index === 0 ? mockDOMRect : null), + }) return rectList }) Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect) @@ -199,7 +200,10 @@ describe('PromptEditorContent', () => { act(() => { captures.editor?.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus')) - captures.editor?.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: null })) + captures.editor?.dispatchCommand( + BLUR_COMMAND, + new FocusEvent('blur', { relatedTarget: null }), + ) }) expect(onFocus).toHaveBeenCalledTimes(1) @@ -209,7 +213,8 @@ describe('PromptEditorContent', () => { it('should render adjacent agent output tokens as inline output blocks', async () => { const captures: Captures = { editor: null, eventEmitter: null } - const initialText = '[§output:ccc:ccc§][§output:output_3ggg:output_3ggg§][§output:ggg:ggg§][§output:output_3fdfdf:output_3fdfdf§]' + const initialText = + '[§output:ccc:ccc§][§output:output_3ggg:output_3ggg§][§output:ggg:ggg§][§output:output_3fdfdf:output_3fdfdf§]' const { container } = render( <PromptEditorContentHarness @@ -258,9 +263,7 @@ describe('PromptEditorContent', () => { onEditorChange={vi.fn()} agentOutputBlock={{ show: true, - outputs: [ - { name: 'qna_report.pdf', type: 'string' }, - ], + outputs: [{ name: 'qna_report.pdf', type: 'string' }], }} />, ) @@ -275,9 +278,7 @@ describe('PromptEditorContent', () => { const captures: Captures = { editor: null, eventEmitter: null } const outputBlock = { show: true, - outputs: [ - { name: 'summary', type: 'string' as const }, - ], + outputs: [{ name: 'summary', type: 'string' as const }], } const { rerender } = render( @@ -305,9 +306,7 @@ describe('PromptEditorContent', () => { onEditorChange={vi.fn()} agentOutputBlock={{ show: true, - outputs: [ - { name: 'summary', type: 'file' }, - ], + outputs: [{ name: 'summary', type: 'file' }], }} />, ) @@ -347,12 +346,20 @@ describe('PromptEditorContent', () => { act(() => { captures.editor?.update(() => { const visitNode = (node: Parameters<typeof $isElementNode>[0]) => { - if (!$isElementNode(node)) - return + if (!$isElementNode(node)) return node.getChildren().forEach((child) => { if (child instanceof AgentOutputBlockNode) { - child.setOutput('summary', 'string', true, nextOutputs, initialOnChange, undefined, false, true) + child.setOutput( + 'summary', + 'string', + true, + nextOutputs, + initialOnChange, + undefined, + false, + true, + ) return } @@ -383,17 +390,16 @@ describe('PromptEditorContent', () => { captures.editor!.getEditorState().read(() => { const root = $getRoot() - const findOutputNode = (node: Parameters<typeof $isElementNode>[0]): AgentOutputBlockNode | null => { - if (!$isElementNode(node)) - return null + const findOutputNode = ( + node: Parameters<typeof $isElementNode>[0], + ): AgentOutputBlockNode | null => { + if (!$isElementNode(node)) return null for (const child of node.getChildren()) { - if (child instanceof AgentOutputBlockNode) - return child + if (child instanceof AgentOutputBlockNode) return child const outputNode = findOutputNode(child) - if (outputNode) - return outputNode + if (outputNode) return outputNode } return null @@ -412,10 +418,20 @@ describe('PromptEditorContent', () => { const onEditorChange = vi.fn() const insertCommand = createCommand<string[]>('prompt-editor-shortcut-insert') const insertSpy = vi.fn() - const Popup = ({ onClose, onInsert }: { onClose: () => void, onInsert: (command: typeof insertCommand, params: string[]) => void }) => ( + const Popup = ({ + onClose, + onInsert, + }: { + onClose: () => void + onInsert: (command: typeof insertCommand, params: string[]) => void + }) => ( <> - <button type="button" onClick={() => onInsert(insertCommand, ['from-shortcut'])}>Insert shortcut</button> - <button type="button" onClick={onClose}>Close shortcut</button> + <button type="button" onClick={() => onInsert(insertCommand, ['from-shortcut'])}> + Insert shortcut + </button> + <button type="button" onClick={onClose}> + Close shortcut + </button> </> ) diff --git a/web/app/components/base/prompt-editor/__tests__/utils.spec.ts b/web/app/components/base/prompt-editor/__tests__/utils.spec.ts index 2af7628fcee17f..bd6080a4726500 100644 --- a/web/app/components/base/prompt-editor/__tests__/utils.spec.ts +++ b/web/app/components/base/prompt-editor/__tests__/utils.spec.ts @@ -1,9 +1,4 @@ -import type { - Klass, - LexicalEditor, - LexicalNode, - TextNode, -} from 'lexical' +import type { Klass, LexicalEditor, LexicalNode, TextNode } from 'lexical' import type { CustomTextNode } from '../plugins/custom-text/node' import type { MenuTextMatch } from '../types' import { @@ -23,14 +18,15 @@ vi.mock('lexical', async (importOriginal) => { return { ...actual, $getSelection: () => mockState.selection, - $isRangeSelection: (selection: unknown) => !!(selection as { __isRangeSelection?: boolean } | null)?.__isRangeSelection, + $isRangeSelection: (selection: unknown) => + !!(selection as { __isRangeSelection?: boolean } | null)?.__isRangeSelection, $createTextNode: mockState.createTextNode, $isTextNode: (node: unknown) => !!(node as { __isTextNode?: boolean } | null)?.__isTextNode, } }) vi.mock('./plugins/custom-text/node', () => ({ - CustomTextNode: class MockCustomTextNode { }, + CustomTextNode: class MockCustomTextNode {}, })) describe('prompt-editor/utils', () => { @@ -282,7 +278,7 @@ describe('prompt-editor/utils', () => { getLatest = vi.fn(() => ({ __mode: 0 })) } const getMatch = vi.fn(() => null) - class TargetNode { } + class TargetNode {} const { editor, registerNodeTransform } = makeEditor() const createdTextNode = { setFormat: vi.fn() } mockState.createTextNode.mockReturnValue(createdTextNode) @@ -319,7 +315,7 @@ describe('prompt-editor/utils', () => { getLatest = vi.fn(() => ({ __mode: 0 })) } const getMatch = vi.fn(() => null) - class TargetNode { } + class TargetNode {} const { editor, registerNodeTransform } = makeEditor() mockState.createTextNode.mockReturnValue({ setFormat: vi.fn() }) type TN = InstanceType<typeof TargetNode> & TextNode @@ -354,7 +350,7 @@ describe('prompt-editor/utils', () => { return callCount === 1 ? { start: 6, end: 10 } : null }) const replacementNode = { setFormat: vi.fn(), replace: vi.fn() } - class TargetNode { } + class TargetNode {} const { editor, registerNodeTransform } = makeEditor() mockState.createTextNode.mockReturnValue({ setFormat: vi.fn() }) type TN = InstanceType<typeof TargetNode> & TextNode @@ -394,10 +390,7 @@ describe('prompt-editor/utils', () => { getNextSibling: vi.fn(() => null), splitText: vi.fn(() => [replacedNode, null]), } as unknown as CustomTextNode - const getMatch = vi - .fn() - .mockReturnValueOnce({ start: 0, end: 1 }) - .mockReturnValueOnce(null) + const getMatch = vi.fn().mockReturnValueOnce({ start: 0, end: 1 }).mockReturnValueOnce(null) const createdNode = { id: 'created' } const createNode = vi.fn(() => createdNode as unknown as LexicalNode) @@ -421,7 +414,11 @@ describe('prompt-editor/utils', () => { splitText: vi.fn(), } as unknown as CustomTextNode - decoratorTransform(node, vi.fn(() => null), vi.fn()) + decoratorTransform( + node, + vi.fn(() => null), + vi.fn(), + ) expect(nextSibling.markDirty).toHaveBeenCalled() }) @@ -570,7 +567,11 @@ describe('prompt-editor/utils', () => { anchor: { type: 'text', offset: 1, getNode: () => anchorNode }, } // replaceableString longer than offset → startOffset < 0 - const longMatch: MenuTextMatch = { leadOffset: 0, matchingString: 'abc', replaceableString: '@abcdef' } + const longMatch: MenuTextMatch = { + leadOffset: 0, + matchingString: 'abc', + replaceableString: '@abcdef', + } expect($splitNodeContainingQuery(longMatch)).toBeNull() }) @@ -699,7 +700,7 @@ describe('prompt-editor/utils', () => { it('should return when nextSibling nextMatch.start !== 0 (line 122-123)', () => { // Similar to decoratorTransform but for textNodeTransform - class TargetNode { } + class TargetNode {} const nextSibling = { __isTextNode: true, isTextEntity: vi.fn(() => false), @@ -743,7 +744,7 @@ describe('prompt-editor/utils', () => { // (this actually tests line 134 indirectly by ensuring line 152 exits; and also line 134=true) // The cleanest way to reach line 134 is: match is null AND nextText is '' AND no nextSibling // That happens when match===null at the start of the while loop: nextText='', no nextSibling → exit - class TargetNode { } + class TargetNode {} const nodeToReplace = { replace: vi.fn(), getFormat: vi.fn(() => 0) } class NodeUnderTest { __isTextNode = true @@ -762,8 +763,7 @@ describe('prompt-editor/utils', () => { let n = 0 const getMatch = vi.fn(() => { n++ - if (n === 1) - return { start: 0, end: 3 } // first iter: match found → splitText → currentNode=null + if (n === 1) return { start: 0, end: 3 } // first iter: match found → splitText → currentNode=null return null // second iter would return null, but we exit at line 152 before this }) const replacementNode = { setFormat: vi.fn(), replace: vi.fn() } @@ -783,7 +783,7 @@ describe('prompt-editor/utils', () => { it('should continue loop when prevSibling isTextEntity and match.start===0 (line 137-138)', () => { // Ensure no prevSibling (so prevSibling processing is skipped) and the node gets a match // at start=0 with a prevSibling that isTextEntity → continue - class TargetNode { } + class TargetNode {} // prevSibling has no __isTextNode → $isTextNode returns false → skip prevSibling block const prevSiblingEntity = { // No __isTextNode so $isTextNode=false, but getNode returns this for prevSibling @@ -818,8 +818,7 @@ describe('prompt-editor/utils', () => { // nextText = 'abc'.slice(3) = '' → nextSibling=null → no nextSibling branch // match not null → check line 137: start===0 && prevSibling.__isTextNode && isTextEntity=true → continue! // call 3 (continue, while loop again): match=getMatch('') = null → return at line 134 - if (n <= 2) - return { start: 0, end: 3 } + if (n <= 2) return { start: 0, end: 3 } return null }) @@ -909,7 +908,7 @@ describe('prompt-editor/utils', () => { // --------------------------------------------------------------------------- describe('registerLexicalTextEntity - remaining textNodeTransform branches', () => { it('textNodeTransform: returns immediately when node is not simple text (line 58-59)', () => { - class TargetNode { } + class TargetNode {} class NodeUnderTest { __isTextNode = true isSimpleText = vi.fn(() => false) // NOT simple text @@ -956,8 +955,7 @@ describe('prompt-editor/utils', () => { const { editor, registerNodeTransform } = makeEditor() mockState.createTextNode.mockReturnValue({ setFormat: vi.fn() }) const getMatch = vi.fn((text: string) => { - if (text === '@abcd') - return { start: 0, end: 4 } // prevMatch + if (text === '@abcd') return { start: 0, end: 4 } // prevMatch return null }) @@ -1029,8 +1027,7 @@ describe('prompt-editor/utils', () => { } // combinedText='@abc', prevMatch.end=4 → diff=4-3=1, text.length=1 → diff===text.length → node.remove() const getMatch = vi.fn((text: string) => { - if (text === '@abc') - return { start: 0, end: 4 } + if (text === '@abc') return { start: 0, end: 4 } return null }) const { editor, registerNodeTransform } = makeEditor() @@ -1051,7 +1048,7 @@ describe('prompt-editor/utils', () => { it('textNodeTransform: returns when nextText is non-empty and nextMatch.start===0 (line 130-131)', () => { // In the else branch (nextText !== ''): if nextMatch !== null && nextMatch.start===0 → return - class TargetNode { } + class TargetNode {} class NodeUnderTest { __isTextNode = true isSimpleText = vi.fn(() => true) @@ -1069,10 +1066,8 @@ describe('prompt-editor/utils', () => { let n = 0 const getMatch = vi.fn(() => { n++ - if (n === 1) - return { start: 0, end: 3 } // first iter: nextText='abcdef'.slice(3)='def' (non-empty) - if (n === 2) - return { start: 0, end: 3 } // second call (on nextText='def'): start===0 → return at line 131 + if (n === 1) return { start: 0, end: 3 } // first iter: nextText='abcdef'.slice(3)='def' (non-empty) + if (n === 2) return { start: 0, end: 3 } // second call (on nextText='def'): start===0 → return at line 131 return null }) const { editor, registerNodeTransform } = makeEditor() diff --git a/web/app/components/base/prompt-editor/constants.tsx b/web/app/components/base/prompt-editor/constants.tsx index 489821ce7c5553..c2be6080514adf 100644 --- a/web/app/components/base/prompt-editor/constants.tsx +++ b/web/app/components/base/prompt-editor/constants.tsx @@ -14,35 +14,31 @@ export const UPDATE_DATASETS_EVENT_EMITTER = 'prompt-editor-context-block-update export const UPDATE_HISTORY_EVENT_EMITTER = 'prompt-editor-history-block-update-role' export const checkHasContextBlock = (text: string) => { - if (!text) - return false + if (!text) return false return text.includes(CONTEXT_PLACEHOLDER_TEXT) } export const checkHasHistoryBlock = (text: string) => { - if (!text) - return false + if (!text) return false return text.includes(HISTORY_PLACEHOLDER_TEXT) } export const checkHasQueryBlock = (text: string) => { - if (!text) - return false + if (!text) return false return text.includes(QUERY_PLACEHOLDER_TEXT) } /* -* {{#1711617514996.name#}} => [1711617514996, name] -* {{#1711617514996.sys.query#}} => [sys, query] -*/ + * {{#1711617514996.name#}} => [1711617514996, name] + * {{#1711617514996.sys.query#}} => [sys, query] + */ export const getInputVars = (text: string): ValueSelector[] => { - if (!text || typeof text !== 'string') - return [] + if (!text || typeof text !== 'string') return [] const allVars = text.match(/\{\{#([^#]*)#\}\}/g) if (allVars && allVars?.length > 0) { // {{#context#}}, {{#query#}} is not input vars const inputVars = allVars - .filter(item => item.includes('.')) + .filter((item) => item.includes('.')) .map((item) => { const valueSelector = item.replace('{{#', '').replace('#}}', '').split('.') if (valueSelector[1] === 'sys' && /^\d+$/.test(valueSelector[0]!)) @@ -57,7 +53,25 @@ export const getInputVars = (text: string): ValueSelector[] => { export const FILE_EXTS: Record<string, string[]> = { [SupportUploadFileTypes.image]: ['JPG', 'JPEG', 'PNG', 'GIF', 'WEBP', 'SVG'], - [SupportUploadFileTypes.document]: ['TXT', 'MD', 'MDX', 'MARKDOWN', 'PDF', 'HTML', 'XLSX', 'XLS', 'DOC', 'DOCX', 'CSV', 'EML', 'MSG', 'PPTX', 'PPT', 'XML', 'EPUB'], + [SupportUploadFileTypes.document]: [ + 'TXT', + 'MD', + 'MDX', + 'MARKDOWN', + 'PDF', + 'HTML', + 'XLSX', + 'XLS', + 'DOC', + 'DOCX', + 'CSV', + 'EML', + 'MSG', + 'PPTX', + 'PPT', + 'XML', + 'EPUB', + ], [SupportUploadFileTypes.audio]: ['MP3', 'M4A', 'WAV', 'AMR', 'MPGA'], [SupportUploadFileTypes.video]: ['MP4', 'MOV', 'MPEG', 'WEBM'], } diff --git a/web/app/components/base/prompt-editor/hooks.ts b/web/app/components/base/prompt-editor/hooks.ts index ff95cc497c49eb..d4d2922dccbe89 100644 --- a/web/app/components/base/prompt-editor/hooks.ts +++ b/web/app/components/base/prompt-editor/hooks.ts @@ -1,17 +1,10 @@ import type { EntityMatch } from '@lexical/text' -import type { - Klass, - LexicalCommand, - LexicalEditor, - TextNode, -} from 'lexical' +import type { Klass, LexicalCommand, LexicalEditor, TextNode } from 'lexical' import type { Dispatch, RefObject, SetStateAction } from 'react' import type { CustomTextNode } from './plugins/custom-text/node' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection' -import { - mergeRegister, -} from '@lexical/utils' +import { mergeRegister } from '@lexical/utils' import { $getNodeByKey, $getSelection, @@ -21,12 +14,7 @@ import { KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, } from 'lexical' -import { - useCallback, - useEffect, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { DELETE_CONTEXT_BLOCK_COMMAND } from './plugins/context-block' import { $isContextBlockNode } from './plugins/context-block/node' import { DELETE_HISTORY_BLOCK_COMMAND } from './plugins/history-block' @@ -35,8 +23,14 @@ import { DELETE_QUERY_BLOCK_COMMAND } from './plugins/query-block' import { $isQueryBlockNode } from './plugins/query-block/node' import { registerLexicalTextEntity } from './utils' -type UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand<undefined>) => [RefObject<HTMLDivElement | null>, boolean] -export const useSelectOrDelete: UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand<undefined>) => { +type UseSelectOrDeleteHandler = ( + nodeKey: string, + command?: LexicalCommand<undefined>, +) => [RefObject<HTMLDivElement | null>, boolean] +export const useSelectOrDelete: UseSelectOrDeleteHandler = ( + nodeKey: string, + command?: LexicalCommand<undefined>, +) => { const ref = useRef<HTMLDivElement>(null) const [editor] = useLexicalComposerContext() const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey) @@ -46,13 +40,11 @@ export const useSelectOrDelete: UseSelectOrDeleteHandler = (nodeKey: string, com const selection = $getSelection() const nodes = selection?.getNodes() if ( - !isSelected - && nodes?.length === 1 - && ( - ($isContextBlockNode(nodes[0]) && command === DELETE_CONTEXT_BLOCK_COMMAND) - || ($isHistoryBlockNode(nodes[0]) && command === DELETE_HISTORY_BLOCK_COMMAND) - || ($isQueryBlockNode(nodes[0]) && command === DELETE_QUERY_BLOCK_COMMAND) - ) + !isSelected && + nodes?.length === 1 && + (($isContextBlockNode(nodes[0]) && command === DELETE_CONTEXT_BLOCK_COMMAND) || + ($isHistoryBlockNode(nodes[0]) && command === DELETE_HISTORY_BLOCK_COMMAND) || + ($isQueryBlockNode(nodes[0]) && command === DELETE_QUERY_BLOCK_COMMAND)) ) { editor.dispatchCommand(command, undefined) } @@ -61,8 +53,7 @@ export const useSelectOrDelete: UseSelectOrDeleteHandler = (nodeKey: string, com event.preventDefault() const node = $getNodeByKey(nodeKey) if ($isDecoratorNode(node)) { - if (command) - editor.dispatchCommand(command, undefined) + if (command) editor.dispatchCommand(command, undefined) node.remove() return true @@ -74,59 +65,54 @@ export const useSelectOrDelete: UseSelectOrDeleteHandler = (nodeKey: string, com [isSelected, nodeKey, command, editor], ) - const handleSelect = useCallback((e: MouseEvent) => { - if (!e.metaKey && !e.ctrlKey) { - e.stopPropagation() - clearSelection() - setSelected(true) - } - }, [setSelected, clearSelection]) + const handleSelect = useCallback( + (e: MouseEvent) => { + if (!e.metaKey && !e.ctrlKey) { + e.stopPropagation() + clearSelection() + setSelected(true) + } + }, + [setSelected, clearSelection], + ) useEffect(() => { const ele = ref.current - if (ele) - ele.addEventListener('click', handleSelect) + if (ele) ele.addEventListener('click', handleSelect) return () => { - if (ele) - ele.removeEventListener('click', handleSelect) + if (ele) ele.removeEventListener('click', handleSelect) } }, [handleSelect]) useEffect(() => { return mergeRegister( - editor.registerCommand( - KEY_DELETE_COMMAND, - handleDelete, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_BACKSPACE_COMMAND, - handleDelete, - COMMAND_PRIORITY_LOW, - ), + editor.registerCommand(KEY_DELETE_COMMAND, handleDelete, COMMAND_PRIORITY_LOW), + editor.registerCommand(KEY_BACKSPACE_COMMAND, handleDelete, COMMAND_PRIORITY_LOW), ) }, [editor, clearSelection, handleDelete]) return [ref, isSelected] } -type UseTriggerHandler = <T extends HTMLElement = HTMLDivElement>() => [RefObject<T | null>, boolean, Dispatch<SetStateAction<boolean>>] +type UseTriggerHandler = <T extends HTMLElement = HTMLDivElement>() => [ + RefObject<T | null>, + boolean, + Dispatch<SetStateAction<boolean>>, +] export const useTrigger: UseTriggerHandler = <T extends HTMLElement = HTMLDivElement>() => { const triggerRef = useRef<T>(null) const [open, setOpen] = useState(false) const handleOpen = useCallback((e: MouseEvent) => { e.stopPropagation() - setOpen(v => !v) + setOpen((v) => !v) }, []) useEffect(() => { const trigger = triggerRef.current - if (trigger) - trigger.addEventListener('click', handleOpen) + if (trigger) trigger.addEventListener('click', handleOpen) return () => { - if (trigger) - trigger.removeEventListener('click', handleOpen) + if (trigger) trigger.removeEventListener('click', handleOpen) } }, [handleOpen]) @@ -150,24 +136,18 @@ type MenuTextMatch = { matchingString: string replaceableString: string } -type TriggerFn = ( - text: string, - editor: LexicalEditor, -) => MenuTextMatch | null +type TriggerFn = (text: string, editor: LexicalEditor) => MenuTextMatch | null const escapeForCharacterClass = (value: string) => value.replace(/[[\]\\^-]/g, '\\$&') export function useBasicTypeaheadTriggerMatch( trigger: string, - { minLength = 1, maxLength = 75 }: { minLength?: number, maxLength?: number }, + { minLength = 1, maxLength = 75 }: { minLength?: number; maxLength?: number }, ): TriggerFn { return useCallback( (text: string) => { const escapedTrigger = escapeForCharacterClass(trigger) const validChars = `[^${escapedTrigger}\\n\\r]` const TypeaheadTriggerRegex = new RegExp( - '(.*)(' - + `[${escapedTrigger}]` - + `((?:${validChars}){0,${maxLength}})` - + ')$', + '(.*)(' + `[${escapedTrigger}]` + `((?:${validChars}){0,${maxLength}})` + ')$', ) const match = TypeaheadTriggerRegex.exec(text) if (match !== null) { diff --git a/web/app/components/base/prompt-editor/index.stories.tsx b/web/app/components/base/prompt-editor/index.stories.tsx index d166813c2917db..a9376c53093dcd 100644 --- a/web/app/components/base/prompt-editor/index.stories.tsx +++ b/web/app/components/base/prompt-editor/index.stories.tsx @@ -2,7 +2,15 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' import { useState } from 'react' // Mock component to avoid complex initialization issues -const PromptEditorMock = ({ value, onChange, placeholder, editable, compact, className, wrapperClassName }: any) => { +const PromptEditorMock = ({ + value, + onChange, + placeholder, + editable, + compact, + className, + wrapperClassName, +}: any) => { const [content, setContent] = useState(value || '') const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { @@ -31,7 +39,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Rich text prompt editor built on Lexical. Supports variable blocks, context blocks, and slash commands for inserting dynamic content. Use `/` or `{` to trigger component picker.\n\n**Note:** This is a simplified version for Storybook. The actual component uses Lexical editor with advanced features.', + component: + 'Rich text prompt editor built on Lexical. Supports variable blocks, context blocks, and slash commands for inserting dynamic content. Use `/` or `{` to trigger component picker.\n\n**Note:** This is a simplified version for Storybook. The actual component uses Lexical editor with advanced features.', }, }, }, @@ -86,9 +95,7 @@ const PromptEditorDemo = (args: any) => { {value && ( <div className="mt-4 rounded-lg bg-gray-50 p-3"> <div className="mb-2 text-xs font-medium text-gray-600">Current Value:</div> - <div className="font-mono text-sm whitespace-pre-wrap text-gray-800"> - {value} - </div> + <div className="font-mono text-sm whitespace-pre-wrap text-gray-800">{value}</div> </div> )} </div> @@ -97,7 +104,7 @@ const PromptEditorDemo = (args: any) => { // Default state export const Default: Story = { - render: args => <PromptEditorDemo {...args} />, + render: (args) => <PromptEditorDemo {...args} />, args: { placeholder: 'Type / for commands...', editable: true, @@ -107,7 +114,7 @@ export const Default: Story = { // With initial value export const WithInitialValue: Story = { - render: args => <PromptEditorDemo {...args} />, + render: (args) => <PromptEditorDemo {...args} />, args: { value: 'Write a summary about the following topic:\n\nPlease include key points and examples.', placeholder: 'Type / for commands...', @@ -117,7 +124,7 @@ export const WithInitialValue: Story = { // Compact mode export const CompactMode: Story = { - render: args => <PromptEditorDemo {...args} />, + render: (args) => <PromptEditorDemo {...args} />, args: { value: 'This is a compact editor with smaller text size.', placeholder: 'Type / for commands...', @@ -128,16 +135,17 @@ export const CompactMode: Story = { // Read-only mode export const ReadOnlyMode: Story = { - render: args => <PromptEditorDemo {...args} />, + render: (args) => <PromptEditorDemo {...args} />, args: { - value: 'This content is read-only and cannot be edited.\n\nYou can select and copy text, but not modify it.', + value: + 'This content is read-only and cannot be edited.\n\nYou can select and copy text, but not modify it.', editable: false, }, } // With variables example export const WithVariablesExample: Story = { - render: args => <PromptEditorDemo {...args} />, + render: (args) => <PromptEditorDemo {...args} />, args: { value: 'Hello, please analyze the following data and provide insights.', placeholder: 'Type / to insert variables...', @@ -147,7 +155,7 @@ export const WithVariablesExample: Story = { // Long content example export const LongContent: Story = { - render: args => <PromptEditorDemo {...args} />, + render: (args) => <PromptEditorDemo {...args} />, args: { value: `You are a helpful AI assistant. Your task is to provide accurate, helpful, and friendly responses. @@ -165,7 +173,7 @@ Please analyze the user's request and provide a comprehensive response.`, // Custom placeholder export const CustomPlaceholder: Story = { - render: args => <PromptEditorDemo {...args} />, + render: (args) => <PromptEditorDemo {...args} />, args: { placeholder: 'Describe the task you want the AI to perform... (Press / for variables)', editable: true, @@ -207,17 +215,13 @@ const MultipleEditorsDemo = () => { <div className="text-sm whitespace-pre-wrap text-gray-800"> {systemPrompt && ( <> - <strong>System:</strong> - {' '} - {systemPrompt} + <strong>System:</strong> {systemPrompt} {userPrompt && '\n\n'} </> )} {userPrompt && ( <> - <strong>User:</strong> - {' '} - {userPrompt} + <strong>User:</strong> {userPrompt} </> )} </div> @@ -297,11 +301,7 @@ const ChatPromptBuilderDemo = () => { <div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6"> <div className="mb-4 flex items-center justify-between"> <h3 className="text-lg font-semibold">Chat Prompt Builder</h3> - <span className="text-xs text-gray-500"> - {characterCount} - {' '} - characters - </span> + <span className="text-xs text-gray-500">{characterCount} characters</span> </div> <div className="min-h-[200px] rounded-lg border border-gray-300 bg-gray-50 p-4"> <PromptEditorMock @@ -311,14 +311,7 @@ const ChatPromptBuilderDemo = () => { /> </div> <div className="mt-4 rounded-lg bg-blue-50 p-3 text-sm text-blue-800"> - 💡 - {' '} - <strong>Tip:</strong> - {' '} - Type - {' '} - <code className="rounded-sm bg-blue-100 px-1 py-0.5">/</code> - {' '} + 💡 <strong>Tip:</strong> Type <code className="rounded-sm bg-blue-100 px-1 py-0.5">/</code>{' '} to insert variables or templates </div> </div> @@ -366,7 +359,7 @@ export const APIInstructionEditor: Story = { // Interactive playground export const Playground: Story = { - render: args => <PromptEditorDemo {...args} />, + render: (args) => <PromptEditorDemo {...args} />, args: { value: '', placeholder: 'Type / for commands...', diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index b5f493c605239c..f893022912e602 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -1,11 +1,13 @@ 'use client' import type { InitialConfigType } from '@lexical/react/LexicalComposer' -import type { - EditorState, -} from 'lexical' +import type { EditorState } from 'lexical' import type { FC } from 'react' -import type { Hotkey, ShortcutPopupDisplayMode, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin' +import type { + Hotkey, + ShortcutPopupDisplayMode, + ShortcutPopupInsertHandler, +} from './plugins/shortcuts-popup-plugin' import type { AgentOutputBlockType, ContextBlockType, @@ -25,49 +27,24 @@ import { cn } from '@langgenius/dify-ui/cn' import { CodeNode } from '@lexical/code' import { LexicalComposer } from '@lexical/react/LexicalComposer' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' -import { - $getRoot, - TextNode, -} from 'lexical' +import { $getRoot, TextNode } from 'lexical' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useEventEmitterContextContext } from '@/context/event-emitter' -import { - UPDATE_DATASETS_EVENT_EMITTER, - UPDATE_HISTORY_EVENT_EMITTER, -} from './constants' +import { UPDATE_DATASETS_EVENT_EMITTER, UPDATE_HISTORY_EVENT_EMITTER } from './constants' import { AgentOutputBlockNode } from './plugins/agent-output-block/node' -import { - ContextBlockNode, -} from './plugins/context-block' -import { - CurrentBlockNode, -} from './plugins/current-block' +import { ContextBlockNode } from './plugins/context-block' +import { CurrentBlockNode } from './plugins/current-block' import { CustomTextNode } from './plugins/custom-text/node' -import { - ErrorMessageBlockNode, -} from './plugins/error-message-block' - -import { - HistoryBlockNode, -} from './plugins/history-block' -import { - HITLInputNode, -} from './plugins/hitl-input-block' -import { - LastRunBlockNode, -} from './plugins/last-run-block' -import { - QueryBlockNode, -} from './plugins/query-block' -import { - RequestURLBlockNode, -} from './plugins/request-url-block' +import { ErrorMessageBlockNode } from './plugins/error-message-block' +import { HistoryBlockNode } from './plugins/history-block' +import { HITLInputNode } from './plugins/hitl-input-block' +import { LastRunBlockNode } from './plugins/last-run-block' +import { QueryBlockNode } from './plugins/query-block' +import { RequestURLBlockNode } from './plugins/request-url-block' import { RosterReferenceBlockNode } from './plugins/roster-reference-block/node' import { VariableValueBlockNode } from './plugins/variable-value-block/node' -import { - WorkflowVariableBlockNode, -} from './plugins/workflow-variable-block' +import { WorkflowVariableBlockNode } from './plugins/workflow-variable-block' import PromptEditorContent from './prompt-editor-content' import { textToEditorState } from './utils' @@ -75,25 +52,27 @@ const ValueSyncPlugin: FC<{ value?: string }> = ({ value }) => { const [editor] = useLexicalComposerContext() useEffect(() => { - if (value === undefined) - return + if (value === undefined) return const incomingValue = value ?? '' const shouldUpdate = editor.getEditorState().read(() => { - const currentText = $getRoot().getChildren().map(node => node.getTextContent()).join('\n') + const currentText = $getRoot() + .getChildren() + .map((node) => node.getTextContent()) + .join('\n') return currentText !== incomingValue }) - if (!shouldUpdate) - return + if (!shouldUpdate) return const editorState = editor.parseEditorState(textToEditorState(incomingValue)) editor.setEditorState(editorState) editor.update(() => { - $getRoot().getAllTextNodes().forEach((node) => { - if (node instanceof CustomTextNode) - node.markDirty() - }) + $getRoot() + .getAllTextNodes() + .forEach((node) => { + if (node instanceof CustomTextNode) node.markDirty() + }) }) }, [editor, value]) @@ -110,7 +89,10 @@ const EditableSyncPlugin: FC<{ editable: boolean }> = ({ editable }) => { return null } -type PromptEditorAriaProps = Pick<React.AriaAttributes, 'aria-controls' | 'aria-haspopup' | 'aria-label' | 'aria-labelledby'> +type PromptEditorAriaProps = Pick< + React.AriaAttributes, + 'aria-controls' | 'aria-haspopup' | 'aria-label' | 'aria-labelledby' +> export type PromptEditorProps = PromptEditorAriaProps & { instanceId?: string @@ -145,7 +127,7 @@ export type PromptEditorProps = PromptEditorAriaProps & { shortcutPopups?: Array<{ hotkey: Hotkey displayMode?: ShortcutPopupDisplayMode - Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }> + Popup: React.ComponentType<{ onClose: () => void; onInsert: ShortcutPopupInsertHandler }> }> } @@ -220,10 +202,12 @@ const PromptEditor: FC<PromptEditorProps> = ({ const handleEditorChange = (editorState: EditorState) => { const text = editorState.read(() => { - return $getRoot().getChildren().map(p => p.getTextContent()).join('\n') + return $getRoot() + .getChildren() + .map((p) => p.getTextContent()) + .join('\n') }) - if (onChange) - onChange(text) + if (onChange) onChange(text) } useEffect(() => { @@ -243,8 +227,7 @@ const PromptEditor: FC<PromptEditorProps> = ({ const onRef = useCallback((nextFloatingAnchorElem: HTMLDivElement | null) => { setFloatingAnchorElem((currentFloatingAnchorElem) => { - if (currentFloatingAnchorElem === nextFloatingAnchorElem) - return currentFloatingAnchorElem + if (currentFloatingAnchorElem === nextFloatingAnchorElem) return currentFloatingAnchorElem return nextFloatingAnchorElem }) diff --git a/web/app/components/base/prompt-editor/plugins/__tests__/on-blur-or-focus-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/__tests__/on-blur-or-focus-block.spec.tsx index 63e62f845cd6ed..cc9375c15f7a2b 100644 --- a/web/app/components/base/prompt-editor/plugins/__tests__/on-blur-or-focus-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/__tests__/on-blur-or-focus-block.spec.tsx @@ -1,17 +1,11 @@ import type { LexicalEditor } from 'lexical' import { LexicalComposer } from '@lexical/react/LexicalComposer' import { act, render, waitFor } from '@testing-library/react' -import { - BLUR_COMMAND, - FOCUS_COMMAND, -} from 'lexical' +import { BLUR_COMMAND, FOCUS_COMMAND } from 'lexical' import OnBlurBlock from '../on-blur-or-focus-block' import { CaptureEditorPlugin } from '../test-utils' -const renderOnBlurBlock = (props?: { - onBlur?: () => void - onFocus?: () => void -}) => { +const renderOnBlurBlock = (props?: { onBlur?: () => void; onFocus?: () => void }) => { let editor: LexicalEditor | null = null const setEditor = (value: LexicalEditor) => { @@ -85,7 +79,10 @@ describe('OnBlurBlock', () => { let handled = false act(() => { - handled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('button'))) + handled = editor!.dispatchCommand( + BLUR_COMMAND, + createBlurEvent(document.createElement('button')), + ) }) expect(handled).toBe(true) @@ -104,7 +101,10 @@ describe('OnBlurBlock', () => { let handled = false act(() => { - handled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div'))) + handled = editor!.dispatchCommand( + BLUR_COMMAND, + createBlurEvent(document.createElement('div')), + ) }) expect(handled).toBe(true) @@ -168,7 +168,10 @@ describe('OnBlurBlock', () => { let blurHandled = true let focusHandled = true act(() => { - blurHandled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div'))) + blurHandled = editor!.dispatchCommand( + BLUR_COMMAND, + createBlurEvent(document.createElement('div')), + ) focusHandled = editor!.dispatchCommand(FOCUS_COMMAND, createFocusEvent()) }) diff --git a/web/app/components/base/prompt-editor/plugins/__tests__/test-helper.spec.ts b/web/app/components/base/prompt-editor/plugins/__tests__/test-helper.spec.ts index 00e2a82e3f2191..a7a15ac18e3f38 100644 --- a/web/app/components/base/prompt-editor/plugins/__tests__/test-helper.spec.ts +++ b/web/app/components/base/prompt-editor/plugins/__tests__/test-helper.spec.ts @@ -61,16 +61,23 @@ describe('test-helpers', () => { const editor = await waitForEditorReady(getEditor) expect(() => { - editor.update(() => { - throw new Error('test error') - }, { discrete: true }) + editor.update( + () => { + throw new Error('test error') + }, + { discrete: true }, + ) }).toThrow('test error') }) }) describe('selectRootEnd', () => { it('should select the end of the root', async () => { - const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null }) + const { getEditor } = renderLexicalEditor({ + namespace: 'test', + nodes: [ParagraphNode, TextNode], + children: null, + }) const editor = await waitForEditorReady(getEditor) selectRootEnd(editor) @@ -88,17 +95,24 @@ describe('test-helpers', () => { describe('Content Reading/Writing Helpers', () => { it('should read root text content', async () => { - const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null }) + const { getEditor } = renderLexicalEditor({ + namespace: 'test', + nodes: [ParagraphNode, TextNode], + children: null, + }) const editor = await waitForEditorReady(getEditor) act(() => { - editor.update(() => { - const root = $getRoot() - root.clear() - const paragraph = $createParagraphNode() - paragraph.append($createTextNode('Hello World')) - root.append(paragraph) - }, { discrete: true }) + editor.update( + () => { + const root = $getRoot() + root.clear() + const paragraph = $createParagraphNode() + paragraph.append($createTextNode('Hello World')) + root.append(paragraph) + }, + { discrete: true }, + ) }) let content = '' @@ -109,7 +123,11 @@ describe('test-helpers', () => { }) it('should set editor root text and select end', async () => { - const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null }) + const { getEditor } = renderLexicalEditor({ + namespace: 'test', + nodes: [ParagraphNode, TextNode], + children: null, + }) const editor = await waitForEditorReady(getEditor) setEditorRootText(editor, 'New Text', $createTextNode) @@ -126,16 +144,23 @@ describe('test-helpers', () => { describe('Node Selection Helpers', () => { it('should get node count', async () => { - const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null }) + const { getEditor } = renderLexicalEditor({ + namespace: 'test', + nodes: [ParagraphNode, TextNode], + children: null, + }) const editor = await waitForEditorReady(getEditor) act(() => { - editor.update(() => { - const root = $getRoot() - root.clear() - root.append($createParagraphNode()) - root.append($createParagraphNode()) - }, { discrete: true }) + editor.update( + () => { + const root = $getRoot() + root.clear() + root.append($createParagraphNode()) + root.append($createParagraphNode()) + }, + { discrete: true }, + ) }) let count = 0 @@ -146,15 +171,22 @@ describe('test-helpers', () => { }) it('should get nodes by type', async () => { - const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null }) + const { getEditor } = renderLexicalEditor({ + namespace: 'test', + nodes: [ParagraphNode, TextNode], + children: null, + }) const editor = await waitForEditorReady(getEditor) act(() => { - editor.update(() => { - const root = $getRoot() - root.clear() - root.append($createParagraphNode()) - }, { discrete: true }) + editor.update( + () => { + const root = $getRoot() + root.clear() + root.append($createParagraphNode()) + }, + { discrete: true }, + ) }) let nodes: ParagraphNode[] = [] @@ -191,9 +223,12 @@ describe('test-helpers', () => { expect(editor).toBeDefined() expect(() => { - editor.update(() => { - throw new Error('test error') - }, { discrete: true }) + editor.update( + () => { + throw new Error('test error') + }, + { discrete: true }, + ) }).toThrow('test error') }) }) diff --git a/web/app/components/base/prompt-editor/plugins/__tests__/update-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/__tests__/update-block.spec.tsx index 4283910c3180ab..3ba4a54e49c21f 100644 --- a/web/app/components/base/prompt-editor/plugins/__tests__/update-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/__tests__/update-block.spec.tsx @@ -41,19 +41,17 @@ const selectRootEnd = (editor: LexicalEditor) => { }) } -const setup = (props?: { - instanceId?: string - withEventEmitter?: boolean -}) => { +const setup = (props?: { instanceId?: string; withEventEmitter?: boolean }) => { const callbacks: Array<(event: TestEvent) => void> = [] - const eventEmitter = props?.withEventEmitter === false - ? null - : { - useSubscription: vi.fn((callback: (event: TestEvent) => void) => { - callbacks.push(callback) - }), - } + const eventEmitter = + props?.withEventEmitter === false + ? null + : { + useSubscription: vi.fn((callback: (event: TestEvent) => void) => { + callbacks.push(callback) + }), + } mockUseEventEmitterContextContext.mockReturnValue({ eventEmitter }) @@ -79,7 +77,7 @@ const setup = (props?: { const emit = (event: TestEvent) => { act(() => { - callbacks.forEach(callback => callback(event)) + callbacks.forEach((callback) => callback(event)) }) } diff --git a/web/app/components/base/prompt-editor/plugins/__tests__/utils.spec.ts b/web/app/components/base/prompt-editor/plugins/__tests__/utils.spec.ts index ca1be5baeee26a..997612dfa1f910 100644 --- a/web/app/components/base/prompt-editor/plugins/__tests__/utils.spec.ts +++ b/web/app/components/base/prompt-editor/plugins/__tests__/utils.spec.ts @@ -32,9 +32,12 @@ describe('Prompt Editor Test Utils', () => { const editor = createTestEditor(nodes) expect(() => { - editor.update(() => { - throw new Error('Test error') - }, { discrete: true }) + editor.update( + () => { + throw new Error('Test error') + }, + { discrete: true }, + ) }).toThrow('Test error') }) @@ -50,15 +53,18 @@ describe('Prompt Editor Test Utils', () => { const editor = createTestEditor(nodes) let content: string = '' - editor.update(() => { - const root = $getRoot() - const paragraph = $createParagraphNode() - const text = $createTextNode('Hello World') - paragraph.append(text) - root.append(paragraph) + editor.update( + () => { + const root = $getRoot() + const paragraph = $createParagraphNode() + const text = $createTextNode('Hello World') + paragraph.append(text) + root.append(paragraph) - content = root.getTextContent() - }, { discrete: true }) + content = root.getTextContent() + }, + { discrete: true }, + ) expect(content).toBe('Hello World') }) diff --git a/web/app/components/base/prompt-editor/plugins/__tests__/utils.ts b/web/app/components/base/prompt-editor/plugins/__tests__/utils.ts index db99f6e456c728..c2e5c6b61aa45d 100644 --- a/web/app/components/base/prompt-editor/plugins/__tests__/utils.ts +++ b/web/app/components/base/prompt-editor/plugins/__tests__/utils.ts @@ -4,16 +4,15 @@ import { createEditor } from 'lexical' export function createTestEditor(nodes: Array<Klass<LexicalNode>> = []) { const editor = createEditor({ nodes, - onError: (error) => { throw error }, + onError: (error) => { + throw error + }, }) const root = document.createElement('div') editor.setRootElement(root) return editor } -export function withEditorUpdate( - editor: LexicalEditor, - fn: () => void, -) { +export function withEditorUpdate(editor: LexicalEditor, fn: () => void) { editor.update(fn, { discrete: true }) } diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/component.spec.tsx index 629ce6244f2263..3d236c5a2bb799 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/component.spec.tsx @@ -6,15 +6,16 @@ import userEvent from '@testing-library/user-event' import { $getNodeByKey } from 'lexical' import AgentOutputBlockComponent from '../component' -const { mockEditorFocus, mockEditorUpdate, mockGetRootText, mockSelectNext, mockSetOutput } = vi.hoisted(() => ({ - mockEditorFocus: vi.fn(), - mockEditorUpdate: vi.fn((callback: () => void) => callback()), - mockGetRootText: { - value: '[§output:summary:summary§]', - }, - mockSelectNext: vi.fn(), - mockSetOutput: vi.fn(), -})) +const { mockEditorFocus, mockEditorUpdate, mockGetRootText, mockSelectNext, mockSetOutput } = + vi.hoisted(() => ({ + mockEditorFocus: vi.fn(), + mockEditorUpdate: vi.fn((callback: () => void) => callback()), + mockGetRootText: { + value: '[§output:summary:summary§]', + }, + mockSelectNext: vi.fn(), + mockSetOutput: vi.fn(), + })) vi.mock('@lexical/react/LexicalComposerContext') vi.mock('@langgenius/dify-ui/select', () => ({ @@ -30,7 +31,9 @@ vi.mock('@langgenius/dify-ui/select', () => ({ <div> <span data-testid="type-select-state">{open ? 'open' : 'closed'}</span> {children} - <button type="button" onClick={() => onValueChange('file')}>Select file</button> + <button type="button" onClick={() => onValueChange('file')}> + Select file + </button> </div> ), SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>, @@ -56,9 +59,11 @@ vi.mock('lexical', async (importOriginal) => { ...actual, $getNodeByKey: vi.fn(), $getRoot: vi.fn(() => ({ - getChildren: () => [{ - getTextContent: () => mockGetRootText.value, - }], + getChildren: () => [ + { + getTextContent: () => mockGetRootText.value, + }, + ], })), } }) @@ -103,7 +108,9 @@ describe('AgentOutputBlockComponent', () => { />, ) - const input = screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }) as HTMLInputElement + const input = screen.getByRole('textbox', { + name: 'workflow.nodes.agent.outputVars.nameLabel', + }) as HTMLInputElement expect(input).toHaveFocus() expect(input.selectionStart).toBe(0) @@ -122,7 +129,9 @@ describe('AgentOutputBlockComponent', () => { />, ) - const input = screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }) as HTMLInputElement + const input = screen.getByRole('textbox', { + name: 'workflow.nodes.agent.outputVars.nameLabel', + }) as HTMLInputElement expect(input).toHaveFocus() expect(input.selectionStart).toBe('summary'.length) @@ -189,13 +198,16 @@ describe('AgentOutputBlockComponent', () => { ) expect(mockSetOutput).toHaveBeenCalledTimes(1) expect(mockEditorFocus).not.toHaveBeenCalled() - expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ - name: 'summary', - type: 'string', - required: false, - }), - ]), '[§output:summary:summary§]') + expect(onChange).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + name: 'summary', + type: 'string', + required: false, + }), + ]), + '[§output:summary:summary§]', + ) }) it('syncs the output name and moves the editor selection after the output block when committing with Enter', async () => { @@ -237,11 +249,14 @@ describe('AgentOutputBlockComponent', () => { ) expect(mockSelectNext).toHaveBeenCalledTimes(1) expect(screen.getByTestId('type-select-state')).toHaveTextContent('closed') - expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ - name: 'summary', - }), - ]), 'Generate [§output:summary:summary§]') + expect(onChange).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + name: 'summary', + }), + ]), + 'Generate [§output:summary:summary§]', + ) await waitFor(() => { expect(mockEditorFocus).toHaveBeenCalledTimes(1) }) @@ -289,11 +304,14 @@ describe('AgentOutputBlockComponent', () => { expect(input).not.toHaveFocus() expect((input as HTMLInputElement).selectionStart).toBe('summary'.length) expect((input as HTMLInputElement).selectionEnd).toBe('summary'.length) - expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ - name: 'summary', - }), - ]), 'Generate [§output:summary:summary§]') + expect(onChange).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + name: 'summary', + }), + ]), + 'Generate [§output:summary:summary§]', + ) }) it('commits the input DOM value on Enter even before React state rerenders', () => { @@ -372,7 +390,9 @@ describe('AgentOutputBlockComponent', () => { ) const input = screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }) - const typeTrigger = screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.typeLabel' }) + const typeTrigger = screen.getByRole('button', { + name: 'workflow.nodes.agent.outputVars.typeLabel', + }) fireEvent.change(input, { target: { value: 'summary' } }) fireEvent.mouseDown(typeTrigger) @@ -437,8 +457,12 @@ describe('AgentOutputBlockComponent', () => { />, ) - expect(screen.queryByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'workflow.nodes.agent.outputVars.typeLabel' })).not.toBeInTheDocument() + expect( + screen.queryByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.nodes.agent.outputVars.typeLabel' }), + ).not.toBeInTheDocument() expect(screen.getByText('qna_report_pdf')).toBeInTheDocument() expect(screen.getByText('file')).toBeInTheDocument() }) diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/node.spec.tsx index 47e487eb463151..5c5d7e15e5f091 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/node.spec.tsx @@ -1,14 +1,6 @@ -import type { - Klass, - LexicalEditor, - LexicalNode, -} from 'lexical' +import type { Klass, LexicalEditor, LexicalNode } from 'lexical' import { createEditor } from 'lexical' -import { - $createAgentOutputBlockNode, - $isAgentOutputBlockNode, - AgentOutputBlockNode, -} from '../node' +import { $createAgentOutputBlockNode, $isAgentOutputBlockNode, AgentOutputBlockNode } from '../node' import { extractAgentOutputNames, getAgentOutputToken, @@ -42,8 +34,25 @@ describe('AgentOutputBlockNode', () => { it('should select the output name only for newly inserted editing nodes', () => { runInEditor(() => { const newEditingNode = $createAgentOutputBlockNode('output', 'string', true) - const existingEditingNode = $createAgentOutputBlockNode('summary', 'string', true, [], undefined, undefined, false) - const typeSelectingNode = $createAgentOutputBlockNode('summary', 'string', true, [], undefined, undefined, false, true) + const existingEditingNode = $createAgentOutputBlockNode( + 'summary', + 'string', + true, + [], + undefined, + undefined, + false, + ) + const typeSelectingNode = $createAgentOutputBlockNode( + 'summary', + 'string', + true, + [], + undefined, + undefined, + false, + true, + ) expect(newEditingNode.shouldSelectNameOnEdit()).toBe(true) expect(existingEditingNode.shouldSelectNameOnEdit()).toBe(false) @@ -66,23 +75,28 @@ describe('AgentOutputBlockNode', () => { }) it('should extract output names from bracketed and legacy tokens', () => { - expect([...extractAgentOutputNames('A [§output:summary:summary§] B §output:final_summary:final_summary§')]).toEqual([ - 'summary', - 'final_summary', - ]) + expect([ + ...extractAgentOutputNames( + 'A [§output:summary:summary§] B §output:final_summary:final_summary§', + ), + ]).toEqual(['summary', 'final_summary']) }) it('should replace only matching output token names', () => { - expect(replaceAgentOutputName( - 'Use [§output:summary:summary§] and §output:other:other§', - 'summary', - 'final_summary', - )).toBe('Use [§output:final_summary:final_summary§] and §output:other:other§') - expect(replaceAgentOutputName( - 'Generate [§output:output:output§] and §output:output:output§', - 'output', - 'summary', - )).toBe('Generate [§output:summary:summary§] and §output:summary:summary§') + expect( + replaceAgentOutputName( + 'Use [§output:summary:summary§] and §output:other:other§', + 'summary', + 'final_summary', + ), + ).toBe('Use [§output:final_summary:final_summary§] and §output:other:other§') + expect( + replaceAgentOutputName( + 'Generate [§output:output:output§] and §output:output:output§', + 'output', + 'summary', + ), + ).toBe('Generate [§output:summary:summary§] and §output:summary:summary§') }) it('should create node with helper and support type guard checks', () => { diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx b/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx index 31ce22cd43ff03..846dda09918fa4 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx @@ -41,17 +41,17 @@ function upsertOutput( outputType: AgentOutputTypeOptionValue, ) { const trimmedName = nextName.trim() - if (!AGENT_OUTPUT_NAME_PATTERN.test(trimmedName)) - return null + if (!AGENT_OUTPUT_NAME_PATTERN.test(trimmedName)) return null - const existingIndex = outputs.findIndex(output => output.name === oldName) - const duplicateIndex = outputs.findIndex(output => output.name === trimmedName && output.name !== oldName) - if (duplicateIndex >= 0) - return null + const existingIndex = outputs.findIndex((output) => output.name === oldName) + const duplicateIndex = outputs.findIndex( + (output) => output.name === trimmedName && output.name !== oldName, + ) + if (duplicateIndex >= 0) return null const nextOutput = createAgentOutputConfig(trimmedName, outputType) if (existingIndex >= 0) - return outputs.map((output, index) => index === existingIndex ? nextOutput : output) + return outputs.map((output, index) => (index === existingIndex ? nextOutput : output)) return [...outputs, nextOutput] } @@ -79,22 +79,18 @@ const AgentOutputBlockComponent = ({ const skipNameFocusRef = useRef(false) useEffect(() => { - if (!isEditing) - return + if (!isEditing) return if (skipNameFocusRef.current) { skipNameFocusRef.current = false return } const input = nameInputRef.current - if (!input) - return + if (!input) return input.focus() - if (selectNameOnEdit) - input.setSelectionRange(0, input.value.length) - else - input.setSelectionRange(input.value.length, input.value.length) + if (selectNameOnEdit) input.setSelectionRange(0, input.value.length) + else input.setSelectionRange(input.value.length, input.value.length) }, [isEditing, selectNameOnEdit]) if (name !== lastNodeName) { @@ -112,11 +108,7 @@ const AgentOutputBlockComponent = ({ selectAfterCommit?: boolean } = {}, ) => { - const { - keepEditing = false, - openTypeSelectOnEdit = false, - selectAfterCommit = false, - } = options + const { keepEditing = false, openTypeSelectOnEdit = false, selectAfterCommit = false } = options const trimmedName = nextName.trim() const nextOutputs = upsertOutput(outputs, name, trimmedName, nextType) if (!nextOutputs) { @@ -128,19 +120,28 @@ const AgentOutputBlockComponent = ({ let nextPrompt: string | undefined editor.update(() => { const node = $getNodeByKey(nodeKey) - if (!$isAgentOutputBlockNode(node)) - return + if (!$isAgentOutputBlockNode(node)) return - const currentPrompt = $getRoot().getChildren().map(node => node.getTextContent()).join('\n') - node.setOutput(trimmedName, nextType, keepEditing, nextOutputs, onChange, onEdit, false, openTypeSelectOnEdit) - if (selectAfterCommit) - node.selectNext() + const currentPrompt = $getRoot() + .getChildren() + .map((node) => node.getTextContent()) + .join('\n') + node.setOutput( + trimmedName, + nextType, + keepEditing, + nextOutputs, + onChange, + onEdit, + false, + openTypeSelectOnEdit, + ) + if (selectAfterCommit) node.selectNext() nextPrompt = replaceAgentOutputName(currentPrompt, name, trimmedName) didCommit = true }) - if (!didCommit) - return false + if (!didCommit) return false onChange?.(nextOutputs, nextPrompt) setDraftName(trimmedName) @@ -155,8 +156,7 @@ const AgentOutputBlockComponent = ({ skipNextBlurCommitRef.current = false editor.update(() => { const node = $getNodeByKey(nodeKey) - if ($isAgentOutputBlockNode(node)) - node.setOpenTypeSelectOnEdit(false) + if ($isAgentOutputBlockNode(node)) node.setOpenTypeSelectOnEdit(false) }) } } @@ -170,20 +170,25 @@ const AgentOutputBlockComponent = ({ <span role="button" tabIndex={0} - aria-label={t($ => $['nodes.agent.outputVars.edit'], { ns: 'workflow', name })} + aria-label={t(($) => $['nodes.agent.outputVars.edit'], { ns: 'workflow', name })} contentEditable={false} className="group/agent-output inline-flex min-w-[18px] cursor-pointer items-center gap-1 rounded-[5px] border border-util-colors-violet-violet-100 bg-util-colors-violet-violet-50 px-1 py-0.5 align-middle shadow-xs" onClick={handleEditRequest} onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') - return + if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() handleEditRequest() }} > - <span aria-hidden="true" className="i-custom-vender-workflow-variable-x size-3.5 shrink-0 text-util-colors-violet-violet-700 group-hover/agent-output:hidden" /> - <span aria-hidden="true" className="i-ri-edit-2-line hidden size-3.5 shrink-0 text-util-colors-violet-violet-700 group-hover/agent-output:inline-block" /> + <span + aria-hidden="true" + className="i-custom-vender-workflow-variable-x size-3.5 shrink-0 text-util-colors-violet-violet-700 group-hover/agent-output:hidden" + /> + <span + aria-hidden="true" + className="i-ri-edit-2-line hidden size-3.5 shrink-0 text-util-colors-violet-violet-700 group-hover/agent-output:inline-block" + /> <span className="system-xs-medium whitespace-nowrap text-util-colors-violet-violet-700"> {name} </span> @@ -200,15 +205,18 @@ const AgentOutputBlockComponent = ({ className="inline-flex items-center gap-[3px] rounded-[5px] border border-util-colors-violet-violet-700 bg-util-colors-violet-violet-50 p-px align-middle shadow-xs" > <span className="flex min-w-0 items-center gap-0.5 pl-0.5"> - <span aria-hidden="true" className="i-custom-vender-workflow-variable-x size-3.5 shrink-0 text-util-colors-violet-violet-700" /> + <span + aria-hidden="true" + className="i-custom-vender-workflow-variable-x size-3.5 shrink-0 text-util-colors-violet-violet-700" + /> <input ref={nameInputRef} - aria-label={t($ => $['nodes.agent.outputVars.nameLabel'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.agent.outputVars.nameLabel'], { ns: 'workflow' })} value={draftName} className="h-4 max-w-28 min-w-5 border-0 bg-transparent p-0 text-center system-xs-regular text-util-colors-violet-violet-700 outline-hidden placeholder:text-util-colors-violet-violet-700/50 focus:w-24" - placeholder={t($ => $['nodes.agent.outputVars.namePlaceholder'], { ns: 'workflow' })} - onMouseDown={event => event.stopPropagation()} - onClick={event => event.stopPropagation()} + placeholder={t(($) => $['nodes.agent.outputVars.namePlaceholder'], { ns: 'workflow' })} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} onKeyDown={(event) => { event.stopPropagation() event.nativeEvent.stopImmediatePropagation?.() @@ -228,7 +236,10 @@ const AgentOutputBlockComponent = ({ if (isTabCommit) { skipNameFocusRef.current = true - event.currentTarget.setSelectionRange(event.currentTarget.value.length, event.currentTarget.value.length) + event.currentTarget.setSelectionRange( + event.currentTarget.value.length, + event.currentTarget.value.length, + ) event.currentTarget.blur() setTypeSelectOpen(true) return @@ -265,25 +276,24 @@ const AgentOutputBlockComponent = ({ onValueChange={(nextType) => { skipNextBlurCommitRef.current = false setTypeSelectOpen(false) - if (nextType) - commitOutput(latestDraftNameRef.current, nextType) + if (nextType) commitOutput(latestDraftNameRef.current, nextType) }} > <SelectLabel className="sr-only"> - {t($ => $['nodes.agent.outputVars.typeLabel'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.outputVars.typeLabel'], { ns: 'workflow' })} </SelectLabel> <SelectTrigger - aria-label={t($ => $['nodes.agent.outputVars.typeLabel'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.agent.outputVars.typeLabel'], { ns: 'workflow' })} className="h-4 min-w-4 rounded bg-util-colors-violet-violet-200 py-0 pr-0.5 pl-1 system-2xs-semibold-uppercase text-util-colors-violet-violet-700 hover:bg-util-colors-violet-violet-200" onMouseDown={() => { skipNextBlurCommitRef.current = true }} - onClick={event => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} > {selected.label} </SelectTrigger> <SelectContent popupClassName="w-40"> - {AGENT_OUTPUT_TYPE_OPTIONS.map(option => ( + {AGENT_OUTPUT_TYPE_OPTIONS.map((option) => ( <SelectItem key={option.value} value={option.value}> <SelectItemText>{option.label}</SelectItemText> <SelectItemIndicator /> diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/index.tsx b/web/app/components/base/prompt-editor/plugins/agent-output-block/index.tsx index ff221d5aa698fd..1bce1f31cce2ed 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/index.tsx @@ -21,17 +21,16 @@ import { parseAgentOutputToken, } from './utils' -function getAgentOutputBlockNodeType(name: string, outputs: NonNullable<AgentOutputBlockType['outputs']>) { - const output = outputs.find(item => item.name === name) +function getAgentOutputBlockNodeType( + name: string, + outputs: NonNullable<AgentOutputBlockType['outputs']>, +) { + const output = outputs.find((item) => item.name === name) return output ? getAgentOutputTypeOptionValue(output) : 'string' } -const AgentOutputBlock = memo(({ - outputs = [], - onChange, - onEdit, -}: AgentOutputBlockType) => { +const AgentOutputBlock = memo(({ outputs = [], onChange, onEdit }: AgentOutputBlockType) => { const [editor] = useLexicalComposerContext() useEffect(() => { @@ -45,10 +44,20 @@ const AgentOutputBlock = memo(({ const name = getUniqueAgentOutputName(outputs) const outputType = 'string' const nextOutputs = [...outputs, createAgentOutputConfig(name, outputType)] - const agentOutputBlockNode = $createAgentOutputBlockNode(name, outputType, true, nextOutputs, onChange, onEdit) + const agentOutputBlockNode = $createAgentOutputBlockNode( + name, + outputType, + true, + nextOutputs, + onChange, + onEdit, + ) $insertNodes([agentOutputBlockNode]) - const nextPrompt = $getRoot().getChildren().map(node => node.getTextContent()).join('\n') + const nextPrompt = $getRoot() + .getChildren() + .map((node) => node.getTextContent()) + .join('\n') onChange?.(nextOutputs, nextPrompt) return true @@ -62,88 +71,92 @@ const AgentOutputBlock = memo(({ }) AgentOutputBlock.displayName = 'AgentOutputBlock' -const AgentOutputBlockReplacementBlock = memo(({ - outputs = [], - onChange, - onEdit, -}: AgentOutputBlockType) => { - const [editor] = useLexicalComposerContext() - - useEffect(() => { - if (!editor.hasNodes([AgentOutputBlockNode])) - throw new Error('AgentOutputBlockNodePlugin: AgentOutputBlockNode not registered on editor') - }, [editor]) - - const createAgentOutputBlockNode = useCallback((textNode: TextNode): AgentOutputBlockNode => { - const match = parseAgentOutputToken(textNode.getTextContent()) - const name = match?.name || '' - const outputType = getAgentOutputBlockNodeType(name, outputs) - - return $applyNodeReplacement($createAgentOutputBlockNode(name, outputType, false, outputs, onChange, onEdit)) - }, [onChange, onEdit, outputs]) - - const getMatch = useCallback((text: string) => { - const match = parseAgentOutputToken(text) +const AgentOutputBlockReplacementBlock = memo( + ({ outputs = [], onChange, onEdit }: AgentOutputBlockType) => { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + if (!editor.hasNodes([AgentOutputBlockNode])) + throw new Error('AgentOutputBlockNodePlugin: AgentOutputBlockNode not registered on editor') + }, [editor]) + + const createAgentOutputBlockNode = useCallback( + (textNode: TextNode): AgentOutputBlockNode => { + const match = parseAgentOutputToken(textNode.getTextContent()) + const name = match?.name || '' + const outputType = getAgentOutputBlockNodeType(name, outputs) + + return $applyNodeReplacement( + $createAgentOutputBlockNode(name, outputType, false, outputs, onChange, onEdit), + ) + }, + [onChange, onEdit, outputs], + ) - if (!match) - return null + const getMatch = useCallback((text: string) => { + const match = parseAgentOutputToken(text) - return { - end: match.end, - start: match.start, - } - }, []) + if (!match) return null - const transformListener = useCallback((textNode: CustomTextNode) => { - return decoratorTransform(textNode, getMatch, createAgentOutputBlockNode, { - allowAdjacentMatches: true, - }) - }, [createAgentOutputBlockNode, getMatch]) + return { + end: match.end, + start: match.start, + } + }, []) - useEffect(() => { - return mergeRegister( - editor.registerNodeTransform(CustomTextNode, transformListener), + const transformListener = useCallback( + (textNode: CustomTextNode) => { + return decoratorTransform(textNode, getMatch, createAgentOutputBlockNode, { + allowAdjacentMatches: true, + }) + }, + [createAgentOutputBlockNode, getMatch], ) - }, [editor, transformListener]) - useEffect(() => { - editor.update(() => { - const visitNode = (node: ElementNode) => { - node.getChildren().forEach((child) => { - if (child instanceof AgentOutputBlockNode) { - const name = child.getName() - const outputType = getAgentOutputBlockNodeType(name, outputs) - if ( - child.getOutputType() !== outputType - || child.getOutputs() !== outputs - || child.getOnChange() !== onChange - || child.getOnEdit() !== onEdit - ) { - child.replace($createAgentOutputBlockNode( - name, - outputType, - child.isEditing(), - outputs, - onChange, - onEdit, - child.shouldSelectNameOnEdit(), - child.shouldOpenTypeSelectOnEdit(), - )) + useEffect(() => { + return mergeRegister(editor.registerNodeTransform(CustomTextNode, transformListener)) + }, [editor, transformListener]) + + useEffect(() => { + editor.update(() => { + const visitNode = (node: ElementNode) => { + node.getChildren().forEach((child) => { + if (child instanceof AgentOutputBlockNode) { + const name = child.getName() + const outputType = getAgentOutputBlockNodeType(name, outputs) + if ( + child.getOutputType() !== outputType || + child.getOutputs() !== outputs || + child.getOnChange() !== onChange || + child.getOnEdit() !== onEdit + ) { + child.replace( + $createAgentOutputBlockNode( + name, + outputType, + child.isEditing(), + outputs, + onChange, + onEdit, + child.shouldSelectNameOnEdit(), + child.shouldOpenTypeSelectOnEdit(), + ), + ) + } + return } - return - } - if ($isElementNode(child)) - visitNode(child) - }) - } + if ($isElementNode(child)) visitNode(child) + }) + } - visitNode($getRoot()) - }) - }, [editor, onChange, onEdit, outputs]) + visitNode($getRoot()) + }) + }, [editor, onChange, onEdit, outputs]) - return null -}) + return null + }, +) AgentOutputBlockReplacementBlock.displayName = 'AgentOutputBlockReplacementBlock' export { AgentOutputBlock, AgentOutputBlockReplacementBlock } diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/node.tsx b/web/app/components/base/prompt-editor/plugins/agent-output-block/node.tsx index 0655f8a3106339..2c31520f196641 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/node.tsx @@ -179,7 +179,16 @@ export function $createAgentOutputBlockNode( selectNameOnEdit = isEditing, openTypeSelectOnEdit = false, ): AgentOutputBlockNode { - return new AgentOutputBlockNode(name, outputType, isEditing, selectNameOnEdit, openTypeSelectOnEdit, outputs, onChange, onEdit) + return new AgentOutputBlockNode( + name, + outputType, + isEditing, + selectNameOnEdit, + openTypeSelectOnEdit, + outputs, + onChange, + onEdit, + ) } export function $isAgentOutputBlockNode( diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/utils.ts b/web/app/components/base/prompt-editor/plugins/agent-output-block/utils.ts index d8bb73aae49a04..d4c9fd457a0558 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/utils.ts +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/utils.ts @@ -1,12 +1,15 @@ -import type { DeclaredOutputConfig, DeclaredOutputType } from '@dify/contracts/api/console/apps/types.gen' - -export type AgentOutputTypeOptionValue - = DeclaredOutputType - | 'array[boolean]' - | 'array[file]' - | 'array[number]' - | 'array[object]' - | 'array[string]' +import type { + DeclaredOutputConfig, + DeclaredOutputType, +} from '@dify/contracts/api/console/apps/types.gen' + +export type AgentOutputTypeOptionValue = + | DeclaredOutputType + | 'array[boolean]' + | 'array[file]' + | 'array[number]' + | 'array[object]' + | 'array[string]' export type AgentOutputTypeOption = { label: string @@ -25,8 +28,7 @@ export function getAgentOutputToken(name: string) { export function parseAgentOutputToken(text: string) { const match = AGENT_OUTPUT_TOKEN_REGEX.exec(text) ?? LEGACY_AGENT_OUTPUT_TOKEN_REGEX.exec(text) - if (!match) - return null + if (!match) return null return { name: match[1]!, @@ -40,19 +42,16 @@ export function extractAgentOutputNames(text: string) { const bracketedRegex = new RegExp(AGENT_OUTPUT_TOKEN_REGEX.source, 'g') const legacyRegex = new RegExp(LEGACY_AGENT_OUTPUT_TOKEN_REGEX.source, 'g') - for (const match of text.matchAll(bracketedRegex)) - names.add(match[1]!) + for (const match of text.matchAll(bracketedRegex)) names.add(match[1]!) - for (const match of text.matchAll(legacyRegex)) - names.add(match[1]!) + for (const match of text.matchAll(legacyRegex)) names.add(match[1]!) return names } export function replaceAgentOutputName(text: string, oldName: string, nextName: string) { const replaceTokenName = (match: string, name: string, _mirrorName: string) => { - if (name !== oldName) - return match + if (name !== oldName) return match return match.startsWith('[') ? getAgentOutputToken(nextName) @@ -78,17 +77,24 @@ export const AGENT_OUTPUT_TYPE_OPTIONS: AgentOutputTypeOption[] = [ ] export function getAgentOutputTypeOption(value: AgentOutputTypeOptionValue) { - return AGENT_OUTPUT_TYPE_OPTIONS.find(option => option.value === value) || AGENT_OUTPUT_TYPE_OPTIONS[0]! + return ( + AGENT_OUTPUT_TYPE_OPTIONS.find((option) => option.value === value) || + AGENT_OUTPUT_TYPE_OPTIONS[0]! + ) } -export function getAgentOutputTypeOptionValue(output: DeclaredOutputConfig): AgentOutputTypeOptionValue { - if (output.type !== 'array') - return output.type +export function getAgentOutputTypeOptionValue( + output: DeclaredOutputConfig, +): AgentOutputTypeOptionValue { + if (output.type !== 'array') return output.type return `array[${output.array_item?.type || 'object'}]` as AgentOutputTypeOptionValue } -export function createAgentOutputConfig(name: string, type: AgentOutputTypeOptionValue): DeclaredOutputConfig { +export function createAgentOutputConfig( + name: string, + type: AgentOutputTypeOptionValue, +): DeclaredOutputConfig { const option = getAgentOutputTypeOption(type) const output: DeclaredOutputConfig = { name: name.trim(), @@ -113,14 +119,12 @@ export function createAgentOutputConfig(name: string, type: AgentOutputTypeOptio } export function getUniqueAgentOutputName(outputs: DeclaredOutputConfig[]) { - const outputNames = new Set(outputs.map(output => output.name)) + const outputNames = new Set(outputs.map((output) => output.name)) const baseName = 'output' - if (!outputNames.has(baseName)) - return baseName + if (!outputNames.has(baseName)) return baseName let index = 1 - while (outputNames.has(`${baseName}_${index}`)) - index += 1 + while (outputNames.has(`${baseName}_${index}`)) index += 1 return `${baseName}_${index}` } diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/hooks.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/hooks.spec.tsx index c21ad32456889e..901d11faa314b5 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/hooks.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/hooks.spec.tsx @@ -21,12 +21,7 @@ import * as React from 'react' import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types' import { VarType } from '@/app/components/workflow/types' import { CustomTextNode } from '../../custom-text/node' -import { - useExternalToolOptions, - useOptions, - usePromptOptions, - useVariableOptions, -} from '../hooks' +import { useExternalToolOptions, useOptions, usePromptOptions, useVariableOptions } from '../hooks' // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -41,16 +36,14 @@ import { function makeLexicalWrapper() { const initialConfig = { namespace: 'hooks-test', - onError: (err: Error) => { throw err }, + onError: (err: Error) => { + throw err + }, // CustomTextNode must be registered so editor.update() in addOption's onSelect can create it nodes: [CustomTextNode], } return function LexicalWrapper({ children }: { children: React.ReactNode }) { - return ( - <LexicalComposer initialConfig={initialConfig}> - {children} - </LexicalComposer> - ) + return <LexicalComposer initialConfig={initialConfig}>{children}</LexicalComposer> } } @@ -72,7 +65,10 @@ function makeRequestURLBlock(overrides: Partial<RequestURLBlockType> = {}): Requ return { show: true, selectable: true, ...overrides } } -function makeVariableBlock(variables: Option[] = [], overrides: Partial<VariableBlockType> = {}): VariableBlockType { +function makeVariableBlock( + variables: Option[] = [], + overrides: Partial<VariableBlockType> = {}, +): VariableBlockType { return { show: true, variables, ...overrides } } @@ -94,7 +90,11 @@ function makeVar(variable: string, type: VarType = VarType.string) { return { variable, type } } -function makeNodeOutPutVar(nodeId: string, title: string, vars: ReturnType<typeof makeVar>[] = []): NodeOutPutVar { +function makeNodeOutPutVar( + nodeId: string, + title: string, + vars: ReturnType<typeof makeVar>[] = [], +): NodeOutPutVar { return { nodeId, title, vars } } @@ -135,27 +135,22 @@ describe('usePromptOptions', () => { */ describe('contextBlock', () => { it('should NOT include context option when show is false', () => { - const { result } = renderHook( - () => usePromptOptions(makeContextBlock({ show: false })), - { wrapper }, - ) + const { result } = renderHook(() => usePromptOptions(makeContextBlock({ show: false })), { + wrapper, + }) expect(result.current).toHaveLength(0) }) it('should include context option when show is true', () => { - const { result } = renderHook( - () => usePromptOptions(makeContextBlock({ show: true })), - { wrapper }, - ) + const { result } = renderHook(() => usePromptOptions(makeContextBlock({ show: true })), { + wrapper, + }) expect(result.current).toHaveLength(1) expect(result.current[0]!.group).toBe('prompt context') }) it('should render the context PromptMenuItem without crashing', () => { - const { result } = renderHook( - () => usePromptOptions(makeContextBlock()), - { wrapper }, - ) + const { result } = renderHook(() => usePromptOptions(makeContextBlock()), { wrapper }) // renderMenuOption returns a React element – just verify it's truthy const el = result.current[0]!.renderMenuOption(renderProps) expect(el).toBeTruthy() @@ -210,19 +205,17 @@ describe('usePromptOptions', () => { }) it('should include query option when show is true', () => { - const { result } = renderHook( - () => usePromptOptions(undefined, makeQueryBlock()), - { wrapper }, - ) + const { result } = renderHook(() => usePromptOptions(undefined, makeQueryBlock()), { + wrapper, + }) expect(result.current).toHaveLength(1) expect(result.current[0]!.group).toBe('prompt query') }) it('should render the query PromptMenuItem without crashing', () => { - const { result } = renderHook( - () => usePromptOptions(undefined, makeQueryBlock()), - { wrapper }, - ) + const { result } = renderHook(() => usePromptOptions(undefined, makeQueryBlock()), { + wrapper, + }) const el = result.current[0]!.renderMenuOption(renderProps) expect(el).toBeTruthy() }) @@ -264,7 +257,8 @@ describe('usePromptOptions', () => { describe('requestURLBlock', () => { it('should NOT include request URL option when show is false', () => { const { result } = renderHook( - () => usePromptOptions(undefined, undefined, undefined, makeRequestURLBlock({ show: false })), + () => + usePromptOptions(undefined, undefined, undefined, makeRequestURLBlock({ show: false })), { wrapper }, ) expect(result.current).toHaveLength(0) @@ -294,7 +288,12 @@ describe('usePromptOptions', () => { () => { const [editor] = useLexicalComposerContext() capturedEditor = editor - return usePromptOptions(undefined, undefined, undefined, makeRequestURLBlock({ selectable: true })) + return usePromptOptions( + undefined, + undefined, + undefined, + makeRequestURLBlock({ selectable: true }), + ) }, { wrapper }, ) @@ -309,7 +308,12 @@ describe('usePromptOptions', () => { () => { const [editor] = useLexicalComposerContext() capturedEditor = editor - return usePromptOptions(undefined, undefined, undefined, makeRequestURLBlock({ selectable: false })) + return usePromptOptions( + undefined, + undefined, + undefined, + makeRequestURLBlock({ selectable: false }), + ) }, { wrapper }, ) @@ -389,12 +393,13 @@ describe('usePromptOptions', () => { describe('all blocks visible', () => { it('should return all four options in correct order', () => { const { result } = renderHook( - () => usePromptOptions( - makeContextBlock(), - makeQueryBlock(), - makeHistoryBlock(), - makeRequestURLBlock(), - ), + () => + usePromptOptions( + makeContextBlock(), + makeQueryBlock(), + makeHistoryBlock(), + makeRequestURLBlock(), + ), { wrapper }, ) expect(result.current).toHaveLength(4) @@ -423,7 +428,8 @@ describe('useVariableOptions', () => { describe('when variableBlock.show is false', () => { it('should return an empty array', () => { const { result } = renderHook( - () => useVariableOptions(makeVariableBlock([{ value: 'foo', name: 'foo' }], { show: false })), + () => + useVariableOptions(makeVariableBlock([{ value: 'foo', name: 'foo' }], { show: false })), { wrapper }, ) expect(result.current).toHaveLength(0) @@ -435,10 +441,7 @@ describe('useVariableOptions', () => { */ describe('when variableBlock is undefined', () => { it('should return an empty array', () => { - const { result } = renderHook( - () => useVariableOptions(undefined), - { wrapper }, - ) + const { result } = renderHook(() => useVariableOptions(undefined), { wrapper }) expect(result.current).toHaveLength(0) }) }) @@ -468,10 +471,7 @@ describe('useVariableOptions', () => { { value: 'alpha', name: 'Alpha' }, { value: 'beta', name: 'Beta' }, ] - const { result } = renderHook( - () => useVariableOptions(makeVariableBlock(vars)), - { wrapper }, - ) + const { result } = renderHook(() => useVariableOptions(makeVariableBlock(vars)), { wrapper }) // 2 variable options + 1 addOption = 3 expect(result.current).toHaveLength(3) expect(result.current[0]!.key).toBe('alpha') @@ -480,10 +480,7 @@ describe('useVariableOptions', () => { it('should render variable VariableMenuItems without crashing', () => { const vars: Option[] = [{ value: 'myvar', name: 'My Var' }] - const { result } = renderHook( - () => useVariableOptions(makeVariableBlock(vars)), - { wrapper }, - ) + const { result } = renderHook(() => useVariableOptions(makeVariableBlock(vars)), { wrapper }) // Pass a queryString so we exercise the highlight splitting code path in VariableMenuItem const el = result.current[0]!.renderMenuOption({ ...renderProps, queryString: 'my' }) expect(el).toBeTruthy() @@ -517,10 +514,9 @@ describe('useVariableOptions', () => { { value: 'beta', name: 'Beta' }, { value: 'ALPHA_UPPER', name: 'ALPHA_UPPER' }, ] - const { result } = renderHook( - () => useVariableOptions(makeVariableBlock(vars), 'alpha'), - { wrapper }, - ) + const { result } = renderHook(() => useVariableOptions(makeVariableBlock(vars), 'alpha'), { + wrapper, + }) // 'alpha' regex (case-insensitive) matches 'alpha' and 'ALPHA_UPPER'; addOption is always appended expect(result.current).toHaveLength(3) expect(result.current[0]!.key).toBe('alpha') @@ -532,10 +528,9 @@ describe('useVariableOptions', () => { { value: 'alpha', name: 'Alpha' }, { value: 'beta', name: 'Beta' }, ] - const { result } = renderHook( - () => useVariableOptions(makeVariableBlock(vars), 'zzz'), - { wrapper }, - ) + const { result } = renderHook(() => useVariableOptions(makeVariableBlock(vars), 'zzz'), { + wrapper, + }) // No match → filtered options=[] + addOption = 1 expect(result.current).toHaveLength(1) }) @@ -549,10 +544,7 @@ describe('useVariableOptions', () => { */ describe('addOption (the last element)', () => { it('should render addOption VariableMenuItem without crashing', () => { - const { result } = renderHook( - () => useVariableOptions(makeVariableBlock([])), - { wrapper }, - ) + const { result } = renderHook(() => useVariableOptions(makeVariableBlock([])), { wrapper }) const lastOption = result.current[result.current.length - 1] const el = lastOption!.renderMenuOption(renderProps) expect(el).toBeTruthy() @@ -611,10 +603,7 @@ describe('useExternalToolOptions', () => { */ describe('when externalToolBlockType is undefined', () => { it('should return an empty array', () => { - const { result } = renderHook( - () => useExternalToolOptions(undefined), - { wrapper }, - ) + const { result } = renderHook(() => useExternalToolOptions(undefined), { wrapper }) expect(result.current).toHaveLength(0) }) }) @@ -713,10 +702,9 @@ describe('useExternalToolOptions', () => { */ describe('addOption (the last element)', () => { it('should render addOption VariableMenuItem (with Tool03/ArrowUpRight icons) without crashing', () => { - const { result } = renderHook( - () => useExternalToolOptions(makeExternalToolBlock({}, [])), - { wrapper }, - ) + const { result } = renderHook(() => useExternalToolOptions(makeExternalToolBlock({}, [])), { + wrapper, + }) const lastOption = result.current[result.current.length - 1] const el = lastOption!.renderMenuOption(renderProps) expect(el).toBeTruthy() @@ -737,10 +725,7 @@ describe('useExternalToolOptions', () => { // Covers the optional-chaining branch: externalToolBlockType?.onAddExternalTool?.() const block = makeExternalToolBlock({}, []) delete block.onAddExternalTool - const { result } = renderHook( - () => useExternalToolOptions(block), - { wrapper }, - ) + const { result } = renderHook(() => useExternalToolOptions(block), { wrapper }) const lastOption = result.current[result.current.length - 1] expect(() => lastOption!.onSelectMenuOption()).not.toThrow() }) @@ -774,13 +759,14 @@ describe('useOptions', () => { describe('allFlattenOptions aggregation', () => { it('should combine prompt, variable, and external tool options', () => { const { result } = renderHook( - () => useOptions( - makeContextBlock(), // 1 prompt option - undefined, - undefined, - makeVariableBlock([{ value: 'v1', name: 'v1' }]), // 1 var + 1 addOption = 2 - makeExternalToolBlock({}, [{ name: 't1', variableName: 'tv1' }]), // 1 tool + 1 addOption = 2 - ), + () => + useOptions( + makeContextBlock(), // 1 prompt option + undefined, + undefined, + makeVariableBlock([{ value: 'v1', name: 'v1' }]), // 1 var + 1 addOption = 2 + makeExternalToolBlock({}, [{ name: 't1', variableName: 'tv1' }]), // 1 tool + 1 addOption = 2 + ), { wrapper }, ) // 1 + 2 + 2 = 5 @@ -794,14 +780,15 @@ describe('useOptions', () => { describe('workflowVariableOptions when show is false', () => { it('should return empty array', () => { const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock([], { show: false }), - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([], { show: false }), + ), { wrapper }, ) expect(result.current.workflowVariableOptions).toHaveLength(0) @@ -817,14 +804,15 @@ describe('useOptions', () => { makeNodeOutPutVar('node-1', 'Node One', [makeVar('out', VarType.string)]), ] const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock(vars), - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock(vars), + ), { wrapper }, ) expect(result.current.workflowVariableOptions).toHaveLength(1) @@ -838,14 +826,11 @@ describe('useOptions', () => { describe('workflowVariableOptions when variables is undefined', () => { it('should default to empty array', () => { const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - { show: true, variables: undefined }, - ), + () => + useOptions(undefined, undefined, undefined, undefined, undefined, { + show: true, + variables: undefined, + }), { wrapper }, ) // No special block injections and no variables → empty array @@ -860,17 +845,18 @@ describe('useOptions', () => { describe('errorMessageBlockType injection', () => { it('should prepend error_message node when show is true and not already present', () => { const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock([]), - undefined, - undefined, - { show: true } satisfies ErrorMessageBlockType, - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + undefined, + { show: true } satisfies ErrorMessageBlockType, + ), { wrapper }, ) expect(result.current.workflowVariableOptions[0]!.nodeId).toBe('error_message') @@ -881,43 +867,51 @@ describe('useOptions', () => { it('should NOT inject error_message when already present in variables', () => { // The findIndex check ensures deduplication const existingVars: NodeOutPutVar[] = [ - makeNodeOutPutVar('error_message', 'error_message', [makeVar('error_message', VarType.string)]), + makeNodeOutPutVar('error_message', 'error_message', [ + makeVar('error_message', VarType.string), + ]), ] const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock(existingVars), - undefined, - undefined, - { show: true } satisfies ErrorMessageBlockType, - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock(existingVars), + undefined, + undefined, + { show: true } satisfies ErrorMessageBlockType, + ), { wrapper }, ) // Should still be 1, not 2 - const errorNodes = result.current.workflowVariableOptions.filter(v => v.nodeId === 'error_message') + const errorNodes = result.current.workflowVariableOptions.filter( + (v) => v.nodeId === 'error_message', + ) expect(errorNodes).toHaveLength(1) }) it('should NOT inject error_message when errorMessageBlockType.show is false', () => { const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock([]), - undefined, - undefined, - { show: false } satisfies ErrorMessageBlockType, - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + undefined, + { show: false } satisfies ErrorMessageBlockType, + ), { wrapper }, ) - const errorNodes = result.current.workflowVariableOptions.filter(v => v.nodeId === 'error_message') + const errorNodes = result.current.workflowVariableOptions.filter( + (v) => v.nodeId === 'error_message', + ) expect(errorNodes).toHaveLength(0) }) }) @@ -928,18 +922,19 @@ describe('useOptions', () => { describe('lastRunBlockType injection', () => { it('should prepend last_run node when show is true and not already present', () => { const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock([]), - undefined, - undefined, - undefined, - { show: true } satisfies LastRunBlockType, - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + undefined, + undefined, + { show: true } satisfies LastRunBlockType, + ), { wrapper }, ) expect(result.current.workflowVariableOptions[0]!.nodeId).toBe('last_run') @@ -951,41 +946,47 @@ describe('useOptions', () => { makeNodeOutPutVar('last_run', 'last_run', [makeVar('last_run', VarType.object)]), ] const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock(existingVars), - undefined, - undefined, - undefined, - { show: true } satisfies LastRunBlockType, - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock(existingVars), + undefined, + undefined, + undefined, + { show: true } satisfies LastRunBlockType, + ), { wrapper }, ) - const lastRunNodes = result.current.workflowVariableOptions.filter(v => v.nodeId === 'last_run') + const lastRunNodes = result.current.workflowVariableOptions.filter( + (v) => v.nodeId === 'last_run', + ) expect(lastRunNodes).toHaveLength(1) }) it('should NOT inject last_run when lastRunBlockType.show is false', () => { const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock([]), - undefined, - undefined, - undefined, - { show: false } satisfies LastRunBlockType, - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + undefined, + undefined, + { show: false } satisfies LastRunBlockType, + ), { wrapper }, ) - const lastRunNodes = result.current.workflowVariableOptions.filter(v => v.nodeId === 'last_run') + const lastRunNodes = result.current.workflowVariableOptions.filter( + (v) => v.nodeId === 'last_run', + ) expect(lastRunNodes).toHaveLength(0) }) }) @@ -999,19 +1000,20 @@ describe('useOptions', () => { it('should prepend current node with title "current_prompt" when generatorType is prompt', () => { const currentBlock: CurrentBlockType = { show: true, generatorType: GeneratorType.prompt } const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock([]), - undefined, - currentBlock, - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + currentBlock, + ), { wrapper }, ) - const currentNode = result.current.workflowVariableOptions.find(v => v.nodeId === 'current') + const currentNode = result.current.workflowVariableOptions.find((v) => v.nodeId === 'current') expect(currentNode).toBeDefined() expect(currentNode!.title).toBe('current_prompt') expect(currentNode!.vars[0]!.type).toBe(VarType.string) @@ -1021,19 +1023,20 @@ describe('useOptions', () => { // Any generatorType value other than 'prompt' results in 'current_code' const currentBlock: CurrentBlockType = { show: true, generatorType: GeneratorType.code } const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock([]), - undefined, - currentBlock, - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + currentBlock, + ), { wrapper }, ) - const currentNode = result.current.workflowVariableOptions.find(v => v.nodeId === 'current') + const currentNode = result.current.workflowVariableOptions.find((v) => v.nodeId === 'current') expect(currentNode).toBeDefined() expect(currentNode!.title).toBe('current_code') }) @@ -1045,38 +1048,44 @@ describe('useOptions', () => { ] const currentBlock: CurrentBlockType = { show: true, generatorType: GeneratorType.prompt } const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock(existingVars), - undefined, - currentBlock, - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock(existingVars), + undefined, + currentBlock, + ), { wrapper }, ) - const currentNodes = result.current.workflowVariableOptions.filter(v => v.nodeId === 'current') + const currentNodes = result.current.workflowVariableOptions.filter( + (v) => v.nodeId === 'current', + ) expect(currentNodes).toHaveLength(1) }) it('should NOT inject current node when currentBlockType.show is false', () => { const currentBlock: CurrentBlockType = { show: false, generatorType: GeneratorType.prompt } const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock([]), - undefined, - currentBlock, - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + currentBlock, + ), { wrapper }, ) - const currentNodes = result.current.workflowVariableOptions.filter(v => v.nodeId === 'current') + const currentNodes = result.current.workflowVariableOptions.filter( + (v) => v.nodeId === 'current', + ) expect(currentNodes).toHaveLength(0) }) }) @@ -1096,22 +1105,23 @@ describe('useOptions', () => { const lastRunBlock: LastRunBlockType = { show: true } const { result } = renderHook( - () => useOptions( - undefined, - undefined, - undefined, - undefined, - undefined, - makeWorkflowVariableBlock(baseVars), - undefined, - currentBlock, - errorBlock, - lastRunBlock, - ), + () => + useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock(baseVars), + undefined, + currentBlock, + errorBlock, + lastRunBlock, + ), { wrapper }, ) - const ids = result.current.workflowVariableOptions.map(v => v.nodeId) + const ids = result.current.workflowVariableOptions.map((v) => v.nodeId) // current is unshifted last, so it ends up at index 0 expect(ids[0]).toBe('current') expect(ids[1]).toBe('last_run') @@ -1131,19 +1141,20 @@ describe('useOptions', () => { const wfVars: NodeOutPutVar[] = [makeNodeOutPutVar('node-x', 'NodeX', [])] const { result } = renderHook( - () => useOptions( - makeContextBlock(), - makeQueryBlock(), - makeHistoryBlock(), - makeVariableBlock(vars), - makeExternalToolBlock({}, tools), - makeWorkflowVariableBlock(wfVars), - makeRequestURLBlock(), - { show: true, generatorType: GeneratorType.prompt } satisfies CurrentBlockType, - { show: true } satisfies ErrorMessageBlockType, - { show: true } satisfies LastRunBlockType, - 'v1', - ), + () => + useOptions( + makeContextBlock(), + makeQueryBlock(), + makeHistoryBlock(), + makeVariableBlock(vars), + makeExternalToolBlock({}, tools), + makeWorkflowVariableBlock(wfVars), + makeRequestURLBlock(), + { show: true, generatorType: GeneratorType.prompt } satisfies CurrentBlockType, + { show: true } satisfies ErrorMessageBlockType, + { show: true } satisfies LastRunBlockType, + 'v1', + ), { wrapper }, ) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx index 9465c2027fee23..7e60d1944440c0 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx @@ -61,7 +61,9 @@ beforeAll(() => { Range.prototype.getClientRects = vi.fn(() => { const rectList = [mockDOMRect] as unknown as DOMRectList Object.defineProperty(rectList, 'length', { value: 1 }) - Object.defineProperty(rectList, 'item', { value: (index: number) => (index === 0 ? mockDOMRect : null) }) + Object.defineProperty(rectList, 'item', { + value: (index: number) => (index === 0 ? mockDOMRect : null), + }) return rectList }) Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect) @@ -85,7 +87,9 @@ function makeCurrentBlock(overrides: Partial<CurrentBlockType> = {}): CurrentBlo return { show: true, generatorType: GeneratorType.prompt, ...overrides } } -function makeErrorMessageBlock(overrides: Partial<ErrorMessageBlockType> = {}): ErrorMessageBlockType { +function makeErrorMessageBlock( + overrides: Partial<ErrorMessageBlockType> = {}, +): ErrorMessageBlockType { return { show: true, ...overrides } } @@ -167,12 +171,15 @@ const MinimalEditor: React.FC<{ lastRunBlock, captures, }) => { - const initialConfig = React.useMemo(() => ({ - namespace: `component-picker-test-${Math.random().toString(16).slice(2)}`, - onError: (e: Error) => { - throw e - }, - }), []) + const initialConfig = React.useMemo( + () => ({ + namespace: `component-picker-test-${Math.random().toString(16).slice(2)}`, + onError: (e: Error) => { + throw e + }, + }), + [], + ) return ( <EventEmitterContextProvider> @@ -208,14 +215,20 @@ async function waitForEditor(captures: Captures): Promise<LexicalEditor> { return captures.editor as LexicalEditor } -async function waitForEventEmitter(captures: Captures): Promise<NonNullable<Captures['eventEmitter']>> { +async function waitForEventEmitter( + captures: Captures, +): Promise<NonNullable<Captures['eventEmitter']>> { await waitFor(() => { expect(captures.eventEmitter).not.toBeNull() }) return captures.eventEmitter as NonNullable<Captures['eventEmitter']> } -async function setEditorText(editor: LexicalEditor, text: string, selectEnd: boolean): Promise<void> { +async function setEditorText( + editor: LexicalEditor, + text: string, + selectEnd: boolean, +): Promise<void> { await act(async () => { editor.update(() => { const root = $getRoot() @@ -224,8 +237,7 @@ async function setEditorText(editor: LexicalEditor, text: string, selectEnd: boo const textNode = $createTextNode(text) paragraph.append(textNode) root.append(paragraph) - if (selectEnd) - textNode.selectEnd() + if (selectEnd) textNode.selectEnd() }) }) } @@ -235,24 +247,26 @@ function readEditorText(editor: LexicalEditor): string { } function getReactFiberFromDom(dom: Element): ReactFiber | null { - const key = Object.keys(dom).find(k => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')) - if (!key) - return null + const key = Object.keys(dom).find( + (k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'), + ) + if (!key) return null return (dom as unknown as Record<string, unknown>)[key] as ReactFiber } -function findHookRefPointingToElement(root: ReactFiber, element: Element): { current: unknown } | null { +function findHookRefPointingToElement( + root: ReactFiber, + element: Element, +): { current: unknown } | null { const visit = (fiber: ReactFiber | null): { current: unknown } | null => { - if (!fiber) - return null + if (!fiber) return null let hook = fiber.memoizedState as ReactHook | null | undefined while (hook) { const state = hook.memoizedState if (state && typeof state === 'object' && 'current' in state) { const ref = state as { current: unknown } - if (ref.current === element) - return ref + if (ref.current === element) return ref } hook = hook.next } @@ -265,7 +279,7 @@ function findHookRefPointingToElement(root: ReactFiber, element: Element): { cur async function flushNextTick(): Promise<void> { // Used to flush 0ms setTimeout work scheduled by renderMenu (refs.setReference guard). await act(async () => { - await new Promise<void>(resolve => setTimeout(resolve, 0)) + await new Promise<void>((resolve) => setTimeout(resolve, 0)) }) } @@ -295,14 +309,14 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { const user = userEvent.setup() const captures: Captures = { editor: null, eventEmitter: null } - render(( + render( <MinimalEditor triggerString="{" contextBlock={makeContextBlock()} queryBlock={makeQueryBlock()} captures={captures} - /> - )) + />, + ) const editor = await waitForEditor(captures) const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') @@ -335,7 +349,7 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { it('does not remove the trigger when selecting an option with an empty key (nodeToRemove && key falsy)', async () => { const captures: Captures = { editor: null, eventEmitter: null } - render(( + render( <MinimalEditor triggerString="{" variableBlock={makeVariableBlock({ @@ -345,8 +359,8 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { variables: [{ name: 'empty', value: '' }], })} captures={captures} - /> - )) + />, + ) const editor = await waitForEditor(captures) await setEditorText(editor, '{', true) @@ -371,13 +385,9 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { it('subscribes to EventEmitter and dispatches INSERT_VARIABLE_VALUE_BLOCK_COMMAND only for matching messages', async () => { const captures: Captures = { editor: null, eventEmitter: null } - render(( - <MinimalEditor - triggerString="{" - contextBlock={makeContextBlock()} - captures={captures} - /> - )) + render( + <MinimalEditor triggerString="{" contextBlock={makeContextBlock()} captures={captures} />, + ) const editor = await waitForEditor(captures) const eventEmitter = await waitForEventEmitter(captures) @@ -385,14 +395,20 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { // Non-object emissions (string) should be ignored by the subscription callback. eventEmitter.emit('some-string') - expect(dispatchSpy).not.toHaveBeenCalledWith(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, expect.any(String)) + expect(dispatchSpy).not.toHaveBeenCalledWith( + INSERT_VARIABLE_VALUE_BLOCK_COMMAND, + expect.any(String), + ) // Mismatched type should be ignored. eventEmitter.emit({ type: 'OTHER', payload: 'x' }) expect(dispatchSpy).not.toHaveBeenCalledWith(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, '{{x}}') // Matching type should dispatch with {{payload}} wrapping. - eventEmitter.emit({ type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND as unknown as string, payload: 'foo' }) + eventEmitter.emit({ + type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND as unknown as string, + payload: 'foo', + }) expect(dispatchSpy).toHaveBeenCalledWith(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, '{{foo}}') }) @@ -400,13 +416,18 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { const captures: Captures = { editor: null, eventEmitter: null } const workflowVariableBlock = makeWorkflowVariableBlock({}, [ - { nodeId: 'custom-flat', title: 'custom-flat', isFlat: true, vars: [makeWorkflowNodeVar('custom_flat', VarType.string)] }, + { + nodeId: 'custom-flat', + title: 'custom-flat', + isFlat: true, + vars: [makeWorkflowNodeVar('custom_flat', VarType.string)], + }, makeWorkflowVarNode('node-output', 'Node Output', [ makeWorkflowNodeVar('output', VarType.string), ]), ]) - render(( + render( <MinimalEditor triggerString="{" workflowVariableBlock={workflowVariableBlock} @@ -414,8 +435,8 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { errorMessageBlock={makeErrorMessageBlock()} lastRunBlock={makeLastRunBlock()} captures={captures} - /> - )) + />, + ) const editor = await waitForEditor(captures) const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') @@ -479,19 +500,23 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { const workflowVariableBlock = makeWorkflowVariableBlock({}, [ makeWorkflowVarNode('node-1', 'Node 1', [ - makeWorkflowNodeVar('sys.query', VarType.object, [makeWorkflowNodeVar('q', VarType.string)]), - makeWorkflowNodeVar('sys.files', VarType.object, [makeWorkflowNodeVar('f', VarType.string)]), + makeWorkflowNodeVar('sys.query', VarType.object, [ + makeWorkflowNodeVar('q', VarType.string), + ]), + makeWorkflowNodeVar('sys.files', VarType.object, [ + makeWorkflowNodeVar('f', VarType.string), + ]), makeWorkflowNodeVar('output', VarType.object, [makeWorkflowNodeVar('x', VarType.string)]), ]), ]) - render(( + render( <MinimalEditor triggerString="{" workflowVariableBlock={workflowVariableBlock} captures={captures} - /> - )) + />, + ) const editor = await waitForEditor(captures) const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') @@ -499,7 +524,9 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { const openPickerAndSelectField = async (variableTitle: string, fieldName: string) => { await setEditorText(editor, '{', true) await screen.findByPlaceholderText('workflow.common.searchVar') - await act(async () => { /* flush effects */ }) + await act(async () => { + /* flush effects */ + }) const label = document.querySelector(`[title="${variableTitle}"]`) expect(label).not.toBeNull() @@ -523,7 +550,11 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { await waitFor(() => expect(readEditorText(editor)).not.toContain('{')) await openPickerAndSelectField('output', 'x') - expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'output', 'x']) + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [ + 'node-1', + 'output', + 'x', + ]) await waitFor(() => expect(readEditorText(editor)).not.toContain('{')) }) @@ -533,19 +564,21 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { const workflowVariableBlock = makeWorkflowVariableBlock({}, [ makeWorkflowVarNode('node-1', 'Node 1', [ - makeWorkflowNodeVar('payload', VarType.object, [makeWorkflowNodeVar('child_name', VarType.string)]), + makeWorkflowNodeVar('payload', VarType.object, [ + makeWorkflowNodeVar('child_name', VarType.string), + ]), makeWorkflowNodeVar('other_value', VarType.string), ]), ]) - render(( + render( <MinimalEditor triggerString="/" contextBlock={makeContextBlock()} workflowVariableBlock={workflowVariableBlock} captures={captures} - /> - )) + />, + ) const editor = await waitForEditor(captures) const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') @@ -567,7 +600,11 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { fireEvent.mouseDown(childField) await user.unhover(row as HTMLElement) - expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'payload', 'child_name']) + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [ + 'node-1', + 'payload', + 'child_name', + ]) await waitFor(() => expect(readEditorText(editor)).not.toContain('/child')) }) @@ -581,14 +618,14 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { ]), ]) - render(( + render( <MinimalEditor triggerString="/" contextBlock={makeContextBlock()} workflowVariableBlock={workflowVariableBlock} captures={captures} - /> - )) + />, + ) const editor = await waitForEditor(captures) await setEditorText(editor, '/c', true) @@ -608,13 +645,11 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { it('clears slash trigger state after creating an agent output from the footer action', async () => { const captures: Captures = { editor: null, eventEmitter: null } - render(( + render( <MinimalEditor triggerString="/" workflowVariableBlock={makeWorkflowVariableBlock({}, [ - makeWorkflowVarNode('node-1', 'Node 1', [ - makeWorkflowNodeVar('output', VarType.string), - ]), + makeWorkflowVarNode('node-1', 'Node 1', [makeWorkflowNodeVar('output', VarType.string)]), ])} agentOutputBlock={{ show: true, @@ -622,8 +657,8 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { onChange: vi.fn(), }} captures={captures} - /> - )) + />, + ) const editor = await waitForEditor(captures) const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') @@ -637,7 +672,9 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { expect(dispatchSpy).toHaveBeenCalledWith(INSERT_AGENT_OUTPUT_BLOCK_COMMAND, undefined) await waitFor(() => { expect(readEditorText(editor)).not.toContain('/') - expect(screen.queryByText('workflow.nodes.agent.outputVars.newOutput')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.agent.outputVars.newOutput'), + ).not.toBeInTheDocument() }) const editable = screen.getByTestId(CONTENT_EDITABLE_TEST_ID) @@ -657,14 +694,14 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { ]), ]) - render(( + render( <MinimalEditor triggerString="/" contextBlock={makeContextBlock()} workflowVariableBlock={workflowVariableBlock} captures={captures} - /> - )) + />, + ) const editor = await waitForEditor(captures) const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') @@ -685,7 +722,10 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { fireEvent.keyDown(document, { key: 'Enter' }) - expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'second_value']) + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [ + 'node-1', + 'second_value', + ]) await waitFor(() => expect(readEditorText(editor)).not.toContain('/e')) }) @@ -693,16 +733,21 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { const captures: Captures = { editor: null, eventEmitter: null } const workflowVariableBlock = makeWorkflowVariableBlock({}, [ - { nodeId: 'current', title: 'current_prompt', isFlat: true, vars: [makeWorkflowNodeVar('current', VarType.string)] }, + { + nodeId: 'current', + title: 'current_prompt', + isFlat: true, + vars: [makeWorkflowNodeVar('current', VarType.string)], + }, ]) - render(( + render( <MinimalEditor triggerString="{" workflowVariableBlock={workflowVariableBlock} captures={captures} - /> - )) + />, + ) const editor = await waitForEditor(captures) const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') @@ -736,15 +781,13 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { vi.useFakeTimers() const captures: Captures = { editor: null, eventEmitter: null } - render(( - <MinimalEditor - triggerString="{" - contextBlock={makeContextBlock()} - captures={captures} - /> - )) + render( + <MinimalEditor triggerString="{" contextBlock={makeContextBlock()} captures={captures} />, + ) - await act(async () => { /* flush effects */ }) + await act(async () => { + /* flush effects */ + }) expect(captures.editor).not.toBeNull() const editor = captures.editor as LexicalEditor @@ -758,8 +801,7 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { expect(fiber).not.toBeNull() const root = (() => { let cur = fiber as ReactFiber - while (cur.return) - cur = cur.return + while (cur.return) cur = cur.return return cur })() @@ -778,19 +820,17 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { const captures: Captures = { editor: null, eventEmitter: null } const workflowVariableBlock = makeWorkflowVariableBlock({}, [ - makeWorkflowVarNode('node-1', 'Node 1', [ - makeWorkflowNodeVar('output', VarType.string), - ]), + makeWorkflowVarNode('node-1', 'Node 1', [makeWorkflowNodeVar('output', VarType.string)]), ]) - render(( + render( <MinimalEditor triggerString="{" workflowVariableBlock={workflowVariableBlock} contextBlock={makeContextBlock()} captures={captures} - /> - )) + />, + ) const editor = await waitForEditor(captures) await setEditorText(editor, '{', true) @@ -807,13 +847,9 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { it('hides the menu after a 200ms delay when blur-sm command is dispatched', async () => { const captures: Captures = { editor: null, eventEmitter: null } - render(( - <MinimalEditor - triggerString="{" - contextBlock={makeContextBlock()} - captures={captures} - /> - )) + render( + <MinimalEditor triggerString="{" contextBlock={makeContextBlock()} captures={captures} />, + ) const editor = await waitForEditor(captures) await setEditorText(editor, '{', true) @@ -822,7 +858,10 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { vi.useFakeTimers() act(() => { - editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur-sm', { relatedTarget: document.createElement('button') })) + editor.dispatchCommand( + BLUR_COMMAND, + new FocusEvent('blur-sm', { relatedTarget: document.createElement('button') }), + ) }) expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument() @@ -839,13 +878,9 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { it('restores menu visibility when focus command is dispatched after blur-sm hides it', async () => { const captures: Captures = { editor: null, eventEmitter: null } - render(( - <MinimalEditor - triggerString="{" - contextBlock={makeContextBlock()} - captures={captures} - /> - )) + render( + <MinimalEditor triggerString="{" contextBlock={makeContextBlock()} captures={captures} />, + ) const editor = await waitForEditor(captures) await setEditorText(editor, '{', true) @@ -854,7 +889,10 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { vi.useFakeTimers() act(() => { - editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur-sm', { relatedTarget: document.createElement('button') })) + editor.dispatchCommand( + BLUR_COMMAND, + new FocusEvent('blur-sm', { relatedTarget: document.createElement('button') }), + ) }) act(() => { vi.advanceTimersByTime(200) @@ -877,13 +915,9 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { it('cancels the blur-sm timer when focus arrives before the 200ms timeout', async () => { const captures: Captures = { editor: null, eventEmitter: null } - render(( - <MinimalEditor - triggerString="{" - contextBlock={makeContextBlock()} - captures={captures} - /> - )) + render( + <MinimalEditor triggerString="{" contextBlock={makeContextBlock()} captures={captures} />, + ) const editor = await waitForEditor(captures) await setEditorText(editor, '{', true) @@ -892,7 +926,10 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { vi.useFakeTimers() act(() => { - editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur-sm', { relatedTarget: document.createElement('button') })) + editor.dispatchCommand( + BLUR_COMMAND, + new FocusEvent('blur-sm', { relatedTarget: document.createElement('button') }), + ) }) act(() => { @@ -911,13 +948,9 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { it('cancels a pending blur-sm timer when a subsequent blur-sm targets var-search-input', async () => { const captures: Captures = { editor: null, eventEmitter: null } - render(( - <MinimalEditor - triggerString="{" - contextBlock={makeContextBlock()} - captures={captures} - /> - )) + render( + <MinimalEditor triggerString="{" contextBlock={makeContextBlock()} captures={captures} />, + ) const editor = await waitForEditor(captures) await setEditorText(editor, '{', true) @@ -926,7 +959,10 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { vi.useFakeTimers() act(() => { - editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur-sm', { relatedTarget: document.createElement('button') })) + editor.dispatchCommand( + BLUR_COMMAND, + new FocusEvent('blur-sm', { relatedTarget: document.createElement('button') }), + ) }) const varInput = document.createElement('input') @@ -948,13 +984,9 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { it('does not hide the menu when blur-sm target is var-search-input', async () => { const captures: Captures = { editor: null, eventEmitter: null } - render(( - <MinimalEditor - triggerString="{" - contextBlock={makeContextBlock()} - captures={captures} - /> - )) + render( + <MinimalEditor triggerString="{" contextBlock={makeContextBlock()} captures={captures} />, + ) const editor = await waitForEditor(captures) await setEditorText(editor, '{', true) @@ -981,17 +1013,19 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { it('does not hide the menu when focus moves into a variable child popup', async () => { const captures: Captures = { editor: null, eventEmitter: null } - render(( + render( <MinimalEditor triggerString="/" workflowVariableBlock={makeWorkflowVariableBlock({}, [ makeWorkflowVarNode('node-1', 'Node 1', [ - makeWorkflowNodeVar('payload', VarType.object, [makeWorkflowNodeVar('child', VarType.string)]), + makeWorkflowNodeVar('payload', VarType.object, [ + makeWorkflowNodeVar('child', VarType.string), + ]), ]), ])} captures={captures} - /> - )) + />, + ) const editor = await waitForEditor(captures) await setEditorText(editor, '/', true) @@ -1006,7 +1040,10 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { document.body.appendChild(popup) act(() => { - editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur-sm', { relatedTarget: popupTarget })) + editor.dispatchCommand( + BLUR_COMMAND, + new FocusEvent('blur-sm', { relatedTarget: popupTarget }), + ) }) act(() => { diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/variable-option.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/variable-option.spec.tsx index 7925765e148632..4efbb7f8e3e014 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/variable-option.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/variable-option.spec.tsx @@ -23,10 +23,7 @@ describe('VariableMenuItem', () => { it('should render the icon when provided', () => { render( - <VariableMenuItem - {...defaultProps} - icon={<span data-testid="test-icon">icon</span>} - />, + <VariableMenuItem {...defaultProps} icon={<span data-testid="test-icon">icon</span>} />, ) expect(screen.getByTestId('test-icon')).toBeInTheDocument() }) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx index 117c1535c6424f..05921f080f9400 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx @@ -19,10 +19,7 @@ import AppIcon from '@/app/components/base/app-icon' import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows' import { BracketsX } from '@/app/components/base/icons/src/vender/line/development' import { File05 } from '@/app/components/base/icons/src/vender/solid/files' -import { - MessageClockCircle, - Tool03, -} from '@/app/components/base/icons/src/vender/solid/general' +import { MessageClockCircle, Tool03 } from '@/app/components/base/icons/src/vender/solid/general' import { UserEdit02 } from '@/app/components/base/icons/src/vender/solid/users' import { VarType } from '@/app/components/workflow/types' import { INSERT_CONTEXT_BLOCK_COMMAND } from '../context-block' @@ -46,38 +43,39 @@ export const usePromptOptions = ( const promptOptions: PickerBlockMenuOption[] = [] if (contextBlock?.show) { - promptOptions.push(new PickerBlockMenuOption({ - key: t($ => $['promptEditor.context.item.title'], { ns: 'common' }), - group: 'prompt context', - render: ({ isSelected, onSelect, onSetHighlight }) => { - return ( - <PromptMenuItem - title={t($ => $['promptEditor.context.item.title'], { ns: 'common' })} - icon={<File05 className="h-4 w-4 text-[#6938EF]" />} - disabled={!contextBlock.selectable} - isSelected={isSelected} - onClick={onSelect} - onMouseEnter={onSetHighlight} - /> - ) - }, - onSelect: () => { - if (!contextBlock?.selectable) - return - editor.dispatchCommand(INSERT_CONTEXT_BLOCK_COMMAND, undefined) - }, - })) + promptOptions.push( + new PickerBlockMenuOption({ + key: t(($) => $['promptEditor.context.item.title'], { ns: 'common' }), + group: 'prompt context', + render: ({ isSelected, onSelect, onSetHighlight }) => { + return ( + <PromptMenuItem + title={t(($) => $['promptEditor.context.item.title'], { ns: 'common' })} + icon={<File05 className="h-4 w-4 text-[#6938EF]" />} + disabled={!contextBlock.selectable} + isSelected={isSelected} + onClick={onSelect} + onMouseEnter={onSetHighlight} + /> + ) + }, + onSelect: () => { + if (!contextBlock?.selectable) return + editor.dispatchCommand(INSERT_CONTEXT_BLOCK_COMMAND, undefined) + }, + }), + ) } if (queryBlock?.show) { promptOptions.push( new PickerBlockMenuOption({ - key: t($ => $['promptEditor.query.item.title'], { ns: 'common' }), + key: t(($) => $['promptEditor.query.item.title'], { ns: 'common' }), group: 'prompt query', render: ({ isSelected, onSelect, onSetHighlight }) => { return ( <PromptMenuItem - title={t($ => $['promptEditor.query.item.title'], { ns: 'common' })} + title={t(($) => $['promptEditor.query.item.title'], { ns: 'common' })} icon={<UserEdit02 className="h-4 w-4 text-[#FD853A]" />} disabled={!queryBlock.selectable} isSelected={isSelected} @@ -87,8 +85,7 @@ export const usePromptOptions = ( ) }, onSelect: () => { - if (!queryBlock?.selectable) - return + if (!queryBlock?.selectable) return editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined) }, }), @@ -96,38 +93,39 @@ export const usePromptOptions = ( } if (requestURLBlock?.show) { - promptOptions.push(new PickerBlockMenuOption({ - key: t($ => $['promptEditor.requestURL.item.title'], { ns: 'common' }), - group: 'request URL', - render: ({ isSelected, onSelect, onSetHighlight }) => { - return ( - <PromptMenuItem - title={t($ => $['promptEditor.requestURL.item.title'], { ns: 'common' })} - icon={<RiGlobalLine className="size-4 text-util-colors-violet-violet-600" />} - disabled={!requestURLBlock.selectable} - isSelected={isSelected} - onClick={onSelect} - onMouseEnter={onSetHighlight} - /> - ) - }, - onSelect: () => { - if (!requestURLBlock?.selectable) - return - editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined) - }, - })) + promptOptions.push( + new PickerBlockMenuOption({ + key: t(($) => $['promptEditor.requestURL.item.title'], { ns: 'common' }), + group: 'request URL', + render: ({ isSelected, onSelect, onSetHighlight }) => { + return ( + <PromptMenuItem + title={t(($) => $['promptEditor.requestURL.item.title'], { ns: 'common' })} + icon={<RiGlobalLine className="size-4 text-util-colors-violet-violet-600" />} + disabled={!requestURLBlock.selectable} + isSelected={isSelected} + onClick={onSelect} + onMouseEnter={onSetHighlight} + /> + ) + }, + onSelect: () => { + if (!requestURLBlock?.selectable) return + editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined) + }, + }), + ) } if (historyBlock?.show) { promptOptions.push( new PickerBlockMenuOption({ - key: t($ => $['promptEditor.history.item.title'], { ns: 'common' }), + key: t(($) => $['promptEditor.history.item.title'], { ns: 'common' }), group: 'prompt history', render: ({ isSelected, onSelect, onSetHighlight }) => { return ( <PromptMenuItem - title={t($ => $['promptEditor.history.item.title'], { ns: 'common' })} + title={t(($) => $['promptEditor.history.item.title'], { ns: 'common' })} icon={<MessageClockCircle className="h-4 w-4 text-[#DD2590]" />} disabled={!historyBlock.selectable} isSelected={isSelected} @@ -137,8 +135,7 @@ export const usePromptOptions = ( ) }, onSelect: () => { - if (!historyBlock?.selectable) - return + if (!historyBlock?.selectable) return editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined) }, }), @@ -155,10 +152,9 @@ export const useVariableOptions = ( const [editor] = useLexicalComposerContext() const options = useMemo(() => { - if (!variableBlock?.variables) - return [] + if (!variableBlock?.variables) return [] - const baseOptions = (variableBlock.variables).map((item) => { + const baseOptions = variableBlock.variables.map((item) => { return new PickerBlockMenuOption({ key: item.value, group: 'prompt variable', @@ -179,22 +175,21 @@ export const useVariableOptions = ( }, }) }) - if (!queryString) - return baseOptions + if (!queryString) return baseOptions const regex = new RegExp(queryString, 'i') - return baseOptions.filter(option => regex.test(option.key)) + return baseOptions.filter((option) => regex.test(option.key)) }, [editor, queryString, variableBlock]) const addOption = useMemo(() => { return new PickerBlockMenuOption({ - key: t($ => $['promptEditor.variable.modal.add'], { ns: 'common' }), + key: t(($) => $['promptEditor.variable.modal.add'], { ns: 'common' }), group: 'prompt variable', render: ({ queryString, isSelected, onSelect, onSetHighlight }) => { return ( <VariableMenuItem - title={t($ => $['promptEditor.variable.modal.add'], { ns: 'common' })} + title={t(($) => $['promptEditor.variable.modal.add'], { ns: 'common' })} icon={<BracketsX className="h-[14px] w-[14px] text-text-accent" />} queryString={queryString} isSelected={isSelected} @@ -227,9 +222,8 @@ export const useExternalToolOptions = ( const [editor] = useLexicalComposerContext() const options = useMemo(() => { - if (!externalToolBlockType?.externalTools) - return [] - const baseToolOptions = (externalToolBlockType.externalTools).map((item) => { + if (!externalToolBlockType?.externalTools) return [] + const baseToolOptions = externalToolBlockType.externalTools.map((item) => { return new PickerBlockMenuOption({ key: item.name, group: 'external tool', @@ -237,13 +231,13 @@ export const useExternalToolOptions = ( return ( <VariableMenuItem title={item.name} - icon={( + icon={ <AppIcon className="h-[14px]! w-[14px]!" icon={item.icon} background={item.icon_background} /> - )} + } extraElement={<div className="text-xs text-text-tertiary">{item.variableName}</div>} queryString={queryString} isSelected={isSelected} @@ -257,22 +251,21 @@ export const useExternalToolOptions = ( }, }) }) - if (!queryString) - return baseToolOptions + if (!queryString) return baseToolOptions const regex = new RegExp(queryString, 'i') - return baseToolOptions.filter(option => regex.test(option.key)) + return baseToolOptions.filter((option) => regex.test(option.key)) }, [editor, queryString, externalToolBlockType]) const addOption = useMemo(() => { return new PickerBlockMenuOption({ - key: t($ => $['promptEditor.variable.modal.addTool'], { ns: 'common' }), + key: t(($) => $['promptEditor.variable.modal.addTool'], { ns: 'common' }), group: 'external tool', render: ({ queryString, isSelected, onSelect, onSetHighlight }) => { return ( <VariableMenuItem - title={t($ => $['promptEditor.variable.modal.addTool'], { ns: 'common' })} + title={t(($) => $['promptEditor.variable.modal.addTool'], { ns: 'common' })} icon={<Tool03 className="h-[14px] w-[14px] text-text-accent" />} extraElement={<ArrowUpRight className="size-3 text-text-tertiary" />} queryString={queryString} @@ -311,10 +304,9 @@ export const useOptions = ( const externalToolOptions = useExternalToolOptions(externalToolBlockType, queryString) const workflowVariableOptions = useMemo(() => { - if (!workflowVariableBlockType?.show) - return [] + if (!workflowVariableBlockType?.show) return [] const res = workflowVariableBlockType.variables || [] - if (errorMessageBlockType?.show && res.findIndex(v => v.nodeId === 'error_message') === -1) { + if (errorMessageBlockType?.show && res.findIndex((v) => v.nodeId === 'error_message') === -1) { res.unshift({ nodeId: 'error_message', title: 'error_message', @@ -327,7 +319,7 @@ export const useOptions = ( ], }) } - if (lastRunBlockType?.show && res.findIndex(v => v.nodeId === 'last_run') === -1) { + if (lastRunBlockType?.show && res.findIndex((v) => v.nodeId === 'last_run') === -1) { res.unshift({ nodeId: 'last_run', title: 'last_run', @@ -340,7 +332,7 @@ export const useOptions = ( ], }) } - if (currentBlockType?.show && res.findIndex(v => v.nodeId === 'current') === -1) { + if (currentBlockType?.show && res.findIndex((v) => v.nodeId === 'current') === -1) { const title = currentBlockType.generatorType === 'prompt' ? 'current_prompt' : 'current_code' res.unshift({ nodeId: 'current', @@ -355,7 +347,14 @@ export const useOptions = ( }) } return res - }, [workflowVariableBlockType?.show, workflowVariableBlockType?.variables, errorMessageBlockType?.show, lastRunBlockType?.show, currentBlockType?.show, currentBlockType?.generatorType]) + }, [ + workflowVariableBlockType?.show, + workflowVariableBlockType?.variables, + errorMessageBlockType?.show, + lastRunBlockType?.show, + currentBlockType?.show, + currentBlockType?.generatorType, + ]) return useMemo(() => { return { diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index f98cbbeef9de98..64e62df3e2d7f8 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -16,33 +16,18 @@ import type { } from '../../types' import type { PickerBlockMenuOption } from './menu' import type { EventEmitterValue } from '@/context/event-emitter' -import { - flip, - offset, - shift, - useFloating, -} from '@floating-ui/react' +import { flip, offset, shift, useFloating } from '@floating-ui/react' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin' import { mergeRegister } from '@lexical/utils' -import { - BLUR_COMMAND, - COMMAND_PRIORITY_EDITOR, - FOCUS_COMMAND, - KEY_ESCAPE_COMMAND, -} from 'lexical' -import { - Fragment, - memo, - useCallback, - useEffect, - useRef, - useState, -} from 'react' +import { BLUR_COMMAND, COMMAND_PRIORITY_EDITOR, FOCUS_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical' +import { Fragment, memo, useCallback, useEffect, useRef, useState } from 'react' import ReactDOM from 'react-dom' import { useTranslation } from 'react-i18next' import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types' -import VarReferenceVars, { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' +import VarReferenceVars, { + VAR_REFERENCE_CHILD_POPUP_CLASS_NAME, +} from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' import { useEventEmitterContextContext } from '@/context/event-emitter' import { useBasicTypeaheadTriggerMatch } from '../../hooks' import { $splitNodeContainingQuery } from '../../utils' @@ -102,11 +87,14 @@ const ComponentPicker = ({ minLength: 0, maxLength: 75, }) - const checkForTriggerMatch = useCallback((text: string, editor: LexicalEditor) => { - const match = baseCheckForTriggerMatch(text, editor) - triggerMatchRef.current = match - return match - }, [baseCheckForTriggerMatch]) + const checkForTriggerMatch = useCallback( + (text: string, editor: LexicalEditor) => { + const match = baseCheckForTriggerMatch(text, editor) + triggerMatchRef.current = match + return match + }, + [baseCheckForTriggerMatch], + ) const [queryString, setQueryString] = useState<string | null>(null) const [blurHidden, setBlurHidden] = useState(false) @@ -127,8 +115,9 @@ const ComponentPicker = ({ (event) => { clearBlurTimer() const target = event?.relatedTarget as HTMLElement - const isVariableMenuTarget = target?.classList?.contains('var-search-input') - || target?.closest?.(`.${VAR_REFERENCE_CHILD_POPUP_CLASS_NAME}`) + const isVariableMenuTarget = + target?.classList?.contains('var-search-input') || + target?.closest?.(`.${VAR_REFERENCE_CHILD_POPUP_CLASS_NAME}`) if (!isVariableMenuTarget) blurTimerRef.current = setTimeout(() => setBlurHidden(true), 200) return false @@ -147,21 +136,21 @@ const ComponentPicker = ({ ) return () => { - if (blurTimerRef.current) - clearTimeout(blurTimerRef.current) + if (blurTimerRef.current) clearTimeout(blurTimerRef.current) unregister() } }, [editor, clearBlurTimer]) eventEmitter?.useSubscription((v: EventEmitterValue) => { - if (typeof v !== 'string' && v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND && typeof v.payload === 'string') + if ( + typeof v !== 'string' && + v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND && + typeof v.payload === 'string' + ) editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${v.payload}}}`) }) - const { - allFlattenOptions, - workflowVariableOptions, - } = useOptions( + const { allFlattenOptions, workflowVariableOptions } = useOptions( contextBlock, queryBlock, historyBlock, @@ -182,8 +171,7 @@ const ComponentPicker = ({ closeMenu: () => void, ) => { editor.update(() => { - if (nodeToRemove && selectedOption?.key) - nodeToRemove.remove() + if (nodeToRemove && selectedOption?.key) nodeToRemove.remove() selectedOption.onSelectMenuOption() closeMenu() @@ -192,30 +180,32 @@ const ComponentPicker = ({ [editor], ) - const handleSelectWorkflowVariable = useCallback((variables: string[]) => { - editor.update(() => { - const currentTriggerMatch = triggerMatchRef.current ?? checkForTriggerMatch(triggerString, editor) - const needRemove = currentTriggerMatch ? $splitNodeContainingQuery(currentTriggerMatch) : null - if (needRemove) - needRemove.remove() - }) - const isFlat = variables.length === 1 - if (isFlat) { - const varName = variables[0] - if (varName === 'current') - editor.dispatchCommand(INSERT_CURRENT_BLOCK_COMMAND, currentBlock?.generatorType) - else if (varName === 'error_message') - editor.dispatchCommand(INSERT_ERROR_MESSAGE_BLOCK_COMMAND, null) - else if (varName === 'last_run') - editor.dispatchCommand(INSERT_LAST_RUN_BLOCK_COMMAND, null) - } - else if (variables[1] === 'sys.query' || variables[1] === 'sys.files') { - editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [variables[1]]) - } - else { - editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables) - } - }, [editor, currentBlock?.generatorType, checkForTriggerMatch, triggerString]) + const handleSelectWorkflowVariable = useCallback( + (variables: string[]) => { + editor.update(() => { + const currentTriggerMatch = + triggerMatchRef.current ?? checkForTriggerMatch(triggerString, editor) + const needRemove = currentTriggerMatch + ? $splitNodeContainingQuery(currentTriggerMatch) + : null + if (needRemove) needRemove.remove() + }) + const isFlat = variables.length === 1 + if (isFlat) { + const varName = variables[0] + if (varName === 'current') + editor.dispatchCommand(INSERT_CURRENT_BLOCK_COMMAND, currentBlock?.generatorType) + else if (varName === 'error_message') + editor.dispatchCommand(INSERT_ERROR_MESSAGE_BLOCK_COMMAND, null) + else if (varName === 'last_run') editor.dispatchCommand(INSERT_LAST_RUN_BLOCK_COMMAND, null) + } else if (variables[1] === 'sys.query' || variables[1] === 'sys.files') { + editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [variables[1]]) + } else { + editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables) + } + }, + [editor, currentBlock?.generatorType, checkForTriggerMatch, triggerString], + ) const resetTypeaheadState = useCallback(() => { triggerMatchRef.current = null @@ -228,26 +218,26 @@ const ComponentPicker = ({ editor.dispatchCommand(KEY_ESCAPE_COMMAND, escapeEvent) }, [editor]) - const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>(( - anchorElementRef, - { options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, - ) => { - const effectiveQueryString = triggerMatchRef.current?.matchingString ?? queryString + const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>( + (anchorElementRef, { options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => { + const effectiveQueryString = triggerMatchRef.current?.matchingString ?? queryString - if (blurHidden) - return null - if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show || showAgentOutputAction))) - return null + if (blurHidden) return null + if ( + !( + anchorElementRef.current && + (allFlattenOptions.length || workflowVariableBlock?.show || showAgentOutputAction) + ) + ) + return null - setTimeout(() => { - if (anchorElementRef.current) - refs.setReference(anchorElementRef.current) - }, 0) + setTimeout(() => { + if (anchorElementRef.current) refs.setReference(anchorElementRef.current) + }, 0) - return ( - <> - { - ReactDOM.createPortal( + return ( + <> + {ReactDOM.createPortal( // The `LexicalMenu` will try to calculate the position of the floating menu based on the first child. // Since we use floating ui, we need to wrap it with a div to prevent the position calculation being affected. // See https://github.com/facebook/lexical/blob/ac97dfa9e14a73ea2d6934ff566282d7f758e8bb/packages/lexical-react/src/shared/LexicalMenu.ts#L493 @@ -260,89 +250,114 @@ const ComponentPicker = ({ }} ref={refs.setFloating} > - { - workflowVariableBlock?.show && ( - <div className="p-1"> - <VarReferenceVars - hideSearch={triggerString === '/'} - searchText={triggerString === '/' ? (effectiveQueryString || '') : undefined} - searchBoxClassName="mt-1" - vars={workflowVariableOptions} - onChange={(variables: string[]) => { - handleSelectWorkflowVariable(variables) - }} - maxHeightClass="max-h-[34vh]" - isSupportFileVar={isSupportFileVar} - onClose={handleClose} - onBlur={handleClose} - showManageInputField={workflowVariableBlock.showManageInputField} - onManageInputField={workflowVariableBlock.onManageInputField} - autoFocus={false} - isInCodeGeneratorInstructionEditor={currentBlock?.generatorType === GeneratorType.code} - /> - </div> - ) - } - { - workflowVariableBlock?.show && !!options.length && ( - <div className="my-1 h-px w-full -translate-x-1 bg-divider-subtle"></div> - ) - } + {workflowVariableBlock?.show && ( + <div className="p-1"> + <VarReferenceVars + hideSearch={triggerString === '/'} + searchText={triggerString === '/' ? effectiveQueryString || '' : undefined} + searchBoxClassName="mt-1" + vars={workflowVariableOptions} + onChange={(variables: string[]) => { + handleSelectWorkflowVariable(variables) + }} + maxHeightClass="max-h-[34vh]" + isSupportFileVar={isSupportFileVar} + onClose={handleClose} + onBlur={handleClose} + showManageInputField={workflowVariableBlock.showManageInputField} + onManageInputField={workflowVariableBlock.onManageInputField} + autoFocus={false} + isInCodeGeneratorInstructionEditor={ + currentBlock?.generatorType === GeneratorType.code + } + /> + </div> + )} + {workflowVariableBlock?.show && !!options.length && ( + <div className="my-1 h-px w-full -translate-x-1 bg-divider-subtle"></div> + )} <div> - { - options.map((option, index) => ( - <Fragment key={option.key}> - { - // Divider - index !== 0 && options.at(index - 1)?.group !== option.group && ( - <div className="my-1 h-px w-full -translate-x-1 bg-divider-subtle"></div> - ) - } - {option.renderMenuOption({ - queryString: effectiveQueryString, - isSelected: workflowVariableBlock?.show ? false : selectedIndex === index, - onSelect: () => { - selectOptionAndCleanUp(option) - }, - onSetHighlight: () => { - setHighlightedIndex(index) - }, - })} - </Fragment> - )) - } + {options.map((option, index) => ( + <Fragment key={option.key}> + { + // Divider + index !== 0 && options.at(index - 1)?.group !== option.group && ( + <div className="my-1 h-px w-full -translate-x-1 bg-divider-subtle"></div> + ) + } + {option.renderMenuOption({ + queryString: effectiveQueryString, + isSelected: workflowVariableBlock?.show ? false : selectedIndex === index, + onSelect: () => { + selectOptionAndCleanUp(option) + }, + onSetHighlight: () => { + setHighlightedIndex(index) + }, + })} + </Fragment> + ))} </div> {showAgentOutputAction && ( <div className="mt-1 border-t border-divider-subtle p-1"> <button type="button" className="flex h-6 w-full items-center gap-1 rounded-md py-1 pr-1 pl-3 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" - onMouseDown={event => event.preventDefault()} + onMouseDown={(event) => event.preventDefault()} onClick={() => { editor.update(() => { - const currentTriggerMatch = triggerMatchRef.current ?? checkForTriggerMatch(triggerString, editor) - const needRemove = currentTriggerMatch ? $splitNodeContainingQuery(currentTriggerMatch) : null - if (needRemove) - needRemove.remove() + const currentTriggerMatch = + triggerMatchRef.current ?? checkForTriggerMatch(triggerString, editor) + const needRemove = currentTriggerMatch + ? $splitNodeContainingQuery(currentTriggerMatch) + : null + if (needRemove) needRemove.remove() }) editor.dispatchCommand(INSERT_AGENT_OUTPUT_BLOCK_COMMAND, undefined) resetTypeaheadState() }} > <span aria-hidden="true" className="i-ri-add-line size-4 shrink-0" /> - <span className="min-w-0 flex-1 truncate">{t($ => $['nodes.agent.outputVars.newOutput'], { ns: 'workflow' })}</span> - <span aria-hidden="true" className="i-ri-question-line size-3.5 shrink-0 text-text-quaternary" /> + <span className="min-w-0 flex-1 truncate"> + {t(($) => $['nodes.agent.outputVars.newOutput'], { ns: 'workflow' })} + </span> + <span + aria-hidden="true" + className="i-ri-question-line size-3.5 shrink-0 text-text-quaternary" + /> </button> </div> )} </div> </div>, anchorElementRef.current, - ) - } - </> - ) - }, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, showAgentOutputAction, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, triggerString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, editor, checkForTriggerMatch, t, resetTypeaheadState]) + )} + </> + ) + }, + [ + blurHidden, + allFlattenOptions.length, + workflowVariableBlock?.show, + showAgentOutputAction, + floatingStyles, + isPositioned, + refs, + workflowVariableOptions, + isSupportFileVar, + handleClose, + currentBlock?.generatorType, + handleSelectWorkflowVariable, + queryString, + triggerString, + workflowVariableBlock?.showManageInputField, + workflowVariableBlock?.onManageInputField, + editor, + checkForTriggerMatch, + t, + resetTypeaheadState, + ], + ) return ( <LexicalTypeaheadMenuPlugin diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx index 679e604adf67d0..52280000b83dc2 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx @@ -27,5 +27,7 @@ export class PickerBlockMenuOption extends MenuOption { } public onSelectMenuOption = () => this.data.onSelect?.() - public renderMenuOption = (menuRenderProps: MenuOptionRenderProps) => <Fragment key={this.data.key}>{this.data.render(menuRenderProps)}</Fragment> + public renderMenuOption = (menuRenderProps: MenuOptionRenderProps) => ( + <Fragment key={this.data.key}>{this.data.render(menuRenderProps)}</Fragment> + ) } diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/prompt-option.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/prompt-option.tsx index 1499fc1d7fb7aa..d67f2a96623a07 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/prompt-option.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/prompt-option.tsx @@ -9,42 +9,38 @@ type PromptMenuItemMenuItemProps = { onMouseEnter: () => void setRefElement?: (element: HTMLDivElement) => void } -export const PromptMenuItem = memo(({ - icon, - title, - disabled, - isSelected, - onClick, - onMouseEnter, - setRefElement, -}: PromptMenuItemMenuItemProps) => { - return ( - <div - className={` - flex h-6 cursor-pointer items-center rounded-md px-3 hover:bg-state-base-hover - ${isSelected && !disabled && 'bg-state-base-hover!'} - ${disabled ? 'cursor-not-allowed opacity-30' : ''} - `} - tabIndex={-1} - ref={setRefElement} - onMouseEnter={() => { - if (disabled) - return - onMouseEnter() - }} - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onClick={() => { - if (disabled) - return - onClick() - }} - > - {icon} - <div className="ml-1 text-[13px] text-text-secondary">{title}</div> - </div> - ) -}) +export const PromptMenuItem = memo( + ({ + icon, + title, + disabled, + isSelected, + onClick, + onMouseEnter, + setRefElement, + }: PromptMenuItemMenuItemProps) => { + return ( + <div + className={`flex h-6 cursor-pointer items-center rounded-md px-3 hover:bg-state-base-hover ${isSelected && !disabled && 'bg-state-base-hover!'} ${disabled ? 'cursor-not-allowed opacity-30' : ''} `} + tabIndex={-1} + ref={setRefElement} + onMouseEnter={() => { + if (disabled) return + onMouseEnter() + }} + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onClick={() => { + if (disabled) return + onClick() + }} + > + {icon} + <div className="ml-1 text-[13px] text-text-secondary">{title}</div> + </div> + ) + }, +) PromptMenuItem.displayName = 'PromptMenuItem' diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/variable-option.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/variable-option.tsx index b98e8b7e6a62fe..422a649ce038e5 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/variable-option.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/variable-option.tsx @@ -10,56 +10,53 @@ type VariableMenuItemProps = { onMouseEnter: () => void setRefElement?: (element: HTMLDivElement) => void } -export const VariableMenuItem = memo(({ - title, - icon, - extraElement, - isSelected, - queryString, - onClick, - onMouseEnter, - setRefElement, -}: VariableMenuItemProps) => { - let before = title - let middle = '' - let after = '' +export const VariableMenuItem = memo( + ({ + title, + icon, + extraElement, + isSelected, + queryString, + onClick, + onMouseEnter, + setRefElement, + }: VariableMenuItemProps) => { + let before = title + let middle = '' + let after = '' - if (queryString) { - const regex = new RegExp(queryString, 'i') - const match = regex.exec(title) + if (queryString) { + const regex = new RegExp(queryString, 'i') + const match = regex.exec(title) - if (match) { - before = title.substring(0, match.index) - middle = match[0] - after = title.substring(match.index + match[0].length) + if (match) { + before = title.substring(0, match.index) + middle = match[0] + after = title.substring(match.index + match[0].length) + } } - } - return ( - <div - className={` - flex h-6 cursor-pointer items-center rounded-md px-3 hover:bg-state-base-hover - ${isSelected && 'bg-state-base-hover'} - `} - tabIndex={-1} - ref={setRefElement} - onMouseEnter={onMouseEnter} - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onClick={onClick} - > - <div className="mr-2"> - {icon} + return ( + <div + className={`flex h-6 cursor-pointer items-center rounded-md px-3 hover:bg-state-base-hover ${isSelected && 'bg-state-base-hover'} `} + tabIndex={-1} + ref={setRefElement} + onMouseEnter={onMouseEnter} + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onClick={onClick} + > + <div className="mr-2">{icon}</div> + <div className="grow truncate text-[13px] text-text-secondary" title={title}> + {before} + <span className="text-text-accent">{middle}</span> + {after} + </div> + {extraElement} </div> - <div className="grow truncate text-[13px] text-text-secondary" title={title}> - {before} - <span className="text-text-accent">{middle}</span> - {after} - </div> - {extraElement} - </div> - ) -}) + ) + }, +) VariableMenuItem.displayName = 'VariableMenuItem' diff --git a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx index f7bb4a17fc405e..dc58a97f9cd903 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx @@ -25,7 +25,7 @@ vi.mock('@/context/event-emitter', () => ({ })) // Helpers -const defaultSetup = (overrides?: { isSelected?: boolean, open?: boolean }) => { +const defaultSetup = (overrides?: { isSelected?: boolean; open?: boolean }) => { const triggerSetOpen = vi.fn() mockUseSelectOrDelete.mockReturnValue([{ current: null }, overrides?.isSelected ?? false]) mockUseTrigger.mockReturnValue([{ current: null }, overrides?.open ?? false, triggerSetOpen]) @@ -53,37 +53,27 @@ describe('ContextBlockComponent', () => { it('should display the context title', () => { defaultSetup() - render( - <ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />, - ) + render(<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />) expect(screen.getByText('common.promptEditor.context.item.title')).toBeInTheDocument() }) it('should display the dataset count', () => { defaultSetup() render( - <ContextBlockComponent - nodeKey="test-key" - datasets={mockDatasets} - onAddContext={vi.fn()} - />, + <ContextBlockComponent nodeKey="test-key" datasets={mockDatasets} onAddContext={vi.fn()} />, ) expect(screen.getByText('2')).toBeInTheDocument() }) it('should display zero count when no datasets provided', () => { defaultSetup() - render( - <ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />, - ) + render(<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />) expect(screen.getByText('0')).toBeInTheDocument() }) it('should render the file icon', () => { defaultSetup() - render( - <ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />, - ) + render(<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />) // File05 icon renders as an SVG const fileIcon = screen.getByTestId('file-icon') expect(fileIcon).toBeInTheDocument() @@ -142,11 +132,7 @@ describe('ContextBlockComponent', () => { it('should show dataset list when dropdown is open', () => { defaultSetup({ open: true }) render( - <ContextBlockComponent - nodeKey="test-key" - datasets={mockDatasets} - onAddContext={vi.fn()} - />, + <ContextBlockComponent nodeKey="test-key" datasets={mockDatasets} onAddContext={vi.fn()} />, ) expect(screen.getByText('Dataset A')).toBeInTheDocument() expect(screen.getByText('Dataset B')).toBeInTheDocument() @@ -155,53 +141,31 @@ describe('ContextBlockComponent', () => { it('should show modal title with dataset count when open', () => { defaultSetup({ open: true }) render( - <ContextBlockComponent - nodeKey="test-key" - datasets={mockDatasets} - onAddContext={vi.fn()} - />, + <ContextBlockComponent nodeKey="test-key" datasets={mockDatasets} onAddContext={vi.fn()} />, ) - expect( - screen.getByText(/common\.promptEditor\.context\.modal\.title/), - ).toBeInTheDocument() + expect(screen.getByText(/common\.promptEditor\.context\.modal\.title/)).toBeInTheDocument() }) it('should show the add context button when open', () => { defaultSetup({ open: true }) render( - <ContextBlockComponent - nodeKey="test-key" - datasets={mockDatasets} - onAddContext={vi.fn()} - />, + <ContextBlockComponent nodeKey="test-key" datasets={mockDatasets} onAddContext={vi.fn()} />, ) - expect( - screen.getByText('common.promptEditor.context.modal.add'), - ).toBeInTheDocument() + expect(screen.getByText('common.promptEditor.context.modal.add')).toBeInTheDocument() }) it('should show the footer text when open', () => { defaultSetup({ open: true }) render( - <ContextBlockComponent - nodeKey="test-key" - datasets={mockDatasets} - onAddContext={vi.fn()} - />, + <ContextBlockComponent nodeKey="test-key" datasets={mockDatasets} onAddContext={vi.fn()} />, ) - expect( - screen.getByText('common.promptEditor.context.modal.footer'), - ).toBeInTheDocument() + expect(screen.getByText('common.promptEditor.context.modal.footer')).toBeInTheDocument() }) it('should render folder icon for each dataset', () => { defaultSetup({ open: true }) render( - <ContextBlockComponent - nodeKey="test-key" - datasets={mockDatasets} - onAddContext={vi.fn()} - />, + <ContextBlockComponent nodeKey="test-key" datasets={mockDatasets} onAddContext={vi.fn()} />, ) const folders = screen.getAllByTestId('folder-icon') expect(folders.length).toBeGreaterThanOrEqual(2) @@ -219,9 +183,7 @@ describe('ContextBlockComponent', () => { ) // Modal content should not be present expect(screen.queryByText('Dataset A')).not.toBeInTheDocument() - expect( - screen.queryByText('common.promptEditor.context.modal.add'), - ).not.toBeInTheDocument() + expect(screen.queryByText('common.promptEditor.context.modal.add')).not.toBeInTheDocument() }) }) @@ -229,16 +191,12 @@ describe('ContextBlockComponent', () => { it('should keep the popover closed when the trigger prevents the default click', async () => { const user = userEvent.setup() const { triggerSetOpen } = defaultSetup() - render( - <ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />, - ) + render(<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />) await user.click(screen.getByTestId('popover-trigger')) expect(triggerSetOpen).not.toHaveBeenCalled() - expect( - screen.queryByText('common.promptEditor.context.modal.add'), - ).not.toBeInTheDocument() + expect(screen.queryByText('common.promptEditor.context.modal.add')).not.toBeInTheDocument() }) it('should call onAddContext when add button is clicked', async () => { @@ -252,7 +210,9 @@ describe('ContextBlockComponent', () => { />, ) - const addButton = screen.getByRole('button', { name: 'common.promptEditor.context.modal.add' }) + const addButton = screen.getByRole('button', { + name: 'common.promptEditor.context.modal.add', + }) await userEvent.click(addButton) expect(handleAddContext).toHaveBeenCalledTimes(1) }) @@ -260,11 +220,7 @@ describe('ContextBlockComponent', () => { it('should render the count badge with open styles when dropdown is open', () => { defaultSetup({ open: true }) render( - <ContextBlockComponent - nodeKey="test-key" - datasets={mockDatasets} - onAddContext={vi.fn()} - />, + <ContextBlockComponent nodeKey="test-key" datasets={mockDatasets} onAddContext={vi.fn()} />, ) const countBadge = screen.getByText('2') expect(countBadge).toHaveClass('bg-[#6938EF]') @@ -274,11 +230,7 @@ describe('ContextBlockComponent', () => { it('should render the count badge with closed styles when dropdown is closed', () => { defaultSetup({ open: false }) render( - <ContextBlockComponent - nodeKey="test-key" - datasets={mockDatasets} - onAddContext={vi.fn()} - />, + <ContextBlockComponent nodeKey="test-key" datasets={mockDatasets} onAddContext={vi.fn()} />, ) const countBadge = screen.getByText('2') expect(countBadge).toHaveClass('bg-white/50') @@ -288,26 +240,20 @@ describe('ContextBlockComponent', () => { describe('Event Emitter Subscription', () => { it('should subscribe to event emitter on mount', () => { defaultSetup() - render( - <ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />, - ) + render(<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />) expect(mockUseSubscription).toHaveBeenCalled() }) it('should update local datasets when UPDATE_DATASETS_EVENT_EMITTER event fires', () => { defaultSetup({ open: true }) // Capture the subscription callback - let subscriptionCallback: (v: Record<string, unknown>) => void = () => { } + let subscriptionCallback: (v: Record<string, unknown>) => void = () => {} mockUseSubscription.mockImplementation((cb: (v: Record<string, unknown>) => void) => { subscriptionCallback = cb }) const { rerender } = render( - <ContextBlockComponent - nodeKey="test-key" - datasets={[]} - onAddContext={vi.fn()} - />, + <ContextBlockComponent nodeKey="test-key" datasets={[]} onAddContext={vi.fn()} />, ) // Initially no datasets @@ -317,20 +263,12 @@ describe('ContextBlockComponent', () => { act(() => { subscriptionCallback({ type: UPDATE_DATASETS_EVENT_EMITTER, - payload: [ - { id: '3', name: 'New Dataset', type: 'text' }, - ], + payload: [{ id: '3', name: 'New Dataset', type: 'text' }], }) }) // Re-render to see state updates - rerender( - <ContextBlockComponent - nodeKey="test-key" - datasets={[]} - onAddContext={vi.fn()} - />, - ) + rerender(<ContextBlockComponent nodeKey="test-key" datasets={[]} onAddContext={vi.fn()} />) expect(screen.getByText('1')).toBeInTheDocument() expect(screen.getByText('New Dataset')).toBeInTheDocument() @@ -338,17 +276,13 @@ describe('ContextBlockComponent', () => { it('should not update datasets when event type does not match', () => { defaultSetup({ open: true }) - let subscriptionCallback: (v: Record<string, unknown>) => void = () => { } + let subscriptionCallback: (v: Record<string, unknown>) => void = () => {} mockUseSubscription.mockImplementation((cb: (v: Record<string, unknown>) => void) => { subscriptionCallback = cb }) render( - <ContextBlockComponent - nodeKey="test-key" - datasets={mockDatasets} - onAddContext={vi.fn()} - />, + <ContextBlockComponent nodeKey="test-key" datasets={mockDatasets} onAddContext={vi.fn()} />, ) // Fire a different event @@ -366,17 +300,15 @@ describe('ContextBlockComponent', () => { it('should ignore string events from the event emitter', () => { defaultSetup({ open: true }) - let subscriptionCallback: (v: Record<string, unknown> | string) => void = () => { } - mockUseSubscription.mockImplementation((cb: (v: Record<string, unknown> | string) => void) => { - subscriptionCallback = cb - }) + let subscriptionCallback: (v: Record<string, unknown> | string) => void = () => {} + mockUseSubscription.mockImplementation( + (cb: (v: Record<string, unknown> | string) => void) => { + subscriptionCallback = cb + }, + ) render( - <ContextBlockComponent - nodeKey="test-key" - datasets={mockDatasets} - onAddContext={vi.fn()} - />, + <ContextBlockComponent nodeKey="test-key" datasets={mockDatasets} onAddContext={vi.fn()} />, ) act(() => { @@ -391,21 +323,13 @@ describe('ContextBlockComponent', () => { describe('Edge Cases', () => { it('should handle empty datasets array', () => { defaultSetup({ open: true }) - render( - <ContextBlockComponent - nodeKey="test-key" - datasets={[]} - onAddContext={vi.fn()} - />, - ) + render(<ContextBlockComponent nodeKey="test-key" datasets={[]} onAddContext={vi.fn()} />) expect(screen.getByText('0')).toBeInTheDocument() }) it('should default datasets to empty array when undefined', () => { defaultSetup() - render( - <ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />, - ) + render(<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />) expect(screen.getByText('0')).toBeInTheDocument() }) diff --git a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/context-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/context-block-replacement-block.spec.tsx index 4f629416011e33..a393fa7e4967b4 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/context-block-replacement-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/context-block-replacement-block.spec.tsx @@ -18,16 +18,14 @@ function createEditorConfig() { return { namespace: 'test', nodes: [CustomTextNode, ContextBlockNode], - onError: (error: Error) => { throw error }, + onError: (error: Error) => { + throw error + }, } } function TestWrapper({ children }: { children: ReactNode }) { - return ( - <LexicalComposer initialConfig={createEditorConfig()}> - {children} - </LexicalComposer> - ) + return <LexicalComposer initialConfig={createEditorConfig()}>{children}</LexicalComposer> } function renderWithEditor(ui: ReactNode) { @@ -41,7 +39,11 @@ const defaultOnCapture = (editor: LexicalEditor) => { capturedEditor = editor } -function EditorCapture({ onCapture = defaultOnCapture }: { onCapture?: (e: LexicalEditor) => void }) { +function EditorCapture({ + onCapture = defaultOnCapture, +}: { + onCapture?: (e: LexicalEditor) => void +}) { const [editor] = useLexicalComposerContext() React.useEffect(() => { onCapture(editor) @@ -51,23 +53,25 @@ function EditorCapture({ onCapture = defaultOnCapture }: { onCapture?: (e: Lexic type ReadResult = { count: number - datasets: Array<{ id: string, name: string, type: string }> + datasets: Array<{ id: string; name: string; type: string }> canNotAddContext: boolean } function insertTextAndRead(text: string): ReadResult { - if (!capturedEditor) - throw new Error('Editor not captured') + if (!capturedEditor) throw new Error('Editor not captured') // Insert CustomTextNode with the given text - capturedEditor.update(() => { - const root = $getRoot() - root.clear() - const paragraph = $createParagraphNode() - const textNode = $createCustomTextNode(text) - paragraph.append(textNode) - root.append(paragraph) - }, { discrete: true }) + capturedEditor.update( + () => { + const root = $getRoot() + root.clear() + const paragraph = $createParagraphNode() + const textNode = $createCustomTextNode(text) + paragraph.append(textNode) + root.append(paragraph) + }, + { discrete: true }, + ) // Read the resulting state — extract all properties inside .read() const result: ReadResult = { count: 0, datasets: [], canNotAddContext: false } @@ -126,7 +130,9 @@ describe('ContextBlockReplacementBlock', () => { const configWithoutNode = { namespace: 'test', nodes: [CustomTextNode], - onError: (error: Error) => { throw error }, + onError: (error: Error) => { + throw error + }, } expect(() => { diff --git a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/index.spec.tsx index 807b1f7aa4e7f6..b8306aeed9ab99 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/index.spec.tsx @@ -16,7 +16,11 @@ vi.mock('../node', async () => { return { ...actual, - $createContextBlockNode: (datasets: Dataset[], onAddContext: () => void, canNotAddContext?: boolean) => { + $createContextBlockNode: ( + datasets: Dataset[], + onAddContext: () => void, + canNotAddContext?: boolean, + ) => { mockCreateContextBlockNode(datasets, onAddContext, canNotAddContext) return actual.$createContextBlockNode(datasets, onAddContext, canNotAddContext) }, @@ -37,7 +41,9 @@ function createEditorConfig(includeContextBlockNode = true): EditorConfig { return { namespace: 'test', nodes: includeContextBlockNode ? [ContextBlockNode] : [], - onError: (error: Error) => { throw error }, + onError: (error: Error) => { + throw error + }, } } @@ -61,29 +67,29 @@ function renderWithEditor(ui: ReactNode, includeContextBlockNode = true) { } function setupParagraphSelection() { - if (!capturedEditor) - throw new Error('Editor not captured') - - capturedEditor.update(() => { - const root = $getRoot() - root.clear() - const paragraph = $createParagraphNode() - root.append(paragraph) - paragraph.select() - }, { discrete: true }) + if (!capturedEditor) throw new Error('Editor not captured') + + capturedEditor.update( + () => { + const root = $getRoot() + root.clear() + const paragraph = $createParagraphNode() + root.append(paragraph) + paragraph.select() + }, + { discrete: true }, + ) } function dispatchInsert() { - if (!capturedEditor) - throw new Error('Editor not captured') + if (!capturedEditor) throw new Error('Editor not captured') setupParagraphSelection() return capturedEditor.dispatchCommand(INSERT_CONTEXT_BLOCK_COMMAND, undefined) } function dispatchDelete() { - if (!capturedEditor) - throw new Error('Editor not captured') + if (!capturedEditor) throw new Error('Editor not captured') return capturedEditor.dispatchCommand(DELETE_CONTEXT_BLOCK_COMMAND, undefined) } @@ -139,7 +145,11 @@ describe('ContextBlock', () => { renderWithEditor(<ContextBlock datasets={datasets} />) dispatchInsert() - expect(mockCreateContextBlockNode).toHaveBeenCalledWith(datasets, expect.any(Function), undefined) + expect(mockCreateContextBlockNode).toHaveBeenCalledWith( + datasets, + expect.any(Function), + undefined, + ) }) it('should pass canNotAddContext to the created node', () => { diff --git a/web/app/components/base/prompt-editor/plugins/context-block/component.tsx b/web/app/components/base/prompt-editor/plugins/context-block/component.tsx index 47f8b2b11bfd47..426909a303cd69 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/component.tsx @@ -1,7 +1,6 @@ import type { FC } from 'react' import type { Dataset } from './index' import type { EventEmitterValue } from '@/context/event-emitter' - import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -30,8 +29,7 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({ const [localDatasets, setLocalDatasets] = useState<Dataset[]>(datasets) eventEmitter?.useSubscription((event?: EventEmitterValue) => { - if (typeof event === 'string') - return + if (typeof event === 'string') return if (event?.type === UPDATE_DATASETS_EVENT_EMITTER && Array.isArray(event.payload)) setLocalDatasets(event.payload as Dataset[]) @@ -39,35 +37,30 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({ return ( <div - className={` - group inline-flex h-6 items-center rounded-[5px] border border-transparent bg-[#F4F3FF] pr-0.5 pl-1 text-[#6938EF] hover:bg-[#EBE9FE] - ${open ? 'bg-[#EBE9FE]' : ''} - ${isSelected && 'border-[#9B8AFB]!'} - `} + className={`group inline-flex h-6 items-center rounded-[5px] border border-transparent bg-[#F4F3FF] pr-0.5 pl-1 text-[#6938EF] hover:bg-[#EBE9FE] ${open ? 'bg-[#EBE9FE]' : ''} ${isSelected && 'border-[#9B8AFB]!'} `} ref={ref} > - <span className="mr-1 i-custom-vender-solid-files-file-05 h-[14px] w-[14px]" data-testid="file-icon" /> - <div className="mr-1 text-xs font-medium">{t($ => $['promptEditor.context.item.title'], { ns: 'common' })}</div> + <span + className="mr-1 i-custom-vender-solid-files-file-05 h-[14px] w-[14px]" + data-testid="file-icon" + /> + <div className="mr-1 text-xs font-medium"> + {t(($) => $['promptEditor.context.item.title'], { ns: 'common' })} + </div> {!canNotAddContext && ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['promptEditor.context.item.title'], { ns: 'common' })} - className={` - flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded border-none p-0 text-[11px] font-semibold - ${open ? 'bg-[#6938EF] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'} - `} + aria-label={t(($) => $['promptEditor.context.item.title'], { ns: 'common' })} + className={`flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded border-none p-0 text-[11px] font-semibold ${open ? 'bg-[#6938EF] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'} `} ref={triggerRef} - onClick={e => e.preventDefault()} + onClick={(e) => e.preventDefault()} > {localDatasets.length} </button> - )} + } /> <PopoverContent placement="bottom-end" @@ -78,19 +71,25 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({ <div className="w-[360px] rounded-xl bg-white shadow-lg"> <div className="p-4"> <div className="mb-2 text-xs font-medium text-gray-500"> - {t($ => $['promptEditor.context.modal.title'], { ns: 'common', num: localDatasets.length })} + {t(($) => $['promptEditor.context.modal.title'], { + ns: 'common', + num: localDatasets.length, + })} </div> <div className="max-h-[270px] overflow-y-auto"> - { - localDatasets.map(dataset => ( - <div key={dataset.id} className="flex h-8 items-center"> - <div className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#EAECF5] bg-[#F5F8FF]"> - <span className="i-custom-vender-solid-files-folder h-4 w-4 text-[#444CE7]" data-testid="folder-icon" /> - </div> - <div className="truncate text-sm text-gray-800" title="">{dataset.name}</div> + {localDatasets.map((dataset) => ( + <div key={dataset.id} className="flex h-8 items-center"> + <div className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#EAECF5] bg-[#F5F8FF]"> + <span + className="i-custom-vender-solid-files-folder h-4 w-4 text-[#444CE7]" + data-testid="folder-icon" + /> + </div> + <div className="truncate text-sm text-gray-800" title=""> + {dataset.name} </div> - )) - } + </div> + ))} </div> <button type="button" @@ -100,17 +99,18 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({ <div className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-gray-100"> <span className="i-ri-add-line h-[14px] w-[14px]" aria-hidden="true" /> </div> - <div className="text-[13px] font-medium" title="">{t($ => $['promptEditor.context.modal.add'], { ns: 'common' })}</div> + <div className="text-[13px] font-medium" title=""> + {t(($) => $['promptEditor.context.modal.add'], { ns: 'common' })} + </div> </button> </div> <div className="rounded-b-xl border-t-[0.5px] border-gray-50 bg-gray-50 px-4 py-3 text-xs text-gray-500"> - {t($ => $['promptEditor.context.modal.footer'], { ns: 'common' })} + {t(($) => $['promptEditor.context.modal.footer'], { ns: 'common' })} </div> </div> </PopoverContent> </Popover> )} - </div> ) } diff --git a/web/app/components/base/prompt-editor/plugins/context-block/context-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/context-block/context-block-replacement-block.tsx index 36e0d7f17b3265..611f60a83004c8 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/context-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/context-block-replacement-block.tsx @@ -3,17 +3,10 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { mergeRegister } from '@lexical/utils' import { noop } from 'es-toolkit/function' import { $applyNodeReplacement } from 'lexical' -import { - memo, - useCallback, - useEffect, -} from 'react' +import { memo, useCallback, useEffect } from 'react' import { CONTEXT_PLACEHOLDER_TEXT } from '../../constants' import { decoratorTransform } from '../../utils' -import { - $createContextBlockNode, - ContextBlockNode, -} from '../context-block/node' +import { $createContextBlockNode, ContextBlockNode } from '../context-block/node' import { CustomTextNode } from '../custom-text/node' const REGEX = new RegExp(CONTEXT_PLACEHOLDER_TEXT) @@ -32,16 +25,14 @@ const ContextBlockReplacementBlock = ({ }, [editor]) const createContextBlockNode = useCallback((): ContextBlockNode => { - if (onInsert) - onInsert() + if (onInsert) onInsert() return $applyNodeReplacement($createContextBlockNode(datasets, onAddContext, canNotAddContext)) }, [datasets, onAddContext, onInsert, canNotAddContext]) const getMatch = useCallback((text: string) => { const matchArr = REGEX.exec(text) - if (matchArr === null) - return null + if (matchArr === null) return null const startOffset = matchArr.index const endOffset = startOffset + CONTEXT_PLACEHOLDER_TEXT.length @@ -54,7 +45,9 @@ const ContextBlockReplacementBlock = ({ useEffect(() => { REGEX.lastIndex = 0 return mergeRegister( - editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createContextBlockNode)), + editor.registerNodeTransform(CustomTextNode, (textNode) => + decoratorTransform(textNode, getMatch, createContextBlockNode), + ), ) }, []) diff --git a/web/app/components/base/prompt-editor/plugins/context-block/index.tsx b/web/app/components/base/prompt-editor/plugins/context-block/index.tsx index e3382c011ded2b..972fc5314a69b5 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/index.tsx @@ -2,19 +2,9 @@ import type { ContextBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' import { noop } from 'es-toolkit/function' -import { - $insertNodes, - COMMAND_PRIORITY_EDITOR, - createCommand, -} from 'lexical' -import { - memo, - useEffect, -} from 'react' -import { - $createContextBlockNode, - ContextBlockNode, -} from './node' +import { $insertNodes, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' +import { memo, useEffect } from 'react' +import { $createContextBlockNode, ContextBlockNode } from './node' export const INSERT_CONTEXT_BLOCK_COMMAND = createCommand('INSERT_CONTEXT_BLOCK_COMMAND') export const DELETE_CONTEXT_BLOCK_COMMAND = createCommand('DELETE_CONTEXT_BLOCK_COMMAND') @@ -25,49 +15,53 @@ export type Dataset = { type: string } -const ContextBlock = memo(({ - datasets = [], - onAddContext = noop, - onInsert, - onDelete, - canNotAddContext, -}: ContextBlockType) => { - const [editor] = useLexicalComposerContext() +const ContextBlock = memo( + ({ + datasets = [], + onAddContext = noop, + onInsert, + onDelete, + canNotAddContext, + }: ContextBlockType) => { + const [editor] = useLexicalComposerContext() - useEffect(() => { - if (!editor.hasNodes([ContextBlockNode])) - throw new Error('ContextBlockPlugin: ContextBlock not registered on editor') + useEffect(() => { + if (!editor.hasNodes([ContextBlockNode])) + throw new Error('ContextBlockPlugin: ContextBlock not registered on editor') - return mergeRegister( - editor.registerCommand( - INSERT_CONTEXT_BLOCK_COMMAND, - () => { - const contextBlockNode = $createContextBlockNode(datasets, onAddContext, canNotAddContext) + return mergeRegister( + editor.registerCommand( + INSERT_CONTEXT_BLOCK_COMMAND, + () => { + const contextBlockNode = $createContextBlockNode( + datasets, + onAddContext, + canNotAddContext, + ) - $insertNodes([contextBlockNode]) + $insertNodes([contextBlockNode]) - if (onInsert) - onInsert() + if (onInsert) onInsert() - return true - }, - COMMAND_PRIORITY_EDITOR, - ), - editor.registerCommand( - DELETE_CONTEXT_BLOCK_COMMAND, - () => { - if (onDelete) - onDelete() + return true + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DELETE_CONTEXT_BLOCK_COMMAND, + () => { + if (onDelete) onDelete() - return true - }, - COMMAND_PRIORITY_EDITOR, - ), - ) - }, [editor, datasets, onAddContext, onInsert, onDelete, canNotAddContext]) + return true + }, + COMMAND_PRIORITY_EDITOR, + ), + ) + }, [editor, datasets, onAddContext, onInsert, onDelete, canNotAddContext]) - return null -}) + return null + }, +) ContextBlock.displayName = 'ContextBlock' export { ContextBlock } diff --git a/web/app/components/base/prompt-editor/plugins/context-block/node.tsx b/web/app/components/base/prompt-editor/plugins/context-block/node.tsx index 6e67ee0e7dd23f..04b0bda4cc068d 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/node.tsx @@ -3,7 +3,11 @@ import type { Dataset } from './index' import { DecoratorNode } from 'lexical' import ContextBlockComponent from './component' -type SerializedNode = SerializedLexicalNode & { datasets: Dataset[], onAddContext: () => void, canNotAddContext: boolean } +type SerializedNode = SerializedLexicalNode & { + datasets: Dataset[] + onAddContext: () => void + canNotAddContext: boolean +} export class ContextBlockNode extends DecoratorNode<React.JSX.Element> { __datasets: Dataset[] @@ -15,14 +19,24 @@ export class ContextBlockNode extends DecoratorNode<React.JSX.Element> { } static override clone(node: ContextBlockNode): ContextBlockNode { - return new ContextBlockNode(node.__datasets, node.__onAddContext, node.getKey(), node.__canNotAddContext) + return new ContextBlockNode( + node.__datasets, + node.__onAddContext, + node.getKey(), + node.__canNotAddContext, + ) } override isInline(): boolean { return true } - constructor(datasets: Dataset[], onAddContext: () => void, key?: NodeKey, canNotAddContext?: boolean) { + constructor( + datasets: Dataset[], + onAddContext: () => void, + key?: NodeKey, + canNotAddContext?: boolean, + ) { super(key) this.__datasets = datasets @@ -70,7 +84,11 @@ export class ContextBlockNode extends DecoratorNode<React.JSX.Element> { } static override importJSON(serializedNode: SerializedNode): ContextBlockNode { - const node = $createContextBlockNode(serializedNode.datasets, serializedNode.onAddContext, serializedNode.canNotAddContext) + const node = $createContextBlockNode( + serializedNode.datasets, + serializedNode.onAddContext, + serializedNode.canNotAddContext, + ) return node } @@ -89,7 +107,11 @@ export class ContextBlockNode extends DecoratorNode<React.JSX.Element> { return '{{#context#}}' } } -export function $createContextBlockNode(datasets: Dataset[], onAddContext: () => void, canNotAddContext?: boolean): ContextBlockNode { +export function $createContextBlockNode( + datasets: Dataset[], + onAddContext: () => void, + canNotAddContext?: boolean, +): ContextBlockNode { return new ContextBlockNode(datasets, onAddContext, undefined, canNotAddContext) } diff --git a/web/app/components/base/prompt-editor/plugins/current-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/current-block/__tests__/component.spec.tsx index 45099bba8a8d85..e8f73c40918e26 100644 --- a/web/app/components/base/prompt-editor/plugins/current-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/current-block/__tests__/component.spec.tsx @@ -84,7 +84,10 @@ describe('CurrentBlockComponent', () => { it('should wire useSelectOrDelete with node key and delete command', () => { renderComponent({ generatorType: GeneratorType.prompt }) - expect(mockUseSelectOrDelete).toHaveBeenCalledWith('current-node', DELETE_CURRENT_BLOCK_COMMAND) + expect(mockUseSelectOrDelete).toHaveBeenCalledWith( + 'current-node', + DELETE_CURRENT_BLOCK_COMMAND, + ) }) }) diff --git a/web/app/components/base/prompt-editor/plugins/current-block/__tests__/current-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/current-block/__tests__/current-block-replacement-block.spec.tsx index c7bd628e489a50..9591aef6b666d0 100644 --- a/web/app/components/base/prompt-editor/plugins/current-block/__tests__/current-block-replacement-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/current-block/__tests__/current-block-replacement-block.spec.tsx @@ -19,23 +19,18 @@ const renderReplacementPlugin = (props?: { generatorType?: GeneratorType onInsert?: () => void }) => { - const { - generatorType = GeneratorType.prompt, - onInsert, - } = props ?? {} + const { generatorType = GeneratorType.prompt, onInsert } = props ?? {} return renderLexicalEditor({ namespace: 'current-block-replacement-plugin-test', nodes: [CustomTextNode, CurrentBlockNode], - children: ( - <CurrentBlockReplacementBlock generatorType={generatorType} onInsert={onInsert} /> - ), + children: <CurrentBlockReplacementBlock generatorType={generatorType} onInsert={onInsert} />, }) } const getCurrentNodeGeneratorTypes = (editor: LexicalEditor) => { return readEditorStateValue(editor, () => { - return $nodesOfType(CurrentBlockNode).map(node => node.getGeneratorType()) + return $nodesOfType(CurrentBlockNode).map((node) => node.getGeneratorType()) }) } @@ -54,7 +49,11 @@ describe('CurrentBlockReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, `prefix ${CURRENT_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text)) + setEditorRootText( + editor, + `prefix ${CURRENT_PLACEHOLDER_TEXT} suffix`, + (text) => new CustomTextNode(text), + ) await waitFor(() => { expect(getNodeCount(editor, CurrentBlockNode)).toBe(1) @@ -72,7 +71,11 @@ describe('CurrentBlockReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, 'plain text without current placeholder', text => new CustomTextNode(text)) + setEditorRootText( + editor, + 'plain text without current placeholder', + (text) => new CustomTextNode(text), + ) await waitFor(() => { expect(getNodeCount(editor, CurrentBlockNode)).toBe(0) @@ -87,7 +90,7 @@ describe('CurrentBlockReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, CURRENT_PLACEHOLDER_TEXT, text => new CustomTextNode(text)) + setEditorRootText(editor, CURRENT_PLACEHOLDER_TEXT, (text) => new CustomTextNode(text)) await waitFor(() => { expect(getNodeCount(editor, CurrentBlockNode)).toBe(1) diff --git a/web/app/components/base/prompt-editor/plugins/current-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/current-block/__tests__/index.spec.tsx index 88f0e6d7791929..a19e261443039d 100644 --- a/web/app/components/base/prompt-editor/plugins/current-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/current-block/__tests__/index.spec.tsx @@ -25,11 +25,7 @@ const renderCurrentBlock = (props?: { onInsert?: () => void onDelete?: () => void }) => { - const { - generatorType = GeneratorType.prompt, - onInsert, - onDelete, - } = props ?? {} + const { generatorType = GeneratorType.prompt, onInsert, onDelete } = props ?? {} return renderLexicalEditor({ namespace: 'current-block-plugin-test', @@ -42,7 +38,7 @@ const renderCurrentBlock = (props?: { const getCurrentNodeGeneratorTypes = (editor: LexicalEditor) => { return readEditorStateValue(editor, () => { - return $nodesOfType(CurrentBlockNode).map(node => node.getGeneratorType()) + return $nodesOfType(CurrentBlockNode).map((node) => node.getGeneratorType()) }) } diff --git a/web/app/components/base/prompt-editor/plugins/current-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/current-block/__tests__/node.spec.tsx index 85bed21293afcf..dbff9b3f9788eb 100644 --- a/web/app/components/base/prompt-editor/plugins/current-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/current-block/__tests__/node.spec.tsx @@ -1,18 +1,9 @@ import { act } from '@testing-library/react' -import { - $createParagraphNode, - $getRoot, -} from 'lexical' +import { $createParagraphNode, $getRoot } from 'lexical' import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types' -import { - createLexicalTestEditor, - expectInlineWrapperDom, -} from '../../test-helpers' +import { createLexicalTestEditor, expectInlineWrapperDom } from '../../test-helpers' import CurrentBlockComponent from '../component' -import { - $createCurrentBlockNode, - CurrentBlockNode, -} from '../node' +import { $createCurrentBlockNode, CurrentBlockNode } from '../node' const createTestEditor = () => { return createLexicalTestEditor('current-block-node-test', [CurrentBlockNode]) diff --git a/web/app/components/base/prompt-editor/plugins/current-block/component.tsx b/web/app/components/base/prompt-editor/plugins/current-block/component.tsx index 89df0714103aef..68cf7d17e12362 100644 --- a/web/app/components/base/prompt-editor/plugins/current-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/current-block/component.tsx @@ -12,10 +12,7 @@ type CurrentBlockComponentProps = { generatorType: GeneratorType } -const CurrentBlockComponent: FC<CurrentBlockComponentProps> = ({ - nodeKey, - generatorType, -}) => { +const CurrentBlockComponent: FC<CurrentBlockComponentProps> = ({ nodeKey, generatorType }) => { const [editor] = useLexicalComposerContext() const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_CURRENT_BLOCK_COMMAND) @@ -29,7 +26,9 @@ const CurrentBlockComponent: FC<CurrentBlockComponentProps> = ({ <div className={cn( 'group/wrap relative mx-0.5 flex h-[18px] items-center rounded-[5px] border pr-[3px] pl-0.5 text-util-colors-violet-violet-600 select-none hover:border-state-accent-solid hover:bg-state-accent-hover', - isSelected ? 'border-state-accent-solid bg-state-accent-hover' : 'border-components-panel-border-subtle bg-components-badge-white-to-dark', + isSelected + ? 'border-state-accent-solid bg-state-accent-hover' + : 'border-components-panel-border-subtle bg-components-badge-white-to-dark', )} onClick={(e) => { e.stopPropagation() @@ -37,7 +36,9 @@ const CurrentBlockComponent: FC<CurrentBlockComponentProps> = ({ ref={ref} > <Icon className="mr-0.5 h-[14px] w-[14px]" /> - <div className="text-xs font-medium">{generatorType === GeneratorType.prompt ? 'current_prompt' : 'current_code'}</div> + <div className="text-xs font-medium"> + {generatorType === GeneratorType.prompt ? 'current_prompt' : 'current_code'} + </div> </div> ) } diff --git a/web/app/components/base/prompt-editor/plugins/current-block/current-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/current-block/current-block-replacement-block.tsx index 8c62e2171fe782..c01080677caea5 100644 --- a/web/app/components/base/prompt-editor/plugins/current-block/current-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/current-block/current-block-replacement-block.tsx @@ -2,25 +2,15 @@ import type { CurrentBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' import { $applyNodeReplacement } from 'lexical' -import { - memo, - useCallback, - useEffect, -} from 'react' +import { memo, useCallback, useEffect } from 'react' import { CURRENT_PLACEHOLDER_TEXT } from '../../constants' import { decoratorTransform } from '../../utils' import { CustomTextNode } from '../custom-text/node' -import { - $createCurrentBlockNode, - CurrentBlockNode, -} from './node' +import { $createCurrentBlockNode, CurrentBlockNode } from './node' const REGEX = new RegExp(CURRENT_PLACEHOLDER_TEXT) -const CurrentBlockReplacementBlock = ({ - generatorType, - onInsert, -}: CurrentBlockType) => { +const CurrentBlockReplacementBlock = ({ generatorType, onInsert }: CurrentBlockType) => { const [editor] = useLexicalComposerContext() useEffect(() => { @@ -29,16 +19,14 @@ const CurrentBlockReplacementBlock = ({ }, [editor]) const createCurrentBlockNode = useCallback((): CurrentBlockNode => { - if (onInsert) - onInsert() + if (onInsert) onInsert() return $applyNodeReplacement($createCurrentBlockNode(generatorType)) }, [onInsert, generatorType]) const getMatch = useCallback((text: string) => { const matchArr = REGEX.exec(text) - if (matchArr === null) - return null + if (matchArr === null) return null const startOffset = matchArr.index const endOffset = startOffset + CURRENT_PLACEHOLDER_TEXT.length @@ -51,7 +39,9 @@ const CurrentBlockReplacementBlock = ({ useEffect(() => { REGEX.lastIndex = 0 return mergeRegister( - editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createCurrentBlockNode)), + editor.registerNodeTransform(CustomTextNode, (textNode) => + decoratorTransform(textNode, getMatch, createCurrentBlockNode), + ), ) }, []) diff --git a/web/app/components/base/prompt-editor/plugins/current-block/index.tsx b/web/app/components/base/prompt-editor/plugins/current-block/index.tsx index 3fead199aa2119..dfd43ca240c6e0 100644 --- a/web/app/components/base/prompt-editor/plugins/current-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/current-block/index.tsx @@ -1,28 +1,14 @@ import type { CurrentBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { - $insertNodes, - COMMAND_PRIORITY_EDITOR, - createCommand, -} from 'lexical' -import { - memo, - useEffect, -} from 'react' -import { - $createCurrentBlockNode, - CurrentBlockNode, -} from './node' +import { $insertNodes, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' +import { memo, useEffect } from 'react' +import { $createCurrentBlockNode, CurrentBlockNode } from './node' export const INSERT_CURRENT_BLOCK_COMMAND = createCommand('INSERT_CURRENT_BLOCK_COMMAND') export const DELETE_CURRENT_BLOCK_COMMAND = createCommand('DELETE_CURRENT_BLOCK_COMMAND') -const CurrentBlock = memo(({ - generatorType, - onInsert, - onDelete, -}: CurrentBlockType) => { +const CurrentBlock = memo(({ generatorType, onInsert, onDelete }: CurrentBlockType) => { const [editor] = useLexicalComposerContext() useEffect(() => { @@ -37,8 +23,7 @@ const CurrentBlock = memo(({ $insertNodes([currentBlockNode]) - if (onInsert) - onInsert() + if (onInsert) onInsert() return true }, @@ -47,8 +32,7 @@ const CurrentBlock = memo(({ editor.registerCommand( DELETE_CURRENT_BLOCK_COMMAND, () => { - if (onDelete) - onDelete() + if (onDelete) onDelete() return true }, diff --git a/web/app/components/base/prompt-editor/plugins/current-block/node.tsx b/web/app/components/base/prompt-editor/plugins/current-block/node.tsx index 6c51da0b5b023c..7293bacce9f4c1 100644 --- a/web/app/components/base/prompt-editor/plugins/current-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/current-block/node.tsx @@ -36,12 +36,7 @@ export class CurrentBlockNode extends DecoratorNode<React.JSX.Element> { } override decorate(): React.JSX.Element { - return ( - <CurrentBlockComponent - nodeKey={this.getKey()} - generatorType={this.getGeneratorType()} - /> - ) + return <CurrentBlockComponent nodeKey={this.getKey()} generatorType={this.getGeneratorType()} /> } getGeneratorType(): GeneratorType { diff --git a/web/app/components/base/prompt-editor/plugins/custom-text/node.tsx b/web/app/components/base/prompt-editor/plugins/custom-text/node.tsx index e4f774f04ecbf4..d877e76862ed3b 100644 --- a/web/app/components/base/prompt-editor/plugins/custom-text/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/custom-text/node.tsx @@ -41,8 +41,7 @@ export class CustomTextNode extends TextNode { } override isSimpleText() { - return ( - (this.__type === 'text' || this.__type === 'custom-text') && this.__mode === 0) + return (this.__type === 'text' || this.__type === 'custom-text') && this.__mode === 0 } } diff --git a/web/app/components/base/prompt-editor/plugins/draggable-plugin/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/draggable-plugin/__tests__/index.spec.tsx index 61cb55c671a506..77f68b1422d826 100644 --- a/web/app/components/base/prompt-editor/plugins/draggable-plugin/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/draggable-plugin/__tests__/index.spec.tsx @@ -37,10 +37,12 @@ vi.mock('@lexical/react/LexicalDraggableBlockPlugin', () => ({ function createRootElementMock() { let mouseMoveHandler: MouseMoveHandler | null = null - const addEventListener = vi.fn((eventName: string, handler: EventListenerOrEventListenerObject) => { - if (eventName === 'mousemove' && typeof handler === 'function') - mouseMoveHandler = handler as MouseMoveHandler - }) + const addEventListener = vi.fn( + (eventName: string, handler: EventListenerOrEventListenerObject) => { + if (eventName === 'mousemove' && typeof handler === 'function') + mouseMoveHandler = handler as MouseMoveHandler + }, + ) const removeEventListener = vi.fn() return { @@ -58,8 +60,7 @@ function getRegisteredMouseMoveHandler( rootMock: ReturnType<typeof createRootElementMock>, ): MouseMoveHandler { const handler = rootMock.getMouseMoveHandler() - if (!handler) - throw new Error('Expected mousemove handler to be registered') + if (!handler) throw new Error('Expected mousemove handler to be registered') return handler } @@ -68,10 +69,9 @@ function setupEditorRoot(rootElement: HTMLElement | null) { getRootElement: vi.fn(() => rootElement), } as unknown as LexicalEditor - vi.mocked(useLexicalComposerContext).mockReturnValue([ - editor, - {}, - ] as unknown as ReturnType<typeof useLexicalComposerContext>) + vi.mocked(useLexicalComposerContext).mockReturnValue([editor, {}] as unknown as ReturnType< + typeof useLexicalComposerContext + >) return editor } @@ -140,7 +140,9 @@ describe('DraggableBlockPlugin', () => { const onMove = getRegisteredMouseMoveHandler(rootMock) const target = document.createElement('div') - target.appendChild(Object.assign(document.createElement('span'), { className: 'support-drag' })) + target.appendChild( + Object.assign(document.createElement('span'), { className: 'support-drag' }), + ) act(() => { onMove({ target } as unknown as MouseEvent) @@ -224,8 +226,7 @@ describe('DraggableBlockPlugin', () => { const renderedMenu = screen.getByTestId('draggable-menu') const isOnMenu = draggableMockState.latestProps?.isOnMenu - if (!isOnMenu) - throw new Error('Expected isOnMenu callback') + if (!isOnMenu) throw new Error('Expected isOnMenu callback') const menuIcon = screen.getByTestId('draggable-menu-icon') const outsideElement = document.createElement('div') diff --git a/web/app/components/base/prompt-editor/plugins/draggable-plugin/index.tsx b/web/app/components/base/prompt-editor/plugins/draggable-plugin/index.tsx index 8b7c690d3040d5..8732d854da32c2 100644 --- a/web/app/components/base/prompt-editor/plugins/draggable-plugin/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/draggable-plugin/index.tsx @@ -12,16 +12,13 @@ function isOnMenu(element: HTMLElement): boolean { const SUPPORT_DRAG_CLASS = 'support-drag' function checkSupportDrag(element: Element | null): boolean { - if (!element) - return false + if (!element) return false - if (element.classList.contains(SUPPORT_DRAG_CLASS)) - return true + if (element.classList.contains(SUPPORT_DRAG_CLASS)) return true - if (element.querySelector(`.${SUPPORT_DRAG_CLASS}`)) - return true + if (element.querySelector(`.${SUPPORT_DRAG_CLASS}`)) return true - return !!(element.closest(`.${SUPPORT_DRAG_CLASS}`)) + return !!element.closest(`.${SUPPORT_DRAG_CLASS}`) } export default function DraggableBlockPlugin({ @@ -31,17 +28,14 @@ export default function DraggableBlockPlugin({ }): JSX.Element { const menuRef = useRef<HTMLDivElement>(null) const targetLineRef = useRef<HTMLDivElement>(null) - const [, setDraggableElement] = useState<HTMLElement | null>( - null, - ) + const [, setDraggableElement] = useState<HTMLElement | null>(null) const [editor] = useLexicalComposerContext() const [isSupportDrag, setIsSupportDrag] = useState(false) useEffect(() => { const root = editor.getRootElement() - if (!root) - return + if (!root) return const onMove = (e: MouseEvent) => { const isSupportDrag = checkSupportDrag(e.target as Element) @@ -58,27 +52,32 @@ export default function DraggableBlockPlugin({ menuRef={menuRef as any} targetLineRef={targetLineRef as any} menuComponent={ - isSupportDrag - ? ( - <div ref={menuRef} className={cn(DRAGGABLE_BLOCK_MENU_CLASSNAME, 'absolute top-4 right-2.5 cursor-grab opacity-0 will-change-transform active:cursor-move')} data-testid="draggable-menu"> - <span className="i-ri-draggable size-3.5 text-text-tertiary" data-testid="draggable-menu-icon" /> - </div> - ) - : null + isSupportDrag ? ( + <div + ref={menuRef} + className={cn( + DRAGGABLE_BLOCK_MENU_CLASSNAME, + 'absolute top-4 right-2.5 cursor-grab opacity-0 will-change-transform active:cursor-move', + )} + data-testid="draggable-menu" + > + <span + className="i-ri-draggable size-3.5 text-text-tertiary" + data-testid="draggable-menu-icon" + /> + </div> + ) : null } - targetLineComponent={( + targetLineComponent={ <div ref={targetLineRef} className="pointer-events-none absolute top-0 left-[-21px] opacity-0 will-change-transform" data-testid="draggable-target-line" // style={{ width: 500 }} // width not worked here > - <div - className="absolute top-0 -right-10 left-0 h-[2px] bg-text-accent-secondary" - > - </div> + <div className="absolute top-0 -right-10 left-0 h-[2px] bg-text-accent-secondary"></div> </div> - )} + } isOnMenu={isOnMenu} onElementChanged={setDraggableElement} /> diff --git a/web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/component.spec.tsx index b9576f35b5b7b4..479d20e02debff 100644 --- a/web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/component.spec.tsx @@ -40,7 +40,9 @@ describe('ErrorMessageBlockComponent', () => { describe('Rendering', () => { it('should render error_message text and base styles when unselected', () => { - const { container } = renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />) + const { container } = renderWithLexicalContext( + <ErrorMessageBlockComponent nodeKey="node-1" />, + ) expect(screen.getByText('error_message')).toBeInTheDocument() expect(container.querySelector('svg')).toBeInTheDocument() @@ -50,7 +52,9 @@ describe('ErrorMessageBlockComponent', () => { it('should render selected styles when node is selected', () => { vi.mocked(useSelectOrDelete).mockReturnValue([mockRef, true]) - const { container } = renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />) + const { container } = renderWithLexicalContext( + <ErrorMessageBlockComponent nodeKey="node-1" />, + ) expect(container.firstChild).toHaveClass('border-state-accent-solid') expect(container.firstChild).toHaveClass('bg-state-accent-hover') @@ -87,9 +91,9 @@ describe('ErrorMessageBlockComponent', () => { it('should throw when ErrorMessageBlockNode is not registered', () => { mockHasNodes.mockReturnValue(false) - expect(() => renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)).toThrow( - 'WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor', - ) + expect(() => + renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />), + ).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor') }) }) }) diff --git a/web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/error-message-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/error-message-block-replacement-block.spec.tsx index 21606240d375f4..61a02c71e1af44 100644 --- a/web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/error-message-block-replacement-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/error-message-block-replacement-block.spec.tsx @@ -44,9 +44,11 @@ describe('ErrorMessageBlockReplacementBlock', () => { mockHasNodes.mockReturnValue(true) mockRegisterNodeTransform.mockReturnValue(vi.fn()) vi.mocked(mergeRegister).mockImplementation((...cleanups) => { - return () => cleanups.forEach(cleanup => cleanup()) + return () => cleanups.forEach((cleanup) => cleanup()) }) - vi.mocked($createErrorMessageBlockNode).mockReturnValue({ type: 'node' } as unknown as ErrorMessageBlockNode) + vi.mocked($createErrorMessageBlockNode).mockReturnValue({ + type: 'node', + } as unknown as ErrorMessageBlockNode) vi.mocked($applyNodeReplacement).mockImplementation((node: LexicalNode) => node) }) @@ -75,7 +77,9 @@ describe('ErrorMessageBlockReplacementBlock', () => { it('should pass matcher and creator to decoratorTransform and match placeholder text', () => { renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />) - const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as (node: LexicalNode) => void + const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as ( + node: LexicalNode, + ) => void const textNode = { id: 't-1' } as unknown as LexicalNode transformCallback(textNode) @@ -85,7 +89,9 @@ describe('ErrorMessageBlockReplacementBlock', () => { expect.any(Function), ) - const getMatch = vi.mocked(decoratorTransform).mock.calls[0]![1] as (text: string) => EntityMatch | null + const getMatch = vi.mocked(decoratorTransform).mock.calls[0]![1] as ( + text: string, + ) => EntityMatch | null const match = getMatch(`hello ${ERROR_MESSAGE_PLACEHOLDER_TEXT} world`) expect(match).toEqual({ @@ -99,10 +105,13 @@ describe('ErrorMessageBlockReplacementBlock', () => { const onInsert = vi.fn() renderWithLexicalContext(<ErrorMessageBlockReplacementBlock onInsert={onInsert} />) - const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as (node: LexicalNode) => void + const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as ( + node: LexicalNode, + ) => void transformCallback({ id: 't-1' } as unknown as LexicalNode) - const createNode = vi.mocked(decoratorTransform).mock.calls[0]![2] as () => ErrorMessageBlockNode + const createNode = vi.mocked(decoratorTransform).mock + .calls[0]![2] as () => ErrorMessageBlockNode const created = createNode() expect(onInsert).toHaveBeenCalledTimes(1) @@ -114,10 +123,13 @@ describe('ErrorMessageBlockReplacementBlock', () => { it('should create replacement node without onInsert callback', () => { renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />) - const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as (node: LexicalNode) => void + const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as ( + node: LexicalNode, + ) => void transformCallback({ id: 't-1' } as unknown as LexicalNode) - const createNode = vi.mocked(decoratorTransform).mock.calls[0]![2] as () => ErrorMessageBlockNode + const createNode = vi.mocked(decoratorTransform).mock + .calls[0]![2] as () => ErrorMessageBlockNode expect(() => createNode()).not.toThrow() expect($createErrorMessageBlockNode).toHaveBeenCalled() diff --git a/web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/index.spec.tsx index bd7b5b266de9e4..968e6029d4c61e 100644 --- a/web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/index.spec.tsx @@ -19,7 +19,7 @@ vi.mock('lexical', async () => { return { ...actual, $insertNodes: vi.fn(), - createCommand: vi.fn(name => name), + createCommand: vi.fn((name) => name), COMMAND_PRIORITY_EDITOR: 1, } }) @@ -52,9 +52,11 @@ describe('ErrorMessageBlock', () => { mockHasNodes.mockReturnValue(true) mockRegisterCommand.mockReturnValue(vi.fn()) vi.mocked(mergeRegister).mockImplementation((...cleanups) => { - return () => cleanups.forEach(cleanup => cleanup()) + return () => cleanups.forEach((cleanup) => cleanup()) }) - vi.mocked($createErrorMessageBlockNode).mockReturnValue({ id: 'node' } as unknown as ErrorMessageBlockNode) + vi.mocked($createErrorMessageBlockNode).mockReturnValue({ + id: 'node', + } as unknown as ErrorMessageBlockNode) }) it('should render null and register insert and delete commands', () => { @@ -130,9 +132,7 @@ describe('ErrorMessageBlock', () => { it('should run merged cleanup on unmount', () => { const insertCleanup = vi.fn() const deleteCleanup = vi.fn() - mockRegisterCommand - .mockReturnValueOnce(insertCleanup) - .mockReturnValueOnce(deleteCleanup) + mockRegisterCommand.mockReturnValueOnce(insertCleanup).mockReturnValueOnce(deleteCleanup) const { unmount } = renderWithLexicalContext(<ErrorMessageBlock />) unmount() diff --git a/web/app/components/base/prompt-editor/plugins/error-message-block/component.tsx b/web/app/components/base/prompt-editor/plugins/error-message-block/component.tsx index 810cbdad2a3896..7d0c2deabca054 100644 --- a/web/app/components/base/prompt-editor/plugins/error-message-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/error-message-block/component.tsx @@ -10,9 +10,7 @@ type Props = Readonly<{ nodeKey: string }> -const ErrorMessageBlockComponent: FC<Props> = ({ - nodeKey, -}) => { +const ErrorMessageBlockComponent: FC<Props> = ({ nodeKey }) => { const [editor] = useLexicalComposerContext() const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_ERROR_MESSAGE_COMMAND) @@ -25,7 +23,9 @@ const ErrorMessageBlockComponent: FC<Props> = ({ <div className={cn( 'group/wrap relative mx-0.5 flex h-[18px] items-center rounded-[5px] border pr-[3px] pl-0.5 text-util-colors-orange-dark-orange-dark-600 select-none hover:border-state-accent-solid hover:bg-state-accent-hover', - isSelected ? 'border-state-accent-solid bg-state-accent-hover' : 'border-components-panel-border-subtle bg-components-badge-white-to-dark', + isSelected + ? 'border-state-accent-solid bg-state-accent-hover' + : 'border-components-panel-border-subtle bg-components-badge-white-to-dark', )} onClick={(e) => { e.stopPropagation() diff --git a/web/app/components/base/prompt-editor/plugins/error-message-block/error-message-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/error-message-block/error-message-block-replacement-block.tsx index 8e8497d2a3c562..f53f532eaa203e 100644 --- a/web/app/components/base/prompt-editor/plugins/error-message-block/error-message-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/error-message-block/error-message-block-replacement-block.tsx @@ -2,24 +2,15 @@ import type { ErrorMessageBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' import { $applyNodeReplacement } from 'lexical' -import { - memo, - useCallback, - useEffect, -} from 'react' +import { memo, useCallback, useEffect } from 'react' import { ERROR_MESSAGE_PLACEHOLDER_TEXT } from '../../constants' import { decoratorTransform } from '../../utils' import { CustomTextNode } from '../custom-text/node' -import { - $createErrorMessageBlockNode, - ErrorMessageBlockNode, -} from './node' +import { $createErrorMessageBlockNode, ErrorMessageBlockNode } from './node' const REGEX = new RegExp(ERROR_MESSAGE_PLACEHOLDER_TEXT) -const ErrorMessageBlockReplacementBlock = ({ - onInsert, -}: ErrorMessageBlockType) => { +const ErrorMessageBlockReplacementBlock = ({ onInsert }: ErrorMessageBlockType) => { const [editor] = useLexicalComposerContext() useEffect(() => { @@ -28,16 +19,14 @@ const ErrorMessageBlockReplacementBlock = ({ }, [editor]) const createErrorMessageBlockNode = useCallback((): ErrorMessageBlockNode => { - if (onInsert) - onInsert() + if (onInsert) onInsert() return $applyNodeReplacement($createErrorMessageBlockNode()) }, [onInsert]) const getMatch = useCallback((text: string) => { const matchArr = REGEX.exec(text) - if (matchArr === null) - return null + if (matchArr === null) return null const startOffset = matchArr.index const endOffset = startOffset + ERROR_MESSAGE_PLACEHOLDER_TEXT.length @@ -50,7 +39,9 @@ const ErrorMessageBlockReplacementBlock = ({ useEffect(() => { REGEX.lastIndex = 0 return mergeRegister( - editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createErrorMessageBlockNode)), + editor.registerNodeTransform(CustomTextNode, (textNode) => + decoratorTransform(textNode, getMatch, createErrorMessageBlockNode), + ), ) }, []) diff --git a/web/app/components/base/prompt-editor/plugins/error-message-block/index.tsx b/web/app/components/base/prompt-editor/plugins/error-message-block/index.tsx index bb487ece7cadb0..c58b031f39340a 100644 --- a/web/app/components/base/prompt-editor/plugins/error-message-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/error-message-block/index.tsx @@ -1,27 +1,16 @@ import type { ErrorMessageBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { - $insertNodes, - COMMAND_PRIORITY_EDITOR, - createCommand, -} from 'lexical' -import { - memo, - useEffect, -} from 'react' -import { - $createErrorMessageBlockNode, - ErrorMessageBlockNode, -} from './node' +import { $insertNodes, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' +import { memo, useEffect } from 'react' +import { $createErrorMessageBlockNode, ErrorMessageBlockNode } from './node' -export const INSERT_ERROR_MESSAGE_BLOCK_COMMAND = createCommand('INSERT_ERROR_MESSAGE_BLOCK_COMMAND') +export const INSERT_ERROR_MESSAGE_BLOCK_COMMAND = createCommand( + 'INSERT_ERROR_MESSAGE_BLOCK_COMMAND', +) export const DELETE_ERROR_MESSAGE_COMMAND = createCommand('DELETE_ERROR_MESSAGE_COMMAND') -const ErrorMessageBlock = memo(({ - onInsert, - onDelete, -}: ErrorMessageBlockType) => { +const ErrorMessageBlock = memo(({ onInsert, onDelete }: ErrorMessageBlockType) => { const [editor] = useLexicalComposerContext() useEffect(() => { @@ -36,8 +25,7 @@ const ErrorMessageBlock = memo(({ $insertNodes([Node]) - if (onInsert) - onInsert() + if (onInsert) onInsert() return true }, @@ -46,8 +34,7 @@ const ErrorMessageBlock = memo(({ editor.registerCommand( DELETE_ERROR_MESSAGE_COMMAND, () => { - if (onDelete) - onDelete() + if (onDelete) onDelete() return true }, diff --git a/web/app/components/base/prompt-editor/plugins/error-message-block/node.tsx b/web/app/components/base/prompt-editor/plugins/error-message-block/node.tsx index 9dd649f69343ed..09d8a78d9404f3 100644 --- a/web/app/components/base/prompt-editor/plugins/error-message-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/error-message-block/node.tsx @@ -32,11 +32,7 @@ export class ErrorMessageBlockNode extends DecoratorNode<React.JSX.Element> { } override decorate(): React.JSX.Element { - return ( - <ErrorMessageBlockComponent - nodeKey={this.getKey()} - /> - ) + return <ErrorMessageBlockComponent nodeKey={this.getKey()} /> } static override importJSON(): ErrorMessageBlockNode { diff --git a/web/app/components/base/prompt-editor/plugins/history-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/history-block/__tests__/component.spec.tsx index 4d5749842479c9..52bf48b15264d3 100644 --- a/web/app/components/base/prompt-editor/plugins/history-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/history-block/__tests__/component.spec.tsx @@ -15,11 +15,13 @@ type HistoryEventPayload = { type HistorySubscriptionHandler = (payload: HistoryEventPayload) => void -const { mockUseSelectOrDelete, mockUseTrigger, mockUseEventEmitterContextContext } = vi.hoisted(() => ({ - mockUseSelectOrDelete: vi.fn(), - mockUseTrigger: vi.fn(), - mockUseEventEmitterContextContext: vi.fn(), -})) +const { mockUseSelectOrDelete, mockUseTrigger, mockUseEventEmitterContextContext } = vi.hoisted( + () => ({ + mockUseSelectOrDelete: vi.fn(), + mockUseTrigger: vi.fn(), + mockUseEventEmitterContextContext: vi.fn(), + }), +) vi.mock('../../../hooks', () => ({ useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args), @@ -36,13 +38,17 @@ const createRoleName = (overrides?: Partial<RoleName>): RoleName => ({ ...overrides, }) -const createSelectHookReturn = (isSelected: boolean): [RefObject<HTMLDivElement | null>, boolean] => { +const createSelectHookReturn = ( + isSelected: boolean, +): [RefObject<HTMLDivElement | null>, boolean] => { return [{ current: null }, isSelected] } const createTriggerHookReturn = ( open: boolean, - setOpen: Dispatch<SetStateAction<boolean>> = vi.fn() as unknown as Dispatch<SetStateAction<boolean>>, + setOpen: Dispatch<SetStateAction<boolean>> = vi.fn() as unknown as Dispatch< + SetStateAction<boolean> + >, ): [RefObject<HTMLDivElement | null>, boolean, Dispatch<SetStateAction<boolean>>] => { return [{ current: null }, open, setOpen] } @@ -67,14 +73,12 @@ describe('HistoryBlockComponent', () => { }) it('should render title and register select or delete hook with node key', () => { - render( - <HistoryBlockComponent - nodeKey="history-node-1" - onEditRole={vi.fn()} - />, - ) + render(<HistoryBlockComponent nodeKey="history-node-1" onEditRole={vi.fn()} />) - expect(mockUseSelectOrDelete).toHaveBeenCalledWith('history-node-1', DELETE_HISTORY_BLOCK_COMMAND) + expect(mockUseSelectOrDelete).toHaveBeenCalledWith( + 'history-node-1', + DELETE_HISTORY_BLOCK_COMMAND, + ) expect(screen.getByText('common.promptEditor.history.item.title')).toBeInTheDocument() }) @@ -83,10 +87,7 @@ describe('HistoryBlockComponent', () => { mockUseTrigger.mockReturnValue(createTriggerHookReturn(true)) const { container } = render( - <HistoryBlockComponent - nodeKey="history-node-2" - onEditRole={vi.fn()} - />, + <HistoryBlockComponent nodeKey="history-node-2" onEditRole={vi.fn()} />, ) const wrapper = container.firstElementChild @@ -116,12 +117,7 @@ describe('HistoryBlockComponent', () => { const setOpen = vi.fn() as unknown as Dispatch<SetStateAction<boolean>> mockUseTrigger.mockReturnValue(createTriggerHookReturn(false, setOpen)) - render( - <HistoryBlockComponent - nodeKey="history-node-trigger" - onEditRole={vi.fn()} - />, - ) + render(<HistoryBlockComponent nodeKey="history-node-trigger" onEditRole={vi.fn()} />) await user.click(screen.getByTestId('popover-trigger')) @@ -236,12 +232,7 @@ describe('HistoryBlockComponent', () => { eventEmitter: undefined, }) - render( - <HistoryBlockComponent - nodeKey="history-node-7" - onEditRole={vi.fn()} - />, - ) + render(<HistoryBlockComponent nodeKey="history-node-7" onEditRole={vi.fn()} />) expect(screen.getByText('common.promptEditor.history.item.title')).toBeInTheDocument() }) diff --git a/web/app/components/base/prompt-editor/plugins/history-block/__tests__/history-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/history-block/__tests__/history-block-replacement-block.spec.tsx index a6e40ab6ed7037..a698866524177e 100644 --- a/web/app/components/base/prompt-editor/plugins/history-block/__tests__/history-block-replacement-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/history-block/__tests__/history-block-replacement-block.spec.tsx @@ -63,7 +63,11 @@ describe('HistoryBlockReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, `prefix ${HISTORY_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text)) + setEditorRootText( + editor, + `prefix ${HISTORY_PLACEHOLDER_TEXT} suffix`, + (text) => new CustomTextNode(text), + ) await waitFor(() => { expect(getNodeCount(editor, HistoryBlockNode)).toBe(1) @@ -78,7 +82,11 @@ describe('HistoryBlockReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, 'plain text without history placeholder', text => new CustomTextNode(text)) + setEditorRootText( + editor, + 'plain text without history placeholder', + (text) => new CustomTextNode(text), + ) await waitFor(() => { expect(getNodeCount(editor, HistoryBlockNode)).toBe(0) @@ -91,7 +99,7 @@ describe('HistoryBlockReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, HISTORY_PLACEHOLDER_TEXT, text => new CustomTextNode(text)) + setEditorRootText(editor, HISTORY_PLACEHOLDER_TEXT, (text) => new CustomTextNode(text)) await waitFor(() => { expect(getNodeCount(editor, HistoryBlockNode)).toBe(1) diff --git a/web/app/components/base/prompt-editor/plugins/history-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/history-block/__tests__/index.spec.tsx index 682ec90a63bfb0..eef3fe4a2627b5 100644 --- a/web/app/components/base/prompt-editor/plugins/history-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/history-block/__tests__/index.spec.tsx @@ -18,7 +18,6 @@ import { HistoryBlock, HistoryBlockNode, INSERT_HISTORY_BLOCK_COMMAND, - } from '../index' const createRoleName = (overrides?: Partial<RoleName>): RoleName => ({ diff --git a/web/app/components/base/prompt-editor/plugins/history-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/history-block/__tests__/node.spec.tsx index c7960d658e1d9c..c6409b09faa030 100644 --- a/web/app/components/base/prompt-editor/plugins/history-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/history-block/__tests__/node.spec.tsx @@ -1,19 +1,11 @@ import type { SerializedNode as SerializedHistoryBlockNode } from '../node' import { act } from '@testing-library/react' import { $getNodeByKey, $getRoot } from 'lexical' -import { - createLexicalTestEditor, - expectInlineWrapperDom, -} from '../../test-helpers' +import { createLexicalTestEditor, expectInlineWrapperDom } from '../../test-helpers' import HistoryBlockComponent from '../component' -import { - $createHistoryBlockNode, - $isHistoryBlockNode, - HistoryBlockNode, +import { $createHistoryBlockNode, $isHistoryBlockNode, HistoryBlockNode } from '../node' -} from '../node' - -const createRoleName = (overrides?: { user?: string, assistant?: string }) => ({ +const createRoleName = (overrides?: { user?: string; assistant?: string }) => ({ user: 'user-role', assistant: 'assistant-role', ...overrides, diff --git a/web/app/components/base/prompt-editor/plugins/history-block/component.tsx b/web/app/components/base/prompt-editor/plugins/history-block/component.tsx index 028124d1bb2ab5..603b10c3710a8d 100644 --- a/web/app/components/base/prompt-editor/plugins/history-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/history-block/component.tsx @@ -2,9 +2,7 @@ import type { FC } from 'react' import type { RoleName } from './index' import type { EventEmitterValue } from '@/context/event-emitter' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { - RiMoreFill, -} from '@remixicon/react' +import { RiMoreFill } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general' @@ -31,42 +29,37 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({ const [localRoleName, setLocalRoleName] = useState<RoleName>(roleName) eventEmitter?.useSubscription((event?: EventEmitterValue) => { - if (typeof event === 'string') - return + if (typeof event === 'string') return - if (event?.type === UPDATE_HISTORY_EVENT_EMITTER && event.payload && typeof event.payload === 'object') + if ( + event?.type === UPDATE_HISTORY_EVENT_EMITTER && + event.payload && + typeof event.payload === 'object' + ) setLocalRoleName(event.payload as RoleName) }) return ( <div - className={` - group inline-flex h-6 items-center rounded-[5px] border border-transparent pr-0.5 pl-1 text-[#DD2590] hover:bg-[#FCE7F6] - ${open ? 'bg-[#FCE7F6]' : 'bg-[#FDF2FA]'} - ${isSelected && 'border-[#F670C7]!'} - `} + className={`group inline-flex h-6 items-center rounded-[5px] border border-transparent pr-0.5 pl-1 text-[#DD2590] hover:bg-[#FCE7F6] ${open ? 'bg-[#FCE7F6]' : 'bg-[#FDF2FA]'} ${isSelected && 'border-[#F670C7]!'} `} ref={ref} > <MessageClockCircle className="mr-1 h-[14px] w-[14px]" /> - <div className="mr-1 text-xs font-medium">{t($ => $['promptEditor.history.item.title'], { ns: 'common' })}</div> - <Popover - open={open} - onOpenChange={setOpen} - > + <div className="mr-1 text-xs font-medium"> + {t(($) => $['promptEditor.history.item.title'], { ns: 'common' })} + </div> + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton={false} - render={( + render={ <div - className={` - flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded - ${open ? 'bg-[#DD2590] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'} - `} + className={`flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded ${open ? 'bg-[#DD2590] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'} `} ref={triggerRef} - onClick={e => e.preventDefault()} + onClick={(e) => e.preventDefault()} > <RiMoreFill className="size-3" /> </div> - )} + } /> <PopoverContent placement="top-end" @@ -76,21 +69,23 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({ > <div className="w-[360px] rounded-xl bg-white shadow-lg"> <div className="p-4"> - <div className="mb-2 text-xs font-medium text-gray-500">{t($ => $['promptEditor.history.modal.title'], { ns: 'common' })}</div> + <div className="mb-2 text-xs font-medium text-gray-500"> + {t(($) => $['promptEditor.history.modal.title'], { ns: 'common' })} + </div> <div className="flex items-center text-sm text-gray-700"> <div className="mr-1 w-20 text-xs font-semibold">{localRoleName?.user}</div> - {t($ => $['promptEditor.history.modal.user'], { ns: 'common' })} + {t(($) => $['promptEditor.history.modal.user'], { ns: 'common' })} </div> <div className="flex items-center text-sm text-gray-700"> <div className="mr-1 w-20 text-xs font-semibold">{localRoleName?.assistant}</div> - {t($ => $['promptEditor.history.modal.assistant'], { ns: 'common' })} + {t(($) => $['promptEditor.history.modal.assistant'], { ns: 'common' })} </div> </div> <div className="cursor-pointer rounded-b-xl border-t border-black/5 px-4 py-3 text-xs text-[#155EEF]" onClick={onEditRole} > - {t($ => $['promptEditor.history.modal.edit'], { ns: 'common' })} + {t(($) => $['promptEditor.history.modal.edit'], { ns: 'common' })} </div> </div> </PopoverContent> diff --git a/web/app/components/base/prompt-editor/plugins/history-block/history-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/history-block/history-block-replacement-block.tsx index 759e654c02b04b..ba64996acdd811 100644 --- a/web/app/components/base/prompt-editor/plugins/history-block/history-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/history-block/history-block-replacement-block.tsx @@ -3,17 +3,11 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { mergeRegister } from '@lexical/utils' import { noop } from 'es-toolkit/function' import { $applyNodeReplacement } from 'lexical' -import { - useCallback, - useEffect, -} from 'react' +import { useCallback, useEffect } from 'react' import { HISTORY_PLACEHOLDER_TEXT } from '../../constants' import { decoratorTransform } from '../../utils' import { CustomTextNode } from '../custom-text/node' -import { - $createHistoryBlockNode, - HistoryBlockNode, -} from '../history-block/node' +import { $createHistoryBlockNode, HistoryBlockNode } from '../history-block/node' const REGEX = new RegExp(HISTORY_PLACEHOLDER_TEXT) @@ -30,16 +24,14 @@ const HistoryBlockReplacementBlock = ({ }, [editor]) const createHistoryBlockNode = useCallback((): HistoryBlockNode => { - if (onInsert) - onInsert() + if (onInsert) onInsert() return $applyNodeReplacement($createHistoryBlockNode(history, onEditRole)) }, [history, onEditRole, onInsert]) const getMatch = useCallback((text: string) => { const matchArr = REGEX.exec(text) - if (matchArr === null) - return null + if (matchArr === null) return null const startOffset = matchArr.index const endOffset = startOffset + HISTORY_PLACEHOLDER_TEXT.length @@ -52,7 +44,9 @@ const HistoryBlockReplacementBlock = ({ useEffect(() => { REGEX.lastIndex = 0 return mergeRegister( - editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createHistoryBlockNode)), + editor.registerNodeTransform(CustomTextNode, (textNode) => + decoratorTransform(textNode, getMatch, createHistoryBlockNode), + ), ) }, []) diff --git a/web/app/components/base/prompt-editor/plugins/history-block/index.tsx b/web/app/components/base/prompt-editor/plugins/history-block/index.tsx index f6fd01f0d9c04c..4c76616c6c4325 100644 --- a/web/app/components/base/prompt-editor/plugins/history-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/history-block/index.tsx @@ -2,19 +2,9 @@ import type { HistoryBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' import { noop } from 'es-toolkit/function' -import { - $insertNodes, - COMMAND_PRIORITY_EDITOR, - createCommand, -} from 'lexical' -import { - memo, - useEffect, -} from 'react' -import { - $createHistoryBlockNode, - HistoryBlockNode, -} from './node' +import { $insertNodes, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' +import { memo, useEffect } from 'react' +import { $createHistoryBlockNode, HistoryBlockNode } from './node' export const INSERT_HISTORY_BLOCK_COMMAND = createCommand('INSERT_HISTORY_BLOCK_COMMAND') export const DELETE_HISTORY_BLOCK_COMMAND = createCommand('DELETE_HISTORY_BLOCK_COMMAND') @@ -24,48 +14,48 @@ export type RoleName = { assistant: string } -const HistoryBlock = memo(({ - history = { user: '', assistant: '' }, - onEditRole = noop, - onInsert, - onDelete, -}: HistoryBlockType) => { - const [editor] = useLexicalComposerContext() - - useEffect(() => { - if (!editor.hasNodes([HistoryBlockNode])) - throw new Error('HistoryBlockPlugin: HistoryBlock not registered on editor') - - return mergeRegister( - editor.registerCommand( - INSERT_HISTORY_BLOCK_COMMAND, - () => { - const historyBlockNode = $createHistoryBlockNode(history, onEditRole) - - $insertNodes([historyBlockNode]) - - if (onInsert) - onInsert() - - return true - }, - COMMAND_PRIORITY_EDITOR, - ), - editor.registerCommand( - DELETE_HISTORY_BLOCK_COMMAND, - () => { - if (onDelete) - onDelete() - - return true - }, - COMMAND_PRIORITY_EDITOR, - ), - ) - }, [editor, history, onEditRole, onInsert, onDelete]) - - return null -}) +const HistoryBlock = memo( + ({ + history = { user: '', assistant: '' }, + onEditRole = noop, + onInsert, + onDelete, + }: HistoryBlockType) => { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + if (!editor.hasNodes([HistoryBlockNode])) + throw new Error('HistoryBlockPlugin: HistoryBlock not registered on editor') + + return mergeRegister( + editor.registerCommand( + INSERT_HISTORY_BLOCK_COMMAND, + () => { + const historyBlockNode = $createHistoryBlockNode(history, onEditRole) + + $insertNodes([historyBlockNode]) + + if (onInsert) onInsert() + + return true + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DELETE_HISTORY_BLOCK_COMMAND, + () => { + if (onDelete) onDelete() + + return true + }, + COMMAND_PRIORITY_EDITOR, + ), + ) + }, [editor, history, onEditRole, onInsert, onDelete]) + + return null + }, +) HistoryBlock.displayName = 'HistoryBlock' export { HistoryBlock } diff --git a/web/app/components/base/prompt-editor/plugins/history-block/node.tsx b/web/app/components/base/prompt-editor/plugins/history-block/node.tsx index 9a363303bf9e7b..9365bc300c9270 100644 --- a/web/app/components/base/prompt-editor/plugins/history-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/history-block/node.tsx @@ -3,7 +3,7 @@ import type { RoleName } from './index' import { DecoratorNode } from 'lexical' import HistoryBlockComponent from './component' -export type SerializedNode = SerializedLexicalNode & { roleName: RoleName, onEditRole: () => void } +export type SerializedNode = SerializedLexicalNode & { roleName: RoleName; onEditRole: () => void } export class HistoryBlockNode extends DecoratorNode<React.JSX.Element> { __roleName: RoleName @@ -79,7 +79,10 @@ export class HistoryBlockNode extends DecoratorNode<React.JSX.Element> { return '{{#histories#}}' } } -export function $createHistoryBlockNode(roleName: RoleName, onEditRole: () => void): HistoryBlockNode { +export function $createHistoryBlockNode( + roleName: RoleName, + onEditRole: () => void, +): HistoryBlockNode { return new HistoryBlockNode(roleName, onEditRole) } diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx index 2fa310d0581d2a..3acb1d9ef222e2 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx @@ -1,8 +1,10 @@ import type { ComponentProps } from 'react' import type { WorkflowNodesMap } from '../../workflow-variable-block/node' -import type { FormInputItem, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types' +import type { + FormInputItem, + ParagraphFormInput, +} from '@/app/components/workflow/nodes/human-input/types' import type { ValueSelector } from '@/app/components/workflow/types' - import { LexicalComposer } from '@lexical/react/LexicalComposer' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { useEffect, useState } from 'react' @@ -32,9 +34,7 @@ const createWorkflowNodesMap = (): WorkflowNodesMap => ({ }, }) -const renderComponent = ( - props: Partial<ComponentProps<typeof HITLInputComponentUI>> = {}, -) => { +const renderComponent = (props: Partial<ComponentProps<typeof HITLInputComponentUI>> = {}) => { const onChange = vi.fn() const onRename = vi.fn() const onRemove = vi.fn() @@ -111,8 +111,12 @@ describe('HITLInputComponentUI', () => { it('should hide action buttons when readonly is true', () => { renderComponent({ readonly: true }) - expect(screen.queryByRole('button', { name: 'common.operation.edit' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.operation.remove' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.edit' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.remove' }), + ).not.toBeInTheDocument() }) it('should close the edit modal when readonly becomes true', async () => { @@ -198,7 +202,9 @@ describe('HITLInputComponentUI', () => { const summary = getByText('alpha, beta') const typeLabel = getByText('appDebug.variableConfig.select') - expect(summary.compareDocumentPosition(typeLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect( + summary.compareDocumentPosition(typeLabel) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() }) it('should render file-list placeholder instead of selected file details', () => { @@ -247,12 +253,7 @@ describe('HITLInputComponentUI', () => { describe('Edit flow', () => { it('should close modal without update when cancel is clicked', async () => { - const { - findByRole, - queryByRole, - onChange, - onRename, - } = renderComponent() + const { findByRole, queryByRole, onChange, onRename } = renderComponent() fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' })) @@ -267,11 +268,7 @@ describe('HITLInputComponentUI', () => { }) it('should prevent renaming to an existing variable name', async () => { - const { - findByRole, - onChange, - onRename, - } = renderComponent({ + const { findByRole, onChange, onRename } = renderComponent({ unavailableVariableNames: ['existing_name'], }) @@ -280,7 +277,9 @@ describe('HITLInputComponentUI', () => { const textbox = await findByRole('textbox') fireEvent.change(textbox, { target: { value: 'existing_name' } }) - expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameDuplicated')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameDuplicated'), + ).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled() fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component.spec.tsx index dc97ee05a5083d..5c68402ef898a0 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component.spec.tsx @@ -1,5 +1,8 @@ import type { RefObject } from 'react' -import type { FormInputItem, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types' +import type { + FormInputItem, + ParagraphFormInput, +} from '@/app/components/workflow/nodes/human-input/types' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { InputVarType } from '@/app/components/workflow/types' @@ -14,45 +17,54 @@ vi.mock('../../../hooks', () => ({ })) vi.mock('../component-ui', () => ({ - default: ({ formInput, onChange }: { formInput?: FormInputItem, onChange: (payload: FormInputItem) => void }) => { - const basePayload: ParagraphFormInput = (formInput && formInput.type === InputVarType.paragraph - ? formInput - : { - type: InputVarType.paragraph, - output_variable_name: 'user_name', - default: { - type: 'constant', - selector: [], - value: 'hello', - }, - }) satisfies ParagraphFormInput + default: ({ + formInput, + onChange, + }: { + formInput?: FormInputItem + onChange: (payload: FormInputItem) => void + }) => { + const basePayload: ParagraphFormInput = ( + formInput && formInput.type === InputVarType.paragraph + ? formInput + : { + type: InputVarType.paragraph, + output_variable_name: 'user_name', + default: { + type: 'constant', + selector: [], + value: 'hello', + }, + } + ) satisfies ParagraphFormInput return ( <div> - <button - type="button" - onClick={() => onChange(basePayload)} - > + <button type="button" onClick={() => onChange(basePayload)}> emit-same-name </button> <button type="button" - onClick={() => onChange({ - ...basePayload, - output_variable_name: 'renamed_name', - })} + onClick={() => + onChange({ + ...basePayload, + output_variable_name: 'renamed_name', + }) + } > emit-rename </button> <button type="button" - onClick={() => onChange({ - ...basePayload, - default: { - type: 'constant', - selector: [], - value: 'updated', - }, - })} + onClick={() => + onChange({ + ...basePayload, + default: { + type: 'constant', + selector: [], + value: 'updated', + }, + }) + } > emit-update </button> @@ -138,10 +150,7 @@ describe('HITLInputComponent', () => { nodeKey="node-key-duplicate" nodeId="node-duplicate" varName="user_name" - formInputs={[ - createInput(), - createInput({ output_variable_name: 'renamed_name' }), - ]} + formInputs={[createInput(), createInput({ output_variable_name: 'renamed_name' })]} onChange={onChange} onRename={vi.fn()} onRemove={vi.fn()} diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/hitl-input-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/hitl-input-block-replacement-block.spec.tsx index 57f1db1fa35478..26eda3fe1f43fe 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/hitl-input-block-replacement-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/hitl-input-block-replacement-block.spec.tsx @@ -6,10 +6,7 @@ import { LexicalComposer } from '@lexical/react/LexicalComposer' import { render, waitFor } from '@testing-library/react' import { $nodesOfType } from 'lexical' import { Type } from '@/app/components/workflow/nodes/llm/types' -import { - BlockEnum, - InputVarType, -} from '@/app/components/workflow/types' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import { CustomTextNode } from '../../custom-text/node' import { getNodesByType, @@ -75,7 +72,8 @@ const renderReplacementPlugin = (props?: { getVarType?: GetVarType formInputs?: FormInputItem[] | null }) => { - const formInputs = props?.formInputs === null ? undefined : (props?.formInputs ?? [createFormInput()]) + const formInputs = + props?.formInputs === null ? undefined : (props?.formInputs ?? [createFormInput()]) return renderLexicalEditor({ namespace: 'hitl-input-replacement-plugin-test', @@ -110,8 +108,7 @@ type HITLInputNodeSnapshot = { const readFirstHITLInputNodeSnapshot = (editor: LexicalEditor): HITLInputNodeSnapshot | null => { return readEditorStateValue(editor, () => { const node = $nodesOfType(HITLInputNode)[0] - if (!node) - return null + if (!node) return null return { variableName: node.getVariableName(), @@ -142,7 +139,11 @@ describe('HITLInputReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, 'before {{#$output.user_name#}} after', text => new CustomTextNode(text)) + setEditorRootText( + editor, + 'before {{#$output.user_name#}} after', + (text) => new CustomTextNode(text), + ) await waitFor(() => { expect(getNodesByType(editor, HITLInputNode)).toHaveLength(1) @@ -150,15 +151,16 @@ describe('HITLInputReplacementBlock', () => { const node = readFirstHITLInputNodeSnapshot(editor) expect(node).not.toBeNull() - if (!node) - throw new Error('Expected HITLInputNode snapshot') + if (!node) throw new Error('Expected HITLInputNode snapshot') expect(node.variableName).toBe('user_name') expect(node.nodeId).toBe('node-1') expect(node.getVarType).toBe(getVarType) expect(node.readonly).toBe(true) expect(node.environmentVariables).toEqual([{ variable: 'env.api_key', type: 'string' }]) - expect(node.conversationVariables).toEqual([{ variable: 'conversation.user_id', type: 'number' }]) + expect(node.conversationVariables).toEqual([ + { variable: 'conversation.user_id', type: 'number' }, + ]) expect(node.ragVariables).toEqual([ { variable: 'rag.shared.file_name', type: 'string', isRagVariable: true }, { variable: 'node-1.doc_name', type: 'string', isRagVariable: true }, @@ -172,7 +174,11 @@ describe('HITLInputReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, 'plain text without replacement token', text => new CustomTextNode(text)) + setEditorRootText( + editor, + 'plain text without replacement token', + (text) => new CustomTextNode(text), + ) await waitFor(() => { expect(getNodesByType(editor, HITLInputNode)).toHaveLength(0) @@ -184,7 +190,7 @@ describe('HITLInputReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, '{{#$output.user_name#}}', text => new CustomTextNode(text)) + setEditorRootText(editor, '{{#$output.user_name#}}', (text) => new CustomTextNode(text)) await waitFor(() => { expect(getNodesByType(editor, HITLInputNode)).toHaveLength(1) @@ -192,8 +198,7 @@ describe('HITLInputReplacementBlock', () => { const node = readFirstHITLInputNodeSnapshot(editor) expect(node).not.toBeNull() - if (!node) - throw new Error('Expected HITLInputNode snapshot') + if (!node) throw new Error('Expected HITLInputNode snapshot') expect(node.environmentVariables).toEqual([]) expect(node.conversationVariables).toEqual([]) @@ -206,7 +211,7 @@ describe('HITLInputReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, '{{#$output.user_name#}}', text => new CustomTextNode(text)) + setEditorRootText(editor, '{{#$output.user_name#}}', (text) => new CustomTextNode(text)) await waitFor(() => { expect(getNodesByType(editor, HITLInputNode)).toHaveLength(1) @@ -214,8 +219,7 @@ describe('HITLInputReplacementBlock', () => { const node = readFirstHITLInputNodeSnapshot(editor) expect(node).not.toBeNull() - if (!node) - throw new Error('Expected HITLInputNode snapshot') + if (!node) throw new Error('Expected HITLInputNode snapshot') expect(node.formInputsLength).toBe(0) }) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/index.spec.tsx index 79b806b889c47f..6e2015f5b02a97 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/index.spec.tsx @@ -3,15 +3,9 @@ import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/ import { LexicalComposer } from '@lexical/react/LexicalComposer' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { act, render, waitFor } from '@testing-library/react' -import { - $nodesOfType, - COMMAND_PRIORITY_EDITOR, -} from 'lexical' +import { $nodesOfType, COMMAND_PRIORITY_EDITOR } from 'lexical' import { useEffect, useState } from 'react' -import { - BlockEnum, - InputVarType, -} from '@/app/components/workflow/types' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import { CustomTextNode } from '../../custom-text/node' import { getNodeCount, @@ -81,7 +75,7 @@ const createInsertPayload = () => ({ const readHITLReadonlyValues = (editor: LexicalEditor): boolean[] => { return readEditorStateValue(editor, () => { - return $nodesOfType(HITLInputNode).map(node => node.getReadonly()) + return $nodesOfType(HITLInputNode).map((node) => node.getReadonly()) }) } @@ -98,7 +92,9 @@ const renderHITLInputBlock = (props?: { nodes: [CustomTextNode, HITLInputNode], children: ( <> - {props?.onWorkflowMapUpdate && <UpdateWorkflowNodesMapPlugin onUpdate={props.onWorkflowMapUpdate} />} + {props?.onWorkflowMapUpdate && ( + <UpdateWorkflowNodesMapPlugin onUpdate={props.onWorkflowMapUpdate} /> + )} <HITLInputBlock nodeId="node-1" formInputs={[createFormInput()]} @@ -277,7 +273,10 @@ describe('HITLInputBlock', () => { let insertHandled = true let deleteHandled = true act(() => { - insertHandled = editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload()) + insertHandled = editor.dispatchCommand( + INSERT_HITL_INPUT_BLOCK_COMMAND, + createInsertPayload(), + ) deleteHandled = editor.dispatchCommand(DELETE_HITL_INPUT_BLOCK_COMMAND, undefined) }) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx index 354fce5c7b3b76..ab6f188b03a208 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx @@ -1,4 +1,7 @@ -import type { FormInputItem, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types' +import type { + FormInputItem, + ParagraphFormInput, +} from '@/app/components/workflow/nodes/human-input/types' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { InputVarType, SupportUploadFileTypes, VarType } from '@/app/components/workflow/types' @@ -55,20 +58,26 @@ vi.mock('@/app/components/app/configuration/config-var/config-select', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({ __esModule: true, - default: ({ onChange }: { onChange: (payload: { - allowed_file_extensions: string[] - allowed_file_types: SupportUploadFileTypes[] - allowed_file_upload_methods: TransferMethod[] - max_length?: number - }) => void }) => ( + default: ({ + onChange, + }: { + onChange: (payload: { + allowed_file_extensions: string[] + allowed_file_types: SupportUploadFileTypes[] + allowed_file_upload_methods: TransferMethod[] + max_length?: number + }) => void + }) => ( <button type="button" - onClick={() => onChange({ - allowed_file_extensions: ['.pdf'], - allowed_file_types: [SupportUploadFileTypes.document], - allowed_file_upload_methods: [TransferMethod.local_file], - max_length: fileUploadSettingMaxLength, - })} + onClick={() => + onChange({ + allowed_file_extensions: ['.pdf'], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_upload_methods: [TransferMethod.local_file], + max_length: fileUploadSettingMaxLength, + }) + } > file-upload-setting </button> @@ -138,7 +147,9 @@ describe('InputField', () => { await user.clear(inputs[0]!) await user.type(inputs[0]!, 'invalid name') - expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameInvalid'))!.toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameInvalid'), + )!.toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.save' }))!.toBeDisabled() await user.click(screen.getByRole('button', { name: 'common.operation.save' })) await user.keyboard('{Control>}{Enter}{/Control}') @@ -164,8 +175,14 @@ describe('InputField', () => { await user.clear(inputs[0]!) await user.type(inputs[0]!, 'existing_name') - expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameDuplicated')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })).toBeDisabled() + expect( + screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameDuplicated'), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ).toBeDisabled() await user.keyboard('{Control>}{Enter}{/Control}') expect(onChange).not.toHaveBeenCalled() }) @@ -224,7 +241,11 @@ describe('InputField', () => { const nameInput = screen.getAllByRole('textbox')[0] await user.type(nameInput!, 'generated_name') - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0]).toEqual({ @@ -291,8 +312,14 @@ describe('InputField', () => { />, ) - await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i)) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i), + ) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0].default.type).toBe('variable') @@ -318,8 +345,14 @@ describe('InputField', () => { />, ) - await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useConstantInstead/i)) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useConstantInstead/i), + ) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0].default.type).toBe('constant') @@ -346,7 +379,11 @@ describe('InputField', () => { ) await user.click(screen.getByText('pick-variable')) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0].default).toEqual({ @@ -375,8 +412,14 @@ describe('InputField', () => { ) await user.keyboard('{Tab}') - await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i)) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i), + ) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0].default).toEqual({ @@ -401,7 +444,11 @@ describe('InputField', () => { ) await user.click(screen.getByRole('button', { name: 'select-select' })) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0]).toEqual({ @@ -413,7 +460,9 @@ describe('InputField', () => { value: [], }, }) - expect(screen.queryByText(/workflow\.nodes\.humanInput\.insertInputField\.prePopulateField/i)).not.toBeInTheDocument() + expect( + screen.queryByText(/workflow\.nodes\.humanInput\.insertInputField\.prePopulateField/i), + ).not.toBeInTheDocument() }) it('should keep paragraph pre-populate editor available after switching back to paragraph', async () => { @@ -429,13 +478,19 @@ describe('InputField', () => { />, ) - expect(screen.getByText('workflow.nodes.humanInput.insertInputField.prePopulateField')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.humanInput.insertInputField.prePopulateField'), + ).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'select-file' })) - expect(screen.queryByText(/workflow\.nodes\.humanInput\.insertInputField\.prePopulateField/i)).not.toBeInTheDocument() + expect( + screen.queryByText(/workflow\.nodes\.humanInput\.insertInputField\.prePopulateField/i), + ).not.toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'select-paragraph' })) - expect(screen.getByText('workflow.nodes.humanInput.insertInputField.prePopulateField')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.humanInput.insertInputField.prePopulateField'), + ).toBeInTheDocument() }) it('should save constant select options', async () => { @@ -454,7 +509,11 @@ describe('InputField', () => { await user.click(screen.getByRole('button', { name: 'select-select' })) await user.click(screen.getByRole('button', { name: 'config-select' })) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0]).toEqual({ @@ -484,10 +543,18 @@ describe('InputField', () => { await user.click(screen.getByRole('button', { name: 'select-select' })) await user.click(screen.getByRole('button', { name: 'config-select' })) - await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i)) + await user.click( + screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i), + ) await user.click(screen.getByText('pick-variable')) - await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useConstantInstead/i)) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useConstantInstead/i), + ) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0]).toEqual({ @@ -515,7 +582,9 @@ describe('InputField', () => { ) await user.click(screen.getByRole('button', { name: 'select-select' })) - await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i)) + await user.click( + screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i), + ) expect(lastVarReferencePickerProps?.filterVar?.({ type: VarType.arrayString })).toBe(true) expect(lastVarReferencePickerProps?.filterVar?.({ type: VarType.string })).toBe(false) @@ -543,7 +612,11 @@ describe('InputField', () => { ) await user.click(screen.getByRole('button', { name: 'select-select' })) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0]).not.toHaveProperty('default') @@ -565,7 +638,11 @@ describe('InputField', () => { await user.click(screen.getByRole('button', { name: 'select-file' })) await user.click(screen.getByRole('button', { name: 'file-upload-setting' })) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0]).toEqual({ @@ -598,7 +675,11 @@ describe('InputField', () => { ) await user.click(screen.getByRole('button', { name: 'select-file' })) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0]).not.toHaveProperty('default') @@ -620,7 +701,11 @@ describe('InputField', () => { await user.click(screen.getByRole('button', { name: 'select-file-list' })) await user.click(screen.getByRole('button', { name: 'file-upload-setting' })) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0]).toEqual({ @@ -650,13 +735,19 @@ describe('InputField', () => { await user.click(screen.getByRole('button', { name: 'select-file-list' })) await user.click(screen.getByRole('button', { name: 'file-upload-setting' })) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange.mock.calls[0]![0]).toEqual(expect.objectContaining({ - type: InputVarType.multiFiles, - number_limits: 1, - })) + expect(onChange.mock.calls[0]![0]).toEqual( + expect.objectContaining({ + type: InputVarType.multiFiles, + number_limits: 1, + }), + ) }) it('should clear paragraph default state when switching to file-list', async () => { @@ -680,7 +771,11 @@ describe('InputField', () => { ) await user.click(screen.getByRole('button', { name: 'select-file-list' })) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })) + await user.click( + screen.getByRole('button', { + name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i, + }), + ) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0]![0]).not.toHaveProperty('default') diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/node.spec.tsx index 399422fe1aa7ee..14bf91762ae7b5 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/node.spec.tsx @@ -1,19 +1,10 @@ import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types' import type { Var } from '@/app/components/workflow/types' import { act } from '@testing-library/react' -import { - BlockEnum, - InputVarType, -} from '@/app/components/workflow/types' -import { - createLexicalTestEditor, - expectInlineWrapperDom, -} from '../../test-helpers' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' +import { createLexicalTestEditor, expectInlineWrapperDom } from '../../test-helpers' import HITLInputBlockComponent from '../component' -import { - $createHITLInputNode, - HITLInputNode, -} from '../node' +import { $createHITLInputNode, HITLInputNode } from '../node' const createFormInput = (): FormInputItem => ({ type: InputVarType.paragraph, diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/pre-populate.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/pre-populate.spec.tsx index 990a7ced4a3a48..48c68280a3d54a 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/pre-populate.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/pre-populate.spec.tsx @@ -33,11 +33,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference let i18n: I18nType const renderWithI18n = (ui: ReactNode) => { - return render( - <I18nextProvider i18n={i18n}> - {ui} - </I18nextProvider>, - ) + return render(<I18nextProvider i18n={i18n}>{ui}</I18nextProvider>) } describe('PrePopulate', () => { @@ -74,13 +70,7 @@ describe('PrePopulate', () => { it('should show placeholder initially and switch out of placeholder on Tab key', async () => { const user = userEvent.setup() - renderWithI18n( - <PrePopulate - nodeId="node-1" - isVariable={false} - value="" - />, - ) + renderWithI18n(<PrePopulate nodeId="node-1" isVariable={false} value="" />) expect(screen.getByText('Static Content'))!.toBeInTheDocument() @@ -111,9 +101,7 @@ describe('PrePopulate', () => { ) } - renderWithI18n( - <Wrapper />, - ) + renderWithI18n(<Wrapper />) await user.clear(screen.getByRole('textbox')) await user.type(screen.getByRole('textbox'), 'next') @@ -147,11 +135,7 @@ describe('PrePopulate', () => { it('should pass variable type filter to picker that allows string number and secret', () => { renderWithI18n( - <PrePopulate - nodeId="node-3" - isVariable - valueSelector={['node-3', 'existing']} - />, + <PrePopulate nodeId="node-3" isVariable valueSelector={['node-3', 'existing']} />, ) const pickerProps = mockVarReferencePicker.mock.calls[0]![0] as VarReferencePickerProps diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/tag-label.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/tag-label.spec.tsx index d85224ca7490ff..25420b8b9ebfac 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/tag-label.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/tag-label.spec.tsx @@ -24,11 +24,7 @@ describe('TagLabel', () => { }) it('should render variable icon label when type is variable', () => { - const { container } = render( - <TagLabel type="variable"> - Variable - </TagLabel>, - ) + const { container } = render(<TagLabel type="variable">Variable</TagLabel>) expect(screen.getByText('Variable')).toBeInTheDocument() expect(container.querySelector('svg')).toBeInTheDocument() diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/type-switch.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/type-switch.spec.tsx index f487dbaae59f32..bbe406530de6ca 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/type-switch.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/type-switch.spec.tsx @@ -11,11 +11,11 @@ describe('TypeSwitch', () => { const user = userEvent.setup() const onIsVariableChange = vi.fn() - render( - <TypeSwitch isVariable={false} onIsVariableChange={onIsVariableChange} />, - ) + render(<TypeSwitch isVariable={false} onIsVariableChange={onIsVariableChange} />) - const trigger = screen.getByRole('button', { name: 'workflow.nodes.humanInput.insertInputField.useVarInstead' }) + const trigger = screen.getByRole('button', { + name: 'workflow.nodes.humanInput.insertInputField.useVarInstead', + }) await user.click(trigger) expect(onIsVariableChange).toHaveBeenCalledWith(true) @@ -25,11 +25,11 @@ describe('TypeSwitch', () => { const user = userEvent.setup() const onIsVariableChange = vi.fn() - render( - <TypeSwitch isVariable onIsVariableChange={onIsVariableChange} />, - ) + render(<TypeSwitch isVariable onIsVariableChange={onIsVariableChange} />) - const trigger = screen.getByRole('button', { name: 'workflow.nodes.humanInput.insertInputField.useConstantInstead' }) + const trigger = screen.getByRole('button', { + name: 'workflow.nodes.humanInput.insertInputField.useConstantInstead', + }) await user.click(trigger) expect(onIsVariableChange).toHaveBeenCalledWith(false) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/variable-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/variable-block.spec.tsx index b0338cb823f862..9e3620688f7fea 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/variable-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/variable-block.spec.tsx @@ -3,14 +3,9 @@ import type { WorkflowNodesMap } from '../../workflow-variable-block/node' import type { Var } from '@/app/components/workflow/types' import { LexicalComposer } from '@lexical/react/LexicalComposer' import { act, render, screen, waitFor } from '@testing-library/react' -import { - $getRoot, -} from 'lexical' +import { $getRoot } from 'lexical' import { Type } from '@/app/components/workflow/nodes/llm/types' -import { - BlockEnum, - VarType, -} from '@/app/components/workflow/types' +import { BlockEnum, VarType } from '@/app/components/workflow/types' import { CaptureEditorPlugin } from '../../test-utils' import { UPDATE_WORKFLOW_NODES_MAP } from '../../workflow-variable-block' import { HITLInputNode } from '../node' @@ -59,7 +54,7 @@ const hasErrorIcon = (container: HTMLElement) => { const renderVariableBlock = (props: { variables: string[] workflowNodesMap?: WorkflowNodesMap - getVarType?: (payload: { nodeId: string, valueSelector: string[] }) => Type + getVarType?: (payload: { nodeId: string; valueSelector: string[] }) => Type environmentVariables?: Var[] conversationVariables?: Var[] ragVariables?: Var[] diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx index 037ebd687108f1..f5d7429a5751e0 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx @@ -34,10 +34,7 @@ type HITLInputComponentUIProps = { environmentVariables?: Var[] conversationVariables?: Var[] ragVariables?: Var[] - getVarType?: (payload: { - nodeId: string - valueSelector: ValueSelector - }) => Type + getVarType?: (payload: { nodeId: string; valueSelector: ValueSelector }) => Type readonly?: boolean } @@ -61,26 +58,20 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({ const paragraphDefault = isParagraphFormInput(resolvedFormInput) ? resolvedFormInput.default : null - const [isShowEditModal, { - setTrue: showEditModal, - setFalse: hideEditModal, - }] = useBoolean(false) + const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false) useEffect(() => { - if (readonly) - hideEditModal() + if (readonly) hideEditModal() }, [hideEditModal, readonly]) // Lexical delegate the click make it unable to add click by the method of react const editBtnRef = useRef<HTMLDivElement>(null) useEffect(() => { const editBtn = editBtnRef.current - if (editBtn) - editBtn.addEventListener('click', showEditModal) + if (editBtn) editBtn.addEventListener('click', showEditModal) return () => { - if (editBtn) - editBtn.removeEventListener('click', showEditModal) + if (editBtn) editBtn.removeEventListener('click', showEditModal) } }, [showEditModal]) @@ -88,52 +79,52 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({ useEffect(() => { const removeBtn = removeBtnRef.current const removeHandler = () => onRemove(varName) - if (removeBtn) - removeBtn.addEventListener('click', removeHandler) + if (removeBtn) removeBtn.addEventListener('click', removeHandler) return () => { - if (removeBtn) - removeBtn.removeEventListener('click', removeHandler) + if (removeBtn) removeBtn.removeEventListener('click', removeHandler) } }, [onRemove, varName]) - const handleChange = useCallback((newPayload: FormInputItem) => { - if (newPayload.output_variable_name !== varName && unavailableVariableNames.includes(newPayload.output_variable_name)) - return + const handleChange = useCallback( + (newPayload: FormInputItem) => { + if ( + newPayload.output_variable_name !== varName && + unavailableVariableNames.includes(newPayload.output_variable_name) + ) + return - if (varName === newPayload.output_variable_name) - onChange(newPayload) - else - onRename(newPayload, varName) - hideEditModal() - }, [hideEditModal, onChange, onRename, unavailableVariableNames, varName]) + if (varName === newPayload.output_variable_name) onChange(newPayload) + else onRename(newPayload, varName) + hideEditModal() + }, + [hideEditModal, onChange, onRename, unavailableVariableNames, varName], + ) const isDefaultValueVariable = useMemo(() => { return paragraphDefault?.type === 'variable' }, [paragraphDefault]) const inputTypeLabel = useMemo(() => { if (isParagraphFormInput(resolvedFormInput)) - return t($ => $['variableConfig.paragraph'], { ns: 'appDebug' }) + return t(($) => $['variableConfig.paragraph'], { ns: 'appDebug' }) if (isSelectFormInput(resolvedFormInput)) - return t($ => $['variableConfig.select'], { ns: 'appDebug' }) + return t(($) => $['variableConfig.select'], { ns: 'appDebug' }) if (isFileFormInput(resolvedFormInput)) - return t($ => $['variableConfig.single-file'], { ns: 'appDebug' }) - return t($ => $['variableConfig.multi-files'], { ns: 'appDebug' }) + return t(($) => $['variableConfig.single-file'], { ns: 'appDebug' }) + return t(($) => $['variableConfig.multi-files'], { ns: 'appDebug' }) }, [resolvedFormInput, t]) const variableSelector = useMemo(() => { - if (isDefaultValueVariable) - return paragraphDefault?.selector || [] + if (isDefaultValueVariable) return paragraphDefault?.selector || [] if (isSelectFormInput(resolvedFormInput) && resolvedFormInput.option_source.type === 'variable') return resolvedFormInput.option_source.selector return null }, [isDefaultValueVariable, paragraphDefault?.selector, resolvedFormInput]) const summaryText = useMemo(() => { - if (isParagraphFormInput(resolvedFormInput)) - return paragraphDefault?.value || inputTypeLabel + if (isParagraphFormInput(resolvedFormInput)) return paragraphDefault?.value || inputTypeLabel if (isSelectFormInput(resolvedFormInput)) { if (resolvedFormInput.option_source.type === 'variable') - return t($ => $[`${i18nPrefix}.variable`], { ns: 'workflow' }) + return t(($) => $[`${i18nPrefix}.variable`], { ns: 'workflow' }) return resolvedFormInput.option_source.value.join(', ') || inputTypeLabel } @@ -141,9 +132,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({ }, [inputTypeLabel, paragraphDefault?.value, resolvedFormInput, t]) return ( - <div - className="group relative flex h-8 w-full items-center rounded-lg border-[1.5px] border-components-input-border-active bg-background-default-hover pr-0.5 pl-1.5 select-none" - > + <div className="group relative flex h-8 w-full items-center rounded-lg border-[1.5px] border-components-input-border-active bg-background-default-hover pr-0.5 pl-1.5 select-none"> <div className="absolute top-[-12px] left-2.5"> <div className="absolute bottom-1 h-[1.5px] w-full bg-background-default-subtle"></div> <div className="relative flex items-center space-x-0.5 px-1 text-text-accent-light-mode-only"> @@ -154,22 +143,20 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({ <div className="flex w-full items-center gap-x-2 pr-5"> <div className="min-w-0 grow"> - {variableSelector - ? ( - <VariableBlock - variables={variableSelector} - workflowNodesMap={workflowNodesMap} - getVarType={getVarType} - environmentVariables={environmentVariables} - conversationVariables={conversationVariables} - ragVariables={ragVariables} - /> - ) - : ( - <div className="max-w-full truncate system-xs-medium text-components-input-text-filled"> - {summaryText} - </div> - )} + {variableSelector ? ( + <VariableBlock + variables={variableSelector} + workflowNodesMap={workflowNodesMap} + getVarType={getVarType} + environmentVariables={environmentVariables} + conversationVariables={conversationVariables} + ragVariables={ragVariables} + /> + ) : ( + <div className="max-w-full truncate system-xs-medium text-components-input-text-filled"> + {summaryText} + </div> + )} </div> <div className="shrink-0 system-2xs-medium text-text-tertiary uppercase"> {inputTypeLabel} @@ -179,20 +166,17 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({ {!readonly && ( <div className="hidden h-full shrink-0 items-center space-x-1 group-hover:flex"> <div className="flex h-full items-center" ref={editBtnRef}> - <ActionButton - size="s" - aria-label={t($ => $['operation.edit'], { ns: 'common' })} - > + <ActionButton size="s" aria-label={t(($) => $['operation.edit'], { ns: 'common' })}> <span className="i-ri-edit-line size-4 text-text-tertiary" aria-hidden="true" /> </ActionButton> </div> <div className="flex h-full items-center" ref={removeBtnRef}> - <ActionButton - size="s" - aria-label={t($ => $['operation.remove'], { ns: 'common' })} - > - <span className="i-ri-delete-bin-line size-4 text-text-tertiary" aria-hidden="true" /> + <ActionButton size="s" aria-label={t(($) => $['operation.remove'], { ns: 'common' })}> + <span + className="i-ri-delete-bin-line size-4 text-text-tertiary" + aria-hidden="true" + /> </ActionButton> </div> </div> @@ -203,12 +187,10 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({ <Dialog open onOpenChange={(open) => { - if (!open) - hideEditModal() + if (!open) hideEditModal() }} > <DialogContent className="w-full max-w-[372px] overflow-hidden! border-none p-0! text-left align-middle"> - <InputField nodeId={nodeId} isEdit diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.tsx index 48f16b188cd7b4..8676b279c68f97 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.tsx @@ -21,10 +21,7 @@ type HITLInputComponentProps = { environmentVariables?: Var[] conversationVariables?: Var[] ragVariables?: Var[] - getVarType?: (payload: { - nodeId: string - valueSelector: ValueSelector - }) => Type + getVarType?: (payload: { nodeId: string; valueSelector: ValueSelector }) => Type readonly?: boolean } @@ -44,33 +41,46 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({ readonly, }) => { const [ref] = useSelectOrDelete(nodeKey, DELETE_HITL_INPUT_BLOCK_COMMAND) - const payload = formInputs.find(item => item.output_variable_name === varName) + const payload = formInputs.find((item) => item.output_variable_name === varName) const unavailableVariableNames = formInputs - .map(item => item.output_variable_name) - .filter(name => name !== varName) + .map((item) => item.output_variable_name) + .filter((name) => name !== varName) - const handleChange = useCallback((newPayload: FormInputItem) => { - if (newPayload.output_variable_name !== varName && unavailableVariableNames.includes(newPayload.output_variable_name)) - return + const handleChange = useCallback( + (newPayload: FormInputItem) => { + if ( + newPayload.output_variable_name !== varName && + unavailableVariableNames.includes(newPayload.output_variable_name) + ) + return - if (!payload) { - onChange([...formInputs, newPayload]) - return - } - if (payload?.output_variable_name !== newPayload.output_variable_name) { - onChange(produce(formInputs, (draft) => { - draft.splice(draft.findIndex(item => item.output_variable_name === payload?.output_variable_name), 1, newPayload) - })) - return - } - onChange(formInputs.map(item => item.output_variable_name === varName ? newPayload : item)) - }, [formInputs, onChange, payload, unavailableVariableNames, varName]) + if (!payload) { + onChange([...formInputs, newPayload]) + return + } + if (payload?.output_variable_name !== newPayload.output_variable_name) { + onChange( + produce(formInputs, (draft) => { + draft.splice( + draft.findIndex( + (item) => item.output_variable_name === payload?.output_variable_name, + ), + 1, + newPayload, + ) + }), + ) + return + } + onChange( + formInputs.map((item) => (item.output_variable_name === varName ? newPayload : item)), + ) + }, + [formInputs, onChange, payload, unavailableVariableNames, varName], + ) return ( - <div - ref={ref} - className="w-full pt-3 pb-1" - > + <div ref={ref} className="w-full pt-3 pb-1"> <ComponentUi nodeId={nodeId} varName={varName} diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx index d6431f81d6e926..c199320cf72a48 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx @@ -4,13 +4,7 @@ import type { Var } from '@/app/components/workflow/types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' import { $applyNodeReplacement } from 'lexical' -import { - memo, - useCallback, - useEffect, - useMemo, - useRef, -} from 'react' +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { HITL_INPUT_REG } from '@/config' import { decoratorTransform } from '../../utils' import { CustomTextNode } from '../custom-text/node' @@ -31,15 +25,23 @@ const HITLInputReplacementBlock = ({ }: HITLInputBlockType) => { const [editor] = useLexicalComposerContext() - const environmentVariables = useMemo(() => variables?.find(o => o.nodeId === 'env')?.vars || [], [variables]) - const conversationVariables = useMemo(() => variables?.find(o => o.nodeId === 'conversation')?.vars || [], [variables]) - const ragVariables = useMemo(() => variables?.reduce<Var[]>((acc, curr) => { - if (curr.nodeId === 'rag') - acc.push(...curr.vars) - else - acc.push(...curr.vars.filter(v => v.isRagVariable)) - return acc - }, []), [variables]) + const environmentVariables = useMemo( + () => variables?.find((o) => o.nodeId === 'env')?.vars || [], + [variables], + ) + const conversationVariables = useMemo( + () => variables?.find((o) => o.nodeId === 'conversation')?.vars || [], + [variables], + ) + const ragVariables = useMemo( + () => + variables?.reduce<Var[]>((acc, curr) => { + if (curr.nodeId === 'rag') acc.push(...curr.vars) + else acc.push(...curr.vars.filter((v) => v.isRagVariable)) + return acc + }, []), + [variables], + ) const latestConfigRef = useRef({ nodeId, formInputs, @@ -73,7 +75,10 @@ const HITLInputReplacementBlock = ({ }, [editor]) const createHITLInputBlockNode = useCallback((textNode: TextNode): HITLInputNode => { - const varName = textNode.getTextContent().split('.')[1]!.replace(/#\}\}$/, '') + const varName = textNode + .getTextContent() + .split('.')[1]! + .replace(/#\}\}$/, '') const { nodeId, formInputs, @@ -88,27 +93,28 @@ const HITLInputReplacementBlock = ({ readonly, } = latestConfigRef.current - return $applyNodeReplacement($createHITLInputNode( - varName, - nodeId, - formInputs || [], - onFormInputsChange!, - onFormInputItemRename, - onFormInputItemRemove!, - workflowNodesMap, - getVarType, - environmentVariables, - conversationVariables, - ragVariables, - readonly, - )) + return $applyNodeReplacement( + $createHITLInputNode( + varName, + nodeId, + formInputs || [], + onFormInputsChange!, + onFormInputItemRename, + onFormInputItemRemove!, + workflowNodesMap, + getVarType, + environmentVariables, + conversationVariables, + ragVariables, + readonly, + ), + ) }, []) const getMatch = useCallback((text: string) => { const matchArr = REGEX.exec(text) - if (matchArr === null) - return null + if (matchArr === null) return null const startOffset = matchArr.index const endOffset = startOffset + matchArr[0].length @@ -121,7 +127,9 @@ const HITLInputReplacementBlock = ({ useEffect(() => { REGEX.lastIndex = 0 return mergeRegister( - editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createHITLInputBlockNode)), + editor.registerNodeTransform(CustomTextNode, (textNode) => + decoratorTransform(textNode, getMatch, createHITLInputBlockNode), + ), ) }, [editor, getMatch, createHITLInputBlockNode]) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx index 73cbbc3ef5e7a3..0a0c83268f2b26 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx @@ -1,113 +1,100 @@ import type { HITLInputBlockType } from '../../types' -import type { - HITLNodeProps, -} from './node' +import type { HITLNodeProps } from './node' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { - $insertNodes, - $nodesOfType, - COMMAND_PRIORITY_EDITOR, - createCommand, -} from 'lexical' -import { - memo, - useEffect, -} from 'react' +import { $insertNodes, $nodesOfType, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' +import { memo, useEffect } from 'react' import { CustomTextNode } from '../custom-text/node' import { UPDATE_WORKFLOW_NODES_MAP as WORKFLOW_UPDATE_WORKFLOW_NODES_MAP } from '../workflow-variable-block' -import { - $createHITLInputNode, - HITLInputNode, -} from './node' +import { $createHITLInputNode, HITLInputNode } from './node' export const INSERT_HITL_INPUT_BLOCK_COMMAND = createCommand('INSERT_HITL_INPUT_BLOCK_COMMAND') export const DELETE_HITL_INPUT_BLOCK_COMMAND = createCommand('DELETE_HITL_INPUT_BLOCK_COMMAND') export const UPDATE_WORKFLOW_NODES_MAP = WORKFLOW_UPDATE_WORKFLOW_NODES_MAP -const HITLInputBlock = memo(({ - onInsert, - onDelete, - workflowNodesMap = {}, - variables: workflowAvailableVariables, - getVarType, - readonly, -}: HITLInputBlockType) => { - const [editor] = useLexicalComposerContext() +const HITLInputBlock = memo( + ({ + onInsert, + onDelete, + workflowNodesMap = {}, + variables: workflowAvailableVariables, + getVarType, + readonly, + }: HITLInputBlockType) => { + const [editor] = useLexicalComposerContext() - useEffect(() => { - editor.update(() => { - editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, { - workflowNodesMap: workflowNodesMap || {}, - availableVariables: workflowAvailableVariables || [], + useEffect(() => { + editor.update(() => { + editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, { + workflowNodesMap: workflowNodesMap || {}, + availableVariables: workflowAvailableVariables || [], + }) }) - }) - }, [editor, workflowNodesMap, workflowAvailableVariables]) + }, [editor, workflowNodesMap, workflowAvailableVariables]) - useEffect(() => { - editor.update(() => { - $nodesOfType(HITLInputNode).forEach((node) => { - node.setReadonly(readonly) + useEffect(() => { + editor.update(() => { + $nodesOfType(HITLInputNode).forEach((node) => { + node.setReadonly(readonly) + }) }) - }) - }, [editor, readonly]) + }, [editor, readonly]) - useEffect(() => { - if (!editor.hasNodes([HITLInputNode])) - throw new Error('HITLInputBlockPlugin: HITLInputBlock not registered on editor') - return mergeRegister( - editor.registerCommand( - INSERT_HITL_INPUT_BLOCK_COMMAND, - (nodeProps: HITLNodeProps) => { - const { - variableName, - nodeId, - formInputs, - onFormInputsChange, - onFormInputItemRename, - onFormInputItemRemove, - } = nodeProps - const currentHITLNode = $createHITLInputNode( - variableName, - nodeId, - formInputs, - onFormInputsChange, - onFormInputItemRename, - onFormInputItemRemove, - workflowNodesMap, - getVarType, - undefined, - undefined, - undefined, - readonly, - ) - const prev = new CustomTextNode('\n') - $insertNodes([prev]) - $insertNodes([currentHITLNode]) - const next = new CustomTextNode('\n') - $insertNodes([next]) - if (onInsert) - onInsert() + useEffect(() => { + if (!editor.hasNodes([HITLInputNode])) + throw new Error('HITLInputBlockPlugin: HITLInputBlock not registered on editor') + return mergeRegister( + editor.registerCommand( + INSERT_HITL_INPUT_BLOCK_COMMAND, + (nodeProps: HITLNodeProps) => { + const { + variableName, + nodeId, + formInputs, + onFormInputsChange, + onFormInputItemRename, + onFormInputItemRemove, + } = nodeProps + const currentHITLNode = $createHITLInputNode( + variableName, + nodeId, + formInputs, + onFormInputsChange, + onFormInputItemRename, + onFormInputItemRemove, + workflowNodesMap, + getVarType, + undefined, + undefined, + undefined, + readonly, + ) + const prev = new CustomTextNode('\n') + $insertNodes([prev]) + $insertNodes([currentHITLNode]) + const next = new CustomTextNode('\n') + $insertNodes([next]) + if (onInsert) onInsert() - return true - }, - COMMAND_PRIORITY_EDITOR, - ), - editor.registerCommand( - DELETE_HITL_INPUT_BLOCK_COMMAND, - () => { - if (onDelete) - onDelete() + return true + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DELETE_HITL_INPUT_BLOCK_COMMAND, + () => { + if (onDelete) onDelete() - return true - }, - COMMAND_PRIORITY_EDITOR, - ), - ) - }, [editor, onInsert, onDelete, workflowNodesMap, getVarType, readonly]) + return true + }, + COMMAND_PRIORITY_EDITOR, + ), + ) + }, [editor, onInsert, onDelete, workflowNodesMap, getVarType, readonly]) - return null -}) + return null + }, +) HITLInputBlock.displayName = 'HITLInputBlock' diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx index c2601ebdca3c7e..f6a70e66f71114 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx @@ -1,5 +1,9 @@ import type { Item as TypeSelectItem } from '@/app/components/app/configuration/config-var/config-modal/type-select' -import type { FormInputItem, FormInputItemDefault, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types' +import type { + FormInputItem, + FormInputItemDefault, + ParagraphFormInput, +} from '@/app/components/workflow/nodes/human-input/types' import type { UploadFileSetting, ValueSelector } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' @@ -45,23 +49,25 @@ const InputField: React.FC<InputFieldProps> = ({ onCancel, }) => { const { t } = useTranslation() - const [tempPayload, setTempPayload] = useState<FormInputItem>(() => payload || createDefaultParagraphFormInput()) + const [tempPayload, setTempPayload] = useState<FormInputItem>( + () => payload || createDefaultParagraphFormInput(), + ) const fieldTypeItems = useMemo<TypeSelectItem[]>(() => { return [ { - name: t($ => $['variableConfig.paragraph'], { ns: 'appDebug' }), + name: t(($) => $['variableConfig.paragraph'], { ns: 'appDebug' }), value: InputVarType.paragraph, }, { - name: t($ => $['variableConfig.select'], { ns: 'appDebug' }), + name: t(($) => $['variableConfig.select'], { ns: 'appDebug' }), value: InputVarType.select, }, { - name: t($ => $['variableConfig.single-file'], { ns: 'appDebug' }), + name: t(($) => $['variableConfig.single-file'], { ns: 'appDebug' }), value: InputVarType.singleFile, }, { - name: t($ => $['variableConfig.multi-files'], { ns: 'appDebug' }), + name: t(($) => $['variableConfig.multi-files'], { ns: 'appDebug' }), value: InputVarType.multiFiles, }, ] @@ -77,26 +83,21 @@ const InputField: React.FC<InputFieldProps> = ({ return createDefaultParagraphFormInput(tempPayload.output_variable_name) }, [tempPayload]) const unavailableVariableNameSet = useMemo(() => { - return new Set(unavailableVariableNames.map(name => name.trim()).filter(Boolean)) + return new Set(unavailableVariableNames.map((name) => name.trim()).filter(Boolean)) }, [unavailableVariableNames]) const variableNameError = useMemo(() => { const name = tempPayload.output_variable_name.trim() - if (!name) - return null - if (name.includes(' ')) - return 'variableNameInvalid' - if (!/^[a-z_]\w{0,29}$/.test(name)) - return 'variableNameInvalid' - if (unavailableVariableNameSet.has(name)) - return 'variableNameDuplicated' + if (!name) return null + if (name.includes(' ')) return 'variableNameInvalid' + if (!/^[a-z_]\w{0,29}$/.test(name)) return 'variableNameInvalid' + if (unavailableVariableNameSet.has(name)) return 'variableNameDuplicated' return null }, [tempPayload.output_variable_name, unavailableVariableNameSet]) const nameValid = useMemo(() => { return !!tempPayload.output_variable_name.trim() && !variableNameError }, [tempPayload.output_variable_name, variableNameError]) const handleSave = useCallback(() => { - if (!nameValid) - return + if (!nameValid) return if (isFileListFormInput(tempPayload)) { const value = tempPayload.number_limits ?? 5 if (!Number.isFinite(value)) { @@ -110,30 +111,32 @@ const InputField: React.FC<InputFieldProps> = ({ onChange(tempPayload) }, [nameValid, onChange, tempPayload]) const handleTypeChange = useCallback((item: TypeSelectItem) => { - setTempPayload(prev => createDefaultFormInputByType(item.value as FormInputItem['type'], prev.output_variable_name)) + setTempPayload((prev) => + createDefaultFormInputByType(item.value as FormInputItem['type'], prev.output_variable_name), + ) }, []) - const handleDefaultValueChange = useCallback((key: keyof FormInputItemDefault) => { - return (value: ValueSelector | string) => { - const nextValue = produce(paragraphPayload, (draft) => { - if (key === 'selector') { - draft.default.type = 'variable' - draft.default.selector = value as ValueSelector - } - else if (key === 'value') { - draft.default.type = 'constant' - draft.default.value = value as string - } - else if (key === 'type') { - draft.default.type = value as 'constant' | 'variable' - } - }) - setTempPayload(nextValue) - } - }, [paragraphPayload]) + const handleDefaultValueChange = useCallback( + (key: keyof FormInputItemDefault) => { + return (value: ValueSelector | string) => { + const nextValue = produce(paragraphPayload, (draft) => { + if (key === 'selector') { + draft.default.type = 'variable' + draft.default.selector = value as ValueSelector + } else if (key === 'value') { + draft.default.type = 'constant' + draft.default.value = value as string + } else if (key === 'type') { + draft.default.type = value as 'constant' | 'variable' + } + }) + setTempPayload(nextValue) + } + }, + [paragraphPayload], + ) const handleSelectOptionsChange = useCallback((options: string[]) => { setTempPayload((prev) => { - if (!isSelectFormInput(prev)) - return prev + if (!isSelectFormInput(prev)) return prev return { ...prev, @@ -147,8 +150,7 @@ const InputField: React.FC<InputFieldProps> = ({ }, []) const handleSelectOptionSourceTypeChange = useCallback((isVariable: boolean) => { setTempPayload((prev) => { - if (!isSelectFormInput(prev)) - return prev + if (!isSelectFormInput(prev)) return prev return { ...prev, @@ -161,8 +163,7 @@ const InputField: React.FC<InputFieldProps> = ({ }, []) const handleSelectOptionSourceSelectorChange = useCallback((selector: ValueSelector | string) => { setTempPayload((prev) => { - if (!isSelectFormInput(prev)) - return prev + if (!isSelectFormInput(prev)) return prev return { ...prev, @@ -176,8 +177,7 @@ const InputField: React.FC<InputFieldProps> = ({ }, []) const handleFilePayloadChange = useCallback((payload: UploadFileSetting) => { setTempPayload((prev) => { - if (!isFileFormInput(prev)) - return prev + if (!isFileFormInput(prev)) return prev return { ...prev, @@ -189,8 +189,7 @@ const InputField: React.FC<InputFieldProps> = ({ }, []) const handleFileListPayloadChange = useCallback((payload: UploadFileSetting) => { setTempPayload((prev) => { - if (!isFileListFormInput(prev)) - return prev + if (!isFileListFormInput(prev)) return prev return { ...prev, @@ -218,12 +217,14 @@ const InputField: React.FC<InputFieldProps> = ({ return ( <div className="flex max-h-[var(--shortcut-popup-max-height,80dvh)] w-[372px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"> <div className="shrink-0 p-3 pb-2"> - <div className="system-md-semibold text-text-primary">{t($ => $[`${i18nPrefix}.title`], { ns: 'workflow' })}</div> + <div className="system-md-semibold text-text-primary"> + {t(($) => $[`${i18nPrefix}.title`], { ns: 'workflow' })} + </div> </div> <div className="min-h-0 flex-1 overflow-y-auto p-3 pt-0 pb-0"> <div className="mt-3"> <div className="system-xs-medium text-text-secondary"> - {t($ => $[`${i18nPrefix}.fieldType`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.fieldType`], { ns: 'workflow' })} </div> <div className="mt-1.5"> <TypeSelector @@ -235,28 +236,28 @@ const InputField: React.FC<InputFieldProps> = ({ </div> <div className="mt-3"> <div className="system-xs-medium text-text-secondary"> - {t($ => $[`${i18nPrefix}.saveResponseAs`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.saveResponseAs`], { ns: 'workflow' })} <span className="relative system-xs-regular text-text-destructive-secondary">*</span> </div> <Input className="mt-1.5" - placeholder={t($ => $[`${i18nPrefix}.saveResponseAsPlaceholder`], { ns: 'workflow' })} + placeholder={t(($) => $[`${i18nPrefix}.saveResponseAsPlaceholder`], { ns: 'workflow' })} value={tempPayload.output_variable_name} onChange={(e) => { - setTempPayload(prev => ({ ...prev, output_variable_name: e.target.value })) + setTempPayload((prev) => ({ ...prev, output_variable_name: e.target.value })) }} autoFocus /> {tempPayload.output_variable_name && variableNameError && ( <div className="mt-1 px-1 system-xs-regular text-text-destructive-secondary"> - {t($ => $[`${i18nPrefix}.${variableNameError}`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.${variableNameError}`], { ns: 'workflow' })} </div> )} </div> {isParagraphFormInput(tempPayload) && ( <div className="mt-4"> <div className="mb-1.5 system-xs-medium text-text-secondary"> - {t($ => $[`${i18nPrefix}.prePopulateField`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.prePopulateField`], { ns: 'workflow' })} </div> <PrePopulate isVariable={paragraphPayload.default.type === 'variable'} @@ -274,39 +275,41 @@ const InputField: React.FC<InputFieldProps> = ({ {isSelectFormInput(tempPayload) && ( <div className="mt-4"> <div className="mb-1.5 system-xs-medium text-text-secondary"> - {t($ => $[`${i18nPrefix}.options`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.options`], { ns: 'workflow' })} </div> - {tempPayload.option_source.type === 'variable' - ? ( - <div className="relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal px-3 pt-2 pb-8"> - <VarReferencePicker - nodeId={nodeId} - value={tempPayload.option_source.selector} - onChange={handleSelectOptionSourceSelectorChange} - readonly={false} - isJustShowValue - filterVar={varPayload => varPayload.type === VarType.arrayString} - /> - <TypeSwitch - className="absolute bottom-1 left-1.5" - isVariable - onIsVariableChange={handleSelectOptionSourceTypeChange} - /> - </div> - ) - : ( - <div className={cn('rounded-lg border border-transparent bg-components-input-bg-normal p-2')}> - <ConfigSelect - options={tempPayload.option_source.value} - onChange={handleSelectOptionsChange} - /> - <TypeSwitch - className="mt-2" - isVariable={false} - onIsVariableChange={handleSelectOptionSourceTypeChange} - /> - </div> + {tempPayload.option_source.type === 'variable' ? ( + <div className="relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal px-3 pt-2 pb-8"> + <VarReferencePicker + nodeId={nodeId} + value={tempPayload.option_source.selector} + onChange={handleSelectOptionSourceSelectorChange} + readonly={false} + isJustShowValue + filterVar={(varPayload) => varPayload.type === VarType.arrayString} + /> + <TypeSwitch + className="absolute bottom-1 left-1.5" + isVariable + onIsVariableChange={handleSelectOptionSourceTypeChange} + /> + </div> + ) : ( + <div + className={cn( + 'rounded-lg border border-transparent bg-components-input-bg-normal p-2', )} + > + <ConfigSelect + options={tempPayload.option_source.value} + onChange={handleSelectOptionsChange} + /> + <TypeSwitch + className="mt-2" + isVariable={false} + onIsVariableChange={handleSelectOptionSourceTypeChange} + /> + </div> + )} </div> )} {isFileFormInput(tempPayload) && ( @@ -336,32 +339,25 @@ const InputField: React.FC<InputFieldProps> = ({ </div> <div className="shrink-0 bg-components-panel-bg p-3"> <div className="flex justify-end space-x-2"> - <Button onClick={onCancel}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - {isEdit - ? ( - <Button - variant="primary" - onClick={handleSave} - disabled={!nameValid} - > - {t($ => $['operation.save'], { ns: 'common' })} - </Button> - ) - : ( - <Button - className="flex" - variant="primary" - disabled={!nameValid} - onClick={handleSave} - > - <span className="mr-1">{t($ => $[`${i18nPrefix}.insert`], { ns: 'workflow' })}</span> - <KbdGroup> - {['Mod', 'Enter'].map(key => ( - <Kbd key={key} color="white">{formatForDisplay(key)}</Kbd> - ))} - </KbdGroup> - </Button> - )} + <Button onClick={onCancel}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> + {isEdit ? ( + <Button variant="primary" onClick={handleSave} disabled={!nameValid}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> + ) : ( + <Button className="flex" variant="primary" disabled={!nameValid} onClick={handleSave}> + <span className="mr-1"> + {t(($) => $[`${i18nPrefix}.insert`], { ns: 'workflow' })} + </span> + <KbdGroup> + {['Mod', 'Enter'].map((key) => ( + <Kbd key={key} color="white"> + {formatForDisplay(key)} + </Kbd> + ))} + </KbdGroup> + </Button> + )} </div> </div> </div> diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.tsx index e6b74f2f2633a9..5e767637101c1c 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.tsx @@ -169,7 +169,13 @@ export class HITLInputNode extends DecoratorNode<React.JSX.Element> { override createDOM(): HTMLElement { const div = document.createElement('div') - div.classList.add('inline-flex', 'w-[calc(100%-1px)]', 'items-center', 'align-middle', 'support-drag') + div.classList.add( + 'inline-flex', + 'w-[calc(100%-1px)]', + 'items-center', + 'align-middle', + 'support-drag', + ) return div } diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.tsx index 6265f76dd69382..8195169bd04cfd 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.tsx @@ -34,24 +34,27 @@ type PlaceholderProps = { } onTypeClick: (isVariable: boolean) => void } -const Placeholder = ({ - varPickerProps, - onTypeClick, -}: PlaceholderProps) => { +const Placeholder = ({ varPickerProps, onTypeClick }: PlaceholderProps) => { const { t } = useTranslation() return ( <div className="mt-1 h-[80px] rounded-lg bg-components-input-bg-normal px-3 pt-2 system-sm-regular text-text-tertiary"> <div className="flex flex-wrap items-center leading-5"> <Trans - i18nKey={$ => $[`${i18nPrefix}.prePopulateFieldPlaceholder`]} + i18nKey={($) => $[`${i18nPrefix}.prePopulateFieldPlaceholder`]} ns="workflow" components={{ - staticContent: <TagLabel type="edit" className="mx-1" onClick={() => onTypeClick(false)}>{t($ => $[`${i18nPrefix}.staticContent`], { ns: 'workflow' })}</TagLabel>, + staticContent: ( + <TagLabel type="edit" className="mx-1" onClick={() => onTypeClick(false)}> + {t(($) => $[`${i18nPrefix}.staticContent`], { ns: 'workflow' })} + </TagLabel> + ), variable: ( <VarReferencePicker {...varPickerProps} trigger={ - <TagLabel type="variable" className="mx-1">{t($ => $[`${i18nPrefix}.variable`], { ns: 'workflow' })}</TagLabel> + <TagLabel type="variable" className="mx-1"> + {t(($) => $[`${i18nPrefix}.variable`], { ns: 'workflow' })} + </TagLabel> } /> ), @@ -73,10 +76,13 @@ const PrePopulate: FC<Props> = ({ }) => { const { t } = useTranslation() const [onPlaceholderClicked, setOnPlaceholderClicked] = useState(false) - const handleTypeChange = useCallback((isVar: boolean) => { - setOnPlaceholderClicked(true) - onIsVariableChange?.(isVar) - }, [onIsVariableChange]) + const handleTypeChange = useCallback( + (isVar: boolean) => { + setOnPlaceholderClicked(true) + onIsVariableChange?.(isVar) + }, + [onIsVariableChange], + ) const [isFocus, setIsFocus] = useState(false) @@ -90,7 +96,8 @@ const PrePopulate: FC<Props> = ({ }, } - const isShowPlaceholder = !onPlaceholderClicked && (isVariable ? (!valueSelector || valueSelector.length === 0) : !value) + const isShowPlaceholder = + !onPlaceholderClicked && (isVariable ? !valueSelector || valueSelector.length === 0 : !value) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -111,10 +118,7 @@ const PrePopulate: FC<Props> = ({ if (isVariable) { return ( <div className="relative h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal px-3 pt-2"> - <VarReferencePicker - {...varPickerProps} - isJustShowValue - /> + <VarReferencePicker {...varPickerProps} isJustShowValue /> <TypeSwitch className="absolute bottom-1 left-1.5" isVariable={isVariable} @@ -124,12 +128,17 @@ const PrePopulate: FC<Props> = ({ ) } return ( - <div className={cn('relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal pb-1', isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs')}> + <div + className={cn( + 'relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal pb-1', + isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs', + )} + > <Textarea - aria-label={t($ => $[`${i18nPrefix}.staticContent`], { ns: 'workflow' })} + aria-label={t(($) => $[`${i18nPrefix}.staticContent`], { ns: 'workflow' })} value={value || ''} className="h-[43px] min-h-[43px] rounded-none border-none bg-transparent px-3 hover:bg-transparent focus:bg-transparent focus:shadow-none" - onValueChange={value => onValueChange?.(value)} + onValueChange={(value) => onValueChange?.(value)} onFocus={() => { setOnPlaceholderClicked(true) setIsFocus(true) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/tag-label.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/tag-label.tsx index 4e0786348a5bac..d55ec6bff6fdef 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/tag-label.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/tag-label.tsx @@ -12,16 +12,14 @@ type Props = Readonly<{ onClick?: () => void }> -const TagLabel: FC<Props> = ({ - type, - children, - className, - onClick, -}) => { +const TagLabel: FC<Props> = ({ type, children, className, onClick }) => { const Icon = type === 'edit' ? RiEditLine : Variable02 return ( <div - className={cn('inline-flex h-5 cursor-pointer items-center space-x-1 rounded-md bg-components-button-secondary-bg px-1 text-text-accent', className)} + className={cn( + 'inline-flex h-5 cursor-pointer items-center space-x-1 rounded-md bg-components-button-secondary-bg px-1 text-text-accent', + className, + )} onClick={onClick} > <Icon className="size-3.5" /> diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.tsx index a24559623fedac..dcd120b533ccec 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.tsx @@ -11,20 +11,27 @@ type Props = Readonly<{ onIsVariableChange?: (isVariable: boolean) => void }> -const TypeSwitch: FC<Props> = ({ - className, - isVariable, - onIsVariableChange, -}) => { +const TypeSwitch: FC<Props> = ({ className, isVariable, onIsVariableChange }) => { const { t } = useTranslation() return ( <button type="button" - className={cn('inline-flex h-6 cursor-pointer items-center space-x-1 rounded-md border-none bg-transparent py-0 pr-2 pl-1.5 text-left text-text-tertiary select-none hover:bg-components-button-ghost-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', className)} + className={cn( + 'inline-flex h-6 cursor-pointer items-center space-x-1 rounded-md border-none bg-transparent py-0 pr-2 pl-1.5 text-left text-text-tertiary select-none hover:bg-components-button-ghost-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + className, + )} onClick={() => onIsVariableChange?.(!isVariable)} > <Variable02 className="size-3.5" aria-hidden="true" /> - <div className="system-xs-medium">{t($ => $[`nodes.humanInput.insertInputField.${isVariable ? 'useConstantInstead' : 'useVarInstead'}`], { ns: 'workflow' })}</div> + <div className="system-xs-medium"> + {t( + ($) => + $[ + `nodes.humanInput.insertInputField.${isVariable ? 'useConstantInstead' : 'useVarInstead'}` + ], + { ns: 'workflow' }, + )} + </div> </button> ) } diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx index 9a9687f90a9d21..140e4b71024504 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx @@ -1,18 +1,15 @@ import type { UpdateWorkflowNodesMapPayload } from '../workflow-variable-block' import type { WorkflowNodesMap } from '../workflow-variable-block/node' import type { ValueSelector, Var } from '@/app/components/workflow/types' -import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card' +import { + PreviewCard, + PreviewCardContent, + PreviewCardTrigger, +} from '@langgenius/dify-ui/preview-card' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { - COMMAND_PRIORITY_EDITOR, -} from 'lexical' -import { - memo, - useEffect, - useMemo, - useState, -} from 'react' +import { COMMAND_PRIORITY_EDITOR } from 'lexical' +import { memo, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { isConversationVar, @@ -22,9 +19,7 @@ import { isSystemVar, } from '@/app/components/workflow/nodes/_base/components/variable/utils' import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel' -import { - VariableLabelInEditor, -} from '@/app/components/workflow/nodes/_base/components/variable/variable-label' +import { VariableLabelInEditor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { Type } from '@/app/components/workflow/nodes/llm/types' import { isExceptionVariable } from '@/app/components/workflow/utils' import { UPDATE_WORKFLOW_NODES_MAP } from '../workflow-variable-block' @@ -36,10 +31,7 @@ type HITLInputVariableBlockComponentProps = { environmentVariables?: Var[] conversationVariables?: Var[] ragVariables?: Var[] - getVarType?: (payload: { - nodeId: string - valueSelector: ValueSelector - }) => Type + getVarType?: (payload: { nodeId: string; valueSelector: ValueSelector }) => Type } const HITLInputVariableBlockComponent = ({ @@ -55,14 +47,13 @@ const HITLInputVariableBlockComponent = ({ const variablesLength = variables.length const isRagVar = isRagVariableVar(variables) const isShowAPart = variablesLength > 2 && !isRagVar - const varName = ( - () => { - const isSystem = isSystemVar(variables) - const varName = variables[variablesLength - 1] - return `${isSystem ? 'sys.' : ''}${varName}` - } - )() - const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState<WorkflowNodesMap>(workflowNodesMap) + const varName = (() => { + const isSystem = isSystemVar(variables) + const varName = variables[variablesLength - 1] + return `${isSystem ? 'sys.' : ''}${varName}` + })() + const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = + useState<WorkflowNodesMap>(workflowNodesMap) const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]!] const isException = isExceptionVariable(varName, node?.type) @@ -71,22 +62,26 @@ const HITLInputVariableBlockComponent = ({ const isEnv = isENV(variables) const isChatVar = isConversationVar(variables) const isGlobal = isGlobalVar(variables) - if (isGlobal) - return true + if (isGlobal) return true if (isEnv) { if (environmentVariables) - variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) - } - else if (isChatVar) { + variableValid = environmentVariables.some( + (v) => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`, + ) + } else if (isChatVar) { if (conversationVariables) - variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) - } - else if (isRagVar) { + variableValid = conversationVariables.some( + (v) => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`, + ) + } else if (isRagVar) { if (ragVariables) - variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`) - } - else { + variableValid = ragVariables.some( + (v) => + v.variable === + `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`, + ) + } else { variableValid = !!node } return variableValid @@ -114,13 +109,14 @@ const HITLInputVariableBlockComponent = ({ nodeTitle={node?.title} variables={variables} isExceptionVariable={isException} - errorMsg={!variableValid ? t($ => $['errorMsg.invalidVariable'], { ns: 'workflow' }) : undefined} + errorMsg={ + !variableValid ? t(($) => $['errorMsg.invalidVariable'], { ns: 'workflow' }) : undefined + } notShowFullPath={isShowAPart} /> ) - if (!node || !isShowAPart) - return Item + if (!node || !isShowAPart) return Item return ( <PreviewCard> @@ -129,12 +125,14 @@ const HITLInputVariableBlockComponent = ({ <VarFullPathPanel nodeName={node.title} path={variables.slice(1)} - varType={getVarType - ? getVarType({ - nodeId: variables[0]!, - valueSelector: variables, - }) - : Type.string} + varType={ + getVarType + ? getVarType({ + nodeId: variables[0]!, + valueSelector: variables, + }) + : Type.string + } nodeType={node?.type} /> </PreviewCardContent> diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/component.spec.tsx index 8a777be0960ae2..fd124ac8cc37cc 100644 --- a/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/component.spec.tsx @@ -23,11 +23,7 @@ const renderComponent = (props?: { withNode?: boolean onParentClick?: () => void }) => { - const { - isSelected = false, - withNode = true, - onParentClick, - } = props ?? {} + const { isSelected = false, withNode = true, onParentClick } = props ?? {} mockUseSelectOrDelete.mockReturnValue(createHookReturn(isSelected)) diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/index.spec.tsx index 73af3ee857c2f5..12c0cab2e0efe9 100644 --- a/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/index.spec.tsx @@ -16,16 +16,11 @@ import { LastRunBlockNode, } from '../index' -const renderLastRunBlock = (props?: { - onInsert?: () => void - onDelete?: () => void -}) => { +const renderLastRunBlock = (props?: { onInsert?: () => void; onDelete?: () => void }) => { return renderLexicalEditor({ namespace: 'last-run-block-plugin-test', nodes: [CustomTextNode, LastRunBlockNode], - children: ( - <LastRunBlock {...(props ?? {})} /> - ), + children: <LastRunBlock {...(props ?? {})} />, }) } diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/last-run-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/last-run-block-replacement-block.spec.tsx index c0adc2275afec3..49c701cf4641be 100644 --- a/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/last-run-block-replacement-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/last-run-block-replacement-block.spec.tsx @@ -11,15 +11,11 @@ import { import { LastRunBlockNode } from '../index' import LastRunReplacementBlock from '../last-run-block-replacement-block' -const renderReplacementPlugin = (props?: { - onInsert?: () => void -}) => { +const renderReplacementPlugin = (props?: { onInsert?: () => void }) => { return renderLexicalEditor({ namespace: 'last-run-block-replacement-plugin-test', nodes: [CustomTextNode, LastRunBlockNode], - children: ( - <LastRunReplacementBlock {...(props ?? {})} /> - ), + children: <LastRunReplacementBlock {...(props ?? {})} />, }) } @@ -35,7 +31,11 @@ describe('LastRunReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, `prefix ${LAST_RUN_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text)) + setEditorRootText( + editor, + `prefix ${LAST_RUN_PLACEHOLDER_TEXT} suffix`, + (text) => new CustomTextNode(text), + ) await waitFor(() => { expect(getNodeCount(editor, LastRunBlockNode)).toBe(1) @@ -49,7 +49,11 @@ describe('LastRunReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, 'plain text without placeholder', text => new CustomTextNode(text)) + setEditorRootText( + editor, + 'plain text without placeholder', + (text) => new CustomTextNode(text), + ) await waitFor(() => { expect(getNodeCount(editor, LastRunBlockNode)).toBe(0) @@ -62,7 +66,7 @@ describe('LastRunReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, LAST_RUN_PLACEHOLDER_TEXT, text => new CustomTextNode(text)) + setEditorRootText(editor, LAST_RUN_PLACEHOLDER_TEXT, (text) => new CustomTextNode(text)) await waitFor(() => { expect(getNodeCount(editor, LastRunBlockNode)).toBe(1) diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/node.spec.tsx index 389e8cb1127e33..1109a25e0f0386 100644 --- a/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/node.spec.tsx @@ -1,13 +1,7 @@ import { act } from '@testing-library/react' -import { - createLexicalTestEditor, - expectInlineWrapperDom, -} from '../../test-helpers' +import { createLexicalTestEditor, expectInlineWrapperDom } from '../../test-helpers' import LastRunBlockComponent from '../component' -import { - $createLastRunBlockNode, - LastRunBlockNode, -} from '../node' +import { $createLastRunBlockNode, LastRunBlockNode } from '../node' const createTestEditor = () => { return createLexicalTestEditor('last-run-block-node-test', [LastRunBlockNode]) diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/component.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/component.tsx index 93ac0a4d71e0cf..d534ec83d16b00 100644 --- a/web/app/components/base/prompt-editor/plugins/last-run-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/last-run-block/component.tsx @@ -10,9 +10,7 @@ type Props = Readonly<{ nodeKey: string }> -const LastRunBlockComponent: FC<Props> = ({ - nodeKey, -}) => { +const LastRunBlockComponent: FC<Props> = ({ nodeKey }) => { const [editor] = useLexicalComposerContext() const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_LAST_RUN_COMMAND) @@ -25,7 +23,9 @@ const LastRunBlockComponent: FC<Props> = ({ <div className={cn( 'group/wrap relative mx-0.5 flex h-[18px] items-center rounded-[5px] border pr-[3px] pl-0.5 text-text-accent select-none hover:border-state-accent-solid hover:bg-state-accent-hover', - isSelected ? 'border-state-accent-solid bg-state-accent-hover' : 'border-components-panel-border-subtle bg-components-badge-white-to-dark', + isSelected + ? 'border-state-accent-solid bg-state-accent-hover' + : 'border-components-panel-border-subtle bg-components-badge-white-to-dark', )} onClick={(e) => { e.stopPropagation() diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/index.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/index.tsx index 8dc0e810173bab..67393d26f81766 100644 --- a/web/app/components/base/prompt-editor/plugins/last-run-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/last-run-block/index.tsx @@ -1,27 +1,14 @@ import type { LastRunBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { - $insertNodes, - COMMAND_PRIORITY_EDITOR, - createCommand, -} from 'lexical' -import { - memo, - useEffect, -} from 'react' -import { - $createLastRunBlockNode, - LastRunBlockNode, -} from './node' +import { $insertNodes, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' +import { memo, useEffect } from 'react' +import { $createLastRunBlockNode, LastRunBlockNode } from './node' export const INSERT_LAST_RUN_BLOCK_COMMAND = createCommand('INSERT_LAST_RUN_BLOCK_COMMAND') export const DELETE_LAST_RUN_COMMAND = createCommand('DELETE_LAST_RUN_COMMAND') -const LastRunBlock = memo(({ - onInsert, - onDelete, -}: LastRunBlockType) => { +const LastRunBlock = memo(({ onInsert, onDelete }: LastRunBlockType) => { const [editor] = useLexicalComposerContext() useEffect(() => { @@ -36,8 +23,7 @@ const LastRunBlock = memo(({ $insertNodes([Node]) - if (onInsert) - onInsert() + if (onInsert) onInsert() return true }, @@ -46,8 +32,7 @@ const LastRunBlock = memo(({ editor.registerCommand( DELETE_LAST_RUN_COMMAND, () => { - if (onDelete) - onDelete() + if (onDelete) onDelete() return true }, diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/last-run-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/last-run-block-replacement-block.tsx index 8fa8e7f1c82402..a37d64b8c4134b 100644 --- a/web/app/components/base/prompt-editor/plugins/last-run-block/last-run-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/last-run-block/last-run-block-replacement-block.tsx @@ -2,42 +2,33 @@ import type { LastRunBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' import { $applyNodeReplacement } from 'lexical' -import { - memo, - useCallback, - useEffect, -} from 'react' +import { memo, useCallback, useEffect } from 'react' import { LAST_RUN_PLACEHOLDER_TEXT } from '../../constants' import { decoratorTransform } from '../../utils' import { CustomTextNode } from '../custom-text/node' -import { - $createLastRunBlockNode, - LastRunBlockNode, -} from './node' +import { $createLastRunBlockNode, LastRunBlockNode } from './node' const REGEX = new RegExp(LAST_RUN_PLACEHOLDER_TEXT) -const LastRunReplacementBlock = ({ - onInsert, -}: LastRunBlockType) => { +const LastRunReplacementBlock = ({ onInsert }: LastRunBlockType) => { const [editor] = useLexicalComposerContext() useEffect(() => { if (!editor.hasNodes([LastRunBlockNode])) - throw new Error('LastRunMessageBlockNodePlugin: LastRunMessageBlockNode not registered on editor') + throw new Error( + 'LastRunMessageBlockNodePlugin: LastRunMessageBlockNode not registered on editor', + ) }, [editor]) const createLastRunBlockNode = useCallback((): LastRunBlockNode => { - if (onInsert) - onInsert() + if (onInsert) onInsert() return $applyNodeReplacement($createLastRunBlockNode()) }, [onInsert]) const getMatch = useCallback((text: string) => { const matchArr = REGEX.exec(text) - if (matchArr === null) - return null + if (matchArr === null) return null const startOffset = matchArr.index const endOffset = startOffset + LAST_RUN_PLACEHOLDER_TEXT.length @@ -50,7 +41,9 @@ const LastRunReplacementBlock = ({ useEffect(() => { REGEX.lastIndex = 0 return mergeRegister( - editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createLastRunBlockNode)), + editor.registerNodeTransform(CustomTextNode, (textNode) => + decoratorTransform(textNode, getMatch, createLastRunBlockNode), + ), ) }, []) diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/node.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/node.tsx index 5ae0c607810a0f..90a48263759165 100644 --- a/web/app/components/base/prompt-editor/plugins/last-run-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/last-run-block/node.tsx @@ -32,11 +32,7 @@ export class LastRunBlockNode extends DecoratorNode<React.JSX.Element> { } override decorate(): React.JSX.Element { - return ( - <LastRunBlockComponent - nodeKey={this.getKey()} - /> - ) + return <LastRunBlockComponent nodeKey={this.getKey()} /> } static override importJSON(): LastRunBlockNode { diff --git a/web/app/components/base/prompt-editor/plugins/on-blur-or-focus-block.tsx b/web/app/components/base/prompt-editor/plugins/on-blur-or-focus-block.tsx index 27ad97f9be4db8..05960dd5df8146 100644 --- a/web/app/components/base/prompt-editor/plugins/on-blur-or-focus-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/on-blur-or-focus-block.tsx @@ -1,21 +1,14 @@ import type { FC } from 'react' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { - BLUR_COMMAND, - COMMAND_PRIORITY_EDITOR, - FOCUS_COMMAND, -} from 'lexical' +import { BLUR_COMMAND, COMMAND_PRIORITY_EDITOR, FOCUS_COMMAND } from 'lexical' import { useEffect } from 'react' type OnBlurBlockProps = { onBlur?: () => void onFocus?: () => void } -const OnBlurBlock: FC<OnBlurBlockProps> = ({ - onBlur, - onFocus, -}) => { +const OnBlurBlock: FC<OnBlurBlockProps> = ({ onBlur, onFocus }) => { const [editor] = useLexicalComposerContext() useEffect(() => { @@ -25,8 +18,7 @@ const OnBlurBlock: FC<OnBlurBlockProps> = ({ (event) => { const target = event?.relatedTarget as HTMLElement if (!target?.classList?.contains('var-search-input')) { - if (onBlur) - onBlur() + if (onBlur) onBlur() } return true }, @@ -35,8 +27,7 @@ const OnBlurBlock: FC<OnBlurBlockProps> = ({ editor.registerCommand( FOCUS_COMMAND, () => { - if (onFocus) - onFocus() + if (onFocus) onFocus() return true }, COMMAND_PRIORITY_EDITOR, diff --git a/web/app/components/base/prompt-editor/plugins/placeholder.tsx b/web/app/components/base/prompt-editor/plugins/placeholder.tsx index 6b9b7a6a870259..489d55400d0a5e 100644 --- a/web/app/components/base/prompt-editor/plugins/placeholder.tsx +++ b/web/app/components/base/prompt-editor/plugins/placeholder.tsx @@ -15,13 +15,14 @@ const Placeholder = ({ const { t } = useTranslation() return ( - <div className={cn( - 'pointer-events-none absolute top-0 left-0 size-full text-sm text-components-input-text-placeholder select-none', - compact ? 'text-[13px] leading-5' : 'text-sm/6', - className, - )} + <div + className={cn( + 'pointer-events-none absolute top-0 left-0 size-full text-sm text-components-input-text-placeholder select-none', + compact ? 'text-[13px] leading-5' : 'text-sm/6', + className, + )} > - {value || t($ => $['promptEditor.placeholder'], { ns: 'common' })} + {value || t(($) => $['promptEditor.placeholder'], { ns: 'common' })} </div> ) } diff --git a/web/app/components/base/prompt-editor/plugins/query-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/query-block/__tests__/index.spec.tsx index f17018f12c0640..fb43690f3fcd59 100644 --- a/web/app/components/base/prompt-editor/plugins/query-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/query-block/__tests__/index.spec.tsx @@ -16,16 +16,16 @@ import { QueryBlockNode, } from '../index' -const renderQueryBlock = (props: { - onInsert?: () => void - onDelete?: () => void -} = {}) => { +const renderQueryBlock = ( + props: { + onInsert?: () => void + onDelete?: () => void + } = {}, +) => { return renderLexicalEditor({ namespace: 'query-block-plugin-test', nodes: [CustomTextNode, QueryBlockNode], - children: ( - <QueryBlock {...props} /> - ), + children: <QueryBlock {...props} />, }) } diff --git a/web/app/components/base/prompt-editor/plugins/query-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/query-block/__tests__/node.spec.tsx index e927856e1aac7d..f12a09fd1397f0 100644 --- a/web/app/components/base/prompt-editor/plugins/query-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/query-block/__tests__/node.spec.tsx @@ -1,14 +1,7 @@ import { act } from '@testing-library/react' -import { - createLexicalTestEditor, - expectInlineWrapperDom, -} from '../../test-helpers' +import { createLexicalTestEditor, expectInlineWrapperDom } from '../../test-helpers' import QueryBlockComponent from '../component' -import { - $createQueryBlockNode, - $isQueryBlockNode, - QueryBlockNode, -} from '../node' +import { $createQueryBlockNode, $isQueryBlockNode, QueryBlockNode } from '../node' describe('QueryBlockNode', () => { const createTestEditor = () => { diff --git a/web/app/components/base/prompt-editor/plugins/query-block/__tests__/query-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/query-block/__tests__/query-block-replacement-block.spec.tsx index 9a92aba3c0f70e..f241d10262f401 100644 --- a/web/app/components/base/prompt-editor/plugins/query-block/__tests__/query-block-replacement-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/query-block/__tests__/query-block-replacement-block.spec.tsx @@ -11,15 +11,15 @@ import { import { QueryBlockNode } from '../index' import QueryBlockReplacementBlock from '../query-block-replacement-block' -const renderReplacementPlugin = (props: { - onInsert?: () => void -} = {}) => { +const renderReplacementPlugin = ( + props: { + onInsert?: () => void + } = {}, +) => { return renderLexicalEditor({ namespace: 'query-block-replacement-plugin-test', nodes: [CustomTextNode, QueryBlockNode], - children: ( - <QueryBlockReplacementBlock {...props} /> - ), + children: <QueryBlockReplacementBlock {...props} />, }) } @@ -35,7 +35,11 @@ describe('QueryBlockReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, `prefix ${QUERY_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text)) + setEditorRootText( + editor, + `prefix ${QUERY_PLACEHOLDER_TEXT} suffix`, + (text) => new CustomTextNode(text), + ) await waitFor(() => { expect(getNodeCount(editor, QueryBlockNode)).toBe(1) @@ -49,7 +53,11 @@ describe('QueryBlockReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, 'plain text without placeholder', text => new CustomTextNode(text)) + setEditorRootText( + editor, + 'plain text without placeholder', + (text) => new CustomTextNode(text), + ) await waitFor(() => { expect(getNodeCount(editor, QueryBlockNode)).toBe(0) @@ -62,7 +70,7 @@ describe('QueryBlockReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, QUERY_PLACEHOLDER_TEXT, text => new CustomTextNode(text)) + setEditorRootText(editor, QUERY_PLACEHOLDER_TEXT, (text) => new CustomTextNode(text)) await waitFor(() => { expect(getNodeCount(editor, QueryBlockNode)).toBe(1) diff --git a/web/app/components/base/prompt-editor/plugins/query-block/component.tsx b/web/app/components/base/prompt-editor/plugins/query-block/component.tsx index 02c131adc1ba1e..80ecf8e413831d 100644 --- a/web/app/components/base/prompt-editor/plugins/query-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/query-block/component.tsx @@ -8,23 +8,20 @@ type QueryBlockComponentProps = { nodeKey: string } -const QueryBlockComponent: FC<QueryBlockComponentProps> = ({ - nodeKey, -}) => { +const QueryBlockComponent: FC<QueryBlockComponentProps> = ({ nodeKey }) => { const { t } = useTranslation() const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_QUERY_BLOCK_COMMAND) return ( <div - className={` - inline-flex h-6 items-center rounded-[5px] border border-transparent bg-[#FFF6ED] pr-0.5 pl-1 hover:bg-[#FFEAD5] - ${isSelected && 'border-[#FD853A]!'} - `} + className={`inline-flex h-6 items-center rounded-[5px] border border-transparent bg-[#FFF6ED] pr-0.5 pl-1 hover:bg-[#FFEAD5] ${isSelected && 'border-[#FD853A]!'} `} ref={ref} > <UserEdit02 className="mr-1 h-[14px] w-[14px] text-[#FD853A]" /> <div className="text-xs font-medium text-[#EC4A0A] opacity-60">{'{{'}</div> - <div className="text-xs font-medium text-[#EC4A0A]">{t($ => $['promptEditor.query.item.title'], { ns: 'common' })}</div> + <div className="text-xs font-medium text-[#EC4A0A]"> + {t(($) => $['promptEditor.query.item.title'], { ns: 'common' })} + </div> <div className="text-xs font-medium text-[#EC4A0A] opacity-60">{'}}'}</div> </div> ) diff --git a/web/app/components/base/prompt-editor/plugins/query-block/index.tsx b/web/app/components/base/prompt-editor/plugins/query-block/index.tsx index adabf0bc6e127a..7b347b0ef10a1a 100644 --- a/web/app/components/base/prompt-editor/plugins/query-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/query-block/index.tsx @@ -1,26 +1,13 @@ import type { QueryBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { - $insertNodes, - COMMAND_PRIORITY_EDITOR, - createCommand, -} from 'lexical' -import { - memo, - useEffect, -} from 'react' -import { - $createQueryBlockNode, - QueryBlockNode, -} from './node' +import { $insertNodes, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' +import { memo, useEffect } from 'react' +import { $createQueryBlockNode, QueryBlockNode } from './node' export const INSERT_QUERY_BLOCK_COMMAND = createCommand('INSERT_QUERY_BLOCK_COMMAND') export const DELETE_QUERY_BLOCK_COMMAND = createCommand('DELETE_QUERY_BLOCK_COMMAND') -const QueryBlock = memo(({ - onInsert, - onDelete, -}: QueryBlockType) => { +const QueryBlock = memo(({ onInsert, onDelete }: QueryBlockType) => { const [editor] = useLexicalComposerContext() useEffect(() => { @@ -34,8 +21,7 @@ const QueryBlock = memo(({ const contextBlockNode = $createQueryBlockNode() $insertNodes([contextBlockNode]) - if (onInsert) - onInsert() + if (onInsert) onInsert() return true }, @@ -44,8 +30,7 @@ const QueryBlock = memo(({ editor.registerCommand( DELETE_QUERY_BLOCK_COMMAND, () => { - if (onDelete) - onDelete() + if (onDelete) onDelete() return true }, diff --git a/web/app/components/base/prompt-editor/plugins/query-block/query-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/query-block/query-block-replacement-block.tsx index 1a3c23b6a6ddd8..1f354848131e2b 100644 --- a/web/app/components/base/prompt-editor/plugins/query-block/query-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/query-block/query-block-replacement-block.tsx @@ -2,24 +2,15 @@ import type { QueryBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' import { $applyNodeReplacement } from 'lexical' -import { - memo, - useCallback, - useEffect, -} from 'react' +import { memo, useCallback, useEffect } from 'react' import { QUERY_PLACEHOLDER_TEXT } from '../../constants' import { decoratorTransform } from '../../utils' import { CustomTextNode } from '../custom-text/node' -import { - $createQueryBlockNode, - QueryBlockNode, -} from '../query-block/node' +import { $createQueryBlockNode, QueryBlockNode } from '../query-block/node' const REGEX = new RegExp(QUERY_PLACEHOLDER_TEXT) -const QueryBlockReplacementBlock = ({ - onInsert, -}: QueryBlockType) => { +const QueryBlockReplacementBlock = ({ onInsert }: QueryBlockType) => { const [editor] = useLexicalComposerContext() useEffect(() => { @@ -28,16 +19,14 @@ const QueryBlockReplacementBlock = ({ }, [editor]) const createQueryBlockNode = useCallback((): QueryBlockNode => { - if (onInsert) - onInsert() + if (onInsert) onInsert() return $applyNodeReplacement($createQueryBlockNode()) }, [onInsert]) const getMatch = useCallback((text: string) => { const matchArr = REGEX.exec(text) - if (matchArr === null) - return null + if (matchArr === null) return null const startOffset = matchArr.index const endOffset = startOffset + QUERY_PLACEHOLDER_TEXT.length @@ -50,7 +39,9 @@ const QueryBlockReplacementBlock = ({ useEffect(() => { REGEX.lastIndex = 0 return mergeRegister( - editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createQueryBlockNode)), + editor.registerNodeTransform(CustomTextNode, (textNode) => + decoratorTransform(textNode, getMatch, createQueryBlockNode), + ), ) }, []) diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/__tests__/index.spec.tsx index 2b479edd092087..f7501df15ca592 100644 --- a/web/app/components/base/prompt-editor/plugins/request-url-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/request-url-block/__tests__/index.spec.tsx @@ -16,16 +16,16 @@ import { RequestURLBlockNode, } from '../index' -const renderRequestURLBlock = (props: { - onInsert?: () => void - onDelete?: () => void -} = {}) => { +const renderRequestURLBlock = ( + props: { + onInsert?: () => void + onDelete?: () => void + } = {}, +) => { return renderLexicalEditor({ namespace: 'request-url-block-plugin-test', nodes: [CustomTextNode, RequestURLBlockNode], - children: ( - <RequestURLBlock {...props} /> - ), + children: <RequestURLBlock {...props} />, }) } diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/__tests__/node.spec.tsx index f3250053873dcb..477a4d7cad53d7 100644 --- a/web/app/components/base/prompt-editor/plugins/request-url-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/request-url-block/__tests__/node.spec.tsx @@ -1,13 +1,7 @@ import { act } from '@testing-library/react' -import { - createLexicalTestEditor, - expectInlineWrapperDom, -} from '../../test-helpers' +import { createLexicalTestEditor, expectInlineWrapperDom } from '../../test-helpers' import RequestURLBlockComponent from '../component' -import { - $createRequestURLBlockNode, - RequestURLBlockNode, -} from '../node' +import { $createRequestURLBlockNode, RequestURLBlockNode } from '../node' describe('RequestURLBlockNode', () => { const createTestEditor = () => { diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/__tests__/request-url-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/__tests__/request-url-block-replacement-block.spec.tsx index 1df74ed4e6eafd..8f088aa5e51983 100644 --- a/web/app/components/base/prompt-editor/plugins/request-url-block/__tests__/request-url-block-replacement-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/request-url-block/__tests__/request-url-block-replacement-block.spec.tsx @@ -11,15 +11,15 @@ import { import { RequestURLBlockNode } from '../index' import RequestURLBlockReplacementBlock from '../request-url-block-replacement-block' -const renderReplacementPlugin = (props: { - onInsert?: () => void -} = {}) => { +const renderReplacementPlugin = ( + props: { + onInsert?: () => void + } = {}, +) => { return renderLexicalEditor({ namespace: 'request-url-block-replacement-plugin-test', nodes: [CustomTextNode, RequestURLBlockNode], - children: ( - <RequestURLBlockReplacementBlock {...props} /> - ), + children: <RequestURLBlockReplacementBlock {...props} />, }) } @@ -35,7 +35,11 @@ describe('RequestURLBlockReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, `prefix ${REQUEST_URL_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text)) + setEditorRootText( + editor, + `prefix ${REQUEST_URL_PLACEHOLDER_TEXT} suffix`, + (text) => new CustomTextNode(text), + ) await waitFor(() => { expect(getNodeCount(editor, RequestURLBlockNode)).toBe(1) @@ -49,7 +53,11 @@ describe('RequestURLBlockReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, 'plain text without placeholder', text => new CustomTextNode(text)) + setEditorRootText( + editor, + 'plain text without placeholder', + (text) => new CustomTextNode(text), + ) await waitFor(() => { expect(getNodeCount(editor, RequestURLBlockNode)).toBe(0) @@ -62,7 +70,7 @@ describe('RequestURLBlockReplacementBlock', () => { const editor = await waitForEditorReady(getEditor) - setEditorRootText(editor, REQUEST_URL_PLACEHOLDER_TEXT, text => new CustomTextNode(text)) + setEditorRootText(editor, REQUEST_URL_PLACEHOLDER_TEXT, (text) => new CustomTextNode(text)) await waitFor(() => { expect(getNodeCount(editor, RequestURLBlockNode)).toBe(1) diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/component.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/component.tsx index 44eaa9c687cf08..7aab8f07df45fe 100644 --- a/web/app/components/base/prompt-editor/plugins/request-url-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/request-url-block/component.tsx @@ -1,7 +1,6 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { RiGlobalLine } from '@remixicon/react' - import { useTranslation } from 'react-i18next' import { useSelectOrDelete } from '../../hooks' import { DELETE_REQUEST_URL_BLOCK_COMMAND } from './index' @@ -10,9 +9,7 @@ type RequestURLBlockComponentProps = { nodeKey: string } -const RequestURLBlockComponent: FC<RequestURLBlockComponentProps> = ({ - nodeKey, -}) => { +const RequestURLBlockComponent: FC<RequestURLBlockComponentProps> = ({ nodeKey }) => { const { t } = useTranslation() const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_REQUEST_URL_BLOCK_COMMAND) @@ -25,7 +22,9 @@ const RequestURLBlockComponent: FC<RequestURLBlockComponentProps> = ({ ref={ref} > <RiGlobalLine className="mr-0.5 size-3.5 text-util-colors-violet-violet-600" /> - <div className="system-xs-medium text-util-colors-violet-violet-600">{t($ => $['promptEditor.requestURL.item.title'], { ns: 'common' })}</div> + <div className="system-xs-medium text-util-colors-violet-violet-600"> + {t(($) => $['promptEditor.requestURL.item.title'], { ns: 'common' })} + </div> </div> ) } diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/index.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/index.tsx index 8f41f449b62e0f..6d729d9809b148 100644 --- a/web/app/components/base/prompt-editor/plugins/request-url-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/request-url-block/index.tsx @@ -1,27 +1,14 @@ import type { RequestURLBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { - $insertNodes, - COMMAND_PRIORITY_EDITOR, - createCommand, -} from 'lexical' -import { - memo, - useEffect, -} from 'react' -import { - $createRequestURLBlockNode, - RequestURLBlockNode, -} from './node' +import { $insertNodes, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' +import { memo, useEffect } from 'react' +import { $createRequestURLBlockNode, RequestURLBlockNode } from './node' export const INSERT_REQUEST_URL_BLOCK_COMMAND = createCommand('INSERT_REQUEST_URL_BLOCK_COMMAND') export const DELETE_REQUEST_URL_BLOCK_COMMAND = createCommand('DELETE_REQUEST_URL_BLOCK_COMMAND') -const RequestURLBlock = memo(({ - onInsert, - onDelete, -}: RequestURLBlockType) => { +const RequestURLBlock = memo(({ onInsert, onDelete }: RequestURLBlockType) => { const [editor] = useLexicalComposerContext() useEffect(() => { @@ -35,8 +22,7 @@ const RequestURLBlock = memo(({ const contextBlockNode = $createRequestURLBlockNode() $insertNodes([contextBlockNode]) - if (onInsert) - onInsert() + if (onInsert) onInsert() return true }, @@ -45,8 +31,7 @@ const RequestURLBlock = memo(({ editor.registerCommand( DELETE_REQUEST_URL_BLOCK_COMMAND, () => { - if (onDelete) - onDelete() + if (onDelete) onDelete() return true }, diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/request-url-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/request-url-block-replacement-block.tsx index 7cac5d0e19078a..981ce63578bbc5 100644 --- a/web/app/components/base/prompt-editor/plugins/request-url-block/request-url-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/request-url-block/request-url-block-replacement-block.tsx @@ -2,24 +2,15 @@ import type { RequestURLBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' import { $applyNodeReplacement } from 'lexical' -import { - memo, - useCallback, - useEffect, -} from 'react' +import { memo, useCallback, useEffect } from 'react' import { REQUEST_URL_PLACEHOLDER_TEXT } from '../../constants' import { decoratorTransform } from '../../utils' import { CustomTextNode } from '../custom-text/node' -import { - $createRequestURLBlockNode, - RequestURLBlockNode, -} from '../request-url-block/node' +import { $createRequestURLBlockNode, RequestURLBlockNode } from '../request-url-block/node' const REGEX = new RegExp(REQUEST_URL_PLACEHOLDER_TEXT) -const RequestURLBlockReplacementBlock = ({ - onInsert, -}: RequestURLBlockType) => { +const RequestURLBlockReplacementBlock = ({ onInsert }: RequestURLBlockType) => { const [editor] = useLexicalComposerContext() useEffect(() => { @@ -28,16 +19,14 @@ const RequestURLBlockReplacementBlock = ({ }, [editor]) const createRequestURLBlockNode = useCallback((): RequestURLBlockNode => { - if (onInsert) - onInsert() + if (onInsert) onInsert() return $applyNodeReplacement($createRequestURLBlockNode()) }, [onInsert]) const getMatch = useCallback((text: string) => { const matchArr = REGEX.exec(text) - if (matchArr === null) - return null + if (matchArr === null) return null const startOffset = matchArr.index const endOffset = startOffset + REQUEST_URL_PLACEHOLDER_TEXT.length @@ -50,7 +39,9 @@ const RequestURLBlockReplacementBlock = ({ useEffect(() => { REGEX.lastIndex = 0 return mergeRegister( - editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createRequestURLBlockNode)), + editor.registerNodeTransform(CustomTextNode, (textNode) => + decoratorTransform(textNode, getMatch, createRequestURLBlockNode), + ), ) }, []) diff --git a/web/app/components/base/prompt-editor/plugins/roster-reference-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/roster-reference-block/__tests__/node.spec.tsx index 710df306a70e86..011e3fde520273 100644 --- a/web/app/components/base/prompt-editor/plugins/roster-reference-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/roster-reference-block/__tests__/node.spec.tsx @@ -1,21 +1,11 @@ -import type { - Klass, - LexicalEditor, - LexicalNode, -} from 'lexical' +import type { Klass, LexicalEditor, LexicalNode } from 'lexical' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createEditor } from 'lexical' import RosterReferenceBlockComponent from '../component' import { RosterReferenceBlockContext } from '../context' -import { - $createRosterReferenceBlockNode, - RosterReferenceBlockNode, -} from '../node' -import { - getRosterReferenceFileIconType, - parseRosterReferenceToken, -} from '../utils' +import { $createRosterReferenceBlockNode, RosterReferenceBlockNode } from '../node' +import { getRosterReferenceFileIconType, parseRosterReferenceToken } from '../utils' describe('RosterReferenceBlockNode', () => { let editor: LexicalEditor @@ -75,9 +65,10 @@ describe('RosterReferenceBlockNode', () => { it('should render warning state for missing references', async () => { const user = userEvent.setup() render( - <RosterReferenceBlockContext value={{ - getWarning: token => `${token.label} does not exist`, - }} + <RosterReferenceBlockContext + value={{ + getWarning: (token) => `${token.label} does not exist`, + }} > <RosterReferenceBlockComponent text="[§skill:playwright:Playwright§]" /> </RosterReferenceBlockContext>, diff --git a/web/app/components/base/prompt-editor/plugins/roster-reference-block/component.tsx b/web/app/components/base/prompt-editor/plugins/roster-reference-block/component.tsx index 2b26e4db9434ae..2c1f8591d063c2 100644 --- a/web/app/components/base/prompt-editor/plugins/roster-reference-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/roster-reference-block/component.tsx @@ -13,21 +13,26 @@ type RosterReferenceBlockComponentProps = { text: string } -const RosterReferenceBlockComponent = ({ - text, -}: RosterReferenceBlockComponentProps) => { +const RosterReferenceBlockComponent = ({ text }: RosterReferenceBlockComponentProps) => { const rosterReferenceBlock = use(RosterReferenceBlockContext) const token = parseRosterReferenceToken(text) - if (!token) - return null + if (!token) return null const isKnowledge = token.kind === 'knowledge' const customIcon = rosterReferenceBlock?.renderIcon?.(token) const warning = rosterReferenceBlock?.getWarning?.(token) - const defaultIcon = token.kind === 'file' - ? <FileTreeIcon type={getRosterReferenceFileIconType(token.label)} className="size-4" /> - : <span className={cn(isKnowledge ? 'size-3.5' : 'size-3.5 shrink-0', getRosterReferenceIconClassName(token))} /> + const defaultIcon = + token.kind === 'file' ? ( + <FileTreeIcon type={getRosterReferenceFileIconType(token.label)} className="size-4" /> + ) : ( + <span + className={cn( + isKnowledge ? 'size-3.5' : 'size-3.5 shrink-0', + getRosterReferenceIconClassName(token), + )} + /> + ) const tokenBlock = ( <span @@ -48,15 +53,17 @@ const RosterReferenceBlockComponent = ({ className={cn( 'inline-flex size-4 shrink-0 items-center justify-center rounded-[5px] border-[0.5px] border-divider-subtle bg-background-default-dodge', token.kind === 'cli_tool' && 'border-divider-regular bg-text-tertiary', - isKnowledge && 'border-divider-subtle bg-util-colors-green-green-500 p-[3px] text-text-primary-on-surface shadow-xs shadow-shadow-shadow-3', + isKnowledge && + 'border-divider-subtle bg-util-colors-green-green-500 p-[3px] text-text-primary-on-surface shadow-xs shadow-shadow-shadow-3', )} > {customIcon || defaultIcon} </span> - <span className={cn( - 'max-w-48 truncate system-xs-medium', - warning ? 'text-text-warning' : 'text-text-accent', - )} + <span + className={cn( + 'max-w-48 truncate system-xs-medium', + warning ? 'text-text-warning' : 'text-text-accent', + )} > {token.label} </span> @@ -66,15 +73,12 @@ const RosterReferenceBlockComponent = ({ </span> ) - if (!warning) - return tokenBlock + if (!warning) return tokenBlock return ( <Tooltip> <TooltipTrigger render={tokenBlock} /> - <TooltipContent> - {warning} - </TooltipContent> + <TooltipContent>{warning}</TooltipContent> </Tooltip> ) } diff --git a/web/app/components/base/prompt-editor/plugins/roster-reference-block/context.ts b/web/app/components/base/prompt-editor/plugins/roster-reference-block/context.ts index 2669dc27178860..93a035a022dc03 100644 --- a/web/app/components/base/prompt-editor/plugins/roster-reference-block/context.ts +++ b/web/app/components/base/prompt-editor/plugins/roster-reference-block/context.ts @@ -1,4 +1,6 @@ import type { RosterReferenceBlockType } from '../../types' import { createContext } from 'react' -export const RosterReferenceBlockContext = createContext<RosterReferenceBlockType | undefined>(undefined) +export const RosterReferenceBlockContext = createContext<RosterReferenceBlockType | undefined>( + undefined, +) diff --git a/web/app/components/base/prompt-editor/plugins/roster-reference-block/index.tsx b/web/app/components/base/prompt-editor/plugins/roster-reference-block/index.tsx index 643546170173b1..9a5323e72d9ad2 100644 --- a/web/app/components/base/prompt-editor/plugins/roster-reference-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/roster-reference-block/index.tsx @@ -3,10 +3,7 @@ import type { LexicalEditor, TextNode } from 'lexical' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' import { $applyNodeReplacement } from 'lexical' -import { - useCallback, - useEffect, -} from 'react' +import { useCallback, useEffect } from 'react' import { decoratorTransform } from '../../utils' import { CustomTextNode } from '../custom-text/node' import { RosterReferenceBlockNode } from './node' @@ -16,11 +13,14 @@ type RosterReferenceNodeRegistry = { _nodes: Map<string, { klass: typeof RosterReferenceBlockNode }> } -function createRegisteredRosterReferenceBlockNode(editor: LexicalEditor, textNode: TextNode): RosterReferenceBlockNode { - const RegisteredRosterReferenceBlockNode = (editor as unknown as RosterReferenceNodeRegistry) - ._nodes - .get(RosterReferenceBlockNode.getType()) - ?.klass ?? RosterReferenceBlockNode +function createRegisteredRosterReferenceBlockNode( + editor: LexicalEditor, + textNode: TextNode, +): RosterReferenceBlockNode { + const RegisteredRosterReferenceBlockNode = + (editor as unknown as RosterReferenceNodeRegistry)._nodes.get( + RosterReferenceBlockNode.getType(), + )?.klass ?? RosterReferenceBlockNode return $applyNodeReplacement(new RegisteredRosterReferenceBlockNode(textNode.getTextContent())) } @@ -30,18 +30,21 @@ const RosterReferenceBlock = () => { useEffect(() => { if (!editor.hasNodes([RosterReferenceBlockNode])) - throw new Error('RosterReferenceBlockPlugin: RosterReferenceBlockNode not registered on editor') + throw new Error( + 'RosterReferenceBlockPlugin: RosterReferenceBlockNode not registered on editor', + ) }, [editor]) - const createRosterReferenceBlockNode = useCallback((textNode: CustomTextNode): RosterReferenceBlockNode => ( - createRegisteredRosterReferenceBlockNode(editor, textNode) - ), [editor]) + const createRosterReferenceBlockNode = useCallback( + (textNode: CustomTextNode): RosterReferenceBlockNode => + createRegisteredRosterReferenceBlockNode(editor, textNode), + [editor], + ) const getRosterReferenceMatch = useCallback((text: string): EntityMatch | null => { const matchArr = ROSTER_REFERENCE_REGEX.exec(text) - if (matchArr === null) - return null + if (matchArr === null) return null const startOffset = matchArr.index const endOffset = startOffset + matchArr[0].length @@ -53,7 +56,9 @@ const RosterReferenceBlock = () => { useEffect(() => { return mergeRegister( - editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getRosterReferenceMatch, createRosterReferenceBlockNode)), + editor.registerNodeTransform(CustomTextNode, (textNode) => + decoratorTransform(textNode, getRosterReferenceMatch, createRosterReferenceBlockNode), + ), ) }, [createRosterReferenceBlockNode, editor, getRosterReferenceMatch]) diff --git a/web/app/components/base/prompt-editor/plugins/roster-reference-block/node.tsx b/web/app/components/base/prompt-editor/plugins/roster-reference-block/node.tsx index 71ee5d351e28c0..710e604ed03b66 100644 --- a/web/app/components/base/prompt-editor/plugins/roster-reference-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/roster-reference-block/node.tsx @@ -1,12 +1,6 @@ -import type { - NodeKey, - SerializedLexicalNode, -} from 'lexical' +import type { NodeKey, SerializedLexicalNode } from 'lexical' import type { JSX } from 'react' -import { - $applyNodeReplacement, - DecoratorNode, -} from 'lexical' +import { $applyNodeReplacement, DecoratorNode } from 'lexical' import RosterReferenceBlockComponent from './component' type SerializedRosterReferenceBlockNode = SerializedLexicalNode & { @@ -47,7 +41,9 @@ export class RosterReferenceBlockNode extends DecoratorNode<JSX.Element> { return <RosterReferenceBlockComponent text={this.getTextContent()} /> } - static override importJSON(serializedNode: SerializedRosterReferenceBlockNode): RosterReferenceBlockNode { + static override importJSON( + serializedNode: SerializedRosterReferenceBlockNode, + ): RosterReferenceBlockNode { return $createRosterReferenceBlockNode(serializedNode.text) } diff --git a/web/app/components/base/prompt-editor/plugins/roster-reference-block/utils.ts b/web/app/components/base/prompt-editor/plugins/roster-reference-block/utils.ts index 82af42492da9ff..58e93ae357219a 100644 --- a/web/app/components/base/prompt-editor/plugins/roster-reference-block/utils.ts +++ b/web/app/components/base/prompt-editor/plugins/roster-reference-block/utils.ts @@ -8,7 +8,8 @@ export type RosterReferenceToken = { label: string } -export const ROSTER_REFERENCE_REGEX = /\[§(?:skill|file|tool-all|tool|cli_tool|knowledge):[^\]§\n\r]+§\]/ +export const ROSTER_REFERENCE_REGEX = + /\[§(?:skill|file|tool-all|tool|cli_tool|knowledge):[^\]§\n\r]+§\]/ const KNOWN_KINDS = new Set<RosterReferenceKind>([ 'skill', @@ -20,25 +21,21 @@ const KNOWN_KINDS = new Set<RosterReferenceKind>([ ]) export function parseRosterReferenceToken(text: string): RosterReferenceToken | null { - if (!text.startsWith('[§') || !text.endsWith('§]')) - return null + if (!text.startsWith('[§') || !text.endsWith('§]')) return null const body = text.slice(2, -2) const firstColonIndex = body.indexOf(':') - if (firstColonIndex === -1) - return null + if (firstColonIndex === -1) return null const kind = body.slice(0, firstColonIndex) as RosterReferenceKind - if (!KNOWN_KINDS.has(kind)) - return null + if (!KNOWN_KINDS.has(kind)) return null const rest = body.slice(firstColonIndex + 1) const secondColonIndex = rest.indexOf(':') const id = secondColonIndex === -1 ? rest : rest.slice(0, secondColonIndex) const label = secondColonIndex === -1 ? id : rest.slice(secondColonIndex + 1) - if (!id || !label) - return null + if (!id || !label) return null return { kind, @@ -65,31 +62,33 @@ const codeFileExtensions = new Set([ 'yaml', 'yml', ]) -const imageFileExtensions = new Set(['apng', 'avif', 'bmp', 'gif', 'ico', 'jpeg', 'jpg', 'png', 'svg', 'webp']) +const imageFileExtensions = new Set([ + 'apng', + 'avif', + 'bmp', + 'gif', + 'ico', + 'jpeg', + 'jpg', + 'png', + 'svg', + 'webp', +]) const tableFileExtensions = new Set(['csv', 'xls', 'xlsx']) const archiveFileExtensions = new Set(['7z', 'gz', 'rar', 'tar', 'zip']) export function getRosterReferenceFileIconType(label: string): FileTreeIconType { const extension = label.includes('.') ? label.split('.').pop()?.toLowerCase() : undefined - if (!extension) - return 'folder' - if (imageFileExtensions.has(extension)) - return 'image' - if (extension === 'pdf') - return 'pdf' - if (extension === 'md' || extension === 'markdown' || extension === 'mdx') - return 'markdown' - if (extension === 'json') - return 'json' - if (tableFileExtensions.has(extension)) - return 'table' - if (archiveFileExtensions.has(extension)) - return 'archive' - if (codeFileExtensions.has(extension)) - return 'code' - if (extension === 'txt') - return 'text' + if (!extension) return 'folder' + if (imageFileExtensions.has(extension)) return 'image' + if (extension === 'pdf') return 'pdf' + if (extension === 'md' || extension === 'markdown' || extension === 'mdx') return 'markdown' + if (extension === 'json') return 'json' + if (tableFileExtensions.has(extension)) return 'table' + if (archiveFileExtensions.has(extension)) return 'archive' + if (codeFileExtensions.has(extension)) return 'code' + if (extension === 'txt') return 'text' return 'file' } diff --git a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/__tests__/index.spec.tsx index d5fd9b149ee8c7..d761573b306a30 100644 --- a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/__tests__/index.spec.tsx @@ -31,7 +31,9 @@ beforeAll(() => { Range.prototype.getClientRects = vi.fn(() => { const rectList = [mockDOMRect] as unknown as DOMRectList Object.defineProperty(rectList, 'length', { value: 1 }) - Object.defineProperty(rectList, 'item', { value: (index: number) => index === 0 ? mockDOMRect : null }) + Object.defineProperty(rectList, 'item', { + value: (index: number) => (index === 0 ? mockDOMRect : null), + }) return rectList }) @@ -51,7 +53,9 @@ type MinimalEditorProps = { withContainer?: boolean hotkey?: string | string[] | string[][] | ((e: KeyboardEvent) => boolean) displayMode?: ShortcutPopupDisplayMode - children?: React.ReactNode | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode) + children?: + | React.ReactNode + | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode) className?: string onOpen?: () => void onClose?: () => void @@ -76,7 +80,11 @@ const MinimalEditor: React.FC<MinimalEditorProps> = ({ return ( <LexicalComposer initialConfig={initialConfig}> - <div data-testid={CONTAINER_ID} className="relative" ref={withContainer ? setContainerEl : undefined}> + <div + data-testid={CONTAINER_ID} + className="relative" + ref={withContainer ? setContainerEl : undefined} + > <RichTextPlugin contentEditable={<ContentEditable data-testid={CONTENT_EDITABLE_ID} />} placeholder={null} @@ -98,7 +106,12 @@ const MinimalEditor: React.FC<MinimalEditorProps> = ({ } /** Helper: focus the content editable and trigger a hotkey. */ -function focusAndTriggerHotkey(key: string, modifiers: Partial<Record<'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey', boolean>> = { ctrlKey: true }) { +function focusAndTriggerHotkey( + key: string, + modifiers: Partial<Record<'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey', boolean>> = { + ctrlKey: true, + }, +) { const ce = screen.getByTestId(CONTENT_EDITABLE_ID) ce.focus() fireEvent.keyDown(document, { key, ...modifiers }) @@ -215,17 +228,20 @@ describe('ShortcutsPopupPlugin', () => { const rightPanel = document.createElement('div') rightPanel.setAttribute('data-workflow-right-panel', '') - rightPanel.getBoundingClientRect = vi.fn(() => ({ - x: 800, - y: 56, - width: 400, - height: 840, - top: 56, - right: 1200, - bottom: 896, - left: 800, - toJSON: () => ({}), - } as DOMRect)) + rightPanel.getBoundingClientRect = vi.fn( + () => + ({ + x: 800, + y: 56, + width: 400, + height: 840, + top: 56, + right: 1200, + bottom: 896, + left: 800, + toJSON: () => ({}), + }) as DOMRect, + ) document.body.appendChild(rightPanel) try { @@ -244,11 +260,13 @@ describe('ShortcutsPopupPlugin', () => { }) expect(floatingDiv.style.getPropertyValue('--shortcut-popup-max-width')).toBe('400px') expect(floatingDiv.style.getPropertyValue('--shortcut-popup-max-height')).toBe('836px') - } - finally { + } finally { rightPanel.remove() Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth }) - Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight }) + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: originalInnerHeight, + }) } }) @@ -274,19 +292,40 @@ describe('ShortcutsPopupPlugin', () => { // ─── matchHotkey: string[][] (nested) hotkey ─── it('matches when hotkey is a nested array (any combo matches)', async () => { - render(<MinimalEditor hotkey={[['ctrl', 'k'], ['meta', 'j']]} />) + render( + <MinimalEditor + hotkey={[ + ['ctrl', 'k'], + ['meta', 'j'], + ]} + />, + ) focusAndTriggerHotkey('k', { ctrlKey: true }) expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument() }) it('matches the second combo in a nested array', async () => { - render(<MinimalEditor hotkey={[['ctrl', 'k'], ['meta', 'j']]} />) + render( + <MinimalEditor + hotkey={[ + ['ctrl', 'k'], + ['meta', 'j'], + ]} + />, + ) focusAndTriggerHotkey('j', { metaKey: true }) expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument() }) it('does not match nested array when no combo matches', async () => { - render(<MinimalEditor hotkey={[['ctrl', 'k'], ['meta', 'j']]} />) + render( + <MinimalEditor + hotkey={[ + ['ctrl', 'k'], + ['meta', 'j'], + ]} + />, + ) focusAndTriggerHotkey('x', { ctrlKey: true }) await waitFor(() => { expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument() @@ -405,16 +444,20 @@ describe('ShortcutsPopupPlugin', () => { const TEST_COMMAND = createCommand<unknown>('TEST_COMMAND') const childrenFn = vi.fn((close: () => void, onInsert: ShortcutPopupInsertHandler) => ( <div> - <button type="button" data-testid="close-btn" onClick={close}>Close</button> - <button type="button" data-testid="insert-btn" onClick={() => onInsert(TEST_COMMAND, ['param1'])}>Insert</button> + <button type="button" data-testid="close-btn" onClick={close}> + Close + </button> + <button + type="button" + data-testid="insert-btn" + onClick={() => onInsert(TEST_COMMAND, ['param1'])} + > + Insert + </button> </div> )) - render( - <MinimalEditor> - {childrenFn} - </MinimalEditor>, - ) + render(<MinimalEditor>{childrenFn}</MinimalEditor>) focusAndTriggerHotkey('/') // Children render function should have been called @@ -435,7 +478,13 @@ describe('ShortcutsPopupPlugin', () => { <MinimalEditor> {(close: () => void, onInsert: ShortcutPopupInsertHandler) => ( <div> - <button type="button" data-testid="insert-btn" onClick={() => onInsert(TEST_COMMAND, ['value'])}>Insert</button> + <button + type="button" + data-testid="insert-btn" + onClick={() => onInsert(TEST_COMMAND, ['value'])} + > + Insert + </button> </div> )} </MinimalEditor>, @@ -455,7 +504,9 @@ describe('ShortcutsPopupPlugin', () => { render( <MinimalEditor> {(close: () => void) => ( - <button type="button" data-testid="close-via-fn" onClick={close}>Close</button> + <button type="button" data-testid="close-via-fn" onClick={close}> + Close + </button> )} </MinimalEditor>, ) @@ -526,7 +577,11 @@ describe('ShortcutsPopupPlugin', () => { const stopPropagationSpy = vi.fn() // Use a custom event to capture preventDefault/stopPropagation calls - const escEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + const escEvent = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }) Object.defineProperty(escEvent, 'preventDefault', { value: preventDefaultSpy }) Object.defineProperty(escEvent, 'stopPropagation', { value: stopPropagationSpy }) document.dispatchEvent(escEvent) @@ -541,7 +596,17 @@ describe('ShortcutsPopupPlugin', () => { // ─── Zero-rect fallback in openPortal ─── it('handles zero-size range rects by falling back to node bounding rect', async () => { // Temporarily override getClientRects to return zero-size rect - const zeroRect = { x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0, toJSON: () => ({}) } + const zeroRect = { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON: () => ({}), + } const originalGetClientRects = Range.prototype.getClientRects const originalGetBoundingClientRect = Range.prototype.getBoundingClientRect @@ -618,7 +683,7 @@ describe('ShortcutsPopupPlugin', () => { // Now stub getSelection to return no ranges so lastSelectionRef is used const originalGetSelection = window.getSelection - window.getSelection = vi.fn(() => ({ rangeCount: 0 } as Selection)) + window.getSelection = vi.fn(() => ({ rangeCount: 0 }) as Selection) focusAndTriggerHotkey('/') expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument() diff --git a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx index 90db634601ecb2..5b7e01fa0cde7b 100644 --- a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx @@ -1,31 +1,17 @@ import type { LexicalCommand } from 'lexical' import type { CSSProperties } from 'react' -import { - autoUpdate, - flip, - offset, - shift, - size, - useFloating, -} from '@floating-ui/react' +import { autoUpdate, flip, offset, shift, size, useFloating } from '@floating-ui/react' import { cn } from '@langgenius/dify-ui/cn' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' -import { - $getSelection, - $isRangeSelection, -} from 'lexical' -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from 'react' +import { $getSelection, $isRangeSelection } from 'lexical' +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { createPortal } from 'react-dom' export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content' -export type ShortcutPopupInsertHandler = <Payload>(command: LexicalCommand<Payload>, params: Payload) => void +export type ShortcutPopupInsertHandler = <Payload>( + command: LexicalCommand<Payload>, + params: Payload, +) => void export type ShortcutPopupDisplayMode = 'selection' | 'workflow-panel-adjacent-center' // Hotkey can be: @@ -37,7 +23,9 @@ export type Hotkey = string | string[] | string[][] | ((e: KeyboardEvent) => boo type ShortcutPopupPluginProps = { hotkey?: Hotkey - children?: React.ReactNode | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode) + children?: + | React.ReactNode + | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode) className?: string container?: Element | null displayMode?: ShortcutPopupDisplayMode @@ -59,9 +47,8 @@ type FixedPlacementState = { function getWorkflowPanelAdjacentPlacement(): FixedPlacementState { const rightPanel = document.querySelector('[data-workflow-right-panel]') as HTMLElement | null const rightPanelRect = rightPanel?.getBoundingClientRect() - const rightBoundary = rightPanelRect && rightPanelRect.left > 0 - ? rightPanelRect.left - : window.innerWidth + const rightBoundary = + rightPanelRect && rightPanelRect.left > 0 ? rightPanelRect.left : window.innerWidth const topBoundary = rightPanelRect?.top ?? 56 const bottomBoundary = window.innerHeight - VIEWPORT_PADDING const availableWidth = Math.max(0, rightBoundary - VIEWPORT_PADDING * 2) @@ -77,16 +64,12 @@ function getWorkflowPanelAdjacentPlacement(): FixedPlacementState { function getWorkflowPanelAdjacentPlacementSnapshot() { /* v8 ignore next 2 -- server/non-browser fallback for a client-only positioning branch. @preserve */ - if (typeof window === 'undefined' || typeof document === 'undefined') - return '0|0|0|0' + if (typeof window === 'undefined' || typeof document === 'undefined') return '0|0|0|0' const placement = getWorkflowPanelAdjacentPlacement() - return [ - placement.right, - placement.top, - placement.availableWidth, - placement.availableHeight, - ].join('|') + return [placement.right, placement.top, placement.availableWidth, placement.availableHeight].join( + '|', + ) } function parseWorkflowPanelAdjacentPlacement(snapshot: string): FixedPlacementState { @@ -101,17 +84,14 @@ function parseWorkflowPanelAdjacentPlacement(snapshot: string): FixedPlacementSt function subscribeWorkflowPanelAdjacentPlacement(callback: () => void) { /* v8 ignore next 2 -- server/non-browser fallback for a client-only positioning branch. @preserve */ - if (typeof window === 'undefined' || typeof document === 'undefined') - return () => {} + if (typeof window === 'undefined' || typeof document === 'undefined') return () => {} window.addEventListener('resize', callback) const rightPanel = document.querySelector('[data-workflow-right-panel]') - const resizeObserver = rightPanel && typeof ResizeObserver !== 'undefined' - ? new ResizeObserver(callback) - : null - if (rightPanel) - resizeObserver?.observe(rightPanel) + const resizeObserver = + rightPanel && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(callback) : null + if (rightPanel) resizeObserver?.observe(rightPanel) return () => { window.removeEventListener('resize', callback) @@ -126,14 +106,12 @@ const SHIFT_ALIASES = new Set(['shift']) function matchHotkey(event: KeyboardEvent, hotkey?: Hotkey) { /* v8 ignore next 2 -- plugin always provides a default hotkey ('mod+/'); undefined hotkey is not reachable via public props flow. @preserve */ - if (!hotkey) - return false + if (!hotkey) return false - if (typeof hotkey === 'function') - return hotkey(event) + if (typeof hotkey === 'function') return hotkey(event) const matchCombo = (tokens: string[]) => { - const parts = tokens.map(t => t.toLowerCase().trim()).filter(Boolean) + const parts = tokens.map((t) => t.toLowerCase().trim()).filter(Boolean) let expectedKey: string | null = null let needMod = false @@ -166,22 +144,16 @@ function matchHotkey(event: KeyboardEvent, hotkey?: Hotkey) { expectedKey = p } - if (needMod && !(event.metaKey || event.ctrlKey)) - return false - if (needCtrl && !event.ctrlKey) - return false - if (needMeta && !event.metaKey) - return false - if (needAlt && !event.altKey) - return false - if (needShift && !event.shiftKey) - return false + if (needMod && !(event.metaKey || event.ctrlKey)) return false + if (needCtrl && !event.ctrlKey) return false + if (needMeta && !event.metaKey) return false + if (needAlt && !event.altKey) return false + if (needShift && !event.shiftKey) return false if (expectedKey) { const k = event.key.toLowerCase() const normalized = k === ' ' ? 'space' : k - if (normalized !== expectedKey) - return false + if (normalized !== expectedKey) return false } return true @@ -191,9 +163,8 @@ function matchHotkey(event: KeyboardEvent, hotkey?: Hotkey) { const isNested = hotkey.length > 0 && Array.isArray((hotkey as unknown[])[0]) if (isNested) { const combos = hotkey as string[][] - return combos.some(tokens => matchCombo(tokens)) - } - else { + return combos.some((tokens) => matchCombo(tokens)) + } else { const tokens = hotkey as string[] return matchCombo(tokens) } @@ -202,7 +173,7 @@ function matchHotkey(event: KeyboardEvent, hotkey?: Hotkey) { const tokensFromString = hotkey .toLowerCase() .split('+') - .map(t => t.trim()) + .map((t) => t.trim()) .filter(Boolean) return matchCombo(tokensFromString) } @@ -227,7 +198,10 @@ export default function ShortcutsPopupPlugin({ const lastSelectionRef = useRef<Range | null>(null) /* v8 ignore next -- defensive non-browser fallback; this client-only plugin runs where document exists (browser/test DOM runtime). @preserve */ - const containerEl = useMemo(() => container ?? (typeof document !== 'undefined' ? document.body : null), [container]) + const containerEl = useMemo( + () => container ?? (typeof document !== 'undefined' ? document.body : null), + [container], + ) const useContainer = !!containerEl && containerEl !== document.body const { refs, floatingStyles, isPositioned } = useFloating({ @@ -245,7 +219,7 @@ export default function ShortcutsPopupPlugin({ Object.assign(elements.floating.style, { '--shortcut-popup-max-width': `${Math.min(400, availableWidth)}px`, '--shortcut-popup-max-height': `${Math.max(0, availableHeight)}px`, - 'overflow': 'visible', + overflow: 'visible', }) }, padding: 8, @@ -271,8 +245,7 @@ export default function ShortcutsPopupPlugin({ const isEditorFocused = useCallback(() => { const root = editor.getRootElement() /* v8 ignore next 2 -- root can be null during Lexical mount/unmount transitions before DOM root attachment. @preserve */ - if (!root) - return false + if (!root) return false return root.contains(document.activeElement) }, [editor]) @@ -285,34 +258,27 @@ export default function ShortcutsPopupPlugin({ const domSelection = window.getSelection() let range: Range | null = null - if (domSelection && domSelection.rangeCount > 0) - range = domSelection.getRangeAt(0).cloneRange() - else - range = lastSelectionRef.current + if (domSelection && domSelection.rangeCount > 0) range = domSelection.getRangeAt(0).cloneRange() + else range = lastSelectionRef.current if (range) { const rects = range.getClientRects() let rect: DOMRect | null = null - if (rects && rects.length) - rect = rects[rects.length - 1]! - - else - rect = range.getBoundingClientRect() + if (rects && rects.length) rect = rects[rects.length - 1]! + else rect = range.getBoundingClientRect() if (rect.width === 0 && rect.height === 0) { const root = editor.getRootElement() /* v8 ignore next 10 -- zero-size rect recovery depends on browser layout/selection geometry; deterministic reproduction in the test DOM runtime is unreliable. @preserve */ if (root) { const sc = range.startContainer - const node = sc.nodeType === Node.ELEMENT_NODE - ? sc as Element - : (sc.parentElement || root) + const node = + sc.nodeType === Node.ELEMENT_NODE ? (sc as Element) : sc.parentElement || root rect = node.getBoundingClientRect() - if (rect.width === 0 && rect.height === 0) - rect = root.getBoundingClientRect() + if (rect.width === 0 && rect.height === 0) rect = root.getBoundingClientRect() } } @@ -344,8 +310,7 @@ export default function ShortcutsPopupPlugin({ return } - if (!isEditorFocused()) - return + if (!isEditorFocused()) return if (matchHotkey(event, hotkey)) { event.preventDefault() @@ -358,35 +323,35 @@ export default function ShortcutsPopupPlugin({ }, [hotkey, open, isEditorFocused, openPortal, closePortal]) useEffect(() => { - if (!open) - return + if (!open) return const onMouseDown = (e: MouseEvent) => { /* v8 ignore next 2 -- outside-click listener can race with ref cleanup during close/unmount; null-ref path is a safety guard. @preserve */ - if (!portalRef.current) - return + if (!portalRef.current) return const target = e.target as HTMLElement | null - if (target?.closest('[data-base-ui-portal]')) - return - if (!portalRef.current.contains(e.target as Node)) - closePortal() + if (target?.closest('[data-base-ui-portal]')) return + if (!portalRef.current.contains(e.target as Node)) closePortal() } document.addEventListener('mousedown', onMouseDown, false) return () => document.removeEventListener('mousedown', onMouseDown, false) }, [open, closePortal]) - const handleInsert = useCallback(<Payload,>(command: LexicalCommand<Payload>, params: Payload) => { - editor.dispatchCommand(command, params) - closePortal() - }, [editor, closePortal]) + const handleInsert = useCallback( + <Payload,>(command: LexicalCommand<Payload>, params: Payload) => { + editor.dispatchCommand(command, params) + closePortal() + }, + [editor, closePortal], + ) - if (!open || !containerEl) - return null + if (!open || !containerEl) return null const isFixedPanelAdjacent = displayMode === 'workflow-panel-adjacent-center' - const fixedPlacementState = parseWorkflowPanelAdjacentPlacement(workflowPanelAdjacentPlacementSnapshot) + const fixedPlacementState = parseWorkflowPanelAdjacentPlacement( + workflowPanelAdjacentPlacementSnapshot, + ) const fixedPanelAdjacentStyles: CSSProperties = isFixedPanelAdjacent - ? { + ? ({ position: 'fixed', right: fixedPlacementState.right, top: fixedPlacementState.top, @@ -396,7 +361,7 @@ export default function ShortcutsPopupPlugin({ visibility: 'visible', ['--shortcut-popup-max-width' as string]: `${Math.min(POPUP_MAX_WIDTH, fixedPlacementState.availableWidth)}px`, ['--shortcut-popup-max-height' as string]: `${fixedPlacementState.availableHeight}px`, - } as CSSProperties + } as CSSProperties) : {} return createPortal( @@ -404,24 +369,24 @@ export default function ShortcutsPopupPlugin({ data-testid="shortcuts-popup" ref={(node) => { portalRef.current = node - if (!isFixedPanelAdjacent) - refs.setFloating(node) + if (!isFixedPanelAdjacent) refs.setFloating(node) }} - className={cn( - 'absolute rounded-xl bg-components-panel-bg-blur shadow-lg', - className, - )} - style={isFixedPanelAdjacent - ? fixedPanelAdjacentStyles - : { - ...floatingStyles, - zIndex: useContainer ? undefined : 50, - overflow: 'visible', - visibility: isPositioned ? 'visible' : 'hidden', - }} + className={cn('absolute rounded-xl bg-components-panel-bg-blur shadow-lg', className)} + style={ + isFixedPanelAdjacent + ? fixedPanelAdjacentStyles + : { + ...floatingStyles, + zIndex: useContainer ? undefined : 50, + overflow: 'visible', + visibility: isPositioned ? 'visible' : 'hidden', + } + } > <div className="max-h-(--shortcut-popup-max-height) max-w-(--shortcut-popup-max-width) overflow-hidden rounded-xl"> - {typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)} + {typeof children === 'function' + ? children(closePortal, handleInsert) + : (children ?? SHORTCUTS_EMPTY_CONTENT)} </div> </div>, containerEl, diff --git a/web/app/components/base/prompt-editor/plugins/test-helpers.ts b/web/app/components/base/prompt-editor/plugins/test-helpers.ts index ef82f3dba3f24e..01f4a389a68ddb 100644 --- a/web/app/components/base/prompt-editor/plugins/test-helpers.ts +++ b/web/app/components/base/prompt-editor/plugins/test-helpers.ts @@ -1,17 +1,8 @@ -import type { - Klass, - LexicalEditor, - LexicalNode, -} from 'lexical' +import type { Klass, LexicalEditor, LexicalNode } from 'lexical' import type { ReactNode } from 'react' import { LexicalComposer } from '@lexical/react/LexicalComposer' import { act, render, waitFor } from '@testing-library/react' -import { - $createParagraphNode, - $getRoot, - $nodesOfType, - createEditor, -} from 'lexical' +import { $createParagraphNode, $getRoot, $nodesOfType, createEditor } from 'lexical' import { createElement } from 'react' import { expect } from 'vitest' import { CaptureEditorPlugin } from './test-utils' @@ -33,24 +24,26 @@ export const renderLexicalEditor = ({ }: RenderLexicalEditorProps): RenderLexicalEditorResult => { let editor: LexicalEditor | null = null - const utils = render(createElement( - LexicalComposer, - { - initialConfig: { - namespace, - onError: (error: Error) => { - throw error + const utils = render( + createElement( + LexicalComposer, + { + initialConfig: { + namespace, + onError: (error: Error) => { + throw error + }, + nodes, }, - nodes, }, - }, - children, - createElement(CaptureEditorPlugin, { - onReady: (value) => { - editor = value - }, - }), - )) + children, + createElement(CaptureEditorPlugin, { + onReady: (value) => { + editor = value + }, + }), + ), + ) return { ...utils, @@ -58,15 +51,15 @@ export const renderLexicalEditor = ({ } } -export const waitForEditorReady = async (getEditor: () => LexicalEditor | null): Promise<LexicalEditor> => { +export const waitForEditorReady = async ( + getEditor: () => LexicalEditor | null, +): Promise<LexicalEditor> => { await waitFor(() => { - if (!getEditor()) - throw new Error('Editor is not ready yet') + if (!getEditor()) throw new Error('Editor is not ready yet') }) const editor = getEditor() - if (!editor) - throw new Error('Editor is not available') + if (!editor) throw new Error('Editor is not available') return editor } @@ -89,7 +82,10 @@ export const readRootTextContent = (editor: LexicalEditor): string => { return content } -export const getNodeCount = <T extends LexicalNode>(editor: LexicalEditor, nodeType: Klass<T>): number => { +export const getNodeCount = <T extends LexicalNode>( + editor: LexicalEditor, + nodeType: Klass<T>, +): number => { let count = 0 editor.getEditorState().read(() => { @@ -99,7 +95,10 @@ export const getNodeCount = <T extends LexicalNode>(editor: LexicalEditor, nodeT return count } -export const getNodesByType = <T extends LexicalNode>(editor: LexicalEditor, nodeType: Klass<T>): T[] => { +export const getNodesByType = <T extends LexicalNode>( + editor: LexicalEditor, + nodeType: Klass<T>, +): T[] => { let nodes: T[] = [] editor.getEditorState().read(() => { @@ -116,8 +115,7 @@ export const readEditorStateValue = <T>(editor: LexicalEditor, reader: () => T): value = reader() }) - if (value === undefined) - throw new Error('Failed to read editor state value') + if (value === undefined) throw new Error('Failed to read editor state value') return value } diff --git a/web/app/components/base/prompt-editor/plugins/update-block.tsx b/web/app/components/base/prompt-editor/plugins/update-block.tsx index e67b027be733d7..9d93e5a6bb9fdd 100644 --- a/web/app/components/base/prompt-editor/plugins/update-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/update-block.tsx @@ -4,15 +4,14 @@ import { useEventEmitterContextContext } from '@/context/event-emitter' import { textToEditorState } from '../utils' import { CustomTextNode } from './custom-text/node' -export const PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER = 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER' +export const PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER = + 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER' export const PROMPT_EDITOR_INSERT_QUICKLY = 'PROMPT_EDITOR_INSERT_QUICKLY' type UpdateBlockProps = { instanceId?: string } -const UpdateBlock = ({ - instanceId, -}: UpdateBlockProps) => { +const UpdateBlock = ({ instanceId }: UpdateBlockProps) => { const { eventEmitter } = useEventEmitterContextContext() const [editor] = useLexicalComposerContext() @@ -21,10 +20,11 @@ const UpdateBlock = ({ const editorState = editor.parseEditorState(textToEditorState(v.payload)) editor.setEditorState(editorState) editor.update(() => { - $getRoot().getAllTextNodes().forEach((node) => { - if (node instanceof CustomTextNode) - node.markDirty() - }) + $getRoot() + .getAllTextNodes() + .forEach((node) => { + if (node instanceof CustomTextNode) node.markDirty() + }) }) } }) diff --git a/web/app/components/base/prompt-editor/plugins/variable-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/variable-block/__tests__/index.spec.tsx index f28dcab4baeeee..33f2089a7df779 100644 --- a/web/app/components/base/prompt-editor/plugins/variable-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/variable-block/__tests__/index.spec.tsx @@ -15,9 +15,7 @@ const renderVariableBlock = () => { return renderLexicalEditor({ namespace: 'variable-block-plugin-test', nodes: [CustomTextNode], - children: ( - <VariableBlock /> - ), + children: <VariableBlock />, }) } diff --git a/web/app/components/base/prompt-editor/plugins/variable-block/index.tsx b/web/app/components/base/prompt-editor/plugins/variable-block/index.tsx index 11e12f3a917162..84b97fc7c4c07c 100644 --- a/web/app/components/base/prompt-editor/plugins/variable-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/variable-block/index.tsx @@ -1,15 +1,13 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { - $insertNodes, - COMMAND_PRIORITY_EDITOR, - createCommand, -} from 'lexical' +import { $insertNodes, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' import { useEffect } from 'react' import { CustomTextNode } from '../custom-text/node' export const INSERT_VARIABLE_BLOCK_COMMAND = createCommand('INSERT_VARIABLE_BLOCK_COMMAND') -export const INSERT_VARIABLE_VALUE_BLOCK_COMMAND = createCommand('INSERT_VARIABLE_VALUE_BLOCK_COMMAND') +export const INSERT_VARIABLE_VALUE_BLOCK_COMMAND = createCommand( + 'INSERT_VARIABLE_VALUE_BLOCK_COMMAND', +) const VariableBlock = () => { const [editor] = useLexicalComposerContext() diff --git a/web/app/components/base/prompt-editor/plugins/variable-value-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/variable-value-block/__tests__/index.spec.tsx index cb36a2e8de69b8..37c6ab0a858be5 100644 --- a/web/app/components/base/prompt-editor/plugins/variable-value-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/variable-value-block/__tests__/index.spec.tsx @@ -35,7 +35,7 @@ describe('VariableValueBlock', () => { vi.clearAllMocks() mockHasNodes.mockReturnValue(true) vi.mocked($createVariableValueBlockNode).mockImplementation( - text => ({ createdText: text } as unknown as VariableValueBlockNode), + (text) => ({ createdText: text }) as unknown as VariableValueBlockNode, ) }) @@ -62,7 +62,9 @@ describe('VariableValueBlock', () => { it('should return match offsets when placeholder exists and null when not present', () => { renderWithLexicalContext(<VariableValueBlock />) - const getMatch = vi.mocked(useLexicalTextEntity).mock.calls[0]![0] as (text: string) => EntityMatch | null + const getMatch = vi.mocked(useLexicalTextEntity).mock.calls[0]![0] as ( + text: string, + ) => EntityMatch | null const match = getMatch('prefix {{foo_1}} suffix') expect(match).toEqual({ start: 7, end: 16 }) @@ -73,9 +75,9 @@ describe('VariableValueBlock', () => { it('should create variable node from text node content in create callback', () => { renderWithLexicalContext(<VariableValueBlock />) - const createNode = vi.mocked(useLexicalTextEntity).mock.calls[0]![2] as ( - textNode: { getTextContent: () => string }, - ) => VariableValueBlockNode + const createNode = vi.mocked(useLexicalTextEntity).mock.calls[0]![2] as (textNode: { + getTextContent: () => string + }) => VariableValueBlockNode const created = createNode({ getTextContent: () => '{{account_id}}', diff --git a/web/app/components/base/prompt-editor/plugins/variable-value-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/variable-value-block/__tests__/node.spec.tsx index c9ab951323da3b..53ccdef12da35d 100644 --- a/web/app/components/base/prompt-editor/plugins/variable-value-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/variable-value-block/__tests__/node.spec.tsx @@ -1,9 +1,6 @@ import type { EditorConfig, Klass, LexicalEditor, LexicalNode, SerializedTextNode } from 'lexical' import { createEditor } from 'lexical' -import { - $createVariableValueBlockNode, - VariableValueBlockNode, -} from '../node' +import { $createVariableValueBlockNode, VariableValueBlockNode } from '../node' describe('VariableValueBlockNode', () => { let editor: LexicalEditor diff --git a/web/app/components/base/prompt-editor/plugins/variable-value-block/index.tsx b/web/app/components/base/prompt-editor/plugins/variable-value-block/index.tsx index 4b24a670a9c4eb..1cb1ad66cdc3c8 100644 --- a/web/app/components/base/prompt-editor/plugins/variable-value-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/variable-value-block/index.tsx @@ -1,14 +1,8 @@ import type { TextNode } from 'lexical' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' -import { - useCallback, - useEffect, -} from 'react' +import { useCallback, useEffect } from 'react' import { useLexicalTextEntity } from '../../hooks' -import { - $createVariableValueBlockNode, - VariableValueBlockNode, -} from './node' +import { $createVariableValueBlockNode, VariableValueBlockNode } from './node' import { getHashtagRegexString } from './utils' const REGEX = new RegExp(getHashtagRegexString(), 'i') @@ -28,8 +22,7 @@ const VariableValueBlock = () => { const getVariableValueMatch = useCallback((text: string) => { const matchArr = REGEX.exec(text) - if (matchArr === null) - return null + if (matchArr === null) return null const hashtagLength = matchArr[0].length const startOffset = matchArr.index diff --git a/web/app/components/base/prompt-editor/plugins/variable-value-block/node.tsx b/web/app/components/base/prompt-editor/plugins/variable-value-block/node.tsx index 8a19dc3090d497..9ab083cf8c1091 100644 --- a/web/app/components/base/prompt-editor/plugins/variable-value-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/variable-value-block/node.tsx @@ -1,11 +1,5 @@ -import type { - EditorConfig, - SerializedTextNode, -} from 'lexical' -import { - $applyNodeReplacement, - TextNode, -} from 'lexical' +import type { EditorConfig, SerializedTextNode } from 'lexical' +import { $applyNodeReplacement, TextNode } from 'lexical' export class VariableValueBlockNode extends TextNode { static override getType(): string { @@ -22,7 +16,15 @@ export class VariableValueBlockNode extends TextNode { override createDOM(config: EditorConfig): HTMLElement { const element = super.createDOM(config) - element.classList.add('inline-flex', 'items-center', 'px-0.5', 'h-[22px]', 'text-text-accent', 'rounded-[5px]', 'align-middle') + element.classList.add( + 'inline-flex', + 'items-center', + 'px-0.5', + 'h-[22px]', + 'text-text-accent', + 'rounded-[5px]', + 'align-middle', + ) return element } diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx index 804d4653cec8ae..70391788716549 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx @@ -30,27 +30,30 @@ vi.mock('@/app/components/workflow/utils', async (importOriginal) => { isExceptionVariable: mockIsExceptionVariable, } }) -vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', async (importOriginal) => { - const actual = await importOriginal<typeof import('@/app/components/workflow/nodes/_base/components/variable/utils')>() - return { - ...actual, - isENV: (valueSelector: ValueSelector) => { - if (mockForcedVariableKind.value === 'env') - return true - return actual.isENV(valueSelector) - }, - isConversationVar: (valueSelector: ValueSelector) => { - if (mockForcedVariableKind.value === 'conversation') - return true - return actual.isConversationVar(valueSelector) - }, - isRagVariableVar: (valueSelector: ValueSelector) => { - if (mockForcedVariableKind.value === 'rag') - return true - return actual.isRagVariableVar(valueSelector) - }, - } -}) +vi.mock( + '@/app/components/workflow/nodes/_base/components/variable/utils', + async (importOriginal) => { + const actual = + await importOriginal< + typeof import('@/app/components/workflow/nodes/_base/components/variable/utils') + >() + return { + ...actual, + isENV: (valueSelector: ValueSelector) => { + if (mockForcedVariableKind.value === 'env') return true + return actual.isENV(valueSelector) + }, + isConversationVar: (valueSelector: ValueSelector) => { + if (mockForcedVariableKind.value === 'conversation') return true + return actual.isConversationVar(valueSelector) + }, + isRagVariableVar: (valueSelector: ValueSelector) => { + if (mockForcedVariableKind.value === 'rag') return true + return actual.isRagVariableVar(valueSelector) + }, + } + }, +) vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({ VariableLabelInEditor: (props: { onClick: (e: React.MouseEvent) => void @@ -68,12 +71,9 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-labe }, })) vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel', () => ({ - default: (props: { - nodeName: string - path: string[] - varType: Type - nodeType?: BlockEnum - }) => <div data-testid="var-full-path-panel">{props.nodeName}</div>, + default: (props: { nodeName: string; path: string[]; varType: Type; nodeType?: BlockEnum }) => ( + <div data-testid="var-full-path-panel">{props.nodeName}</div> + ), })) const mockRegisterCommand = vi.fn() @@ -94,11 +94,14 @@ describe('WorkflowVariableBlockComponent', () => { mockRegisterCommand.mockReturnValue(vi.fn()) mockGetState.mockReturnValue({ transform: [0, 0, 2] }) - vi.mocked(useLexicalComposerContext).mockReturnValue([ - mockEditor, - {}, - ] as unknown as ReturnType<typeof useLexicalComposerContext>) - vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup())) + vi.mocked(useLexicalComposerContext).mockReturnValue([mockEditor, {}] as unknown as ReturnType< + typeof useLexicalComposerContext + >) + vi.mocked(mergeRegister).mockImplementation( + (...cleanups) => + () => + cleanups.forEach((cleanup) => cleanup()), + ) vi.mocked(useSelectOrDelete).mockReturnValue([{ current: null }, false]) vi.mocked(useReactFlow).mockReturnValue({ setViewport: mockSetViewport, @@ -111,13 +114,15 @@ describe('WorkflowVariableBlockComponent', () => { it('should throw when WorkflowVariableBlockNode is not registered', () => { mockHasNodes.mockReturnValue(false) - expect(() => render( - <WorkflowVariableBlockComponent - nodeKey="k" - variables={['node-1', 'output']} - workflowNodesMap={{}} - />, - )).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor') + expect(() => + render( + <WorkflowVariableBlockComponent + nodeKey="k" + variables={['node-1', 'output']} + workflowNodesMap={{}} + />, + ), + ).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor') }) it('should render variable label and register update command', () => { @@ -229,9 +234,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should keep env variable valid when environmentVariables is omitted', () => { @@ -243,9 +250,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should treat env variable as valid when it exists in environmentVariables', () => { @@ -260,9 +269,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should handle env selector with missing segment when environmentVariables are provided', () => { @@ -277,9 +288,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should mark forced env branch invalid when selector prefix is missing', () => { @@ -295,9 +308,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: expect.any(String), - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: expect.any(String), + }), + ) }) it('should treat conversation variable as valid when found in conversationVariables', () => { @@ -312,9 +327,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should keep conversation variable valid when conversationVariables is omitted', () => { @@ -326,9 +343,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should treat conversation variable as valid regardless of conversationVariables contents', () => { @@ -343,9 +362,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should handle conversation selector with missing segment when conversationVariables are provided', () => { @@ -360,9 +381,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should mark forced conversation branch invalid when selector prefix is missing', () => { @@ -378,9 +401,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: expect.any(String), - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: expect.any(String), + }), + ) }) it('should treat global variable as valid without node', () => { @@ -392,9 +417,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should use rag variable validation path', () => { @@ -409,9 +436,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should keep rag variable valid when ragVariables is omitted', () => { @@ -423,9 +452,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should treat rag variable as valid regardless of ragVariables contents', () => { @@ -440,9 +471,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should handle rag selector with missing segment when ragVariables are provided', () => { @@ -457,9 +490,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) it('should mark forced rag branch invalid when selector prefix is missing', () => { @@ -475,9 +510,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: expect.any(String), - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: expect.any(String), + }), + ) }) it('should apply workflow node map updates through command handler', () => { @@ -489,7 +526,9 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - const updateHandler = mockRegisterCommand.mock.calls[0]![1] as (payload: UpdateWorkflowNodesMapPayload) => boolean + const updateHandler = mockRegisterCommand.mock.calls[0]![1] as ( + payload: UpdateWorkflowNodesMapPayload, + ) => boolean let result = false act(() => { result = updateHandler({ @@ -533,9 +572,11 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: expect.any(String), - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: expect.any(String), + }), + ) }) it('should keep non-special variable valid when source key exists in availableVariables', () => { @@ -562,8 +603,10 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, - })) + expect(mockVarLabel).toHaveBeenCalledWith( + expect.objectContaining({ + errorMsg: undefined, + }), + ) }) }) diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/index.spec.tsx index 7494d73b8d1f1b..439ab4008b48ec 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/index.spec.tsx @@ -23,7 +23,7 @@ vi.mock('lexical', async () => { return { ...actual, $insertNodes: vi.fn(), - createCommand: vi.fn(name => name), + createCommand: vi.fn((name) => name), COMMAND_PRIORITY_EDITOR: 1, } }) @@ -69,15 +69,19 @@ describe('WorkflowVariableBlock', () => { vi.clearAllMocks() mockHasNodes.mockReturnValue(true) mockRegisterCommand.mockReturnValue(vi.fn()) - vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup())) - vi.mocked($createWorkflowVariableBlockNode).mockReturnValue({ id: 'workflow-node' } as unknown as WorkflowVariableBlockNode) + vi.mocked(mergeRegister).mockImplementation( + (...cleanups) => + () => + cleanups.forEach((cleanup) => cleanup()), + ) + vi.mocked($createWorkflowVariableBlockNode).mockReturnValue({ + id: 'workflow-node', + } as unknown as WorkflowVariableBlockNode) }) it('should render null and register insert/delete commands', () => { const { container } = renderWithLexicalContext( - <WorkflowVariableBlock - workflowNodesMap={workflowNodesMap} - />, + <WorkflowVariableBlock workflowNodesMap={workflowNodesMap} />, ) expect(container.firstChild).toBeNull() @@ -98,11 +102,7 @@ describe('WorkflowVariableBlock', () => { }) it('should dispatch workflow node map update on mount', () => { - renderWithLexicalContext( - <WorkflowVariableBlock - workflowNodesMap={workflowNodesMap} - />, - ) + renderWithLexicalContext(<WorkflowVariableBlock workflowNodesMap={workflowNodesMap} />) expect(mockUpdate).toHaveBeenCalled() expect(mockDispatchCommand).toHaveBeenCalledWith(UPDATE_WORKFLOW_NODES_MAP, { @@ -114,11 +114,9 @@ describe('WorkflowVariableBlock', () => { it('should throw when WorkflowVariableBlockNode is not registered', () => { mockHasNodes.mockReturnValue(false) - expect(() => renderWithLexicalContext( - <WorkflowVariableBlock - workflowNodesMap={workflowNodesMap} - />, - )).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor') + expect(() => + renderWithLexicalContext(<WorkflowVariableBlock workflowNodesMap={workflowNodesMap} />), + ).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor') }) it('should insert workflow variable block node and call onInsert', () => { @@ -148,11 +146,7 @@ describe('WorkflowVariableBlock', () => { }) it('should return true on insert when onInsert is omitted', () => { - renderWithLexicalContext( - <WorkflowVariableBlock - workflowNodesMap={workflowNodesMap} - />, - ) + renderWithLexicalContext(<WorkflowVariableBlock workflowNodesMap={workflowNodesMap} />) const insertHandler = mockRegisterCommand.mock.calls[0]![1] as (variables: string[]) => boolean expect(insertHandler(['node-1', 'answer'])).toBe(true) @@ -162,10 +156,7 @@ describe('WorkflowVariableBlock', () => { const onDelete = vi.fn() renderWithLexicalContext( - <WorkflowVariableBlock - workflowNodesMap={workflowNodesMap} - onDelete={onDelete} - />, + <WorkflowVariableBlock workflowNodesMap={workflowNodesMap} onDelete={onDelete} />, ) const deleteHandler = mockRegisterCommand.mock.calls[1]![1] as () => boolean @@ -176,11 +167,7 @@ describe('WorkflowVariableBlock', () => { }) it('should return true on delete when onDelete is omitted', () => { - renderWithLexicalContext( - <WorkflowVariableBlock - workflowNodesMap={workflowNodesMap} - />, - ) + renderWithLexicalContext(<WorkflowVariableBlock workflowNodesMap={workflowNodesMap} />) const deleteHandler = mockRegisterCommand.mock.calls[1]![1] as () => boolean expect(deleteHandler()).toBe(true) @@ -189,14 +176,10 @@ describe('WorkflowVariableBlock', () => { it('should run merged cleanup on unmount', () => { const insertCleanup = vi.fn() const deleteCleanup = vi.fn() - mockRegisterCommand - .mockReturnValueOnce(insertCleanup) - .mockReturnValueOnce(deleteCleanup) + mockRegisterCommand.mockReturnValueOnce(insertCleanup).mockReturnValueOnce(deleteCleanup) const { unmount } = renderWithLexicalContext( - <WorkflowVariableBlock - workflowNodesMap={workflowNodesMap} - />, + <WorkflowVariableBlock workflowNodesMap={workflowNodesMap} />, ) unmount() diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/node.spec.tsx index d88753d8b981c2..ded2c76eb7dc28 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/node.spec.tsx @@ -3,10 +3,7 @@ import type { NodeOutPutVar } from '@/app/components/workflow/types' import { createEditor } from 'lexical' import { Type } from '@/app/components/workflow/nodes/llm/types' import { BlockEnum, VarType } from '@/app/components/workflow/types' -import { - $createWorkflowVariableBlockNode, - WorkflowVariableBlockNode, -} from '../node' +import { $createWorkflowVariableBlockNode, WorkflowVariableBlockNode } from '../node' describe('WorkflowVariableBlockNode', () => { let editor: LexicalEditor @@ -56,11 +53,13 @@ describe('WorkflowVariableBlockNode', () => { it('should decorate with component props from node state', () => { runInEditor(() => { const getVarType = vi.fn(() => Type.number) - const availableVariables: NodeOutPutVar[] = [{ - nodeId: 'node-1', - title: 'Node A', - vars: [{ variable: 'answer', type: VarType.string }], - }] + const availableVariables: NodeOutPutVar[] = [ + { + nodeId: 'node-1', + title: 'Node A', + vars: [{ variable: 'answer', type: VarType.string }], + }, + ] const node = new WorkflowVariableBlockNode( ['node-1', 'answer'], @@ -73,7 +72,9 @@ describe('WorkflowVariableBlockNode', () => { const decorated = node.decorate() expect(decorated.props.nodeKey).toBe('decorator-key') expect(decorated.props.variables).toEqual(['node-1', 'answer']) - expect(decorated.props.workflowNodesMap).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } }) + expect(decorated.props.workflowNodesMap).toEqual({ + 'node-1': { title: 'A', type: BlockEnum.LLM }, + }) expect(decorated.props.availableVariables).toEqual(availableVariables) }) }) @@ -81,11 +82,13 @@ describe('WorkflowVariableBlockNode', () => { it('should export and import json with available variables payload', () => { runInEditor(() => { const getVarType = vi.fn(() => Type.string) - const availableVariables: NodeOutPutVar[] = [{ - nodeId: 'node-1', - title: 'Node A', - vars: [{ variable: 'answer', type: VarType.string }], - }] + const availableVariables: NodeOutPutVar[] = [ + { + nodeId: 'node-1', + title: 'Node A', + vars: [{ variable: 'answer', type: VarType.string }], + }, + ] const node = new WorkflowVariableBlockNode( ['node-1', 'answer'], @@ -115,7 +118,9 @@ describe('WorkflowVariableBlockNode', () => { expect(imported).toBeInstanceOf(WorkflowVariableBlockNode) expect(imported.getVariables()).toEqual(['node-2', 'result']) - expect(imported.getWorkflowNodesMap()).toEqual({ 'node-2': { title: 'B', type: BlockEnum.Tool } }) + expect(imported.getWorkflowNodesMap()).toEqual({ + 'node-2': { title: 'B', type: BlockEnum.Tool }, + }) expect(imported.getAvailableVariables()).toEqual(availableVariables) }) }) @@ -123,11 +128,13 @@ describe('WorkflowVariableBlockNode', () => { it('should return getters and text content in expected format', () => { runInEditor(() => { const getVarType = vi.fn(() => Type.string) - const availableVariables: NodeOutPutVar[] = [{ - nodeId: 'node-1', - title: 'Node A', - vars: [{ variable: 'answer', type: VarType.string }], - }] + const availableVariables: NodeOutPutVar[] = [ + { + nodeId: 'node-1', + title: 'Node A', + vars: [{ variable: 'answer', type: VarType.string }], + }, + ] const node = new WorkflowVariableBlockNode( ['node-1', 'answer'], { 'node-1': { title: 'A', type: BlockEnum.LLM } }, @@ -146,12 +153,19 @@ describe('WorkflowVariableBlockNode', () => { it('should create node helper', () => { runInEditor(() => { - const availableVariables: NodeOutPutVar[] = [{ - nodeId: 'node-1', - title: 'Node A', - vars: [{ variable: 'answer', type: VarType.string }], - }] - const node = $createWorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined, availableVariables) + const availableVariables: NodeOutPutVar[] = [ + { + nodeId: 'node-1', + title: 'Node A', + vars: [{ variable: 'answer', type: VarType.string }], + }, + ] + const node = $createWorkflowVariableBlockNode( + ['node-1', 'answer'], + {}, + undefined, + availableVariables, + ) expect(node).toBeInstanceOf(WorkflowVariableBlockNode) expect(node.getAvailableVariables()).toEqual(availableVariables) diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/use-llm-model-plugin-installed.spec.ts b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/use-llm-model-plugin-installed.spec.ts index fd77302d139a24..94bf87001c00b0 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/use-llm-model-plugin-installed.spec.ts +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/use-llm-model-plugin-installed.spec.ts @@ -6,8 +6,9 @@ import { useLlmModelPluginInstalled } from '../use-llm-model-plugin-installed' let mockModelProviders: Array<{ provider: string }> = [] vi.mock('@/context/provider-context', () => ({ - useProviderContextSelector: <T>(selector: (state: { modelProviders: Array<{ provider: string }> }) => T): T => - selector({ modelProviders: mockModelProviders }), + useProviderContextSelector: <T>( + selector: (state: { modelProviders: Array<{ provider: string }> }) => T, + ): T => selector({ modelProviders: mockModelProviders }), })) const createWorkflowNodesMap = (node: Record<string, unknown>): WorkflowNodesMap => @@ -17,7 +18,7 @@ const createWorkflowNodesMap = (node: Record<string, unknown>): WorkflowNodesMap type: BlockEnum.Start, ...node, }, - } as unknown as WorkflowNodesMap) + }) as unknown as WorkflowNodesMap describe('useLlmModelPluginInstalled', () => { beforeEach(() => { @@ -59,9 +60,7 @@ describe('useLlmModelPluginInstalled', () => { }) it('should return false when the matching model plugin is not installed', () => { - mockModelProviders = [ - { provider: 'langgenius/anthropic/claude' }, - ] + mockModelProviders = [{ provider: 'langgenius/anthropic/claude' }] const workflowNodesMap = createWorkflowNodesMap({ id: 'target', type: BlockEnum.LLM, diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/workflow-variable-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/workflow-variable-block-replacement-block.spec.tsx index 6b6729a3a2cc25..9fb2a31e952163 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/workflow-variable-block-replacement-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/workflow-variable-block-replacement-block.spec.tsx @@ -83,8 +83,14 @@ describe('WorkflowVariableBlockReplacementBlock', () => { vi.clearAllMocks() mockHasNodes.mockReturnValue(true) mockRegisterNodeTransform.mockReturnValue(vi.fn()) - vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup())) - vi.mocked($createWorkflowVariableBlockNode).mockReturnValue({ type: 'workflow-node' } as unknown as WorkflowVariableBlockNode) + vi.mocked(mergeRegister).mockImplementation( + (...cleanups) => + () => + cleanups.forEach((cleanup) => cleanup()), + ) + vi.mocked($createWorkflowVariableBlockNode).mockReturnValue({ + type: 'workflow-node', + } as unknown as WorkflowVariableBlockNode) vi.mocked($applyNodeReplacement).mockImplementation((node: LexicalNode) => node) }) @@ -93,9 +99,7 @@ describe('WorkflowVariableBlockReplacementBlock', () => { mockRegisterNodeTransform.mockReturnValue(transformCleanup) const { unmount, container } = renderWithLexicalContext( - <WorkflowVariableBlockReplacementBlock - workflowNodesMap={workflowNodesMap} - />, + <WorkflowVariableBlockReplacementBlock workflowNodesMap={workflowNodesMap} />, ) expect(container.firstChild).toBeNull() @@ -109,21 +113,21 @@ describe('WorkflowVariableBlockReplacementBlock', () => { it('should throw when WorkflowVariableBlockNode is not registered', () => { mockHasNodes.mockReturnValue(false) - expect(() => renderWithLexicalContext( - <WorkflowVariableBlockReplacementBlock - workflowNodesMap={workflowNodesMap} - />, - )).toThrow('WorkflowVariableBlockNodePlugin: WorkflowVariableBlockNode not registered on editor') + expect(() => + renderWithLexicalContext( + <WorkflowVariableBlockReplacementBlock workflowNodesMap={workflowNodesMap} />, + ), + ).toThrow('WorkflowVariableBlockNodePlugin: WorkflowVariableBlockNode not registered on editor') }) it('should pass matcher and creator to decoratorTransform', () => { renderWithLexicalContext( - <WorkflowVariableBlockReplacementBlock - workflowNodesMap={workflowNodesMap} - />, + <WorkflowVariableBlockReplacementBlock workflowNodesMap={workflowNodesMap} />, ) - const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as (node: LexicalNode) => void + const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as ( + node: LexicalNode, + ) => void const textNode = { id: 'text-node' } as unknown as LexicalNode transformCallback(textNode) @@ -136,15 +140,17 @@ describe('WorkflowVariableBlockReplacementBlock', () => { it('should match variable placeholders and return null for non-placeholder text', () => { renderWithLexicalContext( - <WorkflowVariableBlockReplacementBlock - workflowNodesMap={workflowNodesMap} - />, + <WorkflowVariableBlockReplacementBlock workflowNodesMap={workflowNodesMap} />, ) - const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as (node: LexicalNode) => void + const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as ( + node: LexicalNode, + ) => void transformCallback({ id: 'text-node' } as unknown as LexicalNode) - const getMatch = vi.mocked(decoratorTransform).mock.calls[0]![1] as (text: string) => EntityMatch | null + const getMatch = vi.mocked(decoratorTransform).mock.calls[0]![1] as ( + text: string, + ) => EntityMatch | null const match = getMatch('prefix {{#node-1.output#}} suffix') expect(match).toEqual({ @@ -167,12 +173,14 @@ describe('WorkflowVariableBlockReplacementBlock', () => { />, ) - const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as (node: LexicalNode) => void + const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as ( + node: LexicalNode, + ) => void transformCallback({ id: 'text-node' } as unknown as LexicalNode) - const createNode = vi.mocked(decoratorTransform).mock.calls[0]![2] as ( - textNode: { getTextContent: () => string }, - ) => WorkflowVariableBlockNode + const createNode = vi.mocked(decoratorTransform).mock.calls[0]![2] as (textNode: { + getTextContent: () => string + }) => WorkflowVariableBlockNode const created = createNode({ getTextContent: () => '{{#node-1.output#}}', @@ -191,17 +199,17 @@ describe('WorkflowVariableBlockReplacementBlock', () => { it('should create replacement node without optional callbacks and variable groups', () => { renderWithLexicalContext( - <WorkflowVariableBlockReplacementBlock - workflowNodesMap={workflowNodesMap} - />, + <WorkflowVariableBlockReplacementBlock workflowNodesMap={workflowNodesMap} />, ) - const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as (node: LexicalNode) => void + const transformCallback = mockRegisterNodeTransform.mock.calls[0]![1] as ( + node: LexicalNode, + ) => void transformCallback({ id: 'text-node' } as unknown as LexicalNode) - const createNode = vi.mocked(decoratorTransform).mock.calls[0]![2] as ( - textNode: { getTextContent: () => string }, - ) => WorkflowVariableBlockNode + const createNode = vi.mocked(decoratorTransform).mock.calls[0]![2] as (textNode: { + getTextContent: () => string + }) => WorkflowVariableBlockNode expect(() => createNode({ getTextContent: () => '{{#node-1.output#}}' })).not.toThrow() expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith( diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx index 4b209717e71c97..cd6ea13de75523 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx @@ -1,35 +1,28 @@ -import type { - UpdateWorkflowNodesMapPayload, -} from './index' +import type { UpdateWorkflowNodesMapPayload } from './index' import type { WorkflowNodesMap } from './node' import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' -import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card' +import { + PreviewCard, + PreviewCardContent, + PreviewCardTrigger, +} from '@langgenius/dify-ui/preview-card' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { - COMMAND_PRIORITY_EDITOR, -} from 'lexical' -import { - memo, - useCallback, - useEffect, - useMemo, - useState, -} from 'react' +import { COMMAND_PRIORITY_EDITOR } from 'lexical' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useReactFlow, useStoreApi } from 'reactflow' -import { isRagVariableVar, isSpecialVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' -import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel' import { - VariableLabelInEditor, -} from '@/app/components/workflow/nodes/_base/components/variable/variable-label' + isRagVariableVar, + isSpecialVar, + isSystemVar, +} from '@/app/components/workflow/nodes/_base/components/variable/utils' +import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel' +import { VariableLabelInEditor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { Type } from '@/app/components/workflow/nodes/llm/types' import { isExceptionVariable } from '@/app/components/workflow/utils' import { useSelectOrDelete } from '../../hooks' -import { - DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND, - UPDATE_WORKFLOW_NODES_MAP, -} from './index' +import { DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND, UPDATE_WORKFLOW_NODES_MAP } from './index' import { WorkflowVariableBlockNode } from './node' import { useLlmModelPluginInstalled } from './use-llm-model-plugin-installed' @@ -41,10 +34,7 @@ type WorkflowVariableBlockComponentProps = { environmentVariables?: Var[] conversationVariables?: Var[] ragVariables?: Var[] - getVarType?: (payload: { - nodeId: string - valueSelector: ValueSelector - }) => Type + getVarType?: (payload: { nodeId: string; valueSelector: ValueSelector }) => Type } const WorkflowVariableBlockComponent = ({ @@ -60,32 +50,30 @@ const WorkflowVariableBlockComponent = ({ const variablesLength = variables.length const isRagVar = isRagVariableVar(variables) const isShowAPart = variablesLength > 2 && !isRagVar - const varName = ( - () => { - const isSystem = isSystemVar(variables) - const varName = variables[variablesLength - 1] - return `${isSystem ? 'sys.' : ''}${varName}` - } - )() - const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState<WorkflowNodesMap>(workflowNodesMap) - const [localAvailableVariables, setLocalAvailableVariables] = useState<NodeOutPutVar[]>(availableVariables || []) + const varName = (() => { + const isSystem = isSystemVar(variables) + const varName = variables[variablesLength - 1] + return `${isSystem ? 'sys.' : ''}${varName}` + })() + const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = + useState<WorkflowNodesMap>(workflowNodesMap) + const [localAvailableVariables, setLocalAvailableVariables] = useState<NodeOutPutVar[]>( + availableVariables || [], + ) const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]!] const isException = isExceptionVariable(varName, node?.type) const sourceNodeId = variables[isRagVar ? 1 : 0] const isLlmModelInstalled = useLlmModelPluginInstalled(sourceNodeId!, localWorkflowNodesMap) const variableValid = useMemo(() => { - if (isSpecialVar(variables[0] ?? '')) - return true + if (isSpecialVar(variables[0] ?? '')) return true - if (!variables[1]) - return false + if (!variables[1]) return false - const sourceNode = localAvailableVariables.find(v => v.nodeId === variables[0]) - if (!sourceNode) - return false + const sourceNode = localAvailableVariables.find((v) => v.nodeId === variables[0]) + if (!sourceNode) return false - return sourceNode.vars.some(v => v.variable === variables[1]) + return sourceNode.vars.some((v) => v.variable === variables[1]) }, [localAvailableVariables, variables]) const reactflow = useReactFlow() @@ -110,14 +98,9 @@ const WorkflowVariableBlockComponent = ({ const handleVariableJump = useCallback(() => { const workflowContainer = document.getElementById('workflow-container') - const { - clientWidth, - clientHeight, - } = workflowContainer! + const { clientWidth, clientHeight } = workflowContainer! - const { - setViewport, - } = reactflow + const { setViewport } = reactflow const { transform } = store.getState() const zoom = transform[2] const position = node!.position @@ -140,10 +123,10 @@ const WorkflowVariableBlockComponent = ({ isExceptionVariable={isException} errorMsg={ !variableValid - ? t($ => $['errorMsg.invalidVariable'], { ns: 'workflow' }) + ? t(($) => $['errorMsg.invalidVariable'], { ns: 'workflow' }) : !isLlmModelInstalled - ? t($ => $['errorMsg.modelPluginNotInstalled'], { ns: 'workflow' }) - : undefined + ? t(($) => $['errorMsg.modelPluginNotInstalled'], { ns: 'workflow' }) + : undefined } isSelected={isSelected} ref={ref} @@ -151,8 +134,7 @@ const WorkflowVariableBlockComponent = ({ /> ) - if (!node || !isShowAPart) - return Item + if (!node || !isShowAPart) return Item return ( <PreviewCard> @@ -161,12 +143,14 @@ const WorkflowVariableBlockComponent = ({ <VarFullPathPanel nodeName={node.title} path={variables.slice(1)} - varType={getVarType - ? getVarType({ - nodeId: variables[0]!, - valueSelector: variables, - }) - : Type.string} + varType={ + getVarType + ? getVarType({ + nodeId: variables[0]!, + valueSelector: variables, + }) + : Type.string + } nodeType={node?.type} /> </PreviewCardContent> diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/index.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/index.tsx index ab79630f800056..c764c21981d630 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/index.tsx @@ -1,83 +1,81 @@ import type { WorkflowVariableBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { - $insertNodes, - COMMAND_PRIORITY_EDITOR, - createCommand, -} from 'lexical' -import { - memo, - useEffect, -} from 'react' -import { - $createWorkflowVariableBlockNode, - WorkflowVariableBlockNode, -} from './node' +import { $insertNodes, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' +import { memo, useEffect } from 'react' +import { $createWorkflowVariableBlockNode, WorkflowVariableBlockNode } from './node' -export const INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand('INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND') -export const DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand('DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND') +export const INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand( + 'INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND', +) +export const DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand( + 'DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND', +) export type UpdateWorkflowNodesMapPayload = { workflowNodesMap: NonNullable<WorkflowVariableBlockType['workflowNodesMap']> availableVariables: NonNullable<WorkflowVariableBlockType['variables']> } -export const UPDATE_WORKFLOW_NODES_MAP = createCommand<UpdateWorkflowNodesMapPayload>('UPDATE_WORKFLOW_NODES_MAP') -const WorkflowVariableBlock = memo(({ - workflowNodesMap = {}, - variables: workflowAvailableVariables, - onInsert, - onDelete, - getVarType, -}: WorkflowVariableBlockType) => { - const [editor] = useLexicalComposerContext() +export const UPDATE_WORKFLOW_NODES_MAP = createCommand<UpdateWorkflowNodesMapPayload>( + 'UPDATE_WORKFLOW_NODES_MAP', +) +const WorkflowVariableBlock = memo( + ({ + workflowNodesMap = {}, + variables: workflowAvailableVariables, + onInsert, + onDelete, + getVarType, + }: WorkflowVariableBlockType) => { + const [editor] = useLexicalComposerContext() - useEffect(() => { - editor.update(() => { - editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, { - workflowNodesMap: workflowNodesMap || {}, - availableVariables: workflowAvailableVariables || [], + useEffect(() => { + editor.update(() => { + editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, { + workflowNodesMap: workflowNodesMap || {}, + availableVariables: workflowAvailableVariables || [], + }) }) - }) - }, [editor, workflowNodesMap, workflowAvailableVariables]) + }, [editor, workflowNodesMap, workflowAvailableVariables]) - useEffect(() => { - if (!editor.hasNodes([WorkflowVariableBlockNode])) - throw new Error('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor') + useEffect(() => { + if (!editor.hasNodes([WorkflowVariableBlockNode])) + throw new Error( + 'WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor', + ) - return mergeRegister( - editor.registerCommand( - INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, - (variables: string[]) => { - const workflowVariableBlockNode = $createWorkflowVariableBlockNode( - variables, - workflowNodesMap, - getVarType, - workflowAvailableVariables || [], - ) + return mergeRegister( + editor.registerCommand( + INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, + (variables: string[]) => { + const workflowVariableBlockNode = $createWorkflowVariableBlockNode( + variables, + workflowNodesMap, + getVarType, + workflowAvailableVariables || [], + ) - $insertNodes([workflowVariableBlockNode]) - if (onInsert) - onInsert() + $insertNodes([workflowVariableBlockNode]) + if (onInsert) onInsert() - return true - }, - COMMAND_PRIORITY_EDITOR, - ), - editor.registerCommand( - DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND, - () => { - if (onDelete) - onDelete() + return true + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND, + () => { + if (onDelete) onDelete() - return true - }, - COMMAND_PRIORITY_EDITOR, - ), - ) - }, [editor, onInsert, onDelete, workflowNodesMap, getVarType, workflowAvailableVariables]) + return true + }, + COMMAND_PRIORITY_EDITOR, + ), + ) + }, [editor, onInsert, onDelete, workflowNodesMap, getVarType, workflowAvailableVariables]) - return null -}) + return null + }, +) WorkflowVariableBlock.displayName = 'WorkflowVariableBlock' export { WorkflowVariableBlock } diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx index 059216521d2929..257c29a203c8e0 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx @@ -93,8 +93,7 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element> workflowNodesMap: this.getWorkflowNodesMap(), getVarType: this.getVarType(), } - if (this.getAvailableVariables()) - json.availableVariables = this.getAvailableVariables() + if (this.getAvailableVariables()) json.availableVariables = this.getAvailableVariables() return json } diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/use-llm-model-plugin-installed.ts b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/use-llm-model-plugin-installed.ts index 0aa98881b3ecce..5c6f1b1737574a 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/use-llm-model-plugin-installed.ts +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/use-llm-model-plugin-installed.ts @@ -8,16 +8,11 @@ export function useLlmModelPluginInstalled( workflowNodesMap: WorkflowNodesMap | undefined, ): boolean { const node = workflowNodesMap?.[nodeId] - const modelProvider = node?.type === BlockEnum.LLM - ? node.modelProvider - : undefined + const modelProvider = node?.type === BlockEnum.LLM ? node.modelProvider : undefined const modelPluginId = modelProvider ? extractPluginId(modelProvider) : undefined return useProviderContextSelector((state) => { - if (!modelPluginId) - return true - return state.modelProviders.some(p => - extractPluginId(p.provider) === modelPluginId, - ) + if (!modelPluginId) return true + return state.modelProviders.some((p) => extractPluginId(p.provider) === modelPluginId) }) } diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/workflow-variable-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/workflow-variable-block-replacement-block.tsx index e3c947d7867566..ee83c8e67963f3 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/workflow-variable-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/workflow-variable-block-replacement-block.tsx @@ -3,11 +3,7 @@ import type { WorkflowVariableBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' import { $applyNodeReplacement } from 'lexical' -import { - memo, - useCallback, - useEffect, -} from 'react' +import { memo, useCallback, useEffect } from 'react' import { VAR_REGEX as REGEX, resetReg } from '@/config' import { decoratorTransform } from '../../utils' import { CustomTextNode } from '../custom-text/node' @@ -24,27 +20,32 @@ const WorkflowVariableBlockReplacementBlock = ({ useEffect(() => { if (!editor.hasNodes([WorkflowVariableBlockNode])) - throw new Error('WorkflowVariableBlockNodePlugin: WorkflowVariableBlockNode not registered on editor') + throw new Error( + 'WorkflowVariableBlockNodePlugin: WorkflowVariableBlockNode not registered on editor', + ) }, [editor]) - const createWorkflowVariableBlockNode = useCallback((textNode: TextNode): WorkflowVariableBlockNode => { - if (onInsert) - onInsert() + const createWorkflowVariableBlockNode = useCallback( + (textNode: TextNode): WorkflowVariableBlockNode => { + if (onInsert) onInsert() - const nodePathString = textNode.getTextContent().slice(3, -3) - return $applyNodeReplacement($createWorkflowVariableBlockNode( - nodePathString.split('.'), - workflowNodesMap, - getVarType, - variables || [], - )) - }, [onInsert, workflowNodesMap, getVarType, variables]) + const nodePathString = textNode.getTextContent().slice(3, -3) + return $applyNodeReplacement( + $createWorkflowVariableBlockNode( + nodePathString.split('.'), + workflowNodesMap, + getVarType, + variables || [], + ), + ) + }, + [onInsert, workflowNodesMap, getVarType, variables], + ) const getMatch = useCallback((text: string) => { const matchArr = REGEX.exec(text) - if (matchArr === null) - return null + if (matchArr === null) return null const startOffset = matchArr.index const endOffset = startOffset + matchArr[0].length @@ -54,16 +55,17 @@ const WorkflowVariableBlockReplacementBlock = ({ } }, []) - const transformListener = useCallback((textNode: CustomTextNode) => { - resetReg() - return decoratorTransform(textNode, getMatch, createWorkflowVariableBlockNode) - }, [createWorkflowVariableBlockNode, getMatch]) + const transformListener = useCallback( + (textNode: CustomTextNode) => { + resetReg() + return decoratorTransform(textNode, getMatch, createWorkflowVariableBlockNode) + }, + [createWorkflowVariableBlockNode, getMatch], + ) useEffect(() => { resetReg() - return mergeRegister( - editor.registerNodeTransform(CustomTextNode, transformListener), - ) + return mergeRegister(editor.registerNodeTransform(CustomTextNode, transformListener)) }, []) return null diff --git a/web/app/components/base/prompt-editor/prompt-editor-content.tsx b/web/app/components/base/prompt-editor/prompt-editor-content.tsx index 323334ee478a00..bfefdccaf12da1 100644 --- a/web/app/components/base/prompt-editor/prompt-editor-content.tsx +++ b/web/app/components/base/prompt-editor/prompt-editor-content.tsx @@ -1,6 +1,10 @@ import type { EditorState } from 'lexical' import type { FC } from 'react' -import type { Hotkey, ShortcutPopupDisplayMode, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin' +import type { + Hotkey, + ShortcutPopupDisplayMode, + ShortcutPopupInsertHandler, +} from './plugins/shortcuts-popup-plugin' import type { AgentOutputBlockType, ContextBlockType, @@ -23,46 +27,19 @@ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' import * as React from 'react' -import { - AgentOutputBlock, - AgentOutputBlockReplacementBlock, -} from './plugins/agent-output-block' +import { AgentOutputBlock, AgentOutputBlockReplacementBlock } from './plugins/agent-output-block' import ComponentPickerBlock from './plugins/component-picker-block' -import { - ContextBlock, - ContextBlockReplacementBlock, -} from './plugins/context-block' -import { - CurrentBlock, - CurrentBlockReplacementBlock, -} from './plugins/current-block' +import { ContextBlock, ContextBlockReplacementBlock } from './plugins/context-block' +import { CurrentBlock, CurrentBlockReplacementBlock } from './plugins/current-block' import DraggableBlockPlugin from './plugins/draggable-plugin' -import { - ErrorMessageBlock, - ErrorMessageBlockReplacementBlock, -} from './plugins/error-message-block' -import { - HistoryBlock, - HistoryBlockReplacementBlock, -} from './plugins/history-block' -import { - HITLInputBlock, - HITLInputBlockReplacementBlock, -} from './plugins/hitl-input-block' -import { - LastRunBlock, - LastRunReplacementBlock, -} from './plugins/last-run-block' +import { ErrorMessageBlock, ErrorMessageBlockReplacementBlock } from './plugins/error-message-block' +import { HistoryBlock, HistoryBlockReplacementBlock } from './plugins/history-block' +import { HITLInputBlock, HITLInputBlockReplacementBlock } from './plugins/hitl-input-block' +import { LastRunBlock, LastRunReplacementBlock } from './plugins/last-run-block' import OnBlurBlock from './plugins/on-blur-or-focus-block' import Placeholder from './plugins/placeholder' -import { - QueryBlock, - QueryBlockReplacementBlock, -} from './plugins/query-block' -import { - RequestURLBlock, - RequestURLBlockReplacementBlock, -} from './plugins/request-url-block' +import { QueryBlock, QueryBlockReplacementBlock } from './plugins/query-block' +import { RequestURLBlock, RequestURLBlockReplacementBlock } from './plugins/request-url-block' import RosterReferenceBlock from './plugins/roster-reference-block' import { RosterReferenceBlockContext } from './plugins/roster-reference-block/context' import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin' @@ -77,10 +54,13 @@ import { type ShortcutPopup = { hotkey: Hotkey displayMode?: ShortcutPopupDisplayMode - Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }> + Popup: React.ComponentType<{ onClose: () => void; onInsert: ShortcutPopupInsertHandler }> } -type PromptEditorContentAriaProps = Pick<React.AriaAttributes, 'aria-controls' | 'aria-haspopup' | 'aria-label' | 'aria-labelledby'> +type PromptEditorContentAriaProps = Pick< + React.AriaAttributes, + 'aria-controls' | 'aria-haspopup' | 'aria-label' | 'aria-labelledby' +> type PromptEditorContentProps = PromptEditorContentAriaProps & { compact?: boolean @@ -115,11 +95,9 @@ type PromptEditorContentProps = PromptEditorContentAriaProps & { const getShortcutPopupKey = ( hotkey: Hotkey, displayMode: ShortcutPopupDisplayMode | undefined, - Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }>, + Popup: React.ComponentType<{ onClose: () => void; onInsert: ShortcutPopupInsertHandler }>, ) => { - const hotkeyKey = typeof hotkey === 'function' - ? hotkey.name || 'custom' - : JSON.stringify(hotkey) + const hotkeyKey = typeof hotkey === 'function' ? hotkey.name || 'custom' : JSON.stringify(hotkey) const popupKey = Popup.displayName ?? Popup.name ?? 'Popup' return `${popupKey}:${displayMode ?? 'default'}:${hotkeyKey}` @@ -161,7 +139,7 @@ const PromptEditorContent: FC<PromptEditorContentProps> = ({ return ( <RosterReferenceBlockContext value={rosterReferenceBlock}> <RichTextPlugin - contentEditable={( + contentEditable={ <ContentEditable aria-controls={ariaControls} aria-haspopup={ariaHasPopup} @@ -174,18 +152,22 @@ const PromptEditorContent: FC<PromptEditorContentProps> = ({ )} style={style || {}} /> - )} - placeholder={( + } + placeholder={ <Placeholder value={placeholder} className={cn('truncate', placeholderClassName)} compact={compact} /> - )} + } ErrorBoundary={LexicalErrorBoundary} /> {shortcutPopups.map(({ hotkey, displayMode, Popup }) => ( - <ShortcutsPopupPlugin key={getShortcutPopupKey(hotkey, displayMode, Popup)} hotkey={hotkey} displayMode={displayMode}> + <ShortcutsPopupPlugin + key={getShortcutPopupKey(hotkey, displayMode, Popup)} + hotkey={hotkey} + displayMode={displayMode} + > {(closePortal, onInsert) => <Popup onClose={closePortal} onInsert={onInsert} />} </ShortcutsPopupPlugin> ))} @@ -247,9 +229,7 @@ const PromptEditorContent: FC<PromptEditorContentProps> = ({ <VariableValueBlock /> </> )} - {rosterReferenceBlock?.show && ( - <RosterReferenceBlock /> - )} + {rosterReferenceBlock?.show && <RosterReferenceBlock />} {workflowVariableBlock?.show && ( <> <WorkflowVariableBlock {...workflowVariableBlock} /> @@ -292,16 +272,12 @@ const PromptEditorContent: FC<PromptEditorContentProps> = ({ <LastRunReplacementBlock {...lastRunBlock} /> </> )} - {isSupportFileVar && ( - <VariableValueBlock /> - )} + {isSupportFileVar && <VariableValueBlock />} <OnChangePlugin onChange={onEditorChange} /> <OnBlurBlock onBlur={onBlur} onFocus={onFocus} /> <UpdateBlock instanceId={instanceId} /> <HistoryPlugin /> - {floatingAnchorElem && ( - <DraggableBlockPlugin anchorElem={floatingAnchorElem} /> - )} + {floatingAnchorElem && <DraggableBlockPlugin anchorElem={floatingAnchorElem} />} </RosterReferenceBlockContext> ) } diff --git a/web/app/components/base/prompt-editor/types.ts b/web/app/components/base/prompt-editor/types.ts index d87e19dbf744d4..6e2675dbb67464 100644 --- a/web/app/components/base/prompt-editor/types.ts +++ b/web/app/components/base/prompt-editor/types.ts @@ -6,11 +6,7 @@ import type { AgentOutputTypeOptionValue } from './plugins/agent-output-block/ut import type { Dataset } from './plugins/context-block' import type { RoleName } from './plugins/history-block' import type { RosterReferenceToken } from './plugins/roster-reference-block/utils' -import type { - Node, - NodeOutPutVar, - ValueSelector, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' export type Option = { value: string @@ -74,10 +70,7 @@ export type ExternalToolBlockType = { onAddExternalTool?: () => void } -export type GetVarType = (payload: { - nodeId: string - valueSelector: ValueSelector -}) => Type +export type GetVarType = (payload: { nodeId: string; valueSelector: ValueSelector }) => Type export type WorkflowVariableBlockType = { show?: boolean @@ -97,7 +90,12 @@ export type AgentOutputBlockType = { onEdit?: (name: string, outputType: AgentOutputTypeOptionValue) => void } -export type WorkflowNodesMap = Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'> & { modelProvider?: string }> +export type WorkflowNodesMap = Record< + string, + Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'> & { + modelProvider?: string + } +> export type HITLInputBlockType = { show?: boolean diff --git a/web/app/components/base/prompt-editor/utils.ts b/web/app/components/base/prompt-editor/utils.ts index 7fbcde40db4670..b78bfb264e3615 100644 --- a/web/app/components/base/prompt-editor/utils.ts +++ b/web/app/components/base/prompt-editor/utils.ts @@ -1,17 +1,7 @@ import type { EntityMatch } from '@lexical/text' -import type { - Klass, - LexicalEditor, - LexicalNode, - TextNode, -} from 'lexical' +import type { Klass, LexicalEditor, LexicalNode, TextNode } from 'lexical' import type { MenuTextMatch } from './types' -import { - $createTextNode, - $getSelection, - $isRangeSelection, - $isTextNode, -} from 'lexical' +import { $createTextNode, $getSelection, $isRangeSelection, $isTextNode } from 'lexical' import { CustomTextNode } from './plugins/custom-text/node' export function registerLexicalTextEntity<T extends TextNode>( @@ -35,8 +25,7 @@ export function registerLexicalTextEntity<T extends TextNode>( } const textNodeTransform = (node: TextNode) => { - if (!node.isSimpleText()) - return + if (!node.isSimpleText()) return const prevSibling = node.getPreviousSibling() let text = node.getTextContent() @@ -52,8 +41,7 @@ export function registerLexicalTextEntity<T extends TextNode>( if (prevMatch === null || getMode(prevSibling) !== 0) { replaceWithSimpleText(prevSibling) return - } - else { + } else { const diff = prevMatch.end - previousText.length if (diff > 0) { @@ -64,8 +52,7 @@ export function registerLexicalTextEntity<T extends TextNode>( if (diff === text.length) { node.remove() - } - else { + } else { const remainingText = text.slice(diff) node.setTextContent(remainingText) } @@ -73,8 +60,7 @@ export function registerLexicalTextEntity<T extends TextNode>( return } } - } - else if (prevMatch === null || prevMatch.start < previousText.length) { + } else if (prevMatch === null || prevMatch.start < previousText.length) { return } } @@ -92,44 +78,40 @@ export function registerLexicalTextEntity<T extends TextNode>( const nextMatch = getMatch(nextText) if (nextMatch === null) { - if (isTargetNode(nextSibling)) - replaceWithSimpleText(nextSibling) - else - nextSibling.markDirty() + if (isTargetNode(nextSibling)) replaceWithSimpleText(nextSibling) + else nextSibling.markDirty() return - } - else if (nextMatch.start !== 0) { + } else if (nextMatch.start !== 0) { return } } - } - else { + } else { const nextMatch = getMatch(nextText) - if (nextMatch !== null && nextMatch.start === 0) - return + if (nextMatch !== null && nextMatch.start === 0) return } - if (match === null) - return + if (match === null) return - if (match.start === 0 && $isTextNode(prevSibling) && prevSibling.isTextEntity()) - continue + if (match.start === 0 && $isTextNode(prevSibling) && prevSibling.isTextEntity()) continue let nodeToReplace if (match.start === 0) [nodeToReplace, currentNode] = currentNode.splitText(match.end) as [TextNode, TextNode] else - [, nodeToReplace, currentNode] = currentNode.splitText(match.start, match.end) as [TextNode, TextNode, TextNode] + [, nodeToReplace, currentNode] = currentNode.splitText(match.start, match.end) as [ + TextNode, + TextNode, + TextNode, + ] const replacementNode = createNode(nodeToReplace!) replacementNode.setFormat(nodeToReplace!.getFormat()) nodeToReplace!.replace(replacementNode) - if (currentNode == null) - return + if (currentNode == null) return } } @@ -160,8 +142,7 @@ export function registerLexicalTextEntity<T extends TextNode>( if ($isTextNode(nextSibling) && nextSibling.isTextEntity()) { replaceWithSimpleText(nextSibling) // This may have already been converted in the previous block - if (isTargetNode(node)) - replaceWithSimpleText(node) + if (isTargetNode(node)) replaceWithSimpleText(node) } } @@ -178,8 +159,7 @@ export const decoratorTransform = ( allowAdjacentMatches?: boolean }, ) => { - if (!node.isSimpleText()) - return + if (!node.isSimpleText()) return const prevSibling = node.getPreviousSibling() let text = node.getTextContent() @@ -201,99 +181,87 @@ export const decoratorTransform = ( if (nextMatch === null) { nextSibling.markDirty() return - } - else if (nextMatch.start !== 0) { + } else if (nextMatch.start !== 0) { return } } - } - else { + } else { const nextMatch = getMatch(nextText) - if (!options?.allowAdjacentMatches && nextMatch !== null && nextMatch.start === 0) - return + if (!options?.allowAdjacentMatches && nextMatch !== null && nextMatch.start === 0) return } - if (match === null) - return + if (match === null) return - if (match.start === 0 && $isTextNode(prevSibling) && prevSibling.isTextEntity()) - continue + if (match.start === 0 && $isTextNode(prevSibling) && prevSibling.isTextEntity()) continue let nodeToReplace if (match.start === 0) - [nodeToReplace, currentNode] = currentNode.splitText(match.end) as [CustomTextNode, CustomTextNode] + [nodeToReplace, currentNode] = currentNode.splitText(match.end) as [ + CustomTextNode, + CustomTextNode, + ] else - [, nodeToReplace, currentNode] = currentNode.splitText(match.start, match.end) as [CustomTextNode, CustomTextNode, CustomTextNode] + [, nodeToReplace, currentNode] = currentNode.splitText(match.start, match.end) as [ + CustomTextNode, + CustomTextNode, + CustomTextNode, + ] const replacementNode = createNode(nodeToReplace!) nodeToReplace!.replace(replacementNode) - if (currentNode == null) - return + if (currentNode == null) return } } -function getFullMatchOffset( - documentText: string, - entryText: string, - offset: number, -): number { +function getFullMatchOffset(documentText: string, entryText: string, offset: number): number { let triggerOffset = offset for (let i = triggerOffset; i <= entryText.length; i++) { - if (documentText.slice(-i) === entryText.slice(0, i)) - triggerOffset = i + if (documentText.slice(-i) === entryText.slice(0, i)) triggerOffset = i } return triggerOffset } export function $splitNodeContainingQuery(match: MenuTextMatch): TextNode | null { const selection = $getSelection() - if (!$isRangeSelection(selection) || !selection.isCollapsed()) - return null + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null const anchor = selection.anchor - if (anchor.type !== 'text') - return null + if (anchor.type !== 'text') return null const anchorNode = anchor.getNode() - if (!anchorNode.isSimpleText()) - return null + if (!anchorNode.isSimpleText()) return null const selectionOffset = anchor.offset const textContent = anchorNode.getTextContent().slice(0, selectionOffset) const characterOffset = match.replaceableString.length - const queryOffset = getFullMatchOffset( - textContent, - match.matchingString, - characterOffset, - ) + const queryOffset = getFullMatchOffset(textContent, match.matchingString, characterOffset) const startOffset = selectionOffset - queryOffset - if (startOffset < 0) - return null + if (startOffset < 0) return null let newNode - if (startOffset === 0) - [newNode] = anchorNode.splitText(selectionOffset) - else - [, newNode] = anchorNode.splitText(startOffset, selectionOffset) + if (startOffset === 0) [newNode] = anchorNode.splitText(selectionOffset) + else [, newNode] = anchorNode.splitText(startOffset, selectionOffset) return newNode! } export function textToEditorState(text: string) { - const paragraph = text && (typeof text === 'string') ? text.split('\n') : [''] + const paragraph = text && typeof text === 'string' ? text.split('\n') : [''] return JSON.stringify({ root: { children: paragraph.map((p) => { return { - children: [{ - detail: 0, - format: 0, - mode: 'normal', - style: '', - text: p, - type: 'custom-text', - version: 1, - }], + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: p, + type: 'custom-text', + version: 1, + }, + ], direction: 'ltr', format: '', indent: 0, diff --git a/web/app/components/base/prompt-log-modal/__tests__/index.spec.tsx b/web/app/components/base/prompt-log-modal/__tests__/index.spec.tsx index d47cafd14bcb58..9f14b066fb93f1 100644 --- a/web/app/components/base/prompt-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-log-modal/__tests__/index.spec.tsx @@ -64,7 +64,14 @@ describe('PromptLogModal', () => { }) it('returns null when currentLogItem.log is missing', () => { - const { container } = render(<PromptLogModal {...defaultProps} currentLogItem={{ id: '1' } as unknown as Parameters<typeof PromptLogModal>[0]['currentLogItem']} />) + const { container } = render( + <PromptLogModal + {...defaultProps} + currentLogItem={ + { id: '1' } as unknown as Parameters<typeof PromptLogModal>[0]['currentLogItem'] + } + />, + ) expect(container.firstChild).toBeNull() }) }) @@ -80,9 +87,7 @@ describe('PromptLogModal', () => { it('calls onCancel when clicking outside', async () => { const onCancel = vi.fn() - render( - <PromptLogModal {...defaultProps} onCancel={onCancel} />, - ) + render(<PromptLogModal {...defaultProps} onCancel={onCancel} />) expect(useClickAway).toHaveBeenCalled() expect(clickAwayHandlers.length).toBeGreaterThan(0) diff --git a/web/app/components/base/prompt-log-modal/card.tsx b/web/app/components/base/prompt-log-modal/card.tsx index 53661440db2146..bb5585d6890792 100644 --- a/web/app/components/base/prompt-log-modal/card.tsx +++ b/web/app/components/base/prompt-log-modal/card.tsx @@ -2,39 +2,35 @@ import type { FC } from 'react' import { CopyFeedbackNew } from '@/app/components/base/copy-feedback' type CardProps = { - log: { role: string, text: string }[] + log: { role: string; text: string }[] } -const Card: FC<CardProps> = ({ - log, -}) => { +const Card: FC<CardProps> = ({ log }) => { return ( <> - { - log.length === 1 && ( - <div className="px-4 py-2"> - <div className="whitespace-pre-line text-text-secondary"> - {log[0]!.text} + {log.length === 1 && ( + <div className="px-4 py-2"> + <div className="whitespace-pre-line text-text-secondary">{log[0]!.text}</div> + </div> + )} + {log.length > 1 && ( + <div> + {log.map((item, index) => ( + <div + key={index} + className="group/card mb-2 rounded-xl px-4 pt-2 pb-4 last-of-type:mb-0 hover:bg-state-base-hover" + > + <div className="flex h-8 items-center justify-between"> + <div className="font-semibold text-[#2D31A6]">{item.role.toUpperCase()}</div> + <CopyFeedbackNew + className="hidden size-6 group-hover/card:block" + content={item.text} + /> + </div> + <div className="whitespace-pre-line text-text-secondary">{item.text}</div> </div> - </div> - ) - } - { - log.length > 1 && ( - <div> - { - log.map((item, index) => ( - <div key={index} className="group/card mb-2 rounded-xl px-4 pt-2 pb-4 last-of-type:mb-0 hover:bg-state-base-hover"> - <div className="flex h-8 items-center justify-between"> - <div className="font-semibold text-[#2D31A6]">{item.role.toUpperCase()}</div> - <CopyFeedbackNew className="hidden size-6 group-hover/card:block" content={item.text} /> - </div> - <div className="whitespace-pre-line text-text-secondary">{item.text}</div> - </div> - )) - } - </div> - ) - } + ))} + </div> + )} </> ) } diff --git a/web/app/components/base/prompt-log-modal/index.stories.tsx b/web/app/components/base/prompt-log-modal/index.stories.tsx index 42f90e6a57edc4..3f3b3092d42209 100644 --- a/web/app/components/base/prompt-log-modal/index.stories.tsx +++ b/web/app/components/base/prompt-log-modal/index.stories.tsx @@ -40,10 +40,7 @@ const PromptLogPreview = (props: PromptLogModalProps) => { return ( <div className="relative min-h-[540px] w-full bg-background-default-subtle p-6"> - <PromptLogModal - {...props} - currentLogItem={mockLogItem} - /> + <PromptLogModal {...props} currentLogItem={mockLogItem} /> </div> ) } @@ -55,7 +52,8 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Shows the prompt and message transcript used for a chat completion, with copy-to-clipboard support for single prompts.', + component: + 'Shows the prompt and message transcript used for a chat completion, with copy-to-clipboard support for single prompts.', }, }, }, diff --git a/web/app/components/base/prompt-log-modal/index.tsx b/web/app/components/base/prompt-log-modal/index.tsx index 4c063b03bb4906..8c302b6fc5cc49 100644 --- a/web/app/components/base/prompt-log-modal/index.tsx +++ b/web/app/components/base/prompt-log-modal/index.tsx @@ -11,26 +11,20 @@ type PromptLogModalProps = { width: number onCancel: () => void } -const PromptLogModal: FC<PromptLogModalProps> = ({ - currentLogItem, - width, - onCancel, -}) => { +const PromptLogModal: FC<PromptLogModalProps> = ({ currentLogItem, width, onCancel }) => { const { t } = useTranslation() const ref = useRef(null) const [mounted, setMounted] = useState(false) useClickAway(() => { - if (mounted) - onCancel() + if (mounted) onCancel() }, ref) useEffect(() => { setMounted(true) }, []) - if (!currentLogItem || !currentLogItem.log) - return null + if (!currentLogItem || !currentLogItem.log) return null return ( <div @@ -47,17 +41,15 @@ const PromptLogModal: FC<PromptLogModalProps> = ({ <div className="flex h-14 shrink-0 items-center justify-between border-b border-divider-regular pr-5 pl-6"> <div className="text-base font-semibold text-text-primary">PROMPT LOG</div> <div className="flex items-center"> - { - currentLogItem.log?.length === 1 && ( - <> - <CopyFeedbackNew className="size-6" content={currentLogItem.log[0]!.text} /> - <div className="mx-2.5 h-[14px] w-px bg-divider-regular" /> - </> - ) - } + {currentLogItem.log?.length === 1 && ( + <> + <CopyFeedbackNew className="size-6" content={currentLogItem.log[0]!.text} /> + <div className="mx-2.5 h-[14px] w-px bg-divider-regular" /> + </> + )} <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onCancel} className="flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-components-button-secondary-accent-border" > diff --git a/web/app/components/base/qrcode/__tests__/index.spec.tsx b/web/app/components/base/qrcode/__tests__/index.spec.tsx index 0029f1840afc2e..beb6a7d80a17ff 100644 --- a/web/app/components/base/qrcode/__tests__/index.spec.tsx +++ b/web/app/components/base/qrcode/__tests__/index.spec.tsx @@ -17,7 +17,9 @@ describe('ShareQRCode', () => { describe('Rendering', () => { it('renders correctly', () => { render(<ShareQRCode content={content} />) - expect(screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }), + ).toBeInTheDocument() }) }) @@ -27,7 +29,9 @@ describe('ShareQRCode', () => { render(<ShareQRCode content={content} />) expect(screen.queryByRole('img')).not.toBeInTheDocument() - const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) + const trigger = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.qrcode.title', + }) await user.click(trigger) expect(screen.getByRole('img')).toBeInTheDocument() @@ -45,7 +49,9 @@ describe('ShareQRCode', () => { </div>, ) - const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) + const trigger = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.qrcode.title', + }) await user.click(trigger) expect(screen.getByRole('img')).toBeInTheDocument() @@ -57,7 +63,9 @@ describe('ShareQRCode', () => { const user = userEvent.setup() render(<ShareQRCode content={content} />) - const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) + const trigger = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.qrcode.title', + }) await user.click(trigger) const canvas = screen.getByRole('img') @@ -75,18 +83,21 @@ describe('ShareQRCode', () => { try { render(<ShareQRCode content={content} />) - const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) + const trigger = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.qrcode.title', + }) await user.click(trigger!) - const downloadBtn = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.download' }) + const downloadBtn = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.qrcode.download', + }) await user.click(downloadBtn) expect(downloadUrl).toHaveBeenCalledWith({ url: 'data:image/png;base64,test', fileName: 'qrcode.png', }) - } - finally { + } finally { HTMLCanvasElement.prototype.toDataURL = originalToDataURL } }) @@ -95,24 +106,26 @@ describe('ShareQRCode', () => { const user = userEvent.setup() render(<ShareQRCode content={content} />) - const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) + const trigger = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.qrcode.title', + }) await user.click(trigger) // Override querySelector on the panel to simulate canvas not being found const panel = screen.getByRole('img').parentElement! const origQuerySelector = panel.querySelector.bind(panel) panel.querySelector = ((sel: string) => { - if (sel === 'canvas') - return null + if (sel === 'canvas') return null return origQuerySelector(sel) }) as typeof panel.querySelector try { - const downloadBtn = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.download' }) + const downloadBtn = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.qrcode.download', + }) await user.click(downloadBtn) expect(downloadUrl).not.toHaveBeenCalled() - } - finally { + } finally { panel.querySelector = origQuerySelector } }) @@ -121,7 +134,9 @@ describe('ShareQRCode', () => { const user = userEvent.setup() render(<ShareQRCode content={content} />) - const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) + const trigger = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.qrcode.title', + }) await user.click(trigger) // Click on the scan text inside the panel — panel should remain open diff --git a/web/app/components/base/qrcode/index.stories.tsx b/web/app/components/base/qrcode/index.stories.tsx index 2453b990abee5c..ca2553074003a5 100644 --- a/web/app/components/base/qrcode/index.stories.tsx +++ b/web/app/components/base/qrcode/index.stories.tsx @@ -1,11 +1,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' import ShareQRCode from '.' -const QRDemo = ({ - content = 'https://dify.ai', -}: { - content?: string -}) => { +const QRDemo = ({ content = 'https://dify.ai' }: { content?: string }) => { return ( <div className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6"> <p className="text-xs tracking-[0.18em] text-text-tertiary uppercase">Share QR</p> @@ -25,7 +21,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Toggleable QR code generator for sharing app URLs. Clicking the trigger reveals the code with a download CTA.', + component: + 'Toggleable QR code generator for sharing app URLs. Clicking the trigger reveals the code with a download CTA.', }, }, }, diff --git a/web/app/components/base/qrcode/index.tsx b/web/app/components/base/qrcode/index.tsx index 1c05eca9933d0d..e2e690912b53df 100644 --- a/web/app/components/base/qrcode/index.tsx +++ b/web/app/components/base/qrcode/index.tsx @@ -20,18 +20,16 @@ const ShareQRCode = ({ content }: Props) => { const toggleQRCode = (event: React.MouseEvent) => { event.stopPropagation() - setIsShow(prev => !prev) + setIsShow((prev) => !prev) } useEffect(() => { const handleClickOutside = (event: MouseEvent) => { /* v8 ignore next 2 -- this handler can fire during open/close transitions where the panel ref is temporarily null; guard is defensive. @preserve */ - if (qrCodeRef.current && !qrCodeRef.current.contains(event.target as Node)) - setIsShow(false) + if (qrCodeRef.current && !qrCodeRef.current.contains(event.target as Node)) setIsShow(false) } - if (isShow) - document.addEventListener('click', handleClickOutside) + if (isShow) document.addEventListener('click', handleClickOutside) return () => { document.removeEventListener('click', handleClickOutside) @@ -40,8 +38,7 @@ const ShareQRCode = ({ content }: Props) => { const downloadQR = () => { const canvas = qrCodeRef.current?.querySelector('canvas') - if (!(canvas instanceof HTMLCanvasElement)) - return + if (!(canvas instanceof HTMLCanvasElement)) return downloadUrl({ url: canvas.toDataURL(), fileName: 'qrcode.png' }) } @@ -49,20 +46,20 @@ const ShareQRCode = ({ content }: Props) => { event.stopPropagation() } - const tooltipText = t($ => $[`${prefixEmbedded}`], { ns: 'appOverview' }) + const tooltipText = t(($) => $[`${prefixEmbedded}`], { ns: 'appOverview' }) /* v8 ignore next -- react-i18next returns a non-empty key/string in configured runtime; empty fallback protects against missing i18n payloads. @preserve */ const safeTooltipText = tooltipText || '' - const downloadText = t($ => $['overview.appInfo.qrcode.download'], { ns: 'appOverview' }) + const downloadText = t(($) => $['overview.appInfo.qrcode.download'], { ns: 'appOverview' }) return ( <Tooltip> <div className="relative size-6"> <TooltipTrigger - render={( + render={ <ActionButton aria-label={safeTooltipText} onClick={toggleQRCode}> <span className="i-ri-qr-code-line size-4" aria-hidden="true" /> </ActionButton> - )} + } /> {isShow && ( <div @@ -72,7 +69,9 @@ const ShareQRCode = ({ content }: Props) => { > <QRCode size={160} value={content} className="mb-2" /> <div className="flex items-center system-xs-regular"> - <div className="text-text-tertiary">{t($ => $['overview.appInfo.qrcode.scan'], { ns: 'appOverview' })}</div> + <div className="text-text-tertiary"> + {t(($) => $['overview.appInfo.qrcode.scan'], { ns: 'appOverview' })} + </div> <div className="text-text-tertiary">·</div> <button type="button" @@ -85,9 +84,7 @@ const ShareQRCode = ({ content }: Props) => { </div> )} </div> - <TooltipContent> - {safeTooltipText} - </TooltipContent> + <TooltipContent>{safeTooltipText}</TooltipContent> </Tooltip> ) } diff --git a/web/app/components/base/radio-card/__tests__/index.spec.tsx b/web/app/components/base/radio-card/__tests__/index.spec.tsx index 8b8c158a38a864..5f0c67cdb1aa49 100644 --- a/web/app/components/base/radio-card/__tests__/index.spec.tsx +++ b/web/app/components/base/radio-card/__tests__/index.spec.tsx @@ -15,8 +15,13 @@ function RadioCardTypeExamples() { title="Advanced" description="Typed option" /> - {/* @ts-expect-error RadioCard values should stay within the selected RadioGroup value type */} - <RadioCard<ExampleMode> value="invalid" icon={<span>i</span>} title="Invalid" description="Invalid option" /> + <RadioCard<ExampleMode> + // @ts-expect-error RadioCard values should stay within the selected RadioGroup value type + value="invalid" + icon={<span>i</span>} + title="Invalid" + description="Invalid option" + /> </RadioGroup> ) } @@ -78,7 +83,9 @@ describe('RadioCard', () => { expect(radio).toHaveAttribute('aria-checked', 'true') expect(screen.getByText('Config')).toBeInTheDocument() expect(radio.parentElement).toHaveClass('has-[[data-checked]]:border-[1.5px]') - expect(radio.parentElement).toHaveClass('has-[[data-checked]]:bg-components-option-card-option-selected-bg') + expect(radio.parentElement).toHaveClass( + 'has-[[data-checked]]:bg-components-option-card-option-selected-bg', + ) }) it('should apply custom className to the card root and config wrapper', () => { diff --git a/web/app/components/base/radio-card/index.stories.tsx b/web/app/components/base/radio-card/index.stories.tsx index 4747e8b38e40dc..3c5f8536c77745 100644 --- a/web/app/components/base/radio-card/index.stories.tsx +++ b/web/app/components/base/radio-card/index.stories.tsx @@ -11,7 +11,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Radio card for rich single-choice options. Put selectable cards inside `RadioGroup`; the card passes radio props through to `RadioItem` and uses `RadioControl` for the visual dot.', + component: + 'Radio card for rich single-choice options. Put selectable cards inside `RadioGroup`; the card passes radio props through to `RadioItem` and uses `RadioControl` for the visual dot.', }, }, }, @@ -59,7 +60,7 @@ function SelectableCardsDemo() { onValueChange={setSelected} className="w-110 flex-col items-stretch gap-2" > - {options.map(option => ( + {options.map((option) => ( <RadioCard key={option.value} value={option.value} @@ -87,12 +88,12 @@ export const StaticInfoCard: Story = { iconBgClassName="bg-indigo-100" title="Current Retrieval Method" description="This card summarizes the active method and is not a selectable radio option." - chosenConfig={( + chosenConfig={ <div className="flex gap-6 system-xs-regular text-text-tertiary"> <span>Top K: 5</span> <span>Score: 0.8</span> </div> - )} + } /> </div> ), diff --git a/web/app/components/base/radio-card/index.tsx b/web/app/components/base/radio-card/index.tsx index c1e101f877688f..e6261649f86cd2 100644 --- a/web/app/components/base/radio-card/index.tsx +++ b/web/app/components/base/radio-card/index.tsx @@ -39,13 +39,19 @@ function RadioCard<Value = string>(props: Props<Value>) { } = props return ( - <div className={cn( - 'relative rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg p-3', - className, - )} + <div + className={cn( + 'relative rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg p-3', + className, + )} > <div className="flex w-full gap-x-2 text-left"> - <div className={cn(iconBgClassName, 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md')}> + <div + className={cn( + iconBgClassName, + 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md', + )} + > {icon} </div> <div className="min-w-0 grow pr-8"> @@ -56,9 +62,7 @@ function RadioCard<Value = string>(props: Props<Value>) { {Boolean(chosenConfig) && ( <div className="mt-2 flex gap-x-2"> <div className="size-8 shrink-0"></div> - <div className={cn(chosenConfigWrapClassName, 'grow')}> - {chosenConfig} - </div> + <div className={cn(chosenConfigWrapClassName, 'grow')}>{chosenConfig}</div> </div> )} </div> @@ -79,7 +83,12 @@ function RadioCard<Value = string>(props: Props<Value>) { const content = ( <> - <div className={cn(iconBgClassName, 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md')}> + <div + className={cn( + iconBgClassName, + 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md', + )} + > {icon} </div> <div className="min-w-0 grow pr-8"> @@ -96,16 +105,12 @@ function RadioCard<Value = string>(props: Props<Value>) { const config = !!chosenConfig && ( <div className="mt-2 hidden gap-x-2 group-has-data-checked/radio-card:flex"> <div className="size-8 shrink-0"></div> - <div className={cn(chosenConfigWrapClassName, 'grow')}> - {chosenConfig} - </div> + <div className={cn(chosenConfigWrapClassName, 'grow')}>{chosenConfig}</div> </div> ) return ( - <div - className={rootClassName} - > + <div className={rootClassName}> <RadioItem<Value> {...radioRootProps} nativeButton diff --git a/web/app/components/base/search-input/__tests__/index.spec.tsx b/web/app/components/base/search-input/__tests__/index.spec.tsx index 9db43d074fb84e..f601d38f73a8c3 100644 --- a/web/app/components/base/search-input/__tests__/index.spec.tsx +++ b/web/app/components/base/search-input/__tests__/index.spec.tsx @@ -15,7 +15,10 @@ describe('SearchInput', () => { it('renders custom placeholder', () => { render(<SearchInput value="" onValueChange={() => {}} placeholder="Custom Placeholder" />) - expect(screen.getByRole('searchbox', { name: 'common.operation.search' })).toHaveAttribute('placeholder', 'Custom Placeholder') + expect(screen.getByRole('searchbox', { name: 'common.operation.search' })).toHaveAttribute( + 'placeholder', + 'Custom Placeholder', + ) }) it('uses custom aria label', () => { @@ -144,7 +147,9 @@ describe('SearchInput', () => { describe('Style', () => { it('applies custom className', () => { - const { container } = render(<SearchInput value="" onValueChange={() => {}} className="custom-test" />) + const { container } = render( + <SearchInput value="" onValueChange={() => {}} className="custom-test" />, + ) const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-test') }) diff --git a/web/app/components/base/search-input/__tests__/search-state.spec.ts b/web/app/components/base/search-input/__tests__/search-state.spec.ts index 64cb6727e42f5e..2e7dcb440e35e2 100644 --- a/web/app/components/base/search-input/__tests__/search-state.spec.ts +++ b/web/app/components/base/search-input/__tests__/search-state.spec.ts @@ -9,35 +9,43 @@ describe('search-state', () => { }) it('treats search empty as filtering existing source data to no results', () => { - expect(isSearchResultEmpty({ - isLoading: false, - resultCount: 0, - searchText: 'missing', - sourceCount: 2, - })).toBe(true) + expect( + isSearchResultEmpty({ + isLoading: false, + resultCount: 0, + searchText: 'missing', + sourceCount: 2, + }), + ).toBe(true) }) it('does not treat loading or true source empty as search empty', () => { - expect(isSearchResultEmpty({ - isLoading: true, - resultCount: 0, - searchText: 'missing', - sourceCount: 2, - })).toBe(false) - expect(isSearchResultEmpty({ - isLoading: false, - resultCount: 0, - searchText: 'missing', - sourceCount: 0, - })).toBe(false) + expect( + isSearchResultEmpty({ + isLoading: true, + resultCount: 0, + searchText: 'missing', + sourceCount: 2, + }), + ).toBe(false) + expect( + isSearchResultEmpty({ + isLoading: false, + resultCount: 0, + searchText: 'missing', + sourceCount: 0, + }), + ).toBe(false) }) it('supports non-text filters', () => { - expect(isSearchResultEmpty({ - hasActiveFilter: true, - isLoading: false, - resultCount: 0, - sourceCount: 2, - })).toBe(true) + expect( + isSearchResultEmpty({ + hasActiveFilter: true, + isLoading: false, + resultCount: 0, + sourceCount: 2, + }), + ).toBe(true) }) }) diff --git a/web/app/components/base/search-input/index.stories.tsx b/web/app/components/base/search-input/index.stories.tsx index eec9eae5101068..9199116260ac2e 100644 --- a/web/app/components/base/search-input/index.stories.tsx +++ b/web/app/components/base/search-input/index.stories.tsx @@ -11,7 +11,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Search input component with search icon, clear button, and IME composition support for Asian languages.', + component: + 'Search input component with search icon, clear button, and IME composition support for Asian languages.', }, }, }, @@ -59,9 +60,7 @@ const SearchInputDemo = (args: ComponentProps<typeof SearchInput>) => { /> {value && ( <div className="mt-3 text-sm text-gray-600"> - Searching for: - {' '} - <span className="font-semibold">{value}</span> + Searching for: <span className="font-semibold">{value}</span> </div> )} </div> @@ -70,7 +69,7 @@ const SearchInputDemo = (args: ComponentProps<typeof SearchInput>) => { // Default state export const Default: Story = { - render: args => <SearchInputDemo {...args} />, + render: (args) => <SearchInputDemo {...args} />, args: { placeholder: 'Search...', value: '', @@ -82,7 +81,7 @@ export const Default: Story = { // With initial value export const WithInitialValue: Story = { - render: args => <SearchInputDemo {...args} />, + render: (args) => <SearchInputDemo {...args} />, args: { value: 'Initial search query', placeholder: 'Search...', @@ -91,7 +90,7 @@ export const WithInitialValue: Story = { // Custom placeholder export const CustomPlaceholder: Story = { - render: args => <SearchInputDemo {...args} />, + render: (args) => <SearchInputDemo {...args} />, args: { placeholder: 'Search documents, files, and more...', value: '', @@ -110,10 +109,11 @@ const UserListSearchDemo = () => { { id: 5, name: 'Eve Davis', email: 'eve@example.com', role: 'User' }, ] - const filteredUsers = users.filter(user => - user.name.toLowerCase().includes(searchQuery.toLowerCase()) - || user.email.toLowerCase().includes(searchQuery.toLowerCase()) - || user.role.toLowerCase().includes(searchQuery.toLowerCase()), + const filteredUsers = users.filter( + (user) => + user.name.toLowerCase().includes(searchQuery.toLowerCase()) || + user.email.toLowerCase().includes(searchQuery.toLowerCase()) || + user.role.toLowerCase().includes(searchQuery.toLowerCase()), ) return ( @@ -125,43 +125,28 @@ const UserListSearchDemo = () => { placeholder="Search by name, email, or role..." /> <div className="mt-4 space-y-2"> - {filteredUsers.length > 0 - ? ( - filteredUsers.map(user => ( - <div - key={user.id} - className="rounded-lg border border-gray-200 p-3 hover:bg-gray-50" - > - <div className="flex items-center justify-between"> - <div> - <div className="text-sm font-medium">{user.name}</div> - <div className="text-xs text-gray-500">{user.email}</div> - </div> - <span className="rounded-sm bg-blue-100 px-2 py-1 text-xs text-blue-700"> - {user.role} - </span> - </div> + {filteredUsers.length > 0 ? ( + filteredUsers.map((user) => ( + <div key={user.id} className="rounded-lg border border-gray-200 p-3 hover:bg-gray-50"> + <div className="flex items-center justify-between"> + <div> + <div className="text-sm font-medium">{user.name}</div> + <div className="text-xs text-gray-500">{user.email}</div> </div> - )) - ) - : ( - <div className="py-8 text-center text-sm text-gray-500"> - No users found matching " - {searchQuery} - " + <span className="rounded-sm bg-blue-100 px-2 py-1 text-xs text-blue-700"> + {user.role} + </span> </div> - )} + </div> + )) + ) : ( + <div className="py-8 text-center text-sm text-gray-500"> + No users found matching "{searchQuery}" + </div> + )} </div> <div className="mt-4 text-xs text-gray-500"> - Showing - {' '} - {filteredUsers.length} - {' '} - of - {' '} - {users.length} - {' '} - members + Showing {filteredUsers.length} of {users.length} members </div> </div> ) @@ -185,9 +170,10 @@ const ProductSearchDemo = () => { { id: 6, name: 'Laptop Stand', category: 'Accessories', price: 39 }, ] - const filteredProducts = products.filter(product => - product.name.toLowerCase().includes(searchQuery.toLowerCase()) - || product.category.toLowerCase().includes(searchQuery.toLowerCase()), + const filteredProducts = products.filter( + (product) => + product.name.toLowerCase().includes(searchQuery.toLowerCase()) || + product.category.toLowerCase().includes(searchQuery.toLowerCase()), ) return ( @@ -199,27 +185,20 @@ const ProductSearchDemo = () => { placeholder="Search products..." /> <div className="mt-4 grid grid-cols-2 gap-3"> - {filteredProducts.length > 0 - ? ( - filteredProducts.map(product => ( - <div - key={product.id} - className="rounded-lg border border-gray-200 p-4 transition-shadow hover:shadow-md" - > - <div className="mb-1 text-sm font-medium">{product.name}</div> - <div className="mb-2 text-xs text-gray-500">{product.category}</div> - <div className="text-lg font-semibold text-blue-600"> - $ - {product.price} - </div> - </div> - )) - ) - : ( - <div className="col-span-2 py-8 text-center text-sm text-gray-500"> - No products found - </div> - )} + {filteredProducts.length > 0 ? ( + filteredProducts.map((product) => ( + <div + key={product.id} + className="rounded-lg border border-gray-200 p-4 transition-shadow hover:shadow-md" + > + <div className="mb-1 text-sm font-medium">{product.name}</div> + <div className="mb-2 text-xs text-gray-500">{product.category}</div> + <div className="text-lg font-semibold text-blue-600">${product.price}</div> + </div> + )) + ) : ( + <div className="col-span-2 py-8 text-center text-sm text-gray-500">No products found</div> + )} </div> </div> ) @@ -235,56 +214,78 @@ const DocumentationSearchDemo = () => { const [searchQuery, setSearchQuery] = useState('') const docs = [ - { id: 1, title: 'Getting Started', category: 'Introduction', excerpt: 'Learn the basics of our platform' }, - { id: 2, title: 'API Reference', category: 'Developers', excerpt: 'Complete API documentation and examples' }, - { id: 3, title: 'Authentication Guide', category: 'Security', excerpt: 'Set up OAuth and API key authentication' }, - { id: 4, title: 'Best Practices', category: 'Guides', excerpt: 'Tips for optimal performance and security' }, - { id: 5, title: 'Troubleshooting', category: 'Support', excerpt: 'Common issues and their solutions' }, + { + id: 1, + title: 'Getting Started', + category: 'Introduction', + excerpt: 'Learn the basics of our platform', + }, + { + id: 2, + title: 'API Reference', + category: 'Developers', + excerpt: 'Complete API documentation and examples', + }, + { + id: 3, + title: 'Authentication Guide', + category: 'Security', + excerpt: 'Set up OAuth and API key authentication', + }, + { + id: 4, + title: 'Best Practices', + category: 'Guides', + excerpt: 'Tips for optimal performance and security', + }, + { + id: 5, + title: 'Troubleshooting', + category: 'Support', + excerpt: 'Common issues and their solutions', + }, ] - const filteredDocs = docs.filter(doc => - doc.title.toLowerCase().includes(searchQuery.toLowerCase()) - || doc.category.toLowerCase().includes(searchQuery.toLowerCase()) - || doc.excerpt.toLowerCase().includes(searchQuery.toLowerCase()), + const filteredDocs = docs.filter( + (doc) => + doc.title.toLowerCase().includes(searchQuery.toLowerCase()) || + doc.category.toLowerCase().includes(searchQuery.toLowerCase()) || + doc.excerpt.toLowerCase().includes(searchQuery.toLowerCase()), ) return ( <div style={{ width: '700px' }} className="rounded-lg bg-gray-50 p-6"> <h3 className="mb-2 text-xl font-bold">Documentation</h3> - <p className="mb-4 text-sm text-gray-600">Search our comprehensive guides and API references</p> + <p className="mb-4 text-sm text-gray-600"> + Search our comprehensive guides and API references + </p> <SearchInput value={searchQuery} onValueChange={setSearchQuery} placeholder="Search documentation..." /> <div className="mt-4 space-y-3"> - {filteredDocs.length > 0 - ? ( - filteredDocs.map(doc => ( - <div - key={doc.id} - className="cursor-pointer rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-300" - > - <div className="mb-2 flex items-start justify-between"> - <h4 className="text-base font-semibold">{doc.title}</h4> - <span className="rounded-sm bg-gray-100 px-2 py-1 text-xs text-gray-600"> - {doc.category} - </span> - </div> - <p className="text-sm text-gray-600">{doc.excerpt}</p> - </div> - )) - ) - : ( - <div className="py-12 text-center"> - <div className="mb-2 text-4xl">🔍</div> - <div className="text-sm text-gray-500"> - No documentation found for " - {searchQuery} - " - </div> + {filteredDocs.length > 0 ? ( + filteredDocs.map((doc) => ( + <div + key={doc.id} + className="cursor-pointer rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-300" + > + <div className="mb-2 flex items-start justify-between"> + <h4 className="text-base font-semibold">{doc.title}</h4> + <span className="rounded-sm bg-gray-100 px-2 py-1 text-xs text-gray-600"> + {doc.category} + </span> </div> - )} + <p className="text-sm text-gray-600">{doc.excerpt}</p> + </div> + )) + ) : ( + <div className="py-12 text-center"> + <div className="mb-2 text-4xl">🔍</div> + <div className="text-sm text-gray-500">No documentation found for "{searchQuery}"</div> + </div> + )} </div> </div> ) @@ -309,12 +310,15 @@ const CommandPaletteDemo = () => { { id: 7, name: 'Redo last action', icon: '↪️', shortcut: '⌘⇧Z' }, ] - const filteredCommands = commands.filter(cmd => + const filteredCommands = commands.filter((cmd) => cmd.name.toLowerCase().includes(searchQuery.toLowerCase()), ) return ( - <div style={{ width: '600px' }} className="overflow-hidden rounded-lg border border-gray-300 bg-white shadow-lg"> + <div + style={{ width: '600px' }} + className="overflow-hidden rounded-lg border border-gray-300 bg-white shadow-lg" + > <div className="border-b border-gray-200 p-4"> <SearchInput value={searchQuery} @@ -323,28 +327,22 @@ const CommandPaletteDemo = () => { /> </div> <div className="max-h-[400px] overflow-y-auto"> - {filteredCommands.length > 0 - ? ( - filteredCommands.map(cmd => ( - <div - key={cmd.id} - className="flex cursor-pointer items-center justify-between border-b border-gray-100 px-4 py-3 last:border-b-0 hover:bg-gray-100" - > - <div className="flex items-center gap-3"> - <span className="text-xl">{cmd.icon}</span> - <span className="text-sm">{cmd.name}</span> - </div> - <Kbd> - {cmd.shortcut} - </Kbd> - </div> - )) - ) - : ( - <div className="py-8 text-center text-sm text-gray-500"> - No commands found + {filteredCommands.length > 0 ? ( + filteredCommands.map((cmd) => ( + <div + key={cmd.id} + className="flex cursor-pointer items-center justify-between border-b border-gray-100 px-4 py-3 last:border-b-0 hover:bg-gray-100" + > + <div className="flex items-center gap-3"> + <span className="text-xl">{cmd.icon}</span> + <span className="text-sm">{cmd.name}</span> </div> - )} + <Kbd>{cmd.shortcut}</Kbd> + </div> + )) + ) : ( + <div className="py-8 text-center text-sm text-gray-500">No commands found</div> + )} </div> </div> ) @@ -372,7 +370,7 @@ const LiveSearchWithCountDemo = () => { 'MongoDB Guide', ] - const filteredItems = items.filter(item => + const filteredItems = items.filter((item) => item.toLowerCase().includes(searchQuery.toLowerCase()), ) @@ -382,9 +380,7 @@ const LiveSearchWithCountDemo = () => { <h3 className="text-lg font-semibold">Learning Resources</h3> {searchQuery && ( <span className="text-sm text-gray-500"> - {filteredItems.length} - {' '} - result + {filteredItems.length} result {filteredItems.length !== 1 ? 's' : ''} </span> )} @@ -395,7 +391,7 @@ const LiveSearchWithCountDemo = () => { placeholder="Search resources..." /> <div className="mt-4 space-y-2"> - {filteredItems.map(item => ( + {filteredItems.map((item) => ( <div key={item} className="cursor-pointer rounded-lg border border-gray-200 p-3 transition-colors hover:border-blue-300 hover:bg-blue-50" @@ -427,19 +423,11 @@ const SizeVariationsDemo = () => { </div> <div> <label className="mb-2 block text-xs font-medium text-gray-600">Medium Size</label> - <SearchInput - value={value2} - onValueChange={setValue2} - placeholder="Search..." - /> + <SearchInput value={value2} onValueChange={setValue2} placeholder="Search..." /> </div> <div> <label className="mb-2 block text-xs font-medium text-gray-600">Large Size</label> - <SearchInput - value={value3} - onValueChange={setValue3} - placeholder="Search..." - /> + <SearchInput value={value3} onValueChange={setValue3} placeholder="Search..." /> </div> </div> ) @@ -452,7 +440,7 @@ export const SizeVariations: Story = { // Interactive playground export const Playground: Story = { - render: args => <SearchInputDemo {...args} />, + render: (args) => <SearchInputDemo {...args} />, args: { value: '', placeholder: 'Search...', diff --git a/web/app/components/base/search-input/index.tsx b/web/app/components/base/search-input/index.tsx index 2890312318708f..8dc8091d786c43 100644 --- a/web/app/components/base/search-input/index.tsx +++ b/web/app/components/base/search-input/index.tsx @@ -35,23 +35,22 @@ export function SearchInput({ } return ( - <div className={cn( - 'relative', - className, - )} - > - <span className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" aria-hidden="true" /> + <div className={cn('relative', className)}> + <span + className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" + aria-hidden="true" + /> <Input ref={inputRef} type="search" name="query" - aria-label={ariaLabel ?? t($ => $['operation.search'], { ns: 'common' })} + aria-label={ariaLabel ?? t(($) => $['operation.search'], { ns: 'common' })} className={cn( 'ps-7', !!inputValue && 'pe-7', '[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none', )} - placeholder={placeholder ?? t($ => $['operation.search'], { ns: 'common' })} + placeholder={placeholder ?? t(($) => $['operation.search'], { ns: 'common' })} value={inputValue} onValueChange={(nextValue) => { if (isComposingRef.current) { @@ -75,8 +74,7 @@ export function SearchInput({ setCompositionValue(value) }} onCompositionEnd={(e) => { - if (!isComposingRef.current) - return + if (!isComposingRef.current) return isComposingRef.current = false setCompositionValue('') @@ -91,11 +89,14 @@ export function SearchInput({ {!!inputValue && ( <button type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} className="group/clear absolute top-1/2 right-1.5 flex size-5 -translate-y-1/2 cursor-pointer touch-manipulation items-center justify-center rounded-md border-none bg-transparent p-0 outline-hidden focus-visible:bg-components-input-bg-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid" onClick={handleClear} > - <span className="i-ri-close-circle-fill size-4 text-text-quaternary group-hover/clear:text-text-tertiary" aria-hidden="true" /> + <span + className="i-ri-close-circle-fill size-4 text-text-quaternary group-hover/clear:text-text-tertiary" + aria-hidden="true" + /> </button> )} </div> diff --git a/web/app/components/base/simple-pie-chart/index.module.css b/web/app/components/base/simple-pie-chart/index.module.css index 827b18d5f10d87..e9965cf88b6764 100644 --- a/web/app/components/base/simple-pie-chart/index.module.css +++ b/web/app/components/base/simple-pie-chart/index.module.css @@ -1,4 +1,6 @@ .simplePieChart { border-radius: 50%; - box-shadow: 0 0 5px -3px rgb(from var(--simple-pie-chart-color) r g b / 0.1), 0.5px 0.5px 3px 0 rgb(from var(--simple-pie-chart-color) r g b / 0.3); + box-shadow: + 0 0 5px -3px rgb(from var(--simple-pie-chart-color) r g b / 0.1), + 0.5px 0.5px 3px 0 rgb(from var(--simple-pie-chart-color) r g b / 0.3); } diff --git a/web/app/components/base/simple-pie-chart/index.stories.tsx b/web/app/components/base/simple-pie-chart/index.stories.tsx index 5d66a9424c50db..a36654e635f156 100644 --- a/web/app/components/base/simple-pie-chart/index.stories.tsx +++ b/web/app/components/base/simple-pie-chart/index.stories.tsx @@ -24,23 +24,20 @@ const PieChartPlayground = ({ </span> </div> <div className="flex items-center gap-4"> - <SimplePieChart - percentage={percentage} - fill={fill} - stroke={stroke} - size={120} - /> + <SimplePieChart percentage={percentage} fill={fill} stroke={stroke} size={120} /> <div className="flex flex-1 flex-col gap-2"> <label className="flex items-center justify-between text-xs font-medium text-text-secondary"> Target progress - <span className="rounded-sm bg-background-default px-2 py-1 text-[11px] text-text-tertiary">{label}</span> + <span className="rounded-sm bg-background-default px-2 py-1 text-[11px] text-text-tertiary"> + {label} + </span> </label> <input type="range" min={0} max={100} value={percentage} - onChange={event => setPercentage(Number.parseInt(event.target.value, 10))} + onChange={(event) => setPercentage(Number.parseInt(event.target.value, 10))} className="h-2 w-full cursor-pointer appearance-none rounded-full bg-divider-subtle accent-primary-600" /> </div> @@ -56,7 +53,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Thin radial indicator built with ECharts. Use it for quick percentage snapshots inside cards.', + component: + 'Thin radial indicator built with ECharts. Use it for quick percentage snapshots inside cards.', }, }, }, diff --git a/web/app/components/base/simple-pie-chart/index.tsx b/web/app/components/base/simple-pie-chart/index.tsx index b17edcb1a567ec..df86f4756971c1 100644 --- a/web/app/components/base/simple-pie-chart/index.tsx +++ b/web/app/components/base/simple-pie-chart/index.tsx @@ -14,52 +14,62 @@ export type SimplePieChartProps = { className?: string } -const SimplePieChart = ({ percentage = 80, fill = '#fdb022', stroke = '#f79009', size = 12, animationDuration, className }: SimplePieChartProps) => { - const option: EChartsOption = useMemo(() => ({ - series: [ - { - type: 'pie', - radius: ['83%', '100%'], - animation: false, - data: [ - { value: 100, itemStyle: { color: stroke } }, - ], - emphasis: { - disabled: true, +const SimplePieChart = ({ + percentage = 80, + fill = '#fdb022', + stroke = '#f79009', + size = 12, + animationDuration, + className, +}: SimplePieChartProps) => { + const option: EChartsOption = useMemo( + () => ({ + series: [ + { + type: 'pie', + radius: ['83%', '100%'], + animation: false, + data: [{ value: 100, itemStyle: { color: stroke } }], + emphasis: { + disabled: true, + }, + labelLine: { + show: false, + }, + cursor: 'default', }, - labelLine: { - show: false, + { + type: 'pie', + radius: '83%', + animationDuration: animationDuration ?? 600, + data: [ + { value: percentage, itemStyle: { color: fill } }, + { value: 100 - percentage, itemStyle: { color: '#fff' } }, + ], + emphasis: { + disabled: true, + }, + labelLine: { + show: false, + }, + cursor: 'default', }, - cursor: 'default', - }, - { - type: 'pie', - radius: '83%', - animationDuration: animationDuration ?? 600, - data: [ - { value: percentage, itemStyle: { color: fill } }, - { value: 100 - percentage, itemStyle: { color: '#fff' } }, - ], - emphasis: { - disabled: true, - }, - labelLine: { - show: false, - }, - cursor: 'default', - }, - ], - }), [stroke, fill, percentage, animationDuration]) + ], + }), + [stroke, fill, percentage, animationDuration], + ) return ( <ReactECharts option={option} className={cn(style.simplePieChart, className)} - style={{ - '--simple-pie-chart-color': fill, - 'width': size, - 'height': size, - } as CSSProperties} + style={ + { + '--simple-pie-chart-color': fill, + width: size, + height: size, + } as CSSProperties + } /> ) } diff --git a/web/app/components/base/skeleton/__tests__/index.spec.tsx b/web/app/components/base/skeleton/__tests__/index.spec.tsx index 00852f7031d280..4d834adbf64a3c 100644 --- a/web/app/components/base/skeleton/__tests__/index.spec.tsx +++ b/web/app/components/base/skeleton/__tests__/index.spec.tsx @@ -1,12 +1,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' -import { - SkeletonContainer, - SkeletonPoint, - SkeletonRectangle, - SkeletonRow, -} from '../index' +import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '../index' describe('Skeleton Components', () => { describe('Individual Components', () => { diff --git a/web/app/components/base/skeleton/index.stories.tsx b/web/app/components/base/skeleton/index.stories.tsx index 6710c56b955d3c..c4ebbc59727eb8 100644 --- a/web/app/components/base/skeleton/index.stories.tsx +++ b/web/app/components/base/skeleton/index.stories.tsx @@ -1,15 +1,12 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { - SkeletonContainer, - SkeletonPoint, - SkeletonRectangle, - SkeletonRow, -} from '.' +import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '.' const SkeletonDemo = () => { return ( <div className="flex w-full max-w-xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6"> - <div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">Loading skeletons</div> + <div className="text-xs tracking-[0.18em] text-text-tertiary uppercase"> + Loading skeletons + </div> <div className="space-y-4 rounded-xl border border-divider-subtle bg-background-default-subtle p-4"> <SkeletonContainer> <SkeletonRow> @@ -46,7 +43,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Composable skeleton primitives (container, row, rectangle, point) to sketch loading states for panels and lists.', + component: + 'Composable skeleton primitives (container, row, rectangle, point) to sketch loading states for panels and lists.', }, }, }, diff --git a/web/app/components/base/skeleton/index.tsx b/web/app/components/base/skeleton/index.tsx index a9437111131acf..4e4a80fc7efbbd 100644 --- a/web/app/components/base/skeleton/index.tsx +++ b/web/app/components/base/skeleton/index.tsx @@ -33,7 +33,9 @@ export const SkeletonRectangle: FC<SkeletonProps> = (props) => { export const SkeletonPoint: FC<SkeletonProps> = (props) => { const { className, ...rest } = props return ( - <div className={cn('text-xs font-medium text-text-quaternary', className)} {...rest}>·</div> + <div className={cn('text-xs font-medium text-text-quaternary', className)} {...rest}> + · + </div> ) } /** diff --git a/web/app/components/base/sort/__tests__/index.spec.tsx b/web/app/components/base/sort/__tests__/index.spec.tsx index 99db843a7588d0..12036af0e554b2 100644 --- a/web/app/components/base/sort/__tests__/index.spec.tsx +++ b/web/app/components/base/sort/__tests__/index.spec.tsx @@ -21,8 +21,7 @@ describe('Sort component — real portal integration', () => { // helper: returns a non-null HTMLElement or throws with a clear message const getTriggerWrapper = (): HTMLElement => { const wrapper = screen.getByRole('button', { name: /appLog\.filter\.sortBy/i }) - if (!wrapper) - throw new Error('Trigger wrapper element not found for "Sort by" label') + if (!wrapper) throw new Error('Trigger wrapper element not found for "Sort by" label') return wrapper as HTMLElement } @@ -93,10 +92,8 @@ describe('Sort component — real portal integration', () => { const statusRow = screen.getAllByText('Status').at(-1)?.closest('.flex') const nameRow = screen.getByText('Name').closest('.flex') - if (!statusRow) - throw new Error('Status option row not found in menu') - if (!nameRow) - throw new Error('Name option row not found in menu') + if (!statusRow) throw new Error('Status option row not found in menu') + if (!nameRow) throw new Error('Name option row not found in menu') expect(statusRow.querySelector('.i-ri-check-line')).toBeInTheDocument() expect(nameRow.querySelector('.i-ri-check-line')).not.toBeInTheDocument() @@ -106,8 +103,7 @@ describe('Sort component — real portal integration', () => { setup({ value: 'unknown_value' }) const label = screen.getByText('appLog.filter.sortBy') const valueNode = label.nextSibling - if (!valueNode) - throw new Error('Expected a sibling node for the selection text') + if (!valueNode) throw new Error('Expected a sibling node for the selection text') expect(String(valueNode.textContent || '').trim()).toBe('') }) diff --git a/web/app/components/base/sort/index.stories.tsx b/web/app/components/base/sort/index.stories.tsx index abc11b1ea36f3d..ca6127e3b4f19f 100644 --- a/web/app/components/base/sort/index.stories.tsx +++ b/web/app/components/base/sort/index.stories.tsx @@ -25,8 +25,7 @@ const SortPlayground = () => { <span>Sort control</span> <code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary"> sort_by=" - {sortBy} - " + {sortBy}" </code> </div> <Sort @@ -48,7 +47,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Sorting trigger used in log tables. Includes dropdown selection and quick toggle between ascending and descending.', + component: + 'Sorting trigger used in log tables. Includes dropdown selection and quick toggle between ascending and descending.', }, }, }, diff --git a/web/app/components/base/sort/index.tsx b/web/app/components/base/sort/index.tsx index 51c2fc72460a21..d336e5979c5b62 100644 --- a/web/app/components/base/sort/index.tsx +++ b/web/app/components/base/sort/index.tsx @@ -21,28 +21,28 @@ type Props = Readonly<{ onSelect: (value: string) => void }> -function Sort({ - order, - value, - items, - onSelect, -}: Props) { +function Sort({ order, value, items, onSelect }: Props) { const { t } = useTranslation() const triggerContent = useMemo(() => { - return items.find(item => item.value === value)?.name || '' + return items.find((item) => item.value === value)?.name || '' }, [items, value]) return ( <div className="inline-flex items-center"> <DropdownMenu> <div className="relative"> - <DropdownMenuTrigger - className="flex min-h-8 cursor-pointer items-center rounded-l-lg border-none bg-components-input-bg-normal px-2 py-1 outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt! data-popup-open:hover:bg-state-base-hover-alt" - > + <DropdownMenuTrigger className="flex min-h-8 cursor-pointer items-center rounded-l-lg border-none bg-components-input-bg-normal px-2 py-1 outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt! data-popup-open:hover:bg-state-base-hover-alt"> <div className="flex items-center gap-0.5 px-1"> - <div className="system-sm-regular text-text-tertiary">{t($ => $['filter.sortBy'], { ns: 'appLog' })}</div> - <div className={cn('system-sm-regular text-text-tertiary', !!value && 'text-text-secondary')}> + <div className="system-sm-regular text-text-tertiary"> + {t(($) => $['filter.sortBy'], { ns: 'appLog' })} + </div> + <div + className={cn( + 'system-sm-regular text-text-tertiary', + !!value && 'text-text-secondary', + )} + > {triggerContent} </div> </div> @@ -55,18 +55,28 @@ function Sort({ > <DropdownMenuRadioGroup value={value} - onValueChange={nextValue => onSelect(`${order}${nextValue}`)} + onValueChange={(nextValue) => onSelect(`${order}${nextValue}`)} className="max-h-72 overflow-auto p-1" > - {items.map(item => ( + {items.map((item) => ( <DropdownMenuRadioItem key={item.value} value={item.value} closeOnClick className="mx-0 gap-2 rounded-lg px-2 py-[6px]" > - <div title={item.name} className="grow truncate system-sm-medium text-text-secondary">{item.name}</div> - {value === item.value && <span aria-hidden className="i-ri-check-line size-4 shrink-0 text-util-colors-blue-light-blue-light-600" />} + <div + title={item.name} + className="grow truncate system-sm-medium text-text-secondary" + > + {item.name} + </div> + {value === item.value && ( + <span + aria-hidden + className="i-ri-check-line size-4 shrink-0 text-util-colors-blue-light-blue-light-600" + /> + )} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> @@ -75,15 +85,21 @@ function Sort({ </DropdownMenu> <button type="button" - aria-label={t($ => $[`filter.${order ? 'ascending' : 'descending'}`], { ns: 'appLog' })} + aria-label={t(($) => $[`filter.${order ? 'ascending' : 'descending'}`], { ns: 'appLog' })} className="ml-px cursor-pointer rounded-r-lg border-none bg-components-button-tertiary-bg p-2 outline-hidden hover:bg-components-button-tertiary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" onClick={() => onSelect(`${order ? '' : '-'}${value}`)} > - {!order && <span aria-hidden className="i-ri-sort-asc size-4 text-components-button-tertiary-text" />} - {order && <span aria-hidden className="i-ri-sort-desc size-4 text-components-button-tertiary-text" />} + {!order && ( + <span aria-hidden className="i-ri-sort-asc size-4 text-components-button-tertiary-text" /> + )} + {order && ( + <span + aria-hidden + className="i-ri-sort-desc size-4 text-components-button-tertiary-text" + /> + )} </button> </div> - ) } diff --git a/web/app/components/base/svg-gallery/__tests__/index.spec.tsx b/web/app/components/base/svg-gallery/__tests__/index.spec.tsx index a93cc9da0e59bb..f673b79bc47847 100644 --- a/web/app/components/base/svg-gallery/__tests__/index.spec.tsx +++ b/web/app/components/base/svg-gallery/__tests__/index.spec.tsx @@ -17,7 +17,7 @@ vi.mock('@svgdotjs/svg.js', () => ({ vi.mock('dompurify', () => ({ default: { - sanitize: vi.fn(content => content), + sanitize: vi.fn((content) => content), }, })) @@ -111,10 +111,7 @@ describe('SVGRenderer', () => { }) const img = screen.getByAltText('Preview') expect(img)!.toBeInTheDocument() - expect(img)!.toHaveAttribute( - 'src', - expect.stringContaining('data:image/svg+xml;base64'), - ) + expect(img)!.toHaveAttribute('src', expect.stringContaining('data:image/svg+xml;base64')) }) it('closes image preview on cancel', async () => { diff --git a/web/app/components/base/svg-gallery/index.tsx b/web/app/components/base/svg-gallery/index.tsx index 1838733130ecf9..2d0bb8a364a7ff 100644 --- a/web/app/components/base/svg-gallery/index.tsx +++ b/web/app/components/base/svg-gallery/index.tsx @@ -30,8 +30,7 @@ const SVGRenderer = ({ content }: { content: string }) => { useEffect(() => { /* v8 ignore next 2 -- ref is expected after mount, but null can occur during rapid mount/unmount timing in React lifecycle edges. @preserve */ - if (!svgRef.current) - return + if (!svgRef.current) return try { svgRef.current.innerHTML = '' @@ -41,8 +40,7 @@ const SVGRenderer = ({ content }: { content: string }) => { const svgDoc = parser.parseFromString(content, 'image/svg+xml') const svgElement = svgDoc.documentElement - if (!(svgElement instanceof SVGElement)) - throw new Error('Invalid SVG content') + if (!(svgElement instanceof SVGElement)) throw new Error('Invalid SVG content') const originalWidth = Number.parseInt(svgElement.getAttribute('width') || '400', 10) const originalHeight = Number.parseInt(svgElement.getAttribute('height') || '600', 10) @@ -55,12 +53,11 @@ const SVGRenderer = ({ content }: { content: string }) => { rootElement.click(() => { setImagePreview(svgToDataURL(svgElement as Element)) }) - } - catch { + } catch { /* v8 ignore next 2 -- if unmounted while handling parser/render errors, ref becomes null; guard avoids writing to a detached node. @preserve */ - if (!svgRef.current) - return - svgRef.current.innerHTML = '<span style="padding: 1rem;">Error rendering SVG. Wait for the image content to complete.</span>' + if (!svgRef.current) return + svgRef.current.innerHTML = + '<span style="padding: 1rem;">Error rendering SVG. Wait for the image content to complete.</span>' } }, [content, windowSize]) @@ -79,7 +76,9 @@ const SVGRenderer = ({ content }: { content: string }) => { margin: '0 auto', }} /> - {imagePreview && (<ImagePreview url={imagePreview} title="Preview" onCancel={() => setImagePreview('')} />)} + {imagePreview && ( + <ImagePreview url={imagePreview} title="Preview" onCancel={() => setImagePreview('')} /> + )} </> ) } diff --git a/web/app/components/base/svg/index.stories.tsx b/web/app/components/base/svg/index.stories.tsx index cdf4f6bcb60112..3b89cf40e12634 100644 --- a/web/app/components/base/svg/index.stories.tsx +++ b/web/app/components/base/svg/index.stories.tsx @@ -10,9 +10,10 @@ const SvgToggleDemo = () => { <p className="text-xs tracking-[0.18em] text-text-tertiary uppercase">SVG toggle</p> <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} /> <span className="text-xs text-text-secondary"> - Mode: - {' '} - <code className="rounded-sm bg-background-default px-2 py-1 text-[11px]">{isSVG ? 'SVG' : 'PNG'}</code> + Mode:{' '} + <code className="rounded-sm bg-background-default px-2 py-1 text-[11px]"> + {isSVG ? 'SVG' : 'PNG'} + </code> </span> </div> ) diff --git a/web/app/components/base/svg/index.tsx b/web/app/components/base/svg/index.tsx index 0d212a419114de..70d412ac44b7fb 100644 --- a/web/app/components/base/svg/index.tsx +++ b/web/app/components/base/svg/index.tsx @@ -8,12 +8,13 @@ type ISVGBtnProps = { setIsSVG: React.Dispatch<React.SetStateAction<boolean>> } -const SVGBtn = ({ - isSVG, - setIsSVG, -}: ISVGBtnProps) => { +const SVGBtn = ({ isSVG, setIsSVG }: ISVGBtnProps) => { return ( - <ActionButton onClick={() => { setIsSVG(prevIsSVG => !prevIsSVG) }}> + <ActionButton + onClick={() => { + setIsSVG((prevIsSVG) => !prevIsSVG) + }} + > <div className={cn('size-4', isSVG ? s.svgIconed : s.svgIcon)}></div> </ActionButton> ) diff --git a/web/app/components/base/tab-slider-new/__tests__/index.spec.tsx b/web/app/components/base/tab-slider-new/__tests__/index.spec.tsx index a8772093c3775a..ca34da86f5a783 100644 --- a/web/app/components/base/tab-slider-new/__tests__/index.spec.tsx +++ b/web/app/components/base/tab-slider-new/__tests__/index.spec.tsx @@ -11,13 +11,7 @@ describe('TabSliderNew Component', () => { ] it('should render all options with text and icons', () => { - render( - <TabSliderNew - value="all" - options={mockOptions} - onChange={() => { }} - />, - ) + render(<TabSliderNew value="all" options={mockOptions} onChange={() => {}} />) expect(screen.getByText('All')).toBeInTheDocument() expect(screen.getByText('Active')).toBeInTheDocument() @@ -26,13 +20,7 @@ describe('TabSliderNew Component', () => { }) it('should apply active classes when the value matches the option', () => { - render( - <TabSliderNew - value="active" - options={mockOptions} - onChange={() => { }} - />, - ) + render(<TabSliderNew value="active" options={mockOptions} onChange={() => {}} />) const activeTab = screen.getByTestId('tab-item-active') const inactiveTab = screen.getByTestId('tab-item-all') @@ -50,13 +38,7 @@ describe('TabSliderNew Component', () => { const user = userEvent.setup() const handleChange = vi.fn() - render( - <TabSliderNew - value="all" - options={mockOptions} - onChange={handleChange} - />, - ) + render(<TabSliderNew value="all" options={mockOptions} onChange={handleChange} />) const inactiveTab = screen.getByTestId('tab-item-inactive') await user.click(inactiveTab) @@ -71,7 +53,7 @@ describe('TabSliderNew Component', () => { <TabSliderNew value="all" options={mockOptions} - onChange={() => { }} + onChange={() => {}} className={customClass} />, ) @@ -83,13 +65,7 @@ describe('TabSliderNew Component', () => { const user = userEvent.setup() const handleChange = vi.fn() - render( - <TabSliderNew - value="all" - options={mockOptions} - onChange={handleChange} - />, - ) + render(<TabSliderNew value="all" options={mockOptions} onChange={handleChange} />) const activeTab = screen.getByTestId('tab-item-all') await user.click(activeTab) diff --git a/web/app/components/base/tab-slider-new/index.stories.tsx b/web/app/components/base/tab-slider-new/index.stories.tsx index 75cec25fd6beac..a0e876f987a956 100644 --- a/web/app/components/base/tab-slider-new/index.stories.tsx +++ b/web/app/components/base/tab-slider-new/index.stories.tsx @@ -4,15 +4,19 @@ import { useState } from 'react' import TabSliderNew from '.' const OPTIONS = [ - { value: 'visual', text: 'Visual builder', icon: <RiSparklingFill className="mr-2 size-4 text-primary-500" /> }, - { value: 'code', text: 'Code', icon: <RiTerminalBoxLine className="mr-2 size-4 text-text-tertiary" /> }, + { + value: 'visual', + text: 'Visual builder', + icon: <RiSparklingFill className="mr-2 size-4 text-primary-500" />, + }, + { + value: 'code', + text: 'Code', + icon: <RiTerminalBoxLine className="mr-2 size-4 text-text-tertiary" />, + }, ] -const TabSliderNewDemo = ({ - initialValue = 'visual', -}: { - initialValue?: string -}) => { +const TabSliderNewDemo = ({ initialValue = 'visual' }: { initialValue?: string }) => { const [value, setValue] = useState(initialValue) return ( @@ -30,14 +34,15 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Rounded pill tabs suited for switching between editors. Icons illustrate mixed text/icon options.', + component: + 'Rounded pill tabs suited for switching between editors. Icons illustrate mixed text/icon options.', }, }, }, argTypes: { initialValue: { control: 'radio', - options: OPTIONS.map(option => option.value), + options: OPTIONS.map((option) => option.value), }, }, args: { diff --git a/web/app/components/base/tab-slider-new/index.tsx b/web/app/components/base/tab-slider-new/index.tsx index 6f8a905f95fed0..2f7ed53934c598 100644 --- a/web/app/components/base/tab-slider-new/index.tsx +++ b/web/app/components/base/tab-slider-new/index.tsx @@ -12,25 +12,18 @@ type TabSliderProps = { onChange: (v: string) => void options: Option[] } -const TabSliderNew: FC<TabSliderProps> = ({ - className, - value, - onChange, - options, -}) => { +const TabSliderNew: FC<TabSliderProps> = ({ className, value, onChange, options }) => { return ( - <div - data-testid="tab-slider-new" - className={cn(className, 'relative flex')} - > - {options.map(option => ( + <div data-testid="tab-slider-new" className={cn(className, 'relative flex')}> + {options.map((option) => ( <div key={option.value} data-testid={`tab-item-${option.value}`} onClick={() => onChange(option.value)} className={cn( 'mr-1 flex h-[32px] cursor-pointer items-center rounded-lg border-[0.5px] border-transparent px-3 py-[7px] text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover', - value === option.value && 'border-components-main-nav-nav-button-border bg-state-base-hover text-components-main-nav-nav-button-text-active shadow-xs', + value === option.value && + 'border-components-main-nav-nav-button-border bg-state-base-hover text-components-main-nav-nav-button-text-active shadow-xs', )} > {option.icon} diff --git a/web/app/components/base/tab-slider-plain/__tests__/index.spec.tsx b/web/app/components/base/tab-slider-plain/__tests__/index.spec.tsx index 898ff99ce90686..d7cea8a6ce3b67 100644 --- a/web/app/components/base/tab-slider-plain/__tests__/index.spec.tsx +++ b/web/app/components/base/tab-slider-plain/__tests__/index.spec.tsx @@ -11,7 +11,7 @@ describe('TabSlider Component', () => { ] it('should render all options correctly', () => { - render(<TabSlider value="tab1" options={mockOptions} onChange={() => { }} />) + render(<TabSlider value="tab1" options={mockOptions} onChange={() => {}} />) expect(screen.getByText('Overview')).toBeInTheDocument() expect(screen.getByText('Settings')).toBeInTheDocument() @@ -41,7 +41,7 @@ describe('TabSlider Component', () => { }) it('should apply active styles and render indicator for the active tab', () => { - render(<TabSlider value="tab2" options={mockOptions} onChange={() => { }} />) + render(<TabSlider value="tab2" options={mockOptions} onChange={() => {}} />) const activeTab = screen.getByTestId('tab-slider-item-tab2') const activeText = within(activeTab).getByTestId('tab-slider-item-text') @@ -57,7 +57,7 @@ describe('TabSlider Component', () => { }) it('should apply smallItem styles when smallItem prop is true', () => { - render(<TabSlider value="tab1" options={mockOptions} onChange={() => { }} smallItem />) + render(<TabSlider value="tab1" options={mockOptions} onChange={() => {}} smallItem />) const item = screen.getByTestId('tab-slider-item-tab1') expect(item).toHaveClass('system-sm-semibold-uppercase') @@ -65,7 +65,7 @@ describe('TabSlider Component', () => { }) it('should apply standard sizing when smallItem prop is false', () => { - render(<TabSlider value="tab1" options={mockOptions} onChange={() => { }} />) + render(<TabSlider value="tab1" options={mockOptions} onChange={() => {}} />) const item = screen.getByTestId('tab-slider-item-tab1') expect(item).toHaveClass('system-xl-semibold') @@ -73,13 +73,11 @@ describe('TabSlider Component', () => { it('should handle border styles based on noBorderBottom prop', () => { const { rerender } = render( - <TabSlider value="tab1" options={mockOptions} onChange={() => { }} />, + <TabSlider value="tab1" options={mockOptions} onChange={() => {}} />, ) expect(screen.getByTestId('tab-slider')).toHaveClass('border-b') - rerender( - <TabSlider value="tab1" options={mockOptions} onChange={() => { }} noBorderBottom />, - ) + rerender(<TabSlider value="tab1" options={mockOptions} onChange={() => {}} noBorderBottom />) expect(screen.getByTestId('tab-slider')).not.toHaveClass('border-b') }) @@ -89,7 +87,7 @@ describe('TabSlider Component', () => { <TabSlider value="tab1" options={mockOptions} - onChange={() => { }} + onChange={() => {}} itemClassName={customClass} />, ) diff --git a/web/app/components/base/tab-slider-plain/index.stories.tsx b/web/app/components/base/tab-slider-plain/index.stories.tsx index abc27883714bd0..f87cfe67e6bbfb 100644 --- a/web/app/components/base/tab-slider-plain/index.stories.tsx +++ b/web/app/components/base/tab-slider-plain/index.stories.tsx @@ -8,21 +8,13 @@ const OPTIONS = [ { value: 'alerts', text: 'Alerts' }, ] -const TabSliderPlainDemo = ({ - initialValue = 'analytics', -}: { - initialValue?: string -}) => { +const TabSliderPlainDemo = ({ initialValue = 'analytics' }: { initialValue?: string }) => { const [value, setValue] = useState(initialValue) return ( <div className="flex w-full max-w-2xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6"> <div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">Underline tabs</div> - <TabSliderPlain - value={value} - onChange={setValue} - options={OPTIONS} - /> + <TabSliderPlain value={value} onChange={setValue} options={OPTIONS} /> </div> ) } @@ -34,14 +26,15 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Underline-style navigation commonly used in dashboards. Toggle between three sections.', + component: + 'Underline-style navigation commonly used in dashboards. Toggle between three sections.', }, }, }, argTypes: { initialValue: { control: 'radio', - options: OPTIONS.map(option => option.value), + options: OPTIONS.map((option) => option.value), }, }, args: { diff --git a/web/app/components/base/tab-slider-plain/index.tsx b/web/app/components/base/tab-slider-plain/index.tsx index b523252dc65b14..f4adf32ebcc0a3 100644 --- a/web/app/components/base/tab-slider-plain/index.tsx +++ b/web/app/components/base/tab-slider-plain/index.tsx @@ -15,13 +15,7 @@ type ItemProps = Readonly<{ option: Option smallItem?: boolean }> -const Item: FC<ItemProps> = ({ - className, - isActive, - onClick, - option, - smallItem, -}) => { +const Item: FC<ItemProps> = ({ className, isActive, onClick, option, smallItem }) => { return ( <div key={option.value} @@ -44,8 +38,7 @@ const Item: FC<ItemProps> = ({ <div data-testid="tab-active-indicator" className="absolute inset-x-0 bottom-0 h-0.5 bg-util-colors-blue-brand-blue-brand-600" - > - </div> + ></div> )} </div> ) @@ -73,9 +66,13 @@ const TabSlider: FC<Props> = ({ return ( <div data-testid="tab-slider" - className={cn(className, !noBorderBottom && 'border-b border-divider-subtle', 'flex space-x-6')} + className={cn( + className, + !noBorderBottom && 'border-b border-divider-subtle', + 'flex space-x-6', + )} > - {options.map(option => ( + {options.map((option) => ( <Item isActive={option.value === value} option={option} diff --git a/web/app/components/base/tab-slider/__tests__/index.spec.tsx b/web/app/components/base/tab-slider/__tests__/index.spec.tsx index 7209c47036048c..46dbbe8e9f59fe 100644 --- a/web/app/components/base/tab-slider/__tests__/index.spec.tsx +++ b/web/app/components/base/tab-slider/__tests__/index.spec.tsx @@ -77,7 +77,7 @@ describe('TabSlider Component', () => { value="all" options={mockOptions} onChange={onChangeMock} - itemClassName={active => (active ? 'is-active-custom' : 'is-inactive-custom')} + itemClassName={(active) => (active ? 'is-active-custom' : 'is-inactive-custom')} />, ) expect(screen.getByTestId('tab-item-all')).toHaveClass('is-active-custom') @@ -113,7 +113,9 @@ describe('TabSlider Component', () => { }) it('handles invalid value gracefully', () => { - const { container, rerender } = render(<TabSlider value="invalid" options={mockOptions} onChange={onChangeMock} />) + const { container, rerender } = render( + <TabSlider value="invalid" options={mockOptions} onChange={onChangeMock} />, + ) const activeTabs = container.querySelectorAll('.text-text-primary') expect(activeTabs.length).toBe(0) diff --git a/web/app/components/base/tab-slider/index.stories.tsx b/web/app/components/base/tab-slider/index.stories.tsx index 2cfa7d9d1522c7..2e5e7d01c71514 100644 --- a/web/app/components/base/tab-slider/index.stories.tsx +++ b/web/app/components/base/tab-slider/index.stories.tsx @@ -8,22 +8,15 @@ const OPTIONS = [ { value: 'plugins', text: 'Plugins' }, ] -const TabSliderDemo = ({ - initialValue = 'models', -}: { - initialValue?: string -}) => { +const TabSliderDemo = ({ initialValue = 'models' }: { initialValue?: string }) => { const [value, setValue] = useState(initialValue) useEffect(() => { const originalFetch = globalThis.fetch?.bind(globalThis) const handler = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url if (url.includes('/workspaces/current/plugin/list')) { return new Response( @@ -38,8 +31,7 @@ const TabSliderDemo = ({ ) } - if (originalFetch) - return originalFetch(input, init) + if (originalFetch) return originalFetch(input, init) throw new Error(`Unhandled request for ${url}`) } @@ -47,19 +39,14 @@ const TabSliderDemo = ({ globalThis.fetch = handler as typeof globalThis.fetch return () => { - if (originalFetch) - globalThis.fetch = originalFetch + if (originalFetch) globalThis.fetch = originalFetch } }, []) return ( <div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6"> <div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">Segmented tabs</div> - <TabSlider - value={value} - options={OPTIONS} - onChange={setValue} - /> + <TabSlider value={value} options={OPTIONS} onChange={setValue} /> </div> ) } @@ -71,14 +58,15 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Animated segmented control with sliding highlight. A badge appears when plugins are installed (mocked in Storybook).', + component: + 'Animated segmented control with sliding highlight. A badge appears when plugins are installed (mocked in Storybook).', }, }, }, argTypes: { initialValue: { control: 'radio', - options: OPTIONS.map(option => option.value), + options: OPTIONS.map((option) => option.value), }, }, args: { diff --git a/web/app/components/base/tab-slider/index.tsx b/web/app/components/base/tab-slider/index.tsx index 7cb4e6628b7f62..eb29ca908a08ea 100644 --- a/web/app/components/base/tab-slider/index.tsx +++ b/web/app/components/base/tab-slider/index.tsx @@ -17,14 +17,10 @@ type TabSliderProps = { options: Option[] } -const TabSlider: FC<TabSliderProps> = ({ - className, - itemClassName, - value, - onChange, - options, -}) => { - const [activeIndex, setActiveIndex] = useState(() => options.findIndex(option => option.value === value)) +const TabSlider: FC<TabSliderProps> = ({ className, itemClassName, value, onChange, options }) => { + const [activeIndex, setActiveIndex] = useState(() => + options.findIndex((option) => option.value === value), + ) const [sliderStyle, setSliderStyle] = useState({}) const { data: pluginList } = useInstalledPluginList() @@ -40,7 +36,7 @@ const TabSlider: FC<TabSliderProps> = ({ } useEffect(() => { - const newIndex = options.findIndex(option => option.value === value) + const newIndex = options.findIndex((option) => option.value === value) setActiveIndex(newIndex) updateSliderStyle(newIndex) }, [value, options, pluginList?.total]) @@ -48,7 +44,10 @@ const TabSlider: FC<TabSliderProps> = ({ return ( <div data-testid="tab-slider-container" - className={cn(className, 'relative inline-flex items-center justify-center rounded-[10px] bg-components-segmented-control-bg-normal p-0.5')} + className={cn( + className, + 'relative inline-flex items-center justify-center rounded-[10px] bg-components-segmented-control-bg-normal p-0.5', + )} > <div data-testid="tab-slider-bg" @@ -63,10 +62,10 @@ const TabSlider: FC<TabSliderProps> = ({ className={cn( 'relative z-10 flex cursor-pointer items-center justify-center gap-1 rounded-[10px] px-2.5 py-1.5 transition-colors duration-300 ease-in-out', 'system-md-semibold', - index === activeIndex - ? 'text-text-primary' - : 'text-text-tertiary', - typeof itemClassName === 'function' ? itemClassName(index === activeIndex) : itemClassName, + index === activeIndex ? 'text-text-primary' : 'text-text-tertiary', + typeof itemClassName === 'function' + ? itemClassName(index === activeIndex) + : itemClassName, )} onClick={() => { if (index !== activeIndex) { @@ -77,17 +76,11 @@ const TabSlider: FC<TabSliderProps> = ({ > {option.text} {/* if no plugin installed, the badge won't show */} - {option.value === 'plugins' - && (pluginList?.total ?? 0) > 0 - && ( - <Badge - size="s" - uppercase={true} - state={BadgeState.Default} - > - {pluginList?.total} - </Badge> - )} + {option.value === 'plugins' && (pluginList?.total ?? 0) > 0 && ( + <Badge size="s" uppercase={true} state={BadgeState.Default}> + {pluginList?.total} + </Badge> + )} </div> ))} </div> diff --git a/web/app/components/base/tag-input/__tests__/index.spec.tsx b/web/app/components/base/tag-input/__tests__/index.spec.tsx index 896df75fb68874..f6b9015379bb83 100644 --- a/web/app/components/base/tag-input/__tests__/index.spec.tsx +++ b/web/app/components/base/tag-input/__tests__/index.spec.tsx @@ -39,13 +39,17 @@ describe('TagInput', () => { expect(screen.getByText('alpha'))!.toBeInTheDocument() expect(screen.getByText('beta'))!.toBeInTheDocument() - expect(screen.getByPlaceholderText('datasetDocuments.segment.addKeyWord'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetDocuments.segment.addKeyWord'), + )!.toBeInTheDocument() }) it('should render special mode placeholder when confirm key is Tab', () => { renderTagInput({ customizedConfirmKey: 'Tab' }) - expect(screen.getByPlaceholderText('common.model.params.stop_sequencesPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('common.model.params.stop_sequencesPlaceholder'), + )!.toBeInTheDocument() }) it('should render custom placeholder when placeholder prop is provided', () => { @@ -77,7 +81,9 @@ describe('TagInput', () => { it('should hide remove controls when remove is disabled', () => { renderTagInput({ items: ['alpha'], disableRemove: true }) - expect(screen.queryByRole('button', { name: 'common.operation.remove alpha' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.remove alpha' }), + ).not.toBeInTheDocument() }) it('should apply focused style in special mode when input is focused', async () => { diff --git a/web/app/components/base/tag-input/index.stories.tsx b/web/app/components/base/tag-input/index.stories.tsx index 5e6a42e0708ae2..1d41dd24a25278 100644 --- a/web/app/components/base/tag-input/index.stories.tsx +++ b/web/app/components/base/tag-input/index.stories.tsx @@ -9,7 +9,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Tag input component for managing a list of string tags. Features auto-sizing input, duplicate detection, length validation (max 20 chars), and customizable confirm key (Enter or Tab).', + component: + 'Tag input component for managing a list of string tags. Features auto-sizing input, duplicate detection, length validation (max 20 chars), and customizable confirm key (Enter or Tab).', }, }, }, @@ -72,13 +73,10 @@ const TagInputDemo = (args: any) => { {items.length > 0 && ( <div className="mt-4 rounded-lg bg-gray-50 p-3"> <div className="mb-2 text-xs font-medium text-gray-600"> - Current Tags ( - {items.length} + Current Tags ({items.length} ): </div> - <div className="font-mono text-sm text-gray-800"> - {JSON.stringify(items, null, 2)} - </div> + <div className="font-mono text-sm text-gray-800">{JSON.stringify(items, null, 2)}</div> </div> )} </div> @@ -87,7 +85,7 @@ const TagInputDemo = (args: any) => { // Default state (empty) export const Default: Story = { - render: args => <TagInputDemo {...args} />, + render: (args) => <TagInputDemo {...args} />, args: { items: [], placeholder: 'Add a tag...', @@ -97,7 +95,7 @@ export const Default: Story = { // With initial tags export const WithInitialTags: Story = { - render: args => <TagInputDemo {...args} />, + render: (args) => <TagInputDemo {...args} />, args: { items: ['React', 'TypeScript', 'Next.js'], placeholder: 'Add more tags...', @@ -107,7 +105,7 @@ export const WithInitialTags: Story = { // Tab to confirm export const TabToConfirm: Story = { - render: args => <TagInputDemo {...args} />, + render: (args) => <TagInputDemo {...args} />, args: { items: ['keyword1', 'keyword2'], placeholder: 'Press Tab to add...', @@ -117,7 +115,7 @@ export const TabToConfirm: Story = { // Disable remove export const DisableRemove: Story = { - render: args => <TagInputDemo {...args} />, + render: (args) => <TagInputDemo {...args} />, args: { items: ['Permanent', 'Tags', 'Cannot be removed'], disableRemove: true, @@ -127,7 +125,7 @@ export const DisableRemove: Story = { // Disable add export const DisableAdd: Story = { - render: args => <TagInputDemo {...args} />, + render: (args) => <TagInputDemo {...args} />, args: { items: ['Read', 'Only', 'Mode'], disableAdd: true, @@ -136,7 +134,7 @@ export const DisableAdd: Story = { // Required tags export const RequiredTags: Story = { - render: args => <TagInputDemo {...args} />, + render: (args) => <TagInputDemo {...args} />, args: { items: [], placeholder: 'Add required tags...', @@ -198,11 +196,9 @@ const EmailTagsDemo = () => { </div> <div className="mt-4 rounded-lg bg-blue-50 p-3 text-sm text-gray-700"> <strong> - Recipients ( - {recipients.length} + Recipients ({recipients.length} ): - </strong> - {' '} + </strong>{' '} {recipients.join(', ')} </div> </div> @@ -226,9 +222,10 @@ const SearchFiltersDemo = () => { { id: 4, title: 'Task 4', tags: ['completed'] }, ] - const filteredResults = filters.length > 0 - ? mockResults.filter(item => filters.some(filter => item.tags.includes(filter))) - : mockResults + const filteredResults = + filters.length > 0 + ? mockResults.filter((item) => filters.some((filter) => item.tags.includes(filter))) + : mockResults return ( <div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6"> @@ -244,21 +241,18 @@ const SearchFiltersDemo = () => { </div> <div className="mt-6"> <div className="mb-3 text-sm font-medium text-gray-700"> - Results ( - {filteredResults.length} - {' '} - of - {' '} - {mockResults.length} - ) + Results ({filteredResults.length} of {mockResults.length}) </div> <div className="space-y-2"> - {filteredResults.map(item => ( + {filteredResults.map((item) => ( <div key={item.id} className="rounded-lg bg-gray-50 p-3"> <div className="text-sm font-medium">{item.title}</div> <div className="mt-1 flex gap-1"> - {item.tags.map(tag => ( - <span key={tag} className="rounded-sm bg-blue-100 px-2 py-0.5 text-xs text-blue-700"> + {item.tags.map((tag) => ( + <span + key={tag} + className="rounded-sm bg-blue-100 px-2 py-0.5 text-xs text-blue-700" + > {tag} </span> ))} @@ -331,9 +325,7 @@ const KeywordExtractionDemo = () => { <div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6"> <h3 className="mb-4 text-lg font-semibold">SEO Keywords</h3> <div> - <label className="mb-2 block text-sm font-medium text-gray-700"> - Meta Keywords - </label> + <label className="mb-2 block text-sm font-medium text-gray-700">Meta Keywords</label> <TagInput items={keywords} onChange={setKeywords} @@ -349,8 +341,7 @@ const KeywordExtractionDemo = () => { <div className="mb-2 text-xs font-medium text-gray-600">Meta Tag Preview:</div> <code className="text-xs text-gray-700"> <meta name="keywords" content=" - {keywords.join(', ')} - " /> + {keywords.join(', ')}" /> </code> </div> </div> @@ -371,9 +362,7 @@ const TagsWithSuggestionsDemo = () => { <div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6"> <h3 className="mb-4 text-lg font-semibold">Project Tags</h3> <div> - <label className="mb-2 block text-sm font-medium text-gray-700"> - Add Tags - </label> + <label className="mb-2 block text-sm font-medium text-gray-700">Add Tags</label> <TagInput items={tags} onChange={setTags} @@ -385,16 +374,14 @@ const TagsWithSuggestionsDemo = () => { <div className="mb-2 text-xs font-medium text-gray-600">Suggestions:</div> <div className="flex flex-wrap gap-2"> {suggestions - .filter(s => !tags.includes(s)) - .map(suggestion => ( + .filter((s) => !tags.includes(s)) + .map((suggestion) => ( <button key={suggestion} className="cursor-pointer rounded-sm bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200" onClick={() => setTags([...tags, suggestion])} > - + - {' '} - {suggestion} + + {suggestion} </button> ))} </div> @@ -417,22 +404,11 @@ const StopSequencesDemo = () => { <h3 className="mb-4 text-lg font-semibold">AI Model Configuration</h3> <div className="space-y-4"> <div> - <label className="mb-2 block text-sm font-medium text-gray-700"> - Temperature - </label> - <input - type="range" - min="0" - max="2" - step="0.1" - defaultValue="0.7" - className="w-full" - /> + <label className="mb-2 block text-sm font-medium text-gray-700">Temperature</label> + <input type="range" min="0" max="2" step="0.1" defaultValue="0.7" className="w-full" /> </div> <div> - <label className="mb-2 block text-sm font-medium text-gray-700"> - Stop Sequences - </label> + <label className="mb-2 block text-sm font-medium text-gray-700">Stop Sequences</label> <TagInput items={stopSequences} onChange={setStopSequences} @@ -444,9 +420,7 @@ const StopSequencesDemo = () => { </p> </div> <div> - <label className="mb-2 block text-sm font-medium text-gray-700"> - Max Tokens - </label> + <label className="mb-2 block text-sm font-medium text-gray-700">Max Tokens</label> <input type="number" defaultValue="2000" @@ -542,7 +516,7 @@ export const ValidationShowcase: Story = { // Interactive playground export const Playground: Story = { - render: args => <TagInputDemo {...args} />, + render: (args) => <TagInputDemo {...args} />, args: { items: ['tag1', 'tag2'], placeholder: 'Add a tag...', diff --git a/web/app/components/base/tag-input/index.tsx b/web/app/components/base/tag-input/index.tsx index e015006da13ea3..47475181b6449e 100644 --- a/web/app/components/base/tag-input/index.tsx +++ b/web/app/components/base/tag-input/index.tsx @@ -16,43 +16,57 @@ type TagInputProps = { inputClassName?: string } -const TagInput = ({ items, onChange, disableAdd, disableRemove, customizedConfirmKey = 'Enter', isInWorkflow, placeholder, required = false, inputClassName }: TagInputProps) => { +const TagInput = ({ + items, + onChange, + disableAdd, + disableRemove, + customizedConfirmKey = 'Enter', + isInWorkflow, + placeholder, + required = false, + inputClassName, +}: TagInputProps) => { const { t } = useTranslation() const [value, setValue] = useState('') const [focused, setFocused] = useState(false) const isSpecialMode = customizedConfirmKey === 'Tab' - const inputPlaceholder = placeholder || (isSpecialMode ? t($ => $['model.params.stop_sequencesPlaceholder'], { ns: 'common' }) : t($ => $['segment.addKeyWord'], { ns: 'datasetDocuments' })) + const inputPlaceholder = + placeholder || + (isSpecialMode + ? t(($) => $['model.params.stop_sequencesPlaceholder'], { ns: 'common' }) + : t(($) => $['segment.addKeyWord'], { ns: 'datasetDocuments' })) const handleRemove = (index: number) => { const copyItems = [...items] copyItems.splice(index, 1) onChange(copyItems) } - const handleNewTag = useCallback((value: string) => { - const valueTrimmed = value.trim() - if (!valueTrimmed) { - if (required) - toast.error(t($ => $['segment.keywordEmpty'], { ns: 'datasetDocuments' })) - return - } - if ((items.find(item => item === valueTrimmed))) { - toast.error(t($ => $['segment.keywordDuplicate'], { ns: 'datasetDocuments' })) - return - } - if (valueTrimmed.length > 20) { - toast.error(t($ => $['segment.keywordError'], { ns: 'datasetDocuments' })) - return - } - onChange([...items, valueTrimmed]) - setTimeout(() => { - setValue('') - }) - }, [items, onChange, t, required]) + const handleNewTag = useCallback( + (value: string) => { + const valueTrimmed = value.trim() + if (!valueTrimmed) { + if (required) toast.error(t(($) => $['segment.keywordEmpty'], { ns: 'datasetDocuments' })) + return + } + if (items.find((item) => item === valueTrimmed)) { + toast.error(t(($) => $['segment.keywordDuplicate'], { ns: 'datasetDocuments' })) + return + } + if (valueTrimmed.length > 20) { + toast.error(t(($) => $['segment.keywordError'], { ns: 'datasetDocuments' })) + return + } + onChange([...items, valueTrimmed]) + setTimeout(() => { + setValue('') + }) + }, + [items, onChange, t, required], + ) const handleKeyDown = (e: KeyboardEvent) => { - if (isSpecialMode && e.key === 'Enter') - setValue(`${value}↵`) + if (isSpecialMode && e.key === 'Enter') setValue(`${value}↵`) if (e.key === customizedConfirmKey) { - if (isSpecialMode) - e.preventDefault() + if (isSpecialMode) e.preventDefault() handleNewTag(value) } } @@ -61,31 +75,62 @@ const TagInput = ({ items, onChange, disableAdd, disableRemove, customizedConfir setFocused(false) } return ( - <div className={cn('flex flex-wrap', !isInWorkflow && 'min-w-[200px]', isSpecialMode ? 'rounded-lg bg-components-input-bg-normal pb-1 pl-1' : '')}> + <div + className={cn( + 'flex flex-wrap', + !isInWorkflow && 'min-w-[200px]', + isSpecialMode ? 'rounded-lg bg-components-input-bg-normal pb-1 pl-1' : '', + )} + > {(items || []).map((item, index) => ( - <div key={item} className={cn('mt-1 mr-1 flex items-center rounded-md border border-divider-deep bg-components-badge-white-to-dark py-1 pr-1 pl-1.5 system-xs-regular text-text-secondary')}> + <div + key={item} + className={cn( + 'mt-1 mr-1 flex items-center rounded-md border border-divider-deep bg-components-badge-white-to-dark py-1 pr-1 pl-1.5 system-xs-regular text-text-secondary', + )} + > {item} {!disableRemove && ( <button type="button" - aria-label={`${t($ => $['operation.remove'], { ns: 'common' })} ${item}`} + aria-label={`${t(($) => $['operation.remove'], { ns: 'common' })} ${item}`} className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={() => handleRemove(index)} > - <span className="ml-0.5 i-ri-close-line size-3.5 text-text-tertiary" aria-hidden="true" /> + <span + className="ml-0.5 i-ri-close-line size-3.5 text-text-tertiary" + aria-hidden="true" + /> </button> )} </div> ))} {!disableAdd && ( - <div className={cn('group/tag-add mt-1 flex items-center gap-x-0.5', !isSpecialMode ? 'rounded-md border border-dashed border-divider-deep px-1.5' : '')}> - {!isSpecialMode && !focused && <span className="i-ri-add-line size-3.5 text-text-placeholder group-hover/tag-add:text-text-secondary" />} + <div + className={cn( + 'group/tag-add mt-1 flex items-center gap-x-0.5', + !isSpecialMode ? 'rounded-md border border-dashed border-divider-deep px-1.5' : '', + )} + > + {!isSpecialMode && !focused && ( + <span className="i-ri-add-line size-3.5 text-text-placeholder group-hover/tag-add:text-text-secondary" /> + )} <span data-input-value={value || inputPlaceholder} - className={cn(!isInWorkflow && 'max-w-[300px]', isInWorkflow && 'max-w-[146px]', 'grid overflow-hidden rounded-md py-1 system-xs-regular after:invisible after:col-start-1 after:row-start-1 after:whitespace-pre after:content-[attr(data-input-value)]', isSpecialMode && 'border border-transparent px-1.5', focused && isSpecialMode && 'border-dashed border-divider-deep')} + className={cn( + !isInWorkflow && 'max-w-[300px]', + isInWorkflow && 'max-w-[146px]', + 'grid overflow-hidden rounded-md py-1 system-xs-regular after:invisible after:col-start-1 after:row-start-1 after:whitespace-pre after:content-[attr(data-input-value)]', + isSpecialMode && 'border border-transparent px-1.5', + focused && isSpecialMode && 'border-dashed border-divider-deep', + )} > <input - className={cn('col-start-1 row-start-1 w-full min-w-0 appearance-none text-text-primary caret-[#295EFF] outline-hidden placeholder:text-text-placeholder group-hover/tag-add:placeholder:text-text-secondary', isSpecialMode ? 'bg-transparent' : '', inputClassName)} + className={cn( + 'col-start-1 row-start-1 w-full min-w-0 appearance-none text-text-primary caret-[#295EFF] outline-hidden placeholder:text-text-placeholder group-hover/tag-add:placeholder:text-text-secondary', + isSpecialMode ? 'bg-transparent' : '', + inputClassName, + )} onFocus={() => setFocused(true)} onBlur={handleBlur} value={value} diff --git a/web/app/components/base/tag/__tests__/index.spec.tsx b/web/app/components/base/tag/__tests__/index.spec.tsx index 2e13cee74bb353..c04faadad7dba1 100644 --- a/web/app/components/base/tag/__tests__/index.spec.tsx +++ b/web/app/components/base/tag/__tests__/index.spec.tsx @@ -11,7 +11,11 @@ describe('Tag Component', () => { }) it('should render with ReactNode children', () => { - render(<Tag><span data-testid="child">Node</span></Tag>) + render( + <Tag> + <span data-testid="child">Node</span> + </Tag>, + ) expect(screen.getByTestId('child')).toBeInTheDocument() }) @@ -73,12 +77,20 @@ describe('Tag Component', () => { }) it('should apply both bordered and hideBg together', () => { - const { container } = render(<Tag bordered hideBg>Test</Tag>) + const { container } = render( + <Tag bordered hideBg> + Test + </Tag>, + ) expect(container.firstChild).toHaveClass('border', 'bg-transparent') }) it('should override color background with hideBg', () => { - const { container } = render(<Tag color="red" hideBg>Test</Tag>) + const { container } = render( + <Tag color="red" hideBg> + Test + </Tag>, + ) const tag = container.firstChild expect(tag).toHaveClass('bg-transparent', 'text-red-800') }) diff --git a/web/app/components/base/tag/index.stories.tsx b/web/app/components/base/tag/index.stories.tsx index 6f9294424d59c6..8577de2212837f 100644 --- a/web/app/components/base/tag/index.stories.tsx +++ b/web/app/components/base/tag/index.stories.tsx @@ -1,7 +1,12 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' import Tag from '.' -const COLORS: Array<NonNullable<React.ComponentProps<typeof Tag>['color']>> = ['green', 'yellow', 'red', 'gray'] +const COLORS: Array<NonNullable<React.ComponentProps<typeof Tag>['color']>> = [ + 'green', + 'yellow', + 'red', + 'gray', +] const TagGallery = ({ bordered = false, @@ -14,12 +19,17 @@ const TagGallery = ({ <div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6"> <div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">Tag variants</div> <div className="grid grid-cols-2 gap-3"> - {COLORS.map(color => ( - <div key={color} className="flex flex-col items-start gap-2 rounded-xl border border-transparent px-3 py-2 hover:border-divider-subtle hover:bg-background-default-subtle"> + {COLORS.map((color) => ( + <div + key={color} + className="flex flex-col items-start gap-2 rounded-xl border border-transparent px-3 py-2 hover:border-divider-subtle hover:bg-background-default-subtle" + > <Tag color={color} bordered={bordered} hideBg={hideBg}> {color.charAt(0).toUpperCase() + color.slice(1)} </Tag> - <span className="text-[11px] tracking-[0.16em] text-text-quaternary uppercase">{color}</span> + <span className="text-[11px] tracking-[0.16em] text-text-quaternary uppercase"> + {color} + </span> </div> ))} </div> @@ -34,7 +44,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Color-coded label component. Toggle borders or remove background to fit dark/light surfaces.', + component: + 'Color-coded label component. Toggle borders or remove background to fit dark/light surfaces.', }, }, }, diff --git a/web/app/components/base/tag/index.tsx b/web/app/components/base/tag/index.tsx index c027fb17bb89ce..bb5128f8d1db18 100644 --- a/web/app/components/base/tag/index.tsx +++ b/web/app/components/base/tag/index.tsx @@ -28,11 +28,22 @@ const COLOR_MAP = { }, } -export default function Tag({ children, color = 'green', className = '', bordered = false, hideBg = false }: ITagProps) { +export default function Tag({ + children, + color = 'green', + className = '', + bordered = false, + hideBg = false, +}: ITagProps) { return ( - <div className={ - cn('inline-flex shrink-0 items-center rounded-md px-2.5 py-px text-xs/5', COLOR_MAP[color] ? `${COLOR_MAP[color].text} ${COLOR_MAP[color].bg}` : '', bordered ? 'border border-divider-regular' : '', hideBg ? 'bg-transparent' : '', className) - } + <div + className={cn( + 'inline-flex shrink-0 items-center rounded-md px-2.5 py-px text-xs/5', + COLOR_MAP[color] ? `${COLOR_MAP[color].text} ${COLOR_MAP[color].bg}` : '', + bordered ? 'border border-divider-regular' : '', + hideBg ? 'bg-transparent' : '', + className, + )} > {children} </div> diff --git a/web/app/components/base/text-generation/__tests__/hooks.spec.ts b/web/app/components/base/text-generation/__tests__/hooks.spec.ts index 50c68b4eab0a8f..db299870478291 100644 --- a/web/app/components/base/text-generation/__tests__/hooks.spec.ts +++ b/web/app/components/base/text-generation/__tests__/hooks.spec.ts @@ -3,7 +3,14 @@ import { act, renderHook } from '@testing-library/react' import { useTextGeneration } from '../hooks' const mockNotify = vi.fn() -const mockSsePost = vi.fn<(url: string, fetchOptions: { body: Record<string, unknown> }, otherOptions: IOtherOptions) => void>() +const mockSsePost = + vi.fn< + ( + url: string, + fetchOptions: { body: Record<string, unknown> }, + otherOptions: IOtherOptions, + ) => void + >() vi.mock('@langgenius/dify-ui/toast', () => ({ default: { @@ -23,8 +30,7 @@ vi.mock('@/service/base', () => ({ const getLatestStreamOptions = (): IOtherOptions => { const latestCall = mockSsePost.mock.calls[mockSsePost.mock.calls.length - 1] - if (!latestCall) - throw new Error('Expected ssePost to be called at least once') + if (!latestCall) throw new Error('Expected ssePost to be called at least once') return latestCall[2] } @@ -110,7 +116,9 @@ describe('useTextGeneration', () => { streamOptions.onData?.('Old content', true, { messageId: 'message-2' }) }) - const replaceMessage = { answer: 'New content' } as Parameters<NonNullable<IOtherOptions['onMessageReplace']>>[0] + const replaceMessage = { answer: 'New content' } as Parameters< + NonNullable<IOtherOptions['onMessageReplace']> + >[0] act(() => { streamOptions.onMessageReplace?.(replaceMessage) }) diff --git a/web/app/components/base/text-generation/hooks.ts b/web/app/components/base/text-generation/hooks.ts index 37ac8556f07748..b9fdd799958a03 100644 --- a/web/app/components/base/text-generation/hooks.ts +++ b/web/app/components/base/text-generation/hooks.ts @@ -10,35 +10,39 @@ export const useTextGeneration = () => { const [messageId, setMessageId] = useState<string | null>(null) const handleSend = async (url: string, data: any) => { if (isResponding) { - toast.info(t($ => $['errorMessage.waitForResponse'], { ns: 'appDebug' })) + toast.info(t(($) => $['errorMessage.waitForResponse'], { ns: 'appDebug' })) return false } setIsResponding(true) setCompletion('') setMessageId('') let res: string[] = [] - ssePost(url, { - body: { - response_mode: 'streaming', - ...data, + ssePost( + url, + { + body: { + response_mode: 'streaming', + ...data, + }, }, - }, { - onData: (data: string, _isFirstMessage: boolean, { messageId }) => { - res.push(data) - setCompletion(res.join('')) - setMessageId(messageId) + { + onData: (data: string, _isFirstMessage: boolean, { messageId }) => { + res.push(data) + setCompletion(res.join('')) + setMessageId(messageId) + }, + onMessageReplace: (messageReplace) => { + res = [messageReplace.answer] + setCompletion(res.join('')) + }, + onCompleted() { + setIsResponding(false) + }, + onError() { + setIsResponding(false) + }, }, - onMessageReplace: (messageReplace) => { - res = [messageReplace.answer] - setCompletion(res.join('')) - }, - onCompleted() { - setIsResponding(false) - }, - onError() { - setIsResponding(false) - }, - }) + ) return true } return { diff --git a/web/app/components/base/text-generation/types.ts b/web/app/components/base/text-generation/types.ts index 86be8a0a25fbd2..c23261a30f615d 100644 --- a/web/app/components/base/text-generation/types.ts +++ b/web/app/components/base/text-generation/types.ts @@ -1,8 +1,5 @@ import type { ExternalDataTool } from '@/models/common' -import type { - ModelConfig, - VisionFile, -} from '@/types/app' +import type { ModelConfig, VisionFile } from '@/types/app' export { TransferMethod } from '@/types/app' diff --git a/web/app/components/base/theme-selector.tsx b/web/app/components/base/theme-selector.tsx index a7cdc6a9f15eb9..f11fe0c1b45a8b 100644 --- a/web/app/components/base/theme-selector.tsx +++ b/web/app/components/base/theme-selector.tsx @@ -13,7 +13,7 @@ import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' const THEMES = ['light', 'dark', 'system'] as const -export type Theme = typeof THEMES[number] +export type Theme = (typeof THEMES)[number] const isTheme = (value: string): value is Theme => { return (THEMES as readonly string[]).includes(value) @@ -28,27 +28,29 @@ export default function ThemeSelector() { } const handleThemeValueChange = (value: string) => { - if (isTheme(value)) - handleThemeChange(value) + if (isTheme(value)) handleThemeChange(value) } const getCurrentIcon = () => { switch (theme) { - case 'light': return <span className="i-ri-sun-line size-4 text-text-tertiary" /> - case 'dark': return <span className="i-ri-moon-line size-4 text-text-tertiary" /> - default: return <span className="i-ri-computer-line size-4 text-text-tertiary" /> + case 'light': + return <span className="i-ri-sun-line size-4 text-text-tertiary" /> + case 'dark': + return <span className="i-ri-moon-line size-4 text-text-tertiary" /> + default: + return <span className="i-ri-computer-line size-4 text-text-tertiary" /> } } return ( <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <ActionButton - aria-label={t($ => $['theme.theme'], { ns: 'common' })} + aria-label={t(($) => $['theme.theme'], { ns: 'common' })} className="h-8 w-8 p-[6px] data-popup-open:bg-state-base-hover" /> - )} + } > {getCurrentIcon()} </DropdownMenuTrigger> @@ -56,17 +58,23 @@ export default function ThemeSelector() { <DropdownMenuRadioGroup value={theme || 'system'} onValueChange={handleThemeValueChange}> <DropdownMenuRadioItem value="light" closeOnClick> <span className="i-ri-sun-line size-4 text-text-tertiary" /> - <span className="grow px-1 system-md-regular">{t($ => $['theme.light'], { ns: 'common' })}</span> + <span className="grow px-1 system-md-regular"> + {t(($) => $['theme.light'], { ns: 'common' })} + </span> <DropdownMenuRadioItemIndicator data-testid="light-icon" /> </DropdownMenuRadioItem> <DropdownMenuRadioItem value="dark" closeOnClick> <span className="i-ri-moon-line size-4 text-text-tertiary" /> - <span className="grow px-1 system-md-regular">{t($ => $['theme.dark'], { ns: 'common' })}</span> + <span className="grow px-1 system-md-regular"> + {t(($) => $['theme.dark'], { ns: 'common' })} + </span> <DropdownMenuRadioItemIndicator data-testid="dark-icon" /> </DropdownMenuRadioItem> <DropdownMenuRadioItem value="system" closeOnClick> <span className="i-ri-computer-line size-4 text-text-tertiary" /> - <span className="grow px-1 system-md-regular">{t($ => $['theme.auto'], { ns: 'common' })}</span> + <span className="grow px-1 system-md-regular"> + {t(($) => $['theme.auto'], { ns: 'common' })} + </span> <DropdownMenuRadioItemIndicator data-testid="system-icon" /> </DropdownMenuRadioItem> </DropdownMenuRadioGroup> diff --git a/web/app/components/base/theme-switcher.tsx b/web/app/components/base/theme-switcher.tsx index 6ac0e0f0f7cf54..132b44e0569495 100644 --- a/web/app/components/base/theme-switcher.tsx +++ b/web/app/components/base/theme-switcher.tsx @@ -17,7 +17,8 @@ export default function ThemeSwitcher() { type="button" className={cn( 'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', - theme === 'system' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only', + theme === 'system' && + 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only', )} onClick={() => handleThemeChange('system')} aria-label="System theme" @@ -27,12 +28,16 @@ export default function ThemeSwitcher() { <span className="i-ri-computer-line size-4" /> </div> </button> - <div className={cn('h-[14px] w-px bg-transparent', theme === 'dark' && 'bg-divider-regular')} data-testid="divider"></div> + <div + className={cn('h-[14px] w-px bg-transparent', theme === 'dark' && 'bg-divider-regular')} + data-testid="divider" + ></div> <button type="button" className={cn( 'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', - theme === 'light' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only', + theme === 'light' && + 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only', )} onClick={() => handleThemeChange('light')} aria-label="Light theme" @@ -42,12 +47,16 @@ export default function ThemeSwitcher() { <span className="i-ri-sun-line size-4" /> </div> </button> - <div className={cn('h-[14px] w-px bg-transparent', theme === 'system' && 'bg-divider-regular')} data-testid="divider"></div> + <div + className={cn('h-[14px] w-px bg-transparent', theme === 'system' && 'bg-divider-regular')} + data-testid="divider" + ></div> <button type="button" className={cn( 'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', - theme === 'dark' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only', + theme === 'dark' && + 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only', )} onClick={() => handleThemeChange('dark')} aria-label="Dark theme" diff --git a/web/app/components/base/timezone-label/__tests__/index.test.tsx b/web/app/components/base/timezone-label/__tests__/index.test.tsx index 15aa4ee685744b..5f908b28dd6d80 100644 --- a/web/app/components/base/timezone-label/__tests__/index.test.tsx +++ b/web/app/components/base/timezone-label/__tests__/index.test.tsx @@ -5,8 +5,7 @@ import TimezoneLabel from '../index' // Mock the convertTimezoneToOffsetStr function vi.mock('@/app/components/base/date-and-time-picker/utils/dayjs', () => ({ convertTimezoneToOffsetStr: (timezone?: string) => { - if (!timezone) - return 'UTC+0' + if (!timezone) return 'UTC+0' // Mock implementation matching the actual timezone conversions const timezoneOffsets: Record<string, string> = { @@ -15,7 +14,7 @@ vi.mock('@/app/components/base/date-and-time-picker/utils/dayjs', () => ({ 'Europe/London': 'UTC+0', 'Pacific/Auckland': 'UTC+13', 'Pacific/Niue': 'UTC-11', - 'UTC': 'UTC+0', + UTC: 'UTC+0', } return timezoneOffsets[timezone] || 'UTC+0' @@ -112,11 +111,7 @@ describe('TimezoneLabel', () => { it('should render with all props', () => { const { container } = render( - <TimezoneLabel - timezone="America/New_York" - className="text-xs" - inline - />, + <TimezoneLabel timezone="America/New_York" className="text-xs" inline />, ) const span = container.querySelector('span') expect(screen.getByText('UTC-5')).toBeInTheDocument() diff --git a/web/app/components/base/timezone-label/index.tsx b/web/app/components/base/timezone-label/index.tsx index 90b69348b3b303..7b613bd2b8f333 100644 --- a/web/app/components/base/timezone-label/index.tsx +++ b/web/app/components/base/timezone-label/index.tsx @@ -29,16 +29,9 @@ type TimezoneLabelProps = { * // Custom styling * <TimezoneLabel timezone="Europe/London" className="text-xs font-bold" /> */ -const TimezoneLabel: React.FC<TimezoneLabelProps> = ({ - timezone, - className, - inline = false, -}) => { +const TimezoneLabel: React.FC<TimezoneLabelProps> = ({ timezone, className, inline = false }) => { // Memoize offset calculation to avoid redundant computations - const offsetStr = useMemo( - () => convertTimezoneToOffsetStr(timezone), - [timezone], - ) + const offsetStr = useMemo(() => convertTimezoneToOffsetStr(timezone), [timezone]) return ( <span diff --git a/web/app/components/base/upgrade-modal/index.tsx b/web/app/components/base/upgrade-modal/index.tsx index cfae72eaf7e05a..1cfbdb97cf3501 100644 --- a/web/app/components/base/upgrade-modal/index.tsx +++ b/web/app/components/base/upgrade-modal/index.tsx @@ -38,27 +38,44 @@ export function UpgradeModal({ classNames, }: UpgradeModalProps) { return ( - <Dialog - open={open} - onOpenChange={onOpenChange} - > - <DialogContent className={cn(styles.surface, 'w-[580px] max-w-[480px] overflow-hidden rounded-2xl p-0', classNames?.content)}> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent + className={cn( + styles.surface, + 'w-[580px] max-w-[480px] overflow-hidden rounded-2xl p-0', + classNames?.content, + )} + > <div className="relative"> <div aria-hidden - className={cn(styles.heroOverlay, 'pointer-events-none absolute inset-0', classNames?.heroOverlay)} + className={cn( + styles.heroOverlay, + 'pointer-events-none absolute inset-0', + classNames?.heroOverlay, + )} /> <div className={cn('px-8 pt-8', classNames?.body)}> {Icon && ( - <div className={cn(styles.icon, 'flex size-12 items-center justify-center rounded-xl shadow-lg backdrop-blur-[5px]', classNames?.icon)}> + <div + className={cn( + styles.icon, + 'flex size-12 items-center justify-center rounded-xl shadow-lg backdrop-blur-[5px]', + classNames?.icon, + )} + > <Icon className="size-6 text-text-primary-on-surface" /> </div> )} <div className={cn('mt-6 space-y-2', classNames?.copy)}> - <DialogTitle className={cn(styles.highlight, 'title-3xl-semi-bold', classNames?.title)}> + <DialogTitle + className={cn(styles.highlight, 'title-3xl-semi-bold', classNames?.title)} + > {title} </DialogTitle> - <DialogDescription className={cn('system-md-regular text-text-tertiary', classNames?.description)}> + <DialogDescription + className={cn('system-md-regular text-text-tertiary', classNames?.description)} + > {description} </DialogDescription> </div> diff --git a/web/app/components/base/upgrade-modal/style.module.css b/web/app/components/base/upgrade-modal/style.module.css index 50ad48838805a6..d2c2620d937035 100644 --- a/web/app/components/base/upgrade-modal/style.module.css +++ b/web/app/components/base/upgrade-modal/style.module.css @@ -1,7 +1,11 @@ .surface { border: 0.5px solid var(--color-components-panel-border, rgba(16, 24, 40, 0.08)); background: - linear-gradient(109deg, var(--color-background-section, #f9fafb) 0%, var(--color-background-section-burn, #f2f4f7) 100%), + linear-gradient( + 109deg, + var(--color-background-section, #f9fafb) 0%, + var(--color-background-section-burn, #f2f4f7) 100% + ), var(--color-components-panel-bg, #fff); } @@ -11,18 +15,30 @@ background-position: 31px -23px; background-repeat: repeat; mask-image: linear-gradient(180deg, rgba(255, 255, 255, 1) 45%, rgba(255, 255, 255, 0) 75%); - -webkit-mask-image: linear-gradient(180deg, rgba(255, 255, 255, 1) 45%, rgba(255, 255, 255, 0) 75%); + -webkit-mask-image: linear-gradient( + 180deg, + rgba(255, 255, 255, 1) 45%, + rgba(255, 255, 255, 0) 75% + ); } .icon { border: 0.5px solid transparent; background: - linear-gradient(180deg, var(--color-components-avatar-bg-mask-stop-0, rgba(255, 255, 255, 0.12)) 0%, var(--color-components-avatar-bg-mask-stop-100, rgba(255, 255, 255, 0.08)) 100%), + linear-gradient( + 180deg, + var(--color-components-avatar-bg-mask-stop-0, rgba(255, 255, 255, 0.12)) 0%, + var(--color-components-avatar-bg-mask-stop-100, rgba(255, 255, 255, 0.08)) 100% + ), var(--color-util-colors-blue-brand-blue-brand-500, #296dff); } .highlight { - background: linear-gradient(97deg, var(--color-components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -4%, var(--color-components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45%); + background: linear-gradient( + 97deg, + var(--color-components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -4%, + var(--color-components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45% + ); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; diff --git a/web/app/components/base/user-avatar-list/index.tsx b/web/app/components/base/user-avatar-list/index.tsx index 190e7d3cb2b213..ab8c60c24e115e 100644 --- a/web/app/components/base/user-avatar-list/index.tsx +++ b/web/app/components/base/user-avatar-list/index.tsx @@ -21,78 +21,65 @@ type UserAvatarListProps = { } const avatarSizeToPx: Record<AvatarSize, number> = { - 'xxs': 16, - 'xs': 20, - 'sm': 24, - 'md': 32, - 'lg': 36, - 'xl': 40, + xxs: 16, + xs: 20, + sm: 24, + md: 32, + lg: 36, + xl: 40, '2xl': 48, '3xl': 64, } -export const UserAvatarList: FC<UserAvatarListProps> = memo(({ - users, - maxVisible = 3, - size = 'sm', - className = '', - showCount = true, -}) => { - const currentUserId = useAtomValue(userProfileIdAtom) - if (!users.length) - return null +export const UserAvatarList: FC<UserAvatarListProps> = memo( + ({ users, maxVisible = 3, size = 'sm', className = '', showCount = true }) => { + const currentUserId = useAtomValue(userProfileIdAtom) + if (!users.length) return null - const shouldShowCount = showCount && users.length > maxVisible - const actualMaxVisible = shouldShowCount ? Math.max(1, maxVisible - 1) : maxVisible - const visibleUsers = users.slice(0, actualMaxVisible) - const remainingCount = users.length - actualMaxVisible + const shouldShowCount = showCount && users.length > maxVisible + const actualMaxVisible = shouldShowCount ? Math.max(1, maxVisible - 1) : maxVisible + const visibleUsers = users.slice(0, actualMaxVisible) + const remainingCount = users.length - actualMaxVisible - return ( - <div className={`flex items-center -space-x-1 ${className}`}> - {visibleUsers.map((user, index) => { - const isCurrentUser = user.id === currentUserId - const userColor = isCurrentUser ? undefined : getUserColor(user.id) - return ( - <div - key={`${user.id}-${index}`} - className="relative" - style={{ zIndex: visibleUsers.length - index }} - > - <AvatarRoot size={size} className="ring-2 ring-components-panel-bg"> - {user.avatar_url && ( - <AvatarImage - src={user.avatar_url} - alt={user.name} - /> - )} - <AvatarFallback - size={size} - style={userColor ? { backgroundColor: userColor } : undefined} - > - {user.name?.[0]?.toLocaleUpperCase()} - </AvatarFallback> - </AvatarRoot> + return ( + <div className={`flex items-center -space-x-1 ${className}`}> + {visibleUsers.map((user, index) => { + const isCurrentUser = user.id === currentUserId + const userColor = isCurrentUser ? undefined : getUserColor(user.id) + return ( + <div + key={`${user.id}-${index}`} + className="relative" + style={{ zIndex: visibleUsers.length - index }} + > + <AvatarRoot size={size} className="ring-2 ring-components-panel-bg"> + {user.avatar_url && <AvatarImage src={user.avatar_url} alt={user.name} />} + <AvatarFallback + size={size} + style={userColor ? { backgroundColor: userColor } : undefined} + > + {user.name?.[0]?.toLocaleUpperCase()} + </AvatarFallback> + </AvatarRoot> + </div> + ) + })} + {shouldShowCount && remainingCount > 0 && ( + <div className="relative shrink-0" style={{ zIndex: 0 }}> + <div + className="inline-flex items-center justify-center rounded-full bg-gray-500 text-[10px] leading-none text-white ring-2 ring-components-panel-bg" + style={{ + width: avatarSizeToPx[size], + height: avatarSizeToPx[size], + }} + > + +{remainingCount} + </div> </div> - ) - }, - - )} - {shouldShowCount && remainingCount > 0 && ( - <div className="relative shrink-0" style={{ zIndex: 0 }}> - <div - className="inline-flex items-center justify-center rounded-full bg-gray-500 text-[10px] leading-none text-white ring-2 ring-components-panel-bg" - style={{ - width: avatarSizeToPx[size], - height: avatarSizeToPx[size], - }} - > - + - {remainingCount} - </div> - </div> - )} - </div> - ) -}) + )} + </div> + ) + }, +) UserAvatarList.displayName = 'UserAvatarList' diff --git a/web/app/components/base/video-gallery/VideoPlayer.module.css b/web/app/components/base/video-gallery/VideoPlayer.module.css index 04c4a367d62497..8d34f7684e3ee3 100644 --- a/web/app/components/base/video-gallery/VideoPlayer.module.css +++ b/web/app/components/base/video-gallery/VideoPlayer.module.css @@ -51,12 +51,15 @@ align-items: center; } -.leftControls, .rightControls { +.leftControls, +.rightControls { display: flex; align-items: center; } -.playPauseButton, .muteButton, .fullscreenButton { +.playPauseButton, +.muteButton, +.fullscreenButton { background: none; border: none; color: white; @@ -68,7 +71,9 @@ justify-content: center; } -.playPauseButton:hover, .muteButton:hover, .fullscreenButton:hover { +.playPauseButton:hover, +.muteButton:hover, +.fullscreenButton:hover { background-color: rgba(255, 255, 255, 0.1); border-radius: 50%; } diff --git a/web/app/components/base/video-gallery/VideoPlayer.tsx b/web/app/components/base/video-gallery/VideoPlayer.tsx index d4bfde1eaf5937..67f6d44ccfd294 100644 --- a/web/app/components/base/video-gallery/VideoPlayer.tsx +++ b/web/app/components/base/video-gallery/VideoPlayer.tsx @@ -9,32 +9,76 @@ type VideoPlayerProps = { } const PlayIcon = () => ( - <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <svg + aria-hidden="true" + width="20" + height="20" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > <path d="M8 5V19L19 12L8 5Z" fill="currentColor" /> </svg> ) const PauseIcon = () => ( - <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <svg + aria-hidden="true" + width="20" + height="20" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > <path d="M6 19H10V5H6V19ZM14 5V19H18V5H14Z" fill="currentColor" /> </svg> ) const MuteIcon = () => ( - <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M3 9V15H7L12 20V4L7 9H3ZM16.5 12C16.5 10.23 15.48 8.71 14 7.97V16.02C15.48 15.29 16.5 13.77 16.5 12ZM14 3.23V5.29C16.89 6.15 19 8.83 19 12C19 15.17 16.89 17.85 14 18.71V20.77C18.01 19.86 21 16.28 21 12C21 7.72 18.01 4.14 14 3.23Z" fill="currentColor" /> + <svg + aria-hidden="true" + width="20" + height="20" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M3 9V15H7L12 20V4L7 9H3ZM16.5 12C16.5 10.23 15.48 8.71 14 7.97V16.02C15.48 15.29 16.5 13.77 16.5 12ZM14 3.23V5.29C16.89 6.15 19 8.83 19 12C19 15.17 16.89 17.85 14 18.71V20.77C18.01 19.86 21 16.28 21 12C21 7.72 18.01 4.14 14 3.23Z" + fill="currentColor" + /> </svg> ) const UnmuteIcon = () => ( - <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M4.34 2.93L2.93 4.34L7.29 8.7L7 9H3V15H7L12 20V13.41L16.18 17.59C15.69 17.96 15.16 18.27 14.58 18.5V20.58C15.94 20.22 17.15 19.56 18.13 18.67L19.66 20.2L21.07 18.79L4.34 2.93ZM10 15.17L7.83 13H5V11H7.83L10 8.83V15.17ZM19 12C19 12.82 18.85 13.61 18.59 14.34L20.12 15.87C20.68 14.7 21 13.39 21 12C21 7.72 18.01 4.14 14 3.23V5.29C16.89 6.15 19 8.83 19 12ZM12 4L10.12 5.88L12 7.76V4ZM16.5 12C16.5 10.23 15.48 8.71 14 7.97V10.18L16.45 12.63C16.48 12.43 16.5 12.22 16.5 12Z" fill="currentColor" /> + <svg + aria-hidden="true" + width="20" + height="20" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M4.34 2.93L2.93 4.34L7.29 8.7L7 9H3V15H7L12 20V13.41L16.18 17.59C15.69 17.96 15.16 18.27 14.58 18.5V20.58C15.94 20.22 17.15 19.56 18.13 18.67L19.66 20.2L21.07 18.79L4.34 2.93ZM10 15.17L7.83 13H5V11H7.83L10 8.83V15.17ZM19 12C19 12.82 18.85 13.61 18.59 14.34L20.12 15.87C20.68 14.7 21 13.39 21 12C21 7.72 18.01 4.14 14 3.23V5.29C16.89 6.15 19 8.83 19 12ZM12 4L10.12 5.88L12 7.76V4ZM16.5 12C16.5 10.23 15.48 8.71 14 7.97V10.18L16.45 12.63C16.48 12.43 16.5 12.22 16.5 12Z" + fill="currentColor" + /> </svg> ) const FullscreenIcon = () => ( - <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M7 14H5V19H10V17H7V14ZM5 10H7V7H10V5H5V10ZM17 17H14V19H19V14H17V17ZM14 5V7H17V10H19V5H14Z" fill="currentColor" /> + <svg + aria-hidden="true" + width="20" + height="20" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M7 14H5V19H10V17H7V14ZM5 10H7V7H10V5H5V10ZM17 17H14V19H19V14H17V17ZM14 5V7H17V10H19V5H14Z" + fill="currentColor" + /> </svg> ) @@ -54,15 +98,16 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null) const [isSmallSize, setIsSmallSize] = useState(false) const containerRef = useRef<HTMLDivElement>(null) - const playPauseLabel = t($ => $[isPlaying ? 'operation.pause' : 'operation.play'], { ns: 'common' }) - const toggleMuteLabel = t($ => $['operation.toggleMute'], { ns: 'common' }) - const toggleFullscreenLabel = t($ => $['operation.toggleFullscreen'], { ns: 'common' }) + const playPauseLabel = t(($) => $[isPlaying ? 'operation.pause' : 'operation.play'], { + ns: 'common', + }) + const toggleMuteLabel = t(($) => $['operation.toggleMute'], { ns: 'common' }) + const toggleFullscreenLabel = t(($) => $['operation.toggleFullscreen'], { ns: 'common' }) useEffect(() => { const video = videoRef.current /* v8 ignore next 2 -- video element is expected post-mount; null guard protects against lifecycle timing during mount/unmount. @preserve */ - if (!video) - return + if (!video) return const setVideoData = () => { setDuration(video.duration) @@ -90,15 +135,13 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { useEffect(() => { return () => { - if (controlsTimeoutRef.current) - clearTimeout(controlsTimeoutRef.current) + if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current) } }, []) const showControls = useCallback(() => { setIsControlsVisible(true) - if (controlsTimeoutRef.current) - clearTimeout(controlsTimeoutRef.current) + if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current) controlsTimeoutRef.current = setTimeout(() => setIsControlsVisible(false), 3000) }, []) @@ -107,9 +150,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { const video = videoRef.current /* v8 ignore next -- click handler can race with unmount in tests/runtime; guard prevents calling methods on a detached video node. @preserve */ if (video) { - if (isPlaying) - video.pause() - else video.play().catch(error => console.error('Error playing video:', error)) + if (isPlaying) video.pause() + else video.play().catch((error) => console.error('Error playing video:', error)) setIsPlaying(!isPlaying) } }, [isPlaying]) @@ -121,8 +163,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { const newMutedState = !video.muted video.muted = newMutedState setIsMuted(newMutedState) - setVolume(newMutedState ? 0 : (video.volume > 0 ? video.volume : 1)) - video.volume = newMutedState ? 0 : (video.volume > 0 ? video.volume : 1) + setVolume(newMutedState ? 0 : video.volume > 0 ? video.volume : 1) + video.volume = newMutedState ? 0 : video.volume > 0 ? video.volume : 1 } }, []) @@ -130,8 +172,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { const video = videoRef.current /* v8 ignore next -- defensive null-check so fullscreen calls are skipped if video ref is detached. @preserve */ if (video) { - if (document.fullscreenElement) - document.exitFullscreen() + if (document.fullscreenElement) document.exitFullscreen() else video.requestFullscreen() } }, []) @@ -142,47 +183,56 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } - const updateVideoProgress = useCallback((clientX: number, updateTime = false) => { - const progressBar = progressRef.current - const video = videoRef.current - /* v8 ignore next -- progress callbacks may fire while refs are not yet attached or already torn down; guard avoids invalid DOM access. @preserve */ - if (progressBar && video) { - const rect = progressBar.getBoundingClientRect() - const pos = (clientX - rect.left) / rect.width - const newTime = pos * video.duration - if (newTime >= 0 && newTime <= video.duration) { - setHoverTime(newTime) - if (isDragging || updateTime) - video.currentTime = newTime + const updateVideoProgress = useCallback( + (clientX: number, updateTime = false) => { + const progressBar = progressRef.current + const video = videoRef.current + /* v8 ignore next -- progress callbacks may fire while refs are not yet attached or already torn down; guard avoids invalid DOM access. @preserve */ + if (progressBar && video) { + const rect = progressBar.getBoundingClientRect() + const pos = (clientX - rect.left) / rect.width + const newTime = pos * video.duration + if (newTime >= 0 && newTime <= video.duration) { + setHoverTime(newTime) + if (isDragging || updateTime) video.currentTime = newTime + } } - } - }, [isDragging]) + }, + [isDragging], + ) - const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => { - updateVideoProgress(e.clientX) - }, [updateVideoProgress]) + const handleMouseMove = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + updateVideoProgress(e.clientX) + }, + [updateVideoProgress], + ) const handleMouseLeave = useCallback(() => { - if (!isDragging) - setHoverTime(null) + if (!isDragging) setHoverTime(null) }, [isDragging]) - const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => { - e.preventDefault() - updateVideoProgress(e.clientX, true) - }, [updateVideoProgress]) + const handleProgressClick = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + e.preventDefault() + updateVideoProgress(e.clientX, true) + }, + [updateVideoProgress], + ) - const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => { - e.preventDefault() - setIsDragging(true) - updateVideoProgress(e.clientX, true) - }, [updateVideoProgress]) + const handleMouseDown = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + e.preventDefault() + setIsDragging(true) + updateVideoProgress(e.clientX, true) + }, + [updateVideoProgress], + ) useEffect(() => { const handleGlobalMouseMove = (e: MouseEvent) => { /* v8 ignore next -- global mousemove listener remains registered briefly; skip updates once dragging has ended. @preserve */ - if (isDragging) - updateVideoProgress(e.clientX) + if (isDragging) updateVideoProgress(e.clientX) } const handleGlobalMouseUp = () => { @@ -203,8 +253,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { const checkSize = useCallback(() => { /* v8 ignore next 2 -- container ref may be null before first paint or after unmount while resize events are in flight. @preserve */ - if (containerRef.current) - setIsSmallSize(containerRef.current.offsetWidth < 400) + if (containerRef.current) setIsSmallSize(containerRef.current.offsetWidth < 400) }, []) useEffect(() => { @@ -228,14 +277,22 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { }, []) return ( - <div ref={containerRef} className={styles.videoPlayer} onMouseMove={showControls} onMouseEnter={showControls} data-testid="video-player-container"> + <div + ref={containerRef} + className={styles.videoPlayer} + onMouseMove={showControls} + onMouseEnter={showControls} + data-testid="video-player-container" + > <video ref={videoRef} src={src} className={styles.video} data-testid="video-element"> {/* If srcs array is provided, render multiple source elements */} - {srcs && srcs.map((srcUrl, index) => ( - <source key={index} src={srcUrl} /> - ))} + {srcs && srcs.map((srcUrl, index) => <source key={index} src={srcUrl} />)} </video> - <div className={`${styles.controls} ${isControlsVisible ? styles.visible : styles.hidden} ${isSmallSize ? styles.smallSize : ''}`} data-testid="video-controls" data-is-visible={isControlsVisible}> + <div + className={`${styles.controls} ${isControlsVisible ? styles.visible : styles.hidden} ${isSmallSize ? styles.smallSize : ''}`} + data-testid="video-controls" + data-is-visible={isControlsVisible} + > <div className={styles.overlay}> <div className={styles.progressBarContainer}> <div @@ -247,7 +304,10 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { onMouseDown={handleMouseDown} data-testid="video-progress-bar" > - <div className={styles.progress} style={{ width: `${(currentTime / duration) * 100}%` }} /> + <div + className={styles.progress} + style={{ width: `${(currentTime / duration) * 100}%` }} + /> {hoverTime !== null && ( <div className={styles.hoverTimeIndicator} @@ -261,21 +321,27 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { </div> <div className={styles.controlsContent}> <div className={styles.leftControls}> - <button type="button" aria-label={playPauseLabel} className={styles.playPauseButton} onClick={togglePlayPause}> + <button + type="button" + aria-label={playPauseLabel} + className={styles.playPauseButton} + onClick={togglePlayPause} + > {isPlaying ? <PauseIcon /> : <PlayIcon />} </button> {!isSmallSize && ( <span className={styles.time} data-testid="video-time-display"> - {formatTime(currentTime)} - {' '} - / - {' '} - {formatTime(duration)} + {formatTime(currentTime)} / {formatTime(duration)} </span> )} </div> <div className={styles.rightControls}> - <button type="button" aria-label={toggleMuteLabel} className={styles.muteButton} onClick={toggleMute}> + <button + type="button" + aria-label={toggleMuteLabel} + className={styles.muteButton} + onClick={toggleMute} + > {isMuted ? <UnmuteIcon /> : <MuteIcon />} </button> {!isSmallSize && ( @@ -286,7 +352,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { onClick={handleVolumeChange} onMouseDown={(e) => { handleVolumeChange(e) - const handleMouseMove = (e: MouseEvent) => handleVolumeChange(e as unknown as React.MouseEvent<HTMLDivElement>) + const handleMouseMove = (e: MouseEvent) => + handleVolumeChange(e as unknown as React.MouseEvent<HTMLDivElement>) const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) @@ -300,7 +367,12 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => { </div> </div> )} - <button type="button" aria-label={toggleFullscreenLabel} className={styles.fullscreenButton} onClick={toggleFullscreen}> + <button + type="button" + aria-label={toggleFullscreenLabel} + className={styles.fullscreenButton} + onClick={toggleFullscreen} + > <FullscreenIcon /> </button> </div> diff --git a/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx b/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx index 6a7217060773e8..f3ad24700b4e17 100644 --- a/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx +++ b/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx @@ -17,14 +17,15 @@ describe('VideoPlayer', () => { height: 10, x: 0, y: 0, - toJSON: () => { }, + toJSON: () => {}, } as DOMRect) } const getPlayButton = () => screen.getByRole('button', { name: 'common.operation.play' }) const getPauseButton = () => screen.getByRole('button', { name: 'common.operation.pause' }) const getMuteButton = () => screen.getByRole('button', { name: 'common.operation.toggleMute' }) - const getFullscreenButton = () => screen.getByRole('button', { name: 'common.operation.toggleFullscreen' }) + const getFullscreenButton = () => + screen.getByRole('button', { name: 'common.operation.toggleFullscreen' }) beforeEach(() => { vi.clearAllMocks() @@ -48,7 +49,9 @@ describe('VideoPlayer', () => { // Define properties on HTMLVideoElement prototype Object.defineProperty(window.HTMLVideoElement.prototype, 'duration', { configurable: true, - get() { return 100 }, + get() { + return 100 + }, }) type MockVideoElement = HTMLVideoElement & { @@ -61,24 +64,36 @@ describe('VideoPlayer', () => { if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'currentTime')) { Object.defineProperty(window.HTMLVideoElement.prototype, 'currentTime', { configurable: true, - get() { return (this as MockVideoElement)._currentTime || 0 }, - set(v) { (this as MockVideoElement)._currentTime = v }, + get() { + return (this as MockVideoElement)._currentTime || 0 + }, + set(v) { + ;(this as MockVideoElement)._currentTime = v + }, }) } if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'volume')) { Object.defineProperty(window.HTMLVideoElement.prototype, 'volume', { configurable: true, - get() { return (this as MockVideoElement)._volume ?? 1 }, - set(v) { (this as MockVideoElement)._volume = v }, + get() { + return (this as MockVideoElement)._volume ?? 1 + }, + set(v) { + ;(this as MockVideoElement)._volume = v + }, }) } if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'muted')) { Object.defineProperty(window.HTMLVideoElement.prototype, 'muted', { configurable: true, - get() { return (this as MockVideoElement)._muted || false }, - set(v) { (this as MockVideoElement)._muted = v }, + get() { + return (this as MockVideoElement)._muted || false + }, + set(v) { + ;(this as MockVideoElement)._muted = v + }, }) } }) @@ -144,14 +159,18 @@ describe('VideoPlayer', () => { Object.defineProperty(document, 'fullscreenElement', { configurable: true, - get() { return {} }, + get() { + return {} + }, }) await user.click(fullscreenBtn) expect(document.exitFullscreen).toHaveBeenCalled() Object.defineProperty(document, 'fullscreenElement', { configurable: true, - get() { return null }, + get() { + return null + }, }) }) @@ -272,7 +291,7 @@ describe('VideoPlayer', () => { }) it('should handle play() rejection error', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) window.HTMLVideoElement.prototype.play = vi.fn().mockRejectedValue(new Error('Play failed')) const user = userEvent.setup() @@ -285,8 +304,7 @@ describe('VideoPlayer', () => { await waitFor(() => { expect(consoleSpy).toHaveBeenCalledWith('Error playing video:', expect.any(Error)) }) - } - finally { + } finally { consoleSpy.mockRestore() } }) diff --git a/web/app/components/base/video-gallery/index.stories.tsx b/web/app/components/base/video-gallery/index.stories.tsx index 93aba599ef4bad..0b05cb82de08ed 100644 --- a/web/app/components/base/video-gallery/index.stories.tsx +++ b/web/app/components/base/video-gallery/index.stories.tsx @@ -13,7 +13,8 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Stacked list of video players with custom controls for progress, volume, and fullscreen.', + component: + 'Stacked list of video players with custom controls for progress, volume, and fullscreen.', }, source: { language: 'tsx', diff --git a/web/app/components/base/video-gallery/index.tsx b/web/app/components/base/video-gallery/index.tsx index f704af10ca6f82..49dcada60ad5ba 100644 --- a/web/app/components/base/video-gallery/index.tsx +++ b/web/app/components/base/video-gallery/index.tsx @@ -6,9 +6,8 @@ type Props = Readonly<{ }> const VideoGallery: React.FC<Props> = ({ srcs }) => { - const validSrcs = srcs.filter(src => src) - if (validSrcs.length === 0) - return null + const validSrcs = srcs.filter((src) => src) + if (validSrcs.length === 0) return null return ( <div className="my-3" data-testid="video-gallery-container"> diff --git a/web/app/components/base/voice-input/__tests__/index.spec.tsx b/web/app/components/base/voice-input/__tests__/index.spec.tsx index cf19bdc959c816..72f830f7247d29 100644 --- a/web/app/components/base/voice-input/__tests__/index.spec.tsx +++ b/web/app/components/base/voice-input/__tests__/index.spec.tsx @@ -17,8 +17,7 @@ const { mockState, MockRecorder, rafState } = vi.hoisted(() => { class MockRecorderClass { start = vi.fn((..._args: unknown[]) => { - if (state.startOverride) - return state.startOverride() + if (state.startOverride) return state.startOverride() return Promise.resolve() }) @@ -93,7 +92,7 @@ describe('VoiceInput', () => { })) vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1) - vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => { }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) }) it('should start recording on mount and show speaking state', async () => { @@ -154,7 +153,7 @@ describe('VoiceInput', () => { it('should show cancel button during conversion and cancel on click', async () => { const user = userEvent.setup() - vi.mocked(audioToText).mockImplementation(() => new Promise(() => { })) + vi.mocked(audioToText).mockImplementation(() => new Promise(() => {})) mockState.params = { token: 'abc' } render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />) @@ -173,8 +172,7 @@ describe('VoiceInput', () => { let rafCalls = 0 vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { rafCalls++ - if (rafCalls <= 2) - cb(0) + if (rafCalls <= 2) cb(0) return rafCalls }) @@ -192,8 +190,7 @@ describe('VoiceInput', () => { let rafCalls = 0 vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { rafCalls++ - if (rafCalls <= 2) - cb(0) + if (rafCalls <= 2) cb(0) return rafCalls }) @@ -292,8 +289,7 @@ describe('VoiceInput', () => { let rafCalls = 0 vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { rafCalls++ - if (rafCalls <= 1) - cb(0) + if (rafCalls <= 1) cb(0) return rafCalls }) @@ -314,15 +310,13 @@ describe('VoiceInput', () => { expect(timer)!.toHaveTextContent('00:00') await act(async () => { - if (rafState.callback) - rafState.callback() + if (rafState.callback) rafState.callback() }) expect(timer)!.toHaveTextContent('00:01') for (let i = 0; i < 9; i++) { await act(async () => { - if (rafState.callback) - rafState.callback() + if (rafState.callback) rafState.callback() }) } expect(timer)!.toHaveTextContent('00:10') @@ -344,8 +338,7 @@ describe('VoiceInput', () => { let rafCalls = 0 vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { rafCalls++ - if (rafCalls <= 2) - cb(0) + if (rafCalls <= 2) cb(0) return rafCalls }) @@ -410,8 +403,7 @@ describe('VoiceInput', () => { let rafCalls = 0 vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { rafCalls++ - if (rafCalls <= 2) - cb(0) + if (rafCalls <= 2) cb(0) return rafCalls }) @@ -428,13 +420,7 @@ describe('VoiceInput', () => { vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' }) mockState.params = { token: 'token123' } - render( - <VoiceInput - onConverted={onConverted} - onCancel={onCancel} - wordTimestamps="enabled" - />, - ) + render(<VoiceInput onConverted={onConverted} onCancel={onCancel} wordTimestamps="enabled" />) const stopBtn = await screen.findByTestId('voice-input-stop') await user.click(stopBtn) @@ -481,7 +467,7 @@ describe('VoiceInput', () => { it('should render converting UI after stopping', async () => { const user = userEvent.setup() - vi.mocked(audioToText).mockImplementation(() => new Promise(() => { })) + vi.mocked(audioToText).mockImplementation(() => new Promise(() => {})) mockState.params = { token: 'abc' } render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />) @@ -502,8 +488,7 @@ describe('VoiceInput', () => { for (let i = 0; i < 601; i++) { await act(async () => { - if (rafState.callback) - rafState.callback() + if (rafState.callback) rafState.callback() }) } diff --git a/web/app/components/base/voice-input/index.module.css b/web/app/components/base/voice-input/index.module.css index 8286f9d9a95b9d..82ca1d4a224aad 100644 --- a/web/app/components/base/voice-input/index.module.css +++ b/web/app/components/base/voice-input/index.module.css @@ -1,10 +1,12 @@ .wrapper { - background: linear-gradient(131deg, #2250F2 0%, #0EBCF3 100%); - box-shadow: 0px 4px 6px -2px rgba(16, 24, 40, 0.03), 0px 12px 16px -4px rgba(16, 24, 40, 0.08); + background: linear-gradient(131deg, #2250f2 0%, #0ebcf3 100%); + box-shadow: + 0px 4px 6px -2px rgba(16, 24, 40, 0.03), + 0px 12px 16px -4px rgba(16, 24, 40, 0.08); } .convert { - background: linear-gradient(91.92deg, #104AE1 -1.74%, #0098EE 75.74%); + background: linear-gradient(91.92deg, #104ae1 -1.74%, #0098ee 75.74%); background-clip: text; color: transparent; } diff --git a/web/app/components/base/voice-input/index.stories.tsx b/web/app/components/base/voice-input/index.stories.tsx index d46eac9f5f3c1f..0273aad1ba31cf 100644 --- a/web/app/components/base/voice-input/index.stories.tsx +++ b/web/app/components/base/voice-input/index.stories.tsx @@ -9,7 +9,7 @@ const VoiceInputMock = ({ onConverted, onCancel }: any) => { // Simulate recording useState(() => { const interval = setInterval(() => { - setDuration(d => d + 1) + setDuration((d) => d + 1) }, 1000) return () => clearInterval(interval) }) @@ -46,9 +46,7 @@ const VoiceInputMock = ({ onConverted, onCancel }: any) => { )} <div className="z-10 grow"> - {state === 'recording' && ( - <div className="text-sm text-gray-500">Speaking...</div> - )} + {state === 'recording' && <div className="text-sm text-gray-500">Speaking...</div>} {state === 'converting' && ( <div className="text-sm text-gray-500">Converting to text...</div> )} @@ -72,7 +70,9 @@ const VoiceInputMock = ({ onConverted, onCancel }: any) => { </div> )} - <div className={`w-[45px] pl-1 text-xs font-medium ${duration > 500 ? 'text-red-600' : 'text-gray-700'}`}> + <div + className={`w-[45px] pl-1 text-xs font-medium ${duration > 500 ? 'text-red-600' : 'text-gray-700'}`} + > {`0${minutes}:${seconds >= 10 ? seconds : `0${seconds}`}`} </div> </div> @@ -87,7 +87,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Voice input component for recording audio and converting speech to text. Features waveform visualization, recording timer (max 10 minutes), and audio-to-text conversion using js-audio-recorder.\n\n**Note:** This is a simplified mock for Storybook. The actual component requires microphone permissions and audio-to-text API.', + component: + 'Voice input component for recording audio and converting speech to text. Features waveform visualization, recording timer (max 10 minutes), and audio-to-text conversion using js-audio-recorder.\n\n**Note:** This is a simplified mock for Storybook. The actual component requires microphone permissions and audio-to-text API.', }, }, }, @@ -128,12 +129,7 @@ const VoiceInputDemo = () => { </button> )} - {isRecording && ( - <VoiceInputMock - onConverted={handleConverted} - onCancel={handleCancel} - /> - )} + {isRecording && <VoiceInputMock onConverted={handleConverted} onCancel={handleCancel} />} {transcription && ( <div className="mt-4 rounded-lg bg-gray-50 p-4"> @@ -200,37 +196,35 @@ const ChatInputWithVoiceDemo = () => { {/* Input area */} <div className="space-y-3"> - {!isRecording - ? ( - <div className="flex gap-2"> - <input - type="text" - className="flex-1 rounded-lg border border-gray-300 px-4 py-3 text-sm" - placeholder="Type a message..." - value={message} - onChange={e => setMessage(e.target.value)} - /> - <button - className="rounded-lg bg-gray-100 px-4 py-3 hover:bg-gray-200" - onClick={() => setIsRecording(true)} - title="Voice input" - > - 🎤 - </button> - <button className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"> - Send - </button> - </div> - ) - : ( - <VoiceInputMock - onConverted={(text: string) => { - setMessage(text) - setIsRecording(false) - }} - onCancel={() => setIsRecording(false)} - /> - )} + {!isRecording ? ( + <div className="flex gap-2"> + <input + type="text" + className="flex-1 rounded-lg border border-gray-300 px-4 py-3 text-sm" + placeholder="Type a message..." + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + <button + className="rounded-lg bg-gray-100 px-4 py-3 hover:bg-gray-200" + onClick={() => setIsRecording(true)} + title="Voice input" + > + 🎤 + </button> + <button className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"> + Send + </button> + </div> + ) : ( + <VoiceInputMock + onConverted={(text: string) => { + setMessage(text) + setIsRecording(false) + }} + onCancel={() => setIsRecording(false)} + /> + )} </div> </div> ) @@ -249,45 +243,39 @@ const SearchWithVoiceDemo = () => { <div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6"> <h3 className="mb-4 text-lg font-semibold">Voice Search</h3> - {!isRecording - ? ( - <div className="flex gap-2"> - <div className="relative flex-1"> - <input - type="text" - className="w-full rounded-lg border border-gray-300 px-4 py-3 pl-10 text-sm" - placeholder="Search or use voice..." - value={searchQuery} - onChange={e => setSearchQuery(e.target.value)} - /> - <span className="absolute top-1/2 left-3 -translate-y-1/2 text-gray-400"> - 🔍 - </span> - </div> - <button - className="rounded-lg bg-blue-600 px-4 py-3 text-white hover:bg-blue-700" - onClick={() => setIsRecording(true)} - > - 🎤 Voice Search - </button> - </div> - ) - : ( - <VoiceInputMock - onConverted={(text: string) => { - setSearchQuery(text) - setIsRecording(false) - }} - onCancel={() => setIsRecording(false)} + {!isRecording ? ( + <div className="flex gap-2"> + <div className="relative flex-1"> + <input + type="text" + className="w-full rounded-lg border border-gray-300 px-4 py-3 pl-10 text-sm" + placeholder="Search or use voice..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} /> - )} + <span className="absolute top-1/2 left-3 -translate-y-1/2 text-gray-400">🔍</span> + </div> + <button + className="rounded-lg bg-blue-600 px-4 py-3 text-white hover:bg-blue-700" + onClick={() => setIsRecording(true)} + > + 🎤 Voice Search + </button> + </div> + ) : ( + <VoiceInputMock + onConverted={(text: string) => { + setSearchQuery(text) + setIsRecording(false) + }} + onCancel={() => setIsRecording(false)} + /> + )} {searchQuery && !isRecording && ( <div className="mt-4 rounded-lg bg-blue-50 p-4"> <div className="mb-2 text-xs font-medium text-blue-900"> - Searching for: - {' '} - <strong>{searchQuery}</strong> + Searching for: <strong>{searchQuery}</strong> </div> </div> )} @@ -308,63 +296,55 @@ const NoteTakingDemo = () => { <div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6"> <div className="mb-4 flex items-center justify-between"> <h3 className="text-lg font-semibold">Voice Notes</h3> - <span className="text-sm text-gray-500"> - {notes.length} - {' '} - notes - </span> + <span className="text-sm text-gray-500">{notes.length} notes</span> </div> <div className="mb-4"> - {!isRecording - ? ( - <button - className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 px-4 py-3 font-medium text-white hover:bg-red-600" - onClick={() => setIsRecording(true)} - > - <span className="text-xl">🎤</span> - Record Voice Note - </button> - ) - : ( - <VoiceInputMock - onConverted={(text: string) => { - setNotes([...notes, text]) - setIsRecording(false) - }} - onCancel={() => setIsRecording(false)} - /> - )} + {!isRecording ? ( + <button + className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 px-4 py-3 font-medium text-white hover:bg-red-600" + onClick={() => setIsRecording(true)} + > + <span className="text-xl">🎤</span> + Record Voice Note + </button> + ) : ( + <VoiceInputMock + onConverted={(text: string) => { + setNotes([...notes, text]) + setIsRecording(false) + }} + onCancel={() => setIsRecording(false)} + /> + )} </div> <div className="max-h-80 space-y-2 overflow-y-auto"> - {notes.length === 0 - ? ( - <div className="py-12 text-center text-gray-400"> - No notes yet. Click the button above to start recording. - </div> - ) - : ( - notes.map((note, index) => ( - <div key={index} className="rounded-lg border border-gray-200 bg-gray-50 p-3"> - <div className="flex items-start justify-between"> - <div className="flex-1"> - <div className="mb-1 text-xs text-gray-500"> - Note - {index + 1} - </div> - <div className="text-sm text-gray-800">{note}</div> - </div> - <button - className="text-gray-400 hover:text-red-500" - onClick={() => setNotes(notes.filter((_, i) => i !== index))} - > - × - </button> + {notes.length === 0 ? ( + <div className="py-12 text-center text-gray-400"> + No notes yet. Click the button above to start recording. + </div> + ) : ( + notes.map((note, index) => ( + <div key={index} className="rounded-lg border border-gray-200 bg-gray-50 p-3"> + <div className="flex items-start justify-between"> + <div className="flex-1"> + <div className="mb-1 text-xs text-gray-500"> + Note + {index + 1} </div> + <div className="text-sm text-gray-800">{note}</div> </div> - )) - )} + <button + className="text-gray-400 hover:text-red-500" + onClick={() => setNotes(notes.filter((_, i) => i !== index))} + > + × + </button> + </div> + </div> + )) + )} </div> </div> ) @@ -388,69 +368,61 @@ const FormWithVoiceDemo = () => { <div className="space-y-4"> <div> - <label className="mb-2 block text-sm font-medium text-gray-700"> - Product Name - </label> - {activeField === 'name' - ? ( - <VoiceInputMock - onConverted={(text: string) => { - setFormData({ ...formData, name: text }) - setActiveField(null) - }} - onCancel={() => setActiveField(null)} - /> - ) - : ( - <div className="flex gap-2"> - <input - type="text" - className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm" - placeholder="Enter product name..." - value={formData.name} - onChange={e => setFormData({ ...formData, name: e.target.value })} - /> - <button - className="rounded-lg bg-gray-100 px-3 py-2 hover:bg-gray-200" - onClick={() => setActiveField('name')} - > - 🎤 - </button> - </div> - )} + <label className="mb-2 block text-sm font-medium text-gray-700">Product Name</label> + {activeField === 'name' ? ( + <VoiceInputMock + onConverted={(text: string) => { + setFormData({ ...formData, name: text }) + setActiveField(null) + }} + onCancel={() => setActiveField(null)} + /> + ) : ( + <div className="flex gap-2"> + <input + type="text" + className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm" + placeholder="Enter product name..." + value={formData.name} + onChange={(e) => setFormData({ ...formData, name: e.target.value })} + /> + <button + className="rounded-lg bg-gray-100 px-3 py-2 hover:bg-gray-200" + onClick={() => setActiveField('name')} + > + 🎤 + </button> + </div> + )} </div> <div> - <label className="mb-2 block text-sm font-medium text-gray-700"> - Description - </label> - {activeField === 'description' - ? ( - <VoiceInputMock - onConverted={(text: string) => { - setFormData({ ...formData, description: text }) - setActiveField(null) - }} - onCancel={() => setActiveField(null)} - /> - ) - : ( - <div className="space-y-2"> - <textarea - className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" - rows={4} - placeholder="Enter product description..." - value={formData.description} - onChange={e => setFormData({ ...formData, description: e.target.value })} - /> - <button - className="w-full rounded-lg bg-gray-100 px-3 py-2 text-sm hover:bg-gray-200" - onClick={() => setActiveField('description')} - > - 🎤 Use Voice Input - </button> - </div> - )} + <label className="mb-2 block text-sm font-medium text-gray-700">Description</label> + {activeField === 'description' ? ( + <VoiceInputMock + onConverted={(text: string) => { + setFormData({ ...formData, description: text }) + setActiveField(null) + }} + onCancel={() => setActiveField(null)} + /> + ) : ( + <div className="space-y-2"> + <textarea + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" + rows={4} + placeholder="Enter product description..." + value={formData.description} + onChange={(e) => setFormData({ ...formData, description: e.target.value })} + /> + <button + className="w-full rounded-lg bg-gray-100 px-3 py-2 text-sm hover:bg-gray-200" + onClick={() => setActiveField('description')} + > + 🎤 Use Voice Input + </button> + </div> + )} </div> <button className="w-full rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"> @@ -472,10 +444,7 @@ export const FeaturesShowcase: Story = { <h3 className="mb-4 text-lg font-semibold">Voice Input Features</h3> <div className="mb-6"> - <VoiceInputMock - onConverted={() => undefined} - onCancel={() => undefined} - /> + <VoiceInputMock onConverted={() => undefined} onCancel={() => undefined} /> </div> <div className="space-y-4"> @@ -507,7 +476,9 @@ export const FeaturesShowcase: Story = { </div> <div className="rounded-lg bg-orange-50 p-4"> - <div className="mb-2 text-sm font-medium text-orange-900">🔄 Audio-to-Text Conversion</div> + <div className="mb-2 text-sm font-medium text-orange-900"> + 🔄 Audio-to-Text Conversion + </div> <ul className="space-y-1 text-xs text-orange-800"> <li>• Server-side speech-to-text processing</li> <li>• Optional word timestamps support</li> diff --git a/web/app/components/base/voice-input/index.tsx b/web/app/components/base/voice-input/index.tsx index c9581942c63608..663d9e0b4c40dc 100644 --- a/web/app/components/base/voice-input/index.tsx +++ b/web/app/components/base/voice-input/index.tsx @@ -15,18 +15,16 @@ type VoiceInputTypes = { wordTimestamps?: string } -const VoiceInput = ({ - onCancel, - onConverted, - wordTimestamps, -}: VoiceInputTypes) => { +const VoiceInput = ({ onCancel, onConverted, wordTimestamps }: VoiceInputTypes) => { const { t } = useTranslation() - const recorder = useRef(new Recorder({ - sampleBits: 16, - sampleRate: 16000, - numChannels: 1, - compiling: false, - })) + const recorder = useRef( + new Recorder({ + sampleBits: 16, + sampleRate: 16000, + numChannels: 1, + compiling: false, + }), + ) const canvasRef = useRef<HTMLCanvasElement | null>(null) const ctxRef = useRef<CanvasRenderingContext2D | null>(null) const drawRecordId = useRef<number | null>(null) @@ -52,21 +50,18 @@ const VoiceInput = ({ ctx.beginPath() let x = 0 for (let i = 0; i < lineLength; i++) { - let v = dataArray.slice(i * gap, i * gap + gap).reduce((prev: number, next: number) => { - return prev + next - }, 0) / gap + let v = + dataArray.slice(i * gap, i * gap + gap).reduce((prev: number, next: number) => { + return prev + next + }, 0) / gap - if (v < 128) - v = 128 - if (v > 178) - v = 178 - const y = (v - 128) / 50 * canvas.height + if (v < 128) v = 128 + if (v > 178) v = 178 + const y = ((v - 128) / 50) * canvas.height ctx.moveTo(x, 16) - if (ctx.roundRect) - ctx.roundRect(x, 16 - y, 2, y, [1, 1, 0, 0]) - else - ctx.rect(x, 16 - y, 2, y) + if (ctx.roundRect) ctx.roundRect(x, 16 - y, 2, y, [1, 1, 0, 0]) + else ctx.rect(x, 16 - y, 2, y) ctx.fill() x += 3 } @@ -77,8 +72,7 @@ const VoiceInput = ({ setStartRecord(false) setStartConvert(true) recorder.current.stop() - if (drawRecordId.current) - cancelAnimationFrame(drawRecordId.current) + if (drawRecordId.current) cancelAnimationFrame(drawRecordId.current) drawRecordId.current = null const canvas = canvasRef.current! const ctx = ctxRef.current! @@ -95,20 +89,20 @@ const VoiceInput = ({ if (params.token) { url = '/audio-to-text' isPublic = true - } - else if (params.appId) { - if (isInstalledAppPath(pathname)) - url = `/installed-apps/${params.appId}/audio-to-text` - else - url = `/apps/${params.appId}/audio-to-text` + } else if (params.appId) { + if (isInstalledAppPath(pathname)) url = `/installed-apps/${params.appId}/audio-to-text` + else url = `/apps/${params.appId}/audio-to-text` } try { - const audioResponse = await audioToText(url, isPublic ? AppSourceType.webApp : AppSourceType.installedApp, formData) + const audioResponse = await audioToText( + url, + isPublic ? AppSourceType.webApp : AppSourceType.installedApp, + formData, + ) onConverted(audioResponse.text) onCancel() - } - catch { + } catch { onConverted('') onCancel() } @@ -119,10 +113,8 @@ const VoiceInput = ({ setStartRecord(true) setStartConvert(false) - if (canvasRef.current && ctxRef.current) - drawRecord() - } - catch { + if (canvasRef.current && ctxRef.current) drawRecord() + } catch { onCancel() } }, [drawRecord, onCancel, setStartRecord, setStartConvert]) @@ -145,8 +137,7 @@ const VoiceInput = ({ } } }, []) - if (originDuration >= 600 && startRecord) - handleStopRecorder() + if (originDuration >= 600 && startRecord) handleStopRecorder() useEffect(() => { initCanvas() @@ -164,48 +155,46 @@ const VoiceInput = ({ <div className={cn(s.wrapper, 'absolute inset-0 rounded-xl')}> <div className="absolute inset-[1.5px] flex items-center overflow-hidden rounded-[10.5px] bg-primary-25 py-[14px] pr-[6.5px] pl-[14.5px]"> <canvas id="voice-input-record" className="absolute bottom-0 left-0 h-4 w-full" /> - { - startConvert && <div className="mr-2 i-ri-loader-2-line size-4 animate-spin text-primary-700" data-testid="voice-input-loader" /> - } + {startConvert && ( + <div + className="mr-2 i-ri-loader-2-line size-4 animate-spin text-primary-700" + data-testid="voice-input-loader" + /> + )} <div className="grow"> - { - startRecord && ( - <div className="text-sm text-gray-500"> - {t($ => $['voiceInput.speaking'], { ns: 'common' })} - </div> - ) - } - { - startConvert && ( - <div className={cn(s.convert, 'text-sm')} data-testid="voice-input-converting-text"> - {t($ => $['voiceInput.converting'], { ns: 'common' })} - </div> - ) - } - </div> - { - startRecord && ( - <div - className="mr-1 flex size-8 cursor-pointer items-center justify-center rounded-lg hover:bg-primary-100" - onClick={handleStopRecorder} - data-testid="voice-input-stop" - > - <div className="i-ri-stop-circle-line size-5 text-primary-600" /> + {startRecord && ( + <div className="text-sm text-gray-500"> + {t(($) => $['voiceInput.speaking'], { ns: 'common' })} </div> - ) - } - { - startConvert && ( - <div - className="mr-1 flex size-8 cursor-pointer items-center justify-center rounded-lg hover:bg-gray-200" - onClick={onCancel} - data-testid="voice-input-cancel" - > - <div className="i-ri-close-line size-4 text-gray-500" /> + )} + {startConvert && ( + <div className={cn(s.convert, 'text-sm')} data-testid="voice-input-converting-text"> + {t(($) => $['voiceInput.converting'], { ns: 'common' })} </div> - ) - } - <div className={`w-[45px] pl-1 text-xs font-medium ${originDuration > 500 ? 'text-[#F04438]' : 'text-gray-700'}`} data-testid="voice-input-timer">{`0${minutes.toFixed(0)}:${seconds >= 10 ? seconds : `0${seconds}`}`}</div> + )} + </div> + {startRecord && ( + <div + className="mr-1 flex size-8 cursor-pointer items-center justify-center rounded-lg hover:bg-primary-100" + onClick={handleStopRecorder} + data-testid="voice-input-stop" + > + <div className="i-ri-stop-circle-line size-5 text-primary-600" /> + </div> + )} + {startConvert && ( + <div + className="mr-1 flex size-8 cursor-pointer items-center justify-center rounded-lg hover:bg-gray-200" + onClick={onCancel} + data-testid="voice-input-cancel" + > + <div className="i-ri-close-line size-4 text-gray-500" /> + </div> + )} + <div + className={`w-[45px] pl-1 text-xs font-medium ${originDuration > 500 ? 'text-[#F04438]' : 'text-gray-700'}`} + data-testid="voice-input-timer" + >{`0${minutes.toFixed(0)}:${seconds >= 10 ? seconds : `0${seconds}`}`}</div> </div> </div> ) diff --git a/web/app/components/base/voice-input/utils.ts b/web/app/components/base/voice-input/utils.ts index 8fbd1a8b17e400..3560350e489475 100644 --- a/web/app/components/base/voice-input/utils.ts +++ b/web/app/components/base/voice-input/utils.ts @@ -5,9 +5,9 @@ import MPEGMode from 'lamejs/src/js/MPEGMode' /* v8 ignore next - @preserve */ if (globalThis) { - (globalThis as any).MPEGMode = MPEGMode - ; (globalThis as any).Lame = Lame - ; (globalThis as any).BitStream = BitStream + ;(globalThis as any).MPEGMode = MPEGMode + ;(globalThis as any).Lame = Lame + ;(globalThis as any).BitStream = BitStream } export const convertToMp3 = (recorder: any) => { @@ -18,7 +18,8 @@ export const convertToMp3 = (recorder: any) => { const buffer: BlobPart[] = [] const leftData = result.left && new Int16Array(result.left.buffer, 0, result.left.byteLength / 2) - const rightData = result.right && new Int16Array(result.right.buffer, 0, result.right.byteLength / 2) + const rightData = + result.right && new Int16Array(result.right.buffer, 0, result.right.byteLength / 2) const remaining = leftData.length + (rightData ? rightData.length : 0) const maxSamples = 1152 @@ -36,19 +37,16 @@ export const convertToMp3 = (recorder: any) => { if (channels === 2) { right = rightData.subarray(i, i + maxSamples) mp3buf = mp3enc.encodeBuffer(left, right) - } - else { + } else { mp3buf = mp3enc.encodeBuffer(left) } - if (mp3buf.length > 0) - buffer.push(toArrayBuffer(mp3buf)) + if (mp3buf.length > 0) buffer.push(toArrayBuffer(mp3buf)) } const enc = mp3enc.flush() - if (enc.length > 0) - buffer.push(toArrayBuffer(enc)) + if (enc.length > 0) buffer.push(toArrayBuffer(enc)) return new Blob(buffer, { type: 'audio/mp3' }) } diff --git a/web/app/components/base/zendesk/__tests__/index.spec.tsx b/web/app/components/base/zendesk/__tests__/index.spec.tsx index e928b1437b126d..115ba2d5c4f374 100644 --- a/web/app/components/base/zendesk/__tests__/index.spec.tsx +++ b/web/app/components/base/zendesk/__tests__/index.spec.tsx @@ -14,23 +14,28 @@ vi.mock('react', async (importOriginal) => { const actual = await importOriginal<typeof import('react')>() return { ...actual, - memo: vi.fn(fn => fn), + memo: vi.fn((fn) => fn), } }) // Mock config vi.mock('@/config', () => ({ - get IS_CE_EDITION() { return mockIsCeEdition }, - get ZENDESK_WIDGET_KEY() { return mockZendeskWidgetKey }, - get IS_PROD() { return mockIsProd }, + get IS_CE_EDITION() { + return mockIsCeEdition + }, + get ZENDESK_WIDGET_KEY() { + return mockZendeskWidgetKey + }, + get IS_PROD() { + return mockIsProd + }, })) // Mock next/headers vi.mock('@/next/headers', () => ({ headers: vi.fn(() => ({ get: vi.fn((name: string) => { - if (name === 'x-nonce') - return mockNonce + if (name === 'x-nonce') return mockNonce return null }), })), @@ -38,10 +43,10 @@ vi.mock('@/next/headers', () => ({ // Mock next/script type ScriptProps = { - 'children'?: ReactNode - 'id'?: string - 'src'?: string - 'nonce'?: string + children?: ReactNode + id?: string + src?: string + nonce?: string 'data-testid'?: string } vi.mock('@/next/script', () => ({ @@ -88,13 +93,16 @@ describe('Zendesk', () => { const snippet = screen.getByTestId('ze-snippet') expect(snippet).toBeInTheDocument() expect(snippet).toHaveAttribute('id', 'ze-snippet') - expect(snippet).toHaveAttribute('data-src', 'https://static.zdassets.com/ekr/snippet.js?key=test-key') + expect(snippet).toHaveAttribute( + 'data-src', + 'https://static.zdassets.com/ekr/snippet.js?key=test-key', + ) expect(snippet).toHaveAttribute('data-nonce', '') const init = screen.getByTestId('ze-init') expect(init).toBeInTheDocument() expect(init).toHaveAttribute('id', 'ze-init') - expect(init).toHaveTextContent('window.zE(\'messenger\', \'hide\')') + expect(init).toHaveTextContent("window.zE('messenger', 'hide')") expect(init).toHaveAttribute('data-nonce', '') }) diff --git a/web/app/components/base/zendesk/index.tsx b/web/app/components/base/zendesk/index.tsx index 20f4f84baf3cb9..a6a0288d054704 100644 --- a/web/app/components/base/zendesk/index.tsx +++ b/web/app/components/base/zendesk/index.tsx @@ -4,10 +4,9 @@ import { headers } from '@/next/headers' import Script from '@/next/script' const Zendesk = async () => { - if (IS_CE_EDITION || !ZENDESK_WIDGET_KEY) - return null + if (IS_CE_EDITION || !ZENDESK_WIDGET_KEY) return null - const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? '' : '' + const nonce = IS_PROD ? ((await headers()).get('x-nonce') ?? '') : '' /* v8 ignore next -- `nonce` is always a string (`''` or header value), so nullish fallback is unreachable in runtime. @preserve */ const scriptNonce = nonce ?? undefined diff --git a/web/app/components/base/zendesk/utils.ts b/web/app/components/base/zendesk/utils.ts index b0c5a4bdc70964..25138dbe5d64f0 100644 --- a/web/app/components/base/zendesk/utils.ts +++ b/web/app/components/base/zendesk/utils.ts @@ -17,7 +17,10 @@ declare global { } } -export const setZendeskConversationFields = (fields: ConversationField[], callback?: () => unknown) => { +export const setZendeskConversationFields = ( + fields: ConversationField[], + callback?: () => unknown, +) => { if (!IS_CE_EDITION && window.zE) window.zE('messenger:set', 'conversationFields', fields, callback) } @@ -28,8 +31,7 @@ type OpenZendeskWindowOptions = { } const openZendeskWindowOnce = () => { - if (IS_CE_EDITION || !window.zE) - return false + if (IS_CE_EDITION || !window.zE) return false window.zE('messenger', 'show') window.zE('messenger', 'open') @@ -40,16 +42,13 @@ export const openZendeskWindow = ({ interval = 100, retries = 20, }: OpenZendeskWindowOptions = {}) => { - if (IS_CE_EDITION) - return + if (IS_CE_EDITION) return - if (openZendeskWindowOnce()) - return + if (openZendeskWindowOnce()) return let attempts = 0 const timer = window.setInterval(() => { attempts += 1 - if (openZendeskWindowOnce() || attempts >= retries) - window.clearInterval(timer) + if (openZendeskWindowOnce() || attempts >= retries) window.clearInterval(timer) }, interval) } diff --git a/web/app/components/billing/__tests__/config.spec.ts b/web/app/components/billing/__tests__/config.spec.ts index 251848f3d3b909..9fd08a38d464be 100644 --- a/web/app/components/billing/__tests__/config.spec.ts +++ b/web/app/components/billing/__tests__/config.spec.ts @@ -1,4 +1,11 @@ -import { ALL_PLANS, contactSalesUrl, defaultPlan, getStartedWithCommunityUrl, getWithPremiumUrl, NUM_INFINITE } from '../config' +import { + ALL_PLANS, + contactSalesUrl, + defaultPlan, + getStartedWithCommunityUrl, + getWithPremiumUrl, + NUM_INFINITE, +} from '../config' import { Priority } from '../type' describe('Billing Config', () => { @@ -34,11 +41,13 @@ describe('Billing Config', () => { 'logHistory', ] - it.each(['sandbox', 'professional', 'team'] as const)('should have all required fields for %s plan', (planKey) => { - const plan = ALL_PLANS[planKey] - for (const field of requiredFields) - expect(plan[field]).toBeDefined() - }) + it.each(['sandbox', 'professional', 'team'] as const)( + 'should have all required fields for %s plan', + (planKey) => { + const plan = ALL_PLANS[planKey] + for (const field of requiredFields) expect(plan[field]).toBeDefined() + }, + ) it('should have ascending plan levels: sandbox < professional < team', () => { expect(ALL_PLANS.sandbox.level).toBeLessThan(ALL_PLANS.professional.level) @@ -127,7 +136,9 @@ describe('Billing Config', () => { expect(defaultPlan.usage.vectorSpace).toBeLessThanOrEqual(defaultPlan.total.vectorSpace) expect(defaultPlan.usage.buildApps).toBeLessThanOrEqual(defaultPlan.total.buildApps) expect(defaultPlan.usage.teamMembers).toBeLessThanOrEqual(defaultPlan.total.teamMembers) - expect(defaultPlan.usage.annotatedResponse).toBeLessThanOrEqual(defaultPlan.total.annotatedResponse) + expect(defaultPlan.usage.annotatedResponse).toBeLessThanOrEqual( + defaultPlan.total.annotatedResponse, + ) }) }) }) diff --git a/web/app/components/billing/annotation-full/__tests__/modal.spec.tsx b/web/app/components/billing/annotation-full/__tests__/modal.spec.tsx index 3f7862b24bdcc6..3ea13074d6542e 100644 --- a/web/app/components/billing/annotation-full/__tests__/modal.spec.tsx +++ b/web/app/components/billing/annotation-full/__tests__/modal.spec.tsx @@ -31,12 +31,20 @@ type ModalSnapshot = { let mockModalProps: ModalSnapshot | null = null let mockOnOpenChange: ((open: boolean) => void) | undefined vi.mock('@langgenius/dify-ui/dialog', () => ({ - Dialog: ({ open, onOpenChange, children }: { open?: boolean, onOpenChange?: (open: boolean) => void, children: React.ReactNode }) => { + Dialog: ({ + open, + onOpenChange, + children, + }: { + open?: boolean + onOpenChange?: (open: boolean) => void + children: React.ReactNode + }) => { mockOnOpenChange = onOpenChange mockModalProps = { isShow: open !== false } return open === false ? null : <>{children}</> }, - DialogContent: ({ children, className }: { children: React.ReactNode, className?: string }) => { + DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => { mockModalProps = { isShow: true, closable: true, @@ -48,7 +56,11 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({ </div> ) }, - DialogCloseButton: () => <button type="button" data-testid="mock-modal-close" onClick={() => mockOnOpenChange?.(false)}>close</button>, + DialogCloseButton: () => ( + <button type="button" data-testid="mock-modal-close" onClick={() => mockOnOpenChange?.(false)}> + close + </button> + ), })) describe('AnnotationFullModal', () => { @@ -69,11 +81,13 @@ describe('AnnotationFullModal', () => { expect(screen.getByTestId('usage-component')).toHaveAttribute('data-classname', 'mt-4') expect(screen.getByTestId('upgrade-btn')).toHaveTextContent('annotation-create') expect(mockUpgradeBtnProps?.loc).toBe('annotation-create') - expect(mockModalProps).toEqual(expect.objectContaining({ - isShow: true, - closable: true, - className: expect.stringContaining('p-0!'), - })) + expect(mockModalProps).toEqual( + expect.objectContaining({ + isShow: true, + closable: true, + className: expect.stringContaining('p-0!'), + }), + ) expect(mockModalProps?.className).toContain('w-full') }) }) diff --git a/web/app/components/billing/annotation-full/index.tsx b/web/app/components/billing/annotation-full/index.tsx index 1f7b50f2711c7d..efbcac78b6d95d 100644 --- a/web/app/components/billing/annotation-full/index.tsx +++ b/web/app/components/billing/annotation-full/index.tsx @@ -12,12 +12,16 @@ const AnnotationFull: FC = () => { const { t } = useTranslation() return ( - <GridMask wrapperClassName="rounded-lg" canvasClassName="rounded-lg" gradientClassName="rounded-lg"> + <GridMask + wrapperClassName="rounded-lg" + canvasClassName="rounded-lg" + gradientClassName="rounded-lg" + > <div className="mt-6 flex cursor-pointer flex-col rounded-lg border-2 border-solid border-transparent px-3.5 py-4 shadow-md transition-all duration-200 ease-in-out"> <div className="flex items-center justify-between"> <div className={cn(s.textGradient, 'text-base leading-[24px] font-semibold')}> - <div>{t($ => $['annotatedResponse.fullTipLine1'], { ns: 'billing' })}</div> - <div>{t($ => $['annotatedResponse.fullTipLine2'], { ns: 'billing' })}</div> + <div>{t(($) => $['annotatedResponse.fullTipLine1'], { ns: 'billing' })}</div> + <div>{t(($) => $['annotatedResponse.fullTipLine2'], { ns: 'billing' })}</div> </div> <div className="flex"> <UpgradeBtn loc="annotation-create" /> diff --git a/web/app/components/billing/annotation-full/modal.tsx b/web/app/components/billing/annotation-full/modal.tsx index e57236d05c1667..ab7301f12d01f1 100644 --- a/web/app/components/billing/annotation-full/modal.tsx +++ b/web/app/components/billing/annotation-full/modal.tsx @@ -13,31 +13,30 @@ type Props = Readonly<{ show: boolean onHide: () => void }> -const AnnotationFullModal: FC<Props> = ({ - show, - onHide, -}) => { +const AnnotationFullModal: FC<Props> = ({ show, onHide }) => { const { t } = useTranslation() return ( <Dialog open={show} onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DialogContent className="w-full overflow-hidden! border-none p-0! text-left align-middle"> <DialogCloseButton /> - <GridMask wrapperClassName="rounded-lg" canvasClassName="rounded-lg" gradientClassName="rounded-lg"> + <GridMask + wrapperClassName="rounded-lg" + canvasClassName="rounded-lg" + gradientClassName="rounded-lg" + > <div className="mt-6 flex cursor-pointer flex-col rounded-lg border-2 border-solid border-transparent px-7 py-6 shadow-md transition-all duration-200 ease-in-out"> <div className="flex items-center justify-between"> <div className={cn(s.textGradient, 'text-[18px] leading-[27px] font-semibold')}> - <div>{t($ => $['annotatedResponse.fullTipLine1'], { ns: 'billing' })}</div> - <div>{t($ => $['annotatedResponse.fullTipLine2'], { ns: 'billing' })}</div> + <div>{t(($) => $['annotatedResponse.fullTipLine1'], { ns: 'billing' })}</div> + <div>{t(($) => $['annotatedResponse.fullTipLine2'], { ns: 'billing' })}</div> </div> - </div> <Usage className="mt-4" /> <div className="mt-7 flex justify-end"> diff --git a/web/app/components/billing/annotation-full/style.module.css b/web/app/components/billing/annotation-full/style.module.css index 15bedd84ca48ed..3df9ef6c2dad82 100644 --- a/web/app/components/billing/annotation-full/style.module.css +++ b/web/app/components/billing/annotation-full/style.module.css @@ -1,5 +1,5 @@ .textGradient { - background: linear-gradient(92deg, #2250F2 -29.55%, #0EBCF3 75.22%); + background: linear-gradient(92deg, #2250f2 -29.55%, #0ebcf3 75.22%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; diff --git a/web/app/components/billing/annotation-full/usage.tsx b/web/app/components/billing/annotation-full/usage.tsx index 51e757bdefdfb3..614d27f67bbd02 100644 --- a/web/app/components/billing/annotation-full/usage.tsx +++ b/web/app/components/billing/annotation-full/usage.tsx @@ -10,20 +10,15 @@ type Props = Readonly<{ className?: string }> -const Usage: FC<Props> = ({ - className, -}) => { +const Usage: FC<Props> = ({ className }) => { const { t } = useTranslation() const { plan } = useProviderContext() - const { - usage, - total, - } = plan + const { usage, total } = plan return ( <UsageInfo className={className} Icon={MessageFastPlus} - name={t($ => $['annotatedResponse.quotaTitle'], { ns: 'billing' })} + name={t(($) => $['annotatedResponse.quotaTitle'], { ns: 'billing' })} usage={usage.annotatedResponse} total={total.annotatedResponse} /> diff --git a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx index f05bbf592d0d83..e4c2fdffd86528 100644 --- a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx +++ b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx @@ -44,7 +44,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -77,7 +78,9 @@ const buildUsage = (overrides: Partial<UsagePlanInfo> = {}): UsagePlanInfo => ({ ...overrides, }) -const buildProviderContext = (overrides: Partial<ProviderContextState> = {}): ProviderContextState => ({ +const buildProviderContext = ( + overrides: Partial<ProviderContextState> = {}, +): ProviderContextState => ({ ...baseProviderContextValue, plan: { ...baseProviderContextValue.plan, @@ -92,7 +95,9 @@ const buildProviderContext = (overrides: Partial<ProviderContextState> = {}): Pr ...overrides, }) -const buildAppContext = (overrides: Partial<AppContextStateMockState> = {}): AppContextStateMockState => { +const buildAppContext = ( + overrides: Partial<AppContextStateMockState> = {}, +): AppContextStateMockState => { const userProfile: GetAccountProfileResponse = { id: 'user-id', name: 'Test User', @@ -168,40 +173,47 @@ describe('AppsFull', () => { describe('Props', () => { it('should render team messaging and contact button for non-sandbox plans', () => { - ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ - plan: { - ...baseProviderContextValue.plan, - type: Plan.team, - usage: buildUsage({ buildApps: 8 }), - total: buildUsage({ buildApps: 10 }), - reset: { - apiRateLimit: null, - triggerEvents: null, + ;(useProviderContext as Mock).mockReturnValue( + buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.team, + usage: buildUsage({ buildApps: 8 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, }, - }, - })) + }), + ) render(<AppsFull loc="billing_dialog" />) expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument() expect(screen.getByText('billing.apps.fullTip2des')).toBeInTheDocument() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() - expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com') + expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute( + 'href', + 'mailto:support@example.com', + ) expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.team, '1.0.0') }) it('should render upgrade button for professional plans', () => { - ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ - plan: { - ...baseProviderContextValue.plan, - type: Plan.professional, - usage: buildUsage({ buildApps: 4 }), - total: buildUsage({ buildApps: 10 }), - reset: { - apiRateLimit: null, - triggerEvents: null, + ;(useProviderContext as Mock).mockReturnValue( + buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.professional, + usage: buildUsage({ buildApps: 4 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, }, - }, - })) + }), + ) render(<AppsFull loc="billing_dialog" />) @@ -211,24 +223,29 @@ describe('AppsFull', () => { }) it('should render contact button for enterprise plans', () => { - ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ - plan: { - ...baseProviderContextValue.plan, - type: Plan.enterprise, - usage: buildUsage({ buildApps: 9 }), - total: buildUsage({ buildApps: 10 }), - reset: { - apiRateLimit: null, - triggerEvents: null, + ;(useProviderContext as Mock).mockReturnValue( + buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.enterprise, + usage: buildUsage({ buildApps: 9 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, }, - }, - })) + }), + ) render(<AppsFull loc="billing_dialog" />) expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() - expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com') + expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute( + 'href', + 'mailto:support@example.com', + ) expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.enterprise, '1.0.0') }) }) @@ -236,15 +253,17 @@ describe('AppsFull', () => { describe('Edge Cases', () => { it('applies neutral / warning / error tone at distinct usage levels', () => { const findToneClass = (used: number, total: number) => { - ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ - plan: { - ...baseProviderContextValue.plan, - type: Plan.sandbox, - usage: buildUsage({ buildApps: used }), - total: buildUsage({ buildApps: total }), - reset: { apiRateLimit: null, triggerEvents: null }, - }, - })) + ;(useProviderContext as Mock).mockReturnValue( + buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + usage: buildUsage({ buildApps: used }), + total: buildUsage({ buildApps: total }), + reset: { apiRateLimit: null, triggerEvents: null }, + }, + }), + ) const { container, unmount } = render(<AppsFull loc="billing_dialog" />) const indicator = container.querySelector( '[class*="bg-components-progress-"]:not([class*="progress-bar-bg"])', diff --git a/web/app/components/billing/apps-full-in-dialog/index.tsx b/web/app/components/billing/apps-full-in-dialog/index.tsx index 8fd6dfa650bc37..639a325f5cc0d4 100644 --- a/web/app/components/billing/apps-full-in-dialog/index.tsx +++ b/web/app/components/billing/apps-full-in-dialog/index.tsx @@ -15,10 +15,7 @@ import { langGeniusCurrentVersionAtom } from '@/context/version-state' import UpgradeBtn from '../upgrade-btn' import s from './style.module.css' -const AppsFull: FC<{ loc: string, className?: string }> = ({ - loc, - className, -}) => { +const AppsFull: FC<{ loc: string; className?: string }> = ({ loc, className }) => { const { t } = useTranslation() const { plan } = useProviderContext() const userProfileEmail = useAtomValue(userProfileEmailAtom) @@ -28,28 +25,33 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({ const total = plan.total.buildApps const percent = total > 0 ? (usage / total) * 100 : 0 const tone: MeterTone = percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'neutral' - const buildAppsLabel = t($ => $['usagePage.buildApps'], { ns: 'billing' }) + const buildAppsLabel = t(($) => $['usagePage.buildApps'], { ns: 'billing' }) return ( - <div className={cn( - 'flex flex-col gap-3 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-4 shadow-xs backdrop-blur-xs', - className, - )} + <div + className={cn( + 'flex flex-col gap-3 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-4 shadow-xs backdrop-blur-xs', + className, + )} > <div className="flex justify-between"> {!isTeam && ( <div> <div className={cn('mb-1 title-xl-semi-bold', s.textGradient)}> - {t($ => $['apps.fullTip1'], { ns: 'billing' })} + {t(($) => $['apps.fullTip1'], { ns: 'billing' })} + </div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['apps.fullTip1des'], { ns: 'billing' })} </div> - <div className="system-xs-regular text-text-tertiary">{t($ => $['apps.fullTip1des'], { ns: 'billing' })}</div> </div> )} {isTeam && ( <div> <div className={cn('mb-1 title-xl-semi-bold', s.textGradient)}> - {t($ => $['apps.fullTip2'], { ns: 'billing' })} + {t(($) => $['apps.fullTip2'], { ns: 'billing' })} + </div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['apps.fullTip2des'], { ns: 'billing' })} </div> - <div className="system-xs-regular text-text-tertiary">{t($ => $['apps.fullTip2des'], { ns: 'billing' })}</div> </div> )} {(plan.type === Plan.sandbox || plan.type === Plan.professional) && ( @@ -57,8 +59,12 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({ )} {plan.type !== Plan.sandbox && plan.type !== Plan.professional && ( <Button variant="secondary-accent"> - <a target="_blank" rel="noopener noreferrer" href={mailToSupport(userProfileEmail, plan.type, currentVersion)}> - {t($ => $['apps.contactUs'], { ns: 'billing' })} + <a + target="_blank" + rel="noopener noreferrer" + href={mailToSupport(userProfileEmail, plan.type, currentVersion)} + > + {t(($) => $['apps.contactUs'], { ns: 'billing' })} </a> </Button> )} @@ -67,9 +73,7 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({ <div className="flex items-center justify-between system-xs-medium text-text-secondary"> <div>{buildAppsLabel}</div> <div> - {usage} - / - {total} + {usage}/{total} </div> </div> <Meter value={Math.min(percent, 100)} max={100} aria-label={buildAppsLabel}> diff --git a/web/app/components/billing/apps-full-in-dialog/style.module.css b/web/app/components/billing/apps-full-in-dialog/style.module.css index 1f68e665a62cd7..7fe3655a31d147 100644 --- a/web/app/components/billing/apps-full-in-dialog/style.module.css +++ b/web/app/components/billing/apps-full-in-dialog/style.module.css @@ -1,5 +1,5 @@ .textGradient { - background: linear-gradient(92deg, #0EBCF3 -29.55%, #2250F2 75.22%); + background: linear-gradient(92deg, #0ebcf3 -29.55%, #2250f2 75.22%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; diff --git a/web/app/components/billing/billing-page/__tests__/index.spec.tsx b/web/app/components/billing/billing-page/__tests__/index.spec.tsx index 77a9db52373110..0fef57b4888bd1 100644 --- a/web/app/components/billing/billing-page/__tests__/index.spec.tsx +++ b/web/app/components/billing/billing-page/__tests__/index.spec.tsx @@ -71,7 +71,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -102,21 +103,27 @@ describe('Billing', () => { render(<Billing />) - expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /billing\.viewBillingTitle/ }), + ).not.toBeInTheDocument() expect(billingUrlEnabled).toBe(false) }) it('hides the billing action when subscription management permission is missing or billing is disabled', () => { workspacePermissionKeys = [] render(<Billing />) - expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /billing\.viewBillingTitle/ }), + ).not.toBeInTheDocument() expect(billingUrlEnabled).toBe(false) vi.clearAllMocks() workspacePermissionKeys = ['billing.subscription.manage'] enableBilling = false render(<Billing />) - expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /billing\.viewBillingTitle/ }), + ).not.toBeInTheDocument() expect(billingUrlEnabled).toBe(false) }) diff --git a/web/app/components/billing/billing-page/index.tsx b/web/app/components/billing/billing-page/index.tsx index 2fd6b056165a07..dec66dd2eadad0 100644 --- a/web/app/components/billing/billing-page/index.tsx +++ b/web/app/components/billing/billing-page/index.tsx @@ -16,23 +16,31 @@ const Billing: FC = () => { const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { enableBilling } = useProviderContext() - const canManageBillingSubscription = isCurrentWorkspaceManager && hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage) - const { data: billingUrl, isFetching, refetch } = useBillingUrl(enableBilling && canManageBillingSubscription) + const canManageBillingSubscription = + isCurrentWorkspaceManager && + hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage) + const { + data: billingUrl, + isFetching, + refetch, + } = useBillingUrl(enableBilling && canManageBillingSubscription) const openAsyncWindow = useAsyncWindowOpen() const handleOpenBilling = async () => { - await openAsyncWindow(async () => { - const url = (await refetch()).data - if (url) - return url - return null - }, { - immediateUrl: billingUrl, - features: 'noopener,noreferrer', - onError: (err) => { - console.error('Failed to fetch billing url', err) + await openAsyncWindow( + async () => { + const url = (await refetch()).data + if (url) return url + return null }, - }) + { + immediateUrl: billingUrl, + features: 'noopener,noreferrer', + onError: (err) => { + console.error('Failed to fetch billing url', err) + }, + }, + ) } return ( @@ -46,11 +54,17 @@ const Billing: FC = () => { disabled={isFetching} > <div className="flex flex-col gap-0.5 text-left"> - <div className="system-md-semibold text-text-primary">{t($ => $.viewBillingTitle, { ns: 'billing' })}</div> - <div className="system-sm-regular text-text-secondary">{t($ => $.viewBillingDescription, { ns: 'billing' })}</div> + <div className="system-md-semibold text-text-primary"> + {t(($) => $.viewBillingTitle, { ns: 'billing' })} + </div> + <div className="system-sm-regular text-text-secondary"> + {t(($) => $.viewBillingDescription, { ns: 'billing' })} + </div> </div> <span className="inline-flex h-8 w-24 items-center justify-center gap-0.5 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-saas-dify-blue-accessible shadow-[0_1px_2px_rgba(9,9,11,0.05)] backdrop-blur-[5px]"> - <span className="system-sm-medium leading-none">{t($ => $.viewBillingAction, { ns: 'billing' })}</span> + <span className="system-sm-medium leading-none"> + {t(($) => $.viewBillingAction, { ns: 'billing' })} + </span> <span className="i-ri-arrow-right-up-line size-4" /> </span> </button> diff --git a/web/app/components/billing/hooks/use-education-discount.ts b/web/app/components/billing/hooks/use-education-discount.ts index 26c3b3da3eec5b..93b65667e79696 100644 --- a/web/app/components/billing/hooks/use-education-discount.ts +++ b/web/app/components/billing/hooks/use-education-discount.ts @@ -15,11 +15,10 @@ export const useEducationDiscount = () => { const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage) const handleEducationDiscount = useCallback(async () => { - if (isEducationDiscountLoading) - return + if (isEducationDiscountLoading) return if (!canManageBilling) { - toast.error(t($ => $.buyPermissionDeniedTip, { ns: 'billing' })) + toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' })) return } @@ -27,8 +26,7 @@ export const useEducationDiscount = () => { try { const res = await fetchSubscriptionUrls(Plan.professional, 'year') window.location.href = res.url - } - finally { + } finally { setIsEducationDiscountLoading(false) } }, [canManageBilling, isEducationDiscountLoading, t]) diff --git a/web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx b/web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx index 2ea5db840fed9c..86c745f8de203f 100644 --- a/web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx +++ b/web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx @@ -22,15 +22,13 @@ function getPartnerStackGlobal(): PartnerStackGlobal { const ensureCookieMocks = () => { const globals = getPartnerStackGlobal() - if (!globals.__partnerStackCookieMocks) - throw new Error('Cookie mocks not initialized') + if (!globals.__partnerStackCookieMocks) throw new Error('Cookie mocks not initialized') return globals.__partnerStackCookieMocks } const ensureMutateAsync = () => { const globals = getPartnerStackGlobal() - if (!globals.__partnerStackMutateAsync) - throw new Error('Mutate mock not initialized') + if (!globals.__partnerStackMutateAsync) throw new Error('Mutate mock not initialized') return globals.__partnerStackMutateAsync } @@ -128,10 +126,12 @@ describe('usePSInfo', () => { ps_xid: 'existing-click', }) const { get } = ensureCookieMocks() - get.mockReturnValue(JSON.stringify({ - partnerKey: 'existing', - clickId: 'existing-click', - })) + get.mockReturnValue( + JSON.stringify({ + partnerKey: 'existing', + clickId: 'existing-click', + }), + ) const { result } = renderHook(() => usePSInfo()) @@ -270,10 +270,12 @@ describe('usePSInfo', () => { // Fallback to cookie values: covers L19-20 right side of || operator it('should use cookie values when search params are absent', () => { const { get } = ensureCookieMocks() - get.mockReturnValue(JSON.stringify({ - partnerKey: 'cookie-partner', - clickId: 'cookie-click', - })) + get.mockReturnValue( + JSON.stringify({ + partnerKey: 'cookie-partner', + clickId: 'cookie-click', + }), + ) setSearchParams({}) const { result } = renderHook(() => usePSInfo()) diff --git a/web/app/components/billing/partner-stack/cookie-recorder.tsx b/web/app/components/billing/partner-stack/cookie-recorder.tsx index 3c75b2973c6d5b..91097d78b70aa0 100644 --- a/web/app/components/billing/partner-stack/cookie-recorder.tsx +++ b/web/app/components/billing/partner-stack/cookie-recorder.tsx @@ -8,8 +8,7 @@ const PartnerStackCookieRecorder = () => { const { saveOrUpdate } = usePSInfo() useEffect(() => { - if (!IS_CLOUD_EDITION) - return + if (!IS_CLOUD_EDITION) return saveOrUpdate() }, []) diff --git a/web/app/components/billing/partner-stack/index.tsx b/web/app/components/billing/partner-stack/index.tsx index e7b954a576704b..c0155ae2fc593a 100644 --- a/web/app/components/billing/partner-stack/index.tsx +++ b/web/app/components/billing/partner-stack/index.tsx @@ -8,8 +8,7 @@ import usePSInfo from './use-ps-info' const PartnerStack: FC = () => { const { saveOrUpdate, bind } = usePSInfo() useEffect(() => { - if (!IS_CLOUD_EDITION) - return + if (!IS_CLOUD_EDITION) return // Save PartnerStack info in cookie first. Because if user hasn't logged in, redirecting to login page would cause lose the partnerStack info in URL. saveOrUpdate() // bind PartnerStack info after user logged in diff --git a/web/app/components/billing/partner-stack/use-ps-info.ts b/web/app/components/billing/partner-stack/use-ps-info.ts index 5a83dec0e51a64..63efad4a8f921f 100644 --- a/web/app/components/billing/partner-stack/use-ps-info.ts +++ b/web/app/components/billing/partner-stack/use-ps-info.ts @@ -10,35 +10,35 @@ const usePSInfo = () => { const psInfoInCookie = (() => { try { return JSON.parse(Cookies.get(PARTNER_STACK_CONFIG.cookieName) || '{}') - } - catch (e) { + } catch (e) { console.error('Failed to parse partner stack info from cookie:', e) return {} } })() const psPartnerKey = searchParams.get('ps_partner_key') || psInfoInCookie?.partnerKey const psClickId = searchParams.get('ps_xid') || psInfoInCookie?.clickId - const isPSChanged = psInfoInCookie?.partnerKey !== psPartnerKey || psInfoInCookie?.clickId !== psClickId - const [hasBind, { - setTrue: setBind, - }] = useBoolean(false) + const isPSChanged = + psInfoInCookie?.partnerKey !== psPartnerKey || psInfoInCookie?.clickId !== psClickId + const [hasBind, { setTrue: setBind }] = useBoolean(false) const { mutateAsync } = useBindPartnerStackInfo() // Save to top domain. cloud.dify.ai => .dify.ai const domain = globalThis.location?.hostname.replace('cloud', '') const saveOrUpdate = useCallback(() => { - if (!psPartnerKey || !psClickId) - return - if (!isPSChanged) - return - Cookies.set(PARTNER_STACK_CONFIG.cookieName, JSON.stringify({ - partnerKey: psPartnerKey, - clickId: psClickId, - }), { - expires: PARTNER_STACK_CONFIG.saveCookieDays, - path: '/', - domain, - }) + if (!psPartnerKey || !psClickId) return + if (!isPSChanged) return + Cookies.set( + PARTNER_STACK_CONFIG.cookieName, + JSON.stringify({ + partnerKey: psPartnerKey, + clickId: psClickId, + }), + { + expires: PARTNER_STACK_CONFIG.saveCookieDays, + path: '/', + domain, + }, + ) }, [psPartnerKey, psClickId, isPSChanged, domain]) const bind = useCallback(async () => { @@ -50,13 +50,10 @@ const usePSInfo = () => { clickId: psClickId, }) shouldRemoveCookie = true + } catch (error: unknown) { + if ((error as { status: number })?.status === 400) shouldRemoveCookie = true } - catch (error: unknown) { - if ((error as { status: number })?.status === 400) - shouldRemoveCookie = true - } - if (shouldRemoveCookie) - Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain }) + if (shouldRemoveCookie) Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain }) setBind() } }, [psPartnerKey, psClickId, hasBind, domain, setBind, mutateAsync]) diff --git a/web/app/components/billing/plan-upgrade-modal/index.tsx b/web/app/components/billing/plan-upgrade-modal/index.tsx index d3c614b8d7dc71..c58f563bd69254 100644 --- a/web/app/components/billing/plan-upgrade-modal/index.tsx +++ b/web/app/components/billing/plan-upgrade-modal/index.tsx @@ -32,26 +32,22 @@ export function PlanUpgradeModal({ const handleUpgrade = useCallback(() => { onClose() - if (onUpgrade) - onUpgrade() - else - setShowPricingModal() + if (onUpgrade) onUpgrade() + else setShowPricingModal() }, [onClose, onUpgrade, setShowPricingModal]) return ( <UpgradeModal open={show} - onOpenChange={open => !open && onClose()} + onOpenChange={(open) => !open && onClose()} Icon={Icon} title={title} description={description} extraInfo={extraInfo} - footer={( + footer={ <> - <Button - onClick={onClose} - > - {t($ => $['triggerLimitModal.dismiss'], { ns: 'billing' })} + <Button onClick={onClose}> + {t(($) => $['triggerLimitModal.dismiss'], { ns: 'billing' })} </Button> <UpgradeBtn size="custom" @@ -62,7 +58,7 @@ export function PlanUpgradeModal({ loc="trigger-events-limit-modal" /> </> - )} + } /> ) } diff --git a/web/app/components/billing/plan/__tests__/index.spec.tsx b/web/app/components/billing/plan/__tests__/index.spec.tsx index 866e019cc3782f..13aa1115cb9817 100644 --- a/web/app/components/billing/plan/__tests__/index.spec.tsx +++ b/web/app/components/billing/plan/__tests__/index.spec.tsx @@ -34,9 +34,14 @@ vi.mock('@/config', async (importOriginal) => { const setShowAccountSettingModalMock = vi.fn() vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof setShowAccountSettingModalMock }) => unknown) => selector({ - setShowAccountSettingModal: setShowAccountSettingModalMock, - }), + useModalContextSelector: ( + selector: (state: { + setShowAccountSettingModal: typeof setShowAccountSettingModalMock + }) => unknown, + ) => + selector({ + setShowAccountSettingModal: setShowAccountSettingModalMock, + }), })) const providerContextMock = vi.fn() @@ -86,7 +91,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -115,17 +121,29 @@ vi.mock('@/service/use-education', () => ({ }), })) -const verifyStateModalMock = vi.fn(props => ( +const verifyStateModalMock = vi.fn((props) => ( <div data-testid="verify-modal" data-is-show={props.isShow ? 'true' : 'false'}> {props.isShow ? 'visible' : 'hidden'} </div> )) vi.mock('@/app/education-apply/verify-state-modal', () => ({ - default: (props: { isShow: boolean, title?: string, content?: string, email?: string, showLink?: boolean, onConfirm?: () => void, onCancel?: () => void }) => verifyStateModalMock(props), + default: (props: { + isShow: boolean + title?: string + content?: string + email?: string + showLink?: boolean + onConfirm?: () => void + onCancel?: () => void + }) => verifyStateModalMock(props), })) vi.mock('../../upgrade-btn', () => ({ - default: () => <button data-testid="plan-upgrade-btn" type="button">Upgrade</button>, + default: () => ( + <button data-testid="plan-upgrade-btn" type="button"> + Upgrade + </button> + ), })) describe('PlanComp', () => { @@ -215,7 +233,9 @@ describe('PlanComp', () => { fireEvent.click(verifyBtn) await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled()) - await waitFor(() => expect(screen.getByTestId('verify-modal').getAttribute('data-is-show')).toBe('true')) + await waitFor(() => + expect(screen.getByTestId('verify-modal').getAttribute('data-is-show')).toBe('true'), + ) }) it('resets modal context when on education apply path', () => { @@ -399,7 +419,9 @@ describe('PlanComp', () => { const verifyBtn = screen.getByText('education.toVerified') fireEvent.click(verifyBtn) - await waitFor(() => expect(screen.getByTestId('verify-modal').getAttribute('data-is-show')).toBe('true')) + await waitFor(() => + expect(screen.getByTestId('verify-modal').getAttribute('data-is-show')).toBe('true'), + ) // Get the props passed to the modal and call onConfirm/onCancel const lastCall = verifyStateModalMock.mock.calls[verifyStateModalMock.mock.calls.length - 1]![0] diff --git a/web/app/components/billing/plan/assets/__tests__/enterprise.spec.tsx b/web/app/components/billing/plan/assets/__tests__/enterprise.spec.tsx index 08458035ffe5fb..a96b0d2c63820b 100644 --- a/web/app/components/billing/plan/assets/__tests__/enterprise.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/enterprise.spec.tsx @@ -37,7 +37,9 @@ describe('Enterprise Icon Component', () => { it('should render elements with correct fill colors', () => { const { container } = render(<Enterprise />) - const blueElements = container.querySelectorAll('[fill="var(--color-saas-dify-blue-inverted)"]') + const blueElements = container.querySelectorAll( + '[fill="var(--color-saas-dify-blue-inverted)"]', + ) const quaternaryElements = container.querySelectorAll('[fill="var(--color-text-quaternary)"]') expect(blueElements.length).toBeGreaterThan(0) @@ -155,7 +157,7 @@ describe('Enterprise Icon Component', () => { it('should use CSS custom properties for colors', () => { const { container } = render(<Enterprise />) const allFillElements = container.querySelectorAll('[fill]') - const elementsWithCSSVars = Array.from(allFillElements).filter(el => + const elementsWithCSSVars = Array.from(allFillElements).filter((el) => el.getAttribute('fill')?.startsWith('var('), ) @@ -164,7 +166,9 @@ describe('Enterprise Icon Component', () => { it('should have opacity attributes on quaternary path elements', () => { const { container } = render(<Enterprise />) - const quaternaryPaths = container.querySelectorAll('path[fill="var(--color-text-quaternary)"]') + const quaternaryPaths = container.querySelectorAll( + 'path[fill="var(--color-text-quaternary)"]', + ) quaternaryPaths.forEach((path) => { expect(path).toHaveAttribute('opacity', '0.18') @@ -173,7 +177,9 @@ describe('Enterprise Icon Component', () => { it('should not have opacity on blue inverted path elements', () => { const { container } = render(<Enterprise />) - const bluePaths = container.querySelectorAll('path[fill="var(--color-saas-dify-blue-inverted)"]') + const bluePaths = container.querySelectorAll( + 'path[fill="var(--color-saas-dify-blue-inverted)"]', + ) bluePaths.forEach((path) => { expect(path).not.toHaveAttribute('opacity') diff --git a/web/app/components/billing/plan/assets/__tests__/index.spec.tsx b/web/app/components/billing/plan/assets/__tests__/index.spec.tsx index 99da2d9ce37028..b9379e57e7d048 100644 --- a/web/app/components/billing/plan/assets/__tests__/index.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/index.spec.tsx @@ -1,6 +1,5 @@ import { render } from '@testing-library/react' import EnterpriseDirect from '../enterprise' - import { Enterprise, Professional, Sandbox, Team } from '../index' import ProfessionalDirect from '../professional' // Import real components for comparison @@ -130,8 +129,12 @@ describe('Billing Plan Assets - Integration Tests', () => { components.forEach((Component) => { const { container } = render(<Component />) - const elementsWithBlue = container.querySelectorAll('[fill="var(--color-saas-dify-blue-inverted)"]') - const elementsWithQuaternary = container.querySelectorAll('[fill="var(--color-text-quaternary)"]') + const elementsWithBlue = container.querySelectorAll( + '[fill="var(--color-saas-dify-blue-inverted)"]', + ) + const elementsWithQuaternary = container.querySelectorAll( + '[fill="var(--color-text-quaternary)"]', + ) expect(elementsWithBlue.length).toBeGreaterThan(0) expect(elementsWithQuaternary.length).toBeGreaterThan(0) @@ -220,10 +223,18 @@ describe('Billing Plan Assets - Integration Tests', () => { <table> <thead> <tr> - <th><Sandbox /></th> - <th><Professional /></th> - <th><Team /></th> - <th><Enterprise /></th> + <th> + <Sandbox /> + </th> + <th> + <Professional /> + </th> + <th> + <Team /> + </th> + <th> + <Enterprise /> + </th> </tr> </thead> </table>, diff --git a/web/app/components/billing/plan/assets/__tests__/professional.spec.tsx b/web/app/components/billing/plan/assets/__tests__/professional.spec.tsx index dcd63711fad9d0..53de2dd15dd42b 100644 --- a/web/app/components/billing/plan/assets/__tests__/professional.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/professional.spec.tsx @@ -35,7 +35,9 @@ describe('Professional Icon Component', () => { it('should render elements with correct fill colors', () => { const { container } = render(<Professional />) - const blueElements = container.querySelectorAll('[fill="var(--color-saas-dify-blue-inverted)"]') + const blueElements = container.querySelectorAll( + '[fill="var(--color-saas-dify-blue-inverted)"]', + ) const quaternaryElements = container.querySelectorAll('[fill="var(--color-text-quaternary)"]') expect(blueElements.length).toBeGreaterThan(0) @@ -120,7 +122,7 @@ describe('Professional Icon Component', () => { it('should use CSS custom properties for colors', () => { const { container } = render(<Professional />) const allFillElements = container.querySelectorAll('[fill]') - const elementsWithCSSVars = Array.from(allFillElements).filter(el => + const elementsWithCSSVars = Array.from(allFillElements).filter((el) => el.getAttribute('fill')?.startsWith('var('), ) @@ -139,7 +141,9 @@ describe('Professional Icon Component', () => { it('should not have opacity on blue inverted elements', () => { const { container } = render(<Professional />) - const blueElements = container.querySelectorAll('[fill="var(--color-saas-dify-blue-inverted)"]') + const blueElements = container.querySelectorAll( + '[fill="var(--color-saas-dify-blue-inverted)"]', + ) blueElements.forEach((element) => { expect(element).not.toHaveAttribute('opacity') diff --git a/web/app/components/billing/plan/assets/__tests__/sandbox.spec.tsx b/web/app/components/billing/plan/assets/__tests__/sandbox.spec.tsx index 7d256b4fc144c3..6daeef2004a703 100644 --- a/web/app/components/billing/plan/assets/__tests__/sandbox.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/sandbox.spec.tsx @@ -38,7 +38,9 @@ describe('Sandbox Icon Component', () => { it('should render elements with correct fill colors', () => { const { container } = render(<Sandbox />) - const blueElements = container.querySelectorAll('[fill="var(--color-saas-dify-blue-inverted)"]') + const blueElements = container.querySelectorAll( + '[fill="var(--color-saas-dify-blue-inverted)"]', + ) const quaternaryElements = container.querySelectorAll('[fill="var(--color-text-quaternary)"]') expect(blueElements.length).toBeGreaterThan(0) @@ -111,7 +113,7 @@ describe('Sandbox Icon Component', () => { it('should use CSS custom properties for colors', () => { const { container } = render(<Sandbox />) const allFillElements = container.querySelectorAll('[fill]') - const elementsWithCSSVars = Array.from(allFillElements).filter(el => + const elementsWithCSSVars = Array.from(allFillElements).filter((el) => el.getAttribute('fill')?.startsWith('var('), ) diff --git a/web/app/components/billing/plan/assets/__tests__/team.spec.tsx b/web/app/components/billing/plan/assets/__tests__/team.spec.tsx index ffd5571a4d2580..4ced322e0e4196 100644 --- a/web/app/components/billing/plan/assets/__tests__/team.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/team.spec.tsx @@ -37,7 +37,9 @@ describe('Team Icon Component', () => { it('should render elements with correct fill colors', () => { const { container } = render(<Team />) - const blueElements = container.querySelectorAll('[fill="var(--color-saas-dify-blue-inverted)"]') + const blueElements = container.querySelectorAll( + '[fill="var(--color-saas-dify-blue-inverted)"]', + ) const quaternaryElements = container.querySelectorAll('[fill="var(--color-text-quaternary)"]') expect(blueElements.length).toBeGreaterThan(0) @@ -134,7 +136,7 @@ describe('Team Icon Component', () => { it('should use CSS custom properties for colors', () => { const { container } = render(<Team />) const allFillElements = container.querySelectorAll('[fill]') - const elementsWithCSSVars = Array.from(allFillElements).filter(el => + const elementsWithCSSVars = Array.from(allFillElements).filter((el) => el.getAttribute('fill')?.startsWith('var('), ) @@ -143,7 +145,9 @@ describe('Team Icon Component', () => { it('should have opacity attributes on quaternary path elements', () => { const { container } = render(<Team />) - const quaternaryPaths = container.querySelectorAll('path[fill="var(--color-text-quaternary)"]') + const quaternaryPaths = container.querySelectorAll( + 'path[fill="var(--color-text-quaternary)"]', + ) quaternaryPaths.forEach((path) => { expect(path).toHaveAttribute('opacity', '0.18') @@ -152,7 +156,9 @@ describe('Team Icon Component', () => { it('should not have opacity on blue inverted elements', () => { const { container } = render(<Team />) - const blueRects = container.querySelectorAll('rect[fill="var(--color-saas-dify-blue-inverted)"]') + const blueRects = container.querySelectorAll( + 'rect[fill="var(--color-saas-dify-blue-inverted)"]', + ) blueRects.forEach((rect) => { expect(rect).not.toHaveAttribute('opacity') diff --git a/web/app/components/billing/plan/assets/enterprise.tsx b/web/app/components/billing/plan/assets/enterprise.tsx index c63d7dd55c70d8..36f01529b75499 100644 --- a/web/app/components/billing/plan/assets/enterprise.tsx +++ b/web/app/components/billing/plan/assets/enterprise.tsx @@ -1,87 +1,373 @@ const Enterprise = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"> - <path d="M1 2C1 1.44772 1.44772 1 2 1C2.55228 1 3 1.44772 3 2C3 2.55228 2.55228 3 2 3C1.44772 3 1 2.55228 1 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M4.5 2C4.5 1.44772 4.94772 1 5.5 1C6.05228 1 6.5 1.44772 6.5 2C6.5 2.55228 6.05228 3 5.5 3C4.94772 3 4.5 2.55228 4.5 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M8 2C8 1.44772 8.44772 1 9 1C9.55228 1 10 1.44772 10 2C10 2.55228 9.55228 3 9 3C8.44772 3 8 2.55228 8 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M11.5 2C11.5 1.44772 11.9477 1 12.5 1C13.0523 1 13.5 1.44772 13.5 2C13.5 2.55228 13.0523 3 12.5 3C11.9477 3 11.5 2.55228 11.5 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M15 2C15 1.44772 15.4477 1 16 1C16.5523 1 17 1.44772 17 2C17 2.55228 16.5523 3 16 3C15.4477 3 15 2.55228 15 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M18.5 2C18.5 1.44772 18.9477 1 19.5 1C20.0523 1 20.5 1.44772 20.5 2C20.5 2.55228 20.0523 3 19.5 3C18.9477 3 18.5 2.55228 18.5 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M22 2C22 1.44772 22.4477 1 23 1C23.5523 1 24 1.44772 24 2C24 2.55228 23.5523 3 23 3C22.4477 3 22 2.55228 22 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M25.5 2C25.5 1.44772 25.9477 1 26.5 1C27.0523 1 27.5 1.44772 27.5 2C27.5 2.55228 27.0523 3 26.5 3C25.9477 3 25.5 2.55228 25.5 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M29 2C29 1.44772 29.4477 1 30 1C30.5523 1 31 1.44772 31 2C31 2.55228 30.5523 3 30 3C29.4477 3 29 2.55228 29 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 5.5C1 4.94772 1.44772 4.5 2 4.5C2.55228 4.5 3 4.94772 3 5.5C3 6.05228 2.55228 6.5 2 6.5C1.44772 6.5 1 6.05228 1 5.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M4.5 5.5C4.5 4.94772 4.94772 4.5 5.5 4.5C6.05228 4.5 6.5 4.94772 6.5 5.5C6.5 6.05228 6.05228 6.5 5.5 6.5C4.94772 6.5 4.5 6.05228 4.5 5.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 5.5C8 4.94772 8.44772 4.5 9 4.5C9.55228 4.5 10 4.94772 10 5.5C10 6.05228 9.55228 6.5 9 6.5C8.44772 6.5 8 6.05228 8 5.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M11.5 5.5C11.5 4.94772 11.9477 4.5 12.5 4.5C13.0523 4.5 13.5 4.94772 13.5 5.5C13.5 6.05228 13.0523 6.5 12.5 6.5C11.9477 6.5 11.5 6.05228 11.5 5.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M15 5.5C15 4.94772 15.4477 4.5 16 4.5C16.5523 4.5 17 4.94772 17 5.5C17 6.05228 16.5523 6.5 16 6.5C15.4477 6.5 15 6.05228 15 5.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M18.5 5.5C18.5 4.94772 18.9477 4.5 19.5 4.5C20.0523 4.5 20.5 4.94772 20.5 5.5C20.5 6.05228 20.0523 6.5 19.5 6.5C18.9477 6.5 18.5 6.05228 18.5 5.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M22 5.5C22 4.94772 22.4477 4.5 23 4.5C23.5523 4.5 24 4.94772 24 5.5C24 6.05228 23.5523 6.5 23 6.5C22.4477 6.5 22 6.05228 22 5.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M25.5 5.5C25.5 4.94772 25.9477 4.5 26.5 4.5C27.0523 4.5 27.5 4.94772 27.5 5.5C27.5 6.05228 27.0523 6.5 26.5 6.5C25.9477 6.5 25.5 6.05228 25.5 5.5Z" fill="var(--color-text-quaternary)" /> - <path d="M29 5.5C29 4.94772 29.4477 4.5 30 4.5C30.5523 4.5 31 4.94772 31 5.5C31 6.05228 30.5523 6.5 30 6.5C29.4477 6.5 29 6.05228 29 5.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 9C1 8.44772 1.44772 8 2 8C2.55228 8 3 8.44772 3 9C3 9.55228 2.55228 10 2 10C1.44772 10 1 9.55228 1 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M4.5 9C4.5 8.44772 4.94772 8 5.5 8C6.05228 8 6.5 8.44772 6.5 9C6.5 9.55228 6.05228 10 5.5 10C4.94772 10 4.5 9.55228 4.5 9Z" fill="var(--color-text-quaternary)" /> - <path d="M8 9C8 8.44772 8.44772 8 9 8C9.55228 8 10 8.44772 10 9C10 9.55228 9.55228 10 9 10C8.44772 10 8 9.55228 8 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M11.5 9C11.5 8.44772 11.9477 8 12.5 8C13.0523 8 13.5 8.44772 13.5 9C13.5 9.55228 13.0523 10 12.5 10C11.9477 10 11.5 9.55228 11.5 9Z" fill="var(--color-text-quaternary)" /> - <path d="M15 9C15 8.44772 15.4477 8 16 8C16.5523 8 17 8.44772 17 9C17 9.55228 16.5523 10 16 10C15.4477 10 15 9.55228 15 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M18.5 9C18.5 8.44772 18.9477 8 19.5 8C20.0523 8 20.5 8.44772 20.5 9C20.5 9.55228 20.0523 10 19.5 10C18.9477 10 18.5 9.55228 18.5 9Z" fill="var(--color-text-quaternary)" /> - <path d="M22 9C22 8.44772 22.4477 8 23 8C23.5523 8 24 8.44772 24 9C24 9.55228 23.5523 10 23 10C22.4477 10 22 9.55228 22 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M25.5 9C25.5 8.44772 25.9477 8 26.5 8C27.0523 8 27.5 8.44772 27.5 9C27.5 9.55228 27.0523 10 26.5 10C25.9477 10 25.5 9.55228 25.5 9Z" fill="var(--color-text-quaternary)" /> - <path d="M29 9C29 8.44772 29.4477 8 30 8C30.5523 8 31 8.44772 31 9C31 9.55228 30.5523 10 30 10C29.4477 10 29 9.55228 29 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 12.5C1 11.9477 1.44772 11.5 2 11.5C2.55228 11.5 3 11.9477 3 12.5C3 13.0523 2.55228 13.5 2 13.5C1.44772 13.5 1 13.0523 1 12.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M4.5 12.5C4.5 11.9477 4.94772 11.5 5.5 11.5C6.05228 11.5 6.5 11.9477 6.5 12.5C6.5 13.0523 6.05228 13.5 5.5 13.5C4.94772 13.5 4.5 13.0523 4.5 12.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 12.5C8 11.9477 8.44772 11.5 9 11.5C9.55228 11.5 10 11.9477 10 12.5C10 13.0523 9.55228 13.5 9 13.5C8.44772 13.5 8 13.0523 8 12.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M11.5 12.5C11.5 11.9477 11.9477 11.5 12.5 11.5C13.0523 11.5 13.5 11.9477 13.5 12.5C13.5 13.0523 13.0523 13.5 12.5 13.5C11.9477 13.5 11.5 13.0523 11.5 12.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M15 12.5C15 11.9477 15.4477 11.5 16 11.5C16.5523 11.5 17 11.9477 17 12.5C17 13.0523 16.5523 13.5 16 13.5C15.4477 13.5 15 13.0523 15 12.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M18.5 12.5C18.5 11.9477 18.9477 11.5 19.5 11.5C20.0523 11.5 20.5 11.9477 20.5 12.5C20.5 13.0523 20.0523 13.5 19.5 13.5C18.9477 13.5 18.5 13.0523 18.5 12.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M22 12.5C22 11.9477 22.4477 11.5 23 11.5C23.5523 11.5 24 11.9477 24 12.5C24 13.0523 23.5523 13.5 23 13.5C22.4477 13.5 22 13.0523 22 12.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M25.5 12.5C25.5 11.9477 25.9477 11.5 26.5 11.5C27.0523 11.5 27.5 11.9477 27.5 12.5C27.5 13.0523 27.0523 13.5 26.5 13.5C25.9477 13.5 25.5 13.0523 25.5 12.5Z" fill="var(--color-text-quaternary)" /> - <path d="M29 12.5C29 11.9477 29.4477 11.5 30 11.5C30.5523 11.5 31 11.9477 31 12.5C31 13.0523 30.5523 13.5 30 13.5C29.4477 13.5 29 13.0523 29 12.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 16C1 15.4477 1.44772 15 2 15C2.55228 15 3 15.4477 3 16C3 16.5523 2.55228 17 2 17C1.44772 17 1 16.5523 1 16Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M4.5 16C4.5 15.4477 4.94772 15 5.5 15C6.05228 15 6.5 15.4477 6.5 16C6.5 16.5523 6.05228 17 5.5 17C4.94772 17 4.5 16.5523 4.5 16Z" fill="var(--color-text-quaternary)" /> - <path d="M8 16C8 15.4477 8.44772 15 9 15C9.55228 15 10 15.4477 10 16C10 16.5523 9.55228 17 9 17C8.44772 17 8 16.5523 8 16Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M11.5 16C11.5 15.4477 11.9477 15 12.5 15C13.0523 15 13.5 15.4477 13.5 16C13.5 16.5523 13.0523 17 12.5 17C11.9477 17 11.5 16.5523 11.5 16Z" fill="var(--color-text-quaternary)" /> - <path d="M15 16C15 15.4477 15.4477 15 16 15C16.5523 15 17 15.4477 17 16C17 16.5523 16.5523 17 16 17C15.4477 17 15 16.5523 15 16Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M18.5 16C18.5 15.4477 18.9477 15 19.5 15C20.0523 15 20.5 15.4477 20.5 16C20.5 16.5523 20.0523 17 19.5 17C18.9477 17 18.5 16.5523 18.5 16Z" fill="var(--color-text-quaternary)" /> - <path d="M22 16C22 15.4477 22.4477 15 23 15C23.5523 15 24 15.4477 24 16C24 16.5523 23.5523 17 23 17C22.4477 17 22 16.5523 22 16Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M25.5 16C25.5 15.4477 25.9477 15 26.5 15C27.0523 15 27.5 15.4477 27.5 16C27.5 16.5523 27.0523 17 26.5 17C25.9477 17 25.5 16.5523 25.5 16Z" fill="var(--color-text-quaternary)" /> - <path d="M29 16C29 15.4477 29.4477 15 30 15C30.5523 15 31 15.4477 31 16C31 16.5523 30.5523 17 30 17C29.4477 17 29 16.5523 29 16Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 19.5C1 18.9477 1.44772 18.5 2 18.5C2.55228 18.5 3 18.9477 3 19.5C3 20.0523 2.55228 20.5 2 20.5C1.44772 20.5 1 20.0523 1 19.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M4.5 19.5C4.5 18.9477 4.94772 18.5 5.5 18.5C6.05228 18.5 6.5 18.9477 6.5 19.5C6.5 20.0523 6.05228 20.5 5.5 20.5C4.94772 20.5 4.5 20.0523 4.5 19.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 19.5C8 18.9477 8.44772 18.5 9 18.5C9.55228 18.5 10 18.9477 10 19.5C10 20.0523 9.55228 20.5 9 20.5C8.44772 20.5 8 20.0523 8 19.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M11.5 19.5C11.5 18.9477 11.9477 18.5 12.5 18.5C13.0523 18.5 13.5 18.9477 13.5 19.5C13.5 20.0523 13.0523 20.5 12.5 20.5C11.9477 20.5 11.5 20.0523 11.5 19.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M15 19.5C15 18.9477 15.4477 18.5 16 18.5C16.5523 18.5 17 18.9477 17 19.5C17 20.0523 16.5523 20.5 16 20.5C15.4477 20.5 15 20.0523 15 19.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M18.5 19.5C18.5 18.9477 18.9477 18.5 19.5 18.5C20.0523 18.5 20.5 18.9477 20.5 19.5C20.5 20.0523 20.0523 20.5 19.5 20.5C18.9477 20.5 18.5 20.0523 18.5 19.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M22 19.5C22 18.9477 22.4477 18.5 23 18.5C23.5523 18.5 24 18.9477 24 19.5C24 20.0523 23.5523 20.5 23 20.5C22.4477 20.5 22 20.0523 22 19.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M25.5 19.5C25.5 18.9477 25.9477 18.5 26.5 18.5C27.0523 18.5 27.5 18.9477 27.5 19.5C27.5 20.0523 27.0523 20.5 26.5 20.5C25.9477 20.5 25.5 20.0523 25.5 19.5Z" fill="var(--color-text-quaternary)" /> - <path d="M29 19.5C29 18.9477 29.4477 18.5 30 18.5C30.5523 18.5 31 18.9477 31 19.5C31 20.0523 30.5523 20.5 30 20.5C29.4477 20.5 29 20.0523 29 19.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 23C1 22.4477 1.44772 22 2 22C2.55228 22 3 22.4477 3 23C3 23.5523 2.55228 24 2 24C1.44772 24 1 23.5523 1 23Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M4.5 23C4.5 22.4477 4.94772 22 5.5 22C6.05228 22 6.5 22.4477 6.5 23C6.5 23.5523 6.05228 24 5.5 24C4.94772 24 4.5 23.5523 4.5 23Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 23C8 22.4477 8.44772 22 9 22C9.55228 22 10 22.4477 10 23C10 23.5523 9.55228 24 9 24C8.44772 24 8 23.5523 8 23Z" fill="var(--color-text-quaternary)" /> - <path d="M11.5 23C11.5 22.4477 11.9477 22 12.5 22C13.0523 22 13.5 22.4477 13.5 23C13.5 23.5523 13.0523 24 12.5 24C11.9477 24 11.5 23.5523 11.5 23Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M15 23C15 22.4477 15.4477 22 16 22C16.5523 22 17 22.4477 17 23C17 23.5523 16.5523 24 16 24C15.4477 24 15 23.5523 15 23Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M18.5 23C18.5 22.4477 18.9477 22 19.5 22C20.0523 22 20.5 22.4477 20.5 23C20.5 23.5523 20.0523 24 19.5 24C18.9477 24 18.5 23.5523 18.5 23Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M22 23C22 22.4477 22.4477 22 23 22C23.5523 22 24 22.4477 24 23C24 23.5523 23.5523 24 23 24C22.4477 24 22 23.5523 22 23Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M25.5 23C25.5 22.4477 25.9477 22 26.5 22C27.0523 22 27.5 22.4477 27.5 23C27.5 23.5523 27.0523 24 26.5 24C25.9477 24 25.5 23.5523 25.5 23Z" fill="var(--color-text-quaternary)" /> - <path d="M29 23C29 22.4477 29.4477 22 30 22C30.5523 22 31 22.4477 31 23C31 23.5523 30.5523 24 30 24C29.4477 24 29 23.5523 29 23Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 26.5C1 25.9477 1.44772 25.5 2 25.5C2.55228 25.5 3 25.9477 3 26.5C3 27.0523 2.55228 27.5 2 27.5C1.44772 27.5 1 27.0523 1 26.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M4.5 26.5C4.5 25.9477 4.94772 25.5 5.5 25.5C6.05228 25.5 6.5 25.9477 6.5 26.5C6.5 27.0523 6.05228 27.5 5.5 27.5C4.94772 27.5 4.5 27.0523 4.5 26.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 26.5C8 25.9477 8.44772 25.5 9 25.5C9.55228 25.5 10 25.9477 10 26.5C10 27.0523 9.55228 27.5 9 27.5C8.44772 27.5 8 27.0523 8 26.5Z" fill="var(--color-text-quaternary)" /> - <path d="M11.5 26.5C11.5 25.9477 11.9477 25.5 12.5 25.5C13.0523 25.5 13.5 25.9477 13.5 26.5C13.5 27.0523 13.0523 27.5 12.5 27.5C11.9477 27.5 11.5 27.0523 11.5 26.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M15 26.5C15 25.9477 15.4477 25.5 16 25.5C16.5523 25.5 17 25.9477 17 26.5C17 27.0523 16.5523 27.5 16 27.5C15.4477 27.5 15 27.0523 15 26.5Z" fill="var(--color-text-quaternary)" /> - <path d="M18.5 26.5C18.5 25.9477 18.9477 25.5 19.5 25.5C20.0523 25.5 20.5 25.9477 20.5 26.5C20.5 27.0523 20.0523 27.5 19.5 27.5C18.9477 27.5 18.5 27.0523 18.5 26.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M22 26.5C22 25.9477 22.4477 25.5 23 25.5C23.5523 25.5 24 25.9477 24 26.5C24 27.0523 23.5523 27.5 23 27.5C22.4477 27.5 22 27.0523 22 26.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M25.5 26.5C25.5 25.9477 25.9477 25.5 26.5 25.5C27.0523 25.5 27.5 25.9477 27.5 26.5C27.5 27.0523 27.0523 27.5 26.5 27.5C25.9477 27.5 25.5 27.0523 25.5 26.5Z" fill="var(--color-text-quaternary)" /> - <path d="M29 26.5C29 25.9477 29.4477 25.5 30 25.5C30.5523 25.5 31 25.9477 31 26.5C31 27.0523 30.5523 27.5 30 27.5C29.4477 27.5 29 27.0523 29 26.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 30C1 29.4477 1.44772 29 2 29C2.55228 29 3 29.4477 3 30C3 30.5523 2.55228 31 2 31C1.44772 31 1 30.5523 1 30Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M4.5 30C4.5 29.4477 4.94772 29 5.5 29C6.05228 29 6.5 29.4477 6.5 30C6.5 30.5523 6.05228 31 5.5 31C4.94772 31 4.5 30.5523 4.5 30Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 30C8 29.4477 8.44772 29 9 29C9.55228 29 10 29.4477 10 30C10 30.5523 9.55228 31 9 31C8.44772 31 8 30.5523 8 30Z" fill="var(--color-text-quaternary)" /> - <path d="M11.5 30C11.5 29.4477 11.9477 29 12.5 29C13.0523 29 13.5 29.4477 13.5 30C13.5 30.5523 13.0523 31 12.5 31C11.9477 31 11.5 30.5523 11.5 30Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M15 30C15 29.4477 15.4477 29 16 29C16.5523 29 17 29.4477 17 30C17 30.5523 16.5523 31 16 31C15.4477 31 15 30.5523 15 30Z" fill="var(--color-text-quaternary)" /> - <path d="M18.5 30C18.5 29.4477 18.9477 29 19.5 29C20.0523 29 20.5 29.4477 20.5 30C20.5 30.5523 20.0523 31 19.5 31C18.9477 31 18.5 30.5523 18.5 30Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M22 30C22 29.4477 22.4477 29 23 29C23.5523 29 24 29.4477 24 30C24 30.5523 23.5523 31 23 31C22.4477 31 22 30.5523 22 30Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M25.5 30C25.5 29.4477 25.9477 29 26.5 29C27.0523 29 27.5 29.4477 27.5 30C27.5 30.5523 27.0523 31 26.5 31C25.9477 31 25.5 30.5523 25.5 30Z" fill="var(--color-text-quaternary)" /> - <path d="M29 30C29 29.4477 29.4477 29 30 29C30.5523 29 31 29.4477 31 30C31 30.5523 30.5523 31 30 31C29.4477 31 29 30.5523 29 30Z" fill="var(--color-saas-dify-blue-inverted)" /> + <path + d="M1 2C1 1.44772 1.44772 1 2 1C2.55228 1 3 1.44772 3 2C3 2.55228 2.55228 3 2 3C1.44772 3 1 2.55228 1 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M4.5 2C4.5 1.44772 4.94772 1 5.5 1C6.05228 1 6.5 1.44772 6.5 2C6.5 2.55228 6.05228 3 5.5 3C4.94772 3 4.5 2.55228 4.5 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M8 2C8 1.44772 8.44772 1 9 1C9.55228 1 10 1.44772 10 2C10 2.55228 9.55228 3 9 3C8.44772 3 8 2.55228 8 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M11.5 2C11.5 1.44772 11.9477 1 12.5 1C13.0523 1 13.5 1.44772 13.5 2C13.5 2.55228 13.0523 3 12.5 3C11.9477 3 11.5 2.55228 11.5 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M15 2C15 1.44772 15.4477 1 16 1C16.5523 1 17 1.44772 17 2C17 2.55228 16.5523 3 16 3C15.4477 3 15 2.55228 15 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M18.5 2C18.5 1.44772 18.9477 1 19.5 1C20.0523 1 20.5 1.44772 20.5 2C20.5 2.55228 20.0523 3 19.5 3C18.9477 3 18.5 2.55228 18.5 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M22 2C22 1.44772 22.4477 1 23 1C23.5523 1 24 1.44772 24 2C24 2.55228 23.5523 3 23 3C22.4477 3 22 2.55228 22 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M25.5 2C25.5 1.44772 25.9477 1 26.5 1C27.0523 1 27.5 1.44772 27.5 2C27.5 2.55228 27.0523 3 26.5 3C25.9477 3 25.5 2.55228 25.5 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M29 2C29 1.44772 29.4477 1 30 1C30.5523 1 31 1.44772 31 2C31 2.55228 30.5523 3 30 3C29.4477 3 29 2.55228 29 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 5.5C1 4.94772 1.44772 4.5 2 4.5C2.55228 4.5 3 4.94772 3 5.5C3 6.05228 2.55228 6.5 2 6.5C1.44772 6.5 1 6.05228 1 5.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M4.5 5.5C4.5 4.94772 4.94772 4.5 5.5 4.5C6.05228 4.5 6.5 4.94772 6.5 5.5C6.5 6.05228 6.05228 6.5 5.5 6.5C4.94772 6.5 4.5 6.05228 4.5 5.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 5.5C8 4.94772 8.44772 4.5 9 4.5C9.55228 4.5 10 4.94772 10 5.5C10 6.05228 9.55228 6.5 9 6.5C8.44772 6.5 8 6.05228 8 5.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M11.5 5.5C11.5 4.94772 11.9477 4.5 12.5 4.5C13.0523 4.5 13.5 4.94772 13.5 5.5C13.5 6.05228 13.0523 6.5 12.5 6.5C11.9477 6.5 11.5 6.05228 11.5 5.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M15 5.5C15 4.94772 15.4477 4.5 16 4.5C16.5523 4.5 17 4.94772 17 5.5C17 6.05228 16.5523 6.5 16 6.5C15.4477 6.5 15 6.05228 15 5.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M18.5 5.5C18.5 4.94772 18.9477 4.5 19.5 4.5C20.0523 4.5 20.5 4.94772 20.5 5.5C20.5 6.05228 20.0523 6.5 19.5 6.5C18.9477 6.5 18.5 6.05228 18.5 5.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M22 5.5C22 4.94772 22.4477 4.5 23 4.5C23.5523 4.5 24 4.94772 24 5.5C24 6.05228 23.5523 6.5 23 6.5C22.4477 6.5 22 6.05228 22 5.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M25.5 5.5C25.5 4.94772 25.9477 4.5 26.5 4.5C27.0523 4.5 27.5 4.94772 27.5 5.5C27.5 6.05228 27.0523 6.5 26.5 6.5C25.9477 6.5 25.5 6.05228 25.5 5.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 5.5C29 4.94772 29.4477 4.5 30 4.5C30.5523 4.5 31 4.94772 31 5.5C31 6.05228 30.5523 6.5 30 6.5C29.4477 6.5 29 6.05228 29 5.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 9C1 8.44772 1.44772 8 2 8C2.55228 8 3 8.44772 3 9C3 9.55228 2.55228 10 2 10C1.44772 10 1 9.55228 1 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M4.5 9C4.5 8.44772 4.94772 8 5.5 8C6.05228 8 6.5 8.44772 6.5 9C6.5 9.55228 6.05228 10 5.5 10C4.94772 10 4.5 9.55228 4.5 9Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M8 9C8 8.44772 8.44772 8 9 8C9.55228 8 10 8.44772 10 9C10 9.55228 9.55228 10 9 10C8.44772 10 8 9.55228 8 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M11.5 9C11.5 8.44772 11.9477 8 12.5 8C13.0523 8 13.5 8.44772 13.5 9C13.5 9.55228 13.0523 10 12.5 10C11.9477 10 11.5 9.55228 11.5 9Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M15 9C15 8.44772 15.4477 8 16 8C16.5523 8 17 8.44772 17 9C17 9.55228 16.5523 10 16 10C15.4477 10 15 9.55228 15 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M18.5 9C18.5 8.44772 18.9477 8 19.5 8C20.0523 8 20.5 8.44772 20.5 9C20.5 9.55228 20.0523 10 19.5 10C18.9477 10 18.5 9.55228 18.5 9Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M22 9C22 8.44772 22.4477 8 23 8C23.5523 8 24 8.44772 24 9C24 9.55228 23.5523 10 23 10C22.4477 10 22 9.55228 22 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M25.5 9C25.5 8.44772 25.9477 8 26.5 8C27.0523 8 27.5 8.44772 27.5 9C27.5 9.55228 27.0523 10 26.5 10C25.9477 10 25.5 9.55228 25.5 9Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 9C29 8.44772 29.4477 8 30 8C30.5523 8 31 8.44772 31 9C31 9.55228 30.5523 10 30 10C29.4477 10 29 9.55228 29 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 12.5C1 11.9477 1.44772 11.5 2 11.5C2.55228 11.5 3 11.9477 3 12.5C3 13.0523 2.55228 13.5 2 13.5C1.44772 13.5 1 13.0523 1 12.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M4.5 12.5C4.5 11.9477 4.94772 11.5 5.5 11.5C6.05228 11.5 6.5 11.9477 6.5 12.5C6.5 13.0523 6.05228 13.5 5.5 13.5C4.94772 13.5 4.5 13.0523 4.5 12.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 12.5C8 11.9477 8.44772 11.5 9 11.5C9.55228 11.5 10 11.9477 10 12.5C10 13.0523 9.55228 13.5 9 13.5C8.44772 13.5 8 13.0523 8 12.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M11.5 12.5C11.5 11.9477 11.9477 11.5 12.5 11.5C13.0523 11.5 13.5 11.9477 13.5 12.5C13.5 13.0523 13.0523 13.5 12.5 13.5C11.9477 13.5 11.5 13.0523 11.5 12.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M15 12.5C15 11.9477 15.4477 11.5 16 11.5C16.5523 11.5 17 11.9477 17 12.5C17 13.0523 16.5523 13.5 16 13.5C15.4477 13.5 15 13.0523 15 12.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M18.5 12.5C18.5 11.9477 18.9477 11.5 19.5 11.5C20.0523 11.5 20.5 11.9477 20.5 12.5C20.5 13.0523 20.0523 13.5 19.5 13.5C18.9477 13.5 18.5 13.0523 18.5 12.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M22 12.5C22 11.9477 22.4477 11.5 23 11.5C23.5523 11.5 24 11.9477 24 12.5C24 13.0523 23.5523 13.5 23 13.5C22.4477 13.5 22 13.0523 22 12.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M25.5 12.5C25.5 11.9477 25.9477 11.5 26.5 11.5C27.0523 11.5 27.5 11.9477 27.5 12.5C27.5 13.0523 27.0523 13.5 26.5 13.5C25.9477 13.5 25.5 13.0523 25.5 12.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 12.5C29 11.9477 29.4477 11.5 30 11.5C30.5523 11.5 31 11.9477 31 12.5C31 13.0523 30.5523 13.5 30 13.5C29.4477 13.5 29 13.0523 29 12.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 16C1 15.4477 1.44772 15 2 15C2.55228 15 3 15.4477 3 16C3 16.5523 2.55228 17 2 17C1.44772 17 1 16.5523 1 16Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M4.5 16C4.5 15.4477 4.94772 15 5.5 15C6.05228 15 6.5 15.4477 6.5 16C6.5 16.5523 6.05228 17 5.5 17C4.94772 17 4.5 16.5523 4.5 16Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M8 16C8 15.4477 8.44772 15 9 15C9.55228 15 10 15.4477 10 16C10 16.5523 9.55228 17 9 17C8.44772 17 8 16.5523 8 16Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M11.5 16C11.5 15.4477 11.9477 15 12.5 15C13.0523 15 13.5 15.4477 13.5 16C13.5 16.5523 13.0523 17 12.5 17C11.9477 17 11.5 16.5523 11.5 16Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M15 16C15 15.4477 15.4477 15 16 15C16.5523 15 17 15.4477 17 16C17 16.5523 16.5523 17 16 17C15.4477 17 15 16.5523 15 16Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M18.5 16C18.5 15.4477 18.9477 15 19.5 15C20.0523 15 20.5 15.4477 20.5 16C20.5 16.5523 20.0523 17 19.5 17C18.9477 17 18.5 16.5523 18.5 16Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M22 16C22 15.4477 22.4477 15 23 15C23.5523 15 24 15.4477 24 16C24 16.5523 23.5523 17 23 17C22.4477 17 22 16.5523 22 16Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M25.5 16C25.5 15.4477 25.9477 15 26.5 15C27.0523 15 27.5 15.4477 27.5 16C27.5 16.5523 27.0523 17 26.5 17C25.9477 17 25.5 16.5523 25.5 16Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 16C29 15.4477 29.4477 15 30 15C30.5523 15 31 15.4477 31 16C31 16.5523 30.5523 17 30 17C29.4477 17 29 16.5523 29 16Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 19.5C1 18.9477 1.44772 18.5 2 18.5C2.55228 18.5 3 18.9477 3 19.5C3 20.0523 2.55228 20.5 2 20.5C1.44772 20.5 1 20.0523 1 19.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M4.5 19.5C4.5 18.9477 4.94772 18.5 5.5 18.5C6.05228 18.5 6.5 18.9477 6.5 19.5C6.5 20.0523 6.05228 20.5 5.5 20.5C4.94772 20.5 4.5 20.0523 4.5 19.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 19.5C8 18.9477 8.44772 18.5 9 18.5C9.55228 18.5 10 18.9477 10 19.5C10 20.0523 9.55228 20.5 9 20.5C8.44772 20.5 8 20.0523 8 19.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M11.5 19.5C11.5 18.9477 11.9477 18.5 12.5 18.5C13.0523 18.5 13.5 18.9477 13.5 19.5C13.5 20.0523 13.0523 20.5 12.5 20.5C11.9477 20.5 11.5 20.0523 11.5 19.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M15 19.5C15 18.9477 15.4477 18.5 16 18.5C16.5523 18.5 17 18.9477 17 19.5C17 20.0523 16.5523 20.5 16 20.5C15.4477 20.5 15 20.0523 15 19.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M18.5 19.5C18.5 18.9477 18.9477 18.5 19.5 18.5C20.0523 18.5 20.5 18.9477 20.5 19.5C20.5 20.0523 20.0523 20.5 19.5 20.5C18.9477 20.5 18.5 20.0523 18.5 19.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M22 19.5C22 18.9477 22.4477 18.5 23 18.5C23.5523 18.5 24 18.9477 24 19.5C24 20.0523 23.5523 20.5 23 20.5C22.4477 20.5 22 20.0523 22 19.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M25.5 19.5C25.5 18.9477 25.9477 18.5 26.5 18.5C27.0523 18.5 27.5 18.9477 27.5 19.5C27.5 20.0523 27.0523 20.5 26.5 20.5C25.9477 20.5 25.5 20.0523 25.5 19.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 19.5C29 18.9477 29.4477 18.5 30 18.5C30.5523 18.5 31 18.9477 31 19.5C31 20.0523 30.5523 20.5 30 20.5C29.4477 20.5 29 20.0523 29 19.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 23C1 22.4477 1.44772 22 2 22C2.55228 22 3 22.4477 3 23C3 23.5523 2.55228 24 2 24C1.44772 24 1 23.5523 1 23Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M4.5 23C4.5 22.4477 4.94772 22 5.5 22C6.05228 22 6.5 22.4477 6.5 23C6.5 23.5523 6.05228 24 5.5 24C4.94772 24 4.5 23.5523 4.5 23Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 23C8 22.4477 8.44772 22 9 22C9.55228 22 10 22.4477 10 23C10 23.5523 9.55228 24 9 24C8.44772 24 8 23.5523 8 23Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M11.5 23C11.5 22.4477 11.9477 22 12.5 22C13.0523 22 13.5 22.4477 13.5 23C13.5 23.5523 13.0523 24 12.5 24C11.9477 24 11.5 23.5523 11.5 23Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M15 23C15 22.4477 15.4477 22 16 22C16.5523 22 17 22.4477 17 23C17 23.5523 16.5523 24 16 24C15.4477 24 15 23.5523 15 23Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M18.5 23C18.5 22.4477 18.9477 22 19.5 22C20.0523 22 20.5 22.4477 20.5 23C20.5 23.5523 20.0523 24 19.5 24C18.9477 24 18.5 23.5523 18.5 23Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M22 23C22 22.4477 22.4477 22 23 22C23.5523 22 24 22.4477 24 23C24 23.5523 23.5523 24 23 24C22.4477 24 22 23.5523 22 23Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M25.5 23C25.5 22.4477 25.9477 22 26.5 22C27.0523 22 27.5 22.4477 27.5 23C27.5 23.5523 27.0523 24 26.5 24C25.9477 24 25.5 23.5523 25.5 23Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 23C29 22.4477 29.4477 22 30 22C30.5523 22 31 22.4477 31 23C31 23.5523 30.5523 24 30 24C29.4477 24 29 23.5523 29 23Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 26.5C1 25.9477 1.44772 25.5 2 25.5C2.55228 25.5 3 25.9477 3 26.5C3 27.0523 2.55228 27.5 2 27.5C1.44772 27.5 1 27.0523 1 26.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M4.5 26.5C4.5 25.9477 4.94772 25.5 5.5 25.5C6.05228 25.5 6.5 25.9477 6.5 26.5C6.5 27.0523 6.05228 27.5 5.5 27.5C4.94772 27.5 4.5 27.0523 4.5 26.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 26.5C8 25.9477 8.44772 25.5 9 25.5C9.55228 25.5 10 25.9477 10 26.5C10 27.0523 9.55228 27.5 9 27.5C8.44772 27.5 8 27.0523 8 26.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M11.5 26.5C11.5 25.9477 11.9477 25.5 12.5 25.5C13.0523 25.5 13.5 25.9477 13.5 26.5C13.5 27.0523 13.0523 27.5 12.5 27.5C11.9477 27.5 11.5 27.0523 11.5 26.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M15 26.5C15 25.9477 15.4477 25.5 16 25.5C16.5523 25.5 17 25.9477 17 26.5C17 27.0523 16.5523 27.5 16 27.5C15.4477 27.5 15 27.0523 15 26.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M18.5 26.5C18.5 25.9477 18.9477 25.5 19.5 25.5C20.0523 25.5 20.5 25.9477 20.5 26.5C20.5 27.0523 20.0523 27.5 19.5 27.5C18.9477 27.5 18.5 27.0523 18.5 26.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M22 26.5C22 25.9477 22.4477 25.5 23 25.5C23.5523 25.5 24 25.9477 24 26.5C24 27.0523 23.5523 27.5 23 27.5C22.4477 27.5 22 27.0523 22 26.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M25.5 26.5C25.5 25.9477 25.9477 25.5 26.5 25.5C27.0523 25.5 27.5 25.9477 27.5 26.5C27.5 27.0523 27.0523 27.5 26.5 27.5C25.9477 27.5 25.5 27.0523 25.5 26.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 26.5C29 25.9477 29.4477 25.5 30 25.5C30.5523 25.5 31 25.9477 31 26.5C31 27.0523 30.5523 27.5 30 27.5C29.4477 27.5 29 27.0523 29 26.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 30C1 29.4477 1.44772 29 2 29C2.55228 29 3 29.4477 3 30C3 30.5523 2.55228 31 2 31C1.44772 31 1 30.5523 1 30Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M4.5 30C4.5 29.4477 4.94772 29 5.5 29C6.05228 29 6.5 29.4477 6.5 30C6.5 30.5523 6.05228 31 5.5 31C4.94772 31 4.5 30.5523 4.5 30Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 30C8 29.4477 8.44772 29 9 29C9.55228 29 10 29.4477 10 30C10 30.5523 9.55228 31 9 31C8.44772 31 8 30.5523 8 30Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M11.5 30C11.5 29.4477 11.9477 29 12.5 29C13.0523 29 13.5 29.4477 13.5 30C13.5 30.5523 13.0523 31 12.5 31C11.9477 31 11.5 30.5523 11.5 30Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M15 30C15 29.4477 15.4477 29 16 29C16.5523 29 17 29.4477 17 30C17 30.5523 16.5523 31 16 31C15.4477 31 15 30.5523 15 30Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M18.5 30C18.5 29.4477 18.9477 29 19.5 29C20.0523 29 20.5 29.4477 20.5 30C20.5 30.5523 20.0523 31 19.5 31C18.9477 31 18.5 30.5523 18.5 30Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M22 30C22 29.4477 22.4477 29 23 29C23.5523 29 24 29.4477 24 30C24 30.5523 23.5523 31 23 31C22.4477 31 22 30.5523 22 30Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M25.5 30C25.5 29.4477 25.9477 29 26.5 29C27.0523 29 27.5 29.4477 27.5 30C27.5 30.5523 27.0523 31 26.5 31C25.9477 31 25.5 30.5523 25.5 30Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 30C29 29.4477 29.4477 29 30 29C30.5523 29 31 29.4477 31 30C31 30.5523 30.5523 31 30 31C29.4477 31 29 30.5523 29 30Z" + fill="var(--color-saas-dify-blue-inverted)" + /> </svg> ) } diff --git a/web/app/components/billing/plan/assets/professional.tsx b/web/app/components/billing/plan/assets/professional.tsx index 8cd9744bca890d..405da01d24afa8 100644 --- a/web/app/components/billing/plan/assets/professional.tsx +++ b/web/app/components/billing/plan/assets/professional.tsx @@ -1,86 +1,668 @@ const Professional = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"> - <rect opacity="0.18" x="4.5" y="1" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="8" y="1" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect x="11.5" y="1" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> + <rect + opacity="0.18" + x="4.5" + y="1" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="8" + y="1" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + x="11.5" + y="1" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> <rect x="15" y="1" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect x="18.5" y="1" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="22" y="1" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="25.5" y="1" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="29" y="1" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="1" y="4.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect x="4.5" y="4.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> + <rect + x="18.5" + y="1" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="22" + y="1" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="25.5" + y="1" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="29" + y="1" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="1" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + x="4.5" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> <rect x="8" y="4.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="11.5" y="4.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="4.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="4.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect x="22" y="4.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect x="25.5" y="4.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="29" y="4.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="1" y="8" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="8" y="8" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="11.5" y="8" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="8" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="8" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="22" y="8" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect x="25.5" y="8" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="29" y="8" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect x="1" y="11.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="8" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="11.5" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="22" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="25.5" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="4.5" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="8" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="11.5" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="22" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="25.5" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + x="11.5" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + x="22" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + x="25.5" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="29" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="1" + y="8" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="8" + y="8" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="11.5" + y="8" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="8" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="8" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="22" + y="8" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + x="25.5" + y="8" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="29" + y="8" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + x="1" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="8" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="11.5" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="22" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="25.5" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="4.5" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="8" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="11.5" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="22" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="25.5" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> <rect x="29" y="15" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect x="1" y="18.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="4.5" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="8" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="11.5" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="22" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="25.5" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect x="29" y="18.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="1" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect x="4.5" y="22" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="8" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="11.5" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="22" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect x="25.5" y="22" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="29" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="1" y="25.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect x="4.5" y="25.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect x="8" y="25.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="11.5" y="25.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="25.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="25.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect x="22" y="25.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect x="25.5" y="25.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="29" y="25.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="1" y="29" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="4.5" y="29" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="8" y="29" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect x="11.5" y="29" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> + <rect + x="1" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="4.5" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="8" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="11.5" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="22" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="25.5" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + x="29" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="1" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + x="4.5" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="8" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="11.5" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="22" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + x="25.5" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="29" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="1" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + x="4.5" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + x="8" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="11.5" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + x="22" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + x="25.5" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="29" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="1" + y="29" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="4.5" + y="29" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="8" + y="29" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + x="11.5" + y="29" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> <rect x="15" y="29" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect x="18.5" y="29" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="22" y="29" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="25.5" y="29" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="29" y="29" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="1" y="1" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> + <rect + x="18.5" + y="29" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="22" + y="29" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="25.5" + y="29" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="29" + y="29" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="1" + y="1" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> <rect x="1" y="15" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="4.5" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect x="29" y="11.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> + <rect + opacity="0.18" + x="4.5" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + x="29" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> <rect x="4.5" y="8" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> </svg> ) diff --git a/web/app/components/billing/plan/assets/sandbox.tsx b/web/app/components/billing/plan/assets/sandbox.tsx index 0839e8d979445f..71eec68d9471b0 100644 --- a/web/app/components/billing/plan/assets/sandbox.tsx +++ b/web/app/components/billing/plan/assets/sandbox.tsx @@ -3,87 +3,535 @@ import * as React from 'react' const Sandbox = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"> - <rect opacity="0.18" x="1" y="1" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="4.5" y="1" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M8 2C8 1.44772 8.44772 1 9 1C9.55228 1 10 1.44772 10 2C10 2.55228 9.55228 3 9 3C8.44772 3 8 2.55228 8 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M11.5 2C11.5 1.44772 11.9477 1 12.5 1C13.0523 1 13.5 1.44772 13.5 2C13.5 2.55228 13.0523 3 12.5 3C11.9477 3 11.5 2.55228 11.5 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M15 2C15 1.44772 15.4477 1 16 1C16.5523 1 17 1.44772 17 2C17 2.55228 16.5523 3 16 3C15.4477 3 15 2.55228 15 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M18.5 2C18.5 1.44772 18.9477 1 19.5 1C20.0523 1 20.5 1.44772 20.5 2C20.5 2.55228 20.0523 3 19.5 3C18.9477 3 18.5 2.55228 18.5 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M22 2C22 1.44772 22.4477 1 23 1C23.5523 1 24 1.44772 24 2C24 2.55228 23.5523 3 23 3C22.4477 3 22 2.55228 22 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M25.5 2C25.5 1.44772 25.9477 1 26.5 1C27.0523 1 27.5 1.44772 27.5 2C27.5 2.55228 27.0523 3 26.5 3C25.9477 3 25.5 2.55228 25.5 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M29 2C29 1.44772 29.4477 1 30 1C30.5523 1 31 1.44772 31 2C31 2.55228 30.5523 3 30 3C29.4477 3 29 2.55228 29 2Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="1" y="4.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M4.5 5.5C4.5 4.94772 4.94772 4.5 5.5 4.5C6.05228 4.5 6.5 4.94772 6.5 5.5C6.5 6.05228 6.05228 6.5 5.5 6.5C4.94772 6.5 4.5 6.05228 4.5 5.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="8" y="4.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="11.5" y="4.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="4.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="4.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="22" y="4.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M25.5 5.5C25.5 4.94772 25.9477 4.5 26.5 4.5C27.0523 4.5 27.5 4.94772 27.5 5.5C27.5 6.05228 27.0523 6.5 26.5 6.5C25.9477 6.5 25.5 6.05228 25.5 5.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M29 5.5C29 4.94772 29.4477 4.5 30 4.5C30.5523 4.5 31 4.94772 31 5.5C31 6.05228 30.5523 6.5 30 6.5C29.4477 6.5 29 6.05228 29 5.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 9C1 8.44772 1.44772 8 2 8C2.55228 8 3 8.44772 3 9C3 9.55228 2.55228 10 2 10C1.44772 10 1 9.55228 1 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M4.5 9C4.5 8.44772 4.94772 8 5.5 8C6.05228 8 6.5 8.44772 6.5 9C6.5 9.55228 6.05228 10 5.5 10C4.94772 10 4.5 9.55228 4.5 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M8 9C8 8.44772 8.44772 8 9 8C9.55228 8 10 8.44772 10 9C10 9.55228 9.55228 10 9 10C8.44772 10 8 9.55228 8 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M11.5 9C11.5 8.44772 11.9477 8 12.5 8C13.0523 8 13.5 8.44772 13.5 9C13.5 9.55228 13.0523 10 12.5 10C11.9477 10 11.5 9.55228 11.5 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M15 9C15 8.44772 15.4477 8 16 8C16.5523 8 17 8.44772 17 9C17 9.55228 16.5523 10 16 10C15.4477 10 15 9.55228 15 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M18.5 9C18.5 8.44772 18.9477 8 19.5 8C20.0523 8 20.5 8.44772 20.5 9C20.5 9.55228 20.0523 10 19.5 10C18.9477 10 18.5 9.55228 18.5 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M22 9C22 8.44772 22.4477 8 23 8C23.5523 8 24 8.44772 24 9C24 9.55228 23.5523 10 23 10C22.4477 10 22 9.55228 22 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="25.5" y="8" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M29 9C29 8.44772 29.4477 8 30 8C30.5523 8 31 8.44772 31 9C31 9.55228 30.5523 10 30 10C29.4477 10 29 9.55228 29 9Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 12.5C1 11.9477 1.44772 11.5 2 11.5C2.55228 11.5 3 11.9477 3 12.5C3 13.0523 2.55228 13.5 2 13.5C1.44772 13.5 1 13.0523 1 12.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="4.5" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="8" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="11.5" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M22 12.5C22 11.9477 22.4477 11.5 23 11.5C23.5523 11.5 24 11.9477 24 12.5C24 13.0523 23.5523 13.5 23 13.5C22.4477 13.5 22 13.0523 22 12.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="25.5" y="11.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M29 12.5C29 11.9477 29.4477 11.5 30 11.5C30.5523 11.5 31 11.9477 31 12.5C31 13.0523 30.5523 13.5 30 13.5C29.4477 13.5 29 13.0523 29 12.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 16C1 15.4477 1.44772 15 2 15C2.55228 15 3 15.4477 3 16C3 16.5523 2.55228 17 2 17C1.44772 17 1 16.5523 1 16Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="4.5" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="8" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="11.5" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M22 16C22 15.4477 22.4477 15 23 15C23.5523 15 24 15.4477 24 16C24 16.5523 23.5523 17 23 17C22.4477 17 22 16.5523 22 16Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="25.5" y="15" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M29 16C29 15.4477 29.4477 15 30 15C30.5523 15 31 15.4477 31 16C31 16.5523 30.5523 17 30 17C29.4477 17 29 16.5523 29 16Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 19.5C1 18.9477 1.44772 18.5 2 18.5C2.55228 18.5 3 18.9477 3 19.5C3 20.0523 2.55228 20.5 2 20.5C1.44772 20.5 1 20.0523 1 19.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="4.5" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="8" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="11.5" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M22 19.5C22 18.9477 22.4477 18.5 23 18.5C23.5523 18.5 24 18.9477 24 19.5C24 20.0523 23.5523 20.5 23 20.5C22.4477 20.5 22 20.0523 22 19.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="25.5" y="18.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M29 19.5C29 18.9477 29.4477 18.5 30 18.5C30.5523 18.5 31 18.9477 31 19.5C31 20.0523 30.5523 20.5 30 20.5C29.4477 20.5 29 20.0523 29 19.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 23C1 22.4477 1.44772 22 2 22C2.55228 22 3 22.4477 3 23C3 23.5523 2.55228 24 2 24C1.44772 24 1 23.5523 1 23Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="4.5" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="8" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="11.5" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M22 23C22 22.4477 22.4477 22 23 22C23.5523 22 24 22.4477 24 23C24 23.5523 23.5523 24 23 24C22.4477 24 22 23.5523 22 23Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="25.5" y="22" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M29 23C29 22.4477 29.4477 22 30 22C30.5523 22 31 22.4477 31 23C31 23.5523 30.5523 24 30 24C29.4477 24 29 23.5523 29 23Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M1 26.5C1 25.9477 1.44772 25.5 2 25.5C2.55228 25.5 3 25.9477 3 26.5C3 27.0523 2.55228 27.5 2 27.5C1.44772 27.5 1 27.0523 1 26.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="4.5" y="25.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="8" y="25.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="11.5" y="25.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="15" y="25.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="18.5" y="25.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M22 26.5C22 25.9477 22.4477 25.5 23 25.5C23.5523 25.5 24 25.9477 24 26.5C24 27.0523 23.5523 27.5 23 27.5C22.4477 27.5 22 27.0523 22 26.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M25.5 26.5C25.5 25.9477 25.9477 25.5 26.5 25.5C27.0523 25.5 27.5 25.9477 27.5 26.5C27.5 27.0523 27.0523 27.5 26.5 27.5C25.9477 27.5 25.5 27.0523 25.5 26.5Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="29" y="25.5" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <path d="M1 30C1 29.4477 1.44772 29 2 29C2.55228 29 3 29.4477 3 30C3 30.5523 2.55228 31 2 31C1.44772 31 1 30.5523 1 30Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M4.5 30C4.5 29.4477 4.94772 29 5.5 29C6.05228 29 6.5 29.4477 6.5 30C6.5 30.5523 6.05228 31 5.5 31C4.94772 31 4.5 30.5523 4.5 30Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M8 30C8 29.4477 8.44772 29 9 29C9.55228 29 10 29.4477 10 30C10 30.5523 9.55228 31 9 31C8.44772 31 8 30.5523 8 30Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M11.5 30C11.5 29.4477 11.9477 29 12.5 29C13.0523 29 13.5 29.4477 13.5 30C13.5 30.5523 13.0523 31 12.5 31C11.9477 31 11.5 30.5523 11.5 30Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M15 30C15 29.4477 15.4477 29 16 29C16.5523 29 17 29.4477 17 30C17 30.5523 16.5523 31 16 31C15.4477 31 15 30.5523 15 30Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M18.5 30C18.5 29.4477 18.9477 29 19.5 29C20.0523 29 20.5 29.4477 20.5 30C20.5 30.5523 20.0523 31 19.5 31C18.9477 31 18.5 30.5523 18.5 30Z" fill="var(--color-saas-dify-blue-inverted)" /> - <path d="M22 30C22 29.4477 22.4477 29 23 29C23.5523 29 24 29.4477 24 30C24 30.5523 23.5523 31 23 31C22.4477 31 22 30.5523 22 30Z" fill="var(--color-saas-dify-blue-inverted)" /> - <rect opacity="0.18" x="25.5" y="29" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="29" y="29" width="2" height="2" rx="1" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + x="1" + y="1" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="4.5" + y="1" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M8 2C8 1.44772 8.44772 1 9 1C9.55228 1 10 1.44772 10 2C10 2.55228 9.55228 3 9 3C8.44772 3 8 2.55228 8 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M11.5 2C11.5 1.44772 11.9477 1 12.5 1C13.0523 1 13.5 1.44772 13.5 2C13.5 2.55228 13.0523 3 12.5 3C11.9477 3 11.5 2.55228 11.5 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M15 2C15 1.44772 15.4477 1 16 1C16.5523 1 17 1.44772 17 2C17 2.55228 16.5523 3 16 3C15.4477 3 15 2.55228 15 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M18.5 2C18.5 1.44772 18.9477 1 19.5 1C20.0523 1 20.5 1.44772 20.5 2C20.5 2.55228 20.0523 3 19.5 3C18.9477 3 18.5 2.55228 18.5 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M22 2C22 1.44772 22.4477 1 23 1C23.5523 1 24 1.44772 24 2C24 2.55228 23.5523 3 23 3C22.4477 3 22 2.55228 22 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M25.5 2C25.5 1.44772 25.9477 1 26.5 1C27.0523 1 27.5 1.44772 27.5 2C27.5 2.55228 27.0523 3 26.5 3C25.9477 3 25.5 2.55228 25.5 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M29 2C29 1.44772 29.4477 1 30 1C30.5523 1 31 1.44772 31 2C31 2.55228 30.5523 3 30 3C29.4477 3 29 2.55228 29 2Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="1" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M4.5 5.5C4.5 4.94772 4.94772 4.5 5.5 4.5C6.05228 4.5 6.5 4.94772 6.5 5.5C6.5 6.05228 6.05228 6.5 5.5 6.5C4.94772 6.5 4.5 6.05228 4.5 5.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="8" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="11.5" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="22" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M25.5 5.5C25.5 4.94772 25.9477 4.5 26.5 4.5C27.0523 4.5 27.5 4.94772 27.5 5.5C27.5 6.05228 27.0523 6.5 26.5 6.5C25.9477 6.5 25.5 6.05228 25.5 5.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M29 5.5C29 4.94772 29.4477 4.5 30 4.5C30.5523 4.5 31 4.94772 31 5.5C31 6.05228 30.5523 6.5 30 6.5C29.4477 6.5 29 6.05228 29 5.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 9C1 8.44772 1.44772 8 2 8C2.55228 8 3 8.44772 3 9C3 9.55228 2.55228 10 2 10C1.44772 10 1 9.55228 1 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M4.5 9C4.5 8.44772 4.94772 8 5.5 8C6.05228 8 6.5 8.44772 6.5 9C6.5 9.55228 6.05228 10 5.5 10C4.94772 10 4.5 9.55228 4.5 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M8 9C8 8.44772 8.44772 8 9 8C9.55228 8 10 8.44772 10 9C10 9.55228 9.55228 10 9 10C8.44772 10 8 9.55228 8 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M11.5 9C11.5 8.44772 11.9477 8 12.5 8C13.0523 8 13.5 8.44772 13.5 9C13.5 9.55228 13.0523 10 12.5 10C11.9477 10 11.5 9.55228 11.5 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M15 9C15 8.44772 15.4477 8 16 8C16.5523 8 17 8.44772 17 9C17 9.55228 16.5523 10 16 10C15.4477 10 15 9.55228 15 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M18.5 9C18.5 8.44772 18.9477 8 19.5 8C20.0523 8 20.5 8.44772 20.5 9C20.5 9.55228 20.0523 10 19.5 10C18.9477 10 18.5 9.55228 18.5 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M22 9C22 8.44772 22.4477 8 23 8C23.5523 8 24 8.44772 24 9C24 9.55228 23.5523 10 23 10C22.4477 10 22 9.55228 22 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="25.5" + y="8" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 9C29 8.44772 29.4477 8 30 8C30.5523 8 31 8.44772 31 9C31 9.55228 30.5523 10 30 10C29.4477 10 29 9.55228 29 9Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 12.5C1 11.9477 1.44772 11.5 2 11.5C2.55228 11.5 3 11.9477 3 12.5C3 13.0523 2.55228 13.5 2 13.5C1.44772 13.5 1 13.0523 1 12.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="4.5" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="8" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="11.5" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M22 12.5C22 11.9477 22.4477 11.5 23 11.5C23.5523 11.5 24 11.9477 24 12.5C24 13.0523 23.5523 13.5 23 13.5C22.4477 13.5 22 13.0523 22 12.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="25.5" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 12.5C29 11.9477 29.4477 11.5 30 11.5C30.5523 11.5 31 11.9477 31 12.5C31 13.0523 30.5523 13.5 30 13.5C29.4477 13.5 29 13.0523 29 12.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 16C1 15.4477 1.44772 15 2 15C2.55228 15 3 15.4477 3 16C3 16.5523 2.55228 17 2 17C1.44772 17 1 16.5523 1 16Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="4.5" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="8" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="11.5" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M22 16C22 15.4477 22.4477 15 23 15C23.5523 15 24 15.4477 24 16C24 16.5523 23.5523 17 23 17C22.4477 17 22 16.5523 22 16Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="25.5" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 16C29 15.4477 29.4477 15 30 15C30.5523 15 31 15.4477 31 16C31 16.5523 30.5523 17 30 17C29.4477 17 29 16.5523 29 16Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 19.5C1 18.9477 1.44772 18.5 2 18.5C2.55228 18.5 3 18.9477 3 19.5C3 20.0523 2.55228 20.5 2 20.5C1.44772 20.5 1 20.0523 1 19.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="4.5" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="8" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="11.5" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M22 19.5C22 18.9477 22.4477 18.5 23 18.5C23.5523 18.5 24 18.9477 24 19.5C24 20.0523 23.5523 20.5 23 20.5C22.4477 20.5 22 20.0523 22 19.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="25.5" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 19.5C29 18.9477 29.4477 18.5 30 18.5C30.5523 18.5 31 18.9477 31 19.5C31 20.0523 30.5523 20.5 30 20.5C29.4477 20.5 29 20.0523 29 19.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 23C1 22.4477 1.44772 22 2 22C2.55228 22 3 22.4477 3 23C3 23.5523 2.55228 24 2 24C1.44772 24 1 23.5523 1 23Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="4.5" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="8" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="11.5" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M22 23C22 22.4477 22.4477 22 23 22C23.5523 22 24 22.4477 24 23C24 23.5523 23.5523 24 23 24C22.4477 24 22 23.5523 22 23Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="25.5" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M29 23C29 22.4477 29.4477 22 30 22C30.5523 22 31 22.4477 31 23C31 23.5523 30.5523 24 30 24C29.4477 24 29 23.5523 29 23Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M1 26.5C1 25.9477 1.44772 25.5 2 25.5C2.55228 25.5 3 25.9477 3 26.5C3 27.0523 2.55228 27.5 2 27.5C1.44772 27.5 1 27.0523 1 26.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="4.5" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="8" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="11.5" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="15" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="18.5" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M22 26.5C22 25.9477 22.4477 25.5 23 25.5C23.5523 25.5 24 25.9477 24 26.5C24 27.0523 23.5523 27.5 23 27.5C22.4477 27.5 22 27.0523 22 26.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M25.5 26.5C25.5 25.9477 25.9477 25.5 26.5 25.5C27.0523 25.5 27.5 25.9477 27.5 26.5C27.5 27.0523 27.0523 27.5 26.5 27.5C25.9477 27.5 25.5 27.0523 25.5 26.5Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="29" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <path + d="M1 30C1 29.4477 1.44772 29 2 29C2.55228 29 3 29.4477 3 30C3 30.5523 2.55228 31 2 31C1.44772 31 1 30.5523 1 30Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M4.5 30C4.5 29.4477 4.94772 29 5.5 29C6.05228 29 6.5 29.4477 6.5 30C6.5 30.5523 6.05228 31 5.5 31C4.94772 31 4.5 30.5523 4.5 30Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M8 30C8 29.4477 8.44772 29 9 29C9.55228 29 10 29.4477 10 30C10 30.5523 9.55228 31 9 31C8.44772 31 8 30.5523 8 30Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M11.5 30C11.5 29.4477 11.9477 29 12.5 29C13.0523 29 13.5 29.4477 13.5 30C13.5 30.5523 13.0523 31 12.5 31C11.9477 31 11.5 30.5523 11.5 30Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M15 30C15 29.4477 15.4477 29 16 29C16.5523 29 17 29.4477 17 30C17 30.5523 16.5523 31 16 31C15.4477 31 15 30.5523 15 30Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M18.5 30C18.5 29.4477 18.9477 29 19.5 29C20.0523 29 20.5 29.4477 20.5 30C20.5 30.5523 20.0523 31 19.5 31C18.9477 31 18.5 30.5523 18.5 30Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + d="M22 30C22 29.4477 22.4477 29 23 29C23.5523 29 24 29.4477 24 30C24 30.5523 23.5523 31 23 31C22.4477 31 22 30.5523 22 30Z" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + opacity="0.18" + x="25.5" + y="29" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="29" + y="29" + width="2" + height="2" + rx="1" + fill="var(--color-text-quaternary)" + /> </svg> ) } diff --git a/web/app/components/billing/plan/assets/team.tsx b/web/app/components/billing/plan/assets/team.tsx index 5d16e0e83a6442..f2a3853bc3cd71 100644 --- a/web/app/components/billing/plan/assets/team.tsx +++ b/web/app/components/billing/plan/assets/team.tsx @@ -4,84 +4,430 @@ const Team = () => { <rect x="1" y="1" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> <rect x="4.5" y="1" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> <rect x="8" y="1" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M11.5 2C11.5 1.44772 11.9477 1 12.5 1C13.0523 1 13.5 1.44772 13.5 2C13.5 2.55228 13.0523 3 12.5 3C11.9477 3 11.5 2.55228 11.5 2Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M15 2C15 1.44772 15.4477 1 16 1C16.5523 1 17 1.44772 17 2C17 2.55228 16.5523 3 16 3C15.4477 3 15 2.55228 15 2Z" fill="var(--color-text-quaternary)" /> - <rect x="18.5" y="1" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M22 2C22 1.44772 22.4477 1 23 1C23.5523 1 24 1.44772 24 2C24 2.55228 23.5523 3 23 3C22.4477 3 22 2.55228 22 2Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M25.5 2C25.5 1.44772 25.9477 1 26.5 1C27.0523 1 27.5 1.44772 27.5 2C27.5 2.55228 27.0523 3 26.5 3C25.9477 3 25.5 2.55228 25.5 2Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M29 2C29 1.44772 29.4477 1 30 1C30.5523 1 31 1.44772 31 2C31 2.55228 30.5523 3 30 3C29.4477 3 29 2.55228 29 2Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M1 5.5C1 4.94772 1.44772 4.5 2 4.5C2.55228 4.5 3 4.94772 3 5.5C3 6.05228 2.55228 6.5 2 6.5C1.44772 6.5 1 6.05228 1 5.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M4.5 5.5C4.5 4.94772 4.94772 4.5 5.5 4.5C6.05228 4.5 6.5 4.94772 6.5 5.5C6.5 6.05228 6.05228 6.5 5.5 6.5C4.94772 6.5 4.5 6.05228 4.5 5.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 5.5C8 4.94772 8.44772 4.5 9 4.5C9.55228 4.5 10 4.94772 10 5.5C10 6.05228 9.55228 6.5 9 6.5C8.44772 6.5 8 6.05228 8 5.5Z" fill="var(--color-text-quaternary)" /> - <rect x="11.5" y="4.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect x="15" y="4.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M18.5 5.5C18.5 4.94772 18.9477 4.5 19.5 4.5C20.0523 4.5 20.5 4.94772 20.5 5.5C20.5 6.05228 20.0523 6.5 19.5 6.5C18.9477 6.5 18.5 6.05228 18.5 5.5Z" fill="var(--color-text-quaternary)" /> - <rect x="22" y="4.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect x="25.5" y="4.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M29 5.5C29 4.94772 29.4477 4.5 30 4.5C30.5523 4.5 31 4.94772 31 5.5C31 6.05228 30.5523 6.5 30 6.5C29.4477 6.5 29 6.05228 29 5.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M1 9C1 8.44772 1.44772 8 2 8C2.55228 8 3 8.44772 3 9C3 9.55228 2.55228 10 2 10C1.44772 10 1 9.55228 1 9Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M4.5 9C4.5 8.44772 4.94772 8 5.5 8C6.05228 8 6.5 8.44772 6.5 9C6.5 9.55228 6.05228 10 5.5 10C4.94772 10 4.5 9.55228 4.5 9Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 9C8 8.44772 8.44772 8 9 8C9.55228 8 10 8.44772 10 9C10 9.55228 9.55228 10 9 10C8.44772 10 8 9.55228 8 9Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M11.5 9C11.5 8.44772 11.9477 8 12.5 8C13.0523 8 13.5 8.44772 13.5 9C13.5 9.55228 13.0523 10 12.5 10C11.9477 10 11.5 9.55228 11.5 9Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M11.5 2C11.5 1.44772 11.9477 1 12.5 1C13.0523 1 13.5 1.44772 13.5 2C13.5 2.55228 13.0523 3 12.5 3C11.9477 3 11.5 2.55228 11.5 2Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M15 2C15 1.44772 15.4477 1 16 1C16.5523 1 17 1.44772 17 2C17 2.55228 16.5523 3 16 3C15.4477 3 15 2.55228 15 2Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="18.5" + y="1" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M22 2C22 1.44772 22.4477 1 23 1C23.5523 1 24 1.44772 24 2C24 2.55228 23.5523 3 23 3C22.4477 3 22 2.55228 22 2Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M25.5 2C25.5 1.44772 25.9477 1 26.5 1C27.0523 1 27.5 1.44772 27.5 2C27.5 2.55228 27.0523 3 26.5 3C25.9477 3 25.5 2.55228 25.5 2Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M29 2C29 1.44772 29.4477 1 30 1C30.5523 1 31 1.44772 31 2C31 2.55228 30.5523 3 30 3C29.4477 3 29 2.55228 29 2Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M1 5.5C1 4.94772 1.44772 4.5 2 4.5C2.55228 4.5 3 4.94772 3 5.5C3 6.05228 2.55228 6.5 2 6.5C1.44772 6.5 1 6.05228 1 5.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M4.5 5.5C4.5 4.94772 4.94772 4.5 5.5 4.5C6.05228 4.5 6.5 4.94772 6.5 5.5C6.5 6.05228 6.05228 6.5 5.5 6.5C4.94772 6.5 4.5 6.05228 4.5 5.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 5.5C8 4.94772 8.44772 4.5 9 4.5C9.55228 4.5 10 4.94772 10 5.5C10 6.05228 9.55228 6.5 9 6.5C8.44772 6.5 8 6.05228 8 5.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="11.5" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + x="15" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M18.5 5.5C18.5 4.94772 18.9477 4.5 19.5 4.5C20.0523 4.5 20.5 4.94772 20.5 5.5C20.5 6.05228 20.0523 6.5 19.5 6.5C18.9477 6.5 18.5 6.05228 18.5 5.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="22" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + x="25.5" + y="4.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M29 5.5C29 4.94772 29.4477 4.5 30 4.5C30.5523 4.5 31 4.94772 31 5.5C31 6.05228 30.5523 6.5 30 6.5C29.4477 6.5 29 6.05228 29 5.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M1 9C1 8.44772 1.44772 8 2 8C2.55228 8 3 8.44772 3 9C3 9.55228 2.55228 10 2 10C1.44772 10 1 9.55228 1 9Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M4.5 9C4.5 8.44772 4.94772 8 5.5 8C6.05228 8 6.5 8.44772 6.5 9C6.5 9.55228 6.05228 10 5.5 10C4.94772 10 4.5 9.55228 4.5 9Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 9C8 8.44772 8.44772 8 9 8C9.55228 8 10 8.44772 10 9C10 9.55228 9.55228 10 9 10C8.44772 10 8 9.55228 8 9Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M11.5 9C11.5 8.44772 11.9477 8 12.5 8C13.0523 8 13.5 8.44772 13.5 9C13.5 9.55228 13.0523 10 12.5 10C11.9477 10 11.5 9.55228 11.5 9Z" + fill="var(--color-text-quaternary)" + /> <rect x="15" y="8" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M18.5 9C18.5 8.44772 18.9477 8 19.5 8C20.0523 8 20.5 8.44772 20.5 9C20.5 9.55228 20.0523 10 19.5 10C18.9477 10 18.5 9.55228 18.5 9Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M22 9C22 8.44772 22.4477 8 23 8C23.5523 8 24 8.44772 24 9C24 9.55228 23.5523 10 23 10C22.4477 10 22 9.55228 22 9Z" fill="var(--color-text-quaternary)" /> - <rect x="25.5" y="8" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M29 9C29 8.44772 29.4477 8 30 8C30.5523 8 31 8.44772 31 9C31 9.55228 30.5523 10 30 10C29.4477 10 29 9.55228 29 9Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M1 12.5C1 11.9477 1.44772 11.5 2 11.5C2.55228 11.5 3 11.9477 3 12.5C3 13.0523 2.55228 13.5 2 13.5C1.44772 13.5 1 13.0523 1 12.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M4.5 12.5C4.5 11.9477 4.94772 11.5 5.5 11.5C6.05228 11.5 6.5 11.9477 6.5 12.5C6.5 13.0523 6.05228 13.5 5.5 13.5C4.94772 13.5 4.5 13.0523 4.5 12.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 12.5C8 11.9477 8.44772 11.5 9 11.5C9.55228 11.5 10 11.9477 10 12.5C10 13.0523 9.55228 13.5 9 13.5C8.44772 13.5 8 13.0523 8 12.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M11.5 12.5C11.5 11.9477 11.9477 11.5 12.5 11.5C13.0523 11.5 13.5 11.9477 13.5 12.5C13.5 13.0523 13.0523 13.5 12.5 13.5C11.9477 13.5 11.5 13.0523 11.5 12.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M15 12.5C15 11.9477 15.4477 11.5 16 11.5C16.5523 11.5 17 11.9477 17 12.5C17 13.0523 16.5523 13.5 16 13.5C15.4477 13.5 15 13.0523 15 12.5Z" fill="var(--color-text-quaternary)" /> - <rect x="18.5" y="11.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M22 12.5C22 11.9477 22.4477 11.5 23 11.5C23.5523 11.5 24 11.9477 24 12.5C24 13.0523 23.5523 13.5 23 13.5C22.4477 13.5 22 13.0523 22 12.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M25.5 12.5C25.5 11.9477 25.9477 11.5 26.5 11.5C27.0523 11.5 27.5 11.9477 27.5 12.5C27.5 13.0523 27.0523 13.5 26.5 13.5C25.9477 13.5 25.5 13.0523 25.5 12.5Z" fill="var(--color-text-quaternary)" /> - <rect x="29" y="11.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M1 16C1 15.4477 1.44772 15 2 15C2.55228 15 3 15.4477 3 16C3 16.5523 2.55228 17 2 17C1.44772 17 1 16.5523 1 16Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M4.5 16C4.5 15.4477 4.94772 15 5.5 15C6.05228 15 6.5 15.4477 6.5 16C6.5 16.5523 6.05228 17 5.5 17C4.94772 17 4.5 16.5523 4.5 16Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 16C8 15.4477 8.44772 15 9 15C9.55228 15 10 15.4477 10 16C10 16.5523 9.55228 17 9 17C8.44772 17 8 16.5523 8 16Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M11.5 16C11.5 15.4477 11.9477 15 12.5 15C13.0523 15 13.5 15.4477 13.5 16C13.5 16.5523 13.0523 17 12.5 17C11.9477 17 11.5 16.5523 11.5 16Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M15 16C15 15.4477 15.4477 15 16 15C16.5523 15 17 15.4477 17 16C17 16.5523 16.5523 17 16 17C15.4477 17 15 16.5523 15 16Z" fill="var(--color-text-quaternary)" /> - <rect x="18.5" y="15" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M22 16C22 15.4477 22.4477 15 23 15C23.5523 15 24 15.4477 24 16C24 16.5523 23.5523 17 23 17C22.4477 17 22 16.5523 22 16Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M25.5 16C25.5 15.4477 25.9477 15 26.5 15C27.0523 15 27.5 15.4477 27.5 16C27.5 16.5523 27.0523 17 26.5 17C25.9477 17 25.5 16.5523 25.5 16Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M18.5 9C18.5 8.44772 18.9477 8 19.5 8C20.0523 8 20.5 8.44772 20.5 9C20.5 9.55228 20.0523 10 19.5 10C18.9477 10 18.5 9.55228 18.5 9Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M22 9C22 8.44772 22.4477 8 23 8C23.5523 8 24 8.44772 24 9C24 9.55228 23.5523 10 23 10C22.4477 10 22 9.55228 22 9Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="25.5" + y="8" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M29 9C29 8.44772 29.4477 8 30 8C30.5523 8 31 8.44772 31 9C31 9.55228 30.5523 10 30 10C29.4477 10 29 9.55228 29 9Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M1 12.5C1 11.9477 1.44772 11.5 2 11.5C2.55228 11.5 3 11.9477 3 12.5C3 13.0523 2.55228 13.5 2 13.5C1.44772 13.5 1 13.0523 1 12.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M4.5 12.5C4.5 11.9477 4.94772 11.5 5.5 11.5C6.05228 11.5 6.5 11.9477 6.5 12.5C6.5 13.0523 6.05228 13.5 5.5 13.5C4.94772 13.5 4.5 13.0523 4.5 12.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 12.5C8 11.9477 8.44772 11.5 9 11.5C9.55228 11.5 10 11.9477 10 12.5C10 13.0523 9.55228 13.5 9 13.5C8.44772 13.5 8 13.0523 8 12.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M11.5 12.5C11.5 11.9477 11.9477 11.5 12.5 11.5C13.0523 11.5 13.5 11.9477 13.5 12.5C13.5 13.0523 13.0523 13.5 12.5 13.5C11.9477 13.5 11.5 13.0523 11.5 12.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M15 12.5C15 11.9477 15.4477 11.5 16 11.5C16.5523 11.5 17 11.9477 17 12.5C17 13.0523 16.5523 13.5 16 13.5C15.4477 13.5 15 13.0523 15 12.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="18.5" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M22 12.5C22 11.9477 22.4477 11.5 23 11.5C23.5523 11.5 24 11.9477 24 12.5C24 13.0523 23.5523 13.5 23 13.5C22.4477 13.5 22 13.0523 22 12.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M25.5 12.5C25.5 11.9477 25.9477 11.5 26.5 11.5C27.0523 11.5 27.5 11.9477 27.5 12.5C27.5 13.0523 27.0523 13.5 26.5 13.5C25.9477 13.5 25.5 13.0523 25.5 12.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="29" + y="11.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M1 16C1 15.4477 1.44772 15 2 15C2.55228 15 3 15.4477 3 16C3 16.5523 2.55228 17 2 17C1.44772 17 1 16.5523 1 16Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M4.5 16C4.5 15.4477 4.94772 15 5.5 15C6.05228 15 6.5 15.4477 6.5 16C6.5 16.5523 6.05228 17 5.5 17C4.94772 17 4.5 16.5523 4.5 16Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 16C8 15.4477 8.44772 15 9 15C9.55228 15 10 15.4477 10 16C10 16.5523 9.55228 17 9 17C8.44772 17 8 16.5523 8 16Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M11.5 16C11.5 15.4477 11.9477 15 12.5 15C13.0523 15 13.5 15.4477 13.5 16C13.5 16.5523 13.0523 17 12.5 17C11.9477 17 11.5 16.5523 11.5 16Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M15 16C15 15.4477 15.4477 15 16 15C16.5523 15 17 15.4477 17 16C17 16.5523 16.5523 17 16 17C15.4477 17 15 16.5523 15 16Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="18.5" + y="15" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M22 16C22 15.4477 22.4477 15 23 15C23.5523 15 24 15.4477 24 16C24 16.5523 23.5523 17 23 17C22.4477 17 22 16.5523 22 16Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M25.5 16C25.5 15.4477 25.9477 15 26.5 15C27.0523 15 27.5 15.4477 27.5 16C27.5 16.5523 27.0523 17 26.5 17C25.9477 17 25.5 16.5523 25.5 16Z" + fill="var(--color-text-quaternary)" + /> <rect x="29" y="15" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M1 19.5C1 18.9477 1.44772 18.5 2 18.5C2.55228 18.5 3 18.9477 3 19.5C3 20.0523 2.55228 20.5 2 20.5C1.44772 20.5 1 20.0523 1 19.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M4.5 19.5C4.5 18.9477 4.94772 18.5 5.5 18.5C6.05228 18.5 6.5 18.9477 6.5 19.5C6.5 20.0523 6.05228 20.5 5.5 20.5C4.94772 20.5 4.5 20.0523 4.5 19.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 19.5C8 18.9477 8.44772 18.5 9 18.5C9.55228 18.5 10 18.9477 10 19.5C10 20.0523 9.55228 20.5 9 20.5C8.44772 20.5 8 20.0523 8 19.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M11.5 19.5C11.5 18.9477 11.9477 18.5 12.5 18.5C13.0523 18.5 13.5 18.9477 13.5 19.5C13.5 20.0523 13.0523 20.5 12.5 20.5C11.9477 20.5 11.5 20.0523 11.5 19.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M15 19.5C15 18.9477 15.4477 18.5 16 18.5C16.5523 18.5 17 18.9477 17 19.5C17 20.0523 16.5523 20.5 16 20.5C15.4477 20.5 15 20.0523 15 19.5Z" fill="var(--color-text-quaternary)" /> - <rect x="18.5" y="18.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M22 19.5C22 18.9477 22.4477 18.5 23 18.5C23.5523 18.5 24 18.9477 24 19.5C24 20.0523 23.5523 20.5 23 20.5C22.4477 20.5 22 20.0523 22 19.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M25.5 19.5C25.5 18.9477 25.9477 18.5 26.5 18.5C27.0523 18.5 27.5 18.9477 27.5 19.5C27.5 20.0523 27.0523 20.5 26.5 20.5C25.9477 20.5 25.5 20.0523 25.5 19.5Z" fill="var(--color-text-quaternary)" /> - <rect x="29" y="18.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M1 23C1 22.4477 1.44772 22 2 22C2.55228 22 3 22.4477 3 23C3 23.5523 2.55228 24 2 24C1.44772 24 1 23.5523 1 23Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M4.5 23C4.5 22.4477 4.94772 22 5.5 22C6.05228 22 6.5 22.4477 6.5 23C6.5 23.5523 6.05228 24 5.5 24C4.94772 24 4.5 23.5523 4.5 23Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 23C8 22.4477 8.44772 22 9 22C9.55228 22 10 22.4477 10 23C10 23.5523 9.55228 24 9 24C8.44772 24 8 23.5523 8 23Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M11.5 23C11.5 22.4477 11.9477 22 12.5 22C13.0523 22 13.5 22.4477 13.5 23C13.5 23.5523 13.0523 24 12.5 24C11.9477 24 11.5 23.5523 11.5 23Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M1 19.5C1 18.9477 1.44772 18.5 2 18.5C2.55228 18.5 3 18.9477 3 19.5C3 20.0523 2.55228 20.5 2 20.5C1.44772 20.5 1 20.0523 1 19.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M4.5 19.5C4.5 18.9477 4.94772 18.5 5.5 18.5C6.05228 18.5 6.5 18.9477 6.5 19.5C6.5 20.0523 6.05228 20.5 5.5 20.5C4.94772 20.5 4.5 20.0523 4.5 19.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 19.5C8 18.9477 8.44772 18.5 9 18.5C9.55228 18.5 10 18.9477 10 19.5C10 20.0523 9.55228 20.5 9 20.5C8.44772 20.5 8 20.0523 8 19.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M11.5 19.5C11.5 18.9477 11.9477 18.5 12.5 18.5C13.0523 18.5 13.5 18.9477 13.5 19.5C13.5 20.0523 13.0523 20.5 12.5 20.5C11.9477 20.5 11.5 20.0523 11.5 19.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M15 19.5C15 18.9477 15.4477 18.5 16 18.5C16.5523 18.5 17 18.9477 17 19.5C17 20.0523 16.5523 20.5 16 20.5C15.4477 20.5 15 20.0523 15 19.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="18.5" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M22 19.5C22 18.9477 22.4477 18.5 23 18.5C23.5523 18.5 24 18.9477 24 19.5C24 20.0523 23.5523 20.5 23 20.5C22.4477 20.5 22 20.0523 22 19.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M25.5 19.5C25.5 18.9477 25.9477 18.5 26.5 18.5C27.0523 18.5 27.5 18.9477 27.5 19.5C27.5 20.0523 27.0523 20.5 26.5 20.5C25.9477 20.5 25.5 20.0523 25.5 19.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="29" + y="18.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M1 23C1 22.4477 1.44772 22 2 22C2.55228 22 3 22.4477 3 23C3 23.5523 2.55228 24 2 24C1.44772 24 1 23.5523 1 23Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M4.5 23C4.5 22.4477 4.94772 22 5.5 22C6.05228 22 6.5 22.4477 6.5 23C6.5 23.5523 6.05228 24 5.5 24C4.94772 24 4.5 23.5523 4.5 23Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 23C8 22.4477 8.44772 22 9 22C9.55228 22 10 22.4477 10 23C10 23.5523 9.55228 24 9 24C8.44772 24 8 23.5523 8 23Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M11.5 23C11.5 22.4477 11.9477 22 12.5 22C13.0523 22 13.5 22.4477 13.5 23C13.5 23.5523 13.0523 24 12.5 24C11.9477 24 11.5 23.5523 11.5 23Z" + fill="var(--color-text-quaternary)" + /> <rect x="15" y="22" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M18.5 23C18.5 22.4477 18.9477 22 19.5 22C20.0523 22 20.5 22.4477 20.5 23C20.5 23.5523 20.0523 24 19.5 24C18.9477 24 18.5 23.5523 18.5 23Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M22 23C22 22.4477 22.4477 22 23 22C23.5523 22 24 22.4477 24 23C24 23.5523 23.5523 24 23 24C22.4477 24 22 23.5523 22 23Z" fill="var(--color-text-quaternary)" /> - <rect x="25.5" y="22" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M29 23C29 22.4477 29.4477 22 30 22C30.5523 22 31 22.4477 31 23C31 23.5523 30.5523 24 30 24C29.4477 24 29 23.5523 29 23Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M1 26.5C1 25.9477 1.44772 25.5 2 25.5C2.55228 25.5 3 25.9477 3 26.5C3 27.0523 2.55228 27.5 2 27.5C1.44772 27.5 1 27.0523 1 26.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M4.5 26.5C4.5 25.9477 4.94772 25.5 5.5 25.5C6.05228 25.5 6.5 25.9477 6.5 26.5C6.5 27.0523 6.05228 27.5 5.5 27.5C4.94772 27.5 4.5 27.0523 4.5 26.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M8 26.5C8 25.9477 8.44772 25.5 9 25.5C9.55228 25.5 10 25.9477 10 26.5C10 27.0523 9.55228 27.5 9 27.5C8.44772 27.5 8 27.0523 8 26.5Z" fill="var(--color-text-quaternary)" /> - <rect x="11.5" y="25.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect x="15" y="25.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M18.5 26.5C18.5 25.9477 18.9477 25.5 19.5 25.5C20.0523 25.5 20.5 25.9477 20.5 26.5C20.5 27.0523 20.0523 27.5 19.5 27.5C18.9477 27.5 18.5 27.0523 18.5 26.5Z" fill="var(--color-text-quaternary)" /> - <rect x="22" y="25.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect x="25.5" y="25.5" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M29 26.5C29 25.9477 29.4477 25.5 30 25.5C30.5523 25.5 31 25.9477 31 26.5C31 27.0523 30.5523 27.5 30 27.5C29.4477 27.5 29 27.0523 29 26.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M18.5 23C18.5 22.4477 18.9477 22 19.5 22C20.0523 22 20.5 22.4477 20.5 23C20.5 23.5523 20.0523 24 19.5 24C18.9477 24 18.5 23.5523 18.5 23Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M22 23C22 22.4477 22.4477 22 23 22C23.5523 22 24 22.4477 24 23C24 23.5523 23.5523 24 23 24C22.4477 24 22 23.5523 22 23Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="25.5" + y="22" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M29 23C29 22.4477 29.4477 22 30 22C30.5523 22 31 22.4477 31 23C31 23.5523 30.5523 24 30 24C29.4477 24 29 23.5523 29 23Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M1 26.5C1 25.9477 1.44772 25.5 2 25.5C2.55228 25.5 3 25.9477 3 26.5C3 27.0523 2.55228 27.5 2 27.5C1.44772 27.5 1 27.0523 1 26.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M4.5 26.5C4.5 25.9477 4.94772 25.5 5.5 25.5C6.05228 25.5 6.5 25.9477 6.5 26.5C6.5 27.0523 6.05228 27.5 5.5 27.5C4.94772 27.5 4.5 27.0523 4.5 26.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M8 26.5C8 25.9477 8.44772 25.5 9 25.5C9.55228 25.5 10 25.9477 10 26.5C10 27.0523 9.55228 27.5 9 27.5C8.44772 27.5 8 27.0523 8 26.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="11.5" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + x="15" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M18.5 26.5C18.5 25.9477 18.9477 25.5 19.5 25.5C20.0523 25.5 20.5 25.9477 20.5 26.5C20.5 27.0523 20.0523 27.5 19.5 27.5C18.9477 27.5 18.5 27.0523 18.5 26.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="22" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <rect + x="25.5" + y="25.5" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M29 26.5C29 25.9477 29.4477 25.5 30 25.5C30.5523 25.5 31 25.9477 31 26.5C31 27.0523 30.5523 27.5 30 27.5C29.4477 27.5 29 27.0523 29 26.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="1" y="29" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <rect x="4.5" y="29" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> + <rect + x="4.5" + y="29" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> <rect x="8" y="29" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M11.5 30C11.5 29.4477 11.9477 29 12.5 29C13.0523 29 13.5 29.4477 13.5 30C13.5 30.5523 13.0523 31 12.5 31C11.9477 31 11.5 30.5523 11.5 30Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M15 30C15 29.4477 15.4477 29 16 29C16.5523 29 17 29.4477 17 30C17 30.5523 16.5523 31 16 31C15.4477 31 15 30.5523 15 30Z" fill="var(--color-text-quaternary)" /> - <rect x="18.5" y="29" width="2" height="2" rx="1" fill="var(--color-saas-dify-blue-inverted)" /> - <path opacity="0.18" d="M22 30C22 29.4477 22.4477 29 23 29C23.5523 29 24 29.4477 24 30C24 30.5523 23.5523 31 23 31C22.4477 31 22 30.5523 22 30Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M25.5 30C25.5 29.4477 25.9477 29 26.5 29C27.0523 29 27.5 29.4477 27.5 30C27.5 30.5523 27.0523 31 26.5 31C25.9477 31 25.5 30.5523 25.5 30Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M29 30C29 29.4477 29.4477 29 30 29C30.5523 29 31 29.4477 31 30C31 30.5523 30.5523 31 30 31C29.4477 31 29 30.5523 29 30Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M11.5 30C11.5 29.4477 11.9477 29 12.5 29C13.0523 29 13.5 29.4477 13.5 30C13.5 30.5523 13.0523 31 12.5 31C11.9477 31 11.5 30.5523 11.5 30Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M15 30C15 29.4477 15.4477 29 16 29C16.5523 29 17 29.4477 17 30C17 30.5523 16.5523 31 16 31C15.4477 31 15 30.5523 15 30Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="18.5" + y="29" + width="2" + height="2" + rx="1" + fill="var(--color-saas-dify-blue-inverted)" + /> + <path + opacity="0.18" + d="M22 30C22 29.4477 22.4477 29 23 29C23.5523 29 24 29.4477 24 30C24 30.5523 23.5523 31 23 31C22.4477 31 22 30.5523 22 30Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M25.5 30C25.5 29.4477 25.9477 29 26.5 29C27.0523 29 27.5 29.4477 27.5 30C27.5 30.5523 27.0523 31 26.5 31C25.9477 31 25.5 30.5523 25.5 30Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M29 30C29 29.4477 29.4477 29 30 29C30.5523 29 31 29.4477 31 30C31 30.5523 30.5523 31 30 31C29.4477 31 29 30.5523 29 30Z" + fill="var(--color-text-quaternary)" + /> </svg> ) } diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx index e7b649248b6ebe..c531d9ab003b69 100644 --- a/web/app/components/billing/plan/index.tsx +++ b/web/app/components/billing/plan/index.tsx @@ -1,11 +1,7 @@ 'use client' import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' -import { - RiBook2Line, - RiFileEditLine, - RiGroupLine, -} from '@remixicon/react' +import { RiBook2Line, RiFileEditLine, RiGroupLine } from '@remixicon/react' import { useUnmountedRef } from 'ahooks' import { useAtomValue } from 'jotai' import * as React from 'react' @@ -37,36 +33,27 @@ type Props = Readonly<{ loc: string }> -const PlanComp: FC<Props> = ({ - loc, -}) => { +const PlanComp: FC<Props> = ({ loc }) => { const { t } = useTranslation() const router = useRouter() const path = usePathname() const userProfileEmail = useAtomValue(userProfileEmailAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } = useProviderContext() + const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } = + useProviderContext() const isAboutToExpire = allowRefreshEducationVerify - const { - type, - } = plan + const { type } = plan const isEnterprisePlan = String(type) === SelfHostedPlan.enterprise - const { - usage, - total, - reset, - } = plan - const triggerEventsResetInDays = type === Plan.professional && total.triggerEvents !== NUM_INFINITE - ? reset.triggerEvents ?? undefined - : undefined + const { usage, total, reset } = plan + const triggerEventsResetInDays = + type === Plan.professional && total.triggerEvents !== NUM_INFINITE + ? (reset.triggerEvents ?? undefined) + : undefined const apiRateLimitResetInDays = (() => { - if (total.apiRateLimit === NUM_INFINITE) - return undefined - if (typeof reset.apiRateLimit === 'number') - return reset.apiRateLimit - if (type === Plan.sandbox) - return getDaysUntilEndOfMonth() + if (total.apiRateLimit === NUM_INFINITE) return undefined + if (typeof reset.apiRateLimit === 'number') return reset.apiRateLimit + if (type === Plan.sandbox) return getDaysUntilEndOfMonth() return undefined })() @@ -74,70 +61,70 @@ const PlanComp: FC<Props> = ({ const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount() const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage) const { mutateAsync, isPending } = useEducationVerify() - const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) + const setShowAccountSettingModal = useModalContextSelector((s) => s.setShowAccountSettingModal) const setEducationVerifying = useSetEducationVerifying() const unmountedRef = useUnmountedRef() const handleVerify = () => { - if (isPending) - return - mutateAsync().then((res) => { - setEducationVerifying(null) - if (unmountedRef.current) - return - router.push(`/education-apply?token=${res.token}`) - }).catch(() => { - setShowModal(true) - }) + if (isPending) return + mutateAsync() + .then((res) => { + setEducationVerifying(null) + if (unmountedRef.current) return + router.push(`/education-apply?token=${res.token}`) + }) + .catch(() => { + setShowModal(true) + }) } useEffect(() => { // setShowAccountSettingModal would prevent navigation - if (path.startsWith('/education-apply')) - setShowAccountSettingModal(null) + if (path.startsWith('/education-apply')) setShowAccountSettingModal(null) }, [path, setShowAccountSettingModal]) return ( <div className="relative rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn"> <div className="p-6 pb-2"> - {plan.type === Plan.sandbox && ( - <Sandbox /> - )} - {plan.type === Plan.professional && ( - <Professional /> - )} - {plan.type === Plan.team && ( - <Team /> - )} - {isEnterprisePlan && ( - <Enterprise /> - )} + {plan.type === Plan.sandbox && <Sandbox />} + {plan.type === Plan.professional && <Professional />} + {plan.type === Plan.team && <Team />} + {isEnterprisePlan && <Enterprise />} <div className="mt-1 flex items-center"> <div className="grow"> <div className="mb-1 flex items-center gap-1"> - <div className="system-md-semibold-uppercase text-text-primary">{t($ => $[`plans.${type}.name`], { ns: 'billing' })}</div> + <div className="system-md-semibold-uppercase text-text-primary"> + {t(($) => $[`plans.${type}.name`], { ns: 'billing' })} + </div> + </div> + <div className="system-xs-regular text-util-colors-gray-gray-600"> + {t(($) => $[`plans.${type}.for`], { ns: 'billing' })} </div> - <div className="system-xs-regular text-util-colors-gray-gray-600">{t($ => $[`plans.${type}.for`], { ns: 'billing' })}</div> </div> <div className="flex shrink-0 items-center gap-1"> - {IS_CLOUD_EDITION && enableEducationPlan && (!isEducationAccount || isAboutToExpire) && ( - <Button variant="ghost" onClick={handleVerify} disabled={isPending}> - <span className="mr-1 i-ri-graduation-cap-line size-4" /> - {t($ => $.toVerified, { ns: 'education' })} - {isPending && <Loading className="ml-1 animate-spin-slow" />} - </Button> - )} - {IS_CLOUD_EDITION && enableEducationPlan && isEducationAccount && type === Plan.sandbox && canManageBilling && ( - <Button variant="ghost" onClick={handleEducationDiscount} disabled={isEducationDiscountLoading}> - <span className="mr-1 i-ri-graduation-cap-line size-4" /> - {t($ => $.useEducationDiscount, { ns: 'education' })} - {isEducationDiscountLoading && <Loading className="ml-1 animate-spin-slow" />} - </Button> - )} + {IS_CLOUD_EDITION && + enableEducationPlan && + (!isEducationAccount || isAboutToExpire) && ( + <Button variant="ghost" onClick={handleVerify} disabled={isPending}> + <span className="mr-1 i-ri-graduation-cap-line size-4" /> + {t(($) => $.toVerified, { ns: 'education' })} + {isPending && <Loading className="ml-1 animate-spin-slow" />} + </Button> + )} + {IS_CLOUD_EDITION && + enableEducationPlan && + isEducationAccount && + type === Plan.sandbox && + canManageBilling && ( + <Button + variant="ghost" + onClick={handleEducationDiscount} + disabled={isEducationDiscountLoading} + > + <span className="mr-1 i-ri-graduation-cap-line size-4" /> + {t(($) => $.useEducationDiscount, { ns: 'education' })} + {isEducationDiscountLoading && <Loading className="ml-1 animate-spin-slow" />} + </Button> + )} {IS_CLOUD_EDITION && !isEnterprisePlan && ( - <UpgradeBtn - className="shrink-0" - isPlain={type === Plan.team} - isShort - loc={loc} - /> + <UpgradeBtn className="shrink-0" isPlain={type === Plan.team} isShort loc={loc} /> )} </div> </div> @@ -147,47 +134,50 @@ const PlanComp: FC<Props> = ({ <AppsInfo /> <UsageInfo Icon={RiGroupLine} - name={t($ => $['usagePage.teamMembers'], { ns: 'billing' })} + name={t(($) => $['usagePage.teamMembers'], { ns: 'billing' })} usage={usage.teamMembers} total={total.teamMembers} /> <UsageInfo Icon={RiBook2Line} - name={t($ => $['usagePage.documentsUploadQuota'], { ns: 'billing' })} + name={t(($) => $['usagePage.documentsUploadQuota'], { ns: 'billing' })} usage={usage.documentsUploadQuota} total={total.documentsUploadQuota} /> <VectorSpaceInfo /> <UsageInfo Icon={RiFileEditLine} - name={t($ => $['usagePage.annotationQuota'], { ns: 'billing' })} + name={t(($) => $['usagePage.annotationQuota'], { ns: 'billing' })} usage={usage.annotatedResponse} total={total.annotatedResponse} /> <UsageInfo Icon={TriggerAll} - name={t($ => $['usagePage.triggerEvents'], { ns: 'billing' })} + name={t(($) => $['usagePage.triggerEvents'], { ns: 'billing' })} usage={usage.triggerEvents} total={total.triggerEvents} - tooltip={t($ => $['plansCommon.triggerEvents.tooltip'], { ns: 'billing' }) as string} + tooltip={t(($) => $['plansCommon.triggerEvents.tooltip'], { ns: 'billing' }) as string} resetInDays={triggerEventsResetInDays} /> <UsageInfo Icon={ApiAggregate} - name={t($ => $['plansCommon.apiRateLimit'], { ns: 'billing' })} + name={t(($) => $['plansCommon.apiRateLimit'], { ns: 'billing' })} usage={usage.apiRateLimit} total={total.apiRateLimit} - tooltip={total.apiRateLimit === NUM_INFINITE ? undefined : t($ => $['plansCommon.apiRateLimitTooltip'], { ns: 'billing' }) as string} + tooltip={ + total.apiRateLimit === NUM_INFINITE + ? undefined + : (t(($) => $['plansCommon.apiRateLimitTooltip'], { ns: 'billing' }) as string) + } resetInDays={apiRateLimitResetInDays} /> - </div> <VerifyStateModal showLink email={userProfileEmail} isShow={showModal} - title={t($ => $.rejectTitle, { ns: 'education' })} - content={t($ => $.rejectContent, { ns: 'education' })} + title={t(($) => $.rejectTitle, { ns: 'education' })} + content={t(($) => $.rejectContent, { ns: 'education' })} onConfirm={() => setShowModal(false)} onCancel={() => setShowModal(false)} /> diff --git a/web/app/components/billing/pricing/__tests__/dialog.spec.tsx b/web/app/components/billing/pricing/__tests__/dialog.spec.tsx index 3c0db35cf42796..1b09ee62e3a5f1 100644 --- a/web/app/components/billing/pricing/__tests__/dialog.spec.tsx +++ b/web/app/components/billing/pricing/__tests__/dialog.spec.tsx @@ -21,14 +21,16 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({ latestOnOpenChange = onOpenChange return <div data-testid="dialog">{children}</div> }, - DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => ( + DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => ( <div className={className}>{children}</div> ), })) vi.mock('../header', () => ({ default: ({ onClose }: { onClose: () => void }) => ( - <button data-testid="pricing-header-close" onClick={onClose}>close</button> + <button data-testid="pricing-header-close" onClick={onClose}> + close + </button> ), })) @@ -66,7 +68,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) diff --git a/web/app/components/billing/pricing/__tests__/footer.spec.tsx b/web/app/components/billing/pricing/__tests__/footer.spec.tsx index 9a9215c1770cbc..f7d4ff4432f7e9 100644 --- a/web/app/components/billing/pricing/__tests__/footer.spec.tsx +++ b/web/app/components/billing/pricing/__tests__/footer.spec.tsx @@ -4,7 +4,17 @@ import Footer from '../footer' import { CategoryEnum } from '../types' vi.mock('@/next/link', () => ({ - default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => ( + default: ({ + children, + href, + className, + target, + }: { + children: React.ReactNode + href: string + className?: string + target?: string + }) => ( <a href={href} className={className} target={target} data-testid="pricing-link"> {children} </a> @@ -18,18 +28,31 @@ describe('Footer', () => { describe('Rendering', () => { it('should render tax tips and comparison link when in cloud category', () => { - render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.CLOUD} />) + render( + <Footer + pricingPageURL="https://dify.ai/pricing#plans-and-features" + currentCategory={CategoryEnum.CLOUD} + />, + ) expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument() expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument() - expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features') + expect(screen.getByTestId('pricing-link')).toHaveAttribute( + 'href', + 'https://dify.ai/pricing#plans-and-features', + ) expect(screen.getByText('billing.plansCommon.comparePlanAndFeatures')).toBeInTheDocument() }) }) describe('Props', () => { it('should hide tax tips when category is self-hosted', () => { - render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.SELF} />) + render( + <Footer + pricingPageURL="https://dify.ai/pricing#plans-and-features" + currentCategory={CategoryEnum.SELF} + />, + ) expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument() expect(screen.queryByText('billing.plansCommon.taxTipSecond')).not.toBeInTheDocument() diff --git a/web/app/components/billing/pricing/__tests__/index.spec.tsx b/web/app/components/billing/pricing/__tests__/index.spec.tsx index 655d930a2bb170..4b979f366f1015 100644 --- a/web/app/components/billing/pricing/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/__tests__/index.spec.tsx @@ -20,7 +20,17 @@ vi.mock('../plans/self-hosted-plan-item/list', () => ({ })) vi.mock('@/next/link', () => ({ - default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => ( + default: ({ + children, + href, + className, + target, + }: { + children: React.ReactNode + href: string + className?: string + target?: string + }) => ( <a href={href} className={className} target={target} data-testid="pricing-link"> {children} </a> @@ -49,7 +59,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -95,9 +106,14 @@ describe('Pricing', () => { it('should render pricing header and localized footer link', () => { render(<Pricing onCancel={vi.fn()} />) - expect(screen.getByRole('dialog', { name: 'billing.plansCommon.title.plans' })).toBeInTheDocument() + expect( + screen.getByRole('dialog', { name: 'billing.plansCommon.title.plans' }), + ).toBeInTheDocument() expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument() - expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features') + expect(screen.getByTestId('pricing-link')).toHaveAttribute( + 'href', + 'https://dify.ai/en/pricing#plans-and-features', + ) }) it('should default to yearly billing for education accounts', () => { @@ -155,7 +171,10 @@ describe('Pricing', () => { mockLanguage = '' render(<Pricing onCancel={vi.fn()} />) - expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features') + expect(screen.getByTestId('pricing-link')).toHaveAttribute( + 'href', + 'https://dify.ai/pricing#plans-and-features', + ) }) }) }) diff --git a/web/app/components/billing/pricing/assets/__tests__/index.spec.tsx b/web/app/components/billing/pricing/assets/__tests__/index.spec.tsx index 086c31b8d104a5..000af82af45b7d 100644 --- a/web/app/components/billing/pricing/assets/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/assets/__tests__/index.spec.tsx @@ -45,28 +45,40 @@ describe('Pricing Assets', () => { const { container } = render(<Cloud isActive />) const rects = Array.from(container.querySelectorAll('rect')) - expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true) + expect( + rects.some( + (rect) => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)', + ), + ).toBe(true) }) it('should render inactive state for Cloud', () => { const { container } = render(<Cloud isActive={false} />) const rects = Array.from(container.querySelectorAll('rect')) - expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true) + expect(rects.some((rect) => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe( + true, + ) }) it('should render active state for SelfHosted', () => { const { container } = render(<SelfHosted isActive />) const rects = Array.from(container.querySelectorAll('rect')) - expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true) + expect( + rects.some( + (rect) => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)', + ), + ).toBe(true) }) it('should render inactive state for SelfHosted', () => { const { container } = render(<SelfHosted isActive={false} />) const rects = Array.from(container.querySelectorAll('rect')) - expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true) + expect(rects.some((rect) => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe( + true, + ) }) }) }) diff --git a/web/app/components/billing/pricing/assets/cloud.tsx b/web/app/components/billing/pricing/assets/cloud.tsx index 2adeca548b1dd2..3a043e53fcbb9b 100644 --- a/web/app/components/billing/pricing/assets/cloud.tsx +++ b/web/app/components/billing/pricing/assets/cloud.tsx @@ -2,22 +2,51 @@ type CloudProps = { isActive: boolean } -const Cloud = ({ - isActive, -}: CloudProps) => { +const Cloud = ({ isActive }: CloudProps) => { const color = isActive ? 'var(--color-saas-dify-blue-accessible)' : 'var(--color-text-primary)' return ( <svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none"> <g clipPath="url(#clip0_1_4630)"> <rect y="0.5" width="4" height="4" rx="2" fill={color} /> - <rect opacity="0.18" x="6" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + x="6" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> <rect x="12" y="0.5" width="4" height="4" rx="2" fill={color} /> - <rect opacity="0.18" y="6.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + y="6.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> <rect x="6" y="6.5" width="4" height="4" rx="2" fill={color} /> - <rect opacity="0.18" x="12" y="6.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + x="12" + y="6.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> <rect y="12.5" width="4" height="4" rx="2" fill={color} /> - <rect opacity="0.18" x="6" y="12.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + x="6" + y="12.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> <rect x="12" y="12.5" width="4" height="4" rx="2" fill={color} /> </g> <defs> diff --git a/web/app/components/billing/pricing/assets/community.tsx b/web/app/components/billing/pricing/assets/community.tsx index 54f072c900bced..2352f00a30b6d2 100644 --- a/web/app/components/billing/pricing/assets/community.tsx +++ b/web/app/components/billing/pricing/assets/community.tsx @@ -2,87 +2,378 @@ const Community = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" width="60" height="61" viewBox="0 0 60 61" fill="none"> <g clipPath="url(#clip0_1_5128)"> - <path opacity="0.18" d="M0 2.5C0 1.39543 0.895431 0.5 2 0.5C3.10457 0.5 4 1.39543 4 2.5C4 3.60457 3.10457 4.5 2 4.5C0.895431 4.5 0 3.60457 0 2.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7 2.5C7 1.39543 7.89543 0.5 9 0.5C10.1046 0.5 11 1.39543 11 2.5C11 3.60457 10.1046 4.5 9 4.5C7.89543 4.5 7 3.60457 7 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M14 2.5C14 1.39543 14.8954 0.5 16 0.5C17.1046 0.5 18 1.39543 18 2.5C18 3.60457 17.1046 4.5 16 4.5C14.8954 4.5 14 3.60457 14 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M21 2.5C21 1.39543 21.8954 0.5 23 0.5C24.1046 0.5 25 1.39543 25 2.5C25 3.60457 24.1046 4.5 23 4.5C21.8954 4.5 21 3.60457 21 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28 2.5C28 1.39543 28.8954 0.5 30 0.5C31.1046 0.5 32 1.39543 32 2.5C32 3.60457 31.1046 4.5 30 4.5C28.8954 4.5 28 3.60457 28 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35 2.5C35 1.39543 35.8954 0.5 37 0.5C38.1046 0.5 39 1.39543 39 2.5C39 3.60457 38.1046 4.5 37 4.5C35.8954 4.5 35 3.60457 35 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M42 2.5C42 1.39543 42.8954 0.5 44 0.5C45.1046 0.5 46 1.39543 46 2.5C46 3.60457 45.1046 4.5 44 4.5C42.8954 4.5 42 3.60457 42 2.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49 2.5C49 1.39543 49.8954 0.5 51 0.5C52.1046 0.5 53 1.39543 53 2.5C53 3.60457 52.1046 4.5 51 4.5C49.8954 4.5 49 3.60457 49 2.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56 2.5C56 1.39543 56.8954 0.5 58 0.5C59.1046 0.5 60 1.39543 60 2.5C60 3.60457 59.1046 4.5 58 4.5C56.8954 4.5 56 3.60457 56 2.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0 9.5C0 8.39543 0.895431 7.5 2 7.5C3.10457 7.5 4 8.39543 4 9.5C4 10.6046 3.10457 11.5 2 11.5C0.895431 11.5 0 10.6046 0 9.5Z" fill="var(--color-text-quaternary)" /> - <path d="M7 9.5C7 8.39543 7.89543 7.5 9 7.5C10.1046 7.5 11 8.39543 11 9.5C11 10.6046 10.1046 11.5 9 11.5C7.89543 11.5 7 10.6046 7 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14 9.5C14 8.39543 14.8954 7.5 16 7.5C17.1046 7.5 18 8.39543 18 9.5C18 10.6046 17.1046 11.5 16 11.5C14.8954 11.5 14 10.6046 14 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21 9.5C21 8.39543 21.8954 7.5 23 7.5C24.1046 7.5 25 8.39543 25 9.5C25 10.6046 24.1046 11.5 23 11.5C21.8954 11.5 21 10.6046 21 9.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28 9.5C28 8.39543 28.8954 7.5 30 7.5C31.1046 7.5 32 8.39543 32 9.5C32 10.6046 31.1046 11.5 30 11.5C28.8954 11.5 28 10.6046 28 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M35 9.5C35 8.39543 35.8954 7.5 37 7.5C38.1046 7.5 39 8.39543 39 9.5C39 10.6046 38.1046 11.5 37 11.5C35.8954 11.5 35 10.6046 35 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M42 9.5C42 8.39543 42.8954 7.5 44 7.5C45.1046 7.5 46 8.39543 46 9.5C46 10.6046 45.1046 11.5 44 11.5C42.8954 11.5 42 10.6046 42 9.5Z" fill="var(--color-text-quaternary)" /> - <path d="M49 9.5C49 8.39543 49.8954 7.5 51 7.5C52.1046 7.5 53 8.39543 53 9.5C53 10.6046 52.1046 11.5 51 11.5C49.8954 11.5 49 10.6046 49 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56 9.5C56 8.39543 56.8954 7.5 58 7.5C59.1046 7.5 60 8.39543 60 9.5C60 10.6046 59.1046 11.5 58 11.5C56.8954 11.5 56 10.6046 56 9.5Z" fill="var(--color-text-quaternary)" /> - <path d="M0 16.5C0 15.3954 0.895431 14.5 2 14.5C3.10457 14.5 4 15.3954 4 16.5C4 17.6046 3.10457 18.5 2 18.5C0.895431 18.5 0 17.6046 0 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M7 16.5C7 15.3954 7.89543 14.5 9 14.5C10.1046 14.5 11 15.3954 11 16.5C11 17.6046 10.1046 18.5 9 18.5C7.89543 18.5 7 17.6046 7 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M14 16.5C14 15.3954 14.8954 14.5 16 14.5C17.1046 14.5 18 15.3954 18 16.5C18 17.6046 17.1046 18.5 16 18.5C14.8954 18.5 14 17.6046 14 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M21 16.5C21 15.3954 21.8954 14.5 23 14.5C24.1046 14.5 25 15.3954 25 16.5C25 17.6046 24.1046 18.5 23 18.5C21.8954 18.5 21 17.6046 21 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28 16.5C28 15.3954 28.8954 14.5 30 14.5C31.1046 14.5 32 15.3954 32 16.5C32 17.6046 31.1046 18.5 30 18.5C28.8954 18.5 28 17.6046 28 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35 16.5C35 15.3954 35.8954 14.5 37 14.5C38.1046 14.5 39 15.3954 39 16.5C39 17.6046 38.1046 18.5 37 18.5C35.8954 18.5 35 17.6046 35 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M42 16.5C42 15.3954 42.8954 14.5 44 14.5C45.1046 14.5 46 15.3954 46 16.5C46 17.6046 45.1046 18.5 44 18.5C42.8954 18.5 42 17.6046 42 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M49 16.5C49 15.3954 49.8954 14.5 51 14.5C52.1046 14.5 53 15.3954 53 16.5C53 17.6046 52.1046 18.5 51 18.5C49.8954 18.5 49 17.6046 49 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M56 16.5C56 15.3954 56.8954 14.5 58 14.5C59.1046 14.5 60 15.3954 60 16.5C60 17.6046 59.1046 18.5 58 18.5C56.8954 18.5 56 17.6046 56 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M0 23.5C0 22.3954 0.895431 21.5 2 21.5C3.10457 21.5 4 22.3954 4 23.5C4 24.6046 3.10457 25.5 2 25.5C0.895431 25.5 0 24.6046 0 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7 23.5C7 22.3954 7.89543 21.5 9 21.5C10.1046 21.5 11 22.3954 11 23.5C11 24.6046 10.1046 25.5 9 25.5C7.89543 25.5 7 24.6046 7 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14 23.5C14 22.3954 14.8954 21.5 16 21.5C17.1046 21.5 18 22.3954 18 23.5C18 24.6046 17.1046 25.5 16 25.5C14.8954 25.5 14 24.6046 14 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21 23.5C21 22.3954 21.8954 21.5 23 21.5C24.1046 21.5 25 22.3954 25 23.5C25 24.6046 24.1046 25.5 23 25.5C21.8954 25.5 21 24.6046 21 23.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28 23.5C28 22.3954 28.8954 21.5 30 21.5C31.1046 21.5 32 22.3954 32 23.5C32 24.6046 31.1046 25.5 30 25.5C28.8954 25.5 28 24.6046 28 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M35 23.5C35 22.3954 35.8954 21.5 37 21.5C38.1046 21.5 39 22.3954 39 23.5C39 24.6046 38.1046 25.5 37 25.5C35.8954 25.5 35 24.6046 35 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M42 23.5C42 22.3954 42.8954 21.5 44 21.5C45.1046 21.5 46 22.3954 46 23.5C46 24.6046 45.1046 25.5 44 25.5C42.8954 25.5 42 24.6046 42 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49 23.5C49 22.3954 49.8954 21.5 51 21.5C52.1046 21.5 53 22.3954 53 23.5C53 24.6046 52.1046 25.5 51 25.5C49.8954 25.5 49 24.6046 49 23.5Z" fill="var(--color-text-quaternary)" /> - <path d="M56 23.5C56 22.3954 56.8954 21.5 58 21.5C59.1046 21.5 60 22.3954 60 23.5C60 24.6046 59.1046 25.5 58 25.5C56.8954 25.5 56 24.6046 56 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0 30.5C0 29.3954 0.895431 28.5 2 28.5C3.10457 28.5 4 29.3954 4 30.5C4 31.6046 3.10457 32.5 2 32.5C0.895431 32.5 0 31.6046 0 30.5Z" fill="var(--color-text-quaternary)" /> - <path d="M7 30.5C7 29.3954 7.89543 28.5 9 28.5C10.1046 28.5 11 29.3954 11 30.5C11 31.6046 10.1046 32.5 9 32.5C7.89543 32.5 7 31.6046 7 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14 30.5C14 29.3954 14.8954 28.5 16 28.5C17.1046 28.5 18 29.3954 18 30.5C18 31.6046 17.1046 32.5 16 32.5C14.8954 32.5 14 31.6046 14 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21 30.5C21 29.3954 21.8954 28.5 23 28.5C24.1046 28.5 25 29.3954 25 30.5C25 31.6046 24.1046 32.5 23 32.5C21.8954 32.5 21 31.6046 21 30.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28 30.5C28 29.3954 28.8954 28.5 30 28.5C31.1046 28.5 32 29.3954 32 30.5C32 31.6046 31.1046 32.5 30 32.5C28.8954 32.5 28 31.6046 28 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M35 30.5C35 29.3954 35.8954 28.5 37 28.5C38.1046 28.5 39 29.3954 39 30.5C39 31.6046 38.1046 32.5 37 32.5C35.8954 32.5 35 31.6046 35 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M42 30.5C42 29.3954 42.8954 28.5 44 28.5C45.1046 28.5 46 29.3954 46 30.5C46 31.6046 45.1046 32.5 44 32.5C42.8954 32.5 42 31.6046 42 30.5Z" fill="var(--color-text-quaternary)" /> - <path d="M49 30.5C49 29.3954 49.8954 28.5 51 28.5C52.1046 28.5 53 29.3954 53 30.5C53 31.6046 52.1046 32.5 51 32.5C49.8954 32.5 49 31.6046 49 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56 30.5C56 29.3954 56.8954 28.5 58 28.5C59.1046 28.5 60 29.3954 60 30.5C60 31.6046 59.1046 32.5 58 32.5C56.8954 32.5 56 31.6046 56 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0 37.5C0 36.3954 0.895431 35.5 2 35.5C3.10457 35.5 4 36.3954 4 37.5C4 38.6046 3.10457 39.5 2 39.5C0.895431 39.5 0 38.6046 0 37.5Z" fill="var(--color-text-quaternary)" /> - <path d="M7 37.5C7 36.3954 7.89543 35.5 9 35.5C10.1046 35.5 11 36.3954 11 37.5C11 38.6046 10.1046 39.5 9 39.5C7.89543 39.5 7 38.6046 7 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14 37.5C14 36.3954 14.8954 35.5 16 35.5C17.1046 35.5 18 36.3954 18 37.5C18 38.6046 17.1046 39.5 16 39.5C14.8954 39.5 14 38.6046 14 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21 37.5C21 36.3954 21.8954 35.5 23 35.5C24.1046 35.5 25 36.3954 25 37.5C25 38.6046 24.1046 39.5 23 39.5C21.8954 39.5 21 38.6046 21 37.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28 37.5C28 36.3954 28.8954 35.5 30 35.5C31.1046 35.5 32 36.3954 32 37.5C32 38.6046 31.1046 39.5 30 39.5C28.8954 39.5 28 38.6046 28 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M35 37.5C35 36.3954 35.8954 35.5 37 35.5C38.1046 35.5 39 36.3954 39 37.5C39 38.6046 38.1046 39.5 37 39.5C35.8954 39.5 35 38.6046 35 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M42 37.5C42 36.3954 42.8954 35.5 44 35.5C45.1046 35.5 46 36.3954 46 37.5C46 38.6046 45.1046 39.5 44 39.5C42.8954 39.5 42 38.6046 42 37.5Z" fill="var(--color-text-quaternary)" /> - <path d="M49 37.5C49 36.3954 49.8954 35.5 51 35.5C52.1046 35.5 53 36.3954 53 37.5C53 38.6046 52.1046 39.5 51 39.5C49.8954 39.5 49 38.6046 49 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56 37.5C56 36.3954 56.8954 35.5 58 35.5C59.1046 35.5 60 36.3954 60 37.5C60 38.6046 59.1046 39.5 58 39.5C56.8954 39.5 56 38.6046 56 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0 44.5C0 43.3954 0.895431 42.5 2 42.5C3.10457 42.5 4 43.3954 4 44.5C4 45.6046 3.10457 46.5 2 46.5C0.895431 46.5 0 45.6046 0 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7 44.5C7 43.3954 7.89543 42.5 9 42.5C10.1046 42.5 11 43.3954 11 44.5C11 45.6046 10.1046 46.5 9 46.5C7.89543 46.5 7 45.6046 7 44.5Z" fill="var(--color-text-quaternary)" /> - <path d="M14 44.5C14 43.3954 14.8954 42.5 16 42.5C17.1046 42.5 18 43.3954 18 44.5C18 45.6046 17.1046 46.5 16 46.5C14.8954 46.5 14 45.6046 14 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21 44.5C21 43.3954 21.8954 42.5 23 42.5C24.1046 42.5 25 43.3954 25 44.5C25 45.6046 24.1046 46.5 23 46.5C21.8954 46.5 21 45.6046 21 44.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28 44.5C28 43.3954 28.8954 42.5 30 42.5C31.1046 42.5 32 43.3954 32 44.5C32 45.6046 31.1046 46.5 30 46.5C28.8954 46.5 28 45.6046 28 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M35 44.5C35 43.3954 35.8954 42.5 37 42.5C38.1046 42.5 39 43.3954 39 44.5C39 45.6046 38.1046 46.5 37 46.5C35.8954 46.5 35 45.6046 35 44.5Z" fill="var(--color-text-quaternary)" /> - <path d="M42 44.5C42 43.3954 42.8954 42.5 44 42.5C45.1046 42.5 46 43.3954 46 44.5C46 45.6046 45.1046 46.5 44 46.5C42.8954 46.5 42 45.6046 42 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49 44.5C49 43.3954 49.8954 42.5 51 42.5C52.1046 42.5 53 43.3954 53 44.5C53 45.6046 52.1046 46.5 51 46.5C49.8954 46.5 49 45.6046 49 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56 44.5C56 43.3954 56.8954 42.5 58 42.5C59.1046 42.5 60 43.3954 60 44.5C60 45.6046 59.1046 46.5 58 46.5C56.8954 46.5 56 45.6046 56 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0 51.5C0 50.3954 0.895431 49.5 2 49.5C3.10457 49.5 4 50.3954 4 51.5C4 52.6046 3.10457 53.5 2 53.5C0.895431 53.5 0 52.6046 0 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7 51.5C7 50.3954 7.89543 49.5 9 49.5C10.1046 49.5 11 50.3954 11 51.5C11 52.6046 10.1046 53.5 9 53.5C7.89543 53.5 7 52.6046 7 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14 51.5C14 50.3954 14.8954 49.5 16 49.5C17.1046 49.5 18 50.3954 18 51.5C18 52.6046 17.1046 53.5 16 53.5C14.8954 53.5 14 52.6046 14 51.5Z" fill="var(--color-text-quaternary)" /> - <path d="M21 51.5C21 50.3954 21.8954 49.5 23 49.5C24.1046 49.5 25 50.3954 25 51.5C25 52.6046 24.1046 53.5 23 53.5C21.8954 53.5 21 52.6046 21 51.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28 51.5C28 50.3954 28.8954 49.5 30 49.5C31.1046 49.5 32 50.3954 32 51.5C32 52.6046 31.1046 53.5 30 53.5C28.8954 53.5 28 52.6046 28 51.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35 51.5C35 50.3954 35.8954 49.5 37 49.5C38.1046 49.5 39 50.3954 39 51.5C39 52.6046 38.1046 53.5 37 53.5C35.8954 53.5 35 52.6046 35 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M42 51.5C42 50.3954 42.8954 49.5 44 49.5C45.1046 49.5 46 50.3954 46 51.5C46 52.6046 45.1046 53.5 44 53.5C42.8954 53.5 42 52.6046 42 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49 51.5C49 50.3954 49.8954 49.5 51 49.5C52.1046 49.5 53 50.3954 53 51.5C53 52.6046 52.1046 53.5 51 53.5C49.8954 53.5 49 52.6046 49 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56 51.5C56 50.3954 56.8954 49.5 58 49.5C59.1046 49.5 60 50.3954 60 51.5C60 52.6046 59.1046 53.5 58 53.5C56.8954 53.5 56 52.6046 56 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0 58.5C0 57.3954 0.895431 56.5 2 56.5C3.10457 56.5 4 57.3954 4 58.5C4 59.6046 3.10457 60.5 2 60.5C0.895431 60.5 0 59.6046 0 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7 58.5C7 57.3954 7.89543 56.5 9 56.5C10.1046 56.5 11 57.3954 11 58.5C11 59.6046 10.1046 60.5 9 60.5C7.89543 60.5 7 59.6046 7 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14 58.5C14 57.3954 14.8954 56.5 16 56.5C17.1046 56.5 18 57.3954 18 58.5C18 59.6046 17.1046 60.5 16 60.5C14.8954 60.5 14 59.6046 14 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21 58.5C21 57.3954 21.8954 56.5 23 56.5C24.1046 56.5 25 57.3954 25 58.5C25 59.6046 24.1046 60.5 23 60.5C21.8954 60.5 21 59.6046 21 58.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28 58.5C28 57.3954 28.8954 56.5 30 56.5C31.1046 56.5 32 57.3954 32 58.5C32 59.6046 31.1046 60.5 30 60.5C28.8954 60.5 28 59.6046 28 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M35 58.5C35 57.3954 35.8954 56.5 37 56.5C38.1046 56.5 39 57.3954 39 58.5C39 59.6046 38.1046 60.5 37 60.5C35.8954 60.5 35 59.6046 35 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M42 58.5C42 57.3954 42.8954 56.5 44 56.5C45.1046 56.5 46 57.3954 46 58.5C46 59.6046 45.1046 60.5 44 60.5C42.8954 60.5 42 59.6046 42 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49 58.5C49 57.3954 49.8954 56.5 51 56.5C52.1046 56.5 53 57.3954 53 58.5C53 59.6046 52.1046 60.5 51 60.5C49.8954 60.5 49 59.6046 49 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56 58.5C56 57.3954 56.8954 56.5 58 56.5C59.1046 56.5 60 57.3954 60 58.5C60 59.6046 59.1046 60.5 58 60.5C56.8954 60.5 56 59.6046 56 58.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M0 2.5C0 1.39543 0.895431 0.5 2 0.5C3.10457 0.5 4 1.39543 4 2.5C4 3.60457 3.10457 4.5 2 4.5C0.895431 4.5 0 3.60457 0 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7 2.5C7 1.39543 7.89543 0.5 9 0.5C10.1046 0.5 11 1.39543 11 2.5C11 3.60457 10.1046 4.5 9 4.5C7.89543 4.5 7 3.60457 7 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M14 2.5C14 1.39543 14.8954 0.5 16 0.5C17.1046 0.5 18 1.39543 18 2.5C18 3.60457 17.1046 4.5 16 4.5C14.8954 4.5 14 3.60457 14 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M21 2.5C21 1.39543 21.8954 0.5 23 0.5C24.1046 0.5 25 1.39543 25 2.5C25 3.60457 24.1046 4.5 23 4.5C21.8954 4.5 21 3.60457 21 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28 2.5C28 1.39543 28.8954 0.5 30 0.5C31.1046 0.5 32 1.39543 32 2.5C32 3.60457 31.1046 4.5 30 4.5C28.8954 4.5 28 3.60457 28 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35 2.5C35 1.39543 35.8954 0.5 37 0.5C38.1046 0.5 39 1.39543 39 2.5C39 3.60457 38.1046 4.5 37 4.5C35.8954 4.5 35 3.60457 35 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M42 2.5C42 1.39543 42.8954 0.5 44 0.5C45.1046 0.5 46 1.39543 46 2.5C46 3.60457 45.1046 4.5 44 4.5C42.8954 4.5 42 3.60457 42 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49 2.5C49 1.39543 49.8954 0.5 51 0.5C52.1046 0.5 53 1.39543 53 2.5C53 3.60457 52.1046 4.5 51 4.5C49.8954 4.5 49 3.60457 49 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56 2.5C56 1.39543 56.8954 0.5 58 0.5C59.1046 0.5 60 1.39543 60 2.5C60 3.60457 59.1046 4.5 58 4.5C56.8954 4.5 56 3.60457 56 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0 9.5C0 8.39543 0.895431 7.5 2 7.5C3.10457 7.5 4 8.39543 4 9.5C4 10.6046 3.10457 11.5 2 11.5C0.895431 11.5 0 10.6046 0 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M7 9.5C7 8.39543 7.89543 7.5 9 7.5C10.1046 7.5 11 8.39543 11 9.5C11 10.6046 10.1046 11.5 9 11.5C7.89543 11.5 7 10.6046 7 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14 9.5C14 8.39543 14.8954 7.5 16 7.5C17.1046 7.5 18 8.39543 18 9.5C18 10.6046 17.1046 11.5 16 11.5C14.8954 11.5 14 10.6046 14 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21 9.5C21 8.39543 21.8954 7.5 23 7.5C24.1046 7.5 25 8.39543 25 9.5C25 10.6046 24.1046 11.5 23 11.5C21.8954 11.5 21 10.6046 21 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28 9.5C28 8.39543 28.8954 7.5 30 7.5C31.1046 7.5 32 8.39543 32 9.5C32 10.6046 31.1046 11.5 30 11.5C28.8954 11.5 28 10.6046 28 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M35 9.5C35 8.39543 35.8954 7.5 37 7.5C38.1046 7.5 39 8.39543 39 9.5C39 10.6046 38.1046 11.5 37 11.5C35.8954 11.5 35 10.6046 35 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M42 9.5C42 8.39543 42.8954 7.5 44 7.5C45.1046 7.5 46 8.39543 46 9.5C46 10.6046 45.1046 11.5 44 11.5C42.8954 11.5 42 10.6046 42 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M49 9.5C49 8.39543 49.8954 7.5 51 7.5C52.1046 7.5 53 8.39543 53 9.5C53 10.6046 52.1046 11.5 51 11.5C49.8954 11.5 49 10.6046 49 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56 9.5C56 8.39543 56.8954 7.5 58 7.5C59.1046 7.5 60 8.39543 60 9.5C60 10.6046 59.1046 11.5 58 11.5C56.8954 11.5 56 10.6046 56 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M0 16.5C0 15.3954 0.895431 14.5 2 14.5C3.10457 14.5 4 15.3954 4 16.5C4 17.6046 3.10457 18.5 2 18.5C0.895431 18.5 0 17.6046 0 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M7 16.5C7 15.3954 7.89543 14.5 9 14.5C10.1046 14.5 11 15.3954 11 16.5C11 17.6046 10.1046 18.5 9 18.5C7.89543 18.5 7 17.6046 7 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M14 16.5C14 15.3954 14.8954 14.5 16 14.5C17.1046 14.5 18 15.3954 18 16.5C18 17.6046 17.1046 18.5 16 18.5C14.8954 18.5 14 17.6046 14 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M21 16.5C21 15.3954 21.8954 14.5 23 14.5C24.1046 14.5 25 15.3954 25 16.5C25 17.6046 24.1046 18.5 23 18.5C21.8954 18.5 21 17.6046 21 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28 16.5C28 15.3954 28.8954 14.5 30 14.5C31.1046 14.5 32 15.3954 32 16.5C32 17.6046 31.1046 18.5 30 18.5C28.8954 18.5 28 17.6046 28 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35 16.5C35 15.3954 35.8954 14.5 37 14.5C38.1046 14.5 39 15.3954 39 16.5C39 17.6046 38.1046 18.5 37 18.5C35.8954 18.5 35 17.6046 35 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M42 16.5C42 15.3954 42.8954 14.5 44 14.5C45.1046 14.5 46 15.3954 46 16.5C46 17.6046 45.1046 18.5 44 18.5C42.8954 18.5 42 17.6046 42 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M49 16.5C49 15.3954 49.8954 14.5 51 14.5C52.1046 14.5 53 15.3954 53 16.5C53 17.6046 52.1046 18.5 51 18.5C49.8954 18.5 49 17.6046 49 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M56 16.5C56 15.3954 56.8954 14.5 58 14.5C59.1046 14.5 60 15.3954 60 16.5C60 17.6046 59.1046 18.5 58 18.5C56.8954 18.5 56 17.6046 56 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M0 23.5C0 22.3954 0.895431 21.5 2 21.5C3.10457 21.5 4 22.3954 4 23.5C4 24.6046 3.10457 25.5 2 25.5C0.895431 25.5 0 24.6046 0 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7 23.5C7 22.3954 7.89543 21.5 9 21.5C10.1046 21.5 11 22.3954 11 23.5C11 24.6046 10.1046 25.5 9 25.5C7.89543 25.5 7 24.6046 7 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14 23.5C14 22.3954 14.8954 21.5 16 21.5C17.1046 21.5 18 22.3954 18 23.5C18 24.6046 17.1046 25.5 16 25.5C14.8954 25.5 14 24.6046 14 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21 23.5C21 22.3954 21.8954 21.5 23 21.5C24.1046 21.5 25 22.3954 25 23.5C25 24.6046 24.1046 25.5 23 25.5C21.8954 25.5 21 24.6046 21 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28 23.5C28 22.3954 28.8954 21.5 30 21.5C31.1046 21.5 32 22.3954 32 23.5C32 24.6046 31.1046 25.5 30 25.5C28.8954 25.5 28 24.6046 28 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M35 23.5C35 22.3954 35.8954 21.5 37 21.5C38.1046 21.5 39 22.3954 39 23.5C39 24.6046 38.1046 25.5 37 25.5C35.8954 25.5 35 24.6046 35 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M42 23.5C42 22.3954 42.8954 21.5 44 21.5C45.1046 21.5 46 22.3954 46 23.5C46 24.6046 45.1046 25.5 44 25.5C42.8954 25.5 42 24.6046 42 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49 23.5C49 22.3954 49.8954 21.5 51 21.5C52.1046 21.5 53 22.3954 53 23.5C53 24.6046 52.1046 25.5 51 25.5C49.8954 25.5 49 24.6046 49 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M56 23.5C56 22.3954 56.8954 21.5 58 21.5C59.1046 21.5 60 22.3954 60 23.5C60 24.6046 59.1046 25.5 58 25.5C56.8954 25.5 56 24.6046 56 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0 30.5C0 29.3954 0.895431 28.5 2 28.5C3.10457 28.5 4 29.3954 4 30.5C4 31.6046 3.10457 32.5 2 32.5C0.895431 32.5 0 31.6046 0 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M7 30.5C7 29.3954 7.89543 28.5 9 28.5C10.1046 28.5 11 29.3954 11 30.5C11 31.6046 10.1046 32.5 9 32.5C7.89543 32.5 7 31.6046 7 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14 30.5C14 29.3954 14.8954 28.5 16 28.5C17.1046 28.5 18 29.3954 18 30.5C18 31.6046 17.1046 32.5 16 32.5C14.8954 32.5 14 31.6046 14 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21 30.5C21 29.3954 21.8954 28.5 23 28.5C24.1046 28.5 25 29.3954 25 30.5C25 31.6046 24.1046 32.5 23 32.5C21.8954 32.5 21 31.6046 21 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28 30.5C28 29.3954 28.8954 28.5 30 28.5C31.1046 28.5 32 29.3954 32 30.5C32 31.6046 31.1046 32.5 30 32.5C28.8954 32.5 28 31.6046 28 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M35 30.5C35 29.3954 35.8954 28.5 37 28.5C38.1046 28.5 39 29.3954 39 30.5C39 31.6046 38.1046 32.5 37 32.5C35.8954 32.5 35 31.6046 35 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M42 30.5C42 29.3954 42.8954 28.5 44 28.5C45.1046 28.5 46 29.3954 46 30.5C46 31.6046 45.1046 32.5 44 32.5C42.8954 32.5 42 31.6046 42 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M49 30.5C49 29.3954 49.8954 28.5 51 28.5C52.1046 28.5 53 29.3954 53 30.5C53 31.6046 52.1046 32.5 51 32.5C49.8954 32.5 49 31.6046 49 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56 30.5C56 29.3954 56.8954 28.5 58 28.5C59.1046 28.5 60 29.3954 60 30.5C60 31.6046 59.1046 32.5 58 32.5C56.8954 32.5 56 31.6046 56 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0 37.5C0 36.3954 0.895431 35.5 2 35.5C3.10457 35.5 4 36.3954 4 37.5C4 38.6046 3.10457 39.5 2 39.5C0.895431 39.5 0 38.6046 0 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M7 37.5C7 36.3954 7.89543 35.5 9 35.5C10.1046 35.5 11 36.3954 11 37.5C11 38.6046 10.1046 39.5 9 39.5C7.89543 39.5 7 38.6046 7 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14 37.5C14 36.3954 14.8954 35.5 16 35.5C17.1046 35.5 18 36.3954 18 37.5C18 38.6046 17.1046 39.5 16 39.5C14.8954 39.5 14 38.6046 14 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21 37.5C21 36.3954 21.8954 35.5 23 35.5C24.1046 35.5 25 36.3954 25 37.5C25 38.6046 24.1046 39.5 23 39.5C21.8954 39.5 21 38.6046 21 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28 37.5C28 36.3954 28.8954 35.5 30 35.5C31.1046 35.5 32 36.3954 32 37.5C32 38.6046 31.1046 39.5 30 39.5C28.8954 39.5 28 38.6046 28 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M35 37.5C35 36.3954 35.8954 35.5 37 35.5C38.1046 35.5 39 36.3954 39 37.5C39 38.6046 38.1046 39.5 37 39.5C35.8954 39.5 35 38.6046 35 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M42 37.5C42 36.3954 42.8954 35.5 44 35.5C45.1046 35.5 46 36.3954 46 37.5C46 38.6046 45.1046 39.5 44 39.5C42.8954 39.5 42 38.6046 42 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M49 37.5C49 36.3954 49.8954 35.5 51 35.5C52.1046 35.5 53 36.3954 53 37.5C53 38.6046 52.1046 39.5 51 39.5C49.8954 39.5 49 38.6046 49 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56 37.5C56 36.3954 56.8954 35.5 58 35.5C59.1046 35.5 60 36.3954 60 37.5C60 38.6046 59.1046 39.5 58 39.5C56.8954 39.5 56 38.6046 56 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0 44.5C0 43.3954 0.895431 42.5 2 42.5C3.10457 42.5 4 43.3954 4 44.5C4 45.6046 3.10457 46.5 2 46.5C0.895431 46.5 0 45.6046 0 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7 44.5C7 43.3954 7.89543 42.5 9 42.5C10.1046 42.5 11 43.3954 11 44.5C11 45.6046 10.1046 46.5 9 46.5C7.89543 46.5 7 45.6046 7 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M14 44.5C14 43.3954 14.8954 42.5 16 42.5C17.1046 42.5 18 43.3954 18 44.5C18 45.6046 17.1046 46.5 16 46.5C14.8954 46.5 14 45.6046 14 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21 44.5C21 43.3954 21.8954 42.5 23 42.5C24.1046 42.5 25 43.3954 25 44.5C25 45.6046 24.1046 46.5 23 46.5C21.8954 46.5 21 45.6046 21 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28 44.5C28 43.3954 28.8954 42.5 30 42.5C31.1046 42.5 32 43.3954 32 44.5C32 45.6046 31.1046 46.5 30 46.5C28.8954 46.5 28 45.6046 28 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M35 44.5C35 43.3954 35.8954 42.5 37 42.5C38.1046 42.5 39 43.3954 39 44.5C39 45.6046 38.1046 46.5 37 46.5C35.8954 46.5 35 45.6046 35 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M42 44.5C42 43.3954 42.8954 42.5 44 42.5C45.1046 42.5 46 43.3954 46 44.5C46 45.6046 45.1046 46.5 44 46.5C42.8954 46.5 42 45.6046 42 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49 44.5C49 43.3954 49.8954 42.5 51 42.5C52.1046 42.5 53 43.3954 53 44.5C53 45.6046 52.1046 46.5 51 46.5C49.8954 46.5 49 45.6046 49 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56 44.5C56 43.3954 56.8954 42.5 58 42.5C59.1046 42.5 60 43.3954 60 44.5C60 45.6046 59.1046 46.5 58 46.5C56.8954 46.5 56 45.6046 56 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0 51.5C0 50.3954 0.895431 49.5 2 49.5C3.10457 49.5 4 50.3954 4 51.5C4 52.6046 3.10457 53.5 2 53.5C0.895431 53.5 0 52.6046 0 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7 51.5C7 50.3954 7.89543 49.5 9 49.5C10.1046 49.5 11 50.3954 11 51.5C11 52.6046 10.1046 53.5 9 53.5C7.89543 53.5 7 52.6046 7 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14 51.5C14 50.3954 14.8954 49.5 16 49.5C17.1046 49.5 18 50.3954 18 51.5C18 52.6046 17.1046 53.5 16 53.5C14.8954 53.5 14 52.6046 14 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M21 51.5C21 50.3954 21.8954 49.5 23 49.5C24.1046 49.5 25 50.3954 25 51.5C25 52.6046 24.1046 53.5 23 53.5C21.8954 53.5 21 52.6046 21 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28 51.5C28 50.3954 28.8954 49.5 30 49.5C31.1046 49.5 32 50.3954 32 51.5C32 52.6046 31.1046 53.5 30 53.5C28.8954 53.5 28 52.6046 28 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35 51.5C35 50.3954 35.8954 49.5 37 49.5C38.1046 49.5 39 50.3954 39 51.5C39 52.6046 38.1046 53.5 37 53.5C35.8954 53.5 35 52.6046 35 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M42 51.5C42 50.3954 42.8954 49.5 44 49.5C45.1046 49.5 46 50.3954 46 51.5C46 52.6046 45.1046 53.5 44 53.5C42.8954 53.5 42 52.6046 42 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49 51.5C49 50.3954 49.8954 49.5 51 49.5C52.1046 49.5 53 50.3954 53 51.5C53 52.6046 52.1046 53.5 51 53.5C49.8954 53.5 49 52.6046 49 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56 51.5C56 50.3954 56.8954 49.5 58 49.5C59.1046 49.5 60 50.3954 60 51.5C60 52.6046 59.1046 53.5 58 53.5C56.8954 53.5 56 52.6046 56 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0 58.5C0 57.3954 0.895431 56.5 2 56.5C3.10457 56.5 4 57.3954 4 58.5C4 59.6046 3.10457 60.5 2 60.5C0.895431 60.5 0 59.6046 0 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7 58.5C7 57.3954 7.89543 56.5 9 56.5C10.1046 56.5 11 57.3954 11 58.5C11 59.6046 10.1046 60.5 9 60.5C7.89543 60.5 7 59.6046 7 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14 58.5C14 57.3954 14.8954 56.5 16 56.5C17.1046 56.5 18 57.3954 18 58.5C18 59.6046 17.1046 60.5 16 60.5C14.8954 60.5 14 59.6046 14 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21 58.5C21 57.3954 21.8954 56.5 23 56.5C24.1046 56.5 25 57.3954 25 58.5C25 59.6046 24.1046 60.5 23 60.5C21.8954 60.5 21 59.6046 21 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28 58.5C28 57.3954 28.8954 56.5 30 56.5C31.1046 56.5 32 57.3954 32 58.5C32 59.6046 31.1046 60.5 30 60.5C28.8954 60.5 28 59.6046 28 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M35 58.5C35 57.3954 35.8954 56.5 37 56.5C38.1046 56.5 39 57.3954 39 58.5C39 59.6046 38.1046 60.5 37 60.5C35.8954 60.5 35 59.6046 35 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M42 58.5C42 57.3954 42.8954 56.5 44 56.5C45.1046 56.5 46 57.3954 46 58.5C46 59.6046 45.1046 60.5 44 60.5C42.8954 60.5 42 59.6046 42 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49 58.5C49 57.3954 49.8954 56.5 51 56.5C52.1046 56.5 53 57.3954 53 58.5C53 59.6046 52.1046 60.5 51 60.5C49.8954 60.5 49 59.6046 49 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56 58.5C56 57.3954 56.8954 56.5 58 56.5C59.1046 56.5 60 57.3954 60 58.5C60 59.6046 59.1046 60.5 58 60.5C56.8954 60.5 56 59.6046 56 58.5Z" + fill="var(--color-text-quaternary)" + /> </g> <defs> <clipPath id="clip0_1_5128"> diff --git a/web/app/components/billing/pricing/assets/enterprise-noise.tsx b/web/app/components/billing/pricing/assets/enterprise-noise.tsx index 3c6e70d1ae5c45..226931119c009c 100644 --- a/web/app/components/billing/pricing/assets/enterprise-noise.tsx +++ b/web/app/components/billing/pricing/assets/enterprise-noise.tsx @@ -1,15 +1,42 @@ const EnterpriseNoise = () => { return ( - <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="148" viewBox="0 0 100% 148" fill="none"> + <svg + xmlns="http://www.w3.org/2000/svg" + width="100%" + height="148" + viewBox="0 0 100% 148" + fill="none" + > <g opacity="0.05" filter="url(#filter0_g_1_5499)"> <rect y="0" width="100%" height="96" fill="var(--color-saas-dify-blue-accessible)" /> </g> <defs> - <filter id="filter0_g_1_5499" x="0" y="0" width="100%" height="296" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"> + <filter + id="filter0_g_1_5499" + x="0" + y="0" + width="100%" + height="296" + filterUnits="userSpaceOnUse" + colorInterpolationFilters="sRGB" + > <feFlood floodOpacity="0" result="BackgroundImageFix" /> <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> - <feTurbulence type="fractalNoise" baseFrequency="0.625 0.625" numOctaves="3" seed="5427" /> - <feDisplacementMap in="shape" scale="200" xChannelSelector="R" yChannelSelector="G" result="displacedImage" width="100%" height="100%" /> + <feTurbulence + type="fractalNoise" + baseFrequency="0.625 0.625" + numOctaves="3" + seed="5427" + /> + <feDisplacementMap + in="shape" + scale="200" + xChannelSelector="R" + yChannelSelector="G" + result="displacedImage" + width="100%" + height="100%" + /> <feMerge result="effect1_texture_1_5499"> <feMergeNode in="displacedImage" /> </feMerge> diff --git a/web/app/components/billing/pricing/assets/enterprise.tsx b/web/app/components/billing/pricing/assets/enterprise.tsx index 70ab08f5a57bad..0a846b10464022 100644 --- a/web/app/components/billing/pricing/assets/enterprise.tsx +++ b/web/app/components/billing/pricing/assets/enterprise.tsx @@ -2,87 +2,373 @@ const Enterprise = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" width="61" height="61" viewBox="0 0 61 61" fill="none"> <g clipPath="url(#clip0_1_5366)"> - <path d="M0.333496 2.5C0.333496 1.39543 1.22893 0.5 2.3335 0.5C3.43807 0.5 4.3335 1.39543 4.3335 2.5C4.3335 3.60457 3.43807 4.5 2.3335 4.5C1.22893 4.5 0.333496 3.60457 0.333496 2.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M7.3335 2.5C7.3335 1.39543 8.22893 0.5 9.3335 0.5C10.4381 0.5 11.3335 1.39543 11.3335 2.5C11.3335 3.60457 10.4381 4.5 9.3335 4.5C8.22893 4.5 7.3335 3.60457 7.3335 2.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M14.3335 2.5C14.3335 1.39543 15.2289 0.5 16.3335 0.5C17.4381 0.5 18.3335 1.39543 18.3335 2.5C18.3335 3.60457 17.4381 4.5 16.3335 4.5C15.2289 4.5 14.3335 3.60457 14.3335 2.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M21.3335 2.5C21.3335 1.39543 22.2289 0.5 23.3335 0.5C24.4381 0.5 25.3335 1.39543 25.3335 2.5C25.3335 3.60457 24.4381 4.5 23.3335 4.5C22.2289 4.5 21.3335 3.60457 21.3335 2.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M28.3335 2.5C28.3335 1.39543 29.2289 0.5 30.3335 0.5C31.4381 0.5 32.3335 1.39543 32.3335 2.5C32.3335 3.60457 31.4381 4.5 30.3335 4.5C29.2289 4.5 28.3335 3.60457 28.3335 2.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M35.3335 2.5C35.3335 1.39543 36.2289 0.5 37.3335 0.5C38.4381 0.5 39.3335 1.39543 39.3335 2.5C39.3335 3.60457 38.4381 4.5 37.3335 4.5C36.2289 4.5 35.3335 3.60457 35.3335 2.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M42.3335 2.5C42.3335 1.39543 43.2289 0.5 44.3335 0.5C45.4381 0.5 46.3335 1.39543 46.3335 2.5C46.3335 3.60457 45.4381 4.5 44.3335 4.5C43.2289 4.5 42.3335 3.60457 42.3335 2.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M49.3335 2.5C49.3335 1.39543 50.2289 0.5 51.3335 0.5C52.4381 0.5 53.3335 1.39543 53.3335 2.5C53.3335 3.60457 52.4381 4.5 51.3335 4.5C50.2289 4.5 49.3335 3.60457 49.3335 2.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M56.3335 2.5C56.3335 1.39543 57.2289 0.5 58.3335 0.5C59.4381 0.5 60.3335 1.39543 60.3335 2.5C60.3335 3.60457 59.4381 4.5 58.3335 4.5C57.2289 4.5 56.3335 3.60457 56.3335 2.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M0.333496 9.5C0.333496 8.39543 1.22893 7.5 2.3335 7.5C3.43807 7.5 4.3335 8.39543 4.3335 9.5C4.3335 10.6046 3.43807 11.5 2.3335 11.5C1.22893 11.5 0.333496 10.6046 0.333496 9.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M7.3335 9.5C7.3335 8.39543 8.22893 7.5 9.3335 7.5C10.4381 7.5 11.3335 8.39543 11.3335 9.5C11.3335 10.6046 10.4381 11.5 9.3335 11.5C8.22893 11.5 7.3335 10.6046 7.3335 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3335 9.5C14.3335 8.39543 15.2289 7.5 16.3335 7.5C17.4381 7.5 18.3335 8.39543 18.3335 9.5C18.3335 10.6046 17.4381 11.5 16.3335 11.5C15.2289 11.5 14.3335 10.6046 14.3335 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21.3335 9.5C21.3335 8.39543 22.2289 7.5 23.3335 7.5C24.4381 7.5 25.3335 8.39543 25.3335 9.5C25.3335 10.6046 24.4381 11.5 23.3335 11.5C22.2289 11.5 21.3335 10.6046 21.3335 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.3335 9.5C28.3335 8.39543 29.2289 7.5 30.3335 7.5C31.4381 7.5 32.3335 8.39543 32.3335 9.5C32.3335 10.6046 31.4381 11.5 30.3335 11.5C29.2289 11.5 28.3335 10.6046 28.3335 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M35.3335 9.5C35.3335 8.39543 36.2289 7.5 37.3335 7.5C38.4381 7.5 39.3335 8.39543 39.3335 9.5C39.3335 10.6046 38.4381 11.5 37.3335 11.5C36.2289 11.5 35.3335 10.6046 35.3335 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M42.3335 9.5C42.3335 8.39543 43.2289 7.5 44.3335 7.5C45.4381 7.5 46.3335 8.39543 46.3335 9.5C46.3335 10.6046 45.4381 11.5 44.3335 11.5C43.2289 11.5 42.3335 10.6046 42.3335 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.3335 9.5C49.3335 8.39543 50.2289 7.5 51.3335 7.5C52.4381 7.5 53.3335 8.39543 53.3335 9.5C53.3335 10.6046 52.4381 11.5 51.3335 11.5C50.2289 11.5 49.3335 10.6046 49.3335 9.5Z" fill="var(--color-text-quaternary)" /> - <path d="M56.3335 9.5C56.3335 8.39543 57.2289 7.5 58.3335 7.5C59.4381 7.5 60.3335 8.39543 60.3335 9.5C60.3335 10.6046 59.4381 11.5 58.3335 11.5C57.2289 11.5 56.3335 10.6046 56.3335 9.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M0.333496 16.5C0.333496 15.3954 1.22893 14.5 2.3335 14.5C3.43807 14.5 4.3335 15.3954 4.3335 16.5C4.3335 17.6046 3.43807 18.5 2.3335 18.5C1.22893 18.5 0.333496 17.6046 0.333496 16.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M7.3335 16.5C7.3335 15.3954 8.22893 14.5 9.3335 14.5C10.4381 14.5 11.3335 15.3954 11.3335 16.5C11.3335 17.6046 10.4381 18.5 9.3335 18.5C8.22893 18.5 7.3335 17.6046 7.3335 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M14.3335 16.5C14.3335 15.3954 15.2289 14.5 16.3335 14.5C17.4381 14.5 18.3335 15.3954 18.3335 16.5C18.3335 17.6046 17.4381 18.5 16.3335 18.5C15.2289 18.5 14.3335 17.6046 14.3335 16.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M21.3335 16.5C21.3335 15.3954 22.2289 14.5 23.3335 14.5C24.4381 14.5 25.3335 15.3954 25.3335 16.5C25.3335 17.6046 24.4381 18.5 23.3335 18.5C22.2289 18.5 21.3335 17.6046 21.3335 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28.3335 16.5C28.3335 15.3954 29.2289 14.5 30.3335 14.5C31.4381 14.5 32.3335 15.3954 32.3335 16.5C32.3335 17.6046 31.4381 18.5 30.3335 18.5C29.2289 18.5 28.3335 17.6046 28.3335 16.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M35.3335 16.5C35.3335 15.3954 36.2289 14.5 37.3335 14.5C38.4381 14.5 39.3335 15.3954 39.3335 16.5C39.3335 17.6046 38.4381 18.5 37.3335 18.5C36.2289 18.5 35.3335 17.6046 35.3335 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M42.3335 16.5C42.3335 15.3954 43.2289 14.5 44.3335 14.5C45.4381 14.5 46.3335 15.3954 46.3335 16.5C46.3335 17.6046 45.4381 18.5 44.3335 18.5C43.2289 18.5 42.3335 17.6046 42.3335 16.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M49.3335 16.5C49.3335 15.3954 50.2289 14.5 51.3335 14.5C52.4381 14.5 53.3335 15.3954 53.3335 16.5C53.3335 17.6046 52.4381 18.5 51.3335 18.5C50.2289 18.5 49.3335 17.6046 49.3335 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M56.3335 16.5C56.3335 15.3954 57.2289 14.5 58.3335 14.5C59.4381 14.5 60.3335 15.3954 60.3335 16.5C60.3335 17.6046 59.4381 18.5 58.3335 18.5C57.2289 18.5 56.3335 17.6046 56.3335 16.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M0.333496 23.5C0.333496 22.3954 1.22893 21.5 2.3335 21.5C3.43807 21.5 4.3335 22.3954 4.3335 23.5C4.3335 24.6046 3.43807 25.5 2.3335 25.5C1.22893 25.5 0.333496 24.6046 0.333496 23.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M7.3335 23.5C7.3335 22.3954 8.22893 21.5 9.3335 21.5C10.4381 21.5 11.3335 22.3954 11.3335 23.5C11.3335 24.6046 10.4381 25.5 9.3335 25.5C8.22893 25.5 7.3335 24.6046 7.3335 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3335 23.5C14.3335 22.3954 15.2289 21.5 16.3335 21.5C17.4381 21.5 18.3335 22.3954 18.3335 23.5C18.3335 24.6046 17.4381 25.5 16.3335 25.5C15.2289 25.5 14.3335 24.6046 14.3335 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21.3335 23.5C21.3335 22.3954 22.2289 21.5 23.3335 21.5C24.4381 21.5 25.3335 22.3954 25.3335 23.5C25.3335 24.6046 24.4381 25.5 23.3335 25.5C22.2289 25.5 21.3335 24.6046 21.3335 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.3335 23.5C28.3335 22.3954 29.2289 21.5 30.3335 21.5C31.4381 21.5 32.3335 22.3954 32.3335 23.5C32.3335 24.6046 31.4381 25.5 30.3335 25.5C29.2289 25.5 28.3335 24.6046 28.3335 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M35.3335 23.5C35.3335 22.3954 36.2289 21.5 37.3335 21.5C38.4381 21.5 39.3335 22.3954 39.3335 23.5C39.3335 24.6046 38.4381 25.5 37.3335 25.5C36.2289 25.5 35.3335 24.6046 35.3335 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M42.3335 23.5C42.3335 22.3954 43.2289 21.5 44.3335 21.5C45.4381 21.5 46.3335 22.3954 46.3335 23.5C46.3335 24.6046 45.4381 25.5 44.3335 25.5C43.2289 25.5 42.3335 24.6046 42.3335 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.3335 23.5C49.3335 22.3954 50.2289 21.5 51.3335 21.5C52.4381 21.5 53.3335 22.3954 53.3335 23.5C53.3335 24.6046 52.4381 25.5 51.3335 25.5C50.2289 25.5 49.3335 24.6046 49.3335 23.5Z" fill="var(--color-text-quaternary)" /> - <path d="M56.3335 23.5C56.3335 22.3954 57.2289 21.5 58.3335 21.5C59.4381 21.5 60.3335 22.3954 60.3335 23.5C60.3335 24.6046 59.4381 25.5 58.3335 25.5C57.2289 25.5 56.3335 24.6046 56.3335 23.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M0.333496 30.5C0.333496 29.3954 1.22893 28.5 2.3335 28.5C3.43807 28.5 4.3335 29.3954 4.3335 30.5C4.3335 31.6046 3.43807 32.5 2.3335 32.5C1.22893 32.5 0.333496 31.6046 0.333496 30.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M7.3335 30.5C7.3335 29.3954 8.22893 28.5 9.3335 28.5C10.4381 28.5 11.3335 29.3954 11.3335 30.5C11.3335 31.6046 10.4381 32.5 9.3335 32.5C8.22893 32.5 7.3335 31.6046 7.3335 30.5Z" fill="var(--color-text-quaternary)" /> - <path d="M14.3335 30.5C14.3335 29.3954 15.2289 28.5 16.3335 28.5C17.4381 28.5 18.3335 29.3954 18.3335 30.5C18.3335 31.6046 17.4381 32.5 16.3335 32.5C15.2289 32.5 14.3335 31.6046 14.3335 30.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M21.3335 30.5C21.3335 29.3954 22.2289 28.5 23.3335 28.5C24.4381 28.5 25.3335 29.3954 25.3335 30.5C25.3335 31.6046 24.4381 32.5 23.3335 32.5C22.2289 32.5 21.3335 31.6046 21.3335 30.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28.3335 30.5C28.3335 29.3954 29.2289 28.5 30.3335 28.5C31.4381 28.5 32.3335 29.3954 32.3335 30.5C32.3335 31.6046 31.4381 32.5 30.3335 32.5C29.2289 32.5 28.3335 31.6046 28.3335 30.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M35.3335 30.5C35.3335 29.3954 36.2289 28.5 37.3335 28.5C38.4381 28.5 39.3335 29.3954 39.3335 30.5C39.3335 31.6046 38.4381 32.5 37.3335 32.5C36.2289 32.5 35.3335 31.6046 35.3335 30.5Z" fill="var(--color-text-quaternary)" /> - <path d="M42.3335 30.5C42.3335 29.3954 43.2289 28.5 44.3335 28.5C45.4381 28.5 46.3335 29.3954 46.3335 30.5C46.3335 31.6046 45.4381 32.5 44.3335 32.5C43.2289 32.5 42.3335 31.6046 42.3335 30.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M49.3335 30.5C49.3335 29.3954 50.2289 28.5 51.3335 28.5C52.4381 28.5 53.3335 29.3954 53.3335 30.5C53.3335 31.6046 52.4381 32.5 51.3335 32.5C50.2289 32.5 49.3335 31.6046 49.3335 30.5Z" fill="var(--color-text-quaternary)" /> - <path d="M56.3335 30.5C56.3335 29.3954 57.2289 28.5 58.3335 28.5C59.4381 28.5 60.3335 29.3954 60.3335 30.5C60.3335 31.6046 59.4381 32.5 58.3335 32.5C57.2289 32.5 56.3335 31.6046 56.3335 30.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M0.333496 37.5C0.333496 36.3954 1.22893 35.5 2.3335 35.5C3.43807 35.5 4.3335 36.3954 4.3335 37.5C4.3335 38.6046 3.43807 39.5 2.3335 39.5C1.22893 39.5 0.333496 38.6046 0.333496 37.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M7.3335 37.5C7.3335 36.3954 8.22893 35.5 9.3335 35.5C10.4381 35.5 11.3335 36.3954 11.3335 37.5C11.3335 38.6046 10.4381 39.5 9.3335 39.5C8.22893 39.5 7.3335 38.6046 7.3335 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3335 37.5C14.3335 36.3954 15.2289 35.5 16.3335 35.5C17.4381 35.5 18.3335 36.3954 18.3335 37.5C18.3335 38.6046 17.4381 39.5 16.3335 39.5C15.2289 39.5 14.3335 38.6046 14.3335 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21.3335 37.5C21.3335 36.3954 22.2289 35.5 23.3335 35.5C24.4381 35.5 25.3335 36.3954 25.3335 37.5C25.3335 38.6046 24.4381 39.5 23.3335 39.5C22.2289 39.5 21.3335 38.6046 21.3335 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.3335 37.5C28.3335 36.3954 29.2289 35.5 30.3335 35.5C31.4381 35.5 32.3335 36.3954 32.3335 37.5C32.3335 38.6046 31.4381 39.5 30.3335 39.5C29.2289 39.5 28.3335 38.6046 28.3335 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M35.3335 37.5C35.3335 36.3954 36.2289 35.5 37.3335 35.5C38.4381 35.5 39.3335 36.3954 39.3335 37.5C39.3335 38.6046 38.4381 39.5 37.3335 39.5C36.2289 39.5 35.3335 38.6046 35.3335 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M42.3335 37.5C42.3335 36.3954 43.2289 35.5 44.3335 35.5C45.4381 35.5 46.3335 36.3954 46.3335 37.5C46.3335 38.6046 45.4381 39.5 44.3335 39.5C43.2289 39.5 42.3335 38.6046 42.3335 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.3335 37.5C49.3335 36.3954 50.2289 35.5 51.3335 35.5C52.4381 35.5 53.3335 36.3954 53.3335 37.5C53.3335 38.6046 52.4381 39.5 51.3335 39.5C50.2289 39.5 49.3335 38.6046 49.3335 37.5Z" fill="var(--color-text-quaternary)" /> - <path d="M56.3335 37.5C56.3335 36.3954 57.2289 35.5 58.3335 35.5C59.4381 35.5 60.3335 36.3954 60.3335 37.5C60.3335 38.6046 59.4381 39.5 58.3335 39.5C57.2289 39.5 56.3335 38.6046 56.3335 37.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M0.333496 44.5C0.333496 43.3954 1.22893 42.5 2.3335 42.5C3.43807 42.5 4.3335 43.3954 4.3335 44.5C4.3335 45.6046 3.43807 46.5 2.3335 46.5C1.22893 46.5 0.333496 45.6046 0.333496 44.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M7.3335 44.5C7.3335 43.3954 8.22893 42.5 9.3335 42.5C10.4381 42.5 11.3335 43.3954 11.3335 44.5C11.3335 45.6046 10.4381 46.5 9.3335 46.5C8.22893 46.5 7.3335 45.6046 7.3335 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3335 44.5C14.3335 43.3954 15.2289 42.5 16.3335 42.5C17.4381 42.5 18.3335 43.3954 18.3335 44.5C18.3335 45.6046 17.4381 46.5 16.3335 46.5C15.2289 46.5 14.3335 45.6046 14.3335 44.5Z" fill="var(--color-text-quaternary)" /> - <path d="M21.3335 44.5C21.3335 43.3954 22.2289 42.5 23.3335 42.5C24.4381 42.5 25.3335 43.3954 25.3335 44.5C25.3335 45.6046 24.4381 46.5 23.3335 46.5C22.2289 46.5 21.3335 45.6046 21.3335 44.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M28.3335 44.5C28.3335 43.3954 29.2289 42.5 30.3335 42.5C31.4381 42.5 32.3335 43.3954 32.3335 44.5C32.3335 45.6046 31.4381 46.5 30.3335 46.5C29.2289 46.5 28.3335 45.6046 28.3335 44.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M35.3335 44.5C35.3335 43.3954 36.2289 42.5 37.3335 42.5C38.4381 42.5 39.3335 43.3954 39.3335 44.5C39.3335 45.6046 38.4381 46.5 37.3335 46.5C36.2289 46.5 35.3335 45.6046 35.3335 44.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M42.3335 44.5C42.3335 43.3954 43.2289 42.5 44.3335 42.5C45.4381 42.5 46.3335 43.3954 46.3335 44.5C46.3335 45.6046 45.4381 46.5 44.3335 46.5C43.2289 46.5 42.3335 45.6046 42.3335 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.3335 44.5C49.3335 43.3954 50.2289 42.5 51.3335 42.5C52.4381 42.5 53.3335 43.3954 53.3335 44.5C53.3335 45.6046 52.4381 46.5 51.3335 46.5C50.2289 46.5 49.3335 45.6046 49.3335 44.5Z" fill="var(--color-text-quaternary)" /> - <path d="M56.3335 44.5C56.3335 43.3954 57.2289 42.5 58.3335 42.5C59.4381 42.5 60.3335 43.3954 60.3335 44.5C60.3335 45.6046 59.4381 46.5 58.3335 46.5C57.2289 46.5 56.3335 45.6046 56.3335 44.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M0.333496 51.5C0.333496 50.3954 1.22893 49.5 2.3335 49.5C3.43807 49.5 4.3335 50.3954 4.3335 51.5C4.3335 52.6046 3.43807 53.5 2.3335 53.5C1.22893 53.5 0.333496 52.6046 0.333496 51.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M7.3335 51.5C7.3335 50.3954 8.22893 49.5 9.3335 49.5C10.4381 49.5 11.3335 50.3954 11.3335 51.5C11.3335 52.6046 10.4381 53.5 9.3335 53.5C8.22893 53.5 7.3335 52.6046 7.3335 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3335 51.5C14.3335 50.3954 15.2289 49.5 16.3335 49.5C17.4381 49.5 18.3335 50.3954 18.3335 51.5C18.3335 52.6046 17.4381 53.5 16.3335 53.5C15.2289 53.5 14.3335 52.6046 14.3335 51.5Z" fill="var(--color-text-quaternary)" /> - <path d="M21.3335 51.5C21.3335 50.3954 22.2289 49.5 23.3335 49.5C24.4381 49.5 25.3335 50.3954 25.3335 51.5C25.3335 52.6046 24.4381 53.5 23.3335 53.5C22.2289 53.5 21.3335 52.6046 21.3335 51.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M28.3335 51.5C28.3335 50.3954 29.2289 49.5 30.3335 49.5C31.4381 49.5 32.3335 50.3954 32.3335 51.5C32.3335 52.6046 31.4381 53.5 30.3335 53.5C29.2289 53.5 28.3335 52.6046 28.3335 51.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35.3335 51.5C35.3335 50.3954 36.2289 49.5 37.3335 49.5C38.4381 49.5 39.3335 50.3954 39.3335 51.5C39.3335 52.6046 38.4381 53.5 37.3335 53.5C36.2289 53.5 35.3335 52.6046 35.3335 51.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M42.3335 51.5C42.3335 50.3954 43.2289 49.5 44.3335 49.5C45.4381 49.5 46.3335 50.3954 46.3335 51.5C46.3335 52.6046 45.4381 53.5 44.3335 53.5C43.2289 53.5 42.3335 52.6046 42.3335 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.3335 51.5C49.3335 50.3954 50.2289 49.5 51.3335 49.5C52.4381 49.5 53.3335 50.3954 53.3335 51.5C53.3335 52.6046 52.4381 53.5 51.3335 53.5C50.2289 53.5 49.3335 52.6046 49.3335 51.5Z" fill="var(--color-text-quaternary)" /> - <path d="M56.3335 51.5C56.3335 50.3954 57.2289 49.5 58.3335 49.5C59.4381 49.5 60.3335 50.3954 60.3335 51.5C60.3335 52.6046 59.4381 53.5 58.3335 53.5C57.2289 53.5 56.3335 52.6046 56.3335 51.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path d="M0.333496 58.5C0.333496 57.3954 1.22893 56.5 2.3335 56.5C3.43807 56.5 4.3335 57.3954 4.3335 58.5C4.3335 59.6046 3.43807 60.5 2.3335 60.5C1.22893 60.5 0.333496 59.6046 0.333496 58.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M7.3335 58.5C7.3335 57.3954 8.22893 56.5 9.3335 56.5C10.4381 56.5 11.3335 57.3954 11.3335 58.5C11.3335 59.6046 10.4381 60.5 9.3335 60.5C8.22893 60.5 7.3335 59.6046 7.3335 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3335 58.5C14.3335 57.3954 15.2289 56.5 16.3335 56.5C17.4381 56.5 18.3335 57.3954 18.3335 58.5C18.3335 59.6046 17.4381 60.5 16.3335 60.5C15.2289 60.5 14.3335 59.6046 14.3335 58.5Z" fill="var(--color-text-quaternary)" /> - <path d="M21.3335 58.5C21.3335 57.3954 22.2289 56.5 23.3335 56.5C24.4381 56.5 25.3335 57.3954 25.3335 58.5C25.3335 59.6046 24.4381 60.5 23.3335 60.5C22.2289 60.5 21.3335 59.6046 21.3335 58.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M28.3335 58.5C28.3335 57.3954 29.2289 56.5 30.3335 56.5C31.4381 56.5 32.3335 57.3954 32.3335 58.5C32.3335 59.6046 31.4381 60.5 30.3335 60.5C29.2289 60.5 28.3335 59.6046 28.3335 58.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35.3335 58.5C35.3335 57.3954 36.2289 56.5 37.3335 56.5C38.4381 56.5 39.3335 57.3954 39.3335 58.5C39.3335 59.6046 38.4381 60.5 37.3335 60.5C36.2289 60.5 35.3335 59.6046 35.3335 58.5Z" fill="var(--color-saas-dify-blue-accessible)" /> - <path opacity="0.18" d="M42.3335 58.5C42.3335 57.3954 43.2289 56.5 44.3335 56.5C45.4381 56.5 46.3335 57.3954 46.3335 58.5C46.3335 59.6046 45.4381 60.5 44.3335 60.5C43.2289 60.5 42.3335 59.6046 42.3335 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.3335 58.5C49.3335 57.3954 50.2289 56.5 51.3335 56.5C52.4381 56.5 53.3335 57.3954 53.3335 58.5C53.3335 59.6046 52.4381 60.5 51.3335 60.5C50.2289 60.5 49.3335 59.6046 49.3335 58.5Z" fill="var(--color-text-quaternary)" /> - <path d="M56.3335 58.5C56.3335 57.3954 57.2289 56.5 58.3335 56.5C59.4381 56.5 60.3335 57.3954 60.3335 58.5C60.3335 59.6046 59.4381 60.5 58.3335 60.5C57.2289 60.5 56.3335 59.6046 56.3335 58.5Z" fill="var(--color-saas-dify-blue-accessible)" /> + <path + d="M0.333496 2.5C0.333496 1.39543 1.22893 0.5 2.3335 0.5C3.43807 0.5 4.3335 1.39543 4.3335 2.5C4.3335 3.60457 3.43807 4.5 2.3335 4.5C1.22893 4.5 0.333496 3.60457 0.333496 2.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M7.3335 2.5C7.3335 1.39543 8.22893 0.5 9.3335 0.5C10.4381 0.5 11.3335 1.39543 11.3335 2.5C11.3335 3.60457 10.4381 4.5 9.3335 4.5C8.22893 4.5 7.3335 3.60457 7.3335 2.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M14.3335 2.5C14.3335 1.39543 15.2289 0.5 16.3335 0.5C17.4381 0.5 18.3335 1.39543 18.3335 2.5C18.3335 3.60457 17.4381 4.5 16.3335 4.5C15.2289 4.5 14.3335 3.60457 14.3335 2.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M21.3335 2.5C21.3335 1.39543 22.2289 0.5 23.3335 0.5C24.4381 0.5 25.3335 1.39543 25.3335 2.5C25.3335 3.60457 24.4381 4.5 23.3335 4.5C22.2289 4.5 21.3335 3.60457 21.3335 2.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M28.3335 2.5C28.3335 1.39543 29.2289 0.5 30.3335 0.5C31.4381 0.5 32.3335 1.39543 32.3335 2.5C32.3335 3.60457 31.4381 4.5 30.3335 4.5C29.2289 4.5 28.3335 3.60457 28.3335 2.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M35.3335 2.5C35.3335 1.39543 36.2289 0.5 37.3335 0.5C38.4381 0.5 39.3335 1.39543 39.3335 2.5C39.3335 3.60457 38.4381 4.5 37.3335 4.5C36.2289 4.5 35.3335 3.60457 35.3335 2.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M42.3335 2.5C42.3335 1.39543 43.2289 0.5 44.3335 0.5C45.4381 0.5 46.3335 1.39543 46.3335 2.5C46.3335 3.60457 45.4381 4.5 44.3335 4.5C43.2289 4.5 42.3335 3.60457 42.3335 2.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M49.3335 2.5C49.3335 1.39543 50.2289 0.5 51.3335 0.5C52.4381 0.5 53.3335 1.39543 53.3335 2.5C53.3335 3.60457 52.4381 4.5 51.3335 4.5C50.2289 4.5 49.3335 3.60457 49.3335 2.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M56.3335 2.5C56.3335 1.39543 57.2289 0.5 58.3335 0.5C59.4381 0.5 60.3335 1.39543 60.3335 2.5C60.3335 3.60457 59.4381 4.5 58.3335 4.5C57.2289 4.5 56.3335 3.60457 56.3335 2.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M0.333496 9.5C0.333496 8.39543 1.22893 7.5 2.3335 7.5C3.43807 7.5 4.3335 8.39543 4.3335 9.5C4.3335 10.6046 3.43807 11.5 2.3335 11.5C1.22893 11.5 0.333496 10.6046 0.333496 9.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M7.3335 9.5C7.3335 8.39543 8.22893 7.5 9.3335 7.5C10.4381 7.5 11.3335 8.39543 11.3335 9.5C11.3335 10.6046 10.4381 11.5 9.3335 11.5C8.22893 11.5 7.3335 10.6046 7.3335 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3335 9.5C14.3335 8.39543 15.2289 7.5 16.3335 7.5C17.4381 7.5 18.3335 8.39543 18.3335 9.5C18.3335 10.6046 17.4381 11.5 16.3335 11.5C15.2289 11.5 14.3335 10.6046 14.3335 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21.3335 9.5C21.3335 8.39543 22.2289 7.5 23.3335 7.5C24.4381 7.5 25.3335 8.39543 25.3335 9.5C25.3335 10.6046 24.4381 11.5 23.3335 11.5C22.2289 11.5 21.3335 10.6046 21.3335 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.3335 9.5C28.3335 8.39543 29.2289 7.5 30.3335 7.5C31.4381 7.5 32.3335 8.39543 32.3335 9.5C32.3335 10.6046 31.4381 11.5 30.3335 11.5C29.2289 11.5 28.3335 10.6046 28.3335 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M35.3335 9.5C35.3335 8.39543 36.2289 7.5 37.3335 7.5C38.4381 7.5 39.3335 8.39543 39.3335 9.5C39.3335 10.6046 38.4381 11.5 37.3335 11.5C36.2289 11.5 35.3335 10.6046 35.3335 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M42.3335 9.5C42.3335 8.39543 43.2289 7.5 44.3335 7.5C45.4381 7.5 46.3335 8.39543 46.3335 9.5C46.3335 10.6046 45.4381 11.5 44.3335 11.5C43.2289 11.5 42.3335 10.6046 42.3335 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.3335 9.5C49.3335 8.39543 50.2289 7.5 51.3335 7.5C52.4381 7.5 53.3335 8.39543 53.3335 9.5C53.3335 10.6046 52.4381 11.5 51.3335 11.5C50.2289 11.5 49.3335 10.6046 49.3335 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M56.3335 9.5C56.3335 8.39543 57.2289 7.5 58.3335 7.5C59.4381 7.5 60.3335 8.39543 60.3335 9.5C60.3335 10.6046 59.4381 11.5 58.3335 11.5C57.2289 11.5 56.3335 10.6046 56.3335 9.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M0.333496 16.5C0.333496 15.3954 1.22893 14.5 2.3335 14.5C3.43807 14.5 4.3335 15.3954 4.3335 16.5C4.3335 17.6046 3.43807 18.5 2.3335 18.5C1.22893 18.5 0.333496 17.6046 0.333496 16.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M7.3335 16.5C7.3335 15.3954 8.22893 14.5 9.3335 14.5C10.4381 14.5 11.3335 15.3954 11.3335 16.5C11.3335 17.6046 10.4381 18.5 9.3335 18.5C8.22893 18.5 7.3335 17.6046 7.3335 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M14.3335 16.5C14.3335 15.3954 15.2289 14.5 16.3335 14.5C17.4381 14.5 18.3335 15.3954 18.3335 16.5C18.3335 17.6046 17.4381 18.5 16.3335 18.5C15.2289 18.5 14.3335 17.6046 14.3335 16.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M21.3335 16.5C21.3335 15.3954 22.2289 14.5 23.3335 14.5C24.4381 14.5 25.3335 15.3954 25.3335 16.5C25.3335 17.6046 24.4381 18.5 23.3335 18.5C22.2289 18.5 21.3335 17.6046 21.3335 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28.3335 16.5C28.3335 15.3954 29.2289 14.5 30.3335 14.5C31.4381 14.5 32.3335 15.3954 32.3335 16.5C32.3335 17.6046 31.4381 18.5 30.3335 18.5C29.2289 18.5 28.3335 17.6046 28.3335 16.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M35.3335 16.5C35.3335 15.3954 36.2289 14.5 37.3335 14.5C38.4381 14.5 39.3335 15.3954 39.3335 16.5C39.3335 17.6046 38.4381 18.5 37.3335 18.5C36.2289 18.5 35.3335 17.6046 35.3335 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M42.3335 16.5C42.3335 15.3954 43.2289 14.5 44.3335 14.5C45.4381 14.5 46.3335 15.3954 46.3335 16.5C46.3335 17.6046 45.4381 18.5 44.3335 18.5C43.2289 18.5 42.3335 17.6046 42.3335 16.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M49.3335 16.5C49.3335 15.3954 50.2289 14.5 51.3335 14.5C52.4381 14.5 53.3335 15.3954 53.3335 16.5C53.3335 17.6046 52.4381 18.5 51.3335 18.5C50.2289 18.5 49.3335 17.6046 49.3335 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M56.3335 16.5C56.3335 15.3954 57.2289 14.5 58.3335 14.5C59.4381 14.5 60.3335 15.3954 60.3335 16.5C60.3335 17.6046 59.4381 18.5 58.3335 18.5C57.2289 18.5 56.3335 17.6046 56.3335 16.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M0.333496 23.5C0.333496 22.3954 1.22893 21.5 2.3335 21.5C3.43807 21.5 4.3335 22.3954 4.3335 23.5C4.3335 24.6046 3.43807 25.5 2.3335 25.5C1.22893 25.5 0.333496 24.6046 0.333496 23.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M7.3335 23.5C7.3335 22.3954 8.22893 21.5 9.3335 21.5C10.4381 21.5 11.3335 22.3954 11.3335 23.5C11.3335 24.6046 10.4381 25.5 9.3335 25.5C8.22893 25.5 7.3335 24.6046 7.3335 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3335 23.5C14.3335 22.3954 15.2289 21.5 16.3335 21.5C17.4381 21.5 18.3335 22.3954 18.3335 23.5C18.3335 24.6046 17.4381 25.5 16.3335 25.5C15.2289 25.5 14.3335 24.6046 14.3335 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21.3335 23.5C21.3335 22.3954 22.2289 21.5 23.3335 21.5C24.4381 21.5 25.3335 22.3954 25.3335 23.5C25.3335 24.6046 24.4381 25.5 23.3335 25.5C22.2289 25.5 21.3335 24.6046 21.3335 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.3335 23.5C28.3335 22.3954 29.2289 21.5 30.3335 21.5C31.4381 21.5 32.3335 22.3954 32.3335 23.5C32.3335 24.6046 31.4381 25.5 30.3335 25.5C29.2289 25.5 28.3335 24.6046 28.3335 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M35.3335 23.5C35.3335 22.3954 36.2289 21.5 37.3335 21.5C38.4381 21.5 39.3335 22.3954 39.3335 23.5C39.3335 24.6046 38.4381 25.5 37.3335 25.5C36.2289 25.5 35.3335 24.6046 35.3335 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M42.3335 23.5C42.3335 22.3954 43.2289 21.5 44.3335 21.5C45.4381 21.5 46.3335 22.3954 46.3335 23.5C46.3335 24.6046 45.4381 25.5 44.3335 25.5C43.2289 25.5 42.3335 24.6046 42.3335 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.3335 23.5C49.3335 22.3954 50.2289 21.5 51.3335 21.5C52.4381 21.5 53.3335 22.3954 53.3335 23.5C53.3335 24.6046 52.4381 25.5 51.3335 25.5C50.2289 25.5 49.3335 24.6046 49.3335 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M56.3335 23.5C56.3335 22.3954 57.2289 21.5 58.3335 21.5C59.4381 21.5 60.3335 22.3954 60.3335 23.5C60.3335 24.6046 59.4381 25.5 58.3335 25.5C57.2289 25.5 56.3335 24.6046 56.3335 23.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M0.333496 30.5C0.333496 29.3954 1.22893 28.5 2.3335 28.5C3.43807 28.5 4.3335 29.3954 4.3335 30.5C4.3335 31.6046 3.43807 32.5 2.3335 32.5C1.22893 32.5 0.333496 31.6046 0.333496 30.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M7.3335 30.5C7.3335 29.3954 8.22893 28.5 9.3335 28.5C10.4381 28.5 11.3335 29.3954 11.3335 30.5C11.3335 31.6046 10.4381 32.5 9.3335 32.5C8.22893 32.5 7.3335 31.6046 7.3335 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M14.3335 30.5C14.3335 29.3954 15.2289 28.5 16.3335 28.5C17.4381 28.5 18.3335 29.3954 18.3335 30.5C18.3335 31.6046 17.4381 32.5 16.3335 32.5C15.2289 32.5 14.3335 31.6046 14.3335 30.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M21.3335 30.5C21.3335 29.3954 22.2289 28.5 23.3335 28.5C24.4381 28.5 25.3335 29.3954 25.3335 30.5C25.3335 31.6046 24.4381 32.5 23.3335 32.5C22.2289 32.5 21.3335 31.6046 21.3335 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28.3335 30.5C28.3335 29.3954 29.2289 28.5 30.3335 28.5C31.4381 28.5 32.3335 29.3954 32.3335 30.5C32.3335 31.6046 31.4381 32.5 30.3335 32.5C29.2289 32.5 28.3335 31.6046 28.3335 30.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M35.3335 30.5C35.3335 29.3954 36.2289 28.5 37.3335 28.5C38.4381 28.5 39.3335 29.3954 39.3335 30.5C39.3335 31.6046 38.4381 32.5 37.3335 32.5C36.2289 32.5 35.3335 31.6046 35.3335 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M42.3335 30.5C42.3335 29.3954 43.2289 28.5 44.3335 28.5C45.4381 28.5 46.3335 29.3954 46.3335 30.5C46.3335 31.6046 45.4381 32.5 44.3335 32.5C43.2289 32.5 42.3335 31.6046 42.3335 30.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M49.3335 30.5C49.3335 29.3954 50.2289 28.5 51.3335 28.5C52.4381 28.5 53.3335 29.3954 53.3335 30.5C53.3335 31.6046 52.4381 32.5 51.3335 32.5C50.2289 32.5 49.3335 31.6046 49.3335 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M56.3335 30.5C56.3335 29.3954 57.2289 28.5 58.3335 28.5C59.4381 28.5 60.3335 29.3954 60.3335 30.5C60.3335 31.6046 59.4381 32.5 58.3335 32.5C57.2289 32.5 56.3335 31.6046 56.3335 30.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M0.333496 37.5C0.333496 36.3954 1.22893 35.5 2.3335 35.5C3.43807 35.5 4.3335 36.3954 4.3335 37.5C4.3335 38.6046 3.43807 39.5 2.3335 39.5C1.22893 39.5 0.333496 38.6046 0.333496 37.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M7.3335 37.5C7.3335 36.3954 8.22893 35.5 9.3335 35.5C10.4381 35.5 11.3335 36.3954 11.3335 37.5C11.3335 38.6046 10.4381 39.5 9.3335 39.5C8.22893 39.5 7.3335 38.6046 7.3335 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3335 37.5C14.3335 36.3954 15.2289 35.5 16.3335 35.5C17.4381 35.5 18.3335 36.3954 18.3335 37.5C18.3335 38.6046 17.4381 39.5 16.3335 39.5C15.2289 39.5 14.3335 38.6046 14.3335 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21.3335 37.5C21.3335 36.3954 22.2289 35.5 23.3335 35.5C24.4381 35.5 25.3335 36.3954 25.3335 37.5C25.3335 38.6046 24.4381 39.5 23.3335 39.5C22.2289 39.5 21.3335 38.6046 21.3335 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.3335 37.5C28.3335 36.3954 29.2289 35.5 30.3335 35.5C31.4381 35.5 32.3335 36.3954 32.3335 37.5C32.3335 38.6046 31.4381 39.5 30.3335 39.5C29.2289 39.5 28.3335 38.6046 28.3335 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M35.3335 37.5C35.3335 36.3954 36.2289 35.5 37.3335 35.5C38.4381 35.5 39.3335 36.3954 39.3335 37.5C39.3335 38.6046 38.4381 39.5 37.3335 39.5C36.2289 39.5 35.3335 38.6046 35.3335 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M42.3335 37.5C42.3335 36.3954 43.2289 35.5 44.3335 35.5C45.4381 35.5 46.3335 36.3954 46.3335 37.5C46.3335 38.6046 45.4381 39.5 44.3335 39.5C43.2289 39.5 42.3335 38.6046 42.3335 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.3335 37.5C49.3335 36.3954 50.2289 35.5 51.3335 35.5C52.4381 35.5 53.3335 36.3954 53.3335 37.5C53.3335 38.6046 52.4381 39.5 51.3335 39.5C50.2289 39.5 49.3335 38.6046 49.3335 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M56.3335 37.5C56.3335 36.3954 57.2289 35.5 58.3335 35.5C59.4381 35.5 60.3335 36.3954 60.3335 37.5C60.3335 38.6046 59.4381 39.5 58.3335 39.5C57.2289 39.5 56.3335 38.6046 56.3335 37.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M0.333496 44.5C0.333496 43.3954 1.22893 42.5 2.3335 42.5C3.43807 42.5 4.3335 43.3954 4.3335 44.5C4.3335 45.6046 3.43807 46.5 2.3335 46.5C1.22893 46.5 0.333496 45.6046 0.333496 44.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M7.3335 44.5C7.3335 43.3954 8.22893 42.5 9.3335 42.5C10.4381 42.5 11.3335 43.3954 11.3335 44.5C11.3335 45.6046 10.4381 46.5 9.3335 46.5C8.22893 46.5 7.3335 45.6046 7.3335 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3335 44.5C14.3335 43.3954 15.2289 42.5 16.3335 42.5C17.4381 42.5 18.3335 43.3954 18.3335 44.5C18.3335 45.6046 17.4381 46.5 16.3335 46.5C15.2289 46.5 14.3335 45.6046 14.3335 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M21.3335 44.5C21.3335 43.3954 22.2289 42.5 23.3335 42.5C24.4381 42.5 25.3335 43.3954 25.3335 44.5C25.3335 45.6046 24.4381 46.5 23.3335 46.5C22.2289 46.5 21.3335 45.6046 21.3335 44.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M28.3335 44.5C28.3335 43.3954 29.2289 42.5 30.3335 42.5C31.4381 42.5 32.3335 43.3954 32.3335 44.5C32.3335 45.6046 31.4381 46.5 30.3335 46.5C29.2289 46.5 28.3335 45.6046 28.3335 44.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M35.3335 44.5C35.3335 43.3954 36.2289 42.5 37.3335 42.5C38.4381 42.5 39.3335 43.3954 39.3335 44.5C39.3335 45.6046 38.4381 46.5 37.3335 46.5C36.2289 46.5 35.3335 45.6046 35.3335 44.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M42.3335 44.5C42.3335 43.3954 43.2289 42.5 44.3335 42.5C45.4381 42.5 46.3335 43.3954 46.3335 44.5C46.3335 45.6046 45.4381 46.5 44.3335 46.5C43.2289 46.5 42.3335 45.6046 42.3335 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.3335 44.5C49.3335 43.3954 50.2289 42.5 51.3335 42.5C52.4381 42.5 53.3335 43.3954 53.3335 44.5C53.3335 45.6046 52.4381 46.5 51.3335 46.5C50.2289 46.5 49.3335 45.6046 49.3335 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M56.3335 44.5C56.3335 43.3954 57.2289 42.5 58.3335 42.5C59.4381 42.5 60.3335 43.3954 60.3335 44.5C60.3335 45.6046 59.4381 46.5 58.3335 46.5C57.2289 46.5 56.3335 45.6046 56.3335 44.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M0.333496 51.5C0.333496 50.3954 1.22893 49.5 2.3335 49.5C3.43807 49.5 4.3335 50.3954 4.3335 51.5C4.3335 52.6046 3.43807 53.5 2.3335 53.5C1.22893 53.5 0.333496 52.6046 0.333496 51.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M7.3335 51.5C7.3335 50.3954 8.22893 49.5 9.3335 49.5C10.4381 49.5 11.3335 50.3954 11.3335 51.5C11.3335 52.6046 10.4381 53.5 9.3335 53.5C8.22893 53.5 7.3335 52.6046 7.3335 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3335 51.5C14.3335 50.3954 15.2289 49.5 16.3335 49.5C17.4381 49.5 18.3335 50.3954 18.3335 51.5C18.3335 52.6046 17.4381 53.5 16.3335 53.5C15.2289 53.5 14.3335 52.6046 14.3335 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M21.3335 51.5C21.3335 50.3954 22.2289 49.5 23.3335 49.5C24.4381 49.5 25.3335 50.3954 25.3335 51.5C25.3335 52.6046 24.4381 53.5 23.3335 53.5C22.2289 53.5 21.3335 52.6046 21.3335 51.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M28.3335 51.5C28.3335 50.3954 29.2289 49.5 30.3335 49.5C31.4381 49.5 32.3335 50.3954 32.3335 51.5C32.3335 52.6046 31.4381 53.5 30.3335 53.5C29.2289 53.5 28.3335 52.6046 28.3335 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35.3335 51.5C35.3335 50.3954 36.2289 49.5 37.3335 49.5C38.4381 49.5 39.3335 50.3954 39.3335 51.5C39.3335 52.6046 38.4381 53.5 37.3335 53.5C36.2289 53.5 35.3335 52.6046 35.3335 51.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M42.3335 51.5C42.3335 50.3954 43.2289 49.5 44.3335 49.5C45.4381 49.5 46.3335 50.3954 46.3335 51.5C46.3335 52.6046 45.4381 53.5 44.3335 53.5C43.2289 53.5 42.3335 52.6046 42.3335 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.3335 51.5C49.3335 50.3954 50.2289 49.5 51.3335 49.5C52.4381 49.5 53.3335 50.3954 53.3335 51.5C53.3335 52.6046 52.4381 53.5 51.3335 53.5C50.2289 53.5 49.3335 52.6046 49.3335 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M56.3335 51.5C56.3335 50.3954 57.2289 49.5 58.3335 49.5C59.4381 49.5 60.3335 50.3954 60.3335 51.5C60.3335 52.6046 59.4381 53.5 58.3335 53.5C57.2289 53.5 56.3335 52.6046 56.3335 51.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + d="M0.333496 58.5C0.333496 57.3954 1.22893 56.5 2.3335 56.5C3.43807 56.5 4.3335 57.3954 4.3335 58.5C4.3335 59.6046 3.43807 60.5 2.3335 60.5C1.22893 60.5 0.333496 59.6046 0.333496 58.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M7.3335 58.5C7.3335 57.3954 8.22893 56.5 9.3335 56.5C10.4381 56.5 11.3335 57.3954 11.3335 58.5C11.3335 59.6046 10.4381 60.5 9.3335 60.5C8.22893 60.5 7.3335 59.6046 7.3335 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3335 58.5C14.3335 57.3954 15.2289 56.5 16.3335 56.5C17.4381 56.5 18.3335 57.3954 18.3335 58.5C18.3335 59.6046 17.4381 60.5 16.3335 60.5C15.2289 60.5 14.3335 59.6046 14.3335 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M21.3335 58.5C21.3335 57.3954 22.2289 56.5 23.3335 56.5C24.4381 56.5 25.3335 57.3954 25.3335 58.5C25.3335 59.6046 24.4381 60.5 23.3335 60.5C22.2289 60.5 21.3335 59.6046 21.3335 58.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M28.3335 58.5C28.3335 57.3954 29.2289 56.5 30.3335 56.5C31.4381 56.5 32.3335 57.3954 32.3335 58.5C32.3335 59.6046 31.4381 60.5 30.3335 60.5C29.2289 60.5 28.3335 59.6046 28.3335 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35.3335 58.5C35.3335 57.3954 36.2289 56.5 37.3335 56.5C38.4381 56.5 39.3335 57.3954 39.3335 58.5C39.3335 59.6046 38.4381 60.5 37.3335 60.5C36.2289 60.5 35.3335 59.6046 35.3335 58.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> + <path + opacity="0.18" + d="M42.3335 58.5C42.3335 57.3954 43.2289 56.5 44.3335 56.5C45.4381 56.5 46.3335 57.3954 46.3335 58.5C46.3335 59.6046 45.4381 60.5 44.3335 60.5C43.2289 60.5 42.3335 59.6046 42.3335 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.3335 58.5C49.3335 57.3954 50.2289 56.5 51.3335 56.5C52.4381 56.5 53.3335 57.3954 53.3335 58.5C53.3335 59.6046 52.4381 60.5 51.3335 60.5C50.2289 60.5 49.3335 59.6046 49.3335 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M56.3335 58.5C56.3335 57.3954 57.2289 56.5 58.3335 56.5C59.4381 56.5 60.3335 57.3954 60.3335 58.5C60.3335 59.6046 59.4381 60.5 58.3335 60.5C57.2289 60.5 56.3335 59.6046 56.3335 58.5Z" + fill="var(--color-saas-dify-blue-accessible)" + /> </g> <defs> <clipPath id="clip0_1_5366"> diff --git a/web/app/components/billing/pricing/assets/noise-bottom.tsx b/web/app/components/billing/pricing/assets/noise-bottom.tsx index a4c1117a2d5e6b..d550c821ff9c26 100644 --- a/web/app/components/billing/pricing/assets/noise-bottom.tsx +++ b/web/app/components/billing/pricing/assets/noise-bottom.tsx @@ -1,15 +1,42 @@ const NoiseBottom = () => { return ( - <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="148" viewBox="0 0 100% 148" fill="none"> + <svg + xmlns="http://www.w3.org/2000/svg" + width="100%" + height="148" + viewBox="0 0 100% 148" + fill="none" + > <g opacity="0.1" filter="url(#filter0_g_1_5505)"> <rect y="52" width="100%" height="96" fill="var(--color-text-quaternary)" /> </g> <defs> - <filter id="filter0_g_1_5505" x="0" y="0" width="100%" height="296" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"> + <filter + id="filter0_g_1_5505" + x="0" + y="0" + width="100%" + height="296" + filterUnits="userSpaceOnUse" + colorInterpolationFilters="sRGB" + > <feFlood floodOpacity="0" result="BackgroundImageFix" /> <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> - <feTurbulence type="fractalNoise" baseFrequency="0.625 0.625" numOctaves="3" seed="5427" /> - <feDisplacementMap in="shape" scale="200" xChannelSelector="R" yChannelSelector="G" result="displacedImage" width="100%" height="100%" /> + <feTurbulence + type="fractalNoise" + baseFrequency="0.625 0.625" + numOctaves="3" + seed="5427" + /> + <feDisplacementMap + in="shape" + scale="200" + xChannelSelector="R" + yChannelSelector="G" + result="displacedImage" + width="100%" + height="100%" + /> <feMerge result="effect1_texture_1_5505"> <feMergeNode in="displacedImage" /> </feMerge> diff --git a/web/app/components/billing/pricing/assets/noise-top.tsx b/web/app/components/billing/pricing/assets/noise-top.tsx index fae5cc3da1aaa6..70775504c78dc8 100644 --- a/web/app/components/billing/pricing/assets/noise-top.tsx +++ b/web/app/components/billing/pricing/assets/noise-top.tsx @@ -1,15 +1,42 @@ const NoiseTop = () => { return ( - <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="148" viewBox="0 0 100% 148" fill="none"> + <svg + xmlns="http://www.w3.org/2000/svg" + width="100%" + height="148" + viewBox="0 0 100% 148" + fill="none" + > <g opacity="0.1" filter="url(#filter0_g_2_13388)"> <rect y="0" width="100%" height="96" fill="var(--color-text-quaternary)" /> </g> <defs> - <filter id="filter0_g_2_13388" x="0" y="0" width="100%" height="296" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"> + <filter + id="filter0_g_2_13388" + x="0" + y="0" + width="100%" + height="296" + filterUnits="userSpaceOnUse" + colorInterpolationFilters="sRGB" + > <feFlood floodOpacity="0" result="BackgroundImageFix" /> <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> - <feTurbulence type="fractalNoise" baseFrequency="0.625 0.625" numOctaves="3" seed="5427" /> - <feDisplacementMap in="shape" scale="200" xChannelSelector="R" yChannelSelector="G" result="displacedImage" width="100%" height="100%" /> + <feTurbulence + type="fractalNoise" + baseFrequency="0.625 0.625" + numOctaves="3" + seed="5427" + /> + <feDisplacementMap + in="shape" + scale="200" + xChannelSelector="R" + yChannelSelector="G" + result="displacedImage" + width="100%" + height="100%" + /> <feMerge result="effect1_texture_2_13388"> <feMergeNode in="displacedImage" /> </feMerge> diff --git a/web/app/components/billing/pricing/assets/premium-noise.tsx b/web/app/components/billing/pricing/assets/premium-noise.tsx index bf153455957bba..bb3be0607b4268 100644 --- a/web/app/components/billing/pricing/assets/premium-noise.tsx +++ b/web/app/components/billing/pricing/assets/premium-noise.tsx @@ -1,15 +1,42 @@ const PremiumNoise = () => { return ( - <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="148" viewBox="0 0 100% 148" fill="none"> + <svg + xmlns="http://www.w3.org/2000/svg" + width="100%" + height="148" + viewBox="0 0 100% 148" + fill="none" + > <g opacity="0.05" filter="url(#filter0_g_1_5238)"> <rect y="0" width="100%" height="96" fill="var(--color-text-warning)" /> </g> <defs> - <filter id="filter0_g_1_5238" x="0" y="0" width="100%" height="296" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"> + <filter + id="filter0_g_1_5238" + x="0" + y="0" + width="100%" + height="296" + filterUnits="userSpaceOnUse" + colorInterpolationFilters="sRGB" + > <feFlood floodOpacity="0" result="BackgroundImageFix" /> <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> - <feTurbulence type="fractalNoise" baseFrequency="0.625 0.625" numOctaves="3" seed="5427" /> - <feDisplacementMap in="shape" scale="200" xChannelSelector="R" yChannelSelector="G" result="displacedImage" width="100%" height="100%" /> + <feTurbulence + type="fractalNoise" + baseFrequency="0.625 0.625" + numOctaves="3" + seed="5427" + /> + <feDisplacementMap + in="shape" + scale="200" + xChannelSelector="R" + yChannelSelector="G" + result="displacedImage" + width="100%" + height="100%" + /> <feMerge result="effect1_texture_1_5238"> <feMergeNode in="displacedImage" /> </feMerge> diff --git a/web/app/components/billing/pricing/assets/premium.tsx b/web/app/components/billing/pricing/assets/premium.tsx index 909052a33906d5..ac5dfba41e2819 100644 --- a/web/app/components/billing/pricing/assets/premium.tsx +++ b/web/app/components/billing/pricing/assets/premium.tsx @@ -2,87 +2,381 @@ const Premium = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" width="61" height="61" viewBox="0 0 61 61" fill="none"> <g clipPath="url(#clip0_1_5240)"> - <path opacity="0.18" d="M0.666748 2.5C0.666748 1.39543 1.56218 0.5 2.66675 0.5C3.77132 0.5 4.66675 1.39543 4.66675 2.5C4.66675 3.60457 3.77132 4.5 2.66675 4.5C1.56218 4.5 0.666748 3.60457 0.666748 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M7.66675 2.5C7.66675 1.39543 8.56218 0.5 9.66675 0.5C10.7713 0.5 11.6667 1.39543 11.6667 2.5C11.6667 3.60457 10.7713 4.5 9.66675 4.5C8.56218 4.5 7.66675 3.60457 7.66675 2.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M14.6667 2.5C14.6667 1.39543 15.5622 0.5 16.6667 0.5C17.7713 0.5 18.6667 1.39543 18.6667 2.5C18.6667 3.60457 17.7713 4.5 16.6667 4.5C15.5622 4.5 14.6667 3.60457 14.6667 2.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21.6667 2.5C21.6667 1.39543 22.5622 0.5 23.6667 0.5C24.7713 0.5 25.6667 1.39543 25.6667 2.5C25.6667 3.60457 24.7713 4.5 23.6667 4.5C22.5622 4.5 21.6667 3.60457 21.6667 2.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.6667 2.5C28.6667 1.39543 29.5622 0.5 30.6667 0.5C31.7713 0.5 32.6667 1.39543 32.6667 2.5C32.6667 3.60457 31.7713 4.5 30.6667 4.5C29.5622 4.5 28.6667 3.60457 28.6667 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35.6667 2.5C35.6667 1.39543 36.5622 0.5 37.6667 0.5C38.7713 0.5 39.6667 1.39543 39.6667 2.5C39.6667 3.60457 38.7713 4.5 37.6667 4.5C36.5622 4.5 35.6667 3.60457 35.6667 2.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M42.6667 2.5C42.6667 1.39543 43.5622 0.5 44.6667 0.5C45.7713 0.5 46.6667 1.39543 46.6667 2.5C46.6667 3.60457 45.7713 4.5 44.6667 4.5C43.5622 4.5 42.6667 3.60457 42.6667 2.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.6667 2.5C49.6667 1.39543 50.5622 0.5 51.6667 0.5C52.7713 0.5 53.6667 1.39543 53.6667 2.5C53.6667 3.60457 52.7713 4.5 51.6667 4.5C50.5622 4.5 49.6667 3.60457 49.6667 2.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56.6667 2.5C56.6667 1.39543 57.5622 0.5 58.6667 0.5C59.7713 0.5 60.6667 1.39543 60.6667 2.5C60.6667 3.60457 59.7713 4.5 58.6667 4.5C57.5622 4.5 56.6667 3.60457 56.6667 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M0.666748 9.5C0.666748 8.39543 1.56218 7.5 2.66675 7.5C3.77132 7.5 4.66675 8.39543 4.66675 9.5C4.66675 10.6046 3.77132 11.5 2.66675 11.5C1.56218 11.5 0.666748 10.6046 0.666748 9.5Z" fill="var(--color-text-warning)" /> - <path d="M7.66675 9.5C7.66675 8.39543 8.56218 7.5 9.66675 7.5C10.7713 7.5 11.6667 8.39543 11.6667 9.5C11.6667 10.6046 10.7713 11.5 9.66675 11.5C8.56218 11.5 7.66675 10.6046 7.66675 9.5Z" fill="var(--color-text-warning)" /> - <path d="M14.6667 9.5C14.6667 8.39543 15.5622 7.5 16.6667 7.5C17.7713 7.5 18.6667 8.39543 18.6667 9.5C18.6667 10.6046 17.7713 11.5 16.6667 11.5C15.5622 11.5 14.6667 10.6046 14.6667 9.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M21.6667 9.5C21.6667 8.39543 22.5622 7.5 23.6667 7.5C24.7713 7.5 25.6667 8.39543 25.6667 9.5C25.6667 10.6046 24.7713 11.5 23.6667 11.5C22.5622 11.5 21.6667 10.6046 21.6667 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.6667 9.5C28.6667 8.39543 29.5622 7.5 30.6667 7.5C31.7713 7.5 32.6667 8.39543 32.6667 9.5C32.6667 10.6046 31.7713 11.5 30.6667 11.5C29.5622 11.5 28.6667 10.6046 28.6667 9.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35.6667 9.5C35.6667 8.39543 36.5622 7.5 37.6667 7.5C38.7713 7.5 39.6667 8.39543 39.6667 9.5C39.6667 10.6046 38.7713 11.5 37.6667 11.5C36.5622 11.5 35.6667 10.6046 35.6667 9.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M42.6667 9.5C42.6667 8.39543 43.5622 7.5 44.6667 7.5C45.7713 7.5 46.6667 8.39543 46.6667 9.5C46.6667 10.6046 45.7713 11.5 44.6667 11.5C43.5622 11.5 42.6667 10.6046 42.6667 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.6667 9.5C49.6667 8.39543 50.5622 7.5 51.6667 7.5C52.7713 7.5 53.6667 8.39543 53.6667 9.5C53.6667 10.6046 52.7713 11.5 51.6667 11.5C50.5622 11.5 49.6667 10.6046 49.6667 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56.6667 9.5C56.6667 8.39543 57.5622 7.5 58.6667 7.5C59.7713 7.5 60.6667 8.39543 60.6667 9.5C60.6667 10.6046 59.7713 11.5 58.6667 11.5C57.5622 11.5 56.6667 10.6046 56.6667 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0.666748 16.5C0.666748 15.3954 1.56218 14.5 2.66675 14.5C3.77132 14.5 4.66675 15.3954 4.66675 16.5C4.66675 17.6046 3.77132 18.5 2.66675 18.5C1.56218 18.5 0.666748 17.6046 0.666748 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M7.66675 16.5C7.66675 15.3954 8.56218 14.5 9.66675 14.5C10.7713 14.5 11.6667 15.3954 11.6667 16.5C11.6667 17.6046 10.7713 18.5 9.66675 18.5C8.56218 18.5 7.66675 17.6046 7.66675 16.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M14.6667 16.5C14.6667 15.3954 15.5622 14.5 16.6667 14.5C17.7713 14.5 18.6667 15.3954 18.6667 16.5C18.6667 17.6046 17.7713 18.5 16.6667 18.5C15.5622 18.5 14.6667 17.6046 14.6667 16.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21.6667 16.5C21.6667 15.3954 22.5622 14.5 23.6667 14.5C24.7713 14.5 25.6667 15.3954 25.6667 16.5C25.6667 17.6046 24.7713 18.5 23.6667 18.5C22.5622 18.5 21.6667 17.6046 21.6667 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28.6667 16.5C28.6667 15.3954 29.5622 14.5 30.6667 14.5C31.7713 14.5 32.6667 15.3954 32.6667 16.5C32.6667 17.6046 31.7713 18.5 30.6667 18.5C29.5622 18.5 28.6667 17.6046 28.6667 16.5Z" fill="var(--color-text-warning)" /> - <path d="M35.6667 16.5C35.6667 15.3954 36.5622 14.5 37.6667 14.5C38.7713 14.5 39.6667 15.3954 39.6667 16.5C39.6667 17.6046 38.7713 18.5 37.6667 18.5C36.5622 18.5 35.6667 17.6046 35.6667 16.5Z" fill="var(--color-text-warning)" /> - <path d="M42.6667 16.5C42.6667 15.3954 43.5622 14.5 44.6667 14.5C45.7713 14.5 46.6667 15.3954 46.6667 16.5C46.6667 17.6046 45.7713 18.5 44.6667 18.5C43.5622 18.5 42.6667 17.6046 42.6667 16.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M49.6667 16.5C49.6667 15.3954 50.5622 14.5 51.6667 14.5C52.7713 14.5 53.6667 15.3954 53.6667 16.5C53.6667 17.6046 52.7713 18.5 51.6667 18.5C50.5622 18.5 49.6667 17.6046 49.6667 16.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56.6667 16.5C56.6667 15.3954 57.5622 14.5 58.6667 14.5C59.7713 14.5 60.6667 15.3954 60.6667 16.5C60.6667 17.6046 59.7713 18.5 58.6667 18.5C57.5622 18.5 56.6667 17.6046 56.6667 16.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0.666748 23.5C0.666748 22.3954 1.56218 21.5 2.66675 21.5C3.77132 21.5 4.66675 22.3954 4.66675 23.5C4.66675 24.6046 3.77132 25.5 2.66675 25.5C1.56218 25.5 0.666748 24.6046 0.666748 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7.66675 23.5C7.66675 22.3954 8.56218 21.5 9.66675 21.5C10.7713 21.5 11.6667 22.3954 11.6667 23.5C11.6667 24.6046 10.7713 25.5 9.66675 25.5C8.56218 25.5 7.66675 24.6046 7.66675 23.5Z" fill="var(--color-text-quaternary)" /> - <path d="M14.6667 23.5C14.6667 22.3954 15.5622 21.5 16.6667 21.5C17.7713 21.5 18.6667 22.3954 18.6667 23.5C18.6667 24.6046 17.7713 25.5 16.6667 25.5C15.5622 25.5 14.6667 24.6046 14.6667 23.5Z" fill="var(--color-text-warning)" /> - <path d="M21.6667 23.5C21.6667 22.3954 22.5622 21.5 23.6667 21.5C24.7713 21.5 25.6667 22.3954 25.6667 23.5C25.6667 24.6046 24.7713 25.5 23.6667 25.5C22.5622 25.5 21.6667 24.6046 21.6667 23.5Z" fill="var(--color-text-warning)" /> - <path d="M28.6667 23.5C28.6667 22.3954 29.5622 21.5 30.6667 21.5C31.7713 21.5 32.6667 22.3954 32.6667 23.5C32.6667 24.6046 31.7713 25.5 30.6667 25.5C29.5622 25.5 28.6667 24.6046 28.6667 23.5Z" fill="var(--color-text-warning)" /> - <path d="M35.6667 23.5C35.6667 22.3954 36.5622 21.5 37.6667 21.5C38.7713 21.5 39.6667 22.3954 39.6667 23.5C39.6667 24.6046 38.7713 25.5 37.6667 25.5C36.5622 25.5 35.6667 24.6046 35.6667 23.5Z" fill="var(--color-text-warning)" /> - <path d="M42.6667 23.5C42.6667 22.3954 43.5622 21.5 44.6667 21.5C45.7713 21.5 46.6667 22.3954 46.6667 23.5C46.6667 24.6046 45.7713 25.5 44.6667 25.5C43.5622 25.5 42.6667 24.6046 42.6667 23.5Z" fill="var(--color-text-warning)" /> - <path d="M49.6667 23.5C49.6667 22.3954 50.5622 21.5 51.6667 21.5C52.7713 21.5 53.6667 22.3954 53.6667 23.5C53.6667 24.6046 52.7713 25.5 51.6667 25.5C50.5622 25.5 49.6667 24.6046 49.6667 23.5Z" fill="var(--color-text-warning)" /> - <path d="M56.6667 23.5C56.6667 22.3954 57.5622 21.5 58.6667 21.5C59.7713 21.5 60.6667 22.3954 60.6667 23.5C60.6667 24.6046 59.7713 25.5 58.6667 25.5C57.5622 25.5 56.6667 24.6046 56.6667 23.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M0.666748 30.5C0.666748 29.3954 1.56218 28.5 2.66675 28.5C3.77132 28.5 4.66675 29.3954 4.66675 30.5C4.66675 31.6046 3.77132 32.5 2.66675 32.5C1.56218 32.5 0.666748 31.6046 0.666748 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7.66675 30.5C7.66675 29.3954 8.56218 28.5 9.66675 28.5C10.7713 28.5 11.6667 29.3954 11.6667 30.5C11.6667 31.6046 10.7713 32.5 9.66675 32.5C8.56218 32.5 7.66675 31.6046 7.66675 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.6667 30.5C14.6667 29.3954 15.5622 28.5 16.6667 28.5C17.7713 28.5 18.6667 29.3954 18.6667 30.5C18.6667 31.6046 17.7713 32.5 16.6667 32.5C15.5622 32.5 14.6667 31.6046 14.6667 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21.6667 30.5C21.6667 29.3954 22.5622 28.5 23.6667 28.5C24.7713 28.5 25.6667 29.3954 25.6667 30.5C25.6667 31.6046 24.7713 32.5 23.6667 32.5C22.5622 32.5 21.6667 31.6046 21.6667 30.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28.6667 30.5C28.6667 29.3954 29.5622 28.5 30.6667 28.5C31.7713 28.5 32.6667 29.3954 32.6667 30.5C32.6667 31.6046 31.7713 32.5 30.6667 32.5C29.5622 32.5 28.6667 31.6046 28.6667 30.5Z" fill="var(--color-text-warning)" /> - <path d="M35.6667 30.5C35.6667 29.3954 36.5622 28.5 37.6667 28.5C38.7713 28.5 39.6667 29.3954 39.6667 30.5C39.6667 31.6046 38.7713 32.5 37.6667 32.5C36.5622 32.5 35.6667 31.6046 35.6667 30.5Z" fill="var(--color-text-warning)" /> - <path d="M42.6667 30.5C42.6667 29.3954 43.5622 28.5 44.6667 28.5C45.7713 28.5 46.6667 29.3954 46.6667 30.5C46.6667 31.6046 45.7713 32.5 44.6667 32.5C43.5622 32.5 42.6667 31.6046 42.6667 30.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M49.6667 30.5C49.6667 29.3954 50.5622 28.5 51.6667 28.5C52.7713 28.5 53.6667 29.3954 53.6667 30.5C53.6667 31.6046 52.7713 32.5 51.6667 32.5C50.5622 32.5 49.6667 31.6046 49.6667 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56.6667 30.5C56.6667 29.3954 57.5622 28.5 58.6667 28.5C59.7713 28.5 60.6667 29.3954 60.6667 30.5C60.6667 31.6046 59.7713 32.5 58.6667 32.5C57.5622 32.5 56.6667 31.6046 56.6667 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0.666748 37.5C0.666748 36.3954 1.56218 35.5 2.66675 35.5C3.77132 35.5 4.66675 36.3954 4.66675 37.5C4.66675 38.6046 3.77132 39.5 2.66675 39.5C1.56218 39.5 0.666748 38.6046 0.666748 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7.66675 37.5C7.66675 36.3954 8.56218 35.5 9.66675 35.5C10.7713 35.5 11.6667 36.3954 11.6667 37.5C11.6667 38.6046 10.7713 39.5 9.66675 39.5C8.56218 39.5 7.66675 38.6046 7.66675 37.5Z" fill="var(--color-text-quaternary)" /> - <path d="M14.6667 37.5C14.6667 36.3954 15.5622 35.5 16.6667 35.5C17.7713 35.5 18.6667 36.3954 18.6667 37.5C18.6667 38.6046 17.7713 39.5 16.6667 39.5C15.5622 39.5 14.6667 38.6046 14.6667 37.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M21.6667 37.5C21.6667 36.3954 22.5622 35.5 23.6667 35.5C24.7713 35.5 25.6667 36.3954 25.6667 37.5C25.6667 38.6046 24.7713 39.5 23.6667 39.5C22.5622 39.5 21.6667 38.6046 21.6667 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.6667 37.5C28.6667 36.3954 29.5622 35.5 30.6667 35.5C31.7713 35.5 32.6667 36.3954 32.6667 37.5C32.6667 38.6046 31.7713 39.5 30.6667 39.5C29.5622 39.5 28.6667 38.6046 28.6667 37.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35.6667 37.5C35.6667 36.3954 36.5622 35.5 37.6667 35.5C38.7713 35.5 39.6667 36.3954 39.6667 37.5C39.6667 38.6046 38.7713 39.5 37.6667 39.5C36.5622 39.5 35.6667 38.6046 35.6667 37.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M42.6667 37.5C42.6667 36.3954 43.5622 35.5 44.6667 35.5C45.7713 35.5 46.6667 36.3954 46.6667 37.5C46.6667 38.6046 45.7713 39.5 44.6667 39.5C43.5622 39.5 42.6667 38.6046 42.6667 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.6667 37.5C49.6667 36.3954 50.5622 35.5 51.6667 35.5C52.7713 35.5 53.6667 36.3954 53.6667 37.5C53.6667 38.6046 52.7713 39.5 51.6667 39.5C50.5622 39.5 49.6667 38.6046 49.6667 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56.6667 37.5C56.6667 36.3954 57.5622 35.5 58.6667 35.5C59.7713 35.5 60.6667 36.3954 60.6667 37.5C60.6667 38.6046 59.7713 39.5 58.6667 39.5C57.5622 39.5 56.6667 38.6046 56.6667 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0.666748 44.5C0.666748 43.3954 1.56218 42.5 2.66675 42.5C3.77132 42.5 4.66675 43.3954 4.66675 44.5C4.66675 45.6046 3.77132 46.5 2.66675 46.5C1.56218 46.5 0.666748 45.6046 0.666748 44.5Z" fill="var(--color-text-quaternary)" /> - <path d="M7.66675 44.5C7.66675 43.3954 8.56218 42.5 9.66675 42.5C10.7713 42.5 11.6667 43.3954 11.6667 44.5C11.6667 45.6046 10.7713 46.5 9.66675 46.5C8.56218 46.5 7.66675 45.6046 7.66675 44.5Z" fill="var(--color-text-warning)" /> - <path d="M14.6667 44.5C14.6667 43.3954 15.5622 42.5 16.6667 42.5C17.7713 42.5 18.6667 43.3954 18.6667 44.5C18.6667 45.6046 17.7713 46.5 16.6667 46.5C15.5622 46.5 14.6667 45.6046 14.6667 44.5Z" fill="var(--color-text-warning)" /> - <path d="M21.6667 44.5C21.6667 43.3954 22.5622 42.5 23.6667 42.5C24.7713 42.5 25.6667 43.3954 25.6667 44.5C25.6667 45.6046 24.7713 46.5 23.6667 46.5C22.5622 46.5 21.6667 45.6046 21.6667 44.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M28.6667 44.5C28.6667 43.3954 29.5622 42.5 30.6667 42.5C31.7713 42.5 32.6667 43.3954 32.6667 44.5C32.6667 45.6046 31.7713 46.5 30.6667 46.5C29.5622 46.5 28.6667 45.6046 28.6667 44.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35.6667 44.5C35.6667 43.3954 36.5622 42.5 37.6667 42.5C38.7713 42.5 39.6667 43.3954 39.6667 44.5C39.6667 45.6046 38.7713 46.5 37.6667 46.5C36.5622 46.5 35.6667 45.6046 35.6667 44.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M42.6667 44.5C42.6667 43.3954 43.5622 42.5 44.6667 42.5C45.7713 42.5 46.6667 43.3954 46.6667 44.5C46.6667 45.6046 45.7713 46.5 44.6667 46.5C43.5622 46.5 42.6667 45.6046 42.6667 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.6667 44.5C49.6667 43.3954 50.5622 42.5 51.6667 42.5C52.7713 42.5 53.6667 43.3954 53.6667 44.5C53.6667 45.6046 52.7713 46.5 51.6667 46.5C50.5622 46.5 49.6667 45.6046 49.6667 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56.6667 44.5C56.6667 43.3954 57.5622 42.5 58.6667 42.5C59.7713 42.5 60.6667 43.3954 60.6667 44.5C60.6667 45.6046 59.7713 46.5 58.6667 46.5C57.5622 46.5 56.6667 45.6046 56.6667 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0.666748 51.5C0.666748 50.3954 1.56218 49.5 2.66675 49.5C3.77132 49.5 4.66675 50.3954 4.66675 51.5C4.66675 52.6046 3.77132 53.5 2.66675 53.5C1.56218 53.5 0.666748 52.6046 0.666748 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7.66675 51.5C7.66675 50.3954 8.56218 49.5 9.66675 49.5C10.7713 49.5 11.6667 50.3954 11.6667 51.5C11.6667 52.6046 10.7713 53.5 9.66675 53.5C8.56218 53.5 7.66675 52.6046 7.66675 51.5Z" fill="var(--color-text-quaternary)" /> - <path d="M14.6667 51.5C14.6667 50.3954 15.5622 49.5 16.6667 49.5C17.7713 49.5 18.6667 50.3954 18.6667 51.5C18.6667 52.6046 17.7713 53.5 16.6667 53.5C15.5622 53.5 14.6667 52.6046 14.6667 51.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M21.6667 51.5C21.6667 50.3954 22.5622 49.5 23.6667 49.5C24.7713 49.5 25.6667 50.3954 25.6667 51.5C25.6667 52.6046 24.7713 53.5 23.6667 53.5C22.5622 53.5 21.6667 52.6046 21.6667 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.6667 51.5C28.6667 50.3954 29.5622 49.5 30.6667 49.5C31.7713 49.5 32.6667 50.3954 32.6667 51.5C32.6667 52.6046 31.7713 53.5 30.6667 53.5C29.5622 53.5 28.6667 52.6046 28.6667 51.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35.6667 51.5C35.6667 50.3954 36.5622 49.5 37.6667 49.5C38.7713 49.5 39.6667 50.3954 39.6667 51.5C39.6667 52.6046 38.7713 53.5 37.6667 53.5C36.5622 53.5 35.6667 52.6046 35.6667 51.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M42.6667 51.5C42.6667 50.3954 43.5622 49.5 44.6667 49.5C45.7713 49.5 46.6667 50.3954 46.6667 51.5C46.6667 52.6046 45.7713 53.5 44.6667 53.5C43.5622 53.5 42.6667 52.6046 42.6667 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.6667 51.5C49.6667 50.3954 50.5622 49.5 51.6667 49.5C52.7713 49.5 53.6667 50.3954 53.6667 51.5C53.6667 52.6046 52.7713 53.5 51.6667 53.5C50.5622 53.5 49.6667 52.6046 49.6667 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56.6667 51.5C56.6667 50.3954 57.5622 49.5 58.6667 49.5C59.7713 49.5 60.6667 50.3954 60.6667 51.5C60.6667 52.6046 59.7713 53.5 58.6667 53.5C57.5622 53.5 56.6667 52.6046 56.6667 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0.666748 58.5C0.666748 57.3954 1.56218 56.5 2.66675 56.5C3.77132 56.5 4.66675 57.3954 4.66675 58.5C4.66675 59.6046 3.77132 60.5 2.66675 60.5C1.56218 60.5 0.666748 59.6046 0.666748 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7.66675 58.5C7.66675 57.3954 8.56218 56.5 9.66675 56.5C10.7713 56.5 11.6667 57.3954 11.6667 58.5C11.6667 59.6046 10.7713 60.5 9.66675 60.5C8.56218 60.5 7.66675 59.6046 7.66675 58.5Z" fill="var(--color-text-quaternary)" /> - <path d="M14.6667 58.5C14.6667 57.3954 15.5622 56.5 16.6667 56.5C17.7713 56.5 18.6667 57.3954 18.6667 58.5C18.6667 59.6046 17.7713 60.5 16.6667 60.5C15.5622 60.5 14.6667 59.6046 14.6667 58.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M21.6667 58.5C21.6667 57.3954 22.5622 56.5 23.6667 56.5C24.7713 56.5 25.6667 57.3954 25.6667 58.5C25.6667 59.6046 24.7713 60.5 23.6667 60.5C22.5622 60.5 21.6667 59.6046 21.6667 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.6667 58.5C28.6667 57.3954 29.5622 56.5 30.6667 56.5C31.7713 56.5 32.6667 57.3954 32.6667 58.5C32.6667 59.6046 31.7713 60.5 30.6667 60.5C29.5622 60.5 28.6667 59.6046 28.6667 58.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35.6667 58.5C35.6667 57.3954 36.5622 56.5 37.6667 56.5C38.7713 56.5 39.6667 57.3954 39.6667 58.5C39.6667 59.6046 38.7713 60.5 37.6667 60.5C36.5622 60.5 35.6667 59.6046 35.6667 58.5Z" fill="var(--color-text-warning)" /> - <path opacity="0.18" d="M42.6667 58.5C42.6667 57.3954 43.5622 56.5 44.6667 56.5C45.7713 56.5 46.6667 57.3954 46.6667 58.5C46.6667 59.6046 45.7713 60.5 44.6667 60.5C43.5622 60.5 42.6667 59.6046 42.6667 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.6667 58.5C49.6667 57.3954 50.5622 56.5 51.6667 56.5C52.7713 56.5 53.6667 57.3954 53.6667 58.5C53.6667 59.6046 52.7713 60.5 51.6667 60.5C50.5622 60.5 49.6667 59.6046 49.6667 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56.6667 58.5C56.6667 57.3954 57.5622 56.5 58.6667 56.5C59.7713 56.5 60.6667 57.3954 60.6667 58.5C60.6667 59.6046 59.7713 60.5 58.6667 60.5C57.5622 60.5 56.6667 59.6046 56.6667 58.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M0.666748 2.5C0.666748 1.39543 1.56218 0.5 2.66675 0.5C3.77132 0.5 4.66675 1.39543 4.66675 2.5C4.66675 3.60457 3.77132 4.5 2.66675 4.5C1.56218 4.5 0.666748 3.60457 0.666748 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M7.66675 2.5C7.66675 1.39543 8.56218 0.5 9.66675 0.5C10.7713 0.5 11.6667 1.39543 11.6667 2.5C11.6667 3.60457 10.7713 4.5 9.66675 4.5C8.56218 4.5 7.66675 3.60457 7.66675 2.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M14.6667 2.5C14.6667 1.39543 15.5622 0.5 16.6667 0.5C17.7713 0.5 18.6667 1.39543 18.6667 2.5C18.6667 3.60457 17.7713 4.5 16.6667 4.5C15.5622 4.5 14.6667 3.60457 14.6667 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21.6667 2.5C21.6667 1.39543 22.5622 0.5 23.6667 0.5C24.7713 0.5 25.6667 1.39543 25.6667 2.5C25.6667 3.60457 24.7713 4.5 23.6667 4.5C22.5622 4.5 21.6667 3.60457 21.6667 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.6667 2.5C28.6667 1.39543 29.5622 0.5 30.6667 0.5C31.7713 0.5 32.6667 1.39543 32.6667 2.5C32.6667 3.60457 31.7713 4.5 30.6667 4.5C29.5622 4.5 28.6667 3.60457 28.6667 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35.6667 2.5C35.6667 1.39543 36.5622 0.5 37.6667 0.5C38.7713 0.5 39.6667 1.39543 39.6667 2.5C39.6667 3.60457 38.7713 4.5 37.6667 4.5C36.5622 4.5 35.6667 3.60457 35.6667 2.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M42.6667 2.5C42.6667 1.39543 43.5622 0.5 44.6667 0.5C45.7713 0.5 46.6667 1.39543 46.6667 2.5C46.6667 3.60457 45.7713 4.5 44.6667 4.5C43.5622 4.5 42.6667 3.60457 42.6667 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.6667 2.5C49.6667 1.39543 50.5622 0.5 51.6667 0.5C52.7713 0.5 53.6667 1.39543 53.6667 2.5C53.6667 3.60457 52.7713 4.5 51.6667 4.5C50.5622 4.5 49.6667 3.60457 49.6667 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56.6667 2.5C56.6667 1.39543 57.5622 0.5 58.6667 0.5C59.7713 0.5 60.6667 1.39543 60.6667 2.5C60.6667 3.60457 59.7713 4.5 58.6667 4.5C57.5622 4.5 56.6667 3.60457 56.6667 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M0.666748 9.5C0.666748 8.39543 1.56218 7.5 2.66675 7.5C3.77132 7.5 4.66675 8.39543 4.66675 9.5C4.66675 10.6046 3.77132 11.5 2.66675 11.5C1.56218 11.5 0.666748 10.6046 0.666748 9.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M7.66675 9.5C7.66675 8.39543 8.56218 7.5 9.66675 7.5C10.7713 7.5 11.6667 8.39543 11.6667 9.5C11.6667 10.6046 10.7713 11.5 9.66675 11.5C8.56218 11.5 7.66675 10.6046 7.66675 9.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M14.6667 9.5C14.6667 8.39543 15.5622 7.5 16.6667 7.5C17.7713 7.5 18.6667 8.39543 18.6667 9.5C18.6667 10.6046 17.7713 11.5 16.6667 11.5C15.5622 11.5 14.6667 10.6046 14.6667 9.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M21.6667 9.5C21.6667 8.39543 22.5622 7.5 23.6667 7.5C24.7713 7.5 25.6667 8.39543 25.6667 9.5C25.6667 10.6046 24.7713 11.5 23.6667 11.5C22.5622 11.5 21.6667 10.6046 21.6667 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.6667 9.5C28.6667 8.39543 29.5622 7.5 30.6667 7.5C31.7713 7.5 32.6667 8.39543 32.6667 9.5C32.6667 10.6046 31.7713 11.5 30.6667 11.5C29.5622 11.5 28.6667 10.6046 28.6667 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35.6667 9.5C35.6667 8.39543 36.5622 7.5 37.6667 7.5C38.7713 7.5 39.6667 8.39543 39.6667 9.5C39.6667 10.6046 38.7713 11.5 37.6667 11.5C36.5622 11.5 35.6667 10.6046 35.6667 9.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M42.6667 9.5C42.6667 8.39543 43.5622 7.5 44.6667 7.5C45.7713 7.5 46.6667 8.39543 46.6667 9.5C46.6667 10.6046 45.7713 11.5 44.6667 11.5C43.5622 11.5 42.6667 10.6046 42.6667 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.6667 9.5C49.6667 8.39543 50.5622 7.5 51.6667 7.5C52.7713 7.5 53.6667 8.39543 53.6667 9.5C53.6667 10.6046 52.7713 11.5 51.6667 11.5C50.5622 11.5 49.6667 10.6046 49.6667 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56.6667 9.5C56.6667 8.39543 57.5622 7.5 58.6667 7.5C59.7713 7.5 60.6667 8.39543 60.6667 9.5C60.6667 10.6046 59.7713 11.5 58.6667 11.5C57.5622 11.5 56.6667 10.6046 56.6667 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0.666748 16.5C0.666748 15.3954 1.56218 14.5 2.66675 14.5C3.77132 14.5 4.66675 15.3954 4.66675 16.5C4.66675 17.6046 3.77132 18.5 2.66675 18.5C1.56218 18.5 0.666748 17.6046 0.666748 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M7.66675 16.5C7.66675 15.3954 8.56218 14.5 9.66675 14.5C10.7713 14.5 11.6667 15.3954 11.6667 16.5C11.6667 17.6046 10.7713 18.5 9.66675 18.5C8.56218 18.5 7.66675 17.6046 7.66675 16.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M14.6667 16.5C14.6667 15.3954 15.5622 14.5 16.6667 14.5C17.7713 14.5 18.6667 15.3954 18.6667 16.5C18.6667 17.6046 17.7713 18.5 16.6667 18.5C15.5622 18.5 14.6667 17.6046 14.6667 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21.6667 16.5C21.6667 15.3954 22.5622 14.5 23.6667 14.5C24.7713 14.5 25.6667 15.3954 25.6667 16.5C25.6667 17.6046 24.7713 18.5 23.6667 18.5C22.5622 18.5 21.6667 17.6046 21.6667 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28.6667 16.5C28.6667 15.3954 29.5622 14.5 30.6667 14.5C31.7713 14.5 32.6667 15.3954 32.6667 16.5C32.6667 17.6046 31.7713 18.5 30.6667 18.5C29.5622 18.5 28.6667 17.6046 28.6667 16.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M35.6667 16.5C35.6667 15.3954 36.5622 14.5 37.6667 14.5C38.7713 14.5 39.6667 15.3954 39.6667 16.5C39.6667 17.6046 38.7713 18.5 37.6667 18.5C36.5622 18.5 35.6667 17.6046 35.6667 16.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M42.6667 16.5C42.6667 15.3954 43.5622 14.5 44.6667 14.5C45.7713 14.5 46.6667 15.3954 46.6667 16.5C46.6667 17.6046 45.7713 18.5 44.6667 18.5C43.5622 18.5 42.6667 17.6046 42.6667 16.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M49.6667 16.5C49.6667 15.3954 50.5622 14.5 51.6667 14.5C52.7713 14.5 53.6667 15.3954 53.6667 16.5C53.6667 17.6046 52.7713 18.5 51.6667 18.5C50.5622 18.5 49.6667 17.6046 49.6667 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56.6667 16.5C56.6667 15.3954 57.5622 14.5 58.6667 14.5C59.7713 14.5 60.6667 15.3954 60.6667 16.5C60.6667 17.6046 59.7713 18.5 58.6667 18.5C57.5622 18.5 56.6667 17.6046 56.6667 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0.666748 23.5C0.666748 22.3954 1.56218 21.5 2.66675 21.5C3.77132 21.5 4.66675 22.3954 4.66675 23.5C4.66675 24.6046 3.77132 25.5 2.66675 25.5C1.56218 25.5 0.666748 24.6046 0.666748 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7.66675 23.5C7.66675 22.3954 8.56218 21.5 9.66675 21.5C10.7713 21.5 11.6667 22.3954 11.6667 23.5C11.6667 24.6046 10.7713 25.5 9.66675 25.5C8.56218 25.5 7.66675 24.6046 7.66675 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M14.6667 23.5C14.6667 22.3954 15.5622 21.5 16.6667 21.5C17.7713 21.5 18.6667 22.3954 18.6667 23.5C18.6667 24.6046 17.7713 25.5 16.6667 25.5C15.5622 25.5 14.6667 24.6046 14.6667 23.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M21.6667 23.5C21.6667 22.3954 22.5622 21.5 23.6667 21.5C24.7713 21.5 25.6667 22.3954 25.6667 23.5C25.6667 24.6046 24.7713 25.5 23.6667 25.5C22.5622 25.5 21.6667 24.6046 21.6667 23.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M28.6667 23.5C28.6667 22.3954 29.5622 21.5 30.6667 21.5C31.7713 21.5 32.6667 22.3954 32.6667 23.5C32.6667 24.6046 31.7713 25.5 30.6667 25.5C29.5622 25.5 28.6667 24.6046 28.6667 23.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M35.6667 23.5C35.6667 22.3954 36.5622 21.5 37.6667 21.5C38.7713 21.5 39.6667 22.3954 39.6667 23.5C39.6667 24.6046 38.7713 25.5 37.6667 25.5C36.5622 25.5 35.6667 24.6046 35.6667 23.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M42.6667 23.5C42.6667 22.3954 43.5622 21.5 44.6667 21.5C45.7713 21.5 46.6667 22.3954 46.6667 23.5C46.6667 24.6046 45.7713 25.5 44.6667 25.5C43.5622 25.5 42.6667 24.6046 42.6667 23.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M49.6667 23.5C49.6667 22.3954 50.5622 21.5 51.6667 21.5C52.7713 21.5 53.6667 22.3954 53.6667 23.5C53.6667 24.6046 52.7713 25.5 51.6667 25.5C50.5622 25.5 49.6667 24.6046 49.6667 23.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M56.6667 23.5C56.6667 22.3954 57.5622 21.5 58.6667 21.5C59.7713 21.5 60.6667 22.3954 60.6667 23.5C60.6667 24.6046 59.7713 25.5 58.6667 25.5C57.5622 25.5 56.6667 24.6046 56.6667 23.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M0.666748 30.5C0.666748 29.3954 1.56218 28.5 2.66675 28.5C3.77132 28.5 4.66675 29.3954 4.66675 30.5C4.66675 31.6046 3.77132 32.5 2.66675 32.5C1.56218 32.5 0.666748 31.6046 0.666748 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7.66675 30.5C7.66675 29.3954 8.56218 28.5 9.66675 28.5C10.7713 28.5 11.6667 29.3954 11.6667 30.5C11.6667 31.6046 10.7713 32.5 9.66675 32.5C8.56218 32.5 7.66675 31.6046 7.66675 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.6667 30.5C14.6667 29.3954 15.5622 28.5 16.6667 28.5C17.7713 28.5 18.6667 29.3954 18.6667 30.5C18.6667 31.6046 17.7713 32.5 16.6667 32.5C15.5622 32.5 14.6667 31.6046 14.6667 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21.6667 30.5C21.6667 29.3954 22.5622 28.5 23.6667 28.5C24.7713 28.5 25.6667 29.3954 25.6667 30.5C25.6667 31.6046 24.7713 32.5 23.6667 32.5C22.5622 32.5 21.6667 31.6046 21.6667 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28.6667 30.5C28.6667 29.3954 29.5622 28.5 30.6667 28.5C31.7713 28.5 32.6667 29.3954 32.6667 30.5C32.6667 31.6046 31.7713 32.5 30.6667 32.5C29.5622 32.5 28.6667 31.6046 28.6667 30.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M35.6667 30.5C35.6667 29.3954 36.5622 28.5 37.6667 28.5C38.7713 28.5 39.6667 29.3954 39.6667 30.5C39.6667 31.6046 38.7713 32.5 37.6667 32.5C36.5622 32.5 35.6667 31.6046 35.6667 30.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M42.6667 30.5C42.6667 29.3954 43.5622 28.5 44.6667 28.5C45.7713 28.5 46.6667 29.3954 46.6667 30.5C46.6667 31.6046 45.7713 32.5 44.6667 32.5C43.5622 32.5 42.6667 31.6046 42.6667 30.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M49.6667 30.5C49.6667 29.3954 50.5622 28.5 51.6667 28.5C52.7713 28.5 53.6667 29.3954 53.6667 30.5C53.6667 31.6046 52.7713 32.5 51.6667 32.5C50.5622 32.5 49.6667 31.6046 49.6667 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56.6667 30.5C56.6667 29.3954 57.5622 28.5 58.6667 28.5C59.7713 28.5 60.6667 29.3954 60.6667 30.5C60.6667 31.6046 59.7713 32.5 58.6667 32.5C57.5622 32.5 56.6667 31.6046 56.6667 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0.666748 37.5C0.666748 36.3954 1.56218 35.5 2.66675 35.5C3.77132 35.5 4.66675 36.3954 4.66675 37.5C4.66675 38.6046 3.77132 39.5 2.66675 39.5C1.56218 39.5 0.666748 38.6046 0.666748 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7.66675 37.5C7.66675 36.3954 8.56218 35.5 9.66675 35.5C10.7713 35.5 11.6667 36.3954 11.6667 37.5C11.6667 38.6046 10.7713 39.5 9.66675 39.5C8.56218 39.5 7.66675 38.6046 7.66675 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M14.6667 37.5C14.6667 36.3954 15.5622 35.5 16.6667 35.5C17.7713 35.5 18.6667 36.3954 18.6667 37.5C18.6667 38.6046 17.7713 39.5 16.6667 39.5C15.5622 39.5 14.6667 38.6046 14.6667 37.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M21.6667 37.5C21.6667 36.3954 22.5622 35.5 23.6667 35.5C24.7713 35.5 25.6667 36.3954 25.6667 37.5C25.6667 38.6046 24.7713 39.5 23.6667 39.5C22.5622 39.5 21.6667 38.6046 21.6667 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.6667 37.5C28.6667 36.3954 29.5622 35.5 30.6667 35.5C31.7713 35.5 32.6667 36.3954 32.6667 37.5C32.6667 38.6046 31.7713 39.5 30.6667 39.5C29.5622 39.5 28.6667 38.6046 28.6667 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35.6667 37.5C35.6667 36.3954 36.5622 35.5 37.6667 35.5C38.7713 35.5 39.6667 36.3954 39.6667 37.5C39.6667 38.6046 38.7713 39.5 37.6667 39.5C36.5622 39.5 35.6667 38.6046 35.6667 37.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M42.6667 37.5C42.6667 36.3954 43.5622 35.5 44.6667 35.5C45.7713 35.5 46.6667 36.3954 46.6667 37.5C46.6667 38.6046 45.7713 39.5 44.6667 39.5C43.5622 39.5 42.6667 38.6046 42.6667 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.6667 37.5C49.6667 36.3954 50.5622 35.5 51.6667 35.5C52.7713 35.5 53.6667 36.3954 53.6667 37.5C53.6667 38.6046 52.7713 39.5 51.6667 39.5C50.5622 39.5 49.6667 38.6046 49.6667 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56.6667 37.5C56.6667 36.3954 57.5622 35.5 58.6667 35.5C59.7713 35.5 60.6667 36.3954 60.6667 37.5C60.6667 38.6046 59.7713 39.5 58.6667 39.5C57.5622 39.5 56.6667 38.6046 56.6667 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0.666748 44.5C0.666748 43.3954 1.56218 42.5 2.66675 42.5C3.77132 42.5 4.66675 43.3954 4.66675 44.5C4.66675 45.6046 3.77132 46.5 2.66675 46.5C1.56218 46.5 0.666748 45.6046 0.666748 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M7.66675 44.5C7.66675 43.3954 8.56218 42.5 9.66675 42.5C10.7713 42.5 11.6667 43.3954 11.6667 44.5C11.6667 45.6046 10.7713 46.5 9.66675 46.5C8.56218 46.5 7.66675 45.6046 7.66675 44.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M14.6667 44.5C14.6667 43.3954 15.5622 42.5 16.6667 42.5C17.7713 42.5 18.6667 43.3954 18.6667 44.5C18.6667 45.6046 17.7713 46.5 16.6667 46.5C15.5622 46.5 14.6667 45.6046 14.6667 44.5Z" + fill="var(--color-text-warning)" + /> + <path + d="M21.6667 44.5C21.6667 43.3954 22.5622 42.5 23.6667 42.5C24.7713 42.5 25.6667 43.3954 25.6667 44.5C25.6667 45.6046 24.7713 46.5 23.6667 46.5C22.5622 46.5 21.6667 45.6046 21.6667 44.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M28.6667 44.5C28.6667 43.3954 29.5622 42.5 30.6667 42.5C31.7713 42.5 32.6667 43.3954 32.6667 44.5C32.6667 45.6046 31.7713 46.5 30.6667 46.5C29.5622 46.5 28.6667 45.6046 28.6667 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35.6667 44.5C35.6667 43.3954 36.5622 42.5 37.6667 42.5C38.7713 42.5 39.6667 43.3954 39.6667 44.5C39.6667 45.6046 38.7713 46.5 37.6667 46.5C36.5622 46.5 35.6667 45.6046 35.6667 44.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M42.6667 44.5C42.6667 43.3954 43.5622 42.5 44.6667 42.5C45.7713 42.5 46.6667 43.3954 46.6667 44.5C46.6667 45.6046 45.7713 46.5 44.6667 46.5C43.5622 46.5 42.6667 45.6046 42.6667 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.6667 44.5C49.6667 43.3954 50.5622 42.5 51.6667 42.5C52.7713 42.5 53.6667 43.3954 53.6667 44.5C53.6667 45.6046 52.7713 46.5 51.6667 46.5C50.5622 46.5 49.6667 45.6046 49.6667 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56.6667 44.5C56.6667 43.3954 57.5622 42.5 58.6667 42.5C59.7713 42.5 60.6667 43.3954 60.6667 44.5C60.6667 45.6046 59.7713 46.5 58.6667 46.5C57.5622 46.5 56.6667 45.6046 56.6667 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0.666748 51.5C0.666748 50.3954 1.56218 49.5 2.66675 49.5C3.77132 49.5 4.66675 50.3954 4.66675 51.5C4.66675 52.6046 3.77132 53.5 2.66675 53.5C1.56218 53.5 0.666748 52.6046 0.666748 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7.66675 51.5C7.66675 50.3954 8.56218 49.5 9.66675 49.5C10.7713 49.5 11.6667 50.3954 11.6667 51.5C11.6667 52.6046 10.7713 53.5 9.66675 53.5C8.56218 53.5 7.66675 52.6046 7.66675 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M14.6667 51.5C14.6667 50.3954 15.5622 49.5 16.6667 49.5C17.7713 49.5 18.6667 50.3954 18.6667 51.5C18.6667 52.6046 17.7713 53.5 16.6667 53.5C15.5622 53.5 14.6667 52.6046 14.6667 51.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M21.6667 51.5C21.6667 50.3954 22.5622 49.5 23.6667 49.5C24.7713 49.5 25.6667 50.3954 25.6667 51.5C25.6667 52.6046 24.7713 53.5 23.6667 53.5C22.5622 53.5 21.6667 52.6046 21.6667 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.6667 51.5C28.6667 50.3954 29.5622 49.5 30.6667 49.5C31.7713 49.5 32.6667 50.3954 32.6667 51.5C32.6667 52.6046 31.7713 53.5 30.6667 53.5C29.5622 53.5 28.6667 52.6046 28.6667 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35.6667 51.5C35.6667 50.3954 36.5622 49.5 37.6667 49.5C38.7713 49.5 39.6667 50.3954 39.6667 51.5C39.6667 52.6046 38.7713 53.5 37.6667 53.5C36.5622 53.5 35.6667 52.6046 35.6667 51.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M42.6667 51.5C42.6667 50.3954 43.5622 49.5 44.6667 49.5C45.7713 49.5 46.6667 50.3954 46.6667 51.5C46.6667 52.6046 45.7713 53.5 44.6667 53.5C43.5622 53.5 42.6667 52.6046 42.6667 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.6667 51.5C49.6667 50.3954 50.5622 49.5 51.6667 49.5C52.7713 49.5 53.6667 50.3954 53.6667 51.5C53.6667 52.6046 52.7713 53.5 51.6667 53.5C50.5622 53.5 49.6667 52.6046 49.6667 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56.6667 51.5C56.6667 50.3954 57.5622 49.5 58.6667 49.5C59.7713 49.5 60.6667 50.3954 60.6667 51.5C60.6667 52.6046 59.7713 53.5 58.6667 53.5C57.5622 53.5 56.6667 52.6046 56.6667 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0.666748 58.5C0.666748 57.3954 1.56218 56.5 2.66675 56.5C3.77132 56.5 4.66675 57.3954 4.66675 58.5C4.66675 59.6046 3.77132 60.5 2.66675 60.5C1.56218 60.5 0.666748 59.6046 0.666748 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7.66675 58.5C7.66675 57.3954 8.56218 56.5 9.66675 56.5C10.7713 56.5 11.6667 57.3954 11.6667 58.5C11.6667 59.6046 10.7713 60.5 9.66675 60.5C8.56218 60.5 7.66675 59.6046 7.66675 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M14.6667 58.5C14.6667 57.3954 15.5622 56.5 16.6667 56.5C17.7713 56.5 18.6667 57.3954 18.6667 58.5C18.6667 59.6046 17.7713 60.5 16.6667 60.5C15.5622 60.5 14.6667 59.6046 14.6667 58.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M21.6667 58.5C21.6667 57.3954 22.5622 56.5 23.6667 56.5C24.7713 56.5 25.6667 57.3954 25.6667 58.5C25.6667 59.6046 24.7713 60.5 23.6667 60.5C22.5622 60.5 21.6667 59.6046 21.6667 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.6667 58.5C28.6667 57.3954 29.5622 56.5 30.6667 56.5C31.7713 56.5 32.6667 57.3954 32.6667 58.5C32.6667 59.6046 31.7713 60.5 30.6667 60.5C29.5622 60.5 28.6667 59.6046 28.6667 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35.6667 58.5C35.6667 57.3954 36.5622 56.5 37.6667 56.5C38.7713 56.5 39.6667 57.3954 39.6667 58.5C39.6667 59.6046 38.7713 60.5 37.6667 60.5C36.5622 60.5 35.6667 59.6046 35.6667 58.5Z" + fill="var(--color-text-warning)" + /> + <path + opacity="0.18" + d="M42.6667 58.5C42.6667 57.3954 43.5622 56.5 44.6667 56.5C45.7713 56.5 46.6667 57.3954 46.6667 58.5C46.6667 59.6046 45.7713 60.5 44.6667 60.5C43.5622 60.5 42.6667 59.6046 42.6667 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.6667 58.5C49.6667 57.3954 50.5622 56.5 51.6667 56.5C52.7713 56.5 53.6667 57.3954 53.6667 58.5C53.6667 59.6046 52.7713 60.5 51.6667 60.5C50.5622 60.5 49.6667 59.6046 49.6667 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56.6667 58.5C56.6667 57.3954 57.5622 56.5 58.6667 56.5C59.7713 56.5 60.6667 57.3954 60.6667 58.5C60.6667 59.6046 59.7713 60.5 58.6667 60.5C57.5622 60.5 56.6667 59.6046 56.6667 58.5Z" + fill="var(--color-text-quaternary)" + /> </g> <defs> <clipPath id="clip0_1_5240"> diff --git a/web/app/components/billing/pricing/assets/professional.tsx b/web/app/components/billing/pricing/assets/professional.tsx index 7dafc0a3ed10f4..0dc9918a1ad0b2 100644 --- a/web/app/components/billing/pricing/assets/professional.tsx +++ b/web/app/components/billing/pricing/assets/professional.tsx @@ -2,87 +2,711 @@ const Professional = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" width="61" height="61" viewBox="0 0 61 61" fill="none"> <g clipPath="url(#clip0_1_4802)"> - <rect opacity="0.18" x="0.666626" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="7.66663" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="14.6666" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="21.6666" y="0.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect x="28.6666" y="0.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect x="35.6666" y="0.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="42.6666" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="49.6666" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="56.6666" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="0.666626" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="7.66663" y="7.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect x="14.6666" y="7.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="21.6666" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28.6666" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35.6666" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="42.6666" y="7.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect x="49.6666" y="7.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="56.6666" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="0.666626" y="14.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="7.66663" y="14.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="14.6666" y="14.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="21.6666" y="14.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28.6666" y="14.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35.6666" y="14.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="42.6666" y="14.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="49.6666" y="14.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="56.6666" y="14.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="0.666626" y="21.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="7.66663" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="14.6666" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="21.6666" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28.6666" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35.6666" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="42.6666" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="49.6666" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="56.6666" y="21.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect x="0.666626" y="28.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="7.66663" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="14.6666" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="21.6666" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28.6666" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35.6666" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="42.6666" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="49.6666" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="56.6666" y="28.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect x="0.666626" y="35.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="7.66663" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="14.6666" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="21.6666" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28.6666" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35.6666" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="42.6666" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="49.6666" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="56.6666" y="35.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="0.666626" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="7.66663" y="42.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="14.6666" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="21.6666" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28.6666" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35.6666" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="42.6666" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="49.6666" y="42.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="56.6666" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="0.666626" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="7.66663" y="49.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect x="14.6666" y="49.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="21.6666" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28.6666" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35.6666" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="42.6666" y="49.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect x="49.6666" y="49.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="56.6666" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="0.666626" y="56.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="7.66663" y="56.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="14.6666" y="56.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect x="21.6666" y="56.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect x="28.6666" y="56.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect x="35.6666" y="56.5" width="4" height="4" rx="2" fill="var(--color-saas-dify-blue-accessible)" /> - <rect opacity="0.18" x="42.6666" y="56.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="49.6666" y="56.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="56.6666" y="56.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + x="0.666626" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="7.66663" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="14.6666" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="21.6666" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + x="28.6666" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + x="35.6666" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="42.6666" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="49.6666" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="56.6666" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="0.666626" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="7.66663" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + x="14.6666" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="21.6666" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28.6666" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35.6666" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="42.6666" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + x="49.6666" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="56.6666" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="0.666626" + y="14.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="7.66663" + y="14.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="14.6666" + y="14.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="21.6666" + y="14.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28.6666" + y="14.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35.6666" + y="14.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="42.6666" + y="14.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="49.6666" + y="14.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="56.6666" + y="14.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="0.666626" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="7.66663" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="14.6666" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="21.6666" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28.6666" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35.6666" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="42.6666" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="49.6666" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="56.6666" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + x="0.666626" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="7.66663" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="14.6666" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="21.6666" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28.6666" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35.6666" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="42.6666" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="49.6666" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="56.6666" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + x="0.666626" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="7.66663" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="14.6666" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="21.6666" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28.6666" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35.6666" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="42.6666" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="49.6666" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="56.6666" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="0.666626" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="7.66663" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="14.6666" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="21.6666" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28.6666" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35.6666" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="42.6666" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="49.6666" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="56.6666" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="0.666626" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="7.66663" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + x="14.6666" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="21.6666" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28.6666" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35.6666" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="42.6666" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + x="49.6666" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="56.6666" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="0.666626" + y="56.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="7.66663" + y="56.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="14.6666" + y="56.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + x="21.6666" + y="56.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + x="28.6666" + y="56.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + x="35.6666" + y="56.5" + width="4" + height="4" + rx="2" + fill="var(--color-saas-dify-blue-accessible)" + /> + <rect + opacity="0.18" + x="42.6666" + y="56.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="49.6666" + y="56.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="56.6666" + y="56.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> </g> <defs> <clipPath id="clip0_1_4802"> diff --git a/web/app/components/billing/pricing/assets/sandbox.tsx b/web/app/components/billing/pricing/assets/sandbox.tsx index e972546f42d747..ab3c700d08a5b6 100644 --- a/web/app/components/billing/pricing/assets/sandbox.tsx +++ b/web/app/components/billing/pricing/assets/sandbox.tsx @@ -2,87 +2,533 @@ const Sandbox = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" width="60" height="61" viewBox="0 0 60 61" fill="none"> <g clipPath="url(#clip0_1_65915)"> - <rect opacity="0.18" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="7" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M14 2.5C14 1.39543 14.8954 0.5 16 0.5C17.1046 0.5 18 1.39543 18 2.5C18 3.60457 17.1046 4.5 16 4.5C14.8954 4.5 14 3.60457 14 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M21 2.5C21 1.39543 21.8954 0.5 23 0.5C24.1046 0.5 25 1.39543 25 2.5C25 3.60457 24.1046 4.5 23 4.5C21.8954 4.5 21 3.60457 21 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28 2.5C28 1.39543 28.8954 0.5 30 0.5C31.1046 0.5 32 1.39543 32 2.5C32 3.60457 31.1046 4.5 30 4.5C28.8954 4.5 28 3.60457 28 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35 2.5C35 1.39543 35.8954 0.5 37 0.5C38.1046 0.5 39 1.39543 39 2.5C39 3.60457 38.1046 4.5 37 4.5C35.8954 4.5 35 3.60457 35 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M42 2.5C42 1.39543 42.8954 0.5 44 0.5C45.1046 0.5 46 1.39543 46 2.5C46 3.60457 45.1046 4.5 44 4.5C42.8954 4.5 42 3.60457 42 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M49 2.5C49 1.39543 49.8954 0.5 51 0.5C52.1046 0.5 53 1.39543 53 2.5C53 3.60457 52.1046 4.5 51 4.5C49.8954 4.5 49 3.60457 49 2.5Z" fill="var(--color-text-quaternary)" /> - <path d="M56 2.5C56 1.39543 56.8954 0.5 58 0.5C59.1046 0.5 60 1.39543 60 2.5C60 3.60457 59.1046 4.5 58 4.5C56.8954 4.5 56 3.60457 56 2.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M7 9.5C7 8.39543 7.89543 7.5 9 7.5C10.1046 7.5 11 8.39543 11 9.5C11 10.6046 10.1046 11.5 9 11.5C7.89543 11.5 7 10.6046 7 9.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="14" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="21" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="42" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M49 9.5C49 8.39543 49.8954 7.5 51 7.5C52.1046 7.5 53 8.39543 53 9.5C53 10.6046 52.1046 11.5 51 11.5C49.8954 11.5 49 10.6046 49 9.5Z" fill="var(--color-text-quaternary)" /> - <path d="M56 9.5C56 8.39543 56.8954 7.5 58 7.5C59.1046 7.5 60 8.39543 60 9.5C60 10.6046 59.1046 11.5 58 11.5C56.8954 11.5 56 10.6046 56 9.5Z" fill="var(--color-text-quaternary)" /> - <path d="M0 16.5C0 15.3954 0.895431 14.5 2 14.5C3.10457 14.5 4 15.3954 4 16.5C4 17.6046 3.10457 18.5 2 18.5C0.895431 18.5 0 17.6046 0 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M7 16.5C7 15.3954 7.89543 14.5 9 14.5C10.1046 14.5 11 15.3954 11 16.5C11 17.6046 10.1046 18.5 9 18.5C7.89543 18.5 7 17.6046 7 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M14 16.5C14 15.3954 14.8954 14.5 16 14.5C17.1046 14.5 18 15.3954 18 16.5C18 17.6046 17.1046 18.5 16 18.5C14.8954 18.5 14 17.6046 14 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M21 16.5C21 15.3954 21.8954 14.5 23 14.5C24.1046 14.5 25 15.3954 25 16.5C25 17.6046 24.1046 18.5 23 18.5C21.8954 18.5 21 17.6046 21 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28 16.5C28 15.3954 28.8954 14.5 30 14.5C31.1046 14.5 32 15.3954 32 16.5C32 17.6046 31.1046 18.5 30 18.5C28.8954 18.5 28 17.6046 28 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35 16.5C35 15.3954 35.8954 14.5 37 14.5C38.1046 14.5 39 15.3954 39 16.5C39 17.6046 38.1046 18.5 37 18.5C35.8954 18.5 35 17.6046 35 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M42 16.5C42 15.3954 42.8954 14.5 44 14.5C45.1046 14.5 46 15.3954 46 16.5C46 17.6046 45.1046 18.5 44 18.5C42.8954 18.5 42 17.6046 42 16.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="49" y="14.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M56 16.5C56 15.3954 56.8954 14.5 58 14.5C59.1046 14.5 60 15.3954 60 16.5C60 17.6046 59.1046 18.5 58 18.5C56.8954 18.5 56 17.6046 56 16.5Z" fill="var(--color-text-quaternary)" /> - <path d="M0 23.5C0 22.3954 0.895431 21.5 2 21.5C3.10457 21.5 4 22.3954 4 23.5C4 24.6046 3.10457 25.5 2 25.5C0.895431 25.5 0 24.6046 0 23.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="7" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="14" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="21" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M42 23.5C42 22.3954 42.8954 21.5 44 21.5C45.1046 21.5 46 22.3954 46 23.5C46 24.6046 45.1046 25.5 44 25.5C42.8954 25.5 42 24.6046 42 23.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="49" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M56 23.5C56 22.3954 56.8954 21.5 58 21.5C59.1046 21.5 60 22.3954 60 23.5C60 24.6046 59.1046 25.5 58 25.5C56.8954 25.5 56 24.6046 56 23.5Z" fill="var(--color-text-quaternary)" /> - <path d="M0 30.5C0 29.3954 0.895431 28.5 2 28.5C3.10457 28.5 4 29.3954 4 30.5C4 31.6046 3.10457 32.5 2 32.5C0.895431 32.5 0 31.6046 0 30.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="7" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="14" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="21" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M42 30.5C42 29.3954 42.8954 28.5 44 28.5C45.1046 28.5 46 29.3954 46 30.5C46 31.6046 45.1046 32.5 44 32.5C42.8954 32.5 42 31.6046 42 30.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="49" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M56 30.5C56 29.3954 56.8954 28.5 58 28.5C59.1046 28.5 60 29.3954 60 30.5C60 31.6046 59.1046 32.5 58 32.5C56.8954 32.5 56 31.6046 56 30.5Z" fill="var(--color-text-quaternary)" /> - <path d="M0 37.5C0 36.3954 0.895431 35.5 2 35.5C3.10457 35.5 4 36.3954 4 37.5C4 38.6046 3.10457 39.5 2 39.5C0.895431 39.5 0 38.6046 0 37.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="7" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="14" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="21" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M42 37.5C42 36.3954 42.8954 35.5 44 35.5C45.1046 35.5 46 36.3954 46 37.5C46 38.6046 45.1046 39.5 44 39.5C42.8954 39.5 42 38.6046 42 37.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="49" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M56 37.5C56 36.3954 56.8954 35.5 58 35.5C59.1046 35.5 60 36.3954 60 37.5C60 38.6046 59.1046 39.5 58 39.5C56.8954 39.5 56 38.6046 56 37.5Z" fill="var(--color-text-quaternary)" /> - <path d="M0 44.5C0 43.3954 0.895431 42.5 2 42.5C3.10457 42.5 4 43.3954 4 44.5C4 45.6046 3.10457 46.5 2 46.5C0.895431 46.5 0 45.6046 0 44.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="7" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="14" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="21" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M42 44.5C42 43.3954 42.8954 42.5 44 42.5C45.1046 42.5 46 43.3954 46 44.5C46 45.6046 45.1046 46.5 44 46.5C42.8954 46.5 42 45.6046 42 44.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="49" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M56 44.5C56 43.3954 56.8954 42.5 58 42.5C59.1046 42.5 60 43.3954 60 44.5C60 45.6046 59.1046 46.5 58 46.5C56.8954 46.5 56 45.6046 56 44.5Z" fill="var(--color-text-quaternary)" /> - <path d="M0 51.5C0 50.3954 0.895431 49.5 2 49.5C3.10457 49.5 4 50.3954 4 51.5C4 52.6046 3.10457 53.5 2 53.5C0.895431 53.5 0 52.6046 0 51.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="7" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="14" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="21" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="28" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="35" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M42 51.5C42 50.3954 42.8954 49.5 44 49.5C45.1046 49.5 46 50.3954 46 51.5C46 52.6046 45.1046 53.5 44 53.5C42.8954 53.5 42 52.6046 42 51.5Z" fill="var(--color-text-quaternary)" /> - <path d="M49 51.5C49 50.3954 49.8954 49.5 51 49.5C52.1046 49.5 53 50.3954 53 51.5C53 52.6046 52.1046 53.5 51 53.5C49.8954 53.5 49 52.6046 49 51.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="56" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <path d="M0 58.5C0 57.3954 0.895431 56.5 2 56.5C3.10457 56.5 4 57.3954 4 58.5C4 59.6046 3.10457 60.5 2 60.5C0.895431 60.5 0 59.6046 0 58.5Z" fill="var(--color-text-quaternary)" /> - <path d="M7 58.5C7 57.3954 7.89543 56.5 9 56.5C10.1046 56.5 11 57.3954 11 58.5C11 59.6046 10.1046 60.5 9 60.5C7.89543 60.5 7 59.6046 7 58.5Z" fill="var(--color-text-quaternary)" /> - <path d="M14 58.5C14 57.3954 14.8954 56.5 16 56.5C17.1046 56.5 18 57.3954 18 58.5C18 59.6046 17.1046 60.5 16 60.5C14.8954 60.5 14 59.6046 14 58.5Z" fill="var(--color-text-quaternary)" /> - <path d="M21 58.5C21 57.3954 21.8954 56.5 23 56.5C24.1046 56.5 25 57.3954 25 58.5C25 59.6046 24.1046 60.5 23 60.5C21.8954 60.5 21 59.6046 21 58.5Z" fill="var(--color-text-quaternary)" /> - <path d="M28 58.5C28 57.3954 28.8954 56.5 30 56.5C31.1046 56.5 32 57.3954 32 58.5C32 59.6046 31.1046 60.5 30 60.5C28.8954 60.5 28 59.6046 28 58.5Z" fill="var(--color-text-quaternary)" /> - <path d="M35 58.5C35 57.3954 35.8954 56.5 37 56.5C38.1046 56.5 39 57.3954 39 58.5C39 59.6046 38.1046 60.5 37 60.5C35.8954 60.5 35 59.6046 35 58.5Z" fill="var(--color-text-quaternary)" /> - <path d="M42 58.5C42 57.3954 42.8954 56.5 44 56.5C45.1046 56.5 46 57.3954 46 58.5C46 59.6046 45.1046 60.5 44 60.5C42.8954 60.5 42 59.6046 42 58.5Z" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="49" y="56.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> - <rect opacity="0.18" x="56" y="56.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="7" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M14 2.5C14 1.39543 14.8954 0.5 16 0.5C17.1046 0.5 18 1.39543 18 2.5C18 3.60457 17.1046 4.5 16 4.5C14.8954 4.5 14 3.60457 14 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M21 2.5C21 1.39543 21.8954 0.5 23 0.5C24.1046 0.5 25 1.39543 25 2.5C25 3.60457 24.1046 4.5 23 4.5C21.8954 4.5 21 3.60457 21 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28 2.5C28 1.39543 28.8954 0.5 30 0.5C31.1046 0.5 32 1.39543 32 2.5C32 3.60457 31.1046 4.5 30 4.5C28.8954 4.5 28 3.60457 28 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35 2.5C35 1.39543 35.8954 0.5 37 0.5C38.1046 0.5 39 1.39543 39 2.5C39 3.60457 38.1046 4.5 37 4.5C35.8954 4.5 35 3.60457 35 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M42 2.5C42 1.39543 42.8954 0.5 44 0.5C45.1046 0.5 46 1.39543 46 2.5C46 3.60457 45.1046 4.5 44 4.5C42.8954 4.5 42 3.60457 42 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M49 2.5C49 1.39543 49.8954 0.5 51 0.5C52.1046 0.5 53 1.39543 53 2.5C53 3.60457 52.1046 4.5 51 4.5C49.8954 4.5 49 3.60457 49 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M56 2.5C56 1.39543 56.8954 0.5 58 0.5C59.1046 0.5 60 1.39543 60 2.5C60 3.60457 59.1046 4.5 58 4.5C56.8954 4.5 56 3.60457 56 2.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M7 9.5C7 8.39543 7.89543 7.5 9 7.5C10.1046 7.5 11 8.39543 11 9.5C11 10.6046 10.1046 11.5 9 11.5C7.89543 11.5 7 10.6046 7 9.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="14" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="21" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="42" + y="7.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M49 9.5C49 8.39543 49.8954 7.5 51 7.5C52.1046 7.5 53 8.39543 53 9.5C53 10.6046 52.1046 11.5 51 11.5C49.8954 11.5 49 10.6046 49 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M56 9.5C56 8.39543 56.8954 7.5 58 7.5C59.1046 7.5 60 8.39543 60 9.5C60 10.6046 59.1046 11.5 58 11.5C56.8954 11.5 56 10.6046 56 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M0 16.5C0 15.3954 0.895431 14.5 2 14.5C3.10457 14.5 4 15.3954 4 16.5C4 17.6046 3.10457 18.5 2 18.5C0.895431 18.5 0 17.6046 0 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M7 16.5C7 15.3954 7.89543 14.5 9 14.5C10.1046 14.5 11 15.3954 11 16.5C11 17.6046 10.1046 18.5 9 18.5C7.89543 18.5 7 17.6046 7 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M14 16.5C14 15.3954 14.8954 14.5 16 14.5C17.1046 14.5 18 15.3954 18 16.5C18 17.6046 17.1046 18.5 16 18.5C14.8954 18.5 14 17.6046 14 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M21 16.5C21 15.3954 21.8954 14.5 23 14.5C24.1046 14.5 25 15.3954 25 16.5C25 17.6046 24.1046 18.5 23 18.5C21.8954 18.5 21 17.6046 21 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28 16.5C28 15.3954 28.8954 14.5 30 14.5C31.1046 14.5 32 15.3954 32 16.5C32 17.6046 31.1046 18.5 30 18.5C28.8954 18.5 28 17.6046 28 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35 16.5C35 15.3954 35.8954 14.5 37 14.5C38.1046 14.5 39 15.3954 39 16.5C39 17.6046 38.1046 18.5 37 18.5C35.8954 18.5 35 17.6046 35 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M42 16.5C42 15.3954 42.8954 14.5 44 14.5C45.1046 14.5 46 15.3954 46 16.5C46 17.6046 45.1046 18.5 44 18.5C42.8954 18.5 42 17.6046 42 16.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="49" + y="14.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M56 16.5C56 15.3954 56.8954 14.5 58 14.5C59.1046 14.5 60 15.3954 60 16.5C60 17.6046 59.1046 18.5 58 18.5C56.8954 18.5 56 17.6046 56 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M0 23.5C0 22.3954 0.895431 21.5 2 21.5C3.10457 21.5 4 22.3954 4 23.5C4 24.6046 3.10457 25.5 2 25.5C0.895431 25.5 0 24.6046 0 23.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="7" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="14" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="21" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M42 23.5C42 22.3954 42.8954 21.5 44 21.5C45.1046 21.5 46 22.3954 46 23.5C46 24.6046 45.1046 25.5 44 25.5C42.8954 25.5 42 24.6046 42 23.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="49" + y="21.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M56 23.5C56 22.3954 56.8954 21.5 58 21.5C59.1046 21.5 60 22.3954 60 23.5C60 24.6046 59.1046 25.5 58 25.5C56.8954 25.5 56 24.6046 56 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M0 30.5C0 29.3954 0.895431 28.5 2 28.5C3.10457 28.5 4 29.3954 4 30.5C4 31.6046 3.10457 32.5 2 32.5C0.895431 32.5 0 31.6046 0 30.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="7" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="14" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="21" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M42 30.5C42 29.3954 42.8954 28.5 44 28.5C45.1046 28.5 46 29.3954 46 30.5C46 31.6046 45.1046 32.5 44 32.5C42.8954 32.5 42 31.6046 42 30.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="49" + y="28.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M56 30.5C56 29.3954 56.8954 28.5 58 28.5C59.1046 28.5 60 29.3954 60 30.5C60 31.6046 59.1046 32.5 58 32.5C56.8954 32.5 56 31.6046 56 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M0 37.5C0 36.3954 0.895431 35.5 2 35.5C3.10457 35.5 4 36.3954 4 37.5C4 38.6046 3.10457 39.5 2 39.5C0.895431 39.5 0 38.6046 0 37.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="7" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="14" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="21" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M42 37.5C42 36.3954 42.8954 35.5 44 35.5C45.1046 35.5 46 36.3954 46 37.5C46 38.6046 45.1046 39.5 44 39.5C42.8954 39.5 42 38.6046 42 37.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="49" + y="35.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M56 37.5C56 36.3954 56.8954 35.5 58 35.5C59.1046 35.5 60 36.3954 60 37.5C60 38.6046 59.1046 39.5 58 39.5C56.8954 39.5 56 38.6046 56 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M0 44.5C0 43.3954 0.895431 42.5 2 42.5C3.10457 42.5 4 43.3954 4 44.5C4 45.6046 3.10457 46.5 2 46.5C0.895431 46.5 0 45.6046 0 44.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="7" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="14" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="21" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M42 44.5C42 43.3954 42.8954 42.5 44 42.5C45.1046 42.5 46 43.3954 46 44.5C46 45.6046 45.1046 46.5 44 46.5C42.8954 46.5 42 45.6046 42 44.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="49" + y="42.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M56 44.5C56 43.3954 56.8954 42.5 58 42.5C59.1046 42.5 60 43.3954 60 44.5C60 45.6046 59.1046 46.5 58 46.5C56.8954 46.5 56 45.6046 56 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M0 51.5C0 50.3954 0.895431 49.5 2 49.5C3.10457 49.5 4 50.3954 4 51.5C4 52.6046 3.10457 53.5 2 53.5C0.895431 53.5 0 52.6046 0 51.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="7" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="14" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="21" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="28" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="35" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M42 51.5C42 50.3954 42.8954 49.5 44 49.5C45.1046 49.5 46 50.3954 46 51.5C46 52.6046 45.1046 53.5 44 53.5C42.8954 53.5 42 52.6046 42 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M49 51.5C49 50.3954 49.8954 49.5 51 49.5C52.1046 49.5 53 50.3954 53 51.5C53 52.6046 52.1046 53.5 51 53.5C49.8954 53.5 49 52.6046 49 51.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="56" + y="49.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <path + d="M0 58.5C0 57.3954 0.895431 56.5 2 56.5C3.10457 56.5 4 57.3954 4 58.5C4 59.6046 3.10457 60.5 2 60.5C0.895431 60.5 0 59.6046 0 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M7 58.5C7 57.3954 7.89543 56.5 9 56.5C10.1046 56.5 11 57.3954 11 58.5C11 59.6046 10.1046 60.5 9 60.5C7.89543 60.5 7 59.6046 7 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M14 58.5C14 57.3954 14.8954 56.5 16 56.5C17.1046 56.5 18 57.3954 18 58.5C18 59.6046 17.1046 60.5 16 60.5C14.8954 60.5 14 59.6046 14 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M21 58.5C21 57.3954 21.8954 56.5 23 56.5C24.1046 56.5 25 57.3954 25 58.5C25 59.6046 24.1046 60.5 23 60.5C21.8954 60.5 21 59.6046 21 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M28 58.5C28 57.3954 28.8954 56.5 30 56.5C31.1046 56.5 32 57.3954 32 58.5C32 59.6046 31.1046 60.5 30 60.5C28.8954 60.5 28 59.6046 28 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M35 58.5C35 57.3954 35.8954 56.5 37 56.5C38.1046 56.5 39 57.3954 39 58.5C39 59.6046 38.1046 60.5 37 60.5C35.8954 60.5 35 59.6046 35 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + d="M42 58.5C42 57.3954 42.8954 56.5 44 56.5C45.1046 56.5 46 57.3954 46 58.5C46 59.6046 45.1046 60.5 44 60.5C42.8954 60.5 42 59.6046 42 58.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="49" + y="56.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> + <rect + opacity="0.18" + x="56" + y="56.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> </g> <defs> <clipPath id="clip0_1_65915"> diff --git a/web/app/components/billing/pricing/assets/self-hosted.tsx b/web/app/components/billing/pricing/assets/self-hosted.tsx index 7fd430957a3194..c331d1f5a1929d 100644 --- a/web/app/components/billing/pricing/assets/self-hosted.tsx +++ b/web/app/components/billing/pricing/assets/self-hosted.tsx @@ -2,23 +2,59 @@ type SelfHostedProps = { isActive: boolean } -const SelfHosted = ({ - isActive, -}: SelfHostedProps) => { +const SelfHosted = ({ isActive }: SelfHostedProps) => { const color = isActive ? 'var(--color-saas-dify-blue-accessible)' : 'var(--color-text-primary)' return ( <svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none"> <g clipPath="url(#clip0_1_4644)"> - <rect opacity="0.18" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> <rect x="6" y="0.5" width="4" height="4" rx="2" fill={color} /> - <rect opacity="0.18" x="12" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + x="12" + y="0.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> <rect y="6.5" width="4" height="4" rx="2" fill={color} /> - <rect opacity="0.18" x="6" y="6.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + x="6" + y="6.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> <rect x="12" y="6.5" width="4" height="4" rx="2" fill={color} /> - <rect opacity="0.18" y="12.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + y="12.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> <rect x="6" y="12.5" width="4" height="4" rx="2" fill={color} /> - <rect opacity="0.18" x="12" y="12.5" width="4" height="4" rx="2" fill="var(--color-text-quaternary)" /> + <rect + opacity="0.18" + x="12" + y="12.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-quaternary)" + /> </g> <defs> <clipPath id="clip0_1_4644"> diff --git a/web/app/components/billing/pricing/assets/team.tsx b/web/app/components/billing/pricing/assets/team.tsx index a6dbbdfc940658..9577016655714b 100644 --- a/web/app/components/billing/pricing/assets/team.tsx +++ b/web/app/components/billing/pricing/assets/team.tsx @@ -5,84 +5,311 @@ const Team = () => { <rect x="0.333374" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> <rect x="7.33337" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> <rect x="14.3334" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M21.3334 2.5C21.3334 1.39543 22.2288 0.5 23.3334 0.5C24.4379 0.5 25.3334 1.39543 25.3334 2.5C25.3334 3.60457 24.4379 4.5 23.3334 4.5C22.2288 4.5 21.3334 3.60457 21.3334 2.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.3334 2.5C28.3334 1.39543 29.2288 0.5 30.3334 0.5C31.4379 0.5 32.3334 1.39543 32.3334 2.5C32.3334 3.60457 31.4379 4.5 30.3334 4.5C29.2288 4.5 28.3334 3.60457 28.3334 2.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M21.3334 2.5C21.3334 1.39543 22.2288 0.5 23.3334 0.5C24.4379 0.5 25.3334 1.39543 25.3334 2.5C25.3334 3.60457 24.4379 4.5 23.3334 4.5C22.2288 4.5 21.3334 3.60457 21.3334 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.3334 2.5C28.3334 1.39543 29.2288 0.5 30.3334 0.5C31.4379 0.5 32.3334 1.39543 32.3334 2.5C32.3334 3.60457 31.4379 4.5 30.3334 4.5C29.2288 4.5 28.3334 3.60457 28.3334 2.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="35.3334" y="0.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M42.3334 2.5C42.3334 1.39543 43.2288 0.5 44.3334 0.5C45.4379 0.5 46.3334 1.39543 46.3334 2.5C46.3334 3.60457 45.4379 4.5 44.3334 4.5C43.2288 4.5 42.3334 3.60457 42.3334 2.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.3334 2.5C49.3334 1.39543 50.2288 0.5 51.3334 0.5C52.4379 0.5 53.3334 1.39543 53.3334 2.5C53.3334 3.60457 52.4379 4.5 51.3334 4.5C50.2288 4.5 49.3334 3.60457 49.3334 2.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56.3334 2.5C56.3334 1.39543 57.2288 0.5 58.3334 0.5C59.4379 0.5 60.3334 1.39543 60.3334 2.5C60.3334 3.60457 59.4379 4.5 58.3334 4.5C57.2288 4.5 56.3334 3.60457 56.3334 2.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0.333374 9.5C0.333374 8.39543 1.2288 7.5 2.33337 7.5C3.43794 7.5 4.33337 8.39543 4.33337 9.5C4.33337 10.6046 3.43794 11.5 2.33337 11.5C1.2288 11.5 0.333374 10.6046 0.333374 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7.33337 9.5C7.33337 8.39543 8.2288 7.5 9.33337 7.5C10.4379 7.5 11.3334 8.39543 11.3334 9.5C11.3334 10.6046 10.4379 11.5 9.33337 11.5C8.2288 11.5 7.33337 10.6046 7.33337 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3334 9.5C14.3334 8.39543 15.2288 7.5 16.3334 7.5C17.4379 7.5 18.3334 8.39543 18.3334 9.5C18.3334 10.6046 17.4379 11.5 16.3334 11.5C15.2288 11.5 14.3334 10.6046 14.3334 9.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M42.3334 2.5C42.3334 1.39543 43.2288 0.5 44.3334 0.5C45.4379 0.5 46.3334 1.39543 46.3334 2.5C46.3334 3.60457 45.4379 4.5 44.3334 4.5C43.2288 4.5 42.3334 3.60457 42.3334 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.3334 2.5C49.3334 1.39543 50.2288 0.5 51.3334 0.5C52.4379 0.5 53.3334 1.39543 53.3334 2.5C53.3334 3.60457 52.4379 4.5 51.3334 4.5C50.2288 4.5 49.3334 3.60457 49.3334 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56.3334 2.5C56.3334 1.39543 57.2288 0.5 58.3334 0.5C59.4379 0.5 60.3334 1.39543 60.3334 2.5C60.3334 3.60457 59.4379 4.5 58.3334 4.5C57.2288 4.5 56.3334 3.60457 56.3334 2.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0.333374 9.5C0.333374 8.39543 1.2288 7.5 2.33337 7.5C3.43794 7.5 4.33337 8.39543 4.33337 9.5C4.33337 10.6046 3.43794 11.5 2.33337 11.5C1.2288 11.5 0.333374 10.6046 0.333374 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7.33337 9.5C7.33337 8.39543 8.2288 7.5 9.33337 7.5C10.4379 7.5 11.3334 8.39543 11.3334 9.5C11.3334 10.6046 10.4379 11.5 9.33337 11.5C8.2288 11.5 7.33337 10.6046 7.33337 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3334 9.5C14.3334 8.39543 15.2288 7.5 16.3334 7.5C17.4379 7.5 18.3334 8.39543 18.3334 9.5C18.3334 10.6046 17.4379 11.5 16.3334 11.5C15.2288 11.5 14.3334 10.6046 14.3334 9.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="21.3334" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> <rect x="28.3334" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M35.3334 9.5C35.3334 8.39543 36.2288 7.5 37.3334 7.5C38.4379 7.5 39.3334 8.39543 39.3334 9.5C39.3334 10.6046 38.4379 11.5 37.3334 11.5C36.2288 11.5 35.3334 10.6046 35.3334 9.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M35.3334 9.5C35.3334 8.39543 36.2288 7.5 37.3334 7.5C38.4379 7.5 39.3334 8.39543 39.3334 9.5C39.3334 10.6046 38.4379 11.5 37.3334 11.5C36.2288 11.5 35.3334 10.6046 35.3334 9.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="42.3334" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> <rect x="49.3334" y="7.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M56.3334 9.5C56.3334 8.39543 57.2288 7.5 58.3334 7.5C59.4379 7.5 60.3334 8.39543 60.3334 9.5C60.3334 10.6046 59.4379 11.5 58.3334 11.5C57.2288 11.5 56.3334 10.6046 56.3334 9.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0.333374 16.5C0.333374 15.3954 1.2288 14.5 2.33337 14.5C3.43794 14.5 4.33337 15.3954 4.33337 16.5C4.33337 17.6046 3.43794 18.5 2.33337 18.5C1.2288 18.5 0.333374 17.6046 0.333374 16.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7.33337 16.5C7.33337 15.3954 8.2288 14.5 9.33337 14.5C10.4379 14.5 11.3334 15.3954 11.3334 16.5C11.3334 17.6046 10.4379 18.5 9.33337 18.5C8.2288 18.5 7.33337 17.6046 7.33337 16.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3334 16.5C14.3334 15.3954 15.2288 14.5 16.3334 14.5C17.4379 14.5 18.3334 15.3954 18.3334 16.5C18.3334 17.6046 17.4379 18.5 16.3334 18.5C15.2288 18.5 14.3334 17.6046 14.3334 16.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21.3334 16.5C21.3334 15.3954 22.2288 14.5 23.3334 14.5C24.4379 14.5 25.3334 15.3954 25.3334 16.5C25.3334 17.6046 24.4379 18.5 23.3334 18.5C22.2288 18.5 21.3334 17.6046 21.3334 16.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M56.3334 9.5C56.3334 8.39543 57.2288 7.5 58.3334 7.5C59.4379 7.5 60.3334 8.39543 60.3334 9.5C60.3334 10.6046 59.4379 11.5 58.3334 11.5C57.2288 11.5 56.3334 10.6046 56.3334 9.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0.333374 16.5C0.333374 15.3954 1.2288 14.5 2.33337 14.5C3.43794 14.5 4.33337 15.3954 4.33337 16.5C4.33337 17.6046 3.43794 18.5 2.33337 18.5C1.2288 18.5 0.333374 17.6046 0.333374 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7.33337 16.5C7.33337 15.3954 8.2288 14.5 9.33337 14.5C10.4379 14.5 11.3334 15.3954 11.3334 16.5C11.3334 17.6046 10.4379 18.5 9.33337 18.5C8.2288 18.5 7.33337 17.6046 7.33337 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3334 16.5C14.3334 15.3954 15.2288 14.5 16.3334 14.5C17.4379 14.5 18.3334 15.3954 18.3334 16.5C18.3334 17.6046 17.4379 18.5 16.3334 18.5C15.2288 18.5 14.3334 17.6046 14.3334 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21.3334 16.5C21.3334 15.3954 22.2288 14.5 23.3334 14.5C24.4379 14.5 25.3334 15.3954 25.3334 16.5C25.3334 17.6046 24.4379 18.5 23.3334 18.5C22.2288 18.5 21.3334 17.6046 21.3334 16.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="28.3334" y="14.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M35.3334 16.5C35.3334 15.3954 36.2288 14.5 37.3334 14.5C38.4379 14.5 39.3334 15.3954 39.3334 16.5C39.3334 17.6046 38.4379 18.5 37.3334 18.5C36.2288 18.5 35.3334 17.6046 35.3334 16.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M42.3334 16.5C42.3334 15.3954 43.2288 14.5 44.3334 14.5C45.4379 14.5 46.3334 15.3954 46.3334 16.5C46.3334 17.6046 45.4379 18.5 44.3334 18.5C43.2288 18.5 42.3334 17.6046 42.3334 16.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M35.3334 16.5C35.3334 15.3954 36.2288 14.5 37.3334 14.5C38.4379 14.5 39.3334 15.3954 39.3334 16.5C39.3334 17.6046 38.4379 18.5 37.3334 18.5C36.2288 18.5 35.3334 17.6046 35.3334 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M42.3334 16.5C42.3334 15.3954 43.2288 14.5 44.3334 14.5C45.4379 14.5 46.3334 15.3954 46.3334 16.5C46.3334 17.6046 45.4379 18.5 44.3334 18.5C43.2288 18.5 42.3334 17.6046 42.3334 16.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="49.3334" y="14.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M56.3334 16.5C56.3334 15.3954 57.2288 14.5 58.3334 14.5C59.4379 14.5 60.3334 15.3954 60.3334 16.5C60.3334 17.6046 59.4379 18.5 58.3334 18.5C57.2288 18.5 56.3334 17.6046 56.3334 16.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0.333374 23.5C0.333374 22.3954 1.2288 21.5 2.33337 21.5C3.43794 21.5 4.33337 22.3954 4.33337 23.5C4.33337 24.6046 3.43794 25.5 2.33337 25.5C1.2288 25.5 0.333374 24.6046 0.333374 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7.33337 23.5C7.33337 22.3954 8.2288 21.5 9.33337 21.5C10.4379 21.5 11.3334 22.3954 11.3334 23.5C11.3334 24.6046 10.4379 25.5 9.33337 25.5C8.2288 25.5 7.33337 24.6046 7.33337 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3334 23.5C14.3334 22.3954 15.2288 21.5 16.3334 21.5C17.4379 21.5 18.3334 22.3954 18.3334 23.5C18.3334 24.6046 17.4379 25.5 16.3334 25.5C15.2288 25.5 14.3334 24.6046 14.3334 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21.3334 23.5C21.3334 22.3954 22.2288 21.5 23.3334 21.5C24.4379 21.5 25.3334 22.3954 25.3334 23.5C25.3334 24.6046 24.4379 25.5 23.3334 25.5C22.2288 25.5 21.3334 24.6046 21.3334 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.3334 23.5C28.3334 22.3954 29.2288 21.5 30.3334 21.5C31.4379 21.5 32.3334 22.3954 32.3334 23.5C32.3334 24.6046 31.4379 25.5 30.3334 25.5C29.2288 25.5 28.3334 24.6046 28.3334 23.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M56.3334 16.5C56.3334 15.3954 57.2288 14.5 58.3334 14.5C59.4379 14.5 60.3334 15.3954 60.3334 16.5C60.3334 17.6046 59.4379 18.5 58.3334 18.5C57.2288 18.5 56.3334 17.6046 56.3334 16.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0.333374 23.5C0.333374 22.3954 1.2288 21.5 2.33337 21.5C3.43794 21.5 4.33337 22.3954 4.33337 23.5C4.33337 24.6046 3.43794 25.5 2.33337 25.5C1.2288 25.5 0.333374 24.6046 0.333374 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7.33337 23.5C7.33337 22.3954 8.2288 21.5 9.33337 21.5C10.4379 21.5 11.3334 22.3954 11.3334 23.5C11.3334 24.6046 10.4379 25.5 9.33337 25.5C8.2288 25.5 7.33337 24.6046 7.33337 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3334 23.5C14.3334 22.3954 15.2288 21.5 16.3334 21.5C17.4379 21.5 18.3334 22.3954 18.3334 23.5C18.3334 24.6046 17.4379 25.5 16.3334 25.5C15.2288 25.5 14.3334 24.6046 14.3334 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21.3334 23.5C21.3334 22.3954 22.2288 21.5 23.3334 21.5C24.4379 21.5 25.3334 22.3954 25.3334 23.5C25.3334 24.6046 24.4379 25.5 23.3334 25.5C22.2288 25.5 21.3334 24.6046 21.3334 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.3334 23.5C28.3334 22.3954 29.2288 21.5 30.3334 21.5C31.4379 21.5 32.3334 22.3954 32.3334 23.5C32.3334 24.6046 31.4379 25.5 30.3334 25.5C29.2288 25.5 28.3334 24.6046 28.3334 23.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="35.3334" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M42.3334 23.5C42.3334 22.3954 43.2288 21.5 44.3334 21.5C45.4379 21.5 46.3334 22.3954 46.3334 23.5C46.3334 24.6046 45.4379 25.5 44.3334 25.5C43.2288 25.5 42.3334 24.6046 42.3334 23.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.3334 23.5C49.3334 22.3954 50.2288 21.5 51.3334 21.5C52.4379 21.5 53.3334 22.3954 53.3334 23.5C53.3334 24.6046 52.4379 25.5 51.3334 25.5C50.2288 25.5 49.3334 24.6046 49.3334 23.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M42.3334 23.5C42.3334 22.3954 43.2288 21.5 44.3334 21.5C45.4379 21.5 46.3334 22.3954 46.3334 23.5C46.3334 24.6046 45.4379 25.5 44.3334 25.5C43.2288 25.5 42.3334 24.6046 42.3334 23.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.3334 23.5C49.3334 22.3954 50.2288 21.5 51.3334 21.5C52.4379 21.5 53.3334 22.3954 53.3334 23.5C53.3334 24.6046 52.4379 25.5 51.3334 25.5C50.2288 25.5 49.3334 24.6046 49.3334 23.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="56.3334" y="21.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M0.333374 30.5C0.333374 29.3954 1.2288 28.5 2.33337 28.5C3.43794 28.5 4.33337 29.3954 4.33337 30.5C4.33337 31.6046 3.43794 32.5 2.33337 32.5C1.2288 32.5 0.333374 31.6046 0.333374 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7.33337 30.5C7.33337 29.3954 8.2288 28.5 9.33337 28.5C10.4379 28.5 11.3334 29.3954 11.3334 30.5C11.3334 31.6046 10.4379 32.5 9.33337 32.5C8.2288 32.5 7.33337 31.6046 7.33337 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3334 30.5C14.3334 29.3954 15.2288 28.5 16.3334 28.5C17.4379 28.5 18.3334 29.3954 18.3334 30.5C18.3334 31.6046 17.4379 32.5 16.3334 32.5C15.2288 32.5 14.3334 31.6046 14.3334 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21.3334 30.5C21.3334 29.3954 22.2288 28.5 23.3334 28.5C24.4379 28.5 25.3334 29.3954 25.3334 30.5C25.3334 31.6046 24.4379 32.5 23.3334 32.5C22.2288 32.5 21.3334 31.6046 21.3334 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.3334 30.5C28.3334 29.3954 29.2288 28.5 30.3334 28.5C31.4379 28.5 32.3334 29.3954 32.3334 30.5C32.3334 31.6046 31.4379 32.5 30.3334 32.5C29.2288 32.5 28.3334 31.6046 28.3334 30.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M0.333374 30.5C0.333374 29.3954 1.2288 28.5 2.33337 28.5C3.43794 28.5 4.33337 29.3954 4.33337 30.5C4.33337 31.6046 3.43794 32.5 2.33337 32.5C1.2288 32.5 0.333374 31.6046 0.333374 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7.33337 30.5C7.33337 29.3954 8.2288 28.5 9.33337 28.5C10.4379 28.5 11.3334 29.3954 11.3334 30.5C11.3334 31.6046 10.4379 32.5 9.33337 32.5C8.2288 32.5 7.33337 31.6046 7.33337 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3334 30.5C14.3334 29.3954 15.2288 28.5 16.3334 28.5C17.4379 28.5 18.3334 29.3954 18.3334 30.5C18.3334 31.6046 17.4379 32.5 16.3334 32.5C15.2288 32.5 14.3334 31.6046 14.3334 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21.3334 30.5C21.3334 29.3954 22.2288 28.5 23.3334 28.5C24.4379 28.5 25.3334 29.3954 25.3334 30.5C25.3334 31.6046 24.4379 32.5 23.3334 32.5C22.2288 32.5 21.3334 31.6046 21.3334 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.3334 30.5C28.3334 29.3954 29.2288 28.5 30.3334 28.5C31.4379 28.5 32.3334 29.3954 32.3334 30.5C32.3334 31.6046 31.4379 32.5 30.3334 32.5C29.2288 32.5 28.3334 31.6046 28.3334 30.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="35.3334" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M42.3334 30.5C42.3334 29.3954 43.2288 28.5 44.3334 28.5C45.4379 28.5 46.3334 29.3954 46.3334 30.5C46.3334 31.6046 45.4379 32.5 44.3334 32.5C43.2288 32.5 42.3334 31.6046 42.3334 30.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.3334 30.5C49.3334 29.3954 50.2288 28.5 51.3334 28.5C52.4379 28.5 53.3334 29.3954 53.3334 30.5C53.3334 31.6046 52.4379 32.5 51.3334 32.5C50.2288 32.5 49.3334 31.6046 49.3334 30.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M42.3334 30.5C42.3334 29.3954 43.2288 28.5 44.3334 28.5C45.4379 28.5 46.3334 29.3954 46.3334 30.5C46.3334 31.6046 45.4379 32.5 44.3334 32.5C43.2288 32.5 42.3334 31.6046 42.3334 30.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.3334 30.5C49.3334 29.3954 50.2288 28.5 51.3334 28.5C52.4379 28.5 53.3334 29.3954 53.3334 30.5C53.3334 31.6046 52.4379 32.5 51.3334 32.5C50.2288 32.5 49.3334 31.6046 49.3334 30.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="56.3334" y="28.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M0.333374 37.5C0.333374 36.3954 1.2288 35.5 2.33337 35.5C3.43794 35.5 4.33337 36.3954 4.33337 37.5C4.33337 38.6046 3.43794 39.5 2.33337 39.5C1.2288 39.5 0.333374 38.6046 0.333374 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7.33337 37.5C7.33337 36.3954 8.2288 35.5 9.33337 35.5C10.4379 35.5 11.3334 36.3954 11.3334 37.5C11.3334 38.6046 10.4379 39.5 9.33337 39.5C8.2288 39.5 7.33337 38.6046 7.33337 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3334 37.5C14.3334 36.3954 15.2288 35.5 16.3334 35.5C17.4379 35.5 18.3334 36.3954 18.3334 37.5C18.3334 38.6046 17.4379 39.5 16.3334 39.5C15.2288 39.5 14.3334 38.6046 14.3334 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21.3334 37.5C21.3334 36.3954 22.2288 35.5 23.3334 35.5C24.4379 35.5 25.3334 36.3954 25.3334 37.5C25.3334 38.6046 24.4379 39.5 23.3334 39.5C22.2288 39.5 21.3334 38.6046 21.3334 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.3334 37.5C28.3334 36.3954 29.2288 35.5 30.3334 35.5C31.4379 35.5 32.3334 36.3954 32.3334 37.5C32.3334 38.6046 31.4379 39.5 30.3334 39.5C29.2288 39.5 28.3334 38.6046 28.3334 37.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M0.333374 37.5C0.333374 36.3954 1.2288 35.5 2.33337 35.5C3.43794 35.5 4.33337 36.3954 4.33337 37.5C4.33337 38.6046 3.43794 39.5 2.33337 39.5C1.2288 39.5 0.333374 38.6046 0.333374 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7.33337 37.5C7.33337 36.3954 8.2288 35.5 9.33337 35.5C10.4379 35.5 11.3334 36.3954 11.3334 37.5C11.3334 38.6046 10.4379 39.5 9.33337 39.5C8.2288 39.5 7.33337 38.6046 7.33337 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3334 37.5C14.3334 36.3954 15.2288 35.5 16.3334 35.5C17.4379 35.5 18.3334 36.3954 18.3334 37.5C18.3334 38.6046 17.4379 39.5 16.3334 39.5C15.2288 39.5 14.3334 38.6046 14.3334 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21.3334 37.5C21.3334 36.3954 22.2288 35.5 23.3334 35.5C24.4379 35.5 25.3334 36.3954 25.3334 37.5C25.3334 38.6046 24.4379 39.5 23.3334 39.5C22.2288 39.5 21.3334 38.6046 21.3334 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.3334 37.5C28.3334 36.3954 29.2288 35.5 30.3334 35.5C31.4379 35.5 32.3334 36.3954 32.3334 37.5C32.3334 38.6046 31.4379 39.5 30.3334 39.5C29.2288 39.5 28.3334 38.6046 28.3334 37.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="35.3334" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M42.3334 37.5C42.3334 36.3954 43.2288 35.5 44.3334 35.5C45.4379 35.5 46.3334 36.3954 46.3334 37.5C46.3334 38.6046 45.4379 39.5 44.3334 39.5C43.2288 39.5 42.3334 38.6046 42.3334 37.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.3334 37.5C49.3334 36.3954 50.2288 35.5 51.3334 35.5C52.4379 35.5 53.3334 36.3954 53.3334 37.5C53.3334 38.6046 52.4379 39.5 51.3334 39.5C50.2288 39.5 49.3334 38.6046 49.3334 37.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M42.3334 37.5C42.3334 36.3954 43.2288 35.5 44.3334 35.5C45.4379 35.5 46.3334 36.3954 46.3334 37.5C46.3334 38.6046 45.4379 39.5 44.3334 39.5C43.2288 39.5 42.3334 38.6046 42.3334 37.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.3334 37.5C49.3334 36.3954 50.2288 35.5 51.3334 35.5C52.4379 35.5 53.3334 36.3954 53.3334 37.5C53.3334 38.6046 52.4379 39.5 51.3334 39.5C50.2288 39.5 49.3334 38.6046 49.3334 37.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="56.3334" y="35.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M0.333374 44.5C0.333374 43.3954 1.2288 42.5 2.33337 42.5C3.43794 42.5 4.33337 43.3954 4.33337 44.5C4.33337 45.6046 3.43794 46.5 2.33337 46.5C1.2288 46.5 0.333374 45.6046 0.333374 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7.33337 44.5C7.33337 43.3954 8.2288 42.5 9.33337 42.5C10.4379 42.5 11.3334 43.3954 11.3334 44.5C11.3334 45.6046 10.4379 46.5 9.33337 46.5C8.2288 46.5 7.33337 45.6046 7.33337 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3334 44.5C14.3334 43.3954 15.2288 42.5 16.3334 42.5C17.4379 42.5 18.3334 43.3954 18.3334 44.5C18.3334 45.6046 17.4379 46.5 16.3334 46.5C15.2288 46.5 14.3334 45.6046 14.3334 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M21.3334 44.5C21.3334 43.3954 22.2288 42.5 23.3334 42.5C24.4379 42.5 25.3334 43.3954 25.3334 44.5C25.3334 45.6046 24.4379 46.5 23.3334 46.5C22.2288 46.5 21.3334 45.6046 21.3334 44.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M0.333374 44.5C0.333374 43.3954 1.2288 42.5 2.33337 42.5C3.43794 42.5 4.33337 43.3954 4.33337 44.5C4.33337 45.6046 3.43794 46.5 2.33337 46.5C1.2288 46.5 0.333374 45.6046 0.333374 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7.33337 44.5C7.33337 43.3954 8.2288 42.5 9.33337 42.5C10.4379 42.5 11.3334 43.3954 11.3334 44.5C11.3334 45.6046 10.4379 46.5 9.33337 46.5C8.2288 46.5 7.33337 45.6046 7.33337 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3334 44.5C14.3334 43.3954 15.2288 42.5 16.3334 42.5C17.4379 42.5 18.3334 43.3954 18.3334 44.5C18.3334 45.6046 17.4379 46.5 16.3334 46.5C15.2288 46.5 14.3334 45.6046 14.3334 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M21.3334 44.5C21.3334 43.3954 22.2288 42.5 23.3334 42.5C24.4379 42.5 25.3334 43.3954 25.3334 44.5C25.3334 45.6046 24.4379 46.5 23.3334 46.5C22.2288 46.5 21.3334 45.6046 21.3334 44.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="28.3334" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M35.3334 44.5C35.3334 43.3954 36.2288 42.5 37.3334 42.5C38.4379 42.5 39.3334 43.3954 39.3334 44.5C39.3334 45.6046 38.4379 46.5 37.3334 46.5C36.2288 46.5 35.3334 45.6046 35.3334 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M42.3334 44.5C42.3334 43.3954 43.2288 42.5 44.3334 42.5C45.4379 42.5 46.3334 43.3954 46.3334 44.5C46.3334 45.6046 45.4379 46.5 44.3334 46.5C43.2288 46.5 42.3334 45.6046 42.3334 44.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M35.3334 44.5C35.3334 43.3954 36.2288 42.5 37.3334 42.5C38.4379 42.5 39.3334 43.3954 39.3334 44.5C39.3334 45.6046 38.4379 46.5 37.3334 46.5C36.2288 46.5 35.3334 45.6046 35.3334 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M42.3334 44.5C42.3334 43.3954 43.2288 42.5 44.3334 42.5C45.4379 42.5 46.3334 43.3954 46.3334 44.5C46.3334 45.6046 45.4379 46.5 44.3334 46.5C43.2288 46.5 42.3334 45.6046 42.3334 44.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="49.3334" y="42.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M56.3334 44.5C56.3334 43.3954 57.2288 42.5 58.3334 42.5C59.4379 42.5 60.3334 43.3954 60.3334 44.5C60.3334 45.6046 59.4379 46.5 58.3334 46.5C57.2288 46.5 56.3334 45.6046 56.3334 44.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M0.333374 51.5C0.333374 50.3954 1.2288 49.5 2.33337 49.5C3.43794 49.5 4.33337 50.3954 4.33337 51.5C4.33337 52.6046 3.43794 53.5 2.33337 53.5C1.2288 53.5 0.333374 52.6046 0.333374 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M7.33337 51.5C7.33337 50.3954 8.2288 49.5 9.33337 49.5C10.4379 49.5 11.3334 50.3954 11.3334 51.5C11.3334 52.6046 10.4379 53.5 9.33337 53.5C8.2288 53.5 7.33337 52.6046 7.33337 51.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M14.3334 51.5C14.3334 50.3954 15.2288 49.5 16.3334 49.5C17.4379 49.5 18.3334 50.3954 18.3334 51.5C18.3334 52.6046 17.4379 53.5 16.3334 53.5C15.2288 53.5 14.3334 52.6046 14.3334 51.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M56.3334 44.5C56.3334 43.3954 57.2288 42.5 58.3334 42.5C59.4379 42.5 60.3334 43.3954 60.3334 44.5C60.3334 45.6046 59.4379 46.5 58.3334 46.5C57.2288 46.5 56.3334 45.6046 56.3334 44.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M0.333374 51.5C0.333374 50.3954 1.2288 49.5 2.33337 49.5C3.43794 49.5 4.33337 50.3954 4.33337 51.5C4.33337 52.6046 3.43794 53.5 2.33337 53.5C1.2288 53.5 0.333374 52.6046 0.333374 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M7.33337 51.5C7.33337 50.3954 8.2288 49.5 9.33337 49.5C10.4379 49.5 11.3334 50.3954 11.3334 51.5C11.3334 52.6046 10.4379 53.5 9.33337 53.5C8.2288 53.5 7.33337 52.6046 7.33337 51.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M14.3334 51.5C14.3334 50.3954 15.2288 49.5 16.3334 49.5C17.4379 49.5 18.3334 50.3954 18.3334 51.5C18.3334 52.6046 17.4379 53.5 16.3334 53.5C15.2288 53.5 14.3334 52.6046 14.3334 51.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="21.3334" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> <rect x="28.3334" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M35.3334 51.5C35.3334 50.3954 36.2288 49.5 37.3334 49.5C38.4379 49.5 39.3334 50.3954 39.3334 51.5C39.3334 52.6046 38.4379 53.5 37.3334 53.5C36.2288 53.5 35.3334 52.6046 35.3334 51.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M35.3334 51.5C35.3334 50.3954 36.2288 49.5 37.3334 49.5C38.4379 49.5 39.3334 50.3954 39.3334 51.5C39.3334 52.6046 38.4379 53.5 37.3334 53.5C36.2288 53.5 35.3334 52.6046 35.3334 51.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="42.3334" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> <rect x="49.3334" y="49.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M56.3334 51.5C56.3334 50.3954 57.2288 49.5 58.3334 49.5C59.4379 49.5 60.3334 50.3954 60.3334 51.5C60.3334 52.6046 59.4379 53.5 58.3334 53.5C57.2288 53.5 56.3334 52.6046 56.3334 51.5Z" fill="var(--color-text-quaternary)" /> - <rect x="0.333374" y="56.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> + <path + opacity="0.18" + d="M56.3334 51.5C56.3334 50.3954 57.2288 49.5 58.3334 49.5C59.4379 49.5 60.3334 50.3954 60.3334 51.5C60.3334 52.6046 59.4379 53.5 58.3334 53.5C57.2288 53.5 56.3334 52.6046 56.3334 51.5Z" + fill="var(--color-text-quaternary)" + /> + <rect + x="0.333374" + y="56.5" + width="4" + height="4" + rx="2" + fill="var(--color-text-secondary)" + /> <rect x="7.33337" y="56.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> <rect x="14.3334" y="56.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M21.3334 58.5C21.3334 57.3954 22.2288 56.5 23.3334 56.5C24.4379 56.5 25.3334 57.3954 25.3334 58.5C25.3334 59.6046 24.4379 60.5 23.3334 60.5C22.2288 60.5 21.3334 59.6046 21.3334 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M28.3334 58.5C28.3334 57.3954 29.2288 56.5 30.3334 56.5C31.4379 56.5 32.3334 57.3954 32.3334 58.5C32.3334 59.6046 31.4379 60.5 30.3334 60.5C29.2288 60.5 28.3334 59.6046 28.3334 58.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M21.3334 58.5C21.3334 57.3954 22.2288 56.5 23.3334 56.5C24.4379 56.5 25.3334 57.3954 25.3334 58.5C25.3334 59.6046 24.4379 60.5 23.3334 60.5C22.2288 60.5 21.3334 59.6046 21.3334 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M28.3334 58.5C28.3334 57.3954 29.2288 56.5 30.3334 56.5C31.4379 56.5 32.3334 57.3954 32.3334 58.5C32.3334 59.6046 31.4379 60.5 30.3334 60.5C29.2288 60.5 28.3334 59.6046 28.3334 58.5Z" + fill="var(--color-text-quaternary)" + /> <rect x="35.3334" y="56.5" width="4" height="4" rx="2" fill="var(--color-text-secondary)" /> - <path opacity="0.18" d="M42.3334 58.5C42.3334 57.3954 43.2288 56.5 44.3334 56.5C45.4379 56.5 46.3334 57.3954 46.3334 58.5C46.3334 59.6046 45.4379 60.5 44.3334 60.5C43.2288 60.5 42.3334 59.6046 42.3334 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M49.3334 58.5C49.3334 57.3954 50.2288 56.5 51.3334 56.5C52.4379 56.5 53.3334 57.3954 53.3334 58.5C53.3334 59.6046 52.4379 60.5 51.3334 60.5C50.2288 60.5 49.3334 59.6046 49.3334 58.5Z" fill="var(--color-text-quaternary)" /> - <path opacity="0.18" d="M56.3334 58.5C56.3334 57.3954 57.2288 56.5 58.3334 56.5C59.4379 56.5 60.3334 57.3954 60.3334 58.5C60.3334 59.6046 59.4379 60.5 58.3334 60.5C57.2288 60.5 56.3334 59.6046 56.3334 58.5Z" fill="var(--color-text-quaternary)" /> + <path + opacity="0.18" + d="M42.3334 58.5C42.3334 57.3954 43.2288 56.5 44.3334 56.5C45.4379 56.5 46.3334 57.3954 46.3334 58.5C46.3334 59.6046 45.4379 60.5 44.3334 60.5C43.2288 60.5 42.3334 59.6046 42.3334 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M49.3334 58.5C49.3334 57.3954 50.2288 56.5 51.3334 56.5C52.4379 56.5 53.3334 57.3954 53.3334 58.5C53.3334 59.6046 52.4379 60.5 51.3334 60.5C50.2288 60.5 49.3334 59.6046 49.3334 58.5Z" + fill="var(--color-text-quaternary)" + /> + <path + opacity="0.18" + d="M56.3334 58.5C56.3334 57.3954 57.2288 56.5 58.3334 56.5C59.4379 56.5 60.3334 57.3954 60.3334 58.5C60.3334 59.6046 59.4379 60.5 58.3334 60.5C57.2288 60.5 56.3334 59.6046 56.3334 58.5Z" + fill="var(--color-text-quaternary)" + /> </g> <defs> <clipPath id="clip0_1_4943"> diff --git a/web/app/components/billing/pricing/footer.tsx b/web/app/components/billing/pricing/footer.tsx index 2c969640960e39..9cceb3e642854a 100644 --- a/web/app/components/billing/pricing/footer.tsx +++ b/web/app/components/billing/pricing/footer.tsx @@ -10,19 +10,25 @@ type FooterProps = { currentCategory: Category } -const Footer = ({ - pricingPageURL, - currentCategory, -}: FooterProps) => { +const Footer = ({ pricingPageURL, currentCategory }: FooterProps) => { const { t } = useTranslation() return ( <div className="flex min-h-16 w-full justify-center border-t border-divider-accent px-10"> - <div className={cn('flex max-w-[1680px] grow border-x border-divider-accent p-6', currentCategory === CategoryEnum.CLOUD ? 'justify-between' : 'justify-end')}> + <div + className={cn( + 'flex max-w-[1680px] grow border-x border-divider-accent p-6', + currentCategory === CategoryEnum.CLOUD ? 'justify-between' : 'justify-end', + )} + > {currentCategory === CategoryEnum.CLOUD && ( <div className="flex flex-col text-text-tertiary"> - <span className="system-xs-regular">{t($ => $['plansCommon.taxTip'], { ns: 'billing' })}</span> - <span className="system-xs-regular">{t($ => $['plansCommon.taxTipSecond'], { ns: 'billing' })}</span> + <span className="system-xs-regular"> + {t(($) => $['plansCommon.taxTip'], { ns: 'billing' })} + </span> + <span className="system-xs-regular"> + {t(($) => $['plansCommon.taxTipSecond'], { ns: 'billing' })} + </span> </div> )} <span className="flex h-fit items-center gap-x-1 text-saas-dify-blue-accessible"> @@ -32,7 +38,7 @@ const Footer = ({ target="_blank" rel="noopener noreferrer" > - {t($ => $['plansCommon.comparePlanAndFeatures'], { ns: 'billing' })} + {t(($) => $['plansCommon.comparePlanAndFeatures'], { ns: 'billing' })} </Link> <span aria-hidden="true" className="i-ri-arrow-right-up-line size-4" /> </span> diff --git a/web/app/components/billing/pricing/header.module.css b/web/app/components/billing/pricing/header.module.css index fc05646d8656e5..4f438c56ce556b 100644 --- a/web/app/components/billing/pricing/header.module.css +++ b/web/app/components/billing/pricing/header.module.css @@ -1,24 +1,15 @@ .instrumentSerif { - font-family: "Instrument Serif", serif; + font-family: 'Instrument Serif', serif; font-style: italic; } @font-face { - font-family: "Instrument Serif"; + font-family: 'Instrument Serif'; font-style: italic; font-weight: 400; font-display: swap; - src: url("./InstrumentSerif-Italic-Latin.woff2") format("woff2"); + src: url('./InstrumentSerif-Italic-Latin.woff2') format('woff2'); unicode-range: - U+0000-00FF, - U+0100-024F, - U+0259, - U+0300-036F, - U+1E00-1EFF, - U+2010-205E, - U+20A0-20CF, - U+2113, - U+2212, - U+2C60-2C7F, - U+A720-A7FF; + U+0000-00FF, U+0100-024F, U+0259, U+0300-036F, U+1E00-1EFF, U+2010-205E, U+20A0-20CF, U+2113, + U+2212, U+2C60-2C7F, U+A720-A7FF; } diff --git a/web/app/components/billing/pricing/header.tsx b/web/app/components/billing/pricing/header.tsx index 87bb7cf9c72ffb..d436754b5faea2 100644 --- a/web/app/components/billing/pricing/header.tsx +++ b/web/app/components/billing/pricing/header.tsx @@ -10,9 +10,7 @@ type HeaderProps = { onClose: () => void } -const Header = ({ - onClose, -}: HeaderProps) => { +const Header = ({ onClose }: HeaderProps) => { const { t } = useTranslation() return ( @@ -28,16 +26,16 @@ const Header = ({ styles.instrumentSerif, )} > - {t($ => $['plansCommon.title.plans'], { ns: 'billing' })} + {t(($) => $['plansCommon.title.plans'], { ns: 'billing' })} </DialogTitle> </div> <DialogDescription className="system-sm-regular text-text-tertiary"> - {t($ => $['plansCommon.title.description'], { ns: 'billing' })} + {t(($) => $['plansCommon.title.description'], { ns: 'billing' })} </DialogDescription> <Button variant="secondary" className="absolute right-[-18px] bottom-[40.5px] z-10 size-9 rounded-full p-2" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onClose} > <span aria-hidden="true" className="i-ri-close-line size-5" /> diff --git a/web/app/components/billing/pricing/index.tsx b/web/app/components/billing/pricing/index.tsx index 2ba6a57fd928cd..5766a45ee6b3b6 100644 --- a/web/app/components/billing/pricing/index.tsx +++ b/web/app/components/billing/pricing/index.tsx @@ -29,15 +29,14 @@ type PricingProps = { onCancel: () => void } -const Pricing: FC<PricingProps> = ({ - onCancel, -}) => { +const Pricing: FC<PricingProps> = ({ onCancel }) => { const { plan, enableEducationPlan, isEducationAccount } = useProviderContext() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage) const shouldDefaultToYearly = canManageBilling && enableEducationPlan && isEducationAccount const [selectedPlanRange, setSelectedPlanRange] = React.useState<PlanRange>() - const planRange = selectedPlanRange ?? (shouldDefaultToYearly ? PlanRange.yearly : PlanRange.monthly) + const planRange = + selectedPlanRange ?? (shouldDefaultToYearly ? PlanRange.yearly : PlanRange.monthly) const [currentCategory, setCurrentCategory] = useState<Category>(CategoryEnum.CLOUD) const canPay = canManageBilling @@ -50,13 +49,10 @@ const Pricing: FC<PricingProps> = ({ <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > - <DialogContent - className="inset-0 size-full max-h-none max-w-none translate-0 overflow-hidden rounded-none border-none bg-saas-background p-0 shadow-none" - > + <DialogContent className="inset-0 size-full max-h-none max-w-none translate-0 overflow-hidden rounded-none border-none bg-saas-background p-0 shadow-none"> <ScrollAreaRoot className="relative h-full w-full overflow-hidden"> <ScrollAreaViewport className="overscroll-contain"> <ScrollAreaContent className="min-h-full min-w-300"> diff --git a/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx b/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx index ebe3ad43efcab0..e209dd615824cb 100644 --- a/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx +++ b/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx @@ -13,15 +13,7 @@ describe('PlanSwitcherTab', () => { describe('Rendering', () => { it('should render label and icon', () => { - render( - <Tab - Icon={Icon} - value="cloud" - label="Cloud" - isActive={false} - onClick={vi.fn()} - />, - ) + render(<Tab Icon={Icon} value="cloud" label="Cloud" isActive={false} onClick={vi.fn()} />) expect(screen.getByText('Cloud')).toBeInTheDocument() expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'false') @@ -31,15 +23,7 @@ describe('PlanSwitcherTab', () => { describe('Props', () => { it('should call onClick with the provided value', () => { const handleClick = vi.fn() - render( - <Tab - Icon={Icon} - value="self" - label="Self" - isActive={false} - onClick={handleClick} - />, - ) + render(<Tab Icon={Icon} value="self" label="Self" isActive={false} onClick={handleClick} />) fireEvent.click(screen.getByText('Self')) @@ -49,26 +33,12 @@ describe('PlanSwitcherTab', () => { it('should apply distinct styling when isActive is true', () => { const { rerender } = render( - <Tab - Icon={Icon} - value="cloud" - label="Cloud" - isActive={false} - onClick={vi.fn()} - />, + <Tab Icon={Icon} value="cloud" label="Cloud" isActive={false} onClick={vi.fn()} />, ) const inactiveClassName = screen.getByText('Cloud').className - rerender( - <Tab - Icon={Icon} - value="cloud" - label="Cloud" - isActive - onClick={vi.fn()} - />, - ) + rerender(<Tab Icon={Icon} value="cloud" label="Cloud" isActive onClick={vi.fn()} />) const activeClassName = screen.getByText('Cloud').className expect(activeClassName).not.toBe(inactiveClassName) @@ -79,13 +49,7 @@ describe('PlanSwitcherTab', () => { describe('Edge Cases', () => { it('should render when label is empty', () => { const { container } = render( - <Tab - Icon={Icon} - value="cloud" - label="" - isActive={false} - onClick={vi.fn()} - />, + <Tab Icon={Icon} value="cloud" label="" isActive={false} onClick={vi.fn()} />, ) const label = container.querySelector('span') diff --git a/web/app/components/billing/pricing/plan-switcher/index.tsx b/web/app/components/billing/pricing/plan-switcher/index.tsx index 68185572ca639c..0e75b4b8ba6a07 100644 --- a/web/app/components/billing/pricing/plan-switcher/index.tsx +++ b/web/app/components/billing/pricing/plan-switcher/index.tsx @@ -27,12 +27,12 @@ const PlanSwitcher: FC<PlanSwitcherProps> = ({ const tabs = { cloud: { value: 'cloud' as Category, - label: t($ => $['plansCommon.cloud'], { ns: 'billing' }), + label: t(($) => $['plansCommon.cloud'], { ns: 'billing' }), Icon: Cloud, }, self: { value: 'self' as Category, - label: t($ => $['plansCommon.self'], { ns: 'billing' }), + label: t(($) => $['plansCommon.self'], { ns: 'billing' }), Icon: SelfHosted, }, } @@ -53,12 +53,7 @@ const PlanSwitcher: FC<PlanSwitcherProps> = ({ onClick={onChangeCategory} /> </div> - {isCloud && ( - <PlanRangeSwitcher - value={currentPlanRange} - onChange={onChangePlanRange} - /> - )} + {isCloud && <PlanRangeSwitcher value={currentPlanRange} onChange={onChangePlanRange} />} </div> </div> ) diff --git a/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx b/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx index ee254459d7b9a3..ad1c03d6ff8959 100644 --- a/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx +++ b/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx @@ -14,10 +14,7 @@ type PlanRangeSwitcherProps = { onChange: (value: PlanRange) => void } -const PlanRangeSwitcher: FC<PlanRangeSwitcherProps> = ({ - value, - onChange, -}) => { +const PlanRangeSwitcher: FC<PlanRangeSwitcherProps> = ({ value, onChange }) => { const { t } = useTranslation() return ( @@ -30,7 +27,7 @@ const PlanRangeSwitcher: FC<PlanRangeSwitcherProps> = ({ }} /> <span className="system-md-regular text-text-tertiary"> - {t($ => $['plansCommon.annualBilling'], { ns: 'billing', percent: 17 })} + {t(($) => $['plansCommon.annualBilling'], { ns: 'billing', percent: 17 })} </span> </div> ) diff --git a/web/app/components/billing/pricing/plan-switcher/tab.tsx b/web/app/components/billing/pricing/plan-switcher/tab.tsx index ca7393626440fa..22ae96ee839387 100644 --- a/web/app/components/billing/pricing/plan-switcher/tab.tsx +++ b/web/app/components/billing/pricing/plan-switcher/tab.tsx @@ -10,13 +10,7 @@ type TabProps<T> = { onClick: (value: T) => void } -const Tab = <T,>({ - Icon, - value, - label, - isActive, - onClick, -}: TabProps<T>) => { +const Tab = <T,>({ Icon, value, label, isActive, onClick }: TabProps<T>) => { const handleClick = useCallback(() => { onClick(value) }, [onClick, value]) diff --git a/web/app/components/billing/pricing/plans/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/__tests__/index.spec.tsx index 0c506b1ba22ff2..e90ed80366c9c8 100644 --- a/web/app/components/billing/pricing/plans/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/__tests__/index.spec.tsx @@ -9,23 +9,15 @@ import Plans from '../index' import selfHostedPlanItem from '../self-hosted-plan-item' vi.mock('../cloud-plan-item', () => ({ - default: vi.fn(props => ( + default: vi.fn((props) => ( <div data-testid={`cloud-plan-${props.plan}`} data-current-plan={props.currentPlan}> - Cloud - {' '} - {props.plan} + Cloud {props.plan} </div> )), })) vi.mock('../self-hosted-plan-item', () => ({ - default: vi.fn(props => ( - <div data-testid={`self-plan-${props.plan}`}> - Self - {' '} - {props.plan} - </div> - )), + default: vi.fn((props) => <div data-testid={`self-plan-${props.plan}`}>Self {props.plan}</div>), })) const buildPlan = (type: Plan) => { diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx index 6f09657cdcfc09..c6810fef06402e 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx @@ -36,7 +36,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -109,17 +110,13 @@ beforeEach(() => { toast.dismiss() mockAppContext({ isCurrentWorkspaceManager: true, - workspacePermissionKeys: [ - 'billing.view', - 'billing.manage', - 'billing.subscription.manage', - ], + workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'], }) mockUseProviderContext.mockReturnValue({ enableEducationPlan: false, isEducationAccount: false, }) - mockUseAsyncWindowOpen.mockReturnValue(vi.fn(async open => await open())) + mockUseAsyncWindowOpen.mockReturnValue(vi.fn(async (open) => await open())) mockBillingInvoices.mockResolvedValue({ url: 'https://billing.example' }) mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://subscription.example' }) assignedHref = '' @@ -148,7 +145,9 @@ describe('CloudPlanItem', () => { expect(screen.getByText('billing.plans.sandbox.name'))!.toBeInTheDocument() expect(screen.getByText('billing.plans.sandbox.description'))!.toBeInTheDocument() expect(screen.getByText('billing.plansCommon.free'))!.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))!.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }), + )!.toBeInTheDocument() }) it('should display yearly pricing with discount when planRange is yearly', () => { @@ -164,7 +163,9 @@ describe('CloudPlanItem', () => { const professionalPlan = ALL_PLANS[Plan.professional] expect(screen.getByText(`$${professionalPlan.price * 12}`))!.toBeInTheDocument() expect(screen.getByText(`$${professionalPlan.price * 10}`))!.toBeInTheDocument() - expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.year/))!.toBeInTheDocument() + expect( + screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.year/), + )!.toBeInTheDocument() }) it('should show "most popular" badge for professional plan', () => { @@ -362,9 +363,15 @@ describe('CloudPlanItem', () => { />, ) - expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))!.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'education.useEducationDiscount' })).not.toBeInTheDocument() - expect(screen.queryByText('education.planNotSupportEducationDiscount')).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }), + )!.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'education.useEducationDiscount' }), + ).not.toBeInTheDocument() + expect( + screen.queryByText('education.planNotSupportEducationDiscount'), + ).not.toBeInTheDocument() }) it('should hide education unsupported warning when billing manage permission is missing', () => { @@ -386,8 +393,12 @@ describe('CloudPlanItem', () => { />, ) - expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))!.toBeInTheDocument() - expect(screen.queryByText('education.planNotSupportEducationDiscount')).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }), + )!.toBeInTheDocument() + expect( + screen.queryByText('education.planNotSupportEducationDiscount'), + ).not.toBeInTheDocument() }) it('should show education unsupported warning and switch checkout to professional annual', async () => { @@ -413,8 +424,12 @@ describe('CloudPlanItem', () => { expect(screen.getByText('education.educationPricingConfirm.title'))!.toBeInTheDocument() expect(screen.getByText('education.educationPricingConfirm.description'))!.toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.close' }))!.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))!.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.continue' })) + expect( + screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }), + )!.toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { name: 'education.educationPricingConfirm.continue' }), + ) await waitFor(() => { expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'year') @@ -438,10 +453,14 @@ describe('CloudPlanItem', () => { ) fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' })) - fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' })) + fireEvent.click( + screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }), + ) await waitFor(() => { - expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument() + expect( + screen.queryByText('education.educationPricingConfirm.title'), + )!.not.toBeInTheDocument() expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year') expect(assignedHref).toBe('https://subscription.example') }) @@ -466,7 +485,9 @@ describe('CloudPlanItem', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) await waitFor(() => { - expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument() + expect( + screen.queryByText('education.educationPricingConfirm.title'), + )!.not.toBeInTheDocument() }) expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() expect(assignedHref).toBe('') @@ -477,7 +498,10 @@ describe('CloudPlanItem', () => { // Make the first fetch hang until we resolve it let resolveFirst!: (v: { url: string }) => void mockFetchSubscriptionUrls.mockImplementationOnce( - () => new Promise((resolve) => { resolveFirst = resolve }), + () => + new Promise((resolve) => { + resolveFirst = resolve + }), ) render( @@ -507,14 +531,15 @@ describe('CloudPlanItem', () => { // Covers L82-83, L85-87: openAsyncWindow error path when invoices returns no url it('should invoke onError when billing invoices returns empty url', async () => { mockBillingInvoices.mockResolvedValue({ url: '' }) - const openWindow = vi.fn(async (cb: () => Promise<string>, opts: { onError?: (e: Error) => void }) => { - try { - await cb() - } - catch (e) { - opts.onError?.(e as Error) - } - }) + const openWindow = vi.fn( + async (cb: () => Promise<string>, opts: { onError?: (e: Error) => void }) => { + try { + await cb() + } catch (e) { + opts.onError?.(e as Error) + } + }, + ) mockUseAsyncWindowOpen.mockReturnValue(openWindow) render( @@ -549,7 +574,9 @@ describe('CloudPlanItem', () => { const teamPlan = ALL_PLANS[Plan.team] expect(screen.getByText(`$${teamPlan.price}`))!.toBeInTheDocument() - expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.month/))!.toBeInTheDocument() + expect( + screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.month/), + )!.toBeInTheDocument() // Should NOT show crossed-out yearly price // Should NOT show crossed-out yearly price // Should NOT show crossed-out yearly price diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx index 1e40377db2033f..ea6abd74ac4ed8 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx @@ -5,16 +5,22 @@ import { Plan } from '../../../type' const BUTTON_CLASSNAME = { [Plan.sandbox]: { - btnClassname: 'bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover text-text-primary', - btnDisabledClassname: 'bg-components-button-tertiary-bg-disabled hover:bg-components-button-tertiary-bg-disabled text-text-disabled', + btnClassname: + 'bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover text-text-primary', + btnDisabledClassname: + 'bg-components-button-tertiary-bg-disabled hover:bg-components-button-tertiary-bg-disabled text-text-disabled', }, [Plan.professional]: { - btnClassname: 'bg-saas-dify-blue-static hover:bg-saas-dify-blue-static-hover text-text-primary-on-surface', - btnDisabledClassname: 'bg-components-button-tertiary-bg-disabled hover:bg-components-button-tertiary-bg-disabled text-text-disabled', + btnClassname: + 'bg-saas-dify-blue-static hover:bg-saas-dify-blue-static-hover text-text-primary-on-surface', + btnDisabledClassname: + 'bg-components-button-tertiary-bg-disabled hover:bg-components-button-tertiary-bg-disabled text-text-disabled', }, [Plan.team]: { - btnClassname: 'bg-saas-background-inverted hover:bg-saas-background-inverted-hover text-background-default', - btnDisabledClassname: 'bg-components-button-tertiary-bg-disabled hover:bg-components-button-tertiary-bg-disabled text-text-disabled', + btnClassname: + 'bg-saas-background-inverted hover:bg-saas-background-inverted-hover text-background-default', + btnDisabledClassname: + 'bg-components-button-tertiary-bg-disabled hover:bg-components-button-tertiary-bg-disabled text-text-disabled', }, } @@ -26,13 +32,7 @@ type ButtonProps = { warningText?: string } -const Button = ({ - plan, - isPlanDisabled, - btnText, - handleGetPayUrl, - warningText, -}: ButtonProps) => { +const Button = ({ plan, isPlanDisabled, btnText, handleGetPayUrl, warningText }: ButtonProps) => { return ( <div className="relative"> <button diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx index 956ac9ae03ca3d..776674a25759dc 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx @@ -41,12 +41,7 @@ type CloudPlanItemProps = { canPay: boolean } -const CloudPlanItem: FC<CloudPlanItemProps> = ({ - plan, - currentPlan, - planRange, - canPay, -}) => { +const CloudPlanItem: FC<CloudPlanItemProps> = ({ plan, currentPlan, planRange, canPay }) => { const { t } = useTranslation() const [loading, setLoading] = React.useState(false) const i18nPrefix = `plans.${plan}` as const @@ -59,64 +54,66 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ const isPlanDisabled = isCurrentPaidPlan ? false : planInfo.level <= ALL_PLANS[currentPlan].level const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage) - const canManageBillingSubscription = hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage) + const canManageBillingSubscription = hasPermission( + workspacePermissionKeys, + BillingPermission.SubscriptionManage, + ) const { enableEducationPlan, isEducationAccount } = useProviderContext() const isEducationDiscountMode = enableEducationPlan && isEducationAccount const isEducationDiscountSupportedPlan = plan === Plan.professional && isYear - const educationDiscountWarningText = canPay && isEducationDiscountMode && !isFreePlan && !isEducationDiscountSupportedPlan - ? t($ => $.planNotSupportEducationDiscount, { ns: 'education' }) - : undefined + const educationDiscountWarningText = + canPay && isEducationDiscountMode && !isFreePlan && !isEducationDiscountSupportedPlan + ? t(($) => $.planNotSupportEducationDiscount, { ns: 'education' }) + : undefined const openAsyncWindow = useAsyncWindowOpen() const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount() const [showEducationPricingConfirm, setShowEducationPricingConfirm] = React.useState(false) const btnText = useMemo(() => { if (canPay && isEducationDiscountMode && isEducationDiscountSupportedPlan && !isCurrent) - return t($ => $.useEducationDiscount, { ns: 'education' }) + return t(($) => $.useEducationDiscount, { ns: 'education' }) - if (isCurrent) - return t($ => $['plansCommon.currentPlan'], { ns: 'billing' }) + if (isCurrent) return t(($) => $['plansCommon.currentPlan'], { ns: 'billing' }) - return ({ - [Plan.sandbox]: t($ => $['plansCommon.startForFree'], { ns: 'billing' }), - [Plan.professional]: t($ => $['plansCommon.startBuilding'], { ns: 'billing' }), - [Plan.team]: t($ => $['plansCommon.getStarted'], { ns: 'billing' }), - })[plan] + return { + [Plan.sandbox]: t(($) => $['plansCommon.startForFree'], { ns: 'billing' }), + [Plan.professional]: t(($) => $['plansCommon.startBuilding'], { ns: 'billing' }), + [Plan.team]: t(($) => $['plansCommon.getStarted'], { ns: 'billing' }), + }[plan] }, [canPay, isCurrent, isEducationDiscountMode, isEducationDiscountSupportedPlan, plan, t]) const handlePayCurrentPlan = async () => { - if (loading || isEducationDiscountLoading) - return + if (loading || isEducationDiscountLoading) return - if (isPlanDisabled) - return + if (isPlanDisabled) return setLoading(true) try { if (isCurrentPaidPlan) { if (!canManageBillingSubscription) { - toast.error(t($ => $.buyPermissionDeniedTip, { ns: 'billing' })) + toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' })) return } - await openAsyncWindow(async () => { - const res = await consoleClient.billing.invoices.get() - if (res.url) - return res.url - throw new Error('Failed to open billing page') - }, { - onError: (err) => { - toast.error(err.message || String(err)) + await openAsyncWindow( + async () => { + const res = await consoleClient.billing.invoices.get() + if (res.url) return res.url + throw new Error('Failed to open billing page') + }, + { + onError: (err) => { + toast.error(err.message || String(err)) + }, }, - }) + ) return } - if (isFreePlan) - return + if (isFreePlan) return if (!canManageBilling) { - toast.error(t($ => $.buyPermissionDeniedTip, { ns: 'billing' })) + toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' })) return } @@ -128,8 +125,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ const res = await fetchSubscriptionUrls(plan, isYear ? 'year' : 'month') // Adb Block additional tracking block the gtag, so we need to redirect directly window.location.href = res.url - } - finally { + } finally { setLoading(false) } } @@ -155,40 +151,42 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ {ICON_MAP[plan]} <div className="flex min-h-26 flex-col gap-y-2"> <div className="flex items-center gap-x-2.5"> - <div className="text-[30px] leading-[1.2] font-medium text-text-primary">{t($ => $[`${i18nPrefix}.name`], { ns: 'billing' })}</div> - { - isMostPopularPlan && ( - <div className="flex items-center justify-center bg-saas-dify-blue-static px-1.5 py-1"> - <span className="system-2xs-semibold-uppercase text-text-primary-on-surface"> - {t($ => $['plansCommon.mostPopular'], { ns: 'billing' })} - </span> - </div> - ) - } + <div className="text-[30px] leading-[1.2] font-medium text-text-primary"> + {t(($) => $[`${i18nPrefix}.name`], { ns: 'billing' })} + </div> + {isMostPopularPlan && ( + <div className="flex items-center justify-center bg-saas-dify-blue-static px-1.5 py-1"> + <span className="system-2xs-semibold-uppercase text-text-primary-on-surface"> + {t(($) => $['plansCommon.mostPopular'], { ns: 'billing' })} + </span> + </div> + )} + </div> + <div className="system-sm-regular text-text-secondary"> + {t(($) => $[`${i18nPrefix}.description`], { ns: 'billing' })} </div> - <div className="system-sm-regular text-text-secondary">{t($ => $[`${i18nPrefix}.description`], { ns: 'billing' })}</div> </div> </div> {/* Price */} <div className="flex items-end gap-x-2 px-1 pt-4 pb-8"> {isFreePlan && ( - <span className="title-4xl-semi-bold text-text-primary">{t($ => $['plansCommon.free'], { ns: 'billing' })}</span> + <span className="title-4xl-semi-bold text-text-primary"> + {t(($) => $['plansCommon.free'], { ns: 'billing' })} + </span> )} {!isFreePlan && ( <> {isYear && ( <span className="title-4xl-semi-bold text-text-quaternary line-through"> - $ - {planInfo.price * 12} + ${planInfo.price * 12} </span> )} <span className="title-4xl-semi-bold text-text-primary"> - $ - {isYear ? planInfo.price * 10 : planInfo.price} + ${isYear ? planInfo.price * 10 : planInfo.price} </span> <span className="pb-0.5 system-md-regular text-text-tertiary"> - {t($ => $['plansCommon.priceTip'], { ns: 'billing' })} - {t($ => $[`plansCommon.${!isYear ? 'month' : 'year'}`], { ns: 'billing' })} + {t(($) => $['plansCommon.priceTip'], { ns: 'billing' })} + {t(($) => $[`plansCommon.${!isYear ? 'month' : 'year'}`], { ns: 'billing' })} </span> </> )} @@ -202,24 +200,18 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ /> </div> <List plan={plan} /> - <Dialog - open={showEducationPricingConfirm} - onOpenChange={setShowEducationPricingConfirm} - > - <DialogContent - backdropProps={{ forceRender: true }} - className="w-[520px]" - > + <Dialog open={showEducationPricingConfirm} onOpenChange={setShowEducationPricingConfirm}> + <DialogContent backdropProps={{ forceRender: true }} className="w-[520px]"> <DialogCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="top-6 right-6" /> <div className="flex flex-col gap-2 pr-10"> <DialogTitle className="w-full title-2xl-semi-bold text-text-primary"> - {t($ => $['educationPricingConfirm.title'], { ns: 'education' })} + {t(($) => $['educationPricingConfirm.title'], { ns: 'education' })} </DialogTitle> <DialogDescription className="w-full system-md-regular text-text-tertiary"> - {t($ => $['educationPricingConfirm.description'], { ns: 'education' })} + {t(($) => $['educationPricingConfirm.description'], { ns: 'education' })} </DialogDescription> </div> <div className="mt-10 flex items-start justify-end gap-3"> @@ -230,7 +222,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ loading={loading} className="min-w-38" > - {t($ => $['educationPricingConfirm.cancel'], { ns: 'education' })} + {t(($) => $['educationPricingConfirm.cancel'], { ns: 'education' })} </Button> <Button variant="primary" @@ -240,7 +232,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ loading={isEducationDiscountLoading} className="min-w-61" > - {t($ => $['educationPricingConfirm.continue'], { ns: 'education' })} + {t(($) => $['educationPricingConfirm.continue'], { ns: 'education' })} </Button> </div> </DialogContent> diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx index e6a0d78273e0bc..0cd4b4d439f7a7 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx @@ -8,16 +8,24 @@ describe('CloudPlanItem/List', () => { it('should show sandbox specific quotas', () => { render(<List plan={Plan.sandbox} />) - expect(screen.getByText('billing.plansCommon.messageRequest.title:{"count":200}')).toBeInTheDocument() - expect(screen.getByText('billing.plansCommon.triggerEvents.sandbox:{"count":3000}')).toBeInTheDocument() - expect(screen.getByText('billing.plansCommon.startNodes.limited:{"count":2}')).toBeInTheDocument() + expect( + screen.getByText('billing.plansCommon.messageRequest.title:{"count":200}'), + ).toBeInTheDocument() + expect( + screen.getByText('billing.plansCommon.triggerEvents.sandbox:{"count":3000}'), + ).toBeInTheDocument() + expect( + screen.getByText('billing.plansCommon.startNodes.limited:{"count":2}'), + ).toBeInTheDocument() }) it('should show professional monthly quotas and tooltips', async () => { const user = userEvent.setup() render(<List plan={Plan.professional} />) - expect(screen.getByText('billing.plansCommon.messageRequest.titlePerMonth:{"count":5000}')).toBeInTheDocument() + expect( + screen.getByText('billing.plansCommon.messageRequest.titlePerMonth:{"count":5000}'), + ).toBeInTheDocument() await user.hover(screen.getByRole('button', { name: 'billing.plansCommon.vectorSpaceTooltip' })) expect(await screen.findByText('billing.plansCommon.vectorSpaceTooltip')).toBeInTheDocument() expect(screen.getByText('billing.plansCommon.workflowExecution.faster')).toBeInTheDocument() diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/index.tsx index 1c46ee38f2d17d..7fae5da17a916f 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/index.tsx @@ -10,9 +10,7 @@ type ListProps = { plan: BasicPlan } -const List = ({ - plan, -}: ListProps) => { +const List = ({ plan }: ListProps) => { const { t } = useTranslation() const isFreePlan = plan === Plan.sandbox const planInfo = ALL_PLANS[plan] @@ -20,84 +18,126 @@ const List = ({ return ( <div className="flex flex-col gap-y-2.5 p-6"> <Item - label={isFreePlan - ? t($ => $['plansCommon.messageRequest.title'], { ns: 'billing', count: planInfo.messageRequest }) - : t($ => $['plansCommon.messageRequest.titlePerMonth'], { ns: 'billing', count: planInfo.messageRequest })} - tooltip={t($ => $['plansCommon.messageRequest.tooltip'], { ns: 'billing' }) as string} + label={ + isFreePlan + ? t(($) => $['plansCommon.messageRequest.title'], { + ns: 'billing', + count: planInfo.messageRequest, + }) + : t(($) => $['plansCommon.messageRequest.titlePerMonth'], { + ns: 'billing', + count: planInfo.messageRequest, + }) + } + tooltip={t(($) => $['plansCommon.messageRequest.tooltip'], { ns: 'billing' }) as string} /> <Item - label={t($ => $['plansCommon.teamWorkspace'], { ns: 'billing', count: planInfo.teamWorkspace })} + label={t(($) => $['plansCommon.teamWorkspace'], { + ns: 'billing', + count: planInfo.teamWorkspace, + })} /> <Item - label={t($ => $['plansCommon.teamMember'], { ns: 'billing', count: planInfo.teamMembers })} + label={t(($) => $['plansCommon.teamMember'], { + ns: 'billing', + count: planInfo.teamMembers, + })} /> <Item - label={t($ => $['plansCommon.buildApps'], { ns: 'billing', count: planInfo.buildApps })} + label={t(($) => $['plansCommon.buildApps'], { ns: 'billing', count: planInfo.buildApps })} /> <Divider bgStyle="gradient" /> <Item - label={t($ => $['plansCommon.documents'], { ns: 'billing', count: planInfo.documents })} - tooltip={t($ => $['plansCommon.documentsTooltip'], { ns: 'billing' }) as string} + label={t(($) => $['plansCommon.documents'], { ns: 'billing', count: planInfo.documents })} + tooltip={t(($) => $['plansCommon.documentsTooltip'], { ns: 'billing' }) as string} /> <Item - label={t($ => $['plansCommon.vectorSpace'], { ns: 'billing', size: planInfo.vectorSpace })} - tooltip={t($ => $['plansCommon.vectorSpaceTooltip'], { ns: 'billing' }) as string} + label={t(($) => $['plansCommon.vectorSpace'], { + ns: 'billing', + size: planInfo.vectorSpace, + })} + tooltip={t(($) => $['plansCommon.vectorSpaceTooltip'], { ns: 'billing' }) as string} /> <Item - label={t($ => $['plansCommon.documentsRequestQuota'], { ns: 'billing', count: planInfo.documentsRequestQuota })} - tooltip={t($ => $['plansCommon.documentsRequestQuotaTooltip'], { ns: 'billing' })} + label={t(($) => $['plansCommon.documentsRequestQuota'], { + ns: 'billing', + count: planInfo.documentsRequestQuota, + })} + tooltip={t(($) => $['plansCommon.documentsRequestQuotaTooltip'], { ns: 'billing' })} /> <Item - label={[t($ => $[`plansCommon.priority.${planInfo.documentProcessingPriority}`], { ns: 'billing' }), t($ => $['plansCommon.documentProcessingPriority'], { ns: 'billing' })].join('')} + label={[ + t(($) => $[`plansCommon.priority.${planInfo.documentProcessingPriority}`], { + ns: 'billing', + }), + t(($) => $['plansCommon.documentProcessingPriority'], { ns: 'billing' }), + ].join('')} /> <Divider bgStyle="gradient" /> <Item label={ planInfo.triggerEvents === NUM_INFINITE - ? t($ => $['plansCommon.triggerEvents.unlimited'], { ns: 'billing' }) + ? t(($) => $['plansCommon.triggerEvents.unlimited'], { ns: 'billing' }) : plan === Plan.sandbox - ? t($ => $['plansCommon.triggerEvents.sandbox'], { ns: 'billing', count: planInfo.triggerEvents }) - : t($ => $['plansCommon.triggerEvents.professional'], { ns: 'billing', count: planInfo.triggerEvents }) + ? t(($) => $['plansCommon.triggerEvents.sandbox'], { + ns: 'billing', + count: planInfo.triggerEvents, + }) + : t(($) => $['plansCommon.triggerEvents.professional'], { + ns: 'billing', + count: planInfo.triggerEvents, + }) } - tooltip={t($ => $['plansCommon.triggerEvents.tooltip'], { ns: 'billing' }) as string} + tooltip={t(($) => $['plansCommon.triggerEvents.tooltip'], { ns: 'billing' }) as string} /> <Item label={ plan === Plan.sandbox - ? t($ => $['plansCommon.startNodes.limited'], { ns: 'billing', count: 2 }) - : t($ => $['plansCommon.startNodes.unlimited'], { ns: 'billing' }) + ? t(($) => $['plansCommon.startNodes.limited'], { ns: 'billing', count: 2 }) + : t(($) => $['plansCommon.startNodes.unlimited'], { ns: 'billing' }) } /> <Item label={ plan === Plan.sandbox - ? t($ => $['plansCommon.workflowExecution.standard'], { ns: 'billing' }) + ? t(($) => $['plansCommon.workflowExecution.standard'], { ns: 'billing' }) : plan === Plan.professional - ? t($ => $['plansCommon.workflowExecution.faster'], { ns: 'billing' }) - : t($ => $['plansCommon.workflowExecution.priority'], { ns: 'billing' }) + ? t(($) => $['plansCommon.workflowExecution.faster'], { ns: 'billing' }) + : t(($) => $['plansCommon.workflowExecution.priority'], { ns: 'billing' }) } - tooltip={t($ => $['plansCommon.workflowExecution.tooltip'], { ns: 'billing' }) as string} + tooltip={t(($) => $['plansCommon.workflowExecution.tooltip'], { ns: 'billing' }) as string} /> <Divider bgStyle="gradient" /> <Item - label={t($ => $['plansCommon.annotatedResponse.title'], { ns: 'billing', count: planInfo.annotatedResponse })} - tooltip={t($ => $['plansCommon.annotatedResponse.tooltip'], { ns: 'billing' }) as string} + label={t(($) => $['plansCommon.annotatedResponse.title'], { + ns: 'billing', + count: planInfo.annotatedResponse, + })} + tooltip={t(($) => $['plansCommon.annotatedResponse.tooltip'], { ns: 'billing' }) as string} /> <Item - label={t($ => $['plansCommon.logsHistory'], { ns: 'billing', days: planInfo.logHistory === NUM_INFINITE ? t($ => $['plansCommon.unlimited'], { ns: 'billing' }) as string : `${planInfo.logHistory} ${t($ => $['plansCommon.days'], { ns: 'billing' })}` })} + label={t(($) => $['plansCommon.logsHistory'], { + ns: 'billing', + days: + planInfo.logHistory === NUM_INFINITE + ? (t(($) => $['plansCommon.unlimited'], { ns: 'billing' }) as string) + : `${planInfo.logHistory} ${t(($) => $['plansCommon.days'], { ns: 'billing' })}`, + })} /> <Item label={ planInfo.apiRateLimit === NUM_INFINITE - ? t($ => $['plansCommon.unlimitedApiRate'], { ns: 'billing' }) - : `${t($ => $['plansCommon.apiRateLimitUnit'], { ns: 'billing', count: planInfo.apiRateLimit })} ${t($ => $['plansCommon.apiRateLimit'], { ns: 'billing' })}/${t($ => $['plansCommon.month'], { ns: 'billing' })}` + ? t(($) => $['plansCommon.unlimitedApiRate'], { ns: 'billing' }) + : `${t(($) => $['plansCommon.apiRateLimitUnit'], { ns: 'billing', count: planInfo.apiRateLimit })} ${t(($) => $['plansCommon.apiRateLimit'], { ns: 'billing' })}/${t(($) => $['plansCommon.month'], { ns: 'billing' })}` + } + tooltip={ + planInfo.apiRateLimit === NUM_INFINITE + ? undefined + : (t(($) => $['plansCommon.apiRateLimitTooltip'], { ns: 'billing' }) as string) } - tooltip={planInfo.apiRateLimit === NUM_INFINITE ? undefined : t($ => $['plansCommon.apiRateLimitTooltip'], { ns: 'billing' }) as string} /> <Divider bgStyle="gradient" /> - <Item - label={t($ => $['plansCommon.modelProviders'], { ns: 'billing' })} - /> + <Item label={t(($) => $['plansCommon.modelProviders'], { ns: 'billing' })} /> </div> ) } diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.tsx index 129ebaf8534e23..1789beca6b5664 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.tsx @@ -6,18 +6,11 @@ type ItemProps = { tooltip?: string } -const Item = ({ - label, - tooltip, -}: ItemProps) => { +const Item = ({ label, tooltip }: ItemProps) => { return ( <div className="flex items-center"> <span className="grow system-sm-regular text-text-secondary">{label}</span> - {tooltip && ( - <Tooltip - content={tooltip} - /> - )} + {tooltip && <Tooltip content={tooltip} />} </div> ) } diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx index be53ef6b1b85cb..4326da4df2e28a 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx @@ -6,11 +6,8 @@ type TooltipProps = { content: string } -const Tooltip = ({ - content, -}: TooltipProps) => { - if (!content) - return null +const Tooltip = ({ content }: TooltipProps) => { + if (!content) return null return ( <Popover> <PopoverTrigger @@ -20,9 +17,15 @@ const Tooltip = ({ aria-label={content} className="group relative z-10 flex size-[18px] items-center justify-center rounded-sm border-0 bg-state-base-hover p-0 transition-[border-radius,background-color] duration-500 ease-in-out hover:rounded-none hover:bg-saas-dify-blue-static" > - <RiInfoI className="size-3.5 text-text-tertiary group-hover:text-text-primary-on-surface" data-testid="tooltip-icon" /> + <RiInfoI + className="size-3.5 text-text-tertiary group-hover:text-text-primary-on-surface" + data-testid="tooltip-icon" + /> </PopoverTrigger> - <PopoverContent placement="top-end" popupClassName="w-[260px] rounded-none border-0 bg-saas-dify-blue-static px-5 py-[18px] system-xs-regular text-text-primary-on-surface shadow-none"> + <PopoverContent + placement="top-end" + popupClassName="w-[260px] rounded-none border-0 bg-saas-dify-blue-static px-5 py-[18px] system-xs-regular text-text-primary-on-surface shadow-none" + > {content} </PopoverContent> </Popover> diff --git a/web/app/components/billing/pricing/plans/index.tsx b/web/app/components/billing/pricing/plans/index.tsx index 7962dfb24155db..10c5fabb9dc221 100644 --- a/web/app/components/billing/pricing/plans/index.tsx +++ b/web/app/components/billing/pricing/plans/index.tsx @@ -16,59 +16,44 @@ type PlansProps = { canPay: boolean } -const Plans = ({ - plan, - currentPlan, - planRange, - canPay, -}: PlansProps) => { +const Plans = ({ plan, currentPlan, planRange, canPay }: PlansProps) => { const currentPlanType: BasicPlan = plan.type === Plan.enterprise ? Plan.team : plan.type return ( <div className="flex w-full justify-center border-t border-divider-accent px-10"> <div className="flex max-w-[1680px] grow border-x border-divider-accent"> - { - currentPlan === 'cloud' && ( - <> - <CloudPlanItem - currentPlan={currentPlanType} - plan={Plan.sandbox} - planRange={planRange} - canPay={canPay} - /> - <Divider type="vertical" className="mx-0 shrink-0 bg-divider-accent" /> - <CloudPlanItem - currentPlan={currentPlanType} - plan={Plan.professional} - planRange={planRange} - canPay={canPay} - /> - <Divider type="vertical" className="mx-0 shrink-0 bg-divider-accent" /> - <CloudPlanItem - currentPlan={currentPlanType} - plan={Plan.team} - planRange={planRange} - canPay={canPay} - /> - </> - ) - } - { - currentPlan === 'self' && ( - <> - <SelfHostedPlanItem - plan={SelfHostedPlan.community} - /> - <Divider type="vertical" className="mx-0 shrink-0 bg-divider-accent" /> - <SelfHostedPlanItem - plan={SelfHostedPlan.premium} - /> - <Divider type="vertical" className="mx-0 shrink-0 bg-divider-accent" /> - <SelfHostedPlanItem - plan={SelfHostedPlan.enterprise} - /> - </> - ) - } + {currentPlan === 'cloud' && ( + <> + <CloudPlanItem + currentPlan={currentPlanType} + plan={Plan.sandbox} + planRange={planRange} + canPay={canPay} + /> + <Divider type="vertical" className="mx-0 shrink-0 bg-divider-accent" /> + <CloudPlanItem + currentPlan={currentPlanType} + plan={Plan.professional} + planRange={planRange} + canPay={canPay} + /> + <Divider type="vertical" className="mx-0 shrink-0 bg-divider-accent" /> + <CloudPlanItem + currentPlan={currentPlanType} + plan={Plan.team} + planRange={planRange} + canPay={canPay} + /> + </> + )} + {currentPlan === 'self' && ( + <> + <SelfHostedPlanItem plan={SelfHostedPlan.community} /> + <Divider type="vertical" className="mx-0 shrink-0 bg-divider-accent" /> + <SelfHostedPlanItem plan={SelfHostedPlan.premium} /> + <Divider type="vertical" className="mx-0 shrink-0 bg-divider-accent" /> + <SelfHostedPlanItem plan={SelfHostedPlan.enterprise} /> + </> + )} </div> </div> ) diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/button.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/button.spec.tsx index 78d5bf898da65c..9658a2633a4a71 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/button.spec.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/button.spec.tsx @@ -18,12 +18,7 @@ beforeEach(() => { describe('SelfHostedPlanButton', () => { it('should invoke handler when clicked', () => { const handleGetPayUrl = vi.fn() - render( - <Button - plan={SelfHostedPlan.community} - handleGetPayUrl={handleGetPayUrl} - />, - ) + render(<Button plan={SelfHostedPlan.community} handleGetPayUrl={handleGetPayUrl} />) fireEvent.click(screen.getByRole('button', { name: 'billing.plans.community.btnText' })) expect(handleGetPayUrl).toHaveBeenCalledTimes(1) @@ -35,13 +30,10 @@ describe('SelfHostedPlanButton', () => { ])('should render premium button label when theme is $label', ({ theme }) => { mockUseTheme.mockReturnValue({ theme } as unknown as ReturnType<typeof useTheme>) - render( - <Button - plan={SelfHostedPlan.premium} - handleGetPayUrl={vi.fn()} - />, - ) + render(<Button plan={SelfHostedPlan.premium} handleGetPayUrl={vi.fn()} />) - expect(screen.getByRole('button', { name: 'billing.plans.premium.btnText' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'billing.plans.premium.btnText' }), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx index 35046b153d05e0..b10cbdeb666daf 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx @@ -39,7 +39,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx index facdbb7e5808ad..2a2fc4c0ba80ea 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx @@ -3,15 +3,21 @@ import { RiArrowRightLine } from '@remixicon/react' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { AwsMarketplaceDark, AwsMarketplaceLight } from '@/app/components/base/icons/src/public/billing' +import { + AwsMarketplaceDark, + AwsMarketplaceLight, +} from '@/app/components/base/icons/src/public/billing' import useTheme from '@/hooks/use-theme' import { Theme } from '@/types/app' import { SelfHostedPlan } from '../../../type' const BUTTON_CLASSNAME = { - [SelfHostedPlan.community]: 'text-text-primary bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover', - [SelfHostedPlan.premium]: 'text-background-default bg-saas-background-inverted hover:bg-saas-background-inverted-hover', - [SelfHostedPlan.enterprise]: 'text-text-primary-on-surface bg-saas-dify-blue-static hover:bg-saas-dify-blue-static-hover', + [SelfHostedPlan.community]: + 'text-text-primary bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover', + [SelfHostedPlan.premium]: + 'text-background-default bg-saas-background-inverted hover:bg-saas-background-inverted-hover', + [SelfHostedPlan.enterprise]: + 'text-text-primary-on-surface bg-saas-dify-blue-static hover:bg-saas-dify-blue-static-hover', } type ButtonProps = { @@ -19,10 +25,7 @@ type ButtonProps = { handleGetPayUrl: () => void } -const Button = ({ - plan, - handleGetPayUrl, -}: ButtonProps) => { +const Button = ({ plan, handleGetPayUrl }: ButtonProps) => { const { t } = useTranslation() const { theme } = useTheme() const i18nPrefix = `plans.${plan}` as const @@ -42,7 +45,7 @@ const Button = ({ onClick={handleGetPayUrl} > <div className="flex grow items-center gap-x-2"> - <span>{t($ => $[`${i18nPrefix}.btnText`], { ns: 'billing' })}</span> + <span>{t(($) => $[`${i18nPrefix}.btnText`], { ns: 'billing' })}</span> {isPremiumPlan && ( <span className="pt-[7px] pb-px"> <AwsMarketplace className="h-6" /> diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx index 29f999b191a792..3338b12e5796d6 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx @@ -45,9 +45,7 @@ type SelfHostedPlanItemProps = { plan: SelfHostedPlan } -const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({ - plan, -}) => { +const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({ plan }) => { const { t } = useTranslation() const i18nPrefix = `plans.${plan}` as const const isFreePlan = plan === SelfHostedPlan.community @@ -58,7 +56,7 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({ const handleGetPayUrl = useCallback(() => { if (!canManageBilling) { - toast.error(t($ => $.buyPermissionDeniedTip, { ns: 'billing' })) + toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' })) return } if (isFreePlan) { @@ -70,8 +68,7 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({ return } - if (isEnterprisePlan) - window.location.href = contactSalesUrl + if (isEnterprisePlan) window.location.href = contactSalesUrl }, [canManageBilling, isFreePlan, isPremiumPlan, isEnterprisePlan, t]) return ( @@ -83,23 +80,26 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({ <div className="flex flex-col gap-y-6 px-1 pt-10"> {STYLE_MAP[plan].icon} <div className="flex min-h-[104px] flex-col gap-y-2"> - <div className="text-[30px] leading-[1.2] font-medium text-text-primary">{t($ => $[`${i18nPrefix}.name`], { ns: 'billing' })}</div> - <div className="line-clamp-2 system-md-regular text-text-secondary">{t($ => $[`${i18nPrefix}.description`], { ns: 'billing' })}</div> + <div className="text-[30px] leading-[1.2] font-medium text-text-primary"> + {t(($) => $[`${i18nPrefix}.name`], { ns: 'billing' })} + </div> + <div className="line-clamp-2 system-md-regular text-text-secondary"> + {t(($) => $[`${i18nPrefix}.description`], { ns: 'billing' })} + </div> </div> </div> {/* Price */} <div className="flex items-end gap-x-2 px-1 pt-4 pb-8"> - <div className="shrink-0 title-4xl-semi-bold text-text-primary">{t($ => $[`${i18nPrefix}.price`], { ns: 'billing' })}</div> + <div className="shrink-0 title-4xl-semi-bold text-text-primary"> + {t(($) => $[`${i18nPrefix}.price`], { ns: 'billing' })} + </div> {!isFreePlan && ( <span className="pb-0.5 system-md-regular text-text-tertiary"> - {t($ => $[`${i18nPrefix}.priceTip`], { ns: 'billing' })} + {t(($) => $[`${i18nPrefix}.priceTip`], { ns: 'billing' })} </span> )} </div> - <Button - plan={plan} - handleGetPayUrl={handleGetPayUrl} - /> + <Button plan={plan} handleGetPayUrl={handleGetPayUrl} /> </div> <List plan={plan} /> {isPremiumPlan && ( @@ -113,7 +113,7 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({ </div> </div> <span className="system-xs-regular text-text-tertiary"> - {t($ => $['plans.premium.comingSoon'], { ns: 'billing' })} + {t(($) => $['plans.premium.comingSoon'], { ns: 'billing' })} </span> </div> )} diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/index.spec.tsx index 3fe55ba82678b2..a66a39a27c57fa 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/index.spec.tsx @@ -5,9 +5,11 @@ import { createReactI18nextMock } from '@/test/i18n-mock' import List from '../index' // Override global i18n mock to support returnObjects: true for feature arrays -vi.mock('react-i18next', () => createReactI18nextMock({ - 'billing.plans.community.features': ['Feature A', 'Feature B'], -})) +vi.mock('react-i18next', () => + createReactI18nextMock({ + 'billing.plans.community.features': ['Feature A', 'Feature B'], + }), +) describe('SelfHostedPlanItem/List', () => { it('should render plan info', () => { diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.tsx index 9b347592c18c48..1c82e452b60fbc 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.tsx @@ -7,29 +7,26 @@ type ListProps = { plan: SelfHostedPlan } -const List = ({ - plan, -}: ListProps) => { +const List = ({ plan }: ListProps) => { const { t } = useTranslation() const i18nPrefix = `plans.${plan}` as const - const features = t($ => $[`${i18nPrefix}.features`], { ns: 'billing', returnObjects: true }) as string[] + const features = t(($) => $[`${i18nPrefix}.features`], { + ns: 'billing', + returnObjects: true, + }) as string[] return ( <div className="flex flex-col gap-y-[10px] p-6"> <div className="system-md-semibold text-text-secondary"> <Trans - i18nKey={$ => $[`${i18nPrefix}.includesTitle`]} + i18nKey={($) => $[`${i18nPrefix}.includesTitle`]} ns="billing" components={{ highlight: <span className="text-text-warning"></span> }} /> </div> - {features.map(feature => ( - <Item - key={`${plan}-${feature}`} - label={feature} - /> - ), - )} + {features.map((feature) => ( + <Item key={`${plan}-${feature}`} label={feature} /> + ))} </div> ) } diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.tsx index d84e946f36be91..b360f0b6754294 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.tsx @@ -5,9 +5,7 @@ type ItemProps = { label: string } -const Item = ({ - label, -}: ItemProps) => { +const Item = ({ label }: ItemProps) => { return ( <div className="flex items-center gap-x-1"> <div className="py-px"> diff --git a/web/app/components/billing/priority-label/__tests__/index.spec.tsx b/web/app/components/billing/priority-label/__tests__/index.spec.tsx index 2b6201fdffa9f9..ef79e2db7a16a0 100644 --- a/web/app/components/billing/priority-label/__tests__/index.spec.tsx +++ b/web/app/components/billing/priority-label/__tests__/index.spec.tsx @@ -100,7 +100,9 @@ describe('PriorityLabel', () => { renderPriorityLabel() expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument() - expect(screen.queryByText('billing.plansCommon.documentProcessingPriority')).not.toBeInTheDocument() + expect( + screen.queryByText('billing.plansCommon.documentProcessingPriority'), + ).not.toBeInTheDocument() }) it('should render a top priority trigger without mounting upgrade tip by default', () => { @@ -109,7 +111,9 @@ describe('PriorityLabel', () => { renderPriorityLabel() expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument() - expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityTip')).not.toBeInTheDocument() + expect( + screen.queryByText('billing.plansCommon.documentProcessingPriorityTip'), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/billing/priority-label/index.tsx b/web/app/components/billing/priority-label/index.tsx index 5532253a3b2733..2d6eb9b81d3223 100644 --- a/web/app/components/billing/priority-label/index.tsx +++ b/web/app/components/billing/priority-label/index.tsx @@ -4,10 +4,7 @@ import { RiAedFill } from '@remixicon/react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useProviderContext } from '@/context/provider-context' -import { - DocumentProcessingPriority, - Plan, -} from '../type' +import { DocumentProcessingPriority, Plan } from '../type' type PriorityLabelProps = { className?: string @@ -18,11 +15,9 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => { const { plan } = useProviderContext() const priority = useMemo(() => { - if (plan.type === Plan.sandbox) - return DocumentProcessingPriority.standard + if (plan.type === Plan.sandbox) return DocumentProcessingPriority.standard - if (plan.type === Plan.professional) - return DocumentProcessingPriority.priority + if (plan.type === Plan.professional) return DocumentProcessingPriority.priority if (plan.type === Plan.team || plan.type === Plan.enterprise) return DocumentProcessingPriority.topPriority @@ -33,34 +28,30 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => { return ( <Tooltip> <TooltipTrigger - render={( + render={ <div className={cn( 'ml-1 inline-flex h-[18px] shrink-0 items-center rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-[5px] system-2xs-medium text-text-accent-secondary', className, )} /> - )} - > - { - (plan.type === Plan.professional || plan.type === Plan.team || plan.type === Plan.enterprise) && ( - <RiAedFill className="mr-0.5 size-3" /> - ) } - <span>{t($ => $[`plansCommon.priority.${priority}`], { ns: 'billing' })}</span> + > + {(plan.type === Plan.professional || + plan.type === Plan.team || + plan.type === Plan.enterprise) && <RiAedFill className="mr-0.5 size-3" />} + <span>{t(($) => $[`plansCommon.priority.${priority}`], { ns: 'billing' })}</span> </TooltipTrigger> <TooltipContent> <div className="mb-1 text-xs font-semibold text-text-primary"> - {t($ => $['plansCommon.documentProcessingPriority'], { ns: 'billing' })} - : - {' '} - {t($ => $[`plansCommon.priority.${priority}`], { ns: 'billing' })} + {t(($) => $['plansCommon.documentProcessingPriority'], { ns: 'billing' })}:{' '} + {t(($) => $[`plansCommon.priority.${priority}`], { ns: 'billing' })} </div> - { - priority !== DocumentProcessingPriority.topPriority && ( - <div className="text-xs text-text-secondary">{t($ => $['plansCommon.documentProcessingPriorityTip'], { ns: 'billing' })}</div> - ) - } + {priority !== DocumentProcessingPriority.topPriority && ( + <div className="text-xs text-text-secondary"> + {t(($) => $['plansCommon.documentProcessingPriorityTip'], { ns: 'billing' })} + </div> + )} </TooltipContent> </Tooltip> ) diff --git a/web/app/components/billing/trigger-events-limit-modal/index.tsx b/web/app/components/billing/trigger-events-limit-modal/index.tsx index 042a3ce04d3d0d..f2ea27b9a61a94 100644 --- a/web/app/components/billing/trigger-events-limit-modal/index.tsx +++ b/web/app/components/billing/trigger-events-limit-modal/index.tsx @@ -29,19 +29,19 @@ export default function TriggerEventsLimitModal({ onClose={onClose} onUpgrade={onUpgrade} Icon={TriggerAll} - title={t($ => $['triggerLimitModal.title'], { ns: 'billing' })} - description={t($ => $['triggerLimitModal.description'], { ns: 'billing' })} - extraInfo={( + title={t(($) => $['triggerLimitModal.title'], { ns: 'billing' })} + description={t(($) => $['triggerLimitModal.description'], { ns: 'billing' })} + extraInfo={ <UsageInfo className="mt-4 w-full rounded-xl bg-components-panel-on-panel-item-bg" Icon={TriggerAll} - name={t($ => $['triggerLimitModal.usageTitle'], { ns: 'billing' })} + name={t(($) => $['triggerLimitModal.usageTitle'], { ns: 'billing' })} usage={usage} total={total} resetInDays={resetInDays} hideIcon /> - )} + } /> ) } diff --git a/web/app/components/billing/type.ts b/web/app/components/billing/type.ts index 168bc53893d833..214ce5605bdf71 100644 --- a/web/app/components/billing/type.ts +++ b/web/app/components/billing/type.ts @@ -37,7 +37,15 @@ export enum SelfHostedPlan { enterprise = 'enterprise', } -export type UsagePlanInfo = Pick<PlanInfo, 'buildApps' | 'teamMembers' | 'annotatedResponse' | 'documentsUploadQuota' | 'apiRateLimit' | 'triggerEvents'> & { vectorSpace: number } +export type UsagePlanInfo = Pick< + PlanInfo, + | 'buildApps' + | 'teamMembers' + | 'annotatedResponse' + | 'documentsUploadQuota' + | 'apiRateLimit' + | 'triggerEvents' +> & { vectorSpace: number } export type UsageResetInfo = { apiRateLimit?: number | null diff --git a/web/app/components/billing/upgrade-btn/index.tsx b/web/app/components/billing/upgrade-btn/index.tsx index eeb1a10fc8648c..c84c2daa14969d 100644 --- a/web/app/components/billing/upgrade-btn/index.tsx +++ b/web/app/components/billing/upgrade-btn/index.tsx @@ -18,7 +18,10 @@ type Props = Readonly<{ isShort?: boolean onClick?: () => void loc?: string - labelKey?: Exclude<I18nKeysWithPrefix<'billing'>, 'plans.community.features' | 'plans.enterprise.features' | 'plans.premium.features'> + labelKey?: Exclude< + I18nKeysWithPrefix<'billing'>, + 'plans.community.features' | 'plans.enterprise.features' | 'plans.premium.features' + > }> type GtagHandler = (command: 'event', action: 'click_upgrade_btn', payload: { loc: string }) => void @@ -36,14 +39,11 @@ const UpgradeBtn: FC<Props> = ({ const { t } = useTranslation() const { setShowPricingModal } = useModalContext() - if (!IS_CLOUD_EDITION) - return null + if (!IS_CLOUD_EDITION) return null const handleClick = () => { - if (_onClick) - _onClick() - else - setShowPricingModal() + if (_onClick) _onClick() + else setShowPricingModal() } const onClick = () => { handleClick() @@ -55,17 +55,16 @@ const UpgradeBtn: FC<Props> = ({ } } - const defaultBadgeLabel = t($ => $[isShort ? 'upgradeBtn.encourageShort' : 'upgradeBtn.encourage'], { ns: 'billing' }) - const label = labelKey ? t($ => $[labelKey], { ns: 'billing' }) : defaultBadgeLabel + const defaultBadgeLabel = t( + ($) => $[isShort ? 'upgradeBtn.encourageShort' : 'upgradeBtn.encourage'], + { ns: 'billing' }, + ) + const label = labelKey ? t(($) => $[labelKey], { ns: 'billing' }) : defaultBadgeLabel if (isPlain) { return ( - <Button - className={className} - style={style} - onClick={onClick} - > - {labelKey ? label : t($ => $['upgradeBtn.plain'], { ns: 'billing' })} + <Button className={className} style={style} onClick={onClick}> + {labelKey ? label : t(($) => $['upgradeBtn.plain'], { ns: 'billing' })} </Button> ) } @@ -78,11 +77,12 @@ const UpgradeBtn: FC<Props> = ({ className={className} style={style} > - <SparklesSoft aria-hidden="true" className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> + <SparklesSoft + aria-hidden="true" + className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" + /> <div className="system-xs-medium"> - <span className="p-1"> - {label} - </span> + <span className="p-1">{label}</span> </div> </PremiumBadgeButton> ) diff --git a/web/app/components/billing/usage-info/__tests__/apps-info.spec.tsx b/web/app/components/billing/usage-info/__tests__/apps-info.spec.tsx index 48aa1324310a87..c5eb3023f534b0 100644 --- a/web/app/components/billing/usage-info/__tests__/apps-info.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/apps-info.spec.tsx @@ -26,7 +26,9 @@ describe('AppsInfo', () => { expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument() expect(screen.getByText('7')).toBeInTheDocument() expect(screen.getByText('15')).toBeInTheDocument() - expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument() + expect( + screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class'), + ).toBeInTheDocument() }) it('renders without className', () => { diff --git a/web/app/components/billing/usage-info/__tests__/index.spec.tsx b/web/app/components/billing/usage-info/__tests__/index.spec.tsx index b44db314fa483b..d5aa64865da0e1 100644 --- a/web/app/components/billing/usage-info/__tests__/index.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/index.spec.tsx @@ -58,64 +58,33 @@ describe('UsageInfo', () => { }) it('displays unlimited text when total is infinite', () => { - render( - <UsageInfo - Icon={TestIcon} - name="Storage" - usage={10} - total={NUM_INFINITE} - unit="GB" - />, - ) + render(<UsageInfo Icon={TestIcon} name="Storage" usage={10} total={NUM_INFINITE} unit="GB" />) expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument() }) it('applies the neutral / warning / error tone as usage crosses thresholds', () => { const { rerender, container } = render( - <UsageInfo - Icon={TestIcon} - name="Storage" - usage={30} - total={100} - />, + <UsageInfo Icon={TestIcon} name="Storage" usage={30} total={100} />, ) - expect(container.querySelector('.bg-components-progress-bar-progress-solid')).toBeInTheDocument() + expect( + container.querySelector('.bg-components-progress-bar-progress-solid'), + ).toBeInTheDocument() - rerender( - <UsageInfo - Icon={TestIcon} - name="Storage" - usage={85} - total={100} - />, - ) + rerender(<UsageInfo Icon={TestIcon} name="Storage" usage={85} total={100} />) - expect(container.querySelector('.bg-components-progress-warning-progress')).toBeInTheDocument() + expect( + container.querySelector('.bg-components-progress-warning-progress'), + ).toBeInTheDocument() - rerender( - <UsageInfo - Icon={TestIcon} - name="Storage" - usage={120} - total={100} - />, - ) + rerender(<UsageInfo Icon={TestIcon} name="Storage" usage={120} total={100} />) expect(container.querySelector('.bg-components-progress-error-progress')).toBeInTheDocument() }) it('does not render the icon when hideIcon is true', () => { - render( - <UsageInfo - Icon={TestIcon} - name="Storage" - usage={5} - total={100} - hideIcon - />, - ) + render(<UsageInfo Icon={TestIcon} name="Storage" usage={5} total={100} hideIcon />) expect(screen.queryByTestId('usage-icon')).not.toBeInTheDocument() }) @@ -194,7 +163,9 @@ describe('UsageInfo', () => { />, ) - const sandboxBarClass = container.querySelector('.bg-progress-bar-indeterminate-stripe')!.className + const sandboxBarClass = container.querySelector( + '.bg-progress-bar-indeterminate-stripe', + )!.className rerender( <UsageInfo @@ -209,7 +180,9 @@ describe('UsageInfo', () => { />, ) - const nonSandboxBarClass = container.querySelector('.bg-progress-bar-indeterminate-stripe')!.className + const nonSandboxBarClass = container.querySelector( + '.bg-progress-bar-indeterminate-stripe', + )!.className expect(sandboxBarClass).not.toBe(nonSandboxBarClass) }) }) diff --git a/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx b/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx index e379ef4a513e2a..6118f3c924ebd0 100644 --- a/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx @@ -10,7 +10,7 @@ const queryPlaceholder = () => let mockPlanType = Plan.sandbox let mockVectorSpaceUsage = 30 let mockVectorSpaceTotal = 5120 -let mockVectorSpaceApiData: { size: number, limit: number } | undefined +let mockVectorSpaceApiData: { size: number; limit: number } | undefined vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ diff --git a/web/app/components/billing/usage-info/apps-info.tsx b/web/app/components/billing/usage-info/apps-info.tsx index 76e661d947bb4a..8aab1a4273dceb 100644 --- a/web/app/components/billing/usage-info/apps-info.tsx +++ b/web/app/components/billing/usage-info/apps-info.tsx @@ -1,8 +1,6 @@ 'use client' import type { FC } from 'react' -import { - RiApps2Line, -} from '@remixicon/react' +import { RiApps2Line } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import { useProviderContext } from '@/context/provider-context' @@ -12,20 +10,15 @@ type Props = Readonly<{ className?: string }> -const AppsInfo: FC<Props> = ({ - className, -}) => { +const AppsInfo: FC<Props> = ({ className }) => { const { t } = useTranslation() const { plan } = useProviderContext() - const { - usage, - total, - } = plan + const { usage, total } = plan return ( <UsageInfo className={className} Icon={RiApps2Line} - name={t($ => $['usagePage.buildApps'], { ns: 'billing' })} + name={t(($) => $['usagePage.buildApps'], { ns: 'billing' })} usage={usage.buildApps} total={total.buildApps} /> diff --git a/web/app/components/billing/usage-info/index.tsx b/web/app/components/billing/usage-info/index.tsx index a792dcfda907eb..5b3860e923d6dd 100644 --- a/web/app/components/billing/usage-info/index.tsx +++ b/web/app/components/billing/usage-info/index.tsx @@ -55,33 +55,28 @@ const UsageInfo: FC<Props> = ({ // this, so we never need a separate tone override. const rawPercent = total > 0 ? (usage / total) * 100 : 0 const effectivePercent = isSandboxFull ? 100 : Math.min(rawPercent, 100) - const tone: MeterTone - = effectivePercent >= 100 - ? 'error' - : effectivePercent >= 80 - ? 'warning' - : 'neutral' + const tone: MeterTone = + effectivePercent >= 100 ? 'error' : effectivePercent >= 80 ? 'warning' : 'neutral' const isUnlimited = total === NUM_INFINITE - let totalDisplay: string | number = isUnlimited ? t($ => $['plansCommon.unlimited'], { ns: 'billing' }) : total - if (!isUnlimited && unit && unitPosition === 'inline') - totalDisplay = `${total}${unit}` + let totalDisplay: string | number = isUnlimited + ? t(($) => $['plansCommon.unlimited'], { ns: 'billing' }) + : total + if (!isUnlimited && unit && unitPosition === 'inline') totalDisplay = `${total}${unit}` const showUnit = !!unit && !isUnlimited && unitPosition === 'suffix' - const resetText = resetHint ?? (typeof resetInDays === 'number' ? t($ => $['usagePage.resetsIn'], { ns: 'billing', count: resetInDays }) : undefined) + const resetText = + resetHint ?? + (typeof resetInDays === 'number' + ? t(($) => $['usagePage.resetsIn'], { ns: 'billing', count: resetInDays }) + : undefined) - const rightInfo: ReactNode = resetText - ? ( - <div className="ml-auto flex-1 text-right system-xs-regular text-text-tertiary"> - {resetText} - </div> - ) - : showUnit - ? ( - <div className="ml-auto system-xs-medium text-text-tertiary"> - {unit} - </div> - ) - : null + const rightInfo: ReactNode = resetText ? ( + <div className="ml-auto flex-1 text-right system-xs-regular text-text-tertiary"> + {resetText} + </div> + ) : showUnit ? ( + <div className="ml-auto system-xs-medium text-text-tertiary">{unit}</div> + ) : null const usageDisplay: ReactNode = (() => { if (storageMode) { @@ -91,9 +86,7 @@ const UsageInfo: FC<Props> = ({ <span>{storageThreshold}</span> <span className="system-md-regular text-text-quaternary">/</span> <span> - {storageThreshold} - {' '} - {unit} + {storageThreshold} {unit} </span> </div> ) @@ -101,11 +94,7 @@ const UsageInfo: FC<Props> = ({ if (isBelowThreshold) { return ( <div className="flex items-center gap-1"> - <span> - < - {' '} - {storageThreshold} - </span> + <span>< {storageThreshold}</span> {!isSandboxPlan && ( <> <span className="system-md-regular text-text-quaternary">/</span> @@ -134,37 +123,30 @@ const UsageInfo: FC<Props> = ({ ) })() - const bar: ReactNode = isBelowThreshold - ? ( - // Decorative "< N MB" placeholder — not a meter, not a progressbar. - <div - aria-hidden="true" - className="overflow-hidden rounded-md bg-components-progress-bar-bg" - > - <div - className={cn( - 'h-1 rounded-md bg-progress-bar-indeterminate-stripe', - isSandboxPlan ? 'w-full' : 'w-7.5', - )} - /> - </div> - ) - : ( - <Meter value={effectivePercent} max={100} aria-label={name}> - <MeterTrack> - <MeterIndicator tone={tone} /> - </MeterTrack> - </Meter> - ) + const bar: ReactNode = isBelowThreshold ? ( + // Decorative "< N MB" placeholder — not a meter, not a progressbar. + <div aria-hidden="true" className="overflow-hidden rounded-md bg-components-progress-bar-bg"> + <div + className={cn( + 'h-1 rounded-md bg-progress-bar-indeterminate-stripe', + isSandboxPlan ? 'w-full' : 'w-7.5', + )} + /> + </div> + ) : ( + <Meter value={effectivePercent} max={100} aria-label={name}> + <MeterTrack> + <MeterIndicator tone={tone} /> + </MeterTrack> + </Meter> + ) const wrapWithStorageTooltip = (children: ReactNode) => { if (storageMode && storageTooltip) { return ( <Tooltip> <TooltipTrigger render={<div className="cursor-default">{children}</div>} /> - <TooltipContent className="w-50 max-w-50"> - {storageTooltip} - </TooltipContent> + <TooltipContent className="w-50 max-w-50">{storageTooltip}</TooltipContent> </Tooltip> ) } @@ -173,9 +155,7 @@ const UsageInfo: FC<Props> = ({ return ( <div className={cn('flex flex-col gap-2 rounded-xl bg-components-panel-bg p-4', className)}> - {!hideIcon && Icon && ( - <Icon className="size-4 text-text-tertiary" /> - )} + {!hideIcon && Icon && <Icon className="size-4 text-text-tertiary" />} <div className="flex items-center gap-1"> <div className="system-xs-medium text-text-tertiary">{name}</div> {tooltip && ( diff --git a/web/app/components/billing/usage-info/vector-space-info.tsx b/web/app/components/billing/usage-info/vector-space-info.tsx index 7e6d8232599276..b52fa9d3b22090 100644 --- a/web/app/components/billing/usage-info/vector-space-info.tsx +++ b/web/app/components/billing/usage-info/vector-space-info.tsx @@ -1,9 +1,7 @@ 'use client' import type { FC } from 'react' import type { BasicPlan } from '../type' -import { - RiHardDrive3Line, -} from '@remixicon/react' +import { RiHardDrive3Line } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import { useProviderContext } from '@/context/provider-context' @@ -19,9 +17,7 @@ type Props = Readonly<{ // Storage threshold in MB - usage below this shows as "< 50 MB" const STORAGE_THRESHOLD_MB = getPlanVectorSpaceLimitMB(Plan.sandbox) -const VectorSpaceInfo: FC<Props> = ({ - className, -}) => { +const VectorSpaceInfo: FC<Props> = ({ className }) => { const { t } = useTranslation() const { plan } = useProviderContext() const { data: vectorSpace } = useCurrentPlanVectorSpace() @@ -38,11 +34,7 @@ const VectorSpaceInfo: FC<Props> = ({ }, } : plan - const { - type, - usage, - total, - } = displayPlan + const { type, usage, total } = displayPlan // Determine total based on plan type (in MB), derived from ALL_PLANS config const getTotalInMB = () => { @@ -58,15 +50,15 @@ const VectorSpaceInfo: FC<Props> = ({ <UsageInfo className={className} Icon={RiHardDrive3Line} - name={t($ => $['usagePage.vectorSpace'], { ns: 'billing' })} - tooltip={t($ => $['usagePage.vectorSpaceTooltip'], { ns: 'billing' }) as string} + name={t(($) => $['usagePage.vectorSpace'], { ns: 'billing' })} + tooltip={t(($) => $['usagePage.vectorSpaceTooltip'], { ns: 'billing' }) as string} usage={usage.vectorSpace} total={totalInMB} unit="MB" unitPosition="inline" storageMode storageThreshold={STORAGE_THRESHOLD_MB} - storageTooltip={t($ => $['usagePage.storageThresholdTooltip'], { ns: 'billing' }) as string} + storageTooltip={t(($) => $['usagePage.storageThresholdTooltip'], { ns: 'billing' }) as string} isSandboxPlan={isSandbox} /> ) diff --git a/web/app/components/billing/utils/__tests__/index.spec.ts b/web/app/components/billing/utils/__tests__/index.spec.ts index 84818d31751392..1a338f66e9a137 100644 --- a/web/app/components/billing/utils/__tests__/index.spec.ts +++ b/web/app/components/billing/utils/__tests__/index.spec.ts @@ -50,7 +50,9 @@ describe('billing utils', () => { // parseCurrentPlan tests describe('parseCurrentPlan', () => { - const createMockPlanData = (overrides: Partial<CurrentPlanInfoBackend> = {}): CurrentPlanInfoBackend => ({ + const createMockPlanData = ( + overrides: Partial<CurrentPlanInfoBackend> = {}, + ): CurrentPlanInfoBackend => ({ billing: { enabled: true, subscription: { diff --git a/web/app/components/billing/utils/index.ts b/web/app/components/billing/utils/index.ts index c83c0a6c52f639..ceb275c71a777a 100644 --- a/web/app/components/billing/utils/index.ts +++ b/web/app/components/billing/utils/index.ts @@ -8,8 +8,7 @@ import { ALL_PLANS, NUM_INFINITE } from '@/app/components/billing/config' */ export const parseVectorSpaceToMB = (vectorSpace: string): number => { const match = /^(\d+)(MB|GB)$/i.exec(vectorSpace) - if (!match) - return 0 + if (!match) return 0 const value = Number.parseInt(match[1]!, 10) const unit = match[2]!.toUpperCase() @@ -22,35 +21,29 @@ export const parseVectorSpaceToMB = (vectorSpace: string): number => { */ export const getPlanVectorSpaceLimitMB = (planType: BasicPlan): number => { const planInfo = ALL_PLANS[planType] - if (!planInfo) - return 0 + if (!planInfo) return 0 return parseVectorSpaceToMB(planInfo.vectorSpace) } const parseLimit = (limit: number) => { - if (limit === 0) - return NUM_INFINITE + if (limit === 0) return NUM_INFINITE return limit } const parseRateLimit = (limit: number) => { - if (limit === 0 || limit === -1) - return NUM_INFINITE + if (limit === 0 || limit === -1) return NUM_INFINITE return limit } const normalizeResetDate = (resetDate?: number | null) => { - if (typeof resetDate !== 'number' || resetDate <= 0) - return null + if (typeof resetDate !== 'number' || resetDate <= 0) return null - if (resetDate >= 1e12) - return dayjs(resetDate) + if (resetDate >= 1e12) return dayjs(resetDate) - if (resetDate >= 1e9) - return dayjs(resetDate * 1000) + if (resetDate >= 1e9) return dayjs(resetDate * 1000) const digits = resetDate.toString() if (digits.length === 8) { @@ -66,12 +59,10 @@ const normalizeResetDate = (resetDate?: number | null) => { const getResetInDaysFromDate = (resetDate?: number | null) => { const resetDay = normalizeResetDate(resetDate) - if (!resetDay) - return null + if (!resetDay) return null const diff = resetDay.startOf('day').diff(dayjs().startOf('day'), 'day') - if (Number.isNaN(diff) || diff < 0) - return null + if (Number.isNaN(diff) || diff < 0) return null return diff } @@ -86,8 +77,7 @@ export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => { } const getQuotaUsage = (quota?: BillingQuota) => quota?.usage ?? 0 const getQuotaResetInDays = (quota?: BillingQuota) => { - if (!quota) - return null + if (!quota) return null return getResetInDaysFromDate(quota.reset_date) } @@ -108,7 +98,10 @@ export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => { teamMembers: parseLimit(data.members.limit), annotatedResponse: parseLimit(data.annotation_quota_limit.limit), documentsUploadQuota: parseLimit(data.documents_upload_quota.limit), - apiRateLimit: resolveRateLimit(data.api_rate_limit?.limit, planPreset?.apiRateLimit ?? NUM_INFINITE), + apiRateLimit: resolveRateLimit( + data.api_rate_limit?.limit, + planPreset?.apiRateLimit ?? NUM_INFINITE, + ), triggerEvents: resolveRateLimit(data.trigger_event?.limit, planPreset?.triggerEvents), }, reset: { diff --git a/web/app/components/billing/vector-space-full/__tests__/index.spec.tsx b/web/app/components/billing/vector-space-full/__tests__/index.spec.tsx index 42054df649ec42..4ccb87d431b9af 100644 --- a/web/app/components/billing/vector-space-full/__tests__/index.spec.tsx +++ b/web/app/components/billing/vector-space-full/__tests__/index.spec.tsx @@ -18,7 +18,11 @@ vi.mock('@/context/provider-context', () => { }) vi.mock('../../upgrade-btn', () => ({ - default: () => <button data-testid="vector-upgrade-btn" type="button">Upgrade</button>, + default: () => ( + <button data-testid="vector-upgrade-btn" type="button"> + Upgrade + </button> + ), })) vi.mock('@/service/use-billing', () => ({ @@ -31,10 +35,8 @@ vi.mock('@/service/use-billing', () => ({ vi.mock('../../utils', () => ({ getPlanVectorSpaceLimitMB: (planType: string) => { // Return 5 for sandbox (threshold) and 100 for team - if (planType === 'sandbox') - return 5 - if (planType === 'team') - return 100 + if (planType === 'sandbox') return 5 + if (planType === 'team') return 100 return 0 }, })) diff --git a/web/app/components/billing/vector-space-full/index.tsx b/web/app/components/billing/vector-space-full/index.tsx index 68e944383cab84..282eba3ad043f2 100644 --- a/web/app/components/billing/vector-space-full/index.tsx +++ b/web/app/components/billing/vector-space-full/index.tsx @@ -12,12 +12,16 @@ const VectorSpaceFull: FC = () => { const { t } = useTranslation() return ( - <GridMask wrapperClassName="border border-gray-200 rounded-xl" canvasClassName="rounded-xl" gradientClassName="rounded-xl"> + <GridMask + wrapperClassName="border border-gray-200 rounded-xl" + canvasClassName="rounded-xl" + gradientClassName="rounded-xl" + > <div className="px-6 py-5"> <div className="flex items-center justify-between"> <div className={cn(s.textGradient, 'text-base leading-[24px] font-semibold')}> - <div>{t($ => $['vectorSpace.fullTip'], { ns: 'billing' })}</div> - <div>{t($ => $['vectorSpace.fullSolution'], { ns: 'billing' })}</div> + <div>{t(($) => $['vectorSpace.fullTip'], { ns: 'billing' })}</div> + <div>{t(($) => $['vectorSpace.fullSolution'], { ns: 'billing' })}</div> </div> <UpgradeBtn loc="knowledge-add-file" /> </div> diff --git a/web/app/components/billing/vector-space-full/style.module.css b/web/app/components/billing/vector-space-full/style.module.css index 15bedd84ca48ed..3df9ef6c2dad82 100644 --- a/web/app/components/billing/vector-space-full/style.module.css +++ b/web/app/components/billing/vector-space-full/style.module.css @@ -1,5 +1,5 @@ .textGradient { - background: linear-gradient(92deg, #2250F2 -29.55%, #0EBCF3 75.22%); + background: linear-gradient(92deg, #2250f2 -29.55%, #0ebcf3 75.22%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; diff --git a/web/app/components/custom/custom-page/__tests__/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx index 1ac45c685fff4b..bcd54ed2180fb9 100644 --- a/web/app/components/custom/custom-page/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -7,10 +7,7 @@ import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' -import { - initialLangGeniusVersionInfo, - initialWorkspaceInfo, -} from '@/context/app-context-defaults' +import { initialLangGeniusVersionInfo, initialWorkspaceInfo } from '@/context/app-context-defaults' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import CustomPage from '../index' @@ -23,14 +20,15 @@ vi.mock('@/config', async (importOriginal) => { } }) -const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { - systemFeatures: { - branding: { - enabled: true, - workspace_logo: 'https://example.com/workspace-logo.png', +const render = (ui: ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { + branding: { + enabled: true, + workspace_logo: 'https://example.com/workspace-logo.png', + }, }, - }, -}) + }) const { mockToast } = vi.hoisted(() => { const mockToast = Object.assign(vi.fn(), { @@ -130,10 +128,12 @@ describe('CustomPage', () => { it('should show the upgrade banner and open pricing modal for sandbox billing', async () => { const user = userEvent.setup() - mockUseProviderContext.mockReturnValue(createProviderContext({ - enableBilling: true, - planType: Plan.sandbox, - })) + mockUseProviderContext.mockReturnValue( + createProviderContext({ + enableBilling: true, + planType: Plan.sandbox, + }), + ) render(<CustomPage />) @@ -146,10 +146,12 @@ describe('CustomPage', () => { }) it('should show the contact link for professional workspaces', () => { - mockUseProviderContext.mockReturnValue(createProviderContext({ - enableBilling: true, - planType: Plan.professional, - })) + mockUseProviderContext.mockReturnValue( + createProviderContext({ + enableBilling: true, + planType: Plan.professional, + }), + ) render(<CustomPage />) @@ -161,10 +163,12 @@ describe('CustomPage', () => { }) it('should show the contact link for team workspaces', () => { - mockUseProviderContext.mockReturnValue(createProviderContext({ - enableBilling: true, - planType: Plan.team, - })) + mockUseProviderContext.mockReturnValue( + createProviderContext({ + enableBilling: true, + planType: Plan.team, + }), + ) render(<CustomPage />) @@ -173,10 +177,12 @@ describe('CustomPage', () => { }) it('should hide both billing sections when billing is disabled', () => { - mockUseProviderContext.mockReturnValue(createProviderContext({ - enableBilling: false, - planType: Plan.sandbox, - })) + mockUseProviderContext.mockReturnValue( + createProviderContext({ + enableBilling: false, + planType: Plan.sandbox, + }), + ) render(<CustomPage />) diff --git a/web/app/components/custom/custom-page/index.tsx b/web/app/components/custom/custom-page/index.tsx index 51b0b2e9dcd1b1..5e420ec46c00c1 100644 --- a/web/app/components/custom/custom-page/index.tsx +++ b/web/app/components/custom/custom-page/index.tsx @@ -18,24 +18,35 @@ const CustomPage = () => { {showBillingTip && ( <div className="mb-1 flex justify-between rounded-xl bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-4 pl-6 shadow-lg backdrop-blur-xs"> <div className="space-y-1 text-text-primary-on-surface"> - <div className="title-xl-semi-bold">{t($ => $['upgradeTip.title'], { ns: 'custom' })}</div> - <div className="system-sm-regular">{t($ => $['upgradeTip.des'], { ns: 'custom' })}</div> + <div className="title-xl-semi-bold"> + {t(($) => $['upgradeTip.title'], { ns: 'custom' })} + </div> + <div className="system-sm-regular"> + {t(($) => $['upgradeTip.des'], { ns: 'custom' })} + </div> </div> <button type="button" className="flex h-10 w-[120px] cursor-pointer items-center justify-center rounded-3xl border-none bg-white p-0 system-md-semibold text-text-accent shadow-xs hover:opacity-95" onClick={() => setShowPricingModal()} > - {t($ => $['upgradeBtn.encourageShort'], { ns: 'billing' })} + {t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })} </button> </div> )} <CustomWebAppBrand /> {showContact && ( <div className="absolute bottom-0 h-[50px] text-xs leading-[50px] text-text-quaternary"> - {t($ => $['customize.prefix'], { ns: 'custom' })} - <a className="text-text-accent" href={contactSalesUrl} target="_blank" rel="noopener noreferrer">{t($ => $['customize.contactUs'], { ns: 'custom' })}</a> - {t($ => $['customize.suffix'], { ns: 'custom' })} + {t(($) => $['customize.prefix'], { ns: 'custom' })} + <a + className="text-text-accent" + href={contactSalesUrl} + target="_blank" + rel="noopener noreferrer" + > + {t(($) => $['customize.contactUs'], { ns: 'custom' })} + </a> + {t(($) => $['customize.suffix'], { ns: 'custom' })} </div> )} </div> diff --git a/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx b/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx index 4c2574c10bdae7..78bc189b9af608 100644 --- a/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx @@ -9,7 +9,9 @@ vi.mock('../hooks/use-web-app-brand', () => ({ const mockUseWebAppBrand = vi.mocked(useWebAppBrand) -const createHookState = (overrides: Partial<ReturnType<typeof useWebAppBrand>> = {}): ReturnType<typeof useWebAppBrand> => ({ +const createHookState = ( + overrides: Partial<ReturnType<typeof useWebAppBrand>> = {}, +): ReturnType<typeof useWebAppBrand> => ({ fileId: '', imgKey: 100, uploadProgress: 0, diff --git a/web/app/components/custom/custom-web-app-brand/components/__tests__/chat-preview-card.spec.tsx b/web/app/components/custom/custom-web-app-brand/components/__tests__/chat-preview-card.spec.tsx index 6605e408317a99..dbee069d5b7865 100644 --- a/web/app/components/custom/custom-web-app-brand/components/__tests__/chat-preview-card.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/components/__tests__/chat-preview-card.spec.tsx @@ -4,12 +4,7 @@ import ChatPreviewCard from '../chat-preview-card' describe('ChatPreviewCard', () => { it('should render the chat preview with the powered-by footer', () => { - render( - <ChatPreviewCard - imgKey={8} - webappLogo="https://example.com/custom-logo.png" - />, - ) + render(<ChatPreviewCard imgKey={8} webappLogo="https://example.com/custom-logo.png" />) expect(screen.getByText('Chatflow App')).toBeInTheDocument() expect(screen.getByText('Hello! How can I assist you today?')).toBeInTheDocument() diff --git a/web/app/components/custom/custom-web-app-brand/components/__tests__/powered-by-brand.spec.tsx b/web/app/components/custom/custom-web-app-brand/components/__tests__/powered-by-brand.spec.tsx index bca1a6e5b3fa7a..e95ddc28fea757 100644 --- a/web/app/components/custom/custom-web-app-brand/components/__tests__/powered-by-brand.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/components/__tests__/powered-by-brand.spec.tsx @@ -13,18 +13,19 @@ describe('PoweredByBrand', () => { ) expect(screen.getByText('POWERED BY')).toBeInTheDocument() - expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png') + expect(screen.getByAltText('logo')).toHaveAttribute( + 'src', + 'https://example.com/workspace-logo.png', + ) }) it('should fall back to the custom web app logo when workspace branding is unavailable', () => { - render( - <PoweredByBrand - imgKey={42} - webappLogo="https://example.com/custom-logo.png" - />, - ) + render(<PoweredByBrand imgKey={42} webappLogo="https://example.com/custom-logo.png" />) - expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/custom-logo.png?hash=42') + expect(screen.getByAltText('logo')).toHaveAttribute( + 'src', + 'https://example.com/custom-logo.png?hash=42', + ) }) it('should fall back to the Dify logo when no custom branding exists', () => { diff --git a/web/app/components/custom/custom-web-app-brand/components/__tests__/workflow-preview-card.spec.tsx b/web/app/components/custom/custom-web-app-brand/components/__tests__/workflow-preview-card.spec.tsx index d563c4f40bbdeb..be50af424f05cd 100644 --- a/web/app/components/custom/custom-web-app-brand/components/__tests__/workflow-preview-card.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/components/__tests__/workflow-preview-card.spec.tsx @@ -5,17 +5,17 @@ import WorkflowPreviewCard from '../workflow-preview-card' describe('WorkflowPreviewCard', () => { it('should render the workflow preview with execute action and branding footer', () => { render( - <WorkflowPreviewCard - imgKey={9} - workspaceLogo="https://example.com/workspace-logo.png" - />, + <WorkflowPreviewCard imgKey={9} workspaceLogo="https://example.com/workspace-logo.png" />, ) expect(screen.getByText('Workflow App')).toBeInTheDocument() expect(screen.getByText('RUN ONCE')).toBeInTheDocument() expect(screen.getByText('RUN BATCH')).toBeInTheDocument() expect(screen.getByRole('button', { name: /Execute/i })).toBeDisabled() - expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png') + expect(screen.getByAltText('logo')).toHaveAttribute( + 'src', + 'https://example.com/workspace-logo.png', + ) }) it('should hide workflow branding footer when brand removal is enabled', () => { diff --git a/web/app/components/custom/custom-web-app-brand/components/chat-preview-card.tsx b/web/app/components/custom/custom-web-app-brand/components/chat-preview-card.tsx index 06be55580d8dd5..eef450b39a80a0 100644 --- a/web/app/components/custom/custom-web-app-brand/components/chat-preview-card.tsx +++ b/web/app/components/custom/custom-web-app-brand/components/chat-preview-card.tsx @@ -19,7 +19,12 @@ const ChatPreviewCard = ({ <div className="flex h-[320px] grow basis-1/2 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn"> <div className="flex h-full w-[232px] shrink-0 flex-col p-1 pr-0"> <div className="flex items-center gap-3 p-3 pr-2"> - <div className={cn('inline-flex size-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-blue-light-solid')}> + <div + className={cn( + 'inline-flex size-8 items-center justify-center rounded-lg border border-divider-regular', + 'bg-components-icon-bg-blue-light-solid', + )} + > <span className="i-custom-vender-solid-communication-bubble-text-mod size-4 text-components-avatar-shape-fill-stop-100" /> </div> <div className="grow system-md-semibold text-text-secondary">Chatflow App</div> @@ -63,12 +68,16 @@ const ChatPreviewCard = ({ <div className="flex w-[138px] grow flex-col justify-between p-2 pr-0"> <div className="flex grow flex-col justify-between rounded-l-2xl border-[0.5px] border-r-0 border-components-panel-border-subtle bg-chatbot-bg pt-16 pb-4 pl-[22px]"> <div className="w-[720px] rounded-2xl border border-divider-subtle bg-chat-bubble-bg px-4 py-3"> - <div className="mb-1 body-md-regular text-text-primary">Hello! How can I assist you today?</div> + <div className="mb-1 body-md-regular text-text-primary"> + Hello! How can I assist you today? + </div> <Button size="small"> <div className="h-2 w-[144px] rounded-xs bg-text-quaternary opacity-20"></div> </Button> </div> - <div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 body-lg-regular text-text-placeholder shadow-md backdrop-blur-xs">Talk to Dify</div> + <div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 body-lg-regular text-text-placeholder shadow-md backdrop-blur-xs"> + Talk to Dify + </div> </div> </div> </div> diff --git a/web/app/components/custom/custom-web-app-brand/components/powered-by-brand.tsx b/web/app/components/custom/custom-web-app-brand/components/powered-by-brand.tsx index 51c0db53d75d2e..541383f939a412 100644 --- a/web/app/components/custom/custom-web-app-brand/components/powered-by-brand.tsx +++ b/web/app/components/custom/custom-web-app-brand/components/powered-by-brand.tsx @@ -13,17 +13,18 @@ const PoweredByBrand = ({ webappLogo, imgKey, }: PoweredByBrandProps) => { - if (webappBrandRemoved) - return null + if (webappBrandRemoved) return null const previewLogo = workspaceLogo || (webappLogo ? `${webappLogo}?hash=${imgKey}` : '') return ( <> <div className="system-2xs-medium-uppercase text-text-tertiary">POWERED BY</div> - {previewLogo - ? <img src={previewLogo} alt="logo" className="block h-5 w-auto" /> - : <DifyLogo size="small" />} + {previewLogo ? ( + <img src={previewLogo} alt="logo" className="block h-5 w-auto" /> + ) : ( + <DifyLogo size="small" /> + )} </> ) } diff --git a/web/app/components/custom/custom-web-app-brand/components/workflow-preview-card.tsx b/web/app/components/custom/custom-web-app-brand/components/workflow-preview-card.tsx index 0aa97e9e724095..ca390b592e5aac 100644 --- a/web/app/components/custom/custom-web-app-brand/components/workflow-preview-card.tsx +++ b/web/app/components/custom/custom-web-app-brand/components/workflow-preview-card.tsx @@ -19,7 +19,12 @@ const WorkflowPreviewCard = ({ <div className="flex h-[320px] grow basis-1/2 flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn"> <div className="w-full border-b-[0.5px] border-divider-subtle p-4 pb-0"> <div className="mb-2 flex items-center gap-3"> - <div className={cn('inline-flex size-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-indigo-solid')}> + <div + className={cn( + 'inline-flex size-8 items-center justify-center rounded-lg border border-divider-regular', + 'bg-components-icon-bg-indigo-solid', + )} + > <span className="i-ri-exchange-2-fill size-4 text-components-avatar-shape-fill-stop-100" /> </div> <div className="grow system-md-semibold text-text-secondary">Workflow App</div> @@ -28,8 +33,12 @@ const WorkflowPreviewCard = ({ </div> </div> <div className="flex items-center gap-4"> - <div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active system-md-semibold-uppercase text-text-primary">RUN ONCE</div> - <div className="flex h-10 grow items-center border-b-2 border-transparent system-md-semibold-uppercase text-text-tertiary">RUN BATCH</div> + <div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active system-md-semibold-uppercase text-text-primary"> + RUN ONCE + </div> + <div className="flex h-10 grow items-center border-b-2 border-transparent system-md-semibold-uppercase text-text-tertiary"> + RUN BATCH + </div> </div> </div> <div className="grow bg-components-panel-bg"> diff --git a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx index 66f673eb3f4213..f31bcb0004a7c1 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx @@ -9,10 +9,7 @@ import { renderHookWithSystemFeatures } from '@/__tests__/utils/mock-system-feat import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' import { defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' -import { - initialLangGeniusVersionInfo, - initialWorkspaceInfo, -} from '@/context/app-context-defaults' +import { initialLangGeniusVersionInfo, initialWorkspaceInfo } from '@/context/app-context-defaults' import { useProviderContext } from '@/context/provider-context' import { updateCurrentWorkspace } from '@/service/common' import useWebAppBrand from '../use-web-app-brand' @@ -94,7 +91,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { })) }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -136,9 +134,12 @@ const createProviderContext = ({ }) } -const createAppContextValue = (overrides: Partial<AppContextStateMockState> = {}): AppContextStateMockState => { +const createAppContextValue = ( + overrides: Partial<AppContextStateMockState> = {}, +): AppContextStateMockState => { const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides - const workspaceOverrides: Partial<AppContextStateMockState['currentWorkspace']> = currentWorkspaceOverride ?? {} + const workspaceOverrides: Partial<AppContextStateMockState['currentWorkspace']> = + currentWorkspaceOverride ?? {} const currentWorkspace = { ...initialWorkspaceInfo, ...workspaceOverrides, @@ -178,7 +179,9 @@ describe('useWebAppBrand', () => { setAppContextValue(createAppContextValue()) currentBrandingOverrides = {} - mockUpdateCurrentWorkspace.mockResolvedValue(appContextValue.currentWorkspace as ICurrentWorkspace) + mockUpdateCurrentWorkspace.mockResolvedValue( + appContextValue.currentWorkspace as ICurrentWorkspace, + ) mockUseAppContext.mockImplementation(() => appContextValue) mockUseProviderContext.mockReturnValue(createProviderContext()) mockGetImageUploadErrorMessage.mockReturnValue('upload error') @@ -197,10 +200,12 @@ describe('useWebAppBrand', () => { }) it('should disable uploads when customization management permission is missing', () => { - setAppContextValue(createAppContextValue({ - workspacePermissionKeys: [], - isCurrentWorkspaceManager: true, - })) + setAppContextValue( + createAppContextValue({ + workspacePermissionKeys: [], + isCurrentWorkspaceManager: true, + }), + ) const { result } = renderHook(() => useWebAppBrand()) @@ -209,10 +214,12 @@ describe('useWebAppBrand', () => { }) it('should allow uploads for non-manager users with customization management permission', () => { - setAppContextValue(createAppContextValue({ - workspacePermissionKeys: ['customization.manage'], - isCurrentWorkspaceManager: false, - })) + setAppContextValue( + createAppContextValue({ + workspacePermissionKeys: ['customization.manage'], + isCurrentWorkspaceManager: false, + }), + ) const { result } = renderHook(() => useWebAppBrand()) @@ -221,19 +228,23 @@ describe('useWebAppBrand', () => { }) it('should disable uploads in sandbox workspaces and when branding is removed', () => { - mockUseProviderContext.mockReturnValue(createProviderContext({ - enableBilling: true, - planType: Plan.sandbox, - })) - setAppContextValue(createAppContextValue({ - currentWorkspace: { - ...initialWorkspaceInfo, - custom_config: { - replace_webapp_logo: 'https://example.com/replace.png', - remove_webapp_brand: true, + mockUseProviderContext.mockReturnValue( + createProviderContext({ + enableBilling: true, + planType: Plan.sandbox, + }), + ) + setAppContextValue( + createAppContextValue({ + currentWorkspace: { + ...initialWorkspaceInfo, + custom_config: { + replace_webapp_logo: 'https://example.com/replace.png', + remove_webapp_brand: true, + }, }, - }, - })) + }), + ) const { result } = renderHook(() => useWebAppBrand()) @@ -359,9 +370,11 @@ describe('useWebAppBrand', () => { it('should persist the selected logo and reset transient state on apply', async () => { const mutateCurrentWorkspace = vi.fn() - setAppContextValue(createAppContextValue({ - mutateCurrentWorkspace, - })) + setAppContextValue( + createAppContextValue({ + mutateCurrentWorkspace, + }), + ) mockImageUpload.mockImplementation(({ onSuccessCallback }) => { onSuccessCallback({ id: 'new-logo' }) }) @@ -396,9 +409,11 @@ describe('useWebAppBrand', () => { it('should restore the default branding configuration', async () => { const mutateCurrentWorkspace = vi.fn() - setAppContextValue(createAppContextValue({ - mutateCurrentWorkspace, - })) + setAppContextValue( + createAppContextValue({ + mutateCurrentWorkspace, + }), + ) const { result } = renderHook(() => useWebAppBrand()) @@ -418,9 +433,11 @@ describe('useWebAppBrand', () => { it('should persist brand removal changes', async () => { const mutateCurrentWorkspace = vi.fn() - setAppContextValue(createAppContextValue({ - mutateCurrentWorkspace, - })) + setAppContextValue( + createAppContextValue({ + mutateCurrentWorkspace, + }), + ) const { result } = renderHook(() => useWebAppBrand()) diff --git a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts index 63cc5127eb2e29..1379f0227223eb 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts +++ b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts @@ -32,7 +32,9 @@ const useWebAppBrand = () => { const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand const canManageCustomBrand = hasPermission(workspacePermissionKeys, 'customization.manage') const uploadDisabled = isSandbox || webappBrandRemoved || !canManageCustomBrand - const workspaceLogo = systemFeatures.branding.enabled ? systemFeatures.branding.workspace_logo : '' + const workspaceLogo = systemFeatures.branding.enabled + ? systemFeatures.branding.workspace_logo + : '' const persistWorkspaceBrand = async (body: Record<string, unknown>) => { await updateCurrentWorkspace({ url: CUSTOM_CONFIG_URL, @@ -42,25 +44,32 @@ const useWebAppBrand = () => { } const handleChange = (e: ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0] - if (!file) - return + if (!file) return if (file.size > MAX_LOGO_FILE_SIZE) { - toast.error(t($ => $['imageUploader.uploadFromComputerLimit'], { ns: 'common', size: 5 })) + toast.error(t(($) => $['imageUploader.uploadFromComputerLimit'], { ns: 'common', size: 5 })) return } - imageUpload({ - file, - onProgressCallback: setUploadProgress, - onSuccessCallback: (res) => { - setUploadProgress(100) - setFileId(res.id) - }, - onErrorCallback: (error) => { - const errorMessage = getImageUploadErrorMessage(error, t($ => $['imageUploader.uploadFromComputerUploadError'], { ns: 'common' }), t) - toast.error(errorMessage) - setUploadProgress(-1) + imageUpload( + { + file, + onProgressCallback: setUploadProgress, + onSuccessCallback: (res) => { + setUploadProgress(100) + setFileId(res.id) + }, + onErrorCallback: (error) => { + const errorMessage = getImageUploadErrorMessage( + error, + t(($) => $['imageUploader.uploadFromComputerUploadError'], { ns: 'common' }), + t, + ) + toast.error(errorMessage) + setUploadProgress(-1) + }, }, - }, false, WEB_APP_LOGO_UPLOAD_URL) + false, + WEB_APP_LOGO_UPLOAD_URL, + ) } const handleApply = async () => { await persistWorkspaceBrand({ diff --git a/web/app/components/custom/custom-web-app-brand/index.tsx b/web/app/components/custom/custom-web-app-brand/index.tsx index 1cf36869ee1168..df723f7ae930d7 100644 --- a/web/app/components/custom/custom-web-app-brand/index.tsx +++ b/web/app/components/custom/custom-web-app-brand/index.tsx @@ -32,7 +32,7 @@ const CustomWebAppBrand = () => { return ( <div className="py-4"> <div className="mb-2 flex items-center justify-between rounded-xl bg-background-section-burn p-4 system-md-medium text-text-primary"> - {t($ => $['webapp.removeBrand'], { ns: 'custom' })} + {t(($) => $['webapp.removeBrand'], { ns: 'custom' })} <Switch size="lg" checked={webappBrandRemoved ?? false} @@ -40,86 +40,88 @@ const CustomWebAppBrand = () => { onCheckedChange={handleSwitch} /> </div> - <div className={cn('flex h-14 items-center justify-between rounded-xl bg-background-section-burn px-4', webappBrandRemoved && 'opacity-30')}> + <div + className={cn( + 'flex h-14 items-center justify-between rounded-xl bg-background-section-burn px-4', + webappBrandRemoved && 'opacity-30', + )} + > <div> - <div className="system-md-medium text-text-primary">{t($ => $['webapp.changeLogo'], { ns: 'custom' })}</div> - <div className="system-xs-regular text-text-tertiary">{t($ => $['webapp.changeLogoTip'], { ns: 'custom' })}</div> + <div className="system-md-medium text-text-primary"> + {t(($) => $['webapp.changeLogo'], { ns: 'custom' })} + </div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['webapp.changeLogoTip'], { ns: 'custom' })} + </div> </div> <div className="flex items-center"> - {(!uploadDisabled && webappLogo && !webappBrandRemoved) && ( + {!uploadDisabled && webappLogo && !webappBrandRemoved && ( <> <Button variant="ghost" disabled={uploadDisabled || (!webappLogo && !webappBrandRemoved)} onClick={handleRestore} > - {t($ => $.restore, { ns: 'custom' })} + {t(($) => $.restore, { ns: 'custom' })} </Button> <div className="mx-2 h-5 w-px bg-divider-regular"></div> </> )} - { - !uploading && ( - <Button - className="relative mr-2" + {!uploading && ( + <Button className="relative mr-2" disabled={uploadDisabled}> + <span className="mr-1 i-ri-image-add-line size-4" /> + {webappLogo || fileId + ? t(($) => $.change, { ns: 'custom' }) + : t(($) => $.upload, { ns: 'custom' })} + <input + className={cn( + 'absolute inset-0 block w-full text-[0] opacity-0', + uploadDisabled ? 'cursor-not-allowed' : 'cursor-pointer', + )} + onClick={(e) => ((e.target as HTMLInputElement).value = '')} + type="file" + accept={ALLOW_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',')} + onChange={handleChange} disabled={uploadDisabled} + /> + </Button> + )} + {uploading && ( + <Button className="relative mr-2" disabled={true}> + <span className="mr-1 i-ri-loader-2-line size-4 animate-spin" /> + {t(($) => $.uploading, { ns: 'custom' })} + </Button> + )} + {fileId && ( + <> + <Button + className="mr-2" + onClick={handleCancel} + disabled={webappBrandRemoved || !canManageCustomBrand} > - <span className="mr-1 i-ri-image-add-line size-4" /> - { - (webappLogo || fileId) - ? t($ => $.change, { ns: 'custom' }) - : t($ => $.upload, { ns: 'custom' }) - } - <input - className={cn('absolute inset-0 block w-full text-[0] opacity-0', uploadDisabled ? 'cursor-not-allowed' : 'cursor-pointer')} - onClick={e => (e.target as HTMLInputElement).value = ''} - type="file" - accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')} - onChange={handleChange} - disabled={uploadDisabled} - /> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - ) - } - { - uploading && ( <Button - className="relative mr-2" - disabled={true} + variant="primary" + className="mr-2" + onClick={handleApply} + disabled={webappBrandRemoved || !canManageCustomBrand} > - <span className="mr-1 i-ri-loader-2-line size-4 animate-spin" /> - {t($ => $.uploading, { ns: 'custom' })} + {t(($) => $.apply, { ns: 'custom' })} </Button> - ) - } - { - fileId && ( - <> - <Button - className="mr-2" - onClick={handleCancel} - disabled={webappBrandRemoved || !canManageCustomBrand} - > - {t($ => $['operation.cancel'], { ns: 'common' })} - </Button> - <Button - variant="primary" - className="mr-2" - onClick={handleApply} - disabled={webappBrandRemoved || !canManageCustomBrand} - > - {t($ => $.apply, { ns: 'custom' })} - </Button> - </> - ) - } + </> + )} </div> </div> {uploadProgress === -1 && ( - <div className="mt-2 text-xs text-[#D92D20]">{t($ => $.uploadedFail, { ns: 'custom' })}</div> + <div className="mt-2 text-xs text-[#D92D20]"> + {t(($) => $.uploadedFail, { ns: 'custom' })} + </div> )} <div className="mt-5 mb-2 flex items-center gap-2"> - <div className="shrink-0 system-xs-medium-uppercase text-text-tertiary">{t($ => $['overview.appInfo.preview'], { ns: 'appOverview' })}</div> + <div className="shrink-0 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['overview.appInfo.preview'], { ns: 'appOverview' })} + </div> <Divider bgStyle="gradient" className="grow" /> </div> <div className="relative mb-2 flex items-center gap-3"> diff --git a/web/app/components/custom/style.module.css b/web/app/components/custom/style.module.css index 0a839f6387c5dc..51a143720e97de 100644 --- a/web/app/components/custom/style.module.css +++ b/web/app/components/custom/style.module.css @@ -1,5 +1,5 @@ .textGradient { - background: linear-gradient(92deg, #2250F2 -29.55%, #0EBCF3 75.22%); + background: linear-gradient(92deg, #2250f2 -29.55%, #0ebcf3 75.22%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; diff --git a/web/app/components/datasets/__tests__/chunk.spec.tsx b/web/app/components/datasets/__tests__/chunk.spec.tsx index eea972cb178967..63c407d9958462 100644 --- a/web/app/components/datasets/__tests__/chunk.spec.tsx +++ b/web/app/components/datasets/__tests__/chunk.spec.tsx @@ -72,7 +72,7 @@ describe('ChunkLabel', () => { it('should render with special characters in label', () => { render(<ChunkLabel label="Chunk <#1> & 'test'" characterCount={10} />) - expect(screen.getByText('Chunk <#1> & \'test\'')).toBeInTheDocument() + expect(screen.getByText("Chunk <#1> & 'test'")).toBeInTheDocument() }) }) }) @@ -119,7 +119,9 @@ describe('ChunkContainer', () => { describe('Structure', () => { it('should have space-y-2 on the outer container', () => { const { container } = render( - <ChunkContainer label="Chunk" characterCount={10}>Content</ChunkContainer>, + <ChunkContainer label="Chunk" characterCount={10}> + Content + </ChunkContainer>, ) expect(container.firstElementChild).toHaveClass('space-y-2') @@ -139,9 +141,7 @@ describe('ChunkContainer', () => { describe('Edge Cases', () => { it('should render without children', () => { - const { container } = render( - <ChunkContainer label="Empty" characterCount={0} />, - ) + const { container } = render(<ChunkContainer label="Empty" characterCount={0} />) expect(container.firstElementChild).toBeInTheDocument() expect(screen.getByText('Empty')).toBeInTheDocument() @@ -189,7 +189,9 @@ describe('QAPreview', () => { const qa = createQA() render(<QAPreview qa={qa} />) - expect(screen.getByText('Dify is an open-source LLM app development platform.')).toBeInTheDocument() + expect( + screen.getByText('Dify is an open-source LLM app development platform.'), + ).toBeInTheDocument() }) it('should render Q and A labels', () => { diff --git a/web/app/components/datasets/__tests__/mock-dataset-access.ts b/web/app/components/datasets/__tests__/mock-dataset-access.ts index caf65ec35bb9c9..dd5208b54394fa 100644 --- a/web/app/components/datasets/__tests__/mock-dataset-access.ts +++ b/web/app/components/datasets/__tests__/mock-dataset-access.ts @@ -22,15 +22,15 @@ type DatasetAccessMockOptions = { isRbacEnabled?: boolean } -type DatasetAccessAtomKind - = | 'userProfile' - | 'userProfileId' - | 'currentWorkspaceId' - | 'isCurrentWorkspaceOwner' - | 'workspacePermissionKeys' - | 'currentWorkspaceLoading' - | 'workspacePermissionKeysLoading' - | 'datasetRbacEnabled' +type DatasetAccessAtomKind = + | 'userProfile' + | 'userProfileId' + | 'currentWorkspaceId' + | 'isCurrentWorkspaceOwner' + | 'workspacePermissionKeys' + | 'currentWorkspaceLoading' + | 'workspacePermissionKeysLoading' + | 'datasetRbacEnabled' type DatasetAccessMockAtom = { [DATASET_ACCESS_ATOM_KIND]: DatasetAccessAtomKind @@ -52,9 +52,7 @@ const defaultUserProfile = { let datasetAccessMockRegistry: DatasetAccessMockRegistry | undefined -const createMockAtom = ( - kind: DatasetAccessAtomKind, -): DatasetAccessMockAtom => ({ +const createMockAtom = (kind: DatasetAccessAtomKind): DatasetAccessMockAtom => ({ [DATASET_ACCESS_ATOM_KIND]: kind, }) @@ -67,7 +65,8 @@ const getUserProfile = (state: DatasetAccessMockState) => ({ ...state.userProfile, }) -const getWorkspacePermissionKeys = (state: DatasetAccessMockState) => state.workspacePermissionKeys ?? [] +const getWorkspacePermissionKeys = (state: DatasetAccessMockState) => + state.workspacePermissionKeys ?? [] export const createDatasetAccessAtomMock = async <TModule extends object>( importOriginal: <T>() => Promise<T>, @@ -93,9 +92,7 @@ export const createDatasetAccessAtomMock = async <TModule extends object>( } } -export const createDatasetAccessJotaiMock = async ( - importOriginal: <T>() => Promise<T>, -) => { +export const createDatasetAccessJotaiMock = async (importOriginal: <T>() => Promise<T>) => { const actual = await importOriginal<typeof import('jotai')>() return { @@ -104,19 +101,16 @@ export const createDatasetAccessJotaiMock = async ( if (!isDatasetAccessMockAtom(atom)) return actual.useAtomValue(atom as Parameters<typeof actual.useAtomValue>[0]) - if (!datasetAccessMockRegistry) - throw new Error('Dataset access atom mock is not initialized') + if (!datasetAccessMockRegistry) throw new Error('Dataset access atom mock is not initialized') const state = datasetAccessMockRegistry.getState() const options = datasetAccessMockRegistry.getOptions() const userProfile = getUserProfile(state) const workspacePermissionKeys = getWorkspacePermissionKeys(state) - if (atom[DATASET_ACCESS_ATOM_KIND] === 'userProfile') - return userProfile + if (atom[DATASET_ACCESS_ATOM_KIND] === 'userProfile') return userProfile - if (atom[DATASET_ACCESS_ATOM_KIND] === 'userProfileId') - return userProfile.id + if (atom[DATASET_ACCESS_ATOM_KIND] === 'userProfileId') return userProfile.id if (atom[DATASET_ACCESS_ATOM_KIND] === 'currentWorkspaceId') return state.currentWorkspace?.id ?? 'workspace-1' diff --git a/web/app/components/datasets/__tests__/no-linked-apps-panel.spec.tsx b/web/app/components/datasets/__tests__/no-linked-apps-panel.spec.tsx index 02af6ad86afcc9..9f79e393111692 100644 --- a/web/app/components/datasets/__tests__/no-linked-apps-panel.spec.tsx +++ b/web/app/components/datasets/__tests__/no-linked-apps-panel.spec.tsx @@ -31,7 +31,10 @@ describe('NoLinkedAppsPanel', () => { it('should render link with correct href', () => { render(<NoLinkedAppsPanel />) const link = screen.getByRole('link') - expect(link).toHaveAttribute('href', 'https://docs.example.com/use-dify/knowledge/integrate-knowledge-within-application') + expect(link).toHaveAttribute( + 'href', + 'https://docs.example.com/use-dify/knowledge/integrate-knowledge-within-application', + ) }) it('should render link with target="_blank"', () => { @@ -47,6 +50,8 @@ describe('NoLinkedAppsPanel', () => { }) it('should be wrapped with React.memo', () => { - expect((NoLinkedAppsPanel as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + expect((NoLinkedAppsPanel as unknown as { $$typeof: symbol }).$$typeof).toBe( + Symbol.for('react.memo'), + ) }) }) diff --git a/web/app/components/datasets/access-config/__tests__/index.spec.tsx b/web/app/components/datasets/access-config/__tests__/index.spec.tsx index 9621a55ca7971f..580718e0c6d3c0 100644 --- a/web/app/components/datasets/access-config/__tests__/index.spec.tsx +++ b/web/app/components/datasets/access-config/__tests__/index.spec.tsx @@ -20,7 +20,7 @@ const mockDatasetUserAccessSettings = vi.hoisted(() => ({ })) const mockDatasetDetail = vi.hoisted(() => ({ - dataset: undefined as { maintainer?: string | null, permission_keys?: string[] } | undefined, + dataset: undefined as { maintainer?: string | null; permission_keys?: string[] } | undefined, })) const mockAppContextState = vi.hoisted(() => ({ @@ -30,11 +30,12 @@ const mockAppContextState = vi.hoisted(() => ({ let mockIsRbacEnabled = true -const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, { - systemFeatures: { - rbac_enabled: mockIsRbacEnabled, - }, -}) +const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => + renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, + }) const mockAccessRulesEditor = vi.hoisted(() => ({ props: null as AccessRulesEditorProps | null, @@ -70,58 +71,88 @@ vi.mock('@/service/access-control/use-dataset-access-config', () => ({ })) vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: vi.fn((selector: (state: { dataset?: { maintainer?: string | null, permission_keys?: string[] } }) => unknown) => { - return selector({ dataset: mockDatasetDetail.dataset }) - }), + useDatasetDetailContextWithSelector: vi.fn( + ( + selector: (state: { + dataset?: { maintainer?: string | null; permission_keys?: string[] } + }) => unknown, + ) => { + return selector({ dataset: mockDatasetDetail.dataset }) + }, + ), })) vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/app/components/access-rules-editor', () => ({ default: (props: AccessRulesEditorProps) => { mockAccessRulesEditor.props = props - return ( - <div data-testid="access-rules-editor" /> - ) + return <div data-testid="access-rules-editor" /> }, })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) @@ -149,7 +180,9 @@ describe('DatasetAccessConfigPage', () => { it('should render access config title and pass dataset rules to the editor', () => { render(<DatasetAccessConfigPage datasetId="dataset-1" />) - expect(screen.getByRole('heading', { name: 'common.settings.resourceAccess' })).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'common.settings.resourceAccess' }), + ).toBeInTheDocument() expect(screen.getByText('permission.accessRule.datasetDescription')).toBeInTheDocument() expect(screen.getByTestId('access-rules-editor')).toBeInTheDocument() expect(mockAccessRulesEditor.props?.className).toBe('w-full max-w-200') @@ -206,8 +239,16 @@ describe('DatasetAccessConfigPage', () => { render(<DatasetAccessConfigPage datasetId="dataset-1" />) expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument() - expect(vi.mocked(useDatasetAccessRules)).toHaveBeenCalledWith('dataset-1', expect.any(String), { enabled: false }) - expect(vi.mocked(useDatasetUserAccessSettings)).toHaveBeenCalledWith('dataset-1', expect.any(String), { enabled: false }) + expect(vi.mocked(useDatasetAccessRules)).toHaveBeenCalledWith( + 'dataset-1', + expect.any(String), + { enabled: false }, + ) + expect(vi.mocked(useDatasetUserAccessSettings)).toHaveBeenCalledWith( + 'dataset-1', + expect.any(String), + { enabled: false }, + ) }) it('should disable access config queries and hide the editor when RBAC is disabled', () => { @@ -216,41 +257,61 @@ describe('DatasetAccessConfigPage', () => { render(<DatasetAccessConfigPage datasetId="dataset-1" />) expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument() - expect(vi.mocked(useDatasetAccessRules)).toHaveBeenCalledWith('dataset-1', expect.any(String), { enabled: false }) - expect(vi.mocked(useDatasetUserAccessSettings)).toHaveBeenCalledWith('dataset-1', expect.any(String), { enabled: false }) + expect(vi.mocked(useDatasetAccessRules)).toHaveBeenCalledWith( + 'dataset-1', + expect.any(String), + { enabled: false }, + ) + expect(vi.mocked(useDatasetUserAccessSettings)).toHaveBeenCalledWith( + 'dataset-1', + expect.any(String), + { enabled: false }, + ) }) it('should wire open scope and user policy updates', () => { render(<DatasetAccessConfigPage datasetId="dataset-1" />) mockAccessRulesEditor.props?.onOpenScopeChange?.('all') - expect(mockMutations.updateOpenScope).toHaveBeenCalledWith('all', expect.objectContaining({ - onError: expect.any(Function), - })) + expect(mockMutations.updateOpenScope).toHaveBeenCalledWith( + 'all', + expect.objectContaining({ + onError: expect.any(Function), + }), + ) mockAccessRulesEditor.props?.onUserAccessPoliciesChange?.('account-1', ['policy-1']) - expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ - accountId: 'account-1', - accessPolicyIds: ['policy-1'], - }, expect.objectContaining({ - onSettled: expect.any(Function), - })) + expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith( + { + accountId: 'account-1', + accessPolicyIds: ['policy-1'], + }, + expect.objectContaining({ + onSettled: expect.any(Function), + }), + ) mockAccessRulesEditor.props?.onAddAccessSubject?.('account-2', ['default']) - expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ - accountId: 'account-2', - accessPolicyIds: ['default'], - }, expect.objectContaining({ - onSettled: expect.any(Function), - })) + expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith( + { + accountId: 'account-2', + accessPolicyIds: ['default'], + }, + expect.objectContaining({ + onSettled: expect.any(Function), + }), + ) mockAccessRulesEditor.props?.onRemoveAccessPolicyMemberBinding?.('account-3', 'policy-3') - expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({ - accessPolicyId: 'policy-3', - accountIds: ['account-3'], - }, expect.objectContaining({ - onSettled: expect.any(Function), - })) + expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith( + { + accessPolicyId: 'policy-3', + accountIds: ['account-3'], + }, + expect.objectContaining({ + onSettled: expect.any(Function), + }), + ) }) }) }) diff --git a/web/app/components/datasets/access-config/index.tsx b/web/app/components/datasets/access-config/index.tsx index 13890f891503f5..03244f2b486a11 100644 --- a/web/app/components/datasets/access-config/index.tsx +++ b/web/app/components/datasets/access-config/index.tsx @@ -30,7 +30,7 @@ const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) => const { t } = useTranslation() const locale = useLocale() const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale]) - const dataset = useDatasetDetailContextWithSelector(state => state.dataset) + const dataset = useDatasetDetailContextWithSelector((state) => state.dataset) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = useAtomValue(datasetRbacEnabledAtom) @@ -40,11 +40,15 @@ const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) => workspacePermissionKeys, isRbacEnabled, }).canAccessConfig - const { data: datasetAccessRulesResponse, isLoading: isLoadingDatasetAccessRules } = useDatasetAccessRules(datasetId, language, { enabled: canAccessConfig }) - const { data: datasetUserAccessSettingsResponse, isLoading: isLoadingDatasetUserAccessSettings } = useDatasetUserAccessSettings(datasetId, language, { enabled: canAccessConfig }) - const { mutate: updateDatasetOpenScope, isPending: isUpdatingDatasetOpenScope } = useUpdateDatasetOpenScope(datasetId) + const { data: datasetAccessRulesResponse, isLoading: isLoadingDatasetAccessRules } = + useDatasetAccessRules(datasetId, language, { enabled: canAccessConfig }) + const { data: datasetUserAccessSettingsResponse, isLoading: isLoadingDatasetUserAccessSettings } = + useDatasetUserAccessSettings(datasetId, language, { enabled: canAccessConfig }) + const { mutate: updateDatasetOpenScope, isPending: isUpdatingDatasetOpenScope } = + useUpdateDatasetOpenScope(datasetId) const { mutate: updateDatasetUserAccessSettings } = useUpdateDatasetUserAccessSettings(datasetId) - const { mutate: removeDatasetAccessPolicyMemberBindings } = useRemoveDatasetAccessPolicyMemberBindings(datasetId) + const { mutate: removeDatasetAccessPolicyMemberBindings } = + useRemoveDatasetAccessPolicyMemberBindings(datasetId) const [optimisticOpenScope, setOptimisticOpenScope] = useState<ResourceOpenScope | null>(null) const [updatingAccountId, setUpdatingAccountId] = useState<string | null>(null) @@ -52,43 +56,47 @@ const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) => const datasetUserAccessSettings = datasetUserAccessSettingsResponse?.data || [] const openScope = optimisticOpenScope || datasetUserAccessSettingsResponse?.scope - const handleOpenScopeChange = useCallback((nextOpenScope: ResourceOpenScope) => { - if (!canAccessConfig) - return - if (nextOpenScope === openScope) - return + const handleOpenScopeChange = useCallback( + (nextOpenScope: ResourceOpenScope) => { + if (!canAccessConfig) return + if (nextOpenScope === openScope) return - const previousOptimisticOpenScope = optimisticOpenScope - setOptimisticOpenScope(nextOpenScope) - updateDatasetOpenScope(nextOpenScope, { - onError: () => setOptimisticOpenScope(previousOptimisticOpenScope), - }) - }, [canAccessConfig, openScope, optimisticOpenScope, updateDatasetOpenScope]) + const previousOptimisticOpenScope = optimisticOpenScope + setOptimisticOpenScope(nextOpenScope) + updateDatasetOpenScope(nextOpenScope, { + onError: () => setOptimisticOpenScope(previousOptimisticOpenScope), + }) + }, + [canAccessConfig, openScope, optimisticOpenScope, updateDatasetOpenScope], + ) - const handleUserAccessPoliciesChange = useCallback((accountId: string, accessPolicyIds: string[]) => { - if (!canAccessConfig) - return + const handleUserAccessPoliciesChange = useCallback( + (accountId: string, accessPolicyIds: string[]) => { + if (!canAccessConfig) return - setUpdatingAccountId(accountId) - updateDatasetUserAccessSettings( - { accountId, accessPolicyIds }, - { onSettled: () => setUpdatingAccountId(null) }, - ) - }, [canAccessConfig, updateDatasetUserAccessSettings]) + setUpdatingAccountId(accountId) + updateDatasetUserAccessSettings( + { accountId, accessPolicyIds }, + { onSettled: () => setUpdatingAccountId(null) }, + ) + }, + [canAccessConfig, updateDatasetUserAccessSettings], + ) - const handleRemoveAccessPolicyMemberBinding = useCallback((accountId: string, accessPolicyId: string) => { - if (!canAccessConfig) - return + const handleRemoveAccessPolicyMemberBinding = useCallback( + (accountId: string, accessPolicyId: string) => { + if (!canAccessConfig) return - setUpdatingAccountId(accountId) - removeDatasetAccessPolicyMemberBindings( - { accessPolicyId, accountIds: [accountId] }, - { onSettled: () => setUpdatingAccountId(null) }, - ) - }, [canAccessConfig, removeDatasetAccessPolicyMemberBindings]) + setUpdatingAccountId(accountId) + removeDatasetAccessPolicyMemberBindings( + { accessPolicyId, accountIds: [accountId] }, + { onSettled: () => setUpdatingAccountId(null) }, + ) + }, + [canAccessConfig, removeDatasetAccessPolicyMemberBindings], + ) - if (!canAccessConfig) - return <Loading type="app" /> + if (!canAccessConfig) return <Loading type="app" /> return ( <ScrollArea @@ -96,9 +104,11 @@ const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) => slotClassNames={{ viewport: 'overscroll-contain' }} > <header className="flex min-h-15.5 flex-col justify-center px-6 py-3"> - <h1 className="system-sm-semibold text-text-primary">{t($ => $['settings.resourceAccess'], { ns: 'common' })}</h1> + <h1 className="system-sm-semibold text-text-primary"> + {t(($) => $['settings.resourceAccess'], { ns: 'common' })} + </h1> <p className="mt-0.5 system-xs-regular text-text-tertiary"> - {t($ => $['accessRule.datasetDescription'], { ns: 'permission' })} + {t(($) => $['accessRule.datasetDescription'], { ns: 'permission' })} </p> </header> <main className="w-full px-6 pt-8 pb-10"> diff --git a/web/app/components/datasets/chunk.tsx b/web/app/components/datasets/chunk.tsx index 947f2859c4ebf2..dbf2cc880152d4 100644 --- a/web/app/components/datasets/chunk.tsx +++ b/web/app/components/datasets/chunk.tsx @@ -13,15 +13,9 @@ export const ChunkLabel: FC<ChunkLabelProps> = (props) => { <div className="flex items-center text-xs font-medium text-text-tertiary"> <SelectionMod className="size-[10px]" /> <p className="ml-0.5 flex gap-2"> - <span> - {label} - </span> - <span> - · - </span> - <span> - {`${characterCount} characters`} - </span> + <span>{label}</span> + <span>·</span> + <span>{`${characterCount} characters`}</span> </p> </div> ) @@ -34,9 +28,7 @@ export const ChunkContainer: FC<ChunkContainerProps> = (props) => { return ( <div className="space-y-2"> <ChunkLabel label={label} characterCount={characterCount} /> - <div className="body-md-regular text-text-secondary"> - {children} - </div> + <div className="body-md-regular text-text-secondary">{children}</div> </div> ) } @@ -50,11 +42,15 @@ export const QAPreview: FC<QAPreviewProps> = (props) => { return ( <div className="flex flex-col gap-y-2"> <div className="flex gap-x-1"> - <label className="shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary">Q</label> + <label className="shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary"> + Q + </label> <p className="body-md-regular text-text-secondary">{qa.question}</p> </div> <div className="flex gap-x-1"> - <label className="shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary">A</label> + <label className="shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary"> + A + </label> <p className="body-md-regular text-text-secondary">{qa.answer}</p> </div> </div> diff --git a/web/app/components/datasets/common/__tests__/check-rerank-model.spec.ts b/web/app/components/datasets/common/__tests__/check-rerank-model.spec.ts index ce56a92827867f..44bc5e0cf49281 100644 --- a/web/app/components/datasets/common/__tests__/check-rerank-model.spec.ts +++ b/web/app/components/datasets/common/__tests__/check-rerank-model.spec.ts @@ -1,7 +1,14 @@ -import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + Model, + ModelItem, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { RetrievalConfig } from '@/types/app' import { describe, expect, it } from 'vitest' -import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + ConfigurationMethodEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { RerankingModeEnum } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import { isReRankModelSelected } from '../check-rerank-model' @@ -35,20 +42,14 @@ const createRerankModelList = (): Model[] => [ provider: 'openai', icon_small: { en_US: '', zh_Hans: '' }, label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - models: [ - createModelItem('gpt-4-turbo'), - createModelItem('gpt-3.5-turbo'), - ], + models: [createModelItem('gpt-4-turbo'), createModelItem('gpt-3.5-turbo')], status: ModelStatusEnum.active, }, { provider: 'cohere', icon_small: { en_US: '', zh_Hans: '' }, label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, - models: [ - createModelItem('rerank-english-v2.0'), - createModelItem('rerank-multilingual-v2.0'), - ], + models: [createModelItem('rerank-english-v2.0'), createModelItem('rerank-multilingual-v2.0')], status: ModelStatusEnum.active, }, ] diff --git a/web/app/components/datasets/common/check-rerank-model.ts b/web/app/components/datasets/common/check-rerank-model.ts index 003c81a49afa2f..19655d6aa505cd 100644 --- a/web/app/components/datasets/common/check-rerank-model.ts +++ b/web/app/components/datasets/common/check-rerank-model.ts @@ -1,6 +1,4 @@ -import type { - Model, -} from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { RetrievalConfig } from '@/types/app' import { RerankingModeEnum } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' @@ -16,27 +14,32 @@ export const isReRankModelSelected = ({ }) => { const rerankModelSelected = (() => { if (retrievalConfig.reranking_model?.reranking_model_name) { - const provider = rerankModelList.find(({ provider }) => provider === retrievalConfig.reranking_model?.reranking_provider_name) + const provider = rerankModelList.find( + ({ provider }) => provider === retrievalConfig.reranking_model?.reranking_provider_name, + ) - return provider?.models.find(({ model }) => model === retrievalConfig.reranking_model?.reranking_model_name) + return provider?.models.find( + ({ model }) => model === retrievalConfig.reranking_model?.reranking_model_name, + ) } return false })() if ( - indexMethod === 'high_quality' - && ([RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.fullText].includes(retrievalConfig.search_method)) - && retrievalConfig.reranking_enable - && !rerankModelSelected + indexMethod === 'high_quality' && + [RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.fullText].includes(retrievalConfig.search_method) && + retrievalConfig.reranking_enable && + !rerankModelSelected ) { return false } if ( - indexMethod === 'high_quality' - && (retrievalConfig.search_method === RETRIEVE_METHOD.hybrid && retrievalConfig.reranking_mode !== RerankingModeEnum.WeightedScore) - && !rerankModelSelected + indexMethod === 'high_quality' && + retrievalConfig.search_method === RETRIEVE_METHOD.hybrid && + retrievalConfig.reranking_mode !== RerankingModeEnum.WeightedScore && + !rerankModelSelected ) { return false } diff --git a/web/app/components/datasets/common/chunking-mode-label.tsx b/web/app/components/datasets/common/chunking-mode-label.tsx index 817abaade6f0ce..4150447499076b 100644 --- a/web/app/components/datasets/common/chunking-mode-label.tsx +++ b/web/app/components/datasets/common/chunking-mode-label.tsx @@ -10,10 +10,7 @@ type Props = Readonly<{ isQAMode: boolean }> -const ChunkingModeLabel: FC<Props> = ({ - isGeneralMode, - isQAMode, -}) => { +const ChunkingModeLabel: FC<Props> = ({ isGeneralMode, isQAMode }) => { const { t } = useTranslation() const TypeIcon = isGeneralMode ? GeneralChunk : ParentChildChunk const generalSuffix = isQAMode ? ' · QA' : '' @@ -22,7 +19,11 @@ const ChunkingModeLabel: FC<Props> = ({ <Badge> <div className="flex h-full items-center space-x-0.5 text-text-tertiary"> <TypeIcon className="size-3" /> - <span className="system-2xs-medium-uppercase">{isGeneralMode ? `${t($ => $['chunkingMode.general'], { ns: 'dataset' })}${generalSuffix}` : t($ => $['chunkingMode.parentChild'], { ns: 'dataset' })}</span> + <span className="system-2xs-medium-uppercase"> + {isGeneralMode + ? `${t(($) => $['chunkingMode.general'], { ns: 'dataset' })}${generalSuffix}` + : t(($) => $['chunkingMode.parentChild'], { ns: 'dataset' })} + </span> </div> </Badge> ) diff --git a/web/app/components/datasets/common/credential-icon.tsx b/web/app/components/datasets/common/credential-icon.tsx index f993eca5853d92..6d6161e7aeaaaf 100644 --- a/web/app/components/datasets/common/credential-icon.tsx +++ b/web/app/components/datasets/common/credential-icon.tsx @@ -24,7 +24,10 @@ export const CredentialIcon: React.FC<CredentialIconProps> = ({ }) => { const [showAvatar, setShowAvatar] = useState(!!avatarUrl && avatarUrl !== 'default') const firstLetter = useMemo(() => name.charAt(0).toUpperCase(), [name]) - const bgColor = useMemo(() => ICON_BG_COLORS[firstLetter.charCodeAt(0) % ICON_BG_COLORS.length], [firstLetter]) + const bgColor = useMemo( + () => ICON_BG_COLORS[firstLetter.charCodeAt(0) % ICON_BG_COLORS.length], + [firstLetter], + ) const onImgLoadError = useCallback(() => { setShowAvatar(false) diff --git a/web/app/components/datasets/common/document-file-icon.tsx b/web/app/components/datasets/common/document-file-icon.tsx index edf79c6c114dea..75aaeed76e50ad 100644 --- a/web/app/components/datasets/common/document-file-icon.tsx +++ b/web/app/components/datasets/common/document-file-icon.tsx @@ -26,15 +26,14 @@ type Props = Readonly<{ className?: string }> -const DocumentFileIcon: FC<Props> = ({ - extension, - name, - size = 'md', - className, -}) => { +const DocumentFileIcon: FC<Props> = ({ extension, name, size = 'md', className }) => { const localExtension = extension?.toLowerCase() || name?.split('.')?.pop()?.toLowerCase() return ( - <FileTypeIcon type={extendToFileTypeMap[localExtension!] || FileAppearanceTypeEnum.document} size={size} className={className} /> + <FileTypeIcon + type={extendToFileTypeMap[localExtension!] || FileAppearanceTypeEnum.document} + size={size} + className={className} + /> ) } export default React.memo(DocumentFileIcon) diff --git a/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx index fa21f71c9d1d73..28017d606e5c86 100644 --- a/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx +++ b/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx @@ -6,11 +6,9 @@ import { ChunkingMode, DataSourceType } from '@/models/datasets' import DocumentList from '../document-list' vi.mock('../../document-file-icon', () => ({ - default: ({ name, extension }: { name?: string, extension?: string }) => ( + default: ({ name, extension }: { name?: string; extension?: string }) => ( <span data-testid="file-icon"> - {name} - . - {extension} + {name}.{extension} </span> ), })) @@ -63,8 +61,8 @@ const renderDocumentList = (list: SimpleDocumentDetail[], onValueChange = vi.fn( <Combobox open items={list} - itemToStringLabel={document => document.name} - itemToStringValue={document => document.id} + itemToStringLabel={(document) => document.name} + itemToStringValue={(document) => document.id} onValueChange={onValueChange} > <DocumentList /> diff --git a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx index f934ef545e9fd4..faf327c8391440 100644 --- a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx @@ -151,9 +151,7 @@ describe('DocumentPicker', () => { it('should keep focus in the search input when deleting from an empty result', async () => { const user = userEvent.setup() mockUseDocumentList.mockImplementation(({ query }) => ({ - data: query.keyword === 'missing' - ? { data: [] } - : mockDocumentListData, + data: query.keyword === 'missing' ? { data: [] } : mockDocumentListData, })) renderDocumentPicker() @@ -165,7 +163,9 @@ describe('DocumentPicker', () => { await user.type(searchInput, 'missing') expect(await screen.findByText('common.noData')).toBeInTheDocument() - await user.keyboard('{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}') + await user.keyboard( + '{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}', + ) expect(trigger).toHaveAttribute('aria-expanded', 'true') expect(searchInput).toHaveFocus() @@ -192,10 +192,7 @@ describe('DocumentPicker', () => { const onChange = vi.fn() const selectedDocument = createDocument({ id: 'doc-2', name: 'Document 2' }) mockDocumentListData = { - data: [ - createDocument({ id: 'doc-1', name: 'Document 1' }), - selectedDocument, - ], + data: [createDocument({ id: 'doc-1', name: 'Document 1' }), selectedDocument], } renderDocumentPicker({ onChange }) diff --git a/web/app/components/datasets/common/document-picker/__tests__/preview-document-picker.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/preview-document-picker.spec.tsx index c7eb2c740c1de9..e5070ff9147327 100644 --- a/web/app/components/datasets/common/document-picker/__tests__/preview-document-picker.spec.tsx +++ b/web/app/components/datasets/common/document-picker/__tests__/preview-document-picker.spec.tsx @@ -20,11 +20,14 @@ const createMockDocumentList = (count: number): DocumentItem[] => { id: `doc-${index + 1}`, name: `Document ${index + 1}`, extension: index % 2 === 0 ? 'pdf' : 'txt', - })) + }), + ) } // Factory function to create default props -const createDefaultProps = (overrides: Partial<React.ComponentProps<typeof PreviewDocumentPicker>> = {}) => ({ +const createDefaultProps = ( + overrides: Partial<React.ComponentProps<typeof PreviewDocumentPicker>> = {}, +) => ({ value: createMockDocumentItem({ id: 'selected-doc', name: 'Selected Document' }), files: createMockDocumentList(3), onChange: vi.fn(), @@ -32,7 +35,9 @@ const createDefaultProps = (overrides: Partial<React.ComponentProps<typeof Previ }) // Helper to render component with default props -const renderComponent = (props: Partial<React.ComponentProps<typeof PreviewDocumentPicker>> = {}) => { +const renderComponent = ( + props: Partial<React.ComponentProps<typeof PreviewDocumentPicker>> = {}, +) => { const defaultProps = createDefaultProps(props) return { ...render(<PreviewDocumentPicker {...defaultProps} />), @@ -215,9 +220,7 @@ describe('PreviewDocumentPicker', () => { <PreviewDocumentPicker value={value} files={files} onChange={onChange1} />, ) - rerender( - <PreviewDocumentPicker value={value} files={files} onChange={onChange2} />, - ) + rerender(<PreviewDocumentPicker value={value} files={files} onChange={onChange2} />) expect(screen.getByTestId('popover')).toBeInTheDocument() }) @@ -238,9 +241,7 @@ describe('PreviewDocumentPicker', () => { <PreviewDocumentPicker value={value} files={files} onChange={onChange} />, ) - rerender( - <PreviewDocumentPicker value={value} files={files} onChange={onChange} />, - ) + rerender(<PreviewDocumentPicker value={value} files={files} onChange={onChange} />) expect(screen.getByTestId('popover')).toBeInTheDocument() }) diff --git a/web/app/components/datasets/common/document-picker/document-list.tsx b/web/app/components/datasets/common/document-picker/document-list.tsx index 726efb5f260fc5..914e4b8f72ca8f 100644 --- a/web/app/components/datasets/common/document-picker/document-list.tsx +++ b/web/app/components/datasets/common/document-picker/document-list.tsx @@ -1,11 +1,7 @@ 'use client' import type { SimpleDocumentDetail } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' -import { - ComboboxItem, - ComboboxItemText, - ComboboxList, -} from '@langgenius/dify-ui/combobox' +import { ComboboxItem, ComboboxItemText, ComboboxList } from '@langgenius/dify-ui/combobox' import FileIcon from '../document-file-icon' type Props = Readonly<{ @@ -14,19 +10,15 @@ type Props = Readonly<{ function getDocumentExtension(document: SimpleDocumentDetail) { const detailExtension = document.data_source_detail_dict?.upload_file?.extension - if (detailExtension) - return detailExtension + if (detailExtension) return detailExtension const dataSourceInfo = document.data_source_info - if (dataSourceInfo && 'upload_file' in dataSourceInfo) - return dataSourceInfo.upload_file.extension + if (dataSourceInfo && 'upload_file' in dataSourceInfo) return dataSourceInfo.upload_file.extension return '' } -export default function DocumentList({ - className, -}: Props) { +export default function DocumentList({ className }: Props) { return ( <ComboboxList className={cn('max-h-[calc(100vh-120px)] p-0', className)}> {(item: SimpleDocumentDetail) => { diff --git a/web/app/components/datasets/common/document-picker/index.tsx b/web/app/components/datasets/common/document-picker/index.tsx index 55770839eccafa..d013312384130a 100644 --- a/web/app/components/datasets/common/document-picker/index.tsx +++ b/web/app/components/datasets/common/document-picker/index.tsx @@ -43,16 +43,13 @@ function isSameDocument(item: SimpleDocumentDetail, value: SimpleDocumentDetail) } function getDocumentExtension(document?: SimpleDocumentDetail | null) { - if (!document) - return '' + if (!document) return '' const detailExtension = document.data_source_detail_dict?.upload_file?.extension - if (detailExtension) - return detailExtension + if (detailExtension) return detailExtension const dataSourceInfo = document.data_source_info - if (dataSourceInfo && 'upload_file' in dataSourceInfo) - return dataSourceInfo.upload_file.extension + if (dataSourceInfo && 'upload_file' in dataSourceInfo) return dataSourceInfo.upload_file.extension return '' } @@ -71,9 +68,10 @@ function DocumentPickerTriggerValue({ const TypeIcon = isParentChild ? ParentChildChunk : GeneralChunk const ArrowIcon = RiArrowDownSLine const parentModeLabel = (() => { - if (!parentMode) - return '--' - return parentMode === 'paragraph' ? t($ => $['parentMode.paragraph'], { ns: 'dataset' }) : t($ => $['parentMode.fullDoc'], { ns: 'dataset' }) + if (!parentMode) return '--' + return parentMode === 'paragraph' + ? t(($) => $['parentMode.paragraph'], { ns: 'dataset' }) + : t(($) => $['parentMode.fullDoc'], { ns: 'dataset' }) })() return ( @@ -89,9 +87,10 @@ function DocumentPickerTriggerValue({ <span className="flex h-3 max-w-[300px] items-center gap-0.5 text-text-tertiary"> <TypeIcon className="size-3 shrink-0" /> <span className={cn('truncate system-2xs-medium-uppercase', isParentChild && 'mt-0.5')}> - {isGeneralMode && t($ => $['chunkingMode.general'], { ns: 'dataset' })} - {isQAMode && t($ => $['chunkingMode.qa'], { ns: 'dataset' })} - {isParentChild && `${t($ => $['chunkingMode.parentChild'], { ns: 'dataset' })} · ${parentModeLabel}`} + {isGeneralMode && t(($) => $['chunkingMode.general'], { ns: 'dataset' })} + {isQAMode && t(($) => $['chunkingMode.qa'], { ns: 'dataset' })} + {isParentChild && + `${t(($) => $['chunkingMode.parentChild'], { ns: 'dataset' })} · ${parentModeLabel}`} </span> </span> </span> @@ -99,12 +98,7 @@ function DocumentPickerTriggerValue({ ) } -export function DocumentPicker({ - datasetId, - value, - parentMode, - onChange, -}: Props) { +export function DocumentPicker({ datasetId, value, parentMode, onChange }: Props) { const { t } = useTranslation() const [searchValue, setSearchValue] = useState('') const debouncedSearchValue = useDebounce(searchValue, { wait: 500 }) @@ -120,18 +114,15 @@ export function DocumentPicker({ const documentsList = data?.data ?? [] const handleInputValueChange = (inputValue: string, details: ComboboxChangeEventDetails) => { - if (details.reason !== 'item-press') - setSearchValue(inputValue) + if (details.reason !== 'item-press') setSearchValue(inputValue) } const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen) - setSearchValue('') + if (!nextOpen) setSearchValue('') } const handleDocumentChange = (document: SimpleDocumentDetail | null) => { - if (!document) - return + if (!document) return onChange(document) setSearchValue('') @@ -151,7 +142,7 @@ export function DocumentPicker({ filter={null} > <ComboboxTrigger - aria-label={value?.name || t($ => $['operation.search'], { ns: 'common' })} + aria-label={value?.name || t(($) => $['operation.search'], { ns: 'common' })} icon={false} className={cn( 'ml-1 flex size-auto rounded-lg border-0 bg-transparent px-2 py-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active data-popup-open:bg-state-base-hover', @@ -169,29 +160,28 @@ export function DocumentPicker({ popupClassName="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg backdrop-blur-[5px]" > <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> + <span + className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> <ComboboxInput - aria-label={t($ => $['operation.search'], { ns: 'common' })} - placeholder={t($ => $['operation.search'], { ns: 'common' })} + aria-label={t(($) => $['operation.search'], { ns: 'common' })} + placeholder={t(($) => $['operation.search'], { ns: 'common' })} className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary" /> </ComboboxInputGroup> - <DocumentList - className="mt-2 data-empty:mt-0" - /> - {data - ? ( - <ComboboxEmpty className="p-0"> - <div className="mt-2 flex h-[100px] w-full items-center justify-center px-3 py-2 system-sm-regular text-text-tertiary"> - {t($ => $.noData, { ns: 'common' })} - </div> - </ComboboxEmpty> - ) - : ( - <ComboboxStatus className="mt-2 flex h-[100px] w-full items-center justify-center"> - <Loading /> - </ComboboxStatus> - )} + <DocumentList className="mt-2 data-empty:mt-0" /> + {data ? ( + <ComboboxEmpty className="p-0"> + <div className="mt-2 flex h-[100px] w-full items-center justify-center px-3 py-2 system-sm-regular text-text-tertiary"> + {t(($) => $.noData, { ns: 'common' })} + </div> + </ComboboxEmpty> + ) : ( + <ComboboxStatus className="mt-2 flex h-[100px] w-full items-center justify-center"> + <Loading /> + </ComboboxStatus> + )} </ComboboxContent> </Combobox> ) diff --git a/web/app/components/datasets/common/document-picker/preview-document-picker.tsx b/web/app/components/datasets/common/document-picker/preview-document-picker.tsx index 636f2eb0c2aa6b..18a987baf66716 100644 --- a/web/app/components/datasets/common/document-picker/preview-document-picker.tsx +++ b/web/app/components/datasets/common/document-picker/preview-document-picker.tsx @@ -2,11 +2,7 @@ import type { FC } from 'react' import type { DocumentItem } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' @@ -22,35 +18,33 @@ type Props = Readonly<{ onChange: (value: DocumentItem) => void }> -const PreviewDocumentPicker: FC<Props> = ({ - className, - value, - files, - onChange, -}) => { +const PreviewDocumentPicker: FC<Props> = ({ className, value, files, onChange }) => { const { t } = useTranslation() const name = value?.name || '' const extension = value?.extension - const [open, { - set: setOpen, - }] = useBoolean(false) + const [open, { set: setOpen }] = useBoolean(false) const ArrowIcon = RiArrowDownSLine - const handleChange = useCallback((item: DocumentItem) => { - onChange(item) - setOpen(false) - }, [onChange, setOpen]) + const handleChange = useCallback( + (item: DocumentItem) => { + onChange(item) + setOpen(false) + }, + [onChange, setOpen], + ) return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton={false} - render={( - <div className={cn('flex h-6 items-center rounded-md px-1 select-none hover:bg-state-base-hover data-popup-open:bg-state-base-hover', className)}> + render={ + <div + className={cn( + 'flex h-6 items-center rounded-md px-1 select-none hover:bg-state-base-hover data-popup-open:bg-state-base-hover', + className, + )} + > <FileIcon name={name} extension={extension} size="lg" /> <div className="ml-1 flex flex-col items-start"> <div className="flex items-center space-x-0.5"> @@ -62,7 +56,7 @@ const PreviewDocumentPicker: FC<Props> = ({ </div> </div> </div> - )} + } /> <PopoverContent placement="bottom-start" @@ -70,19 +64,18 @@ const PreviewDocumentPicker: FC<Props> = ({ popupClassName="border-none bg-transparent shadow-none" > <div className="w-[392px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]"> - {files?.length > 1 && <div className="flex h-8 items-center pl-2 system-xs-medium-uppercase text-text-tertiary">{t($ => $.preprocessDocument, { ns: 'dataset', num: files.length })}</div>} - {files?.length > 0 - ? ( - <PreviewDocumentList - list={files} - onChange={handleChange} - /> - ) - : ( - <div className="mt-2 flex h-[100px] w-[360px] items-center justify-center"> - <Loading /> - </div> - )} + {files?.length > 1 && ( + <div className="flex h-8 items-center pl-2 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $.preprocessDocument, { ns: 'dataset', num: files.length })} + </div> + )} + {files?.length > 0 ? ( + <PreviewDocumentList list={files} onChange={handleChange} /> + ) : ( + <div className="mt-2 flex h-[100px] w-[360px] items-center justify-center"> + <Loading /> + </div> + )} </div> </PopoverContent> </Popover> @@ -99,7 +92,7 @@ function PreviewDocumentList({ }) { return ( <div className="max-h-[calc(100vh-120px)] overflow-auto"> - {list.map(item => ( + {list.map((item) => ( <button key={item.id} type="button" diff --git a/web/app/components/datasets/common/document-status-with-action/__tests__/auto-disabled-document.spec.tsx b/web/app/components/datasets/common/document-status-with-action/__tests__/auto-disabled-document.spec.tsx index ba058860d3ac1f..8e23731e58f830 100644 --- a/web/app/components/datasets/common/document-status-with-action/__tests__/auto-disabled-document.spec.tsx +++ b/web/app/components/datasets/common/document-status-with-action/__tests__/auto-disabled-document.spec.tsx @@ -1,7 +1,6 @@ import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import { useAutoDisabledDocuments } from '@/service/knowledge/use-document' import AutoDisabledDocument from '../auto-disabled-document' @@ -14,10 +13,11 @@ type AutoDisabledDocumentsResponse = { document_ids: string[] } const createMockQueryResult = ( data: AutoDisabledDocumentsResponse | undefined, isLoading: boolean, -) => ({ - data, - isLoading, -}) as ReturnType<typeof useAutoDisabledDocuments> +) => + ({ + data, + isLoading, + }) as ReturnType<typeof useAutoDisabledDocuments> const mockMutateAsync = vi.fn() const mockInvalidDisabledDocument = vi.fn() @@ -46,9 +46,7 @@ describe('AutoDisabledDocument', () => { describe('Rendering', () => { it('should render nothing when loading', () => { - mockUseAutoDisabledDocuments.mockReturnValue( - createMockQueryResult(undefined, true), - ) + mockUseAutoDisabledDocuments.mockReturnValue(createMockQueryResult(undefined, true)) const { container } = render(<AutoDisabledDocument datasetId="test-dataset" />) expect(container.firstChild).toBeNull() @@ -64,9 +62,7 @@ describe('AutoDisabledDocument', () => { }) it('should render nothing when document_ids is undefined', () => { - mockUseAutoDisabledDocuments.mockReturnValue( - createMockQueryResult(undefined, false), - ) + mockUseAutoDisabledDocuments.mockReturnValue(createMockQueryResult(undefined, false)) const { container } = render(<AutoDisabledDocument datasetId="test-dataset" />) expect(container.firstChild).toBeNull() diff --git a/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx b/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx index a27418221346f4..012532b5d67bfc 100644 --- a/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx +++ b/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx @@ -24,37 +24,35 @@ afterEach(() => { }) // Helper to create mock query result -const createMockQueryResult = ( - data: ErrorDocsResponse | undefined, - isLoading: boolean, -) => ({ - data, - isLoading, - refetch: mockRefetch, - // Required query result properties - error: null, - isError: false, - isFetched: true, - isFetching: false, - isSuccess: !isLoading && !!data, - status: isLoading ? 'pending' : 'success', - dataUpdatedAt: Date.now(), - errorUpdatedAt: 0, - failureCount: 0, - failureReason: null, - errorUpdateCount: 0, - isLoadingError: false, - isPaused: false, - isPlaceholderData: false, - isPending: isLoading, - isRefetchError: false, - isRefetching: false, - isStale: false, - fetchStatus: 'idle', - promise: Promise.resolve(data as ErrorDocsResponse), - isFetchedAfterMount: true, - isInitialLoading: false, -}) as unknown as ReturnType<typeof useDatasetErrorDocs> +const createMockQueryResult = (data: ErrorDocsResponse | undefined, isLoading: boolean) => + ({ + data, + isLoading, + refetch: mockRefetch, + // Required query result properties + error: null, + isError: false, + isFetched: true, + isFetching: false, + isSuccess: !isLoading && !!data, + status: isLoading ? 'pending' : 'success', + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isLoadingError: false, + isPaused: false, + isPlaceholderData: false, + isPending: isLoading, + isRefetchError: false, + isRefetching: false, + isStale: false, + fetchStatus: 'idle', + promise: Promise.resolve(data as ErrorDocsResponse), + isFetchedAfterMount: true, + isInitialLoading: false, + }) as unknown as ReturnType<typeof useDatasetErrorDocs> describe('RetryButton (IndexFailed)', () => { beforeEach(() => { @@ -64,18 +62,14 @@ describe('RetryButton (IndexFailed)', () => { describe('Rendering', () => { it('should render nothing when loading', () => { - mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult(undefined, true), - ) + mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult(undefined, true)) const { container } = render(<RetryButton datasetId="test-dataset" />) expect(container.firstChild).toBeNull() }) it('should render nothing when no error documents', () => { - mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult({ total: 0, data: [] }, false), - ) + mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) const { container } = render(<RetryButton datasetId="test-dataset" />) expect(container.firstChild).toBeNull() @@ -83,14 +77,13 @@ describe('RetryButton (IndexFailed)', () => { it('should render StatusWithAction when error documents exist', () => { mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult({ - total: 3, - data: [ - { id: 'doc1' }, - { id: 'doc2' }, - { id: 'doc3' }, - ] as ErrorDocsResponse['data'], - }, false), + createMockQueryResult( + { + total: 3, + data: [{ id: 'doc1' }, { id: 'doc2' }, { id: 'doc3' }] as ErrorDocsResponse['data'], + }, + false, + ), ) render(<RetryButton datasetId="test-dataset" />) @@ -99,10 +92,13 @@ describe('RetryButton (IndexFailed)', () => { it('should display error count in description', () => { mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult({ - total: 5, - data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], - }, false), + createMockQueryResult( + { + total: 5, + data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], + }, + false, + ), ) render(<RetryButton datasetId="test-dataset" />) @@ -112,9 +108,7 @@ describe('RetryButton (IndexFailed)', () => { describe('Props', () => { it('should pass datasetId to useDatasetErrorDocs', () => { - mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult({ total: 0, data: [] }, false), - ) + mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) render(<RetryButton datasetId="my-dataset-id" />) expect(mockUseDatasetErrorDocs).toHaveBeenCalledWith('my-dataset-id') @@ -124,10 +118,13 @@ describe('RetryButton (IndexFailed)', () => { describe('User Interactions', () => { it('should call retryErrorDocs when retry button is clicked', async () => { mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult({ - total: 2, - data: [{ id: 'doc1' }, { id: 'doc2' }] as ErrorDocsResponse['data'], - }, false), + createMockQueryResult( + { + total: 2, + data: [{ id: 'doc1' }, { id: 'doc2' }] as ErrorDocsResponse['data'], + }, + false, + ), ) mockRetryErrorDocs.mockResolvedValue({ result: 'success' }) @@ -152,10 +149,13 @@ describe('RetryButton (IndexFailed)', () => { it('should refetch error docs after successful retry', async () => { mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult({ - total: 1, - data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], - }, false), + createMockQueryResult( + { + total: 1, + data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], + }, + false, + ), ) mockRetryErrorDocs.mockResolvedValue({ result: 'success' }) @@ -172,16 +172,22 @@ describe('RetryButton (IndexFailed)', () => { it('should disable button while retrying', async () => { mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult({ - total: 1, - data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], - }, false), + createMockQueryResult( + { + total: 1, + data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], + }, + false, + ), ) let resolveRetry: ((value: { result: 'success' }) => void) | undefined - mockRetryErrorDocs.mockImplementation(() => new Promise((resolve) => { - resolveRetry = resolve - })) + mockRetryErrorDocs.mockImplementation( + () => + new Promise((resolve) => { + resolveRetry = resolve + }), + ) render(<RetryButton datasetId="test-dataset" />) @@ -205,10 +211,13 @@ describe('RetryButton (IndexFailed)', () => { describe('State Management', () => { it('should transition to error state when retry fails', async () => { mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult({ - total: 1, - data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], - }, false), + createMockQueryResult( + { + total: 1, + data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], + }, + false, + ), ) mockRetryErrorDocs.mockResolvedValue({ result: 'fail' }) @@ -234,19 +243,20 @@ describe('RetryButton (IndexFailed)', () => { // Initially has errors mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult({ - total: 1, - data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], - }, false), + createMockQueryResult( + { + total: 1, + data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], + }, + false, + ), ) rerender(<RetryButton datasetId="test-dataset" />) expect(screen.getByText(/retry/i)).toBeInTheDocument() // Now no errors - mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult({ total: 0, data: [] }, false), - ) + mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) rerender(<RetryButton datasetId="test-dataset" />) @@ -258,9 +268,7 @@ describe('RetryButton (IndexFailed)', () => { describe('Edge Cases', () => { it('should handle empty data array', () => { - mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult({ total: 0, data: [] }, false), - ) + mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) const { container } = render(<RetryButton datasetId="test-dataset" />) expect(container.firstChild).toBeNull() @@ -269,9 +277,7 @@ describe('RetryButton (IndexFailed)', () => { it('should handle undefined data by showing error state', () => { // When data is undefined but not loading, the component shows error state // because errorDocs?.total is not strictly equal to 0 - mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult(undefined, false), - ) + mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult(undefined, false)) render(<RetryButton datasetId="test-dataset" />) // Component renders with undefined count @@ -279,9 +285,7 @@ describe('RetryButton (IndexFailed)', () => { }) it('should handle retry with empty document list', async () => { - mockUseDatasetErrorDocs.mockReturnValue( - createMockQueryResult({ total: 1, data: [] }, false), - ) + mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult({ total: 1, data: [] }, false)) mockRetryErrorDocs.mockResolvedValue({ result: 'success' }) diff --git a/web/app/components/datasets/common/document-status-with-action/__tests__/status-with-action.spec.tsx b/web/app/components/datasets/common/document-status-with-action/__tests__/status-with-action.spec.tsx index 0688adf1c6dbff..2ce3e5cb09f115 100644 --- a/web/app/components/datasets/common/document-status-with-action/__tests__/status-with-action.spec.tsx +++ b/web/app/components/datasets/common/document-status-with-action/__tests__/status-with-action.spec.tsx @@ -53,13 +53,7 @@ describe('StatusWithAction', () => { it('should render action button when actionText and onAction are provided', () => { const onAction = vi.fn() - render( - <StatusWithAction - description="Test" - actionText="Click me" - onAction={onAction} - />, - ) + render(<StatusWithAction description="Test" actionText="Click me" onAction={onAction} />) expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument() }) @@ -70,11 +64,7 @@ describe('StatusWithAction', () => { it('should render divider when action is present', () => { const { container } = render( - <StatusWithAction - description="Test" - actionText="Click me" - onAction={() => {}} - />, + <StatusWithAction description="Test" actionText="Click me" onAction={() => {}} />, ) // Divider component renders a div with specific classes expect(container.querySelector('.bg-divider-regular')).toBeInTheDocument() @@ -84,13 +74,7 @@ describe('StatusWithAction', () => { describe('User Interactions', () => { it('should call onAction when action button is clicked', () => { const onAction = vi.fn() - render( - <StatusWithAction - description="Test" - actionText="Click me" - onAction={onAction} - />, - ) + render(<StatusWithAction description="Test" actionText="Click me" onAction={onAction} />) fireEvent.click(screen.getByRole('button', { name: 'Click me' })) expect(onAction).toHaveBeenCalledTimes(1) @@ -99,12 +83,7 @@ describe('StatusWithAction', () => { it('should not call onAction when disabled', () => { const onAction = vi.fn() render( - <StatusWithAction - description="Test" - actionText="Click me" - onAction={onAction} - disabled - />, + <StatusWithAction description="Test" actionText="Click me" onAction={onAction} disabled />, ) fireEvent.click(screen.getByRole('button', { name: 'Click me' })) @@ -113,12 +92,7 @@ describe('StatusWithAction', () => { it('should apply disabled styles when disabled prop is true', () => { render( - <StatusWithAction - description="Test" - actionText="Click me" - onAction={() => {}} - disabled - />, + <StatusWithAction description="Test" actionText="Click me" onAction={() => {}} disabled />, ) const actionButton = screen.getByRole('button', { name: 'Click me' }) diff --git a/web/app/components/datasets/common/document-status-with-action/auto-disabled-document.tsx b/web/app/components/datasets/common/document-status-with-action/auto-disabled-document.tsx index 2fc439bc58a5fa..63765d257025b0 100644 --- a/web/app/components/datasets/common/document-status-with-action/auto-disabled-document.tsx +++ b/web/app/components/datasets/common/document-status-with-action/auto-disabled-document.tsx @@ -4,16 +4,18 @@ import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useAutoDisabledDocuments, useDocumentEnable, useInvalidDisabledDocument } from '@/service/knowledge/use-document' +import { + useAutoDisabledDocuments, + useDocumentEnable, + useInvalidDisabledDocument, +} from '@/service/knowledge/use-document' import StatusWithAction from './status-with-action' type Props = Readonly<{ datasetId: string }> -const AutoDisabledDocument: FC<Props> = ({ - datasetId, -}) => { +const AutoDisabledDocument: FC<Props> = ({ datasetId }) => { const { t } = useTranslation() const { data, isLoading } = useAutoDisabledDocuments(datasetId) const invalidDisabledDocument = useInvalidDisabledDocument() @@ -23,16 +25,15 @@ const AutoDisabledDocument: FC<Props> = ({ const handleEnableDocuments = useCallback(async () => { await enableDocument({ datasetId, documentIds }) invalidDisabledDocument() - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) }, []) - if (!hasDisabledDocument || isLoading) - return null + if (!hasDisabledDocument || isLoading) return null return ( <StatusWithAction type="info" - description={t($ => $.documentsDisabled, { ns: 'dataset', num: documentIds?.length })} - actionText={t($ => $.enable, { ns: 'dataset' })} + description={t(($) => $.documentsDisabled, { ns: 'dataset', num: documentIds?.length })} + actionText={t(($) => $.enable, { ns: 'dataset' })} onAction={handleEnableDocuments} /> ) diff --git a/web/app/components/datasets/common/document-status-with-action/index-failed.tsx b/web/app/components/datasets/common/document-status-with-action/index-failed.tsx index e93981c4e17f03..22b8f8aae4d0f8 100644 --- a/web/app/components/datasets/common/document-status-with-action/index-failed.tsx +++ b/web/app/components/datasets/common/document-status-with-action/index-failed.tsx @@ -45,27 +45,23 @@ const RetryButton: FC<Props> = ({ datasetId }) => { if (res.result === 'success') { refetchErrorDocs() dispatch({ type: 'success' }) - } - else { + } else { dispatch({ type: 'error' }) } } useEffect(() => { - if (errorDocs?.total === 0) - dispatch({ type: 'success' }) - else - dispatch({ type: 'error' }) + if (errorDocs?.total === 0) dispatch({ type: 'success' }) + else dispatch({ type: 'error' }) }, [errorDocs?.total]) - if (isLoading || indexState.value === 'success') - return null + if (isLoading || indexState.value === 'success') return null return ( <StatusWithAction type="warning" - description={`${errorDocs?.total} ${t($ => $.docsFailedNotice, { ns: 'dataset' })}`} - actionText={t($ => $.retry, { ns: 'dataset' })} + description={`${errorDocs?.total} ${t(($) => $.docsFailedNotice, { ns: 'dataset' })}`} + actionText={t(($) => $.retry, { ns: 'dataset' })} disabled={indexState.value === 'retry'} onAction={indexState.value === 'error' ? onRetryErrorDocs : noop} /> diff --git a/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx b/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx index f9d8023826c3b2..cbf850724d841c 100644 --- a/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx +++ b/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx @@ -1,7 +1,12 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { RiAlertFill, RiCheckboxCircleFill, RiErrorWarningFill, RiInformation2Fill } from '@remixicon/react' +import { + RiAlertFill, + RiCheckboxCircleFill, + RiErrorWarningFill, + RiInformation2Fill, +} from '@remixicon/react' import * as React from 'react' import Divider from '@/app/components/base/divider' @@ -47,13 +52,17 @@ const StatusAction: FC<Props> = ({ const { Icon, color } = getIcon(type) return ( <div className="relative flex h-[34px] items-center rounded-lg border border-components-panel-border bg-components-panel-bg-blur pr-3 pl-2 shadow-xs"> - <div className={ - `absolute inset-0 rounded-lg opacity-40 ${(type === 'success' && 'bg-[linear-gradient(92deg,rgba(23,178,106,0.25)_0%,rgba(255,255,255,0.00)_100%)]') - || (type === 'warning' && 'bg-[linear-gradient(92deg,rgba(247,144,9,0.25)_0%,rgba(255,255,255,0.00)_100%)]') - || (type === 'error' && 'bg-[linear-gradient(92deg,rgba(240,68,56,0.25)_0%,rgba(255,255,255,0.00)_100%)]') - || (type === 'info' && 'bg-[linear-gradient(92deg,rgba(11,165,236,0.25)_0%,rgba(255,255,255,0.00)_100%)]') - }` - } + <div + className={`absolute inset-0 rounded-lg opacity-40 ${ + (type === 'success' && + 'bg-[linear-gradient(92deg,rgba(23,178,106,0.25)_0%,rgba(255,255,255,0.00)_100%)]') || + (type === 'warning' && + 'bg-[linear-gradient(92deg,rgba(247,144,9,0.25)_0%,rgba(255,255,255,0.00)_100%)]') || + (type === 'error' && + 'bg-[linear-gradient(92deg,rgba(240,68,56,0.25)_0%,rgba(255,255,255,0.00)_100%)]') || + (type === 'info' && + 'bg-[linear-gradient(92deg,rgba(11,165,236,0.25)_0%,rgba(255,255,255,0.00)_100%)]') + }`} /> <div className="relative z-10 flex h-full items-center space-x-2"> <Icon className={cn('size-4', color)} /> @@ -65,7 +74,10 @@ const StatusAction: FC<Props> = ({ type="button" disabled={disabled} onClick={onAction} - className={cn('cursor-pointer border-none bg-transparent p-0 text-left text-[13px] font-semibold text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', disabled && 'cursor-not-allowed text-text-disabled')} + className={cn( + 'cursor-pointer border-none bg-transparent p-0 text-left text-[13px] font-semibold text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + disabled && 'cursor-not-allowed text-text-disabled', + )} > {actionText} </button> diff --git a/web/app/components/datasets/common/economical-retrieval-method-config/__tests__/index.spec.tsx b/web/app/components/datasets/common/economical-retrieval-method-config/__tests__/index.spec.tsx index 402a0f06b7bf58..830b0054806ea1 100644 --- a/web/app/components/datasets/common/economical-retrieval-method-config/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/economical-retrieval-method-config/__tests__/index.spec.tsx @@ -3,7 +3,13 @@ import { RETRIEVE_METHOD } from '@/types/app' import EconomicalRetrievalMethodConfig from '../index' vi.mock('../../../settings/option-card', () => ({ - default: ({ children, title, description, disabled, id }: { + default: ({ + children, + title, + description, + disabled, + id, + }: { children?: React.ReactNode title?: string description?: React.ReactNode @@ -18,15 +24,17 @@ vi.mock('../../../settings/option-card', () => ({ })) vi.mock('../../retrieval-param-config', () => ({ - default: ({ value, onChange, type }: { + default: ({ + value, + onChange, + type, + }: { value: Record<string, unknown> onChange: (value: Record<string, unknown>) => void type?: string }) => ( <div data-testid="retrieval-param-config" data-type={type}> - <button onClick={() => onChange({ ...value, newProp: 'changed' })}> - Change Value - </button> + <button onClick={() => onChange({ ...value, newProp: 'changed' })}>Change Value</button> </div> ), })) diff --git a/web/app/components/datasets/common/economical-retrieval-method-config/index.tsx b/web/app/components/datasets/common/economical-retrieval-method-config/index.tsx index 1fa15edbcce944..e99c5b53abf796 100644 --- a/web/app/components/datasets/common/economical-retrieval-method-config/index.tsx +++ b/web/app/components/datasets/common/economical-retrieval-method-config/index.tsx @@ -15,11 +15,7 @@ type Props = Readonly<{ onChange: (value: RetrievalConfig) => void }> -const EconomicalRetrievalMethodConfig: FC<Props> = ({ - disabled = false, - value, - onChange, -}) => { +const EconomicalRetrievalMethodConfig: FC<Props> = ({ disabled = false, value, onChange }) => { const { t } = useTranslation() return ( @@ -28,8 +24,8 @@ const EconomicalRetrievalMethodConfig: FC<Props> = ({ disabled={disabled} icon={<VectorSearch className="size-4" />} iconActiveColor="text-util-colors-purple-purple-600" - title={t($ => $['retrieval.keyword_search.title'], { ns: 'dataset' })} - description={t($ => $['retrieval.keyword_search.description'], { ns: 'dataset' })} + title={t(($) => $['retrieval.keyword_search.title'], { ns: 'dataset' })} + description={t(($) => $['retrieval.keyword_search.description'], { ns: 'dataset' })} isActive effectColor={EffectColor.purple} showEffectColor diff --git a/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx b/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx index 12d44a3eecdd2b..c9a670da54badd 100644 --- a/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx @@ -15,7 +15,7 @@ let capturedOnClick: ((file: FileEntity) => void) | null = null // Mock FileThumb to capture click handler vi.mock('@/app/components/base/file-thumb', () => ({ - default: ({ file, onClick }: { file: FileEntity, onClick?: (file: FileEntity) => void }) => { + default: ({ file, onClick }: { file: FileEntity; onClick?: (file: FileEntity) => void }) => { // Capture the onClick for testing capturedOnClick = onClick ?? null return ( @@ -48,7 +48,9 @@ vi.mock('../../image-previewer', () => ({ <div data-testid="image-previewer"> <span data-testid="preview-count">{images.length}</span> <span data-testid="preview-index">{initialIndex}</span> - <button data-testid="close-preview" onClick={onClose}>Close</button> + <button data-testid="close-preview" onClick={onClose}> + Close + </button> </div> ), })) @@ -95,9 +97,7 @@ describe('ImageList', () => { describe('Props', () => { it('should apply custom className', () => { const images = createMockImages(3) - const { container } = render( - <ImageList images={images} size="md" className="custom-class" />, - ) + const { container } = render(<ImageList images={images} size="md" className="custom-class" />) expect(container.firstChild)!.toHaveClass('custom-class') }) diff --git a/web/app/components/datasets/common/image-list/index.tsx b/web/app/components/datasets/common/image-list/index.tsx index b04dd5afbdedff..2c982edc97aa26 100644 --- a/web/app/components/datasets/common/image-list/index.tsx +++ b/web/app/components/datasets/common/image-list/index.tsx @@ -21,12 +21,7 @@ type ImageListProps = { className?: string } -const ImageList = ({ - images, - size, - limit = 9, - className, -}: ImageListProps) => { +const ImageList = ({ images, size, limit = 9, className }: ImageListProps) => { const [showMore, setShowMore] = useState(false) const [previewIndex, setPreviewIndex] = useState(0) const [previewImages, setPreviewImages] = useState<ImageInfo[]>([]) @@ -39,17 +34,21 @@ const ImageList = ({ setShowMore(true) }, []) - const handleImageClick = useCallback((file: FileEntity) => { - const index = limitedImages.findIndex(image => image.sourceUrl === file.sourceUrl) - if (index === -1) - return - setPreviewIndex(index) - setPreviewImages(limitedImages.map(image => ({ - url: image.sourceUrl, - name: image.name, - size: image.size, - }))) - }, [limitedImages]) + const handleImageClick = useCallback( + (file: FileEntity) => { + const index = limitedImages.findIndex((image) => image.sourceUrl === file.sourceUrl) + if (index === -1) return + setPreviewIndex(index) + setPreviewImages( + limitedImages.map((image) => ({ + url: image.sourceUrl, + name: image.name, + size: image.size, + })), + ) + }, + [limitedImages], + ) const handleClosePreview = useCallback(() => { setPreviewImages([]) @@ -58,21 +57,11 @@ const ImageList = ({ return ( <> <div className={cn('flex flex-wrap gap-1', className)}> - { - limitedImages.map(image => ( - <FileThumb - key={image.sourceUrl} - file={image} - size={size} - onClick={handleImageClick} - /> - )) - } + {limitedImages.map((image) => ( + <FileThumb key={image.sourceUrl} file={image} size={size} onClick={handleImageClick} /> + ))} {images.length > limit && !showMore && ( - <More - count={images.length - limitedImages.length} - onClick={handleShowMore} - /> + <More count={images.length - limitedImages.length} onClick={handleShowMore} /> )} </div> {previewImages.length > 0 && ( diff --git a/web/app/components/datasets/common/image-list/more.tsx b/web/app/components/datasets/common/image-list/more.tsx index 39f6367556c9ef..8413eaa64e1f15 100644 --- a/web/app/components/datasets/common/image-list/more.tsx +++ b/web/app/components/datasets/common/image-list/more.tsx @@ -8,20 +8,20 @@ type MoreProps = { const More = ({ count, onClick }: MoreProps) => { const formatNumber = (num: number) => { - if (num === 0) - return '0' - if (num < 1000) - return num.toString() - if (num < 1000000) - return `${(num / 1000).toFixed(1)}k` + if (num === 0) return '0' + if (num < 1000) return num.toString() + if (num < 1000000) return `${(num / 1000).toFixed(1)}k` return `${(num / 1000000).toFixed(1)}M` } - const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => { - e.stopPropagation() - e.preventDefault() - onClick?.() - }, [onClick]) + const handleClick = useCallback( + (e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation() + e.preventDefault() + onClick?.() + }, + [onClick], + ) const label = `+${formatNumber(count)}` @@ -33,9 +33,7 @@ const More = ({ count, onClick }: MoreProps) => { > <div className="relative z-10 size-full rounded-md border-[1.5px] border-components-panel-bg bg-divider-regular"> <div className="flex size-full items-center justify-center"> - <span className="system-xs-regular text-text-tertiary"> - {label} - </span> + <span className="system-xs-regular text-text-tertiary">{label}</span> </div> </div> <div className="absolute top-1 -right-0.5 z-0 h-6 w-1 rounded-r-md bg-divider-regular" /> diff --git a/web/app/components/datasets/common/image-previewer/__tests__/index.spec.tsx b/web/app/components/datasets/common/image-previewer/__tests__/index.spec.tsx index aea1a181ffc22e..cbc4249f1445f2 100644 --- a/web/app/components/datasets/common/image-previewer/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/image-previewer/__tests__/index.spec.tsx @@ -25,8 +25,7 @@ class MockImage { this._src = value // Trigger onload after a microtask setTimeout(() => { - if (this.onload) - this.onload() + if (this.onload) this.onload() }, 0) } @@ -160,9 +159,7 @@ describe('ImagePreviewer', () => { // Find and click next button (right arrow) const buttons = document.querySelectorAll('button') - const nextButton = Array.from(buttons).find(btn => - btn.className.includes('right-8'), - ) + const nextButton = Array.from(buttons).find((btn) => btn.className.includes('right-8')) if (nextButton) { await act(async () => { @@ -189,9 +186,7 @@ describe('ImagePreviewer', () => { // Find and click prev button (left arrow) const buttons = document.querySelectorAll('button') - const prevButton = Array.from(buttons).find(btn => - btn.className.includes('left-8'), - ) + const prevButton = Array.from(buttons).find((btn) => btn.className.includes('left-8')) if (prevButton) { await act(async () => { @@ -213,9 +208,7 @@ describe('ImagePreviewer', () => { }) const buttons = document.querySelectorAll('button') - const prevButton = Array.from(buttons).find(btn => - btn.className.includes('left-8'), - ) + const prevButton = Array.from(buttons).find((btn) => btn.className.includes('left-8')) expect(prevButton)!.toBeDisabled() }) @@ -229,9 +222,7 @@ describe('ImagePreviewer', () => { }) const buttons = document.querySelectorAll('button') - const nextButton = Array.from(buttons).find(btn => - btn.className.includes('right-8'), - ) + const nextButton = Array.from(buttons).find((btn) => btn.className.includes('right-8')) expect(nextButton)!.toBeDisabled() }) @@ -298,9 +289,7 @@ describe('ImagePreviewer', () => { }) const buttons = document.querySelectorAll('button') - const prevButton = Array.from(buttons).find(btn => - btn.className.includes('left-8'), - ) + const prevButton = Array.from(buttons).find((btn) => btn.className.includes('left-8')) if (prevButton) { await act(async () => { @@ -328,9 +317,7 @@ describe('ImagePreviewer', () => { }) const buttons = document.querySelectorAll('button') - const nextButton = Array.from(buttons).find(btn => - btn.className.includes('right-8'), - ) + const nextButton = Array.from(buttons).find((btn) => btn.className.includes('right-8')) if (nextButton) { await act(async () => { @@ -402,8 +389,11 @@ describe('ImagePreviewer', () => { // Find and click the retry button (not the nav buttons) const allButtons = document.querySelectorAll('button') - const retryButton = Array.from(allButtons).find(btn => - btn.className.includes('rounded-full') && !btn.className.includes('left-8') && !btn.className.includes('right-8'), + const retryButton = Array.from(allButtons).find( + (btn) => + btn.className.includes('rounded-full') && + !btn.className.includes('left-8') && + !btn.className.includes('right-8'), ) expect(retryButton)!.toBeInTheDocument() @@ -463,12 +453,8 @@ describe('ImagePreviewer', () => { // Both navigation buttons should be disabled const buttons = document.querySelectorAll('button') - const prevButton = Array.from(buttons).find(btn => - btn.className.includes('left-8'), - ) - const nextButton = Array.from(buttons).find(btn => - btn.className.includes('right-8'), - ) + const prevButton = Array.from(buttons).find((btn) => btn.className.includes('left-8')) + const nextButton = Array.from(buttons).find((btn) => btn.className.includes('right-8')) expect(prevButton)!.toBeDisabled() expect(nextButton)!.toBeDisabled() diff --git a/web/app/components/datasets/common/image-previewer/index.tsx b/web/app/components/datasets/common/image-previewer/index.tsx index d089202690db76..a334a8a1fe567d 100644 --- a/web/app/components/datasets/common/image-previewer/index.tsx +++ b/web/app/components/datasets/common/image-previewer/index.tsx @@ -28,42 +28,38 @@ type ImagePreviewerProps = { onClose: () => void } -const ImagePreviewer = ({ - images, - initialIndex = 0, - onClose, -}: ImagePreviewerProps) => { +const ImagePreviewer = ({ images, initialIndex = 0, onClose }: ImagePreviewerProps) => { const [currentIndex, setCurrentIndex] = useState(initialIndex) const [cachedImages, setCachedImages] = useState<Record<string, CachedImage>>(() => { - return images.reduce((acc, image) => { - acc[image.url] = { - status: 'loading', - width: 0, - height: 0, - } - return acc - }, {} as Record<string, CachedImage>) + return images.reduce( + (acc, image) => { + acc[image.url] = { + status: 'loading', + width: 0, + height: 0, + } + return acc + }, + {} as Record<string, CachedImage>, + ) }) const isMounted = useRef(false) const fetchImage = useCallback(async (image: ImageInfo) => { const { url } = image // Skip if already cached - if (imageCache.has(url)) - return + if (imageCache.has(url)) return try { const res = await fetch(url) - if (!res.ok) - throw new Error(`Failed to load: ${url}`) + if (!res.ok) throw new Error(`Failed to load: ${url}`) const blob = await res.blob() const blobUrl = URL.createObjectURL(blob) const img = new Image() img.src = blobUrl img.onload = () => { - if (!isMounted.current) - return + if (!isMounted.current) return imageCache.set(url, { blobUrl, status: 'loaded', @@ -82,8 +78,7 @@ const ImagePreviewer = ({ } }) } - } - catch { + } catch { if (isMounted.current) { setCachedImages((prev) => { return { @@ -110,8 +105,7 @@ const ImagePreviewer = ({ isMounted.current = false // Cleanup released blob URLs not in current list imageCache.forEach(({ blobUrl }, key) => { - if (blobUrl) - URL.revokeObjectURL(blobUrl) + if (blobUrl) URL.revokeObjectURL(blobUrl) imageCache.delete(key) }) } @@ -122,29 +116,30 @@ const ImagePreviewer = ({ }, [images, currentIndex]) const prevImage = useCallback(() => { - if (currentIndex === 0) - return - setCurrentIndex(prevIndex => prevIndex - 1) + if (currentIndex === 0) return + setCurrentIndex((prevIndex) => prevIndex - 1) }, [currentIndex]) const nextImage = useCallback(() => { - if (currentIndex === images.length - 1) - return - setCurrentIndex(prevIndex => prevIndex + 1) + if (currentIndex === images.length - 1) return + setCurrentIndex((prevIndex) => prevIndex + 1) }, [currentIndex, images.length]) - const retryImage = useCallback((image: ImageInfo) => { - setCachedImages((prev) => { - return { - ...prev, - [image.url]: { - ...prev[image.url]!, - status: 'loading', - }, - } - }) - fetchImage(image) - }, [fetchImage]) + const retryImage = useCallback( + (image: ImageInfo) => { + setCachedImages((prev) => { + return { + ...prev, + [image.url]: { + ...prev[image.url]!, + status: 'loading', + }, + } + }) + fetchImage(image) + }, + [fetchImage], + ) useHotkey('ArrowLeft', prevImage) useHotkey('ArrowRight', nextImage) @@ -153,8 +148,7 @@ const ImagePreviewer = ({ <Dialog open onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} disablePointerDismissal > @@ -173,9 +167,7 @@ const ImagePreviewer = ({ </Button> <Kbd>{formatForDisplay('Escape')}</Kbd> </div> - {cachedImages[currentImage!.url]!.status === 'loading' && ( - <Loading type="app" /> - )} + {cachedImages[currentImage!.url]!.status === 'loading' && <Loading type="app" />} {cachedImages[currentImage!.url]!.status === 'error' && ( <div className="flex max-w-sm flex-col items-center gap-y-2 system-sm-regular text-text-tertiary"> <span>{`Failed to load image: ${currentImage!.url}. Please try again.`}</span> diff --git a/web/app/components/datasets/common/image-uploader/__tests__/store.spec.tsx b/web/app/components/datasets/common/image-uploader/__tests__/store.spec.tsx index 7663ecd50abb40..a7293df4d1f3a2 100644 --- a/web/app/components/datasets/common/image-uploader/__tests__/store.spec.tsx +++ b/web/app/components/datasets/common/image-uploader/__tests__/store.spec.tsx @@ -137,11 +137,7 @@ describe('image-uploader store', () => { const TestComponent = () => { const store = useFileStore() - return ( - <button onClick={() => store.getState().setFiles(newFiles)}> - Set Files - </button> - ) + return <button onClick={() => store.getState().setFiles(newFiles)}>Set Files</button> } render( @@ -215,10 +211,9 @@ describe('image-uploader store', () => { it('should throw error when used outside provider', () => { const TestComponent = () => { try { - useFileStoreWithSelector(state => state.files) + useFileStoreWithSelector((state) => state.files) return <div>No Error</div> - } - catch { + } catch { return <div>Error</div> } } @@ -231,7 +226,7 @@ describe('image-uploader store', () => { const initialFiles = [createMockFile('1'), createMockFile('2')] const TestComponent = () => { - const files = useFileStoreWithSelector(state => state.files) + const files = useFileStoreWithSelector((state) => state.files) return <div data-testid="files-count">{files.length}</div> } @@ -248,12 +243,8 @@ describe('image-uploader store', () => { const onChange = vi.fn() const TestComponent = () => { - const setFiles = useFileStoreWithSelector(state => state.setFiles) - return ( - <button onClick={() => setFiles([createMockFile('new')])}> - Update - </button> - ) + const setFiles = useFileStoreWithSelector((state) => state.setFiles) + return <button onClick={() => setFiles([createMockFile('new')])}>Update</button> } render( @@ -273,16 +264,14 @@ describe('image-uploader store', () => { const renderCount = { current: 0 } const TestComponent = () => { - const files = useFileStoreWithSelector(state => state.files) - const setFiles = useFileStoreWithSelector(state => state.setFiles) + const files = useFileStoreWithSelector((state) => state.files) + const setFiles = useFileStoreWithSelector((state) => state.setFiles) renderCount.current++ return ( <div> <span data-testid="count">{files.length}</span> - <button onClick={() => setFiles([...files, createMockFile('new')])}> - Add - </button> + <button onClick={() => setFiles([...files, createMockFile('new')])}>Add</button> </div> ) } diff --git a/web/app/components/datasets/common/image-uploader/__tests__/utils.spec.ts b/web/app/components/datasets/common/image-uploader/__tests__/utils.spec.ts index cf9e2402757fc8..00fb3244f1e34d 100644 --- a/web/app/components/datasets/common/image-uploader/__tests__/utils.spec.ts +++ b/web/app/components/datasets/common/image-uploader/__tests__/utils.spec.ts @@ -212,7 +212,7 @@ describe('image-uploader utils', () => { }) describe('traverseFileEntry', () => { - type MockFile = { name: string, relativePath?: string } + type MockFile = { name: string; relativePath?: string } type FileCallback = (file: MockFile) => void type EntriesCallback = (entries: FileSystemEntry[]) => void @@ -302,8 +302,7 @@ describe('image-uploader utils', () => { if (readCount === 0) { readCount++ callback([mockFileEntry1, mockFileEntry2]) - } - else { + } else { callback([]) } }, diff --git a/web/app/components/datasets/common/image-uploader/hooks/__tests__/use-upload.spec.tsx b/web/app/components/datasets/common/image-uploader/hooks/__tests__/use-upload.spec.tsx index 53354deede7bb4..3669d428876a19 100644 --- a/web/app/components/datasets/common/image-uploader/hooks/__tests__/use-upload.spec.tsx +++ b/web/app/components/datasets/common/image-uploader/hooks/__tests__/use-upload.spec.tsx @@ -30,7 +30,12 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ type FileUploadOptions = { file: File onProgressCallback?: (progress: number) => void - onSuccessCallback?: (res: { id: string, extension: string, mime_type: string, size: number }) => void + onSuccessCallback?: (res: { + id: string + extension: string + mime_type: string + size: number + }) => void onErrorCallback?: (error?: Error) => void } @@ -43,11 +48,7 @@ vi.mock('@/app/components/base/file-uploader/utils', () => ({ })) const createWrapper = () => { - return ({ children }: PropsWithChildren) => ( - <FileContextProvider> - {children} - </FileContextProvider> - ) + return ({ children }: PropsWithChildren) => <FileContextProvider>{children}</FileContextProvider> } const createMockFile = (name = 'test.png', _size = 1024, type = 'image/png') => { @@ -64,25 +65,24 @@ class MockFileReader { private listeners: Record<string, EventCallback[]> = {} addEventListener(event: string, callback: EventCallback) { - if (!this.listeners[event]) - this.listeners[event] = [] + if (!this.listeners[event]) this.listeners[event] = [] this.listeners[event].push(callback) } removeEventListener(event: string, callback: EventCallback) { if (this.listeners[event]) - this.listeners[event] = this.listeners[event].filter(cb => cb !== callback) + this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback) } readAsDataURL(_file: File) { setTimeout(() => { this.result = 'data:image/png;base64,mockBase64Data' - this.listeners.load?.forEach(cb => cb()) + this.listeners.load?.forEach((cb) => cb()) }, 0) } triggerError() { - this.listeners.error?.forEach(cb => cb()) + this.listeners.error?.forEach((cb) => cb()) } } @@ -91,7 +91,12 @@ describe('useUpload hook', () => { vi.clearAllMocks() mockFileUpload.mockImplementation(({ onSuccessCallback }) => { setTimeout(() => { - onSuccessCallback?.({ id: 'uploaded-id', extension: 'png', mime_type: 'image/png', size: 1024 }) + onSuccessCallback?.({ + id: 'uploaded-id', + extension: 'png', + mime_type: 'image/png', + size: 1024, + }) }, 0) }) // Mock FileReader globally @@ -209,7 +214,9 @@ describe('useUpload hook', () => { type ToastCall = [string] const mockNotify = vi.mocked(toast.error) const calls = mockNotify.mock.calls as ToastCall[] - const typeErrorCalls = calls.filter(call => call[0].includes('common.fileUploader.fileExtensionNotSupport')) + const typeErrorCalls = calls.filter((call) => + call[0].includes('common.fileUploader.fileExtensionNotSupport'), + ) expect(typeErrorCalls.length).toBe(0) }) }) @@ -338,9 +345,7 @@ describe('useUpload hook', () => { result.current.handleRemoveFile('file1') }) - expect(onChange).toHaveBeenCalledWith([ - { id: 'file2', name: 'test2.png', progress: 100 }, - ]) + expect(onChange).toHaveBeenCalledWith([{ id: 'file2', name: 'test2.png', progress: 100 }]) }) }) @@ -348,7 +353,12 @@ describe('useUpload hook', () => { it('should re-upload file when called with valid fileId', async () => { const onChange = vi.fn() const initialFiles: Partial<FileEntity>[] = [ - { id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') }, + { + id: 'file1', + name: 'test1.png', + progress: -1, + originalFile: new File(['test'], 'test1.png'), + }, ] const wrapper = ({ children }: PropsWithChildren) => ( @@ -371,7 +381,12 @@ describe('useUpload hook', () => { it('should not re-upload when fileId is not found', () => { const onChange = vi.fn() const initialFiles: Partial<FileEntity>[] = [ - { id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') }, + { + id: 'file1', + name: 'test1.png', + progress: -1, + originalFile: new File(['test'], 'test1.png'), + }, ] const wrapper = ({ children }: PropsWithChildren) => ( @@ -399,7 +414,12 @@ describe('useUpload hook', () => { const onChange = vi.fn() const initialFiles: Partial<FileEntity>[] = [ - { id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') }, + { + id: 'file1', + name: 'test1.png', + progress: -1, + originalFile: new File(['test'], 'test1.png'), + }, ] const wrapper = ({ children }: PropsWithChildren) => ( @@ -422,20 +442,25 @@ describe('useUpload hook', () => { describe('handleLocalFileUpload', () => { it('should upload file and update progress', async () => { - mockFileUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }: FileUploadOptions) => { - setTimeout(() => { - onProgressCallback?.(50) + mockFileUpload.mockImplementation( + ({ onProgressCallback, onSuccessCallback }: FileUploadOptions) => { setTimeout(() => { - onSuccessCallback?.({ id: 'uploaded-id', extension: 'png', mime_type: 'image/png', size: 1024 }) - }, 10) - }, 0) - }) + onProgressCallback?.(50) + setTimeout(() => { + onSuccessCallback?.({ + id: 'uploaded-id', + extension: 'png', + mime_type: 'image/png', + size: 1024, + }) + }, 10) + }, 0) + }, + ) const onChange = vi.fn() const wrapper = ({ children }: PropsWithChildren) => ( - <FileContextProvider onChange={onChange}> - {children} - </FileContextProvider> + <FileContextProvider onChange={onChange}>{children}</FileContextProvider> ) const { result } = renderHook(() => useUpload(), { wrapper }) @@ -460,9 +485,7 @@ describe('useUpload hook', () => { const onChange = vi.fn() const wrapper = ({ children }: PropsWithChildren) => ( - <FileContextProvider onChange={onChange}> - {children} - </FileContextProvider> + <FileContextProvider onChange={onChange}>{children}</FileContextProvider> ) const { result } = renderHook(() => useUpload(), { wrapper }) @@ -500,10 +523,7 @@ describe('useUpload hook', () => { // Try to add 2 more files (would exceed limit of 20) const mockEvent = { target: { - files: [ - createMockFile('new1.png'), - createMockFile('new2.png'), - ], + files: [createMockFile('new1.png'), createMockFile('new2.png')], }, } as unknown as React.ChangeEvent<HTMLInputElement> @@ -561,20 +581,19 @@ describe('useUpload hook', () => { private listeners: Record<string, EventCallback[]> = {} addEventListener(event: string, callback: EventCallback) { - if (!this.listeners[event]) - this.listeners[event] = [] + if (!this.listeners[event]) this.listeners[event] = [] this.listeners[event].push(callback) } removeEventListener(event: string, callback: EventCallback) { if (this.listeners[event]) - this.listeners[event] = this.listeners[event].filter(cb => cb !== callback) + this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback) } readAsDataURL(_file: File) { // Trigger error instead of load setTimeout(() => { - this.listeners.error?.forEach(cb => cb()) + this.listeners.error?.forEach((cb) => cb()) }, 0) } } @@ -583,9 +602,7 @@ describe('useUpload hook', () => { const onChange = vi.fn() const wrapper = ({ children }: PropsWithChildren) => ( - <FileContextProvider onChange={onChange}> - {children} - </FileContextProvider> + <FileContextProvider onChange={onChange}>{children}</FileContextProvider> ) const { result } = renderHook(() => useUpload(), { wrapper }) @@ -699,10 +716,12 @@ describe('useUpload hook', () => { await act(async () => { fireEvent.drop(dropZone, { dataTransfer: { - items: [{ - webkitGetAsEntry: () => null, - getAsFile: () => mockFile, - }], + items: [ + { + webkitGetAsEntry: () => null, + getAsFile: () => mockFile, + }, + ], }, }) }) @@ -747,10 +766,12 @@ describe('useUpload hook', () => { await act(async () => { fireEvent.drop(dropZone, { dataTransfer: { - items: [{ - webkitGetAsEntry: () => null, - getAsFile: () => invalidFile, - }], + items: [ + { + webkitGetAsEntry: () => null, + getAsFile: () => invalidFile, + }, + ], }, }) }) @@ -783,10 +804,12 @@ describe('useUpload hook', () => { await act(async () => { fireEvent.drop(dropZone, { dataTransfer: { - items: [{ - webkitGetAsEntry: () => mockFileEntry, - getAsFile: () => mockFile, - }], + items: [ + { + webkitGetAsEntry: () => mockFileEntry, + getAsFile: () => mockFile, + }, + ], }, }) }) diff --git a/web/app/components/datasets/common/image-uploader/hooks/use-upload.ts b/web/app/components/datasets/common/image-uploader/hooks/use-upload.ts index 90f23e500aca44..9969e28874be19 100644 --- a/web/app/components/datasets/common/image-uploader/hooks/use-upload.ts +++ b/web/app/components/datasets/common/image-uploader/hooks/use-upload.ts @@ -28,8 +28,7 @@ export const useUpload = () => { const handleDragEnter = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.target !== dragRef.current) - setDragging(true) + if (e.target !== dragRef.current) setDragging(true) } const handleDragOver = (e: DragEvent) => { e.preventDefault() @@ -38,8 +37,7 @@ export const useUpload = () => { const handleDragLeave = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.target === dragRef.current) - setDragging(false) + if (e.target === dragRef.current) setDragging(false) } const checkFileType = useCallback((file: File) => { @@ -47,203 +45,234 @@ export const useUpload = () => { return ACCEPT_TYPES.includes(ext!.toLowerCase()) }, []) - const checkFileSize = useCallback((file: File) => { - const { size } = file - return size <= fileUploadConfig.imageFileSizeLimit * 1024 * 1024 - }, [fileUploadConfig]) + const checkFileSize = useCallback( + (file: File) => { + const { size } = file + return size <= fileUploadConfig.imageFileSizeLimit * 1024 * 1024 + }, + [fileUploadConfig], + ) - const showErrorMessage = useCallback((type: 'type' | 'size') => { - if (type === 'type') - toast.error(t($ => $['fileUploader.fileExtensionNotSupport'], { ns: 'common' })) - else - toast.error(t($ => $['imageUploader.fileSizeLimitExceeded'], { ns: 'dataset', size: fileUploadConfig.imageFileSizeLimit })) - }, [fileUploadConfig, t]) + const showErrorMessage = useCallback( + (type: 'type' | 'size') => { + if (type === 'type') + toast.error(t(($) => $['fileUploader.fileExtensionNotSupport'], { ns: 'common' })) + else + toast.error( + t(($) => $['imageUploader.fileSizeLimitExceeded'], { + ns: 'dataset', + size: fileUploadConfig.imageFileSizeLimit, + }), + ) + }, + [fileUploadConfig, t], + ) - const getValidFiles = useCallback((files: File[]) => { - let validType = true - let validSize = true - const validFiles = files.filter((file) => { - if (!checkFileType(file)) { - validType = false - return false - } - if (!checkFileSize(file)) { - validSize = false - return false - } - return true - }) - if (!validType) - showErrorMessage('type') - else if (!validSize) - showErrorMessage('size') + const getValidFiles = useCallback( + (files: File[]) => { + let validType = true + let validSize = true + const validFiles = files.filter((file) => { + if (!checkFileType(file)) { + validType = false + return false + } + if (!checkFileSize(file)) { + validSize = false + return false + } + return true + }) + if (!validType) showErrorMessage('type') + else if (!validSize) showErrorMessage('size') - return validFiles - }, [checkFileType, checkFileSize, showErrorMessage]) + return validFiles + }, + [checkFileType, checkFileSize, showErrorMessage], + ) const selectHandle = () => { - if (uploaderRef.current) - uploaderRef.current.click() + if (uploaderRef.current) uploaderRef.current.click() } - const handleAddFile = useCallback((newFile: FileEntity) => { - const { - files, - setFiles, - } = fileStore.getState() - - const newFiles = produce(files, (draft) => { - draft.push(newFile) - }) - setFiles(newFiles) - }, [fileStore]) + const handleAddFile = useCallback( + (newFile: FileEntity) => { + const { files, setFiles } = fileStore.getState() - const handleUpdateFile = useCallback((newFile: FileEntity) => { - const { - files, - setFiles, - } = fileStore.getState() - - const newFiles = produce(files, (draft) => { - const index = draft.findIndex(file => file.id === newFile.id) + const newFiles = produce(files, (draft) => { + draft.push(newFile) + }) + setFiles(newFiles) + }, + [fileStore], + ) - if (index > -1) - draft[index] = newFile - }) - setFiles(newFiles) - }, [fileStore]) + const handleUpdateFile = useCallback( + (newFile: FileEntity) => { + const { files, setFiles } = fileStore.getState() - const handleRemoveFile = useCallback((fileId: string) => { - const { - files, - setFiles, - } = fileStore.getState() + const newFiles = produce(files, (draft) => { + const index = draft.findIndex((file) => file.id === newFile.id) - const newFiles = files.filter(file => file.id !== fileId) - setFiles(newFiles) - }, [fileStore]) + if (index > -1) draft[index] = newFile + }) + setFiles(newFiles) + }, + [fileStore], + ) - const handleReUploadFile = useCallback((fileId: string) => { - const { - files, - setFiles, - } = fileStore.getState() - const index = files.findIndex(file => file.id === fileId) + const handleRemoveFile = useCallback( + (fileId: string) => { + const { files, setFiles } = fileStore.getState() - if (index > -1) { - const uploadingFile = files[index]! - const newFiles = produce(files, (draft) => { - draft[index]!.progress = 0 - }) + const newFiles = files.filter((file) => file.id !== fileId) setFiles(newFiles) - fileUpload({ - file: uploadingFile!.originalFile!, - onProgressCallback: (progress) => { - handleUpdateFile({ ...uploadingFile, progress }) - }, - onSuccessCallback: (res) => { - handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) - }, - onErrorCallback: (error?: any) => { - const errorMessage = getFileUploadErrorMessage(error, t($ => $['fileUploader.uploadFromComputerUploadError'], { ns: 'common' }), t) - toast.error(errorMessage) - handleUpdateFile({ ...uploadingFile, progress: -1 }) - }, - }) - } - }, [fileStore, t, handleUpdateFile]) + }, + [fileStore], + ) - const handleLocalFileUpload = useCallback((file: File) => { - const reader = new FileReader() - const isImage = file.type.startsWith('image') + const handleReUploadFile = useCallback( + (fileId: string) => { + const { files, setFiles } = fileStore.getState() + const index = files.findIndex((file) => file.id === fileId) - reader.addEventListener( - 'load', - () => { - const uploadingFile = { - id: uuid4(), - name: file.name, - extension: getFileType(file), - mimeType: file.type, - size: file.size, - progress: 0, - originalFile: file, - base64Url: isImage ? reader.result as string : '', - } - handleAddFile(uploadingFile) + if (index > -1) { + const uploadingFile = files[index]! + const newFiles = produce(files, (draft) => { + draft[index]!.progress = 0 + }) + setFiles(newFiles) fileUpload({ - file: uploadingFile.originalFile, + file: uploadingFile!.originalFile!, onProgressCallback: (progress) => { handleUpdateFile({ ...uploadingFile, progress }) }, onSuccessCallback: (res) => { - handleUpdateFile({ - ...uploadingFile, - extension: res.extension, - mimeType: res.mime_type, - size: res.size, - uploadedId: res.id, - progress: 100, - }) + handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) }, onErrorCallback: (error?: any) => { - const errorMessage = getFileUploadErrorMessage(error, t($ => $['fileUploader.uploadFromComputerUploadError'], { ns: 'common' }), t) + const errorMessage = getFileUploadErrorMessage( + error, + t(($) => $['fileUploader.uploadFromComputerUploadError'], { ns: 'common' }), + t, + ) toast.error(errorMessage) handleUpdateFile({ ...uploadingFile, progress: -1 }) }, }) - }, - false, - ) - reader.addEventListener( - 'error', - () => { - toast.error(t($ => $['fileUploader.uploadFromComputerReadError'], { ns: 'common' })) - }, - false, - ) - reader.readAsDataURL(file) - }, [t, handleAddFile, handleUpdateFile]) + } + }, + [fileStore, t, handleUpdateFile], + ) - const handleFileUpload = useCallback((newFiles: File[]) => { - const { files } = fileStore.getState() - const { singleChunkAttachmentLimit } = fileUploadConfig - if (newFiles.length === 0) - return - if (files.length + newFiles.length > singleChunkAttachmentLimit) { - toast.error(t($ => $['imageUploader.singleChunkAttachmentLimitTooltip'], { ns: 'datasetHitTesting', limit: singleChunkAttachmentLimit })) - return - } - for (const file of newFiles) - handleLocalFileUpload(file) - }, [fileUploadConfig, fileStore, t, handleLocalFileUpload]) + const handleLocalFileUpload = useCallback( + (file: File) => { + const reader = new FileReader() + const isImage = file.type.startsWith('image') - const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { - const { imageFileBatchLimit } = fileUploadConfig - const files = Array.from(e.target.files ?? []).slice(0, imageFileBatchLimit) - const validFiles = getValidFiles(files) - handleFileUpload(validFiles) - }, [getValidFiles, handleFileUpload, fileUploadConfig]) + reader.addEventListener( + 'load', + () => { + const uploadingFile = { + id: uuid4(), + name: file.name, + extension: getFileType(file), + mimeType: file.type, + size: file.size, + progress: 0, + originalFile: file, + base64Url: isImage ? (reader.result as string) : '', + } + handleAddFile(uploadingFile) + fileUpload({ + file: uploadingFile.originalFile, + onProgressCallback: (progress) => { + handleUpdateFile({ ...uploadingFile, progress }) + }, + onSuccessCallback: (res) => { + handleUpdateFile({ + ...uploadingFile, + extension: res.extension, + mimeType: res.mime_type, + size: res.size, + uploadedId: res.id, + progress: 100, + }) + }, + onErrorCallback: (error?: any) => { + const errorMessage = getFileUploadErrorMessage( + error, + t(($) => $['fileUploader.uploadFromComputerUploadError'], { ns: 'common' }), + t, + ) + toast.error(errorMessage) + handleUpdateFile({ ...uploadingFile, progress: -1 }) + }, + }) + }, + false, + ) + reader.addEventListener( + 'error', + () => { + toast.error(t(($) => $['fileUploader.uploadFromComputerReadError'], { ns: 'common' })) + }, + false, + ) + reader.readAsDataURL(file) + }, + [t, handleAddFile, handleUpdateFile], + ) - const handleDrop = useCallback(async (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - setDragging(false) - if (!e.dataTransfer) - return - const nested = await Promise.all( - Array.from(e.dataTransfer.items).map((it) => { - const entry = (it as any).webkitGetAsEntry?.() - if (entry) - return traverseFileEntry(entry) - const f = it.getAsFile?.() - return f ? Promise.resolve([f]) : Promise.resolve([]) - }), - ) - const files = nested.flat().slice(0, fileUploadConfig.imageFileBatchLimit) - const validFiles = getValidFiles(files) - handleFileUpload(validFiles) - }, [fileUploadConfig, handleFileUpload, getValidFiles]) + const handleFileUpload = useCallback( + (newFiles: File[]) => { + const { files } = fileStore.getState() + const { singleChunkAttachmentLimit } = fileUploadConfig + if (newFiles.length === 0) return + if (files.length + newFiles.length > singleChunkAttachmentLimit) { + toast.error( + t(($) => $['imageUploader.singleChunkAttachmentLimitTooltip'], { + ns: 'datasetHitTesting', + limit: singleChunkAttachmentLimit, + }), + ) + return + } + for (const file of newFiles) handleLocalFileUpload(file) + }, + [fileUploadConfig, fileStore, t, handleLocalFileUpload], + ) + + const fileChangeHandle = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + const { imageFileBatchLimit } = fileUploadConfig + const files = Array.from(e.target.files ?? []).slice(0, imageFileBatchLimit) + const validFiles = getValidFiles(files) + handleFileUpload(validFiles) + }, + [getValidFiles, handleFileUpload, fileUploadConfig], + ) + + const handleDrop = useCallback( + async (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragging(false) + if (!e.dataTransfer) return + const nested = await Promise.all( + Array.from(e.dataTransfer.items).map((it) => { + const entry = (it as any).webkitGetAsEntry?.() + if (entry) return traverseFileEntry(entry) + const f = it.getAsFile?.() + return f ? Promise.resolve([f]) : Promise.resolve([]) + }), + ) + const files = nested.flat().slice(0, fileUploadConfig.imageFileBatchLimit) + const validFiles = getValidFiles(files) + handleFileUpload(validFiles) + }, + [fileUploadConfig, handleFileUpload, getValidFiles], + ) useEffect(() => { dropRef.current?.addEventListener('dragenter', handleDragEnter) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/image-input.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/image-input.spec.tsx index 8b44828b3e561e..26e04ed2ba867e 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/image-input.spec.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/image-input.spec.tsx @@ -14,11 +14,7 @@ vi.mock('@/service/use-common', () => ({ })) const renderWithProvider = (ui: React.ReactElement) => { - return render( - <FileContextProvider> - {ui} - </FileContextProvider>, - ) + return render(<FileContextProvider>{ui}</FileContextProvider>) } describe('ImageInput (image-uploader-in-chunk)', () => { diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/image-item.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/image-item.spec.tsx index 957cd941f0244c..5095ce4abd4b0e 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/image-item.spec.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/image-item.spec.tsx @@ -3,15 +3,16 @@ import { fireEvent, render } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import ImageItem from '../image-item' -const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({ - id: 'test-id', - name: 'test.png', - progress: 100, - base64Url: 'data:image/png;base64,test', - sourceUrl: 'https://example.com/test.png', - size: 1024, - ...overrides, -} as FileEntity) +const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => + ({ + id: 'test-id', + name: 'test.png', + progress: 100, + base64Url: 'data:image/png;base64,test', + sourceUrl: 'https://example.com/test.png', + size: 1024, + ...overrides, + }) as FileEntity describe('ImageItem (image-uploader-in-chunk)', () => { describe('Rendering', () => { @@ -32,9 +33,7 @@ describe('ImageItem (image-uploader-in-chunk)', () => { describe('Props', () => { it('should show delete button when showDeleteAction is true', () => { const file = createMockFile() - const { container } = render( - <ImageItem file={file} showDeleteAction onRemove={() => {}} />, - ) + const { container } = render(<ImageItem file={file} showDeleteAction onRemove={() => {}} />) // Delete button has RiCloseLine icon const deleteButton = container.querySelector('button') expect(deleteButton).toBeInTheDocument() @@ -98,9 +97,7 @@ describe('ImageItem (image-uploader-in-chunk)', () => { it('should call onRemove when delete button is clicked', () => { const onRemove = vi.fn() const file = createMockFile() - const { container } = render( - <ImageItem file={file} showDeleteAction onRemove={onRemove} />, - ) + const { container } = render(<ImageItem file={file} showDeleteAction onRemove={onRemove} />) const deleteButton = container.querySelector('button') if (deleteButton) { @@ -161,8 +158,7 @@ describe('ImageItem (image-uploader-in-chunk)', () => { const imageContainer = container.querySelector('.group\\/file-image') expect(() => { - if (imageContainer) - fireEvent.click(imageContainer) + if (imageContainer) fireEvent.click(imageContainer) }).not.toThrow() }) @@ -172,8 +168,7 @@ describe('ImageItem (image-uploader-in-chunk)', () => { const deleteButton = container.querySelector('button') expect(() => { - if (deleteButton) - fireEvent.click(deleteButton) + if (deleteButton) fireEvent.click(deleteButton) }).not.toThrow() }) @@ -183,8 +178,7 @@ describe('ImageItem (image-uploader-in-chunk)', () => { const errorOverlay = container.querySelector('.bg-background-overlay-destructive') expect(() => { - if (errorOverlay) - fireEvent.click(errorOverlay) + if (errorOverlay) fireEvent.click(errorOverlay) }).not.toThrow() }) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/index.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/index.spec.tsx index f38d17e74b1813..c650503ccff792 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/index.spec.tsx @@ -16,7 +16,9 @@ vi.mock('@/service/use-common', () => ({ vi.mock('@/app/components/datasets/common/image-previewer', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="image-previewer"> - <button data-testid="close-preview" onClick={onClose}>Close</button> + <button data-testid="close-preview" onClick={onClose}> + Close + </button> </div> ), })) @@ -25,9 +27,7 @@ describe('ImageUploaderInChunk', () => { describe('Rendering', () => { it('should render without crashing', () => { const onChange = vi.fn() - const { container } = render( - <ImageUploaderInChunkWrapper value={[]} onChange={onChange} />, - ) + const { container } = render(<ImageUploaderInChunkWrapper value={[]} onChange={onChange} />) expect(container.firstChild).toBeInTheDocument() }) @@ -50,11 +50,7 @@ describe('ImageUploaderInChunk', () => { it('should apply custom className', () => { const onChange = vi.fn() const { container } = render( - <ImageUploaderInChunkWrapper - value={[]} - onChange={onChange} - className="custom-class" - />, + <ImageUploaderInChunkWrapper value={[]} onChange={onChange} className="custom-class" />, ) expect(container.firstChild).toHaveClass('custom-class') }) @@ -149,9 +145,7 @@ describe('ImageUploaderInChunk', () => { describe('Edge Cases', () => { it('should handle empty files array', () => { const onChange = vi.fn() - const { container } = render( - <ImageUploaderInChunkWrapper value={[]} onChange={onChange} />, - ) + const { container } = render(<ImageUploaderInChunkWrapper value={[]} onChange={onChange} />) expect(container.firstChild).toBeInTheDocument() }) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.tsx index aaf9540c8d0a94..126c495d02862d 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.tsx @@ -26,7 +26,7 @@ const ImageUploader = () => { className="hidden" type="file" multiple - accept={ACCEPT_TYPES.map(ext => `.${ext}`).join(',')} + accept={ACCEPT_TYPES.map((ext) => `.${ext}`).join(',')} onChange={fileChangeHandle} /> <div @@ -39,17 +39,14 @@ const ImageUploader = () => { <div className="flex items-center justify-center gap-x-2 system-sm-medium text-text-secondary"> <RiUploadCloud2Line className="size-5 text-text-tertiary" /> <div> - <span>{t($ => $['imageUploader.button'], { ns: 'dataset' })}</span> - <span - className="ml-1 cursor-pointer text-text-accent" - onClick={selectHandle} - > - {t($ => $['imageUploader.browse'], { ns: 'dataset' })} + <span>{t(($) => $['imageUploader.button'], { ns: 'dataset' })}</span> + <span className="ml-1 cursor-pointer text-text-accent" onClick={selectHandle}> + {t(($) => $['imageUploader.browse'], { ns: 'dataset' })} </span> </div> </div> <div className="system-xs-regular"> - {t($ => $['imageUploader.tip'], { + {t(($) => $['imageUploader.tip'], { ns: 'dataset', size: fileUploadConfig.imageFileSizeLimit, supportTypes: ACCEPT_TYPES.join(', '), diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-item.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-item.tsx index 469a475751beb2..3febcf51cd8341 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-item.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-item.tsx @@ -1,13 +1,8 @@ import type { FileEntity } from '../types' import { Button } from '@langgenius/dify-ui/button' import { ProgressCircle } from '@langgenius/dify-ui/progress' -import { - RiCloseLine, -} from '@remixicon/react' -import { - memo, - useCallback, -} from 'react' +import { RiCloseLine } from '@remixicon/react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import FileImageRender from '@/app/components/base/file-uploader/file-image-render' import { ReplayLine } from '@/app/components/base/icons/src/vender/other' @@ -20,74 +15,68 @@ type ImageItemProps = { onReUpload?: (fileId: string) => void onPreview?: (fileId: string) => void } -const ImageItem = ({ - file, - showDeleteAction, - onRemove, - onReUpload, - onPreview, -}: ImageItemProps) => { +const ImageItem = ({ file, showDeleteAction, onRemove, onReUpload, onPreview }: ImageItemProps) => { const { t } = useTranslation() const { id, progress, base64Url, sourceUrl } = file - const handlePreview = useCallback((e: React.MouseEvent<HTMLDivElement>) => { - e.stopPropagation() - e.preventDefault() - onPreview?.(id) - }, [onPreview, id]) + const handlePreview = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + e.stopPropagation() + e.preventDefault() + onPreview?.(id) + }, + [onPreview, id], + ) - const handleRemove = useCallback((e: React.MouseEvent<HTMLButtonElement>) => { - e.stopPropagation() - e.preventDefault() - onRemove?.(id) - }, [onRemove, id]) + const handleRemove = useCallback( + (e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation() + e.preventDefault() + onRemove?.(id) + }, + [onRemove, id], + ) - const handleReUpload = useCallback((e: React.MouseEvent<HTMLDivElement>) => { - e.stopPropagation() - e.preventDefault() - onReUpload?.(id) - }, [onReUpload, id]) + const handleReUpload = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + e.stopPropagation() + e.preventDefault() + onReUpload?.(id) + }, + [onReUpload, id], + ) return ( - <div - className="group/file-image relative cursor-pointer" - onClick={handlePreview} - > - { - showDeleteAction && ( - <Button - className="absolute -top-1.5 -right-1.5 z-11 hidden size-5 rounded-full p-0 group-hover/file-image:flex" - onClick={handleRemove} - > - <RiCloseLine className="size-4 text-components-button-secondary-text" /> - </Button> - ) - } + <div className="group/file-image relative cursor-pointer" onClick={handlePreview}> + {showDeleteAction && ( + <Button + className="absolute -top-1.5 -right-1.5 z-11 hidden size-5 rounded-full p-0 group-hover/file-image:flex" + onClick={handleRemove} + > + <RiCloseLine className="size-4 text-components-button-secondary-text" /> + </Button> + )} <FileImageRender className="h-[68px] w-[68px] shadow-md" imageUrl={base64Url || sourceUrl || ''} /> - { - progress >= 0 && !fileIsUploaded(file) && ( - <div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-effects-image-frame bg-background-overlay-alt"> - <ProgressCircle - value={progress} - color="white" - aria-label={t($ => $.uploading, { ns: 'custom' })} - /> - </div> - ) - } - { - progress === -1 && ( - <div - className="absolute inset-0 z-10 flex items-center justify-center border-2 border-state-destructive-border bg-background-overlay-destructive" - onClick={handleReUpload} - > - <ReplayLine className="size-5 text-text-primary-on-surface" /> - </div> - ) - } + {progress >= 0 && !fileIsUploaded(file) && ( + <div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-effects-image-frame bg-background-overlay-alt"> + <ProgressCircle + value={progress} + color="white" + aria-label={t(($) => $.uploading, { ns: 'custom' })} + /> + </div> + )} + {progress === -1 && ( + <div + className="absolute inset-0 z-10 flex items-center justify-center border-2 border-state-destructive-border bg-background-overlay-destructive" + onClick={handleReUpload} + > + <ReplayLine className="size-5 text-text-primary-on-surface" /> + </div> + )} </div> ) } diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/index.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/index.tsx index bfb4e654b22c19..e878c62a44d4e2 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/index.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/index.tsx @@ -4,10 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { useCallback, useState } from 'react' import ImagePreviewer from '@/app/components/datasets/common/image-previewer' import { useUpload } from '../hooks/use-upload' -import { - FileContextProvider, - useFileStoreWithSelector, -} from '../store' +import { FileContextProvider, useFileStoreWithSelector } from '../store' import ImageInput from './image-input' import FileItem from './image-item' @@ -15,51 +12,47 @@ type ImageUploaderInChunkProps = { disabled?: boolean className?: string } -const ImageUploaderInChunk = ({ - disabled, - className, -}: ImageUploaderInChunkProps) => { - const files = useFileStoreWithSelector(s => s.files) +const ImageUploaderInChunk = ({ disabled, className }: ImageUploaderInChunkProps) => { + const files = useFileStoreWithSelector((s) => s.files) const [previewIndex, setPreviewIndex] = useState(0) const [previewImages, setPreviewImages] = useState<ImageInfo[]>([]) - const handleImagePreview = useCallback((fileId: string) => { - const index = files.findIndex(item => item.id === fileId) - if (index === -1) - return - setPreviewIndex(index) - setPreviewImages(files.map(item => ({ - url: item.base64Url || item.sourceUrl || '', - name: item.name, - size: item.size, - }))) - }, [files]) + const handleImagePreview = useCallback( + (fileId: string) => { + const index = files.findIndex((item) => item.id === fileId) + if (index === -1) return + setPreviewIndex(index) + setPreviewImages( + files.map((item) => ({ + url: item.base64Url || item.sourceUrl || '', + name: item.name, + size: item.size, + })), + ) + }, + [files], + ) const handleClosePreview = useCallback(() => { setPreviewImages([]) }, []) - const { - handleRemoveFile, - handleReUploadFile, - } = useUpload() + const { handleRemoveFile, handleReUploadFile } = useUpload() return ( <div className={cn('w-full', className)}> {!disabled && <ImageInput />} <div className="flex flex-wrap gap-2 py-1"> - { - files.map(file => ( - <FileItem - key={file.id} - file={file} - showDeleteAction={!disabled} - onRemove={handleRemoveFile} - onReUpload={handleReUploadFile} - onPreview={handleImagePreview} - /> - )) - } + {files.map((file) => ( + <FileItem + key={file.id} + file={file} + showDeleteAction={!disabled} + onRemove={handleRemoveFile} + onReUpload={handleReUploadFile} + onPreview={handleImagePreview} + /> + ))} </div> {previewImages.length > 0 && ( <ImagePreviewer @@ -83,10 +76,7 @@ const ImageUploaderInChunkWrapper = ({ ...props }: ImageUploaderInChunkWrapperProps) => { return ( - <FileContextProvider - value={value} - onChange={onChange} - > + <FileContextProvider value={value} onChange={onChange}> <ImageUploaderInChunk {...props} /> </FileContextProvider> ) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/image-input.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/image-input.spec.tsx index ddf8f2f55c21f5..a8ab5482f47e5c 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/image-input.spec.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/image-input.spec.tsx @@ -15,11 +15,7 @@ vi.mock('@/service/use-common', () => ({ })) const renderWithProvider = (ui: React.ReactElement, initialFiles: FileEntity[] = []) => { - return render( - <FileContextProvider value={initialFiles}> - {ui} - </FileContextProvider>, - ) + return render(<FileContextProvider value={initialFiles}>{ui}</FileContextProvider>) } describe('ImageInput (image-uploader-in-retrieval-testing)', () => { @@ -93,8 +89,7 @@ describe('ImageInput (image-uploader-in-retrieval-testing)', () => { const input = document.querySelector('input[type="file"]') as HTMLInputElement const clickSpy = vi.spyOn(input, 'click') - if (clickableArea) - fireEvent.click(clickableArea) + if (clickableArea) fireEvent.click(clickableArea) expect(clickSpy).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/image-item.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/image-item.spec.tsx index e66875854c42e9..7a40afb26698e6 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/image-item.spec.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/image-item.spec.tsx @@ -3,15 +3,16 @@ import { fireEvent, render } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import ImageItem from '../image-item' -const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({ - id: 'test-id', - name: 'test.png', - progress: 100, - base64Url: 'data:image/png;base64,test', - sourceUrl: 'https://example.com/test.png', - size: 1024, - ...overrides, -} as FileEntity) +const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => + ({ + id: 'test-id', + name: 'test.png', + progress: 100, + base64Url: 'data:image/png;base64,test', + sourceUrl: 'https://example.com/test.png', + size: 1024, + ...overrides, + }) as FileEntity describe('ImageItem (image-uploader-in-retrieval-testing)', () => { describe('Rendering', () => { @@ -31,9 +32,7 @@ describe('ImageItem (image-uploader-in-retrieval-testing)', () => { describe('Props', () => { it('should show delete button when showDeleteAction is true', () => { const file = createMockFile() - const { container } = render( - <ImageItem file={file} showDeleteAction onRemove={() => {}} />, - ) + const { container } = render(<ImageItem file={file} showDeleteAction onRemove={() => {}} />) const deleteButton = container.querySelector('button') expect(deleteButton).toBeInTheDocument() }) @@ -82,9 +81,7 @@ describe('ImageItem (image-uploader-in-retrieval-testing)', () => { it('should call onRemove when delete button is clicked', () => { const onRemove = vi.fn() const file = createMockFile() - const { container } = render( - <ImageItem file={file} showDeleteAction onRemove={onRemove} />, - ) + const { container } = render(<ImageItem file={file} showDeleteAction onRemove={onRemove} />) const deleteButton = container.querySelector('button') if (deleteButton) { @@ -129,8 +126,7 @@ describe('ImageItem (image-uploader-in-retrieval-testing)', () => { expect(() => { const imageContainer = container.querySelector('.group\\/file-image') - if (imageContainer) - fireEvent.click(imageContainer) + if (imageContainer) fireEvent.click(imageContainer) }).not.toThrow() }) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/index.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/index.spec.tsx index 1d369295efab7d..daffb04c42113d 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/index.spec.tsx @@ -16,7 +16,9 @@ vi.mock('@/service/use-common', () => ({ vi.mock('@/app/components/datasets/common/image-previewer', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="image-previewer"> - <button data-testid="close-preview" onClick={onClose}>Close</button> + <button data-testid="close-preview" onClick={onClose}> + Close + </button> </div> ), })) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx index 84b91e7b984d70..cd99d1d15a2926 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx @@ -8,14 +8,9 @@ import { useFileStoreWithSelector } from '../store' const ImageUploader = () => { const { t } = useTranslation() - const files = useFileStoreWithSelector(s => s.files) + const files = useFileStoreWithSelector((s) => s.files) - const { - fileUploadConfig, - uploaderRef, - fileChangeHandle, - selectHandle, - } = useUpload() + const { fileUploadConfig, uploaderRef, fileChangeHandle, selectHandle } = useUpload() return ( <div> @@ -25,25 +20,25 @@ const ImageUploader = () => { className="hidden" type="file" multiple - accept={ACCEPT_TYPES.map(ext => `.${ext}`).join(',')} + accept={ACCEPT_TYPES.map((ext) => `.${ext}`).join(',')} onChange={fileChangeHandle} /> <div className="flex flex-wrap gap-1"> <Tooltip disabled={files.length === 0}> <TooltipTrigger - render={( + render={ <div className="group flex cursor-pointer items-center gap-x-2" onClick={selectHandle} /> - )} + } > <div className="flex size-8 items-center justify-center rounded-lg border border-dashed border-components-dropzone-border bg-components-button-tertiary-bg group-hover:bg-components-button-tertiary-bg-hover"> <RiImageAddLine className="size-4 text-text-tertiary" /> </div> {files.length === 0 && ( <span className="system-sm-regular text-text-quaternary group-hover:text-text-tertiary"> - {t($ => $['imageUploader.tip'], { + {t(($) => $['imageUploader.tip'], { ns: 'datasetHitTesting', size: fileUploadConfig.imageFileSizeLimit, batchCount: fileUploadConfig.imageFileBatchLimit, @@ -51,8 +46,11 @@ const ImageUploader = () => { </span> )} </TooltipTrigger> - <TooltipContent sideOffset={4} className="rounded-lg p-1.5 system-xs-medium text-text-secondary"> - {t($ => $['imageUploader.tooltip'], { + <TooltipContent + sideOffset={4} + className="rounded-lg p-1.5 system-xs-medium text-text-secondary" + > + {t(($) => $['imageUploader.tooltip'], { ns: 'datasetHitTesting', size: fileUploadConfig.imageFileSizeLimit, batchCount: fileUploadConfig.imageFileBatchLimit, diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-item.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-item.tsx index dea5e6dd170d8d..b32a1cacadbf3e 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-item.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-item.tsx @@ -1,13 +1,8 @@ import type { FileEntity } from '../types' import { Button } from '@langgenius/dify-ui/button' import { ProgressCircle } from '@langgenius/dify-ui/progress' -import { - RiCloseLine, -} from '@remixicon/react' -import { - memo, - useCallback, -} from 'react' +import { RiCloseLine } from '@remixicon/react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import FileImageRender from '@/app/components/base/file-uploader/file-image-render' import { ReplayLine } from '@/app/components/base/icons/src/vender/other' @@ -20,74 +15,65 @@ type ImageItemProps = { onReUpload?: (fileId: string) => void onPreview?: (fileId: string) => void } -const ImageItem = ({ - file, - showDeleteAction, - onRemove, - onReUpload, - onPreview, -}: ImageItemProps) => { +const ImageItem = ({ file, showDeleteAction, onRemove, onReUpload, onPreview }: ImageItemProps) => { const { t } = useTranslation() const { id, progress, base64Url, sourceUrl } = file - const handlePreview = useCallback((e: React.MouseEvent<HTMLDivElement>) => { - e.stopPropagation() - e.preventDefault() - onPreview?.(id) - }, [onPreview, id]) + const handlePreview = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + e.stopPropagation() + e.preventDefault() + onPreview?.(id) + }, + [onPreview, id], + ) - const handleRemove = useCallback((e: React.MouseEvent<HTMLButtonElement>) => { - e.stopPropagation() - e.preventDefault() - onRemove?.(id) - }, [onRemove, id]) + const handleRemove = useCallback( + (e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation() + e.preventDefault() + onRemove?.(id) + }, + [onRemove, id], + ) - const handleReUpload = useCallback((e: React.MouseEvent<HTMLDivElement>) => { - e.stopPropagation() - e.preventDefault() - onReUpload?.(id) - }, [onReUpload, id]) + const handleReUpload = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + e.stopPropagation() + e.preventDefault() + onReUpload?.(id) + }, + [onReUpload, id], + ) return ( - <div - className="group/file-image relative cursor-pointer" - onClick={handlePreview} - > - { - showDeleteAction && ( - <Button - className="absolute -top-1.5 -right-1.5 z-11 hidden size-5 rounded-full p-0 group-hover/file-image:flex" - onClick={handleRemove} - > - <RiCloseLine className="size-4 text-components-button-secondary-text" /> - </Button> - ) - } - <FileImageRender - className="size-20 shadow-md" - imageUrl={base64Url || sourceUrl || ''} - /> - { - progress >= 0 && !fileIsUploaded(file) && ( - <div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-effects-image-frame bg-background-overlay-alt"> - <ProgressCircle - value={progress} - color="white" - aria-label={t($ => $.uploading, { ns: 'custom' })} - /> - </div> - ) - } - { - progress === -1 && ( - <div - className="absolute inset-0 z-10 flex items-center justify-center border-2 border-state-destructive-border bg-background-overlay-destructive" - onClick={handleReUpload} - > - <ReplayLine className="size-5 text-text-primary-on-surface" /> - </div> - ) - } + <div className="group/file-image relative cursor-pointer" onClick={handlePreview}> + {showDeleteAction && ( + <Button + className="absolute -top-1.5 -right-1.5 z-11 hidden size-5 rounded-full p-0 group-hover/file-image:flex" + onClick={handleRemove} + > + <RiCloseLine className="size-4 text-components-button-secondary-text" /> + </Button> + )} + <FileImageRender className="size-20 shadow-md" imageUrl={base64Url || sourceUrl || ''} /> + {progress >= 0 && !fileIsUploaded(file) && ( + <div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-effects-image-frame bg-background-overlay-alt"> + <ProgressCircle + value={progress} + color="white" + aria-label={t(($) => $.uploading, { ns: 'custom' })} + /> + </div> + )} + {progress === -1 && ( + <div + className="absolute inset-0 z-10 flex items-center justify-center border-2 border-state-destructive-border bg-background-overlay-destructive" + onClick={handleReUpload} + > + <ReplayLine className="size-5 text-text-primary-on-surface" /> + </div> + )} </div> ) } diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/index.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/index.tsx index 622ca3235a1f17..ba7ac3e7d61813 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/index.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/index.tsx @@ -1,17 +1,11 @@ import type { FileEntity } from '../types' import type { ImageInfo } from '@/app/components/datasets/common/image-previewer' import { cn } from '@langgenius/dify-ui/cn' -import { - useCallback, - useState, -} from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ImagePreviewer from '@/app/components/datasets/common/image-previewer' import { useUpload } from '../hooks/use-upload' -import { - FileContextProvider, - useFileStoreWithSelector, -} from '../store' +import { FileContextProvider, useFileStoreWithSelector } from '../store' import ImageInput from './image-input' import ImageItem from './image-item' @@ -30,65 +24,54 @@ const ImageUploaderInRetrievalTesting = ({ actionAreaClassName, }: ImageUploaderInRetrievalTestingProps) => { const { t } = useTranslation() - const files = useFileStoreWithSelector(s => s.files) + const files = useFileStoreWithSelector((s) => s.files) const [previewIndex, setPreviewIndex] = useState(0) const [previewImages, setPreviewImages] = useState<ImageInfo[]>([]) - const { - dragging, - dragRef, - dropRef, - handleRemoveFile, - handleReUploadFile, - } = useUpload() + const { dragging, dragRef, dropRef, handleRemoveFile, handleReUploadFile } = useUpload() - const handleImagePreview = useCallback((fileId: string) => { - const index = files.findIndex(item => item.id === fileId) - if (index === -1) - return - setPreviewIndex(index) - setPreviewImages(files.map(item => ({ - url: item.base64Url || item.sourceUrl || '', - name: item.name, - size: item.size, - }))) - }, [files]) + const handleImagePreview = useCallback( + (fileId: string) => { + const index = files.findIndex((item) => item.id === fileId) + if (index === -1) return + setPreviewIndex(index) + setPreviewImages( + files.map((item) => ({ + url: item.base64Url || item.sourceUrl || '', + name: item.name, + size: item.size, + })), + ) + }, + [files], + ) const handleClosePreview = useCallback(() => { setPreviewImages([]) }, []) return ( - <div - ref={dropRef} - className={cn('relative flex w-full flex-col', className)} - > + <div ref={dropRef} className={cn('relative flex w-full flex-col', className)}> {dragging && ( - <div - className="absolute inset-0.5 z-10 flex items-center justify-center rounded-lg border-[1.5px] border-dashed border-components-dropzone-border-accent bg-components-dropzone-bg-accent" - > - <div>{t($ => $['imageUploader.dropZoneTip'], { ns: 'datasetHitTesting' })}</div> + <div className="absolute inset-0.5 z-10 flex items-center justify-center rounded-lg border-[1.5px] border-dashed border-components-dropzone-border-accent bg-components-dropzone-bg-accent"> + <div>{t(($) => $['imageUploader.dropZoneTip'], { ns: 'datasetHitTesting' })}</div> <div ref={dragRef} className="absolute inset-0" /> </div> )} {textArea} - { - showUploader && !!files.length && ( - <div className="flex flex-wrap gap-1 bg-background-default px-4 py-2"> - { - files.map(file => ( - <ImageItem - key={file.id} - file={file} - showDeleteAction - onRemove={handleRemoveFile} - onReUpload={handleReUploadFile} - onPreview={handleImagePreview} - /> - )) - } - </div> - ) - } + {showUploader && !!files.length && ( + <div className="flex flex-wrap gap-1 bg-background-default px-4 py-2"> + {files.map((file) => ( + <ImageItem + key={file.id} + file={file} + showDeleteAction + onRemove={handleRemoveFile} + onReUpload={handleReUploadFile} + onPreview={handleImagePreview} + /> + ))} + </div> + )} <div className={cn( 'flex', @@ -121,10 +104,7 @@ const ImageUploaderInRetrievalTestingWrapper = ({ ...props }: ImageUploaderInRetrievalTestingWrapperProps) => { return ( - <FileContextProvider - value={value} - onChange={onChange} - > + <FileContextProvider value={value} onChange={onChange}> <ImageUploaderInRetrievalTesting {...props} /> </FileContextProvider> ) diff --git a/web/app/components/datasets/common/image-uploader/store.tsx b/web/app/components/datasets/common/image-uploader/store.tsx index f14cc9667fbe24..b4de86d4ebc7b2 100644 --- a/web/app/components/datasets/common/image-uploader/store.tsx +++ b/web/app/components/datasets/common/image-uploader/store.tsx @@ -1,15 +1,6 @@ -import type { - FileEntity, -} from './types' -import { - createContext, - use, - useRef, -} from 'react' -import { - create, - useStore, -} from 'zustand' +import type { FileEntity } from './types' +import { createContext, use, useRef } from 'react' +import { create, useStore } from 'zustand' type Shape = { files: FileEntity[] @@ -20,7 +11,7 @@ export const createFileStore = ( value: FileEntity[] = [], onChange?: (files: FileEntity[]) => void, ) => { - return create<Shape>(set => ({ + return create<Shape>((set) => ({ files: value ? [...value] : [], setFiles: (files) => { set({ files }) @@ -34,8 +25,7 @@ const FileContext = createContext<FileStore | null>(null) export function useFileStoreWithSelector<T>(selector: (state: Shape) => T): T { const store = use(FileContext) - if (!store) - throw new Error('Missing FileContext.Provider in the tree') + if (!store) throw new Error('Missing FileContext.Provider in the tree') return useStore(store, selector) } @@ -49,19 +39,10 @@ type FileProviderProps = { value?: FileEntity[] onChange?: (files: FileEntity[]) => void } -export const FileContextProvider = ({ - children, - value, - onChange, -}: FileProviderProps) => { +export const FileContextProvider = ({ children, value, onChange }: FileProviderProps) => { const storeRef = useRef<FileStore | undefined>(undefined) - if (!storeRef.current) - storeRef.current = createFileStore(value, onChange) + if (!storeRef.current) storeRef.current = createFileStore(value, onChange) - return ( - <FileContext.Provider value={storeRef.current}> - {children} - </FileContext.Provider> - ) + return <FileContext.Provider value={storeRef.current}>{children}</FileContext.Provider> } diff --git a/web/app/components/datasets/common/image-uploader/utils.ts b/web/app/components/datasets/common/image-uploader/utils.ts index 952c82a0c43d23..6bf02494504da9 100644 --- a/web/app/components/datasets/common/image-uploader/utils.ts +++ b/web/app/components/datasets/common/image-uploader/utils.ts @@ -7,8 +7,7 @@ import { } from './constants' export const getFileType = (currentFile: File) => { - if (!currentFile) - return '' + if (!currentFile) return '' const arr = currentFile.name.split('.') return arr[arr.length - 1]! @@ -21,54 +20,47 @@ type FileWithPath = { export const traverseFileEntry = (entry: FileSystemEntry, prefix = ''): Promise<FileWithPath[]> => { return new Promise((resolve) => { if (entry.isFile) { - (entry as FileSystemFileEntry).file((file: FileWithPath) => { + ;(entry as FileSystemFileEntry).file((file: FileWithPath) => { file.relativePath = `${prefix}${file.name}` resolve([file]) }) - } - else if (entry.isDirectory) { + } else if (entry.isDirectory) { const reader = (entry as FileSystemDirectoryEntry).createReader() const entries: FileSystemEntry[] = [] const read = () => { reader.readEntries(async (results: FileSystemEntry[]) => { if (!results.length) { const files = await Promise.all( - entries.map(ent => - traverseFileEntry(ent, `${prefix}${entry.name}/`), - ), + entries.map((ent) => traverseFileEntry(ent, `${prefix}${entry.name}/`)), ) resolve(files.flat()) - } - else { + } else { entries.push(...results) read() } }) } read() - } - else { + } else { resolve([]) } }) } export const fileIsUploaded = (file: FileEntity) => { - if (file.uploadedId || file.progress === 100) - return true + if (file.uploadedId || file.progress === 100) return true } const getNumberValue = (value: number | string | undefined | null): number => { - if (value === undefined || value === null) - return 0 - if (typeof value === 'number') - return value - if (typeof value === 'string') - return Number(value) + if (value === undefined || value === null) return 0 + if (typeof value === 'number') return value + if (typeof value === 'string') return Number(value) return 0 } -export const getFileUploadConfig = (fileUploadConfigResponse: FileUploadConfigResponse | undefined) => { +export const getFileUploadConfig = ( + fileUploadConfigResponse: FileUploadConfigResponse | undefined, +) => { if (!fileUploadConfigResponse) { return { imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT, @@ -86,7 +78,11 @@ export const getFileUploadConfig = (fileUploadConfigResponse: FileUploadConfigRe const singleChunkAttachmentLimit = getNumberValue(single_chunk_attachment_limit) return { imageFileSizeLimit: imageFileSizeLimit > 0 ? imageFileSizeLimit : DEFAULT_IMAGE_FILE_SIZE_LIMIT, - imageFileBatchLimit: imageFileBatchLimit > 0 ? imageFileBatchLimit : DEFAULT_IMAGE_FILE_BATCH_LIMIT, - singleChunkAttachmentLimit: singleChunkAttachmentLimit > 0 ? singleChunkAttachmentLimit : DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT, + imageFileBatchLimit: + imageFileBatchLimit > 0 ? imageFileBatchLimit : DEFAULT_IMAGE_FILE_BATCH_LIMIT, + singleChunkAttachmentLimit: + singleChunkAttachmentLimit > 0 + ? singleChunkAttachmentLimit + : DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT, } } diff --git a/web/app/components/datasets/common/retrieval-method-config/__tests__/index.spec.tsx b/web/app/components/datasets/common/retrieval-method-config/__tests__/index.spec.tsx index c31be481363b80..487cb1a9c04cb1 100644 --- a/web/app/components/datasets/common/retrieval-method-config/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/retrieval-method-config/__tests__/index.spec.tsx @@ -1,11 +1,7 @@ import type { RetrievalConfig } from '@/types/app' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import { - DEFAULT_WEIGHTED_SCORE, - RerankingModeEnum, - WeightedScoreEnum, -} from '@/models/datasets' +import { DEFAULT_WEIGHTED_SCORE, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import RetrievalMethodConfig from '../index' @@ -23,7 +19,7 @@ vi.mock('@/context/provider-context', () => ({ })) // Mock model hooks with controllable return values -let mockRerankDefaultModel: { provider: { provider: string }, model: string } | undefined = { +let mockRerankDefaultModel: { provider: { provider: string }; model: string } | undefined = { provider: { provider: 'test-provider' }, model: 'test-rerank-model', } @@ -38,7 +34,12 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () // Mock child component RetrievalParamConfig to simplify testing vi.mock('../../retrieval-param-config', () => ({ - default: ({ type, value, onChange, showMultiModalTip }: { + default: ({ + type, + value, + onChange, + showMultiModalTip, + }: { type: RETRIEVE_METHOD value: RetrievalConfig onChange: (v: RetrievalConfig) => void @@ -72,7 +73,9 @@ const createMockRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): Re }) // Helper to render component with default props -const renderComponent = (props: Partial<React.ComponentProps<typeof RetrievalMethodConfig>> = {}) => { +const renderComponent = ( + props: Partial<React.ComponentProps<typeof RetrievalMethodConfig>> = {}, +) => { const defaultProps = { value: createMockRetrievalConfig(), onChange: vi.fn(), @@ -161,7 +164,9 @@ describe('RetrievalMethodConfig', () => { }) expect(screen.getByTestId('retrieval-param-config-semantic_search')).toBeInTheDocument() - expect(screen.queryByTestId('retrieval-param-config-full_text_search')).not.toBeInTheDocument() + expect( + screen.queryByTestId('retrieval-param-config-full_text_search'), + ).not.toBeInTheDocument() expect(screen.queryByTestId('retrieval-param-config-hybrid_search')).not.toBeInTheDocument() }) @@ -181,7 +186,9 @@ describe('RetrievalMethodConfig', () => { }) expect(screen.queryByTestId('retrieval-param-config-semantic_search')).not.toBeInTheDocument() - expect(screen.queryByTestId('retrieval-param-config-full_text_search')).not.toBeInTheDocument() + expect( + screen.queryByTestId('retrieval-param-config-full_text_search'), + ).not.toBeInTheDocument() expect(screen.getByTestId('retrieval-param-config-hybrid_search')).toBeInTheDocument() }) }) @@ -209,14 +216,18 @@ describe('RetrievalMethodConfig', () => { renderComponent({ disabled: true }) // When disabled, clicking should not trigger onChange - const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]') + const semanticOption = screen + .getByText('dataset.retrieval.semantic_search.title') + .closest('div[class*="cursor"]') expect(semanticOption).toHaveClass('cursor-not-allowed') }) it('should default disabled to false', () => { renderComponent() - const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]') + const semanticOption = screen + .getByText('dataset.retrieval.semantic_search.title') + .closest('div[class*="cursor"]') expect(semanticOption).not.toHaveClass('cursor-not-allowed') }) }) @@ -230,7 +241,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + const semanticOption = screen + .getByText('dataset.retrieval.semantic_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(semanticOption!) expect(onChange).toHaveBeenCalledTimes(1) @@ -249,7 +262,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const fullTextOption = screen.getByText('dataset.retrieval.full_text_search.title').closest('div[class*="cursor-pointer"]') + const fullTextOption = screen + .getByText('dataset.retrieval.full_text_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(fullTextOption!) expect(onChange).toHaveBeenCalledTimes(1) @@ -268,7 +283,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + const hybridOption = screen + .getByText('dataset.retrieval.hybrid_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(hybridOption!) expect(onChange).toHaveBeenCalledTimes(1) @@ -287,7 +304,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + const semanticOption = screen + .getByText('dataset.retrieval.semantic_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(semanticOption!) expect(onChange).not.toHaveBeenCalled() @@ -301,7 +320,9 @@ describe('RetrievalMethodConfig', () => { disabled: true, }) - const fullTextOption = screen.getByText('dataset.retrieval.full_text_search.title').closest('div[class*="cursor"]') + const fullTextOption = screen + .getByText('dataset.retrieval.full_text_search.title') + .closest('div[class*="cursor"]') fireEvent.click(fullTextOption!) expect(onChange).not.toHaveBeenCalled() @@ -340,7 +361,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + const semanticOption = screen + .getByText('dataset.retrieval.semantic_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(semanticOption!) expect(onChange).toHaveBeenCalledWith( @@ -368,7 +391,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + const semanticOption = screen + .getByText('dataset.retrieval.semantic_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(semanticOption!) expect(onChange).toHaveBeenCalledWith( @@ -393,7 +418,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + const semanticOption = screen + .getByText('dataset.retrieval.semantic_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(semanticOption!) expect(onChange).toHaveBeenCalledWith( @@ -416,7 +443,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + const hybridOption = screen + .getByText('dataset.retrieval.hybrid_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(hybridOption!) expect(onChange).toHaveBeenCalledWith( @@ -441,7 +470,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + const hybridOption = screen + .getByText('dataset.retrieval.hybrid_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(hybridOption!) expect(onChange).toHaveBeenCalledWith( @@ -461,7 +492,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + const hybridOption = screen + .getByText('dataset.retrieval.hybrid_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(hybridOption!) expect(onChange).toHaveBeenCalledWith( @@ -502,7 +535,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + const hybridOption = screen + .getByText('dataset.retrieval.hybrid_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(hybridOption!) expect(onChange).toHaveBeenCalledWith( @@ -525,7 +560,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + const hybridOption = screen + .getByText('dataset.retrieval.hybrid_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(hybridOption!) expect(onChange).toHaveBeenCalledWith( @@ -542,14 +579,20 @@ describe('RetrievalMethodConfig', () => { describe('Callback Stability', () => { it('should maintain stable onSwitch callback when value changes', () => { const onChange = vi.fn() - const value1 = createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText, top_k: 4 }) - const value2 = createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText, top_k: 8 }) + const value1 = createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + top_k: 4, + }) + const value2 = createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + top_k: 8, + }) - const { rerender } = render( - <RetrievalMethodConfig value={value1} onChange={onChange} />, - ) + const { rerender } = render(<RetrievalMethodConfig value={value1} onChange={onChange} />) - const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + const semanticOption = screen + .getByText('dataset.retrieval.semantic_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(semanticOption!) expect(onChange).toHaveBeenCalledTimes(1) @@ -565,13 +608,13 @@ describe('RetrievalMethodConfig', () => { const onChange2 = vi.fn() const value = createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText }) - const { rerender } = render( - <RetrievalMethodConfig value={value} onChange={onChange1} />, - ) + const { rerender } = render(<RetrievalMethodConfig value={value} onChange={onChange1} />) rerender(<RetrievalMethodConfig value={value} onChange={onChange2} />) - const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + const semanticOption = screen + .getByText('dataset.retrieval.semantic_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(semanticOption!) expect(onChange1).not.toHaveBeenCalled() @@ -592,9 +635,7 @@ describe('RetrievalMethodConfig', () => { const onChange = vi.fn() const value = createMockRetrievalConfig() - const { rerender } = render( - <RetrievalMethodConfig value={value} onChange={onChange} />, - ) + const { rerender } = render(<RetrievalMethodConfig value={value} onChange={onChange} />) // Rerender with same props reference rerender(<RetrievalMethodConfig value={value} onChange={onChange} />) @@ -637,7 +678,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + const semanticOption = screen + .getByText('dataset.retrieval.semantic_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(semanticOption!) expect(onChange).toHaveBeenCalledWith( @@ -666,7 +709,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + const hybridOption = screen + .getByText('dataset.retrieval.hybrid_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(hybridOption!) expect(onChange).toHaveBeenCalledWith( @@ -695,7 +740,9 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + const hybridOption = screen + .getByText('dataset.retrieval.hybrid_search.title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(hybridOption!) expect(onChange).toHaveBeenCalledWith( @@ -715,8 +762,12 @@ describe('RetrievalMethodConfig', () => { onChange, }) - const fullTextOption = screen.getByText('dataset.retrieval.full_text_search.title').closest('div[class*="cursor-pointer"]') - const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + const fullTextOption = screen + .getByText('dataset.retrieval.full_text_search.title') + .closest('div[class*="cursor-pointer"]') + const hybridOption = screen + .getByText('dataset.retrieval.hybrid_search.title') + .closest('div[class*="cursor-pointer"]') // Rapid clicks fireEvent.click(fullTextOption!) @@ -777,10 +828,7 @@ describe('RetrievalMethodConfig', () => { describe('Prop Variations', () => { it('should render with minimum required props', () => { const { container } = render( - <RetrievalMethodConfig - value={createMockRetrievalConfig()} - onChange={vi.fn()} - />, + <RetrievalMethodConfig value={createMockRetrievalConfig()} onChange={vi.fn()} />, ) expect(container.firstChild).toBeInTheDocument() @@ -800,23 +848,23 @@ describe('RetrievalMethodConfig', () => { describe('disabled prop variations', () => { it('should handle disabled=true', () => { renderComponent({ disabled: true }) - const option = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]') + const option = screen + .getByText('dataset.retrieval.semantic_search.title') + .closest('div[class*="cursor"]') expect(option).toHaveClass('cursor-not-allowed') }) it('should handle disabled=false', () => { renderComponent({ disabled: false }) - const option = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]') + const option = screen + .getByText('dataset.retrieval.semantic_search.title') + .closest('div[class*="cursor"]') expect(option).toHaveClass('cursor-pointer') }) }) describe('search_method variations', () => { - const methods = [ - RETRIEVE_METHOD.semantic, - RETRIEVE_METHOD.fullText, - RETRIEVE_METHOD.hybrid, - ] + const methods = [RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.fullText, RETRIEVE_METHOD.hybrid] it.each(methods)('should correctly highlight %s when active', (method) => { renderComponent({ @@ -858,7 +906,10 @@ describe('RetrievalMethodConfig', () => { const hybridCard = hybridTitle.closest('div[class*="cursor"]') // Should contain recommended badge from OptionCard - expect(hybridCard?.querySelector('[class*="badge"]') || screen.queryByText('datasetCreation.stepTwo.recommend')).toBeTruthy() + expect( + hybridCard?.querySelector('[class*="badge"]') || + screen.queryByText('datasetCreation.stepTwo.recommend'), + ).toBeTruthy() }) }) diff --git a/web/app/components/datasets/common/retrieval-method-config/index.tsx b/web/app/components/datasets/common/retrieval-method-config/index.tsx index 5a0af9c475541e..8b716f9fde4b28 100644 --- a/web/app/components/datasets/common/retrieval-method-config/index.tsx +++ b/web/app/components/datasets/common/retrieval-method-config/index.tsx @@ -4,15 +4,15 @@ import type { RetrievalConfig } from '@/types/app' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { FullTextSearch, HybridSearch, VectorSearch } from '@/app/components/base/icons/src/vender/knowledge' +import { + FullTextSearch, + HybridSearch, + VectorSearch, +} from '@/app/components/base/icons/src/vender/knowledge' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { useProviderContext } from '@/context/provider-context' -import { - DEFAULT_WEIGHTED_SCORE, - RerankingModeEnum, - WeightedScoreEnum, -} from '@/models/datasets' +import { DEFAULT_WEIGHTED_SCORE, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import { EffectColor } from '../../settings/chunk-structure/types' import OptionCard from '../../settings/option-card' @@ -33,64 +33,77 @@ const RetrievalMethodConfig: FC<Props> = ({ }) => { const { t } = useTranslation() const { supportRetrievalMethods } = useProviderContext() - const { - defaultModel: rerankDefaultModel, - currentModel: isRerankDefaultModelValid, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) + const { defaultModel: rerankDefaultModel, currentModel: isRerankDefaultModelValid } = + useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) - const onSwitch = useCallback((retrieveMethod: RETRIEVE_METHOD) => { - if ([RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.fullText].includes(retrieveMethod)) { - onChange({ - ...value, - search_method: retrieveMethod, - ...((!value.reranking_model.reranking_model_name || !value.reranking_model.reranking_provider_name) - ? { - reranking_model: { - reranking_provider_name: isRerankDefaultModelValid ? rerankDefaultModel?.provider?.provider ?? '' : '', - reranking_model_name: isRerankDefaultModelValid ? rerankDefaultModel?.model ?? '' : '', - }, - reranking_enable: !!isRerankDefaultModelValid, - } - : { - reranking_enable: true, - }), - }) - } - if (retrieveMethod === RETRIEVE_METHOD.hybrid) { - onChange({ - ...value, - search_method: retrieveMethod, - ...((!value.reranking_model.reranking_model_name || !value.reranking_model.reranking_provider_name) - ? { - reranking_model: { - reranking_provider_name: isRerankDefaultModelValid ? rerankDefaultModel?.provider?.provider ?? '' : '', - reranking_model_name: isRerankDefaultModelValid ? rerankDefaultModel?.model ?? '' : '', - }, - reranking_enable: !!isRerankDefaultModelValid, - reranking_mode: isRerankDefaultModelValid ? RerankingModeEnum.RerankingModel : RerankingModeEnum.WeightedScore, - } - : { - reranking_enable: true, - reranking_mode: RerankingModeEnum.RerankingModel, - }), - ...(!value.weights - ? { - weights: { - weight_type: WeightedScoreEnum.Customized, - vector_setting: { - vector_weight: DEFAULT_WEIGHTED_SCORE.other.semantic, - embedding_provider_name: '', - embedding_model_name: '', + const onSwitch = useCallback( + (retrieveMethod: RETRIEVE_METHOD) => { + if ([RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.fullText].includes(retrieveMethod)) { + onChange({ + ...value, + search_method: retrieveMethod, + ...(!value.reranking_model.reranking_model_name || + !value.reranking_model.reranking_provider_name + ? { + reranking_model: { + reranking_provider_name: isRerankDefaultModelValid + ? (rerankDefaultModel?.provider?.provider ?? '') + : '', + reranking_model_name: isRerankDefaultModelValid + ? (rerankDefaultModel?.model ?? '') + : '', }, - keyword_setting: { - keyword_weight: DEFAULT_WEIGHTED_SCORE.other.keyword, + reranking_enable: !!isRerankDefaultModelValid, + } + : { + reranking_enable: true, + }), + }) + } + if (retrieveMethod === RETRIEVE_METHOD.hybrid) { + onChange({ + ...value, + search_method: retrieveMethod, + ...(!value.reranking_model.reranking_model_name || + !value.reranking_model.reranking_provider_name + ? { + reranking_model: { + reranking_provider_name: isRerankDefaultModelValid + ? (rerankDefaultModel?.provider?.provider ?? '') + : '', + reranking_model_name: isRerankDefaultModelValid + ? (rerankDefaultModel?.model ?? '') + : '', }, - }, - } - : {}), - }) - } - }, [value, rerankDefaultModel, isRerankDefaultModelValid, onChange]) + reranking_enable: !!isRerankDefaultModelValid, + reranking_mode: isRerankDefaultModelValid + ? RerankingModeEnum.RerankingModel + : RerankingModeEnum.WeightedScore, + } + : { + reranking_enable: true, + reranking_mode: RerankingModeEnum.RerankingModel, + }), + ...(!value.weights + ? { + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: DEFAULT_WEIGHTED_SCORE.other.semantic, + embedding_provider_name: '', + embedding_model_name: '', + }, + keyword_setting: { + keyword_weight: DEFAULT_WEIGHTED_SCORE.other.keyword, + }, + }, + } + : {}), + }) + } + }, + [value, rerankDefaultModel, isRerankDefaultModelValid, onChange], + ) return ( <div className="flex flex-col gap-y-2"> @@ -100,8 +113,8 @@ const RetrievalMethodConfig: FC<Props> = ({ disabled={disabled} icon={<VectorSearch className="size-4" />} iconActiveColor="text-util-colors-purple-purple-600" - title={t($ => $['retrieval.semantic_search.title'], { ns: 'dataset' })} - description={t($ => $['retrieval.semantic_search.description'], { ns: 'dataset' })} + title={t(($) => $['retrieval.semantic_search.title'], { ns: 'dataset' })} + description={t(($) => $['retrieval.semantic_search.description'], { ns: 'dataset' })} isActive={value.search_method === RETRIEVE_METHOD.semantic} onClick={onSwitch} effectColor={EffectColor.purple} @@ -124,8 +137,8 @@ const RetrievalMethodConfig: FC<Props> = ({ disabled={disabled} icon={<FullTextSearch className="size-4" />} iconActiveColor="text-util-colors-purple-purple-600" - title={t($ => $['retrieval.full_text_search.title'], { ns: 'dataset' })} - description={t($ => $['retrieval.full_text_search.description'], { ns: 'dataset' })} + title={t(($) => $['retrieval.full_text_search.title'], { ns: 'dataset' })} + description={t(($) => $['retrieval.full_text_search.description'], { ns: 'dataset' })} isActive={value.search_method === RETRIEVE_METHOD.fullText} onClick={onSwitch} effectColor={EffectColor.purple} @@ -148,8 +161,8 @@ const RetrievalMethodConfig: FC<Props> = ({ disabled={disabled} icon={<HybridSearch className="size-4" />} iconActiveColor="text-util-colors-purple-purple-600" - title={t($ => $['retrieval.hybrid_search.title'], { ns: 'dataset' })} - description={t($ => $['retrieval.hybrid_search.description'], { ns: 'dataset' })} + title={t(($) => $['retrieval.hybrid_search.title'], { ns: 'dataset' })} + description={t(($) => $['retrieval.hybrid_search.description'], { ns: 'dataset' })} isActive={value.search_method === RETRIEVE_METHOD.hybrid} onClick={onSwitch} effectColor={EffectColor.purple} diff --git a/web/app/components/datasets/common/retrieval-method-info/index.tsx b/web/app/components/datasets/common/retrieval-method-info/index.tsx index cdc36670124f0a..0b342ca52913ac 100644 --- a/web/app/components/datasets/common/retrieval-method-info/index.tsx +++ b/web/app/components/datasets/common/retrieval-method-info/index.tsx @@ -3,11 +3,13 @@ import { RETRIEVE_METHOD } from '@/types/app' import { retrievalIcon } from '../../create/icons' export const getIcon = (type: RETRIEVE_METHOD) => { - return ({ - [RETRIEVE_METHOD.semantic]: retrievalIcon.vector, - [RETRIEVE_METHOD.fullText]: retrievalIcon.fullText, - [RETRIEVE_METHOD.hybrid]: retrievalIcon.hybrid, - [RETRIEVE_METHOD.invertedIndex]: retrievalIcon.vector, - [RETRIEVE_METHOD.keywordSearch]: retrievalIcon.vector, - })[type] || retrievalIcon.vector + return ( + { + [RETRIEVE_METHOD.semantic]: retrievalIcon.vector, + [RETRIEVE_METHOD.fullText]: retrievalIcon.fullText, + [RETRIEVE_METHOD.hybrid]: retrievalIcon.hybrid, + [RETRIEVE_METHOD.invertedIndex]: retrievalIcon.vector, + [RETRIEVE_METHOD.keywordSearch]: retrievalIcon.vector, + }[type] || retrievalIcon.vector + ) } diff --git a/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx b/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx index 93e5b4c712cdaf..023472d62803a4 100644 --- a/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx @@ -11,7 +11,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }, })) -let mockCurrentModel: { model: string, provider: string } | null = { +let mockCurrentModel: { model: string; provider: string } | null = { model: 'rerank-model', provider: 'rerank-provider', } @@ -33,8 +33,17 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ onSelect, defaultModel }: { onSelect: (v: { provider: string, model: string }) => void, defaultModel?: { provider: string, model: string } }) => ( - <div data-testid="model-selector" data-default-model={defaultModel ? JSON.stringify(defaultModel) : ''}> + default: ({ + onSelect, + defaultModel, + }: { + onSelect: (v: { provider: string; model: string }) => void + defaultModel?: { provider: string; model: string } + }) => ( + <div + data-testid="model-selector" + data-default-model={defaultModel ? JSON.stringify(defaultModel) : ''} + > <button data-testid="select-model-btn" onClick={() => onSelect({ provider: 'new-provider', model: 'new-model' })} @@ -46,12 +55,15 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec })) vi.mock('@/app/components/app/configuration/dataset-config/params-config/weighted-score', () => ({ - default: ({ value, onChange }: { value: { value: number[] }, onChange: (v: { value: number[] }) => void }) => ( + default: ({ + value, + onChange, + }: { + value: { value: number[] } + onChange: (v: { value: number[] }) => void + }) => ( <div data-testid="weighted-score" data-value={JSON.stringify(value)}> - <button - data-testid="change-weights-btn" - onClick={() => onChange({ value: [0.6, 0.4] })} - > + <button data-testid="change-weights-btn" onClick={() => onChange({ value: [0.6, 0.4] })}> Change Weights </button> </div> @@ -59,12 +71,9 @@ vi.mock('@/app/components/app/configuration/dataset-config/params-config/weighte })) vi.mock('@/app/components/base/param-item/top-k-item', () => ({ - default: ({ value, onChange }: { value: number, onChange: (key: string, v: number) => void }) => ( + default: ({ value, onChange }: { value: number; onChange: (key: string, v: number) => void }) => ( <div data-testid="top-k-item" data-value={value}> - <button - data-testid="change-top-k-btn" - onClick={() => onChange('top_k', 10)} - > + <button data-testid="change-top-k-btn" onClick={() => onChange('top_k', 10)}> Change TopK </button> </div> @@ -72,7 +81,13 @@ vi.mock('@/app/components/base/param-item/top-k-item', () => ({ })) vi.mock('@/app/components/base/param-item/score-threshold-item', () => ({ - default: ({ value, onChange, enable, hasSwitch, onSwitchChange }: { + default: ({ + value, + onChange, + enable, + hasSwitch, + onSwitchChange, + }: { value: number onChange: (key: string, v: number) => void enable: boolean @@ -85,10 +100,7 @@ vi.mock('@/app/components/base/param-item/score-threshold-item', () => ({ data-enabled={enable} data-has-switch={hasSwitch} > - <button - data-testid="change-score-btn" - onClick={() => onChange('score_threshold', 0.8)} - > + <button data-testid="change-score-btn" onClick={() => onChange('score_threshold', 0.8)}> Change Score </button> {hasSwitch && onSwitchChange && ( @@ -104,7 +116,13 @@ vi.mock('@/app/components/base/param-item/score-threshold-item', () => ({ })) vi.mock('@langgenius/dify-ui/switch', () => ({ - Switch: ({ checked, onCheckedChange }: { checked: boolean, onCheckedChange?: (v: boolean) => void }) => ( + Switch: ({ + checked, + onCheckedChange, + }: { + checked: boolean + onCheckedChange?: (v: boolean) => void + }) => ( <button data-testid="rerank-switch" data-checked={checked} @@ -324,7 +342,9 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip'))!.toBeInTheDocument() + expect( + screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip'), + )!.toBeInTheDocument() }) it('should not show multimodal tip when showMultiModalTip is false', () => { @@ -338,7 +358,9 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip'), + ).not.toBeInTheDocument() }) }) @@ -551,7 +573,9 @@ describe('RetrievalParamConfig', () => { />, ) - fireEvent.click(screen.getByRole('radio', { name: /common\.modelProvider\.rerankModel\.key/ })) + fireEvent.click( + screen.getByRole('radio', { name: /common\.modelProvider\.rerankModel\.key/ }), + ) expect(mockOnChange).not.toHaveBeenCalled() }) @@ -581,7 +605,9 @@ describe('RetrievalParamConfig', () => { />, ) - fireEvent.click(screen.getByRole('radio', { name: /common\.modelProvider\.rerankModel\.key/ })) + fireEvent.click( + screen.getByRole('radio', { name: /common\.modelProvider\.rerankModel\.key/ }), + ) expect(mockNotify).toHaveBeenCalledWith('workflow.errorMsg.rerankModelRequired') }) @@ -692,7 +718,9 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip'))!.toBeInTheDocument() + expect( + screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip'), + )!.toBeInTheDocument() }) it('should not show multimodal tip for hybrid search with WeightedScore', () => { @@ -720,7 +748,9 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip'), + ).not.toBeInTheDocument() }) it('should not render rerank switch for hybrid search', () => { diff --git a/web/app/components/datasets/common/retrieval-param-config/index.tsx b/web/app/components/datasets/common/retrieval-param-config/index.tsx index c49a227742ec05..741d76b5b423ea 100644 --- a/web/app/components/datasets/common/retrieval-param-config/index.tsx +++ b/web/app/components/datasets/common/retrieval-param-config/index.tsx @@ -2,7 +2,6 @@ import type { FC } from 'react' import type { RetrievalConfig } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' - import { RadioGroup } from '@langgenius/dify-ui/radio' import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' @@ -16,13 +15,12 @@ import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold import TopKItem from '@/app/components/base/param-item/top-k-item' import RadioCard from '@/app/components/base/radio-card' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useCurrentProviderAndModel, useModelListAndDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import { - DEFAULT_WEIGHTED_SCORE, - RerankingModeEnum, - WeightedScoreEnum, -} from '@/models/datasets' + useCurrentProviderAndModel, + useModelListAndDefaultModel, +} from '@/app/components/header/account-setting/model-provider-page/hooks' +import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { DEFAULT_WEIGHTED_SCORE, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import ProgressIndicator from '../../create/assets/progress-indicator.svg' import Reranking from '../../create/assets/rerank.svg' @@ -46,31 +44,26 @@ const RetrievalParamConfig: FC<Props> = ({ const canToggleRerankModalEnable = type !== RETRIEVE_METHOD.hybrid const isEconomical = type === RETRIEVE_METHOD.keywordSearch const isHybridSearch = type === RETRIEVE_METHOD.hybrid - const { - modelList: rerankModelList, - } = useModelListAndDefaultModel(ModelTypeEnum.rerank) + const { modelList: rerankModelList } = useModelListAndDefaultModel(ModelTypeEnum.rerank) - const { - currentModel, - } = useCurrentProviderAndModel( - rerankModelList, - { - provider: value.reranking_model?.reranking_provider_name ?? '', - model: value.reranking_model?.reranking_model_name ?? '', + const { currentModel } = useCurrentProviderAndModel(rerankModelList, { + provider: value.reranking_model?.reranking_provider_name ?? '', + model: value.reranking_model?.reranking_model_name ?? '', + }) + + const handleToggleRerankEnable = useCallback( + (enable: boolean) => { + if (disabled) return + if (enable && !currentModel) + toast.error(t(($) => $['errorMsg.rerankModelRequired'], { ns: 'workflow' })) + onChange({ + ...value, + reranking_enable: enable, + }) }, + [currentModel, disabled, onChange, t, value], ) - const handleToggleRerankEnable = useCallback((enable: boolean) => { - if (disabled) - return - if (enable && !currentModel) - toast.error(t($ => $['errorMsg.rerankModelRequired'], { ns: 'workflow' })) - onChange({ - ...value, - reranking_enable: enable, - }) - }, [currentModel, disabled, onChange, t, value]) - const rerankModel = useMemo(() => { return { provider_name: value.reranking_model.reranking_provider_name, @@ -79,10 +72,8 @@ const RetrievalParamConfig: FC<Props> = ({ }, [value.reranking_model]) const handleChangeRerankMode = (v: RerankingModeEnum) => { - if (disabled) - return - if (v === value.reranking_mode) - return + if (disabled) return + if (v === value.reranking_mode) return const result = { ...value, @@ -103,20 +94,20 @@ const RetrievalParamConfig: FC<Props> = ({ } } if (v === RerankingModeEnum.RerankingModel && !currentModel) - toast.error(t($ => $['errorMsg.rerankModelRequired'], { ns: 'workflow' })) + toast.error(t(($) => $['errorMsg.rerankModelRequired'], { ns: 'workflow' })) onChange(result) } const rerankingModeOptions = [ { value: RerankingModeEnum.WeightedScore, - label: t($ => $['weightedScore.title'], { ns: 'dataset' }), - tips: t($ => $['weightedScore.description'], { ns: 'dataset' }), + label: t(($) => $['weightedScore.title'], { ns: 'dataset' }), + tips: t(($) => $['weightedScore.description'], { ns: 'dataset' }), }, { value: RerankingModeEnum.RerankingModel, - label: t($ => $['modelProvider.rerankModel.key'], { ns: 'common' }), - tips: t($ => $['modelProvider.rerankModel.tip'], { ns: 'common' }), + label: t(($) => $['modelProvider.rerankModel.key'], { ns: 'common' }), + tips: t(($) => $['modelProvider.rerankModel.tip'], { ns: 'common' }), }, ] @@ -134,74 +125,76 @@ const RetrievalParamConfig: FC<Props> = ({ /> )} <div className="flex items-center"> - <span className="mr-0.5 system-sm-semibold text-text-secondary">{t($ => $['modelProvider.rerankModel.key'], { ns: 'common' })}</span> + <span className="mr-0.5 system-sm-semibold text-text-secondary"> + {t(($) => $['modelProvider.rerankModel.key'], { ns: 'common' })} + </span> <Infotip - aria-label={t($ => $['modelProvider.rerankModel.tip'], { ns: 'common' })} + aria-label={t(($) => $['modelProvider.rerankModel.tip'], { ns: 'common' })} popupClassName="w-[200px]" > - {t($ => $['modelProvider.rerankModel.tip'], { ns: 'common' })} + {t(($) => $['modelProvider.rerankModel.tip'], { ns: 'common' })} </Infotip> </div> </div> - { - value.reranking_enable && ( - <> - <ModelSelector - defaultModel={rerankModel && { provider: rerankModel.provider_name, model: rerankModel.model_name }} - modelList={rerankModelList} - onSelect={(v) => { - if (disabled) - return - onChange({ - ...value, - reranking_model: { - reranking_provider_name: v.provider, - reranking_model_name: v.model, - }, - }) - }} - readonly={disabled} - /> - {showMultiModalTip && ( - <div className="mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]"> - <div className="absolute inset-0 bg-dataset-warning-message-bg opacity-40" /> - <div className="p-1"> - <AlertTriangle className="size-4 text-text-warning-secondary" /> - </div> - <span className="system-xs-medium text-text-primary"> - {t($ => $['form.retrievalSetting.multiModalTip'], { ns: 'datasetSettings' })} - </span> + {value.reranking_enable && ( + <> + <ModelSelector + defaultModel={ + rerankModel && { + provider: rerankModel.provider_name, + model: rerankModel.model_name, + } + } + modelList={rerankModelList} + onSelect={(v) => { + if (disabled) return + onChange({ + ...value, + reranking_model: { + reranking_provider_name: v.provider, + reranking_model_name: v.model, + }, + }) + }} + readonly={disabled} + /> + {showMultiModalTip && ( + <div className="mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]"> + <div className="absolute inset-0 bg-dataset-warning-message-bg opacity-40" /> + <div className="p-1"> + <AlertTriangle className="size-4 text-text-warning-secondary" /> </div> - )} - </> - ) - } + <span className="system-xs-medium text-text-primary"> + {t(($) => $['form.retrievalSetting.multiModalTip'], { ns: 'datasetSettings' })} + </span> + </div> + )} + </> + )} </div> )} - { - !isHybridSearch && ( - <div className={cn(!isEconomical && 'mt-4', 'space-between flex space-x-4')}> - <TopKItem - className="grow" - value={value.top_k} - onChange={(_key, v) => { - if (disabled) - return - onChange({ - ...value, - top_k: v, - }) - }} - enable={true} - disabled={disabled} - /> - {(!isEconomical && !(value.search_method === RETRIEVE_METHOD.fullText && !value.reranking_enable)) && ( + {!isHybridSearch && ( + <div className={cn(!isEconomical && 'mt-4', 'space-between flex space-x-4')}> + <TopKItem + className="grow" + value={value.top_k} + onChange={(_key, v) => { + if (disabled) return + onChange({ + ...value, + top_k: v, + }) + }} + enable={true} + disabled={disabled} + /> + {!isEconomical && + !(value.search_method === RETRIEVE_METHOD.fullText && !value.reranking_enable) && ( <ScoreThresholdItem className="grow" value={value.score_threshold} onChange={(_key, v) => { - if (disabled) - return + if (disabled) return onChange({ ...value, score_threshold: v, @@ -211,8 +204,7 @@ const RetrievalParamConfig: FC<Props> = ({ hasSwitch={true} disabled={disabled} onSwitchChange={(_key, v) => { - if (disabled) - return + if (disabled) return onChange({ ...value, score_threshold_enabled: v, @@ -220,145 +212,136 @@ const RetrievalParamConfig: FC<Props> = ({ }} /> )} - </div> - ) - } - { - isHybridSearch && ( - <> - <RadioGroup<RerankingModeEnum> - aria-label={t($ => $['modelProvider.rerankModel.key'], { ns: 'common' })} - value={value.reranking_mode} - onValueChange={handleChangeRerankMode} - className="mb-4 flex gap-2" - > - { - rerankingModeOptions.map(option => ( - <RadioCard<RerankingModeEnum> - key={option.value} - value={option.value} - icon={( - <img - src={ - option.value === RerankingModeEnum.WeightedScore - ? ProgressIndicator.src - : Reranking.src - } - alt="" - /> - )} - title={option.label} - description={option.tips} - className="flex-1" - /> - )) - } - </RadioGroup> - { - value.reranking_mode === RerankingModeEnum.WeightedScore && ( - <WeightedScore - value={{ - value: [ - value.weights!.vector_setting.vector_weight, - value.weights!.keyword_setting.keyword_weight, - ], - }} - onChange={(v) => { - if (disabled) - return - onChange({ - ...value, - weights: { - ...value.weights!, - vector_setting: { - ...value.weights!.vector_setting, - vector_weight: v.value[0]!, - }, - keyword_setting: { - ...value.weights!.keyword_setting, - keyword_weight: v.value[1]!, - }, - }, - }) - }} - /> - ) - } - { - value.reranking_mode !== RerankingModeEnum.WeightedScore && ( - <> - <ModelSelector - defaultModel={rerankModel && { provider: rerankModel.provider_name, model: rerankModel.model_name }} - modelList={rerankModelList} - onSelect={(v) => { - if (disabled) - return - onChange({ - ...value, - reranking_model: { - reranking_provider_name: v.provider, - reranking_model_name: v.model, - }, - }) - }} - readonly={disabled} + </div> + )} + {isHybridSearch && ( + <> + <RadioGroup<RerankingModeEnum> + aria-label={t(($) => $['modelProvider.rerankModel.key'], { ns: 'common' })} + value={value.reranking_mode} + onValueChange={handleChangeRerankMode} + className="mb-4 flex gap-2" + > + {rerankingModeOptions.map((option) => ( + <RadioCard<RerankingModeEnum> + key={option.value} + value={option.value} + icon={ + <img + src={ + option.value === RerankingModeEnum.WeightedScore + ? ProgressIndicator.src + : Reranking.src + } + alt="" /> - {showMultiModalTip && ( - <div className="mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]"> - <div className="absolute inset-0 bg-dataset-warning-message-bg opacity-40" /> - <div className="p-1"> - <AlertTriangle className="size-4 text-text-warning-secondary" /> - </div> - <span className="system-xs-medium text-text-primary"> - {t($ => $['form.retrievalSetting.multiModalTip'], { ns: 'datasetSettings' })} - </span> - </div> - )} - </> - ) - } - <div className={cn(!isEconomical && 'mt-4', 'space-between flex space-x-6')}> - <TopKItem - className="grow" - value={value.top_k} - onChange={(_key, v) => { - if (disabled) - return - onChange({ - ...value, - top_k: v, - }) - }} - enable={true} - disabled={disabled} + } + title={option.label} + description={option.tips} + className="flex-1" /> - <ScoreThresholdItem - className="grow" - value={value.score_threshold} - onChange={(_key, v) => { - if (disabled) - return - onChange({ - ...value, - score_threshold: v, - }) - }} - enable={value.score_threshold_enabled} - hasSwitch={true} - disabled={disabled} - onSwitchChange={(_key, v) => { - if (disabled) - return + ))} + </RadioGroup> + {value.reranking_mode === RerankingModeEnum.WeightedScore && ( + <WeightedScore + value={{ + value: [ + value.weights!.vector_setting.vector_weight, + value.weights!.keyword_setting.keyword_weight, + ], + }} + onChange={(v) => { + if (disabled) return + onChange({ + ...value, + weights: { + ...value.weights!, + vector_setting: { + ...value.weights!.vector_setting, + vector_weight: v.value[0]!, + }, + keyword_setting: { + ...value.weights!.keyword_setting, + keyword_weight: v.value[1]!, + }, + }, + }) + }} + /> + )} + {value.reranking_mode !== RerankingModeEnum.WeightedScore && ( + <> + <ModelSelector + defaultModel={ + rerankModel && { + provider: rerankModel.provider_name, + model: rerankModel.model_name, + } + } + modelList={rerankModelList} + onSelect={(v) => { + if (disabled) return onChange({ ...value, - score_threshold_enabled: v, + reranking_model: { + reranking_provider_name: v.provider, + reranking_model_name: v.model, + }, }) }} + readonly={disabled} /> - </div> - </> - ) - } + {showMultiModalTip && ( + <div className="mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]"> + <div className="absolute inset-0 bg-dataset-warning-message-bg opacity-40" /> + <div className="p-1"> + <AlertTriangle className="size-4 text-text-warning-secondary" /> + </div> + <span className="system-xs-medium text-text-primary"> + {t(($) => $['form.retrievalSetting.multiModalTip'], { ns: 'datasetSettings' })} + </span> + </div> + )} + </> + )} + <div className={cn(!isEconomical && 'mt-4', 'space-between flex space-x-6')}> + <TopKItem + className="grow" + value={value.top_k} + onChange={(_key, v) => { + if (disabled) return + onChange({ + ...value, + top_k: v, + }) + }} + enable={true} + disabled={disabled} + /> + <ScoreThresholdItem + className="grow" + value={value.score_threshold} + onChange={(_key, v) => { + if (disabled) return + onChange({ + ...value, + score_threshold: v, + }) + }} + enable={value.score_threshold_enabled} + hasSwitch={true} + disabled={disabled} + onSwitchChange={(_key, v) => { + if (disabled) return + onChange({ + ...value, + score_threshold_enabled: v, + }) + }} + /> + </div> + </> + )} </div> ) } diff --git a/web/app/components/datasets/create-from-pipeline/__tests__/footer.spec.tsx b/web/app/components/datasets/create-from-pipeline/__tests__/footer.spec.tsx index 24c8b0338dcb3e..cfb9002d2730c7 100644 --- a/web/app/components/datasets/create-from-pipeline/__tests__/footer.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/__tests__/footer.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import Footer from '../footer' // Configurable mock for search params @@ -22,7 +21,13 @@ let capturedActiveTab: string | undefined let capturedDslUrl: string | undefined vi.mock('../create-options/create-from-dsl-modal', () => ({ - default: ({ show, onClose, onSuccess, activeTab, dslUrl }: { + default: ({ + show, + onClose, + onSuccess, + activeTab, + dslUrl, + }: { show: boolean onClose: () => void onSuccess: () => void @@ -31,14 +36,16 @@ vi.mock('../create-options/create-from-dsl-modal', () => ({ }) => { capturedActiveTab = activeTab capturedDslUrl = dslUrl - return show - ? ( - <div data-testid="dsl-modal"> - <button data-testid="close-modal" onClick={onClose}>Close</button> - <button data-testid="success-modal" onClick={onSuccess}>Success</button> - </div> - ) - : null + return show ? ( + <div data-testid="dsl-modal"> + <button data-testid="close-modal" onClick={onClose}> + Close + </button> + <button data-testid="success-modal" onClick={onSuccess}> + Success + </button> + </div> + ) : null }, CreateFromDSLModalTab: { FROM_URL: 'FROM_URL', diff --git a/web/app/components/datasets/create-from-pipeline/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/__tests__/index.spec.tsx index 658c7e4a02630d..28918949f97004 100644 --- a/web/app/components/datasets/create-from-pipeline/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/__tests__/index.spec.tsx @@ -1,6 +1,5 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' - import CreateFromPipeline from '../index' // Mock child components to isolate testing @@ -18,7 +17,9 @@ vi.mock('../footer', () => ({ vi.mock('../../../base/effect', () => ({ default: ({ className }: { className?: string }) => ( - <div data-testid="mock-effect" className={className}>Effect</div> + <div data-testid="mock-effect" className={className}> + Effect + </div> ), })) @@ -56,7 +57,13 @@ describe('CreateFromPipeline', () => { it('should have proper container classes', () => { const { container } = render(<CreateFromPipeline />) const mainDiv = container.firstChild as HTMLElement - expect(mainDiv).toHaveClass('relative', 'flex', 'flex-col', 'overflow-hidden', 'rounded-t-2xl') + expect(mainDiv).toHaveClass( + 'relative', + 'flex', + 'flex-col', + 'overflow-hidden', + 'rounded-t-2xl', + ) }) it('should have correct height calculation', () => { @@ -68,7 +75,11 @@ describe('CreateFromPipeline', () => { it('should have border and background styling', () => { const { container } = render(<CreateFromPipeline />) const mainDiv = container.firstChild as HTMLElement - expect(mainDiv).toHaveClass('border-t', 'border-effects-highlight', 'bg-background-default-subtle') + expect(mainDiv).toHaveClass( + 'border-t', + 'border-effects-highlight', + 'bg-background-default-subtle', + ) }) it('should position Effect component correctly', () => { diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/dsl-confirm-modal.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/dsl-confirm-modal.spec.tsx index 160c6a4c0627d1..8c9980ddcbaebf 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/dsl-confirm-modal.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/dsl-confirm-modal.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import DSLConfirmModal from '../dsl-confirm-modal' // DSLConfirmModal Component Tests diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/header.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/header.spec.tsx index 2ca6fbfec059a1..f32532358c9363 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/header.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/header.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import Header from '../header' // Header Component Tests diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx index 6138fc10f3afdf..a8ad1acf78371b 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx @@ -37,14 +37,24 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ const toastMocks = vi.hoisted(() => { const record = vi.fn() - const api = vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })) + const api = vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ message, ...options }), + ) return { record, api: Object.assign(api, { - success: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'success', message, ...options })), - error: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'error', message, ...options })), - warning: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'warning', message, ...options })), - info: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'info', message, ...options })), + success: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'success', message, ...options }), + ), + error: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'info', message, ...options }), + ), dismiss: vi.fn(), update: vi.fn(), promise: vi.fn(), @@ -57,7 +67,8 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('use-context-selector', async () => { - const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector') + const actual = + await vi.importActual<typeof import('use-context-selector')>('use-context-selector') return { ...actual, useContext: vi.fn(() => ({ notify: toastMocks.api })), @@ -87,9 +98,7 @@ const createWrapper = () => { }, }) return ({ children }: { children: React.ReactNode }) => ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) } @@ -105,25 +114,13 @@ describe('CreateFromDSLModal', () => { describe('Rendering', () => { it('should render without crashing when show is true', () => { - render( - <CreateFromDSLModal - show={true} - onClose={vi.fn()} - />, - { wrapper: createWrapper() }, - ) + render(<CreateFromDSLModal show={true} onClose={vi.fn()} />, { wrapper: createWrapper() }) expect(screen.getByText('app.importFromDSL'))!.toBeInTheDocument() }) it('should not render modal content when show is false', () => { - render( - <CreateFromDSLModal - show={false} - onClose={vi.fn()} - />, - { wrapper: createWrapper() }, - ) + render(<CreateFromDSLModal show={false} onClose={vi.fn()} />, { wrapper: createWrapper() }) // Modal with show=false should not display its content visibly const modal = screen.queryByText('app.importFromDSL') @@ -131,26 +128,14 @@ describe('CreateFromDSLModal', () => { }) it('should render file tab by default', () => { - render( - <CreateFromDSLModal - show={true} - onClose={vi.fn()} - />, - { wrapper: createWrapper() }, - ) + render(<CreateFromDSLModal show={true} onClose={vi.fn()} />, { wrapper: createWrapper() }) expect(screen.getByText('app.importFromDSLFile'))!.toBeInTheDocument() expect(screen.getByText('app.importFromDSLUrl'))!.toBeInTheDocument() }) it('should render cancel and import buttons', () => { - render( - <CreateFromDSLModal - show={true} - onClose={vi.fn()} - />, - { wrapper: createWrapper() }, - ) + render(<CreateFromDSLModal show={true} onClose={vi.fn()} />, { wrapper: createWrapper() }) expect(screen.getByText('app.newApp.Cancel'))!.toBeInTheDocument() expect(screen.getByText('app.newApp.import'))!.toBeInTheDocument() @@ -186,13 +171,7 @@ describe('CreateFromDSLModal', () => { describe('Props', () => { it('should use FROM_FILE as default activeTab', () => { - render( - <CreateFromDSLModal - show={true} - onClose={vi.fn()} - />, - { wrapper: createWrapper() }, - ) + render(<CreateFromDSLModal show={true} onClose={vi.fn()} />, { wrapper: createWrapper() }) // File tab content should be visible // File tab content should be visible @@ -229,13 +208,7 @@ describe('CreateFromDSLModal', () => { it('should call onClose when cancel button is clicked', () => { const onClose = vi.fn() - render( - <CreateFromDSLModal - show={true} - onClose={onClose} - />, - { wrapper: createWrapper() }, - ) + render(<CreateFromDSLModal show={true} onClose={onClose} />, { wrapper: createWrapper() }) fireEvent.click(screen.getByText('app.newApp.Cancel')) expect(onClose).toHaveBeenCalled() @@ -244,13 +217,7 @@ describe('CreateFromDSLModal', () => { describe('State Management', () => { it('should switch between tabs', () => { - render( - <CreateFromDSLModal - show={true} - onClose={vi.fn()} - />, - { wrapper: createWrapper() }, - ) + render(<CreateFromDSLModal show={true} onClose={vi.fn()} />, { wrapper: createWrapper() }) // Initially file tab is active // Initially file tab is active @@ -378,9 +345,11 @@ describe('CreateFromDSLModal', () => { await waitFor(() => { expect(onSuccess).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + }), + ) expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-789/pipeline') }) }) @@ -388,7 +357,9 @@ describe('CreateFromDSLModal', () => { it('should handle import with COMPLETED_WITH_WARNINGS status', async () => { const onSuccess = vi.fn() const onClose = vi.fn() - mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'completed-with-warnings' })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ status: 'completed-with-warnings' }), + ) mockHandleCheckPluginDependencies.mockResolvedValue(undefined) render( @@ -408,20 +379,24 @@ describe('CreateFromDSLModal', () => { fireEvent.click(importButton) await waitFor(() => { - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'warning', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'warning', + }), + ) }) }) it('should handle import with PENDING status and show error modal', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) const onClose = vi.fn() - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - status: 'pending', - imported_dsl_version: '0.9.0', - current_dsl_version: '1.0.0', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + status: 'pending', + imported_dsl_version: '0.9.0', + current_dsl_version: '1.0.0', + }), + ) render( <CreateFromDSLModal @@ -473,9 +448,11 @@ describe('CreateFromDSLModal', () => { fireEvent.click(importButton) await waitFor(() => { - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) }) @@ -498,17 +475,21 @@ describe('CreateFromDSLModal', () => { fireEvent.click(importButton) await waitFor(() => { - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) }) it('should check plugin dependencies after successful import', async () => { - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - status: 'completed', - pipeline_id: 'pipeline-123', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + status: 'completed', + pipeline_id: 'pipeline-123', + }), + ) mockHandleCheckPluginDependencies.mockResolvedValue(undefined) render( @@ -536,13 +517,7 @@ describe('CreateFromDSLModal', () => { describe('Event Handlers', () => { it('should call onClose when header close button is clicked', () => { const onClose = vi.fn() - render( - <CreateFromDSLModal - show={true} - onClose={onClose} - />, - { wrapper: createWrapper() }, - ) + render(<CreateFromDSLModal show={true} onClose={onClose} />, { wrapper: createWrapper() }) // Find and click the close icon in header const closeIcon = document.querySelector('[class*="cursor-pointer"]') @@ -555,13 +530,7 @@ describe('CreateFromDSLModal', () => { it('should close modal on ESC key press', () => { const onClose = vi.fn() - render( - <CreateFromDSLModal - show={true} - onClose={onClose} - />, - { wrapper: createWrapper() }, - ) + render(<CreateFromDSLModal show={true} onClose={onClose} />, { wrapper: createWrapper() }) const escEvent = new KeyboardEvent('keydown', { key: 'Escape', @@ -622,9 +591,9 @@ describe('CreateFromDSLModal', () => { }) it('should prevent duplicate submissions', async () => { - mockImportDSL.mockImplementation(() => new Promise(resolve => - setTimeout(() => resolve(createImportDSLResponse()), 1000), - )) + mockImportDSL.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(createImportDSLResponse()), 1000)), + ) render( <CreateFromDSLModal @@ -728,10 +697,12 @@ describe('CreateFromDSLModal', () => { }) it('should handle response without pipeline_id', async () => { - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - status: 'completed', - pipeline_id: null, - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + status: 'completed', + pipeline_id: null, + }), + ) render( <CreateFromDSLModal @@ -854,7 +825,9 @@ describe('CreateFromDSLModal', () => { // Create a mock file with content const fileContent = 'test yaml content' - const mockFile = new File([fileContent], 'test.pipeline', { type: 'application/octet-stream' }) + const mockFile = new File([fileContent], 'test.pipeline', { + type: 'application/octet-stream', + }) // Get the file input and simulate file selection const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement @@ -926,12 +899,14 @@ describe('CreateFromDSLModal', () => { const onSuccess = vi.fn() const onClose = vi.fn() - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - id: 'import-123', - status: 'pending', - imported_dsl_version: '0.9.0', - current_dsl_version: '1.0.0', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + id: 'import-123', + status: 'pending', + imported_dsl_version: '0.9.0', + current_dsl_version: '1.0.0', + }), + ) mockImportDSLConfirm.mockResolvedValue({ status: 'completed', @@ -981,9 +956,11 @@ describe('CreateFromDSLModal', () => { // Verify success handling await waitFor(() => { - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + }), + ) }) vi.useRealTimers() @@ -992,10 +969,12 @@ describe('CreateFromDSLModal', () => { it('should handle DSL confirm with no importId', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - id: '', // Empty id - status: 'pending', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + id: '', // Empty id + status: 'pending', + }), + ) render( <CreateFromDSLModal @@ -1031,10 +1010,12 @@ describe('CreateFromDSLModal', () => { it('should handle DSL confirm API error', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - id: 'import-123', - status: 'pending', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + id: 'import-123', + status: 'pending', + }), + ) mockImportDSLConfirm.mockResolvedValue(null) @@ -1063,9 +1044,11 @@ describe('CreateFromDSLModal', () => { fireEvent.click(screen.getByText('app.newApp.Confirm')) await waitFor(() => { - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) vi.useRealTimers() @@ -1074,10 +1057,12 @@ describe('CreateFromDSLModal', () => { it('should handle DSL confirm with FAILED status', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - id: 'import-123', - status: 'pending', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + id: 'import-123', + status: 'pending', + }), + ) mockImportDSLConfirm.mockResolvedValue({ status: 'failed', @@ -1110,9 +1095,11 @@ describe('CreateFromDSLModal', () => { fireEvent.click(screen.getByText('app.newApp.Confirm')) await waitFor(() => { - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) vi.useRealTimers() @@ -1121,9 +1108,11 @@ describe('CreateFromDSLModal', () => { it('should close error modal when cancel is clicked', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - status: 'pending', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + status: 'pending', + }), + ) render( <CreateFromDSLModal @@ -1199,12 +1188,7 @@ describe('Tab', () => { describe('Rendering', () => { it('should render both tabs', () => { - render( - <Tab - currentTab={CreateFromDSLModalTab.FROM_FILE} - setCurrentTab={vi.fn()} - />, - ) + render(<Tab currentTab={CreateFromDSLModalTab.FROM_FILE} setCurrentTab={vi.fn()} />) expect(screen.getByText('app.importFromDSLFile'))!.toBeInTheDocument() expect(screen.getByText('app.importFromDSLUrl'))!.toBeInTheDocument() @@ -1214,12 +1198,7 @@ describe('Tab', () => { describe('Event Handlers', () => { it('should call setCurrentTab when clicking file tab', () => { const setCurrentTab = vi.fn() - render( - <Tab - currentTab={CreateFromDSLModalTab.FROM_URL} - setCurrentTab={setCurrentTab} - />, - ) + render(<Tab currentTab={CreateFromDSLModalTab.FROM_URL} setCurrentTab={setCurrentTab} />) fireEvent.click(screen.getByText('app.importFromDSLFile')) // Tab uses bind() which passes the key as first argument and event as second @@ -1229,12 +1208,7 @@ describe('Tab', () => { it('should call setCurrentTab when clicking URL tab', () => { const setCurrentTab = vi.fn() - render( - <Tab - currentTab={CreateFromDSLModalTab.FROM_FILE} - setCurrentTab={setCurrentTab} - />, - ) + render(<Tab currentTab={CreateFromDSLModalTab.FROM_FILE} setCurrentTab={setCurrentTab} />) fireEvent.click(screen.getByText('app.importFromDSLUrl')) // Tab uses bind() which passes the key as first argument and event as second @@ -1252,25 +1226,13 @@ describe('TabItem', () => { describe('Rendering', () => { it('should render label', () => { - render( - <TabItem - isActive={false} - label="Test Tab" - onClick={vi.fn()} - />, - ) + render(<TabItem isActive={false} label="Test Tab" onClick={vi.fn()} />) expect(screen.getByText('Test Tab'))!.toBeInTheDocument() }) it('should render active indicator when active', () => { - render( - <TabItem - isActive={true} - label="Test Tab" - onClick={vi.fn()} - />, - ) + render(<TabItem isActive={true} label="Test Tab" onClick={vi.fn()} />) // Active indicator is the bottom border div const indicator = document.querySelector('[class*="bg-util-colors-blue"]') @@ -1278,39 +1240,21 @@ describe('TabItem', () => { }) it('should not render active indicator when inactive', () => { - render( - <TabItem - isActive={false} - label="Test Tab" - onClick={vi.fn()} - />, - ) + render(<TabItem isActive={false} label="Test Tab" onClick={vi.fn()} />) const indicator = document.querySelector('[class*="bg-util-colors-blue"]') expect(indicator).toBeNull() }) it('should have active text color when active', () => { - render( - <TabItem - isActive={true} - label="Test Tab" - onClick={vi.fn()} - />, - ) + render(<TabItem isActive={true} label="Test Tab" onClick={vi.fn()} />) const item = screen.getByText('Test Tab') expect(item.className).toContain('text-text-primary') }) it('should have inactive text color when inactive', () => { - render( - <TabItem - isActive={false} - label="Test Tab" - onClick={vi.fn()} - />, - ) + render(<TabItem isActive={false} label="Test Tab" onClick={vi.fn()} />) const item = screen.getByText('Test Tab') expect(item.className).toContain('text-text-tertiary') @@ -1320,13 +1264,7 @@ describe('TabItem', () => { describe('Event Handlers', () => { it('should call onClick when clicked', () => { const onClick = vi.fn() - render( - <TabItem - isActive={false} - label="Test Tab" - onClick={onClick} - />, - ) + render(<TabItem isActive={false} label="Test Tab" onClick={onClick} />) fireEvent.click(screen.getByText('Test Tab')) expect(onClick).toHaveBeenCalled() @@ -1342,12 +1280,7 @@ describe('Uploader', () => { describe('Rendering', () => { it('should render upload prompt when no file', () => { - render( - <Uploader - file={undefined} - updateFile={vi.fn()} - />, - ) + render(<Uploader file={undefined} updateFile={vi.fn()} />) expect(screen.getByText('app.dslUploader.button'))!.toBeInTheDocument() expect(screen.getByRole('button', { name: 'app.dslUploader.browse' }))!.toBeInTheDocument() @@ -1356,12 +1289,7 @@ describe('Uploader', () => { it('should render file info when file is selected', () => { const mockFile = createMockFile('test.pipeline') - render( - <Uploader - file={mockFile} - updateFile={vi.fn()} - />, - ) + render(<Uploader file={mockFile} updateFile={vi.fn()} />) expect(screen.getByText('test.pipeline'))!.toBeInTheDocument() expect(screen.getByText('PIPELINE'))!.toBeInTheDocument() @@ -1369,11 +1297,7 @@ describe('Uploader', () => { it('should apply custom className', () => { const { container } = render( - <Uploader - file={undefined} - updateFile={vi.fn()} - className="custom-class" - />, + <Uploader file={undefined} updateFile={vi.fn()} className="custom-class" />, ) expect(container.firstChild)!.toHaveClass('custom-class') @@ -1383,12 +1307,7 @@ describe('Uploader', () => { describe('Event Handlers', () => { it('should call updateFile when browse link is clicked and file is selected', async () => { const updateFile = vi.fn() - render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + render(<Uploader file={undefined} updateFile={updateFile} />) // Get the hidden input const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement @@ -1410,12 +1329,7 @@ describe('Uploader', () => { const updateFile = vi.fn() const mockFile = createMockFile() - render( - <Uploader - file={mockFile} - updateFile={updateFile} - />, - ) + render(<Uploader file={mockFile} updateFile={updateFile} />) // Find and click delete button - the button contains the delete icon const deleteButton = document.querySelector('button') @@ -1427,12 +1341,7 @@ describe('Uploader', () => { it('should handle browse click', () => { const updateFile = vi.fn() - render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + render(<Uploader file={undefined} updateFile={updateFile} />) const browseLink = screen.getByRole('button', { name: 'app.dslUploader.browse' }) const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement @@ -1448,12 +1357,7 @@ describe('Uploader', () => { describe('Drag and Drop', () => { it('should show drag state when dragging over', () => { - render( - <Uploader - file={undefined} - updateFile={vi.fn()} - />, - ) + render(<Uploader file={undefined} updateFile={vi.fn()} />) const dropArea = document.querySelector('[class*="border-dashed"]')! @@ -1468,17 +1372,11 @@ describe('Uploader', () => { }) it('should handle dragOver event', () => { - render( - <Uploader - file={undefined} - updateFile={vi.fn()} - />, - ) + render(<Uploader file={undefined} updateFile={vi.fn()} />) const dashedArea = document.querySelector('[class*="border-dashed"]') const dropArea = dashedArea?.parentElement - if (!dropArea) - return + if (!dropArea) return // DragOver should prevent default and stop propagation const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true }) @@ -1490,18 +1388,12 @@ describe('Uploader', () => { }) it('should handle dragLeave event and reset dragging state when target is dragRef', async () => { - render( - <Uploader - file={undefined} - updateFile={vi.fn()} - />, - ) + render(<Uploader file={undefined} updateFile={vi.fn()} />) const dropArea = document.querySelector('[class*="border-dashed"]')! const dropAreaParent = dropArea.parentElement - if (!dropAreaParent) - return + if (!dropAreaParent) return // First trigger dragEnter to set dragging state fireEvent.dragEnter(dropArea, { @@ -1530,18 +1422,12 @@ describe('Uploader', () => { }) it('should not reset dragging when dragLeave target is not dragRef', async () => { - render( - <Uploader - file={undefined} - updateFile={vi.fn()} - />, - ) + render(<Uploader file={undefined} updateFile={vi.fn()} />) const dropArea = document.querySelector('[class*="border-dashed"]')! const dropAreaParent = dropArea.parentElement - if (!dropAreaParent) - return + if (!dropAreaParent) return // First trigger dragEnter to set dragging state fireEvent.dragEnter(dropArea, { @@ -1565,17 +1451,11 @@ describe('Uploader', () => { it('should handle file drop', async () => { const updateFile = vi.fn() - render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + render(<Uploader file={undefined} updateFile={updateFile} />) const dashedArea = document.querySelector('[class*="border-dashed"]') const dropArea = dashedArea?.parentElement - if (!dropArea) - return + if (!dropArea) return const mockFile = createMockFile() @@ -1590,17 +1470,11 @@ describe('Uploader', () => { it('should reject multiple files', async () => { const updateFile = vi.fn() - render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + render(<Uploader file={undefined} updateFile={updateFile} />) const dashedArea = document.querySelector('[class*="border-dashed"]') const dropArea = dashedArea?.parentElement - if (!dropArea) - return + if (!dropArea) return const mockFile1 = createMockFile('file1.pipeline') const mockFile2 = createMockFile('file2.pipeline') @@ -1612,26 +1486,22 @@ describe('Uploader', () => { }) expect(updateFile).not.toHaveBeenCalled() - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) }) describe('Edge Cases', () => { it('should handle drop event without dataTransfer', () => { const updateFile = vi.fn() - render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + render(<Uploader file={undefined} updateFile={updateFile} />) const dashedArea = document.querySelector('[class*="border-dashed"]') const dropArea = dashedArea?.parentElement - if (!dropArea) - return + if (!dropArea) return fireEvent.drop(dropArea) @@ -1641,12 +1511,7 @@ describe('Uploader', () => { it('should handle file cancel in selectHandle and restore original file', () => { const updateFile = vi.fn() - render( - <Uploader - file={undefined} - updateFile={updateFile} - />, - ) + render(<Uploader file={undefined} updateFile={updateFile} />) // Get the file input const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement @@ -1675,12 +1540,7 @@ describe('Uploader', () => { }) it('should not set dragging when target equals dragRef', () => { - render( - <Uploader - file={undefined} - updateFile={vi.fn()} - />, - ) + render(<Uploader file={undefined} updateFile={vi.fn()} />) const dropArea = document.querySelector('[class*="border-dashed"]')! @@ -1708,12 +1568,7 @@ describe('Uploader', () => { const updateFile = vi.fn() const mockFile = createMockFile() - render( - <Uploader - file={mockFile} - updateFile={updateFile} - />, - ) + render(<Uploader file={mockFile} updateFile={updateFile} />) // Find and click delete button const deleteButton = document.querySelector('button') @@ -1740,12 +1595,7 @@ describe('DSLConfirmModal', () => { describe('Rendering', () => { it('should render title', () => { - render( - <DSLConfirmModal - onCancel={vi.fn()} - onConfirm={vi.fn()} - />, - ) + render(<DSLConfirmModal onCancel={vi.fn()} onConfirm={vi.fn()} />) expect(screen.getByText('app.newApp.appCreateDSLErrorTitle'))!.toBeInTheDocument() }) @@ -1767,24 +1617,14 @@ describe('DSLConfirmModal', () => { }) it('should render cancel and confirm buttons', () => { - render( - <DSLConfirmModal - onCancel={vi.fn()} - onConfirm={vi.fn()} - />, - ) + render(<DSLConfirmModal onCancel={vi.fn()} onConfirm={vi.fn()} />) expect(screen.getByText('app.newApp.Cancel'))!.toBeInTheDocument() expect(screen.getByText('app.newApp.Confirm'))!.toBeInTheDocument() }) it('should render with default empty versions', () => { - render( - <DSLConfirmModal - onCancel={vi.fn()} - onConfirm={vi.fn()} - />, - ) + render(<DSLConfirmModal onCancel={vi.fn()} onConfirm={vi.fn()} />) // Should not crash with default empty strings // Should not crash with default empty strings @@ -1792,13 +1632,7 @@ describe('DSLConfirmModal', () => { }) it('should disable confirm button when confirmDisabled is true', () => { - render( - <DSLConfirmModal - onCancel={vi.fn()} - onConfirm={vi.fn()} - confirmDisabled={true} - />, - ) + render(<DSLConfirmModal onCancel={vi.fn()} onConfirm={vi.fn()} confirmDisabled={true} />) const confirmButton = screen.getByText('app.newApp.Confirm').closest('button') expect(confirmButton)!.toBeDisabled() @@ -1808,12 +1642,7 @@ describe('DSLConfirmModal', () => { describe('Event Handlers', () => { it('should call onCancel when cancel button is clicked', () => { const onCancel = vi.fn() - render( - <DSLConfirmModal - onCancel={onCancel} - onConfirm={vi.fn()} - />, - ) + render(<DSLConfirmModal onCancel={onCancel} onConfirm={vi.fn()} />) fireEvent.click(screen.getByText('app.newApp.Cancel')) expect(onCancel).toHaveBeenCalled() @@ -1821,12 +1650,7 @@ describe('DSLConfirmModal', () => { it('should call onConfirm when confirm button is clicked', () => { const onConfirm = vi.fn() - render( - <DSLConfirmModal - onCancel={vi.fn()} - onConfirm={onConfirm} - />, - ) + render(<DSLConfirmModal onCancel={vi.fn()} onConfirm={onConfirm} />) fireEvent.click(screen.getByText('app.newApp.Confirm')) expect(onConfirm).toHaveBeenCalled() @@ -1836,12 +1660,7 @@ describe('DSLConfirmModal', () => { // This test verifies that the Modal's onClose prop calls onCancel // The implementation is: onClose={() => onCancel()} const onCancel = vi.fn() - render( - <DSLConfirmModal - onCancel={onCancel} - onConfirm={vi.fn()} - />, - ) + render(<DSLConfirmModal onCancel={onCancel} onConfirm={vi.fn()} />) // Trigger the cancel button which also calls onCancel // This confirms onCancel is properly wired up @@ -1851,12 +1670,7 @@ describe('DSLConfirmModal', () => { it('should call onCancel when modal is closed via escape key', () => { const onCancel = vi.fn() - render( - <DSLConfirmModal - onCancel={onCancel} - onConfirm={vi.fn()} - />, - ) + render(<DSLConfirmModal onCancel={onCancel} onConfirm={vi.fn()} />) // Pressing Escape triggers Modal's onClose which calls onCancel const escEvent = new KeyboardEvent('keydown', { @@ -1874,12 +1688,7 @@ describe('DSLConfirmModal', () => { describe('Props', () => { it('should use default versions when not provided', () => { - render( - <DSLConfirmModal - onCancel={vi.fn()} - onConfirm={vi.fn()} - />, - ) + render(<DSLConfirmModal onCancel={vi.fn()} onConfirm={vi.fn()} />) // Component should render without crashing // Component should render without crashing @@ -1887,12 +1696,7 @@ describe('DSLConfirmModal', () => { }) it('should use default confirmDisabled when not provided', () => { - render( - <DSLConfirmModal - onCancel={vi.fn()} - onConfirm={vi.fn()} - />, - ) + render(<DSLConfirmModal onCancel={vi.fn()} onConfirm={vi.fn()} />) const confirmButton = screen.getByText('app.newApp.Confirm').closest('button') expect(confirmButton).not.toBeDisabled() @@ -1916,14 +1720,9 @@ describe('CreateFromDSLModal Integration', () => { mockImportDSL.mockResolvedValue(createImportDSLResponse()) mockHandleCheckPluginDependencies.mockResolvedValue(undefined) - render( - <CreateFromDSLModal - show={true} - onSuccess={onSuccess} - onClose={onClose} - />, - { wrapper: createWrapper() }, - ) + render(<CreateFromDSLModal show={true} onSuccess={onSuccess} onClose={onClose} />, { + wrapper: createWrapper(), + }) // Switch to URL tab fireEvent.click(screen.getByText('app.importFromDSLUrl')) @@ -1953,11 +1752,13 @@ describe('CreateFromDSLModal Integration', () => { it('should handle version mismatch flow - shows error modal', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) const onClose = vi.fn() - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - status: 'pending', - imported_dsl_version: '0.8.0', - current_dsl_version: '1.0.0', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + status: 'pending', + imported_dsl_version: '0.8.0', + current_dsl_version: '1.0.0', + }), + ) render( <CreateFromDSLModal diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/uploader.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/uploader.spec.tsx index bab1d505b77d3d..09214a53b33971 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/uploader.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/uploader.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import Uploader from '../uploader' const { mockToast } = vi.hoisted(() => { @@ -134,8 +133,7 @@ describe('Uploader', () => { const { container } = render(<Uploader {...defaultProps} file={file} />) const deleteButton = container.querySelector('[class*="group-hover:flex"] button') - if (deleteButton) - fireEvent.click(deleteButton) + if (deleteButton) fireEvent.click(deleteButton) expect(defaultProps.updateFile).toHaveBeenCalledWith() }) diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx index e0b3ee2e3a266e..e52449905f4d7a 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx @@ -30,30 +30,38 @@ const DSLConfirmModal = ({ <AlertDialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <AlertDialogContent className="w-[480px] max-w-none! overflow-hidden! border-none p-6 text-left align-middle shadow-xl"> <div className="flex flex-col items-start gap-2 self-stretch pb-4"> - <AlertDialogTitle className="title-2xl-semi-bold text-text-primary">{t($ => $['newApp.appCreateDSLErrorTitle'], { ns: 'app' })}</AlertDialogTitle> - <AlertDialogDescription render={<div />} className="flex grow flex-col system-md-regular text-text-secondary"> - <div>{t($ => $['newApp.appCreateDSLErrorPart1'], { ns: 'app' })}</div> - <div>{t($ => $['newApp.appCreateDSLErrorPart2'], { ns: 'app' })}</div> + <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['newApp.appCreateDSLErrorTitle'], { ns: 'app' })} + </AlertDialogTitle> + <AlertDialogDescription + render={<div />} + className="flex grow flex-col system-md-regular text-text-secondary" + > + <div>{t(($) => $['newApp.appCreateDSLErrorPart1'], { ns: 'app' })}</div> + <div>{t(($) => $['newApp.appCreateDSLErrorPart2'], { ns: 'app' })}</div> <br /> <div> - {t($ => $['newApp.appCreateDSLErrorPart3'], { ns: 'app' })} + {t(($) => $['newApp.appCreateDSLErrorPart3'], { ns: 'app' })} <span className="system-md-medium">{versions.importedVersion}</span> </div> <div> - {t($ => $['newApp.appCreateDSLErrorPart4'], { ns: 'app' })} + {t(($) => $['newApp.appCreateDSLErrorPart4'], { ns: 'app' })} <span className="system-md-medium">{versions.systemVersion}</span> </div> </AlertDialogDescription> </div> <AlertDialogActions className="items-start p-0 pt-6"> - <AlertDialogCancelButton variant="secondary">{t($ => $['newApp.Cancel'], { ns: 'app' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton onClick={onConfirm} disabled={confirmDisabled}>{t($ => $['newApp.Confirm'], { ns: 'app' })}</AlertDialogConfirmButton> + <AlertDialogCancelButton variant="secondary"> + {t(($) => $['newApp.Cancel'], { ns: 'app' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton onClick={onConfirm} disabled={confirmDisabled}> + {t(($) => $['newApp.Confirm'], { ns: 'app' })} + </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> </AlertDialog> diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/header.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/header.tsx index 4329a8283c2c75..3f3b70674199db 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/header.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/header.tsx @@ -6,14 +6,12 @@ type HeaderProps = { onClose: () => void } -const Header = ({ - onClose, -}: HeaderProps) => { +const Header = ({ onClose }: HeaderProps) => { const { t } = useTranslation() return ( <div className="relative flex items-center justify-between pt-6 pr-14 pb-3 pl-6 title-2xl-semi-bold text-text-primary"> - {t($ => $.importFromDSL, { ns: 'app' })} + {t(($) => $.importFromDSL, { ns: 'app' })} <div className="absolute top-5 right-5 flex size-8 cursor-pointer items-center" onClick={onClose} diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx index a00cdf6098a2f2..8f039a223222c1 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx @@ -34,14 +34,24 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ const toastMocks = vi.hoisted(() => { const record = vi.fn() - const api = vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })) + const api = vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ message, ...options }), + ) return { record, api: Object.assign(api, { - success: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'success', message, ...options })), - error: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'error', message, ...options })), - warning: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'warning', message, ...options })), - info: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'info', message, ...options })), + success: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'success', message, ...options }), + ), + error: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'info', message, ...options }), + ), dismiss: vi.fn(), update: vi.fn(), promise: vi.fn(), @@ -54,7 +64,8 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('use-context-selector', async () => { - const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector') + const actual = + await vi.importActual<typeof import('use-context-selector')>('use-context-selector') return { ...actual, useContext: vi.fn(() => ({ notify: toastMocks.api })), @@ -80,9 +91,7 @@ const createWrapper = () => { }, }) return ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) } @@ -98,10 +107,7 @@ describe('useDSLImport', () => { describe('initialization', () => { it('should initialize with default values', () => { - const { result } = renderHook( - () => useDSLImport({}), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useDSLImport({}), { wrapper: createWrapper() }) expect(result.current.currentFile).toBeUndefined() expect(result.current.currentTab).toBe(CreateFromDSLModalTab.FROM_FILE) @@ -133,10 +139,7 @@ describe('useDSLImport', () => { describe('setCurrentTab', () => { it('should update current tab', () => { - const { result } = renderHook( - () => useDSLImport({}), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useDSLImport({}), { wrapper: createWrapper() }) act(() => { result.current.setCurrentTab(CreateFromDSLModalTab.FROM_URL) @@ -148,10 +151,7 @@ describe('useDSLImport', () => { describe('setDslUrlValue', () => { it('should update DSL URL value', () => { - const { result } = renderHook( - () => useDSLImport({}), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useDSLImport({}), { wrapper: createWrapper() }) act(() => { result.current.setDslUrlValue('https://new-url.com/pipeline') @@ -163,12 +163,11 @@ describe('useDSLImport', () => { describe('handleFile', () => { it('should set file and trigger file reading', async () => { - const { result } = renderHook( - () => useDSLImport({}), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useDSLImport({}), { wrapper: createWrapper() }) - const mockFile = new File(['test content'], 'test.pipeline', { type: 'application/octet-stream' }) + const mockFile = new File(['test content'], 'test.pipeline', { + type: 'application/octet-stream', + }) await act(async () => { result.current.handleFile(mockFile) @@ -179,12 +178,11 @@ describe('useDSLImport', () => { }) it('should clear file when undefined is passed', async () => { - const { result } = renderHook( - () => useDSLImport({}), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useDSLImport({}), { wrapper: createWrapper() }) - const mockFile = new File(['test content'], 'test.pipeline', { type: 'application/octet-stream' }) + const mockFile = new File(['test content'], 'test.pipeline', { + type: 'application/octet-stream', + }) // First set a file await act(async () => { @@ -239,7 +237,11 @@ describe('useDSLImport', () => { it('should be false when URL tab is active and URL is entered', () => { const { result } = renderHook( - () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_URL, dslUrl: 'https://example.com' }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com', + }), { wrapper: createWrapper() }, ) @@ -257,12 +259,13 @@ describe('useDSLImport', () => { const onClose = vi.fn() const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - onSuccess, - onClose, - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onSuccess, + onClose, + }), { wrapper: createWrapper() }, ) @@ -290,12 +293,13 @@ describe('useDSLImport', () => { const onClose = vi.fn() const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - onSuccess, - onClose, - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onSuccess, + onClose, + }), { wrapper: createWrapper() }, ) @@ -307,9 +311,11 @@ describe('useDSLImport', () => { await waitFor(() => { expect(onSuccess).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + }), + ) expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-789/pipeline') }) @@ -318,19 +324,22 @@ describe('useDSLImport', () => { it('should handle import with COMPLETED_WITH_WARNINGS status', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'completed-with-warnings' })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ status: 'completed-with-warnings' }), + ) mockHandleCheckPluginDependencies.mockResolvedValue(undefined) const onSuccess = vi.fn() const onClose = vi.fn() const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - onSuccess, - onClose, - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onSuccess, + onClose, + }), { wrapper: createWrapper() }, ) @@ -340,9 +349,11 @@ describe('useDSLImport', () => { }) await waitFor(() => { - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'warning', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'warning', + }), + ) }) vi.useRealTimers() @@ -350,20 +361,23 @@ describe('useDSLImport', () => { it('should handle import with PENDING status and show confirm modal', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - status: 'pending', - imported_dsl_version: '0.9.0', - current_dsl_version: '1.0.0', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + status: 'pending', + imported_dsl_version: '0.9.0', + current_dsl_version: '1.0.0', + }), + ) const onClose = vi.fn() const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - onClose, - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onClose, + }), { wrapper: createWrapper() }, ) @@ -395,10 +409,11 @@ describe('useDSLImport', () => { mockImportDSL.mockResolvedValue(null) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -408,9 +423,11 @@ describe('useDSLImport', () => { }) await waitFor(() => { - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) vi.useRealTimers() @@ -418,16 +435,19 @@ describe('useDSLImport', () => { it('should handle FAILED status', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - status: 'failed', - error: 'Missing rag_pipeline data in YAML content', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + status: 'failed', + error: 'Missing rag_pipeline data in YAML content', + }), + ) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -448,18 +468,24 @@ describe('useDSLImport', () => { it('should show response error when import request rejects with a response body', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockRejectedValue(new Response(JSON.stringify({ - error: 'Missing rag_pipeline data in YAML content', - }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - })) + mockImportDSL.mockRejectedValue( + new Response( + JSON.stringify({ + error: 'Missing rag_pipeline data in YAML content', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -480,17 +506,20 @@ describe('useDSLImport', () => { it('should check plugin dependencies when pipeline_id is present', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - status: 'completed', - pipeline_id: 'pipeline-123', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + status: 'completed', + pipeline_id: 'pipeline-123', + }), + ) mockHandleCheckPluginDependencies.mockResolvedValue(undefined) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -508,16 +537,19 @@ describe('useDSLImport', () => { it('should not check plugin dependencies when pipeline_id is null', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - status: 'completed', - pipeline_id: null, - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + status: 'completed', + pipeline_id: null, + }), + ) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -537,10 +569,11 @@ describe('useDSLImport', () => { vi.useFakeTimers({ shouldAdvanceTime: true }) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: '', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: '', + }), { wrapper: createWrapper() }, ) @@ -562,20 +595,23 @@ describe('useDSLImport', () => { mockHandleCheckPluginDependencies.mockResolvedValue(undefined) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_FILE, - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_FILE, + }), { wrapper: createWrapper() }, ) const fileContent = 'test yaml content' - const mockFile = new File([fileContent], 'test.pipeline', { type: 'application/octet-stream' }) + const mockFile = new File([fileContent], 'test.pipeline', { + type: 'application/octet-stream', + }) // Set up file and wait for FileReader to complete await act(async () => { result.current.handleFile(mockFile) // Give FileReader time to process - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) }) // Trigger create @@ -598,9 +634,10 @@ describe('useDSLImport', () => { vi.useFakeTimers({ shouldAdvanceTime: true }) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_FILE, - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_FILE, + }), { wrapper: createWrapper() }, ) @@ -620,10 +657,12 @@ describe('useDSLImport', () => { vi.useFakeTimers({ shouldAdvanceTime: true }) // First, trigger pending status to get importId - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - id: 'import-123', - status: 'pending', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + id: 'import-123', + status: 'pending', + }), + ) mockImportDSLConfirm.mockResolvedValue({ status: 'completed', @@ -636,11 +675,12 @@ describe('useDSLImport', () => { const onSuccess = vi.fn() const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - onSuccess, - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onSuccess, + }), { wrapper: createWrapper() }, ) @@ -666,9 +706,11 @@ describe('useDSLImport', () => { expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-123') expect(onSuccess).toHaveBeenCalled() expect(result.current.showConfirmModal).toBe(false) - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + }), + ) }) vi.useRealTimers() @@ -677,18 +719,21 @@ describe('useDSLImport', () => { it('should handle confirm API error', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - id: 'import-123', - status: 'pending', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + id: 'import-123', + status: 'pending', + }), + ) mockImportDSLConfirm.mockResolvedValue(null) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -708,9 +753,11 @@ describe('useDSLImport', () => { }) await waitFor(() => { - expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.record).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) vi.useRealTimers() @@ -719,10 +766,12 @@ describe('useDSLImport', () => { it('should handle confirm with FAILED status', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - id: 'import-123', - status: 'pending', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + id: 'import-123', + status: 'pending', + }), + ) mockImportDSLConfirm.mockResolvedValue({ status: 'failed', @@ -732,10 +781,11 @@ describe('useDSLImport', () => { }) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -767,23 +817,31 @@ describe('useDSLImport', () => { it('should show response error when confirm request rejects with a response body', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - id: 'import-123', - status: 'pending', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + id: 'import-123', + status: 'pending', + }), + ) - mockImportDSLConfirm.mockRejectedValue(new Response(JSON.stringify({ - error: 'Import information expired or does not exist', - }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - })) + mockImportDSLConfirm.mockRejectedValue( + new Response( + JSON.stringify({ + error: 'Import information expired or does not exist', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -812,10 +870,11 @@ describe('useDSLImport', () => { it('should return early when importId is not set', async () => { const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -830,10 +889,12 @@ describe('useDSLImport', () => { it('should check plugin dependencies on confirm success', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - id: 'import-123', - status: 'pending', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + id: 'import-123', + status: 'pending', + }), + ) mockImportDSLConfirm.mockResolvedValue({ status: 'completed', @@ -844,10 +905,11 @@ describe('useDSLImport', () => { mockHandleCheckPluginDependencies.mockResolvedValue(undefined) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -877,20 +939,26 @@ describe('useDSLImport', () => { vi.useFakeTimers({ shouldAdvanceTime: true }) let resolveConfirm: (value: unknown) => void - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - id: 'import-123', - status: 'pending', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + id: 'import-123', + status: 'pending', + }), + ) - mockImportDSLConfirm.mockImplementation(() => new Promise((resolve) => { - resolveConfirm = resolve - })) + mockImportDSLConfirm.mockImplementation( + () => + new Promise((resolve) => { + resolveConfirm = resolve + }), + ) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -937,16 +1005,19 @@ describe('useDSLImport', () => { it('should close confirm modal', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - id: 'import-123', - status: 'pending', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + id: 'import-123', + status: 'pending', + }), + ) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -978,15 +1049,19 @@ describe('useDSLImport', () => { vi.useFakeTimers({ shouldAdvanceTime: true }) let resolveImport: (value: unknown) => void - mockImportDSL.mockImplementation(() => new Promise((resolve) => { - resolveImport = resolve - })) + mockImportDSL.mockImplementation( + () => + new Promise((resolve) => { + resolveImport = resolve + }), + ) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -1028,7 +1103,9 @@ describe('useDSLImport', () => { ) const fileContent = 'yaml content here' - const mockFile = new File([fileContent], 'test.pipeline', { type: 'application/octet-stream' }) + const mockFile = new File([fileContent], 'test.pipeline', { + type: 'application/octet-stream', + }) await act(async () => { result.current.handleFile(mockFile) @@ -1062,17 +1139,20 @@ describe('useDSLImport', () => { describe('navigation after import', () => { it('should navigate to pipeline page after successful import', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - status: 'completed', - dataset_id: 'test-dataset-id', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + status: 'completed', + dataset_id: 'test-dataset-id', + }), + ) mockHandleCheckPluginDependencies.mockResolvedValue(undefined) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) @@ -1091,10 +1171,12 @@ describe('useDSLImport', () => { it('should navigate to pipeline page after confirm success', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - mockImportDSL.mockResolvedValue(createImportDSLResponse({ - id: 'import-123', - status: 'pending', - })) + mockImportDSL.mockResolvedValue( + createImportDSLResponse({ + id: 'import-123', + status: 'pending', + }), + ) mockImportDSLConfirm.mockResolvedValue({ status: 'completed', @@ -1105,10 +1187,11 @@ describe('useDSLImport', () => { mockHandleCheckPluginDependencies.mockResolvedValue(undefined) const { result } = renderHook( - () => useDSLImport({ - activeTab: CreateFromDSLModalTab.FROM_URL, - dslUrl: 'https://example.com/test.pipeline', - }), + () => + useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), { wrapper: createWrapper() }, ) diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts index 8f2fa23df7a95f..6228668fabeb6c 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts @@ -27,8 +27,7 @@ type ImportErrorResponse = { error?: unknown } const getNonEmptyString = (value: unknown): string | undefined => { - if (typeof value !== 'string') - return undefined + if (typeof value !== 'string') return undefined const trimmedValue = value.trim() return trimmedValue || undefined @@ -36,18 +35,21 @@ const getNonEmptyString = (value: unknown): string | undefined => { const getImportErrorMessage = async (error: unknown): Promise<string | undefined> => { if (error instanceof Response && !error.bodyUsed) { try { - const errorData = await error.clone().json() as ImportErrorResponse + const errorData = (await error.clone().json()) as ImportErrorResponse return getNonEmptyString(errorData.message) ?? getNonEmptyString(errorData.error) - } - catch {} + } catch {} } - if (error instanceof Error) - return getNonEmptyString(error.message) + if (error instanceof Error) return getNonEmptyString(error.message) return undefined } -export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', onSuccess, onClose }: UseDSLImportOptions) => { +export const useDSLImport = ({ + activeTab = CreateFromDSLModalTab.FROM_FILE, + dslUrl = '', + onSuccess, + onClose, +}: UseDSLImportOptions) => { const { push } = useRouter() const { t } = useTranslation() const [currentFile, setDSLFile] = useState<File>() @@ -62,9 +64,12 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU const isCreatingRef = useRef(false) const { mutateAsync: importDSL } = useImportPipelineDSL() const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm() - const notifyError = useCallback((message?: string) => { - toast.error(message || t($ => $['creation.errorTip'], { ns: 'datasetPipeline' })) - }, [t]) + const notifyError = useCallback( + (message?: string) => { + toast.error(message || t(($) => $['creation.errorTip'], { ns: 'datasetPipeline' })) + }, + [t], + ) const readFile = useCallback((file: File) => { const reader = new FileReader() reader.onload = (event) => { @@ -73,20 +78,18 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU } reader.readAsText(file) }, []) - const handleFile = useCallback((file?: File) => { - setDSLFile(file) - if (file) - readFile(file) - if (!file) - setFileContent('') - }, [readFile]) + const handleFile = useCallback( + (file?: File) => { + setDSLFile(file) + if (file) readFile(file) + if (!file) setFileContent('') + }, + [readFile], + ) const onCreate = useCallback(async () => { - if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) - return - if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue) - return - if (isCreatingRef.current) - return + if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) return + if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue) return + if (isCreatingRef.current) return isCreatingRef.current = true try { let response @@ -106,19 +109,30 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU notifyError() return } - const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response - if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { + const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = + response + if ( + status === DSLImportStatus.COMPLETED || + status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ) { onSuccess?.() onClose?.() - toast(t($ => $[status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution'], { ns: 'datasetPipeline' }), { - type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', - description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t($ => $['newApp.appCreateDSLWarning'], { ns: 'app' }), - }) - if (pipeline_id) - await handleCheckPluginDependencies(pipeline_id, true) + toast( + t( + ($) => + $[status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution'], + { ns: 'datasetPipeline' }, + ), + { + type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', + description: + status === DSLImportStatus.COMPLETED_WITH_WARNINGS && + t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }), + }, + ) + if (pipeline_id) await handleCheckPluginDependencies(pipeline_id, true) push(`/datasets/${dataset_id}/pipeline`) - } - else if (status === DSLImportStatus.PENDING) { + } else if (status === DSLImportStatus.PENDING) { setVersions({ importedVersion: imported_dsl_version ?? '', systemVersion: current_dsl_version ?? '', @@ -128,15 +142,12 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU setShowConfirmModal(true) }, 300) setImportId(id) - } - else { + } else { notifyError(response.error) } - } - catch (error) { + } catch (error) { notifyError(await getImportErrorMessage(error)) - } - finally { + } finally { isCreatingRef.current = false } }, [ @@ -154,8 +165,7 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU ]) const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) const onDSLConfirm = useCallback(async () => { - if (!importId) - return + if (!importId) return setIsConfirming(true) try { const response = await importDSLConfirm(importId) @@ -167,19 +177,15 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU if (status === DSLImportStatus.COMPLETED) { onSuccess?.() setShowConfirmModal(false) - toast.success(t($ => $['creation.successTip'], { ns: 'datasetPipeline' })) - if (pipeline_id) - await handleCheckPluginDependencies(pipeline_id, true) + toast.success(t(($) => $['creation.successTip'], { ns: 'datasetPipeline' })) + if (pipeline_id) await handleCheckPluginDependencies(pipeline_id, true) push(`/datasets/${dataset_id}/pipeline`) - } - else if (status === DSLImportStatus.FAILED) { + } else if (status === DSLImportStatus.FAILED) { notifyError(error) } - } - catch (error) { + } catch (error) { notifyError(await getImportErrorMessage(error)) - } - finally { + } finally { setIsConfirming(false) } }, [importId, importDSLConfirm, notifyError, t, onSuccess, handleCheckPluginDependencies, push]) @@ -187,10 +193,8 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU setShowConfirmModal(false) }, []) const buttonDisabled = useMemo(() => { - if (currentTab === CreateFromDSLModalTab.FROM_FILE) - return !currentFile - if (currentTab === CreateFromDSLModalTab.FROM_URL) - return !dslUrlValue + if (currentTab === CreateFromDSLModalTab.FROM_FILE) return !currentFile + if (currentTab === CreateFromDSLModalTab.FROM_URL) return !dslUrlValue return false }, [currentTab, currentFile, dslUrlValue]) return { diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx index 42aa0e658eb75e..012f7223fe1ec0 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx @@ -51,46 +51,34 @@ const CreateFromDSLModal = ({ return ( <> - <Dialog open={show} onOpenChange={open => !open && !showConfirmModal && onClose()}> + <Dialog open={show} onOpenChange={(open) => !open && !showConfirmModal && onClose()}> <DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl"> - <Header onClose={onClose} /> - <Tab - currentTab={currentTab} - setCurrentTab={setCurrentTab} - /> + <Tab currentTab={currentTab} setCurrentTab={setCurrentTab} /> <div className="px-6 py-4"> {currentTab === CreateFromDSLModalTab.FROM_FILE && ( - <Uploader - className="mt-0" - file={currentFile} - updateFile={handleFile} - /> + <Uploader className="mt-0" file={currentFile} updateFile={handleFile} /> )} {currentTab === CreateFromDSLModalTab.FROM_URL && ( <div> - <div className="leading6 mb-1 system-md-semibold text-text-secondary"> - DSL URL - </div> + <div className="leading6 mb-1 system-md-semibold text-text-secondary">DSL URL</div> <Input - placeholder={t($ => $.importFromDSLUrlPlaceholder, { ns: 'app' }) || ''} + placeholder={t(($) => $.importFromDSLUrlPlaceholder, { ns: 'app' }) || ''} value={dslUrlValue} - onChange={e => setDslUrlValue(e.target.value)} + onChange={(e) => setDslUrlValue(e.target.value)} /> </div> )} </div> <div className="flex justify-end gap-x-2 p-6 pt-5"> - <Button onClick={onClose}> - {t($ => $['newApp.Cancel'], { ns: 'app' })} - </Button> + <Button onClick={onClose}>{t(($) => $['newApp.Cancel'], { ns: 'app' })}</Button> <Button disabled={buttonDisabled} variant="primary" onClick={handleCreateApp} className="gap-1" > - <span>{t($ => $['newApp.import'], { ns: 'app' })}</span> + <span>{t(($) => $['newApp.import'], { ns: 'app' })}</span> </Button> </div> </DialogContent> diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/index.spec.tsx index c7d12e70286760..a417a5e122bc1d 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/index.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal' import Tab from '../index' diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/item.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/item.spec.tsx index bc89302c2cfd41..1084806110684a 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/item.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/item.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import Item from '../item' // Item Component Tests diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.tsx index c5fa6d58ca4866..129df9e34a8814 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.tsx @@ -8,35 +8,30 @@ type TabProps = { setCurrentTab: (tab: CreateFromDSLModalTab) => void } -const Tab = ({ - currentTab, - setCurrentTab, -}: TabProps) => { +const Tab = ({ currentTab, setCurrentTab }: TabProps) => { const { t } = useTranslation() const tabs = [ { key: CreateFromDSLModalTab.FROM_FILE, - label: t($ => $.importFromDSLFile, { ns: 'app' }), + label: t(($) => $.importFromDSLFile, { ns: 'app' }), }, { key: CreateFromDSLModalTab.FROM_URL, - label: t($ => $.importFromDSLUrl, { ns: 'app' }), + label: t(($) => $.importFromDSLUrl, { ns: 'app' }), }, ] return ( <div className="flex h-9 items-center gap-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary"> - { - tabs.map(tab => ( - <Item - key={tab.key} - isActive={currentTab === tab.key} - label={tab.label} - onClick={setCurrentTab.bind(null, tab.key)} - /> - )) - } + {tabs.map((tab) => ( + <Item + key={tab.key} + isActive={currentTab === tab.key} + label={tab.label} + onClick={setCurrentTab.bind(null, tab.key)} + /> + ))} </div> ) } diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/item.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/item.tsx index 0dd0454eb29506..2e076d85866f12 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/item.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/item.tsx @@ -7,11 +7,7 @@ type ItemProps = { onClick: () => void } -const Item = ({ - isActive, - label, - onClick, -}: ItemProps) => { +const Item = ({ isActive, label, onClick }: ItemProps) => { return ( <div className={cn( @@ -21,11 +17,9 @@ const Item = ({ onClick={onClick} > {label} - { - isActive && ( - <div className="absolute bottom-0 h-0.5 w-full bg-util-colors-blue-brand-blue-brand-600" /> - ) - } + {isActive && ( + <div className="absolute bottom-0 h-0.5 w-full bg-util-colors-blue-brand-blue-brand-600" /> + )} </div> ) } diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx index dd5bf01bb05d15..9de6e9efe11cb6 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx @@ -23,8 +23,7 @@ const Uploader: FC<Props> = ({ file, updateFile, className }) => { const handleDragEnter = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.target !== dragRef.current) - setDragging(true) + if (e.target !== dragRef.current) setDragging(true) } const handleDragOver = (e: DragEvent) => { e.preventDefault() @@ -33,18 +32,16 @@ const Uploader: FC<Props> = ({ file, updateFile, className }) => { const handleDragLeave = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.target === dragRef.current) - setDragging(false) + if (e.target === dragRef.current) setDragging(false) } const handleDrop = (e: DragEvent) => { e.preventDefault() e.stopPropagation() setDragging(false) - if (!e.dataTransfer) - return + if (!e.dataTransfer) return const files = Array.from(e.dataTransfer.files) if (files.length > 1) { - toast.error(t($ => $['stepOne.uploader.validation.count'], { ns: 'datasetCreation' })) + toast.error(t(($) => $['stepOne.uploader.validation.count'], { ns: 'datasetCreation' })) return } updateFile(files[0]) @@ -59,8 +56,7 @@ const Uploader: FC<Props> = ({ file, updateFile, className }) => { } } const removeFile = () => { - if (fileUploader.current) - fileUploader.current.value = '' + if (fileUploader.current) fileUploader.current.value = '' updateFile() } const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => { @@ -82,20 +78,33 @@ const Uploader: FC<Props> = ({ file, updateFile, className }) => { }, []) return ( <div className={cn('mt-6', className)}> - <input ref={fileUploader} style={{ display: 'none' }} type="file" id="fileUploader" accept=".pipeline" onChange={fileChangeHandle} /> + <input + ref={fileUploader} + style={{ display: 'none' }} + type="file" + id="fileUploader" + accept=".pipeline" + onChange={fileChangeHandle} + /> <div ref={dropRef}> {!file && ( - <div className={cn('flex h-12 items-center rounded-[10px] border border-dashed border-components-dropzone-border bg-components-dropzone-bg text-sm font-normal', dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent')}> + <div + className={cn( + 'flex h-12 items-center rounded-[10px] border border-dashed border-components-dropzone-border bg-components-dropzone-bg text-sm font-normal', + dragging && + 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent', + )} + > <div className="flex w-full items-center justify-center space-x-2"> <RiUploadCloud2Line className="size-6 text-text-tertiary" /> <div className="text-text-tertiary"> - {t($ => $['dslUploader.button'], { ns: 'app' })} + {t(($) => $['dslUploader.button'], { ns: 'app' })} <button type="button" className="inline cursor-pointer border-none bg-transparent p-0 pl-1 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={selectHandle} > - {t($ => $['dslUploader.browse'], { ns: 'app' })} + {t(($) => $['dslUploader.browse'], { ns: 'app' })} </button> </div> </div> diff --git a/web/app/components/datasets/create-from-pipeline/footer.tsx b/web/app/components/datasets/create-from-pipeline/footer.tsx index aede97e6259536..7921b213715879 100644 --- a/web/app/components/datasets/create-from-pipeline/footer.tsx +++ b/web/app/components/datasets/create-from-pipeline/footer.tsx @@ -18,8 +18,7 @@ const Footer = () => { const invalidDatasetList = useInvalidDatasetList() const activeTab = useMemo(() => { - if (dslUrl) - return CreateFromDSLModalTab.FROM_URL + if (dslUrl) return CreateFromDSLModalTab.FROM_URL return undefined }, [dslUrl]) @@ -30,8 +29,7 @@ const Footer = () => { const onCloseImportModal = useCallback(() => { setShowImportModal(false) - if (dslUrl) - replace('/datasets/create-from-pipeline') + if (dslUrl) replace('/datasets/create-from-pipeline') }, [dslUrl, replace]) const onImportFromDSLSuccess = useCallback(() => { @@ -47,7 +45,7 @@ const Footer = () => { onClick={openImportFromDSL} > <RiFileUploadLine className="size-5" /> - <span>{t($ => $['creation.importDSL'], { ns: 'datasetPipeline' })}</span> + <span>{t(($) => $['creation.importDSL'], { ns: 'datasetPipeline' })}</span> </button> <CreateFromDSLModal show={showImportModal} diff --git a/web/app/components/datasets/create-from-pipeline/header.tsx b/web/app/components/datasets/create-from-pipeline/header.tsx index cbdf3d2b83aef7..4989133cf6c010 100644 --- a/web/app/components/datasets/create-from-pipeline/header.tsx +++ b/web/app/components/datasets/create-from-pipeline/header.tsx @@ -9,16 +9,9 @@ const Header = () => { return ( <div className="relative flex px-16 pt-5 pb-2 system-md-semibold text-text-primary"> - <span>{t($ => $['creation.backToKnowledge'], { ns: 'datasetPipeline' })}</span> - <Link - className="absolute bottom-0 left-5" - href="/datasets" - replace - > - <Button - variant="secondary-accent" - className="size-9 rounded-full p-0" - > + <span>{t(($) => $['creation.backToKnowledge'], { ns: 'datasetPipeline' })}</span> + <Link className="absolute bottom-0 left-5" href="/datasets" replace> + <Button variant="secondary-accent" className="size-9 rounded-full p-0"> <RiArrowLeftLine className="size-5" /> </Button> </Link> diff --git a/web/app/components/datasets/create-from-pipeline/index.tsx b/web/app/components/datasets/create-from-pipeline/index.tsx index f5b5c2ec8bfe07..7b33822b35334d 100644 --- a/web/app/components/datasets/create-from-pipeline/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/index.tsx @@ -6,9 +6,7 @@ import List from './list' const CreateFromPipeline = () => { return ( - <div - className="relative flex h-[calc(100vh-56px)] flex-col overflow-hidden rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle" - > + <div className="relative flex h-[calc(100vh-56px)] flex-col overflow-hidden rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle"> <Effect className="top-[-34px] left-8 opacity-20" /> <Header /> <List /> diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx index a411f0d612cb7e..ce5ff2d24570e6 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx @@ -2,19 +2,27 @@ import type { ReactElement } from 'react' import { screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' - import BuiltInPipelineList from '../built-in-pipeline-list' -const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { - systemFeatures: { enable_marketplace: true }, -}) +const render = (ui: ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { enable_marketplace: true }, + }) vi.mock('../create-card', () => ({ default: () => <div data-testid="create-card">CreateCard</div>, })) vi.mock('../template-card', () => ({ - default: ({ type, pipeline, showMoreOperations }: { type: string, pipeline: { name: string }, showMoreOperations?: boolean }) => ( + default: ({ + type, + pipeline, + showMoreOperations, + }: { + type: string + pipeline: { name: string } + showMoreOperations?: boolean + }) => ( <div data-testid="template-card" data-type={type} data-show-more={String(showMoreOperations)}> {pipeline.name} </div> @@ -81,10 +89,7 @@ describe('BuiltInPipelineList', () => { // Rendering with Data Tests describe('Rendering with Data', () => { it('should render TemplateCard for each pipeline when not loading', () => { - const mockPipelines = [ - { name: 'Pipeline 1' }, - { name: 'Pipeline 2' }, - ] + const mockPipelines = [{ name: 'Pipeline 1' }, { name: 'Pipeline 2' }] mockUsePipelineTemplateList.mockReturnValue({ data: { pipeline_templates: mockPipelines }, isLoading: false, @@ -150,7 +155,12 @@ describe('BuiltInPipelineList', () => { const { container } = render(<BuiltInPipelineList />) const grid = container.querySelector('.grid') expect(grid).toHaveClass('grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', 'gap-3', 'py-2') - expect(grid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'md:grid-cols-3', 'lg:grid-cols-4') + expect(grid).not.toHaveClass( + 'grid-cols-1', + 'sm:grid-cols-2', + 'md:grid-cols-3', + 'lg:grid-cols-4', + ) }) }) diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx index 57f6b00819169b..0bf08ecee2b6df 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import CreateCard from '../create-card' const mockPush = vi.fn() @@ -80,7 +79,9 @@ describe('CreateCard', () => { render(<CreateCard />) - const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]') + const card = screen + .getByText(/createFromScratch\.title/i) + .closest('div[class*="cursor-pointer"]') fireEvent.click(card!) await waitFor(() => { @@ -96,7 +97,9 @@ describe('CreateCard', () => { render(<CreateCard />) - const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]') + const card = screen + .getByText(/createFromScratch\.title/i) + .closest('div[class*="cursor-pointer"]') fireEvent.click(card!) await waitFor(() => { @@ -112,7 +115,9 @@ describe('CreateCard', () => { render(<CreateCard />) - const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]') + const card = screen + .getByText(/createFromScratch\.title/i) + .closest('div[class*="cursor-pointer"]') fireEvent.click(card!) await waitFor(() => { @@ -128,7 +133,9 @@ describe('CreateCard', () => { render(<CreateCard />) - const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]') + const card = screen + .getByText(/createFromScratch\.title/i) + .closest('div[class*="cursor-pointer"]') fireEvent.click(card!) // Should not throw and should handle error gracefully @@ -145,7 +152,9 @@ describe('CreateCard', () => { render(<CreateCard />) - const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]') + const card = screen + .getByText(/createFromScratch\.title/i) + .closest('div[class*="cursor-pointer"]') fireEvent.click(card!) await waitFor(() => { diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/customized-list.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/customized-list.spec.tsx index 610f8f8b97c7b2..a0b4c096b83780 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/customized-list.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/customized-list.spec.tsx @@ -1,11 +1,10 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import CustomizedList from '../customized-list' // Mock TemplateCard vi.mock('../template-card', () => ({ - default: ({ type, pipeline }: { type: string, pipeline: { name: string } }) => ( + default: ({ type, pipeline }: { type: string; pipeline: { name: string } }) => ( <div data-testid="template-card" data-type={type}> {pipeline.name} </div> @@ -66,9 +65,7 @@ describe('CustomizedList', () => { it('should render title when list has items', () => { mockUsePipelineTemplateList.mockReturnValue({ data: { - pipeline_templates: [ - { name: 'Pipeline 1' }, - ], + pipeline_templates: [{ name: 'Pipeline 1' }], }, isLoading: false, }) @@ -78,11 +75,7 @@ describe('CustomizedList', () => { }) it('should render TemplateCard for each pipeline', () => { - const mockPipelines = [ - { name: 'Pipeline 1' }, - { name: 'Pipeline 2' }, - { name: 'Pipeline 3' }, - ] + const mockPipelines = [{ name: 'Pipeline 1' }, { name: 'Pipeline 2' }, { name: 'Pipeline 3' }] mockUsePipelineTemplateList.mockReturnValue({ data: { pipeline_templates: mockPipelines }, isLoading: false, @@ -133,7 +126,12 @@ describe('CustomizedList', () => { const { container } = render(<CustomizedList />) const grid = container.querySelector('.grid') expect(grid).toHaveClass('grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', 'gap-3', 'py-2') - expect(grid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'md:grid-cols-3', 'lg:grid-cols-4') + expect(grid).not.toHaveClass( + 'grid-cols-1', + 'sm:grid-cols-2', + 'md:grid-cols-3', + 'lg:grid-cols-4', + ) }) }) }) diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/index.spec.tsx index 4c29d1935924bb..b7d50461643fac 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/index.spec.tsx @@ -1,6 +1,5 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' - import List from '../index' vi.mock('../built-in-pipeline-list', () => ({ diff --git a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx index 72a121d5124af4..749c67aea0d00a 100644 --- a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx @@ -10,28 +10,31 @@ import TemplateCard from './template-card' const BuiltInPipelineList = () => { const locale = useLocale() const language = useMemo(() => { - if (['zh-Hans', 'ja-JP'].includes(locale)) - return locale + if (['zh-Hans', 'ja-JP'].includes(locale)) return locale return LanguagesSupported[0] }, [locale]) const { data: enableMarketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) - const { data: pipelineList, isLoading } = usePipelineTemplateList({ type: 'built-in', language }, enableMarketplace) + const { data: pipelineList, isLoading } = usePipelineTemplateList( + { type: 'built-in', language }, + enableMarketplace, + ) const list = pipelineList?.pipeline_templates || [] return ( <div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-3 py-2"> <CreateCard /> - {!isLoading && list.map((pipeline, index) => ( - <TemplateCard - key={index} - type="built-in" - pipeline={pipeline} - showMoreOperations={false} - /> - ))} + {!isLoading && + list.map((pipeline, index) => ( + <TemplateCard + key={index} + type="built-in" + pipeline={pipeline} + showMoreOperations={false} + /> + ))} </div> ) } diff --git a/web/app/components/datasets/create-from-pipeline/list/create-card.tsx b/web/app/components/datasets/create-from-pipeline/list/create-card.tsx index 7b9c7a12a8698b..dcdfe70d5965b7 100644 --- a/web/app/components/datasets/create-from-pipeline/list/create-card.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/create-card.tsx @@ -20,7 +20,7 @@ const CreateCard = () => { onSuccess: (data) => { if (data) { const { id } = data - toast.success(t($ => $['creation.successTip'], { ns: 'datasetPipeline' })) + toast.success(t(($) => $['creation.successTip'], { ns: 'datasetPipeline' })) invalidDatasetList() trackEvent('create_datasets_from_scratch', { dataset_id: id, @@ -29,7 +29,7 @@ const CreateCard = () => { } }, onError: () => { - toast.error(t($ => $['creation.errorTip'], { ns: 'datasetPipeline' })) + toast.error(t(($) => $['creation.errorTip'], { ns: 'datasetPipeline' })) }, }) }, [createEmptyDataset, push, invalidDatasetList, t]) @@ -44,11 +44,11 @@ const CreateCard = () => { <RiAddCircleLine className="size-5 text-text-quaternary group-hover:text-text-accent" /> </div> <div className="truncate system-md-semibold text-text-primary"> - {t($ => $['creation.createFromScratch.title'], { ns: 'datasetPipeline' })} + {t(($) => $['creation.createFromScratch.title'], { ns: 'datasetPipeline' })} </div> </div> <p className="line-clamp-3 px-4 py-1 system-xs-regular text-text-tertiary"> - {t($ => $['creation.createFromScratch.description'], { ns: 'datasetPipeline' })} + {t(($) => $['creation.createFromScratch.description'], { ns: 'datasetPipeline' })} </p> </div> ) diff --git a/web/app/components/datasets/create-from-pipeline/list/customized-list.tsx b/web/app/components/datasets/create-from-pipeline/list/customized-list.tsx index 6e001795403164..20e2fcef10dc8b 100644 --- a/web/app/components/datasets/create-from-pipeline/list/customized-list.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/customized-list.tsx @@ -7,19 +7,16 @@ const CustomizedList = () => { const { data: pipelineList, isLoading } = usePipelineTemplateList({ type: 'customized' }) const list = pipelineList?.pipeline_templates || [] - if (isLoading || list.length === 0) - return null + if (isLoading || list.length === 0) return null return ( <> - <div className="pt-2 system-sm-semibold-uppercase text-text-tertiary">{t($ => $['templates.customized'], { ns: 'datasetPipeline' })}</div> + <div className="pt-2 system-sm-semibold-uppercase text-text-tertiary"> + {t(($) => $['templates.customized'], { ns: 'datasetPipeline' })} + </div> <div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-3 py-2"> {list.map((pipeline, index) => ( - <TemplateCard - key={index} - type="customized" - pipeline={pipeline} - /> + <TemplateCard key={index} type="customized" pipeline={pipeline} /> ))} </div> </> diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/actions.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/actions.spec.tsx index 999843a1388ae9..41ae70a943f452 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/actions.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/actions.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import Actions from '../actions' // Actions Component Tests diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/content.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/content.spec.tsx index e587dc23fd292e..8e36cd62821dd9 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/content.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/content.spec.tsx @@ -2,7 +2,6 @@ import type { IconInfo } from '@/models/datasets' import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { ChunkingMode } from '@/models/datasets' - import Content from '../content' const createIconInfo = (overrides: Partial<IconInfo> = {}): IconInfo => ({ diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx index 6560c8617c03f9..68d6f1e72ff7f0 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx @@ -485,7 +485,9 @@ describe('EditPipelineInfo', () => { return Promise.resolve() }) - const { container } = render(<EditPipelineInfo {...defaultProps} pipeline={createImagePipelineTemplate()} />) + const { container } = render( + <EditPipelineInfo {...defaultProps} pipeline={createImagePipelineTemplate()} />, + ) const appIcon = container.querySelector('[class*="cursor-pointer"]') fireEvent.click(appIcon!) diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx index 8f28f750459784..e5627cd384ce3f 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx @@ -43,7 +43,14 @@ let _capturedHandleExportDSL: (() => void) | undefined let _capturedOpenEditModal: (() => void) | undefined vi.mock('../actions', () => ({ - default: ({ onApplyTemplate, handleShowTemplateDetails, showMoreOperations, openEditModal, handleExportDSL, handleDelete }: { + default: ({ + onApplyTemplate, + handleShowTemplateDetails, + showMoreOperations, + openEditModal, + handleExportDSL, + handleDelete, + }: { onApplyTemplate: () => void handleShowTemplateDetails: () => void showMoreOperations: boolean @@ -56,13 +63,23 @@ vi.mock('../actions', () => ({ _capturedOpenEditModal = openEditModal return ( <div data-testid="actions"> - <button data-testid="action-choose" onClick={onApplyTemplate}>operations.choose</button> - <button data-testid="action-details" onClick={handleShowTemplateDetails}>operations.details</button> + <button data-testid="action-choose" onClick={onApplyTemplate}> + operations.choose + </button> + <button data-testid="action-details" onClick={handleShowTemplateDetails}> + operations.details + </button> {showMoreOperations && ( <> - <button data-testid="action-edit" onClick={openEditModal}>Edit</button> - <button data-testid="action-export" onClick={handleExportDSL}>Export</button> - <button data-testid="action-delete" onClick={handleDelete}>Delete</button> + <button data-testid="action-edit" onClick={openEditModal}> + Edit + </button> + <button data-testid="action-export" onClick={handleExportDSL}> + Export + </button> + <button data-testid="action-delete" onClick={handleDelete}> + Delete + </button> </> )} </div> @@ -74,17 +91,23 @@ vi.mock('../actions', () => ({ vi.mock('../edit-pipeline-info', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="edit-pipeline-info"> - <button data-testid="edit-close" onClick={onClose}>Close</button> + <button data-testid="edit-close" onClick={onClose}> + Close + </button> </div> ), })) // Mock Details component vi.mock('../details', () => ({ - default: ({ onClose, onApplyTemplate }: { onClose: () => void, onApplyTemplate: () => void }) => ( + default: ({ onClose, onApplyTemplate }: { onClose: () => void; onApplyTemplate: () => void }) => ( <div data-testid="details-component"> - <button data-testid="details-close" onClick={onClose}>Close</button> - <button data-testid="details-apply" onClick={onApplyTemplate}>Apply</button> + <button data-testid="details-close" onClick={onClose}> + Close + </button> + <button data-testid="details-apply" onClick={onApplyTemplate}> + Apply + </button> </div> ), })) @@ -119,7 +142,9 @@ vi.mock('@/service/use-pipeline', () => ({ }), useExportTemplateDSL: () => ({ mutateAsync: mockExportPipelineDSL, - get isPending() { return mockIsExporting }, + get isPending() { + return mockIsExporting + }, }), useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList, })) @@ -155,8 +180,10 @@ describe('TemplateCard', () => { type: 'customized' as const, } - const getDeleteConfirmButton = () => screen.getByRole('button', { name: 'common.operation.confirm' }) - const getDeleteCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' }) + const getDeleteConfirmButton = () => + screen.getByRole('button', { name: 'common.operation.confirm' }) + const getDeleteCancelButton = () => + screen.getByRole('button', { name: 'common.operation.cancel' }) beforeEach(() => { vi.clearAllMocks() @@ -481,9 +508,11 @@ describe('TemplateCard', () => { fireEvent.click(exportButton) await waitFor(() => { - expect(downloadBlob).toHaveBeenCalledWith(expect.objectContaining({ - fileName: 'Test Pipeline.pipeline', - })) + expect(downloadBlob).toHaveBeenCalledWith( + expect.objectContaining({ + fileName: 'Test Pipeline.pipeline', + }), + ) }) }) }) @@ -662,7 +691,14 @@ describe('TemplateCard', () => { it('should have proper card styling', () => { const { container } = render(<TemplateCard {...defaultProps} />) const card = container.firstChild as HTMLElement - expect(card).toHaveClass('group', 'relative', 'flex', 'cursor-pointer', 'flex-col', 'rounded-xl') + expect(card).toHaveClass( + 'group', + 'relative', + 'flex', + 'cursor-pointer', + 'flex-col', + 'rounded-xl', + ) }) it('should have fixed height', () => { diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/operations.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/operations.spec.tsx index 17df8ae5741625..c235567f15ef27 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/operations.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/operations.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import Operations from '../operations' // Operations Component Tests @@ -49,7 +48,9 @@ describe('Operations', () => { it('should call onExport when export is clicked', () => { render(<Operations {...defaultProps} />) - const exportButton = screen.getByText(/exportPipeline/i).closest('div[class*="cursor-pointer"]') + const exportButton = screen + .getByText(/exportPipeline/i) + .closest('div[class*="cursor-pointer"]') fireEvent.click(exportButton!) expect(defaultProps.onExport).toHaveBeenCalledTimes(1) @@ -82,7 +83,9 @@ describe('Operations', () => { it('should stop propagation on export click', () => { render(<Operations {...defaultProps} />) - const exportButton = screen.getByText(/exportPipeline/i).closest('div[class*="cursor-pointer"]') + const exportButton = screen + .getByText(/exportPipeline/i) + .closest('div[class*="cursor-pointer"]') fireEvent.click(exportButton!) expect(defaultProps.onExport).toHaveBeenCalled() diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx index 0d8806ed9b926f..767a7a947da2da 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx @@ -36,50 +36,44 @@ const Actions = ({ isMoreOperationsOpen ? 'flex' : 'hidden group-hover:flex', )} > - <Button - variant="primary" - onClick={onApplyTemplate} - className="grow gap-x-0.5" - > + <Button variant="primary" onClick={onApplyTemplate} className="grow gap-x-0.5"> <span aria-hidden className="i-ri-add-line size-4" /> - <span className="px-0.5">{t($ => $['operations.choose'], { ns: 'datasetPipeline' })}</span> + <span className="px-0.5"> + {t(($) => $['operations.choose'], { ns: 'datasetPipeline' })} + </span> </Button> - <Button - variant="secondary" - onClick={handleShowTemplateDetails} - className="grow gap-x-0.5" - > + <Button variant="secondary" onClick={handleShowTemplateDetails} className="grow gap-x-0.5"> <span aria-hidden className="i-ri-arrow-right-up-line size-4" /> - <span className="px-0.5">{t($ => $['operations.details'], { ns: 'datasetPipeline' })}</span> + <span className="px-0.5"> + {t(($) => $['operations.details'], { ns: 'datasetPipeline' })} + </span> </Button> - { - showMoreOperations && ( - <DropdownMenu open={isMoreOperationsOpen} onOpenChange={setIsMoreOperationsOpen}> - <DropdownMenuTrigger - aria-label={t($ => $['operation.more'], { ns: 'common' })} - className={cn( - 'flex size-8 cursor-pointer items-center justify-center rounded-lg p-0 shadow-xs shadow-shadow-shadow-3', - 'data-popup-open:bg-state-base-hover', - )} - onClick={e => e.stopPropagation()} - > - <span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" /> - </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="min-w-[160px] border-0 bg-transparent py-0 shadow-none backdrop-blur-none" - > - <Operations - openEditModal={openEditModal} - onExport={handleExportDSL} - onDelete={handleDelete} - onClose={() => setIsMoreOperationsOpen(false)} - /> - </DropdownMenuContent> - </DropdownMenu> - ) - } + {showMoreOperations && ( + <DropdownMenu open={isMoreOperationsOpen} onOpenChange={setIsMoreOperationsOpen}> + <DropdownMenuTrigger + aria-label={t(($) => $['operation.more'], { ns: 'common' })} + className={cn( + 'flex size-8 cursor-pointer items-center justify-center rounded-lg p-0 shadow-xs shadow-shadow-shadow-3', + 'data-popup-open:bg-state-base-hover', + )} + onClick={(e) => e.stopPropagation()} + > + <span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" /> + </DropdownMenuTrigger> + <DropdownMenuContent + placement="bottom-end" + sideOffset={4} + popupClassName="min-w-[160px] border-0 bg-transparent py-0 shadow-none backdrop-blur-none" + > + <Operations + openEditModal={openEditModal} + onExport={handleExportDSL} + onDelete={handleDelete} + onClose={() => setIsMoreOperationsOpen(false)} + /> + </DropdownMenuContent> + </DropdownMenu> + )} </div> ) } diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/content.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/content.tsx index 3b902e5b3e424d..66cbf5cafd2866 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/content.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/content.tsx @@ -12,12 +12,7 @@ type ContentProps = { chunkStructure: ChunkingMode } -const Content = ({ - name, - description, - iconInfo, - chunkStructure, -}: ContentProps) => { +const Content = ({ name, description, iconInfo, chunkStructure }: ContentProps) => { const { t } = useTranslation() const Icon = DOC_FORM_ICON_WITH_BG[chunkStructure] || General @@ -37,14 +32,11 @@ const Content = ({ </div> </div> <div className="flex grow flex-col gap-y-1 overflow-hidden py-px"> - <div - className="truncate system-md-semibold text-text-secondary" - title={name} - > + <div className="truncate system-md-semibold text-text-secondary" title={name}> {name} </div> <div className="system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $[`chunkingMode.${DOC_FORM_TEXT[chunkStructure]}`], { ns: 'dataset' })} + {t(($) => $[`chunkingMode.${DOC_FORM_TEXT[chunkStructure]}`], { ns: 'dataset' })} </div> </div> </div> diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/chunk-structure-card.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/chunk-structure-card.spec.tsx index 62e043a0f7189b..2dd5acd2851d24 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/chunk-structure-card.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/chunk-structure-card.spec.tsx @@ -1,6 +1,5 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' - import ChunkStructureCard from '../chunk-structure-card' import { EffectColor } from '../types' diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/hooks.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/hooks.spec.tsx index b3d3b98043d46f..2b170ad94c04da 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/hooks.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/hooks.spec.tsx @@ -1,6 +1,5 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it } from 'vitest' - import { ChunkingMode } from '@/models/datasets' import { useChunkStructureConfig } from '../hooks' import { EffectColor } from '../types' diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/index.spec.tsx index d6b3456ba227fe..9c547d850ad0c8 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/index.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import Details from '../index' // Mock WorkflowPreview diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/details/chunk-structure-card.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/details/chunk-structure-card.tsx index 217313c7c499da..5c02931548f609 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/details/chunk-structure-card.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/details/chunk-structure-card.tsx @@ -29,38 +29,33 @@ const ChunkStructureCard = ({ effectColor, }: ChunkStructureCardProps) => { return ( - <div className={cn( - 'relative flex overflow-hidden rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-bg p-2 shadow-xs shadow-shadow-shadow-3', - className, - )} - > - <div className={cn( - 'absolute -top-1 -left-1 size-14 rounded-full blur-[80px]', - `${HEADER_EFFECT_MAP[effectColor]}`, + <div + className={cn( + 'relative flex overflow-hidden rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-bg p-2 shadow-xs shadow-shadow-shadow-3', + className, )} + > + <div + className={cn( + 'absolute -top-1 -left-1 size-14 rounded-full blur-[80px]', + `${HEADER_EFFECT_MAP[effectColor]}`, + )} /> <div className="p-1"> - <div className={cn( - 'flex size-6 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-subtle text-text-primary-on-surface shadow-md shadow-shadow-shadow-5', - `${IconBackgroundColorMap[effectColor]}`, - )} + <div + className={cn( + 'flex size-6 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-subtle text-text-primary-on-surface shadow-md shadow-shadow-shadow-5', + `${IconBackgroundColorMap[effectColor]}`, + )} > {icon} </div> </div> <div className="flex grow flex-col gap-y-0.5 py-px"> <div className="flex items-center gap-x-1"> - <span className="system-sm-medium text-text-secondary"> - {title} - </span> + <span className="system-sm-medium text-text-secondary">{title}</span> </div> - { - description && ( - <div className="system-xs-regular text-text-tertiary"> - {description} - </div> - ) - } + {description && <div className="system-xs-regular text-text-tertiary">{description}</div>} </div> </div> ) diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/details/hooks.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/details/hooks.tsx index fbb7140e67f98b..676e6f2f62a558 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/details/hooks.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/details/hooks.tsx @@ -1,6 +1,10 @@ import type { Option } from './types' import { useTranslation } from 'react-i18next' -import { GeneralChunk, ParentChildChunk, QuestionAndAnswer } from '@/app/components/base/icons/src/vender/knowledge' +import { + GeneralChunk, + ParentChildChunk, + QuestionAndAnswer, +} from '@/app/components/base/icons/src/vender/knowledge' import { ChunkingMode } from '@/models/datasets' import { EffectColor } from './types' @@ -10,21 +14,20 @@ export const useChunkStructureConfig = () => { const GeneralOption: Option = { icon: <GeneralChunk className="size-4" />, title: 'General', - description: t($ => $['stepTwo.generalTip'], { ns: 'datasetCreation' }), + description: t(($) => $['stepTwo.generalTip'], { ns: 'datasetCreation' }), effectColor: EffectColor.indigo, } const ParentChildOption: Option = { icon: <ParentChildChunk className="size-4" />, title: 'Parent-Child', - description: t($ => $['stepTwo.parentChildTip'], { ns: 'datasetCreation' }), + description: t(($) => $['stepTwo.parentChildTip'], { ns: 'datasetCreation' }), effectColor: EffectColor.blueLight, } const QuestionAnswerOption: Option = { icon: <QuestionAndAnswer className="size-4" />, title: 'Q&A', - description: t($ => $['stepTwo.qaTip'], { ns: 'datasetCreation' }), + description: t(($) => $['stepTwo.qaTip'], { ns: 'datasetCreation' }), effectColor: EffectColor.green, - } const chunkStructureConfig: Record<ChunkingMode, Option> = { diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx index 3274ec5c79c337..92fdaeb432e591 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx @@ -19,21 +19,18 @@ type DetailsProps = { onClose: () => void } -const Details = ({ - id, - type, - onApplyTemplate, - onClose, -}: DetailsProps) => { +const Details = ({ id, type, onApplyTemplate, onClose }: DetailsProps) => { const { t } = useTranslation() - const { data: pipelineTemplateInfo } = usePipelineTemplateById({ - template_id: id, - type, - }, true) + const { data: pipelineTemplateInfo } = usePipelineTemplateById( + { + template_id: id, + type, + }, + true, + ) const appIcon = useMemo(() => { - if (!pipelineTemplateInfo) - return { type: 'emoji', icon: '📙', background: '#FFF4ED' } + if (!pipelineTemplateInfo) return { type: 'emoji', icon: '📙', background: '#FFF4ED' } const iconInfo = pipelineTemplateInfo.icon_info return iconInfo.icon_type === 'image' ? { type: 'image', url: iconInfo.icon_url || '', fileId: iconInfo.icon || '' } @@ -43,18 +40,13 @@ const Details = ({ const chunkStructureConfig = useChunkStructureConfig() if (!pipelineTemplateInfo) { - return ( - <Loading type="app" /> - ) + return <Loading type="app" /> } return ( <div className="flex h-full"> <div className="flex grow items-center justify-center p-3 pr-0"> - <WorkflowPreview - {...pipelineTemplateInfo.graph} - className="overflow-hidden rounded-2xl" - /> + <WorkflowPreview {...pipelineTemplateInfo.graph} className="overflow-hidden rounded-2xl" /> </div> <div className="relative flex w-[360px] shrink-0 flex-col"> <button @@ -85,7 +77,7 @@ const Details = ({ className="truncate system-2xs-medium-uppercase text-text-tertiary" title={pipelineTemplateInfo.created_by} > - {t($ => $['details.createdBy'], { + {t(($) => $['details.createdBy'], { ns: 'datasetPipeline', author: pipelineTemplateInfo.created_by, })} @@ -97,25 +89,23 @@ const Details = ({ {pipelineTemplateInfo.description} </p> <div className="p-3"> - <Button - variant="primary" - onClick={onApplyTemplate} - className="w-full gap-x-0.5" - > + <Button variant="primary" onClick={onApplyTemplate} className="w-full gap-x-0.5"> <RiAddLine className="size-4" /> - <span className="px-0.5">{t($ => $['operations.useTemplate'], { ns: 'datasetPipeline' })}</span> + <span className="px-0.5"> + {t(($) => $['operations.useTemplate'], { ns: 'datasetPipeline' })} + </span> </Button> </div> <div className="flex flex-col gap-y-1 px-4 py-2"> <div className="flex h-6 items-center gap-x-0.5"> <span className="system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['details.structure'], { ns: 'datasetPipeline' })} + {t(($) => $['details.structure'], { ns: 'datasetPipeline' })} </span> <Infotip - aria-label={t($ => $['details.structureTooltip'], { ns: 'datasetPipeline' })} + aria-label={t(($) => $['details.structureTooltip'], { ns: 'datasetPipeline' })} popupClassName="max-w-[240px]" > - {t($ => $['details.structureTooltip'], { ns: 'datasetPipeline' })} + {t(($) => $['details.structureTooltip'], { ns: 'datasetPipeline' })} </Infotip> </div> <ChunkStructureCard {...chunkStructureConfig[pipelineTemplateInfo.chunk_structure]} /> diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/edit-pipeline-info.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/edit-pipeline-info.tsx index 554f1b387784e6..0aa17c18f427c1 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/edit-pipeline-info.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/edit-pipeline-info.tsx @@ -17,17 +17,18 @@ type EditPipelineInfoProps = { pipeline: PipelineTemplate } -const EditPipelineInfo = ({ - onClose, - pipeline, -}: EditPipelineInfoProps) => { +const EditPipelineInfo = ({ onClose, pipeline }: EditPipelineInfoProps) => { const { t } = useTranslation() const [name, setName] = useState(pipeline.name) const iconInfo = pipeline.icon const [appIcon, setAppIcon] = useState<AppIconSelection>( iconInfo.icon_type === 'image' ? { type: 'image' as const, url: iconInfo.icon_url || '', fileId: iconInfo.icon || '' } - : { type: 'emoji' as const, icon: iconInfo.icon || '', background: iconInfo.icon_background || '' }, + : { + type: 'emoji' as const, + icon: iconInfo.icon || '', + background: iconInfo.icon_background || '', + }, ) const [description, setDescription] = useState(pipeline.description) const [showAppIconPicker, setShowAppIconPicker] = useState(false) @@ -54,7 +55,7 @@ const EditPipelineInfo = ({ const handleSave = useCallback(async () => { if (!name) { - toast.error(t($ => $.editPipelineInfoNameRequired, { ns: 'datasetPipeline' })) + toast.error(t(($) => $.editPipelineInfoNameRequired, { ns: 'datasetPipeline' })) return } const request = { @@ -74,14 +75,22 @@ const EditPipelineInfo = ({ onClose() }, }) - }, [name, appIcon, description, pipeline.id, updatePipeline, invalidCustomizedTemplateList, onClose]) + }, [ + name, + appIcon, + description, + pipeline.id, + updatePipeline, + invalidCustomizedTemplateList, + onClose, + ]) return ( <div className="relative flex flex-col"> {/* Header */} <div className="pt-6 pr-14 pb-3 pl-6"> <span className="title-2xl-semi-bold text-text-primary"> - {t($ => $.editPipelineInfo, { ns: 'datasetPipeline' })} + {t(($) => $.editPipelineInfo, { ns: 'datasetPipeline' })} </span> </div> <button @@ -96,12 +105,12 @@ const EditPipelineInfo = ({ <div className="flex items-end gap-x-3 self-stretch"> <div className="flex grow flex-col gap-y-1 pb-1"> <label className="flex h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $.pipelineNameAndIcon, { ns: 'datasetPipeline' })} + {t(($) => $.pipelineNameAndIcon, { ns: 'datasetPipeline' })} </label> <Input onChange={handleAppNameChange} value={name} - placeholder={t($ => $.knowledgeNameAndIconPlaceholder, { ns: 'datasetPipeline' })} + placeholder={t(($) => $.knowledgeNameAndIconPlaceholder, { ns: 'datasetPipeline' })} /> </div> <AppIcon @@ -117,37 +126,33 @@ const EditPipelineInfo = ({ </div> <div className="flex flex-col gap-y-1"> <label className="flex h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $.knowledgeDescription, { ns: 'datasetPipeline' })} + {t(($) => $.knowledgeDescription, { ns: 'datasetPipeline' })} </label> <Textarea - aria-label={t($ => $.knowledgeDescription, { ns: 'datasetPipeline' })} + aria-label={t(($) => $.knowledgeDescription, { ns: 'datasetPipeline' })} onValueChange={handleDescriptionChange} value={description} - placeholder={t($ => $.knowledgeDescriptionPlaceholder, { ns: 'datasetPipeline' })} + placeholder={t(($) => $.knowledgeDescriptionPlaceholder, { ns: 'datasetPipeline' })} /> </div> </div> {/* Actions */} <div className="flex items-center justify-end gap-x-2 p-6 pt-5"> - <Button - variant="secondary" - onClick={onClose} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button variant="secondary" onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button - variant="primary" - onClick={handleSave} - > - {t($ => $['operation.save'], { ns: 'common' })} + <Button variant="primary" onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> {showAppIconPicker && ( <AppIconPicker open={showAppIconPicker} - initialEmoji={appIcon.type === 'emoji' - ? { icon: appIcon.icon, background: appIcon.background } - : undefined} + initialEmoji={ + appIcon.type === 'emoji' + ? { icon: appIcon.icon, background: appIcon.background } + : undefined + } onOpenChange={setShowAppIconPicker} onSelect={handleSelectAppIcon} /> diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx index 52425da83c8ce4..75c7ddd4e461a0 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx @@ -36,21 +36,20 @@ type TemplateCardProps = { type: 'customized' | 'built-in' } -const TemplateCard = ({ - pipeline, - showMoreOperations = true, - type, -}: TemplateCardProps) => { +const TemplateCard = ({ pipeline, showMoreOperations = true, type }: TemplateCardProps) => { const { t } = useTranslation() const { push } = useRouter() const [showEditModal, setShowEditModal] = useState(false) const [showDeleteConfirm, setShowConfirmDelete] = useState(false) const [showDetailModal, setShowDetailModal] = useState(false) - const { refetch: getPipelineTemplateInfo } = usePipelineTemplateById({ - template_id: pipeline.id, - type, - }, false) + const { refetch: getPipelineTemplateInfo } = usePipelineTemplateById( + { + template_id: pipeline.id, + type, + }, + false, + ) const { mutateAsync: createDataset } = useCreatePipelineDatasetFromCustomized() const { handleCheckPluginDependencies } = usePluginDependencies() const invalidDatasetList = useInvalidDatasetList() @@ -58,7 +57,7 @@ const TemplateCard = ({ const handleUseTemplate = useCallback(async () => { const { data: pipelineTemplateInfo } = await getPipelineTemplateInfo() if (!pipelineTemplateInfo) { - toast.error(t($ => $['creation.errorTip'], { ns: 'datasetPipeline' })) + toast.error(t(($) => $['creation.errorTip'], { ns: 'datasetPipeline' })) return } const request = { @@ -66,7 +65,7 @@ const TemplateCard = ({ } await createDataset(request, { onSuccess: async (newDataset) => { - toast.success(t($ => $['creation.successTip'], { ns: 'datasetPipeline' })) + toast.success(t(($) => $['creation.successTip'], { ns: 'datasetPipeline' })) invalidDatasetList() if (newDataset.pipeline_id) await handleCheckPluginDependencies(newDataset.pipeline_id, true) @@ -78,10 +77,20 @@ const TemplateCard = ({ push(`/datasets/${newDataset.dataset_id}/pipeline`) }, onError: () => { - toast.error(t($ => $['creation.errorTip'], { ns: 'datasetPipeline' })) + toast.error(t(($) => $['creation.errorTip'], { ns: 'datasetPipeline' })) }, }) - }, [getPipelineTemplateInfo, createDataset, t, handleCheckPluginDependencies, push, invalidDatasetList, pipeline.name, pipeline.id, type]) + }, [ + getPipelineTemplateInfo, + createDataset, + t, + handleCheckPluginDependencies, + push, + invalidDatasetList, + pipeline.name, + pipeline.id, + type, + ]) const handleShowTemplateDetails = useCallback(() => { setShowDetailModal(true) @@ -102,16 +111,15 @@ const TemplateCard = ({ const { mutateAsync: exportPipelineDSL, isPending: isExporting } = useExportTemplateDSL() const handleExportDSL = useCallback(async () => { - if (isExporting) - return + if (isExporting) return await exportPipelineDSL(pipeline.id, { onSuccess: (res) => { const blob = new Blob([res.data], { type: 'application/yaml' }) downloadBlob({ data: blob, fileName: `${pipeline.name}.pipeline` }) - toast.success(t($ => $['exportDSL.successTip'], { ns: 'datasetPipeline' })) + toast.success(t(($) => $['exportDSL.successTip'], { ns: 'datasetPipeline' })) }, onError: () => { - toast.error(t($ => $['exportDSL.errorTip'], { ns: 'datasetPipeline' })) + toast.error(t(($) => $['exportDSL.errorTip'], { ns: 'datasetPipeline' })) }, }) }, [t, isExporting, pipeline.id, pipeline.name, exportPipelineDSL]) @@ -156,33 +164,30 @@ const TemplateCard = ({ <Dialog open={showEditModal} onOpenChange={(open) => { - if (!open) - closeEditModal() + if (!open) closeEditModal() }} > <DialogContent className="w-[calc(100vw-2rem)] max-w-[520px]! overflow-hidden! border-none p-0 text-left align-middle"> - - <EditPipelineInfo - pipeline={pipeline} - onClose={closeEditModal} - /> + <EditPipelineInfo pipeline={pipeline} onClose={closeEditModal} /> </DialogContent> </Dialog> )} - <AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && onCancelDelete()}> + <AlertDialog open={showDeleteConfirm} onOpenChange={(open) => !open && onCancelDelete()}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['deletePipeline.title'], { ns: 'datasetPipeline' })} + {t(($) => $['deletePipeline.title'], { ns: 'datasetPipeline' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['deletePipeline.content'], { ns: 'datasetPipeline' })} + {t(($) => $['deletePipeline.content'], { ns: 'datasetPipeline' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={onConfirmDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> @@ -191,12 +196,10 @@ const TemplateCard = ({ <Dialog open={showDetailModal} onOpenChange={(open) => { - if (!open) - closeDetailsModal() + if (!open) closeDetailsModal() }} > <DialogContent className="h-[calc(100dvh-64px)] max-h-[calc(100dvh-64px)] w-[calc(100vw-2rem)] max-w-[1680px]! overflow-hidden! rounded-3xl border-none p-0 text-left align-middle"> - <Details id={pipeline.id} type={type} diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx index be2ad22fc7afe7..366a2b1cbacb9a 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx @@ -9,12 +9,7 @@ type OperationsProps = { onClose?: () => void } -const Operations = ({ - openEditModal, - onDelete, - onExport, - onClose, -}: OperationsProps) => { +const Operations = ({ openEditModal, onDelete, onExport, onClose }: OperationsProps) => { const { t } = useTranslation() const onClickEdit = (e: React.MouseEvent<HTMLDivElement>) => { @@ -46,7 +41,7 @@ const Operations = ({ onClick={onClickEdit} > <span className="px-1 system-md-regular text-text-secondary"> - {t($ => $['operations.editInfo'], { ns: 'datasetPipeline' })} + {t(($) => $['operations.editInfo'], { ns: 'datasetPipeline' })} </span> </div> <div @@ -54,7 +49,7 @@ const Operations = ({ onClick={onClickExport} > <span className="px-1 system-md-regular text-text-secondary"> - {t($ => $['operations.exportPipeline'], { ns: 'datasetPipeline' })} + {t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })} </span> </div> </div> @@ -65,7 +60,7 @@ const Operations = ({ onClick={onClickDelete} > <span className="px-1 system-md-regular text-text-secondary group-hover:text-text-destructive"> - {t($ => $['operation.delete'], { ns: 'common' })} + {t(($) => $['operation.delete'], { ns: 'common' })} </span> </div> </div> diff --git a/web/app/components/datasets/create/__tests__/index.spec.tsx b/web/app/components/datasets/create/__tests__/index.spec.tsx index 491842e272dc30..2a2d1ac90ffced 100644 --- a/web/app/components/datasets/create/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/__tests__/index.spec.tsx @@ -25,7 +25,7 @@ const IndexingTypeValues = { // Mock next/link vi.mock('@/next/link', () => { - return function MockLink({ children, href }: { children: React.ReactNode, href: string }) { + return function MockLink({ children, href }: { children: React.ReactNode; href: string }) { return <a href={href}>{children}</a> } }) @@ -42,7 +42,8 @@ let mockWorkspacePermissionKeys = ['dataset.create_and_management'] let mockIsLoadingWorkspacePermissionKeys = false vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: mockCurrentUserId }, @@ -51,7 +52,8 @@ vi.mock('@/context/account-state', async (importOriginal) => { })) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: mockCurrentUserId }, @@ -60,7 +62,8 @@ vi.mock('@/context/workspace-state', async (importOriginal) => { })) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: mockCurrentUserId }, @@ -69,7 +72,8 @@ vi.mock('@/context/permission-state', async (importOriginal) => { })) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: mockCurrentUserId }, @@ -78,7 +82,8 @@ vi.mock('@/context/version-state', async (importOriginal) => { })) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: mockCurrentUserId }, @@ -93,7 +98,11 @@ vi.mock('@/context/modal-context', () => ({ useModalContext: () => ({ setShowAccountSettingModal: mockSetShowAccountSettingModal, }), - useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown) => { + useModalContextSelector: ( + selector: (state: { + setShowAccountSettingModal: typeof mockSetShowAccountSettingModal + }) => unknown, + ) => { const state = { setShowAccountSettingModal: mockSetShowAccountSettingModal, } @@ -102,7 +111,8 @@ vi.mock('@/context/modal-context', () => ({ })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) @@ -110,7 +120,9 @@ vi.mock('jotai', async (importOriginal) => { // Mock dataset detail context let mockDatasetDetail: DataSet | undefined vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | undefined }) => unknown) => { + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: DataSet | undefined }) => unknown, + ) => { const state = { dataset: mockDatasetDetail, } @@ -119,7 +131,7 @@ vi.mock('@/context/dataset-detail', () => ({ })) // Mock useDefaultModel hook -let mockEmbeddingsDefaultModel: { model: string, provider: string } | undefined +let mockEmbeddingsDefaultModel: { model: string; provider: string } | undefined vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useDefaultModel: () => ({ data: mockEmbeddingsDefaultModel, @@ -156,8 +168,12 @@ vi.mock('../step-one', () => ({ <span data-testid="step-one-files-count">{props.files?.length || 0}</span> <span data-testid="step-one-notion-pages-count">{props.notionPages?.length || 0}</span> <span data-testid="step-one-website-pages-count">{props.websitePages?.length || 0}</span> - <button data-testid="step-one-next" onClick={props.onStepChange}>Next Step</button> - <button data-testid="step-one-setting" onClick={props.onSetting}>Open Settings</button> + <button data-testid="step-one-next" onClick={props.onStepChange}> + Next Step + </button> + <button data-testid="step-one-setting" onClick={props.onSetting}> + Open Settings + </button> <button data-testid="step-one-change-type" onClick={() => props.changeType(DataSourceType.NOTION)} @@ -166,14 +182,22 @@ vi.mock('../step-one', () => ({ </button> <button data-testid="step-one-update-files" - onClick={() => props.updateFileList([{ fileID: 'test-1', file: { name: 'test.txt' }, progress: 0 }] as unknown as FileItem[])} + onClick={() => + props.updateFileList([ + { fileID: 'test-1', file: { name: 'test.txt' }, progress: 0 }, + ] as unknown as FileItem[]) + } > Add File </button> <button data-testid="step-one-update-file-progress" onClick={() => { - const mockFile = { fileID: 'test-1', file: { name: 'test.txt' }, progress: 0 } as unknown as FileItem + const mockFile = { + fileID: 'test-1', + file: { name: 'test.txt' }, + progress: 0, + } as unknown as FileItem props.updateFile(mockFile, 50, [mockFile]) }} > @@ -181,7 +205,11 @@ vi.mock('../step-one', () => ({ </button> <button data-testid="step-one-update-notion-pages" - onClick={() => props.updateNotionPages([{ page_id: 'page-1', type: 'page' }] as unknown as NotionPage[])} + onClick={() => + props.updateNotionPages([ + { page_id: 'page-1', type: 'page' }, + ] as unknown as NotionPage[]) + } > Add Notion Page </button> @@ -193,7 +221,11 @@ vi.mock('../step-one', () => ({ </button> <button data-testid="step-one-update-website-pages" - onClick={() => props.updateWebsitePages([{ title: 'Test', markdown: '', description: '', source_url: 'https://test.com' }])} + onClick={() => + props.updateWebsitePages([ + { title: 'Test', markdown: '', description: '', source_url: 'https://test.com' }, + ]) + } > Add Website Page </button> @@ -229,9 +261,15 @@ vi.mock('../step-two', () => ({ <span data-testid="step-two-data-source-type">{props.dataSourceType}</span> <span data-testid="step-two-files-count">{props.files?.length || 0}</span> <span data-testid="step-two-can-create-document">{String(props.canCreateDocument)}</span> - <button data-testid="step-two-prev" onClick={() => props.onStepChange!(-1)}>Prev Step</button> - <button data-testid="step-two-next" onClick={() => props.onStepChange!(1)}>Next Step</button> - <button data-testid="step-two-setting" onClick={props.onSetting}>Open Settings</button> + <button data-testid="step-two-prev" onClick={() => props.onStepChange!(-1)}> + Prev Step + </button> + <button data-testid="step-two-next" onClick={() => props.onStepChange!(1)}> + Next Step + </button> + <button data-testid="step-two-setting" onClick={props.onSetting}> + Open Settings + </button> <button data-testid="step-two-update-indexing-cache" onClick={() => props.updateIndexingTypeCache!('high_quality')} @@ -342,12 +380,13 @@ const createMockDataset = (overrides?: Partial<DataSet>): DataSet => ({ ...overrides, }) -const createMockDataSourceAuth = (overrides?: Partial<DataSourceAuth>): DataSourceAuth => ({ - credential_id: 'cred-1', - provider: 'notion', - plugin_id: 'plugin-1', - ...overrides, -} as DataSourceAuth) +const createMockDataSourceAuth = (overrides?: Partial<DataSourceAuth>): DataSourceAuth => + ({ + credential_id: 'cred-1', + provider: 'notion', + plugin_id: 'plugin-1', + ...overrides, + }) as DataSourceAuth describe('DatasetUpdateForm', () => { beforeEach(() => { @@ -470,7 +509,9 @@ describe('DatasetUpdateForm', () => { it('should initialize with FILE data source type', () => { render(<DatasetUpdateForm />) - expect(screen.getByTestId('step-one-data-source-type'))!.toHaveTextContent(DataSourceType.FILE) + expect(screen.getByTestId('step-one-data-source-type'))!.toHaveTextContent( + DataSourceType.FILE, + ) }) it('should update dataSourceType when changeType is called', () => { @@ -478,7 +519,9 @@ describe('DatasetUpdateForm', () => { fireEvent.click(screen.getByTestId('step-one-change-type')) - expect(screen.getByTestId('step-one-data-source-type'))!.toHaveTextContent(DataSourceType.NOTION) + expect(screen.getByTestId('step-one-data-source-type'))!.toHaveTextContent( + DataSourceType.NOTION, + ) }) }) @@ -719,7 +762,9 @@ describe('DatasetUpdateForm', () => { fireEvent.click(screen.getByTestId('step-one-next')) - expect(screen.getByTestId('step-two-data-source-type'))!.toHaveTextContent(DataSourceType.NOTION) + expect(screen.getByTestId('step-two-data-source-type'))!.toHaveTextContent( + DataSourceType.NOTION, + ) }) it('should pass files mapped to file property to StepTwo', () => { @@ -750,7 +795,9 @@ describe('DatasetUpdateForm', () => { // Assert - Go to step 3 and verify fireEvent.click(screen.getByTestId('step-two-next')) - expect(screen.getByTestId('step-three-retrieval-method'))!.toHaveTextContent('semantic_search') + expect(screen.getByTestId('step-three-retrieval-method'))!.toHaveTextContent( + 'semantic_search', + ) }) it('should update result cache from StepTwo', () => { @@ -912,7 +959,9 @@ describe('DatasetUpdateForm', () => { fireEvent.click(screen.getByTestId('step-one-next')) fireEvent.click(screen.getByTestId('step-two-next')) - expect(screen.getByTestId('step-three-retrieval-method'))!.toHaveTextContent('full_text_search') + expect(screen.getByTestId('step-three-retrieval-method'))!.toHaveTextContent( + 'full_text_search', + ) }) }) @@ -988,7 +1037,9 @@ describe('DatasetUpdateForm', () => { // Assert - Should use cached value // Assert - Should use cached value - expect(screen.getByTestId('step-three-retrieval-method'))!.toHaveTextContent('semantic_search') + expect(screen.getByTestId('step-three-retrieval-method'))!.toHaveTextContent( + 'semantic_search', + ) }) it('should handle step state correctly after multiple navigations', () => { @@ -1043,7 +1094,9 @@ describe('DatasetUpdateForm', () => { // Assert - All state should be preserved // Assert - All state should be preserved - expect(screen.getByTestId('step-one-data-source-type'))!.toHaveTextContent(DataSourceType.NOTION) + expect(screen.getByTestId('step-one-data-source-type'))!.toHaveTextContent( + DataSourceType.NOTION, + ) expect(screen.getByTestId('step-one-files-count'))!.toHaveTextContent('1') expect(screen.getByTestId('step-one-notion-pages-count'))!.toHaveTextContent('1') }) @@ -1067,7 +1120,9 @@ describe('DatasetUpdateForm', () => { // Assert - All data flows through to Step 3 // Assert - All data flows through to Step 3 expect(screen.getByTestId('step-three-indexing-type'))!.toHaveTextContent('high_quality') - expect(screen.getByTestId('step-three-retrieval-method'))!.toHaveTextContent('semantic_search') + expect(screen.getByTestId('step-three-retrieval-method'))!.toHaveTextContent( + 'semantic_search', + ) expect(stepThreeProps.creationCache?.batch).toBe('batch-1') }) diff --git a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx index d1787fc47a2bba..cc337268a1944a 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx @@ -1,4 +1,8 @@ -import type { FullDocumentDetail, IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets' +import type { + FullDocumentDetail, + IndexingStatusResponse, + ProcessRuleResponse, +} from '@/models/datasets' import { act, render, renderHook, screen } from '@testing-library/react' import { DataSourceType, ProcessMode } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' @@ -23,7 +27,7 @@ vi.mock('@/next/navigation', () => ({ // Mock API service const mockFetchIndexingStatusBatch = vi.fn() vi.mock('@/service/datasets', () => ({ - fetchIndexingStatusBatch: (params: { datasetId: string, batchId: string }) => + fetchIndexingStatusBatch: (params: { datasetId: string; batchId: string }) => mockFetchIndexingStatusBatch(params), })) @@ -99,60 +103,58 @@ const createMockIndexingStatus = ( /** * Create a mock FullDocumentDetail */ -const createMockDocument = ( - overrides: Partial<FullDocumentDetail> = {}, -): FullDocumentDetail => ({ - id: 'doc-1', - name: 'test-document.txt', - data_source_type: DataSourceType.FILE, - data_source_info: { - upload_file: { - id: 'file-1', - name: 'test-document.txt', - extension: 'txt', - mime_type: 'text/plain', - size: 1024, - created_by: 'user-1', - created_at: Date.now(), +const createMockDocument = (overrides: Partial<FullDocumentDetail> = {}): FullDocumentDetail => + ({ + id: 'doc-1', + name: 'test-document.txt', + data_source_type: DataSourceType.FILE, + data_source_info: { + upload_file: { + id: 'file-1', + name: 'test-document.txt', + extension: 'txt', + mime_type: 'text/plain', + size: 1024, + created_by: 'user-1', + created_at: Date.now(), + }, }, - }, - batch: 'batch-1', - created_api_request_id: 'req-1', - processing_started_at: Date.now(), - parsing_completed_at: Date.now(), - cleaning_completed_at: Date.now(), - splitting_completed_at: Date.now(), - tokens: 100, - indexing_latency: 5000, - completed_at: Date.now(), - paused_by: '', - paused_at: 0, - stopped_at: 0, - indexing_status: 'completed', - disabled_at: 0, - ...overrides, -} as FullDocumentDetail) + batch: 'batch-1', + created_api_request_id: 'req-1', + processing_started_at: Date.now(), + parsing_completed_at: Date.now(), + cleaning_completed_at: Date.now(), + splitting_completed_at: Date.now(), + tokens: 100, + indexing_latency: 5000, + completed_at: Date.now(), + paused_by: '', + paused_at: 0, + stopped_at: 0, + indexing_status: 'completed', + disabled_at: 0, + ...overrides, + }) as FullDocumentDetail /** * Create a mock ProcessRuleResponse */ -const createMockProcessRule = ( - overrides: Partial<ProcessRuleResponse> = {}, -): ProcessRuleResponse => ({ - mode: ProcessMode.general, - rules: { - segmentation: { - separator: '\n', - max_tokens: 500, - chunk_overlap: 50, +const createMockProcessRule = (overrides: Partial<ProcessRuleResponse> = {}): ProcessRuleResponse => + ({ + mode: ProcessMode.general, + rules: { + segmentation: { + separator: '\n', + max_tokens: 500, + chunk_overlap: 50, + }, + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ], }, - pre_processing_rules: [ - { id: 'remove_extra_spaces', enabled: true }, - { id: 'remove_urls_emails', enabled: false }, - ], - }, - ...overrides, -} as ProcessRuleResponse) + ...overrides, + }) as ProcessRuleResponse // Utils Tests @@ -165,21 +167,31 @@ describe('utils', () => { upload_file: { id: 'file-1', name: 'test.txt' }, } - expect(isLegacyDataSourceInfo(info as Parameters<typeof isLegacyDataSourceInfo>[0])).toBe(true) + expect(isLegacyDataSourceInfo(info as Parameters<typeof isLegacyDataSourceInfo>[0])).toBe( + true, + ) }) it('should return false for null', () => { - expect(isLegacyDataSourceInfo(null as unknown as Parameters<typeof isLegacyDataSourceInfo>[0])).toBe(false) + expect( + isLegacyDataSourceInfo(null as unknown as Parameters<typeof isLegacyDataSourceInfo>[0]), + ).toBe(false) }) it('should return false for undefined', () => { - expect(isLegacyDataSourceInfo(undefined as unknown as Parameters<typeof isLegacyDataSourceInfo>[0])).toBe(false) + expect( + isLegacyDataSourceInfo( + undefined as unknown as Parameters<typeof isLegacyDataSourceInfo>[0], + ), + ).toBe(false) }) it('should return false when upload_file is not an object', () => { const info = { upload_file: 'string-value' } - expect(isLegacyDataSourceInfo(info as unknown as Parameters<typeof isLegacyDataSourceInfo>[0])).toBe(false) + expect( + isLegacyDataSourceInfo(info as unknown as Parameters<typeof isLegacyDataSourceInfo>[0]), + ).toBe(false) }) }) @@ -194,7 +206,9 @@ describe('utils', () => { ['error', false], ['paused', false], ])('should return %s for status "%s"', (status, expected) => { - const detail = createMockIndexingStatus({ indexing_status: status as IndexingStatusResponse['indexing_status'] }) + const detail = createMockIndexingStatus({ + indexing_status: status as IndexingStatusResponse['indexing_status'], + }) expect(isSourceEmbedding(detail)).toBe(expected) }) @@ -264,7 +278,11 @@ describe('utils', () => { it('should create lookup functions for documents', () => { const documents = [ createMockDocument({ id: 'doc-1', name: 'file1.txt' }), - createMockDocument({ id: 'doc-2', name: 'file2.pdf', data_source_type: DataSourceType.NOTION }), + createMockDocument({ + id: 'doc-2', + name: 'file2.pdf', + data_source_type: DataSourceType.NOTION, + }), ] const lookup = createDocumentLookup(documents) @@ -304,7 +322,9 @@ describe('utils', () => { const documents = [ createMockDocument({ id: 'doc-1', - data_source_info: { some_other_field: 'value' } as unknown as FullDocumentDetail['data_source_info'], + data_source_info: { + some_other_field: 'value', + } as unknown as FullDocumentDetail['data_source_info'], }), ] const lookup = createDocumentLookup(documents) @@ -314,12 +334,12 @@ describe('utils', () => { it('should memoize lookups with Map for performance', () => { const documents = Array.from({ length: 1000 }, (_, i) => - createMockDocument({ id: `doc-${i}`, name: `file${i}.txt` })) + createMockDocument({ id: `doc-${i}`, name: `file${i}.txt` }), + ) const lookup = createDocumentLookup(documents) const startTime = performance.now() - for (let i = 0; i < 1000; i++) - lookup.getName(`doc-${i}`) + for (let i = 0; i < 1000; i++) lookup.getName(`doc-${i}`) const duration = performance.now() - startTime @@ -366,9 +386,7 @@ describe('useIndexingStatusPolling', () => { const mockStatus = [createMockIndexingStatus({ indexing_status: 'completed' })] mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) - renderHook(() => - useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), - ) + renderHook(() => useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' })) await act(async () => { await vi.runOnlyPendingTimersAsync() @@ -386,9 +404,7 @@ describe('useIndexingStatusPolling', () => { .mockResolvedValueOnce({ data: indexingStatus }) .mockResolvedValueOnce({ data: completedStatus }) - renderHook(() => - useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), - ) + renderHook(() => useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' })) // First poll await act(async () => { @@ -439,9 +455,7 @@ describe('useIndexingStatusPolling', () => { .mockRejectedValueOnce(new Error('Network error')) .mockResolvedValueOnce({ data: [createMockIndexingStatus({ indexing_status: 'completed' })] }) - renderHook(() => - useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), - ) + renderHook(() => useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' })) await act(async () => { await vi.runOnlyPendingTimersAsync() @@ -539,7 +553,9 @@ describe('UpgradeBanner', () => { it('should render upgrade message', () => { render(<UpgradeBanner />) - expect(screen.getByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i)).toBeInTheDocument() + expect( + screen.getByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i), + ).toBeInTheDocument() }) it('should render ZapFast icon', () => { @@ -552,7 +568,9 @@ describe('UpgradeBanner', () => { render(<UpgradeBanner />) // Assert - UpgradeBtn should be rendered - const upgradeContainer = screen.getByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i).parentElement + const upgradeContainer = screen.getByText( + /billing\.plansCommon\.documentProcessingPriorityUpgrade/i, + ).parentElement expect(upgradeContainer).toBeInTheDocument() }) }) @@ -660,11 +678,7 @@ describe('IndexingProgressItem', () => { const detail = createMockIndexingStatus() render( - <IndexingProgressItem - detail={detail} - name={filename} - sourceType={DataSourceType.FILE} - />, + <IndexingProgressItem detail={detail} name={filename} sourceType={DataSourceType.FILE} />, ) expect(screen.getByText(filename)).toBeInTheDocument() @@ -856,7 +870,9 @@ describe('RuleDetail', () => { expect(screen.getByText(/datasetDocuments\.embedding\.segmentLength/i)).toBeInTheDocument() expect(screen.getByText(/datasetDocuments\.embedding\.textCleaning/i)).toBeInTheDocument() expect(screen.getByText(/datasetCreation\.stepTwo\.indexMode/i)).toBeInTheDocument() - expect(screen.getByText(/datasetSettings\.form\.retrievalSetting\.title/i)).toBeInTheDocument() + expect( + screen.getByText(/datasetSettings\.form\.retrievalSetting\.title/i), + ).toBeInTheDocument() }) }) @@ -943,9 +959,7 @@ describe('RuleDetail', () => { const sourceData = createMockProcessRule({ mode: ProcessMode.general, rules: { - pre_processing_rules: [ - { id: 'remove_extra_spaces', enabled: false }, - ], + pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: false }], }, } as Partial<ProcessRuleResponse>) @@ -998,7 +1012,9 @@ describe('RuleDetail', () => { ])('should show correct label for %s retrieval method', (method, expectedKey) => { render(<RuleDetail retrievalMethod={method} />) - expect(screen.getByText(new RegExp(`dataset\\.retrieval\\.${expectedKey}\\.title`, 'i'))).toBeInTheDocument() + expect( + screen.getByText(new RegExp(`dataset\\.retrieval\\.${expectedKey}\\.title`, 'i')), + ).toBeInTheDocument() }) }) }) @@ -1078,13 +1094,7 @@ describe('EmbeddingProcess', () => { ] mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) - render( - <EmbeddingProcess - datasetId="ds-1" - batchId="batch-1" - documents={documents} - />, - ) + render(<EmbeddingProcess datasetId="ds-1" batchId="batch-1" documents={documents} />) await act(async () => { await vi.runOnlyPendingTimersAsync() @@ -1111,7 +1121,9 @@ describe('EmbeddingProcess', () => { await vi.runOnlyPendingTimersAsync() }) - expect(screen.getByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i)).toBeInTheDocument() + expect( + screen.getByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i), + ).toBeInTheDocument() }) it('should not show upgrade banner when billing is disabled', async () => { @@ -1124,7 +1136,9 @@ describe('EmbeddingProcess', () => { await vi.runOnlyPendingTimersAsync() }) - expect(screen.queryByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i)).not.toBeInTheDocument() + expect( + screen.queryByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i), + ).not.toBeInTheDocument() }) it('should not show upgrade banner for team plan', async () => { @@ -1142,7 +1156,9 @@ describe('EmbeddingProcess', () => { await vi.runOnlyPendingTimersAsync() }) - expect(screen.queryByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i)).not.toBeInTheDocument() + expect( + screen.queryByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i), + ).not.toBeInTheDocument() }) }) @@ -1216,13 +1232,7 @@ describe('EmbeddingProcess', () => { it('should pass indexingType to RuleDetail', async () => { mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) - render( - <EmbeddingProcess - datasetId="ds-1" - batchId="batch-1" - indexingType="economy" - />, - ) + render(<EmbeddingProcess datasetId="ds-1" batchId="batch-1" indexingType="economy" />) await act(async () => { await vi.runOnlyPendingTimersAsync() @@ -1240,11 +1250,7 @@ describe('EmbeddingProcess', () => { }) const { rerender } = render( - <EmbeddingProcess - datasetId="ds-1" - batchId="batch-1" - documents={documents} - />, + <EmbeddingProcess datasetId="ds-1" batchId="batch-1" documents={documents} />, ) await act(async () => { @@ -1252,13 +1258,7 @@ describe('EmbeddingProcess', () => { }) // Rerender with same documents reference - rerender( - <EmbeddingProcess - datasetId="ds-1" - batchId="batch-1" - documents={documents} - />, - ) + rerender(<EmbeddingProcess datasetId="ds-1" batchId="batch-1" documents={documents} />) // Assert - component should render without issues expect(screen.getByText('test.txt')).toBeInTheDocument() @@ -1301,13 +1301,7 @@ describe('EmbeddingProcess', () => { ], }) - render( - <EmbeddingProcess - datasetId="ds-1" - batchId="batch-1" - documents={documents} - />, - ) + render(<EmbeddingProcess datasetId="ds-1" batchId="batch-1" documents={documents} />) await act(async () => { await vi.runOnlyPendingTimersAsync() @@ -1320,13 +1314,7 @@ describe('EmbeddingProcess', () => { it('should handle undefined retrievalMethod', async () => { mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) - render( - <EmbeddingProcess - datasetId="ds-1" - batchId="batch-1" - indexingType="high_quality" - />, - ) + render(<EmbeddingProcess datasetId="ds-1" batchId="batch-1" indexingType="high_quality" />) await act(async () => { await vi.runOnlyPendingTimersAsync() diff --git a/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx index f71c4a0cc1c5ff..4ad973c46be0ae 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx @@ -97,35 +97,20 @@ describe('IndexingProgressItem', () => { }) it('should show priority label when billing is enabled', () => { - render( - <IndexingProgressItem - detail={makeDetail()} - name="test.pdf" - enableBilling={true} - />, - ) + render(<IndexingProgressItem detail={makeDetail()} name="test.pdf" enableBilling={true} />) expect(screen.getByTestId('priority-label')).toBeInTheDocument() }) it('should not show priority label when billing is disabled', () => { - render( - <IndexingProgressItem - detail={makeDetail()} - name="test.pdf" - enableBilling={false} - />, - ) + render(<IndexingProgressItem detail={makeDetail()} name="test.pdf" enableBilling={false} />) expect(screen.queryByTestId('priority-label')).not.toBeInTheDocument() }) it('should apply error styling for error status', () => { const { container } = render( - <IndexingProgressItem - detail={makeDetail({ indexing_status: 'error' })} - name="error.pdf" - />, + <IndexingProgressItem detail={makeDetail({ indexing_status: 'error' })} name="error.pdf" />, ) const wrapper = container.firstChild as HTMLElement diff --git a/web/app/components/datasets/create/embedding-process/__tests__/rule-detail.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/rule-detail.spec.tsx index 5213e9f6858473..0af3a82868707b 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/rule-detail.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/rule-detail.spec.tsx @@ -6,7 +6,7 @@ import { RETRIEVE_METHOD } from '@/types/app' import RuleDetail from '../rule-detail' vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({ - FieldInfo: ({ label, displayedValue }: { label: string, displayedValue: string }) => ( + FieldInfo: ({ label, displayedValue }: { label: string; displayedValue: string }) => ( <div data-testid="field-info"> <span data-testid="field-label">{label}</span> <span data-testid="field-value">{displayedValue}</span> @@ -23,17 +23,18 @@ describe('RuleDetail', () => { vi.clearAllMocks() }) - const makeSourceData = (overrides: Partial<ProcessRuleResponse> = {}): ProcessRuleResponse => ({ - mode: ProcessMode.general, - rules: { - segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 }, - pre_processing_rules: [ - { id: 'remove_extra_spaces', enabled: true }, - { id: 'remove_urls_emails', enabled: false }, - ], - }, - ...overrides, - } as ProcessRuleResponse) + const makeSourceData = (overrides: Partial<ProcessRuleResponse> = {}): ProcessRuleResponse => + ({ + mode: ProcessMode.general, + rules: { + segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 }, + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ], + }, + ...overrides, + }) as ProcessRuleResponse it('should render mode, segment length, text cleaning, index mode, and retrieval fields', () => { render( @@ -94,24 +95,14 @@ describe('RuleDetail', () => { }) it('should display segment length for general mode', () => { - render( - <RuleDetail - sourceData={makeSourceData()} - indexingType="high_quality" - />, - ) + render(<RuleDetail sourceData={makeSourceData()} indexingType="high_quality" />) const values = screen.getAllByTestId('field-value') expect(values[1]!.textContent).toBe('500') }) it('should display enabled pre-processing rules', () => { - render( - <RuleDetail - sourceData={makeSourceData()} - indexingType="high_quality" - />, - ) + render(<RuleDetail sourceData={makeSourceData()} indexingType="high_quality" />) const values = screen.getAllByTestId('field-value') // Only remove_extra_spaces is enabled @@ -119,12 +110,7 @@ describe('RuleDetail', () => { }) it('should display economical index mode', () => { - render( - <RuleDetail - sourceData={makeSourceData()} - indexingType="economy" - />, - ) + render(<RuleDetail sourceData={makeSourceData()} indexingType="economy" />) const values = screen.getAllByTestId('field-value') // Index mode field is 4th (index 3) @@ -132,12 +118,7 @@ describe('RuleDetail', () => { }) it('should display qualified index mode for high_quality', () => { - render( - <RuleDetail - sourceData={makeSourceData()} - indexingType="high_quality" - />, - ) + render(<RuleDetail sourceData={makeSourceData()} indexingType="high_quality" />) const values = screen.getAllByTestId('field-value') expect(values[3]!.textContent).toContain('stepTwo.qualified') diff --git a/web/app/components/datasets/create/embedding-process/__tests__/upgrade-banner.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/upgrade-banner.spec.tsx index 2e09da46ba4b4a..ca4db9c98222e9 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/upgrade-banner.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/upgrade-banner.spec.tsx @@ -6,7 +6,11 @@ vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({ ZapFast: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="zap-icon" {...props} />, })) vi.mock('@/app/components/billing/upgrade-btn', () => ({ - default: ({ loc }: { loc: string }) => <button data-testid="upgrade-btn" data-loc={loc}>Upgrade</button>, + default: ({ loc }: { loc: string }) => ( + <button data-testid="upgrade-btn" data-loc={loc}> + Upgrade + </button> + ), })) describe('UpgradeBanner', () => { @@ -18,7 +22,9 @@ describe('UpgradeBanner', () => { render(<UpgradeBanner />) expect(screen.getByTestId('zap-icon')).toBeInTheDocument() - expect(screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade')).toBeInTheDocument() + expect( + screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade'), + ).toBeInTheDocument() expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument() }) diff --git a/web/app/components/datasets/create/embedding-process/__tests__/use-indexing-status-polling.spec.ts b/web/app/components/datasets/create/embedding-process/__tests__/use-indexing-status-polling.spec.ts index b958cadf032e57..66650d3ce99021 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/use-indexing-status-polling.spec.ts +++ b/web/app/components/datasets/create/embedding-process/__tests__/use-indexing-status-polling.spec.ts @@ -71,11 +71,9 @@ describe('useIndexingStatusPolling', () => { }) it('should continue polling on fetch error', async () => { - mockFetchIndexingStatusBatch - .mockRejectedValueOnce(new Error('network')) - .mockResolvedValueOnce({ - data: [{ indexing_status: 'completed' }], - }) + mockFetchIndexingStatusBatch.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ + data: [{ indexing_status: 'completed' }], + }) const { result } = renderHook(() => useIndexingStatusPolling(defaultParams)) // First call: rejects @@ -92,10 +90,7 @@ describe('useIndexingStatusPolling', () => { it('should detect embedding statuses', async () => { mockFetchIndexingStatusBatch.mockResolvedValue({ - data: [ - { indexing_status: 'splitting' }, - { indexing_status: 'parsing' }, - ], + data: [{ indexing_status: 'splitting' }, { indexing_status: 'parsing' }], }) const { result } = renderHook(() => useIndexingStatusPolling(defaultParams)) @@ -109,10 +104,7 @@ describe('useIndexingStatusPolling', () => { it('should detect mixed statuses (some completed, some embedding)', async () => { mockFetchIndexingStatusBatch.mockResolvedValue({ - data: [ - { indexing_status: 'completed' }, - { indexing_status: 'indexing' }, - ], + data: [{ indexing_status: 'completed' }, { indexing_status: 'indexing' }], }) const { result } = renderHook(() => useIndexingStatusPolling(defaultParams)) @@ -145,10 +137,7 @@ describe('useIndexingStatusPolling', () => { it('should treat error and paused as completed statuses', async () => { mockFetchIndexingStatusBatch.mockResolvedValue({ - data: [ - { indexing_status: 'error' }, - { indexing_status: 'paused' }, - ], + data: [{ indexing_status: 'error' }, { indexing_status: 'paused' }], }) const { result } = renderHook(() => useIndexingStatusPolling(defaultParams)) diff --git a/web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts b/web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts index aa997a01c4cf38..f82382774a91b6 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts +++ b/web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts @@ -1,6 +1,12 @@ import type { DataSourceInfo, FullDocumentDetail, IndexingStatusResponse } from '@/models/datasets' import { describe, expect, it } from 'vitest' -import { createDocumentLookup, getFileType, getSourcePercent, isLegacyDataSourceInfo, isSourceEmbedding } from '../utils' +import { + createDocumentLookup, + getFileType, + getSourcePercent, + isLegacyDataSourceInfo, + isSourceEmbedding, +} from '../utils' describe('isLegacyDataSourceInfo', () => { it('should return true when upload_file object exists', () => { @@ -38,19 +44,27 @@ describe('isSourceEmbedding', () => { describe('getSourcePercent', () => { it('should calculate correct percentage', () => { - expect(getSourcePercent({ completed_segments: 50, total_segments: 100 } as IndexingStatusResponse)).toBe(50) + expect( + getSourcePercent({ completed_segments: 50, total_segments: 100 } as IndexingStatusResponse), + ).toBe(50) }) it('should return 0 when total is 0', () => { - expect(getSourcePercent({ completed_segments: 0, total_segments: 0 } as IndexingStatusResponse)).toBe(0) + expect( + getSourcePercent({ completed_segments: 0, total_segments: 0 } as IndexingStatusResponse), + ).toBe(0) }) it('should cap at 100', () => { - expect(getSourcePercent({ completed_segments: 150, total_segments: 100 } as IndexingStatusResponse)).toBe(100) + expect( + getSourcePercent({ completed_segments: 150, total_segments: 100 } as IndexingStatusResponse), + ).toBe(100) }) it('should round to nearest integer', () => { - expect(getSourcePercent({ completed_segments: 1, total_segments: 3 } as IndexingStatusResponse)).toBe(33) + expect( + getSourcePercent({ completed_segments: 1, total_segments: 3 } as IndexingStatusResponse), + ).toBe(33) }) it('should handle undefined segments as 0', () => { @@ -124,10 +138,12 @@ describe('createDocumentLookup', () => { }) it('should return undefined notion icon for non-legacy info', () => { - const docs = [{ - id: 'doc-3', - data_source_info: { some_other: 'field' }, - }] as unknown as FullDocumentDetail[] + const docs = [ + { + id: 'doc-3', + data_source_info: { some_other: 'field' }, + }, + ] as unknown as FullDocumentDetail[] const lookup = createDocumentLookup(docs) expect(lookup.getNotionIcon('doc-3')).toBeUndefined() }) diff --git a/web/app/components/datasets/create/embedding-process/index.tsx b/web/app/components/datasets/create/embedding-process/index.tsx index c5d0bac9100253..b27aee42a5f4ce 100644 --- a/web/app/components/datasets/create/embedding-process/index.tsx +++ b/web/app/components/datasets/create/embedding-process/index.tsx @@ -2,11 +2,7 @@ import type { FC } from 'react' import type { FullDocumentDetail } from '@/models/datasets' import type { RETRIEVE_METHOD } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' -import { - RiArrowRightLine, - RiLoader2Fill, - RiTerminalBoxLine, -} from '@remixicon/react' +import { RiArrowRightLine, RiLoader2Fill, RiTerminalBoxLine } from '@remixicon/react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' @@ -32,7 +28,7 @@ type EmbeddingProcessProps = { } // Status header component -const StatusHeader: FC<{ isEmbedding: boolean, isCompleted: boolean }> = ({ +const StatusHeader: FC<{ isEmbedding: boolean; isCompleted: boolean }> = ({ isEmbedding, isCompleted, }) => { @@ -43,10 +39,10 @@ const StatusHeader: FC<{ isEmbedding: boolean, isCompleted: boolean }> = ({ {isEmbedding && ( <> <RiLoader2Fill className="size-4 animate-spin" /> - <span>{t($ => $['embedding.processing'], { ns: 'datasetDocuments' })}</span> + <span>{t(($) => $['embedding.processing'], { ns: 'datasetDocuments' })}</span> </> )} - {isCompleted && t($ => $['embedding.completed'], { ns: 'datasetDocuments' })} + {isCompleted && t(($) => $['embedding.completed'], { ns: 'datasetDocuments' })} </div> ) } @@ -66,12 +62,8 @@ const ActionButtons: FC<{ <span className="px-0.5">Access the API</span> </Button> </Link> - <Button - className="w-fit gap-x-0.5 px-3" - variant="primary" - onClick={onNavToDocuments} - > - <span className="px-0.5">{t($ => $['stepThree.navTo'], { ns: 'datasetCreation' })}</span> + <Button className="w-fit gap-x-0.5 px-3" variant="primary" onClick={onNavToDocuments}> + <span className="px-0.5">{t(($) => $['stepThree.navTo'], { ns: 'datasetCreation' })}</span> <RiArrowRightLine className="size-4 stroke-current stroke-1" /> </Button> </div> @@ -101,10 +93,7 @@ const EmbeddingProcess: FC<EmbeddingProcessProps> = ({ const { data: ruleDetail } = useProcessRule(firstDocumentId) // Document lookup utilities - memoized for performance - const documentLookup = useMemo( - () => createDocumentLookup(documents), - [documents], - ) + const documentLookup = useMemo(() => createDocumentLookup(documents), [documents]) const handleNavToDocuments = () => { invalidDocumentList() @@ -121,7 +110,7 @@ const EmbeddingProcess: FC<EmbeddingProcessProps> = ({ {showUpgradeBanner && <UpgradeBanner />} <div className="flex flex-col gap-0.5 pb-2"> - {statusList.map(detail => ( + {statusList.map((detail) => ( <IndexingProgressItem key={detail.id} detail={detail} @@ -142,10 +131,7 @@ const EmbeddingProcess: FC<EmbeddingProcessProps> = ({ /> </div> - <ActionButtons - apiReferenceUrl={apiReferenceUrl} - onNavToDocuments={handleNavToDocuments} - /> + <ActionButtons apiReferenceUrl={apiReferenceUrl} onNavToDocuments={handleNavToDocuments} /> </> ) } diff --git a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx index b98d9e7e109742..886341c5e845c1 100644 --- a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx +++ b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx @@ -2,10 +2,7 @@ import type { FC } from 'react' import type { IndexingStatusResponse } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiCheckboxCircleFill, - RiErrorWarningFill, -} from '@remixicon/react' +import { RiCheckboxCircleFill, RiErrorWarningFill } from '@remixicon/react' import NotionIcon from '@/app/components/base/notion-icon' import PriorityLabel from '@/app/components/billing/priority-label' import { DataSourceType } from '@/models/datasets' @@ -21,7 +18,7 @@ type IndexingProgressItemProps = { } // Status icon component for completed/error states -const StatusIcon: FC<{ status: string, error?: string }> = ({ status, error }) => { +const StatusIcon: FC<{ status: string; error?: string }> = ({ status, error }) => { if (status === 'completed') return <RiCheckboxCircleFill className="size-4 shrink-0 text-text-success" /> @@ -52,23 +49,12 @@ const SourceTypeIcon: FC<{ }> = ({ sourceType, name, notionIcon }) => { if (sourceType === DataSourceType.FILE) { return ( - <DocumentFileIcon - size="sm" - className="shrink-0" - name={name} - extension={getFileType(name)} - /> + <DocumentFileIcon size="sm" className="shrink-0" name={name} extension={getFileType(name)} /> ) } if (sourceType === DataSourceType.NOTION) { - return ( - <NotionIcon - className="shrink-0" - type="page" - src={notionIcon} - /> - ) + return <NotionIcon className="shrink-0" type="page" src={notionIcon} /> } return null @@ -99,20 +85,12 @@ const IndexingProgressItem: FC<IndexingProgressItemProps> = ({ /> )} <div className="z-1 flex h-full items-center gap-1 pr-2 pl-[6px]"> - <SourceTypeIcon - sourceType={sourceType} - name={name} - notionIcon={notionIcon} - /> + <SourceTypeIcon sourceType={sourceType} name={name} notionIcon={notionIcon} /> <div className="flex w-0 grow items-center gap-1" title={name}> - <div className="truncate system-xs-medium text-text-secondary"> - {name} - </div> + <div className="truncate system-xs-medium text-text-secondary">{name}</div> {enableBilling && <PriorityLabel className="ml-0" />} </div> - {isEmbedding && ( - <div className="shrink-0 text-xs text-text-secondary">{`${percent}%`}</div> - )} + {isEmbedding && <div className="shrink-0 text-xs text-text-secondary">{`${percent}%`}</div>} <StatusIcon status={detail.indexing_status} error={detail.error} /> </div> </div> diff --git a/web/app/components/datasets/create/embedding-process/rule-detail.tsx b/web/app/components/datasets/create/embedding-process/rule-detail.tsx index ee347d0b52e0fa..bcd00b114ba005 100644 --- a/web/app/components/datasets/create/embedding-process/rule-detail.tsx +++ b/web/app/components/datasets/create/embedding-process/rule-detail.tsx @@ -36,59 +36,60 @@ const RuleDetail: FC<RuleDetailProps> = ({ sourceData, indexingType, retrievalMe const { t } = useTranslation() const segmentationRuleLabels = { - mode: t($ => $['embedding.mode'], { ns: 'datasetDocuments' }), - segmentLength: t($ => $['embedding.segmentLength'], { ns: 'datasetDocuments' }), - textCleaning: t($ => $['embedding.textCleaning'], { ns: 'datasetDocuments' }), + mode: t(($) => $['embedding.mode'], { ns: 'datasetDocuments' }), + segmentLength: t(($) => $['embedding.segmentLength'], { ns: 'datasetDocuments' }), + textCleaning: t(($) => $['embedding.textCleaning'], { ns: 'datasetDocuments' }), } - const getRuleName = useCallback((key: string): string | undefined => { - const translationKey = PRE_PROCESSING_RULE_KEYS[key as keyof typeof PRE_PROCESSING_RULE_KEYS] - return translationKey ? t($ => $[translationKey], { ns: 'datasetCreation' }) : undefined - }, [t]) + const getRuleName = useCallback( + (key: string): string | undefined => { + const translationKey = PRE_PROCESSING_RULE_KEYS[key as keyof typeof PRE_PROCESSING_RULE_KEYS] + return translationKey ? t(($) => $[translationKey], { ns: 'datasetCreation' }) : undefined + }, + [t], + ) const getModeValue = useCallback((): string => { - if (!sourceData?.mode) - return '-' + if (!sourceData?.mode) return '-' if (sourceData.mode === ProcessMode.general) - return t($ => $['embedding.custom'], { ns: 'datasetDocuments' }) + return t(($) => $['embedding.custom'], { ns: 'datasetDocuments' }) - const parentModeLabel = sourceData.rules?.parent_mode === 'paragraph' - ? t($ => $['parentMode.paragraph'], { ns: 'dataset' }) - : t($ => $['parentMode.fullDoc'], { ns: 'dataset' }) + const parentModeLabel = + sourceData.rules?.parent_mode === 'paragraph' + ? t(($) => $['parentMode.paragraph'], { ns: 'dataset' }) + : t(($) => $['parentMode.fullDoc'], { ns: 'dataset' }) - return `${t($ => $['embedding.hierarchical'], { ns: 'datasetDocuments' })} · ${parentModeLabel}` + return `${t(($) => $['embedding.hierarchical'], { ns: 'datasetDocuments' })} · ${parentModeLabel}` }, [sourceData, t]) const getSegmentLengthValue = useCallback((): string | number => { - if (!sourceData?.mode) - return '-' + if (!sourceData?.mode) return '-' const maxTokens = isNumber(sourceData.rules?.segmentation?.max_tokens) ? sourceData.rules.segmentation.max_tokens : '-' - if (sourceData.mode === ProcessMode.general) - return maxTokens + if (sourceData.mode === ProcessMode.general) return maxTokens const childMaxTokens = isNumber(sourceData.rules?.subchunk_segmentation?.max_tokens) ? sourceData.rules.subchunk_segmentation.max_tokens : '-' - return `${t($ => $['embedding.parentMaxTokens'], { ns: 'datasetDocuments' })} ${maxTokens}; ${t($ => $['embedding.childMaxTokens'], { ns: 'datasetDocuments' })} ${childMaxTokens}` + return `${t(($) => $['embedding.parentMaxTokens'], { ns: 'datasetDocuments' })} ${maxTokens}; ${t(($) => $['embedding.childMaxTokens'], { ns: 'datasetDocuments' })} ${childMaxTokens}` }, [sourceData, t]) const getTextCleaningValue = useCallback((): string => { - if (!sourceData?.mode) - return '-' + if (!sourceData?.mode) return '-' - const enabledRules = sourceData.rules?.pre_processing_rules?.filter(rule => rule.enabled) || [] + const enabledRules = + sourceData.rules?.pre_processing_rules?.filter((rule) => rule.enabled) || [] const ruleNames = enabledRules .map((rule) => { const name = getRuleName(rule.id) return typeof name === 'string' ? name : '' }) - .filter(name => name) + .filter((name) => name) return ruleNames.length > 0 ? ruleNames.join(',') : '-' }, [sourceData, getRuleName]) @@ -99,16 +100,25 @@ const RuleDetail: FC<RuleDetailProps> = ({ sourceData, indexingType, retrievalMe } const isEconomical = indexingType === IndexingType.ECONOMICAL - const indexMethodIconSrc = isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality - const indexModeLabel = t($ => $[`stepTwo.${isEconomical ? 'economical' : 'qualified'}`], { ns: 'datasetCreation' }) - - const effectiveRetrievalMethod = isEconomical ? 'keyword_search' : (retrievalMethod ?? 'semantic_search') - const retrievalLabel = t($ => $[`retrieval.${effectiveRetrievalMethod}.title`], { ns: 'dataset' }) - const retrievalIconSrc = RETRIEVAL_ICON_MAP[retrievalMethod as keyof typeof RETRIEVAL_ICON_MAP] ?? retrievalIcon.vector + const indexMethodIconSrc = isEconomical + ? indexMethodIcon.economical + : indexMethodIcon.high_quality + const indexModeLabel = t(($) => $[`stepTwo.${isEconomical ? 'economical' : 'qualified'}`], { + ns: 'datasetCreation', + }) + + const effectiveRetrievalMethod = isEconomical + ? 'keyword_search' + : (retrievalMethod ?? 'semantic_search') + const retrievalLabel = t(($) => $[`retrieval.${effectiveRetrievalMethod}.title`], { + ns: 'dataset', + }) + const retrievalIconSrc = + RETRIEVAL_ICON_MAP[retrievalMethod as keyof typeof RETRIEVAL_ICON_MAP] ?? retrievalIcon.vector return ( <div className="flex flex-col gap-1"> - {Object.keys(segmentationRuleLabels).map(field => ( + {Object.keys(segmentationRuleLabels).map((field) => ( <FieldInfo key={field} label={segmentationRuleLabels[field as keyof typeof segmentationRuleLabels]} @@ -116,12 +126,12 @@ const RuleDetail: FC<RuleDetailProps> = ({ sourceData, indexingType, retrievalMe /> ))} <FieldInfo - label={t($ => $['stepTwo.indexMode'], { ns: 'datasetCreation' })} + label={t(($) => $['stepTwo.indexMode'], { ns: 'datasetCreation' })} displayedValue={indexModeLabel} valueIcon={<img className="size-4" src={indexMethodIconSrc} alt="" />} /> <FieldInfo - label={t($ => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} + label={t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} displayedValue={retrievalLabel} valueIcon={<img className="size-4" src={retrievalIconSrc} alt="" />} /> diff --git a/web/app/components/datasets/create/embedding-process/upgrade-banner.tsx b/web/app/components/datasets/create/embedding-process/upgrade-banner.tsx index 4f3a992a90c977..e42d2053962247 100644 --- a/web/app/components/datasets/create/embedding-process/upgrade-banner.tsx +++ b/web/app/components/datasets/create/embedding-process/upgrade-banner.tsx @@ -12,7 +12,7 @@ const UpgradeBanner: FC = () => { <ZapFast className="h-4 w-4 text-[#FB6514]" /> </div> <div className="mx-3 grow text-[13px] font-medium text-gray-700"> - {t($ => $['plansCommon.documentProcessingPriorityUpgrade'], { ns: 'billing' })} + {t(($) => $['plansCommon.documentProcessingPriorityUpgrade'], { ns: 'billing' })} </div> <UpgradeBtn loc="knowledge-speed-up" /> </div> diff --git a/web/app/components/datasets/create/embedding-process/use-indexing-status-polling.ts b/web/app/components/datasets/create/embedding-process/use-indexing-status-polling.ts index f8e69e47af082a..f3c1314f6a6275 100644 --- a/web/app/components/datasets/create/embedding-process/use-indexing-status-polling.ts +++ b/web/app/components/datasets/create/embedding-process/use-indexing-status-polling.ts @@ -18,10 +18,10 @@ type IndexingStatusPollingResult = { } const isStatusCompleted = (status: string): boolean => - COMPLETED_STATUSES.includes(status as typeof COMPLETED_STATUSES[number]) + COMPLETED_STATUSES.includes(status as (typeof COMPLETED_STATUSES)[number]) const isAllCompleted = (statusList: IndexingStatusResponse[]): boolean => - statusList.every(item => isStatusCompleted(item.indexing_status)) + statusList.every((item) => isStatusCompleted(item.indexing_status)) /** * Custom hook for polling indexing status with automatic stop on completion. @@ -46,8 +46,7 @@ export const useIndexingStatusPolling = ({ } const poll = async (): Promise<void> => { - if (isStopPollingRef.current) - return + if (isStopPollingRef.current) return try { const data = await fetchStatus() @@ -55,8 +54,7 @@ export const useIndexingStatusPolling = ({ isStopPollingRef.current = true return } - } - catch { + } catch { // Continue polling on error } @@ -71,13 +69,12 @@ export const useIndexingStatusPolling = ({ return () => { isStopPollingRef.current = true - if (timeoutId) - clearTimeout(timeoutId) + if (timeoutId) clearTimeout(timeoutId) } }, [datasetId, batchId]) - const isEmbedding = statusList.some(item => - EMBEDDING_STATUSES.includes(item?.indexing_status as typeof EMBEDDING_STATUSES[number]), + const isEmbedding = statusList.some((item) => + EMBEDDING_STATUSES.includes(item?.indexing_status as (typeof EMBEDDING_STATUSES)[number]), ) const isEmbeddingCompleted = statusList.length > 0 && isAllCompleted(statusList) diff --git a/web/app/components/datasets/create/embedding-process/utils.ts b/web/app/components/datasets/create/embedding-process/utils.ts index 6fbefb0230e26c..0f0fbfd84d7cd7 100644 --- a/web/app/components/datasets/create/embedding-process/utils.ts +++ b/web/app/components/datasets/create/embedding-process/utils.ts @@ -19,7 +19,7 @@ export const isLegacyDataSourceInfo = (info: DataSourceInfo): info is LegacyData * Check if a status indicates the source is being embedded */ export const isSourceEmbedding = (detail: IndexingStatusResponse): boolean => - EMBEDDING_STATUSES.includes(detail.indexing_status as typeof EMBEDDING_STATUSES[number]) + EMBEDDING_STATUSES.includes(detail.indexing_status as (typeof EMBEDDING_STATUSES)[number]) /** * Calculate the progress percentage for a document @@ -28,36 +28,34 @@ export const getSourcePercent = (detail: IndexingStatusResponse): number => { const completedCount = detail.completed_segments || 0 const totalCount = detail.total_segments || 0 - if (totalCount === 0) - return 0 + if (totalCount === 0) return 0 - const percent = Math.round(completedCount * 100 / totalCount) + const percent = Math.round((completedCount * 100) / totalCount) return Math.min(percent, 100) } /** * Get file extension from filename, defaults to 'txt' */ -export const getFileType = (name?: string): string => - name?.split('.').pop() || 'txt' +export const getFileType = (name?: string): string => name?.split('.').pop() || 'txt' /** * Document lookup utilities - provides document info by ID from a list */ export const createDocumentLookup = (documents: FullDocumentDetail[]) => { - const documentMap = new Map(documents.map(doc => [doc.id, doc])) + const documentMap = new Map(documents.map((doc) => [doc.id, doc])) return { getDocument: (id: string) => documentMap.get(id), getName: (id: string) => documentMap.get(id)?.name, - getSourceType: (id: string) => documentMap.get(id)?.data_source_type as DataSourceType | undefined, + getSourceType: (id: string) => + documentMap.get(id)?.data_source_type as DataSourceType | undefined, getNotionIcon: (id: string) => { const info = documentMap.get(id)?.data_source_info - if (info && isLegacyDataSourceInfo(info)) - return info.notion_page_icon + if (info && isLegacyDataSourceInfo(info)) return info.notion_page_icon return undefined }, } diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx index 32b7b1d17d9bfb..19dca0975820aa 100644 --- a/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx @@ -43,10 +43,12 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ // Type cast mocked functions const mockCreateEmptyDataset = createEmptyDataset as MockedFunction<typeof createEmptyDataset> const mockInvalidDatasetList = vi.fn() -const mockUseInvalidDatasetList = useInvalidDatasetList as MockedFunction<typeof useInvalidDatasetList> +const mockUseInvalidDatasetList = useInvalidDatasetList as MockedFunction< + typeof useInvalidDatasetList +> // Test data builder for props -const createDefaultProps = (overrides?: Partial<{ show: boolean, onHide: () => void }>) => ({ +const createDefaultProps = (overrides?: Partial<{ show: boolean; onHide: () => void }>) => ({ show: true, onHide: vi.fn(), ...overrides, @@ -81,7 +83,9 @@ describe('EmptyDatasetCreationModal', () => { expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepOne.modal.tip')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepOne.modal.input')).toBeInTheDocument() - expect(screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder'), + ).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepOne.modal.confirmButton')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepOne.modal.cancelButton')).toBeInTheDocument() }) @@ -91,7 +95,9 @@ describe('EmptyDatasetCreationModal', () => { render(<EmptyDatasetCreationModal {...props} />) - const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement + const input = screen.getByPlaceholderText( + 'datasetCreation.stepOne.modal.placeholder', + ) as HTMLInputElement expect(input.value).toBe('') }) @@ -161,7 +167,9 @@ describe('EmptyDatasetCreationModal', () => { it('should update input value when user types', () => { const props = createDefaultProps() render(<EmptyDatasetCreationModal {...props} />) - const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement + const input = screen.getByPlaceholderText( + 'datasetCreation.stepOne.modal.placeholder', + ) as HTMLInputElement fireEvent.change(input, { target: { value: 'My Dataset' } }) @@ -171,7 +179,9 @@ describe('EmptyDatasetCreationModal', () => { it('should persist input value when modal is hidden and shown again via rerender', () => { const onHide = vi.fn() const { rerender } = render(<EmptyDatasetCreationModal show={true} onHide={onHide} />) - const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement + const input = screen.getByPlaceholderText( + 'datasetCreation.stepOne.modal.placeholder', + ) as HTMLInputElement // Act - Type in input fireEvent.change(input, { target: { value: 'Test Dataset' } }) @@ -182,14 +192,18 @@ describe('EmptyDatasetCreationModal', () => { rerender(<EmptyDatasetCreationModal show={true} onHide={onHide} />) // Assert - Input value persists because component state is preserved during rerender - const newInput = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement + const newInput = screen.getByPlaceholderText( + 'datasetCreation.stepOne.modal.placeholder', + ) as HTMLInputElement expect(newInput.value).toBe('Test Dataset') }) it('should handle consecutive input changes', () => { const props = createDefaultProps() render(<EmptyDatasetCreationModal {...props} />) - const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement + const input = screen.getByPlaceholderText( + 'datasetCreation.stepOne.modal.placeholder', + ) as HTMLInputElement fireEvent.change(input, { target: { value: 'A' } }) expect(input.value).toBe('A') diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/index.module.css b/web/app/components/datasets/create/empty-dataset-creation-modal/index.module.css index 803bcac4136d98..c5370ce6507078 100644 --- a/web/app/components/datasets/create/empty-dataset-creation-modal/index.module.css +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/index.module.css @@ -1,7 +1,7 @@ @reference "../../../../styles/globals.css"; .modalHeader { - @apply flex items-center place-content-between h-8; + @apply flex h-8 place-content-between items-center; } .modalHeader .title { @apply grow text-text-primary; @@ -10,7 +10,7 @@ line-height: 32px; } .modalHeader .close { - @apply shrink-0 h-4 w-4 bg-center bg-no-repeat cursor-pointer; + @apply h-4 w-4 shrink-0 cursor-pointer bg-center bg-no-repeat; background-image: url(../assets/close.svg); background-size: 16px; } diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx index 2039cb7ac80516..2868ce3cc122eb 100644 --- a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx @@ -24,11 +24,11 @@ const EmptyDatasetCreationModal = ({ show = false, onHide }: IProps) => { const invalidDatasetList = useInvalidDatasetList() const submit = async () => { if (!inputValue) { - toast.error(t($ => $['stepOne.modal.nameNotEmpty'], { ns: 'datasetCreation' })) + toast.error(t(($) => $['stepOne.modal.nameNotEmpty'], { ns: 'datasetCreation' })) return } if (inputValue.length > 40) { - toast.error(t($ => $['stepOne.modal.nameLengthInvalid'], { ns: 'datasetCreation' })) + toast.error(t(($) => $['stepOne.modal.nameLengthInvalid'], { ns: 'datasetCreation' })) return } try { @@ -40,38 +40,50 @@ const EmptyDatasetCreationModal = ({ show = false, onHide }: IProps) => { }) onHide() router.push(`/datasets/${dataset.id}/documents`) - } - catch { - toast.error(t($ => $['stepOne.modal.failed'], { ns: 'datasetCreation' })) + } catch { + toast.error(t(($) => $['stepOne.modal.failed'], { ns: 'datasetCreation' })) } } return ( <Dialog open={show} onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DialogContent className="w-full max-w-[520px]! overflow-hidden! border-none px-8 text-left align-middle"> - <div className={s.modalHeader}> - <div className={s.title}>{t($ => $['stepOne.modal.title'], { ns: 'datasetCreation' })}</div> + <div className={s.title}> + {t(($) => $['stepOne.modal.title'], { ns: 'datasetCreation' })} + </div> <button type="button" - className={cn(s.close, 'border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden')} - aria-label={t($ => $['operation.close'], { ns: 'common' })} + className={cn( + s.close, + 'border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + )} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onHide} /> </div> - <div className={s.tip}>{t($ => $['stepOne.modal.tip'], { ns: 'datasetCreation' })}</div> + <div className={s.tip}>{t(($) => $['stepOne.modal.tip'], { ns: 'datasetCreation' })}</div> <div className={s.form}> - <div className={s.label}>{t($ => $['stepOne.modal.input'], { ns: 'datasetCreation' })}</div> - <Input value={inputValue} placeholder={t($ => $['stepOne.modal.placeholder'], { ns: 'datasetCreation' }) || ''} onChange={e => setInputValue(e.target.value)} /> + <div className={s.label}> + {t(($) => $['stepOne.modal.input'], { ns: 'datasetCreation' })} + </div> + <Input + value={inputValue} + placeholder={t(($) => $['stepOne.modal.placeholder'], { ns: 'datasetCreation' }) || ''} + onChange={(e) => setInputValue(e.target.value)} + /> </div> <div className="flex flex-row-reverse"> - <Button className="ml-2 w-24" variant="primary" onClick={submit}>{t($ => $['stepOne.modal.confirmButton'], { ns: 'datasetCreation' })}</Button> - <Button className="w-24" onClick={onHide}>{t($ => $['stepOne.modal.cancelButton'], { ns: 'datasetCreation' })}</Button> + <Button className="ml-2 w-24" variant="primary" onClick={submit}> + {t(($) => $['stepOne.modal.confirmButton'], { ns: 'datasetCreation' })} + </Button> + <Button className="w-24" onClick={onHide}> + {t(($) => $['stepOne.modal.cancelButton'], { ns: 'datasetCreation' })} + </Button> </div> </DialogContent> </Dialog> diff --git a/web/app/components/datasets/create/file-preview/__tests__/index.spec.tsx b/web/app/components/datasets/create/file-preview/__tests__/index.spec.tsx index c0168071866ae7..92701430c3e2c0 100644 --- a/web/app/components/datasets/create/file-preview/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/file-preview/__tests__/index.spec.tsx @@ -31,7 +31,7 @@ const createMockFile = (overrides: Partial<File> = {}): File => { } // Helper to render FilePreview with default props -const renderFilePreview = (props: Partial<{ file?: File, hidePreview: () => void }> = {}) => { +const renderFilePreview = (props: Partial<{ file?: File; hidePreview: () => void }> = {}) => { const defaultProps = { file: createMockFile(), hidePreview: vi.fn(), @@ -112,7 +112,7 @@ describe('FilePreview', () => { it('should show loading indicator initially', async () => { // Arrange - Delay API response to keep loading state mockFetchFilePreview.mockImplementation( - () => new Promise(resolve => setTimeout(() => resolve({ content: 'test' }), 100)), + () => new Promise((resolve) => setTimeout(() => resolve({ content: 'test' }), 100)), ) const { container } = renderFilePreview() @@ -143,13 +143,21 @@ describe('FilePreview', () => { let resolveSecond: (value: { content: string }) => void mockFetchFilePreview - .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) - .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve + }), + ) // Act - Initial render - const { rerender, container } = render( - <FilePreview file={file1} hidePreview={vi.fn()} />, - ) + const { rerender, container } = render(<FilePreview file={file1} hidePreview={vi.fn()} />) // First file loading - spinner should be visible // First file loading - spinner should be visible @@ -213,9 +221,7 @@ describe('FilePreview', () => { const file1 = createMockFile({ id: 'file-1' }) const file2 = createMockFile({ id: 'file-2' }) - const { rerender } = render( - <FilePreview file={file1} hidePreview={vi.fn()} />, - ) + const { rerender } = render(<FilePreview file={file1} hidePreview={vi.fn()} />) await waitFor(() => { expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-1' }) @@ -303,7 +309,12 @@ describe('FilePreview', () => { describe('State Management', () => { it('should initialize with loading state true', async () => { // Arrange - Keep loading indefinitely (never resolves) - mockFetchFilePreview.mockImplementation(() => new Promise(() => { /* intentionally empty */ })) + mockFetchFilePreview.mockImplementation( + () => + new Promise(() => { + /* intentionally empty */ + }), + ) const { container } = renderFilePreview() @@ -325,14 +336,15 @@ describe('FilePreview', () => { const file1 = createMockFile({ id: 'file-1' }) const file2 = createMockFile({ id: 'file-2' }) - mockFetchFilePreview - .mockResolvedValueOnce({ content: 'Content 1' }) - .mockImplementationOnce(() => new Promise(() => { /* never resolves */ })) - - const { rerender, container } = render( - <FilePreview file={file1} hidePreview={vi.fn()} />, + mockFetchFilePreview.mockResolvedValueOnce({ content: 'Content 1' }).mockImplementationOnce( + () => + new Promise(() => { + /* never resolves */ + }), ) + const { rerender, container } = render(<FilePreview file={file1} hidePreview={vi.fn()} />) + await waitFor(() => { expect(screen.getByText('Content 1'))!.toBeInTheDocument() }) @@ -353,14 +365,15 @@ describe('FilePreview', () => { let resolveSecond: (value: { content: string }) => void - mockFetchFilePreview - .mockResolvedValueOnce({ content: 'Content 1' }) - .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) - - const { rerender } = render( - <FilePreview file={file1} hidePreview={vi.fn()} />, + mockFetchFilePreview.mockResolvedValueOnce({ content: 'Content 1' }).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve + }), ) + const { rerender } = render(<FilePreview file={file1} hidePreview={vi.fn()} />) + await waitFor(() => { expect(screen.getByText('Content 1'))!.toBeInTheDocument() }) @@ -554,9 +567,7 @@ describe('FilePreview', () => { const file1 = createMockFile({ id: 'file-1' }) const file2 = createMockFile({ id: 'file-2' }) - const { rerender } = render( - <FilePreview file={file1} hidePreview={vi.fn()} />, - ) + const { rerender } = render(<FilePreview file={file1} hidePreview={vi.fn()} />) await waitFor(() => { expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) @@ -574,9 +585,7 @@ describe('FilePreview', () => { const hidePreview1 = vi.fn() const hidePreview2 = vi.fn() - const { rerender } = render( - <FilePreview file={file} hidePreview={hidePreview1} />, - ) + const { rerender } = render(<FilePreview file={file} hidePreview={hidePreview1} />) await waitFor(() => { expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) @@ -592,12 +601,9 @@ describe('FilePreview', () => { }) it('should handle rapid file changes', async () => { - const files = Array.from({ length: 5 }, (_, i) => - createMockFile({ id: `file-${i}` })) + const files = Array.from({ length: 5 }, (_, i) => createMockFile({ id: `file-${i}` })) - const { rerender } = render( - <FilePreview file={files[0]} hidePreview={vi.fn()} />, - ) + const { rerender } = render(<FilePreview file={files[0]} hidePreview={vi.fn()} />) // Rapidly change files for (let i = 1; i < files.length; i++) @@ -611,7 +617,7 @@ describe('FilePreview', () => { it('should handle unmount during loading', async () => { mockFetchFilePreview.mockImplementation( - () => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)), + () => new Promise((resolve) => setTimeout(() => resolve({ content: 'delayed' }), 1000)), ) const { unmount } = renderFilePreview() @@ -626,9 +632,7 @@ describe('FilePreview', () => { it('should handle file changing from defined to undefined', async () => { const file = createMockFile() - const { rerender, container } = render( - <FilePreview file={file} hidePreview={vi.fn()} />, - ) + const { rerender, container } = render(<FilePreview file={file} hidePreview={vi.fn()} />) await waitFor(() => { expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) diff --git a/web/app/components/datasets/create/file-preview/index.module.css b/web/app/components/datasets/create/file-preview/index.module.css index 6ab73dbcc75857..adcb09c9bd27c3 100644 --- a/web/app/components/datasets/create/file-preview/index.module.css +++ b/web/app/components/datasets/create/file-preview/index.module.css @@ -1,41 +1,41 @@ @reference "../../../../styles/globals.css"; .filePreview { - @apply flex flex-col border-l border-components-panel-border shrink-0 bg-background-default-lighter; - width: 100%; - } - - .previewHeader { - @apply border-b border-divider-subtle shrink-0; - margin: 42px 32px 0; - padding-bottom: 16px; - } - - .previewHeader .title { - @apply flex justify-between items-center text-text-primary; - } - - .previewHeader .fileName { - @apply text-text-tertiary; - } - - .previewHeader .filetype { - @apply text-text-tertiary; - } - - .previewContent { - @apply overflow-y-auto grow text-text-secondary; - padding: 20px 32px; - } - - .previewContent .loading { - width: 100%; - height: 180px; - background: transparent center no-repeat url(../assets/Loading.svg); - background-size: contain; - } - - .fileContent { - white-space: pre-line; - word-break: break-all; - } + @apply flex shrink-0 flex-col border-l border-components-panel-border bg-background-default-lighter; + width: 100%; +} + +.previewHeader { + @apply shrink-0 border-b border-divider-subtle; + margin: 42px 32px 0; + padding-bottom: 16px; +} + +.previewHeader .title { + @apply flex items-center justify-between text-text-primary; +} + +.previewHeader .fileName { + @apply text-text-tertiary; +} + +.previewHeader .filetype { + @apply text-text-tertiary; +} + +.previewContent { + @apply grow overflow-y-auto text-text-secondary; + padding: 20px 32px; +} + +.previewContent .loading { + width: 100%; + height: 180px; + background: transparent center no-repeat url(../assets/Loading.svg); + background-size: contain; +} + +.fileContent { + white-space: pre-line; + word-break: break-all; +} diff --git a/web/app/components/datasets/create/file-preview/index.tsx b/web/app/components/datasets/create/file-preview/index.tsx index f60f1109bd548e..50c4f56bcd4748 100644 --- a/web/app/components/datasets/create/file-preview/index.tsx +++ b/web/app/components/datasets/create/file-preview/index.tsx @@ -14,10 +14,7 @@ type IProps = { hidePreview: () => void } -const FilePreview = ({ - file, - hidePreview, -}: IProps) => { +const FilePreview = ({ file, hidePreview }: IProps) => { const { t } = useTranslation() const [previewContent, setPreviewContent] = useState('') const [loading, setLoading] = useState(true) @@ -27,13 +24,11 @@ const FilePreview = ({ const res = await fetchFilePreview({ fileID }) setPreviewContent(res.content) setLoading(false) - } - catch { } + } catch {} } const getFileName = (currentFile?: File) => { - if (!currentFile) - return '' + if (!currentFile) return '' const arr = currentFile.name.split('.') return arr.slice(0, -1).join() } @@ -49,11 +44,11 @@ const FilePreview = ({ <div className={cn(s.filePreview, 'h-full')}> <div className={cn(s.previewHeader)}> <div className={cn(s.title, 'title-md-semi-bold')}> - <span>{t($ => $['stepOne.filePreview'], { ns: 'datasetCreation' })}</span> + <span>{t(($) => $['stepOne.filePreview'], { ns: 'datasetCreation' })}</span> <button type="button" className="flex size-6 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={hidePreview} > <XMarkIcon className="size-4" aria-hidden="true"></XMarkIcon> @@ -61,17 +56,12 @@ const FilePreview = ({ </div> <div className={cn(s.fileName, 'system-xs-medium')}> <span>{getFileName(file)}</span> - <span className={cn(s.filetype)}> - . - {file?.extension} - </span> + <span className={cn(s.filetype)}>.{file?.extension}</span> </div> </div> <div className={cn(s.previewContent)}> {loading && <Loading type="area" />} - {!loading && ( - <div className={cn(s.fileContent, 'body-md-regular')}>{previewContent}</div> - )} + {!loading && <div className={cn(s.fileContent, 'body-md-regular')}>{previewContent}</div>} </div> </div> ) diff --git a/web/app/components/datasets/create/file-uploader/__tests__/constants.spec.ts b/web/app/components/datasets/create/file-uploader/__tests__/constants.spec.ts index 3659ecce795331..4582abc9ab768b 100644 --- a/web/app/components/datasets/create/file-uploader/__tests__/constants.spec.ts +++ b/web/app/components/datasets/create/file-uploader/__tests__/constants.spec.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - PROGRESS_COMPLETE, - PROGRESS_ERROR, - PROGRESS_NOT_STARTED, -} from '../constants' +import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' describe('file-uploader constants', () => { // Verify progress sentinel values diff --git a/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx b/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx index 83a8f48f578b57..1921e7fd52a0e7 100644 --- a/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx @@ -6,7 +6,8 @@ import FileUploader from '../index' const mockNotify = vi.fn() vi.mock('use-context-selector', async () => { - const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector') + const actual = + await vi.importActual<typeof import('use-context-selector')>('use-context-selector') return { ...actual, useContext: vi.fn(() => ({ notify: mockNotify })), @@ -49,29 +50,29 @@ vi.mock('@/types/app', () => ({ // Mock DocumentFileIcon - uses relative path from file-list-item.tsx vi.mock('@/app/components/datasets/common/document-file-icon', () => ({ - default: ({ extension }: { extension: string }) => <div data-testid="document-icon">{extension}</div>, + default: ({ extension }: { extension: string }) => ( + <div data-testid="document-icon">{extension}</div> + ), })) // Mock SimplePieChart vi.mock('@/next/dynamic', () => ({ default: () => { const Component = ({ percentage }: { percentage: number }) => ( - <div data-testid="pie-chart"> - {percentage} - % - </div> + <div data-testid="pie-chart">{percentage}%</div> ) return Component }, })) describe('FileUploader', () => { - const createMockFile = (overrides: Partial<File> = {}): File => ({ - name: 'test.pdf', - size: 1024, - type: 'application/pdf', - ...overrides, - } as File) + const createMockFile = (overrides: Partial<File> = {}): File => + ({ + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + ...overrides, + }) as File const createMockFileItem = (overrides: Partial<FileItem> = {}): FileItem => ({ fileID: `file-${Date.now()}`, @@ -145,14 +146,18 @@ describe('FileUploader', () => { it('should show single file text when batch upload disabled', () => { render(<FileUploader {...defaultProps} supportBatchUpload={false} />) - expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/)).toBeInTheDocument() + expect( + screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/), + ).toBeInTheDocument() }) it('should hide dropzone when not batch upload and has files', () => { const fileList = [createMockFileItem()] render(<FileUploader {...defaultProps} supportBatchUpload={false} fileList={fileList} />) - expect(screen.queryByText(/datasetCreation\.stepOne\.uploader\.button/)).not.toBeInTheDocument() + expect( + screen.queryByText(/datasetCreation\.stepOne\.uploader\.button/), + ).not.toBeInTheDocument() }) }) @@ -163,12 +168,13 @@ describe('FileUploader', () => { file: createMockFile({ id: 'file-id' } as Partial<File>), }) - const { container } = render(<FileUploader {...defaultProps} fileList={[fileItem]} onPreview={onPreview} />) + const { container } = render( + <FileUploader {...defaultProps} fileList={[fileItem]} onPreview={onPreview} />, + ) // Find the file list item container by its class pattern const fileElement = container.querySelector('[class*="flex h-12"]') - if (fileElement) - fireEvent.click(fileElement) + if (fileElement) fireEvent.click(fileElement) expect(onPreview).toHaveBeenCalledWith(fileItem.file) }) @@ -178,15 +184,18 @@ describe('FileUploader', () => { const fileItem = createMockFileItem() const { container } = render( - <FileUploader {...defaultProps} fileList={[fileItem]} onFileListUpdate={onFileListUpdate} />, + <FileUploader + {...defaultProps} + fileList={[fileItem]} + onFileListUpdate={onFileListUpdate} + />, ) // Find the delete button (the span with cursor-pointer containing the icon) const deleteButtons = container.querySelectorAll('[class*="cursor-pointer"]') // Get the last one which should be the delete button (not the browse label) const deleteButton = deleteButtons[deleteButtons.length - 1] - if (deleteButton) - fireEvent.click(deleteButton) + if (deleteButton) fireEvent.click(deleteButton) expect(onFileListUpdate).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx b/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx index e7a25cbdd88a36..f96e254e0ef36c 100644 --- a/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx @@ -19,12 +19,22 @@ vi.mock('@/types/app', () => ({ // Mock SimplePieChart with dynamic import handling vi.mock('@/next/dynamic', () => ({ default: () => { - const DynamicComponent = ({ percentage, stroke, fill }: { percentage: number, stroke: string, fill: string }) => ( - <div data-testid="pie-chart" data-percentage={percentage} data-stroke={stroke} data-fill={fill}> - Pie Chart: - {' '} - {percentage} - % + const DynamicComponent = ({ + percentage, + stroke, + fill, + }: { + percentage: number + stroke: string + fill: string + }) => ( + <div + data-testid="pie-chart" + data-percentage={percentage} + data-stroke={stroke} + data-fill={fill} + > + Pie Chart: {percentage}% </div> ) DynamicComponent.displayName = 'SimplePieChart' @@ -34,7 +44,7 @@ vi.mock('@/next/dynamic', () => ({ // Mock DocumentFileIcon vi.mock('@/app/components/datasets/common/document-file-icon', () => ({ - default: ({ name, extension, size }: { name: string, extension: string, size: string }) => ( + default: ({ name, extension, size }: { name: string; extension: string; size: string }) => ( <div data-testid="document-icon" data-name={name} data-extension={extension} data-size={size}> Document Icon </div> @@ -42,13 +52,14 @@ vi.mock('@/app/components/datasets/common/document-file-icon', () => ({ })) describe('FileListItem', () => { - const createMockFile = (overrides: Partial<File> = {}): File => ({ - name: 'test-document.pdf', - size: 1024 * 100, // 100KB - type: 'application/pdf', - lastModified: Date.now(), - ...overrides, - } as File) + const createMockFile = (overrides: Partial<File> = {}): File => + ({ + name: 'test-document.pdf', + size: 1024 * 100, // 100KB + type: 'application/pdf', + lastModified: Date.now(), + ...overrides, + }) as File const createMockFileItem = (overrides: Partial<FileItem> = {}): FileItem => ({ fileID: 'file-123', @@ -209,7 +220,9 @@ describe('FileListItem', () => { it('should call onRemove when delete button is clicked', () => { const onRemove = vi.fn() const fileItem = createMockFileItem() - const { container } = render(<FileListItem {...defaultProps} fileItem={fileItem} onRemove={onRemove} />) + const { container } = render( + <FileListItem {...defaultProps} fileItem={fileItem} onRemove={onRemove} />, + ) const deleteButton = container.querySelector('.cursor-pointer')! fireEvent.click(deleteButton) @@ -224,7 +237,14 @@ describe('FileListItem', () => { const fileItem = createMockFileItem({ file: createMockFile({ id: 'uploaded-id' } as Partial<File>), }) - const { container } = render(<FileListItem {...defaultProps} fileItem={fileItem} onPreview={onPreview} onRemove={onRemove} />) + const { container } = render( + <FileListItem + {...defaultProps} + fileItem={fileItem} + onPreview={onPreview} + onRemove={onRemove} + />, + ) const deleteButton = container.querySelector('.cursor-pointer')! fireEvent.click(deleteButton) diff --git a/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx b/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx index ac5014e4b2d88c..cb64248e531baf 100644 --- a/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx @@ -8,8 +8,9 @@ import UploadDropzone from '../upload-dropzone' let mockEnableBilling = false vi.mock('@/context/provider-context', () => ({ - useProviderContextSelector: <T,>(selector: (state: Pick<ProviderContextState, 'enableBilling'>) => T): T => - selector({ enableBilling: mockEnableBilling }), + useProviderContextSelector: <T,>( + selector: (state: Pick<ProviderContextState, 'enableBilling'>) => T, + ): T => selector({ enableBilling: mockEnableBilling }), })) // Helper to create mock ref objects for testing @@ -82,9 +83,13 @@ describe('UploadDropzone', () => { render(<UploadDropzone {...defaultProps} />) - const tipWithoutTotal = screen.getByText(/datasetCreation\.stepOne\.uploader\.tip(?!WithTotalLimit)/) + const tipWithoutTotal = screen.getByText( + /datasetCreation\.stepOne\.uploader\.tip(?!WithTotalLimit)/, + ) expect(tipWithoutTotal).toBeInTheDocument() - expect(screen.queryByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/)).not.toBeInTheDocument() + expect( + screen.queryByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/), + ).not.toBeInTheDocument() }) it('should render tip with total count limit when billing is enabled', () => { @@ -92,8 +97,12 @@ describe('UploadDropzone', () => { render(<UploadDropzone {...defaultProps} />) - expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/)).toBeInTheDocument() - expect(screen.queryByText(/datasetCreation\.stepOne\.uploader\.tip(?!WithTotalLimit)/)).not.toBeInTheDocument() + expect( + screen.getByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/), + ).toBeInTheDocument() + expect( + screen.queryByText(/datasetCreation\.stepOne\.uploader\.tip(?!WithTotalLimit)/), + ).not.toBeInTheDocument() }) it('should pass file size, batch count and supported types to tip when billing is disabled', () => { @@ -113,7 +122,8 @@ describe('UploadDropzone', () => { render(<UploadDropzone {...defaultProps} />) - const tipText = screen.getByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/).textContent ?? '' + const tipText = + screen.getByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/).textContent ?? '' expect(tipText).toContain('"size":15') expect(tipText).toContain('"batchCount":5') expect(tipText).toContain('"supportTypes":"PDF, DOCX, TXT"') @@ -149,20 +159,30 @@ describe('UploadDropzone', () => { it('should show single file text when supportBatchUpload is false', () => { render(<UploadDropzone {...defaultProps} supportBatchUpload={false} />) - expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/)).toBeInTheDocument() + expect( + screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/), + ).toBeInTheDocument() }) }) describe('dragging state', () => { it('should apply dragging styles when dragging is true', () => { const { container } = render(<UploadDropzone {...defaultProps} dragging={true} />) - const dropzone = container.querySelector('[class*="border-components-dropzone-border-accent"]') + const dropzone = container.querySelector( + '[class*="border-components-dropzone-border-accent"]', + ) expect(dropzone).toBeInTheDocument() }) it('should render drag overlay when dragging', () => { const dragRef = createMockRef<HTMLDivElement>() - render(<UploadDropzone {...defaultProps} dragging={true} dragRef={dragRef as RefObject<HTMLDivElement | null>} />) + render( + <UploadDropzone + {...defaultProps} + dragging={true} + dragRef={dragRef as RefObject<HTMLDivElement | null>} + />, + ) const overlay = document.querySelector('.absolute.left-0.top-0') expect(overlay).toBeInTheDocument() }) @@ -201,19 +221,32 @@ describe('UploadDropzone', () => { describe('refs', () => { it('should attach dropRef to drop container', () => { const dropRef = createMockRef<HTMLDivElement>() - render(<UploadDropzone {...defaultProps} dropRef={dropRef as RefObject<HTMLDivElement | null>} />) + render( + <UploadDropzone {...defaultProps} dropRef={dropRef as RefObject<HTMLDivElement | null>} />, + ) expect(dropRef.current).toBeInstanceOf(HTMLDivElement) }) it('should attach fileUploaderRef to input element', () => { const fileUploaderRef = createMockRef<HTMLInputElement>() - render(<UploadDropzone {...defaultProps} fileUploaderRef={fileUploaderRef as RefObject<HTMLInputElement | null>} />) + render( + <UploadDropzone + {...defaultProps} + fileUploaderRef={fileUploaderRef as RefObject<HTMLInputElement | null>} + />, + ) expect(fileUploaderRef.current).toBeInstanceOf(HTMLInputElement) }) it('should attach dragRef to overlay when dragging', () => { const dragRef = createMockRef<HTMLDivElement>() - render(<UploadDropzone {...defaultProps} dragging={true} dragRef={dragRef as RefObject<HTMLDivElement | null>} />) + render( + <UploadDropzone + {...defaultProps} + dragging={true} + dragRef={dragRef as RefObject<HTMLDivElement | null>} + />, + ) expect(dragRef.current).toBeInstanceOf(HTMLDivElement) }) }) diff --git a/web/app/components/datasets/create/file-uploader/components/file-list-item.tsx b/web/app/components/datasets/create/file-uploader/components/file-list-item.tsx index 2092ee0a2c0ad8..79eda0d0e0d2c0 100644 --- a/web/app/components/datasets/create/file-uploader/components/file-list-item.tsx +++ b/web/app/components/datasets/create/file-uploader/components/file-list-item.tsx @@ -9,7 +9,9 @@ import { Theme } from '@/types/app' import { formatFileSize, getFileExtension } from '@/utils/format' import { PROGRESS_COMPLETE, PROGRESS_ERROR } from '../constants' -const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { ssr: false }) +const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { + ssr: false, +}) export type FileListItemProps = { fileItem: FileItem @@ -17,20 +19,15 @@ export type FileListItemProps = { onRemove: (fileID: string) => void } -const FileListItem = ({ - fileItem, - onPreview, - onRemove, -}: FileListItemProps) => { +const FileListItem = ({ fileItem, onPreview, onRemove }: FileListItemProps) => { const { theme } = useTheme() - const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme]) + const chartColor = useMemo(() => (theme === Theme.dark ? '#5289ff' : '#296dff'), [theme]) const isUploading = fileItem.progress >= 0 && fileItem.progress < PROGRESS_COMPLETE const isError = fileItem.progress === PROGRESS_ERROR const handleClick = () => { - if (fileItem.file?.id) - onPreview(fileItem.file) + if (fileItem.file?.id) onPreview(fileItem.file) } const handleRemove = (e: React.MouseEvent) => { @@ -72,9 +69,7 @@ const FileListItem = ({ animationDuration={0} /> )} - {isError && ( - <RiErrorWarningFill className="size-4 text-text-destructive" /> - )} + {isError && <RiErrorWarningFill className="size-4 text-text-destructive" />} <span className="flex size-6 cursor-pointer items-center justify-center" onClick={handleRemove} diff --git a/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx b/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx index eeb820e080a2ed..732bf2a34cb577 100644 --- a/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx +++ b/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx @@ -31,7 +31,7 @@ const UploadDropzone = ({ onFileChange, }: UploadDropzoneProps) => { const { t } = useTranslation() - const enableBilling = useProviderContextSelector(state => state.enableBilling) + const enableBilling = useProviderContextSelector((state) => state.enableBilling) return ( <> @@ -55,28 +55,25 @@ const UploadDropzone = ({ <span className="mr-2 i-ri-upload-cloud-2-line size-5" /> <span> {supportBatchUpload - ? t($ => $['stepOne.uploader.button'], { ns: 'datasetCreation' }) - : t($ => $['stepOne.uploader.buttonSingleFile'], { ns: 'datasetCreation' })} + ? t(($) => $['stepOne.uploader.button'], { ns: 'datasetCreation' }) + : t(($) => $['stepOne.uploader.buttonSingleFile'], { ns: 'datasetCreation' })} {acceptTypes.length > 0 && ( - <label - className="ml-1 cursor-pointer text-text-accent" - onClick={onSelectFile} - > - {t($ => $['stepOne.uploader.browse'], { ns: 'datasetCreation' })} + <label className="ml-1 cursor-pointer text-text-accent" onClick={onSelectFile}> + {t(($) => $['stepOne.uploader.browse'], { ns: 'datasetCreation' })} </label> )} </span> </div> <div> {enableBilling - ? t($ => $['stepOne.uploader.tipWithTotalLimit'], { + ? t(($) => $['stepOne.uploader.tipWithTotalLimit'], { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit, supportTypes: supportTypesShowNames, batchCount: fileUploadConfig.batch_count_limit, totalCount: fileUploadConfig.file_upload_limit, }) - : t($ => $['stepOne.uploader.tip'], { + : t(($) => $['stepOne.uploader.tip'], { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit, supportTypes: supportTypesShowNames, diff --git a/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx index ecf1e4feaa72ac..26938422ee2595 100644 --- a/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from 'react' import type { CustomFile, FileItem } from '@/models/datasets' import { act, render, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants' // Import after mocks import { useFileUpload } from '../use-file-upload' @@ -51,11 +50,7 @@ vi.mock('@/app/components/base/file-uploader/utils', () => ({ })) const createWrapper = () => { - return ({ children }: { children: ReactNode }) => ( - <> - {children} - </> - ) + return ({ children }: { children: ReactNode }) => <>{children}</> } describe('useFileUpload', () => { @@ -78,10 +73,9 @@ describe('useFileUpload', () => { describe('initialization', () => { it('should initialize with default values', () => { - const { result } = renderHook( - () => useFileUpload(defaultOptions), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload(defaultOptions), { + wrapper: createWrapper(), + }) expect(result.current.dragging).toBe(false) expect(result.current.hideUpload).toBe(false) @@ -92,11 +86,12 @@ describe('useFileUpload', () => { it('should set hideUpload true when not batch upload and has files', () => { const { result } = renderHook( - () => useFileUpload({ - ...defaultOptions, - supportBatchUpload: false, - fileList: [{ fileID: 'file-1', file: {} as CustomFile, progress: 100 }], - }), + () => + useFileUpload({ + ...defaultOptions, + supportBatchUpload: false, + fileList: [{ fileID: 'file-1', file: {} as CustomFile, progress: 100 }], + }), { wrapper: createWrapper() }, ) @@ -104,19 +99,17 @@ describe('useFileUpload', () => { }) it('should compute acceptTypes correctly', () => { - const { result } = renderHook( - () => useFileUpload(defaultOptions), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload(defaultOptions), { + wrapper: createWrapper(), + }) expect(result.current.acceptTypes).toEqual(['.pdf', '.docx', '.txt', '.md']) }) it('should compute supportTypesShowNames correctly', () => { - const { result } = renderHook( - () => useFileUpload(defaultOptions), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload(defaultOptions), { + wrapper: createWrapper(), + }) expect(result.current.supportTypesShowNames).toContain('PDF') expect(result.current.supportTypesShowNames).toContain('DOCX') @@ -127,10 +120,11 @@ describe('useFileUpload', () => { it('should set batch limit to 1 when not batch upload', () => { const { result } = renderHook( - () => useFileUpload({ - ...defaultOptions, - supportBatchUpload: false, - }), + () => + useFileUpload({ + ...defaultOptions, + supportBatchUpload: false, + }), { wrapper: createWrapper() }, ) @@ -141,10 +135,9 @@ describe('useFileUpload', () => { describe('selectHandle', () => { it('should trigger click on file input', () => { - const { result } = renderHook( - () => useFileUpload(defaultOptions), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload(defaultOptions), { + wrapper: createWrapper(), + }) const mockClick = vi.fn() const mockInput = { click: mockClick } as unknown as HTMLInputElement @@ -161,10 +154,9 @@ describe('useFileUpload', () => { }) it('should do nothing when file input ref is null', () => { - const { result } = renderHook( - () => useFileUpload(defaultOptions), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload(defaultOptions), { + wrapper: createWrapper(), + }) expect(() => { act(() => { @@ -177,10 +169,9 @@ describe('useFileUpload', () => { describe('handlePreview', () => { it('should call onPreview when file has id', () => { const onPreview = vi.fn() - const { result } = renderHook( - () => useFileUpload({ ...defaultOptions, onPreview }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload({ ...defaultOptions, onPreview }), { + wrapper: createWrapper(), + }) const mockFile = { id: 'file-123', name: 'test.pdf', size: 1024 } as CustomFile @@ -193,10 +184,9 @@ describe('useFileUpload', () => { it('should not call onPreview when file has no id', () => { const onPreview = vi.fn() - const { result } = renderHook( - () => useFileUpload({ ...defaultOptions, onPreview }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload({ ...defaultOptions, onPreview }), { + wrapper: createWrapper(), + }) const mockFile = { name: 'test.pdf', size: 1024 } as CustomFile @@ -211,10 +201,9 @@ describe('useFileUpload', () => { describe('removeFile', () => { it('should call onFileListUpdate with filtered list', () => { const onFileListUpdate = vi.fn() - const { result } = renderHook( - () => useFileUpload({ ...defaultOptions, onFileListUpdate }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload({ ...defaultOptions, onFileListUpdate }), { + wrapper: createWrapper(), + }) act(() => { result.current.removeFile('file-to-remove') @@ -224,10 +213,9 @@ describe('useFileUpload', () => { }) it('should clear file input value', () => { - const { result } = renderHook( - () => useFileUpload(defaultOptions), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload(defaultOptions), { + wrapper: createWrapper(), + }) const mockInput = { value: 'some-file' } as HTMLInputElement Object.defineProperty(result.current.fileUploaderRef, 'current', { @@ -248,10 +236,9 @@ describe('useFileUpload', () => { mockUpload.mockResolvedValue({ id: 'uploaded-id' }) const prepareFileList = vi.fn() - const { result } = renderHook( - () => useFileUpload({ ...defaultOptions, prepareFileList }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload({ ...defaultOptions, prepareFileList }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { @@ -269,13 +256,14 @@ describe('useFileUpload', () => { it('should limit files to batch count', () => { const prepareFileList = vi.fn() - const { result } = renderHook( - () => useFileUpload({ ...defaultOptions, prepareFileList }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload({ ...defaultOptions, prepareFileList }), { + wrapper: createWrapper(), + }) - const files = Array.from({ length: 10 }, (_, i) => - new File(['content'], `file${i}.pdf`, { type: 'application/pdf' })) + const files = Array.from( + { length: 10 }, + (_, i) => new File(['content'], `file${i}.pdf`, { type: 'application/pdf' }), + ) const event = { target: { files }, @@ -293,10 +281,9 @@ describe('useFileUpload', () => { }) it('should reject invalid file types', () => { - const { result } = renderHook( - () => useFileUpload(defaultOptions), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload(defaultOptions), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.exe', { type: 'application/x-msdownload' }) const event = { @@ -307,19 +294,18 @@ describe('useFileUpload', () => { result.current.fileChangeHandle(event) }) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should reject files exceeding size limit', () => { - const { result } = renderHook( - () => useFileUpload(defaultOptions), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload(defaultOptions), { + wrapper: createWrapper(), + }) // Create a file larger than the limit (15MB) - const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.pdf', { type: 'application/pdf' }) + const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.pdf', { + type: 'application/pdf', + }) const event = { target: { files: [largeFile] }, @@ -329,17 +315,14 @@ describe('useFileUpload', () => { result.current.fileChangeHandle(event) }) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should handle null files', () => { const prepareFileList = vi.fn() - const { result } = renderHook( - () => useFileUpload({ ...defaultOptions, prepareFileList }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload({ ...defaultOptions, prepareFileList }), { + wrapper: createWrapper(), + }) const event = { target: { files: null }, @@ -355,11 +338,7 @@ describe('useFileUpload', () => { describe('drag and drop handlers', () => { const TestDropzone = ({ options }: { options: typeof defaultOptions }) => { - const { - dropRef, - dragRef, - dragging, - } = useFileUpload(options) + const { dropRef, dragRef, dragging } = useFileUpload(options) return ( <div> @@ -453,13 +432,17 @@ describe('useFileUpload', () => { const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) await act(async () => { - const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { + dataTransfer: DataTransfer | null + } Object.defineProperty(dropEvent, 'dataTransfer', { value: { - items: [{ - getAsFile: () => mockFile, - webkitGetAsEntry: () => null, - }], + items: [ + { + getAsFile: () => mockFile, + webkitGetAsEntry: () => null, + }, + ], }, }) dropzone.dispatchEvent(dropEvent) @@ -484,7 +467,9 @@ describe('useFileUpload', () => { const dropzone = getByTestId('dropzone') await act(async () => { - const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { + dataTransfer: DataTransfer | null + } Object.defineProperty(dropEvent, 'dataTransfer', { value: null }) dropzone.dispatchEvent(dropEvent) }) @@ -499,7 +484,9 @@ describe('useFileUpload', () => { const { getByTestId } = await act(async () => render( <> - <TestDropzone options={{ ...defaultOptions, supportBatchUpload: false, prepareFileList }} /> + <TestDropzone + options={{ ...defaultOptions, supportBatchUpload: false, prepareFileList }} + /> </>, ), ) @@ -511,10 +498,12 @@ describe('useFileUpload', () => { ] await act(async () => { - const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { + dataTransfer: DataTransfer | null + } Object.defineProperty(dropEvent, 'dataTransfer', { value: { - items: files.map(f => ({ + items: files.map((f) => ({ getAsFile: () => f, webkitGetAsEntry: () => null, })), @@ -547,17 +536,21 @@ describe('useFileUpload', () => { const dropzone = getByTestId('dropzone') await act(async () => { - const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { + dataTransfer: DataTransfer | null + } Object.defineProperty(dropEvent, 'dataTransfer', { value: { - items: [{ - getAsFile: () => mockFile, - webkitGetAsEntry: () => ({ - isFile: true, - isDirectory: false, - file: (callback: (file: File) => void) => callback(mockFile), - }), - }], + items: [ + { + getAsFile: () => mockFile, + webkitGetAsEntry: () => ({ + isFile: true, + isDirectory: false, + file: (callback: (file: File) => void) => callback(mockFile), + }), + }, + ], }, }) dropzone.dispatchEvent(dropEvent) @@ -585,34 +578,48 @@ describe('useFileUpload', () => { await act(async () => { let callCount = 0 - const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { + dataTransfer: DataTransfer | null + } Object.defineProperty(dropEvent, 'dataTransfer', { value: { - items: [{ - getAsFile: () => null, - webkitGetAsEntry: () => ({ - isFile: false, - isDirectory: true, - name: 'folder', - createReader: () => ({ - readEntries: (callback: (entries: Array<{ isFile: boolean, isDirectory: boolean, name?: string, file?: (cb: (f: File) => void) => void }>) => void) => { - // First call returns file entry, second call returns empty (signals end) - if (callCount === 0) { - callCount++ - callback([{ - isFile: true, - isDirectory: false, - name: 'nested.pdf', - file: (cb: (f: File) => void) => cb(mockFile), - }]) - } - else { - callback([]) - } - }, + items: [ + { + getAsFile: () => null, + webkitGetAsEntry: () => ({ + isFile: false, + isDirectory: true, + name: 'folder', + createReader: () => ({ + readEntries: ( + callback: ( + entries: Array<{ + isFile: boolean + isDirectory: boolean + name?: string + file?: (cb: (f: File) => void) => void + }>, + ) => void, + ) => { + // First call returns file entry, second call returns empty (signals end) + if (callCount === 0) { + callCount++ + callback([ + { + isFile: true, + isDirectory: false, + name: 'nested.pdf', + file: (cb: (f: File) => void) => cb(mockFile), + }, + ]) + } else { + callback([]) + } + }, + }), }), - }), - }], + }, + ], }, }) dropzone.dispatchEvent(dropEvent) @@ -637,29 +644,33 @@ describe('useFileUpload', () => { const dropzone = getByTestId('dropzone') await act(async () => { - const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { + dataTransfer: DataTransfer | null + } Object.defineProperty(dropEvent, 'dataTransfer', { value: { - items: [{ - getAsFile: () => null, - webkitGetAsEntry: () => ({ - isFile: false, - isDirectory: true, - name: 'empty-folder', - createReader: () => ({ - readEntries: (callback: (entries: never[]) => void) => { - callback([]) - }, + items: [ + { + getAsFile: () => null, + webkitGetAsEntry: () => ({ + isFile: false, + isDirectory: true, + name: 'empty-folder', + createReader: () => ({ + readEntries: (callback: (entries: never[]) => void) => { + callback([]) + }, + }), }), - }), - }], + }, + ], }, }) dropzone.dispatchEvent(dropEvent) }) // Should not prepare file list if no valid files - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) }) it('should handle entry that is neither file nor directory', async () => { @@ -676,23 +687,27 @@ describe('useFileUpload', () => { const dropzone = getByTestId('dropzone') await act(async () => { - const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { + dataTransfer: DataTransfer | null + } Object.defineProperty(dropEvent, 'dataTransfer', { value: { - items: [{ - getAsFile: () => null, - webkitGetAsEntry: () => ({ - isFile: false, - isDirectory: false, - }), - }], + items: [ + { + getAsFile: () => null, + webkitGetAsEntry: () => ({ + isFile: false, + isDirectory: false, + }), + }, + ], }, }) dropzone.dispatchEvent(dropEvent) }) // Should not throw and should handle gracefully - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) }) }) @@ -701,10 +716,9 @@ describe('useFileUpload', () => { mockUpload.mockResolvedValue({ id: 'uploaded-id', name: 'test.pdf' }) const onFileUpdate = vi.fn() - const { result } = renderHook( - () => useFileUpload({ ...defaultOptions, onFileUpdate }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload({ ...defaultOptions, onFileUpdate }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { @@ -730,10 +744,9 @@ describe('useFileUpload', () => { const onFileUpdate = vi.fn() - const { result } = renderHook( - () => useFileUpload({ ...defaultOptions, onFileUpdate }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload({ ...defaultOptions, onFileUpdate }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { @@ -765,10 +778,9 @@ describe('useFileUpload', () => { mockUpload.mockRejectedValue(new Error('Upload failed')) const onFileUpdate = vi.fn() - const { result } = renderHook( - () => useFileUpload({ ...defaultOptions, onFileUpdate }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload({ ...defaultOptions, onFileUpdate }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { @@ -780,9 +792,7 @@ describe('useFileUpload', () => { }) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) }) @@ -790,10 +800,9 @@ describe('useFileUpload', () => { mockUpload.mockResolvedValue({ id: 'uploaded-id', name: 'test.pdf' }) const onFileUpdate = vi.fn() - const { result } = renderHook( - () => useFileUpload({ ...defaultOptions, onFileUpdate }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload({ ...defaultOptions, onFileUpdate }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { @@ -816,10 +825,9 @@ describe('useFileUpload', () => { mockUpload.mockRejectedValue(new Error('Upload failed')) const onFileUpdate = vi.fn() - const { result } = renderHook( - () => useFileUpload({ ...defaultOptions, onFileUpdate }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload({ ...defaultOptions, onFileUpdate }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { @@ -848,15 +856,18 @@ describe('useFileUpload', () => { })) const { result } = renderHook( - () => useFileUpload({ - ...defaultOptions, - fileList: existingFiles, - }), + () => + useFileUpload({ + ...defaultOptions, + fileList: existingFiles, + }), { wrapper: createWrapper() }, ) - const files = Array.from({ length: 5 }, (_, i) => - new File(['content'], `new-${i}.pdf`, { type: 'application/pdf' })) + const files = Array.from( + { length: 5 }, + (_, i) => new File(['content'], `new-${i}.pdf`, { type: 'application/pdf' }), + ) const event = { target: { files }, @@ -866,9 +877,7 @@ describe('useFileUpload', () => { result.current.fileChangeHandle(event) }) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) }) @@ -877,10 +886,9 @@ describe('useFileUpload', () => { mockUpload.mockResolvedValue({ id: 'file-id' }) const prepareFileList = vi.fn() - const { result } = renderHook( - () => useFileUpload({ ...defaultOptions, prepareFileList }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useFileUpload({ ...defaultOptions, prepareFileList }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { diff --git a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts b/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts index 7c23f19d704fbc..8e1a4f4f11a831 100644 --- a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts +++ b/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts @@ -97,144 +97,177 @@ export const useFileUpload = ({ } return [...supportTypes] - .map(item => extensionMap[item] || item) - .map(item => item.toLowerCase()) + .map((item) => extensionMap[item] || item) + .map((item) => item.toLowerCase()) .filter((item, index, self) => self.indexOf(item) === index) - .map(item => item.toUpperCase()) + .map((item) => item.toUpperCase()) .join(locale !== LanguagesSupported[1] ? ', ' : '、 ') }, [supportTypes, locale]) const acceptTypes = useMemo(() => supportTypes.map((ext: string) => `.${ext}`), [supportTypes]) - const fileUploadConfig = useMemo(() => ({ - file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15, - batch_count_limit: supportBatchUpload ? (fileUploadConfigResponse?.batch_count_limit ?? 5) : 1, - file_upload_limit: supportBatchUpload ? (fileUploadConfigResponse?.file_upload_limit ?? 5) : 1, - }), [fileUploadConfigResponse, supportBatchUpload]) - - const isValid = useCallback((file: File) => { - const { size } = file - const ext = `.${getFileExtension(file.name)}` - const isValidType = acceptTypes.includes(ext.toLowerCase()) - if (!isValidType) - toast.error(t($ => $['stepOne.uploader.validation.typeError'], { ns: 'datasetCreation' })) - - const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024 - if (!isValidSize) - toast.error(t($ => $['stepOne.uploader.validation.size'], { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit })) - - return isValidType && isValidSize - }, [fileUploadConfig, t, acceptTypes]) - - const fileUpload = useCallback(async (fileItem: FileItem): Promise<FileItem> => { - const formData = new FormData() - formData.append('file', fileItem.file) - const onProgress = (e: ProgressEvent) => { - if (e.lengthComputable) { - const percent = Math.floor(e.loaded / e.total * 100) - onFileUpdate(fileItem, percent, fileListRef.current) - } - } + const fileUploadConfig = useMemo( + () => ({ + file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15, + batch_count_limit: supportBatchUpload + ? (fileUploadConfigResponse?.batch_count_limit ?? 5) + : 1, + file_upload_limit: supportBatchUpload + ? (fileUploadConfigResponse?.file_upload_limit ?? 5) + : 1, + }), + [fileUploadConfigResponse, supportBatchUpload], + ) - return upload({ - xhr: new XMLHttpRequest(), - data: formData, - onprogress: onProgress, - }, false, undefined, '?source=datasets') - .then((res) => { - const completeFile = { - fileID: fileItem.fileID, - file: res as unknown as File, - progress: PROGRESS_NOT_STARTED, + const isValid = useCallback( + (file: File) => { + const { size } = file + const ext = `.${getFileExtension(file.name)}` + const isValidType = acceptTypes.includes(ext.toLowerCase()) + if (!isValidType) + toast.error(t(($) => $['stepOne.uploader.validation.typeError'], { ns: 'datasetCreation' })) + + const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024 + if (!isValidSize) + toast.error( + t(($) => $['stepOne.uploader.validation.size'], { + ns: 'datasetCreation', + size: fileUploadConfig.file_size_limit, + }), + ) + + return isValidType && isValidSize + }, + [fileUploadConfig, t, acceptTypes], + ) + + const fileUpload = useCallback( + async (fileItem: FileItem): Promise<FileItem> => { + const formData = new FormData() + formData.append('file', fileItem.file) + const onProgress = (e: ProgressEvent) => { + if (e.lengthComputable) { + const percent = Math.floor((e.loaded / e.total) * 100) + onFileUpdate(fileItem, percent, fileListRef.current) } - const index = fileListRef.current.findIndex(item => item.fileID === fileItem.fileID) - fileListRef.current[index] = completeFile - onFileUpdate(completeFile, PROGRESS_COMPLETE, fileListRef.current) - return Promise.resolve({ ...completeFile }) - }) - .catch((e) => { - const errorMessage = getFileUploadErrorMessage(e, t($ => $['stepOne.uploader.failed'], { ns: 'datasetCreation' }), t) - toast.error(errorMessage) - onFileUpdate(fileItem, PROGRESS_ERROR, fileListRef.current) - return Promise.resolve({ ...fileItem }) - }) - .finally() - }, [onFileUpdate, t]) - - const uploadBatchFiles = useCallback((bFiles: FileItem[]) => { - bFiles.forEach(bf => (bf.progress = 0)) - return Promise.all(bFiles.map(fileUpload)) - }, [fileUpload]) - - const uploadMultipleFiles = useCallback(async (files: FileItem[]) => { - const batchCountLimit = fileUploadConfig.batch_count_limit - const length = files.length - let start = 0 - let end = 0 - - while (start < length) { - if (start + batchCountLimit > length) - end = length - else - end = start + batchCountLimit - const bFiles = files.slice(start, end) - await uploadBatchFiles(bFiles) - start = end - } - }, [fileUploadConfig, uploadBatchFiles]) + } - const initialUpload = useCallback((files: File[]) => { - const filesCountLimit = fileUploadConfig.file_upload_limit - if (!files.length) - return false + return upload( + { + xhr: new XMLHttpRequest(), + data: formData, + onprogress: onProgress, + }, + false, + undefined, + '?source=datasets', + ) + .then((res) => { + const completeFile = { + fileID: fileItem.fileID, + file: res as unknown as File, + progress: PROGRESS_NOT_STARTED, + } + const index = fileListRef.current.findIndex((item) => item.fileID === fileItem.fileID) + fileListRef.current[index] = completeFile + onFileUpdate(completeFile, PROGRESS_COMPLETE, fileListRef.current) + return Promise.resolve({ ...completeFile }) + }) + .catch((e) => { + const errorMessage = getFileUploadErrorMessage( + e, + t(($) => $['stepOne.uploader.failed'], { ns: 'datasetCreation' }), + t, + ) + toast.error(errorMessage) + onFileUpdate(fileItem, PROGRESS_ERROR, fileListRef.current) + return Promise.resolve({ ...fileItem }) + }) + .finally() + }, + [onFileUpdate, t], + ) - if (files.length + fileList.length > filesCountLimit && !IS_CE_EDITION) { - toast.error(t($ => $['stepOne.uploader.validation.filesNumber'], { ns: 'datasetCreation', filesNumber: filesCountLimit })) - return false - } + const uploadBatchFiles = useCallback( + (bFiles: FileItem[]) => { + bFiles.forEach((bf) => (bf.progress = 0)) + return Promise.all(bFiles.map(fileUpload)) + }, + [fileUpload], + ) + + const uploadMultipleFiles = useCallback( + async (files: FileItem[]) => { + const batchCountLimit = fileUploadConfig.batch_count_limit + const length = files.length + let start = 0 + let end = 0 + + while (start < length) { + if (start + batchCountLimit > length) end = length + else end = start + batchCountLimit + const bFiles = files.slice(start, end) + await uploadBatchFiles(bFiles) + start = end + } + }, + [fileUploadConfig, uploadBatchFiles], + ) - const preparedFiles = files.map((file, index) => ({ - fileID: `file${index}-${Date.now()}`, - file, - progress: PROGRESS_NOT_STARTED, - })) - const newFiles = [...fileListRef.current, ...preparedFiles] - prepareFileList(newFiles) - fileListRef.current = newFiles - uploadMultipleFiles(preparedFiles) - }, [prepareFileList, uploadMultipleFiles, t, fileList, fileUploadConfig]) + const initialUpload = useCallback( + (files: File[]) => { + const filesCountLimit = fileUploadConfig.file_upload_limit + if (!files.length) return false + + if (files.length + fileList.length > filesCountLimit && !IS_CE_EDITION) { + toast.error( + t(($) => $['stepOne.uploader.validation.filesNumber'], { + ns: 'datasetCreation', + filesNumber: filesCountLimit, + }), + ) + return false + } + + const preparedFiles = files.map((file, index) => ({ + fileID: `file${index}-${Date.now()}`, + file, + progress: PROGRESS_NOT_STARTED, + })) + const newFiles = [...fileListRef.current, ...preparedFiles] + prepareFileList(newFiles) + fileListRef.current = newFiles + uploadMultipleFiles(preparedFiles) + }, + [prepareFileList, uploadMultipleFiles, t, fileList, fileUploadConfig], + ) const traverseFileEntry = useCallback( (entry: FileSystemEntry, prefix = ''): Promise<FileWithPath[]> => { return new Promise((resolve) => { if (entry.isFile) { - (entry as FileSystemFileEntry).file((file: FileWithPath) => { + ;(entry as FileSystemFileEntry).file((file: FileWithPath) => { file.relativePath = `${prefix}${file.name}` resolve([file]) }) - } - else if (entry.isDirectory) { + } else if (entry.isDirectory) { const reader = (entry as FileSystemDirectoryEntry).createReader() const entries: FileSystemEntry[] = [] const read = () => { reader.readEntries(async (results: FileSystemEntry[]) => { if (!results.length) { const files = await Promise.all( - entries.map(ent => - traverseFileEntry(ent, `${prefix}${entry.name}/`), - ), + entries.map((ent) => traverseFileEntry(ent, `${prefix}${entry.name}/`)), ) resolve(files.flat()) - } - else { + } else { entries.push(...results) read() } }) } read() - } - else { + } else { resolve([]) } }) @@ -245,8 +278,7 @@ export const useFileUpload = ({ const handleDragEnter = useCallback((e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.target !== dragRef.current) - setDragging(true) + if (e.target !== dragRef.current) setDragging(true) }, []) const handleDragOver = useCallback((e: DragEvent) => { @@ -257,8 +289,7 @@ export const useFileUpload = ({ const handleDragLeave = useCallback((e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.target === dragRef.current) - setDragging(false) + if (e.target === dragRef.current) setDragging(false) }, []) const handleDrop = useCallback( @@ -266,20 +297,19 @@ export const useFileUpload = ({ e.preventDefault() e.stopPropagation() setDragging(false) - if (!e.dataTransfer) - return + if (!e.dataTransfer) return const nested = await Promise.all( Array.from(e.dataTransfer.items).map((it) => { - const entry = (it as DataTransferItem & { webkitGetAsEntry?: () => FileSystemEntry | null }).webkitGetAsEntry?.() - if (entry) - return traverseFileEntry(entry) + const entry = ( + it as DataTransferItem & { webkitGetAsEntry?: () => FileSystemEntry | null } + ).webkitGetAsEntry?.() + if (entry) return traverseFileEntry(entry) const f = it.getAsFile?.() return f ? Promise.resolve([f as FileWithPath]) : Promise.resolve([]) }), ) let files = nested.flat() - if (!supportBatchUpload) - files = files.slice(0, 1) + if (!supportBatchUpload) files = files.slice(0, 1) files = files.slice(0, fileUploadConfig.batch_count_limit) const valid = files.filter(isValid) initialUpload(valid) @@ -288,28 +318,34 @@ export const useFileUpload = ({ ) const selectHandle = useCallback(() => { - if (fileUploaderRef.current) - fileUploaderRef.current.click() + if (fileUploaderRef.current) fileUploaderRef.current.click() }, []) - const removeFile = useCallback((fileID: string) => { - if (fileUploaderRef.current) - fileUploaderRef.current.value = '' + const removeFile = useCallback( + (fileID: string) => { + if (fileUploaderRef.current) fileUploaderRef.current.value = '' - fileListRef.current = fileListRef.current.filter(item => item.fileID !== fileID) - onFileListUpdate?.([...fileListRef.current]) - }, [onFileListUpdate]) + fileListRef.current = fileListRef.current.filter((item) => item.fileID !== fileID) + onFileListUpdate?.([...fileListRef.current]) + }, + [onFileListUpdate], + ) - const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { - let files = Array.from(e.target.files ?? []) as File[] - files = files.slice(0, fileUploadConfig.batch_count_limit) - initialUpload(files.filter(isValid)) - }, [isValid, initialUpload, fileUploadConfig]) + const fileChangeHandle = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + let files = Array.from(e.target.files ?? []) as File[] + files = files.slice(0, fileUploadConfig.batch_count_limit) + initialUpload(files.filter(isValid)) + }, + [isValid, initialUpload, fileUploadConfig], + ) - const handlePreview = useCallback((file: File) => { - if (file?.id) - onPreview(file) - }, [onPreview]) + const handlePreview = useCallback( + (file: File) => { + if (file?.id) onPreview(file) + }, + [onPreview], + ) useEffect(() => { const dropArea = dropRef.current diff --git a/web/app/components/datasets/create/file-uploader/index.tsx b/web/app/components/datasets/create/file-uploader/index.tsx index 38313fc9bfba25..811fbffcff37d1 100644 --- a/web/app/components/datasets/create/file-uploader/index.tsx +++ b/web/app/components/datasets/create/file-uploader/index.tsx @@ -52,7 +52,7 @@ const FileUploader = ({ return ( <div className="mb-5 w-[640px]"> <div className={cn('mb-1 text-sm/6 font-semibold text-text-secondary', titleClassName)}> - {t($ => $['stepOne.uploader.title'], { ns: 'datasetCreation' })} + {t(($) => $['stepOne.uploader.title'], { ns: 'datasetCreation' })} </div> {!hideUpload && ( @@ -72,7 +72,7 @@ const FileUploader = ({ {fileList.length > 0 && ( <div className="max-w-[640px] cursor-default space-y-1"> - {fileList.map(fileItem => ( + {fileList.map((fileItem) => ( <FileListItem key={fileItem.fileID} fileItem={fileItem} diff --git a/web/app/components/datasets/create/index.tsx b/web/app/components/datasets/create/index.tsx index 0f1eefafa648bb..7db907f1f4bf5c 100644 --- a/web/app/components/datasets/create/index.tsx +++ b/web/app/components/datasets/create/index.tsx @@ -1,6 +1,11 @@ 'use client' import type { NotionPage } from '@/models/common' -import type { CrawlOptions, CrawlResultItem, createDocumentResponse, FileItem } from '@/models/datasets' +import type { + CrawlOptions, + CrawlResultItem, + createDocumentResponse, + FileItem, +} from '@/models/datasets' import type { RETRIEVE_METHOD } from '@/types/app' import { produce } from 'immer' import { useAtomValue } from 'jotai' @@ -13,7 +18,10 @@ import { useDefaultModel } from '@/app/components/header/account-setting/model-p import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting' import { userProfileIdAtom } from '@/context/account-state' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { workspacePermissionKeysAtom, workspacePermissionKeysLoadingAtom } from '@/context/permission-state' +import { + workspacePermissionKeysAtom, + workspacePermissionKeysLoadingAtom, +} from '@/context/permission-state' import { DataSourceProvider } from '@/models/common' import { DataSourceType } from '@/models/datasets' import { useRouter } from '@/next/navigation' @@ -44,20 +52,20 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { const { t } = useTranslation() const router = useRouter() const openIntegrationsSetting = useIntegrationsSetting() - const datasetDetail = useDatasetDetailContextWithSelector(state => state.dataset) + const datasetDetail = useDatasetDetailContextWithSelector((state) => state.dataset) const currentUserId = useAtomValue(userProfileIdAtom) const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding) - const canAddDocumentsToDataset = !datasetId || getDatasetACLCapabilities(datasetDetail?.permission_keys, { - currentUserId, - resourceMaintainer: datasetDetail?.maintainer, - workspacePermissionKeys, - }).canUse - const shouldRedirectToDocuments = !!datasetId - && !!datasetDetail - && !isLoadingWorkspacePermissionKeys - && !canAddDocumentsToDataset + const canAddDocumentsToDataset = + !datasetId || + getDatasetACLCapabilities(datasetDetail?.permission_keys, { + currentUserId, + resourceMaintainer: datasetDetail?.maintainer, + workspacePermissionKeys, + }).canUse + const shouldRedirectToDocuments = + !!datasetId && !!datasetDetail && !isLoadingWorkspacePermissionKeys && !canAddDocumentsToDataset const [dataSourceType, setDataSourceType] = useState<DataSourceType>(DataSourceType.FILE) const [step, setStep] = useState(1) @@ -69,7 +77,9 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { const [notionCredentialId, setNotionCredentialId] = useState<string>('') const [websitePages, setWebsitePages] = useState<CrawlResultItem[]>([]) const [crawlOptions, setCrawlOptions] = useState<CrawlOptions>(DEFAULT_CRAWL_OPTIONS) - const [websiteCrawlProvider, setWebsiteCrawlProvider] = useState<DataSourceProvider>(DataSourceProvider.jinaReader) + const [websiteCrawlProvider, setWebsiteCrawlProvider] = useState<DataSourceProvider>( + DataSourceProvider.jinaReader, + ) const [websiteCrawlJobId, setWebsiteCrawlJobId] = useState('') const { @@ -91,7 +101,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { }, []) const updateFile = useCallback((fileItem: FileItem, progress: number, list: FileItem[]) => { - const targetIndex = list.findIndex(file => file.fileID === fileItem.fileID) + const targetIndex = list.findIndex((file) => file.fileID === fileItem.fileID) const newList = produce(list, (draft) => { draft[targetIndex] = { ...draft[targetIndex]!, @@ -117,90 +127,97 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { setStep(step + 1) }, [step, setStep]) - const changeStep = useCallback((delta: number) => { - setStep(step + delta) - }, [step, setStep]) + const changeStep = useCallback( + (delta: number) => { + setStep(step + delta) + }, + [step, setStep], + ) useEffect(() => { - if (shouldRedirectToDocuments && datasetId) - router.replace(`/datasets/${datasetId}/documents`) + if (shouldRedirectToDocuments && datasetId) router.replace(`/datasets/${datasetId}/documents`) }, [datasetId, router, shouldRedirectToDocuments]) if ((!!datasetId && isLoadingWorkspacePermissionKeys) || shouldRedirectToDocuments) return <Loading type="app" /> if (fetchingAuthedDataSourceListError) - return <AppUnavailable code={500} unknownReason={t($ => $['error.unavailable'], { ns: 'datasetCreation' }) as string} /> + return ( + <AppUnavailable + code={500} + unknownReason={t(($) => $['error.unavailable'], { ns: 'datasetCreation' }) as string} + /> + ) return ( <div className="flex h-full min-h-0 flex-col overflow-hidden bg-components-panel-bg"> <TopBar activeIndex={step - 1} datasetId={datasetId} /> <div className="min-h-0 flex-1"> - { - isLoadingAuthedDataSourceList && ( - <Loading type="app" /> - ) - } - { - !isLoadingAuthedDataSourceList && ( - <> - {step === 1 && ( - <StepOne - authedDataSourceList={dataSourceList?.result || []} - onSetting={() => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })} - datasetId={datasetId} - dataSourceType={dataSourceType} - dataSourceTypeDisable={!!datasetDetail?.data_source_type} - changeType={setDataSourceType} - files={fileList} - updateFile={updateFile} - updateFileList={updateFileList} - notionPages={notionPages} - notionCredentialId={notionCredentialId} - updateNotionPages={updateNotionPages} - updateNotionCredentialId={updateNotionCredentialId} - onStepChange={nextStep} - websitePages={websitePages} - updateWebsitePages={setWebsitePages} - onWebsiteCrawlProviderChange={setWebsiteCrawlProvider} - onWebsiteCrawlJobIdChange={setWebsiteCrawlJobId} - crawlOptions={crawlOptions} - onCrawlOptionsChange={setCrawlOptions} - /> - )} - {(step === 2 && (!datasetId || (datasetId && !!datasetDetail))) && ( - <StepTwo - isAPIKeySet={!!embeddingsDefaultModel} - onSetting={() => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })} - indexingType={datasetDetail?.indexing_technique} - datasetId={datasetId} - dataSourceType={dataSourceType} - files={fileList.map(file => file.file)} - notionPages={notionPages} - notionCredentialId={notionCredentialId} - websitePages={websitePages} - websiteCrawlProvider={websiteCrawlProvider} - websiteCrawlJobId={websiteCrawlJobId} - onStepChange={changeStep} - canCreateDocument={canAddDocumentsToDataset} - updateIndexingTypeCache={updateIndexingTypeCache} - updateRetrievalMethodCache={updateRetrievalMethodCache} - updateResultCache={updateResultCache} - crawlOptions={crawlOptions} - /> - )} - {step === 3 && ( - <StepThree - datasetId={datasetId} - datasetName={datasetDetail?.name} - indexingType={datasetDetail?.indexing_technique || indexingTypeCache} - retrievalMethod={datasetDetail?.retrieval_model_dict?.search_method || retrievalMethodCache || undefined} - creationCache={result} - /> - )} - </> - ) - } + {isLoadingAuthedDataSourceList && <Loading type="app" />} + {!isLoadingAuthedDataSourceList && ( + <> + {step === 1 && ( + <StepOne + authedDataSourceList={dataSourceList?.result || []} + onSetting={() => + openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) + } + datasetId={datasetId} + dataSourceType={dataSourceType} + dataSourceTypeDisable={!!datasetDetail?.data_source_type} + changeType={setDataSourceType} + files={fileList} + updateFile={updateFile} + updateFileList={updateFileList} + notionPages={notionPages} + notionCredentialId={notionCredentialId} + updateNotionPages={updateNotionPages} + updateNotionCredentialId={updateNotionCredentialId} + onStepChange={nextStep} + websitePages={websitePages} + updateWebsitePages={setWebsitePages} + onWebsiteCrawlProviderChange={setWebsiteCrawlProvider} + onWebsiteCrawlJobIdChange={setWebsiteCrawlJobId} + crawlOptions={crawlOptions} + onCrawlOptionsChange={setCrawlOptions} + /> + )} + {step === 2 && (!datasetId || (datasetId && !!datasetDetail)) && ( + <StepTwo + isAPIKeySet={!!embeddingsDefaultModel} + onSetting={() => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })} + indexingType={datasetDetail?.indexing_technique} + datasetId={datasetId} + dataSourceType={dataSourceType} + files={fileList.map((file) => file.file)} + notionPages={notionPages} + notionCredentialId={notionCredentialId} + websitePages={websitePages} + websiteCrawlProvider={websiteCrawlProvider} + websiteCrawlJobId={websiteCrawlJobId} + onStepChange={changeStep} + canCreateDocument={canAddDocumentsToDataset} + updateIndexingTypeCache={updateIndexingTypeCache} + updateRetrievalMethodCache={updateRetrievalMethodCache} + updateResultCache={updateResultCache} + crawlOptions={crawlOptions} + /> + )} + {step === 3 && ( + <StepThree + datasetId={datasetId} + datasetName={datasetDetail?.name} + indexingType={datasetDetail?.indexing_technique || indexingTypeCache} + retrievalMethod={ + datasetDetail?.retrieval_model_dict?.search_method || + retrievalMethodCache || + undefined + } + creationCache={result} + /> + )} + </> + )} </div> </div> ) diff --git a/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx b/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx index d69a3b55131644..5abd804415b4b6 100644 --- a/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx @@ -9,7 +9,9 @@ vi.mock('@/service/datasets', () => ({ fetchNotionPagePreview: vi.fn(), })) -const mockFetchNotionPagePreview = fetchNotionPagePreview as MockedFunction<typeof fetchNotionPagePreview> +const mockFetchNotionPagePreview = fetchNotionPagePreview as MockedFunction< + typeof fetchNotionPagePreview +> // Factory function to create mock NotionPage objects const createMockNotionPage = (overrides: Partial<NotionPage> = {}): NotionPage => { @@ -26,7 +28,10 @@ const createMockNotionPage = (overrides: Partial<NotionPage> = {}): NotionPage = } // Factory function to create NotionPage with emoji icon -const createMockNotionPageWithEmojiIcon = (emoji: string, overrides: Partial<NotionPage> = {}): NotionPage => { +const createMockNotionPageWithEmojiIcon = ( + emoji: string, + overrides: Partial<NotionPage> = {}, +): NotionPage => { return createMockNotionPage({ page_icon: { type: 'emoji', @@ -38,7 +43,10 @@ const createMockNotionPageWithEmojiIcon = (emoji: string, overrides: Partial<Not } // Factory function to create NotionPage with URL icon -const createMockNotionPageWithUrlIcon = (url: string, overrides: Partial<NotionPage> = {}): NotionPage => { +const createMockNotionPageWithUrlIcon = ( + url: string, + overrides: Partial<NotionPage> = {}, +): NotionPage => { return createMockNotionPage({ page_icon: { type: 'url', @@ -99,7 +107,7 @@ describe('NotionPagePreview', () => { afterEach(async () => { // Wait for any pending state updates to complete await act(async () => { - await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) }) }) @@ -188,7 +196,7 @@ describe('NotionPagePreview', () => { it('should show loading indicator initially', async () => { // Arrange - Delay API response to keep loading state mockFetchNotionPagePreview.mockImplementation( - () => new Promise(resolve => setTimeout(() => resolve({ content: 'test' }), 100)), + () => new Promise((resolve) => setTimeout(() => resolve({ content: 'test' }), 100)), ) // Act - Don't wait for content to load @@ -218,12 +226,26 @@ describe('NotionPagePreview', () => { let resolveSecond: (value: { content: string }) => void mockFetchNotionPagePreview - .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) - .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve + }), + ) // Act - Initial render const { rerender, container } = render( - <NotionPagePreview currentPage={page1} notionCredentialId="cred-123" hidePreview={vi.fn()} />, + <NotionPagePreview + currentPage={page1} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, ) // First page loading - spinner should be visible @@ -239,7 +261,13 @@ describe('NotionPagePreview', () => { }) // Rerender with new page - rerender(<NotionPagePreview currentPage={page2} notionCredentialId="cred-123" hidePreview={vi.fn()} />) + rerender( + <NotionPagePreview + currentPage={page2} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, + ) // Should show loading again await waitFor(() => { @@ -288,7 +316,11 @@ describe('NotionPagePreview', () => { const page2 = createMockNotionPage({ page_id: 'page-2' }) const { rerender } = render( - <NotionPagePreview currentPage={page1} notionCredentialId="cred-123" hidePreview={vi.fn()} />, + <NotionPagePreview + currentPage={page1} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, ) await waitFor(() => { @@ -300,7 +332,13 @@ describe('NotionPagePreview', () => { }) await act(async () => { - rerender(<NotionPagePreview currentPage={page2} notionCredentialId="cred-123" hidePreview={vi.fn()} />) + rerender( + <NotionPagePreview + currentPage={page2} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, + ) }) await waitFor(() => { @@ -314,7 +352,9 @@ describe('NotionPagePreview', () => { }) it('should handle API success and display content', async () => { - mockFetchNotionPagePreview.mockResolvedValue({ content: 'Notion page preview content from API' }) + mockFetchNotionPagePreview.mockResolvedValue({ + content: 'Notion page preview content from API', + }) await renderNotionPagePreview() @@ -371,7 +411,12 @@ describe('NotionPagePreview', () => { describe('State Management', () => { it('should initialize with loading state true', async () => { // Arrange - Keep loading indefinitely (never resolves) - mockFetchNotionPagePreview.mockImplementation(() => new Promise(() => { /* intentionally empty */ })) + mockFetchNotionPagePreview.mockImplementation( + () => + new Promise(() => { + /* intentionally empty */ + }), + ) // Act - Don't wait for content const { container } = await renderNotionPagePreview({}, false) @@ -394,10 +439,19 @@ describe('NotionPagePreview', () => { mockFetchNotionPagePreview .mockResolvedValueOnce({ content: 'Content 1' }) - .mockImplementationOnce(() => new Promise(() => { /* never resolves */ })) + .mockImplementationOnce( + () => + new Promise(() => { + /* never resolves */ + }), + ) const { rerender, container } = render( - <NotionPagePreview currentPage={page1} notionCredentialId="cred-123" hidePreview={vi.fn()} />, + <NotionPagePreview + currentPage={page1} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, ) await waitFor(() => { @@ -406,7 +460,13 @@ describe('NotionPagePreview', () => { // Change page await act(async () => { - rerender(<NotionPagePreview currentPage={page2} notionCredentialId="cred-123" hidePreview={vi.fn()} />) + rerender( + <NotionPagePreview + currentPage={page2} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, + ) }) // Assert - Loading should be shown again @@ -424,10 +484,19 @@ describe('NotionPagePreview', () => { mockFetchNotionPagePreview .mockResolvedValueOnce({ content: 'Content 1' }) - .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve + }), + ) const { rerender } = render( - <NotionPagePreview currentPage={page1} notionCredentialId="cred-123" hidePreview={vi.fn()} />, + <NotionPagePreview + currentPage={page1} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, ) await waitFor(() => { @@ -436,7 +505,13 @@ describe('NotionPagePreview', () => { // Change page await act(async () => { - rerender(<NotionPagePreview currentPage={page2} notionCredentialId="cred-123" hidePreview={vi.fn()} />) + rerender( + <NotionPagePreview + currentPage={page2} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, + ) }) // Resolve second fetch @@ -620,7 +695,11 @@ describe('NotionPagePreview', () => { const page2 = createMockNotionPage({ page_id: 'page-2' }) const { rerender } = render( - <NotionPagePreview currentPage={page1} notionCredentialId="cred-123" hidePreview={vi.fn()} />, + <NotionPagePreview + currentPage={page1} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, ) await waitFor(() => { @@ -628,7 +707,13 @@ describe('NotionPagePreview', () => { }) await act(async () => { - rerender(<NotionPagePreview currentPage={page2} notionCredentialId="cred-123" hidePreview={vi.fn()} />) + rerender( + <NotionPagePreview + currentPage={page2} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, + ) }) await waitFor(() => { @@ -642,7 +727,11 @@ describe('NotionPagePreview', () => { const hidePreview2 = vi.fn() const { rerender } = render( - <NotionPagePreview currentPage={page} notionCredentialId="cred-123" hidePreview={hidePreview1} />, + <NotionPagePreview + currentPage={page} + notionCredentialId="cred-123" + hidePreview={hidePreview1} + />, ) await waitFor(() => { @@ -650,7 +739,13 @@ describe('NotionPagePreview', () => { }) await act(async () => { - rerender(<NotionPagePreview currentPage={page} notionCredentialId="cred-123" hidePreview={hidePreview2} />) + rerender( + <NotionPagePreview + currentPage={page} + notionCredentialId="cred-123" + hidePreview={hidePreview2} + />, + ) }) // Assert - Should not call API again (currentPage didn't change by reference) @@ -670,7 +765,13 @@ describe('NotionPagePreview', () => { }) await act(async () => { - rerender(<NotionPagePreview currentPage={page} notionCredentialId="cred-2" hidePreview={vi.fn()} />) + rerender( + <NotionPagePreview + currentPage={page} + notionCredentialId="cred-2" + hidePreview={vi.fn()} + />, + ) }) // Assert - Should not call API again (only currentPage is in dependency array) @@ -679,16 +780,27 @@ describe('NotionPagePreview', () => { it('should handle rapid page changes', async () => { const pages = Array.from({ length: 5 }, (_, i) => - createMockNotionPage({ page_id: `page-${i}` })) + createMockNotionPage({ page_id: `page-${i}` }), + ) const { rerender } = render( - <NotionPagePreview currentPage={pages[0]} notionCredentialId="cred-123" hidePreview={vi.fn()} />, + <NotionPagePreview + currentPage={pages[0]} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, ) // Rapidly change pages for (let i = 1; i < pages.length; i++) { await act(async () => { - rerender(<NotionPagePreview currentPage={pages[i]} notionCredentialId="cred-123" hidePreview={vi.fn()} />) + rerender( + <NotionPagePreview + currentPage={pages[i]} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, + ) }) } @@ -700,7 +812,7 @@ describe('NotionPagePreview', () => { it('should handle unmount during loading', async () => { mockFetchNotionPagePreview.mockImplementation( - () => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)), + () => new Promise((resolve) => setTimeout(() => resolve({ content: 'delayed' }), 1000)), ) // Act - Don't wait for content @@ -717,7 +829,11 @@ describe('NotionPagePreview', () => { const page = createMockNotionPage() const { rerender, container } = render( - <NotionPagePreview currentPage={page} notionCredentialId="cred-123" hidePreview={vi.fn()} />, + <NotionPagePreview + currentPage={page} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, ) await waitFor(() => { @@ -725,7 +841,13 @@ describe('NotionPagePreview', () => { }) await act(async () => { - rerender(<NotionPagePreview currentPage={undefined} notionCredentialId="cred-123" hidePreview={vi.fn()} />) + rerender( + <NotionPagePreview + currentPage={undefined} + notionCredentialId="cred-123" + hidePreview={vi.fn()} + />, + ) }) // Assert - Should not crash, API should not be called again @@ -908,8 +1030,7 @@ describe('NotionPagePreview', () => { expect(container.firstChild).toBeInTheDocument() // NotionIcon renders img when type is 'url' const img = container.querySelector('img[alt="page icon"]') - if (img) - expect(img).toBeInTheDocument() + if (img) expect(img).toBeInTheDocument() // Restore console.error consoleErrorSpy.mockRestore() diff --git a/web/app/components/datasets/create/notion-page-preview/index.module.css b/web/app/components/datasets/create/notion-page-preview/index.module.css index 642594b24377ea..cc6312bb8938ca 100644 --- a/web/app/components/datasets/create/notion-page-preview/index.module.css +++ b/web/app/components/datasets/create/notion-page-preview/index.module.css @@ -1,17 +1,17 @@ @reference "../../../../styles/globals.css"; .filePreview { - @apply flex flex-col border-l border-components-panel-border shrink-0 bg-background-default-lighter; + @apply flex shrink-0 flex-col border-l border-components-panel-border bg-background-default-lighter; } .previewHeader { - @apply border-b border-divider-subtle shrink-0; + @apply shrink-0 border-b border-divider-subtle; margin: 42px 32px 0; padding-bottom: 16px; } .previewHeader .title { - @apply flex justify-between items-center text-text-primary; + @apply flex items-center justify-between text-text-primary; } .previewHeader .fileName { @@ -19,7 +19,7 @@ } .previewContent { - @apply overflow-y-auto grow text-text-secondary; + @apply grow overflow-y-auto text-text-secondary; padding: 20px 32px; } diff --git a/web/app/components/datasets/create/notion-page-preview/index.tsx b/web/app/components/datasets/create/notion-page-preview/index.tsx index 3721530f7c39e2..41384f527a7057 100644 --- a/web/app/components/datasets/create/notion-page-preview/index.tsx +++ b/web/app/components/datasets/create/notion-page-preview/index.tsx @@ -16,18 +16,13 @@ type IProps = { hidePreview: () => void } -const NotionPagePreview = ({ - currentPage, - notionCredentialId, - hidePreview, -}: IProps) => { +const NotionPagePreview = ({ currentPage, notionCredentialId, hidePreview }: IProps) => { const { t } = useTranslation() const [previewContent, setPreviewContent] = useState('') const [loading, setLoading] = useState(true) const getPreviewContent = async () => { - if (!currentPage) - return + if (!currentPage) return try { const res = await fetchNotionPagePreview({ pageID: currentPage.page_id, @@ -36,8 +31,7 @@ const NotionPagePreview = ({ }) setPreviewContent(res.content) setLoading(false) - } - catch { } + } catch {} } useEffect(() => { @@ -51,30 +45,24 @@ const NotionPagePreview = ({ <div className={cn(s.filePreview, 'h-full')}> <div className={cn(s.previewHeader)}> <div className={cn(s.title, 'title-md-semi-bold')}> - <span>{t($ => $['stepOne.pagePreview'], { ns: 'datasetCreation' })}</span> + <span>{t(($) => $['stepOne.pagePreview'], { ns: 'datasetCreation' })}</span> <button type="button" className="flex size-6 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={hidePreview} > <XMarkIcon className="size-4" aria-hidden="true"></XMarkIcon> </button> </div> <div className={cn(s.fileName, 'system-xs-medium')}> - <NotionIcon - className="mr-1 shrink-0" - type="page" - src={currentPage?.page_icon} - /> + <NotionIcon className="mr-1 shrink-0" type="page" src={currentPage?.page_icon} /> {currentPage?.page_name} </div> </div> <div className={cn(s.previewContent, 'body-md-regular')}> {loading && <Loading type="area" />} - {!loading && ( - <div className={cn(s.fileContent, 'body-md-regular')}>{previewContent}</div> - )} + {!loading && <div className={cn(s.fileContent, 'body-md-regular')}>{previewContent}</div>} </div> </div> ) diff --git a/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx index 86c1f3ea626325..ad9c8ba46b1ff2 100644 --- a/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx @@ -17,7 +17,9 @@ vi.mock('@/config', () => ({ // Mock dataset detail context let mockDatasetDetail: DataSet | undefined vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | undefined }) => DataSet | undefined) => { + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: DataSet | undefined }) => DataSet | undefined, + ) => { return selector({ dataset: mockDatasetDetail }) }, })) @@ -48,7 +50,7 @@ vi.mock('@/service/use-billing', () => ({ })) vi.mock('../../file-uploader', () => ({ - default: ({ onPreview, fileList }: { onPreview: (file: File) => void, fileList: FileItem[] }) => ( + default: ({ onPreview, fileList }: { onPreview: (file: File) => void; fileList: FileItem[] }) => ( <div data-testid="file-uploader"> <span data-testid="file-count">{fileList.length}</span> <button data-testid="preview-file" onClick={() => onPreview(new File(['test'], 'test.txt'))}> @@ -63,7 +65,14 @@ vi.mock('../../website', () => ({ <div data-testid="website"> <button data-testid="preview-website" - onClick={() => onPreview({ title: 'Test', markdown: '', description: '', source_url: 'https://test.com' })} + onClick={() => + onPreview({ + title: 'Test', + markdown: '', + description: '', + source_url: 'https://test.com', + }) + } > Preview Website </button> @@ -72,15 +81,14 @@ vi.mock('../../website', () => ({ })) vi.mock('../../empty-dataset-creation-modal', () => ({ - default: ({ show, onHide }: { show: boolean, onHide: () => void }) => ( - show - ? ( - <div data-testid="empty-dataset-modal"> - <button data-testid="close-modal" onClick={onHide}>Close</button> - </div> - ) - : null - ), + default: ({ show, onHide }: { show: boolean; onHide: () => void }) => + show ? ( + <div data-testid="empty-dataset-modal"> + <button data-testid="close-modal" onClick={onHide}> + Close + </button> + </div> + ) : null, })) // NotionConnector is a base component - imported directly without mock @@ -104,19 +112,23 @@ vi.mock('@/app/components/billing/vector-space-full', () => ({ })) vi.mock('../../file-preview', () => ({ - default: ({ file, hidePreview }: { file: File, hidePreview: () => void }) => ( + default: ({ file, hidePreview }: { file: File; hidePreview: () => void }) => ( <div data-testid="file-preview"> <span>{file.name}</span> - <button data-testid="hide-file-preview" onClick={hidePreview}>Hide</button> + <button data-testid="hide-file-preview" onClick={hidePreview}> + Hide + </button> </div> ), })) vi.mock('../../notion-page-preview', () => ({ - default: ({ currentPage, hidePreview }: { currentPage: NotionPage, hidePreview: () => void }) => ( + default: ({ currentPage, hidePreview }: { currentPage: NotionPage; hidePreview: () => void }) => ( <div data-testid="notion-page-preview"> <span>{currentPage.page_id}</span> - <button data-testid="hide-notion-preview" onClick={hidePreview}>Hide</button> + <button data-testid="hide-notion-preview" onClick={hidePreview}> + Hide + </button> </div> ), })) @@ -128,7 +140,7 @@ vi.mock('../upgrade-card', () => ({ default: () => <div data-testid="upgrade-card">Upgrade Card</div>, })) -const createMockCustomFile = (overrides: { id?: string, name?: string } = {}) => { +const createMockCustomFile = (overrides: { id?: string; name?: string } = {}) => { const file = new File(['test content'], overrides.name ?? 'test.txt', { type: 'text/plain' }) return Object.assign(file, { id: overrides.id ?? 'uploaded-id', @@ -141,16 +153,17 @@ const createMockCustomFile = (overrides: { id?: string, name?: string } = {}) => const createMockFileItem = (overrides: Partial<FileItem> = {}): FileItem => ({ fileID: `file-${Date.now()}`, - file: createMockCustomFile(overrides.file as { id?: string, name?: string }), + file: createMockCustomFile(overrides.file as { id?: string; name?: string }), progress: 100, ...overrides, }) -const createMockNotionPage = (overrides: Partial<NotionPage> = {}): NotionPage => ({ - page_id: `page-${Date.now()}`, - type: 'page', - ...overrides, -} as NotionPage) +const createMockNotionPage = (overrides: Partial<NotionPage> = {}): NotionPage => + ({ + page_id: `page-${Date.now()}`, + type: 'page', + ...overrides, + }) as NotionPage const createMockCrawlResult = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({ title: 'Test Page', @@ -160,13 +173,14 @@ const createMockCrawlResult = (overrides: Partial<CrawlResultItem> = {}): CrawlR ...overrides, }) -const createMockDataSourceAuth = (overrides: Partial<DataSourceAuth> = {}): DataSourceAuth => ({ - credential_id: 'cred-1', - provider: 'notion_datasource', - plugin_id: 'plugin-1', - credentials_list: [{ id: 'cred-1', name: 'Workspace 1' }], - ...overrides, -} as DataSourceAuth) +const createMockDataSourceAuth = (overrides: Partial<DataSourceAuth> = {}): DataSourceAuth => + ({ + credential_id: 'cred-1', + provider: 'notion_datasource', + plugin_id: 'plugin-1', + credentials_list: [{ id: 'cred-1', name: 'Workspace 1' }], + ...overrides, + }) as DataSourceAuth const defaultProps = { dataSourceType: DataSourceType.FILE, @@ -243,13 +257,21 @@ describe('StepOne', () => { // Assert - NotionConnector shows sync title and connect button expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i }), + ).toBeInTheDocument() }) it('should render NotionPageSelector when dataSourceType is NOTION and authenticated', () => { const authedDataSourceList = [createMockDataSourceAuth()] - render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} authedDataSourceList={authedDataSourceList} />) + render( + <StepOne + {...defaultProps} + dataSourceType={DataSourceType.NOTION} + authedDataSourceList={authedDataSourceList} + />, + ) expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument() }) @@ -269,7 +291,9 @@ describe('StepOne', () => { it('should not render empty dataset creation link when datasetId exists', () => { render(<StepOne {...defaultProps} datasetId="dataset-123" />) - expect(screen.queryByText('datasetCreation.stepOne.emptyDatasetCreation')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetCreation.stepOne.emptyDatasetCreation'), + ).not.toBeInTheDocument() }) }) @@ -285,7 +309,9 @@ describe('StepOne', () => { it('should call onSetting when NotionConnector connect button is clicked', () => { const onSetting = vi.fn() - render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} onSetting={onSetting} />) + render( + <StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} onSetting={onSetting} />, + ) // Act - The NotionConnector's button calls onSetting fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i })) @@ -325,13 +351,21 @@ describe('StepOne', () => { describe('Memoization', () => { it('should correctly compute isNotionAuthed based on authedDataSourceList', () => { // Arrange - No auth - const { rerender } = render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} />) + const { rerender } = render( + <StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} />, + ) // NotionConnector shows the sync title when not authenticated expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() // Act - Add auth const authedDataSourceList = [createMockDataSourceAuth()] - rerender(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} authedDataSourceList={authedDataSourceList} />) + rerender( + <StepOne + {...defaultProps} + dataSourceType={DataSourceType.NOTION} + authedDataSourceList={authedDataSourceList} + />, + ) expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument() }) @@ -349,7 +383,9 @@ describe('StepOne', () => { render(<StepOne {...defaultProps} files={files} />) // Assert - Button should be enabled - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).not.toBeDisabled() }) it('should correctly compute fileNextDisabled when some files are not uploaded', () => { @@ -447,7 +483,13 @@ describe('StepOne', () => { it('should show notion page preview when preview button is clicked', () => { const authedDataSourceList = [createMockDataSourceAuth()] - render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} authedDataSourceList={authedDataSourceList} />) + render( + <StepOne + {...defaultProps} + dataSourceType={DataSourceType.NOTION} + authedDataSourceList={authedDataSourceList} + />, + ) fireEvent.click(screen.getByTestId('preview-notion')) @@ -468,7 +510,14 @@ describe('StepOne', () => { it('should handle empty notionPages array', () => { const authedDataSourceList = [createMockDataSourceAuth()] - render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} notionPages={[]} authedDataSourceList={authedDataSourceList} />) + render( + <StepOne + {...defaultProps} + dataSourceType={DataSourceType.NOTION} + notionPages={[]} + authedDataSourceList={authedDataSourceList} + />, + ) // Assert - Button should be disabled when no pages selected expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() @@ -482,7 +531,13 @@ describe('StepOne', () => { }) it('should handle empty authedDataSourceList', () => { - render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} authedDataSourceList={[]} />) + render( + <StepOne + {...defaultProps} + dataSourceType={DataSourceType.NOTION} + authedDataSourceList={[]} + />, + ) // Assert - Should show NotionConnector with connect button expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() @@ -491,7 +546,13 @@ describe('StepOne', () => { it('should handle authedDataSourceList without notion credentials', () => { const authedDataSourceList = [createMockDataSourceAuth({ credentials_list: [] })] - render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} authedDataSourceList={authedDataSourceList} />) + render( + <StepOne + {...defaultProps} + dataSourceType={DataSourceType.NOTION} + authedDataSourceList={authedDataSourceList} + />, + ) // Assert - Should show NotionConnector with connect button expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() diff --git a/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx b/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx index 774a33256a8e03..6e852dd61dbbe3 100644 --- a/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx +++ b/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx @@ -19,7 +19,7 @@ vi.mock('@/context/modal-context', () => ({ })) vi.mock('@/app/components/billing/upgrade-btn', () => ({ - default: ({ onClick, className }: { onClick?: () => void, className?: string }) => ( + default: ({ onClick, className }: { onClick?: () => void; className?: string }) => ( <button type="button" className={className} onClick={onClick} data-testid="upgrade-btn"> upgrade </button> diff --git a/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx index a807412008b05b..8e489ea99d1c2d 100644 --- a/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx +++ b/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx @@ -5,28 +5,40 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // Mock child components - paths must match source file's imports (relative to source) vi.mock('../../../file-preview', () => ({ - default: ({ file, hidePreview }: { file: { name: string }, hidePreview: () => void }) => ( + default: ({ file, hidePreview }: { file: { name: string }; hidePreview: () => void }) => ( <div data-testid="file-preview"> <span>{file.name}</span> - <button data-testid="close-file" onClick={hidePreview}>close-file</button> + <button data-testid="close-file" onClick={hidePreview}> + close-file + </button> </div> ), })) vi.mock('../../../notion-page-preview', () => ({ - default: ({ currentPage, hidePreview }: { currentPage: { page_name: string }, hidePreview: () => void }) => ( + default: ({ + currentPage, + hidePreview, + }: { + currentPage: { page_name: string } + hidePreview: () => void + }) => ( <div data-testid="notion-preview"> <span>{currentPage.page_name}</span> - <button data-testid="close-notion" onClick={hidePreview}>close-notion</button> + <button data-testid="close-notion" onClick={hidePreview}> + close-notion + </button> </div> ), })) vi.mock('../../../website/preview', () => ({ - default: ({ payload, hidePreview }: { payload: { title: string }, hidePreview: () => void }) => ( + default: ({ payload, hidePreview }: { payload: { title: string }; hidePreview: () => void }) => ( <div data-testid="website-preview"> <span>{payload.title}</span> - <button data-testid="close-website" onClick={hidePreview}>close-website</button> + <button data-testid="close-website" onClick={hidePreview}> + close-website + </button> </div> ), })) @@ -57,19 +69,31 @@ describe('PreviewPanel', () => { }) it('should render file preview when currentFile is set', () => { - render(<PreviewPanel {...defaultProps} currentFile={{ name: 'test.pdf' } as unknown as File} />) + render( + <PreviewPanel {...defaultProps} currentFile={{ name: 'test.pdf' } as unknown as File} />, + ) expect(screen.getByTestId('file-preview')).toBeInTheDocument() expect(screen.getByText('test.pdf')).toBeInTheDocument() }) it('should render notion preview when currentNotionPage is set', () => { - render(<PreviewPanel {...defaultProps} currentNotionPage={{ page_name: 'My Page' } as unknown as NotionPage} />) + render( + <PreviewPanel + {...defaultProps} + currentNotionPage={{ page_name: 'My Page' } as unknown as NotionPage} + />, + ) expect(screen.getByTestId('notion-preview')).toBeInTheDocument() expect(screen.getByText('My Page')).toBeInTheDocument() }) it('should render website preview when currentWebsite is set', () => { - render(<PreviewPanel {...defaultProps} currentWebsite={{ title: 'My Site' } as unknown as CrawlResultItem} />) + render( + <PreviewPanel + {...defaultProps} + currentWebsite={{ title: 'My Site' } as unknown as CrawlResultItem} + />, + ) expect(screen.getByTestId('website-preview')).toBeInTheDocument() expect(screen.getByText('My Site')).toBeInTheDocument() }) @@ -82,7 +106,9 @@ describe('PreviewPanel', () => { describe('interactions', () => { it('should call hideFilePreview when file preview close clicked', () => { - render(<PreviewPanel {...defaultProps} currentFile={{ name: 'test.pdf' } as unknown as File} />) + render( + <PreviewPanel {...defaultProps} currentFile={{ name: 'test.pdf' } as unknown as File} />, + ) fireEvent.click(screen.getByTestId('close-file')) expect(defaultProps.hideFilePreview).toHaveBeenCalledOnce() }) @@ -94,13 +120,23 @@ describe('PreviewPanel', () => { }) it('should call hideNotionPagePreview when notion preview close clicked', () => { - render(<PreviewPanel {...defaultProps} currentNotionPage={{ page_name: 'My Page' } as unknown as NotionPage} />) + render( + <PreviewPanel + {...defaultProps} + currentNotionPage={{ page_name: 'My Page' } as unknown as NotionPage} + />, + ) fireEvent.click(screen.getByTestId('close-notion')) expect(defaultProps.hideNotionPagePreview).toHaveBeenCalledOnce() }) it('should call hideWebsitePreview when website preview close clicked', () => { - render(<PreviewPanel {...defaultProps} currentWebsite={{ title: 'My Site' } as unknown as CrawlResultItem} />) + render( + <PreviewPanel + {...defaultProps} + currentWebsite={{ title: 'My Site' } as unknown as CrawlResultItem} + />, + ) fireEvent.click(screen.getByTestId('close-website')) expect(defaultProps.hideWebsitePreview).toHaveBeenCalledOnce() }) diff --git a/web/app/components/datasets/create/step-one/components/data-source-type-selector.tsx b/web/app/components/datasets/create/step-one/components/data-source-type-selector.tsx index 96378859ee600b..29cc6c5d5bb000 100644 --- a/web/app/components/datasets/create/step-one/components/data-source-type-selector.tsx +++ b/web/app/components/datasets/create/step-one/components/data-source-type-selector.tsx @@ -3,7 +3,11 @@ import { cn } from '@langgenius/dify-ui/cn' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config' +import { + ENABLE_WEBSITE_FIRECRAWL, + ENABLE_WEBSITE_JINAREADER, + ENABLE_WEBSITE_WATERCRAWL, +} from '@/config' import { DataSourceType } from '@/models/datasets' import s from '../index.module.css' @@ -14,10 +18,10 @@ type DataSourceTypeSelectorProps = { onClearPreviews: (type: DataSourceType) => void } -type DataSourceLabelKey - = | 'stepOne.dataSourceType.file' - | 'stepOne.dataSourceType.notion' - | 'stepOne.dataSourceType.web' +type DataSourceLabelKey = + | 'stepOne.dataSourceType.file' + | 'stepOne.dataSourceType.notion' + | 'stepOne.dataSourceType.web' type DataSourceOption = { type: DataSourceType @@ -53,24 +57,30 @@ function DataSourceTypeSelector({ }: DataSourceTypeSelectorProps) { const { t } = useTranslation() - const isWebEnabled = ENABLE_WEBSITE_FIRECRAWL || ENABLE_WEBSITE_JINAREADER || ENABLE_WEBSITE_WATERCRAWL + const isWebEnabled = + ENABLE_WEBSITE_FIRECRAWL || ENABLE_WEBSITE_JINAREADER || ENABLE_WEBSITE_WATERCRAWL - const handleTypeChange = useCallback((type: DataSourceType) => { - if (disabled) - return - onChange(type) - onClearPreviews(type) - }, [disabled, onChange, onClearPreviews]) + const handleTypeChange = useCallback( + (type: DataSourceType) => { + if (disabled) return + onChange(type) + onClearPreviews(type) + }, + [disabled, onChange, onClearPreviews], + ) - const visibleOptions = useMemo(() => DATA_SOURCE_OPTIONS.filter((option) => { - if (option.type === DataSourceType.WEB) - return isWebEnabled - return true - }), [isWebEnabled]) + const visibleOptions = useMemo( + () => + DATA_SOURCE_OPTIONS.filter((option) => { + if (option.type === DataSourceType.WEB) return isWebEnabled + return true + }), + [isWebEnabled], + ) return ( <div className="mb-8 grid grid-cols-3 gap-4"> - {visibleOptions.map(option => ( + {visibleOptions.map((option) => ( <div key={option.type} className={cn( @@ -83,10 +93,10 @@ function DataSourceTypeSelector({ > <span className={cn(s.datasetIcon, option.iconClass)} /> <span - title={t($ => $[option.labelKey], { ns: 'datasetCreation' }) || undefined} + title={t(($) => $[option.labelKey], { ns: 'datasetCreation' }) || undefined} className="truncate" > - {t($ => $[option.labelKey], { ns: 'datasetCreation' })} + {t(($) => $[option.labelKey], { ns: 'datasetCreation' })} </span> </div> ))} diff --git a/web/app/components/datasets/create/step-one/components/next-step-button.tsx b/web/app/components/datasets/create/step-one/components/next-step-button.tsx index 0e599b9cf99b71..ede901d53b7090 100644 --- a/web/app/components/datasets/create/step-one/components/next-step-button.tsx +++ b/web/app/components/datasets/create/step-one/components/next-step-button.tsx @@ -19,7 +19,7 @@ function NextStepButton({ disabled, onClick }: NextStepButtonProps) { <div className="flex max-w-[640px] justify-end gap-2"> <Button disabled={disabled} variant="primary" onClick={onClick}> <span className="flex gap-0.5 px-[10px]"> - <span className="px-0.5">{t($ => $['stepOne.button'], { ns: 'datasetCreation' })}</span> + <span className="px-0.5">{t(($) => $['stepOne.button'], { ns: 'datasetCreation' })}</span> <RiArrowRightLine className="size-4" /> </span> </Button> diff --git a/web/app/components/datasets/create/step-one/components/preview-panel.tsx b/web/app/components/datasets/create/step-one/components/preview-panel.tsx index b398fea7898583..0921e7997b5744 100644 --- a/web/app/components/datasets/create/step-one/components/preview-panel.tsx +++ b/web/app/components/datasets/create/step-one/components/preview-panel.tsx @@ -46,13 +46,15 @@ function PreviewPanel({ notionCredentialId={notionCredentialId} /> )} - {currentWebsite && <WebsitePreview payload={currentWebsite} hidePreview={hideWebsitePreview} />} + {currentWebsite && ( + <WebsitePreview payload={currentWebsite} hidePreview={hideWebsitePreview} /> + )} {isShowPlanUpgradeModal && ( <PlanUpgradeModal show onClose={hidePlanUpgradeModal} - title={t($ => $['upgrade.uploadMultiplePages.title'], { ns: 'billing' })!} - description={t($ => $['upgrade.uploadMultiplePages.description'], { ns: 'billing' })!} + title={t(($) => $['upgrade.uploadMultiplePages.title'], { ns: 'billing' })!} + description={t(($) => $['upgrade.uploadMultiplePages.description'], { ns: 'billing' })!} /> )} </div> diff --git a/web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts b/web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts index 9ab71d78e9282c..499a4519aa5c72 100644 --- a/web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts +++ b/web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts @@ -45,7 +45,10 @@ describe('usePreviewState', () => { it('should show and hide website preview', () => { const { result } = renderHook(() => usePreviewState()) - const website = { title: 'Example', source_url: 'https://example.com' } as unknown as CrawlResultItem + const website = { + title: 'Example', + source_url: 'https://example.com', + } as unknown as CrawlResultItem act(() => { result.current.showWebsitePreview(website) diff --git a/web/app/components/datasets/create/step-one/index.module.css b/web/app/components/datasets/create/step-one/index.module.css index 36f167f25092ae..3a270d90ac9b4c 100644 --- a/web/app/components/datasets/create/step-one/index.module.css +++ b/web/app/components/datasets/create/step-one/index.module.css @@ -10,41 +10,39 @@ } .dataSourceItem { - @apply w-full relative flex items-center p-3 h-14 bg-components-option-card-option-bg rounded-xl - cursor-pointer border border-components-option-card-option-border text-text-secondary; + @apply relative flex h-14 w-full cursor-pointer items-center rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-3 text-text-secondary; } .dataSourceItem:hover { - @apply bg-components-option-card-option-bg-hover border border-components-option-card-option-border-hover shadow-xs shadow-shadow-shadow-3; + @apply border border-components-option-card-option-border-hover bg-components-option-card-option-bg-hover shadow-xs shadow-shadow-shadow-3; } .dataSourceItem.active { - @apply bg-components-option-card-option-selected-bg border border-components-option-card-option-selected-border - ring-[0.5px] ring-components-option-card-option-selected-border; + @apply border border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg ring-[0.5px] ring-components-option-card-option-selected-border; } .dataSourceItem.disabled, .dataSourceItem.disabled:hover { - @apply bg-components-option-card-option-bg border-[0.5px] border-components-option-card-option-border cursor-not-allowed text-text-disabled shadow-none; + @apply cursor-not-allowed border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg text-text-disabled shadow-none; } .comingTag { - @apply flex justify-center items-center bg-white; + @apply flex items-center justify-center bg-white; position: absolute; right: 8px; top: -10px; padding: 1px 6px; height: 20px; - border: 1px solid #E0EAFF; + border: 1px solid #e0eaff; border-radius: 6px; font-weight: 500; font-size: 12px; line-height: 18px; - color: #444CE7; + color: #444ce7; } .datasetIcon { - @apply flex shrink-0 mr-2 w-8 h-8 rounded-lg bg-center bg-no-repeat bg-text-primary-on-surface border-[0.5px] border-divider-regular backdrop-blur-sm; + @apply mr-2 flex h-8 w-8 shrink-0 rounded-lg border-[0.5px] border-divider-regular bg-text-primary-on-surface bg-center bg-no-repeat backdrop-blur-sm; background-image: url(../assets/file.svg); background-size: 16px; } diff --git a/web/app/components/datasets/create/step-one/index.tsx b/web/app/components/datasets/create/step-one/index.tsx index 96f1aed399f35c..02d7bdaf29a9ef 100644 --- a/web/app/components/datasets/create/step-one/index.tsx +++ b/web/app/components/datasets/create/step-one/index.tsx @@ -49,17 +49,27 @@ type IStepOneProps = { // Helper function to check if notion is authenticated function checkNotionAuth(authedDataSourceList: DataSourceAuth[]): boolean { - const notionSource = authedDataSourceList.find(item => item.provider === 'notion_datasource') + const notionSource = authedDataSourceList.find((item) => item.provider === 'notion_datasource') return Boolean(notionSource && notionSource.credentials_list.length > 0) } // Helper function to get notion credential list function getNotionCredentialList(authedDataSourceList: DataSourceAuth[]) { - return authedDataSourceList.find(item => item.provider === 'notion_datasource')?.credentials_list || [] + return ( + authedDataSourceList.find((item) => item.provider === 'notion_datasource')?.credentials_list || + [] + ) } // Lookup table for checking multiple items by data source type -const MULTIPLE_ITEMS_CHECK: Record<DataSourceType, (props: { files: FileItem[], notionPages: NotionPage[], websitePages: CrawlResultItem[] }) => boolean> = { +const MULTIPLE_ITEMS_CHECK: Record< + DataSourceType, + (props: { + files: FileItem[] + notionPages: NotionPage[] + websitePages: CrawlResultItem[] + }) => boolean +> = { [DataSourceType.FILE]: ({ files }) => files.length > 1, [DataSourceType.NOTION]: ({ notionPages }) => notionPages.length > 1, [DataSourceType.WEB]: ({ websitePages }) => websitePages.length > 1, @@ -88,7 +98,7 @@ const StepOne = ({ authedDataSourceList, }: IStepOneProps) => { const { t } = useTranslation() - const dataset = useDatasetDetailContextWithSelector(state => state.dataset) + const dataset = useDatasetDetailContextWithSelector((state) => state.dataset) const { plan, enableBilling } = useProviderContext() // Preview state management @@ -108,7 +118,10 @@ const StepOne = ({ const [showModal, { setTrue: openModal, setFalse: closeModal }] = useBoolean(false) // Plan upgrade modal state - const [isShowPlanUpgradeModal, { setTrue: showPlanUpgradeModal, setFalse: hidePlanUpgradeModal }] = useBoolean(false) + const [ + isShowPlanUpgradeModal, + { setTrue: showPlanUpgradeModal, setFalse: hidePlanUpgradeModal }, + ] = useBoolean(false) // Computed values const shouldShowDataSourceTypeList = !datasetId || (datasetId && !dataset?.data_source_type) @@ -118,42 +131,42 @@ const StepOne = ({ ? (inCreatePageDataSourceType ?? DataSourceType.FILE) : (dataset?.data_source_type ?? DataSourceType.FILE) - const allFileLoaded = files.length > 0 && files.every(file => file.file.id) + const allFileLoaded = files.length > 0 && files.every((file) => file.file.id) const hasNotion = notionPages.length > 0 const shouldCheckVectorSpace = enableBilling && (allFileLoaded || hasNotion) - const { - data: vectorSpace, - isFetching: isFetchingVectorSpacePlan, - } = useCurrentPlanVectorSpace(shouldCheckVectorSpace) + const { data: vectorSpace, isFetching: isFetchingVectorSpacePlan } = + useCurrentPlanVectorSpace(shouldCheckVectorSpace) const isCheckingVectorSpace = shouldCheckVectorSpace && !vectorSpace && isFetchingVectorSpacePlan - const isVectorSpaceFull = !!vectorSpace - && vectorSpace.limit > 0 - && vectorSpace.size >= vectorSpace.limit + const isVectorSpaceFull = + !!vectorSpace && vectorSpace.limit > 0 && vectorSpace.size >= vectorSpace.limit const isShowVectorSpaceFull = (allFileLoaded || hasNotion) && isVectorSpaceFull && enableBilling const supportBatchUpload = !enableBilling || plan.type !== Plan.sandbox - const isNotionAuthed = useMemo(() => checkNotionAuth(authedDataSourceList), [authedDataSourceList]) - const notionCredentialList = useMemo(() => getNotionCredentialList(authedDataSourceList), [authedDataSourceList]) + const isNotionAuthed = useMemo( + () => checkNotionAuth(authedDataSourceList), + [authedDataSourceList], + ) + const notionCredentialList = useMemo( + () => getNotionCredentialList(authedDataSourceList), + [authedDataSourceList], + ) const fileNextDisabled = useMemo(() => { - if (!files.length) - return true - if (files.some(file => !file.file.id)) - return true - if (isCheckingVectorSpace) - return true + if (!files.length) return true + if (files.some((file) => !file.file.id)) return true + if (isCheckingVectorSpace) return true return isShowVectorSpaceFull }, [files, isCheckingVectorSpace, isShowVectorSpaceFull]) // Clear previews when switching data source type - const handleClearPreviews = useCallback((newType: DataSourceType) => { - if (newType !== DataSourceType.FILE) - hideFilePreview() - if (newType !== DataSourceType.NOTION) - hideNotionPagePreview() - if (newType !== DataSourceType.WEB) - hideWebsitePreview() - }, [hideFilePreview, hideNotionPagePreview, hideWebsitePreview]) + const handleClearPreviews = useCallback( + (newType: DataSourceType) => { + if (newType !== DataSourceType.FILE) hideFilePreview() + if (newType !== DataSourceType.NOTION) hideNotionPagePreview() + if (newType !== DataSourceType.WEB) hideWebsitePreview() + }, + [hideFilePreview, hideNotionPagePreview, hideWebsitePreview], + ) // Handle step change with batch upload check const onStepChange = useCallback(() => { @@ -165,7 +178,15 @@ const StepOne = ({ } } doOnStepChange() - }, [dataSourceType, doOnStepChange, files, supportBatchUpload, notionPages, showPlanUpgradeModal, websitePages]) + }, [ + dataSourceType, + doOnStepChange, + files, + supportBatchUpload, + notionPages, + showPlanUpgradeModal, + websitePages, + ]) return ( <div className="size-full overflow-x-auto"> @@ -177,7 +198,7 @@ const StepOne = ({ {shouldShowDataSourceTypeList && ( <> <div className={cn(s.stepHeader, 'system-md-semibold text-text-secondary')}> - {t($ => $['steps.one'], { ns: 'datasetCreation' })} + {t(($) => $['steps.one'], { ns: 'datasetCreation' })} </div> <DataSourceTypeSelector currentType={dataSourceType} @@ -193,7 +214,9 @@ const StepOne = ({ <> <FileUploader fileList={files} - titleClassName={!shouldShowDataSourceTypeList ? 'mt-[30px] mb-[44px]! text-lg!' : undefined} + titleClassName={ + !shouldShowDataSourceTypeList ? 'mt-[30px] mb-[44px]! text-lg!' : undefined + } prepareFileList={updateFileList} onFileListUpdate={updateFileList} onFileUpdate={updateFile} @@ -227,7 +250,7 @@ const StepOne = ({ <> <div className="mb-8 w-[640px]"> <NotionPageSelector - value={notionPages.map(page => page.page_id)} + value={notionPages.map((page) => page.page_id)} onSelect={updateNotionPages} onPreview={showNotionPagePreview} credentialList={notionCredentialList} @@ -285,7 +308,7 @@ const StepOne = ({ onClick={openModal} > <RiFolder6Line className="mr-1 size-4" /> - {t($ => $['stepOne.emptyDatasetCreation'], { ns: 'datasetCreation' })} + {t(($) => $['stepOne.emptyDatasetCreation'], { ns: 'datasetCreation' })} </span> </> )} diff --git a/web/app/components/datasets/create/step-one/upgrade-card.tsx b/web/app/components/datasets/create/step-one/upgrade-card.tsx index 3ede655fb9833d..20838d115f1fbe 100644 --- a/web/app/components/datasets/create/step-one/upgrade-card.tsx +++ b/web/app/components/datasets/create/step-one/upgrade-card.tsx @@ -15,14 +15,17 @@ const UpgradeCard: FC = () => { setShowPricingModal() }, [setShowPricingModal]) - if (!IS_CLOUD_EDITION) - return null + if (!IS_CLOUD_EDITION) return null return ( <div className="flex items-center justify-between rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg py-3 pr-3.5 pl-4 shadow-xs backdrop-blur-[5px]"> <div> - <div className="bg-[linear-gradient(92deg,var(--components-input-border-active-prompt-1,#0BA5EC)_0%,var(--components-input-border-active-prompt-2,#155AEF)_99.21%)] bg-clip-text title-md-semi-bold text-transparent">{t($ => $['upgrade.uploadMultipleFiles.title'], { ns: 'billing' })}</div> - <div className="system-xs-regular text-text-tertiary">{t($ => $['upgrade.uploadMultipleFiles.description'], { ns: 'billing' })}</div> + <div className="bg-[linear-gradient(92deg,var(--components-input-border-active-prompt-1,#0BA5EC)_0%,var(--components-input-border-active-prompt-2,#155AEF)_99.21%)] bg-clip-text title-md-semi-bold text-transparent"> + {t(($) => $['upgrade.uploadMultipleFiles.title'], { ns: 'billing' })} + </div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['upgrade.uploadMultipleFiles.description'], { ns: 'billing' })} + </div> </div> <UpgradeBtn size="custom" diff --git a/web/app/components/datasets/create/step-three/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-three/__tests__/index.spec.tsx index f5b8eec0152a1c..0770874e8aebe1 100644 --- a/web/app/components/datasets/create/step-three/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-three/__tests__/index.spec.tsx @@ -1,4 +1,9 @@ -import type { createDocumentResponse, DataSet, FullDocumentDetail, IconInfo } from '@/models/datasets' +import type { + createDocumentResponse, + DataSet, + FullDocumentDetail, + IconInfo, +} from '@/models/datasets' import { render, screen } from '@testing-library/react' import { RETRIEVE_METHOD } from '@/types/app' import StepThree from '../index' @@ -42,40 +47,43 @@ const createMockIconInfo = (overrides: Partial<IconInfo> = {}): IconInfo => ({ }) // Factory function to create mock FullDocumentDetail -const createMockDocument = (overrides: Partial<FullDocumentDetail> = {}): FullDocumentDetail => ({ - id: 'doc-123', - name: 'test-document.txt', - data_source_type: 'upload_file', - data_source_info: { - upload_file: { - id: 'file-123', - name: 'test-document.txt', - extension: 'txt', - mime_type: 'text/plain', - size: 1024, - created_by: 'user-1', - created_at: Date.now(), +const createMockDocument = (overrides: Partial<FullDocumentDetail> = {}): FullDocumentDetail => + ({ + id: 'doc-123', + name: 'test-document.txt', + data_source_type: 'upload_file', + data_source_info: { + upload_file: { + id: 'file-123', + name: 'test-document.txt', + extension: 'txt', + mime_type: 'text/plain', + size: 1024, + created_by: 'user-1', + created_at: Date.now(), + }, }, - }, - batch: 'batch-123', - created_api_request_id: 'request-123', - processing_started_at: Date.now(), - parsing_completed_at: Date.now(), - cleaning_completed_at: Date.now(), - splitting_completed_at: Date.now(), - tokens: 100, - indexing_latency: 5000, - completed_at: Date.now(), - paused_by: '', - paused_at: 0, - stopped_at: 0, - indexing_status: 'completed', - disabled_at: 0, - ...overrides, -} as FullDocumentDetail) + batch: 'batch-123', + created_api_request_id: 'request-123', + processing_started_at: Date.now(), + parsing_completed_at: Date.now(), + cleaning_completed_at: Date.now(), + splitting_completed_at: Date.now(), + tokens: 100, + indexing_latency: 5000, + completed_at: Date.now(), + paused_by: '', + paused_at: 0, + stopped_at: 0, + indexing_status: 'completed', + disabled_at: 0, + ...overrides, + }) as FullDocumentDetail // Factory function to create mock createDocumentResponse -const createMockCreationCache = (overrides: Partial<createDocumentResponse> = {}): createDocumentResponse => ({ +const createMockCreationCache = ( + overrides: Partial<createDocumentResponse> = {}, +): createDocumentResponse => ({ dataset: { id: 'dataset-123', name: 'Test Dataset', @@ -143,7 +151,9 @@ describe('StepThree', () => { expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepThree.sideTipContent')).toBeInTheDocument() - expect(screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore'), + ).toBeInTheDocument() }) it('should not render side tip panel on mobile', () => { @@ -167,7 +177,10 @@ describe('StepThree', () => { renderStepThree() const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore') - expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/use-dify/knowledge/integrate-knowledge-within-application') + expect(link).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en-US/use-dify/knowledge/integrate-knowledge-within-application', + ) expect(link).toHaveAttribute('target', '_blank') expect(link).toHaveAttribute('rel', 'noreferrer noopener') }) @@ -176,7 +189,13 @@ describe('StepThree', () => { const { container } = renderStepThree() const outerDiv = container.firstChild as HTMLElement - expect(outerDiv).toHaveClass('flex', 'size-full', 'max-h-full', 'justify-center', 'overflow-y-auto') + expect(outerDiv).toHaveClass( + 'flex', + 'size-full', + 'max-h-full', + 'justify-center', + 'overflow-y-auto', + ) }) }) @@ -226,7 +245,9 @@ describe('StepThree', () => { }) // Assert - Check the text contains the dataset name (in the description) - const description = screen.getByText(/datasetCreation.stepThree.additionP1.*Existing Dataset Name.*datasetCreation.stepThree.additionP2/i) + const description = screen.getByText( + /datasetCreation.stepThree.additionP1.*Existing Dataset Name.*datasetCreation.stepThree.additionP2/i, + ) expect(description).toBeInTheDocument() }) @@ -249,7 +270,8 @@ describe('StepThree', () => { it('should use creationCache indexing_technique when indexingType is not provided', () => { const creationCache = createMockCreationCache() - creationCache.dataset!.indexing_technique = 'economy' as unknown as DataSet['indexing_technique'] + creationCache.dataset!.indexing_technique = + 'economy' as unknown as DataSet['indexing_technique'] renderStepThree({ creationCache }) @@ -258,7 +280,8 @@ describe('StepThree', () => { it('should prefer creationCache indexing_technique over indexingType prop', () => { const creationCache = createMockCreationCache() - creationCache.dataset!.indexing_technique = 'cache_technique' as unknown as DataSet['indexing_technique'] + creationCache.dataset!.indexing_technique = + 'cache_technique' as unknown as DataSet['indexing_technique'] renderStepThree({ creationCache, indexingType: 'prop_technique' }) @@ -276,7 +299,9 @@ describe('StepThree', () => { it('should use creationCache retrieval method when retrievalMethod is not provided', () => { const creationCache = createMockCreationCache() - creationCache.dataset!.retrieval_model_dict = { search_method: 'full_text_search' } as unknown as DataSet['retrieval_model_dict'] + creationCache.dataset!.retrieval_model_dict = { + search_method: 'full_text_search', + } as unknown as DataSet['retrieval_model_dict'] renderStepThree({ creationCache }) @@ -296,7 +321,11 @@ describe('StepThree', () => { it('should pass documents from creationCache to EmbeddingProcess', () => { const creationCache = createMockCreationCache() - creationCache.documents = [createMockDocument(), createMockDocument(), createMockDocument()] as unknown as createDocumentResponse['documents'] + creationCache.documents = [ + createMockDocument(), + createMockDocument(), + createMockDocument(), + ] as unknown as createDocumentResponse['documents'] renderStepThree({ creationCache }) @@ -558,8 +587,7 @@ describe('StepThree', () => { // Assert - Default background color should be applied const appIcon = container.querySelector('span[style*="background"]') - if (appIcon) - expect(appIcon).toHaveStyle({ background: '#FFF4ED' }) + if (appIcon) expect(appIcon).toHaveStyle({ background: '#FFF4ED' }) }) it('should use icon_info from creationCache when available', () => { @@ -575,8 +603,7 @@ describe('StepThree', () => { // Assert - Custom background color should be applied const appIcon = container.querySelector('span[style*="background"]') - if (appIcon) - expect(appIcon).toHaveStyle({ background: '#00FF00' }) + if (appIcon) expect(appIcon).toHaveStyle({ background: '#00FF00' }) }) it('should use default icon when creationCache dataset icon_info is undefined', () => { diff --git a/web/app/components/datasets/create/step-three/index.tsx b/web/app/components/datasets/create/step-three/index.tsx index 41c954ffa6d0be..27e97965d83ab3 100644 --- a/web/app/components/datasets/create/step-three/index.tsx +++ b/web/app/components/datasets/create/step-three/index.tsx @@ -3,7 +3,6 @@ import type { createDocumentResponse, FullDocumentDetail } from '@/models/datase import type { RETRIEVE_METHOD } from '@/types/app' import { RiBookOpenLine } from '@remixicon/react' import * as React from 'react' - import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Divider from '@/app/components/base/divider' @@ -19,7 +18,13 @@ type StepThreeProps = { creationCache?: createDocumentResponse } -const StepThree = ({ datasetId, datasetName, indexingType, creationCache, retrievalMethod }: StepThreeProps) => { +const StepThree = ({ + datasetId, + datasetName, + indexingType, + creationCache, + retrievalMethod, +}: StepThreeProps) => { const { t } = useTranslation() const docLink = useDocLink() @@ -39,8 +44,12 @@ const StepThree = ({ datasetId, datasetName, indexingType, creationCache, retrie {!datasetId && ( <> <div className="flex flex-col gap-y-1 pb-3"> - <div className="title-2xl-semi-bold text-text-primary">{t($ => $['stepThree.creationTitle'], { ns: 'datasetCreation' })}</div> - <div className="system-sm-regular text-text-tertiary">{t($ => $['stepThree.creationContent'], { ns: 'datasetCreation' })}</div> + <div className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['stepThree.creationTitle'], { ns: 'datasetCreation' })} + </div> + <div className="system-sm-regular text-text-tertiary"> + {t(($) => $['stepThree.creationContent'], { ns: 'datasetCreation' })} + </div> </div> <div className="flex items-center gap-x-4"> <AppIcon @@ -53,7 +62,7 @@ const StepThree = ({ datasetId, datasetName, indexingType, creationCache, retrie /> <div className="flex grow flex-col gap-y-1"> <div className="flex h-6 items-center system-sm-semibold text-text-secondary"> - {t($ => $['stepThree.label'], { ns: 'datasetCreation' })} + {t(($) => $['stepThree.label'], { ns: 'datasetCreation' })} </div> <div className="w-full truncate rounded-lg bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled"> <span className="px-1">{datasetName || creationCache?.dataset?.name}</span> @@ -65,8 +74,10 @@ const StepThree = ({ datasetId, datasetName, indexingType, creationCache, retrie )} {datasetId && ( <div className="flex flex-col gap-y-1 pb-3"> - <div className="title-2xl-semi-bold text-text-primary">{t($ => $['stepThree.additionTitle'], { ns: 'datasetCreation' })}</div> - <div className="system-sm-regular text-text-tertiary">{`${t($ => $['stepThree.additionP1'], { ns: 'datasetCreation' })} ${datasetName || creationCache?.dataset?.name} ${t($ => $['stepThree.additionP2'], { ns: 'datasetCreation' })}`}</div> + <div className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['stepThree.additionTitle'], { ns: 'datasetCreation' })} + </div> + <div className="system-sm-regular text-text-tertiary">{`${t(($) => $['stepThree.additionP1'], { ns: 'datasetCreation' })} ${datasetName || creationCache?.dataset?.name} ${t(($) => $['stepThree.additionP2'], { ns: 'datasetCreation' })}`}</div> </div> )} <EmbeddingProcess @@ -74,7 +85,9 @@ const StepThree = ({ datasetId, datasetName, indexingType, creationCache, retrie batchId={creationCache?.batch || ''} documents={creationCache?.documents as FullDocumentDetail[]} indexingType={creationCache?.dataset?.indexing_technique || indexingType} - retrievalMethod={creationCache?.dataset?.retrieval_model_dict?.search_method || retrievalMethod} + retrievalMethod={ + creationCache?.dataset?.retrieval_model_dict?.search_method || retrievalMethod + } /> </div> </div> @@ -84,15 +97,19 @@ const StepThree = ({ datasetId, datasetName, indexingType, creationCache, retrie <div className="flex size-10 items-center justify-center rounded-[10px] bg-components-card-bg shadow-lg"> <RiBookOpenLine className="size-5 text-text-accent" /> </div> - <div className="text-base font-semibold text-text-secondary">{t($ => $['stepThree.sideTipTitle'], { ns: 'datasetCreation' })}</div> - <div className="text-text-tertiary">{t($ => $['stepThree.sideTipContent'], { ns: 'datasetCreation' })}</div> + <div className="text-base font-semibold text-text-secondary"> + {t(($) => $['stepThree.sideTipTitle'], { ns: 'datasetCreation' })} + </div> + <div className="text-text-tertiary"> + {t(($) => $['stepThree.sideTipContent'], { ns: 'datasetCreation' })} + </div> <a href={docLink('/use-dify/knowledge/integrate-knowledge-within-application')} target="_blank" rel="noreferrer noopener" className="system-sm-regular text-text-accent" > - {t($ => $['addDocuments.stepThree.learnMore'], { ns: 'datasetPipeline' })} + {t(($) => $['addDocuments.stepThree.learnMore'], { ns: 'datasetPipeline' })} </a> </div> </div> diff --git a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx index a1610064181585..5299589fe56168 100644 --- a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx @@ -11,7 +11,11 @@ import type { } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' -import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + ConfigurationMethodEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets' import { expectLoadingButton } from '@/test/button' import { RETRIEVE_METHOD } from '@/types/app' @@ -53,32 +57,43 @@ let mockCurrentDataset: typeof mockDataset | null = null const mockMutateDatasetRes = vi.fn() vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: typeof mockDataset | null, mutateDatasetRes: () => void }) => unknown) => - selector({ dataset: mockCurrentDataset, mutateDatasetRes: mockMutateDatasetRes }), + useDatasetDetailContextWithSelector: ( + selector: (state: { + dataset: typeof mockDataset | null + mutateDatasetRes: () => void + }) => unknown, + ) => selector({ dataset: mockCurrentDataset, mutateDatasetRes: mockMutateDatasetRes }), })) const mockEmbeddingModelList = [ { provider: 'openai', model: 'text-embedding-ada-002' }, { provider: 'cohere', model: 'embed-english-v3.0' }, ] -const mockDefaultEmbeddingModel = { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' } +const mockDefaultEmbeddingModel = { + provider: { provider: 'openai' }, + model: 'text-embedding-ada-002', +} // Model[] type structure for rerank model list (simplified mock) -const mockRerankModelList: Model[] = [{ - provider: 'cohere', - icon_small: { en_US: 'cohere-icon', zh_Hans: 'cohere-icon' }, - label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, - models: [{ - model: 'rerank-english-v3.0', - label: { en_US: 'Rerank English v3.0', zh_Hans: 'Rerank English v3.0' }, - model_type: ModelTypeEnum.rerank, - features: [], - fetch_from: ConfigurationMethodEnum.predefinedModel, +const mockRerankModelList: Model[] = [ + { + provider: 'cohere', + icon_small: { en_US: 'cohere-icon', zh_Hans: 'cohere-icon' }, + label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, + models: [ + { + model: 'rerank-english-v3.0', + label: { en_US: 'Rerank English v3.0', zh_Hans: 'Rerank English v3.0' }, + model_type: ModelTypeEnum.rerank, + features: [], + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + }, + ], status: ModelStatusEnum.active, - model_properties: {}, - load_balancing_enabled: false, - }], - status: ModelStatusEnum.active, -}] + }, +] const mockRerankDefaultModel = { provider: { provider: 'cohere' }, model: 'rerank-english-v3.0' } let mockIsRerankDefaultModelValid = true @@ -94,7 +109,14 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () const mockFetchDefaultProcessRuleMutate = vi.fn() vi.mock('@/service/knowledge/use-create-dataset', () => ({ - useFetchDefaultProcessRule: ({ onSuccess }: { onSuccess: (data: { rules: Rules, limits: { indexing_max_segmentation_tokens_length: number } }) => void }) => ({ + useFetchDefaultProcessRule: ({ + onSuccess, + }: { + onSuccess: (data: { + rules: Rules + limits: { indexing_max_segmentation_tokens_length: number } + }) => void + }) => ({ mutate: (url: string) => { mockFetchDefaultProcessRuleMutate(url) onSuccess({ @@ -134,23 +156,35 @@ vi.mock('@/service/knowledge/use-create-dataset', () => ({ reset: vi.fn(), }), useCreateFirstDocument: () => ({ - mutateAsync: vi.fn().mockImplementation(async (params: unknown, options?: { onSuccess?: (data: unknown) => void }) => { - const data = { dataset: { id: 'new-dataset-id' } } - options?.onSuccess?.(data) - return data - }), + mutateAsync: vi + .fn() + .mockImplementation( + async (params: unknown, options?: { onSuccess?: (data: unknown) => void }) => { + const data = { dataset: { id: 'new-dataset-id' } } + options?.onSuccess?.(data) + return data + }, + ), isPending: false, }), useCreateDocument: () => ({ - mutateAsync: vi.fn().mockImplementation(async (params: unknown, options?: { onSuccess?: (data: unknown) => void }) => { - const data = { document: { id: 'new-doc-id' } } - options?.onSuccess?.(data) - return data - }), + mutateAsync: vi + .fn() + .mockImplementation( + async (params: unknown, options?: { onSuccess?: (data: unknown) => void }) => { + const data = { document: { id: 'new-doc-id' } } + options?.onSuccess?.(data) + return data + }, + ), isPending: false, }), - getNotionInfo: vi.fn().mockReturnValue([{ workspace_id: 'ws-1', pages: [{ page_id: 'page-1' }] }]), - getWebsiteInfo: vi.fn().mockReturnValue({ provider: 'jinaReader', job_id: 'job-123', urls: ['https://test.com'] }), + getNotionInfo: vi + .fn() + .mockReturnValue([{ workspace_id: 'ws-1', pages: [{ page_id: 'page-1' }] }]), + getWebsiteInfo: vi + .fn() + .mockReturnValue({ provider: 'jinaReader', job_id: 'job-123', urls: ['https://test.com'] }), })) vi.mock('@/service/knowledge/use-dataset', () => ({ @@ -170,17 +204,26 @@ vi.mock('@/config', async () => { // Mock PreviewDocumentPicker to allow testing handlePickerChange vi.mock('@/app/components/datasets/common/document-picker/preview-document-picker', () => ({ - // eslint-disable-next-line ts/no-explicit-any - default: ({ onChange, value, files }: { onChange: (item: any) => void, value: any, files: any[] }) => ( + /* eslint-disable ts/no-explicit-any */ + default: ({ + onChange, + value, + files, + }: { + onChange: (item: any) => void + value: any + files: any[] + }) => ( <div data-testid="preview-picker"> <span>{value?.name}</span> - {files?.map((f: { id: string, name: string }) => ( + {files?.map((f: { id: string; name: string }) => ( <button key={f.id} data-testid={`picker-${f.id}`} onClick={() => onChange(f)}> {f.name} </button> ))} </div> ), + /* eslint-enable ts/no-explicit-any */ })) vi.mock('@/app/components/datasets/settings/utils', () => ({ @@ -189,9 +232,17 @@ vi.mock('@/app/components/datasets/settings/utils', () => ({ // Mock complex child components to avoid deep dependency chains when rendering StepTwo vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ onSelect, readonly }: { onSelect?: (val: Record<string, string>) => void, readonly?: boolean }) => ( + default: ({ + onSelect, + readonly, + }: { + onSelect?: (val: Record<string, string>) => void + readonly?: boolean + }) => ( <div data-testid="model-selector" data-readonly={readonly}> - <button onClick={() => onSelect?.({ provider: 'openai', model: 'text-embedding-3-small' })}>Select Model</button> + <button onClick={() => onSelect?.({ provider: 'openai', model: 'text-embedding-3-small' })}> + Select Model + </button> </div> ), })) @@ -212,48 +263,52 @@ vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', ( ), })) -const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => ({ - id: 'file-1', - name: 'test-file.pdf', - extension: 'pdf', - size: 1024, - type: 'application/pdf', - lastModified: Date.now(), - ...overrides, -} as CustomFile) - -const createMockNotionPage = (overrides?: Partial<NotionPage>): NotionPage => ({ - page_id: 'notion-page-1', - page_name: 'Test Notion Page', - page_icon: null, - type: 'page', - ...overrides, -} as NotionPage) - -const createMockWebsitePage = (overrides?: Partial<CrawlResultItem>): CrawlResultItem => ({ - source_url: 'https://example.com/page1', - title: 'Test Website Page', - description: 'Test description', - markdown: '# Test Content', - ...overrides, -} as CrawlResultItem) - -const createMockDocumentDetail = (overrides?: Partial<FullDocumentDetail>): FullDocumentDetail => ({ - id: 'doc-1', - doc_form: ChunkingMode.text, - doc_language: 'English', - file: { id: 'file-1', name: 'test.pdf', extension: 'pdf' }, - notion_page: createMockNotionPage(), - website_page: createMockWebsitePage(), - dataset_process_rule: { - mode: ProcessMode.general, - rules: { - segmentation: { separator: '\\n\\n', max_tokens: 1024, chunk_overlap: 50 }, - pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }], +const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => + ({ + id: 'file-1', + name: 'test-file.pdf', + extension: 'pdf', + size: 1024, + type: 'application/pdf', + lastModified: Date.now(), + ...overrides, + }) as CustomFile + +const createMockNotionPage = (overrides?: Partial<NotionPage>): NotionPage => + ({ + page_id: 'notion-page-1', + page_name: 'Test Notion Page', + page_icon: null, + type: 'page', + ...overrides, + }) as NotionPage + +const createMockWebsitePage = (overrides?: Partial<CrawlResultItem>): CrawlResultItem => + ({ + source_url: 'https://example.com/page1', + title: 'Test Website Page', + description: 'Test description', + markdown: '# Test Content', + ...overrides, + }) as CrawlResultItem + +const createMockDocumentDetail = (overrides?: Partial<FullDocumentDetail>): FullDocumentDetail => + ({ + id: 'doc-1', + doc_form: ChunkingMode.text, + doc_language: 'English', + file: { id: 'file-1', name: 'test.pdf', extension: 'pdf' }, + notion_page: createMockNotionPage(), + website_page: createMockWebsitePage(), + dataset_process_rule: { + mode: ProcessMode.general, + rules: { + segmentation: { separator: '\\n\\n', max_tokens: 1024, chunk_overlap: 50 }, + pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }], + }, }, - }, - ...overrides, -} as FullDocumentDetail) + ...overrides, + }) as FullDocumentDetail const createMockRules = (overrides?: Partial<Rules>): Rules => ({ segmentation: { separator: '\\n\\n', max_tokens: 1024, chunk_overlap: 50 }, @@ -266,7 +321,9 @@ const createMockRules = (overrides?: Partial<Rules>): Rules => ({ ...overrides, }) -const createMockEstimate = (overrides?: Partial<FileIndexingEstimateResponse>): FileIndexingEstimateResponse => ({ +const createMockEstimate = ( + overrides?: Partial<FileIndexingEstimateResponse>, +): FileIndexingEstimateResponse => ({ total_segments: 10, total_nodes: 10, tokens: 5000, @@ -310,7 +367,7 @@ describe('escape utility', () => { }) it('should escape single quotes', () => { - expect(escape('\'')).toBe('\\\'') + expect(escape("'")).toBe("\\'") }) it('should handle mixed content', () => { @@ -353,7 +410,7 @@ describe('unescape utility', () => { }) it('should unescape single and double quotes', () => { - expect(unescape('\\\'')).toBe('\'') + expect(unescape("\\'")).toBe("'") expect(unescape('\\"')).toBe('"') }) @@ -518,8 +575,8 @@ describe('useSegmentationState', () => { result.current.toggleRule('rule1') }) - expect(result.current.rules.find(r => r.id === 'rule1')?.enabled).toBe(false) - expect(result.current.rules.find(r => r.id === 'rule2')?.enabled).toBe(false) + expect(result.current.rules.find((r) => r.id === 'rule1')?.enabled).toBe(false) + expect(result.current.rules.find((r) => r.id === 'rule2')?.enabled).toBe(false) }) it('should not affect other rules', () => { @@ -536,8 +593,8 @@ describe('useSegmentationState', () => { result.current.toggleRule('rule2') }) - expect(result.current.rules.find(r => r.id === 'rule1')?.enabled).toBe(true) - expect(result.current.rules.find(r => r.id === 'rule2')?.enabled).toBe(true) + expect(result.current.rules.find((r) => r.id === 'rule1')?.enabled).toBe(true) + expect(result.current.rules.find((r) => r.id === 'rule2')?.enabled).toBe(true) }) }) @@ -883,7 +940,10 @@ describe('useIndexingConfig', () => { const customRetrievalConfig: RetrievalConfig = { search_method: RETRIEVE_METHOD.hybrid, reranking_enable: true, - reranking_model: { reranking_provider_name: 'custom', reranking_model_name: 'custom-model' }, + reranking_model: { + reranking_provider_name: 'custom', + reranking_model_name: 'custom-model', + }, top_k: 10, score_threshold_enabled: true, score_threshold: 0.8, @@ -1025,7 +1085,10 @@ describe('usePreviewState', () => { }) it('should return mapped website page value for WEB data source', () => { - const websitePage = createMockWebsitePage({ source_url: 'https://test.com', title: 'Test Title' }) + const websitePage = createMockWebsitePage({ + source_url: 'https://test.com', + title: 'Test Title', + }) const { result } = renderHook(() => usePreviewState({ ...defaultOptions, @@ -1090,9 +1153,7 @@ describe('usePreviewState', () => { describe('handlePreviewChange', () => { it('should update preview file for FILE data source', () => { const files = [createMockFile(), createMockFile({ id: 'file-2', name: 'second.pdf' })] - const { result } = renderHook(() => - usePreviewState({ ...defaultOptions, files }), - ) + const { result } = renderHook(() => usePreviewState({ ...defaultOptions, files })) act(() => { result.current.handlePreviewChange({ id: 'file-2', name: 'second.pdf' }) @@ -2370,12 +2431,7 @@ describe('StepTwo Component', () => { describe('Conditional Rendering', () => { it('should show options based on currentDataset doc_form', () => { mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild } - render( - <StepTwo - {...defaultStepTwoProps} - datasetId="test-id" - />, - ) + render(<StepTwo {...defaultStepTwoProps} datasetId="test-id" />) // When currentDataset has parentChild doc_form, should show parent-child option // When currentDataset has parentChild doc_form, should show parent-child option expect(screen.getByText(/stepTwo\.segmentation/i))!.toBeInTheDocument() @@ -2440,12 +2496,7 @@ describe('StepTwo Component', () => { it('should only show parent-child option when dataset has parentChild doc_form', () => { mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild } - render( - <StepTwo - {...defaultStepTwoProps} - datasetId="test-id" - />, - ) + render(<StepTwo {...defaultStepTwoProps} datasetId="test-id" />) // showGeneralOption should be false (parentChild not in [text, qa]) // showParentChildOption should be true // showGeneralOption should be false (parentChild not in [text, qa]) @@ -2455,12 +2506,7 @@ describe('StepTwo Component', () => { it('should show general option only when dataset has text doc_form', () => { mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.text } - render( - <StepTwo - {...defaultStepTwoProps} - datasetId="test-id" - />, - ) + render(<StepTwo {...defaultStepTwoProps} datasetId="test-id" />) // showGeneralOption should be true (text is in [text, qa]) // showGeneralOption should be true (text is in [text, qa]) expect(screen.getByText('datasetCreation.stepTwo.general'))!.toBeInTheDocument() @@ -2470,36 +2516,21 @@ describe('StepTwo Component', () => { describe('Upload in Dataset', () => { it('should show general option when in upload with text doc_form', () => { mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.text } - render( - <StepTwo - {...defaultStepTwoProps} - datasetId="test-id" - />, - ) + render(<StepTwo {...defaultStepTwoProps} datasetId="test-id" />) expect(screen.getByText(/stepTwo\.segmentation/i))!.toBeInTheDocument() }) it('should show general option for empty dataset (no doc_form)', () => { // eslint-disable-next-line ts/no-explicit-any mockCurrentDataset = { ...mockDataset, doc_form: undefined as any } - render( - <StepTwo - {...defaultStepTwoProps} - datasetId="test-id" - />, - ) + render(<StepTwo {...defaultStepTwoProps} datasetId="test-id" />) expect(screen.getByText(/stepTwo\.segmentation/i))!.toBeInTheDocument() }) it('should show both options in empty dataset upload', () => { // eslint-disable-next-line ts/no-explicit-any mockCurrentDataset = { ...mockDataset, doc_form: undefined as any } - render( - <StepTwo - {...defaultStepTwoProps} - datasetId="test-id" - />, - ) + render(<StepTwo {...defaultStepTwoProps} datasetId="test-id" />) // isUploadInEmptyDataset=true shows both options // isUploadInEmptyDataset=true shows both options expect(screen.getByText('datasetCreation.stepTwo.general'))!.toBeInTheDocument() @@ -2531,12 +2562,7 @@ describe('StepTwo Component', () => { it('should disable model and retrieval config when datasetId has existing data source', () => { mockCurrentDataset = { ...mockDataset, data_source_type: DataSourceType.FILE } - render( - <StepTwo - {...defaultStepTwoProps} - datasetId="test-id" - />, - ) + render(<StepTwo {...defaultStepTwoProps} datasetId="test-id" />) // isModelAndRetrievalConfigDisabled should be true const modelSelector = screen.getByTestId('model-selector') expect(modelSelector)!.toHaveAttribute('data-readonly', 'true') diff --git a/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx index 8d5779fd784726..81e9eabdf1cd4b 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx @@ -5,9 +5,18 @@ import { ChunkingMode } from '@/models/datasets' import { GeneralChunkingOptions } from '../general-chunking-options' vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({ - default: ({ onSummaryIndexSettingChange }: { onSummaryIndexSettingChange?: (val: Record<string, unknown>) => void }) => ( + default: ({ + onSummaryIndexSettingChange, + }: { + onSummaryIndexSettingChange?: (val: Record<string, unknown>) => void + }) => ( <div data-testid="summary-index-setting"> - <button data-testid="summary-toggle" onClick={() => onSummaryIndexSettingChange?.({ enable: true })}>Toggle</button> + <button + data-testid="summary-toggle" + onClick={() => onSummaryIndexSettingChange?.({ enable: true })} + > + Toggle + </button> </div> ), })) @@ -105,7 +114,13 @@ describe('GeneralChunkingOptions', () => { it('should call onDocFormChange with text mode when card switched', () => { const onDocFormChange = vi.fn() - render(<GeneralChunkingOptions {...defaultProps} isActive={false} onDocFormChange={onDocFormChange} />) + render( + <GeneralChunkingOptions + {...defaultProps} + isActive={false} + onDocFormChange={onDocFormChange} + />, + ) // OptionCard fires onSwitched which calls onDocFormChange(ChunkingMode.text) // Since isActive=false, clicking the card triggers the switch const titleEl = screen.getByText(`${ns}.stepTwo.general`) @@ -129,14 +144,26 @@ describe('GeneralChunkingOptions', () => { it('should toggle back to text mode from QA mode', () => { const onDocFormChange = vi.fn() - render(<GeneralChunkingOptions {...defaultProps} currentDocForm={ChunkingMode.qa} onDocFormChange={onDocFormChange} />) + render( + <GeneralChunkingOptions + {...defaultProps} + currentDocForm={ChunkingMode.qa} + onDocFormChange={onDocFormChange} + />, + ) fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`)) expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.text) }) it('should not toggle QA mode when hasCurrentDatasetDocForm is true', () => { const onDocFormChange = vi.fn() - render(<GeneralChunkingOptions {...defaultProps} hasCurrentDatasetDocForm onDocFormChange={onDocFormChange} />) + render( + <GeneralChunkingOptions + {...defaultProps} + hasCurrentDatasetDocForm + onDocFormChange={onDocFormChange} + />, + ) fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`)) expect(onDocFormChange).not.toHaveBeenCalled() }) @@ -160,7 +187,13 @@ describe('GeneralChunkingOptions', () => { it('should call onSummaryIndexSettingChange', () => { const onSummaryIndexSettingChange = vi.fn() - render(<GeneralChunkingOptions {...defaultProps} showSummaryIndexSetting onSummaryIndexSettingChange={onSummaryIndexSettingChange} />) + render( + <GeneralChunkingOptions + {...defaultProps} + showSummaryIndexSetting + onSummaryIndexSettingChange={onSummaryIndexSettingChange} + />, + ) fireEvent.click(screen.getByTestId('summary-toggle')) expect(onSummaryIndexSettingChange).toHaveBeenCalledWith({ enable: true }) }) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx index 195c3f9e9ffa26..785710f78d2c7b 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx @@ -7,12 +7,31 @@ import { IndexingType } from '../../hooks' import { IndexingModeSection } from '../indexing-mode-section' vi.mock('@/next/link', () => ({ - default: ({ children, href, ...props }: { children?: React.ReactNode, href?: string, className?: string }) => <a href={href} {...props}>{children}</a>, + default: ({ + children, + href, + ...props + }: { + children?: React.ReactNode + href?: string + className?: string + }) => ( + <a href={href} {...props}> + {children} + </a> + ), })) // Mock external domain components vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({ - default: ({ onChange, disabled }: { value?: RetrievalConfig, onChange?: (val: Record<string, unknown>) => void, disabled?: boolean }) => ( + default: ({ + onChange, + disabled, + }: { + value?: RetrievalConfig + onChange?: (val: Record<string, unknown>) => void + disabled?: boolean + }) => ( <div data-testid="retrieval-method-config" data-disabled={disabled}> <button onClick={() => onChange?.({ search_method: 'updated' })}>Change Retrieval</button> </div> @@ -20,7 +39,13 @@ vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({ })) vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({ - default: ({ disabled }: { value?: RetrievalConfig, onChange?: (val: Record<string, unknown>) => void, disabled?: boolean }) => ( + default: ({ + disabled, + }: { + value?: RetrievalConfig + onChange?: (val: Record<string, unknown>) => void + disabled?: boolean + }) => ( <div data-testid="economical-retrieval-config" data-disabled={disabled}> Economical Config </div> @@ -28,9 +53,17 @@ vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', ( })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ onSelect, readonly }: { onSelect?: (val: Record<string, string>) => void, readonly?: boolean }) => ( + default: ({ + onSelect, + readonly, + }: { + onSelect?: (val: Record<string, string>) => void + readonly?: boolean + }) => ( <div data-testid="model-selector" data-readonly={readonly}> - <button onClick={() => onSelect?.({ provider: 'openai', model: 'text-embedding-3-small' })}>Select Model</button> + <button onClick={() => onSelect?.({ provider: 'openai', model: 'text-embedding-3-small' })}> + Select Model + </button> </div> ), })) @@ -91,13 +124,25 @@ describe('IndexingModeSection', () => { }) it('should only show qualified option when hasSetIndexType and type is qualified', () => { - render(<IndexingModeSection {...defaultProps} hasSetIndexType indexType={IndexingType.QUALIFIED} />) + render( + <IndexingModeSection + {...defaultProps} + hasSetIndexType + indexType={IndexingType.QUALIFIED} + />, + ) expect(screen.getByText(`${ns}.stepTwo.qualified`))!.toBeInTheDocument() expect(screen.queryByText(`${ns}.stepTwo.economical`)).not.toBeInTheDocument() }) it('should only show economical option when hasSetIndexType and type is economical', () => { - render(<IndexingModeSection {...defaultProps} hasSetIndexType indexType={IndexingType.ECONOMICAL} />) + render( + <IndexingModeSection + {...defaultProps} + hasSetIndexType + indexType={IndexingType.ECONOMICAL} + />, + ) expect(screen.getByText(`${ns}.stepTwo.economical`))!.toBeInTheDocument() expect(screen.queryByText(`${ns}.stepTwo.qualified`)).not.toBeInTheDocument() }) @@ -121,9 +166,14 @@ describe('IndexingModeSection', () => { it('should call onEmbeddingModelChange when model selected', () => { const onEmbeddingModelChange = vi.fn() - render(<IndexingModeSection {...defaultProps} onEmbeddingModelChange={onEmbeddingModelChange} />) + render( + <IndexingModeSection {...defaultProps} onEmbeddingModelChange={onEmbeddingModelChange} />, + ) fireEvent.click(screen.getByText('Select Model')) - expect(onEmbeddingModelChange).toHaveBeenCalledWith({ provider: 'openai', model: 'text-embedding-3-small' }) + expect(onEmbeddingModelChange).toHaveBeenCalledWith({ + provider: 'openai', + model: 'text-embedding-3-small', + }) }) }) @@ -140,7 +190,9 @@ describe('IndexingModeSection', () => { it('should call onRetrievalConfigChange from qualified config', () => { const onRetrievalConfigChange = vi.fn() - render(<IndexingModeSection {...defaultProps} onRetrievalConfigChange={onRetrievalConfigChange} />) + render( + <IndexingModeSection {...defaultProps} onRetrievalConfigChange={onRetrievalConfigChange} />, + ) fireEvent.click(screen.getByText('Change Retrieval')) expect(onRetrievalConfigChange).toHaveBeenCalledWith({ search_method: 'updated' }) }) @@ -149,8 +201,16 @@ describe('IndexingModeSection', () => { describe('Index Type Switching', () => { it('should call onIndexTypeChange when switching to qualified', () => { const onIndexTypeChange = vi.fn() - render(<IndexingModeSection {...defaultProps} indexType={IndexingType.ECONOMICAL} onIndexTypeChange={onIndexTypeChange} />) - const qualifiedCard = screen.getByText(`${ns}.stepTwo.qualified`).closest('[class*="rounded-xl"]')! + render( + <IndexingModeSection + {...defaultProps} + indexType={IndexingType.ECONOMICAL} + onIndexTypeChange={onIndexTypeChange} + />, + ) + const qualifiedCard = screen + .getByText(`${ns}.stepTwo.qualified`) + .closest('[class*="rounded-xl"]')! fireEvent.click(qualifiedCard) expect(onIndexTypeChange).toHaveBeenCalledWith(IndexingType.QUALIFIED) }) @@ -173,12 +233,24 @@ describe('IndexingModeSection', () => { describe('High Quality Tip', () => { it('should show high quality tip when qualified is selected and not locked', () => { - render(<IndexingModeSection {...defaultProps} indexType={IndexingType.QUALIFIED} hasSetIndexType={false} />) + render( + <IndexingModeSection + {...defaultProps} + indexType={IndexingType.QUALIFIED} + hasSetIndexType={false} + />, + ) expect(screen.getByText(`${ns}.stepTwo.highQualityTip`))!.toBeInTheDocument() }) it('should not show high quality tip when index type is locked', () => { - render(<IndexingModeSection {...defaultProps} indexType={IndexingType.QUALIFIED} hasSetIndexType />) + render( + <IndexingModeSection + {...defaultProps} + indexType={IndexingType.QUALIFIED} + hasSetIndexType + />, + ) expect(screen.queryByText(`${ns}.stepTwo.highQualityTip`)).not.toBeInTheDocument() }) }) @@ -186,7 +258,13 @@ describe('IndexingModeSection', () => { describe('QA Confirm Dialog', () => { it('should call onQAConfirmDialogClose when cancel clicked', () => { const onClose = vi.fn() - render(<IndexingModeSection {...defaultProps} isQAConfirmDialogOpen onQAConfirmDialogClose={onClose} />) + render( + <IndexingModeSection + {...defaultProps} + isQAConfirmDialogOpen + onQAConfirmDialogClose={onClose} + />, + ) const cancelBtns = screen.getAllByText(`${ns}.stepTwo.cancel`) fireEvent.click(cancelBtns[0]!) expect(onClose).toHaveBeenCalled() @@ -194,7 +272,13 @@ describe('IndexingModeSection', () => { it('should call onQAConfirmDialogConfirm when confirm clicked', () => { const onConfirm = vi.fn() - render(<IndexingModeSection {...defaultProps} isQAConfirmDialogOpen onQAConfirmDialogConfirm={onConfirm} />) + render( + <IndexingModeSection + {...defaultProps} + isQAConfirmDialogOpen + onQAConfirmDialogConfirm={onConfirm} + />, + ) fireEvent.click(screen.getByText(`${ns}.stepTwo.switch`)) expect(onConfirm).toHaveBeenCalled() }) @@ -202,13 +286,29 @@ describe('IndexingModeSection', () => { describe('Dataset Settings Link', () => { it('should show settings link when economical and hasSetIndexType', () => { - render(<IndexingModeSection {...defaultProps} hasSetIndexType indexType={IndexingType.ECONOMICAL} datasetId="ds-123" />) + render( + <IndexingModeSection + {...defaultProps} + hasSetIndexType + indexType={IndexingType.ECONOMICAL} + datasetId="ds-123" + />, + ) expect(screen.getByText(`${ns}.stepTwo.datasetSettingLink`))!.toBeInTheDocument() - expect(screen.getByText(`${ns}.stepTwo.datasetSettingLink`).closest('a'))!.toHaveAttribute('href', '/datasets/ds-123/settings') + expect(screen.getByText(`${ns}.stepTwo.datasetSettingLink`).closest('a'))!.toHaveAttribute( + 'href', + '/datasets/ds-123/settings', + ) }) it('should show settings link under model selector when disabled', () => { - render(<IndexingModeSection {...defaultProps} isModelAndRetrievalConfigDisabled datasetId="ds-456" />) + render( + <IndexingModeSection + {...defaultProps} + isModelAndRetrievalConfigDisabled + datasetId="ds-456" + />, + ) const links = screen.getAllByText(`${ns}.stepTwo.datasetSettingLink`) expect(links.length).toBeGreaterThan(0) }) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx index d59e759ab18f5f..8665a164420fa8 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx @@ -63,7 +63,7 @@ describe('OptionCardHeader', () => { describe('OptionCard', () => { const defaultProps = { icon: <span data-testid="icon">icon</span>, - title: <span>Card Title</span> as React.ReactNode, + title: (<span>Card Title</span>) as React.ReactNode, description: 'Card description', } @@ -88,18 +88,14 @@ describe('OptionCard', () => { it('should not call onSwitched when already active', () => { const onSwitched = vi.fn() - const { container } = render( - <OptionCard {...defaultProps} isActive onSwitched={onSwitched} />, - ) + const { container } = render(<OptionCard {...defaultProps} isActive onSwitched={onSwitched} />) fireEvent.click(container.firstChild!) expect(onSwitched).not.toHaveBeenCalled() }) it('should not call onSwitched when disabled', () => { const onSwitched = vi.fn() - const { container } = render( - <OptionCard {...defaultProps} disabled onSwitched={onSwitched} />, - ) + const { container } = render(<OptionCard {...defaultProps} disabled onSwitched={onSwitched} />) fireEvent.click(container.firstChild!) expect(onSwitched).not.toHaveBeenCalled() }) @@ -130,7 +126,9 @@ describe('OptionCard', () => { it('should not apply selected border when noHighlight is true', () => { const { container } = render(<OptionCard {...defaultProps} isActive noHighlight />) - expect(container.firstChild).not.toHaveClass('border-components-option-card-option-selected-border') + expect(container.firstChild).not.toHaveClass( + 'border-components-option-card-option-selected-border', + ) }) it('should apply disabled opacity and pointer-events styles', () => { @@ -145,9 +143,7 @@ describe('OptionCard', () => { }) it('should forward custom style', () => { - const { container } = render( - <OptionCard {...defaultProps} style={{ maxWidth: '300px' }} />, - ) + const { container } = render(<OptionCard {...defaultProps} style={{ maxWidth: '300px' }} />) expect((container.firstChild as HTMLElement).style.maxWidth).toBe('300px') }) }) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx index 7f33b04f48d764..b4de17a4cfccce 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx @@ -6,9 +6,18 @@ import { ChunkingMode } from '@/models/datasets' import { ParentChildOptions } from '../parent-child-options' vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({ - default: ({ onSummaryIndexSettingChange }: { onSummaryIndexSettingChange?: (val: Record<string, unknown>) => void }) => ( + default: ({ + onSummaryIndexSettingChange, + }: { + onSummaryIndexSettingChange?: (val: Record<string, unknown>) => void + }) => ( <div data-testid="summary-index-setting"> - <button data-testid="summary-toggle" onClick={() => onSummaryIndexSettingChange?.({ enable: true })}>Toggle</button> + <button + data-testid="summary-toggle" + onClick={() => onSummaryIndexSettingChange?.({ enable: true })} + > + Toggle + </button> </div> ), })) @@ -114,7 +123,9 @@ describe('ParentChildOptions', () => { it('should call onDocFormChange with parentChild when card switched', () => { const onDocFormChange = vi.fn() - render(<ParentChildOptions {...defaultProps} isActive={false} onDocFormChange={onDocFormChange} />) + render( + <ParentChildOptions {...defaultProps} isActive={false} onDocFormChange={onDocFormChange} />, + ) const titleEl = screen.getByText(`${ns}.stepTwo.parentChild`) fireEvent.click(titleEl.closest('[class*="rounded-xl"]')!) expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.parentChild) @@ -122,7 +133,9 @@ describe('ParentChildOptions', () => { it('should call onChunkForContextChange when full-doc chosen', () => { const onChunkForContextChange = vi.fn() - render(<ParentChildOptions {...defaultProps} onChunkForContextChange={onChunkForContextChange} />) + render( + <ParentChildOptions {...defaultProps} onChunkForContextChange={onChunkForContextChange} />, + ) fireEvent.click(screen.getByText(`${ns}.stepTwo.fullDoc`)) expect(onChunkForContextChange).toHaveBeenCalledWith('full-doc') }) @@ -130,7 +143,13 @@ describe('ParentChildOptions', () => { it('should call onChunkForContextChange when paragraph chosen', () => { const onChunkForContextChange = vi.fn() const config = createParentChildConfig({ chunkForContext: 'full-doc' }) - render(<ParentChildOptions {...defaultProps} parentChildConfig={config} onChunkForContextChange={onChunkForContextChange} />) + render( + <ParentChildOptions + {...defaultProps} + parentChildConfig={config} + onChunkForContextChange={onChunkForContextChange} + />, + ) fireEvent.click(screen.getByText(`${ns}.stepTwo.paragraph`)) expect(onChunkForContextChange).toHaveBeenCalledWith('paragraph') }) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/preview-panel.spec.tsx index 5e61b53ad7bad3..704eb88bdcf39b 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/preview-panel.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/preview-panel.spec.tsx @@ -6,7 +6,9 @@ import { ChunkingMode, DataSourceType } from '@/models/datasets' import { PreviewPanel } from '../preview-panel' vi.mock('@/app/components/base/float-right-container', () => ({ - default: ({ children }: { children: React.ReactNode }) => <div data-testid="float-container">{children}</div>, + default: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="float-container">{children}</div> + ), })) vi.mock('@/app/components/base/badge', () => ({ @@ -14,22 +16,23 @@ vi.mock('@/app/components/base/badge', () => ({ })) vi.mock('@/app/components/base/skeleton', () => ({ - SkeletonContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="skeleton">{children}</div>, + SkeletonContainer: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="skeleton">{children}</div> + ), SkeletonPoint: () => <span />, SkeletonRectangle: () => <span />, SkeletonRow: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, })) vi.mock('../../../../chunk', () => ({ - ChunkContainer: ({ children, label }: { children: React.ReactNode, label: string }) => ( + ChunkContainer: ({ children, label }: { children: React.ReactNode; label: string }) => ( <div data-testid="chunk-container"> - {label} - : - {' '} - {children} + {label}: {children} </div> ), - QAPreview: ({ qa }: { qa: { question: string } }) => <div data-testid="qa-preview">{qa.question}</div>, + QAPreview: ({ qa }: { qa: { question: string } }) => ( + <div data-testid="qa-preview">{qa.question}</div> + ), })) vi.mock('../../../../common/document-picker/preview-document-picker', () => ({ @@ -41,22 +44,21 @@ vi.mock('../../../../documents/detail/completed/common/summary-label', () => ({ })) vi.mock('../../../../formatted-text/flavours/preview-slice', () => ({ - PreviewSlice: ({ label, text }: { label: string, text: string }) => ( + PreviewSlice: ({ label, text }: { label: string; text: string }) => ( <span data-testid="preview-slice"> - {label} - : - {' '} - {text} + {label}: {text} </span> ), })) vi.mock('../../../../formatted-text/formatted', () => ({ - FormattedText: ({ children }: { children: React.ReactNode }) => <p data-testid="formatted-text">{children}</p>, + FormattedText: ({ children }: { children: React.ReactNode }) => ( + <p data-testid="formatted-text">{children}</p> + ), })) vi.mock('../../../../preview/container', () => ({ - default: ({ children, header }: { children: React.ReactNode, header: React.ReactNode }) => ( + default: ({ children, header }: { children: React.ReactNode; header: React.ReactNode }) => ( <div data-testid="preview-container"> {header} {children} @@ -65,7 +67,7 @@ vi.mock('../../../../preview/container', () => ({ })) vi.mock('../../../../preview/header', () => ({ - PreviewHeader: ({ children, title }: { children: React.ReactNode, title: string }) => ( + PreviewHeader: ({ children, title }: { children: React.ReactNode; title: string }) => ( <div data-testid="preview-header"> {title} {children} @@ -96,7 +98,9 @@ describe('PreviewPanel', () => { it('should render preview header with title', () => { render(<PreviewPanel {...defaultProps} />) - expect(screen.getByTestId('preview-header')).toHaveTextContent('datasetCreation.stepTwo.preview') + expect(screen.getByTestId('preview-header')).toHaveTextContent( + 'datasetCreation.stepTwo.preview', + ) }) it('should render document picker', () => { @@ -128,9 +132,7 @@ describe('PreviewPanel', () => { it('should render QA preview', () => { const estimate: Partial<FileIndexingEstimateResponse> = { - qa_preview: [ - { question: 'Q1', answer: 'A1' }, - ], + qa_preview: [{ question: 'Q1', answer: 'A1' }], } render( <PreviewPanel @@ -144,9 +146,7 @@ describe('PreviewPanel', () => { it('should render parent-child preview', () => { const estimate: Partial<FileIndexingEstimateResponse> = { - preview: [ - { content: 'parent chunk', child_chunks: ['child1', 'child2'], summary: '' }, - ], + preview: [{ content: 'parent chunk', child_chunks: ['child1', 'child2'], summary: '' }], } render( <PreviewPanel diff --git a/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx index 834a3921a11ff4..16649ea04a40c5 100644 --- a/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx +++ b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx @@ -1,13 +1,13 @@ 'use client' import type { FC } from 'react' -import type { PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets' +import type { + PreProcessingRule, + SummaryIndexSetting as SummaryIndexSettingType, +} from '@/models/datasets' import { Button } from '@langgenius/dify-ui/button' import { Checkbox } from '@langgenius/dify-ui/checkbox' -import { - RiAlertFill, - RiSearchEyeLine, -} from '@remixicon/react' +import { RiAlertFill, RiSearchEyeLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import { Infotip } from '@/app/components/base/infotip' @@ -85,9 +85,9 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({ const getRuleName = (key: string): string => { const ruleNameMap: Record<string, string> = { - remove_extra_spaces: t($ => $['stepTwo.removeExtraSpaces'], { ns: 'datasetCreation' }), - remove_urls_emails: t($ => $['stepTwo.removeUrlEmails'], { ns: 'datasetCreation' }), - remove_stopwords: t($ => $['stepTwo.removeStopwords'], { ns: 'datasetCreation' }), + remove_extra_spaces: t(($) => $['stepTwo.removeExtraSpaces'], { ns: 'datasetCreation' }), + remove_urls_emails: t(($) => $['stepTwo.removeUrlEmails'], { ns: 'datasetCreation' }), + remove_stopwords: t(($) => $['stepTwo.removeStopwords'], { ns: 'datasetCreation' }), } return ruleNameMap[key] ?? key } @@ -95,76 +95,70 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({ return ( <OptionCard className="mb-2 bg-background-section" - title={t($ => $['stepTwo.general'], { ns: 'datasetCreation' })} - icon={<img width={20} height={20} src={SettingCog.src} alt={t($ => $['stepTwo.general'], { ns: 'datasetCreation' })} />} + title={t(($) => $['stepTwo.general'], { ns: 'datasetCreation' })} + icon={ + <img + width={20} + height={20} + src={SettingCog.src} + alt={t(($) => $['stepTwo.general'], { ns: 'datasetCreation' })} + /> + } activeHeaderClassName="bg-dataset-option-card-blue-gradient" - description={t($ => $['stepTwo.generalTip'], { ns: 'datasetCreation' })} + description={t(($) => $['stepTwo.generalTip'], { ns: 'datasetCreation' })} isActive={isActive} onSwitched={() => onDocFormChange(ChunkingMode.text)} - actions={( + actions={ <> <Button variant="secondary-accent" onClick={onPreview}> <RiSearchEyeLine className="mr-0.5 size-4" /> - {t($ => $['stepTwo.previewChunk'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.previewChunk'], { ns: 'datasetCreation' })} </Button> <Button variant="ghost" onClick={onReset}> - {t($ => $['stepTwo.reset'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.reset'], { ns: 'datasetCreation' })} </Button> </> - )} + } noHighlight={isInUpload && isNotUploadInEmptyDataset} > <div className="flex flex-col gap-y-4"> <div className="flex gap-3"> <DelimiterInput value={segmentIdentifier} - onChange={e => onSegmentIdentifierChange(e.target.value)} + onChange={(e) => onSegmentIdentifierChange(e.target.value)} /> <MaxLengthInput unit="characters" value={maxChunkLength} onChange={onMaxChunkLengthChange} /> - <OverlapInput - unit="characters" - value={overlap} - min={1} - onChange={onOverlapChange} - /> + <OverlapInput unit="characters" value={overlap} min={1} onChange={onOverlapChange} /> </div> <div className="flex w-full flex-col"> <div className="flex items-center gap-x-2"> <div className="inline-flex shrink-0"> - <TextLabel>{t($ => $['stepTwo.rules'], { ns: 'datasetCreation' })}</TextLabel> + <TextLabel>{t(($) => $['stepTwo.rules'], { ns: 'datasetCreation' })}</TextLabel> </div> <Divider className="grow" bgStyle="gradient" /> </div> <div className="mt-1"> - {rules.map(rule => ( - <label - key={rule.id} - className={`${s.ruleItem} cursor-pointer`} - > - <Checkbox - checked={rule.enabled} - onCheckedChange={() => onRuleToggle(rule.id)} - /> + {rules.map((rule) => ( + <label key={rule.id} className={`${s.ruleItem} cursor-pointer`}> + <Checkbox checked={rule.enabled} onCheckedChange={() => onRuleToggle(rule.id)} /> <span className="ml-2 system-sm-regular text-text-secondary"> {getRuleName(rule.id)} </span> </label> ))} - { - showSummaryIndexSetting && IS_CE_EDITION && ( - <div className="mt-3"> - <SummaryIndexSetting - entry="create-document" - summaryIndexSetting={summaryIndexSetting} - onSummaryIndexSettingChange={onSummaryIndexSettingChange} - /> - </div> - ) - } + {showSummaryIndexSetting && IS_CE_EDITION && ( + <div className="mt-3"> + <SummaryIndexSetting + entry="create-document" + summaryIndexSetting={summaryIndexSetting} + onSummaryIndexSettingChange={onSummaryIndexSettingChange} + /> + </div> + )} {IS_CE_EDITION && ( <> <Divider type="horizontal" className="my-4 bg-divider-subtle" /> @@ -176,16 +170,13 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({ checked={currentDocForm === ChunkingMode.qa} disabled={hasCurrentDatasetDocForm} onCheckedChange={() => { - if (hasCurrentDatasetDocForm) - return - if (currentDocForm === ChunkingMode.qa) - onDocFormChange(ChunkingMode.text) - else - onDocFormChange(ChunkingMode.qa) + if (hasCurrentDatasetDocForm) return + if (currentDocForm === ChunkingMode.qa) onDocFormChange(ChunkingMode.text) + else onDocFormChange(ChunkingMode.qa) }} /> <span className="ml-2 system-sm-regular text-text-secondary"> - {t($ => $['stepTwo.useQALanguage'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.useQALanguage'], { ns: 'datasetCreation' })} </span> </label> <LanguageSelect @@ -194,22 +185,23 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({ disabled={currentDocForm !== ChunkingMode.qa} /> <Infotip - aria-label={t($ => $['stepTwo.QATip'], { ns: 'datasetCreation' })} + aria-label={t(($) => $['stepTwo.QATip'], { ns: 'datasetCreation' })} className="size-3.5" > - {t($ => $['stepTwo.QATip'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.QATip'], { ns: 'datasetCreation' })} </Infotip> </div> {currentDocForm === ChunkingMode.qa && ( <div style={{ - background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.1) 0%, rgba(255, 255, 255, 0.00) 100%)', + background: + 'linear-gradient(92deg, rgba(247, 144, 9, 0.1) 0%, rgba(255, 255, 255, 0.00) 100%)', }} className="mt-2 flex h-10 items-center gap-2 rounded-xl border border-components-panel-border px-3 text-xs shadow-xs backdrop-blur-[5px]" > <RiAlertFill className="size-4 text-text-warning-secondary" /> <span className="system-xs-medium text-text-primary"> - {t($ => $['stepTwo.QATip'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.QATip'], { ns: 'datasetCreation' })} </span> </div> )} diff --git a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx index 894e1d652cdd66..afe42470404651 100644 --- a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx +++ b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx @@ -1,7 +1,10 @@ 'use client' import type { FC } from 'react' -import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + DefaultModel, + Model, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { RetrievalConfig } from '@/types/app' import { AlertDialog, @@ -73,40 +76,39 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({ const getIndexingTechnique = () => indexType const economicalDisabledReason = (() => { if (docForm === ChunkingMode.qa) - return t($ => $['stepTwo.notAvailableForQA'], { ns: 'datasetCreation' }) + return t(($) => $['stepTwo.notAvailableForQA'], { ns: 'datasetCreation' }) if (docForm !== ChunkingMode.text) - return t($ => $['stepTwo.notAvailableForParentChild'], { ns: 'datasetCreation' }) + return t(($) => $['stepTwo.notAvailableForParentChild'], { ns: 'datasetCreation' }) })() return ( <> {/* Index Mode */} <div className="mb-1 system-md-semibold text-text-secondary"> - {t($ => $['stepTwo.indexMode'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.indexMode'], { ns: 'datasetCreation' })} </div> <AlertDialog open={isQAConfirmDialogOpen} onOpenChange={(open) => { - if (!open) - onQAConfirmDialogClose() + if (!open) onQAConfirmDialogClose() }} > <AlertDialogContent className="w-[432px]"> <div className="flex flex-col gap-2 p-6 pb-4"> <AlertDialogTitle className="text-lg/7 font-semibold text-text-primary"> - {t($ => $['stepTwo.qaSwitchHighQualityTipTitle'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.qaSwitchHighQualityTipTitle'], { ns: 'datasetCreation' })} </AlertDialogTitle> <AlertDialogDescription className="text-sm/5 text-text-secondary"> - {t($ => $['stepTwo.qaSwitchHighQualityTipContent'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.qaSwitchHighQualityTipContent'], { ns: 'datasetCreation' })} </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton variant="secondary"> - {t($ => $['stepTwo.cancel'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.cancel'], { ns: 'datasetCreation' })} </AlertDialogCancelButton> <AlertDialogConfirmButton tone="default" onClick={onQAConfirmDialogConfirm}> - {t($ => $['stepTwo.switch'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.switch'], { ns: 'datasetCreation' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> @@ -116,26 +118,26 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({ {(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.QUALIFIED)) && ( <OptionCard className="flex-1 self-stretch" - title={( + title={ <div className="flex items-center"> - {t($ => $['stepTwo.qualified'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.qualified'], { ns: 'datasetCreation' })} <Badge className={cn( 'ml-1 h-[18px]', - (!hasSetIndexType && indexType === IndexingType.QUALIFIED) + !hasSetIndexType && indexType === IndexingType.QUALIFIED ? 'border-text-accent-secondary text-text-accent-secondary' : '', )} uppercase > - {t($ => $['stepTwo.recommend'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.recommend'], { ns: 'datasetCreation' })} </Badge> <span className="ml-auto"> {!hasSetIndexType && <span className={cn(s.radio)} />} </span> </div> - )} - description={t($ => $['stepTwo.qualifiedTip'], { ns: 'datasetCreation' })} + } + description={t(($) => $['stepTwo.qualifiedTip'], { ns: 'datasetCreation' })} icon={<img src={indexMethodIcon.high_quality} alt="" />} isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED} disabled={hasSetIndexType} @@ -147,8 +149,11 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({ {(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.ECONOMICAL)) && ( <OptionCard className="flex-1 self-stretch" - title={t($ => $['stepTwo.economical'], { ns: 'datasetCreation' })} - description={economicalDisabledReason || t($ => $['stepTwo.economicalTip'], { ns: 'datasetCreation' })} + title={t(($) => $['stepTwo.economical'], { ns: 'datasetCreation' })} + description={ + economicalDisabledReason || + t(($) => $['stepTwo.economicalTip'], { ns: 'datasetCreation' }) + } icon={<img src={indexMethodIcon.economical} alt="" />} isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL} disabled={hasSetIndexType || !!economicalDisabledReason} @@ -165,7 +170,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({ <AlertTriangle className="size-4 text-text-warning-secondary" /> </div> <span className="system-xs-medium text-text-primary"> - {t($ => $['stepTwo.highQualityTip'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.highQualityTip'], { ns: 'datasetCreation' })} </span> </div> )} @@ -173,9 +178,9 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({ {/* Economical index setting tip */} {hasSetIndexType && indexType === IndexingType.ECONOMICAL && ( <div className="mt-2 system-xs-medium text-text-tertiary"> - {t($ => $['stepTwo.indexSettingTip'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.indexSettingTip'], { ns: 'datasetCreation' })} <Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}> - {t($ => $['stepTwo.datasetSettingLink'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.datasetSettingLink'], { ns: 'datasetCreation' })} </Link> </div> )} @@ -183,8 +188,13 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({ {/* Embedding model */} {indexType === IndexingType.QUALIFIED && ( <div className="mt-5"> - <div className={cn('mb-1 system-md-semibold text-text-secondary', datasetId && 'flex items-center justify-between')}> - {t($ => $['form.embeddingModel'], { ns: 'datasetSettings' })} + <div + className={cn( + 'mb-1 system-md-semibold text-text-secondary', + datasetId && 'flex items-center justify-between', + )} + > + {t(($) => $['form.embeddingModel'], { ns: 'datasetSettings' })} </div> <ModelSelector readonly={isModelAndRetrievalConfigDisabled} @@ -195,9 +205,9 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({ /> {isModelAndRetrievalConfigDisabled && ( <div className="mt-2 system-xs-medium text-text-tertiary"> - {t($ => $['stepTwo.indexSettingTip'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.indexSettingTip'], { ns: 'datasetCreation' })} <Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}> - {t($ => $['stepTwo.datasetSettingLink'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.datasetSettingLink'], { ns: 'datasetCreation' })} </Link> </div> )} @@ -208,48 +218,49 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({ {/* Retrieval Method Config */} <div> - {!isModelAndRetrievalConfigDisabled - ? ( - <div className="mb-1"> - <div className="mb-0.5 system-md-semibold text-text-secondary"> - {t($ => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} - </div> - <div className="body-xs-regular text-text-tertiary"> - <a - target="_blank" - rel="noopener noreferrer" - href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')} - className="text-text-accent" - > - {t($ => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })} - </a> - {t($ => $['form.retrievalSetting.longDescription'], { ns: 'datasetSettings' })} - </div> - </div> - ) - : ( - <div className={cn('mb-0.5 system-md-semibold text-text-secondary', 'flex items-center justify-between')}> - <div>{t($ => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })}</div> - </div> + {!isModelAndRetrievalConfigDisabled ? ( + <div className="mb-1"> + <div className="mb-0.5 system-md-semibold text-text-secondary"> + {t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} + </div> + <div className="body-xs-regular text-text-tertiary"> + <a + target="_blank" + rel="noopener noreferrer" + href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')} + className="text-text-accent" + > + {t(($) => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })} + </a> + {t(($) => $['form.retrievalSetting.longDescription'], { ns: 'datasetSettings' })} + </div> + </div> + ) : ( + <div + className={cn( + 'mb-0.5 system-md-semibold text-text-secondary', + 'flex items-center justify-between', )} + > + <div>{t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })}</div> + </div> + )} <div> - {getIndexingTechnique() === IndexingType.QUALIFIED - ? ( - <RetrievalMethodConfig - disabled={isModelAndRetrievalConfigDisabled} - value={retrievalConfig} - onChange={onRetrievalConfigChange} - showMultiModalTip={showMultiModalTip} - /> - ) - : ( - <EconomicalRetrievalMethodConfig - disabled={isModelAndRetrievalConfigDisabled} - value={retrievalConfig} - onChange={onRetrievalConfigChange} - /> - )} + {getIndexingTechnique() === IndexingType.QUALIFIED ? ( + <RetrievalMethodConfig + disabled={isModelAndRetrievalConfigDisabled} + value={retrievalConfig} + onChange={onRetrievalConfigChange} + showMultiModalTip={showMultiModalTip} + /> + ) : ( + <EconomicalRetrievalMethodConfig + disabled={isModelAndRetrievalConfigDisabled} + value={retrievalConfig} + onChange={onRetrievalConfigChange} + /> + )} </div> </div> </> diff --git a/web/app/components/datasets/create/step-two/components/inputs.tsx b/web/app/components/datasets/create/step-two/components/inputs.tsx index 7fea8a3e0e7825..afb2788a26eb20 100644 --- a/web/app/components/datasets/create/step-two/components/inputs.tsx +++ b/web/app/components/datasets/create/step-two/components/inputs.tsx @@ -1,4 +1,8 @@ -import type { NumberFieldInputProps, NumberFieldProps, NumberFieldSize } from '@langgenius/dify-ui/number-field' +import type { + NumberFieldInputProps, + NumberFieldProps, + NumberFieldSize, +} from '@langgenius/dify-ui/number-field' import type { FC, PropsWithChildren, ReactNode } from 'react' import type { InputProps } from '@/app/components/base/input' import { @@ -17,7 +21,11 @@ import Input from '@/app/components/base/input' import { env } from '@/env' const TextLabel: FC<PropsWithChildren> = (props) => { - return <label className="text-xs leading-none font-semibold text-text-secondary">{props.children}</label> + return ( + <label className="text-xs leading-none font-semibold text-text-secondary"> + {props.children} + </label> + ) } const FormField: FC<PropsWithChildren<{ label: ReactNode }>> = (props) => { @@ -29,31 +37,40 @@ const FormField: FC<PropsWithChildren<{ label: ReactNode }>> = (props) => { ) } -export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({ tooltip, onChange, value, ...rest }) => { +export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({ + tooltip, + onChange, + value, + ...rest +}) => { const { t } = useTranslation() const isComposing = useRef(false) const [compositionValue, setCompositionValue] = useState('') return ( - <FormField label={( - <div className="mb-1 flex items-center"> - <span className="mr-0.5 system-sm-semibold">{t($ => $['stepTwo.separator'], { ns: 'datasetCreation' })}</span> - <Infotip aria-label={tooltip || t($ => $['stepTwo.separatorTip'], { ns: 'datasetCreation' })} popupClassName="max-w-[200px]"> - {tooltip || t($ => $['stepTwo.separatorTip'], { ns: 'datasetCreation' })} - </Infotip> - </div> - )} + <FormField + label={ + <div className="mb-1 flex items-center"> + <span className="mr-0.5 system-sm-semibold"> + {t(($) => $['stepTwo.separator'], { ns: 'datasetCreation' })} + </span> + <Infotip + aria-label={tooltip || t(($) => $['stepTwo.separatorTip'], { ns: 'datasetCreation' })} + popupClassName="max-w-[200px]" + > + {tooltip || t(($) => $['stepTwo.separatorTip'], { ns: 'datasetCreation' })} + </Infotip> + </div> + } > <Input type="text" className="h-9" - placeholder={t($ => $['stepTwo.separatorPlaceholder'], { ns: 'datasetCreation' })!} + placeholder={t(($) => $['stepTwo.separatorPlaceholder'], { ns: 'datasetCreation' })!} value={isComposing.current ? compositionValue : value} onChange={(e) => { - if (isComposing.current) - setCompositionValue(e.target.value) - else - onChange?.(e) + if (isComposing.current) setCompositionValue(e.target.value) + else onChange?.(e) }} onCompositionStart={() => { isComposing.current = true @@ -63,7 +80,10 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({ tooltip, const committed = e.currentTarget.value isComposing.current = false setCompositionValue('') - onChange?.({ ...e, target: { ...e.target, value: committed } } as unknown as React.ChangeEvent<HTMLInputElement>) + onChange?.({ + ...e, + target: { ...e.target, value: committed }, + } as unknown as React.ChangeEvent<HTMLInputElement>) }} {...rest} /> @@ -71,12 +91,13 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({ tooltip, ) } -type CompoundNumberInputProps = Omit<NumberFieldProps, 'children' | 'className' | 'onValueChange'> & Omit<NumberFieldInputProps, 'children' | 'size' | 'onChange'> & { - label: string - unit?: ReactNode - size?: NumberFieldSize - onChange: (value: number) => void -} +type CompoundNumberInputProps = Omit<NumberFieldProps, 'children' | 'className' | 'onValueChange'> & + Omit<NumberFieldInputProps, 'children' | 'size' | 'onChange'> & { + label: string + unit?: ReactNode + size?: NumberFieldSize + onChange: (value: number) => void + } function CompoundNumberInput({ label, @@ -86,7 +107,20 @@ function CompoundNumberInput({ className, ...props }: CompoundNumberInputProps) { - const { value, defaultValue, min, max, step, disabled, readOnly, required, id, name, onBlur, ...inputProps } = props + const { + value, + defaultValue, + min, + max, + step, + disabled, + readOnly, + required, + id, + name, + onBlur, + ...inputProps + } = props const emptyValue = defaultValue ?? min ?? 0 return ( @@ -101,7 +135,7 @@ function CompoundNumberInput({ required={required} id={id} name={name} - onValueChange={value => onChange(value ?? emptyValue)} + onValueChange={(value) => onChange(value ?? emptyValue)} > <NumberFieldGroup size={size}> <NumberFieldInput @@ -111,11 +145,7 @@ function CompoundNumberInput({ className={className} onBlur={onBlur} /> - {Boolean(unit) && ( - <NumberFieldUnit size={size}> - {unit} - </NumberFieldUnit> - )} + {Boolean(unit) && <NumberFieldUnit size={size}>{unit}</NumberFieldUnit>} <NumberFieldControls> <NumberFieldIncrement size={size} /> <NumberFieldDecrement size={size} /> @@ -131,14 +161,9 @@ export const MaxLengthInput: FC<LabeledCompoundNumberInputProps> = (props) => { const maxValue = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH const { t } = useTranslation() - const label = t($ => $['stepTwo.maxLength'], { ns: 'datasetCreation' }) + const label = t(($) => $['stepTwo.maxLength'], { ns: 'datasetCreation' }) return ( - <FormField label={( - <div className="mb-1 system-sm-semibold"> - {label} - </div> - )} - > + <FormField label={<div className="mb-1 system-sm-semibold">{label}</div>}> <CompoundNumberInput label={label} size="large" @@ -153,16 +178,20 @@ export const MaxLengthInput: FC<LabeledCompoundNumberInputProps> = (props) => { export const OverlapInput: FC<LabeledCompoundNumberInputProps> = (props) => { const { t } = useTranslation() - const label = t($ => $['stepTwo.overlap'], { ns: 'datasetCreation' }) + const label = t(($) => $['stepTwo.overlap'], { ns: 'datasetCreation' }) return ( - <FormField label={( - <div className="mb-1 flex items-center"> - <span className="system-sm-semibold">{label}</span> - <Infotip aria-label={t($ => $['stepTwo.overlapTip'], { ns: 'datasetCreation' })} popupClassName="max-w-[200px]"> - {t($ => $['stepTwo.overlapTip'], { ns: 'datasetCreation' })} - </Infotip> - </div> - )} + <FormField + label={ + <div className="mb-1 flex items-center"> + <span className="system-sm-semibold">{label}</span> + <Infotip + aria-label={t(($) => $['stepTwo.overlapTip'], { ns: 'datasetCreation' })} + popupClassName="max-w-[200px]" + > + {t(($) => $['stepTwo.overlapTip'], { ns: 'datasetCreation' })} + </Infotip> + </div> + } > <CompoundNumberInput label={label} diff --git a/web/app/components/datasets/create/step-two/components/option-card.tsx b/web/app/components/datasets/create/step-two/components/option-card.tsx index c00baa02456439..8ada7d0586747e 100644 --- a/web/app/components/datasets/create/step-two/components/option-card.tsx +++ b/web/app/components/datasets/create/step-two/components/option-card.tsx @@ -1,9 +1,19 @@ import type { ComponentProps, FC, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' -const TriangleArrow: FC<ComponentProps<'svg'>> = props => ( - <svg xmlns="http://www.w3.org/2000/svg" width="24" height="11" viewBox="0 0 24 11" fill="none" {...props}> - <path d="M9.87868 1.12132C11.0503 -0.0502525 12.9497 -0.0502525 14.1213 1.12132L23.3137 10.3137H0.686292L9.87868 1.12132Z" fill="currentColor" /> +const TriangleArrow: FC<ComponentProps<'svg'>> = (props) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="11" + viewBox="0 0 24 11" + fill="none" + {...props} + > + <path + d="M9.87868 1.12132C11.0503 -0.0502525 12.9497 -0.0502525 14.1213 1.12132L23.3137 10.3137H0.686292L9.87868 1.12132Z" + fill="currentColor" + /> </svg> ) @@ -20,9 +30,23 @@ type OptionCardHeaderProps = { export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => { const { icon, title, description, isActive, activeClassName, effectImg, disabled } = props return ( - <div className={cn('relative flex flex-1 overflow-hidden rounded-t-xl', isActive && activeClassName, !disabled && 'cursor-pointer')}> + <div + className={cn( + 'relative flex flex-1 overflow-hidden rounded-t-xl', + isActive && activeClassName, + !disabled && 'cursor-pointer', + )} + > <div className="relative flex size-14 items-center justify-center overflow-hidden"> - {isActive && effectImg && <img src={effectImg} className="absolute top-0 left-0 size-full" alt="" width={56} height={56} />} + {isActive && effectImg && ( + <img + src={effectImg} + className="absolute top-0 left-0 size-full" + alt="" + width={56} + height={56} + /> + )} <div className="p-1"> <div className="flex action-btn-l justify-center border border-components-panel-border-subtle bg-background-default-dodge shadow-md"> {icon} @@ -30,7 +54,10 @@ export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => { </div> </div> <TriangleArrow - className={cn('absolute -bottom-1.5 left-4 text-transparent', isActive && 'text-components-panel-bg')} + className={cn( + 'absolute -bottom-1.5 left-4 text-transparent', + isActive && 'text-components-panel-bg', + )} /> <div className="flex-1 space-y-0.5 py-3 pr-4"> <div className="system-md-semibold text-text-secondary">{title}</div> @@ -54,24 +81,38 @@ type OptionCardProps = { disabled?: boolean } & Omit<ComponentProps<'div'>, 'title' | 'onClick'> -export const OptionCard: FC<OptionCardProps> = ( - { - ref, - ...props - }, -) => { - const { icon, className, title, description, isActive, children, actions, activeHeaderClassName, style, effectImg, onSwitched, noHighlight, disabled, ...rest } = props +export const OptionCard: FC<OptionCardProps> = ({ ref, ...props }) => { + const { + icon, + className, + title, + description, + isActive, + children, + actions, + activeHeaderClassName, + style, + effectImg, + onSwitched, + noHighlight, + disabled, + ...rest + } = props return ( <div - className={cn('flex flex-col rounded-xl bg-components-option-card-option-bg shadow-xs', (isActive && !noHighlight) - ? 'border-[1.5px] border-components-option-card-option-selected-border' - : 'border border-components-option-card-option-border', disabled && 'pointer-events-none opacity-50', className)} + className={cn( + 'flex flex-col rounded-xl bg-components-option-card-option-bg shadow-xs', + isActive && !noHighlight + ? 'border-[1.5px] border-components-option-card-option-selected-border' + : 'border border-components-option-card-option-border', + disabled && 'pointer-events-none opacity-50', + className, + )} style={{ ...style, }} onClick={() => { - if (!isActive && !disabled) - onSwitched?.() + if (!isActive && !disabled) onSwitched?.() }} {...rest} ref={ref} @@ -89,11 +130,7 @@ export const OptionCard: FC<OptionCardProps> = ( {!!(isActive && (children || actions)) && ( <div className="rounded-b-xl bg-components-panel-bg px-4 py-3"> {children} - {!!actions && ( - <div className="mt-4 flex gap-2"> - {actions} - </div> - )} + {!!actions && <div className="mt-4 flex gap-2">{actions}</div>} </div> )} </div> diff --git a/web/app/components/datasets/create/step-two/components/parent-child-options.tsx b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx index 5e44c9cd85882e..c5fe1374680f63 100644 --- a/web/app/components/datasets/create/step-two/components/parent-child-options.tsx +++ b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx @@ -2,7 +2,11 @@ import type { FC } from 'react' import type { ParentChildConfig } from '../hooks' -import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets' +import type { + ParentMode, + PreProcessingRule, + SummaryIndexSetting as SummaryIndexSettingType, +} from '@/models/datasets' import { Button } from '@langgenius/dify-ui/button' import { Checkbox } from '@langgenius/dify-ui/checkbox' import { RadioGroup } from '@langgenius/dify-ui/radio' @@ -77,34 +81,34 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({ const getRuleName = (key: string): string => { const ruleNameMap: Record<string, string> = { - remove_extra_spaces: t($ => $['stepTwo.removeExtraSpaces'], { ns: 'datasetCreation' }), - remove_urls_emails: t($ => $['stepTwo.removeUrlEmails'], { ns: 'datasetCreation' }), - remove_stopwords: t($ => $['stepTwo.removeStopwords'], { ns: 'datasetCreation' }), + remove_extra_spaces: t(($) => $['stepTwo.removeExtraSpaces'], { ns: 'datasetCreation' }), + remove_urls_emails: t(($) => $['stepTwo.removeUrlEmails'], { ns: 'datasetCreation' }), + remove_stopwords: t(($) => $['stepTwo.removeStopwords'], { ns: 'datasetCreation' }), } return ruleNameMap[key] ?? key } return ( <OptionCard - title={t($ => $['stepTwo.parentChild'], { ns: 'datasetCreation' })} + title={t(($) => $['stepTwo.parentChild'], { ns: 'datasetCreation' })} icon={<ParentChildChunk className="h-[20px] w-[20px]" />} effectImg={BlueEffect.src} className="text-util-colors-blue-light-blue-light-500" activeHeaderClassName="bg-dataset-option-card-blue-gradient" - description={t($ => $['stepTwo.parentChildTip'], { ns: 'datasetCreation' })} + description={t(($) => $['stepTwo.parentChildTip'], { ns: 'datasetCreation' })} isActive={isActive} onSwitched={() => onDocFormChange(ChunkingMode.parentChild)} - actions={( + actions={ <> <Button variant="secondary-accent" onClick={onPreview}> <RiSearchEyeLine className="mr-0.5 size-4" /> - {t($ => $['stepTwo.previewChunk'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.previewChunk'], { ns: 'datasetCreation' })} </Button> <Button variant="ghost" onClick={onReset}> - {t($ => $['stepTwo.reset'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.reset'], { ns: 'datasetCreation' })} </Button> </> - )} + } noHighlight={isInUpload && isNotUploadInEmptyDataset} > <div className="flex flex-col gap-4"> @@ -112,27 +116,31 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({ <div> <div className="flex items-center gap-x-2"> <div className="inline-flex shrink-0"> - <TextLabel>{t($ => $['stepTwo.parentChunkForContext'], { ns: 'datasetCreation' })}</TextLabel> + <TextLabel> + {t(($) => $['stepTwo.parentChunkForContext'], { ns: 'datasetCreation' })} + </TextLabel> </div> <Divider className="grow" bgStyle="gradient" /> </div> <RadioGroup<ParentMode> - aria-label={t($ => $['stepTwo.parentChunkForContext'], { ns: 'datasetCreation' })} + aria-label={t(($) => $['stepTwo.parentChunkForContext'], { ns: 'datasetCreation' })} value={parentChildConfig.chunkForContext} - onValueChange={value => onChunkForContextChange(value)} + onValueChange={(value) => onChunkForContextChange(value)} className="mt-1 flex-col items-stretch gap-2" > <RadioCard<ParentMode> value="paragraph" icon={<img src={Note.src} alt="" />} - title={t($ => $['stepTwo.paragraph'], { ns: 'datasetCreation' })} - description={t($ => $['stepTwo.paragraphTip'], { ns: 'datasetCreation' })} - chosenConfig={( + title={t(($) => $['stepTwo.paragraph'], { ns: 'datasetCreation' })} + description={t(($) => $['stepTwo.paragraphTip'], { ns: 'datasetCreation' })} + chosenConfig={ <div className="flex gap-3"> <DelimiterInput value={parentChildConfig.parent.delimiter} - tooltip={t($ => $['stepTwo.parentChildDelimiterTip'], { ns: 'datasetCreation' })!} - onChange={e => onParentDelimiterChange(e.target.value)} + tooltip={ + t(($) => $['stepTwo.parentChildDelimiterTip'], { ns: 'datasetCreation' })! + } + onChange={(e) => onParentDelimiterChange(e.target.value)} /> <MaxLengthInput unit="characters" @@ -140,13 +148,13 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({ onChange={onParentMaxLengthChange} /> </div> - )} + } /> <RadioCard<ParentMode> value="full-doc" icon={<img src={FileList.src} alt="" />} - title={t($ => $['stepTwo.fullDoc'], { ns: 'datasetCreation' })} - description={t($ => $['stepTwo.fullDocTip'], { ns: 'datasetCreation' })} + title={t(($) => $['stepTwo.fullDoc'], { ns: 'datasetCreation' })} + description={t(($) => $['stepTwo.fullDocTip'], { ns: 'datasetCreation' })} /> </RadioGroup> </div> @@ -155,15 +163,19 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({ <div> <div className="flex items-center gap-x-2"> <div className="inline-flex shrink-0"> - <TextLabel>{t($ => $['stepTwo.childChunkForRetrieval'], { ns: 'datasetCreation' })}</TextLabel> + <TextLabel> + {t(($) => $['stepTwo.childChunkForRetrieval'], { ns: 'datasetCreation' })} + </TextLabel> </div> <Divider className="grow" bgStyle="gradient" /> </div> <div className="mt-1 flex gap-3"> <DelimiterInput value={parentChildConfig.child.delimiter} - tooltip={t($ => $['stepTwo.parentChildChunkDelimiterTip'], { ns: 'datasetCreation' })!} - onChange={e => onChildDelimiterChange(e.target.value)} + tooltip={ + t(($) => $['stepTwo.parentChildChunkDelimiterTip'], { ns: 'datasetCreation' })! + } + onChange={(e) => onChildDelimiterChange(e.target.value)} /> <MaxLengthInput unit="characters" @@ -177,36 +189,28 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({ <div> <div className="flex items-center gap-x-2"> <div className="inline-flex shrink-0"> - <TextLabel>{t($ => $['stepTwo.rules'], { ns: 'datasetCreation' })}</TextLabel> + <TextLabel>{t(($) => $['stepTwo.rules'], { ns: 'datasetCreation' })}</TextLabel> </div> <Divider className="grow" bgStyle="gradient" /> </div> <div className="mt-1"> - {rules.map(rule => ( - <label - key={rule.id} - className={`${s.ruleItem} cursor-pointer`} - > - <Checkbox - checked={rule.enabled} - onCheckedChange={() => onRuleToggle(rule.id)} - /> + {rules.map((rule) => ( + <label key={rule.id} className={`${s.ruleItem} cursor-pointer`}> + <Checkbox checked={rule.enabled} onCheckedChange={() => onRuleToggle(rule.id)} /> <span className="ml-2 system-sm-regular text-text-secondary"> {getRuleName(rule.id)} </span> </label> ))} - { - showSummaryIndexSetting && IS_CE_EDITION && ( - <div className="mt-3"> - <SummaryIndexSetting - entry="create-document" - summaryIndexSetting={summaryIndexSetting} - onSummaryIndexSettingChange={onSummaryIndexSettingChange} - /> - </div> - ) - } + {showSummaryIndexSetting && IS_CE_EDITION && ( + <div className="mt-3"> + <SummaryIndexSetting + entry="create-document" + summaryIndexSetting={summaryIndexSetting} + onSummaryIndexSettingChange={onSummaryIndexSettingChange} + /> + </div> + )} </div> </div> </div> diff --git a/web/app/components/datasets/create/step-two/components/preview-panel.tsx b/web/app/components/datasets/create/step-two/components/preview-panel.tsx index c1fe9f0344a8f5..2a84804b3ed972 100644 --- a/web/app/components/datasets/create/step-two/components/preview-panel.tsx +++ b/web/app/components/datasets/create/step-two/components/preview-panel.tsx @@ -9,7 +9,12 @@ import { noop } from 'es-toolkit/function' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import FloatRightContainer from '@/app/components/base/float-right-container' -import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' +import { + SkeletonContainer, + SkeletonPoint, + SkeletonRectangle, + SkeletonRow, +} from '@/app/components/base/skeleton' import { FULL_DOC_PREVIEW_LENGTH } from '@/config' import { ChunkingMode } from '@/models/datasets' import { ChunkContainer, QAPreview } from '../../../chunk' @@ -29,13 +34,13 @@ type PreviewPanelProps = { parentChildConfig: ParentChildConfig isSetting?: boolean // Picker - pickerFiles: Array<{ id: string, name: string, extension: string }> - pickerValue: { id: string, name: string, extension: string } + pickerFiles: Array<{ id: string; name: string; extension: string }> + pickerValue: { id: string; name: string; extension: string } // Mutation state isIdle: boolean isPending: boolean // Actions - onPickerChange: (selected: { id: string, name: string }) => void + onPickerChange: (selected: { id: string; name: string }) => void } export const PreviewPanel: FC<PreviewPanelProps> = ({ @@ -56,30 +61,38 @@ export const PreviewPanel: FC<PreviewPanelProps> = ({ return ( <FloatRightContainer isMobile={isMobile} isOpen={true} onClose={noop}> <PreviewContainer - header={( - <PreviewHeader title={t($ => $['stepTwo.preview'], { ns: 'datasetCreation' })}> + header={ + <PreviewHeader title={t(($) => $['stepTwo.preview'], { ns: 'datasetCreation' })}> <div className="flex items-center gap-1"> <PreviewDocumentPicker - files={pickerFiles as Array<Required<{ id: string, name: string, extension: string }>>} + files={ + pickerFiles as Array<Required<{ id: string; name: string; extension: string }>> + } onChange={onPickerChange} value={isSetting ? pickerFiles[0] : pickerValue} /> {currentDocForm !== ChunkingMode.qa && ( <Badge - text={t($ => $['stepTwo.previewChunkCount'], { - ns: 'datasetCreation', - count: estimate?.total_segments || 0, - }) as string} + text={ + t(($) => $['stepTwo.previewChunkCount'], { + ns: 'datasetCreation', + count: estimate?.total_segments || 0, + }) as string + } /> )} </div> </PreviewHeader> + } + className={cn( + 'relative flex h-full w-1/2 shrink-0 p-4 pr-0', + isMobile && 'w-full max-w-[524px]', )} - className={cn('relative flex h-full w-1/2 shrink-0 p-4 pr-0', isMobile && 'w-full max-w-[524px]')} mainClassName="space-y-6" > {/* QA Preview */} - {currentDocForm === ChunkingMode.qa && estimate?.qa_preview && ( + {currentDocForm === ChunkingMode.qa && + estimate?.qa_preview && estimate.qa_preview.map((item, index) => ( <ChunkContainer key={item.question} @@ -88,11 +101,11 @@ export const PreviewPanel: FC<PreviewPanelProps> = ({ > <QAPreview qa={item} /> </ChunkContainer> - )) - )} + ))} {/* Text Preview */} - {currentDocForm === ChunkingMode.text && estimate?.preview && ( + {currentDocForm === ChunkingMode.text && + estimate?.preview && estimate.preview.map((item, index) => ( <ChunkContainer key={item.content} @@ -102,16 +115,17 @@ export const PreviewPanel: FC<PreviewPanelProps> = ({ {item.content} {item.summary && <SummaryLabel summary={item.summary} />} </ChunkContainer> - )) - )} + ))} {/* Parent-Child Preview */} - {currentDocForm === ChunkingMode.parentChild && estimate?.preview && ( + {currentDocForm === ChunkingMode.parentChild && + estimate?.preview && estimate.preview.map((item, index) => { const indexForLabel = index + 1 - const childChunks = parentChildConfig.chunkForContext === 'full-doc' - ? item.child_chunks.slice(0, FULL_DOC_PREVIEW_LENGTH) - : item.child_chunks + const childChunks = + parentChildConfig.chunkForContext === 'full-doc' + ? item.child_chunks.slice(0, FULL_DOC_PREVIEW_LENGTH) + : item.child_chunks return ( <ChunkContainer key={item.content} @@ -136,8 +150,7 @@ export const PreviewPanel: FC<PreviewPanelProps> = ({ {item.summary && <SummaryLabel summary={item.summary} />} </ChunkContainer> ) - }) - )} + })} {/* Idle State */} {isIdle && ( @@ -145,7 +158,7 @@ export const PreviewPanel: FC<PreviewPanelProps> = ({ <div className="flex flex-col items-center justify-center gap-3"> <RiSearchEyeLine className="size-10 text-text-empty-state-icon" /> <p className="text-sm text-text-tertiary"> - {t($ => $['stepTwo.previewChunkTip'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.previewChunkTip'], { ns: 'datasetCreation' })} </p> </div> </div> diff --git a/web/app/components/datasets/create/step-two/components/step-two-footer.tsx b/web/app/components/datasets/create/step-two/components/step-two-footer.tsx index 08edab800123fb..52bbf351f2eb9e 100644 --- a/web/app/components/datasets/create/step-two/components/step-two-footer.tsx +++ b/web/app/components/datasets/create/step-two/components/step-two-footer.tsx @@ -27,15 +27,10 @@ export const StepTwoFooter: FC<StepTwoFooterProps> = ({ <div className="mt-8 flex items-center py-2"> <Button onClick={onPrevious}> <RiArrowLeftLine className="mr-1 size-4" /> - {t($ => $['stepTwo.previousStep'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.previousStep'], { ns: 'datasetCreation' })} </Button> - <Button - className="ml-auto" - loading={isCreating} - variant="primary" - onClick={onCreate} - > - {t($ => $['stepTwo.nextStep'], { ns: 'datasetCreation' })} + <Button className="ml-auto" loading={isCreating} variant="primary" onClick={onCreate}> + {t(($) => $['stepTwo.nextStep'], { ns: 'datasetCreation' })} </Button> </div> ) @@ -43,15 +38,11 @@ export const StepTwoFooter: FC<StepTwoFooterProps> = ({ return ( <div className="mt-8 flex items-center py-2"> - <Button - loading={isCreating} - variant="primary" - onClick={onCreate} - > - {t($ => $['stepTwo.save'], { ns: 'datasetCreation' })} + <Button loading={isCreating} variant="primary" onClick={onCreate}> + {t(($) => $['stepTwo.save'], { ns: 'datasetCreation' })} </Button> <Button className="ml-2" onClick={onCancel}> - {t($ => $['stepTwo.cancel'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.cancel'], { ns: 'datasetCreation' })} </Button> </div> ) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/escape.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/escape.spec.ts index 0f0b167822ed5c..e05d626eb9a4b7 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/escape.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/escape.spec.ts @@ -32,7 +32,7 @@ describe('escape', () => { }) it('should escape single quote', () => { - expect(escape('\'')).toBe('\\\'') + expect(escape("'")).toBe("\\'") }) // Multiple special characters in one string diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/unescape.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/unescape.spec.ts index b0261e625000c2..f05608fd59d022 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/unescape.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/unescape.spec.ts @@ -35,8 +35,8 @@ describe('unescape', () => { expect(unescape('\\\\')).toBe('\\') }) - it('should unescape \\\' to single quote', () => { - expect(unescape('\\\'')).toBe('\'') + it("should unescape \\' to single quote", () => { + expect(unescape("\\'")).toBe("'") }) it('should unescape \\" to double quote', () => { diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts index cb6fadde4ee246..99e23f01fac720 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts @@ -1,4 +1,9 @@ -import type { CreateDocumentReq, CustomFile, FullDocumentDetail, ProcessRule } from '@/models/datasets' +import type { + CreateDocumentReq, + CustomFile, + FullDocumentDetail, + ProcessRule, +} from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' import { renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -88,14 +93,16 @@ describe('useDocumentCreation', () => { const { result } = renderHook(() => useDocumentCreation(defaultOptions)) const invalid = { ...defaultValidationParams, overlap: 2000, maxChunkLength: 1000 } expect(result.current.validateParams(invalid)).toBe(false) - expect(mocks.toastNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mocks.toastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should return false when maxChunkLength > limitMaxChunkLength', () => { const { result } = renderHook(() => useDocumentCreation(defaultOptions)) - const invalid = { ...defaultValidationParams, maxChunkLength: 5000, limitMaxChunkLength: 4000 } + const invalid = { + ...defaultValidationParams, + maxChunkLength: 5000, + limitMaxChunkLength: 4000, + } expect(result.current.validateParams(invalid)).toBe(false) }) @@ -195,7 +202,11 @@ describe('useDocumentCreation', () => { }), ) - await result.current.executeCreation({} as CreateDocumentReq, IndexingType.QUALIFIED, defaultValidationParams.retrievalConfig) + await result.current.executeCreation( + {} as CreateDocumentReq, + IndexingType.QUALIFIED, + defaultValidationParams.retrievalConfig, + ) expect(mocks.mutateAsync).not.toHaveBeenCalled() expect(mocks.invalidDatasetList).not.toHaveBeenCalled() @@ -214,7 +225,11 @@ describe('useDocumentCreation', () => { }), ) - await result.current.executeCreation({} as CreateDocumentReq, IndexingType.QUALIFIED, defaultValidationParams.retrievalConfig) + await result.current.executeCreation( + {} as CreateDocumentReq, + IndexingType.QUALIFIED, + defaultValidationParams.retrievalConfig, + ) expect(mocks.mutateAsync).toHaveBeenCalled() expect(onSave).toHaveBeenCalled() diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts index 1ac13aee760d68..0fe797c27abc52 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts @@ -4,11 +4,11 @@ import { RETRIEVE_METHOD } from '@/types/app' // Hoisted mock state const mocks = vi.hoisted(() => ({ - rerankModelList: [] as Array<{ provider: { provider: string }, model: string }>, - rerankDefaultModel: null as { provider: { provider: string }, model: string } | null, - isRerankDefaultModelValid: null as { provider: { provider: string }, model: string } | null, - embeddingModelList: [] as Array<{ provider: { provider: string }, model: string }>, - defaultEmbeddingModel: null as { provider: { provider: string }, model: string } | null, + rerankModelList: [] as Array<{ provider: { provider: string }; model: string }>, + rerankDefaultModel: null as { provider: { provider: string }; model: string } | null, + isRerankDefaultModelValid: null as { provider: { provider: string }; model: string } | null, + embeddingModelList: [] as Array<{ provider: { provider: string }; model: string }>, + defaultEmbeddingModel: null as { provider: { provider: string }; model: string } | null, })) vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts index 59676e68a8a9b8..d207a689a63c29 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts @@ -65,18 +65,22 @@ describe('useIndexingEstimate', () => { }) it('should select notion mutation for NOTION type', () => { - const { result } = renderHook(() => useIndexingEstimate({ - ...defaultOptions, - dataSourceType: DataSourceType.NOTION, - })) + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + }), + ) expect(result.current.estimate).toBeNull() }) it('should select web mutation for WEB type', () => { - const { result } = renderHook(() => useIndexingEstimate({ - ...defaultOptions, - dataSourceType: DataSourceType.WEB, - })) + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + }), + ) expect(result.current.estimate).toBeNull() }) }) @@ -89,19 +93,23 @@ describe('useIndexingEstimate', () => { }) it('should call notion mutate for NOTION type', () => { - const { result } = renderHook(() => useIndexingEstimate({ - ...defaultOptions, - dataSourceType: DataSourceType.NOTION, - })) + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + }), + ) result.current.fetchEstimate() expect(mocks.notionMutate).toHaveBeenCalledOnce() }) it('should call web mutate for WEB type', () => { - const { result } = renderHook(() => useIndexingEstimate({ - ...defaultOptions, - dataSourceType: DataSourceType.WEB, - })) + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + }), + ) result.current.fetchEstimate() expect(mocks.webMutate).toHaveBeenCalledOnce() }) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts index b13dcb53270a8b..2587319aa9db09 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts @@ -6,36 +6,42 @@ import { DataSourceType } from '@/models/datasets' import { usePreviewState } from '../use-preview-state' // Factory functions -const createFile = (id: string, name: string): CustomFile => ({ - id, - name, - size: 1024, - type: 'text/plain', - extension: 'txt', - created_by: 'user', - created_at: Date.now(), -} as unknown as CustomFile) - -const createNotionPage = (pageId: string, pageName: string): NotionPage => ({ - page_id: pageId, - page_name: pageName, - page_icon: null, - parent_id: '', - type: 'page', - is_bound: true, -} as unknown as NotionPage) - -const createWebsitePage = (url: string, title: string): CrawlResultItem => ({ - source_url: url, - title, - markdown: '', - description: '', -} as unknown as CrawlResultItem) +const createFile = (id: string, name: string): CustomFile => + ({ + id, + name, + size: 1024, + type: 'text/plain', + extension: 'txt', + created_by: 'user', + created_at: Date.now(), + }) as unknown as CustomFile + +const createNotionPage = (pageId: string, pageName: string): NotionPage => + ({ + page_id: pageId, + page_name: pageName, + page_icon: null, + parent_id: '', + type: 'page', + is_bound: true, + }) as unknown as NotionPage + +const createWebsitePage = (url: string, title: string): CrawlResultItem => + ({ + source_url: url, + title, + markdown: '', + description: '', + }) as unknown as CrawlResultItem describe('usePreviewState', () => { const files = [createFile('f-1', 'file1.txt'), createFile('f-2', 'file2.txt')] const notionPages = [createNotionPage('np-1', 'Page 1'), createNotionPage('np-2', 'Page 2')] - const websitePages = [createWebsitePage('https://a.com', 'Site A'), createWebsitePage('https://b.com', 'Site B')] + const websitePages = [ + createWebsitePage('https://a.com', 'Site A'), + createWebsitePage('https://b.com', 'Site B'), + ] beforeEach(() => { vi.clearAllMocks() @@ -43,71 +49,83 @@ describe('usePreviewState', () => { describe('initial state for FILE', () => { it('should set first file as preview', () => { - const { result } = renderHook(() => usePreviewState({ - dataSourceType: DataSourceType.FILE, - files, - notionPages: [], - websitePages: [], - })) + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + }), + ) expect(result.current.previewFile).toBe(files[0]) }) }) describe('initial state for NOTION', () => { it('should set first notion page as preview', () => { - const { result } = renderHook(() => usePreviewState({ - dataSourceType: DataSourceType.NOTION, - files: [], - notionPages, - websitePages: [], - })) + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + }), + ) expect(result.current.previewNotionPage).toBe(notionPages[0]) }) }) describe('initial state for WEB', () => { it('should set first website page as preview', () => { - const { result } = renderHook(() => usePreviewState({ - dataSourceType: DataSourceType.WEB, - files: [], - notionPages: [], - websitePages, - })) + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.WEB, + files: [], + notionPages: [], + websitePages, + }), + ) expect(result.current.previewWebsitePage).toBe(websitePages[0]) }) }) describe('getPreviewPickerItems', () => { it('should return files for FILE type', () => { - const { result } = renderHook(() => usePreviewState({ - dataSourceType: DataSourceType.FILE, - files, - notionPages: [], - websitePages: [], - })) + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + }), + ) const items = result.current.getPreviewPickerItems() expect(items).toHaveLength(2) }) it('should return mapped notion pages for NOTION type', () => { - const { result } = renderHook(() => usePreviewState({ - dataSourceType: DataSourceType.NOTION, - files: [], - notionPages, - websitePages: [], - })) + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + }), + ) const items = result.current.getPreviewPickerItems() expect(items).toHaveLength(2) expect(items[0]).toEqual({ id: 'np-1', name: 'Page 1', extension: 'md' }) }) it('should return mapped website pages for WEB type', () => { - const { result } = renderHook(() => usePreviewState({ - dataSourceType: DataSourceType.WEB, - files: [], - notionPages: [], - websitePages, - })) + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.WEB, + files: [], + notionPages: [], + websitePages, + }), + ) const items = result.current.getPreviewPickerItems() expect(items).toHaveLength(2) expect(items[0]).toEqual({ id: 'https://a.com', name: 'Site A', extension: 'md' }) @@ -116,23 +134,27 @@ describe('usePreviewState', () => { describe('getPreviewPickerValue', () => { it('should return current preview file for FILE type', () => { - const { result } = renderHook(() => usePreviewState({ - dataSourceType: DataSourceType.FILE, - files, - notionPages: [], - websitePages: [], - })) + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + }), + ) const value = result.current.getPreviewPickerValue() expect(value).toBe(files[0]) }) it('should return mapped notion page value for NOTION type', () => { - const { result } = renderHook(() => usePreviewState({ - dataSourceType: DataSourceType.NOTION, - files: [], - notionPages, - websitePages: [], - })) + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + }), + ) const value = result.current.getPreviewPickerValue() expect(value).toEqual({ id: 'np-1', name: 'Page 1', extension: 'md' }) }) @@ -140,12 +162,14 @@ describe('usePreviewState', () => { describe('handlePreviewChange', () => { it('should change preview file for FILE type', () => { - const { result } = renderHook(() => usePreviewState({ - dataSourceType: DataSourceType.FILE, - files, - notionPages: [], - websitePages: [], - })) + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + }), + ) act(() => { result.current.handlePreviewChange({ id: 'f-2', name: 'file2.txt' }) @@ -154,12 +178,14 @@ describe('usePreviewState', () => { }) it('should change preview notion page for NOTION type', () => { - const { result } = renderHook(() => usePreviewState({ - dataSourceType: DataSourceType.NOTION, - files: [], - notionPages, - websitePages: [], - })) + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + }), + ) act(() => { result.current.handlePreviewChange({ id: 'np-2', name: 'Page 2' }) @@ -168,12 +194,14 @@ describe('usePreviewState', () => { }) it('should change preview website page for WEB type', () => { - const { result } = renderHook(() => usePreviewState({ - dataSourceType: DataSourceType.WEB, - files: [], - notionPages: [], - websitePages, - })) + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.WEB, + files: [], + notionPages: [], + websitePages, + }), + ) act(() => { result.current.handlePreviewChange({ id: 'https://b.com', name: 'Site B' }) @@ -182,12 +210,14 @@ describe('usePreviewState', () => { }) it('should not change if selected page not found (NOTION)', () => { - const { result } = renderHook(() => usePreviewState({ - dataSourceType: DataSourceType.NOTION, - files: [], - notionPages, - websitePages: [], - })) + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + }), + ) act(() => { result.current.handlePreviewChange({ id: 'non-existent', name: 'x' }) diff --git a/web/app/components/datasets/create/step-two/hooks/escape.ts b/web/app/components/datasets/create/step-two/hooks/escape.ts index 2e1c3a9d736463..2c44152b9b00b6 100644 --- a/web/app/components/datasets/create/step-two/hooks/escape.ts +++ b/web/app/components/datasets/create/step-two/hooks/escape.ts @@ -1,6 +1,5 @@ function escape(input: string): string { - if (!input || typeof input !== 'string') - return '' + if (!input || typeof input !== 'string') return '' const res = input // .replaceAll('\\', '\\\\') // This would add too many backslashes @@ -11,7 +10,7 @@ function escape(input: string): string { .replaceAll('\r', '\\r') .replaceAll('\t', '\\t') .replaceAll('\v', '\\v') - .replaceAll('\'', '\\\'') + .replaceAll("'", "\\'") return res } diff --git a/web/app/components/datasets/create/step-two/hooks/index.ts b/web/app/components/datasets/create/step-two/hooks/index.ts index b216ead2e6b833..24399f09af015b 100644 --- a/web/app/components/datasets/create/step-two/hooks/index.ts +++ b/web/app/components/datasets/create/step-two/hooks/index.ts @@ -6,5 +6,12 @@ export { useIndexingEstimate } from './use-indexing-estimate' export { usePreviewState } from './use-preview-state' -export { DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP, DEFAULT_SEGMENT_IDENTIFIER, defaultParentChildConfig, MAXIMUM_CHUNK_TOKEN_LENGTH, useSegmentationState } from './use-segmentation-state' +export { + DEFAULT_MAXIMUM_CHUNK_LENGTH, + DEFAULT_OVERLAP, + DEFAULT_SEGMENT_IDENTIFIER, + defaultParentChildConfig, + MAXIMUM_CHUNK_TOKEN_LENGTH, + useSegmentationState, +} from './use-segmentation-state' export type { ParentChildConfig } from './use-segmentation-state' diff --git a/web/app/components/datasets/create/step-two/hooks/unescape.ts b/web/app/components/datasets/create/step-two/hooks/unescape.ts index b12cfb25cd8852..b6a98020c0e37c 100644 --- a/web/app/components/datasets/create/step-two/hooks/unescape.ts +++ b/web/app/components/datasets/create/step-two/hooks/unescape.ts @@ -16,17 +16,18 @@ * \U([0-9A-Fa-f]+) - sixth alternative; matches the 8-digit hexadecimal escape sequence used by python (\U0001F3B5) * ) */ -const jsEscapeRegex = /\\(u\{([0-9A-Fa-f]+)\}|u([0-9A-Fa-f]{4})|x([0-9A-Fa-f]{2})|([1-7][0-7]{0,2}|[0-7]{2,3})|(['"tbrnfv0\\]))|\\U([0-9A-Fa-f]{8})/g +const jsEscapeRegex = + /\\(u\{([0-9A-Fa-f]+)\}|u([0-9A-Fa-f]{4})|x([0-9A-Fa-f]{2})|([1-7][0-7]{0,2}|[0-7]{2,3})|(['"tbrnfv0\\]))|\\U([0-9A-Fa-f]{8})/g const usualEscapeSequences: Record<string, string> = { '0': '\0', - 'b': '\b', - 'f': '\f', - 'n': '\n', - 'r': '\r', - 't': '\t', - 'v': '\v', - '\'': '\'', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + "'": "'", '"': '"', '\\': '\\', } @@ -35,20 +36,17 @@ const fromHex = (str: string) => String.fromCodePoint(Number.parseInt(str, 16)) const fromOct = (str: string) => String.fromCodePoint(Number.parseInt(str, 8)) const unescape = (str: string) => { - return str.replace(jsEscapeRegex, (_, __, varHex, longHex, shortHex, octal, specialCharacter, python) => { - if (varHex !== undefined) - return fromHex(varHex) - else if (longHex !== undefined) - return fromHex(longHex) - else if (shortHex !== undefined) - return fromHex(shortHex) - else if (octal !== undefined) - return fromOct(octal) - else if (python !== undefined) - return fromHex(python) - else - return usualEscapeSequences[specialCharacter]! - }) + return str.replace( + jsEscapeRegex, + (_, __, varHex, longHex, shortHex, octal, specialCharacter, python) => { + if (varHex !== undefined) return fromHex(varHex) + else if (longHex !== undefined) return fromHex(longHex) + else if (shortHex !== undefined) return fromHex(shortHex) + else if (octal !== undefined) return fromOct(octal) + else if (python !== undefined) return fromHex(python) + else return usualEscapeSequences[specialCharacter]! + }, + ) } export default unescape diff --git a/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts b/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts index f47072783578e1..b17bb62383f90b 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts @@ -1,6 +1,19 @@ -import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + DefaultModel, + Model, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { NotionPage } from '@/models/common' -import type { ChunkingMode, CrawlOptions, CrawlResultItem, CreateDocumentReq, createDocumentResponse, CustomFile, FullDocumentDetail, ProcessRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets' +import type { + ChunkingMode, + CrawlOptions, + CrawlResultItem, + CreateDocumentReq, + createDocumentResponse, + CustomFile, + FullDocumentDetail, + ProcessRule, + SummaryIndexSetting as SummaryIndexSettingType, +} from '@/models/datasets' import type { RetrievalConfig, RETRIEVE_METHOD } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' import { useCallback } from 'react' @@ -9,7 +22,12 @@ import { trackEvent } from '@/app/components/base/amplitude' import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model' import { DataSourceProvider } from '@/models/common' import { DataSourceType } from '@/models/datasets' -import { getNotionInfo, getWebsiteInfo, useCreateDocument, useCreateFirstDocument } from '@/service/knowledge/use-create-dataset' +import { + getNotionInfo, + getWebsiteInfo, + useCreateDocument, + useCreateFirstDocument, +} from '@/service/knowledge/use-create-dataset' import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' import { IndexingType } from './use-indexing-config' import { MAXIMUM_CHUNK_TOKEN_LENGTH } from './use-segmentation-state' @@ -47,153 +65,220 @@ type ValidationParams = { } export const useDocumentCreation = (options: UseDocumentCreationOptions) => { const { t } = useTranslation() - const { datasetId, isSetting, documentDetail, dataSourceType, files, notionPages, notionCredentialId, websitePages, crawlOptions, websiteCrawlProvider = DataSourceProvider.jinaReader, websiteCrawlJobId = '', canCreateDocument = true, onStepChange, updateIndexingTypeCache, updateResultCache, updateRetrievalMethodCache, onSave, mutateDatasetRes } = options + const { + datasetId, + isSetting, + documentDetail, + dataSourceType, + files, + notionPages, + notionCredentialId, + websitePages, + crawlOptions, + websiteCrawlProvider = DataSourceProvider.jinaReader, + websiteCrawlJobId = '', + canCreateDocument = true, + onStepChange, + updateIndexingTypeCache, + updateResultCache, + updateRetrievalMethodCache, + onSave, + mutateDatasetRes, + } = options const createFirstDocumentMutation = useCreateFirstDocument() const createDocumentMutation = useCreateDocument(datasetId!) const invalidDatasetList = useInvalidDatasetList() const isCreating = createFirstDocumentMutation.isPending || createDocumentMutation.isPending // Validate creation params - const validateParams = useCallback((params: ValidationParams): boolean => { - const { segmentationType, maxChunkLength, limitMaxChunkLength, overlap, indexType, embeddingModel, rerankModelList, retrievalConfig } = params - if (segmentationType === 'general' && overlap > maxChunkLength) { - toast.error(t($ => $['stepTwo.overlapCheck'], { ns: 'datasetCreation' })) - return false - } - if (segmentationType === 'general' && maxChunkLength > limitMaxChunkLength) { - toast.error(t($ => $['stepTwo.maxLengthCheck'], { ns: 'datasetCreation', limit: limitMaxChunkLength })) - return false - } - if (!isSetting) { - if (indexType === IndexingType.QUALIFIED && (!embeddingModel.model || !embeddingModel.provider)) { - toast.error(t($ => $['datasetConfig.embeddingModelRequired'], { ns: 'appDebug' })) - return false - } - if (!isReRankModelSelected({ + const validateParams = useCallback( + (params: ValidationParams): boolean => { + const { + segmentationType, + maxChunkLength, + limitMaxChunkLength, + overlap, + indexType, + embeddingModel, rerankModelList, retrievalConfig, - indexMethod: indexType, - })) { - toast.error(t($ => $['datasetConfig.rerankModelRequired'], { ns: 'appDebug' })) + } = params + if (segmentationType === 'general' && overlap > maxChunkLength) { + toast.error(t(($) => $['stepTwo.overlapCheck'], { ns: 'datasetCreation' })) return false } - } - return true - }, [t, isSetting]) + if (segmentationType === 'general' && maxChunkLength > limitMaxChunkLength) { + toast.error( + t(($) => $['stepTwo.maxLengthCheck'], { + ns: 'datasetCreation', + limit: limitMaxChunkLength, + }), + ) + return false + } + if (!isSetting) { + if ( + indexType === IndexingType.QUALIFIED && + (!embeddingModel.model || !embeddingModel.provider) + ) { + toast.error(t(($) => $['datasetConfig.embeddingModelRequired'], { ns: 'appDebug' })) + return false + } + if ( + !isReRankModelSelected({ + rerankModelList, + retrievalConfig, + indexMethod: indexType, + }) + ) { + toast.error(t(($) => $['datasetConfig.rerankModelRequired'], { ns: 'appDebug' })) + return false + } + } + return true + }, + [t, isSetting], + ) // Build creation params - const buildCreationParams = useCallback((currentDocForm: ChunkingMode, docLanguage: string, processRule: ProcessRule, retrievalConfig: RetrievalConfig, embeddingModel: DefaultModel, indexingTechnique: string, summaryIndexSetting?: SummaryIndexSettingType): CreateDocumentReq | null => { - if (isSetting) { - return { - original_document_id: documentDetail?.id, - doc_form: currentDocForm, - doc_language: docLanguage, + const buildCreationParams = useCallback( + ( + currentDocForm: ChunkingMode, + docLanguage: string, + processRule: ProcessRule, + retrievalConfig: RetrievalConfig, + embeddingModel: DefaultModel, + indexingTechnique: string, + summaryIndexSetting?: SummaryIndexSettingType, + ): CreateDocumentReq | null => { + if (isSetting) { + return { + original_document_id: documentDetail?.id, + doc_form: currentDocForm, + doc_language: docLanguage, + process_rule: processRule, + summary_index_setting: summaryIndexSetting, + retrieval_model: retrievalConfig, + embedding_model: embeddingModel.model, + embedding_model_provider: embeddingModel.provider, + indexing_technique: indexingTechnique, + } as CreateDocumentReq + } + const params: CreateDocumentReq = { + data_source: { + type: dataSourceType, + info_list: { + data_source_type: dataSourceType, + }, + }, + indexing_technique: indexingTechnique, process_rule: processRule, summary_index_setting: summaryIndexSetting, + doc_form: currentDocForm, + doc_language: docLanguage, retrieval_model: retrievalConfig, embedding_model: embeddingModel.model, embedding_model_provider: embeddingModel.provider, - indexing_technique: indexingTechnique, } as CreateDocumentReq - } - const params: CreateDocumentReq = { - data_source: { - type: dataSourceType, - info_list: { - data_source_type: dataSourceType, - }, - }, - indexing_technique: indexingTechnique, - process_rule: processRule, - summary_index_setting: summaryIndexSetting, - doc_form: currentDocForm, - doc_language: docLanguage, - retrieval_model: retrievalConfig, - embedding_model: embeddingModel.model, - embedding_model_provider: embeddingModel.provider, - } as CreateDocumentReq - // Add data source specific info - if (dataSourceType === DataSourceType.FILE) { - params.data_source!.info_list.file_info_list = { - file_ids: files.map(file => file.id || '').filter(Boolean), + // Add data source specific info + if (dataSourceType === DataSourceType.FILE) { + params.data_source!.info_list.file_info_list = { + file_ids: files.map((file) => file.id || '').filter(Boolean), + } } - } - if (dataSourceType === DataSourceType.NOTION) - params.data_source!.info_list.notion_info_list = getNotionInfo(notionPages, notionCredentialId) - if (dataSourceType === DataSourceType.WEB) { - params.data_source!.info_list.website_info_list = getWebsiteInfo({ - websiteCrawlProvider, - websiteCrawlJobId, - websitePages, - crawlOptions, - }) - } - return params - }, [ - isSetting, - documentDetail, - dataSourceType, - files, - notionPages, - notionCredentialId, - websitePages, - websiteCrawlProvider, - websiteCrawlJobId, - crawlOptions, - ]) + if (dataSourceType === DataSourceType.NOTION) + params.data_source!.info_list.notion_info_list = getNotionInfo( + notionPages, + notionCredentialId, + ) + if (dataSourceType === DataSourceType.WEB) { + params.data_source!.info_list.website_info_list = getWebsiteInfo({ + websiteCrawlProvider, + websiteCrawlJobId, + websitePages, + crawlOptions, + }) + } + return params + }, + [ + isSetting, + documentDetail, + dataSourceType, + files, + notionPages, + notionCredentialId, + websitePages, + websiteCrawlProvider, + websiteCrawlJobId, + crawlOptions, + ], + ) // Execute creation - const executeCreation = useCallback(async (params: CreateDocumentReq, indexType: IndexingType, retrievalConfig: RetrievalConfig) => { - if (datasetId && !isSetting && !canCreateDocument) - return + const executeCreation = useCallback( + async ( + params: CreateDocumentReq, + indexType: IndexingType, + retrievalConfig: RetrievalConfig, + ) => { + if (datasetId && !isSetting && !canCreateDocument) return - if (!datasetId) { - await createFirstDocumentMutation.mutateAsync(params, { - onSuccess(data) { - updateIndexingTypeCache?.(indexType) - updateResultCache?.(data) - updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) - }, - }) - } - else { - await createDocumentMutation.mutateAsync(params, { - onSuccess(data) { - updateIndexingTypeCache?.(indexType) - updateResultCache?.(data) - updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) - }, + if (!datasetId) { + await createFirstDocumentMutation.mutateAsync(params, { + onSuccess(data) { + updateIndexingTypeCache?.(indexType) + updateResultCache?.(data) + updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) + }, + }) + } else { + await createDocumentMutation.mutateAsync(params, { + onSuccess(data) { + updateIndexingTypeCache?.(indexType) + updateResultCache?.(data) + updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) + }, + }) + } + mutateDatasetRes?.() + invalidDatasetList() + trackEvent('create_datasets', { + data_source_type: dataSourceType, + indexing_technique: indexType, }) - } - mutateDatasetRes?.() - invalidDatasetList() - trackEvent('create_datasets', { - data_source_type: dataSourceType, - indexing_technique: indexType, - }) - onStepChange?.(+1) - if (isSetting) - onSave?.() - }, [ - datasetId, - createFirstDocumentMutation, - createDocumentMutation, - updateIndexingTypeCache, - updateResultCache, - updateRetrievalMethodCache, - mutateDatasetRes, - invalidDatasetList, - dataSourceType, - onStepChange, - isSetting, - canCreateDocument, - onSave, - ]) + onStepChange?.(+1) + if (isSetting) onSave?.() + }, + [ + datasetId, + createFirstDocumentMutation, + createDocumentMutation, + updateIndexingTypeCache, + updateResultCache, + updateRetrievalMethodCache, + mutateDatasetRes, + invalidDatasetList, + dataSourceType, + onStepChange, + isSetting, + canCreateDocument, + onSave, + ], + ) // Validate preview params - const validatePreviewParams = useCallback((maxChunkLength: number): boolean => { - if (maxChunkLength > MAXIMUM_CHUNK_TOKEN_LENGTH) { - toast.error(t($ => $['stepTwo.maxLengthCheck'], { ns: 'datasetCreation', limit: MAXIMUM_CHUNK_TOKEN_LENGTH })) - return false - } - return true - }, [t]) + const validatePreviewParams = useCallback( + (maxChunkLength: number): boolean => { + if (maxChunkLength > MAXIMUM_CHUNK_TOKEN_LENGTH) { + toast.error( + t(($) => $['stepTwo.maxLengthCheck'], { + ns: 'datasetCreation', + limit: MAXIMUM_CHUNK_TOKEN_LENGTH, + }), + ) + return false + } + return true + }, + [t], + ) return { isCreating, validateParams, diff --git a/web/app/components/datasets/create/step-two/hooks/use-indexing-config.ts b/web/app/components/datasets/create/step-two/hooks/use-indexing-config.ts index a8dd5f22178d59..04b9f907634a00 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-indexing-config.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-indexing-config.ts @@ -3,7 +3,11 @@ import type { RetrievalConfig } from '@/types/app' import { useEffect, useMemo, useState } from 'react' import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useDefaultModel, useModelList, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { + useDefaultModel, + useModelList, + useModelListAndDefaultModelAndCurrentProviderAndModel, +} from '@/app/components/header/account-setting/model-provider-page/hooks' import { RETRIEVE_METHOD } from '@/types/app' export enum IndexingType { @@ -53,8 +57,7 @@ export const useIndexingConfig = (options: UseIndexingConfigOptions) => { // Index type state const [indexType, setIndexType] = useState<IndexingType>(() => { - if (initialIndexType) - return initialIndexType + if (initialIndexType) return initialIndexType return isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL }) @@ -73,15 +76,16 @@ export const useIndexingConfig = (options: UseIndexingConfigOptions) => { // Sync retrieval config with rerank model when available useEffect(() => { - if (initialRetrievalConfig) - return + if (initialRetrievalConfig) return setRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic, reranking_enable: !!isRerankDefaultModelValid, reranking_model: { - reranking_provider_name: isRerankDefaultModelValid ? rerankDefaultModel?.provider.provider ?? '' : '', - reranking_model_name: isRerankDefaultModelValid ? rerankDefaultModel?.model ?? '' : '', + reranking_provider_name: isRerankDefaultModelValid + ? (rerankDefaultModel?.provider.provider ?? '') + : '', + reranking_model_name: isRerankDefaultModelValid ? (rerankDefaultModel?.model ?? '') : '', }, top_k: 3, score_threshold_enabled: false, @@ -91,10 +95,8 @@ export const useIndexingConfig = (options: UseIndexingConfigOptions) => { // Sync index type with props useEffect(() => { - if (initialIndexType) - setIndexType(initialIndexType) - else - setIndexType(isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL) + if (initialIndexType) setIndexType(initialIndexType) + else setIndexType(isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL) }, [isAPIKeySet, initialIndexType]) // Show multimodal tip diff --git a/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts b/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts index ea14ef2c993190..f2f9ea6089e11d 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts @@ -1,6 +1,12 @@ import type { IndexingType } from './use-indexing-config' import type { NotionPage } from '@/models/common' -import type { ChunkingMode, CrawlOptions, CrawlResultItem, CustomFile, ProcessRule } from '@/models/datasets' +import type { + ChunkingMode, + CrawlOptions, + CrawlResultItem, + CustomFile, + ProcessRule, +} from '@/models/datasets' import { useCallback } from 'react' import { DataSourceProvider } from '@/models/common' import { DataSourceType } from '@/models/datasets' @@ -55,9 +61,7 @@ export const useIndexingEstimate = (options: UseIndexingEstimateOptions) => { docForm: currentDocForm, docLanguage, dataSourceType: DataSourceType.FILE, - files: previewFileName - ? [files.find(file => file.name === previewFileName)!] - : files, + files: previewFileName ? [files.find((file) => file.name === previewFileName)!] : files, indexingTechnique, processRule, dataset_id: datasetId!, @@ -91,10 +95,8 @@ export const useIndexingEstimate = (options: UseIndexingEstimateOptions) => { // Get current mutation based on data source type const getCurrentMutation = useCallback(() => { - if (dataSourceType === DataSourceType.FILE) - return fileQuery - if (dataSourceType === DataSourceType.NOTION) - return notionQuery + if (dataSourceType === DataSourceType.FILE) return fileQuery + if (dataSourceType === DataSourceType.NOTION) return notionQuery return websiteQuery }, [dataSourceType, fileQuery, notionQuery, websiteQuery]) @@ -102,12 +104,9 @@ export const useIndexingEstimate = (options: UseIndexingEstimateOptions) => { // Trigger estimate fetch const fetchEstimate = useCallback(() => { - if (dataSourceType === DataSourceType.FILE) - fileQuery.mutate() - else if (dataSourceType === DataSourceType.NOTION) - notionQuery.mutate() - else - websiteQuery.mutate() + if (dataSourceType === DataSourceType.FILE) fileQuery.mutate() + else if (dataSourceType === DataSourceType.NOTION) notionQuery.mutate() + else websiteQuery.mutate() }, [dataSourceType, fileQuery, notionQuery, websiteQuery]) return { diff --git a/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts b/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts index 8ac1b7904d1da2..f7eda67405a20d 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts @@ -1,5 +1,10 @@ import type { NotionPage } from '@/models/common' -import type { CrawlResultItem, CustomFile, DocumentItem, FullDocumentDetail } from '@/models/datasets' +import type { + CrawlResultItem, + CustomFile, + DocumentItem, + FullDocumentDetail, +} from '@/models/datasets' import { useCallback, useState } from 'react' import { DataSourceType } from '@/models/datasets' @@ -13,34 +18,21 @@ type UsePreviewStateOptions = { } export const usePreviewState = (options: UsePreviewStateOptions) => { - const { - dataSourceType, - files, - notionPages, - websitePages, - documentDetail, - datasetId, - } = options + const { dataSourceType, files, notionPages, websitePages, documentDetail, datasetId } = options // File preview state const [previewFile, setPreviewFile] = useState<DocumentItem>( - (datasetId && documentDetail) - ? documentDetail.file - : files[0], + datasetId && documentDetail ? documentDetail.file : files[0], ) // Notion page preview state const [previewNotionPage, setPreviewNotionPage] = useState<NotionPage>( - (datasetId && documentDetail) - ? documentDetail.notion_page - : notionPages[0], + datasetId && documentDetail ? documentDetail.notion_page : notionPages[0], ) // Website page preview state const [previewWebsitePage, setPreviewWebsitePage] = useState<CrawlResultItem>( - (datasetId && documentDetail) - ? documentDetail.website_page - : websitePages[0], + datasetId && documentDetail ? documentDetail.website_page : websitePages[0], ) // Get preview items for document picker based on data source type @@ -49,14 +41,14 @@ export const usePreviewState = (options: UsePreviewStateOptions) => { return files as Array<Required<CustomFile>> } if (dataSourceType === DataSourceType.NOTION) { - return notionPages.map(page => ({ + return notionPages.map((page) => ({ id: page.page_id, name: page.page_name, extension: 'md', })) } if (dataSourceType === DataSourceType.WEB) { - return websitePages.map(page => ({ + return websitePages.map((page) => ({ id: page.source_url, name: page.title, extension: 'md', @@ -88,21 +80,20 @@ export const usePreviewState = (options: UsePreviewStateOptions) => { }, [dataSourceType, previewFile, previewNotionPage, previewWebsitePage]) // Handle preview change - const handlePreviewChange = useCallback((selected: { id: string, name: string }) => { - if (dataSourceType === DataSourceType.FILE) { - setPreviewFile(selected as DocumentItem) - } - else if (dataSourceType === DataSourceType.NOTION) { - const selectedPage = notionPages.find(page => page.page_id === selected.id) - if (selectedPage) - setPreviewNotionPage(selectedPage) - } - else if (dataSourceType === DataSourceType.WEB) { - const selectedPage = websitePages.find(page => page.source_url === selected.id) - if (selectedPage) - setPreviewWebsitePage(selectedPage) - } - }, [dataSourceType, notionPages, websitePages]) + const handlePreviewChange = useCallback( + (selected: { id: string; name: string }) => { + if (dataSourceType === DataSourceType.FILE) { + setPreviewFile(selected as DocumentItem) + } else if (dataSourceType === DataSourceType.NOTION) { + const selectedPage = notionPages.find((page) => page.page_id === selected.id) + if (selectedPage) setPreviewNotionPage(selectedPage) + } else if (dataSourceType === DataSourceType.WEB) { + const selectedPage = websitePages.find((page) => page.source_url === selected.id) + if (selectedPage) setPreviewWebsitePage(selectedPage) + } + }, + [dataSourceType, notionPages, websitePages], + ) return { // File preview diff --git a/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts b/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts index cdd2f61c0c3d93..b8a92274e6f140 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts @@ -1,4 +1,10 @@ -import type { ParentMode, PreProcessingRule, ProcessRule, Rules, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets' +import type { + ParentMode, + PreProcessingRule, + ProcessRule, + Rules, + SummaryIndexSetting as SummaryIndexSettingType, +} from '@/models/datasets' import { useCallback, useRef, useState } from 'react' import { env } from '@/env' import { ChunkingMode, ProcessMode } from '@/models/datasets' @@ -57,8 +63,12 @@ export const useSegmentationState = (options: UseSegmentationStateOptions = {}) // Pre-processing rules const [rules, setRules] = useState<PreProcessingRule[]>([]) const [defaultConfig, setDefaultConfig] = useState<Rules>() - const [summaryIndexSetting, setSummaryIndexSetting] = useState<SummaryIndexSettingType | undefined>(initialSummaryIndexSetting) - const summaryIndexSettingRef = useRef<SummaryIndexSettingType | undefined>(initialSummaryIndexSetting) + const [summaryIndexSetting, setSummaryIndexSetting] = useState< + SummaryIndexSettingType | undefined + >(initialSummaryIndexSetting) + const summaryIndexSettingRef = useRef<SummaryIndexSettingType | undefined>( + initialSummaryIndexSetting, + ) const handleSummaryIndexSettingChange = useCallback((payload: SummaryIndexSettingType) => { setSummaryIndexSetting((prev) => { const newSetting = { ...prev, ...payload } @@ -68,23 +78,23 @@ export const useSegmentationState = (options: UseSegmentationStateOptions = {}) }, []) // Parent-child config - const [parentChildConfig, setParentChildConfig] = useState<ParentChildConfig>(defaultParentChildConfig) + const [parentChildConfig, setParentChildConfig] = + useState<ParentChildConfig>(defaultParentChildConfig) // Escaped segment identifier setter const setSegmentIdentifier = useCallback((value: string, canEmpty?: boolean) => { if (value) { doSetSegmentIdentifier(escape(value)) - } - else { + } else { doSetSegmentIdentifier(canEmpty ? '' : DEFAULT_SEGMENT_IDENTIFIER) } }, []) // Rule toggle handler const toggleRule = useCallback((id: string) => { - setRules(prev => prev.map(rule => - rule.id === id ? { ...rule, enabled: !rule.enabled } : rule, - )) + setRules((prev) => + prev.map((rule) => (rule.id === id ? { ...rule, enabled: !rule.enabled } : rule)), + ) }, []) // Reset to defaults @@ -99,100 +109,108 @@ export const useSegmentationState = (options: UseSegmentationStateOptions = {}) }, [defaultConfig, setSegmentIdentifier]) // Apply config from document detail - const applyConfigFromRules = useCallback((rulesConfig: Rules, isHierarchical: boolean) => { - const separator = rulesConfig.segmentation.separator - const max = rulesConfig.segmentation.max_tokens - const chunkOverlap = rulesConfig.segmentation.chunk_overlap - - setSegmentIdentifier(separator) - setMaxChunkLength(max) - setOverlap(chunkOverlap!) - setRules(rulesConfig.pre_processing_rules) - setDefaultConfig(rulesConfig) - - if (isHierarchical) { - setParentChildConfig({ - chunkForContext: rulesConfig.parent_mode || 'paragraph', - parent: { - delimiter: escape(rulesConfig.segmentation.separator), - maxLength: rulesConfig.segmentation.max_tokens, - }, - child: { - delimiter: escape(rulesConfig.subchunk_segmentation!.separator), - maxLength: rulesConfig.subchunk_segmentation!.max_tokens, - }, - }) - } - }, [setSegmentIdentifier]) + const applyConfigFromRules = useCallback( + (rulesConfig: Rules, isHierarchical: boolean) => { + const separator = rulesConfig.segmentation.separator + const max = rulesConfig.segmentation.max_tokens + const chunkOverlap = rulesConfig.segmentation.chunk_overlap + + setSegmentIdentifier(separator) + setMaxChunkLength(max) + setOverlap(chunkOverlap!) + setRules(rulesConfig.pre_processing_rules) + setDefaultConfig(rulesConfig) + + if (isHierarchical) { + setParentChildConfig({ + chunkForContext: rulesConfig.parent_mode || 'paragraph', + parent: { + delimiter: escape(rulesConfig.segmentation.separator), + maxLength: rulesConfig.segmentation.max_tokens, + }, + child: { + delimiter: escape(rulesConfig.subchunk_segmentation!.separator), + maxLength: rulesConfig.subchunk_segmentation!.max_tokens, + }, + }) + } + }, + [setSegmentIdentifier], + ) // Get process rule for API - const getProcessRule = useCallback((docForm: ChunkingMode): ProcessRule => { - if (docForm === ChunkingMode.parentChild) { + const getProcessRule = useCallback( + (docForm: ChunkingMode): ProcessRule => { + if (docForm === ChunkingMode.parentChild) { + return { + rules: { + pre_processing_rules: rules, + segmentation: { + separator: unescape(parentChildConfig.parent.delimiter), + max_tokens: parentChildConfig.parent.maxLength, + }, + parent_mode: parentChildConfig.chunkForContext, + subchunk_segmentation: { + separator: unescape(parentChildConfig.child.delimiter), + max_tokens: parentChildConfig.child.maxLength, + }, + }, + mode: 'hierarchical', + summary_index_setting: summaryIndexSettingRef.current, + } as ProcessRule + } + return { rules: { pre_processing_rules: rules, segmentation: { - separator: unescape(parentChildConfig.parent.delimiter), - max_tokens: parentChildConfig.parent.maxLength, - }, - parent_mode: parentChildConfig.chunkForContext, - subchunk_segmentation: { - separator: unescape(parentChildConfig.child.delimiter), - max_tokens: parentChildConfig.child.maxLength, + separator: unescape(segmentIdentifier), + max_tokens: maxChunkLength, + chunk_overlap: overlap, }, }, - mode: 'hierarchical', + mode: segmentationType, summary_index_setting: summaryIndexSettingRef.current, } as ProcessRule - } - - return { - rules: { - pre_processing_rules: rules, - segmentation: { - separator: unescape(segmentIdentifier), - max_tokens: maxChunkLength, - chunk_overlap: overlap, - }, - }, - mode: segmentationType, - summary_index_setting: summaryIndexSettingRef.current, - } as ProcessRule - }, [rules, parentChildConfig, segmentIdentifier, maxChunkLength, overlap, segmentationType]) + }, + [rules, parentChildConfig, segmentIdentifier, maxChunkLength, overlap, segmentationType], + ) // Update parent config field - const updateParentConfig = useCallback((field: 'delimiter' | 'maxLength', value: string | number) => { - setParentChildConfig((prev) => { - let newValue: string | number - if (field === 'delimiter') - newValue = value ? escape(value as string) : '' - else - newValue = value - return { - ...prev, - parent: { ...prev.parent, [field]: newValue }, - } - }) - }, []) + const updateParentConfig = useCallback( + (field: 'delimiter' | 'maxLength', value: string | number) => { + setParentChildConfig((prev) => { + let newValue: string | number + if (field === 'delimiter') newValue = value ? escape(value as string) : '' + else newValue = value + return { + ...prev, + parent: { ...prev.parent, [field]: newValue }, + } + }) + }, + [], + ) // Update child config field - const updateChildConfig = useCallback((field: 'delimiter' | 'maxLength', value: string | number) => { - setParentChildConfig((prev) => { - let newValue: string | number - if (field === 'delimiter') - newValue = value ? escape(value as string) : '' - else - newValue = value - return { - ...prev, - child: { ...prev.child, [field]: newValue }, - } - }) - }, []) + const updateChildConfig = useCallback( + (field: 'delimiter' | 'maxLength', value: string | number) => { + setParentChildConfig((prev) => { + let newValue: string | number + if (field === 'delimiter') newValue = value ? escape(value as string) : '' + else newValue = value + return { + ...prev, + child: { ...prev.child, [field]: newValue }, + } + }) + }, + [], + ) // Set chunk for context mode const setChunkForContext = useCallback((mode: ParentMode) => { - setParentChildConfig(prev => ({ ...prev, chunkForContext: mode })) + setParentChildConfig((prev) => ({ ...prev, chunkForContext: mode })) }, []) return { diff --git a/web/app/components/datasets/create/step-two/index.module.css b/web/app/components/datasets/create/step-two/index.module.css index e50ed961117753..5ebc3230b588b4 100644 --- a/web/app/components/datasets/create/step-two/index.module.css +++ b/web/app/components/datasets/create/step-two/index.module.css @@ -1,7 +1,7 @@ @reference "../../../../styles/globals.css"; .pageHeader { - @apply px-16 flex justify-between items-center; + @apply flex items-center justify-between px-16; position: sticky; top: 0; left: 0; @@ -40,8 +40,8 @@ left: 0; width: 100%; padding: 8px 20px 8px 40px; - background: #FFFAEB; - border-top: 0.5px solid #FEF0C7; + background: #fffaeb; + border-top: 0.5px solid #fef0c7; border-radius: 12px; font-size: 12px; line-height: 18px; @@ -61,7 +61,7 @@ } .indexItem .warningTip .click { - color: #155EEF; + color: #155eef; cursor: pointer; } @@ -77,11 +77,11 @@ } .indexItem.disabled:hover .radio { - @apply w-4 h-4 border-[2px] border-gray-200 rounded-full; + @apply h-4 w-4 rounded-full border-[2px] border-gray-200; } .radioItem { - @apply relative mb-2 rounded-xl border border-components-option-card-option-border cursor-pointer bg-components-option-card-option-bg; + @apply relative mb-2 cursor-pointer rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg; } .radioItem.segmentationItem.custom { @@ -120,12 +120,12 @@ left: 20px; width: 32px; height: 32px; - background: #EEF4FF center no-repeat; + background: #eef4ff center no-repeat; border-radius: 8px; } .typeIcon.auto { - background-color: #F5F3FF; + background-color: #f5f3ff; background-image: url(../assets/zap-fast.svg); } @@ -134,7 +134,7 @@ } .typeIcon.qualified { - background-color: #FFF6ED; + background-color: #fff6ed; background-image: url(../assets/star-07.svg); } @@ -143,7 +143,7 @@ } .radioItem .radio { - @apply w-4 h-4 border-[2px] border-gray-200 rounded-full; + @apply h-4 w-4 rounded-full border-[2px] border-gray-200; position: absolute; top: 26px; right: 20px; @@ -151,8 +151,10 @@ .radioItem:hover { background-color: #ffffff; - border-color: #B2CCFF; - box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03); + border-color: #b2ccff; + box-shadow: + 0px 12px 16px -4px rgba(16, 24, 40, 0.08), + 0px 4px 6px -2px rgba(16, 24, 40, 0.03); } .radioItem:hover .radio { @@ -161,21 +163,25 @@ .radioItem.active { border-width: 1.5px; - border-color: #528BFF; - box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06); + border-color: #528bff; + box-shadow: + 0px 1px 3px rgba(16, 24, 40, 0.1), + 0px 1px 2px rgba(16, 24, 40, 0.06); } .radioItem.active .radio { top: 25.5px; right: 19.5px; border-width: 5px; - border-color: #155EEF; + border-color: #155eef; } .radioItem.active:hover { border-width: 1.5px; - border-color: #528BFF; - box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06); + border-color: #528bff; + box-shadow: + 0px 1px 3px rgba(16, 24, 40, 0.1), + 0px 1px 2px rgba(16, 24, 40, 0.06); } .radioItem.active .typeIcon { @@ -188,7 +194,7 @@ } .typeHeader { - @apply flex flex-col px-16 py-3 justify-center; + @apply flex flex-col justify-center px-16 py-3; } .typeHeader .title { @@ -214,21 +220,21 @@ align-items: center; padding: 0 6px; margin-left: 4px; - border: 1px solid #E0EAFF; + border: 1px solid #e0eaff; border-radius: 6px; font-weight: 500; font-size: 12px; line-height: 20px; - color: #444CE7; + color: #444ce7; } .typeFormBody { @apply px-16; - border-top: 1px solid #F2F4F7; + border-top: 1px solid #f2f4f7; } .formRow { - @apply flex justify-between mt-6; + @apply mt-6 flex justify-between; } .formRow .label { @@ -253,21 +259,21 @@ } .input { - @apply inline-flex h-9 w-full py-1 px-2 pr-14 rounded-lg text-xs leading-normal; - @apply bg-gray-100 caret-primary-600 hover:bg-gray-100 focus:inset-ring-1 focus:inset-ring-gray-200 focus:bg-white focus-visible:outline-hidden placeholder:text-gray-400; + @apply inline-flex h-9 w-full rounded-lg px-2 py-1 pr-14 text-xs leading-normal; + @apply bg-gray-100 caret-primary-600 placeholder:text-gray-400 hover:bg-gray-100 focus:bg-white focus:inset-ring-1 focus:inset-ring-gray-200 focus-visible:outline-hidden; } .source { - @apply flex justify-between items-center mt-8 px-6 py-4 rounded-xl bg-gray-50 border border-gray-100; + @apply mt-8 flex items-center justify-between rounded-xl border border-gray-100 bg-gray-50 px-6 py-4; } .source .divider { - @apply shrink-0 mx-4 w-px bg-gray-200; + @apply mx-4 w-px shrink-0 bg-gray-200; height: 42px; } .fileIcon { - @apply inline-flex mr-1 w-6 h-6 bg-center bg-no-repeat; + @apply mr-1 inline-flex h-6 w-6 bg-center bg-no-repeat; background-image: url(../assets/pdf.svg); background-size: 24px; } @@ -317,7 +323,7 @@ } .sourceCount { - @apply shrink-0 ml-1; + @apply ml-1 shrink-0; font-weight: 500; font-size: 13px; line-height: 18px; @@ -330,26 +336,26 @@ } .divider { - @apply mx-3 w-px h-4 bg-gray-200; + @apply mx-3 h-4 w-px bg-gray-200; } .calculating { - color: #98A2B3; + color: #98a2b3; font-size: 12px; line-height: 18px; } .sideTip { - @apply flex flex-col items-center shrink-0; + @apply flex shrink-0 flex-col items-center; padding-top: 108px; width: 524px; - border-left: 0.5px solid #F2F4F7; + border-left: 0.5px solid #f2f4f7; } .tipCard { @apply flex flex-col items-start p-6; width: 320px; - background-color: #F9FAFB; + background-color: #f9fafb; box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05); border-radius: 12px; } @@ -357,7 +363,7 @@ .tipCard .icon { width: 32px; height: 32px; - border: 1px solid #EAECF0; + border: 1px solid #eaecf0; border-radius: 6px; background: center no-repeat url(../assets/book-open-01.svg); background-size: 16px; @@ -395,7 +401,7 @@ font-size: 12px; line-height: 18px; background: rgba(255, 255, 255, 0.9); - border-bottom: 0.5px solid #EAECF0; + border-bottom: 0.5px solid #eaecf0; backdrop-filter: blur(4px); animation: fix 0.5s; } diff --git a/web/app/components/datasets/create/step-two/index.tsx b/web/app/components/datasets/create/step-two/index.tsx index f842a5ba2e915a..fc9f36d105212a 100644 --- a/web/app/components/datasets/create/step-two/index.tsx +++ b/web/app/components/datasets/create/step-two/index.tsx @@ -14,8 +14,22 @@ import { LanguagesSupported } from '@/i18n-config/language' import { DataSourceProvider } from '@/models/common' import { ChunkingMode, ProcessMode } from '@/models/datasets' import { useFetchDefaultProcessRule } from '@/service/knowledge/use-create-dataset' -import { GeneralChunkingOptions, IndexingModeSection, ParentChildOptions, PreviewPanel, StepTwoFooter } from './components' -import { IndexingType, MAXIMUM_CHUNK_TOKEN_LENGTH, useDocumentCreation, useIndexingConfig, useIndexingEstimate, usePreviewState, useSegmentationState } from './hooks' +import { + GeneralChunkingOptions, + IndexingModeSection, + ParentChildOptions, + PreviewPanel, + StepTwoFooter, +} from './components' +import { + IndexingType, + MAXIMUM_CHUNK_TOKEN_LENGTH, + useDocumentCreation, + useIndexingConfig, + useIndexingEstimate, + usePreviewState, + useSegmentationState, +} from './hooks' // eslint-disable-next-line no-barrel-files/no-barrel-files export { IndexingType } @@ -45,8 +59,8 @@ const StepTwo: FC<StepTwoProps> = ({ const { t } = useTranslation() const locale = useLocale() const isMobile = useBreakpoints() === MediaType.mobile - const currentDataset = useDatasetDetailContextWithSelector(s => s.dataset) - const mutateDatasetRes = useDatasetDetailContextWithSelector(s => s.mutateDatasetRes) + const currentDataset = useDatasetDetailContextWithSelector((s) => s.dataset) + const mutateDatasetRes = useDatasetDetailContextWithSelector((s) => s.mutateDatasetRes) // Computed flags const isInUpload = Boolean(currentDataset) @@ -54,30 +68,52 @@ const StepTwo: FC<StepTwoProps> = ({ const isNotUploadInEmptyDataset = !isUploadInEmptyDataset const isInInit = !isInUpload && !isSetting const isInCreatePage = !datasetId || (datasetId && !currentDataset?.data_source_type) - const dataSourceType = isInCreatePage ? inCreatePageDataSourceType : (currentDataset?.data_source_type ?? inCreatePageDataSourceType) + const dataSourceType = isInCreatePage + ? inCreatePageDataSourceType + : (currentDataset?.data_source_type ?? inCreatePageDataSourceType) const hasSetIndexType = !!propsIndexingType const isModelAndRetrievalConfigDisabled = !!datasetId && !!currentDataset?.data_source_type // Document form state - const [docForm, setDocForm] = useState<ChunkingMode>((datasetId && documentDetail) ? documentDetail.doc_form as ChunkingMode : ChunkingMode.text) - const [docLanguage, setDocLanguage] = useState<string>(() => (datasetId && documentDetail) ? documentDetail.doc_language : (locale !== LanguagesSupported[1] ? 'English' : 'Chinese Simplified')) + const [docForm, setDocForm] = useState<ChunkingMode>( + datasetId && documentDetail ? (documentDetail.doc_form as ChunkingMode) : ChunkingMode.text, + ) + const [docLanguage, setDocLanguage] = useState<string>(() => + datasetId && documentDetail + ? documentDetail.doc_language + : locale !== LanguagesSupported[1] + ? 'English' + : 'Chinese Simplified', + ) const [isQAConfirmDialogOpen, setIsQAConfirmDialogOpen] = useState(false) const currentDocForm = currentDataset?.doc_form || docForm // Custom hooks const segmentation = useSegmentationState({ - initialSegmentationType: currentDataset?.doc_form === ChunkingMode.parentChild ? ProcessMode.parentChild : ProcessMode.general, + initialSegmentationType: + currentDataset?.doc_form === ChunkingMode.parentChild + ? ProcessMode.parentChild + : ProcessMode.general, initialSummaryIndexSetting: currentDataset?.summary_index_setting, }) const showSummaryIndexSetting = !currentDataset const indexing = useIndexingConfig({ initialIndexType: propsIndexingType, - initialEmbeddingModel: currentDataset?.embedding_model ? { provider: currentDataset.embedding_model_provider, model: currentDataset.embedding_model } : undefined, + initialEmbeddingModel: currentDataset?.embedding_model + ? { provider: currentDataset.embedding_model_provider, model: currentDataset.embedding_model } + : undefined, initialRetrievalConfig: currentDataset?.retrieval_model_dict, isAPIKeySet, hasSetIndexType, }) - const preview = usePreviewState({ dataSourceType, files, notionPages, websitePages, documentDetail, datasetId }) + const preview = usePreviewState({ + dataSourceType, + files, + notionPages, + websitePages, + documentDetail, + datasetId, + }) const creation = useDocumentCreation({ datasetId, isSetting, @@ -128,29 +164,41 @@ const StepTwo: FC<StepTwoProps> = ({ }) // Event handlers - const handleDocFormChange = useCallback((value: ChunkingMode) => { - if (value === ChunkingMode.qa && indexing.indexType === IndexingType.ECONOMICAL) { - setIsQAConfirmDialogOpen(true) - return - } - if (value === ChunkingMode.parentChild && indexing.indexType === IndexingType.ECONOMICAL) - indexing.setIndexType(IndexingType.QUALIFIED) - setDocForm(value) - segmentation.setSegmentationType(value === ChunkingMode.parentChild ? ProcessMode.parentChild : ProcessMode.general) - estimateHook.reset() - }, [indexing, segmentation, estimateHook]) + const handleDocFormChange = useCallback( + (value: ChunkingMode) => { + if (value === ChunkingMode.qa && indexing.indexType === IndexingType.ECONOMICAL) { + setIsQAConfirmDialogOpen(true) + return + } + if (value === ChunkingMode.parentChild && indexing.indexType === IndexingType.ECONOMICAL) + indexing.setIndexType(IndexingType.QUALIFIED) + setDocForm(value) + segmentation.setSegmentationType( + value === ChunkingMode.parentChild ? ProcessMode.parentChild : ProcessMode.general, + ) + estimateHook.reset() + }, + [indexing, segmentation, estimateHook], + ) const updatePreview = useCallback(() => { - if (segmentation.segmentationType === ProcessMode.general && segmentation.maxChunkLength > MAXIMUM_CHUNK_TOKEN_LENGTH) { - toast.error(t($ => $['stepTwo.maxLengthCheck'], { ns: 'datasetCreation', limit: MAXIMUM_CHUNK_TOKEN_LENGTH })) + if ( + segmentation.segmentationType === ProcessMode.general && + segmentation.maxChunkLength > MAXIMUM_CHUNK_TOKEN_LENGTH + ) { + toast.error( + t(($) => $['stepTwo.maxLengthCheck'], { + ns: 'datasetCreation', + limit: MAXIMUM_CHUNK_TOKEN_LENGTH, + }), + ) return } estimateHook.fetchEstimate() }, [segmentation, t, estimateHook]) const handleCreate = useCallback(async () => { - if (!canCreateDocument) - return + if (!canCreateDocument) return const isValid = creation.validateParams({ segmentationType: segmentation.segmentationType, @@ -162,19 +210,28 @@ const StepTwo: FC<StepTwoProps> = ({ rerankModelList: indexing.rerankModelList, retrievalConfig: indexing.retrievalConfig, }) - if (!isValid) - return - const params = creation.buildCreationParams(currentDocForm, docLanguage, segmentation.getProcessRule(currentDocForm), indexing.retrievalConfig, indexing.embeddingModel, indexing.getIndexingTechnique(), segmentation.summaryIndexSetting) - if (!params) - return + if (!isValid) return + const params = creation.buildCreationParams( + currentDocForm, + docLanguage, + segmentation.getProcessRule(currentDocForm), + indexing.retrievalConfig, + indexing.embeddingModel, + indexing.getIndexingTechnique(), + segmentation.summaryIndexSetting, + ) + if (!params) return await creation.executeCreation(params, indexing.indexType, indexing.retrievalConfig) }, [canCreateDocument, creation, segmentation, indexing, currentDocForm, docLanguage]) - const handlePickerChange = useCallback((selected: { id: string, name: string }) => { - estimateHook.reset() - preview.handlePreviewChange(selected) - estimateHook.fetchEstimate() - }, [estimateHook, preview]) + const handlePickerChange = useCallback( + (selected: { id: string; name: string }) => { + estimateHook.reset() + preview.handlePreviewChange(selected) + estimateHook.fetchEstimate() + }, + [estimateHook, preview], + ) const handleQAConfirm = useCallback(() => { setIsQAConfirmDialogOpen(false) @@ -186,24 +243,35 @@ const StepTwo: FC<StepTwoProps> = ({ useEffect(() => { if (!isSetting) { fetchDefaultProcessRuleMutation.mutate('/datasets/process-rule') - } - else if (documentDetail) { + } else if (documentDetail) { const rules = documentDetail.dataset_process_rule.rules - const isHierarchical = documentDetail.doc_form === ChunkingMode.parentChild || Boolean(rules.parent_mode && rules.subchunk_segmentation) + const isHierarchical = + documentDetail.doc_form === ChunkingMode.parentChild || + Boolean(rules.parent_mode && rules.subchunk_segmentation) segmentation.applyConfigFromRules(rules, isHierarchical) segmentation.setSegmentationType(documentDetail.dataset_process_rule.mode) } - // eslint-disable-next-line react/exhaustive-deps + // eslint-disable-next-line react/exhaustive-deps }, []) // Show options conditions - const showGeneralOption = (isInUpload && [ChunkingMode.text, ChunkingMode.qa].includes(currentDataset!.doc_form)) || isUploadInEmptyDataset || isInInit - const showParentChildOption = (isInUpload && currentDataset!.doc_form === ChunkingMode.parentChild) || isUploadInEmptyDataset || isInInit + const showGeneralOption = + (isInUpload && [ChunkingMode.text, ChunkingMode.qa].includes(currentDataset!.doc_form)) || + isUploadInEmptyDataset || + isInInit + const showParentChildOption = + (isInUpload && currentDataset!.doc_form === ChunkingMode.parentChild) || + isUploadInEmptyDataset || + isInInit return ( <div className="flex size-full"> - <div className={cn('relative h-full w-1/2 overflow-y-auto py-6', isMobile ? 'px-4' : 'px-12')}> - <div className="mb-1 system-md-semibold text-text-secondary">{t($ => $['stepTwo.segmentation'], { ns: 'datasetCreation' })}</div> + <div + className={cn('relative h-full w-1/2 overflow-y-auto py-6', isMobile ? 'px-4' : 'px-12')} + > + <div className="mb-1 system-md-semibold text-text-secondary"> + {t(($) => $['stepTwo.segmentation'], { ns: 'datasetCreation' })} + </div> {showGeneralOption && ( <GeneralChunkingOptions segmentIdentifier={segmentation.segmentIdentifier} @@ -216,7 +284,7 @@ const StepTwo: FC<StepTwoProps> = ({ isInUpload={isInUpload} isNotUploadInEmptyDataset={isNotUploadInEmptyDataset} hasCurrentDatasetDocForm={!!currentDataset?.doc_form} - onSegmentIdentifierChange={value => segmentation.setSegmentIdentifier(value, true)} + onSegmentIdentifierChange={(value) => segmentation.setSegmentIdentifier(value, true)} onMaxChunkLengthChange={segmentation.setMaxChunkLength} onOverlapChange={segmentation.setOverlap} onRuleToggle={segmentation.toggleRule} @@ -240,10 +308,10 @@ const StepTwo: FC<StepTwoProps> = ({ isNotUploadInEmptyDataset={isNotUploadInEmptyDataset} onDocFormChange={handleDocFormChange} onChunkForContextChange={segmentation.setChunkForContext} - onParentDelimiterChange={v => segmentation.updateParentConfig('delimiter', v)} - onParentMaxLengthChange={v => segmentation.updateParentConfig('maxLength', v)} - onChildDelimiterChange={v => segmentation.updateChildConfig('delimiter', v)} - onChildMaxLengthChange={v => segmentation.updateChildConfig('maxLength', v)} + onParentDelimiterChange={(v) => segmentation.updateParentConfig('delimiter', v)} + onParentMaxLengthChange={(v) => segmentation.updateParentConfig('maxLength', v)} + onChildDelimiterChange={(v) => segmentation.updateChildConfig('delimiter', v)} + onChildMaxLengthChange={(v) => segmentation.updateChildConfig('maxLength', v)} onRuleToggle={segmentation.toggleRule} onPreview={updatePreview} onReset={segmentation.resetToDefaults} @@ -270,7 +338,13 @@ const StepTwo: FC<StepTwoProps> = ({ onQAConfirmDialogClose={() => setIsQAConfirmDialogOpen(false)} onQAConfirmDialogConfirm={handleQAConfirm} /> - <StepTwoFooter isSetting={isSetting} isCreating={creation.isCreating} onPrevious={() => onStepChange?.(-1)} onCreate={handleCreate} onCancel={onCancel} /> + <StepTwoFooter + isSetting={isSetting} + isCreating={creation.isCreating} + onPrevious={() => onStepChange?.(-1)} + onCreate={handleCreate} + onCancel={onCancel} + /> </div> <PreviewPanel isMobile={isMobile} @@ -279,7 +353,9 @@ const StepTwo: FC<StepTwoProps> = ({ estimate={estimateHook.estimate} parentChildConfig={segmentation.parentChildConfig} isSetting={isSetting} - pickerFiles={preview.getPreviewPickerItems() as Array<{ id: string, name: string, extension: string }>} + pickerFiles={ + preview.getPreviewPickerItems() as Array<{ id: string; name: string; extension: string }> + } pickerValue={preview.getPreviewPickerValue()} isIdle={estimateHook.isIdle} isPending={estimateHook.isPending} diff --git a/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx index f75d45c41c9e6d..3dbdcfce6d9f97 100644 --- a/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ import * as React from 'react' import { languages } from '@/i18n-config/language' import LanguageSelect from '../index' -const supportedLanguages = languages.filter(language => language.supported) +const supportedLanguages = languages.filter((language) => language.supported) const createDefaultProps = (overrides?: Partial<ILanguageSelectProps>): ILanguageSelectProps => ({ currentLanguage: 'English', @@ -39,7 +39,9 @@ describe('LanguageSelect', () => { it('should render non-listed current language values', () => { render(<LanguageSelect {...createDefaultProps({ currentLanguage: 'NonExistentLanguage' })} />) - expect(screen.getByRole('combobox', { name: 'language' })).toHaveTextContent('NonExistentLanguage') + expect(screen.getByRole('combobox', { name: 'language' })).toHaveTextContent( + 'NonExistentLanguage', + ) }) it('should render a placeholder when current language is empty', () => { @@ -65,7 +67,7 @@ describe('LanguageSelect', () => { await openSelect() - const unsupportedLanguages = languages.filter(language => !language.supported) + const unsupportedLanguages = languages.filter((language) => !language.supported) unsupportedLanguages.forEach((language) => { expect(screen.queryByRole('option', { name: language.prompt_name })).not.toBeInTheDocument() }) @@ -109,13 +111,23 @@ describe('LanguageSelect', () => { it('should ignore null values emitted by the select control', async () => { vi.resetModules() vi.doMock('@langgenius/dify-ui/select', () => ({ - Select: ({ onValueChange, children }: { onValueChange?: (value: string | null) => void, children: React.ReactNode }) => { + Select: ({ + onValueChange, + children, + }: { + onValueChange?: (value: string | null) => void + children: React.ReactNode + }) => { React.useEffect(() => { onValueChange?.(null) }, [onValueChange]) return <div>{children}</div> }, - SelectTrigger: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type="button" {...props}>{children}</button>, + SelectTrigger: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => ( + <button type="button" {...props}> + {children} + </button> + ), SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, SelectItemText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>, @@ -160,7 +172,11 @@ describe('LanguageSelect', () => { render(<LanguageSelect {...createDefaultProps()} />) const trigger = screen.getByRole('combobox', { name: 'language' }) - expect(trigger).toHaveClass('mx-1', 'bg-components-button-tertiary-bg', 'text-components-button-tertiary-text') + expect(trigger).toHaveClass( + 'mx-1', + 'bg-components-button-tertiary-bg', + 'text-components-button-tertiary-text', + ) }) it('should be wrapped with React.memo', () => { diff --git a/web/app/components/datasets/create/step-two/language-select/index.tsx b/web/app/components/datasets/create/step-two/language-select/index.tsx index bd1eee3df61994..486b436a6d424f 100644 --- a/web/app/components/datasets/create/step-two/language-select/index.tsx +++ b/web/app/components/datasets/create/step-two/language-select/index.tsx @@ -1,7 +1,14 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import * as React from 'react' import { languages } from '@/i18n-config/language' @@ -11,19 +18,14 @@ export type ILanguageSelectProps = { disabled?: boolean } -const LanguageSelect: FC<ILanguageSelectProps> = ({ - currentLanguage, - onSelect, - disabled, -}) => { - const supportedLanguages = languages.filter(language => language.supported) +const LanguageSelect: FC<ILanguageSelectProps> = ({ currentLanguage, onSelect, disabled }) => { + const supportedLanguages = languages.filter((language) => language.supported) return ( <Select value={currentLanguage} onValueChange={(value) => { - if (value == null) - return + if (value == null) return onSelect(value) }} disabled={disabled} @@ -33,16 +35,13 @@ const LanguageSelect: FC<ILanguageSelectProps> = ({ aria-label="language" className={cn( 'mx-1 w-auto shrink-0 bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-tertiary-bg', - disabled && 'cursor-not-allowed bg-components-button-tertiary-bg-disabled text-components-button-tertiary-text-disabled hover:bg-components-button-tertiary-bg-disabled', + disabled && + 'cursor-not-allowed bg-components-button-tertiary-bg-disabled text-components-button-tertiary-text-disabled hover:bg-components-button-tertiary-bg-disabled', )} > {currentLanguage || <span> </span>} </SelectTrigger> - <SelectContent - placement="bottom-start" - sideOffset={4} - popupClassName="w-max" - > + <SelectContent placement="bottom-start" sideOffset={4} popupClassName="w-max"> {supportedLanguages.map(({ prompt_name }) => ( <SelectItem key={prompt_name} value={prompt_name}> <SelectItemText>{prompt_name}</SelectItemText> diff --git a/web/app/components/datasets/create/step-two/types.ts b/web/app/components/datasets/create/step-two/types.ts index 3868bb0ef4f4be..ab5542901d900c 100644 --- a/web/app/components/datasets/create/step-two/types.ts +++ b/web/app/components/datasets/create/step-two/types.ts @@ -1,6 +1,13 @@ import type { IndexingType } from './hooks' import type { DataSourceProvider, NotionPage } from '@/models/common' -import type { CrawlOptions, CrawlResultItem, createDocumentResponse, CustomFile, DataSourceType, FullDocumentDetail } from '@/models/datasets' +import type { + CrawlOptions, + CrawlResultItem, + createDocumentResponse, + CustomFile, + DataSourceType, + FullDocumentDetail, +} from '@/models/datasets' import type { RETRIEVE_METHOD } from '@/types/app' export type StepTwoProps = { diff --git a/web/app/components/datasets/create/stepper/__tests__/index.spec.tsx b/web/app/components/datasets/create/stepper/__tests__/index.spec.tsx index 0c16a2f95a3f37..3942d624b73fe6 100644 --- a/web/app/components/datasets/create/stepper/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/stepper/__tests__/index.spec.tsx @@ -336,9 +336,7 @@ describe('StepperStep', () => { it('should calculate active correctly for different indices', () => { // Test index 1 with activeIndex 1 - const { rerender } = render( - <StepperStep name="Step" index={1} activeIndex={1} />, - ) + const { rerender } = render(<StepperStep name="Step" index={1} activeIndex={1} />) expect(screen.getByText('STEP 2')).toBeInTheDocument() // Test index 5 with activeIndex 5 @@ -457,9 +455,7 @@ describe('StepperStep', () => { describe('activeIndex prop', () => { it('should determine state based on activeIndex comparison', () => { // Active: index === activeIndex - const { rerender } = render( - <StepperStep name="Step" index={1} activeIndex={1} />, - ) + const { rerender } = render(<StepperStep name="Step" index={1} activeIndex={1} />) expect(screen.getByText('STEP 2')).toBeInTheDocument() // Completed: index < activeIndex diff --git a/web/app/components/datasets/create/stepper/index.tsx b/web/app/components/datasets/create/stepper/index.tsx index 918e3b6a4295a2..a560d02e18dad0 100644 --- a/web/app/components/datasets/create/stepper/index.tsx +++ b/web/app/components/datasets/create/stepper/index.tsx @@ -16,11 +16,7 @@ export const Stepper: FC<StepperProps> = (props) => { const isLast = index === steps.length - 1 return ( <Fragment key={index}> - <StepperStep - {...step} - activeIndex={activeIndex} - index={index} - /> + <StepperStep {...step} activeIndex={activeIndex} index={index} /> {!isLast && <div className="h-px w-4 bg-divider-deep" />} </Fragment> ) diff --git a/web/app/components/datasets/create/stepper/step.tsx b/web/app/components/datasets/create/stepper/step.tsx index a29790afecfa18..23b8634ec3e120 100644 --- a/web/app/components/datasets/create/stepper/step.tsx +++ b/web/app/components/datasets/create/stepper/step.tsx @@ -17,26 +17,38 @@ export const StepperStep: FC<StepperStepProps> = (props) => { const label = isActive ? `STEP ${index + 1}` : `${index + 1}` return ( <div className="flex items-center gap-2"> - <div className={cn('inline-flex h-5 flex-col items-center justify-center gap-2 rounded-3xl py-1', isActive - ? 'bg-state-accent-solid px-2' - : !isDisabled - ? 'w-5 border border-text-quaternary' - : 'w-5 border border-divider-deep')} + <div + className={cn( + 'inline-flex h-5 flex-col items-center justify-center gap-2 rounded-3xl py-1', + isActive + ? 'bg-state-accent-solid px-2' + : !isDisabled + ? 'w-5 border border-text-quaternary' + : 'w-5 border border-divider-deep', + )} > - <div className={cn('text-center system-2xs-semibold-uppercase', isActive - ? 'text-text-primary-on-surface' - : !isDisabled - ? 'text-text-tertiary' - : 'text-text-quaternary')} + <div + className={cn( + 'text-center system-2xs-semibold-uppercase', + isActive + ? 'text-text-primary-on-surface' + : !isDisabled + ? 'text-text-tertiary' + : 'text-text-quaternary', + )} > {label} </div> </div> - <div className={cn('system-xs-medium-uppercase', isActive - ? 'system-xs-semibold-uppercase text-text-accent' - : !isDisabled - ? 'text-text-tertiary' - : 'text-text-quaternary')} + <div + className={cn( + 'system-xs-medium-uppercase', + isActive + ? 'system-xs-semibold-uppercase text-text-accent' + : !isDisabled + ? 'text-text-tertiary' + : 'text-text-quaternary', + )} > {name} </div> diff --git a/web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx b/web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx index b182efa59add1e..3be7a099a21cee 100644 --- a/web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx @@ -4,7 +4,17 @@ import { TopBar } from '../index' // Mock next/link to capture href values vi.mock('@/next/link', () => ({ - default: ({ children, href, replace, className }: { children: React.ReactNode, href: string, replace?: boolean, className?: string }) => ( + default: ({ + children, + href, + replace, + className, + }: { + children: React.ReactNode + href: string + replace?: boolean + className?: string + }) => ( <a href={href} data-replace={replace} className={className} data-testid="back-link"> {children} </a> @@ -204,12 +214,18 @@ describe('TopBar', () => { rerender(<TopBar activeIndex={0} datasetId="new-dataset" />) - expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-dataset/documents') + expect(screen.getByTestId('back-link')).toHaveAttribute( + 'href', + '/datasets/new-dataset/documents', + ) }) it('should update fallbackRoute when datasetId changes from defined to undefined', () => { const { rerender } = render(<TopBar activeIndex={0} datasetId="existing-id" />) - expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/existing-id/documents') + expect(screen.getByTestId('back-link')).toHaveAttribute( + 'href', + '/datasets/existing-id/documents', + ) rerender(<TopBar activeIndex={0} datasetId={undefined} />) @@ -227,7 +243,9 @@ describe('TopBar', () => { }) it('should not change fallbackRoute when className changes but datasetId stays same', () => { - const { rerender } = render(<TopBar activeIndex={0} datasetId="stable-id" className="class-1" />) + const { rerender } = render( + <TopBar activeIndex={0} datasetId="stable-id" className="class-1" />, + ) const initialHref = screen.getByTestId('back-link').getAttribute('href') rerender(<TopBar activeIndex={0} datasetId="stable-id" className="class-2" />) @@ -334,7 +352,10 @@ describe('TopBar', () => { const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') - expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/full-props-id/documents') + expect(screen.getByTestId('back-link')).toHaveAttribute( + 'href', + '/datasets/full-props-id/documents', + ) }) it('should render correctly with minimal props (only activeIndex)', () => { @@ -364,7 +385,9 @@ describe('TopBar', () => { const { container } = renderTopBar({ activeIndex: 0 }) // Assert - Check for centered positioning classes - const centeredContainer = container.querySelector('.absolute.left-1\\/2.top-1\\/2.-translate-1\\/2') + const centeredContainer = container.querySelector( + '.absolute.left-1\\/2.top-1\\/2.-translate-1\\/2', + ) expect(centeredContainer).toBeInTheDocument() }) @@ -436,7 +459,10 @@ describe('TopBar', () => { expect(container.firstChild).toBeInTheDocument() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('new-class') - expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/another-id/documents') + expect(screen.getByTestId('back-link')).toHaveAttribute( + 'href', + '/datasets/another-id/documents', + ) }) }) }) diff --git a/web/app/components/datasets/create/top-bar/index.tsx b/web/app/components/datasets/create/top-bar/index.tsx index ce9734f3fd8374..aa90cac5ed7670 100644 --- a/web/app/components/datasets/create/top-bar/index.tsx +++ b/web/app/components/datasets/create/top-bar/index.tsx @@ -27,19 +27,30 @@ export const TopBar: FC<TopBarProps> = (props) => { }, [datasetId]) return ( - <div className={cn('relative flex h-[52px] shrink-0 items-center justify-between border-b border-b-divider-subtle', className)}> - <Link href={fallbackRoute} replace className="inline-flex h-12 items-center justify-start gap-1 py-2 pr-6 pl-2"> + <div + className={cn( + 'relative flex h-[52px] shrink-0 items-center justify-between border-b border-b-divider-subtle', + className, + )} + > + <Link + href={fallbackRoute} + replace + className="inline-flex h-12 items-center justify-start gap-1 py-2 pr-6 pl-2" + > <div className="p-2"> <RiArrowLeftLine className="size-4 text-text-primary" /> </div> <p className="system-sm-semibold-uppercase text-text-primary"> - {t($ => $['steps.header.fallbackRoute'], { ns: 'datasetCreation' })} + {t(($) => $['steps.header.fallbackRoute'], { ns: 'datasetCreation' })} </p> </Link> <div className="absolute top-1/2 left-1/2 -translate-1/2"> <Stepper steps={Array.from({ length: 3 }, (_, i) => ({ - name: t($ => $[STEP_T_MAP[(i + 1) as keyof typeof STEP_T_MAP]], { ns: 'datasetCreation' }), + name: t(($) => $[STEP_T_MAP[(i + 1) as keyof typeof STEP_T_MAP]], { + ns: 'datasetCreation', + }), }))} {...rest} /> diff --git a/web/app/components/datasets/create/website/__tests__/base.spec.tsx b/web/app/components/datasets/create/website/__tests__/base.spec.tsx index 573fd4065802be..cbec02e4c8f48d 100644 --- a/web/app/components/datasets/create/website/__tests__/base.spec.tsx +++ b/web/app/components/datasets/create/website/__tests__/base.spec.tsx @@ -365,7 +365,8 @@ describe('CrawledResult', () => { }) const getSelectAllCheckbox = () => screen.getByRole('checkbox', { name: /selectAll|resetAll/ }) - const getItemCheckbox = (index: number) => screen.getByRole('checkbox', { name: new RegExp(`Page ${index + 1}`) }) + const getItemCheckbox = (index: number) => + screen.getByRole('checkbox', { name: new RegExp(`Page ${index + 1}`) }) describe('Rendering', () => { it('should render all items in list', () => { diff --git a/web/app/components/datasets/create/website/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/__tests__/index.spec.tsx index 1d976ca0fa57e8..7a5da3aeec0695 100644 --- a/web/app/components/datasets/create/website/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/website/__tests__/index.spec.tsx @@ -30,21 +30,29 @@ vi.mock('../index.module.css', () => ({ })) vi.mock('../firecrawl', () => ({ - default: (props: Record<string, unknown>) => <div data-testid="firecrawl-component" data-props={JSON.stringify(props)} />, + default: (props: Record<string, unknown>) => ( + <div data-testid="firecrawl-component" data-props={JSON.stringify(props)} /> + ), })) vi.mock('../jina-reader', () => ({ - default: (props: Record<string, unknown>) => <div data-testid="jina-reader-component" data-props={JSON.stringify(props)} />, + default: (props: Record<string, unknown>) => ( + <div data-testid="jina-reader-component" data-props={JSON.stringify(props)} /> + ), })) vi.mock('../watercrawl', () => ({ - default: (props: Record<string, unknown>) => <div data-testid="watercrawl-component" data-props={JSON.stringify(props)} />, + default: (props: Record<string, unknown>) => ( + <div data-testid="watercrawl-component" data-props={JSON.stringify(props)} /> + ), })) vi.mock('../no-data', () => ({ - default: ({ onConfig, provider }: { onConfig: () => void, provider: string }) => ( + default: ({ onConfig, provider }: { onConfig: () => void; provider: string }) => ( <div data-testid="no-data-component" data-provider={provider}> - <button onClick={onConfig} data-testid="no-data-config-button">Configure</button> + <button onClick={onConfig} data-testid="no-data-config-button"> + Configure + </button> </div> ), })) @@ -54,9 +62,15 @@ let mockEnableFirecrawl = true let mockEnableWatercrawl = true vi.mock('@/config', () => ({ - get ENABLE_WEBSITE_JINAREADER() { return mockEnableJinaReader }, - get ENABLE_WEBSITE_FIRECRAWL() { return mockEnableFirecrawl }, - get ENABLE_WEBSITE_WATERCRAWL() { return mockEnableWatercrawl }, + get ENABLE_WEBSITE_JINAREADER() { + return mockEnableJinaReader + }, + get ENABLE_WEBSITE_FIRECRAWL() { + return mockEnableFirecrawl + }, + get ENABLE_WEBSITE_WATERCRAWL() { + return mockEnableWatercrawl + }, })) const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({ @@ -70,10 +84,7 @@ const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOpt ...overrides, }) -const createMockDataSourceAuth = ( - provider: string, - credentialsCount = 1, -): DataSourceAuth => ({ +const createMockDataSourceAuth = (provider: string, credentialsCount = 1): DataSourceAuth => ({ author: 'test', provider, plugin_id: `${provider}-plugin`, diff --git a/web/app/components/datasets/create/website/__tests__/no-data.spec.tsx b/web/app/components/datasets/create/website/__tests__/no-data.spec.tsx index b19e117d69e683..21b88068a729e3 100644 --- a/web/app/components/datasets/create/website/__tests__/no-data.spec.tsx +++ b/web/app/components/datasets/create/website/__tests__/no-data.spec.tsx @@ -19,9 +19,15 @@ let mockEnableJinaReader = true let mockEnableWaterCrawl = true vi.mock('@/config', () => ({ - get ENABLE_WEBSITE_FIRECRAWL() { return mockEnableFirecrawl }, - get ENABLE_WEBSITE_JINAREADER() { return mockEnableJinaReader }, - get ENABLE_WEBSITE_WATERCRAWL() { return mockEnableWaterCrawl }, + get ENABLE_WEBSITE_FIRECRAWL() { + return mockEnableFirecrawl + }, + get ENABLE_WEBSITE_JINAREADER() { + return mockEnableJinaReader + }, + get ENABLE_WEBSITE_WATERCRAWL() { + return mockEnableWaterCrawl + }, })) // NoData Component Tests diff --git a/web/app/components/datasets/create/website/base/__tests__/crawled-result-item.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/crawled-result-item.spec.tsx index a6a85d3eef4f82..20494e5385060a 100644 --- a/web/app/components/datasets/create/website/base/__tests__/crawled-result-item.spec.tsx +++ b/web/app/components/datasets/create/website/base/__tests__/crawled-result-item.spec.tsx @@ -5,7 +5,10 @@ import CrawledResultItem from '../crawled-result-item' describe('CrawledResultItem', () => { const defaultProps = { - payload: { title: 'Example Page', source_url: 'https://example.com/page' } as CrawlResultItemType, + payload: { + title: 'Example Page', + source_url: 'https://example.com/page', + } as CrawlResultItemType, isChecked: false, isPreview: false, onCheckChange: vi.fn(), diff --git a/web/app/components/datasets/create/website/base/__tests__/crawling.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/crawling.spec.tsx index 36fbf6fbc5e4f5..f72fe090f2b2c2 100644 --- a/web/app/components/datasets/create/website/base/__tests__/crawling.spec.tsx +++ b/web/app/components/datasets/create/website/base/__tests__/crawling.spec.tsx @@ -3,7 +3,9 @@ import { describe, expect, it, vi } from 'vitest' import Crawling from '../crawling' vi.mock('@/app/components/base/icons/src/public/other', () => ({ - RowStruct: (props: React.HTMLAttributes<HTMLDivElement>) => <div data-testid="row-struct" {...props} />, + RowStruct: (props: React.HTMLAttributes<HTMLDivElement>) => ( + <div data-testid="row-struct" {...props} /> + ), })) describe('Crawling', () => { diff --git a/web/app/components/datasets/create/website/base/__tests__/error-message.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/error-message.spec.tsx index c5218229829f68..c9b8730866c819 100644 --- a/web/app/components/datasets/create/website/base/__tests__/error-message.spec.tsx +++ b/web/app/components/datasets/create/website/base/__tests__/error-message.spec.tsx @@ -3,7 +3,9 @@ import { describe, expect, it, vi } from 'vitest' import ErrorMessage from '../error-message' vi.mock('@/app/components/base/icons/src/vender/solid/alertsAndFeedback', () => ({ - AlertTriangle: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="alert-icon" {...props} />, + AlertTriangle: (props: React.SVGProps<SVGSVGElement>) => ( + <svg data-testid="alert-icon" {...props} /> + ), })) describe('ErrorMessage', () => { diff --git a/web/app/components/datasets/create/website/base/__tests__/options-wrap.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/options-wrap.spec.tsx index 06e62d41fbabeb..6d5311340ad129 100644 --- a/web/app/components/datasets/create/website/base/__tests__/options-wrap.spec.tsx +++ b/web/app/components/datasets/create/website/base/__tests__/options-wrap.spec.tsx @@ -3,7 +3,9 @@ import { describe, expect, it, vi } from 'vitest' import OptionsWrap from '../options-wrap' vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ - ChevronRight: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="chevron-icon" {...props} />, + ChevronRight: (props: React.SVGProps<SVGSVGElement>) => ( + <svg data-testid="chevron-icon" {...props} /> + ), })) describe('OptionsWrap', () => { diff --git a/web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx index 6ee8481de0adfa..bd0db16d115e24 100644 --- a/web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx +++ b/web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx @@ -2,9 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { expectLoadingButton } from '@/test/button' - // Component Imports (after mocks) - import UrlInput from '../url-input' // Mock Setup diff --git a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx index 2868e8e45de0e1..b41a9b408b15cb 100644 --- a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx +++ b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx @@ -23,11 +23,13 @@ export default function CheckboxWithLabel({ return ( <div className={cn(className, 'flex h-7 items-center')}> <label className="flex min-w-0 cursor-pointer items-center"> - <Checkbox - checked={isChecked} - onCheckedChange={checked => onChange(checked)} - /> - <span className={cn('ml-2 min-w-0 text-left text-sm font-normal text-text-secondary', labelClassName)}> + <Checkbox checked={isChecked} onCheckedChange={(checked) => onChange(checked)} /> + <span + className={cn( + 'ml-2 min-w-0 text-left text-sm font-normal text-text-secondary', + labelClassName, + )} + > {label} </span> </label> diff --git a/web/app/components/datasets/create/website/base/crawled-result-item.tsx b/web/app/components/datasets/create/website/base/crawled-result-item.tsx index 8bd8510fb0331c..7301229af7a8f0 100644 --- a/web/app/components/datasets/create/website/base/crawled-result-item.tsx +++ b/web/app/components/datasets/create/website/base/crawled-result-item.tsx @@ -24,27 +24,26 @@ const CrawledResultItem: FC<Props> = ({ }) => { const { t } = useTranslation() return ( - <div className={cn(isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover', 'rounded-lg p-2')}> + <div + className={cn( + isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover', + 'rounded-lg p-2', + )} + > <div className="relative flex"> <label className="flex min-w-0 grow cursor-pointer"> <div className="flex h-5 items-center"> <Checkbox className="mr-2 shrink-0" checked={isChecked} - onCheckedChange={checked => onCheckChange(checked)} + onCheckedChange={(checked) => onCheckChange(checked)} /> </div> <div className="flex min-w-0 grow flex-col"> - <div - className="truncate text-sm font-medium text-text-secondary" - title={payload.title} - > + <div className="truncate text-sm font-medium text-text-secondary" title={payload.title}> {payload.title} </div> - <div - className="mt-0.5 truncate text-xs text-text-tertiary" - title={payload.source_url} - > + <div className="mt-0.5 truncate text-xs text-text-tertiary" title={payload.source_url}> {payload.source_url} </div> </div> @@ -53,7 +52,7 @@ const CrawledResultItem: FC<Props> = ({ onClick={onPreview} className="top-0 right-0 hidden h-6 px-1.5 text-xs font-medium uppercase group-hover:absolute group-hover:block" > - {t($ => $['stepOne.website.preview'], { ns: 'datasetCreation' })} + {t(($) => $['stepOne.website.preview'], { ns: 'datasetCreation' })} </Button> </div> </div> diff --git a/web/app/components/datasets/create/website/base/crawled-result.tsx b/web/app/components/datasets/create/website/base/crawled-result.tsx index 432a95dffc0420..bae352606b4f40 100644 --- a/web/app/components/datasets/create/website/base/crawled-result.tsx +++ b/web/app/components/datasets/create/website/base/crawled-result.tsx @@ -32,42 +32,54 @@ const CrawledResult: FC<Props> = ({ const isCheckAll = checkedList.length === list.length const handleCheckedAll = useCallback(() => { - if (!isCheckAll) - onSelectedChange(list) - - else - onSelectedChange([]) + if (!isCheckAll) onSelectedChange(list) + else onSelectedChange([]) }, [isCheckAll, list, onSelectedChange]) - const handleItemCheckChange = useCallback((item: CrawlResultItem) => { - return (checked: boolean) => { - if (checked) - onSelectedChange([...checkedList, item]) - - else - onSelectedChange(checkedList.filter(checkedItem => checkedItem.source_url !== item.source_url)) - } - }, [checkedList, onSelectedChange]) + const handleItemCheckChange = useCallback( + (item: CrawlResultItem) => { + return (checked: boolean) => { + if (checked) onSelectedChange([...checkedList, item]) + else + onSelectedChange( + checkedList.filter((checkedItem) => checkedItem.source_url !== item.source_url), + ) + } + }, + [checkedList, onSelectedChange], + ) const [previewIndex, setPreviewIndex] = React.useState<number>(-1) - const handlePreview = useCallback((index: number) => { - return () => { - setPreviewIndex(index) - onPreview(list[index]!) - } - }, [list, onPreview]) + const handlePreview = useCallback( + (index: number) => { + return () => { + setPreviewIndex(index) + onPreview(list[index]!) + } + }, + [list, onPreview], + ) return ( - <div className={cn(className, 'border-t-[0.5px] border-divider-regular shadow-xs shadow-shadow-shadow-3')}> + <div + className={cn( + className, + 'border-t-[0.5px] border-divider-regular shadow-xs shadow-shadow-shadow-3', + )} + > <div className="flex h-[34px] items-center justify-between px-4"> <CheckboxWithLabel isChecked={isCheckAll} onChange={handleCheckedAll} - label={isCheckAll ? t($ => $[`${I18N_PREFIX}.resetAll`], { ns: 'datasetCreation' }) : t($ => $[`${I18N_PREFIX}.selectAll`], { ns: 'datasetCreation' })} + label={ + isCheckAll + ? t(($) => $[`${I18N_PREFIX}.resetAll`], { ns: 'datasetCreation' }) + : t(($) => $[`${I18N_PREFIX}.selectAll`], { ns: 'datasetCreation' }) + } labelClassName="system-[13px] leading-[16px] font-medium text-text-secondary" /> <div className="text-xs text-text-tertiary"> - {t($ => $[`${I18N_PREFIX}.scrapTimeInfo`], { + {t(($) => $[`${I18N_PREFIX}.scrapTimeInfo`], { ns: 'datasetCreation', total: list.length, time: usedTime.toFixed(1), @@ -81,7 +93,9 @@ const CrawledResult: FC<Props> = ({ isPreview={index === previewIndex} onPreview={handlePreview(index)} payload={item} - isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)} + isChecked={checkedList.some( + (checkedItem) => checkedItem.source_url === item.source_url, + )} onCheckChange={handleItemCheckChange(item)} /> ))} diff --git a/web/app/components/datasets/create/website/base/crawling.tsx b/web/app/components/datasets/create/website/base/crawling.tsx index a6fe574743a245..c51a201047c468 100644 --- a/web/app/components/datasets/create/website/base/crawling.tsx +++ b/web/app/components/datasets/create/website/base/crawling.tsx @@ -10,22 +10,13 @@ type Props = Readonly<{ totalNum: number }> -const Crawling: FC<Props> = ({ - className = '', - crawledNum, - totalNum, -}) => { +const Crawling: FC<Props> = ({ className = '', crawledNum, totalNum }) => { const { t } = useTranslation() return ( <div className={className}> - <div className="flex h-[34px] items-center border-y-[0.5px] border-divider-regular px-4 - text-xs text-text-tertiary shadow-xs shadow-shadow-shadow-3" - > - {t($ => $['stepOne.website.totalPageScraped'], { ns: 'datasetCreation' })} - {' '} - {crawledNum} - / + <div className="flex h-[34px] items-center border-y-[0.5px] border-divider-regular px-4 text-xs text-text-tertiary shadow-xs shadow-shadow-shadow-3"> + {t(($) => $['stepOne.website.totalPageScraped'], { ns: 'datasetCreation' })} {crawledNum}/ {totalNum} </div> diff --git a/web/app/components/datasets/create/website/base/error-message.tsx b/web/app/components/datasets/create/website/base/error-message.tsx index 0344c0b066fb50..c4c872aa04f5e8 100644 --- a/web/app/components/datasets/create/website/base/error-message.tsx +++ b/web/app/components/datasets/create/website/base/error-message.tsx @@ -10,13 +10,14 @@ type Props = Readonly<{ errorMsg?: string }> -const ErrorMessage: FC<Props> = ({ - className, - title, - errorMsg, -}) => { +const ErrorMessage: FC<Props> = ({ className, title, errorMsg }) => { return ( - <div className={cn(className, 'border-t border-divider-subtle bg-dataset-warning-message-bg px-4 py-2 opacity-40')}> + <div + className={cn( + className, + 'border-t border-divider-subtle bg-dataset-warning-message-bg px-4 py-2 opacity-40', + )} + > <div className="flex h-5 items-center"> <AlertTriangle className="mr-2 size-4 text-text-warning-secondary" /> <div className="system-md-medium text-text-warning">{title}</div> diff --git a/web/app/components/datasets/create/website/base/field.tsx b/web/app/components/datasets/create/website/base/field.tsx index 33227adc3fcc8f..a93af69f1edad6 100644 --- a/web/app/components/datasets/create/website/base/field.tsx +++ b/web/app/components/datasets/create/website/base/field.tsx @@ -31,23 +31,24 @@ const Field: FC<Props> = ({ return ( <div className={cn(className)}> <div className="flex py-[7px]"> - <div className={cn(labelClassName, 'flex h-[16px] items-center text-[13px] font-semibold text-text-secondary')}> - {label} - {' '} + <div + className={cn( + labelClassName, + 'flex h-[16px] items-center text-[13px] font-semibold text-text-secondary', + )} + > + {label}{' '} </div> - {isRequired && <span className="ml-0.5 text-xs font-semibold text-text-destructive">*</span>} + {isRequired && ( + <span className="ml-0.5 text-xs font-semibold text-text-destructive">*</span> + )} {tooltip && ( <Infotip aria-label={tooltip} className="ml-0.5" popupClassName="w-[200px]"> {tooltip} </Infotip> )} </div> - <Input - value={value} - onChange={onChange} - placeholder={placeholder} - isNumber={isNumber} - /> + <Input value={value} onChange={onChange} placeholder={placeholder} isNumber={isNumber} /> </div> ) } diff --git a/web/app/components/datasets/create/website/base/header.tsx b/web/app/components/datasets/create/website/base/header.tsx index e24a428aa18f95..04a96ca8db7efd 100644 --- a/web/app/components/datasets/create/website/base/header.tsx +++ b/web/app/components/datasets/create/website/base/header.tsx @@ -24,10 +24,11 @@ const Header = ({ return ( <div className="flex items-center gap-x-2"> <div className="flex shrink-0 grow items-center gap-x-1"> - <div className={cn( - 'text-text-secondary', - isInPipeline ? 'system-sm-semibold' : 'system-md-semibold', - )} + <div + className={cn( + 'text-text-secondary', + isInPipeline ? 'system-sm-semibold' : 'system-md-semibold', + )} > {title} </div> @@ -39,11 +40,7 @@ const Header = ({ onClick={onClickConfiguration} > <RiEqualizer2Line className="size-4" /> - {!isInPipeline && ( - <span className="system-xs-medium"> - {buttonText} - </span> - )} + {!isInPipeline && <span className="system-xs-medium">{buttonText}</span>} </Button> </div> <a @@ -53,7 +50,9 @@ const Header = ({ rel="noopener noreferrer" > <RiBookOpenLine className="size-3.5 shrink-0" /> - <span className="grow truncate" title={docTitle}>{docTitle}</span> + <span className="grow truncate" title={docTitle}> + {docTitle} + </span> </a> </div> ) diff --git a/web/app/components/datasets/create/website/base/options-wrap.tsx b/web/app/components/datasets/create/website/base/options-wrap.tsx index 0dacb3f4c2c7d6..fe6498669f43c0 100644 --- a/web/app/components/datasets/create/website/base/options-wrap.tsx +++ b/web/app/components/datasets/create/website/base/options-wrap.tsx @@ -16,21 +16,13 @@ type Props = Readonly<{ controlFoldOptions?: number }> -const OptionsWrap: FC<Props> = ({ - className = '', - children, - controlFoldOptions, -}) => { +const OptionsWrap: FC<Props> = ({ className = '', children, controlFoldOptions }) => { const { t } = useTranslation() - const [fold, { - toggle: foldToggle, - setTrue: foldHide, - }] = useBoolean(false) + const [fold, { toggle: foldToggle, setTrue: foldHide }] = useBoolean(false) useEffect(() => { - if (controlFoldOptions) - foldHide() + if (controlFoldOptions) foldHide() }, [controlFoldOptions]) return ( <div className={cn(className, !fold ? 'mb-0' : 'mb-3')}> @@ -40,16 +32,13 @@ const OptionsWrap: FC<Props> = ({ > <div className="flex grow items-center"> <RiEqualizer2Line className="mr-1 size-4 text-text-secondary" /> - <span className="text-[13px] leading-[16px] font-semibold text-text-secondary uppercase">{t($ => $[`${I18N_PREFIX}.options`], { ns: 'datasetCreation' })}</span> + <span className="text-[13px] leading-[16px] font-semibold text-text-secondary uppercase"> + {t(($) => $[`${I18N_PREFIX}.options`], { ns: 'datasetCreation' })} + </span> </div> <ChevronRight className={cn(!fold && 'rotate-90', 'size-4 shrink-0 text-text-tertiary')} /> </div> - {!fold && ( - <div className="mb-4"> - {children} - </div> - )} - + {!fold && <div className="mb-4">{children}</div>} </div> ) } diff --git a/web/app/components/datasets/create/website/base/text-input.tsx b/web/app/components/datasets/create/website/base/text-input.tsx index 8048d614ff1142..d3ab1d3b09fce5 100644 --- a/web/app/components/datasets/create/website/base/text-input.tsx +++ b/web/app/components/datasets/create/website/base/text-input.tsx @@ -12,28 +12,25 @@ type Props = Readonly<{ const MIN_VALUE = 0 -const Input: FC<Props> = ({ - value, - onChange, - placeholder = '', - isNumber = false, -}) => { - const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { - const value = e.target.value - if (isNumber) { - let numberValue = Number.parseInt(value, 10) // integer only - if (Number.isNaN(numberValue)) { - onChange('') +const Input: FC<Props> = ({ value, onChange, placeholder = '', isNumber = false }) => { + const handleChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + const value = e.target.value + if (isNumber) { + let numberValue = Number.parseInt(value, 10) // integer only + if (Number.isNaN(numberValue)) { + onChange('') + return + } + if (numberValue < MIN_VALUE) numberValue = MIN_VALUE + + onChange(numberValue) return } - if (numberValue < MIN_VALUE) - numberValue = MIN_VALUE - - onChange(numberValue) - return - } - onChange(value) - }, [isNumber, onChange]) + onChange(value) + }, + [isNumber, onChange], + ) const otherOption = (() => { if (isNumber) { @@ -41,9 +38,7 @@ const Input: FC<Props> = ({ min: MIN_VALUE, } } - return { - - } + return {} })() return ( <input @@ -51,12 +46,7 @@ const Input: FC<Props> = ({ {...otherOption} value={value} onChange={handleChange} - className="focus:bg-components-inout-border-active flex h-8 w-full rounded-lg border border-transparent bg-components-input-bg-normal - p-2 system-xs-regular text-components-input-text-filled - caret-[#295eff] placeholder:text-components-input-text-placeholder hover:border - hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border focus:border-components-input-border-active - focus:shadow-xs focus:shadow-shadow-shadow-3 - focus-visible:outline-hidden" + className="focus:bg-components-inout-border-active flex h-8 w-full rounded-lg border border-transparent bg-components-input-bg-normal p-2 system-xs-regular text-components-input-text-filled caret-[#295eff] placeholder:text-components-input-text-placeholder hover:border hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border focus:border-components-input-border-active focus:shadow-xs focus:shadow-shadow-shadow-3 focus-visible:outline-hidden" placeholder={placeholder} /> ) diff --git a/web/app/components/datasets/create/website/base/url-input.tsx b/web/app/components/datasets/create/website/base/url-input.tsx index 261bdb40664e90..a3ba51c4049187 100644 --- a/web/app/components/datasets/create/website/base/url-input.tsx +++ b/web/app/components/datasets/create/website/base/url-input.tsx @@ -14,10 +14,7 @@ type Props = Readonly<{ onRun: (url: string) => void }> -const UrlInput: FC<Props> = ({ - isRunning, - onRun, -}) => { +const UrlInput: FC<Props> = ({ isRunning, onRun }) => { const { t } = useTranslation() const docLink = useDocLink() const [url, setUrl] = useState('') @@ -25,24 +22,15 @@ const UrlInput: FC<Props> = ({ setUrl(url as string) }, []) const handleOnRun = useCallback(() => { - if (isRunning) - return + if (isRunning) return onRun(url) }, [isRunning, onRun, url]) return ( <div className="flex items-center justify-between gap-x-2"> - <Input - value={url} - onChange={handleUrlChange} - placeholder={docLink()} - /> - <Button - variant="primary" - onClick={handleOnRun} - loading={isRunning} - > - {!isRunning ? t($ => $[`${I18N_PREFIX}.run`], { ns: 'datasetCreation' }) : ''} + <Input value={url} onChange={handleUrlChange} placeholder={docLink()} /> + <Button variant="primary" onClick={handleOnRun} loading={isRunning}> + {!isRunning ? t(($) => $[`${I18N_PREFIX}.run`], { ns: 'datasetCreation' }) : ''} </Button> </div> ) diff --git a/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx index 6289e5847dd730..caa5f5ab7aa322 100644 --- a/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx @@ -2,9 +2,7 @@ import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' - // Component Import (after mocks) - import FireCrawl from '../index' // Mock Setup - Only mock API calls and context @@ -666,9 +664,7 @@ describe('FireCrawl', () => { const limitInput = screen.getByDisplayValue('10') fireEvent.change(limitInput, { target: { value: '20' } }) - expect(mockOnCrawlOptionsChange).toHaveBeenCalledWith( - expect.objectContaining({ limit: 20 }), - ) + expect(mockOnCrawlOptionsChange).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 })) }) it('should call onCrawlOptionsChange when checkbox changes', () => { diff --git a/web/app/components/datasets/create/website/firecrawl/__tests__/options.spec.tsx b/web/app/components/datasets/create/website/firecrawl/__tests__/options.spec.tsx index 60c239bfa10b71..869a2ff5937ce6 100644 --- a/web/app/components/datasets/create/website/firecrawl/__tests__/options.spec.tsx +++ b/web/app/components/datasets/create/website/firecrawl/__tests__/options.spec.tsx @@ -102,25 +102,37 @@ describe('Options', () => { const payload = createMockCrawlOptions({ crawl_sub_pages: true }) render(<Options payload={payload} onChange={mockOnChange} />) - expect(screen.getByRole('checkbox', { name: /crawlSubPage/i })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('checkbox', { name: /crawlSubPage/i })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) it('should display crawl_sub_pages checkbox without check icon when false', () => { const payload = createMockCrawlOptions({ crawl_sub_pages: false }) render(<Options payload={payload} onChange={mockOnChange} />) - expect(screen.getByRole('checkbox', { name: /crawlSubPage/i })).toHaveAttribute('aria-checked', 'false') + expect(screen.getByRole('checkbox', { name: /crawlSubPage/i })).toHaveAttribute( + 'aria-checked', + 'false', + ) }) it('should display only_main_content checkbox with check icon when true', () => { const payload = createMockCrawlOptions({ only_main_content: true }) render(<Options payload={payload} onChange={mockOnChange} />) - expect(screen.getByRole('checkbox', { name: /extractOnlyMainContent/i })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('checkbox', { name: /extractOnlyMainContent/i })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) it('should display only_main_content checkbox without check icon when false', () => { const payload = createMockCrawlOptions({ only_main_content: false }) render(<Options payload={payload} onChange={mockOnChange} />) - expect(screen.getByRole('checkbox', { name: /extractOnlyMainContent/i })).toHaveAttribute('aria-checked', 'false') + expect(screen.getByRole('checkbox', { name: /extractOnlyMainContent/i })).toHaveAttribute( + 'aria-checked', + 'false', + ) }) it('should display limit value in input', () => { @@ -319,17 +331,13 @@ describe('Options', () => { const limitInput = screen.getByDisplayValue('10') fireEvent.change(limitInput, { target: { value: '15' } }) - expect(mockOnChange).toHaveBeenCalledWith( - expect.objectContaining({ limit: 15 }), - ) + expect(mockOnChange).toHaveBeenCalledWith(expect.objectContaining({ limit: 15 })) // Change max_depth const maxDepthInput = screen.getByDisplayValue('2') fireEvent.change(maxDepthInput, { target: { value: '5' } }) - expect(mockOnChange).toHaveBeenCalledWith( - expect.objectContaining({ max_depth: 5 }), - ) + expect(mockOnChange).toHaveBeenCalledWith(expect.objectContaining({ max_depth: 5 })) }) it('should handle multiple rapid changes', () => { diff --git a/web/app/components/datasets/create/website/firecrawl/index.tsx b/web/app/components/datasets/create/website/firecrawl/index.tsx index 6e77d6935d9178..a7a5b3ca4ec38b 100644 --- a/web/app/components/datasets/create/website/firecrawl/index.tsx +++ b/web/app/components/datasets/create/website/firecrawl/index.tsx @@ -32,7 +32,7 @@ const Step = { running: 'running', finished: 'finished', } as const -type Step = typeof Step[keyof typeof Step] +type Step = (typeof Step)[keyof typeof Step] type CrawlState = { current: number total: number @@ -47,14 +47,20 @@ type CrawlFinishedResult = { data: CrawlResultItem[] } } -const FireCrawl: FC<Props> = ({ onPreview, checkedCrawlResult, onCheckedCrawlResultChange, onJobIdChange, crawlOptions, onCrawlOptionsChange }) => { +const FireCrawl: FC<Props> = ({ + onPreview, + checkedCrawlResult, + onCheckedCrawlResultChange, + onJobIdChange, + crawlOptions, + onCrawlOptionsChange, +}) => { const { t } = useTranslation() const [step, setStep] = useState<Step>(Step.init) const [controlFoldOptions, setControlFoldOptions] = useState<number>(0) const isMountedRef = useRef(true) useEffect(() => { - if (step !== Step.init) - setControlFoldOptions(Date.now()) + if (step !== Step.init) setControlFoldOptions(Date.now()) }, [step]) useEffect(() => { return () => { @@ -67,139 +73,149 @@ const FireCrawl: FC<Props> = ({ onPreview, checkedCrawlResult, onCheckedCrawlRes payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [openIntegrationsSetting]) - const checkValid = useCallback((url: string) => { - let errorMsg = '' - if (!url) { - errorMsg = t($ => $[`${ERROR_I18N_PREFIX}.fieldRequired`], { - ns: 'common', - field: 'url', - }) - } - if (!errorMsg && !((url.startsWith('http://') || url.startsWith('https://')))) - errorMsg = t($ => $[`${ERROR_I18N_PREFIX}.urlError`], { ns: 'common' }) - if (!errorMsg && (crawlOptions.limit === null || crawlOptions.limit === undefined || crawlOptions.limit === '')) { - errorMsg = t($ => $[`${ERROR_I18N_PREFIX}.fieldRequired`], { - ns: 'common', - field: t($ => $[`${I18N_PREFIX}.limit`], { ns: 'datasetCreation' }), - }) - } - return { - isValid: !errorMsg, - errorMsg, - } - }, [crawlOptions, t]) + const checkValid = useCallback( + (url: string) => { + let errorMsg = '' + if (!url) { + errorMsg = t(($) => $[`${ERROR_I18N_PREFIX}.fieldRequired`], { + ns: 'common', + field: 'url', + }) + } + if (!errorMsg && !(url.startsWith('http://') || url.startsWith('https://'))) + errorMsg = t(($) => $[`${ERROR_I18N_PREFIX}.urlError`], { ns: 'common' }) + if ( + !errorMsg && + (crawlOptions.limit === null || + crawlOptions.limit === undefined || + crawlOptions.limit === '') + ) { + errorMsg = t(($) => $[`${ERROR_I18N_PREFIX}.fieldRequired`], { + ns: 'common', + field: t(($) => $[`${I18N_PREFIX}.limit`], { ns: 'datasetCreation' }), + }) + } + return { + isValid: !errorMsg, + errorMsg, + } + }, + [crawlOptions, t], + ) const isInit = step === Step.init const isCrawlFinished = step === Step.finished const isRunning = step === Step.running const [crawlResult, setCrawlResult] = useState<CrawlState | undefined>(undefined) const [crawlErrorMessage, setCrawlErrorMessage] = useState('') const showError = isCrawlFinished && crawlErrorMessage - const waitForCrawlFinished = useCallback(async (jobId: string): Promise<CrawlFinishedResult> => { - const cancelledResult: CrawlFinishedResult = { - isCancelled: true, - isError: false, - data: { - data: [], - }, - } - try { - const res = await checkFirecrawlTaskStatus(jobId) as any - if (res.status === 'completed') { - return { - isError: false, - data: { - ...res, - total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), - }, - } satisfies CrawlFinishedResult + const waitForCrawlFinished = useCallback( + async (jobId: string): Promise<CrawlFinishedResult> => { + const cancelledResult: CrawlFinishedResult = { + isCancelled: true, + isError: false, + data: { + data: [], + }, } - if (res.status === 'error' || !res.status) { - // can't get the error message from the firecrawl api + try { + const res = (await checkFirecrawlTaskStatus(jobId)) as any + if (res.status === 'completed') { + return { + isError: false, + data: { + ...res, + total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), + }, + } satisfies CrawlFinishedResult + } + if (res.status === 'error' || !res.status) { + // can't get the error message from the firecrawl api + return { + isError: true, + errorMessage: res.message, + data: { + data: [], + }, + } satisfies CrawlFinishedResult + } + res.data = res.data.map((item: any) => ({ + ...item, + content: item.markdown, + })) + if (!isMountedRef.current) return cancelledResult + // update the progress + setCrawlResult({ + ...res, + total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), + }) + onCheckedCrawlResultChange(res.data || []) // default select the crawl result + await sleep(2500) + if (!isMountedRef.current) return cancelledResult + return await waitForCrawlFinished(jobId) + } catch (e: any) { + if (!isMountedRef.current) return cancelledResult + const errorBody = typeof e?.json === 'function' ? await e.json() : undefined return { isError: true, - errorMessage: res.message, + errorMessage: errorBody?.message, data: { data: [], }, } satisfies CrawlFinishedResult } - res.data = res.data.map((item: any) => ({ - ...item, - content: item.markdown, - })) - if (!isMountedRef.current) - return cancelledResult - // update the progress - setCrawlResult({ - ...res, - total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), - }) - onCheckedCrawlResultChange(res.data || []) // default select the crawl result - await sleep(2500) - if (!isMountedRef.current) - return cancelledResult - return await waitForCrawlFinished(jobId) - } - catch (e: any) { - if (!isMountedRef.current) - return cancelledResult - const errorBody = typeof e?.json === 'function' ? await e.json() : undefined - return { - isError: true, - errorMessage: errorBody?.message, - data: { - data: [], - }, - } satisfies CrawlFinishedResult - } - }, [crawlOptions.limit, onCheckedCrawlResultChange]) - const handleRun = useCallback(async (url: string) => { - const { isValid, errorMsg } = checkValid(url) - if (!isValid) { - toast.error(errorMsg!) - return - } - setStep(Step.running) - try { - const passToServerCrawlOptions: any = { - ...crawlOptions, - } - if (crawlOptions.max_depth === '') - delete passToServerCrawlOptions.max_depth - const res = await createFirecrawlTask({ - url, - options: passToServerCrawlOptions, - }) as any - if (!isMountedRef.current) - return - const jobId = res.job_id - onJobIdChange(jobId) - const { isCancelled, isError, data, errorMessage } = await waitForCrawlFinished(jobId) - if (isCancelled || !isMountedRef.current) + }, + [crawlOptions.limit, onCheckedCrawlResultChange], + ) + const handleRun = useCallback( + async (url: string) => { + const { isValid, errorMsg } = checkValid(url) + if (!isValid) { + toast.error(errorMsg!) return - if (isError) { - setCrawlErrorMessage(errorMessage || t($ => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' })) } - else { - setCrawlResult(data as CrawlState) - onCheckedCrawlResultChange(data.data || []) // default select the crawl result - setCrawlErrorMessage('') + setStep(Step.running) + try { + const passToServerCrawlOptions: any = { + ...crawlOptions, + } + if (crawlOptions.max_depth === '') delete passToServerCrawlOptions.max_depth + const res = (await createFirecrawlTask({ + url, + options: passToServerCrawlOptions, + })) as any + if (!isMountedRef.current) return + const jobId = res.job_id + onJobIdChange(jobId) + const { isCancelled, isError, data, errorMessage } = await waitForCrawlFinished(jobId) + if (isCancelled || !isMountedRef.current) return + if (isError) { + setCrawlErrorMessage( + errorMessage || t(($) => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' }), + ) + } else { + setCrawlResult(data as CrawlState) + onCheckedCrawlResultChange(data.data || []) // default select the crawl result + setCrawlErrorMessage('') + } + } catch (e) { + if (!isMountedRef.current) return + setCrawlErrorMessage(t(($) => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' })!) + console.log(e) + } finally { + if (isMountedRef.current) setStep(Step.finished) } - } - catch (e) { - if (!isMountedRef.current) - return - setCrawlErrorMessage(t($ => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' })!) - console.log(e) - } - finally { - if (isMountedRef.current) - setStep(Step.finished) - } - }, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished, onCheckedCrawlResultChange]) + }, + [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished, onCheckedCrawlResultChange], + ) return ( <div> - <Header onClickConfiguration={handleSetting} title={t($ => $[`${I18N_PREFIX}.firecrawlTitle`], { ns: 'datasetCreation' })} buttonText={t($ => $[`${I18N_PREFIX}.configureFirecrawl`], { ns: 'datasetCreation' })} docTitle={t($ => $[`${I18N_PREFIX}.firecrawlDoc`], { ns: 'datasetCreation' })} docLink="https://docs.firecrawl.dev/introduction" /> + <Header + onClickConfiguration={handleSetting} + title={t(($) => $[`${I18N_PREFIX}.firecrawlTitle`], { ns: 'datasetCreation' })} + buttonText={t(($) => $[`${I18N_PREFIX}.configureFirecrawl`], { ns: 'datasetCreation' })} + docTitle={t(($) => $[`${I18N_PREFIX}.firecrawlDoc`], { ns: 'datasetCreation' })} + docLink="https://docs.firecrawl.dev/introduction" + /> <div className="mt-2 rounded-xl border border-components-panel-border bg-background-default-subtle p-4 pb-0"> <UrlInput onRun={handleRun} isRunning={isRunning} /> <OptionsWrap className="mt-4" controlFoldOptions={controlFoldOptions}> @@ -208,11 +224,32 @@ const FireCrawl: FC<Props> = ({ onPreview, checkedCrawlResult, onCheckedCrawlRes {!isInit && ( <div className="relative left-[-16px] mt-3 w-[calc(100%+32px)] rounded-b-xl"> - {isRunning - && (<Crawling className="mt-2" crawledNum={crawlResult?.current || 0} totalNum={crawlResult?.total || Number.parseFloat(crawlOptions.limit as string) || 0} />)} - {showError && (<ErrorMessage className="rounded-b-xl" title={t($ => $[`${I18N_PREFIX}.exceptionErrorTitle`], { ns: 'datasetCreation' })} errorMsg={crawlErrorMessage} />)} - {isCrawlFinished && !showError - && (<CrawledResult className="mb-2" list={crawlResult?.data || []} checkedList={checkedCrawlResult} onSelectedChange={onCheckedCrawlResultChange} onPreview={onPreview} usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0} />)} + {isRunning && ( + <Crawling + className="mt-2" + crawledNum={crawlResult?.current || 0} + totalNum={ + crawlResult?.total || Number.parseFloat(crawlOptions.limit as string) || 0 + } + /> + )} + {showError && ( + <ErrorMessage + className="rounded-b-xl" + title={t(($) => $[`${I18N_PREFIX}.exceptionErrorTitle`], { ns: 'datasetCreation' })} + errorMsg={crawlErrorMessage} + /> + )} + {isCrawlFinished && !showError && ( + <CrawledResult + className="mb-2" + list={crawlResult?.data || []} + checkedList={checkedCrawlResult} + onSelectedChange={onCheckedCrawlResultChange} + onPreview={onPreview} + usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0} + /> + )} </div> )} </div> diff --git a/web/app/components/datasets/create/website/firecrawl/options.tsx b/web/app/components/datasets/create/website/firecrawl/options.tsx index 0172ec8283efef..9dc08583f69aa1 100644 --- a/web/app/components/datasets/create/website/firecrawl/options.tsx +++ b/web/app/components/datasets/create/website/firecrawl/options.tsx @@ -16,25 +16,24 @@ type Props = Readonly<{ onChange: (payload: CrawlOptions) => void }> -const Options: FC<Props> = ({ - className = '', - payload, - onChange, -}) => { +const Options: FC<Props> = ({ className = '', payload, onChange }) => { const { t } = useTranslation() - const handleChange = useCallback((key: keyof CrawlOptions) => { - return (value: any) => { - onChange({ - ...payload, - [key]: value, - }) - } - }, [payload, onChange]) + const handleChange = useCallback( + (key: keyof CrawlOptions) => { + return (value: any) => { + onChange({ + ...payload, + [key]: value, + }) + } + }, + [payload, onChange], + ) return ( <div className={cn(className, 'space-y-2')}> <CheckboxWithLabel - label={t($ => $[`${I18N_PREFIX}.crawlSubPage`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.crawlSubPage`], { ns: 'datasetCreation' })} isChecked={payload.crawl_sub_pages} onChange={handleChange('crawl_sub_pages')} labelClassName="text-[13px] leading-[16px] font-medium text-text-secondary" @@ -42,7 +41,7 @@ const Options: FC<Props> = ({ <div className="flex justify-between space-x-4"> <Field className="shrink-0 grow" - label={t($ => $[`${I18N_PREFIX}.limit`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.limit`], { ns: 'datasetCreation' })} value={payload.limit} onChange={handleChange('limit')} isNumber @@ -50,32 +49,32 @@ const Options: FC<Props> = ({ /> <Field className="shrink-0 grow" - label={t($ => $[`${I18N_PREFIX}.maxDepth`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.maxDepth`], { ns: 'datasetCreation' })} value={payload.max_depth} onChange={handleChange('max_depth')} isNumber - tooltip={t($ => $[`${I18N_PREFIX}.maxDepthTooltip`], { ns: 'datasetCreation' })!} + tooltip={t(($) => $[`${I18N_PREFIX}.maxDepthTooltip`], { ns: 'datasetCreation' })!} /> </div> <div className="flex justify-between space-x-4"> <Field className="shrink-0 grow" - label={t($ => $[`${I18N_PREFIX}.excludePaths`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.excludePaths`], { ns: 'datasetCreation' })} value={payload.excludes} onChange={handleChange('excludes')} placeholder="blog/*, /about/*" /> <Field className="shrink-0 grow" - label={t($ => $[`${I18N_PREFIX}.includeOnlyPaths`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.includeOnlyPaths`], { ns: 'datasetCreation' })} value={payload.includes} onChange={handleChange('includes')} placeholder="articles/*" /> </div> <CheckboxWithLabel - label={t($ => $[`${I18N_PREFIX}.extractOnlyMainContent`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.extractOnlyMainContent`], { ns: 'datasetCreation' })} isChecked={payload.only_main_content} onChange={handleChange('only_main_content')} labelClassName="text-[13px] leading-[16px] font-medium text-text-secondary" diff --git a/web/app/components/datasets/create/website/index.module.css b/web/app/components/datasets/create/website/index.module.css index 0f07b181355f62..f5232e3140d32b 100644 --- a/web/app/components/datasets/create/website/index.module.css +++ b/web/app/components/datasets/create/website/index.module.css @@ -1,14 +1,14 @@ @reference "../../../../styles/globals.css"; .jinaLogo { - @apply w-4 h-4 bg-center bg-no-repeat inline-block; - background-color: #F5FAFF; + @apply inline-block h-4 w-4 bg-center bg-no-repeat; + background-color: #f5faff; background-image: url(../assets/jina.png); background-size: 16px; } .watercrawlLogo { - @apply w-4 h-4 bg-center bg-no-repeat inline-block; + @apply inline-block h-4 w-4 bg-center bg-no-repeat; /*background-color: #F5FAFF;*/ background-image: url(../assets/watercrawl.svg); background-size: 16px; diff --git a/web/app/components/datasets/create/website/index.tsx b/web/app/components/datasets/create/website/index.tsx index 5c1b33d925906a..bab174019f87ed 100644 --- a/web/app/components/datasets/create/website/index.tsx +++ b/web/app/components/datasets/create/website/index.tsx @@ -8,7 +8,11 @@ import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting' -import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config' +import { + ENABLE_WEBSITE_FIRECRAWL, + ENABLE_WEBSITE_JINAREADER, + ENABLE_WEBSITE_WATERCRAWL, +} from '@/config' import { DataSourceProvider } from '@/models/common' import Firecrawl from './firecrawl' import s from './index.module.css' @@ -39,15 +43,23 @@ const Website: FC<Props> = ({ }) => { const { t } = useTranslation() const openIntegrationsSetting = useIntegrationsSetting() - const [selectedProvider, setSelectedProvider] = useState<DataSourceProvider>(DataSourceProvider.jinaReader) + const [selectedProvider, setSelectedProvider] = useState<DataSourceProvider>( + DataSourceProvider.jinaReader, + ) - const availableProviders = useMemo(() => authedDataSourceList.filter((item) => { - return [ - DataSourceProvider.jinaReader, - DataSourceProvider.fireCrawl, - DataSourceProvider.waterCrawl, - ].includes(item.provider as DataSourceProvider) && item.credentials_list.length > 0 - }), [authedDataSourceList]) + const availableProviders = useMemo( + () => + authedDataSourceList.filter((item) => { + return ( + [ + DataSourceProvider.jinaReader, + DataSourceProvider.fireCrawl, + DataSourceProvider.waterCrawl, + ].includes(item.provider as DataSourceProvider) && item.credentials_list.length > 0 + ) + }), + [authedDataSourceList], + ) const handleOnConfig = useCallback(() => { openIntegrationsSetting({ @@ -55,22 +67,24 @@ const Website: FC<Props> = ({ }) }, [openIntegrationsSetting]) - const source = availableProviders.find(source => source.provider === selectedProvider) + const source = availableProviders.find((source) => source.provider === selectedProvider) return ( <div> <div className="mb-4"> <div className="mb-2 system-md-medium text-text-secondary"> - {t($ => $['stepOne.website.chooseProvider'], { ns: 'datasetCreation' })} + {t(($) => $['stepOne.website.chooseProvider'], { ns: 'datasetCreation' })} </div> <div className="flex space-x-2"> {ENABLE_WEBSITE_JINAREADER && ( <button type="button" - className={cn('flex items-center justify-center rounded-lg px-4 py-2', selectedProvider === DataSourceProvider.jinaReader - ? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium text-text-primary' - : `border border-components-option-card-option-border bg-components-option-card-option-bg system-sm-regular text-text-secondary - hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3`)} + className={cn( + 'flex items-center justify-center rounded-lg px-4 py-2', + selectedProvider === DataSourceProvider.jinaReader + ? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium text-text-primary' + : `border border-components-option-card-option-border bg-components-option-card-option-bg system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3`, + )} onClick={() => { setSelectedProvider(DataSourceProvider.jinaReader) onCrawlProviderChange(DataSourceProvider.jinaReader) @@ -83,10 +97,12 @@ const Website: FC<Props> = ({ {ENABLE_WEBSITE_FIRECRAWL && ( <button type="button" - className={cn('rounded-lg px-4 py-2', selectedProvider === DataSourceProvider.fireCrawl - ? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium text-text-primary' - : `border border-components-option-card-option-border bg-components-option-card-option-bg system-sm-regular text-text-secondary - hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3`)} + className={cn( + 'rounded-lg px-4 py-2', + selectedProvider === DataSourceProvider.fireCrawl + ? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium text-text-primary' + : `border border-components-option-card-option-border bg-components-option-card-option-bg system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3`, + )} onClick={() => { setSelectedProvider(DataSourceProvider.fireCrawl) onCrawlProviderChange(DataSourceProvider.fireCrawl) @@ -98,10 +114,12 @@ const Website: FC<Props> = ({ {ENABLE_WEBSITE_WATERCRAWL && ( <button type="button" - className={cn('flex items-center justify-center rounded-lg px-4 py-2', selectedProvider === DataSourceProvider.waterCrawl - ? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium text-text-primary' - : `border border-components-option-card-option-border bg-components-option-card-option-bg system-sm-regular text-text-secondary - hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3`)} + className={cn( + 'flex items-center justify-center rounded-lg px-4 py-2', + selectedProvider === DataSourceProvider.waterCrawl + ? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium text-text-primary' + : `border border-components-option-card-option-border bg-components-option-card-option-bg system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3`, + )} onClick={() => { setSelectedProvider(DataSourceProvider.waterCrawl) onCrawlProviderChange(DataSourceProvider.waterCrawl) @@ -143,9 +161,7 @@ const Website: FC<Props> = ({ onCrawlOptionsChange={onCrawlOptionsChange} /> )} - {!source && ( - <NoData onConfig={handleOnConfig} provider={selectedProvider} /> - )} + {!source && <NoData onConfig={handleOnConfig} provider={selectedProvider} />} </div> ) } diff --git a/web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx index d65a25286c8c0e..5b2eeefcdc3cf6 100644 --- a/web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx @@ -85,7 +85,9 @@ describe('JinaReader', () => { render(<JinaReader {...props} />) - expect(screen.getByText('datasetCreation.stepOne.website.jinaReaderTitle'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.jinaReaderTitle'), + )!.toBeInTheDocument() }) it('should render header with configuration button', () => { @@ -93,7 +95,9 @@ describe('JinaReader', () => { render(<JinaReader {...props} />) - expect(screen.getByText('datasetCreation.stepOne.website.configureJinaReader'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.configureJinaReader'), + )!.toBeInTheDocument() }) it('should render URL input field', () => { @@ -151,7 +155,9 @@ describe('JinaReader', () => { if (limitLabel) { // The limit input is a number input (spinbutton role) within the same container - const limitInput = limitLabel.closest('div')?.parentElement?.querySelector('input[type="number"]') + const limitInput = limitLabel + .closest('div') + ?.parentElement?.querySelector('input[type="number"]') if (limitInput) { await user.clear(limitInput) @@ -159,8 +165,7 @@ describe('JinaReader', () => { expect(onCrawlOptionsChange).toHaveBeenCalled() } - } - else { + } else { // Options might not be visible, just verify component renders // Options might not be visible, just verify component renders expect(screen.getByText('datasetCreation.stepOne.website.options'))!.toBeInTheDocument() @@ -212,7 +217,10 @@ describe('JinaReader', () => { const mockCreateTask = createJinaReaderTask as Mock let resolvePromise: () => void const taskPromise = new Promise((resolve) => { - resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } }) + resolvePromise = () => + resolve({ + data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' }, + }) }) mockCreateTask.mockImplementation(() => taskPromise) @@ -326,7 +334,9 @@ describe('JinaReader', () => { // Assert - options should be folded after crawl starts await waitFor(() => { - expect(screen.queryByText('datasetCreation.stepOne.website.crawlSubPage')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetCreation.stepOne.website.crawlSubPage'), + ).not.toBeInTheDocument() }) }) }) @@ -359,7 +369,10 @@ describe('JinaReader', () => { const mockCreateTask = createJinaReaderTask as Mock let resolvePromise: () => void const taskPromise = new Promise((resolve) => { - resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } }) + resolvePromise = () => + resolve({ + data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' }, + }) }) mockCreateTask.mockImplementation(() => taskPromise) @@ -410,7 +423,9 @@ describe('JinaReader', () => { it('should memoize checkValid callback based on crawlOptions', async () => { const mockCreateTask = createJinaReaderTask as Mock - mockCreateTask.mockResolvedValue({ data: { title: 'T', content: 'C', description: 'D', url: 'https://a.com' } }) + mockCreateTask.mockResolvedValue({ + data: { title: 'T', content: 'C', description: 'D', url: 'https://a.com' }, + }) const props = createDefaultProps() @@ -560,7 +575,9 @@ describe('JinaReader', () => { // Assert - options should be hidden // Assert - options should be hidden // Assert - options should be hidden - expect(screen.queryByText('datasetCreation.stepOne.website.crawlSubPage')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetCreation.stepOne.website.crawlSubPage'), + ).not.toBeInTheDocument() await userEvent.click(optionsHeader) @@ -674,7 +691,9 @@ describe('JinaReader', () => { await userEvent.click(screen.getByRole('button', { name: /run/i })) await waitFor(() => { - expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'), + )!.toBeInTheDocument() }) expect(screen.getByText('Crawl failed due to network error'))!.toBeInTheDocument() @@ -697,7 +716,9 @@ describe('JinaReader', () => { await userEvent.click(screen.getByRole('button', { name: /run/i })) await waitFor(() => { - expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'), + )!.toBeInTheDocument() }) }) @@ -712,7 +733,8 @@ describe('JinaReader', () => { current: 100, total: 100, data: Array.from({ length: 100 }, (_, i) => - createCrawlResultItem({ source_url: `https://example.com/${i}` })), + createCrawlResultItem({ source_url: `https://example.com/${i}` }), + ), }) const props = createDefaultProps({ @@ -857,7 +879,9 @@ describe('JinaReader', () => { await userEvent.click(screen.getByRole('button', { name: /run/i })) await waitFor(() => { - expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'), + )!.toBeInTheDocument() }) consoleSpy.mockRestore() @@ -881,7 +905,9 @@ describe('JinaReader', () => { await userEvent.click(screen.getByRole('button', { name: /run/i })) await waitFor(() => { - expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'), + )!.toBeInTheDocument() }) }) @@ -903,7 +929,9 @@ describe('JinaReader', () => { await userEvent.click(screen.getByRole('button', { name: /run/i })) await waitFor(() => { - expect(screen.getByText('datasetCreation.stepOne.website.unknownError'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.unknownError'), + )!.toBeInTheDocument() }) }) @@ -1444,7 +1472,9 @@ describe('JinaReader', () => { await userEvent.click(screen.getByRole('button', { name: /run/i })) await waitFor(() => { - expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'), + )!.toBeInTheDocument() }) }) }) diff --git a/web/app/components/datasets/create/website/jina-reader/__tests__/options.spec.tsx b/web/app/components/datasets/create/website/jina-reader/__tests__/options.spec.tsx index 92f6ccc90cb133..35fb801240610e 100644 --- a/web/app/components/datasets/create/website/jina-reader/__tests__/options.spec.tsx +++ b/web/app/components/datasets/create/website/jina-reader/__tests__/options.spec.tsx @@ -66,25 +66,37 @@ describe('Options (jina-reader)', () => { it('should display crawl_sub_pages checkbox with check icon when true', () => { const payload = createMockCrawlOptions({ crawl_sub_pages: true }) render(<Options payload={payload} onChange={mockOnChange} />) - expect(screen.getByRole('checkbox', { name: /crawlSubPage/i })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('checkbox', { name: /crawlSubPage/i })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) it('should display crawl_sub_pages checkbox without check icon when false', () => { const payload = createMockCrawlOptions({ crawl_sub_pages: false }) render(<Options payload={payload} onChange={mockOnChange} />) - expect(screen.getByRole('checkbox', { name: /crawlSubPage/i })).toHaveAttribute('aria-checked', 'false') + expect(screen.getByRole('checkbox', { name: /crawlSubPage/i })).toHaveAttribute( + 'aria-checked', + 'false', + ) }) it('should display use_sitemap checkbox with check icon when true', () => { const payload = createMockCrawlOptions({ use_sitemap: true }) render(<Options payload={payload} onChange={mockOnChange} />) - expect(screen.getByRole('checkbox', { name: /useSitemap/i })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('checkbox', { name: /useSitemap/i })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) it('should display use_sitemap checkbox without check icon when false', () => { const payload = createMockCrawlOptions({ use_sitemap: false }) render(<Options payload={payload} onChange={mockOnChange} />) - expect(screen.getByRole('checkbox', { name: /useSitemap/i })).toHaveAttribute('aria-checked', 'false') + expect(screen.getByRole('checkbox', { name: /useSitemap/i })).toHaveAttribute( + 'aria-checked', + 'false', + ) }) it('should display limit value in input', () => { diff --git a/web/app/components/datasets/create/website/jina-reader/index.tsx b/web/app/components/datasets/create/website/jina-reader/index.tsx index 0f73dc872a67f8..6da21c114a1db4 100644 --- a/web/app/components/datasets/create/website/jina-reader/index.tsx +++ b/web/app/components/datasets/create/website/jina-reader/index.tsx @@ -32,14 +32,20 @@ const Step = { running: 'running', finished: 'finished', } as const -type Step = typeof Step[keyof typeof Step] -const JinaReader: FC<Props> = ({ onPreview, checkedCrawlResult, onCheckedCrawlResultChange, onJobIdChange, crawlOptions, onCrawlOptionsChange }) => { +type Step = (typeof Step)[keyof typeof Step] +const JinaReader: FC<Props> = ({ + onPreview, + checkedCrawlResult, + onCheckedCrawlResultChange, + onJobIdChange, + crawlOptions, + onCrawlOptionsChange, +}) => { const { t } = useTranslation() const [step, setStep] = useState<Step>(Step.init) const [controlFoldOptions, setControlFoldOptions] = useState<number>(0) useEffect(() => { - if (step !== Step.init) - setControlFoldOptions(Date.now()) + if (step !== Step.init) setControlFoldOptions(Date.now()) }, [step]) const openIntegrationsSetting = useIntegrationsSetting() const handleSetting = useCallback(() => { @@ -47,134 +53,156 @@ const JinaReader: FC<Props> = ({ onPreview, checkedCrawlResult, onCheckedCrawlRe payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [openIntegrationsSetting]) - const checkValid = useCallback((url: string) => { - let errorMsg = '' - if (!url) { - errorMsg = t($ => $[`${ERROR_I18N_PREFIX}.fieldRequired`], { - ns: 'common', - field: 'url', - }) - } - if (!errorMsg && !((url.startsWith('http://') || url.startsWith('https://')))) - errorMsg = t($ => $[`${ERROR_I18N_PREFIX}.urlError`], { ns: 'common' }) - if (!errorMsg && (crawlOptions.limit === null || crawlOptions.limit === undefined || crawlOptions.limit === '')) { - errorMsg = t($ => $[`${ERROR_I18N_PREFIX}.fieldRequired`], { - ns: 'common', - field: t($ => $[`${I18N_PREFIX}.limit`], { ns: 'datasetCreation' }), - }) - } - return { - isValid: !errorMsg, - errorMsg, - } - }, [crawlOptions, t]) + const checkValid = useCallback( + (url: string) => { + let errorMsg = '' + if (!url) { + errorMsg = t(($) => $[`${ERROR_I18N_PREFIX}.fieldRequired`], { + ns: 'common', + field: 'url', + }) + } + if (!errorMsg && !(url.startsWith('http://') || url.startsWith('https://'))) + errorMsg = t(($) => $[`${ERROR_I18N_PREFIX}.urlError`], { ns: 'common' }) + if ( + !errorMsg && + (crawlOptions.limit === null || + crawlOptions.limit === undefined || + crawlOptions.limit === '') + ) { + errorMsg = t(($) => $[`${ERROR_I18N_PREFIX}.fieldRequired`], { + ns: 'common', + field: t(($) => $[`${I18N_PREFIX}.limit`], { ns: 'datasetCreation' }), + }) + } + return { + isValid: !errorMsg, + errorMsg, + } + }, + [crawlOptions, t], + ) const isInit = step === Step.init const isCrawlFinished = step === Step.finished const isRunning = step === Step.running - const [crawlResult, setCrawlResult] = useState<{ - current: number - total: number - data: CrawlResultItem[] - time_consuming: number | string - } | undefined>(undefined) + const [crawlResult, setCrawlResult] = useState< + | { + current: number + total: number + data: CrawlResultItem[] + time_consuming: number | string + } + | undefined + >(undefined) const [crawlErrorMessage, setCrawlErrorMessage] = useState('') const showError = isCrawlFinished && crawlErrorMessage - const waitForCrawlFinished = useCallback(async (jobId: string) => { - try { - const res = await checkJinaReaderTaskStatus(jobId) as any - if (res.status === 'completed') { - return { - isError: false, - data: { - ...res, - total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), - }, + const waitForCrawlFinished = useCallback( + async (jobId: string) => { + try { + const res = (await checkJinaReaderTaskStatus(jobId)) as any + if (res.status === 'completed') { + return { + isError: false, + data: { + ...res, + total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), + }, + } } - } - if (res.status === 'failed' || !res.status) { + if (res.status === 'failed' || !res.status) { + return { + isError: true, + errorMessage: res.message, + data: { + data: [], + }, + } + } + // update the progress + setCrawlResult({ + ...res, + total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), + }) + onCheckedCrawlResultChange(res.data || []) // default select the crawl result + await sleep(2500) + return await waitForCrawlFinished(jobId) + } catch (e: any) { + const errorBody = await e.json() return { isError: true, - errorMessage: res.message, + errorMessage: errorBody.message, data: { data: [], }, } } - // update the progress - setCrawlResult({ - ...res, - total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), - }) - onCheckedCrawlResultChange(res.data || []) // default select the crawl result - await sleep(2500) - return await waitForCrawlFinished(jobId) - } - catch (e: any) { - const errorBody = await e.json() - return { - isError: true, - errorMessage: errorBody.message, - data: { - data: [], - }, - } - } - }, [crawlOptions.limit, onCheckedCrawlResultChange]) - const handleRun = useCallback(async (url: string) => { - const { isValid, errorMsg } = checkValid(url) - if (!isValid) { - toast.error(errorMsg!) - return - } - setStep(Step.running) - try { - const startTime = Date.now() - const res = await createJinaReaderTask({ - url, - options: crawlOptions, - }) as any - if (res.data) { - const { title, content, description, url } = res.data - const data = { - current: 1, - total: 1, - data: [{ - title, - markdown: content, - description, - source_url: url, - }], - time_consuming: (Date.now() - startTime) / 1000, - } - setCrawlResult(data) - onCheckedCrawlResultChange(data.data || []) - setCrawlErrorMessage('') + }, + [crawlOptions.limit, onCheckedCrawlResultChange], + ) + const handleRun = useCallback( + async (url: string) => { + const { isValid, errorMsg } = checkValid(url) + if (!isValid) { + toast.error(errorMsg!) + return } - else if (res.job_id) { - const jobId = res.job_id - onJobIdChange(jobId) - const { isError, data, errorMessage } = await waitForCrawlFinished(jobId) - if (isError) { - setCrawlErrorMessage(errorMessage || t($ => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' })) - } - else { + setStep(Step.running) + try { + const startTime = Date.now() + const res = (await createJinaReaderTask({ + url, + options: crawlOptions, + })) as any + if (res.data) { + const { title, content, description, url } = res.data + const data = { + current: 1, + total: 1, + data: [ + { + title, + markdown: content, + description, + source_url: url, + }, + ], + time_consuming: (Date.now() - startTime) / 1000, + } setCrawlResult(data) - onCheckedCrawlResultChange(data.data || []) // default select the crawl result + onCheckedCrawlResultChange(data.data || []) setCrawlErrorMessage('') + } else if (res.job_id) { + const jobId = res.job_id + onJobIdChange(jobId) + const { isError, data, errorMessage } = await waitForCrawlFinished(jobId) + if (isError) { + setCrawlErrorMessage( + errorMessage || t(($) => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' }), + ) + } else { + setCrawlResult(data) + onCheckedCrawlResultChange(data.data || []) // default select the crawl result + setCrawlErrorMessage('') + } } + } catch (e) { + setCrawlErrorMessage(t(($) => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' })!) + console.log(e) + } finally { + setStep(Step.finished) } - } - catch (e) { - setCrawlErrorMessage(t($ => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' })!) - console.log(e) - } - finally { - setStep(Step.finished) - } - }, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, t, waitForCrawlFinished]) + }, + [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, t, waitForCrawlFinished], + ) return ( <div> - <Header onClickConfiguration={handleSetting} title={t($ => $[`${I18N_PREFIX}.jinaReaderTitle`], { ns: 'datasetCreation' })} buttonText={t($ => $[`${I18N_PREFIX}.configureJinaReader`], { ns: 'datasetCreation' })} docTitle={t($ => $[`${I18N_PREFIX}.jinaReaderDoc`], { ns: 'datasetCreation' })} docLink="https://jina.ai/reader" /> + <Header + onClickConfiguration={handleSetting} + title={t(($) => $[`${I18N_PREFIX}.jinaReaderTitle`], { ns: 'datasetCreation' })} + buttonText={t(($) => $[`${I18N_PREFIX}.configureJinaReader`], { ns: 'datasetCreation' })} + docTitle={t(($) => $[`${I18N_PREFIX}.jinaReaderDoc`], { ns: 'datasetCreation' })} + docLink="https://jina.ai/reader" + /> <div className="mt-2 rounded-xl border border-components-panel-border bg-background-default-subtle p-4 pb-0"> <UrlInput onRun={handleRun} isRunning={isRunning} /> <OptionsWrap className="mt-4" controlFoldOptions={controlFoldOptions}> @@ -183,11 +211,32 @@ const JinaReader: FC<Props> = ({ onPreview, checkedCrawlResult, onCheckedCrawlRe {!isInit && ( <div className="relative left-[-16px] mt-3 w-[calc(100%+32px)] rounded-b-xl"> - {isRunning - && (<Crawling className="mt-2" crawledNum={crawlResult?.current || 0} totalNum={crawlResult?.total || Number.parseFloat(crawlOptions.limit as string) || 0} />)} - {showError && (<ErrorMessage className="rounded-b-xl" title={t($ => $[`${I18N_PREFIX}.exceptionErrorTitle`], { ns: 'datasetCreation' })} errorMsg={crawlErrorMessage} />)} - {isCrawlFinished && !showError - && (<CrawledResult className="mb-2" list={crawlResult?.data || []} checkedList={checkedCrawlResult} onSelectedChange={onCheckedCrawlResultChange} onPreview={onPreview} usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0} />)} + {isRunning && ( + <Crawling + className="mt-2" + crawledNum={crawlResult?.current || 0} + totalNum={ + crawlResult?.total || Number.parseFloat(crawlOptions.limit as string) || 0 + } + /> + )} + {showError && ( + <ErrorMessage + className="rounded-b-xl" + title={t(($) => $[`${I18N_PREFIX}.exceptionErrorTitle`], { ns: 'datasetCreation' })} + errorMsg={crawlErrorMessage} + /> + )} + {isCrawlFinished && !showError && ( + <CrawledResult + className="mb-2" + list={crawlResult?.data || []} + checkedList={checkedCrawlResult} + onSelectedChange={onCheckedCrawlResultChange} + onPreview={onPreview} + usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0} + /> + )} </div> )} </div> diff --git a/web/app/components/datasets/create/website/jina-reader/options.tsx b/web/app/components/datasets/create/website/jina-reader/options.tsx index c43322ea5f823f..51540bc6c6190f 100644 --- a/web/app/components/datasets/create/website/jina-reader/options.tsx +++ b/web/app/components/datasets/create/website/jina-reader/options.tsx @@ -16,40 +16,41 @@ type Props = Readonly<{ onChange: (payload: CrawlOptions) => void }> -const Options: FC<Props> = ({ - className = '', - payload, - onChange, -}) => { +const Options: FC<Props> = ({ className = '', payload, onChange }) => { const { t } = useTranslation() - const handleChange = useCallback((key: keyof CrawlOptions) => { - return (value: any) => { - onChange({ - ...payload, - [key]: value, - }) - } - }, [payload, onChange]) + const handleChange = useCallback( + (key: keyof CrawlOptions) => { + return (value: any) => { + onChange({ + ...payload, + [key]: value, + }) + } + }, + [payload, onChange], + ) return ( <div className={cn(className, 'space-y-2')}> <CheckboxWithLabel - label={t($ => $[`${I18N_PREFIX}.crawlSubPage`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.crawlSubPage`], { ns: 'datasetCreation' })} isChecked={payload.crawl_sub_pages} onChange={handleChange('crawl_sub_pages')} labelClassName="text-[13px] leading-[16px] font-medium text-text-secondary" /> <CheckboxWithLabel - label={t($ => $[`${I18N_PREFIX}.useSitemap`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.useSitemap`], { ns: 'datasetCreation' })} isChecked={payload.use_sitemap} onChange={handleChange('use_sitemap')} - tooltip={t($ => $[`${I18N_PREFIX}.useSitemapTooltip`], { ns: 'datasetCreation' }) as string} + tooltip={ + t(($) => $[`${I18N_PREFIX}.useSitemapTooltip`], { ns: 'datasetCreation' }) as string + } labelClassName="text-[13px] leading-[16px] font-medium text-text-secondary" /> <div className="flex justify-between space-x-4"> <Field className="shrink-0 grow" - label={t($ => $[`${I18N_PREFIX}.limit`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.limit`], { ns: 'datasetCreation' })} value={payload.limit} onChange={handleChange('limit')} isNumber diff --git a/web/app/components/datasets/create/website/no-data.tsx b/web/app/components/datasets/create/website/no-data.tsx index 8044c9b48e831d..810bd8b4dd88a9 100644 --- a/web/app/components/datasets/create/website/no-data.tsx +++ b/web/app/components/datasets/create/website/no-data.tsx @@ -4,7 +4,11 @@ import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useTranslation } from 'react-i18next' import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others' -import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config' +import { + ENABLE_WEBSITE_FIRECRAWL, + ENABLE_WEBSITE_JINAREADER, + ENABLE_WEBSITE_WATERCRAWL, +} from '@/config' import { DataSourceProvider } from '@/models/common' import s from './index.module.css' @@ -15,51 +19,54 @@ type Props = Readonly<{ provider: DataSourceProvider }> -const NoData: FC<Props> = ({ - onConfig, - provider, -}) => { +const NoData: FC<Props> = ({ onConfig, provider }) => { const { t } = useTranslation() - const providerConfig: Record<DataSourceProvider, { - emoji: React.ReactNode - title: string - description: string - } | null> = { + const providerConfig: Record< + DataSourceProvider, + { + emoji: React.ReactNode + title: string + description: string + } | null + > = { [DataSourceProvider.jinaReader]: ENABLE_WEBSITE_JINAREADER ? { emoji: <span className={s.jinaLogo} />, - title: t($ => $[`${I18N_PREFIX}.jinaReaderNotConfigured`], { ns: 'datasetCreation' }), - description: t($ => $[`${I18N_PREFIX}.jinaReaderNotConfiguredDescription`], { ns: 'datasetCreation' }), + title: t(($) => $[`${I18N_PREFIX}.jinaReaderNotConfigured`], { ns: 'datasetCreation' }), + description: t(($) => $[`${I18N_PREFIX}.jinaReaderNotConfiguredDescription`], { + ns: 'datasetCreation', + }), } : null, [DataSourceProvider.fireCrawl]: ENABLE_WEBSITE_FIRECRAWL ? { emoji: '🔥', - title: t($ => $[`${I18N_PREFIX}.fireCrawlNotConfigured`], { ns: 'datasetCreation' }), - description: t($ => $[`${I18N_PREFIX}.fireCrawlNotConfiguredDescription`], { ns: 'datasetCreation' }), + title: t(($) => $[`${I18N_PREFIX}.fireCrawlNotConfigured`], { ns: 'datasetCreation' }), + description: t(($) => $[`${I18N_PREFIX}.fireCrawlNotConfiguredDescription`], { + ns: 'datasetCreation', + }), } : null, [DataSourceProvider.waterCrawl]: ENABLE_WEBSITE_WATERCRAWL ? { emoji: '💧', - title: t($ => $[`${I18N_PREFIX}.waterCrawlNotConfigured`], { ns: 'datasetCreation' }), - description: t($ => $[`${I18N_PREFIX}.waterCrawlNotConfiguredDescription`], { ns: 'datasetCreation' }), + title: t(($) => $[`${I18N_PREFIX}.waterCrawlNotConfigured`], { ns: 'datasetCreation' }), + description: t(($) => $[`${I18N_PREFIX}.waterCrawlNotConfiguredDescription`], { + ns: 'datasetCreation', + }), } : null, } const currentProvider = providerConfig[provider] || providerConfig.jinareader - if (!currentProvider) - return null + if (!currentProvider) return null return ( <> <div className="mt-4 max-w-[640px] rounded-2xl bg-workflow-process-bg p-6"> - <div className="flex h-12 w-12 items-center justify-center rounded-[10px] border-[0.5px] - border-components-card-border bg-components-card-bg shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]" - > + <div className="flex h-12 w-12 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"> {currentProvider.emoji} </div> <div className="mt-2 mb-1 flex flex-col gap-y-1 pt-1 pb-3"> @@ -67,12 +74,10 @@ const NoData: FC<Props> = ({ {currentProvider.title} <Icon3Dots className="relative -top-2.5 -left-1.5 inline" /> </span> - <div className="system-sm-regular text-text-tertiary"> - {currentProvider.description} - </div> + <div className="system-sm-regular text-text-tertiary">{currentProvider.description}</div> </div> <Button variant="primary" onClick={onConfig}> - {t($ => $[`${I18N_PREFIX}.configure`], { ns: 'datasetCreation' })} + {t(($) => $[`${I18N_PREFIX}.configure`], { ns: 'datasetCreation' })} </Button> </div> </> diff --git a/web/app/components/datasets/create/website/preview.tsx b/web/app/components/datasets/create/website/preview.tsx index b71e0227f8aa88..e891d7619f83ec 100644 --- a/web/app/components/datasets/create/website/preview.tsx +++ b/web/app/components/datasets/create/website/preview.tsx @@ -11,30 +11,27 @@ type IProps = { hidePreview: () => void } -const WebsitePreview = ({ - payload, - hidePreview, -}: IProps) => { +const WebsitePreview = ({ payload, hidePreview }: IProps) => { const { t } = useTranslation() return ( <div className={cn(s.filePreview, 'h-full')}> <div className={cn(s.previewHeader)}> <div className={cn(s.title, 'title-md-semi-bold')}> - <span>{t($ => $['stepOne.pagePreview'], { ns: 'datasetCreation' })}</span> + <span>{t(($) => $['stepOne.pagePreview'], { ns: 'datasetCreation' })}</span> <button type="button" className="flex size-6 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={hidePreview} > <XMarkIcon className="size-4" aria-hidden="true"></XMarkIcon> </button> </div> - <div className="title-sm-semi-bold wrap-break-word text-text-primary"> - {payload.title} + <div className="title-sm-semi-bold wrap-break-word text-text-primary">{payload.title}</div> + <div className="truncate system-xs-medium text-text-tertiary" title={payload.source_url}> + {payload.source_url} </div> - <div className="truncate system-xs-medium text-text-tertiary" title={payload.source_url}>{payload.source_url}</div> </div> <div className={cn(s.previewContent, 'body-md-regular')}> <div className={cn(s.fileContent)}>{payload.markdown}</div> diff --git a/web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx index b13287878c2603..557ca3f27f59b6 100644 --- a/web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx @@ -35,7 +35,8 @@ vi.mock('@/context/modal-context', () => ({ // Mock i18n context vi.mock('@/context/i18n', () => ({ - useDocLink: () => (path?: string) => path ? `https://docs.dify.ai/en${path}` : 'https://docs.dify.ai/en/', + useDocLink: () => (path?: string) => + path ? `https://docs.dify.ai/en${path}` : 'https://docs.dify.ai/en/', })) // Note: limit and max_depth are typed as `number | string` in CrawlOptions @@ -87,7 +88,9 @@ describe('WaterCrawl', () => { render(<WaterCrawl {...props} />) - expect(screen.getByText('datasetCreation.stepOne.website.watercrawlTitle'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.watercrawlTitle'), + )!.toBeInTheDocument() }) it('should render header with configuration button', () => { @@ -95,7 +98,9 @@ describe('WaterCrawl', () => { render(<WaterCrawl {...props} />) - expect(screen.getByText('datasetCreation.stepOne.website.configureWatercrawl'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.configureWatercrawl'), + )!.toBeInTheDocument() }) it('should render URL input field', () => { @@ -155,7 +160,9 @@ describe('WaterCrawl', () => { if (limitLabel) { // The limit input is a number input (spinbutton role) within the same container - const limitInput = limitLabel.closest('div')?.parentElement?.querySelector('input[type="number"]') + const limitInput = limitLabel + .closest('div') + ?.parentElement?.querySelector('input[type="number"]') if (limitInput) { await user.clear(limitInput) @@ -163,8 +170,7 @@ describe('WaterCrawl', () => { expect(onCrawlOptionsChange).toHaveBeenCalled() } - } - else { + } else { // Options might not be visible, just verify component renders // Options might not be visible, just verify component renders expect(screen.getByText('datasetCreation.stepOne.website.options'))!.toBeInTheDocument() @@ -216,9 +222,12 @@ describe('WaterCrawl', () => { it('should transition from init to running state when run is clicked', async () => { const mockCreateTask = createWatercrawlTask as Mock let resolvePromise: () => void - mockCreateTask.mockImplementation(() => new Promise((resolve) => { - resolvePromise = () => resolve({ job_id: 'test-job' }) - })) + mockCreateTask.mockImplementation( + () => + new Promise((resolve) => { + resolvePromise = () => resolve({ job_id: 'test-job' }) + }), + ) const props = createDefaultProps() @@ -329,7 +338,9 @@ describe('WaterCrawl', () => { // Assert - options should be folded after crawl starts await waitFor(() => { - expect(screen.queryByText('datasetCreation.stepOne.website.crawlSubPage')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetCreation.stepOne.website.crawlSubPage'), + ).not.toBeInTheDocument() }) }) }) @@ -360,7 +371,12 @@ describe('WaterCrawl', () => { it('should update controlFoldOptions when step changes', async () => { const mockCreateTask = createWatercrawlTask as Mock - mockCreateTask.mockImplementation(() => new Promise(() => { /* pending */ })) + mockCreateTask.mockImplementation( + () => + new Promise(() => { + /* pending */ + }), + ) const props = createDefaultProps() @@ -563,7 +579,9 @@ describe('WaterCrawl', () => { // Assert - options should be hidden // Assert - options should be hidden // Assert - options should be hidden - expect(screen.queryByText('datasetCreation.stepOne.website.crawlSubPage')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetCreation.stepOne.website.crawlSubPage'), + ).not.toBeInTheDocument() await userEvent.click(optionsHeader) @@ -680,7 +698,9 @@ describe('WaterCrawl', () => { await userEvent.click(screen.getByRole('button', { name: /run/i })) await waitFor(() => { - expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'), + )!.toBeInTheDocument() }) expect(screen.getByText('Crawl failed due to network error'))!.toBeInTheDocument() @@ -703,7 +723,9 @@ describe('WaterCrawl', () => { await userEvent.click(screen.getByRole('button', { name: /run/i })) await waitFor(() => { - expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'), + )!.toBeInTheDocument() }) }) @@ -718,7 +740,8 @@ describe('WaterCrawl', () => { current: 100, total: 100, data: Array.from({ length: 100 }, (_, i) => - createCrawlResultItem({ source_url: `https://example.com/${i}` })), + createCrawlResultItem({ source_url: `https://example.com/${i}` }), + ), }) const props = createDefaultProps({ @@ -754,7 +777,9 @@ describe('WaterCrawl', () => { await userEvent.click(screen.getByRole('button', { name: /run/i })) await waitFor(() => { - expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'), + )!.toBeInTheDocument() }) }) }) @@ -891,7 +916,9 @@ describe('WaterCrawl', () => { await userEvent.click(screen.getByRole('button', { name: /run/i })) await waitFor(() => { - expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'), + )!.toBeInTheDocument() }) consoleSpy.mockRestore() @@ -915,7 +942,9 @@ describe('WaterCrawl', () => { await userEvent.click(screen.getByRole('button', { name: /run/i })) await waitFor(() => { - expect(screen.getByText('datasetCreation.stepOne.website.unknownError'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.unknownError'), + )!.toBeInTheDocument() }) }) @@ -1006,7 +1035,12 @@ describe('WaterCrawl', () => { const mockCheckStatus = checkWatercrawlTaskStatus as Mock mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-current-job' }) - mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ })) + mockCheckStatus.mockImplementation( + () => + new Promise(() => { + /* never resolves */ + }), + ) const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: 10 }), @@ -1028,7 +1062,12 @@ describe('WaterCrawl', () => { const mockCheckStatus = checkWatercrawlTaskStatus as Mock mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-total-job' }) - mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ })) + mockCheckStatus.mockImplementation( + () => + new Promise(() => { + /* never resolves */ + }), + ) const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: '0' }), @@ -1077,7 +1116,12 @@ describe('WaterCrawl', () => { const mockCheckStatus = checkWatercrawlTaskStatus as Mock mockCreateTask.mockResolvedValueOnce({ job_id: 'no-total-job' }) - mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ })) + mockCheckStatus.mockImplementation( + () => + new Promise(() => { + /* never resolves */ + }), + ) const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: 15 }), @@ -1106,7 +1150,12 @@ describe('WaterCrawl', () => { total: 0, data: [], }) - .mockImplementationOnce(() => new Promise(() => { /* never resolves */ })) + .mockImplementationOnce( + () => + new Promise(() => { + /* never resolves */ + }), + ) const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: 5 }), @@ -1371,7 +1420,12 @@ describe('WaterCrawl', () => { const mockCheckStatus = checkWatercrawlTaskStatus as Mock mockCreateTask.mockResolvedValueOnce({ job_id: 'progress-job' }) - mockCheckStatus.mockImplementation(() => new Promise(() => { /* pending */ })) + mockCheckStatus.mockImplementation( + () => + new Promise(() => { + /* pending */ + }), + ) const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: 10 }), @@ -1451,7 +1505,9 @@ describe('WaterCrawl', () => { await userEvent.click(screen.getByRole('button', { name: /run/i })) await waitFor(() => { - expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'))!.toBeInTheDocument() + expect( + screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle'), + )!.toBeInTheDocument() }) }) @@ -1476,14 +1532,16 @@ describe('WaterCrawl', () => { current: 5, total: 10, data: Array.from({ length: 5 }, (_, i) => - createCrawlResultItem({ source_url: `https://page${i + 1}.com` })), + createCrawlResultItem({ source_url: `https://page${i + 1}.com` }), + ), }) .mockResolvedValueOnce({ status: 'completed', current: 10, total: 10, data: Array.from({ length: 10 }, (_, i) => - createCrawlResultItem({ source_url: `https://page${i + 1}.com` })), + createCrawlResultItem({ source_url: `https://page${i + 1}.com` }), + ), }) const props = createDefaultProps({ @@ -1504,9 +1562,7 @@ describe('WaterCrawl', () => { // Final result should be selected await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenLastCalledWith( - expect.arrayContaining([ - expect.objectContaining({ source_url: 'https://page1.com' }), - ]), + expect.arrayContaining([expect.objectContaining({ source_url: 'https://page1.com' })]), ) }) }) @@ -1620,11 +1676,13 @@ describe('WaterCrawl', () => { current: 1, total: 1, time_consuming: 1.2, - data: [createCrawlResultItem({ - title: 'Preview Page', - markdown: '# Preview Content', - source_url: 'https://preview.com/page', - })], + data: [ + createCrawlResultItem({ + title: 'Preview Page', + markdown: '# Preview Content', + source_url: 'https://preview.com/page', + }), + ], }) const props = createDefaultProps({ diff --git a/web/app/components/datasets/create/website/watercrawl/__tests__/options.spec.tsx b/web/app/components/datasets/create/website/watercrawl/__tests__/options.spec.tsx index 60504fe0a77443..1cfae3fcd6b21c 100644 --- a/web/app/components/datasets/create/website/watercrawl/__tests__/options.spec.tsx +++ b/web/app/components/datasets/create/website/watercrawl/__tests__/options.spec.tsx @@ -84,27 +84,39 @@ describe('Options (watercrawl)', () => { const payload = createMockCrawlOptions({ crawl_sub_pages: true }) render(<Options payload={payload} onChange={mockOnChange} />) - expect(screen.getByRole('checkbox', { name: /crawlSubPage/i })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('checkbox', { name: /crawlSubPage/i })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) it('should display crawl_sub_pages checkbox without check icon when false', () => { const payload = createMockCrawlOptions({ crawl_sub_pages: false }) render(<Options payload={payload} onChange={mockOnChange} />) - expect(screen.getByRole('checkbox', { name: /crawlSubPage/i })).toHaveAttribute('aria-checked', 'false') + expect(screen.getByRole('checkbox', { name: /crawlSubPage/i })).toHaveAttribute( + 'aria-checked', + 'false', + ) }) it('should display only_main_content checkbox with check icon when true', () => { const payload = createMockCrawlOptions({ only_main_content: true }) render(<Options payload={payload} onChange={mockOnChange} />) - expect(screen.getByRole('checkbox', { name: /extractOnlyMainContent/i })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('checkbox', { name: /extractOnlyMainContent/i })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) it('should display only_main_content checkbox without check icon when false', () => { const payload = createMockCrawlOptions({ only_main_content: false }) render(<Options payload={payload} onChange={mockOnChange} />) - expect(screen.getByRole('checkbox', { name: /extractOnlyMainContent/i })).toHaveAttribute('aria-checked', 'false') + expect(screen.getByRole('checkbox', { name: /extractOnlyMainContent/i })).toHaveAttribute( + 'aria-checked', + 'false', + ) }) it('should display limit value in input', () => { diff --git a/web/app/components/datasets/create/website/watercrawl/index.tsx b/web/app/components/datasets/create/website/watercrawl/index.tsx index 375c7d8fab3abc..9a4480d4050629 100644 --- a/web/app/components/datasets/create/website/watercrawl/index.tsx +++ b/web/app/components/datasets/create/website/watercrawl/index.tsx @@ -32,14 +32,20 @@ const Step = { running: 'running', finished: 'finished', } as const -type Step = typeof Step[keyof typeof Step] -const WaterCrawl: FC<Props> = ({ onPreview, checkedCrawlResult, onCheckedCrawlResultChange, onJobIdChange, crawlOptions, onCrawlOptionsChange }) => { +type Step = (typeof Step)[keyof typeof Step] +const WaterCrawl: FC<Props> = ({ + onPreview, + checkedCrawlResult, + onCheckedCrawlResultChange, + onJobIdChange, + crawlOptions, + onCrawlOptionsChange, +}) => { const { t } = useTranslation() const [step, setStep] = useState<Step>(Step.init) const [controlFoldOptions, setControlFoldOptions] = useState<number>(0) useEffect(() => { - if (step !== Step.init) - setControlFoldOptions(Date.now()) + if (step !== Step.init) setControlFoldOptions(Date.now()) }, [step]) const openIntegrationsSetting = useIntegrationsSetting() const handleSetting = useCallback(() => { @@ -47,136 +53,154 @@ const WaterCrawl: FC<Props> = ({ onPreview, checkedCrawlResult, onCheckedCrawlRe payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [openIntegrationsSetting]) - const checkValid = useCallback((url: string) => { - let errorMsg = '' - if (!url) { - errorMsg = t($ => $[`${ERROR_I18N_PREFIX}.fieldRequired`], { - ns: 'common', - field: 'url', - }) - } - if (!errorMsg && !((url.startsWith('http://') || url.startsWith('https://')))) - errorMsg = t($ => $[`${ERROR_I18N_PREFIX}.urlError`], { ns: 'common' }) - if (!errorMsg && (crawlOptions.limit === null || crawlOptions.limit === undefined || crawlOptions.limit === '')) { - errorMsg = t($ => $[`${ERROR_I18N_PREFIX}.fieldRequired`], { - ns: 'common', - field: t($ => $[`${I18N_PREFIX}.limit`], { ns: 'datasetCreation' }), - }) - } - return { - isValid: !errorMsg, - errorMsg, - } - }, [crawlOptions, t]) + const checkValid = useCallback( + (url: string) => { + let errorMsg = '' + if (!url) { + errorMsg = t(($) => $[`${ERROR_I18N_PREFIX}.fieldRequired`], { + ns: 'common', + field: 'url', + }) + } + if (!errorMsg && !(url.startsWith('http://') || url.startsWith('https://'))) + errorMsg = t(($) => $[`${ERROR_I18N_PREFIX}.urlError`], { ns: 'common' }) + if ( + !errorMsg && + (crawlOptions.limit === null || + crawlOptions.limit === undefined || + crawlOptions.limit === '') + ) { + errorMsg = t(($) => $[`${ERROR_I18N_PREFIX}.fieldRequired`], { + ns: 'common', + field: t(($) => $[`${I18N_PREFIX}.limit`], { ns: 'datasetCreation' }), + }) + } + return { + isValid: !errorMsg, + errorMsg, + } + }, + [crawlOptions, t], + ) const isInit = step === Step.init const isCrawlFinished = step === Step.finished const isRunning = step === Step.running - const [crawlResult, setCrawlResult] = useState<{ - current: number - total: number - data: CrawlResultItem[] - time_consuming: number | string - } | undefined>(undefined) + const [crawlResult, setCrawlResult] = useState< + | { + current: number + total: number + data: CrawlResultItem[] + time_consuming: number | string + } + | undefined + >(undefined) const [crawlErrorMessage, setCrawlErrorMessage] = useState('') const showError = isCrawlFinished && crawlErrorMessage - const waitForCrawlFinished = useCallback(async (jobId: string): Promise<any> => { - try { - const res = await checkWatercrawlTaskStatus(jobId) as any - if (res.status === 'completed') { - return { - isError: false, - data: { - ...res, - total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), - }, + const waitForCrawlFinished = useCallback( + async (jobId: string): Promise<any> => { + try { + const res = (await checkWatercrawlTaskStatus(jobId)) as any + if (res.status === 'completed') { + return { + isError: false, + data: { + ...res, + total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), + }, + } } - } - if (res.status === 'error' || !res.status) { - // can't get the error message from the watercrawl api + if (res.status === 'error' || !res.status) { + // can't get the error message from the watercrawl api + return { + isError: true, + errorMessage: res.message, + data: { + data: [], + }, + } + } + // update the progress + setCrawlResult({ + ...res, + total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), + }) + onCheckedCrawlResultChange(res.data || []) // default select the crawl result + await sleep(2500) + return await waitForCrawlFinished(jobId) + } catch (error: unknown) { + let errorMessage = '' + const maybeErrorWithJson = error as { + json?: () => Promise<unknown> + message?: unknown + } | null + if (maybeErrorWithJson?.json) { + try { + const errorBody = (await maybeErrorWithJson.json()) as { + message?: unknown + } | null + if (typeof errorBody?.message === 'string') errorMessage = errorBody.message + } catch {} + } + if (!errorMessage && typeof maybeErrorWithJson?.message === 'string') + errorMessage = maybeErrorWithJson.message return { isError: true, - errorMessage: res.message, + errorMessage, data: { data: [], }, } } - // update the progress - setCrawlResult({ - ...res, - total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)), - }) - onCheckedCrawlResultChange(res.data || []) // default select the crawl result - await sleep(2500) - return await waitForCrawlFinished(jobId) - } - catch (error: unknown) { - let errorMessage = '' - const maybeErrorWithJson = error as { - json?: () => Promise<unknown> - message?: unknown - } | null - if (maybeErrorWithJson?.json) { - try { - const errorBody = await maybeErrorWithJson.json() as { - message?: unknown - } | null - if (typeof errorBody?.message === 'string') - errorMessage = errorBody.message - } - catch { } - } - if (!errorMessage && typeof maybeErrorWithJson?.message === 'string') - errorMessage = maybeErrorWithJson.message - return { - isError: true, - errorMessage, - data: { - data: [], - }, - } - } - }, [crawlOptions.limit, onCheckedCrawlResultChange]) - const handleRun = useCallback(async (url: string) => { - const { isValid, errorMsg } = checkValid(url) - if (!isValid) { - toast.error(errorMsg!) - return - } - setStep(Step.running) - try { - const passToServerCrawlOptions: any = { - ...crawlOptions, - } - if (crawlOptions.max_depth === '') - delete passToServerCrawlOptions.max_depth - const res = await createWatercrawlTask({ - url, - options: passToServerCrawlOptions, - }) as any - const jobId = res.job_id - onJobIdChange(jobId) - const { isError, data, errorMessage } = await waitForCrawlFinished(jobId) - if (isError) { - setCrawlErrorMessage(errorMessage || t($ => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' })) + }, + [crawlOptions.limit, onCheckedCrawlResultChange], + ) + const handleRun = useCallback( + async (url: string) => { + const { isValid, errorMsg } = checkValid(url) + if (!isValid) { + toast.error(errorMsg!) + return } - else { - setCrawlResult(data) - onCheckedCrawlResultChange(data.data || []) // default select the crawl result - setCrawlErrorMessage('') + setStep(Step.running) + try { + const passToServerCrawlOptions: any = { + ...crawlOptions, + } + if (crawlOptions.max_depth === '') delete passToServerCrawlOptions.max_depth + const res = (await createWatercrawlTask({ + url, + options: passToServerCrawlOptions, + })) as any + const jobId = res.job_id + onJobIdChange(jobId) + const { isError, data, errorMessage } = await waitForCrawlFinished(jobId) + if (isError) { + setCrawlErrorMessage( + errorMessage || t(($) => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' }), + ) + } else { + setCrawlResult(data) + onCheckedCrawlResultChange(data.data || []) // default select the crawl result + setCrawlErrorMessage('') + } + } catch (e) { + setCrawlErrorMessage(t(($) => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' })!) + console.log(e) + } finally { + setStep(Step.finished) } - } - catch (e) { - setCrawlErrorMessage(t($ => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' })!) - console.log(e) - } - finally { - setStep(Step.finished) - } - }, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, t, waitForCrawlFinished]) + }, + [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, t, waitForCrawlFinished], + ) return ( <div> - <Header onClickConfiguration={handleSetting} title={t($ => $[`${I18N_PREFIX}.watercrawlTitle`], { ns: 'datasetCreation' })} buttonText={t($ => $[`${I18N_PREFIX}.configureWatercrawl`], { ns: 'datasetCreation' })} docTitle={t($ => $[`${I18N_PREFIX}.watercrawlDoc`], { ns: 'datasetCreation' })} docLink="https://docs.watercrawl.dev/" /> + <Header + onClickConfiguration={handleSetting} + title={t(($) => $[`${I18N_PREFIX}.watercrawlTitle`], { ns: 'datasetCreation' })} + buttonText={t(($) => $[`${I18N_PREFIX}.configureWatercrawl`], { ns: 'datasetCreation' })} + docTitle={t(($) => $[`${I18N_PREFIX}.watercrawlDoc`], { ns: 'datasetCreation' })} + docLink="https://docs.watercrawl.dev/" + /> <div className="mt-2 rounded-xl border border-components-panel-border bg-background-default-subtle p-4 pb-0"> <UrlInput onRun={handleRun} isRunning={isRunning} /> <OptionsWrap className="mt-4" controlFoldOptions={controlFoldOptions}> @@ -185,11 +209,32 @@ const WaterCrawl: FC<Props> = ({ onPreview, checkedCrawlResult, onCheckedCrawlRe {!isInit && ( <div className="relative left-[-16px] mt-3 w-[calc(100%+32px)] rounded-b-xl"> - {isRunning - && (<Crawling className="mt-2" crawledNum={crawlResult?.current || 0} totalNum={crawlResult?.total || Number.parseFloat(crawlOptions.limit as string) || 0} />)} - {showError && (<ErrorMessage className="rounded-b-xl" title={t($ => $[`${I18N_PREFIX}.exceptionErrorTitle`], { ns: 'datasetCreation' })} errorMsg={crawlErrorMessage} />)} - {isCrawlFinished && !showError - && (<CrawledResult className="mb-2" list={crawlResult?.data || []} checkedList={checkedCrawlResult} onSelectedChange={onCheckedCrawlResultChange} onPreview={onPreview} usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0} />)} + {isRunning && ( + <Crawling + className="mt-2" + crawledNum={crawlResult?.current || 0} + totalNum={ + crawlResult?.total || Number.parseFloat(crawlOptions.limit as string) || 0 + } + /> + )} + {showError && ( + <ErrorMessage + className="rounded-b-xl" + title={t(($) => $[`${I18N_PREFIX}.exceptionErrorTitle`], { ns: 'datasetCreation' })} + errorMsg={crawlErrorMessage} + /> + )} + {isCrawlFinished && !showError && ( + <CrawledResult + className="mb-2" + list={crawlResult?.data || []} + checkedList={checkedCrawlResult} + onSelectedChange={onCheckedCrawlResultChange} + onPreview={onPreview} + usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0} + /> + )} </div> )} </div> diff --git a/web/app/components/datasets/create/website/watercrawl/options.tsx b/web/app/components/datasets/create/website/watercrawl/options.tsx index 0172ec8283efef..9dc08583f69aa1 100644 --- a/web/app/components/datasets/create/website/watercrawl/options.tsx +++ b/web/app/components/datasets/create/website/watercrawl/options.tsx @@ -16,25 +16,24 @@ type Props = Readonly<{ onChange: (payload: CrawlOptions) => void }> -const Options: FC<Props> = ({ - className = '', - payload, - onChange, -}) => { +const Options: FC<Props> = ({ className = '', payload, onChange }) => { const { t } = useTranslation() - const handleChange = useCallback((key: keyof CrawlOptions) => { - return (value: any) => { - onChange({ - ...payload, - [key]: value, - }) - } - }, [payload, onChange]) + const handleChange = useCallback( + (key: keyof CrawlOptions) => { + return (value: any) => { + onChange({ + ...payload, + [key]: value, + }) + } + }, + [payload, onChange], + ) return ( <div className={cn(className, 'space-y-2')}> <CheckboxWithLabel - label={t($ => $[`${I18N_PREFIX}.crawlSubPage`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.crawlSubPage`], { ns: 'datasetCreation' })} isChecked={payload.crawl_sub_pages} onChange={handleChange('crawl_sub_pages')} labelClassName="text-[13px] leading-[16px] font-medium text-text-secondary" @@ -42,7 +41,7 @@ const Options: FC<Props> = ({ <div className="flex justify-between space-x-4"> <Field className="shrink-0 grow" - label={t($ => $[`${I18N_PREFIX}.limit`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.limit`], { ns: 'datasetCreation' })} value={payload.limit} onChange={handleChange('limit')} isNumber @@ -50,32 +49,32 @@ const Options: FC<Props> = ({ /> <Field className="shrink-0 grow" - label={t($ => $[`${I18N_PREFIX}.maxDepth`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.maxDepth`], { ns: 'datasetCreation' })} value={payload.max_depth} onChange={handleChange('max_depth')} isNumber - tooltip={t($ => $[`${I18N_PREFIX}.maxDepthTooltip`], { ns: 'datasetCreation' })!} + tooltip={t(($) => $[`${I18N_PREFIX}.maxDepthTooltip`], { ns: 'datasetCreation' })!} /> </div> <div className="flex justify-between space-x-4"> <Field className="shrink-0 grow" - label={t($ => $[`${I18N_PREFIX}.excludePaths`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.excludePaths`], { ns: 'datasetCreation' })} value={payload.excludes} onChange={handleChange('excludes')} placeholder="blog/*, /about/*" /> <Field className="shrink-0 grow" - label={t($ => $[`${I18N_PREFIX}.includeOnlyPaths`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.includeOnlyPaths`], { ns: 'datasetCreation' })} value={payload.includes} onChange={handleChange('includes')} placeholder="articles/*" /> </div> <CheckboxWithLabel - label={t($ => $[`${I18N_PREFIX}.extractOnlyMainContent`], { ns: 'datasetCreation' })} + label={t(($) => $[`${I18N_PREFIX}.extractOnlyMainContent`], { ns: 'datasetCreation' })} isChecked={payload.only_main_content} onChange={handleChange('only_main_content')} labelClassName="text-[13px] leading-[16px] font-medium text-text-secondary" diff --git a/web/app/components/datasets/documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/__tests__/index.spec.tsx index fd171211a5e795..b5d78a0189e0ae 100644 --- a/web/app/components/datasets/documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/__tests__/index.spec.tsx @@ -48,7 +48,8 @@ vi.mock('@/context/provider-context', () => ({ })) vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'test-user' }, @@ -56,7 +57,8 @@ vi.mock('@/context/account-state', async (importOriginal) => { })) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'test-user' }, @@ -64,7 +66,8 @@ vi.mock('@/context/workspace-state', async (importOriginal) => { })) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'test-user' }, @@ -72,7 +75,8 @@ vi.mock('@/context/permission-state', async (importOriginal) => { })) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'test-user' }, @@ -80,7 +84,8 @@ vi.mock('@/context/version-state', async (importOriginal) => { })) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'test-user' }, @@ -126,7 +131,8 @@ vi.mock('@/service/knowledge/use-document', () => ({ })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) @@ -227,7 +233,7 @@ vi.mock('../components/documents-header', () => ({ <span data-testid="header-embedding-available">{String(embeddingAvailable)}</span> <input data-testid="search-input" - onChange={e => onInputChange(e.target.value)} + onChange={(e) => onInputChange(e.target.value)} placeholder="Search documents" /> <button data-testid="add-document-btn" onClick={onAddDocument}> @@ -247,7 +253,11 @@ vi.mock('../components/documents-header', () => ({ })) vi.mock('../components/empty-element', () => ({ - default: ({ canAdd, onClick, type }: { + default: ({ + canAdd, + onClick, + type, + }: { canAdd: boolean onClick: () => void type: 'sync' | 'upload' @@ -405,20 +415,22 @@ describe('Documents', () => { }) it('should render sync type empty element for Notion data source', () => { - vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => { - const mockState = { - dataset: { - id: 'test-dataset-id', - name: 'Test Dataset', - embedding_available: true, - data_source_type: DataSourceType.NOTION, - runtime_mode: 'rag', - created_by: 'test-user', - permission_keys: ['dataset.acl.use', 'dataset.acl.edit'], - }, - } - return selector(mockState as MockState) - }) + vi.mocked(useDatasetDetailContextWithSelector).mockImplementation( + (selector: MockSelector) => { + const mockState = { + dataset: { + id: 'test-dataset-id', + name: 'Test Dataset', + embedding_available: true, + data_source_type: DataSourceType.NOTION, + runtime_mode: 'rag', + created_by: 'test-user', + permission_keys: ['dataset.acl.use', 'dataset.acl.edit'], + }, + } + return selector(mockState as MockState) + }, + ) vi.mocked(useDocumentList).mockReturnValueOnce({ data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, isLoading: false, @@ -511,43 +523,49 @@ describe('Documents', () => { }) it('should navigate to pipeline create page when dataset is rag_pipeline mode', () => { - vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => { - const mockState = { - dataset: { - id: 'test-dataset-id', - name: 'Test Dataset', - embedding_available: true, - data_source_type: DataSourceType.FILE, - runtime_mode: 'rag_pipeline', - created_by: 'test-user', - permission_keys: ['dataset.acl.use', 'dataset.acl.edit'], - }, - } - return selector(mockState as MockState) - }) + vi.mocked(useDatasetDetailContextWithSelector).mockImplementation( + (selector: MockSelector) => { + const mockState = { + dataset: { + id: 'test-dataset-id', + name: 'Test Dataset', + embedding_available: true, + data_source_type: DataSourceType.FILE, + runtime_mode: 'rag_pipeline', + created_by: 'test-user', + permission_keys: ['dataset.acl.use', 'dataset.acl.edit'], + }, + } + return selector(mockState as MockState) + }, + ) render(<Documents {...defaultProps} />) screen.getByTestId('add-document-btn').click() - expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-id/documents/create-from-pipeline') + expect(mockPush).toHaveBeenCalledWith( + '/datasets/test-dataset-id/documents/create-from-pipeline', + ) }) it('should navigate from empty element add button', () => { - vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => { - const mockState = { - dataset: { - id: 'test-dataset-id', - name: 'Test Dataset', - embedding_available: true, - data_source_type: DataSourceType.FILE, - runtime_mode: 'rag', - created_by: 'test-user', - permission_keys: ['dataset.acl.use', 'dataset.acl.edit'], - }, - } - return selector(mockState as MockState) - }) + vi.mocked(useDatasetDetailContextWithSelector).mockImplementation( + (selector: MockSelector) => { + const mockState = { + dataset: { + id: 'test-dataset-id', + name: 'Test Dataset', + embedding_available: true, + data_source_type: DataSourceType.FILE, + runtime_mode: 'rag', + created_by: 'test-user', + permission_keys: ['dataset.acl.use', 'dataset.acl.edit'], + }, + } + return selector(mockState as MockState) + }, + ) vi.mocked(useDocumentList).mockReturnValueOnce({ data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, isLoading: false, @@ -663,10 +681,12 @@ describe('Documents', () => { describe('Edge Cases and Error Handling', () => { it('should handle undefined dataset gracefully', () => { - vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => { - const mockState = { dataset: undefined } - return selector(mockState as MockState) - }) + vi.mocked(useDatasetDetailContextWithSelector).mockImplementation( + (selector: MockSelector) => { + const mockState = { dataset: undefined } + return selector(mockState as MockState) + }, + ) render(<Documents {...defaultProps} />) @@ -698,18 +718,20 @@ describe('Documents', () => { }) it('should handle embedding not available', () => { - vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => { - const mockState = { - dataset: { - id: 'test-dataset-id', - name: 'Test Dataset', - embedding_available: false, - data_source_type: DataSourceType.FILE, - runtime_mode: 'rag', - }, - } - return selector(mockState as MockState) - }) + vi.mocked(useDatasetDetailContextWithSelector).mockImplementation( + (selector: MockSelector) => { + const mockState = { + dataset: { + id: 'test-dataset-id', + name: 'Test Dataset', + embedding_available: false, + data_source_type: DataSourceType.FILE, + runtime_mode: 'rag', + }, + } + return selector(mockState as MockState) + }, + ) render(<Documents {...defaultProps} />) diff --git a/web/app/components/datasets/documents/__tests__/status-filter.spec.ts b/web/app/components/datasets/documents/__tests__/status-filter.spec.ts index c18f4ef6880265..96e707f6db6058 100644 --- a/web/app/components/datasets/documents/__tests__/status-filter.spec.ts +++ b/web/app/components/datasets/documents/__tests__/status-filter.spec.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' - import { normalizeStatusForQuery, sanitizeStatusValue } from '../status-filter' vi.mock('@/models/datasets', () => ({ @@ -132,17 +131,12 @@ describe('status-filter', () => { // Non-aliased known values should pass through describe('non-aliased known values', () => { - it.each([ - 'queuing', - 'indexing', - 'paused', - 'error', - 'available', - 'disabled', - 'archived', - ])('should return %s as-is when not aliased', (status) => { - expect(normalizeStatusForQuery(status)).toBe(status) - }) + it.each(['queuing', 'indexing', 'paused', 'error', 'available', 'disabled', 'archived'])( + 'should return %s as-is when not aliased', + (status) => { + expect(normalizeStatusForQuery(status)).toBe(status) + }, + ) }) // URL alias flows through sanitize first, then query alias diff --git a/web/app/components/datasets/documents/components/__tests__/documents-header.spec.tsx b/web/app/components/datasets/documents/components/__tests__/documents-header.spec.tsx index 9aceb5101fe48d..6c280486c360e8 100644 --- a/web/app/components/datasets/documents/components/__tests__/documents-header.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/documents-header.spec.tsx @@ -7,9 +7,12 @@ import DocumentsHeader from '../documents-header' // Mock the context hooks // Mock child components that require API calls -vi.mock('@/app/components/datasets/common/document-status-with-action/auto-disabled-document', () => ({ - default: () => <div data-testid="auto-disabled-document">AutoDisabledDocument</div>, -})) +vi.mock( + '@/app/components/datasets/common/document-status-with-action/auto-disabled-document', + () => ({ + default: () => <div data-testid="auto-disabled-document">AutoDisabledDocument</div>, + }), +) vi.mock('@/app/components/datasets/common/document-status-with-action/index-failed', () => ({ default: () => <div data-testid="index-failed">IndexFailed</div>, diff --git a/web/app/components/datasets/documents/components/__tests__/list.spec.tsx b/web/app/components/datasets/documents/components/__tests__/list.spec.tsx index 0af39a5e2835b5..0ce6276ea96608 100644 --- a/web/app/components/datasets/documents/components/__tests__/list.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/list.spec.tsx @@ -1,7 +1,6 @@ import type { SimpleDocumentDetail } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import { useDocumentSort } from '../document-list/hooks' import DocumentList from '../list' @@ -51,29 +50,41 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock child components that are complex vi.mock('../document-list/components', () => ({ - DocumentTableRow: ({ doc, index }: { doc: SimpleDocumentDetail, index: number }) => ( + DocumentTableRow: ({ doc, index }: { doc: SimpleDocumentDetail; index: number }) => ( <tr data-testid={`doc-row-${doc.id}`}> <td>{index + 1}</td> <td>{doc.name}</td> </tr> ), renderTdValue: (val: string) => val || '-', - SortHeader: ({ field, label, onSort }: { field: string, label: string, onSort: (f: string) => void }) => ( - <button data-testid={`sort-${field}`} onClick={() => onSort(field)}>{label}</button> + SortHeader: ({ + field, + label, + onSort, + }: { + field: string + label: string + onSort: (f: string) => void + }) => ( + <button data-testid={`sort-${field}`} onClick={() => onSort(field)}> + {label} + </button> ), })) vi.mock('../../detail/completed/common/batch-action', () => ({ - default: ({ selectedIds, onCancel }: { selectedIds: string[], onCancel: () => void }) => ( + default: ({ selectedIds, onCancel }: { selectedIds: string[]; onCancel: () => void }) => ( <div data-testid="batch-action"> <span data-testid="selected-count">{selectedIds.length}</span> - <button data-testid="cancel-selection" onClick={onCancel}>Cancel</button> + <button data-testid="cancel-selection" onClick={onCancel}> + Cancel + </button> </div> ), })) vi.mock('../../rename-modal', () => ({ - default: ({ name, onClose }: { name: string, onClose: () => void }) => ( + default: ({ name, onClose }: { name: string; onClose: () => void }) => ( <div data-testid="rename-modal"> <span>{name}</span> <button onClick={onClose}>Close</button> @@ -140,7 +151,9 @@ describe('DocumentList', () => { it('should render select-all area when embeddingAvailable is true', () => { render(<DocumentList {...defaultProps} embeddingAvailable={true} />) - expect(screen.getByRole('checkbox', { name: 'common.operation.selectAll' })).toBeInTheDocument() + expect( + screen.getByRole('checkbox', { name: 'common.operation.selectAll' }), + ).toBeInTheDocument() }) it('should still render # column when embeddingAvailable is false', () => { @@ -168,7 +181,9 @@ describe('DocumentList', () => { const docs = [createDoc({ id: 'a', name: 'Doc A' }), createDoc({ id: 'b', name: 'Doc B' })] const onSelectedIdChange = vi.fn() - render(<DocumentList {...defaultProps} documents={docs} onSelectedIdChange={onSelectedIdChange} />) + render( + <DocumentList {...defaultProps} documents={docs} onSelectedIdChange={onSelectedIdChange} />, + ) fireEvent.click(screen.getByRole('checkbox', { name: 'common.operation.selectAll' })) diff --git a/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx b/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx index 957b7b7dd0dc6e..2b459d1d9848bb 100644 --- a/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx @@ -58,7 +58,7 @@ vi.mock('@/service/knowledge/use-document', () => ({ // Mock downloadUrl utility const mockDownloadUrl = vi.fn() vi.mock('@/utils/download', () => ({ - downloadUrl: (params: { url: string, fileName: string }) => mockDownloadUrl(params), + downloadUrl: (params: { url: string; fileName: string }) => mockDownloadUrl(params), })) afterEach(() => { @@ -141,7 +141,9 @@ describe('Operations', () => { />, ) - expect(screen.queryByRole('button', { name: 'common.operation.more' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.more' }), + ).not.toBeInTheDocument() const switchElement = screen.getByRole('switch') expect(switchElement)!.toHaveAttribute('aria-disabled', 'true') @@ -187,11 +189,7 @@ describe('Operations', () => { it('should render disabled switch when archived', () => { render( - <Operations - {...defaultProps} - scene="list" - detail={{ ...defaultDetail, archived: true }} - />, + <Operations {...defaultProps} scene="list" detail={{ ...defaultDetail, archived: true }} />, ) const disabledSwitch = document.querySelector('[disabled]') expect(disabledSwitch).toBeDefined() @@ -200,11 +198,7 @@ describe('Operations', () => { it('should call enable when switch is toggled on', async () => { vi.useFakeTimers() render( - <Operations - {...defaultProps} - scene="list" - detail={{ ...defaultDetail, enabled: false }} - />, + <Operations {...defaultProps} scene="list" detail={{ ...defaultDetail, enabled: false }} />, ) const switchElement = document.querySelector('[role="switch"]') await act(async () => { @@ -221,11 +215,7 @@ describe('Operations', () => { it('should call disable when switch is toggled off', async () => { vi.useFakeTimers() render( - <Operations - {...defaultProps} - scene="list" - detail={{ ...defaultDetail, enabled: true }} - />, + <Operations {...defaultProps} scene="list" detail={{ ...defaultDetail, enabled: true }} />, ) const switchElement = document.querySelector('[role="switch"]') await act(async () => { @@ -242,11 +232,7 @@ describe('Operations', () => { it('should not call enable if already enabled', async () => { vi.useFakeTimers() render( - <Operations - {...defaultProps} - scene="list" - detail={{ ...defaultDetail, enabled: true }} - />, + <Operations {...defaultProps} scene="list" detail={{ ...defaultDetail, enabled: true }} />, ) // Simulate trying to enable when already enabled - this won't happen via switch click // because the switch would toggle to disable. But handleSwitch has early returns @@ -258,7 +244,9 @@ describe('Operations', () => { it('should not render a standalone settings button', () => { render(<Operations {...defaultProps} />) - expect(screen.queryByRole('button', { name: 'datasetDocuments.list.action.settings' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'datasetDocuments.list.action.settings' }), + ).not.toBeInTheDocument() }) it('should navigate to settings when settings menu item is clicked', async () => { @@ -347,12 +335,7 @@ describe('Operations', () => { }) it('should call un_archive when unarchive action is clicked', async () => { - render( - <Operations - {...defaultProps} - detail={{ ...defaultDetail, archived: true }} - />, - ) + render(<Operations {...defaultProps} detail={{ ...defaultDetail, archived: true }} />) await openPopover() const unarchiveButton = screen.getByText('datasetDocuments.list.action.unarchive') await act(async () => { @@ -438,7 +421,8 @@ describe('Operations', () => { const user = userEvent.setup() render(<Operations {...defaultProps} />) await openPopover() - const renameAction = screen.getByText('datasetDocuments.list.table.rename').parentElement as HTMLElement + const renameAction = screen.getByText('datasetDocuments.list.table.rename') + .parentElement as HTMLElement await user.click(renameAction) const renameInput = await screen.findByRole('textbox') @@ -475,16 +459,16 @@ describe('Operations', () => { fireEvent.click(syncButton) }) await waitFor(() => { - expect(mockSyncWebsite).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockSyncWebsite).toHaveBeenCalledWith({ + datasetId: 'dataset-1', + documentId: 'doc-1', + }) }) }) it('should call pause when pause action is clicked', async () => { render( - <Operations - {...defaultProps} - detail={{ ...defaultDetail, display_status: 'indexing' }} - />, + <Operations {...defaultProps} detail={{ ...defaultDetail, display_status: 'indexing' }} />, ) await openPopover() const pauseButton = screen.getByText('datasetDocuments.list.action.pause') @@ -498,10 +482,7 @@ describe('Operations', () => { it('should call resume when resume action is clicked', async () => { render( - <Operations - {...defaultProps} - detail={{ ...defaultDetail, display_status: 'paused' }} - />, + <Operations {...defaultProps} detail={{ ...defaultDetail, display_status: 'paused' }} />, ) await openPopover() const resumeButton = screen.getByText('datasetDocuments.list.action.resume') @@ -522,7 +503,10 @@ describe('Operations', () => { }) await waitFor(() => { expect(mockDownload).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) - expect(mockDownloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/download', fileName: 'Test Document' }) + expect(mockDownloadUrl).toHaveBeenCalledWith({ + url: 'https://example.com/download', + fileName: 'Test Document', + }) }) }) @@ -532,7 +516,9 @@ describe('Operations', () => { const settingsButton = screen.getByText('datasetDocuments.list.action.settings').parentElement const downloadButton = screen.getByText('datasetDocuments.list.action.download').parentElement - expect(settingsButton?.compareDocumentPosition(downloadButton!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(settingsButton?.compareDocumentPosition(downloadButton!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ) }) it('should show download option for archived file data source', async () => { @@ -635,30 +621,21 @@ describe('Operations', () => { describe('display status', () => { it('should render pause action when status is indexing', () => { render( - <Operations - {...defaultProps} - detail={{ ...defaultDetail, display_status: 'indexing' }} - />, + <Operations {...defaultProps} detail={{ ...defaultDetail, display_status: 'indexing' }} />, ) expect(document.querySelector('.flex.items-center'))!.toBeInTheDocument() }) it('should render resume action when status is paused', () => { render( - <Operations - {...defaultProps} - detail={{ ...defaultDetail, display_status: 'paused' }} - />, + <Operations {...defaultProps} detail={{ ...defaultDetail, display_status: 'paused' }} />, ) expect(document.querySelector('.flex.items-center'))!.toBeInTheDocument() }) it('should not show pause/resume for available status', async () => { render( - <Operations - {...defaultProps} - detail={{ ...defaultDetail, display_status: 'available' }} - />, + <Operations {...defaultProps} detail={{ ...defaultDetail, display_status: 'available' }} />, ) const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement if (moreButton) { @@ -711,7 +688,9 @@ describe('Operations', () => { describe('memoization', () => { it('should be wrapped with React.memo', () => { - expect((Operations as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + expect((Operations as unknown as { $$typeof: symbol }).$$typeof).toBe( + Symbol.for('react.memo'), + ) }) }) diff --git a/web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx b/web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx index 34351dc71d2a16..94458a0f5837fd 100644 --- a/web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx @@ -2,7 +2,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' // Import after mock import { renameDocumentName } from '@/service/datasets' - import RenameModal from '../rename-modal' const { mockToastSuccess, mockToastError } = vi.hoisted(() => ({ @@ -151,7 +150,7 @@ describe('RenameModal', () => { // The button should be in loading state await waitFor(() => { const buttons = screen.getAllByRole('button') - const saveBtn = buttons.find(btn => btn.textContent?.includes('operation.save')) + const saveBtn = buttons.find((btn) => btn.textContent?.includes('operation.save')) expect(saveBtn).toBeInTheDocument() }) @@ -191,7 +190,7 @@ describe('RenameModal', () => { it('should handle name with special characters', () => { render(<RenameModal {...defaultProps} name="Document <with> 'special' chars" />) const input = screen.getByRole('textbox') - expect(input).toHaveValue('Document <with> \'special\' chars') + expect(input).toHaveValue("Document <with> 'special' chars") }) }) }) diff --git a/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx b/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx index 5db8775741cc48..7978ca1555a943 100644 --- a/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx @@ -24,7 +24,11 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: { doc_form: string, created_by: string, permission_keys: string[] } }) => unknown) => + useDatasetDetailContextWithSelector: ( + selector: (state: { + dataset: { doc_form: string; created_by: string; permission_keys: string[] } + }) => unknown, + ) => selector({ dataset: { doc_form: ChunkingMode.text, @@ -35,7 +39,8 @@ vi.mock('@/context/dataset-detail', () => ({ })) vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'user-1' }, @@ -43,7 +48,8 @@ vi.mock('@/context/account-state', async (importOriginal) => { })) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'user-1' }, @@ -51,7 +57,8 @@ vi.mock('@/context/workspace-state', async (importOriginal) => { })) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'user-1' }, @@ -59,7 +66,8 @@ vi.mock('@/context/permission-state', async (importOriginal) => { })) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'user-1' }, @@ -67,7 +75,8 @@ vi.mock('@/context/version-state', async (importOriginal) => { })) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'user-1' }, @@ -86,60 +95,61 @@ vi.mock('@/app/components/datasets/metadata/hooks/use-batch-edit-document-metada })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) -const createTestQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false, gcTime: 0 }, - mutations: { retry: false }, - }, -}) +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }) const createWrapper = () => { const queryClient = createTestQueryClient() return ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) } -const createMockDoc = (overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail => ({ - id: `doc-${Math.random().toString(36).substr(2, 9)}`, - position: 1, - data_source_type: DataSourceType.FILE, - data_source_info: {}, - data_source_detail_dict: { - upload_file: { name: 'test.txt', extension: 'txt' }, - }, - dataset_process_rule_id: 'rule-1', - batch: 'batch-1', - name: 'test-document.txt', - created_from: 'web', - created_by: 'user-1', - created_at: Date.now(), - tokens: 100, - indexing_status: 'completed', - error: null, - enabled: true, - disabled_at: null, - disabled_by: null, - archived: false, - archived_reason: null, - archived_by: null, - archived_at: null, - updated_at: Date.now(), - doc_type: null, - doc_metadata: undefined, - display_status: 'available', - word_count: 500, - hit_count: 10, - doc_form: 'text_model', - ...overrides, -} as SimpleDocumentDetail) +const createMockDoc = (overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail => + ({ + id: `doc-${Math.random().toString(36).substr(2, 9)}`, + position: 1, + data_source_type: DataSourceType.FILE, + data_source_info: {}, + data_source_detail_dict: { + upload_file: { name: 'test.txt', extension: 'txt' }, + }, + dataset_process_rule_id: 'rule-1', + batch: 'batch-1', + name: 'test-document.txt', + created_from: 'web', + created_by: 'user-1', + created_at: Date.now(), + tokens: 100, + indexing_status: 'completed', + error: null, + enabled: true, + disabled_at: null, + disabled_by: null, + archived: false, + archived_reason: null, + archived_by: null, + archived_at: null, + updated_at: Date.now(), + doc_type: null, + doc_metadata: undefined, + display_status: 'available', + word_count: 500, + hit_count: 10, + doc_form: 'text_model', + ...overrides, + }) as SimpleDocumentDetail const defaultPagination: PaginationProps = { current: 1, @@ -249,9 +259,18 @@ describe('DocumentList', () => { } render(<DocumentList {...props} />, { wrapper: createWrapper() }) - expect(screen.getByRole('checkbox', { name: 'Document 1.txt' })).toHaveAttribute('aria-checked', 'true') - expect(screen.getByRole('checkbox', { name: 'Document 2.txt' })).toHaveAttribute('aria-checked', 'true') - expect(screen.getByRole('checkbox', { name: 'Document 3.txt' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('checkbox', { name: 'Document 1.txt' })).toHaveAttribute( + 'aria-checked', + 'true', + ) + expect(screen.getByRole('checkbox', { name: 'Document 2.txt' })).toHaveAttribute( + 'aria-checked', + 'true', + ) + expect(screen.getByRole('checkbox', { name: 'Document 3.txt' })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) it('should show indeterminate state when some are selected', () => { @@ -294,11 +313,12 @@ describe('DocumentList', () => { it('should call onSortChange when sortable header is clicked', () => { const onSortChange = vi.fn() - const { container } = render(<DocumentList {...defaultProps} onSortChange={onSortChange} />, { wrapper: createWrapper() }) + const { container } = render(<DocumentList {...defaultProps} onSortChange={onSortChange} />, { + wrapper: createWrapper(), + }) const sortableHeaders = container.querySelectorAll('thead button') - if (sortableHeaders.length > 0) - fireEvent.click(sortableHeaders[0]!) + if (sortableHeaders.length > 0) fireEvent.click(sortableHeaders[0]!) expect(onSortChange).toHaveBeenCalled() }) @@ -387,9 +407,7 @@ describe('DocumentList', () => { const props = { ...defaultProps, selectedIds: ['doc-1'], - documents: [ - createMockDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }), - ], + documents: [createMockDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE })], } render(<DocumentList {...props} />, { wrapper: createWrapper() }) @@ -402,9 +420,7 @@ describe('DocumentList', () => { const props = { ...defaultProps, selectedIds: ['doc-1'], - documents: [ - createMockDoc({ id: 'doc-1', display_status: 'error' }), - ], + documents: [createMockDoc({ id: 'doc-1', display_status: 'error' })], } render(<DocumentList {...props} />, { wrapper: createWrapper() }) @@ -446,7 +462,9 @@ describe('DocumentList', () => { fireEvent.click(await screen.findByText('datasetDocuments.list.table.rename')) }) - expect(screen.getByRole('dialog', { name: 'datasetDocuments.list.table.rename' }))!.toBeInTheDocument() + expect( + screen.getByRole('dialog', { name: 'datasetDocuments.list.table.rename' }), + )!.toBeInTheDocument() }) it('should call onUpdate when document is renamed', () => { @@ -546,7 +564,8 @@ describe('DocumentList', () => { it('should handle large number of documents', () => { const manyDocs = Array.from({ length: 20 }, (_, i) => - createMockDoc({ id: `doc-${i}`, name: `Document ${i}.txt` })) + createMockDoc({ id: `doc-${i}`, name: `Document ${i}.txt` }), + ) const props = { ...defaultProps, documents: manyDocs } render(<DocumentList {...props} />, { wrapper: createWrapper() }) diff --git a/web/app/components/datasets/documents/components/document-list/components/__tests__/document-source-icon.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-source-icon.spec.tsx index 2a42273a9b0a3f..cc4cba1410a4fd 100644 --- a/web/app/components/datasets/documents/components/document-list/components/__tests__/document-source-icon.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-source-icon.spec.tsx @@ -5,39 +5,40 @@ import { DataSourceType } from '@/models/datasets' import { DatasourceType } from '@/models/pipeline' import DocumentSourceIcon from '../document-source-icon' -const createMockDoc = (overrides: Record<string, unknown> = {}): SimpleDocumentDetail => ({ - id: 'doc-1', - position: 1, - data_source_type: DataSourceType.FILE, - data_source_info: {}, - data_source_detail_dict: {}, - dataset_process_rule_id: 'rule-1', - dataset_id: 'dataset-1', - batch: 'batch-1', - name: 'test-document.txt', - created_from: 'web', - created_by: 'user-1', - created_at: Date.now(), - tokens: 100, - indexing_status: 'completed', - error: null, - enabled: true, - disabled_at: null, - disabled_by: null, - archived: false, - archived_reason: null, - archived_by: null, - archived_at: null, - updated_at: Date.now(), - doc_type: null, - doc_metadata: undefined, - doc_language: 'en', - display_status: 'available', - word_count: 100, - hit_count: 10, - doc_form: 'text_model', - ...overrides, -}) as unknown as SimpleDocumentDetail +const createMockDoc = (overrides: Record<string, unknown> = {}): SimpleDocumentDetail => + ({ + id: 'doc-1', + position: 1, + data_source_type: DataSourceType.FILE, + data_source_info: {}, + data_source_detail_dict: {}, + dataset_process_rule_id: 'rule-1', + dataset_id: 'dataset-1', + batch: 'batch-1', + name: 'test-document.txt', + created_from: 'web', + created_by: 'user-1', + created_at: Date.now(), + tokens: 100, + indexing_status: 'completed', + error: null, + enabled: true, + disabled_at: null, + disabled_by: null, + archived: false, + archived_reason: null, + archived_by: null, + archived_at: null, + updated_at: Date.now(), + doc_type: null, + doc_metadata: undefined, + doc_language: 'en', + display_status: 'available', + word_count: 100, + hit_count: 10, + doc_form: 'text_model', + ...overrides, + }) as unknown as SimpleDocumentDetail describe('DocumentSourceIcon', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx index 135b7c2135bfcd..e5e81cd3de678f 100644 --- a/web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx @@ -17,12 +17,13 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: () => new URLSearchParams(mockSearchParams), })) -const createTestQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false, gcTime: 0 }, - mutations: { retry: false }, - }, -}) +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }) const createWrapper = (value: string[] = [], onValueChange = vi.fn()) => { const queryClient = createTestQueryClient() @@ -30,13 +31,11 @@ const createWrapper = (value: string[] = [], onValueChange = vi.fn()) => { <QueryClientProvider client={queryClient}> <CheckboxGroup value={value} - onValueChange={nextValue => onValueChange(nextValue)} + onValueChange={(nextValue) => onValueChange(nextValue)} allValues={['doc-1']} > <table> - <tbody> - {children} - </tbody> + <tbody>{children}</tbody> </table> </CheckboxGroup> </QueryClientProvider> @@ -45,41 +44,42 @@ const createWrapper = (value: string[] = [], onValueChange = vi.fn()) => { type LocalDoc = SimpleDocumentDetail & { percent?: number } -const createMockDoc = (overrides: Record<string, unknown> = {}): LocalDoc => ({ - id: 'doc-1', - position: 1, - data_source_type: DataSourceType.FILE, - data_source_info: {}, - data_source_detail_dict: { - upload_file: { name: 'test.txt', extension: 'txt' }, - }, - dataset_process_rule_id: 'rule-1', - dataset_id: 'dataset-1', - batch: 'batch-1', - name: 'test-document.txt', - created_from: 'web', - created_by: 'user-1', - created_at: Date.now(), - tokens: 100, - indexing_status: 'completed', - error: null, - enabled: true, - disabled_at: null, - disabled_by: null, - archived: false, - archived_reason: null, - archived_by: null, - archived_at: null, - updated_at: Date.now(), - doc_type: null, - doc_metadata: undefined, - doc_language: 'en', - display_status: 'available', - word_count: 500, - hit_count: 10, - doc_form: 'text_model', - ...overrides, -}) as unknown as LocalDoc +const createMockDoc = (overrides: Record<string, unknown> = {}): LocalDoc => + ({ + id: 'doc-1', + position: 1, + data_source_type: DataSourceType.FILE, + data_source_info: {}, + data_source_detail_dict: { + upload_file: { name: 'test.txt', extension: 'txt' }, + }, + dataset_process_rule_id: 'rule-1', + dataset_id: 'dataset-1', + batch: 'batch-1', + name: 'test-document.txt', + created_from: 'web', + created_by: 'user-1', + created_at: Date.now(), + tokens: 100, + indexing_status: 'completed', + error: null, + enabled: true, + disabled_at: null, + disabled_by: null, + archived: false, + archived_reason: null, + archived_by: null, + archived_at: null, + updated_at: Date.now(), + doc_type: null, + doc_metadata: undefined, + doc_language: 'en', + display_status: 'available', + word_count: 500, + hit_count: 10, + doc_form: 'text_model', + ...overrides, + }) as unknown as LocalDoc const getRowCheckbox = () => screen.getByRole('checkbox', { name: 'test-document.txt' }) @@ -144,7 +144,9 @@ describe('DocumentTableRow', () => { }) it('should stop propagation when checkbox container is clicked', () => { - const { container } = render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() }) + const { container } = render(<DocumentTableRow {...defaultProps} />, { + wrapper: createWrapper(), + }) const checkboxContainer = container.querySelector('td')?.querySelector('div') if (checkboxContainer) { @@ -186,7 +188,9 @@ describe('DocumentTableRow', () => { fireEvent.click(screen.getByRole('row')) - expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1?page=2&status=error') + expect(mockPush).toHaveBeenCalledWith( + '/datasets/dataset-1/documents/doc-1?page=2&status=error', + ) }) }) @@ -205,14 +209,18 @@ describe('DocumentTableRow', () => { it('should display 0 with empty style when word_count is 0', () => { const doc = createMockDoc({ word_count: 0 }) - const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() }) + const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { + wrapper: createWrapper(), + }) const zeroCells = container.querySelectorAll('.text-text-tertiary') expect(zeroCells.length).toBeGreaterThan(0) }) it('should handle undefined word_count', () => { const doc = createMockDoc({ word_count: undefined as unknown as number }) - const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() }) + const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { + wrapper: createWrapper(), + }) expect(container)!.toBeInTheDocument() }) }) @@ -232,7 +240,9 @@ describe('DocumentTableRow', () => { it('should display 0 with empty style when hit_count is 0', () => { const doc = createMockDoc({ hit_count: 0 }) - const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() }) + const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { + wrapper: createWrapper(), + }) const zeroCells = container.querySelectorAll('.text-text-tertiary') expect(zeroCells.length).toBeGreaterThan(0) }) @@ -240,14 +250,18 @@ describe('DocumentTableRow', () => { describe('Chunking Mode', () => { it('should render ChunkingModeLabel with general mode', () => { - render(<DocumentTableRow {...defaultProps} isGeneralMode isQAMode={false} />, { wrapper: createWrapper() }) + render(<DocumentTableRow {...defaultProps} isGeneralMode isQAMode={false} />, { + wrapper: createWrapper(), + }) // ChunkingModeLabel should be rendered // ChunkingModeLabel should be rendered expect(screen.getByRole('row'))!.toBeInTheDocument() }) it('should render ChunkingModeLabel with QA mode', () => { - render(<DocumentTableRow {...defaultProps} isGeneralMode={false} isQAMode />, { wrapper: createWrapper() }) + render(<DocumentTableRow {...defaultProps} isGeneralMode={false} isQAMode />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('row'))!.toBeInTheDocument() }) }) @@ -286,13 +300,17 @@ describe('DocumentTableRow', () => { describe('Operations', () => { it('should pass selectedIds to Operations component', () => { - render(<DocumentTableRow {...defaultProps} selectedIds={['doc-1', 'doc-2']} />, { wrapper: createWrapper() }) + render(<DocumentTableRow {...defaultProps} selectedIds={['doc-1', 'doc-2']} />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('row'))!.toBeInTheDocument() }) it('should pass onSelectedIdChange to Operations component', () => { const onSelectedIdChange = vi.fn() - render(<DocumentTableRow {...defaultProps} onSelectedIdChange={onSelectedIdChange} />, { wrapper: createWrapper() }) + render(<DocumentTableRow {...defaultProps} onSelectedIdChange={onSelectedIdChange} />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('row'))!.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/components/document-list/components/__tests__/sort-header.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/__tests__/sort-header.spec.tsx index 8730f3f27828db..d3ac1cf3739a22 100644 --- a/web/app/components/datasets/documents/components/document-list/components/__tests__/sort-header.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/__tests__/sort-header.spec.tsx @@ -40,9 +40,7 @@ describe('SortHeader', () => { describe('active state', () => { it('should have tertiary text color when active', () => { - const { container } = render( - <SortHeader {...defaultProps} currentSortField="created_at" />, - ) + const { container } = render(<SortHeader {...defaultProps} currentSortField="created_at" />) const icon = container.querySelector('button span') expect(icon).toHaveClass('text-text-tertiary') }) diff --git a/web/app/components/datasets/documents/components/document-list/components/document-source-icon.tsx b/web/app/components/datasets/documents/components/document-list/components/document-source-icon.tsx index 0d51837cf297a3..5fd76db95cf8b4 100644 --- a/web/app/components/datasets/documents/components/document-list/components/document-source-icon.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/document-source-icon.tsx @@ -1,5 +1,11 @@ import type { FC } from 'react' -import type { LegacyDataSourceInfo, LocalFileInfo, OnlineDocumentInfo, OnlineDriveInfo, SimpleDocumentDetail } from '@/models/datasets' +import type { + LegacyDataSourceInfo, + LocalFileInfo, + OnlineDocumentInfo, + OnlineDriveInfo, + SimpleDocumentDetail, +} from '@/models/datasets' import { RiGlobalLine } from '@remixicon/react' import * as React from 'react' import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' @@ -18,7 +24,9 @@ const isLocalFile = (dataSourceType: DataSourceType | DatasourceType) => { } const isOnlineDocument = (dataSourceType: DataSourceType | DatasourceType) => { - return dataSourceType === DatasourceType.onlineDocument || dataSourceType === DataSourceType.NOTION + return ( + dataSourceType === DatasourceType.onlineDocument || dataSourceType === DataSourceType.NOTION + ) } const isWebsiteCrawl = (dataSourceType: DataSourceType | DatasourceType) => { @@ -34,18 +42,13 @@ const isCreateFromRAGPipeline = (createdFrom: string) => { } const getFileExtension = (fileName: string): string => { - if (!fileName) - return '' + if (!fileName) return '' const parts = fileName.split('.') - if (parts.length <= 1 || (parts[0] === '' && parts.length === 2)) - return '' + if (parts.length <= 1 || (parts[0] === '' && parts.length === 2)) return '' return parts[parts.length - 1]!.toLowerCase() } -const DocumentSourceIcon: FC<DocumentSourceIconProps> = React.memo(({ - doc, - fileType, -}) => { +const DocumentSourceIcon: FC<DocumentSourceIconProps> = React.memo(({ doc, fileType }) => { if (isOnlineDocument(doc.data_source_type)) { return ( <NotionIcon @@ -63,13 +66,11 @@ const DocumentSourceIcon: FC<DocumentSourceIconProps> = React.memo(({ if (isLocalFile(doc.data_source_type)) { return ( <FileTypeIcon - type={ - extensionToFileType( - isCreateFromRAGPipeline(doc.created_from) - ? (doc?.data_source_info as LocalFileInfo)?.extension - : ((doc?.data_source_info as LegacyDataSourceInfo)?.upload_file?.extension ?? fileType), - ) - } + type={extensionToFileType( + isCreateFromRAGPipeline(doc.created_from) + ? (doc?.data_source_info as LocalFileInfo)?.extension + : ((doc?.data_source_info as LegacyDataSourceInfo)?.upload_file?.extension ?? fileType), + )} className="mr-1.5" /> ) @@ -78,11 +79,9 @@ const DocumentSourceIcon: FC<DocumentSourceIconProps> = React.memo(({ if (isOnlineDrive(doc.data_source_type)) { return ( <FileTypeIcon - type={ - extensionToFileType( - getFileExtension((doc?.data_source_info as unknown as OnlineDriveInfo)?.name), - ) - } + type={extensionToFileType( + getFileExtension((doc?.data_source_info as unknown as OnlineDriveInfo)?.name), + )} className="mr-1.5" /> ) diff --git a/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx b/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx index e834f9187dfd86..2833430c217ec5 100644 --- a/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx @@ -37,146 +37,164 @@ type DocumentTableRowProps = { } const renderCount = (count: number | undefined) => { - if (!count) - return renderTdValue(0, true) + if (!count) return renderTdValue(0, true) - if (count < 1000) - return count + if (count < 1000) return count return `${formatNumber((count / 1000).toFixed(1))}k` } -const DocumentTableRow = React.memo(({ - doc, - index, - datasetId, - isGeneralMode, - isQAMode, - embeddingAvailable, - selectedIds, - onSelectedIdChange, - onShowRenameModal, - onUpdate, -}: DocumentTableRowProps) => { - const { t } = useTranslation() - const { formatTime } = useTimestamp() - const router = useRouter() - const searchParams = useSearchParams() - const documentNameId = React.useId() - const dataset = useDatasetDetailContextWithSelector(s => s.dataset) - const currentUserId = useAtomValue(userProfileIdAtom) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys, { - currentUserId, - resourceMaintainer: dataset?.maintainer, - workspacePermissionKeys, - }), [dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys]) +const DocumentTableRow = React.memo( + ({ + doc, + index, + datasetId, + isGeneralMode, + isQAMode, + embeddingAvailable, + selectedIds, + onSelectedIdChange, + onShowRenameModal, + onUpdate, + }: DocumentTableRowProps) => { + const { t } = useTranslation() + const { formatTime } = useTimestamp() + const router = useRouter() + const searchParams = useSearchParams() + const documentNameId = React.useId() + const dataset = useDatasetDetailContextWithSelector((s) => s.dataset) + const currentUserId = useAtomValue(userProfileIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + const datasetACLCapabilities = React.useMemo( + () => + getDatasetACLCapabilities(dataset?.permission_keys, { + currentUserId, + resourceMaintainer: dataset?.maintainer, + workspacePermissionKeys, + }), + [dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys], + ) - const isFile = doc.data_source_type === DataSourceType.FILE - const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : '' - const queryString = searchParams.toString() + const isFile = doc.data_source_type === DataSourceType.FILE + const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : '' + const queryString = searchParams.toString() - const handleRowClick = useCallback(() => { - router.push(`/datasets/${datasetId}/documents/${doc.id}${queryString ? `?${queryString}` : ''}`) - }, [router, datasetId, doc.id, queryString]) + const handleRowClick = useCallback(() => { + router.push( + `/datasets/${datasetId}/documents/${doc.id}${queryString ? `?${queryString}` : ''}`, + ) + }, [router, datasetId, doc.id, queryString]) - const stopPropagation = useCallback((e: React.SyntheticEvent) => { - e.stopPropagation() - }, []) + const stopPropagation = useCallback((e: React.SyntheticEvent) => { + e.stopPropagation() + }, []) - const handleRenameClick = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - onShowRenameModal(doc) - }, [doc, onShowRenameModal]) + const handleRenameClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onShowRenameModal(doc) + }, + [doc, onShowRenameModal], + ) - return ( - <tr - className="h-8 cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover" - onClick={handleRowClick} - > - <td className="text-left align-middle text-xs text-text-tertiary"> - <div className="flex items-center" role="presentation" onClick={stopPropagation} onKeyDown={stopPropagation}> - <Checkbox - className="mr-2 shrink-0" - value={doc.id} - aria-labelledby={documentNameId} - /> - {index + 1} - </div> - </td> - <td> - <div className="group mr-6 flex max-w-[460px] items-center hover:mr-0"> - <div className="flex shrink-0 items-center"> - <DocumentSourceIcon doc={doc} fileType={fileType} /> + return ( + <tr + className="h-8 cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover" + onClick={handleRowClick} + > + <td className="text-left align-middle text-xs text-text-tertiary"> + <div + className="flex items-center" + role="presentation" + onClick={stopPropagation} + onKeyDown={stopPropagation} + > + <Checkbox className="mr-2 shrink-0" value={doc.id} aria-labelledby={documentNameId} /> + {index + 1} </div> - <Tooltip> - <TooltipTrigger - render={( - <span id={documentNameId} className="grow truncate text-sm">{doc.name}</span> - )} - /> - <TooltipContent> - {doc.name} - </TooltipContent> - </Tooltip> - {doc.summary_index_status && ( - <div className="ml-1 hidden shrink-0 group-hover:flex"> - <SummaryStatus status={doc.summary_index_status} /> - </div> - )} - {datasetACLCapabilities.canEdit && ( - <div className="hidden shrink-0 group-hover:ml-auto group-hover:flex"> - <Tooltip> - <TooltipTrigger - render={( - <button - type="button" - className="cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover" - onClick={handleRenameClick} - > - <span className="i-ri-edit-line size-4 text-text-tertiary" /> - </button> - )} - /> - <TooltipContent> - {t($ => $['list.table.rename'], { ns: 'datasetDocuments' })} - </TooltipContent> - </Tooltip> + </td> + <td> + <div className="group mr-6 flex max-w-[460px] items-center hover:mr-0"> + <div className="flex shrink-0 items-center"> + <DocumentSourceIcon doc={doc} fileType={fileType} /> </div> + <Tooltip> + <TooltipTrigger + render={ + <span id={documentNameId} className="grow truncate text-sm"> + {doc.name} + </span> + } + /> + <TooltipContent>{doc.name}</TooltipContent> + </Tooltip> + {doc.summary_index_status && ( + <div className="ml-1 hidden shrink-0 group-hover:flex"> + <SummaryStatus status={doc.summary_index_status} /> + </div> + )} + {datasetACLCapabilities.canEdit && ( + <div className="hidden shrink-0 group-hover:ml-auto group-hover:flex"> + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + className="cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover" + onClick={handleRenameClick} + > + <span className="i-ri-edit-line size-4 text-text-tertiary" /> + </button> + } + /> + <TooltipContent> + {t(($) => $['list.table.rename'], { ns: 'datasetDocuments' })} + </TooltipContent> + </Tooltip> + </div> + )} + </div> + </td> + <td> + <ChunkingModeLabel isGeneralMode={isGeneralMode} isQAMode={isQAMode} /> + </td> + <td>{renderCount(doc.word_count)}</td> + <td>{renderCount(doc.hit_count)}</td> + <td className="text-[13px] text-text-secondary"> + {formatTime( + doc.created_at, + t(($) => $.dateTimeFormat, { ns: 'datasetHitTesting' }) as string, )} - </div> - </td> - <td> - <ChunkingModeLabel - isGeneralMode={isGeneralMode} - isQAMode={isQAMode} - /> - </td> - <td>{renderCount(doc.word_count)}</td> - <td>{renderCount(doc.hit_count)}</td> - <td className="text-[13px] text-text-secondary"> - {formatTime(doc.created_at, t($ => $.dateTimeFormat, { ns: 'datasetHitTesting' }) as string)} - </td> - <td> - <StatusItem status={doc.display_status} /> - </td> - <td> - <Operations - selectedIds={selectedIds} - onSelectedIdChange={onSelectedIdChange} - embeddingAvailable={embeddingAvailable} - datasetId={datasetId} - detail={pick(doc, ['name', 'enabled', 'archived', 'id', 'data_source_type', 'doc_form', 'display_status'])} - onUpdate={onUpdate} - canEdit={datasetACLCapabilities.canEdit} - canDownload={datasetACLCapabilities.canDocumentDownload} - canDelete={datasetACLCapabilities.canDeleteFile} - canViewSettings={datasetACLCapabilities.canEdit} - /> - </td> - </tr> - ) -}) + </td> + <td> + <StatusItem status={doc.display_status} /> + </td> + <td> + <Operations + selectedIds={selectedIds} + onSelectedIdChange={onSelectedIdChange} + embeddingAvailable={embeddingAvailable} + datasetId={datasetId} + detail={pick(doc, [ + 'name', + 'enabled', + 'archived', + 'id', + 'data_source_type', + 'doc_form', + 'display_status', + ])} + onUpdate={onUpdate} + canEdit={datasetACLCapabilities.canEdit} + canDownload={datasetACLCapabilities.canDocumentDownload} + canDelete={datasetACLCapabilities.canDeleteFile} + canViewSettings={datasetACLCapabilities.canEdit} + /> + </td> + </tr> + ) + }, +) DocumentTableRow.displayName = 'DocumentTableRow' diff --git a/web/app/components/datasets/documents/components/document-list/components/sort-header.tsx b/web/app/components/datasets/documents/components/document-list/components/sort-header.tsx index 5d79cfff3b30aa..e9a446f1da3e44 100644 --- a/web/app/components/datasets/documents/components/document-list/components/sort-header.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/sort-header.tsx @@ -11,33 +11,29 @@ type SortHeaderProps = { onSort: (field: SortField) => void } -const SortHeader: FC<SortHeaderProps> = React.memo(({ - field, - label, - currentSortField, - sortOrder, - onSort, -}) => { - const isActive = currentSortField === field - const isDesc = isActive && sortOrder === 'desc' +const SortHeader: FC<SortHeaderProps> = React.memo( + ({ field, label, currentSortField, sortOrder, onSort }) => { + const isActive = currentSortField === field + const isDesc = isActive && sortOrder === 'desc' - return ( - <button - type="button" - className="flex items-center bg-transparent p-0 text-left hover:text-text-secondary" - onClick={() => onSort(field)} - > - {label} - <span - className={cn( - 'ml-0.5 i-ri-arrow-down-line size-3 transition-all', - isActive ? 'text-text-tertiary' : 'text-text-disabled', - isActive && !isDesc ? 'rotate-180' : '', - )} - /> - </button> - ) -}) + return ( + <button + type="button" + className="flex items-center bg-transparent p-0 text-left hover:text-text-secondary" + onClick={() => onSort(field)} + > + {label} + <span + className={cn( + 'ml-0.5 i-ri-arrow-down-line size-3 transition-all', + isActive ? 'text-text-tertiary' : 'text-text-disabled', + isActive && !isDesc ? 'rotate-180' : '', + )} + /> + </button> + ) + }, +) SortHeader.displayName = 'SortHeader' diff --git a/web/app/components/datasets/documents/components/document-list/components/utils.tsx b/web/app/components/datasets/documents/components/document-list/components/utils.tsx index ddcc1c767e1ce2..7176dbbd900fc7 100644 --- a/web/app/components/datasets/documents/components/document-list/components/utils.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/utils.tsx @@ -3,14 +3,7 @@ import { cn } from '@langgenius/dify-ui/cn' import s from '../../../style.module.css' export const renderTdValue = (value: string | number | null, isEmptyStyle = false): ReactNode => { - const className = cn( - isEmptyStyle ? 'text-text-tertiary' : 'text-text-secondary', - s.tdValue, - ) + const className = cn(isEmptyStyle ? 'text-text-tertiary' : 'text-text-secondary', s.tdValue) - return ( - <div className={className}> - {value ?? '-'} - </div> - ) + return <div className={className}>{value ?? '-'}</div> } diff --git a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.tsx b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.tsx index 4b537f95a33c84..641ed4f8722986 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.tsx @@ -16,19 +16,18 @@ const mockUseDocumentDelete = vi.mocked(useDocument.useDocumentDelete) const mockUseDocumentBatchRetryIndex = vi.mocked(useDocument.useDocumentBatchRetryIndex) const mockUseDocumentDownloadZip = vi.mocked(useDocument.useDocumentDownloadZip) -const createTestQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, -}) +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) const createWrapper = () => { const queryClient = createTestQueryClient() return ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) } @@ -57,12 +56,24 @@ describe('useDocumentActions', () => { submittedAt: 0, }) - mockUseDocumentArchive.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentArchive>) - mockUseDocumentSummary.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentSummary>) - mockUseDocumentEnable.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentEnable>) - mockUseDocumentDisable.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentDisable>) - mockUseDocumentDelete.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentDelete>) - mockUseDocumentBatchRetryIndex.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentBatchRetryIndex>) + mockUseDocumentArchive.mockReturnValue( + createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentArchive>, + ) + mockUseDocumentSummary.mockReturnValue( + createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentSummary>, + ) + mockUseDocumentEnable.mockReturnValue( + createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentEnable>, + ) + mockUseDocumentDisable.mockReturnValue( + createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentDisable>, + ) + mockUseDocumentDelete.mockReturnValue( + createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentDelete>, + ) + mockUseDocumentBatchRetryIndex.mockReturnValue( + createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentBatchRetryIndex>, + ) mockUseDocumentDownloadZip.mockReturnValue({ ...createMockMutation(), isPending: false, @@ -76,13 +87,14 @@ describe('useDocumentActions', () => { const onClearSelection = vi.fn() const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1'], - downloadableSelectedIds: [], - onUpdate, - onClearSelection, - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), { wrapper: createWrapper() }, ) @@ -102,13 +114,14 @@ describe('useDocumentActions', () => { const onClearSelection = vi.fn() const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1'], - downloadableSelectedIds: [], - onUpdate, - onClearSelection, - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), { wrapper: createWrapper() }, ) @@ -127,13 +140,14 @@ describe('useDocumentActions', () => { const onClearSelection = vi.fn() const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1'], - downloadableSelectedIds: [], - onUpdate, - onClearSelection, - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), { wrapper: createWrapper() }, ) @@ -154,13 +168,14 @@ describe('useDocumentActions', () => { const onClearSelection = vi.fn() const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1', 'doc2'], - downloadableSelectedIds: [], - onUpdate, - onClearSelection, - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1', 'doc2'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), { wrapper: createWrapper() }, ) @@ -180,13 +195,14 @@ describe('useDocumentActions', () => { const onClearSelection = vi.fn() const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1'], - downloadableSelectedIds: [], - onUpdate, - onClearSelection, - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), { wrapper: createWrapper() }, ) @@ -209,13 +225,14 @@ describe('useDocumentActions', () => { } as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>) const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1'], - downloadableSelectedIds: ['doc1'], - onUpdate: vi.fn(), - onClearSelection: vi.fn(), - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: ['doc1'], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + }), { wrapper: createWrapper() }, ) @@ -236,13 +253,14 @@ describe('useDocumentActions', () => { } as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>) const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1', 'doc2'], - downloadableSelectedIds: ['doc1'], - onUpdate: vi.fn(), - onClearSelection: vi.fn(), - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1', 'doc2'], + downloadableSelectedIds: ['doc1'], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + }), { wrapper: createWrapper() }, ) @@ -265,13 +283,14 @@ describe('useDocumentActions', () => { } as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>) const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: [], - downloadableSelectedIds: [], - onUpdate: vi.fn(), - onClearSelection: vi.fn(), - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: [], + downloadableSelectedIds: [], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + }), { wrapper: createWrapper() }, ) @@ -286,13 +305,14 @@ describe('useDocumentActions', () => { const onClearSelection = vi.fn() const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1'], - downloadableSelectedIds: [], - onUpdate, - onClearSelection, - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), { wrapper: createWrapper() }, ) @@ -310,13 +330,14 @@ describe('useDocumentActions', () => { const onClearSelection = vi.fn() const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1'], - downloadableSelectedIds: [], - onUpdate, - onClearSelection, - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), { wrapper: createWrapper() }, ) @@ -338,13 +359,14 @@ describe('useDocumentActions', () => { } as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>) const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1'], - downloadableSelectedIds: ['doc1'], - onUpdate: vi.fn(), - onClearSelection: vi.fn(), - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: ['doc1'], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + }), { wrapper: createWrapper() }, ) @@ -365,13 +387,14 @@ describe('useDocumentActions', () => { } as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>) const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1'], - downloadableSelectedIds: ['doc1'], - onUpdate: vi.fn(), - onClearSelection: vi.fn(), - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: ['doc1'], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + }), { wrapper: createWrapper() }, ) @@ -390,13 +413,14 @@ describe('useDocumentActions', () => { const onUpdate = vi.fn() const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1'], - downloadableSelectedIds: [], - onUpdate, - onClearSelection: vi.fn(), - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection: vi.fn(), + }), { wrapper: createWrapper() }, ) @@ -415,13 +439,14 @@ describe('useDocumentActions', () => { const onUpdate = vi.fn() const { result } = renderHook( - () => useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1'], - downloadableSelectedIds: [], - onUpdate, - onClearSelection: vi.fn(), - }), + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection: vi.fn(), + }), { wrapper: createWrapper() }, ) diff --git a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-selection.spec.ts b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-selection.spec.ts index d266a2d4bd690f..fe8957efaa02bb 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-selection.spec.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-selection.spec.ts @@ -6,23 +6,24 @@ import { useDocumentSelection } from '../use-document-selection' type LocalDoc = SimpleDocumentDetail & { percent?: number } -const createMockDocument = (overrides: Partial<LocalDoc> = {}): LocalDoc => ({ - id: 'doc1', - name: 'Test Document', - data_source_type: DataSourceType.FILE, - data_source_info: {}, - data_source_detail_dict: {}, - word_count: 100, - hit_count: 10, - created_at: 1000000, - position: 1, - doc_form: 'text_model', - enabled: true, - archived: false, - display_status: 'available', - created_from: 'api', - ...overrides, -} as LocalDoc) +const createMockDocument = (overrides: Partial<LocalDoc> = {}): LocalDoc => + ({ + id: 'doc1', + name: 'Test Document', + data_source_type: DataSourceType.FILE, + data_source_info: {}, + data_source_detail_dict: {}, + word_count: 100, + hit_count: 10, + created_at: 1000000, + position: 1, + doc_form: 'text_model', + enabled: true, + archived: false, + display_status: 'available', + created_from: 'api', + ...overrides, + }) as LocalDoc describe('useDocumentSelection', () => { describe('hasErrorDocumentsSelected', () => { diff --git a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-sort.spec.ts b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-sort.spec.ts index 004597afa9da9f..784b0d74b296e1 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-sort.spec.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-sort.spec.ts @@ -6,10 +6,12 @@ describe('useDocumentSort', () => { describe('remote state parsing', () => { it('should parse descending created_at sort', () => { const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => useDocumentSort({ - remoteSortValue: '-created_at', - onRemoteSortChange, - })) + const { result } = renderHook(() => + useDocumentSort({ + remoteSortValue: '-created_at', + onRemoteSortChange, + }), + ) expect(result.current.sortField).toBe('created_at') expect(result.current.sortOrder).toBe('desc') @@ -17,10 +19,12 @@ describe('useDocumentSort', () => { it('should parse ascending hit_count sort', () => { const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => useDocumentSort({ - remoteSortValue: 'hit_count', - onRemoteSortChange, - })) + const { result } = renderHook(() => + useDocumentSort({ + remoteSortValue: 'hit_count', + onRemoteSortChange, + }), + ) expect(result.current.sortField).toBe('hit_count') expect(result.current.sortOrder).toBe('asc') @@ -28,10 +32,12 @@ describe('useDocumentSort', () => { it('should fallback to inactive field for unsupported sort key', () => { const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => useDocumentSort({ - remoteSortValue: '-name', - onRemoteSortChange, - })) + const { result } = renderHook(() => + useDocumentSort({ + remoteSortValue: '-name', + onRemoteSortChange, + }), + ) expect(result.current.sortField).toBeNull() expect(result.current.sortOrder).toBe('desc') @@ -41,10 +47,12 @@ describe('useDocumentSort', () => { describe('handleSort', () => { it('should switch to desc when selecting a different field', () => { const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => useDocumentSort({ - remoteSortValue: '-created_at', - onRemoteSortChange, - })) + const { result } = renderHook(() => + useDocumentSort({ + remoteSortValue: '-created_at', + onRemoteSortChange, + }), + ) act(() => { result.current.handleSort('hit_count') @@ -55,10 +63,12 @@ describe('useDocumentSort', () => { it('should toggle desc -> asc when clicking active field', () => { const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => useDocumentSort({ - remoteSortValue: '-hit_count', - onRemoteSortChange, - })) + const { result } = renderHook(() => + useDocumentSort({ + remoteSortValue: '-hit_count', + onRemoteSortChange, + }), + ) act(() => { result.current.handleSort('hit_count') @@ -69,10 +79,12 @@ describe('useDocumentSort', () => { it('should toggle asc -> desc when clicking active field', () => { const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => useDocumentSort({ - remoteSortValue: 'created_at', - onRemoteSortChange, - })) + const { result } = renderHook(() => + useDocumentSort({ + remoteSortValue: 'created_at', + onRemoteSortChange, + }), + ) act(() => { result.current.handleSort('created_at') @@ -83,10 +95,12 @@ describe('useDocumentSort', () => { it('should ignore null field', () => { const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => useDocumentSort({ - remoteSortValue: '-created_at', - onRemoteSortChange, - })) + const { result } = renderHook(() => + useDocumentSort({ + remoteSortValue: '-created_at', + onRemoteSortChange, + }), + ) act(() => { result.current.handleSort(null) diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts b/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts index 6853ff01c262a5..cf48ee5bc80bb7 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts @@ -28,9 +28,10 @@ type UseDocumentActionsOptions = { * We intentionally avoid leaking dataset info in the exported archive name. */ const generateDocsZipFileName = (): string => { - const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') - ? crypto.randomUUID() - : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}` + const randomPart = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}` return `${randomPart}-docs.zip` } @@ -51,42 +52,46 @@ export const useDocumentActions = ({ const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex() const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip() - type SupportedActionType - = | typeof DocumentActionType.archive - | typeof DocumentActionType.summary - | typeof DocumentActionType.enable - | typeof DocumentActionType.disable - | typeof DocumentActionType.delete + type SupportedActionType = + | typeof DocumentActionType.archive + | typeof DocumentActionType.summary + | typeof DocumentActionType.enable + | typeof DocumentActionType.disable + | typeof DocumentActionType.delete - const actionMutationMap = useMemo(() => ({ - [DocumentActionType.archive]: archiveDocument, - [DocumentActionType.summary]: generateSummary, - [DocumentActionType.enable]: enableDocument, - [DocumentActionType.disable]: disableDocument, - [DocumentActionType.delete]: deleteDocument, - } as const), [archiveDocument, generateSummary, enableDocument, disableDocument, deleteDocument]) + const actionMutationMap = useMemo( + () => + ({ + [DocumentActionType.archive]: archiveDocument, + [DocumentActionType.summary]: generateSummary, + [DocumentActionType.enable]: enableDocument, + [DocumentActionType.disable]: disableDocument, + [DocumentActionType.delete]: deleteDocument, + }) as const, + [archiveDocument, generateSummary, enableDocument, disableDocument, deleteDocument], + ) - const handleAction = useCallback((actionName: SupportedActionType) => { - return async () => { - const opApi = actionMutationMap[actionName] - if (!opApi) - return + const handleAction = useCallback( + (actionName: SupportedActionType) => { + return async () => { + const opApi = actionMutationMap[actionName] + if (!opApi) return - const [e] = await asyncRunSafe<CommonResponse>( - opApi({ datasetId, documentIds: selectedIds }), - ) + const [e] = await asyncRunSafe<CommonResponse>( + opApi({ datasetId, documentIds: selectedIds }), + ) - if (!e) { - if (actionName === DocumentActionType.delete) - onClearSelection() - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - onUpdate() + if (!e) { + if (actionName === DocumentActionType.delete) onClearSelection() + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + onUpdate() + } else { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } } - else { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - } - } - }, [actionMutationMap, datasetId, selectedIds, onClearSelection, onUpdate, t]) + }, + [actionMutationMap, datasetId, selectedIds, onClearSelection, onUpdate, t], + ) const handleBatchReIndex = useCallback(async () => { const [e] = await asyncRunSafe<CommonResponse>( @@ -94,23 +99,21 @@ export const useDocumentActions = ({ ) if (!e) { onClearSelection() - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) onUpdate() - } - else { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } else { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) } }, [retryIndexDocument, datasetId, selectedIds, onClearSelection, onUpdate, t]) const handleBatchDownload = useCallback(async () => { - if (isDownloadingZip) - return + if (isDownloadingZip) return const [e, blob] = await asyncRunSafe( requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds }), ) if (e || !blob) { - toast.error(t($ => $['actionMsg.downloadUnsuccessfully'], { ns: 'common' })) + toast.error(t(($) => $['actionMsg.downloadUnsuccessfully'], { ns: 'common' })) return } diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.ts b/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.ts index 5344ac88f3d6ce..4ac2de01c0f634 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.ts @@ -16,14 +16,14 @@ export const useDocumentSelection = ({ onSelectedIdChange, }: UseDocumentSelectionOptions) => { const hasErrorDocumentsSelected = useMemo(() => { - return documents.some(doc => selectedIds.includes(doc.id) && doc.display_status === 'error') + return documents.some((doc) => selectedIds.includes(doc.id) && doc.display_status === 'error') }, [documents, selectedIds]) const downloadableSelectedIds = useMemo(() => { const selectedSet = new Set(selectedIds) return documents - .filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE) - .map(doc => doc.id) + .filter((doc) => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE) + .map((doc) => doc.id) }, [documents, selectedIds]) const clearSelection = useCallback(() => { diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.ts b/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.ts index 0e0b07db6f4b83..8d642fa3492a11 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.ts @@ -19,20 +19,22 @@ export const useDocumentSort = ({ const sortKey = remoteSortValue.startsWith('-') ? remoteSortValue.slice(1) : remoteSortValue const sortField = useMemo<SortField>(() => { - return REMOTE_SORT_FIELDS.has(sortKey as RemoteSortField) ? sortKey as RemoteSortField : null + return REMOTE_SORT_FIELDS.has(sortKey as RemoteSortField) ? (sortKey as RemoteSortField) : null }, [sortKey]) - const handleSort = useCallback((field: SortField) => { - if (!field) - return - - if (sortField === field) { - const nextSortOrder = sortOrder === 'desc' ? 'asc' : 'desc' - onRemoteSortChange(nextSortOrder === 'desc' ? `-${field}` : field) - return - } - onRemoteSortChange(`-${field}`) - }, [onRemoteSortChange, sortField, sortOrder]) + const handleSort = useCallback( + (field: SortField) => { + if (!field) return + + if (sortField === field) { + const nextSortOrder = sortOrder === 'desc' ? 'asc' : 'desc' + onRemoteSortChange(nextSortOrder === 'desc' ? `-${field}` : field) + return + } + onRemoteSortChange(`-${field}`) + }, + [onRemoteSortChange, sortField, sortOrder], + ) return { sortField, diff --git a/web/app/components/datasets/documents/components/documents-header.tsx b/web/app/components/datasets/documents/components/documents-header.tsx index c877b2ad950eb6..eee44600d98ad1 100644 --- a/web/app/components/datasets/documents/components/documents-header.tsx +++ b/web/app/components/datasets/documents/components/documents-header.tsx @@ -1,6 +1,9 @@ 'use client' import type { FC } from 'react' -import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types' +import type { + BuiltInMetadataItem, + MetadataItemWithValueLength, +} from '@/app/components/datasets/metadata/types' import type { SortType } from '@/service/datasets' import { Button } from '@langgenius/dify-ui/button' import { useMemo } from 'react' @@ -90,30 +93,40 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({ const isDataSourceNotion = dataSourceType === DataSourceType.NOTION const isDataSourceWeb = dataSourceType === DataSourceType.WEB - const statusFilterItems: SelectOption[] = useMemo(() => [ - { value: 'all', name: t($ => $['list.index.all'], { ns: 'datasetDocuments' }) as string }, - { value: 'queuing', name: DOC_INDEX_STATUS_MAP.queuing.text }, - { value: 'indexing', name: DOC_INDEX_STATUS_MAP.indexing.text }, - { value: 'paused', name: DOC_INDEX_STATUS_MAP.paused.text }, - { value: 'error', name: DOC_INDEX_STATUS_MAP.error.text }, - { value: 'available', name: DOC_INDEX_STATUS_MAP.available.text }, - { value: 'enabled', name: DOC_INDEX_STATUS_MAP.enabled.text }, - { value: 'disabled', name: DOC_INDEX_STATUS_MAP.disabled.text }, - { value: 'archived', name: DOC_INDEX_STATUS_MAP.archived.text }, - ], [DOC_INDEX_STATUS_MAP, t]) + const statusFilterItems: SelectOption[] = useMemo( + () => [ + { value: 'all', name: t(($) => $['list.index.all'], { ns: 'datasetDocuments' }) as string }, + { value: 'queuing', name: DOC_INDEX_STATUS_MAP.queuing.text }, + { value: 'indexing', name: DOC_INDEX_STATUS_MAP.indexing.text }, + { value: 'paused', name: DOC_INDEX_STATUS_MAP.paused.text }, + { value: 'error', name: DOC_INDEX_STATUS_MAP.error.text }, + { value: 'available', name: DOC_INDEX_STATUS_MAP.available.text }, + { value: 'enabled', name: DOC_INDEX_STATUS_MAP.enabled.text }, + { value: 'disabled', name: DOC_INDEX_STATUS_MAP.disabled.text }, + { value: 'archived', name: DOC_INDEX_STATUS_MAP.archived.text }, + ], + [DOC_INDEX_STATUS_MAP, t], + ) - const sortItems: SelectOption[] = useMemo(() => [ - { value: 'created_at', name: t($ => $['list.sort.uploadTime'], { ns: 'datasetDocuments' }) as string }, - { value: 'hit_count', name: t($ => $['list.sort.hitCount'], { ns: 'datasetDocuments' }) as string }, - ], [t]) + const sortItems: SelectOption[] = useMemo( + () => [ + { + value: 'created_at', + name: t(($) => $['list.sort.uploadTime'], { ns: 'datasetDocuments' }) as string, + }, + { + value: 'hit_count', + name: t(($) => $['list.sort.hitCount'], { ns: 'datasetDocuments' }) as string, + }, + ], + [t], + ) // Determine add button text based on data source type const addButtonText = useMemo(() => { - if (isDataSourceNotion) - return t($ => $['list.addPages'], { ns: 'datasetDocuments' }) - if (isDataSourceWeb) - return t($ => $['list.addUrl'], { ns: 'datasetDocuments' }) - return t($ => $['list.addFile'], { ns: 'datasetDocuments' }) + if (isDataSourceNotion) return t(($) => $['list.addPages'], { ns: 'datasetDocuments' }) + if (isDataSourceWeb) return t(($) => $['list.addUrl'], { ns: 'datasetDocuments' }) + return t(($) => $['list.addFile'], { ns: 'datasetDocuments' }) }, [isDataSourceNotion, isDataSourceWeb, t]) return ( @@ -121,17 +134,17 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({ {/* Title section */} <div className="flex flex-col justify-center gap-1 px-6 pt-4"> <h1 className="text-base font-semibold text-text-primary"> - {t($ => $['list.title'], { ns: 'datasetDocuments' })} + {t(($) => $['list.title'], { ns: 'datasetDocuments' })} </h1> <div className="flex items-center space-x-0.5 text-sm font-normal text-text-tertiary"> - <span>{t($ => $['list.desc'], { ns: 'datasetDocuments' })}</span> + <span>{t(($) => $['list.desc'], { ns: 'datasetDocuments' })}</span> <a className="flex items-center text-text-accent" target="_blank" rel="noopener noreferrer" href={docLink('/use-dify/knowledge/integrate-knowledge-within-application')} > - <span>{t($ => $['list.learnMore'], { ns: 'datasetDocuments' })}</span> + <span>{t(($) => $['list.learnMore'], { ns: 'datasetDocuments' })}</span> <span className="i-ri-external-link-line size-3" /> </a> </div> @@ -146,7 +159,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({ showLeftIcon={false} value={statusFilterValue} items={statusFilterItems} - onSelect={item => onStatusFilterChange(item?.value ? String(item.value) : '')} + onSelect={(item) => onStatusFilterChange(item?.value ? String(item.value) : '')} onClear={onStatusFilterClear} /> <Input @@ -154,7 +167,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({ showClearIcon wrapperClassName="w-[200px]!" value={inputValue} - onChange={e => onInputChange(e.target.value)} + onChange={(e) => onInputChange(e.target.value)} onClear={() => onInputChange('')} /> <div className="h-3.5 w-px bg-divider-regular"></div> @@ -162,7 +175,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({ order={sortValue.startsWith('-') ? '-' : ''} value={sortValue.replace('-', '')} items={sortItems} - onSelect={value => onSortChange(String(value))} + onSelect={(value) => onSortChange(String(value))} /> </div> @@ -173,13 +186,13 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({ {!embeddingAvailable && ( <StatusWithAction type="warning" - description={t($ => $.embeddingModelNotAvailable, { ns: 'dataset' })} + description={t(($) => $.embeddingModelNotAvailable, { ns: 'dataset' })} /> )} {embeddingAvailable && canManageMetadata && ( <Button variant="secondary" className="shrink-0" onClick={showEditMetadataModal}> <span className="mr-1 i-ri-draft-line size-4" /> - {t($ => $['metadata.metadata'], { ns: 'dataset' })} + {t(($) => $['metadata.metadata'], { ns: 'dataset' })} </Button> )} {isShowEditMetadataModal && ( diff --git a/web/app/components/datasets/documents/components/empty-element.tsx b/web/app/components/datasets/documents/components/empty-element.tsx index e00d84d6faf591..104f89655a6514 100644 --- a/web/app/components/datasets/documents/components/empty-element.tsx +++ b/web/app/components/datasets/documents/components/empty-element.tsx @@ -21,16 +21,16 @@ const EmptyElement: FC<EmptyElementProps> = ({ canAdd = false, onClick, type = ' {type === 'upload' ? <FolderPlusIcon /> : <NotionIcon />} </div> <span className={s.emptyTitle}> - {t($ => $['list.empty.title'], { ns: 'datasetDocuments' })} + {t(($) => $['list.empty.title'], { ns: 'datasetDocuments' })} <ThreeDotsIcon className="relative -top-3 -left-1.5 inline" /> </span> <div className={s.emptyTip}> - {t($ => $[`list.empty.${type}.tip`], { ns: 'datasetDocuments' })} + {t(($) => $[`list.empty.${type}.tip`], { ns: 'datasetDocuments' })} </div> {type === 'upload' && canAdd && ( <Button onClick={onClick} className={s.addFileBtn} variant="secondary-accent"> <PlusIcon className={s.plusIcon} /> - {t($ => $['list.addFile'], { ns: 'datasetDocuments' })} + {t(($) => $['list.addFile'], { ns: 'datasetDocuments' })} </Button> )} </div> diff --git a/web/app/components/datasets/documents/components/icons.tsx b/web/app/components/datasets/documents/components/icons.tsx index 6a862f12f0c5e4..11187c3d821e41 100644 --- a/web/app/components/datasets/documents/components/icons.tsx +++ b/web/app/components/datasets/documents/components/icons.tsx @@ -2,27 +2,73 @@ import type * as React from 'react' export const FolderPlusIcon = ({ className }: React.SVGProps<SVGElement>) => { return ( - <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}> - <path d="M10.8332 5.83333L9.90355 3.9741C9.63601 3.439 9.50222 3.17144 9.30265 2.97597C9.12615 2.80311 8.91344 2.67164 8.6799 2.59109C8.41581 2.5 8.11668 2.5 7.51841 2.5H4.33317C3.39975 2.5 2.93304 2.5 2.57652 2.68166C2.26292 2.84144 2.00795 3.09641 1.84816 3.41002C1.6665 3.76654 1.6665 4.23325 1.6665 5.16667V5.83333M1.6665 5.83333H14.3332C15.7333 5.83333 16.4334 5.83333 16.9681 6.10582C17.4386 6.3455 17.821 6.72795 18.0607 7.19836C18.3332 7.73314 18.3332 8.4332 18.3332 9.83333V13.5C18.3332 14.9001 18.3332 15.6002 18.0607 16.135C17.821 16.6054 17.4386 16.9878 16.9681 17.2275C16.4334 17.5 15.7333 17.5 14.3332 17.5H5.6665C4.26637 17.5 3.56631 17.5 3.03153 17.2275C2.56112 16.9878 2.17867 16.6054 1.93899 16.135C1.6665 15.6002 1.6665 14.9001 1.6665 13.5V5.83333ZM9.99984 14.1667V9.16667M7.49984 11.6667H12.4998" stroke="#667085" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> + <svg + width="20" + height="20" + viewBox="0 0 20 20" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className={className ?? ''} + > + <path + d="M10.8332 5.83333L9.90355 3.9741C9.63601 3.439 9.50222 3.17144 9.30265 2.97597C9.12615 2.80311 8.91344 2.67164 8.6799 2.59109C8.41581 2.5 8.11668 2.5 7.51841 2.5H4.33317C3.39975 2.5 2.93304 2.5 2.57652 2.68166C2.26292 2.84144 2.00795 3.09641 1.84816 3.41002C1.6665 3.76654 1.6665 4.23325 1.6665 5.16667V5.83333M1.6665 5.83333H14.3332C15.7333 5.83333 16.4334 5.83333 16.9681 6.10582C17.4386 6.3455 17.821 6.72795 18.0607 7.19836C18.3332 7.73314 18.3332 8.4332 18.3332 9.83333V13.5C18.3332 14.9001 18.3332 15.6002 18.0607 16.135C17.821 16.6054 17.4386 16.9878 16.9681 17.2275C16.4334 17.5 15.7333 17.5 14.3332 17.5H5.6665C4.26637 17.5 3.56631 17.5 3.03153 17.2275C2.56112 16.9878 2.17867 16.6054 1.93899 16.135C1.6665 15.6002 1.6665 14.9001 1.6665 13.5V5.83333ZM9.99984 14.1667V9.16667M7.49984 11.6667H12.4998" + stroke="#667085" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> </svg> ) } export const ThreeDotsIcon = ({ className }: React.SVGProps<SVGElement>) => { return ( - <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}> - <path d="M5 6.5V5M8.93934 7.56066L10 6.5M10.0103 11.5H11.5103" stroke="#374151" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> + <svg + width="16" + height="16" + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className={className ?? ''} + > + <path + d="M5 6.5V5M8.93934 7.56066L10 6.5M10.0103 11.5H11.5103" + stroke="#374151" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> </svg> ) } export const NotionIcon = ({ className }: React.SVGProps<SVGElement>) => { return ( - <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}> + <svg + width="20" + height="20" + viewBox="0 0 20 20" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className={className ?? ''} + > <g clipPath="url(#clip0_2164_11263)"> - <path fillRule="evenodd" clipRule="evenodd" d="M3.5725 18.2611L1.4229 15.5832C0.905706 14.9389 0.625 14.1466 0.625 13.3312V3.63437C0.625 2.4129 1.60224 1.39936 2.86295 1.31328L12.8326 0.632614C13.5569 0.583164 14.2768 0.775682 14.8717 1.17794L18.3745 3.5462C19.0015 3.97012 19.375 4.66312 19.375 5.40266V16.427C19.375 17.6223 18.4141 18.6121 17.1798 18.688L6.11458 19.3692C5.12958 19.4298 4.17749 19.0148 3.5725 18.2611Z" fill="white" /> - <path d="M7.03006 8.48669V8.35974C7.03006 8.03794 7.28779 7.77104 7.61997 7.74886L10.0396 7.58733L13.3857 12.5147V8.19009L12.5244 8.07528V8.01498C12.5244 7.68939 12.788 7.42074 13.1244 7.4035L15.326 7.29073V7.60755C15.326 7.75628 15.2154 7.88349 15.0638 7.90913L14.534 7.99874V15.0023L13.8691 15.231C13.3136 15.422 12.6952 15.2175 12.3772 14.7377L9.12879 9.83574V14.5144L10.1287 14.7057L10.1147 14.7985C10.0711 15.089 9.82028 15.3087 9.51687 15.3222L7.03006 15.4329C6.99718 15.1205 7.23132 14.841 7.55431 14.807L7.88143 14.7727V8.53453L7.03006 8.48669Z" fill="black" /> - <path fillRule="evenodd" clipRule="evenodd" d="M12.9218 1.85424L2.95217 2.53491C2.35499 2.57568 1.89209 3.05578 1.89209 3.63437V13.3312C1.89209 13.8748 2.07923 14.403 2.42402 14.8325L4.57362 17.5104C4.92117 17.9434 5.46812 18.1818 6.03397 18.147L17.0991 17.4658C17.6663 17.4309 18.1078 16.9762 18.1078 16.427V5.40266C18.1078 5.06287 17.9362 4.74447 17.6481 4.54969L14.1453 2.18143C13.7883 1.94008 13.3564 1.82457 12.9218 1.85424ZM3.44654 3.78562C3.30788 3.68296 3.37387 3.46909 3.54806 3.4566L12.9889 2.77944C13.2897 2.75787 13.5886 2.8407 13.8318 3.01305L15.7261 4.35508C15.798 4.40603 15.7642 4.51602 15.6752 4.52086L5.67742 5.0646C5.37485 5.08106 5.0762 4.99217 4.83563 4.81406L3.44654 3.78562ZM5.20848 6.76919C5.20848 6.4444 5.47088 6.1761 5.80642 6.15783L16.3769 5.58216C16.7039 5.56435 16.9792 5.81583 16.9792 6.13239V15.6783C16.9792 16.0025 16.7177 16.2705 16.3829 16.2896L5.8793 16.8872C5.51537 16.9079 5.20848 16.6283 5.20848 16.2759V6.76919Z" fill="black" /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M3.5725 18.2611L1.4229 15.5832C0.905706 14.9389 0.625 14.1466 0.625 13.3312V3.63437C0.625 2.4129 1.60224 1.39936 2.86295 1.31328L12.8326 0.632614C13.5569 0.583164 14.2768 0.775682 14.8717 1.17794L18.3745 3.5462C19.0015 3.97012 19.375 4.66312 19.375 5.40266V16.427C19.375 17.6223 18.4141 18.6121 17.1798 18.688L6.11458 19.3692C5.12958 19.4298 4.17749 19.0148 3.5725 18.2611Z" + fill="white" + /> + <path + d="M7.03006 8.48669V8.35974C7.03006 8.03794 7.28779 7.77104 7.61997 7.74886L10.0396 7.58733L13.3857 12.5147V8.19009L12.5244 8.07528V8.01498C12.5244 7.68939 12.788 7.42074 13.1244 7.4035L15.326 7.29073V7.60755C15.326 7.75628 15.2154 7.88349 15.0638 7.90913L14.534 7.99874V15.0023L13.8691 15.231C13.3136 15.422 12.6952 15.2175 12.3772 14.7377L9.12879 9.83574V14.5144L10.1287 14.7057L10.1147 14.7985C10.0711 15.089 9.82028 15.3087 9.51687 15.3222L7.03006 15.4329C6.99718 15.1205 7.23132 14.841 7.55431 14.807L7.88143 14.7727V8.53453L7.03006 8.48669Z" + fill="black" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M12.9218 1.85424L2.95217 2.53491C2.35499 2.57568 1.89209 3.05578 1.89209 3.63437V13.3312C1.89209 13.8748 2.07923 14.403 2.42402 14.8325L4.57362 17.5104C4.92117 17.9434 5.46812 18.1818 6.03397 18.147L17.0991 17.4658C17.6663 17.4309 18.1078 16.9762 18.1078 16.427V5.40266C18.1078 5.06287 17.9362 4.74447 17.6481 4.54969L14.1453 2.18143C13.7883 1.94008 13.3564 1.82457 12.9218 1.85424ZM3.44654 3.78562C3.30788 3.68296 3.37387 3.46909 3.54806 3.4566L12.9889 2.77944C13.2897 2.75787 13.5886 2.8407 13.8318 3.01305L15.7261 4.35508C15.798 4.40603 15.7642 4.51602 15.6752 4.52086L5.67742 5.0646C5.37485 5.08106 5.0762 4.99217 4.83563 4.81406L3.44654 3.78562ZM5.20848 6.76919C5.20848 6.4444 5.47088 6.1761 5.80642 6.15783L16.3769 5.58216C16.7039 5.56435 16.9792 5.81583 16.9792 6.13239V15.6783C16.9792 16.0025 16.7177 16.2705 16.3829 16.2896L5.8793 16.8872C5.51537 16.9079 5.20848 16.6283 5.20848 16.2759V6.76919Z" + fill="black" + /> </g> <defs> <clipPath id="clip0_2164_11263"> diff --git a/web/app/components/datasets/documents/components/list.tsx b/web/app/components/datasets/documents/components/list.tsx index 0d89eb7cee7244..c4a121b8f2d128 100644 --- a/web/app/components/datasets/documents/components/list.tsx +++ b/web/app/components/datasets/documents/components/list.tsx @@ -62,14 +62,23 @@ const DocumentList = ({ const { t } = useTranslation() const pageSize = pagination.limit ?? 10 const totalPages = Math.max(Math.ceil(pagination.total / pageSize), 1) - const datasetConfig = useDatasetDetailContext(s => s.dataset) + const datasetConfig = useDatasetDetailContext((s) => s.dataset) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const datasetACLCapabilities = useMemo(() => getDatasetACLCapabilities(datasetConfig?.permission_keys, { - currentUserId, - resourceMaintainer: datasetConfig?.maintainer, - workspacePermissionKeys, - }), [datasetConfig?.maintainer, datasetConfig?.permission_keys, currentUserId, workspacePermissionKeys]) + const datasetACLCapabilities = useMemo( + () => + getDatasetACLCapabilities(datasetConfig?.permission_keys, { + currentUserId, + resourceMaintainer: datasetConfig?.maintainer, + workspacePermissionKeys, + }), + [ + datasetConfig?.maintainer, + datasetConfig?.permission_keys, + currentUserId, + workspacePermissionKeys, + ], + ) const chunkingMode = datasetConfig?.doc_form const isGeneralMode = chunkingMode !== ChunkingMode.parentChild const isQAMode = chunkingMode === ChunkingMode.qa @@ -81,16 +90,13 @@ const DocumentList = ({ }) // Selection - const { - hasErrorDocumentsSelected, - downloadableSelectedIds, - clearSelection, - } = useDocumentSelection({ - documents, - selectedIds, - onSelectedIdChange, - }) - const documentIds = useMemo(() => documents.map(doc => doc.id), [documents]) + const { hasErrorDocumentsSelected, downloadableSelectedIds, clearSelection } = + useDocumentSelection({ + documents, + selectedIds, + onSelectedIdChange, + }) + const documentIds = useMemo(() => documents.map((doc) => doc.id), [documents]) // Actions const { handleAction, handleBatchReIndex, handleBatchDownload } = useDocumentActions({ @@ -102,30 +108,28 @@ const DocumentList = ({ }) // Batch edit metadata - const { - isShowEditModal, - showEditModal, - hideEditModal, - originalList, - handleSave, - } = useBatchEditDocumentMetadata({ - datasetId, - docList: documents.filter(doc => selectedIds.includes(doc.id)), - selectedDocumentIds: selectedIds, - onUpdate, - }) + const { isShowEditModal, showEditModal, hideEditModal, originalList, handleSave } = + useBatchEditDocumentMetadata({ + datasetId, + docList: documents.filter((doc) => selectedIds.includes(doc.id)), + selectedDocumentIds: selectedIds, + onUpdate, + }) // Rename modal const [currDocument, setCurrDocument] = useState<LocalDoc | null>(null) - const [isShowRenameModal, { - setTrue: setShowRenameModalTrue, - setFalse: setShowRenameModalFalse, - }] = useBoolean(false) + const [ + isShowRenameModal, + { setTrue: setShowRenameModalTrue, setFalse: setShowRenameModalFalse }, + ] = useBoolean(false) - const handleShowRenameModal = useCallback((doc: LocalDoc) => { - setCurrDocument(doc) - setShowRenameModalTrue() - }, [setShowRenameModalTrue]) + const handleShowRenameModal = useCallback( + (doc: LocalDoc) => { + setCurrDocument(doc) + setShowRenameModalTrue() + }, + [setShowRenameModalTrue], + ) const handleRenamed = useCallback(() => { onUpdate() @@ -135,34 +139,38 @@ const DocumentList = ({ <div className="relative mt-3 flex size-full flex-col"> <CheckboxGroup value={selectedIds} - onValueChange={nextSelectedIds => onSelectedIdChange(nextSelectedIds)} + onValueChange={(nextSelectedIds) => onSelectedIdChange(nextSelectedIds)} allValues={documentIds} className="relative h-0 grow overflow-x-auto" > - <table className={`w-full max-w-full min-w-[700px] border-collapse border-0 text-sm ${s.documentTable}`}> + <table + className={`w-full max-w-full min-w-[700px] border-collapse border-0 text-sm ${s.documentTable}`} + > <thead className="h-8 border-b border-divider-subtle text-xs/8 font-medium text-text-tertiary uppercase"> <tr> <td className="w-12"> - <div className="flex items-center" onClick={e => e.stopPropagation()}> + <div className="flex items-center" onClick={(e) => e.stopPropagation()}> {embeddingAvailable && ( <Checkbox className="mr-2 shrink-0" parent - aria-label={t($ => $['operation.selectAll'], { ns: 'common' })} + aria-label={t(($) => $['operation.selectAll'], { ns: 'common' })} /> )} # </div> </td> - <td> - {t($ => $['list.table.header.fileName'], { ns: 'datasetDocuments' })} + <td>{t(($) => $['list.table.header.fileName'], { ns: 'datasetDocuments' })}</td> + <td className="w-[130px]"> + {t(($) => $['list.table.header.chunkingMode'], { ns: 'datasetDocuments' })} + </td> + <td className="w-24"> + {t(($) => $['list.table.header.words'], { ns: 'datasetDocuments' })} </td> - <td className="w-[130px]">{t($ => $['list.table.header.chunkingMode'], { ns: 'datasetDocuments' })}</td> - <td className="w-24">{t($ => $['list.table.header.words'], { ns: 'datasetDocuments' })}</td> <td className="w-44"> <SortHeader field="hit_count" - label={t($ => $['list.table.header.hitCount'], { ns: 'datasetDocuments' })} + label={t(($) => $['list.table.header.hitCount'], { ns: 'datasetDocuments' })} currentSortField={sortField} sortOrder={sortOrder} onSort={handleSort} @@ -171,14 +179,18 @@ const DocumentList = ({ <td className="w-44"> <SortHeader field="created_at" - label={t($ => $['list.table.header.uploadTime'], { ns: 'datasetDocuments' })} + label={t(($) => $['list.table.header.uploadTime'], { ns: 'datasetDocuments' })} currentSortField={sortField} sortOrder={sortOrder} onSort={handleSort} /> </td> - <td className="w-40">{t($ => $['list.table.header.status'], { ns: 'datasetDocuments' })}</td> - <td className="w-20">{t($ => $['list.table.header.action'], { ns: 'datasetDocuments' })}</td> + <td className="w-40"> + {t(($) => $['list.table.header.status'], { ns: 'datasetDocuments' })} + </td> + <td className="w-20"> + {t(($) => $['list.table.header.action'], { ns: 'datasetDocuments' })} + </td> </tr> </thead> <tbody className="text-text-secondary"> @@ -205,14 +217,34 @@ const DocumentList = ({ <BatchAction className="absolute bottom-16 left-0 z-20" selectedIds={selectedIds} - onArchive={datasetACLCapabilities.canEdit ? handleAction(DocumentActionType.archive) : undefined} - onBatchSummary={datasetACLCapabilities.canEdit ? handleAction(DocumentActionType.summary) : undefined} - onBatchEnable={datasetACLCapabilities.canEdit ? handleAction(DocumentActionType.enable) : undefined} - onBatchDisable={datasetACLCapabilities.canEdit ? handleAction(DocumentActionType.disable) : undefined} - onBatchDownload={datasetACLCapabilities.canDocumentDownload && downloadableSelectedIds.length > 0 ? handleBatchDownload : undefined} - onBatchDelete={datasetACLCapabilities.canDeleteFile ? handleAction(DocumentActionType.delete) : undefined} + onArchive={ + datasetACLCapabilities.canEdit ? handleAction(DocumentActionType.archive) : undefined + } + onBatchSummary={ + datasetACLCapabilities.canEdit ? handleAction(DocumentActionType.summary) : undefined + } + onBatchEnable={ + datasetACLCapabilities.canEdit ? handleAction(DocumentActionType.enable) : undefined + } + onBatchDisable={ + datasetACLCapabilities.canEdit ? handleAction(DocumentActionType.disable) : undefined + } + onBatchDownload={ + datasetACLCapabilities.canDocumentDownload && downloadableSelectedIds.length > 0 + ? handleBatchDownload + : undefined + } + onBatchDelete={ + datasetACLCapabilities.canDeleteFile + ? handleAction(DocumentActionType.delete) + : undefined + } onEditMetadata={datasetACLCapabilities.canEdit ? showEditModal : undefined} - onBatchReIndex={datasetACLCapabilities.canEdit && hasErrorDocumentsSelected ? handleBatchReIndex : undefined} + onBatchReIndex={ + datasetACLCapabilities.canEdit && hasErrorDocumentsSelected + ? handleBatchReIndex + : undefined + } onCancel={clearSelection} /> )} @@ -222,22 +254,25 @@ const DocumentList = ({ className="shrink-0" page={pagination.current + 1} totalPages={totalPages} - onPageChange={page => pagination.onChange(page - 1)} + onPageChange={(page) => pagination.onChange(page - 1)} labels={{ - previous: t($ => $['pagination.previous'], { ns: 'common' }), - next: t($ => $['pagination.next'], { ns: 'common' }), - editPageNumber: (page, totalPages) => t($ => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), - pageNumberInput: t($ => $['pagination.pageNumber'], { ns: 'common' }), + previous: t(($) => $['pagination.previous'], { ns: 'common' }), + next: t(($) => $['pagination.next'], { ns: 'common' }), + editPageNumber: (page, totalPages) => + t(($) => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), + pageNumberInput: t(($) => $['pagination.pageNumber'], { ns: 'common' }), }} - pageSize={pagination.onLimitChange - ? { - value: pageSize, - options: [10, 25, 50], - onValueChange: pagination.onLimitChange, - label: t($ => $['pagination.perPage'], { ns: 'common' }), - ariaLabel: t($ => $['pagination.perPage'], { ns: 'common' }), - } - : undefined} + pageSize={ + pagination.onLimitChange + ? { + value: pageSize, + options: [10, 25, 50], + onValueChange: pagination.onLimitChange, + label: t(($) => $['pagination.perPage'], { ns: 'common' }), + ariaLabel: t(($) => $['pagination.perPage'], { ns: 'common' }), + } + : undefined + } /> )} diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx index 73269f6f8e4a28..14dc1bf502e1bd 100644 --- a/web/app/components/datasets/documents/components/operations.tsx +++ b/web/app/components/datasets/documents/components/operations.tsx @@ -28,7 +28,19 @@ import Divider from '@/app/components/base/divider' import { IS_CE_EDITION } from '@/config' import { DataSourceType, DocumentActionType } from '@/models/datasets' import { useRouter } from '@/next/navigation' -import { useDocumentArchive, useDocumentDelete, useDocumentDisable, useDocumentDownload, useDocumentEnable, useDocumentPause, useDocumentResume, useDocumentSummary, useDocumentUnArchive, useSyncDocument, useSyncWebsite } from '@/service/knowledge/use-document' +import { + useDocumentArchive, + useDocumentDelete, + useDocumentDisable, + useDocumentDownload, + useDocumentEnable, + useDocumentPause, + useDocumentResume, + useDocumentSummary, + useDocumentUnArchive, + useSyncDocument, + useSyncWebsite, +} from '@/service/knowledge/use-document' import { asyncRunSafe } from '@/utils' import { downloadUrl } from '@/utils/download' import s from '../style.module.css' @@ -70,7 +82,14 @@ const Operations = ({ canDelete, canViewSettings, }: OperationsProps) => { - const { id, name, enabled = false, archived = false, data_source_type, display_status } = detail || {} + const { + id, + name, + enabled = false, + archived = false, + data_source_type, + display_status, + } = detail || {} const [showModal, setShowModal] = useState(false) const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false) const [deleting, setDeleting] = useState(false) @@ -92,116 +111,128 @@ const Operations = ({ const canShowSummaryAction = canEdit && !archived && IS_CE_EDITION const canShowSettingsAction = canViewSettings const canShowDownloadAction = canDownload && data_source_type === DataSourceType.FILE - const canShowSyncAction = canEdit && !archived && ['notion_import', DataSourceType.WEB].includes(data_source_type) + const canShowSyncAction = + canEdit && !archived && ['notion_import', DataSourceType.WEB].includes(data_source_type) const canShowPauseAction = canEdit && !archived && display_status?.toLowerCase() === 'indexing' const canShowResumeAction = canEdit && !archived && display_status?.toLowerCase() === 'paused' const canShowArchiveAction = canEdit && !archived const canShowUnarchiveAction = canEdit && archived const canShowDeleteAction = canDelete - const canShowPrimarySection = canShowRenameAction || canShowSummaryAction || canShowSettingsAction || canShowDownloadAction || canShowSyncAction - const canShowStatusSection = canShowPauseAction || canShowResumeAction || canShowArchiveAction || canShowUnarchiveAction - const hasOperationsMenu = embeddingAvailable && (canShowPrimarySection || canShowStatusSection || canShowDeleteAction) - const canOperate = useCallback((operationName: OperationName) => { - if (operationName === DocumentActionType.delete) - return canDelete + const canShowPrimarySection = + canShowRenameAction || + canShowSummaryAction || + canShowSettingsAction || + canShowDownloadAction || + canShowSyncAction + const canShowStatusSection = + canShowPauseAction || canShowResumeAction || canShowArchiveAction || canShowUnarchiveAction + const hasOperationsMenu = + embeddingAvailable && (canShowPrimarySection || canShowStatusSection || canShowDeleteAction) + const canOperate = useCallback( + (operationName: OperationName) => { + if (operationName === DocumentActionType.delete) return canDelete - return canEdit - }, [canDelete, canEdit]) - const onOperate = useCallback(async (operationName: OperationName) => { - if (!canOperate(operationName)) - return + return canEdit + }, + [canDelete, canEdit], + ) + const onOperate = useCallback( + async (operationName: OperationName) => { + if (!canOperate(operationName)) return - let opApi - switch (operationName) { - case 'archive': - opApi = archiveDocument - break - case 'un_archive': - opApi = unArchiveDocument - break - case 'enable': - opApi = enableDocument - break - case 'disable': - opApi = disableDocument - break - case 'sync': - if (data_source_type === 'notion_import') - opApi = syncDocument - else - opApi = syncWebsite - break - case 'summary': - opApi = generateSummary - break - case 'pause': - opApi = pauseDocument - break - case 'resume': - opApi = resumeDocument - break - default: - opApi = deleteDocument - setDeleting(true) - break - } - const [e] = await asyncRunSafe<CommonResponse>(opApi({ datasetId, documentId: id }) as Promise<CommonResponse>) - if (!e) { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - // If it is a delete operation, need to update the selectedIds state - if (selectedIds && onSelectedIdChange && operationName === DocumentActionType.delete) - onSelectedIdChange(selectedIds.filter(selectedId => selectedId !== id)) - onUpdate(operationName) - } - else { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - } - if (operationName === DocumentActionType.delete) - setDeleting(false) - }, [ - archiveDocument, - canOperate, - data_source_type, - datasetId, - deleteDocument, - disableDocument, - enableDocument, - generateSummary, - id, - onSelectedIdChange, - onUpdate, - pauseDocument, - resumeDocument, - selectedIds, - syncDocument, - syncWebsite, - t, - unArchiveDocument, - ]) - const { run: handleSwitch } = useDebounceFn((operationName: OperationName) => { - if (!canEdit) - return - if (operationName === DocumentActionType.enable && enabled) - return - if (operationName === DocumentActionType.disable && !enabled) - return - onOperate(operationName) - }, { wait: 500 }) + let opApi + switch (operationName) { + case 'archive': + opApi = archiveDocument + break + case 'un_archive': + opApi = unArchiveDocument + break + case 'enable': + opApi = enableDocument + break + case 'disable': + opApi = disableDocument + break + case 'sync': + if (data_source_type === 'notion_import') opApi = syncDocument + else opApi = syncWebsite + break + case 'summary': + opApi = generateSummary + break + case 'pause': + opApi = pauseDocument + break + case 'resume': + opApi = resumeDocument + break + default: + opApi = deleteDocument + setDeleting(true) + break + } + const [e] = await asyncRunSafe<CommonResponse>( + opApi({ datasetId, documentId: id }) as Promise<CommonResponse>, + ) + if (!e) { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + // If it is a delete operation, need to update the selectedIds state + if (selectedIds && onSelectedIdChange && operationName === DocumentActionType.delete) + onSelectedIdChange(selectedIds.filter((selectedId) => selectedId !== id)) + onUpdate(operationName) + } else { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } + if (operationName === DocumentActionType.delete) setDeleting(false) + }, + [ + archiveDocument, + canOperate, + data_source_type, + datasetId, + deleteDocument, + disableDocument, + enableDocument, + generateSummary, + id, + onSelectedIdChange, + onUpdate, + pauseDocument, + resumeDocument, + selectedIds, + syncDocument, + syncWebsite, + t, + unArchiveDocument, + ], + ) + const { run: handleSwitch } = useDebounceFn( + (operationName: OperationName) => { + if (!canEdit) return + if (operationName === DocumentActionType.enable && enabled) return + if (operationName === DocumentActionType.disable && !enabled) return + onOperate(operationName) + }, + { wait: 500 }, + ) const [currDocument, setCurrDocument] = useState<{ id: string name: string } | null>(null) - const [isShowRenameModal, { setTrue: setShowRenameModalTrue, setFalse: setShowRenameModalFalse }] = useBoolean(false) - const handleShowRenameModal = useCallback((doc: { - id: string - name: string - }) => { - if (!canEdit) - return + const [ + isShowRenameModal, + { setTrue: setShowRenameModalTrue, setFalse: setShowRenameModalFalse }, + ] = useBoolean(false) + const handleShowRenameModal = useCallback( + (doc: { id: string; name: string }) => { + if (!canEdit) return - setCurrDocument(doc) - setShowRenameModalTrue() - }, [canEdit, setShowRenameModalTrue]) + setCurrDocument(doc) + setShowRenameModalTrue() + }, + [canEdit, setShowRenameModalTrue], + ) const handleRenamed = useCallback(() => { onUpdate() }, [onUpdate]) @@ -212,15 +243,15 @@ const Operations = ({ e.stopPropagation() }, []) const handleDownload = useCallback(async () => { - if (!canDownload) - return + if (!canDownload) return // Avoid repeated clicks while the signed URL request is in-flight. - if (isDownloading) - return + if (isDownloading) return // Request a signed URL first (it points to `/files/<id>/file-preview?...&as_attachment=true`). - const [e, res] = await asyncRunSafe<DocumentDownloadResponse>(downloadDocument({ datasetId, documentId: id }) as Promise<DocumentDownloadResponse>) + const [e, res] = await asyncRunSafe<DocumentDownloadResponse>( + downloadDocument({ datasetId, documentId: id }) as Promise<DocumentDownloadResponse>, + ) if (e || !res?.url) { - toast.error(t($ => $['actionMsg.downloadUnsuccessfully'], { ns: 'common' })) + toast.error(t(($) => $['actionMsg.downloadUnsuccessfully'], { ns: 'common' })) return } // Trigger download without navigating away (helps avoid duplicate downloads in some browsers). @@ -233,66 +264,106 @@ const Operations = ({ name: detail.name, }) }, [closeOperationsMenu, detail.id, detail.name, handleShowRenameModal]) - const handleMenuOperation = useCallback((operationName: OperationName) => { - closeOperationsMenu() - void onOperate(operationName) - }, [closeOperationsMenu, onOperate]) + const handleMenuOperation = useCallback( + (operationName: OperationName) => { + closeOperationsMenu() + void onOperate(operationName) + }, + [closeOperationsMenu, onOperate], + ) const handleDeleteClick = useCallback(() => { - if (!canDelete) - return + if (!canDelete) return closeOperationsMenu() setShowModal(true) }, [canDelete, closeOperationsMenu]) - const handleDownloadClick = useCallback((evt: React.MouseEvent<HTMLElement>) => { - evt.preventDefault() - evt.stopPropagation() - evt.nativeEvent.stopImmediatePropagation?.() - if (!canDownload) - return + const handleDownloadClick = useCallback( + (evt: React.MouseEvent<HTMLElement>) => { + evt.preventDefault() + evt.stopPropagation() + evt.nativeEvent.stopImmediatePropagation?.() + if (!canDownload) return - closeOperationsMenu() - void handleDownload() - }, [canDownload, closeOperationsMenu, handleDownload]) + closeOperationsMenu() + void handleDownload() + }, + [canDownload, closeOperationsMenu, handleDownload], + ) const menuActionClassName = cn(s.actionItem, 'border-none bg-transparent') const menuDeleteActionClassName = cn(menuActionClassName, s.deleteActionItem, 'group') - const handleSettingsClick = useCallback((evt: React.MouseEvent<HTMLElement>) => { - evt.preventDefault() - evt.stopPropagation() - evt.nativeEvent.stopImmediatePropagation?.() - if (!canViewSettings) - return + const handleSettingsClick = useCallback( + (evt: React.MouseEvent<HTMLElement>) => { + evt.preventDefault() + evt.stopPropagation() + evt.nativeEvent.stopImmediatePropagation?.() + if (!canViewSettings) return - closeOperationsMenu() - router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`) - }, [canViewSettings, closeOperationsMenu, datasetId, detail.id, router]) + closeOperationsMenu() + router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`) + }, + [canViewSettings, closeOperationsMenu, datasetId, detail.id, router], + ) const settingsMenuItem = ( - <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleSettingsClick}> + <button + type="button" + className={cn(menuActionClassName, 'text-left')} + onClick={handleSettingsClick} + > <span aria-hidden className="i-ri-equalizer-2-line size-4 text-text-tertiary" /> - <span className={s.actionName}>{t($ => $['list.action.settings'], { ns: 'datasetDocuments' })}</span> + <span className={s.actionName}> + {t(($) => $['list.action.settings'], { ns: 'datasetDocuments' })} + </span> </button> ) const renderListSwitch = () => { if (!canEdit) - return <Switch checked={archived ? false : enabled} onCheckedChange={noop} disabled={true} size="md" /> + return ( + <Switch + checked={archived ? false : enabled} + onCheckedChange={noop} + disabled={true} + size="md" + /> + ) if (archived) { return ( <Popover> - <PopoverTrigger nativeButton={false} openOnHover render={<div><Switch checked={false} onCheckedChange={noop} disabled={true} size="md" /></div>} /> + <PopoverTrigger + nativeButton={false} + openOnHover + render={ + <div> + <Switch checked={false} onCheckedChange={noop} disabled={true} size="md" /> + </div> + } + /> <PopoverContent popupClassName="px-3 py-2 font-semibold system-xs-regular text-text-tertiary"> - {t($ => $['list.action.enableWarning'], { ns: 'datasetDocuments' })} + {t(($) => $['list.action.enableWarning'], { ns: 'datasetDocuments' })} </PopoverContent> </Popover> ) } - return <Switch checked={enabled} onCheckedChange={v => handleSwitch(v ? 'enable' : 'disable')} size="md" /> + return ( + <Switch + checked={enabled} + onCheckedChange={(v) => handleSwitch(v ? 'enable' : 'disable')} + size="md" + /> + ) } return ( - <div className="flex items-center" role="presentation" onClick={stopPropagation} onKeyDown={stopPropagation}> - {isListScene && !embeddingAvailable && (<Switch checked={false} onCheckedChange={noop} disabled={true} size="md" />)} + <div + className="flex items-center" + role="presentation" + onClick={stopPropagation} + onKeyDown={stopPropagation} + > + {isListScene && !embeddingAvailable && ( + <Switch checked={false} onCheckedChange={noop} disabled={true} size="md" /> + )} {isListScene && embeddingAvailable && ( <> {renderListSwitch()} @@ -303,7 +374,7 @@ const Operations = ({ <> <DropdownMenu open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}> <DropdownMenuTrigger - aria-label={t($ => $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.more'], { ns: 'common' })} className={cn( isListScene ? s.actionIconWrapperList : s.actionIconWrapperDetail, 'inline-flex items-center justify-center', @@ -317,7 +388,10 @@ const Operations = ({ }} > <div className={cn(s.commonIcon)}> - <span aria-hidden className="i-ri-more-fill size-4 text-components-button-secondary-text" /> + <span + aria-hidden + className="i-ri-more-fill size-4 text-components-button-secondary-text" + /> </div> </DropdownMenuTrigger> <DropdownMenuContent @@ -329,61 +403,130 @@ const Operations = ({ {canShowPrimarySection && ( <> {canShowRenameAction && ( - <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleShowRename}> + <button + type="button" + className={cn(menuActionClassName, 'text-left')} + onClick={handleShowRename} + > <span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" /> - <span className={s.actionName}>{t($ => $['list.table.rename'], { ns: 'datasetDocuments' })}</span> + <span className={s.actionName}> + {t(($) => $['list.table.rename'], { ns: 'datasetDocuments' })} + </span> </button> )} {canShowSummaryAction && ( - <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('summary')}> - <span aria-hidden className="i-custom-vender-knowledge-search-lines-sparkle size-4 text-text-tertiary" /> - <span className={s.actionName}>{t($ => $['list.action.summary'], { ns: 'datasetDocuments' })}</span> + <button + type="button" + className={cn(menuActionClassName, 'text-left')} + onClick={() => handleMenuOperation('summary')} + > + <span + aria-hidden + className="i-custom-vender-knowledge-search-lines-sparkle size-4 text-text-tertiary" + /> + <span className={s.actionName}> + {t(($) => $['list.action.summary'], { ns: 'datasetDocuments' })} + </span> </button> )} {canShowSettingsAction && settingsMenuItem} {canShowDownloadAction && ( - <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleDownloadClick}> - <span aria-hidden className="i-ri-download-2-line size-4 text-text-tertiary" /> - <span className={s.actionName}>{t($ => $['list.action.download'], { ns: 'datasetDocuments' })}</span> + <button + type="button" + className={cn(menuActionClassName, 'text-left')} + onClick={handleDownloadClick} + > + <span + aria-hidden + className="i-ri-download-2-line size-4 text-text-tertiary" + /> + <span className={s.actionName}> + {t(($) => $['list.action.download'], { ns: 'datasetDocuments' })} + </span> </button> )} {canShowSyncAction && ( - <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('sync')}> - <span aria-hidden className="i-ri-loop-left-line size-4 text-text-tertiary" /> - <span className={s.actionName}>{t($ => $['list.action.sync'], { ns: 'datasetDocuments' })}</span> + <button + type="button" + className={cn(menuActionClassName, 'text-left')} + onClick={() => handleMenuOperation('sync')} + > + <span + aria-hidden + className="i-ri-loop-left-line size-4 text-text-tertiary" + /> + <span className={s.actionName}> + {t(($) => $['list.action.sync'], { ns: 'datasetDocuments' })} + </span> </button> )} {(canShowStatusSection || canShowDeleteAction) && <Divider className="my-1" />} </> )} {canShowPauseAction && ( - <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('pause')}> - <span aria-hidden className="i-ri-pause-circle-line size-4 text-text-tertiary" /> - <span className={s.actionName}>{t($ => $['list.action.pause'], { ns: 'datasetDocuments' })}</span> + <button + type="button" + className={cn(menuActionClassName, 'text-left')} + onClick={() => handleMenuOperation('pause')} + > + <span + aria-hidden + className="i-ri-pause-circle-line size-4 text-text-tertiary" + /> + <span className={s.actionName}> + {t(($) => $['list.action.pause'], { ns: 'datasetDocuments' })} + </span> </button> )} {canShowResumeAction && ( - <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('resume')}> + <button + type="button" + className={cn(menuActionClassName, 'text-left')} + onClick={() => handleMenuOperation('resume')} + > <span aria-hidden className="i-ri-play-circle-line size-4 text-text-tertiary" /> - <span className={s.actionName}>{t($ => $['list.action.resume'], { ns: 'datasetDocuments' })}</span> + <span className={s.actionName}> + {t(($) => $['list.action.resume'], { ns: 'datasetDocuments' })} + </span> </button> )} {canShowArchiveAction && ( - <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('archive')}> + <button + type="button" + className={cn(menuActionClassName, 'text-left')} + onClick={() => handleMenuOperation('archive')} + > <span aria-hidden className="i-ri-archive-2-line size-4 text-text-tertiary" /> - <span className={s.actionName}>{t($ => $['list.action.archive'], { ns: 'datasetDocuments' })}</span> + <span className={s.actionName}> + {t(($) => $['list.action.archive'], { ns: 'datasetDocuments' })} + </span> </button> )} {canShowUnarchiveAction && ( - <button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('un_archive')}> + <button + type="button" + className={cn(menuActionClassName, 'text-left')} + onClick={() => handleMenuOperation('un_archive')} + > <span aria-hidden className="i-ri-archive-2-line size-4 text-text-tertiary" /> - <span className={s.actionName}>{t($ => $['list.action.unarchive'], { ns: 'datasetDocuments' })}</span> + <span className={s.actionName}> + {t(($) => $['list.action.unarchive'], { ns: 'datasetDocuments' })} + </span> </button> )} {canShowDeleteAction && ( - <button type="button" className={cn(menuDeleteActionClassName, 'text-left')} onClick={handleDeleteClick}> - <span aria-hidden className="i-ri-delete-bin-line size-4 text-text-tertiary group-hover:text-text-destructive" /> - <span className={cn(s.actionName, 'group-hover:text-text-destructive')}>{t($ => $['list.action.delete'], { ns: 'datasetDocuments' })}</span> + <button + type="button" + className={cn(menuDeleteActionClassName, 'text-left')} + onClick={handleDeleteClick} + > + <span + aria-hidden + className="i-ri-delete-bin-line size-4 text-text-tertiary group-hover:text-text-destructive" + /> + <span className={cn(s.actionName, 'group-hover:text-text-destructive')}> + {t(($) => $['list.action.delete'], { ns: 'datasetDocuments' })} + </span> </button> )} </div> @@ -391,26 +534,40 @@ const Operations = ({ </DropdownMenu> </> )} - <AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}> + <AlertDialog open={showModal} onOpenChange={(open) => !open && setShowModal(false)}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['list.delete.title'], { ns: 'datasetDocuments' })} + {t(($) => $['list.delete.title'], { ns: 'datasetDocuments' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['list.delete.content'], { ns: 'datasetDocuments' })} + {t(($) => $['list.delete.content'], { ns: 'datasetDocuments' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={() => onOperate('delete')}> - {t($ => $['operation.sure'], { ns: 'common' })} + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton + loading={deleting} + disabled={deleting} + onClick={() => onOperate('delete')} + > + {t(($) => $['operation.sure'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> </AlertDialog> - {isShowRenameModal && currDocument && (<RenameModal datasetId={datasetId} documentId={currDocument.id} name={currDocument.name} onClose={setShowRenameModalFalse} onSaved={handleRenamed} />)} + {isShowRenameModal && currDocument && ( + <RenameModal + datasetId={datasetId} + documentId={currDocument.id} + name={currDocument.name} + onClose={setShowRenameModalFalse} + onSaved={handleRenamed} + /> + )} </div> ) } diff --git a/web/app/components/datasets/documents/components/rename-modal.tsx b/web/app/components/datasets/documents/components/rename-modal.tsx index a5893b0609ed92..a3e893fd74b5ee 100644 --- a/web/app/components/datasets/documents/components/rename-modal.tsx +++ b/web/app/components/datasets/documents/components/rename-modal.tsx @@ -18,20 +18,12 @@ type Props = Readonly<{ onSaved: () => void }> -const RenameModal: FC<Props> = ({ - documentId, - datasetId, - name, - onClose, - onSaved, -}) => { +const RenameModal: FC<Props> = ({ documentId, datasetId, name, onClose, onSaved }) => { const { t } = useTranslation() const [newName, setNewName] = useState(name) - const [saveLoading, { - setTrue: setSaveLoadingTrue, - setFalse: setSaveLoadingFalse, - }] = useBoolean(false) + const [saveLoading, { setTrue: setSaveLoadingTrue, setFalse: setSaveLoadingFalse }] = + useBoolean(false) const handleSave = async () => { setSaveLoadingTrue() @@ -41,15 +33,12 @@ const RenameModal: FC<Props> = ({ documentId, name: newName, }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) onSaved() onClose() - } - catch (error) { - if (error) - toast.error(error.toString()) - } - finally { + } catch (error) { + if (error) toast.error(error.toString()) + } finally { setSaveLoadingFalse() } } @@ -58,25 +47,26 @@ const RenameModal: FC<Props> = ({ <Dialog open onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DialogContent className="overflow-hidden! border-none text-left align-middle"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['list.table.rename'], { ns: 'datasetDocuments' })} + {t(($) => $['list.table.rename'], { ns: 'datasetDocuments' })} </DialogTitle> - <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t($ => $['list.table.name'], { ns: 'datasetDocuments' })}</div> - <Input - className="mt-2 h-10" - value={newName} - onChange={e => setNewName(e.target.value)} - /> + <div className="mt-6 text-sm leading-[21px] font-medium text-text-primary"> + {t(($) => $['list.table.name'], { ns: 'datasetDocuments' })} + </div> + <Input className="mt-2 h-10" value={newName} onChange={(e) => setNewName(e.target.value)} /> <div className="mt-10 flex justify-end"> - <Button className="mr-2 shrink-0" onClick={onClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" className="shrink-0" onClick={handleSave} loading={saveLoading}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button className="mr-2 shrink-0" onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button variant="primary" className="shrink-0" onClick={handleSave} loading={saveLoading}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> </DialogContent> </Dialog> diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx index ca0a3bbb43d1b5..30d87799d7b3e3 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx @@ -42,8 +42,9 @@ let mockDatasetPermissionKeys = ['dataset.acl.use'] const mockRouterReplace = vi.fn() vi.mock('@/context/provider-context', () => ({ - useProviderContextSelector: (selector: (state: { plan: typeof mockPlan, enableBilling: boolean }) => unknown) => - selector({ plan: mockPlan, enableBilling: true }), + useProviderContextSelector: ( + selector: (state: { plan: typeof mockPlan; enableBilling: boolean }) => unknown, + ) => selector({ plan: mockPlan, enableBilling: true }), })) let mockCurrentUserId = 'user-1' @@ -51,7 +52,8 @@ let mockWorkspacePermissionKeys = ['dataset.create_and_management'] let mockIsLoadingWorkspacePermissionKeys = false vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: mockCurrentUserId }, @@ -60,7 +62,8 @@ vi.mock('@/context/account-state', async (importOriginal) => { })) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: mockCurrentUserId }, @@ -69,7 +72,8 @@ vi.mock('@/context/workspace-state', async (importOriginal) => { })) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: mockCurrentUserId }, @@ -78,7 +82,8 @@ vi.mock('@/context/permission-state', async (importOriginal) => { })) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: mockCurrentUserId }, @@ -87,7 +92,8 @@ vi.mock('@/context/version-state', async (importOriginal) => { })) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: mockCurrentUserId }, @@ -107,14 +113,25 @@ vi.mock('@/service/use-billing', () => ({ })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: { id: string, pipeline_id: string, permission_keys: string[] } }) => unknown) => - selector({ dataset: { id: 'test-dataset-id', pipeline_id: 'test-pipeline-id', permission_keys: mockDatasetPermissionKeys } }), + useDatasetDetailContextWithSelector: ( + selector: (state: { + dataset: { id: string; pipeline_id: string; permission_keys: string[] } + }) => unknown, + ) => + selector({ + dataset: { + id: 'test-dataset-id', + pipeline_id: 'test-pipeline-id', + permission_keys: mockDatasetPermissionKeys, + }, + }), })) // Mock API services @@ -172,7 +189,7 @@ vi.mock('@/next/navigation', () => ({ // Mock next/link vi.mock('@/next/link', () => ({ - default: ({ children, href }: { children: React.ReactNode, href: string }) => ( + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( <a href={href}>{children}</a> ), })) @@ -194,7 +211,7 @@ const mockStoreState = { localFileList: [] as FileItem[], currentLocalFile: undefined as CustomFile | undefined, setCurrentLocalFile: vi.fn(), - documentsData: [] as { workspace_id: string, pages: { page_id: string }[] }[], + documentsData: [] as { workspace_id: string; pages: { page_id: string }[] }[], onlineDocuments: [] as (NotionPage & { workspace_id: string })[], currentDocument: undefined as (NotionPage & { workspace_id: string }) | undefined, setDocumentsData: vi.fn(), @@ -230,7 +247,8 @@ vi.mock('../data-source/store', () => ({ useDataSourceStore: () => ({ getState: () => mockStoreState, }), - useDataSourceStoreWithSelector: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), + useDataSourceStoreWithSelector: (selector: (state: typeof mockStoreState) => unknown) => + selector(mockStoreState), })) vi.mock('../data-source/store/provider', () => ({ @@ -253,45 +271,52 @@ const createMockDatasource = (overrides?: Partial<Datasource>): Datasource => ({ ...overrides, }) -const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => ({ - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 1024, - extension: '.txt', - mime_type: 'text/plain', - ...overrides, -} as CustomFile) - -const createMockFileItem = (overrides?: Partial<FileItem>): FileItem => ({ - file: createMockFile(), - progress: 100, - ...overrides, -} as FileItem) - -const createMockNotionPage = (overrides?: Partial<NotionPage & { workspace_id: string }>): NotionPage & { workspace_id: string } => ({ - page_id: 'page-1', - page_name: 'Test Page', - page_icon: null, - type: 'page', - workspace_id: 'workspace-1', - ...overrides, -} as NotionPage & { workspace_id: string }) - -const createMockCrawlResult = (overrides?: Partial<CrawlResultItem>): CrawlResultItem => ({ - source_url: 'https://example.com', - title: 'Test Page', - markdown: '# Test', - description: 'A test page', - ...overrides, -} as CrawlResultItem) - -const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => ({ - id: 'drive-file-1', - name: 'test-file.pdf', - type: 'file', - ...overrides, -} as OnlineDriveFile) +const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => + ({ + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 1024, + extension: '.txt', + mime_type: 'text/plain', + ...overrides, + }) as CustomFile + +const createMockFileItem = (overrides?: Partial<FileItem>): FileItem => + ({ + file: createMockFile(), + progress: 100, + ...overrides, + }) as FileItem + +const createMockNotionPage = ( + overrides?: Partial<NotionPage & { workspace_id: string }>, +): NotionPage & { workspace_id: string } => + ({ + page_id: 'page-1', + page_name: 'Test Page', + page_icon: null, + type: 'page', + workspace_id: 'workspace-1', + ...overrides, + }) as NotionPage & { workspace_id: string } + +const createMockCrawlResult = (overrides?: Partial<CrawlResultItem>): CrawlResultItem => + ({ + source_url: 'https://example.com', + title: 'Test Page', + markdown: '# Test', + description: 'A test page', + ...overrides, + }) as CrawlResultItem + +const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => + ({ + id: 'drive-file-1', + name: 'test-file.pdf', + type: 'file', + ...overrides, + }) as OnlineDriveFile beforeEach(() => { mockDatasetPermissionKeys = ['dataset.acl.use'] @@ -384,159 +409,187 @@ describe('useDatasourceUIState', () => { }) it('should return true for localFile when no files are loaded', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource(), - allFileLoaded: false, - localFileListLength: 0, - })) + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource(), + allFileLoaded: false, + localFileListLength: 0, + }), + ) expect(result.current.nextBtnDisabled).toBe(true) }) it('should return false for localFile when files are loaded', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource(), - allFileLoaded: true, - localFileListLength: 1, - })) + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource(), + allFileLoaded: true, + localFileListLength: 1, + }), + ) expect(result.current.nextBtnDisabled).toBe(false) }) it('should return true for onlineDocument when no documents are selected', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource({ - nodeData: { - ...createMockDatasource().nodeData, - provider_type: DatasourceType.onlineDocument, - }, + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource({ + nodeData: { + ...createMockDatasource().nodeData, + provider_type: DatasourceType.onlineDocument, + }, + }), + onlineDocumentsLength: 0, }), - onlineDocumentsLength: 0, - })) + ) expect(result.current.nextBtnDisabled).toBe(true) }) it('should return false for onlineDocument when documents are selected', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource({ - nodeData: { - ...createMockDatasource().nodeData, - provider_type: DatasourceType.onlineDocument, - }, + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource({ + nodeData: { + ...createMockDatasource().nodeData, + provider_type: DatasourceType.onlineDocument, + }, + }), + onlineDocumentsLength: 1, }), - onlineDocumentsLength: 1, - })) + ) expect(result.current.nextBtnDisabled).toBe(false) }) }) describe('isShowVectorSpaceFull', () => { it('should return false when vector space is not full', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource(), - allFileLoaded: true, - isVectorSpaceFull: false, - })) + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource(), + allFileLoaded: true, + isVectorSpaceFull: false, + }), + ) expect(result.current.isShowVectorSpaceFull).toBe(false) }) it('should return true when vector space is full and billing is enabled', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource(), - allFileLoaded: true, - isVectorSpaceFull: true, - enableBilling: true, - })) + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource(), + allFileLoaded: true, + isVectorSpaceFull: true, + enableBilling: true, + }), + ) expect(result.current.isShowVectorSpaceFull).toBe(true) }) it('should return false when vector space is full but billing is disabled', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource(), - allFileLoaded: true, - isVectorSpaceFull: true, - enableBilling: false, - })) + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource(), + allFileLoaded: true, + isVectorSpaceFull: true, + enableBilling: false, + }), + ) expect(result.current.isShowVectorSpaceFull).toBe(false) }) }) describe('showSelect', () => { it('should return false for localFile datasource', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource(), - })) + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource(), + }), + ) expect(result.current.showSelect).toBe(false) }) it('should return true for onlineDocument when pages exist', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource({ - nodeData: { - ...createMockDatasource().nodeData, - provider_type: DatasourceType.onlineDocument, - }, + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource({ + nodeData: { + ...createMockDatasource().nodeData, + provider_type: DatasourceType.onlineDocument, + }, + }), + currentWorkspacePagesLength: 5, }), - currentWorkspacePagesLength: 5, - })) + ) expect(result.current.showSelect).toBe(true) }) it('should return true for onlineDrive when non-bucket files exist', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource({ - nodeData: { - ...createMockDatasource().nodeData, - provider_type: DatasourceType.onlineDrive, - }, + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource({ + nodeData: { + ...createMockDatasource().nodeData, + provider_type: DatasourceType.onlineDrive, + }, + }), + onlineDriveFileList: [createMockOnlineDriveFile()], }), - onlineDriveFileList: [createMockOnlineDriveFile()], - })) + ) expect(result.current.showSelect).toBe(true) }) it('should return false for onlineDrive when only buckets exist', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource({ - nodeData: { - ...createMockDatasource().nodeData, - provider_type: DatasourceType.onlineDrive, - }, + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource({ + nodeData: { + ...createMockDatasource().nodeData, + provider_type: DatasourceType.onlineDrive, + }, + }), + onlineDriveFileList: [ + createMockOnlineDriveFile({ type: 'bucket' as OnlineDriveFile['type'] }), + ], }), - onlineDriveFileList: [createMockOnlineDriveFile({ type: 'bucket' as OnlineDriveFile['type'] })], - })) + ) expect(result.current.showSelect).toBe(false) }) }) describe('tip', () => { it('should return empty string for localFile', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource(), - })) + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource(), + }), + ) expect(result.current.tip).toBe('') }) it('should return translation key for onlineDocument', () => { - const { result } = renderHook(() => useDatasourceUIState({ - ...defaultParams, - datasource: createMockDatasource({ - nodeData: { - ...createMockDatasource().nodeData, - provider_type: DatasourceType.onlineDocument, - }, + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: createMockDatasource({ + nodeData: { + ...createMockDatasource().nodeData, + provider_type: DatasourceType.onlineDocument, + }, + }), }), - })) + ) expect(result.current.tip).toContain('datasetPipeline.addDocuments.selectOnlineDocumentTip') }) }) @@ -703,27 +756,45 @@ describe('StepOneContent', () => { describe('StepTwoContent', () => { // Mock ProcessDocuments since it has complex dependencies vi.mock('../process-documents', () => ({ - default: React.forwardRef(({ dataSourceNodeId, isRunning, onProcess, onPreview, onSubmit, onBack }: { - dataSourceNodeId: string - isRunning: boolean - onProcess: () => void - onPreview: () => void - onSubmit: (data: Record<string, unknown>) => void - onBack: () => void - }, ref: React.Ref<{ submit: () => void }>) => { - React.useImperativeHandle(ref, () => ({ - submit: () => onSubmit({ test: 'data' }), - })) - return ( - <div data-testid="process-documents"> - <span data-testid="datasource-node-id">{dataSourceNodeId}</span> - <span data-testid="is-running">{isRunning.toString()}</span> - <button data-testid="process-btn" onClick={onProcess}>Process</button> - <button data-testid="preview-btn" onClick={onPreview}>Preview</button> - <button data-testid="back-btn" onClick={onBack}>Back</button> - </div> - ) - }), + default: React.forwardRef( + ( + { + dataSourceNodeId, + isRunning, + onProcess, + onPreview, + onSubmit, + onBack, + }: { + dataSourceNodeId: string + isRunning: boolean + onProcess: () => void + onPreview: () => void + onSubmit: (data: Record<string, unknown>) => void + onBack: () => void + }, + ref: React.Ref<{ submit: () => void }>, + ) => { + React.useImperativeHandle(ref, () => ({ + submit: () => onSubmit({ test: 'data' }), + })) + return ( + <div data-testid="process-documents"> + <span data-testid="datasource-node-id">{dataSourceNodeId}</span> + <span data-testid="is-running">{isRunning.toString()}</span> + <button data-testid="process-btn" onClick={onProcess}> + Process + </button> + <button data-testid="preview-btn" onClick={onPreview}> + Preview + </button> + <button data-testid="back-btn" onClick={onBack}> + Back + </button> + </div> + ) + }, + ), })) const defaultProps = { @@ -777,7 +848,7 @@ describe('StepTwoContent', () => { describe('StepThreeContent', () => { // Mock Processing since it has complex dependencies vi.mock('../processing', () => ({ - default: ({ batchId, documents }: { batchId: string, documents: unknown[] }) => ( + default: ({ batchId, documents }: { batchId: string; documents: unknown[] }) => ( <div data-testid="processing"> <span data-testid="batch-id">{batchId}</span> <span data-testid="documents-count">{documents.length}</span> @@ -797,7 +868,9 @@ describe('StepThreeContent', () => { it('should pass documents count to Processing', () => { const documents = [{ id: '1' }, { id: '2' }] - render(<StepThreeContent batchId="batch-123" documents={documents as InitialDocumentDetail[]} />) + render( + <StepThreeContent batchId="batch-123" documents={documents as InitialDocumentDetail[]} />, + ) expect(screen.getByTestId('documents-count'))!.toHaveTextContent('2') }) }) @@ -806,16 +879,22 @@ describe('StepThreeContent', () => { describe('StepOnePreview', () => { // Mock preview components vi.mock('../preview/file-preview', () => ({ - default: ({ file, hidePreview }: { file: CustomFile, hidePreview: () => void }) => ( + default: ({ file, hidePreview }: { file: CustomFile; hidePreview: () => void }) => ( <div data-testid="file-preview"> <span data-testid="file-name">{file.name}</span> - <button data-testid="hide-preview" onClick={hidePreview}>Hide</button> + <button data-testid="hide-preview" onClick={hidePreview}> + Hide + </button> </div> ), })) vi.mock('../preview/online-document-preview', () => ({ - default: ({ datasourceNodeId, currentPage, hidePreview }: { + default: ({ + datasourceNodeId, + currentPage, + hidePreview, + }: { datasourceNodeId: string currentPage: NotionPage & { workspace_id: string } hidePreview: () => void @@ -823,16 +902,26 @@ describe('StepOnePreview', () => { <div data-testid="online-document-preview"> <span data-testid="node-id">{datasourceNodeId}</span> <span data-testid="page-id">{currentPage.page_id}</span> - <button data-testid="hide-preview" onClick={hidePreview}>Hide</button> + <button data-testid="hide-preview" onClick={hidePreview}> + Hide + </button> </div> ), })) vi.mock('../preview/web-preview', () => ({ - default: ({ currentWebsite, hidePreview }: { currentWebsite: CrawlResultItem, hidePreview: () => void }) => ( + default: ({ + currentWebsite, + hidePreview, + }: { + currentWebsite: CrawlResultItem + hidePreview: () => void + }) => ( <div data-testid="web-preview"> <span data-testid="url">{currentWebsite.source_url}</span> - <button data-testid="hide-preview" onClick={hidePreview}>Hide</button> + <button data-testid="hide-preview" onClick={hidePreview}> + Hide + </button> </div> ), })) @@ -854,17 +943,14 @@ describe('StepOnePreview', () => { it('should not render any preview when no file is selected', () => { const { container } = render(<StepOnePreview {...defaultProps} />) expect(container.querySelector('[data-testid="file-preview"]')).not.toBeInTheDocument() - expect(container.querySelector('[data-testid="online-document-preview"]')).not.toBeInTheDocument() + expect( + container.querySelector('[data-testid="online-document-preview"]'), + ).not.toBeInTheDocument() expect(container.querySelector('[data-testid="web-preview"]')).not.toBeInTheDocument() }) it('should render FilePreview when currentLocalFile is set', () => { - render( - <StepOnePreview - {...defaultProps} - currentLocalFile={createMockFile()} - />, - ) + render(<StepOnePreview {...defaultProps} currentLocalFile={createMockFile()} />) expect(screen.getByTestId('file-preview'))!.toBeInTheDocument() expect(screen.getByTestId('file-name'))!.toHaveTextContent('test.txt') }) @@ -881,12 +967,7 @@ describe('StepOnePreview', () => { }) it('should render WebsitePreview when currentWebsite is set', () => { - render( - <StepOnePreview - {...defaultProps} - currentWebsite={createMockCrawlResult()} - />, - ) + render(<StepOnePreview {...defaultProps} currentWebsite={createMockCrawlResult()} />) expect(screen.getByTestId('web-preview'))!.toBeInTheDocument() }) @@ -909,7 +990,12 @@ describe('StepOnePreview', () => { describe('StepTwoPreview', () => { // Mock ChunkPreview vi.mock('../preview/chunk-preview', () => ({ - default: ({ dataSourceType, isIdle, isPending, onPreview }: { + default: ({ + dataSourceType, + isIdle, + isPending, + onPreview, + }: { dataSourceType: string isIdle: boolean isPending: boolean @@ -919,7 +1005,9 @@ describe('StepTwoPreview', () => { <span data-testid="datasource-type">{dataSourceType}</span> <span data-testid="is-idle">{isIdle.toString()}</span> <span data-testid="is-pending">{isPending.toString()}</span> - <button data-testid="preview-btn" onClick={onPreview}>Preview</button> + <button data-testid="preview-btn" onClick={onPreview}> + Preview + </button> </div> ), })) @@ -977,19 +1065,21 @@ describe('StepTwoPreview', () => { describe('Edge Cases', () => { describe('Empty States', () => { it('should handle undefined datasource in useDatasourceUIState', () => { - const { result } = renderHook(() => useDatasourceUIState({ - datasource: undefined, - allFileLoaded: false, - localFileListLength: 0, - onlineDocumentsLength: 0, - websitePagesLength: 0, - selectedFileIdsLength: 0, - onlineDriveFileList: [], - isVectorSpaceFull: false, - enableBilling: true, - currentWorkspacePagesLength: 0, - fileUploadConfig: { file_size_limit: 15, batch_count_limit: 5 }, - })) + const { result } = renderHook(() => + useDatasourceUIState({ + datasource: undefined, + allFileLoaded: false, + localFileListLength: 0, + onlineDocumentsLength: 0, + websitePagesLength: 0, + selectedFileIdsLength: 0, + onlineDriveFileList: [], + isVectorSpaceFull: false, + enableBilling: true, + currentWorkspacePagesLength: 0, + fileUploadConfig: { file_size_limit: 15, batch_count_limit: 5 }, + }), + ) expect(result.current.datasourceType).toBeUndefined() expect(result.current.nextBtnDisabled).toBe(true) @@ -998,42 +1088,46 @@ describe('Edge Cases', () => { describe('Boundary Conditions', () => { it('should handle zero file size limit', () => { - const { result } = renderHook(() => useDatasourceUIState({ - datasource: createMockDatasource({ - nodeData: { - ...createMockDatasource().nodeData, - provider_type: DatasourceType.onlineDrive, - }, + const { result } = renderHook(() => + useDatasourceUIState({ + datasource: createMockDatasource({ + nodeData: { + ...createMockDatasource().nodeData, + provider_type: DatasourceType.onlineDrive, + }, + }), + allFileLoaded: false, + localFileListLength: 0, + onlineDocumentsLength: 0, + websitePagesLength: 0, + selectedFileIdsLength: 0, + onlineDriveFileList: [], + isVectorSpaceFull: false, + enableBilling: true, + currentWorkspacePagesLength: 0, + fileUploadConfig: { file_size_limit: 0, batch_count_limit: 0 }, }), - allFileLoaded: false, - localFileListLength: 0, - onlineDocumentsLength: 0, - websitePagesLength: 0, - selectedFileIdsLength: 0, - onlineDriveFileList: [], - isVectorSpaceFull: false, - enableBilling: true, - currentWorkspacePagesLength: 0, - fileUploadConfig: { file_size_limit: 0, batch_count_limit: 0 }, - })) + ) expect(result.current.tip).toContain('datasetPipeline.addDocuments.selectOnlineDriveTip') }) it('should handle very large file counts', () => { - const { result } = renderHook(() => useDatasourceUIState({ - datasource: createMockDatasource(), - allFileLoaded: true, - localFileListLength: 10000, - onlineDocumentsLength: 0, - websitePagesLength: 0, - selectedFileIdsLength: 0, - onlineDriveFileList: [], - isVectorSpaceFull: false, - enableBilling: true, - currentWorkspacePagesLength: 0, - fileUploadConfig: { file_size_limit: 15, batch_count_limit: 5 }, - })) + const { result } = renderHook(() => + useDatasourceUIState({ + datasource: createMockDatasource(), + allFileLoaded: true, + localFileListLength: 10000, + onlineDocumentsLength: 0, + websitePagesLength: 0, + selectedFileIdsLength: 0, + onlineDriveFileList: [], + isVectorSpaceFull: false, + enableBilling: true, + currentWorkspacePagesLength: 0, + fileUploadConfig: { file_size_limit: 15, batch_count_limit: 5 }, + }), + ) expect(result.current.nextBtnDisabled).toBe(false) }) @@ -1138,10 +1232,12 @@ describe('Store Hooks', () => { }) it('should compute PagesMapAndSelectedPagesId correctly', () => { - mockStoreState.documentsData = [{ - workspace_id: 'ws-1', - pages: [{ page_id: 'page-1' }], - }] + mockStoreState.documentsData = [ + { + workspace_id: 'ws-1', + pages: [{ page_id: 'page-1' }], + }, + ] const { result } = renderHook(() => useOnlineDocument()) expect(result.current.PagesMapAndSelectedPagesId['page-1']).toBeDefined() }) @@ -1186,24 +1282,27 @@ describe('All Datasource Types', () => { describe.each(datasourceTypes)('$name datasource type', ({ type }) => { it(`should handle ${type} in useDatasourceUIState`, () => { - const { result } = renderHook(() => useDatasourceUIState({ - datasource: createMockDatasource({ - nodeData: { - ...createMockDatasource().nodeData, - provider_type: type, - }, + const { result } = renderHook(() => + useDatasourceUIState({ + datasource: createMockDatasource({ + nodeData: { + ...createMockDatasource().nodeData, + provider_type: type, + }, + }), + allFileLoaded: type === DatasourceType.localFile, + localFileListLength: type === DatasourceType.localFile ? 1 : 0, + onlineDocumentsLength: type === DatasourceType.onlineDocument ? 1 : 0, + websitePagesLength: type === DatasourceType.websiteCrawl ? 1 : 0, + selectedFileIdsLength: type === DatasourceType.onlineDrive ? 1 : 0, + onlineDriveFileList: + type === DatasourceType.onlineDrive ? [createMockOnlineDriveFile()] : [], + isVectorSpaceFull: false, + enableBilling: true, + currentWorkspacePagesLength: type === DatasourceType.onlineDocument ? 1 : 0, + fileUploadConfig: { file_size_limit: 15, batch_count_limit: 5 }, }), - allFileLoaded: type === DatasourceType.localFile, - localFileListLength: type === DatasourceType.localFile ? 1 : 0, - onlineDocumentsLength: type === DatasourceType.onlineDocument ? 1 : 0, - websitePagesLength: type === DatasourceType.websiteCrawl ? 1 : 0, - selectedFileIdsLength: type === DatasourceType.onlineDrive ? 1 : 0, - onlineDriveFileList: type === DatasourceType.onlineDrive ? [createMockOnlineDriveFile()] : [], - isVectorSpaceFull: false, - enableBilling: true, - currentWorkspacePagesLength: type === DatasourceType.onlineDocument ? 1 : 0, - fileUploadConfig: { file_size_limit: 15, batch_count_limit: 5 }, - })) + ) expect(result.current.datasourceType).toBe(type) expect(result.current.nextBtnDisabled).toBe(false) @@ -1782,10 +1881,21 @@ describe('useDatasourceActions - Async Functions', () => { const createMockDataSourceStoreForAsync = (datasourceType: string) => ({ getState: () => ({ - previewLocalFileRef: { current: datasourceType === DatasourceType.localFile ? createMockFile() : undefined }, - previewOnlineDocumentRef: { current: datasourceType === DatasourceType.onlineDocument ? createMockNotionPage() : undefined }, - previewWebsitePageRef: { current: datasourceType === DatasourceType.websiteCrawl ? createMockCrawlResult() : undefined }, - previewOnlineDriveFileRef: { current: datasourceType === DatasourceType.onlineDrive ? createMockOnlineDriveFile() : undefined }, + previewLocalFileRef: { + current: datasourceType === DatasourceType.localFile ? createMockFile() : undefined, + }, + previewOnlineDocumentRef: { + current: + datasourceType === DatasourceType.onlineDocument ? createMockNotionPage() : undefined, + }, + previewWebsitePageRef: { + current: + datasourceType === DatasourceType.websiteCrawl ? createMockCrawlResult() : undefined, + }, + previewOnlineDriveFileRef: { + current: + datasourceType === DatasourceType.onlineDrive ? createMockOnlineDriveFile() : undefined, + }, currentCredentialId: 'cred-1', bucket: 'test-bucket', localFileList: [createMockFileItem()], @@ -1807,7 +1917,9 @@ describe('useDatasourceActions - Async Functions', () => { datasource: createMockDatasource(), datasourceType: DatasourceType.localFile, pipelineId: 'pipeline-1', - dataSourceStore: createMockDataSourceStoreForAsync(DatasourceType.localFile) as MockDataSourceStore, + dataSourceStore: createMockDataSourceStoreForAsync( + DatasourceType.localFile, + ) as MockDataSourceStore, setEstimateData, setBatchId: vi.fn(), setDocuments: vi.fn(), @@ -1839,7 +1951,9 @@ describe('useDatasourceActions - Async Functions', () => { datasource: createMockDatasource(), datasourceType: DatasourceType.localFile, pipelineId: 'pipeline-1', - dataSourceStore: createMockDataSourceStoreForAsync(DatasourceType.localFile) as MockDataSourceStore, + dataSourceStore: createMockDataSourceStoreForAsync( + DatasourceType.localFile, + ) as MockDataSourceStore, setEstimateData: vi.fn(), setBatchId, setDocuments, @@ -1868,7 +1982,9 @@ describe('useDatasourceActions - Async Functions', () => { datasource: undefined, datasourceType: DatasourceType.localFile, pipelineId: 'pipeline-1', - dataSourceStore: createMockDataSourceStoreForAsync(DatasourceType.localFile) as MockDataSourceStore, + dataSourceStore: createMockDataSourceStoreForAsync( + DatasourceType.localFile, + ) as MockDataSourceStore, setEstimateData: vi.fn(), setBatchId: vi.fn(), setDocuments: vi.fn(), @@ -1895,7 +2011,9 @@ describe('useDatasourceActions - Async Functions', () => { datasource: createMockDatasource(), datasourceType: DatasourceType.localFile, pipelineId: undefined, - dataSourceStore: createMockDataSourceStoreForAsync(DatasourceType.localFile) as MockDataSourceStore, + dataSourceStore: createMockDataSourceStoreForAsync( + DatasourceType.localFile, + ) as MockDataSourceStore, setEstimateData: vi.fn(), setBatchId: vi.fn(), setDocuments: vi.fn(), @@ -1927,7 +2045,9 @@ describe('useDatasourceActions - Async Functions', () => { }), datasourceType: DatasourceType.onlineDocument, pipelineId: 'pipeline-1', - dataSourceStore: createMockDataSourceStoreForAsync(DatasourceType.onlineDocument) as MockDataSourceStore, + dataSourceStore: createMockDataSourceStoreForAsync( + DatasourceType.onlineDocument, + ) as MockDataSourceStore, setEstimateData: vi.fn(), setBatchId: vi.fn(), setDocuments: vi.fn(), @@ -1960,7 +2080,9 @@ describe('useDatasourceActions - Async Functions', () => { }), datasourceType: DatasourceType.websiteCrawl, pipelineId: 'pipeline-1', - dataSourceStore: createMockDataSourceStoreForAsync(DatasourceType.websiteCrawl) as MockDataSourceStore, + dataSourceStore: createMockDataSourceStoreForAsync( + DatasourceType.websiteCrawl, + ) as MockDataSourceStore, setEstimateData: vi.fn(), setBatchId: vi.fn(), setDocuments: vi.fn(), @@ -1993,7 +2115,9 @@ describe('useDatasourceActions - Async Functions', () => { }), datasourceType: DatasourceType.onlineDrive, pipelineId: 'pipeline-1', - dataSourceStore: createMockDataSourceStoreForAsync(DatasourceType.onlineDrive) as MockDataSourceStore, + dataSourceStore: createMockDataSourceStoreForAsync( + DatasourceType.onlineDrive, + ) as MockDataSourceStore, setEstimateData: vi.fn(), setBatchId: vi.fn(), setDocuments: vi.fn(), diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx index c4ddec7434b06c..2e14f7f70b3e2e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx @@ -8,13 +8,15 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/next/link', () => ({ - default: ({ children, href }: { children: React.ReactNode, href: string }) => ( - <a href={href} data-testid="back-link">{children}</a> + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + <a href={href} data-testid="back-link"> + {children} + </a> ), })) vi.mock('../step-indicator', () => ({ - default: ({ steps, currentStep }: { steps: Step[], currentStep: number }) => ( + default: ({ steps, currentStep }: { steps: Step[]; currentStep: number }) => ( <div data-testid="step-indicator" data-steps={steps.length} data-current={currentStep} /> ), })) diff --git a/web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx index dac136dd7ff0f5..0d28f619e6a28f 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx @@ -11,14 +11,23 @@ vi.mock('@/next/navigation', () => ({ // Mock next/link to capture href vi.mock('@/next/link', () => ({ - default: ({ children, href, replace }: { children: React.ReactNode, href: string, replace?: boolean }) => ( + default: ({ + children, + href, + replace, + }: { + children: React.ReactNode + href: string + replace?: boolean + }) => ( <a href={href} data-replace={replace}> {children} </a> ), })) -const getSelectAllCheckbox = () => screen.getByRole('checkbox', { name: 'common.operation.selectAll' }) +const getSelectAllCheckbox = () => + screen.getByRole('checkbox', { name: 'common.operation.selectAll' }) describe('Actions', () => { // Default mock for required props @@ -35,7 +44,9 @@ describe('Actions', () => { it('should render without crashing', () => { render(<Actions {...defaultProps} />) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).toBeInTheDocument() }) it('should render cancel button with correct link', () => { @@ -463,14 +474,7 @@ describe('Actions', () => { it('should handle very long tip text', () => { const longTip = 'A'.repeat(500) - render( - <Actions - {...defaultProps} - showSelect={true} - tip={longTip} - onSelectAll={vi.fn()} - />, - ) + render(<Actions {...defaultProps} showSelect={true} tip={longTip} onSelectAll={vi.fn()} />) // Assert - tip should render with truncate class const tipElement = screen.getByTitle(longTip) @@ -480,14 +484,7 @@ describe('Actions', () => { it('should handle tip with special characters', () => { const specialTip = '<script>alert("xss")</script> & "quotes" \'apostrophes\'' - render( - <Actions - {...defaultProps} - showSelect={true} - tip={specialTip} - onSelectAll={vi.fn()} - />, - ) + render(<Actions {...defaultProps} showSelect={true} tip={specialTip} onSelectAll={vi.fn()} />) // Assert - special characters should be rendered safely expect(screen.getByText(specialTip)).toBeInTheDocument() @@ -496,14 +493,7 @@ describe('Actions', () => { it('should handle tip with unicode characters', () => { const unicodeTip = '选中 5 个文件,共 10MB 🚀' - render( - <Actions - {...defaultProps} - showSelect={true} - tip={unicodeTip} - onSelectAll={vi.fn()} - />, - ) + render(<Actions {...defaultProps} showSelect={true} tip={unicodeTip} onSelectAll={vi.fn()} />) expect(screen.getByText(unicodeTip)).toBeInTheDocument() }) @@ -553,7 +543,9 @@ describe('Actions', () => { // Assert - should render checkbox expect(getSelectAllCheckbox()).toBeInTheDocument() - await expect(user.click(screen.getByText('common.operation.selectAll'))).resolves.toBeUndefined() + await expect( + user.click(screen.getByText('common.operation.selectAll')), + ).resolves.toBeUndefined() }) it('should handle empty datasetId from params', () => { @@ -610,7 +602,9 @@ describe('Actions', () => { expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() expect(screen.getByText('All props provided')).toBeInTheDocument() expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).not.toBeDisabled() }) it('should render minimal component with only required props', () => { @@ -618,7 +612,9 @@ describe('Actions', () => { expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).not.toBeDisabled() }) }) @@ -649,9 +645,12 @@ describe('Actions', () => { ) // Assert - component should render without errors - const expectedAriaChecked = expectedState === 'indeterminate' - ? 'mixed' - : expectedState === 'checked' ? 'true' : 'false' + const expectedAriaChecked = + expectedState === 'indeterminate' + ? 'mixed' + : expectedState === 'checked' + ? 'true' + : 'false' expect(getSelectAllCheckbox()).toHaveAttribute('aria-checked', expectedAriaChecked) expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() }, diff --git a/web/app/components/datasets/documents/create-from-pipeline/actions/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/actions/index.tsx index 95f45d18815a63..10d18654ee7cb5 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/actions/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/actions/index.tsx @@ -30,18 +30,14 @@ const Actions = ({ const { datasetId } = useParams() const indeterminate = useMemo(() => { - if (!showSelect) - return false - if (selectedOptions === undefined || totalOptions === undefined) - return false + if (!showSelect) return false + if (selectedOptions === undefined || totalOptions === undefined) return false return selectedOptions > 0 && selectedOptions < totalOptions }, [showSelect, selectedOptions, totalOptions]) const checked = useMemo(() => { - if (!showSelect) - return false - if (selectedOptions === undefined || totalOptions === undefined) - return false + if (!showSelect) return false + if (selectedOptions === undefined || totalOptions === undefined) return false return selectedOptions > 0 && selectedOptions === totalOptions }, [showSelect, selectedOptions, totalOptions]) @@ -51,12 +47,12 @@ const Actions = ({ <> <label className="flex shrink-0 cursor-pointer items-center gap-x-2 py-[3px] pr-2 pl-4"> <Checkbox - onCheckedChange={checked => onSelectAll?.(checked)} + onCheckedChange={(checked) => onSelectAll?.(checked)} indeterminate={indeterminate} checked={checked} /> <span className="system-sm-medium text-text-accent"> - {t($ => $['operation.selectAll'], { ns: 'common' })} + {t(($) => $['operation.selectAll'], { ns: 'common' })} </span> </label> {tip && ( @@ -67,15 +63,9 @@ const Actions = ({ </> )} <div className="flex grow items-center justify-end gap-x-2"> - <Link - href={`/datasets/${datasetId}/documents`} - replace - > - <Button - variant="ghost" - className="px-3 py-2" - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Link href={`/datasets/${datasetId}/documents`} replace> + <Button variant="ghost" className="px-3 py-2"> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> </Link> <Button @@ -84,7 +74,7 @@ const Actions = ({ onClick={handleNextStep} className="gap-x-0.5" > - <span className="px-0.5">{t($ => $['stepOne.button'], { ns: 'datasetCreation' })}</span> + <span className="px-0.5">{t(($) => $['stepOne.button'], { ns: 'datasetCreation' })}</span> <RiArrowRightLine className="size-4" /> </Button> </div> diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/hooks.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/hooks.spec.tsx index 617da1f697d8f6..e9c5a6ece02a15 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/hooks.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/hooks.spec.tsx @@ -10,11 +10,13 @@ vi.mock('@/app/components/workflow/block-selector/utils', () => ({ })) let mockDataSourceListReturn: { - data: Array<{ - plugin_id: string - provider: string - declaration: { identity: { icon: string, author: string } } - }> | undefined + data: + | Array<{ + plugin_id: string + provider: string + declaration: { identity: { icon: string; author: string } } + }> + | undefined isSuccess: boolean } @@ -26,19 +28,20 @@ vi.mock('@/utils/var', () => ({ basePath: '', })) -const createMockDataSourceNode = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ - plugin_id: 'plugin-abc', - provider_type: 'builtin', - provider_name: 'web-scraper', - datasource_name: 'scraper', - datasource_label: 'Web Scraper', - datasource_parameters: {}, - datasource_configurations: {}, - title: 'DataSource', - desc: '', - type: '' as DataSourceNodeType['type'], - ...overrides, -} as DataSourceNodeType) +const createMockDataSourceNode = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => + ({ + plugin_id: 'plugin-abc', + provider_type: 'builtin', + provider_name: 'web-scraper', + datasource_name: 'scraper', + datasource_label: 'Web Scraper', + datasource_parameters: {}, + datasource_configurations: {}, + title: 'DataSource', + desc: '', + type: '' as DataSourceNodeType['type'], + ...overrides, + }) as DataSourceNodeType describe('useDatasourceIcon', () => { beforeEach(() => { @@ -52,9 +55,7 @@ describe('useDatasourceIcon', () => { it('should return undefined when data is not loaded (isSuccess false)', () => { mockDataSourceListReturn = { data: undefined, isSuccess: false } - const { result } = renderHook(() => - useDatasourceIcon(createMockDataSourceNode()), - ) + const { result } = renderHook(() => useDatasourceIcon(createMockDataSourceNode())) expect(result.current).toBeUndefined() }) @@ -74,10 +75,12 @@ describe('useDatasourceIcon', () => { ], isSuccess: true, } - mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({ - plugin_id: item.plugin_id, - icon: item.declaration.identity.icon, - })) + mockTransformDataSourceToTool.mockImplementation( + (item: { plugin_id: string; declaration: { identity: { icon: string } } }) => ({ + plugin_id: item.plugin_id, + icon: item.declaration.identity.icon, + }), + ) const { result } = renderHook(() => useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })), @@ -97,10 +100,12 @@ describe('useDatasourceIcon', () => { ], isSuccess: true, } - mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({ - plugin_id: item.plugin_id, - icon: item.declaration.identity.icon, - })) + mockTransformDataSourceToTool.mockImplementation( + (item: { plugin_id: string; declaration: { identity: { icon: string } } }) => ({ + plugin_id: item.plugin_id, + icon: item.declaration.identity.icon, + }), + ) const { result } = renderHook(() => useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })), @@ -125,10 +130,12 @@ describe('useDatasourceIcon', () => { ], isSuccess: true, } - mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({ - plugin_id: item.plugin_id, - icon: item.declaration.identity.icon, - })) + mockTransformDataSourceToTool.mockImplementation( + (item: { plugin_id: string; declaration: { identity: { icon: string } } }) => ({ + plugin_id: item.plugin_id, + icon: item.declaration.identity.icon, + }), + ) const { result } = renderHook(() => useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })), diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/index.spec.tsx index 78542ad522979f..9f57a56c742cde 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/index.spec.tsx @@ -33,7 +33,9 @@ vi.mock('@/utils/var', () => ({ basePath: '/mock-base-path', })) -const createMockDataSourceNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ +const createMockDataSourceNodeData = ( + overrides?: Partial<DataSourceNodeType>, +): DataSourceNodeType => ({ title: 'Test Data Source', desc: 'Test description', type: BlockEnum.DataSource, @@ -47,7 +49,9 @@ const createMockDataSourceNodeData = (overrides?: Partial<DataSourceNodeType>): ...overrides, }) -const createMockPipelineNode = (overrides?: Partial<Node<DataSourceNodeType>>): Node<DataSourceNodeType> => { +const createMockPipelineNode = ( + overrides?: Partial<Node<DataSourceNodeType>>, +): Node<DataSourceNodeType> => { const nodeData = createMockDataSourceNodeData(overrides?.data) return { id: `node-${Math.random().toString(36).slice(2, 9)}`, @@ -67,12 +71,11 @@ const createMockPipelineNodes = (count = 3): Node<DataSourceNodeType>[] => { plugin_id: `plugin-${i + 1}`, datasource_name: `datasource-${i + 1}`, }), - })) + }), + ) } -const createMockDatasourceOption = ( - node: Node<DataSourceNodeType>, -) => ({ +const createMockDatasourceOption = (node: Node<DataSourceNodeType>) => ({ label: node.data.title, value: node.id, data: node.data, @@ -91,31 +94,23 @@ const createMockDataSourceListItem = (overrides?: Record<string, unknown>) => ({ ...overrides, }) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, -}) +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) -const renderWithProviders = ( - ui: React.ReactElement, - queryClient?: QueryClient, -) => { +const renderWithProviders = (ui: React.ReactElement, queryClient?: QueryClient) => { const client = queryClient || createQueryClient() - return render( - <QueryClientProvider client={client}> - {ui} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>) } const createHookWrapper = () => { const queryClient = createQueryClient() return ({ children }: { children: React.ReactNode }) => ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) } @@ -195,7 +190,11 @@ describe('DatasourceIcon', () => { it('should merge custom className with default classes', () => { const { container } = render( - <DatasourceIcon iconUrl="https://example.com/icon.png" className="custom-class" size="sm" />, + <DatasourceIcon + iconUrl="https://example.com/icon.png" + className="custom-class" + size="sm" + />, ) expect(container.firstChild)!.toHaveClass('custom-class') @@ -222,7 +221,8 @@ describe('DatasourceIcon', () => { }) it('should handle data URL as iconUrl', () => { - const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' + const dataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' const { container } = render(<DatasourceIcon iconUrl={dataUrl} />) @@ -268,7 +268,7 @@ describe('useDatasourceIcon', () => { data: [], isSuccess: false, }) - mockTransformDataSourceToTool.mockImplementation(item => ({ + mockTransformDataSourceToTool.mockImplementation((item) => ({ plugin_id: item.plugin_id, icon: item.declaration?.identity?.icon, })) @@ -318,7 +318,7 @@ describe('useDatasourceIcon', () => { data: mockDataSourceList, isSuccess: true, }) - mockTransformDataSourceToTool.mockImplementation(item => ({ + mockTransformDataSourceToTool.mockImplementation((item) => ({ plugin_id: item.plugin_id, icon: item.declaration?.identity?.icon, })) @@ -368,7 +368,7 @@ describe('useDatasourceIcon', () => { data: mockDataSourceList, isSuccess: true, }) - mockTransformDataSourceToTool.mockImplementation(item => ({ + mockTransformDataSourceToTool.mockImplementation((item) => ({ plugin_id: item.plugin_id, icon: item.declaration?.identity?.icon, })) @@ -399,7 +399,7 @@ describe('useDatasourceIcon', () => { data: mockDataSourceList, isSuccess: true, }) - mockTransformDataSourceToTool.mockImplementation(item => ({ + mockTransformDataSourceToTool.mockImplementation((item) => ({ plugin_id: item.plugin_id, icon: item.declaration?.identity?.icon, })) @@ -460,7 +460,7 @@ describe('useDatasourceIcon', () => { data: mockDataSourceList, isSuccess: true, }) - mockTransformDataSourceToTool.mockImplementation(item => ({ + mockTransformDataSourceToTool.mockImplementation((item) => ({ plugin_id: item.plugin_id, icon: item.declaration?.identity?.icon, })) @@ -551,9 +551,7 @@ describe('OptionCard', () => { describe('Props', () => { describe('selected', () => { it('should apply selected styles when selected is true', () => { - const { container } = renderWithProviders( - <OptionCard {...defaultProps} selected={true} />, - ) + const { container } = renderWithProviders(<OptionCard {...defaultProps} selected={true} />) const card = container.firstChild expect(card)!.toHaveClass('border-components-option-card-option-selected-border') @@ -561,9 +559,7 @@ describe('OptionCard', () => { }) it('should apply unselected styles when selected is false', () => { - const { container } = renderWithProviders( - <OptionCard {...defaultProps} selected={false} />, - ) + const { container } = renderWithProviders(<OptionCard {...defaultProps} selected={false} />) const card = container.firstChild expect(card)!.toHaveClass('border-components-option-card-option-border') @@ -588,9 +584,7 @@ describe('OptionCard', () => { describe('onClick', () => { it('should call onClick when card is clicked', () => { const mockOnClick = vi.fn() - renderWithProviders( - <OptionCard {...defaultProps} onClick={mockOnClick} />, - ) + renderWithProviders(<OptionCard {...defaultProps} onClick={mockOnClick} />) // Act - Click on the label text's parent card const labelElement = screen.getByText('Test Option') @@ -602,9 +596,7 @@ describe('OptionCard', () => { }) it('should not crash when onClick is not provided', () => { - renderWithProviders( - <OptionCard {...defaultProps} onClick={undefined} />, - ) + renderWithProviders(<OptionCard {...defaultProps} onClick={undefined} />) // Act - Click on the label text's parent card should not throw const labelElement = screen.getByText('Test Option') @@ -753,9 +745,7 @@ describe('DataSourceOptions', () => { const customNodes = createMockPipelineNodes(2) mockUseDatasourceOptions.mockReturnValue(customNodes.map(createMockDatasourceOption)) - renderWithProviders( - <DataSourceOptions {...defaultProps} pipelineNodes={customNodes} />, - ) + renderWithProviders(<DataSourceOptions {...defaultProps} pipelineNodes={customNodes} />) expect(mockUseDatasourceOptions).toHaveBeenCalledWith(customNodes) }) @@ -763,9 +753,7 @@ describe('DataSourceOptions', () => { it('should handle empty pipelineNodes array', () => { mockUseDatasourceOptions.mockReturnValue([]) - renderWithProviders( - <DataSourceOptions {...defaultProps} pipelineNodes={[]} />, - ) + renderWithProviders(<DataSourceOptions {...defaultProps} pipelineNodes={[]} />) expect(mockUseDatasourceOptions).toHaveBeenCalledWith([]) }) @@ -774,10 +762,7 @@ describe('DataSourceOptions', () => { describe('datasourceNodeId', () => { it('should mark corresponding option as selected', () => { const { container } = renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-2" - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-2" />, ) // Assert - Check for selected styling on second card @@ -787,35 +772,30 @@ describe('DataSourceOptions', () => { it('should show no selection when datasourceNodeId is empty', () => { const { container } = renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="" - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="" />, ) // Assert - No card should have selected styling - const selectedCards = container.querySelectorAll('.border-components-option-card-option-selected-border') + const selectedCards = container.querySelectorAll( + '.border-components-option-card-option-selected-border', + ) expect(selectedCards).toHaveLength(0) }) it('should show no selection when datasourceNodeId does not match any option', () => { const { container } = renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="non-existent-node" - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="non-existent-node" />, ) - const selectedCards = container.querySelectorAll('.border-components-option-card-option-selected-border') + const selectedCards = container.querySelectorAll( + '.border-components-option-card-option-selected-border', + ) expect(selectedCards).toHaveLength(0) }) it('should update selection when datasourceNodeId changes', () => { const { container, rerender } = renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-1" - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-1" />, ) // Assert initial selection @@ -825,10 +805,7 @@ describe('DataSourceOptions', () => { // Act - Change selection rerender( <QueryClientProvider client={createQueryClient()}> - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-2" - /> + <DataSourceOptions {...defaultProps} datasourceNodeId="node-2" /> </QueryClientProvider>, ) @@ -843,12 +820,7 @@ describe('DataSourceOptions', () => { it('should receive onSelect callback', () => { const mockOnSelect = vi.fn() - renderWithProviders( - <DataSourceOptions - {...defaultProps} - onSelect={mockOnSelect} - />, - ) + renderWithProviders(<DataSourceOptions {...defaultProps} onSelect={mockOnSelect} />) // Assert - Component renders without error // Assert - Component renders without error @@ -864,11 +836,7 @@ describe('DataSourceOptions', () => { const mockOnSelect = vi.fn() renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="" - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="" onSelect={mockOnSelect} />, ) // Assert - Should auto-select first option on mount @@ -883,11 +851,7 @@ describe('DataSourceOptions', () => { const mockOnSelect = vi.fn() renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-2" - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-2" onSelect={mockOnSelect} />, ) // Assert - Should not auto-select because datasourceNodeId is provided @@ -913,11 +877,7 @@ describe('DataSourceOptions', () => { it('should only run useEffect once on initial mount', () => { const mockOnSelect = vi.fn() const { rerender } = renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="" - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="" onSelect={mockOnSelect} />, ) // Assert - Called once on mount @@ -926,11 +886,7 @@ describe('DataSourceOptions', () => { // Act - Rerender with same props rerender( <QueryClientProvider client={createQueryClient()}> - <DataSourceOptions - {...defaultProps} - datasourceNodeId="" - onSelect={mockOnSelect} - /> + <DataSourceOptions {...defaultProps} datasourceNodeId="" onSelect={mockOnSelect} /> </QueryClientProvider>, ) @@ -946,10 +902,7 @@ describe('DataSourceOptions', () => { const mockOnSelect = vi.fn() const { rerender } = renderWithProviders( - <DataSourceOptions - {...defaultProps} - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} onSelect={mockOnSelect} />, ) // Get initial click handlers @@ -963,10 +916,7 @@ describe('DataSourceOptions', () => { // Act - Rerender with same onSelect reference rerender( <QueryClientProvider client={createQueryClient()}> - <DataSourceOptions - {...defaultProps} - onSelect={mockOnSelect} - /> + <DataSourceOptions {...defaultProps} onSelect={mockOnSelect} /> </QueryClientProvider>, ) @@ -980,11 +930,7 @@ describe('DataSourceOptions', () => { const mockOnSelect2 = vi.fn() const { rerender } = renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-1" - onSelect={mockOnSelect1} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-1" onSelect={mockOnSelect1} />, ) // Act - Click with first callback @@ -994,11 +940,7 @@ describe('DataSourceOptions', () => { // Act - Change callback rerender( <QueryClientProvider client={createQueryClient()}> - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-1" - onSelect={mockOnSelect2} - /> + <DataSourceOptions {...defaultProps} datasourceNodeId="node-1" onSelect={mockOnSelect2} /> </QueryClientProvider>, ) @@ -1017,11 +959,7 @@ describe('DataSourceOptions', () => { const mockOnSelect = vi.fn() const { rerender } = renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-1" - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-1" onSelect={mockOnSelect} />, ) // Act - Click first option @@ -1033,7 +971,7 @@ describe('DataSourceOptions', () => { // Act - Change options const newNodes = createMockPipelineNodes(2) - const newOptions = newNodes.map(node => createMockDatasourceOption(node)) + const newOptions = newNodes.map((node) => createMockDatasourceOption(node)) mockUseDatasourceOptions.mockReturnValue(newOptions) rerender( @@ -1063,11 +1001,7 @@ describe('DataSourceOptions', () => { it('should call onSelect with correct datasource when clicking an option', () => { const mockOnSelect = vi.fn() renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-1" - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-1" onSelect={mockOnSelect} />, ) // Act - Click second option @@ -1083,11 +1017,7 @@ describe('DataSourceOptions', () => { it('should allow selecting already selected option', () => { const mockOnSelect = vi.fn() renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-1" - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-1" onSelect={mockOnSelect} />, ) // Act - Click already selected option @@ -1103,11 +1033,7 @@ describe('DataSourceOptions', () => { it('should allow multiple sequential selections', () => { const mockOnSelect = vi.fn() renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-1" - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-1" onSelect={mockOnSelect} />, ) // Act - Click options sequentially @@ -1135,11 +1061,7 @@ describe('DataSourceOptions', () => { it('should handle rapid successive clicks', async () => { const mockOnSelect = vi.fn() renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-1" - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-1" onSelect={mockOnSelect} />, ) // Act - Rapid clicks @@ -1164,10 +1086,7 @@ describe('DataSourceOptions', () => { mockUseDatasourceOptions.mockReturnValue([]) const { container } = renderWithProviders( - <DataSourceOptions - {...defaultProps} - pipelineNodes={[]} - />, + <DataSourceOptions {...defaultProps} pipelineNodes={[]} />, ) expect(container.firstChild)!.toBeInTheDocument() @@ -1188,22 +1107,24 @@ describe('DataSourceOptions', () => { describe('Null/Undefined Values', () => { it('should handle option with missing data properties', () => { - const optionWithMinimalData = [{ - label: 'Minimal Option', - value: 'minimal-1', - data: { - title: 'Minimal', - desc: '', - type: BlockEnum.DataSource, - plugin_id: '', - provider_type: '', - provider_name: '', - datasource_name: '', - datasource_label: '', - datasource_parameters: {}, - datasource_configurations: {}, - } as DataSourceNodeType, - }] + const optionWithMinimalData = [ + { + label: 'Minimal Option', + value: 'minimal-1', + data: { + title: 'Minimal', + desc: '', + type: BlockEnum.DataSource, + plugin_id: '', + provider_type: '', + provider_name: '', + datasource_name: '', + datasource_label: '', + datasource_parameters: {}, + datasource_configurations: {}, + } as DataSourceNodeType, + }, + ] mockUseDatasourceOptions.mockReturnValue(optionWithMinimalData) renderWithProviders(<DataSourceOptions {...defaultProps} />) @@ -1218,12 +1139,7 @@ describe('DataSourceOptions', () => { const manyOptions = manyNodes.map(createMockDatasourceOption) mockUseDatasourceOptions.mockReturnValue(manyOptions) - renderWithProviders( - <DataSourceOptions - {...defaultProps} - pipelineNodes={manyNodes} - />, - ) + renderWithProviders(<DataSourceOptions {...defaultProps} pipelineNodes={manyNodes} />) expect(screen.getByText('Data Source 1'))!.toBeInTheDocument() expect(screen.getByText('Data Source 50'))!.toBeInTheDocument() @@ -1241,12 +1157,7 @@ describe('DataSourceOptions', () => { const specialOptions = [createMockDatasourceOption(specialNode)] mockUseDatasourceOptions.mockReturnValue(specialOptions) - renderWithProviders( - <DataSourceOptions - {...defaultProps} - pipelineNodes={[specialNode]} - />, - ) + renderWithProviders(<DataSourceOptions {...defaultProps} pipelineNodes={[specialNode]} />) // Assert - Special characters should be escaped/rendered safely // Assert - Special characters should be escaped/rendered safely @@ -1263,22 +1174,19 @@ describe('DataSourceOptions', () => { const unicodeOptions = [createMockDatasourceOption(unicodeNode)] mockUseDatasourceOptions.mockReturnValue(unicodeOptions) - renderWithProviders( - <DataSourceOptions - {...defaultProps} - pipelineNodes={[unicodeNode]} - />, - ) + renderWithProviders(<DataSourceOptions {...defaultProps} pipelineNodes={[unicodeNode]} />) expect(screen.getByText('数据源 📁 Source émoji'))!.toBeInTheDocument() }) it('should handle empty string as option value', () => { - const emptyValueOption = [{ - label: 'Empty Value Option', - value: '', - data: createMockDataSourceNodeData(), - }] + const emptyValueOption = [ + { + label: 'Empty Value Option', + value: '', + data: createMockDataSourceNodeData(), + }, + ] mockUseDatasourceOptions.mockReturnValue(emptyValueOption) renderWithProviders(<DataSourceOptions {...defaultProps} />) @@ -1294,11 +1202,7 @@ describe('DataSourceOptions', () => { const mockOnSelect = vi.fn() renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-1" - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-1" onSelect={mockOnSelect} />, ) // Assert - Click should still work @@ -1323,11 +1227,7 @@ describe('DataSourceOptions', () => { const mockOnSelect = vi.fn() renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-a" - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-a" onSelect={mockOnSelect} />, ) // Assert - Both should render @@ -1346,10 +1246,7 @@ describe('DataSourceOptions', () => { it('should handle unmounting without errors', () => { const mockOnSelect = vi.fn() const { unmount } = renderWithProviders( - <DataSourceOptions - {...defaultProps} - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} onSelect={mockOnSelect} />, ) unmount() @@ -1392,11 +1289,7 @@ describe('DataSourceOptions', () => { it('should handle unmounting during rapid interactions', async () => { const mockOnSelect = vi.fn() const { unmount } = renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-1" - onSelect={mockOnSelect} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-1" onSelect={mockOnSelect} />, ) // Act - Start interactions then unmount @@ -1453,10 +1346,7 @@ describe('DataSourceOptions', () => { it('should correctly pass selected state to OptionCard', () => { const { container } = renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId="node-2" - />, + <DataSourceOptions {...defaultProps} datasourceNodeId="node-2" />, ) const cards = container.querySelectorAll('.rounded-xl.border') @@ -1474,9 +1364,7 @@ describe('DataSourceOptions', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(vi.fn()) renderWithProviders(<DataSourceOptions {...defaultProps} />) - expect(consoleSpy).not.toHaveBeenCalledWith( - expect.stringContaining('key'), - ) + expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('key')) consoleSpy.mockRestore() }) }) @@ -1490,10 +1378,7 @@ describe('DataSourceOptions', () => { { datasourceNodeId: 'non-existent', description: 'non-existent node' }, ])('should handle datasourceNodeId as $description', ({ datasourceNodeId }) => { renderWithProviders( - <DataSourceOptions - {...defaultProps} - datasourceNodeId={datasourceNodeId} - />, + <DataSourceOptions {...defaultProps} datasourceNodeId={datasourceNodeId} />, ) expect(screen.getByText('Data Source 1'))!.toBeInTheDocument() @@ -1510,17 +1395,11 @@ describe('DataSourceOptions', () => { mockUseDatasourceOptions.mockReturnValue(options) renderWithProviders( - <DataSourceOptions - pipelineNodes={nodes} - datasourceNodeId="" - onSelect={vi.fn()} - />, + <DataSourceOptions pipelineNodes={nodes} datasourceNodeId="" onSelect={vi.fn()} />, ) - if (count > 0) - expect(screen.getByText('Data Source 1'))!.toBeInTheDocument() - else - expect(screen.queryByText('Data Source 1')).not.toBeInTheDocument() + if (count > 0) expect(screen.getByText('Data Source 1'))!.toBeInTheDocument() + else expect(screen.queryByText('Data Source 1')).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/option-card.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/option-card.spec.tsx index 8f05b2671b40b5..15a5a47479afa1 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/option-card.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/option-card.spec.tsx @@ -15,19 +15,20 @@ vi.mock('../datasource-icon', () => ({ ), })) -const createMockNodeData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => ({ - title: 'Test Node', - desc: '', - type: {} as DataSourceNodeType['type'], - plugin_id: 'test-plugin', - provider_type: 'builtin', - provider_name: 'test-provider', - datasource_name: 'test-ds', - datasource_label: 'Test DS', - datasource_parameters: {}, - datasource_configurations: {}, - ...overrides, -} as DataSourceNodeType) +const createMockNodeData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => + ({ + title: 'Test Node', + desc: '', + type: {} as DataSourceNodeType['type'], + plugin_id: 'test-plugin', + provider_type: 'builtin', + provider_name: 'test-provider', + datasource_name: 'test-ds', + datasource_label: 'Test DS', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, + }) as DataSourceNodeType describe('OptionCard', () => { const defaultProps = { @@ -75,9 +76,7 @@ describe('OptionCard', () => { it('should not throw when onClick is undefined', () => { expect(() => { - const { container } = render( - <OptionCard {...defaultProps} onClick={undefined} />, - ) + const { container } = render(<OptionCard {...defaultProps} onClick={undefined} />) fireEvent.click(container.firstElementChild!) }).not.toThrow() }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/datasource-icon.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/datasource-icon.tsx index 5261319e98878b..c3aeb99b99a348 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/datasource-icon.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/datasource-icon.tsx @@ -14,19 +14,14 @@ const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record<string, string> = { md: 'w-6 h-6 rounded-lg shadow-md', } -const DatasourceIcon: FC<DatasourceIconProps> = ({ - size = 'sm', - className, - iconUrl, -}) => { +const DatasourceIcon: FC<DatasourceIconProps> = ({ size = 'sm', className, iconUrl }) => { return ( - <div className={ - cn( + <div + className={cn( 'flex items-center justify-center shadow-none', ICON_CONTAINER_CLASSNAME_SIZE_MAP[size], className, - ) - } + )} > <div className="size-full shrink-0 rounded-md bg-cover bg-center" diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/hooks.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/hooks.tsx index 9d4349ebe42243..690388de064422 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/hooks.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/hooks.tsx @@ -8,16 +8,17 @@ export const useDatasourceIcon = (data: DataSourceNodeType) => { const { data: dataSourceListData, isSuccess } = useDataSourceList(true) const datasourceIcon = useMemo(() => { - if (!isSuccess) - return + if (!isSuccess) return const dataSourceList = [...(dataSourceListData || [])] dataSourceList.forEach((item) => { const icon = item.declaration.identity.icon if (typeof icon == 'string' && !icon.includes(basePath)) item.declaration.identity.icon = `${basePath}${icon}` }) - const formattedDataSourceList = dataSourceList.map(item => transformDataSourceToTool(item)) - return formattedDataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon + const formattedDataSourceList = dataSourceList.map((item) => transformDataSourceToTool(item)) + return formattedDataSourceList?.find( + (toolWithProvider) => toolWithProvider.plugin_id === data.plugin_id, + )?.icon }, [data.plugin_id, dataSourceListData, isSuccess]) return datasourceIcon diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.tsx index 51cf34d2734b8e..26a9f4c2b2ec0c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.tsx @@ -18,25 +18,26 @@ const DataSourceOptions = ({ }: DataSourceOptionsProps) => { const options = useDatasourceOptions(pipelineNodes) - const handelSelect = useCallback((value: string) => { - const selectedOption = options.find(option => option.value === value) - if (!selectedOption) - return - const datasource = { - nodeId: selectedOption.value, - nodeData: selectedOption.data, - } - onSelect(datasource) - }, [onSelect, options]) + const handelSelect = useCallback( + (value: string) => { + const selectedOption = options.find((option) => option.value === value) + if (!selectedOption) return + const datasource = { + nodeId: selectedOption.value, + nodeData: selectedOption.data, + } + onSelect(datasource) + }, + [onSelect, options], + ) useEffect(() => { - if (options.length > 0 && !datasourceNodeId) - handelSelect(options[0]!.value) + if (options.length > 0 && !datasourceNodeId) handelSelect(options[0]!.value) }, []) return ( <div className="grid w-full grid-cols-4 gap-1"> - {options.map(option => ( + {options.map((option) => ( <OptionCard key={option.value} label={option.label} diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/option-card.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/option-card.tsx index 748a31431af522..09a6d733fe7805 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/option-card.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/option-card.tsx @@ -11,12 +11,7 @@ type OptionCardProps = { onClick?: () => void } -const OptionCard = ({ - label, - selected, - nodeData, - onClick, -}: OptionCardProps) => { +const OptionCard = ({ label, selected, nodeData, onClick }: OptionCardProps) => { const iconUrl = useDatasourceIcon(nodeData) as string return ( @@ -33,7 +28,10 @@ const OptionCard = ({ <DatasourceIcon iconUrl={iconUrl} /> </div> <div - className={cn('line-clamp-2 grow system-sm-medium text-text-secondary', selected && 'text-text-primary')} + className={cn( + 'line-clamp-2 grow system-sm-medium text-text-secondary', + selected && 'text-text-primary', + )} title={label} > {label} diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx index 0db8f54df4ea2a..e05199eba40916 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx @@ -40,9 +40,12 @@ const createMockCredentials = (count: number = 3): DataSourceCredential[] => name: `Credential ${i + 1}`, avatar_url: `https://example.com/avatar-${i + 1}.png`, is_default: i === 0, - })) + }), + ) -const createDefaultProps = (overrides?: Partial<CredentialSelectorProps>): CredentialSelectorProps => ({ +const createDefaultProps = ( + overrides?: Partial<CredentialSelectorProps>, +): CredentialSelectorProps => ({ currentCredentialId: 'cred-1', onCredentialChange: vi.fn(), credentials: createMockCredentials(), @@ -148,13 +151,16 @@ describe('CredentialSelector', () => { ['cred-1', 'Credential 1'], ['cred-2', 'Credential 2'], ['cred-3', 'Credential 3'], - ])('should display %s credential name when currentCredentialId is %s', (credId, expectedName) => { - const props = createDefaultProps({ currentCredentialId: credId }) + ])( + 'should display %s credential name when currentCredentialId is %s', + (credId, expectedName) => { + const props = createDefaultProps({ currentCredentialId: credId }) - render(<CredentialSelector {...props} />) + render(<CredentialSelector {...props} />) - expect(screen.getByText(expectedName))!.toBeInTheDocument() - }) + expect(screen.getByText(expectedName))!.toBeInTheDocument() + }, + ) }) describe('credentials prop', () => { @@ -185,7 +191,9 @@ describe('CredentialSelector', () => { it('should handle credentials with special characters in name', () => { const props = createDefaultProps({ - credentials: [createMockCredential({ id: 'cred-special', name: 'Test & Credential <special>' })], + credentials: [ + createMockCredential({ id: 'cred-special', name: 'Test & Credential <special>' }), + ], currentCredentialId: 'cred-special', }) @@ -428,12 +436,7 @@ describe('CredentialSelector', () => { createMockCredential({ id: 'cred-4', name: 'New Credential 4' }), createMockCredential({ id: 'cred-5', name: 'New Credential 5' }), ] - rerender( - <CredentialSelector - {...props} - credentials={newCredentials} - />, - ) + rerender(<CredentialSelector {...props} credentials={newCredentials} />) // Assert - Should auto-select first of new credentials await waitFor(() => { @@ -531,9 +534,7 @@ describe('CredentialSelector', () => { expect(screen.getByText('Credential 1'))!.toBeInTheDocument() // Act - Change credentials - const newCredentials = [ - createMockCredential({ id: 'cred-1', name: 'Updated Credential 1' }), - ] + const newCredentials = [createMockCredential({ id: 'cred-1', name: 'Updated Credential 1' })] rerender(<CredentialSelector {...props} credentials={newCredentials} />) // Assert - Should display updated name @@ -597,9 +598,7 @@ describe('CredentialSelector', () => { const { rerender } = render(<CredentialSelector {...props} />) // Act - Create new credentials array with different data - const newCredentials = [ - createMockCredential({ id: 'cred-1', name: 'New Name 1' }), - ] + const newCredentials = [createMockCredential({ id: 'cred-1', name: 'New Name 1' })] rerender(<CredentialSelector {...props} credentials={newCredentials} />) expect(screen.getByText('New Name 1'))!.toBeInTheDocument() diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/item.spec.tsx index 7aa6c8f0c3af57..9b8dc879ce80d6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/item.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/item.spec.tsx @@ -9,7 +9,11 @@ vi.mock('@/app/components/datasets/common/credential-icon', () => ({ describe('CredentialSelectorItem', () => { const defaultProps = { - credential: { id: 'cred-1', name: 'My Account', avatar_url: 'https://example.com/avatar.png' } as DataSourceCredential, + credential: { + id: 'cred-1', + name: 'My Account', + avatar_url: 'https://example.com/avatar.png', + } as DataSourceCredential, isSelected: false, onCredentialChange: vi.fn(), } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/trigger.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/trigger.spec.tsx index 3e5cec12b8786c..9b92c1049440e7 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/trigger.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/trigger.spec.tsx @@ -11,7 +11,9 @@ describe('CredentialSelectorTrigger', () => { it('should render credential name when provided', () => { render( <Trigger - currentCredential={{ id: 'cred-1', name: 'Account A', avatar_url: '' } as DataSourceCredential} + currentCredential={ + { id: 'cred-1', name: 'Account A', avatar_url: '' } as DataSourceCredential + } isOpen={false} />, ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.tsx index b00564f9b468ca..b5b144647cf041 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.tsx @@ -1,9 +1,5 @@ import type { DataSourceCredential } from '@/types/pipeline' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useEffect, useMemo } from 'react' @@ -24,32 +20,25 @@ const CredentialSelector = ({ const [open, { set, setFalse }] = useBoolean(false) const currentCredential = useMemo(() => { - return credentials.find(cred => cred.id === currentCredentialId) + return credentials.find((cred) => cred.id === currentCredentialId) }, [credentials, currentCredentialId]) useEffect(() => { - if (!currentCredential && credentials.length) - onCredentialChange(credentials[0]!.id) + if (!currentCredential && credentials.length) onCredentialChange(credentials[0]!.id) }, [currentCredential, credentials]) - const handleCredentialChange = useCallback((credentialId: string) => { - onCredentialChange(credentialId) - setFalse() - }, [onCredentialChange, setFalse]) + const handleCredentialChange = useCallback( + (credentialId: string) => { + onCredentialChange(credentialId) + setFalse() + }, + [onCredentialChange, setFalse], + ) return ( - <Popover - open={open} - onOpenChange={set} - > - <PopoverTrigger - nativeButton={false} - render={<div className="grow overflow-hidden" />} - > - <Trigger - currentCredential={currentCredential} - isOpen={open} - /> + <Popover open={open} onOpenChange={set}> + <PopoverTrigger nativeButton={false} render={<div className="grow overflow-hidden" />}> + <Trigger currentCredential={currentCredential} isOpen={open} /> </PopoverTrigger> <PopoverContent placement="bottom-start" diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx index b162411f6cea64..4b5eae85517f6f 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx @@ -10,11 +10,7 @@ type ItemProps = { onCredentialChange: (credentialId: string) => void } -const Item = ({ - credential, - isSelected, - onCredentialChange, -}: ItemProps) => { +const Item = ({ credential, isSelected, onCredentialChange }: ItemProps) => { const { avatar_url, name } = credential const handleCredentialChange = useCallback(() => { @@ -26,19 +22,9 @@ const Item = ({ className="flex cursor-pointer items-center gap-x-2 rounded-lg p-2 hover:bg-state-base-hover" onClick={handleCredentialChange} > - <CredentialIcon - avatarUrl={avatar_url} - name={name} - size={20} - /> - <span className="grow truncate system-sm-medium text-text-secondary"> - {name} - </span> - { - isSelected && ( - <RiCheckLine className="size-4 shrink-0 text-text-accent" /> - ) - } + <CredentialIcon avatarUrl={avatar_url} name={name} size={20} /> + <span className="grow truncate system-sm-medium text-text-secondary">{name}</span> + {isSelected && <RiCheckLine className="size-4 shrink-0 text-text-accent" />} </div> ) } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/list.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/list.tsx index 09988a42d5becd..dfb3ed43568f33 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/list.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/list.tsx @@ -8,26 +8,20 @@ type ListProps = { onCredentialChange: (credentialId: string) => void } -const List = ({ - currentCredentialId, - credentials, - onCredentialChange, -}: ListProps) => { +const List = ({ currentCredentialId, credentials, onCredentialChange }: ListProps) => { return ( <div className="flex w-[280px] flex-col gap-y-1 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"> - { - credentials.map((credential) => { - const isSelected = credential.id === currentCredentialId - return ( - <Item - key={credential.id} - credential={credential} - isSelected={isSelected} - onCredentialChange={onCredentialChange} - /> - ) - }) - } + {credentials.map((credential) => { + const isSelected = credential.id === currentCredentialId + return ( + <Item + key={credential.id} + credential={credential} + isSelected={isSelected} + onCredentialChange={onCredentialChange} + /> + ) + })} </div> ) } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/trigger.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/trigger.tsx index 980a7ff1ddab31..c65d742cd319d8 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/trigger.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/trigger.tsx @@ -9,14 +9,8 @@ type TriggerProps = { isOpen: boolean } -const Trigger = ({ - currentCredential, - isOpen, -}: TriggerProps) => { - const { - avatar_url, - name = '', - } = currentCredential || {} +const Trigger = ({ currentCredential, isOpen }: TriggerProps) => { + const { avatar_url, name = '' } = currentCredential || {} return ( <div @@ -25,15 +19,9 @@ const Trigger = ({ isOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover', )} > - <CredentialIcon - avatarUrl={avatar_url} - name={name} - size={20} - /> + <CredentialIcon avatarUrl={avatar_url} name={name} size={20} /> <div className="flex grow items-center gap-x-1 overflow-hidden"> - <span className="grow truncate system-md-semibold text-text-secondary"> - {name} - </span> + <span className="grow truncate system-md-semibold text-text-secondary">{name}</span> <RiArrowDownSLine className="size-4 shrink-0 text-text-secondary" /> </div> </div> diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx index 9f2f4adf8211a5..8252d27fd55438 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx @@ -13,26 +13,18 @@ type HeaderProps = { pluginName: string } & CredentialSelectorProps -const Header = ({ - docTitle, - docLink, - onClickConfiguration, - pluginName, - ...rest -}: HeaderProps) => { +const Header = ({ docTitle, docLink, onClickConfiguration, pluginName, ...rest }: HeaderProps) => { const { t } = useTranslation() - const configurationTip = t($ => $.configurationTip, { ns: 'datasetPipeline', pluginName }) + const configurationTip = t(($) => $.configurationTip, { ns: 'datasetPipeline', pluginName }) return ( <div className="flex items-center justify-between gap-x-2"> <div className="flex items-center gap-x-1 overflow-hidden"> - <CredentialSelector - {...rest} - /> + <CredentialSelector {...rest} /> <Divider type="vertical" className="mx-1 h-3.5 shrink-0" /> <Tooltip> <TooltipTrigger - render={( + render={ <Button variant="ghost" size="small" @@ -42,11 +34,9 @@ const Header = ({ > <span aria-hidden className="i-ri-equalizer-2-line size-4" /> </Button> - )} + } /> - <TooltipContent> - {configurationTip} - </TooltipContent> + <TooltipContent>{configurationTip}</TooltipContent> </Tooltip> </div> <a diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx index 4ec21ab1fb5214..9ff640d61c4d99 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx @@ -29,10 +29,7 @@ vi.mock('@/app/components/datasets/common/document-file-icon', () => ({ vi.mock('@/next/dynamic', () => ({ default: () => { const Component = ({ percentage }: { percentage: number }) => ( - <div data-testid="pie-chart"> - {percentage} - % - </div> + <div data-testid="pie-chart">{percentage}%</div> ) return Component }, @@ -70,9 +67,7 @@ describe('LocalFile', () => { describe('rendering', () => { it('should render the component container', () => { - const { container } = render( - <LocalFile allowedExtensions={['pdf', 'docx']} />, - ) + const { container } = render(<LocalFile allowedExtensions={['pdf', 'docx']} />) expect(container.firstChild).toHaveClass('flex', 'flex-col') }) @@ -129,12 +124,13 @@ describe('LocalFile', () => { }) it('should render multiple file items', () => { - const createMockFile = (name: string) => ({ - name, - size: 1024, - type: 'application/pdf', - lastModified: Date.now(), - }) as File + const createMockFile = (name: string) => + ({ + name, + size: 1024, + type: 'application/pdf', + lastModified: Date.now(), + }) as File mockUseLocalFileUpload.mockReturnValue({ ...defaultHookReturn, @@ -161,9 +157,7 @@ describe('LocalFile', () => { mockUseLocalFileUpload.mockReturnValue({ ...defaultHookReturn, - localFileList: [ - { fileID: 'unique-id-123', file: mockFile, progress: -1 }, - ], + localFileList: [{ fileID: 'unique-id-123', file: mockFile, progress: -1 }], }) render(<LocalFile allowedExtensions={['pdf']} />) @@ -243,9 +237,7 @@ describe('LocalFile', () => { ...defaultHookReturn, handlePreview, removeFile, - localFileList: [ - { fileID: 'test-id', file: mockFile, progress: 50 }, - ], + localFileList: [{ fileID: 'test-id', file: mockFile, progress: 50 }], }) render(<LocalFile allowedExtensions={['pdf']} />) @@ -266,9 +258,7 @@ describe('LocalFile', () => { mockUseLocalFileUpload.mockReturnValue({ ...defaultHookReturn, hideUpload: false, - localFileList: [ - { fileID: 'file-1', file: mockFile, progress: -1 }, - ], + localFileList: [{ fileID: 'file-1', file: mockFile, progress: -1 }], }) render(<LocalFile allowedExtensions={['pdf']} />) @@ -288,9 +278,7 @@ describe('LocalFile', () => { mockUseLocalFileUpload.mockReturnValue({ ...defaultHookReturn, hideUpload: true, - localFileList: [ - { fileID: 'file-1', file: mockFile, progress: -1 }, - ], + localFileList: [{ fileID: 'file-1', file: mockFile, progress: -1 }], }) render(<LocalFile allowedExtensions={['pdf']} />) @@ -311,9 +299,7 @@ describe('LocalFile', () => { mockUseLocalFileUpload.mockReturnValue({ ...defaultHookReturn, - localFileList: [ - { fileID: 'file-1', file: mockFile, progress: -1 }, - ], + localFileList: [{ fileID: 'file-1', file: mockFile, progress: -1 }], }) const { container } = render(<LocalFile allowedExtensions={['pdf']} />) @@ -369,9 +355,7 @@ describe('LocalFile', () => { mockUseLocalFileUpload.mockReturnValue({ ...defaultHookReturn, hideUpload: false, - localFileList: [ - { fileID: 'file-1', file: mockFile, progress: 50 }, - ], + localFileList: [{ fileID: 'file-1', file: mockFile, progress: 50 }], dragging: false, }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx index 789064e163a480..7021a21f022bca 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx @@ -19,12 +19,22 @@ vi.mock('@/types/app', () => ({ // Mock SimplePieChart with dynamic import handling vi.mock('@/next/dynamic', () => ({ default: () => { - const DynamicComponent = ({ percentage, stroke, fill }: { percentage: number, stroke: string, fill: string }) => ( - <div data-testid="pie-chart" data-percentage={percentage} data-stroke={stroke} data-fill={fill}> - Pie Chart: - {' '} - {percentage} - % + const DynamicComponent = ({ + percentage, + stroke, + fill, + }: { + percentage: number + stroke: string + fill: string + }) => ( + <div + data-testid="pie-chart" + data-percentage={percentage} + data-stroke={stroke} + data-fill={fill} + > + Pie Chart: {percentage}% </div> ) DynamicComponent.displayName = 'SimplePieChart' @@ -34,7 +44,7 @@ vi.mock('@/next/dynamic', () => ({ // Mock DocumentFileIcon vi.mock('@/app/components/datasets/common/document-file-icon', () => ({ - default: ({ name, extension, size }: { name: string, extension: string, size: string }) => ( + default: ({ name, extension, size }: { name: string; extension: string; size: string }) => ( <div data-testid="document-icon" data-name={name} data-extension={extension} data-size={size}> Document Icon </div> @@ -42,13 +52,14 @@ vi.mock('@/app/components/datasets/common/document-file-icon', () => ({ })) describe('FileListItem', () => { - const createMockFile = (overrides: Partial<File> = {}): File => ({ - name: 'test-document.pdf', - size: 1024 * 100, // 100KB - type: 'application/pdf', - lastModified: Date.now(), - ...overrides, - } as File) + const createMockFile = (overrides: Partial<File> = {}): File => + ({ + name: 'test-document.pdf', + size: 1024 * 100, // 100KB + type: 'application/pdf', + lastModified: Date.now(), + ...overrides, + }) as File const createMockFileItem = (overrides: Partial<FileItem> = {}): FileItem => ({ fileID: 'file-123', @@ -204,7 +215,9 @@ describe('FileListItem', () => { it('should call onRemove when delete button is clicked', () => { const onRemove = vi.fn() const fileItem = createMockFileItem() - const { container } = render(<FileListItem {...defaultProps} fileItem={fileItem} onRemove={onRemove} />) + const { container } = render( + <FileListItem {...defaultProps} fileItem={fileItem} onRemove={onRemove} />, + ) const deleteButton = container.querySelector('.cursor-pointer')! fireEvent.click(deleteButton) @@ -216,7 +229,9 @@ describe('FileListItem', () => { it('should stop propagation when delete button is clicked', () => { const onPreview = vi.fn() const onRemove = vi.fn() - const { container } = render(<FileListItem {...defaultProps} onPreview={onPreview} onRemove={onRemove} />) + const { container } = render( + <FileListItem {...defaultProps} onPreview={onPreview} onRemove={onRemove} />, + ) const deleteButton = container.querySelector('.cursor-pointer')! fireEvent.click(deleteButton) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx index 3ade486474ddb9..003502b9c7cf30 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx @@ -8,8 +8,9 @@ import UploadDropzone from '../upload-dropzone' let mockEnableBilling = false vi.mock('@/context/provider-context', () => ({ - useProviderContextSelector: <T,>(selector: (state: Pick<ProviderContextState, 'enableBilling'>) => T): T => - selector({ enableBilling: mockEnableBilling }), + useProviderContextSelector: <T,>( + selector: (state: Pick<ProviderContextState, 'enableBilling'>) => T, + ): T => selector({ enableBilling: mockEnableBilling }), })) // Helper to create mock ref objects for testing @@ -88,9 +89,13 @@ describe('UploadDropzone', () => { render(<UploadDropzone {...defaultProps} />) - const tipWithoutTotal = screen.getByText(/datasetCreation\.stepOne\.uploader\.tip(?!WithTotalLimit)/) + const tipWithoutTotal = screen.getByText( + /datasetCreation\.stepOne\.uploader\.tip(?!WithTotalLimit)/, + ) expect(tipWithoutTotal).toBeInTheDocument() - expect(screen.queryByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/)).not.toBeInTheDocument() + expect( + screen.queryByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/), + ).not.toBeInTheDocument() }) it('should render tip with total count limit when billing is enabled', () => { @@ -98,8 +103,12 @@ describe('UploadDropzone', () => { render(<UploadDropzone {...defaultProps} />) - expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/)).toBeInTheDocument() - expect(screen.queryByText(/datasetCreation\.stepOne\.uploader\.tip(?!WithTotalLimit)/)).not.toBeInTheDocument() + expect( + screen.getByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/), + ).toBeInTheDocument() + expect( + screen.queryByText(/datasetCreation\.stepOne\.uploader\.tip(?!WithTotalLimit)/), + ).not.toBeInTheDocument() }) it('should pass file size, batch count and supported types to tip when billing is disabled', () => { @@ -119,7 +128,8 @@ describe('UploadDropzone', () => { render(<UploadDropzone {...defaultProps} />) - const tipText = screen.getByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/).textContent ?? '' + const tipText = + screen.getByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/).textContent ?? '' expect(tipText).toContain('"size":15') expect(tipText).toContain('"batchCount":5') expect(tipText).toContain('"supportTypes":"PDF, DOCX, TXT"') @@ -160,7 +170,9 @@ describe('UploadDropzone', () => { it('should show single file text when supportBatchUpload is false', () => { render(<UploadDropzone {...defaultProps} supportBatchUpload={false} />) - expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/)).toBeInTheDocument() + expect( + screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/), + ).toBeInTheDocument() }) }) @@ -168,13 +180,21 @@ describe('UploadDropzone', () => { it('should apply dragging styles when dragging is true', () => { const { container } = render(<UploadDropzone {...defaultProps} dragging={true} />) - const dropzone = container.querySelector('[class*="border-components-dropzone-border-accent"]') + const dropzone = container.querySelector( + '[class*="border-components-dropzone-border-accent"]', + ) expect(dropzone).toBeInTheDocument() }) it('should render drag overlay when dragging', () => { const dragRef = createMockRef<HTMLDivElement>() - render(<UploadDropzone {...defaultProps} dragging={true} dragRef={dragRef as RefObject<HTMLDivElement | null>} />) + render( + <UploadDropzone + {...defaultProps} + dragging={true} + dragRef={dragRef as RefObject<HTMLDivElement | null>} + />, + ) const overlay = document.querySelector('.absolute.left-0.top-0') expect(overlay).toBeInTheDocument() @@ -215,21 +235,34 @@ describe('UploadDropzone', () => { describe('refs', () => { it('should attach dropRef to drop container', () => { const dropRef = createMockRef<HTMLDivElement>() - render(<UploadDropzone {...defaultProps} dropRef={dropRef as RefObject<HTMLDivElement | null>} />) + render( + <UploadDropzone {...defaultProps} dropRef={dropRef as RefObject<HTMLDivElement | null>} />, + ) expect(dropRef.current).toBeInstanceOf(HTMLDivElement) }) it('should attach fileUploaderRef to input element', () => { const fileUploaderRef = createMockRef<HTMLInputElement>() - render(<UploadDropzone {...defaultProps} fileUploaderRef={fileUploaderRef as RefObject<HTMLInputElement | null>} />) + render( + <UploadDropzone + {...defaultProps} + fileUploaderRef={fileUploaderRef as RefObject<HTMLInputElement | null>} + />, + ) expect(fileUploaderRef.current).toBeInstanceOf(HTMLInputElement) }) it('should attach dragRef to overlay when dragging', () => { const dragRef = createMockRef<HTMLDivElement>() - render(<UploadDropzone {...defaultProps} dragging={true} dragRef={dragRef as RefObject<HTMLDivElement | null>} />) + render( + <UploadDropzone + {...defaultProps} + dragging={true} + dragRef={dragRef as RefObject<HTMLDivElement | null>} + />, + ) expect(dragRef.current).toBeInstanceOf(HTMLDivElement) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.tsx index 8430539e14363c..6f55f97194c05b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.tsx @@ -10,7 +10,9 @@ import { Theme } from '@/types/app' import { formatFileSize } from '@/utils/format' import { PROGRESS_ERROR } from '../constants' -const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { ssr: false }) +const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { + ssr: false, +}) export type FileListItemProps = { fileItem: FileItem @@ -18,13 +20,9 @@ export type FileListItemProps = { onRemove: (fileID: string) => void } -const FileListItem = ({ - fileItem, - onPreview, - onRemove, -}: FileListItemProps) => { +const FileListItem = ({ fileItem, onPreview, onRemove }: FileListItemProps) => { const { theme } = useTheme() - const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme]) + const chartColor = useMemo(() => (theme === Theme.dark ? '#5289ff' : '#296dff'), [theme]) const isUploading = fileItem.progress >= 0 && fileItem.progress < 100 const isError = fileItem.progress === PROGRESS_ERROR @@ -66,11 +64,14 @@ const FileListItem = ({ </div> <div className="flex w-16 shrink-0 items-center justify-end gap-1 pr-3"> {isUploading && ( - <SimplePieChart percentage={fileItem.progress} stroke={chartColor} fill={chartColor} animationDuration={0} /> - )} - {isError && ( - <RiErrorWarningFill className="size-4 text-text-destructive" /> + <SimplePieChart + percentage={fileItem.progress} + stroke={chartColor} + fill={chartColor} + animationDuration={0} + /> )} + {isError && <RiErrorWarningFill className="size-4 text-text-destructive" />} <span className="flex size-6 cursor-pointer items-center justify-center" onClick={handleRemove} diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx index 476ea97ae2212f..a58cf8f0c9936b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx @@ -37,7 +37,7 @@ const UploadDropzone = ({ allowedExtensions, }: UploadDropzoneProps) => { const { t } = useTranslation() - const enableBilling = useProviderContextSelector(state => state.enableBilling) + const enableBilling = useProviderContextSelector((state) => state.enableBilling) return ( <> @@ -60,22 +60,26 @@ const UploadDropzone = ({ <div className="flex min-h-5 items-center justify-center text-sm/4 text-text-secondary"> <span className="mr-2 i-ri-upload-cloud-2-line size-5" /> <span> - {supportBatchUpload ? t($ => $['stepOne.uploader.button'], { ns: 'datasetCreation' }) : t($ => $['stepOne.uploader.buttonSingleFile'], { ns: 'datasetCreation' })} + {supportBatchUpload + ? t(($) => $['stepOne.uploader.button'], { ns: 'datasetCreation' }) + : t(($) => $['stepOne.uploader.buttonSingleFile'], { ns: 'datasetCreation' })} {allowedExtensions.length > 0 && ( - <label className="ml-1 cursor-pointer text-text-accent" onClick={onSelectFile}>{t($ => $['stepOne.uploader.browse'], { ns: 'datasetCreation' })}</label> + <label className="ml-1 cursor-pointer text-text-accent" onClick={onSelectFile}> + {t(($) => $['stepOne.uploader.browse'], { ns: 'datasetCreation' })} + </label> )} </span> </div> <div> {enableBilling - ? t($ => $['stepOne.uploader.tipWithTotalLimit'], { + ? t(($) => $['stepOne.uploader.tipWithTotalLimit'], { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit, supportTypes: supportTypesShowNames, batchCount: fileUploadConfig.batch_count_limit, totalCount: fileUploadConfig.file_upload_limit, }) - : t($ => $['stepOne.uploader.tip'], { + : t(($) => $['stepOne.uploader.tip'], { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit, supportTypes: supportTypesShowNames, diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx index 72a0d72db0240e..da2411a885d890 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx @@ -53,8 +53,9 @@ const mockGetState = vi.fn(() => ({ const mockStore = { getState: mockGetState } vi.mock('../../../store', () => ({ - useDataSourceStoreWithSelector: vi.fn((selector: (state: { localFileList: FileItem[] }) => FileItem[]) => - selector({ localFileList: [] }), + useDataSourceStoreWithSelector: vi.fn( + (selector: (state: { localFileList: FileItem[] }) => FileItem[]) => + selector({ localFileList: [] }), ), useDataSourceStore: vi.fn(() => mockStore), })) @@ -86,11 +87,7 @@ vi.mock('@/service/base', () => ({ const { useLocalFileUpload } = await import('../use-local-file-upload') const createWrapper = () => { - return ({ children }: { children: ReactNode }) => ( - <> - {children} - </> - ) + return ({ children }: { children: ReactNode }) => <>{children}</> } describe('useLocalFileUpload', () => { @@ -112,10 +109,9 @@ describe('useLocalFileUpload', () => { }) it('should create refs for dropzone, drag area, and file uploader', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) expect(result.current.dropRef).toBeDefined() expect(result.current.dragRef).toBeDefined() @@ -143,10 +139,9 @@ describe('useLocalFileUpload', () => { }) it('should provide file upload config with defaults', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) expect(result.current.fileUploadConfig.file_size_limit).toBe(15) expect(result.current.fileUploadConfig.batch_count_limit).toBe(5) @@ -178,10 +173,9 @@ describe('useLocalFileUpload', () => { describe('selectHandle', () => { it('should trigger file input click', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const mockClick = vi.fn() const mockInput = { click: mockClick } as unknown as HTMLInputElement @@ -198,10 +192,9 @@ describe('useLocalFileUpload', () => { }) it('should handle null fileUploaderRef gracefully', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) expect(() => { act(() => { @@ -213,10 +206,9 @@ describe('useLocalFileUpload', () => { describe('removeFile', () => { it('should remove file from list', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) act(() => { result.current.removeFile('file-id-123') @@ -226,10 +218,9 @@ describe('useLocalFileUpload', () => { }) it('should clear file input value when removing', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const mockInput = { value: 'some-file.pdf' } as HTMLInputElement Object.defineProperty(result.current.fileUploaderRef, 'current', { @@ -247,10 +238,9 @@ describe('useLocalFileUpload', () => { describe('handlePreview', () => { it('should set current local file when file has id', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const mockFile = { id: 'file-123', name: 'test.pdf', size: 1024 } @@ -262,10 +252,9 @@ describe('useLocalFileUpload', () => { }) it('should not set current file when file has no id', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const mockFile = { name: 'test.pdf', size: 1024 } @@ -281,10 +270,9 @@ describe('useLocalFileUpload', () => { it('should handle valid files', async () => { mockUpload.mockResolvedValue({ id: 'uploaded-id' }) - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { @@ -303,10 +291,9 @@ describe('useLocalFileUpload', () => { }) it('should handle empty file list', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const event = { target: { @@ -322,10 +309,9 @@ describe('useLocalFileUpload', () => { }) it('should reject files with invalid type', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.exe', { type: 'application/exe' }) const event = { @@ -338,16 +324,13 @@ describe('useLocalFileUpload', () => { result.current.fileChangeHandle(event) }) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should reject files exceeding size limit', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) // Create a mock file larger than 15MB const largeSize = 20 * 1024 * 1024 @@ -364,22 +347,21 @@ describe('useLocalFileUpload', () => { result.current.fileChangeHandle(event) }) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should limit files to batch count limit', async () => { mockUpload.mockResolvedValue({ id: 'uploaded-id' }) - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) // Create 10 files but batch limit is 5 - const files = Array.from({ length: 10 }, (_, i) => - new File(['content'], `file${i}.pdf`, { type: 'application/pdf' })) + const files = Array.from( + { length: 10 }, + (_, i) => new File(['content'], `file${i}.pdf`, { type: 'application/pdf' }), + ) const event = { target: { @@ -406,10 +388,9 @@ describe('useLocalFileUpload', () => { const uploadedResponse = { id: 'server-file-id' } mockUpload.mockResolvedValue(uploadedResponse) - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { @@ -430,10 +411,9 @@ describe('useLocalFileUpload', () => { it('should handle upload error', async () => { mockUpload.mockRejectedValue(new Error('Upload failed')) - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { @@ -447,19 +427,16 @@ describe('useLocalFileUpload', () => { }) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) }) it('should call upload with correct parameters', async () => { mockUpload.mockResolvedValue({ id: 'file-id' }) - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { @@ -488,19 +465,17 @@ describe('useLocalFileUpload', () => { describe('extension mapping', () => { it('should map md to markdown', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['md'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['md'] }), { + wrapper: createWrapper(), + }) expect(result.current.supportTypesShowNames).toContain('MARKDOWN') }) it('should map htm to html', () => { - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['htm'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['htm'] }), { + wrapper: createWrapper(), + }) expect(result.current.supportTypesShowNames).toContain('HTML') }) @@ -528,15 +503,17 @@ describe('useLocalFileUpload', () => { describe('drag and drop handlers', () => { // Helper component that renders with the hook and connects refs - const TestDropzone = ({ allowedExtensions, supportBatchUpload = true }: { + const TestDropzone = ({ + allowedExtensions, + supportBatchUpload = true, + }: { allowedExtensions: string[] supportBatchUpload?: boolean }) => { - const { - dropRef, - dragRef, - dragging, - } = useLocalFileUpload({ allowedExtensions, supportBatchUpload }) + const { dropRef, dragRef, dragging } = useLocalFileUpload({ + allowedExtensions, + supportBatchUpload, + }) return ( <div> @@ -634,14 +611,16 @@ describe('useLocalFileUpload', () => { await act(async () => { const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { - dataTransfer: { items: DataTransferItem[], files: File[] } | null + dataTransfer: { items: DataTransferItem[]; files: File[] } | null } // Mock dataTransfer with items array (used by the shared hook for directory traversal) dropEvent.dataTransfer = { - items: [{ - kind: 'file', - getAsFile: () => mockFile, - }] as unknown as DataTransferItem[], + items: [ + { + kind: 'file', + getAsFile: () => mockFile, + }, + ] as unknown as DataTransferItem[], files: [mockFile], } dropzone.dispatchEvent(dropEvent) @@ -665,7 +644,9 @@ describe('useLocalFileUpload', () => { mockSetLocalFileList.mockClear() await act(async () => { - const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: { files: File[] } | null } + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { + dataTransfer: { files: File[] } | null + } dropEvent.dataTransfer = null dropzone.dispatchEvent(dropEvent) }) @@ -693,11 +674,11 @@ describe('useLocalFileUpload', () => { await act(async () => { const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { - dataTransfer: { items: DataTransferItem[], files: File[] } | null + dataTransfer: { items: DataTransferItem[]; files: File[] } | null } // Mock dataTransfer with items array (used by the shared hook for directory traversal) dropEvent.dataTransfer = { - items: files.map(f => ({ + items: files.map((f) => ({ kind: 'file', getAsFile: () => f, })) as unknown as DataTransferItem[], @@ -724,18 +705,19 @@ describe('useLocalFileUpload', () => { file: { name: `existing-${i}.pdf`, size: 1024 } as CustomFile, progress: 100, })) - vi.mocked(useDataSourceStoreWithSelector).mockImplementation(selector => + vi.mocked(useDataSourceStoreWithSelector).mockImplementation((selector) => selector({ localFileList: existingFiles } as Parameters<typeof selector>[0]), ) - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) // Try to add 5 more files when limit is 10 and we already have 8 - const files = Array.from({ length: 5 }, (_, i) => - new File(['content'], `new-${i}.pdf`, { type: 'application/pdf' })) + const files = Array.from( + { length: 5 }, + (_, i) => new File(['content'], `new-${i}.pdf`, { type: 'application/pdf' }), + ) const event = { target: { files }, @@ -746,12 +728,10 @@ describe('useLocalFileUpload', () => { }) // Should show error about files number limit - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) // Reset mock for other tests - vi.mocked(useDataSourceStoreWithSelector).mockImplementation(selector => + vi.mocked(useDataSourceStoreWithSelector).mockImplementation((selector) => selector({ localFileList: [] as FileItem[] } as Parameters<typeof selector>[0]), ) }) @@ -766,10 +746,9 @@ describe('useLocalFileUpload', () => { return { id: 'uploaded-id' } }) - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { @@ -808,10 +787,9 @@ describe('useLocalFileUpload', () => { return { id: 'uploaded-id' } }) - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { @@ -850,10 +828,9 @@ describe('useLocalFileUpload', () => { it('should set PROGRESS_ERROR on upload failure', async () => { mockUpload.mockRejectedValue(new Error('Upload failed')) - const { result } = renderHook( - () => useLocalFileUpload({ allowedExtensions: ['pdf'] }), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useLocalFileUpload({ allowedExtensions: ['pdf'] }), { + wrapper: createWrapper(), + }) const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) const event = { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts index 667010f661d103..239850c4ed0bd9 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts @@ -18,44 +18,56 @@ export const useLocalFileUpload = ({ allowedExtensions, supportBatchUpload = true, }: UseLocalFileUploadOptions) => { - const localFileList = useDataSourceStoreWithSelector(state => state.localFileList) + const localFileList = useDataSourceStoreWithSelector((state) => state.localFileList) const dataSourceStore = useDataSourceStore() const fileListRef = useRef<FileItem[]>([]) // Sync fileListRef with localFileList for internal tracking fileListRef.current = localFileList - const prepareFileList = useCallback((files: FileItem[]) => { - const { setLocalFileList } = dataSourceStore.getState() - setLocalFileList(files) - fileListRef.current = files - }, [dataSourceStore]) + const prepareFileList = useCallback( + (files: FileItem[]) => { + const { setLocalFileList } = dataSourceStore.getState() + setLocalFileList(files) + fileListRef.current = files + }, + [dataSourceStore], + ) - const onFileUpdate = useCallback((fileItem: FileItem, progress: number, list: FileItem[]) => { - const { setLocalFileList } = dataSourceStore.getState() - const newList = produce(list, (draft) => { - const targetIndex = draft.findIndex(file => file.fileID === fileItem.fileID) - if (targetIndex !== -1) { - draft[targetIndex] = { - ...draft[targetIndex], - ...fileItem, - progress, + const onFileUpdate = useCallback( + (fileItem: FileItem, progress: number, list: FileItem[]) => { + const { setLocalFileList } = dataSourceStore.getState() + const newList = produce(list, (draft) => { + const targetIndex = draft.findIndex((file) => file.fileID === fileItem.fileID) + if (targetIndex !== -1) { + draft[targetIndex] = { + ...draft[targetIndex], + ...fileItem, + progress, + } } - } - }) - setLocalFileList(newList) - }, [dataSourceStore]) + }) + setLocalFileList(newList) + }, + [dataSourceStore], + ) - const onFileListUpdate = useCallback((files: FileItem[]) => { - const { setLocalFileList } = dataSourceStore.getState() - setLocalFileList(files) - fileListRef.current = files - }, [dataSourceStore]) + const onFileListUpdate = useCallback( + (files: FileItem[]) => { + const { setLocalFileList } = dataSourceStore.getState() + setLocalFileList(files) + fileListRef.current = files + }, + [dataSourceStore], + ) - const onPreview = useCallback((file: File) => { - const { setCurrentLocalFile } = dataSourceStore.getState() - setCurrentLocalFile(file) - }, [dataSourceStore]) + const onPreview = useCallback( + (file: File) => { + const { setCurrentLocalFile } = dataSourceStore.getState() + setCurrentLocalFile(file) + }, + [dataSourceStore], + ) const { dropRef, diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx index 93a48f6be7d6e9..5955908b274c1f 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx @@ -8,10 +8,7 @@ type LocalFileProps = { supportBatchUpload?: boolean } -const LocalFile = ({ - allowedExtensions, - supportBatchUpload = true, -}: LocalFileProps) => { +const LocalFile = ({ allowedExtensions, supportBatchUpload = true }: LocalFileProps) => { const { dropRef, dragRef, diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx index d01bb13e586a36..60626ec79c0919 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx @@ -14,7 +14,8 @@ vi.mock('@/context/i18n', () => ({ // Mock dataset-detail context - context provider requires mocking let mockPipelineId = 'pipeline-123' vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (s: Record<string, unknown>) => unknown) => selector({ dataset: { pipeline_id: mockPipelineId } }), + useDatasetDetailContextWithSelector: (selector: (s: Record<string, unknown>) => unknown) => + selector({ dataset: { pipeline_id: mockPipelineId } }), })) // Mock modal context - context provider requires mocking @@ -23,7 +24,8 @@ vi.mock('@/context/modal-context', () => ({ useModalContext: () => ({ setShowAccountSettingModal: mockSetShowAccountSettingModal, }), - useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), + useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) => + selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), })) // Mock ssePost - API service requires mocking @@ -78,7 +80,8 @@ const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } vi.mock('../../store', () => ({ - useDataSourceStoreWithSelector: (selector: (s: Record<string, unknown>) => unknown) => selector(mockStoreState as unknown as Record<string, unknown>), + useDataSourceStoreWithSelector: (selector: (s: Record<string, unknown>) => unknown) => + selector(mockStoreState as unknown as Record<string, unknown>), useDataSourceStore: () => mockDataSourceStore, })) @@ -90,21 +93,33 @@ vi.mock('../../base/header', () => ({ <span data-testid="header-doc-link">{props.docLink as string}</span> <span data-testid="header-plugin-name">{props.pluginName as string}</span> <span data-testid="header-credential-id">{props.currentCredentialId as string}</span> - <button data-testid="header-config-btn" onClick={props.onClickConfiguration as React.MouseEventHandler}>Configure</button> - <button data-testid="header-credential-change" onClick={() => (props.onCredentialChange as (id: string) => void)('new-cred-id')}>Change Credential</button> - <span data-testid="header-credentials-count">{(props.credentials as unknown[] | undefined)?.length || 0}</span> + <button + data-testid="header-config-btn" + onClick={props.onClickConfiguration as React.MouseEventHandler} + > + Configure + </button> + <button + data-testid="header-credential-change" + onClick={() => (props.onCredentialChange as (id: string) => void)('new-cred-id')} + > + Change Credential + </button> + <span data-testid="header-credentials-count"> + {(props.credentials as unknown[] | undefined)?.length || 0} + </span> </div> ), })) // Mock SearchInput component vi.mock('@/app/components/base/notion-page-selector/search-input', () => ({ - default: ({ value, onChange }: { value: string, onChange: (v: string) => void }) => ( + default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => ( <div data-testid="search-input"> <input data-testid="search-input-field" value={value} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} placeholder="Search" /> </div> @@ -115,14 +130,18 @@ vi.mock('@/app/components/base/notion-page-selector/search-input', () => ({ vi.mock('../page-selector', () => ({ default: (props: Record<string, unknown>) => ( <div data-testid="page-selector"> - <span data-testid="page-selector-checked-count">{(props.checkedIds as Set<string> | undefined)?.size || 0}</span> + <span data-testid="page-selector-checked-count"> + {(props.checkedIds as Set<string> | undefined)?.size || 0} + </span> <span data-testid="page-selector-search-value">{props.searchValue as string}</span> <span data-testid="page-selector-can-preview">{String(props.canPreview)}</span> <span data-testid="page-selector-multiple-choice">{String(props.isMultipleChoice)}</span> <span data-testid="page-selector-credential-id">{props.currentCredentialId as string}</span> <button data-testid="page-selector-select-btn" - onClick={() => (props.onSelect as (ids: Set<string>) => void)(new Set(['page-1', 'page-2']))} + onClick={() => + (props.onSelect as (ids: Set<string>) => void)(new Set(['page-1', 'page-2'])) + } > Select Pages </button> @@ -145,17 +164,18 @@ vi.mock('../title', () => ({ ), })) -const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ - title: 'Test Node', - plugin_id: 'plugin-123', - provider_type: 'notion', - provider_name: 'notion-provider', - datasource_name: 'notion-ds', - datasource_label: 'Notion', - datasource_parameters: {}, - datasource_configurations: {}, - ...overrides, -} as DataSourceNodeType) +const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => + ({ + title: 'Test Node', + plugin_id: 'plugin-123', + provider_type: 'notion', + provider_name: 'notion-provider', + datasource_name: 'notion-ds', + datasource_label: 'Notion', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, + }) as DataSourceNodeType const createMockPage = (overrides?: Partial<NotionPage>): NotionPage => ({ page_id: 'page-1', @@ -168,7 +188,9 @@ const createMockPage = (overrides?: Partial<NotionPage>): NotionPage => ({ ...overrides, }) -const createMockWorkspace = (overrides?: Partial<DataSourceNotionWorkspace>): DataSourceNotionWorkspace => ({ +const createMockWorkspace = ( + overrides?: Partial<DataSourceNotionWorkspace>, +): DataSourceNotionWorkspace => ({ workspace_id: 'workspace-1', workspace_name: 'Test Workspace', workspace_icon: null, @@ -176,7 +198,7 @@ const createMockWorkspace = (overrides?: Partial<DataSourceNotionWorkspace>): Da ...overrides, }) -const createMockCredential = (overrides?: Partial<{ id: string, name: string }>) => ({ +const createMockCredential = (overrides?: Partial<{ id: string; name: string }>) => ({ id: 'cred-1', name: 'Test Credential', avatar_url: 'https://example.com/avatar.png', @@ -582,9 +604,7 @@ describe('OnlineDocuments', () => { }) it('should have stable handlePreviewPage that updates store', () => { - const mockPages = [ - createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), - ] + const mockPages = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] mockStoreState.documentsData = [createMockWorkspace({ pages: mockPages })] const props = createDefaultProps() render(<OnlineDocuments {...props} />) @@ -899,7 +919,10 @@ describe('OnlineDocuments', () => { const nodeData = createMockNodeData({ datasource_parameters: { // Object without 'value' key - should use the object itself - objWithoutValue: { type: VarKindType.constant, other: 'data' } as Record<string, unknown> & { type: VarKindType }, + objWithoutValue: { type: VarKindType.constant, other: 'data' } as Record< + string, + unknown + > & { type: VarKindType }, }, }) const props = createDefaultProps({ nodeData }) @@ -912,7 +935,10 @@ describe('OnlineDocuments', () => { expect.objectContaining({ body: expect.objectContaining({ inputs: expect.objectContaining({ - objWithoutValue: expect.objectContaining({ type: VarKindType.constant, other: 'data' }), + objWithoutValue: expect.objectContaining({ + type: VarKindType.constant, + other: 'data', + }), }), }), }), @@ -922,8 +948,14 @@ describe('OnlineDocuments', () => { it('should handle multiple workspaces in documentsData', () => { mockStoreState.documentsData = [ - createMockWorkspace({ workspace_id: 'ws-1', pages: [createMockPage({ page_id: 'page-1' })] }), - createMockWorkspace({ workspace_id: 'ws-2', pages: [createMockPage({ page_id: 'page-2' })] }), + createMockWorkspace({ + workspace_id: 'ws-1', + pages: [createMockPage({ page_id: 'page-1' })], + }), + createMockWorkspace({ + workspace_id: 'ws-2', + pages: [createMockPage({ page_id: 'page-2' })], + }), ] const props = createDefaultProps() @@ -940,7 +972,9 @@ describe('OnlineDocuments', () => { const searchInput = screen.getByTestId('search-input-field') fireEvent.change(searchInput, { target: { value: 'test<script>alert("xss")</script>' } }) - expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('test<script>alert("xss")</script>') + expect(mockStoreState.setSearchValue).toHaveBeenCalledWith( + 'test<script>alert("xss")</script>', + ) }) it('should handle unicode characters in searchValue', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/title.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/title.spec.tsx index 3f0d7efb24f458..8985efca6cb5f5 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/title.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/title.spec.tsx @@ -5,6 +5,10 @@ import Title from '../title' describe('OnlineDocumentTitle', () => { it('should render title with name prop', () => { render(<Title name="Notion Workspace" />) - expect(screen.getByText('datasetPipeline.onlineDocument.pageSelectorTitle:{"name":"Notion Workspace"}')).toBeInTheDocument() + expect( + screen.getByText( + 'datasetPipeline.onlineDocument.pageSelectorTitle:{"name":"Notion Workspace"}', + ), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx index e258a8368ea3f4..7c78c4d391c7e9 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx @@ -34,19 +34,17 @@ const OnlineDocuments = ({ onCredentialChange, }: OnlineDocumentsProps) => { const docLink = useDocLink() - const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id) + const pipelineId = useDatasetDetailContextWithSelector((s) => s.dataset?.pipeline_id) const openIntegrationsSetting = useIntegrationsSetting() - const { - documentsData, - searchValue, - selectedPagesId, - currentCredentialId, - } = useDataSourceStoreWithSelector(useShallow(state => ({ - documentsData: state.documentsData, - searchValue: state.searchValue, - selectedPagesId: state.selectedPagesId, - currentCredentialId: state.currentCredentialId, - }))) + const { documentsData, searchValue, selectedPagesId, currentCredentialId } = + useDataSourceStoreWithSelector( + useShallow((state) => ({ + documentsData: state.documentsData, + searchValue: state.searchValue, + selectedPagesId: state.selectedPagesId, + currentCredentialId: state.currentCredentialId, + })), + ) const { data: dataSourceAuth } = useGetDataSourceAuth({ pluginId: nodeData.plugin_id, @@ -56,16 +54,19 @@ const OnlineDocuments = ({ const dataSourceStore = useDataSourceStore() const PagesMapAndSelectedPagesId: DataSourceNotionPageMap = useMemo(() => { - const pagesMap = (documentsData || []).reduce((prev: DataSourceNotionPageMap, next: DataSourceNotionWorkspace) => { - next.pages.forEach((page) => { - prev[page.page_id] = { - ...page, - workspace_id: next.workspace_id, - } - }) - - return prev - }, {}) + const pagesMap = (documentsData || []).reduce( + (prev: DataSourceNotionPageMap, next: DataSourceNotionWorkspace) => { + next.pages.forEach((page) => { + prev[page.page_id] = { + ...page, + workspace_id: next.workspace_id, + } + }) + + return prev + }, + {}, + ) return pagesMap }, [documentsData]) @@ -76,10 +77,14 @@ const OnlineDocuments = ({ const getOnlineDocuments = useCallback(async () => { const { currentCredentialId } = dataSourceStore.getState() // Convert datasource_parameters to inputs format for the API - const inputs = Object.entries(nodeData.datasource_parameters || {}).reduce((acc, [key, value]) => { - acc[key] = typeof value === 'object' && value !== null && 'value' in value ? value.value : value - return acc - }, {} as Record<string, any>) + const inputs = Object.entries(nodeData.datasource_parameters || {}).reduce( + (acc, [key, value]) => { + acc[key] = + typeof value === 'object' && value !== null && 'value' in value ? value.value : value + return acc + }, + {} as Record<string, any>, + ) ssePost( datasourceNodeRunURL, @@ -103,27 +108,37 @@ const OnlineDocuments = ({ }, [dataSourceStore, datasourceNodeRunURL, nodeData.datasource_parameters]) useEffect(() => { - if (!currentCredentialId) - return + if (!currentCredentialId) return getOnlineDocuments() }, [currentCredentialId, getOnlineDocuments]) - const handleSearchValueChange = useCallback((value: string) => { - const { setSearchValue } = dataSourceStore.getState() - setSearchValue(value) - }, [dataSourceStore]) + const handleSearchValueChange = useCallback( + (value: string) => { + const { setSearchValue } = dataSourceStore.getState() + setSearchValue(value) + }, + [dataSourceStore], + ) - const handleSelectPages = useCallback((newSelectedPagesId: Set<string>) => { - const { setSelectedPagesId, setOnlineDocuments } = dataSourceStore.getState() - const selectedPages = Array.from(newSelectedPagesId).map(pageId => PagesMapAndSelectedPagesId[pageId]!) - setSelectedPagesId(new Set(Array.from(newSelectedPagesId))) - setOnlineDocuments(selectedPages) - }, [dataSourceStore, PagesMapAndSelectedPagesId]) + const handleSelectPages = useCallback( + (newSelectedPagesId: Set<string>) => { + const { setSelectedPagesId, setOnlineDocuments } = dataSourceStore.getState() + const selectedPages = Array.from(newSelectedPagesId).map( + (pageId) => PagesMapAndSelectedPagesId[pageId]!, + ) + setSelectedPagesId(new Set(Array.from(newSelectedPagesId))) + setOnlineDocuments(selectedPages) + }, + [dataSourceStore, PagesMapAndSelectedPagesId], + ) - const handlePreviewPage = useCallback((previewPageId: string) => { - const { setCurrentDocument } = dataSourceStore.getState() - setCurrentDocument(PagesMapAndSelectedPagesId[previewPageId]) - }, [PagesMapAndSelectedPagesId, dataSourceStore]) + const handlePreviewPage = useCallback( + (previewPageId: string) => { + const { setCurrentDocument } = dataSourceStore.getState() + setCurrentDocument(PagesMapAndSelectedPagesId[previewPageId]) + }, + [PagesMapAndSelectedPagesId, dataSourceStore], + ) const handleSetting = useCallback(() => { openIntegrationsSetting({ @@ -147,32 +162,27 @@ const OnlineDocuments = ({ <div className="flex grow items-center"> <Title name={nodeData.datasource_label} /> </div> - <SearchInput - value={searchValue} - onChange={handleSearchValueChange} - /> + <SearchInput value={searchValue} onChange={handleSearchValueChange} /> </div> <div className="overflow-hidden rounded-b-xl"> - {documentsData?.length - ? ( - <PageSelector - key={`${currentCredentialId}:${supportBatchUpload ? 'multiple' : 'single'}`} - checkedIds={selectedPagesId} - disabledValue={new Set()} - searchValue={searchValue} - list={documentsData[0]!.pages || []} - pagesMap={PagesMapAndSelectedPagesId} - onSelect={handleSelectPages} - canPreview={!isInPipeline} - onPreview={handlePreviewPage} - isMultipleChoice={supportBatchUpload} - /> - ) - : ( - <div className="flex h-[296px] items-center justify-center"> - <Loading type="app" /> - </div> - )} + {documentsData?.length ? ( + <PageSelector + key={`${currentCredentialId}:${supportBatchUpload ? 'multiple' : 'single'}`} + checkedIds={selectedPagesId} + disabledValue={new Set()} + searchValue={searchValue} + list={documentsData[0]!.pages || []} + pagesMap={PagesMapAndSelectedPagesId} + onSelect={handleSelectPages} + canPreview={!isInPipeline} + onPreview={handlePreviewPage} + isMultipleChoice={supportBatchUpload} + /> + ) : ( + <div className="flex h-[296px] items-center justify-center"> + <Loading type="app" /> + </div> + )} </div> </div> </div> diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/index.spec.tsx index c4bd37232c3287..0cc0c1c97a6aaf 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/index.spec.tsx @@ -1,4 +1,7 @@ -import type { NotionPageTreeItem, NotionPageTreeMap } from '@/app/components/base/notion-page-selector/page-selector/types' +import type { + NotionPageTreeItem, + NotionPageTreeMap, +} from '@/app/components/base/notion-page-selector/page-selector/types' import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' @@ -19,7 +22,8 @@ const getAllRadios = () => document.querySelectorAll('.size-4.rounded-full') const isCheckboxChecked = (checkbox: Element) => checkbox.getAttribute('aria-checked') === 'true' -const isCheckboxDisabled = (checkbox: Element) => checkbox.hasAttribute('data-disabled') || checkbox.getAttribute('aria-disabled') === 'true' +const isCheckboxDisabled = (checkbox: Element) => + checkbox.hasAttribute('data-disabled') || checkbox.getAttribute('aria-disabled') === 'true' const createMockPage = (overrides?: Partial<DataSourceNotionPage>): DataSourceNotionPage => ({ page_id: 'page-1', @@ -58,10 +62,26 @@ const createDefaultProps = (overrides?: Partial<PageSelectorProps>): PageSelecto // Helper to create hierarchical page structure const createHierarchicalPages = () => { - const rootPage = createMockPage({ page_id: 'root-page', page_name: 'Root Page', parent_id: 'root' }) - const childPage1 = createMockPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-page' }) - const childPage2 = createMockPage({ page_id: 'child-2', page_name: 'Child 2', parent_id: 'root-page' }) - const grandChild = createMockPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }) + const rootPage = createMockPage({ + page_id: 'root-page', + page_name: 'Root Page', + parent_id: 'root', + }) + const childPage1 = createMockPage({ + page_id: 'child-1', + page_name: 'Child 1', + parent_id: 'root-page', + }) + const childPage2 = createMockPage({ + page_id: 'child-2', + page_name: 'Child 2', + parent_id: 'root-page', + }) + const grandChild = createMockPage({ + page_id: 'grandchild-1', + page_name: 'Grandchild 1', + parent_id: 'child-1', + }) const list = [rootPage, childPage1, childPage2, grandChild] const pagesMap = createMockPagesMap(list) @@ -91,7 +111,9 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) - expect(screen.getByText('common.dataSource.notion.selector.noSearchResult'))!.toBeInTheDocument() + expect( + screen.getByText('common.dataSource.notion.selector.noSearchResult'), + )!.toBeInTheDocument() expect(screen.queryByTestId('virtual-list')).not.toBeInTheDocument() }) @@ -140,7 +162,9 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) - expect(screen.queryByText('common.dataSource.notion.selector.preview')).not.toBeInTheDocument() + expect( + screen.queryByText('common.dataSource.notion.selector.preview'), + ).not.toBeInTheDocument() }) it('should render NotionIcon for each page', () => { @@ -342,7 +366,9 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) - expect(screen.getByText('common.dataSource.notion.selector.noSearchResult'))!.toBeInTheDocument() + expect( + screen.getByText('common.dataSource.notion.selector.noSearchResult'), + )!.toBeInTheDocument() }) it('should show all pages when searchValue is empty', () => { @@ -411,7 +437,9 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) - expect(screen.queryByText('common.dataSource.notion.selector.preview')).not.toBeInTheDocument() + expect( + screen.queryByText('common.dataSource.notion.selector.preview'), + ).not.toBeInTheDocument() }) it('should use default value true when canPreview is not provided', () => { @@ -515,9 +543,7 @@ describe('PageSelector', () => { describe('currentCredentialId prop', () => { it('should reset dataList when currentCredentialId changes', () => { - const pages = [ - createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), - ] + const pages = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] const props = createDefaultProps({ list: pages, pagesMap: createMockPagesMap(pages), @@ -599,9 +625,10 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) // Find and click the expand arrow (uses hover:bg-components-button-ghost-bg-hover class) - const arrowButton = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') - if (arrowButton) - fireEvent.click(arrowButton) + const arrowButton = document.querySelector( + '[class*="hover:bg-components-button-ghost-bg-hover"]', + ) + if (arrowButton) fireEvent.click(arrowButton) expect(screen.getByText(rootPage.page_name))!.toBeInTheDocument() expect(screen.getByText(childPage1.page_name))!.toBeInTheDocument() @@ -715,9 +742,10 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) // Find expand arrow for root page (has RiArrowRightSLine icon) - const expandArrow = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') - if (expandArrow) - fireEvent.click(expandArrow) + const expandArrow = document.querySelector( + '[class*="hover:bg-components-button-ghost-bg-hover"]', + ) + if (expandArrow) fireEvent.click(expandArrow) // Assert - Children should be visible // Assert - Children should be visible @@ -735,7 +763,9 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) // First expand - const expandArrow = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + const expandArrow = document.querySelector( + '[class*="hover:bg-components-button-ghost-bg-hover"]', + ) if (expandArrow) { fireEvent.click(expandArrow) // Then collapse @@ -849,7 +879,9 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) // Assert - Tree structure should be built (verified by expand functionality) - const expandArrow = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + const expandArrow = document.querySelector( + '[class*="hover:bg-components-button-ghost-bg-hover"]', + ) expect(expandArrow)!.toBeInTheDocument() // Root page has children }) @@ -903,7 +935,9 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) - expect(screen.getByText('common.dataSource.notion.selector.noSearchResult'))!.toBeInTheDocument() + expect( + screen.getByText('common.dataSource.notion.selector.noSearchResult'), + )!.toBeInTheDocument() }) }) @@ -952,9 +986,10 @@ describe('PageSelector', () => { // Initially children are hidden expect(screen.queryByText(childPage1.page_name)).not.toBeInTheDocument() - const expandArrow = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') - if (expandArrow) - fireEvent.click(expandArrow) + const expandArrow = document.querySelector( + '[class*="hover:bg-components-button-ghost-bg-hover"]', + ) + if (expandArrow) fireEvent.click(expandArrow) // Children become visible // Children become visible @@ -1058,7 +1093,9 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) - expect(screen.getByText('common.dataSource.notion.selector.noSearchResult'))!.toBeInTheDocument() + expect( + screen.getByText('common.dataSource.notion.selector.noSearchResult'), + )!.toBeInTheDocument() }) it('should handle null page_icon', () => { @@ -1229,7 +1266,9 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) // Assert - No expand arrow for leaf pages - const arrowButton = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + const arrowButton = document.querySelector( + '[class*="hover:bg-components-button-ghost-bg-hover"]', + ) expect(arrowButton).not.toBeInTheDocument() }) }) @@ -1250,12 +1289,12 @@ describe('PageSelector', () => { if (propVariation.canPreview) expect(screen.getByText('common.dataSource.notion.selector.preview'))!.toBeInTheDocument() else - expect(screen.queryByText('common.dataSource.notion.selector.preview')).not.toBeInTheDocument() + expect( + screen.queryByText('common.dataSource.notion.selector.preview'), + ).not.toBeInTheDocument() - if (propVariation.isMultipleChoice) - expect(getCheckbox())!.toBeInTheDocument() - else - expect(getRadio())!.toBeInTheDocument() + if (propVariation.isMultipleChoice) expect(getCheckbox())!.toBeInTheDocument() + else expect(getRadio())!.toBeInTheDocument() }) it('should handle all default prop values', () => { @@ -1477,7 +1516,9 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) // Assert - Root page should have expand arrow - const arrowContainer = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + const arrowContainer = document.querySelector( + '[class*="hover:bg-components-button-ghost-bg-hover"]', + ) expect(arrowContainer)!.toBeInTheDocument() }) @@ -1491,7 +1532,9 @@ describe('PageSelector', () => { render(<PageSelector {...props} />) // Assert - No expand arrow for leaf pages - const arrowContainer = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + const arrowContainer = document.querySelector( + '[class*="hover:bg-components-button-ghost-bg-hover"]', + ) expect(arrowContainer).not.toBeInTheDocument() }) @@ -1507,7 +1550,9 @@ describe('PageSelector', () => { // Assert - No expand arrows in search mode (renderArrow returns null when searchValue) // The arrows are only shown when !searchValue - const arrowContainer = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + const arrowContainer = document.querySelector( + '[class*="hover:bg-components-button-ghost-bg-hover"]', + ) expect(arrowContainer).not.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/utils.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/utils.spec.ts index a7175a47de00c5..b4da3f0d1612d5 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/utils.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/utils.spec.ts @@ -1,4 +1,7 @@ -import type { NotionPageTreeItem, NotionPageTreeMap } from '@/app/components/base/notion-page-selector/page-selector/types' +import type { + NotionPageTreeItem, + NotionPageTreeMap, +} from '@/app/components/base/notion-page-selector/page-selector/types' import type { DataSourceNotionPageMap } from '@/models/common' import { describe, expect, it } from 'vitest' import { recursivePushInParentDescendants } from '@/app/components/base/notion-page-selector/page-selector/utils' @@ -28,7 +31,12 @@ describe('recursivePushInParentDescendants', () => { child1: makePageEntry({ page_id: 'child1', parent_id: 'parent1', page_name: 'Child' }), } - recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child1!, listTreeMap.child1!) + recursivePushInParentDescendants( + pagesMap, + listTreeMap, + listTreeMap.child1!, + listTreeMap.child1!, + ) expect(listTreeMap.parent1).toBeDefined() expect(listTreeMap.parent1!.children.has('child1')).toBe(true) @@ -60,10 +68,19 @@ describe('recursivePushInParentDescendants', () => { } as unknown as DataSourceNotionPageMap const listTreeMap: NotionPageTreeMap = { - root_child: makePageEntry({ page_id: 'root_child', parent_id: 'root', page_name: 'Root Child' }), + root_child: makePageEntry({ + page_id: 'root_child', + parent_id: 'root', + page_name: 'Root Child', + }), } - recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.root_child!, listTreeMap.root_child!) + recursivePushInParentDescendants( + pagesMap, + listTreeMap, + listTreeMap.root_child!, + listTreeMap.root_child!, + ) // No new entries should be added since parent is root expect(Object.keys(listTreeMap)).toEqual(['root_child']) @@ -87,11 +104,22 @@ describe('recursivePushInParentDescendants', () => { } as unknown as DataSourceNotionPageMap const listTreeMap: NotionPageTreeMap = { - parent: makePageEntry({ page_id: 'parent', parent_id: 'root', children: new Set(['child1']), descendants: new Set(['child1']), page_name: 'Parent' }), + parent: makePageEntry({ + page_id: 'parent', + parent_id: 'root', + children: new Set(['child1']), + descendants: new Set(['child1']), + page_name: 'Parent', + }), child2: makePageEntry({ page_id: 'child2', parent_id: 'parent', page_name: 'Child2' }), } - recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child2!, listTreeMap.child2!) + recursivePushInParentDescendants( + pagesMap, + listTreeMap, + listTreeMap.child2!, + listTreeMap.child2!, + ) expect(listTreeMap.parent!.children.has('child2')).toBe(true) expect(listTreeMap.parent!.descendants.has('child2')).toBe(true) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx index 5ca2881029f216..65ee30aece6803 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx @@ -50,7 +50,7 @@ const PageSelector = ({ if (!rows.length) { return ( <div className="flex h-[296px] items-center justify-center text-[13px] text-text-tertiary"> - {t($ => $['dataSource.notion.selector.noSearchResult'], { ns: 'common' })} + {t(($) => $['dataSource.notion.selector.noSearchResult'], { ns: 'common' })} </div> ) } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/title.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/title.tsx index 4eee7576b25be3..c25034d2077802 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/title.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/title.tsx @@ -5,14 +5,12 @@ type TitleProps = { name: string } -const Title = ({ - name, -}: TitleProps) => { +const Title = ({ name }: TitleProps) => { const { t } = useTranslation() return ( <div className="px-[5px] py-1 system-sm-medium text-text-secondary"> - {t($ => $['onlineDocument.pageSelectorTitle'], { ns: 'datasetPipeline', name })} + {t(($) => $['onlineDocument.pageSelectorTitle'], { ns: 'datasetPipeline', name })} </div> ) } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx index b057241c84706a..cbe009ff80bc53 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx @@ -73,7 +73,8 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { }) vi.mock('../../store', () => ({ - useDataSourceStoreWithSelector: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), + useDataSourceStoreWithSelector: (selector: (state: typeof mockStoreState) => unknown) => + selector(mockStoreState), useDataSourceStore: () => mockDataSourceStore, })) @@ -93,8 +94,12 @@ vi.mock('../../base/header', () => ({ <span data-testid="header-plugin-name">{props.pluginName}</span> <span data-testid="header-credential-id">{props.currentCredentialId}</span> <span data-testid="header-credentials-count">{props.credentials?.length || 0}</span> - <button type="button" onClick={() => props.onCredentialChange('credential-2')}>Change Credential</button> - <button type="button" onClick={props.onClickConfiguration}>Configure</button> + <button type="button" onClick={() => props.onCredentialChange('credential-2')}> + Change Credential + </button> + <button type="button" onClick={props.onClickConfiguration}> + Configure + </button> </div> ), })) @@ -123,35 +128,69 @@ vi.mock('../file-list', () => ({ <span data-testid="file-list-loading">{String(props.isLoading)}</span> <span data-testid="file-list-in-pipeline">{String(props.isInPipeline)}</span> <span data-testid="file-list-support-batch">{String(props.supportBatchUpload)}</span> - <button type="button" onClick={() => props.updateKeywords('report')}>Search</button> - <button type="button" onClick={props.resetKeywords}>Reset Search</button> + <button type="button" onClick={() => props.updateKeywords('report')}> + Search + </button> + <button type="button" onClick={props.resetKeywords}> + Reset Search + </button> <button type="button" - onClick={() => props.handleSelectFile({ id: 'file-1', name: 'Report.pdf', type: OnlineDriveFileType.file })} + onClick={() => + props.handleSelectFile({ + id: 'file-1', + name: 'Report.pdf', + type: OnlineDriveFileType.file, + }) + } > Select File </button> <button type="button" - onClick={() => props.handleSelectFile({ id: 'bucket-1', name: 'Bucket', type: OnlineDriveFileType.bucket })} + onClick={() => + props.handleSelectFile({ + id: 'bucket-1', + name: 'Bucket', + type: OnlineDriveFileType.bucket, + }) + } > Select Bucket </button> <button type="button" - onClick={() => props.handleOpenFolder({ id: 'folder-prefix', name: 'Folder', type: OnlineDriveFileType.folder })} + onClick={() => + props.handleOpenFolder({ + id: 'folder-prefix', + name: 'Folder', + type: OnlineDriveFileType.folder, + }) + } > Open Folder </button> <button type="button" - onClick={() => props.handleOpenFolder({ id: 'bucket-id', name: 'Bucket', type: OnlineDriveFileType.bucket })} + onClick={() => + props.handleOpenFolder({ + id: 'bucket-id', + name: 'Bucket', + type: OnlineDriveFileType.bucket, + }) + } > Open Bucket </button> <button type="button" - onClick={() => props.handleOpenFolder({ id: 'file-1', name: 'Report.pdf', type: OnlineDriveFileType.file })} + onClick={() => + props.handleOpenFolder({ + id: 'file-1', + name: 'Report.pdf', + type: OnlineDriveFileType.file, + }) + } > Open File </button> @@ -159,17 +198,18 @@ vi.mock('../file-list', () => ({ ), })) -const createNodeData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => ({ - title: 'Online Drive Node', - plugin_id: 'plugin-123', - provider_type: 'online_drive', - provider_name: 'online-drive-provider', - datasource_name: 'online-drive-datasource', - datasource_label: 'Online Drive', - datasource_parameters: {}, - datasource_configurations: {}, - ...overrides, -} as DataSourceNodeType) +const createNodeData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => + ({ + title: 'Online Drive Node', + plugin_id: 'plugin-123', + provider_type: 'online_drive', + provider_name: 'online-drive-provider', + datasource_name: 'online-drive-datasource', + datasource_label: 'Online Drive', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, + }) as DataSourceNodeType const createCredential = (id = 'credential-1') => ({ id, @@ -305,9 +345,7 @@ describe('OnlineDrive', () => { data: [ { bucket: 'bucket-a', - files: [ - { id: 'new-file', name: 'New.pdf', size: 1024, type: 'file' }, - ], + files: [{ id: 'new-file', name: 'New.pdf', size: 1024, type: 'file' }], is_truncated: true, next_page_parameters: { cursor: 'next-page' }, }, diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx index c7060b092e9538..cf256b33a6dce4 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx @@ -32,7 +32,8 @@ const mockDataSourceStore = { getState: mockGetState } vi.mock('../../../store', () => ({ useDataSourceStore: () => mockDataSourceStore, - useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), + useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => + selector(mockStoreState), })) const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => ({ @@ -88,7 +89,9 @@ describe('FileList', () => { render(<FileList {...props} />) // Assert - search input should be visible - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + ).toBeInTheDocument() }) it('should render with correct container styles', () => { @@ -109,7 +112,9 @@ describe('FileList', () => { render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input).toBeInTheDocument() }) @@ -189,8 +194,14 @@ describe('FileList', () => { render(<FileList {...props} />) - expect(screen.getByRole('checkbox', { name: 'file1.txt' })).toHaveAttribute('aria-checked', 'true') - expect(screen.getByRole('checkbox', { name: 'file2.txt' })).toHaveAttribute('aria-checked', 'false') + expect(screen.getByRole('checkbox', { name: 'file1.txt' })).toHaveAttribute( + 'aria-checked', + 'true', + ) + expect(screen.getByRole('checkbox', { name: 'file2.txt' })).toHaveAttribute( + 'aria-checked', + 'false', + ) }) }) @@ -200,7 +211,9 @@ describe('FileList', () => { render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input).toHaveValue('my-search') }) }) @@ -257,14 +270,18 @@ describe('FileList', () => { render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input).toHaveValue('initial-keyword') }) it('should update inputValue when input changes', () => { const props = createDefaultProps({ keywords: '' }) render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) fireEvent.change(input, { target: { value: 'new-value' } }) @@ -277,7 +294,9 @@ describe('FileList', () => { const mockUpdateKeywords = vi.fn() const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) fireEvent.change(input, { target: { value: 'debounced-value' } }) @@ -292,7 +311,9 @@ describe('FileList', () => { it('should update inputValue on input change', () => { const props = createDefaultProps() render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) fireEvent.change(input, { target: { value: 'typed-text' } }) @@ -303,7 +324,9 @@ describe('FileList', () => { const mockUpdateKeywords = vi.fn() const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) fireEvent.change(input, { target: { value: 'search-term' } }) @@ -314,7 +337,9 @@ describe('FileList', () => { const mockUpdateKeywords = vi.fn() const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) fireEvent.change(input, { target: { value: 'a' } }) fireEvent.change(input, { target: { value: 'ab' } }) @@ -343,7 +368,9 @@ describe('FileList', () => { it('should reset inputValue to empty string when clear is clicked', () => { const props = createDefaultProps({ keywords: 'to-be-reset' }) render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) fireEvent.change(input, { target: { value: 'some-search' } }) // Act - Find and click the clear icon @@ -366,18 +393,26 @@ describe('FileList', () => { const fileItem = screen.getByText('test.txt') fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!) - expect(mockHandleSelectFile).toHaveBeenCalledWith(expect.objectContaining({ - id: 'file-1', - name: 'test.txt', - type: OnlineDriveFileType.file, - })) + expect(mockHandleSelectFile).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'file-1', + name: 'test.txt', + type: OnlineDriveFileType.file, + }), + ) }) }) describe('handleOpenFolder', () => { it('should call handleOpenFolder when folder item is clicked', () => { const mockHandleOpenFolder = vi.fn() - const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })] + const fileList = [ + createMockOnlineDriveFile({ + id: 'folder-1', + name: 'my-folder', + type: OnlineDriveFileType.folder, + }), + ] const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList }) render(<FileList {...props} />) @@ -385,11 +420,13 @@ describe('FileList', () => { const folderItem = screen.getByText('my-folder') fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!) - expect(mockHandleOpenFolder).toHaveBeenCalledWith(expect.objectContaining({ - id: 'folder-1', - name: 'my-folder', - type: OnlineDriveFileType.folder, - })) + expect(mockHandleOpenFolder).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'folder-1', + name: 'my-folder', + type: OnlineDriveFileType.folder, + }), + ) }) }) }) @@ -400,7 +437,9 @@ describe('FileList', () => { render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input).toHaveValue('') }) @@ -410,7 +449,9 @@ describe('FileList', () => { render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input).toHaveValue(specialChars) }) @@ -420,7 +461,9 @@ describe('FileList', () => { render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input).toHaveValue(unicodeKeywords) }) @@ -436,7 +479,8 @@ describe('FileList', () => { it('should handle large number of files', () => { const fileList = Array.from({ length: 50 }, (_, i) => - createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` })) + createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` }), + ) const props = createDefaultProps({ fileList }) render(<FileList {...props} />) @@ -449,7 +493,9 @@ describe('FileList', () => { it('should handle whitespace-only keywords input', () => { const props = createDefaultProps() render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) fireEvent.change(input, { target: { value: ' ' } }) @@ -464,14 +510,19 @@ describe('FileList', () => { { isInPipeline: true, supportBatchUpload: false }, { isInPipeline: false, supportBatchUpload: true }, { isInPipeline: false, supportBatchUpload: false }, - ])('should render correctly with isInPipeline=$isInPipeline and supportBatchUpload=$supportBatchUpload', (propVariation) => { - const props = createDefaultProps(propVariation) + ])( + 'should render correctly with isInPipeline=$isInPipeline and supportBatchUpload=$supportBatchUpload', + (propVariation) => { + const props = createDefaultProps(propVariation) - render(<FileList {...props} />) + render(<FileList {...props} />) - // Assert - Component should render without crashing - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() - }) + // Assert - Component should render without crashing + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + ).toBeInTheDocument() + }, + ) it.each([ { isLoading: true, fileCount: 0, description: 'loading state with no files' }, @@ -479,39 +530,48 @@ describe('FileList', () => { { isLoading: false, fileCount: 3, description: 'not loading with files' }, ])('should handle $description correctly', ({ isLoading, fileCount }) => { const fileList = Array.from({ length: fileCount }, (_, i) => - createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` })) + createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` }), + ) const props = createDefaultProps({ isLoading, fileList }) const { container } = render(<FileList {...props} />) if (isLoading && fileCount === 0) expect(container.querySelector('.spin-animation')).toBeInTheDocument() - else if (!isLoading && fileCount === 0) expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument() - - else - expect(screen.getByText('file-0.txt')).toBeInTheDocument() + else expect(screen.getByText('file-0.txt')).toBeInTheDocument() }) it.each([ { keywords: '', searchResultsLength: 0 }, { keywords: 'test', searchResultsLength: 5 }, { keywords: 'not-found', searchResultsLength: 0 }, - ])('should render correctly with keywords="$keywords" and searchResultsLength=$searchResultsLength', ({ keywords, searchResultsLength }) => { - const props = createDefaultProps({ keywords, searchResultsLength }) + ])( + 'should render correctly with keywords="$keywords" and searchResultsLength=$searchResultsLength', + ({ keywords, searchResultsLength }) => { + const props = createDefaultProps({ keywords, searchResultsLength }) - render(<FileList {...props} />) + render(<FileList {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - expect(input).toHaveValue(keywords) - }) + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) + expect(input).toHaveValue(keywords) + }, + ) }) // File Type Variations describe('File Type Variations', () => { it('should render folder type correctly', () => { - const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })] + const fileList = [ + createMockOnlineDriveFile({ + id: 'folder-1', + name: 'my-folder', + type: OnlineDriveFileType.folder, + }), + ] const props = createDefaultProps({ fileList }) render(<FileList {...props} />) @@ -520,7 +580,13 @@ describe('FileList', () => { }) it('should render bucket type correctly', () => { - const fileList = [createMockOnlineDriveFile({ id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket })] + const fileList = [ + createMockOnlineDriveFile({ + id: 'bucket-1', + name: 'my-bucket', + type: OnlineDriveFileType.bucket, + }), + ] const props = createDefaultProps({ fileList }) render(<FileList {...props} />) @@ -540,7 +606,13 @@ describe('FileList', () => { }) it('should not show checkbox for bucket type', () => { - const fileList = [createMockOnlineDriveFile({ id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket })] + const fileList = [ + createMockOnlineDriveFile({ + id: 'bucket-1', + name: 'my-bucket', + type: OnlineDriveFileType.bucket, + }), + ] const props = createDefaultProps({ fileList, supportBatchUpload: true }) render(<FileList {...props} />) @@ -561,7 +633,9 @@ describe('FileList', () => { render(<FileList {...props} />) - expect(screen.getByText(/datasetPipeline\.onlineDrive\.breadcrumbs\.searchResult/)).toBeInTheDocument() + expect( + screen.getByText(/datasetPipeline\.onlineDrive\.breadcrumbs\.searchResult/), + ).toBeInTheDocument() }) }) @@ -587,7 +661,13 @@ describe('FileList', () => { it('should maintain stable handleOpenFolder callback', () => { const mockHandleOpenFolder = vi.fn() - const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })] + const fileList = [ + createMockOnlineDriveFile({ + id: 'folder-1', + name: 'my-folder', + type: OnlineDriveFileType.folder, + }), + ] const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList }) const { rerender } = render(<FileList {...props} />) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx index 5ad964699c19a2..0101c257feaea6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx @@ -19,7 +19,8 @@ const mockDataSourceStore = { getState: mockGetState } vi.mock('../../../../store', () => ({ useDataSourceStore: () => mockDataSourceStore, - useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), + useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => + selector(mockStoreState), })) type HeaderProps = React.ComponentProps<typeof Header> @@ -61,7 +62,9 @@ describe('Header', () => { // Assert - search input should be visible // Assert - search input should be visible - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should render with correct container styles', () => { @@ -84,7 +87,9 @@ describe('Header', () => { render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input)!.toBeInTheDocument() expect(input)!.toHaveValue('test-value') }) @@ -117,7 +122,9 @@ describe('Header', () => { render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input)!.toHaveValue('') }) @@ -126,7 +133,9 @@ describe('Header', () => { render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input)!.toHaveValue('search-query') }) @@ -136,7 +145,9 @@ describe('Header', () => { render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input)!.toHaveValue(specialChars) }) @@ -146,7 +157,9 @@ describe('Header', () => { render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input)!.toHaveValue(unicodeValue) }) }) @@ -159,7 +172,9 @@ describe('Header', () => { // Assert - Component should render without errors // Assert - Component should render without errors - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should render with single breadcrumb', () => { @@ -167,7 +182,9 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should render with multiple breadcrumbs', () => { @@ -175,7 +192,9 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) }) @@ -187,7 +206,9 @@ describe('Header', () => { // Assert - keywords are passed through, component renders // Assert - keywords are passed through, component renders - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) }) @@ -197,7 +218,9 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should render with bucket value', () => { @@ -205,7 +228,9 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) }) @@ -215,7 +240,9 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should handle positive search results', () => { @@ -225,7 +252,9 @@ describe('Header', () => { // Assert - Breadcrumbs will show search results text when keywords exist and results > 0 // Assert - Breadcrumbs will show search results text when keywords exist and results > 0 - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should handle large search results count', () => { @@ -233,7 +262,9 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) }) @@ -243,7 +274,9 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should render correctly when isInPipeline is true', () => { @@ -251,7 +284,9 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) }) }) @@ -263,7 +298,9 @@ describe('Header', () => { const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) fireEvent.change(input, { target: { value: 'new-value' } }) @@ -276,7 +313,9 @@ describe('Header', () => { const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) fireEvent.change(input, { target: { value: 'a' } }) fireEvent.change(input, { target: { value: 'ab' } }) @@ -287,9 +326,14 @@ describe('Header', () => { it('should handle empty string input', () => { const mockHandleInputChange = vi.fn() - const props = createDefaultProps({ inputValue: 'existing', handleInputChange: mockHandleInputChange }) + const props = createDefaultProps({ + inputValue: 'existing', + handleInputChange: mockHandleInputChange, + }) render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) fireEvent.change(input, { target: { value: '' } }) @@ -301,7 +345,9 @@ describe('Header', () => { const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) fireEvent.change(input, { target: { value: ' ' } }) @@ -370,13 +416,17 @@ describe('Header', () => { // Assert - Component renders without errors // Assert - Component renders without errors - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should re-render when inputValue changes', () => { const props = createDefaultProps({ inputValue: 'initial' }) const { rerender } = render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input)!.toHaveValue('initial') // Act - Rerender with different inputValue @@ -398,7 +448,9 @@ describe('Header', () => { // Assert - Component renders without errors // Assert - Component renders without errors - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should re-render when keywords change', () => { @@ -411,7 +463,9 @@ describe('Header', () => { // Assert - Component renders without errors // Assert - Component renders without errors - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) }) @@ -422,7 +476,9 @@ describe('Header', () => { render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input)!.toHaveValue(longValue) }) @@ -432,7 +488,9 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should handle breadcrumbs with special characters', () => { @@ -441,7 +499,9 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should handle breadcrumbs with unicode names', () => { @@ -450,7 +510,9 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should handle bucket with special characters', () => { @@ -458,14 +520,18 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should pass the event object to handleInputChange callback', () => { const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) fireEvent.change(input, { target: { value: 'test-value' } }) @@ -483,13 +549,18 @@ describe('Header', () => { { isInPipeline: true, bucket: 'my-bucket' }, { isInPipeline: false, bucket: '' }, { isInPipeline: false, bucket: 'my-bucket' }, - ])('should render correctly with isInPipeline=$isInPipeline and bucket=$bucket', (propVariation) => { - const props = createDefaultProps(propVariation) + ])( + 'should render correctly with isInPipeline=$isInPipeline and bucket=$bucket', + (propVariation) => { + const props = createDefaultProps(propVariation) - render(<Header {...props} />) + render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() - }) + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() + }, + ) it.each([ { keywords: '', searchResultsLength: 0, description: 'no search' }, @@ -501,20 +572,28 @@ describe('Header', () => { render(<Header {...props} />) - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it.each([ { breadcrumbs: [], inputValue: '', expected: 'empty state' }, { breadcrumbs: ['root'], inputValue: 'search', expected: 'single breadcrumb with search' }, { breadcrumbs: ['a', 'b', 'c'], inputValue: '', expected: 'multiple breadcrumbs no search' }, - { breadcrumbs: ['a', 'b', 'c', 'd', 'e'], inputValue: 'query', expected: 'many breadcrumbs with search' }, + { + breadcrumbs: ['a', 'b', 'c', 'd', 'e'], + inputValue: 'query', + expected: 'many breadcrumbs with search', + }, ])('should handle $expected correctly', ({ breadcrumbs, inputValue }) => { const props = createDefaultProps({ breadcrumbs, inputValue }) render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input)!.toHaveValue(inputValue) }) }) @@ -534,7 +613,9 @@ describe('Header', () => { // Assert - Component should render successfully, meaning props are passed correctly // Assert - Component should render successfully, meaning props are passed correctly - expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder'), + )!.toBeInTheDocument() }) it('should pass correct props to Input component', () => { @@ -548,7 +629,9 @@ describe('Header', () => { render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) expect(input)!.toHaveValue('test-input') // Test onChange handler @@ -563,7 +646,9 @@ describe('Header', () => { const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) const { rerender } = render(<Header {...props} />) - const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + const input = screen.getByPlaceholderText( + 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', + ) // Act - Fire change event, rerender, fire again fireEvent.change(input, { target: { value: 'first' } }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx index b0a49eee0d1d00..164b4f19c9b303 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx @@ -3,7 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import Bucket from '../bucket' vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({ - BucketsGray: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="buckets-gray" {...props} />, + BucketsGray: (props: React.SVGProps<SVGSVGElement>) => ( + <svg data-testid="buckets-gray" {...props} /> + ), })) describe('Bucket', () => { @@ -29,7 +31,9 @@ describe('Bucket', () => { it('should call handleBackToBucketList on icon button click', () => { render(<Bucket {...defaultProps} />) - fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.onlineDrive.breadcrumbs.allBuckets' })) + fireEvent.click( + screen.getByRole('button', { name: 'datasetPipeline.onlineDrive.breadcrumbs.allBuckets' }), + ) expect(defaultProps.handleBackToBucketList).toHaveBeenCalledOnce() }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/drive.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/drive.spec.tsx index ce3bab6d013fad..27017d9bf2b004 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/drive.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/drive.spec.tsx @@ -17,7 +17,9 @@ describe('Drive', () => { it('should render "All Files" button text', () => { render(<Drive {...defaultProps} />) - expect(screen.getByRole('button')).toHaveTextContent('datasetPipeline.onlineDrive.breadcrumbs.allFiles') + expect(screen.getByRole('button')).toHaveTextContent( + 'datasetPipeline.onlineDrive.breadcrumbs.allFiles', + ) }) it('should show separator "/" when breadcrumbs has items', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx index 9db4b78b519f9b..0814f87d0b6e13 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx @@ -19,7 +19,8 @@ const mockDataSourceStore = { getState: mockGetState } vi.mock('../../../../../store', () => ({ useDataSourceStore: () => mockDataSourceStore, - useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), + useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => + selector(mockStoreState), })) type BreadcrumbsProps = React.ComponentProps<typeof Breadcrumbs> @@ -95,7 +96,9 @@ describe('Breadcrumbs', () => { // Assert - Search result text should be displayed // Assert - Search result text should be displayed - expect(screen.getByText(/datasetPipeline\.onlineDrive\.breadcrumbs\.searchResult/))!.toBeInTheDocument() + expect( + screen.getByText(/datasetPipeline\.onlineDrive\.breadcrumbs\.searchResult/), + )!.toBeInTheDocument() }) it('should not show search results when keywords is empty', () => { @@ -163,7 +166,9 @@ describe('Breadcrumbs', () => { render(<Breadcrumbs {...props} />) - expect(screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets'))!.toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets'), + )!.toBeInTheDocument() }) it('should not show all buckets title when breadcrumbs exist', () => { @@ -175,7 +180,9 @@ describe('Breadcrumbs', () => { render(<Breadcrumbs {...props} />) - expect(screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets'), + ).not.toBeInTheDocument() }) it('should not show all buckets title when bucket is set', () => { @@ -219,7 +226,9 @@ describe('Breadcrumbs', () => { // Assert - Should show bucket name instead // Assert - Should show bucket name instead // Assert - Should show bucket name instead - expect(screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets'), + ).not.toBeInTheDocument() }) }) @@ -294,7 +303,9 @@ describe('Breadcrumbs', () => { // Assert - "All Files" should be displayed // Assert - "All Files" should be displayed - expect(screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles'))!.toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles'), + )!.toBeInTheDocument() }) it('should not render Drive component when hasBucket is true', () => { @@ -306,7 +317,9 @@ describe('Breadcrumbs', () => { render(<Breadcrumbs {...props} />) - expect(screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles'), + ).not.toBeInTheDocument() }) }) @@ -464,7 +477,9 @@ describe('Breadcrumbs', () => { // Assert - Only Drive should be visible // Assert - Only Drive should be visible - expect(screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles'))!.toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles'), + )!.toBeInTheDocument() }) it('should handle single breadcrumb', () => { @@ -960,7 +975,9 @@ describe('Breadcrumbs', () => { // Assert - Should show all buckets title // Assert - Should show all buckets title - expect(screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets'))!.toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets'), + )!.toBeInTheDocument() }) it('should handle breadcrumb with only whitespace', () => { @@ -999,16 +1016,19 @@ describe('Breadcrumbs', () => { { isInPipeline: false, bucket: '', expectedNum: 3 }, { isInPipeline: true, bucket: 'b', expectedNum: 1 }, { isInPipeline: false, bucket: 'b', expectedNum: 2 }, - ])('should calculate displayBreadcrumbNum=$expectedNum when isInPipeline=$isInPipeline and bucket=$bucket', ({ isInPipeline, bucket, expectedNum }) => { - mockStoreState.hasBucket = !!bucket - const breadcrumbs = Array.from({ length: expectedNum + 2 }, (_, i) => `f${i}`) - const props = createDefaultProps({ isInPipeline, bucket, breadcrumbs }) + ])( + 'should calculate displayBreadcrumbNum=$expectedNum when isInPipeline=$isInPipeline and bucket=$bucket', + ({ isInPipeline, bucket, expectedNum }) => { + mockStoreState.hasBucket = !!bucket + const breadcrumbs = Array.from({ length: expectedNum + 2 }, (_, i) => `f${i}`) + const props = createDefaultProps({ isInPipeline, bucket, breadcrumbs }) - render(<Breadcrumbs {...props} />) + render(<Breadcrumbs {...props} />) - // Assert - Should collapse because breadcrumbs.length > expectedNum - expect(getDropdownTrigger()).toBeInTheDocument() - }) + // Assert - Should collapse because breadcrumbs.length > expectedNum + expect(getDropdownTrigger()).toBeInTheDocument() + }, + ) }) describe('Integration', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/bucket.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/bucket.tsx index cf5c5a7cc9d2be..d07f66e448e382 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/bucket.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/bucket.tsx @@ -25,16 +25,17 @@ const Bucket = ({ }: BucketProps) => { const { t } = useTranslation() const handleClickItem = useCallback(() => { - if (!disabled) - handleClickBucketName() + if (!disabled) handleClickBucketName() }, [disabled, handleClickBucketName]) - const allBucketsLabel = t($ => $['onlineDrive.breadcrumbs.allBuckets'], { ns: 'datasetPipeline' }) + const allBucketsLabel = t(($) => $['onlineDrive.breadcrumbs.allBuckets'], { + ns: 'datasetPipeline', + }) return ( <> <Tooltip> <TooltipTrigger - render={( + render={ <Button type="button" variant="ghost" @@ -45,18 +46,18 @@ const Bucket = ({ > <BucketsGray aria-hidden /> </Button> - )} + } /> - <TooltipContent> - {allBucketsLabel} - </TooltipContent> + <TooltipContent>{allBucketsLabel}</TooltipContent> </Tooltip> <span className="system-xs-regular text-divider-deep">/</span> <button type="button" className={cn( 'max-w-full shrink truncate rounded-md px-[5px] py-1', - isActive ? 'system-sm-medium text-text-secondary' : 'system-sm-regular text-text-tertiary', + isActive + ? 'system-sm-medium text-text-secondary' + : 'system-sm-regular text-text-tertiary', !disabled && 'hover:bg-state-base-hover', )} disabled={disabled} diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/drive.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/drive.tsx index 80c957554f88f4..76ce25172cbe71 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/drive.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/drive.tsx @@ -7,10 +7,7 @@ type DriveProps = { handleBackToRoot: () => void } -const Drive = ({ - breadcrumbs, - handleBackToRoot, -}: DriveProps) => { +const Drive = ({ breadcrumbs, handleBackToRoot }: DriveProps) => { const { t } = useTranslation() return ( @@ -19,13 +16,14 @@ const Drive = ({ type="button" className={cn( 'max-w-full shrink truncate rounded-md px-[5px] py-1', - breadcrumbs.length > 0 && 'system-sm-regular text-text-tertiary hover:bg-state-base-hover', + breadcrumbs.length > 0 && + 'system-sm-regular text-text-tertiary hover:bg-state-base-hover', breadcrumbs.length === 0 && 'system-sm-medium text-text-secondary', )} onClick={handleBackToRoot} disabled={breadcrumbs.length === 0} > - {t($ => $['onlineDrive.breadcrumbs.allFiles'], { ns: 'datasetPipeline' })} + {t(($) => $['onlineDrive.breadcrumbs.allFiles'], { ns: 'datasetPipeline' })} </button> {breadcrumbs.length > 0 && <span className="system-xs-regular text-divider-deep">/</span>} </> diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx index d93de876e71ec5..01874a88f7872b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx @@ -553,10 +553,11 @@ describe('Dropdown', () => { // Rerender with different callback rerender( - <Dropdown {...createDefaultProps({ - breadcrumbs: ['folder'], - onBreadcrumbClick: mockOnBreadcrumbClick2, - })} + <Dropdown + {...createDefaultProps({ + breadcrumbs: ['folder'], + onBreadcrumbClick: mockOnBreadcrumbClick2, + })} />, ) @@ -700,23 +701,26 @@ describe('Dropdown', () => { { startIndex: 1, breadcrumbs: ['a'], expectedIndex: 1 }, { startIndex: 5, breadcrumbs: ['a'], expectedIndex: 5 }, { startIndex: 10, breadcrumbs: ['a', 'b'], expectedIndex: 10 }, - ])('should handle startIndex=$startIndex correctly', async ({ startIndex, breadcrumbs, expectedIndex }) => { - const mockOnBreadcrumbClick = vi.fn() - const props = createDefaultProps({ - startIndex, - breadcrumbs, - onBreadcrumbClick: mockOnBreadcrumbClick, - }) - render(<Dropdown {...props} />) + ])( + 'should handle startIndex=$startIndex correctly', + async ({ startIndex, breadcrumbs, expectedIndex }) => { + const mockOnBreadcrumbClick = vi.fn() + const props = createDefaultProps({ + startIndex, + breadcrumbs, + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + render(<Dropdown {...props} />) - fireEvent.click(screen.getByRole('button')) - await waitFor(() => { - expect(screen.getByText(breadcrumbs[0]!))!.toBeInTheDocument() - }) - fireEvent.click(screen.getByText(breadcrumbs[0]!)) + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText(breadcrumbs[0]!))!.toBeInTheDocument() + }) + fireEvent.click(screen.getByText(breadcrumbs[0]!)) - expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(expectedIndex) - }) + expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(expectedIndex) + }, + ) it.each([ { breadcrumbs: [], description: 'empty array' }, @@ -731,8 +735,7 @@ describe('Dropdown', () => { // Assert - Should render without errors await waitFor(() => { - if (breadcrumbs.length > 0) - expect(screen.getByText(breadcrumbs[0]!))!.toBeInTheDocument() + if (breadcrumbs.length > 0) expect(screen.getByText(breadcrumbs[0]!))!.toBeInTheDocument() }) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx index fd582a3bae547f..ccfdf6f83e869e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx @@ -7,9 +7,7 @@ import Item from '../item' const renderItem = (ui: ReactElement) => { return render( <DropdownMenu open> - <DropdownMenuContent> - {ui} - </DropdownMenuContent> + <DropdownMenuContent>{ui}</DropdownMenuContent> </DropdownMenu>, ) } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx index 80c6f7bfb03ee7..4f470919c6d61e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx @@ -7,9 +7,7 @@ import Menu from '../menu' const renderMenu = (ui: ReactElement) => { return render( <DropdownMenu open> - <DropdownMenuContent> - {ui} - </DropdownMenuContent> + <DropdownMenuContent>{ui}</DropdownMenuContent> </DropdownMenu>, ) } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx index 9b1ab45c58b2f9..dc2f9ed640e27e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx @@ -14,20 +14,16 @@ type DropdownProps = { onBreadcrumbClick: (index: number) => void } -const Dropdown = ({ - startIndex, - breadcrumbs, - onBreadcrumbClick, -}: DropdownProps) => { +const Dropdown = ({ startIndex, breadcrumbs, onBreadcrumbClick }: DropdownProps) => { const { t } = useTranslation() return ( <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.more'], { ns: 'common' })} className={cn( 'flex size-6 items-center justify-center rounded-md', 'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover', @@ -35,7 +31,7 @@ const Dropdown = ({ > <span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" /> </button> - )} + } /> <DropdownMenuContent placement="bottom-start" diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/item.tsx index beb217f6b778f0..27fd12e210a6db 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/item.tsx @@ -7,11 +7,7 @@ type ItemProps = { onBreadcrumbClick: (index: number) => void } -const Item = ({ - name, - index, - onBreadcrumbClick, -}: ItemProps) => { +const Item = ({ name, index, onBreadcrumbClick }: ItemProps) => { return ( <DropdownMenuItem className="rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover" diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/menu.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/menu.tsx index 44af10cd95a859..8e89f8322f8ff0 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/menu.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/menu.tsx @@ -7,11 +7,7 @@ type MenuProps = { onBreadcrumbClick: (index: number) => void } -const Menu = ({ - breadcrumbs, - startIndex, - onBreadcrumbClick, -}: MenuProps) => { +const Menu = ({ breadcrumbs, startIndex, onBreadcrumbClick }: MenuProps) => { return ( <div className="flex w-[136px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"> {breadcrumbs.map((breadcrumb, index) => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.tsx index c035a194ba7d04..0c0dd3c65b5f50 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.tsx @@ -24,7 +24,7 @@ const Breadcrumbs = ({ }: BreadcrumbsProps) => { const { t } = useTranslation() const dataSourceStore = useDataSourceStore() - const hasBucket = useDataSourceStoreWithSelector(s => s.hasBucket) + const hasBucket = useDataSourceStoreWithSelector((s) => s.hasBucket) const showSearchResult = !!keywords && searchResultsLength > 0 const showBucketListTitle = breadcrumbs.length === 0 && hasBucket && bucket === '' @@ -46,7 +46,8 @@ const Breadcrumbs = ({ }, [displayBreadcrumbNum, breadcrumbs]) const handleBackToBucketList = useCallback(() => { - const { setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix, setBucket } = dataSourceStore.getState() + const { setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix, setBucket } = + dataSourceStore.getState() setOnlineDriveFileList([]) setSelectedFileIds([]) setBucket('') @@ -55,7 +56,8 @@ const Breadcrumbs = ({ }, [dataSourceStore]) const handleClickBucketName = useCallback(() => { - const { setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix } = dataSourceStore.getState() + const { setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix } = + dataSourceStore.getState() setOnlineDriveFileList([]) setSelectedFileIds([]) setBreadcrumbs([]) @@ -63,28 +65,39 @@ const Breadcrumbs = ({ }, [dataSourceStore]) const handleBackToRoot = useCallback(() => { - const { setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix } = dataSourceStore.getState() + const { setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix } = + dataSourceStore.getState() setOnlineDriveFileList([]) setSelectedFileIds([]) setBreadcrumbs([]) setPrefix([]) }, [dataSourceStore]) - const handleClickBreadcrumb = useCallback((index: number) => { - const { breadcrumbs, prefix, setOnlineDriveFileList, setSelectedFileIds, setBreadcrumbs, setPrefix } = dataSourceStore.getState() - const newBreadcrumbs = breadcrumbs.slice(0, index + 1) - const newPrefix = prefix.slice(0, index + 1) - setOnlineDriveFileList([]) - setSelectedFileIds([]) - setBreadcrumbs(newBreadcrumbs) - setPrefix(newPrefix) - }, [dataSourceStore]) + const handleClickBreadcrumb = useCallback( + (index: number) => { + const { + breadcrumbs, + prefix, + setOnlineDriveFileList, + setSelectedFileIds, + setBreadcrumbs, + setPrefix, + } = dataSourceStore.getState() + const newBreadcrumbs = breadcrumbs.slice(0, index + 1) + const newPrefix = prefix.slice(0, index + 1) + setOnlineDriveFileList([]) + setSelectedFileIds([]) + setBreadcrumbs(newBreadcrumbs) + setPrefix(newPrefix) + }, + [dataSourceStore], + ) return ( <div className="flex grow items-center overflow-hidden"> {showSearchResult && ( <div className="text-test-secondary px-[5px] system-sm-medium"> - {t($ => $['onlineDrive.breadcrumbs.searchResult'], { + {t(($) => $['onlineDrive.breadcrumbs.searchResult'], { ns: 'datasetPipeline', searchResultsLength, folderName: breadcrumbs.length > 0 ? breadcrumbs[breadcrumbs.length - 1] : bucket, @@ -93,7 +106,7 @@ const Breadcrumbs = ({ )} {!showSearchResult && showBucketListTitle && ( <div className="text-test-secondary px-[5px] system-sm-medium"> - {t($ => $['onlineDrive.breadcrumbs.allBuckets'], { ns: 'datasetPipeline' })} + {t(($) => $['onlineDrive.breadcrumbs.allBuckets'], { ns: 'datasetPipeline' })} </div> )} {!showSearchResult && !showBucketListTitle && ( @@ -108,12 +121,7 @@ const Breadcrumbs = ({ showSeparator={breadcrumbs.length > 0} /> )} - {!hasBucket && ( - <Drive - breadcrumbs={breadcrumbs} - handleBackToRoot={handleBackToRoot} - /> - )} + {!hasBucket && <Drive breadcrumbs={breadcrumbs} handleBackToRoot={handleBackToRoot} />} {!breadcrumbsConfig.needCollapsed && ( <> {breadcrumbsConfig.original.map((breadcrumb, index) => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/item.tsx index 6af4628b804e4c..3c1eab3e757a62 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/item.tsx @@ -20,8 +20,7 @@ const BreadcrumbItem = ({ showSeparator = true, }: BreadcrumbItemProps) => { const handleClickItem = useCallback(() => { - if (!disabled) - handleClick(index) + if (!disabled) handleClick(index) }, [disabled, handleClick, index]) return ( @@ -30,7 +29,9 @@ const BreadcrumbItem = ({ type="button" className={cn( 'max-w-full shrink truncate rounded-md px-[5px] py-1', - isActive ? 'system-sm-medium text-text-secondary' : 'system-sm-regular text-text-tertiary', + isActive + ? 'system-sm-medium text-text-secondary' + : 'system-sm-regular text-text-tertiary', !disabled && 'hover:bg-state-base-hover', )} disabled={disabled} diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.tsx index d59732d34e2b1b..6c6e27f6cbdbf1 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.tsx @@ -39,7 +39,9 @@ const Header = ({ value={inputValue} onChange={handleInputChange} onClear={handleResetKeywords} - placeholder={t($ => $['onlineDrive.breadcrumbs.searchPlaceholder'], { ns: 'datasetPipeline' })} + placeholder={t(($) => $['onlineDrive.breadcrumbs.searchPlaceholder'], { + ns: 'datasetPipeline', + })} showLeftIcon showClearIcon wrapperClassName="w-[200px] h-8 shrink-0" diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-search-result.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-search-result.spec.tsx index 8b88a939e84ec9..8a4bc521074025 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-search-result.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-search-result.spec.tsx @@ -3,7 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import EmptySearchResult from '../empty-search-result' vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({ - SearchMenu: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="search-icon" {...props} />, + SearchMenu: (props: React.SVGProps<SVGSVGElement>) => ( + <svg data-testid="search-icon" {...props} /> + ), })) describe('EmptySearchResult', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/file-icon.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/file-icon.spec.tsx index 3377d4099d2270..062b4da8e91975 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/file-icon.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/file-icon.spec.tsx @@ -7,7 +7,9 @@ vi.mock('@/app/components/base/file-uploader/file-type-icon', () => ({ default: ({ type }: { type: string }) => <span data-testid="file-type-icon">{type}</span>, })) vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({ - BucketsBlue: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="bucket-icon" {...props} />, + BucketsBlue: (props: React.SVGProps<SVGSVGElement>) => ( + <svg data-testid="bucket-icon" {...props} /> + ), Folder: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="folder-icon" {...props} />, })) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/index.spec.tsx index 1abec1c774913a..7219155c0278fb 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/index.spec.tsx @@ -8,7 +8,13 @@ import List from '../index' // Mock Item component for List tests - child component with complex behavior vi.mock('../item', () => ({ - default: ({ file, isSelected, onSelect, onOpen, isMultipleChoice }: { + default: ({ + file, + isSelected, + onSelect, + onOpen, + isMultipleChoice, + }: { file: OnlineDriveFile isSelected: boolean onSelect: (file: OnlineDriveFile) => void @@ -22,8 +28,12 @@ vi.mock('../item', () => ({ data-multiple-choice={isMultipleChoice} > <span data-testid={`item-name-${file.id}`}>{file.name}</span> - <button data-testid={`item-select-${file.id}`} onClick={() => onSelect(file)}>Select</button> - <button data-testid={`item-open-${file.id}`} onClick={() => onOpen(file)}>Open</button> + <button data-testid={`item-select-${file.id}`} onClick={() => onSelect(file)}> + Select + </button> + <button data-testid={`item-open-${file.id}`} onClick={() => onOpen(file)}> + Open + </button> </div> ) }, @@ -31,9 +41,7 @@ vi.mock('../item', () => ({ // Mock EmptyFolder component for List tests vi.mock('../empty-folder', () => ({ - default: () => ( - <div data-testid="empty-folder">Empty Folder</div> - ), + default: () => <div data-testid="empty-folder">Empty Folder</div>, })) // Mock EmptySearchResult component for List tests @@ -41,7 +49,9 @@ vi.mock('../empty-search-result', () => ({ default: ({ onResetKeywords }: { onResetKeywords: () => void }) => ( <div data-testid="empty-search-result"> <span>No results</span> - <button data-testid="reset-keywords-btn" onClick={onResetKeywords}>Reset</button> + <button data-testid="reset-keywords-btn" onClick={onResetKeywords}> + Reset + </button> </div> ), })) @@ -73,11 +83,13 @@ const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): Online }) const createMockFileList = (count: number): OnlineDriveFile[] => { - return Array.from({ length: count }, (_, index) => createMockOnlineDriveFile({ - id: `file-${index + 1}`, - name: `file-${index + 1}.txt`, - size: (index + 1) * 1024, - })) + return Array.from({ length: count }, (_, index) => + createMockOnlineDriveFile({ + id: `file-${index + 1}`, + name: `file-${index + 1}.txt`, + size: (index + 1) * 1024, + }), + ) } type ListProps = React.ComponentProps<typeof List> @@ -128,15 +140,17 @@ const createMockIntersectionObserver = () => { const triggerIntersection = (isIntersecting: boolean) => { if (mockIntersectionObserverCallback) { - const entries = [{ - isIntersecting, - boundingClientRect: {} as DOMRectReadOnly, - intersectionRatio: isIntersecting ? 1 : 0, - intersectionRect: {} as DOMRectReadOnly, - rootBounds: null, - target: document.createElement('div'), - time: Date.now(), - }] as IntersectionObserverEntry[] + const entries = [ + { + isIntersecting, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: isIntersecting ? 1 : 0, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + target: document.createElement('div'), + time: Date.now(), + }, + ] as IntersectionObserverEntry[] mockIntersectionObserverCallback(entries, {} as IntersectionObserver) } } @@ -158,7 +172,8 @@ describe('List', () => { mockIntersectionObserverInstance = null // Setup IntersectionObserver mock - window.IntersectionObserver = createMockIntersectionObserver() as unknown as typeof IntersectionObserver + window.IntersectionObserver = + createMockIntersectionObserver() as unknown as typeof IntersectionObserver }) afterEach(() => { @@ -349,30 +364,38 @@ describe('List', () => { describe('isLoading prop', () => { it.each([ { isLoading: true, fileList: [], keywords: '', expected: 'isAllLoading' }, - { isLoading: true, fileList: createMockFileList(2), keywords: '', expected: 'isPartialLoading' }, + { + isLoading: true, + fileList: createMockFileList(2), + keywords: '', + expected: 'isPartialLoading', + }, { isLoading: false, fileList: [], keywords: '', expected: 'isEmpty' }, { isLoading: false, fileList: createMockFileList(2), keywords: '', expected: 'hasFiles' }, - ])('should render correctly when isLoading=$isLoading with fileList.length=$fileList.length', ({ isLoading, fileList, expected }) => { - const props = createDefaultProps({ isLoading, fileList }) - - render(<List {...props} />) - - switch (expected) { - case 'isAllLoading': - expect(screen.getByRole('status')).toBeInTheDocument() - break - case 'isPartialLoading': - expect(screen.getByRole('status')).toBeInTheDocument() - expect(screen.getByTestId('item-file-1')).toBeInTheDocument() - break - case 'isEmpty': - expect(screen.getByTestId('empty-folder')).toBeInTheDocument() - break - case 'hasFiles': - expect(screen.getByTestId('item-file-1')).toBeInTheDocument() - break - } - }) + ])( + 'should render correctly when isLoading=$isLoading with fileList.length=$fileList.length', + ({ isLoading, fileList, expected }) => { + const props = createDefaultProps({ isLoading, fileList }) + + render(<List {...props} />) + + switch (expected) { + case 'isAllLoading': + expect(screen.getByRole('status')).toBeInTheDocument() + break + case 'isPartialLoading': + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + break + case 'isEmpty': + expect(screen.getByTestId('empty-folder')).toBeInTheDocument() + break + case 'hasFiles': + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + break + } + }, + ) }) describe('supportBatchUpload prop', () => { @@ -446,7 +469,11 @@ describe('List', () => { it('should call handleOpenFolder when opening a folder', () => { const handleOpenFolder = vi.fn() const fileList = [ - createMockOnlineDriveFile({ id: 'folder-1', name: 'Documents', type: OnlineDriveFileType.folder }), + createMockOnlineDriveFile({ + id: 'folder-1', + name: 'Documents', + type: OnlineDriveFileType.folder, + }), ] const props = createDefaultProps({ fileList, @@ -786,9 +813,21 @@ describe('List', () => { it('should handle mixed file types in list', () => { const fileList = [ - createMockOnlineDriveFile({ id: 'file-1', type: OnlineDriveFileType.file, name: 'doc.pdf' }), - createMockOnlineDriveFile({ id: 'folder-1', type: OnlineDriveFileType.folder, name: 'Documents' }), - createMockOnlineDriveFile({ id: 'bucket-1', type: OnlineDriveFileType.bucket, name: 'my-bucket' }), + createMockOnlineDriveFile({ + id: 'file-1', + type: OnlineDriveFileType.file, + name: 'doc.pdf', + }), + createMockOnlineDriveFile({ + id: 'folder-1', + type: OnlineDriveFileType.folder, + name: 'Documents', + }), + createMockOnlineDriveFile({ + id: 'bucket-1', + type: OnlineDriveFileType.bucket, + name: 'my-bucket', + }), ] const props = createDefaultProps({ fileList }) @@ -887,20 +926,20 @@ describe('List', () => { }) describe('Prop Variations', () => { - it.each([ - { supportBatchUpload: true }, - { supportBatchUpload: false }, - ])('should render correctly with supportBatchUpload=$supportBatchUpload', ({ supportBatchUpload }) => { - const fileList = createMockFileList(2) - const props = createDefaultProps({ fileList, supportBatchUpload }) + it.each([{ supportBatchUpload: true }, { supportBatchUpload: false }])( + 'should render correctly with supportBatchUpload=$supportBatchUpload', + ({ supportBatchUpload }) => { + const fileList = createMockFileList(2) + const props = createDefaultProps({ fileList, supportBatchUpload }) - render(<List {...props} />) + render(<List {...props} />) - expect(screen.getByTestId('item-file-1')).toHaveAttribute( - 'data-multiple-choice', - String(supportBatchUpload), - ) - }) + expect(screen.getByTestId('item-file-1')).toHaveAttribute( + 'data-multiple-choice', + String(supportBatchUpload), + ) + }, + ) it.each([ { isLoading: true, fileCount: 0, keywords: '', expectedState: 'all-loading' }, @@ -908,31 +947,34 @@ describe('List', () => { { isLoading: false, fileCount: 0, keywords: '', expectedState: 'empty-folder' }, { isLoading: false, fileCount: 0, keywords: 'search', expectedState: 'empty-search' }, { isLoading: false, fileCount: 5, keywords: '', expectedState: 'file-list' }, - ])('should render $expectedState when isLoading=$isLoading, fileCount=$fileCount, keywords=$keywords', ({ isLoading, fileCount, keywords, expectedState }) => { - const fileList = createMockFileList(fileCount) - const props = createDefaultProps({ fileList, isLoading, keywords }) + ])( + 'should render $expectedState when isLoading=$isLoading, fileCount=$fileCount, keywords=$keywords', + ({ isLoading, fileCount, keywords, expectedState }) => { + const fileList = createMockFileList(fileCount) + const props = createDefaultProps({ fileList, isLoading, keywords }) - render(<List {...props} />) + render(<List {...props} />) - switch (expectedState) { - case 'all-loading': - expect(screen.getByRole('status')).toBeInTheDocument() - break - case 'partial-loading': - expect(screen.getByRole('status')).toBeInTheDocument() - expect(screen.getByTestId('item-file-1')).toBeInTheDocument() - break - case 'empty-folder': - expect(screen.getByTestId('empty-folder')).toBeInTheDocument() - break - case 'empty-search': - expect(screen.getByTestId('empty-search-result')).toBeInTheDocument() - break - case 'file-list': - expect(screen.getByTestId('item-file-1')).toBeInTheDocument() - break - } - }) + switch (expectedState) { + case 'all-loading': + expect(screen.getByRole('status')).toBeInTheDocument() + break + case 'partial-loading': + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + break + case 'empty-folder': + expect(screen.getByTestId('empty-folder')).toBeInTheDocument() + break + case 'empty-search': + expect(screen.getByTestId('empty-search-result')).toBeInTheDocument() + break + case 'file-list': + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + break + } + }, + ) it.each([ { selectedCount: 0, expectedSelected: [] }, @@ -949,7 +991,10 @@ describe('List', () => { fileList.forEach((file) => { const isSelected = expectedSelected.includes(file.id) - expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', String(isSelected)) + expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute( + 'data-selected', + String(isSelected), + ) }) }) }) @@ -1020,7 +1065,9 @@ describe('EmptySearchResult', () => { let ActualEmptySearchResult: React.ComponentType<{ onResetKeywords: () => void }> beforeAll(async () => { - const mod = await vi.importActual<{ default: React.ComponentType<{ onResetKeywords: () => void }> }>('../empty-search-result') + const mod = await vi.importActual<{ + default: React.ComponentType<{ onResetKeywords: () => void }> + }>('../empty-search-result') ActualEmptySearchResult = mod.default }) @@ -1038,7 +1085,9 @@ describe('EmptySearchResult', () => { it('should render empty search result message', () => { const onResetKeywords = vi.fn() render(<ActualEmptySearchResult onResetKeywords={onResetKeywords} />) - expect(screen.getByText(/datasetPipeline\.onlineDrive\.emptySearchResult/)).toBeInTheDocument() + expect( + screen.getByText(/datasetPipeline\.onlineDrive\.emptySearchResult/), + ).toBeInTheDocument() }) it('should render reset keywords button', () => { @@ -1093,7 +1142,9 @@ describe('EmptySearchResult', () => { it('should have readable text content', () => { const onResetKeywords = vi.fn() render(<ActualEmptySearchResult onResetKeywords={onResetKeywords} />) - expect(screen.getByText(/datasetPipeline\.onlineDrive\.emptySearchResult/)).toBeInTheDocument() + expect( + screen.getByText(/datasetPipeline\.onlineDrive\.emptySearchResult/), + ).toBeInTheDocument() }) }) }) @@ -1101,11 +1152,18 @@ describe('EmptySearchResult', () => { // FileIcon Component Tests (using actual component) describe('FileIcon', () => { // Get real component for testing - type FileIconProps = { type: OnlineDriveFileType, fileName: string, size?: 'sm' | 'md' | 'lg' | 'xl', className?: string } + type FileIconProps = { + type: OnlineDriveFileType + fileName: string + size?: 'sm' | 'md' | 'lg' | 'xl' + className?: string + } let ActualFileIcon: React.ComponentType<FileIconProps> beforeAll(async () => { - const mod = await vi.importActual<{ default: React.ComponentType<FileIconProps> }>('../file-icon') + const mod = await vi.importActual<{ default: React.ComponentType<FileIconProps> }>( + '../file-icon', + ) ActualFileIcon = mod.default }) @@ -1152,9 +1210,7 @@ describe('FileIcon', () => { { type: OnlineDriveFileType.folder, fileName: 'folder-name' }, { type: OnlineDriveFileType.file, fileName: 'file.txt' }, ])('should render correctly for type=$type', ({ type, fileName }) => { - const { container } = render( - <ActualFileIcon type={type} fileName={fileName} />, - ) + const { container } = render(<ActualFileIcon type={type} fileName={fileName} />) expect(container.firstChild).toBeInTheDocument() }) }) @@ -1229,9 +1285,7 @@ describe('FileIcon', () => { describe('Edge Cases', () => { it('should handle empty fileName', () => { - const { container } = render( - <ActualFileIcon type={OnlineDriveFileType.file} fileName="" />, - ) + const { container } = render(<ActualFileIcon type={OnlineDriveFileType.file} fileName="" />) expect(container.firstChild).toBeInTheDocument() }) @@ -1320,7 +1374,11 @@ describe('Item', () => { it('should not render file size for folder type', () => { const props = createItemProps({ - file: createMockOnlineDriveFile({ size: 1024, type: OnlineDriveFileType.folder, name: 'Documents' }), + file: createMockOnlineDriveFile({ + size: 1024, + type: OnlineDriveFileType.folder, + name: 'Documents', + }), }) render(<ActualItem {...props} />) expect(screen.queryByText('1 KB')).not.toBeInTheDocument() @@ -1445,7 +1503,10 @@ describe('Item', () => { it('should call onOpen when clicking on folder item', () => { const onOpen = vi.fn() - const file = createMockOnlineDriveFile({ type: OnlineDriveFileType.folder, name: 'Documents' }) + const file = createMockOnlineDriveFile({ + type: OnlineDriveFileType.folder, + name: 'Documents', + }) const props = createItemProps({ file, onOpen }) render(<ActualItem {...props} />) fireEvent.click(screen.getByText('Documents')) @@ -1454,7 +1515,10 @@ describe('Item', () => { it('should call onOpen when clicking on bucket item', () => { const onOpen = vi.fn() - const file = createMockOnlineDriveFile({ type: OnlineDriveFileType.bucket, name: 'my-bucket' }) + const file = createMockOnlineDriveFile({ + type: OnlineDriveFileType.bucket, + name: 'my-bucket', + }) const props = createItemProps({ file, onOpen }) render(<ActualItem {...props} />) fireEvent.click(screen.getByText('my-bucket')) @@ -1490,8 +1554,7 @@ describe('Item', () => { <RadioGroup aria-label="Files" onValueChange={(fileId) => { - if (fileId === file.id) - onSelect(file) + if (fileId === file.id) onSelect(file) }} > <ActualItem {...props} /> @@ -1548,7 +1611,9 @@ describe('Item', () => { }) it('should handle very large file size', () => { - const props = createItemProps({ file: createMockOnlineDriveFile({ size: 1024 * 1024 * 1024 * 5 }) }) + const props = createItemProps({ + file: createMockOnlineDriveFile({ size: 1024 * 1024 * 1024 * 5 }), + }) render(<ActualItem {...props} />) expect(screen.getByText('5.00 GB')).toBeInTheDocument() }) @@ -1563,8 +1628,13 @@ describe('utils', () => { let FileAppearanceTypeEnum: Record<string, string> beforeAll(async () => { - const utils = await vi.importActual<{ getFileExtension: typeof getFileExtension, getFileType: typeof getFileType }>('../utils') - const types = await vi.importActual<{ FileAppearanceTypeEnum: typeof FileAppearanceTypeEnum }>('@/app/components/base/file-uploader/types') + const utils = await vi.importActual<{ + getFileExtension: typeof getFileExtension + getFileType: typeof getFileType + }>('../utils') + const types = await vi.importActual<{ FileAppearanceTypeEnum: typeof FileAppearanceTypeEnum }>( + '@/app/components/base/file-uploader/types', + ) getFileExtension = utils.getFileExtension getFileType = utils.getFileType FileAppearanceTypeEnum = types.FileAppearanceTypeEnum diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.tsx index dbffc9f65578af..fd2b7d89bba3b7 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.tsx @@ -6,7 +6,9 @@ const EmptyFolder = () => { return ( <div className="flex size-full items-center justify-center rounded-[10px] bg-background-section px-1 py-1.5"> - <span className="system-xs-regular text-text-tertiary">{t($ => $['onlineDrive.emptyFolder'], { ns: 'datasetPipeline' })}</span> + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['onlineDrive.emptyFolder'], { ns: 'datasetPipeline' })} + </span> </div> ) } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-search-result.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-search-result.tsx index e5b0f5d592cc8a..c30c4c231c0021 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-search-result.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-search-result.tsx @@ -18,15 +18,12 @@ const EmptySearchResult = ({ <div className="flex size-full flex-col items-center justify-center gap-y-2 rounded-[10px] bg-background-section p-6"> <SearchMenu className="size-8 text-text-tertiary" /> <div className="system-sm-regular text-text-secondary"> - {t($ => $['onlineDrive.emptySearchResult'], { ns: 'datasetPipeline' })} + {t(($) => $['onlineDrive.emptySearchResult'], { ns: 'datasetPipeline' })} </div> - <Button - variant="secondary-accent" - size="small" - onClick={onResetKeywords} - className="px-1.5" - > - <span className="px-[3px]">{t($ => $['onlineDrive.resetKeywords'], { ns: 'datasetPipeline' })}</span> + <Button variant="secondary-accent" size="small" onClick={onResetKeywords} className="px-1.5"> + <span className="px-[3px]"> + {t(($) => $['onlineDrive.resetKeywords'], { ns: 'datasetPipeline' })} + </span> </Button> </div> ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/file-icon.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/file-icon.tsx index 81a6df9ec6ed0f..b2a4912bbddd94 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/file-icon.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/file-icon.tsx @@ -13,38 +13,22 @@ type FileIconProps = { className?: string } -const FileIcon = ({ - type, - fileName, - size = 'md', - className, -}: FileIconProps) => { +const FileIcon = ({ type, fileName, size = 'md', className }: FileIconProps) => { const fileType = useMemo(() => { - if (type === OnlineDriveFileType.bucket || type === OnlineDriveFileType.folder) - return 'custom' + if (type === OnlineDriveFileType.bucket || type === OnlineDriveFileType.folder) return 'custom' return getFileType(fileName) }, [type, fileName]) if (type === OnlineDriveFileType.bucket) { - return ( - <BucketsBlue className={cn('size-[18px]', className)} /> - ) + return <BucketsBlue className={cn('size-[18px]', className)} /> } if (type === OnlineDriveFileType.folder) { - return ( - <Folder className={cn('size-[18px]', className)} /> - ) + return <Folder className={cn('size-[18px]', className)} /> } - return ( - <FileTypeIcon - size={size} - type={fileType} - className={cn('size-[18px]', className)} - /> - ) + return <FileTypeIcon size={size} type={fileType} className={cn('size-[18px]', className)} /> } export default React.memo(FileIcon) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.tsx index f2ffc05e1fa045..2ba1c033c1893c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.tsx @@ -38,13 +38,17 @@ const List = ({ useEffect(() => { if (anchorRef.current) { - observerRef.current = new IntersectionObserver((entries) => { - const { setNextPageParameters, currentNextPageParametersRef, isTruncated } = dataSourceStore.getState() - if (entries[0]!.isIntersecting && isTruncated.current && !isLoading) - setNextPageParameters(currentNextPageParametersRef.current) - }, { - rootMargin: '100px', - }) + observerRef.current = new IntersectionObserver( + (entries) => { + const { setNextPageParameters, currentNextPageParametersRef, isTruncated } = + dataSourceStore.getState() + if (entries[0]!.isIntersecting && isTruncated.current && !isLoading) + setNextPageParameters(currentNextPageParametersRef.current) + }, + { + rootMargin: '100px', + }, + ) observerRef.current.observe(anchorRef.current) } return () => observerRef.current?.disconnect() @@ -56,9 +60,8 @@ const List = ({ const isSearchResultEmpty = !isLoading && fileList.length === 0 && keywords.length > 0 const selectedFileId = selectedFileIds[0] const handleRadioChange = (fileId: string) => { - const selectedFile = fileList.find(file => file.id === fileId) - if (selectedFile) - handleSelectFile(selectedFile) + const selectedFile = fileList.find((file) => file.id === fileId) + if (selectedFile) handleSelectFile(selectedFile) } const fileItems = fileList.map((file) => { const isSelected = selectedFileIds.includes(file.id) @@ -76,47 +79,35 @@ const List = ({ return ( <div className="grow overflow-hidden p-1 pt-0"> - { - isAllLoading && ( - <Loading type="app" /> - ) - } - { - isEmptyFolder && ( - <EmptyFolder /> - ) - } - { - isSearchResultEmpty && ( - <EmptySearchResult onResetKeywords={handleResetKeywords} /> - ) - } + {isAllLoading && <Loading type="app" />} + {isEmptyFolder && <EmptyFolder />} + {isSearchResultEmpty && <EmptySearchResult onResetKeywords={handleResetKeywords} />} {fileList.length > 0 && ( <div className="flex h-full flex-col gap-y-px overflow-y-auto rounded-[10px] bg-background-section px-1 py-1.5"> - {supportBatchUpload - ? fileItems - : ( - <RadioGroup - aria-label={t($ => $['onlineDrive.breadcrumbs.allFiles'], { ns: 'datasetPipeline' })} - value={selectedFileId} - onValueChange={handleRadioChange} - className="contents" - > - {fileItems} - </RadioGroup> - )} - { - isPartialLoading && ( - <div - className="flex items-center justify-center py-2" - role="status" - aria-live="polite" - aria-label={t($ => $.loading, { ns: 'appApi' })} - > - <RiLoader2Line className="animation-spin size-4 text-text-tertiary" /> - </div> - ) - } + {supportBatchUpload ? ( + fileItems + ) : ( + <RadioGroup + aria-label={t(($) => $['onlineDrive.breadcrumbs.allFiles'], { + ns: 'datasetPipeline', + })} + value={selectedFileId} + onValueChange={handleRadioChange} + className="contents" + > + {fileItems} + </RadioGroup> + )} + {isPartialLoading && ( + <div + className="flex items-center justify-center py-2" + role="status" + aria-live="polite" + aria-label={t(($) => $.loading, { ns: 'appApi' })} + > + <RiLoader2Line className="animation-spin size-4 text-text-tertiary" /> + </div> + )} <div ref={anchorRef} className="h-0" /> </div> )} diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx index 0d8109d43eb64d..6a0fe3d5588cfc 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx @@ -31,22 +31,24 @@ const Item = ({ const isBucket = type === 'bucket' const isFolder = type === 'folder' - const disabledTip = t($ => $['onlineDrive.notSupportedFileType'], { ns: 'datasetPipeline' }) + const disabledTip = t(($) => $['onlineDrive.notSupportedFileType'], { ns: 'datasetPipeline' }) const handleCheckboxSelect = useCallback(() => { onSelect(file) }, [file, onSelect]) - const handleClickItem = useCallback((e: React.MouseEvent<HTMLDivElement>) => { - e.stopPropagation() - if (disabled) - return - if (isBucket || isFolder) { - onOpen(file) - return - } - onSelect(file) - }, [disabled, file, isBucket, isFolder, onOpen, onSelect]) + const handleClickItem = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + e.stopPropagation() + if (disabled) return + if (isBucket || isFolder) { + onOpen(file) + return + } + onSelect(file) + }, + [disabled, file, isBucket, isFolder, onOpen, onSelect], + ) return ( <div @@ -54,7 +56,7 @@ const Item = ({ onClick={handleClickItem} > {!isBucket && isMultipleChoice && ( - <span onClick={event => event.stopPropagation()}> + <span onClick={(event) => event.stopPropagation()}> <Checkbox className="shrink-0" disabled={disabled} @@ -65,53 +67,47 @@ const Item = ({ </span> )} {!isBucket && !isMultipleChoice && ( - <span onClick={event => event.stopPropagation()}> - <Radio - className="shrink-0" - disabled={disabled} - value={file.id} - aria-label={name} - /> + <span onClick={(event) => event.stopPropagation()}> + <Radio className="shrink-0" disabled={disabled} value={file.id} aria-label={name} /> </span> )} - {disabled - ? ( - <Popover> - <PopoverTrigger - openOnHover - aria-label={disabledTip} - className="flex grow items-center gap-x-1 overflow-hidden border-0 bg-transparent p-0 py-0.5 text-left opacity-30" - > - <FileIcon type={type} fileName={name} className="shrink-0 transform-gpu" /> - <span - className="grow truncate system-sm-medium text-text-secondary" - title={name} - > - {name} - </span> - {!isFolder && typeof size === 'number' && ( - <span className="shrink-0 system-xs-regular text-text-tertiary">{formatFileSize(size)}</span> - )} - </PopoverTrigger> - <PopoverContent placement="top-end" popupClassName="px-3 py-2 system-xs-regular text-text-tertiary"> - {disabledTip} - </PopoverContent> - </Popover> - ) - : ( - <div className="flex grow items-center gap-x-1 overflow-hidden py-0.5"> - <FileIcon type={type} fileName={name} className="shrink-0 transform-gpu" /> - <span - className="grow truncate system-sm-medium text-text-secondary" - title={name} - > - {name} + {disabled ? ( + <Popover> + <PopoverTrigger + openOnHover + aria-label={disabledTip} + className="flex grow items-center gap-x-1 overflow-hidden border-0 bg-transparent p-0 py-0.5 text-left opacity-30" + > + <FileIcon type={type} fileName={name} className="shrink-0 transform-gpu" /> + <span className="grow truncate system-sm-medium text-text-secondary" title={name}> + {name} + </span> + {!isFolder && typeof size === 'number' && ( + <span className="shrink-0 system-xs-regular text-text-tertiary"> + {formatFileSize(size)} </span> - {!isFolder && typeof size === 'number' && ( - <span className="shrink-0 system-xs-regular text-text-tertiary">{formatFileSize(size)}</span> - )} - </div> + )} + </PopoverTrigger> + <PopoverContent + placement="top-end" + popupClassName="px-3 py-2 system-xs-regular text-text-tertiary" + > + {disabledTip} + </PopoverContent> + </Popover> + ) : ( + <div className="flex grow items-center gap-x-1 overflow-hidden py-0.5"> + <FileIcon type={type} fileName={name} className="shrink-0 transform-gpu" /> + <span className="grow truncate system-sm-medium text-text-secondary" title={name}> + {name} + </span> + {!isFolder && typeof size === 'number' && ( + <span className="shrink-0 system-xs-regular text-text-tertiary"> + {formatFileSize(size)} + </span> )} + </div> + )} </div> ) } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/utils.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/utils.ts index 07b23a56ff8d3a..2ff6d7fb0d3503 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/utils.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/utils.ts @@ -2,11 +2,9 @@ import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/type import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' export const getFileExtension = (fileName: string): string => { - if (!fileName) - return '' + if (!fileName) return '' const parts = fileName.split('.') - if (parts.length <= 1 || (parts[0] === '' && parts.length === 2)) - return '' + if (parts.length <= 1 || (parts[0] === '' && parts.length === 2)) return '' return parts[parts.length - 1]!.toLowerCase() } @@ -14,23 +12,18 @@ export const getFileExtension = (fileName: string): string => { export const getFileType = (fileName: string) => { const extension = getFileExtension(fileName) - if (extension === 'gif') - return FileAppearanceTypeEnum.gif + if (extension === 'gif') return FileAppearanceTypeEnum.gif - if (FILE_EXTS.image!.includes(extension.toUpperCase())) - return FileAppearanceTypeEnum.image + if (FILE_EXTS.image!.includes(extension.toUpperCase())) return FileAppearanceTypeEnum.image - if (FILE_EXTS.video!.includes(extension.toUpperCase())) - return FileAppearanceTypeEnum.video + if (FILE_EXTS.video!.includes(extension.toUpperCase())) return FileAppearanceTypeEnum.video - if (FILE_EXTS.audio!.includes(extension.toUpperCase())) - return FileAppearanceTypeEnum.audio + if (FILE_EXTS.audio!.includes(extension.toUpperCase())) return FileAppearanceTypeEnum.audio if (extension === 'html' || extension === 'htm' || extension === 'xml' || extension === 'json') return FileAppearanceTypeEnum.code - if (extension === 'pdf') - return FileAppearanceTypeEnum.pdf + if (extension === 'pdf') return FileAppearanceTypeEnum.pdf if (extension === 'md' || extension === 'markdown' || extension === 'mdx') return FileAppearanceTypeEnum.markdown @@ -38,14 +31,11 @@ export const getFileType = (fileName: string) => { if (extension === 'xlsx' || extension === 'xls' || extension === 'csv') return FileAppearanceTypeEnum.excel - if (extension === 'docx' || extension === 'doc') - return FileAppearanceTypeEnum.word + if (extension === 'docx' || extension === 'doc') return FileAppearanceTypeEnum.word - if (extension === 'pptx' || extension === 'ppt') - return FileAppearanceTypeEnum.ppt + if (extension === 'pptx' || extension === 'ppt') return FileAppearanceTypeEnum.ppt - if (FILE_EXTS.document!.includes(extension.toUpperCase())) - return FileAppearanceTypeEnum.document + if (FILE_EXTS.document!.includes(extension.toUpperCase())) return FileAppearanceTypeEnum.document return FileAppearanceTypeEnum.custom } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx index 2d5af6b6b22081..f8c94e761cb12c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx @@ -34,7 +34,7 @@ const OnlineDrive = ({ }: OnlineDriveProps) => { const docLink = useDocLink() const [isInitialMount, setIsInitialMount] = useState(true) - const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id) + const pipelineId = useDatasetDetailContextWithSelector((s) => s.dataset?.pipeline_id) const openIntegrationsSetting = useIntegrationsSetting() const { nextPageParameters, @@ -45,16 +45,18 @@ const OnlineDrive = ({ selectedFileIds, onlineDriveFileList, currentCredentialId, - } = useDataSourceStoreWithSelector(useShallow(state => ({ - nextPageParameters: state.nextPageParameters, - breadcrumbs: state.breadcrumbs, - prefix: state.prefix, - keywords: state.keywords, - bucket: state.bucket, - selectedFileIds: state.selectedFileIds, - onlineDriveFileList: state.onlineDriveFileList, - currentCredentialId: state.currentCredentialId, - }))) + } = useDataSourceStoreWithSelector( + useShallow((state) => ({ + nextPageParameters: state.nextPageParameters, + breadcrumbs: state.breadcrumbs, + prefix: state.prefix, + keywords: state.keywords, + bucket: state.bucket, + selectedFileIds: state.selectedFileIds, + onlineDriveFileList: state.onlineDriveFileList, + currentCredentialId: state.currentCredentialId, + })), + ) const dataSourceStore = useDataSourceStore() const [isLoading, setIsLoading] = useState(false) const isLoadingRef = useRef(false) @@ -69,9 +71,9 @@ const OnlineDrive = ({ : `/rag/pipelines/${pipelineId}/workflows/draft/datasource/nodes/${nodeId}/run` const getOnlineDriveFiles = useCallback(async () => { - if (isLoadingRef.current) - return - const { nextPageParameters, prefix, bucket, onlineDriveFileList, currentCredentialId } = dataSourceStore.getState() + if (isLoadingRef.current) return + const { nextPageParameters, prefix, bucket, onlineDriveFileList, currentCredentialId } = + dataSourceStore.getState() setIsLoading(true) isLoadingRef.current = true ssePost( @@ -90,7 +92,12 @@ const OnlineDrive = ({ }, { onDataSourceNodeCompleted: (documentsData: DataSourceNodeCompletedResponse) => { - const { setOnlineDriveFileList, isTruncated, currentNextPageParametersRef, setHasBucket } = dataSourceStore.getState() + const { + setOnlineDriveFileList, + isTruncated, + currentNextPageParametersRef, + setHasBucket, + } = dataSourceStore.getState() const { fileList: newFileList, isTruncated: newIsTruncated, @@ -114,29 +121,31 @@ const OnlineDrive = ({ }, [dataSourceStore, datasourceNodeRunURL, breadcrumbs]) useEffect(() => { - if (!currentCredentialId) - return + if (!currentCredentialId) return if (isInitialMount) { // Only fetch files on initial mount if fileList is empty - if (onlineDriveFileList.length === 0) - getOnlineDriveFiles() + if (onlineDriveFileList.length === 0) getOnlineDriveFiles() setIsInitialMount(false) - } - else { + } else { getOnlineDriveFiles() } }, [nextPageParameters, prefix, bucket, currentCredentialId]) const filteredOnlineDriveFileList = useMemo(() => { if (keywords) - return onlineDriveFileList.filter(file => file.name.toLowerCase().includes(keywords.toLowerCase())) + return onlineDriveFileList.filter((file) => + file.name.toLowerCase().includes(keywords.toLowerCase()), + ) return onlineDriveFileList }, [onlineDriveFileList, keywords]) - const updateKeywords = useCallback((keywords: string) => { - const { setKeywords } = dataSourceStore.getState() - setKeywords(keywords) - }, [dataSourceStore]) + const updateKeywords = useCallback( + (keywords: string) => { + const { setKeywords } = dataSourceStore.getState() + setKeywords(keywords) + }, + [dataSourceStore], + ) const resetKeywords = useCallback(() => { const { setKeywords } = dataSourceStore.getState() @@ -144,44 +153,53 @@ const OnlineDrive = ({ setKeywords('') }, [dataSourceStore]) - const handleSelectFile = useCallback((file: OnlineDriveFile) => { - const { selectedFileIds, setSelectedFileIds } = dataSourceStore.getState() - if (file.type === OnlineDriveFileType.bucket) - return - const newSelectedFileList = produce(selectedFileIds, (draft) => { - if (draft.includes(file.id)) { - const index = draft.indexOf(file.id) - draft.splice(index, 1) - } - else { - if (!supportBatchUpload && draft.length >= 1) - return - draft.push(file.id) - } - }) - setSelectedFileIds(newSelectedFileList) - }, [dataSourceStore, supportBatchUpload]) - - const handleOpenFolder = useCallback((file: OnlineDriveFile) => { - const { breadcrumbs, prefix, setBreadcrumbs, setPrefix, setBucket, setOnlineDriveFileList, setSelectedFileIds } = dataSourceStore.getState() - if (file.type === OnlineDriveFileType.file) - return - setOnlineDriveFileList([]) - if (file.type === OnlineDriveFileType.bucket) { - setBucket(file.name) - } - else { - setSelectedFileIds([]) - const newBreadcrumbs = produce(breadcrumbs, (draft) => { - draft.push(file.name) - }) - const newPrefix = produce(prefix, (draft) => { - draft.push(file.id) + const handleSelectFile = useCallback( + (file: OnlineDriveFile) => { + const { selectedFileIds, setSelectedFileIds } = dataSourceStore.getState() + if (file.type === OnlineDriveFileType.bucket) return + const newSelectedFileList = produce(selectedFileIds, (draft) => { + if (draft.includes(file.id)) { + const index = draft.indexOf(file.id) + draft.splice(index, 1) + } else { + if (!supportBatchUpload && draft.length >= 1) return + draft.push(file.id) + } }) - setBreadcrumbs(newBreadcrumbs) - setPrefix(newPrefix) - } - }, [dataSourceStore]) + setSelectedFileIds(newSelectedFileList) + }, + [dataSourceStore, supportBatchUpload], + ) + + const handleOpenFolder = useCallback( + (file: OnlineDriveFile) => { + const { + breadcrumbs, + prefix, + setBreadcrumbs, + setPrefix, + setBucket, + setOnlineDriveFileList, + setSelectedFileIds, + } = dataSourceStore.getState() + if (file.type === OnlineDriveFileType.file) return + setOnlineDriveFileList([]) + if (file.type === OnlineDriveFileType.bucket) { + setBucket(file.name) + } else { + setSelectedFileIds([]) + const newBreadcrumbs = produce(breadcrumbs, (draft) => { + draft.push(file.name) + }) + const newPrefix = produce(prefix, (draft) => { + draft.push(file.id) + }) + setBreadcrumbs(newBreadcrumbs) + setPrefix(newPrefix) + } + }, + [dataSourceStore], + ) const handleSetting = useCallback(() => { openIntegrationsSetting({ diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/utils.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/utils.ts index 8f0ff13ac57540..a5b0de4f6073d0 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/utils.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/utils.ts @@ -6,14 +6,24 @@ export const isFile = (type: 'file' | 'folder'): boolean => { return type === 'file' } -export const isBucketListInitiation = (data: OnlineDriveData[], prefix: string[], bucket: string): boolean => { - if (bucket || prefix.length > 0) - return false - const hasBucket = data.every(item => !!item.bucket) - return hasBucket && (data.length > 1 || (data.length === 1 && !!data[0]!.bucket && data[0]!.files.length === 0)) +export const isBucketListInitiation = ( + data: OnlineDriveData[], + prefix: string[], + bucket: string, +): boolean => { + if (bucket || prefix.length > 0) return false + const hasBucket = data.every((item) => !!item.bucket) + return ( + hasBucket && + (data.length > 1 || (data.length === 1 && !!data[0]!.bucket && data[0]!.files.length === 0)) + ) } -export const convertOnlineDriveData = (data: OnlineDriveData[], prefix: string[], bucket: string): { +export const convertOnlineDriveData = ( + data: OnlineDriveData[], + prefix: string[], + bucket: string, +): { fileList: OnlineDriveFile[] isTruncated: boolean nextPageParameters: Record<string, any> @@ -24,8 +34,7 @@ export const convertOnlineDriveData = (data: OnlineDriveData[], prefix: string[] let nextPageParameters: Record<string, any> = {} let hasBucket = false - if (data.length === 0) - return { fileList, isTruncated, nextPageParameters, hasBucket } + if (data.length === 0) return { fileList, isTruncated, nextPageParameters, hasBucket } if (isBucketListInitiation(data, prefix, bucket)) { data.forEach((item) => { @@ -36,8 +45,7 @@ export const convertOnlineDriveData = (data: OnlineDriveData[], prefix: string[] }) }) hasBucket = true - } - else { + } else { data[0]!.files.forEach((file) => { const { id, name, size, type } = file const isFileType = isFile(type) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/index.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/index.spec.ts index 231cdcdfc2075d..d7977cc2128f13 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/index.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/index.spec.ts @@ -53,7 +53,7 @@ describe('createDataSourceStore', () => { describe('useDataSourceStoreWithSelector', () => { it('should throw when used outside provider', () => { expect(() => { - renderHook(() => useDataSourceStoreWithSelector(s => s.currentCredentialId)) + renderHook(() => useDataSourceStoreWithSelector((s) => s.currentCredentialId)) }).toThrow('Missing DataSourceContext.Provider in the tree') }) @@ -61,7 +61,7 @@ describe('useDataSourceStoreWithSelector', () => { const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(DataSourceProvider, null, children) const { result } = renderHook( - () => useDataSourceStoreWithSelector(s => s.currentCredentialId), + () => useDataSourceStoreWithSelector((s) => s.currentCredentialId), { wrapper }, ) expect(result.current).toBe('') @@ -78,10 +78,7 @@ describe('useDataSourceStore', () => { it('should return store when used inside provider', () => { const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(DataSourceProvider, null, children) - const { result } = renderHook( - () => useDataSourceStore(), - { wrapper }, - ) + const { result } = renderHook(() => useDataSourceStore(), { wrapper }) expect(result.current).toBeDefined() expect(typeof result.current.getState).toBe('function') }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/index.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/index.ts index 75b00b9f61868a..678ada374ab862 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/index.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/index.ts @@ -12,11 +12,11 @@ import { createOnlineDocumentSlice } from './slices/online-document' import { createOnlineDriveSlice } from './slices/online-drive' import { createWebsiteCrawlSlice } from './slices/website-crawl' -export type DataSourceShape = CommonShape - & LocalFileSliceShape - & OnlineDocumentSliceShape - & WebsiteCrawlSliceShape - & OnlineDriveSliceShape +export type DataSourceShape = CommonShape & + LocalFileSliceShape & + OnlineDocumentSliceShape & + WebsiteCrawlSliceShape & + OnlineDriveSliceShape export const createDataSourceStore = () => { return createStore<DataSourceShape>((...args) => ({ @@ -30,16 +30,14 @@ export const createDataSourceStore = () => { export const useDataSourceStoreWithSelector = <T>(selector: (state: DataSourceShape) => T): T => { const store = use(DataSourceContext) - if (!store) - throw new Error('Missing DataSourceContext.Provider in the tree') + if (!store) throw new Error('Missing DataSourceContext.Provider in the tree') return useStore(store, selector) } export const useDataSourceStore = () => { const store = use(DataSourceContext) - if (!store) - throw new Error('Missing DataSourceContext.Provider in the tree') + if (!store) throw new Error('Missing DataSourceContext.Provider in the tree') return store } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/provider.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/provider.tsx index a1d27021c8cd87..31d62e746602b1 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/provider.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/provider.tsx @@ -11,18 +11,13 @@ type DataSourceProviderProps = { children: React.ReactNode } -const DataSourceProvider = ({ - children, -}: DataSourceProviderProps) => { +const DataSourceProvider = ({ children }: DataSourceProviderProps) => { const storeRef = useRef<DataSourceStoreApi>(null) - if (!storeRef.current) - storeRef.current = createDataSourceStore() + if (!storeRef.current) storeRef.current = createDataSourceStore() return ( - <DataSourceContext.Provider value={storeRef.current!}> - {children} - </DataSourceContext.Provider> + <DataSourceContext.Provider value={storeRef.current!}>{children}</DataSourceContext.Provider> ) } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/local-file.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/local-file.spec.ts index f3ae03acde21ce..ac849f26a9821a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/local-file.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/local-file.spec.ts @@ -4,7 +4,8 @@ import { describe, expect, it } from 'vitest' import { createStore } from 'zustand' import { createLocalFileSlice } from '../local-file' -const createTestStore = () => createStore<LocalFileSliceShape>((...args) => createLocalFileSlice(...args)) +const createTestStore = () => + createStore<LocalFileSliceShape>((...args) => createLocalFileSlice(...args)) describe('createLocalFileSlice', () => { it('should initialize with default values', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-document.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-document.spec.ts index a98f56c19c854c..99a5017342fe6a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-document.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-document.spec.ts @@ -4,7 +4,8 @@ import { describe, expect, it } from 'vitest' import { createStore } from 'zustand' import { createOnlineDocumentSlice } from '../online-document' -const createTestStore = () => createStore<OnlineDocumentSliceShape>((...args) => createOnlineDocumentSlice(...args)) +const createTestStore = () => + createStore<OnlineDocumentSliceShape>((...args) => createOnlineDocumentSlice(...args)) describe('createOnlineDocumentSlice', () => { it('should initialize with default values', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-drive.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-drive.spec.ts index f0b61a62a269ef..c5df37c1ac1c3e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-drive.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-drive.spec.ts @@ -4,7 +4,8 @@ import { describe, expect, it } from 'vitest' import { createStore } from 'zustand' import { createOnlineDriveSlice } from '../online-drive' -const createTestStore = () => createStore<OnlineDriveSliceShape>((...args) => createOnlineDriveSlice(...args)) +const createTestStore = () => + createStore<OnlineDriveSliceShape>((...args) => createOnlineDriveSlice(...args)) describe('createOnlineDriveSlice', () => { it('should initialize with default values', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/website-crawl.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/website-crawl.spec.ts index a81ef61c03010e..ec32c2a0410017 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/website-crawl.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/website-crawl.spec.ts @@ -5,7 +5,8 @@ import { createStore } from 'zustand' import { CrawlStep } from '@/models/datasets' import { createWebsiteCrawlSlice } from '../website-crawl' -const createTestStore = () => createStore<WebsiteCrawlSliceShape>((...args) => createWebsiteCrawlSlice(...args)) +const createTestStore = () => + createStore<WebsiteCrawlSliceShape>((...args) => createWebsiteCrawlSlice(...args)) describe('createWebsiteCrawlSlice', () => { it('should initialize with default values', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/common.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/common.ts index 4519e0eb018ac3..7d4b140c61c2f8 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/common.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/common.ts @@ -8,12 +8,12 @@ export type CommonShape = { } export const createCommonSlice: StateCreator<CommonShape> = (set) => { - return ({ + return { currentNodeIdRef: { current: '' }, currentCredentialId: '', setCurrentCredentialId: (credentialId: string) => { set({ currentCredentialId: credentialId }) }, currentCredentialIdRef: { current: '' }, - }) + } } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/local-file.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/local-file.ts index f13ef6226485e0..b6bec6f2fb3a0b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/local-file.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/local-file.ts @@ -10,7 +10,7 @@ export type LocalFileSliceShape = { } export const createLocalFileSlice: StateCreator<LocalFileSliceShape> = (set, get) => { - return ({ + return { localFileList: [], setLocalFileList: (fileList: FileItem[]) => { set(() => ({ @@ -20,9 +20,10 @@ export const createLocalFileSlice: StateCreator<LocalFileSliceShape> = (set, get previewLocalFileRef.current = fileList[0]?.file as DocumentItem }, currentLocalFile: undefined, - setCurrentLocalFile: (file: File | undefined) => set(() => ({ - currentLocalFile: file, - })), + setCurrentLocalFile: (file: File | undefined) => + set(() => ({ + currentLocalFile: file, + })), previewLocalFileRef: { current: undefined }, - }) + } } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/online-document.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/online-document.ts index a68acadde1a6f5..1d4b202901e284 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/online-document.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/online-document.ts @@ -16,15 +16,17 @@ export type OnlineDocumentSliceShape = { } export const createOnlineDocumentSlice: StateCreator<OnlineDocumentSliceShape> = (set, get) => { - return ({ + return { documentsData: [], - setDocumentsData: (documentsData: DataSourceNotionWorkspace[]) => set(() => ({ - documentsData, - })), + setDocumentsData: (documentsData: DataSourceNotionWorkspace[]) => + set(() => ({ + documentsData, + })), searchValue: '', - setSearchValue: (searchValue: string) => set(() => ({ - searchValue, - })), + setSearchValue: (searchValue: string) => + set(() => ({ + searchValue, + })), onlineDocuments: [], setOnlineDocuments: (documents: NotionPage[]) => { set(() => ({ @@ -34,13 +36,15 @@ export const createOnlineDocumentSlice: StateCreator<OnlineDocumentSliceShape> = previewOnlineDocumentRef.current = documents[0] }, currentDocument: undefined, - setCurrentDocument: (document: NotionPage | undefined) => set(() => ({ - currentDocument: document, - })), + setCurrentDocument: (document: NotionPage | undefined) => + set(() => ({ + currentDocument: document, + })), selectedPagesId: new Set(), - setSelectedPagesId: (selectedPagesId: Set<string>) => set(() => ({ - selectedPagesId, - })), + setSelectedPagesId: (selectedPagesId: Set<string>) => + set(() => ({ + selectedPagesId, + })), previewOnlineDocumentRef: { current: undefined }, - }) + } } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/online-drive.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/online-drive.ts index 0a0ba990dfeab3..feebc674696c2c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/online-drive.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/online-drive.ts @@ -24,19 +24,22 @@ export type OnlineDriveSliceShape = { } export const createOnlineDriveSlice: StateCreator<OnlineDriveSliceShape> = (set, get) => { - return ({ + return { breadcrumbs: [], - setBreadcrumbs: (breadcrumbs: string[]) => set(() => ({ - breadcrumbs, - })), + setBreadcrumbs: (breadcrumbs: string[]) => + set(() => ({ + breadcrumbs, + })), prefix: [], - setPrefix: (prefix: string[]) => set(() => ({ - prefix, - })), + setPrefix: (prefix: string[]) => + set(() => ({ + prefix, + })), keywords: '', - setKeywords: (keywords: string) => set(() => ({ - keywords, - })), + setKeywords: (keywords: string) => + set(() => ({ + keywords, + })), selectedFileIds: [], setSelectedFileIds: (selectedFileIds: string[]) => { set(() => ({ @@ -44,26 +47,30 @@ export const createOnlineDriveSlice: StateCreator<OnlineDriveSliceShape> = (set, })) const id = selectedFileIds[0] const { onlineDriveFileList, previewOnlineDriveFileRef } = get() - previewOnlineDriveFileRef.current = onlineDriveFileList.find(file => file.id === id) + previewOnlineDriveFileRef.current = onlineDriveFileList.find((file) => file.id === id) }, onlineDriveFileList: [], - setOnlineDriveFileList: (onlineDriveFileList: OnlineDriveFile[]) => set(() => ({ - onlineDriveFileList, - })), + setOnlineDriveFileList: (onlineDriveFileList: OnlineDriveFile[]) => + set(() => ({ + onlineDriveFileList, + })), bucket: '', - setBucket: (bucket: string) => set(() => ({ - bucket, - })), + setBucket: (bucket: string) => + set(() => ({ + bucket, + })), nextPageParameters: {}, currentNextPageParametersRef: { current: {} }, - setNextPageParameters: (nextPageParameters: Record<string, any>) => set(() => ({ - nextPageParameters, - })), + setNextPageParameters: (nextPageParameters: Record<string, any>) => + set(() => ({ + nextPageParameters, + })), isTruncated: { current: false }, previewOnlineDriveFileRef: { current: undefined }, hasBucket: false, - setHasBucket: (hasBucket: boolean) => set(() => ({ - hasBucket, - })), - }) + setHasBucket: (hasBucket: boolean) => + set(() => ({ + hasBucket, + })), + } } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/website-crawl.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/website-crawl.ts index 51e8e5ede4eab7..df7c3ec4e5de2c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/website-crawl.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/website-crawl.ts @@ -17,7 +17,7 @@ export type WebsiteCrawlSliceShape = { } export const createWebsiteCrawlSlice: StateCreator<WebsiteCrawlSliceShape> = (set, get) => { - return ({ + return { websitePages: [], setWebsitePages: (pages: CrawlResultItem[]) => { set(() => ({ @@ -27,21 +27,25 @@ export const createWebsiteCrawlSlice: StateCreator<WebsiteCrawlSliceShape> = (se previewWebsitePageRef.current = pages[0] }, currentWebsite: undefined, - setCurrentWebsite: (website: CrawlResultItem | undefined) => set(() => ({ - currentWebsite: website, - })), + setCurrentWebsite: (website: CrawlResultItem | undefined) => + set(() => ({ + currentWebsite: website, + })), crawlResult: undefined, - setCrawlResult: (result: CrawlResult | undefined) => set(() => ({ - crawlResult: result, - })), + setCrawlResult: (result: CrawlResult | undefined) => + set(() => ({ + crawlResult: result, + })), step: CrawlStep.init, - setStep: (step: CrawlStep) => set(() => ({ - step, - })), + setStep: (step: CrawlStep) => + set(() => ({ + step, + })), previewIndex: -1, - setPreviewIndex: (index: number) => set(() => ({ - previewIndex: index, - })), + setPreviewIndex: (index: number) => + set(() => ({ + previewIndex: index, + })), previewWebsitePageRef: { current: undefined }, - }) + } } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx index 0f2507bde28324..18559c6e0098ea 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx @@ -15,7 +15,9 @@ vi.mock('@/context/i18n', () => ({ // Mock dataset-detail context - context provider requires mocking let mockPipelineId: string | undefined = 'pipeline-123' vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (s: { dataset: { pipeline_id: string | undefined } }) => unknown) => selector({ dataset: { pipeline_id: mockPipelineId } }), + useDatasetDetailContextWithSelector: ( + selector: (s: { dataset: { pipeline_id: string | undefined } }) => unknown, + ) => selector({ dataset: { pipeline_id: mockPipelineId } }), })) // Mock modal context - context provider requires mocking @@ -24,7 +26,9 @@ vi.mock('@/context/modal-context', () => ({ useModalContext: () => ({ setShowAccountSettingModal: mockSetShowAccountSettingModal, }), - useModalContextSelector: (selector: (s: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), + useModalContextSelector: ( + selector: (s: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown, + ) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), })) // Mock ssePost - API service requires mocking @@ -46,10 +50,11 @@ vi.mock('@/service/use-datasource', () => ({ })) // Mock usePipeline hooks - API service hooks require mocking -const { mockUseDraftPipelinePreProcessingParams, mockUsePublishedPipelinePreProcessingParams } = vi.hoisted(() => ({ - mockUseDraftPipelinePreProcessingParams: vi.fn(), - mockUsePublishedPipelinePreProcessingParams: vi.fn(), -})) +const { mockUseDraftPipelinePreProcessingParams, mockUsePublishedPipelinePreProcessingParams } = + vi.hoisted(() => ({ + mockUseDraftPipelinePreProcessingParams: vi.fn(), + mockUsePublishedPipelinePreProcessingParams: vi.fn(), + })) vi.mock('@/service/use-pipeline', () => ({ useDraftPipelinePreProcessingParams: mockUseDraftPipelinePreProcessingParams, @@ -59,7 +64,9 @@ vi.mock('@/service/use-pipeline', () => ({ // Note: zustand/react/shallow useShallow is imported directly (simple utility function) const mockStoreState = { - crawlResult: undefined as { data: CrawlResultItem[], time_consuming: number | string } | undefined, + crawlResult: undefined as + | { data: CrawlResultItem[]; time_consuming: number | string } + | undefined, step: CrawlStep.init, websitePages: [] as CrawlResultItem[], previewIndex: -1, @@ -75,7 +82,8 @@ const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } vi.mock('../../store', () => ({ - useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), + useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => + selector(mockStoreState), useDataSourceStore: () => mockDataSourceStore, })) @@ -87,9 +95,18 @@ vi.mock('../../base/header', () => ({ <span data-testid="header-doc-link">{props.docLink as string}</span> <span data-testid="header-plugin-name">{props.pluginName as string}</span> <span data-testid="header-credential-id">{props.currentCredentialId as string}</span> - <button data-testid="header-config-btn" onClick={props.onClickConfiguration as () => void}>Configure</button> - <button data-testid="header-credential-change" onClick={() => (props.onCredentialChange as (id: string) => void)('new-cred-id')}>Change Credential</button> - <span data-testid="header-credentials-count">{(props.credentials as unknown[] | undefined)?.length || 0}</span> + <button data-testid="header-config-btn" onClick={props.onClickConfiguration as () => void}> + Configure + </button> + <button + data-testid="header-credential-change" + onClick={() => (props.onCredentialChange as (id: string) => void)('new-cred-id')} + > + Change Credential + </button> + <span data-testid="header-credentials-count"> + {(props.credentials as unknown[] | undefined)?.length || 0} + </span> </div> ), })) @@ -101,12 +118,17 @@ vi.mock('../base/options', () => ({ <div data-testid="options"> <span data-testid="options-step">{props.step as string}</span> <span data-testid="options-run-disabled">{String(props.runDisabled)}</span> - <span data-testid="options-variables-count">{(props.variables as unknown[] | undefined)?.length || 0}</span> + <span data-testid="options-variables-count"> + {(props.variables as unknown[] | undefined)?.length || 0} + </span> <button data-testid="options-submit-btn" onClick={() => { mockOptionsSubmit() - ;(props.onSubmit as (v: Record<string, unknown>) => void)({ url: 'https://example.com', depth: 2 }) + ;(props.onSubmit as (v: Record<string, unknown>) => void)({ + url: 'https://example.com', + depth: 2, + }) }} > Submit @@ -139,21 +161,35 @@ vi.mock('../base/error-message', () => ({ vi.mock('../base/crawled-result', () => ({ default: (props: Record<string, unknown>) => ( <div data-testid="crawled-result" className={props.className as string}> - <span data-testid="crawled-result-count">{(props.list as unknown[] | undefined)?.length || 0}</span> - <span data-testid="crawled-result-checked-count">{(props.checkedList as unknown[] | undefined)?.length || 0}</span> + <span data-testid="crawled-result-count"> + {(props.list as unknown[] | undefined)?.length || 0} + </span> + <span data-testid="crawled-result-checked-count"> + {(props.checkedList as unknown[] | undefined)?.length || 0} + </span> <span data-testid="crawled-result-used-time">{props.usedTime as number}</span> <span data-testid="crawled-result-preview-index">{props.previewIndex as number}</span> <span data-testid="crawled-result-show-preview">{String(props.showPreview)}</span> <span data-testid="crawled-result-multiple-choice">{String(props.isMultipleChoice)}</span> <button data-testid="crawled-result-select-change" - onClick={() => (props.onSelectedChange as (v: { source_url: string, title: string }[]) => void)([{ source_url: 'https://example.com', title: 'Test' }])} + onClick={() => + (props.onSelectedChange as (v: { source_url: string; title: string }[]) => void)([ + { source_url: 'https://example.com', title: 'Test' }, + ]) + } > Change Selection </button> <button data-testid="crawled-result-preview" - onClick={() => (props.onPreview as ((item: { source_url: string, title: string }, idx: number) => void) | undefined)?.({ source_url: 'https://example.com', title: 'Test' }, 0)} + onClick={() => + ( + props.onPreview as + | ((item: { source_url: string; title: string }, idx: number) => void) + | undefined + )?.({ source_url: 'https://example.com', title: 'Test' }, 0) + } > Preview </button> @@ -161,17 +197,18 @@ vi.mock('../base/crawled-result', () => ({ ), })) -const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ - title: 'Test Node', - plugin_id: 'plugin-123', - provider_type: 'website', - provider_name: 'website-provider', - datasource_name: 'website-ds', - datasource_label: 'Website Crawler', - datasource_parameters: {}, - datasource_configurations: {}, - ...overrides, -} as DataSourceNodeType) +const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => + ({ + title: 'Test Node', + plugin_id: 'plugin-123', + provider_type: 'website', + provider_name: 'website-provider', + datasource_name: 'website-ds', + datasource_label: 'Website Crawler', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, + }) as DataSourceNodeType const createMockCrawlResultItem = (overrides?: Partial<CrawlResultItem>): CrawlResultItem => ({ source_url: 'https://example.com/page1', @@ -181,7 +218,7 @@ const createMockCrawlResultItem = (overrides?: Partial<CrawlResultItem>): CrawlR ...overrides, }) -const createMockCredential = (overrides?: Partial<{ id: string, name: string }>) => ({ +const createMockCredential = (overrides?: Partial<{ id: string; name: string }>) => ({ id: 'cred-1', name: 'Test Credential', avatar_url: 'https://example.com/avatar.png', diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx index 3cf721959e6808..0c6e1e606c1111 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx @@ -5,8 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import CrawledResultItem from '../crawled-result-item' vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( - <button data-testid="preview-button" onClick={onClick}>{children}</button> + Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => ( + <button data-testid="preview-button" onClick={onClick}> + {children} + </button> ), })) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result.spec.tsx index 6c476e6dd6db10..6610e6e541fe44 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result.spec.tsx @@ -1,10 +1,17 @@ import type { CrawlResultItem } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' - import CrawledResult from '../crawled-result' vi.mock('../checkbox-with-label', () => ({ - default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => ( + default: ({ + isChecked, + onChange, + label, + }: { + isChecked: boolean + onChange: () => void + label: string + }) => ( <label> <input type="checkbox" @@ -86,9 +93,7 @@ describe('CrawledResult', () => { }) it('should apply custom className', () => { - const { container } = render( - <CrawledResult {...defaultProps} className="my-custom-class" />, - ) + const { container } = render(<CrawledResult {...defaultProps} className="my-custom-class" />) expect(container.firstChild)!.toHaveClass('my-custom-class') }) @@ -197,13 +202,7 @@ describe('CrawledResult', () => { describe('Preview', () => { it('should call onPreview with correct item and index', () => { const onPreview = vi.fn() - render( - <CrawledResult - {...defaultProps} - onPreview={onPreview} - showPreview={true} - />, - ) + render(<CrawledResult {...defaultProps} onPreview={onPreview} showPreview={true} />) fireEvent.click(screen.getByTestId(`preview-${defaultList[1]!.source_url}`)) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx index 698aa83e770b73..fea24f7fc6e7b2 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx @@ -9,7 +9,9 @@ import CrawledResultItem from '../crawled-result-item' import Crawling from '../crawling' import ErrorMessage from '../error-message' -const createMockCrawlResultItem = (overrides?: Partial<CrawlResultItemType>): CrawlResultItemType => ({ +const createMockCrawlResultItem = ( + overrides?: Partial<CrawlResultItemType>, +): CrawlResultItemType => ({ source_url: 'https://example.com/page1', title: 'Test Page Title', markdown: '# Test content', @@ -22,7 +24,8 @@ const createMockCrawlResultItems = (count = 3): CrawlResultItemType[] => { createMockCrawlResultItem({ source_url: `https://example.com/page${i + 1}`, title: `Page ${i + 1}`, - })) + }), + ) } // CheckboxWithLabel Tests @@ -47,13 +50,19 @@ describe('CheckboxWithLabel', () => { it('should render checkbox in unchecked state', () => { render(<CheckboxWithLabel {...defaultProps} isChecked={false} />) - expect(screen.getByRole('checkbox', { name: 'Test Label' })).toHaveAttribute('aria-checked', 'false') + expect(screen.getByRole('checkbox', { name: 'Test Label' })).toHaveAttribute( + 'aria-checked', + 'false', + ) }) it('should render checkbox in checked state', () => { render(<CheckboxWithLabel {...defaultProps} isChecked={true} />) - expect(screen.getByRole('checkbox', { name: 'Test Label' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('checkbox', { name: 'Test Label' })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) it('should render tooltip when provided', () => { @@ -71,9 +80,7 @@ describe('CheckboxWithLabel', () => { describe('Props', () => { it('should apply custom className', () => { - const { container } = render( - <CheckboxWithLabel {...defaultProps} className="custom-class" />, - ) + const { container } = render(<CheckboxWithLabel {...defaultProps} className="custom-class" />) expect(container.firstChild)!.toHaveClass('custom-class') }) @@ -159,7 +166,10 @@ describe('CrawledResultItem', () => { it('should render checkbox as checked when isChecked is true', () => { render(<CrawledResultItem {...defaultProps} isChecked={true} />) - expect(screen.getByRole('checkbox', { name: /Test Page Title/ })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('checkbox', { name: /Test Page Title/ })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) it('should render preview button when showPreview is true', () => { @@ -222,11 +232,7 @@ describe('CrawledResultItem', () => { const mockOnCheckChange = vi.fn() const user = userEvent.setup() render( - <CrawledResultItem - {...defaultProps} - isChecked={false} - onCheckChange={mockOnCheckChange} - />, + <CrawledResultItem {...defaultProps} isChecked={false} onCheckChange={mockOnCheckChange} />, ) await user.click(screen.getByText('Test Page Title')) @@ -238,11 +244,7 @@ describe('CrawledResultItem', () => { const mockOnCheckChange = vi.fn() const user = userEvent.setup() render( - <CrawledResultItem - {...defaultProps} - isChecked={true} - onCheckChange={mockOnCheckChange} - />, + <CrawledResultItem {...defaultProps} isChecked={true} onCheckChange={mockOnCheckChange} />, ) await user.click(screen.getByText('Test Page Title')) @@ -265,8 +267,7 @@ describe('CrawledResultItem', () => { <RadioGroup aria-label="Crawled pages" onValueChange={(sourceUrl) => { - if (sourceUrl === defaultProps.payload.source_url) - mockOnCheckChange(true) + if (sourceUrl === defaultProps.payload.source_url) mockOnCheckChange(true) }} > <CrawledResultItem @@ -356,17 +357,13 @@ describe('CrawledResult', () => { describe('Props', () => { it('should apply custom className', () => { - const { container } = render( - <CrawledResult {...defaultProps} className="custom-class" />, - ) + const { container } = render(<CrawledResult {...defaultProps} className="custom-class" />) expect(container.firstChild)!.toHaveClass('custom-class') }) it('should highlight item at previewIndex', () => { - render( - <CrawledResult {...defaultProps} previewIndex={1} />, - ) + render(<CrawledResult {...defaultProps} previewIndex={1} />) // Assert - Second item should have active state expect(screen.getByText('Page 2').closest('.relative')).toHaveClass('bg-state-base-active') @@ -375,14 +372,18 @@ describe('CrawledResult', () => { it('should pass showPreview to items', () => { render(<CrawledResult {...defaultProps} showPreview={true} />) - const buttons = screen.getAllByRole('button', { name: 'datasetCreation.stepOne.website.preview' }) + const buttons = screen.getAllByRole('button', { + name: 'datasetCreation.stepOne.website.preview', + }) expect(buttons.length).toBe(3) }) it('should not show preview buttons when showPreview is false', () => { render(<CrawledResult {...defaultProps} showPreview={false} />) - expect(screen.queryByRole('button', { name: 'datasetCreation.stepOne.website.preview' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'datasetCreation.stepOne.website.preview' }), + ).not.toBeInTheDocument() }) }) @@ -492,7 +493,9 @@ describe('CrawledResult', () => { />, ) - const buttons = screen.getAllByRole('button', { name: 'datasetCreation.stepOne.website.preview' }) + const buttons = screen.getAllByRole('button', { + name: 'datasetCreation.stepOne.website.preview', + }) fireEvent.click(buttons[1]!) // Second item's preview button expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1) @@ -502,12 +505,7 @@ describe('CrawledResult', () => { // Arrange - showPreview is true but onPreview is undefined const list = createMockCrawlResultItems(3) render( - <CrawledResult - {...defaultProps} - list={list} - onPreview={undefined} - showPreview={true} - />, + <CrawledResult {...defaultProps} list={list} onPreview={undefined} showPreview={true} />, ) // Act - Click preview button should trigger early return in handlePreview @@ -587,9 +585,7 @@ describe('Crawling', () => { describe('Props', () => { it('should apply custom className', () => { - const { container } = render( - <Crawling {...defaultProps} className="custom-crawling-class" />, - ) + const { container } = render(<Crawling {...defaultProps} className="custom-crawling-class" />) expect(container.firstChild)!.toHaveClass('custom-crawling-class') }) @@ -692,7 +688,8 @@ describe('ErrorMessage', () => { }) it('should handle long error message', () => { - const longErrorMsg = 'This is a very detailed error message explaining what went wrong and how to fix it. It contains multiple sentences.' + const longErrorMsg = + 'This is a very detailed error message explaining what went wrong and how to fix it. It contains multiple sentences.' render(<ErrorMessage {...defaultProps} errorMsg={longErrorMsg} />) @@ -725,14 +722,7 @@ describe('Base Components Integration', () => { it('should render CrawledResult with CrawledResultItem children', () => { const list = createMockCrawlResultItems(2) - render( - <CrawledResult - list={list} - checkedList={[]} - onSelectedChange={vi.fn()} - usedTime={1.0} - />, - ) + render(<CrawledResult list={list} checkedList={[]} onSelectedChange={vi.fn()} usedTime={1.0} />) // Assert - Both items should render // Assert - Both items should render @@ -779,7 +769,9 @@ describe('Base Components Integration', () => { expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0]]) // Act - Preview second item - const previewButtons = screen.getAllByRole('button', { name: 'datasetCreation.stepOne.website.preview' }) + const previewButtons = screen.getAllByRole('button', { + name: 'datasetCreation.stepOne.website.preview', + }) fireEvent.click(previewButtons[1]!) expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx index 682bb85a6522d6..d2a414fd40fbd6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx @@ -23,8 +23,13 @@ export default function CheckboxWithLabel({ return ( <div className={cn('flex items-center', className)}> <label className="flex min-w-0 cursor-pointer items-center"> - <Checkbox checked={isChecked} onCheckedChange={checked => onChange(checked)} /> - <span className={cn('ml-2 min-w-0 text-left system-sm-medium text-text-secondary', labelClassName)}> + <Checkbox checked={isChecked} onCheckedChange={(checked) => onChange(checked)} /> + <span + className={cn( + 'ml-2 min-w-0 text-left system-sm-medium text-text-secondary', + labelClassName, + )} + > {label} </span> </label> diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx index 323f1126ce8530..d932527e11dcf8 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx @@ -29,66 +29,54 @@ const CrawledResultItem = ({ const { t } = useTranslation() return ( - <div className={cn( - 'relative flex gap-x-2 rounded-lg p-2', - isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover', - )} + <div + className={cn( + 'relative flex gap-x-2 rounded-lg p-2', + isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover', + )} > - { - isMultipleChoice - ? ( - <label className="flex min-w-0 grow cursor-pointer gap-x-2"> - <Checkbox - className="shrink-0" - checked={isChecked} - onCheckedChange={checked => onCheckChange(checked)} - /> - <div className="flex min-w-0 grow flex-col gap-y-0.5"> - <div - className="truncate system-sm-medium text-text-secondary" - title={payload.title} - > - {payload.title} - </div> - <div - className="truncate system-xs-regular text-text-tertiary" - title={payload.source_url} - > - {payload.source_url} - </div> - </div> - </label> - ) - : ( - <label className="flex min-w-0 grow cursor-pointer gap-x-2"> - <Radio - className="shrink-0" - value={payload.source_url} - /> - <div className="flex min-w-0 grow flex-col gap-y-0.5"> - <div - className="truncate system-sm-medium text-text-secondary" - title={payload.title} - > - {payload.title} - </div> - <div - className="truncate system-xs-regular text-text-tertiary" - title={payload.source_url} - > - {payload.source_url} - </div> - </div> - </label> - ) - } + {isMultipleChoice ? ( + <label className="flex min-w-0 grow cursor-pointer gap-x-2"> + <Checkbox + className="shrink-0" + checked={isChecked} + onCheckedChange={(checked) => onCheckChange(checked)} + /> + <div className="flex min-w-0 grow flex-col gap-y-0.5"> + <div className="truncate system-sm-medium text-text-secondary" title={payload.title}> + {payload.title} + </div> + <div + className="truncate system-xs-regular text-text-tertiary" + title={payload.source_url} + > + {payload.source_url} + </div> + </div> + </label> + ) : ( + <label className="flex min-w-0 grow cursor-pointer gap-x-2"> + <Radio className="shrink-0" value={payload.source_url} /> + <div className="flex min-w-0 grow flex-col gap-y-0.5"> + <div className="truncate system-sm-medium text-text-secondary" title={payload.title}> + {payload.title} + </div> + <div + className="truncate system-xs-regular text-text-tertiary" + title={payload.source_url} + > + {payload.source_url} + </div> + </div> + </label> + )} {showPreview && ( <Button size="small" onClick={onPreview} className="top-2 right-2 hidden px-1.5 system-xs-medium-uppercase group-hover:absolute group-hover:block" > - {t($ => $['stepOne.website.preview'], { ns: 'datasetCreation' })} + {t(($) => $['stepOne.website.preview'], { ns: 'datasetCreation' })} </Button> )} </div> diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result.tsx index 527d4c8a0722b5..0282aeaa8e0d17 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result.tsx @@ -38,43 +38,48 @@ const CrawledResult = ({ const isCheckAll = checkedList.length === list.length const handleCheckedAll = useCallback(() => { - if (!isCheckAll) - onSelectedChange(list) - - else - onSelectedChange([]) + if (!isCheckAll) onSelectedChange(list) + else onSelectedChange([]) }, [isCheckAll, list, onSelectedChange]) - const handleItemCheckChange = useCallback((item: CrawlResultItem) => { - return (checked: boolean) => { - if (checked) { - if (isMultipleChoice) - onSelectedChange([...checkedList, item]) - else - onSelectedChange([item]) + const handleItemCheckChange = useCallback( + (item: CrawlResultItem) => { + return (checked: boolean) => { + if (checked) { + if (isMultipleChoice) onSelectedChange([...checkedList, item]) + else onSelectedChange([item]) + } else { + onSelectedChange( + checkedList.filter((checkedItem) => checkedItem.source_url !== item.source_url), + ) + } } - else { onSelectedChange(checkedList.filter(checkedItem => checkedItem.source_url !== item.source_url)) } - } - }, [checkedList, onSelectedChange, isMultipleChoice]) + }, + [checkedList, onSelectedChange, isMultipleChoice], + ) - const handlePreview = useCallback((index: number) => { - if (!onPreview) - return - onPreview(list[index]!, index) - }, [list, onPreview]) + const handlePreview = useCallback( + (index: number) => { + if (!onPreview) return + onPreview(list[index]!, index) + }, + [list, onPreview], + ) const selectedSourceUrl = checkedList[0]?.source_url - const handleRadioChange = useCallback((sourceUrl: string) => { - const selectedItem = list.find(item => item.source_url === sourceUrl) - if (selectedItem) - onSelectedChange([selectedItem]) - }, [list, onSelectedChange]) + const handleRadioChange = useCallback( + (sourceUrl: string) => { + const selectedItem = list.find((item) => item.source_url === sourceUrl) + if (selectedItem) onSelectedChange([selectedItem]) + }, + [list, onSelectedChange], + ) const resultItems = list.map((item, index) => ( <CrawledResultItem key={item.source_url} payload={item} - isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)} + isChecked={checkedList.some((checkedItem) => checkedItem.source_url === item.source_url)} onCheckChange={handleItemCheckChange(item)} isPreview={index === previewIndex} onPreview={handlePreview.bind(null, index)} @@ -86,7 +91,7 @@ const CrawledResult = ({ return ( <div className={cn('flex flex-col gap-y-2', className)}> <div className="pt-2 system-sm-medium text-text-primary"> - {t($ => $[`${I18N_PREFIX}.scrapTimeInfo`], { + {t(($) => $[`${I18N_PREFIX}.scrapTimeInfo`], { ns: 'datasetCreation', total: list.length, time: usedTime.toFixed(1), @@ -98,30 +103,32 @@ const CrawledResult = ({ <CheckboxWithLabel isChecked={isCheckAll} onChange={handleCheckedAll} - label={isCheckAll ? t($ => $[`${I18N_PREFIX}.resetAll`], { ns: 'datasetCreation' }) : t($ => $[`${I18N_PREFIX}.selectAll`], { ns: 'datasetCreation' })} + label={ + isCheckAll + ? t(($) => $[`${I18N_PREFIX}.resetAll`], { ns: 'datasetCreation' }) + : t(($) => $[`${I18N_PREFIX}.selectAll`], { ns: 'datasetCreation' }) + } /> </div> )} - {isMultipleChoice - ? ( - <div className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2"> - {resultItems} - </div> - ) - : ( - <RadioGroup - aria-label={t($ => $[`${I18N_PREFIX}.scrapTimeInfo`], { - ns: 'datasetCreation', - total: list.length, - time: usedTime.toFixed(1), - })} - value={selectedSourceUrl} - onValueChange={handleRadioChange} - className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2" - > - {resultItems} - </RadioGroup> - )} + {isMultipleChoice ? ( + <div className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2"> + {resultItems} + </div> + ) : ( + <RadioGroup + aria-label={t(($) => $[`${I18N_PREFIX}.scrapTimeInfo`], { + ns: 'datasetCreation', + total: list.length, + time: usedTime.toFixed(1), + })} + value={selectedSourceUrl} + onValueChange={handleRadioChange} + className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2" + > + {resultItems} + </RadioGroup> + )} </div> </div> ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawling.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawling.tsx index 8506c42fb96ee2..5f9122b818c29b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawling.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawling.tsx @@ -18,16 +18,11 @@ type ItemProps = { secondLineWidth: string } -const Block = React.memo(({ - className, -}: BlockProps) => { +const Block = React.memo(({ className }: BlockProps) => { return <div className={cn('bg-text-quaternary opacity-20', className)} /> }) -const Item = React.memo(({ - firstLineWidth, - secondLineWidth, -}: ItemProps) => { +const Item = React.memo(({ firstLineWidth, secondLineWidth }: ItemProps) => { return ( <div className="flex gap-x-2 px-2 py-[5px]"> <div className="py-0.5"> @@ -45,31 +40,28 @@ const Item = React.memo(({ ) }) -const Crawling = ({ - className = '', - crawledNum, - totalNum, -}: CrawlingProps) => { +const Crawling = ({ className = '', crawledNum, totalNum }: CrawlingProps) => { const { t } = useTranslation() - const itemsConfig = [{ - firstLineWidth: 'w-[35%]', - secondLineWidth: 'w-[50%]', - }, { - firstLineWidth: 'w-[40%]', - secondLineWidth: 'w-[45%]', - }, { - firstLineWidth: 'w-[30%]', - secondLineWidth: 'w-[36%]', - }] + const itemsConfig = [ + { + firstLineWidth: 'w-[35%]', + secondLineWidth: 'w-[50%]', + }, + { + firstLineWidth: 'w-[40%]', + secondLineWidth: 'w-[45%]', + }, + { + firstLineWidth: 'w-[30%]', + secondLineWidth: 'w-[36%]', + }, + ] return ( <div className={cn('mt-2 flex flex-col gap-y-2 pt-2', className)}> <div className="system-sm-medium text-text-primary"> - {t($ => $['stepOne.website.totalPageScraped'], { ns: 'datasetCreation' })} - {' '} - {crawledNum} - / + {t(($) => $['stepOne.website.totalPageScraped'], { ns: 'datasetCreation' })} {crawledNum}/ {totalNum} </div> <div className="overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg"> diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/error-message.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/error-message.tsx index 62056c1f5a9cbc..a1f0efa285d959 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/error-message.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/error-message.tsx @@ -8,25 +8,20 @@ type ErrorMessageProps = { errorMsg?: string } -const ErrorMessage = ({ - className, - title, - errorMsg, -}: ErrorMessageProps) => { +const ErrorMessage = ({ className, title, errorMsg }: ErrorMessageProps) => { return ( - <div className={cn( - 'flex gap-x-0.5 rounded-xl border-[0.5px] border-components-panel-border bg-toast-error-bg p-2 shadow-xs shadow-shadow-shadow-3', - className, - )} + <div + className={cn( + 'flex gap-x-0.5 rounded-xl border-[0.5px] border-components-panel-border bg-toast-error-bg p-2 shadow-xs shadow-shadow-shadow-3', + className, + )} > <div className="flex size-6 items-center justify-center"> <RiErrorWarningFill className="size-4 text-text-destructive" /> </div> <div className="flex flex-col gap-y-0.5 py-1"> <div className="system-xs-medium text-text-primary">{title}</div> - {errorMsg && ( - <div className="system-xs-regular text-text-secondary">{errorMsg}</div> - )} + {errorMsg && <div className="system-xs-regular text-text-secondary">{errorMsg}</div>} </div> </div> ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx index c78739adbf4410..9488c2975bdbba 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx @@ -38,14 +38,21 @@ const mockBaseField = vi.fn() vi.mock('@/app/components/base/form/form-scenarios/base/field', () => { const MockBaseFieldFactory = (props: Record<string, unknown>) => { mockBaseField(props) - const config = props.config as { variable?: string, label?: string } | undefined - const MockField = ({ form }: { form: { getFieldValue?: (field: string) => string, setFieldValue?: (field: string, value: string) => void } }) => ( + const config = props.config as { variable?: string; label?: string } | undefined + const MockField = ({ + form, + }: { + form: { + getFieldValue?: (field: string) => string + setFieldValue?: (field: string, value: string) => void + } + }) => ( <div data-testid={`field-${config?.variable || 'unknown'}`}> <span data-testid={`field-label-${config?.variable}`}>{config?.label}</span> <input data-testid={`field-input-${config?.variable}`} value={form.getFieldValue?.(config?.variable || '') || ''} - onChange={e => form.setFieldValue?.(config?.variable || '', e.target.value)} + onChange={(e) => form.setFieldValue?.(config?.variable || '', e.target.value)} /> </div> ) @@ -58,7 +65,10 @@ vi.mock('@/app/components/base/form/form-scenarios/base/field', () => { const mockHandleSubmit = vi.fn() const mockFormValues: Record<string, unknown> = {} vi.mock('@/app/components/base/form', () => ({ - useAppForm: (options: { validators?: { onSubmit?: (arg: { value: Record<string, unknown> }) => unknown }, onSubmit?: (arg: { value: Record<string, unknown> }) => void }) => { + useAppForm: (options: { + validators?: { onSubmit?: (arg: { value: Record<string, unknown> }) => unknown } + onSubmit?: (arg: { value: Record<string, unknown> }) => void + }) => { const formOptions = options return { handleSubmit: () => { @@ -76,7 +86,9 @@ vi.mock('@/app/components/base/form', () => ({ }, })) -const createMockVariable = (overrides?: Partial<RAGPipelineVariables[0]>): RAGPipelineVariables[0] => ({ +const createMockVariable = ( + overrides?: Partial<RAGPipelineVariables[0]>, +): RAGPipelineVariables[0] => ({ belong_to_node_id: 'node-1', type: PipelineInputVarType.textInput, label: 'Test Label', @@ -93,7 +105,8 @@ const createMockVariables = (count = 1): RAGPipelineVariables => { createMockVariable({ variable: `variable_${i}`, label: `Label ${i}`, - })) + }), + ) } type MockConfiguration = { @@ -135,7 +148,7 @@ describe('Options', () => { mockToastError.mockReset() // Reset mock form values - Object.keys(mockFormValues).forEach(key => delete mockFormValues[key]) + Object.keys(mockFormValues).forEach((key) => delete mockFormValues[key]) // Default mock return values - using real generateZodSchema mockUseInitialData.mockReturnValue({}) @@ -350,8 +363,16 @@ describe('Options', () => { it('should pass form values to onSubmit', () => { // Arrange - Use non-required fields so validation passes const configs = [ - createMockConfiguration({ variable: 'url', required: false, type: BaseFieldType.textInput }), - createMockConfiguration({ variable: 'depth', required: false, type: BaseFieldType.numberInput }), + createMockConfiguration({ + variable: 'url', + required: false, + type: BaseFieldType.textInput, + }), + createMockConfiguration({ + variable: 'depth', + required: false, + type: BaseFieldType.numberInput, + }), ] mockUseConfigurations.mockReturnValue(configs) mockFormValues.url = 'https://example.com' @@ -677,7 +698,8 @@ describe('Options', () => { it('should handle many configurations', () => { const manyConfigs = Array.from({ length: 10 }, (_, i) => - createMockConfiguration({ variable: `field_${i}`, label: `Field ${i}` })) + createMockConfiguration({ variable: `field_${i}`, label: `Field ${i}` }), + ) mockUseConfigurations.mockReturnValue(manyConfigs) const props = createDefaultProps() @@ -690,8 +712,18 @@ describe('Options', () => { it('should handle validation with multiple required fields (shows first error)', () => { // Arrange - Multiple required fields const configs = [ - createMockConfiguration({ variable: 'url', label: 'URL', required: true, type: BaseFieldType.textInput }), - createMockConfiguration({ variable: 'depth', label: 'Depth', required: true, type: BaseFieldType.textInput }), + createMockConfiguration({ + variable: 'url', + label: 'URL', + required: true, + type: BaseFieldType.textInput, + }), + createMockConfiguration({ + variable: 'depth', + label: 'Depth', + required: true, + type: BaseFieldType.textInput, + }), ] mockUseConfigurations.mockReturnValue(configs) const props = createDefaultProps() @@ -739,8 +771,7 @@ describe('Options', () => { // Act - Toggle rapidly multiple times const toggleText = screen.getByText(/options/i) - for (let i = 0; i < 5; i++) - fireEvent.click(toggleText) + for (let i = 0; i < 5; i++) fireEvent.click(toggleText) // Assert - Final state should be folded (odd number of clicks) expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() @@ -756,21 +787,21 @@ describe('Options', () => { [{ step: CrawlStep.running, runDisabled: true }, true, 'running'], [{ step: CrawlStep.finished, runDisabled: false }, false, 'run'], [{ step: CrawlStep.finished, runDisabled: true }, true, 'run'], - ] as const)('should render correctly with step=%s, runDisabled=%s', (propVariation, expectedDisabled, expectedText) => { - const props = createDefaultProps(propVariation) + ] as const)( + 'should render correctly with step=%s, runDisabled=%s', + (propVariation, expectedDisabled, expectedText) => { + const props = createDefaultProps(propVariation) - render(<Options {...props} />) + render(<Options {...props} />) - const button = screen.getByRole('button') - if (propVariation.step === CrawlStep.running) - expectLoadingButton(button) - else if (expectedDisabled) - expect(button).toBeDisabled() - else - expect(button).not.toBeDisabled() + const button = screen.getByRole('button') + if (propVariation.step === CrawlStep.running) expectLoadingButton(button) + else if (expectedDisabled) expect(button).toBeDisabled() + else expect(button).not.toBeDisabled() - expect(screen.getByText(new RegExp(expectedText, 'i'))).toBeInTheDocument() - }) + expect(screen.getByText(new RegExp(expectedText, 'i'))).toBeInTheDocument() + }, + ) it('should handle all CrawlStep values', () => { // Arrange & Act & Assert @@ -790,7 +821,7 @@ describe('Options', () => { createMockVariable({ type: PipelineInputVarType.checkbox, variable: 'checkbox_field' }), createMockVariable({ type: PipelineInputVarType.select, variable: 'select_field' }), ] - const configurations = variables.map(v => createMockConfiguration({ variable: v.variable })) + const configurations = variables.map((v) => createMockConfiguration({ variable: v.variable })) mockUseConfigurations.mockReturnValue(configurations) const props = createDefaultProps({ variables }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx index 4b7abff08d0906..2e3171b5f436b8 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx @@ -10,7 +10,10 @@ import { useAppForm } from '@/app/components/base/form' import BaseField from '@/app/components/base/form/form-scenarios/base/field' import { generateZodSchema } from '@/app/components/base/form/form-scenarios/base/utils' import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general' -import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields' +import { + useConfigurations, + useInitialData, +} from '@/app/components/rag-pipeline/hooks/use-input-fields' import { CrawlStep } from '@/models/datasets' const I18N_PREFIX = 'stepOne.website' @@ -22,12 +25,7 @@ type OptionsProps = { onSubmit: (data: Record<string, any>) => void } -const Options = ({ - variables, - step, - runDisabled, - onSubmit, -}: OptionsProps) => { +const Options = ({ variables, step, runDisabled, onSubmit }: OptionsProps) => { const { t } = useTranslation() const initialData = useInitialData(variables) const configurations = useConfigurations(variables) @@ -55,18 +53,12 @@ const Options = ({ }, }) - const [fold, { - toggle: foldToggle, - setTrue: foldHide, - setFalse: foldShow, - }] = useBoolean(false) + const [fold, { toggle: foldToggle, setTrue: foldHide, setFalse: foldShow }] = useBoolean(false) useEffect(() => { // When the step change - if (step !== CrawlStep.init) - foldHide() - else - foldShow() + if (step !== CrawlStep.init) foldHide() + else foldShow() }, [step]) const isRunning = useMemo(() => step === CrawlStep.running, [step]) @@ -86,9 +78,11 @@ const Options = ({ onClick={foldToggle} > <span className="system-sm-semibold-uppercase text-text-secondary"> - {t($ => $[`${I18N_PREFIX}.options`], { ns: 'datasetCreation' })} + {t(($) => $[`${I18N_PREFIX}.options`], { ns: 'datasetCreation' })} </span> - <ArrowDownRoundFill className={cn('size-4 shrink-0 text-text-quaternary', fold && '-rotate-90')} /> + <ArrowDownRoundFill + className={cn('size-4 shrink-0 text-text-quaternary', fold && '-rotate-90')} + /> </div> <Button variant="primary" @@ -98,7 +92,11 @@ const Options = ({ className="shrink-0 gap-x-0.5" > <RiPlayLargeLine className="size-4" /> - <span className="px-0.5">{!isRunning ? t($ => $[`${I18N_PREFIX}.run`], { ns: 'datasetCreation' }) : t($ => $[`${I18N_PREFIX}.running`], { ns: 'datasetCreation' })}</span> + <span className="px-0.5"> + {!isRunning + ? t(($) => $[`${I18N_PREFIX}.run`], { ns: 'datasetCreation' }) + : t(($) => $[`${I18N_PREFIX}.running`], { ns: 'datasetCreation' })} + </span> </Button> </div> {!fold && ( diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx index 4896e35c7ae416..6d4d545cc800a1 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx @@ -51,21 +51,18 @@ const WebsiteCrawl = ({ const [totalNum, setTotalNum] = useState(0) const [crawledNum, setCrawledNum] = useState(0) const [crawlErrorMessage, setCrawlErrorMessage] = useState('') - const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id) + const pipelineId = useDatasetDetailContextWithSelector((s) => s.dataset?.pipeline_id) const openIntegrationsSetting = useIntegrationsSetting() - const { - crawlResult, - step, - checkedCrawlResult, - previewIndex, - currentCredentialId, - } = useDataSourceStoreWithSelector(useShallow(state => ({ - crawlResult: state.crawlResult, - step: state.step, - checkedCrawlResult: state.websitePages, - previewIndex: state.previewIndex, - currentCredentialId: state.currentCredentialId, - }))) + const { crawlResult, step, checkedCrawlResult, previewIndex, currentCredentialId } = + useDataSourceStoreWithSelector( + useShallow((state) => ({ + crawlResult: state.crawlResult, + step: state.step, + checkedCrawlResult: state.websitePages, + previewIndex: state.previewIndex, + currentCredentialId: state.currentCredentialId, + })), + ) const { data: dataSourceAuth } = useGetDataSourceAuth({ pluginId: nodeData.plugin_id, @@ -74,11 +71,16 @@ const WebsiteCrawl = ({ const dataSourceStore = useDataSourceStore() - const usePreProcessingParams = useRef(!isInPipeline ? usePublishedPipelinePreProcessingParams : useDraftPipelinePreProcessingParams) - const { data: paramsConfig, isFetching: isFetchingParams } = usePreProcessingParams.current({ - pipeline_id: pipelineId!, - node_id: nodeId, - }, !!pipelineId && !!nodeId) + const usePreProcessingParams = useRef( + !isInPipeline ? usePublishedPipelinePreProcessingParams : useDraftPipelinePreProcessingParams, + ) + const { data: paramsConfig, isFetching: isFetchingParams } = usePreProcessingParams.current( + { + pipeline_id: pipelineId!, + node_id: nodeId, + }, + !!pipelineId && !!nodeId, + ) const isInit = step === CrawlStep.init const isCrawlFinished = step === CrawlStep.finished @@ -88,58 +90,72 @@ const WebsiteCrawl = ({ ? `/rag/pipelines/${pipelineId}/workflows/published/datasource/nodes/${nodeId}/run` : `/rag/pipelines/${pipelineId}/workflows/draft/datasource/nodes/${nodeId}/run` - const handleCheckedCrawlResultChange = useCallback((checkedCrawlResult: CrawlResultItem[]) => { - const { setWebsitePages } = dataSourceStore.getState() - setWebsitePages(checkedCrawlResult) - }, [dataSourceStore]) + const handleCheckedCrawlResultChange = useCallback( + (checkedCrawlResult: CrawlResultItem[]) => { + const { setWebsitePages } = dataSourceStore.getState() + setWebsitePages(checkedCrawlResult) + }, + [dataSourceStore], + ) - const handlePreview = useCallback((website: CrawlResultItem, index: number) => { - const { setCurrentWebsite, setPreviewIndex } = dataSourceStore.getState() - setCurrentWebsite(website) - setPreviewIndex(index) - }, [dataSourceStore]) + const handlePreview = useCallback( + (website: CrawlResultItem, index: number) => { + const { setCurrentWebsite, setPreviewIndex } = dataSourceStore.getState() + setCurrentWebsite(website) + setPreviewIndex(index) + }, + [dataSourceStore], + ) - const handleRun = useCallback(async (value: Record<string, any>) => { - const { setStep, setCrawlResult, currentCredentialId } = dataSourceStore.getState() + const handleRun = useCallback( + async (value: Record<string, any>) => { + const { setStep, setCrawlResult, currentCredentialId } = dataSourceStore.getState() - setStep(CrawlStep.running) - ssePost( - datasourceNodeRunURL, - { - body: { - inputs: value, - datasource_type: DatasourceType.websiteCrawl, - credential_id: currentCredentialId, - response_mode: 'streaming', - }, - }, - { - onDataSourceNodeProcessing: (data: DataSourceNodeProcessingResponse) => { - setTotalNum(data.total ?? 0) - setCrawledNum(data.completed ?? 0) - }, - onDataSourceNodeCompleted: (data: DataSourceNodeCompletedResponse) => { - const { data: crawlData, time_consuming } = data - const crawlResultData = { - data: crawlData as CrawlResultItem[], - time_consuming: time_consuming ?? 0, - } - setCrawlResult(crawlResultData) - handleCheckedCrawlResultChange(supportBatchUpload ? crawlData : crawlData.slice(0, 1)) // default select the crawl result - setCrawlErrorMessage('') - setStep(CrawlStep.finished) + setStep(CrawlStep.running) + ssePost( + datasourceNodeRunURL, + { + body: { + inputs: value, + datasource_type: DatasourceType.websiteCrawl, + credential_id: currentCredentialId, + response_mode: 'streaming', + }, }, - onDataSourceNodeError: (error: DataSourceNodeErrorResponse) => { - setCrawlErrorMessage(error.error || t($ => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' })) - setStep(CrawlStep.finished) + { + onDataSourceNodeProcessing: (data: DataSourceNodeProcessingResponse) => { + setTotalNum(data.total ?? 0) + setCrawledNum(data.completed ?? 0) + }, + onDataSourceNodeCompleted: (data: DataSourceNodeCompletedResponse) => { + const { data: crawlData, time_consuming } = data + const crawlResultData = { + data: crawlData as CrawlResultItem[], + time_consuming: time_consuming ?? 0, + } + setCrawlResult(crawlResultData) + handleCheckedCrawlResultChange(supportBatchUpload ? crawlData : crawlData.slice(0, 1)) // default select the crawl result + setCrawlErrorMessage('') + setStep(CrawlStep.finished) + }, + onDataSourceNodeError: (error: DataSourceNodeErrorResponse) => { + setCrawlErrorMessage( + error.error || t(($) => $[`${I18N_PREFIX}.unknownError`], { ns: 'datasetCreation' }), + ) + setStep(CrawlStep.finished) + }, }, - }, - ) - }, [dataSourceStore, datasourceNodeRunURL, handleCheckedCrawlResultChange, supportBatchUpload, t]) + ) + }, + [dataSourceStore, datasourceNodeRunURL, handleCheckedCrawlResultChange, supportBatchUpload, t], + ) - const handleSubmit = useCallback((value: Record<string, any>) => { - handleRun(value) - }, [handleRun]) + const handleSubmit = useCallback( + (value: Record<string, any>) => { + handleRun(value) + }, + [handleRun], + ) const handleSetting = useCallback(() => { openIntegrationsSetting({ @@ -147,12 +163,15 @@ const WebsiteCrawl = ({ }) }, [openIntegrationsSetting]) - const handleCredentialChange = useCallback((credentialId: string) => { - setCrawledNum(0) - setTotalNum(0) - setCrawlErrorMessage('') - onCredentialChange(credentialId) - }, [onCredentialChange]) + const handleCredentialChange = useCallback( + (credentialId: string) => { + setCrawledNum(0) + setTotalNum(0) + setCrawlErrorMessage('') + onCredentialChange(credentialId) + }, + [onCredentialChange], + ) return ( <div className="flex flex-col"> @@ -175,16 +194,11 @@ const WebsiteCrawl = ({ </div> {!isInit && ( <div className="relative flex flex-col"> - {isRunning && ( - <Crawling - crawledNum={crawledNum} - totalNum={totalNum} - /> - )} + {isRunning && <Crawling crawledNum={crawledNum} totalNum={totalNum} />} {showError && ( <ErrorMessage className="mt-2" - title={t($ => $[`${I18N_PREFIX}.exceptionErrorTitle`], { ns: 'datasetCreation' })} + title={t(($) => $[`${I18N_PREFIX}.exceptionErrorTitle`], { ns: 'datasetCreation' })} errorMsg={crawlErrorMessage} /> )} diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-add-documents-steps.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-add-documents-steps.spec.ts index 5776f597ab2dbc..cfb5745a2b6690 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-add-documents-steps.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-add-documents-steps.spec.ts @@ -19,7 +19,7 @@ describe('useAddDocumentsSteps', () => { it('should have correct step labels', () => { const { result } = renderHook(() => useAddDocumentsSteps()) - const labels = result.current.steps.map(s => s.label) + const labels = result.current.steps.map((s) => s.label) expect(labels[0]).toContain('chooseDatasource') expect(labels[1]).toContain('processDocuments') expect(labels[2]).toContain('processingDocuments') diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts index a1c38c683d38b3..6899046351960a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts @@ -23,7 +23,10 @@ vi.mock('@/app/components/base/amplitude', () => ({ describe('useDatasourceActions', () => { let store: ReturnType<typeof createDataSourceStore> const defaultParams = () => ({ - datasource: { nodeId: 'node-1', nodeData: { provider_type: DatasourceType.localFile } } as unknown as Datasource, + datasource: { + nodeId: 'node-1', + nodeData: { provider_type: DatasourceType.localFile }, + } as unknown as Datasource, datasourceType: DatasourceType.localFile, pipelineId: 'pipeline-1', dataSourceStore: store, @@ -125,7 +128,10 @@ describe('useDatasourceActions', () => { const { result } = renderHook(() => useDatasourceActions(params)) result.current.formRef.current = { submit: vi.fn() } - const website = { title: 'Page', source_url: 'https://example.com' } as unknown as CrawlResultItem + const website = { + title: 'Page', + source_url: 'https://example.com', + } as unknown as CrawlResultItem act(() => { result.current.handlePreviewWebsiteChange(website) }) @@ -178,8 +184,13 @@ describe('useDatasourceActions', () => { it('should handle submit with preview mode', async () => { const params = defaultParams() - store.getState().setLocalFileList([{ file: { id: 'f1', name: 'test.pdf' } }] as unknown as FileItem[]) - store.getState().previewLocalFileRef.current = { id: 'f1', name: 'test.pdf' } as unknown as DocumentItem + store + .getState() + .setLocalFileList([{ file: { id: 'f1', name: 'test.pdf' } }] as unknown as FileItem[]) + store.getState().previewLocalFileRef.current = { + id: 'f1', + name: 'test.pdf', + } as unknown as DocumentItem mockRunPublishedPipeline.mockResolvedValue({ data: { outputs: { tokens: 100 } } }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts index 7ecd4bf841b7d1..06de03f66e2bd9 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts @@ -18,20 +18,19 @@ vi.mock('@/app/components/workflow/types', async () => { const { useDatasourceOptions } = await import('../use-datasource-options') describe('useDatasourceOptions', () => { - const createNode = (id: string, title: string, type: string): Node<DataSourceNodeType> => ({ - id, - position: { x: 0, y: 0 }, - data: { - type, - title, - provider_type: 'local_file', - }, - } as unknown as Node<DataSourceNodeType>) + const createNode = (id: string, title: string, type: string): Node<DataSourceNodeType> => + ({ + id, + position: { x: 0, y: 0 }, + data: { + type, + title, + provider_type: 'local_file', + }, + }) as unknown as Node<DataSourceNodeType> it('should return empty array for no datasource nodes', () => { - const nodes = [ - createNode('n1', 'LLM Node', 'llm'), - ] + const nodes = [createNode('n1', 'LLM Node', 'llm')] const { result } = renderHook(() => useDatasourceOptions(nodes)) expect(result.current).toEqual([]) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-store.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-store.spec.ts index 70d95000d73b38..cec86b453dde6e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-store.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-store.spec.ts @@ -8,7 +8,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { CrawlStep } from '@/models/datasets' import { createDataSourceStore } from '../../data-source/store' import { DataSourceContext } from '../../data-source/store/provider' -import { useLocalFile, useOnlineDocument, useOnlineDrive, useWebsiteCrawl } from '../use-datasource-store' +import { + useLocalFile, + useOnlineDocument, + useOnlineDrive, + useWebsiteCrawl, +} from '../use-datasource-store' const createWrapper = (store: ReturnType<typeof createDataSourceStore>) => { return ({ children }: { children: ReactNode }) => @@ -32,20 +37,24 @@ describe('useLocalFile', () => { }) it('should compute allFileLoaded when all files have ids', () => { - store.getState().setLocalFileList([ - { file: { id: 'f1', name: 'a.pdf' } }, - { file: { id: 'f2', name: 'b.pdf' } }, - ] as unknown as FileItem[]) + store + .getState() + .setLocalFileList([ + { file: { id: 'f1', name: 'a.pdf' } }, + { file: { id: 'f2', name: 'b.pdf' } }, + ] as unknown as FileItem[]) const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) }) expect(result.current.allFileLoaded).toBe(true) }) it('should compute allFileLoaded as false when some files lack ids', () => { - store.getState().setLocalFileList([ - { file: { id: 'f1', name: 'a.pdf' } }, - { file: { id: '', name: 'b.pdf' } }, - ] as unknown as FileItem[]) + store + .getState() + .setLocalFileList([ + { file: { id: 'f1', name: 'a.pdf' } }, + { file: { id: '', name: 'b.pdf' } }, + ] as unknown as FileItem[]) const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) }) expect(result.current.allFileLoaded).toBe(false) @@ -79,9 +88,11 @@ describe('useOnlineDocument', () => { }) it('should build PagesMapAndSelectedPagesId from documentsData', () => { - store.getState().setDocumentsData([ - { workspace_id: 'w1', pages: [{ page_id: 'p1', page_name: 'Page 1' }] }, - ] as unknown as DataSourceNotionWorkspace[]) + store + .getState() + .setDocumentsData([ + { workspace_id: 'w1', pages: [{ page_id: 'p1', page_name: 'Page 1' }] }, + ] as unknown as DataSourceNotionWorkspace[]) const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) }) expect(result.current.PagesMapAndSelectedPagesId).toHaveProperty('p1') @@ -99,7 +110,11 @@ describe('useOnlineDocument', () => { }) it('should clear online document data', () => { - store.getState().setDocumentsData([{ workspace_id: 'w1', pages: [] }] as unknown as DataSourceNotionWorkspace[]) + store + .getState() + .setDocumentsData([ + { workspace_id: 'w1', pages: [] }, + ] as unknown as DataSourceNotionWorkspace[]) store.getState().setSearchValue('test') store.getState().setOnlineDocuments([{ page_id: 'p1' }] as unknown as NotionPage[]) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts index 2032bb2c09bf1d..c6909a7304b900 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts @@ -102,7 +102,9 @@ describe('useDatasourceUIState', () => { const { result } = renderHook(() => useDatasourceUIState({ ...defaultParams, - datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + datasource: { + nodeData: { provider_type: DatasourceType.onlineDocument }, + } as unknown as Datasource, onlineDocumentsLength: 2, }), ) @@ -113,7 +115,9 @@ describe('useDatasourceUIState', () => { const { result } = renderHook(() => useDatasourceUIState({ ...defaultParams, - datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + datasource: { + nodeData: { provider_type: DatasourceType.onlineDocument }, + } as unknown as Datasource, onlineDocumentsLength: 0, }), ) @@ -131,7 +135,9 @@ describe('useDatasourceUIState', () => { const { result } = renderHook(() => useDatasourceUIState({ ...defaultParams, - datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + datasource: { + nodeData: { provider_type: DatasourceType.onlineDocument }, + } as unknown as Datasource, currentWorkspacePagesLength: 5, }), ) @@ -142,10 +148,10 @@ describe('useDatasourceUIState', () => { const { result } = renderHook(() => useDatasourceUIState({ ...defaultParams, - datasource: { nodeData: { provider_type: DatasourceType.onlineDrive } } as unknown as Datasource, - onlineDriveFileList: [ - { id: '1', name: 'file.txt', type: OnlineDriveFileType.file }, - ], + datasource: { + nodeData: { provider_type: DatasourceType.onlineDrive }, + } as unknown as Datasource, + onlineDriveFileList: [{ id: '1', name: 'file.txt', type: OnlineDriveFileType.file }], }), ) expect(result.current.showSelect).toBe(true) @@ -155,10 +161,10 @@ describe('useDatasourceUIState', () => { const { result } = renderHook(() => useDatasourceUIState({ ...defaultParams, - datasource: { nodeData: { provider_type: DatasourceType.onlineDrive } } as unknown as Datasource, - onlineDriveFileList: [ - { id: '1', name: 'bucket-1', type: OnlineDriveFileType.bucket }, - ], + datasource: { + nodeData: { provider_type: DatasourceType.onlineDrive }, + } as unknown as Datasource, + onlineDriveFileList: [{ id: '1', name: 'bucket-1', type: OnlineDriveFileType.bucket }], }), ) expect(result.current.showSelect).toBe(false) @@ -170,7 +176,9 @@ describe('useDatasourceUIState', () => { const { result } = renderHook(() => useDatasourceUIState({ ...defaultParams, - datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + datasource: { + nodeData: { provider_type: DatasourceType.onlineDocument }, + } as unknown as Datasource, currentWorkspacePagesLength: 10, onlineDocumentsLength: 3, }), @@ -196,7 +204,9 @@ describe('useDatasourceUIState', () => { const { result } = renderHook(() => useDatasourceUIState({ ...defaultParams, - datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + datasource: { + nodeData: { provider_type: DatasourceType.onlineDocument }, + } as unknown as Datasource, }), ) expect(result.current.tip).toContain('selectOnlineDocumentTip') diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/index.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/index.ts index 0faf3c52f7f93f..7c0e6558fb1f2b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/index.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/index.ts @@ -1,5 +1,10 @@ export { useAddDocumentsSteps } from './use-add-documents-steps' export { useDatasourceActions } from './use-datasource-actions' export { useDatasourceOptions } from './use-datasource-options' -export { useLocalFile, useOnlineDocument, useOnlineDrive, useWebsiteCrawl } from './use-datasource-store' +export { + useLocalFile, + useOnlineDocument, + useOnlineDrive, + useWebsiteCrawl, +} from './use-datasource-store' export { useDatasourceUIState } from './use-datasource-ui-state' diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-add-documents-steps.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-add-documents-steps.ts index e63339c802f186..ec745aa66b729d 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-add-documents-steps.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-add-documents-steps.ts @@ -10,24 +10,24 @@ export const useAddDocumentsSteps = () => { const [currentStep, setCurrentStep] = useState(1) const handleNextStep = useCallback(() => { - setCurrentStep(preStep => preStep + 1) + setCurrentStep((preStep) => preStep + 1) }, []) const handleBackStep = useCallback(() => { - setCurrentStep(preStep => preStep - 1) + setCurrentStep((preStep) => preStep - 1) }, []) const steps = [ { - label: t($ => $['addDocuments.steps.chooseDatasource'], { ns: 'datasetPipeline' }), + label: t(($) => $['addDocuments.steps.chooseDatasource'], { ns: 'datasetPipeline' }), value: AddDocumentsStep.dataSource, }, { - label: t($ => $['addDocuments.steps.processDocuments'], { ns: 'datasetPipeline' }), + label: t(($) => $['addDocuments.steps.processDocuments'], { ns: 'datasetPipeline' }), value: AddDocumentsStep.processDocuments, }, { - label: t($ => $['addDocuments.steps.processingDocuments'], { ns: 'datasetPipeline' }), + label: t(($) => $['addDocuments.steps.processingDocuments'], { ns: 'datasetPipeline' }), value: AddDocumentsStep.processingDocuments, }, ] diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-actions.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-actions.ts index a0ac69bd1ab7e1..e6e65cb72e0513 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-actions.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-actions.ts @@ -2,7 +2,12 @@ import type { StoreApi } from 'zustand' import type { DataSourceShape } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types' import type { DataSourceNotionPageMap, NotionPage } from '@/models/common' -import type { CrawlResultItem, DocumentItem, CustomFile as File, FileIndexingEstimateResponse } from '@/models/datasets' +import type { + CrawlResultItem, + DocumentItem, + CustomFile as File, + FileIndexingEstimateResponse, +} from '@/models/datasets' import type { OnlineDriveFile, PublishedPipelineRunPreviewResponse, @@ -76,32 +81,31 @@ export const useDatasourceActions = ({ const datasourceInfoList: Record<string, unknown>[] = [] if (datasourceType === DatasourceType.localFile && previewLocalFileRef.current) { - datasourceInfoList.push(buildLocalFileDatasourceInfo( - previewLocalFileRef.current as File, - currentCredentialId, - )) + datasourceInfoList.push( + buildLocalFileDatasourceInfo(previewLocalFileRef.current as File, currentCredentialId), + ) } if (datasourceType === DatasourceType.onlineDocument && previewOnlineDocumentRef.current) { - datasourceInfoList.push(buildOnlineDocumentDatasourceInfo( - previewOnlineDocumentRef.current, - currentCredentialId, - )) + datasourceInfoList.push( + buildOnlineDocumentDatasourceInfo(previewOnlineDocumentRef.current, currentCredentialId), + ) } if (datasourceType === DatasourceType.websiteCrawl && previewWebsitePageRef.current) { - datasourceInfoList.push(buildWebsiteCrawlDatasourceInfo( - previewWebsitePageRef.current, - currentCredentialId, - )) + datasourceInfoList.push( + buildWebsiteCrawlDatasourceInfo(previewWebsitePageRef.current, currentCredentialId), + ) } if (datasourceType === DatasourceType.onlineDrive && previewOnlineDriveFileRef.current) { - datasourceInfoList.push(buildOnlineDriveDatasourceInfo( - previewOnlineDriveFileRef.current, - bucket, - currentCredentialId, - )) + datasourceInfoList.push( + buildOnlineDriveDatasourceInfo( + previewOnlineDriveFileRef.current, + bucket, + currentCredentialId, + ), + ) } return datasourceInfoList @@ -141,7 +145,7 @@ export const useDatasourceActions = ({ if (datasourceType === DatasourceType.onlineDrive) { selectedFileIds.forEach((id) => { - const file = onlineDriveFileList.find(f => f.id === id) + const file = onlineDriveFileList.find((f) => f.id === id) if (file) datasourceInfoList.push(buildOnlineDriveDatasourceInfo(file, bucket, currentCredentialId)) }) @@ -151,58 +155,83 @@ export const useDatasourceActions = ({ }, [dataSourceStore, datasourceType]) // Handle chunk preview - const handlePreviewChunks = useCallback(async (data: Record<string, unknown>) => { - if (!datasource || !pipelineId) - return - - const datasourceInfoList = buildPreviewDatasourceInfo() - await runPublishedPipeline({ - pipeline_id: pipelineId, - inputs: data, - start_node_id: datasource.nodeId, - datasource_type: datasourceType as DatasourceType, - datasource_info_list: datasourceInfoList, - is_preview: true, - }, { - onSuccess: (res) => { - setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs) - }, - }) - }, [datasource, pipelineId, datasourceType, buildPreviewDatasourceInfo, runPublishedPipeline, setEstimateData]) + const handlePreviewChunks = useCallback( + async (data: Record<string, unknown>) => { + if (!datasource || !pipelineId) return + + const datasourceInfoList = buildPreviewDatasourceInfo() + await runPublishedPipeline( + { + pipeline_id: pipelineId, + inputs: data, + start_node_id: datasource.nodeId, + datasource_type: datasourceType as DatasourceType, + datasource_info_list: datasourceInfoList, + is_preview: true, + }, + { + onSuccess: (res) => { + setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs) + }, + }, + ) + }, + [ + datasource, + pipelineId, + datasourceType, + buildPreviewDatasourceInfo, + runPublishedPipeline, + setEstimateData, + ], + ) // Handle document processing - const handleProcess = useCallback(async (data: Record<string, unknown>) => { - if (!canProcess) - return - - if (!datasource || !pipelineId) - return - - const datasourceInfoList = buildProcessDatasourceInfo() - await runPublishedPipeline({ - pipeline_id: pipelineId, - inputs: data, - start_node_id: datasource.nodeId, - datasource_type: datasourceType as DatasourceType, - datasource_info_list: datasourceInfoList, - is_preview: false, - }, { - onSuccess: (res) => { - setBatchId((res as PublishedPipelineRunResponse).batch || '') - setDocuments((res as PublishedPipelineRunResponse).documents || []) - handleNextStep() - trackEvent('dataset_document_added', { - data_source_type: datasourceType, - indexing_technique: 'pipeline', - }) - }, - }) - }, [canProcess, datasource, pipelineId, datasourceType, buildProcessDatasourceInfo, runPublishedPipeline, setBatchId, setDocuments, handleNextStep]) + const handleProcess = useCallback( + async (data: Record<string, unknown>) => { + if (!canProcess) return + + if (!datasource || !pipelineId) return + + const datasourceInfoList = buildProcessDatasourceInfo() + await runPublishedPipeline( + { + pipeline_id: pipelineId, + inputs: data, + start_node_id: datasource.nodeId, + datasource_type: datasourceType as DatasourceType, + datasource_info_list: datasourceInfoList, + is_preview: false, + }, + { + onSuccess: (res) => { + setBatchId((res as PublishedPipelineRunResponse).batch || '') + setDocuments((res as PublishedPipelineRunResponse).documents || []) + handleNextStep() + trackEvent('dataset_document_added', { + data_source_type: datasourceType, + indexing_technique: 'pipeline', + }) + }, + }, + ) + }, + [ + canProcess, + datasource, + pipelineId, + datasourceType, + buildProcessDatasourceInfo, + runPublishedPipeline, + setBatchId, + setDocuments, + handleNextStep, + ], + ) // Form submission handlers const onClickProcess = useCallback(() => { - if (!canProcess) - return + if (!canProcess) return isPreview.current = false formRef.current?.submit() @@ -213,100 +242,118 @@ export const useDatasourceActions = ({ formRef.current?.submit() }, []) - const handleSubmit = useCallback((data: Record<string, unknown>) => { - if (isPreview.current) - handlePreviewChunks(data) - else - handleProcess(data) - }, [handlePreviewChunks, handleProcess]) + const handleSubmit = useCallback( + (data: Record<string, unknown>) => { + if (isPreview.current) handlePreviewChunks(data) + else handleProcess(data) + }, + [handlePreviewChunks, handleProcess], + ) // Preview change handlers - const handlePreviewFileChange = useCallback((file: DocumentItem) => { - const { previewLocalFileRef } = dataSourceStore.getState() - previewLocalFileRef.current = file - onClickPreview() - }, [dataSourceStore, onClickPreview]) - - const handlePreviewOnlineDocumentChange = useCallback((page: NotionPage) => { - const { previewOnlineDocumentRef } = dataSourceStore.getState() - previewOnlineDocumentRef.current = page - onClickPreview() - }, [dataSourceStore, onClickPreview]) - - const handlePreviewWebsiteChange = useCallback((website: CrawlResultItem) => { - const { previewWebsitePageRef } = dataSourceStore.getState() - previewWebsitePageRef.current = website - onClickPreview() - }, [dataSourceStore, onClickPreview]) - - const handlePreviewOnlineDriveFileChange = useCallback((file: OnlineDriveFile) => { - const { previewOnlineDriveFileRef } = dataSourceStore.getState() - previewOnlineDriveFileRef.current = file - onClickPreview() - }, [dataSourceStore, onClickPreview]) + const handlePreviewFileChange = useCallback( + (file: DocumentItem) => { + const { previewLocalFileRef } = dataSourceStore.getState() + previewLocalFileRef.current = file + onClickPreview() + }, + [dataSourceStore, onClickPreview], + ) + + const handlePreviewOnlineDocumentChange = useCallback( + (page: NotionPage) => { + const { previewOnlineDocumentRef } = dataSourceStore.getState() + previewOnlineDocumentRef.current = page + onClickPreview() + }, + [dataSourceStore, onClickPreview], + ) + + const handlePreviewWebsiteChange = useCallback( + (website: CrawlResultItem) => { + const { previewWebsitePageRef } = dataSourceStore.getState() + previewWebsitePageRef.current = website + onClickPreview() + }, + [dataSourceStore, onClickPreview], + ) + + const handlePreviewOnlineDriveFileChange = useCallback( + (file: OnlineDriveFile) => { + const { previewOnlineDriveFileRef } = dataSourceStore.getState() + previewOnlineDriveFileRef.current = file + onClickPreview() + }, + [dataSourceStore, onClickPreview], + ) // Select all handler - const handleSelectAll = useCallback((checked: boolean) => { - const { - onlineDriveFileList, - setOnlineDocuments, - setSelectedFileIds, - setSelectedPagesId, - } = dataSourceStore.getState() - - if (datasourceType === DatasourceType.onlineDocument) { - const allIds = currentWorkspacePages?.map(page => page.page_id) || [] - if (checked) { - const selectedPages = Array.from(allIds).map(pageId => PagesMapAndSelectedPagesId[pageId]!) - setOnlineDocuments(selectedPages) - setSelectedPagesId(new Set(allIds)) + const handleSelectAll = useCallback( + (checked: boolean) => { + const { onlineDriveFileList, setOnlineDocuments, setSelectedFileIds, setSelectedPagesId } = + dataSourceStore.getState() + + if (datasourceType === DatasourceType.onlineDocument) { + const allIds = currentWorkspacePages?.map((page) => page.page_id) || [] + if (checked) { + const selectedPages = Array.from(allIds).map( + (pageId) => PagesMapAndSelectedPagesId[pageId]!, + ) + setOnlineDocuments(selectedPages) + setSelectedPagesId(new Set(allIds)) + } else { + setOnlineDocuments([]) + setSelectedPagesId(new Set()) + } } - else { - setOnlineDocuments([]) - setSelectedPagesId(new Set()) - } - } - if (datasourceType === DatasourceType.onlineDrive) { - const allKeys = onlineDriveFileList.filter(item => item.type !== 'bucket').map(file => file.id) - if (checked) - setSelectedFileIds(allKeys) - else - setSelectedFileIds([]) - } - }, [PagesMapAndSelectedPagesId, currentWorkspacePages, dataSourceStore, datasourceType]) + if (datasourceType === DatasourceType.onlineDrive) { + const allKeys = onlineDriveFileList + .filter((item) => item.type !== 'bucket') + .map((file) => file.id) + if (checked) setSelectedFileIds(allKeys) + else setSelectedFileIds([]) + } + }, + [PagesMapAndSelectedPagesId, currentWorkspacePages, dataSourceStore, datasourceType], + ) // Clear datasource data based on type - const clearDataSourceData = useCallback((dataSource: Datasource) => { - const providerType = dataSource.nodeData.provider_type - const clearFunctions: Record<string, () => void> = { - [DatasourceType.onlineDocument]: clearOnlineDocumentData, - [DatasourceType.websiteCrawl]: clearWebsiteCrawlData, - [DatasourceType.onlineDrive]: clearOnlineDriveData, - [DatasourceType.localFile]: () => {}, - } - clearFunctions[providerType]?.() - }, [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData]) + const clearDataSourceData = useCallback( + (dataSource: Datasource) => { + const providerType = dataSource.nodeData.provider_type + const clearFunctions: Record<string, () => void> = { + [DatasourceType.onlineDocument]: clearOnlineDocumentData, + [DatasourceType.websiteCrawl]: clearWebsiteCrawlData, + [DatasourceType.onlineDrive]: clearOnlineDriveData, + [DatasourceType.localFile]: () => {}, + } + clearFunctions[providerType]?.() + }, + [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData], + ) // Switch datasource handler - const handleSwitchDataSource = useCallback((dataSource: Datasource) => { - const { - setCurrentCredentialId, - currentNodeIdRef, - } = dataSourceStore.getState() - clearDataSourceData(dataSource) - setCurrentCredentialId('') - currentNodeIdRef.current = dataSource.nodeId - setDatasource(dataSource) - }, [clearDataSourceData, dataSourceStore, setDatasource]) + const handleSwitchDataSource = useCallback( + (dataSource: Datasource) => { + const { setCurrentCredentialId, currentNodeIdRef } = dataSourceStore.getState() + clearDataSourceData(dataSource) + setCurrentCredentialId('') + currentNodeIdRef.current = dataSource.nodeId + setDatasource(dataSource) + }, + [clearDataSourceData, dataSourceStore, setDatasource], + ) // Credential change handler - const handleCredentialChange = useCallback((credentialId: string) => { - const { setCurrentCredentialId } = dataSourceStore.getState() - if (datasource) - clearDataSourceData(datasource) - setCurrentCredentialId(credentialId) - }, [clearDataSourceData, dataSourceStore, datasource]) + const handleCredentialChange = useCallback( + (credentialId: string) => { + const { setCurrentCredentialId } = dataSourceStore.getState() + if (datasource) clearDataSourceData(datasource) + setCurrentCredentialId(credentialId) + }, + [clearDataSourceData, dataSourceStore, datasource], + ) return { isPreview, diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-options.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-options.ts index a8b233faba17c9..a681aca43e5c2e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-options.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-options.ts @@ -8,7 +8,7 @@ import { BlockEnum } from '@/app/components/workflow/types' * Hook for getting datasource options from pipeline nodes */ export const useDatasourceOptions = (pipelineNodes: Node<DataSourceNodeType>[]) => { - const datasourceNodes = pipelineNodes.filter(node => node.data.type === BlockEnum.DataSource) + const datasourceNodes = pipelineNodes.filter((node) => node.data.type === BlockEnum.DataSource) const options = useMemo(() => { const options: DataSourceOption[] = [] diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-store.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-store.ts index da620de1544bea..9d55c54da476a2 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-store.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-store.ts @@ -8,16 +8,18 @@ import { useDataSourceStore, useDataSourceStoreWithSelector } from '../data-sour * Hook for local file datasource store operations */ export const useLocalFile = () => { - const { - localFileList, - currentLocalFile, - } = useDataSourceStoreWithSelector(useShallow(state => ({ - localFileList: state.localFileList, - currentLocalFile: state.currentLocalFile, - }))) + const { localFileList, currentLocalFile } = useDataSourceStoreWithSelector( + useShallow((state) => ({ + localFileList: state.localFileList, + currentLocalFile: state.currentLocalFile, + })), + ) const dataSourceStore = useDataSourceStore() - const allFileLoaded = useMemo(() => (localFileList.length > 0 && localFileList.every(file => file.file.id)), [localFileList]) + const allFileLoaded = useMemo( + () => localFileList.length > 0 && localFileList.every((file) => file.file.id), + [localFileList], + ) const hidePreviewLocalFile = useCallback(() => { const { setCurrentLocalFile } = dataSourceStore.getState() @@ -36,30 +38,31 @@ export const useLocalFile = () => { * Hook for online document datasource store operations */ export const useOnlineDocument = () => { - const { - documentsData, - onlineDocuments, - currentDocument, - } = useDataSourceStoreWithSelector(useShallow(state => ({ - documentsData: state.documentsData, - onlineDocuments: state.onlineDocuments, - currentDocument: state.currentDocument, - }))) + const { documentsData, onlineDocuments, currentDocument } = useDataSourceStoreWithSelector( + useShallow((state) => ({ + documentsData: state.documentsData, + onlineDocuments: state.onlineDocuments, + currentDocument: state.currentDocument, + })), + ) const dataSourceStore = useDataSourceStore() const currentWorkspace = documentsData[0] const PagesMapAndSelectedPagesId: DataSourceNotionPageMap = useMemo(() => { - const pagesMap = (documentsData || []).reduce((prev: DataSourceNotionPageMap, next: DataSourceNotionWorkspace) => { - next.pages.forEach((page) => { - prev[page.page_id] = { - ...page, - workspace_id: next.workspace_id, - } - }) - - return prev - }, {}) + const pagesMap = (documentsData || []).reduce( + (prev: DataSourceNotionPageMap, next: DataSourceNotionWorkspace) => { + next.pages.forEach((page) => { + prev[page.page_id] = { + ...page, + workspace_id: next.workspace_id, + } + }) + + return prev + }, + {}, + ) return pagesMap }, [documentsData]) @@ -97,13 +100,12 @@ export const useOnlineDocument = () => { * Hook for website crawl datasource store operations */ export const useWebsiteCrawl = () => { - const { - websitePages, - currentWebsite, - } = useDataSourceStoreWithSelector(useShallow(state => ({ - websitePages: state.websitePages, - currentWebsite: state.currentWebsite, - }))) + const { websitePages, currentWebsite } = useDataSourceStoreWithSelector( + useShallow((state) => ({ + websitePages: state.websitePages, + currentWebsite: state.currentWebsite, + })), + ) const dataSourceStore = useDataSourceStore() const hideWebsitePreview = useCallback(() => { @@ -113,13 +115,8 @@ export const useWebsiteCrawl = () => { }, [dataSourceStore]) const clearWebsiteCrawlData = useCallback(() => { - const { - setStep, - setCrawlResult, - setWebsitePages, - setPreviewIndex, - setCurrentWebsite, - } = dataSourceStore.getState() + const { setStep, setCrawlResult, setWebsitePages, setPreviewIndex, setCurrentWebsite } = + dataSourceStore.getState() setStep(CrawlStep.init) setCrawlResult(undefined) setCurrentWebsite(undefined) @@ -139,27 +136,21 @@ export const useWebsiteCrawl = () => { * Hook for online drive datasource store operations */ export const useOnlineDrive = () => { - const { - onlineDriveFileList, - selectedFileIds, - } = useDataSourceStoreWithSelector(useShallow(state => ({ - onlineDriveFileList: state.onlineDriveFileList, - selectedFileIds: state.selectedFileIds, - }))) + const { onlineDriveFileList, selectedFileIds } = useDataSourceStoreWithSelector( + useShallow((state) => ({ + onlineDriveFileList: state.onlineDriveFileList, + selectedFileIds: state.selectedFileIds, + })), + ) const dataSourceStore = useDataSourceStore() const selectedOnlineDriveFileList = useMemo(() => { - return selectedFileIds.map(id => onlineDriveFileList.find(item => item.id === id)!) + return selectedFileIds.map((id) => onlineDriveFileList.find((item) => item.id === id)!) }, [onlineDriveFileList, selectedFileIds]) const clearOnlineDriveData = useCallback(() => { - const { - setOnlineDriveFileList, - setBucket, - setPrefix, - setKeywords, - setSelectedFileIds, - } = dataSourceStore.getState() + const { setOnlineDriveFileList, setBucket, setPrefix, setKeywords, setSelectedFileIds } = + dataSourceStore.getState() setOnlineDriveFileList([]) setBucket('') setPrefix([]) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-ui-state.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-ui-state.ts index 257c76aa0e660c..a84de2e1f956f4 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-ui-state.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-ui-state.ts @@ -16,7 +16,7 @@ type DatasourceUIStateParams = { isCheckingVectorSpace?: boolean enableBilling: boolean currentWorkspacePagesLength: number - fileUploadConfig: { file_size_limit: number, batch_count_limit: number } + fileUploadConfig: { file_size_limit: number; batch_count_limit: number } } /** @@ -40,8 +40,7 @@ export const useDatasourceUIState = ({ const datasourceType = datasource?.nodeData.provider_type const isShowVectorSpaceFull = useMemo(() => { - if (!datasource || !datasourceType) - return false + if (!datasource || !datasourceType) return false // Lookup table for vector space full condition check const vectorSpaceFullConditions: Record<string, boolean> = { @@ -53,31 +52,55 @@ export const useDatasourceUIState = ({ const condition = vectorSpaceFullConditions[datasourceType] return condition && isVectorSpaceFull && enableBilling - }, [datasource, datasourceType, allFileLoaded, onlineDocumentsLength, websitePagesLength, onlineDriveFileList.length, isVectorSpaceFull, enableBilling]) + }, [ + datasource, + datasourceType, + allFileLoaded, + onlineDocumentsLength, + websitePagesLength, + onlineDriveFileList.length, + isVectorSpaceFull, + enableBilling, + ]) // Lookup table for next button disabled conditions const nextBtnDisabled = useMemo(() => { - if (!datasource || !datasourceType) - return true + if (!datasource || !datasourceType) return true const disabledConditions: Record<string, boolean> = { - [DatasourceType.localFile]: isCheckingVectorSpace || isShowVectorSpaceFull || localFileListLength === 0 || !allFileLoaded, - [DatasourceType.onlineDocument]: isCheckingVectorSpace || isShowVectorSpaceFull || onlineDocumentsLength === 0, - [DatasourceType.websiteCrawl]: isCheckingVectorSpace || isShowVectorSpaceFull || websitePagesLength === 0, - [DatasourceType.onlineDrive]: isCheckingVectorSpace || isShowVectorSpaceFull || selectedFileIdsLength === 0, + [DatasourceType.localFile]: + isCheckingVectorSpace || + isShowVectorSpaceFull || + localFileListLength === 0 || + !allFileLoaded, + [DatasourceType.onlineDocument]: + isCheckingVectorSpace || isShowVectorSpaceFull || onlineDocumentsLength === 0, + [DatasourceType.websiteCrawl]: + isCheckingVectorSpace || isShowVectorSpaceFull || websitePagesLength === 0, + [DatasourceType.onlineDrive]: + isCheckingVectorSpace || isShowVectorSpaceFull || selectedFileIdsLength === 0, } return disabledConditions[datasourceType] ?? true - }, [datasource, datasourceType, isCheckingVectorSpace, isShowVectorSpaceFull, localFileListLength, allFileLoaded, onlineDocumentsLength, websitePagesLength, selectedFileIdsLength]) + }, [ + datasource, + datasourceType, + isCheckingVectorSpace, + isShowVectorSpaceFull, + localFileListLength, + allFileLoaded, + onlineDocumentsLength, + websitePagesLength, + selectedFileIdsLength, + ]) // Check if select all should be shown const showSelect = useMemo(() => { - if (datasourceType === DatasourceType.onlineDocument) - return currentWorkspacePagesLength > 0 + if (datasourceType === DatasourceType.onlineDocument) return currentWorkspacePagesLength > 0 if (datasourceType === DatasourceType.onlineDrive) { - const nonBucketItems = onlineDriveFileList.filter(item => item.type !== 'bucket') - const isBucketList = onlineDriveFileList.some(file => file.type === 'bucket') + const nonBucketItems = onlineDriveFileList.filter((item) => item.type !== 'bucket') + const isBucketList = onlineDriveFileList.some((file) => file.type === 'bucket') return !isBucketList && nonBucketItems.length > 0 } @@ -86,22 +109,19 @@ export const useDatasourceUIState = ({ // Total selectable options count const totalOptions = useMemo(() => { - if (datasourceType === DatasourceType.onlineDocument) - return currentWorkspacePagesLength + if (datasourceType === DatasourceType.onlineDocument) return currentWorkspacePagesLength if (datasourceType === DatasourceType.onlineDrive) - return onlineDriveFileList.filter(item => item.type !== 'bucket').length + return onlineDriveFileList.filter((item) => item.type !== 'bucket').length return undefined }, [currentWorkspacePagesLength, datasourceType, onlineDriveFileList]) // Selected options count const selectedOptions = useMemo(() => { - if (datasourceType === DatasourceType.onlineDocument) - return onlineDocumentsLength + if (datasourceType === DatasourceType.onlineDocument) return onlineDocumentsLength - if (datasourceType === DatasourceType.onlineDrive) - return selectedFileIdsLength + if (datasourceType === DatasourceType.onlineDrive) return selectedFileIdsLength return undefined }, [datasourceType, onlineDocumentsLength, selectedFileIdsLength]) @@ -109,10 +129,13 @@ export const useDatasourceUIState = ({ // Tip message for selection const tip = useMemo(() => { if (datasourceType === DatasourceType.onlineDocument) - return t($ => $['addDocuments.selectOnlineDocumentTip'], { ns: 'datasetPipeline', count: 50 }) + return t(($) => $['addDocuments.selectOnlineDocumentTip'], { + ns: 'datasetPipeline', + count: 50, + }) if (datasourceType === DatasourceType.onlineDrive) { - return t($ => $['addDocuments.selectOnlineDriveTip'], { + return t(($) => $['addDocuments.selectOnlineDriveTip'], { ns: 'datasetPipeline', count: fileUploadConfig.batch_count_limit, fileSize: fileUploadConfig.file_size_limit, diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/index.tsx index 5ea2c8cd3e8580..1c9ae60606c853 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/index.tsx @@ -12,7 +12,10 @@ import Loading from '@/app/components/base/loading' import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { userProfileIdAtom } from '@/context/account-state' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { workspacePermissionKeysAtom, workspacePermissionKeysLoadingAtom } from '@/context/permission-state' +import { + workspacePermissionKeysAtom, + workspacePermissionKeysLoadingAtom, +} from '@/context/permission-state' import { useProviderContextSelector } from '@/context/provider-context' import { DatasourceType } from '@/models/pipeline' import { useRouter } from '@/next/navigation' @@ -38,9 +41,9 @@ import { StepOnePreview, StepTwoPreview } from './steps/preview-panel' const CreateFormPipeline = () => { const { t } = useTranslation() const router = useRouter() - const plan = useProviderContextSelector(state => state.plan) - const enableBilling = useProviderContextSelector(state => state.enableBilling) - const dataset = useDatasetDetailContextWithSelector(s => s.dataset) + const plan = useProviderContextSelector((state) => state.plan) + const enableBilling = useProviderContextSelector((state) => state.enableBilling) + const dataset = useDatasetDetailContextWithSelector((s) => s.dataset) const pipelineId = dataset?.pipeline_id const currentUserId = useAtomValue(userProfileIdAtom) const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom) @@ -51,24 +54,31 @@ const CreateFormPipeline = () => { resourceMaintainer: dataset?.maintainer, workspacePermissionKeys, }).canUse - const shouldRedirectToDocuments = !!dataset - && !isLoadingWorkspacePermissionKeys - && !canAddDocumentsToDataset + const shouldRedirectToDocuments = + !!dataset && !isLoadingWorkspacePermissionKeys && !canAddDocumentsToDataset // Core state const [datasource, setDatasource] = useState<Datasource>() - const [estimateData, setEstimateData] = useState<FileIndexingEstimateResponse | undefined>(undefined) + const [estimateData, setEstimateData] = useState<FileIndexingEstimateResponse | undefined>( + undefined, + ) const [batchId, setBatchId] = useState('') const [documents, setDocuments] = useState<InitialDocumentDetail[]>([]) // Data fetching - const { data: pipelineInfo, isFetching: isFetchingPipelineInfo } = usePublishedPipelineInfo(pipelineId || '') + const { data: pipelineInfo, isFetching: isFetchingPipelineInfo } = usePublishedPipelineInfo( + pipelineId || '', + ) const { data: fileUploadConfigResponse } = useFileUploadConfig() - const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? { - file_size_limit: 15, - batch_count_limit: 5, - }, [fileUploadConfigResponse]) + const fileUploadConfig = useMemo( + () => + fileUploadConfigResponse ?? { + file_size_limit: 15, + batch_count_limit: 5, + }, + [fileUploadConfigResponse], + ) // Steps management const { @@ -79,12 +89,7 @@ const CreateFormPipeline = () => { } = useAddDocumentsSteps() // Datasource-specific hooks - const { - localFileList, - allFileLoaded, - currentLocalFile, - hidePreviewLocalFile, - } = useLocalFile() + const { localFileList, allFileLoaded, currentLocalFile, hidePreviewLocalFile } = useLocalFile() const { currentWorkspace, @@ -95,12 +100,8 @@ const CreateFormPipeline = () => { clearOnlineDocumentData, } = useOnlineDocument() - const { - websitePages, - currentWebsite, - hideWebsitePreview, - clearWebsiteCrawlData, - } = useWebsiteCrawl() + const { websitePages, currentWebsite, hideWebsitePreview, clearWebsiteCrawlData } = + useWebsiteCrawl() const { onlineDriveFileList, @@ -110,20 +111,17 @@ const CreateFormPipeline = () => { } = useOnlineDrive() // Computed values - const shouldCheckVectorSpace = enableBilling && ( - allFileLoaded - || onlineDocuments.length > 0 - || websitePages.length > 0 - || selectedFileIds.length > 0 - ) - const { - data: vectorSpace, - isFetching: isFetchingVectorSpacePlan, - } = useCurrentPlanVectorSpace(shouldCheckVectorSpace) + const shouldCheckVectorSpace = + enableBilling && + (allFileLoaded || + onlineDocuments.length > 0 || + websitePages.length > 0 || + selectedFileIds.length > 0) + const { data: vectorSpace, isFetching: isFetchingVectorSpacePlan } = + useCurrentPlanVectorSpace(shouldCheckVectorSpace) const isCheckingVectorSpace = shouldCheckVectorSpace && !vectorSpace && isFetchingVectorSpacePlan - const isVectorSpaceFull = !!vectorSpace - && vectorSpace.limit > 0 - && vectorSpace.size >= vectorSpace.limit + const isVectorSpaceFull = + !!vectorSpace && vectorSpace.limit > 0 && vectorSpace.size >= vectorSpace.limit const supportBatchUpload = !enableBilling || plan.type !== 'sandbox' // UI state @@ -151,10 +149,10 @@ const CreateFormPipeline = () => { }) // Plan upgrade modal - const [isShowPlanUpgradeModal, { - setTrue: showPlanUpgradeModal, - setFalse: hidePlanUpgradeModal, - }] = useBoolean(false) + const [ + isShowPlanUpgradeModal, + { setTrue: showPlanUpgradeModal, setFalse: hidePlanUpgradeModal }, + ] = useBoolean(false) // Next step with batch upload check const handleNextStep = useCallback(() => { @@ -172,7 +170,16 @@ const CreateFormPipeline = () => { } } doHandleNextStep() - }, [datasourceType, doHandleNextStep, localFileList.length, onlineDocuments.length, selectedFileIds.length, showPlanUpgradeModal, supportBatchUpload, websitePages.length]) + }, [ + datasourceType, + doHandleNextStep, + localFileList.length, + onlineDocuments.length, + selectedFileIds.length, + showPlanUpgradeModal, + supportBatchUpload, + websitePages.length, + ]) // Datasource actions const { @@ -213,11 +220,9 @@ const CreateFormPipeline = () => { router.replace(`/datasets/${dataset.id}/documents`) }, [dataset, router, shouldRedirectToDocuments]) - if (isFetchingPipelineInfo) - return <Loading type="app" /> + if (isFetchingPipelineInfo) return <Loading type="app" /> - if (isLoadingWorkspacePermissionKeys || shouldRedirectToDocuments) - return <Loading type="app" /> + if (isLoadingWorkspacePermissionKeys || shouldRedirectToDocuments) return <Loading type="app" /> return ( <div className="relative flex h-[calc(100vh-56px)] w-full min-w-[1024px] overflow-x-auto rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle"> @@ -225,7 +230,7 @@ const CreateFormPipeline = () => { <div className="flex h-full flex-col px-14"> <LeftHeader steps={steps} - title={t($ => $['addDocuments.title'], { ns: 'datasetPipeline' })} + title={t(($) => $['addDocuments.title'], { ns: 'datasetPipeline' })} currentStep={currentStep} /> <div className="grow overflow-y-auto"> @@ -259,12 +264,7 @@ const CreateFormPipeline = () => { onBack={handleBackStep} /> )} - {currentStep === 3 && ( - <StepThreeContent - batchId={batchId} - documents={documents} - /> - )} + {currentStep === 3 && <StepThreeContent batchId={batchId} documents={documents} />} </div> </div> </div> @@ -304,8 +304,8 @@ const CreateFormPipeline = () => { <PlanUpgradeModal show onClose={hidePlanUpgradeModal} - title={t($ => $['upgrade.uploadMultiplePages.title'], { ns: 'billing' })!} - description={t($ => $['upgrade.uploadMultiplePages.description'], { ns: 'billing' })!} + title={t(($) => $['upgrade.uploadMultiplePages.title'], { ns: 'billing' })!} + description={t(($) => $['upgrade.uploadMultiplePages.description'], { ns: 'billing' })!} /> )} </div> diff --git a/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx b/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx index ab432d69627190..3a3238591e239d 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx @@ -13,11 +13,7 @@ type LeftHeaderProps = { currentStep: number } -const LeftHeader = ({ - steps, - title, - currentStep, -}: LeftHeaderProps) => { +const LeftHeader = ({ steps, title, currentStep }: LeftHeaderProps) => { const { datasetId } = useParams() return ( @@ -29,14 +25,9 @@ const LeftHeader = ({ <span className="system-2xs-regular text-divider-regular">/</span> <StepIndicator steps={steps} currentStep={currentStep} /> </div> - <div className="system-md-semibold text-text-primary"> - {steps[currentStep - 1]?.label} - </div> + <div className="system-md-semibold text-text-primary">{steps[currentStep - 1]?.label}</div> {currentStep !== steps.length && ( - <Link - href={`/datasets/${datasetId}/documents`} - replace - > + <Link href={`/datasets/${datasetId}/documents`} replace> <Button variant="secondary-accent" className="absolute top-3.5 -left-11 size-9 rounded-full p-0" diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx index c98acc2086e703..d1d53629c49445 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx @@ -12,17 +12,23 @@ import ChunkPreview from '../chunk-preview' // Mock dataset-detail context - needs mock to control return values const mockDocForm = vi.fn() vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (_selector: (s: { dataset: { doc_form: ChunkingMode } }) => ChunkingMode) => { + useDatasetDetailContextWithSelector: ( + _selector: (s: { dataset: { doc_form: ChunkingMode } }) => ChunkingMode, + ) => { return mockDocForm() }, })) // Mock document picker - needs mock for simplified interaction testing vi.mock('../../../../common/document-picker/preview-document-picker', () => ({ - default: ({ files, onChange, value }: { - files: Array<{ id: string, name: string, extension: string }> - onChange: (selected: { id: string, name: string, extension: string }) => void - value: { id: string, name: string, extension: string } + default: ({ + files, + onChange, + value, + }: { + files: Array<{ id: string; name: string; extension: string }> + onChange: (selected: { id: string; name: string; extension: string }) => void + value: { id: string; name: string; extension: string } }) => ( <div data-testid="document-picker"> <span data-testid="picker-value">{value?.name || 'No selection'}</span> @@ -30,34 +36,36 @@ vi.mock('../../../../common/document-picker/preview-document-picker', () => ({ data-testid="picker-select" value={value?.id || ''} onChange={(e) => { - const selected = files.find(f => f.id === e.target.value) - if (selected) - onChange(selected) + const selected = files.find((f) => f.id === e.target.value) + if (selected) onChange(selected) }} > - {files.map(f => ( - <option key={f.id} value={f.id}>{f.name}</option> + {files.map((f) => ( + <option key={f.id} value={f.id}> + {f.name} + </option> ))} </select> </div> ), })) -const createMockLocalFile = (overrides?: Partial<CustomFile>): CustomFile => ({ - id: 'file-1', - name: 'test-file.pdf', - size: 1024, - type: 'application/pdf', - extension: 'pdf', - lastModified: Date.now(), - webkitRelativePath: '', - arrayBuffer: vi.fn() as () => Promise<ArrayBuffer>, - bytes: vi.fn() as () => Promise<Uint8Array>, - slice: vi.fn() as (start?: number, end?: number, contentType?: string) => Blob, - stream: vi.fn() as () => ReadableStream<Uint8Array>, - text: vi.fn() as () => Promise<string>, - ...overrides, -} as CustomFile) +const createMockLocalFile = (overrides?: Partial<CustomFile>): CustomFile => + ({ + id: 'file-1', + name: 'test-file.pdf', + size: 1024, + type: 'application/pdf', + extension: 'pdf', + lastModified: Date.now(), + webkitRelativePath: '', + arrayBuffer: vi.fn() as () => Promise<ArrayBuffer>, + bytes: vi.fn() as () => Promise<Uint8Array>, + slice: vi.fn() as (start?: number, end?: number, contentType?: string) => Blob, + stream: vi.fn() as () => ReadableStream<Uint8Array>, + text: vi.fn() as () => Promise<string>, + ...overrides, + }) as CustomFile const createMockNotionPage = (overrides?: Partial<NotionPage>): NotionPage => ({ page_id: 'page-1', @@ -86,7 +94,9 @@ const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): Online ...overrides, }) -const createMockEstimateData = (overrides?: Partial<FileIndexingEstimateResponse>): FileIndexingEstimateResponse => ({ +const createMockEstimateData = ( + overrides?: Partial<FileIndexingEstimateResponse>, +): FileIndexingEstimateResponse => ({ total_nodes: 5, tokens: 1000, total_price: 0.01, @@ -184,7 +194,9 @@ describe('ChunkPreview', () => { // i18n mock returns keys expect(screen.getByText('datasetCreation.stepTwo.previewChunkTip')).toBeInTheDocument() - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.previewChunks')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.previewChunks'), + ).toBeInTheDocument() }) it('should call onPreview when preview button is clicked', () => { @@ -255,9 +267,7 @@ describe('ChunkPreview', () => { it('should render parent-child preview chunks', () => { mockDocForm.mockReturnValue(ChunkingMode.parentChild) const estimateData = createMockEstimateData({ - preview: [ - { content: 'Parent chunk 1', child_chunks: ['Child 1', 'Child 2'] }, - ], + preview: [{ content: 'Parent chunk 1', child_chunks: ['Child 1', 'Child 2'] }], }) render(<ChunkPreview {...defaultProps} estimateData={estimateData} />) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx index ed5b8ccea8b36a..3cca0655314bff 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx @@ -3,8 +3,10 @@ import { render, screen } from '@testing-library/react' import Loading from '../loading' vi.mock('@/app/components/base/skeleton', () => ({ - SkeletonContainer: ({ children, className }: { children?: ReactNode, className?: string }) => ( - <div data-testid="skeleton-container" className={className}>{children}</div> + SkeletonContainer: ({ children, className }: { children?: ReactNode; className?: string }) => ( + <div data-testid="skeleton-container" className={className}> + {children} + </div> ), SkeletonRectangle: ({ className }: { className?: string }) => ( <div data-testid="skeleton-rectangle" className={className} /> diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx index 96033a6f608557..abbefcd97aa42e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx @@ -23,7 +23,9 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { // Mock dataset-detail context - needs mock to control return values const mockPipelineId = vi.fn() vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (_selector: (s: { dataset: { pipeline_id: string } }) => string) => { + useDatasetDetailContextWithSelector: ( + _selector: (s: { dataset: { pipeline_id: string } }) => string, + ) => { return mockPipelineId() }, })) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx index 6265f939145d49..074b7a5524d215 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx @@ -1,5 +1,10 @@ import type { NotionPage } from '@/models/common' -import type { CrawlResultItem, CustomFile, DocumentItem, FileIndexingEstimateResponse } from '@/models/datasets' +import type { + CrawlResultItem, + CustomFile, + DocumentItem, + FileIndexingEstimateResponse, +} from '@/models/datasets' import type { OnlineDriveFile } from '@/models/pipeline' import { Button } from '@langgenius/dify-ui/button' import { RiSearchEyeLine } from '@remixicon/react' @@ -7,7 +12,12 @@ import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' -import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' +import { + SkeletonContainer, + SkeletonPoint, + SkeletonRectangle, + SkeletonRow, +} from '@/app/components/base/skeleton' import SummaryLabel from '@/app/components/datasets/documents/detail/completed/common/summary-label' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { ChunkingMode } from '@/models/datasets' @@ -52,118 +62,108 @@ const ChunkPreview = ({ handlePreviewOnlineDriveFileChange, }: ChunkPreviewProps) => { const { t } = useTranslation() - const currentDocForm = useDatasetDetailContextWithSelector(s => s.dataset?.doc_form) + const currentDocForm = useDatasetDetailContextWithSelector((s) => s.dataset?.doc_form) const [previewFile, setPreviewFile] = useState<DocumentItem>(localFiles[0] as DocumentItem) - const [previewOnlineDocument, setPreviewOnlineDocument] = useState<NotionPage>(onlineDocuments[0]!) + const [previewOnlineDocument, setPreviewOnlineDocument] = useState<NotionPage>( + onlineDocuments[0]!, + ) const [previewWebsitePage, setPreviewWebsitePage] = useState<CrawlResultItem>(websitePages[0]!) - const [previewOnlineDriveFile, setPreviewOnlineDriveFile] = useState<OnlineDriveFile>(onlineDriveFiles[0]!) + const [previewOnlineDriveFile, setPreviewOnlineDriveFile] = useState<OnlineDriveFile>( + onlineDriveFiles[0]!, + ) return ( <PreviewContainer - header={( - <PreviewHeader - title={t($ => $['stepTwo.preview'], { ns: 'datasetCreation' })} - > + header={ + <PreviewHeader title={t(($) => $['stepTwo.preview'], { ns: 'datasetCreation' })}> <div className="flex items-center gap-1"> - {dataSourceType === DatasourceType.localFile - && ( - <PreviewDocumentPicker - files={localFiles as Array<Required<CustomFile>>} - onChange={(selected) => { - setPreviewFile(selected) - handlePreviewFileChange(selected) - }} - value={previewFile} - /> - )} - {dataSourceType === DatasourceType.onlineDocument - && ( - <PreviewDocumentPicker - files={ - onlineDocuments.map(page => ({ - id: page.page_id, - name: page.page_name, - extension: 'md', - })) - } - onChange={(selected) => { - const selectedPage = onlineDocuments.find(page => page.page_id === selected.id) - setPreviewOnlineDocument(selectedPage!) - handlePreviewOnlineDocumentChange(selectedPage!) - }} - value={{ - id: previewOnlineDocument?.page_id || '', - name: previewOnlineDocument?.page_name || '', - extension: 'md', - }} - /> - )} - {dataSourceType === DatasourceType.websiteCrawl - && ( - <PreviewDocumentPicker - files={ - websitePages.map(page => ({ - id: page.source_url, - name: page.title, - extension: 'md', - })) - } - onChange={(selected) => { - const selectedPage = websitePages.find(page => page.source_url === selected.id) - setPreviewWebsitePage(selectedPage!) - handlePreviewWebsitePageChange(selectedPage!) - }} - value={ - { - id: previewWebsitePage?.source_url || '', - name: previewWebsitePage?.title || '', - extension: 'md', - } - } - /> - )} - {dataSourceType === DatasourceType.onlineDrive - && ( - <PreviewDocumentPicker - files={ - onlineDriveFiles.map(file => ({ - id: file.id, - name: file.name, - extension: getFileExtension(previewOnlineDriveFile?.name), - })) - } - onChange={(selected) => { - const selectedFile = onlineDriveFiles.find(file => file.id === selected.id) - setPreviewOnlineDriveFile(selectedFile!) - handlePreviewOnlineDriveFileChange(selectedFile!) - }} - value={ - { - id: previewOnlineDriveFile?.id || '', - name: previewOnlineDriveFile?.name || '', - extension: getFileExtension(previewOnlineDriveFile?.name), - } - } - /> - )} - { - currentDocForm !== ChunkingMode.qa - && ( - <Badge text={t($ => $['stepTwo.previewChunkCount'], { - ns: 'datasetCreation', - count: estimateData?.total_segments || 0, - }) as string} - /> - ) - } + {dataSourceType === DatasourceType.localFile && ( + <PreviewDocumentPicker + files={localFiles as Array<Required<CustomFile>>} + onChange={(selected) => { + setPreviewFile(selected) + handlePreviewFileChange(selected) + }} + value={previewFile} + /> + )} + {dataSourceType === DatasourceType.onlineDocument && ( + <PreviewDocumentPicker + files={onlineDocuments.map((page) => ({ + id: page.page_id, + name: page.page_name, + extension: 'md', + }))} + onChange={(selected) => { + const selectedPage = onlineDocuments.find((page) => page.page_id === selected.id) + setPreviewOnlineDocument(selectedPage!) + handlePreviewOnlineDocumentChange(selectedPage!) + }} + value={{ + id: previewOnlineDocument?.page_id || '', + name: previewOnlineDocument?.page_name || '', + extension: 'md', + }} + /> + )} + {dataSourceType === DatasourceType.websiteCrawl && ( + <PreviewDocumentPicker + files={websitePages.map((page) => ({ + id: page.source_url, + name: page.title, + extension: 'md', + }))} + onChange={(selected) => { + const selectedPage = websitePages.find((page) => page.source_url === selected.id) + setPreviewWebsitePage(selectedPage!) + handlePreviewWebsitePageChange(selectedPage!) + }} + value={{ + id: previewWebsitePage?.source_url || '', + name: previewWebsitePage?.title || '', + extension: 'md', + }} + /> + )} + {dataSourceType === DatasourceType.onlineDrive && ( + <PreviewDocumentPicker + files={onlineDriveFiles.map((file) => ({ + id: file.id, + name: file.name, + extension: getFileExtension(previewOnlineDriveFile?.name), + }))} + onChange={(selected) => { + const selectedFile = onlineDriveFiles.find((file) => file.id === selected.id) + setPreviewOnlineDriveFile(selectedFile!) + handlePreviewOnlineDriveFileChange(selectedFile!) + }} + value={{ + id: previewOnlineDriveFile?.id || '', + name: previewOnlineDriveFile?.name || '', + extension: getFileExtension(previewOnlineDriveFile?.name), + }} + /> + )} + {currentDocForm !== ChunkingMode.qa && ( + <Badge + text={ + t(($) => $['stepTwo.previewChunkCount'], { + ns: 'datasetCreation', + count: estimateData?.total_segments || 0, + }) as string + } + /> + )} </div> </PreviewHeader> - )} + } className="relative flex size-full shrink-0" mainClassName="space-y-6" > - {!isPending && currentDocForm === ChunkingMode.qa && estimateData?.qa_preview && ( + {!isPending && + currentDocForm === ChunkingMode.qa && + estimateData?.qa_preview && estimateData?.qa_preview.map((item, index) => ( <ChunkContainer key={`${item.question}-${index}`} @@ -172,9 +172,10 @@ const ChunkPreview = ({ > <QAPreview qa={item} /> </ChunkContainer> - )) - )} - {!isPending && currentDocForm === ChunkingMode.text && estimateData?.preview && ( + ))} + {!isPending && + currentDocForm === ChunkingMode.text && + estimateData?.preview && estimateData?.preview.map((item, index) => ( <ChunkContainer key={`${item.content}-${index}`} @@ -184,9 +185,10 @@ const ChunkPreview = ({ {item.content} {item.summary && <SummaryLabel summary={item.summary} />} </ChunkContainer> - )) - )} - {!isPending && currentDocForm === ChunkingMode.parentChild && estimateData?.preview && ( + ))} + {!isPending && + currentDocForm === ChunkingMode.parentChild && + estimateData?.preview && estimateData?.preview?.map((item, index) => { const indexForLabel = index + 1 return ( @@ -213,17 +215,16 @@ const ChunkPreview = ({ </FormattedText> </ChunkContainer> ) - }) - )} + })} {isIdle && ( <div className="flex size-full items-center justify-center"> <div className="flex flex-col items-center justify-center gap-3 pb-4"> <RiSearchEyeLine className="size-10 text-text-empty-state-icon" /> <p className="text-sm text-text-tertiary"> - {t($ => $['stepTwo.previewChunkTip'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.previewChunkTip'], { ns: 'datasetCreation' })} </p> <Button onClick={onPreview}> - {t($ => $['addDocuments.stepTwo.previewChunks'], { ns: 'datasetPipeline' })} + {t(($) => $['addDocuments.stepTwo.previewChunks'], { ns: 'datasetPipeline' })} </Button> </div> </div> diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.tsx index 0613d5c59a5173..03bfb4e8c23f3b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.tsx @@ -14,16 +14,12 @@ type FilePreviewProps = { hidePreview: () => void } -const FilePreview = ({ - file, - hidePreview, -}: FilePreviewProps) => { +const FilePreview = ({ file, hidePreview }: FilePreviewProps) => { const { t } = useTranslation() const { data: fileData, isFetching } = useFilePreview(file.id || '') const fileName = useMemo(() => { - if (!file) - return '' + if (!file) return '' const arr = file.name.split('.') return arr.slice(0, -1).join() }, [file]) @@ -32,7 +28,9 @@ const FilePreview = ({ <div className="flex size-full flex-col rounded-t-xl border-t border-l border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5"> <div className="flex gap-x-2 border-b border-divider-subtle pt-4 pr-4 pb-3 pl-6"> <div className="flex grow flex-col gap-y-1"> - <div className="system-2xs-semibold-uppercase text-text-accent">{t($ => $['addDocuments.stepOne.preview'], { ns: 'datasetPipeline' })}</div> + <div className="system-2xs-semibold-uppercase text-text-accent"> + {t(($) => $['addDocuments.stepOne.preview'], { ns: 'datasetPipeline' })} + </div> <div className="text-tex-primary title-md-semi-bold">{`${fileName}.${file.extension || ''}`}</div> <div className="flex items-center gap-x-1 system-xs-medium text-text-tertiary"> <DocumentFileIcon @@ -46,7 +44,7 @@ const FilePreview = ({ {fileData && ( <> <span>·</span> - <span>{`${formatNumberAbbreviated(fileData.content.length)} ${t($ => $['addDocuments.characters'], { ns: 'datasetPipeline' })}`}</span> + <span>{`${formatNumberAbbreviated(fileData.content.length)} ${t(($) => $['addDocuments.characters'], { ns: 'datasetPipeline' })}`}</span> </> )} </div> diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx index 29e72bb9018dc7..1538dac7a3b1d2 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx @@ -26,40 +26,45 @@ const OnlineDocumentPreview = ({ }: OnlineDocumentPreviewProps) => { const { t } = useTranslation() const [content, setContent] = useState('') - const pipelineId = useDatasetDetailContextWithSelector(state => state.dataset?.pipeline_id) + const pipelineId = useDatasetDetailContextWithSelector((state) => state.dataset?.pipeline_id) const { mutateAsync: getOnlineDocumentContent, isPending } = usePreviewOnlineDocument() const dataSourceStore = useDataSourceStore() useEffect(() => { const { currentCredentialId } = dataSourceStore.getState() - getOnlineDocumentContent({ - workspaceID: currentPage.workspace_id, - pageID: currentPage.page_id, - pageType: currentPage.type, - pipelineId: pipelineId || '', - datasourceNodeId, - credentialId: currentCredentialId, - }, { - onSuccess(data) { - setContent(data.content) + getOnlineDocumentContent( + { + workspaceID: currentPage.workspace_id, + pageID: currentPage.page_id, + pageType: currentPage.type, + pipelineId: pipelineId || '', + datasourceNodeId, + credentialId: currentCredentialId, }, - onError(error) { - toast.error(error.message) + { + onSuccess(data) { + setContent(data.content) + }, + onError(error) { + toast.error(error.message) + }, }, - }) + ) }, [currentPage.page_id]) return ( <div className="flex size-full flex-col rounded-t-xl border-t border-l border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5"> <div className="flex gap-x-2 border-b border-divider-subtle pt-4 pr-4 pb-3 pl-6"> <div className="flex grow flex-col gap-y-1"> - <div className="system-2xs-semibold-uppercase text-text-accent">{t($ => $['addDocuments.stepOne.preview'], { ns: 'datasetPipeline' })}</div> + <div className="system-2xs-semibold-uppercase text-text-accent"> + {t(($) => $['addDocuments.stepOne.preview'], { ns: 'datasetPipeline' })} + </div> <div className="text-tex-primary title-md-semi-bold">{currentPage?.page_name}</div> <div className="flex items-center gap-x-1 system-xs-medium text-text-tertiary"> <Notion className="size-3.5" /> <span>{currentPage.type}</span> <span>·</span> - <span>{`${formatNumberAbbreviated(content.length)} ${t($ => $['addDocuments.characters'], { ns: 'datasetPipeline' })}`}</span> + <span>{`${formatNumberAbbreviated(content.length)} ${t(($) => $['addDocuments.characters'], { ns: 'datasetPipeline' })}`}</span> </div> </div> <button diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.tsx index 81256831e8f9cc..859fd401c94584 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.tsx @@ -10,24 +10,25 @@ type WebsitePreviewProps = { hidePreview: () => void } -const WebsitePreview = ({ - currentWebsite, - hidePreview, -}: WebsitePreviewProps) => { +const WebsitePreview = ({ currentWebsite, hidePreview }: WebsitePreviewProps) => { const { t } = useTranslation() return ( <div className="flex size-full flex-col rounded-t-xl border-t border-l border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5"> <div className="flex gap-x-2 border-b border-divider-subtle pt-4 pr-4 pb-3 pl-6"> <div className="flex grow flex-col gap-y-1"> - <div className="system-2xs-semibold-uppercase">{t($ => $['addDocuments.stepOne.preview'], { ns: 'datasetPipeline' })}</div> + <div className="system-2xs-semibold-uppercase"> + {t(($) => $['addDocuments.stepOne.preview'], { ns: 'datasetPipeline' })} + </div> <div className="text-tex-primary title-md-semi-bold">{currentWebsite.title}</div> <div className="flex gap-x-1 system-xs-medium text-text-tertiary"> <RiGlobalLine className="size-3.5" /> - <span className="uppercase" title={currentWebsite.source_url}>{currentWebsite.source_url}</span> + <span className="uppercase" title={currentWebsite.source_url}> + {currentWebsite.source_url} + </span> <span>·</span> <span>·</span> - <span>{`${formatNumberAbbreviated(currentWebsite.markdown.length)} ${t($ => $['addDocuments.characters'], { ns: 'datasetPipeline' })}`}</span> + <span>{`${formatNumberAbbreviated(currentWebsite.markdown.length)} ${t(($) => $['addDocuments.characters'], { ns: 'datasetPipeline' })}`}</span> </div> </div> <button diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/actions.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/actions.spec.tsx index a4c5ec4938ab75..f13519ed477780 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/actions.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/actions.spec.tsx @@ -48,21 +48,27 @@ describe('Actions', () => { it('should disable process button when runDisabled is true', () => { render(<Actions {...defaultProps} runDisabled />) - const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button') + const processButton = screen + .getByText('datasetPipeline.operations.saveAndProcess') + .closest('button') expect(processButton).toBeDisabled() }) it('should enable process button when runDisabled is false', () => { render(<Actions {...defaultProps} runDisabled={false} />) - const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button') + const processButton = screen + .getByText('datasetPipeline.operations.saveAndProcess') + .closest('button') expect(processButton).not.toBeDisabled() }) it('should enable process button when runDisabled is undefined', () => { render(<Actions {...defaultProps} />) - const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button') + const processButton = screen + .getByText('datasetPipeline.operations.saveAndProcess') + .closest('button') expect(processButton).not.toBeDisabled() }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx index 16b6ef13731a7e..216bc1b0b08348 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx @@ -27,7 +27,9 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { /** * Creates mock configuration for testing */ -const createMockConfiguration = (overrides: Partial<BaseConfiguration> = {}): BaseConfiguration => ({ +const createMockConfiguration = ( + overrides: Partial<BaseConfiguration> = {}, +): BaseConfiguration => ({ type: BaseFieldType.textInput, variable: 'testVariable', label: 'Test Label', @@ -85,7 +87,9 @@ describe('Actions', () => { it('should render back button with arrow icon', () => { render(<Actions {...defaultActionsProps} />) - const backButton = screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i }) + const backButton = screen.getByRole('button', { + name: /datasetPipeline.operations.dataSource/i, + }) expect(backButton).toBeInTheDocument() expect(backButton.querySelector('svg')).toBeInTheDocument() }) @@ -93,7 +97,9 @@ describe('Actions', () => { it('should render process button', () => { render(<Actions {...defaultActionsProps} />) - const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + const processButton = screen.getByRole('button', { + name: /datasetPipeline.operations.saveAndProcess/i, + }) expect(processButton).toBeInTheDocument() }) @@ -110,21 +116,27 @@ describe('Actions', () => { it('should not disable process button when runDisabled is false', () => { render(<Actions {...defaultActionsProps} runDisabled={false} />) - const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + const processButton = screen.getByRole('button', { + name: /datasetPipeline.operations.saveAndProcess/i, + }) expect(processButton).not.toBeDisabled() }) it('should disable process button when runDisabled is true', () => { render(<Actions {...defaultActionsProps} runDisabled={true} />) - const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + const processButton = screen.getByRole('button', { + name: /datasetPipeline.operations.saveAndProcess/i, + }) expect(processButton).toBeDisabled() }) it('should not disable process button when runDisabled is undefined', () => { render(<Actions {...defaultActionsProps} runDisabled={undefined} />) - const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + const processButton = screen.getByRole('button', { + name: /datasetPipeline.operations.saveAndProcess/i, + }) expect(processButton).not.toBeDisabled() }) }) @@ -136,7 +148,9 @@ describe('Actions', () => { const onBack = vi.fn() render(<Actions {...defaultActionsProps} onBack={onBack} />) - fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i })) + fireEvent.click( + screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i }), + ) expect(onBack).toHaveBeenCalledTimes(1) }) @@ -145,7 +159,9 @@ describe('Actions', () => { const onProcess = vi.fn() render(<Actions {...defaultActionsProps} onProcess={onProcess} />) - fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) + fireEvent.click( + screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }), + ) expect(onProcess).toHaveBeenCalledTimes(1) }) @@ -154,7 +170,9 @@ describe('Actions', () => { const onProcess = vi.fn() render(<Actions {...defaultActionsProps} onProcess={onProcess} runDisabled={true} />) - fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) + fireEvent.click( + screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }), + ) expect(onProcess).not.toHaveBeenCalled() }) @@ -184,7 +202,9 @@ describe('Header', () => { it('should render without crashing', () => { render(<Header {...defaultHeaderProps} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() }) it('should render reset button', () => { @@ -196,7 +216,9 @@ describe('Header', () => { it('should render preview button with icon', () => { render(<Header {...defaultHeaderProps} />) - const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + const previewButton = screen.getByRole('button', { + name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i, + }) expect(previewButton).toBeInTheDocument() expect(previewButton.querySelector('svg')).toBeInTheDocument() }) @@ -204,7 +226,9 @@ describe('Header', () => { it('should render title with correct text', () => { render(<Header {...defaultHeaderProps} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() }) it('should have correct container layout', () => { @@ -236,14 +260,18 @@ describe('Header', () => { it('should not disable preview button when previewDisabled is false', () => { render(<Header {...defaultHeaderProps} previewDisabled={false} />) - const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + const previewButton = screen.getByRole('button', { + name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i, + }) expect(previewButton).not.toBeDisabled() }) it('should disable preview button when previewDisabled is true', () => { render(<Header {...defaultHeaderProps} previewDisabled={true} />) - const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + const previewButton = screen.getByRole('button', { + name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i, + }) expect(previewButton).toBeDisabled() }) }) @@ -251,13 +279,14 @@ describe('Header', () => { it('should handle onPreview being undefined', () => { render(<Header {...defaultHeaderProps} onPreview={undefined} />) - const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + const previewButton = screen.getByRole('button', { + name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i, + }) expect(previewButton).toBeInTheDocument() let didThrow = false try { fireEvent.click(previewButton) - } - catch { + } catch { didThrow = true } expect(didThrow).toBe(false) @@ -288,7 +317,9 @@ describe('Header', () => { const onPreview = vi.fn() render(<Header {...defaultHeaderProps} onPreview={onPreview} />) - fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) + fireEvent.click( + screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }), + ) expect(onPreview).toHaveBeenCalledTimes(1) }) @@ -297,7 +328,9 @@ describe('Header', () => { const onPreview = vi.fn() render(<Header {...defaultHeaderProps} onPreview={onPreview} previewDisabled={true} />) - fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) + fireEvent.click( + screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }), + ) expect(onPreview).not.toHaveBeenCalled() }) @@ -316,7 +349,9 @@ describe('Header', () => { render(<Header {...defaultHeaderProps} resetDisabled={true} previewDisabled={true} />) const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) - const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + const previewButton = screen.getByRole('button', { + name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i, + }) expect(resetButton).toBeDisabled() expect(previewButton).toBeDisabled() }) @@ -325,7 +360,9 @@ describe('Header', () => { render(<Header {...defaultHeaderProps} resetDisabled={false} previewDisabled={false} />) const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) - const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + const previewButton = screen.getByRole('button', { + name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i, + }) expect(resetButton).not.toBeDisabled() expect(previewButton).not.toBeDisabled() }) @@ -353,7 +390,9 @@ describe('Form', () => { it('should render without crashing', () => { render(<Form {...defaultFormProps} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() }) it('should render form element', () => { @@ -366,9 +405,13 @@ describe('Form', () => { it('should render Header component', () => { render(<Form {...defaultFormProps} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() expect(screen.getByRole('button', { name: /common.operation.reset/i })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }), + ).toBeInTheDocument() }) it('should have correct form structure', () => { @@ -384,14 +427,18 @@ describe('Form', () => { it('should disable preview button when isRunning is true', () => { render(<Form {...defaultFormProps} isRunning={true} />) - const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + const previewButton = screen.getByRole('button', { + name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i, + }) expect(previewButton).toBeDisabled() }) it('should not disable preview button when isRunning is false', () => { render(<Form {...defaultFormProps} isRunning={false} />) - const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + const previewButton = screen.getByRole('button', { + name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i, + }) expect(previewButton).not.toBeDisabled() }) }) @@ -412,7 +459,13 @@ describe('Form', () => { createMockConfiguration({ variable: 'var3', label: 'Variable 3' }), ] - render(<Form {...defaultFormProps} configurations={configurations} initialData={{ var1: '', var2: '', var3: '' }} />) + render( + <Form + {...defaultFormProps} + configurations={configurations} + initialData={{ var1: '', var2: '', var3: '' }} + />, + ) expect(screen.getByText('Variable 1')).toBeInTheDocument() expect(screen.getByText('Variable 2')).toBeInTheDocument() @@ -466,7 +519,9 @@ describe('Form', () => { const onPreview = vi.fn() render(<Form {...defaultFormProps} onPreview={onPreview} />) - fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) + fireEvent.click( + screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }), + ) expect(onPreview).toHaveBeenCalledTimes(1) }) @@ -494,9 +549,7 @@ describe('Form', () => { }) it('should enable reset button when form becomes dirty', async () => { - const configurations = [ - createMockConfiguration({ variable: 'field1', label: 'Field 1' }), - ] + const configurations = [createMockConfiguration({ variable: 'field1', label: 'Field 1' })] render(<Form {...defaultFormProps} configurations={configurations} />) @@ -511,12 +564,12 @@ describe('Form', () => { }) it('should reset form to initial values when reset button is clicked', async () => { - const configurations = [ - createMockConfiguration({ variable: 'field1', label: 'Field 1' }), - ] + const configurations = [createMockConfiguration({ variable: 'field1', label: 'Field 1' })] const initialData = { field1: 'initial value' } - render(<Form {...defaultFormProps} configurations={configurations} initialData={initialData} />) + render( + <Form {...defaultFormProps} configurations={configurations} initialData={initialData} />, + ) // Act - change input to make form dirty const input = screen.getByRole('textbox') @@ -538,12 +591,12 @@ describe('Form', () => { }) it('should call form.reset when handleReset is triggered', async () => { - const configurations = [ - createMockConfiguration({ variable: 'field1', label: 'Field 1' }), - ] + const configurations = [createMockConfiguration({ variable: 'field1', label: 'Field 1' })] const initialData = { field1: 'original' } - render(<Form {...defaultFormProps} configurations={configurations} initialData={initialData} />) + render( + <Form {...defaultFormProps} configurations={configurations} initialData={initialData} />, + ) // Make form dirty const input = screen.getByRole('textbox') @@ -581,7 +634,9 @@ describe('Form', () => { it('should not call onSubmit when validation fails', async () => { const onSubmit = vi.fn() const failingSchema = createFailingSchema() - const { container } = render(<Form {...defaultFormProps} schema={failingSchema} onSubmit={onSubmit} />) + const { container } = render( + <Form {...defaultFormProps} schema={failingSchema} onSubmit={onSubmit} />, + ) const form = container.querySelector('form')! fireEvent.submit(form) @@ -596,7 +651,9 @@ describe('Form', () => { it('should call onSubmit when validation passes', async () => { const onSubmit = vi.fn() const passingSchema = createMockSchema() - const { container } = render(<Form {...defaultFormProps} schema={passingSchema} onSubmit={onSubmit} />) + const { container } = render( + <Form {...defaultFormProps} schema={passingSchema} onSubmit={onSubmit} />, + ) const form = container.querySelector('form')! fireEvent.submit(form) @@ -612,16 +669,32 @@ describe('Form', () => { it('should handle empty initialData', () => { render(<Form {...defaultFormProps} initialData={{}} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() }) it('should handle configurations with different field types', () => { const configurations = [ - createMockConfiguration({ type: BaseFieldType.textInput, variable: 'text', label: 'Text Field' }), - createMockConfiguration({ type: BaseFieldType.numberInput, variable: 'number', label: 'Number Field' }), + createMockConfiguration({ + type: BaseFieldType.textInput, + variable: 'text', + label: 'Text Field', + }), + createMockConfiguration({ + type: BaseFieldType.numberInput, + variable: 'number', + label: 'Number Field', + }), ] - render(<Form {...defaultFormProps} configurations={configurations} initialData={{ text: '', number: 0 }} />) + render( + <Form + {...defaultFormProps} + configurations={configurations} + initialData={{ text: '', number: 0 }} + />, + ) expect(screen.getByText('Text Field')).toBeInTheDocument() expect(screen.getByText('Number Field')).toBeInTheDocument() @@ -630,7 +703,9 @@ describe('Form', () => { it('should handle null ref', () => { render(<Form {...defaultFormProps} ref={{ current: null }} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() }) }) @@ -678,14 +753,18 @@ describe('Process Documents Components Integration', () => { it('should render Header within Form', () => { render(<Form {...defaultFormProps} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() expect(screen.getByRole('button', { name: /common.operation.reset/i })).toBeInTheDocument() }) it('should pass isRunning to Header for previewDisabled', () => { render(<Form {...defaultFormProps} isRunning={true} />) - const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + const previewButton = screen.getByRole('button', { + name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i, + }) expect(previewButton).toBeDisabled() }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx index dc54ba2757ef53..77f696eebb299c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx @@ -21,15 +21,24 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { // Mock the Header component (sibling component, not a base component) vi.mock('../header', () => ({ - default: ({ onReset, resetDisabled, onPreview, previewDisabled }: { + default: ({ + onReset, + resetDisabled, + onPreview, + previewDisabled, + }: { onReset: () => void resetDisabled: boolean onPreview: () => void previewDisabled: boolean }) => ( <div data-testid="form-header"> - <button data-testid="reset-btn" onClick={onReset} disabled={resetDisabled}>Reset</button> - <button data-testid="preview-btn" onClick={onPreview} disabled={previewDisabled}>Preview</button> + <button data-testid="reset-btn" onClick={onReset} disabled={resetDisabled}> + Reset + </button> + <button data-testid="preview-btn" onClick={onPreview} disabled={previewDisabled}> + Preview + </button> </div> ), })) @@ -40,8 +49,20 @@ const schema = z.object({ }) const defaultConfigs: BaseConfiguration[] = [ - { variable: 'name', type: 'text-input', label: 'Name', required: true, showConditions: [] } as BaseConfiguration, - { variable: 'value', type: 'text-input', label: 'Value', required: false, showConditions: [] } as BaseConfiguration, + { + variable: 'name', + type: 'text-input', + label: 'Name', + required: true, + showConditions: [], + } as BaseConfiguration, + { + variable: 'value', + type: 'text-input', + label: 'Value', + required: false, + showConditions: [], + } as BaseConfiguration, ] const defaultProps = { @@ -72,12 +93,32 @@ describe('Form (process-documents)', () => { it('should render all configuration fields', () => { const configs: BaseConfiguration[] = [ - { variable: 'a', type: 'text-input', label: 'A', required: false, showConditions: [] } as BaseConfiguration, - { variable: 'b', type: 'text-input', label: 'B', required: false, showConditions: [] } as BaseConfiguration, - { variable: 'c', type: 'text-input', label: 'C', required: false, showConditions: [] } as BaseConfiguration, + { + variable: 'a', + type: 'text-input', + label: 'A', + required: false, + showConditions: [], + } as BaseConfiguration, + { + variable: 'b', + type: 'text-input', + label: 'B', + required: false, + showConditions: [], + } as BaseConfiguration, + { + variable: 'c', + type: 'text-input', + label: 'C', + required: false, + showConditions: [], + } as BaseConfiguration, ] - render(<Form {...defaultProps} configurations={configs} initialData={{ a: '', b: '', c: '' }} />) + render( + <Form {...defaultProps} configurations={configs} initialData={{ a: '', b: '', c: '' }} />, + ) expect(screen.getByText('A')).toBeInTheDocument() expect(screen.getByText('B')).toBeInTheDocument() diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx index 431fa76f2c088d..62a7ca53263d67 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx @@ -3,7 +3,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import Header from '../header' vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick, disabled, variant }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, variant: string }) => ( + Button: ({ + children, + onClick, + disabled, + variant, + }: { + children: React.ReactNode + onClick: () => void + disabled?: boolean + variant: string + }) => ( <button data-testid={`btn-${variant}`} onClick={onClick} disabled={disabled}> {children} </button> @@ -24,7 +34,9 @@ describe('Header', () => { it('should render chunk settings title', () => { render(<Header {...defaultProps} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() }) it('should render reset and preview buttons', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts index 440c9781966829..2464536997074b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts @@ -7,10 +7,12 @@ const mockUseDatasetDetailContextWithSelector = vi.fn() const mockUsePublishedPipelineProcessingParams = vi.fn() vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (value: unknown) => unknown) => mockUseDatasetDetailContextWithSelector(selector), + useDatasetDetailContextWithSelector: (selector: (value: unknown) => unknown) => + mockUseDatasetDetailContextWithSelector(selector), })) vi.mock('@/service/use-pipeline', () => ({ - usePublishedPipelineProcessingParams: (params: PipelineProcessingParamsRequest) => mockUsePublishedPipelineProcessingParams(params), + usePublishedPipelineProcessingParams: (params: PipelineProcessingParamsRequest) => + mockUsePublishedPipelineProcessingParams(params), })) describe('useInputVariables', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/index.spec.tsx index 6fe69571348895..6635917cc06660 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/index.spec.tsx @@ -2,7 +2,10 @@ import type { BaseConfiguration } from '@/app/components/base/form/form-scenario import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' -import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields' +import { + useConfigurations, + useInitialData, +} from '@/app/components/rag-pipeline/hooks/use-input-fields' import { useInputVariables } from '../hooks' import ProcessDocuments from '../index' @@ -31,7 +34,9 @@ vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ /** * Creates mock configuration for testing */ -const createMockConfiguration = (overrides: Partial<BaseConfiguration> = {}): BaseConfiguration => ({ +const createMockConfiguration = ( + overrides: Partial<BaseConfiguration> = {}, +): BaseConfiguration => ({ type: BaseFieldType.textInput, variable: 'testVariable', label: 'Test Label', @@ -47,7 +52,9 @@ const createMockConfiguration = (overrides: Partial<BaseConfiguration> = {}): Ba /** * Creates default test props */ -const createDefaultProps = (overrides: Partial<React.ComponentProps<typeof ProcessDocuments>> = {}) => ({ +const createDefaultProps = ( + overrides: Partial<React.ComponentProps<typeof ProcessDocuments>> = {}, +) => ({ dataSourceNodeId: 'test-node-id', ref: { current: null } as React.RefObject<unknown>, isRunning: false, @@ -76,7 +83,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) // Assert - check for Header title from Form component - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() }) it('should render Form and Actions components', () => { @@ -85,7 +94,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) // Assert - check for elements from both components - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument() expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument() }) @@ -115,7 +126,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() }) }) @@ -125,7 +138,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + const previewButton = screen.getByRole('button', { + name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i, + }) expect(previewButton).toBeDisabled() }) @@ -134,7 +149,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + const previewButton = screen.getByRole('button', { + name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i, + }) expect(previewButton).not.toBeDisabled() }) @@ -144,7 +161,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + const processButton = screen.getByRole('button', { + name: /datasetPipeline.operations.saveAndProcess/i, + }) expect(processButton).toBeDisabled() }) }) @@ -170,7 +189,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) + fireEvent.click( + screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }), + ) expect(onProcess).toHaveBeenCalledTimes(1) }) @@ -181,7 +202,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i })) + fireEvent.click( + screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i }), + ) expect(onBack).toHaveBeenCalledTimes(1) }) @@ -192,7 +215,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) + fireEvent.click( + screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }), + ) expect(onPreview).toHaveBeenCalledTimes(1) }) @@ -262,7 +287,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + const processButton = screen.getByRole('button', { + name: /datasetPipeline.operations.saveAndProcess/i, + }) expect(processButton).toBeDisabled() }) @@ -272,7 +299,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + const processButton = screen.getByRole('button', { + name: /datasetPipeline.operations.saveAndProcess/i, + }) expect(processButton).toBeDisabled() }) @@ -282,7 +311,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + const processButton = screen.getByRole('button', { + name: /datasetPipeline.operations.saveAndProcess/i, + }) expect(processButton).not.toBeDisabled() }) @@ -292,7 +323,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + const processButton = screen.getByRole('button', { + name: /datasetPipeline.operations.saveAndProcess/i, + }) expect(processButton).toBeDisabled() }) }) @@ -310,7 +343,9 @@ describe('ProcessDocuments', () => { const { rerender } = render(<ProcessDocuments {...props} />) rerender(<ProcessDocuments {...props} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() }) it('should update when dataSourceNodeId prop changes', () => { @@ -333,7 +368,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() }) it('should handle empty variables array', () => { @@ -343,7 +380,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() }) it('should handle special characters in dataSourceNodeId', () => { @@ -371,9 +410,15 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) - fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i })) - fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) + fireEvent.click( + screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }), + ) + fireEvent.click( + screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i }), + ) + fireEvent.click( + screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }), + ) expect(onProcess).toHaveBeenCalledTimes(1) expect(onBack).toHaveBeenCalledTimes(1) @@ -398,11 +443,11 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) - if (expectedDisabled) - expect(processButton).toBeDisabled() - else - expect(processButton).not.toBeDisabled() + const processButton = screen.getByRole('button', { + name: /datasetPipeline.operations.saveAndProcess/i, + }) + if (expectedDisabled) expect(processButton).toBeDisabled() + else expect(processButton).not.toBeDisabled() }, ) }) @@ -425,8 +470,16 @@ describe('ProcessDocuments', () => { it('should handle configurations with different field types', () => { mockConfigurations = [ - createMockConfiguration({ type: BaseFieldType.textInput, variable: 'textVar', label: 'Text Field' }), - createMockConfiguration({ type: BaseFieldType.numberInput, variable: 'numberVar', label: 'Number Field' }), + createMockConfiguration({ + type: BaseFieldType.textInput, + variable: 'textVar', + label: 'Text Field', + }), + createMockConfiguration({ + type: BaseFieldType.numberInput, + variable: 'numberVar', + label: 'Number Field', + }), ] mockInitialData = { textVar: '', numberVar: 0 } const props = createDefaultProps() @@ -445,7 +498,9 @@ describe('ProcessDocuments', () => { mockIsFetchingParams = false mockParamsConfig = { variables: [{ variable: 'testVar', type: 'text', label: 'Test' }] } mockInitialData = { testVar: 'initial value' } - mockConfigurations = [createMockConfiguration({ variable: 'testVar', label: 'Test Variable' })] + mockConfigurations = [ + createMockConfiguration({ variable: 'testVar', label: 'Test Variable' }), + ] const props = { dataSourceNodeId: 'full-test-node', @@ -459,7 +514,9 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings'), + ).toBeInTheDocument() expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument() expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument() expect(screen.getByText('Test Variable')).toBeInTheDocument() diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/actions.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/actions.tsx index 5b390f3c805aba..c4ac2cfd8b0629 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/actions.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/actions.tsx @@ -9,29 +9,19 @@ type ActionsProps = { onProcess: () => void } -const Actions = ({ - onBack, - runDisabled, - onProcess, -}: ActionsProps) => { +const Actions = ({ onBack, runDisabled, onProcess }: ActionsProps) => { const { t } = useTranslation() return ( <div className="flex items-center justify-between"> - <Button - variant="secondary" - onClick={onBack} - className="gap-x-0.5" - > + <Button variant="secondary" onClick={onBack} className="gap-x-0.5"> <RiArrowLeftLine className="size-4" /> - <span className="px-0.5">{t($ => $['operations.dataSource'], { ns: 'datasetPipeline' })}</span> + <span className="px-0.5"> + {t(($) => $['operations.dataSource'], { ns: 'datasetPipeline' })} + </span> </Button> - <Button - variant="primary" - disabled={runDisabled} - onClick={onProcess} - > - {t($ => $['operations.saveAndProcess'], { ns: 'datasetPipeline' })} + <Button variant="primary" disabled={runDisabled} onClick={onProcess}> + {t(($) => $['operations.saveAndProcess'], { ns: 'datasetPipeline' })} </Button> </div> ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/form.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/form.tsx index 80cd737e7e6651..4a29e4fd7cb8c7 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/form.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/form.tsx @@ -67,8 +67,8 @@ const Form = ({ }} > <form.Subscribe - selector={state => state.isDirty} - children={isDirty => ( + selector={(state) => state.isDirty} + children={(isDirty) => ( <Header onReset={handleReset} resetDisabled={!isDirty} diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/header.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/header.tsx index 6c1329e8e8f43e..5acb952ffc8738 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/header.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/header.tsx @@ -10,21 +10,16 @@ type HeaderProps = { onPreview?: () => void } -const Header = ({ - onReset, - resetDisabled, - previewDisabled, - onPreview, -}: HeaderProps) => { +const Header = ({ onReset, resetDisabled, previewDisabled, onPreview }: HeaderProps) => { const { t } = useTranslation() return ( <div className="flex items-center gap-x-1 px-4 py-2"> <div className="grow system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['addDocuments.stepTwo.chunkSettings'], { ns: 'datasetPipeline' })} + {t(($) => $['addDocuments.stepTwo.chunkSettings'], { ns: 'datasetPipeline' })} </div> <Button variant="ghost" disabled={resetDisabled} onClick={onReset}> - {t($ => $['operation.reset'], { ns: 'common' })} + {t(($) => $['operation.reset'], { ns: 'common' })} </Button> <Button variant="secondary-accent" @@ -33,7 +28,9 @@ const Header = ({ disabled={previewDisabled} > <RiSearchEyeLine className="size-4" /> - <span className="px-0.5">{t($ => $['addDocuments.stepTwo.previewChunks'], { ns: 'datasetPipeline' })}</span> + <span className="px-0.5"> + {t(($) => $['addDocuments.stepTwo.previewChunks'], { ns: 'datasetPipeline' })} + </span> </Button> </div> ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/hooks.ts b/web/app/components/datasets/documents/create-from-pipeline/process-documents/hooks.ts index bb4e872ff0c18e..dd5655d062b7e8 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/hooks.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/hooks.ts @@ -2,11 +2,13 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { usePublishedPipelineProcessingParams } from '@/service/use-pipeline' export const useInputVariables = (datasourceNodeId: string) => { - const pipelineId = useDatasetDetailContextWithSelector(state => state.dataset?.pipeline_id) - const { data: paramsConfig, isFetching: isFetchingParams } = usePublishedPipelineProcessingParams({ - pipeline_id: pipelineId!, - node_id: datasourceNodeId, - }) + const pipelineId = useDatasetDetailContextWithSelector((state) => state.dataset?.pipeline_id) + const { data: paramsConfig, isFetching: isFetchingParams } = usePublishedPipelineProcessingParams( + { + pipeline_id: pipelineId!, + node_id: datasourceNodeId, + }, + ) return { paramsConfig, diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/index.tsx index 770c6c820be655..82e59e9155b4d4 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/index.tsx @@ -1,6 +1,9 @@ import * as React from 'react' import { generateZodSchema } from '@/app/components/base/form/form-scenarios/base/utils' -import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields' +import { + useConfigurations, + useInitialData, +} from '@/app/components/rag-pipeline/hooks/use-input-fields' import Actions from './actions' import Form from './form' import { useInputVariables } from './hooks' diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/__tests__/index.spec.tsx index 22459a963dbebb..3a29eb79c6e8d9 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/__tests__/index.spec.tsx @@ -9,20 +9,24 @@ import Processing from '../index' // Strips leading slash from path to match actual implementation behavior vi.mock('@/context/i18n', () => ({ useDocLink: () => (path?: string) => { - const normalizedPath = path?.startsWith('/') ? path.slice(1) : (path || '') + const normalizedPath = path?.startsWith('/') ? path.slice(1) : path || '' return `https://docs.dify.ai/en-US/${normalizedPath}` }, })) // Mock dataset detail context -let mockDataset: { - id?: string - indexing_technique?: string - retrieval_model_dict?: { search_method?: string } -} | undefined +let mockDataset: + | { + id?: string + indexing_technique?: string + retrieval_model_dict?: { search_method?: string } + } + | undefined vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: <T,>(selector: (state: { dataset?: typeof mockDataset }) => T): T => { + useDatasetDetailContextWithSelector: <T,>( + selector: (state: { dataset?: typeof mockDataset }) => T, + ): T => { return selector({ dataset: mockDataset }) }, })) @@ -37,8 +41,10 @@ vi.mock('../embedding-process', () => ({ <span data-testid="ep-dataset-id">{props.datasetId as string}</span> <span data-testid="ep-batch-id">{props.batchId as string}</span> <span data-testid="ep-documents-count">{(props.documents as unknown[])?.length ?? 0}</span> - <span data-testid="ep-indexing-type">{props.indexingType as string || 'undefined'}</span> - <span data-testid="ep-retrieval-method">{props.retrievalMethod as string || 'undefined'}</span> + <span data-testid="ep-indexing-type">{(props.indexingType as string) || 'undefined'}</span> + <span data-testid="ep-retrieval-method"> + {(props.retrievalMethod as string) || 'undefined'} + </span> </div> ) }, @@ -51,7 +57,9 @@ vi.mock('../embedding-process', () => ({ * Uses deterministic counter-based IDs to avoid flaky tests */ let documentIdCounter = 0 -const createMockDocument = (overrides: Partial<InitialDocumentDetail> = {}): InitialDocumentDetail => ({ +const createMockDocument = ( + overrides: Partial<InitialDocumentDetail> = {}, +): InitialDocumentDetail => ({ id: overrides.id ?? `doc-${++documentIdCounter}`, name: 'test-document.txt', data_source_type: DatasourceType.localFile, @@ -72,7 +80,8 @@ const createMockDocuments = (count: number): InitialDocumentDetail[] => id: `doc-${index + 1}`, name: `document-${index + 1}.txt`, position: index, - })) + }), + ) describe('Processing', () => { beforeEach(() => { @@ -123,7 +132,9 @@ describe('Processing', () => { // Assert - verify translation keys are rendered expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepThree.sideTipContent')).toBeInTheDocument() - expect(screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore'), + ).toBeInTheDocument() }) it('should render the documentation link with correct attributes', () => { @@ -134,8 +145,13 @@ describe('Processing', () => { render(<Processing {...props} />) - const link = screen.getByRole('link', { name: 'datasetPipeline.addDocuments.stepThree.learnMore' }) - expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/use-dify/knowledge/knowledge-pipeline/authorize-data-source') + const link = screen.getByRole('link', { + name: 'datasetPipeline.addDocuments.stepThree.learnMore', + }) + expect(link).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en-US/use-dify/knowledge/knowledge-pipeline/authorize-data-source', + ) expect(link).toHaveAttribute('target', '_blank') expect(link).toHaveAttribute('rel', 'noreferrer noopener') }) @@ -633,7 +649,12 @@ describe('Processing', () => { // Retrieval Method Variations describe('Retrieval Method Variations', () => { // Tests for different retrieval methods - const retrievalMethods = ['semantic_search', 'keyword_search', 'hybrid_search', 'full_text_search'] + const retrievalMethods = [ + 'semantic_search', + 'keyword_search', + 'hybrid_search', + 'full_text_search', + ] it.each(retrievalMethods)('should handle %s retrieval method', (method) => { mockDataset = { diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx index f59f5c091ba2d8..1189a227e69bf6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx @@ -18,8 +18,19 @@ vi.mock('@/next/navigation', () => ({ // Mock next/link vi.mock('@/next/link', () => ({ - default: function MockLink({ children, href, ...props }: { children: React.ReactNode, href: string }) { - return <a href={href} {...props}>{children}</a> + default: function MockLink({ + children, + href, + ...props + }: { + children: React.ReactNode + href: string + }) { + return ( + <a href={href} {...props}> + {children} + </a> + ) }, })) @@ -66,7 +77,9 @@ vi.mock('@/hooks/use-api-access-url', () => ({ * Uses deterministic counter-based IDs to avoid flaky tests */ let documentIdCounter = 0 -const createMockDocument = (overrides: Partial<InitialDocumentDetail> = {}): InitialDocumentDetail => ({ +const createMockDocument = ( + overrides: Partial<InitialDocumentDetail> = {}, +): InitialDocumentDetail => ({ id: overrides.id ?? `doc-${++documentIdCounter}`, name: 'test-document.txt', data_source_type: DatasourceType.localFile, @@ -81,7 +94,9 @@ const createMockDocument = (overrides: Partial<InitialDocumentDetail> = {}): Ini /** * Creates a mock IndexingStatusResponse for testing */ -const createMockIndexingStatus = (overrides: Partial<IndexingStatusResponse> = {}): IndexingStatusResponse => ({ +const createMockIndexingStatus = ( + overrides: Partial<IndexingStatusResponse> = {}, +): IndexingStatusResponse => ({ id: `doc-${Math.random().toString(36).slice(2, 9)}`, indexing_status: 'waiting' as DocumentIndexingStatus, processing_started_at: Date.now(), @@ -100,13 +115,15 @@ const createMockIndexingStatus = (overrides: Partial<IndexingStatusResponse> = { /** * Creates default props for EmbeddingProcess component */ -const createDefaultProps = (overrides: Partial<{ - datasetId: string - batchId: string - documents: InitialDocumentDetail[] - indexingType: IndexingType - retrievalMethod: RETRIEVE_METHOD -}> = {}) => ({ +const createDefaultProps = ( + overrides: Partial<{ + datasetId: string + batchId: string + documents: InitialDocumentDetail[] + indexingType: IndexingType + retrievalMethod: RETRIEVE_METHOD + }> = {}, +) => ({ datasetId: 'dataset-123', batchId: 'batch-456', documents: [createMockDocument({ id: 'doc-1', name: 'test-doc.pdf' })], @@ -192,7 +209,9 @@ describe('EmbeddingProcess', () => { render(<EmbeddingProcess {...props} />) - expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade')).not.toBeInTheDocument() + expect( + screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade'), + ).not.toBeInTheDocument() }) it('should show upgrade banner when billing is enabled and plan is not team', () => { @@ -202,7 +221,9 @@ describe('EmbeddingProcess', () => { render(<EmbeddingProcess {...props} />) - expect(screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade')).toBeInTheDocument() + expect( + screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade'), + ).toBeInTheDocument() }) it('should not show upgrade banner when plan is team', () => { @@ -212,7 +233,9 @@ describe('EmbeddingProcess', () => { render(<EmbeddingProcess {...props} />) - expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade')).not.toBeInTheDocument() + expect( + screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade'), + ).not.toBeInTheDocument() }) it('should show upgrade banner for professional plan', () => { @@ -222,7 +245,9 @@ describe('EmbeddingProcess', () => { render(<EmbeddingProcess {...props} />) - expect(screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade')).toBeInTheDocument() + expect( + screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade'), + ).toBeInTheDocument() }) }) @@ -322,7 +347,11 @@ describe('EmbeddingProcess', () => { it('should show completed status when all documents have error status', async () => { const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ - createMockIndexingStatus({ id: 'doc-1', indexing_status: 'error', error: 'Processing failed' }), + createMockIndexingStatus({ + id: 'doc-1', + indexing_status: 'error', + error: 'Processing failed', + }), ] const props = createDefaultProps({ documents: [doc1] }) @@ -495,14 +524,14 @@ describe('EmbeddingProcess', () => { // Assert - call count should not increase significantly after completion // Note: Due to React Strict Mode, there might be double renders - expect(mockFetchIndexingStatus.mock.calls.length).toBeLessThanOrEqual(callCountAfterComplete + 1) + expect(mockFetchIndexingStatus.mock.calls.length).toBeLessThanOrEqual( + callCountAfterComplete + 1, + ) }) it('should stop polling when all documents have errors', async () => { const doc1 = createMockDocument({ id: 'doc-1' }) - mockIndexingStatusData = [ - createMockIndexingStatus({ id: 'doc-1', indexing_status: 'error' }), - ] + mockIndexingStatusData = [createMockIndexingStatus({ id: 'doc-1', indexing_status: 'error' })] const props = createDefaultProps({ documents: [doc1] }) render(<EmbeddingProcess {...props} />) @@ -541,7 +570,9 @@ describe('EmbeddingProcess', () => { vi.advanceTimersByTime(5000) // Assert - should not poll significantly more after paused state - expect(mockFetchIndexingStatus.mock.calls.length).toBeLessThanOrEqual(callCountAfterPaused + 1) + expect(mockFetchIndexingStatus.mock.calls.length).toBeLessThanOrEqual( + callCountAfterPaused + 1, + ) }) it('should cleanup timeout on unmount', async () => { @@ -786,7 +817,9 @@ describe('EmbeddingProcess', () => { const props = createDefaultProps({ documents: [] }) // Suppress console errors for expected error - const consoleError = vi.spyOn(console, 'error').mockImplementation(Function.prototype as () => void) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(Function.prototype as () => void) // Act & Assert - explicitly assert the error behavior expect(() => { @@ -905,7 +938,11 @@ describe('EmbeddingProcess', () => { }) it('should pass different retrievalMethod values', () => { - const retrievalMethods = [RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.fullText, RETRIEVE_METHOD.hybrid] + const retrievalMethods = [ + RETRIEVE_METHOD.semantic, + RETRIEVE_METHOD.fullText, + RETRIEVE_METHOD.hybrid, + ] retrievalMethods.forEach((retrievalMethod) => { const props = createDefaultProps({ retrievalMethod }) @@ -1077,7 +1114,9 @@ describe('EmbeddingProcess', () => { }) // Assert - upgrade banner should not be present - expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade')).not.toBeInTheDocument() + expect( + screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade'), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx index c0873f2c5db2fe..a704c3da700020 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx @@ -8,7 +8,15 @@ import RuleDetail from '../rule-detail' // Mock FieldInfo component vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({ - FieldInfo: ({ label, displayedValue, valueIcon }: { label: string, displayedValue: string, valueIcon?: React.ReactNode }) => ( + FieldInfo: ({ + label, + displayedValue, + valueIcon, + }: { + label: string + displayedValue: string + valueIcon?: React.ReactNode + }) => ( <div data-testid="field-info" data-label={label}> <span data-testid="field-label">{label}</span> <span data-testid="field-value">{displayedValue}</span> @@ -35,7 +43,9 @@ vi.mock('@/app/components/datasets/create/icons', () => ({ /** * Creates a mock ProcessRuleResponse for testing */ -const createMockProcessRule = (overrides: Partial<ProcessRuleResponse> = {}): ProcessRuleResponse => ({ +const createMockProcessRule = ( + overrides: Partial<ProcessRuleResponse> = {}, +): ProcessRuleResponse => ({ mode: ProcessMode.general, rules: { pre_processing_rules: [], @@ -135,7 +145,9 @@ describe('RuleDetail', () => { render(<RuleDetail sourceData={sourceData} />) const fieldValues = screen.getAllByTestId('field-value') - expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.hierarchical · dataset.parentMode.paragraph') + expect(fieldValues[0]).toHaveTextContent( + 'datasetDocuments.embedding.hierarchical · dataset.parentMode.paragraph', + ) }) it('should show hierarchical mode with full-doc parent mode', () => { @@ -152,7 +164,9 @@ describe('RuleDetail', () => { render(<RuleDetail sourceData={sourceData} />) const fieldValues = screen.getAllByTestId('field-value') - expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.hierarchical · dataset.parentMode.fullDoc') + expect(fieldValues[0]).toHaveTextContent( + 'datasetDocuments.embedding.hierarchical · dataset.parentMode.fullDoc', + ) }) }) @@ -196,7 +210,10 @@ describe('RuleDetail', () => { render(<RuleDetail retrievalMethod={RETRIEVE_METHOD.semantic} />) const fieldInfos = screen.getAllByTestId('field-info') - expect(fieldInfos[2]).toHaveAttribute('data-label', 'datasetSettings.form.retrievalSetting.title') + expect(fieldInfos[2]).toHaveAttribute( + 'data-label', + 'datasetSettings.form.retrievalSetting.title', + ) }) it('should show semantic search title for qualified indexing with semantic method', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx index a9f5315865f35d..fd4ce4abf7d098 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx @@ -49,7 +49,9 @@ const EmbeddingProcess = ({ const { t } = useTranslation() const router = useRouter() const { enableBilling, plan } = useProviderContext() - const [indexingStatusBatchDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse[]>([]) + const [indexingStatusBatchDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse[]>( + [], + ) const [shouldPoll, setShouldPoll] = useState(true) const { mutateAsync: fetchIndexingStatus } = useIndexingStatusBatch({ datasetId, batchId }) @@ -61,13 +63,13 @@ const EmbeddingProcess = ({ onSuccess: (res) => { const indexingStatusDetailList = res.data setIndexingStatusDetail(indexingStatusDetailList) - const isCompleted = indexingStatusDetailList.every(indexingStatusDetail => ['completed', 'error', 'paused'].includes(indexingStatusDetail.indexing_status)) - if (isCompleted) - setShouldPoll(false) + const isCompleted = indexingStatusDetailList.every((indexingStatusDetail) => + ['completed', 'error', 'paused'].includes(indexingStatusDetail.indexing_status), + ) + if (isCompleted) setShouldPoll(false) }, onSettled: () => { - if (shouldPoll) - timeoutId = setTimeout(fetchData, 2500) + if (shouldPoll) timeoutId = setTimeout(fetchData, 2500) }, }) } @@ -91,45 +93,51 @@ const EmbeddingProcess = ({ const apiReferenceUrl = useDatasetApiAccessUrl() const isEmbeddingWaiting = useMemo(() => { - if (!indexingStatusBatchDetail.length) - return false - return indexingStatusBatchDetail.every(indexingStatusDetail => ['waiting'].includes(indexingStatusDetail?.indexing_status || '')) + if (!indexingStatusBatchDetail.length) return false + return indexingStatusBatchDetail.every((indexingStatusDetail) => + ['waiting'].includes(indexingStatusDetail?.indexing_status || ''), + ) }, [indexingStatusBatchDetail]) const isEmbedding = useMemo(() => { - if (!indexingStatusBatchDetail.length) - return false - return indexingStatusBatchDetail.some(indexingStatusDetail => ['indexing', 'splitting', 'parsing', 'cleaning'].includes(indexingStatusDetail?.indexing_status || '')) + if (!indexingStatusBatchDetail.length) return false + return indexingStatusBatchDetail.some((indexingStatusDetail) => + ['indexing', 'splitting', 'parsing', 'cleaning'].includes( + indexingStatusDetail?.indexing_status || '', + ), + ) }, [indexingStatusBatchDetail]) const isEmbeddingCompleted = useMemo(() => { - if (!indexingStatusBatchDetail.length) - return false - return indexingStatusBatchDetail.every(indexingStatusDetail => ['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status || '')) + if (!indexingStatusBatchDetail.length) return false + return indexingStatusBatchDetail.every((indexingStatusDetail) => + ['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status || ''), + ) }, [indexingStatusBatchDetail]) const getSourceName = (id: string) => { - const doc = documents.find(document => document.id === id) + const doc = documents.find((document) => document.id === id) return doc?.name } const getFileType = (name?: string) => name?.split('.').pop() || 'txt' const getSourcePercent = (detail: IndexingStatusResponse) => { const completedCount = detail.completed_segments || 0 const totalCount = detail.total_segments || 0 - if (totalCount === 0) - return 0 - const percent = Math.round(completedCount * 100 / totalCount) + if (totalCount === 0) return 0 + const percent = Math.round((completedCount * 100) / totalCount) return percent > 100 ? 100 : percent } const getSourceType = (id: string) => { - const doc = documents.find(document => document.id === id) + const doc = documents.find((document) => document.id === id) return doc?.data_source_type } const getIcon = (id: string) => { - const doc = documents.find(document => document.id === id) + const doc = documents.find((document) => document.id === id) return doc?.data_source_info.notion_page_icon } const isSourceEmbedding = (detail: IndexingStatusResponse) => - ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'].includes(detail.indexing_status || '') + ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'].includes( + detail.indexing_status || '', + ) return ( <> @@ -139,32 +147,33 @@ const EmbeddingProcess = ({ <> <RiLoader2Fill className="size-4 animate-spin" /> <span> - {isEmbeddingWaiting ? t($ => $['embedding.waiting'], { ns: 'datasetDocuments' }) : t($ => $['embedding.processing'], { ns: 'datasetDocuments' })} + {isEmbeddingWaiting + ? t(($) => $['embedding.waiting'], { ns: 'datasetDocuments' }) + : t(($) => $['embedding.processing'], { ns: 'datasetDocuments' })} </span> </> )} - {isEmbeddingCompleted && t($ => $['embedding.completed'], { ns: 'datasetDocuments' })} + {isEmbeddingCompleted && t(($) => $['embedding.completed'], { ns: 'datasetDocuments' })} </div> - { - enableBilling && plan.type !== Plan.team && ( - <div className="flex h-[52px] items-center gap-x-2 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-2.5 pl-3 shadow-xs shadow-shadow-shadow-3"> - <div className="flex shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 shadow-md shadow-shadow-shadow-5"> - <RiAedFill className="size-4 text-text-primary-on-surface" /> - </div> - <div className="grow system-md-medium text-text-primary"> - {t($ => $['plansCommon.documentProcessingPriorityUpgrade'], { ns: 'billing' })} - </div> - <UpgradeBtn loc="knowledge-speed-up" /> + {enableBilling && plan.type !== Plan.team && ( + <div className="flex h-[52px] items-center gap-x-2 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-2.5 pl-3 shadow-xs shadow-shadow-shadow-3"> + <div className="flex shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 shadow-md shadow-shadow-shadow-5"> + <RiAedFill className="size-4 text-text-primary-on-surface" /> </div> - ) - } + <div className="grow system-md-medium text-text-primary"> + {t(($) => $['plansCommon.documentProcessingPriorityUpgrade'], { ns: 'billing' })} + </div> + <UpgradeBtn loc="knowledge-speed-up" /> + </div> + )} <div className="flex flex-col gap-0.5 pb-2"> - {indexingStatusBatchDetail.map(indexingStatusDetail => ( + {indexingStatusBatchDetail.map((indexingStatusDetail) => ( <div key={indexingStatusDetail.id} className={cn( 'relative h-[26px] overflow-hidden rounded-md bg-components-progress-bar-bg', - indexingStatusDetail.indexing_status === 'error' && 'bg-state-destructive-hover-alt', + indexingStatusDetail.indexing_status === 'error' && + 'bg-state-destructive-hover-alt', )} > {isSourceEmbedding(indexingStatusDetail) && ( @@ -189,15 +198,14 @@ const EmbeddingProcess = ({ src={getIcon(indexingStatusDetail.id)} /> )} - <div className="flex w-0 grow items-center gap-1" title={getSourceName(indexingStatusDetail.id)}> + <div + className="flex w-0 grow items-center gap-1" + title={getSourceName(indexingStatusDetail.id)} + > <div className="truncate system-xs-medium text-text-secondary"> {getSourceName(indexingStatusDetail.id)} </div> - { - enableBilling && ( - <PriorityLabel className="ml-0" /> - ) - } + {enableBilling && <PriorityLabel className="ml-0" />} </div> {isSourceEmbedding(indexingStatusDetail) && ( <div className="shrink-0 text-xs text-text-secondary">{`${getSourcePercent(indexingStatusDetail)}%`}</div> @@ -231,24 +239,16 @@ const EmbeddingProcess = ({ /> </div> <div className="mt-6 flex items-center gap-x-2 py-2"> - <Link - href={apiReferenceUrl} - target="_blank" - rel="noopener noreferrer" - > - <Button - className="w-fit gap-x-0.5 px-3" - > + <Link href={apiReferenceUrl} target="_blank" rel="noopener noreferrer"> + <Button className="w-fit gap-x-0.5 px-3"> <RiTerminalBoxLine className="size-4" /> <span className="px-0.5">Access the API</span> </Button> </Link> - <Button - className="w-fit gap-x-0.5 px-3" - variant="primary" - onClick={navToDocumentList} - > - <span className="px-0.5">{t($ => $['stepThree.navTo'], { ns: 'datasetCreation' })}</span> + <Button className="w-fit gap-x-0.5 px-3" variant="primary" onClick={navToDocumentList}> + <span className="px-0.5"> + {t(($) => $['stepThree.navTo'], { ns: 'datasetCreation' })} + </span> <RiArrowRightLine className="size-4 stroke-current stroke-1" /> </Button> </div> diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx index 040ac4b868a56a..2dafdc5573be00 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx @@ -14,41 +14,46 @@ type RuleDetailProps = { retrievalMethod?: RETRIEVE_METHOD } -const RuleDetail = ({ - sourceData, - indexingType, - retrievalMethod, -}: RuleDetailProps) => { +const RuleDetail = ({ sourceData, indexingType, retrievalMethod }: RuleDetailProps) => { const { t } = useTranslation() - const getValue = useCallback((field: string) => { - let value = '-' - switch (field) { - case 'mode': - value = !sourceData?.mode - ? value - - : sourceData.mode === ProcessMode.general - ? (t($ => $['embedding.custom'], { ns: 'datasetDocuments' }) as string) - - : `${t($ => $['embedding.hierarchical'], { ns: 'datasetDocuments' })} · ${sourceData?.rules?.parent_mode === 'paragraph' - ? t($ => $['parentMode.paragraph'], { ns: 'dataset' }) - : t($ => $['parentMode.fullDoc'], { ns: 'dataset' })}` - break - } - return value - }, [sourceData, t]) + const getValue = useCallback( + (field: string) => { + let value = '-' + switch (field) { + case 'mode': + value = !sourceData?.mode + ? value + : sourceData.mode === ProcessMode.general + ? (t(($) => $['embedding.custom'], { ns: 'datasetDocuments' }) as string) + : `${t(($) => $['embedding.hierarchical'], { ns: 'datasetDocuments' })} · ${ + sourceData?.rules?.parent_mode === 'paragraph' + ? t(($) => $['parentMode.paragraph'], { ns: 'dataset' }) + : t(($) => $['parentMode.fullDoc'], { ns: 'dataset' }) + }` + break + } + return value + }, + [sourceData, t], + ) return ( <div className="flex flex-col gap-1" data-testid="rule-detail"> <FieldInfo - label={t($ => $['embedding.mode'], { ns: 'datasetDocuments' })} + label={t(($) => $['embedding.mode'], { ns: 'datasetDocuments' })} displayedValue={getValue('mode')} /> <FieldInfo - label={t($ => $['stepTwo.indexMode'], { ns: 'datasetCreation' })} - displayedValue={t($ => $[`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`], { ns: 'datasetCreation' }) as string} - valueIcon={( + label={t(($) => $['stepTwo.indexMode'], { ns: 'datasetCreation' })} + displayedValue={ + t( + ($) => + $[`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`], + { ns: 'datasetCreation' }, + ) as string + } + valueIcon={ <img className="size-4" src={ @@ -58,25 +63,30 @@ const RuleDetail = ({ } alt="" /> - )} + } /> <FieldInfo - label={t($ => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} - displayedValue={t($ => $[`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`], { ns: 'dataset' })} - valueIcon={( + label={t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} + displayedValue={t( + ($) => + $[ + `retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : (retrievalMethod ?? 'semantic_search')}.title` + ], + { ns: 'dataset' }, + )} + valueIcon={ <img className="size-4" src={ retrievalMethod === RETRIEVE_METHOD.fullText ? retrievalIcon.fullText - : retrievalMethod === RETRIEVE_METHOD.hybrid ? retrievalIcon.hybrid : retrievalIcon.vector } alt="" /> - )} + } /> </div> ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx index 4ff9fe06119db7..7fcf1039c3f52c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx @@ -12,15 +12,14 @@ type ProcessingProps = { documents: InitialDocumentDetail[] } -const Processing = ({ - batchId, - documents, -}: ProcessingProps) => { +const Processing = ({ batchId, documents }: ProcessingProps) => { const { t } = useTranslation() const docLink = useDocLink() - const datasetId = useDatasetDetailContextWithSelector(s => s.dataset?.id) - const indexingType = useDatasetDetailContextWithSelector(s => s.dataset?.indexing_technique) - const retrievalMethod = useDatasetDetailContextWithSelector(s => s.dataset?.retrieval_model_dict?.search_method) + const datasetId = useDatasetDetailContextWithSelector((s) => s.dataset?.id) + const indexingType = useDatasetDetailContextWithSelector((s) => s.dataset?.indexing_technique) + const retrievalMethod = useDatasetDetailContextWithSelector( + (s) => s.dataset?.retrieval_model_dict?.search_method, + ) return ( <div className="flex size-full justify-center overflow-hidden"> @@ -41,15 +40,19 @@ const Processing = ({ <RiBookOpenLine className="size-5 text-text-accent" /> </div> <div className="flex flex-col gap-y-2"> - <div className="system-xl-semibold text-text-secondary">{t($ => $['stepThree.sideTipTitle'], { ns: 'datasetCreation' })}</div> - <div className="system-sm-regular text-text-tertiary">{t($ => $['stepThree.sideTipContent'], { ns: 'datasetCreation' })}</div> + <div className="system-xl-semibold text-text-secondary"> + {t(($) => $['stepThree.sideTipTitle'], { ns: 'datasetCreation' })} + </div> + <div className="system-sm-regular text-text-tertiary"> + {t(($) => $['stepThree.sideTipContent'], { ns: 'datasetCreation' })} + </div> <a href={docLink('/use-dify/knowledge/knowledge-pipeline/authorize-data-source')} target="_blank" rel="noreferrer noopener" className="system-sm-regular text-text-accent" > - {t($ => $['addDocuments.stepThree.learnMore'], { ns: 'datasetPipeline' })} + {t(($) => $['addDocuments.stepThree.learnMore'], { ns: 'datasetPipeline' })} </a> </div> </div> diff --git a/web/app/components/datasets/documents/create-from-pipeline/step-indicator.tsx b/web/app/components/datasets/documents/create-from-pipeline/step-indicator.tsx index 56ea12caeb3fe2..a0bbdf5ae21d23 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/step-indicator.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/step-indicator.tsx @@ -11,10 +11,7 @@ type StepIndicatorProps = { steps: Step[] } -const StepIndicator = ({ - currentStep, - steps, -}: StepIndicatorProps) => { +const StepIndicator = ({ currentStep, steps }: StepIndicatorProps) => { return ( <div className="flex gap-x-1"> {steps.map((step, index) => { @@ -22,7 +19,10 @@ const StepIndicator = ({ return ( <div key={step.value} - className={cn('size-1 rounded-lg bg-divider-solid', isActive && 'w-2 bg-state-accent-solid')} + className={cn( + 'size-1 rounded-lg bg-divider-solid', + isActive && 'w-2 bg-state-accent-solid', + )} /> ) })} diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx index 9c29206e7d8c05..89433540821ecd 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx @@ -1,7 +1,12 @@ import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types' import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import type { NotionPage } from '@/models/common' -import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse, FileItem } from '@/models/datasets' +import type { + CrawlResultItem, + CustomFile, + FileIndexingEstimateResponse, + FileItem, +} from '@/models/datasets' import type { OnlineDriveFile } from '@/models/pipeline' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -99,7 +104,9 @@ describe('StepOnePreview', () => { }) it('should not render FilePreview when currentLocalFile is undefined', () => { - const { container } = render(<StepOnePreview {...defaultProps} currentLocalFile={undefined} />) + const { container } = render( + <StepOnePreview {...defaultProps} currentLocalFile={undefined} />, + ) // Container should still render but without file preview content expect(container.querySelector('.h-full')).toBeInTheDocument() }) @@ -268,7 +275,9 @@ describe('StepTwoPreview', () => { }) it('should handle empty onlineDriveFiles', () => { - const { container } = render(<StepTwoPreview {...defaultProps} selectedOnlineDriveFileList={[]} />) + const { container } = render( + <StepTwoPreview {...defaultProps} selectedOnlineDriveFileList={[]} />, + ) expect(container.querySelector('.h-full')).toBeInTheDocument() }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx index 4e3c4ce270c9d6..96f6e70d9f7e38 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx @@ -28,7 +28,9 @@ vi.mock('@/app/components/billing/vector-space-full', () => ({ vi.mock('@/app/components/billing/upgrade-btn', () => ({ default: ({ onClick }: { onClick?: () => void }) => ( - <button data-testid="upgrade-btn" onClick={onClick}>Upgrade</button> + <button data-testid="upgrade-btn" onClick={onClick}> + Upgrade + </button> ), })) @@ -78,7 +80,10 @@ vi.mock('../../data-source-options/hooks', () => ({ // Mock the entire local-file component since it has deep context dependencies vi.mock('../../data-source/local-file', () => ({ - default: ({ allowedExtensions, supportBatchUpload }: { + default: ({ + allowedExtensions, + supportBatchUpload, + }: { allowedExtensions: string[] supportBatchUpload: boolean }) => ( @@ -92,13 +97,19 @@ vi.mock('../../data-source/local-file', () => ({ // Mock online documents since it has complex OAuth/API dependencies vi.mock('../../data-source/online-documents', () => ({ - default: ({ nodeId, onCredentialChange }: { + default: ({ + nodeId, + onCredentialChange, + }: { nodeId: string onCredentialChange: (credentialId: string) => void }) => ( <div data-testid="online-documents"> <span data-testid="online-doc-node-id">{nodeId}</span> - <button data-testid="credential-change-btn" onClick={() => onCredentialChange('new-credential')}> + <button + data-testid="credential-change-btn" + onClick={() => onCredentialChange('new-credential')} + > Change Credential </button> </div> @@ -107,13 +118,19 @@ vi.mock('../../data-source/online-documents', () => ({ // Mock website crawl vi.mock('../../data-source/website-crawl', () => ({ - default: ({ nodeId, onCredentialChange }: { + default: ({ + nodeId, + onCredentialChange, + }: { nodeId: string onCredentialChange: (credentialId: string) => void }) => ( <div data-testid="website-crawl"> <span data-testid="website-crawl-node-id">{nodeId}</span> - <button data-testid="website-credential-btn" onClick={() => onCredentialChange('website-credential')}> + <button + data-testid="website-credential-btn" + onClick={() => onCredentialChange('website-credential')} + > Change Website Credential </button> </div> @@ -122,13 +139,19 @@ vi.mock('../../data-source/website-crawl', () => ({ // Mock online drive vi.mock('../../data-source/online-drive', () => ({ - default: ({ nodeId, onCredentialChange }: { + default: ({ + nodeId, + onCredentialChange, + }: { nodeId: string onCredentialChange: (credentialId: string) => void }) => ( <div data-testid="online-drive"> <span data-testid="online-drive-node-id">{nodeId}</span> - <button data-testid="drive-credential-btn" onClick={() => onCredentialChange('drive-credential')}> + <button + data-testid="drive-credential-btn" + onClick={() => onCredentialChange('drive-credential')} + > Change Drive Credential </button> </div> diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-three-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-three-content.spec.tsx index 9a37d859db07e5..3870b05f8fa3a5 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-three-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-three-content.spec.tsx @@ -21,7 +21,11 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock EmbeddingProcess component as it has complex dependencies vi.mock('../../processing/embedding-process', () => ({ - default: ({ datasetId, batchId, documents }: { + default: ({ + datasetId, + batchId, + documents, + }: { datasetId: string batchId: string documents: InitialDocumentDetail[] diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-two-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-two-content.spec.tsx index 84cf96aaa9e57f..25601b400c201c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-two-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-two-content.spec.tsx @@ -5,30 +5,42 @@ import StepTwoContent from '../step-two-content' // Mock ProcessDocuments component as it has complex hook dependencies vi.mock('../../process-documents', () => ({ - default: vi.fn().mockImplementation(({ - dataSourceNodeId, - isRunning, - onProcess, - onPreview, - onSubmit, - onBack, - }: { - dataSourceNodeId: string - isRunning: boolean - onProcess: () => void - onPreview: () => void - onSubmit: (data: Record<string, unknown>) => void - onBack: () => void - }) => ( - <div data-testid="process-documents"> - <span data-testid="data-source-node-id">{dataSourceNodeId}</span> - <span data-testid="is-running">{String(isRunning)}</span> - <button data-testid="process-btn" onClick={onProcess}>Process</button> - <button data-testid="preview-btn" onClick={onPreview}>Preview</button> - <button data-testid="submit-btn" onClick={() => onSubmit({ key: 'value' })}>Submit</button> - <button data-testid="back-btn" onClick={onBack}>Back</button> - </div> - )), + default: vi + .fn() + .mockImplementation( + ({ + dataSourceNodeId, + isRunning, + onProcess, + onPreview, + onSubmit, + onBack, + }: { + dataSourceNodeId: string + isRunning: boolean + onProcess: () => void + onPreview: () => void + onSubmit: (data: Record<string, unknown>) => void + onBack: () => void + }) => ( + <div data-testid="process-documents"> + <span data-testid="data-source-node-id">{dataSourceNodeId}</span> + <span data-testid="is-running">{String(isRunning)}</span> + <button data-testid="process-btn" onClick={onProcess}> + Process + </button> + <button data-testid="preview-btn" onClick={onPreview}> + Preview + </button> + <button data-testid="submit-btn" onClick={() => onSubmit({ key: 'value' })}> + Submit + </button> + <button data-testid="back-btn" onClick={onBack}> + Back + </button> + </div> + ), + ), })) describe('StepTwoContent', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/preview-panel.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/preview-panel.tsx index 392d28bb9e83b5..087cf8c3ddafe0 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/preview-panel.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/preview-panel.tsx @@ -1,7 +1,13 @@ 'use client' import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types' import type { NotionPage } from '@/models/common' -import type { CrawlResultItem, CustomFile, DocumentItem, FileIndexingEstimateResponse, FileItem } from '@/models/datasets' +import type { + CrawlResultItem, + CustomFile, + DocumentItem, + FileIndexingEstimateResponse, + FileItem, +} from '@/models/datasets' import type { DatasourceType, OnlineDriveFile } from '@/models/pipeline' import { memo } from 'react' import ChunkPreview from '../preview/chunk-preview' @@ -19,41 +25,37 @@ type StepOnePreviewProps = { hideWebsitePreview: () => void } -export const StepOnePreview = memo(({ - datasource, - currentLocalFile, - currentDocument, - currentWebsite, - hidePreviewLocalFile, - hidePreviewOnlineDocument, - hideWebsitePreview, -}: StepOnePreviewProps) => { - return ( - <div className="h-full min-w-0 flex-1"> - <div className="flex h-full flex-col pt-2 pl-2"> - {currentLocalFile && ( - <FilePreview - file={currentLocalFile} - hidePreview={hidePreviewLocalFile} - /> - )} - {currentDocument && ( - <OnlineDocumentPreview - datasourceNodeId={datasource!.nodeId} - currentPage={currentDocument} - hidePreview={hidePreviewOnlineDocument} - /> - )} - {currentWebsite && ( - <WebsitePreview - currentWebsite={currentWebsite} - hidePreview={hideWebsitePreview} - /> - )} +export const StepOnePreview = memo( + ({ + datasource, + currentLocalFile, + currentDocument, + currentWebsite, + hidePreviewLocalFile, + hidePreviewOnlineDocument, + hideWebsitePreview, + }: StepOnePreviewProps) => { + return ( + <div className="h-full min-w-0 flex-1"> + <div className="flex h-full flex-col pt-2 pl-2"> + {currentLocalFile && ( + <FilePreview file={currentLocalFile} hidePreview={hidePreviewLocalFile} /> + )} + {currentDocument && ( + <OnlineDocumentPreview + datasourceNodeId={datasource!.nodeId} + currentPage={currentDocument} + hidePreview={hidePreviewOnlineDocument} + /> + )} + {currentWebsite && ( + <WebsitePreview currentWebsite={currentWebsite} hidePreview={hideWebsitePreview} /> + )} + </div> </div> - </div> - ) -}) + ) + }, +) StepOnePreview.displayName = 'StepOnePreview' type StepTwoPreviewProps = { @@ -72,41 +74,43 @@ type StepTwoPreviewProps = { handlePreviewOnlineDriveFileChange: (file: OnlineDriveFile) => void } -export const StepTwoPreview = memo(({ - datasourceType, - localFileList, - onlineDocuments, - websitePages, - selectedOnlineDriveFileList, - isIdle, - isPendingPreview, - estimateData, - onPreview, - handlePreviewFileChange, - handlePreviewOnlineDocumentChange, - handlePreviewWebsitePageChange, - handlePreviewOnlineDriveFileChange, -}: StepTwoPreviewProps) => { - return ( - <div className="h-full min-w-0 flex-1"> - <div className="flex h-full flex-col pt-2 pl-2"> - <ChunkPreview - dataSourceType={datasourceType as DatasourceType} - localFiles={localFileList.map(file => file.file)} - onlineDocuments={onlineDocuments} - websitePages={websitePages} - onlineDriveFiles={selectedOnlineDriveFileList} - isIdle={isIdle} - isPending={isPendingPreview} - estimateData={estimateData} - onPreview={onPreview} - handlePreviewFileChange={handlePreviewFileChange} - handlePreviewOnlineDocumentChange={handlePreviewOnlineDocumentChange} - handlePreviewWebsitePageChange={handlePreviewWebsitePageChange} - handlePreviewOnlineDriveFileChange={handlePreviewOnlineDriveFileChange} - /> +export const StepTwoPreview = memo( + ({ + datasourceType, + localFileList, + onlineDocuments, + websitePages, + selectedOnlineDriveFileList, + isIdle, + isPendingPreview, + estimateData, + onPreview, + handlePreviewFileChange, + handlePreviewOnlineDocumentChange, + handlePreviewWebsitePageChange, + handlePreviewOnlineDriveFileChange, + }: StepTwoPreviewProps) => { + return ( + <div className="h-full min-w-0 flex-1"> + <div className="flex h-full flex-col pt-2 pl-2"> + <ChunkPreview + dataSourceType={datasourceType as DatasourceType} + localFiles={localFileList.map((file) => file.file)} + onlineDocuments={onlineDocuments} + websitePages={websitePages} + onlineDriveFiles={selectedOnlineDriveFileList} + isIdle={isIdle} + isPending={isPendingPreview} + estimateData={estimateData} + onPreview={onPreview} + handlePreviewFileChange={handlePreviewFileChange} + handlePreviewOnlineDocumentChange={handlePreviewOnlineDocumentChange} + handlePreviewWebsitePageChange={handlePreviewWebsitePageChange} + handlePreviewOnlineDriveFileChange={handlePreviewOnlineDriveFileChange} + /> + </div> </div> - </div> - ) -}) + ) + }, +) StepTwoPreview.displayName = 'StepTwoPreview' diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.tsx index 31871b10f1289f..c37804ed3b199a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.tsx @@ -49,9 +49,8 @@ const StepOneContent = ({ onSelectAll, onNextStep, }: StepOneContentProps) => { - const showUpgradeCard = !supportBatchUpload - && datasourceType === DatasourceType.localFile - && localFileListLength > 0 + const showUpgradeCard = + !supportBatchUpload && datasourceType === DatasourceType.localFile && localFileListLength > 0 return ( <div className="flex flex-col gap-y-5 pt-4"> diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/step-three-content.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/step-three-content.tsx index f4b15888a9b8db..aed578fd3bdfd7 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/step-three-content.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/step-three-content.tsx @@ -8,16 +8,8 @@ type StepThreeContentProps = { documents: InitialDocumentDetail[] } -const StepThreeContent = ({ - batchId, - documents, -}: StepThreeContentProps) => { - return ( - <Processing - batchId={batchId} - documents={documents} - /> - ) +const StepThreeContent = ({ batchId, documents }: StepThreeContentProps) => { + return <Processing batchId={batchId} documents={documents} /> } export default memo(StepThreeContent) diff --git a/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx index 9524998290c3fd..7a6c97f81b0dd8 100644 --- a/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx @@ -4,24 +4,25 @@ import { DocumentContext, useDocumentContext } from '../context' describe('DocumentContext', () => { it('should return the default empty context value when no provider is present', () => { - const { result } = renderHook(() => useDocumentContext(value => value)) + const { result } = renderHook(() => useDocumentContext((value) => value)) expect(result.current).toEqual({}) }) it('should select values from the nearest provider', () => { const wrapper = ({ children }: { children: ReactNode }) => ( - <DocumentContext.Provider value={{ - datasetId: 'dataset-1', - documentId: 'document-1', - }} + <DocumentContext.Provider + value={{ + datasetId: 'dataset-1', + documentId: 'document-1', + }} > {children} </DocumentContext.Provider> ) const { result } = renderHook( - () => useDocumentContext(value => `${value.datasetId}:${value.documentId}`), + () => useDocumentContext((value) => `${value.datasetId}:${value.documentId}`), { wrapper }, ) diff --git a/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx index b48575d209d3cb..cfadb41f337766 100644 --- a/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx @@ -2,7 +2,6 @@ import type { SimpleDocumentDetail } from '@/models/datasets' import { render } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode, DataSourceType } from '@/models/datasets' - import { DocumentTitle } from '../document-title' const mockPush = vi.fn() @@ -79,25 +78,19 @@ describe('DocumentTitle', () => { describe('Rendering', () => { it('should render without crashing', () => { - const { container } = render( - <DocumentTitle datasetId="dataset-1" />, - ) + const { container } = render(<DocumentTitle datasetId="dataset-1" />) expect(container.firstChild).toBeInTheDocument() }) it('should render DocumentPicker component', () => { - const { getByTestId } = render( - <DocumentTitle datasetId="dataset-1" />, - ) + const { getByTestId } = render(<DocumentTitle datasetId="dataset-1" />) expect(getByTestId('document-picker')).toBeInTheDocument() }) it('should render with correct container classes', () => { - const { container } = render( - <DocumentTitle datasetId="dataset-1" />, - ) + const { container } = render(<DocumentTitle datasetId="dataset-1" />) const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') @@ -109,9 +102,7 @@ describe('DocumentTitle', () => { describe('Props', () => { it('should pass datasetId to DocumentPicker', () => { - const { getByTestId } = render( - <DocumentTitle datasetId="test-dataset-id" />, - ) + const { getByTestId } = render(<DocumentTitle datasetId="test-dataset-id" />) expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('test-dataset-id') }) @@ -119,11 +110,7 @@ describe('DocumentTitle', () => { it('should pass the selected document to DocumentPicker', () => { const document = createDocument({ id: 'doc-current' }) const { getByTestId } = render( - <DocumentTitle - datasetId="dataset-1" - document={document} - parentMode="paragraph" - />, + <DocumentTitle datasetId="dataset-1" document={document} parentMode="paragraph" />, ) expect(getByTestId('document-picker')).toHaveAttribute('data-value-id', 'doc-current') @@ -131,9 +118,7 @@ describe('DocumentTitle', () => { }) it('should pass no parent mode when it is undefined', () => { - const { getByTestId } = render( - <DocumentTitle datasetId="dataset-1" />, - ) + const { getByTestId } = render(<DocumentTitle datasetId="dataset-1" />) expect(getByTestId('document-picker')).toHaveAttribute('data-parent-mode', '') }) @@ -150,9 +135,7 @@ describe('DocumentTitle', () => { describe('Navigation', () => { it('should navigate to document page when document is selected', () => { - const { getByTestId } = render( - <DocumentTitle datasetId="dataset-1" />, - ) + const { getByTestId } = render(<DocumentTitle datasetId="dataset-1" />) getByTestId('document-picker').click() @@ -162,9 +145,7 @@ describe('DocumentTitle', () => { describe('Edge Cases', () => { it('should handle an empty document value', () => { - const { getByTestId } = render( - <DocumentTitle datasetId="dataset-1" />, - ) + const { getByTestId } = render(<DocumentTitle datasetId="dataset-1" />) expect(getByTestId('document-picker')).toHaveAttribute('data-value-id', '') }) diff --git a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx index ebbd6ef4a2ae65..35c46f0e796f88 100644 --- a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -5,7 +5,10 @@ import { DatasetACLPermission } from '@/utils/permission' // --- All hoisted mock fns and state (accessible inside vi.mock factories) --- const mocks = vi.hoisted(() => { const state = { - dataset: { embedding_available: true, permission_keys: ['dataset.acl.edit'] } as Record<string, unknown> | null, + dataset: { embedding_available: true, permission_keys: ['dataset.acl.edit'] } as Record< + string, + unknown + > | null, documentDetail: null as Record<string, unknown> | null, documentError: null as Error | null, documentMetadata: null as Record<string, unknown> | null, @@ -67,10 +70,8 @@ vi.mock('@/service/knowledge/use-segment', () => ({ vi.mock('@/service/use-base', () => ({ useInvalid: (key: unknown) => { const keyStr = JSON.stringify(key) - if (keyStr === JSON.stringify(['segment-list'])) - return mocks.invalidSegmentList - if (keyStr === JSON.stringify(['child-segment-list'])) - return mocks.invalidChildSegmentList + if (keyStr === JSON.stringify(['segment-list'])) return mocks.invalidSegmentList + if (keyStr === JSON.stringify(['child-segment-list'])) return mocks.invalidChildSegmentList return vi.fn() }, })) @@ -81,7 +82,15 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ // --- Child component mocks --- vi.mock('../completed', () => ({ - default: ({ embeddingAvailable, showNewSegmentModal, archived }: { embeddingAvailable?: boolean, showNewSegmentModal?: () => void, archived?: boolean }) => ( + default: ({ + embeddingAvailable, + showNewSegmentModal, + archived, + }: { + embeddingAvailable?: boolean + showNewSegmentModal?: () => void + archived?: boolean + }) => ( <div data-testid="completed" data-embedding-available={embeddingAvailable} @@ -96,22 +105,33 @@ vi.mock('../completed', () => ({ vi.mock('../embedding', () => ({ default: ({ detailUpdate }: { detailUpdate?: () => void }) => ( <div data-testid="embedding"> - <button data-testid="embedding-refresh" onClick={detailUpdate}>Refresh</button> + <button data-testid="embedding-refresh" onClick={detailUpdate}> + Refresh + </button> </div> ), })) vi.mock('../batch-modal', () => ({ - default: ({ isShow, onCancel, onConfirm }: { isShow?: boolean, onCancel?: () => void, onConfirm?: (val: Record<string, unknown>) => void }) => ( - isShow - ? ( - <div data-testid="batch-modal"> - <button data-testid="batch-cancel" onClick={onCancel}>Cancel</button> - <button data-testid="batch-confirm" onClick={() => onConfirm?.({ file: { id: 'file-1' } })}>Confirm</button> - </div> - ) - : null - ), + default: ({ + isShow, + onCancel, + onConfirm, + }: { + isShow?: boolean + onCancel?: () => void + onConfirm?: (val: Record<string, unknown>) => void + }) => + isShow ? ( + <div data-testid="batch-modal"> + <button data-testid="batch-cancel" onClick={onCancel}> + Cancel + </button> + <button data-testid="batch-confirm" onClick={() => onConfirm?.({ file: { id: 'file-1' } })}> + Confirm + </button> + </div> + ) : null, })) vi.mock('../document-title', () => ({ @@ -124,54 +144,102 @@ vi.mock('../document-title', () => ({ data_source_info?: { upload_file?: { extension?: string } } } | null }) => { - const extension = document?.data_source_detail_dict?.upload_file?.extension - ?? document?.data_source_info?.upload_file?.extension - - return <div data-testid="document-title" data-extension={extension}>{document?.name}</div> + const extension = + document?.data_source_detail_dict?.upload_file?.extension ?? + document?.data_source_info?.upload_file?.extension + + return ( + <div data-testid="document-title" data-extension={extension}> + {document?.name} + </div> + ) }, })) vi.mock('../segment-add', () => ({ - SegmentAdd: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => ( + SegmentAdd: ({ + showNewSegmentModal, + showBatchModal, + embedding, + }: { + showNewSegmentModal?: () => void + showBatchModal?: () => void + embedding?: boolean + }) => ( <div data-testid="segment-add" data-embedding={embedding}> - <button data-testid="new-segment-btn" onClick={showNewSegmentModal}>New Segment</button> - <button data-testid="batch-btn" onClick={showBatchModal}>Batch Import</button> + <button data-testid="new-segment-btn" onClick={showNewSegmentModal}> + New Segment + </button> + <button data-testid="batch-btn" onClick={showBatchModal}> + Batch Import + </button> </div> ), })) vi.mock('../../components/operations', () => ({ - default: ({ onUpdate, scene }: { onUpdate?: (action?: string) => void, scene?: string }) => ( + default: ({ onUpdate, scene }: { onUpdate?: (action?: string) => void; scene?: string }) => ( <div data-testid="operations" data-scene={scene}> - <button data-testid="op-rename" onClick={() => onUpdate?.('rename')}>Rename</button> - <button data-testid="op-delete" onClick={() => onUpdate?.('delete')}>Delete</button> - <button data-testid="op-noop" onClick={() => onUpdate?.()}>NoOp</button> + <button data-testid="op-rename" onClick={() => onUpdate?.('rename')}> + Rename + </button> + <button data-testid="op-delete" onClick={() => onUpdate?.('delete')}> + Delete + </button> + <button data-testid="op-noop" onClick={() => onUpdate?.()}> + NoOp + </button> </div> ), })) vi.mock('../../status-item', () => ({ - default: ({ status, scene }: { status?: string, scene?: string }) => ( - <div data-testid="status-item" data-scene={scene}>{status}</div> + default: ({ status, scene }: { status?: string; scene?: string }) => ( + <div data-testid="status-item" data-scene={scene}> + {status} + </div> ), })) vi.mock('@/app/components/datasets/metadata/metadata-document', () => ({ - default: ({ datasetId, documentId, canEdit }: { datasetId?: string, documentId?: string, canEdit?: boolean }) => ( - <div data-testid="metadata" data-dataset-id={datasetId} data-document-id={documentId} data-can-edit={String(canEdit)}>Metadata</div> + default: ({ + datasetId, + documentId, + canEdit, + }: { + datasetId?: string + documentId?: string + canEdit?: boolean + }) => ( + <div + data-testid="metadata" + data-dataset-id={datasetId} + data-document-id={documentId} + data-can-edit={String(canEdit)} + > + Metadata + </div> ), })) vi.mock('@/app/components/base/float-right-container', () => ({ - default: ({ children, isOpen, onClose }: { children?: React.ReactNode, isOpen?: boolean, onClose?: () => void }) => - isOpen - ? ( - <div data-testid="float-right-container"> - <button data-testid="close-metadata" onClick={onClose}>Close</button> - {children} - </div> - ) - : null, + default: ({ + children, + isOpen, + onClose, + }: { + children?: React.ReactNode + isOpen?: boolean + onClose?: () => void + }) => + isOpen ? ( + <div data-testid="float-right-container"> + <button data-testid="close-metadata" onClick={onClose}> + Close + </button> + {children} + </div> + ) : null, })) // --- Lazy import (after all vi.mock calls) --- @@ -196,7 +264,10 @@ describe('DocumentDetail', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() - mocks.state.dataset = { embedding_available: true, permission_keys: [DatasetACLPermission.Edit] } + mocks.state.dataset = { + embedding_available: true, + permission_keys: [DatasetACLPermission.Edit], + } mocks.state.documentDetail = createDocumentDetail() mocks.state.documentError = null mocks.state.documentMetadata = null @@ -232,12 +303,15 @@ describe('DocumentDetail', () => { expect(screen.queryByTestId('embedding')).not.toBeInTheDocument() }) - it.each(['queuing', 'indexing', 'paused'])('should render Embedding when status is %s', (status) => { - mocks.state.documentDetail = createDocumentDetail({ display_status: status }) - render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) - expect(screen.getByTestId('embedding')).toBeInTheDocument() - expect(screen.queryByTestId('completed')).not.toBeInTheDocument() - }) + it.each(['queuing', 'indexing', 'paused'])( + 'should render Embedding when status is %s', + (status) => { + mocks.state.documentDetail = createDocumentDetail({ display_status: status }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('embedding')).toBeInTheDocument() + expect(screen.queryByTestId('completed')).not.toBeInTheDocument() + }, + ) it('should render DocumentTitle with name and extension', () => { render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) @@ -287,7 +361,10 @@ describe('DocumentDetail', () => { }) it('should hide SegmentAdd when dataset only has readonly ACL permission', () => { - mocks.state.dataset = { embedding_available: true, permission_keys: [DatasetACLPermission.Readonly] } + mocks.state.dataset = { + embedding_available: true, + permission_keys: [DatasetACLPermission.Readonly], + } render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) expect(screen.queryByTestId('segment-add')).not.toBeInTheDocument() }) @@ -327,7 +404,10 @@ describe('DocumentDetail', () => { }) it('should pass readonly ACL state to Metadata', () => { - mocks.state.dataset = { embedding_available: true, permission_keys: [DatasetACLPermission.Readonly] } + mocks.state.dataset = { + embedding_available: true, + permission_keys: [DatasetACLPermission.Readonly], + } render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) expect(screen.getByTestId('metadata')).toHaveAttribute('data-can-edit', 'false') @@ -343,7 +423,9 @@ describe('DocumentDetail', () => { it('should expose aria label for back button', () => { render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) - expect(screen.getByRole('button', { name: 'common.operation.back' })).toHaveAttribute('aria-label') + expect(screen.getByRole('button', { name: 'common.operation.back' })).toHaveAttribute( + 'aria-label', + ) }) it('should preserve query params when navigating back', () => { diff --git a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx index 9121056ea4edba..8252496e1e1739 100644 --- a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx @@ -3,7 +3,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' import { IndexingType } from '../../../create/step-two' - import NewSegmentModal from '../new-segment' vi.mock('@/next/navigation', () => ({ @@ -19,7 +18,9 @@ const toastSuccessSpy = vi.spyOn(toast, 'success') // Mock dataset detail context let mockIndexingTechnique = IndexingType.QUALIFIED vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: { indexing_technique: string } }) => unknown) => { + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: { indexing_technique: string } }) => unknown, + ) => { return selector({ dataset: { indexing_technique: mockIndexingTechnique } }) }, })) @@ -28,7 +29,9 @@ vi.mock('@/context/dataset-detail', () => ({ let mockFullScreen = false const mockToggleFullScreen = vi.fn() vi.mock('../completed', () => ({ - useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => { + useSegmentListContext: ( + selector: (state: { fullScreen: boolean; toggleFullScreen: () => void }) => unknown, + ) => { const state = { fullScreen: mockFullScreen, toggleFullScreen: mockToggleFullScreen, @@ -46,9 +49,21 @@ vi.mock('@/service/knowledge/use-segment', () => ({ })) vi.mock('../completed/common/action-buttons', () => ({ - default: ({ handleCancel, handleSave, loading, actionType }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string }) => ( + default: ({ + handleCancel, + handleSave, + loading, + actionType, + }: { + handleCancel: () => void + handleSave: () => void + loading: boolean + actionType: string + }) => ( <div data-testid="action-buttons"> - <button onClick={handleCancel} data-testid="cancel-btn">Cancel</button> + <button onClick={handleCancel} data-testid="cancel-btn"> + Cancel + </button> <button onClick={handleSave} disabled={loading} data-testid="save-btn"> {loading ? 'Saving...' : 'Save'} </button> @@ -58,12 +73,20 @@ vi.mock('../completed/common/action-buttons', () => ({ })) vi.mock('../completed/common/add-another', () => ({ - default: ({ checked, onCheckedChange, className }: { checked: boolean, onCheckedChange: (checked: boolean) => void, className?: string }) => ( + default: ({ + checked, + onCheckedChange, + className, + }: { + checked: boolean + onCheckedChange: (checked: boolean) => void + className?: string + }) => ( <label className={className}> <input type="checkbox" checked={checked} - onChange={event => onCheckedChange(event.currentTarget.checked)} + onChange={(event) => onCheckedChange(event.currentTarget.checked)} /> datasetDocuments.segment.addAnother </label> @@ -71,19 +94,33 @@ vi.mock('../completed/common/add-another', () => ({ })) vi.mock('../completed/common/chunk-content', () => ({ - default: ({ docForm, question, answer, onQuestionChange, onAnswerChange, isEditMode }: { docForm: string, question: string, answer: string, onQuestionChange: (v: string) => void, onAnswerChange: (v: string) => void, isEditMode: boolean }) => ( + default: ({ + docForm, + question, + answer, + onQuestionChange, + onAnswerChange, + isEditMode, + }: { + docForm: string + question: string + answer: string + onQuestionChange: (v: string) => void + onAnswerChange: (v: string) => void + isEditMode: boolean + }) => ( <div data-testid="chunk-content"> <input data-testid="question-input" value={question} - onChange={e => onQuestionChange(e.target.value)} + onChange={(e) => onQuestionChange(e.target.value)} placeholder={docForm === ChunkingMode.qa ? 'Question' : 'Content'} /> {docForm === ChunkingMode.qa && ( <input data-testid="answer-input" value={answer} - onChange={e => onAnswerChange(e.target.value)} + onChange={(e) => onAnswerChange(e.target.value)} placeholder="Answer" /> )} @@ -97,28 +134,42 @@ vi.mock('../completed/common/dot', () => ({ })) vi.mock('../completed/common/keywords', () => ({ - default: ({ keywords, onKeywordsChange, _isEditMode, _actionType }: { keywords: string[], onKeywordsChange: (v: string[]) => void, _isEditMode?: boolean, _actionType?: string }) => ( + default: ({ + keywords, + onKeywordsChange, + _isEditMode, + _actionType, + }: { + keywords: string[] + onKeywordsChange: (v: string[]) => void + _isEditMode?: boolean + _actionType?: string + }) => ( <div data-testid="keywords"> <input data-testid="keywords-input" value={keywords.join(',')} - onChange={e => onKeywordsChange(e.target.value.split(',').filter(Boolean))} + onChange={(e) => onKeywordsChange(e.target.value.split(',').filter(Boolean))} /> </div> ), })) vi.mock('../completed/common/segment-index-tag', () => ({ - SegmentIndexTag: ({ label }: { label: string }) => <span data-testid="segment-index-tag">{label}</span>, + SegmentIndexTag: ({ label }: { label: string }) => ( + <span data-testid="segment-index-tag">{label}</span> + ), })) vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-chunk', () => ({ - default: ({ onChange }: { value?: unknown[], onChange: (v: { uploadedId: string }[]) => void }) => ( + default: ({ + onChange, + }: { + value?: unknown[] + onChange: (v: { uploadedId: string }[]) => void + }) => ( <div data-testid="image-uploader"> - <button - data-testid="upload-image-btn" - onClick={() => onChange([{ uploadedId: 'img-1' }])} - > + <button data-testid="upload-image-btn" onClick={() => onChange([{ uploadedId: 'img-1' }])}> Upload Image </button> </div> @@ -340,7 +391,9 @@ describe('NewSegmentModal', () => { render(<NewSegmentModal {...defaultProps} />) - expect(screen.getByRole('checkbox', { name: 'datasetDocuments.segment.addAnother' }))!.toBeInTheDocument() + expect( + screen.getByRole('checkbox', { name: 'datasetDocuments.segment.addAnother' }), + )!.toBeInTheDocument() }) it('should call toggleFullScreen when expand button is clicked', () => { @@ -402,11 +455,13 @@ describe('NewSegmentModal', () => { it('should call viewNewlyAddedChunk when the toast action is clicked', async () => { const mockViewNewlyAddedChunk = vi.fn() - mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }) + mockAddSegment.mockImplementation( + (_params: unknown, options: { onSuccess: () => void; onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }, + ) render( <> @@ -433,11 +488,13 @@ describe('NewSegmentModal', () => { describe('QA mode save with content', () => { it('should save with both question and answer in QA mode', async () => { - mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }) + mockAddSegment.mockImplementation( + (_params: unknown, options: { onSuccess: () => void; onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }, + ) render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) @@ -466,16 +523,20 @@ describe('NewSegmentModal', () => { describe('Keywords in save params', () => { it('should include keywords in save params when keywords are provided', async () => { mockIndexingTechnique = IndexingType.ECONOMICAL - mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }) + mockAddSegment.mockImplementation( + (_params: unknown, options: { onSuccess: () => void; onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }, + ) render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) // Enter content - fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Content with keywords' } }) + fireEvent.change(screen.getByTestId('question-input'), { + target: { value: 'Content with keywords' }, + }) // Enter keywords fireEvent.change(screen.getByTestId('keywords-input'), { target: { value: 'kw1,kw2' } }) @@ -498,16 +559,20 @@ describe('NewSegmentModal', () => { describe('Save with attachments', () => { it('should include attachment_ids in save params when images are uploaded', async () => { - mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }) + mockAddSegment.mockImplementation( + (_params: unknown, options: { onSuccess: () => void; onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }, + ) render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) // Enter content - fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Content with images' } }) + fireEvent.change(screen.getByTestId('question-input'), { + target: { value: 'Content with images' }, + }) // Upload an image fireEvent.click(screen.getByTestId('upload-image-btn')) @@ -531,13 +596,17 @@ describe('NewSegmentModal', () => { describe('handleCancel with addAnother unchecked', () => { it('should call onCancel when addAnother is unchecked and save succeeds', async () => { const mockOnCancel = vi.fn() - mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }) + mockAddSegment.mockImplementation( + (_params: unknown, options: { onSuccess: () => void; onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }, + ) - render(<NewSegmentModal {...defaultProps} onCancel={mockOnCancel} docForm={ChunkingMode.text} />) + render( + <NewSegmentModal {...defaultProps} onCancel={mockOnCancel} docForm={ChunkingMode.text} />, + ) const checkbox = screen.getByRole('checkbox', { name: 'datasetDocuments.segment.addAnother' }) fireEvent.click(checkbox) @@ -556,11 +625,13 @@ describe('NewSegmentModal', () => { describe('onSave after success', () => { it('should call onSave immediately after save succeeds', async () => { const mockOnSave = vi.fn() - mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }) + mockAddSegment.mockImplementation( + (_params: unknown, options: { onSuccess: () => void; onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }, + ) render(<NewSegmentModal {...defaultProps} onSave={mockOnSave} docForm={ChunkingMode.text} />) @@ -595,7 +666,9 @@ describe('NewSegmentModal', () => { render(<NewSegmentModal {...defaultProps} />) - expect(screen.getByRole('checkbox', { name: 'datasetDocuments.segment.addAnother' }))!.toBeInTheDocument() + expect( + screen.getByRole('checkbox', { name: 'datasetDocuments.segment.addAnother' }), + )!.toBeInTheDocument() expect(screen.getByTestId('action-buttons'))!.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-downloader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-downloader.spec.tsx index 52353b856ac041..657dcf1df49f5d 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-downloader.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-downloader.spec.tsx @@ -3,7 +3,6 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { LanguagesSupported } from '@/i18n-config/language' import { ChunkingMode } from '@/models/datasets' - import CSVDownload from '../csv-downloader' // Mock useLocale @@ -13,7 +12,17 @@ vi.mock('@/context/i18n', () => ({ })) // Mock react-papaparse -const MockCSVDownloader = ({ children, data, filename, type }: { children: ReactNode, data: unknown, filename: string, type: string }) => ( +const MockCSVDownloader = ({ + children, + data, + filename, + type, +}: { + children: ReactNode + data: unknown + filename: string + type: string +}) => ( <div data-testid="csv-downloader-link" data-filename={filename} @@ -131,11 +140,7 @@ describe('CSVDownloader', () => { const link = screen.getByTestId('csv-downloader-link') const data = JSON.parse(link.getAttribute('data-data') || '[]') - expect(data).toEqual([ - ['segment content'], - ['content1'], - ['content2'], - ]) + expect(data).toEqual([['segment content'], ['content1'], ['content2']]) }) it('should provide Chinese QA template when locale is Chinese and docForm is qa', () => { @@ -159,11 +164,7 @@ describe('CSVDownloader', () => { const link = screen.getByTestId('csv-downloader-link') const data = JSON.parse(link.getAttribute('data-data') || '[]') - expect(data).toEqual([ - ['分段内容'], - ['内容 1'], - ['内容 2'], - ]) + expect(data).toEqual([['分段内容'], ['内容 1'], ['内容 2']]) }) }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx index a4d7871719e2fb..f1b3edb3af97f2 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx @@ -2,7 +2,6 @@ import type { CustomFile, FileItem } from '@/models/datasets' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Theme } from '@/types/app' - import CSVUploader from '../csv-uploader' // Mock upload service @@ -27,15 +26,18 @@ const toastMocks = vi.hoisted(() => { const call = vi.fn() return { call, - api: Object.assign(vi.fn((message: unknown, options?: Record<string, unknown>) => call({ message, ...options })), { - success: vi.fn((message, options) => call({ type: 'success', message, ...options })), - error: vi.fn((message, options) => call({ type: 'error', message, ...options })), - warning: vi.fn((message, options) => call({ type: 'warning', message, ...options })), - info: vi.fn((message, options) => call({ type: 'info', message, ...options })), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }), + api: Object.assign( + vi.fn((message: unknown, options?: Record<string, unknown>) => call({ message, ...options })), + { + success: vi.fn((message, options) => call({ type: 'success', message, ...options })), + error: vi.fn((message, options) => call({ type: 'error', message, ...options })), + warning: vi.fn((message, options) => call({ type: 'warning', message, ...options })), + info: vi.fn((message, options) => call({ type: 'info', message, ...options })), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), + }, + ), } }) @@ -44,7 +46,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('use-context-selector', async (importOriginal) => { - const actual = await importOriginal() as Record<string, unknown> + const actual = (await importOriginal()) as Record<string, unknown> return { ...actual, useContext: () => ({ @@ -151,7 +153,9 @@ describe('CSVUploader', () => { file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile, progress: 100, } - const { container } = render(<CSVUploader {...defaultProps} file={mockFile} updateFile={mockUpdateFile} />) + const { container } = render( + <CSVUploader {...defaultProps} file={mockFile} updateFile={mockUpdateFile} />, + ) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement Object.defineProperty(fileInput, 'value', { configurable: true, @@ -169,9 +173,7 @@ describe('CSVUploader', () => { const mockUpdateFile = vi.fn() mockUpload.mockResolvedValueOnce({ id: 'uploaded-id' }) - const { container } = render( - <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, - ) + const { container } = render(<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) @@ -251,9 +253,7 @@ describe('CSVUploader', () => { const mockUpdateFile = vi.fn() mockUpload.mockResolvedValueOnce({ id: 'test-id' }) - const { container } = render( - <CSVUploader file={undefined} updateFile={mockUpdateFile} />, - ) + const { container } = render(<CSVUploader file={undefined} updateFile={mockUpdateFile} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) @@ -268,9 +268,7 @@ describe('CSVUploader', () => { describe('Edge Cases', () => { it('should handle empty file list', () => { const mockUpdateFile = vi.fn() - const { container } = render( - <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, - ) + const { container } = render(<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement fireEvent.change(fileInput, { target: { files: [] } }) @@ -280,9 +278,7 @@ describe('CSVUploader', () => { it('should handle null file', () => { const mockUpdateFile = vi.fn() - const { container } = render( - <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, - ) + const { container } = render(<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement fireEvent.change(fileInput, { target: { files: null } }) @@ -307,9 +303,7 @@ describe('CSVUploader', () => { const mockUpdateFile = vi.fn() mockUpload.mockRejectedValueOnce(new Error('Upload failed')) - const { container } = render( - <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, - ) + const { container } = render(<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) @@ -370,9 +364,7 @@ describe('CSVUploader', () => { return Promise.resolve({ id: 'uploaded-id' }) }) - const { container } = render( - <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, - ) + const { container } = render(<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) @@ -406,9 +398,7 @@ describe('CSVUploader', () => { return Promise.resolve({ id: 'uploaded-id' }) }) - const { container } = render( - <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, - ) + const { container } = render(<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) @@ -519,14 +509,15 @@ describe('CSVUploader', () => { const mockUpdateFile = vi.fn() mockUpload.mockResolvedValueOnce({ id: 'dropped-file-id' }) - const { container } = render( - <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, - ) + const { container } = render(<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />) const dropZone = getDropZone(container) // Create a drop event with a CSV file const testFile = new File(['csv,data'], 'dropped.csv', { type: 'text/csv' }) - const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as unknown as DragEvent + const dropEvent = new Event('drop', { + bubbles: true, + cancelable: true, + }) as unknown as DragEvent Object.defineProperty(dropEvent, 'dataTransfer', { value: { files: [testFile], @@ -549,7 +540,10 @@ describe('CSVUploader', () => { // Create a drop event with multiple files const file1 = new File(['csv1'], 'file1.csv', { type: 'text/csv' }) const file2 = new File(['csv2'], 'file2.csv', { type: 'text/csv' }) - const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as unknown as DragEvent + const dropEvent = new Event('drop', { + bubbles: true, + cancelable: true, + }) as unknown as DragEvent Object.defineProperty(dropEvent, 'dataTransfer', { value: { files: [file1, file2], @@ -569,9 +563,7 @@ describe('CSVUploader', () => { it('should handle drop event without dataTransfer', () => { const mockUpdateFile = vi.fn() - const { container } = render( - <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, - ) + const { container } = render(<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />) const dropZone = getDropZone(container) // Create a drop event without dataTransfer @@ -595,9 +587,7 @@ describe('CSVUploader', () => { fireEvent.change(fileInput, { target: { files: [testFile] } }) // Assert - should be valid and trigger upload - expect(toastMocks.call).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(toastMocks.call).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) }) }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/index.spec.tsx index 11fa4bca388dfe..cdb7a412561aad 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/index.spec.tsx @@ -1,7 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' - import BatchModal from '../index' vi.mock('../csv-downloader', () => ({ @@ -13,18 +12,18 @@ vi.mock('../csv-downloader', () => ({ })) vi.mock('../csv-uploader', () => ({ - default: ({ file, updateFile }: { file: { file?: { id: string } } | undefined, updateFile: (file: { file: { id: string } } | undefined) => void }) => ( + default: ({ + file, + updateFile, + }: { + file: { file?: { id: string } } | undefined + updateFile: (file: { file: { id: string } } | undefined) => void + }) => ( <div data-testid="csv-uploader"> - <button - data-testid="upload-btn" - onClick={() => updateFile({ file: { id: 'test-file-id' } })} - > + <button data-testid="upload-btn" onClick={() => updateFile({ file: { id: 'test-file-id' } })}> Upload </button> - <button - data-testid="clear-btn" - onClick={() => updateFile(undefined)} - > + <button data-testid="clear-btn" onClick={() => updateFile(undefined)}> Clear </button> {file && <span data-testid="file-info">{file.file?.id}</span>} @@ -131,7 +130,9 @@ describe('BatchModal', () => { it('should pass docForm to CSVDownloader', () => { render(<BatchModal {...defaultProps} docForm={ChunkingMode.qa} />) - expect(screen.getByTestId('csv-downloader').getAttribute('data-doc-form')).toBe(ChunkingMode.qa) + expect(screen.getByTestId('csv-downloader').getAttribute('data-doc-form')).toBe( + ChunkingMode.qa, + ) }) }) @@ -163,8 +164,7 @@ describe('BatchModal', () => { // Act - try to click run (should be disabled) const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button') - if (runButton) - fireEvent.click(runButton) + if (runButton) fireEvent.click(runButton) expect(mockOnConfirm).not.toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.tsx b/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.tsx index 7956e55005ff4d..ef4587c429b29a 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.tsx @@ -2,9 +2,7 @@ import type { FC } from 'react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { - useCSVDownloader, -} from 'react-papaparse' +import { useCSVDownloader } from 'react-papaparse' import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general' import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' @@ -20,16 +18,8 @@ const CSV_TEMPLATE_QA_CN = [ ['问题 1', '答案 1'], ['问题 2', '答案 2'], ] -const CSV_TEMPLATE_EN = [ - ['segment content'], - ['content1'], - ['content2'], -] -const CSV_TEMPLATE_CN = [ - ['分段内容'], - ['内容 1'], - ['内容 2'], -] +const CSV_TEMPLATE_EN = [['segment content'], ['content1'], ['content2']] +const CSV_TEMPLATE_CN = [['分段内容'], ['内容 1'], ['内容 2']] const CSVDownload: FC<{ docForm: ChunkingMode }> = ({ docForm }) => { const { t } = useTranslation() @@ -38,50 +28,46 @@ const CSVDownload: FC<{ docForm: ChunkingMode }> = ({ docForm }) => { const getTemplate = () => { if (locale === LanguagesSupported[1]) { - if (docForm === ChunkingMode.qa) - return CSV_TEMPLATE_QA_CN + if (docForm === ChunkingMode.qa) return CSV_TEMPLATE_QA_CN return CSV_TEMPLATE_CN } - if (docForm === ChunkingMode.qa) - return CSV_TEMPLATE_QA_EN + if (docForm === ChunkingMode.qa) return CSV_TEMPLATE_QA_EN return CSV_TEMPLATE_EN } return ( <div className="mt-6"> - <div className="text-sm font-medium text-text-primary">{t($ => $['generation.csvStructureTitle'], { ns: 'share' })}</div> + <div className="text-sm font-medium text-text-primary"> + {t(($) => $['generation.csvStructureTitle'], { ns: 'share' })} + </div> <div className="mt-2 max-h-[500px] overflow-auto"> {docForm === ChunkingMode.qa && ( <table className="w-full table-fixed border-separate border-spacing-0 rounded-lg border border-divider-subtle text-xs"> <thead className="text-text-secondary"> <tr> - <td className="h-9 border-b border-divider-subtle pr-2 pl-3">{t($ => $['list.batchModal.question'], { ns: 'datasetDocuments' })}</td> - <td className="h-9 border-b border-divider-subtle pr-2 pl-3">{t($ => $['list.batchModal.answer'], { ns: 'datasetDocuments' })}</td> + <td className="h-9 border-b border-divider-subtle pr-2 pl-3"> + {t(($) => $['list.batchModal.question'], { ns: 'datasetDocuments' })} + </td> + <td className="h-9 border-b border-divider-subtle pr-2 pl-3"> + {t(($) => $['list.batchModal.answer'], { ns: 'datasetDocuments' })} + </td> </tr> </thead> <tbody className="text-text-tertiary"> <tr> <td className="h-9 border-b border-divider-subtle pr-2 pl-3 text-[13px]"> - {t($ => $['list.batchModal.question'], { ns: 'datasetDocuments' })} - {' '} - 1 + {t(($) => $['list.batchModal.question'], { ns: 'datasetDocuments' })} 1 </td> <td className="h-9 border-b border-divider-subtle pr-2 pl-3 text-[13px]"> - {t($ => $['list.batchModal.answer'], { ns: 'datasetDocuments' })} - {' '} - 1 + {t(($) => $['list.batchModal.answer'], { ns: 'datasetDocuments' })} 1 </td> </tr> <tr> <td className="h-9 pr-2 pl-3 text-[13px]"> - {t($ => $['list.batchModal.question'], { ns: 'datasetDocuments' })} - {' '} - 2 + {t(($) => $['list.batchModal.question'], { ns: 'datasetDocuments' })} 2 </td> <td className="h-9 pr-2 pl-3 text-[13px]"> - {t($ => $['list.batchModal.answer'], { ns: 'datasetDocuments' })} - {' '} - 2 + {t(($) => $['list.batchModal.answer'], { ns: 'datasetDocuments' })} 2 </td> </tr> </tbody> @@ -91,22 +77,20 @@ const CSVDownload: FC<{ docForm: ChunkingMode }> = ({ docForm }) => { <table className="w-full table-fixed border-separate border-spacing-0 rounded-lg border border-divider-subtle text-xs"> <thead className="text-text-secondary"> <tr> - <td className="h-9 border-b border-divider-subtle pr-2 pl-3">{t($ => $['list.batchModal.contentTitle'], { ns: 'datasetDocuments' })}</td> + <td className="h-9 border-b border-divider-subtle pr-2 pl-3"> + {t(($) => $['list.batchModal.contentTitle'], { ns: 'datasetDocuments' })} + </td> </tr> </thead> <tbody className="text-text-tertiary"> <tr> <td className="h-9 border-b border-divider-subtle pr-2 pl-3 text-[13px]"> - {t($ => $['list.batchModal.content'], { ns: 'datasetDocuments' })} - {' '} - 1 + {t(($) => $['list.batchModal.content'], { ns: 'datasetDocuments' })} 1 </td> </tr> <tr> <td className="h-9 pr-2 pl-3 text-[13px]"> - {t($ => $['list.batchModal.content'], { ns: 'datasetDocuments' })} - {' '} - 2 + {t(($) => $['list.batchModal.content'], { ns: 'datasetDocuments' })} 2 </td> </tr> </tbody> @@ -122,11 +106,10 @@ const CSVDownload: FC<{ docForm: ChunkingMode }> = ({ docForm }) => { > <div className="flex h-[18px] items-center space-x-1 text-xs font-medium text-text-accent"> <DownloadIcon className="mr-1 size-3" /> - {t($ => $['list.batchModal.template'], { ns: 'datasetDocuments' })} + {t(($) => $['list.batchModal.template'], { ns: 'datasetDocuments' })} </div> </CSVDownloader> </div> - ) } export default React.memo(CSVDownload) diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx index 887fe8c0c30b30..672d63b955d58b 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx @@ -27,72 +27,92 @@ const CSVUploader: FC<Props> = ({ file, updateFile }) => { const dragRef = useRef<HTMLDivElement>(null) const fileUploader = useRef<HTMLInputElement>(null) const { data: fileUploadConfigResponse } = useFileUploadConfig() - const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? { - file_size_limit: 15, - }, [fileUploadConfigResponse]) + const fileUploadConfig = useMemo( + () => + fileUploadConfigResponse ?? { + file_size_limit: 15, + }, + [fileUploadConfigResponse], + ) type UploadResult = Awaited<ReturnType<typeof upload>> - const fileUpload = useCallback(async (fileItem: FileItem): Promise<FileItem> => { - fileItem.progress = 0 - const formData = new FormData() - formData.append('file', fileItem.file) - const onProgress = (e: ProgressEvent) => { - if (e.lengthComputable) { - const progress = Math.floor(e.loaded / e.total * 100) - updateFile({ - ...fileItem, - progress, + const fileUpload = useCallback( + async (fileItem: FileItem): Promise<FileItem> => { + fileItem.progress = 0 + const formData = new FormData() + formData.append('file', fileItem.file) + const onProgress = (e: ProgressEvent) => { + if (e.lengthComputable) { + const progress = Math.floor((e.loaded / e.total) * 100) + updateFile({ + ...fileItem, + progress, + }) + } + } + return upload( + { + xhr: new XMLHttpRequest(), + data: formData, + onprogress: onProgress, + }, + false, + undefined, + '?source=datasets', + ) + .then((res: UploadResult) => { + const updatedFile = Object.assign({}, fileItem.file, { + id: res.id, + ...(res as Partial<File>), + }) as File + const completeFile: FileItem = { + fileID: fileItem.fileID, + file: updatedFile, + progress: 100, + } + updateFile(completeFile) + return Promise.resolve({ ...completeFile }) + }) + .catch((e) => { + const errorMessage = getFileUploadErrorMessage( + e, + t(($) => $['stepOne.uploader.failed'], { ns: 'datasetCreation' }), + t, + ) + toast.error(errorMessage) + const errorFile = { + ...fileItem, + progress: -2, + } + updateFile(errorFile) + return Promise.resolve({ ...errorFile }) }) + .finally() + }, + [t, updateFile], + ) + const uploadFile = useCallback( + async (fileItem: FileItem) => { + await fileUpload(fileItem) + }, + [fileUpload], + ) + const initialUpload = useCallback( + (file?: File) => { + if (!file) return false + const newFile: FileItem = { + fileID: `file0-${Date.now()}`, + file, + progress: -1, } - } - return upload({ - xhr: new XMLHttpRequest(), - data: formData, - onprogress: onProgress, - }, false, undefined, '?source=datasets') - .then((res: UploadResult) => { - const updatedFile = Object.assign({}, fileItem.file, { - id: res.id, - ...(res as Partial<File>), - }) as File - const completeFile: FileItem = { - fileID: fileItem.fileID, - file: updatedFile, - progress: 100, - } - updateFile(completeFile) - return Promise.resolve({ ...completeFile }) - }) - .catch((e) => { - const errorMessage = getFileUploadErrorMessage(e, t($ => $['stepOne.uploader.failed'], { ns: 'datasetCreation' }), t) - toast.error(errorMessage) - const errorFile = { - ...fileItem, - progress: -2, - } - updateFile(errorFile) - return Promise.resolve({ ...errorFile }) - }) - .finally() - }, [t, updateFile]) - const uploadFile = useCallback(async (fileItem: FileItem) => { - await fileUpload(fileItem) - }, [fileUpload]) - const initialUpload = useCallback((file?: File) => { - if (!file) - return false - const newFile: FileItem = { - fileID: `file0-${Date.now()}`, - file, - progress: -1, - } - updateFile(newFile) - uploadFile(newFile) - }, [updateFile, uploadFile]) + updateFile(newFile) + uploadFile(newFile) + }, + [updateFile, uploadFile], + ) const handleDragEnter = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.target !== dragRef.current) - setDragging(true) + if (e.target !== dragRef.current) setDragging(true) } const handleDragOver = (e: DragEvent) => { e.preventDefault() @@ -101,58 +121,59 @@ const CSVUploader: FC<Props> = ({ file, updateFile }) => { const handleDragLeave = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.target === dragRef.current) - setDragging(false) + if (e.target === dragRef.current) setDragging(false) } const handleDrop = (e: DragEvent) => { e.preventDefault() e.stopPropagation() setDragging(false) - if (!e.dataTransfer) - return + if (!e.dataTransfer) return const files = Array.from(e.dataTransfer.files) if (files.length > 1) { - toast.error(t($ => $['stepOne.uploader.validation.count'], { ns: 'datasetCreation' })) + toast.error(t(($) => $['stepOne.uploader.validation.count'], { ns: 'datasetCreation' })) return } initialUpload(files[0]) } const selectHandle = () => { - if (fileUploader.current) - fileUploader.current.click() + if (fileUploader.current) fileUploader.current.click() } const removeFile = () => { - if (fileUploader.current) - fileUploader.current.value = '' + if (fileUploader.current) fileUploader.current.value = '' updateFile() } const getFileType = (currentFile: File) => { - if (!currentFile) - return '' + if (!currentFile) return '' const arr = currentFile.name.split('.') return arr[arr.length - 1] } - const isValid = useCallback((file?: File) => { - if (!file) - return false - const { size } = file - const ext = `.${getFileType(file)}` - const isValidType = ext.toLowerCase() === '.csv' - if (!isValidType) - toast.error(t($ => $['stepOne.uploader.validation.typeError'], { ns: 'datasetCreation' })) - const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024 - if (!isValidSize) - toast.error(t($ => $['stepOne.uploader.validation.size'], { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit })) - return isValidType && isValidSize - }, [fileUploadConfig, t]) + const isValid = useCallback( + (file?: File) => { + if (!file) return false + const { size } = file + const ext = `.${getFileType(file)}` + const isValidType = ext.toLowerCase() === '.csv' + if (!isValidType) + toast.error(t(($) => $['stepOne.uploader.validation.typeError'], { ns: 'datasetCreation' })) + const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024 + if (!isValidSize) + toast.error( + t(($) => $['stepOne.uploader.validation.size'], { + ns: 'datasetCreation', + size: fileUploadConfig.file_size_limit, + }), + ) + return isValidType && isValidSize + }, + [fileUploadConfig, t], + ) const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => { const currentFile = e.target.files?.[0] - if (!isValid(currentFile)) - return + if (!isValid(currentFile)) return initialUpload(currentFile) } const { theme } = useTheme() - const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme]) + const chartColor = useMemo(() => (theme === Theme.dark ? '#5289ff' : '#296dff'), [theme]) useEffect(() => { dropRef.current?.addEventListener('dragenter', handleDragEnter) dropRef.current?.addEventListener('dragover', handleDragOver) @@ -167,20 +188,32 @@ const CSVUploader: FC<Props> = ({ file, updateFile }) => { }, []) return ( <div className="mt-6"> - <input ref={fileUploader} style={{ display: 'none' }} type="file" id="fileUploader" accept=".csv" onChange={fileChangeHandle} /> + <input + ref={fileUploader} + style={{ display: 'none' }} + type="file" + id="fileUploader" + accept=".csv" + onChange={fileChangeHandle} + /> <div ref={dropRef}> {!file && ( - <div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur text-sm font-normal', dragging && 'border border-divider-subtle bg-components-panel-on-panel-item-bg-hover')}> + <div + className={cn( + 'flex h-20 items-center rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur text-sm font-normal', + dragging && 'border border-divider-subtle bg-components-panel-on-panel-item-bg-hover', + )} + > <div className="flex w-full items-center justify-center space-x-2"> <CSVIcon className="shrink-0" /> <div className="text-text-secondary"> - {t($ => $['list.batchModal.csvUploadTitle'], { ns: 'datasetDocuments' })} + {t(($) => $['list.batchModal.csvUploadTitle'], { ns: 'datasetDocuments' })} <button type="button" className="inline cursor-pointer border-none bg-transparent p-0 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={selectHandle} > - {t($ => $['list.batchModal.browse'], { ns: 'datasetDocuments' })} + {t(($) => $['list.batchModal.browse'], { ns: 'datasetDocuments' })} </button> </div> </div> @@ -188,25 +221,39 @@ const CSVUploader: FC<Props> = ({ file, updateFile }) => { </div> )} {file && ( - <div className={cn('group flex h-20 items-center rounded-xl border border-components-panel-border bg-components-panel-bg-blur px-6 text-sm font-normal', 'hover:border-divider-subtle hover:bg-components-panel-on-panel-item-bg-hover')}> + <div + className={cn( + 'group flex h-20 items-center rounded-xl border border-components-panel-border bg-components-panel-bg-blur px-6 text-sm font-normal', + 'hover:border-divider-subtle hover:bg-components-panel-on-panel-item-bg-hover', + )} + > <CSVIcon className="shrink-0" /> <div className="ml-2 flex w-0 grow"> - <span className="max-w-[calc(100%-30px)] overflow-hidden text-ellipsis whitespace-nowrap text-text-primary">{file.file.name.replace(/.csv$/, '')}</span> + <span className="max-w-[calc(100%-30px)] overflow-hidden text-ellipsis whitespace-nowrap text-text-primary"> + {file.file.name.replace(/.csv$/, '')} + </span> <span className="shrink-0 text-text-secondary">.csv</span> </div> <div className="hidden items-center group-hover:flex"> - {(file.progress < 100 && file.progress >= 0) && ( + {file.progress < 100 && file.progress >= 0 && ( <> - <SimplePieChart percentage={file.progress} stroke={chartColor} fill={chartColor} animationDuration={0} /> + <SimplePieChart + percentage={file.progress} + stroke={chartColor} + fill={chartColor} + animationDuration={0} + /> <div className="mx-2 h-4 w-px bg-text-secondary" /> </> )} - <Button onClick={selectHandle}>{t($ => $['stepOne.uploader.change'], { ns: 'datasetCreation' })}</Button> + <Button onClick={selectHandle}> + {t(($) => $['stepOne.uploader.change'], { ns: 'datasetCreation' })} + </Button> <div className="mx-2 h-4 w-px bg-text-secondary" /> <button type="button" className="cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.delete'], { ns: 'common' })} + aria-label={t(($) => $['operation.delete'], { ns: 'common' })} onClick={removeFile} > <RiDeleteBinLine className="size-4 text-text-secondary" aria-hidden="true" /> diff --git a/web/app/components/datasets/documents/detail/batch-modal/index.tsx b/web/app/components/datasets/documents/detail/batch-modal/index.tsx index 6327831376e0ce..863a5745a3bae7 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/index.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/index.tsx @@ -2,12 +2,7 @@ import type { FC } from 'react' import type { ChunkingMode, FileItem } from '@/models/datasets' import { Button } from '@langgenius/dify-ui/button' -import { - Dialog, - DialogCloseButton, - DialogContent, - DialogTitle, -} from '@langgenius/dify-ui/dialog' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -23,69 +18,50 @@ type IBatchModalProps = { type BatchModalContentProps = Omit<IBatchModalProps, 'isShow'> -const BatchModalContent: FC<BatchModalContentProps> = ({ - docForm, - onCancel, - onConfirm, -}) => { +const BatchModalContent: FC<BatchModalContentProps> = ({ docForm, onCancel, onConfirm }) => { const { t } = useTranslation() const [currentCSV, setCurrentCSV] = useState<FileItem>() const handleFile = (file?: FileItem) => setCurrentCSV(file) const handleSend = () => { - if (!currentCSV) - return + if (!currentCSV) return onCancel() onConfirm(currentCSV) } return ( <DialogContent className="w-[520px]! overflow-hidden! rounded-xl! border-0! px-8 py-6"> - <DialogTitle className="relative pb-1 text-xl leading-[30px] font-medium text-text-primary">{t($ => $['list.batchModal.title'], { ns: 'datasetDocuments' })}</DialogTitle> + <DialogTitle className="relative pb-1 text-xl leading-[30px] font-medium text-text-primary"> + {t(($) => $['list.batchModal.title'], { ns: 'datasetDocuments' })} + </DialogTitle> <DialogCloseButton className="top-4 right-4" - aria-label={t($ => $['list.batchModal.cancel'], { ns: 'datasetDocuments' })} - /> - <CSVUploader - file={currentCSV} - updateFile={handleFile} - /> - <CSVDownloader - docForm={docForm} + aria-label={t(($) => $['list.batchModal.cancel'], { ns: 'datasetDocuments' })} /> + <CSVUploader file={currentCSV} updateFile={handleFile} /> + <CSVDownloader docForm={docForm} /> <div className="mt-[28px] flex justify-end pt-6"> <Button className="mr-2" onClick={onCancel}> - {t($ => $['list.batchModal.cancel'], { ns: 'datasetDocuments' })} + {t(($) => $['list.batchModal.cancel'], { ns: 'datasetDocuments' })} </Button> - <Button variant="primary" onClick={handleSend} disabled={!currentCSV || !currentCSV.file || !currentCSV.file.id}> - {t($ => $['list.batchModal.run'], { ns: 'datasetDocuments' })} + <Button + variant="primary" + onClick={handleSend} + disabled={!currentCSV || !currentCSV.file || !currentCSV.file.id} + > + {t(($) => $['list.batchModal.run'], { ns: 'datasetDocuments' })} </Button> </div> </DialogContent> ) } -const BatchModal: FC<IBatchModalProps> = ({ - isShow, - docForm, - onCancel, - onConfirm, -}) => { +const BatchModal: FC<IBatchModalProps> = ({ isShow, docForm, onCancel, onConfirm }) => { return ( - <Dialog - open={isShow} - onOpenChange={open => !open && onCancel()} - disablePointerDismissal - > - {isShow - ? ( - <BatchModalContent - docForm={docForm} - onCancel={onCancel} - onConfirm={onConfirm} - /> - ) - : null} + <Dialog open={isShow} onOpenChange={(open) => !open && onCancel()} disablePointerDismissal> + {isShow ? ( + <BatchModalContent docForm={docForm} onCancel={onCancel} onConfirm={onConfirm} /> + ) : null} </Dialog> ) } diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx index a7de0258c035bd..a207f6eb4c7600 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx @@ -1,14 +1,15 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' - import ChildSegmentDetail from '../child-segment-detail' // Mock segment list context let mockFullScreen = false const mockToggleFullScreen = vi.fn() vi.mock('../index', () => ({ - useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => { + useSegmentListContext: ( + selector: (state: { fullScreen: boolean; toggleFullScreen: () => void }) => unknown, + ) => { const state = { fullScreen: mockFullScreen, toggleFullScreen: mockToggleFullScreen, @@ -30,22 +31,44 @@ vi.mock('@/context/event-emitter', () => ({ })) vi.mock('../common/action-buttons', () => ({ - default: ({ handleCancel, handleSave, loading, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, isChildChunk?: boolean }) => ( + default: ({ + handleCancel, + handleSave, + loading, + isChildChunk, + }: { + handleCancel: () => void + handleSave: () => void + loading: boolean + isChildChunk?: boolean + }) => ( <div data-testid="action-buttons"> - <button onClick={handleCancel} data-testid="cancel-btn">Cancel</button> - <button onClick={handleSave} disabled={loading} data-testid="save-btn">Save</button> + <button onClick={handleCancel} data-testid="cancel-btn"> + Cancel + </button> + <button onClick={handleSave} disabled={loading} data-testid="save-btn"> + Save + </button> <span data-testid="is-child-chunk">{isChildChunk ? 'true' : 'false'}</span> </div> ), })) vi.mock('../common/chunk-content', () => ({ - default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => ( + default: ({ + question, + onQuestionChange, + isEditMode, + }: { + question: string + onQuestionChange: (v: string) => void + isEditMode: boolean + }) => ( <div data-testid="chunk-content"> <input data-testid="content-input" value={question} - onChange={e => onQuestionChange(e.target.value)} + onChange={(e) => onQuestionChange(e.target.value)} /> <span data-testid="edit-mode">{isEditMode ? 'editing' : 'viewing'}</span> </div> @@ -57,11 +80,9 @@ vi.mock('../common/dot', () => ({ })) vi.mock('../common/segment-index-tag', () => ({ - SegmentIndexTag: ({ positionId, labelPrefix }: { positionId?: string, labelPrefix?: string }) => ( + SegmentIndexTag: ({ positionId, labelPrefix }: { positionId?: string; labelPrefix?: string }) => ( <span data-testid="segment-index-tag"> - {labelPrefix} - {' '} - {positionId} + {labelPrefix} {positionId} </span> ), })) @@ -129,9 +150,7 @@ describe('ChildSegmentDetail', () => { describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { const mockOnCancel = vi.fn() - render( - <ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />, - ) + render(<ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />) fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) @@ -152,11 +171,7 @@ describe('ChildSegmentDetail', () => { fireEvent.click(screen.getByTestId('save-btn')) - expect(mockOnUpdate).toHaveBeenCalledWith( - 'chunk-1', - 'child-chunk-1', - 'Test content', - ) + expect(mockOnUpdate).toHaveBeenCalledWith('chunk-1', 'child-chunk-1', 'Test content') }) it('should update content when input changes', () => { diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-list.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-list.spec.tsx index 11ced823da6300..5fb6fbf72b95f1 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-list.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-list.spec.tsx @@ -1,7 +1,6 @@ import type { ChildChunkDetail } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import ChildSegmentList from '../child-segment-list' // Mock document context @@ -15,7 +14,9 @@ vi.mock('../../context', () => ({ // Mock segment list context let mockCurrChildChunk: { childChunkInfo: { id: string } } | null = null vi.mock('../index', () => ({ - useSegmentListContext: (selector: (state: { currChildChunk: { childChunkInfo: { id: string } } | null }) => unknown) => { + useSegmentListContext: ( + selector: (state: { currChildChunk: { childChunkInfo: { id: string } } | null }) => unknown, + ) => { return selector({ currChildChunk: mockCurrChildChunk }) }, })) @@ -23,7 +24,9 @@ vi.mock('../index', () => ({ vi.mock('../common/empty', () => ({ default: ({ onClearFilter }: { onClearFilter: () => void }) => ( <div data-testid="empty"> - <button onClick={onClearFilter} data-testid="clear-filter-btn">Clear Filter</button> + <button onClick={onClearFilter} data-testid="clear-filter-btn"> + Clear Filter + </button> </div> ), })) @@ -53,17 +56,25 @@ vi.mock('../../../../formatted-text/flavours/edit-slice', () => ({ offsetOptions: unknown }) => ( <div data-testid="edit-slice" className={className}> - <span data-testid="slice-label" className={labelClassName}>{label}</span> + <span data-testid="slice-label" className={labelClassName}> + {label} + </span> <span data-testid="slice-text">{text}</span> - <button data-testid="delete-slice-btn" onClick={onDelete}>Delete</button> - <button data-testid="click-slice-btn" onClick={e => onClick(e)}>Click</button> + <button data-testid="delete-slice-btn" onClick={onDelete}> + Delete + </button> + <button data-testid="click-slice-btn" onClick={(e) => onClick(e)}> + Click + </button> </div> ), })) vi.mock('../../../../formatted-text/formatted', () => ({ - FormattedText: ({ children, className }: { children: React.ReactNode, className: string }) => ( - <div data-testid="formatted-text" className={className}>{children}</div> + FormattedText: ({ children, className }: { children: React.ReactNode; className: string }) => ( + <div data-testid="formatted-text" className={className}> + {children} + </div> ), })) @@ -138,8 +149,7 @@ describe('ChildSegmentList', () => { // Act - click on the collapse toggle const toggleArea = screen.getByText(/segment\.childChunks/i).closest('div') - if (toggleArea) - fireEvent.click(toggleArea) + if (toggleArea) fireEvent.click(toggleArea) // Assert - child chunks should be visible expect(screen.getByTestId('formatted-text')).toBeInTheDocument() @@ -219,7 +229,9 @@ describe('ChildSegmentList', () => { it('should call handleAddNewChildChunk when add button is clicked', () => { mockParentMode = 'full-doc' const mockHandleAddNewChildChunk = vi.fn() - render(<ChildSegmentList {...defaultProps} handleAddNewChildChunk={mockHandleAddNewChildChunk} />) + render( + <ChildSegmentList {...defaultProps} handleAddNewChildChunk={mockHandleAddNewChildChunk} />, + ) fireEvent.click(screen.getByText(/operation\.add/i)) @@ -249,7 +261,14 @@ describe('ChildSegmentList', () => { it('should call onClearFilter when clear filter button is clicked', () => { mockParentMode = 'full-doc' const mockOnClearFilter = vi.fn() - render(<ChildSegmentList {...defaultProps} inputValue="search" childChunks={[]} onClearFilter={mockOnClearFilter} />) + render( + <ChildSegmentList + {...defaultProps} + inputValue="search" + childChunks={[]} + onClearFilter={mockOnClearFilter} + />, + ) fireEvent.click(screen.getByTestId('clear-filter-btn')) @@ -298,7 +317,9 @@ describe('ChildSegmentList', () => { }) it('should not apply opacity when focused is true even if enabled is false', () => { - const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} focused={true} />) + const { container } = render( + <ChildSegmentList {...defaultProps} enabled={false} focused={true} />, + ) const wrapper = container.firstChild as HTMLElement expect(wrapper).not.toHaveClass('opacity-50') diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/display-toggle.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/display-toggle.spec.tsx index 3444e05a47927d..fc2787d02df4e5 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/display-toggle.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/display-toggle.spec.tsx @@ -27,9 +27,7 @@ describe('DisplayToggle', () => { describe('Props', () => { it('should render expand icon when isCollapsed is true', () => { - const { container } = render( - <DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />, - ) + const { container } = render(<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />) // Assert - RiLineHeight icon for expand const icon = container.querySelector('.size-4') @@ -37,9 +35,7 @@ describe('DisplayToggle', () => { }) it('should render collapse icon when isCollapsed is false', () => { - const { container } = render( - <DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />, - ) + const { container } = render(<DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />) // Assert - Collapse icon const icon = container.querySelector('.size-4') @@ -73,9 +69,7 @@ describe('DisplayToggle', () => { // Tooltip tests describe('Tooltip', () => { it('should render with tooltip wrapper', () => { - const { container } = render( - <DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />, - ) + const { container } = render(<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />) // Assert - Tooltip renders a wrapper around button expect(container.firstChild).toBeInTheDocument() @@ -96,9 +90,7 @@ describe('DisplayToggle', () => { }) it('should maintain structure when rerendered', () => { - const { rerender } = render( - <DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />, - ) + const { rerender } = render(<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />) rerender(<DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx index 55986304d44ffb..e05f829d75adcb 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx @@ -1,5 +1,10 @@ import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context' -import type { ChildChunkDetail, ChunkingMode, ParentMode, SegmentDetailModel } from '@/models/datasets' +import type { + ChildChunkDetail, + ChunkingMode, + ParentMode, + SegmentDetailModel, +} from '@/models/datasets' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' @@ -106,12 +111,9 @@ vi.mock('@/service/knowledge/use-segment', () => ({ vi.mock('@/service/use-base', () => ({ useInvalid: (key: unknown[]) => { const keyStr = JSON.stringify(key) - if (keyStr.includes('"enabled":"all"')) - return mockInvalidChunkListAll - if (keyStr.includes('"enabled":true')) - return mockInvalidChunkListEnabled - if (keyStr.includes('"enabled":false')) - return mockInvalidChunkListDisabled + if (keyStr.includes('"enabled":"all"')) return mockInvalidChunkListAll + if (keyStr.includes('"enabled":true')) return mockInvalidChunkListEnabled + if (keyStr.includes('"enabled":false')) return mockInvalidChunkListDisabled return vi.fn() }, })) @@ -141,30 +143,59 @@ vi.mock('../components/menu-bar', async () => { const { Checkbox } = await import('@langgenius/dify-ui/checkbox') return { - default: ({ hasSelectableSegments, totalText, onInputChange, inputValue, isLoading, onChangeStatus }: { + default: ({ + hasSelectableSegments, + totalText, + onInputChange, + inputValue, + isLoading, + onChangeStatus, + }: { hasSelectableSegments: boolean totalText: string onInputChange: (value: string) => void inputValue: string isLoading: boolean - onChangeStatus?: (item: { value: string | number, name: string }) => void + onChangeStatus?: (item: { value: string | number; name: string }) => void }) => ( <div data-testid="menu-bar"> <span data-testid="total-text">{totalText}</span> <input data-testid="search-input" value={inputValue} - onChange={e => onInputChange(e.target.value)} + onChange={(e) => onInputChange(e.target.value)} disabled={isLoading} /> - {hasSelectableSegments - ? <Checkbox parent data-testid="select-all-button" aria-label="Select All" disabled={isLoading} /> - : <span data-testid="select-all-spacer" aria-hidden />} + {hasSelectableSegments ? ( + <Checkbox + parent + data-testid="select-all-button" + aria-label="Select All" + disabled={isLoading} + /> + ) : ( + <span data-testid="select-all-spacer" aria-hidden /> + )} {onChangeStatus && ( <> - <button data-testid="status-enabled" onClick={() => onChangeStatus({ value: 1, name: 'Enabled' })}>Enabled</button> - <button data-testid="status-disabled" onClick={() => onChangeStatus({ value: 0, name: 'Disabled' })}>Disabled</button> - <button data-testid="status-all" onClick={() => onChangeStatus({ value: 'all', name: 'All' })}>All</button> + <button + data-testid="status-enabled" + onClick={() => onChangeStatus({ value: 1, name: 'Enabled' })} + > + Enabled + </button> + <button + data-testid="status-disabled" + onClick={() => onChangeStatus({ value: 0, name: 'Disabled' })} + > + Disabled + </button> + <button + data-testid="status-all" + onClick={() => onChangeStatus({ value: 'all', name: 'All' })} + > + All + </button> </> )} </div> @@ -182,7 +213,13 @@ vi.mock('../components/segment-list-content', () => ({ })) vi.mock('../common/batch-action', () => ({ - default: ({ selectedIds, onCancel, onBatchEnable, onBatchDisable, onBatchDelete }: { + default: ({ + selectedIds, + onCancel, + onBatchEnable, + onBatchDisable, + onBatchDelete, + }: { selectedIds: string[] onCancel: () => void onBatchEnable: () => void @@ -191,10 +228,18 @@ vi.mock('../common/batch-action', () => ({ }) => ( <div data-testid="batch-action"> <span data-testid="selected-count">{selectedIds.length}</span> - <button data-testid="cancel-batch" onClick={onCancel}>Cancel</button> - <button data-testid="batch-enable" onClick={onBatchEnable}>Enable</button> - <button data-testid="batch-disable" onClick={onBatchDisable}>Disable</button> - <button data-testid="batch-delete" onClick={onBatchDelete}>Delete</button> + <button data-testid="cancel-batch" onClick={onCancel}> + Cancel + </button> + <button data-testid="batch-enable" onClick={onBatchEnable}> + Enable + </button> + <button data-testid="batch-disable" onClick={onBatchDisable}> + Disable + </button> + <button data-testid="batch-delete" onClick={onBatchDelete}> + Delete + </button> </div> ), })) @@ -204,7 +249,12 @@ vi.mock('@/app/components/base/divider', () => ({ })) vi.mock('@langgenius/dify-ui/pagination', () => ({ - Pagination: ({ page, totalPages, onPageChange, pageSize }: { + Pagination: ({ + page, + totalPages, + onPageChange, + pageSize, + }: { page: number totalPages: number onPageChange: (page: number) => void @@ -215,15 +265,21 @@ vi.mock('@langgenius/dify-ui/pagination', () => ({ <div data-testid="pagination"> <span data-testid="current-page">{page - 1}</span> <span data-testid="total-pages">{totalPages}</span> - <button data-testid="next-page" onClick={() => onPageChange(page + 1)}>Next</button> + <button data-testid="next-page" onClick={() => onPageChange(page + 1)}> + Next + </button> {pageSize && ( - <button data-testid="change-limit" onClick={() => pageSize.onValueChange(20)}>Change Limit</button> + <button data-testid="change-limit" onClick={() => pageSize.onValueChange(20)}> + Change Limit + </button> )} </div> ), })) -const createMockSegmentDetail = (overrides: Partial<SegmentDetailModel> = {}): SegmentDetailModel => ({ +const createMockSegmentDetail = ( + overrides: Partial<SegmentDetailModel> = {}, +): SegmentDetailModel => ({ id: `segment-${Math.random().toString(36).substr(2, 9)}`, position: 1, document_id: 'doc-1', @@ -263,19 +319,18 @@ const _createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): Child ...overrides, }) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, -}) +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) const createWrapper = () => { const queryClient = createQueryClient() return ({ children }: { children: React.ReactNode }) => ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) } @@ -283,10 +338,10 @@ describe('SegmentListContext', () => { describe('Default Values', () => { it('should have correct default context values', () => { const TestComponent = () => { - const isCollapsed = useSegmentListContext(s => s.isCollapsed) - const fullScreen = useSegmentListContext(s => s.fullScreen) - const currSegment = useSegmentListContext(s => s.currSegment) - const currChildChunk = useSegmentListContext(s => s.currChildChunk) + const isCollapsed = useSegmentListContext((s) => s.isCollapsed) + const fullScreen = useSegmentListContext((s) => s.fullScreen) + const currSegment = useSegmentListContext((s) => s.currSegment) + const currChildChunk = useSegmentListContext((s) => s.currChildChunk) return ( <div> @@ -318,9 +373,9 @@ describe('SegmentListContext', () => { } const TestComponent = () => { - const isCollapsed = useSegmentListContext(s => s.isCollapsed) - const fullScreen = useSegmentListContext(s => s.fullScreen) - const currSegment = useSegmentListContext(s => s.currSegment) + const isCollapsed = useSegmentListContext((s) => s.isCollapsed) + const fullScreen = useSegmentListContext((s) => s.fullScreen) + const currSegment = useSegmentListContext((s) => s.currSegment) return ( <div> @@ -346,8 +401,8 @@ describe('SegmentListContext', () => { describe('Selector Optimization', () => { it('should select specific values from context', () => { const TestComponent = () => { - const isCollapsed = useSegmentListContext(s => s.isCollapsed) - const fullScreen = useSegmentListContext(s => s.fullScreen) + const isCollapsed = useSegmentListContext((s) => s.isCollapsed) + const fullScreen = useSegmentListContext((s) => s.fullScreen) return ( <div> <span data-testid="isCollapsed">{String(isCollapsed)}</span> @@ -357,13 +412,14 @@ describe('SegmentListContext', () => { } const { rerender } = render( - <SegmentListContext.Provider value={{ - isCollapsed: true, - fullScreen: false, - toggleFullScreen: vi.fn(), - currSegment: { showModal: false }, - currChildChunk: { showModal: false }, - }} + <SegmentListContext.Provider + value={{ + isCollapsed: true, + fullScreen: false, + toggleFullScreen: vi.fn(), + currSegment: { showModal: false }, + currChildChunk: { showModal: false }, + }} > <TestComponent /> </SegmentListContext.Provider>, @@ -374,13 +430,14 @@ describe('SegmentListContext', () => { // Rerender with changed values rerender( - <SegmentListContext.Provider value={{ - isCollapsed: false, - fullScreen: true, - toggleFullScreen: vi.fn(), - currSegment: { showModal: false }, - currChildChunk: { showModal: false }, - }} + <SegmentListContext.Provider + value={{ + isCollapsed: false, + fullScreen: true, + toggleFullScreen: vi.fn(), + currSegment: { showModal: false }, + currChildChunk: { showModal: false }, + }} > <TestComponent /> </SegmentListContext.Provider>, @@ -513,13 +570,17 @@ describe('Completed Component', () => { }) it('should handle embeddingAvailable prop', () => { - render(<Completed {...defaultProps} embeddingAvailable={false} />, { wrapper: createWrapper() }) + render(<Completed {...defaultProps} embeddingAvailable={false} />, { + wrapper: createWrapper(), + }) expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument() }) it('should handle showNewSegmentModal prop', () => { - render(<Completed {...defaultProps} showNewSegmentModal={true} />, { wrapper: createWrapper() }) + render(<Completed {...defaultProps} showNewSegmentModal={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByTestId('drawer-group'))!.toBeInTheDocument() }) @@ -856,8 +917,7 @@ describe('refreshChunkListDataWithDetailChanged callback', () => { render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) // Call the captured callback - status is 'all' by default - if (capturedRefreshCallback) - capturedRefreshCallback() + if (capturedRefreshCallback) capturedRefreshCallback() // With status 'all', should call both disabled and enabled invalidation expect(mockInvalidChunkListDisabled).toHaveBeenCalled() @@ -897,8 +957,7 @@ describe('refreshChunkListDataWithDetailChanged callback', () => { }) // Call the callback with status 'true' - if (capturedRefreshCallback) - capturedRefreshCallback() + if (capturedRefreshCallback) capturedRefreshCallback() // With status true, should call all and disabled invalidation expect(mockInvalidChunkListAll).toHaveBeenCalled() @@ -923,8 +982,7 @@ describe('refreshChunkListDataWithDetailChanged callback', () => { }) // Call the callback with status 'false' - if (capturedRefreshCallback) - capturedRefreshCallback() + if (capturedRefreshCallback) capturedRefreshCallback() // With status false, should call all and enabled invalidation expect(mockInvalidChunkListAll).toHaveBeenCalled() @@ -1062,10 +1120,9 @@ describe('Inline callback and hook initialization coverage', () => { // Covers lines 61-63: useModalState({ onNewSegmentModalChange }) it('should pass onNewSegmentModalChange to modal state hook', () => { const mockOnChange = vi.fn() - render( - <Completed {...defaultProps} onNewSegmentModalChange={mockOnChange} />, - { wrapper: createWrapper() }, - ) + render(<Completed {...defaultProps} onNewSegmentModalChange={mockOnChange} />, { + wrapper: createWrapper(), + }) expect(screen.getByTestId('drawer-group'))!.toBeInTheDocument() }) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx index 082dfb6ab73f71..313b693b001c8c 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx @@ -1,7 +1,6 @@ import { toast, ToastHost } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import NewChildSegmentModal from '../new-child-segment' vi.mock('@/next/navigation', () => ({ @@ -26,7 +25,9 @@ vi.mock('../../context', () => ({ let mockFullScreen = false const mockToggleFullScreen = vi.fn() vi.mock('../index', () => ({ - useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => { + useSegmentListContext: ( + selector: (state: { fullScreen: boolean; toggleFullScreen: () => void }) => unknown, + ) => { const state = { fullScreen: mockFullScreen, toggleFullScreen: mockToggleFullScreen, @@ -44,9 +45,23 @@ vi.mock('@/service/knowledge/use-segment', () => ({ })) vi.mock('../common/action-buttons', () => ({ - default: ({ handleCancel, handleSave, loading, actionType, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string, isChildChunk?: boolean }) => ( + default: ({ + handleCancel, + handleSave, + loading, + actionType, + isChildChunk, + }: { + handleCancel: () => void + handleSave: () => void + loading: boolean + actionType: string + isChildChunk?: boolean + }) => ( <div data-testid="action-buttons"> - <button onClick={handleCancel} data-testid="cancel-btn">Cancel</button> + <button onClick={handleCancel} data-testid="cancel-btn"> + Cancel + </button> <button onClick={handleSave} disabled={loading} data-testid="save-btn"> {loading ? 'Saving...' : 'Save'} </button> @@ -57,12 +72,20 @@ vi.mock('../common/action-buttons', () => ({ })) vi.mock('../common/add-another', () => ({ - default: ({ checked, onCheckedChange, className }: { checked: boolean, onCheckedChange: (checked: boolean) => void, className?: string }) => ( + default: ({ + checked, + onCheckedChange, + className, + }: { + checked: boolean + onCheckedChange: (checked: boolean) => void + className?: string + }) => ( <label className={className}> <input type="checkbox" checked={checked} - onChange={event => onCheckedChange(event.currentTarget.checked)} + onChange={(event) => onCheckedChange(event.currentTarget.checked)} /> datasetDocuments.segment.addAnother </label> @@ -70,12 +93,20 @@ vi.mock('../common/add-another', () => ({ })) vi.mock('../common/chunk-content', () => ({ - default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => ( + default: ({ + question, + onQuestionChange, + isEditMode, + }: { + question: string + onQuestionChange: (v: string) => void + isEditMode: boolean + }) => ( <div data-testid="chunk-content"> <input data-testid="content-input" value={question} - onChange={e => onQuestionChange(e.target.value)} + onChange={(e) => onQuestionChange(e.target.value)} /> <span data-testid="edit-mode">{isEditMode ? 'editing' : 'viewing'}</span> </div> @@ -87,7 +118,9 @@ vi.mock('../common/dot', () => ({ })) vi.mock('../common/segment-index-tag', () => ({ - SegmentIndexTag: ({ label }: { label: string }) => <span data-testid="segment-index-tag">{label}</span>, + SegmentIndexTag: ({ label }: { label: string }) => ( + <span data-testid="segment-index-tag">{label}</span> + ), })) describe('NewChildSegmentModal', () => { @@ -134,16 +167,16 @@ describe('NewChildSegmentModal', () => { it('should render add another checkbox', () => { render(<NewChildSegmentModal {...defaultProps} />) - expect(screen.getByRole('checkbox', { name: 'datasetDocuments.segment.addAnother' }))!.toBeInTheDocument() + expect( + screen.getByRole('checkbox', { name: 'datasetDocuments.segment.addAnother' }), + )!.toBeInTheDocument() }) }) describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { const mockOnCancel = vi.fn() - render( - <NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />, - ) + render(<NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />) fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) @@ -257,7 +290,9 @@ describe('NewChildSegmentModal', () => { render(<NewChildSegmentModal {...defaultProps} />) - expect(screen.getByRole('checkbox', { name: 'datasetDocuments.segment.addAnother' }))!.toBeInTheDocument() + expect( + screen.getByRole('checkbox', { name: 'datasetDocuments.segment.addAnother' }), + )!.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx index 8ac57759cdaf0d..8b8682d83a6fe5 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx @@ -2,14 +2,15 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode } from '@/models/datasets' - import { SegmentDetail } from '../segment-detail' // Mock dataset detail context let mockIndexingTechnique = IndexingType.QUALIFIED let mockRuntimeMode = 'general' vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: { indexing_technique: string, runtime_mode: string } }) => unknown) => { + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: { indexing_technique: string; runtime_mode: string } }) => unknown, + ) => { return selector({ dataset: { indexing_technique: mockIndexingTechnique, @@ -31,7 +32,9 @@ vi.mock('../../context', () => ({ let mockFullScreen = false const mockToggleFullScreen = vi.fn() vi.mock('../index', () => ({ - useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => { + useSegmentListContext: ( + selector: (state: { fullScreen: boolean; toggleFullScreen: () => void }) => unknown, + ) => { const state = { fullScreen: mockFullScreen, toggleFullScreen: mockToggleFullScreen, @@ -50,30 +53,62 @@ vi.mock('@/context/event-emitter', () => ({ })) vi.mock('../common/action-buttons', () => ({ - default: ({ handleCancel, handleSave, handleRegeneration, loading, showRegenerationButton }: { handleCancel: () => void, handleSave: () => void, handleRegeneration?: () => void, loading: boolean, showRegenerationButton?: boolean }) => ( + default: ({ + handleCancel, + handleSave, + handleRegeneration, + loading, + showRegenerationButton, + }: { + handleCancel: () => void + handleSave: () => void + handleRegeneration?: () => void + loading: boolean + showRegenerationButton?: boolean + }) => ( <div data-testid="action-buttons"> - <button onClick={handleCancel} data-testid="cancel-btn">Cancel</button> - <button onClick={handleSave} disabled={loading} data-testid="save-btn">Save</button> + <button onClick={handleCancel} data-testid="cancel-btn"> + Cancel + </button> + <button onClick={handleSave} disabled={loading} data-testid="save-btn"> + Save + </button> {showRegenerationButton && ( - <button onClick={handleRegeneration} data-testid="regenerate-btn">Regenerate</button> + <button onClick={handleRegeneration} data-testid="regenerate-btn"> + Regenerate + </button> )} </div> ), })) vi.mock('../common/chunk-content', () => ({ - default: ({ docForm, question, answer, onQuestionChange, onAnswerChange, isEditMode }: { docForm: string, question: string, answer: string, onQuestionChange: (v: string) => void, onAnswerChange: (v: string) => void, isEditMode: boolean }) => ( + default: ({ + docForm, + question, + answer, + onQuestionChange, + onAnswerChange, + isEditMode, + }: { + docForm: string + question: string + answer: string + onQuestionChange: (v: string) => void + onAnswerChange: (v: string) => void + isEditMode: boolean + }) => ( <div data-testid="chunk-content"> <input data-testid="question-input" value={question} - onChange={e => onQuestionChange(e.target.value)} + onChange={(e) => onQuestionChange(e.target.value)} /> {docForm === ChunkingMode.qa && ( <input data-testid="answer-input" value={answer} - onChange={e => onAnswerChange(e.target.value)} + onChange={(e) => onAnswerChange(e.target.value)} /> )} <span data-testid="edit-mode">{isEditMode ? 'editing' : 'viewing'}</span> @@ -86,46 +121,81 @@ vi.mock('../common/dot', () => ({ })) vi.mock('../common/keywords', () => ({ - default: ({ keywords, onKeywordsChange, _isEditMode, actionType }: { keywords: string[], onKeywordsChange: (v: string[]) => void, _isEditMode?: boolean, actionType: string }) => ( + default: ({ + keywords, + onKeywordsChange, + _isEditMode, + actionType, + }: { + keywords: string[] + onKeywordsChange: (v: string[]) => void + _isEditMode?: boolean + actionType: string + }) => ( <div data-testid="keywords"> <span data-testid="keywords-action">{actionType}</span> <input data-testid="keywords-input" value={keywords.join(',')} - onChange={e => onKeywordsChange(e.target.value.split(',').filter(Boolean))} + onChange={(e) => onKeywordsChange(e.target.value.split(',').filter(Boolean))} /> </div> ), })) vi.mock('../common/segment-index-tag', () => ({ - SegmentIndexTag: ({ positionId, label, labelPrefix }: { positionId?: string, label?: string, labelPrefix?: string }) => ( + SegmentIndexTag: ({ + positionId, + label, + labelPrefix, + }: { + positionId?: string + label?: string + labelPrefix?: string + }) => ( <span data-testid="segment-index-tag"> - {labelPrefix} - {' '} - {positionId} - {' '} - {label} + {labelPrefix} {positionId} {label} </span> ), })) vi.mock('../common/regeneration-modal', () => ({ - default: ({ isShow, onConfirm, onCancel, onClose }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, onClose: () => void }) => ( - isShow - ? ( - <div data-testid="regeneration-modal"> - <button onClick={onConfirm} data-testid="confirm-regeneration">Confirm</button> - <button onClick={onCancel} data-testid="cancel-regeneration">Cancel</button> - <button onClick={onClose} data-testid="close-regeneration">Close</button> - </div> - ) - : null - ), + default: ({ + isShow, + onConfirm, + onCancel, + onClose, + }: { + isShow: boolean + onConfirm: () => void + onCancel: () => void + onClose: () => void + }) => + isShow ? ( + <div data-testid="regeneration-modal"> + <button onClick={onConfirm} data-testid="confirm-regeneration"> + Confirm + </button> + <button onClick={onCancel} data-testid="cancel-regeneration"> + Cancel + </button> + <button onClick={onClose} data-testid="close-regeneration"> + Close + </button> + </div> + ) : null, })) vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-chunk', () => ({ - default: ({ disabled, value, onChange }: { value?: unknown[], onChange?: (v: unknown[]) => void, disabled?: boolean }) => { + default: ({ + disabled, + value, + onChange, + }: { + value?: unknown[] + onChange?: (v: unknown[]) => void + disabled?: boolean + }) => { return ( <div data-testid="image-uploader"> <span data-testid="uploader-disabled">{disabled ? 'disabled' : 'enabled'}</span> @@ -442,7 +512,14 @@ describe('SegmentDetail', () => { const segInfoWithAttachments = { ...defaultSegInfo, attachments: [ - { id: 'att-1', name: 'file1.jpg', size: 1000, mime_type: 'image/jpeg', extension: 'jpg', source_url: 'http://example.com/file1.jpg' }, + { + id: 'att-1', + name: 'file1.jpg', + size: 1000, + mime_type: 'image/jpeg', + extension: 'jpg', + source_url: 'http://example.com/file1.jpg', + }, ], } @@ -476,13 +553,7 @@ describe('SegmentDetail', () => { it('should close modal and edit drawer when close after regeneration is clicked', () => { const mockOnCancel = vi.fn() - render( - <SegmentDetail - {...defaultProps} - isEditMode={true} - onCancel={mockOnCancel} - />, - ) + render(<SegmentDetail {...defaultProps} isEditMode={true} onCancel={mockOnCancel} />) fireEvent.click(screen.getByTestId('regenerate-btn')) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx index f3fa0d092952c5..513b14c0528f35 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx @@ -4,7 +4,7 @@ import { SegmentListContext, useSegmentListContext } from '../segment-list-conte describe('SegmentListContext', () => { it('should expose the default collapsed state', () => { - const { result } = renderHook(() => useSegmentListContext(value => value)) + const { result } = renderHook(() => useSegmentListContext((value) => value)) expect(result.current).toEqual({ isCollapsed: true, @@ -18,31 +18,33 @@ describe('SegmentListContext', () => { it('should select provider values from the current segment list context', () => { const toggleFullScreen = vi.fn() const wrapper = ({ children }: { children: ReactNode }) => ( - <SegmentListContext.Provider value={{ - isCollapsed: false, - fullScreen: true, - toggleFullScreen, - currSegment: { - showModal: true, - isEditMode: true, - segInfo: { id: 'segment-1' } as never, - }, - currChildChunk: { - showModal: true, - childChunkInfo: { id: 'child-1' } as never, - }, - }} + <SegmentListContext.Provider + value={{ + isCollapsed: false, + fullScreen: true, + toggleFullScreen, + currSegment: { + showModal: true, + isEditMode: true, + segInfo: { id: 'segment-1' } as never, + }, + currChildChunk: { + showModal: true, + childChunkInfo: { id: 'child-1' } as never, + }, + }} > {children} </SegmentListContext.Provider> ) const { result } = renderHook( - () => useSegmentListContext(value => ({ - fullScreen: value.fullScreen, - segmentOpen: value.currSegment.showModal, - childOpen: value.currChildChunk.showModal, - })), + () => + useSegmentListContext((value) => ({ + fullScreen: value.fullScreen, + segmentOpen: value.currSegment.showModal, + childOpen: value.currChildChunk.showModal, + })), { wrapper }, ) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/segment-list.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list.spec.tsx index 4e9f8805368cc3..b7bcc6203b6c0b 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/segment-list.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list.spec.tsx @@ -2,14 +2,15 @@ import type { SegmentDetailModel } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' - import SegmentList from '../segment-list' // Mock document context let mockDocForm = ChunkingMode.text let mockParentMode = 'paragraph' vi.mock('../../context', () => ({ - useDocumentContext: (selector: (state: { docForm: ChunkingMode, parentMode: string }) => unknown) => { + useDocumentContext: ( + selector: (state: { docForm: ChunkingMode; parentMode: string }) => unknown, + ) => { return selector({ docForm: mockDocForm, parentMode: mockParentMode, @@ -21,7 +22,12 @@ vi.mock('../../context', () => ({ let mockCurrSegment: { segInfo: { id: string } } | null = null let mockCurrChildChunk: { childChunkInfo: { segment_id: string } } | null = null vi.mock('../index', () => ({ - useSegmentListContext: (selector: (state: { currSegment: { segInfo: { id: string } } | null, currChildChunk: { childChunkInfo: { segment_id: string } } | null }) => unknown) => { + useSegmentListContext: ( + selector: (state: { + currSegment: { segInfo: { id: string } } | null + currChildChunk: { childChunkInfo: { segment_id: string } } | null + }) => unknown, + ) => { return selector({ currSegment: mockCurrSegment, currChildChunk: mockCurrChildChunk, @@ -32,7 +38,9 @@ vi.mock('../index', () => ({ vi.mock('../common/empty', () => ({ default: ({ onClearFilter }: { onClearFilter: () => void }) => ( <div data-testid="empty"> - <button onClick={onClearFilter} data-testid="clear-filter-btn">Clear Filter</button> + <button onClick={onClearFilter} data-testid="clear-filter-btn"> + Clear Filter + </button> </div> ), })) @@ -61,7 +69,7 @@ vi.mock('../segment-card', () => ({ onClickSlice: (childChunk: unknown) => void archived: boolean embeddingAvailable: boolean - focused: { segmentIndex: boolean, segmentContent: boolean } + focused: { segmentIndex: boolean; segmentContent: boolean } }) => ( <div data-testid="segment-card" data-id={detail.id}> <span data-testid="segment-content">{detail.content}</span> @@ -69,13 +77,30 @@ vi.mock('../segment-card', () => ({ <span data-testid="embedding-available">{embeddingAvailable ? 'true' : 'false'}</span> <span data-testid="focused-index">{focused.segmentIndex ? 'true' : 'false'}</span> <span data-testid="focused-content">{focused.segmentContent ? 'true' : 'false'}</span> - <button onClick={onClick} data-testid="card-click">Click</button> - <button onClick={onClickEdit} data-testid="edit-btn">Edit</button> - <button onClick={() => onChangeSwitch(true, detail.id)} data-testid="switch-btn">Switch</button> - <button onClick={() => onDelete(detail.id)} data-testid="delete-btn">Delete</button> - <button onClick={() => onDeleteChildChunk(detail.id, 'child-1')} data-testid="delete-child-btn">Delete Child</button> - <button onClick={() => handleAddNewChildChunk(detail.id)} data-testid="add-child-btn">Add Child</button> - <button onClick={() => onClickSlice({ id: 'slice-1' })} data-testid="click-slice-btn">Click Slice</button> + <button onClick={onClick} data-testid="card-click"> + Click + </button> + <button onClick={onClickEdit} data-testid="edit-btn"> + Edit + </button> + <button onClick={() => onChangeSwitch(true, detail.id)} data-testid="switch-btn"> + Switch + </button> + <button onClick={() => onDelete(detail.id)} data-testid="delete-btn"> + Delete + </button> + <button + onClick={() => onDeleteChildChunk(detail.id, 'child-1')} + data-testid="delete-child-btn" + > + Delete Child + </button> + <button onClick={() => handleAddNewChildChunk(detail.id)} data-testid="add-child-btn"> + Add Child + </button> + <button onClick={() => onClickSlice({ id: 'slice-1' })} data-testid="click-slice-btn"> + Click Slice + </button> </div> ), })) @@ -97,27 +122,28 @@ describe('SegmentList', () => { mockCurrChildChunk = null }) - const createMockSegment = (id: string, content: string): SegmentDetailModel => ({ - id, - content, - position: 1, - word_count: 10, - tokens: 5, - hit_count: 0, - enabled: true, - status: 'completed', - created_at: Date.now(), - updated_at: Date.now(), - keywords: [], - document_id: 'doc-1', - sign_content: content, - index_node_id: `index-${id}`, - index_node_hash: `hash-${id}`, - answer: '', - error: null, - disabled_at: null, - disabled_by: null, - } as unknown as SegmentDetailModel) + const createMockSegment = (id: string, content: string): SegmentDetailModel => + ({ + id, + content, + position: 1, + word_count: 10, + tokens: 5, + hit_count: 0, + enabled: true, + status: 'completed', + created_at: Date.now(), + updated_at: Date.now(), + keywords: [], + document_id: 'doc-1', + sign_content: content, + index_node_id: `index-${id}`, + index_node_hash: `hash-${id}`, + answer: '', + error: null, + disabled_at: null, + disabled_by: null, + }) as unknown as SegmentDetailModel const defaultProps = { ref: null, @@ -268,10 +294,7 @@ describe('SegmentList', () => { rerender( <SegmentList {...defaultProps} - items={[ - createMockSegment('seg-2', 'Content 2'), - createMockSegment('seg-3', 'Content 3'), - ]} + items={[createMockSegment('seg-2', 'Content 2'), createMockSegment('seg-3', 'Content 3')]} />, ) @@ -290,7 +313,9 @@ describe('SegmentList', () => { it('should label each segment checkbox', () => { render(<SegmentList {...defaultProps} />) - expect(screen.getByRole('checkbox', { name: 'datasetDocuments.segment.chunk 1' })).toBeInTheDocument() + expect( + screen.getByRole('checkbox', { name: 'datasetDocuments.segment.chunk 1' }), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx b/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx index fe7746badaba2f..0bb4b2daa02b89 100644 --- a/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx +++ b/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx @@ -1,11 +1,7 @@ import type { FC } from 'react' import type { ChildChunkDetail, ChunkingMode } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' -import { - RiCloseLine, - RiCollapseDiagonalLine, - RiExpandDiagonalLine, -} from '@remixicon/react' +import { RiCloseLine, RiCollapseDiagonalLine, RiExpandDiagonalLine } from '@remixicon/react' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -41,14 +37,12 @@ const ChildSegmentDetail: FC<IChildSegmentDetailProps> = ({ const [content, setContent] = useState(childChunkInfo?.content || '') const { eventEmitter } = useEventEmitterContextContext() const [loading, setLoading] = useState(false) - const fullScreen = useSegmentListContext(s => s.fullScreen) - const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen) + const fullScreen = useSegmentListContext((s) => s.fullScreen) + const toggleFullScreen = useSegmentListContext((s) => s.toggleFullScreen) eventEmitter?.useSubscription((v) => { - if (v === 'update-child-segment') - setLoading(true) - if (v === 'update-child-segment-done') - setLoading(false) + if (v === 'update-child-segment') setLoading(true) + if (v === 'update-child-segment-done') setLoading(false) }) const handleCancel = () => { @@ -61,30 +55,38 @@ const ChildSegmentDetail: FC<IChildSegmentDetailProps> = ({ const wordCountText = useMemo(() => { const count = content.length - return `${formatNumber(count)} ${t($ => $['segment.characters'], { ns: 'datasetDocuments', count })}` + return `${formatNumber(count)} ${t(($) => $['segment.characters'], { ns: 'datasetDocuments', count })}` }, [content.length]) const EditTimeText = useMemo(() => { const timeText = formatTime({ date: (childChunkInfo?.updated_at ?? 0) * 1000, - dateFormat: `${t($ => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, + dateFormat: `${t(($) => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, }) - return `${t($ => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}` + return `${t(($) => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}` }, [childChunkInfo?.updated_at]) return ( <div className="flex h-full flex-col"> - <div className={cn('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pr-4 pl-6' : 'pt-3 pr-3 pl-4')}> + <div + className={cn( + 'flex items-center justify-between', + fullScreen ? 'border border-divider-subtle py-3 pr-4 pl-6' : 'pt-3 pr-3 pl-4', + )} + > <div className="flex flex-col"> - <div className="system-xl-semibold text-text-primary">{t($ => $['segment.editChildChunk'], { ns: 'datasetDocuments' })}</div> + <div className="system-xl-semibold text-text-primary"> + {t(($) => $['segment.editChildChunk'], { ns: 'datasetDocuments' })} + </div> <div className="flex items-center gap-x-2"> - <SegmentIndexTag positionId={childChunkInfo?.position || ''} labelPrefix={t($ => $['segment.childChunk'], { ns: 'datasetDocuments' }) as string} /> + <SegmentIndexTag + positionId={childChunkInfo?.position || ''} + labelPrefix={t(($) => $['segment.childChunk'], { ns: 'datasetDocuments' }) as string} + /> <Dot /> <span className="system-xs-medium text-text-tertiary">{wordCountText}</span> <Dot /> - <span className="system-xs-medium text-text-tertiary"> - {EditTimeText} - </span> + <span className="system-xs-medium text-text-tertiary">{EditTimeText}</span> </div> </div> <div className="flex items-center"> @@ -101,17 +103,21 @@ const ChildSegmentDetail: FC<IChildSegmentDetailProps> = ({ )} <button type="button" - aria-label={t($ => $[fullScreen ? 'operation.zoomOut' : 'operation.zoomIn'], { ns: 'common' })} + aria-label={t(($) => $[fullScreen ? 'operation.zoomOut' : 'operation.zoomIn'], { + ns: 'common', + })} className="mr-1 flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" onClick={toggleFullScreen} > - {fullScreen - ? <RiCollapseDiagonalLine className="size-4 text-text-tertiary" aria-hidden="true" /> - : <RiExpandDiagonalLine className="size-4 text-text-tertiary" aria-hidden="true" />} + {fullScreen ? ( + <RiCollapseDiagonalLine className="size-4 text-text-tertiary" aria-hidden="true" /> + ) : ( + <RiExpandDiagonalLine className="size-4 text-text-tertiary" aria-hidden="true" /> + )} </button> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" onClick={onCancel} > @@ -119,12 +125,22 @@ const ChildSegmentDetail: FC<IChildSegmentDetailProps> = ({ </button> </div> </div> - <div className={cn('flex w-full grow', fullScreen ? 'flex-row justify-center px-6 pt-6' : 'px-4 py-3')}> - <div className={cn('h-full overflow-hidden break-all whitespace-pre-line', fullScreen ? 'w-1/2' : 'w-full')}> + <div + className={cn( + 'flex w-full grow', + fullScreen ? 'flex-row justify-center px-6 pt-6' : 'px-4 py-3', + )} + > + <div + className={cn( + 'h-full overflow-hidden break-all whitespace-pre-line', + fullScreen ? 'w-1/2' : 'w-full', + )} + > <ChunkContent docForm={docForm} question={content} - onQuestionChange={content => setContent(content)} + onQuestionChange={(content) => setContent(content)} isEditMode={true} /> </div> diff --git a/web/app/components/datasets/documents/detail/completed/child-segment-list.tsx b/web/app/components/datasets/documents/detail/completed/child-segment-list.tsx index 989287d6814ae6..c2550fb2837e5a 100644 --- a/web/app/components/datasets/documents/detail/completed/child-segment-list.tsx +++ b/web/app/components/datasets/documents/detail/completed/child-segment-list.tsx @@ -34,7 +34,11 @@ function computeTotalInfo( isSearching: boolean, total: number | undefined, childChunksLength: number, -): { displayText: string, count: number, translationKey: 'segment.searchResults' | 'segment.childChunks' } { +): { + displayText: string + count: number + translationKey: 'segment.searchResults' | 'segment.childChunks' +} { if (isSearching) { const count = total ?? 0 return { @@ -75,25 +79,29 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({ focused = false, }) => { const { t } = useTranslation() - const parentMode = useDocumentContext(s => s.parentMode) - const currChildChunk = useSegmentListContext(s => s.currChildChunk) + const parentMode = useDocumentContext((s) => s.parentMode) + const currChildChunk = useSegmentListContext((s) => s.currChildChunk) const [collapsed, setCollapsed] = useState(true) const isParagraphMode = parentMode === 'paragraph' const isFullDocMode = parentMode === 'full-doc' const isSearching = inputValue !== '' && isFullDocMode - const contentOpacity = (enabled || focused) ? '' : 'opacity-50 group-hover/card:opacity-100' - const { displayText, count, translationKey } = computeTotalInfo(isFullDocMode, isSearching, total, childChunks.length) - const totalText = `${displayText} ${t($ => $[translationKey], { ns: 'datasetDocuments', count })}` + const contentOpacity = enabled || focused ? '' : 'opacity-50 group-hover/card:opacity-100' + const { displayText, count, translationKey } = computeTotalInfo( + isFullDocMode, + isSearching, + total, + childChunks.length, + ) + const totalText = `${displayText} ${t(($) => $[translationKey], { ns: 'datasetDocuments', count })}` - const toggleCollapse = () => setCollapsed(prev => !prev) + const toggleCollapse = () => setCollapsed((prev) => !prev) const showContent = (isFullDocMode && !isLoading) || !collapsed const hoverVisibleClass = isParagraphMode ? 'hidden group-hover/card:inline-block' : '' const renderCollapseIcon = () => { - if (!isParagraphMode) - return null + if (!isParagraphMode) return null const Icon = collapsed ? RiArrowRightSLine : RiArrowDownSLine return <Icon className={cn('mr-0.5 size-4 text-text-secondary', collapsed && 'opacity-50')} /> } @@ -102,7 +110,7 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({ const isEdited = childChunk.updated_at !== childChunk.created_at const isFocused = currChildChunk?.childChunkInfo?.id === childChunk.id const label = isEdited - ? `C-${childChunk.position} · ${t($ => $['segment.edited'], { ns: 'datasetDocuments' })}` + ? `C-${childChunk.position} · ${t(($) => $['segment.edited'], { ns: 'datasetDocuments' })}` : `C-${childChunk.position}` return ( @@ -114,7 +122,10 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({ className="child-chunk" labelClassName={isFocused ? 'bg-state-accent-solid text-text-primary-on-surface' : ''} labelInnerClassName="text-[10px] font-semibold align-bottom leading-6" - contentClassName={cn('leading-6!', isFocused ? 'bg-state-accent-hover-alt text-text-primary' : 'text-text-secondary')} + contentClassName={cn( + 'leading-6!', + isFocused ? 'bg-state-accent-hover-alt text-text-primary' : 'text-text-secondary', + )} showDivider={false} onClick={(e) => { e.stopPropagation() @@ -131,7 +142,9 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({ const renderContent = () => { if (childChunks.length > 0) { return ( - <FormattedText className={cn('flex w-full flex-col leading-6!', isParagraphMode ? 'gap-y-2' : 'gap-y-3')}> + <FormattedText + className={cn('flex w-full flex-col leading-6!', isParagraphMode ? 'gap-y-2' : 'gap-y-3')} + > {childChunks.map(renderChildChunkItem)} </FormattedText> ) @@ -147,15 +160,21 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({ } return ( - <div className={cn( - 'flex flex-col', - contentOpacity, - isParagraphMode ? 'pt-1 pb-2' : 'grow px-3', - isFullDocMode && isLoading && 'overflow-y-hidden', - )} + <div + className={cn( + 'flex flex-col', + contentOpacity, + isParagraphMode ? 'pt-1 pb-2' : 'grow px-3', + isFullDocMode && isLoading && 'overflow-y-hidden', + )} > {isFullDocMode && <Divider type="horizontal" className="my-1 h-px bg-divider-subtle" />} - <div className={cn('flex items-center justify-between', isFullDocMode && 'sticky -top-2 left-0 bg-background-default pt-2 pb-3')}> + <div + className={cn( + 'flex items-center justify-between', + isFullDocMode && 'sticky -top-2 left-0 bg-background-default pt-2 pb-3', + )} + > <div className={cn( 'flex h-7 items-center rounded-lg pr-3 pl-1', @@ -170,7 +189,11 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({ > {renderCollapseIcon()} <span className="system-sm-semibold-uppercase text-text-secondary">{totalText}</span> - <span className={cn('pl-1.5 text-xs font-medium text-text-quaternary', hoverVisibleClass)}>·</span> + <span + className={cn('pl-1.5 text-xs font-medium text-text-quaternary', hoverVisibleClass)} + > + · + </span> <button type="button" className={cn( @@ -184,7 +207,7 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({ }} disabled={isLoading} > - {t($ => $['operation.add'], { ns: 'common' })} + {t(($) => $['operation.add'], { ns: 'common' })} </button> </div> {isFullDocMode && ( @@ -193,7 +216,7 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({ showClearIcon wrapperClassName="w-52!" value={inputValue} - onChange={e => handleInputChange?.(e.target.value)} + onChange={(e) => handleInputChange?.(e.target.value)} onClear={() => handleInputChange?.('')} /> )} diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx index 2d59ac4227f870..1861c55ed7b1b5 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx @@ -21,19 +21,17 @@ const createWrapper = (contextValue: { parentMode?: 'paragraph' | 'full-doc' }) => { return ({ children }: { children: React.ReactNode }) => ( - <DocumentContext.Provider value={contextValue}> - {children} - </DocumentContext.Provider> + <DocumentContext.Provider value={contextValue}>{children}</DocumentContext.Provider> ) } const getEscCallback = (): ((e: KeyboardEvent) => void) | undefined => { - const escCall = mockUseHotkey.mock.calls.find(call => call[0] === 'Escape') + const escCall = mockUseHotkey.mock.calls.find((call) => call[0] === 'Escape') return escCall?.[1] } const getCtrlSCallback = (): ((e: KeyboardEvent) => void) | undefined => { - const ctrlSCall = mockUseHotkey.mock.calls.find(call => call[0] === 'Mod+S') + const ctrlSCall = mockUseHotkey.mock.calls.find((call) => call[0] === 'Mod+S') return ctrlSCall?.[1] } @@ -46,11 +44,7 @@ describe('ActionButtons', () => { describe('Rendering', () => { it('should render without crashing', () => { const { container } = render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={vi.fn()} - loading={false} - />, + <ActionButtons handleCancel={vi.fn()} handleSave={vi.fn()} loading={false} />, { wrapper: createWrapper({}) }, ) @@ -58,53 +52,33 @@ describe('ActionButtons', () => { }) it('should render cancel button', () => { - render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={vi.fn()} - loading={false} - />, - { wrapper: createWrapper({}) }, - ) + render(<ActionButtons handleCancel={vi.fn()} handleSave={vi.fn()} loading={false} />, { + wrapper: createWrapper({}), + }) expect(screen.getByText(/operation\.cancel/i))!.toBeInTheDocument() }) it('should render save button', () => { - render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={vi.fn()} - loading={false} - />, - { wrapper: createWrapper({}) }, - ) + render(<ActionButtons handleCancel={vi.fn()} handleSave={vi.fn()} loading={false} />, { + wrapper: createWrapper({}), + }) expect(screen.getByText(/operation\.save/i))!.toBeInTheDocument() }) it('should render ESC keyboard hint on cancel button', () => { - render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={vi.fn()} - loading={false} - />, - { wrapper: createWrapper({}) }, - ) + render(<ActionButtons handleCancel={vi.fn()} handleSave={vi.fn()} loading={false} />, { + wrapper: createWrapper({}), + }) expect(screen.getByText('Esc'))!.toBeInTheDocument() }) it('should render S keyboard hint on save button', () => { - render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={vi.fn()} - loading={false} - />, - { wrapper: createWrapper({}) }, - ) + render(<ActionButtons handleCancel={vi.fn()} handleSave={vi.fn()} loading={false} />, { + wrapper: createWrapper({}), + }) expect(screen.getByText('S'))!.toBeInTheDocument() }) @@ -114,11 +88,7 @@ describe('ActionButtons', () => { it('should call handleCancel when cancel button is clicked', () => { const mockHandleCancel = vi.fn() render( - <ActionButtons - handleCancel={mockHandleCancel} - handleSave={vi.fn()} - loading={false} - />, + <ActionButtons handleCancel={mockHandleCancel} handleSave={vi.fn()} loading={false} />, { wrapper: createWrapper({}) }, ) @@ -130,14 +100,9 @@ describe('ActionButtons', () => { it('should call handleSave when save button is clicked', () => { const mockHandleSave = vi.fn() - render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={mockHandleSave} - loading={false} - />, - { wrapper: createWrapper({}) }, - ) + render(<ActionButtons handleCancel={vi.fn()} handleSave={mockHandleSave} loading={false} />, { + wrapper: createWrapper({}), + }) const buttons = screen.getAllByRole('button') const saveButton = buttons[buttons.length - 1] // Save button is last @@ -147,14 +112,9 @@ describe('ActionButtons', () => { }) it('should disable save button when loading is true', () => { - render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={vi.fn()} - loading={true} - />, - { wrapper: createWrapper({}) }, - ) + render(<ActionButtons handleCancel={vi.fn()} handleSave={vi.fn()} loading={true} />, { + wrapper: createWrapper({}), + }) const buttons = screen.getAllByRole('button') const saveButton = buttons[buttons.length - 1] @@ -248,8 +208,7 @@ describe('ActionButtons', () => { ) const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button') - if (regenerationButton) - fireEvent.click(regenerationButton) + if (regenerationButton) fireEvent.click(regenerationButton) expect(mockHandleRegeneration).toHaveBeenCalledTimes(1) }) @@ -336,34 +295,21 @@ describe('ActionButtons', () => { it('should handle missing context values gracefully', () => { // Arrange & Act & Assert - should not throw expect(() => { - render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={vi.fn()} - loading={false} - />, - { wrapper: createWrapper({}) }, - ) + render(<ActionButtons handleCancel={vi.fn()} handleSave={vi.fn()} loading={false} />, { + wrapper: createWrapper({}), + }) }).not.toThrow() }) it('should maintain structure when rerendered', () => { const { rerender } = render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={vi.fn()} - loading={false} - />, + <ActionButtons handleCancel={vi.fn()} handleSave={vi.fn()} loading={false} />, { wrapper: createWrapper({}) }, ) rerender( <DocumentContext.Provider value={{}}> - <ActionButtons - handleCancel={vi.fn()} - handleSave={vi.fn()} - loading={true} - /> + <ActionButtons handleCancel={vi.fn()} handleSave={vi.fn()} loading={true} /> </DocumentContext.Provider>, ) @@ -374,14 +320,9 @@ describe('ActionButtons', () => { describe('Keyboard Shortcuts', () => { it('should display ctrl key hint on save button', () => { - render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={vi.fn()} - loading={false} - />, - { wrapper: createWrapper({}) }, - ) + render(<ActionButtons handleCancel={vi.fn()} handleSave={vi.fn()} loading={false} />, { + wrapper: createWrapper({}), + }) const kbdElements = document.querySelectorAll('kbd') expect(kbdElements.length).toBeGreaterThan(0) @@ -391,11 +332,7 @@ describe('ActionButtons', () => { const mockHandleCancel = vi.fn() const mockPreventDefault = vi.fn() render( - <ActionButtons - handleCancel={mockHandleCancel} - handleSave={vi.fn()} - loading={false} - />, + <ActionButtons handleCancel={mockHandleCancel} handleSave={vi.fn()} loading={false} />, { wrapper: createWrapper({}) }, ) @@ -411,14 +348,9 @@ describe('ActionButtons', () => { it('should call handleSave and preventDefault when Ctrl+S is pressed and not loading', () => { const mockHandleSave = vi.fn() const mockPreventDefault = vi.fn() - render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={mockHandleSave} - loading={false} - />, - { wrapper: createWrapper({}) }, - ) + render(<ActionButtons handleCancel={vi.fn()} handleSave={mockHandleSave} loading={false} />, { + wrapper: createWrapper({}), + }) // Act - get the Ctrl+S callback and invoke it const ctrlSCallback = getCtrlSCallback() @@ -432,14 +364,9 @@ describe('ActionButtons', () => { it('should not call handleSave when Ctrl+S is pressed while loading', () => { const mockHandleSave = vi.fn() const mockPreventDefault = vi.fn() - render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={mockHandleSave} - loading={true} - />, - { wrapper: createWrapper({}) }, - ) + render(<ActionButtons handleCancel={vi.fn()} handleSave={mockHandleSave} loading={true} />, { + wrapper: createWrapper({}), + }) // Act - get the Ctrl+S callback and invoke it const ctrlSCallback = getCtrlSCallback() @@ -451,16 +378,11 @@ describe('ActionButtons', () => { }) it('should register the Mod+S hotkey', () => { - render( - <ActionButtons - handleCancel={vi.fn()} - handleSave={vi.fn()} - loading={false} - />, - { wrapper: createWrapper({}) }, - ) + render(<ActionButtons handleCancel={vi.fn()} handleSave={vi.fn()} loading={false} />, { + wrapper: createWrapper({}), + }) - const ctrlSCall = mockUseHotkey.mock.calls.find(call => call[0] === 'Mod+S') + const ctrlSCall = mockUseHotkey.mock.calls.find((call) => call[0] === 'Mod+S') expect(ctrlSCall).toBeDefined() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/add-another.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/add-another.spec.tsx index 350591c64582fb..56adcde5bafa33 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/add-another.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/add-another.spec.tsx @@ -12,17 +12,13 @@ describe('AddAnother', () => { describe('Rendering', () => { it('should render without crashing', () => { - const { container } = render( - <AddAnother checked={false} onCheckedChange={vi.fn()} />, - ) + const { container } = render(<AddAnother checked={false} onCheckedChange={vi.fn()} />) expect(container.firstChild).toBeInTheDocument() }) it('should render the checkbox', () => { - render( - <AddAnother checked={false} onCheckedChange={vi.fn()} />, - ) + render(<AddAnother checked={false} onCheckedChange={vi.fn()} />) expect(getCheckbox()).toBeInTheDocument() }) @@ -35,9 +31,7 @@ describe('AddAnother', () => { }) it('should render with correct base styling classes', () => { - const { container } = render( - <AddAnother checked={false} onCheckedChange={vi.fn()} />, - ) + const { container } = render(<AddAnother checked={false} onCheckedChange={vi.fn()} />) const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') @@ -49,28 +43,20 @@ describe('AddAnother', () => { describe('Props', () => { it('should render unchecked state when checked is false', () => { - render( - <AddAnother checked={false} onCheckedChange={vi.fn()} />, - ) + render(<AddAnother checked={false} onCheckedChange={vi.fn()} />) expect(getCheckbox()).toHaveAttribute('aria-checked', 'false') }) it('should render checked state when checked is true', () => { - render( - <AddAnother checked={true} onCheckedChange={vi.fn()} />, - ) + render(<AddAnother checked={true} onCheckedChange={vi.fn()} />) expect(getCheckbox()).toHaveAttribute('aria-checked', 'true') }) it('should apply custom className', () => { const { container } = render( - <AddAnother - checked={false} - onCheckedChange={vi.fn()} - className="custom-class" - />, + <AddAnother checked={false} onCheckedChange={vi.fn()} className="custom-class" />, ) const wrapper = container.firstChild as HTMLElement @@ -82,9 +68,7 @@ describe('AddAnother', () => { it('should call mockOnCheckedChange when checkbox is clicked', async () => { const mockOnCheckedChange = vi.fn() const user = userEvent.setup() - render( - <AddAnother checked={false} onCheckedChange={mockOnCheckedChange} />, - ) + render(<AddAnother checked={false} onCheckedChange={mockOnCheckedChange} />) await user.click(screen.getByText(/segment\.addAnother/i)) @@ -108,18 +92,14 @@ describe('AddAnother', () => { describe('Structure', () => { it('should render text with tertiary text color', () => { - const { container } = render( - <AddAnother checked={false} onCheckedChange={vi.fn()} />, - ) + const { container } = render(<AddAnother checked={false} onCheckedChange={vi.fn()} />) const textElement = container.querySelector('.text-text-tertiary') expect(textElement).toBeInTheDocument() }) it('should render text with xs medium font styling', () => { - const { container } = render( - <AddAnother checked={false} onCheckedChange={vi.fn()} />, - ) + const { container } = render(<AddAnother checked={false} onCheckedChange={vi.fn()} />) const textElement = container.querySelector('.system-xs-medium') expect(textElement).toBeInTheDocument() @@ -141,12 +121,9 @@ describe('AddAnother', () => { it('should handle rapid state changes', async () => { const mockOnCheckedChange = vi.fn() const user = userEvent.setup() - render( - <AddAnother checked={false} onCheckedChange={mockOnCheckedChange} />, - ) + render(<AddAnother checked={false} onCheckedChange={mockOnCheckedChange} />) - for (let i = 0; i < 5; i++) - await user.click(screen.getByText(/segment\.addAnother/i)) + for (let i = 0; i < 5; i++) await user.click(screen.getByText(/segment\.addAnother/i)) expect(mockOnCheckedChange).toHaveBeenCalledTimes(5) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/chunk-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/chunk-content.spec.tsx index a92cc9d3502eef..841960f250d934 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/chunk-content.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/chunk-content.spec.tsx @@ -189,9 +189,7 @@ describe('ChunkContent', () => { }) it('should disable textarea when isEditMode is false in text mode', () => { - const { container } = render( - <ChunkContent {...defaultProps} isEditMode={false} />, - ) + const { container } = render(<ChunkContent {...defaultProps} isEditMode={false} />) // Assert - In view mode, Markdown is rendered instead of textarea // Assert - In view mode, Markdown is rendered instead of textarea @@ -273,11 +271,7 @@ describe('ChunkContent', () => { it('should handle ChunkingMode.parentChild similar to text mode', () => { render( - <ChunkContent - {...defaultProps} - docForm={ChunkingMode.parentChild} - isEditMode={true} - />, + <ChunkContent {...defaultProps} docForm={ChunkingMode.parentChild} isEditMode={true} />, ) // Assert - parentChild should render like text mode @@ -288,13 +282,7 @@ describe('ChunkContent', () => { describe('Edge Cases', () => { it('should handle empty question', () => { - render( - <ChunkContent - {...defaultProps} - question="" - isEditMode={true} - />, - ) + render(<ChunkContent {...defaultProps} question="" isEditMode={true} />) const textarea = screen.getByRole('textbox') expect(textarea)!.toHaveValue('') @@ -317,13 +305,7 @@ describe('ChunkContent', () => { }) it('should handle undefined answer in QA mode', () => { - render( - <ChunkContent - {...defaultProps} - docForm={ChunkingMode.qa} - isEditMode={true} - />, - ) + render(<ChunkContent {...defaultProps} docForm={ChunkingMode.qa} isEditMode={true} />) // Assert - should render without crashing // Assert - should render without crashing @@ -335,9 +317,7 @@ describe('ChunkContent', () => { <ChunkContent {...defaultProps} question="Initial" isEditMode={true} />, ) - rerender( - <ChunkContent {...defaultProps} question="Updated" isEditMode={true} />, - ) + rerender(<ChunkContent {...defaultProps} question="Updated" isEditMode={true} />) const textarea = screen.getByRole('textbox') expect(textarea)!.toHaveValue('Updated') diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx index 4ba28f3335916f..9fe43656745c64 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx @@ -2,15 +2,16 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { CompletedDrawer } from '../drawer' -( +;( globalThis as typeof globalThis & { BASE_UI_ANIMATIONS_DISABLED: boolean } ).BASE_UI_ANIMATIONS_DISABLED = true const getOverlay = () => - Array.from(document.querySelectorAll<HTMLElement>('[class]')) - .find(element => element.className.includes('bg-background-overlay')) + Array.from(document.querySelectorAll<HTMLElement>('[class]')).find((element) => + element.className.includes('bg-background-overlay'), + ) describe('Drawer', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx index 6b6227492a597d..f4dc08cd61b4c5 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx @@ -5,9 +5,20 @@ import { DocumentDetailDrawer } from '../full-screen-drawer' // Mock the Drawer component since it has high complexity vi.mock('../drawer', () => ({ - CompletedDrawer: ({ children, open, panelClassName, panelContentClassName, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, modal: boolean }) => { - if (!open) - return null + CompletedDrawer: ({ + children, + open, + panelClassName, + panelContentClassName, + modal, + }: { + children: ReactNode + open: boolean + panelClassName: string + panelContentClassName: string + modal: boolean + }) => { + if (!open) return null return ( <div data-testid="drawer-mock" @@ -68,8 +79,12 @@ describe('DocumentDetailDrawer', () => { const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-panel-class')).toContain('w-full') - expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-full') - expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-full') + expect(drawer.getAttribute('data-panel-class')).toContain( + 'data-[swipe-direction=right]:w-full', + ) + expect(drawer.getAttribute('data-panel-class')).toContain( + 'data-[swipe-direction=left]:w-full', + ) }) it('should pass fullScreen=false to Drawer with fixed width class', () => { @@ -81,8 +96,12 @@ describe('DocumentDetailDrawer', () => { const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]') - expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-[568px]') - expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-[568px]') + expect(drawer.getAttribute('data-panel-class')).toContain( + 'data-[swipe-direction=right]:w-[568px]', + ) + expect(drawer.getAttribute('data-panel-class')).toContain( + 'data-[swipe-direction=left]:w-[568px]', + ) }) it('should render as non-modal by default', () => { diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/keywords.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/keywords.spec.tsx index 32165e3278ddbe..b64a799cb6d356 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/keywords.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/keywords.spec.tsx @@ -9,35 +9,20 @@ describe('Keywords', () => { describe('Rendering', () => { it('should render without crashing', () => { - const { container } = render( - <Keywords - keywords={['test']} - onKeywordsChange={vi.fn()} - />, - ) + const { container } = render(<Keywords keywords={['test']} onKeywordsChange={vi.fn()} />) expect(container.firstChild).toBeInTheDocument() }) it('should render the keywords label', () => { - render( - <Keywords - keywords={['test']} - onKeywordsChange={vi.fn()} - />, - ) + render(<Keywords keywords={['test']} onKeywordsChange={vi.fn()} />) // Assert - i18n key format expect(screen.getByText(/segment\.keywords/i)).toBeInTheDocument() }) it('should render with correct container classes', () => { - const { container } = render( - <Keywords - keywords={['test']} - onKeywordsChange={vi.fn()} - />, - ) + const { container } = render(<Keywords keywords={['test']} onKeywordsChange={vi.fn()} />) const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') @@ -87,11 +72,7 @@ describe('Keywords', () => { it('should apply custom className', () => { const { container } = render( - <Keywords - keywords={['test']} - onKeywordsChange={vi.fn()} - className="custom-class" - />, + <Keywords keywords={['test']} onKeywordsChange={vi.fn()} className="custom-class" />, ) const wrapper = container.firstChild as HTMLElement @@ -100,11 +81,7 @@ describe('Keywords', () => { it('should use default actionType of view', () => { render( - <Keywords - segInfo={{ id: '1', keywords: [] }} - keywords={[]} - onKeywordsChange={vi.fn()} - />, + <Keywords segInfo={{ id: '1', keywords: [] }} keywords={[]} onKeywordsChange={vi.fn()} />, ) // Assert - dash should appear in view mode with empty keywords @@ -114,36 +91,21 @@ describe('Keywords', () => { describe('Structure', () => { it('should render label with uppercase styling', () => { - const { container } = render( - <Keywords - keywords={['test']} - onKeywordsChange={vi.fn()} - />, - ) + const { container } = render(<Keywords keywords={['test']} onKeywordsChange={vi.fn()} />) const labelElement = container.querySelector('.system-xs-medium-uppercase') expect(labelElement).toBeInTheDocument() }) it('should render keywords container with overflow handling', () => { - const { container } = render( - <Keywords - keywords={['test']} - onKeywordsChange={vi.fn()} - />, - ) + const { container } = render(<Keywords keywords={['test']} onKeywordsChange={vi.fn()} />) const keywordsContainer = container.querySelector('.overflow-auto') expect(keywordsContainer).toBeInTheDocument() }) it('should render keywords container with max height', () => { - const { container } = render( - <Keywords - keywords={['test']} - onKeywordsChange={vi.fn()} - />, - ) + const { container } = render(<Keywords keywords={['test']} onKeywordsChange={vi.fn()} />) const keywordsContainer = container.querySelector('.max-h-\\[200px\\]') expect(keywordsContainer).toBeInTheDocument() @@ -171,11 +133,7 @@ describe('Keywords', () => { describe('Edge Cases', () => { it('should handle empty keywords array in view mode without segInfo keywords', () => { const { container } = render( - <Keywords - keywords={[]} - onKeywordsChange={vi.fn()} - actionType="view" - />, + <Keywords keywords={[]} onKeywordsChange={vi.fn()} actionType="view" />, ) // Assert - container should be rendered diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/regeneration-modal.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/regeneration-modal.spec.tsx index cc7f1aafa41c9c..f67c5b5e85079c 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/regeneration-modal.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/regeneration-modal.spec.tsx @@ -26,11 +26,7 @@ const TestWrapper = ({ children }: { children: ReactNode }) => { // Create a wrapper component with event emitter context const createWrapper = () => { - return ({ children }: { children: ReactNode }) => ( - <TestWrapper> - {children} - </TestWrapper> - ) + return ({ children }: { children: ReactNode }) => <TestWrapper>{children}</TestWrapper> } describe('RegenerationModal', () => { @@ -81,7 +77,9 @@ describe('RegenerationModal', () => { describe('User Interactions', () => { it('should call onCancel when cancel button is clicked', () => { const mockOnCancel = vi.fn() - render(<RegenerationModal {...defaultProps} onCancel={mockOnCancel} />, { wrapper: createWrapper() }) + render(<RegenerationModal {...defaultProps} onCancel={mockOnCancel} />, { + wrapper: createWrapper(), + }) fireEvent.click(screen.getByText(/operation\.cancel/i)) @@ -90,7 +88,9 @@ describe('RegenerationModal', () => { it('should call onConfirm when regenerate button is clicked', () => { const mockOnConfirm = vi.fn() - render(<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />, { wrapper: createWrapper() }) + render(<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />, { + wrapper: createWrapper(), + }) fireEvent.click(screen.getByText(/operation\.regenerate/i)) @@ -110,10 +110,9 @@ describe('RegenerationModal', () => { describe('Edge Cases', () => { it('should handle toggling isShow prop', () => { - const { rerender } = render( - <RegenerationModal {...defaultProps} isShow={true} />, - { wrapper: createWrapper() }, - ) + const { rerender } = render(<RegenerationModal {...defaultProps} isShow={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument() rerender( @@ -148,8 +147,7 @@ describe('RegenerationModal', () => { render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) act(() => { - if (emitFunction) - emitFunction('update-segment') + if (emitFunction) emitFunction('update-segment') }) await waitFor(() => { @@ -161,8 +159,7 @@ describe('RegenerationModal', () => { render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) act(() => { - if (emitFunction) - emitFunction('update-segment') + if (emitFunction) emitFunction('update-segment') }) await waitFor(() => { @@ -174,8 +171,7 @@ describe('RegenerationModal', () => { render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) act(() => { - if (emitFunction) - emitFunction('update-segment') + if (emitFunction) emitFunction('update-segment') }) await waitFor(() => { @@ -238,7 +234,9 @@ describe('RegenerationModal', () => { it('should call onClose when close button is clicked in success state', async () => { const mockOnClose = vi.fn() - render(<RegenerationModal {...defaultProps} onClose={mockOnClose} />, { wrapper: createWrapper() }) + render(<RegenerationModal {...defaultProps} onClose={mockOnClose} />, { + wrapper: createWrapper(), + }) act(() => { if (emitFunction) { diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/segment-index-tag.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/segment-index-tag.spec.tsx index 6b31df0896b029..907a2c6dd2870b 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/segment-index-tag.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/segment-index-tag.spec.tsx @@ -59,9 +59,7 @@ describe('SegmentIndexTag', () => { }) it('should apply custom className', () => { - const { container } = render( - <SegmentIndexTag positionId={1} className="custom-class" />, - ) + const { container } = render(<SegmentIndexTag positionId={1} className="custom-class" />) const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-status.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-status.spec.tsx index 8681b67912f3bb..36c46a321f50e8 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-status.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-status.spec.tsx @@ -4,10 +4,14 @@ import { describe, expect, it, vi } from 'vitest' import SummaryStatus from '../summary-status' vi.mock('@/app/components/base/badge', () => ({ - default: ({ children }: { children: React.ReactNode }) => <span data-testid="badge">{children}</span>, + default: ({ children }: { children: React.ReactNode }) => ( + <span data-testid="badge">{children}</span> + ), })) vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({ - SearchLinesSparkle: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="sparkle-icon" {...props} />, + SearchLinesSparkle: (props: React.SVGProps<SVGSVGElement>) => ( + <svg data-testid="sparkle-icon" {...props} /> + ), })) describe('SummaryStatus', () => { diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-text.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-text.spec.tsx index f4478f6b37874e..1e470aa0d515b0 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-text.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-text.spec.tsx @@ -4,7 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import SummaryText from '../summary-text' vi.mock('react-textarea-autosize', () => ({ - default: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea data-testid="textarea" {...props} />, + default: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => ( + <textarea data-testid="textarea" {...props} /> + ), })) describe('SummaryText', () => { diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary.spec.tsx index c6176eeefa441f..d7cc32f004e8a1 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary.spec.tsx @@ -153,7 +153,10 @@ describe('SummaryText', () => { render(<SummaryText />) const textarea = screen.getByRole('textbox') expect(textarea).toBeInTheDocument() - expect(textarea).toHaveAttribute('placeholder', expect.stringContaining('segment.summaryPlaceholder')) + expect(textarea).toHaveAttribute( + 'placeholder', + expect.stringContaining('segment.summaryPlaceholder'), + ) }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx b/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx index 47483ac487a6b0..a8508c7572aa8a 100644 --- a/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx @@ -28,8 +28,8 @@ const ActionButtons: FC<IActionButtonsProps> = ({ showRegenerationButton = true, }) => { const { t } = useTranslation() - const docForm = useDocumentContext(s => s.docForm) - const parentMode = useDocumentContext(s => s.parentMode) + const docForm = useDocumentContext((s) => s.docForm) + const parentMode = useDocumentContext((s) => s.parentMode) useHotkey('Escape', (e) => { e.preventDefault() @@ -38,8 +38,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({ useHotkey('Mod+S', (e) => { e.preventDefault() - if (loading) - return + if (loading) return handleSave() }) @@ -49,36 +48,34 @@ const ActionButtons: FC<IActionButtonsProps> = ({ return ( <div className="flex items-center gap-x-2"> - <Button - onClick={handleCancel} - > + <Button onClick={handleCancel}> <div className="flex items-center gap-x-1"> - <span className="system-sm-medium text-components-button-secondary-text">{t($ => $['operation.cancel'], { ns: 'common' })}</span> + <span className="system-sm-medium text-components-button-secondary-text"> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </span> <Kbd>{formatForDisplay('Escape')}</Kbd> </div> </Button> - {(isParentChildParagraphMode && actionType === 'edit' && !isChildChunk && showRegenerationButton) - ? ( - <Button - onClick={handleRegeneration} - disabled={loading} - > - <span className="system-sm-medium text-components-button-secondary-text"> - {t($ => $['operation.saveAndRegenerate'], { ns: 'common' })} - </span> - </Button> - ) - : null} - <Button - variant="primary" - onClick={handleSave} - disabled={loading} - > + {isParentChildParagraphMode && + actionType === 'edit' && + !isChildChunk && + showRegenerationButton ? ( + <Button onClick={handleRegeneration} disabled={loading}> + <span className="system-sm-medium text-components-button-secondary-text"> + {t(($) => $['operation.saveAndRegenerate'], { ns: 'common' })} + </span> + </Button> + ) : null} + <Button variant="primary" onClick={handleSave} disabled={loading}> <div className="flex items-center gap-x-1"> - <span className="text-components-button-primary-text">{t($ => $['operation.save'], { ns: 'common' })}</span> + <span className="text-components-button-primary-text"> + {t(($) => $['operation.save'], { ns: 'common' })} + </span> <KbdGroup> - {['Mod', 'S'].map(key => ( - <Kbd key={key} color="white">{formatForDisplay(key)}</Kbd> + {['Mod', 'S'].map((key) => ( + <Kbd key={key} color="white"> + {formatForDisplay(key)} + </Kbd> ))} </KbdGroup> </div> diff --git a/web/app/components/datasets/documents/detail/completed/common/add-another.tsx b/web/app/components/datasets/documents/detail/completed/common/add-another.tsx index 051d098dade985..6e80aea16a4f74 100644 --- a/web/app/components/datasets/documents/detail/completed/common/add-another.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/add-another.tsx @@ -10,11 +10,7 @@ type AddAnotherProps = { onCheckedChange: (checked: boolean) => void } -const AddAnother: FC<AddAnotherProps> = ({ - className, - checked, - onCheckedChange, -}) => { +const AddAnother: FC<AddAnotherProps> = ({ className, checked, onCheckedChange }) => { const { t } = useTranslation() return ( @@ -25,7 +21,9 @@ const AddAnother: FC<AddAnotherProps> = ({ checked={checked} onCheckedChange={onCheckedChange} /> - <span className="system-xs-medium text-text-tertiary">{t($ => $['segment.addAnother'], { ns: 'datasetDocuments' })}</span> + <span className="system-xs-medium text-text-tertiary"> + {t(($) => $['segment.addAnother'], { ns: 'datasetDocuments' })} + </span> </label> ) } diff --git a/web/app/components/datasets/documents/detail/completed/common/batch-action.tsx b/web/app/components/datasets/documents/detail/completed/common/batch-action.tsx index cb79c51259bcca..a5bfe516cc36a7 100644 --- a/web/app/components/datasets/documents/detail/completed/common/batch-action.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/batch-action.tsx @@ -10,7 +10,15 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDownload2Line, RiDraftLine, RiRefreshLine } from '@remixicon/react' +import { + RiArchive2Line, + RiCheckboxCircleLine, + RiCloseCircleLine, + RiDeleteBinLine, + RiDownload2Line, + RiDraftLine, + RiRefreshLine, +} from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -47,17 +55,12 @@ const BatchAction: FC<IBatchActionProps> = ({ onCancel, }) => { const { t } = useTranslation() - const [isShowDeleteConfirm, { - setTrue: showDeleteConfirm, - setFalse: hideDeleteConfirm, - }] = useBoolean(false) - const [isDeleting, { - setTrue: setIsDeleting, - }] = useBoolean(false) + const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = + useBoolean(false) + const [isDeleting, { setTrue: setIsDeleting }] = useBoolean(false) const handleBatchDelete = async () => { - if (!onBatchDelete) - return + if (!onBatchDelete) return setIsDeleting() await onBatchDelete() @@ -70,77 +73,61 @@ const BatchAction: FC<IBatchActionProps> = ({ <span className="flex size-5 items-center justify-center rounded-md bg-text-accent system-xs-medium text-text-primary-on-surface"> {selectedIds.length} </span> - <span className="system-sm-semibold text-text-accent">{t($ => $[`${i18nPrefix}.selected`], { ns: 'dataset' })}</span> + <span className="system-sm-semibold text-text-accent"> + {t(($) => $[`${i18nPrefix}.selected`], { ns: 'dataset' })} + </span> </div> <Divider type="vertical" className="mx-0.5 h-3.5 bg-divider-regular" /> {onBatchEnable && ( - <Button - variant="ghost" - className="gap-x-0.5 px-3" - onClick={onBatchEnable} - > + <Button variant="ghost" className="gap-x-0.5 px-3" onClick={onBatchEnable}> <RiCheckboxCircleLine className="size-4" /> - <span className="px-0.5">{t($ => $[`${i18nPrefix}.enable`], { ns: 'dataset' })}</span> + <span className="px-0.5">{t(($) => $[`${i18nPrefix}.enable`], { ns: 'dataset' })}</span> </Button> )} {onBatchDisable && ( - <Button - variant="ghost" - className="gap-x-0.5 px-3" - onClick={onBatchDisable} - > + <Button variant="ghost" className="gap-x-0.5 px-3" onClick={onBatchDisable}> <RiCloseCircleLine className="size-4" /> - <span className="px-0.5">{t($ => $[`${i18nPrefix}.disable`], { ns: 'dataset' })}</span> + <span className="px-0.5"> + {t(($) => $[`${i18nPrefix}.disable`], { ns: 'dataset' })} + </span> </Button> )} {onEditMetadata && ( - <Button - variant="ghost" - className="gap-x-0.5 px-3" - onClick={onEditMetadata} - > + <Button variant="ghost" className="gap-x-0.5 px-3" onClick={onEditMetadata}> <RiDraftLine className="size-4" /> - <span className="px-0.5">{t($ => $['metadata.metadata'], { ns: 'dataset' })}</span> + <span className="px-0.5">{t(($) => $['metadata.metadata'], { ns: 'dataset' })}</span> </Button> )} {onBatchSummary && IS_CE_EDITION && ( - <Button - variant="ghost" - className="gap-x-0.5 px-3" - onClick={onBatchSummary} - > + <Button variant="ghost" className="gap-x-0.5 px-3" onClick={onBatchSummary}> <SearchLinesSparkle className="size-4" /> - <span className="px-0.5">{t($ => $['list.action.summary'], { ns: 'datasetDocuments' })}</span> + <span className="px-0.5"> + {t(($) => $['list.action.summary'], { ns: 'datasetDocuments' })} + </span> </Button> )} {onArchive && ( - <Button - variant="ghost" - className="gap-x-0.5 px-3" - onClick={onArchive} - > + <Button variant="ghost" className="gap-x-0.5 px-3" onClick={onArchive}> <RiArchive2Line className="size-4" /> - <span className="px-0.5">{t($ => $[`${i18nPrefix}.archive`], { ns: 'dataset' })}</span> + <span className="px-0.5"> + {t(($) => $[`${i18nPrefix}.archive`], { ns: 'dataset' })} + </span> </Button> )} {onBatchReIndex && ( - <Button - variant="ghost" - className="gap-x-0.5 px-3" - onClick={onBatchReIndex} - > + <Button variant="ghost" className="gap-x-0.5 px-3" onClick={onBatchReIndex}> <RiRefreshLine className="size-4" /> - <span className="px-0.5">{t($ => $[`${i18nPrefix}.reIndex`], { ns: 'dataset' })}</span> + <span className="px-0.5"> + {t(($) => $[`${i18nPrefix}.reIndex`], { ns: 'dataset' })} + </span> </Button> )} {onBatchDownload && ( - <Button - variant="ghost" - className="gap-x-0.5 px-3" - onClick={onBatchDownload} - > + <Button variant="ghost" className="gap-x-0.5 px-3" onClick={onBatchDownload}> <RiDownload2Line className="size-4" /> - <span className="px-0.5">{t($ => $[`${i18nPrefix}.download`], { ns: 'dataset' })}</span> + <span className="px-0.5"> + {t(($) => $[`${i18nPrefix}.download`], { ns: 'dataset' })} + </span> </Button> )} {onBatchDelete && ( @@ -151,34 +138,39 @@ const BatchAction: FC<IBatchActionProps> = ({ onClick={showDeleteConfirm} > <RiDeleteBinLine className="size-4" /> - <span className="px-0.5">{t($ => $[`${i18nPrefix}.delete`], { ns: 'dataset' })}</span> + <span className="px-0.5">{t(($) => $[`${i18nPrefix}.delete`], { ns: 'dataset' })}</span> </Button> )} <Divider type="vertical" className="mx-0.5 h-3.5 bg-divider-regular" /> - <Button - variant="ghost" - className="px-3" - onClick={onCancel} - > - <span className="px-0.5">{t($ => $[`${i18nPrefix}.cancel`], { ns: 'dataset' })}</span> + <Button variant="ghost" className="px-3" onClick={onCancel}> + <span className="px-0.5">{t(($) => $[`${i18nPrefix}.cancel`], { ns: 'dataset' })}</span> </Button> </div> {onBatchDelete && ( - <AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}> + <AlertDialog + open={isShowDeleteConfirm} + onOpenChange={(open) => !open && hideDeleteConfirm()} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['list.delete.title'], { ns: 'datasetDocuments' })} + {t(($) => $['list.delete.title'], { ns: 'datasetDocuments' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['list.delete.content'], { ns: 'datasetDocuments' })} + {t(($) => $['list.delete.content'], { ns: 'datasetDocuments' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton loading={isDeleting} disabled={isDeleting} onClick={handleBatchDelete}> - {t($ => $['operation.sure'], { ns: 'common' })} + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton + loading={isDeleting} + disabled={isDeleting} + onClick={handleBatchDelete} + > + {t(($) => $['operation.sure'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx b/web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx index 84152261b3b523..40345d9fd0c327 100644 --- a/web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx @@ -8,23 +8,22 @@ import { ChunkingMode } from '@/models/datasets' type IContentProps = ComponentProps<'textarea'> -const Textarea: FC<IContentProps> = React.memo(({ - value, - placeholder, - className, - disabled, - ...rest -}) => { - return ( - <textarea - className={cn('inset-0 w-full resize-none appearance-none overflow-y-auto border-none bg-transparent outline-hidden', className)} - placeholder={placeholder} - value={value} - disabled={disabled} - {...rest} - /> - ) -}) +const Textarea: FC<IContentProps> = React.memo( + ({ value, placeholder, className, disabled, ...rest }) => { + return ( + <textarea + className={cn( + 'inset-0 w-full resize-none appearance-none overflow-y-auto border-none bg-transparent outline-hidden', + className, + )} + placeholder={placeholder} + value={value} + disabled={disabled} + {...rest} + /> + ) + }, +) Textarea.displayName = 'Textarea' @@ -33,64 +32,59 @@ type IAutoResizeTextAreaProps = ComponentProps<'textarea'> & { labelRef: React.RefObject<HTMLDivElement | null> } -const AutoResizeTextArea: FC<IAutoResizeTextAreaProps> = React.memo(({ - className, - placeholder, - value, - disabled, - containerRef, - labelRef, - ...rest -}) => { - const textareaRef = useRef<HTMLTextAreaElement>(null) - const observerRef = useRef<ResizeObserver>(null) - const [maxHeight, setMaxHeight] = useState(0) - - useEffect(() => { - const textarea = textareaRef.current - if (!textarea) - return - textarea.style.height = 'auto' - const lineHeight = Number.parseInt(getComputedStyle(textarea).lineHeight) - const textareaHeight = Math.max(textarea.scrollHeight, lineHeight) - textarea.style.height = `${textareaHeight}px` - }, [value]) - - useEffect(() => { - const container = containerRef.current - const label = labelRef.current - if (!container || !label) - return - const updateMaxHeight = () => { - const containerHeight = container.clientHeight - const labelHeight = label.clientHeight - const padding = 32 - const space = 12 - const maxHeight = Math.floor((containerHeight - 2 * labelHeight - padding - space) / 2) - setMaxHeight(maxHeight) - } - updateMaxHeight() - observerRef.current = new ResizeObserver(updateMaxHeight) - observerRef.current.observe(container) - return () => { - observerRef.current?.disconnect() - } - }, []) +const AutoResizeTextArea: FC<IAutoResizeTextAreaProps> = React.memo( + ({ className, placeholder, value, disabled, containerRef, labelRef, ...rest }) => { + const textareaRef = useRef<HTMLTextAreaElement>(null) + const observerRef = useRef<ResizeObserver>(null) + const [maxHeight, setMaxHeight] = useState(0) + + useEffect(() => { + const textarea = textareaRef.current + if (!textarea) return + textarea.style.height = 'auto' + const lineHeight = Number.parseInt(getComputedStyle(textarea).lineHeight) + const textareaHeight = Math.max(textarea.scrollHeight, lineHeight) + textarea.style.height = `${textareaHeight}px` + }, [value]) + + useEffect(() => { + const container = containerRef.current + const label = labelRef.current + if (!container || !label) return + const updateMaxHeight = () => { + const containerHeight = container.clientHeight + const labelHeight = label.clientHeight + const padding = 32 + const space = 12 + const maxHeight = Math.floor((containerHeight - 2 * labelHeight - padding - space) / 2) + setMaxHeight(maxHeight) + } + updateMaxHeight() + observerRef.current = new ResizeObserver(updateMaxHeight) + observerRef.current.observe(container) + return () => { + observerRef.current?.disconnect() + } + }, []) - return ( - <textarea - ref={textareaRef} - className={cn('inset-0 w-full resize-none appearance-none border-none bg-transparent outline-hidden', className)} - style={{ - maxHeight, - }} - placeholder={placeholder} - value={value} - disabled={disabled} - {...rest} - /> - ) -}) + return ( + <textarea + ref={textareaRef} + className={cn( + 'inset-0 w-full resize-none appearance-none border-none bg-transparent outline-hidden', + className, + )} + style={{ + maxHeight, + }} + placeholder={placeholder} + value={value} + disabled={disabled} + {...rest} + /> + ) + }, +) AutoResizeTextArea.displayName = 'AutoResizeTextArea' @@ -102,43 +96,41 @@ type IQATextAreaProps = { isEditMode?: boolean } -const QATextArea: FC<IQATextAreaProps> = React.memo(({ - question, - answer, - onQuestionChange, - onAnswerChange, - isEditMode = true, -}) => { - const { t } = useTranslation() - const containerRef = useRef<HTMLDivElement>(null) - const labelRef = useRef<HTMLDivElement>(null) +const QATextArea: FC<IQATextAreaProps> = React.memo( + ({ question, answer, onQuestionChange, onAnswerChange, isEditMode = true }) => { + const { t } = useTranslation() + const containerRef = useRef<HTMLDivElement>(null) + const labelRef = useRef<HTMLDivElement>(null) - return ( - <div ref={containerRef} className="h-full overflow-hidden"> - <div ref={labelRef} className="mb-1 text-xs font-medium text-text-tertiary">QUESTION</div> - <AutoResizeTextArea - className="text-sm tracking-[-0.07px] text-text-secondary caret-[#295EFF]" - value={question} - placeholder={t($ => $['segment.questionPlaceholder'], { ns: 'datasetDocuments' }) || ''} - onChange={e => onQuestionChange(e.target.value)} - disabled={!isEditMode} - containerRef={containerRef} - labelRef={labelRef} - /> - <div className="mt-6 mb-1 text-xs font-medium text-text-tertiary">ANSWER</div> - <AutoResizeTextArea - className="text-sm tracking-[-0.07px] text-text-secondary caret-[#295EFF]" - value={answer} - placeholder={t($ => $['segment.answerPlaceholder'], { ns: 'datasetDocuments' }) || ''} - onChange={e => onAnswerChange?.(e.target.value)} - disabled={!isEditMode} - autoFocus - containerRef={containerRef} - labelRef={labelRef} - /> - </div> - ) -}) + return ( + <div ref={containerRef} className="h-full overflow-hidden"> + <div ref={labelRef} className="mb-1 text-xs font-medium text-text-tertiary"> + QUESTION + </div> + <AutoResizeTextArea + className="text-sm tracking-[-0.07px] text-text-secondary caret-[#295EFF]" + value={question} + placeholder={t(($) => $['segment.questionPlaceholder'], { ns: 'datasetDocuments' }) || ''} + onChange={(e) => onQuestionChange(e.target.value)} + disabled={!isEditMode} + containerRef={containerRef} + labelRef={labelRef} + /> + <div className="mt-6 mb-1 text-xs font-medium text-text-tertiary">ANSWER</div> + <AutoResizeTextArea + className="text-sm tracking-[-0.07px] text-text-secondary caret-[#295EFF]" + value={answer} + placeholder={t(($) => $['segment.answerPlaceholder'], { ns: 'datasetDocuments' }) || ''} + onChange={(e) => onAnswerChange?.(e.target.value)} + disabled={!isEditMode} + autoFocus + containerRef={containerRef} + labelRef={labelRef} + /> + </div> + ) + }, +) QATextArea.displayName = 'QATextArea' @@ -187,8 +179,8 @@ const ChunkContent: FC<IChunkContentProps> = ({ <Textarea className="h-full w-full pb-6 body-md-regular tracking-[-0.07px] text-text-secondary caret-[#295EFF]" value={question} - placeholder={t($ => $['segment.contentPlaceholder'], { ns: 'datasetDocuments' }) || ''} - onChange={e => onQuestionChange(e.target.value)} + placeholder={t(($) => $['segment.contentPlaceholder'], { ns: 'datasetDocuments' }) || ''} + onChange={(e) => onQuestionChange(e.target.value)} disabled={!isEditMode} autoFocus /> diff --git a/web/app/components/datasets/documents/detail/completed/common/dot.tsx b/web/app/components/datasets/documents/detail/completed/common/dot.tsx index d0a3543851b10f..e4ba26cfdefded 100644 --- a/web/app/components/datasets/documents/detail/completed/common/dot.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/dot.tsx @@ -1,9 +1,7 @@ import * as React from 'react' const Dot = () => { - return ( - <div className="system-xs-medium text-text-quaternary">·</div> - ) + return <div className="system-xs-medium text-text-quaternary">·</div> } Dot.displayName = 'Dot' diff --git a/web/app/components/datasets/documents/detail/completed/common/drawer.tsx b/web/app/components/datasets/documents/detail/completed/common/drawer.tsx index dac3cef9f2d285..129b8f2250de7e 100644 --- a/web/app/components/datasets/documents/detail/completed/common/drawer.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/drawer.tsx @@ -46,8 +46,7 @@ export function CompletedDrawer({ panelContentClassName, modal = false, }: CompletedDrawerProps) { - if (!open) - return null + if (!open) return null return ( <Drawer @@ -55,14 +54,10 @@ export function CompletedDrawer({ modal={modal} swipeDirection={SIDE_TO_SWIPE_DIRECTION[side]} disablePointerDismissal - onOpenChange={nextOpen => !nextOpen && onClose()} + onOpenChange={(nextOpen) => !nextOpen && onClose()} > <DrawerPortal> - {modal && ( - <DrawerBackdrop - onClick={onClose} - /> - )} + {modal && <DrawerBackdrop onClick={onClose} />} <DrawerViewport className="pointer-events-none"> <DrawerPopup aria-modal={modal ? 'true' : 'false'} diff --git a/web/app/components/datasets/documents/detail/completed/common/empty.tsx b/web/app/components/datasets/documents/detail/completed/common/empty.tsx index 9e0a2d2ef2e952..b7c0c512918687 100644 --- a/web/app/components/datasets/documents/detail/completed/common/empty.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/empty.tsx @@ -8,9 +8,7 @@ type IEmptyProps = { } const EmptyCard = React.memo(() => { - return ( - <div className="h-32 w-full shrink-0 rounded-xl bg-background-section-burn opacity-30" /> - ) + return <div className="h-32 w-full shrink-0 rounded-xl bg-background-section-burn opacity-30" /> }) EmptyCard.displayName = 'EmptyCard' @@ -19,14 +17,26 @@ type LineProps = { className?: string } -const Line = React.memo(({ - className, -}: LineProps) => { +const Line = React.memo(({ className }: LineProps) => { return ( - <svg xmlns="http://www.w3.org/2000/svg" width="2" height="241" viewBox="0 0 2 241" fill="none" className={className}> + <svg + xmlns="http://www.w3.org/2000/svg" + width="2" + height="241" + viewBox="0 0 2 241" + fill="none" + className={className} + > <path d="M1 0.5L1 240.5" stroke="url(#paint0_linear_1989_74474)" /> <defs> - <linearGradient id="paint0_linear_1989_74474" x1="-7.99584" y1="240.5" x2="-7.88094" y2="0.50004" gradientUnits="userSpaceOnUse"> + <linearGradient + id="paint0_linear_1989_74474" + x1="-7.99584" + y1="240.5" + x2="-7.88094" + y2="0.50004" + gradientUnits="userSpaceOnUse" + > <stop stopColor="white" stopOpacity="0.01" /> <stop offset="0.503965" stopColor="#101828" stopOpacity="0.08" /> <stop offset="1" stopColor="white" stopOpacity="0.01" /> @@ -38,9 +48,7 @@ const Line = React.memo(({ Line.displayName = 'Line' -const Empty: FC<IEmptyProps> = ({ - onClearFilter, -}) => { +const Empty: FC<IEmptyProps> = ({ onClearFilter }) => { const { t } = useTranslation() return ( @@ -54,22 +62,20 @@ const Empty: FC<IEmptyProps> = ({ <Line className="absolute top-full left-1/2 -translate-1/2 rotate-90" /> </div> <div className="mt-3 system-md-regular text-text-tertiary"> - {t($ => $['segment.empty'], { ns: 'datasetDocuments' })} + {t(($) => $['segment.empty'], { ns: 'datasetDocuments' })} </div> <button type="button" className="mt-1 system-sm-medium text-text-accent" onClick={onClearFilter} > - {t($ => $['segment.clearFilter'], { ns: 'datasetDocuments' })} + {t(($) => $['segment.clearFilter'], { ns: 'datasetDocuments' })} </button> </div> <div className="absolute top-0 left-0 -z-20 flex size-full flex-col gap-y-3 overflow-hidden"> - { - Array.from({ length: 10 }).map((_, i) => ( - <EmptyCard key={i} /> - )) - } + {Array.from({ length: 10 }).map((_, i) => ( + <EmptyCard key={i} /> + ))} </div> <div className="absolute top-0 left-0 -z-10 size-full bg-dataset-chunk-list-mask-bg" /> </div> diff --git a/web/app/components/datasets/documents/detail/completed/common/keywords.tsx b/web/app/components/datasets/documents/detail/completed/common/keywords.tsx index 5f837df4f01855..4c5ff1511a46c0 100644 --- a/web/app/components/datasets/documents/detail/completed/common/keywords.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/keywords.tsx @@ -25,18 +25,20 @@ const Keywords: FC<IKeywordsProps> = ({ const { t } = useTranslation() return ( <div className={cn('flex flex-col', className)}> - <div className="system-xs-medium-uppercase text-text-tertiary">{t($ => $['segment.keywords'], { ns: 'datasetDocuments' })}</div> + <div className="system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['segment.keywords'], { ns: 'datasetDocuments' })} + </div> <div className="flex max-h-[200px] w-full flex-wrap gap-1 overflow-auto text-text-tertiary"> - {(!segInfo?.keywords?.length && actionType === 'view') - ? '-' - : ( - <TagInput - items={keywords} - onChange={newKeywords => onKeywordsChange(newKeywords)} - disableAdd={!isEditMode} - disableRemove={!isEditMode || (keywords.length === 1)} - /> - )} + {!segInfo?.keywords?.length && actionType === 'view' ? ( + '-' + ) : ( + <TagInput + items={keywords} + onChange={(newKeywords) => onKeywordsChange(newKeywords)} + disableAdd={!isEditMode} + disableRemove={!isEditMode || keywords.length === 1} + /> + )} </div> </div> ) diff --git a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx b/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx index be189d41c1869c..8bdcbaec1e99bf 100644 --- a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx @@ -18,24 +18,23 @@ type IDefaultContentProps = { onConfirm: () => void } -const DefaultContent: FC<IDefaultContentProps> = React.memo(({ - onCancel, - onConfirm, -}) => { +const DefaultContent: FC<IDefaultContentProps> = React.memo(({ onCancel, onConfirm }) => { const { t } = useTranslation() return ( <> <div className="pb-4"> - <AlertDialogTitle className="title-2xl-semi-bold text-text-primary">{t($ => $['segment.regenerationConfirmTitle'], { ns: 'datasetDocuments' })}</AlertDialogTitle> - <AlertDialogDescription className="system-md-regular text-text-secondary">{t($ => $['segment.regenerationConfirmMessage'], { ns: 'datasetDocuments' })}</AlertDialogDescription> + <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['segment.regenerationConfirmTitle'], { ns: 'datasetDocuments' })} + </AlertDialogTitle> + <AlertDialogDescription className="system-md-regular text-text-secondary"> + {t(($) => $['segment.regenerationConfirmMessage'], { ns: 'datasetDocuments' })} + </AlertDialogDescription> </div> <div className="flex justify-end gap-x-2 pt-6"> - <Button onClick={onCancel}> - {t($ => $['operation.cancel'], { ns: 'common' })} - </Button> + <Button onClick={onCancel}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> <Button variant="primary" tone="destructive" onClick={onConfirm}> - {t($ => $['operation.regenerate'], { ns: 'common' })} + {t(($) => $['operation.regenerate'], { ns: 'common' })} </Button> </div> </> @@ -50,13 +49,22 @@ const RegeneratingContent: FC = React.memo(() => { return ( <> <div className="pb-4"> - <span className="title-2xl-semi-bold text-text-primary">{t($ => $['segment.regeneratingTitle'], { ns: 'datasetDocuments' })}</span> - <p className="system-md-regular text-text-secondary">{t($ => $['segment.regeneratingMessage'], { ns: 'datasetDocuments' })}</p> + <span className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['segment.regeneratingTitle'], { ns: 'datasetDocuments' })} + </span> + <p className="system-md-regular text-text-secondary"> + {t(($) => $['segment.regeneratingMessage'], { ns: 'datasetDocuments' })} + </p> </div> <div className="flex justify-end pt-6"> - <Button variant="primary" tone="destructive" disabled className="inline-flex items-center gap-x-0.5"> + <Button + variant="primary" + tone="destructive" + disabled + className="inline-flex items-center gap-x-0.5" + > <RiLoader2Line className="size-4 animate-spin text-components-button-destructive-primary-text-disabled" /> - <span>{t($ => $['operation.regenerate'], { ns: 'common' })}</span> + <span>{t(($) => $['operation.regenerate'], { ns: 'common' })}</span> </Button> </div> </> @@ -69,32 +77,36 @@ type IRegenerationCompletedContentProps = { onClose: () => void } -const RegenerationCompletedContent: FC<IRegenerationCompletedContentProps> = React.memo(({ - onClose, -}) => { - const { t } = useTranslation() - const targetTime = useRef(Date.now() + 5000) - const [countdown] = useCountDown({ - targetDate: targetTime.current, - onEnd: () => { - onClose() - }, - }) - - return ( - <> - <div className="pb-4"> - <span className="title-2xl-semi-bold text-text-primary">{t($ => $['segment.regenerationSuccessTitle'], { ns: 'datasetDocuments' })}</span> - <p className="system-md-regular text-text-secondary">{t($ => $['segment.regenerationSuccessMessage'], { ns: 'datasetDocuments' })}</p> - </div> - <div className="flex justify-end pt-6"> - <Button variant="primary" onClick={onClose}> - {`${t($ => $['operation.close'], { ns: 'common' })}${countdown === 0 ? '' : `(${Math.round(countdown / 1000)})`}`} - </Button> - </div> - </> - ) -}) +const RegenerationCompletedContent: FC<IRegenerationCompletedContentProps> = React.memo( + ({ onClose }) => { + const { t } = useTranslation() + const targetTime = useRef(Date.now() + 5000) + const [countdown] = useCountDown({ + targetDate: targetTime.current, + onEnd: () => { + onClose() + }, + }) + + return ( + <> + <div className="pb-4"> + <span className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['segment.regenerationSuccessTitle'], { ns: 'datasetDocuments' })} + </span> + <p className="system-md-regular text-text-secondary"> + {t(($) => $['segment.regenerationSuccessMessage'], { ns: 'datasetDocuments' })} + </p> + </div> + <div className="flex justify-end pt-6"> + <Button variant="primary" onClick={onClose}> + {`${t(($) => $['operation.close'], { ns: 'common' })}${countdown === 0 ? '' : `(${Math.round(countdown / 1000)})`}`} + </Button> + </div> + </> + ) + }, +) RegenerationCompletedContent.displayName = 'RegenerationCompletedContent' @@ -120,16 +132,16 @@ const RegenerationModal: FC<IRegenerationModalProps> = ({ setLoading(true) setUpdateSucceeded(false) } - if (v === 'update-segment-success') - setUpdateSucceeded(true) - if (v === 'update-segment-done') - setLoading(false) + if (v === 'update-segment-success') setUpdateSucceeded(true) + if (v === 'update-segment-done') setLoading(false) }) return ( <AlertDialog open={isShow}> <AlertDialogContent className="w-[calc(100vw-2rem)] max-w-[480px]! overflow-hidden! rounded-2xl! border-none p-6 text-left align-middle shadow-xl"> - {!loading && !updateSucceeded && <DefaultContent onCancel={onCancel} onConfirm={onConfirm} />} + {!loading && !updateSucceeded && ( + <DefaultContent onCancel={onCancel} onConfirm={onConfirm} /> + )} {loading && !updateSucceeded && <RegeneratingContent />} {!loading && updateSucceeded && <RegenerationCompletedContent onClose={onClose} />} </AlertDialogContent> diff --git a/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.tsx b/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.tsx index fb44062c915184..b3acce685fe6c5 100644 --- a/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.tsx @@ -23,8 +23,7 @@ export const SegmentIndexTag: FC<ISegmentIndexTagProps> = ({ }) => { const localPositionId = useMemo(() => { const positionIdStr = String(positionId) - if (positionIdStr.length >= 2) - return `${labelPrefix}-${positionId}` + if (positionIdStr.length >= 2) return `${labelPrefix}-${positionId}` return `${labelPrefix}-${positionIdStr.padStart(2, '0')}` }, [positionId, labelPrefix]) return ( diff --git a/web/app/components/datasets/documents/detail/completed/common/summary-label.tsx b/web/app/components/datasets/documents/detail/completed/common/summary-label.tsx index d8cfa1a56dc98b..89c80f339cfd1d 100644 --- a/web/app/components/datasets/documents/detail/completed/common/summary-label.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/summary-label.tsx @@ -6,16 +6,13 @@ type SummaryLabelProps = { summary?: string className?: string } -const SummaryLabel = ({ - summary, - className, -}: SummaryLabelProps) => { +const SummaryLabel = ({ summary, className }: SummaryLabelProps) => { const { t } = useTranslation() return ( <div className={cn('space-y-1', className)}> <div className="mt-2 flex items-center justify-between system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['segment.summary'], { ns: 'datasetDocuments' })} + {t(($) => $['segment.summary'], { ns: 'datasetDocuments' })} <div className="ml-2 h-px grow bg-divider-regular"></div> </div> <div className="body-xs-regular text-text-tertiary">{summary}</div> diff --git a/web/app/components/datasets/documents/detail/completed/common/summary-status.tsx b/web/app/components/datasets/documents/detail/completed/common/summary-status.tsx index 860c24c679837f..28ab0b4dfc17c0 100644 --- a/web/app/components/datasets/documents/detail/completed/common/summary-status.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/summary-status.tsx @@ -13,25 +13,24 @@ const SummaryStatus = ({ status }: SummaryStatusProps) => { const tip = useMemo(() => { if (status === 'SUMMARIZING') { - return t($ => $['list.summary.generatingSummary'], { ns: 'datasetDocuments' }) + return t(($) => $['list.summary.generatingSummary'], { ns: 'datasetDocuments' }) } return '' }, [status, t]) - if (status !== 'SUMMARIZING') - return null + if (status !== 'SUMMARIZING') return null return ( <Tooltip> <TooltipTrigger - render={( + render={ <span className="inline-flex"> <Badge className="border-text-accent-secondary text-text-accent-secondary"> <SearchLinesSparkle aria-hidden className="mr-0.5 size-3" /> - <span>{t($ => $['list.summary.generating'], { ns: 'datasetDocuments' })}</span> + <span>{t(($) => $['list.summary.generating'], { ns: 'datasetDocuments' })}</span> </Badge> </span> - )} + } /> <TooltipContent>{tip}</TooltipContent> </Tooltip> diff --git a/web/app/components/datasets/documents/detail/completed/common/summary-text.tsx b/web/app/components/datasets/documents/detail/completed/common/summary-text.tsx index fe8e91d363f3b2..374f45d773edc9 100644 --- a/web/app/components/datasets/documents/detail/completed/common/summary-text.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/summary-text.tsx @@ -8,24 +8,22 @@ type SummaryTextProps = { onChange?: (value: string) => void disabled?: boolean } -const SummaryText = ({ - value, - onChange, - disabled, -}: SummaryTextProps) => { +const SummaryText = ({ value, onChange, disabled }: SummaryTextProps) => { const { t } = useTranslation() return ( <div className="space-y-1"> - <div className="system-xs-medium-uppercase text-text-tertiary">{t($ => $['segment.summary'], { ns: 'datasetDocuments' })}</div> + <div className="system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['segment.summary'], { ns: 'datasetDocuments' })} + </div> <Textarea className={cn( 'w-full resize-none bg-transparent body-sm-regular leading-6 text-text-secondary outline-hidden', )} - placeholder={t($ => $['segment.summaryPlaceholder'], { ns: 'datasetDocuments' })} + placeholder={t(($) => $['segment.summaryPlaceholder'], { ns: 'datasetDocuments' })} minRows={1} value={value ?? ''} - onChange={e => onChange?.(e.target.value)} + onChange={(e) => onChange?.(e.target.value)} disabled={disabled} /> </div> diff --git a/web/app/components/datasets/documents/detail/completed/common/tag.tsx b/web/app/components/datasets/documents/detail/completed/common/tag.tsx index 98c7df7fca95e3..77d626c5d6ff96 100644 --- a/web/app/components/datasets/documents/detail/completed/common/tag.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/tag.tsx @@ -1,7 +1,7 @@ import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' -const Tag = ({ text, className }: { text: string, className?: string }) => { +const Tag = ({ text, className }: { text: string; className?: string }) => { return ( <div className={cn('inline-flex items-center gap-x-0.5', className)}> <span className="text-xs font-medium text-text-quaternary">#</span> diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx index 4e3c935f0a32d7..48345b0328198c 100644 --- a/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx @@ -5,9 +5,20 @@ import { ChunkingMode } from '@/models/datasets' import { DrawerGroup } from '../drawer-group' vi.mock('../../common/full-screen-drawer', () => ({ - DocumentDetailDrawer: ({ open, children, modal = false }: { open: boolean, children: React.ReactNode, modal?: boolean }) => ( - open ? <div data-testid="document-detail-drawer" data-modal={modal}>{children}</div> : null - ), + DocumentDetailDrawer: ({ + open, + children, + modal = false, + }: { + open: boolean + children: React.ReactNode + modal?: boolean + }) => + open ? ( + <div data-testid="document-detail-drawer" data-modal={modal}> + {children} + </div> + ) : null, })) vi.mock('../../segment-detail', () => ({ @@ -60,7 +71,11 @@ describe('DrawerGroup', () => { render( <DrawerGroup {...defaultProps} - currSegment={{ segInfo: { id: 'seg-1' } as SegmentDetailModel, showModal: true, isEditMode: true }} + currSegment={{ + segInfo: { id: 'seg-1' } as SegmentDetailModel, + showModal: true, + isEditMode: true, + }} />, ) expect(screen.getByTestId('segment-detail')).toBeInTheDocument() @@ -68,9 +83,7 @@ describe('DrawerGroup', () => { }) it('should render new segment modal when showNewSegmentModal is true', () => { - render( - <DrawerGroup {...defaultProps} showNewSegmentModal={true} />, - ) + render(<DrawerGroup {...defaultProps} showNewSegmentModal={true} />) expect(screen.getByTestId('new-segment')).toBeInTheDocument() expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true') }) @@ -87,9 +100,7 @@ describe('DrawerGroup', () => { }) it('should render new child segment modal when showNewChildSegmentModal is true', () => { - render( - <DrawerGroup {...defaultProps} showNewChildSegmentModal={true} />, - ) + render(<DrawerGroup {...defaultProps} showNewChildSegmentModal={true} />) expect(screen.getByTestId('new-child-segment')).toBeInTheDocument() expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true') }) diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx index 666c5a673b271f..8f5e9dc769ad67 100644 --- a/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx @@ -5,7 +5,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import MenuBar from '../menu-bar' vi.mock('../../display-toggle', () => ({ - default: ({ isCollapsed, toggleCollapsed }: { isCollapsed: boolean, toggleCollapsed: () => void }) => ( + default: ({ + isCollapsed, + toggleCollapsed, + }: { + isCollapsed: boolean + toggleCollapsed: () => void + }) => ( <button data-testid="display-toggle" onClick={toggleCollapsed}> {isCollapsed ? 'collapsed' : 'expanded'} </button> @@ -56,7 +62,9 @@ describe('MenuBar', () => { it('should not render select all checkbox when there are no selectable segments', () => { renderMenuBar({ hasSelectableSegments: false }) - expect(screen.queryByRole('checkbox', { name: 'common.operation.selectAll' })).not.toBeInTheDocument() + expect( + screen.queryByRole('checkbox', { name: 'common.operation.selectAll' }), + ).not.toBeInTheDocument() }) it('should call onInputChange when input changes', () => { diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx index 9f35b1b2eb7222..cc3c9866efcc56 100644 --- a/web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx @@ -10,25 +10,25 @@ vi.mock('../../child-segment-list', () => ({ })) vi.mock('../../segment-card', () => ({ - default: ({ detail, onClick }: { detail: { id: string }, onClick?: () => void }) => ( - <div data-testid="segment-card" onClick={onClick}>{detail?.id}</div> + default: ({ detail, onClick }: { detail: { id: string }; onClick?: () => void }) => ( + <div data-testid="segment-card" onClick={onClick}> + {detail?.id} + </div> ), })) vi.mock('../../segment-list', () => { const SegmentList = vi.fn(({ items }: { items: { id: string }[] }) => ( - <div data-testid="segment-list"> - {items?.length ?? 0} - {' '} - items - </div> + <div data-testid="segment-list">{items?.length ?? 0} items</div> )) return { default: SegmentList } }) describe('FullDocModeContent', () => { const defaultProps = { - segments: [{ id: 'seg-1', position: 1, content: 'test', word_count: 10 }] as SegmentDetailModel[], + segments: [ + { id: 'seg-1', position: 1, content: 'test', word_count: 10 }, + ] as SegmentDetailModel[], childSegments: [], isLoadingSegmentList: false, isLoadingChildSegmentList: false, diff --git a/web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx b/web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx index 21f94e0ea879c1..d22ecd8d0d3c66 100644 --- a/web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx @@ -1,7 +1,17 @@ 'use client' -import type { SegmentStatusFilterOption, SegmentStatusFilterValue } from '../hooks/use-search-filter' +import type { + SegmentStatusFilterOption, + SegmentStatusFilterValue, +} from '../hooks/use-search-filter' import { Checkbox } from '@langgenius/dify-ui/checkbox' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' @@ -34,38 +44,36 @@ function MenuBar({ toggleCollapsed, }: MenuBarProps) { const { t } = useTranslation() - const selectedStatus = statusList.find(item => item.value === selectDefaultValue) ?? null + const selectedStatus = statusList.find((item) => item.value === selectDefaultValue) ?? null return ( <div className={s.docSearchWrapper}> - {hasSelectableSegments - ? ( - <Checkbox - className="shrink-0" - parent - aria-label={t($ => $['operation.selectAll'], { ns: 'common' })} - disabled={isLoading} - /> - ) - : ( - <span className="size-4 shrink-0" aria-hidden /> - )} - <div className="flex-1 pl-5 system-sm-semibold-uppercase text-text-secondary">{totalText}</div> + {hasSelectableSegments ? ( + <Checkbox + className="shrink-0" + parent + aria-label={t(($) => $['operation.selectAll'], { ns: 'common' })} + disabled={isLoading} + /> + ) : ( + <span className="size-4 shrink-0" aria-hidden /> + )} + <div className="flex-1 pl-5 system-sm-semibold-uppercase text-text-secondary"> + {totalText} + </div> <Select<SegmentStatusFilterValue> value={selectedStatus?.value ?? null} onValueChange={(nextValue) => { - if (nextValue == null) - return - const nextItem = statusList.find(item => item.value === nextValue) - if (nextItem) - onChangeStatus(nextItem) + if (nextValue == null) return + const nextItem = statusList.find((item) => item.value === nextValue) + if (nextItem) onChangeStatus(nextItem) }} > <SelectTrigger className="mr-2 w-[100px] shrink-0 shadow-none"> {selectedStatus?.name ?? ''} </SelectTrigger> <SelectContent popupClassName="w-[160px]"> - {statusList.map(item => ( + {statusList.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -78,7 +86,7 @@ function MenuBar({ showClearIcon wrapperClassName="w-52!" value={inputValue} - onChange={e => onInputChange(e.target.value)} + onChange={(e) => onInputChange(e.target.value)} onClear={() => onInputChange('')} /> <Divider type="vertical" className="mx-3 h-3.5" /> diff --git a/web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx b/web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx index 036743f88b4194..96e001a59d60ac 100644 --- a/web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx @@ -42,10 +42,11 @@ export const FullDocModeContent: FC<FullDocModeContentProps> = ({ const firstSegment = segments[0] return ( - <div className={cn( - 'flex grow flex-col overflow-x-hidden', - (isLoadingSegmentList || isLoadingChildSegmentList) ? 'overflow-y-hidden' : 'overflow-y-auto', - )} + <div + className={cn( + 'flex grow flex-col overflow-x-hidden', + isLoadingSegmentList || isLoadingChildSegmentList ? 'overflow-y-hidden' : 'overflow-y-auto', + )} > <SegmentCard detail={firstSegment} diff --git a/web/app/components/datasets/documents/detail/completed/display-toggle.tsx b/web/app/components/datasets/documents/detail/completed/display-toggle.tsx index 110a647b37eb93..4ba09fb339d228 100644 --- a/web/app/components/datasets/documents/detail/completed/display-toggle.tsx +++ b/web/app/components/datasets/documents/detail/completed/display-toggle.tsx @@ -10,31 +10,29 @@ type DisplayToggleProps = { toggleCollapsed: () => void } -const DisplayToggle: FC<DisplayToggleProps> = ({ - isCollapsed, - toggleCollapsed, -}) => { +const DisplayToggle: FC<DisplayToggleProps> = ({ isCollapsed, toggleCollapsed }) => { const { t } = useTranslation() - const label = isCollapsed ? t($ => $['segment.expandChunks'], { ns: 'datasetDocuments' }) : t($ => $['segment.collapseChunks'], { ns: 'datasetDocuments' }) + const label = isCollapsed + ? t(($) => $['segment.expandChunks'], { ns: 'datasetDocuments' }) + : t(($) => $['segment.collapseChunks'], { ns: 'datasetDocuments' }) return ( <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={label} - className="flex items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border - bg-components-button-secondary-bg p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]" + className="flex items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]" onClick={toggleCollapsed} > - { - isCollapsed - ? <RiLineHeight className="size-4 text-components-button-secondary-text" /> - : <Collapse className="size-4 text-components-button-secondary-text" /> - } + {isCollapsed ? ( + <RiLineHeight className="size-4 text-components-button-secondary-text" /> + ) : ( + <Collapse className="size-4 text-components-button-secondary-text" /> + )} </button> - )} + } /> <TooltipContent className="border-[0.5px] border-components-panel-border system-xs-medium text-text-secondary"> {label} diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts index fb19d49bc21c3e..13f86fe2cacc5b 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts @@ -1,5 +1,11 @@ import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context' -import type { ChildChunkDetail, ChildSegmentsResponse, ChunkingMode, ParentMode, SegmentDetailModel } from '@/models/datasets' +import type { + ChildChunkDetail, + ChildSegmentsResponse, + ChunkingMode, + ParentMode, + SegmentDetailModel, +} from '@/models/datasets' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' import * as React from 'react' @@ -11,7 +17,7 @@ type MutationCallbacks = { onSuccess: (res: MutationResponse) => void onSettled: () => void } -type _ErrorCallback = { onSuccess?: () => void, onError: () => void } +type _ErrorCallback = { onSuccess?: () => void; onError: () => void } // Hoisted Mocks @@ -33,7 +39,11 @@ const { mockNotify: vi.fn(), mockEventEmitter: { emit: vi.fn(), on: vi.fn(), off: vi.fn() }, mockQueryClient: { setQueryData: vi.fn() }, - mockChildSegmentListData: { current: { data: [] as ChildChunkDetail[], total: 0, total_pages: 0 } as ChildSegmentsResponse | undefined }, + mockChildSegmentListData: { + current: { data: [] as ChildChunkDetail[], total: 0, total_pages: 0 } as + | ChildSegmentsResponse + | undefined, + }, mockDeleteChildSegment: vi.fn(), mockUpdateChildSegment: vi.fn(), mockInvalidChildSegmentList: vi.fn(), @@ -84,12 +94,13 @@ vi.mock('@/service/use-base', () => ({ useInvalid: () => mockInvalidChildSegmentList, })) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, -}) +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) const createWrapper = () => { const queryClient = createQueryClient() @@ -190,32 +201,43 @@ describe('useChildSegmentData', () => { mockParentMode.current = 'paragraph' const updateSegmentInCache = vi.fn() - mockDeleteChildSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }) - - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - updateSegmentInCache, - }), { - wrapper: createWrapper(), - }) + mockDeleteChildSegment.mockImplementation( + async (_params, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }, + ) + + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + updateSegmentInCache, + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.onDeleteChildChunk('seg-1', 'child-1') }) expect(mockDeleteChildSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'common.actionMsg.modifiedSuccessfully', + }) expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function)) }) it('should delete child chunk and reset list in full-doc mode', async () => { mockParentMode.current = 'full-doc' - mockDeleteChildSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }) + mockDeleteChildSegment.mockImplementation( + async (_params, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }, + ) const { result } = renderHook(() => useChildSegmentData(defaultOptions), { wrapper: createWrapper(), @@ -229,9 +251,11 @@ describe('useChildSegmentData', () => { }) it('should notify error on failure', async () => { - mockDeleteChildSegment.mockImplementation(async (_params, { onError }: { onError: () => void }) => { - onError() - }) + mockDeleteChildSegment.mockImplementation( + async (_params, { onError }: { onError: () => void }) => { + onError() + }, + ) const { result } = renderHook(() => useChildSegmentData(defaultOptions), { wrapper: createWrapper(), @@ -241,7 +265,10 @@ describe('useChildSegmentData', () => { await result.current.onDeleteChildChunk('seg-1', 'child-1') }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'common.actionMsg.modifiedUnsuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'common.actionMsg.modifiedUnsuccessfully', + }) }) }) @@ -255,7 +282,10 @@ describe('useChildSegmentData', () => { await result.current.handleUpdateChildChunk('seg-1', 'child-1', ' ') }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.contentEmpty' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetDocuments.segment.contentEmpty', + }) expect(mockUpdateChildSegment).not.toHaveBeenCalled() }) @@ -265,33 +295,42 @@ describe('useChildSegmentData', () => { const onCloseChildSegmentDetail = vi.fn() const refreshChunkListDataWithDetailChanged = vi.fn() - mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => { - onSuccess({ - data: createMockChildChunk({ - content: 'updated content', - type: 'customized', - word_count: 50, - updated_at: 1700000001, + mockUpdateChildSegment.mockImplementation( + async (_params, { onSuccess, onSettled }: MutationCallbacks) => { + onSuccess({ + data: createMockChildChunk({ + content: 'updated content', + type: 'customized', + word_count: 50, + updated_at: 1700000001, + }), + }) + onSettled() + }, + ) + + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + updateSegmentInCache, + onCloseChildSegmentDetail, + refreshChunkListDataWithDetailChanged, }), - }) - onSettled() - }) - - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - updateSegmentInCache, - onCloseChildSegmentDetail, - refreshChunkListDataWithDetailChanged, - }), { - wrapper: createWrapper(), - }) + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'updated content') }) expect(mockUpdateChildSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'common.actionMsg.modifiedSuccessfully', + }) expect(onCloseChildSegmentDetail).toHaveBeenCalled() expect(updateSegmentInCache).toHaveBeenCalled() expect(refreshChunkListDataWithDetailChanged).toHaveBeenCalled() @@ -303,24 +342,30 @@ describe('useChildSegmentData', () => { mockParentMode.current = 'full-doc' const onCloseChildSegmentDetail = vi.fn() - mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => { - onSuccess({ - data: createMockChildChunk({ - content: 'updated content', - type: 'customized', - word_count: 50, - updated_at: 1700000001, + mockUpdateChildSegment.mockImplementation( + async (_params, { onSuccess, onSettled }: MutationCallbacks) => { + onSuccess({ + data: createMockChildChunk({ + content: 'updated content', + type: 'customized', + word_count: 50, + updated_at: 1700000001, + }), + }) + onSettled() + }, + ) + + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + onCloseChildSegmentDetail, }), - }) - onSettled() - }) - - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - onCloseChildSegmentDetail, - }), { - wrapper: createWrapper(), - }) + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'updated content') @@ -337,13 +382,17 @@ describe('useChildSegmentData', () => { const refreshChunkListDataWithDetailChanged = vi.fn() const newChildChunk = createMockChildChunk({ id: 'new-child' }) - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - updateSegmentInCache, - refreshChunkListDataWithDetailChanged, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + updateSegmentInCache, + refreshChunkListDataWithDetailChanged, + }), + { + wrapper: createWrapper(), + }, + ) act(() => { result.current.onSaveNewChildChunk(newChildChunk) @@ -372,12 +421,16 @@ describe('useChildSegmentData', () => { it('should set needScrollToBottom and not reset when adding new page', () => { mockChildSegmentListData.current = { data: [], total: 10, total_pages: 1, page: 1, limit: 20 } - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - limit: 10, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + limit: 10, + }), + { + wrapper: createWrapper(), + }, + ) act(() => { result.current.viewNewlyAddedChildChunk() @@ -389,12 +442,16 @@ describe('useChildSegmentData', () => { it('should call resetChildList when not adding new page', () => { mockChildSegmentListData.current = { data: [], total: 5, total_pages: 1, page: 1, limit: 20 } - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - limit: 10, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + limit: 10, + }), + { + wrapper: createWrapper(), + }, + ) act(() => { result.current.viewNewlyAddedChildChunk() @@ -406,24 +463,32 @@ describe('useChildSegmentData', () => { describe('Query disabled states', () => { it('should disable query when not in fullDocMode', () => { - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - isFullDocMode: false, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + isFullDocMode: false, + }), + { + wrapper: createWrapper(), + }, + ) // Query should be disabled but hook should still work expect(result.current.childSegments).toEqual([]) }) it('should disable query when segments is empty', () => { - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - segments: [], - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + segments: [], + }), + { + wrapper: createWrapper(), + }, + ) expect(result.current.childSegments).toEqual([]) }) @@ -434,16 +499,22 @@ describe('useChildSegmentData', () => { mockParentMode.current = 'paragraph' const updateSegmentInCache = vi.fn() - mockDeleteChildSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }) - - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - updateSegmentInCache, - }), { - wrapper: createWrapper(), - }) + mockDeleteChildSegment.mockImplementation( + async (_params, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }, + ) + + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + updateSegmentInCache, + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.onDeleteChildChunk('seg-1', 'child-1') @@ -470,27 +541,33 @@ describe('useChildSegmentData', () => { const onCloseChildSegmentDetail = vi.fn() const refreshChunkListDataWithDetailChanged = vi.fn() - mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => { - onSuccess({ - data: createMockChildChunk({ - id: 'child-1', - content: 'new content', - type: 'customized', - word_count: 50, - updated_at: 1700000001, + mockUpdateChildSegment.mockImplementation( + async (_params, { onSuccess, onSettled }: MutationCallbacks) => { + onSuccess({ + data: createMockChildChunk({ + id: 'child-1', + content: 'new content', + type: 'customized', + word_count: 50, + updated_at: 1700000001, + }), + }) + onSettled() + }, + ) + + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + updateSegmentInCache, + onCloseChildSegmentDetail, + refreshChunkListDataWithDetailChanged, }), - }) - onSettled() - }) - - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - updateSegmentInCache, - onCloseChildSegmentDetail, - refreshChunkListDataWithDetailChanged, - }), { - wrapper: createWrapper(), - }) + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content') @@ -518,25 +595,31 @@ describe('useChildSegmentData', () => { mockParentMode.current = 'full-doc' const onCloseChildSegmentDetail = vi.fn() - mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => { - onSuccess({ - data: createMockChildChunk({ - id: 'child-1', - content: 'new content', - type: 'customized', - word_count: 50, - updated_at: 1700000001, + mockUpdateChildSegment.mockImplementation( + async (_params, { onSuccess, onSettled }: MutationCallbacks) => { + onSuccess({ + data: createMockChildChunk({ + id: 'child-1', + content: 'new content', + type: 'customized', + word_count: 50, + updated_at: 1700000001, + }), + }) + onSettled() + }, + ) + + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + onCloseChildSegmentDetail, }), - }) - onSettled() - }) - - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - onCloseChildSegmentDetail, - }), { - wrapper: createWrapper(), - }) + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content') @@ -550,44 +633,52 @@ describe('useChildSegmentData', () => { const onCloseChildSegmentDetail = vi.fn() // Capture the setQueryData callback to verify null-safety - mockQueryClient.setQueryData.mockImplementation((_key: unknown, updater: (old: unknown) => unknown) => { - if (typeof updater === 'function') { - // Invoke with undefined to cover the !old branch - const resultWithUndefined = updater(undefined) - expect(resultWithUndefined).toBeUndefined() - // Also test with real data - const resultWithData = updater({ - data: [ - createMockChildChunk({ id: 'child-1', content: 'old content' }), - createMockChildChunk({ id: 'child-2', content: 'other' }), - ], - total: 2, - total_pages: 1, - }) as ChildSegmentsResponse - expect(resultWithData.data[0]!.content).toBe('new content') - expect(resultWithData.data[1]!.content).toBe('other') - } - }) - - mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => { - onSuccess({ - data: createMockChildChunk({ - id: 'child-1', - content: 'new content', - type: 'customized', - word_count: 50, - updated_at: 1700000001, + mockQueryClient.setQueryData.mockImplementation( + (_key: unknown, updater: (old: unknown) => unknown) => { + if (typeof updater === 'function') { + // Invoke with undefined to cover the !old branch + const resultWithUndefined = updater(undefined) + expect(resultWithUndefined).toBeUndefined() + // Also test with real data + const resultWithData = updater({ + data: [ + createMockChildChunk({ id: 'child-1', content: 'old content' }), + createMockChildChunk({ id: 'child-2', content: 'other' }), + ], + total: 2, + total_pages: 1, + }) as ChildSegmentsResponse + expect(resultWithData.data[0]!.content).toBe('new content') + expect(resultWithData.data[1]!.content).toBe('other') + } + }, + ) + + mockUpdateChildSegment.mockImplementation( + async (_params, { onSuccess, onSettled }: MutationCallbacks) => { + onSuccess({ + data: createMockChildChunk({ + id: 'child-1', + content: 'new content', + type: 'customized', + word_count: 50, + updated_at: 1700000001, + }), + }) + onSettled() + }, + ) + + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + onCloseChildSegmentDetail, }), - }) - onSettled() - }) - - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - onCloseChildSegmentDetail, - }), { - wrapper: createWrapper(), - }) + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content') @@ -680,12 +771,16 @@ describe('useChildSegmentData', () => { describe('Query params edge cases', () => { it('should handle currentPage of 0 by defaulting to page 1', () => { - const { result } = renderHook(() => useChildSegmentData({ - ...defaultOptions, - currentPage: 0, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useChildSegmentData({ + ...defaultOptions, + currentPage: 0, + }), + { + wrapper: createWrapper(), + }, + ) // Should still work with page defaulted to 1 expect(result.current.childSegments).toEqual([]) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts index 5616af241d85f8..caa917c8a97e1e 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts @@ -1,6 +1,11 @@ import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context' -import type { ChunkingMode, ParentMode, SegmentDetailModel, SegmentsResponse } from '@/models/datasets' +import type { + ChunkingMode, + ParentMode, + SegmentDetailModel, + SegmentsResponse, +} from '@/models/datasets' import type { SegmentImportStatus } from '@/types/dataset' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' @@ -57,7 +62,16 @@ const { mockNotify: vi.fn(), mockEventEmitter: { emit: vi.fn(), on: vi.fn(), off: vi.fn() }, mockQueryClient: { setQueryData: vi.fn() }, - mockSegmentListData: { current: { data: [] as SegmentDetailModel[], total: 0, total_pages: 0, has_more: false, limit: 20, page: 1 } as SegmentsResponse | undefined }, + mockSegmentListData: { + current: { + data: [] as SegmentDetailModel[], + total: 0, + total_pages: 0, + has_more: false, + limit: 20, + page: 1, + } as SegmentsResponse | undefined, + }, mockEnableSegment: vi.fn(), mockDisableSegment: vi.fn(), mockDeleteSegment: vi.fn(), @@ -122,22 +136,20 @@ vi.mock('@/service/knowledge/use-segment', () => ({ vi.mock('@/service/use-base', () => ({ useInvalid: (key: unknown[]) => { const keyObj = key[2] as { enabled?: boolean | 'all' } | undefined - if (keyObj?.enabled === 'all') - return mockInvalidChunkListAll - if (keyObj?.enabled === true) - return mockInvalidChunkListEnabled - if (keyObj?.enabled === false) - return mockInvalidChunkListDisabled + if (keyObj?.enabled === 'all') return mockInvalidChunkListAll + if (keyObj?.enabled === true) return mockInvalidChunkListEnabled + if (keyObj?.enabled === false) return mockInvalidChunkListDisabled return mockInvalidSegmentList }, })) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, -}) +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) const createWrapper = () => { const queryClient = createQueryClient() @@ -193,7 +205,14 @@ describe('useSegmentListData', () => { mockParentMode.current = 'paragraph' mockDatasetId.current = 'test-dataset-id' mockDocumentId.current = 'test-document-id' - mockSegmentListData.current = { data: [], total: 0, total_pages: 0, has_more: false, limit: 20, page: 1 } + mockSegmentListData.current = { + data: [], + total: 0, + total_pages: 0, + has_more: false, + limit: 20, + page: 1, + } mockPathname.current = '/datasets/test/documents/test' }) @@ -231,7 +250,14 @@ describe('useSegmentListData', () => { describe('totalText computation', () => { it('should show chunks count when not searching', () => { - mockSegmentListData.current = { data: [], total: 10, total_pages: 1, has_more: false, limit: 20, page: 1 } + mockSegmentListData.current = { + data: [], + total: 10, + total_pages: 1, + has_more: false, + limit: 20, + page: 1, + } const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), @@ -242,28 +268,50 @@ describe('useSegmentListData', () => { }) it('should show search results when searching', () => { - mockSegmentListData.current = { data: [], total: 5, total_pages: 1, has_more: false, limit: 20, page: 1 } + mockSegmentListData.current = { + data: [], + total: 5, + total_pages: 1, + has_more: false, + limit: 20, + page: 1, + } - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - searchValue: 'test', - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + searchValue: 'test', + }), + { + wrapper: createWrapper(), + }, + ) expect(result.current.totalText).toContain('5') expect(result.current.totalText).toContain('datasetDocuments.segment.searchResults') }) it('should show search results when status is filtered', () => { - mockSegmentListData.current = { data: [], total: 3, total_pages: 1, has_more: false, limit: 20, page: 1 } + mockSegmentListData.current = { + data: [], + total: 3, + total_pages: 1, + has_more: false, + limit: 20, + page: 1, + } - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - selectedStatus: true, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + selectedStatus: true, + }), + { + wrapper: createWrapper(), + }, + ) expect(result.current.totalText).toContain('datasetDocuments.segment.searchResults') }) @@ -271,7 +319,14 @@ describe('useSegmentListData', () => { it('should show parent chunks in parentChild paragraph mode', () => { mockDocForm.current = ChunkingModeEnum.parentChild mockParentMode.current = 'paragraph' - mockSegmentListData.current = { data: [], total: 7, total_pages: 1, has_more: false, limit: 20, page: 1 } + mockSegmentListData.current = { + data: [], + total: 7, + total_pages: 1, + has_more: false, + limit: 20, + page: 1, + } const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), @@ -295,12 +350,16 @@ describe('useSegmentListData', () => { it('should call clearSelection and invalidSegmentList', () => { const clearSelection = vi.fn() - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - clearSelection, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + clearSelection, + }), + { + wrapper: createWrapper(), + }, + ) act(() => { result.current.resetList() @@ -313,16 +372,22 @@ describe('useSegmentListData', () => { describe('refreshChunkListWithStatusChanged', () => { it('should invalidate disabled and enabled when status is all', async () => { - mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }) + mockEnableSegment.mockImplementation( + async (_params, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }, + ) - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - selectedStatus: 'all', - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + selectedStatus: 'all', + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.onChangeSwitch(true, 'seg-1') @@ -333,16 +398,22 @@ describe('useSegmentListData', () => { }) it('should invalidate segment list when status is not all', async () => { - mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }) + mockEnableSegment.mockImplementation( + async (_params, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }, + ) - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - selectedStatus: true, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + selectedStatus: true, + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.onChangeSwitch(true, 'seg-1') @@ -354,9 +425,11 @@ describe('useSegmentListData', () => { describe('onChangeSwitch', () => { it('should call enableSegment when enable is true', async () => { - mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }) + mockEnableSegment.mockImplementation( + async (_params, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }, + ) const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), @@ -367,13 +440,18 @@ describe('useSegmentListData', () => { }) expect(mockEnableSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'common.actionMsg.modifiedSuccessfully', + }) }) it('should call disableSegment when enable is false', async () => { - mockDisableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }) + mockDisableSegment.mockImplementation( + async (_params, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }, + ) const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), @@ -387,16 +465,22 @@ describe('useSegmentListData', () => { }) it('should use selectedSegmentIds when segId is empty', async () => { - mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }) + mockEnableSegment.mockImplementation( + async (_params, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }, + ) - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - selectedSegmentIds: ['seg-1', 'seg-2'], - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + selectedSegmentIds: ['seg-1', 'seg-2'], + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.onChangeSwitch(true, '') @@ -409,9 +493,11 @@ describe('useSegmentListData', () => { }) it('should notify error on failure', async () => { - mockEnableSegment.mockImplementation(async (_params, { onError }: { onError: () => void }) => { - onError() - }) + mockEnableSegment.mockImplementation( + async (_params, { onError }: { onError: () => void }) => { + onError() + }, + ) const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), @@ -421,45 +507,63 @@ describe('useSegmentListData', () => { await result.current.onChangeSwitch(true, 'seg-1') }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'common.actionMsg.modifiedUnsuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'common.actionMsg.modifiedUnsuccessfully', + }) }) }) describe('onDelete', () => { it('should call deleteSegment and resetList on success', async () => { const clearSelection = vi.fn() - mockDeleteSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }) + mockDeleteSegment.mockImplementation( + async (_params, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }, + ) - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - clearSelection, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + clearSelection, + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.onDelete('seg-1') }) expect(mockDeleteSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'common.actionMsg.modifiedSuccessfully', + }) }) it('should clear selection when deleting batch (no segId)', async () => { const clearSelection = vi.fn() - mockDeleteSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }) + mockDeleteSegment.mockImplementation( + async (_params, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }, + ) - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - selectedSegmentIds: ['seg-1', 'seg-2'], - clearSelection, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + selectedSegmentIds: ['seg-1', 'seg-2'], + clearSelection, + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.onDelete('') @@ -470,9 +574,11 @@ describe('useSegmentListData', () => { }) it('should notify error on failure', async () => { - mockDeleteSegment.mockImplementation(async (_params, { onError }: { onError: () => void }) => { - onError() - }) + mockDeleteSegment.mockImplementation( + async (_params, { onError }: { onError: () => void }) => { + onError() + }, + ) const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), @@ -482,7 +588,10 @@ describe('useSegmentListData', () => { await result.current.onDelete('seg-1') }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'common.actionMsg.modifiedUnsuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'common.actionMsg.modifiedUnsuccessfully', + }) }) }) @@ -496,7 +605,10 @@ describe('useSegmentListData', () => { await result.current.handleUpdateSegment('seg-1', ' ', '', [], []) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.contentEmpty' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetDocuments.segment.contentEmpty', + }) expect(mockUpdateSegment).not.toHaveBeenCalled() }) @@ -511,7 +623,10 @@ describe('useSegmentListData', () => { await result.current.handleUpdateSegment('seg-1', '', 'answer', [], []) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.questionEmpty' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetDocuments.segment.questionEmpty', + }) }) it('should validate empty answer in QA mode', async () => { @@ -525,7 +640,10 @@ describe('useSegmentListData', () => { await result.current.handleUpdateSegment('seg-1', 'question', ' ', [], []) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.answerEmpty' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetDocuments.segment.answerEmpty', + }) }) it('should validate attachments are uploaded', async () => { @@ -534,34 +652,50 @@ describe('useSegmentListData', () => { }) await act(async () => { - await result.current.handleUpdateSegment('seg-1', 'content', '', [], [ - createMockFileEntity({ id: '1', name: 'test.png', uploadedId: undefined }), - ]) + await result.current.handleUpdateSegment( + 'seg-1', + 'content', + '', + [], + [createMockFileEntity({ id: '1', name: 'test.png', uploadedId: undefined })], + ) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.allFilesUploaded' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetDocuments.segment.allFilesUploaded', + }) }) it('should call updateSegment with correct params', async () => { - mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }) + mockUpdateSegment.mockImplementation( + async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { + onSuccess({ data: createMockSegment() }) + onSettled() + }, + ) const onCloseSegmentDetail = vi.fn() - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - onCloseSegmentDetail, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + onCloseSegmentDetail, + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.handleUpdateSegment('seg-1', 'updated content', '', ['keyword1'], []) }) expect(mockUpdateSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'common.actionMsg.modifiedSuccessfully', + }) expect(onCloseSegmentDetail).toHaveBeenCalled() expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment') expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment-success') @@ -569,18 +703,24 @@ describe('useSegmentListData', () => { }) it('should not close modal when needRegenerate is true', async () => { - mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }) + mockUpdateSegment.mockImplementation( + async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { + onSuccess({ data: createMockSegment() }) + onSettled() + }, + ) const onCloseSegmentDetail = vi.fn() - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - onCloseSegmentDetail, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + onCloseSegmentDetail, + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.handleUpdateSegment('seg-1', 'content', '', [], [], 'summary', true) @@ -590,19 +730,25 @@ describe('useSegmentListData', () => { }) it('should include attachments in params', async () => { - mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }) + mockUpdateSegment.mockImplementation( + async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { + onSuccess({ data: createMockSegment() }) + onSettled() + }, + ) const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), }) await act(async () => { - await result.current.handleUpdateSegment('seg-1', 'content', '', [], [ - createMockFileEntity({ id: '1', name: 'test.png', uploadedId: 'uploaded-1' }), - ]) + await result.current.handleUpdateSegment( + 'seg-1', + 'content', + '', + [], + [createMockFileEntity({ id: '1', name: 'test.png', uploadedId: 'uploaded-1' })], + ) }) expect(mockUpdateSegment).toHaveBeenCalledWith( @@ -616,14 +762,25 @@ describe('useSegmentListData', () => { describe('viewNewlyAddedChunk', () => { it('should set needScrollToBottom and not call resetList when adding new page', () => { - mockSegmentListData.current = { data: [], total: 10, total_pages: 1, has_more: false, limit: 20, page: 1 } + mockSegmentListData.current = { + data: [], + total: 10, + total_pages: 1, + has_more: false, + limit: 20, + page: 1, + } - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - limit: 10, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + limit: 10, + }), + { + wrapper: createWrapper(), + }, + ) act(() => { result.current.viewNewlyAddedChunk() @@ -633,16 +790,27 @@ describe('useSegmentListData', () => { }) it('should call resetList when not adding new page', () => { - mockSegmentListData.current = { data: [], total: 5, total_pages: 1, has_more: false, limit: 20, page: 1 } + mockSegmentListData.current = { + data: [], + total: 5, + total_pages: 1, + has_more: false, + limit: 20, + page: 1, + } const clearSelection = vi.fn() - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - clearSelection, - limit: 10, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + clearSelection, + limit: 10, + }), + { + wrapper: createWrapper(), + }, + ) act(() => { result.current.viewNewlyAddedChunk() @@ -660,7 +828,7 @@ describe('useSegmentListData', () => { }) act(() => { - result.current.updateSegmentInCache('seg-1', seg => ({ ...seg, enabled: false })) + result.current.updateSegmentInCache('seg-1', (seg) => ({ ...seg, enabled: false })) }) expect(mockQueryClient.setQueryData).toHaveBeenCalled() @@ -671,12 +839,16 @@ describe('useSegmentListData', () => { it('should reset list when pathname changes', async () => { const clearSelection = vi.fn() - renderHook(() => useSegmentListData({ - ...defaultOptions, - clearSelection, - }), { - wrapper: createWrapper(), - }) + renderHook( + () => + useSegmentListData({ + ...defaultOptions, + clearSelection, + }), + { + wrapper: createWrapper(), + }, + ) // Initial call from effect expect(clearSelection).toHaveBeenCalled() @@ -688,13 +860,17 @@ describe('useSegmentListData', () => { it('should reset list when import status is COMPLETED', () => { const clearSelection = vi.fn() - renderHook(() => useSegmentListData({ - ...defaultOptions, - importStatus: segmentImportStatus.completed, - clearSelection, - }), { - wrapper: createWrapper(), - }) + renderHook( + () => + useSegmentListData({ + ...defaultOptions, + importStatus: segmentImportStatus.completed, + clearSelection, + }), + { + wrapper: createWrapper(), + }, + ) expect(clearSelection).toHaveBeenCalled() }) @@ -702,17 +878,23 @@ describe('useSegmentListData', () => { describe('refreshChunkListDataWithDetailChanged', () => { it('should call correct invalidation for status all', async () => { - mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }) + mockUpdateSegment.mockImplementation( + async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { + onSuccess({ data: createMockSegment() }) + onSettled() + }, + ) - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - selectedStatus: 'all', - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + selectedStatus: 'all', + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.handleUpdateSegment('seg-1', 'content', '', [], []) @@ -723,17 +905,23 @@ describe('useSegmentListData', () => { }) it('should call correct invalidation for status true', async () => { - mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }) + mockUpdateSegment.mockImplementation( + async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { + onSuccess({ data: createMockSegment() }) + onSettled() + }, + ) - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - selectedStatus: true, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + selectedStatus: true, + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.handleUpdateSegment('seg-1', 'content', '', [], []) @@ -744,17 +932,23 @@ describe('useSegmentListData', () => { }) it('should call correct invalidation for status false', async () => { - mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }) + mockUpdateSegment.mockImplementation( + async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { + onSuccess({ data: createMockSegment() }) + onSettled() + }, + ) - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - selectedStatus: false, - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + selectedStatus: false, + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.handleUpdateSegment('seg-1', 'content', '', [], []) @@ -769,10 +963,12 @@ describe('useSegmentListData', () => { it('should set content and answer for QA mode', async () => { mockDocForm.current = ChunkingModeEnum.qa as ChunkingMode - mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }) + mockUpdateSegment.mockImplementation( + async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { + onSuccess({ data: createMockSegment() }) + onSettled() + }, + ) const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), @@ -807,7 +1003,7 @@ describe('useSegmentListData', () => { // Call updateSegmentInCache which should handle undefined gracefully act(() => { - result.current.updateSegmentInCache('seg-1', seg => ({ ...seg, enabled: false })) + result.current.updateSegmentInCache('seg-1', (seg) => ({ ...seg, enabled: false })) }) expect(mockQueryClient.setQueryData).toHaveBeenCalled() @@ -836,7 +1032,7 @@ describe('useSegmentListData', () => { }) act(() => { - result.current.updateSegmentInCache('seg-1', seg => ({ ...seg, enabled: false })) + result.current.updateSegmentInCache('seg-1', (seg) => ({ ...seg, enabled: false })) }) expect(mockQueryClient.setQueryData).toHaveBeenCalled() @@ -850,16 +1046,22 @@ describe('useSegmentListData', () => { return result }) - mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }) + mockEnableSegment.mockImplementation( + async (_params, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }, + ) - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - selectedSegmentIds: ['seg-1', 'seg-2'], - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + selectedSegmentIds: ['seg-1', 'seg-2'], + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.onChangeSwitch(true, '') @@ -890,16 +1092,22 @@ describe('useSegmentListData', () => { return result }) - mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }) + mockEnableSegment.mockImplementation( + async (_params, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }, + ) - const { result } = renderHook(() => useSegmentListData({ - ...defaultOptions, - selectedSegmentIds: ['seg-1', 'seg-2'], - }), { - wrapper: createWrapper(), - }) + const { result } = renderHook( + () => + useSegmentListData({ + ...defaultOptions, + selectedSegmentIds: ['seg-1', 'seg-2'], + }), + { + wrapper: createWrapper(), + }, + ) await act(async () => { await result.current.onChangeSwitch(true, '') diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-selection.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-selection.spec.ts index 432232e2e2b61f..760347ba25866b 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-selection.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-selection.spec.ts @@ -50,18 +50,22 @@ describe('useSegmentSelection', () => { }) it('should merge current page selection without dropping selected ids from other pages', () => { - expect(mergeCurrentPageSelectedSegmentIds({ - selectedSegmentIds: ['page-1-a', 'page-1-b'], - currentPageSegmentIds: ['page-2-a', 'page-2-b'], - nextCurrentPageSelectedSegmentIds: ['page-2-a'], - })).toEqual(['page-1-a', 'page-1-b', 'page-2-a']) + expect( + mergeCurrentPageSelectedSegmentIds({ + selectedSegmentIds: ['page-1-a', 'page-1-b'], + currentPageSegmentIds: ['page-2-a', 'page-2-b'], + nextCurrentPageSelectedSegmentIds: ['page-2-a'], + }), + ).toEqual(['page-1-a', 'page-1-b', 'page-2-a']) }) it('should replace only current page selected ids when current page selection changes', () => { - expect(mergeCurrentPageSelectedSegmentIds({ - selectedSegmentIds: ['page-1-a', 'page-2-a', 'page-2-b'], - currentPageSegmentIds: ['page-2-a', 'page-2-b'], - nextCurrentPageSelectedSegmentIds: ['page-2-b'], - })).toEqual(['page-1-a', 'page-2-b']) + expect( + mergeCurrentPageSelectedSegmentIds({ + selectedSegmentIds: ['page-1-a', 'page-2-a', 'page-2-b'], + currentPageSegmentIds: ['page-2-a', 'page-2-b'], + nextCurrentPageSelectedSegmentIds: ['page-2-b'], + }), + ).toEqual(['page-1-a', 'page-2-b']) }) }) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.ts index 2f57448657ff38..8ed8a687facb35 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.ts @@ -1,10 +1,20 @@ -import type { ChildChunkDetail, ChildSegmentsResponse, SegmentDetailModel, SegmentUpdater } from '@/models/datasets' +import type { + ChildChunkDetail, + ChildSegmentsResponse, + SegmentDetailModel, + SegmentUpdater, +} from '@/models/datasets' import { toast } from '@langgenius/dify-ui/toast' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useEventEmitterContextContext } from '@/context/event-emitter' -import { useChildSegmentList, useChildSegmentListKey, useDeleteChildSegment, useUpdateChildSegment } from '@/service/knowledge/use-segment' +import { + useChildSegmentList, + useChildSegmentListKey, + useDeleteChildSegment, + useUpdateChildSegment, +} from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' import { useDocumentContext } from '../../context' @@ -17,7 +27,10 @@ type UseChildSegmentDataOptions = { isFullDocMode: boolean onCloseChildSegmentDetail: () => void refreshChunkListDataWithDetailChanged: () => void - updateSegmentInCache: (segmentId: string, updater: (seg: SegmentDetailModel) => SegmentDetailModel) => void + updateSegmentInCache: ( + segmentId: string, + updater: (seg: SegmentDetailModel) => SegmentDetailModel, + ) => void } type UseChildSegmentDataReturn = { childSegments: ChildChunkDetail[] @@ -27,44 +40,72 @@ type UseChildSegmentDataReturn = { needScrollToBottom: React.RefObject<boolean> // Operations onDeleteChildChunk: (segmentId: string, childChunkId: string) => Promise<void> - handleUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void> + handleUpdateChildChunk: ( + segmentId: string, + childChunkId: string, + content: string, + ) => Promise<void> onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void resetChildList: () => void viewNewlyAddedChildChunk: () => void } -export const useChildSegmentData = (options: UseChildSegmentDataOptions): UseChildSegmentDataReturn => { - const { searchValue, currentPage, limit, segments, currChunkId, isFullDocMode, onCloseChildSegmentDetail, refreshChunkListDataWithDetailChanged, updateSegmentInCache } = options +export const useChildSegmentData = ( + options: UseChildSegmentDataOptions, +): UseChildSegmentDataReturn => { + const { + searchValue, + currentPage, + limit, + segments, + currChunkId, + isFullDocMode, + onCloseChildSegmentDetail, + refreshChunkListDataWithDetailChanged, + updateSegmentInCache, + } = options const { t } = useTranslation() const { eventEmitter } = useEventEmitterContextContext() const queryClient = useQueryClient() - const datasetId = useDocumentContext(s => s.datasetId) || '' - const documentId = useDocumentContext(s => s.documentId) || '' - const parentMode = useDocumentContext(s => s.parentMode) + const datasetId = useDocumentContext((s) => s.datasetId) || '' + const documentId = useDocumentContext((s) => s.documentId) || '' + const parentMode = useDocumentContext((s) => s.parentMode) const childSegmentListRef = useRef<HTMLDivElement>(null) const needScrollToBottom = useRef(false) // Build query params - const queryParams = useMemo(() => ({ - page: currentPage === 0 ? 1 : currentPage, - limit, - keyword: searchValue, - }), [currentPage, limit, searchValue]) + const queryParams = useMemo( + () => ({ + page: currentPage === 0 ? 1 : currentPage, + limit, + keyword: searchValue, + }), + [currentPage, limit, searchValue], + ) const segmentId = segments[0]?.id || '' // Build query key for optimistic updates - const currentQueryKey = useMemo(() => [...useChildSegmentListKey, datasetId, documentId, segmentId, queryParams], [datasetId, documentId, segmentId, queryParams]) + const currentQueryKey = useMemo( + () => [...useChildSegmentListKey, datasetId, documentId, segmentId, queryParams], + [datasetId, documentId, segmentId, queryParams], + ) // Fetch child segment list - const { isLoading: isLoadingChildSegmentList, data: childChunkListData } = useChildSegmentList({ - datasetId, - documentId, - segmentId, - params: queryParams, - }, !isFullDocMode || segments.length === 0) + const { isLoading: isLoadingChildSegmentList, data: childChunkListData } = useChildSegmentList( + { + datasetId, + documentId, + segmentId, + params: queryParams, + }, + !isFullDocMode || segments.length === 0, + ) // Derive child segments from query data const childSegments = useMemo(() => childChunkListData?.data || [], [childChunkListData]) const invalidChildSegmentList = useInvalid(useChildSegmentListKey) // Scroll to bottom when child segments change useEffect(() => { if (childSegmentListRef.current && needScrollToBottom.current) { - childSegmentListRef.current.scrollTo({ top: childSegmentListRef.current.scrollHeight, behavior: 'smooth' }) + childSegmentListRef.current.scrollTo({ + top: childSegmentListRef.current.scrollHeight, + behavior: 'smooth', + }) needScrollToBottom.current = false } }, [childSegments]) @@ -72,102 +113,142 @@ export const useChildSegmentData = (options: UseChildSegmentDataOptions): UseChi invalidChildSegmentList() }, [invalidChildSegmentList]) // Optimistic update helper for child segments - const updateChildSegmentInCache = useCallback((childChunkId: string, updater: (chunk: ChildChunkDetail) => ChildChunkDetail) => { - queryClient.setQueryData<ChildSegmentsResponse>(currentQueryKey, (old) => { - if (!old) - return old - return { - ...old, - data: old.data.map(chunk => chunk.id === childChunkId ? updater(chunk) : chunk), - } - }) - }, [queryClient, currentQueryKey]) + const updateChildSegmentInCache = useCallback( + (childChunkId: string, updater: (chunk: ChildChunkDetail) => ChildChunkDetail) => { + queryClient.setQueryData<ChildSegmentsResponse>(currentQueryKey, (old) => { + if (!old) return old + return { + ...old, + data: old.data.map((chunk) => (chunk.id === childChunkId ? updater(chunk) : chunk)), + } + }) + }, + [queryClient, currentQueryKey], + ) // Mutations const { mutateAsync: deleteChildSegment } = useDeleteChildSegment() const { mutateAsync: updateChildSegment } = useUpdateChildSegment() - const onDeleteChildChunk = useCallback(async (segmentIdParam: string, childChunkId: string) => { - await deleteChildSegment({ datasetId, documentId, segmentId: segmentIdParam, childChunkId }, { - onSuccess: () => { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - if (parentMode === 'paragraph') { - // Update parent segment's child_chunks in cache - updateSegmentInCache(segmentIdParam, seg => ({ - ...seg, - child_chunks: seg.child_chunks?.filter(chunk => chunk.id !== childChunkId), - })) - } - else { - resetChildList() - } - }, - onError: () => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - }, - }) - }, [datasetId, documentId, parentMode, deleteChildSegment, updateSegmentInCache, resetChildList, t]) - const handleUpdateChildChunk = useCallback(async (segmentIdParam: string, childChunkId: string, content: string) => { - const params: SegmentUpdater = { content: '' } - if (!content.trim()) { - toast.error(t($ => $['segment.contentEmpty'], { ns: 'datasetDocuments' })) - return - } - params.content = content - eventEmitter?.emit('update-child-segment') - await updateChildSegment({ datasetId, documentId, segmentId: segmentIdParam, childChunkId, body: params }, { - onSuccess: (res) => { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - onCloseChildSegmentDetail() - if (parentMode === 'paragraph') { - // Update parent segment's child_chunks in cache - updateSegmentInCache(segmentIdParam, seg => ({ - ...seg, - child_chunks: seg.child_chunks?.map(childSeg => childSeg.id === childChunkId - ? { - ...childSeg, - content: res.data.content, - type: res.data.type, - word_count: res.data.word_count, - updated_at: res.data.updated_at, - } - : childSeg), - })) - refreshChunkListDataWithDetailChanged() - } - else { - updateChildSegmentInCache(childChunkId, chunk => ({ - ...chunk, - content: res.data.content, - type: res.data.type, - word_count: res.data.word_count, - updated_at: res.data.updated_at, - })) - } - }, - onSettled: () => { - eventEmitter?.emit('update-child-segment-done') - }, - }) - }, [datasetId, documentId, parentMode, updateChildSegment, eventEmitter, onCloseChildSegmentDetail, updateSegmentInCache, updateChildSegmentInCache, refreshChunkListDataWithDetailChanged, t]) - const onSaveNewChildChunk = useCallback((newChildChunk?: ChildChunkDetail) => { - if (parentMode === 'paragraph') { - // Update parent segment's child_chunks in cache - updateSegmentInCache(currChunkId, seg => ({ - ...seg, - child_chunks: [...(seg.child_chunks || []), newChildChunk!], - })) - refreshChunkListDataWithDetailChanged() - } - else { - resetChildList() - } - }, [parentMode, currChunkId, updateSegmentInCache, refreshChunkListDataWithDetailChanged, resetChildList]) + const onDeleteChildChunk = useCallback( + async (segmentIdParam: string, childChunkId: string) => { + await deleteChildSegment( + { datasetId, documentId, segmentId: segmentIdParam, childChunkId }, + { + onSuccess: () => { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + if (parentMode === 'paragraph') { + // Update parent segment's child_chunks in cache + updateSegmentInCache(segmentIdParam, (seg) => ({ + ...seg, + child_chunks: seg.child_chunks?.filter((chunk) => chunk.id !== childChunkId), + })) + } else { + resetChildList() + } + }, + onError: () => { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + }, + }, + ) + }, + [ + datasetId, + documentId, + parentMode, + deleteChildSegment, + updateSegmentInCache, + resetChildList, + t, + ], + ) + const handleUpdateChildChunk = useCallback( + async (segmentIdParam: string, childChunkId: string, content: string) => { + const params: SegmentUpdater = { content: '' } + if (!content.trim()) { + toast.error(t(($) => $['segment.contentEmpty'], { ns: 'datasetDocuments' })) + return + } + params.content = content + eventEmitter?.emit('update-child-segment') + await updateChildSegment( + { datasetId, documentId, segmentId: segmentIdParam, childChunkId, body: params }, + { + onSuccess: (res) => { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + onCloseChildSegmentDetail() + if (parentMode === 'paragraph') { + // Update parent segment's child_chunks in cache + updateSegmentInCache(segmentIdParam, (seg) => ({ + ...seg, + child_chunks: seg.child_chunks?.map((childSeg) => + childSeg.id === childChunkId + ? { + ...childSeg, + content: res.data.content, + type: res.data.type, + word_count: res.data.word_count, + updated_at: res.data.updated_at, + } + : childSeg, + ), + })) + refreshChunkListDataWithDetailChanged() + } else { + updateChildSegmentInCache(childChunkId, (chunk) => ({ + ...chunk, + content: res.data.content, + type: res.data.type, + word_count: res.data.word_count, + updated_at: res.data.updated_at, + })) + } + }, + onSettled: () => { + eventEmitter?.emit('update-child-segment-done') + }, + }, + ) + }, + [ + datasetId, + documentId, + parentMode, + updateChildSegment, + eventEmitter, + onCloseChildSegmentDetail, + updateSegmentInCache, + updateChildSegmentInCache, + refreshChunkListDataWithDetailChanged, + t, + ], + ) + const onSaveNewChildChunk = useCallback( + (newChildChunk?: ChildChunkDetail) => { + if (parentMode === 'paragraph') { + // Update parent segment's child_chunks in cache + updateSegmentInCache(currChunkId, (seg) => ({ + ...seg, + child_chunks: [...(seg.child_chunks || []), newChildChunk!], + })) + refreshChunkListDataWithDetailChanged() + } else { + resetChildList() + } + }, + [ + parentMode, + currChunkId, + updateSegmentInCache, + refreshChunkListDataWithDetailChanged, + resetChildList, + ], + ) const viewNewlyAddedChildChunk = useCallback(() => { const totalPages = childChunkListData?.total_pages || 0 const total = childChunkListData?.total || 0 const newPage = Math.ceil((total + 1) / limit) needScrollToBottom.current = true - if (newPage > totalPages) - return + if (newPage > totalPages) return resetChildList() }, [childChunkListData, limit, resetChildList]) return { diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts index 71e21a5a8048cc..83081bfc07c2da 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-modal-state.ts @@ -82,11 +82,11 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur }, []) const toggleFullScreen = useCallback(() => { - setFullScreen(prev => !prev) + setFullScreen((prev) => !prev) }, []) const toggleCollapsed = useCallback(() => { - setIsCollapsed(prev => !prev) + setIsCollapsed((prev) => !prev) }, []) return { diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-search-filter.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-search-filter.ts index ca8c177ac24021..ef350960b78aa7 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-search-filter.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-search-filter.ts @@ -34,25 +34,34 @@ export const useSearchFilter = (options: UseSearchFilterOptions): UseSearchFilte const [selectedStatus, setSelectedStatus] = useState<boolean | 'all'>('all') const statusList = useRef<SegmentStatusFilterOption[]>([ - { value: 'all', name: t($ => $['list.index.all'], { ns: 'datasetDocuments' }) }, - { value: 0, name: t($ => $['list.status.disabled'], { ns: 'datasetDocuments' }) }, - { value: 1, name: t($ => $['list.status.enabled'], { ns: 'datasetDocuments' }) }, + { value: 'all', name: t(($) => $['list.index.all'], { ns: 'datasetDocuments' }) }, + { value: 0, name: t(($) => $['list.status.disabled'], { ns: 'datasetDocuments' }) }, + { value: 1, name: t(($) => $['list.status.enabled'], { ns: 'datasetDocuments' }) }, ]) - const { run: handleSearch } = useDebounceFn(() => { - setSearchValue(inputValue) - onPageChange(1) - }, { wait: 500 }) + const { run: handleSearch } = useDebounceFn( + () => { + setSearchValue(inputValue) + onPageChange(1) + }, + { wait: 500 }, + ) - const handleInputChange = useCallback((value: string) => { - setInputValue(value) - handleSearch() - }, [handleSearch]) + const handleInputChange = useCallback( + (value: string) => { + setInputValue(value) + handleSearch() + }, + [handleSearch], + ) - const onChangeStatus = useCallback(({ value }: SegmentStatusFilterOption) => { - setSelectedStatus(value === 'all' ? 'all' : !!value) - onPageChange(1) - }, [onPageChange]) + const onChangeStatus = useCallback( + ({ value }: SegmentStatusFilterOption) => { + setSelectedStatus(value === 'all' ? 'all' : !!value) + onPageChange(1) + }, + [onPageChange], + ) const onClearFilter = useCallback(() => { setInputValue('') @@ -66,8 +75,7 @@ export const useSearchFilter = (options: UseSearchFilterOptions): UseSearchFilte }, [onPageChange]) const selectDefaultValue = useMemo(() => { - if (selectedStatus === 'all') - return 'all' + if (selectedStatus === 'all') return 'all' return selectedStatus ? 1 : 0 }, [selectedStatus]) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts index 7a8678255f3d98..f61e7a64d933d2 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts @@ -8,7 +8,17 @@ import { useTranslation } from 'react-i18next' import { useEventEmitterContextContext } from '@/context/event-emitter' import { ChunkingMode } from '@/models/datasets' import { usePathname } from '@/next/navigation' -import { useChunkListAllKey, useChunkListDisabledKey, useChunkListEnabledKey, useDeleteSegment, useDisableSegment, useEnableSegment, useSegmentList, useSegmentListKey, useUpdateSegment } from '@/service/knowledge/use-segment' +import { + useChunkListAllKey, + useChunkListDisabledKey, + useChunkListEnabledKey, + useDeleteSegment, + useDisableSegment, + useEnableSegment, + useSegmentList, + useSegmentListKey, + useUpdateSegment, +} from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' import { segmentImportStatus } from '@/types/dataset' import { formatNumber } from '@/utils/format' @@ -36,36 +46,64 @@ type UseSegmentListDataReturn = { // Operations onChangeSwitch: (enable: boolean, segId?: string) => Promise<void> onDelete: (segId?: string) => Promise<void> - handleUpdateSegment: (segmentId: string, question: string, answer: string, keywords: string[], attachments: FileEntity[], summary?: string, needRegenerate?: boolean) => Promise<void> + handleUpdateSegment: ( + segmentId: string, + question: string, + answer: string, + keywords: string[], + attachments: FileEntity[], + summary?: string, + needRegenerate?: boolean, + ) => Promise<void> resetList: () => void viewNewlyAddedChunk: () => void invalidSegmentList: () => void - updateSegmentInCache: (segmentId: string, updater: (seg: SegmentDetailModel) => SegmentDetailModel) => void + updateSegmentInCache: ( + segmentId: string, + updater: (seg: SegmentDetailModel) => SegmentDetailModel, + ) => void } -export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegmentListDataReturn => { - const { searchValue, selectedStatus, selectedSegmentIds, importStatus, currentPage, limit, onCloseSegmentDetail, clearSelection } = options +export const useSegmentListData = ( + options: UseSegmentListDataOptions, +): UseSegmentListDataReturn => { + const { + searchValue, + selectedStatus, + selectedSegmentIds, + importStatus, + currentPage, + limit, + onCloseSegmentDetail, + clearSelection, + } = options const { t } = useTranslation() const pathname = usePathname() const { eventEmitter } = useEventEmitterContextContext() const queryClient = useQueryClient() - const datasetId = useDocumentContext(s => s.datasetId) || '' - const documentId = useDocumentContext(s => s.documentId) || '' - const docForm = useDocumentContext(s => s.docForm) - const parentMode = useDocumentContext(s => s.parentMode) + const datasetId = useDocumentContext((s) => s.datasetId) || '' + const documentId = useDocumentContext((s) => s.documentId) || '' + const docForm = useDocumentContext((s) => s.docForm) + const parentMode = useDocumentContext((s) => s.parentMode) const segmentListRef = useRef<HTMLDivElement>(null) const needScrollToBottom = useRef(false) const isFullDocMode = useMemo(() => { return docForm === ChunkingMode.parentChild && parentMode === 'full-doc' }, [docForm, parentMode]) // Build query params - const queryParams = useMemo(() => ({ - page: isFullDocMode ? 1 : currentPage, - limit: isFullDocMode ? DEFAULT_LIMIT : limit, - keyword: isFullDocMode ? '' : searchValue, - enabled: selectedStatus, - }), [isFullDocMode, currentPage, limit, searchValue, selectedStatus]) + const queryParams = useMemo( + () => ({ + page: isFullDocMode ? 1 : currentPage, + limit: isFullDocMode ? DEFAULT_LIMIT : limit, + keyword: isFullDocMode ? '' : searchValue, + enabled: selectedStatus, + }), + [isFullDocMode, currentPage, limit, searchValue, selectedStatus], + ) // Build query key for optimistic updates - const currentQueryKey = useMemo(() => [...useSegmentListKey, datasetId, documentId, queryParams], [datasetId, documentId, queryParams]) + const currentQueryKey = useMemo( + () => [...useSegmentListKey, datasetId, documentId, queryParams], + [datasetId, documentId, queryParams], + ) // Fetch segment list const { isLoading: isLoadingSegmentList, data: segmentListData } = useSegmentList({ datasetId, @@ -82,7 +120,10 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme // Scroll to bottom when needed useEffect(() => { if (segmentListRef.current && needScrollToBottom.current) { - segmentListRef.current.scrollTo({ top: segmentListRef.current.scrollHeight, behavior: 'smooth' }) + segmentListRef.current.scrollTo({ + top: segmentListRef.current.scrollHeight, + behavior: 'smooth', + }) needScrollToBottom.current = false } }, [segments]) @@ -106,8 +147,7 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme if (selectedStatus === 'all') { invalidChunkListDisabled() invalidChunkListEnabled() - } - else { + } else { invalidSegmentList() } }, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidSegmentList]) @@ -129,130 +169,173 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme refreshMap[String(selectedStatus)]?.() }, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll]) // Optimistic update helper using React Query's setQueryData - const updateSegmentInCache = useCallback((segmentId: string, updater: (seg: SegmentDetailModel) => SegmentDetailModel) => { - queryClient.setQueryData<SegmentsResponse>(currentQueryKey, (old) => { - if (!old) - return old - return { - ...old, - data: old.data.map(seg => seg.id === segmentId ? updater(seg) : seg), - } - }) - }, [queryClient, currentQueryKey]) + const updateSegmentInCache = useCallback( + (segmentId: string, updater: (seg: SegmentDetailModel) => SegmentDetailModel) => { + queryClient.setQueryData<SegmentsResponse>(currentQueryKey, (old) => { + if (!old) return old + return { + ...old, + data: old.data.map((seg) => (seg.id === segmentId ? updater(seg) : seg)), + } + }) + }, + [queryClient, currentQueryKey], + ) // Batch update helper - const updateSegmentsInCache = useCallback((segmentIds: string[], updater: (seg: SegmentDetailModel) => SegmentDetailModel) => { - queryClient.setQueryData<SegmentsResponse>(currentQueryKey, (old) => { - if (!old) - return old - return { - ...old, - data: old.data.map(seg => segmentIds.includes(seg.id) ? updater(seg) : seg), - } - }) - }, [queryClient, currentQueryKey]) + const updateSegmentsInCache = useCallback( + (segmentIds: string[], updater: (seg: SegmentDetailModel) => SegmentDetailModel) => { + queryClient.setQueryData<SegmentsResponse>(currentQueryKey, (old) => { + if (!old) return old + return { + ...old, + data: old.data.map((seg) => (segmentIds.includes(seg.id) ? updater(seg) : seg)), + } + }) + }, + [queryClient, currentQueryKey], + ) // Mutations const { mutateAsync: enableSegment } = useEnableSegment() const { mutateAsync: disableSegment } = useDisableSegment() const { mutateAsync: deleteSegment } = useDeleteSegment() const { mutateAsync: updateSegment } = useUpdateSegment() - const onChangeSwitch = useCallback(async (enable: boolean, segId?: string) => { - const operationApi = enable ? enableSegment : disableSegment - const targetIds = segId ? [segId] : selectedSegmentIds - await operationApi({ datasetId, documentId, segmentIds: targetIds }, { - onSuccess: () => { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - updateSegmentsInCache(targetIds, seg => ({ ...seg, enabled: enable })) - refreshChunkListWithStatusChanged() - }, - onError: () => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - }, - }) - }, [datasetId, documentId, selectedSegmentIds, disableSegment, enableSegment, t, updateSegmentsInCache, refreshChunkListWithStatusChanged]) - const onDelete = useCallback(async (segId?: string) => { - const targetIds = segId ? [segId] : selectedSegmentIds - await deleteSegment({ datasetId, documentId, segmentIds: targetIds }, { - onSuccess: () => { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - resetList() - if (!segId) - clearSelection() - }, - onError: () => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - }, - }) - }, [datasetId, documentId, selectedSegmentIds, deleteSegment, resetList, clearSelection, t]) - const handleUpdateSegment = useCallback(async (segmentId: string, question: string, answer: string, keywords: string[], attachments: FileEntity[], summary?: string, needRegenerate = false) => { - const params: SegmentUpdater = { content: '', attachment_ids: [] } - // Validate and build params based on doc form - if (docForm === ChunkingMode.qa) { - if (!question.trim()) { - toast.error(t($ => $['segment.questionEmpty'], { ns: 'datasetDocuments' })) - return + const onChangeSwitch = useCallback( + async (enable: boolean, segId?: string) => { + const operationApi = enable ? enableSegment : disableSegment + const targetIds = segId ? [segId] : selectedSegmentIds + await operationApi( + { datasetId, documentId, segmentIds: targetIds }, + { + onSuccess: () => { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + updateSegmentsInCache(targetIds, (seg) => ({ ...seg, enabled: enable })) + refreshChunkListWithStatusChanged() + }, + onError: () => { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + }, + }, + ) + }, + [ + datasetId, + documentId, + selectedSegmentIds, + disableSegment, + enableSegment, + t, + updateSegmentsInCache, + refreshChunkListWithStatusChanged, + ], + ) + const onDelete = useCallback( + async (segId?: string) => { + const targetIds = segId ? [segId] : selectedSegmentIds + await deleteSegment( + { datasetId, documentId, segmentIds: targetIds }, + { + onSuccess: () => { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + resetList() + if (!segId) clearSelection() + }, + onError: () => { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + }, + }, + ) + }, + [datasetId, documentId, selectedSegmentIds, deleteSegment, resetList, clearSelection, t], + ) + const handleUpdateSegment = useCallback( + async ( + segmentId: string, + question: string, + answer: string, + keywords: string[], + attachments: FileEntity[], + summary?: string, + needRegenerate = false, + ) => { + const params: SegmentUpdater = { content: '', attachment_ids: [] } + // Validate and build params based on doc form + if (docForm === ChunkingMode.qa) { + if (!question.trim()) { + toast.error(t(($) => $['segment.questionEmpty'], { ns: 'datasetDocuments' })) + return + } + if (!answer.trim()) { + toast.error(t(($) => $['segment.answerEmpty'], { ns: 'datasetDocuments' })) + return + } + params.content = question + params.answer = answer + } else { + if (!question.trim()) { + toast.error(t(($) => $['segment.contentEmpty'], { ns: 'datasetDocuments' })) + return + } + params.content = question } - if (!answer.trim()) { - toast.error(t($ => $['segment.answerEmpty'], { ns: 'datasetDocuments' })) - return + if (keywords.length) params.keywords = keywords + if (attachments.length) { + const notAllUploaded = attachments.some((item) => !item.uploadedId) + if (notAllUploaded) { + toast.error(t(($) => $['segment.allFilesUploaded'], { ns: 'datasetDocuments' })) + return + } + params.attachment_ids = attachments.map((item) => item.uploadedId!) } - params.content = question - params.answer = answer - } - else { - if (!question.trim()) { - toast.error(t($ => $['segment.contentEmpty'], { ns: 'datasetDocuments' })) - return - } - params.content = question - } - if (keywords.length) - params.keywords = keywords - if (attachments.length) { - const notAllUploaded = attachments.some(item => !item.uploadedId) - if (notAllUploaded) { - toast.error(t($ => $['segment.allFilesUploaded'], { ns: 'datasetDocuments' })) - return - } - params.attachment_ids = attachments.map(item => item.uploadedId!) - } - params.summary = summary ?? '' - if (needRegenerate) - params.regenerate_child_chunks = needRegenerate - eventEmitter?.emit('update-segment') - await updateSegment({ datasetId, documentId, segmentId, body: params }, { - onSuccess(res) { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - if (!needRegenerate) - onCloseSegmentDetail() - updateSegmentInCache(segmentId, seg => ({ - ...seg, - answer: res.data.answer, - content: res.data.content, - sign_content: res.data.sign_content, - keywords: res.data.keywords, - attachments: res.data.attachments, - summary: res.data.summary, - word_count: res.data.word_count, - hit_count: res.data.hit_count, - enabled: res.data.enabled, - updated_at: res.data.updated_at, - child_chunks: res.data.child_chunks, - })) - refreshChunkListDataWithDetailChanged() - eventEmitter?.emit('update-segment-success') - }, - onSettled() { - eventEmitter?.emit('update-segment-done') - }, - }) - }, [datasetId, documentId, docForm, updateSegment, eventEmitter, onCloseSegmentDetail, updateSegmentInCache, refreshChunkListDataWithDetailChanged, t]) + params.summary = summary ?? '' + if (needRegenerate) params.regenerate_child_chunks = needRegenerate + eventEmitter?.emit('update-segment') + await updateSegment( + { datasetId, documentId, segmentId, body: params }, + { + onSuccess(res) { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + if (!needRegenerate) onCloseSegmentDetail() + updateSegmentInCache(segmentId, (seg) => ({ + ...seg, + answer: res.data.answer, + content: res.data.content, + sign_content: res.data.sign_content, + keywords: res.data.keywords, + attachments: res.data.attachments, + summary: res.data.summary, + word_count: res.data.word_count, + hit_count: res.data.hit_count, + enabled: res.data.enabled, + updated_at: res.data.updated_at, + child_chunks: res.data.child_chunks, + })) + refreshChunkListDataWithDetailChanged() + eventEmitter?.emit('update-segment-success') + }, + onSettled() { + eventEmitter?.emit('update-segment-done') + }, + }, + ) + }, + [ + datasetId, + documentId, + docForm, + updateSegment, + eventEmitter, + onCloseSegmentDetail, + updateSegmentInCache, + refreshChunkListDataWithDetailChanged, + t, + ], + ) const viewNewlyAddedChunk = useCallback(() => { const totalPages = segmentListData?.total_pages || 0 const total = segmentListData?.total || 0 const newPage = Math.ceil((total + 1) / limit) needScrollToBottom.current = true - if (newPage > totalPages) - return + if (newPage > totalPages) return resetList() }, [segmentListData, limit, resetList]) // Compute total text for display @@ -261,14 +344,16 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme if (!isSearch) { const total = segmentListData?.total ? formatNumber(segmentListData.total) : '--' const count = total === '--' ? 0 : segmentListData!.total - const translationKey = (docForm === ChunkingMode.parentChild && parentMode === 'paragraph') - ? 'segment.parentChunks' as const - : 'segment.chunks' as const - return `${total} ${t($ => $[translationKey], { ns: 'datasetDocuments', count })}` + const translationKey = + docForm === ChunkingMode.parentChild && parentMode === 'paragraph' + ? ('segment.parentChunks' as const) + : ('segment.chunks' as const) + return `${total} ${t(($) => $[translationKey], { ns: 'datasetDocuments', count })}` } - const total = typeof segmentListData?.total === 'number' ? formatNumber(segmentListData.total) : 0 + const total = + typeof segmentListData?.total === 'number' ? formatNumber(segmentListData.total) : 0 const count = segmentListData?.total || 0 - return `${total} ${t($ => $['segment.searchResults'], { ns: 'datasetDocuments', count })}` + return `${total} ${t(($) => $['segment.searchResults'], { ns: 'datasetDocuments', count })}` }, [segmentListData, docForm, parentMode, searchValue, selectedStatus, t]) return { segments, diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-selection.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-selection.ts index de7a8e459a388e..f28193916e724b 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-selection.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-selection.ts @@ -19,12 +19,11 @@ export const mergeCurrentPageSelectedSegmentIds = ({ nextCurrentPageSelectedSegmentIds, }: MergeCurrentPageSelectedSegmentIdsOptions) => { const currentPageSegmentIdSet = new Set(currentPageSegmentIds) - const selectedSegmentIdsOutsideCurrentPage = selectedSegmentIds.filter(segmentId => !currentPageSegmentIdSet.has(segmentId)) + const selectedSegmentIdsOutsideCurrentPage = selectedSegmentIds.filter( + (segmentId) => !currentPageSegmentIdSet.has(segmentId), + ) - return [ - ...selectedSegmentIdsOutsideCurrentPage, - ...nextCurrentPageSelectedSegmentIds, - ] + return [...selectedSegmentIdsOutsideCurrentPage, ...nextCurrentPageSelectedSegmentIds] } export const useSegmentSelection = (): UseSegmentSelectionReturn => { diff --git a/web/app/components/datasets/documents/detail/completed/index.tsx b/web/app/components/datasets/documents/detail/completed/index.tsx index c2f2bb3e909d4f..304fc03aab5ca0 100644 --- a/web/app/components/datasets/documents/detail/completed/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/index.tsx @@ -26,10 +26,7 @@ import { useSegmentListData, useSegmentSelection, } from './hooks' -import { - SegmentListContext, - useSegmentListContext, -} from './segment-list-context' +import { SegmentListContext, useSegmentListContext } from './segment-list-context' const DEFAULT_LIMIT = 10 @@ -53,7 +50,7 @@ const Completed: FC<ICompletedProps> = ({ archived, }) => { const { t } = useTranslation() - const docForm = useDocumentContext(s => s.docForm) + const docForm = useDocumentContext((s) => s.docForm) // Pagination state const [currentPage, setCurrentPage] = useState(1) @@ -93,7 +90,12 @@ const Completed: FC<ICompletedProps> = ({ }, } refreshMap[String(searchFilter.selectedStatus)]?.() - }, [searchFilter.selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll]) + }, [ + searchFilter.selectedStatus, + invalidChunkListDisabled, + invalidChunkListEnabled, + invalidChunkListAll, + ]) // Segment list data const segmentListDataHook = useSegmentListData({ @@ -108,20 +110,27 @@ const Completed: FC<ICompletedProps> = ({ }) const segmentIds = useMemo( - () => segmentListDataHook.segments.map(segment => segment.id), + () => segmentListDataHook.segments.map((segment) => segment.id), [segmentListDataHook.segments], ) const currentPageSegmentIdSet = useMemo(() => new Set(segmentIds), [segmentIds]) const currentPageSelectedSegmentIds = useMemo(() => { - return selectionState.selectedSegmentIds.filter(segmentId => currentPageSegmentIdSet.has(segmentId)) + return selectionState.selectedSegmentIds.filter((segmentId) => + currentPageSegmentIdSet.has(segmentId), + ) }, [currentPageSegmentIdSet, selectionState.selectedSegmentIds]) - const handleCurrentPageSelectedSegmentIdsChange = useCallback((nextCurrentPageSelectedSegmentIds: string[]) => { - selectionState.onSelectedSegmentIdsChange(mergeCurrentPageSelectedSegmentIds({ - selectedSegmentIds: selectionState.selectedSegmentIds, - currentPageSegmentIds: segmentIds, - nextCurrentPageSelectedSegmentIds, - })) - }, [segmentIds, selectionState.selectedSegmentIds, selectionState.onSelectedSegmentIdsChange]) + const handleCurrentPageSelectedSegmentIdsChange = useCallback( + (nextCurrentPageSelectedSegmentIds: string[]) => { + selectionState.onSelectedSegmentIdsChange( + mergeCurrentPageSelectedSegmentIds({ + selectedSegmentIds: selectionState.selectedSegmentIds, + currentPageSegmentIds: segmentIds, + nextCurrentPageSelectedSegmentIds, + }), + ) + }, + [segmentIds, selectionState.selectedSegmentIds, selectionState.onSelectedSegmentIdsChange], + ) // Child segment data const childSegmentDataHook = useChildSegmentData({ @@ -141,7 +150,11 @@ const Completed: FC<ICompletedProps> = ({ if (segmentListDataHook.isFullDocMode) return childSegmentDataHook.childChunkListData?.total || 0 return segmentListDataHook.segmentListData?.total || 0 - }, [segmentListDataHook.isFullDocMode, childSegmentDataHook.childChunkListData, segmentListDataHook.segmentListData]) + }, [ + segmentListDataHook.isFullDocMode, + childSegmentDataHook.childChunkListData, + segmentListDataHook.segmentListData, + ]) const totalPages = Math.max(Math.ceil(paginationTotal / limit), 1) // Handle page change @@ -150,78 +163,81 @@ const Completed: FC<ICompletedProps> = ({ }, []) // Context value - const contextValue = useMemo<SegmentListContextValue>(() => ({ - isCollapsed: modalState.isCollapsed, - fullScreen: modalState.fullScreen, - toggleFullScreen: modalState.toggleFullScreen, - currSegment: modalState.currSegment, - currChildChunk: modalState.currChildChunk, - }), [ - modalState.isCollapsed, - modalState.fullScreen, - modalState.toggleFullScreen, - modalState.currSegment, - modalState.currChildChunk, - ]) + const contextValue = useMemo<SegmentListContextValue>( + () => ({ + isCollapsed: modalState.isCollapsed, + fullScreen: modalState.fullScreen, + toggleFullScreen: modalState.toggleFullScreen, + currSegment: modalState.currSegment, + currChildChunk: modalState.currChildChunk, + }), + [ + modalState.isCollapsed, + modalState.fullScreen, + modalState.toggleFullScreen, + modalState.currSegment, + modalState.currChildChunk, + ], + ) return ( <SegmentListContext.Provider value={contextValue}> {/* Segment list */} - {segmentListDataHook.isFullDocMode - ? ( - <FullDocModeContent - segments={segmentListDataHook.segments} - childSegments={childSegmentDataHook.childSegments} - isLoadingSegmentList={segmentListDataHook.isLoadingSegmentList} - isLoadingChildSegmentList={childSegmentDataHook.isLoadingChildSegmentList} - currSegmentId={modalState.currSegment?.segInfo?.id} - onClickCard={modalState.onClickCard} - onDeleteChildChunk={childSegmentDataHook.onDeleteChildChunk} - handleInputChange={searchFilter.handleInputChange} - handleAddNewChildChunk={modalState.handleAddNewChildChunk} - onClickSlice={modalState.onClickSlice} - archived={archived} - childChunkTotal={childSegmentDataHook.childChunkListData?.total || 0} - inputValue={searchFilter.inputValue} - onClearFilter={searchFilter.onClearFilter} - /> - ) - : ( - <CheckboxGroup - aria-label={t($ => $['segment.chunk'], { ns: 'datasetDocuments' })} - value={currentPageSelectedSegmentIds} - onValueChange={nextSegmentIds => handleCurrentPageSelectedSegmentIdsChange(nextSegmentIds)} - allValues={segmentIds} - className="flex min-h-0 grow flex-col" - > - <MenuBar - hasSelectableSegments={segmentIds.length > 0} - isLoading={segmentListDataHook.isLoadingSegmentList} - totalText={segmentListDataHook.totalText} - statusList={searchFilter.statusList} - selectDefaultValue={searchFilter.selectDefaultValue} - onChangeStatus={searchFilter.onChangeStatus} - inputValue={searchFilter.inputValue} - onInputChange={searchFilter.handleInputChange} - isCollapsed={modalState.isCollapsed} - toggleCollapsed={modalState.toggleCollapsed} - /> - <GeneralModeContent - segmentListRef={segmentListDataHook.segmentListRef} - embeddingAvailable={embeddingAvailable} - isLoadingSegmentList={segmentListDataHook.isLoadingSegmentList} - segments={segmentListDataHook.segments} - onChangeSwitch={segmentListDataHook.onChangeSwitch} - onDelete={segmentListDataHook.onDelete} - onClickCard={modalState.onClickCard} - archived={archived} - onDeleteChildChunk={childSegmentDataHook.onDeleteChildChunk} - handleAddNewChildChunk={modalState.handleAddNewChildChunk} - onClickSlice={modalState.onClickSlice} - onClearFilter={searchFilter.onClearFilter} - /> - </CheckboxGroup> - )} + {segmentListDataHook.isFullDocMode ? ( + <FullDocModeContent + segments={segmentListDataHook.segments} + childSegments={childSegmentDataHook.childSegments} + isLoadingSegmentList={segmentListDataHook.isLoadingSegmentList} + isLoadingChildSegmentList={childSegmentDataHook.isLoadingChildSegmentList} + currSegmentId={modalState.currSegment?.segInfo?.id} + onClickCard={modalState.onClickCard} + onDeleteChildChunk={childSegmentDataHook.onDeleteChildChunk} + handleInputChange={searchFilter.handleInputChange} + handleAddNewChildChunk={modalState.handleAddNewChildChunk} + onClickSlice={modalState.onClickSlice} + archived={archived} + childChunkTotal={childSegmentDataHook.childChunkListData?.total || 0} + inputValue={searchFilter.inputValue} + onClearFilter={searchFilter.onClearFilter} + /> + ) : ( + <CheckboxGroup + aria-label={t(($) => $['segment.chunk'], { ns: 'datasetDocuments' })} + value={currentPageSelectedSegmentIds} + onValueChange={(nextSegmentIds) => + handleCurrentPageSelectedSegmentIdsChange(nextSegmentIds) + } + allValues={segmentIds} + className="flex min-h-0 grow flex-col" + > + <MenuBar + hasSelectableSegments={segmentIds.length > 0} + isLoading={segmentListDataHook.isLoadingSegmentList} + totalText={segmentListDataHook.totalText} + statusList={searchFilter.statusList} + selectDefaultValue={searchFilter.selectDefaultValue} + onChangeStatus={searchFilter.onChangeStatus} + inputValue={searchFilter.inputValue} + onInputChange={searchFilter.handleInputChange} + isCollapsed={modalState.isCollapsed} + toggleCollapsed={modalState.toggleCollapsed} + /> + <GeneralModeContent + segmentListRef={segmentListDataHook.segmentListRef} + embeddingAvailable={embeddingAvailable} + isLoadingSegmentList={segmentListDataHook.isLoadingSegmentList} + segments={segmentListDataHook.segments} + onChangeSwitch={segmentListDataHook.onChangeSwitch} + onDelete={segmentListDataHook.onDelete} + onClickCard={modalState.onClickCard} + archived={archived} + onDeleteChildChunk={childSegmentDataHook.onDeleteChildChunk} + handleAddNewChildChunk={modalState.handleAddNewChildChunk} + onClickSlice={modalState.onClickSlice} + onClearFilter={searchFilter.onClearFilter} + /> + </CheckboxGroup> + )} {/* Pagination */} <Divider type="horizontal" className="mx-6 my-0 h-px w-auto bg-divider-subtle" /> @@ -230,17 +246,18 @@ const Completed: FC<ICompletedProps> = ({ totalPages={totalPages} onPageChange={handlePageChange} labels={{ - previous: t($ => $['pagination.previous'], { ns: 'common' }), - next: t($ => $['pagination.next'], { ns: 'common' }), - editPageNumber: (page, totalPages) => t($ => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), - pageNumberInput: t($ => $['pagination.pageNumber'], { ns: 'common' }), + previous: t(($) => $['pagination.previous'], { ns: 'common' }), + next: t(($) => $['pagination.next'], { ns: 'common' }), + editPageNumber: (page, totalPages) => + t(($) => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), + pageNumberInput: t(($) => $['pagination.pageNumber'], { ns: 'common' }), }} pageSize={{ value: limit, options: [10, 25, 50], onValueChange: setLimit, - label: t($ => $['pagination.perPage'], { ns: 'common' }), - ariaLabel: t($ => $['pagination.perPage'], { ns: 'common' }), + label: t(($) => $['pagination.perPage'], { ns: 'common' }), + ariaLabel: t(($) => $['pagination.perPage'], { ns: 'common' }), }} /> diff --git a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx index 7ce19004baa951..bbda2bc053949e 100644 --- a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx +++ b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx @@ -33,18 +33,17 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({ }) => { const { t } = useTranslation() const [content, setContent] = useState('') - const { datasetId, documentId } = useParams<{ datasetId: string, documentId: string }>() + const { datasetId, documentId } = useParams<{ datasetId: string; documentId: string }>() const [loading, setLoading] = useState(false) const [addAnother, setAddAnother] = useState(true) - const fullScreen = useSegmentListContext(s => s.fullScreen) - const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen) - const parentMode = useDocumentContext(s => s.parentMode) + const fullScreen = useSegmentListContext((s) => s.fullScreen) + const toggleFullScreen = useSegmentListContext((s) => s.toggleFullScreen) + const parentMode = useDocumentContext((s) => s.parentMode) const isFullDocMode = parentMode === 'full-doc' const handleCancel = (actionType: 'esc' | 'add' = 'esc') => { - if (actionType === 'esc' || !addAnother) - onCancel() + if (actionType === 'esc' || !addAnother) onCancel() } const { mutateAsync: addChildSegment } = useAddChildSegment() @@ -53,46 +52,60 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({ const params: SegmentUpdater = { content: '' } if (!content.trim()) - return toast.error(t($ => $['segment.contentEmpty'], { ns: 'datasetDocuments' })) + return toast.error(t(($) => $['segment.contentEmpty'], { ns: 'datasetDocuments' })) params.content = content setLoading(true) - await addChildSegment({ datasetId, documentId, segmentId: chunkId, body: params }, { - onSuccess(res) { - toast.success(t($ => $['segment.childChunkAdded'], { ns: 'datasetDocuments' }), { - actionProps: isFullDocMode - ? { - children: t($ => $['operation.view'], { ns: 'common' }), - onClick: viewNewlyAddedChildChunk, - } - : undefined, - }) - handleCancel('add') - setContent('') - if (isFullDocMode) { - onSave() - } - else { - onSave(res.data) - } + await addChildSegment( + { datasetId, documentId, segmentId: chunkId, body: params }, + { + onSuccess(res) { + toast.success( + t(($) => $['segment.childChunkAdded'], { ns: 'datasetDocuments' }), + { + actionProps: isFullDocMode + ? { + children: t(($) => $['operation.view'], { ns: 'common' }), + onClick: viewNewlyAddedChildChunk, + } + : undefined, + }, + ) + handleCancel('add') + setContent('') + if (isFullDocMode) { + onSave() + } else { + onSave(res.data) + } + }, + onSettled() { + setLoading(false) + }, }, - onSettled() { - setLoading(false) - }, - }) + ) } const count = content.length - const wordCountText = `${formatNumber(count)} ${t($ => $['segment.characters'], { ns: 'datasetDocuments', count })}` + const wordCountText = `${formatNumber(count)} ${t(($) => $['segment.characters'], { ns: 'datasetDocuments', count })}` return ( <div className="flex h-full flex-col"> - <div className={cn('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pr-4 pl-6' : 'pt-3 pr-3 pl-4')}> + <div + className={cn( + 'flex items-center justify-between', + fullScreen ? 'border border-divider-subtle py-3 pr-4 pl-6' : 'pt-3 pr-3 pl-4', + )} + > <div className="flex flex-col"> - <div className="system-xl-semibold text-text-primary">{t($ => $['segment.addChildChunk'], { ns: 'datasetDocuments' })}</div> + <div className="system-xl-semibold text-text-primary"> + {t(($) => $['segment.addChildChunk'], { ns: 'datasetDocuments' })} + </div> <div className="flex items-center gap-x-2"> - <SegmentIndexTag label={t($ => $['segment.newChildChunk'], { ns: 'datasetDocuments' }) as string} /> + <SegmentIndexTag + label={t(($) => $['segment.newChildChunk'], { ns: 'datasetDocuments' }) as string} + /> <Dot /> <span className="system-xs-medium text-text-tertiary">{wordCountText}</span> </div> @@ -113,7 +126,7 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({ )} <button type="button" - aria-label={t($ => $['operation.zoomIn'], { ns: 'common' })} + aria-label={t(($) => $['operation.zoomIn'], { ns: 'common' })} className="mr-1 flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" onClick={toggleFullScreen} > @@ -121,7 +134,7 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({ </button> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" onClick={handleCancel.bind(null, 'esc')} > @@ -129,12 +142,22 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({ </button> </div> </div> - <div className={cn('flex w-full grow', fullScreen ? 'flex-row justify-center px-6 pt-6' : 'px-4 py-3')}> - <div className={cn('h-full overflow-hidden break-all whitespace-pre-line', fullScreen ? 'w-1/2' : 'w-full')}> + <div + className={cn( + 'flex w-full grow', + fullScreen ? 'flex-row justify-center px-6 pt-6' : 'px-4 py-3', + )} + > + <div + className={cn( + 'h-full overflow-hidden break-all whitespace-pre-line', + fullScreen ? 'w-1/2' : 'w-full', + )} + > <ChunkContent docForm={ChunkingMode.parentChild} question={content} - onQuestionChange={content => setContent(content)} + onQuestionChange={(content) => setContent(content)} isEditMode={true} /> </div> diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/chunk-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/chunk-content.spec.tsx index 3b6492939c3f7b..b08bb3667b82df 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/chunk-content.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/chunk-content.spec.tsx @@ -3,7 +3,6 @@ import { render, screen } from '@testing-library/react' import { noop } from 'es-toolkit/function' import { createContext, useContextSelector } from 'use-context-selector' import { describe, expect, it, vi } from 'vitest' - import ChunkContent from '../chunk-content' // Create mock context matching the actual SegmentListContextValue @@ -55,19 +54,17 @@ describe('ChunkContent', () => { describe('Rendering', () => { it('should render without crashing', () => { - const { container } = render( - <ChunkContent detail={defaultDetail} isFullDocMode={false} />, - { wrapper: createWrapper() }, - ) + const { container } = render(<ChunkContent detail={defaultDetail} isFullDocMode={false} />, { + wrapper: createWrapper(), + }) expect(container.firstChild).toBeInTheDocument() }) it('should render content in non-QA mode', () => { - const { container } = render( - <ChunkContent detail={defaultDetail} isFullDocMode={false} />, - { wrapper: createWrapper() }, - ) + const { container } = render(<ChunkContent detail={defaultDetail} isFullDocMode={false} />, { + wrapper: createWrapper(), + }) // Assert - should render without Q and A labels expect(container.textContent).not.toContain('Q') @@ -84,20 +81,16 @@ describe('ChunkContent', () => { answer: 'Answer content', } - render( - <ChunkContent detail={qaDetail} isFullDocMode={false} />, - { wrapper: createWrapper() }, - ) + render(<ChunkContent detail={qaDetail} isFullDocMode={false} />, { wrapper: createWrapper() }) expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() }) it('should not render Q and A labels when answer is undefined', () => { - render( - <ChunkContent detail={defaultDetail} isFullDocMode={false} />, - { wrapper: createWrapper() }, - ) + render(<ChunkContent detail={defaultDetail} isFullDocMode={false} />, { + wrapper: createWrapper(), + }) expect(screen.queryByText('Q')).not.toBeInTheDocument() expect(screen.queryByText('A')).not.toBeInTheDocument() @@ -107,11 +100,7 @@ describe('ChunkContent', () => { describe('Props', () => { it('should apply custom className', () => { const { container } = render( - <ChunkContent - detail={defaultDetail} - isFullDocMode={false} - className="custom-class" - />, + <ChunkContent detail={defaultDetail} isFullDocMode={false} className="custom-class" />, { wrapper: createWrapper() }, ) @@ -119,30 +108,27 @@ describe('ChunkContent', () => { }) it('should handle isFullDocMode=true', () => { - const { container } = render( - <ChunkContent detail={defaultDetail} isFullDocMode={true} />, - { wrapper: createWrapper() }, - ) + const { container } = render(<ChunkContent detail={defaultDetail} isFullDocMode={true} />, { + wrapper: createWrapper(), + }) // Assert - should have line-clamp-3 class expect(container.querySelector('.line-clamp-3')).toBeInTheDocument() }) it('should handle isFullDocMode=false with isCollapsed=true', () => { - const { container } = render( - <ChunkContent detail={defaultDetail} isFullDocMode={false} />, - { wrapper: createWrapper(true) }, - ) + const { container } = render(<ChunkContent detail={defaultDetail} isFullDocMode={false} />, { + wrapper: createWrapper(true), + }) // Assert - should have line-clamp-2 class expect(container.querySelector('.line-clamp-2')).toBeInTheDocument() }) it('should handle isFullDocMode=false with isCollapsed=false', () => { - const { container } = render( - <ChunkContent detail={defaultDetail} isFullDocMode={false} />, - { wrapper: createWrapper(false) }, - ) + const { container } = render(<ChunkContent detail={defaultDetail} isFullDocMode={false} />, { + wrapper: createWrapper(false), + }) // Assert - should have line-clamp-20 class expect(container.querySelector('.line-clamp-20')).toBeInTheDocument() @@ -157,10 +143,9 @@ describe('ChunkContent', () => { sign_content: 'Sign content', } - const { container } = render( - <ChunkContent detail={detail} isFullDocMode={false} />, - { wrapper: createWrapper() }, - ) + const { container } = render(<ChunkContent detail={detail} isFullDocMode={false} />, { + wrapper: createWrapper(), + }) // Assert - The component uses sign_content || content expect(container.firstChild).toBeInTheDocument() @@ -172,10 +157,9 @@ describe('ChunkContent', () => { sign_content: '', } - const { container } = render( - <ChunkContent detail={detail} isFullDocMode={false} />, - { wrapper: createWrapper() }, - ) + const { container } = render(<ChunkContent detail={detail} isFullDocMode={false} />, { + wrapper: createWrapper(), + }) expect(container.firstChild).toBeInTheDocument() }) @@ -188,10 +172,9 @@ describe('ChunkContent', () => { sign_content: '', } - const { container } = render( - <ChunkContent detail={emptyDetail} isFullDocMode={false} />, - { wrapper: createWrapper() }, - ) + const { container } = render(<ChunkContent detail={emptyDetail} isFullDocMode={false} />, { + wrapper: createWrapper(), + }) expect(container.firstChild).toBeInTheDocument() }) @@ -204,10 +187,7 @@ describe('ChunkContent', () => { } // Act - empty answer is falsy, so QA mode won't render - render( - <ChunkContent detail={qaDetail} isFullDocMode={false} />, - { wrapper: createWrapper() }, - ) + render(<ChunkContent detail={qaDetail} isFullDocMode={false} />, { wrapper: createWrapper() }) // Assert - should not show Q and A labels since answer is empty string (falsy) expect(screen.queryByText('Q')).not.toBeInTheDocument() diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx index 6df95ddabada3b..a8d862aa9ae135 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx @@ -1,6 +1,11 @@ import type { SegmentListContextValue } from '@/app/components/datasets/documents/detail/completed' import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context' -import type { Attachment, ChildChunkDetail, ParentMode, SegmentDetailModel } from '@/models/datasets' +import type { + Attachment, + ChildChunkDetail, + ParentMode, + SegmentDetailModel, +} from '@/models/datasets' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { ChunkingMode } from '@/models/datasets' @@ -41,19 +46,38 @@ vi.mock('../../index', () => ({ // StatusItem uses React Query hooks which require QueryClientProvider vi.mock('../../../../status-item', () => ({ - default: ({ status, reverse, textCls }: { status: string, reverse?: boolean, textCls?: string }) => ( + default: ({ + status, + reverse, + textCls, + }: { + status: string + reverse?: boolean + textCls?: string + }) => ( <div data-testid="status-item" data-status={status} data-reverse={reverse} className={textCls}> - Status: - {' '} - {status} + Status: {status} </div> ), })) // ImageList has deep dependency: FileThumb → file-uploader → react-pdf-highlighter (ESM) vi.mock('@/app/components/datasets/common/image-list', () => ({ - default: ({ images, size, className }: { images: Array<{ sourceUrl: string, name: string }>, size?: string, className?: string }) => ( - <div data-testid="image-list" data-image-count={images.length} data-size={size} className={className}> + default: ({ + images, + size, + className, + }: { + images: Array<{ sourceUrl: string; name: string }> + size?: string + className?: string + }) => ( + <div + data-testid="image-list" + data-image-count={images.length} + data-size={size} + className={className} + > {images.map((img, idx: number) => ( <img key={idx} src={img.sourceUrl} alt={img.name} /> ))} @@ -63,8 +87,10 @@ vi.mock('@/app/components/datasets/common/image-list', () => ({ // Markdown uses next/dynamic and shiki (ESM) vi.mock('@/app/components/base/markdown', () => ({ - Markdown: ({ content, className }: { content: string, className?: string }) => ( - <div data-testid="markdown" className={`markdown-body ${className || ''}`}>{content}</div> + Markdown: ({ content, className }: { content: string; className?: string }) => ( + <div data-testid="markdown" className={`markdown-body ${className || ''}`}> + {content} + </div> ), })) @@ -90,7 +116,9 @@ const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildC ...overrides, }) -const createMockSegmentDetail = (overrides: Partial<SegmentDetailModel & { document?: { name: string } }> = {}): SegmentDetailModel & { document?: { name: string } } => ({ +const createMockSegmentDetail = ( + overrides: Partial<SegmentDetailModel & { document?: { name: string } }> = {}, +): SegmentDetailModel & { document?: { name: string } } => ({ id: 'segment-1', position: 1, document_id: 'doc-1', @@ -161,7 +189,9 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - expect(screen.getByText('250 datasetDocuments.segment.characters:{"count":250}')).toBeInTheDocument() + expect( + screen.getByText('250 datasetDocuments.segment.characters:{"count":250}'), + ).toBeInTheDocument() }) it('should render hit count text', () => { @@ -176,7 +206,12 @@ describe('SegmentCard', () => { const detail = createMockSegmentDetail() render( - <SegmentCard loading={false} detail={detail} className="custom-class" focused={defaultFocused} />, + <SegmentCard + loading={false} + detail={detail} + className="custom-class" + focused={defaultFocused} + />, ) const card = screen.getByTestId('segment-card') @@ -343,7 +378,9 @@ describe('SegmentCard', () => { mockDocForm.current = ChunkingMode.parentChild mockParentMode.current = 'full-doc' - render(<SegmentCard loading={false} detail={detail} onClick={onClick} focused={defaultFocused} />) + render( + <SegmentCard loading={false} detail={detail} onClick={onClick} focused={defaultFocused} />, + ) const viewMoreButton = screen.getByRole('button', { name: /viewMore/i }) fireEvent.click(viewMoreButton) @@ -401,7 +438,11 @@ describe('SegmentCard', () => { it('should call onChangeSwitch when switch is toggled', async () => { const onChangeSwitch = vi.fn().mockResolvedValue(undefined) - const detail = createMockSegmentDetail({ id: 'test-segment-id', enabled: true, status: 'completed' }) + const detail = createMockSegmentDetail({ + id: 'test-segment-id', + enabled: true, + status: 'completed', + }) render( <SegmentCard @@ -556,7 +597,9 @@ describe('SegmentCard', () => { it('should compute contentOpacity correctly when enabled', () => { const detail = createMockSegmentDetail({ enabled: true }) - const { container } = render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) + const { container } = render( + <SegmentCard loading={false} detail={detail} focused={defaultFocused} />, + ) const wordCount = container.querySelector('.system-xs-medium.text-text-tertiary') expect(wordCount).not.toHaveClass('opacity-50') @@ -592,7 +635,9 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - expect(screen.getByText('1 datasetDocuments.segment.characters:{"count":1}')).toBeInTheDocument() + expect( + screen.getByText('1 datasetDocuments.segment.characters:{"count":1}'), + ).toBeInTheDocument() }) }) @@ -638,7 +683,10 @@ describe('SegmentCard', () => { it('should render ChildSegmentList when in paragraph mode with child chunks', () => { mockDocForm.current = ChunkingMode.parentChild mockParentMode.current = 'paragraph' - const childChunks = [createMockChildChunk(), createMockChildChunk({ id: 'child-2', position: 2 })] + const childChunks = [ + createMockChildChunk(), + createMockChildChunk({ id: 'child-2', position: 2 }), + ] const detail = createMockSegmentDetail({ child_chunks: childChunks }) render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) @@ -697,7 +745,9 @@ describe('SegmentCard', () => { mockDocForm.current = ChunkingMode.text const detail = createMockSegmentDetail({ keywords: ['keyword1', 'keyword2'] }) - const { container } = render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) + const { container } = render( + <SegmentCard loading={false} detail={detail} focused={defaultFocused} />, + ) expect(screen.getByText('keyword1')).toBeInTheDocument() expect(screen.getByText('keyword2')).toBeInTheDocument() @@ -755,7 +805,9 @@ describe('SegmentCard', () => { }) it('should handle empty detail object gracefully', () => { - render(<SegmentCard loading={false} detail={{} as SegmentDetailModel} focused={defaultFocused} />) + render( + <SegmentCard loading={false} detail={{} as SegmentDetailModel} focused={defaultFocused} />, + ) expect(screen.getByText(/Chunk/i)).toBeInTheDocument() }) @@ -801,7 +853,9 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - expect(screen.getByText('0 datasetDocuments.segment.characters:{"count":0}')).toBeInTheDocument() + expect( + screen.getByText('0 datasetDocuments.segment.characters:{"count":0}'), + ).toBeInTheDocument() }) it('should handle zero hit count', () => { @@ -967,7 +1021,9 @@ describe('SegmentCard', () => { it('should handle loading transition correctly', () => { const detail = createMockSegmentDetail() - const { rerender } = render(<SegmentCard loading={true} detail={detail} focused={defaultFocused} />) + const { rerender } = render( + <SegmentCard loading={true} detail={detail} focused={defaultFocused} />, + ) // When loading, content should not be visible expect(screen.queryByText('Test signed content')).not.toBeInTheDocument() @@ -1042,7 +1098,9 @@ describe('SegmentCard', () => { enabled: false, }) - const { container } = render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) + const { container } = render( + <SegmentCard loading={false} detail={detail} focused={defaultFocused} />, + ) // The ChunkContent wrapper should have opacity class when disabled const qaWrapper = container.querySelector('.flex.gap-x-1') diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.tsx index 8bb818acb2ed9d..c3edb5ed9ebacc 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.tsx @@ -17,11 +17,7 @@ type ChunkContentProps = { const selectIsCollapsed = (s: SegmentListContextValue) => s.isCollapsed -const ChunkContent: FC<ChunkContentProps> = ({ - detail, - isFullDocMode, - className, -}) => { +const ChunkContent: FC<ChunkContentProps> = ({ detail, isFullDocMode, className }) => { const { answer, content, sign_content } = detail const isCollapsed = useSegmentListContext(selectIsCollapsed) @@ -29,17 +25,27 @@ const ChunkContent: FC<ChunkContentProps> = ({ return ( <div className={className}> <div className="flex gap-x-1"> - <div className="w-4 shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary">Q</div> + <div className="w-4 shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary"> + Q + </div> <Markdown - className={cn('body-md-regular text-text-secondary', isCollapsed ? 'line-clamp-2' : 'line-clamp-20')} + className={cn( + 'body-md-regular text-text-secondary', + isCollapsed ? 'line-clamp-2' : 'line-clamp-20', + )} content={content} customDisallowedElements={['input']} /> </div> <div className="flex gap-x-1"> - <div className="w-4 shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary">A</div> + <div className="w-4 shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary"> + A + </div> <Markdown - className={cn('body-md-regular text-text-secondary', isCollapsed ? 'line-clamp-2' : 'line-clamp-20')} + className={cn( + 'body-md-regular text-text-secondary', + isCollapsed ? 'line-clamp-2' : 'line-clamp-20', + )} content={answer} customDisallowedElements={['input']} /> @@ -49,7 +55,11 @@ const ChunkContent: FC<ChunkContentProps> = ({ } return ( <Markdown - className={cn('mt-0.5! text-text-secondary!', isFullDocMode ? 'line-clamp-3' : isCollapsed ? 'line-clamp-2' : 'line-clamp-20', className)} + className={cn( + 'mt-0.5! text-text-secondary!', + isFullDocMode ? 'line-clamp-3' : isCollapsed ? 'line-clamp-2' : 'line-clamp-20', + className, + )} content={sign_content || content || ''} customDisallowedElements={['input']} /> diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx index 42a17640a88552..80526d4995bac9 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx @@ -33,7 +33,7 @@ import ChunkContent from './chunk-content' type ISegmentCardProps = { loading: boolean - detail?: SegmentDetailModel & { document?: { name: string }, status?: string } + detail?: SegmentDetailModel & { document?: { name: string }; status?: string } onClick?: () => void onChangeSwitch?: (enabled: boolean, segId?: string) => Promise<void> onDelete?: (segId: string) => Promise<void> @@ -83,8 +83,8 @@ const SegmentCard: FC<ISegmentCardProps> = ({ attachments = [], } = detail as Required<ISegmentCardProps>['detail'] const [showModal, setShowModal] = useState(false) - const docForm = useDocumentContext(s => s.docForm) - const parentMode = useDocumentContext(s => s.parentMode) + const docForm = useDocumentContext((s) => s.docForm) + const parentMode = useDocumentContext((s) => s.parentMode) const isGeneralMode = useMemo(() => { return docForm === ChunkingMode.text @@ -103,31 +103,31 @@ const SegmentCard: FC<ISegmentCardProps> = ({ }, [docForm, parentMode]) const chunkEdited = useMemo(() => { - if (docForm === ChunkingMode.parentChild && parentMode === 'full-doc') - return false + if (docForm === ChunkingMode.parentChild && parentMode === 'full-doc') return false return isAfter(updated_at * 1000, created_at * 1000) }, [docForm, parentMode, updated_at, created_at]) const contentOpacity = useMemo(() => { - return (enabled || focused.segmentContent) ? '' : 'opacity-50 group-hover/card:opacity-100' + return enabled || focused.segmentContent ? '' : 'opacity-50 group-hover/card:opacity-100' }, [enabled, focused.segmentContent]) const handleClickCard = useCallback(() => { - if (docForm !== ChunkingMode.parentChild || parentMode !== 'full-doc') - onClick?.() + if (docForm !== ChunkingMode.parentChild || parentMode !== 'full-doc') onClick?.() }, [docForm, parentMode, onClick]) const wordCountText = useMemo(() => { const total = formatNumber(word_count) - return `${total} ${t($ => $['segment.characters'], { ns: 'datasetDocuments', count: word_count })}` + return `${total} ${t(($) => $['segment.characters'], { ns: 'datasetDocuments', count: word_count })}` }, [word_count, t]) const labelPrefix = useMemo(() => { - return isParentChildMode ? t($ => $['segment.parentChunk'], { ns: 'datasetDocuments' }) : t($ => $['segment.chunk'], { ns: 'datasetDocuments' }) + return isParentChildMode + ? t(($) => $['segment.parentChunk'], { ns: 'datasetDocuments' }) + : t(($) => $['segment.chunk'], { ns: 'datasetDocuments' }) }, [isParentChildMode, t]) const images = useMemo(() => { - return attachments.map(attachment => ({ + return attachments.map((attachment) => ({ name: attachment.name, mimeType: attachment.mime_type, sourceUrl: attachment.source_url, @@ -136,8 +136,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({ })) }, [attachments]) - if (loading) - return <ParentChunkCardSkeleton /> + if (loading) return <ParentChunkCardSkeleton /> return ( <div @@ -162,84 +161,103 @@ const SegmentCard: FC<ISegmentCardProps> = ({ labelPrefix={labelPrefix} /> <Dot /> - <div className={cn('system-xs-medium text-text-tertiary', contentOpacity)}>{wordCountText}</div> + <div className={cn('system-xs-medium text-text-tertiary', contentOpacity)}> + {wordCountText} + </div> <Dot /> - <div className={cn('system-xs-medium text-text-tertiary', contentOpacity)}>{`${formatNumber(hit_count)} ${t($ => $['segment.hitCount'], { ns: 'datasetDocuments' })}`}</div> + <div + className={cn('system-xs-medium text-text-tertiary', contentOpacity)} + >{`${formatNumber(hit_count)} ${t(($) => $['segment.hitCount'], { ns: 'datasetDocuments' })}`}</div> {chunkEdited && ( <> <Dot /> - <Badge text={t($ => $['segment.edited'], { ns: 'datasetDocuments' }) as string} uppercase className={contentOpacity} /> + <Badge + text={t(($) => $['segment.edited'], { ns: 'datasetDocuments' }) as string} + uppercase + className={contentOpacity} + /> </> )} </div> - {!isFullDocMode - ? ( - <div className="flex items-center"> - <StatusItem status={enabled ? 'enabled' : 'disabled'} reverse textCls="text-text-tertiary system-xs-regular" /> - {embeddingAvailable && ( - <div className="absolute -top-2 -right-2.5 z-20 hidden items-center gap-x-0.5 rounded-[10px] border-[0.5px] - border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-md backdrop-blur-[5px] group-hover/card:flex" - > - {!archived && ( - <> - <Tooltip> - <TooltipTrigger - render={( - <button - type="button" - aria-label={t($ => $['operation.edit'], { ns: 'common' })} - className="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent p-0 hover:bg-state-base-hover" - onClick={(e) => { - e.stopPropagation() - onClickEdit?.() - }} - > - <RiEditLine className="size-4 text-text-tertiary" aria-hidden="true" /> - </button> - )} - /> - <TooltipContent className="system-xs-medium text-text-secondary">Edit</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger - render={( - <button - type="button" - aria-label={t($ => $['operation.delete'], { ns: 'common' })} - className="group/delete flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent p-0 hover:bg-state-destructive-hover" - onClick={(e) => { - e.stopPropagation() - setShowModal(true) - }} - > - <RiDeleteBinLine className="size-4 text-text-tertiary group-hover/delete:text-text-destructive" aria-hidden="true" /> - </button> - )} - /> - <TooltipContent className="system-xs-medium text-text-secondary">Delete</TooltipContent> - </Tooltip> - <Divider type="vertical" className="h-3.5 bg-divider-regular" /> - </> - )} - <div - onClick={(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => - e.stopPropagation()} - className="flex items-center" - > - <Switch - size="md" - disabled={archived || detail?.status !== 'completed'} - checked={enabled} - onCheckedChange={async (val) => { - await onChangeSwitch?.(val, id) - }} + {!isFullDocMode ? ( + <div className="flex items-center"> + <StatusItem + status={enabled ? 'enabled' : 'disabled'} + reverse + textCls="text-text-tertiary system-xs-regular" + /> + {embeddingAvailable && ( + <div className="absolute -top-2 -right-2.5 z-20 hidden items-center gap-x-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-md backdrop-blur-[5px] group-hover/card:flex"> + {!archived && ( + <> + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + aria-label={t(($) => $['operation.edit'], { ns: 'common' })} + className="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent p-0 hover:bg-state-base-hover" + onClick={(e) => { + e.stopPropagation() + onClickEdit?.() + }} + > + <RiEditLine + className="size-4 text-text-tertiary" + aria-hidden="true" + /> + </button> + } /> - </div> - </div> + <TooltipContent className="system-xs-medium text-text-secondary"> + Edit + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + aria-label={t(($) => $['operation.delete'], { ns: 'common' })} + className="group/delete flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent p-0 hover:bg-state-destructive-hover" + onClick={(e) => { + e.stopPropagation() + setShowModal(true) + }} + > + <RiDeleteBinLine + className="size-4 text-text-tertiary group-hover/delete:text-text-destructive" + aria-hidden="true" + /> + </button> + } + /> + <TooltipContent className="system-xs-medium text-text-secondary"> + Delete + </TooltipContent> + </Tooltip> + <Divider type="vertical" className="h-3.5 bg-divider-regular" /> + </> )} + <div + onClick={(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => + e.stopPropagation() + } + className="flex items-center" + > + <Switch + size="md" + disabled={archived || detail?.status !== 'completed'} + checked={enabled} + onCheckedChange={async (val) => { + await onChangeSwitch?.(val, id) + }} + /> + </div> </div> - ) - : null} + )} + </div> + ) : null} </> </div> <ChunkContent @@ -252,54 +270,51 @@ const SegmentCard: FC<ISegmentCardProps> = ({ className={contentOpacity} /> {images.length > 0 && <ImageList images={images} size="md" className="py-1" />} - { - summary && ( - <SummaryLabel summary={summary} className="mt-2" /> - ) - } + {summary && <SummaryLabel summary={summary} className="mt-2" />} {isGeneralMode && ( <div className={cn('flex flex-wrap items-center gap-2 py-1.5', contentOpacity)}> - {keywords?.map(keyword => <Tag key={keyword} text={keyword} />)} + {keywords?.map((keyword) => ( + <Tag key={keyword} text={keyword} /> + ))} </div> )} - { - isFullDocMode - ? ( - <button - type="button" - className="mt-0.5 mb-2 border-none bg-transparent p-0 text-left system-xs-semibold-uppercase text-text-accent" - onClick={() => onClick?.()} - > - {t($ => $['operation.viewMore'], { ns: 'common' })} - </button> - ) - : null - } - { - isParagraphMode && child_chunks.length > 0 - && ( - <ChildSegmentList - parentChunkId={id} - childChunks={child_chunks} - enabled={enabled} - onDelete={onDeleteChildChunk!} - handleAddNewChildChunk={handleAddNewChildChunk} - onClickSlice={onClickSlice} - focused={focused.segmentContent} - /> - ) - } - <AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}> + {isFullDocMode ? ( + <button + type="button" + className="mt-0.5 mb-2 border-none bg-transparent p-0 text-left system-xs-semibold-uppercase text-text-accent" + onClick={() => onClick?.()} + > + {t(($) => $['operation.viewMore'], { ns: 'common' })} + </button> + ) : null} + {isParagraphMode && child_chunks.length > 0 && ( + <ChildSegmentList + parentChunkId={id} + childChunks={child_chunks} + enabled={enabled} + onDelete={onDeleteChildChunk!} + handleAddNewChildChunk={handleAddNewChildChunk} + onClickSlice={onClickSlice} + focused={focused.segmentContent} + /> + )} + <AlertDialog open={showModal} onOpenChange={(open) => !open && setShowModal(false)}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['segment.delete'], { ns: 'datasetDocuments' })} + {t(($) => $['segment.delete'], { ns: 'datasetDocuments' })} </AlertDialogTitle> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton onClick={async () => { await onDelete?.(id) }}> - {t($ => $['operation.sure'], { ns: 'common' })} + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton + onClick={async () => { + await onDelete?.(id) + }} + > + {t(($) => $['operation.sure'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx index 0424bfaef85190..de39b2a503c3c3 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx @@ -1,11 +1,7 @@ import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { SegmentDetailModel } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' -import { - RiCloseLine, - RiCollapseDiagonalLine, - RiExpandDiagonalLine, -} from '@remixicon/react' +import { RiCloseLine, RiCollapseDiagonalLine, RiExpandDiagonalLine } from '@remixicon/react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { v4 as uuid4 } from 'uuid' @@ -50,36 +46,40 @@ export function SegmentDetail({ docForm, }: ISegmentDetailProps) { const { t } = useTranslation() - const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '') + const [question, setQuestion] = useState( + isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '', + ) const [answer, setAnswer] = useState(segInfo?.answer || '') const [summary, setSummary] = useState(segInfo?.summary || '') const [attachments, setAttachments] = useState<FileEntity[]>(() => { - return segInfo?.attachments?.map(item => ({ - id: uuid4(), - name: item.name, - size: item.size, - mimeType: item.mime_type, - extension: item.extension, - sourceUrl: item.source_url, - uploadedId: item.id, - progress: 100, - })) || [] + return ( + segInfo?.attachments?.map((item) => ({ + id: uuid4(), + name: item.name, + size: item.size, + mimeType: item.mime_type, + extension: item.extension, + sourceUrl: item.source_url, + uploadedId: item.id, + progress: 100, + })) || [] + ) }) const [keywords, setKeywords] = useState<string[]>(segInfo?.keywords || []) const { eventEmitter } = useEventEmitterContextContext() const [loading, setLoading] = useState(false) const [showRegenerationModal, setShowRegenerationModal] = useState(false) - const fullScreen = useSegmentListContext(s => s.fullScreen) - const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen) - const parentMode = useDocumentContext(s => s.parentMode) - const indexingTechnique = useDatasetDetailContextWithSelector(s => s.dataset?.indexing_technique) - const runtimeMode = useDatasetDetailContextWithSelector(s => s.dataset?.runtime_mode) + const fullScreen = useSegmentListContext((s) => s.fullScreen) + const toggleFullScreen = useSegmentListContext((s) => s.toggleFullScreen) + const parentMode = useDocumentContext((s) => s.parentMode) + const indexingTechnique = useDatasetDetailContextWithSelector( + (s) => s.dataset?.indexing_technique, + ) + const runtimeMode = useDatasetDetailContextWithSelector((s) => s.dataset?.runtime_mode) eventEmitter?.useSubscription((v) => { - if (v === 'update-segment') - setLoading(true) - if (v === 'update-segment-done') - setLoading(false) + if (v === 'update-segment') setLoading(true) + if (v === 'update-segment-done') setLoading(false) }) const handleCancel = useCallback(() => { @@ -112,28 +112,39 @@ export function SegmentDetail({ }, []) const wordCountText = useMemo(() => { - const contentLength = docForm === ChunkingMode.qa ? (question.length + answer.length) : question.length - const total = formatNumber(isEditMode ? contentLength : segInfo!.word_count as number) - const count = isEditMode ? contentLength : segInfo!.word_count as number - return `${total} ${t($ => $['segment.characters'], { ns: 'datasetDocuments', count })}` + const contentLength = + docForm === ChunkingMode.qa ? question.length + answer.length : question.length + const total = formatNumber(isEditMode ? contentLength : (segInfo!.word_count as number)) + const count = isEditMode ? contentLength : (segInfo!.word_count as number) + return `${total} ${t(($) => $['segment.characters'], { ns: 'datasetDocuments', count })}` }, [isEditMode, question.length, answer.length, docForm, segInfo, t]) const isFullDocMode = docForm === ChunkingMode.parentChild && parentMode === 'full-doc' - const titleText = isEditMode ? t($ => $['segment.editChunk'], { ns: 'datasetDocuments' }) : t($ => $['segment.chunkDetail'], { ns: 'datasetDocuments' }) - const labelPrefix = docForm === ChunkingMode.parentChild ? t($ => $['segment.parentChunk'], { ns: 'datasetDocuments' }) : t($ => $['segment.chunk'], { ns: 'datasetDocuments' }) + const titleText = isEditMode + ? t(($) => $['segment.editChunk'], { ns: 'datasetDocuments' }) + : t(($) => $['segment.chunkDetail'], { ns: 'datasetDocuments' }) + const labelPrefix = + docForm === ChunkingMode.parentChild + ? t(($) => $['segment.parentChunk'], { ns: 'datasetDocuments' }) + : t(($) => $['segment.chunk'], { ns: 'datasetDocuments' }) const isECOIndexing = indexingTechnique === IndexingType.ECONOMICAL return ( <div className="flex h-full flex-col"> - <div className={cn( - 'flex shrink-0 items-center justify-between', - fullScreen ? 'border border-divider-subtle py-3 pr-4 pl-6' : 'pt-3 pr-3 pl-4', - )} + <div + className={cn( + 'flex shrink-0 items-center justify-between', + fullScreen ? 'border border-divider-subtle py-3 pr-4 pl-6' : 'pt-3 pr-3 pl-4', + )} > <div className="flex flex-col"> <div className="system-xl-semibold text-text-primary">{titleText}</div> <div className="flex items-center gap-x-2"> - <SegmentIndexTag positionId={segInfo?.position || ''} label={isFullDocMode ? labelPrefix : ''} labelPrefix={labelPrefix} /> + <SegmentIndexTag + positionId={segInfo?.position || ''} + label={isFullDocMode ? labelPrefix : ''} + labelPrefix={labelPrefix} + /> <Dot /> <span className="system-xs-medium text-text-tertiary">{wordCountText}</span> </div> @@ -153,19 +164,21 @@ export function SegmentDetail({ )} <button type="button" - aria-label={t($ => $[fullScreen ? 'operation.zoomOut' : 'operation.zoomIn'], { ns: 'common' })} + aria-label={t(($) => $[fullScreen ? 'operation.zoomOut' : 'operation.zoomIn'], { + ns: 'common', + })} className="mr-1 flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" onClick={toggleFullScreen} > - { - fullScreen - ? <RiCollapseDiagonalLine className="size-4 text-text-tertiary" aria-hidden="true" /> - : <RiExpandDiagonalLine className="size-4 text-text-tertiary" aria-hidden="true" /> - } + {fullScreen ? ( + <RiCollapseDiagonalLine className="size-4 text-text-tertiary" aria-hidden="true" /> + ) : ( + <RiExpandDiagonalLine className="size-4 text-text-tertiary" aria-hidden="true" /> + )} </button> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" onClick={onCancel} > @@ -173,28 +186,37 @@ export function SegmentDetail({ </button> </div> </div> - <div className={cn( - 'flex h-0 grow', - fullScreen ? 'w-full flex-row justify-center gap-x-8 px-6 pt-6' : 'flex-col gap-y-1 px-4 py-3', - !isEditMode && 'pb-0', - )} - > - <div className={cn( - isEditMode ? 'overflow-hidden break-all whitespace-pre-line' : 'overflow-y-auto', - fullScreen ? 'w-1/2' : 'h-0 grow', + <div + className={cn( + 'flex h-0 grow', + fullScreen + ? 'w-full flex-row justify-center gap-x-8 px-6 pt-6' + : 'flex-col gap-y-1 px-4 py-3', + !isEditMode && 'pb-0', )} + > + <div + className={cn( + isEditMode ? 'overflow-hidden break-all whitespace-pre-line' : 'overflow-y-auto', + fullScreen ? 'w-1/2' : 'h-0 grow', + )} > <ChunkContent docForm={docForm} question={question} answer={answer} - onQuestionChange={question => setQuestion(question)} - onAnswerChange={answer => setAnswer(answer)} + onQuestionChange={(question) => setQuestion(question)} + onAnswerChange={(answer) => setAnswer(answer)} isEditMode={isEditMode} /> </div> - <div className={cn('flex shrink-0 flex-col', fullScreen ? 'w-[320px] gap-y-2' : 'w-full gap-y-1')}> + <div + className={cn( + 'flex shrink-0 flex-col', + fullScreen ? 'w-[320px] gap-y-2' : 'w-full gap-y-1', + )} + > <ImageUploaderInChunk disabled={!isEditMode} value={attachments} @@ -202,7 +224,7 @@ export function SegmentDetail({ /> <SummaryText value={summary} - onChange={summary => setSummary(summary)} + onChange={(summary) => setSummary(summary)} disabled={!isEditMode} /> {isECOIndexing && ( @@ -212,7 +234,7 @@ export function SegmentDetail({ segInfo={segInfo} keywords={keywords} isEditMode={isEditMode} - onKeywordsChange={keywords => setKeywords(keywords)} + onKeywordsChange={(keywords) => setKeywords(keywords)} /> )} </div> @@ -228,16 +250,14 @@ export function SegmentDetail({ /> </div> )} - { - showRegenerationModal && ( - <RegenerationModal - isShow={showRegenerationModal} - onConfirm={onConfirmRegeneration} - onCancel={onCancelRegeneration} - onClose={onCloseAfterRegeneration} - /> - ) - } + {showRegenerationModal && ( + <RegenerationModal + isShow={showRegenerationModal} + onConfirm={onConfirmRegeneration} + onCancel={onCancelRegeneration} + onClose={onCloseAfterRegeneration} + /> + )} </div> ) } diff --git a/web/app/components/datasets/documents/detail/completed/segment-list.tsx b/web/app/components/datasets/documents/detail/completed/segment-list.tsx index 254a36f8e6c12e..554daec99d3a65 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-list.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-list.tsx @@ -26,37 +26,36 @@ type ISegmentListProps = { onClearFilter: () => void } -const SegmentList = ( - { - ref, - isLoading, - items, - onClick: onClickCard, - onChangeSwitch, - onDelete, - onDeleteChildChunk, - handleAddNewChildChunk, - onClickSlice, - archived, - embeddingAvailable, - onClearFilter, - }: ISegmentListProps & { - ref: React.LegacyRef<HTMLDivElement> - }, -) => { +const SegmentList = ({ + ref, + isLoading, + items, + onClick: onClickCard, + onChangeSwitch, + onDelete, + onDeleteChildChunk, + handleAddNewChildChunk, + onClickSlice, + archived, + embeddingAvailable, + onClearFilter, +}: ISegmentListProps & { + ref: React.LegacyRef<HTMLDivElement> +}) => { const { t } = useTranslation() - const docForm = useDocumentContext(s => s.docForm) - const parentMode = useDocumentContext(s => s.parentMode) - const currSegment = useSegmentListContext(s => s.currSegment) - const currChildChunk = useSegmentListContext(s => s.currChildChunk) + const docForm = useDocumentContext((s) => s.docForm) + const parentMode = useDocumentContext((s) => s.parentMode) + const currSegment = useSegmentListContext((s) => s.currSegment) + const currChildChunk = useSegmentListContext((s) => s.currChildChunk) const Skeleton = useMemo(() => { - return (docForm === ChunkingMode.parentChild && parentMode === 'paragraph') ? ParagraphListSkeleton : GeneralListSkeleton + return docForm === ChunkingMode.parentChild && parentMode === 'paragraph' + ? ParagraphListSkeleton + : GeneralListSkeleton }, [docForm, parentMode]) // Loading skeleton - if (isLoading) - return <Skeleton /> + if (isLoading) return <Skeleton /> // Search result is empty if (items.length === 0) { return ( @@ -67,51 +66,50 @@ const SegmentList = ( } return ( <div ref={ref} className="flex grow flex-col overflow-y-auto"> - { - items.map((segItem) => { - const isLast = items[items.length - 1]!.id === segItem.id - const segmentIndexFocused - = currSegment?.segInfo?.id === segItem.id - || (!currSegment && currChildChunk?.childChunkInfo?.segment_id === segItem.id) - const segmentContentFocused = currSegment?.segInfo?.id === segItem.id - || currChildChunk?.childChunkInfo?.segment_id === segItem.id - return ( - <div key={segItem.id} className="flex items-start gap-x-2"> - <Checkbox - key={`${segItem.id}-checkbox`} - className="mt-3.5 shrink-0" - value={segItem.id} - aria-label={`${t($ => $['segment.chunk'], { ns: 'datasetDocuments' })} ${segItem.position}`} + {items.map((segItem) => { + const isLast = items[items.length - 1]!.id === segItem.id + const segmentIndexFocused = + currSegment?.segInfo?.id === segItem.id || + (!currSegment && currChildChunk?.childChunkInfo?.segment_id === segItem.id) + const segmentContentFocused = + currSegment?.segInfo?.id === segItem.id || + currChildChunk?.childChunkInfo?.segment_id === segItem.id + return ( + <div key={segItem.id} className="flex items-start gap-x-2"> + <Checkbox + key={`${segItem.id}-checkbox`} + className="mt-3.5 shrink-0" + value={segItem.id} + aria-label={`${t(($) => $['segment.chunk'], { ns: 'datasetDocuments' })} ${segItem.position}`} + /> + <div className="min-w-0 grow"> + <SegmentCard + key={`${segItem.id}-card`} + detail={segItem} + onClick={() => onClickCard(segItem, true)} + onChangeSwitch={onChangeSwitch} + onClickEdit={() => onClickCard(segItem, true)} + onDelete={onDelete} + onDeleteChildChunk={onDeleteChildChunk} + handleAddNewChildChunk={handleAddNewChildChunk} + onClickSlice={onClickSlice} + loading={false} + archived={archived} + embeddingAvailable={embeddingAvailable} + focused={{ + segmentIndex: segmentIndexFocused, + segmentContent: segmentContentFocused, + }} /> - <div className="min-w-0 grow"> - <SegmentCard - key={`${segItem.id}-card`} - detail={segItem} - onClick={() => onClickCard(segItem, true)} - onChangeSwitch={onChangeSwitch} - onClickEdit={() => onClickCard(segItem, true)} - onDelete={onDelete} - onDeleteChildChunk={onDeleteChildChunk} - handleAddNewChildChunk={handleAddNewChildChunk} - onClickSlice={onClickSlice} - loading={false} - archived={archived} - embeddingAvailable={embeddingAvailable} - focused={{ - segmentIndex: segmentIndexFocused, - segmentContent: segmentContentFocused, - }} - /> - {!isLast && ( - <div className="w-full px-3"> - <Divider type="horizontal" className="my-1 bg-divider-subtle" /> - </div> - )} - </div> + {!isLast && ( + <div className="w-full px-3"> + <Divider type="horizontal" className="my-1 bg-divider-subtle" /> + </div> + )} </div> - ) - }) - } + </div> + ) + })} </div> ) } diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.tsx index 5f48a8603b7529..41a42bc89a028c 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.tsx @@ -17,7 +17,9 @@ const FullDocListSkeleton = () => { return ( <div className="relative z-10 flex w-full grow flex-col gap-y-3 overflow-y-hidden"> <div className="absolute top-0 bottom-14 left-0 z-20 size-full bg-dataset-chunk-list-mask-bg" /> - {Array.from({ length: 15 }).map((_, index) => <Slice key={index} />)} + {Array.from({ length: 15 }).map((_, index) => ( + <Slice key={index} /> + ))} </div> ) } diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.tsx index 068b9fbe39b407..06ba1f40423b37 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.tsx @@ -53,10 +53,7 @@ const GeneralListSkeleton = () => { {Array.from({ length: 10 }).map((_, index) => { return ( <div key={index} className="flex items-start gap-x-2"> - <CheckboxSkeleton - key={`${index}-checkbox`} - className="mt-3.5 shrink-0" - /> + <CheckboxSkeleton key={`${index}-checkbox`} className="mt-3.5 shrink-0" /> <div className="grow"> <CardSkelton /> {index !== 9 && ( diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.tsx index 25a87757e0253f..496ebcb6e6a90c 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.tsx @@ -55,10 +55,7 @@ const ParagraphListSkeleton = () => { {Array.from({ length: 10 }).map((_, index) => { return ( <div key={index} className="flex items-start gap-x-2"> - <CheckboxSkeleton - key={`${index}-checkbox`} - className="mt-3.5 shrink-0" - /> + <CheckboxSkeleton key={`${index}-checkbox`} className="mt-3.5 shrink-0" /> <div className="grow"> <CardSkelton /> {index !== 9 && ( diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.tsx index b16d40fa8e77a7..ce6593bf600a1d 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.tsx @@ -32,8 +32,12 @@ const ParentChunkCardSkelton = () => { </SkeletonContainer> </SkeletonContainer> <div className="mt-0.5 flex items-center px-3"> - <button type="button" className="pt-0.5 system-xs-semibold-uppercase text-components-button-secondary-accent-text-disabled" disabled> - {t($ => $['operation.viewMore'], { ns: 'common' })} + <button + type="button" + className="pt-0.5 system-xs-semibold-uppercase text-components-button-secondary-accent-text-disabled" + disabled + > + {t(($) => $['operation.viewMore'], { ns: 'common' })} </button> </div> </div> diff --git a/web/app/components/datasets/documents/detail/completed/style.module.css b/web/app/components/datasets/documents/detail/completed/style.module.css index 8cfbc178c0e10a..94fae58de250a5 100644 --- a/web/app/components/datasets/documents/detail/completed/style.module.css +++ b/web/app/components/datasets/documents/detail/completed/style.module.css @@ -1,62 +1,62 @@ @reference "../../../../../styles/globals.css"; .docSearchWrapper { - @apply sticky w-full -top-3 flex items-center mb-3 justify-between z-[11] flex-wrap gap-y-1 pr-3; + @apply sticky -top-3 z-[11] mb-3 flex w-full flex-wrap items-center justify-between gap-y-1 pr-3; } .listContainer { height: calc(100% - 3.25rem); @apply box-border pb-[30px]; } .cardWrapper { - @apply grid gap-4 grid-cols-3 min-w-[902px] last:mb-[30px]; + @apply grid min-w-[902px] grid-cols-3 gap-4 last:mb-[30px]; } .segWrapper { - @apply box-border h-[180px] w-full xl:min-w-[290px] bg-gray-50 px-4 pt-4 flex flex-col rounded-xl border border-transparent hover:border-gray-200 hover:shadow-lg hover:cursor-pointer hover:bg-white; + @apply box-border flex h-[180px] w-full flex-col rounded-xl border border-transparent bg-gray-50 px-4 pt-4 hover:cursor-pointer hover:border-gray-200 hover:bg-white hover:shadow-lg xl:min-w-[290px]; } .segTitleWrapper { @apply flex items-center justify-between; } .segStatusWrapper { - @apply flex items-center box-border; + @apply box-border flex items-center; } .segContent { white-space: wrap; - @apply flex-1 h-0 min-h-0 mt-2 overflow-hidden text-ellipsis text-sm text-gray-800 from-gray-800 to-white; + @apply mt-2 h-0 min-h-0 flex-1 overflow-hidden from-gray-800 to-white text-sm text-ellipsis text-gray-800; } .segData { - @apply hidden text-gray-500 text-xs pt-2; + @apply hidden pt-2 text-xs text-gray-500; } .segDataText { @apply max-w-[80px] truncate; } .chartLinkText { background: linear-gradient(to left, white, 90%, transparent); - @apply text-primary-600 font-semibold text-xs absolute right-0 hidden h-12 pl-12 items-center; + @apply absolute right-0 hidden h-12 items-center pl-12 text-xs font-semibold text-primary-600; } .segModalContent { - @apply h-96 text-gray-800 text-base break-all overflow-y-scroll; + @apply h-96 overflow-y-scroll text-base break-all text-gray-800; white-space: pre-line; } .footer { - @apply flex items-center justify-between box-border border-t-gray-200 border-t-[0.5px] pt-3 mt-4 flex-wrap gap-y-2; + @apply mt-4 box-border flex flex-wrap items-center justify-between gap-y-2 border-t-[0.5px] border-t-gray-200 pt-3; } .numberInfo { - @apply text-gray-500 text-xs font-medium; + @apply text-xs font-medium text-gray-500; } .keywordTitle { - @apply text-gray-500 mb-2 mt-1 text-xs uppercase; + @apply mt-1 mb-2 text-xs text-gray-500 uppercase; } .keywordWrapper { - @apply text-gray-700 w-full max-h-[200px] overflow-auto flex flex-wrap; + @apply flex max-h-[200px] w-full flex-wrap overflow-auto text-gray-700; } .keyword { - @apply text-sm border border-gray-200 max-w-[200px] max-h-[100px] whitespace-pre-line overflow-y-auto mr-1 mb-2 last:mr-0 px-2 py-1 rounded-lg; + @apply mr-1 mb-2 max-h-[100px] max-w-[200px] overflow-y-auto rounded-lg border border-gray-200 px-2 py-1 text-sm whitespace-pre-line last:mr-0; } .hashText { - @apply w-48 inline-block truncate; + @apply inline-block w-48 truncate; } .commonIcon { - @apply w-3 h-3 inline-block align-middle mr-1 bg-gray-500; + @apply mr-1 inline-block h-3 w-3 bg-gray-500 align-middle; mask-repeat: no-repeat; mask-size: contain; mask-position: center center; @@ -71,7 +71,7 @@ mask-image: url(../../assets/bezierCurve.svg); } .cardLoadingWrapper { - @apply relative w-full h-full inline-block rounded-b-xl; + @apply relative inline-block h-full w-full rounded-b-xl; background-position: center center; background-repeat: no-repeat; background-size: 100% 100%; @@ -84,26 +84,22 @@ background-image: url(../../assets/hitLoading.svg); } */ .cardLoadingBg { - @apply h-full relative rounded-b-xl mt-4; + @apply relative mt-4 h-full rounded-b-xl; left: calc(-1rem - 1px); width: calc(100% + 2rem + 2px); height: calc(100% - 1rem + 1px); - background: linear-gradient( - 180deg, - rgba(252, 252, 253, 0) 0%, - #fcfcfd 74.15% - ); + background: linear-gradient(180deg, rgba(252, 252, 253, 0) 0%, #fcfcfd 74.15%); } .hitTitleWrapper { - @apply w-full flex items-center justify-between mb-2; + @apply mb-2 flex w-full items-center justify-between; } .progressWrapper { - @apply flex items-center justify-between w-full; + @apply flex w-full items-center justify-between; } .progress { border-radius: 3px; - @apply relative h-1.5 box-border border border-gray-300 flex-1 mr-2; + @apply relative mr-2 box-border h-1.5 flex-1 border border-gray-300; } .progressLoading { @apply border-[#EAECF0] bg-[#EAECF0]; @@ -113,33 +109,33 @@ } .progressText { font-size: 13px; - @apply text-gray-700 font-bold; + @apply font-bold text-gray-700; } .progressTextLoading { border-radius: 5px; @apply h-3.5 w-3.5 bg-[#EAECF0]; } .editTip { - box-shadow: 0px 4px 6px -2px rgba(16, 24, 40, 0.03), 0px 12px 16px -4px rgba(16, 24, 40, 0.08); + box-shadow: + 0px 4px 6px -2px rgba(16, 24, 40, 0.03), + 0px 12px 16px -4px rgba(16, 24, 40, 0.08); } .delModal { - background: linear-gradient( - 180deg, - rgba(217, 45, 32, 0.05) 0%, - rgba(217, 45, 32, 0) 24.02% - ), - #f9fafb; - box-shadow: 0px 20px 24px -4px rgba(16, 24, 40, 0.08), + background: + linear-gradient(180deg, rgba(217, 45, 32, 0.05) 0%, rgba(217, 45, 32, 0) 24.02%), #f9fafb; + box-shadow: + 0px 20px 24px -4px rgba(16, 24, 40, 0.08), 0px 8px 8px -4px rgba(16, 24, 40, 0.03); @apply rounded-2xl p-8; } .warningWrapper { - box-shadow: 0px 20px 24px -4px rgba(16, 24, 40, 0.08), + box-shadow: + 0px 20px 24px -4px rgba(16, 24, 40, 0.08), 0px 8px 8px -4px rgba(16, 24, 40, 0.03); background: rgba(255, 255, 255, 0.9); - @apply h-12 w-12 border-[0.5px] border-gray-100 rounded-xl mb-3 flex items-center justify-center; + @apply mb-3 flex h-12 w-12 items-center justify-center rounded-xl border-[0.5px] border-gray-100; } .warningIcon { - @apply w-[22px] h-[22px] fill-current text-red-600; + @apply h-[22px] w-[22px] fill-current text-red-600; } diff --git a/web/app/components/datasets/documents/detail/document-title.tsx b/web/app/components/datasets/documents/detail/document-title.tsx index 0a1cfbf61aa248..7326102bdd4c84 100644 --- a/web/app/components/datasets/documents/detail/document-title.tsx +++ b/web/app/components/datasets/documents/detail/document-title.tsx @@ -10,12 +10,7 @@ type DocumentTitleProps = { wrapperCls?: string } -export function DocumentTitle({ - datasetId, - document, - parentMode, - wrapperCls, -}: DocumentTitleProps) { +export function DocumentTitle({ datasetId, document, parentMode, wrapperCls }: DocumentTitleProps) { const router = useRouter() return ( diff --git a/web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx index 56894cbf452110..e5f5011b0006dc 100644 --- a/web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx @@ -37,27 +37,30 @@ const mockPauseDocIndexing = vi.mocked(datasetsService.pauseDocIndexing) const mockResumeDocIndexing = vi.mocked(datasetsService.resumeDocIndexing) const mockUseProcessRule = vi.mocked(useDataset.useProcessRule) -const createTestQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false, gcTime: 0 }, - mutations: { retry: false }, - }, -}) +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }) -const createWrapper = (contextValue: DocumentContextValue = { datasetId: 'ds1', documentId: 'doc1' }) => { +const createWrapper = ( + contextValue: DocumentContextValue = { datasetId: 'ds1', documentId: 'doc1' }, +) => { const queryClient = createTestQueryClient() return ({ children }: { children: ReactNode }) => ( <QueryClientProvider client={queryClient}> <> - <DocumentContext.Provider value={contextValue}> - {children} - </DocumentContext.Provider> + <DocumentContext.Provider value={contextValue}>{children}</DocumentContext.Provider> </> </QueryClientProvider> ) } -const mockIndexingStatus = (overrides: Partial<IndexingStatusResponse> = {}): IndexingStatusResponse => ({ +const mockIndexingStatus = ( + overrides: Partial<IndexingStatusResponse> = {}, +): IndexingStatusResponse => ({ id: 'doc1', indexing_status: 'indexing', completed_segments: 50, @@ -116,10 +119,9 @@ describe('EmbeddingDetail', () => { it('should render with provided datasetId and documentId props', async () => { mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) - render( - <EmbeddingDetail {...defaultProps} datasetId="custom-ds" documentId="custom-doc" />, - { wrapper: createWrapper({ datasetId: '', documentId: '' }) }, - ) + render(<EmbeddingDetail {...defaultProps} datasetId="custom-ds" documentId="custom-doc" />, { + wrapper: createWrapper({ datasetId: '', documentId: '' }), + }) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalledWith({ @@ -155,7 +157,9 @@ describe('EmbeddingDetail', () => { }) it('should show completed status', async () => { - mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'completed' })) + mockFetchIndexingStatus.mockResolvedValue( + mockIndexingStatus({ indexing_status: 'completed' }), + ) render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() }) @@ -187,10 +191,12 @@ describe('EmbeddingDetail', () => { describe('Progress Display', () => { it('should display segment progress', async () => { - mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ - completed_segments: 50, - total_segments: 100, - })) + mockFetchIndexingStatus.mockResolvedValue( + mockIndexingStatus({ + completed_segments: 50, + total_segments: 100, + }), + ) render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() }) @@ -279,10 +285,9 @@ describe('EmbeddingDetail', () => { it('should display qualified index mode', async () => { mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) - render( - <EmbeddingDetail {...defaultProps} indexingType={IndexingType.QUALIFIED} />, - { wrapper: createWrapper() }, - ) + render(<EmbeddingDetail {...defaultProps} indexingType={IndexingType.QUALIFIED} />, { + wrapper: createWrapper(), + }) await waitFor(() => { expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument() @@ -292,10 +297,9 @@ describe('EmbeddingDetail', () => { it('should display economical index mode', async () => { mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) - render( - <EmbeddingDetail {...defaultProps} indexingType={IndexingType.ECONOMICAL} />, - { wrapper: createWrapper() }, - ) + render(<EmbeddingDetail {...defaultProps} indexingType={IndexingType.ECONOMICAL} />, { + wrapper: createWrapper(), + }) await waitFor(() => { expect(screen.getByText(/stepTwo\.economical/i)).toBeInTheDocument() @@ -311,15 +315,17 @@ describe('EmbeddingDetail', () => { .mockResolvedValueOnce(mockIndexingStatus({ indexing_status: 'indexing' })) .mockResolvedValueOnce(mockIndexingStatus({ indexing_status: 'completed' })) - render( - <EmbeddingDetail {...defaultProps} detailUpdate={detailUpdate} />, - { wrapper: createWrapper() }, - ) + render(<EmbeddingDetail {...defaultProps} detailUpdate={detailUpdate} />, { + wrapper: createWrapper(), + }) // Wait for the terminal status to trigger detailUpdate - await waitFor(() => { - expect(mockFetchIndexingStatus).toHaveBeenCalled() - }, { timeout: 5000 }) + await waitFor( + () => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }, + { timeout: 5000 }, + ) }) }) @@ -343,7 +349,9 @@ describe('EmbeddingDetail', () => { it('should render skeleton component', async () => { mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) - const { container } = render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() }) + const { container } = render(<EmbeddingDetail {...defaultProps} />, { + wrapper: createWrapper(), + }) // EmbeddingSkeleton should be rendered - check for the skeleton wrapper element await waitFor(() => { diff --git a/web/app/components/datasets/documents/detail/embedding/components/__tests__/progress-bar.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/__tests__/progress-bar.spec.tsx index c4c6501eef220b..607f667e948b5c 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/__tests__/progress-bar.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/__tests__/progress-bar.spec.tsx @@ -28,7 +28,14 @@ describe('ProgressBar', () => { it('should render progress bar container with correct classes', () => { const { container } = render(<ProgressBar {...defaultProps} />) const { wrapper } = getProgressElements(container) - expect(wrapper).toHaveClass('flex', 'h-2', 'w-full', 'items-center', 'overflow-hidden', 'rounded-md') + expect(wrapper).toHaveClass( + 'flex', + 'h-2', + 'w-full', + 'items-center', + 'overflow-hidden', + 'rounded-md', + ) }) it('should render inner progress bar with transition classes', () => { diff --git a/web/app/components/datasets/documents/detail/embedding/components/progress-bar.tsx b/web/app/components/datasets/documents/detail/embedding/components/progress-bar.tsx index 881b7dc0e26514..749327d476c3bd 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/progress-bar.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/progress-bar.tsx @@ -10,34 +10,30 @@ type ProgressBarProps = { isError: boolean } -const ProgressBar: FC<ProgressBarProps> = React.memo(({ - percent, - isEmbedding, - isCompleted, - isPaused, - isError, -}) => { - const isActive = isEmbedding || isCompleted - const isHighlighted = isPaused || isError +const ProgressBar: FC<ProgressBarProps> = React.memo( + ({ percent, isEmbedding, isCompleted, isPaused, isError }) => { + const isActive = isEmbedding || isCompleted + const isHighlighted = isPaused || isError - return ( - <div - className={cn( - 'flex h-2 w-full items-center overflow-hidden rounded-md border border-components-progress-bar-border', - isEmbedding ? 'bg-components-progress-bar-bg/50' : 'bg-components-progress-bar-bg', - )} - > + return ( <div className={cn( - 'h-full transition-all duration-300', - isActive && 'bg-components-progress-bar-progress-solid', - isHighlighted && 'bg-components-progress-bar-progress-highlight', + 'flex h-2 w-full items-center overflow-hidden rounded-md border border-components-progress-bar-border', + isEmbedding ? 'bg-components-progress-bar-bg/50' : 'bg-components-progress-bar-bg', )} - style={{ width: `${percent}%` }} - /> - </div> - ) -}) + > + <div + className={cn( + 'h-full transition-all duration-300', + isActive && 'bg-components-progress-bar-progress-solid', + isHighlighted && 'bg-components-progress-bar-progress-highlight', + )} + style={{ width: `${percent}%` }} + /> + </div> + ) + }, +) ProgressBar.displayName = 'ProgressBar' diff --git a/web/app/components/datasets/documents/detail/embedding/components/rule-detail.tsx b/web/app/components/datasets/documents/detail/embedding/components/rule-detail.tsx index b090a5228a1147..453bff7383322f 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/rule-detail.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/rule-detail.tsx @@ -17,110 +17,118 @@ type RuleDetailProps = { } const getRetrievalIcon = (method?: RETRIEVE_METHOD) => { - if (method === 'full_text_search') - return retrievalIcon.fullText - if (method === 'hybrid_search') - return retrievalIcon.hybrid + if (method === 'full_text_search') return retrievalIcon.fullText + if (method === 'hybrid_search') return retrievalIcon.hybrid return retrievalIcon.vector } -const RuleDetail: FC<RuleDetailProps> = React.memo(({ - sourceData, - indexingType, - retrievalMethod, -}) => { - const { t } = useTranslation() - - const segmentationRuleMap = { - mode: t($ => $['embedding.mode'], { ns: 'datasetDocuments' }), - segmentLength: t($ => $['embedding.segmentLength'], { ns: 'datasetDocuments' }), - textCleaning: t($ => $['embedding.textCleaning'], { ns: 'datasetDocuments' }), - } - - const getRuleName = useCallback((key: string) => { - const ruleNameMap: Record<string, string> = { - remove_extra_spaces: t($ => $['stepTwo.removeExtraSpaces'], { ns: 'datasetCreation' }), - remove_urls_emails: t($ => $['stepTwo.removeUrlEmails'], { ns: 'datasetCreation' }), - remove_stopwords: t($ => $['stepTwo.removeStopwords'], { ns: 'datasetCreation' }), - } - return ruleNameMap[key] - }, [t]) - - const getValue = useCallback((field: string) => { - const defaultValue = '-' - - if (!sourceData?.mode) - return defaultValue - - const maxTokens = typeof sourceData?.rules?.segmentation?.max_tokens === 'number' - ? sourceData.rules.segmentation.max_tokens - : defaultValue - - const childMaxTokens = typeof sourceData?.rules?.subchunk_segmentation?.max_tokens === 'number' - ? sourceData.rules.subchunk_segmentation.max_tokens - : defaultValue - - const isGeneralMode = sourceData.mode === ProcessMode.general - - const fieldValueMap: Record<string, string | number> = { - mode: isGeneralMode - ? t($ => $['embedding.custom'], { ns: 'datasetDocuments' }) - : `${t($ => $['embedding.hierarchical'], { ns: 'datasetDocuments' })} · ${ - sourceData?.rules?.parent_mode === 'paragraph' - ? t($ => $['parentMode.paragraph'], { ns: 'dataset' }) - : t($ => $['parentMode.fullDoc'], { ns: 'dataset' }) - }`, - segmentLength: isGeneralMode - ? maxTokens - : `${t($ => $['embedding.parentMaxTokens'], { ns: 'datasetDocuments' })} ${maxTokens}; ${t($ => $['embedding.childMaxTokens'], { ns: 'datasetDocuments' })} ${childMaxTokens}`, - textCleaning: sourceData?.rules?.pre_processing_rules - ?.filter(rule => rule.enabled) - .map(rule => getRuleName(rule.id)) - .join(',') || defaultValue, +const RuleDetail: FC<RuleDetailProps> = React.memo( + ({ sourceData, indexingType, retrievalMethod }) => { + const { t } = useTranslation() + + const segmentationRuleMap = { + mode: t(($) => $['embedding.mode'], { ns: 'datasetDocuments' }), + segmentLength: t(($) => $['embedding.segmentLength'], { ns: 'datasetDocuments' }), + textCleaning: t(($) => $['embedding.textCleaning'], { ns: 'datasetDocuments' }), } - return fieldValueMap[field] ?? defaultValue - }, [sourceData, t, getRuleName]) - - const isEconomical = indexingType === IndexingType.ECONOMICAL - - return ( - <div className="py-3"> - <div className="flex flex-col gap-y-1"> - {Object.keys(segmentationRuleMap).map(field => ( - <FieldInfo - key={field} - label={segmentationRuleMap[field as keyof typeof segmentationRuleMap]} - displayedValue={String(getValue(field))} - /> - ))} + const getRuleName = useCallback( + (key: string) => { + const ruleNameMap: Record<string, string> = { + remove_extra_spaces: t(($) => $['stepTwo.removeExtraSpaces'], { ns: 'datasetCreation' }), + remove_urls_emails: t(($) => $['stepTwo.removeUrlEmails'], { ns: 'datasetCreation' }), + remove_stopwords: t(($) => $['stepTwo.removeStopwords'], { ns: 'datasetCreation' }), + } + return ruleNameMap[key] + }, + [t], + ) + + const getValue = useCallback( + (field: string) => { + const defaultValue = '-' + + if (!sourceData?.mode) return defaultValue + + const maxTokens = + typeof sourceData?.rules?.segmentation?.max_tokens === 'number' + ? sourceData.rules.segmentation.max_tokens + : defaultValue + + const childMaxTokens = + typeof sourceData?.rules?.subchunk_segmentation?.max_tokens === 'number' + ? sourceData.rules.subchunk_segmentation.max_tokens + : defaultValue + + const isGeneralMode = sourceData.mode === ProcessMode.general + + const fieldValueMap: Record<string, string | number> = { + mode: isGeneralMode + ? t(($) => $['embedding.custom'], { ns: 'datasetDocuments' }) + : `${t(($) => $['embedding.hierarchical'], { ns: 'datasetDocuments' })} · ${ + sourceData?.rules?.parent_mode === 'paragraph' + ? t(($) => $['parentMode.paragraph'], { ns: 'dataset' }) + : t(($) => $['parentMode.fullDoc'], { ns: 'dataset' }) + }`, + segmentLength: isGeneralMode + ? maxTokens + : `${t(($) => $['embedding.parentMaxTokens'], { ns: 'datasetDocuments' })} ${maxTokens}; ${t(($) => $['embedding.childMaxTokens'], { ns: 'datasetDocuments' })} ${childMaxTokens}`, + textCleaning: + sourceData?.rules?.pre_processing_rules + ?.filter((rule) => rule.enabled) + .map((rule) => getRuleName(rule.id)) + .join(',') || defaultValue, + } + + return fieldValueMap[field] ?? defaultValue + }, + [sourceData, t, getRuleName], + ) + + const isEconomical = indexingType === IndexingType.ECONOMICAL + + return ( + <div className="py-3"> + <div className="flex flex-col gap-y-1"> + {Object.keys(segmentationRuleMap).map((field) => ( + <FieldInfo + key={field} + label={segmentationRuleMap[field as keyof typeof segmentationRuleMap]} + displayedValue={String(getValue(field))} + /> + ))} + </div> + <Divider type="horizontal" className="bg-divider-subtle" /> + <FieldInfo + label={t(($) => $['stepTwo.indexMode'], { ns: 'datasetCreation' })} + displayedValue={ + t(($) => $[`stepTwo.${isEconomical ? 'economical' : 'qualified'}`], { + ns: 'datasetCreation', + }) as string + } + valueIcon={ + <img + className="size-4" + src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality} + alt="" + /> + } + /> + <FieldInfo + label={t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} + displayedValue={t( + ($) => + $[ + `retrieval.${isEconomical ? 'keyword_search' : (retrievalMethod ?? 'semantic_search')}.title` + ], + { ns: 'dataset' }, + )} + valueIcon={<img className="size-4" src={getRetrievalIcon(retrievalMethod)} alt="" />} + /> </div> - <Divider type="horizontal" className="bg-divider-subtle" /> - <FieldInfo - label={t($ => $['stepTwo.indexMode'], { ns: 'datasetCreation' })} - displayedValue={t($ => $[`stepTwo.${isEconomical ? 'economical' : 'qualified'}`], { ns: 'datasetCreation' }) as string} - valueIcon={( - <img - className="size-4" - src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality} - alt="" - /> - )} - /> - <FieldInfo - label={t($ => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} - displayedValue={t($ => $[`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`], { ns: 'dataset' })} - valueIcon={( - <img - className="size-4" - src={getRetrievalIcon(retrievalMethod)} - alt="" - /> - )} - /> - </div> - ) -}) + ) + }, +) RuleDetail.displayName = 'RuleDetail' diff --git a/web/app/components/datasets/documents/detail/embedding/components/segment-progress.tsx b/web/app/components/datasets/documents/detail/embedding/components/segment-progress.tsx index 85d1593b6a9f32..23f1a8ecc25a7a 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/segment-progress.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/segment-progress.tsx @@ -8,24 +8,22 @@ type SegmentProgressProps = { percent: number } -const SegmentProgress: FC<SegmentProgressProps> = React.memo(({ - completedSegments, - totalSegments, - percent, -}) => { - const { t } = useTranslation() +const SegmentProgress: FC<SegmentProgressProps> = React.memo( + ({ completedSegments, totalSegments, percent }) => { + const { t } = useTranslation() - const completed = completedSegments ?? '--' - const total = totalSegments ?? '--' + const completed = completedSegments ?? '--' + const total = totalSegments ?? '--' - return ( - <div className="flex w-full items-center"> - <span className="system-xs-medium text-text-secondary"> - {`${t($ => $['embedding.segments'], { ns: 'datasetDocuments' })} ${completed}/${total} · ${percent}%`} - </span> - </div> - ) -}) + return ( + <div className="flex w-full items-center"> + <span className="system-xs-medium text-text-secondary"> + {`${t(($) => $['embedding.segments'], { ns: 'datasetDocuments' })} ${completed}/${total} · ${percent}%`} + </span> + </div> + ) + }, +) SegmentProgress.displayName = 'SegmentProgress' diff --git a/web/app/components/datasets/documents/detail/embedding/components/status-header.tsx b/web/app/components/datasets/documents/detail/embedding/components/status-header.tsx index 81004b3d07a15d..33194bb61a7a8f 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/status-header.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/status-header.tsx @@ -14,70 +14,68 @@ type StatusHeaderProps = { isResumeLoading?: boolean } -const StatusHeader: FC<StatusHeaderProps> = React.memo(({ - isEmbedding, - isCompleted, - isPaused, - isError, - onPause, - onResume, - isPauseLoading, - isResumeLoading, -}) => { - const { t } = useTranslation() +const StatusHeader: FC<StatusHeaderProps> = React.memo( + ({ + isEmbedding, + isCompleted, + isPaused, + isError, + onPause, + onResume, + isPauseLoading, + isResumeLoading, + }) => { + const { t } = useTranslation() - const getStatusText = () => { - if (isEmbedding) - return t($ => $['embedding.processing'], { ns: 'datasetDocuments' }) - if (isCompleted) - return t($ => $['embedding.completed'], { ns: 'datasetDocuments' }) - if (isPaused) - return t($ => $['embedding.paused'], { ns: 'datasetDocuments' }) - if (isError) - return t($ => $['embedding.error'], { ns: 'datasetDocuments' }) - return '' - } + const getStatusText = () => { + if (isEmbedding) return t(($) => $['embedding.processing'], { ns: 'datasetDocuments' }) + if (isCompleted) return t(($) => $['embedding.completed'], { ns: 'datasetDocuments' }) + if (isPaused) return t(($) => $['embedding.paused'], { ns: 'datasetDocuments' }) + if (isError) return t(($) => $['embedding.error'], { ns: 'datasetDocuments' }) + return '' + } - const buttonBaseClass = `flex items-center gap-x-1 rounded-md border-[0.5px] + const buttonBaseClass = `flex items-center gap-x-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] disabled:cursor-not-allowed disabled:opacity-50` - return ( - <div className="flex h-6 items-center gap-x-1"> - {isEmbedding && <RiLoader2Line className="size-4 animate-spin text-text-secondary" />} - <span className="grow system-md-semibold-uppercase text-text-secondary"> - {getStatusText()} - </span> - {isEmbedding && ( - <button - type="button" - className={buttonBaseClass} - onClick={onPause} - disabled={isPauseLoading} - > - <RiPauseCircleLine className="size-3.5 text-components-button-secondary-text" /> - <span className="pr-[3px] system-xs-medium text-components-button-secondary-text"> - {t($ => $['embedding.pause'], { ns: 'datasetDocuments' })} - </span> - </button> - )} - {isPaused && ( - <button - type="button" - className={buttonBaseClass} - onClick={onResume} - disabled={isResumeLoading} - > - <RiPlayCircleLine className="size-3.5 text-components-button-secondary-text" /> - <span className="pr-[3px] system-xs-medium text-components-button-secondary-text"> - {t($ => $['embedding.resume'], { ns: 'datasetDocuments' })} - </span> - </button> - )} - </div> - ) -}) + return ( + <div className="flex h-6 items-center gap-x-1"> + {isEmbedding && <RiLoader2Line className="size-4 animate-spin text-text-secondary" />} + <span className="grow system-md-semibold-uppercase text-text-secondary"> + {getStatusText()} + </span> + {isEmbedding && ( + <button + type="button" + className={buttonBaseClass} + onClick={onPause} + disabled={isPauseLoading} + > + <RiPauseCircleLine className="size-3.5 text-components-button-secondary-text" /> + <span className="pr-[3px] system-xs-medium text-components-button-secondary-text"> + {t(($) => $['embedding.pause'], { ns: 'datasetDocuments' })} + </span> + </button> + )} + {isPaused && ( + <button + type="button" + className={buttonBaseClass} + onClick={onResume} + disabled={isResumeLoading} + > + <RiPlayCircleLine className="size-3.5 text-components-button-secondary-text" /> + <span className="pr-[3px] system-xs-medium text-components-button-secondary-text"> + {t(($) => $['embedding.resume'], { ns: 'datasetDocuments' })} + </span> + </button> + )} + </div> + ) + }, +) StatusHeader.displayName = 'StatusHeader' diff --git a/web/app/components/datasets/documents/detail/embedding/hooks/__tests__/use-embedding-status.spec.tsx b/web/app/components/datasets/documents/detail/embedding/hooks/__tests__/use-embedding-status.spec.tsx index 53660fa41029b9..19ea6c68c1cf29 100644 --- a/web/app/components/datasets/documents/detail/embedding/hooks/__tests__/use-embedding-status.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/hooks/__tests__/use-embedding-status.spec.tsx @@ -19,23 +19,24 @@ const mockFetchIndexingStatus = vi.mocked(datasetsService.fetchIndexingStatus) const mockPauseDocIndexing = vi.mocked(datasetsService.pauseDocIndexing) const mockResumeDocIndexing = vi.mocked(datasetsService.resumeDocIndexing) -const createTestQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, -}) +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) const createWrapper = () => { const queryClient = createTestQueryClient() return ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) } -const mockIndexingStatus = (overrides: Partial<IndexingStatusResponse> = {}): IndexingStatusResponse => ({ +const mockIndexingStatus = ( + overrides: Partial<IndexingStatusResponse> = {}, +): IndexingStatusResponse => ({ id: 'doc1', indexing_status: 'indexing', completed_segments: 50, @@ -158,19 +159,13 @@ describe('use-embedding-status', () => { }) it('should not fetch when datasetId is missing', () => { - renderHook( - () => useEmbeddingStatus({ documentId: 'doc1' }), - { wrapper: createWrapper() }, - ) + renderHook(() => useEmbeddingStatus({ documentId: 'doc1' }), { wrapper: createWrapper() }) expect(mockFetchIndexingStatus).not.toHaveBeenCalled() }) it('should not fetch when documentId is missing', () => { - renderHook( - () => useEmbeddingStatus({ datasetId: 'ds1' }), - { wrapper: createWrapper() }, - ) + renderHook(() => useEmbeddingStatus({ datasetId: 'ds1' }), { wrapper: createWrapper() }) expect(mockFetchIndexingStatus).not.toHaveBeenCalled() }) @@ -195,10 +190,12 @@ describe('use-embedding-status', () => { }) it('should set isCompleted when status is completed', async () => { - mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ - indexing_status: 'completed', - completed_segments: 100, - })) + mockFetchIndexingStatus.mockResolvedValue( + mockIndexingStatus({ + indexing_status: 'completed', + completed_segments: 100, + }), + ) const { result } = renderHook( () => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }), @@ -213,9 +210,11 @@ describe('use-embedding-status', () => { }) it('should set isPaused when status is paused', async () => { - mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ - indexing_status: 'paused', - })) + mockFetchIndexingStatus.mockResolvedValue( + mockIndexingStatus({ + indexing_status: 'paused', + }), + ) const { result } = renderHook( () => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }), @@ -228,10 +227,12 @@ describe('use-embedding-status', () => { }) it('should set isError when status is error', async () => { - mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ - indexing_status: 'error', - completed_segments: 25, - })) + mockFetchIndexingStatus.mockResolvedValue( + mockIndexingStatus({ + indexing_status: 'error', + completed_segments: 25, + }), + ) const { result } = renderHook( () => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }), diff --git a/web/app/components/datasets/documents/detail/embedding/hooks/index.ts b/web/app/components/datasets/documents/detail/embedding/hooks/index.ts index 2d0c4fa25ebbd7..3b714a2b991031 100644 --- a/web/app/components/datasets/documents/detail/embedding/hooks/index.ts +++ b/web/app/components/datasets/documents/detail/embedding/hooks/index.ts @@ -1,7 +1 @@ -export { - - useEmbeddingStatus, - - usePauseIndexing, - useResumeIndexing, -} from './use-embedding-status' +export { useEmbeddingStatus, usePauseIndexing, useResumeIndexing } from './use-embedding-status' diff --git a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts b/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts index 8039ec05bb4a10..edcac54f2ab52a 100644 --- a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts +++ b/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts @@ -2,11 +2,7 @@ import type { CommonResponse } from '@/models/common' import type { IndexingStatusResponse } from '@/models/datasets' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef } from 'react' -import { - fetchIndexingStatus, - pauseDocIndexing, - resumeDocIndexing, -} from '@/service/datasets' +import { fetchIndexingStatus, pauseDocIndexing, resumeDocIndexing } from '@/service/datasets' const NAME_SPACE = 'embedding' @@ -14,17 +10,16 @@ const EMBEDDING_STATUSES = ['indexing', 'splitting', 'parsing', 'cleaning'] as c const TERMINAL_STATUSES = ['completed', 'error', 'paused'] as const export const isEmbeddingStatus = (status?: string): boolean => { - return EMBEDDING_STATUSES.includes(status as typeof EMBEDDING_STATUSES[number]) + return EMBEDDING_STATUSES.includes(status as (typeof EMBEDDING_STATUSES)[number]) } export const isTerminalStatus = (status?: string): boolean => { - return TERMINAL_STATUSES.includes(status as typeof TERMINAL_STATUSES[number]) + return TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]) } export const calculatePercent = (completed?: number, total?: number): number => { - if (!total || total === 0) - return 0 - const percent = Math.round((completed || 0) * 100 / total) + if (!total || total === 0) return 0 + const percent = Math.round(((completed || 0) * 100) / total) return Math.min(percent, 100) } @@ -112,7 +107,12 @@ type UsePauseResumeOptions = { onError?: (error: Error) => void } -export const usePauseIndexing = ({ datasetId, documentId, onSuccess, onError }: UsePauseResumeOptions) => { +export const usePauseIndexing = ({ + datasetId, + documentId, + onSuccess, + onError, +}: UsePauseResumeOptions) => { return useMutation<CommonResponse, Error>({ mutationKey: [NAME_SPACE, 'pause', datasetId, documentId], mutationFn: () => pauseDocIndexing({ datasetId: datasetId!, documentId: documentId! }), @@ -121,7 +121,12 @@ export const usePauseIndexing = ({ datasetId, documentId, onSuccess, onError }: }) } -export const useResumeIndexing = ({ datasetId, documentId, onSuccess, onError }: UsePauseResumeOptions) => { +export const useResumeIndexing = ({ + datasetId, + documentId, + onSuccess, + onError, +}: UsePauseResumeOptions) => { return useMutation<CommonResponse, Error>({ mutationKey: [NAME_SPACE, 'resume', datasetId, documentId], mutationFn: () => resumeDocIndexing({ datasetId: datasetId!, documentId: documentId! }), diff --git a/web/app/components/datasets/documents/detail/embedding/index.tsx b/web/app/components/datasets/documents/detail/embedding/index.tsx index 59565de3fd5f88..a36ec10960ebd6 100644 --- a/web/app/components/datasets/documents/detail/embedding/index.tsx +++ b/web/app/components/datasets/documents/detail/embedding/index.tsx @@ -18,23 +18,38 @@ type EmbeddingDetailProps = { retrievalMethod?: RETRIEVE_METHOD detailUpdate: VoidFunction } -const EmbeddingDetail: FC<EmbeddingDetailProps> = ({ datasetId: dstId, documentId: docId, detailUpdate, indexingType, retrievalMethod }) => { +const EmbeddingDetail: FC<EmbeddingDetailProps> = ({ + datasetId: dstId, + documentId: docId, + detailUpdate, + indexingType, + retrievalMethod, +}) => { const { t } = useTranslation() - const contextDatasetId = useDocumentContext(s => s.datasetId) - const contextDocumentId = useDocumentContext(s => s.documentId) + const contextDatasetId = useDocumentContext((s) => s.datasetId) + const contextDocumentId = useDocumentContext((s) => s.documentId) const datasetId = dstId ?? contextDatasetId const documentId = docId ?? contextDocumentId - const { data: indexingStatus, isEmbedding, isCompleted, isPaused, isError, percent, resetStatus, refetch } = useEmbeddingStatus({ + const { + data: indexingStatus, + isEmbedding, + isCompleted, + isPaused, + isError, + percent, + resetStatus, + refetch, + } = useEmbeddingStatus({ datasetId, documentId, onComplete: detailUpdate, }) const { data: ruleDetail } = useProcessRule(documentId) const handleSuccess = useCallback(() => { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) }, [t]) const handleError = useCallback(() => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) }, [t]) const pauseMutation = usePauseIndexing({ datasetId, @@ -64,10 +79,33 @@ const EmbeddingDetail: FC<EmbeddingDetailProps> = ({ datasetId: dstId, documentI return ( <> <div className="flex flex-col gap-y-2 px-16 py-12"> - <StatusHeader isEmbedding={isEmbedding} isCompleted={isCompleted} isPaused={isPaused} isError={isError} onPause={handlePause} onResume={handleResume} isPauseLoading={pauseMutation.isPending} isResumeLoading={resumeMutation.isPending} /> - <ProgressBar percent={percent} isEmbedding={isEmbedding} isCompleted={isCompleted} isPaused={isPaused} isError={isError} /> - <SegmentProgress completedSegments={indexingStatus?.completed_segments} totalSegments={indexingStatus?.total_segments} percent={percent} /> - <RuleDetail sourceData={ruleDetail} indexingType={indexingType} retrievalMethod={retrievalMethod} /> + <StatusHeader + isEmbedding={isEmbedding} + isCompleted={isCompleted} + isPaused={isPaused} + isError={isError} + onPause={handlePause} + onResume={handleResume} + isPauseLoading={pauseMutation.isPending} + isResumeLoading={resumeMutation.isPending} + /> + <ProgressBar + percent={percent} + isEmbedding={isEmbedding} + isCompleted={isCompleted} + isPaused={isPaused} + isError={isError} + /> + <SegmentProgress + completedSegments={indexingStatus?.completed_segments} + totalSegments={indexingStatus?.total_segments} + percent={percent} + /> + <RuleDetail + sourceData={ruleDetail} + indexingType={indexingType} + retrievalMethod={retrievalMethod} + /> </div> <EmbeddingSkeleton /> </> diff --git a/web/app/components/datasets/documents/detail/embedding/skeleton/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/embedding/skeleton/__tests__/index.spec.tsx index b350ce8a2036fe..a4af5c4dbeddfa 100644 --- a/web/app/components/datasets/documents/detail/embedding/skeleton/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/skeleton/__tests__/index.spec.tsx @@ -3,10 +3,14 @@ import EmbeddingSkeleton from '../index' // Mock Skeleton components vi.mock('@/app/components/base/skeleton', () => ({ - SkeletonContainer: ({ children }: { children?: React.ReactNode }) => <div data-testid="skeleton-container">{children}</div>, + SkeletonContainer: ({ children }: { children?: React.ReactNode }) => ( + <div data-testid="skeleton-container">{children}</div> + ), SkeletonPoint: () => <div data-testid="skeleton-point" />, SkeletonRectangle: () => <div data-testid="skeleton-rectangle" />, - SkeletonRow: ({ children }: { children?: React.ReactNode }) => <div data-testid="skeleton-row">{children}</div>, + SkeletonRow: ({ children }: { children?: React.ReactNode }) => ( + <div data-testid="skeleton-row">{children}</div> + ), })) // Mock Divider diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx index 736b1eabb129fd..a95fc0b4090858 100644 --- a/web/app/components/datasets/documents/detail/index.tsx +++ b/web/app/components/datasets/documents/detail/index.tsx @@ -18,8 +18,17 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { ChunkingMode, DisplayStatusList } from '@/models/datasets' import { useRouter, useSearchParams } from '@/next/navigation' -import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '@/service/knowledge/use-document' -import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment' +import { + useDocumentDetail, + useDocumentMetadata, + useInvalidDocumentList, +} from '@/service/knowledge/use-document' +import { + useCheckSegmentBatchImportProgress, + useChildSegmentListKey, + useSegmentBatchImport, + useSegmentListKey, +} from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' import { segmentImportStatus } from '@/types/dataset' import { getDatasetACLCapabilities } from '@/utils/permission' @@ -38,8 +47,8 @@ type DocumentDetailProps = { documentId: string } -const NON_TERMINAL_DISPLAY_STATUSES = new Set<typeof DisplayStatusList[number]>( - DisplayStatusList.filter(s => s === 'queuing' || s === 'indexing' || s === 'paused'), +const NON_TERMINAL_DISPLAY_STATUSES = new Set<(typeof DisplayStatusList)[number]>( + DisplayStatusList.filter((s) => s === 'queuing' || s === 'indexing' || s === 'paused'), ) const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => { @@ -50,16 +59,17 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => { const media = useBreakpoints() const isMobile = media === MediaType.mobile - const dataset = useDatasetDetailContextWithSelector(s => s.dataset) + const dataset = useDatasetDetailContextWithSelector((s) => s.dataset) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const embeddingAvailable = !!dataset?.embedding_available const datasetACLCapabilities = useMemo( - () => getDatasetACLCapabilities(dataset?.permission_keys, { - currentUserId, - resourceMaintainer: dataset?.maintainer, - workspacePermissionKeys, - }), + () => + getDatasetACLCapabilities(dataset?.permission_keys, { + currentUserId, + resourceMaintainer: dataset?.maintainer, + workspacePermissionKeys, + }), [dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys], ) const canEditDocument = datasetACLCapabilities.canEdit @@ -74,46 +84,62 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => { const { mutateAsync: checkSegmentBatchImportProgress } = useCheckSegmentBatchImportProgress() const checkProcess = async (jobID: string) => { - await checkSegmentBatchImportProgress({ jobID }, { - onSuccess: (res) => { - setImportStatus(res.job_status) - if (res.job_status === segmentImportStatus.waiting || res.job_status === segmentImportStatus.processing) - setTimeout(() => checkProcess(res.job_id), 2500) - if (res.job_status === segmentImportStatus.error) - toast.error(`${t($ => $['list.batchModal.runError'], { ns: 'datasetDocuments' })}`) - }, - onError: (e) => { - const message = 'message' in e ? `: ${e.message}` : '' - toast.error(`${t($ => $['list.batchModal.runError'], { ns: 'datasetDocuments' })}${message}`) + await checkSegmentBatchImportProgress( + { jobID }, + { + onSuccess: (res) => { + setImportStatus(res.job_status) + if ( + res.job_status === segmentImportStatus.waiting || + res.job_status === segmentImportStatus.processing + ) + setTimeout(() => checkProcess(res.job_id), 2500) + if (res.job_status === segmentImportStatus.error) + toast.error(`${t(($) => $['list.batchModal.runError'], { ns: 'datasetDocuments' })}`) + }, + onError: (e) => { + const message = 'message' in e ? `: ${e.message}` : '' + toast.error( + `${t(($) => $['list.batchModal.runError'], { ns: 'datasetDocuments' })}${message}`, + ) + }, }, - }) + ) } const { mutateAsync: segmentBatchImport } = useSegmentBatchImport() const runBatch = async (csv: FileItem) => { - await segmentBatchImport({ - url: `/datasets/${datasetId}/documents/${documentId}/segments/batch_import`, - body: { upload_file_id: csv.file.id! }, - }, { - onSuccess: (res) => { - setImportStatus(res.job_status) - checkProcess(res.job_id) + await segmentBatchImport( + { + url: `/datasets/${datasetId}/documents/${documentId}/segments/batch_import`, + body: { upload_file_id: csv.file.id! }, }, - onError: (e) => { - const message = 'message' in e ? `: ${e.message}` : '' - toast.error(`${t($ => $['list.batchModal.runError'], { ns: 'datasetDocuments' })}${message}`) + { + onSuccess: (res) => { + setImportStatus(res.job_status) + checkProcess(res.job_id) + }, + onError: (e) => { + const message = 'message' in e ? `: ${e.message}` : '' + toast.error( + `${t(($) => $['list.batchModal.runError'], { ns: 'datasetDocuments' })}${message}`, + ) + }, }, - }) + ) } - const { data: documentDetail, error, refetch: detailMutate } = useDocumentDetail({ + const { + data: documentDetail, + error, + refetch: detailMutate, + } = useDocumentDetail({ datasetId, documentId, params: { metadata: 'without' }, refetchInterval: (query) => { const status = query.state.data?.display_status - if (!status || NON_TERMINAL_DISPLAY_STATUSES.has(status)) - return 2500 + if (!status || NON_TERMINAL_DISPLAY_STATUSES.has(status)) return 2500 return false }, }) @@ -132,70 +158,103 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => { const isDetailLoading = !documentDetail && !error - const embedding = NON_TERMINAL_DISPLAY_STATUSES.has(documentDetail?.display_status as DocumentDisplayStatus) + const embedding = NON_TERMINAL_DISPLAY_STATUSES.has( + documentDetail?.display_status as DocumentDisplayStatus, + ) const invalidChunkList = useInvalid(useSegmentListKey) const invalidChildChunkList = useInvalid(useChildSegmentListKey) const invalidDocumentList = useInvalidDocumentList(datasetId) - const handleOperate = useCallback((operateName?: string) => { - invalidDocumentList() - if (operateName === 'delete') { - backToPrev() - } - else { - detailMutate() - // If operation is not rename, refresh the chunk list after 5 seconds - if (operateName) { - setTimeout(() => { - invalidChunkList() - invalidChildChunkList() - }, 5000) + const handleOperate = useCallback( + (operateName?: string) => { + invalidDocumentList() + if (operateName === 'delete') { + backToPrev() + } else { + detailMutate() + // If operation is not rename, refresh the chunk list after 5 seconds + if (operateName) { + setTimeout(() => { + invalidChunkList() + invalidChildChunkList() + }, 5000) + } } - } - }, [invalidDocumentList, backToPrev, detailMutate, invalidChunkList, invalidChildChunkList]) + }, + [invalidDocumentList, backToPrev, detailMutate, invalidChunkList, invalidChildChunkList], + ) const parentMode = useMemo(() => { - return documentDetail?.document_process_rule?.rules?.parent_mode || documentDetail?.dataset_process_rule?.rules?.parent_mode || 'paragraph' - }, [documentDetail?.document_process_rule?.rules?.parent_mode, documentDetail?.dataset_process_rule?.rules?.parent_mode]) + return ( + documentDetail?.document_process_rule?.rules?.parent_mode || + documentDetail?.dataset_process_rule?.rules?.parent_mode || + 'paragraph' + ) + }, [ + documentDetail?.document_process_rule?.rules?.parent_mode, + documentDetail?.dataset_process_rule?.rules?.parent_mode, + ]) const isFullDocMode = useMemo(() => { const chunkMode = documentDetail?.doc_form return chunkMode === ChunkingMode.parentChild && parentMode === 'full-doc' }, [documentDetail?.doc_form, parentMode]) - const contextValue = useMemo(() => ({ - datasetId, - documentId, - docForm: documentDetail?.doc_form as ChunkingMode, - parentMode, - }), [datasetId, documentId, documentDetail?.doc_form, parentMode]) + const contextValue = useMemo( + () => ({ + datasetId, + documentId, + docForm: documentDetail?.doc_form as ChunkingMode, + parentMode, + }), + [datasetId, documentId, documentDetail?.doc_form, parentMode], + ) - const statusDetail = useMemo(() => ({ - enabled: documentDetail?.enabled || false, - archived: documentDetail?.archived || false, - id: documentId, - }), [documentDetail?.enabled, documentDetail?.archived, documentId]) + const statusDetail = useMemo( + () => ({ + enabled: documentDetail?.enabled || false, + archived: documentDetail?.archived || false, + id: documentId, + }), + [documentDetail?.enabled, documentDetail?.archived, documentId], + ) - const operationsDetail = useMemo(() => ({ - name: documentDetail?.name || '', - enabled: documentDetail?.enabled || false, - archived: documentDetail?.archived || false, - id: documentId, - data_source_type: documentDetail?.data_source_type || '', - doc_form: documentDetail?.doc_form || '', - }), [documentDetail?.name, documentDetail?.enabled, documentDetail?.archived, documentId, documentDetail?.data_source_type, documentDetail?.doc_form]) + const operationsDetail = useMemo( + () => ({ + name: documentDetail?.name || '', + enabled: documentDetail?.enabled || false, + archived: documentDetail?.archived || false, + id: documentId, + data_source_type: documentDetail?.data_source_type || '', + doc_form: documentDetail?.doc_form || '', + }), + [ + documentDetail?.name, + documentDetail?.enabled, + documentDetail?.archived, + documentId, + documentDetail?.data_source_type, + documentDetail?.doc_form, + ], + ) - const docDetail = useMemo(() => ({ - ...documentDetail, - ...documentMetadata, - doc_type: documentMetadata?.doc_type === 'others' ? '' : documentMetadata?.doc_type, - } as FullDocumentDetail), [documentDetail, documentMetadata]) + const docDetail = useMemo( + () => + ({ + ...documentDetail, + ...documentMetadata, + doc_type: documentMetadata?.doc_type === 'others' ? '' : documentMetadata?.doc_type, + }) as FullDocumentDetail, + [documentDetail, documentMetadata], + ) - const backButtonLabel = t($ => $['operation.back'], { ns: 'common' }) - const metadataToggleLabel = `${showMetadata - ? t($ => $['operation.close'], { ns: 'common' }) - : t($ => $['operation.view'], { ns: 'common' })} ${t($ => $['metadata.title'], { ns: 'datasetDocuments' })}` + const backButtonLabel = t(($) => $['operation.back'], { ns: 'common' }) + const metadataToggleLabel = `${ + showMetadata + ? t(($) => $['operation.close'], { ns: 'common' }) + : t(($) => $['operation.view'], { ns: 'common' }) + } ${t(($) => $['metadata.title'], { ns: 'datasetDocuments' })}` return ( <DocumentContext.Provider value={contextValue}> @@ -220,18 +279,22 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => { parentMode={parentMode} /> <div className="flex flex-wrap items-center"> - {embeddingAvailable && canEditDocument && documentDetail && !documentDetail.archived && !isFullDocMode && ( - <> - <SegmentAdd - importStatus={importStatus} - clearImportStatus={resetImportStatus} - showNewSegmentModal={showNewSegmentModal} - showBatchModal={showBatchModal} - embedding={embedding} - /> - <Divider type="vertical" className="mx-3! h-[14px]! bg-divider-regular!" /> - </> - )} + {embeddingAvailable && + canEditDocument && + documentDetail && + !documentDetail.archived && + !isFullDocMode && ( + <> + <SegmentAdd + importStatus={importStatus} + clearImportStatus={resetImportStatus} + showNewSegmentModal={showNewSegmentModal} + showBatchModal={showBatchModal} + embedding={embedding} + /> + <Divider type="vertical" className="mx-3! h-[14px]! bg-divider-regular!" /> + </> + )} {documentDetail && ( <StatusItem status={documentDetail.display_status || 'available'} @@ -265,39 +328,55 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => { className={style.layoutRightIcon} onClick={() => setShowMetadata(!showMetadata)} > - { - showMetadata - ? <span aria-hidden="true" className="i-ri-layout-left-2-line size-4 text-components-button-secondary-text" /> - : <span aria-hidden="true" className="i-ri-layout-right-2-line size-4 text-components-button-secondary-text" /> - } + {showMetadata ? ( + <span + aria-hidden="true" + className="i-ri-layout-left-2-line size-4 text-components-button-secondary-text" + /> + ) : ( + <span + aria-hidden="true" + className="i-ri-layout-right-2-line size-4 text-components-button-secondary-text" + /> + )} </button> </div> </div> <div className="flex flex-1 flex-row" style={{ height: 'calc(100% - 4rem)' }}> - {isDetailLoading - ? <Loading type="app" /> - : ( - <div className={cn('flex h-full min-w-0 grow flex-col', !embedding && isFullDocMode && 'relative px-11 pt-4', !embedding && !isFullDocMode && 'relative pt-3 pr-11 pl-5')}> - {embedding - ? ( - <Embedding - detailUpdate={detailMutate} - indexingType={dataset?.indexing_technique} - retrievalMethod={dataset?.retrieval_model_dict?.search_method} - /> - ) - : ( - <Completed - embeddingAvailable={embeddingAvailable} - showNewSegmentModal={newSegmentModalVisible} - onNewSegmentModalChange={setNewSegmentModalVisible} - importStatus={importStatus} - archived={documentDetail?.archived} - /> - )} - </div> + {isDetailLoading ? ( + <Loading type="app" /> + ) : ( + <div + className={cn( + 'flex h-full min-w-0 grow flex-col', + !embedding && isFullDocMode && 'relative px-11 pt-4', + !embedding && !isFullDocMode && 'relative pt-3 pr-11 pl-5', )} - <FloatRightContainer showClose isOpen={showMetadata} onClose={() => setShowMetadata(false)} isMobile={isMobile} panelClassName="justify-start!"> + > + {embedding ? ( + <Embedding + detailUpdate={detailMutate} + indexingType={dataset?.indexing_technique} + retrievalMethod={dataset?.retrieval_model_dict?.search_method} + /> + ) : ( + <Completed + embeddingAvailable={embeddingAvailable} + showNewSegmentModal={newSegmentModalVisible} + onNewSegmentModalChange={setNewSegmentModalVisible} + importStatus={importStatus} + archived={documentDetail?.archived} + /> + )} + </div> + )} + <FloatRightContainer + showClose + isOpen={showMetadata} + onClose={() => setShowMetadata(false)} + isMobile={isMobile} + panelClassName="justify-start!" + > <Metadata className="mt-3 mr-2" datasetId={datasetId} diff --git a/web/app/components/datasets/documents/detail/metadata/components/__tests__/field-info.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/__tests__/field-info.spec.tsx index 8a826ada3958ff..ca403a0632a01c 100644 --- a/web/app/components/datasets/documents/detail/metadata/components/__tests__/field-info.spec.tsx +++ b/web/app/components/datasets/documents/detail/metadata/components/__tests__/field-info.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import FieldInfo from '../field-info' vi.mock('@/utils', () => ({ @@ -80,7 +79,9 @@ describe('FieldInfo', () => { it('should call onUpdate when input value changes', () => { const onUpdate = vi.fn() - render(<FieldInfo label="Title" value="" showEdit={true} inputType="input" onUpdate={onUpdate} />) + render( + <FieldInfo label="Title" value="" showEdit={true} inputType="input" onUpdate={onUpdate} />, + ) fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New' } }) @@ -89,7 +90,15 @@ describe('FieldInfo', () => { it('should call onUpdate when textarea value changes', () => { const onUpdate = vi.fn() - render(<FieldInfo label="Desc" value="" showEdit={true} inputType="textarea" onUpdate={onUpdate} />) + render( + <FieldInfo + label="Desc" + value="" + showEdit={true} + inputType="textarea" + onUpdate={onUpdate} + />, + ) fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated' } }) diff --git a/web/app/components/datasets/documents/detail/metadata/components/field-info.tsx b/web/app/components/datasets/documents/detail/metadata/components/field-info.tsx index 41209814e049bd..bd0bfa1bfa2d93 100644 --- a/web/app/components/datasets/documents/detail/metadata/components/field-info.tsx +++ b/web/app/components/datasets/documents/detail/metadata/components/field-info.tsx @@ -2,7 +2,14 @@ import type { FC, ReactNode } from 'react' import type { inputType } from '@/hooks/use-metadata' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { useTranslation } from 'react-i18next' import AutoHeightTextarea from '@/app/components/base/auto-height-textarea' import Input from '@/app/components/base/input' @@ -17,7 +24,7 @@ type FieldInfoProps = { defaultValue?: string showEdit?: boolean inputType?: inputType - selectOptions?: Array<{ value: string, name: string }> + selectOptions?: Array<{ value: string; name: string }> onUpdate?: (v: string) => void } @@ -36,27 +43,26 @@ const FieldInfo: FC<FieldInfoProps> = ({ const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190 const editAlignTop = showEdit && inputType === 'textarea' const readAlignTop = !showEdit && textNeedWrap - const selectedOption = selectOptions.find(option => option.value === value) + const selectedOption = selectOptions.find((option) => option.value === value) const renderContent = () => { - if (!showEdit) - return displayedValue + if (!showEdit) return displayedValue if (inputType === 'select') { return ( <Select value={selectedOption?.value ?? null} onValueChange={(nextValue) => { - if (!nextValue) - return + if (!nextValue) return onUpdate?.(nextValue) }} > <SelectTrigger className={cn(s.select, s.selectWrapper)}> - {selectedOption?.name ?? `${t($ => $['metadata.placeholder.select'], { ns: 'datasetDocuments' })}${label}`} + {selectedOption?.name ?? + `${t(($) => $['metadata.placeholder.select'], { ns: 'datasetDocuments' })}${label}`} </SelectTrigger> <SelectContent> - {selectOptions.map(option => ( + {selectOptions.map((option) => ( <SelectItem key={option.value} value={option.value}> <SelectItemText>{option.name}</SelectItemText> <SelectItemIndicator /> @@ -70,27 +76,40 @@ const FieldInfo: FC<FieldInfoProps> = ({ if (inputType === 'textarea') { return ( <AutoHeightTextarea - onChange={e => onUpdate?.(e.target.value)} + onChange={(e) => onUpdate?.(e.target.value)} value={value} className={s.textArea} - placeholder={`${t($ => $['metadata.placeholder.add'], { ns: 'datasetDocuments' })}${label}`} + placeholder={`${t(($) => $['metadata.placeholder.add'], { ns: 'datasetDocuments' })}${label}`} /> ) } return ( <Input - onChange={e => onUpdate?.(e.target.value)} + onChange={(e) => onUpdate?.(e.target.value)} value={value} defaultValue={defaultValue} - placeholder={`${t($ => $['metadata.placeholder.add'], { ns: 'datasetDocuments' })}${label}`} + placeholder={`${t(($) => $['metadata.placeholder.add'], { ns: 'datasetDocuments' })}${label}`} /> ) } return ( - <div className={cn('flex min-h-5 items-center gap-1 py-0.5 text-xs', editAlignTop && 'items-start!', readAlignTop && 'items-start! pt-1')}> - <div className={cn('w-[200px] shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-text-tertiary', editAlignTop && 'pt-1')}>{label}</div> + <div + className={cn( + 'flex min-h-5 items-center gap-1 py-0.5 text-xs', + editAlignTop && 'items-start!', + readAlignTop && 'items-start! pt-1', + )} + > + <div + className={cn( + 'w-[200px] shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-text-tertiary', + editAlignTop && 'pt-1', + )} + > + {label} + </div> <div className="flex grow items-center gap-1 text-text-secondary"> {valueIcon} {renderContent()} diff --git a/web/app/components/datasets/documents/detail/metadata/style.module.css b/web/app/components/datasets/documents/detail/metadata/style.module.css index 77b7e81bc3f909..20d565b7273593 100644 --- a/web/app/components/datasets/documents/detail/metadata/style.module.css +++ b/web/app/components/datasets/documents/detail/metadata/style.module.css @@ -1,14 +1,14 @@ @reference "../../../../../styles/globals.css"; .main { - @apply w-full shrink-0 overflow-y-auto border-none p-0 sm:w-96 sm:px-6 sm:py-5 sm:border-l sm:border-l-gray-100 xl:w-[360px]; + @apply w-full shrink-0 overflow-y-auto border-none p-0 sm:w-96 sm:border-l sm:border-l-gray-100 sm:px-6 sm:py-5 xl:w-[360px]; } .operationWrapper { - @apply flex flex-col items-center gap-4 mt-7 mb-8; + @apply mt-7 mb-8 flex flex-col items-center gap-4; } .iconWrapper { - @apply box-border cursor-pointer h-8 w-8 inline-flex items-center justify-center; - @apply border-[#EAECF5] border rounded-lg hover:border-primary-200 hover:bg-primary-25 hover:shadow-md; + @apply box-border inline-flex h-8 w-8 cursor-pointer items-center justify-center; + @apply rounded-lg border border-[#EAECF5] hover:border-primary-200 hover:bg-primary-25 hover:shadow-md; } .icon { @apply h-4 w-4 stroke-current stroke-[2px] text-gray-700 group-hover:stroke-primary-600; @@ -17,7 +17,7 @@ @apply !border-[1.5px] !border-primary-400 !bg-primary-25 !shadow-xs; } .commonIcon { - @apply w-4 h-4 inline-block align-middle bg-gray-700 hover:bg-primary-600; + @apply inline-block h-4 w-4 bg-gray-700 align-middle hover:bg-primary-600; } .bookOpenIcon { mask-image: url(../../assets/bookOpen.svg); @@ -41,29 +41,29 @@ mask-image: url(../../assets/messageTextCircle.svg); } .radioGroup { - @apply !bg-transparent !gap-2; + @apply !gap-2 !bg-transparent; } .radio { - @apply !p-0 !mr-0 hover:bg-transparent !rounded-lg; + @apply !mr-0 !rounded-lg !p-0 hover:bg-transparent; } .title { - @apply text-sm text-gray-800 font-medium leading-6; + @apply text-sm leading-6 font-medium text-gray-800; } .titleWrapper { @apply flex items-center justify-between; } .desc { - @apply text-gray-500 text-xs; + @apply text-xs text-gray-500; } .changeTip { - @apply text-[#D92D20] text-xs text-center; + @apply text-center text-xs text-[#D92D20]; } .opBtnWrapper { @apply flex items-center justify-center gap-1; } .opBtn { - @apply !h-6 !w-14 !px-0 !text-xs !font-medium rounded-md; + @apply !h-6 !w-14 rounded-md !px-0 !text-xs !font-medium; } .opEditBtn { box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05); @@ -76,19 +76,19 @@ @apply !border-[0.5px] !border-primary-700 !font-medium hover:!border-none; } .opIcon { - @apply h-3 w-3 stroke-current stroke-2 mr-1; + @apply mr-1 h-3 w-3 stroke-current stroke-2; } .select { - @apply !h-7 !py-0 !pl-2 !text-xs !bg-gray-50 rounded-md !shadow-none hover:!bg-gray-100; + @apply !h-7 rounded-md !bg-gray-50 !py-0 !pl-2 !text-xs !shadow-none hover:!bg-gray-100; } .selectWrapper { - @apply !h-7 w-full + @apply !h-7 w-full; } .selectWrapper ul { - @apply text-xs + @apply text-xs; } .selectWrapper li { - @apply flex items-center h-8 + @apply flex h-8 items-center; } .documentTypeShow { @apply flex items-center text-xs text-gray-500; @@ -104,5 +104,5 @@ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); } .input { - @apply !bg-gray-50 hover:!bg-gray-100 focus-visible:!bg-white + @apply !bg-gray-50 hover:!bg-gray-100 focus-visible:!bg-white; } diff --git a/web/app/components/datasets/documents/detail/new-segment.tsx b/web/app/components/datasets/documents/detail/new-segment.tsx index b77b2b28aa837d..e03b61ad44143a 100644 --- a/web/app/components/datasets/documents/detail/new-segment.tsx +++ b/web/app/components/datasets/documents/detail/new-segment.tsx @@ -39,19 +39,23 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({ const [question, setQuestion] = useState('') const [answer, setAnswer] = useState('') const [attachments, setAttachments] = useState<FileEntity[]>([]) - const { datasetId, documentId } = useParams<{ datasetId: string, documentId: string }>() + const { datasetId, documentId } = useParams<{ datasetId: string; documentId: string }>() const [keywords, setKeywords] = useState<string[]>([]) const [loading, setLoading] = useState(false) const [addAnother, setAddAnother] = useState(true) - const fullScreen = useSegmentListContext(s => s.fullScreen) - const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen) - const indexingTechnique = useDatasetDetailContextWithSelector(s => s.dataset?.indexing_technique) + const fullScreen = useSegmentListContext((s) => s.fullScreen) + const toggleFullScreen = useSegmentListContext((s) => s.toggleFullScreen) + const indexingTechnique = useDatasetDetailContextWithSelector( + (s) => s.dataset?.indexing_technique, + ) const [imageUploaderKey, setImageUploaderKey] = useState(() => Date.now()) - const handleCancel = useCallback((actionType: 'esc' | 'add' = 'esc') => { - if (actionType === 'esc' || !addAnother) - onCancel() - }, [onCancel, addAnother]) + const handleCancel = useCallback( + (actionType: 'esc' | 'add' = 'esc') => { + if (actionType === 'esc' || !addAnother) onCancel() + }, + [onCancel, addAnother], + ) const onAttachmentsChange = useCallback((attachments: FileEntity[]) => { setAttachments(attachments) @@ -63,68 +67,90 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({ const params: SegmentUpdater = { content: '', attachment_ids: [] } if (docForm === ChunkingMode.qa) { if (!question.trim()) { - return toast.error(t($ => $['segment.questionEmpty'], { ns: 'datasetDocuments' })) + return toast.error(t(($) => $['segment.questionEmpty'], { ns: 'datasetDocuments' })) } if (!answer.trim()) { - return toast.error(t($ => $['segment.answerEmpty'], { ns: 'datasetDocuments' })) + return toast.error(t(($) => $['segment.answerEmpty'], { ns: 'datasetDocuments' })) } params.content = question params.answer = answer - } - else { + } else { if (!question.trim()) { - return toast.error(t($ => $['segment.contentEmpty'], { ns: 'datasetDocuments' })) + return toast.error(t(($) => $['segment.contentEmpty'], { ns: 'datasetDocuments' })) } params.content = question } - if (keywords?.length) - params.keywords = keywords + if (keywords?.length) params.keywords = keywords if (attachments.length) - params.attachment_ids = attachments.filter(item => Boolean(item.uploadedId)).map(item => item.uploadedId!) + params.attachment_ids = attachments + .filter((item) => Boolean(item.uploadedId)) + .map((item) => item.uploadedId!) setLoading(true) - await addSegment({ datasetId, documentId, body: params }, { - onSuccess() { - toast.success(t($ => $['segment.chunkAdded'], { ns: 'datasetDocuments' }), { - actionProps: { - children: t($ => $['operation.view'], { ns: 'common' }), - onClick: viewNewlyAddedChunk, - }, - }) - handleCancel('add') - setQuestion('') - setAnswer('') - setAttachments([]) - setImageUploaderKey(Date.now()) - setKeywords([]) - onSave() - }, - onSettled() { - setLoading(false) + await addSegment( + { datasetId, documentId, body: params }, + { + onSuccess() { + toast.success( + t(($) => $['segment.chunkAdded'], { ns: 'datasetDocuments' }), + { + actionProps: { + children: t(($) => $['operation.view'], { ns: 'common' }), + onClick: viewNewlyAddedChunk, + }, + }, + ) + handleCancel('add') + setQuestion('') + setAnswer('') + setAttachments([]) + setImageUploaderKey(Date.now()) + setKeywords([]) + onSave() + }, + onSettled() { + setLoading(false) + }, }, - }) - }, [docForm, keywords, addSegment, datasetId, documentId, question, answer, attachments, t, handleCancel, onSave, viewNewlyAddedChunk]) + ) + }, [ + docForm, + keywords, + addSegment, + datasetId, + documentId, + question, + answer, + attachments, + t, + handleCancel, + onSave, + viewNewlyAddedChunk, + ]) - const count = docForm === ChunkingMode.qa ? (question.length + answer.length) : question.length - const wordCountText = `${formatNumber(count)} ${t($ => $['segment.characters'], { ns: 'datasetDocuments', count })}` + const count = docForm === ChunkingMode.qa ? question.length + answer.length : question.length + const wordCountText = `${formatNumber(count)} ${t(($) => $['segment.characters'], { ns: 'datasetDocuments', count })}` const isECOIndexing = indexingTechnique === IndexingType.ECONOMICAL return ( <div className="flex h-full flex-col"> <div - className={cn('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pr-4 pl-6' : 'pt-3 pr-3 pl-4')} + className={cn( + 'flex items-center justify-between', + fullScreen ? 'border border-divider-subtle py-3 pr-4 pl-6' : 'pt-3 pr-3 pl-4', + )} > <div className="flex flex-col"> <div className="system-xl-semibold text-text-primary"> - {t($ => $['segment.addChunk'], { ns: 'datasetDocuments' })} + {t(($) => $['segment.addChunk'], { ns: 'datasetDocuments' })} </div> <div className="flex items-center gap-x-2"> - <SegmentIndexTag label={t($ => $['segment.newChunk'], { ns: 'datasetDocuments' })!} /> + <SegmentIndexTag label={t(($) => $['segment.newChunk'], { ns: 'datasetDocuments' })!} /> <Dot /> <span className="system-xs-medium text-text-tertiary">{wordCountText}</span> </div> @@ -144,7 +170,7 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({ )} <button type="button" - aria-label={t($ => $['operation.zoomIn'], { ns: 'common' })} + aria-label={t(($) => $['operation.zoomIn'], { ns: 'common' })} className="mr-1 flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" onClick={toggleFullScreen} > @@ -152,7 +178,7 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({ </button> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-1.5" onClick={handleCancel.bind(null, 'esc')} > @@ -160,14 +186,26 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({ </button> </div> </div> - <div className={cn('flex grow', fullScreen ? 'w-full flex-row justify-center gap-x-8 px-6 pt-6' : 'flex-col gap-y-1 px-4 py-3')}> - <div className={cn('overflow-hidden break-all whitespace-pre-line', fullScreen ? 'w-1/2' : 'grow')}> + <div + className={cn( + 'flex grow', + fullScreen + ? 'w-full flex-row justify-center gap-x-8 px-6 pt-6' + : 'flex-col gap-y-1 px-4 py-3', + )} + > + <div + className={cn( + 'overflow-hidden break-all whitespace-pre-line', + fullScreen ? 'w-1/2' : 'grow', + )} + > <ChunkContent docForm={docForm} question={question} answer={answer} - onQuestionChange={question => setQuestion(question)} - onAnswerChange={answer => setAnswer(answer)} + onQuestionChange={(question) => setQuestion(question)} + onAnswerChange={(answer) => setAnswer(answer)} isEditMode={true} /> </div> @@ -183,7 +221,7 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({ actionType="add" keywords={keywords} isEditMode={true} - onKeywordsChange={keywords => setKeywords(keywords)} + onKeywordsChange={(keywords) => setKeywords(keywords)} /> )} </div> diff --git a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx index 800e9d79d6e747..d7400da7a29e5b 100644 --- a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx @@ -3,7 +3,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Plan } from '@/app/components/billing/type' import { segmentImportStatus } from '@/types/dataset' - import { SegmentAdd } from '../index' // Mock provider context @@ -131,7 +130,9 @@ describe('SegmentAdd', () => { fireEvent.click(screen.getByRole('button', { name: /list\.action\.batchAdd/i })) - expect(await screen.findByRole('menuitem', { name: /list\.action\.batchAdd/i })).toBeInTheDocument() + expect( + await screen.findByRole('menuitem', { name: /list\.action\.batchAdd/i }), + ).toBeInTheDocument() }) it('should call showBatchModal when batch add is clicked', async () => { @@ -229,14 +230,18 @@ describe('SegmentAdd', () => { // Progress bar width tests describe('Progress Bar', () => { it('should show 3/12 width progress bar for WAITING status', () => { - const { container } = render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />) + const { container } = render( + <SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />, + ) const progressBar = container.querySelector('.w-3\\/12') expect(progressBar).toBeInTheDocument() }) it('should show 2/3 width progress bar for PROCESSING status', () => { - const { container } = render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />) + const { container } = render( + <SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />, + ) const progressBar = container.querySelector('.w-2\\/3') expect(progressBar).toBeInTheDocument() diff --git a/web/app/components/datasets/documents/detail/segment-add/index.tsx b/web/app/components/datasets/documents/detail/segment-add/index.tsx index eb196eccc28998..dcb319fdb347d6 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/index.tsx @@ -50,21 +50,31 @@ export function SegmentAdd({ if (importStatus) { return ( <> - {(importStatus === segmentImportStatus.waiting || importStatus === segmentImportStatus.processing) && ( - <div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-progress-bar-border - bg-components-progress-bar-border px-2.5 py-2 text-components-button-secondary-accent-text - shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]" - > - <div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === segmentImportStatus.waiting ? 'w-3/12' : 'w-2/3')} /> + {(importStatus === segmentImportStatus.waiting || + importStatus === segmentImportStatus.processing) && ( + <div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-progress-bar-border bg-components-progress-bar-border px-2.5 py-2 text-components-button-secondary-accent-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"> + <div + className={cn( + 'absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', + importStatus === segmentImportStatus.waiting ? 'w-3/12' : 'w-2/3', + )} + /> <span aria-hidden className="mr-1 i-ri-loader-2-line size-4 animate-spin" /> - <span className="z-10 pr-0.5 system-sm-medium">{t($ => $['list.batchModal.processing'], { ns: 'datasetDocuments' })}</span> + <span className="z-10 pr-0.5 system-sm-medium"> + {t(($) => $['list.batchModal.processing'], { ns: 'datasetDocuments' })} + </span> </div> )} {importStatus === segmentImportStatus.completed && ( <div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"> <div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-success"> - <span aria-hidden className="mr-1 i-custom-vender-solid-general-check-circle size-4" /> - <span className="pr-0.5 system-sm-medium">{t($ => $['list.batchModal.completed'], { ns: 'datasetDocuments' })}</span> + <span + aria-hidden + className="mr-1 i-custom-vender-solid-general-check-circle size-4" + /> + <span className="pr-0.5 system-sm-medium"> + {t(($) => $['list.batchModal.completed'], { ns: 'datasetDocuments' })} + </span> </div> <div className="m-1 inline-flex items-center"> <button @@ -72,7 +82,7 @@ export function SegmentAdd({ className="cursor-pointer rounded-md border-none bg-transparent px-1.5 py-1 text-left system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={clearImportStatus} > - {t($ => $['list.batchModal.ok'], { ns: 'datasetDocuments' })} + {t(($) => $['list.batchModal.ok'], { ns: 'datasetDocuments' })} </button> </div> <div className="absolute top-0 left-0 -z-10 size-full bg-dataset-chunk-process-success-bg opacity-40" /> @@ -82,7 +92,9 @@ export function SegmentAdd({ <div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"> <div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-destructive"> <span aria-hidden className="mr-1 i-ri-error-warning-fill size-4" /> - <span className="pr-0.5 system-sm-medium">{t($ => $['list.batchModal.error'], { ns: 'datasetDocuments' })}</span> + <span className="pr-0.5 system-sm-medium"> + {t(($) => $['list.batchModal.error'], { ns: 'datasetDocuments' })} + </span> </div> <div className="m-1 inline-flex items-center"> <button @@ -90,7 +102,7 @@ export function SegmentAdd({ className="cursor-pointer rounded-md border-none bg-transparent px-1.5 py-1 text-left system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={clearImportStatus} > - {t($ => $['list.batchModal.ok'], { ns: 'datasetDocuments' })} + {t(($) => $['list.batchModal.ok'], { ns: 'datasetDocuments' })} </button> </div> <div className="absolute top-0 left-0 -z-10 size-full bg-dataset-chunk-process-error-bg opacity-40" /> @@ -104,42 +116,42 @@ export function SegmentAdd({ <div className={cn( 'relative z-20 flex items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]', - embedding && 'border-components-button-secondary-border-disabled bg-components-button-secondary-bg-disabled', + embedding && + 'border-components-button-secondary-border-disabled bg-components-button-secondary-bg-disabled', )} > <button type="button" - className={`inline-flex items-center rounded-l-lg border-0 border-r border-r-divider-subtle bg-transparent px-2.5 py-2 text-left - hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`} + className={`inline-flex items-center rounded-l-lg border-0 border-r border-r-divider-subtle bg-transparent px-2.5 py-2 text-left hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`} onClick={() => openSegmentDialog(showNewSegmentModal)} disabled={embedding} > <span aria-hidden className={cn('i-ri-add-line size-4', textColor)} /> - <span className={cn('ml-0.5 px-0.5 text-[13px] leading-[16px] font-medium capitalize', textColor)}> - {t($ => $['list.action.addButton'], { ns: 'datasetDocuments' })} + <span + className={cn( + 'ml-0.5 px-0.5 text-[13px] leading-[16px] font-medium capitalize', + textColor, + )} + > + {t(($) => $['list.action.addButton'], { ns: 'datasetDocuments' })} </span> </button> <DropdownMenu> <DropdownMenuTrigger - aria-label={t($ => $['list.action.batchAdd'], { ns: 'datasetDocuments' })} + aria-label={t(($) => $['list.action.batchAdd'], { ns: 'datasetDocuments' })} disabled={embedding} className={cn( - `rounded-l-none rounded-r-lg border-0 bg-transparent p-2 backdrop-blur-[5px] - hover:bg-state-base-hover disabled:cursor-not-allowed disabled:bg-transparent disabled:hover:bg-transparent data-popup-open:bg-state-base-hover`, + `rounded-l-none rounded-r-lg border-0 bg-transparent p-2 backdrop-blur-[5px] hover:bg-state-base-hover disabled:cursor-not-allowed disabled:bg-transparent disabled:hover:bg-transparent data-popup-open:bg-state-base-hover`, )} > <span aria-hidden className={cn('i-ri-arrow-down-s-line size-4', textColor)} /> </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="min-w-[120px]" - > + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[120px]"> <DropdownMenuItem className="system-md-regular" onClick={() => openSegmentDialog(showBatchModal)} > - {t($ => $['list.action.batchAdd'], { ns: 'datasetDocuments' })} + {t(($) => $['list.action.batchAdd'], { ns: 'datasetDocuments' })} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> @@ -147,11 +159,10 @@ export function SegmentAdd({ <PlanUpgradeModal show onClose={() => setIsPlanUpgradeModalOpen(false)} - title={t($ => $['upgrade.addChunks.title'], { ns: 'billing' })!} - description={t($ => $['upgrade.addChunks.description'], { ns: 'billing' })!} + title={t(($) => $['upgrade.addChunks.title'], { ns: 'billing' })!} + description={t(($) => $['upgrade.addChunks.description'], { ns: 'billing' })!} /> )} </div> - ) } diff --git a/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx b/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx index ac687d82800c10..e675c50c54566f 100644 --- a/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import DocumentSettings from '../document-settings' const mockPush = vi.fn() @@ -15,7 +14,7 @@ vi.mock('@/next/navigation', () => ({ // Mock use-context-selector vi.mock('use-context-selector', async (importOriginal) => { - const actual = await importOriginal() as Record<string, unknown> + const actual = (await importOriginal()) as Record<string, unknown> return { ...actual, useContext: () => ({ @@ -53,7 +52,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/base/app-unavailable', () => ({ - default: ({ code, unknownReason }: { code?: number, unknownReason?: string }) => ( + default: ({ code, unknownReason }: { code?: number; unknownReason?: string }) => ( <div data-testid="app-unavailable"> <span data-testid="error-code">{code}</span> <span data-testid="error-reason">{unknownReason}</span> @@ -63,7 +62,9 @@ vi.mock('@/app/components/base/app-unavailable', () => ({ vi.mock('@/app/components/base/loading', () => ({ default: ({ type }: { type?: string }) => ( - <div data-testid="loading" data-type={type}>Loading...</div> + <div data-testid="loading" data-type={type}> + Loading... + </div> ), })) @@ -93,9 +94,15 @@ vi.mock('@/app/components/datasets/create/step-two', () => ({ <span data-testid="data-source-type">{dataSourceType}</span> <span data-testid="is-setting">{isSetting ? 'true' : 'false'}</span> <span data-testid="files-count">{files?.length || 0}</span> - <button onClick={onSetting} data-testid="setting-btn">Setting</button> - <button onClick={onSave} data-testid="save-btn">Save</button> - <button onClick={onCancel} data-testid="cancel-btn">Cancel</button> + <button onClick={onSetting} data-testid="setting-btn"> + Setting + </button> + <button onClick={onSave} data-testid="save-btn"> + Save + </button> + <button onClick={onCancel} data-testid="cancel-btn"> + Cancel + </button> </div> ), })) @@ -320,9 +327,7 @@ describe('DocumentSettings', () => { }) it('should maintain structure when rerendered', () => { - const { rerender } = render( - <DocumentSettings datasetId="dataset-1" documentId="doc-1" />, - ) + const { rerender } = render(<DocumentSettings datasetId="dataset-1" documentId="doc-1" />) rerender(<DocumentSettings datasetId="dataset-2" documentId="doc-2" />) diff --git a/web/app/components/datasets/documents/detail/settings/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/__tests__/index.spec.tsx index e7cd851724880e..969b25103a41c2 100644 --- a/web/app/components/datasets/documents/detail/settings/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/__tests__/index.spec.tsx @@ -1,40 +1,29 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import Settings from '../index' // Mock the dataset detail context let mockRuntimeMode: string | undefined = 'general' vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: { runtime_mode: string | undefined } }) => unknown) => { + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: { runtime_mode: string | undefined } }) => unknown, + ) => { return selector({ dataset: { runtime_mode: mockRuntimeMode } }) }, })) vi.mock('../document-settings', () => ({ - default: ({ datasetId, documentId }: { datasetId: string, documentId: string }) => ( + default: ({ datasetId, documentId }: { datasetId: string; documentId: string }) => ( <div data-testid="document-settings"> - DocumentSettings - - {' '} - {datasetId} - {' '} - - - {' '} - {documentId} + DocumentSettings - {datasetId} - {documentId} </div> ), })) vi.mock('../pipeline-settings', () => ({ - default: ({ datasetId, documentId }: { datasetId: string, documentId: string }) => ( + default: ({ datasetId, documentId }: { datasetId: string; documentId: string }) => ( <div data-testid="pipeline-settings"> - PipelineSettings - - {' '} - {datasetId} - {' '} - - - {' '} - {documentId} + PipelineSettings - {datasetId} - {documentId} </div> ), })) @@ -47,9 +36,7 @@ describe('Settings', () => { describe('Rendering', () => { it('should render without crashing', () => { - const { container } = render( - <Settings datasetId="dataset-1" documentId="doc-1" />, - ) + const { container } = render(<Settings datasetId="dataset-1" documentId="doc-1" />) expect(container.firstChild).toBeInTheDocument() }) @@ -109,9 +96,7 @@ describe('Settings', () => { it('should maintain structure when rerendered', () => { mockRuntimeMode = 'general' - const { rerender } = render( - <Settings datasetId="dataset-1" documentId="doc-1" />, - ) + const { rerender } = render(<Settings datasetId="dataset-1" documentId="doc-1" />) rerender(<Settings datasetId="dataset-2" documentId="doc-2" />) diff --git a/web/app/components/datasets/documents/detail/settings/document-settings.tsx b/web/app/components/datasets/documents/detail/settings/document-settings.tsx index 09e0bef63e8572..d10d8d2c5317d4 100644 --- a/web/app/components/datasets/documents/detail/settings/document-settings.tsx +++ b/web/app/components/datasets/documents/detail/settings/document-settings.tsx @@ -22,7 +22,11 @@ import { useDefaultModel } from '@/app/components/header/account-setting/model-p import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting' import DatasetDetailContext from '@/context/dataset-detail' import { useRouter } from '@/next/navigation' -import { useDocumentDetail, useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document' +import { + useDocumentDetail, + useInvalidDocumentDetail, + useInvalidDocumentList, +} from '@/service/knowledge/use-document' type DocumentSettingsProps = { datasetId: string @@ -58,7 +62,9 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { const dataSourceInfo = documentDetail?.data_source_info // Type guards for DataSourceInfo union - const isLegacyDataSourceInfo = (info: DataSourceInfo | undefined): info is LegacyDataSourceInfo => { + const isLegacyDataSourceInfo = ( + info: DataSourceInfo | undefined, + ): info is LegacyDataSourceInfo => { return !!info && 'upload_file' in info } const isWebsiteCrawlInfo = (info: DataSourceInfo | undefined): info is WebsiteCrawlInfo => { @@ -105,10 +111,12 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { const files = useMemo<CustomFile[]>(() => { // Handle upload_file_id format if (uploadFileIdInfo) { - return [{ - id: uploadFileIdInfo.upload_file_id, - name: documentDetail?.name || '', - } as unknown as CustomFile] + return [ + { + id: uploadFileIdInfo.upload_file_id, + name: documentDetail?.name || '', + } as unknown as CustomFile, + ] } // Handle legacy upload_file format @@ -119,36 +127,50 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { // Handle local file info format if (localFileInfo) { const { related_id, name, extension } = localFileInfo - return [{ - id: related_id, - name, - extension, - } as unknown as CustomFile] + return [ + { + id: related_id, + name, + extension, + } as unknown as CustomFile, + ] } return [] }, [uploadFileIdInfo, legacyInfo?.upload_file, localFileInfo, documentDetail?.name]) const websitePages = useMemo(() => { - if (!websiteInfo) - return [] - return [{ - title: websiteInfo.title, - source_url: websiteInfo.source_url, - markdown: websiteInfo.content, - description: websiteInfo.description, - }] + if (!websiteInfo) return [] + return [ + { + title: websiteInfo.title, + source_url: websiteInfo.source_url, + markdown: websiteInfo.content, + description: websiteInfo.description, + }, + ] }, [websiteInfo]) - const crawlOptions = (dataSourceInfo && typeof dataSourceInfo === 'object' && 'includes' in dataSourceInfo && 'excludes' in dataSourceInfo) - ? dataSourceInfo as unknown as CrawlOptions - : undefined - - const websiteCrawlProvider = (websiteInfo?.provider ?? legacyInfo?.provider) as DataSourceProvider | undefined + const crawlOptions = + dataSourceInfo && + typeof dataSourceInfo === 'object' && + 'includes' in dataSourceInfo && + 'excludes' in dataSourceInfo + ? (dataSourceInfo as unknown as CrawlOptions) + : undefined + + const websiteCrawlProvider = (websiteInfo?.provider ?? legacyInfo?.provider) as + | DataSourceProvider + | undefined const websiteCrawlJobId = websiteInfo?.job_id ?? legacyInfo?.job_id if (error) - return <AppUnavailable code={500} unknownReason={t($ => $['error.unavailable'], { ns: 'datasetCreation' }) as string} /> + return ( + <AppUnavailable + code={500} + unknownReason={t(($) => $['error.unavailable'], { ns: 'datasetCreation' }) as string} + /> + ) return ( <div className="flex" style={{ height: 'calc(100vh - 56px)' }}> @@ -161,7 +183,9 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { datasetId={datasetId} dataSourceType={documentDetail.data_source_type as DataSourceType} notionPages={currentPage ? [currentPage as unknown as NotionPage] : []} - notionCredentialId={legacyInfo?.credential_id || onlineDocumentInfo?.credential_id || ''} + notionCredentialId={ + legacyInfo?.credential_id || onlineDocumentInfo?.credential_id || '' + } websitePages={websitePages} websiteCrawlProvider={websiteCrawlProvider} websiteCrawlJobId={websiteCrawlJobId || ''} diff --git a/web/app/components/datasets/documents/detail/settings/index.tsx b/web/app/components/datasets/documents/detail/settings/index.tsx index 45b885fb066913..6e87c9b07e3005 100644 --- a/web/app/components/datasets/documents/detail/settings/index.tsx +++ b/web/app/components/datasets/documents/detail/settings/index.tsx @@ -9,28 +9,15 @@ type SettingsProps = { documentId: string } -const Settings = ({ - datasetId, - documentId, -}: SettingsProps) => { - const runtimeMode = useDatasetDetailContextWithSelector(s => s.dataset?.runtime_mode) +const Settings = ({ datasetId, documentId }: SettingsProps) => { + const runtimeMode = useDatasetDetailContextWithSelector((s) => s.dataset?.runtime_mode) const isGeneralDataset = runtimeMode === 'general' if (isGeneralDataset) { - return ( - <DocumentSettings - datasetId={datasetId} - documentId={documentId} - /> - ) + return <DocumentSettings datasetId={datasetId} documentId={documentId} /> } - return ( - <PipelineSettings - datasetId={datasetId} - documentId={documentId} - /> - ) + return <PipelineSettings datasetId={datasetId} documentId={documentId} /> } export default Settings diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx index 764667c55cb13b..c4a213c1e479a5 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx @@ -17,8 +17,9 @@ vi.mock('@/next/navigation', () => ({ // Mock dataset detail context const mockPipelineId = 'pipeline-123' vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string, doc_form: string } }) => unknown) => - selector({ dataset: { pipeline_id: mockPipelineId, doc_form: 'text_model' } }), + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: { pipeline_id: string; doc_form: string } }) => unknown, + ) => selector({ dataset: { pipeline_id: mockPipelineId, doc_form: 'text_model' } }), })) // Mock API hooks for PipelineSettings @@ -26,7 +27,8 @@ const mockUsePipelineExecutionLog = vi.fn() const mockMutateAsync = vi.fn() const mockUseRunPublishedPipeline = vi.fn() vi.mock('@/service/use-pipeline', () => ({ - usePipelineExecutionLog: (params: { dataset_id: string, document_id: string }) => mockUsePipelineExecutionLog(params), + usePipelineExecutionLog: (params: { dataset_id: string; document_id: string }) => + mockUsePipelineExecutionLog(params), useRunPublishedPipeline: () => mockUseRunPublishedPipeline(), // For ProcessDocuments component usePublishedPipelineProcessingParams: () => ({ @@ -55,14 +57,14 @@ vi.mock('../../../../create-from-pipeline/process-documents/form', () => ({ }: { ref: React.RefObject<{ submit: () => void }> initialData: Record<string, unknown> - configurations: Array<{ variable: string, label: string, type: string }> + configurations: Array<{ variable: string; label: string; type: string }> schema: unknown onSubmit: (data: Record<string, unknown>) => void onPreview: () => void isRunning: boolean }) { if (ref && typeof ref === 'object' && 'current' in ref) { - (ref as React.MutableRefObject<{ submit: () => void }>).current = { + ;(ref as React.MutableRefObject<{ submit: () => void }>).current = { submit: () => onSubmit(initialData), } } @@ -133,11 +135,7 @@ const createQueryClient = () => const renderWithProviders = (ui: React.ReactElement) => { const queryClient = createQueryClient() - return render( - <QueryClientProvider client={queryClient}> - {ui} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>) } // Factory functions for test data @@ -194,7 +192,9 @@ describe('PipelineSettings', () => { // Assert - Real LeftHeader should render with correct content expect(screen.getByText('datasetPipeline.documentSettings.title')).toBeInTheDocument() - expect(screen.getByText('datasetPipeline.addDocuments.steps.processDocuments')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.addDocuments.steps.processDocuments'), + ).toBeInTheDocument() // Real ProcessDocuments should render expect(screen.getByTestId('process-form')).toBeInTheDocument() // ChunkPreview should render @@ -380,7 +380,9 @@ describe('PipelineSettings', () => { renderWithProviders(<PipelineSettings {...props} />) // Find the "Save and Process" button (from real ProcessDocuments > Actions) - const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }) + const processButton = screen.getByRole('button', { + name: 'datasetPipeline.operations.saveAndProcess', + }) fireEvent.click(processButton) await waitFor(() => { @@ -393,7 +395,9 @@ describe('PipelineSettings', () => { const props = createDefaultProps() renderWithProviders(<PipelineSettings {...props} />) - fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) + fireEvent.click( + screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }), + ) await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( @@ -415,7 +419,9 @@ describe('PipelineSettings', () => { const props = createDefaultProps() renderWithProviders(<PipelineSettings {...props} />) - fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) + fireEvent.click( + screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }), + ) await waitFor(() => { expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents') @@ -430,7 +436,9 @@ describe('PipelineSettings', () => { const props = createDefaultProps() renderWithProviders(<PipelineSettings {...props} />) - fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) + fireEvent.click( + screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }), + ) await waitFor(() => { expect(mockInvalidDocumentList).toHaveBeenCalled() @@ -536,7 +544,12 @@ describe('PipelineSettings', () => { ])('should handle %s datasource type correctly', (datasourceType, testId, expectedCount) => { const datasourceInfoMap: Record<DatasourceType, Record<string, unknown>> = { [DatasourceType.localFile]: { related_id: 'f1', name: 'file.pdf', extension: 'pdf' }, - [DatasourceType.websiteCrawl]: { content: 'c', description: 'd', source_url: 'u', title: 't' }, + [DatasourceType.websiteCrawl]: { + content: 'c', + description: 'd', + source_url: 'u', + title: 't', + }, [DatasourceType.onlineDocument]: { workspace_id: 'w1', page: { page_id: 'p1' } }, [DatasourceType.onlineDrive]: { id: 'd1', type: 'doc', name: 'n', size: 100 }, } @@ -615,7 +628,9 @@ describe('PipelineSettings', () => { const props = createDefaultProps() renderWithProviders(<PipelineSettings {...props} />) - fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) + fireEvent.click( + screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }), + ) await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( @@ -690,7 +705,9 @@ describe('PipelineSettings', () => { const props = createDefaultProps() renderWithProviders(<PipelineSettings {...props} />) - fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) + fireEvent.click( + screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }), + ) // Assert - isPending && isPreview.current should be false (true && false = false) await waitFor(() => { diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx index 30019ca67d901b..c6ebea22020a59 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import LeftHeader from '../left-header' const mockBack = vi.fn() diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx index 5941e71d391f68..c0b10a9742a716 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx @@ -20,19 +20,22 @@ type PipelineSettingsProps = { documentId: string } -const PipelineSettings = ({ - datasetId, - documentId, -}: PipelineSettingsProps) => { +const PipelineSettings = ({ datasetId, documentId }: PipelineSettingsProps) => { const { t } = useTranslation() const { push } = useRouter() - const [estimateData, setEstimateData] = useState<FileIndexingEstimateResponse | undefined>(undefined) - const pipelineId = useDatasetDetailContextWithSelector(state => state.dataset?.pipeline_id) + const [estimateData, setEstimateData] = useState<FileIndexingEstimateResponse | undefined>( + undefined, + ) + const pipelineId = useDatasetDetailContextWithSelector((state) => state.dataset?.pipeline_id) const isPreview = useRef(false) const formRef = useRef<any>(null) - const { data: lastRunData, isFetching: isFetchingLastRunData, isError } = usePipelineExecutionLog({ + const { + data: lastRunData, + isFetching: isFetchingLastRunData, + isError, + } = usePipelineExecutionLog({ dataset_id: datasetId, document_id: documentId, }) @@ -92,50 +95,69 @@ const PipelineSettings = ({ const { mutateAsync: runPublishedPipeline, isIdle, isPending } = useRunPublishedPipeline() - const handlePreviewChunks = useCallback(async (data: Record<string, any>) => { - if (!lastRunData) - return - const datasourceInfoList: Record<string, any>[] = [] - const documentInfo = lastRunData.datasource_info - datasourceInfoList.push(documentInfo) - await runPublishedPipeline({ - pipeline_id: pipelineId!, - inputs: data, - start_node_id: lastRunData.datasource_node_id, - datasource_type: lastRunData.datasource_type, - datasource_info_list: datasourceInfoList, - is_preview: true, - }, { - onSuccess: (res) => { - setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs) - }, - }) - }, [lastRunData, pipelineId, runPublishedPipeline]) + const handlePreviewChunks = useCallback( + async (data: Record<string, any>) => { + if (!lastRunData) return + const datasourceInfoList: Record<string, any>[] = [] + const documentInfo = lastRunData.datasource_info + datasourceInfoList.push(documentInfo) + await runPublishedPipeline( + { + pipeline_id: pipelineId!, + inputs: data, + start_node_id: lastRunData.datasource_node_id, + datasource_type: lastRunData.datasource_type, + datasource_info_list: datasourceInfoList, + is_preview: true, + }, + { + onSuccess: (res) => { + setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs) + }, + }, + ) + }, + [lastRunData, pipelineId, runPublishedPipeline], + ) const invalidDocumentList = useInvalidDocumentList(datasetId) const invalidDocumentDetail = useInvalidDocumentDetail() - const handleProcess = useCallback(async (data: Record<string, any>) => { - if (!lastRunData) - return - const datasourceInfoList: Record<string, any>[] = [] - const documentInfo = lastRunData.datasource_info - datasourceInfoList.push(documentInfo) - await runPublishedPipeline({ - pipeline_id: pipelineId!, - inputs: data, - start_node_id: lastRunData.datasource_node_id, - datasource_type: lastRunData.datasource_type, - datasource_info_list: datasourceInfoList, - original_document_id: documentId, - is_preview: false, - }, { - onSuccess: () => { - invalidDocumentList() - invalidDocumentDetail() - push(`/datasets/${datasetId}/documents`) - }, - }) - }, [datasetId, documentId, invalidDocumentDetail, invalidDocumentList, lastRunData, pipelineId, push, runPublishedPipeline]) + const handleProcess = useCallback( + async (data: Record<string, any>) => { + if (!lastRunData) return + const datasourceInfoList: Record<string, any>[] = [] + const documentInfo = lastRunData.datasource_info + datasourceInfoList.push(documentInfo) + await runPublishedPipeline( + { + pipeline_id: pipelineId!, + inputs: data, + start_node_id: lastRunData.datasource_node_id, + datasource_type: lastRunData.datasource_type, + datasource_info_list: datasourceInfoList, + original_document_id: documentId, + is_preview: false, + }, + { + onSuccess: () => { + invalidDocumentList() + invalidDocumentDetail() + push(`/datasets/${datasetId}/documents`) + }, + }, + ) + }, + [ + datasetId, + documentId, + invalidDocumentDetail, + invalidDocumentList, + lastRunData, + pipelineId, + push, + runPublishedPipeline, + ], + ) const onClickProcess = useCallback(() => { isPreview.current = false @@ -147,29 +169,31 @@ const PipelineSettings = ({ formRef.current?.submit() }, []) - const handleSubmit = useCallback((data: Record<string, any>) => { - if (isPreview.current) - handlePreviewChunks(data) - else - handleProcess(data) - }, [handlePreviewChunks, handleProcess]) + const handleSubmit = useCallback( + (data: Record<string, any>) => { + if (isPreview.current) handlePreviewChunks(data) + else handleProcess(data) + }, + [handlePreviewChunks, handleProcess], + ) if (isFetchingLastRunData) { - return ( - <Loading type="app" /> - ) + return <Loading type="app" /> } if (isError) - return <AppUnavailable code={500} unknownReason={t($ => $['error.unavailable'], { ns: 'datasetCreation' }) as string} /> + return ( + <AppUnavailable + code={500} + unknownReason={t(($) => $['error.unavailable'], { ns: 'datasetCreation' }) as string} + /> + ) return ( - <div - className="relative flex h-[calc(100vh-56px)] min-w-[1024px] overflow-x-auto rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle" - > + <div className="relative flex h-[calc(100vh-56px)] min-w-[1024px] overflow-x-auto rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle"> <div className="h-full min-w-0 flex-1"> <div className="flex h-full flex-col px-14"> - <LeftHeader title={t($ => $['documentSettings.title'], { ns: 'datasetPipeline' })} /> + <LeftHeader title={t(($) => $['documentSettings.title'], { ns: 'datasetPipeline' })} /> <div className="grow overflow-y-auto"> <ProcessDocuments ref={formRef} diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx index cc8e226ffa11c6..00ecf9ed8a4f54 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx @@ -10,9 +10,7 @@ type LeftHeaderProps = { title: string } -const LeftHeader = ({ - title, -}: LeftHeaderProps) => { +const LeftHeader = ({ title }: LeftHeaderProps) => { const { t } = useTranslation() const { back } = useRouter() @@ -26,13 +24,13 @@ const LeftHeader = ({ {title} </div> <div className="system-md-semibold text-text-primary"> - {t($ => $['addDocuments.steps.processDocuments'], { ns: 'datasetPipeline' })} + {t(($) => $['addDocuments.steps.processDocuments'], { ns: 'datasetPipeline' })} </div> <Button variant="secondary-accent" className="absolute top-3.5 -left-11 size-9 rounded-full p-0" onClick={navigateBack} - aria-label={t($ => $['operation.back'], { ns: 'common' })} + aria-label={t(($) => $['operation.back'], { ns: 'common' })} > <RiArrowLeftLine className="size-5" /> </Button> diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts index 227dc63a8cb480..5892ff53cacd5b 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts @@ -5,8 +5,9 @@ import { useInputVariables } from '../hooks' let mockPipelineId: string | undefined vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id?: string } | null }) => unknown) => - selector({ dataset: mockPipelineId ? { pipeline_id: mockPipelineId } : null }), + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: { pipeline_id?: string } | null }) => unknown, + ) => selector({ dataset: mockPipelineId ? { pipeline_id: mockPipelineId } : null }), })) let mockParamsReturn: { @@ -15,11 +16,11 @@ let mockParamsReturn: { } const mockUsePublishedPipelineProcessingParams = vi.fn( - (_params: { pipeline_id: string, node_id: string }) => mockParamsReturn, + (_params: { pipeline_id: string; node_id: string }) => mockParamsReturn, ) vi.mock('@/service/use-pipeline', () => ({ - usePublishedPipelineProcessingParams: (params: { pipeline_id: string, node_id: string }) => + usePublishedPipelineProcessingParams: (params: { pipeline_id: string; node_id: string }) => mockUsePublishedPipelineProcessingParams(params), })) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx index a38672c3dc4861..e6b3f4ac6497bd 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx @@ -7,8 +7,9 @@ import ProcessDocuments from '../index' // Mock dataset detail context - required for useInputVariables hook const mockPipelineId = 'pipeline-123' vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string } }) => string) => - selector({ dataset: { pipeline_id: mockPipelineId } }), + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: { pipeline_id: string } }) => string, + ) => selector({ dataset: { pipeline_id: mockPipelineId } }), })) // Mock API call for pipeline processing params @@ -33,7 +34,7 @@ vi.mock('../../../../../create-from-pipeline/process-documents/form', () => ({ }: { ref: React.RefObject<{ submit: () => void }> initialData: Record<string, unknown> - configurations: Array<{ variable: string, label: string, type: string }> + configurations: Array<{ variable: string; label: string; type: string }> schema: unknown onSubmit: (data: Record<string, unknown>) => void onPreview: () => void @@ -41,7 +42,7 @@ vi.mock('../../../../../create-from-pipeline/process-documents/form', () => ({ }) { // Expose submit method via ref for parent component control if (ref && typeof ref === 'object' && 'current' in ref) { - (ref as React.MutableRefObject<{ submit: () => void }>).current = { + ;(ref as React.MutableRefObject<{ submit: () => void }>).current = { submit: () => onSubmit(initialData), } } @@ -82,11 +83,7 @@ const createQueryClient = () => const renderWithProviders = (ui: React.ReactElement) => { const queryClient = createQueryClient() - return render( - <QueryClientProvider client={queryClient}> - {ui} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>) } // Factory function for creating mock variables - matches RAGPipelineVariable type @@ -100,15 +97,17 @@ const createMockVariable = (overrides: Partial<RAGPipelineVariable> = {}): RAGPi }) // Default props factory -const createDefaultProps = (overrides: Partial<{ - datasourceNodeId: string - lastRunInputData: Record<string, unknown> - isRunning: boolean - ref: React.RefObject<{ submit: () => void } | null> - onProcess: () => void - onPreview: () => void - onSubmit: (data: Record<string, unknown>) => void -}> = {}) => ({ +const createDefaultProps = ( + overrides: Partial<{ + datasourceNodeId: string + lastRunInputData: Record<string, unknown> + isRunning: boolean + ref: React.RefObject<{ submit: () => void } | null> + onProcess: () => void + onPreview: () => void + onSubmit: (data: Record<string, unknown>) => void + }> = {}, +) => ({ datasourceNodeId: 'node-123', lastRunInputData: {}, isRunning: false, @@ -136,7 +135,9 @@ describe('ProcessDocuments', () => { // Assert - verify both Form and Actions are rendered expect(screen.getByTestId('process-form')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }), + ).toBeInTheDocument() }) it('should render with correct container structure', () => { @@ -150,8 +151,16 @@ describe('ProcessDocuments', () => { it('should render form fields based on variables configuration', () => { const variables: RAGPipelineVariable[] = [ - createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number }), - createMockVariable({ variable: 'separator', label: 'Separator', type: PipelineInputVarType.textInput }), + createMockVariable({ + variable: 'chunk_size', + label: 'Chunk Size', + type: PipelineInputVarType.number, + }), + createMockVariable({ + variable: 'separator', + label: 'Separator', + type: PipelineInputVarType.textInput, + }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() @@ -172,7 +181,12 @@ describe('ProcessDocuments', () => { describe('lastRunInputData', () => { it('should use lastRunInputData as initial form values', () => { const variables: RAGPipelineVariable[] = [ - createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }), + createMockVariable({ + variable: 'chunk_size', + label: 'Chunk Size', + type: PipelineInputVarType.number, + default_value: '100', + }), ] mockParamsConfig.mockReturnValue({ variables }) const lastRunInputData = { chunk_size: 500 } @@ -187,7 +201,12 @@ describe('ProcessDocuments', () => { it('should use default_value when lastRunInputData is empty', () => { const variables: RAGPipelineVariable[] = [ - createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }), + createMockVariable({ + variable: 'chunk_size', + label: 'Chunk Size', + type: PipelineInputVarType.number, + default_value: '100', + }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps({ lastRunInputData: {} }) @@ -205,7 +224,9 @@ describe('ProcessDocuments', () => { renderWithProviders(<ProcessDocuments {...props} />) - const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }) + const processButton = screen.getByRole('button', { + name: 'datasetPipeline.operations.saveAndProcess', + }) expect(processButton).not.toBeDisabled() }) @@ -214,7 +235,9 @@ describe('ProcessDocuments', () => { renderWithProviders(<ProcessDocuments {...props} />) - const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }) + const processButton = screen.getByRole('button', { + name: 'datasetPipeline.operations.saveAndProcess', + }) expect(processButton).toBeDisabled() }) @@ -256,7 +279,9 @@ describe('ProcessDocuments', () => { const props = createDefaultProps({ onProcess }) renderWithProviders(<ProcessDocuments {...props} />) - fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) + fireEvent.click( + screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }), + ) expect(onProcess).toHaveBeenCalledTimes(1) }) @@ -266,7 +291,9 @@ describe('ProcessDocuments', () => { const props = createDefaultProps({ onProcess, isRunning: true }) renderWithProviders(<ProcessDocuments {...props} />) - fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) + fireEvent.click( + screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }), + ) expect(onProcess).not.toHaveBeenCalled() }) @@ -287,7 +314,12 @@ describe('ProcessDocuments', () => { describe('onSubmit', () => { it('should call onSubmit with form data when form is submitted', () => { const variables: RAGPipelineVariable[] = [ - createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }), + createMockVariable({ + variable: 'chunk_size', + label: 'Chunk Size', + type: PipelineInputVarType.number, + default_value: '100', + }), ] mockParamsConfig.mockReturnValue({ variables }) const onSubmit = vi.fn() @@ -308,7 +340,12 @@ describe('ProcessDocuments', () => { describe('Data Transformation', () => { it('should transform text-input variable to string initial value', () => { const variables: RAGPipelineVariable[] = [ - createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput, default_value: 'default' }), + createMockVariable({ + variable: 'name', + label: 'Name', + type: PipelineInputVarType.textInput, + default_value: 'default', + }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() @@ -321,7 +358,12 @@ describe('ProcessDocuments', () => { it('should transform number variable to number initial value', () => { const variables: RAGPipelineVariable[] = [ - createMockVariable({ variable: 'count', label: 'Count', type: PipelineInputVarType.number, default_value: '42' }), + createMockVariable({ + variable: 'count', + label: 'Count', + type: PipelineInputVarType.number, + default_value: '42', + }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() @@ -334,7 +376,11 @@ describe('ProcessDocuments', () => { it('should use empty string for text-input without default value', () => { const variables: RAGPipelineVariable[] = [ - createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput }), + createMockVariable({ + variable: 'name', + label: 'Name', + type: PipelineInputVarType.textInput, + }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() @@ -347,7 +393,12 @@ describe('ProcessDocuments', () => { it('should prioritize lastRunInputData over default_value', () => { const variables: RAGPipelineVariable[] = [ - createMockVariable({ variable: 'size', label: 'Size', type: PipelineInputVarType.number, default_value: '100' }), + createMockVariable({ + variable: 'size', + label: 'Size', + type: PipelineInputVarType.number, + default_value: '100', + }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps({ lastRunInputData: { size: 999 } }) @@ -397,9 +448,24 @@ describe('ProcessDocuments', () => { describe('Multiple variables', () => { it('should handle multiple variables of different types', () => { const variables: RAGPipelineVariable[] = [ - createMockVariable({ variable: 'text_field', label: 'Text', type: PipelineInputVarType.textInput, default_value: 'hello' }), - createMockVariable({ variable: 'number_field', label: 'Number', type: PipelineInputVarType.number, default_value: '123' }), - createMockVariable({ variable: 'select_field', label: 'Select', type: PipelineInputVarType.select, default_value: 'option1' }), + createMockVariable({ + variable: 'text_field', + label: 'Text', + type: PipelineInputVarType.textInput, + default_value: 'hello', + }), + createMockVariable({ + variable: 'number_field', + label: 'Number', + type: PipelineInputVarType.number, + default_value: '123', + }), + createMockVariable({ + variable: 'select_field', + label: 'Select', + type: PipelineInputVarType.select, + default_value: 'option1', + }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() @@ -414,8 +480,18 @@ describe('ProcessDocuments', () => { it('should submit all variables data correctly', () => { const variables: RAGPipelineVariable[] = [ - createMockVariable({ variable: 'field1', label: 'Field 1', type: PipelineInputVarType.textInput, default_value: 'value1' }), - createMockVariable({ variable: 'field2', label: 'Field 2', type: PipelineInputVarType.number, default_value: '42' }), + createMockVariable({ + variable: 'field1', + label: 'Field 1', + type: PipelineInputVarType.textInput, + default_value: 'value1', + }), + createMockVariable({ + variable: 'field2', + label: 'Field 2', + type: PipelineInputVarType.number, + default_value: '42', + }), ] mockParamsConfig.mockReturnValue({ variables }) const onSubmit = vi.fn() @@ -460,7 +536,12 @@ describe('ProcessDocuments', () => { describe('Integration', () => { it('should coordinate form submission flow correctly', () => { const variables: RAGPipelineVariable[] = [ - createMockVariable({ variable: 'setting', label: 'Setting', type: PipelineInputVarType.textInput, default_value: 'initial' }), + createMockVariable({ + variable: 'setting', + label: 'Setting', + type: PipelineInputVarType.textInput, + default_value: 'initial', + }), ] mockParamsConfig.mockReturnValue({ variables }) const onProcess = vi.fn() @@ -474,7 +555,9 @@ describe('ProcessDocuments', () => { expect(input.defaultValue).toBe('initial') // Act - click process button - fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) + fireEvent.click( + screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }), + ) // Assert - onProcess is called expect(onProcess).toHaveBeenCalled() @@ -482,7 +565,11 @@ describe('ProcessDocuments', () => { it('should render complete UI with all interactive elements', () => { const variables: RAGPipelineVariable[] = [ - createMockVariable({ variable: 'test', label: 'Test Field', type: PipelineInputVarType.textInput }), + createMockVariable({ + variable: 'test', + label: 'Test Field', + type: PipelineInputVarType.textInput, + }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() @@ -493,7 +580,9 @@ describe('ProcessDocuments', () => { expect(screen.getByTestId('process-form')).toBeInTheDocument() expect(screen.getByText('Test Field')).toBeInTheDocument() expect(screen.getByTestId('preview-btn')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }), + ).toBeInTheDocument() }) }) }) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.tsx index cae9e84a2a36d3..6984e5f299c69a 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.tsx @@ -7,20 +7,13 @@ type ActionsProps = { onProcess: () => void } -const Actions = ({ - onProcess, - runDisabled, -}: ActionsProps) => { +const Actions = ({ onProcess, runDisabled }: ActionsProps) => { const { t } = useTranslation() return ( <div className="flex items-center justify-end"> - <Button - variant="primary" - onClick={onProcess} - disabled={runDisabled} - > - {t($ => $['operations.saveAndProcess'], { ns: 'datasetPipeline' })} + <Button variant="primary" onClick={onProcess} disabled={runDisabled}> + {t(($) => $['operations.saveAndProcess'], { ns: 'datasetPipeline' })} </Button> </div> ) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/hooks.ts b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/hooks.ts index bb4e872ff0c18e..dd5655d062b7e8 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/hooks.ts +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/hooks.ts @@ -2,11 +2,13 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { usePublishedPipelineProcessingParams } from '@/service/use-pipeline' export const useInputVariables = (datasourceNodeId: string) => { - const pipelineId = useDatasetDetailContextWithSelector(state => state.dataset?.pipeline_id) - const { data: paramsConfig, isFetching: isFetchingParams } = usePublishedPipelineProcessingParams({ - pipeline_id: pipelineId!, - node_id: datasourceNodeId, - }) + const pipelineId = useDatasetDetailContextWithSelector((state) => state.dataset?.pipeline_id) + const { data: paramsConfig, isFetching: isFetchingParams } = usePublishedPipelineProcessingParams( + { + pipeline_id: pipelineId!, + node_id: datasourceNodeId, + }, + ) return { paramsConfig, diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.tsx index 6f892216d87770..af83ac57d480da 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.tsx @@ -1,5 +1,8 @@ import { generateZodSchema } from '@/app/components/base/form/form-scenarios/base/utils' -import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields' +import { + useConfigurations, + useInitialData, +} from '@/app/components/rag-pipeline/hooks/use-input-fields' import Form from '../../../../create-from-pipeline/process-documents/form' import Actions from './actions' import { useInputVariables } from './hooks' diff --git a/web/app/components/datasets/documents/detail/style.module.css b/web/app/components/datasets/documents/detail/style.module.css index 47f9c85fa00278..046c2edb0839a0 100644 --- a/web/app/components/datasets/documents/detail/style.module.css +++ b/web/app/components/datasets/documents/detail/style.module.css @@ -7,7 +7,5 @@ @apply !h-6 !w-6; } .layoutRightIcon { - @apply p-2 ml-2 border-[0.5px] border-components-button-secondary-border hover:border-components-button-secondary-border-hover - rounded-lg bg-components-button-secondary-bg hover:bg-components-button-secondary-bg-hover cursor-pointer - shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]; + @apply ml-2 cursor-pointer rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover; } diff --git a/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.tsx b/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.tsx index dda78bd068e06e..24709178117ddd 100644 --- a/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.tsx +++ b/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.tsx @@ -134,15 +134,13 @@ describe('useDocumentListQueryState', () => { expect(result.current.query.sort).toBe('-created_at') }) - it.each([ - '-created_at', - 'created_at', - '-hit_count', - 'hit_count', - ] as const)('should accept valid sort value %s', (sortValue) => { - const { result } = renderWithAdapter(`?sort=${sortValue}`) - expect(result.current.query.sort).toBe(sortValue) - }) + it.each(['-created_at', 'created_at', '-hit_count', 'hit_count'] as const)( + 'should accept valid sort value %s', + (sortValue) => { + const { result } = renderWithAdapter(`?sort=${sortValue}`) + expect(result.current.query.sort).toBe(sortValue) + }, + ) }) describe('updateQuery', () => { diff --git a/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts b/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts index e0dbee66601e49..c706d14044a434 100644 --- a/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts +++ b/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts @@ -1,11 +1,16 @@ import type { DocumentListQuery } from '../use-document-list-query-state' - import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useDocumentsPageState } from '../use-documents-page-state' const mockUpdateQuery = vi.fn() -let mockQuery: DocumentListQuery = { page: 1, limit: 10, keyword: '', status: 'all', sort: '-created_at' } +let mockQuery: DocumentListQuery = { + page: 1, + limit: 10, + keyword: '', + status: 'all', + sort: '-created_at', +} vi.mock('@/models/datasets', () => ({ DisplayStatusList: [ @@ -33,7 +38,7 @@ vi.mock('../use-document-list-query-state', async () => { query, updateQuery: (updates: Partial<DocumentListQuery>) => { mockUpdateQuery(updates) - setQuery(prev => ({ ...prev, ...updates })) + setQuery((prev) => ({ ...prev, ...updates })) }, } }, diff --git a/web/app/components/datasets/documents/hooks/use-document-list-query-state.ts b/web/app/components/datasets/documents/hooks/use-document-list-query-state.ts index d06ffe767c5166..218679fbadb1a6 100644 --- a/web/app/components/datasets/documents/hooks/use-document-list-query-state.ts +++ b/web/app/components/datasets/documents/hooks/use-document-list-query-state.ts @@ -7,8 +7,7 @@ import { sanitizeStatusValue } from '../status-filter' const ALLOWED_SORT_VALUES: SortType[] = ['-created_at', 'created_at', '-hit_count', 'hit_count'] const sanitizeSortValue = (value?: string | null): SortType => { - if (!value) - return '-created_at' + if (!value) return '-created_at' return (ALLOWED_SORT_VALUES.includes(value as SortType) ? value : '-created_at') as SortType } @@ -26,7 +25,7 @@ const parseAsPage = createParser<number>({ const n = Number.parseInt(value, 10) return Number.isNaN(n) || n <= 0 ? null : n }, - serialize: value => value.toString(), + serialize: (value) => value.toString(), }).withDefault(1) const parseAsLimit = createParser<number>({ @@ -34,17 +33,17 @@ const parseAsLimit = createParser<number>({ const n = Number.parseInt(value, 10) return Number.isNaN(n) || n <= 0 || n > 100 ? null : n }, - serialize: value => value.toString(), + serialize: (value) => value.toString(), }).withDefault(10) const parseAsDocStatus = createParser<string>({ - parse: value => sanitizeStatusValue(value), - serialize: value => value, + parse: (value) => sanitizeStatusValue(value), + serialize: (value) => value, }).withDefault('all') const parseAsDocSort = createParser<SortType>({ - parse: value => sanitizeSortValue(value), - serialize: value => value, + parse: (value) => sanitizeSortValue(value), + serialize: (value) => value, }).withDefault('-created_at' as SortType) const parseAsKeyword = parseAsString.withDefault('') @@ -65,39 +64,42 @@ const KEYWORD_URL_UPDATE_THROTTLE = throttle(300) export function useDocumentListQueryState() { const [query, setQuery] = useQueryStates(documentListParsers) - const updateQuery = useCallback((updates: Partial<DocumentListQuery>) => { - const patch = { ...updates } - if ('page' in patch && patch.page !== undefined) - patch.page = sanitizePageValue(patch.page) - if ('limit' in patch && patch.limit !== undefined) - patch.limit = sanitizeLimitValue(patch.limit) - if ('status' in patch) - patch.status = sanitizeStatusValue(patch.status) - if ('sort' in patch) - patch.sort = sanitizeSortValue(patch.sort) - if ('keyword' in patch && typeof patch.keyword === 'string' && patch.keyword.trim() === '') - patch.keyword = '' - - // If keyword is part of this patch (even with page reset), treat it as a search update: - // use replace to avoid creating a history entry per input-driven change. - if ('keyword' in patch) { - setQuery(patch, { - history: 'replace', - limitUrlUpdates: patch.keyword === '' ? undefined : KEYWORD_URL_UPDATE_THROTTLE, - }) - return - } - - setQuery(patch, { history: 'push' }) - }, [setQuery]) + const updateQuery = useCallback( + (updates: Partial<DocumentListQuery>) => { + const patch = { ...updates } + if ('page' in patch && patch.page !== undefined) patch.page = sanitizePageValue(patch.page) + if ('limit' in patch && patch.limit !== undefined) + patch.limit = sanitizeLimitValue(patch.limit) + if ('status' in patch) patch.status = sanitizeStatusValue(patch.status) + if ('sort' in patch) patch.sort = sanitizeSortValue(patch.sort) + if ('keyword' in patch && typeof patch.keyword === 'string' && patch.keyword.trim() === '') + patch.keyword = '' + + // If keyword is part of this patch (even with page reset), treat it as a search update: + // use replace to avoid creating a history entry per input-driven change. + if ('keyword' in patch) { + setQuery(patch, { + history: 'replace', + limitUrlUpdates: patch.keyword === '' ? undefined : KEYWORD_URL_UPDATE_THROTTLE, + }) + return + } + + setQuery(patch, { history: 'push' }) + }, + [setQuery], + ) const resetQuery = useCallback(() => { setQuery(null, { history: 'replace' }) }, [setQuery]) - return useMemo(() => ({ - query, - updateQuery, - resetQuery, - }), [query, updateQuery, resetQuery]) + return useMemo( + () => ({ + query, + updateQuery, + resetQuery, + }), + [query, updateQuery, resetQuery], + ) } diff --git a/web/app/components/datasets/documents/hooks/use-documents-page-state.ts b/web/app/components/datasets/documents/hooks/use-documents-page-state.ts index 36b1e8c760fa17..764cb4c20f568e 100644 --- a/web/app/components/datasets/documents/hooks/use-documents-page-state.ts +++ b/web/app/components/datasets/documents/hooks/use-documents-page-state.ts @@ -19,39 +19,51 @@ export function useDocumentsPageState() { const [selectedIds, setSelectedIds] = useState<string[]>([]) - const handlePageChange = useCallback((newPage: number) => { - updateQuery({ page: newPage + 1 }) - }, [updateQuery]) + const handlePageChange = useCallback( + (newPage: number) => { + updateQuery({ page: newPage + 1 }) + }, + [updateQuery], + ) - const handleLimitChange = useCallback((newLimit: number) => { - updateQuery({ limit: newLimit, page: 1 }) - }, [updateQuery]) + const handleLimitChange = useCallback( + (newLimit: number) => { + updateQuery({ limit: newLimit, page: 1 }) + }, + [updateQuery], + ) - const handleInputChange = useCallback((value: string) => { - if (value !== query.keyword) - setSelectedIds([]) - updateQuery({ keyword: value, page: 1 }) - }, [query.keyword, updateQuery]) + const handleInputChange = useCallback( + (value: string) => { + if (value !== query.keyword) setSelectedIds([]) + updateQuery({ keyword: value, page: 1 }) + }, + [query.keyword, updateQuery], + ) - const handleStatusFilterChange = useCallback((value: string) => { - const selectedValue = sanitizeStatusValue(value) - setSelectedIds([]) - updateQuery({ status: selectedValue, page: 1 }) - }, [updateQuery]) + const handleStatusFilterChange = useCallback( + (value: string) => { + const selectedValue = sanitizeStatusValue(value) + setSelectedIds([]) + updateQuery({ status: selectedValue, page: 1 }) + }, + [updateQuery], + ) const handleStatusFilterClear = useCallback(() => { - if (statusFilterValue === 'all') - return + if (statusFilterValue === 'all') return setSelectedIds([]) updateQuery({ status: 'all', page: 1 }) }, [statusFilterValue, updateQuery]) - const handleSortChange = useCallback((value: string) => { - const next = value as SortType - if (next === sortValue) - return - updateQuery({ sort: next, page: 1 }) - }, [sortValue, updateQuery]) + const handleSortChange = useCallback( + (value: string) => { + const next = value as SortType + if (next === sortValue) return + updateQuery({ sort: next, page: 1 }) + }, + [sortValue, updateQuery], + ) return { inputValue, diff --git a/web/app/components/datasets/documents/index.tsx b/web/app/components/datasets/documents/index.tsx index e068073998be42..b042dc7bd98a4b 100644 --- a/web/app/components/datasets/documents/index.tsx +++ b/web/app/components/datasets/documents/index.tsx @@ -9,7 +9,11 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useProviderContext } from '@/context/provider-context' import { DataSourceType } from '@/models/datasets' import { useRouter } from '@/next/navigation' -import { useDocumentList, useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document' +import { + useDocumentList, + useInvalidDocumentDetail, + useInvalidDocumentList, +} from '@/service/knowledge/use-document' import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' import { getDatasetACLCapabilities } from '@/utils/permission' @@ -32,7 +36,7 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => { const { plan } = useProviderContext() const isFreePlan = plan.type === 'sandbox' - const dataset = useDatasetDetailContextWithSelector(s => s.dataset) + const dataset = useDatasetDetailContextWithSelector((s) => s.dataset) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const embeddingAvailable = !!dataset?.embedding_available @@ -72,12 +76,14 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => { sort: sortValue, }, refetchInterval: (query) => { - const shouldForcePolling = normalizedStatusFilterValue !== 'all' - && FORCED_POLLING_STATUSES.has(normalizedStatusFilterValue) + const shouldForcePolling = + normalizedStatusFilterValue !== 'all' && + FORCED_POLLING_STATUSES.has(normalizedStatusFilterValue) const documents = query.state.data?.data - if (!documents) - return POLLING_INTERVAL - const hasIncompleteDocuments = documents.some(({ indexing_status }) => !TERMINAL_INDEXING_STATUSES.has(indexing_status)) + if (!documents) return POLLING_INTERVAL + const hasIncompleteDocuments = documents.some( + ({ indexing_status }) => !TERMINAL_INDEXING_STATUSES.has(indexing_status), + ) return shouldForcePolling || hasIncompleteDocuments ? POLLING_INTERVAL : false }, }) @@ -117,8 +123,7 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => { // Route to document creation page const routeToDocCreate = useCallback(() => { - if (!datasetACLCapabilities.canUse) - return + if (!datasetACLCapabilities.canUse) return if (dataset?.runtime_mode === 'rag_pipeline') { router.push(`/datasets/${datasetId}/documents/create-from-pipeline`) return @@ -131,8 +136,7 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => { // Render content based on loading and data state const renderContent = () => { - if (isListLoading && !documentsRes) - return <Loading type="app" /> + if (isListLoading && !documentsRes) return <Loading type="app" /> if (total > 0) { return ( @@ -196,9 +200,7 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => { onBuiltInEnabledChange={setBuiltInEnabled} onAddDocument={routeToDocCreate} /> - <div className="flex h-0 grow flex-col px-6 pt-4"> - {renderContent()} - </div> + <div className="flex h-0 grow flex-col px-6 pt-4">{renderContent()}</div> </div> ) } diff --git a/web/app/components/datasets/documents/status-filter.ts b/web/app/components/datasets/documents/status-filter.ts index d3457743514717..13fe50c1c9fb11 100644 --- a/web/app/components/datasets/documents/status-filter.ts +++ b/web/app/components/datasets/documents/status-filter.ts @@ -2,7 +2,7 @@ import { DisplayStatusList } from '@/models/datasets' const KNOWN_STATUS_VALUES = new Set<string>([ 'all', - ...DisplayStatusList.map(item => item.toLowerCase()), + ...DisplayStatusList.map((item) => item.toLowerCase()), ]) const URL_STATUS_ALIASES: Record<string, string> = { @@ -14,20 +14,17 @@ const QUERY_STATUS_ALIASES: Record<string, string> = { } export const sanitizeStatusValue = (value?: string | null) => { - if (!value) - return 'all' + if (!value) return 'all' const normalized = value.toLowerCase() - if (URL_STATUS_ALIASES[normalized]) - return URL_STATUS_ALIASES[normalized] + if (URL_STATUS_ALIASES[normalized]) return URL_STATUS_ALIASES[normalized] return KNOWN_STATUS_VALUES.has(normalized) ? normalized : 'all' } export const normalizeStatusForQuery = (value?: string | null) => { const sanitized = sanitizeStatusValue(value) - if (sanitized === 'all') - return 'all' + if (sanitized === 'all') return 'all' return QUERY_STATUS_ALIASES[sanitized] || sanitized } diff --git a/web/app/components/datasets/documents/status-item/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/status-item/__tests__/hooks.spec.ts index 6d1a0d6881ed5b..661e9a901b1e65 100644 --- a/web/app/components/datasets/documents/status-item/__tests__/hooks.spec.ts +++ b/web/app/components/datasets/documents/status-item/__tests__/hooks.spec.ts @@ -14,7 +14,16 @@ describe('useIndexStatus', () => { it('should return all expected status keys', () => { const { result } = renderHook(() => useIndexStatus()) - const expectedKeys = ['queuing', 'indexing', 'paused', 'error', 'available', 'enabled', 'disabled', 'archived'] + const expectedKeys = [ + 'queuing', + 'indexing', + 'paused', + 'error', + 'available', + 'enabled', + 'disabled', + 'archived', + ] const keys = Object.keys(result.current) expect(keys).toEqual(expect.arrayContaining(expectedKeys)) }) diff --git a/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx index 490dbed6378d12..bc75c59335fe78 100644 --- a/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx @@ -5,14 +5,24 @@ import StatusItem from '../index' const toastMocks = vi.hoisted(() => { const record = vi.fn() - const api = vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })) + const api = vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ message, ...options }), + ) return { record, api: Object.assign(api, { - success: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'success', message, ...options })), - error: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'error', message, ...options })), - warning: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'warning', message, ...options })), - info: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'info', message, ...options })), + success: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'success', message, ...options }), + ), + error: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'info', message, ...options }), + ), dismiss: vi.fn(), update: vi.fn(), promise: vi.fn(), @@ -495,7 +505,9 @@ describe('StatusItem', () => { describe('memoization', () => { it('should be wrapped with React.memo', () => { - expect((StatusItem as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + expect((StatusItem as unknown as { $$typeof: symbol }).$$typeof).toBe( + Symbol.for('react.memo'), + ) }) }) diff --git a/web/app/components/datasets/documents/status-item/hooks.ts b/web/app/components/datasets/documents/status-item/hooks.ts index 08ea16107c2dde..2158d2198439a1 100644 --- a/web/app/components/datasets/documents/status-item/hooks.ts +++ b/web/app/components/datasets/documents/status-item/hooks.ts @@ -4,13 +4,34 @@ import { useTranslation } from 'react-i18next' export const useIndexStatus = () => { const { t } = useTranslation() return { - queuing: { status: 'warning', text: t($ => $['list.status.queuing'], { ns: 'datasetDocuments' }) }, - indexing: { status: 'normal', text: t($ => $['list.status.indexing'], { ns: 'datasetDocuments' }) }, - paused: { status: 'warning', text: t($ => $['list.status.paused'], { ns: 'datasetDocuments' }) }, - error: { status: 'error', text: t($ => $['list.status.error'], { ns: 'datasetDocuments' }) }, - available: { status: 'success', text: t($ => $['list.status.available'], { ns: 'datasetDocuments' }) }, - enabled: { status: 'success', text: t($ => $['list.status.enabled'], { ns: 'datasetDocuments' }) }, - disabled: { status: 'disabled', text: t($ => $['list.status.disabled'], { ns: 'datasetDocuments' }) }, - archived: { status: 'disabled', text: t($ => $['list.status.archived'], { ns: 'datasetDocuments' }) }, - } satisfies Record<string, { status: StatusDotStatus, text: string }> + queuing: { + status: 'warning', + text: t(($) => $['list.status.queuing'], { ns: 'datasetDocuments' }), + }, + indexing: { + status: 'normal', + text: t(($) => $['list.status.indexing'], { ns: 'datasetDocuments' }), + }, + paused: { + status: 'warning', + text: t(($) => $['list.status.paused'], { ns: 'datasetDocuments' }), + }, + error: { status: 'error', text: t(($) => $['list.status.error'], { ns: 'datasetDocuments' }) }, + available: { + status: 'success', + text: t(($) => $['list.status.available'], { ns: 'datasetDocuments' }), + }, + enabled: { + status: 'success', + text: t(($) => $['list.status.enabled'], { ns: 'datasetDocuments' }), + }, + disabled: { + status: 'disabled', + text: t(($) => $['list.status.disabled'], { ns: 'datasetDocuments' }), + }, + archived: { + status: 'disabled', + text: t(($) => $['list.status.archived'], { ns: 'datasetDocuments' }), + }, + } satisfies Record<string, { status: StatusDotStatus; text: string }> } diff --git a/web/app/components/datasets/documents/status-item/index.tsx b/web/app/components/datasets/documents/status-item/index.tsx index 6dba524edeb390..15215060be3625 100644 --- a/web/app/components/datasets/documents/status-item/index.tsx +++ b/web/app/components/datasets/documents/status-item/index.tsx @@ -12,7 +12,11 @@ import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' -import { useDocumentDelete, useDocumentDisable, useDocumentEnable } from '@/service/knowledge/use-document' +import { + useDocumentDelete, + useDocumentDisable, + useDocumentEnable, +} from '@/service/knowledge/use-document' import { asyncRunSafe } from '@/utils' import s from '../style.module.css' import { useIndexStatus } from './hooks' @@ -39,7 +43,17 @@ type StatusItemProps = { onUpdate?: (operationName?: string) => void canEdit?: boolean } -const StatusItem = ({ status, reverse = false, scene = 'list', textCls = '', errorMessage, datasetId = '', detail, onUpdate, canEdit = false }: StatusItemProps) => { +const StatusItem = ({ + status, + reverse = false, + scene = 'list', + textCls = '', + errorMessage, + datasetId = '', + detail, + onUpdate, + canEdit = false, +}: StatusItemProps) => { const { t } = useTranslation() const DOC_INDEX_STATUS_MAP = useIndexStatus() const localStatus = status.toLowerCase() as keyof typeof DOC_INDEX_STATUS_MAP @@ -49,8 +63,7 @@ const StatusItem = ({ status, reverse = false, scene = 'list', textCls = '', err const { mutateAsync: disableDocument } = useDocumentDisable() const { mutateAsync: deleteDocument } = useDocumentDelete() const onOperate = async (operationName: OperationName) => { - if (!canEdit) - return + if (!canEdit) return let opApi = deleteDocument switch (operationName) { @@ -61,29 +74,36 @@ const StatusItem = ({ status, reverse = false, scene = 'list', textCls = '', err opApi = disableDocument break } - const [e] = await asyncRunSafe<CommonResponse>(opApi({ datasetId, documentId: id }) as Promise<CommonResponse>) + const [e] = await asyncRunSafe<CommonResponse>( + opApi({ datasetId, documentId: id }) as Promise<CommonResponse>, + ) if (!e) { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) onUpdate?.(operationName) - } - else { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } else { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) } } - const { run: handleSwitch } = useDebounceFn((operationName: OperationName) => { - if (!canEdit) - return - if (operationName === 'enable' && enabled) - return - if (operationName === 'disable' && !enabled) - return - onOperate(operationName) - }, { wait: 500 }) + const { run: handleSwitch } = useDebounceFn( + (operationName: OperationName) => { + if (!canEdit) return + if (operationName === 'enable' && enabled) return + if (operationName === 'disable' && !enabled) return + onOperate(operationName) + }, + { wait: 500 }, + ) const embedding = useMemo(() => { return ['queuing', 'indexing', 'paused'].includes(localStatus) }, [localStatus]) return ( - <div className={cn('flex items-center', reverse ? 'flex-row-reverse' : '', scene === 'detail' ? s.statusItemDetail : '')}> + <div + className={cn( + 'flex items-center', + reverse ? 'flex-row-reverse' : '', + scene === 'detail' ? s.statusItemDetail : '', + )} + > <StatusDot status={statusItem.status} className={reverse ? 'ml-2' : 'mr-2'} /> <span className={cn(`${STATUS_TEXT_COLOR_MAP[statusItem.status]} text-sm`, textCls)}> {statusItem.text} @@ -101,19 +121,21 @@ const StatusItem = ({ status, reverse = false, scene = 'list', textCls = '', err <div className="ml-1.5 flex items-center justify-between"> <Tooltip disabled={!archived}> <TooltipTrigger - render={( + render={ <span className="flex"> <Switch checked={archived ? false : enabled} - onCheckedChange={v => !archived && canEdit && handleSwitch(v ? 'enable' : 'disable')} + onCheckedChange={(v) => + !archived && canEdit && handleSwitch(v ? 'enable' : 'disable') + } disabled={embedding || archived || !canEdit} size="md" /> </span> - )} + } /> <TooltipContent className="system-xs-medium text-text-secondary"> - {t($ => $['list.action.enableWarning'], { ns: 'datasetDocuments' })} + {t(($) => $['list.action.enableWarning'], { ns: 'datasetDocuments' })} </TooltipContent> </Tooltip> </div> diff --git a/web/app/components/datasets/documents/style.module.css b/web/app/components/datasets/documents/style.module.css index ef11b7bb45e377..92aa55c7e5da83 100644 --- a/web/app/components/datasets/documents/style.module.css +++ b/web/app/components/datasets/documents/style.module.css @@ -11,43 +11,43 @@ max-width: 200px; } .actionIconWrapperList { - @apply !h-6 !w-6 !border-none !p-1 rounded-md hover:!bg-state-base-hover; + @apply !h-6 !w-6 rounded-md !border-none !p-1 hover:!bg-state-base-hover; } .actionIconWrapperDetail { - @apply !p-2 !border-[0.5px] !border-components-button-secondary-border !bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3 hover:!border-components-button-secondary-border-hover hover:!bg-components-button-secondary-bg-hover; + @apply !border-[0.5px] !border-components-button-secondary-border !bg-components-button-secondary-bg !p-2 shadow-xs shadow-shadow-shadow-3 hover:!border-components-button-secondary-border-hover hover:!bg-components-button-secondary-bg-hover; } .actionItem { - @apply h-9 w-[calc(100%-8px)] py-2 px-3 mx-1 flex items-center gap-2 rounded-lg border-none bg-transparent text-left hover:bg-state-base-hover cursor-pointer; + @apply mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent px-3 py-2 text-left hover:bg-state-base-hover; } .deleteActionItem { @apply hover:!bg-state-destructive-hover; } .actionName { - @apply text-text-secondary text-sm; + @apply text-sm text-text-secondary; } .addFileBtn { - @apply mt-4 w-fit !text-[13px] font-medium border-[0.5px] border-components-button-secondary-border; + @apply mt-4 w-fit border-[0.5px] border-components-button-secondary-border !text-[13px] font-medium; } .plusIcon { - @apply w-4 h-4 mr-2 stroke-current stroke-[1.5px]; + @apply mr-2 h-4 w-4 stroke-current stroke-[1.5px]; } .emptyWrapper { - @apply flex items-center justify-center h-full; + @apply flex h-full items-center justify-center; } .emptyElement { - @apply bg-components-panel-on-panel-item-bg border-divider-subtle w-[560px] h-fit box-border px-5 py-4 rounded-2xl; + @apply box-border h-fit w-[560px] rounded-2xl border-divider-subtle bg-components-panel-on-panel-item-bg px-5 py-4; } .emptyTitle { - @apply text-text-secondary font-semibold; + @apply font-semibold text-text-secondary; } .emptyTip { - @apply mt-2 text-text-primary text-sm font-normal; + @apply mt-2 text-sm font-normal text-text-primary; } .emptySymbolIconWrapper { - @apply w-[44px] h-[44px] border border-solid border-components-button-secondary-border rounded-lg flex items-center justify-center mb-2; + @apply mb-2 flex h-[44px] w-[44px] items-center justify-center rounded-lg border border-solid border-components-button-secondary-border; } .commonIcon { - @apply w-4 h-4 inline-block align-middle; + @apply inline-block h-4 w-4 align-middle; background-repeat: no-repeat; background-position: center center; background-size: contain; @@ -90,29 +90,26 @@ background-image: url(~@/assets/docx.svg); } .statusItemDetail { - @apply border-[0.5px] border-components-button-secondary-border inline-flex items-center - rounded-lg pl-2.5 pr-2 py-2 mr-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]; + @apply mr-2 inline-flex items-center rounded-lg border-[0.5px] border-components-button-secondary-border py-2 pr-2 pl-2.5 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]; } .tdValue { - @apply text-sm overflow-hidden text-ellipsis whitespace-nowrap; + @apply overflow-hidden text-sm text-ellipsis whitespace-nowrap; } .delModal { - background: linear-gradient( - 180deg, - rgba(217, 45, 32, 0.05) 0%, - rgba(217, 45, 32, 0) 24.02% - ), - #f9fafb; - box-shadow: 0px 20px 24px -4px rgba(16, 24, 40, 0.08), + background: + linear-gradient(180deg, rgba(217, 45, 32, 0.05) 0%, rgba(217, 45, 32, 0) 24.02%), #f9fafb; + box-shadow: + 0px 20px 24px -4px rgba(16, 24, 40, 0.08), 0px 8px 8px -4px rgba(16, 24, 40, 0.03); @apply rounded-2xl p-8; } .warningWrapper { - box-shadow: 0px 20px 24px -4px rgba(16, 24, 40, 0.08), + box-shadow: + 0px 20px 24px -4px rgba(16, 24, 40, 0.08), 0px 8px 8px -4px rgba(16, 24, 40, 0.03); background: rgba(255, 255, 255, 0.9); - @apply h-12 w-12 border-[0.5px] border-gray-100 rounded-xl mb-3 flex items-center justify-center; + @apply mb-3 flex h-12 w-12 items-center justify-center rounded-xl border-[0.5px] border-gray-100; } .warningIcon { - @apply w-[22px] h-[22px] fill-current text-red-600; + @apply h-[22px] w-[22px] fill-current text-red-600; } diff --git a/web/app/components/datasets/documents/types.ts b/web/app/components/datasets/documents/types.ts index a4c89453ef967c..205e3cab7008e8 100644 --- a/web/app/components/datasets/documents/types.ts +++ b/web/app/components/datasets/documents/types.ts @@ -1 +1,10 @@ -export type OperationName = 'delete' | 'archive' | 'enable' | 'disable' | 'sync' | 'un_archive' | 'pause' | 'resume' | 'summary' +export type OperationName = + | 'delete' + | 'archive' + | 'enable' + | 'disable' + | 'sync' + | 'un_archive' + | 'pause' + | 'resume' + | 'summary' diff --git a/web/app/components/datasets/external-api/external-api-modal/Form.tsx b/web/app/components/datasets/external-api/external-api-modal/Form.tsx index a6da7c5849a8b6..f3bee9cefc6577 100644 --- a/web/app/components/datasets/external-api/external-api-modal/Form.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/Form.tsx @@ -17,75 +17,87 @@ type FormProps = { inputClassName?: string } -const Form: FC<FormProps> = React.memo(({ - className, - itemClassName, - fieldLabelClassName, - value, - onChange, - formSchemas, - inputClassName, -}) => { - const { t, i18n } = useTranslation() - const docLink = useDocLink() +const Form: FC<FormProps> = React.memo( + ({ + className, + itemClassName, + fieldLabelClassName, + value, + onChange, + formSchemas, + inputClassName, + }) => { + const { t, i18n } = useTranslation() + const docLink = useDocLink() - const handleFormChange = (key: string, val: string) => { - if (key === 'name') { - onChange({ ...value, [key]: val }) + const handleFormChange = (key: string, val: string) => { + if (key === 'name') { + onChange({ ...value, [key]: val }) + } else { + onChange({ + ...value, + settings: { + ...value.settings, + [key]: val, + }, + }) + } } - else { - onChange({ - ...value, - settings: { - ...value.settings, - [key]: val, - }, - }) - } - } - const renderField = (formSchema: FormSchema) => { - const { variable, type, label, required } = formSchema - const fieldValue = variable === 'name' ? value[variable] : (value.settings[variable as keyof typeof value.settings] || '') + const renderField = (formSchema: FormSchema) => { + const { variable, type, label, required } = formSchema + const fieldValue = + variable === 'name' + ? value[variable] + : value.settings[variable as keyof typeof value.settings] || '' - return ( - <div key={variable} className={cn(itemClassName, 'flex flex-col items-start gap-1 self-stretch')}> - <div className="flex w-full items-center justify-between"> - <label className={cn(fieldLabelClassName, 'system-sm-semibold text-text-secondary')} htmlFor={variable}> - {label[i18n.language] || label.en_US} - {required && <span className="ml-1 text-red-500">*</span>} - </label> - {variable === 'endpoint' && ( - <a - href={docLink('/use-dify/knowledge/external-knowledge-api') || '/'} - target="_blank" - rel="noopener noreferrer" - className="flex items-center body-xs-regular text-text-accent" + return ( + <div + key={variable} + className={cn(itemClassName, 'flex flex-col items-start gap-1 self-stretch')} + > + <div className="flex w-full items-center justify-between"> + <label + className={cn(fieldLabelClassName, 'system-sm-semibold text-text-secondary')} + htmlFor={variable} > - <RiBookOpenLine className="mr-1 size-3 text-text-accent" /> - {t($ => $.externalAPIPanelDocumentation, { ns: 'dataset' })} - </a> - )} + {label[i18n.language] || label.en_US} + {required && <span className="ml-1 text-red-500">*</span>} + </label> + {variable === 'endpoint' && ( + <a + href={docLink('/use-dify/knowledge/external-knowledge-api') || '/'} + target="_blank" + rel="noopener noreferrer" + className="flex items-center body-xs-regular text-text-accent" + > + <RiBookOpenLine className="mr-1 size-3 text-text-accent" /> + {t(($) => $.externalAPIPanelDocumentation, { ns: 'dataset' })} + </a> + )} + </div> + <Input + type={type === 'secret' ? 'password' : 'text'} + id={variable} + name={variable} + value={fieldValue} + onChange={(val) => handleFormChange(variable, val.target.value)} + required={required} + className={cn(inputClassName)} + /> </div> - <Input - type={type === 'secret' ? 'password' : 'text'} - id={variable} - name={variable} - value={fieldValue} - onChange={val => handleFormChange(variable, val.target.value)} - required={required} - className={cn(inputClassName)} - /> - </div> - ) - } + ) + } - return ( - <form className={cn('flex flex-col items-start justify-center gap-4 self-stretch', className)}> - {formSchemas.map(formSchema => renderField(formSchema))} - </form> - ) -}) + return ( + <form + className={cn('flex flex-col items-start justify-center gap-4 self-stretch', className)} + > + {formSchemas.map((formSchema) => renderField(formSchema))} + </form> + ) + }, +) Form.displayName = 'Form' diff --git a/web/app/components/datasets/external-api/external-api-modal/__tests__/Form.spec.tsx b/web/app/components/datasets/external-api/external-api-modal/__tests__/Form.spec.tsx index 5b5bfac8ef8996..033103f5f9eb10 100644 --- a/web/app/components/datasets/external-api/external-api-modal/__tests__/Form.spec.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/__tests__/Form.spec.tsx @@ -72,7 +72,10 @@ describe('Form', () => { render(<Form {...defaultProps} />) const docLink = screen.getByText('dataset.externalAPIPanelDocumentation') expect(docLink).toBeInTheDocument() - expect(docLink.closest('a')).toHaveAttribute('href', expect.stringContaining('docs.example.com')) + expect(docLink.closest('a')).toHaveAttribute( + 'href', + expect.stringContaining('docs.example.com'), + ) }) it('should render password type input for secret fields', () => { @@ -101,7 +104,9 @@ describe('Form', () => { }) it('should apply fieldLabelClassName to labels', () => { - const { container } = render(<Form {...defaultProps} fieldLabelClassName="custom-label-class" />) + const { container } = render( + <Form {...defaultProps} fieldLabelClassName="custom-label-class" />, + ) const labels = container.querySelectorAll('label.custom-label-class') expect(labels.length).toBe(3) }) diff --git a/web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx b/web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx index 18b38c807c770c..0702a6565a3096 100644 --- a/web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx @@ -3,7 +3,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' // Import mocked service import { createExternalAPI } from '@/service/datasets' - import AddExternalAPIModal from '../index' // Mock API service @@ -101,7 +100,9 @@ describe('AddExternalAPIModal', () => { ) expect(screen.getByText('dataset.editExternalAPIFormWarning.front'))!.toBeInTheDocument() // Verify the count is displayed in the warning section - const warningElement = screen.getByText('dataset.editExternalAPIFormWarning.front').parentElement + const warningElement = screen.getByText( + 'dataset.editExternalAPIFormWarning.front', + ).parentElement expect(warningElement?.textContent).toContain('2') }) @@ -341,9 +342,7 @@ describe('AddExternalAPIModal', () => { fireEvent.click(confirmButton) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'success' }), - ) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) }) }) @@ -422,7 +421,10 @@ describe('AddExternalAPIModal', () => { it('should render documentation link in encryption notice', () => { render(<AddExternalAPIModal {...defaultProps} />) const link = screen.getByRole('link', { name: 'PKCS1_OAEP' }) - expect(link)!.toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html') + expect(link)!.toHaveAttribute( + 'href', + 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html', + ) expect(link)!.toHaveAttribute('target', '_blank') }) }) diff --git a/web/app/components/datasets/external-api/external-api-modal/index.tsx b/web/app/components/datasets/external-api/external-api-modal/index.tsx index 4fda56b54f593b..77065ccac87350 100644 --- a/web/app/components/datasets/external-api/external-api-modal/index.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/index.tsx @@ -66,18 +66,31 @@ const emptyExternalAPIFormData: CreateExternalAPIReq = { }, } -const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCancel, datasetBindings, isEditMode, onEdit }) => { +const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ + data, + onSave, + onCancel, + datasetBindings, + isEditMode, + onEdit, +}) => { const { t } = useTranslation() const [loading, setLoading] = useState(false) const [showConfirm, setShowConfirm] = useState(false) - const [formData, setFormData] = useState<CreateExternalAPIReq>(() => isEditMode && data ? data : emptyExternalAPIFormData) - const hasEmptyInputs = Object.values(formData).some(value => typeof value === 'string' ? value.trim() === '' : Object.values(value).some(v => v.trim() === '')) + const [formData, setFormData] = useState<CreateExternalAPIReq>(() => + isEditMode && data ? data : emptyExternalAPIFormData, + ) + const hasEmptyInputs = Object.values(formData).some((value) => + typeof value === 'string' + ? value.trim() === '' + : Object.values(value).some((v) => v.trim() === ''), + ) const handleDataChange = (val: CreateExternalAPIReq) => { setFormData(val) } const handleSave = async () => { if (formData && formData.settings.api_key && formData.settings.api_key?.length < 5) { - toast.error(t($ => $['apiBasedExtension.modal.apiKey.lengthError'], { ns: 'common' })) + toast.error(t(($) => $['apiBasedExtension.modal.apiKey.lengthError'], { ns: 'common' })) setLoading(false) return } @@ -86,16 +99,14 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan if (isEditMode && onEdit) { // Only send [__HIDDEN__] when the user has not changed the key, otherwise // send the actual api_key so updated tokens are persisted. - const apiKeyToSend = formData.settings.api_key === '[__HIDDEN__]' - ? '[__HIDDEN__]' - : formData.settings.api_key + const apiKeyToSend = + formData.settings.api_key === '[__HIDDEN__]' ? '[__HIDDEN__]' : formData.settings.api_key await onEdit({ ...formData, settings: { ...formData.settings, api_key: apiKeyToSend }, }) toast.success('External API updated successfully') - } - else { + } else { const res = await createExternalAPI({ body: formData }) if (res && res.id) { toast.success('External API saved successfully') @@ -103,12 +114,10 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan } } onCancel() - } - catch (error) { + } catch (error) { console.error('Error saving/updating external API:', error) toast.error('Failed to save/update External API') - } - finally { + } finally { setLoading(false) } } @@ -117,37 +126,37 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan open disablePointerDismissal onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[480px]! max-w-none! flex-col overflow-hidden! rounded-2xl! border-[0.5px]! border-components-panel-border! bg-components-panel-bg! p-0! shadow-xl!"> <div className="relative flex min-h-0 w-full flex-1 flex-col items-start"> <div className="flex shrink-0 flex-col items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> <DialogTitle className="grow self-stretch title-2xl-semi-bold text-text-primary"> - {isEditMode ? t($ => $.editExternalAPIFormTitle, { ns: 'dataset' }) : t($ => $.createExternalAPI, { ns: 'dataset' })} + {isEditMode + ? t(($) => $.editExternalAPIFormTitle, { ns: 'dataset' }) + : t(($) => $.createExternalAPI, { ns: 'dataset' })} </DialogTitle> {isEditMode && (datasetBindings?.length ?? 0) > 0 && ( <div className="flex items-center system-xs-regular text-text-tertiary"> - {t($ => $['editExternalAPIFormWarning.front'], { ns: 'dataset' })} + {t(($) => $['editExternalAPIFormWarning.front'], { ns: 'dataset' })} <span className="flex cursor-pointer items-center text-text-accent">   - {datasetBindings?.length} - {' '} - {t($ => $['editExternalAPIFormWarning.end'], { ns: 'dataset' })} -  + {datasetBindings?.length}{' '} + {t(($) => $['editExternalAPIFormWarning.end'], { ns: 'dataset' })} +   <Popover> <PopoverTrigger openOnHover - aria-label={t($ => $['editExternalAPIFormWarning.end'], { ns: 'dataset' })} - render={( + aria-label={t(($) => $['editExternalAPIFormWarning.end'], { ns: 'dataset' })} + render={ <button type="button" className="flex size-3.5 items-center justify-center rounded-sm outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover" > <RiInformation2Line className="size-3.5" /> </button> - )} + } /> <PopoverContent placement="bottom" @@ -155,12 +164,17 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan > <div className="p-1"> <div className="flex items-start self-stretch pt-1 pr-3 pb-0.5 pl-2"> - <div className="system-xs-medium-uppercase text-text-tertiary">{`${datasetBindings?.length} ${t($ => $.editExternalAPITooltipTitle, { ns: 'dataset' })}`}</div> + <div className="system-xs-medium-uppercase text-text-tertiary">{`${datasetBindings?.length} ${t(($) => $.editExternalAPITooltipTitle, { ns: 'dataset' })}`}</div> </div> - {datasetBindings?.map(binding => ( - <div key={binding.id} className="flex items-center gap-1 self-stretch px-2 py-1"> + {datasetBindings?.map((binding) => ( + <div + key={binding.id} + className="flex items-center gap-1 self-stretch px-2 py-1" + > <RiBook2Line className="size-4 text-text-secondary" /> - <div className="system-sm-medium text-text-secondary">{binding.name}</div> + <div className="system-sm-medium text-text-secondary"> + {binding.name} + </div> </div> ))} </div> @@ -173,41 +187,46 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan <ActionButton className="absolute top-5 right-5" onClick={onCancel}> <RiCloseLine className="h-[18px] w-[18px] shrink-0 text-text-tertiary" /> </ActionButton> - <Form value={formData} onChange={handleDataChange} formSchemas={formSchemas} className="min-h-0 w-full flex-1 overflow-y-auto px-6 py-3" /> + <Form + value={formData} + onChange={handleDataChange} + formSchemas={formSchemas} + className="min-h-0 w-full flex-1 overflow-y-auto px-6 py-3" + /> <div className="flex shrink-0 items-center justify-end gap-2 self-stretch p-6 pt-5"> <Button type="button" variant="secondary" onClick={onCancel}> - {t($ => $['externalAPIForm.cancel'], { ns: 'dataset' })} + {t(($) => $['externalAPIForm.cancel'], { ns: 'dataset' })} </Button> <Button type="submit" variant="primary" onClick={() => { - if (isEditMode && (datasetBindings?.length ?? 0) > 0) - setShowConfirm(true) - else if (isEditMode && onEdit) - onEdit(formData) - else - handleSave() + if (isEditMode && (datasetBindings?.length ?? 0) > 0) setShowConfirm(true) + else if (isEditMode && onEdit) onEdit(formData) + else handleSave() }} disabled={hasEmptyInputs || loading} > - {t($ => $['externalAPIForm.save'], { ns: 'dataset' })} + {t(($) => $['externalAPIForm.save'], { ns: 'dataset' })} </Button> </div> - <div className="flex shrink-0 items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px] border-divider-subtle - bg-background-soft px-2 py-3 system-xs-regular text-text-tertiary" - > + <div className="flex shrink-0 items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px] border-divider-subtle bg-background-soft px-2 py-3 system-xs-regular text-text-tertiary"> <RiLock2Fill className="size-3 text-text-quaternary" /> - {t($ => $['externalAPIForm.encrypted.front'], { ns: 'dataset' })} - <a className="text-text-accent" target="_blank" rel="noopener noreferrer" href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html"> + {t(($) => $['externalAPIForm.encrypted.front'], { ns: 'dataset' })} + <a + className="text-text-accent" + target="_blank" + rel="noopener noreferrer" + href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html" + > PKCS1_OAEP </a> - {t($ => $['externalAPIForm.encrypted.end'], { ns: 'dataset' })} + {t(($) => $['externalAPIForm.encrypted.end'], { ns: 'dataset' })} </div> </div> <AlertDialog open={showConfirm && (datasetBindings?.length ?? 0) > 0} - onOpenChange={open => !open && setShowConfirm(false)} + onOpenChange={(open) => !open && setShowConfirm(false)} > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> @@ -215,13 +234,15 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan Warning </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {`${t($ => $['editExternalAPIConfirmWarningContent.front'], { ns: 'dataset' })} ${datasetBindings?.length} ${t($ => $['editExternalAPIConfirmWarningContent.end'], { ns: 'dataset' })}`} + {`${t(($) => $['editExternalAPIConfirmWarningContent.front'], { ns: 'dataset' })} ${datasetBindings?.length} ${t(($) => $['editExternalAPIConfirmWarningContent.end'], { ns: 'dataset' })}`} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={handleSave}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/datasets/external-api/external-api-panel/__tests__/index.spec.tsx b/web/app/components/datasets/external-api/external-api-panel/__tests__/index.spec.tsx index aa32061addd313..e94b5d787b2ebf 100644 --- a/web/app/components/datasets/external-api/external-api-panel/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-api/external-api-panel/__tests__/index.spec.tsx @@ -29,8 +29,19 @@ vi.mock('@/context/external-knowledge-api-context', () => ({ // Mock the ExternalKnowledgeAPICard to avoid mocking its internal dependencies vi.mock('../../external-knowledge-api-card', () => ({ - default: ({ api, canManageExternalKnowledgeApi }: { api: ExternalAPIItem, canManageExternalKnowledgeApi: boolean }) => ( - <div data-testid={`api-card-${api.id}`} data-can-manage-external-knowledge-api={canManageExternalKnowledgeApi}>{api.name}</div> + default: ({ + api, + canManageExternalKnowledgeApi, + }: { + api: ExternalAPIItem + canManageExternalKnowledgeApi: boolean + }) => ( + <div + data-testid={`api-card-${api.id}`} + data-can-manage-external-knowledge-api={canManageExternalKnowledgeApi} + > + {api.name} + </div> ), })) @@ -64,7 +75,10 @@ describe('ExternalAPIPanel', () => { render(<ExternalAPIPanel {...defaultProps} />) const docLink = screen.getByText('dataset.externalAPIPanelDocumentation') expect(docLink)!.toBeInTheDocument() - expect(docLink.closest('a'))!.toHaveAttribute('href', 'https://docs.example.com/use-dify/knowledge/external-knowledge-api') + expect(docLink.closest('a'))!.toHaveAttribute( + 'href', + 'https://docs.example.com/use-dify/knowledge/external-knowledge-api', + ) }) it('should render create button', () => { @@ -79,7 +93,8 @@ describe('ExternalAPIPanel', () => { it('should render close button', () => { const { container } = render(<ExternalAPIPanel {...defaultProps} />) - const closeButton = container.querySelector('[class*="action-button"]') || screen.getAllByRole('button')[0] + const closeButton = + container.querySelector('[class*="action-button"]') || screen.getAllByRole('button')[0] expect(closeButton)!.toBeInTheDocument() }) }) @@ -89,9 +104,10 @@ describe('ExternalAPIPanel', () => { mockIsLoading = true const { container } = render(<ExternalAPIPanel {...defaultProps} />) // Loading component should be rendered - const loadingElement = container.querySelector('[class*="loading"]') - || container.querySelector('.animate-spin') - || screen.queryByRole('status') + const loadingElement = + container.querySelector('[class*="loading"]') || + container.querySelector('.animate-spin') || + screen.queryByRole('status') expect(loadingElement || container.textContent).toBeTruthy() }) }) @@ -129,7 +145,10 @@ describe('ExternalAPIPanel', () => { render(<ExternalAPIPanel {...defaultProps} />) expect(screen.getByTestId('api-card-api-1'))!.toBeInTheDocument() expect(screen.getByTestId('api-card-api-2'))!.toBeInTheDocument() - expect(screen.getByTestId('api-card-api-1')).toHaveAttribute('data-can-manage-external-knowledge-api', 'true') + expect(screen.getByTestId('api-card-api-1')).toHaveAttribute( + 'data-can-manage-external-knowledge-api', + 'true', + ) expect(screen.getByText('Test API 1'))!.toBeInTheDocument() expect(screen.getByText('Test API 2'))!.toBeInTheDocument() }) @@ -141,8 +160,8 @@ describe('ExternalAPIPanel', () => { render(<ExternalAPIPanel canManageExternalKnowledgeApi={true} onClose={onClose} />) // Find the close button (ActionButton with close icon) const buttons = screen.getAllByRole('button') - const closeButton = buttons.find(btn => btn.querySelector('svg[class*="ri-close"]')) - || buttons[0] + const closeButton = + buttons.find((btn) => btn.querySelector('svg[class*="ri-close"]')) || buttons[0] fireEvent.click(closeButton!) expect(onClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/external-api/external-api-panel/index.tsx b/web/app/components/datasets/external-api/external-api-panel/index.tsx index eeef9b1876a9cc..71276a442e75a1 100644 --- a/web/app/components/datasets/external-api/external-api-panel/index.tsx +++ b/web/app/components/datasets/external-api/external-api-panel/index.tsx @@ -1,10 +1,6 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - RiAddLine, - RiBookOpenLine, - RiCloseLine, -} from '@remixicon/react' +import { RiAddLine, RiBookOpenLine, RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' @@ -19,15 +15,18 @@ type ExternalAPIPanelProps = { onClose: () => void } -const ExternalAPIPanel: React.FC<ExternalAPIPanelProps> = ({ canManageExternalKnowledgeApi, onClose }) => { +const ExternalAPIPanel: React.FC<ExternalAPIPanelProps> = ({ + canManageExternalKnowledgeApi, + onClose, +}) => { const { t } = useTranslation() const docLink = useDocLink() const { setShowExternalKnowledgeAPIModal } = useModalContext() - const { externalKnowledgeApiList, mutateExternalKnowledgeApis, isLoading } = useExternalKnowledgeApi() + const { externalKnowledgeApiList, mutateExternalKnowledgeApis, isLoading } = + useExternalKnowledgeApi() const handleOpenExternalAPIModal = () => { - if (!canManageExternalKnowledgeApi) - return + if (!canManageExternalKnowledgeApi) return setShowExternalKnowledgeAPIModal({ payload: { name: '', settings: { endpoint: '', api_key: '' } }, @@ -43,10 +42,7 @@ const ExternalAPIPanel: React.FC<ExternalAPIPanelProps> = ({ canManageExternalKn } return ( - <div - tabIndex={-1} - className={cn('absolute top-14 right-0 bottom-2 z-10 flex outline-hidden')} - > + <div tabIndex={-1} className={cn('absolute top-14 right-0 bottom-2 z-10 flex outline-hidden')}> <div className={cn( 'relative flex h-full w-[420px] flex-col rounded-l-2xl border border-components-panel-border bg-components-panel-bg-alt', @@ -54,15 +50,21 @@ const ExternalAPIPanel: React.FC<ExternalAPIPanelProps> = ({ canManageExternalKn > <div className="flex items-start self-stretch p-4 pb-0"> <div className="flex grow flex-col items-start gap-1"> - <div className="self-stretch system-xl-semibold text-text-primary">{t($ => $.externalAPIPanelTitle, { ns: 'dataset' })}</div> - <div className="self-stretch body-xs-regular text-text-tertiary">{t($ => $.externalAPIPanelDescription, { ns: 'dataset' })}</div> + <div className="self-stretch system-xl-semibold text-text-primary"> + {t(($) => $.externalAPIPanelTitle, { ns: 'dataset' })} + </div> + <div className="self-stretch body-xs-regular text-text-tertiary"> + {t(($) => $.externalAPIPanelDescription, { ns: 'dataset' })} + </div> <a className="flex cursor-pointer items-center justify-center gap-1 self-stretch" href={docLink('/use-dify/knowledge/external-knowledge-api')} target="_blank" > <RiBookOpenLine className="size-3 text-text-accent" /> - <div className="grow body-xs-regular text-text-accent">{t($ => $.externalAPIPanelDocumentation, { ns: 'dataset' })}</div> + <div className="grow body-xs-regular text-text-accent"> + {t(($) => $.externalAPIPanelDocumentation, { ns: 'dataset' })} + </div> </a> </div> <div className="flex items-center"> @@ -79,24 +81,24 @@ const ExternalAPIPanel: React.FC<ExternalAPIPanelProps> = ({ canManageExternalKn onClick={handleOpenExternalAPIModal} > <RiAddLine className="size-4 text-components-button-primary-text" /> - <div className="system-sm-medium text-components-button-primary-text">{t($ => $.createExternalAPI, { ns: 'dataset' })}</div> + <div className="system-sm-medium text-components-button-primary-text"> + {t(($) => $.createExternalAPI, { ns: 'dataset' })} + </div> </Button> </div> )} <div className="flex grow flex-col items-start gap-1 self-stretch px-4 py-0"> - {isLoading - ? ( - <Loading /> - ) - : ( - externalKnowledgeApiList.map(api => ( - <ExternalKnowledgeAPICard - key={api.id} - api={api} - canManageExternalKnowledgeApi={canManageExternalKnowledgeApi} - /> - )) - )} + {isLoading ? ( + <Loading /> + ) : ( + externalKnowledgeApiList.map((api) => ( + <ExternalKnowledgeAPICard + key={api.id} + api={api} + canManageExternalKnowledgeApi={canManageExternalKnowledgeApi} + /> + )) + )} </div> </div> </div> diff --git a/web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx b/web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx index 93edb4e898db25..501a2be17907f0 100644 --- a/web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx @@ -3,7 +3,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' // Import mocked services import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI } from '@/service/datasets' - import ExternalKnowledgeAPICard from '../index' // Mock API services @@ -77,12 +76,9 @@ describe('ExternalKnowledgeAPICard', () => { }) it('should hide edit and delete buttons when external knowledge API management is unavailable', () => { - const { container } = render(( - <ExternalKnowledgeAPICard - {...defaultProps} - canManageExternalKnowledgeApi={false} - /> - )) + const { container } = render( + <ExternalKnowledgeAPICard {...defaultProps} canManageExternalKnowledgeApi={false} />, + ) expect(container.querySelectorAll('button').length).toBe(0) }) @@ -363,7 +359,12 @@ describe('ExternalKnowledgeAPICard', () => { ...mockApi, settings: { endpoint: '', api_key: 'key' }, } - render(<ExternalKnowledgeAPICard api={apiWithEmptyEndpoint} canManageExternalKnowledgeApi={true} />) + render( + <ExternalKnowledgeAPICard + api={apiWithEmptyEndpoint} + canManageExternalKnowledgeApi={true} + />, + ) expect(screen.getByText('Test External API'))!.toBeInTheDocument() }) diff --git a/web/app/components/datasets/external-api/external-knowledge-api-card/index.tsx b/web/app/components/datasets/external-api/external-knowledge-api-card/index.tsx index 0f2bf932b0b16a..71fd8b7a91006b 100644 --- a/web/app/components/datasets/external-api/external-knowledge-api-card/index.tsx +++ b/web/app/components/datasets/external-api/external-knowledge-api-card/index.tsx @@ -9,10 +9,7 @@ import { AlertDialogDescription, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' -import { - RiDeleteBinLine, - RiEditLine, -} from '@remixicon/react' +import { RiDeleteBinLine, RiEditLine } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,14 +17,22 @@ import ActionButton from '@/app/components/base/action-button' import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development' import { useExternalKnowledgeApi } from '@/context/external-knowledge-api-context' import { useModalContext } from '@/context/modal-context' -import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI, updateExternalAPI } from '@/service/datasets' +import { + checkUsageExternalAPI, + deleteExternalAPI, + fetchExternalAPI, + updateExternalAPI, +} from '@/service/datasets' type ExternalKnowledgeAPICardProps = { api: ExternalAPIItem canManageExternalKnowledgeApi: boolean } -const ExternalKnowledgeAPICard: React.FC<ExternalKnowledgeAPICardProps> = ({ api, canManageExternalKnowledgeApi }) => { +const ExternalKnowledgeAPICard: React.FC<ExternalKnowledgeAPICardProps> = ({ + api, + canManageExternalKnowledgeApi, +}) => { const { setShowExternalKnowledgeAPIModal } = useModalContext() const [showConfirm, setShowConfirm] = useState(false) const [isHovered, setIsHovered] = useState(false) @@ -37,8 +42,7 @@ const ExternalKnowledgeAPICard: React.FC<ExternalKnowledgeAPICardProps> = ({ api const { t } = useTranslation() const handleEditClick = async () => { - if (!canManageExternalKnowledgeApi) - return + if (!canManageExternalKnowledgeApi) return try { const response = await fetchExternalAPI({ apiTemplateId: api.id }) @@ -75,65 +79,58 @@ const ExternalKnowledgeAPICard: React.FC<ExternalKnowledgeAPICardProps> = ({ api }, }) mutateExternalKnowledgeApis() - } - catch (error) { + } catch (error) { console.error('Error updating external knowledge API:', error) } }, }) - } - catch (error) { + } catch (error) { console.error('Error fetching external knowledge API data:', error) } } const handleDeleteClick = async () => { - if (!canManageExternalKnowledgeApi) - return + if (!canManageExternalKnowledgeApi) return try { const usage = await checkUsageExternalAPI({ apiTemplateId: api.id }) - if (usage.is_using) - setUsageCount(usage.count) + if (usage.is_using) setUsageCount(usage.count) setShowConfirm(true) - } - catch (error) { + } catch (error) { console.error('Error checking external API usage:', error) } } const handleConfirmDelete = async () => { - if (!canManageExternalKnowledgeApi) - return + if (!canManageExternalKnowledgeApi) return try { const response = await deleteExternalAPI({ apiTemplateId: api.id }) if (response && response.result === 'success') { setShowConfirm(false) mutateExternalKnowledgeApis() - } - else { + } else { console.error('Failed to delete external API') } - } - catch (error) { + } catch (error) { console.error('Error deleting external knowledge API:', error) } } return ( <> - <div className={`shadows-shadow-xs flex items-start self-stretch rounded-lg border-[0.5px] border-components-panel-border-subtle - bg-components-panel-on-panel-item-bg p-2 - pl-3 ${isHovered ? 'border-state-destructive-border bg-state-destructive-hover' : ''}`} + <div + className={`shadows-shadow-xs flex items-start self-stretch rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-2 pl-3 ${isHovered ? 'border-state-destructive-border bg-state-destructive-hover' : ''}`} > <div className="flex grow flex-col items-start justify-center gap-1.5 py-1"> <div className="flex items-center gap-1 self-stretch text-text-secondary"> <ApiConnectionMod className="size-4" /> <div className="system-sm-medium">{api.name}</div> </div> - <div className="self-stretch system-xs-regular text-text-tertiary">{api.settings.endpoint}</div> + <div className="self-stretch system-xs-regular text-text-tertiary"> + {api.settings.endpoint} + </div> </div> {canManageExternalKnowledgeApi && ( <div className="flex items-start gap-1"> @@ -151,22 +148,26 @@ const ExternalKnowledgeAPICard: React.FC<ExternalKnowledgeAPICardProps> = ({ api </div> )} </div> - <AlertDialog open={showConfirm} onOpenChange={open => !open && setShowConfirm(false)}> + <AlertDialog open={showConfirm} onOpenChange={(open) => !open && setShowConfirm(false)}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {`${t($ => $['deleteExternalAPIConfirmWarningContent.title.front'], { ns: 'dataset' })} ${api.name}${t($ => $['deleteExternalAPIConfirmWarningContent.title.end'], { ns: 'dataset' })}`} + {`${t(($) => $['deleteExternalAPIConfirmWarningContent.title.front'], { ns: 'dataset' })} ${api.name}${t(($) => $['deleteExternalAPIConfirmWarningContent.title.end'], { ns: 'dataset' })}`} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> {usageCount > 0 - ? `${t($ => $['deleteExternalAPIConfirmWarningContent.content.front'], { ns: 'dataset' })} ${usageCount} ${t($ => $['deleteExternalAPIConfirmWarningContent.content.end'], { ns: 'dataset' })}` - : t($ => $['deleteExternalAPIConfirmWarningContent.noConnectionContent'], { ns: 'dataset' })} + ? `${t(($) => $['deleteExternalAPIConfirmWarningContent.content.front'], { ns: 'dataset' })} ${usageCount} ${t(($) => $['deleteExternalAPIConfirmWarningContent.content.end'], { ns: 'dataset' })}` + : t(($) => $['deleteExternalAPIConfirmWarningContent.noConnectionContent'], { + ns: 'dataset', + })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={handleConfirmDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx index 7da605725cb985..fca2f5aca38317 100644 --- a/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx @@ -109,7 +109,9 @@ async function fillFormAndSubmit(user: ReturnType<typeof userEvent.setup>) { // Wait for button to be enabled await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) @@ -143,7 +145,9 @@ describe('ExternalKnowledgeBaseConnector', () => { it('should render connect button disabled initially', () => { render(<ExternalKnowledgeBaseConnector />) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).toBeDisabled() }) }) @@ -169,7 +173,9 @@ describe('ExternalKnowledgeBaseConnector', () => { }) // Verify success notification - expect(mockToastSuccess).toHaveBeenCalledWith('dataset.externalKnowledgeForm.connectedSuccess') + expect(mockToastSuccess).toHaveBeenCalledWith( + 'dataset.externalKnowledgeForm.connectedSuccess', + ) // Verify navigation back expect(mockRouterBack).toHaveBeenCalledTimes(1) @@ -254,11 +260,15 @@ describe('ExternalKnowledgeBaseConnector', () => { fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } }) await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') await user.click(connectButton!) // Button should show loading (the real Button component has loading prop) @@ -270,7 +280,9 @@ describe('ExternalKnowledgeBaseConnector', () => { resolvePromise({ id: 'new-id' }) await waitFor(() => { - expect(mockToastSuccess).toHaveBeenCalledWith('dataset.externalKnowledgeForm.connectedSuccess') + expect(mockToastSuccess).toHaveBeenCalledWith( + 'dataset.externalKnowledgeForm.connectedSuccess', + ) }) }) }) @@ -283,7 +295,9 @@ describe('ExternalKnowledgeBaseConnector', () => { const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') fireEvent.change(nameInput, { target: { value: 'Test' } }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).toBeDisabled() }) @@ -293,7 +307,9 @@ describe('ExternalKnowledgeBaseConnector', () => { const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).toBeDisabled() }) @@ -307,7 +323,9 @@ describe('ExternalKnowledgeBaseConnector', () => { fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } }) await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) }) @@ -320,7 +338,9 @@ describe('ExternalKnowledgeBaseConnector', () => { render(<ExternalKnowledgeBaseConnector />) const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') - const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') + const descriptionInput = screen.getByPlaceholderText( + 'dataset.externalKnowledgeDescriptionPlaceholder', + ) await user.type(nameInput, 'My Knowledge Base') await user.type(descriptionInput, 'My Description') @@ -333,7 +353,9 @@ describe('ExternalKnowledgeBaseConnector', () => { const user = userEvent.setup() render(<ExternalKnowledgeBaseConnector />) - const cancelButton = screen.getByText('dataset.externalKnowledgeForm.cancel').closest('button') + const cancelButton = screen + .getByText('dataset.externalKnowledgeForm.cancel') + .closest('button') await user.click(cancelButton!) expect(mockReplace).toHaveBeenCalledWith('/datasets') @@ -344,7 +366,7 @@ describe('ExternalKnowledgeBaseConnector', () => { render(<ExternalKnowledgeBaseConnector />) const buttons = screen.getAllByRole('button') - const backButton = buttons.find(btn => btn.classList.contains('rounded-full')) + const backButton = buttons.find((btn) => btn.classList.contains('rounded-full')) await user.click(backButton!) expect(mockReplace).toHaveBeenCalledWith('/datasets') diff --git a/web/app/components/datasets/external-knowledge-base/connector/index.tsx b/web/app/components/datasets/external-knowledge-base/connector/index.tsx index 524d2590a1a1e7..fe5c9a56f2ada8 100644 --- a/web/app/components/datasets/external-knowledge-base/connector/index.tsx +++ b/web/app/components/datasets/external-knowledge-base/connector/index.tsx @@ -20,24 +20,22 @@ const ExternalKnowledgeBaseConnector = () => { setLoading(true) const result = await createExternalKnowledgeBase({ body: formValue }) if (result && result.id) { - toast.success(t($ => $['externalKnowledgeForm.connectedSuccess'], { ns: 'dataset' })) + toast.success(t(($) => $['externalKnowledgeForm.connectedSuccess'], { ns: 'dataset' })) trackEvent('create_external_knowledge_base', { provider: formValue.provider, name: formValue.name, }) router.back() + } else { + throw new Error('Failed to create external knowledge base') } - else { throw new Error('Failed to create external knowledge base') } - } - catch (error) { + } catch (error) { console.error('Error creating external knowledge base:', error) - toast.error(t($ => $['externalKnowledgeForm.connectedFailed'], { ns: 'dataset' })) + toast.error(t(($) => $['externalKnowledgeForm.connectedFailed'], { ns: 'dataset' })) } setLoading(false) } - return ( - <ExternalKnowledgeBaseCreate onConnect={handleConnect} loading={loading} /> - ) + return <ExternalKnowledgeBaseCreate onConnect={handleConnect} loading={loading} /> } export default ExternalKnowledgeBaseConnector diff --git a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx index 6cdbbd86865baa..2dc8ca62b4b533 100644 --- a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx @@ -1,7 +1,4 @@ -import { - RiAddLine, - RiArrowDownSLine, -} from '@remixicon/react' +import { RiAddLine, RiArrowDownSLine } from '@remixicon/react' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -26,14 +23,14 @@ const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onS const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) const [selectedItem, setSelectedItem] = useState<ApiItem | null>( - items.find(item => item.value === value) || null, + items.find((item) => item.value === value) || null, ) const { setShowExternalKnowledgeAPIModal } = useModalContext() const { mutateExternalKnowledgeApis } = useExternalKnowledgeApi() const router = useRouter() useEffect(() => { - const newSelectedItem = items.find(item => item.value === value) || null + const newSelectedItem = items.find((item) => item.value === value) || null setSelectedItem(newSelectedItem) }, [value, items]) @@ -60,27 +57,30 @@ const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onS return ( <div className="relative w-full"> <div - className={`flex cursor-pointer items-center justify-between gap-0.5 self-stretch rounded-lg bg-components-input-bg-normal px-2 - py-1 hover:bg-state-base-hover-alt ${isOpen && 'bg-state-base-hover-alt'}`} + className={`flex cursor-pointer items-center justify-between gap-0.5 self-stretch rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt ${isOpen && 'bg-state-base-hover-alt'}`} onClick={() => setIsOpen(!isOpen)} > - {selectedItem - ? ( - <div className="flex items-center gap-2 self-stretch rounded-lg p-1"> - <ApiConnectionMod className="size-4 text-text-secondary" /> - <div className="flex grow items-center"> - <span className="overflow-hidden system-sm-regular text-ellipsis text-components-input-text-filled">{selectedItem.name}</span> - </div> - </div> - ) - : ( - <span className="system-sm-regular text-components-input-text-placeholder">{t($ => $['selectExternalKnowledgeAPI.placeholder'], { ns: 'dataset' })}</span> - )} - <RiArrowDownSLine className={`size-4 text-text-quaternary transition-transform ${isOpen ? 'text-text-secondary' : ''}`} /> + {selectedItem ? ( + <div className="flex items-center gap-2 self-stretch rounded-lg p-1"> + <ApiConnectionMod className="size-4 text-text-secondary" /> + <div className="flex grow items-center"> + <span className="overflow-hidden system-sm-regular text-ellipsis text-components-input-text-filled"> + {selectedItem.name} + </span> + </div> + </div> + ) : ( + <span className="system-sm-regular text-components-input-text-placeholder"> + {t(($) => $['selectExternalKnowledgeAPI.placeholder'], { ns: 'dataset' })} + </span> + )} + <RiArrowDownSLine + className={`size-4 text-text-quaternary transition-transform ${isOpen ? 'text-text-secondary' : ''}`} + /> </div> {isOpen && ( <div className="absolute z-10 mt-1 w-full rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg"> - {items.map(item => ( + {items.map((item) => ( <div key={item.value} className="flex cursor-pointer items-center p-1" @@ -88,8 +88,12 @@ const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onS > <div className="flex w-full items-center gap-2 self-stretch rounded-lg p-2 hover:bg-state-base-hover"> <ApiConnectionMod className="size-4 text-text-secondary" /> - <span className="grow overflow-hidden system-sm-medium text-ellipsis text-text-secondary">{item.name}</span> - <span className="overflow-hidden text-right system-xs-regular text-ellipsis text-text-tertiary">{item.url}</span> + <span className="grow overflow-hidden system-sm-medium text-ellipsis text-text-secondary"> + {item.name} + </span> + <span className="overflow-hidden text-right system-xs-regular text-ellipsis text-text-tertiary"> + {item.url} + </span> </div> </div> ))} @@ -99,7 +103,9 @@ const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onS onClick={handleAddNewAPI} > <RiAddLine className="size-4 text-text-secondary" /> - <span className="grow overflow-hidden system-sm-medium text-ellipsis text-text-secondary">{t($ => $.createNewExternalAPI, { ns: 'dataset' })}</span> + <span className="grow overflow-hidden system-sm-medium text-ellipsis text-text-secondary"> + {t(($) => $.createNewExternalAPI, { ns: 'dataset' })} + </span> </div> </div> </div> diff --git a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx index 7f7eb2f251f2b6..6cee272f8413a1 100644 --- a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx @@ -14,10 +14,14 @@ import ExternalApiSelect from './ExternalApiSelect' type ExternalApiSelectionProps = { external_knowledge_api_id: string external_knowledge_id: string - onChange: (data: { external_knowledge_api_id?: string, external_knowledge_id?: string }) => void + onChange: (data: { external_knowledge_api_id?: string; external_knowledge_id?: string }) => void } -const ExternalApiSelection: React.FC<ExternalApiSelectionProps> = ({ external_knowledge_api_id, external_knowledge_id, onChange }) => { +const ExternalApiSelection: React.FC<ExternalApiSelectionProps> = ({ + external_knowledge_api_id, + external_knowledge_id, + onChange, +}) => { const { t } = useTranslation() const router = useRouter() const { externalKnowledgeApiList } = useExternalKnowledgeApi() @@ -25,7 +29,7 @@ const ExternalApiSelection: React.FC<ExternalApiSelectionProps> = ({ external_kn const { setShowExternalKnowledgeAPIModal } = useModalContext() const { mutateExternalKnowledgeApis } = useExternalKnowledgeApi() - const apiItems = externalKnowledgeApiList.map(api => ({ + const apiItems = externalKnowledgeApiList.map((api) => ({ value: api.id, name: api.name, url: api.settings.endpoint, @@ -63,34 +67,40 @@ const ExternalApiSelection: React.FC<ExternalApiSelectionProps> = ({ external_kn <form className="flex flex-col gap-4 self-stretch"> <div className="flex flex-col gap-1 self-stretch"> <div className="flex flex-col self-stretch"> - <label className="system-sm-semibold text-text-secondary">{t($ => $.externalAPIPanelTitle, { ns: 'dataset' })}</label> + <label className="system-sm-semibold text-text-secondary"> + {t(($) => $.externalAPIPanelTitle, { ns: 'dataset' })} + </label> </div> - {apiItems.length > 0 - ? ( - <ExternalApiSelect - items={apiItems} - value={selectedApiId} - onSelect={(e) => { - setSelectedApiId(e.value) - onChange({ external_knowledge_api_id: e.value, external_knowledge_id }) - }} - /> - ) - : ( - <Button variant="tertiary" onClick={handleAddNewAPI} className="justify-start gap-0.5"> - <RiAddLine className="size-4 text-text-tertiary" /> - <span className="system-sm-regular text-text-tertiary">{t($ => $.noExternalKnowledge, { ns: 'dataset' })}</span> - </Button> - )} + {apiItems.length > 0 ? ( + <ExternalApiSelect + items={apiItems} + value={selectedApiId} + onSelect={(e) => { + setSelectedApiId(e.value) + onChange({ external_knowledge_api_id: e.value, external_knowledge_id }) + }} + /> + ) : ( + <Button variant="tertiary" onClick={handleAddNewAPI} className="justify-start gap-0.5"> + <RiAddLine className="size-4 text-text-tertiary" /> + <span className="system-sm-regular text-text-tertiary"> + {t(($) => $.noExternalKnowledge, { ns: 'dataset' })} + </span> + </Button> + )} </div> <div className="flex flex-col gap-1 self-stretch"> <div className="flex flex-col self-stretch"> - <label className="system-sm-semibold text-text-secondary">{t($ => $.externalKnowledgeId, { ns: 'dataset' })}</label> + <label className="system-sm-semibold text-text-secondary"> + {t(($) => $.externalKnowledgeId, { ns: 'dataset' })} + </label> </div> <Input value={external_knowledge_id} - onChange={e => onChange({ external_knowledge_id: e.target.value, external_knowledge_api_id })} - placeholder={t($ => $.externalKnowledgeIdPlaceholder, { ns: 'dataset' }) ?? ''} + onChange={(e) => + onChange({ external_knowledge_id: e.target.value, external_knowledge_api_id }) + } + placeholder={t(($) => $.externalKnowledgeIdPlaceholder, { ns: 'dataset' }) ?? ''} /> </div> </form> diff --git a/web/app/components/datasets/external-knowledge-base/create/InfoPanel.tsx b/web/app/components/datasets/external-knowledge-base/create/InfoPanel.tsx index 3fe1b663f9b4fb..83c03866c41342 100644 --- a/web/app/components/datasets/external-knowledge-base/create/InfoPanel.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/InfoPanel.tsx @@ -14,14 +14,19 @@ const InfoPanel = () => { </div> <p className="flex flex-col items-start gap-2 self-stretch"> <span className="self-stretch system-xl-semibold text-text-secondary"> - {t($ => $['connectDatasetIntro.title'], { ns: 'dataset' })} + {t(($) => $['connectDatasetIntro.title'], { ns: 'dataset' })} </span> <span className="system-sm-regular text-text-tertiary"> - {t($ => $['connectDatasetIntro.content.front'], { ns: 'dataset' })} - <a className="ml-1 system-sm-regular text-text-accent" href={docLink('/use-dify/knowledge/external-knowledge-api')} target="_blank" rel="noopener noreferrer"> - {t($ => $['connectDatasetIntro.content.link'], { ns: 'dataset' })} + {t(($) => $['connectDatasetIntro.content.front'], { ns: 'dataset' })} + <a + className="ml-1 system-sm-regular text-text-accent" + href={docLink('/use-dify/knowledge/external-knowledge-api')} + target="_blank" + rel="noopener noreferrer" + > + {t(($) => $['connectDatasetIntro.content.link'], { ns: 'dataset' })} </a> - {t($ => $['connectDatasetIntro.content.end'], { ns: 'dataset' })} + {t(($) => $['connectDatasetIntro.content.end'], { ns: 'dataset' })} </span> <a className="self-stretch system-sm-regular text-text-accent" @@ -29,7 +34,7 @@ const InfoPanel = () => { target="_blank" rel="noopener noreferrer" > - {t($ => $['connectDatasetIntro.learnMore'], { ns: 'dataset' })} + {t(($) => $['connectDatasetIntro.learnMore'], { ns: 'dataset' })} </a> </p> </div> diff --git a/web/app/components/datasets/external-knowledge-base/create/KnowledgeBaseInfo.tsx b/web/app/components/datasets/external-knowledge-base/create/KnowledgeBaseInfo.tsx index 893654052a2650..dc8aeac203aad9 100644 --- a/web/app/components/datasets/external-knowledge-base/create/KnowledgeBaseInfo.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/KnowledgeBaseInfo.tsx @@ -5,7 +5,7 @@ import Input from '@/app/components/base/input' type KnowledgeBaseInfoProps = { name: string description?: string - onChange: (data: { name?: string, description?: string }) => void + onChange: (data: { name?: string; description?: string }) => void } const KnowledgeBaseInfo: React.FC<KnowledgeBaseInfoProps> = ({ name, description, onChange }) => { @@ -24,23 +24,29 @@ const KnowledgeBaseInfo: React.FC<KnowledgeBaseInfoProps> = ({ name, description <div className="flex flex-col gap-4 self-stretch"> <div className="flex flex-col gap-1 self-stretch"> <div className="flex flex-col justify-center self-stretch"> - <label className="system-sm-semibold text-text-secondary">{t($ => $.externalKnowledgeName, { ns: 'dataset' })}</label> + <label className="system-sm-semibold text-text-secondary"> + {t(($) => $.externalKnowledgeName, { ns: 'dataset' })} + </label> </div> <Input value={name} onChange={handleNameChange} - placeholder={t($ => $.externalKnowledgeNamePlaceholder, { ns: 'dataset' }) ?? ''} + placeholder={t(($) => $.externalKnowledgeNamePlaceholder, { ns: 'dataset' }) ?? ''} /> </div> <div className="flex flex-col gap-1 self-stretch"> <div className="flex flex-col justify-center self-stretch"> - <label className="system-sm-semibold text-text-secondary">{t($ => $.externalKnowledgeDescription, { ns: 'dataset' })}</label> + <label className="system-sm-semibold text-text-secondary"> + {t(($) => $.externalKnowledgeDescription, { ns: 'dataset' })} + </label> </div> <div className="flex flex-col gap-1 self-stretch"> <textarea value={description} - onChange={e => handleDescriptionChange(e)} - placeholder={t($ => $.externalKnowledgeDescriptionPlaceholder, { ns: 'dataset' }) ?? ''} + onChange={(e) => handleDescriptionChange(e)} + placeholder={ + t(($) => $.externalKnowledgeDescriptionPlaceholder, { ns: 'dataset' }) ?? '' + } className={`flex h-20 items-start self-stretch rounded-lg bg-components-input-bg-normal p-3 py-2 ${description ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder'} system-sm-regular`} /> </div> diff --git a/web/app/components/datasets/external-knowledge-base/create/RetrievalSettings.tsx b/web/app/components/datasets/external-knowledge-base/create/RetrievalSettings.tsx index a875b7f9ea0b61..8c0204d52699d4 100644 --- a/web/app/components/datasets/external-knowledge-base/create/RetrievalSettings.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/RetrievalSettings.tsx @@ -12,7 +12,11 @@ type RetrievalSettingsProps = { isInHitTesting?: boolean isInRetrievalSetting?: boolean readonly?: boolean - onChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void + onChange: (data: { + top_k?: number + score_threshold?: number + score_threshold_enabled?: boolean + }) => void } const RetrievalSettings: FC<RetrievalSettingsProps> = ({ @@ -31,20 +35,25 @@ const RetrievalSettings: FC<RetrievalSettingsProps> = ({ } return ( - <div className={cn('flex flex-col gap-2 self-stretch', isInRetrievalSetting && 'w-full max-w-[480px]')}> + <div + className={cn( + 'flex flex-col gap-2 self-stretch', + isInRetrievalSetting && 'w-full max-w-[480px]', + )} + > {!isInHitTesting && !isInRetrievalSetting && ( <div className="flex h-7 flex-col gap-2 self-stretch pt-1"> - <label className="system-sm-semibold text-text-secondary">{t($ => $.retrievalSettings, { ns: 'dataset' })}</label> + <label className="system-sm-semibold text-text-secondary"> + {t(($) => $.retrievalSettings, { ns: 'dataset' })} + </label> </div> )} - <div className={cn( - 'flex gap-4 self-stretch', - { + <div + className={cn('flex gap-4 self-stretch', { 'flex-col': isInHitTesting, 'flex-row': isInRetrievalSetting, 'flex-col sm:flex-row': !isInHitTesting && !isInRetrievalSetting, - }, - )} + })} > <div className="flex grow flex-col gap-1"> <TopKItem diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx index 8d055606b8cfc1..42cfd476b926ee 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx @@ -7,7 +7,11 @@ const mocks = vi.hoisted(() => ({ refresh: vi.fn(), setShowExternalKnowledgeAPIModal: vi.fn(), mutateExternalKnowledgeApis: vi.fn(), - externalKnowledgeApiList: [] as Array<{ id: string, name: string, settings: { endpoint: string } }>, + externalKnowledgeApiList: [] as Array<{ + id: string + name: string + settings: { endpoint: string } + }>, })) vi.mock('@/next/navigation', () => ({ @@ -28,14 +32,27 @@ vi.mock('@/context/external-knowledge-api-context', () => ({ })) // Mock ExternalApiSelect as simple stub -type MockSelectItem = { value: string, name: string } +type MockSelectItem = { value: string; name: string } vi.mock('../ExternalApiSelect', () => ({ - default: ({ items, value, onSelect }: { items: MockSelectItem[], value?: string, onSelect: (item: MockSelectItem) => void }) => ( + default: ({ + items, + value, + onSelect, + }: { + items: MockSelectItem[] + value?: string + onSelect: (item: MockSelectItem) => void + }) => ( <div data-testid="external-api-select"> <span data-testid="select-value">{value}</span> <span data-testid="select-items-count">{items.length}</span> {items.map((item: MockSelectItem) => ( - <button type="button" key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}> + <button + type="button" + key={item.value} + data-testid={`select-${item.value}`} + onClick={() => onSelect(item)} + > {item.name} </button> ))} diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/InfoPanel.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/InfoPanel.spec.tsx index 91701e40538d1b..552cb6e32b60ff 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/InfoPanel.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/InfoPanel.spec.tsx @@ -57,13 +57,19 @@ describe('InfoPanel', () => { it('should have correct href for external knowledge API doc link', () => { render(<InfoPanel />) const docLink = screen.getByText(/connectDatasetIntro\.content\.link/) - expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/use-dify/knowledge/external-knowledge-api') + expect(docLink).toHaveAttribute( + 'href', + 'https://docs.dify.ai/use-dify/knowledge/external-knowledge-api', + ) }) it('should have correct href for learn more link', () => { render(<InfoPanel />) const learnMoreLink = screen.getByText(/connectDatasetIntro\.learnMore/) - expect(learnMoreLink).toHaveAttribute('href', 'https://docs.dify.ai/use-dify/knowledge/connect-external-knowledge-base') + expect(learnMoreLink).toHaveAttribute( + 'href', + 'https://docs.dify.ai/use-dify/knowledge/connect-external-knowledge-base', + ) }) it('should open links in new tab', () => { diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/KnowledgeBaseInfo.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/KnowledgeBaseInfo.spec.tsx index 3e2698ccb62558..de64a0a86e5289 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/KnowledgeBaseInfo.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/KnowledgeBaseInfo.spec.tsx @@ -139,7 +139,9 @@ describe('KnowledgeBaseInfo', () => { }) it('should apply filled text color class when description has content', () => { - const { container } = render(<KnowledgeBaseInfo {...defaultProps} description="has content" />) + const { container } = render( + <KnowledgeBaseInfo {...defaultProps} description="has content" />, + ) const textarea = container.querySelector('textarea') expect(textarea).toHaveClass('text-components-input-text-filled') }) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx index e4da8a1a5abe31..82d7872bcf86b9 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx @@ -3,22 +3,49 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // Mock param items to simplify testing vi.mock('@/app/components/base/param-item/top-k-item', () => ({ - default: ({ value, onChange, enable }: { value: number, onChange: (key: string, val: number) => void, enable: boolean }) => ( + default: ({ + value, + onChange, + enable, + }: { + value: number + onChange: (key: string, val: number) => void + enable: boolean + }) => ( <div data-testid="top-k-item"> <span data-testid="top-k-value">{value}</span> - <button data-testid="top-k-change" onClick={() => onChange('top_k', 8)}>change</button> + <button data-testid="top-k-change" onClick={() => onChange('top_k', 8)}> + change + </button> <span data-testid="top-k-enabled">{String(enable)}</span> </div> ), })) vi.mock('@/app/components/base/param-item/score-threshold-item', () => ({ - default: ({ value, onChange, enable, onSwitchChange }: { value: number, onChange: (key: string, val: number) => void, enable: boolean, onSwitchChange: (key: string, val: boolean) => void }) => ( + default: ({ + value, + onChange, + enable, + onSwitchChange, + }: { + value: number + onChange: (key: string, val: number) => void + enable: boolean + onSwitchChange: (key: string, val: boolean) => void + }) => ( <div data-testid="score-threshold-item"> <span data-testid="score-value">{value}</span> - <button data-testid="score-change" onClick={() => onChange('score_threshold', 0.9)}>change</button> + <button data-testid="score-change" onClick={() => onChange('score_threshold', 0.9)}> + change + </button> <span data-testid="score-enabled">{String(enable)}</span> - <button data-testid="score-switch" onClick={() => onSwitchChange('score_threshold_enabled', true)}>switch</button> + <button + data-testid="score-switch" + onClick={() => onSwitchChange('score_threshold_enabled', true)} + > + switch + </button> </div> ), })) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx index d92bf20da36191..86a83187fc58d6 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx @@ -17,7 +17,8 @@ vi.mock('@/next/navigation', () => ({ // Mock useDocLink hook vi.mock('@/context/i18n', () => ({ - useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`, + useDocLink: () => (path?: string) => + `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`, })) // Mock external context providers (these are external dependencies) @@ -70,7 +71,9 @@ vi.mock('@/context/external-knowledge-api-context', () => ({ })) // Helper to render component with default props -const renderComponent = (props: Partial<React.ComponentProps<typeof ExternalKnowledgeBaseCreate>> = {}) => { +const renderComponent = ( + props: Partial<React.ComponentProps<typeof ExternalKnowledgeBaseCreate>> = {}, +) => { const defaultProps = { onConnect: vi.fn(), loading: false, @@ -79,7 +82,9 @@ const renderComponent = (props: Partial<React.ComponentProps<typeof ExternalKnow } const getVisibleText = (text: string) => { - const element = screen.getAllByText(text).find(element => !element.classList.contains('sr-only')) + const element = screen + .getAllByText(text) + .find((element) => !element.classList.contains('sr-only')) expect(element).toBeDefined() return element! } @@ -155,7 +160,10 @@ describe('ExternalKnowledgeBaseCreate', () => { renderComponent() const docLink = screen.getByText('dataset.connectHelper.helper4') - expect(docLink)!.toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/use-dify/knowledge/connect-external-knowledge-base') + expect(docLink)!.toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/cloud/use-dify/knowledge/connect-external-knowledge-base', + ) expect(docLink)!.toHaveAttribute('target', '_blank') expect(docLink)!.toHaveAttribute('rel', 'noopener noreferrer') }) @@ -166,7 +174,9 @@ describe('ExternalKnowledgeBaseCreate', () => { it('should pass loading prop to connect button', () => { renderComponent({ loading: true }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton)!.toBeInTheDocument() }) @@ -185,11 +195,15 @@ describe('ExternalKnowledgeBaseCreate', () => { // Wait for useEffect to auto-select the first API await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') await user.click(connectButton!) expect(onConnect).toHaveBeenCalledWith( @@ -207,7 +221,9 @@ describe('ExternalKnowledgeBaseCreate', () => { const onConnect = vi.fn() renderComponent({ onConnect }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton)!.toBeDisabled() await user.click(connectButton!) @@ -220,8 +236,12 @@ describe('ExternalKnowledgeBaseCreate', () => { it('should initialize form data with default values', () => { renderComponent() - const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') as HTMLInputElement - const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') as HTMLTextAreaElement + const nameInput = screen.getByPlaceholderText( + 'dataset.externalKnowledgeNamePlaceholder', + ) as HTMLInputElement + const descriptionInput = screen.getByPlaceholderText( + 'dataset.externalKnowledgeDescriptionPlaceholder', + ) as HTMLTextAreaElement expect(nameInput.value).toBe('') expect(descriptionInput.value).toBe('') @@ -239,7 +259,9 @@ describe('ExternalKnowledgeBaseCreate', () => { it('should update description when textarea changes', () => { renderComponent() - const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') + const descriptionInput = screen.getByPlaceholderText( + 'dataset.externalKnowledgeDescriptionPlaceholder', + ) fireEvent.change(descriptionInput, { target: { value: 'New Description' } }) expect((descriptionInput as HTMLTextAreaElement).value).toBe('New Description') @@ -257,7 +279,9 @@ describe('ExternalKnowledgeBaseCreate', () => { it('should apply filled text style when description has value', () => { renderComponent() - const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') as HTMLTextAreaElement + const descriptionInput = screen.getByPlaceholderText( + 'dataset.externalKnowledgeDescriptionPlaceholder', + ) as HTMLTextAreaElement // Initially empty - should have placeholder style expect(descriptionInput.className).toContain('text-components-input-text-placeholder') @@ -270,7 +294,9 @@ describe('ExternalKnowledgeBaseCreate', () => { it('should apply placeholder text style when description is empty', () => { renderComponent() - const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') as HTMLTextAreaElement + const descriptionInput = screen.getByPlaceholderText( + 'dataset.externalKnowledgeDescriptionPlaceholder', + ) as HTMLTextAreaElement // Add then clear description fireEvent.change(descriptionInput, { target: { value: 'Some description' } }) @@ -289,7 +315,9 @@ describe('ExternalKnowledgeBaseCreate', () => { const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-456' } }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton)!.toBeDisabled() }) @@ -302,7 +330,9 @@ describe('ExternalKnowledgeBaseCreate', () => { fireEvent.change(nameInput, { target: { value: ' ' } }) fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-456' } }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton)!.toBeDisabled() }) @@ -312,7 +342,9 @@ describe('ExternalKnowledgeBaseCreate', () => { const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') fireEvent.change(nameInput, { target: { value: 'Test Name' } }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton)!.toBeDisabled() }) @@ -327,7 +359,9 @@ describe('ExternalKnowledgeBaseCreate', () => { // Wait for auto-selection of API await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) }) @@ -340,7 +374,7 @@ describe('ExternalKnowledgeBaseCreate', () => { renderComponent() const buttons = screen.getAllByRole('button') - const backButton = buttons.find(btn => btn.classList.contains('rounded-full')) + const backButton = buttons.find((btn) => btn.classList.contains('rounded-full')) await user.click(backButton!) expect(mockReplace).toHaveBeenCalledWith('/datasets') @@ -350,7 +384,9 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - const cancelButton = screen.getByText('dataset.externalKnowledgeForm.cancel').closest('button') + const cancelButton = screen + .getByText('dataset.externalKnowledgeForm.cancel') + .closest('button') await user.click(cancelButton!) expect(mockReplace).toHaveBeenCalledWith('/datasets') @@ -363,7 +399,9 @@ describe('ExternalKnowledgeBaseCreate', () => { // Fill all fields using real components const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') - const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') + const descriptionInput = screen.getByPlaceholderText( + 'dataset.externalKnowledgeDescriptionPlaceholder', + ) const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') fireEvent.change(nameInput, { target: { value: 'My Knowledge Base' } }) @@ -371,11 +409,15 @@ describe('ExternalKnowledgeBaseCreate', () => { fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-abc' } }) await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') await user.click(connectButton!) expect(onConnect).toHaveBeenCalledWith( @@ -393,7 +435,9 @@ describe('ExternalKnowledgeBaseCreate', () => { renderComponent() const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') - const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') + const descriptionInput = screen.getByPlaceholderText( + 'dataset.externalKnowledgeDescriptionPlaceholder', + ) const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') await user.type(nameInput, 'Typed Name') @@ -420,11 +464,15 @@ describe('ExternalKnowledgeBaseCreate', () => { fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } }) await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') await user.click(connectButton!) // Should have auto-selected the first API @@ -463,11 +511,15 @@ describe('ExternalKnowledgeBaseCreate', () => { fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } }) await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') await user.click(connectButton!) // Should have selected the second API @@ -690,7 +742,7 @@ describe('ExternalKnowledgeBaseCreate', () => { ) const buttons = screen.getAllByRole('button') - const backButton = buttons.find(btn => btn.classList.contains('rounded-full')) + const backButton = buttons.find((btn) => btn.classList.contains('rounded-full')) await user.click(backButton!) expect(mockReplace).toHaveBeenCalledTimes(1) @@ -721,11 +773,15 @@ describe('ExternalKnowledgeBaseCreate', () => { rerender(<ExternalKnowledgeBaseCreate onConnect={onConnect2} loading={false} />) await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') await user.click(connectButton!) // Should use the new callback @@ -748,11 +804,15 @@ describe('ExternalKnowledgeBaseCreate', () => { fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge' } }) await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') await user.click(connectButton!) expect(onConnect).toHaveBeenCalledWith( @@ -790,8 +850,7 @@ describe('ExternalKnowledgeBaseCreate', () => { const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') // Rapid updates - for (let i = 0; i < 10; i++) - fireEvent.change(nameInput, { target: { value: `Name ${i}` } }) + for (let i = 0; i < 10; i++) fireEvent.change(nameInput, { target: { value: `Name ${i}` } }) expect((nameInput as HTMLInputElement).value).toBe('Name 9') }) @@ -808,11 +867,15 @@ describe('ExternalKnowledgeBaseCreate', () => { fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge' } }) await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') await user.click(connectButton!) expect(onConnect).toHaveBeenCalledWith( @@ -828,14 +891,18 @@ describe('ExternalKnowledgeBaseCreate', () => { it('should pass loading state to connect button', () => { renderComponent({ loading: true }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton)!.toBeInTheDocument() }) it('should render correctly when not loading', () => { renderComponent({ loading: false }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton)!.toBeInTheDocument() }) }) @@ -860,11 +927,15 @@ describe('ExternalKnowledgeBaseCreate', () => { fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } }) await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') await user.click(connectButton!) expect(onConnect).toHaveBeenCalledWith( @@ -1057,11 +1128,15 @@ describe('ExternalKnowledgeBaseCreate', () => { fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } }) await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') await user.click(connectButton!) expect(onConnect).toHaveBeenCalledWith({ @@ -1096,11 +1171,15 @@ describe('ExternalKnowledgeBaseCreate', () => { fireEvent.change(knowledgeIdInput, { target: { value: 'custom-kb' } }) await waitFor(() => { - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') expect(connectButton).not.toBeDisabled() }) - const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + const connectButton = screen + .getByText('dataset.externalKnowledgeForm.connect') + .closest('button') await user.click(connectButton!) expect(onConnect).toHaveBeenCalledWith( diff --git a/web/app/components/datasets/external-knowledge-base/create/index.tsx b/web/app/components/datasets/external-knowledge-base/create/index.tsx index 3c1d63bfb6bd33..9603cfe06417b6 100644 --- a/web/app/components/datasets/external-knowledge-base/create/index.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/index.tsx @@ -18,7 +18,10 @@ type ExternalKnowledgeBaseCreateProps = { loading: boolean } -const ExternalKnowledgeBaseCreate: React.FC<ExternalKnowledgeBaseCreateProps> = ({ onConnect, loading }) => { +const ExternalKnowledgeBaseCreate: React.FC<ExternalKnowledgeBaseCreateProps> = ({ + onConnect, + loading, +}) => { const { t } = useTranslation() const docLink = useDocLink() const router = useRouter() @@ -33,7 +36,6 @@ const ExternalKnowledgeBaseCreate: React.FC<ExternalKnowledgeBaseCreateProps> = score_threshold_enabled: false, }, provider: 'external', - }) const navBackHandle = useCallback(() => { @@ -44,11 +46,12 @@ const ExternalKnowledgeBaseCreate: React.FC<ExternalKnowledgeBaseCreateProps> = setFormData(newData) } - const isFormValid = formData.name.trim() !== '' - && formData.external_knowledge_api_id !== '' - && formData.external_knowledge_id !== '' - && formData.external_retrieval_model.top_k !== undefined - && formData.external_retrieval_model.score_threshold !== undefined + const isFormValid = + formData.name.trim() !== '' && + formData.external_knowledge_api_id !== '' && + formData.external_knowledge_id !== '' && + formData.external_retrieval_model.top_k !== undefined && + formData.external_retrieval_model.score_threshold !== undefined return ( <div className="flex grow flex-col self-stretch rounded-t-2xl border-t border-effects-highlight bg-components-panel-bg"> @@ -56,18 +59,24 @@ const ExternalKnowledgeBaseCreate: React.FC<ExternalKnowledgeBaseCreateProps> = <div className="flex w-full max-w-[960px] flex-col items-center px-14 py-0"> <div className="flex w-full max-w-[640px] grow flex-col items-center gap-4 pt-6 pb-8"> <div className="relative flex flex-col items-center gap-[2px] self-stretch py-2"> - <div className="grow self-stretch system-xl-semibold text-text-primary">{t($ => $.connectDataset, { ns: 'dataset' })}</div> + <div className="grow self-stretch system-xl-semibold text-text-primary"> + {t(($) => $.connectDataset, { ns: 'dataset' })} + </div> <p className="system-sm-regular text-text-tertiary"> - <span>{t($ => $['connectHelper.helper1'], { ns: 'dataset' })}</span> - <span className="system-sm-medium text-text-secondary">{t($ => $['connectHelper.helper2'], { ns: 'dataset' })}</span> - <span>{t($ => $['connectHelper.helper3'], { ns: 'dataset' })}</span> - <a className="self-stretch system-sm-regular text-text-accent" href={docLink('/use-dify/knowledge/connect-external-knowledge-base')} target="_blank" rel="noopener noreferrer"> - {t($ => $['connectHelper.helper4'], { ns: 'dataset' })} - </a> - <span> - {t($ => $['connectHelper.helper5'], { ns: 'dataset' })} - {' '} + <span>{t(($) => $['connectHelper.helper1'], { ns: 'dataset' })}</span> + <span className="system-sm-medium text-text-secondary"> + {t(($) => $['connectHelper.helper2'], { ns: 'dataset' })} </span> + <span>{t(($) => $['connectHelper.helper3'], { ns: 'dataset' })}</span> + <a + className="self-stretch system-sm-regular text-text-accent" + href={docLink('/use-dify/knowledge/connect-external-knowledge-base')} + target="_blank" + rel="noopener noreferrer" + > + {t(($) => $['connectHelper.helper4'], { ns: 'dataset' })} + </a> + <span>{t(($) => $['connectHelper.helper5'], { ns: 'dataset' })} </span> </p> <Button className="absolute top-1 left-[-44px] flex h-8 w-8 items-center justify-center rounded-full p-2" @@ -80,35 +89,43 @@ const ExternalKnowledgeBaseCreate: React.FC<ExternalKnowledgeBaseCreateProps> = <KnowledgeBaseInfo name={formData.name} description={formData.description ?? ''} - onChange={data => handleFormChange({ - ...formData, - ...data, - })} + onChange={(data) => + handleFormChange({ + ...formData, + ...data, + }) + } /> <Divider /> <ExternalApiSelection external_knowledge_api_id={formData.external_knowledge_api_id} external_knowledge_id={formData.external_knowledge_id} - onChange={data => handleFormChange({ - ...formData, - ...data, - })} + onChange={(data) => + handleFormChange({ + ...formData, + ...data, + }) + } /> <RetrievalSettings topK={formData.external_retrieval_model.top_k} scoreThreshold={formData.external_retrieval_model.score_threshold} scoreThresholdEnabled={formData.external_retrieval_model.score_threshold_enabled} - onChange={data => handleFormChange({ - ...formData, - external_retrieval_model: { - ...formData.external_retrieval_model, - ...data, - }, - })} + onChange={(data) => + handleFormChange({ + ...formData, + external_retrieval_model: { + ...formData.external_retrieval_model, + ...data, + }, + }) + } /> <div className="flex items-center justify-end gap-2 self-stretch py-2"> <Button variant="secondary" onClick={navBackHandle}> - <div className="system-sm-medium text-components-button-secondary-text">{t($ => $['externalKnowledgeForm.cancel'], { ns: 'dataset' })}</div> + <div className="system-sm-medium text-components-button-secondary-text"> + {t(($) => $['externalKnowledgeForm.cancel'], { ns: 'dataset' })} + </div> </Button> <Button variant="primary" @@ -118,7 +135,9 @@ const ExternalKnowledgeBaseCreate: React.FC<ExternalKnowledgeBaseCreateProps> = disabled={!isFormValid} loading={loading} > - <div className="system-sm-medium text-components-button-primary-text">{t($ => $['externalKnowledgeForm.connect'], { ns: 'dataset' })}</div> + <div className="system-sm-medium text-components-button-primary-text"> + {t(($) => $['externalKnowledgeForm.connect'], { ns: 'dataset' })} + </div> <RiArrowRightLine className="size-4 text-components-button-primary-text" /> </Button> </div> diff --git a/web/app/components/datasets/extra-info/__tests__/index.spec.tsx b/web/app/components/datasets/extra-info/__tests__/index.spec.tsx index beccaa666006c8..d1dbc439e5b89d 100644 --- a/web/app/components/datasets/extra-info/__tests__/index.spec.tsx +++ b/web/app/components/datasets/extra-info/__tests__/index.spec.tsx @@ -4,9 +4,7 @@ import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import { AppModeEnum } from '@/types/app' import { DatasetACLPermission } from '@/utils/permission' - // Component Imports (after mocks) - import ApiAccess from '../api-access' import ApiAccessCard from '../api-access/card' import ExtraInfo from '../index' @@ -25,8 +23,18 @@ vi.mock('@/next/navigation', () => ({ // Mock next/link vi.mock('@/next/link', () => ({ - default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => ( - <a href={href} {...props}>{children}</a> + default: ({ + children, + href, + ...props + }: { + children: React.ReactNode + href: string + [key: string]: unknown + }) => ( + <a href={href} {...props}> + {children} + </a> ), })) @@ -53,8 +61,9 @@ vi.mock('@/context/dataset-detail', () => ({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasetRes, })), - useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset, mutateDatasetRes?: () => void }) => unknown) => - selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }), + useDatasetDetailContextWithSelector: vi.fn( + (selector: (v: { dataset?: typeof mockDataset; mutateDatasetRes?: () => void }) => unknown) => + selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }), ), })) @@ -88,15 +97,14 @@ vi.mock('@/context/i18n', () => ({ // Mock SecretKeyModal to avoid complex modal rendering vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ - default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( - isShow - ? ( - <div data-testid="secret-key-modal"> - <button onClick={onClose} data-testid="close-modal-btn">Close</button> - </div> - ) - : null - ), + default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) => + isShow ? ( + <div data-testid="secret-key-modal"> + <button onClick={onClose} data-testid="close-modal-btn"> + Close + </button> + </div> + ) : null, })) // Test Data Factory @@ -114,7 +122,8 @@ const createMockRelatedApp = (overrides: Partial<RelatedApp> = {}): RelatedApp = const createMockRelatedAppsResponse = (count: number = 2): RelatedAppResponse => ({ data: Array.from({ length: count }, (_, i) => - createMockRelatedApp({ id: `app-${i + 1}`, name: `App ${i + 1}` })), + createMockRelatedApp({ id: `app-${i + 1}`, name: `App ${i + 1}` }), + ), total: count, }) @@ -153,13 +162,7 @@ describe('Statistics', () => { it('should render related apps total correctly', () => { const relatedApps = createMockRelatedAppsResponse(5) - render( - <Statistics - expand={true} - documentCount={10} - relatedApps={relatedApps} - />, - ) + render(<Statistics expand={true} documentCount={10} relatedApps={relatedApps} />) expect(screen.getByText('5')).toBeInTheDocument() }) @@ -203,13 +206,7 @@ describe('Statistics', () => { }) it('should render placeholder when relatedApps is undefined', () => { - render( - <Statistics - expand={true} - documentCount={10} - relatedApps={undefined} - />, - ) + render(<Statistics expand={true} documentCount={10} relatedApps={undefined} />) expect(screen.getAllByText('--').length).toBeGreaterThanOrEqual(1) }) @@ -229,13 +226,7 @@ describe('Statistics', () => { it('should handle empty related apps array', () => { const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 } - render( - <Statistics - expand={true} - documentCount={10} - relatedApps={emptyRelatedApps} - />, - ) + render(<Statistics expand={true} documentCount={10} relatedApps={emptyRelatedApps} />) expect(screen.getByText('0')).toBeInTheDocument() }) @@ -272,13 +263,7 @@ describe('Statistics', () => { it('should render LinkedAppsPanel when related apps exist', async () => { const relatedApps = createMockRelatedAppsResponse(3) - render( - <Statistics - expand={true} - documentCount={10} - relatedApps={relatedApps} - />, - ) + render(<Statistics expand={true} documentCount={10} relatedApps={relatedApps} />) // The LinkedAppsPanel should be rendered inside the tooltip // We can't easily test tooltip content in this context without more setup @@ -289,13 +274,7 @@ describe('Statistics', () => { it('should render NoLinkedAppsPanel when no related apps', () => { const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 } - render( - <Statistics - expand={true} - documentCount={10} - relatedApps={emptyRelatedApps} - />, - ) + render(<Statistics expand={true} documentCount={10} relatedApps={emptyRelatedApps} />) // Verify component renders correctly with empty apps expect(screen.getByText('0')).toBeInTheDocument() @@ -365,45 +344,25 @@ describe('ApiAccess', () => { describe('Rendering', () => { it('should render without crashing', () => { - render( - <ApiAccess - expand={true} - apiEnabled={true} - />, - ) + render(<ApiAccess expand={true} apiEnabled={true} />) expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) it('should render API title when expanded', () => { - render( - <ApiAccess - expand={true} - apiEnabled={true} - />, - ) + render(<ApiAccess expand={true} apiEnabled={true} />) expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) it('should not render API title when collapsed', () => { - render( - <ApiAccess - expand={false} - apiEnabled={true} - />, - ) + render(<ApiAccess expand={false} apiEnabled={true} />) expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument() }) it('should render indicator when API is enabled', () => { - const { container } = render( - <ApiAccess - expand={true} - apiEnabled={true} - />, - ) + const { container } = render(<ApiAccess expand={true} apiEnabled={true} />) // Indicator component should be present const indicatorElement = container.querySelector('.relative.flex.h-8') @@ -411,12 +370,7 @@ describe('ApiAccess', () => { }) it('should render indicator when API is disabled', () => { - const { container } = render( - <ApiAccess - expand={true} - apiEnabled={false} - />, - ) + const { container } = render(<ApiAccess expand={true} apiEnabled={false} />) // Indicator component should be present const indicatorElement = container.querySelector('.relative.flex.h-8') @@ -428,12 +382,7 @@ describe('ApiAccess', () => { it('should toggle popup open state on click', async () => { const user = userEvent.setup() - render( - <ApiAccess - expand={true} - apiEnabled={true} - />, - ) + render(<ApiAccess expand={true} apiEnabled={true} />) const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') expect(trigger).toBeInTheDocument() @@ -445,26 +394,18 @@ describe('ApiAccess', () => { }) it('should apply hover styles on trigger', () => { - render( - <ApiAccess - expand={true} - apiEnabled={true} - />, - ) + render(<ApiAccess expand={true} apiEnabled={true} />) - const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('div[class*="cursor-pointer"]') + const trigger = screen + .getByText(/appMenus\.apiAccess/i) + .closest('div[class*="cursor-pointer"]') expect(trigger).toHaveClass('cursor-pointer') }) }) describe('Props Variations', () => { it('should apply compressed layout when expand is false', () => { - const { container } = render( - <ApiAccess - expand={false} - apiEnabled={true} - />, - ) + const { container } = render(<ApiAccess expand={false} apiEnabled={true} />) // When collapsed, width should be w-8 const triggerContainer = container.querySelector('[class*="w-8"]') @@ -474,12 +415,7 @@ describe('ApiAccess', () => { it('should pass apiEnabled to Card component', async () => { const user = userEvent.setup() - render( - <ApiAccess - expand={true} - apiEnabled={true} - />, - ) + render(<ApiAccess expand={true} apiEnabled={true} />) const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') if (trigger) { @@ -491,19 +427,9 @@ describe('ApiAccess', () => { describe('Memoization', () => { it('should be memoized with React.memo', () => { - const { rerender } = render( - <ApiAccess - expand={true} - apiEnabled={true} - />, - ) + const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />) - rerender( - <ApiAccess - expand={true} - apiEnabled={true} - />, - ) + rerender(<ApiAccess expand={true} apiEnabled={true} />) expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) @@ -522,51 +448,31 @@ describe('ApiAccessCard', () => { describe('Rendering', () => { it('should render without crashing', () => { - render( - <ApiAccessCard - apiEnabled={true} - />, - ) + render(<ApiAccessCard apiEnabled={true} />) expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() }) it('should display enabled status when API is enabled', () => { - render( - <ApiAccessCard - apiEnabled={true} - />, - ) + render(<ApiAccessCard apiEnabled={true} />) expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() }) it('should display disabled status when API is disabled', () => { - render( - <ApiAccessCard - apiEnabled={false} - />, - ) + render(<ApiAccessCard apiEnabled={false} />) expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument() }) it('should render API Reference link', () => { - render( - <ApiAccessCard - apiEnabled={true} - />, - ) + render(<ApiAccessCard apiEnabled={true} />) expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument() }) it('should render switch component', () => { - render( - <ApiAccessCard - apiEnabled={true} - />, - ) + render(<ApiAccessCard apiEnabled={true} />) expect(screen.getByRole('switch')).toBeInTheDocument() }) @@ -576,11 +482,7 @@ describe('ApiAccessCard', () => { it('should call enableDatasetServiceApi when switch is toggled on', async () => { const user = userEvent.setup() - render( - <ApiAccessCard - apiEnabled={false} - />, - ) + render(<ApiAccessCard apiEnabled={false} />) const switchButton = screen.getByRole('switch') await user.click(switchButton) @@ -593,11 +495,7 @@ describe('ApiAccessCard', () => { it('should call disableDatasetServiceApi when switch is toggled off', async () => { const user = userEvent.setup() - render( - <ApiAccessCard - apiEnabled={true} - />, - ) + render(<ApiAccessCard apiEnabled={true} />) const switchButton = screen.getByRole('switch') await user.click(switchButton) @@ -610,11 +508,7 @@ describe('ApiAccessCard', () => { it('should call mutateDatasetRes after successful API toggle', async () => { const user = userEvent.setup() - render( - <ApiAccessCard - apiEnabled={false} - />, - ) + render(<ApiAccessCard apiEnabled={false} />) const switchButton = screen.getByRole('switch') await user.click(switchButton) @@ -628,11 +522,7 @@ describe('ApiAccessCard', () => { mockEnableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' }) const user = userEvent.setup() - render( - <ApiAccessCard - apiEnabled={false} - />, - ) + render(<ApiAccessCard apiEnabled={false} />) const switchButton = screen.getByRole('switch') await user.click(switchButton) @@ -646,11 +536,7 @@ describe('ApiAccessCard', () => { }) it('should have correct href for API Reference link', () => { - render( - <ApiAccessCard - apiEnabled={true} - />, - ) + render(<ApiAccessCard apiEnabled={true} />) const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a') expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') @@ -661,11 +547,7 @@ describe('ApiAccessCard', () => { it('should disable switch when dataset lacks edit ACL permission', () => { mockDataset.permission_keys = [] - render( - <ApiAccessCard - apiEnabled={true} - />, - ) + render(<ApiAccessCard apiEnabled={true} />) const switchButton = screen.getByRole('switch') expect(switchButton).toHaveAttribute('aria-disabled', 'true') @@ -674,11 +556,7 @@ describe('ApiAccessCard', () => { it('should enable switch when dataset has edit ACL permission', () => { mockDataset.permission_keys = [DatasetACLPermission.Edit] - render( - <ApiAccessCard - apiEnabled={true} - />, - ) + render(<ApiAccessCard apiEnabled={true} />) const switchButton = screen.getByRole('switch') expect(switchButton).not.toHaveAttribute('aria-disabled', 'true') @@ -687,34 +565,18 @@ describe('ApiAccessCard', () => { describe('Memoization', () => { it('should be memoized with React.memo', () => { - const { rerender } = render( - <ApiAccessCard - apiEnabled={true} - />, - ) + const { rerender } = render(<ApiAccessCard apiEnabled={true} />) - rerender( - <ApiAccessCard - apiEnabled={true} - />, - ) + rerender(<ApiAccessCard apiEnabled={true} />) expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() }) it('should use useCallback for handlers', () => { // Verify handlers are stable by rendering multiple times - const { rerender } = render( - <ApiAccessCard - apiEnabled={true} - />, - ) + const { rerender } = render(<ApiAccessCard apiEnabled={true} />) - rerender( - <ApiAccessCard - apiEnabled={true} - />, - ) + rerender(<ApiAccessCard apiEnabled={true} />) // Component should render without issues with memoized callbacks expect(screen.getByRole('switch')).toBeInTheDocument() @@ -868,7 +730,7 @@ describe('ExtraInfo', () => { expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() // Reset mock - vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector => + vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => selector({ dataset: mockDataset as DataSet, mutateDatasetRes: vi.fn() }), ) }) @@ -914,13 +776,7 @@ describe('ExtraInfo', () => { it('should pass relatedApps to Statistics component', () => { const relatedApps = createMockRelatedAppsResponse(7) - render( - <ExtraInfo - expand={true} - documentCount={10} - relatedApps={relatedApps} - />, - ) + render(<ExtraInfo expand={true} documentCount={10} relatedApps={relatedApps} />) expect(screen.getByText('7')).toBeInTheDocument() }) @@ -940,25 +796,13 @@ describe('ExtraInfo', () => { }) it('should handle undefined relatedApps', () => { - render( - <ExtraInfo - expand={true} - documentCount={10} - relatedApps={undefined} - />, - ) + render(<ExtraInfo expand={true} documentCount={10} relatedApps={undefined} />) expect(screen.getByText('10')).toBeInTheDocument() }) it('should handle all undefined optional props', () => { - render( - <ExtraInfo - expand={true} - documentCount={undefined} - relatedApps={undefined} - />, - ) + render(<ExtraInfo expand={true} documentCount={undefined} relatedApps={undefined} />) // Should render without crashing expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() @@ -967,13 +811,7 @@ describe('ExtraInfo', () => { it('should handle zero values correctly', () => { const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 } - render( - <ExtraInfo - expand={true} - documentCount={0} - relatedApps={emptyRelatedApps} - />, - ) + render(<ExtraInfo expand={true} documentCount={0} relatedApps={emptyRelatedApps} />) expect(screen.getAllByText('0')).toHaveLength(2) }) @@ -1083,11 +921,7 @@ describe('ExtraInfo Integration', () => { it('should render complete expanded view with all child components', () => { render( - <ExtraInfo - expand={true} - documentCount={25} - relatedApps={createMockRelatedAppsResponse(5)} - />, + <ExtraInfo expand={true} documentCount={25} relatedApps={createMockRelatedAppsResponse(5)} />, ) // Statistics content @@ -1102,20 +936,17 @@ describe('ExtraInfo Integration', () => { const user = userEvent.setup() render( - <ExtraInfo - expand={true} - documentCount={10} - relatedApps={createMockRelatedAppsResponse(3)} - />, + <ExtraInfo expand={true} documentCount={10} relatedApps={createMockRelatedAppsResponse(3)} />, ) // Verify statistics are visible expect(screen.getByText('10')).toBeInTheDocument() expect(screen.getByText('3')).toBeInTheDocument() - const apiAccessTrigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') - if (apiAccessTrigger) - await user.click(apiAccessTrigger) + const apiAccessTrigger = screen + .getByText(/appMenus\.apiAccess/i) + .closest('[class*="cursor-pointer"]') + if (apiAccessTrigger) await user.click(apiAccessTrigger) // The popup should open with Card content (showing enabled/disabled status) await waitFor(() => { @@ -1125,11 +956,7 @@ describe('ExtraInfo Integration', () => { it('should integrate with context correctly across all components', async () => { render( - <ExtraInfo - expand={true} - documentCount={10} - relatedApps={createMockRelatedAppsResponse()} - />, + <ExtraInfo expand={true} documentCount={10} relatedApps={createMockRelatedAppsResponse()} />, ) // The component tree should correctly receive context values diff --git a/web/app/components/datasets/extra-info/__tests__/statistics.spec.tsx b/web/app/components/datasets/extra-info/__tests__/statistics.spec.tsx index 042f6fae9940a2..3c027fdab08002 100644 --- a/web/app/components/datasets/extra-info/__tests__/statistics.spec.tsx +++ b/web/app/components/datasets/extra-info/__tests__/statistics.spec.tsx @@ -72,11 +72,15 @@ describe('Statistics', () => { }) it('should use the compact bottom-sidebar statistics layout', () => { - const { container } = render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />) + const { container } = render( + <Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />, + ) expect(container.firstChild).toHaveClass('items-start', 'gap-x-0.5', 'px-1', 'pt-2') expect(container.querySelector('.rotate-\\[15deg\\]')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.datasetMenus.relatedApp' })).toHaveClass('max-w-full') + expect(screen.getByRole('button', { name: 'common.datasetMenus.relatedApp' })).toHaveClass( + 'max-w-full', + ) }) it('should be wrapped with React.memo', () => { diff --git a/web/app/components/datasets/extra-info/api-access/__tests__/index.spec.tsx b/web/app/components/datasets/extra-info/api-access/__tests__/index.spec.tsx index 024616839c7ac0..9c68fab4427355 100644 --- a/web/app/components/datasets/extra-info/api-access/__tests__/index.spec.tsx +++ b/web/app/components/datasets/extra-info/api-access/__tests__/index.spec.tsx @@ -126,7 +126,10 @@ describe('ApiAccess', () => { const { container } = render(<ApiAccess expand={true} apiEnabled={true} />) expect(container.firstChild).toHaveClass('px-1', 'py-2') - expect(screen.getByText('common.appMenus.apiAccess')).toHaveClass('system-sm-regular', 'truncate') + expect(screen.getByText('common.appMenus.apiAccess')).toHaveClass( + 'system-sm-regular', + 'truncate', + ) }) }) }) diff --git a/web/app/components/datasets/extra-info/api-access/card.tsx b/web/app/components/datasets/extra-info/api-access/card.tsx index 38d660b0070de9..0ba81c7125d313 100644 --- a/web/app/components/datasets/extra-info/api-access/card.tsx +++ b/web/app/components/datasets/extra-info/api-access/card.tsx @@ -10,46 +10,48 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' import Link from '@/next/link' -import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset' +import { + useDisableDatasetServiceApi, + useEnableDatasetServiceApi, +} from '@/service/knowledge/use-dataset' import { getDatasetACLCapabilities } from '@/utils/permission' type CardProps = { apiEnabled: boolean } -const Card = ({ - apiEnabled, -}: CardProps) => { +const Card = ({ apiEnabled }: CardProps) => { const { t } = useTranslation() - const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id) - const dataset = useDatasetDetailContextWithSelector(state => state.dataset) - const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes) + const datasetId = useDatasetDetailContextWithSelector((state) => state.dataset?.id) + const dataset = useDatasetDetailContextWithSelector((state) => state.dataset) + const mutateDatasetRes = useDatasetDetailContextWithSelector((state) => state.mutateDatasetRes) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi() const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi() const datasetACLCapabilities = React.useMemo( - () => getDatasetACLCapabilities(dataset?.permission_keys, { - currentUserId, - resourceMaintainer: dataset?.maintainer, - workspacePermissionKeys, - }), + () => + getDatasetACLCapabilities(dataset?.permission_keys, { + currentUserId, + resourceMaintainer: dataset?.maintainer, + workspacePermissionKeys, + }), [dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys], ) const canManageApiAccess = datasetACLCapabilities.canEdit const apiReferenceUrl = useDatasetApiAccessUrl() - const onToggle = useCallback(async (state: boolean) => { - let result: 'success' | 'fail' - if (state) - result = (await enableDatasetServiceApi(datasetId ?? '')).result - else - result = (await disableDatasetServiceApi(datasetId ?? '')).result - if (result === 'success') - mutateDatasetRes?.() - }, [datasetId, enableDatasetServiceApi, mutateDatasetRes, disableDatasetServiceApi]) + const onToggle = useCallback( + async (state: boolean) => { + let result: 'success' | 'fail' + if (state) result = (await enableDatasetServiceApi(datasetId ?? '')).result + else result = (await disableDatasetServiceApi(datasetId ?? '')).result + if (result === 'success') mutateDatasetRes?.() + }, + [datasetId, enableDatasetServiceApi, mutateDatasetRes, disableDatasetServiceApi], + ) return ( <div className="w-[208px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg"> @@ -57,10 +59,7 @@ const Card = ({ <div className="p-2"> <div className="mb-1.5 flex justify-between"> <div className="flex items-center gap-1"> - <StatusDot - className="shrink-0" - status={apiEnabled ? 'success' : 'warning'} - /> + <StatusDot className="shrink-0" status={apiEnabled ? 'success' : 'warning'} /> <div className={cn( 'system-xs-semibold-uppercase', @@ -68,8 +67,8 @@ const Card = ({ )} > {apiEnabled - ? t($ => $['serviceApi.enabled'], { ns: 'dataset' }) - : t($ => $['serviceApi.disabled'], { ns: 'dataset' })} + ? t(($) => $['serviceApi.enabled'], { ns: 'dataset' }) + : t(($) => $['serviceApi.disabled'], { ns: 'dataset' })} </div> </div> <Switch @@ -79,7 +78,7 @@ const Card = ({ /> </div> <div className="system-xs-regular text-text-tertiary"> - {t($ => $['appMenus.apiAccessTip'], { ns: 'common' })} + {t(($) => $['appMenus.apiAccessTip'], { ns: 'common' })} </div> </div> </div> @@ -93,7 +92,7 @@ const Card = ({ > <span className="i-ri-book-open-line size-3.5 shrink-0" /> <div className="grow truncate system-sm-regular"> - {t($ => $['overview.apiInfo.doc'], { ns: 'appOverview' })} + {t(($) => $['overview.apiInfo.doc'], { ns: 'appOverview' })} </div> <span className="i-ri-arrow-right-up-line size-3.5 shrink-0" /> </Link> diff --git a/web/app/components/datasets/extra-info/api-access/index.tsx b/web/app/components/datasets/extra-info/api-access/index.tsx index f0d830f0fe17b4..9d48c39b23ca81 100644 --- a/web/app/components/datasets/extra-info/api-access/index.tsx +++ b/web/app/components/datasets/extra-info/api-access/index.tsx @@ -12,37 +12,36 @@ type ApiAccessProps = { apiEnabled: boolean } -const ApiAccess = ({ - expand, - apiEnabled, -}: ApiAccessProps) => { +const ApiAccess = ({ expand, apiEnabled }: ApiAccessProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) return ( <div className={cn(expand ? 'px-1 py-2' : 'flex justify-center px-3 py-2')}> - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <button type="button" className="w-full border-none bg-transparent p-0 text-left"> - <div className={cn( - 'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3', - !expand && 'w-8 justify-center', - open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover', - )} + <div + className={cn( + 'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3', + !expand && 'w-8 justify-center', + open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover', + )} > <ApiAggregate className="size-4 shrink-0 text-text-secondary" /> - {expand && <div className="min-w-0 grow truncate system-sm-regular text-text-secondary">{t($ => $['appMenus.apiAccess'], { ns: 'common' })}</div>} + {expand && ( + <div className="min-w-0 grow truncate system-sm-regular text-text-secondary"> + {t(($) => $['appMenus.apiAccess'], { ns: 'common' })} + </div> + )} <StatusDot className={cn('shrink-0', !expand && 'absolute -top-px -right-px')} status={apiEnabled ? 'success' : 'warning'} /> </div> </button> - )} + } /> <PopoverContent placement="top-start" @@ -50,9 +49,7 @@ const ApiAccess = ({ alignOffset={-4} popupClassName="border-none bg-transparent shadow-none" > - <Card - apiEnabled={apiEnabled} - /> + <Card apiEnabled={apiEnabled} /> </PopoverContent> </Popover> </div> diff --git a/web/app/components/datasets/extra-info/index.tsx b/web/app/components/datasets/extra-info/index.tsx index d892e7ae8b5e4f..94aded52e60112 100644 --- a/web/app/components/datasets/extra-info/index.tsx +++ b/web/app/components/datasets/extra-info/index.tsx @@ -10,26 +10,15 @@ type IExtraInfoProps = { expand: boolean } -const ExtraInfo = ({ - relatedApps, - documentCount, - expand, -}: IExtraInfoProps) => { - const apiEnabled = useDatasetDetailContextWithSelector(state => state.dataset?.enable_api) +const ExtraInfo = ({ relatedApps, documentCount, expand }: IExtraInfoProps) => { + const apiEnabled = useDatasetDetailContextWithSelector((state) => state.dataset?.enable_api) return ( <> {expand && ( - <Statistics - expand={expand} - documentCount={documentCount} - relatedApps={relatedApps} - /> + <Statistics expand={expand} documentCount={documentCount} relatedApps={relatedApps} /> )} - <ApiAccess - expand={expand} - apiEnabled={apiEnabled ?? false} - /> + <ApiAccess expand={expand} apiEnabled={apiEnabled ?? false} /> </> ) } diff --git a/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx b/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx index fdbebc3d8a7350..06ec8bd9373d47 100644 --- a/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx +++ b/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx @@ -14,9 +14,7 @@ const createWrapper = () => { }) return ({ children }: { children: React.ReactNode }) => ( <QueryClientProvider client={queryClient}> - <Popover open> - {children} - </Popover> + <Popover open>{children}</Popover> </QueryClientProvider> ) } @@ -77,12 +75,19 @@ describe('Card (Service API)', () => { // Props: tests different apiBaseUrl values describe('Props', () => { it('should display provided apiBaseUrl', () => { - renderWithProviders(<Card apiBaseUrl="https://custom-api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />) + renderWithProviders( + <Card + apiBaseUrl="https://custom-api.example.com" + onOpenSecretKeyModal={onOpenSecretKeyModal} + />, + ) expect(screen.getByText('https://custom-api.example.com')).toBeInTheDocument() }) it('should show green indicator when apiBaseUrl is provided', () => { - renderWithProviders(<Card apiBaseUrl="https://api.dify.ai" onOpenSecretKeyModal={onOpenSecretKeyModal} />) + renderWithProviders( + <Card apiBaseUrl="https://api.dify.ai" onOpenSecretKeyModal={onOpenSecretKeyModal} />, + ) // The Indicator component receives color="green" when apiBaseUrl is truthy const statusText = screen.getByText(/serviceApi\.enabled/) expect(statusText).toHaveClass('text-text-success') @@ -153,7 +158,9 @@ describe('Card (Service API)', () => { it('should handle apiBaseUrl with special characters', () => { const specialUrl = 'https://api.dify.ai/v1?key=value&foo=bar' - renderWithProviders(<Card apiBaseUrl={specialUrl} onOpenSecretKeyModal={onOpenSecretKeyModal} />) + renderWithProviders( + <Card apiBaseUrl={specialUrl} onOpenSecretKeyModal={onOpenSecretKeyModal} />, + ) expect(screen.getByText(specialUrl)).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx index 979c935397cc4b..4da1f419ec8844 100644 --- a/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx +++ b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx @@ -2,7 +2,6 @@ import { Popover } from '@langgenius/dify-ui/popover' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' - import Card from '../card' import ServiceApi from '../index' @@ -11,35 +10,40 @@ import ServiceApi from '../index' let mockWorkspacePermissionKeys: string[] = ['dataset.api_key.manage'] vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ workspacePermissionKeys: mockWorkspacePermissionKeys, })) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ workspacePermissionKeys: mockWorkspacePermissionKeys, })) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ workspacePermissionKeys: mockWorkspacePermissionKeys, })) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ workspacePermissionKeys: mockWorkspacePermissionKeys, })) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ workspacePermissionKeys: mockWorkspacePermissionKeys, @@ -56,15 +60,26 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) // Mock next/link vi.mock('@/next/link', () => ({ - default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => ( - <a href={href} {...props}>{children}</a> + default: ({ + children, + href, + ...props + }: { + children: React.ReactNode + href: string + [key: string]: unknown + }) => ( + <a href={href} {...props}> + {children} + </a> ), })) @@ -75,20 +90,26 @@ vi.mock('@/hooks/use-api-access-url', () => ({ // Mock SecretKeyModal to avoid complex modal rendering vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ - default: ({ isShow, onClose, canManage }: { isShow: boolean, onClose: () => void, canManage: boolean }) => ( - isShow - ? ( - <div data-testid="secret-key-modal"> - <span data-testid="secret-key-modal-can-manage">{String(canManage)}</span> - <button onClick={onClose} data-testid="close-modal-btn">Close</button> - </div> - ) - : null - ), + default: ({ + isShow, + onClose, + canManage, + }: { + isShow: boolean + onClose: () => void + canManage: boolean + }) => + isShow ? ( + <div data-testid="secret-key-modal"> + <span data-testid="secret-key-modal-can-manage">{String(canManage)}</span> + <button onClick={onClose} data-testid="close-modal-btn"> + Close + </button> + </div> + ) : null, })) -const renderCard = (ui: React.ReactElement) => - render(<Popover open>{ui}</Popover>) +const renderCard = (ui: React.ReactElement) => render(<Popover open>{ui}</Popover>) describe('ServiceApi', () => { beforeEach(() => { @@ -140,7 +161,8 @@ describe('ServiceApi', () => { }) it('should handle long apiBaseUrl without breaking layout', () => { - const longUrl = 'https://api.example.com/v1/very/long/path/to/endpoint/that/might/break/layout' + const longUrl = + 'https://api.example.com/v1/very/long/path/to/endpoint/that/might/break/layout' render(<ServiceApi apiBaseUrl={longUrl} />) expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument() }) @@ -159,8 +181,7 @@ describe('ServiceApi', () => { render(<ServiceApi apiBaseUrl="https://api.example.com" />) const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') - if (trigger) - await user.click(trigger) + if (trigger) await user.click(trigger) await waitFor(() => { expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() @@ -176,8 +197,7 @@ describe('ServiceApi', () => { render(<ServiceApi apiBaseUrl={testUrl} />) const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') - if (trigger) - await user.click(trigger) + if (trigger) await user.click(trigger) await waitFor(() => { expect(screen.getByText(testUrl)).toBeInTheDocument() @@ -212,7 +232,9 @@ describe('Card (service-api)', () => { describe('Rendering', () => { it('should render without crashing', () => { - renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />) + renderCard( + <Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />, + ) expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument() }) @@ -223,12 +245,16 @@ describe('Card (service-api)', () => { }) it('should render API Key button', () => { - renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />) + renderCard( + <Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />, + ) expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument() }) it('should render API Reference button', () => { - renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />) + renderCard( + <Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />, + ) expect(screen.getByText(/serviceApi\.card\.apiReference/i)).toBeInTheDocument() }) }) @@ -237,24 +263,37 @@ describe('Card (service-api)', () => { it('should call onOpenSecretKeyModal when API Key button is clicked', async () => { const user = userEvent.setup() - renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} canManageSecretKey />) + renderCard( + <Card + apiBaseUrl="https://api.example.com" + onOpenSecretKeyModal={onOpenSecretKeyModal} + canManageSecretKey + />, + ) const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') - if (apiKeyButton) - await user.click(apiKeyButton) + if (apiKeyButton) await user.click(apiKeyButton) expect(onOpenSecretKeyModal).toHaveBeenCalledTimes(1) }) it('should disable API Key button when secret key management is not allowed', () => { - renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} canManageSecretKey={false} />) + renderCard( + <Card + apiBaseUrl="https://api.example.com" + onOpenSecretKeyModal={onOpenSecretKeyModal} + canManageSecretKey={false} + />, + ) const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') expect(apiKeyButton).toBeDisabled() }) it('should have correct href for API Reference link', () => { - renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />) + renderCard( + <Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />, + ) const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a') expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') @@ -274,8 +313,7 @@ describe('ServiceApi Integration', () => { // Open popover const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') - if (trigger) - await user.click(trigger) + if (trigger) await user.click(trigger) await waitFor(() => { expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument() @@ -283,8 +321,7 @@ describe('ServiceApi Integration', () => { // Click API Key button (wrapped by PopoverClose) const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') - if (apiKeyButton) - await user.click(apiKeyButton) + if (apiKeyButton) await user.click(apiKeyButton) // Modal should appear await waitFor(() => { @@ -303,16 +340,14 @@ describe('ServiceApi Integration', () => { render(<ServiceApi apiBaseUrl="https://api.example.com" />) const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') - if (trigger) - await user.click(trigger) + if (trigger) await user.click(trigger) await waitFor(() => { expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument() }) const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') - if (apiKeyButton) - await user.click(apiKeyButton) + if (apiKeyButton) await user.click(apiKeyButton) await waitFor(() => { expect(screen.getByTestId('secret-key-modal-can-manage')).toHaveTextContent('true') @@ -326,8 +361,7 @@ describe('ServiceApi Integration', () => { render(<ServiceApi apiBaseUrl="https://api.example.com" />) const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]') - if (trigger) - await user.click(trigger) + if (trigger) await user.click(trigger) await waitFor(() => { expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument() @@ -335,8 +369,7 @@ describe('ServiceApi Integration', () => { const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') expect(apiKeyButton).toBeDisabled() - if (apiKeyButton) - await user.click(apiKeyButton) + if (apiKeyButton) await user.click(apiKeyButton) expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() }) diff --git a/web/app/components/datasets/extra-info/service-api/card.tsx b/web/app/components/datasets/extra-info/service-api/card.tsx index 1c57c966a230ac..725efabeadc8f9 100644 --- a/web/app/components/datasets/extra-info/service-api/card.tsx +++ b/web/app/components/datasets/extra-info/service-api/card.tsx @@ -13,11 +13,7 @@ type CardProps = { canManageSecretKey?: boolean } -const Card = ({ - apiBaseUrl, - onOpenSecretKeyModal, - canManageSecretKey = false, -}: CardProps) => { +const Card = ({ apiBaseUrl, onOpenSecretKeyModal, canManageSecretKey = false }: CardProps) => { const { t } = useTranslation() const apiReferenceUrl = useDatasetApiAccessUrl() @@ -31,43 +27,32 @@ const Card = ({ <span className="i-custom-vender-knowledge-api-aggregate size-4 text-text-primary-on-surface" /> </div> <div className="grow truncate system-sm-semibold text-text-secondary"> - {t($ => $['serviceApi.card.title'], { ns: 'dataset' })} + {t(($) => $['serviceApi.card.title'], { ns: 'dataset' })} </div> </div> <div className="flex items-center gap-x-1"> - <StatusDot - className="shrink-0" - status={ - apiBaseUrl ? 'success' : 'warning' - } - /> - <div - className="system-xs-semibold-uppercase text-text-success" - > - {t($ => $['serviceApi.enabled'], { ns: 'dataset' })} + <StatusDot className="shrink-0" status={apiBaseUrl ? 'success' : 'warning'} /> + <div className="system-xs-semibold-uppercase text-text-success"> + {t(($) => $['serviceApi.enabled'], { ns: 'dataset' })} </div> </div> </div> <div className="flex flex-col"> <div className="system-xs-regular leading-6 text-text-tertiary"> - {t($ => $['serviceApi.card.endpoint'], { ns: 'dataset' })} + {t(($) => $['serviceApi.card.endpoint'], { ns: 'dataset' })} </div> <div className="flex h-8 items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2"> <div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1"> - <div className="truncate system-xs-medium text-text-secondary"> - {apiBaseUrl} - </div> + <div className="truncate system-xs-medium text-text-secondary">{apiBaseUrl}</div> </div> - <CopyFeedback - content={apiBaseUrl} - /> + <CopyFeedback content={apiBaseUrl} /> </div> </div> </div> {/* Actions */} <div className="flex gap-x-1 border-t-[0.5px] border-divider-subtle p-4"> <PopoverClose - render={( + render={ <Button variant="ghost" size="small" @@ -77,24 +62,16 @@ const Card = ({ > <span className="i-ri-key-2-line size-3.5 shrink-0" /> <span className="px-[3px] system-xs-medium"> - {t($ => $['serviceApi.card.apiKey'], { ns: 'dataset' })} + {t(($) => $['serviceApi.card.apiKey'], { ns: 'dataset' })} </span> </Button> - )} + } /> - <Link - href={apiReferenceUrl} - target="_blank" - rel="noopener noreferrer" - > - <Button - variant="ghost" - size="small" - className="gap-x-px text-text-tertiary" - > + <Link href={apiReferenceUrl} target="_blank" rel="noopener noreferrer"> + <Button variant="ghost" size="small" className="gap-x-px text-text-tertiary"> <span className="i-ri-book-open-line size-3.5 shrink-0" /> <span className="px-[3px] system-xs-medium"> - {t($ => $['serviceApi.card.apiReference'], { ns: 'dataset' })} + {t(($) => $['serviceApi.card.apiReference'], { ns: 'dataset' })} </span> </Button> </Link> diff --git a/web/app/components/datasets/extra-info/service-api/index.tsx b/web/app/components/datasets/extra-info/service-api/index.tsx index 2cd086795d6aa2..43f2ef43f9a197 100644 --- a/web/app/components/datasets/extra-info/service-api/index.tsx +++ b/web/app/components/datasets/extra-info/service-api/index.tsx @@ -14,9 +14,7 @@ type ServiceApiProps = { apiBaseUrl: string } -const ServiceApi = ({ - apiBaseUrl, -}: ServiceApiProps) => { +const ServiceApi = ({ apiBaseUrl }: ServiceApiProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false) @@ -33,28 +31,23 @@ const ServiceApi = ({ return ( <div className="flex items-center"> - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <button type="button" className="w-full border-none bg-transparent p-0 text-left"> - <div className={cn( - 'relative flex h-6 cursor-pointer items-center justify-center gap-1 overflow-hidden rounded-md px-1.5 py-1 text-text-tertiary', - open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover', - )} + <div + className={cn( + 'relative flex h-6 cursor-pointer items-center justify-center gap-1 overflow-hidden rounded-md px-1.5 py-1 text-text-tertiary', + open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover', + )} > - <StatusDot - className={cn('shrink-0')} - status={ - apiBaseUrl ? 'success' : 'warning' - } - /> - <div className="px-0.5 system-xs-medium">{t($ => $['serviceApi.title'], { ns: 'dataset' })}</div> + <StatusDot className={cn('shrink-0')} status={apiBaseUrl ? 'success' : 'warning'} /> + <div className="px-0.5 system-xs-medium"> + {t(($) => $['serviceApi.title'], { ns: 'dataset' })} + </div> </div> </button> - )} + } /> <PopoverContent placement="top-start" diff --git a/web/app/components/datasets/extra-info/statistics.tsx b/web/app/components/datasets/extra-info/statistics.tsx index 25346ded7c1992..63f640b51d317e 100644 --- a/web/app/components/datasets/extra-info/statistics.tsx +++ b/web/app/components/datasets/extra-info/statistics.tsx @@ -12,11 +12,7 @@ type StatisticsProps = { relatedApps?: RelatedAppResponse } -const Statistics = ({ - expand, - documentCount, - relatedApps, -}: StatisticsProps) => { +const Statistics = ({ expand, documentCount, relatedApps }: StatisticsProps) => { const { t } = useTranslation() const relatedAppsTotal = relatedApps?.total @@ -29,7 +25,7 @@ const Statistics = ({ {documentCount ?? '--'} </div> <div className="truncate system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['datasetMenus.documents'], { ns: 'common' })} + {t(($) => $['datasetMenus.documents'], { ns: 'common' })} </div> </div> <div className="flex h-[42px] w-[15px] shrink-0 items-center justify-center"> @@ -42,29 +38,28 @@ const Statistics = ({ <Popover> <PopoverTrigger openOnHover - aria-label={t($ => $['datasetMenus.relatedApp'], { ns: 'common' })} - render={( + aria-label={t(($) => $['datasetMenus.relatedApp'], { ns: 'common' })} + render={ <button type="button" className="flex max-w-full cursor-pointer items-center gap-x-0.5 rounded-sm system-2xs-medium-uppercase text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-hover" > - <span className="truncate">{t($ => $['datasetMenus.relatedApp'], { ns: 'common' })}</span> + <span className="truncate"> + {t(($) => $['datasetMenus.relatedApp'], { ns: 'common' })} + </span> <RiInformation2Line className="size-3 shrink-0" /> </button> - )} + } /> <PopoverContent placement="top-start" popupClassName="border-0 bg-transparent p-0 shadow-none" > - {hasRelatedApps - ? ( - <LinkedAppsPanel - relatedApps={relatedApps.data} - isMobile={!expand} - /> - ) - : <NoLinkedAppsPanel />} + {hasRelatedApps ? ( + <LinkedAppsPanel relatedApps={relatedApps.data} isMobile={!expand} /> + ) : ( + <NoLinkedAppsPanel /> + )} </PopoverContent> </Popover> </div> diff --git a/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx b/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx index a94aa393b2c73e..ed8b5b5226cc35 100644 --- a/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx +++ b/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx @@ -10,16 +10,19 @@ vi.mock('@floating-ui/react', () => ({ shift: vi.fn(), offset: vi.fn(), FloatingFocusManager: ({ children }: { children: React.ReactNode }) => ( - <div data-testid="floating-focus-manager"> - {children} - </div> + <div data-testid="floating-focus-manager">{children}</div> ), useFloating: ({ onOpenChange }: { onOpenChange?: (open: boolean) => void } = {}) => { capturedOnOpenChange = onOpenChange ?? null return { refs: { setReference: vi.fn(), setFloating: vi.fn() }, floatingStyles: {}, - context: { open: false, onOpenChange: vi.fn(), refs: { domReference: { current: null } }, nodeId: undefined }, + context: { + open: false, + onOpenChange: vi.fn(), + refs: { domReference: { current: null } }, + nodeId: undefined, + }, } }, useHover: () => ({}), @@ -32,8 +35,10 @@ vi.mock('@floating-ui/react', () => ({ })) vi.mock('@/app/components/base/action-button', () => { - const comp = ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( - <button data-testid="action-button" onClick={onClick}>{children}</button> + const comp = ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => ( + <button data-testid="action-button" onClick={onClick}> + {children} + </button> ) return { default: comp, @@ -45,7 +50,7 @@ const { EditSlice } = await import('../edit-slice') // Helper to find divider span (zero-width space) const findDividerSpan = (container: HTMLElement) => - Array.from(container.querySelectorAll('span')).find(s => s.textContent?.includes('\u200B')) + Array.from(container.querySelectorAll('span')).find((s) => s.textContent?.includes('\u200B')) describe('EditSlice', () => { const defaultProps = { @@ -157,7 +162,8 @@ describe('EditSlice', () => { act(() => { capturedOnOpenChange?.(true) }) - const floatingSpan = screen.getByTestId('floating-focus-manager').firstElementChild as HTMLElement + const floatingSpan = screen.getByTestId('floating-focus-manager') + .firstElementChild as HTMLElement fireEvent.mouseEnter(floatingSpan) await waitFor(() => { @@ -173,7 +179,8 @@ describe('EditSlice', () => { act(() => { capturedOnOpenChange?.(true) }) - const floatingSpan = screen.getByTestId('floating-focus-manager').firstElementChild as HTMLElement + const floatingSpan = screen.getByTestId('floating-focus-manager') + .firstElementChild as HTMLElement fireEvent.mouseEnter(floatingSpan) await waitFor(() => { @@ -184,7 +191,9 @@ describe('EditSlice', () => { await waitFor(() => { expect(screen.getByText('S1').parentElement).not.toHaveClass('bg-state-destructive-solid!') - expect(screen.getByText('Sample text content')).not.toHaveClass('bg-state-destructive-hover-alt!') + expect(screen.getByText('Sample text content')).not.toHaveClass( + 'bg-state-destructive-hover-alt!', + ) }) }) }) diff --git a/web/app/components/datasets/formatted-text/flavours/__tests__/preview-slice.spec.tsx b/web/app/components/datasets/formatted-text/flavours/__tests__/preview-slice.spec.tsx index 88a5ee72e5a945..5f136ec559b782 100644 --- a/web/app/components/datasets/formatted-text/flavours/__tests__/preview-slice.spec.tsx +++ b/web/app/components/datasets/formatted-text/flavours/__tests__/preview-slice.spec.tsx @@ -14,7 +14,12 @@ vi.mock('@floating-ui/react', () => ({ return { refs: { setReference: vi.fn(), setFloating: vi.fn() }, floatingStyles: {}, - context: { open: false, onOpenChange: vi.fn(), refs: { domReference: { current: null } }, nodeId: undefined }, + context: { + open: false, + onOpenChange: vi.fn(), + refs: { domReference: { current: null } }, + nodeId: undefined, + }, } }, useHover: () => ({}), @@ -30,7 +35,7 @@ const { PreviewSlice } = await import('../preview-slice') // Helper to find divider span (zero-width space) const findDividerSpan = (container: HTMLElement) => - Array.from(container.querySelectorAll('span')).find(s => s.textContent?.includes('\u200B')) + Array.from(container.querySelectorAll('span')).find((s) => s.textContent?.includes('\u200B')) describe('PreviewSlice', () => { const defaultProps = { @@ -63,7 +68,9 @@ describe('PreviewSlice', () => { // ---- Class Name Tests ---- it('should apply custom className', () => { - render(<PreviewSlice {...defaultProps} data-testid="preview-slice" className="preview-custom" />) + render( + <PreviewSlice {...defaultProps} data-testid="preview-slice" className="preview-custom" />, + ) expect(screen.getByTestId('preview-slice')).toHaveClass('preview-custom') }) diff --git a/web/app/components/datasets/formatted-text/flavours/edit-slice.tsx b/web/app/components/datasets/formatted-text/flavours/edit-slice.tsx index 4ba5ede666240c..e2b6d706ccb3d2 100644 --- a/web/app/components/datasets/formatted-text/flavours/edit-slice.tsx +++ b/web/app/components/datasets/formatted-text/flavours/edit-slice.tsx @@ -1,7 +1,18 @@ import type { OffsetOptions } from '@floating-ui/react' import type { FC, ReactNode } from 'react' import type { SliceProps } from './type' -import { autoUpdate, flip, FloatingFocusManager, offset, shift, useDismiss, useFloating, useHover, useInteractions, useRole } from '@floating-ui/react' +import { + autoUpdate, + flip, + FloatingFocusManager, + offset, + shift, + useDismiss, + useFloating, + useHover, + useInteractions, + useRole, +} from '@floating-ui/react' import { cn } from '@langgenius/dify-ui/cn' import { RiDeleteBinLine } from '@remixicon/react' import { useState } from 'react' @@ -39,11 +50,7 @@ export const EditSlice: FC<EditSliceProps> = (props) => { onOpenChange: setDelBtnShow, placement: 'right-start', whileElementsMounted: autoUpdate, - middleware: [ - flip(), - shift(), - offset(offsetOptions), - ], + middleware: [flip(), shift(), offset(offsetOptions)], }) const hover = useHover(context, {}) const dismiss = useDismiss(context) @@ -61,7 +68,10 @@ export const EditSlice: FC<EditSliceProps> = (props) => { {...getReferenceProps()} > <SliceLabel - className={cn(isDestructive && 'bg-state-destructive-solid! text-text-primary-on-surface!', labelClassName)} + className={cn( + isDestructive && 'bg-state-destructive-solid! text-text-primary-on-surface!', + labelClassName, + )} labelInnerClassName={labelInnerClassName} > {label} @@ -72,14 +82,10 @@ export const EditSlice: FC<EditSliceProps> = (props) => { {text} </SliceContent> {showDivider && ( - <SliceDivider - className={cn(isDestructive && 'bg-state-destructive-hover-alt!')} - /> + <SliceDivider className={cn(isDestructive && 'bg-state-destructive-hover-alt!')} /> )} {delBtnShow && ( - <FloatingFocusManager - context={context} - > + <FloatingFocusManager context={context}> <span ref={refs.setFloating} style={floatingStyles} diff --git a/web/app/components/datasets/formatted-text/flavours/preview-slice.tsx b/web/app/components/datasets/formatted-text/flavours/preview-slice.tsx index 99ef8816efd06e..324084f18654e6 100644 --- a/web/app/components/datasets/formatted-text/flavours/preview-slice.tsx +++ b/web/app/components/datasets/formatted-text/flavours/preview-slice.tsx @@ -1,6 +1,16 @@ import type { FC, ReactNode } from 'react' import type { SliceProps } from './type' -import { autoUpdate, flip, inline, shift, useDismiss, useFloating, useHover, useInteractions, useRole } from '@floating-ui/react' +import { + autoUpdate, + flip, + inline, + shift, + useDismiss, + useFloating, + useHover, + useInteractions, + useRole, +} from '@floating-ui/react' import { useState } from 'react' import { SliceContainer, SliceContent, SliceDivider, SliceLabel } from './shared' @@ -19,11 +29,7 @@ export const PreviewSlice: FC<PreviewSliceProps> = (props) => { onOpenChange: setTooltipOpen, whileElementsMounted: autoUpdate, placement: 'top', - middleware: [ - inline(), - flip(), - shift(), - ], + middleware: [inline(), flip(), shift()], }) const hover = useHover(context, { delay: { open: 500 }, diff --git a/web/app/components/datasets/formatted-text/flavours/shared.tsx b/web/app/components/datasets/formatted-text/flavours/shared.tsx index 10137d3936ea61..ae9dffb1214834 100644 --- a/web/app/components/datasets/formatted-text/flavours/shared.tsx +++ b/web/app/components/datasets/formatted-text/flavours/shared.tsx @@ -5,12 +5,7 @@ const baseStyle = 'py-[3px]' type SliceContainerProps = ComponentProps<'span'> -export const SliceContainer: FC<SliceContainerProps> = ( - { - ref, - ...props - }, -) => { +export const SliceContainer: FC<SliceContainerProps> = ({ ref, ...props }) => { const { className, ...rest } = props return ( <span @@ -24,22 +19,19 @@ SliceContainer.displayName = 'SliceContainer' type SliceLabelProps = ComponentProps<'span'> & { labelInnerClassName?: string } -export const SliceLabel: FC<SliceLabelProps> = ( - { - ref, - ...props - }, -) => { +export const SliceLabel: FC<SliceLabelProps> = ({ ref, ...props }) => { const { className, children, labelInnerClassName, ...rest } = props return ( <span {...rest} ref={ref} - className={cn(baseStyle, 'bg-state-base-hover-alt px-1 text-text-tertiary uppercase group-hover:bg-state-accent-solid group-hover:text-text-primary-on-surface', className)} + className={cn( + baseStyle, + 'bg-state-base-hover-alt px-1 text-text-tertiary uppercase group-hover:bg-state-accent-solid group-hover:text-text-primary-on-surface', + className, + )} > - <span className={cn('text-nowrap', labelInnerClassName)}> - {children} - </span> + <span className={cn('text-nowrap', labelInnerClassName)}>{children}</span> </span> ) } @@ -47,18 +39,17 @@ SliceLabel.displayName = 'SliceLabel' type SliceContentProps = ComponentProps<'span'> -export const SliceContent: FC<SliceContentProps> = ( - { - ref, - ...props - }, -) => { +export const SliceContent: FC<SliceContentProps> = ({ ref, ...props }) => { const { className, children, ...rest } = props return ( <span {...rest} ref={ref} - className={cn(baseStyle, 'bg-state-base-hover px-1 leading-7 break-all whitespace-pre-line group-hover:bg-state-accent-hover-alt group-hover:text-text-primary', className)} + className={cn( + baseStyle, + 'bg-state-base-hover px-1 leading-7 break-all whitespace-pre-line group-hover:bg-state-accent-hover-alt group-hover:text-text-primary', + className, + )} > {children} </span> @@ -68,18 +59,17 @@ SliceContent.displayName = 'SliceContent' type SliceDividerProps = ComponentProps<'span'> -export const SliceDivider: FC<SliceDividerProps> = ( - { - ref, - ...props - }, -) => { +export const SliceDivider: FC<SliceDividerProps> = ({ ref, ...props }) => { const { className, ...rest } = props return ( <span {...rest} ref={ref} - className={cn(baseStyle, 'bg-state-base-active px-px text-sm group-hover:bg-state-accent-solid', className)} + className={cn( + baseStyle, + 'bg-state-base-active px-px text-sm group-hover:bg-state-accent-solid', + className, + )} > {/* use a zero-width space to make the hover area bigger */} ​ diff --git a/web/app/components/datasets/formatted-text/formatted.tsx b/web/app/components/datasets/formatted-text/formatted.tsx index 0938ec223dee8c..817047d2413395 100644 --- a/web/app/components/datasets/formatted-text/formatted.tsx +++ b/web/app/components/datasets/formatted-text/formatted.tsx @@ -6,10 +6,7 @@ type FormattedTextProps = ComponentProps<'p'> export const FormattedText: FC<FormattedTextProps> = (props) => { const { className, ...rest } = props return ( - <p - {...rest} - className={cn('leading-7', className)} - > + <p {...rest} className={cn('leading-7', className)}> {props.children} </p> ) diff --git a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx index 806fea14ed3daf..897f5a8287f6f0 100644 --- a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx +++ b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx @@ -14,12 +14,32 @@ import HitTestingPage from '../index' // Mock RetrievalSettings to allow triggering onChange vi.mock('@/app/components/datasets/external-knowledge-base/create/RetrievalSettings', () => ({ - default: ({ onChange }: { onChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void }) => { + default: ({ + onChange, + }: { + onChange: (data: { + top_k?: number + score_threshold?: number + score_threshold_enabled?: boolean + }) => void + }) => { return ( <div data-testid="retrieval-settings-mock"> - <button data-testid="change-top-k" onClick={() => onChange({ top_k: 8 })}>Change Top K</button> - <button data-testid="change-score-threshold" onClick={() => onChange({ score_threshold: 0.9 })}>Change Score Threshold</button> - <button data-testid="change-score-enabled" onClick={() => onChange({ score_threshold_enabled: true })}>Change Score Enabled</button> + <button data-testid="change-top-k" onClick={() => onChange({ top_k: 8 })}> + Change Top K + </button> + <button + data-testid="change-score-threshold" + onClick={() => onChange({ score_threshold: 0.9 })} + > + Change Score Threshold + </button> + <button + data-testid="change-score-enabled" + onClick={() => onChange({ score_threshold_enabled: true })} + > + Change Score Enabled + </button> </div> ) }, @@ -74,33 +94,39 @@ vi.mock('use-context-selector', () => ({ vi.mock('@/context/dataset-detail', () => ({ default: {}, useDatasetDetailContext: vi.fn(() => ({ dataset: mockDataset })), - useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset }) => unknown) => - selector({ dataset: mockDataset as DataSet }), + useDatasetDetailContextWithSelector: vi.fn( + (selector: (v: { dataset?: typeof mockDataset }) => unknown) => + selector({ dataset: mockDataset as DataSet }), ), })) vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) @@ -124,7 +150,8 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) @@ -152,7 +179,9 @@ vi.mock('@/hooks/use-breakpoints', () => ({ // Mock timestamp hook vi.mock('@/hooks/use-timestamp', () => ({ default: vi.fn(() => ({ - formatTime: vi.fn((timestamp: number, _format: string) => new Date(timestamp * 1000).toISOString()), + formatTime: vi.fn((timestamp: number, _format: string) => + new Date(timestamp * 1000).toISOString(), + ), })), })) @@ -169,39 +198,68 @@ vi.mock('@/service/use-common', () => ({ })) // Store ref to ImageUploader onChange for testing -let _mockImageUploaderOnChange: ((files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void) | null = null +let _mockImageUploaderOnChange: + | (( + files: Array<{ + sourceUrl?: string + uploadedId?: string + mimeType: string + name: string + size: number + extension: string + }>, + ) => void) + | null = null // Mock ImageUploaderInRetrievalTesting to capture onChange -vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ - default: ({ textArea, actionButton, onChange }: { - textArea: React.ReactNode - actionButton: React.ReactNode - onChange: (files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void - }) => { - _mockImageUploaderOnChange = onChange - return ( - <div data-testid="image-uploader-mock"> - {textArea} - {actionButton} - <button - data-testid="trigger-image-change" - onClick={() => onChange([ - { - sourceUrl: 'http://example.com/new-image.png', - uploadedId: 'new-uploaded-id', - mimeType: 'image/png', - name: 'new-image.png', - size: 2000, - extension: 'png', - }, - ])} - > - Add Image - </button> - </div> - ) - }, -})) +vi.mock( + '@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', + () => ({ + default: ({ + textArea, + actionButton, + onChange, + }: { + textArea: React.ReactNode + actionButton: React.ReactNode + onChange: ( + files: Array<{ + sourceUrl?: string + uploadedId?: string + mimeType: string + name: string + size: number + extension: string + }>, + ) => void + }) => { + _mockImageUploaderOnChange = onChange + return ( + <div data-testid="image-uploader-mock"> + {textArea} + {actionButton} + <button + data-testid="trigger-image-change" + onClick={() => + onChange([ + { + sourceUrl: 'http://example.com/new-image.png', + uploadedId: 'new-uploaded-id', + mimeType: 'image/png', + name: 'new-image.png', + size: 2000, + extension: 'png', + }, + ]) + } + > + Add Image + </button> + </div> + ) + }, + }), +) // Mock docLink hook vi.mock('@/context/i18n', () => ({ @@ -211,11 +269,7 @@ vi.mock('@/context/i18n', () => ({ // Mock provider context for retrieval method config vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(() => ({ - supportRetrievalMethods: [ - 'semantic_search', - 'full_text_search', - 'hybrid_search', - ], + supportRetrievalMethods: ['semantic_search', 'full_text_search', 'hybrid_search'], })), })) @@ -246,25 +300,22 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () // Test Wrapper with QueryClientProvider -const createTestQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - }, - mutations: { - retry: false, +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + mutations: { + retry: false, + }, }, - }, -}) + }) const TestWrapper = ({ children }: { children: ReactNode }) => { const queryClient = createTestQueryClient() - return ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> - ) + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> } const renderWithProviders = (ui: React.ReactElement) => { @@ -310,26 +361,25 @@ const createMockRecord = (overrides = {}): HitTestingRecord => ({ created_by_role: 'account', created_by: 'user-1', created_at: 1609459200, - queries: [ - { content: 'Test query', content_type: 'text_query', file_info: null }, - ], + queries: [{ content: 'Test query', content_type: 'text_query', file_info: null }], ...overrides, }) -const _createMockRetrievalConfig = (overrides = {}): RetrievalConfig => ({ - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_mode: undefined, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - weights: undefined, - top_k: 10, - score_threshold_enabled: false, - score_threshold: 0.5, - ...overrides, -} as RetrievalConfig) +const _createMockRetrievalConfig = (overrides = {}): RetrievalConfig => + ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + weights: undefined, + top_k: 10, + score_threshold_enabled: false, + score_threshold: 0.5, + ...overrides, + }) as RetrievalConfig // HitTestingPage Component Tests // NOTE: Child component unit tests (Score, Mask, EmptyRecords, ResultItemMeta, @@ -381,7 +431,11 @@ describe('HitTestingPage', () => { renderWithProviders(<HitTestingPage datasetId="dataset-1" />) expect(screen.queryByRole('textbox')).not.toBeInTheDocument() - expect(vi.mocked(useDatasetTestingRecords)).toHaveBeenCalledWith('dataset-1', { limit: 10, page: 1 }, { enabled: false }) + expect(vi.mocked(useDatasetTestingRecords)).toHaveBeenCalledWith( + 'dataset-1', + { limit: 10, page: 1 }, + { enabled: false }, + ) }) }) @@ -396,7 +450,8 @@ describe('HitTestingPage', () => { const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) // Loading component should be visible - look for the loading animation - const loadingElement = container.querySelector('[class*="animate"]') || container.querySelector('.flex-1') + const loadingElement = + container.querySelector('[class*="animate"]') || container.querySelector('.flex-1') expect(loadingElement)!.toBeInTheDocument() }) }) @@ -448,7 +503,8 @@ describe('HitTestingPage', () => { const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) // Pagination should be visible - look for pagination controls - const paginationElement = container.querySelector('[class*="pagination"]') || container.querySelector('nav') + const paginationElement = + container.querySelector('[class*="pagination"]') || container.querySelector('nav') expect(paginationElement || screen.getAllByText('Test query').length > 0).toBeTruthy() }) }) @@ -468,13 +524,14 @@ describe('HitTestingPage', () => { // Find the method selector (cursor-pointer div with the retrieval method) const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find(el => !el.closest('button') && !el.closest('tr')) + const methodSelector = Array.from(methodSelectors).find( + (el) => !el.closest('button') && !el.closest('tr'), + ) // Verify we found a method selector to click expect(methodSelector).toBeTruthy() - if (methodSelector) - fireEvent.click(methodSelector) + if (methodSelector) fireEvent.click(methodSelector) // The component should still be functional after the click // The component should still be functional after the click @@ -544,9 +601,7 @@ describe('HitTestingPage', () => { describe('Record Interaction', () => { it('should update queries when a record is clicked', async () => { const mockRecord = createMockRecord({ - queries: [ - { content: 'Record query text', content_type: 'text_query', file_info: null }, - ], + queries: [{ content: 'Record query text', content_type: 'text_query', file_info: null }], }) const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') @@ -567,8 +622,7 @@ describe('HitTestingPage', () => { // Find and click the record row const recordText = screen.getByText('Record query text') const row = recordText.closest('tr') - if (row) - fireEvent.click(row) + if (row) fireEvent.click(row) // The query input should be updated - this causes re-render with new key // The query input should be updated - this causes re-render with new key @@ -604,7 +658,9 @@ describe('HitTestingPage', () => { it('should handle mobile breakpoint', async () => { // Mock mobile breakpoint const useBreakpoints = await import('@/hooks/use-breakpoints') - vi.mocked(useBreakpoints.default).mockReturnValue('mobile' as unknown as ReturnType<typeof useBreakpoints.default>) + vi.mocked(useBreakpoints.default).mockReturnValue( + 'mobile' as unknown as ReturnType<typeof useBreakpoints.default>, + ) const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) @@ -619,17 +675,23 @@ describe('HitTestingPage', () => { const useBreakpoints = await import('@/hooks/use-breakpoints') // First render with desktop - vi.mocked(useBreakpoints.default).mockReturnValue('pc' as unknown as ReturnType<typeof useBreakpoints.default>) + vi.mocked(useBreakpoints.default).mockReturnValue( + 'pc' as unknown as ReturnType<typeof useBreakpoints.default>, + ) const { rerender, container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) expect(container.firstChild)!.toBeInTheDocument() // Re-render with mobile - vi.mocked(useBreakpoints.default).mockReturnValue('mobile' as unknown as ReturnType<typeof useBreakpoints.default>) + vi.mocked(useBreakpoints.default).mockReturnValue( + 'mobile' as unknown as ReturnType<typeof useBreakpoints.default>, + ) rerender( - <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}> + <QueryClientProvider + client={new QueryClient({ defaultOptions: { queries: { retry: false } } })} + > <HitTestingPage datasetId="dataset-1" /> </QueryClientProvider>, ) @@ -646,7 +708,8 @@ describe('Integration: Hit Testing Flow', () => { mockHitTestingMutateAsync.mockReset() mockExternalHitTestingMutateAsync.mockReset() - const { useHitTesting, useExternalKnowledgeBaseHitTesting } = await import('@/service/knowledge/use-hit-testing') + const { useHitTesting, useExternalKnowledgeBaseHitTesting } = + await import('@/service/knowledge/use-hit-testing') vi.mocked(useHitTesting).mockReturnValue({ mutateAsync: mockHitTestingMutateAsync, isPending: false, @@ -671,17 +734,14 @@ describe('Integration: Hit Testing Flow', () => { renderWithProviders(<HitTestingPage datasetId="dataset-1" />) // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) + const textarea = await waitFor(() => screen.getByRole('textbox'), { timeout: 3000 }) // Type query fireEvent.change(textarea, { target: { value: 'Test query' } }) // Find submit button by class const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + const submitButton = buttons.find((btn) => btn.classList.contains('w-[88px]')) expect(submitButton).not.toBeDisabled() }) @@ -691,10 +751,7 @@ describe('Integration: Hit Testing Flow', () => { const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) + const textarea = await waitFor(() => screen.getByRole('textbox'), { timeout: 3000 }) // Type query fireEvent.change(textarea, { target: { value: 'Test query' } }) @@ -713,8 +770,7 @@ describe('Integration: Hit Testing Flow', () => { mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { // Call onSuccess synchronously to ensure state is updated - if (options?.onSuccess) - options.onSuccess(mockResponse) + if (options?.onSuccess) options.onSuccess(mockResponse) return mockResponse }) @@ -734,18 +790,14 @@ describe('Integration: Hit Testing Flow', () => { const { container: _container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) // Wait for textbox to be rendered with timeout for CI environment - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) + const textarea = await waitFor(() => screen.getByRole('textbox'), { timeout: 3000 }) // Type query fireEvent.change(textarea, { target: { value: 'Test query' } }) const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) + const submitButton = buttons.find((btn) => btn.classList.contains('w-[88px]')) + if (submitButton) fireEvent.click(submitButton) // Wait for the mutation to complete await waitFor( @@ -759,15 +811,11 @@ describe('Integration: Hit Testing Flow', () => { it('should render ResultItem components for non-external results', async () => { const mockResponse: HitTestingResponse = { query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, - records: [ - createMockHitTesting({ score: 0.95 }), - createMockHitTesting({ score: 0.85 }), - ], + records: [createMockHitTesting({ score: 0.95 }), createMockHitTesting({ score: 0.85 })], } mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - if (options?.onSuccess) - options.onSuccess(mockResponse) + if (options?.onSuccess) options.onSuccess(mockResponse) return mockResponse }) @@ -781,18 +829,14 @@ describe('Integration: Hit Testing Flow', () => { const { container: _container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) // Wait for component to be fully rendered with longer timeout - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) + const textarea = await waitFor(() => screen.getByRole('textbox'), { timeout: 3000 }) // Submit a query fireEvent.change(textarea, { target: { value: 'Test query' } }) const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) + const submitButton = buttons.find((btn) => btn.classList.contains('w-[88px]')) + if (submitButton) fireEvent.click(submitButton) // Wait for mutation to complete with longer timeout await waitFor( @@ -817,8 +861,7 @@ describe('Integration: Hit Testing Flow', () => { } mockExternalHitTestingMutateAsync.mockImplementation(async (_params, options) => { - if (options?.onSuccess) - options.onSuccess(mockExternalResponse) + if (options?.onSuccess) options.onSuccess(mockExternalResponse) return mockExternalResponse }) @@ -829,18 +872,14 @@ describe('Integration: Hit Testing Flow', () => { expect(container.firstChild)!.toBeInTheDocument() // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) + const textarea = await waitFor(() => screen.getByRole('textbox'), { timeout: 3000 }) // Type in textarea to verify component is functional fireEvent.change(textarea, { target: { value: 'Test query' } }) const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) + const submitButton = buttons.find((btn) => btn.classList.contains('w-[88px]')) + if (submitButton) fireEvent.click(submitButton) // Verify component is still functional after submission await waitFor( @@ -858,7 +897,8 @@ describe('Drawer and Modal Interactions', () => { beforeEach(async () => { vi.clearAllMocks() - const { useHitTesting, useExternalKnowledgeBaseHitTesting } = await import('@/service/knowledge/use-hit-testing') + const { useHitTesting, useExternalKnowledgeBaseHitTesting } = + await import('@/service/knowledge/use-hit-testing') vi.mocked(useHitTesting).mockReturnValue({ mutateAsync: mockHitTestingMutateAsync, isPending: false, @@ -875,7 +915,7 @@ describe('Drawer and Modal Interactions', () => { // Find and click the retrieval method selector to open the drawer const methodSelectors = container.querySelectorAll('.cursor-pointer') const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + (el) => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), ) if (methodSelector) { @@ -899,7 +939,7 @@ describe('Drawer and Modal Interactions', () => { // Open the modal first const methodSelectors = container.querySelectorAll('.cursor-pointer') const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + (el) => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), ) if (methodSelector) { @@ -919,7 +959,8 @@ describe('renderHitResults Coverage', () => { vi.clearAllMocks() mockHitTestingMutateAsync.mockReset() - const { useHitTesting, useExternalKnowledgeBaseHitTesting } = await import('@/service/knowledge/use-hit-testing') + const { useHitTesting, useExternalKnowledgeBaseHitTesting } = + await import('@/service/knowledge/use-hit-testing') vi.mocked(useHitTesting).mockReturnValue({ mutateAsync: mockHitTestingMutateAsync, isPending: false, @@ -944,27 +985,22 @@ describe('renderHitResults Coverage', () => { mockHitTestingMutateAsync.mockImplementation(async (params, options) => { // Simulate async behavior await Promise.resolve() - if (options?.onSuccess) - options.onSuccess(mockResponse) + if (options?.onSuccess) options.onSuccess(mockResponse) return mockResponse }) const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) + const textarea = await waitFor(() => screen.getByRole('textbox'), { timeout: 3000 }) // Enter query fireEvent.change(textarea, { target: { value: 'test query' } }) const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + const submitButton = buttons.find((btn) => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) + if (submitButton) fireEvent.click(submitButton) // Verify component is functional await waitFor(() => { @@ -973,14 +1009,11 @@ describe('renderHitResults Coverage', () => { }) it('should iterate through records and render ResultItem for each', async () => { - const mockRecords = [ - createMockHitTesting({ score: 0.9 }), - ] + const mockRecords = [createMockHitTesting({ score: 0.9 })] mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { const response = { query: { content: 'test' }, records: mockRecords } - if (options?.onSuccess) - options.onSuccess(response) + if (options?.onSuccess) options.onSuccess(response) return response }) @@ -990,9 +1023,8 @@ describe('renderHitResults Coverage', () => { fireEvent.change(textarea, { target: { value: 'test' } }) const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) + const submitButton = buttons.find((btn) => btn.classList.contains('w-[88px]')) + if (submitButton) fireEvent.click(submitButton) await waitFor(() => { expect(container.firstChild)!.toBeInTheDocument() @@ -1013,7 +1045,7 @@ describe('ModifyRetrievalModal onSave Coverage', () => { // Open the drawer const methodSelectors = container.querySelectorAll('.cursor-pointer') const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + (el) => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), ) if (methodSelector) { @@ -1036,11 +1068,10 @@ describe('ModifyRetrievalModal onSave Coverage', () => { // Open the drawer const methodSelectors = container.querySelectorAll('.cursor-pointer') const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + (el) => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), ) - if (methodSelector) - fireEvent.click(methodSelector) + if (methodSelector) fireEvent.click(methodSelector) // Component should still be rendered // Component should still be rendered @@ -1056,7 +1087,8 @@ describe('HitTestingPage Internal Functions Coverage', () => { mockHitTestingMutateAsync.mockReset() mockExternalHitTestingMutateAsync.mockReset() - const { useHitTesting, useExternalKnowledgeBaseHitTesting } = await import('@/service/knowledge/use-hit-testing') + const { useHitTesting, useExternalKnowledgeBaseHitTesting } = + await import('@/service/knowledge/use-hit-testing') vi.mocked(useHitTesting).mockReturnValue({ mutateAsync: mockHitTestingMutateAsync, isPending: false, @@ -1082,33 +1114,32 @@ describe('HitTestingPage Internal Functions Coverage', () => { // Setup mutation to call onSuccess synchronously mockHitTestingMutateAsync.mockImplementation((_params, options) => { // Synchronously call onSuccess - if (options?.onSuccess) - options.onSuccess(mockResponse) + if (options?.onSuccess) options.onSuccess(mockResponse) return Promise.resolve(mockResponse) }) const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) + const textarea = await waitFor(() => screen.getByRole('textbox'), { timeout: 3000 }) // Enter query and submit fireEvent.change(textarea, { target: { value: 'test query' } }) const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + const submitButton = buttons.find((btn) => btn.classList.contains('w-[88px]')) if (submitButton) { fireEvent.click(submitButton) } // Wait for state updates - await waitFor(() => { - expect(container.firstChild)!.toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(container.firstChild)!.toBeInTheDocument() + }, + { timeout: 3000 }, + ) // Verify mutation was called expect(mockHitTestingMutateAsync).toHaveBeenCalled() @@ -1120,7 +1151,7 @@ describe('HitTestingPage Internal Functions Coverage', () => { // Find and click retrieval method to open drawer const methodSelectors = container.querySelectorAll('.cursor-pointer') const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + (el) => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), ) if (methodSelector) { @@ -1155,23 +1186,22 @@ describe('HitTestingPage Internal Functions Coverage', () => { const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) + const textarea = await waitFor(() => screen.getByRole('textbox'), { timeout: 3000 }) // Submit a query fireEvent.change(textarea, { target: { value: 'test' } }) const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + const submitButton = buttons.find((btn) => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) + if (submitButton) fireEvent.click(submitButton) // Verify the component renders - await waitFor(() => { - expect(container.firstChild)!.toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(container.firstChild)!.toBeInTheDocument() + }, + { timeout: 3000 }, + ) }) }) diff --git a/web/app/components/datasets/hit-testing/__tests__/modify-external-retrieval-modal.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/modify-external-retrieval-modal.spec.tsx index 7a9ca20e403ba6..8b7f380d80474e 100644 --- a/web/app/components/datasets/hit-testing/__tests__/modify-external-retrieval-modal.spec.tsx +++ b/web/app/components/datasets/hit-testing/__tests__/modify-external-retrieval-modal.spec.tsx @@ -3,13 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import ModifyExternalRetrievalModal from '../modify-external-retrieval-modal' vi.mock('@/app/components/base/action-button', () => ({ - default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( - <button data-testid="action-button" onClick={onClick}>{children}</button> + default: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => ( + <button data-testid="action-button" onClick={onClick}> + {children} + </button> ), })) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick, variant }: { children: React.ReactNode, onClick: () => void, variant?: string }) => ( + Button: ({ + children, + onClick, + variant, + }: { + children: React.ReactNode + onClick: () => void + variant?: string + }) => ( <button data-testid={variant === 'primary' ? 'save-button' : 'cancel-button'} onClick={onClick}> {children} </button> @@ -17,13 +27,32 @@ vi.mock('@langgenius/dify-ui/button', () => ({ })) vi.mock('../../external-knowledge-base/create/RetrievalSettings', () => ({ - default: ({ topK, scoreThreshold, _scoreThresholdEnabled, onChange }: { topK: number, scoreThreshold: number, _scoreThresholdEnabled: boolean, onChange: (data: Record<string, unknown>) => void }) => ( + default: ({ + topK, + scoreThreshold, + _scoreThresholdEnabled, + onChange, + }: { + topK: number + scoreThreshold: number + _scoreThresholdEnabled: boolean + onChange: (data: Record<string, unknown>) => void + }) => ( <div data-testid="retrieval-settings"> <span data-testid="top-k">{topK}</span> <span data-testid="score-threshold">{scoreThreshold}</span> - <button data-testid="change-top-k" onClick={() => onChange({ top_k: 10 })}>change top k</button> - <button data-testid="change-score" onClick={() => onChange({ score_threshold: 0.9 })}>change score</button> - <button data-testid="change-enabled" onClick={() => onChange({ score_threshold_enabled: true })}>change enabled</button> + <button data-testid="change-top-k" onClick={() => onChange({ top_k: 10 })}> + change top k + </button> + <button data-testid="change-score" onClick={() => onChange({ score_threshold: 0.9 })}> + change score + </button> + <button + data-testid="change-enabled" + onClick={() => onChange({ score_threshold_enabled: true })} + > + change enabled + </button> </div> ), })) @@ -79,9 +108,7 @@ describe('ModifyExternalRetrievalModal', () => { render(<ModifyExternalRetrievalModal {...defaultProps} />) fireEvent.click(screen.getByTestId('change-top-k')) fireEvent.click(screen.getByTestId('save-button')) - expect(defaultProps.onSave).toHaveBeenCalledWith( - expect.objectContaining({ top_k: 10 }), - ) + expect(defaultProps.onSave).toHaveBeenCalledWith(expect.objectContaining({ top_k: 10 })) }) it('should save updated score threshold', () => { diff --git a/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx index 91d3a9923db298..23f7aa03285851 100644 --- a/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx +++ b/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx @@ -22,11 +22,14 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick }: { children: React.ReactNode, onClick: () => void, variant?: string }) => ( - <button onClick={onClick}> - {children} - </button> - ), + Button: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick: () => void + variant?: string + }) => <button onClick={onClick}>{children}</button>, })) vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ @@ -34,10 +37,21 @@ vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ })) vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({ - default: ({ value, onChange }: { value: RetrievalConfig, onChange: (v: RetrievalConfig) => void }) => ( + default: ({ + value, + onChange, + }: { + value: RetrievalConfig + onChange: (v: RetrievalConfig) => void + }) => ( <div data-testid="retrieval-method-config"> <span>{value.search_method}</span> - <button data-testid="change-config" onClick={() => onChange({ ...value, search_method: RETRIEVE_METHOD.hybrid })}>change</button> + <button + data-testid="change-config" + onClick={() => onChange({ ...value, search_method: RETRIEVE_METHOD.hybrid })} + > + change + </button> </div> ), })) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/child-chunks-item.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/child-chunks-item.spec.tsx index 9428f0ad455168..03588e042cf21e 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/child-chunks-item.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/child-chunks-item.spec.tsx @@ -48,9 +48,7 @@ describe('ChildChunksItem', () => { it('should render with besideChunkName styling on Score', () => { const payload = createChildChunkPayload({ score: 0.6 }) - const { container } = render( - <ChildChunksItem payload={payload} isShowAll={false} />, - ) + const { container } = render(<ChildChunksItem payload={payload} isShowAll={false} />) // Assert - Score with besideChunkName has h-[20.5px] and border-l-0 const scoreEl = container.querySelector('[class*="h-\\[20\\.5px\\]"]') @@ -63,9 +61,7 @@ describe('ChildChunksItem', () => { it('should apply line-clamp-2 when isShowAll is false', () => { const payload = createChildChunkPayload() - const { container } = render( - <ChildChunksItem payload={payload} isShowAll={false} />, - ) + const { container } = render(<ChildChunksItem payload={payload} isShowAll={false} />) const root = container.firstElementChild expect(root?.className).toContain('line-clamp-2') @@ -74,9 +70,7 @@ describe('ChildChunksItem', () => { it('should not apply line-clamp-2 when isShowAll is true', () => { const payload = createChildChunkPayload() - const { container } = render( - <ChildChunksItem payload={payload} isShowAll={true} />, - ) + const { container } = render(<ChildChunksItem payload={payload} isShowAll={true} />) const root = container.firstElementChild expect(root?.className).not.toContain('line-clamp-2') diff --git a/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx index 90e52bf2716bf7..73b93147219701 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx @@ -20,7 +20,9 @@ vi.mock('../../../documents/detail/completed/common/dot', () => ({ })) vi.mock('../../../documents/detail/completed/common/segment-index-tag', () => ({ - SegmentIndexTag: ({ positionId }: { positionId: number }) => <span data-testid="segment-index-tag">{positionId}</span>, + SegmentIndexTag: ({ positionId }: { positionId: number }) => ( + <span data-testid="segment-index-tag">{positionId}</span> + ), })) vi.mock('../../../documents/detail/completed/common/summary-text', () => ({ @@ -32,7 +34,9 @@ vi.mock('@/app/components/datasets/documents/detail/completed/common/tag', () => })) vi.mock('../child-chunks-item', () => ({ - default: ({ payload }: { payload: { id: string } }) => <div data-testid="child-chunk">{payload.id}</div>, + default: ({ payload }: { payload: { id: string } }) => ( + <div data-testid="child-chunk">{payload.id}</div> + ), })) vi.mock('../mask', () => ({ diff --git a/web/app/components/datasets/hit-testing/components/__tests__/records.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/records.spec.tsx index 649dcc4d25b6ea..f7efd02266657d 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/records.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/records.spec.tsx @@ -13,12 +13,13 @@ vi.mock('../../../common/image-list', () => ({ default: () => <div data-testid="image-list" />, })) -const makeRecord = (id: string, source: string, created_at: number, content = 'query text') => ({ - id, - source, - created_at, - queries: [{ content, content_type: 'text_query', file_info: null }], -}) as unknown as HitTestingRecord +const makeRecord = (id: string, source: string, created_at: number, content = 'query text') => + ({ + id, + source, + created_at, + queries: [{ content, content_type: 'text_query', file_info: null }], + }) as unknown as HitTestingRecord describe('Records', () => { const mockOnClick = vi.fn() @@ -35,10 +36,7 @@ describe('Records', () => { }) it('should render records', () => { - const records = [ - makeRecord('1', 'app', 1000), - makeRecord('2', 'hit_testing', 2000), - ] + const records = [makeRecord('1', 'app', 1000), makeRecord('2', 'hit_testing', 2000)] render(<Records records={records} onClickRecord={mockOnClick} />) expect(screen.getAllByText('query text')).toHaveLength(2) }) @@ -64,10 +62,7 @@ describe('Records', () => { }) it('should toggle sort order on time header click', () => { - const records = [ - makeRecord('1', 'app', 1000, 'early'), - makeRecord('2', 'app', 3000, 'late'), - ] + const records = [makeRecord('1', 'app', 1000, 'early'), makeRecord('2', 'app', 3000, 'late')] render(<Records records={records} onClickRecord={mockOnClick} />) // Default: desc, so late first @@ -80,15 +75,27 @@ describe('Records', () => { }) it('should render image list for image queries', () => { - const records = [{ - id: '1', - source: 'app', - created_at: 1000, - queries: [ - { content: '', content_type: 'text_query', file_info: null }, - { content: '', content_type: 'image_query', file_info: { name: 'img.png', mime_type: 'image/png', source_url: 'url', size: 100, extension: 'png' } }, - ], - }] as unknown as HitTestingRecord[] + const records = [ + { + id: '1', + source: 'app', + created_at: 1000, + queries: [ + { content: '', content_type: 'text_query', file_info: null }, + { + content: '', + content_type: 'image_query', + file_info: { + name: 'img.png', + mime_type: 'image/png', + source_url: 'url', + size: 100, + extension: 'png', + }, + }, + ], + }, + ] as unknown as HitTestingRecord[] render(<Records records={records} onClickRecord={mockOnClick} />) expect(screen.getByTestId('image-list')).toBeInTheDocument() }) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item-meta.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item-meta.spec.tsx index 0cd32ee82c8344..2c32471b7790bc 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/result-item-meta.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item-meta.spec.tsx @@ -10,41 +10,20 @@ describe('ResultItemMeta', () => { // Rendering tests for the result item meta component describe('Rendering', () => { it('should render the segment index tag with prefix and position', () => { - render( - <ResultItemMeta - labelPrefix="Chunk" - positionId={3} - wordCount={150} - score={0.9} - />, - ) + render(<ResultItemMeta labelPrefix="Chunk" positionId={3} wordCount={150} score={0.9} />) expect(screen.getByText('Chunk-03')).toBeInTheDocument() }) it('should render the word count', () => { - render( - <ResultItemMeta - labelPrefix="Chunk" - positionId={1} - wordCount={250} - score={0.8} - />, - ) + render(<ResultItemMeta labelPrefix="Chunk" positionId={1} wordCount={250} score={0.8} />) expect(screen.getByText(/250/)).toBeInTheDocument() expect(screen.getByText(/characters/i)).toBeInTheDocument() }) it('should render the score component', () => { - render( - <ResultItemMeta - labelPrefix="Chunk" - positionId={1} - wordCount={100} - score={0.75} - />, - ) + render(<ResultItemMeta labelPrefix="Chunk" positionId={1} wordCount={100} score={0.75} />) expect(screen.getByText('0.75')).toBeInTheDocument() expect(screen.getByText('score')).toBeInTheDocument() @@ -65,14 +44,7 @@ describe('ResultItemMeta', () => { }) it('should render dot separator', () => { - render( - <ResultItemMeta - labelPrefix="Chunk" - positionId={1} - wordCount={100} - score={0.5} - />, - ) + render(<ResultItemMeta labelPrefix="Chunk" positionId={1} wordCount={100} score={0.5} />) expect(screen.getByText('·')).toBeInTheDocument() }) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx index c8ef05418106c1..bf3b6ae055b030 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx @@ -12,7 +12,9 @@ vi.mock('../../../common/image-list', () => ({ })) vi.mock('../child-chunks-item', () => ({ - default: ({ payload }: { payload: { id: string } }) => <div data-testid="child-chunk">{payload.id}</div>, + default: ({ payload }: { payload: { id: string } }) => ( + <div data-testid="child-chunk">{payload.id}</div> + ), })) vi.mock('../chunk-detail-modal', () => ({ @@ -20,11 +22,15 @@ vi.mock('../chunk-detail-modal', () => ({ })) vi.mock('../result-item-footer', () => ({ - default: ({ docTitle }: { docTitle: string }) => <div data-testid="result-item-footer">{docTitle}</div>, + default: ({ docTitle }: { docTitle: string }) => ( + <div data-testid="result-item-footer">{docTitle}</div> + ), })) vi.mock('../result-item-meta', () => ({ - default: ({ positionId }: { positionId: number }) => <div data-testid="result-item-meta">{positionId}</div>, + default: ({ positionId }: { positionId: number }) => ( + <div data-testid="result-item-meta">{positionId}</div> + ), })) vi.mock('@/app/components/datasets/documents/detail/completed/common/summary-label', () => ({ @@ -104,7 +110,9 @@ describe('ResultItem', () => { it('should render images when files exist', () => { const payload = makePayload({ - files: [{ name: 'img.png', mime_type: 'image/png', source_url: 'url', size: 100, extension: 'png' }], + files: [ + { name: 'img.png', mime_type: 'image/png', source_url: 'url', size: 100, extension: 'png' }, + ], }) render(<ResultItem payload={payload} />) expect(screen.getByTestId('image-list')).toBeInTheDocument() diff --git a/web/app/components/datasets/hit-testing/components/child-chunks-item.tsx b/web/app/components/datasets/hit-testing/components/child-chunks-item.tsx index e467c905254044..2794eda62f8dc0 100644 --- a/web/app/components/datasets/hit-testing/components/child-chunks-item.tsx +++ b/web/app/components/datasets/hit-testing/components/child-chunks-item.tsx @@ -10,15 +10,10 @@ type Props = { readonly isShowAll: boolean } -const ChildChunks: FC<Props> = ({ - payload, - isShowAll, -}) => { +const ChildChunks: FC<Props> = ({ payload, isShowAll }) => { const { score, content, position } = payload return ( - <div - className={!isShowAll ? 'line-clamp-2 break-all' : ''} - > + <div className={!isShowAll ? 'line-clamp-2 break-all' : ''}> <div className="relative top-[-2px] inline-flex items-center"> <div className="flex h-[20.5px] items-center bg-state-accent-solid px-1 system-2xs-semibold-uppercase text-text-primary-on-surface"> C- @@ -26,7 +21,9 @@ const ChildChunks: FC<Props> = ({ </div> <Score value={score} besideChunkName /> </div> - <SliceContent className="bg-state-accent-hover py-0.5 text-sm font-normal text-text-secondary group-hover:bg-state-accent-hover">{content}</SliceContent> + <SliceContent className="bg-state-accent-hover py-0.5 text-sm font-normal text-text-secondary group-hover:bg-state-accent-hover"> + {content} + </SliceContent> </div> ) } diff --git a/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx b/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx index 3428974492446e..0560debf261056 100644 --- a/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx +++ b/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx @@ -24,22 +24,22 @@ type ChunkDetailModalProps = { onHide: () => void } -const ChunkDetailModal = ({ - payload, - onHide, -}: ChunkDetailModalProps) => { +const ChunkDetailModal = ({ payload, onHide }: ChunkDetailModalProps) => { const { t } = useTranslation() const { segment, score, child_chunks, files, summary } = payload const { position, content, sign_content, keywords, document, answer } = segment const isParentChildRetrieval = !!(child_chunks && child_chunks.length > 0) const extension = document.name.split('.').slice(-1)[0] as FileAppearanceTypeEnum - const heighClassName = isParentChildRetrieval ? 'h-[min(627px,80vh)] overflow-y-auto' : 'h-[min(539px,80vh)] overflow-y-auto' - const labelPrefix = isParentChildRetrieval ? t($ => $['segment.parentChunk'], { ns: 'datasetDocuments' }) : t($ => $['segment.chunk'], { ns: 'datasetDocuments' }) + const heighClassName = isParentChildRetrieval + ? 'h-[min(627px,80vh)] overflow-y-auto' + : 'h-[min(539px,80vh)] overflow-y-auto' + const labelPrefix = isParentChildRetrieval + ? t(($) => $['segment.parentChunk'], { ns: 'datasetDocuments' }) + : t(($) => $['segment.chunk'], { ns: 'datasetDocuments' }) const images = useMemo(() => { - if (!files) - return [] - return files.map(file => ({ + if (!files) return [] + return files.map((file) => ({ name: file.name, mimeType: file.mime_type, sourceUrl: file.source_url, @@ -55,11 +55,17 @@ const ChunkDetailModal = ({ <Dialog open onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > - <DialogContent className={cn('max-h-[calc(100dvh-2rem)] overflow-y-auto! border-none p-6 text-left align-middle', isParentChildRetrieval ? 'w-[1200px] max-w-none! min-w-[1200px]!' : 'w-[800px] max-w-none! min-w-[800px]!')}> + <DialogContent + className={cn( + 'max-h-[calc(100dvh-2rem)] overflow-y-auto! border-none p-6 text-left align-middle', + isParentChildRetrieval + ? 'w-[1200px] max-w-none! min-w-[1200px]!' + : 'w-[800px] max-w-none! min-w-[800px]!', + )} + > <DialogCloseButton onClick={(e) => { e.stopPropagation() @@ -67,7 +73,7 @@ const ChunkDetailModal = ({ }} /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $[`${i18nPrefix}chunkDetail`], { ns: 'datasetHitTesting' })} + {t(($) => $[`${i18nPrefix}chunkDetail`], { ns: 'datasetHitTesting' })} </DialogTitle> <div className="mt-4 flex"> @@ -83,7 +89,9 @@ const ChunkDetailModal = ({ <Dot /> <div className="flex grow items-center space-x-1"> <FileIcon type={extension} size="sm" /> - <span className="w-0 grow truncate text-[13px] font-normal text-text-secondary">{document.name}</span> + <span className="w-0 grow truncate text-[13px] font-normal text-text-secondary"> + {document.name} + </span> </div> </div> <Score value={score} /> @@ -100,13 +108,17 @@ const ChunkDetailModal = ({ {answer && ( <div className="break-all"> <div className="flex gap-x-1"> - <div className="w-4 shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary">Q</div> + <div className="w-4 shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary"> + Q + </div> <div className={cn('line-clamp-20 body-md-regular text-text-secondary')}> {content} </div> </div> <div className="flex gap-x-1"> - <div className="w-4 shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary">A</div> + <div className="w-4 shrink-0 text-[13px] leading-[20px] font-medium text-text-tertiary"> + A + </div> <div className={cn('line-clamp-20 body-md-regular text-text-secondary')}> {answer} </div> @@ -118,17 +130,15 @@ const ChunkDetailModal = ({ </div> {(showImages || showKeywords || !!summary) && ( <div className="flex flex-col gap-y-3 pt-3"> - {showImages && ( - <ImageList images={images} size="md" className="py-1" /> - )} - {!!summary && ( - <SummaryText value={summary} disabled /> - )} + {showImages && <ImageList images={images} size="md" className="py-1" />} + {!!summary && <SummaryText value={summary} disabled />} {showKeywords && ( <div className="flex flex-col gap-y-1"> - <div className="text-xs font-medium text-text-tertiary uppercase">{t($ => $[`${i18nPrefix}keyword`], { ns: 'datasetHitTesting' })}</div> + <div className="text-xs font-medium text-text-tertiary uppercase"> + {t(($) => $[`${i18nPrefix}keyword`], { ns: 'datasetHitTesting' })} + </div> <div className="flex flex-wrap gap-x-2"> - {keywords.map(keyword => ( + {keywords.map((keyword) => ( <Tag key={keyword} text={keyword} /> ))} </div> @@ -140,9 +150,14 @@ const ChunkDetailModal = ({ {isParentChildRetrieval && ( <div className="flex-1 pb-6 pl-6"> - <div className="system-xs-semibold-uppercase text-text-secondary">{t($ => $[`${i18nPrefix}hitChunks`], { ns: 'datasetHitTesting', num: child_chunks.length })}</div> + <div className="system-xs-semibold-uppercase text-text-secondary"> + {t(($) => $[`${i18nPrefix}hitChunks`], { + ns: 'datasetHitTesting', + num: child_chunks.length, + })} + </div> <div className={cn('mt-1 space-y-2', heighClassName)}> - {child_chunks.map(item => ( + {child_chunks.map((item) => ( <ChildChunksItem key={item.id} payload={item} isShowAll /> ))} </div> diff --git a/web/app/components/datasets/hit-testing/components/empty-records.tsx b/web/app/components/datasets/hit-testing/components/empty-records.tsx index 1f1fde5a4f71a2..cc75daac2131d8 100644 --- a/web/app/components/datasets/hit-testing/components/empty-records.tsx +++ b/web/app/components/datasets/hit-testing/components/empty-records.tsx @@ -9,7 +9,9 @@ const EmptyRecords = () => { <div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"> <RiHistoryLine className="size-5 text-text-tertiary" /> </div> - <div className="my-2 text-[13px] leading-4 font-medium text-text-tertiary">{t($ => $.noRecentTip, { ns: 'datasetHitTesting' })}</div> + <div className="my-2 text-[13px] leading-4 font-medium text-text-tertiary"> + {t(($) => $.noRecentTip, { ns: 'datasetHitTesting' })} + </div> </div> ) } diff --git a/web/app/components/datasets/hit-testing/components/mask.tsx b/web/app/components/datasets/hit-testing/components/mask.tsx index bdab61f2f754bf..7915ac4d3c16c4 100644 --- a/web/app/components/datasets/hit-testing/components/mask.tsx +++ b/web/app/components/datasets/hit-testing/components/mask.tsx @@ -5,14 +5,13 @@ type MaskProps = { className?: string } -const Mask = ({ - className, -}: MaskProps) => { +const Mask = ({ className }: MaskProps) => { return ( - <div className={cn( - 'h-12 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg', - className, - )} + <div + className={cn( + 'h-12 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg', + className, + )} /> ) } diff --git a/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx index 51f2150d457e8f..d1a66113eb3d94 100644 --- a/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx @@ -8,31 +8,61 @@ import QueryInput from '../index' // Capture onChange callback so tests can trigger handleImageChange let capturedOnChange: ((files: FileEntity[]) => void) | null = null -vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ - default: ({ textArea, actionButton, onChange }: { textArea: React.ReactNode, actionButton: React.ReactNode, onChange?: (files: FileEntity[]) => void }) => { - capturedOnChange = onChange ?? null - return ( - <div data-testid="image-uploader"> - {textArea} - {actionButton} - </div> - ) - }, -})) +vi.mock( + '@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', + () => ({ + default: ({ + textArea, + actionButton, + onChange, + }: { + textArea: React.ReactNode + actionButton: React.ReactNode + onChange?: (files: FileEntity[]) => void + }) => { + capturedOnChange = onChange ?? null + return ( + <div data-testid="image-uploader"> + {textArea} + {actionButton} + </div> + ) + }, + }), +) vi.mock('@/app/components/datasets/common/retrieval-method-info', () => ({ getIcon: () => '/test-icon.png', })) // Capture onSave callback for external retrieval modal -let _capturedModalOnSave: ((data: { top_k: number, score_threshold: number, score_threshold_enabled: boolean }) => void) | null = null +let _capturedModalOnSave: + | ((data: { top_k: number; score_threshold: number; score_threshold_enabled: boolean }) => void) + | null = null vi.mock('@/app/components/datasets/hit-testing/modify-external-retrieval-modal', () => ({ - default: ({ onSave, onClose }: { onSave: (data: { top_k: number, score_threshold: number, score_threshold_enabled: boolean }) => void, onClose: () => void }) => { + default: ({ + onSave, + onClose, + }: { + onSave: (data: { + top_k: number + score_threshold: number + score_threshold_enabled: boolean + }) => void + onClose: () => void + }) => { _capturedModalOnSave = onSave return ( <div data-testid="external-retrieval-modal"> - <button data-testid="modal-save" onClick={() => onSave({ top_k: 10, score_threshold: 0.8, score_threshold_enabled: true })}>Save</button> - <button data-testid="modal-close" onClick={onClose}>Close</button> + <button + data-testid="modal-save" + onClick={() => onSave({ top_k: 10, score_threshold: 0.8, score_threshold_enabled: true })} + > + Save + </button> + <button data-testid="modal-close" onClick={onClose}> + Close + </button> </div> ) }, @@ -41,7 +71,13 @@ vi.mock('@/app/components/datasets/hit-testing/modify-external-retrieval-modal', // Capture handleTextChange callback let _capturedHandleTextChange: ((e: React.ChangeEvent<HTMLTextAreaElement>) => void) | null = null vi.mock('../textarea', () => ({ - default: ({ text, handleTextChange }: { text: string, handleTextChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void }) => { + default: ({ + text, + handleTextChange, + }: { + text: string + handleTextChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void + }) => { _capturedHandleTextChange = handleTextChange return <textarea data-testid="textarea" defaultValue={text} onChange={handleTextChange} /> }, @@ -58,7 +94,9 @@ describe('QueryInput', () => { setHitResult: vi.fn(), setExternalHitResult: vi.fn(), loading: false, - queries: [{ content: 'test query', content_type: 'text_query', file_info: null }] satisfies Query[], + queries: [ + { content: 'test query', content_type: 'text_query', file_info: null }, + ] satisfies Query[], setQueries: vi.fn(), isExternal: false, onClickRetrievalMethod: vi.fn(), @@ -122,7 +160,9 @@ describe('QueryInput', () => { it('should disable submit button when text exceeds 200 characters', () => { const props = { ...defaultProps, - queries: [{ content: 'a'.repeat(201), content_type: 'text_query', file_info: null }] satisfies Query[], + queries: [ + { content: 'a'.repeat(201), content_type: 'text_query', file_info: null }, + ] satisfies Query[], } render(<QueryInput {...props} />) expect(screen.getByRole('button', { name: /input\.testing/ }))!.toBeDisabled() @@ -143,7 +183,14 @@ describe('QueryInput', () => { { content: 'https://img.example.com/1.png', content_type: 'image_query', - file_info: { id: 'img-1', name: 'photo.png', size: 1024, mime_type: 'image/png', extension: 'png', source_url: 'https://img.example.com/1.png' }, + file_info: { + id: 'img-1', + name: 'photo.png', + size: 1024, + mime_type: 'image/png', + extension: 'png', + source_url: 'https://img.example.com/1.png', + }, }, ] render(<QueryInput {...defaultProps} queries={queries} />) @@ -255,16 +302,18 @@ describe('QueryInput', () => { it('should update queries when images change', () => { render(<QueryInput {...defaultProps} />) - const files: FileEntity[] = [{ - id: 'f-1', - name: 'pic.jpg', - size: 2048, - mimeType: 'image/jpeg', - extension: 'jpg', - sourceUrl: 'https://img.example.com/pic.jpg', - uploadedId: 'uploaded-1', - progress: 100, - }] + const files: FileEntity[] = [ + { + id: 'f-1', + name: 'pic.jpg', + size: 2048, + mimeType: 'image/jpeg', + extension: 'jpg', + sourceUrl: 'https://img.example.com/pic.jpg', + uploadedId: 'uploaded-1', + progress: 100, + }, + ] capturedOnChange?.(files) @@ -283,15 +332,17 @@ describe('QueryInput', () => { it('should handle files with missing sourceUrl and uploadedId', () => { render(<QueryInput {...defaultProps} />) - const files: FileEntity[] = [{ - id: 'f-2', - name: 'no-url.jpg', - size: 512, - mimeType: 'image/jpeg', - extension: 'jpg', - progress: 100, - // sourceUrl and uploadedId are undefined - }] + const files: FileEntity[] = [ + { + id: 'f-2', + name: 'no-url.jpg', + size: 512, + mimeType: 'image/jpeg', + extension: 'jpg', + progress: 100, + // sourceUrl and uploadedId are undefined + }, + ] capturedOnChange?.(files) @@ -309,7 +360,18 @@ describe('QueryInput', () => { it('should replace all existing image queries with new ones', () => { const queries: Query[] = [ { content: 'text', content_type: 'text_query', file_info: null }, - { content: 'old-img', content_type: 'image_query', file_info: { id: 'old', name: 'old.png', size: 100, mime_type: 'image/png', extension: 'png', source_url: '' } }, + { + content: 'old-img', + content_type: 'image_query', + file_info: { + id: 'old', + name: 'old.png', + size: 100, + mime_type: 'image/png', + extension: 'png', + source_url: '', + }, + }, ] render(<QueryInput {...defaultProps} queries={queries} />) @@ -317,13 +379,11 @@ describe('QueryInput', () => { // Should keep text query but remove all image queries expect(defaultProps.setQueries).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ content_type: 'text_query' }), - ]), + expect.arrayContaining([expect.objectContaining({ content_type: 'text_query' })]), ) // Should not contain image_query const calledWith = defaultProps.setQueries.mock.calls[0]![0] as Query[] - expect(calledWith.filter(q => q.content_type === 'image_query')).toHaveLength(0) + expect(calledWith.filter((q) => q.content_type === 'image_query')).toHaveLength(0) }) }) @@ -361,7 +421,9 @@ describe('QueryInput', () => { return response }) - render(<QueryInput {...defaultProps} hitTestingMutation={mockMutation} onSubmit={mockOnSubmit} />) + render( + <QueryInput {...defaultProps} hitTestingMutation={mockMutation} onSubmit={mockOnSubmit} />, + ) fireEvent.click(screen.getByRole('button', { name: /input\.testing/ })) @@ -399,7 +461,13 @@ describe('QueryInput', () => { return response }) - render(<QueryInput {...defaultProps} isExternal={true} externalKnowledgeBaseHitTestingMutation={mockExternalMutation} />) + render( + <QueryInput + {...defaultProps} + isExternal={true} + externalKnowledgeBaseHitTestingMutation={mockExternalMutation} + />, + ) fireEvent.click(screen.getByRole('button', { name: /input\.testing/ })) @@ -423,7 +491,18 @@ describe('QueryInput', () => { it('should include image attachment_ids in submit request', async () => { const queries: Query[] = [ { content: 'test', content_type: 'text_query', file_info: null }, - { content: 'img-url', content_type: 'image_query', file_info: { id: 'img-id', name: 'pic.png', size: 100, mime_type: 'image/png', extension: 'png', source_url: 'img-url' } }, + { + content: 'img-url', + content_type: 'image_query', + file_info: { + id: 'img-id', + name: 'pic.png', + size: 100, + mime_type: 'image/png', + extension: 'png', + source_url: 'img-url', + }, + }, ] const mockResponse = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] } const mockMutation = vi.fn(async (_req, opts) => { diff --git a/web/app/components/datasets/hit-testing/components/query-input/__tests__/textarea.spec.tsx b/web/app/components/datasets/hit-testing/components/query-input/__tests__/textarea.spec.tsx index c7d5f8f799eec5..d6262358e063ef 100644 --- a/web/app/components/datasets/hit-testing/components/query-input/__tests__/textarea.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/query-input/__tests__/textarea.spec.tsx @@ -101,7 +101,9 @@ describe('Textarea', () => { // Assert - Corner icon should have red class const cornerWrapper = container.querySelector('.right-0.top-0') const cornerSvg = cornerWrapper?.querySelector('svg') - expect(cornerSvg?.className.baseVal || cornerSvg?.getAttribute('class')).toContain('text-util-colors-red-red-100') + expect(cornerSvg?.className.baseVal || cornerSvg?.getAttribute('class')).toContain( + 'text-util-colors-red-red-100', + ) }) }) diff --git a/web/app/components/datasets/hit-testing/components/query-input/index.tsx b/web/app/components/datasets/hit-testing/components/query-input/index.tsx index 46e52c45ace57e..8202dc19cf0ca2 100644 --- a/web/app/components/datasets/hit-testing/components/query-input/index.tsx +++ b/web/app/components/datasets/hit-testing/components/query-input/index.tsx @@ -12,10 +12,7 @@ import type { import type { RetrievalConfig } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - RiEqualizer2Line, - RiPlayCircleLine, -} from '@remixicon/react' +import { RiEqualizer2Line, RiPlayCircleLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -66,7 +63,7 @@ const QueryInput = ({ externalKnowledgeBaseHitTestingMutation, }: QueryInputProps) => { const { t } = useTranslation() - const isMultimodal = useDatasetDetailContextWithSelector(s => !!s.dataset?.is_multimodal) + const isMultimodal = useDatasetDetailContextWithSelector((s) => !!s.dataset?.is_multimodal) const [isSettingsOpen, setIsSettingsOpen] = useState(false) const [externalRetrievalSettings, setExternalRetrievalSettings] = useState({ top_k: 4, @@ -75,124 +72,151 @@ const QueryInput = ({ }) const text = useMemo(() => { - return queries.find(query => query.content_type === 'text_query')?.content ?? '' + return queries.find((query) => query.content_type === 'text_query')?.content ?? '' }, [queries]) const images = useMemo(() => { const imageQueries = queries - .filter(query => query.content_type === 'image_query') - .map(query => query.file_info) + .filter((query) => query.content_type === 'image_query') + .map((query) => query.file_info) .filter(Boolean) as Attachment[] - return imageQueries.map(item => ({ - id: uuid4(), - name: item.name, - size: item.size, - mimeType: item.mime_type, - extension: item.extension, - sourceUrl: item.source_url, - uploadedId: item.id, - progress: 100, - })) || [] + return ( + imageQueries.map((item) => ({ + id: uuid4(), + name: item.name, + size: item.size, + mimeType: item.mime_type, + extension: item.extension, + sourceUrl: item.source_url, + uploadedId: item.id, + progress: 100, + })) || [] + ) }, [queries]) const isAllUploaded = useMemo(() => { - return images.every(image => !!image.uploadedId) + return images.every((image) => !!image.uploadedId) }, [images]) - const handleSaveExternalRetrievalSettings = useCallback((data: { - top_k: number - score_threshold: number - score_threshold_enabled: boolean - }) => { - setExternalRetrievalSettings(data) - setIsSettingsOpen(false) - }, []) + const handleSaveExternalRetrievalSettings = useCallback( + (data: { top_k: number; score_threshold: number; score_threshold_enabled: boolean }) => { + setExternalRetrievalSettings(data) + setIsSettingsOpen(false) + }, + [], + ) - const handleTextChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => { - const newQueries = [...queries] - const textQuery = newQueries.find(query => query.content_type === 'text_query') - if (!textQuery) { - newQueries.push({ - content: event.target.value, - content_type: 'text_query', - file_info: null, - }) - } - else { - textQuery.content = event.target.value - } - setQueries(newQueries) - }, [queries, setQueries]) + const handleTextChange = useCallback( + (event: ChangeEvent<HTMLTextAreaElement>) => { + const newQueries = [...queries] + const textQuery = newQueries.find((query) => query.content_type === 'text_query') + if (!textQuery) { + newQueries.push({ + content: event.target.value, + content_type: 'text_query', + file_info: null, + }) + } else { + textQuery.content = event.target.value + } + setQueries(newQueries) + }, + [queries, setQueries], + ) - const handleImageChange = useCallback((files: FileEntity[]) => { - let newQueries = [...queries] - newQueries = newQueries.filter(query => query.content_type !== 'image_query') - files.forEach((file) => { - newQueries.push({ - content: file.sourceUrl || '', - content_type: 'image_query', - file_info: { - id: file.uploadedId || '', - mime_type: file.mimeType, - source_url: file.sourceUrl || '', - name: file.name, - size: file.size, - extension: file.extension, - }, + const handleImageChange = useCallback( + (files: FileEntity[]) => { + let newQueries = [...queries] + newQueries = newQueries.filter((query) => query.content_type !== 'image_query') + files.forEach((file) => { + newQueries.push({ + content: file.sourceUrl || '', + content_type: 'image_query', + file_info: { + id: file.uploadedId || '', + mime_type: file.mimeType, + source_url: file.sourceUrl || '', + name: file.name, + size: file.size, + extension: file.extension, + }, + }) }) - }) - setQueries(newQueries) - }, [queries, setQueries]) + setQueries(newQueries) + }, + [queries, setQueries], + ) const onSubmit = useCallback(async () => { - if (!canRunRetrievalRecall) - return + if (!canRunRetrievalRecall) return - await hitTestingMutation({ - query: text, - attachment_ids: images.map(image => image.uploadedId), - retrieval_model: { - ...retrievalConfig, - search_method: isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method, + await hitTestingMutation( + { + query: text, + attachment_ids: images.map((image) => image.uploadedId), + retrieval_model: { + ...retrievalConfig, + search_method: isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method, + }, }, - }, { - onSuccess: (data) => { - setHitResult(data) - onUpdateList?.() - if (_onSubmit) - _onSubmit() + { + onSuccess: (data) => { + setHitResult(data) + onUpdateList?.() + if (_onSubmit) _onSubmit() + }, }, - }) - }, [canRunRetrievalRecall, text, retrievalConfig, isEconomy, hitTestingMutation, onUpdateList, _onSubmit, images, setHitResult]) + ) + }, [ + canRunRetrievalRecall, + text, + retrievalConfig, + isEconomy, + hitTestingMutation, + onUpdateList, + _onSubmit, + images, + setHitResult, + ]) const externalRetrievalTestingOnSubmit = useCallback(async () => { - if (!canRunRetrievalRecall) - return + if (!canRunRetrievalRecall) return - await externalKnowledgeBaseHitTestingMutation({ - query: text, - external_retrieval_model: { - top_k: externalRetrievalSettings.top_k, - score_threshold: externalRetrievalSettings.score_threshold, - score_threshold_enabled: externalRetrievalSettings.score_threshold_enabled, + await externalKnowledgeBaseHitTestingMutation( + { + query: text, + external_retrieval_model: { + top_k: externalRetrievalSettings.top_k, + score_threshold: externalRetrievalSettings.score_threshold, + score_threshold_enabled: externalRetrievalSettings.score_threshold_enabled, + }, }, - }, { - onSuccess: (data) => { - setExternalHitResult(data) - onUpdateList?.() + { + onSuccess: (data) => { + setExternalHitResult(data) + onUpdateList?.() + }, }, - }) - }, [canRunRetrievalRecall, text, externalRetrievalSettings, externalKnowledgeBaseHitTestingMutation, onUpdateList, setExternalHitResult]) + ) + }, [ + canRunRetrievalRecall, + text, + externalRetrievalSettings, + externalKnowledgeBaseHitTestingMutation, + onUpdateList, + setExternalHitResult, + ]) const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method - const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" /> + const icon = ( + <img + className="size-3.5 text-util-colors-purple-purple-600" + src={getIcon(retrievalMethod)} + alt="" + /> + ) const TextAreaComp = useMemo(() => { - return ( - <Textarea - text={text} - handleTextChange={handleTextChange} - /> - ) + return <Textarea text={text} handleTextChange={handleTextChange} /> }, [text, handleTextChange]) const ActionButtonComp = useMemo(() => { return ( @@ -200,56 +224,75 @@ const QueryInput = ({ onClick={isExternal ? externalRetrievalTestingOnSubmit : onSubmit} variant="primary" loading={loading} - disabled={!canRunRetrievalRecall || (text.length === 0 && images.length === 0) || text.length > 200 || (images.length > 0 && !isAllUploaded)} + disabled={ + !canRunRetrievalRecall || + (text.length === 0 && images.length === 0) || + text.length > 200 || + (images.length > 0 && !isAllUploaded) + } className="w-[88px]" > <RiPlayCircleLine className="mr-1 size-4" /> - {t($ => $['input.testing'], { ns: 'datasetHitTesting' })} + {t(($) => $['input.testing'], { ns: 'datasetHitTesting' })} </Button> ) - }, [isExternal, externalRetrievalTestingOnSubmit, onSubmit, canRunRetrievalRecall, text, loading, t, images, isAllUploaded]) + }, [ + isExternal, + externalRetrievalTestingOnSubmit, + onSubmit, + canRunRetrievalRecall, + text, + loading, + t, + images, + isAllUploaded, + ]) return ( - <div className={cn('relative flex h-80 shrink-0 flex-col overflow-hidden rounded-xl bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs')}> + <div + className={cn( + 'relative flex h-80 shrink-0 flex-col overflow-hidden rounded-xl bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs', + )} + > <div className="flex h-full flex-col overflow-hidden rounded-[10px] bg-background-section-burn"> <div className="relative flex shrink-0 items-center justify-between p-1.5 pb-1 pl-3"> <span className="system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['input.title'], { ns: 'datasetHitTesting' })} + {t(($) => $['input.title'], { ns: 'datasetHitTesting' })} </span> - {isExternal - ? ( - <Button - variant="secondary" - size="small" - onClick={() => setIsSettingsOpen(!isSettingsOpen)} - > - <RiEqualizer2Line className="size-3.5 text-components-button-secondary-text" /> - <div className="flex items-center justify-center gap-1 px-[3px]"> - <span className="system-xs-medium text-components-button-secondary-text">{t($ => $.settingTitle, { ns: 'datasetHitTesting' })}</span> - </div> - </Button> - ) - : ( - <div - onClick={onClickRetrievalMethod} - className="flex h-7 cursor-pointer items-center space-x-0.5 rounded-lg border-[0.5px] border-components-button-secondary-bg bg-components-button-secondary-bg px-1.5 shadow-xs backdrop-blur-[5px] hover:bg-components-button-secondary-bg-hover" - > - {icon} - <div className="text-xs font-medium text-text-secondary uppercase">{t($ => $[`retrieval.${retrievalMethod}.title`], { ns: 'dataset' })}</div> - <RiEqualizer2Line className="size-4 text-components-menu-item-text"></RiEqualizer2Line> - </div> - )} - { - isSettingsOpen && ( - <ModifyExternalRetrievalModal - onClose={() => setIsSettingsOpen(false)} - onSave={handleSaveExternalRetrievalSettings} - initialTopK={externalRetrievalSettings.top_k} - initialScoreThreshold={externalRetrievalSettings.score_threshold} - initialScoreThresholdEnabled={externalRetrievalSettings.score_threshold_enabled} - /> - ) - } + {isExternal ? ( + <Button + variant="secondary" + size="small" + onClick={() => setIsSettingsOpen(!isSettingsOpen)} + > + <RiEqualizer2Line className="size-3.5 text-components-button-secondary-text" /> + <div className="flex items-center justify-center gap-1 px-[3px]"> + <span className="system-xs-medium text-components-button-secondary-text"> + {t(($) => $.settingTitle, { ns: 'datasetHitTesting' })} + </span> + </div> + </Button> + ) : ( + <div + onClick={onClickRetrievalMethod} + className="flex h-7 cursor-pointer items-center space-x-0.5 rounded-lg border-[0.5px] border-components-button-secondary-bg bg-components-button-secondary-bg px-1.5 shadow-xs backdrop-blur-[5px] hover:bg-components-button-secondary-bg-hover" + > + {icon} + <div className="text-xs font-medium text-text-secondary uppercase"> + {t(($) => $[`retrieval.${retrievalMethod}.title`], { ns: 'dataset' })} + </div> + <RiEqualizer2Line className="size-4 text-components-menu-item-text"></RiEqualizer2Line> + </div> + )} + {isSettingsOpen && ( + <ModifyExternalRetrievalModal + onClose={() => setIsSettingsOpen(false)} + onSave={handleSaveExternalRetrievalSettings} + initialTopK={externalRetrievalSettings.top_k} + initialScoreThreshold={externalRetrievalSettings.score_threshold} + initialScoreThresholdEnabled={externalRetrievalSettings.score_threshold_enabled} + /> + )} </div> <ImageUploaderInRetrievalTesting textArea={TextAreaComp} diff --git a/web/app/components/datasets/hit-testing/components/query-input/textarea.tsx b/web/app/components/datasets/hit-testing/components/query-input/textarea.tsx index da2c372e568fc5..fcacd12eeac465 100644 --- a/web/app/components/datasets/hit-testing/components/query-input/textarea.tsx +++ b/web/app/components/datasets/hit-testing/components/query-input/textarea.tsx @@ -10,56 +10,55 @@ type TextareaProps = { handleTextChange: (e: ChangeEvent<HTMLTextAreaElement>) => void } -const Textarea = ({ - text, - handleTextChange, -}: TextareaProps) => { +const Textarea = ({ text, handleTextChange }: TextareaProps) => { const { t } = useTranslation() return ( - <div className={cn( - 'relative flex-1 overflow-hidden rounded-t-[10px] border-t-[0.5px] border-components-panel-border-subtle bg-background-default px-4 pt-3 pb-0', - text.length > 200 && 'border-state-destructive-active', - )} + <div + className={cn( + 'relative flex-1 overflow-hidden rounded-t-[10px] border-t-[0.5px] border-components-panel-border-subtle bg-background-default px-4 pt-3 pb-0', + text.length > 200 && 'border-state-destructive-active', + )} > <textarea className="h-full w-full resize-none border-none bg-transparent system-md-regular text-text-secondary caret-[#295EFF] placeholder:text-components-input-text-placeholder focus-visible:outline-hidden" value={text} onChange={handleTextChange} - placeholder={t($ => $['input.placeholder'], { ns: 'datasetHitTesting' }) as string} + placeholder={t(($) => $['input.placeholder'], { ns: 'datasetHitTesting' }) as string} /> <div className="absolute top-0 right-0 flex items-center"> - <Corner className={cn( - 'text-background-section-burn', - text.length > 200 && 'text-util-colors-red-red-100', - )} + <Corner + className={cn( + 'text-background-section-burn', + text.length > 200 && 'text-util-colors-red-red-100', + )} /> - {text.length > 200 - ? ( - <Tooltip> - <TooltipTrigger - render={( - <div - className={cn('bg-util-colors-red-red-100 py-1 pr-2 system-2xs-medium-uppercase text-util-colors-red-red-600')} - > - {`${text.length}/200`} - </div> + {text.length > 200 ? ( + <Tooltip> + <TooltipTrigger + render={ + <div + className={cn( + 'bg-util-colors-red-red-100 py-1 pr-2 system-2xs-medium-uppercase text-util-colors-red-red-600', )} - /> - <TooltipContent> - {t($ => $['input.countWarning'], { ns: 'datasetHitTesting' })} - </TooltipContent> - </Tooltip> - ) - : ( - <div - className={cn( - 'bg-background-section-burn py-1 pr-2 system-2xs-medium-uppercase text-text-tertiary', - )} - > - {`${text.length}/200`} - </div> + > + {`${text.length}/200`} + </div> + } + /> + <TooltipContent> + {t(($) => $['input.countWarning'], { ns: 'datasetHitTesting' })} + </TooltipContent> + </Tooltip> + ) : ( + <div + className={cn( + 'bg-background-section-burn py-1 pr-2 system-2xs-medium-uppercase text-text-tertiary', )} + > + {`${text.length}/200`} + </div> + )} </div> </div> ) diff --git a/web/app/components/datasets/hit-testing/components/records.tsx b/web/app/components/datasets/hit-testing/components/records.tsx index bc7e316d328abf..cb15fb754dd759 100644 --- a/web/app/components/datasets/hit-testing/components/records.tsx +++ b/web/app/components/datasets/hit-testing/components/records.tsx @@ -12,17 +12,14 @@ type RecordsProps = { onClickRecord: (record: HitTestingRecord) => void } -const Records = ({ - records, - onClickRecord, -}: RecordsProps) => { +const Records = ({ records, onClickRecord }: RecordsProps) => { const { t } = useTranslation() const { formatTime } = useTimestamp() const [sortTimeOrder, setTimeOrder] = useState<'asc' | 'desc'>('desc') const handleSortTime = useCallback(() => { - setTimeOrder(prev => prev === 'asc' ? 'desc' : 'asc') + setTimeOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')) }, []) const sortedRecords = useMemo(() => { @@ -33,10 +30,10 @@ const Records = ({ const getImageList = (queries: Query[]) => { const imageQueries = queries - .filter(query => query.content_type === 'image_query') - .map(query => query.file_info) + .filter((query) => query.content_type === 'image_query') + .map((query) => query.file_info) .filter(Boolean) as Attachment[] - return imageQueries.map(image => ({ + return imageQueries.map((image) => ({ name: image.name, mimeType: image.mime_type, sourceUrl: image.source_url, @@ -50,19 +47,17 @@ const Records = ({ <table className="w-full border-collapse border-0 text-[13px] leading-4 text-text-secondary"> <thead className="sticky top-0 h-7 text-xs leading-7 font-medium text-text-tertiary uppercase backdrop-blur-[5px]"> <tr> - <td className="rounded-l-lg bg-background-section-burn pl-3">{t($ => $['table.header.queryContent'], { ns: 'datasetHitTesting' })}</td> - <td className="w-[128px] bg-background-section-burn pl-3">{t($ => $['table.header.source'], { ns: 'datasetHitTesting' })}</td> + <td className="rounded-l-lg bg-background-section-burn pl-3"> + {t(($) => $['table.header.queryContent'], { ns: 'datasetHitTesting' })} + </td> + <td className="w-[128px] bg-background-section-burn pl-3"> + {t(($) => $['table.header.source'], { ns: 'datasetHitTesting' })} + </td> <td className="w-48 rounded-r-lg bg-background-section-burn pl-3"> - <div - className="flex cursor-pointer items-center" - onClick={handleSortTime} - > - {t($ => $['table.header.time'], { ns: 'datasetHitTesting' })} + <div className="flex cursor-pointer items-center" onClick={handleSortTime}> + {t(($) => $['table.header.time'], { ns: 'datasetHitTesting' })} <RiArrowDownLine - className={cn( - 'ml-0.5 size-3.5', - sortTimeOrder === 'asc' ? 'rotate-180' : '', - )} + className={cn('ml-0.5 size-3.5', sortTimeOrder === 'asc' ? 'rotate-180' : '')} /> </div> </td> @@ -72,7 +67,8 @@ const Records = ({ {sortedRecords.map((record) => { const { id, source, created_at, queries } = record const SourceIcon = record.source === 'app' ? RiApps2Line : RiFocus2Line - const content = queries.find(query => query.content_type === 'text_query')?.content || '' + const content = + queries.find((query) => query.content_type === 'text_query')?.content || '' const images = getImageList(queries) return ( <tr @@ -82,29 +78,25 @@ const Records = ({ > <td className="max-w-xs p-3 pr-2"> <div className="flex flex-col gap-y-1"> - {content && ( - <div className="line-clamp-2"> - {content} - </div> - )} + {content && <div className="line-clamp-2">{content}</div>} {images.length > 0 && ( - <ImageList - images={images} - size="md" - className="py-1" - limit={5} - /> + <ImageList images={images} size="md" className="py-1" limit={5} /> )} </div> </td> <td className="w-[128px] p-3 pr-2"> <div className="flex items-center"> <SourceIcon className="mr-1 size-4 text-text-tertiary" /> - <span className="capitalize">{source.replace('_', ' ').replace('hit testing', 'retrieval test')}</span> + <span className="capitalize"> + {source.replace('_', ' ').replace('hit testing', 'retrieval test')} + </span> </div> </td> <td className="w-48 p-3 pr-2"> - {formatTime(created_at, t($ => $.dateTimeFormat, { ns: 'datasetHitTesting' }) as string)} + {formatTime( + created_at, + t(($) => $.dateTimeFormat, { ns: 'datasetHitTesting' }) as string, + )} </td> </tr> ) diff --git a/web/app/components/datasets/hit-testing/components/result-item-external.tsx b/web/app/components/datasets/hit-testing/components/result-item-external.tsx index fbbea7e26b715b..a8e46b50b43ac2 100644 --- a/web/app/components/datasets/hit-testing/components/result-item-external.tsx +++ b/web/app/components/datasets/hit-testing/components/result-item-external.tsx @@ -19,15 +19,22 @@ type Props = { const ResultItemExternal: FC<Props> = ({ payload, positionId }) => { const { t } = useTranslation() const { content, title, score } = payload - const [ - isShowDetailModal, - { setTrue: showDetailModal, setFalse: hideDetailModal }, - ] = useBoolean(false) + const [isShowDetailModal, { setTrue: showDetailModal, setFalse: hideDetailModal }] = + useBoolean(false) return ( - <div className={cn('cursor-pointer rounded-xl bg-chat-bubble-bg pt-3 hover:shadow-lg')} onClick={showDetailModal}> + <div + className={cn('cursor-pointer rounded-xl bg-chat-bubble-bg pt-3 hover:shadow-lg')} + onClick={showDetailModal} + > {/* Meta info */} - <ResultItemMeta className="px-3" labelPrefix="Chunk" positionId={positionId} wordCount={content.length} score={score} /> + <ResultItemMeta + className="px-3" + labelPrefix="Chunk" + positionId={positionId} + wordCount={content.length} + score={score} + /> {/* Main */} <div className="mt-1 px-3"> @@ -35,24 +42,32 @@ const ResultItemExternal: FC<Props> = ({ payload, positionId }) => { </div> {/* Foot */} - <ResultItemFooter docType={FileAppearanceTypeEnum.custom} docTitle={title} showDetailModal={showDetailModal} /> + <ResultItemFooter + docType={FileAppearanceTypeEnum.custom} + docTitle={title} + showDetailModal={showDetailModal} + /> {isShowDetailModal && ( <Dialog open={isShowDetailModal} onOpenChange={(open) => { - if (!open) - hideDetailModal() + if (!open) hideDetailModal() }} > <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-full min-w-[800px]! flex-col overflow-hidden! border-none text-left align-middle"> <DialogCloseButton /> <DialogTitle className="shrink-0 title-2xl-semi-bold text-text-primary"> - {t($ => $[`${i18nPrefix}chunkDetail`], { ns: 'datasetHitTesting' })} + {t(($) => $[`${i18nPrefix}chunkDetail`], { ns: 'datasetHitTesting' })} </DialogTitle> <div className="mt-4 flex min-h-0 flex-1 flex-col"> - <ResultItemMeta labelPrefix="Chunk" positionId={positionId} wordCount={content.length} score={score} /> + <ResultItemMeta + labelPrefix="Chunk" + positionId={positionId} + wordCount={content.length} + score={score} + /> <div className="mt-2 min-h-0 flex-1 overflow-y-auto body-md-regular break-all text-text-secondary"> {content} </div> diff --git a/web/app/components/datasets/hit-testing/components/result-item-footer.tsx b/web/app/components/datasets/hit-testing/components/result-item-footer.tsx index dacf0913b2402c..35cc30ea1db462 100644 --- a/web/app/components/datasets/hit-testing/components/result-item-footer.tsx +++ b/web/app/components/datasets/hit-testing/components/result-item-footer.tsx @@ -13,11 +13,7 @@ type Props = { } const i18nPrefix = '' -const ResultItemFooter: FC<Props> = ({ - docType, - docTitle, - showDetailModal, -}) => { +const ResultItemFooter: FC<Props> = ({ docType, docTitle, showDetailModal }) => { const { t } = useTranslation() return ( @@ -33,7 +29,9 @@ const ResultItemFooter: FC<Props> = ({ className="flex cursor-pointer items-center space-x-1 border-none bg-transparent p-0 text-left text-text-tertiary" onClick={showDetailModal} > - <div className="text-xs uppercase">{t($ => $[`${i18nPrefix}open`], { ns: 'datasetHitTesting' })}</div> + <div className="text-xs uppercase"> + {t(($) => $[`${i18nPrefix}open`], { ns: 'datasetHitTesting' })} + </div> <RiArrowRightUpLine className="size-3.5" aria-hidden /> </button> </div> diff --git a/web/app/components/datasets/hit-testing/components/result-item-meta.tsx b/web/app/components/datasets/hit-testing/components/result-item-meta.tsx index ebd9e0c70ab25c..a0d51d2a44b178 100644 --- a/web/app/components/datasets/hit-testing/components/result-item-meta.tsx +++ b/web/app/components/datasets/hit-testing/components/result-item-meta.tsx @@ -15,13 +15,7 @@ type Props = { readonly className?: string } -const ResultItemMeta: FC<Props> = ({ - labelPrefix, - positionId, - wordCount, - score, - className, -}) => { +const ResultItemMeta: FC<Props> = ({ labelPrefix, positionId, wordCount, score, className }) => { const { t } = useTranslation() return ( @@ -34,9 +28,8 @@ const ResultItemMeta: FC<Props> = ({ /> <Dot /> <div className="system-xs-medium text-text-tertiary"> - {wordCount} - {' '} - {t($ => $['segment.characters'], { ns: 'datasetDocuments', count: wordCount })} + {wordCount}{' '} + {t(($) => $['segment.characters'], { ns: 'datasetDocuments', count: wordCount })} </div> </div> <Score value={score} /> diff --git a/web/app/components/datasets/hit-testing/components/result-item.tsx b/web/app/components/datasets/hit-testing/components/result-item.tsx index 9beecc43f32674..3de7938d48702d 100644 --- a/web/app/components/datasets/hit-testing/components/result-item.tsx +++ b/web/app/components/datasets/hit-testing/components/result-item.tsx @@ -22,9 +22,7 @@ type ResultItemProps = { payload: HitTesting } -const ResultItem = ({ - payload, -}: ResultItemProps) => { +const ResultItem = ({ payload }: ResultItemProps) => { const { t } = useTranslation() const { segment, score, child_chunks, files, summary } = payload const data = segment @@ -32,20 +30,15 @@ const ResultItem = ({ const isParentChildRetrieval = !!(child_chunks && child_chunks.length > 0) const extension = document.name.split('.').slice(-1)[0] as FileAppearanceTypeEnum const fileType = extensionToFileType(extension) - const [isFold, { - toggle: toggleFold, - }] = useBoolean(false) + const [isFold, { toggle: toggleFold }] = useBoolean(false) const Icon = isFold ? RiArrowRightSLine : RiArrowDownSLine - const [isShowDetailModal, { - setTrue: showDetailModal, - setFalse: hideDetailModal, - }] = useBoolean(false) + const [isShowDetailModal, { setTrue: showDetailModal, setFalse: hideDetailModal }] = + useBoolean(false) const images = useMemo(() => { - if (!files) - return [] - return files.map(file => ({ + if (!files) return [] + return files.map((file) => ({ name: file.name, mimeType: file.mime_type, sourceUrl: file.source_url, @@ -55,9 +48,18 @@ const ResultItem = ({ }, [files]) return ( - <div className={cn('cursor-pointer rounded-xl bg-chat-bubble-bg pt-3 hover:shadow-lg')} onClick={showDetailModal}> + <div + className={cn('cursor-pointer rounded-xl bg-chat-bubble-bg pt-3 hover:shadow-lg')} + onClick={showDetailModal} + > {/* Meta info */} - <ResultItemMeta className="px-3" labelPrefix={`${isParentChildRetrieval ? 'Parent-' : ''}Chunk`} positionId={position} wordCount={word_count} score={score} /> + <ResultItemMeta + className="px-3" + labelPrefix={`${isParentChildRetrieval ? 'Parent-' : ''}Chunk`} + positionId={position} + wordCount={word_count} + score={score} + /> {/* Main */} <div className="mt-1 px-3"> @@ -66,25 +68,34 @@ const ResultItem = ({ content={sign_content || content} customDisallowedElements={['input']} /> - {images.length > 0 && ( - <ImageList images={images} size="md" className="py-1" /> - )} + {images.length > 0 && <ImageList images={images} size="md" className="py-1" />} {isParentChildRetrieval && ( <div className="mt-1"> <div - className={cn('inline-flex h-6 cursor-pointer items-center space-x-0.5 rounded-lg text-text-secondary select-none', isFold && 'bg-workflow-process-bg pl-1')} + className={cn( + 'inline-flex h-6 cursor-pointer items-center space-x-0.5 rounded-lg text-text-secondary select-none', + isFold && 'bg-workflow-process-bg pl-1', + )} onClick={(e) => { e.stopPropagation() toggleFold() }} > <Icon className={cn('size-4', isFold && 'opacity-50')} /> - <div className="text-xs font-semibold uppercase">{t($ => $[`${i18nPrefix}hitChunks`], { ns: 'datasetHitTesting', num: child_chunks.length })}</div> + <div className="text-xs font-semibold uppercase"> + {t(($) => $[`${i18nPrefix}hitChunks`], { + ns: 'datasetHitTesting', + num: child_chunks.length, + })} + </div> </div> {!isFold && ( <div className="space-y-2"> - {child_chunks.map(item => ( - <div key={item.id} className="ml-[7px] border-l-2 border-text-accent-secondary pl-[7px]"> + {child_chunks.map((item) => ( + <div + key={item.id} + className="ml-[7px] border-l-2 border-text-accent-secondary pl-[7px]" + > <ChildChunkItem payload={item} isShowAll={false} /> </div> ))} @@ -94,26 +105,21 @@ const ResultItem = ({ )} {!isParentChildRetrieval && keywords && keywords.length > 0 && ( <div className="mt-2 flex flex-wrap"> - {keywords.map(keyword => ( + {keywords.map((keyword) => ( <Tag key={keyword} text={keyword} className="mr-2" /> ))} </div> )} - {summary && ( - <SummaryLabel summary={summary} className="mt-2" /> - )} + {summary && <SummaryLabel summary={summary} className="mt-2" />} </div> {/* Foot */} - <ResultItemFooter docType={fileType} docTitle={document.name} showDetailModal={showDetailModal} /> + <ResultItemFooter + docType={fileType} + docTitle={document.name} + showDetailModal={showDetailModal} + /> - { - isShowDetailModal && ( - <ChunkDetailModal - payload={payload} - onHide={hideDetailModal} - /> - ) - } + {isShowDetailModal && <ChunkDetailModal payload={payload} onHide={hideDetailModal} />} </div> ) } diff --git a/web/app/components/datasets/hit-testing/components/score.tsx b/web/app/components/datasets/hit-testing/components/score.tsx index b7026eb87b5b1a..aff99e2e5461d8 100644 --- a/web/app/components/datasets/hit-testing/components/score.tsx +++ b/web/app/components/datasets/hit-testing/components/score.tsx @@ -8,16 +8,27 @@ type Props = { readonly besideChunkName?: boolean } -const Score: FC<Props> = ({ - value, - besideChunkName, -}) => { - if (!value || isNaN(value)) - return null +const Score: FC<Props> = ({ value, besideChunkName }) => { + if (!value || isNaN(value)) return null return ( - <div className={cn('relative items-center overflow-hidden border border-components-progress-bar-border px-[5px]', besideChunkName ? 'h-[20.5px] border-l-0' : 'h-[20px] rounded-md')}> - <div className={cn('absolute top-0 left-0 h-full border-r-[1.5px] border-components-progress-brand-progress bg-util-colors-blue-brand-blue-brand-100', value === 1 && 'border-r-0')} style={{ width: `${value * 100}%` }} /> - <div className={cn('relative flex h-full items-center space-x-0.5 text-util-colors-blue-brand-blue-brand-700')}> + <div + className={cn( + 'relative items-center overflow-hidden border border-components-progress-bar-border px-[5px]', + besideChunkName ? 'h-[20.5px] border-l-0' : 'h-[20px] rounded-md', + )} + > + <div + className={cn( + 'absolute top-0 left-0 h-full border-r-[1.5px] border-components-progress-brand-progress bg-util-colors-blue-brand-blue-brand-100', + value === 1 && 'border-r-0', + )} + style={{ width: `${value * 100}%` }} + /> + <div + className={cn( + 'relative flex h-full items-center space-x-0.5 text-util-colors-blue-brand-blue-brand-700', + )} + > <div className="system-2xs-medium-uppercase">score</div> <div className="system-xs-semibold">{value?.toFixed(2)}</div> </div> diff --git a/web/app/components/datasets/hit-testing/index.tsx b/web/app/components/datasets/hit-testing/index.tsx index cf227c8dd48513..c7cdb753eaf48d 100644 --- a/web/app/components/datasets/hit-testing/index.tsx +++ b/web/app/components/datasets/hit-testing/index.tsx @@ -59,7 +59,9 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => { const isMobile = media === MediaType.mobile const [hitResult, setHitResult] = useState<HitTestingResponse | undefined>() - const [externalHitResult, setExternalHitResult] = useState<ExternalKnowledgeBaseHitTestingResponse | undefined>() + const [externalHitResult, setExternalHitResult] = useState< + ExternalKnowledgeBaseHitTestingResponse | undefined + >() const [queries, setQueries] = useState<Query[]>([]) const [queryInputKey, setQueryInputKey] = useState(Date.now()) @@ -72,18 +74,32 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => { resourceMaintainer: currentDataset?.maintainer, workspacePermissionKeys, }).canRetrievalRecall - const { data: recordsRes, refetch: recordsRefetch, isLoading: isRecordsLoading } = useDatasetTestingRecords(datasetId, { limit, page: currPage + 1 }, { enabled: canRunRetrievalRecall }) + const { + data: recordsRes, + refetch: recordsRefetch, + isLoading: isRecordsLoading, + } = useDatasetTestingRecords( + datasetId, + { limit, page: currPage + 1 }, + { enabled: canRunRetrievalRecall }, + ) const total = recordsRes?.total || 0 const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1 const isExternal = currentDataset?.provider === 'external' - const [retrievalConfig, setRetrievalConfig] = useState(currentDataset?.retrieval_model_dict as RetrievalConfig) + const [retrievalConfig, setRetrievalConfig] = useState( + currentDataset?.retrieval_model_dict as RetrievalConfig, + ) const [isShowModifyRetrievalModal, setIsShowModifyRetrievalModal] = useState(false) - const [isShowRightPanel, { setTrue: showRightPanel, setFalse: hideRightPanel, set: setShowRightPanel }] = useBoolean(!isMobile) + const [ + isShowRightPanel, + { setTrue: showRightPanel, setFalse: hideRightPanel, set: setShowRightPanel }, + ] = useBoolean(!isMobile) - const { mutateAsync: hitTestingMutation, isPending: isHitTestingPending } = useHitTesting(datasetId) + const { mutateAsync: hitTestingMutation, isPending: isHitTestingPending } = + useHitTesting(datasetId) const { mutateAsync: externalKnowledgeBaseHitTestingMutation, isPending: isExternalKnowledgeBaseHitTestingPending, @@ -94,21 +110,19 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => { const renderHitResults = (results: HitTesting[] | ExternalKnowledgeBaseHitTesting[]) => ( <div className="flex h-full flex-col rounded-tl-2xl bg-background-body px-4 py-3"> <div className="mb-2 shrink-0 pl-2 leading-6 font-semibold text-text-primary"> - {t($ => $['hit.title'], { ns: 'datasetHitTesting', num: results.length })} + {t(($) => $['hit.title'], { ns: 'datasetHitTesting', num: results.length })} </div> <div className="grow space-y-2 overflow-y-auto"> {results.map((record, idx) => - isExternal - ? ( - <ResultItemExternal - key={idx} - positionId={idx + 1} - payload={record as ExternalKnowledgeBaseHitTesting} - /> - ) - : ( - <ResultItem key={idx} payload={record as HitTesting} /> - ), + isExternal ? ( + <ResultItemExternal + key={idx} + positionId={idx + 1} + payload={record as ExternalKnowledgeBaseHitTesting} + /> + ) : ( + <ResultItem key={idx} payload={record as HitTesting} /> + ), )} </div> </div> @@ -116,9 +130,11 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => { const renderEmptyState = () => ( <div className="flex h-full flex-col items-center justify-center rounded-tl-2xl bg-background-body px-4 py-3"> - <div className={cn(docStyle.commonIcon, docStyle.targetIcon, 'size-14! bg-text-quaternary!')} /> + <div + className={cn(docStyle.commonIcon, docStyle.targetIcon, 'size-14! bg-text-quaternary!')} + /> <div className="mt-3 text-[13px] text-text-quaternary"> - {t($ => $['hit.emptyTip'], { ns: 'datasetHitTesting' })} + {t(($) => $['hit.emptyTip'], { ns: 'datasetHitTesting' })} </div> </div> ) @@ -132,15 +148,18 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => { setShowRightPanel(!isMobile) }, [isMobile, setShowRightPanel]) - if (!canRunRetrievalRecall) - return <Loading type="app" /> + if (!canRunRetrievalRecall) return <Loading type="app" /> return ( <div className="relative flex size-full gap-x-6 overflow-y-auto pl-6"> <div className="flex min-w-0 flex-1 flex-col py-3"> <div className="mb-4 flex flex-col justify-center"> - <h1 className="text-base font-semibold text-text-primary">{t($ => $.title, { ns: 'datasetHitTesting' })}</h1> - <p className="mt-0.5 text-[13px] leading-4 font-normal text-text-tertiary">{t($ => $.desc, { ns: 'datasetHitTesting' })}</p> + <h1 className="text-base font-semibold text-text-primary"> + {t(($) => $.title, { ns: 'datasetHitTesting' })} + </h1> + <p className="mt-0.5 text-[13px] leading-4 font-normal text-text-tertiary"> + {t(($) => $.desc, { ns: 'datasetHitTesting' })} + </p> </div> <QueryInput key={queryInputKey} @@ -159,33 +178,34 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => { externalKnowledgeBaseHitTestingMutation={externalKnowledgeBaseHitTestingMutation} canRunRetrievalRecall={canRunRetrievalRecall} /> - <div className="mt-6 mb-3 text-base font-semibold text-text-primary">{t($ => $.records, { ns: 'datasetHitTesting' })}</div> + <div className="mt-6 mb-3 text-base font-semibold text-text-primary"> + {t(($) => $.records, { ns: 'datasetHitTesting' })} + </div> {isRecordsLoading && ( - <div className="flex-1"><Loading type="app" /></div> + <div className="flex-1"> + <Loading type="app" /> + </div> )} {!isRecordsLoading && recordsRes?.data && recordsRes.data.length > 0 && ( <> <Records records={recordsRes?.data} onClickRecord={handleClickRecord} /> - {(total && total > limit) - ? ( - <Pagination - page={currPage + 1} - totalPages={totalPages} - onPageChange={page => setCurrPage(page - 1)} - labels={{ - previous: t($ => $['pagination.previous'], { ns: 'common' }), - next: t($ => $['pagination.next'], { ns: 'common' }), - editPageNumber: (page, totalPages) => t($ => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), - pageNumberInput: t($ => $['pagination.pageNumber'], { ns: 'common' }), - }} - /> - ) - : null} + {total && total > limit ? ( + <Pagination + page={currPage + 1} + totalPages={totalPages} + onPageChange={(page) => setCurrPage(page - 1)} + labels={{ + previous: t(($) => $['pagination.previous'], { ns: 'common' }), + next: t(($) => $['pagination.next'], { ns: 'common' }), + editPageNumber: (page, totalPages) => + t(($) => $['pagination.editPageNumber'], { ns: 'common', page, totalPages }), + pageNumberInput: t(($) => $['pagination.pageNumber'], { ns: 'common' }), + }} + /> + ) : null} </> )} - {!isRecordsLoading && !recordsRes?.data?.length && ( - <EmptyRecords /> - )} + {!isRecordsLoading && !recordsRes?.data?.length && <EmptyRecords />} </div> <FloatRightContainer panelClassName="justify-start! overflow-y-auto!" @@ -195,23 +215,20 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => { onClose={hideRightPanel} > <div className="flex min-w-0 flex-1 flex-col pt-3"> - {isRetrievalLoading - ? ( - <div className="flex h-full flex-col rounded-tl-2xl bg-background-body px-4 py-3"> - <CardSkelton /> - </div> - ) - : ( - (() => { - if (!hitResult?.records.length && !externalHitResult?.records.length) - return renderEmptyState() - - if (hitResult?.records.length) - return renderHitResults(hitResult.records) - - return renderHitResults(externalHitResult?.records || []) - })() - )} + {isRetrievalLoading ? ( + <div className="flex h-full flex-col rounded-tl-2xl bg-background-body px-4 py-3"> + <CardSkelton /> + </div> + ) : ( + (() => { + if (!hitResult?.records.length && !externalHitResult?.records.length) + return renderEmptyState() + + if (hitResult?.records.length) return renderHitResults(hitResult.records) + + return renderHitResults(externalHitResult?.records || []) + })() + )} </div> </FloatRightContainer> <Drawer @@ -219,8 +236,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => { modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - setIsShowModifyRetrievalModal(false) + if (!open) setIsShowModifyRetrievalModal(false) }} > <DrawerPortal> diff --git a/web/app/components/datasets/hit-testing/modify-external-retrieval-modal.tsx b/web/app/components/datasets/hit-testing/modify-external-retrieval-modal.tsx index cd040c5adbe70a..9a995547b5b271 100644 --- a/web/app/components/datasets/hit-testing/modify-external-retrieval-modal.tsx +++ b/web/app/components/datasets/hit-testing/modify-external-retrieval-modal.tsx @@ -1,7 +1,5 @@ import { Button } from '@langgenius/dify-ui/button' -import { - RiCloseLine, -} from '@remixicon/react' +import { RiCloseLine } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' @@ -9,7 +7,11 @@ import RetrievalSettings from '../external-knowledge-base/create/RetrievalSettin type ModifyExternalRetrievalModalProps = { onClose: () => void - onSave: (data: { top_k: number, score_threshold: number, score_threshold_enabled: boolean }) => void + onSave: (data: { + top_k: number + score_threshold: number + score_threshold_enabled: boolean + }) => void initialTopK: number initialScoreThreshold: number initialScoreThresholdEnabled: boolean @@ -27,26 +29,32 @@ const ModifyExternalRetrievalModal: React.FC<ModifyExternalRetrievalModalProps> const [scoreThreshold, setScoreThreshold] = useState(initialScoreThreshold) const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(initialScoreThresholdEnabled) - const handleSettingsChange = (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => { - if (data.top_k !== undefined) - setTopK(data.top_k) - if (data.score_threshold !== undefined) - setScoreThreshold(data.score_threshold) + const handleSettingsChange = (data: { + top_k?: number + score_threshold?: number + score_threshold_enabled?: boolean + }) => { + if (data.top_k !== undefined) setTopK(data.top_k) + if (data.score_threshold !== undefined) setScoreThreshold(data.score_threshold) if (data.score_threshold_enabled !== undefined) setScoreThresholdEnabled(data.score_threshold_enabled) } const handleSave = () => { - onSave({ top_k: topK, score_threshold: scoreThreshold, score_threshold_enabled: scoreThresholdEnabled }) + onSave({ + top_k: topK, + score_threshold: scoreThreshold, + score_threshold_enabled: scoreThresholdEnabled, + }) onClose() } return ( - <div className="shadows-shadow-2xl absolute top-[36px] right-[14px] z-10 flex w-[320px] flex-col items-start rounded-2xl - border-[0.5px] border-components-panel-border bg-components-panel-bg" - > + <div className="shadows-shadow-2xl absolute top-[36px] right-[14px] z-10 flex w-[320px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg"> <div className="flex items-center justify-between self-stretch p-4 pb-2"> - <div className="grow system-xl-semibold text-text-primary">{t($ => $.settingTitle, { ns: 'datasetHitTesting' })}</div> + <div className="grow system-xl-semibold text-text-primary"> + {t(($) => $.settingTitle, { ns: 'datasetHitTesting' })} + </div> <ActionButton className="ml-auto" onClick={onClose}> <RiCloseLine className="size-4 shrink-0" /> </ActionButton> @@ -61,8 +69,12 @@ const ModifyExternalRetrievalModal: React.FC<ModifyExternalRetrievalModalProps> /> </div> <div className="flex w-full items-end justify-end gap-1 p-4 pt-2"> - <Button className="min-w-[72px] shrink-0" onClick={onClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" className="min-w-[72px] shrink-0" onClick={handleSave}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button className="min-w-[72px] shrink-0" onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button variant="primary" className="min-w-[72px] shrink-0" onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> </div> ) diff --git a/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx b/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx index 51bae3c744a781..378d955e47b0c7 100644 --- a/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx +++ b/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx @@ -29,8 +29,12 @@ const ModifyRetrievalModal: FC<Props> = ({ indexMethod, value, isShow, onHide, o const { t } = useTranslation() const docLink = useDocLink() const [retrievalConfig, setRetrievalConfig] = useState(value) - const embeddingModel = useDatasetDetailContextWithSelector(state => state.dataset?.embedding_model) - const embeddingModelProvider = useDatasetDetailContextWithSelector(state => state.dataset?.embedding_model_provider) + const embeddingModel = useDatasetDetailContextWithSelector( + (state) => state.dataset?.embedding_model, + ) + const embeddingModelProvider = useDatasetDetailContextWithSelector( + (state) => state.dataset?.embedding_model_provider, + ) // useClickAway(() => { // if (ref) // onHide() @@ -38,12 +42,14 @@ const ModifyRetrievalModal: FC<Props> = ({ indexMethod, value, isShow, onHide, o const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank) const handleSave = () => { - if (!isReRankModelSelected({ - rerankModelList, - retrievalConfig, - indexMethod, - })) { - toast.error(t($ => $['datasetConfig.rerankModelRequired'], { ns: 'appDebug' })) + if ( + !isReRankModelSelected({ + rerankModelList, + retrievalConfig, + indexMethod, + }) + ) { + toast.error(t(($) => $['datasetConfig.rerankModelRequired'], { ns: 'appDebug' })) return } onSave(retrievalConfig) @@ -63,9 +69,16 @@ const ModifyRetrievalModal: FC<Props> = ({ indexMethod, value, isShow, onHide, o embeddingModelList, rerankModelList, }) - }, [embeddingModelProvider, embeddingModel, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, indexMethod, embeddingModelList, rerankModelList]) - if (!isShow) - return null + }, [ + embeddingModelProvider, + embeddingModel, + retrievalConfig.reranking_enable, + retrievalConfig.reranking_model, + indexMethod, + embeddingModelList, + rerankModelList, + ]) + if (!isShow) return null return ( <div className="flex w-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9" @@ -76,19 +89,24 @@ const ModifyRetrievalModal: FC<Props> = ({ indexMethod, value, isShow, onHide, o > <div className="flex h-15 shrink-0 justify-between px-3 pt-3.5 pb-1"> <div className="text-base font-semibold text-text-primary"> - <div>{t($ => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })}</div> + <div>{t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })}</div> <div className="text-xs leading-[18px] font-normal text-text-tertiary"> - <a target="_blank" rel="noopener noreferrer" href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')} className="text-text-accent"> - {t($ => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })} + <a + target="_blank" + rel="noopener noreferrer" + href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')} + className="text-text-accent" + > + {t(($) => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })} </a> - {t($ => $['form.retrievalSetting.description'], { ns: 'datasetSettings' })} + {t(($) => $['form.retrievalSetting.description'], { ns: 'datasetSettings' })} </div> </div> <div className="flex"> <button type="button" className="flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onHide} > <RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" /> @@ -98,15 +116,25 @@ const ModifyRetrievalModal: FC<Props> = ({ indexMethod, value, isShow, onHide, o <div className="px-4 py-2"> <div className="mb-1 text-[13px] leading-6 font-semibold text-text-secondary"> - {t($ => $['form.retrievalSetting.method'], { ns: 'datasetSettings' })} + {t(($) => $['form.retrievalSetting.method'], { ns: 'datasetSettings' })} </div> - {indexMethod === 'high_quality' - ? (<RetrievalMethodConfig value={retrievalConfig} onChange={setRetrievalConfig} showMultiModalTip={showMultiModalTip} />) - : (<EconomicalRetrievalMethodConfig value={retrievalConfig} onChange={setRetrievalConfig} />)} + {indexMethod === 'high_quality' ? ( + <RetrievalMethodConfig + value={retrievalConfig} + onChange={setRetrievalConfig} + showMultiModalTip={showMultiModalTip} + /> + ) : ( + <EconomicalRetrievalMethodConfig value={retrievalConfig} onChange={setRetrievalConfig} /> + )} </div> <div className="flex justify-end p-4 pt-2"> - <Button className="mr-2 shrink-0" onClick={onHide}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" className="shrink-0" onClick={handleSave}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button className="mr-2 shrink-0" onClick={onHide}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button variant="primary" className="shrink-0" onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> </div> ) diff --git a/web/app/components/datasets/list/__tests__/datasets.spec.tsx b/web/app/components/datasets/list/__tests__/datasets.spec.tsx index 21fff153a197db..89f36743077898 100644 --- a/web/app/components/datasets/list/__tests__/datasets.spec.tsx +++ b/web/app/components/datasets/list/__tests__/datasets.spec.tsx @@ -70,9 +70,7 @@ vi.mock('../../rename-modal', () => ({ vi.mock('../dataset-card', () => ({ default: ({ dataset }: { dataset: DataSet }) => ( - <article data-testid={`dataset-card-${dataset.id}`}> - {dataset.name} - </article> + <article data-testid={`dataset-card-${dataset.id}`}>{dataset.name}</article> ), })) @@ -142,16 +140,17 @@ describe('Datasets', () => { ], }, ], - ) => ({ - pages: pages.map((page, index) => ({ - has_more: false, - limit: page.data.length, - page: index + 1, - total: page.data.length, - ...page, - })), - pageParams: pages.map((_, index) => index + 1), - }) as unknown as ReturnType<typeof useDatasetList>['data'] + ) => + ({ + pages: pages.map((page, index) => ({ + has_more: false, + limit: page.data.length, + page: index + 1, + total: page.data.length, + ...page, + })), + pageParams: pages.map((_, index) => index + 1), + }) as unknown as ReturnType<typeof useDatasetList>['data'] const defaultProps = { datasetList: createDatasetListData(), @@ -223,12 +222,7 @@ describe('Datasets', () => { describe('Loading States', () => { it('should show dataset card skeletons while initial dataset list is loading', () => { render( - <Datasets - {...defaultProps} - datasetList={undefined} - isFetching={true} - isLoading={true} - />, + <Datasets {...defaultProps} datasetList={undefined} isFetching={true} isLoading={true} />, ) expect(screen.getByRole('status', { name: /common\.loading/ })).toBeInTheDocument() @@ -265,7 +259,9 @@ describe('Datasets', () => { render( <Datasets {...defaultProps} - datasetList={createDatasetListData([{ data: [createMockDataset({ id: 'dataset-1', name: 'Dataset 1' })] }])} + datasetList={createDatasetListData([ + { data: [createMockDataset({ id: 'dataset-1', name: 'Dataset 1' })] }, + ])} isFetching={true} isPlaceholderData={true} />, @@ -388,7 +384,16 @@ describe('Datasets', () => { it('should have correct grid styling', () => { render(<Datasets {...defaultProps} />) const nav = screen.getByRole('navigation') - expect(nav).toHaveClass('relative', 'grid', 'grow', 'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', 'content-start', 'gap-3', 'px-8', 'pt-2') + expect(nav).toHaveClass( + 'relative', + 'grid', + 'grow', + 'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', + 'content-start', + 'gap-3', + 'px-8', + 'pt-2', + ) }) }) diff --git a/web/app/components/datasets/list/__tests__/header.spec.tsx b/web/app/components/datasets/list/__tests__/header.spec.tsx index 04ae8513d06c22..29c4def89ca199 100644 --- a/web/app/components/datasets/list/__tests__/header.spec.tsx +++ b/web/app/components/datasets/list/__tests__/header.spec.tsx @@ -2,18 +2,32 @@ import { render, screen } from '@testing-library/react' import DatasetListHeader from '../header' vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, className }: { children: React.ReactNode, className?: string }) => ( - <button type="button" className={className}>{children}</button> + Button: ({ children, className }: { children: React.ReactNode; className?: string }) => ( + <button type="button" className={className}> + {children} + </button> ), })) vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({ DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - DropdownMenuItem: ({ children, className, onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => ( - <button type="button" className={className} onClick={onClick}>{children}</button> + DropdownMenuItem: ({ + children, + className, + onClick, + }: { + children: React.ReactNode + className?: string + onClick?: () => void + }) => ( + <button type="button" className={className} onClick={onClick}> + {children} + </button> + ), + DropdownMenuSeparator: ({ className }: { className?: string }) => ( + <hr data-testid="create-menu-separator" className={className} /> ), - DropdownMenuSeparator: ({ className }: { className?: string }) => <hr data-testid="create-menu-separator" className={className} />, DropdownMenuTrigger: ({ render }: { render: React.ReactNode }) => render, })) @@ -55,7 +69,9 @@ describe('DatasetListHeader', () => { it('uses the updated create menu labels and pipeline icon', () => { render(<DatasetListHeader {...defaultProps} />) - expect(screen.getByRole('button', { name: /dataset\.firstEmpty\.createTitle/ })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /dataset\.firstEmpty\.createTitle/ }), + ).toBeInTheDocument() const menuItem = screen.getByRole('button', { name: /dataset\.firstEmpty\.pipelineTitle/ }) @@ -65,26 +81,34 @@ describe('DatasetListHeader', () => { it('should hide dataset creation actions when dataset.create_and_management is unavailable', () => { render(<DatasetListHeader {...defaultProps} canCreateDataset={false} />) - expect(screen.queryByRole('button', { name: /dataset\.firstEmpty\.createTitle/ })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: /dataset\.firstEmpty\.pipelineTitle/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /dataset\.firstEmpty\.createTitle/ }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /dataset\.firstEmpty\.pipelineTitle/ }), + ).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /dataset\.connectDataset/ })).toBeInTheDocument() }) it('should hide external API panel entry when dataset.external.connect is unavailable', () => { render(<DatasetListHeader {...defaultProps} canConnectExternalDataset={false} />) - expect(screen.queryByRole('button', { name: /dataset\.externalAPIPanelTitle/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /dataset\.externalAPIPanelTitle/ }), + ).not.toBeInTheDocument() }) it('should hide the create menu when no creation or connection action is available', () => { - render(( + render( <DatasetListHeader {...defaultProps} canConnectExternalDataset={false} canCreateDataset={false} - /> - )) + />, + ) - expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /common\.operation\.create/ }), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/list/__tests__/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx index d4402b05f7cd49..c5b8c8987065a1 100644 --- a/web/app/components/datasets/list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/__tests__/index.spec.tsx @@ -21,27 +21,32 @@ vi.mock('@/next/navigation', () => ({ // Mock app context vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) @@ -56,7 +61,8 @@ vi.mock('@/context/external-api-panel-context', () => ({ })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) @@ -96,7 +102,13 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ // Mock Datasets component vi.mock('../datasets', () => ({ - default: ({ datasetList, emptyElement }: { datasetList?: { pages: Array<{ total?: number }> }, emptyElement?: ReactNode }) => ( + default: ({ + datasetList, + emptyElement, + }: { + datasetList?: { pages: Array<{ total?: number }> } + emptyElement?: ReactNode + }) => ( <div data-testid="datasets-component"> <span data-testid="dataset-total">{datasetList?.pages[0]?.total}</span> {emptyElement} @@ -106,8 +118,17 @@ vi.mock('../datasets', () => ({ // Mock ExternalAPIPanel component vi.mock('../../external-api/external-api-panel', () => ({ - default: ({ canManageExternalKnowledgeApi, onClose }: { canManageExternalKnowledgeApi: boolean, onClose: () => void }) => ( - <div data-testid="external-api-panel" data-can-manage-external-knowledge-api={canManageExternalKnowledgeApi}> + default: ({ + canManageExternalKnowledgeApi, + onClose, + }: { + canManageExternalKnowledgeApi: boolean + onClose: () => void + }) => ( + <div + data-testid="external-api-panel" + data-can-manage-external-knowledge-api={canManageExternalKnowledgeApi} + > <button onClick={onClose}>Close Panel</button> </div> ), @@ -123,12 +144,20 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ // Mock TagManagementModal vi.mock('@/features/tag-management/components/tag-management-modal', () => ({ - TagManagementModal: ({ show }: { show: boolean }) => show ? <div data-testid="tag-management-modal" /> : null, + TagManagementModal: ({ show }: { show: boolean }) => + show ? <div data-testid="tag-management-modal" /> : null, })) // Mock TagFilter vi.mock('@/features/tag-management/components/tag-filter', () => ({ - TagFilter: ({ onChange, onOpenTagManagement }: { value: string[], onChange: (val: string[]) => void, onOpenTagManagement: () => void }) => ( + TagFilter: ({ + onChange, + onOpenTagManagement, + }: { + value: string[] + onChange: (val: string[]) => void + onOpenTagManagement: () => void + }) => ( <div data-testid="tag-filter"> <button onClick={() => onChange(['tag-1', 'tag-2'])}>Select Tags</button> <button onClick={onOpenTagManagement}>Manage Tags</button> @@ -138,7 +167,15 @@ vi.mock('@/features/tag-management/components/tag-filter', () => ({ // Mock CheckboxWithLabel vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({ - default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => ( + default: ({ + isChecked, + onChange, + label, + }: { + isChecked: boolean + onChange: () => void + label: string + }) => ( <label> <input type="checkbox" @@ -211,9 +248,11 @@ describe('List', () => { render(<List />) - expect(useDatasetList).toHaveBeenCalledWith(expect.objectContaining({ - include_all: false, - })) + expect(useDatasetList).toHaveBeenCalledWith( + expect.objectContaining({ + include_all: false, + }), + ) }) it('should query datasets with empty keywords initially', async () => { @@ -221,9 +260,11 @@ describe('List', () => { render(<List />) - expect(useDatasetList).toHaveBeenCalledWith(expect.objectContaining({ - keyword: '', - })) + expect(useDatasetList).toHaveBeenCalledWith( + expect.objectContaining({ + keyword: '', + }), + ) }) it('should query datasets with empty tags initially', async () => { @@ -231,9 +272,11 @@ describe('List', () => { render(<List />) - expect(useDatasetList).toHaveBeenCalledWith(expect.objectContaining({ - tag_ids: [], - })) + expect(useDatasetList).toHaveBeenCalledWith( + expect.objectContaining({ + tag_ids: [], + }), + ) }) }) @@ -323,7 +366,9 @@ describe('List', () => { render(<List />) expect(screen.getByText('dataset.firstEmpty.title')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /dataset\.firstEmpty\.pipelineTitle/ })).toHaveAttribute('href', '/datasets/create-from-pipeline') + expect( + screen.getByRole('link', { name: /dataset\.firstEmpty\.pipelineTitle/ }), + ).toHaveAttribute('href', '/datasets/create-from-pipeline') }) it('should not render first empty state for legacy editors without dataset creation permissions', async () => { @@ -366,13 +411,16 @@ describe('List', () => { it('should keep the regular list for empty filtered results', async () => { const { useDatasetList } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetList).mockImplementation(params => ({ - data: { pages: [{ data: [], total: params.include_all ? 0 : 1 }] }, - fetchNextPage: vi.fn(), - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - } as unknown as ReturnType<typeof useDatasetList>)) + vi.mocked(useDatasetList).mockImplementation( + (params) => + ({ + data: { pages: [{ data: [], total: params.include_all ? 0 : 1 }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + }) as unknown as ReturnType<typeof useDatasetList>, + ) render(<List />) @@ -430,7 +478,10 @@ describe('List', () => { render(<ListComponent />) expect(screen.getByTestId('external-api-panel')).toBeInTheDocument() - expect(screen.getByTestId('external-api-panel')).toHaveAttribute('data-can-manage-external-knowledge-api', 'true') + expect(screen.getByTestId('external-api-panel')).toHaveAttribute( + 'data-can-manage-external-knowledge-api', + 'true', + ) }) it('should not show ExternalAPIPanel without dataset.external.connect even when panel state is open', async () => { diff --git a/web/app/components/datasets/list/dataset-card-skeleton.tsx b/web/app/components/datasets/list/dataset-card-skeleton.tsx index 8535dd3601860e..05dd74afb48e27 100644 --- a/web/app/components/datasets/list/dataset-card-skeleton.tsx +++ b/web/app/components/datasets/list/dataset-card-skeleton.tsx @@ -5,10 +5,7 @@ type DatasetCardSkeletonProps = { count?: number } -const DatasetCardSkeleton = ({ - label, - count = 6, -}: DatasetCardSkeletonProps) => ( +const DatasetCardSkeleton = ({ label, count = 6 }: DatasetCardSkeletonProps) => ( <div className="contents" role="status" aria-label={label} aria-live="polite"> {Array.from({ length: count }, (_, index) => ( <div diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx index 584397f8a1823e..b8e78e15451af9 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx @@ -24,15 +24,26 @@ const mockOpenAccessConfig = vi.fn() const mockCloseAccessConfig = vi.fn() const toastMocks = vi.hoisted(() => { const record = vi.fn() - const api = Object.assign(vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })), { - success: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'success', message, ...options })), - error: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'error', message, ...options })), - warning: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'warning', message, ...options })), - info: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'info', message, ...options })), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }) + const api = Object.assign( + vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })), + { + success: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'success', message, ...options }), + ), + error: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'info', message, ...options }), + ), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), + }, + ) return { record, api } }) @@ -51,27 +62,32 @@ let mockAppContextState = { } vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState) }) @@ -96,7 +112,8 @@ vi.mock('../hooks/use-dataset-card-state', () => ({ })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) @@ -105,11 +122,16 @@ vi.mock('../components/corner-labels', () => ({ default: () => <div data-testid="corner-labels" />, })) vi.mock('../components/dataset-card-header', () => ({ - default: ({ dataset }: { dataset: DataSet }) => <div data-testid="card-header">{dataset.name}</div>, + default: ({ dataset }: { dataset: DataSet }) => ( + <div data-testid="card-header">{dataset.name}</div> + ), })) vi.mock('../components/dataset-card-modals', () => ({ default: ({ onCloseAccessConfig }: { onCloseAccessConfig?: () => void }) => ( - <div data-testid="card-modals" data-has-close-access-config={typeof onCloseAccessConfig === 'function'} /> + <div + data-testid="card-modals" + data-has-close-access-config={typeof onCloseAccessConfig === 'function'} + /> ), })) vi.mock('@/features/tag-management/components/dataset-card-tags', () => ({ @@ -129,34 +151,38 @@ vi.mock('@/features/tag-management/components/dataset-card-tags', () => ({ })) vi.mock('../components/operations-dropdown', () => ({ default: ({ openAccessConfig }: { openAccessConfig?: () => void }) => ( - <div data-testid="operations-dropdown" data-has-open-access-config={typeof openAccessConfig === 'function'} /> + <div + data-testid="operations-dropdown" + data-has-open-access-config={typeof openAccessConfig === 'function'} + /> ), })) // Factory function for DataSet mock data -const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ - id: 'dataset-1', - name: 'Test Dataset', - description: 'Test description', - provider: 'vendor', - permission: DatasetPermission.allTeamMembers, - data_source_type: DataSourceType.FILE, - indexing_technique: IndexingType.QUALIFIED, - embedding_available: true, - app_count: 5, - document_count: 10, - word_count: 1000, - created_at: 1609459200, - updated_at: 1609545600, - tags: [], - embedding_model: 'text-embedding-ada-002', - embedding_model_provider: 'openai', - created_by: 'user-1', - doc_form: ChunkingMode.text, - total_available_documents: 10, - runtime_mode: 'general', - ...overrides, -} as DataSet) +const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => + ({ + id: 'dataset-1', + name: 'Test Dataset', + description: 'Test description', + provider: 'vendor', + permission: DatasetPermission.allTeamMembers, + data_source_type: DataSourceType.FILE, + indexing_technique: IndexingType.QUALIFIED, + embedding_available: true, + app_count: 5, + document_count: 10, + word_count: 1000, + created_at: 1609459200, + updated_at: 1609545600, + tags: [], + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + created_by: 'user-1', + doc_form: ChunkingMode.text, + total_available_documents: 10, + runtime_mode: 'general', + ...overrides, + }) as DataSet describe('DatasetCard Integration', () => { beforeEach(() => { @@ -329,7 +355,9 @@ describe('DatasetCard Component', () => { const dataset = createMockDataset({ name: 'Preview Only Dataset', permission_keys: [DatasetACLPermission.Preview], - tags: [{ id: 'tag-preview', name: 'Readonly Tag', type: 'knowledge' as const, binding_count: '' }], + tags: [ + { id: 'tag-preview', name: 'Readonly Tag', type: 'knowledge' as const, binding_count: '' }, + ], }) render(<DatasetCard dataset={dataset} />) @@ -384,8 +412,14 @@ describe('DatasetCard Component', () => { const dataset = createMockDataset() render(<DatasetCard dataset={dataset} />) - expect(screen.getByTestId('operations-dropdown')).toHaveAttribute('data-has-open-access-config', 'true') - expect(screen.getByTestId('card-modals')).toHaveAttribute('data-has-close-access-config', 'true') + expect(screen.getByTestId('operations-dropdown')).toHaveAttribute( + 'data-has-open-access-config', + 'true', + ) + expect(screen.getByTestId('card-modals')).toHaveAttribute( + 'data-has-close-access-config', + 'true', + ) }) it('should navigate to hitTesting for external provider', () => { diff --git a/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx index 015f7f1cbb5de8..d3d5f8d5deb599 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx @@ -4,11 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import Operations from '../operations' function renderInMenu(ui: React.ReactElement) { - return render( - <DropdownMenu open> - {ui} - </DropdownMenu>, - ) + return render(<DropdownMenu open>{ui}</DropdownMenu>) } describe('Operations', () => { @@ -84,7 +80,9 @@ describe('Operations', () => { it('should call openAccessConfig when access config is clicked', () => { const openAccessConfig = vi.fn() - renderInMenu(<Operations {...defaultProps} showAccessConfig openAccessConfig={openAccessConfig} />) + renderInMenu( + <Operations {...defaultProps} showAccessConfig openAccessConfig={openAccessConfig} />, + ) fireEvent.click(screen.getByText(/settings\.resourceAccess/)) expect(openAccessConfig).toHaveBeenCalledTimes(1) diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx index f1a683be81f8b1..6d90e51cd35b5f 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx @@ -6,28 +6,29 @@ import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datase import CornerLabels from '../corner-labels' describe('CornerLabels', () => { - const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ - id: 'dataset-1', - name: 'Test Dataset', - description: 'Test description', - provider: 'vendor', - permission: DatasetPermission.allTeamMembers, - data_source_type: DataSourceType.FILE, - indexing_technique: IndexingType.QUALIFIED, - embedding_available: true, - app_count: 5, - document_count: 10, - word_count: 1000, - created_at: 1609459200, - updated_at: 1609545600, - tags: [], - embedding_model: 'text-embedding-ada-002', - embedding_model_provider: 'openai', - created_by: 'user-1', - doc_form: ChunkingMode.text, - runtime_mode: 'general', - ...overrides, - } as DataSet) + const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => + ({ + id: 'dataset-1', + name: 'Test Dataset', + description: 'Test description', + provider: 'vendor', + permission: DatasetPermission.allTeamMembers, + data_source_type: DataSourceType.FILE, + indexing_technique: IndexingType.QUALIFIED, + embedding_available: true, + app_count: 5, + document_count: 10, + word_count: 1000, + created_at: 1609459200, + updated_at: 1609545600, + tags: [], + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + created_by: 'user-1', + doc_form: ChunkingMode.text, + runtime_mode: 'general', + ...overrides, + }) as DataSet describe('Rendering', () => { it('should render without crashing when embedding is available', () => { diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-footer.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-footer.spec.tsx index e170de2340e3d6..bd09f5bfd80ae3 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-footer.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-footer.spec.tsx @@ -16,28 +16,29 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ })) describe('DatasetCardFooter', () => { - const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ - id: 'dataset-1', - name: 'Test Dataset', - description: 'Test description', - provider: 'vendor', - permission: DatasetPermission.allTeamMembers, - data_source_type: DataSourceType.FILE, - indexing_technique: IndexingType.QUALIFIED, - embedding_available: true, - app_count: 5, - document_count: 10, - word_count: 1000, - created_at: 1609459200, - updated_at: 1609545600, - tags: [], - embedding_model: 'text-embedding-ada-002', - embedding_model_provider: 'openai', - created_by: 'user-1', - doc_form: ChunkingMode.text, - total_available_documents: 10, - ...overrides, - } as DataSet) + const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => + ({ + id: 'dataset-1', + name: 'Test Dataset', + description: 'Test description', + provider: 'vendor', + permission: DatasetPermission.allTeamMembers, + data_source_type: DataSourceType.FILE, + indexing_technique: IndexingType.QUALIFIED, + embedding_available: true, + app_count: 5, + document_count: 10, + word_count: 1000, + created_at: 1609459200, + updated_at: 1609545600, + tags: [], + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + created_by: 'user-1', + doc_form: ChunkingMode.text, + total_available_documents: 10, + ...overrides, + }) as DataSet describe('Rendering', () => { it('should render without crashing', () => { @@ -167,7 +168,11 @@ describe('DatasetCardFooter', () => { }) it('should handle zero app count', () => { - const dataset = createMockDataset({ app_count: 0, document_count: 5, total_available_documents: 5 }) + const dataset = createMockDataset({ + app_count: 0, + document_count: 5, + total_available_documents: 5, + }) render(<DatasetCardFooter dataset={dataset} />) // Both document count and app count are shown const zeros = screen.getAllByText('0') diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx index 39c9c5bae91f69..dccef6289e6c3f 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx @@ -8,8 +8,10 @@ import DatasetCardHeader from '../dataset-card-header' // Mock AppIcon component to avoid emoji-mart initialization issues vi.mock('@/app/components/base/app-icon', () => ({ - default: ({ icon, className }: { icon?: string, className?: string }) => ( - <div data-testid="app-icon" className={className}>{icon}</div> + default: ({ icon, className }: { icon?: string; className?: string }) => ( + <div data-testid="app-icon" className={className}> + {icon} + </div> ), })) @@ -27,10 +29,8 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ vi.mock('@/hooks/use-knowledge', () => ({ useKnowledge: () => ({ formatIndexingTechniqueAndMethod: (technique: string, _method: string) => { - if (technique === 'high_quality') - return 'High Quality' - if (technique === 'economy') - return 'Economy' + if (technique === 'high_quality') return 'High Quality' + if (technique === 'economy') return 'Economy' return '' }, }), @@ -38,16 +38,15 @@ vi.mock('@/hooks/use-knowledge', () => ({ vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey((key: string, options?: { ns?: string }) => { - if (key === 'cornerLabel.pipeline' && options?.ns === 'dataset') - return 'Pipeline' + if (key === 'cornerLabel.pipeline' && options?.ns === 'dataset') return 'Pipeline' return options?.ns ? `${options.ns}.${key}` : key }), }), - }) + } }) describe('DatasetCardHeader', () => { @@ -214,7 +213,9 @@ describe('DatasetCardHeader', () => { const dataset = createMockDataset({ doc_form: ChunkingMode.text, indexing_technique: IndexingType.QUALIFIED, - retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'], + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + } as DataSet['retrieval_model_dict'], runtime_mode: 'general', }) render(<DatasetCardHeader dataset={dataset} />) @@ -235,7 +236,9 @@ describe('DatasetCardHeader', () => { const dataset = createMockDataset({ doc_form: ChunkingMode.text, indexing_technique: IndexingType.QUALIFIED, - retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'], + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + } as DataSet['retrieval_model_dict'], runtime_mode: 'rag_pipeline', is_published: true, }) diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx index fd6ac86eedb318..a338f64548f51a 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx @@ -7,16 +7,21 @@ import DatasetCardModals from '../dataset-card-modals' // Mock RenameDatasetModal since it's from a different feature folder vi.mock('../../../../rename-modal', () => ({ - default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: () => void }) => ( - show - ? ( - <div data-testid="rename-modal"> - <button onClick={onClose}>Close Rename</button> - <button onClick={onSuccess}>Success</button> - </div> - ) - : null - ), + default: ({ + show, + onClose, + onSuccess, + }: { + show: boolean + onClose: () => void + onSuccess?: () => void + }) => + show ? ( + <div data-testid="rename-modal"> + <button onClick={onClose}>Close Rename</button> + <button onClick={onSuccess}>Success</button> + </div> + ) : null, })) describe('DatasetCardModals', () => { diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/description.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/description.spec.tsx index 1a4d6c57ccaa24..f466b5093dd127 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/description.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/description.spec.tsx @@ -6,27 +6,28 @@ import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datase import Description from '../description' describe('Description', () => { - const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ - id: 'dataset-1', - name: 'Test Dataset', - description: 'This is a test description', - provider: 'vendor', - permission: DatasetPermission.allTeamMembers, - data_source_type: DataSourceType.FILE, - indexing_technique: IndexingType.QUALIFIED, - embedding_available: true, - app_count: 5, - document_count: 10, - word_count: 1000, - created_at: 1609459200, - updated_at: 1609545600, - tags: [], - embedding_model: 'text-embedding-ada-002', - embedding_model_provider: 'openai', - created_by: 'user-1', - doc_form: ChunkingMode.text, - ...overrides, - } as DataSet) + const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => + ({ + id: 'dataset-1', + name: 'Test Dataset', + description: 'This is a test description', + provider: 'vendor', + permission: DatasetPermission.allTeamMembers, + data_source_type: DataSourceType.FILE, + indexing_technique: IndexingType.QUALIFIED, + embedding_available: true, + app_count: 5, + document_count: 10, + word_count: 1000, + created_at: 1609459200, + updated_at: 1609545600, + tags: [], + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + created_by: 'user-1', + doc_form: ChunkingMode.text, + ...overrides, + }) as DataSet describe('Rendering', () => { it('should render without crashing', () => { @@ -63,7 +64,14 @@ describe('Description', () => { const dataset = createMockDataset({ embedding_available: true }) render(<Description dataset={dataset} />) const descDiv = screen.getByTitle(dataset.description) - expect(descDiv).toHaveClass('system-xs-regular', 'line-clamp-2', 'h-10', 'px-4', 'py-1', 'text-text-tertiary') + expect(descDiv).toHaveClass( + 'system-xs-regular', + 'line-clamp-2', + 'h-10', + 'px-4', + 'py-1', + 'text-text-tertiary', + ) }) it('should have opacity class when embedding is not available', () => { diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx index 76f30efe5a36e4..99fcf8f94b5f7c 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx @@ -14,82 +14,110 @@ const mockAppContextState = vi.hoisted(() => ({ let mockIsRbacEnabled = true -const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, { - systemFeatures: { - rbac_enabled: mockIsRbacEnabled, - }, -}) +const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => + renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, + }) vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) describe('OperationsDropdown', () => { - const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ - id: 'dataset-1', - name: 'Test Dataset', - description: 'Test description', - provider: 'vendor', - permission: DatasetPermission.allTeamMembers, - data_source_type: DataSourceType.FILE, - indexing_technique: IndexingType.QUALIFIED, - embedding_available: true, - app_count: 5, - document_count: 10, - word_count: 1000, - updated_at: 1609545600, - tags: [], - embedding_model: 'text-embedding-ada-002', - embedding_model_provider: 'openai', - created_by: 'user-1', - doc_form: ChunkingMode.text, - runtime_mode: 'general', - permission_keys: [ - DatasetACLPermission.Edit, - DatasetACLPermission.Delete, - DatasetACLPermission.ImportExportDSL, - DatasetACLPermission.AccessConfig, - ], - ...overrides, - } as DataSet) + const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => + ({ + id: 'dataset-1', + name: 'Test Dataset', + description: 'Test description', + provider: 'vendor', + permission: DatasetPermission.allTeamMembers, + data_source_type: DataSourceType.FILE, + indexing_technique: IndexingType.QUALIFIED, + embedding_available: true, + app_count: 5, + document_count: 10, + word_count: 1000, + updated_at: 1609545600, + tags: [], + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + created_by: 'user-1', + doc_form: ChunkingMode.text, + runtime_mode: 'general', + permission_keys: [ + DatasetACLPermission.Edit, + DatasetACLPermission.Delete, + DatasetACLPermission.ImportExportDSL, + DatasetACLPermission.AccessConfig, + ], + ...overrides, + }) as DataSet const defaultProps = { dataset: createMockDataset(), @@ -165,7 +193,9 @@ describe('OperationsDropdown', () => { fireEvent.click(screen.getByLabelText('Dataset operations')) - expect(screen.queryByText('datasetPipeline.operations.exportPipeline')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetPipeline.operations.exportPipeline'), + ).not.toBeInTheDocument() }) it('should show resource access option when dataset has access config ACL permission', () => { @@ -235,7 +265,9 @@ describe('OperationsDropdown', () => { render( <div> - <button type="button" onClick={onOutsideClick}>Outside action</button> + <button type="button" onClick={onOutsideClick}> + Outside action + </button> <OperationsDropdown {...defaultProps} /> </div>, ) @@ -269,7 +301,13 @@ describe('OperationsDropdown', () => { const dataset = createMockDataset({ permission_keys: [DatasetACLPermission.AccessConfig], }) - render(<OperationsDropdown {...defaultProps} dataset={dataset} openAccessConfig={openAccessConfig} />) + render( + <OperationsDropdown + {...defaultProps} + dataset={dataset} + openAccessConfig={openAccessConfig} + />, + ) fireEvent.click(screen.getByLabelText('Dataset operations')) fireEvent.click(screen.getByText('common.settings.resourceAccess')) diff --git a/web/app/components/datasets/list/dataset-card/components/corner-labels.tsx b/web/app/components/datasets/list/dataset-card/components/corner-labels.tsx index 1699f848ac84de..cf2e1d28dd432a 100644 --- a/web/app/components/datasets/list/dataset-card/components/corner-labels.tsx +++ b/web/app/components/datasets/list/dataset-card/components/corner-labels.tsx @@ -13,7 +13,7 @@ const CornerLabels = ({ dataset }: CornerLabelsProps) => { if (!dataset.embedding_available) { return ( <CornerLabel - label={t($ => $['cornerLabel.unavailable'], { ns: 'dataset' })} + label={t(($) => $['cornerLabel.unavailable'], { ns: 'dataset' })} className="absolute top-0 right-0 z-5" labelClassName="rounded-tr-xl" /> diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-footer.tsx b/web/app/components/datasets/list/dataset-card/components/dataset-card-footer.tsx index 07ef8db3bf6ec5..1ec5883140fdf3 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-footer.tsx +++ b/web/app/components/datasets/list/dataset-card/components/dataset-card-footer.tsx @@ -28,8 +28,12 @@ const DatasetCardFooter = ({ dataset }: DatasetCardFooterProps) => { const documentCountTooltip = useMemo(() => { const availableDocCount = dataset.total_available_documents ?? 0 if (availableDocCount < dataset.document_count) - return t($ => $.partialEnabled, { ns: 'dataset', count: dataset.document_count, num: availableDocCount }) - return t($ => $.docAllEnabled, { ns: 'dataset', count: availableDocCount }) + return t(($) => $.partialEnabled, { + ns: 'dataset', + count: dataset.document_count, + num: availableDocCount, + }) + return t(($) => $.docAllEnabled, { ns: 'dataset', count: availableDocCount }) }, [t, dataset.document_count, dataset.total_available_documents]) return ( @@ -41,34 +45,32 @@ const DatasetCardFooter = ({ dataset }: DatasetCardFooterProps) => { > <Tooltip> <TooltipTrigger - render={( + render={ <div className="flex items-center gap-x-1"> <RiFileTextFill className="size-3 text-text-quaternary" /> <span className="system-xs-medium">{documentCount}</span> </div> - )} + } /> - <TooltipContent> - {documentCountTooltip} - </TooltipContent> + <TooltipContent>{documentCountTooltip}</TooltipContent> </Tooltip> {!isExternalProvider && ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="flex items-center gap-x-1"> <RiRobot2Fill className="size-3 text-text-quaternary" /> <span className="system-xs-medium">{dataset.app_count}</span> </div> - )} + } /> <TooltipContent> - {`${dataset.app_count} ${t($ => $.appCount, { ns: 'dataset' })}`} + {`${dataset.app_count} ${t(($) => $.appCount, { ns: 'dataset' })}`} </TooltipContent> </Tooltip> )} <span className="system-xs-regular text-divider-deep">/</span> - <span className="system-xs-regular">{`${t($ => $.updated, { ns: 'dataset' })} ${formatTimeFromNow(dataset.updated_at * 1000)}`}</span> + <span className="system-xs-regular">{`${t(($) => $.updated, { ns: 'dataset' })} ${formatTimeFromNow(dataset.updated_at * 1000)}`}</span> </div> ) } diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-header.tsx b/web/app/components/datasets/list/dataset-card/components/dataset-card-header.tsx index 17c6d5bae7caa9..ee8ea7879f59a8 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-header.tsx +++ b/web/app/components/datasets/list/dataset-card/components/dataset-card-header.tsx @@ -8,7 +8,8 @@ import { useKnowledge } from '@/hooks/use-knowledge' import { DOC_FORM_ICON_WITH_BG, DOC_FORM_TEXT } from '@/models/datasets' const EXTERNAL_PROVIDER = 'external' -const docModeInfoClassName = 'flex min-h-3 items-center gap-x-3 system-2xs-medium-uppercase text-text-tertiary' +const docModeInfoClassName = + 'flex min-h-3 items-center gap-x-3 system-2xs-medium-uppercase text-text-tertiary' type DatasetCardHeaderProps = { dataset: DataSet @@ -21,11 +22,7 @@ type DocModeInfoProps = { isShowDocModeInfo: boolean } -const DocModeInfo = ({ - dataset, - isExternalProvider, - isShowDocModeInfo, -}: DocModeInfoProps) => { +const DocModeInfo = ({ dataset, isExternalProvider, isShowDocModeInfo }: DocModeInfoProps) => { const { t } = useTranslation() const { formatIndexingTechniqueAndMethod } = useKnowledge() const isPipeline = dataset.embedding_available && dataset.runtime_mode === 'rag_pipeline' @@ -33,7 +30,7 @@ const DocModeInfo = ({ if (isExternalProvider) { return ( <div className={docModeInfoClassName}> - <span>{t($ => $.externalKnowledgeBase, { ns: 'dataset' })}</span> + <span>{t(($) => $.externalKnowledgeBase, { ns: 'dataset' })}</span> </div> ) } @@ -44,7 +41,9 @@ const DocModeInfo = ({ const indexingText = dataset.indexing_technique ? formatIndexingTechniqueAndMethod( dataset.indexing_technique as 'economy' | 'high_quality', - dataset.retrieval_model_dict?.search_method as Parameters<typeof formatIndexingTechniqueAndMethod>[1], + dataset.retrieval_model_dict?.search_method as Parameters< + typeof formatIndexingTechniqueAndMethod + >[1], ) : '' @@ -52,31 +51,28 @@ const DocModeInfo = ({ <div className={docModeInfoClassName}> {isPipeline && ( <span className="max-w-full min-w-0 truncate"> - {t($ => $['cornerLabel.pipeline'], { ns: 'dataset' })} + {t(($) => $['cornerLabel.pipeline'], { ns: 'dataset' })} </span> )} {isShowDocModeInfo && !!dataset.doc_form && ( <span className="max-w-full min-w-0 truncate" - title={t($ => $[`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`], { ns: 'dataset' })} + title={t(($) => $[`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`], { ns: 'dataset' })} > - {t($ => $[`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`], { ns: 'dataset' })} + {t(($) => $[`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`], { ns: 'dataset' })} </span> )} {isShowDocModeInfo && dataset.indexing_technique && indexingText && ( - <span - className="max-w-full min-w-0 truncate" - title={indexingText} - > + <span className="max-w-full min-w-0 truncate" title={indexingText}> {indexingText} </span> )} {isShowDocModeInfo && dataset.is_multimodal && ( <span className="max-w-full min-w-0 truncate" - title={t($ => $.multimodal, { ns: 'dataset' })} + title={t(($) => $.multimodal, { ns: 'dataset' })} > - {t($ => $.multimodal, { ns: 'dataset' })} + {t(($) => $.multimodal, { ns: 'dataset' })} </span> )} </div> @@ -87,26 +83,38 @@ const DocModeInfo = ({ const DatasetCardHeader = ({ dataset }: DatasetCardHeaderProps) => { const isExternalProvider = dataset.provider === EXTERNAL_PROVIDER - const isShowChunkingModeIcon = dataset.doc_form && (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published) + const isShowChunkingModeIcon = + dataset.doc_form && (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published) const isShowDocModeInfo = Boolean( - dataset.doc_form - && dataset.indexing_technique - && dataset.retrieval_model_dict?.search_method - && (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published), + dataset.doc_form && + dataset.indexing_technique && + dataset.retrieval_model_dict?.search_method && + (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published), ) - const chunkingModeIcon = dataset.doc_form ? DOC_FORM_ICON_WITH_BG[dataset.doc_form] : React.Fragment + const chunkingModeIcon = dataset.doc_form + ? DOC_FORM_ICON_WITH_BG[dataset.doc_form] + : React.Fragment const Icon = isExternalProvider ? DOC_FORM_ICON_WITH_BG.external : chunkingModeIcon - const iconInfo = useMemo(() => dataset.icon_info || { - icon: '📙', - icon_type: 'emoji' as const, - icon_background: '#FFF4ED', - icon_url: '', - }, [dataset.icon_info]) + const iconInfo = useMemo( + () => + dataset.icon_info || { + icon: '📙', + icon_type: 'emoji' as const, + icon_background: '#FFF4ED', + icon_url: '', + }, + [dataset.icon_info], + ) return ( - <div className={cn('flex items-center gap-x-3 px-4 pt-4 pb-2', !dataset.embedding_available && 'opacity-30')}> + <div + className={cn( + 'flex items-center gap-x-3 px-4 pt-4 pb-2', + !dataset.embedding_available && 'opacity-30', + )} + > <div className="relative shrink-0"> <AppIcon size="large" @@ -122,10 +130,7 @@ const DatasetCardHeader = ({ dataset }: DatasetCardHeaderProps) => { )} </div> <div className="flex grow flex-col gap-y-1 overflow-hidden py-px"> - <div - className="truncate system-md-semibold text-text-secondary" - title={dataset.name} - > + <div className="truncate system-md-semibold text-text-secondary" title={dataset.name}> {dataset.name} </div> <DocModeInfo diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx b/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx index 12c7ac19e1a1a6..3b4e8232c22a68 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx +++ b/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx @@ -49,11 +49,14 @@ const DatasetCardModals = ({ onSuccess={onSuccess} /> )} - <AlertDialog open={modalState.showConfirmDelete} onOpenChange={open => !open && onCloseConfirm()}> + <AlertDialog + open={modalState.showConfirmDelete} + onOpenChange={(open) => !open && onCloseConfirm()} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $.deleteDatasetConfirmTitle, { ns: 'dataset' })} + {t(($) => $.deleteDatasetConfirmTitle, { ns: 'dataset' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> {modalState.confirmMessage} @@ -61,10 +64,10 @@ const DatasetCardModals = ({ </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={onConfirmDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/datasets/list/dataset-card/components/description.tsx b/web/app/components/datasets/list/dataset-card/components/description.tsx index 44b0559eef1d5d..c278c346429822 100644 --- a/web/app/components/datasets/list/dataset-card/components/description.tsx +++ b/web/app/components/datasets/list/dataset-card/components/description.tsx @@ -8,7 +8,10 @@ type DescriptionProps = { const Description = ({ dataset }: DescriptionProps) => ( <div - className={cn('line-clamp-2 h-10 px-4 py-1 system-xs-regular text-text-tertiary', !dataset.embedding_available && 'opacity-30')} + className={cn( + 'line-clamp-2 h-10 px-4 py-1 system-xs-regular text-text-tertiary', + !dataset.embedding_available && 'opacity-30', + )} title={dataset.description} > {dataset.description} diff --git a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx index 7e2a41d5dfa663..7df72adca88869 100644 --- a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx +++ b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx @@ -32,19 +32,29 @@ const OperationsDropdown = ({ const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = useAtomValue(datasetRbacEnabledAtom) - const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset.permission_keys, { - currentUserId, - resourceMaintainer: dataset.maintainer, - workspacePermissionKeys, - isRbacEnabled, - }), [dataset.maintainer, dataset.permission_keys, currentUserId, isRbacEnabled, workspacePermissionKeys]) - const canShowOperations = datasetACLCapabilities.canEdit - || datasetACLCapabilities.canImportExportDSL - || datasetACLCapabilities.canAccessConfig - || datasetACLCapabilities.canDelete + const datasetACLCapabilities = React.useMemo( + () => + getDatasetACLCapabilities(dataset.permission_keys, { + currentUserId, + resourceMaintainer: dataset.maintainer, + workspacePermissionKeys, + isRbacEnabled, + }), + [ + dataset.maintainer, + dataset.permission_keys, + currentUserId, + isRbacEnabled, + workspacePermissionKeys, + ], + ) + const canShowOperations = + datasetACLCapabilities.canEdit || + datasetACLCapabilities.canImportExportDSL || + datasetACLCapabilities.canAccessConfig || + datasetACLCapabilities.canDelete - if (!canShowOperations) - return null + if (!canShowOperations) return null return ( <div @@ -54,7 +64,7 @@ const OperationsDropdown = ({ ? 'pointer-events-auto visible' : 'pointer-events-none invisible group-hover:pointer-events-auto group-hover:visible', )} - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > <DropdownMenu modal={false} open={open} onOpenChange={setOpen}> <DropdownMenuTrigger @@ -69,14 +79,13 @@ const OperationsDropdown = ({ > <span className="i-ri-more-fill size-5 text-text-tertiary" /> </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-end" - popupClassName="min-w-[186px]" - > + <DropdownMenuContent placement="bottom-end" popupClassName="min-w-[186px]"> <Operations showEdit={datasetACLCapabilities.canEdit} showDelete={datasetACLCapabilities.canDelete} - showExportPipeline={dataset.runtime_mode === 'rag_pipeline' && datasetACLCapabilities.canImportExportDSL} + showExportPipeline={ + dataset.runtime_mode === 'rag_pipeline' && datasetACLCapabilities.canImportExportDSL + } showAccessConfig={datasetACLCapabilities.canAccessConfig} openRenameModal={openRenameModal} handleExportPipeline={handleExportPipeline} diff --git a/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts b/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts index e4a7bea9bd26a1..dd8f00930ee7e5 100644 --- a/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts +++ b/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts @@ -38,28 +38,29 @@ vi.mock('@/service/use-pipeline', () => ({ })) describe('useDatasetCardState', () => { - const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ - id: 'dataset-1', - name: 'Test Dataset', - description: 'Test description', - provider: 'vendor', - permission: DatasetPermission.allTeamMembers, - data_source_type: DataSourceType.FILE, - indexing_technique: IndexingType.QUALIFIED, - embedding_available: true, - app_count: 5, - document_count: 10, - word_count: 1000, - created_at: 1609459200, - updated_at: 1609545600, - tags: [{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: '' }], - embedding_model: 'text-embedding-ada-002', - embedding_model_provider: 'openai', - created_by: 'user-1', - doc_form: ChunkingMode.text, - pipeline_id: 'pipeline-1', - ...overrides, - } as DataSet) + const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => + ({ + id: 'dataset-1', + name: 'Test Dataset', + description: 'Test description', + provider: 'vendor', + permission: DatasetPermission.allTeamMembers, + data_source_type: DataSourceType.FILE, + indexing_technique: IndexingType.QUALIFIED, + embedding_available: true, + app_count: 5, + document_count: 10, + word_count: 1000, + created_at: 1609459200, + updated_at: 1609545600, + tags: [{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: '' }], + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + created_by: 'user-1', + doc_form: ChunkingMode.text, + pipeline_id: 'pipeline-1', + ...overrides, + }) as DataSet beforeEach(() => { vi.clearAllMocks() @@ -75,9 +76,7 @@ describe('useDatasetCardState', () => { describe('Initial State', () => { it('should have initial modal state closed', () => { const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) expect(result.current.modalState.showRenameModal).toBe(false) expect(result.current.modalState.showConfirmDelete).toBe(false) @@ -86,9 +85,7 @@ describe('useDatasetCardState', () => { it('should not be exporting initially', () => { const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) expect(result.current.exporting).toBe(false) }) @@ -97,9 +94,7 @@ describe('useDatasetCardState', () => { describe('Modal Handlers', () => { it('should open rename modal when openRenameModal is called', () => { const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) act(() => { result.current.openRenameModal() @@ -110,9 +105,7 @@ describe('useDatasetCardState', () => { it('should close rename modal when closeRenameModal is called', () => { const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) act(() => { result.current.openRenameModal() @@ -127,9 +120,7 @@ describe('useDatasetCardState', () => { it('should close confirm delete modal when closeConfirmDelete is called', async () => { const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) // First trigger show confirm delete act(() => { @@ -152,9 +143,7 @@ describe('useDatasetCardState', () => { it('should check usage and show confirm modal with not-in-use message', async () => { mockCheckUsage.mockResolvedValue({ is_using: false }) const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) await act(async () => { await result.current.detectIsUsedByApp() @@ -168,9 +157,7 @@ describe('useDatasetCardState', () => { it('should show in-use message when dataset is used by app', async () => { mockCheckUsage.mockResolvedValue({ is_using: true }) const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) await act(async () => { await result.current.detectIsUsedByApp() @@ -184,9 +171,7 @@ describe('useDatasetCardState', () => { it('should delete dataset and call onSuccess', async () => { const onSuccess = vi.fn() const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess })) await act(async () => { await result.current.onConfirmDelete() @@ -198,9 +183,7 @@ describe('useDatasetCardState', () => { it('should close confirm modal after delete', async () => { const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) // First open confirm modal await act(async () => { @@ -218,9 +201,7 @@ describe('useDatasetCardState', () => { describe('handleExportPipeline', () => { it('should not export if pipeline_id is missing', async () => { const dataset = createMockDataset({ pipeline_id: undefined }) - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) await act(async () => { await result.current.handleExportPipeline() @@ -231,9 +212,7 @@ describe('useDatasetCardState', () => { it('should export pipeline with correct parameters', async () => { const dataset = createMockDataset({ pipeline_id: 'pipeline-1', name: 'Test Pipeline' }) - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) await act(async () => { await result.current.handleExportPipeline(true) @@ -249,9 +228,7 @@ describe('useDatasetCardState', () => { describe('Edge Cases', () => { it('should handle undefined onSuccess', async () => { const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset })) // Should not throw when onSuccess is undefined await act(async () => { @@ -268,9 +245,7 @@ describe('useDatasetCardState', () => { mockExportPipeline.mockRejectedValue(new Error('Export failed')) const dataset = createMockDataset({ pipeline_id: 'pipeline-1' }) - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) await act(async () => { await result.current.handleExportPipeline() @@ -287,9 +262,7 @@ describe('useDatasetCardState', () => { mockCheckUsage.mockRejectedValue(mockResponse) const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) await act(async () => { await result.current.detectIsUsedByApp() @@ -303,9 +276,7 @@ describe('useDatasetCardState', () => { mockCheckUsage.mockRejectedValue(new Error('Network error')) const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) await act(async () => { await result.current.detectIsUsedByApp() @@ -319,9 +290,7 @@ describe('useDatasetCardState', () => { mockCheckUsage.mockRejectedValue({}) const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) await act(async () => { await result.current.detectIsUsedByApp() @@ -332,9 +301,7 @@ describe('useDatasetCardState', () => { it('should handle exporting state correctly', async () => { const dataset = createMockDataset({ pipeline_id: 'pipeline-1' }) - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) // Exporting should initially be false expect(result.current.exporting).toBe(false) @@ -349,9 +316,7 @@ describe('useDatasetCardState', () => { it('should reset exporting state after export completes', async () => { const dataset = createMockDataset({ pipeline_id: 'pipeline-1' }) - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) await act(async () => { await result.current.handleExportPipeline() @@ -364,9 +329,7 @@ describe('useDatasetCardState', () => { mockExportPipeline.mockRejectedValue(new Error('Export failed')) const dataset = createMockDataset({ pipeline_id: 'pipeline-1' }) - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) + const { result } = renderHook(() => useDatasetCardState({ dataset, onSuccess: vi.fn() })) await act(async () => { await result.current.handleExportPipeline() diff --git a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts index 54703350504ba2..0f500fbbafdb22 100644 --- a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts +++ b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts @@ -36,15 +36,15 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO // Modal handlers const openRenameModal = useCallback(() => { - setModalState(prev => ({ ...prev, showRenameModal: true })) + setModalState((prev) => ({ ...prev, showRenameModal: true })) }, []) const closeRenameModal = useCallback(() => { - setModalState(prev => ({ ...prev, showRenameModal: false })) + setModalState((prev) => ({ ...prev, showRenameModal: false })) }, []) const closeConfirmDelete = useCallback(() => { - setModalState(prev => ({ ...prev, showConfirmDelete: false })) + setModalState((prev) => ({ ...prev, showConfirmDelete: false })) }, []) const openAccessConfig = useCallback(() => { @@ -52,7 +52,7 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO }, [dataset.id, push]) const closeAccessConfig = useCallback(() => { - setModalState(prev => ({ ...prev, showAccessConfig: false })) + setModalState((prev) => ({ ...prev, showAccessConfig: false })) }, []) // API mutations @@ -61,48 +61,46 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL() // Export pipeline handler - const handleExportPipeline = useCallback(async (include: boolean = false) => { - const { pipeline_id, name } = dataset - if (!pipeline_id || exporting) - return - - try { - setExporting(true) - const { data } = await exportPipelineConfig({ - pipelineId: pipeline_id, - include, - }) - const file = new Blob([data], { type: 'application/yaml' }) - downloadBlob({ data: file, fileName: `${name}.pipeline` }) - } - catch { - toast.error(t($ => $.exportFailed, { ns: 'app' })) - } - finally { - setExporting(false) - } - }, [dataset, exportPipelineConfig, exporting, t]) + const handleExportPipeline = useCallback( + async (include: boolean = false) => { + const { pipeline_id, name } = dataset + if (!pipeline_id || exporting) return + + try { + setExporting(true) + const { data } = await exportPipelineConfig({ + pipelineId: pipeline_id, + include, + }) + const file = new Blob([data], { type: 'application/yaml' }) + downloadBlob({ data: file, fileName: `${name}.pipeline` }) + } catch { + toast.error(t(($) => $.exportFailed, { ns: 'app' })) + } finally { + setExporting(false) + } + }, + [dataset, exportPipelineConfig, exporting, t], + ) // Delete flow handlers const detectIsUsedByApp = useCallback(async () => { try { const { is_using: isUsedByApp } = await checkUsage(dataset.id) const message = isUsedByApp - ? t($ => $.datasetUsedByApp, { ns: 'dataset' })! - : t($ => $.deleteDatasetConfirmContent, { ns: 'dataset' })! - setModalState(prev => ({ + ? t(($) => $.datasetUsedByApp, { ns: 'dataset' })! + : t(($) => $.deleteDatasetConfirmContent, { ns: 'dataset' })! + setModalState((prev) => ({ ...prev, confirmMessage: message, showConfirmDelete: true, })) - } - catch (e: unknown) { + } catch (e: unknown) { if (e instanceof Response) { const res = await e.json() - toast.error(res?.message || t($ => $.unknownError, { ns: 'dataset' })) - } - else { - toast.error((e as Error)?.message || t($ => $.unknownError, { ns: 'dataset' })) + toast.error(res?.message || t(($) => $.unknownError, { ns: 'dataset' })) + } else { + toast.error((e as Error)?.message || t(($) => $.unknownError, { ns: 'dataset' })) } } }, [dataset.id, checkUsage, t]) @@ -110,10 +108,9 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO const onConfirmDelete = useCallback(async () => { try { await deleteDatasetMutation(dataset.id) - toast.success(t($ => $.datasetDeleted, { ns: 'dataset' })) + toast.success(t(($) => $.datasetDeleted, { ns: 'dataset' })) onSuccess?.() - } - finally { + } finally { closeConfirmDelete() } }, [dataset.id, deleteDatasetMutation, onSuccess, t, closeConfirmDelete]) diff --git a/web/app/components/datasets/list/dataset-card/index.tsx b/web/app/components/datasets/list/dataset-card/index.tsx index f49331f8c55118..2d346af4efe2a9 100644 --- a/web/app/components/datasets/list/dataset-card/index.tsx +++ b/web/app/components/datasets/list/dataset-card/index.tsx @@ -10,7 +10,11 @@ import { userProfileIdAtom } from '@/context/account-state' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { DatasetCardTags } from '@/features/tag-management/components/dataset-card-tags' import { useRouter } from '@/next/navigation' -import { getDatasetACLCapabilities, hasOnlyDatasetPreviewPermission, hasPermission } from '@/utils/permission' +import { + getDatasetACLCapabilities, + hasOnlyDatasetPreviewPermission, + hasPermission, +} from '@/utils/permission' import CornerLabels from './components/corner-labels' import DatasetCardFooter from './components/dataset-card-footer' import DatasetCardHeader from './components/dataset-card-header' @@ -27,11 +31,7 @@ type DatasetCardProps = { onOpenTagManagement?: () => void } -const DatasetCard = ({ - dataset, - onSuccess, - onOpenTagManagement = () => {}, -}: DatasetCardProps) => { +const DatasetCard = ({ dataset, onSuccess, onOpenTagManagement = () => {} }: DatasetCardProps) => { const { t } = useTranslation() const { push } = useRouter() const currentUserId = useAtomValue(userProfileIdAtom) @@ -55,16 +55,20 @@ const DatasetCard = ({ return dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published }, [dataset.runtime_mode, dataset.is_published]) const isPreviewOnly = hasOnlyDatasetPreviewPermission(dataset.permission_keys) - const datasetACLCapabilities = useMemo(() => getDatasetACLCapabilities(dataset.permission_keys, { - currentUserId, - resourceMaintainer: dataset.maintainer, - workspacePermissionKeys, - }), [dataset.maintainer, dataset.permission_keys, currentUserId, workspacePermissionKeys]) + const datasetACLCapabilities = useMemo( + () => + getDatasetACLCapabilities(dataset.permission_keys, { + currentUserId, + resourceMaintainer: dataset.maintainer, + workspacePermissionKeys, + }), + [dataset.maintainer, dataset.permission_keys, currentUserId, workspacePermissionKeys], + ) const canManageAppTags = hasPermission(workspacePermissionKeys, 'dataset.tag.manage') const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || datasetACLCapabilities.canEdit) const showPreviewOnlyAccessWarning = () => { - toast.warning(t($ => $.noAccessResourcePermission, { ns: 'app' })) + toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) } const handleCardClick = (e: MouseEvent) => { @@ -75,21 +79,20 @@ const DatasetCard = ({ } if (isExternalProvider) { - push(datasetACLCapabilities.canRetrievalRecall - ? `/datasets/${dataset.id}/hitTesting` - : `/datasets/${dataset.id}/settings`) - } - else if (isPipelineUnpublished) { + push( + datasetACLCapabilities.canRetrievalRecall + ? `/datasets/${dataset.id}/hitTesting` + : `/datasets/${dataset.id}/settings`, + ) + } else if (isPipelineUnpublished) { push(`/datasets/${dataset.id}/pipeline`) - } - else { + } else { push(`/datasets/${dataset.id}/documents`) } } const handlePreviewOnlyCardKeyDown = (e: KeyboardEvent<HTMLElement>) => { - if (!isPreviewOnly || (e.key !== 'Enter' && e.key !== ' ')) - return + if (!isPreviewOnly || (e.key !== 'Enter' && e.key !== ' ')) return e.preventDefault() showPreviewOnlyAccessWarning() diff --git a/web/app/components/datasets/list/dataset-card/operations.tsx b/web/app/components/datasets/list/dataset-card/operations.tsx index 11d4e7edf3927c..b3ed3fd8c6fc1f 100644 --- a/web/app/components/datasets/list/dataset-card/operations.tsx +++ b/web/app/components/datasets/list/dataset-card/operations.tsx @@ -1,7 +1,4 @@ -import { - DropdownMenuItem, - DropdownMenuSeparator, -} from '@langgenius/dify-ui/dropdown-menu' +import { DropdownMenuItem, DropdownMenuSeparator } from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -55,19 +52,19 @@ const Operations = ({ {showEdit && ( <DropdownMenuItem onClick={handleRename}> <span aria-hidden className="mr-1 i-ri-edit-line size-4 text-text-tertiary" /> - {t($ => $['operation.edit'], { ns: 'common' })} + {t(($) => $['operation.edit'], { ns: 'common' })} </DropdownMenuItem> )} {showExportPipeline && ( <DropdownMenuItem onClick={handleExport}> <span aria-hidden className="mr-1 i-ri-file-download-line size-4 text-text-tertiary" /> - {t($ => $['operations.exportPipeline'], { ns: 'datasetPipeline' })} + {t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })} </DropdownMenuItem> )} {showAccessConfig && ( <DropdownMenuItem onClick={handleAccessConfig}> <span aria-hidden className="mr-1 i-ri-lock-line size-4 text-text-tertiary" /> - {t($ => $['settings.resourceAccess'], { ns: 'common' })} + {t(($) => $['settings.resourceAccess'], { ns: 'common' })} </DropdownMenuItem> )} {showDelete && ( @@ -75,7 +72,7 @@ const Operations = ({ <DropdownMenuSeparator /> <DropdownMenuItem variant="destructive" onClick={handleDelete}> <span aria-hidden className="mr-1 i-ri-delete-bin-line size-4" /> - {t($ => $['operation.delete'], { ns: 'common' })} + {t(($) => $['operation.delete'], { ns: 'common' })} </DropdownMenuItem> </> )} diff --git a/web/app/components/datasets/list/datasets.tsx b/web/app/components/datasets/list/datasets.tsx index 62bb5bc31163bf..a338cd9c8658c2 100644 --- a/web/app/components/datasets/list/datasets.tsx +++ b/web/app/components/datasets/list/datasets.tsx @@ -30,7 +30,7 @@ const Datasets = ({ isLoading, isPlaceholderData, emptyElement, - onOpenTagManagement = () => { }, + onOpenTagManagement = () => {}, }: Props) => { const { t } = useTranslation() const invalidDatasetList = useInvalidDatasetList() @@ -38,35 +38,48 @@ const Datasets = ({ const observerRef = useRef<IntersectionObserver>(null) const pages = datasetList?.pages ?? [] const datasets = pages.flatMap(({ data }) => data) - const showDatasetSkeleton = !isFetchingNextPage && (isLoading || (isPlaceholderData && isFetching && datasets.length === 0)) + const showDatasetSkeleton = + !isFetchingNextPage && (isLoading || (isPlaceholderData && isFetching && datasets.length === 0)) useEffect(() => { - document.title = `${t($ => $.knowledge, { ns: 'dataset' })} - Dify` + document.title = `${t(($) => $.knowledge, { ns: 'dataset' })} - Dify` }, [t]) useEffect(() => { if (anchorRef.current) { - observerRef.current = new IntersectionObserver((entries) => { - if (entries[0]!.isIntersecting && hasNextPage && !isFetching && !isPlaceholderData) - fetchNextPage() - }, { - rootMargin: '100px', - }) + observerRef.current = new IntersectionObserver( + (entries) => { + if (entries[0]!.isIntersecting && hasNextPage && !isFetching && !isPlaceholderData) + fetchNextPage() + }, + { + rootMargin: '100px', + }, + ) observerRef.current.observe(anchorRef.current) } return () => observerRef.current?.disconnect() }, [anchorRef, hasNextPage, isFetching, isPlaceholderData, fetchNextPage]) - const hasAnyDataset = (datasetList?.pages[0]?.total ?? 0) > 0 || !!datasetList?.pages.some(({ data }) => data.length > 0) + const hasAnyDataset = + (datasetList?.pages[0]?.total ?? 0) > 0 || + !!datasetList?.pages.some(({ data }) => data.length > 0) return ( <> <nav className="relative grid grow grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3 px-8 pt-2"> - {showDatasetSkeleton - ? <DatasetCardSkeleton label={t($ => $.loading, { ns: 'common' })} /> - : datasets.map(dataset => ( - <DatasetCard key={dataset.id} dataset={dataset} onSuccess={invalidDatasetList} onOpenTagManagement={onOpenTagManagement} />), - )} + {showDatasetSkeleton ? ( + <DatasetCardSkeleton label={t(($) => $.loading, { ns: 'common' })} /> + ) : ( + datasets.map((dataset) => ( + <DatasetCard + key={dataset.id} + dataset={dataset} + onSuccess={invalidDatasetList} + onOpenTagManagement={onOpenTagManagement} + /> + )) + )} {!showDatasetSkeleton && !hasAnyDataset && emptyElement} {isFetchingNextPage && <Loading />} <div ref={anchorRef} className="h-0" /> diff --git a/web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx b/web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx index 9e3006ff5f6d9b..b0ec519f1e9679 100644 --- a/web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx @@ -2,8 +2,18 @@ import { render, screen } from '@testing-library/react' import DatasetFirstEmptyState from '..' vi.mock('@/next/link', () => ({ - default: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) => ( - <a href={href} className={className}>{children}</a> + default: ({ + children, + href, + className, + }: { + children: React.ReactNode + href: string + className?: string + }) => ( + <a href={href} className={className}> + {children} + </a> ), })) @@ -14,13 +24,18 @@ describe('DatasetFirstEmptyState', () => { const pipelineLink = screen.getByRole('link', { name: /dataset\.firstEmpty\.pipelineTitle/ }) expect(pipelineLink).toHaveAttribute('href', '/datasets/create-from-pipeline') - expect(pipelineLink.querySelector('.i-custom-vender-pipeline-pipeline-line')).toBeInTheDocument() + expect( + pipelineLink.querySelector('.i-custom-vender-pipeline-pipeline-line'), + ).toBeInTheDocument() }) it('lays out placeholder cards with auto-fill grid columns', () => { - const { container } = render(<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset />) - const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none')) - .find(element => element.className.includes('grid-rows-4')) + const { container } = render( + <DatasetFirstEmptyState canConnectExternalDataset canCreateDataset />, + ) + const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none')).find( + (element) => element.className.includes('grid-rows-4'), + ) if (!placeholderGrid) throw new Error('Expected dataset first empty state placeholder grid to render') @@ -30,19 +45,33 @@ describe('DatasetFirstEmptyState', () => { 'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', 'grid-rows-4', ) - expect(placeholderGrid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'lg:grid-cols-3', 'xl:grid-cols-4') + expect(placeholderGrid).not.toHaveClass( + 'grid-cols-1', + 'sm:grid-cols-2', + 'lg:grid-cols-3', + 'xl:grid-cols-4', + ) }) it('should hide dataset creation actions when dataset.create_and_management is unavailable', () => { render(<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset={false} />) - expect(screen.queryByRole('link', { name: /dataset\.firstEmpty\.createTitle/ })).not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: /dataset\.firstEmpty\.pipelineTitle/ })).not.toBeInTheDocument() - expect(screen.getByRole('link', { name: /dataset\.connectDataset/ })).toHaveAttribute('href', '/datasets/connect') + expect( + screen.queryByRole('link', { name: /dataset\.firstEmpty\.createTitle/ }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: /dataset\.firstEmpty\.pipelineTitle/ }), + ).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: /dataset\.connectDataset/ })).toHaveAttribute( + 'href', + '/datasets/connect', + ) }) it('should render nothing when no empty-state action is available', () => { - const { container } = render(<DatasetFirstEmptyState canConnectExternalDataset={false} canCreateDataset={false} />) + const { container } = render( + <DatasetFirstEmptyState canConnectExternalDataset={false} canCreateDataset={false} />, + ) expect(container).toBeEmptyDOMElement() }) diff --git a/web/app/components/datasets/list/first-empty-state/index.tsx b/web/app/components/datasets/list/first-empty-state/index.tsx index c0d6bbe1413fa3..69300f2d1e25dc 100644 --- a/web/app/components/datasets/list/first-empty-state/index.tsx +++ b/web/app/components/datasets/list/first-empty-state/index.tsx @@ -2,7 +2,10 @@ import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' import FirstEmptyActionCard from '@/app/components/apps/first-empty-state/action-card' -const EMPTY_PLACEHOLDER_CARD_IDS = Array.from({ length: 16 }, (_, index) => `dataset-placeholder-card-${index}`) +const EMPTY_PLACEHOLDER_CARD_IDS = Array.from( + { length: 16 }, + (_, index) => `dataset-placeholder-card-${index}`, +) type EmptyCreateAction = { badge?: string @@ -27,46 +30,53 @@ function DatasetFirstEmptyState({ const createActions: EmptyCreateAction[] = canCreateDataset ? [ { - badge: t($ => $['firstEmpty.recommended'], { ns: 'dataset' }), + badge: t(($) => $['firstEmpty.recommended'], { ns: 'dataset' }), href: '/datasets/create', icon: <span aria-hidden className="i-ri-add-line size-4" />, id: 'create', - title: t($ => $['firstEmpty.createTitle'], { ns: 'dataset' }), - description: t($ => $['firstEmpty.createDescription'], { ns: 'dataset' }), + title: t(($) => $['firstEmpty.createTitle'], { ns: 'dataset' }), + description: t(($) => $['firstEmpty.createDescription'], { ns: 'dataset' }), }, { href: '/datasets/create-from-pipeline', icon: <span aria-hidden className="i-custom-vender-pipeline-pipeline-line size-4" />, id: 'pipeline', - title: t($ => $['firstEmpty.pipelineTitle'], { ns: 'dataset' }), - description: t($ => $['firstEmpty.pipelineDescription'], { ns: 'dataset' }), + title: t(($) => $['firstEmpty.pipelineTitle'], { ns: 'dataset' }), + description: t(($) => $['firstEmpty.pipelineDescription'], { ns: 'dataset' }), }, ] : [] const connectAction: EmptyCreateAction | undefined = canConnectExternalDataset ? { href: '/datasets/connect', - icon: <span aria-hidden className="i-custom-vender-solid-development-api-connection-mod size-4" />, + icon: ( + <span + aria-hidden + className="i-custom-vender-solid-development-api-connection-mod size-4" + /> + ), id: 'connect', - title: t($ => $.connectDataset, { ns: 'dataset' }), - description: t($ => $['firstEmpty.connectDescription'], { ns: 'dataset' }), + title: t(($) => $.connectDataset, { ns: 'dataset' }), + description: t(($) => $['firstEmpty.connectDescription'], { ns: 'dataset' }), } : undefined const hasActions = createActions.length > 0 || !!connectAction - if (!hasActions) - return null + if (!hasActions) return null return ( <div className="flex grow flex-col overflow-hidden"> <div className="relative min-h-[520px] flex-1 overflow-hidden"> <div className="pointer-events-none absolute inset-x-8 inset-y-2 grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] grid-rows-4 gap-3"> - {EMPTY_PLACEHOLDER_CARD_IDS.map(id => ( + {EMPTY_PLACEHOLDER_CARD_IDS.map((id) => ( <div key={id} className="rounded-xl bg-background-default-lighter opacity-75" /> ))} </div> <div className="pointer-events-none absolute inset-0 bg-linear-to-b from-background-body/0 to-background-body" /> - <section className="absolute inset-0 flex items-center justify-center overflow-hidden p-2" aria-labelledby="datasets-first-empty-title"> + <section + className="absolute inset-0 flex items-center justify-center overflow-hidden p-2" + aria-labelledby="datasets-first-empty-title" + > <div className="flex w-full max-w-[520px] flex-col items-center gap-6"> <div className="flex flex-col items-center gap-3"> <div className="flex size-14 items-center justify-center overflow-hidden rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-1 backdrop-blur-md"> @@ -75,13 +85,13 @@ function DatasetFirstEmptyState({ </div> </div> <h2 id="datasets-first-empty-title" className="system-sm-regular text-text-tertiary"> - {t($ => $['firstEmpty.title'], { ns: 'dataset' })} + {t(($) => $['firstEmpty.title'], { ns: 'dataset' })} </h2> </div> <div className="flex w-full flex-col gap-2 pb-8"> {createActions.length > 0 && ( <div className="flex flex-col gap-2"> - {createActions.map(action => ( + {createActions.map((action) => ( <FirstEmptyActionCard key={action.id} badge={action.badge} @@ -97,7 +107,9 @@ function DatasetFirstEmptyState({ {createActions.length > 0 && connectAction && ( <div className="flex items-center gap-2 text-text-tertiary"> <div className="h-px min-w-0 flex-1 bg-linear-to-r from-background-body/0 to-divider-regular" /> - <span className="system-xs-medium-uppercase uppercase">{t($ => $['firstEmpty.or'], { ns: 'dataset' })}</span> + <span className="system-xs-medium-uppercase uppercase"> + {t(($) => $['firstEmpty.or'], { ns: 'dataset' })} + </span> <div className="h-px min-w-0 flex-1 bg-linear-to-r from-divider-regular to-background-body/0" /> </div> )} diff --git a/web/app/components/datasets/list/header.tsx b/web/app/components/datasets/list/header.tsx index 26283b4321a768..e6ea7ed73c8e68 100644 --- a/web/app/components/datasets/list/header.tsx +++ b/web/app/components/datasets/list/header.tsx @@ -1,7 +1,13 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label' @@ -49,7 +55,9 @@ const DatasetListHeader = ({ return ( <div className="sticky top-0 z-10 flex flex-col gap-[14px] bg-background-body px-8 pt-4 pb-2"> <div className="flex h-6 w-full items-center gap-2"> - <h1 className="min-w-0 flex-1 text-[18px]/[21.6px] font-semibold text-text-primary">{t($ => $.knowledge, { ns: 'dataset' })}</h1> + <h1 className="min-w-0 flex-1 text-[18px]/[21.6px] font-semibold text-text-primary"> + {t(($) => $.knowledge, { ns: 'dataset' })} + </h1> <div className="flex shrink-0 items-center gap-2"> {canConnectExternalDataset && ( <button @@ -57,8 +65,13 @@ const DatasetListHeader = ({ className="flex h-6 items-center justify-center gap-1 overflow-hidden rounded-md px-1.5 py-1 text-text-tertiary hover:bg-state-base-hover" onClick={onExternalApiClick} > - <span aria-hidden className="i-custom-vender-solid-development-api-connection-mod size-3.5 shrink-0" /> - <span className="px-0.5 system-xs-medium">{t($ => $.externalAPIPanelTitle, { ns: 'dataset' })}</span> + <span + aria-hidden + className="i-custom-vender-solid-development-api-connection-mod size-3.5 shrink-0" + /> + <span className="px-0.5 system-xs-medium"> + {t(($) => $.externalAPIPanelTitle, { ns: 'dataset' })} + </span> </button> )} <ServiceApi apiBaseUrl={apiBaseUrl} /> @@ -73,21 +86,17 @@ const DatasetListHeader = ({ onOpenTagManagement={onOpenTagManagement} showLeadingIcon={false} /> - <SearchInput - className="w-[200px]" - value={keywords} - onValueChange={onKeywordsChange} - /> + <SearchInput className="w-[200px]" value={keywords} onValueChange={onKeywordsChange} /> {isCurrentWorkspaceOwner && ( <> <div className="h-3.5 w-px bg-divider-regular" /> <CheckboxWithLabel isChecked={includeAll} onChange={onIncludeAllChange} - label={t($ => $.allKnowledge, { ns: 'dataset' })} + label={t(($) => $.allKnowledge, { ns: 'dataset' })} labelClassName="system-md-regular text-text-tertiary" className="h-8" - tooltip={t($ => $.allKnowledgeDescription, { ns: 'dataset' }) as string} + tooltip={t(($) => $.allKnowledgeDescription, { ns: 'dataset' }) as string} /> </> )} @@ -96,38 +105,42 @@ const DatasetListHeader = ({ {showCreateMenu && ( <DropdownMenu modal={false}> <DropdownMenuTrigger - render={( - <Button - variant="primary" - size="medium" - className="gap-0.5 px-2 shadow-xs" - > + render={ + <Button variant="primary" size="medium" className="gap-0.5 px-2 shadow-xs"> <span aria-hidden className="i-ri-add-line size-4 shrink-0" /> - <span className="pl-1">{t($ => $['operation.create'], { ns: 'common' })}</span> + <span className="pl-1"> + {t(($) => $['operation.create'], { ns: 'common' })} + </span> <span aria-hidden className="i-ri-arrow-down-s-line size-4 shrink-0" /> </Button> - )} + } /> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="w-80" - > + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-80"> {canCreateDataset && ( <> <DropdownMenuItem className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary" onClick={onCreateDataset} > - <span aria-hidden className="i-ri-add-line size-4 shrink-0 text-text-secondary" /> - <span className="min-w-0 flex-1 truncate px-1">{t($ => $['firstEmpty.createTitle'], { ns: 'dataset' })}</span> + <span + aria-hidden + className="i-ri-add-line size-4 shrink-0 text-text-secondary" + /> + <span className="min-w-0 flex-1 truncate px-1"> + {t(($) => $['firstEmpty.createTitle'], { ns: 'dataset' })} + </span> </DropdownMenuItem> <DropdownMenuItem className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary" onClick={onCreateFromPipeline} > - <span aria-hidden className="i-custom-vender-pipeline-pipeline-line size-4 shrink-0 text-text-secondary" /> - <span className="min-w-0 flex-1 truncate px-1">{t($ => $['firstEmpty.pipelineTitle'], { ns: 'dataset' })}</span> + <span + aria-hidden + className="i-custom-vender-pipeline-pipeline-line size-4 shrink-0 text-text-secondary" + /> + <span className="min-w-0 flex-1 truncate px-1"> + {t(($) => $['firstEmpty.pipelineTitle'], { ns: 'dataset' })} + </span> </DropdownMenuItem> </> )} @@ -139,8 +152,13 @@ const DatasetListHeader = ({ className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary" onClick={onConnectDataset} > - <span aria-hidden className="i-custom-vender-solid-development-api-connection-mod size-4 shrink-0 text-text-secondary" /> - <span className="min-w-0 flex-1 truncate px-1">{t($ => $.connectDataset, { ns: 'dataset' })}</span> + <span + aria-hidden + className="i-custom-vender-solid-development-api-connection-mod size-4 shrink-0 text-text-secondary" + /> + <span className="min-w-0 flex-1 truncate px-1"> + {t(($) => $.connectDataset, { ns: 'dataset' })} + </span> </DropdownMenuItem> )} </DropdownMenuContent> diff --git a/web/app/components/datasets/list/index.tsx b/web/app/components/datasets/list/index.tsx index 25774728afed8c..19e061c9a69ea8 100644 --- a/web/app/components/datasets/list/index.tsx +++ b/web/app/components/datasets/list/index.tsx @@ -2,7 +2,6 @@ import { useBoolean, useDebounceFn } from 'ahooks' import { useAtomValue } from 'jotai' - // Libraries import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -12,7 +11,11 @@ import { isCurrentWorkspaceOwnerAtom } from '@/context/workspace-state' import { TagManagementModal } from '@/features/tag-management/components/tag-management-modal' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter } from '@/next/navigation' -import { useDatasetApiBaseUrl, useDatasetList, useInvalidDatasetList } from '@/service/knowledge/use-dataset' +import { + useDatasetApiBaseUrl, + useDatasetList, + useInvalidDatasetList, +} from '@/service/knowledge/use-dataset' import { hasPermission } from '@/utils/permission' // Components import FilterEmptyState from '../../base/filter-empty-state' @@ -29,22 +32,28 @@ const List = () => { const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel() const [includeAll, { toggle: toggleIncludeAll }] = useBoolean(false) const invalidDatasetList = useInvalidDatasetList() - useDocumentTitle(t($ => $.knowledge, { ns: 'dataset' })) + useDocumentTitle(t(($) => $.knowledge, { ns: 'dataset' })) const [keywords, setKeywords] = useState('') const [searchKeywords, setSearchKeywords] = useState('') - const { run: handleSearch } = useDebounceFn(() => { - setSearchKeywords(keywords) - }, { wait: 500 }) + const { run: handleSearch } = useDebounceFn( + () => { + setSearchKeywords(keywords) + }, + { wait: 500 }, + ) const handleKeywordsChange = (value: string) => { setKeywords(value) handleSearch() } const [tagFilterValue, setTagFilterValue] = useState<string[]>([]) const [tagIDs, setTagIDs] = useState<string[]>([]) - const { run: handleTagsUpdate } = useDebounceFn(() => { - setTagIDs(tagFilterValue) - }, { wait: 500 }) + const { run: handleTagsUpdate } = useDebounceFn( + () => { + setTagIDs(tagFilterValue) + }, + { wait: 500 }, + ) const handleTagsChange = (value: string[]) => { setTagFilterValue(value) handleTagsUpdate() @@ -52,7 +61,10 @@ const List = () => { const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management') - const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect') + const canConnectExternalDataset = hasPermission( + workspacePermissionKeys, + 'dataset.external.connect', + ) const { data: apiBaseInfo } = useDatasetApiBaseUrl() const datasetListQuery = useDatasetList({ initialPage: 1, @@ -64,70 +76,82 @@ const List = () => { const pages = datasetListQuery.data?.pages ?? [] const hasResolvedFirstPage = pages.length > 0 const hasAnyDataset = (pages[0]?.total ?? 0) > 0 - const hasActiveFilters = tagIDs.length > 0 || keywords.trim().length > 0 || searchKeywords.trim().length > 0 || includeAll - const showEmptyDataList = !hasAnyDataset && (canCreateDataset || canConnectExternalDataset) && hasResolvedFirstPage && !hasActiveFilters + const hasActiveFilters = + tagIDs.length > 0 || + keywords.trim().length > 0 || + searchKeywords.trim().length > 0 || + includeAll + const showEmptyDataList = + !hasAnyDataset && + (canCreateDataset || canConnectExternalDataset) && + hasResolvedFirstPage && + !hasActiveFilters const showFilteredEmptyState = !hasAnyDataset && hasResolvedFirstPage && hasActiveFilters return ( <div className="relative flex grow flex-col overflow-y-auto bg-background-body"> - {showEmptyDataList - ? ( - <> - <DatasetListHeader - apiBaseUrl={apiBaseInfo?.api_base_url ?? ''} - canConnectExternalDataset={canConnectExternalDataset} - canCreateDataset={canCreateDataset} - includeAll={includeAll} - isCurrentWorkspaceOwner={isCurrentWorkspaceOwner} - keywords={keywords} - tagFilterValue={tagFilterValue} - onCreateDataset={() => push('/datasets/create')} - onCreateFromPipeline={() => push('/datasets/create-from-pipeline')} - onConnectDataset={() => push('/datasets/connect')} - onExternalApiClick={() => setShowExternalApiPanel(true)} - onIncludeAllChange={toggleIncludeAll} - onKeywordsChange={handleKeywordsChange} - onOpenTagManagement={() => setShowTagManagementModal(true)} - onTagsChange={handleTagsChange} - /> - <DatasetFirstEmptyState - canConnectExternalDataset={canConnectExternalDataset} - canCreateDataset={canCreateDataset} - /> - </> - ) - : ( - <> - <DatasetListHeader - apiBaseUrl={apiBaseInfo?.api_base_url ?? ''} - canConnectExternalDataset={canConnectExternalDataset} - canCreateDataset={canCreateDataset} - includeAll={includeAll} - isCurrentWorkspaceOwner={isCurrentWorkspaceOwner} - keywords={keywords} - tagFilterValue={tagFilterValue} - onCreateDataset={() => push('/datasets/create')} - onCreateFromPipeline={() => push('/datasets/create-from-pipeline')} - onConnectDataset={() => push('/datasets/connect')} - onExternalApiClick={() => setShowExternalApiPanel(true)} - onIncludeAllChange={toggleIncludeAll} - onKeywordsChange={handleKeywordsChange} - onOpenTagManagement={() => setShowTagManagementModal(true)} - onTagsChange={handleTagsChange} - /> - <Datasets - datasetList={datasetListQuery.data} - emptyElement={showFilteredEmptyState ? <FilterEmptyState title={t($ => $['filterEmpty.noKnowledge'], { ns: 'dataset' })} /> : undefined} - fetchNextPage={datasetListQuery.fetchNextPage} - hasNextPage={datasetListQuery.hasNextPage} - isFetching={datasetListQuery.isFetching} - isFetchingNextPage={datasetListQuery.isFetchingNextPage} - isLoading={datasetListQuery.isLoading} - isPlaceholderData={datasetListQuery.isPlaceholderData} - onOpenTagManagement={() => setShowTagManagementModal(true)} - /> - </> - )} + {showEmptyDataList ? ( + <> + <DatasetListHeader + apiBaseUrl={apiBaseInfo?.api_base_url ?? ''} + canConnectExternalDataset={canConnectExternalDataset} + canCreateDataset={canCreateDataset} + includeAll={includeAll} + isCurrentWorkspaceOwner={isCurrentWorkspaceOwner} + keywords={keywords} + tagFilterValue={tagFilterValue} + onCreateDataset={() => push('/datasets/create')} + onCreateFromPipeline={() => push('/datasets/create-from-pipeline')} + onConnectDataset={() => push('/datasets/connect')} + onExternalApiClick={() => setShowExternalApiPanel(true)} + onIncludeAllChange={toggleIncludeAll} + onKeywordsChange={handleKeywordsChange} + onOpenTagManagement={() => setShowTagManagementModal(true)} + onTagsChange={handleTagsChange} + /> + <DatasetFirstEmptyState + canConnectExternalDataset={canConnectExternalDataset} + canCreateDataset={canCreateDataset} + /> + </> + ) : ( + <> + <DatasetListHeader + apiBaseUrl={apiBaseInfo?.api_base_url ?? ''} + canConnectExternalDataset={canConnectExternalDataset} + canCreateDataset={canCreateDataset} + includeAll={includeAll} + isCurrentWorkspaceOwner={isCurrentWorkspaceOwner} + keywords={keywords} + tagFilterValue={tagFilterValue} + onCreateDataset={() => push('/datasets/create')} + onCreateFromPipeline={() => push('/datasets/create-from-pipeline')} + onConnectDataset={() => push('/datasets/connect')} + onExternalApiClick={() => setShowExternalApiPanel(true)} + onIncludeAllChange={toggleIncludeAll} + onKeywordsChange={handleKeywordsChange} + onOpenTagManagement={() => setShowTagManagementModal(true)} + onTagsChange={handleTagsChange} + /> + <Datasets + datasetList={datasetListQuery.data} + emptyElement={ + showFilteredEmptyState ? ( + <FilterEmptyState + title={t(($) => $['filterEmpty.noKnowledge'], { ns: 'dataset' })} + /> + ) : undefined + } + fetchNextPage={datasetListQuery.fetchNextPage} + hasNextPage={datasetListQuery.hasNextPage} + isFetching={datasetListQuery.isFetching} + isFetchingNextPage={datasetListQuery.isFetchingNextPage} + isLoading={datasetListQuery.isLoading} + isPlaceholderData={datasetListQuery.isPlaceholderData} + onOpenTagManagement={() => setShowTagManagementModal(true)} + /> + </> + )} <TagManagementModal type="knowledge" show={showTagManagementModal} diff --git a/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx b/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx index 5eab01b3f00ff1..9cc3b5d6329e84 100644 --- a/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx +++ b/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx @@ -24,12 +24,8 @@ vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({ return ( <div role="group" aria-label="Date picker"> {trigger} - <button onClick={() => onChange(value || null)}> - Select Date - </button> - <button onClick={() => onClear()}> - Clear Date - </button> + <button onClick={() => onChange(value || null)}>Select Date</button> + <button onClick={() => onClear()}>Clear Date</button> </div> ) }, @@ -39,8 +35,7 @@ vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({ vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ formatTime: (timestamp: number) => { - if (!timestamp) - return '' + if (!timestamp) return '' return new Date(timestamp * 1000).toLocaleDateString() }, }), @@ -62,7 +57,9 @@ describe('WrappedDatePicker', () => { it('should render placeholder text when no value', () => { const handleChange = vi.fn() render(<WrappedDatePicker onChange={handleChange} />) - expect(screen.getByRole('button', { name: 'dataset.metadata.chooseTime' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'dataset.metadata.chooseTime' }), + ).toBeInTheDocument() }) it('should render formatted date when value is provided', () => { @@ -94,9 +91,7 @@ describe('WrappedDatePicker', () => { it('should render close icon for clearing', () => { const handleChange = vi.fn() const timestamp = Math.floor(Date.now() / 1000) - render( - <WrappedDatePicker value={timestamp} onChange={handleChange} />, - ) + render(<WrappedDatePicker value={timestamp} onChange={handleChange} />) expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument() }) @@ -104,7 +99,9 @@ describe('WrappedDatePicker', () => { const handleChange = vi.fn() render(<WrappedDatePicker label="Metadata field" onChange={handleChange} />) - expect(screen.getByRole('button', { name: 'Metadata field: dataset.metadata.chooseTime' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Metadata field: dataset.metadata.chooseTime' }), + ).toBeInTheDocument() }) it('should include the field label in the clear button accessible name', () => { @@ -112,7 +109,9 @@ describe('WrappedDatePicker', () => { const timestamp = 1609459200 render(<WrappedDatePicker label="Metadata field" value={timestamp} onChange={handleChange} />) - expect(screen.getByRole('button', { name: 'Metadata field: common.operation.clear' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Metadata field: common.operation.clear' }), + ).toBeInTheDocument() }) }) @@ -164,9 +163,7 @@ describe('WrappedDatePicker', () => { it('should call onChange with null when close icon is clicked directly', () => { const handleChange = vi.fn() const timestamp = Math.floor(Date.now() / 1000) - render( - <WrappedDatePicker value={timestamp} onChange={handleChange} />, - ) + render(<WrappedDatePicker value={timestamp} onChange={handleChange} />) fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) @@ -176,9 +173,7 @@ describe('WrappedDatePicker', () => { it('should show close button on hover when value exists', () => { const handleChange = vi.fn() const timestamp = Math.floor(Date.now() / 1000) - const { container } = render( - <WrappedDatePicker value={timestamp} onChange={handleChange} />, - ) + const { container } = render(<WrappedDatePicker value={timestamp} onChange={handleChange} />) // The close icon should be present but hidden initially const triggerGroup = container.querySelector('.group') @@ -188,13 +183,10 @@ describe('WrappedDatePicker', () => { it('should handle clicking on trigger element', () => { const handleChange = vi.fn() const timestamp = Math.floor(Date.now() / 1000) - const { container } = render( - <WrappedDatePicker value={timestamp} onChange={handleChange} />, - ) + const { container } = render(<WrappedDatePicker value={timestamp} onChange={handleChange} />) const trigger = container.querySelector('.group.flex') - if (trigger) - fireEvent.click(trigger) + if (trigger) fireEvent.click(trigger) expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) @@ -211,9 +203,7 @@ describe('WrappedDatePicker', () => { it('should have secondary text color when value exists', () => { const handleChange = vi.fn() const timestamp = Math.floor(Date.now() / 1000) - const { container } = render( - <WrappedDatePicker value={timestamp} onChange={handleChange} />, - ) + const { container } = render(<WrappedDatePicker value={timestamp} onChange={handleChange} />) const textElement = container.querySelector('.text-text-secondary') expect(textElement).toBeInTheDocument() }) @@ -228,9 +218,7 @@ describe('WrappedDatePicker', () => { it('should have quaternary text color for close icon when value exists', () => { const handleChange = vi.fn() const timestamp = Math.floor(Date.now() / 1000) - const { container } = render( - <WrappedDatePicker value={timestamp} onChange={handleChange} />, - ) + const { container } = render(<WrappedDatePicker value={timestamp} onChange={handleChange} />) const closeIcon = container.querySelector('.text-text-quaternary') expect(closeIcon).toBeInTheDocument() }) @@ -252,9 +240,7 @@ describe('WrappedDatePicker', () => { it('should handle switching between no value and value', () => { const handleChange = vi.fn() - const { rerender } = render( - <WrappedDatePicker onChange={handleChange} />, - ) + const { rerender } = render(<WrappedDatePicker onChange={handleChange} />) expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() diff --git a/web/app/components/datasets/metadata/base/date-picker.tsx b/web/app/components/datasets/metadata/base/date-picker.tsx index aae1558501198f..1f21629d431c68 100644 --- a/web/app/components/datasets/metadata/base/date-picker.tsx +++ b/web/app/components/datasets/metadata/base/date-picker.tsx @@ -1,9 +1,6 @@ import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types' import { cn } from '@langgenius/dify-ui/cn' -import { - RiCalendarLine, - RiCloseCircleFill, -} from '@remixicon/react' +import { RiCalendarLine, RiCloseCircleFill } from '@remixicon/react' import { useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' import { useCallback } from 'react' @@ -18,77 +15,77 @@ type Props = Readonly<{ value?: number onChange: (date: number | null) => void }> -const WrappedDatePicker = ({ - className, - label, - value, - onChange, -}: Props) => { +const WrappedDatePicker = ({ className, label, value, onChange }: Props) => { const { t } = useTranslation() const { data: timezone } = useQuery({ ...userProfileQueryOptions(), - select: data => data.profile.timezone ?? undefined, + select: (data) => data.profile.timezone ?? undefined, }) const { formatTime: formatTimestamp } = useTimestamp() - const handleDateChange = useCallback((date?: dayjs.Dayjs) => { - if (date) - onChange(date.unix()) - else - onChange(null) - }, [onChange]) + const handleDateChange = useCallback( + (date?: dayjs.Dayjs) => { + if (date) onChange(date.unix()) + else onChange(null) + }, + [onChange], + ) - const renderTrigger = useCallback(({ - handleClickTrigger, - }: TriggerProps) => { - const hasValue = Boolean(value) - const triggerText = value ? formatTimestamp(value, t($ => $['metadata.dateTimeFormat'], { ns: 'datasetDocuments' })) : t($ => $['metadata.chooseTime'], { ns: 'dataset' }) - const clearLabel = t($ => $['operation.clear'], { ns: 'common' }) + const renderTrigger = useCallback( + ({ handleClickTrigger }: TriggerProps) => { + const hasValue = Boolean(value) + const triggerText = value + ? formatTimestamp( + value, + t(($) => $['metadata.dateTimeFormat'], { ns: 'datasetDocuments' }), + ) + : t(($) => $['metadata.chooseTime'], { ns: 'dataset' }) + const clearLabel = t(($) => $['operation.clear'], { ns: 'common' }) - return ( - <div className={cn('group flex items-center rounded-md bg-components-input-bg-normal', className)}> - <button - type="button" - aria-label={label ? `${label}: ${triggerText}` : undefined} - className="flex min-w-0 grow items-center border-none bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={handleClickTrigger} + return ( + <div + className={cn( + 'group flex items-center rounded-md bg-components-input-bg-normal', + className, + )} > - <span - className={cn( - 'grow', - hasValue ? 'text-text-secondary' : 'text-text-tertiary', - )} + <button + type="button" + aria-label={label ? `${label}: ${triggerText}` : undefined} + className="flex min-w-0 grow items-center border-none bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleClickTrigger} > - {triggerText} - </span> - <RiCalendarLine - aria-hidden="true" - className={cn( - 'block size-4 shrink-0', - hasValue ? 'text-text-quaternary group-hover:hidden' : 'text-text-tertiary', - )} - /> - </button> - {hasValue - ? ( - <button - type="button" - aria-label={label ? `${label}: ${clearLabel}` : clearLabel} - className={cn( - 'hidden size-4 cursor-pointer rounded-full border-none bg-transparent p-0 text-text-quaternary group-hover:block hover:text-components-input-text-filled focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', - )} - onClick={(event) => { - event.stopPropagation() - handleDateChange() - }} - > - <RiCloseCircleFill className="size-4" aria-hidden="true" /> - </button> - ) - : null} - </div> - ) - }, [className, label, value, formatTimestamp, t, handleDateChange]) + <span className={cn('grow', hasValue ? 'text-text-secondary' : 'text-text-tertiary')}> + {triggerText} + </span> + <RiCalendarLine + aria-hidden="true" + className={cn( + 'block size-4 shrink-0', + hasValue ? 'text-text-quaternary group-hover:hidden' : 'text-text-tertiary', + )} + /> + </button> + {hasValue ? ( + <button + type="button" + aria-label={label ? `${label}: ${clearLabel}` : clearLabel} + className={cn( + 'hidden size-4 cursor-pointer rounded-full border-none bg-transparent p-0 text-text-quaternary group-hover:block hover:text-components-input-text-filled focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + )} + onClick={(event) => { + event.stopPropagation() + handleDateChange() + }} + > + <RiCloseCircleFill className="size-4" aria-hidden="true" /> + </button> + ) : null} + </div> + ) + }, + [className, label, value, formatTimestamp, t, handleDateChange], + ) return ( <DatePicker diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/add-row.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/add-row.spec.tsx index 342bddc33fbb21..c9b99963b018ff 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/add-row.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/add-row.spec.tsx @@ -21,7 +21,7 @@ vi.mock('../input-combined', () => ({ data-testid="input-combined" data-type={type} value={value || ''} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} /> ), })) @@ -52,18 +52,14 @@ describe('AddRow', () => { it('should render label with payload name', () => { const handleChange = vi.fn() const handleRemove = vi.fn() - render( - <AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />, - ) + render(<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />) expect(screen.getByTestId('label')).toHaveTextContent('test_field') }) it('should render input combined component', () => { const handleChange = vi.fn() const handleRemove = vi.fn() - render( - <AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />, - ) + render(<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />) expect(screen.getByTestId('input-combined')).toBeInTheDocument() }) @@ -80,18 +76,14 @@ describe('AddRow', () => { it('should pass correct type to input combined', () => { const handleChange = vi.fn() const handleRemove = vi.fn() - render( - <AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />, - ) + render(<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />) expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.string) }) it('should pass correct value to input combined', () => { const handleChange = vi.fn() const handleRemove = vi.fn() - render( - <AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />, - ) + render(<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />) expect(screen.getByTestId('input-combined')).toHaveValue('test value') }) }) @@ -128,9 +120,7 @@ describe('AddRow', () => { type: DataType.number, value: 42, } - render( - <AddRow payload={numberPayload} onChange={handleChange} onRemove={handleRemove} />, - ) + render(<AddRow payload={numberPayload} onChange={handleChange} onRemove={handleRemove} />) expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number) }) }) @@ -139,9 +129,7 @@ describe('AddRow', () => { it('should call onChange with updated payload when input changes', () => { const handleChange = vi.fn() const handleRemove = vi.fn() - render( - <AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />, - ) + render(<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />) fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } }) @@ -159,8 +147,7 @@ describe('AddRow', () => { ) const removeButton = container.querySelector('.cursor-pointer') - if (removeButton) - fireEvent.click(removeButton) + if (removeButton) fireEvent.click(removeButton) expect(handleRemove).toHaveBeenCalledTimes(1) }) @@ -168,9 +155,7 @@ describe('AddRow', () => { it('should preserve other payload properties on change', () => { const handleChange = vi.fn() const handleRemove = vi.fn() - render( - <AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />, - ) + render(<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />) fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'updated' } }) @@ -192,7 +177,10 @@ describe('AddRow', () => { <AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />, ) const removeButton = container.querySelector('.cursor-pointer') - expect(removeButton).toHaveClass('hover:bg-state-destructive-hover', 'hover:text-text-destructive') + expect(removeButton).toHaveClass( + 'hover:bg-state-destructive-hover', + 'hover:text-text-destructive', + ) }) }) @@ -204,9 +192,7 @@ describe('AddRow', () => { ...mockPayload, value: null, } - render( - <AddRow payload={nullPayload} onChange={handleChange} onRemove={handleRemove} />, - ) + render(<AddRow payload={nullPayload} onChange={handleChange} onRemove={handleRemove} />) expect(screen.getByTestId('input-combined')).toBeInTheDocument() }) @@ -217,9 +203,7 @@ describe('AddRow', () => { ...mockPayload, value: '', } - render( - <AddRow payload={emptyPayload} onChange={handleChange} onRemove={handleRemove} />, - ) + render(<AddRow payload={emptyPayload} onChange={handleChange} onRemove={handleRemove} />) expect(screen.getByTestId('input-combined')).toHaveValue('') }) @@ -231,9 +215,7 @@ describe('AddRow', () => { type: DataType.time, value: 1609459200, } - render( - <AddRow payload={timePayload} onChange={handleChange} onRemove={handleRemove} />, - ) + render(<AddRow payload={timePayload} onChange={handleChange} onRemove={handleRemove} />) expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time) }) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edit-row.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edit-row.spec.tsx index 19c02198b2c3d9..2923900ce416d5 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edit-row.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edit-row.spec.tsx @@ -32,7 +32,7 @@ vi.mock('../input-combined', () => ({ data-testid="input-combined" data-type={type} value={value || ''} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} readOnly={readOnly} /> ), @@ -42,7 +42,9 @@ vi.mock('../input-combined', () => ({ vi.mock('../input-has-set-multiple-value', () => ({ default: ({ onClear, readOnly }: MultipleValueInputProps) => ( <div data-testid="multiple-value-input" data-readonly={readOnly}> - <button data-testid="clear-multiple" onClick={onClear}>Clear Multiple</button> + <button data-testid="clear-multiple" onClick={onClear}> + Clear Multiple + </button> </div> ), })) @@ -50,14 +52,18 @@ vi.mock('../input-has-set-multiple-value', () => ({ // Mock Label component vi.mock('../label', () => ({ default: ({ text, isDeleted }: LabelProps) => ( - <div data-testid="label" data-deleted={isDeleted}>{text}</div> + <div data-testid="label" data-deleted={isDeleted}> + {text} + </div> ), })) // Mock EditedBeacon component vi.mock('../edited-beacon', () => ({ default: ({ onReset }: EditedBeaconProps) => ( - <button data-testid="edited-beacon" onClick={onReset}>Reset</button> + <button data-testid="edited-beacon" onClick={onReset}> + Reset + </button> ), })) @@ -253,8 +259,7 @@ describe('EditMetadatabatchItem', () => { ) const deleteButton = container.querySelector('.cursor-pointer') - if (deleteButton) - fireEvent.click(deleteButton) + if (deleteButton) fireEvent.click(deleteButton) expect(handleRemove).toHaveBeenCalledWith('test-id') }) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx index 5941182f70202f..28376a4ff6a596 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx @@ -13,7 +13,13 @@ type DatePickerProps = { // Mock the base date-picker component vi.mock('../../base/date-picker', () => ({ default: ({ value, onChange, className, label }: DatePickerProps) => ( - <button type="button" aria-label={label} data-testid="date-picker" className={className} onClick={() => onChange(Date.now())}> + <button + type="button" + aria-label={label} + data-testid="date-picker" + className={className} + onClick={() => onChange(Date.now())} + > {value || 'Pick date'} </button> ), @@ -24,7 +30,12 @@ describe('InputCombined', () => { it('should render without crashing', () => { const handleChange = vi.fn() const { container } = render( - <InputCombined label="Metadata field" type={DataType.string} value="" onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.string} + value="" + onChange={handleChange} + />, ) expect(container.firstChild).toBeInTheDocument() }) @@ -32,7 +43,12 @@ describe('InputCombined', () => { it('should render text input for string type', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.string} value="test" onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.string} + value="test" + onChange={handleChange} + />, ) const input = screen.getByDisplayValue('test') expect(input).toBeInTheDocument() @@ -42,7 +58,12 @@ describe('InputCombined', () => { it('should render number input for number type', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.number} value={42} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.number} + value={42} + onChange={handleChange} + />, ) const input = screen.getByRole('textbox', { name: 'Metadata field' }) expect(input).toBeInTheDocument() @@ -52,7 +73,12 @@ describe('InputCombined', () => { it('should render date picker for time type', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.time} value={Date.now()} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.time} + value={Date.now()} + onChange={handleChange} + />, ) expect(screen.getByTestId('date-picker')).toBeInTheDocument() }) @@ -62,7 +88,12 @@ describe('InputCombined', () => { it('should call onChange with input value for string type', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.string} value="" onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.string} + value="" + onChange={handleChange} + />, ) const input = screen.getByRole('textbox') @@ -74,7 +105,12 @@ describe('InputCombined', () => { it('should display current value for string type', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.string} value="existing value" onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.string} + value="existing value" + onChange={handleChange} + />, ) expect(screen.getByDisplayValue('existing value')).toBeInTheDocument() @@ -83,7 +119,13 @@ describe('InputCombined', () => { it('should apply readOnly prop to string input', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.string} value="test" onChange={handleChange} readOnly />, + <InputCombined + label="Metadata field" + type={DataType.string} + value="test" + onChange={handleChange} + readOnly + />, ) const input = screen.getByRole('textbox') @@ -95,7 +137,12 @@ describe('InputCombined', () => { it('should call onChange with number value for number type', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.number} value={0} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.number} + value={0} + onChange={handleChange} + />, ) const input = screen.getByRole('textbox') @@ -107,7 +154,12 @@ describe('InputCombined', () => { it('should reset cleared number input to 0', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.number} value={42} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.number} + value={42} + onChange={handleChange} + />, ) const input = screen.getByRole('textbox') @@ -119,7 +171,12 @@ describe('InputCombined', () => { it('should display current value for number type', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.number} value={999} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.number} + value={999} + onChange={handleChange} + />, ) expect(screen.getByRole('textbox')).toHaveValue('999') @@ -128,7 +185,13 @@ describe('InputCombined', () => { it('should apply readOnly prop to number input', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.number} value={42} onChange={handleChange} readOnly />, + <InputCombined + label="Metadata field" + type={DataType.number} + value={42} + onChange={handleChange} + readOnly + />, ) const input = screen.getByRole('textbox') @@ -140,7 +203,12 @@ describe('InputCombined', () => { it('should render date picker for time type', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.time} value={1234567890} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.time} + value={1234567890} + onChange={handleChange} + />, ) expect(screen.getByTestId('date-picker')).toBeInTheDocument() @@ -149,7 +217,12 @@ describe('InputCombined', () => { it('should label the date picker trigger with the metadata field name', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.time} value={1234567890} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.time} + value={1234567890} + onChange={handleChange} + />, ) expect(screen.getByRole('button', { name: 'Metadata field' })).toBeInTheDocument() @@ -158,7 +231,12 @@ describe('InputCombined', () => { it('should call onChange when date is selected', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.time} value={null} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.time} + value={null} + onChange={handleChange} + />, ) fireEvent.click(screen.getByTestId('date-picker')) @@ -187,7 +265,12 @@ describe('InputCombined', () => { it('should handle null value for string type', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.string} value={null} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.string} + value={null} + onChange={handleChange} + />, ) const input = screen.getByRole('textbox') @@ -197,7 +280,12 @@ describe('InputCombined', () => { it('should handle undefined value for string type', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.string} value={undefined as unknown as string} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.string} + value={undefined as unknown as string} + onChange={handleChange} + />, ) const input = screen.getByRole('textbox') @@ -207,7 +295,12 @@ describe('InputCombined', () => { it('should handle null value for number type', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.number} value={null} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.number} + value={null} + onChange={handleChange} + />, ) const input = screen.getByRole('textbox') @@ -219,7 +312,12 @@ describe('InputCombined', () => { it('should have correct base styling for string input', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.string} value="" onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.string} + value="" + onChange={handleChange} + />, ) const input = screen.getByRole('textbox') @@ -229,7 +327,12 @@ describe('InputCombined', () => { it('should have correct styling for number input', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.number} value={0} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.number} + value={0} + onChange={handleChange} + />, ) const input = screen.getByRole('textbox') @@ -241,7 +344,12 @@ describe('InputCombined', () => { it('should handle empty string value', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.string} value="" onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.string} + value="" + onChange={handleChange} + />, ) const input = screen.getByRole('textbox') @@ -251,7 +359,12 @@ describe('InputCombined', () => { it('should handle zero value for number', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.number} value={0} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.number} + value={0} + onChange={handleChange} + />, ) expect(screen.getByRole('textbox')).toHaveValue('0') @@ -260,7 +373,12 @@ describe('InputCombined', () => { it('should handle negative number', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.number} value={-100} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.number} + value={-100} + onChange={handleChange} + />, ) expect(screen.getByRole('textbox')).toHaveValue('-100') @@ -269,7 +387,12 @@ describe('InputCombined', () => { it('should handle special characters in string', () => { const handleChange = vi.fn() render( - <InputCombined label="Metadata field" type={DataType.string} value={'<script>alert("xss")</script>'} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.string} + value={'<script>alert("xss")</script>'} + onChange={handleChange} + />, ) expect(screen.getByDisplayValue('<script>alert("xss")</script>')).toBeInTheDocument() @@ -278,13 +401,23 @@ describe('InputCombined', () => { it('should handle switching between types', () => { const handleChange = vi.fn() const { rerender } = render( - <InputCombined label="Metadata field" type={DataType.string} value="test" onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.string} + value="test" + onChange={handleChange} + />, ) expect(screen.getByRole('textbox')).toBeInTheDocument() rerender( - <InputCombined label="Metadata field" type={DataType.number} value={42} onChange={handleChange} />, + <InputCombined + label="Metadata field" + type={DataType.number} + value={42} + onChange={handleChange} + />, ) expect(screen.getByRole('textbox')).toBeInTheDocument() diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-has-set-multiple-value.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-has-set-multiple-value.spec.tsx index ef76fd361aabbb..9b9acfdf932184 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-has-set-multiple-value.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-has-set-multiple-value.spec.tsx @@ -13,7 +13,13 @@ describe('InputHasSetMultipleValue', () => { it('should render with correct wrapper styling', () => { const handleClear = vi.fn() const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />) - expect(container.firstChild).toHaveClass('h-6', 'grow', 'rounded-md', 'bg-components-input-bg-normal', 'p-0.5') + expect(container.firstChild).toHaveClass( + 'h-6', + 'grow', + 'rounded-md', + 'bg-components-input-bg-normal', + 'p-0.5', + ) }) it('should render multiple value text', () => { @@ -43,14 +49,18 @@ describe('InputHasSetMultipleValue', () => { it('should show close icon when readOnly is false', () => { const handleClear = vi.fn() - const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />) + const { container } = render( + <InputHasSetMultipleValue onClear={handleClear} readOnly={false} />, + ) const svg = container.querySelector('svg') expect(svg).toBeInTheDocument() }) it('should show close icon when readOnly is undefined', () => { const handleClear = vi.fn() - const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={undefined} />) + const { container } = render( + <InputHasSetMultipleValue onClear={handleClear} readOnly={undefined} />, + ) const svg = container.querySelector('svg') expect(svg).toBeInTheDocument() }) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/label.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/label.spec.tsx index bce0de4118aa64..f0005263a8118a 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/label.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/label.spec.tsx @@ -12,7 +12,13 @@ describe('Label', () => { it('should render text with correct styling', () => { render(<Label text="My Label" />) const labelElement = screen.getByText('My Label') - expect(labelElement).toHaveClass('system-xs-medium', 'w-[136px]', 'shrink-0', 'truncate', 'text-text-tertiary') + expect(labelElement).toHaveClass( + 'system-xs-medium', + 'w-[136px]', + 'shrink-0', + 'truncate', + 'text-text-tertiary', + ) }) it('should not have deleted styling by default', () => { diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx index 8535528b48102a..9eeeaaf4cf6e30 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx @@ -62,20 +62,24 @@ vi.mock('../edit-row', () => ({ default: ({ payload, onChange, onRemove, onReset }: EditRowProps) => ( <div role="group" aria-label={`Edit metadata ${payload.name}`}> <span>{payload.name}</span> - <button type="button" onClick={() => onChange({ ...payload, value: 'changed', isUpdated: true, updateType: UpdateType.changeValue })}> - Change - {' '} - {payload.name} + <button + type="button" + onClick={() => + onChange({ + ...payload, + value: 'changed', + isUpdated: true, + updateType: UpdateType.changeValue, + }) + } + > + Change {payload.name} </button> <button type="button" onClick={() => onRemove(payload.id)}> - Remove - {' '} - {payload.name} + Remove {payload.name} </button> <button type="button" onClick={() => onReset(payload.id)}> - Reset - {' '} - {payload.name} + Reset {payload.name} </button> </div> ), @@ -86,14 +90,10 @@ vi.mock('../add-row', () => ({ <div role="group" aria-label={`Added metadata ${payload.name}`}> <span>{payload.name}</span> <button type="button" onClick={() => onChange({ ...payload, value: 'new_value' })}> - Change - {' '} - {payload.name} + Change {payload.name} </button> <button type="button" onClick={onRemove}> - Remove - {' '} - {payload.name} + Remove {payload.name} </button> </div> ), @@ -121,8 +121,10 @@ describe('EditMetadataBatchModal', () => { const getEditRows = () => screen.getAllByRole('group', { name: /^Edit metadata / }) const getEditRow = (name: string) => screen.getByRole('group', { name: `Edit metadata ${name}` }) - const getAddedRow = (name: string) => screen.getByRole('group', { name: `Added metadata ${name}` }) - const queryAddedRow = (name: string) => screen.queryByRole('group', { name: `Added metadata ${name}` }) + const getAddedRow = (name: string) => + screen.getByRole('group', { name: `Added metadata ${name}` }) + const queryAddedRow = (name: string) => + screen.queryByRole('group', { name: `Added metadata ${name}` }) const openMetadataPicker = () => { fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) } @@ -132,8 +134,13 @@ describe('EditMetadataBatchModal', () => { } const createMetadata = async (name = 'created_field') => { openMetadataPicker() - fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })) - fireEvent.change(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), { target: { value: name } }) + fireEvent.click( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }), + ) + fireEvent.change( + screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + { target: { value: name } }, + ) const saveButtons = screen.getAllByRole('button', { name: 'common.operation.save' }) fireEvent.click(saveButtons[saveButtons.length - 1]!) } @@ -171,14 +178,20 @@ describe('EditMetadataBatchModal', () => { it('should render checkbox for apply to all', async () => { render(<EditMetadataBatchModal {...defaultProps} />) await waitFor(() => { - expect(screen.getByRole('checkbox', { name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument' })).toBeInTheDocument() + expect( + screen.getByRole('checkbox', { + name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument', + }), + ).toBeInTheDocument() }) }) it('should render dataset metadata picker', async () => { render(<EditMetadataBatchModal {...defaultProps} />) await waitFor(() => { - expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))!.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }), + )!.toBeInTheDocument() }) }) }) @@ -220,7 +233,9 @@ describe('EditMetadataBatchModal', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - const checkbox = screen.getByRole('checkbox', { name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument' }) + const checkbox = screen.getByRole('checkbox', { + name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument', + }) await user.click(checkbox) await waitFor(() => { @@ -409,7 +424,9 @@ describe('EditMetadataBatchModal', () => { }) openMetadataPicker() - fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })) + fireEvent.click( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' }), + ) expect(onShowManage).toHaveBeenCalled() }) @@ -476,11 +493,7 @@ describe('EditMetadataBatchModal', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(onSave).toHaveBeenCalledWith( - expect.any(Array), - expect.any(Array), - expect.any(Boolean), - ) + expect(onSave).toHaveBeenCalledWith(expect.any(Array), expect.any(Array), expect.any(Boolean)) }) it('should pass isApplyToAllSelectDocument as true when checked', async () => { @@ -492,16 +505,16 @@ describe('EditMetadataBatchModal', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - await user.click(screen.getByRole('checkbox', { name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument' })) + await user.click( + screen.getByRole('checkbox', { + name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument', + }), + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) await waitFor(() => { - expect(onSave).toHaveBeenCalledWith( - expect.any(Array), - expect.any(Array), - true, - ) + expect(onSave).toHaveBeenCalledWith(expect.any(Array), expect.any(Array), true) }) }) @@ -521,7 +534,7 @@ describe('EditMetadataBatchModal', () => { expect(onSave).toHaveBeenCalled() // The first argument should not contain the deleted item (id '1') const savedList = onSave.mock.calls[0]![0] as MetadataItemInBatchEdit[] - const hasDeletedItem = savedList.some(item => item.id === '1') + const hasDeletedItem = savedList.some((item) => item.id === '1') expect(hasDeletedItem).toBe(false) }) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/add-row.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/add-row.tsx index f7dd573a735fee..d6bc6c574da80e 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/add-row.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/add-row.tsx @@ -14,12 +14,7 @@ type Props = Readonly<{ onRemove: () => void }> -const AddRow: FC<Props> = ({ - className, - payload, - onChange, - onRemove, -}) => { +const AddRow: FC<Props> = ({ className, payload, onChange, onRemove }) => { return ( <div className={cn('flex h-6 items-center space-x-0.5', className)}> <Label text={payload.name} /> @@ -27,14 +22,12 @@ const AddRow: FC<Props> = ({ label={payload.name} type={payload.type} value={payload.value} - onChange={value => onChange({ ...payload, value })} + onChange={(value) => onChange({ ...payload, value })} /> <div - className={ - cn( - 'cursor-pointer rounded-md p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive', - ) - } + className={cn( + 'cursor-pointer rounded-md p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive', + )} onClick={onRemove} > <RiIndeterminateCircleLine className="size-4" /> diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/edit-row.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/edit-row.tsx index 94da42f18e0311..9d43f959a036da 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/edit-row.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/edit-row.tsx @@ -17,42 +17,37 @@ type Props = Readonly<{ onReset: (id: string) => void }> -const EditMetadatabatchItem: FC<Props> = ({ - payload, - onChange, - onRemove, - onReset, -}) => { +const EditMetadatabatchItem: FC<Props> = ({ payload, onChange, onRemove, onReset }) => { const isUpdated = payload.isUpdated const isDeleted = payload.updateType === UpdateType.delete return ( <div className="flex h-6 items-center space-x-0.5"> - {isUpdated ? <EditedBeacon onReset={() => onReset(payload.id)} /> : <div className="size-4 shrink-0" />} + {isUpdated ? ( + <EditedBeacon onReset={() => onReset(payload.id)} /> + ) : ( + <div className="size-4 shrink-0" /> + )} <Label text={payload.name} isDeleted={isDeleted} /> - {payload.isMultipleValue - ? ( - <InputHasSetMultipleValue - onClear={() => onChange({ ...payload, value: null, isMultipleValue: false })} - readOnly={isDeleted} - /> - ) - : ( - <InputCombined - label={payload.name} - type={payload.type} - value={payload.value} - onChange={v => onChange({ ...payload, value: v as string })} - readOnly={isDeleted} - /> - )} + {payload.isMultipleValue ? ( + <InputHasSetMultipleValue + onClear={() => onChange({ ...payload, value: null, isMultipleValue: false })} + readOnly={isDeleted} + /> + ) : ( + <InputCombined + label={payload.name} + type={payload.type} + value={payload.value} + onChange={(v) => onChange({ ...payload, value: v as string })} + readOnly={isDeleted} + /> + )} <div - className={ - cn( - 'cursor-pointer rounded-md p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive', - isDeleted && 'cursor-default bg-state-destructive-hover text-text-destructive', - ) - } + className={cn( + 'cursor-pointer rounded-md p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive', + isDeleted && 'cursor-default bg-state-destructive-hover text-text-destructive', + )} onClick={() => onRemove(payload.id)} > <RiDeleteBinLine className="size-4" /> diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx index b3fb2f2d5fe2b3..bef82b83eae940 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx @@ -11,40 +11,37 @@ type Props = Readonly<{ onReset: () => void }> -const EditedBeacon: FC<Props> = ({ - onReset, -}) => { +const EditedBeacon: FC<Props> = ({ onReset }) => { const { t } = useTranslation() const ref = useRef(null) const isHovering = useHover(ref) return ( <div ref={ref} className="size-4 cursor-pointer"> - {isHovering - ? ( - <Tooltip> - <TooltipTrigger - render={( - <button - type="button" - aria-label={t($ => $['operation.reset'], { ns: 'common' })} - className="flex size-4 items-center justify-center rounded-full border-none bg-text-accent-secondary p-0" - onClick={onReset} - > - <RiResetLeftLine className="size-[10px] text-text-primary-on-surface" aria-hidden="true" /> - </button> - )} - /> - <TooltipContent> - {t($ => $['operation.reset'], { ns: 'common' })} - </TooltipContent> - </Tooltip> - ) - : ( - <div className="flex size-4 items-center justify-center"> - <div className="size-1 rounded-full bg-text-accent-secondary"></div> - </div> - )} + {isHovering ? ( + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + aria-label={t(($) => $['operation.reset'], { ns: 'common' })} + className="flex size-4 items-center justify-center rounded-full border-none bg-text-accent-secondary p-0" + onClick={onReset} + > + <RiResetLeftLine + className="size-[10px] text-text-primary-on-surface" + aria-hidden="true" + /> + </button> + } + /> + <TooltipContent>{t(($) => $['operation.reset'], { ns: 'common' })}</TooltipContent> + </Tooltip> + ) : ( + <div className="flex size-4 items-center justify-center"> + <div className="size-1 rounded-full bg-text-accent-secondary"></div> + </div> + )} </div> ) } diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx index 5156944c679dc0..5409e1c6946b45 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx @@ -33,14 +33,7 @@ const InputCombined: FC<Props> = ({ }) => { const className = cn('h-6 grow p-0.5 text-xs') if (type === DataType.time) { - return ( - <Datepicker - label={label} - className={className} - value={value} - onChange={onChange} - /> - ) + return <Datepicker label={label} className={className} value={value} onChange={onChange} /> } if (type === DataType.number) { @@ -50,13 +43,10 @@ const InputCombined: FC<Props> = ({ className="min-w-0" value={value} readOnly={readOnly} - onValueChange={value => onChange(value ?? 0)} + onValueChange={(value) => onChange(value ?? 0)} > <NumberFieldGroup> - <NumberFieldInput - aria-label={label} - className={cn(className, 'rounded-l-md')} - /> + <NumberFieldInput aria-label={label} className={cn(className, 'rounded-l-md')} /> <NumberFieldControls className="overflow-hidden"> <NumberFieldIncrement className="py-0" /> <NumberFieldDecrement className="py-0" /> @@ -71,7 +61,7 @@ const InputCombined: FC<Props> = ({ aria-label={label} className={cn(configClassName, className, 'rounded-md')} value={value} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} readOnly={readOnly} /> ) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.tsx index 49072bd93104ba..8b59ab0d3b7899 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.tsx @@ -10,21 +10,22 @@ type Props = Readonly<{ readOnly?: boolean }> -const InputHasSetMultipleValue: FC<Props> = ({ - onClear, - readOnly, -}) => { +const InputHasSetMultipleValue: FC<Props> = ({ onClear, readOnly }) => { const { t } = useTranslation() return ( <div className="h-6 grow rounded-md bg-components-input-bg-normal p-0.5 text-[0]"> - <div className={cn('inline-flex h-5 items-center space-x-0.5 rounded-[5px] border-[0.5px] border-components-panel-border bg-components-badge-white-to-dark pr-0.5 pl-1.5 shadow-xs', readOnly && 'pr-1.5')}> - <div className="system-xs-regular text-text-secondary">{t($ => $['metadata.batchEditMetadata.multipleValue'], { ns: 'dataset' })}</div> + <div + className={cn( + 'inline-flex h-5 items-center space-x-0.5 rounded-[5px] border-[0.5px] border-components-panel-border bg-components-badge-white-to-dark pr-0.5 pl-1.5 shadow-xs', + readOnly && 'pr-1.5', + )} + > + <div className="system-xs-regular text-text-secondary"> + {t(($) => $['metadata.batchEditMetadata.multipleValue'], { ns: 'dataset' })} + </div> {!readOnly && ( <div className="cursor-pointer rounded-sm p-px text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"> - <RiCloseLine - className="size-3.5" - onClick={onClear} - /> + <RiCloseLine className="size-3.5" onClick={onClear} /> </div> )} </div> diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/label.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/label.tsx index 2a1204a41860ac..5c81e89caa1ad3 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/label.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/label.tsx @@ -9,17 +9,14 @@ type Props = Readonly<{ text: string }> -const Label: FC<Props> = ({ - isDeleted, - className, - text, -}) => { +const Label: FC<Props> = ({ isDeleted, className, text }) => { return ( - <div className={cn( - 'w-[136px] shrink-0 truncate system-xs-medium text-text-tertiary', - isDeleted && 'text-text-quaternary line-through', - className, - )} + <div + className={cn( + 'w-[136px] shrink-0 truncate system-xs-medium text-text-tertiary', + isDeleted && 'text-text-quaternary line-through', + className, + )} > {text} </div> diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx index 93634b054dcf41..3de464ac2faee7 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx @@ -23,97 +23,148 @@ type Props = Readonly<{ datasetId: string documentNum: number list: MetadataItemInBatchEdit[] - onSave: (editedList: MetadataItemInBatchEdit[], addedList: MetadataItemInBatchEdit[], isApplyToAllSelectDocument: boolean) => void + onSave: ( + editedList: MetadataItemInBatchEdit[], + addedList: MetadataItemInBatchEdit[], + isApplyToAllSelectDocument: boolean, + ) => void onHide: () => void onShowManage: () => void }> -const EditMetadataBatchModal: FC<Props> = ({ datasetId, documentNum, list, onSave, onHide, onShowManage }) => { +const EditMetadataBatchModal: FC<Props> = ({ + datasetId, + documentNum, + list, + onSave, + onHide, + onShowManage, +}) => { const { t } = useTranslation() const [templeList, setTempleList] = useState<MetadataItemWithEdit[]>(list) - const handleTemplesChange = useCallback((payload: MetadataItemWithEdit) => { - const newTempleList = produce(templeList, (draft) => { - const index = draft.findIndex(i => i.id === payload.id) - if (index !== -1) { - draft[index] = payload - draft[index].isUpdated = true - draft[index].updateType = UpdateType.changeValue - } - }) - setTempleList(newTempleList) - }, [templeList]) - const handleTempleItemRemove = useCallback((id: string) => { - const newTempleList = produce(templeList, (draft) => { - const index = draft.findIndex(i => i.id === id) - if (index !== -1) { - draft[index]!.isUpdated = true - draft[index]!.updateType = UpdateType.delete - } - }) - setTempleList(newTempleList) - }, [templeList]) - const handleItemReset = useCallback((id: string) => { - const newTempleList = produce(templeList, (draft) => { - const index = draft.findIndex(i => i.id === id) - if (index !== -1) { - draft[index] = { ...list[index]! } - draft[index]!.isUpdated = false - delete draft[index]!.updateType - } - }) - setTempleList(newTempleList) - }, [list, templeList]) + const handleTemplesChange = useCallback( + (payload: MetadataItemWithEdit) => { + const newTempleList = produce(templeList, (draft) => { + const index = draft.findIndex((i) => i.id === payload.id) + if (index !== -1) { + draft[index] = payload + draft[index].isUpdated = true + draft[index].updateType = UpdateType.changeValue + } + }) + setTempleList(newTempleList) + }, + [templeList], + ) + const handleTempleItemRemove = useCallback( + (id: string) => { + const newTempleList = produce(templeList, (draft) => { + const index = draft.findIndex((i) => i.id === id) + if (index !== -1) { + draft[index]!.isUpdated = true + draft[index]!.updateType = UpdateType.delete + } + }) + setTempleList(newTempleList) + }, + [templeList], + ) + const handleItemReset = useCallback( + (id: string) => { + const newTempleList = produce(templeList, (draft) => { + const index = draft.findIndex((i) => i.id === id) + if (index !== -1) { + draft[index] = { ...list[index]! } + draft[index]!.isUpdated = false + delete draft[index]!.updateType + } + }) + setTempleList(newTempleList) + }, + [list, templeList], + ) const { checkName } = useCheckMetadataName() const { mutate: doAddMetaData } = useCreateMetaData(datasetId) - const handleAddMetaData = useCallback(async (payload: BuiltInMetadataItem) => { - const errorMsg = checkName(payload.name).errorMsg - if (errorMsg) { - toast.error(errorMsg) - return Promise.reject(new Error(errorMsg)) - } - await doAddMetaData(payload) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - }, [checkName, doAddMetaData, t]) + const handleAddMetaData = useCallback( + async (payload: BuiltInMetadataItem) => { + const errorMsg = checkName(payload.name).errorMsg + if (errorMsg) { + toast.error(errorMsg) + return Promise.reject(new Error(errorMsg)) + } + await doAddMetaData(payload) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + }, + [checkName, doAddMetaData, t], + ) const [addedList, setAddedList] = useState<MetadataItemWithEdit[]>([]) - const handleAddedListChange = useCallback((payload: MetadataItemWithEdit) => { - const newAddedList = addedList.map(i => i.id === payload.id ? payload : i) - setAddedList(newAddedList) - }, [addedList]) - const handleAddedItemRemove = useCallback((removeIndex: number) => { - return () => { - const newAddedList = addedList.filter((i, index) => index !== removeIndex) + const handleAddedListChange = useCallback( + (payload: MetadataItemWithEdit) => { + const newAddedList = addedList.map((i) => (i.id === payload.id ? payload : i)) setAddedList(newAddedList) - } - }, [addedList]) + }, + [addedList], + ) + const handleAddedItemRemove = useCallback( + (removeIndex: number) => { + return () => { + const newAddedList = addedList.filter((i, index) => index !== removeIndex) + setAddedList(newAddedList) + } + }, + [addedList], + ) const [isApplyToAllSelectDocument, setIsApplyToAllSelectDocument] = useState(false) const handleSave = useCallback(() => { - onSave(templeList.filter(item => item.updateType !== UpdateType.delete), addedList, isApplyToAllSelectDocument) + onSave( + templeList.filter((item) => item.updateType !== UpdateType.delete), + addedList, + isApplyToAllSelectDocument, + ) }, [templeList, addedList, isApplyToAllSelectDocument, onSave]) return ( <Dialog open onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DialogContent className="w-full max-w-[640px]! overflow-hidden! border-none text-left align-middle"> <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $[`${i18nPrefix}.editMetadata`], { ns: 'dataset' })} + {t(($) => $[`${i18nPrefix}.editMetadata`], { ns: 'dataset' })} </DialogTitle> - <div className="mt-1 system-xs-medium text-text-accent">{t($ => $[`${i18nPrefix}.editDocumentsNum`], { ns: 'dataset', num: documentNum })}</div> + <div className="mt-1 system-xs-medium text-text-accent"> + {t(($) => $[`${i18nPrefix}.editDocumentsNum`], { ns: 'dataset', num: documentNum })} + </div> <div className="max-h-[305px] overflow-x-hidden overflow-y-auto"> <div className="mt-4 space-y-2"> - {templeList.map(item => (<EditMetadataBatchItem key={item.id} payload={item} onChange={handleTemplesChange} onRemove={handleTempleItemRemove} onReset={handleItemReset} />))} + {templeList.map((item) => ( + <EditMetadataBatchItem + key={item.id} + payload={item} + onChange={handleTemplesChange} + onRemove={handleTempleItemRemove} + onReset={handleItemReset} + /> + ))} </div> <div className="mt-4 pl-[18px]"> <div className="flex items-center"> - <div className="mr-2 shrink-0 system-xs-medium-uppercase text-text-tertiary">{t($ => $['metadata.createMetadata.title'], { ns: 'dataset' })}</div> + <div className="mr-2 shrink-0 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['metadata.createMetadata.title'], { ns: 'dataset' })} + </div> <Divider bgStyle="gradient" /> </div> <div className="mt-2 space-y-2"> - {addedList.map((item, i) => (<AddedMetadataItem key={i} payload={item} onChange={handleAddedListChange} onRemove={handleAddedItemRemove(i)} />))} + {addedList.map((item, i) => ( + <AddedMetadataItem + key={i} + payload={item} + onChange={handleAddedListChange} + onRemove={handleAddedItemRemove(i)} + /> + ))} </div> <div className="mt-3"> <DatasetMetadataPicker @@ -122,7 +173,9 @@ const EditMetadataBatchModal: FC<Props> = ({ datasetId, documentNum, list, onSav sideOffset={4} alignOffset={0} onCreateMetadata={handleAddMetaData} - onSelectMetadata={data => setAddedList([...addedList, data as MetadataItemWithEdit])} + onSelectMetadata={(data) => + setAddedList([...addedList, data as MetadataItemWithEdit]) + } onOpenMetadataManagement={onShowManage} /> </div> @@ -137,23 +190,23 @@ const EditMetadataBatchModal: FC<Props> = ({ datasetId, documentNum, list, onSav onCheckedChange={setIsApplyToAllSelectDocument} /> <span className="mr-1 ml-2 system-xs-medium text-text-secondary"> - {t($ => $[`${i18nPrefix}.applyToAllSelectDocument`], { ns: 'dataset' })} + {t(($) => $[`${i18nPrefix}.applyToAllSelectDocument`], { ns: 'dataset' })} </span> </label> <Infotip - aria-label={t($ => $[`${i18nPrefix}.applyToAllSelectDocumentTip`], { ns: 'dataset' })} + aria-label={t(($) => $[`${i18nPrefix}.applyToAllSelectDocumentTip`], { + ns: 'dataset', + })} className="p-px text-text-tertiary" popupClassName="max-w-[240px]" > - {t($ => $[`${i18nPrefix}.applyToAllSelectDocumentTip`], { ns: 'dataset' })} + {t(($) => $[`${i18nPrefix}.applyToAllSelectDocumentTip`], { ns: 'dataset' })} </Infotip> </div> <div className="flex items-center space-x-2"> - <Button onClick={onHide}> - {t($ => $['operation.cancel'], { ns: 'common' })} - </Button> + <Button onClick={onHide}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> <Button onClick={handleSave} variant="primary"> - {t($ => $['operation.save'], { ns: 'common' })} + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </div> diff --git a/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts index 84af23349432c4..a891c8c225688f 100644 --- a/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts @@ -54,9 +54,7 @@ describe('useBatchEditDocumentMetadata', () => { { id: 'doc-2', name: 'Document 2', - doc_metadata: [ - { id: '1', name: 'field_one', type: DataType.string, value: 'Value 2' }, - ], + doc_metadata: [{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 2' }], }, ] @@ -144,11 +142,13 @@ describe('useBatchEditDocumentMetadata', () => { const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListWithBuiltIn as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListWithBuiltIn as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) - const hasBuiltIn = result.current.originalList.some(item => item.id === 'built-in') + const hasBuiltIn = result.current.originalList.some((item) => item.id === 'built-in') expect(hasBuiltIn).toBe(false) }) @@ -156,26 +156,24 @@ describe('useBatchEditDocumentMetadata', () => { const docListWithDifferentValues: DocListItem[] = [ { id: 'doc-1', - doc_metadata: [ - { id: '1', name: 'field', type: DataType.string, value: 'Value A' }, - ], + doc_metadata: [{ id: '1', name: 'field', type: DataType.string, value: 'Value A' }], }, { id: 'doc-2', - doc_metadata: [ - { id: '1', name: 'field', type: DataType.string, value: 'Value B' }, - ], + doc_metadata: [{ id: '1', name: 'field', type: DataType.string, value: 'Value B' }], }, ] const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListWithDifferentValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListWithDifferentValues as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) - const fieldItem = result.current.originalList.find(item => item.id === '1') + const fieldItem = result.current.originalList.find((item) => item.id === '1') expect(fieldItem?.isMultipleValue).toBe(true) }) @@ -183,26 +181,24 @@ describe('useBatchEditDocumentMetadata', () => { const docListWithSameValues: DocListItem[] = [ { id: 'doc-1', - doc_metadata: [ - { id: '1', name: 'field', type: DataType.string, value: 'Same Value' }, - ], + doc_metadata: [{ id: '1', name: 'field', type: DataType.string, value: 'Same Value' }], }, { id: 'doc-2', - doc_metadata: [ - { id: '1', name: 'field', type: DataType.string, value: 'Same Value' }, - ], + doc_metadata: [{ id: '1', name: 'field', type: DataType.string, value: 'Same Value' }], }, ] const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListWithSameValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListWithSameValues as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) - const fieldItem = result.current.originalList.find(item => item.id === '1') + const fieldItem = result.current.originalList.find((item) => item.id === '1') expect(fieldItem?.isMultipleValue).toBe(false) }) @@ -211,33 +207,29 @@ describe('useBatchEditDocumentMetadata', () => { const docListThreeDocs: DocListItem[] = [ { id: 'doc-1', - doc_metadata: [ - { id: '1', name: 'field', type: DataType.string, value: 'Value A' }, - ], + doc_metadata: [{ id: '1', name: 'field', type: DataType.string, value: 'Value A' }], }, { id: 'doc-2', - doc_metadata: [ - { id: '1', name: 'field', type: DataType.string, value: 'Value B' }, - ], + doc_metadata: [{ id: '1', name: 'field', type: DataType.string, value: 'Value B' }], }, { id: 'doc-3', - doc_metadata: [ - { id: '1', name: 'field', type: DataType.string, value: 'Value C' }, - ], + doc_metadata: [{ id: '1', name: 'field', type: DataType.string, value: 'Value C' }], }, ] const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListThreeDocs as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListThreeDocs as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) // Should only have one item for field '1', marked as multiple - const fieldItems = result.current.originalList.filter(item => item.id === '1') + const fieldItems = result.current.originalList.filter((item) => item.id === '1') expect(fieldItems.length).toBe(1) expect(fieldItems[0]!.isMultipleValue).toBe(true) }) @@ -294,16 +286,16 @@ describe('useBatchEditDocumentMetadata', () => { const docListSingleDoc: DocListItem[] = [ { id: 'doc-1', - doc_metadata: [ - { id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' }, - ], + doc_metadata: [{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' }], }, ] const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListSingleDoc as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) @@ -352,7 +344,9 @@ describe('useBatchEditDocumentMetadata', () => { const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListSingleDoc as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) @@ -377,16 +371,16 @@ describe('useBatchEditDocumentMetadata', () => { const docListSingleDoc: DocListItem[] = [ { id: 'doc-1', - doc_metadata: [ - { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' }, - ], + doc_metadata: [{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' }], }, ] const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListSingleDoc as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) @@ -424,9 +418,7 @@ describe('useBatchEditDocumentMetadata', () => { const docListMissingField: DocListItem[] = [ { id: 'doc-1', - doc_metadata: [ - { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' }, - ], + doc_metadata: [{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' }], }, { id: 'doc-2', @@ -437,7 +429,9 @@ describe('useBatchEditDocumentMetadata', () => { const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListMissingField as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListMissingField as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) @@ -467,15 +461,11 @@ describe('useBatchEditDocumentMetadata', () => { const docListDifferentValues: DocListItem[] = [ { id: 'doc-1', - doc_metadata: [ - { id: '1', name: 'field_one', type: DataType.string, value: 'Value A' }, - ], + doc_metadata: [{ id: '1', name: 'field_one', type: DataType.string, value: 'Value A' }], }, { id: 'doc-2', - doc_metadata: [ - { id: '1', name: 'field_one', type: DataType.string, value: 'Value B' }, - ], + doc_metadata: [{ id: '1', name: 'field_one', type: DataType.string, value: 'Value B' }], }, { id: 'doc-3', @@ -486,7 +476,9 @@ describe('useBatchEditDocumentMetadata', () => { const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListDifferentValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListDifferentValues as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) @@ -523,7 +515,9 @@ describe('useBatchEditDocumentMetadata', () => { const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListSingleDoc as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) @@ -622,16 +616,16 @@ describe('useBatchEditDocumentMetadata', () => { const docListSingleDoc: DocListItem[] = [ { id: 'doc-1', - doc_metadata: [ - { id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' }, - ], + doc_metadata: [{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' }], }, ] const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListSingleDoc as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) @@ -665,16 +659,16 @@ describe('useBatchEditDocumentMetadata', () => { const docListSingleDoc: DocListItem[] = [ { id: 'doc-1', - doc_metadata: [ - { id: '1', name: 'field_one', type: DataType.string, value: 'Value' }, - ], + doc_metadata: [{ id: '1', name: 'field_one', type: DataType.string, value: 'Value' }], }, ] const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListSingleDoc as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) @@ -723,7 +717,9 @@ describe('useBatchEditDocumentMetadata', () => { const { result } = renderHook(() => useBatchEditDocumentMetadata({ ...defaultProps, - docList: docListNoMetadata as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + docList: docListNoMetadata as Parameters< + typeof useBatchEditDocumentMetadata + >[0]['docList'], }), ) diff --git a/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts index 6fa6f6f4418174..926df7459ba69c 100644 --- a/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts @@ -297,10 +297,9 @@ describe('useEditDatasetMetadata', () => { describe('Edge Cases', () => { it('should handle different datasetIds', () => { - const { result, rerender } = renderHook( - props => useEditDatasetMetadata(props), - { initialProps: defaultProps }, - ) + const { result, rerender } = renderHook((props) => useEditDatasetMetadata(props), { + initialProps: defaultProps, + }) expect(result.current).toBeDefined() diff --git a/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts index a13378ea80c8c7..941bcf1f819e12 100644 --- a/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts @@ -141,13 +141,13 @@ describe('useMetadataDocument', () => { it('should return list without built-in items', () => { const { result } = renderHook(() => useMetadataDocument(defaultProps)) - const hasBuiltIn = result.current.list.some(item => item.id === 'built-in') + const hasBuiltIn = result.current.list.some((item) => item.id === 'built-in') expect(hasBuiltIn).toBe(false) }) it('should return builtList with only built-in items', () => { const { result } = renderHook(() => useMetadataDocument(defaultProps)) - const allBuiltIn = result.current.builtList.every(item => item.id === 'built-in') + const allBuiltIn = result.current.builtList.every((item) => item.id === 'built-in') expect(allBuiltIn).toBe(true) }) @@ -347,13 +347,10 @@ describe('useMetadataDocument', () => { const { result } = renderHook(() => useMetadataDocument(defaultProps)) // Find language field in originInfo - const languageField = result.current.originInfo.find( - item => item.name === 'Language', - ) + const languageField = result.current.originInfo.find((item) => item.name === 'Language') // If language field exists and docDetail has language 'en', value should be 'English' - if (languageField) - expect(languageField.value).toBe('English') + if (languageField) expect(languageField.value).toBe('English') }) it('should return dash for empty field values', () => { @@ -372,9 +369,7 @@ describe('useMetadataDocument', () => { ) // Check if there's any field with '-' value (meaning empty) - const hasEmptyField = result.current.originInfo.some( - item => item.value === '-', - ) + const hasEmptyField = result.current.originInfo.some((item) => item.value === '-') // language field should return '-' since it's not set expect(hasEmptyField).toBe(true) }) @@ -384,13 +379,10 @@ describe('useMetadataDocument', () => { const { result } = renderHook(() => useMetadataDocument(defaultProps)) // The data_source_type field is a text field, not select - const sourceTypeField = result.current.originInfo.find( - item => item.name === 'Source Type', - ) + const sourceTypeField = result.current.originInfo.find((item) => item.name === 'Source Type') // It should return the raw value since it's not a select type - if (sourceTypeField) - expect(sourceTypeField.value).toBe('upload_file') + if (sourceTypeField) expect(sourceTypeField.value).toBe('upload_file') }) }) @@ -412,12 +404,11 @@ describe('useMetadataDocument', () => { // Find hit_count field which has a render function const hitCountField = result.current.technicalParameters.find( - item => item.name === 'Hit Count', + (item) => item.name === 'Hit Count', ) // The render function should format as "val/segmentCount" - if (hitCountField) - expect(hitCountField.value).toBe('50/10') + if (hitCountField) expect(hitCountField.value).toBe('50/10') }) it('should return raw value when no render function', () => { @@ -425,11 +416,10 @@ describe('useMetadataDocument', () => { // Find word_count field which has no render function const wordCountField = result.current.technicalParameters.find( - item => item.name === 'Word Count', + (item) => item.name === 'Word Count', ) - if (wordCountField) - expect(wordCountField.value).toBe(100) + if (wordCountField) expect(wordCountField.value).toBe(100) }) it('should handle fields with render function and undefined segment_count', () => { @@ -449,12 +439,11 @@ describe('useMetadataDocument', () => { ) const hitCountField = result.current.technicalParameters.find( - item => item.name === 'Hit Count', + (item) => item.name === 'Hit Count', ) // Should use 0 as default for segment_count - if (hitCountField) - expect(hitCountField.value).toBe('25/0') + if (hitCountField) expect(hitCountField.value).toBe('25/0') }) it('should return dash for null/undefined values', () => { @@ -473,12 +462,9 @@ describe('useMetadataDocument', () => { ) // 0 should still be shown, but empty string should show '-' - const sourceTypeField = result.current.originInfo.find( - item => item.name === 'Source Type', - ) + const sourceTypeField = result.current.originInfo.find((item) => item.name === 'Source Type') - if (sourceTypeField) - expect(sourceTypeField.value).toBe('-') + if (sourceTypeField) expect(sourceTypeField.value).toBe('-') }) it('should handle 0 value correctly (not treated as empty)', () => { @@ -498,11 +484,10 @@ describe('useMetadataDocument', () => { // word_count of 0 should still show 0, not '-' const wordCountField = result.current.technicalParameters.find( - item => item.name === 'Word Count', + (item) => item.name === 'Word Count', ) - if (wordCountField) - expect(wordCountField.value).toBe(0) + if (wordCountField) expect(wordCountField.value).toBe(0) }) }) @@ -519,10 +504,9 @@ describe('useMetadataDocument', () => { }) it('should handle different datasetIds', () => { - const { result, rerender } = renderHook( - props => useMetadataDocument(props), - { initialProps: defaultProps }, - ) + const { result, rerender } = renderHook((props) => useMetadataDocument(props), { + initialProps: defaultProps, + }) expect(result.current).toBeDefined() @@ -550,18 +534,14 @@ describe('useMetadataDocument', () => { ) // Language should be mapped - const languageField = result.current.originInfo.find( - item => item.name === 'Language', - ) - if (languageField) - expect(languageField.value).toBe('Chinese') + const languageField = result.current.originInfo.find((item) => item.name === 'Language') + if (languageField) expect(languageField.value).toBe('Chinese') // Hit count should be rendered const hitCountField = result.current.technicalParameters.find( - item => item.name === 'Hit Count', + (item) => item.name === 'Hit Count', ) - if (hitCountField) - expect(hitCountField.value).toBe('100/20') + if (hitCountField) expect(hitCountField.value).toBe('100/20') }) it('should handle unknown language', () => { @@ -581,9 +561,7 @@ describe('useMetadataDocument', () => { ) // Unknown language should return undefined from the map - const languageField = result.current.originInfo.find( - item => item.name === 'Language', - ) + const languageField = result.current.originInfo.find((item) => item.name === 'Language') // When language is not in map, it returns undefined expect(languageField?.value).toBeUndefined() }) diff --git a/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.ts b/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.ts index 9083ea85e74fdc..ae76e5933ebcae 100644 --- a/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.ts +++ b/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.ts @@ -1,4 +1,9 @@ -import type { MetadataBatchEditToServer, MetadataItemInBatchEdit, MetadataItemWithEdit, MetadataItemWithValue } from '../types' +import type { + MetadataBatchEditToServer, + MetadataItemInBatchEdit, + MetadataItemWithEdit, + MetadataItemWithValue, +} from '../types' import type { SimpleDocumentDetail } from '@/models/datasets' import { toast } from '@langgenius/dify-ui/toast' import { useBoolean } from 'ahooks' @@ -13,13 +18,18 @@ type Props = Readonly<{ selectedDocumentIds?: string[] onUpdate: () => void }> -const useBatchEditDocumentMetadata = ({ datasetId, docList, selectedDocumentIds, onUpdate }: Props) => { +const useBatchEditDocumentMetadata = ({ + datasetId, + docList, + selectedDocumentIds, + onUpdate, +}: Props) => { const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false) const metaDataList: MetadataItemWithValue[][] = (() => { const res: MetadataItemWithValue[][] = [] docList.forEach((item) => { if (item.doc_metadata) { - res.push(item.doc_metadata.filter(item => item.id !== 'built-in')) + res.push(item.doc_metadata.filter((item) => item.id !== 'built-in')) return } res.push([]) @@ -28,16 +38,18 @@ const useBatchEditDocumentMetadata = ({ datasetId, docList, selectedDocumentIds, })() // To check is key has multiple value const originalList: MetadataItemInBatchEdit[] = useMemo(() => { - const idNameValue: Record<string, { - value: string | number | null - isMultipleValue: boolean - }> = {} + const idNameValue: Record< + string, + { + value: string | number | null + isMultipleValue: boolean + } + > = {} const res: MetadataItemInBatchEdit[] = [] metaDataList.forEach((metaData) => { metaData.forEach((item) => { - if (idNameValue[item.id]?.isMultipleValue) - return - const itemInRes = res.find(i => i.id === item.id) + if (idNameValue[item.id]?.isMultipleValue) return + const itemInRes = res.find((i) => i.id === item.id) if (!idNameValue[item.id]) { idNameValue[item.id] = { value: item.value, @@ -60,44 +72,50 @@ const useBatchEditDocumentMetadata = ({ datasetId, docList, selectedDocumentIds, }) return res }, [metaDataList]) - const toCleanMetadataItem = (item: MetadataItemWithValue | MetadataItemWithEdit | MetadataItemInBatchEdit): MetadataItemWithValue => ({ + const toCleanMetadataItem = ( + item: MetadataItemWithValue | MetadataItemWithEdit | MetadataItemInBatchEdit, + ): MetadataItemWithValue => ({ id: item.id, name: item.name, type: item.type, value: item.value ?? null, }) - const formateToBackendList = (editedList: MetadataItemWithEdit[], addedList: MetadataItemInBatchEdit[], isApplyToAllSelectDocument: boolean) => { + const formateToBackendList = ( + editedList: MetadataItemWithEdit[], + addedList: MetadataItemInBatchEdit[], + isApplyToAllSelectDocument: boolean, + ) => { const updatedList = editedList.filter((editedItem) => { return editedItem.updateType === UpdateType.changeValue }) const removedList = originalList.filter((originalItem) => { - const editedItem = editedList.find(i => i.id === originalItem.id) - if (!editedItem) // removed item + const editedItem = editedList.find((i) => i.id === originalItem.id) + if (!editedItem) + // removed item return true return false }) // Use selectedDocumentIds if available, otherwise fall back to docList - const documentIds = selectedDocumentIds || docList.map(doc => doc.id) + const documentIds = selectedDocumentIds || docList.map((doc) => doc.id) const res: MetadataBatchEditToServer = documentIds.map((documentId) => { // Find the document in docList to get its metadata - const docIndex = docList.findIndex(doc => doc.id === documentId) + const docIndex = docList.findIndex((doc) => doc.id === documentId) const oldMetadataList = docIndex >= 0 ? metaDataList[docIndex] : [] let newMetadataList: MetadataItemWithValue[] = [...(oldMetadataList ?? []), ...addedList] .filter((item) => { - return !removedList.find(removedItem => removedItem.id === item.id) + return !removedList.find((removedItem) => removedItem.id === item.id) }) .map(toCleanMetadataItem) if (isApplyToAllSelectDocument) { // add missing metadata item updatedList.forEach((editedItem) => { - if (!newMetadataList.find(i => i.id === editedItem.id) && !editedItem.isMultipleValue) + if (!newMetadataList.find((i) => i.id === editedItem.id) && !editedItem.isMultipleValue) newMetadataList.push(toCleanMetadataItem(editedItem)) }) } newMetadataList = newMetadataList.map((item) => { - const editedItem = updatedList.find(i => i.id === item.id) - if (editedItem) - return toCleanMetadataItem(editedItem) + const editedItem = updatedList.find((i) => i.id === item.id) + if (editedItem) return toCleanMetadataItem(editedItem) return item }) return { @@ -109,7 +127,11 @@ const useBatchEditDocumentMetadata = ({ datasetId, docList, selectedDocumentIds, return res } const { mutateAsync } = useBatchUpdateDocMetadata() - const handleSave = async (editedList: MetadataItemInBatchEdit[], addedList: MetadataItemInBatchEdit[], isApplyToAllSelectDocument: boolean) => { + const handleSave = async ( + editedList: MetadataItemInBatchEdit[], + addedList: MetadataItemInBatchEdit[], + isApplyToAllSelectDocument: boolean, + ) => { const backendList = formateToBackendList(editedList, addedList, isApplyToAllSelectDocument) await mutateAsync({ dataset_id: datasetId, @@ -117,7 +139,7 @@ const useBatchEditDocumentMetadata = ({ datasetId, docList, selectedDocumentIds, }) onUpdate() hideEditModal() - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) } return { isShowEditModal, diff --git a/web/app/components/datasets/metadata/hooks/use-check-metadata-name.ts b/web/app/components/datasets/metadata/hooks/use-check-metadata-name.ts index eca0493e938338..e593411affad53 100644 --- a/web/app/components/datasets/metadata/hooks/use-check-metadata-name.ts +++ b/web/app/components/datasets/metadata/hooks/use-check-metadata-name.ts @@ -8,19 +8,19 @@ const useCheckMetadataName = () => { checkName: (name: string) => { if (!name) { return { - errorMsg: t($ => $[`${i18nPrefix}.empty`], { ns: 'dataset' }), + errorMsg: t(($) => $[`${i18nPrefix}.empty`], { ns: 'dataset' }), } } if (!/^[a-z][a-z0-9_]*$/.test(name)) { return { - errorMsg: t($ => $[`${i18nPrefix}.invalid`], { ns: 'dataset' }), + errorMsg: t(($) => $[`${i18nPrefix}.invalid`], { ns: 'dataset' }), } } if (name.length > 255) { return { - errorMsg: t($ => $[`${i18nPrefix}.tooLong`], { ns: 'dataset', max: 255 }), + errorMsg: t(($) => $[`${i18nPrefix}.tooLong`], { ns: 'dataset', max: 255 }), } } diff --git a/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts b/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts index 77464aeaae9eee..619eacfa98b045 100644 --- a/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts +++ b/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts @@ -4,13 +4,22 @@ import { toast } from '@langgenius/dify-ui/toast' import { useBoolean } from 'ahooks' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useBuiltInMetaDataFields, useCreateMetaData, useDatasetMetaData, useDeleteMetaData, useRenameMeta, useUpdateBuiltInStatus } from '@/service/knowledge/use-metadata' +import { + useBuiltInMetaDataFields, + useCreateMetaData, + useDatasetMetaData, + useDeleteMetaData, + useRenameMeta, + useUpdateBuiltInStatus, +} from '@/service/knowledge/use-metadata' import { isShowManageMetadataLocalStorageKey } from '../types' import useCheckMetadataName from './use-check-metadata-name' -const useEditDatasetMetadata = ({ datasetId, -// dataset, - onUpdateDocList }: { +const useEditDatasetMetadata = ({ + datasetId, + // dataset, + onUpdateDocList, +}: { datasetId: string dataset?: DataSet onUpdateDocList: () => void @@ -27,29 +36,38 @@ const useEditDatasetMetadata = ({ datasetId, const { data: datasetMetaData } = useDatasetMetaData(datasetId) const { mutate: doAddMetaData } = useCreateMetaData(datasetId) const { checkName } = useCheckMetadataName() - const handleAddMetaData = useCallback(async (payload: BuiltInMetadataItem) => { - const errorMsg = checkName(payload.name).errorMsg - if (errorMsg) { - toast.error(errorMsg) - return Promise.reject(new Error(errorMsg)) - } - await doAddMetaData(payload) - }, [checkName, doAddMetaData]) + const handleAddMetaData = useCallback( + async (payload: BuiltInMetadataItem) => { + const errorMsg = checkName(payload.name).errorMsg + if (errorMsg) { + toast.error(errorMsg) + return Promise.reject(new Error(errorMsg)) + } + await doAddMetaData(payload) + }, + [checkName, doAddMetaData], + ) const { mutate: doRenameMetaData } = useRenameMeta(datasetId) - const handleRename = useCallback(async (payload: MetadataItemWithValueLength) => { - const errorMsg = checkName(payload.name).errorMsg - if (errorMsg) { - toast.error(errorMsg) - return Promise.reject(new Error(errorMsg)) - } - await doRenameMetaData(payload) - onUpdateDocList() - }, [checkName, doRenameMetaData, onUpdateDocList]) + const handleRename = useCallback( + async (payload: MetadataItemWithValueLength) => { + const errorMsg = checkName(payload.name).errorMsg + if (errorMsg) { + toast.error(errorMsg) + return Promise.reject(new Error(errorMsg)) + } + await doRenameMetaData(payload) + onUpdateDocList() + }, + [checkName, doRenameMetaData, onUpdateDocList], + ) const { mutateAsync: doDeleteMetaData } = useDeleteMetaData(datasetId) - const handleDeleteMetaData = useCallback(async (metaDataId: string) => { - await doDeleteMetaData(metaDataId) - onUpdateDocList() - }, [doDeleteMetaData, onUpdateDocList]) + const handleDeleteMetaData = useCallback( + async (metaDataId: string) => { + await doDeleteMetaData(metaDataId) + onUpdateDocList() + }, + [doDeleteMetaData, onUpdateDocList], + ) const [builtInEnabled, setBuiltInEnabled] = useState(datasetMetaData?.built_in_field_enabled) useEffect(() => { setBuiltInEnabled(datasetMetaData?.built_in_field_enabled) @@ -69,7 +87,7 @@ const useEditDatasetMetadata = ({ datasetId, setBuiltInEnabled: async (enable: boolean) => { await toggleBuiltInStatus(enable) setBuiltInEnabled(enable) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) }, } } diff --git a/web/app/components/datasets/metadata/hooks/use-metadata-document.ts b/web/app/components/datasets/metadata/hooks/use-metadata-document.ts index b6484985fe33ae..42b84eb62318c6 100644 --- a/web/app/components/datasets/metadata/hooks/use-metadata-document.ts +++ b/web/app/components/datasets/metadata/hooks/use-metadata-document.ts @@ -6,7 +6,12 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDatasetDetailContext } from '@/context/dataset-detail' import { useLanguages, useMetadataMap } from '@/hooks/use-metadata' -import { useBatchUpdateDocMetadata, useCreateMetaData, useDatasetMetaData, useDocumentMetaData } from '@/service/knowledge/use-metadata' +import { + useBatchUpdateDocMetadata, + useCreateMetaData, + useDatasetMetaData, + useDocumentMetaData, +} from '@/service/knowledge/use-metadata' import { DataType } from '../types' import useCheckMetadataName from './use-check-metadata-name' @@ -27,38 +32,42 @@ const useMetadataDocument = ({ datasetId, documentId, docDetail }: Props) => { documentId, }) const allList = documentDetail?.doc_metadata || [] - const list = allList.filter(item => item.id !== 'built-in') - const builtList = allList.filter(item => item.id === 'built-in') + const list = allList.filter((item) => item.id !== 'built-in') + const builtList = allList.filter((item) => item.id === 'built-in') const [tempList, setTempList] = useState<MetadataItemWithValue[]>(list) const { mutateAsync: doAddMetaData } = useCreateMetaData(datasetId) const handleSelectMetaData = useCallback((metaData: MetadataItemWithValue) => { setTempList((prev) => { - const index = prev.findIndex(item => item.id === metaData.id) - if (index === -1) - return [...prev, metaData] + const index = prev.findIndex((item) => item.id === metaData.id) + if (index === -1) return [...prev, metaData] return prev }) }, []) - const handleAddMetaData = useCallback(async (payload: BuiltInMetadataItem) => { - const errorMsg = checkName(payload.name).errorMsg - if (errorMsg) { - toast.error(errorMsg) - return Promise.reject(new Error(errorMsg)) - } - await doAddMetaData(payload) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - }, [checkName, doAddMetaData, t]) + const handleAddMetaData = useCallback( + async (payload: BuiltInMetadataItem) => { + const errorMsg = checkName(payload.name).errorMsg + if (errorMsg) { + toast.error(errorMsg) + return Promise.reject(new Error(errorMsg)) + } + await doAddMetaData(payload) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + }, + [checkName, doAddMetaData, t], + ) const hasData = list.length > 0 const handleSave = async () => { await mutateAsync({ dataset_id: datasetId, - metadata_list: [{ - document_id: documentId, - metadata_list: tempList, - }], + metadata_list: [ + { + document_id: documentId, + metadata_list: tempList, + }, + ], }) setIsEdit(false) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) } const handleCancel = () => { setTempList(list) @@ -78,18 +87,18 @@ const useMetadataDocument = ({ datasetId, documentId, docDetail }: Props) => { const fieldMap = metadataMap[mainField]?.subFieldsMap const sourceData = docDetail const getTargetMap = (field: string) => { - if (field === 'language') - return languageMap + if (field === 'language') return languageMap return {} as any } const getTargetValue = (field: string) => { const val = get(sourceData, field, '') - if (!val && val !== 0) - return '-' - if (fieldMap[field]?.inputType === 'select') - return getTargetMap(field)[val] + if (!val && val !== 0) return '-' + if (fieldMap[field]?.inputType === 'select') return getTargetMap(field)[val] if (fieldMap[field]?.render) - return fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined) + return fieldMap[field]?.render?.( + val, + field === 'hit_count' ? (get(sourceData, 'segment_count', 0) as number) : undefined, + ) return val } const fieldList = Object.keys(fieldMap).map((key) => { diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx index f0cd4d0fc81a68..ad7244934cdefe 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx @@ -173,14 +173,18 @@ describe('CreateContent', () => { const handleSave = vi.fn() renderCreateContent({ onSave: handleSave, hasBack: true }) - expect(screen.getByRole('button', { name: /dataset\.metadata\.createMetadata\.back/ })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /dataset\.metadata\.createMetadata\.back/ }), + ).toBeInTheDocument() }) it('should not show back button when hasBack is false', () => { const handleSave = vi.fn() renderCreateContent({ onSave: handleSave, hasBack: false }) - expect(screen.queryByRole('button', { name: /dataset\.metadata\.createMetadata\.back/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /dataset\.metadata\.createMetadata\.back/ }), + ).not.toBeInTheDocument() }) it('should call onBack when back button is clicked', () => { @@ -188,7 +192,9 @@ describe('CreateContent', () => { const handleBack = vi.fn() renderCreateContent({ onSave: handleSave, hasBack: true, onBack: handleBack }) - fireEvent.click(screen.getByRole('button', { name: /dataset\.metadata\.createMetadata\.back/ })) + fireEvent.click( + screen.getByRole('button', { name: /dataset\.metadata\.createMetadata\.back/ }), + ) expect(handleBack).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx index a987104d70eed7..53c7dc1e1d1305 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx @@ -30,7 +30,9 @@ describe('CreateMetadataModal', () => { />, ) expect(screen.getByRole('dialog')).toBeInTheDocument() - expect(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' })).toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + ).toBeInTheDocument() }) it('should render trigger element', () => { @@ -57,7 +59,9 @@ describe('CreateMetadataModal', () => { hasBack />, ) - expect(screen.getByRole('button', { name: 'dataset.metadata.createMetadata.back' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'dataset.metadata.createMetadata.back' }), + ).toBeInTheDocument() }) it('should pass hasBack=undefined when not provided', () => { @@ -69,7 +73,9 @@ describe('CreateMetadataModal', () => { onSave={vi.fn()} />, ) - expect(screen.queryByRole('button', { name: 'dataset.metadata.createMetadata.back' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'dataset.metadata.createMetadata.back' }), + ).not.toBeInTheDocument() }) it('should accept custom popupLeft', () => { @@ -114,9 +120,12 @@ describe('CreateMetadataModal', () => { />, ) - fireEvent.change(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), { - target: { value: 'test' }, - }) + fireEvent.change( + screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + { + target: { value: 'test' }, + }, + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(handleSave).toHaveBeenCalledWith({ diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx index f6d168dbe90dca..13aa2a820f0233 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx @@ -8,9 +8,7 @@ import DatasetMetadataDrawer from '../dataset-metadata-drawer' vi.mock('@/service/knowledge/use-metadata', () => ({ useDatasetMetaData: () => ({ data: { - doc_metadata: [ - { id: '1', name: 'existing_field', type: DataType.string }, - ], + doc_metadata: [{ id: '1', name: 'existing_field', type: DataType.string }], }, }), })) @@ -66,16 +64,23 @@ describe('DatasetMetadataDrawer', () => { } const openCreateMetadata = async () => { - fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.datasetMetadata.addMetaData' })) + fireEvent.click( + screen.getByRole('button', { name: 'dataset.metadata.datasetMetadata.addMetaData' }), + ) await waitFor(() => { - expect(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' })).toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + ).toBeInTheDocument() }) } const saveCreatedMetadata = (name = 'new_field') => { - fireEvent.change(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), { - target: { value: name }, - }) + fireEvent.change( + screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + { + target: { value: name }, + }, + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) } @@ -114,7 +119,9 @@ describe('DatasetMetadataDrawer', () => { it('should render add metadata button', async () => { render(<DatasetMetadataDrawer {...defaultProps} />) await waitFor(() => { - expect(screen.getByRole('button', { name: 'dataset.metadata.datasetMetadata.addMetaData' }))!.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'dataset.metadata.datasetMetadata.addMetaData' }), + )!.toBeInTheDocument() }) }) @@ -171,7 +178,9 @@ describe('DatasetMetadataDrawer', () => { await openCreateMetadata() - expect(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }))!.toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + )!.toBeInTheDocument() }) it('should call onAdd and show success toast when metadata is added', async () => { @@ -215,7 +224,9 @@ describe('DatasetMetadataDrawer', () => { saveCreatedMetadata() await waitFor(() => { - expect(screen.queryByRole('textbox', { name: 'dataset.metadata.createMetadata.name' })).not.toBeInTheDocument() + expect( + screen.queryByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + ).not.toBeInTheDocument() }) }) }) @@ -296,7 +307,9 @@ describe('DatasetMetadataDrawer', () => { // Verify rename modal closes while drawer stays open await waitFor(() => { - expect(screen.queryByRole('dialog', { name: 'dataset.metadata.datasetMetadata.rename' })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: 'dataset.metadata.datasetMetadata.rename' }), + ).not.toBeInTheDocument() expect(screen.getAllByRole('dialog')).toHaveLength(1) }) }) @@ -319,7 +332,9 @@ describe('DatasetMetadataDrawer', () => { fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) await waitFor(() => { - expect(screen.queryByRole('dialog', { name: 'dataset.metadata.datasetMetadata.rename' })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: 'dataset.metadata.datasetMetadata.rename' }), + ).not.toBeInTheDocument() expect(screen.getAllByRole('dialog')).toHaveLength(1) }) }) @@ -338,7 +353,7 @@ describe('DatasetMetadataDrawer', () => { // Confirm dialog should appear await waitFor(() => { const confirmBtns = screen.getAllByRole('button') - const hasConfirmBtn = confirmBtns.some(btn => + const hasConfirmBtn = confirmBtns.some((btn) => btn.textContent?.toLowerCase().includes('confirm'), ) expect(hasConfirmBtn).toBe(true) @@ -358,18 +373,17 @@ describe('DatasetMetadataDrawer', () => { // Wait for confirm dialog await waitFor(() => { const confirmBtns = screen.getAllByRole('button') - const hasConfirmBtn = confirmBtns.some(btn => + const hasConfirmBtn = confirmBtns.some((btn) => btn.textContent?.toLowerCase().includes('confirm'), ) expect(hasConfirmBtn).toBe(true) }) const confirmBtns = screen.getAllByRole('button') - const confirmBtn = confirmBtns.find(btn => + const confirmBtn = confirmBtns.find((btn) => btn.textContent?.toLowerCase().includes('confirm'), ) - if (confirmBtn) - fireEvent.click(confirmBtn) + if (confirmBtn) fireEvent.click(confirmBtn) await waitFor(() => { expect(onRemove).toHaveBeenCalledWith('1') @@ -396,18 +410,15 @@ describe('DatasetMetadataDrawer', () => { // Wait for confirm dialog await waitFor(() => { const confirmBtns = screen.getAllByRole('button') - const hasConfirmBtn = confirmBtns.some(btn => + const hasConfirmBtn = confirmBtns.some((btn) => btn.textContent?.toLowerCase().includes('confirm'), ) expect(hasConfirmBtn).toBe(true) }) const cancelBtns = screen.getAllByRole('button') - const cancelBtn = cancelBtns.find(btn => - btn.textContent?.toLowerCase().includes('cancel'), - ) - if (cancelBtn) - fireEvent.click(cancelBtn) + const cancelBtn = cancelBtns.find((btn) => btn.textContent?.toLowerCase().includes('cancel')) + if (cancelBtn) fireEvent.click(cancelBtn) }) }) @@ -429,9 +440,7 @@ describe('DatasetMetadataDrawer', () => { describe('Built-in Items State', () => { it('should show disabled styling when built-in is disabled', async () => { - render( - <DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled={false} />, - ) + render(<DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled={false} />) await waitFor(() => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() @@ -443,9 +452,7 @@ describe('DatasetMetadataDrawer', () => { }) it('should not show disabled styling when built-in is enabled', async () => { - render( - <DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled />, - ) + render(<DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled />) await waitFor(() => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() @@ -475,9 +482,7 @@ describe('DatasetMetadataDrawer', () => { }) it('should handle single built-in metadata item', async () => { - const singleBuiltIn: BuiltInMetadataItem[] = [ - { name: 'created_at', type: DataType.time }, - ] + const singleBuiltIn: BuiltInMetadataItem[] = [{ name: 'created_at', type: DataType.time }] render(<DatasetMetadataDrawer {...defaultProps} builtInMetadata={singleBuiltIn} />) await waitFor(() => { expect(screen.getByText('created_at'))!.toBeInTheDocument() diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-picker.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-picker.spec.tsx index 48b8d3ad669958..69c1f271c29dbf 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-picker.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-picker.spec.tsx @@ -19,7 +19,9 @@ const metadataItems: MetadataItem[] = [ { id: '3', name: 'field_three', type: DataType.time }, ] -function renderDatasetMetadataPicker(overrides: Partial<React.ComponentProps<typeof DatasetMetadataPicker>> = {}) { +function renderDatasetMetadataPicker( + overrides: Partial<React.ComponentProps<typeof DatasetMetadataPicker>> = {}, +) { const props = { datasetId: 'dataset-1', onSelectMetadata: vi.fn(), @@ -48,7 +50,9 @@ describe('DatasetMetadataPicker', () => { it('should render an add metadata picker trigger', () => { renderDatasetMetadataPicker() - expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }), + ).toBeInTheDocument() }) it('should show metadata options when opened', async () => { @@ -69,7 +73,10 @@ describe('DatasetMetadataPicker', () => { renderDatasetMetadataPicker() await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) - await user.type(screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' }), 'two') + await user.type( + screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' }), + 'two', + ) expect(screen.getByRole('option', { name: /field_two/ })).toBeInTheDocument() expect(screen.queryByRole('option', { name: /field_one/ })).not.toBeInTheDocument() @@ -81,7 +88,10 @@ describe('DatasetMetadataPicker', () => { renderDatasetMetadataPicker() await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) - await user.type(screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' }), 'missing') + await user.type( + screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' }), + 'missing', + ) expect(await screen.findByRole('status')).toHaveTextContent('common.noData') }) @@ -102,7 +112,9 @@ describe('DatasetMetadataPicker', () => { type: DataType.number, }) await waitFor(() => { - expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })).toHaveAttribute('aria-expanded', 'false') + expect( + screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }), + ).toHaveAttribute('aria-expanded', 'false') }) }) }) @@ -114,8 +126,13 @@ describe('DatasetMetadataPicker', () => { renderDatasetMetadataPicker({ onCreateMetadata }) await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) - await user.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })) - await user.type(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), 'new_field') + await user.click( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }), + ) + await user.type( + screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + 'new_field', + ) await user.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(onCreateMetadata).toHaveBeenCalledWith({ @@ -123,7 +140,9 @@ describe('DatasetMetadataPicker', () => { type: DataType.string, }) await waitFor(() => { - expect(screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' })).toBeInTheDocument() + expect( + screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' }), + ).toBeInTheDocument() }) }) @@ -132,11 +151,18 @@ describe('DatasetMetadataPicker', () => { renderDatasetMetadataPicker() await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) - await user.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })) + await user.click( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }), + ) await user.click(screen.getByRole('button', { name: 'dataset.metadata.createMetadata.back' })) - expect(screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })).toHaveAttribute('aria-expanded', 'true') + expect( + screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' }), + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })).toHaveAttribute( + 'aria-expanded', + 'true', + ) }) it('should open metadata management and close the picker', async () => { @@ -145,11 +171,15 @@ describe('DatasetMetadataPicker', () => { renderDatasetMetadataPicker({ onOpenMetadataManagement }) await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) - await user.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })) + await user.click( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' }), + ) expect(onOpenMetadataManagement).toHaveBeenCalled() await waitFor(() => { - expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })).toHaveAttribute('aria-expanded', 'false') + expect( + screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }), + ).toHaveAttribute('aria-expanded', 'false') }) }) }) @@ -168,8 +198,12 @@ describe('DatasetMetadataPicker', () => { await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) expect(await screen.findByRole('status')).toHaveTextContent('common.noData') - expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' }), + ).toBeInTheDocument() }) }) }) diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/field.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/field.spec.tsx index 030ab4bdb073dc..24fcbb3c50a55a 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/field.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/field.spec.tsx @@ -27,7 +27,11 @@ describe('Field', () => { describe('Props', () => { it('should apply custom className', () => { - const { container } = render(<Field label="Label" className="custom-class">Content</Field>) + const { container } = render( + <Field label="Label" className="custom-class"> + Content + </Field>, + ) expect(container.firstChild).toHaveClass('custom-class') }) @@ -85,12 +89,20 @@ describe('Field', () => { describe('Edge Cases', () => { it('should render with undefined className', () => { - render(<Field label="Label" className={undefined}>Content</Field>) + render( + <Field label="Label" className={undefined}> + Content + </Field>, + ) expect(screen.getByText('Content')).toBeInTheDocument() }) it('should render with empty className', () => { - render(<Field label="Label" className="">Content</Field>) + render( + <Field label="Label" className=""> + Content + </Field>, + ) expect(screen.getByText('Content')).toBeInTheDocument() }) @@ -100,7 +112,11 @@ describe('Field', () => { }) it('should render with empty children', () => { - const { container } = render(<Field label="Label"><span></span></Field>) + const { container } = render( + <Field label="Label"> + <span></span> + </Field>, + ) expect(container.firstChild).toBeInTheDocument() }) diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx b/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx index badd0ec02524ac..b4fa4875002a52 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx @@ -18,22 +18,23 @@ export type Props = Readonly<{ onBack?: () => void }> -export function CreateContent({ - onClose = noop, - hasBack, - onBack, - onSave, -}: Props) { +export function CreateContent({ onClose = noop, hasBack, onBack, onSave }: Props) { const { t } = useTranslation() const [type, setType] = useState(DataType.string) - const handleTypeChange = useCallback((newType: DataType) => { - return () => setType(newType) - }, [setType]) + const handleTypeChange = useCallback( + (newType: DataType) => { + return () => setType(newType) + }, + [setType], + ) const [name, setName] = useState('') - const handleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { - setName(e.target.value) - }, [setName]) + const handleNameChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + setName(e.target.value) + }, + [setName], + ) const handleSave = useCallback(() => { onSave({ @@ -51,17 +52,19 @@ export function CreateContent({ onClick={onBack} > <span className="i-ri-arrow-left-line size-4" aria-hidden="true" /> - <span className="system-xs-semibold-uppercase">{t($ => $[`${i18nPrefix}.back`], { ns: 'dataset' })}</span> + <span className="system-xs-semibold-uppercase"> + {t(($) => $[`${i18nPrefix}.back`], { ns: 'dataset' })} + </span> </button> )} <div className="mb-1 flex h-6 items-center justify-between"> <div className="system-xl-semibold text-text-primary"> - {t($ => $[`${i18nPrefix}.title`], { ns: 'dataset' })} + {t(($) => $[`${i18nPrefix}.title`], { ns: 'dataset' })} </div> {!hasBack && ( <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="cursor-pointer border-none bg-transparent p-1.5 text-text-tertiary" onClick={onClose} > @@ -71,7 +74,7 @@ export function CreateContent({ </div> <div className="mt-2"> <div className="space-y-3"> - <Field label={t($ => $[`${i18nPrefix}.type`], { ns: 'dataset' })}> + <Field label={t(($) => $[`${i18nPrefix}.type`], { ns: 'dataset' })}> <div className="grid grid-cols-3 gap-2"> <OptionCard title="String" @@ -90,28 +93,22 @@ export function CreateContent({ /> </div> </Field> - <Field label={t($ => $[`${i18nPrefix}.name`], { ns: 'dataset' })}> + <Field label={t(($) => $[`${i18nPrefix}.name`], { ns: 'dataset' })}> <Input - aria-label={t($ => $[`${i18nPrefix}.name`], { ns: 'dataset' })} + aria-label={t(($) => $[`${i18nPrefix}.name`], { ns: 'dataset' })} value={name} onChange={handleNameChange} - placeholder={t($ => $[`${i18nPrefix}.namePlaceholder`], { ns: 'dataset' })} + placeholder={t(($) => $[`${i18nPrefix}.namePlaceholder`], { ns: 'dataset' })} /> </Field> </div> </div> <div className="mt-4 flex justify-end"> - <Button - className="mr-2" - onClick={onClose} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button className="mr-2" onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button - onClick={handleSave} - variant="primary" - > - {t($ => $['operation.save'], { ns: 'common' })} + <Button onClick={handleSave} variant="primary"> + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </div> diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx b/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx index 50b2ca8fc94a59..6ed7f3ef0ec0b2 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx @@ -9,7 +9,8 @@ type Props = Readonly<{ setOpen: (open: boolean) => void trigger: React.ReactNode popupLeft?: number -}> & CreateContentProps +}> & + CreateContentProps export function CreateMetadataModal({ open, @@ -18,15 +19,14 @@ export function CreateMetadataModal({ popupLeft = 20, ...createContentProps }: Props) { - const triggerElement = React.isValidElement(trigger) - ? trigger - : <button type="button">{trigger}</button> + const triggerElement = React.isValidElement(trigger) ? ( + trigger + ) : ( + <button type="button">{trigger}</button> + ) return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger render={triggerElement as React.ReactElement} /> <PopoverContent placement="left-start" @@ -34,9 +34,12 @@ export function CreateMetadataModal({ alignOffset={-38} popupClassName="w-[320px]" > - <CreateContent {...createContentProps} onClose={() => setOpen(false)} onBack={() => setOpen(false)} /> + <CreateContent + {...createContentProps} + onClose={() => setOpen(false)} + onBack={() => setOpen(false)} + /> </PopoverContent> </Popover> - ) } diff --git a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx index d7e157af137338..fbe924d3e54a9a 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx @@ -56,13 +56,7 @@ type ItemProps = { onRename?: () => void onDelete?: () => void } -const Item: FC<ItemProps> = ({ - readonly, - disabled, - payload, - onRename, - onDelete, -}) => { +const Item: FC<ItemProps> = ({ readonly, disabled, payload, onRename, onDelete }) => { const { t } = useTranslation() const iconClassName = getIconClassName(payload.type) @@ -72,10 +66,8 @@ const Item: FC<ItemProps> = ({ const deleteBtnRef = useRef<HTMLButtonElement>(null) const isDeleteHovering = useHover(deleteBtnRef) - const [isShowDeleteConfirm, { - setTrue: showDeleteConfirm, - setFalse: hideDeleteConfirm, - }] = useBoolean(false) + const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = + useBoolean(false) const handleDelete = useCallback(() => { hideDeleteConfirm() onDelete?.() @@ -98,18 +90,22 @@ const Item: FC<ItemProps> = ({ > <div className="flex h-full items-center space-x-1 text-text-tertiary"> <span className={cn(iconClassName, 'size-4 shrink-0')} aria-hidden="true" /> - <div className="max-w-[250px] truncate system-sm-medium text-text-primary">{payload.name}</div> + <div className="max-w-[250px] truncate system-sm-medium text-text-primary"> + {payload.name} + </div> <div className="shrink-0 system-xs-regular">{payload.type}</div> </div> {(!readonly || disabled) && ( <div className="ml-2 shrink-0 system-xs-regular text-text-tertiary group-hover/item:hidden"> - {disabled ? t($ => $[`${i18nPrefix}.disabled`], { ns: 'dataset' }) : t($ => $[`${i18nPrefix}.values`], { ns: 'dataset', num: payload.count || 0 })} + {disabled + ? t(($) => $[`${i18nPrefix}.disabled`], { ns: 'dataset' }) + : t(($) => $[`${i18nPrefix}.values`], { ns: 'dataset', num: payload.count || 0 })} </div> )} <div className="ml-2 hidden items-center space-x-1 text-text-tertiary group-hover/item:flex"> <button type="button" - aria-label={t($ => $['operation.edit'], { ns: 'common' })} + aria-label={t(($) => $['operation.edit'], { ns: 'common' })} className="cursor-pointer rounded-md border-none bg-transparent p-0.5 hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={handleRename} > @@ -118,27 +114,35 @@ const Item: FC<ItemProps> = ({ <button type="button" ref={deleteBtnRef} - aria-label={t($ => $['operation.remove'], { ns: 'common' })} + aria-label={t(($) => $['operation.remove'], { ns: 'common' })} className="cursor-pointer rounded-md border-none bg-transparent p-0.5 hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden" onClick={showDeleteConfirm} > <RiDeleteBinLine className="size-4" aria-hidden="true" /> </button> </div> - <AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}> + <AlertDialog + open={isShowDeleteConfirm} + onOpenChange={(open) => !open && hideDeleteConfirm()} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['metadata.datasetMetadata.deleteTitle'], { ns: 'dataset' })} + {t(($) => $['metadata.datasetMetadata.deleteTitle'], { ns: 'dataset' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['metadata.datasetMetadata.deleteContent'], { ns: 'dataset', name: payload.name })} + {t(($) => $['metadata.datasetMetadata.deleteContent'], { + ns: 'dataset', + name: payload.name, + })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={handleDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> @@ -162,39 +166,48 @@ const DatasetMetadataDrawer: FC<Props> = ({ const [isShowRenameModal, setIsShowRenameModal] = useState(false) const [currPayload, setCurrPayload] = useState<MetadataItemWithValueLength | null>(null) const [templeName, setTempleName] = useState('') - const handleRename = useCallback((payload: MetadataItemWithValueLength) => { - return () => { - setCurrPayload(payload) - setTempleName(payload.name) - setIsShowRenameModal(true) - } - }, [setCurrPayload, setIsShowRenameModal]) + const handleRename = useCallback( + (payload: MetadataItemWithValueLength) => { + return () => { + setCurrPayload(payload) + setTempleName(payload.name) + setIsShowRenameModal(true) + } + }, + [setCurrPayload, setIsShowRenameModal], + ) const [open, setOpen] = useState(false) - const handleAdd = useCallback(async (data: BuiltInMetadataItem) => { - await onAdd(data) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - setOpen(false) - }, [onAdd, t]) + const handleAdd = useCallback( + async (data: BuiltInMetadataItem) => { + await onAdd(data) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + setOpen(false) + }, + [onAdd, t], + ) const handleRenamed = useCallback(async () => { - const item = userMetadata.find(p => p.id === currPayload?.id) + const item = userMetadata.find((p) => p.id === currPayload?.id) if (item) { await onRename({ ...item, name: templeName, }) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) } setIsShowRenameModal(false) }, [userMetadata, currPayload?.id, onRename, templeName, t]) - const handleDelete = useCallback((payload: MetadataItemWithValueLength) => { - return async () => { - await onRemove(payload.id) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - } - }, [onRemove, t]) + const handleDelete = useCallback( + (payload: MetadataItemWithValueLength) => { + return async () => { + await onRemove(payload.id) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + } + }, + [onRemove, t], + ) return ( <Drawer @@ -202,8 +215,7 @@ const DatasetMetadataDrawer: FC<Props> = ({ modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DrawerPortal> @@ -213,30 +225,32 @@ const DatasetMetadataDrawer: FC<Props> = ({ <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> <div className="flex shrink-0 justify-between px-4 pt-6 pb-4"> <DrawerTitle className="text-lg/6 font-medium text-text-primary"> - {t($ => $['metadata.metadata'], { ns: 'dataset' })} + {t(($) => $['metadata.metadata'], { ns: 'dataset' })} </DrawerTitle> <DrawerCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-6 rounded-md" /> </div> <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6"> - <div className="system-sm-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.description`], { ns: 'dataset' })}</div> + <div className="system-sm-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.description`], { ns: 'dataset' })} + </div> <CreateMetadataModal open={open} setOpen={setOpen} - trigger={( + trigger={ <Button variant="primary" className="mt-3"> <RiAddLine className="mr-1" /> - {t($ => $[`${i18nPrefix}.addMetaData`], { ns: 'dataset' })} + {t(($) => $[`${i18nPrefix}.addMetaData`], { ns: 'dataset' })} </Button> - )} + } hasBack onSave={handleAdd} /> <div className="mt-3 space-y-1"> - {userMetadata.map(payload => ( + {userMetadata.map((payload) => ( <Item key={payload.id} payload={payload} @@ -247,18 +261,20 @@ const DatasetMetadataDrawer: FC<Props> = ({ </div> <div className="mt-3 flex h-6 items-center"> - <Switch - checked={isBuiltInEnabled} - onCheckedChange={onIsBuiltInEnabledChange} - /> - <div className="mr-0.5 ml-2 system-sm-semibold text-text-secondary">{t($ => $[`${i18nPrefix}.builtIn`], { ns: 'dataset' })}</div> - <Infotip aria-label={t($ => $[`${i18nPrefix}.builtInDescription`], { ns: 'dataset' })} popupClassName="max-w-[100px]"> - {t($ => $[`${i18nPrefix}.builtInDescription`], { ns: 'dataset' })} + <Switch checked={isBuiltInEnabled} onCheckedChange={onIsBuiltInEnabledChange} /> + <div className="mr-0.5 ml-2 system-sm-semibold text-text-secondary"> + {t(($) => $[`${i18nPrefix}.builtIn`], { ns: 'dataset' })} + </div> + <Infotip + aria-label={t(($) => $[`${i18nPrefix}.builtInDescription`], { ns: 'dataset' })} + popupClassName="max-w-[100px]" + > + {t(($) => $[`${i18nPrefix}.builtInDescription`], { ns: 'dataset' })} </Infotip> </div> <div className="mt-1 space-y-1"> - {builtInMetadata.map(payload => ( + {builtInMetadata.map((payload) => ( <Item key={payload.name} readonly @@ -272,21 +288,25 @@ const DatasetMetadataDrawer: FC<Props> = ({ <Dialog open onOpenChange={(open) => { - if (!open) - setIsShowRenameModal(false) + if (!open) setIsShowRenameModal(false) }} > <DialogContent className="overflow-hidden! border-none text-left align-middle"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $[`${i18nPrefix}.rename`], { ns: 'dataset' })} + {t(($) => $[`${i18nPrefix}.rename`], { ns: 'dataset' })} </DialogTitle> - <Field label={t($ => $[`${i18nPrefix}.name`], { ns: 'dataset' })} className="mt-4"> + <Field + label={t(($) => $[`${i18nPrefix}.name`], { ns: 'dataset' })} + className="mt-4" + > <Input - aria-label={t($ => $[`${i18nPrefix}.name`], { ns: 'dataset' })} + aria-label={t(($) => $[`${i18nPrefix}.name`], { ns: 'dataset' })} value={templeName} - onChange={e => setTempleName(e.target.value)} - placeholder={t($ => $[`${i18nPrefix}.namePlaceholder`], { ns: 'dataset' })} + onChange={(e) => setTempleName(e.target.value)} + placeholder={t(($) => $[`${i18nPrefix}.namePlaceholder`], { + ns: 'dataset', + })} /> </Field> <div className="mt-4 flex justify-end"> @@ -297,14 +317,10 @@ const DatasetMetadataDrawer: FC<Props> = ({ setTempleName(currPayload!.name) }} > - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button - onClick={handleRenamed} - variant="primary" - disabled={!templeName} - > - {t($ => $['operation.save'], { ns: 'common' })} + <Button onClick={handleRenamed} variant="primary" disabled={!templeName}> + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-picker.tsx b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-picker.tsx index 7aa80e8b59ccc8..97877bc90b3a0d 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-picker.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-picker.tsx @@ -28,7 +28,7 @@ const PickerView = { create: 'create', } as const -type PickerView = typeof PickerView[keyof typeof PickerView] +type PickerView = (typeof PickerView)[keyof typeof PickerView] export type DatasetMetadataPickerProps = { datasetId: string @@ -79,18 +79,15 @@ export function DatasetMetadataPicker({ const handleOpenChange = (nextOpen: boolean) => { setOpen(nextOpen) - if (!nextOpen) - resetPicker() + if (!nextOpen) resetPicker() } const handleInputValueChange = (inputValue: string, details: ComboboxChangeEventDetails) => { - if (details.reason !== 'item-press') - setQuery(inputValue) + if (details.reason !== 'item-press') setQuery(inputValue) } const handleMetadataChange = (metadata: MetadataItem | null) => { - if (!metadata) - return + if (!metadata) return onSelectMetadata({ id: metadata.id, @@ -105,8 +102,7 @@ export function DatasetMetadataPicker({ try { await onCreateMetadata(metadata) resetPicker() - } - catch { + } catch { // Keep the create view open so callers can surface validation feedback and the user can correct the input. } } @@ -118,24 +114,26 @@ export function DatasetMetadataPicker({ } return ( - <Popover - open={open} - onOpenChange={handleOpenChange} - > + <Popover open={open} onOpenChange={handleOpenChange}> <PopoverTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['metadata.addMetadata'], { ns: 'dataset' })} + aria-label={t(($) => $['metadata.addMetadata'], { ns: 'dataset' })} aria-expanded={open} className="flex h-6 w-full cursor-pointer items-center justify-center rounded-md border-0 bg-components-button-tertiary-bg px-2 py-0 text-xs font-medium text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover focus-visible:bg-components-button-tertiary-bg-hover" > <span className="flex min-w-0 items-center justify-center gap-1"> - <span className="i-ri-add-line size-3.5 shrink-0 text-components-button-tertiary-text" aria-hidden="true" /> - <span className="truncate text-components-button-tertiary-text">{t($ => $['metadata.addMetadata'], { ns: 'dataset' })}</span> + <span + className="i-ri-add-line size-3.5 shrink-0 text-components-button-tertiary-text" + aria-hidden="true" + /> + <span className="truncate text-components-button-tertiary-text"> + {t(($) => $['metadata.addMetadata'], { ns: 'dataset' })} + </span> </span> </button> - )} + } /> <PopoverContent placement={placement} @@ -143,37 +141,35 @@ export function DatasetMetadataPicker({ alignOffset={alignOffset} popupClassName="w-[320px] bg-components-panel-bg-blur backdrop-blur-[5px]" > - {view === PickerView.select - ? ( - <Combobox<MetadataItem> - value={null} - items={metadataItems} - inputValue={query} - onInputValueChange={handleInputValueChange} - onValueChange={handleMetadataChange} - itemToStringLabel={getMetadataLabel} - itemToStringValue={getMetadataValue} - isItemEqualToValue={isSameMetadata} - filter={metadataFilter} - > - <MetadataPickerSelectPanel - query={query} - onNewMetadata={() => { - setView(PickerView.create) - setQuery('') - }} - onOpenMetadataManagement={handleOpenManagement} - /> - </Combobox> - ) - : ( - <CreateContent - onSave={handleCreateMetadata} - hasBack - onBack={resetPicker} - onClose={resetPicker} - /> - )} + {view === PickerView.select ? ( + <Combobox<MetadataItem> + value={null} + items={metadataItems} + inputValue={query} + onInputValueChange={handleInputValueChange} + onValueChange={handleMetadataChange} + itemToStringLabel={getMetadataLabel} + itemToStringValue={getMetadataValue} + isItemEqualToValue={isSameMetadata} + filter={metadataFilter} + > + <MetadataPickerSelectPanel + query={query} + onNewMetadata={() => { + setView(PickerView.create) + setQuery('') + }} + onOpenMetadataManagement={handleOpenManagement} + /> + </Combobox> + ) : ( + <CreateContent + onSave={handleCreateMetadata} + hasBack + onBack={resetPicker} + onClose={resetPicker} + /> + )} </PopoverContent> </Popover> ) @@ -194,27 +190,22 @@ function MetadataPickerSelectPanel({ <> <div className="p-2 pb-1"> <ComboboxInputGroup> - <span className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> + <span + className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> <ComboboxInput - aria-label={t($ => $[`${i18nPrefix}.search`], { ns: 'dataset' })} - placeholder={t($ => $[`${i18nPrefix}.search`], { ns: 'dataset' })} + aria-label={t(($) => $[`${i18nPrefix}.search`], { ns: 'dataset' })} + placeholder={t(($) => $[`${i18nPrefix}.search`], { ns: 'dataset' })} className="pl-2" /> - {query && ( - <ComboboxClear - aria-label={t($ => $['operation.clear'], { ns: 'common' })} - /> - )} + {query && <ComboboxClear aria-label={t(($) => $['operation.clear'], { ns: 'common' })} />} </ComboboxInputGroup> </div> <ComboboxList> - {(metadata: MetadataItem) => ( - <MetadataOption key={metadata.id} metadata={metadata} /> - )} + {(metadata: MetadataItem) => <MetadataOption key={metadata.id} metadata={metadata} />} </ComboboxList> - <ComboboxEmpty> - {t($ => $.noData, { ns: 'common' })} - </ComboboxEmpty> + <ComboboxEmpty>{t(($) => $.noData, { ns: 'common' })}</ComboboxEmpty> <ComboboxSeparator /> <MetadataPickerActions onNewMetadata={onNewMetadata} @@ -224,11 +215,7 @@ function MetadataPickerSelectPanel({ ) } -function MetadataOption({ - metadata, -}: { - metadata: MetadataItem -}) { +function MetadataOption({ metadata }: { metadata: MetadataItem }) { const iconClassName = getIconClassName(metadata.type) return ( @@ -237,9 +224,7 @@ function MetadataOption({ <span className={cn(iconClassName, 'size-3.5 shrink-0')} aria-hidden="true" /> <span className="min-w-0 grow truncate">{metadata.name}</span> </ComboboxItemText> - <span className="shrink-0 system-xs-regular text-text-tertiary"> - {metadata.type} - </span> + <span className="shrink-0 system-xs-regular text-text-tertiary">{metadata.type}</span> </ComboboxItem> ) } @@ -264,7 +249,9 @@ function MetadataPickerActions({ onClick={onNewMetadata} > <span className="i-ri-add-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> - <span className="truncate system-sm-medium">{t($ => $[`${i18nPrefix}.newAction`], { ns: 'dataset' })}</span> + <span className="truncate system-sm-medium"> + {t(($) => $[`${i18nPrefix}.newAction`], { ns: 'dataset' })} + </span> </button> <div className="flex h-8 shrink-0 items-center text-text-secondary"> <div className="mx-1 h-3 w-px bg-divider-regular" /> @@ -276,8 +263,13 @@ function MetadataPickerActions({ )} onClick={onOpenMetadataManagement} > - <span className="system-sm-medium">{t($ => $[`${i18nPrefix}.manageAction`], { ns: 'dataset' })}</span> - <span className="i-ri-arrow-right-up-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> + <span className="system-sm-medium"> + {t(($) => $[`${i18nPrefix}.manageAction`], { ns: 'dataset' })} + </span> + <span + className="i-ri-arrow-right-up-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> </button> </div> </div> diff --git a/web/app/components/datasets/metadata/metadata-dataset/field.tsx b/web/app/components/datasets/metadata/metadata-dataset/field.tsx index 84d1a9563f9044..69e41a6581b63c 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/field.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/field.tsx @@ -8,11 +8,7 @@ type Props = Readonly<{ children: React.ReactNode }> -const Field: FC<Props> = ({ - className, - label, - children, -}) => { +const Field: FC<Props> = ({ className, label, children }) => { return ( <div className={className}> <div className="py-1 system-sm-semibold text-text-secondary">{label}</div> diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/field.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/field.spec.tsx index 714dd0c6bba3c3..43a309c5782530 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/field.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/field.spec.tsx @@ -13,7 +13,14 @@ describe('Field', () => { it('should render label with correct styling', () => { render(<Field label="My Label">Content</Field>) const labelElement = screen.getByText('My Label') - expect(labelElement).toHaveClass('system-xs-medium', 'w-[128px]', 'shrink-0', 'truncate', 'py-1', 'text-text-tertiary') + expect(labelElement).toHaveClass( + 'system-xs-medium', + 'w-[128px]', + 'shrink-0', + 'truncate', + 'py-1', + 'text-text-tertiary', + ) }) it('should render children in correct container', () => { @@ -91,7 +98,11 @@ describe('Field', () => { }) it('should render with empty children', () => { - const { container } = render(<Field label="Label"><span></span></Field>) + const { container } = render( + <Field label="Label"> + <span></span> + </Field>, + ) expect(container.firstChild).toBeInTheDocument() }) diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx index 815bd27c6cd3ac..f84d1eb9ebcf21 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx @@ -221,7 +221,9 @@ describe('MetadataDocument', () => { it('should render technical parameters section', () => { mockUseMetadataDocument.mockReturnValue({ ...defaultHookReturn, - technicalParameters: [{ id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }], + technicalParameters: [ + { id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }, + ], }) render( @@ -242,7 +244,9 @@ describe('MetadataDocument', () => { builtInEnabled: true, builtList: [{ id: 'built-1', name: 'created_at', type: DataType.time, value: 1609459200 }], originInfo: [{ id: 'origin-1', name: 'source', type: DataType.string, value: 'upload' }], - technicalParameters: [{ id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }], + technicalParameters: [ + { id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }, + ], }) render( @@ -481,9 +485,7 @@ describe('MetadataDocument', () => { it('should pass onChange callback to InfoGroup', async () => { const setTempList = vi.fn() - const tempList = [ - { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' }, - ] + const tempList = [{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' }] mockUseMetadataDocument.mockReturnValue({ ...defaultHookReturn, isEdit: true, @@ -531,8 +533,7 @@ describe('MetadataDocument', () => { if (deleteContainers.length > 0) { const deleteIcon = deleteContainers[0]!.querySelector('svg') - if (deleteIcon) - fireEvent.click(deleteIcon) + if (deleteIcon) fireEvent.click(deleteIcon) await waitFor(() => { expect(setTempList).toHaveBeenCalled() @@ -824,9 +825,7 @@ describe('MetadataDocument', () => { it('should handle null values in metadata', () => { mockUseMetadataDocument.mockReturnValue({ ...defaultHookReturn, - list: [ - { id: '1', name: 'null_field', type: DataType.string, value: null }, - ], + list: [{ id: '1', name: 'null_field', type: DataType.string, value: null }], }) render( @@ -844,7 +843,12 @@ describe('MetadataDocument', () => { mockUseMetadataDocument.mockReturnValue({ ...defaultHookReturn, list: [ - { id: '1', name: 'undefined_field', type: DataType.string, value: undefined as unknown as null }, + { + id: '1', + name: 'undefined_field', + type: DataType.string, + value: undefined as unknown as null, + }, ], }) diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx index 0abcf976c1c3a5..c9b7154d53b298 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx @@ -24,9 +24,7 @@ vi.mock('@/next/navigation', () => ({ vi.mock('@/service/knowledge/use-metadata', () => ({ useDatasetMetaData: () => ({ data: { - doc_metadata: [ - { id: '1', name: 'test', type: DataType.string }, - ], + doc_metadata: [{ id: '1', name: 'test', type: DataType.string }], }, }), })) @@ -35,8 +33,7 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ formatTime: (timestamp: number) => { - if (!timestamp) - return '' + if (!timestamp) return '' return new Date(timestamp * 1000).toLocaleDateString() }, }), @@ -49,7 +46,7 @@ vi.mock('../../edit-metadata-batch/input-combined', () => ({ aria-label={label} data-type={type} value={value || ''} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} /> ), })) @@ -67,30 +64,22 @@ describe('InfoGroup', () => { describe('Rendering', () => { it('should render without crashing', () => { - const { container } = render( - <InfoGroup dataSetId="ds-1" list={mockList} />, - ) + const { container } = render(<InfoGroup dataSetId="ds-1" list={mockList} />) expect(container.firstChild)!.toBeInTheDocument() }) it('should render title when provided', () => { - render( - <InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />, - ) + render(<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />) expect(screen.getByText('Test Title'))!.toBeInTheDocument() }) it('should not render header when noHeader is true', () => { - render( - <InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" noHeader />, - ) + render(<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" noHeader />) expect(screen.queryByText('Test Title')).not.toBeInTheDocument() }) it('should render all list items', () => { - render( - <InfoGroup dataSetId="ds-1" list={mockList} />, - ) + render(<InfoGroup dataSetId="ds-1" list={mockList} />) expect(screen.getByText('field_one'))!.toBeInTheDocument() expect(screen.getByText('field_two'))!.toBeInTheDocument() expect(screen.getByText('built-in'))!.toBeInTheDocument() @@ -123,31 +112,27 @@ describe('InfoGroup', () => { describe('Edit Mode', () => { it('should render dataset metadata picker when isEdit is true', () => { - render( - <InfoGroup dataSetId="ds-1" list={mockList} isEdit />, - ) - expect(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))!.toBeInTheDocument() + render(<InfoGroup dataSetId="ds-1" list={mockList} isEdit />) + expect( + screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }), + )!.toBeInTheDocument() }) it('should not render dataset metadata picker when isEdit is false', () => { - render( - <InfoGroup dataSetId="ds-1" list={mockList} isEdit={false} />, - ) - expect(screen.queryByRole('button', { name: 'dataset.metadata.addMetadata' })).not.toBeInTheDocument() + render(<InfoGroup dataSetId="ds-1" list={mockList} isEdit={false} />) + expect( + screen.queryByRole('button', { name: 'dataset.metadata.addMetadata' }), + ).not.toBeInTheDocument() }) it('should render input combined for each item in edit mode', () => { - render( - <InfoGroup dataSetId="ds-1" list={mockList} isEdit />, - ) + render(<InfoGroup dataSetId="ds-1" list={mockList} isEdit />) const inputs = screen.getAllByRole('textbox') expect(inputs).toHaveLength(3) }) it('should render delete icons in edit mode', () => { - render( - <InfoGroup dataSetId="ds-1" list={mockList} isEdit />, - ) + render(<InfoGroup dataSetId="ds-1" list={mockList} isEdit />) expect(screen.getAllByRole('button', { name: 'common.operation.remove' })).toHaveLength(3) }) }) @@ -155,9 +140,7 @@ describe('InfoGroup', () => { describe('User Interactions', () => { it('should call onChange when input value changes', () => { const handleChange = vi.fn() - render( - <InfoGroup dataSetId="ds-1" list={mockList} isEdit onChange={handleChange} />, - ) + render(<InfoGroup dataSetId="ds-1" list={mockList} isEdit onChange={handleChange} />) const inputs = screen.getAllByRole('textbox') fireEvent.change(inputs[0]!, { target: { value: 'New Value' } }) @@ -167,9 +150,7 @@ describe('InfoGroup', () => { it('should call onDelete when delete icon is clicked', () => { const handleDelete = vi.fn() - render( - <InfoGroup dataSetId="ds-1" list={mockList} isEdit onDelete={handleDelete} />, - ) + render(<InfoGroup dataSetId="ds-1" list={mockList} isEdit onDelete={handleDelete} />) fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.remove' })[0]!) @@ -178,9 +159,7 @@ describe('InfoGroup', () => { it('should call onSelect when metadata is selected', async () => { const handleSelect = vi.fn() - render( - <InfoGroup dataSetId="ds-1" list={mockList} isEdit onSelect={handleSelect} />, - ) + render(<InfoGroup dataSetId="ds-1" list={mockList} isEdit onSelect={handleSelect} />) fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) fireEvent.click(await screen.findByRole('option', { name: /test/ })) @@ -194,15 +173,18 @@ describe('InfoGroup', () => { it('should call onAdd when new metadata is saved', async () => { const handleAdd = vi.fn() - render( - <InfoGroup dataSetId="ds-1" list={mockList} isEdit onAdd={handleAdd} />, - ) + render(<InfoGroup dataSetId="ds-1" list={mockList} isEdit onAdd={handleAdd} />) fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) - fireEvent.click(await screen.findByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })) - fireEvent.change(screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), { - target: { value: 'new_field' }, - }) + fireEvent.click( + await screen.findByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }), + ) + fireEvent.change( + screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + { + target: { value: 'new_field' }, + }, + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(handleAdd).toHaveBeenCalledWith({ @@ -212,12 +194,12 @@ describe('InfoGroup', () => { }) it('should navigate to documents page when manage is clicked', async () => { - render( - <InfoGroup dataSetId="ds-1" list={mockList} isEdit />, - ) + render(<InfoGroup dataSetId="ds-1" list={mockList} isEdit />) fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) - fireEvent.click(await screen.findByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })) + fireEvent.click( + await screen.findByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' }), + ) expect(mockRouterPush).toHaveBeenCalledWith('/datasets/ds-1/documents') }) @@ -240,9 +222,7 @@ describe('InfoGroup', () => { }) it('should use uppercase title by default', () => { - render( - <InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />, - ) + render(<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />) const titleElement = screen.getByText('Test Title') expect(titleElement)!.toHaveClass('system-xs-semibold-uppercase') }) @@ -261,9 +241,7 @@ describe('InfoGroup', () => { const stringList: MetadataItemWithValue[] = [ { id: '1', name: 'field', type: DataType.string, value: 'Test Value' }, ] - render( - <InfoGroup dataSetId="ds-1" list={stringList} />, - ) + render(<InfoGroup dataSetId="ds-1" list={stringList} />) expect(screen.getByText('Test Value'))!.toBeInTheDocument() }) @@ -271,9 +249,7 @@ describe('InfoGroup', () => { const numberList: MetadataItemWithValue[] = [ { id: '1', name: 'field', type: DataType.number, value: 123 }, ] - render( - <InfoGroup dataSetId="ds-1" list={numberList} />, - ) + render(<InfoGroup dataSetId="ds-1" list={numberList} />) expect(screen.getByText('123'))!.toBeInTheDocument() }) @@ -281,9 +257,7 @@ describe('InfoGroup', () => { const timeList: MetadataItemWithValue[] = [ { id: '1', name: 'field', type: DataType.time, value: 1609459200 }, ] - render( - <InfoGroup dataSetId="ds-1" list={timeList} />, - ) + render(<InfoGroup dataSetId="ds-1" list={timeList} />) // The mock formatTime returns formatted date // The mock formatTime returns formatted date expect(screen.getByText('field'))!.toBeInTheDocument() @@ -292,9 +266,7 @@ describe('InfoGroup', () => { describe('Edge Cases', () => { it('should handle empty list', () => { - const { container } = render( - <InfoGroup dataSetId="ds-1" list={[]} />, - ) + const { container } = render(<InfoGroup dataSetId="ds-1" list={[]} />) expect(container.firstChild)!.toBeInTheDocument() }) @@ -302,9 +274,7 @@ describe('InfoGroup', () => { const nullList: MetadataItemWithValue[] = [ { id: '1', name: 'field', type: DataType.string, value: null }, ] - render( - <InfoGroup dataSetId="ds-1" list={nullList} />, - ) + render(<InfoGroup dataSetId="ds-1" list={nullList} />) expect(screen.getByText('field'))!.toBeInTheDocument() }) @@ -312,9 +282,7 @@ describe('InfoGroup', () => { const builtInList: MetadataItemWithValue[] = [ { id: 'built-in', name: 'field', type: DataType.string, value: 'test' }, ] - render( - <InfoGroup dataSetId="ds-1" list={builtInList} />, - ) + render(<InfoGroup dataSetId="ds-1" list={builtInList} />) expect(screen.getByText('field'))!.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/metadata/metadata-document/field.tsx b/web/app/components/datasets/metadata/metadata-document/field.tsx index 7118738e38e9e4..16e2ad80aa739e 100644 --- a/web/app/components/datasets/metadata/metadata-document/field.tsx +++ b/web/app/components/datasets/metadata/metadata-document/field.tsx @@ -7,18 +7,13 @@ type Props = Readonly<{ children: React.ReactNode }> -const Field: FC<Props> = ({ - label, - children, -}) => { +const Field: FC<Props> = ({ label, children }) => { return ( <div className="flex items-start space-x-2"> <div className="w-[128px] shrink-0 items-center truncate py-1 system-xs-medium text-text-tertiary"> {label} </div> - <div className="w-[244px] shrink-0"> - {children} - </div> + <div className="w-[244px] shrink-0">{children}</div> </div> ) } diff --git a/web/app/components/datasets/metadata/metadata-document/index.tsx b/web/app/components/datasets/metadata/metadata-document/index.tsx index 1db423a32e3a53..26a606b7326ae2 100644 --- a/web/app/components/datasets/metadata/metadata-document/index.tsx +++ b/web/app/components/datasets/metadata/metadata-document/index.tsx @@ -49,50 +49,50 @@ const MetadataDocument: FC<Props> = ({ return ( <div className={cn('w-[388px] space-y-4', className)}> - {(hasData || isEdit) - ? ( - <div className="pl-2"> - <InfoGroup - title={t($ => $['metadata.metadata'], { ns: 'dataset' })} - uppercaseTitle={false} - titleTooltip={t($ => $[`${i18nPrefix}.metadataToolTip`], { ns: 'dataset' })} - list={isEdit ? tempList : list} - dataSetId={datasetId} - headerRight={embeddingAvailable && canEdit && (isEdit - ? ( - <div className="flex space-x-1"> - <Button variant="ghost" size="small" onClick={handleCancel}> - <div>{t($ => $['operation.cancel'], { ns: 'common' })}</div> - </Button> - <Button variant="primary" size="small" onClick={handleSave}> - <div>{t($ => $['operation.save'], { ns: 'common' })}</div> - </Button> - </div> - ) - : ( - <Button variant="ghost" size="small" onClick={startToEdit}> - <span className="mr-1 i-ri-edit-line size-3.5 cursor-pointer text-text-tertiary" /> - <div>{t($ => $['operation.edit'], { ns: 'common' })}</div> - </Button> - ))} - isEdit={isEdit} - contentClassName="mt-5" - onChange={(item) => { - const newList = tempList.map(i => (i.name === item.name ? item : i)) - setTempList(newList) - }} - onDelete={(item) => { - const newList = tempList.filter(i => i.name !== item.name) - setTempList(newList) - }} - onAdd={handleAddMetaData} - onSelect={handleSelectMetaData} - /> - </div> - ) - : ( - embeddingAvailable && canEdit && <NoData onStart={() => setIsEdit(true)} /> - )} + {hasData || isEdit ? ( + <div className="pl-2"> + <InfoGroup + title={t(($) => $['metadata.metadata'], { ns: 'dataset' })} + uppercaseTitle={false} + titleTooltip={t(($) => $[`${i18nPrefix}.metadataToolTip`], { ns: 'dataset' })} + list={isEdit ? tempList : list} + dataSetId={datasetId} + headerRight={ + embeddingAvailable && + canEdit && + (isEdit ? ( + <div className="flex space-x-1"> + <Button variant="ghost" size="small" onClick={handleCancel}> + <div>{t(($) => $['operation.cancel'], { ns: 'common' })}</div> + </Button> + <Button variant="primary" size="small" onClick={handleSave}> + <div>{t(($) => $['operation.save'], { ns: 'common' })}</div> + </Button> + </div> + ) : ( + <Button variant="ghost" size="small" onClick={startToEdit}> + <span className="mr-1 i-ri-edit-line size-3.5 cursor-pointer text-text-tertiary" /> + <div>{t(($) => $['operation.edit'], { ns: 'common' })}</div> + </Button> + )) + } + isEdit={isEdit} + contentClassName="mt-5" + onChange={(item) => { + const newList = tempList.map((i) => (i.name === item.name ? item : i)) + setTempList(newList) + }} + onDelete={(item) => { + const newList = tempList.filter((i) => i.name !== item.name) + setTempList(newList) + }} + onAdd={handleAddMetaData} + onSelect={handleSelectMetaData} + /> + </div> + ) : ( + embeddingAvailable && canEdit && <NoData onStart={() => setIsEdit(true)} /> + )} {builtInEnabled && ( <div className="pl-2"> <Divider className="my-3" bgStyle="gradient" /> @@ -108,13 +108,13 @@ const MetadataDocument: FC<Props> = ({ {/* Old Metadata */} <InfoGroup className="pl-2" - title={t($ => $[`${i18nPrefix}.documentInformation`], { ns: 'dataset' })} + title={t(($) => $[`${i18nPrefix}.documentInformation`], { ns: 'dataset' })} list={originInfo} dataSetId={datasetId} /> <InfoGroup className="pl-2" - title={t($ => $[`${i18nPrefix}.technicalParameters`], { ns: 'dataset' })} + title={t(($) => $[`${i18nPrefix}.technicalParameters`], { ns: 'dataset' })} list={technicalParameters} dataSetId={datasetId} /> diff --git a/web/app/components/datasets/metadata/metadata-document/info-group.tsx b/web/app/components/datasets/metadata/metadata-document/info-group.tsx index aa3f738570e4b9..0999246f170f26 100644 --- a/web/app/components/datasets/metadata/metadata-document/info-group.tsx +++ b/web/app/components/datasets/metadata/metadata-document/info-group.tsx @@ -61,7 +61,14 @@ const InfoGroup: FC<Props> = ({ {!noHeader && ( <div className="flex items-center justify-between"> <div className="flex items-center space-x-1"> - <div className={cn('text-text-secondary', uppercaseTitle ? 'system-xs-semibold-uppercase' : 'system-md-semibold')}>{title}</div> + <div + className={cn( + 'text-text-secondary', + uppercaseTitle ? 'system-xs-semibold-uppercase' : 'system-md-semibold', + )} + > + {title} + </div> {titleTooltip && ( <Infotip aria-label={titleTooltip} popupClassName="max-w-[240px]"> {titleTooltip} @@ -77,36 +84,43 @@ const InfoGroup: FC<Props> = ({ <div> <DatasetMetadataPicker datasetId={dataSetId} - onSelectMetadata={data => onSelect?.(data as MetadataItemWithValue)} - onCreateMetadata={data => onAdd?.(data)} + onSelectMetadata={(data) => onSelect?.(data as MetadataItemWithValue)} + onCreateMetadata={(data) => onAdd?.(data)} onOpenMetadataManagement={handleMangeMetadata} /> {list.length > 0 && <Divider className="my-3" bgStyle="gradient" />} </div> )} {list.map((item, i) => ( - <Field key={(item.id && item.id !== 'built-in') ? item.id : `${i}`} label={item.name}> - {isEdit - ? ( - <div className="flex items-center space-x-0.5"> - <InputCombined - className="h-6" - label={item.name} - type={item.type} - value={item.value} - onChange={value => onChange?.({ ...item, value })} - /> - <button - type="button" - aria-label={t($ => $['operation.remove'], { ns: 'common' })} - className="shrink-0 cursor-pointer rounded-md border-none bg-transparent p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={() => onDelete?.(item)} - > - <RiDeleteBinLine className="size-4" aria-hidden="true" /> - </button> - </div> - ) - : (<div className="py-1 system-xs-regular text-text-secondary">{(item.value && item.type === DataType.time) ? formatTimestamp((item.value as number), t($ => $['metadata.dateTimeFormat'], { ns: 'datasetDocuments' })) : item.value}</div>)} + <Field key={item.id && item.id !== 'built-in' ? item.id : `${i}`} label={item.name}> + {isEdit ? ( + <div className="flex items-center space-x-0.5"> + <InputCombined + className="h-6" + label={item.name} + type={item.type} + value={item.value} + onChange={(value) => onChange?.({ ...item, value })} + /> + <button + type="button" + aria-label={t(($) => $['operation.remove'], { ns: 'common' })} + className="shrink-0 cursor-pointer rounded-md border-none bg-transparent p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => onDelete?.(item)} + > + <RiDeleteBinLine className="size-4" aria-hidden="true" /> + </button> + </div> + ) : ( + <div className="py-1 system-xs-regular text-text-secondary"> + {item.value && item.type === DataType.time + ? formatTimestamp( + item.value as number, + t(($) => $['metadata.dateTimeFormat'], { ns: 'datasetDocuments' }), + ) + : item.value} + </div> + )} </Field> ))} </div> diff --git a/web/app/components/datasets/metadata/metadata-document/no-data.tsx b/web/app/components/datasets/metadata/metadata-document/no-data.tsx index 181e607632963c..2208a725e339d3 100644 --- a/web/app/components/datasets/metadata/metadata-document/no-data.tsx +++ b/web/app/components/datasets/metadata/metadata-document/no-data.tsx @@ -9,16 +9,18 @@ type Props = Readonly<{ onStart: () => void }> -const NoData: FC<Props> = ({ - onStart, -}) => { +const NoData: FC<Props> = ({ onStart }) => { const { t } = useTranslation() return ( <div className="rounded-xl bg-linear-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4 pt-3"> - <div className="text-xs/5 font-semibold text-text-secondary">{t($ => $['metadata.metadata'], { ns: 'dataset' })}</div> - <div className="mt-1 system-xs-regular text-text-tertiary">{t($ => $['metadata.documentMetadata.metadataToolTip'], { ns: 'dataset' })}</div> + <div className="text-xs/5 font-semibold text-text-secondary"> + {t(($) => $['metadata.metadata'], { ns: 'dataset' })} + </div> + <div className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $['metadata.documentMetadata.metadataToolTip'], { ns: 'dataset' })} + </div> <Button variant="primary" className="mt-2" onClick={onStart}> - <div>{t($ => $['metadata.documentMetadata.startLabeling'], { ns: 'dataset' })}</div> + <div>{t(($) => $['metadata.documentMetadata.startLabeling'], { ns: 'dataset' })}</div> <RiArrowRightLine className="ml-1 size-4" /> </Button> </div> diff --git a/web/app/components/datasets/metadata/types.ts b/web/app/components/datasets/metadata/types.ts index fb1728232ae4ee..5545acb40072e0 100644 --- a/web/app/components/datasets/metadata/types.ts +++ b/web/app/components/datasets/metadata/types.ts @@ -25,7 +25,11 @@ export type MetadataItemInBatchEdit = MetadataItemWithValue & { isMultipleValue?: boolean } -export type MetadataBatchEditToServer = { document_id: string, metadata_list: MetadataItemWithValue[], partial_update?: boolean }[] +export type MetadataBatchEditToServer = { + document_id: string + metadata_list: MetadataItemWithValue[] + partial_update?: boolean +}[] export enum UpdateType { changeValue = 'changeValue', diff --git a/web/app/components/datasets/no-linked-apps-panel.tsx b/web/app/components/datasets/no-linked-apps-panel.tsx index db4b5258131087..624493bd9e2821 100644 --- a/web/app/components/datasets/no-linked-apps-panel.tsx +++ b/web/app/components/datasets/no-linked-apps-panel.tsx @@ -12,7 +12,9 @@ const NoLinkedAppsPanel = () => { <div className="inline-flex rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle p-2"> <RiApps2AddLine className="size-4 text-text-tertiary" /> </div> - <div className="my-2 text-xs text-text-tertiary">{t($ => $['datasetMenus.emptyTip'], { ns: 'common' })}</div> + <div className="my-2 text-xs text-text-tertiary"> + {t(($) => $['datasetMenus.emptyTip'], { ns: 'common' })} + </div> <a className="mt-2 inline-flex cursor-pointer items-center text-xs text-text-accent" href={docLink('/use-dify/knowledge/integrate-knowledge-within-application')} @@ -20,7 +22,7 @@ const NoLinkedAppsPanel = () => { rel="noopener noreferrer" > <RiBookOpenLine className="mr-1 size-4 text-text-accent" /> - {t($ => $['datasetMenus.viewDoc'], { ns: 'common' })} + {t(($) => $['datasetMenus.viewDoc'], { ns: 'common' })} </a> </div> ) diff --git a/web/app/components/datasets/preview/__tests__/container.spec.tsx b/web/app/components/datasets/preview/__tests__/container.spec.tsx index 9f7ff17522a068..4989ccb39eeaed 100644 --- a/web/app/components/datasets/preview/__tests__/container.spec.tsx +++ b/web/app/components/datasets/preview/__tests__/container.spec.tsx @@ -18,7 +18,11 @@ describe('PreviewContainer', () => { }) it('should render children in the content area', () => { - render(<PreviewContainer header="Header" data-testid="inner-container">Main content</PreviewContainer>) + render( + <PreviewContainer header="Header" data-testid="inner-container"> + Main content + </PreviewContainer>, + ) const contentEl = screen.getByTestId('inner-container').lastElementChild expect(contentEl)!.toHaveTextContent('Main content') @@ -47,7 +51,9 @@ describe('PreviewContainer', () => { describe('Props', () => { it('should apply className to the outer wrapper div', () => { const { container } = render( - <PreviewContainer header="Header" className="outer-class">Content</PreviewContainer>, + <PreviewContainer header="Header" className="outer-class"> + Content + </PreviewContainer>, ) expect(container.firstElementChild)!.toHaveClass('outer-class') @@ -55,7 +61,9 @@ describe('PreviewContainer', () => { it('should apply mainClassName to the content area', () => { render( - <PreviewContainer header="Header" mainClassName="custom-main" data-testid="inner-container">Content</PreviewContainer>, + <PreviewContainer header="Header" mainClassName="custom-main" data-testid="inner-container"> + Content + </PreviewContainer>, ) const contentEl = screen.getByTestId('inner-container').lastElementChild @@ -66,7 +74,9 @@ describe('PreviewContainer', () => { it('should forward ref to the inner container div', () => { const ref = vi.fn() render( - <PreviewContainer header="Header" ref={ref}>Content</PreviewContainer>, + <PreviewContainer header="Header" ref={ref}> + Content + </PreviewContainer>, ) expect(ref).toHaveBeenCalled() @@ -87,7 +97,13 @@ describe('PreviewContainer', () => { it('should render ReactNode as header', () => { render( - <PreviewContainer header={<div data-testid="complex-header"><span>Complex</span></div>}> + <PreviewContainer + header={ + <div data-testid="complex-header"> + <span>Complex</span> + </div> + } + > Content </PreviewContainer>, ) @@ -108,7 +124,9 @@ describe('PreviewContainer', () => { it('should have inner div with flex column layout', () => { render( - <PreviewContainer header="Header" data-testid="inner">Content</PreviewContainer>, + <PreviewContainer header="Header" data-testid="inner"> + Content + </PreviewContainer>, ) const inner = screen.getByTestId('inner') @@ -116,7 +134,11 @@ describe('PreviewContainer', () => { }) it('should have content area with overflow-y-auto for scrolling', () => { - render(<PreviewContainer header="Header" data-testid="inner-container">Content</PreviewContainer>) + render( + <PreviewContainer header="Header" data-testid="inner-container"> + Content + </PreviewContainer>, + ) expect(screen.getByTestId('inner-container').lastElementChild)!.toHaveClass('overflow-y-auto') }) @@ -138,7 +160,11 @@ describe('PreviewContainer', () => { }) it('should render with null children', () => { - render(<PreviewContainer header="Header" data-testid="inner-container">{null}</PreviewContainer>) + render( + <PreviewContainer header="Header" data-testid="inner-container"> + {null} + </PreviewContainer>, + ) expect(screen.getByTestId('inner-container').lastElementChild)!.toBeInTheDocument() }) @@ -159,11 +185,15 @@ describe('PreviewContainer', () => { it('should not crash on re-render with different props', () => { const { rerender } = render( - <PreviewContainer header="First" className="a">Content A</PreviewContainer>, + <PreviewContainer header="First" className="a"> + Content A + </PreviewContainer>, ) rerender( - <PreviewContainer header="Second" className="b" mainClassName="new-main">Content B</PreviewContainer>, + <PreviewContainer header="Second" className="b" mainClassName="new-main"> + Content B + </PreviewContainer>, ) expect(screen.getByText('Second'))!.toBeInTheDocument() diff --git a/web/app/components/datasets/preview/__tests__/header.spec.tsx b/web/app/components/datasets/preview/__tests__/header.spec.tsx index 8f7e44e18c6f08..931e036b2b122f 100644 --- a/web/app/components/datasets/preview/__tests__/header.spec.tsx +++ b/web/app/components/datasets/preview/__tests__/header.spec.tsx @@ -49,7 +49,14 @@ describe('PreviewHeader', () => { }) it('should pass rest props to the outer div', () => { - render(<PreviewHeader title="Title" data-testid="header" id="header-1" aria-label="preview header" />) + render( + <PreviewHeader + title="Title" + data-testid="header" + id="header-1" + aria-label="preview header" + />, + ) const el = screen.getByTestId('header') expect(el).toHaveAttribute('id', 'header-1') @@ -101,7 +108,7 @@ describe('PreviewHeader', () => { it('should handle special characters in title', () => { render(<PreviewHeader title="Test & <Special> 'Characters'" />) - expect(screen.getByText('Test & <Special> \'Characters\'')).toBeInTheDocument() + expect(screen.getByText("Test & <Special> 'Characters'")).toBeInTheDocument() }) it('should handle long titles', () => { diff --git a/web/app/components/datasets/preview/container.tsx b/web/app/components/datasets/preview/container.tsx index c651de6c1f726c..17f747320904a8 100644 --- a/web/app/components/datasets/preview/container.tsx +++ b/web/app/components/datasets/preview/container.tsx @@ -14,14 +14,12 @@ const PreviewContainer: FC<PreviewContainerProps> = (props) => { <div {...rest} ref={ref} - className={cn('flex h-full w-full flex-col rounded-tl-xl border-t-[0.5px] border-l-[0.5px] border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5')} + className={cn( + 'flex h-full w-full flex-col rounded-tl-xl border-t-[0.5px] border-l-[0.5px] border-components-panel-border bg-background-default-lighter shadow-md shadow-shadow-shadow-5', + )} > - <header className="border-b border-divider-subtle pt-4 pr-4 pb-3 pl-5"> - {header} - </header> - <div className={cn('w-full grow overflow-y-auto px-6 py-5', mainClassName)}> - {children} - </div> + <header className="border-b border-divider-subtle pt-4 pr-4 pb-3 pl-5">{header}</header> + <div className={cn('w-full grow overflow-y-auto px-6 py-5', mainClassName)}>{children}</div> </div> </div> ) diff --git a/web/app/components/datasets/preview/header.tsx b/web/app/components/datasets/preview/header.tsx index 3646c96f49d1a6..051dc0a37f5422 100644 --- a/web/app/components/datasets/preview/header.tsx +++ b/web/app/components/datasets/preview/header.tsx @@ -8,13 +8,8 @@ type PreviewHeaderProps = Omit<ComponentProps<'div'>, 'title'> & { export const PreviewHeader: FC<PreviewHeaderProps> = (props) => { const { title, className, children, ...rest } = props return ( - <div - {...rest} - className={cn(className)} - > - <div - className="mb-1 px-1 system-2xs-semibold-uppercase text-text-accent uppercase" - > + <div {...rest} className={cn(className)}> + <div className="mb-1 px-1 system-2xs-semibold-uppercase text-text-accent uppercase"> {title} </div> {children} diff --git a/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx b/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx index 8fde3275112279..528bbd23e09b48 100644 --- a/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx @@ -30,7 +30,9 @@ vi.mock('@/service/datasets', () => ({ // Mock AppIcon - simplified mock to enable testing onClick callback vi.mock('../../../base/app-icon', () => ({ default: ({ onClick }: { onClick?: () => void }) => ( - <button data-testid="app-icon" onClick={onClick}>Icon</button> + <button data-testid="app-icon" onClick={onClick}> + Icon + </button> ), })) @@ -40,14 +42,26 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({ onSelect, }: { onOpenChange: (open: boolean) => void - onSelect: (payload: { type: 'emoji', icon: string, background: string }) => void + onSelect: (payload: { type: 'emoji'; icon: string; background: string }) => void }) => { let selectedBackground = '#FFEAD5' return ( <div> <input placeholder="Search emojis..." /> - <button type="button" aria-label="#E4FBCC" onClick={() => { selectedBackground = '#E4FBCC' }} /> - <button type="button" aria-label="#E0F2FE" onClick={() => { selectedBackground = '#E0F2FE' }} /> + <button + type="button" + aria-label="#E4FBCC" + onClick={() => { + selectedBackground = '#E4FBCC' + }} + /> + <button + type="button" + aria-label="#E0F2FE" + onClick={() => { + selectedBackground = '#E0F2FE' + }} + /> <button type="button" onClick={() => { @@ -57,7 +71,9 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({ > iconPicker.ok </button> - <button type="button" onClick={() => onOpenChange(false)}>iconPicker.cancel</button> + <button type="button" onClick={() => onOpenChange(false)}> + iconPicker.cancel + </button> </div> ) }, @@ -115,24 +131,26 @@ describe('RenameDatasetModal', () => { }) // Create a dataset with image icon - const createMockDatasetWithImageIcon = (): DataSet => createMockDataset({ - icon_info: { - icon: 'file-id-123', - icon_type: 'image', - icon_background: undefined, - icon_url: 'https://example.com/icon.png', - }, - }) + const createMockDatasetWithImageIcon = (): DataSet => + createMockDataset({ + icon_info: { + icon: 'file-id-123', + icon_type: 'image', + icon_background: undefined, + icon_url: 'https://example.com/icon.png', + }, + }) // Create a dataset with external knowledge info - const createMockExternalDataset = (): DataSet => createMockDataset({ - external_knowledge_info: { - external_knowledge_id: 'ext-knowledge-1', - external_knowledge_api_id: 'ext-api-1', - external_knowledge_api_name: 'External API', - external_knowledge_api_endpoint: 'https://api.example.com', - }, - }) + const createMockExternalDataset = (): DataSet => + createMockDataset({ + external_knowledge_info: { + external_knowledge_id: 'ext-knowledge-1', + external_knowledge_api_id: 'ext-api-1', + external_knowledge_api_name: 'External API', + external_knowledge_api_endpoint: 'https://api.example.com', + }, + }) const defaultProps = { show: true, @@ -206,7 +224,9 @@ describe('RenameDatasetModal', () => { const dataset = createMockDataset({ description: '' }) render(<RenameDatasetModal {...defaultProps} dataset={dataset} />) // Find the textarea by its placeholder - const descriptionTextarea = screen.getByPlaceholderText('datasetSettings.form.descPlaceholder') + const descriptionTextarea = screen.getByPlaceholderText( + 'datasetSettings.form.descPlaceholder', + ) expect(descriptionTextarea)!.toHaveValue('') }) @@ -334,9 +354,12 @@ describe('RenameDatasetModal', () => { it('should disable save button while loading', async () => { // Create a promise that we can control let resolvePromise: (value: DataSet) => void - mockUpdateDatasetSetting.mockImplementation(() => new Promise((resolve) => { - resolvePromise = resolve - })) + mockUpdateDatasetSetting.mockImplementation( + () => + new Promise((resolve) => { + resolvePromise = resolve + }), + ) render(<RenameDatasetModal {...defaultProps} />) @@ -532,7 +555,9 @@ describe('RenameDatasetModal', () => { it('should call onSuccess and onClose after successful save', async () => { const handleSuccess = vi.fn() const handleClose = vi.fn() - render(<RenameDatasetModal {...defaultProps} onSuccess={handleSuccess} onClose={handleClose} />) + render( + <RenameDatasetModal {...defaultProps} onSuccess={handleSuccess} onClose={handleClose} />, + ) const saveButton = screen.getByText('common.operation.save') await act(async () => { @@ -1109,9 +1134,12 @@ describe('RenameDatasetModal', () => { it('should handle double click on save button', async () => { // Use a promise we can control to ensure the first click is still "loading" let resolvePromise: (value: DataSet) => void - mockUpdateDatasetSetting.mockImplementationOnce(() => new Promise((resolve) => { - resolvePromise = resolve - })) + mockUpdateDatasetSetting.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePromise = resolve + }), + ) render(<RenameDatasetModal {...defaultProps} />) @@ -1175,7 +1203,10 @@ describe('RenameDatasetModal', () => { expect(screen.getByDisplayValue('Test Dataset'))!.toBeInTheDocument() - const newDataset = createMockDataset({ name: 'Different Dataset', description: 'Different description' }) + const newDataset = createMockDataset({ + name: 'Different Dataset', + description: 'Different description', + }) rerender(<RenameDatasetModal {...defaultProps} dataset={newDataset} />) // Note: The component uses useState with initial value, so it won't update @@ -1223,9 +1254,12 @@ describe('RenameDatasetModal', () => { describe('Loading State', () => { it('should show loading state during API call', async () => { let resolvePromise: (value: DataSet) => void - mockUpdateDatasetSetting.mockImplementation(() => new Promise((resolve) => { - resolvePromise = resolve - })) + mockUpdateDatasetSetting.mockImplementation( + () => + new Promise((resolve) => { + resolvePromise = resolve + }), + ) render(<RenameDatasetModal {...defaultProps} />) diff --git a/web/app/components/datasets/rename-modal/index.tsx b/web/app/components/datasets/rename-modal/index.tsx index 0bcf5013614640..5950ae1a65c02c 100644 --- a/web/app/components/datasets/rename-modal/index.tsx +++ b/web/app/components/datasets/rename-modal/index.tsx @@ -28,9 +28,19 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset const [description, setDescription] = useState<string>(dataset.description) const externalKnowledgeId = dataset.external_knowledge_info.external_knowledge_id const externalKnowledgeApiId = dataset.external_knowledge_info.external_knowledge_api_id - const [appIcon, setAppIcon] = useState<AppIconSelection>(dataset.icon_info?.icon_type === 'image' - ? { type: 'image' as const, url: dataset.icon_info?.icon_url || '', fileId: dataset.icon_info?.icon || '' } - : { type: 'emoji' as const, icon: dataset.icon_info?.icon || '', background: dataset.icon_info?.icon_background || '' }) + const [appIcon, setAppIcon] = useState<AppIconSelection>( + dataset.icon_info?.icon_type === 'image' + ? { + type: 'image' as const, + url: dataset.icon_info?.icon_url || '', + fileId: dataset.icon_info?.icon || '', + } + : { + type: 'emoji' as const, + icon: dataset.icon_info?.icon || '', + background: dataset.icon_info?.icon_background || '', + }, + ) const [showAppIconPicker, setShowAppIconPicker] = useState(false) const handleOpenAppIconPicker = useCallback(() => { setShowAppIconPicker(true) @@ -40,7 +50,7 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset }, []) const onConfirm: MouseEventHandler = useCallback(async () => { if (!name.trim()) { - toast.error(t($ => $['form.nameError'], { ns: 'datasetSettings' })) + toast.error(t(($) => $['form.nameError'], { ns: 'datasetSettings' })) return } try { @@ -66,28 +76,36 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset datasetId: dataset.id, body, }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - if (onSuccess) - onSuccess() + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + if (onSuccess) onSuccess() onClose() - } - catch { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - } - finally { + } catch { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } finally { setLoading(false) } - }, [appIcon, description, dataset.id, externalKnowledgeApiId, externalKnowledgeId, name, onClose, onSuccess, t]) + }, [ + appIcon, + description, + dataset.id, + externalKnowledgeApiId, + externalKnowledgeId, + name, + onClose, + onSuccess, + t, + ]) return ( <Dialog open={show}> <DialogContent className="w-full max-w-[520px] overflow-hidden! rounded-xl border-none px-8 py-6 text-left align-middle"> - <div className="flex items-center justify-between pb-2"> - <div className="text-xl leading-[30px] font-medium text-text-primary">{t($ => $.title, { ns: 'datasetSettings' })}</div> + <div className="text-xl leading-[30px] font-medium text-text-primary"> + {t(($) => $.title, { ns: 'datasetSettings' })} + </div> <button type="button" className="cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onClose} > <RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" /> @@ -96,32 +114,58 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset <div> <div className={cn('flex flex-col py-4')}> <div className="shrink-0 py-2 text-sm leading-[20px] font-medium text-text-primary"> - {t($ => $['form.name'], { ns: 'datasetSettings' })} + {t(($) => $['form.name'], { ns: 'datasetSettings' })} </div> <div className="flex items-center gap-x-2"> - <AppIcon size="medium" onClick={handleOpenAppIconPicker} className="cursor-pointer" iconType={appIcon.type} icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} background={appIcon.type === 'image' ? undefined : appIcon.background} imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} showEditIcon /> - <Input value={name} onChange={e => setName(e.target.value)} className="h-9 grow" placeholder={t($ => $['form.namePlaceholder'], { ns: 'datasetSettings' }) || ''} /> + <AppIcon + size="medium" + onClick={handleOpenAppIconPicker} + className="cursor-pointer" + iconType={appIcon.type} + icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} + background={appIcon.type === 'image' ? undefined : appIcon.background} + imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} + showEditIcon + /> + <Input + value={name} + onChange={(e) => setName(e.target.value)} + className="h-9 grow" + placeholder={t(($) => $['form.namePlaceholder'], { ns: 'datasetSettings' }) || ''} + /> </div> </div> <div className={cn('flex flex-col py-4')}> <div className="shrink-0 py-2 text-sm leading-[20px] font-medium text-text-primary"> - {t($ => $['form.desc'], { ns: 'datasetSettings' })} + {t(($) => $['form.desc'], { ns: 'datasetSettings' })} </div> <div className="w-full"> - <Textarea aria-label={t($ => $['form.desc'], { ns: 'datasetSettings' })} value={description} onValueChange={value => setDescription(value)} className="resize-none" placeholder={t($ => $['form.descPlaceholder'], { ns: 'datasetSettings' }) || ''} /> + <Textarea + aria-label={t(($) => $['form.desc'], { ns: 'datasetSettings' })} + value={description} + onValueChange={(value) => setDescription(value)} + className="resize-none" + placeholder={t(($) => $['form.descPlaceholder'], { ns: 'datasetSettings' }) || ''} + /> </div> </div> </div> <div className="flex justify-end pt-6"> - <Button className="mr-2" onClick={onClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button disabled={loading} variant="primary" onClick={onConfirm}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button className="mr-2" onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button disabled={loading} variant="primary" onClick={onConfirm}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> {showAppIconPicker && ( <AppIconPicker open={showAppIconPicker} - initialEmoji={appIcon.type === 'emoji' - ? { icon: appIcon.icon, background: appIcon.background } - : undefined} + initialEmoji={ + appIcon.type === 'emoji' + ? { icon: appIcon.icon, background: appIcon.background } + : undefined + } onOpenChange={setShowAppIconPicker} onSelect={handleSelectAppIcon} /> diff --git a/web/app/components/datasets/settings/__tests__/option-card.spec.tsx b/web/app/components/datasets/settings/__tests__/option-card.spec.tsx index ba670dc144bf29..0a6cb0c529ff80 100644 --- a/web/app/components/datasets/settings/__tests__/option-card.spec.tsx +++ b/web/app/components/datasets/settings/__tests__/option-card.spec.tsx @@ -132,9 +132,7 @@ describe('OptionCard', () => { }) it('should not render effect color when effectColor is not provided', () => { - const { container } = render( - <OptionCard {...defaultProps} showEffectColor={true} />, - ) + const { container } = render(<OptionCard {...defaultProps} showEffectColor={true} />) const effectElement = container.querySelector('.blur-\\[80px\\]') expect(effectElement).not.toBeInTheDocument() }) @@ -192,9 +190,7 @@ describe('OptionCard', () => { }) it('should not render children container when children is not provided', () => { - const { container } = render( - <OptionCard {...defaultProps} showChildren={true} />, - ) + const { container } = render(<OptionCard {...defaultProps} showChildren={true} />) const childContainer = container.querySelector('.bg-components-panel-bg') expect(childContainer).not.toBeInTheDocument() }) diff --git a/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx b/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx index 39b4ffc78419ad..48dc4f4b7051c0 100644 --- a/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx +++ b/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx @@ -19,7 +19,15 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () // Mock ModelSelector (external component from header module) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ onSelect, readonly, defaultModel }: { onSelect?: (val: Record<string, string>) => void, readonly?: boolean, defaultModel?: { model?: string } }) => ( + default: ({ + onSelect, + readonly, + defaultModel, + }: { + onSelect?: (val: Record<string, string>) => void + readonly?: boolean + defaultModel?: { model?: string } + }) => ( <div data-testid="model-selector" data-readonly={readonly}> <span data-testid="current-model">{defaultModel?.model || 'none'}</span> <button @@ -118,20 +126,14 @@ describe('SummaryIndexSetting', () => { it('should show disabled text when not enabled', () => { render( - <SummaryIndexSetting - entry="dataset-settings" - summaryIndexSetting={{ enable: false }} - />, + <SummaryIndexSetting entry="dataset-settings" summaryIndexSetting={{ enable: false }} />, ) expect(screen.getByText(`${ns}.form.summaryAutoGenEnableTip`)).toBeInTheDocument() }) it('should show enabled tip when enabled', () => { render( - <SummaryIndexSetting - entry="dataset-settings" - summaryIndexSetting={{ enable: true }} - />, + <SummaryIndexSetting entry="dataset-settings" summaryIndexSetting={{ enable: true }} />, ) expect(screen.getByText(`${ns}.form.summaryAutoGenTip`)).toBeInTheDocument() }) @@ -168,10 +170,7 @@ describe('SummaryIndexSetting', () => { it('should not show model selector when disabled', () => { render( - <SummaryIndexSetting - entry="create-document" - summaryIndexSetting={{ enable: false }} - />, + <SummaryIndexSetting entry="create-document" summaryIndexSetting={{ enable: false }} />, ) expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument() }) @@ -207,19 +206,18 @@ describe('SummaryIndexSetting', () => { render( <SummaryIndexSetting entry="knowledge-base" - summaryIndexSetting={{ enable: true, model_provider_name: 'anthropic', model_name: 'claude-3' }} + summaryIndexSetting={{ + enable: true, + model_provider_name: 'anthropic', + model_name: 'claude-3', + }} />, ) expect(screen.getByTestId('current-model')).toHaveTextContent('claude-3') }) it('should pass undefined defaultModel when provider is missing', () => { - render( - <SummaryIndexSetting - entry="knowledge-base" - summaryIndexSetting={{ enable: true }} - />, - ) + render(<SummaryIndexSetting entry="knowledge-base" summaryIndexSetting={{ enable: true }} />) expect(screen.getByTestId('current-model')).toHaveTextContent('none') }) }) diff --git a/web/app/components/datasets/settings/chunk-structure/__tests__/hooks.spec.tsx b/web/app/components/datasets/settings/chunk-structure/__tests__/hooks.spec.tsx index 518c74d0e11ca1..18a4032398c772 100644 --- a/web/app/components/datasets/settings/chunk-structure/__tests__/hooks.spec.tsx +++ b/web/app/components/datasets/settings/chunk-structure/__tests__/hooks.spec.tsx @@ -175,7 +175,7 @@ describe('useChunkStructure', () => { it('should return options in correct order', () => { const { result } = renderHook(() => useChunkStructure()) - const ids = result.current.options.map(opt => opt.id) + const ids = result.current.options.map((opt) => opt.id) expect(ids).toEqual(['text_model', 'hierarchical_model', 'qa_model']) }) @@ -217,9 +217,9 @@ describe('useChunkStructure', () => { it('should return consistent options on multiple renders', () => { const { result, rerender } = renderHook(() => useChunkStructure()) - const firstRenderOptions = result.current.options.map(opt => opt.id) + const firstRenderOptions = result.current.options.map((opt) => opt.id) rerender() - const secondRenderOptions = result.current.options.map(opt => opt.id) + const secondRenderOptions = result.current.options.map((opt) => opt.id) expect(firstRenderOptions).toEqual(secondRenderOptions) }) diff --git a/web/app/components/datasets/settings/chunk-structure/hooks.tsx b/web/app/components/datasets/settings/chunk-structure/hooks.tsx index 1e7cb1a016261c..97ac2c98ca48c5 100644 --- a/web/app/components/datasets/settings/chunk-structure/hooks.tsx +++ b/web/app/components/datasets/settings/chunk-structure/hooks.tsx @@ -16,7 +16,7 @@ export const useChunkStructure = () => { icon: <GeneralChunk className="size-[18px]" />, iconActiveColor: 'text-util-colors-indigo-indigo-600', title: 'General', - description: t($ => $['stepTwo.generalTip'], { ns: 'datasetCreation' }), + description: t(($) => $['stepTwo.generalTip'], { ns: 'datasetCreation' }), effectColor: EffectColor.indigo, showEffectColor: true, } @@ -25,7 +25,7 @@ export const useChunkStructure = () => { icon: <ParentChildChunk className="size-[18px]" />, iconActiveColor: 'text-util-colors-blue-light-blue-light-500', title: 'Parent-Child', - description: t($ => $['stepTwo.parentChildTip'], { ns: 'datasetCreation' }), + description: t(($) => $['stepTwo.parentChildTip'], { ns: 'datasetCreation' }), effectColor: EffectColor.blueLight, showEffectColor: true, } @@ -33,14 +33,10 @@ export const useChunkStructure = () => { id: ChunkingMode.qa, icon: <QuestionAndAnswer className="size-[18px]" />, title: 'Q&A', - description: t($ => $['stepTwo.qaTip'], { ns: 'datasetCreation' }), + description: t(($) => $['stepTwo.qaTip'], { ns: 'datasetCreation' }), } - const options = [ - GeneralOption, - ParentChildOption, - QuestionAnswerOption, - ] + const options = [GeneralOption, ParentChildOption, QuestionAnswerOption] return { options, diff --git a/web/app/components/datasets/settings/chunk-structure/index.tsx b/web/app/components/datasets/settings/chunk-structure/index.tsx index 977620c4c1cec8..c1e6fc04a8438b 100644 --- a/web/app/components/datasets/settings/chunk-structure/index.tsx +++ b/web/app/components/datasets/settings/chunk-structure/index.tsx @@ -7,32 +7,26 @@ type ChunkStructureProps = { chunkStructure: ChunkingMode } -const ChunkStructure = ({ - chunkStructure, -}: ChunkStructureProps) => { - const { - options, - } = useChunkStructure() +const ChunkStructure = ({ chunkStructure }: ChunkStructureProps) => { + const { options } = useChunkStructure() return ( <div className="flex flex-col gap-y-1"> - { - options.map(option => ( - <OptionCard - key={option.id} - id={option.id} - icon={option.icon} - iconActiveColor={option.iconActiveColor} - title={option.title} - description={option.description} - isActive={chunkStructure === option.id} - effectColor={option.effectColor} - showEffectColor - className="gap-x-1.5 p-3 pr-4" - disabled - /> - )) - } + {options.map((option) => ( + <OptionCard + key={option.id} + id={option.id} + icon={option.icon} + iconActiveColor={option.iconActiveColor} + title={option.title} + description={option.description} + isActive={chunkStructure === option.id} + effectColor={option.effectColor} + showEffectColor + className="gap-x-1.5 p-3 pr-4" + disabled + /> + ))} </div> ) } diff --git a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx index b79da32e9e2c8a..7be297896a5a0f 100644 --- a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx @@ -38,54 +38,79 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }) vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => ({ - userProfile: mockUserProfile, - workspacePermissionKeys: mockWorkspacePermissionKeys, - }), () => ({ - isRbacEnabled: false, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => ({ + userProfile: mockUserProfile, + workspacePermissionKeys: mockWorkspacePermissionKeys, + }), + () => ({ + isRbacEnabled: false, + }), + ) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => ({ - userProfile: mockUserProfile, - workspacePermissionKeys: mockWorkspacePermissionKeys, - }), () => ({ - isRbacEnabled: false, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => ({ + userProfile: mockUserProfile, + workspacePermissionKeys: mockWorkspacePermissionKeys, + }), + () => ({ + isRbacEnabled: false, + }), + ) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => ({ - userProfile: mockUserProfile, - workspacePermissionKeys: mockWorkspacePermissionKeys, - }), () => ({ - isRbacEnabled: false, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => ({ + userProfile: mockUserProfile, + workspacePermissionKeys: mockWorkspacePermissionKeys, + }), + () => ({ + isRbacEnabled: false, + }), + ) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => ({ - userProfile: mockUserProfile, - workspacePermissionKeys: mockWorkspacePermissionKeys, - }), () => ({ - isRbacEnabled: false, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => ({ + userProfile: mockUserProfile, + workspacePermissionKeys: mockWorkspacePermissionKeys, + }), + () => ({ + isRbacEnabled: false, + }), + ) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => ({ - userProfile: mockUserProfile, - workspacePermissionKeys: mockWorkspacePermissionKeys, - }), () => ({ - isRbacEnabled: false, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => ({ + userProfile: mockUserProfile, + workspacePermissionKeys: mockWorkspacePermissionKeys, + }), + () => ({ + isRbacEnabled: false, + }), + ) }) const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ @@ -161,7 +186,9 @@ const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ let mockDataset: DataSet = createMockDataset() vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown) => { + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: DataSet | null; mutateDatasetRes: () => void }) => unknown, + ) => { const state = { dataset: mockDataset, mutateDatasetRes: mockMutateDatasets, @@ -171,7 +198,8 @@ vi.mock('@/context/dataset-detail', () => ({ })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) @@ -189,8 +217,28 @@ vi.mock('@/service/use-common', () => ({ useMembers: () => ({ data: { accounts: [ - { id: 'user-1', name: 'User 1', email: 'user1@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, - { id: 'user-2', name: 'User 2', email: 'user2@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + { + id: 'user-1', + name: 'User 1', + email: 'user1@example.com', + role: 'owner', + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, + { + id: 'user-2', + name: 'User 2', + email: 'user2@example.com', + role: 'admin', + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, ], }, }), @@ -271,12 +319,12 @@ vi.mock('../components/indexing-section', () => ({ </a> </> )} - {!!(currentDataset - && currentDataset.doc_form !== ChunkingMode.parentChild - && currentDataset.indexing_technique - && indexMethod) && ( - <div>form.indexMethod</div> - )} + {!!( + currentDataset && + currentDataset.doc_form !== ChunkingMode.parentChild && + currentDataset.indexing_technique && + indexMethod + ) && <div>form.indexMethod</div>} {indexMethod === IndexingType.QUALIFIED && <div>form.embeddingModel</div>} {currentDataset?.provider !== 'external' && indexMethod && ( <> @@ -432,7 +480,7 @@ describe('Form', () => { it('should show loading state on save button while saving', async () => { const { updateDatasetSetting } = await import('@/service/datasets') vi.mocked(updateDatasetSetting).mockImplementation( - () => new Promise(resolve => setTimeout(resolve, 100)), + () => new Promise((resolve) => setTimeout(resolve, 100)), ) render(<Form />) diff --git a/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx index 0d1ceaacf772ee..435327804548a3 100644 --- a/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx @@ -32,39 +32,64 @@ const mockAppContextState = vi.hoisted(() => ({ // Mock app-context vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: false, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: false, + }), + ) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: false, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: false, + }), + ) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: false, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: false, + }), + ) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: false, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: false, + }), + ) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: false, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: false, + }), + ) }) // Mock image uploader hooks for AppIconPicker @@ -85,7 +110,8 @@ vi.mock('@/app/components/base/image-uploader/hooks', () => ({ })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) @@ -160,8 +186,30 @@ describe('BasicInfoSection', () => { } const mockMemberList: Member[] = [ - { id: 'user-1', name: 'User 1', email: 'user1@example.com', role: 'owner', roles: [], avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, - { id: 'user-2', name: 'User 2', email: 'user2@example.com', role: 'admin', roles: [], avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + { + id: 'user-1', + name: 'User 1', + email: 'user1@example.com', + role: 'owner', + roles: [], + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, + { + id: 'user-2', + name: 'User 2', + email: 'user2@example.com', + role: 'admin', + roles: [], + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, ] const mockIconInfo: IconInfo = { @@ -301,7 +349,9 @@ describe('BasicInfoSection', () => { describe('App Icon', () => { it('should call handleOpenAppIconPicker when icon is clicked', () => { const handleOpenAppIconPicker = vi.fn() - const { container } = render(<BasicInfoSection {...defaultProps} handleOpenAppIconPicker={handleOpenAppIconPicker} />) + const { container } = render( + <BasicInfoSection {...defaultProps} handleOpenAppIconPicker={handleOpenAppIconPicker} />, + ) // Find the clickable icon element - it's inside a wrapper that handles the click const iconWrapper = container.querySelector('[class*="cursor-pointer"]') @@ -312,7 +362,9 @@ describe('BasicInfoSection', () => { }) it('should render AppIconPicker when showAppIconPicker is true', () => { - const { baseElement } = render(<BasicInfoSection {...defaultProps} showAppIconPicker={true} />) + const { baseElement } = render( + <BasicInfoSection {...defaultProps} showAppIconPicker={true} />, + ) // AppIconPicker renders a modal with emoji tabs and options via portal // We just verify the component renders without crashing when picker is shown @@ -401,9 +453,7 @@ describe('BasicInfoSection', () => { }) it('should be disabled when form is readonly from dataset ACL editability', () => { - const { container } = render( - <BasicInfoSection {...defaultProps} readonly />, - ) + const { container } = render(<BasicInfoSection {...defaultProps} readonly />) const disabledElement = container.querySelector('[class*="cursor-not-allowed"]') expect(disabledElement)!.toBeInTheDocument() @@ -466,7 +516,9 @@ describe('BasicInfoSection', () => { }) it('should update when description prop changes', () => { - const { rerender } = render(<BasicInfoSection {...defaultProps} description="Initial Description" />) + const { rerender } = render( + <BasicInfoSection {...defaultProps} description="Initial Description" />, + ) expect(screen.getByDisplayValue('Initial Description'))!.toBeInTheDocument() @@ -476,7 +528,9 @@ describe('BasicInfoSection', () => { }) it('should update when permission prop changes', () => { - const { rerender } = render(<BasicInfoSection {...defaultProps} permission={DatasetPermission.onlyMe} />) + const { rerender } = render( + <BasicInfoSection {...defaultProps} permission={DatasetPermission.onlyMe} />, + ) expect(screen.getByText(/form\.permissionsOnlyMe/i))!.toBeInTheDocument() @@ -504,12 +558,7 @@ describe('BasicInfoSection', () => { }) it('should handle empty member list', () => { - render( - <BasicInfoSection - {...defaultProps} - memberList={[]} - />, - ) + render(<BasicInfoSection {...defaultProps} memberList={[]} />) expect(screen.getByText(/form\.permissionsOnlyMe/i))!.toBeInTheDocument() }) diff --git a/web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx index 1a2a2e17b13bef..adcf9b19f1a3b5 100644 --- a/web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx @@ -167,7 +167,9 @@ describe('ExternalKnowledgeSection', () => { it('should call handleSettingsChange when settings change', () => { const handleSettingsChange = vi.fn() - render(<ExternalKnowledgeSection {...defaultProps} handleSettingsChange={handleSettingsChange} />) + render( + <ExternalKnowledgeSection {...defaultProps} handleSettingsChange={handleSettingsChange} />, + ) // The handler should be properly passed to RetrievalSettings // Actual interaction depends on RetrievalSettings implementation @@ -280,13 +282,16 @@ describe('ExternalKnowledgeSection', () => { ...mockDataset, external_knowledge_info: { ...mockDataset.external_knowledge_info, - external_knowledge_api_name: 'This is a very long external knowledge API name that should be truncated', + external_knowledge_api_name: + 'This is a very long external knowledge API name that should be truncated', }, } render(<ExternalKnowledgeSection {...defaultProps} currentDataset={longNameDataset} />) - expect(screen.getByText(/This is a very long external knowledge API name/)).toBeInTheDocument() + expect( + screen.getByText(/This is a very long external knowledge API name/), + ).toBeInTheDocument() }) it('should handle long API endpoints', () => { @@ -294,13 +299,16 @@ describe('ExternalKnowledgeSection', () => { ...mockDataset, external_knowledge_info: { ...mockDataset.external_knowledge_info, - external_knowledge_api_endpoint: 'https://api.very-long-domain-name.example.com/api/v1/external/knowledge', + external_knowledge_api_endpoint: + 'https://api.very-long-domain-name.example.com/api/v1/external/knowledge', }, } render(<ExternalKnowledgeSection {...defaultProps} currentDataset={longEndpointDataset} />) - expect(screen.getByText(/https:\/\/api.very-long-domain-name.example.com/)).toBeInTheDocument() + expect( + screen.getByText(/https:\/\/api.very-long-domain-name.example.com/), + ).toBeInTheDocument() }) it('should handle special characters in API name', () => { @@ -328,7 +336,9 @@ describe('ExternalKnowledgeSection', () => { it('should handle settings change for top_k', () => { const handleSettingsChange = vi.fn() - render(<ExternalKnowledgeSection {...defaultProps} handleSettingsChange={handleSettingsChange} />) + render( + <ExternalKnowledgeSection {...defaultProps} handleSettingsChange={handleSettingsChange} />, + ) // Find and interact with the top_k control in RetrievalSettings // The exact interaction depends on RetrievalSettings implementation @@ -336,14 +346,18 @@ describe('ExternalKnowledgeSection', () => { it('should handle settings change for score_threshold', () => { const handleSettingsChange = vi.fn() - render(<ExternalKnowledgeSection {...defaultProps} handleSettingsChange={handleSettingsChange} />) + render( + <ExternalKnowledgeSection {...defaultProps} handleSettingsChange={handleSettingsChange} />, + ) // Find and interact with the score_threshold control in RetrievalSettings }) it('should handle settings change for score_threshold_enabled', () => { const handleSettingsChange = vi.fn() - render(<ExternalKnowledgeSection {...defaultProps} handleSettingsChange={handleSettingsChange} />) + render( + <ExternalKnowledgeSection {...defaultProps} handleSettingsChange={handleSettingsChange} />, + ) // Find and interact with the score_threshold_enabled toggle in RetrievalSettings }) diff --git a/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx index a4c5b0365dab16..e9a370f1b4b0f2 100644 --- a/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx @@ -1,8 +1,15 @@ -import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + DefaultModel, + Model, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { DataSet, SummaryIndexSetting } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' import { fireEvent, render, screen } from '@testing-library/react' -import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + ConfigurationMethodEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import { IndexingType } from '../../../../create/step-two' @@ -86,7 +93,10 @@ vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({ summaryIndexSetting?: SummaryIndexSetting onSummaryIndexSettingChange?: (payload: SummaryIndexSetting) => void }) => ( - <div data-testid="summary-index-setting" data-enabled={summaryIndexSetting?.enable ? 'true' : 'false'}> + <div + data-testid="summary-index-setting" + data-enabled={summaryIndexSetting?.enable ? 'true' : 'false'} + > <button type="button" onClick={() => onSummaryIndexSettingChange?.({ enable: true })}> summary-enable </button> @@ -112,7 +122,8 @@ vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({ onChange({ ...value, top_k: 6, - })} + }) + } > update-retrieval </button> @@ -135,7 +146,8 @@ vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', ( onChange({ ...value, search_method: RETRIEVE_METHOD.keywordSearch, - })} + }) + } > update-economy-retrieval </button> @@ -274,7 +286,10 @@ describe('IndexingSection', () => { renderComponent() expect(screen.getByText(/(?:^|\.)form\.embeddingModel(?=$|:)/)).toBeInTheDocument() - expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'text-embedding-ada-002') + expect(screen.getByTestId('model-selector')).toHaveAttribute( + 'data-model', + 'text-embedding-ada-002', + ) }) }) @@ -287,16 +302,25 @@ describe('IndexingSection', () => { }, }) - expect(screen.queryByText(/(?:^|\.)form\.chunkStructure\.title(?=$|:)/)).not.toBeInTheDocument() + expect( + screen.queryByText(/(?:^|\.)form\.chunkStructure\.title(?=$|:)/), + ).not.toBeInTheDocument() expect(screen.queryByTestId('chunk-structure')).not.toBeInTheDocument() }) it('should render the chunk structure learn more link and description', () => { renderComponent() - const learnMoreLink = screen.getByRole('link', { name: /(?:^|\.)form\.chunkStructure\.learnMore(?=$|:)/ }) - expect(learnMoreLink).toHaveAttribute('href', expect.stringContaining('chunking-and-cleaning-text')) - expect(screen.getByText(/(?:^|\.)form\.chunkStructure\.description(?=$|:)/)).toBeInTheDocument() + const learnMoreLink = screen.getByRole('link', { + name: /(?:^|\.)form\.chunkStructure\.learnMore(?=$|:)/, + }) + expect(learnMoreLink).toHaveAttribute( + 'href', + expect.stringContaining('chunking-and-cleaning-text'), + ) + expect( + screen.getByText(/(?:^|\.)form\.chunkStructure\.description(?=$|:)/), + ).toBeInTheDocument() }) }) @@ -316,15 +340,21 @@ describe('IndexingSection', () => { it('should render both index method options', () => { renderComponent() - expect(screen.getByRole('button', { name: /(?:^|\.)stepTwo\.qualified(?=$|:)/ })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /(?:^|\.)form\.indexMethodEconomy(?=$|:)/ })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /(?:^|\.)stepTwo\.qualified(?=$|:)/ }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /(?:^|\.)form\.indexMethodEconomy(?=$|:)/ }), + ).toBeInTheDocument() }) it('should call setIndexMethod when the user selects a new index method', () => { const setIndexMethod = vi.fn() renderComponent({ setIndexMethod }) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)form\.indexMethodEconomy(?=$|:)/ })) + fireEvent.click( + screen.getByRole('button', { name: /(?:^|\.)form\.indexMethodEconomy(?=$|:)/ }), + ) expect(setIndexMethod).toHaveBeenCalledWith(IndexingType.ECONOMICAL) }) @@ -412,9 +442,16 @@ describe('IndexingSection', () => { it('should render the retrieval learn more link', () => { renderComponent() - const learnMoreLink = screen.getByRole('link', { name: /(?:^|\.)form\.retrievalSetting\.learnMore(?=$|:)/ }) - expect(learnMoreLink).toHaveAttribute('href', expect.stringContaining('setting-indexing-methods')) - expect(screen.getByText(/(?:^|\.)form\.retrievalSetting\.description(?=$|:)/)).toBeInTheDocument() + const learnMoreLink = screen.getByRole('link', { + name: /(?:^|\.)form\.retrievalSetting\.learnMore(?=$|:)/, + }) + expect(learnMoreLink).toHaveAttribute( + 'href', + expect.stringContaining('setting-indexing-methods'), + ) + expect( + screen.getByText(/(?:^|\.)form\.retrievalSetting\.description(?=$|:)/), + ).toBeInTheDocument() }) it('should render the high-quality retrieval config and propagate changes', () => { @@ -462,7 +499,9 @@ describe('IndexingSection', () => { }, }) - expect(screen.queryByText(/(?:^|\.)form\.retrievalSetting\.title(?=$|:)/)).not.toBeInTheDocument() + expect( + screen.queryByText(/(?:^|\.)form\.retrievalSetting\.title(?=$|:)/), + ).not.toBeInTheDocument() expect(screen.queryByTestId('retrieval-method-config')).not.toBeInTheDocument() expect(screen.queryByTestId('economical-retrieval-method-config')).not.toBeInTheDocument() }) diff --git a/web/app/components/datasets/settings/form/components/basic-info-section.tsx b/web/app/components/datasets/settings/form/components/basic-info-section.tsx index f7843c1fcc57f8..c604ba5f97b8ee 100644 --- a/web/app/components/datasets/settings/form/components/basic-info-section.tsx +++ b/web/app/components/datasets/settings/form/components/basic-info-section.tsx @@ -57,7 +57,9 @@ const BasicInfoSection = ({ {/* Dataset name and icon */} <div className={rowClass}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.nameAndIcon'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.nameAndIcon'], { ns: 'datasetSettings' })} + </div> </div> <div className="flex grow items-center gap-x-2"> <AppIcon @@ -73,7 +75,7 @@ const BasicInfoSection = ({ <Input disabled={!currentDataset?.embedding_available || readonly} value={name} - onChange={e => setName(e.target.value)} + onChange={(e) => setName(e.target.value)} /> </div> </div> @@ -81,16 +83,18 @@ const BasicInfoSection = ({ {/* Dataset description */} <div className={rowClass}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.desc'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.desc'], { ns: 'datasetSettings' })} + </div> </div> <div className="grow"> <Textarea - aria-label={t($ => $['form.desc'], { ns: 'datasetSettings' })} + aria-label={t(($) => $['form.desc'], { ns: 'datasetSettings' })} disabled={!currentDataset?.embedding_available || readonly} className="resize-none" - placeholder={t($ => $['form.descPlaceholder'], { ns: 'datasetSettings' }) || ''} + placeholder={t(($) => $['form.descPlaceholder'], { ns: 'datasetSettings' }) || ''} value={description} - onValueChange={value => setDescription(value)} + onValueChange={(value) => setDescription(value)} /> </div> </div> @@ -98,14 +102,16 @@ const BasicInfoSection = ({ {/* Permissions */} <div className={rowClass}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.permissions'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.permissions'], { ns: 'datasetSettings' })} + </div> </div> <div className="grow"> <PermissionSelector disabled={!currentDataset?.embedding_available || readonly} permission={permission} value={selectedMemberIDs} - onChange={v => setPermission(v)} + onChange={(v) => setPermission(v)} onMemberSelect={setSelectedMemberIDs} memberList={memberList} /> @@ -115,9 +121,11 @@ const BasicInfoSection = ({ {showAppIconPicker && ( <AppIconPicker open={showAppIconPicker} - initialEmoji={iconInfo.icon_type === 'emoji' - ? { icon: iconInfo.icon, background: iconInfo.icon_background } - : undefined} + initialEmoji={ + iconInfo.icon_type === 'emoji' + ? { icon: iconInfo.icon, background: iconInfo.icon_background } + : undefined + } onOpenChange={setShowAppIconPicker} onSelect={handleSelectAppIcon} /> diff --git a/web/app/components/datasets/settings/form/components/external-knowledge-section.tsx b/web/app/components/datasets/settings/form/components/external-knowledge-section.tsx index 8437fe561ac88c..957fcf92ad20fa 100644 --- a/web/app/components/datasets/settings/form/components/external-knowledge-section.tsx +++ b/web/app/components/datasets/settings/form/components/external-knowledge-section.tsx @@ -13,7 +13,11 @@ type ExternalKnowledgeSectionProps = { topK: number scoreThreshold: number scoreThresholdEnabled: boolean - handleSettingsChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void + handleSettingsChange: (data: { + top_k?: number + score_threshold?: number + score_threshold_enabled?: boolean + }) => void readonly?: boolean } @@ -34,7 +38,9 @@ const ExternalKnowledgeSection = ({ {/* Retrieval Settings */} <div className={rowClass}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} + </div> </div> <RetrievalSettings topK={topK} @@ -51,7 +57,9 @@ const ExternalKnowledgeSection = ({ {/* External Knowledge API */} <div className={rowClass}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.externalKnowledgeAPI'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.externalKnowledgeAPI'], { ns: 'datasetSettings' })} + </div> </div> <div className="w-full"> <div className="flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2"> @@ -70,7 +78,9 @@ const ExternalKnowledgeSection = ({ {/* External Knowledge ID */} <div className={rowClass}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.externalKnowledgeID'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.externalKnowledgeID'], { ns: 'datasetSettings' })} + </div> </div> <div className="w-full"> <div className="flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2"> diff --git a/web/app/components/datasets/settings/form/components/indexing-section.tsx b/web/app/components/datasets/settings/form/components/indexing-section.tsx index 989860365b9ce2..1a18fa247e47ba 100644 --- a/web/app/components/datasets/settings/form/components/indexing-section.tsx +++ b/web/app/components/datasets/settings/form/components/indexing-section.tsx @@ -1,5 +1,8 @@ 'use client' -import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + DefaultModel, + Model, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { DataSet, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' import { useTranslation } from 'react-i18next' @@ -54,17 +57,22 @@ const IndexingSection = ({ const { t } = useTranslation() const docLink = useDocLink() - const isShowIndexMethod = currentDataset - && currentDataset.doc_form !== ChunkingMode.parentChild - && currentDataset.indexing_technique - && indexMethod + const isShowIndexMethod = + currentDataset && + currentDataset.doc_form !== ChunkingMode.parentChild && + currentDataset.indexing_technique && + indexMethod - const showUpgradeWarning = currentDataset?.indexing_technique === IndexingType.ECONOMICAL - && indexMethod === IndexingType.QUALIFIED + const showUpgradeWarning = + currentDataset?.indexing_technique === IndexingType.ECONOMICAL && + indexMethod === IndexingType.QUALIFIED - const showSummaryIndexSetting = indexMethod === IndexingType.QUALIFIED - && [ChunkingMode.text, ChunkingMode.parentChild].includes(currentDataset?.doc_form as ChunkingMode) - && IS_CE_EDITION + const showSummaryIndexSetting = + indexMethod === IndexingType.QUALIFIED && + [ChunkingMode.text, ChunkingMode.parentChild].includes( + currentDataset?.doc_form as ChunkingMode, + ) && + IS_CE_EDITION return ( <> @@ -75,7 +83,7 @@ const IndexingSection = ({ <div className={rowClass}> <div className="flex w-[180px] shrink-0 flex-col"> <div className="flex h-8 items-center system-sm-semibold text-text-secondary"> - {t($ => $['form.chunkStructure.title'], { ns: 'datasetSettings' })} + {t(($) => $['form.chunkStructure.title'], { ns: 'datasetSettings' })} </div> <div className="body-xs-regular text-text-tertiary"> <a @@ -84,9 +92,9 @@ const IndexingSection = ({ href={docLink('/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text')} className="text-text-accent" > - {t($ => $['form.chunkStructure.learnMore'], { ns: 'datasetSettings' })} + {t(($) => $['form.chunkStructure.learnMore'], { ns: 'datasetSettings' })} </a> - {t($ => $['form.chunkStructure.description'], { ns: 'datasetSettings' })} + {t(($) => $['form.chunkStructure.description'], { ns: 'datasetSettings' })} </div> </div> <div className="grow"> @@ -104,7 +112,9 @@ const IndexingSection = ({ {!!isShowIndexMethod && ( <div className={rowClass}> <div className={labelClass}> - <div className="system-sm-semibold text-text-secondary">{t($ => $['form.indexMethod'], { ns: 'datasetSettings' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['form.indexMethod'], { ns: 'datasetSettings' })} + </div> </div> <div className="grow"> <IndexMethod @@ -122,7 +132,7 @@ const IndexingSection = ({ <span className="i-ri-alert-fill size-4 text-text-warning-secondary" /> </div> <span className="system-xs-medium text-text-primary"> - {t($ => $['form.upgradeHighQualityTip'], { ns: 'datasetSettings' })} + {t(($) => $['form.upgradeHighQualityTip'], { ns: 'datasetSettings' })} </span> </div> )} @@ -135,7 +145,7 @@ const IndexingSection = ({ <div className={rowClass}> <div className={labelClass}> <div className="system-sm-semibold text-text-secondary"> - {t($ => $['form.embeddingModel'], { ns: 'datasetSettings' })} + {t(($) => $['form.embeddingModel'], { ns: 'datasetSettings' })} </div> </div> <div className="grow"> @@ -170,7 +180,7 @@ const IndexingSection = ({ <div className={labelClass}> <div className="flex w-[180px] shrink-0 flex-col"> <div className="flex h-7 items-center pt-1 system-sm-semibold text-text-secondary"> - {t($ => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} + {t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} </div> <div className="body-xs-regular text-text-tertiary"> <a @@ -179,29 +189,27 @@ const IndexingSection = ({ href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')} className="text-text-accent" > - {t($ => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })} + {t(($) => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })} </a> - {t($ => $['form.retrievalSetting.description'], { ns: 'datasetSettings' })} + {t(($) => $['form.retrievalSetting.description'], { ns: 'datasetSettings' })} </div> </div> </div> <div className="grow"> - {indexMethod === IndexingType.QUALIFIED - ? ( - <RetrievalMethodConfig - value={retrievalConfig} - onChange={setRetrievalConfig} - showMultiModalTip={showMultiModalTip} - disabled={readonly} - /> - ) - : ( - <EconomicalRetrievalMethodConfig - value={retrievalConfig} - onChange={setRetrievalConfig} - disabled={readonly} - /> - )} + {indexMethod === IndexingType.QUALIFIED ? ( + <RetrievalMethodConfig + value={retrievalConfig} + onChange={setRetrievalConfig} + showMultiModalTip={showMultiModalTip} + disabled={readonly} + /> + ) : ( + <EconomicalRetrievalMethodConfig + value={retrievalConfig} + onChange={setRetrievalConfig} + disabled={readonly} + /> + )} </div> </div> </> diff --git a/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts b/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts index bb5ce66364bf71..8c7276877b3306 100644 --- a/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts +++ b/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts @@ -1,7 +1,12 @@ import type { DataSet } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' import { act, renderHook, waitFor } from '@testing-library/react' -import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets' +import { + ChunkingMode, + DatasetPermission, + DataSourceType, + WeightedScoreEnum, +} from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import { DatasetACLPermission } from '@/utils/permission' import { IndexingType } from '../../../../create/step-two' @@ -17,7 +22,8 @@ const mockMutateDatasets = vi.fn() const mockInvalidDatasetList = vi.fn() vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'user-1' }, @@ -25,7 +31,8 @@ vi.mock('@/context/account-state', async (importOriginal) => { })) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'user-1' }, @@ -33,7 +40,8 @@ vi.mock('@/context/workspace-state', async (importOriginal) => { })) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'user-1' }, @@ -41,7 +49,8 @@ vi.mock('@/context/permission-state', async (importOriginal) => { })) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'user-1' }, @@ -49,7 +58,8 @@ vi.mock('@/context/version-state', async (importOriginal) => { })) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessAtomMock(importOriginal, () => ({ userProfile: { id: 'user-1' }, @@ -129,7 +139,9 @@ const createDefaultMockDataset = (): DataSet => ({ let mockDataset: DataSet = createDefaultMockDataset() vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown) => { + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: DataSet | null; mutateDatasetRes: () => void }) => unknown, + ) => { const state = { dataset: mockDataset, mutateDatasetRes: mockMutateDatasets, @@ -139,7 +151,8 @@ vi.mock('@/context/dataset-detail', () => ({ })) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) @@ -157,8 +170,28 @@ vi.mock('@/service/use-common', () => ({ useMembers: () => ({ data: { accounts: [ - { id: 'user-1', name: 'User 1', email: 'user1@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, - { id: 'user-2', name: 'User 2', email: 'user2@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + { + id: 'user-1', + name: 'User 1', + email: 'user1@example.com', + role: 'owner', + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, + { + id: 'user-2', + name: 'User 2', + email: 'user2@example.com', + role: 'admin', + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, ], }, }), @@ -596,7 +629,9 @@ describe('useFormState', () => { it('should not save when already loading', async () => { const { updateDatasetSetting } = await import('@/service/datasets') - vi.mocked(updateDatasetSetting).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) + vi.mocked(updateDatasetSetting).mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), + ) const { result } = renderHook(() => useFormState()) diff --git a/web/app/components/datasets/settings/form/hooks/use-form-state.ts b/web/app/components/datasets/settings/form/hooks/use-form-state.ts index ad250cb35fcb74..33c18d18f82a13 100644 --- a/web/app/components/datasets/settings/form/hooks/use-form-state.ts +++ b/web/app/components/datasets/settings/form/hooks/use-form-state.ts @@ -30,17 +30,23 @@ const DEFAULT_APP_ICON: IconInfo = { export const useFormState = () => { const { t } = useTranslation() - const currentDataset = useDatasetDetailContextWithSelector(state => state.dataset) - const mutateDatasets = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes) + const currentDataset = useDatasetDetailContextWithSelector((state) => state.dataset) + const mutateDatasets = useDatasetDetailContextWithSelector((state) => state.mutateDatasetRes) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const datasetACLCapabilities = useMemo( - () => getDatasetACLCapabilities(currentDataset?.permission_keys, { + () => + getDatasetACLCapabilities(currentDataset?.permission_keys, { + currentUserId, + resourceMaintainer: currentDataset?.maintainer, + workspacePermissionKeys, + }), + [ + currentDataset?.maintainer, + currentDataset?.permission_keys, currentUserId, - resourceMaintainer: currentDataset?.maintainer, workspacePermissionKeys, - }), - [currentDataset?.maintainer, currentDataset?.permission_keys, currentUserId, workspacePermissionKeys], + ], ) const canEditSettings = datasetACLCapabilities.canEdit @@ -55,17 +61,25 @@ export const useFormState = () => { // Permission state const [permission, setPermission] = useState(currentDataset?.permission) - const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset?.partial_member_list || []) + const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>( + currentDataset?.partial_member_list || [], + ) // External retrieval state const [topK, setTopK] = useState(currentDataset?.external_retrieval_model.top_k ?? 2) - const [scoreThreshold, setScoreThreshold] = useState(currentDataset?.external_retrieval_model.score_threshold ?? 0.5) - const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(currentDataset?.external_retrieval_model.score_threshold_enabled ?? false) + const [scoreThreshold, setScoreThreshold] = useState( + currentDataset?.external_retrieval_model.score_threshold ?? 0.5, + ) + const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState( + currentDataset?.external_retrieval_model.score_threshold_enabled ?? false, + ) // Indexing and retrieval state const [indexMethod, setIndexMethod] = useState(currentDataset?.indexing_technique) const [keywordNumber, setKeywordNumber] = useState(currentDataset?.keyword_number ?? 10) - const [retrievalConfig, setRetrievalConfig] = useState(currentDataset?.retrieval_model_dict as RetrievalConfig) + const [retrievalConfig, setRetrievalConfig] = useState( + currentDataset?.retrieval_model_dict as RetrievalConfig, + ) const [embeddingModel, setEmbeddingModel] = useState<DefaultModel>( currentDataset?.embedding_model ? { @@ -79,7 +93,9 @@ export const useFormState = () => { ) // Summary index state - const [summaryIndexSetting, setSummaryIndexSetting] = useState(currentDataset?.summary_index_setting) + const [summaryIndexSetting, setSummaryIndexSetting] = useState( + currentDataset?.summary_index_setting, + ) // Model lists const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank) @@ -108,35 +124,34 @@ export const useFormState = () => { }, []) // External retrieval settings handler - const handleSettingsChange = useCallback((data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => { - if (data.top_k !== undefined) - setTopK(data.top_k) - if (data.score_threshold !== undefined) - setScoreThreshold(data.score_threshold) - if (data.score_threshold_enabled !== undefined) - setScoreThresholdEnabled(data.score_threshold_enabled) - }, []) + const handleSettingsChange = useCallback( + (data: { top_k?: number; score_threshold?: number; score_threshold_enabled?: boolean }) => { + if (data.top_k !== undefined) setTopK(data.top_k) + if (data.score_threshold !== undefined) setScoreThreshold(data.score_threshold) + if (data.score_threshold_enabled !== undefined) + setScoreThresholdEnabled(data.score_threshold_enabled) + }, + [], + ) // Summary index setting handler const handleSummaryIndexSettingChange = useCallback((payload: SummaryIndexSettingType) => { - setSummaryIndexSetting(prev => ({ ...prev, ...payload })) + setSummaryIndexSetting((prev) => ({ ...prev, ...payload })) }, []) // Save handler const handleSave = async () => { - if (!canEditSettings) - return + if (!canEditSettings) return - if (loading) - return + if (loading) return if (!name?.trim()) { - toast.error(t($ => $['form.nameError'], { ns: 'datasetSettings' })) + toast.error(t(($) => $['form.nameError'], { ns: 'datasetSettings' })) return } if (!isReRankModelSelected({ rerankModelList, retrievalConfig, indexMethod })) { - toast.error(t($ => $['datasetConfig.rerankModelRequired'], { ns: 'appDebug' })) + toast.error(t(($) => $['datasetConfig.rerankModelRequired'], { ns: 'appDebug' })) return } @@ -156,7 +171,9 @@ export const useFormState = () => { indexing_technique: indexMethod, retrieval_model: { ...retrievalConfig, - score_threshold: retrievalConfig.score_threshold_enabled ? retrievalConfig.score_threshold : 0, + score_threshold: retrievalConfig.score_threshold_enabled + ? retrievalConfig.score_threshold + : 0, }, embedding_model: embeddingModel.model, embedding_model_provider: embeddingModel.provider, @@ -166,7 +183,8 @@ export const useFormState = () => { if (currentDataset!.provider === 'external') { body.external_knowledge_id = currentDataset!.external_knowledge_info.external_knowledge_id - body.external_knowledge_api_id = currentDataset!.external_knowledge_info.external_knowledge_api_id + body.external_knowledge_api_id = + currentDataset!.external_knowledge_info.external_knowledge_api_id body.external_retrieval_model = { top_k: topK, score_threshold: scoreThreshold, @@ -178,23 +196,21 @@ export const useFormState = () => { body.partial_member_list = selectedMemberIDs.map((id) => { return { user_id: id, - role: memberList.find(member => member.id === id)?.role, + role: memberList.find((member) => member.id === id)?.role, } }) } await updateDatasetSetting({ datasetId: currentDataset!.id, body }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) if (mutateDatasets) { await mutateDatasets() invalidDatasetList() } - } - catch { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - } - finally { + } catch { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } finally { setLoading(false) } } @@ -212,7 +228,14 @@ export const useFormState = () => { embeddingModelList, rerankModelList, }) - }, [embeddingModel, rerankModelList, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, embeddingModelList, indexMethod]) + }, [ + embeddingModel, + rerankModelList, + retrievalConfig.reranking_enable, + retrievalConfig.reranking_model, + embeddingModelList, + indexMethod, + ]) return { // Context values diff --git a/web/app/components/datasets/settings/form/index.tsx b/web/app/components/datasets/settings/form/index.tsx index dd2c750461c890..a6353bb7e40edb 100644 --- a/web/app/components/datasets/settings/form/index.tsx +++ b/web/app/components/datasets/settings/form/index.tsx @@ -89,35 +89,33 @@ const Form = () => { readonly={readonly} /> - {isExternalProvider - ? ( - <ExternalKnowledgeSection - currentDataset={currentDataset} - topK={topK} - scoreThreshold={scoreThreshold} - scoreThresholdEnabled={scoreThresholdEnabled} - handleSettingsChange={handleSettingsChange} - readonly={readonly} - /> - ) - : ( - <IndexingSection - currentDataset={currentDataset} - indexMethod={indexMethod} - setIndexMethod={setIndexMethod} - keywordNumber={keywordNumber} - setKeywordNumber={setKeywordNumber} - embeddingModel={embeddingModel} - setEmbeddingModel={setEmbeddingModel} - embeddingModelList={embeddingModelList} - retrievalConfig={retrievalConfig} - setRetrievalConfig={setRetrievalConfig} - summaryIndexSetting={summaryIndexSetting} - handleSummaryIndexSettingChange={handleSummaryIndexSettingChange} - showMultiModalTip={showMultiModalTip} - readonly={readonly} - /> - )} + {isExternalProvider ? ( + <ExternalKnowledgeSection + currentDataset={currentDataset} + topK={topK} + scoreThreshold={scoreThreshold} + scoreThresholdEnabled={scoreThresholdEnabled} + handleSettingsChange={handleSettingsChange} + readonly={readonly} + /> + ) : ( + <IndexingSection + currentDataset={currentDataset} + indexMethod={indexMethod} + setIndexMethod={setIndexMethod} + keywordNumber={keywordNumber} + setKeywordNumber={setKeywordNumber} + embeddingModel={embeddingModel} + setEmbeddingModel={setEmbeddingModel} + embeddingModelList={embeddingModelList} + retrievalConfig={retrievalConfig} + setRetrievalConfig={setRetrievalConfig} + summaryIndexSetting={summaryIndexSetting} + handleSummaryIndexSettingChange={handleSummaryIndexSettingChange} + showMultiModalTip={showMultiModalTip} + readonly={readonly} + /> + )} <Divider type="horizontal" className="my-1 h-px bg-divider-subtle" /> @@ -132,7 +130,7 @@ const Form = () => { disabled={loading || readonly} onClick={handleSave} > - {t($ => $['form.save'], { ns: 'datasetSettings' })} + {t(($) => $['form.save'], { ns: 'datasetSettings' })} </Button> </div> </div> diff --git a/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx index eb55acfb389627..2d9c1cdf9344f1 100644 --- a/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx @@ -14,9 +14,10 @@ describe('IndexMethod', () => { vi.clearAllMocks() }) - const getKeywordSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords', { - selector: 'input[type="range"]', - }) + const getKeywordSlider = () => + screen.getByLabelText('datasetSettings.form.numberOfKeywords', { + selector: 'input[type="range"]', + }) describe('Rendering', () => { it('should render without crashing', () => { @@ -58,7 +59,9 @@ describe('IndexMethod', () => { }) it('should mark Economy as active when value is ECONOMICAL', () => { - const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} />) + const { container } = render( + <IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} />, + ) const activeCards = container.querySelectorAll('.ring-\\[1px\\]') expect(activeCards).toHaveLength(1) }) @@ -67,7 +70,9 @@ describe('IndexMethod', () => { describe('User Interactions', () => { it('should call onChange with QUALIFIED when High Quality is clicked', () => { const handleChange = vi.fn() - render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} onChange={handleChange} />) + render( + <IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} onChange={handleChange} />, + ) // Find and click High Quality option const highQualityTitle = screen.getByText(/stepTwo\.qualified/) @@ -79,7 +84,14 @@ describe('IndexMethod', () => { it('should call onChange with ECONOMICAL when Economy is clicked', () => { const handleChange = vi.fn() - render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} currentValue={IndexingType.ECONOMICAL} />) + render( + <IndexMethod + {...defaultProps} + value={IndexingType.QUALIFIED} + onChange={handleChange} + currentValue={IndexingType.ECONOMICAL} + />, + ) // Find and click Economy option - use getAllByText and get the first one (title) const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/) @@ -92,7 +104,9 @@ describe('IndexMethod', () => { it('should not call onChange when clicking already active option', () => { const handleChange = vi.fn() - render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} />) + render( + <IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} />, + ) const highQualityTitle = screen.getByText(/stepTwo\.qualified/) const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement @@ -111,7 +125,14 @@ describe('IndexMethod', () => { it('should disable Economy option when currentValue is QUALIFIED', () => { const handleChange = vi.fn() - render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} onChange={handleChange} value={IndexingType.ECONOMICAL} />) + render( + <IndexMethod + {...defaultProps} + currentValue={IndexingType.QUALIFIED} + onChange={handleChange} + value={IndexingType.ECONOMICAL} + />, + ) // Try to click Economy option - use getAllByText and get the first one (title) const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/) @@ -184,12 +205,20 @@ describe('IndexMethod', () => { describe('Props', () => { it('should update active state when value prop changes', () => { - const { rerender, container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />) + const { rerender, container } = render( + <IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />, + ) let activeCards = container.querySelectorAll('.ring-\\[1px\\]') expect(activeCards).toHaveLength(1) - rerender(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} currentValue={IndexingType.ECONOMICAL} />) + rerender( + <IndexMethod + {...defaultProps} + value={IndexingType.ECONOMICAL} + currentValue={IndexingType.ECONOMICAL} + />, + ) activeCards = container.querySelectorAll('.ring-\\[1px\\]') expect(activeCards).toHaveLength(1) diff --git a/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx index 8375e651be9df5..62900b4425b8ae 100644 --- a/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx @@ -11,19 +11,24 @@ describe('KeyWordNumber', () => { vi.clearAllMocks() }) - const getSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords', { - selector: 'input[type="range"]', - }) + const getSlider = () => + screen.getByLabelText('datasetSettings.form.numberOfKeywords', { + selector: 'input[type="range"]', + }) describe('Rendering', () => { it('should render without crashing', () => { render(<KeyWordNumber {...defaultProps} />) - expect(screen.getByText(/form\.numberOfKeywords/, { selector: '.truncate' })).toBeInTheDocument() + expect( + screen.getByText(/form\.numberOfKeywords/, { selector: '.truncate' }), + ).toBeInTheDocument() }) it('should render label text', () => { render(<KeyWordNumber {...defaultProps} />) - expect(screen.getByText(/form\.numberOfKeywords/, { selector: '.truncate' })).toBeInTheDocument() + expect( + screen.getByText(/form\.numberOfKeywords/, { selector: '.truncate' }), + ).toBeInTheDocument() }) it('should render infotip with question icon', () => { diff --git a/web/app/components/datasets/settings/index-method/index.tsx b/web/app/components/datasets/settings/index-method/index.tsx index 43c2ea62941663..d07056cf8dbec0 100644 --- a/web/app/components/datasets/settings/index-method/index.tsx +++ b/web/app/components/datasets/settings/index-method/index.tsx @@ -1,10 +1,6 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useTranslation } from 'react-i18next' import { Economic, HighQuality } from '@/app/components/base/icons/src/vender/knowledge' import { IndexingType } from '../../create/step-two' @@ -41,8 +37,8 @@ const IndexMethod = ({ onClick={onChange} icon={<HighQuality className="size-[18px]" />} iconActiveColor="text-util-colors-orange-orange-500" - title={t($ => $['stepTwo.qualified'], { ns: 'datasetCreation' })} - description={t($ => $['form.indexMethodHighQualityTip'], { ns: 'datasetSettings' })} + title={t(($) => $['stepTwo.qualified'], { ns: 'datasetCreation' })} + description={t(($) => $['form.indexMethodHighQualityTip'], { ns: 'datasetSettings' })} disabled={disabled} isRecommended effectColor={EffectColor.orange} @@ -51,19 +47,18 @@ const IndexMethod = ({ /> {/* Economy */} <Popover> - <PopoverTrigger - nativeButton={false} - openOnHover={isEconomyDisabled} - render={<div />} - > + <PopoverTrigger nativeButton={false} openOnHover={isEconomyDisabled} render={<div />}> <OptionCard id={IndexingType.ECONOMICAL} isActive={value === IndexingType.ECONOMICAL} onClick={onChange} icon={<Economic className="size-[18px]" />} iconActiveColor="text-util-colors-indigo-indigo-600" - title={t($ => $['form.indexMethodEconomy'], { ns: 'datasetSettings' })} - description={t($ => $['form.indexMethodEconomyTip'], { ns: 'datasetSettings', count: keywordNumber })} + title={t(($) => $['form.indexMethodEconomy'], { ns: 'datasetSettings' })} + description={t(($) => $['form.indexMethodEconomyTip'], { + ns: 'datasetSettings', + count: keywordNumber, + })} disabled={disabled || isEconomyDisabled} effectColor={EffectColor.indigo} showEffectColor @@ -82,7 +77,7 @@ const IndexMethod = ({ sideOffset={4} popupClassName="rounded-lg border-0 bg-components-tooltip-bg p-3 text-xs font-medium text-text-secondary shadow-lg" > - {t($ => $['form.indexMethodChangeToEconomyDisabledTip'], { ns: 'datasetSettings' })} + {t(($) => $['form.indexMethodChangeToEconomyDisabledTip'], { ns: 'datasetSettings' })} </PopoverContent> )} </Popover> diff --git a/web/app/components/datasets/settings/index-method/keyword-number.tsx b/web/app/components/datasets/settings/index-method/keyword-number.tsx index 0f8c9aef3a7348..87a7eab0370ac6 100644 --- a/web/app/components/datasets/settings/index-method/keyword-number.tsx +++ b/web/app/components/datasets/settings/index-method/keyword-number.tsx @@ -21,28 +21,23 @@ type KeyWordNumberProps = { onKeywordNumberChange: (value: number) => void } -const KeyWordNumber = ({ - keywordNumber, - onKeywordNumberChange, -}: KeyWordNumberProps) => { +const KeyWordNumber = ({ keywordNumber, onKeywordNumberChange }: KeyWordNumberProps) => { const { t } = useTranslation() - const label = t($ => $['form.numberOfKeywords'], { ns: 'datasetSettings' }) + const label = t(($) => $['form.numberOfKeywords'], { ns: 'datasetSettings' }) - const handleInputChange = useCallback((value: number | null) => { - onKeywordNumberChange(value ?? MIN_KEYWORD_NUMBER) - }, [onKeywordNumberChange]) + const handleInputChange = useCallback( + (value: number | null) => { + onKeywordNumberChange(value ?? MIN_KEYWORD_NUMBER) + }, + [onKeywordNumberChange], + ) return ( <Fieldset className="flex items-center gap-x-1"> <FieldsetLegend className="sr-only">{label}</FieldsetLegend> <div className="flex grow items-center gap-x-0.5"> - <div className="truncate system-xs-medium text-text-secondary"> - {label} - </div> - <Infotip - aria-label={label} - className="size-3.5" - > + <div className="truncate system-xs-medium text-text-secondary">{label}</div> + <Infotip aria-label={label} className="size-3.5"> {label} </Infotip> </div> diff --git a/web/app/components/datasets/settings/option-card.tsx b/web/app/components/datasets/settings/option-card.tsx index 4160bd20ce42b7..9501227ebfdd01 100644 --- a/web/app/components/datasets/settings/option-card.tsx +++ b/web/app/components/datasets/settings/option-card.tsx @@ -53,70 +53,52 @@ const OptionCard = <T,>({ ref={ref} className={cn( 'cursor-pointer overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg', - isActive && 'border border-components-option-card-option-selected-border ring-[1px] ring-components-option-card-option-selected-border', + isActive && + 'border border-components-option-card-option-selected-border ring-[1px] ring-components-option-card-option-selected-border', disabled && 'cursor-not-allowed opacity-50', )} onClick={() => { - if (isActive || disabled) - return + if (isActive || disabled) return onClick?.(id) }} > - <div className={cn( - 'relative flex rounded-t-xl p-2', - className, - )} - > - { - effectColor && showEffectColor && ( - <div className={cn( + <div className={cn('relative flex rounded-t-xl p-2', className)}> + {effectColor && showEffectColor && ( + <div + className={cn( 'absolute top-[-2px] left-[-2px] h-14 w-14 rounded-full blur-[80px]', `${HEADER_EFFECT_MAP[effectColor]}`, )} - /> - ) - } - { - !!icon && ( - <div className={cn( + /> + )} + {!!icon && ( + <div + className={cn( 'flex size-6 shrink-0 items-center justify-center text-text-tertiary', isActive && iconActiveColor, )} - > - {icon} - </div> - ) - } + > + {icon} + </div> + )} <div className="flex grow flex-col gap-y-0.5 py-px"> <div className="flex items-center gap-x-1"> - <span className="system-sm-medium text-text-secondary"> - {title} - </span> - { - isRecommended && ( - <Badge className="h-[18px] border-text-accent-secondary text-text-accent-secondary"> - {t($ => $['stepTwo.recommend'], { ns: 'datasetCreation' })} - </Badge> - ) - } + <span className="system-sm-medium text-text-secondary">{title}</span> + {isRecommended && ( + <Badge className="h-[18px] border-text-accent-secondary text-text-accent-secondary"> + {t(($) => $['stepTwo.recommend'], { ns: 'datasetCreation' })} + </Badge> + )} </div> - { - description && ( - <div className="system-xs-regular text-text-tertiary"> - {description} - </div> - ) - } + {description && <div className="system-xs-regular text-text-tertiary">{description}</div>} </div> </div> - { - !!(children && showChildren) && ( - <div className="relative rounded-b-xl bg-components-panel-bg p-4"> - <ArrowShape className="absolute top-[-11px] left-[14px] size-4 text-components-panel-bg" /> - {children} - </div> - ) - } + {!!(children && showChildren) && ( + <div className="relative rounded-b-xl bg-components-panel-bg p-4"> + <ArrowShape className="absolute top-[-11px] left-[14px] size-4 text-components-panel-bg" /> + {children} + </div> + )} </div> ) } diff --git a/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx b/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx index 784c16f18439e9..bac29cc4f63db5 100644 --- a/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx @@ -17,53 +17,123 @@ const mockAppContextState = vi.hoisted(() => ({ let mockIsRbacEnabled = false vi.mock('@/context/account-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/permission-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/version-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createDatasetAccessAtomMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') - - return createDatasetAccessAtomMock(importOriginal, () => mockAppContextState, () => ({ - isRbacEnabled: mockIsRbacEnabled, - })) + const { createDatasetAccessAtomMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') + + return createDatasetAccessAtomMock( + importOriginal, + () => mockAppContextState, + () => ({ + isRbacEnabled: mockIsRbacEnabled, + }), + ) }) vi.mock('jotai', async (importOriginal) => { - const { createDatasetAccessJotaiMock } = await import('@/app/components/datasets/__tests__/mock-dataset-access') + const { createDatasetAccessJotaiMock } = + await import('@/app/components/datasets/__tests__/mock-dataset-access') return createDatasetAccessJotaiMock(importOriginal) }) describe('PermissionSelector', () => { const mockMemberList: Member[] = [ - { id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', roles: [], last_login_at: '', created_at: '', status: 'active' }!, - { id: 'user-2', name: 'John Doe', email: 'john@example.com', avatar: '', avatar_url: '', role: 'admin', roles: [], last_login_at: '', created_at: '', status: 'active' }!, - { id: 'user-3', name: 'Jane Smith', email: 'jane@example.com', avatar: '', avatar_url: '', role: 'editor', roles: [], last_login_at: '', created_at: '', status: 'active' }!, - { id: 'user-4', name: 'Dataset Operator', email: 'operator@example.com', avatar: '', avatar_url: '', role: 'dataset_operator', roles: [], last_login_at: '', created_at: '', status: 'active' }!, + { + id: 'user-1', + name: 'Current User', + email: 'current@example.com', + avatar: '', + avatar_url: '', + role: 'owner', + roles: [], + last_login_at: '', + created_at: '', + status: 'active', + }!, + { + id: 'user-2', + name: 'John Doe', + email: 'john@example.com', + avatar: '', + avatar_url: '', + role: 'admin', + roles: [], + last_login_at: '', + created_at: '', + status: 'active', + }!, + { + id: 'user-3', + name: 'Jane Smith', + email: 'jane@example.com', + avatar: '', + avatar_url: '', + role: 'editor', + roles: [], + last_login_at: '', + created_at: '', + status: 'active', + }!, + { + id: 'user-4', + name: 'Dataset Operator', + email: 'operator@example.com', + avatar: '', + avatar_url: '', + role: 'dataset_operator', + roles: [], + last_login_at: '', + created_at: '', + status: 'active', + }!, ] const defaultProps = { @@ -86,12 +156,16 @@ describe('PermissionSelector', () => { }) it('should render Only Me option when permission is onlyMe', () => { - renderWithSystemFeatures(<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />) + renderWithSystemFeatures( + <PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />, + ) expect(screen.getByText(/form\.permissionsOnlyMe/))!.toBeInTheDocument() }) it('should render All Team Members option when permission is allTeamMembers', () => { - renderWithSystemFeatures(<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />) + renderWithSystemFeatures( + <PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />, + ) expect(screen.getByText(/form\.permissionsAllMember/))!.toBeInTheDocument() }) @@ -136,7 +210,13 @@ describe('PermissionSelector', () => { describe('Permission Selection', () => { it('should call onChange with onlyMe when Only Me is selected', async () => { const handleChange = vi.fn() - renderWithSystemFeatures(<PermissionSelector {...defaultProps} onChange={handleChange} permission={DatasetPermission.allTeamMembers} />) + renderWithSystemFeatures( + <PermissionSelector + {...defaultProps} + onChange={handleChange} + permission={DatasetPermission.allTeamMembers} + />, + ) const trigger = screen.getByText(/form\.permissionsAllMember/) fireEvent.click(trigger) @@ -191,10 +271,7 @@ describe('PermissionSelector', () => { describe('Member Selection', () => { it('should show member list when partialMembers is selected', async () => { renderWithSystemFeatures( - <PermissionSelector - {...defaultProps} - permission={DatasetPermission.partialMembers} - />, + <PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />, ) const trigger = screen.getByTitle(/Current User/) @@ -255,10 +332,7 @@ describe('PermissionSelector', () => { describe('Search Functionality', () => { it('should allow typing in search input', async () => { renderWithSystemFeatures( - <PermissionSelector - {...defaultProps} - permission={DatasetPermission.partialMembers} - />, + <PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />, ) const trigger = screen.getByTitle(/Current User/) @@ -274,10 +348,7 @@ describe('PermissionSelector', () => { it('should render search input in partial members mode', async () => { renderWithSystemFeatures( - <PermissionSelector - {...defaultProps} - permission={DatasetPermission.partialMembers} - />, + <PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />, ) const trigger = screen.getByTitle(/Current User/) @@ -290,10 +361,7 @@ describe('PermissionSelector', () => { it('should filter members after debounce completes', async () => { renderWithSystemFeatures( - <PermissionSelector - {...defaultProps} - permission={DatasetPermission.partialMembers} - />, + <PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />, ) const trigger = screen.getByTitle(/Current User/) @@ -316,10 +384,7 @@ describe('PermissionSelector', () => { it('should handle clear search functionality', async () => { renderWithSystemFeatures( - <PermissionSelector - {...defaultProps} - permission={DatasetPermission.partialMembers} - />, + <PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />, ) const trigger = screen.getByTitle(/Current User/) @@ -343,10 +408,7 @@ describe('PermissionSelector', () => { it('should filter members by email', async () => { renderWithSystemFeatures( - <PermissionSelector - {...defaultProps} - permission={DatasetPermission.partialMembers} - />, + <PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />, ) const trigger = screen.getByTitle(/Current User/) @@ -369,10 +431,7 @@ describe('PermissionSelector', () => { it('should show no results message when search matches nothing', async () => { renderWithSystemFeatures( - <PermissionSelector - {...defaultProps} - permission={DatasetPermission.partialMembers} - />, + <PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />, ) const trigger = screen.getByTitle(/Current User/) @@ -395,10 +454,7 @@ describe('PermissionSelector', () => { it('should show current user when search matches user name', async () => { renderWithSystemFeatures( - <PermissionSelector - {...defaultProps} - permission={DatasetPermission.partialMembers} - />, + <PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />, ) const trigger = screen.getByTitle(/Current User/) @@ -421,10 +477,7 @@ describe('PermissionSelector', () => { it('should show current user when search matches user email', async () => { renderWithSystemFeatures( - <PermissionSelector - {...defaultProps} - permission={DatasetPermission.partialMembers} - />, + <PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />, ) const trigger = screen.getByTitle(/Current User/) @@ -446,7 +499,9 @@ describe('PermissionSelector', () => { describe('Disabled State', () => { it('should apply disabled styles when disabled', () => { - const { container } = renderWithSystemFeatures(<PermissionSelector {...defaultProps} disabled={true} />) + const { container } = renderWithSystemFeatures( + <PermissionSelector {...defaultProps} disabled={true} />, + ) // When disabled, the component has cursor-not-allowed! class (escaped in Tailwind) const triggerElement = container.querySelector('[class*="cursor-not-allowed"]') expect(triggerElement)!.toBeInTheDocument() @@ -501,22 +556,14 @@ describe('PermissionSelector', () => { describe('Edge Cases', () => { it('should handle empty member list', () => { - renderWithSystemFeatures( - <PermissionSelector - {...defaultProps} - memberList={[]} - />, - ) + renderWithSystemFeatures(<PermissionSelector {...defaultProps} memberList={[]} />) expect(screen.getByText(/form\.permissionsOnlyMe/))!.toBeInTheDocument() }) it('should handle member list with only current user', () => { renderWithSystemFeatures( - <PermissionSelector - {...defaultProps} - memberList={[mockMemberList[0]!]} - />, + <PermissionSelector {...defaultProps} memberList={[mockMemberList[0]!]} />, ) expect(screen.getByText(/form\.permissionsOnlyMe/))!.toBeInTheDocument() @@ -528,7 +575,18 @@ describe('PermissionSelector', () => { // This is tested indirectly through the memberList filtering const memberListWithNormalUser: Member[] = [ ...mockMemberList, - { id: 'user-5', name: 'Normal User', email: 'normal@example.com', avatar: '', avatar_url: '', role: 'normal', roles: [], last_login_at: '', created_at: '', status: 'active' }, + { + id: 'user-5', + name: 'Normal User', + email: 'normal@example.com', + avatar: '', + avatar_url: '', + role: 'normal', + roles: [], + last_login_at: '', + created_at: '', + status: 'active', + }, ] renderWithSystemFeatures( @@ -547,11 +605,15 @@ describe('PermissionSelector', () => { describe('Props', () => { it('should update when permission prop changes', () => { - const { rerender } = renderWithSystemFeatures(<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />) + const { rerender } = renderWithSystemFeatures( + <PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />, + ) expect(screen.getByText(/form\.permissionsOnlyMe/))!.toBeInTheDocument() - rerender(<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />) + rerender( + <PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />, + ) expect(screen.getByText(/form\.permissionsAllMember/))!.toBeInTheDocument() }) diff --git a/web/app/components/datasets/settings/permission-selector/__tests__/member-item.spec.tsx b/web/app/components/datasets/settings/permission-selector/__tests__/member-item.spec.tsx index bd3d83013747e9..912af72cf17de7 100644 --- a/web/app/components/datasets/settings/permission-selector/__tests__/member-item.spec.tsx +++ b/web/app/components/datasets/settings/permission-selector/__tests__/member-item.spec.tsx @@ -168,7 +168,7 @@ describe('MemberItem', () => { }) it('should handle special characters in name', () => { - const specialName = 'O\'Connor-Smith' + const specialName = "O'Connor-Smith" render(<MemberItem {...defaultProps} name={specialName} />) expect(screen.getByText(specialName)).toBeInTheDocument() }) diff --git a/web/app/components/datasets/settings/permission-selector/index.tsx b/web/app/components/datasets/settings/permission-selector/index.tsx index 23672e8c32a882..c6424bda5cae7e 100644 --- a/web/app/components/datasets/settings/permission-selector/index.tsx +++ b/web/app/components/datasets/settings/permission-selector/index.tsx @@ -2,11 +2,7 @@ import type { Member } from '@/models/common' import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useDebounceFn } from 'ahooks' import { useAtomValue } from 'jotai' import { useCallback, useMemo, useState } from 'react' @@ -41,24 +37,30 @@ const PermissionSelector = ({ const [keywords, setKeywords] = useState('') const [searchKeywords, setSearchKeywords] = useState('') - const { run: handleSearch } = useDebounceFn(() => { - setSearchKeywords(keywords) - }, { wait: 500 }) + const { run: handleSearch } = useDebounceFn( + () => { + setSearchKeywords(keywords) + }, + { wait: 500 }, + ) const handleKeywordsChange = (value: string) => { setKeywords(value) handleSearch() } - const selectMember = useCallback((member: Member) => { - if (value.includes(member.id)) - onMemberSelect(value.filter(v => v !== member.id)) - else - onMemberSelect([...value, member.id]) - }, [value, onMemberSelect]) + const selectMember = useCallback( + (member: Member) => { + if (value.includes(member.id)) onMemberSelect(value.filter((v) => v !== member.id)) + else onMemberSelect([...value, member.id]) + }, + [value, onMemberSelect], + ) const selectedMembers = useMemo(() => { return [ userProfile, - ...memberList.filter(member => member.id !== userProfile.id).filter(member => value.includes(member.id)), + ...memberList + .filter((member) => member.id !== userProfile.id) + .filter((member) => value.includes(member.id)), ] }, [userProfile, value, memberList]) @@ -67,7 +69,11 @@ const PermissionSelector = ({ }, [searchKeywords, userProfile]) const filteredMemberList = useMemo(() => { - return memberList.filter(member => (member.name.includes(searchKeywords) || member.email.includes(searchKeywords)) && member.id !== userProfile.id) + return memberList.filter( + (member) => + (member.name.includes(searchKeywords) || member.email.includes(searchKeywords)) && + member.id !== userProfile.id, + ) }, [memberList, searchKeywords, userProfile]) const onSelectOnlyMe = useCallback(() => { @@ -88,7 +94,7 @@ const PermissionSelector = ({ const isOnlyMe = permission === DatasetPermission.onlyMe const isAllTeamMembers = permission === DatasetPermission.allTeamMembers const isPartialMembers = permission === DatasetPermission.partialMembers - const selectedMemberNames = selectedMembers.map(member => member.name).join(', ') + const selectedMemberNames = selectedMembers.map((member) => member.name).join(', ') const isDisabledByRBAC = isRbacEnabled const isDisabled = disabled || isDisabledByRBAC @@ -96,98 +102,94 @@ const PermissionSelector = ({ <Popover open={open} onOpenChange={(nextOpen) => { - if (isDisabled) - return + if (isDisabled) return setOpen(nextOpen) }} > <div className="relative"> <PopoverTrigger - render={( - <div className={cn('group flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt', isDisabled && 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!')}> + render={ + <div + className={cn( + 'group flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt', + isDisabled && + 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!', + )} + > {isDisabledByRBAC && ( <> <div className="flex size-6 shrink-0 items-center justify-center"> <span className="i-ri-lock-2-line size-4 text-text-tertiary" /> </div> <div className="grow p-1 system-sm-regular text-components-input-text-placeholder"> - {t($ => $['form.permissionsAccessConfig'], { ns: 'datasetSettings' })} + {t(($) => $['form.permissionsAccessConfig'], { ns: 'datasetSettings' })} </div> </> )} - { - !isDisabledByRBAC && isOnlyMe && ( - <> - <div className="flex size-6 shrink-0 items-center justify-center"> - <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" /> - </div> - <div className="grow p-1 system-sm-regular text-components-input-text-filled"> - {t($ => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })} - </div> - </> - ) - } - { - !isDisabledByRBAC && isAllTeamMembers && ( - <> - <div className="flex size-6 shrink-0 items-center justify-center"> - <span className="i-ri-group-2-line size-4 text-text-secondary" /> - </div> - <div className="grow p-1 system-sm-regular text-components-input-text-filled"> - {t($ => $['form.permissionsAllMember'], { ns: 'datasetSettings' })} - </div> - </> - ) - } - { - !isDisabledByRBAC && isPartialMembers && ( - <> - <div className="relative flex size-6 shrink-0 items-center justify-center"> - { - selectedMembers.length === 1 && ( - <Avatar - avatar={selectedMembers[0]!.avatar_url} - name={selectedMembers[0]!.name} - size="xs" - /> - ) - } - { - selectedMembers.length >= 2 && ( - <> - <Avatar - avatar={selectedMembers[0]!.avatar_url} - name={selectedMembers[0]!.name} - className="absolute top-0 left-0 z-0" - size="xxs" - /> - <Avatar - avatar={selectedMembers[1]!.avatar_url} - name={selectedMembers[1]!.name} - className="absolute right-0 bottom-0 z-10" - size="xxs" - /> - </> - ) - } - </div> - <div - title={selectedMemberNames} - className="grow truncate p-1 system-sm-regular text-components-input-text-filled" - > - {selectedMemberNames} - </div> - </> - ) - } - <span className={cn( - 'i-ri-arrow-down-s-line', - 'size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary group-data-popup-open:text-text-secondary', - isDisabled && 'text-components-input-text-placeholder!', + {!isDisabledByRBAC && isOnlyMe && ( + <> + <div className="flex size-6 shrink-0 items-center justify-center"> + <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" /> + </div> + <div className="grow p-1 system-sm-regular text-components-input-text-filled"> + {t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })} + </div> + </> )} + {!isDisabledByRBAC && isAllTeamMembers && ( + <> + <div className="flex size-6 shrink-0 items-center justify-center"> + <span className="i-ri-group-2-line size-4 text-text-secondary" /> + </div> + <div className="grow p-1 system-sm-regular text-components-input-text-filled"> + {t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })} + </div> + </> + )} + {!isDisabledByRBAC && isPartialMembers && ( + <> + <div className="relative flex size-6 shrink-0 items-center justify-center"> + {selectedMembers.length === 1 && ( + <Avatar + avatar={selectedMembers[0]!.avatar_url} + name={selectedMembers[0]!.name} + size="xs" + /> + )} + {selectedMembers.length >= 2 && ( + <> + <Avatar + avatar={selectedMembers[0]!.avatar_url} + name={selectedMembers[0]!.name} + className="absolute top-0 left-0 z-0" + size="xxs" + /> + <Avatar + avatar={selectedMembers[1]!.avatar_url} + name={selectedMembers[1]!.name} + className="absolute right-0 bottom-0 z-10" + size="xxs" + /> + </> + )} + </div> + <div + title={selectedMemberNames} + className="grow truncate p-1 system-sm-regular text-components-input-text-filled" + > + {selectedMemberNames} + </div> + </> + )} + <span + className={cn( + 'i-ri-arrow-down-s-line', + 'size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary group-data-popup-open:text-text-secondary', + isDisabled && 'text-components-input-text-placeholder!', + )} /> </div> - )} + } /> <PopoverContent placement="bottom-start" @@ -199,31 +201,36 @@ const PermissionSelector = ({ {/* Only me */} <Item leftIcon={ - <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className="shrink-0" size="sm" /> + <Avatar + avatar={userProfile.avatar_url} + name={userProfile.name} + className="shrink-0" + size="sm" + /> } - text={t($ => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })} + text={t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })} onClick={onSelectOnlyMe} isSelected={isOnlyMe} /> {/* All team members */} <Item - leftIcon={( + leftIcon={ <div className="flex size-6 shrink-0 items-center justify-center"> <span className="i-ri-group-2-line size-4 text-text-secondary" /> </div> - )} - text={t($ => $['form.permissionsAllMember'], { ns: 'datasetSettings' })} + } + text={t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })} onClick={onSelectAllMembers} isSelected={isAllTeamMembers} /> {/* Partial members */} <Item - leftIcon={( + leftIcon={ <div className="flex size-6 shrink-0 items-center justify-center"> <span className="i-ri-lock-2-line size-4 text-text-secondary" /> </div> - )} - text={t($ => $['form.permissionsInvitedMembers'], { ns: 'datasetSettings' })} + } + text={t(($) => $['form.permissionsInvitedMembers'], { ns: 'datasetSettings' })} onClick={onSelectPartialMembers} isSelected={isPartialMembers} /> @@ -236,17 +243,20 @@ const PermissionSelector = ({ <Input className={cn('w-full pl-[26px]', keywords && 'pr-[26px]')} value={keywords} - placeholder={t($ => $['operation.search'], { ns: 'common' }) || ''} - onChange={e => handleKeywordsChange(e.target.value)} + placeholder={t(($) => $['operation.search'], { ns: 'common' }) || ''} + onChange={(e) => handleKeywordsChange(e.target.value)} /> {!!keywords && ( <button type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} className="group absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer border-none bg-transparent p-px" onClick={() => handleKeywordsChange('')} > - <span className="i-ri-close-circle-fill size-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary" aria-hidden="true" /> + <span + className="i-ri-close-circle-fill size-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary" + aria-hidden="true" + /> </button> )} </div> @@ -255,7 +265,12 @@ const PermissionSelector = ({ {showMe && ( <MemberItem leftIcon={ - <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className="shrink-0" size="sm" /> + <Avatar + avatar={userProfile.avatar_url} + name={userProfile.name} + className="shrink-0" + size="sm" + /> } name={userProfile.name} email={userProfile.email} @@ -263,11 +278,16 @@ const PermissionSelector = ({ isMe /> )} - {filteredMemberList.map(member => ( + {filteredMemberList.map((member) => ( <MemberItem key={member.id} leftIcon={ - <Avatar avatar={member.avatar_url} name={member.name} className="shrink-0" size="sm" /> + <Avatar + avatar={member.avatar_url} + name={member.name} + className="shrink-0" + size="sm" + /> } name={member.name} email={member.email} @@ -275,13 +295,11 @@ const PermissionSelector = ({ onClick={selectMember.bind(null, member)} /> ))} - { - !showMe && filteredMemberList.length === 0 && ( - <div className="flex items-center justify-center px-1 py-6 text-center system-xs-regular whitespace-pre-wrap text-text-tertiary"> - {t($ => $['form.onSearchResults'], { ns: 'datasetSettings' })} - </div> - ) - } + {!showMe && filteredMemberList.length === 0 && ( + <div className="flex items-center justify-center px-1 py-6 text-center system-xs-regular whitespace-pre-wrap text-text-tertiary"> + {t(($) => $['form.onSearchResults'], { ns: 'datasetSettings' })} + </div> + )} </div> </div> )} diff --git a/web/app/components/datasets/settings/permission-selector/member-item.tsx b/web/app/components/datasets/settings/permission-selector/member-item.tsx index 48c10e007197f0..30c44d81f68838 100644 --- a/web/app/components/datasets/settings/permission-selector/member-item.tsx +++ b/web/app/components/datasets/settings/permission-selector/member-item.tsx @@ -33,13 +33,15 @@ const MemberItem = ({ {name} {isMe && ( <span className="system-xs-regular text-text-tertiary"> - {t($ => $['form.me'], { ns: 'datasetSettings' })} + {t(($) => $['form.me'], { ns: 'datasetSettings' })} </span> )} </div> <div className="truncate system-xs-regular text-text-tertiary">{email}</div> </div> - {isSelected && <RiCheckLine className={cn('size-4 shrink-0 text-text-accent', isMe && 'opacity-30')} />} + {isSelected && ( + <RiCheckLine className={cn('size-4 shrink-0 text-text-accent', isMe && 'opacity-30')} /> + )} </div> ) } diff --git a/web/app/components/datasets/settings/permission-selector/permission-item.tsx b/web/app/components/datasets/settings/permission-selector/permission-item.tsx index 28e1c78be2063e..0cea3a79707d74 100644 --- a/web/app/components/datasets/settings/permission-selector/permission-item.tsx +++ b/web/app/components/datasets/settings/permission-selector/permission-item.tsx @@ -8,21 +8,14 @@ type PermissionItemProps = { isSelected: boolean } -const PermissionItem = ({ - leftIcon, - text, - onClick, - isSelected, -}: PermissionItemProps) => { +const PermissionItem = ({ leftIcon, text, onClick, isSelected }: PermissionItemProps) => { return ( <div className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1 hover:bg-state-base-hover" onClick={onClick} > {leftIcon} - <div className="grow px-1 system-md-regular text-text-secondary"> - {text} - </div> + <div className="grow px-1 system-md-regular text-text-secondary">{text}</div> {isSelected && <RiCheckLine className="size-4 text-text-accent" />} </div> ) diff --git a/web/app/components/datasets/settings/summary-index-setting.tsx b/web/app/components/datasets/settings/summary-index-setting.tsx index c332e531d71657..44e44831ed4e38 100644 --- a/web/app/components/datasets/settings/summary-index-setting.tsx +++ b/web/app/components/datasets/settings/summary-index-setting.tsx @@ -2,11 +2,7 @@ import type { DefaultModel } from '@/app/components/header/account-setting/model import type { SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets' import { Switch } from '@langgenius/dify-ui/switch' import { Textarea } from '@langgenius/dify-ui/textarea' -import { - memo, - useCallback, - useMemo, -} from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -26,9 +22,7 @@ const SummaryIndexSetting = ({ readonly = false, }: SummaryIndexSettingProps) => { const { t } = useTranslation() - const { - data: textGenerationModelList, - } = useModelList(ModelTypeEnum.textGeneration) + const { data: textGenerationModelList } = useModelList(ModelTypeEnum.textGeneration) const summaryIndexModelConfig = useMemo(() => { if (!summaryIndexSetting?.model_name || !summaryIndexSetting?.model_provider_name) return undefined @@ -39,36 +33,45 @@ const SummaryIndexSetting = ({ } }, [summaryIndexSetting?.model_name, summaryIndexSetting?.model_provider_name]) - const handleSummaryIndexEnableChange = useCallback((value: boolean) => { - onSummaryIndexSettingChange?.({ - enable: value, - }) - }, [onSummaryIndexSettingChange]) + const handleSummaryIndexEnableChange = useCallback( + (value: boolean) => { + onSummaryIndexSettingChange?.({ + enable: value, + }) + }, + [onSummaryIndexSettingChange], + ) - const handleSummaryIndexModelChange = useCallback((model: DefaultModel) => { - onSummaryIndexSettingChange?.({ - model_provider_name: model.provider, - model_name: model.model, - }) - }, [onSummaryIndexSettingChange]) + const handleSummaryIndexModelChange = useCallback( + (model: DefaultModel) => { + onSummaryIndexSettingChange?.({ + model_provider_name: model.provider, + model_name: model.model, + }) + }, + [onSummaryIndexSettingChange], + ) - const handleSummaryIndexPromptChange = useCallback((value: string) => { - onSummaryIndexSettingChange?.({ - summary_prompt: value, - }) - }, [onSummaryIndexSettingChange]) + const handleSummaryIndexPromptChange = useCallback( + (value: string) => { + onSummaryIndexSettingChange?.({ + summary_prompt: value, + }) + }, + [onSummaryIndexSettingChange], + ) if (entry === 'knowledge-base') { return ( <div> <div className="flex h-6 items-center justify-between"> <div className="flex items-center system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['form.summaryAutoGen'], { ns: 'datasetSettings' })} + {t(($) => $['form.summaryAutoGen'], { ns: 'datasetSettings' })} <Infotip - aria-label={t($ => $['form.summaryAutoGenTip'], { ns: 'datasetSettings' })} + aria-label={t(($) => $['form.summaryAutoGenTip'], { ns: 'datasetSettings' })} className="ml-1" > - {t($ => $['form.summaryAutoGenTip'], { ns: 'datasetSettings' })} + {t(($) => $['form.summaryAutoGenTip'], { ns: 'datasetSettings' })} </Infotip> </div> <Switch @@ -78,32 +81,37 @@ const SummaryIndexSetting = ({ disabled={readonly} /> </div> - { - summaryIndexSetting?.enable && ( - <div> - <div className="mt-2 mb-1.5 flex h-6 items-center system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['form.summaryModel'], { ns: 'datasetSettings' })} - </div> - <ModelSelector - defaultModel={summaryIndexModelConfig && { provider: summaryIndexModelConfig.providerName, model: summaryIndexModelConfig.modelName }} - modelList={textGenerationModelList} - onSelect={handleSummaryIndexModelChange} - readonly={readonly} - showDeprecatedWarnIcon - /> - <div className="mt-3 flex h-6 items-center system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['form.summaryInstructions'], { ns: 'datasetSettings' })} - </div> - <Textarea - aria-label={t($ => $['form.summaryInstructions'], { ns: 'datasetSettings' })} - value={summaryIndexSetting?.summary_prompt ?? ''} - onValueChange={handleSummaryIndexPromptChange} - disabled={readonly} - placeholder={t($ => $['form.summaryInstructionsPlaceholder'], { ns: 'datasetSettings' })} - /> + {summaryIndexSetting?.enable && ( + <div> + <div className="mt-2 mb-1.5 flex h-6 items-center system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['form.summaryModel'], { ns: 'datasetSettings' })} </div> - ) - } + <ModelSelector + defaultModel={ + summaryIndexModelConfig && { + provider: summaryIndexModelConfig.providerName, + model: summaryIndexModelConfig.modelName, + } + } + modelList={textGenerationModelList} + onSelect={handleSummaryIndexModelChange} + readonly={readonly} + showDeprecatedWarnIcon + /> + <div className="mt-3 flex h-6 items-center system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['form.summaryInstructions'], { ns: 'datasetSettings' })} + </div> + <Textarea + aria-label={t(($) => $['form.summaryInstructions'], { ns: 'datasetSettings' })} + value={summaryIndexSetting?.summary_prompt ?? ''} + onValueChange={handleSummaryIndexPromptChange} + disabled={readonly} + placeholder={t(($) => $['form.summaryInstructionsPlaceholder'], { + ns: 'datasetSettings', + })} + /> + </div> + )} </div> ) } @@ -114,7 +122,7 @@ const SummaryIndexSetting = ({ <div className="flex gap-x-1"> <div className="flex h-7 w-[180px] shrink-0 items-center pt-1"> <div className="system-sm-semibold text-text-secondary"> - {t($ => $['form.summaryAutoGen'], { ns: 'datasetSettings' })} + {t(($) => $['form.summaryAutoGen'], { ns: 'datasetSettings' })} </div> </div> <div className="py-1.5"> @@ -126,59 +134,62 @@ const SummaryIndexSetting = ({ size="md" disabled={readonly} /> - { - summaryIndexSetting?.enable ? t($ => $['list.status.enabled'], { ns: 'datasetDocuments' }) : t($ => $['list.status.disabled'], { ns: 'datasetDocuments' }) - } + {summaryIndexSetting?.enable + ? t(($) => $['list.status.enabled'], { ns: 'datasetDocuments' }) + : t(($) => $['list.status.disabled'], { ns: 'datasetDocuments' })} </div> <div className="mt-2 system-sm-regular text-text-tertiary"> - { - summaryIndexSetting?.enable && t($ => $['form.summaryAutoGenTip'], { ns: 'datasetSettings' }) - } - { - !summaryIndexSetting?.enable && t($ => $['form.summaryAutoGenEnableTip'], { ns: 'datasetSettings' }) - } + {summaryIndexSetting?.enable && + t(($) => $['form.summaryAutoGenTip'], { ns: 'datasetSettings' })} + {!summaryIndexSetting?.enable && + t(($) => $['form.summaryAutoGenEnableTip'], { ns: 'datasetSettings' })} </div> </div> </div> - { - summaryIndexSetting?.enable && ( - <> - <div className="flex gap-x-1"> - <div className="flex h-7 w-[180px] shrink-0 items-center pt-1"> - <div className="system-sm-medium text-text-tertiary"> - {t($ => $['form.summaryModel'], { ns: 'datasetSettings' })} - </div> - </div> - <div className="grow"> - <ModelSelector - defaultModel={summaryIndexModelConfig && { provider: summaryIndexModelConfig.providerName, model: summaryIndexModelConfig.modelName }} - modelList={textGenerationModelList} - onSelect={handleSummaryIndexModelChange} - readonly={readonly} - showDeprecatedWarnIcon - triggerClassName="h-8" - /> + {summaryIndexSetting?.enable && ( + <> + <div className="flex gap-x-1"> + <div className="flex h-7 w-[180px] shrink-0 items-center pt-1"> + <div className="system-sm-medium text-text-tertiary"> + {t(($) => $['form.summaryModel'], { ns: 'datasetSettings' })} </div> </div> - <div className="flex"> - <div className="flex h-7 w-[180px] shrink-0 items-center pt-1"> - <div className="system-sm-medium text-text-tertiary"> - {t($ => $['form.summaryInstructions'], { ns: 'datasetSettings' })} - </div> - </div> - <div className="grow"> - <Textarea - aria-label={t($ => $['form.summaryInstructions'], { ns: 'datasetSettings' })} - value={summaryIndexSetting?.summary_prompt ?? ''} - onValueChange={handleSummaryIndexPromptChange} - disabled={readonly} - placeholder={t($ => $['form.summaryInstructionsPlaceholder'], { ns: 'datasetSettings' })} - /> + <div className="grow"> + <ModelSelector + defaultModel={ + summaryIndexModelConfig && { + provider: summaryIndexModelConfig.providerName, + model: summaryIndexModelConfig.modelName, + } + } + modelList={textGenerationModelList} + onSelect={handleSummaryIndexModelChange} + readonly={readonly} + showDeprecatedWarnIcon + triggerClassName="h-8" + /> + </div> + </div> + <div className="flex"> + <div className="flex h-7 w-[180px] shrink-0 items-center pt-1"> + <div className="system-sm-medium text-text-tertiary"> + {t(($) => $['form.summaryInstructions'], { ns: 'datasetSettings' })} </div> </div> - </> - ) - } + <div className="grow"> + <Textarea + aria-label={t(($) => $['form.summaryInstructions'], { ns: 'datasetSettings' })} + value={summaryIndexSetting?.summary_prompt ?? ''} + onValueChange={handleSummaryIndexPromptChange} + disabled={readonly} + placeholder={t(($) => $['form.summaryInstructionsPlaceholder'], { + ns: 'datasetSettings', + })} + /> + </div> + </div> + </> + )} </div> ) } @@ -194,40 +205,45 @@ const SummaryIndexSetting = ({ disabled={readonly} /> <div className="system-sm-semibold text-text-secondary"> - {t($ => $['form.summaryAutoGen'], { ns: 'datasetSettings' })} + {t(($) => $['form.summaryAutoGen'], { ns: 'datasetSettings' })} </div> </div> - { - summaryIndexSetting?.enable && ( - <> - <div> - <div className="mb-1.5 flex h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $['form.summaryModel'], { ns: 'datasetSettings' })} - </div> - <ModelSelector - defaultModel={summaryIndexModelConfig && { provider: summaryIndexModelConfig.providerName, model: summaryIndexModelConfig.modelName }} - modelList={textGenerationModelList} - onSelect={handleSummaryIndexModelChange} - readonly={readonly} - showDeprecatedWarnIcon - triggerClassName="h-8" - /> + {summaryIndexSetting?.enable && ( + <> + <div> + <div className="mb-1.5 flex h-6 items-center system-sm-medium text-text-secondary"> + {t(($) => $['form.summaryModel'], { ns: 'datasetSettings' })} </div> - <div> - <div className="mb-1.5 flex h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $['form.summaryInstructions'], { ns: 'datasetSettings' })} - </div> - <Textarea - aria-label={t($ => $['form.summaryInstructions'], { ns: 'datasetSettings' })} - value={summaryIndexSetting?.summary_prompt ?? ''} - onValueChange={handleSummaryIndexPromptChange} - disabled={readonly} - placeholder={t($ => $['form.summaryInstructionsPlaceholder'], { ns: 'datasetSettings' })} - /> + <ModelSelector + defaultModel={ + summaryIndexModelConfig && { + provider: summaryIndexModelConfig.providerName, + model: summaryIndexModelConfig.modelName, + } + } + modelList={textGenerationModelList} + onSelect={handleSummaryIndexModelChange} + readonly={readonly} + showDeprecatedWarnIcon + triggerClassName="h-8" + /> + </div> + <div> + <div className="mb-1.5 flex h-6 items-center system-sm-medium text-text-secondary"> + {t(($) => $['form.summaryInstructions'], { ns: 'datasetSettings' })} </div> - </> - ) - } + <Textarea + aria-label={t(($) => $['form.summaryInstructions'], { ns: 'datasetSettings' })} + value={summaryIndexSetting?.summary_prompt ?? ''} + onValueChange={handleSummaryIndexPromptChange} + disabled={readonly} + placeholder={t(($) => $['form.summaryInstructionsPlaceholder'], { + ns: 'datasetSettings', + })} + /> + </div> + </> + )} </div> ) } diff --git a/web/app/components/datasets/settings/utils/__tests__/index.spec.ts b/web/app/components/datasets/settings/utils/__tests__/index.spec.ts index 9a51873b1f8add..808816a02ac922 100644 --- a/web/app/components/datasets/settings/utils/__tests__/index.spec.ts +++ b/web/app/components/datasets/settings/utils/__tests__/index.spec.ts @@ -1,5 +1,14 @@ -import type { DefaultModel, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + DefaultModel, + Model, + ModelItem, +} from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + ConfigurationMethodEnum, + ModelFeatureEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { IndexingType } from '../../../create/step-two' import { checkShowMultiModalTip } from '../index' @@ -42,11 +51,7 @@ describe('checkShowMultiModalTip', () => { createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]), ]), ], - rerankModelList: [ - createModelProvider('cohere', [ - createModelItem('rerank-english-v2.0', []), - ]), - ], + rerankModelList: [createModelProvider('cohere', [createModelItem('rerank-english-v2.0', [])])], } describe('Return false conditions', () => { @@ -194,11 +199,7 @@ describe('checkShowMultiModalTip', () => { createModelItem('azure-embedding', [ModelFeatureEnum.vision]), ]), ], - rerankModelList: [ - createModelProvider('jina', [ - createModelItem('jina-reranker', []), - ]), - ], + rerankModelList: [createModelProvider('jina', [createModelItem('jina-reranker', [])])], }) expect(result).toBe(true) }) @@ -236,9 +237,7 @@ describe('checkShowMultiModalTip', () => { const result = checkShowMultiModalTip({ ...defaultProps, - embeddingModelList: [ - createModelProvider('openai', [modelItem]), - ], + embeddingModelList: [createModelProvider('openai', [modelItem])], }) expect(result).toBe(false) }) @@ -258,9 +257,7 @@ describe('checkShowMultiModalTip', () => { const result = checkShowMultiModalTip({ ...defaultProps, - embeddingModelList: [ - createModelProvider('openai', [modelItem]), - ], + embeddingModelList: [createModelProvider('openai', [modelItem])], }) expect(result).toBe(false) }) @@ -283,9 +280,7 @@ describe('checkShowMultiModalTip', () => { const result = checkShowMultiModalTip({ ...defaultProps, embeddingModelList: [ - createModelProvider('azure', [ - createModelItem('azure-model', []), - ]), + createModelProvider('azure', [createModelItem('azure-model', [])]), createModelProvider('openai', [ createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]), ]), diff --git a/web/app/components/datasets/settings/utils/index.tsx b/web/app/components/datasets/settings/utils/index.tsx index dcf591aae9189e..beb07e38e2c0a9 100644 --- a/web/app/components/datasets/settings/utils/index.tsx +++ b/web/app/components/datasets/settings/utils/index.tsx @@ -1,4 +1,7 @@ -import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + DefaultModel, + Model, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { IndexingType } from '../../create/step-two' @@ -24,24 +27,30 @@ export const checkShowMultiModalTip = ({ }: ShowMultiModalTipProps) => { if (indexMethod !== IndexingType.QUALIFIED || !embeddingModel.provider || !embeddingModel.model) return false - const currentEmbeddingModelProvider = embeddingModelList.find(model => model.provider === embeddingModel.provider) - if (!currentEmbeddingModelProvider) - return false - const currentEmbeddingModel = currentEmbeddingModelProvider.models.find(model => model.model === embeddingModel.model) - if (!currentEmbeddingModel) - return false - const isCurrentEmbeddingModelSupportMultiModal = !!currentEmbeddingModel.features?.includes(ModelFeatureEnum.vision) - if (!isCurrentEmbeddingModelSupportMultiModal) - return false + const currentEmbeddingModelProvider = embeddingModelList.find( + (model) => model.provider === embeddingModel.provider, + ) + if (!currentEmbeddingModelProvider) return false + const currentEmbeddingModel = currentEmbeddingModelProvider.models.find( + (model) => model.model === embeddingModel.model, + ) + if (!currentEmbeddingModel) return false + const isCurrentEmbeddingModelSupportMultiModal = !!currentEmbeddingModel.features?.includes( + ModelFeatureEnum.vision, + ) + if (!isCurrentEmbeddingModelSupportMultiModal) return false const { rerankingModelName, rerankingProviderName } = rerankModel - if (!rerankingEnable || !rerankingModelName || !rerankingProviderName) - return false - const currentRerankingModelProvider = rerankModelList.find(model => model.provider === rerankingProviderName) - if (!currentRerankingModelProvider) - return false - const currentRerankingModel = currentRerankingModelProvider.models.find(model => model.model === rerankingModelName) - if (!currentRerankingModel) - return false - const isRerankingModelSupportMultiModal = !!currentRerankingModel.features?.includes(ModelFeatureEnum.vision) + if (!rerankingEnable || !rerankingModelName || !rerankingProviderName) return false + const currentRerankingModelProvider = rerankModelList.find( + (model) => model.provider === rerankingProviderName, + ) + if (!currentRerankingModelProvider) return false + const currentRerankingModel = currentRerankingModelProvider.models.find( + (model) => model.model === rerankingModelName, + ) + if (!currentRerankingModel) return false + const isRerankingModelSupportMultiModal = !!currentRerankingModel.features?.includes( + ModelFeatureEnum.vision, + ) return !isRerankingModelSupportMultiModal } diff --git a/web/app/components/detail-sidebar/__tests__/index.spec.tsx b/web/app/components/detail-sidebar/__tests__/index.spec.tsx index c8bb4ee2aa1433..c59069cc52508e 100644 --- a/web/app/components/detail-sidebar/__tests__/index.spec.tsx +++ b/web/app/components/detail-sidebar/__tests__/index.spec.tsx @@ -3,10 +3,13 @@ import { DetailSidebarFrame } from '..' import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage' const { hotkeyRegistrations } = vi.hoisted(() => ({ - hotkeyRegistrations: new Map<string, { - handler: (event: { preventDefault: () => void }) => void - options?: { ignoreInputs?: boolean } - }>(), + hotkeyRegistrations: new Map< + string, + { + handler: (event: { preventDefault: () => void }) => void + options?: { ignoreInputs?: boolean } + } + >(), })) const mockAppContextState = vi.hoisted(() => ({ current: { @@ -52,19 +55,24 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('@/app/components/main-nav/components/account-section', () => ({ default: ({ compact }: { compact?: boolean }) => ( - <button type="button" aria-label="account">{compact ? 'Compact account' : 'Expanded account'}</button> + <button type="button" aria-label="account"> + {compact ? 'Compact account' : 'Expanded account'} + </button> ), })) vi.mock('@/app/components/main-nav/components/help-menu', () => ({ default: ({ triggerClassName }: { triggerClassName?: string }) => ( - <button type="button" aria-label="help" className={triggerClassName}>Help</button> + <button type="button" aria-label="help" className={triggerClassName}> + Help + </button> ), })) @@ -77,11 +85,15 @@ function renderDetailSidebarFrame() { <DetailSidebarFrame renderTop={({ expand, onToggle }) => ( <div data-testid="detail-top" data-expand={expand}> - <button type="button" data-testid="detail-toggle" onClick={onToggle}>Toggle</button> + <button type="button" data-testid="detail-toggle" onClick={onToggle}> + Toggle + </button> </div> )} renderSection={({ expand }) => ( - <div data-testid="detail-section" data-expand={expand}>Section</div> + <div data-testid="detail-section" data-expand={expand}> + Section + </div> )} />, ) diff --git a/web/app/components/detail-sidebar/index.tsx b/web/app/components/detail-sidebar/index.tsx index 5d6538a0c13bc5..fba1442ac4e3c9 100644 --- a/web/app/components/detail-sidebar/index.tsx +++ b/web/app/components/detail-sidebar/index.tsx @@ -22,18 +22,13 @@ type DetailSidebarFrameProps = { renderSection: (props: Pick<DetailSidebarRenderProps, 'expand'>) => ReactNode } -const secondarySidebarHelpTriggerIcon = <span aria-hidden className="i-ri-question-line size-4 shrink-0" /> +const secondarySidebarHelpTriggerIcon = ( + <span aria-hidden className="i-ri-question-line size-4 shrink-0" /> +) -function SecondarySidebarHelpMenu({ - triggerClassName, -}: { - triggerClassName?: string -}) { +function SecondarySidebarHelpMenu({ triggerClassName }: { triggerClassName?: string }) { return ( - <HelpMenu - triggerIcon={secondarySidebarHelpTriggerIcon} - triggerClassName={triggerClassName} - /> + <HelpMenu triggerIcon={secondarySidebarHelpTriggerIcon} triggerClassName={triggerClassName} /> ) } @@ -47,11 +42,16 @@ export function DetailSidebarFrame({ const detailNavigationMode = storedDetailSidebarExpand === 'collapse' ? 'collapse' : 'expand' const detailNavigationExpanded = detailNavigationMode === 'expand' const [detailNavigationHoverPreviewOpen, setDetailNavigationHoverPreviewOpen] = useState(false) - const [detailNavigationTransitionDisabled, setDetailNavigationTransitionDisabled] = useState(false) - const closeDetailNavigationHoverPreviewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) + const [detailNavigationTransitionDisabled, setDetailNavigationTransitionDisabled] = + useState(false) + const closeDetailNavigationHoverPreviewTimerRef = useRef<ReturnType<typeof setTimeout> | null>( + null, + ) const detailNavigationTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) - const isDetailNavigationHoverPreviewOpen = !detailNavigationExpanded && detailNavigationHoverPreviewOpen - const detailNavigationVisibleExpanded = detailNavigationExpanded || isDetailNavigationHoverPreviewOpen + const isDetailNavigationHoverPreviewOpen = + !detailNavigationExpanded && detailNavigationHoverPreviewOpen + const detailNavigationVisibleExpanded = + detailNavigationExpanded || isDetailNavigationHoverPreviewOpen const bottomNavigationExpanded = detailNavigationVisibleExpanded const currentEnv = langGeniusVersionInfo?.current_env const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT' @@ -76,8 +76,7 @@ export function DetailSidebarFrame({ }, [detailNavigationExpanded, isDetailNavigationHoverPreviewOpen, setStoredDetailSidebarExpand]) const openDetailNavigationHoverPreview = useCallback(() => { - if (detailNavigationExpanded) - return + if (detailNavigationExpanded) return if (closeDetailNavigationHoverPreviewTimerRef.current) clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current) @@ -103,19 +102,27 @@ export function DetailSidebarFrame({ } }, []) - useHotkey('Mod+B', (e) => { - e.preventDefault() - handleToggleDetailNavigation() - }, { - ignoreInputs: false, - }) + useHotkey( + 'Mod+B', + (e) => { + e.preventDefault() + handleToggleDetailNavigation() + }, + { + ignoreInputs: false, + }, + ) return ( <aside className={cn( 'relative flex h-full shrink-0 bg-background-body p-1', detailNavigationTransitionDisabled ? 'transition-none' : 'transition-all', - isDetailNavigationHoverPreviewOpen ? 'w-16 overflow-visible' : detailNavigationExpanded ? 'w-62 overflow-hidden' : 'w-16 overflow-hidden', + isDetailNavigationHoverPreviewOpen + ? 'w-16 overflow-visible' + : detailNavigationExpanded + ? 'w-62 overflow-hidden' + : 'w-16 overflow-hidden', className, )} > @@ -144,29 +151,28 @@ export function DetailSidebarFrame({ </div> )} </div> - <div className={cn( - !bottomNavigationExpanded - ? 'flex w-full shrink-0 flex-col items-center gap-0.5 rounded-lg px-2 pt-1 pb-3' - : 'flex w-60 items-center justify-between bg-components-panel-bg py-3 pr-1 pl-3', - )} + <div + className={cn( + !bottomNavigationExpanded + ? 'flex w-full shrink-0 flex-col items-center gap-0.5 rounded-lg px-2 pt-1 pb-3' + : 'flex w-60 items-center justify-between bg-components-panel-bg py-3 pr-1 pl-3', + )} > - {!bottomNavigationExpanded - ? ( - <> - <SecondarySidebarHelpMenu triggerClassName="mb-2" /> - <AccountSection compact /> - </> - ) - : ( - <> - <div className="flex min-w-0 items-center gap-1 overflow-hidden"> - <AccountSection /> - </div> - <div className="flex shrink-0 items-center justify-center rounded-full p-1"> - <SecondarySidebarHelpMenu /> - </div> - </> - )} + {!bottomNavigationExpanded ? ( + <> + <SecondarySidebarHelpMenu triggerClassName="mb-2" /> + <AccountSection compact /> + </> + ) : ( + <> + <div className="flex min-w-0 items-center gap-1 overflow-hidden"> + <AccountSection /> + </div> + <div className="flex shrink-0 items-center justify-center rounded-full p-1"> + <SecondarySidebarHelpMenu /> + </div> + </> + )} </div> </div> </aside> diff --git a/web/app/components/detail-sidebar/storage.ts b/web/app/components/detail-sidebar/storage.ts index 2fb03d235953b6..a572104b8e1e2f 100644 --- a/web/app/components/detail-sidebar/storage.ts +++ b/web/app/components/detail-sidebar/storage.ts @@ -4,13 +4,7 @@ type DetailSidebarMode = 'expand' | 'collapse' export const DETAIL_SIDEBAR_STORAGE_KEY = 'app-detail-collapse-or-expand' -const [ - useDetailSidebarMode, - _useDetailSidebarModeValue, - useSetDetailSidebarMode, -] = createLocalStorageState<DetailSidebarMode>(DETAIL_SIDEBAR_STORAGE_KEY, 'expand', { raw: true }) +const [useDetailSidebarMode, _useDetailSidebarModeValue, useSetDetailSidebarMode] = + createLocalStorageState<DetailSidebarMode>(DETAIL_SIDEBAR_STORAGE_KEY, 'expand', { raw: true }) -export { - useDetailSidebarMode, - useSetDetailSidebarMode, -} +export { useDetailSidebarMode, useSetDetailSidebarMode } diff --git a/web/app/components/develop/ApiServer.tsx b/web/app/components/develop/ApiServer.tsx index f9fe0e14a4cbbc..837015cb48173d 100644 --- a/web/app/components/develop/ApiServer.tsx +++ b/web/app/components/develop/ApiServer.tsx @@ -10,29 +10,25 @@ type ApiServerProps = { appId?: string canManageApiKey?: boolean } -const ApiServer: FC<ApiServerProps> = ({ - apiBaseUrl, - appId, - canManageApiKey = false, -}) => { +const ApiServer: FC<ApiServerProps> = ({ apiBaseUrl, appId, canManageApiKey = false }) => { const { t } = useTranslation() return ( <div className="flex flex-wrap items-center gap-y-2"> <div className="mr-2 flex h-8 items-center rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal pr-1 pl-1.5 leading-5"> - <div className="mr-0.5 h-5 shrink-0 rounded-md border border-divider-subtle px-1.5 text-[11px] text-text-tertiary">{t($ => $.apiServer, { ns: 'appApi' })}</div> - <div className="w-fit truncate px-1 text-[13px] font-medium text-text-secondary sm:w-[248px]">{apiBaseUrl}</div> + <div className="mr-0.5 h-5 shrink-0 rounded-md border border-divider-subtle px-1.5 text-[11px] text-text-tertiary"> + {t(($) => $.apiServer, { ns: 'appApi' })} + </div> + <div className="w-fit truncate px-1 text-[13px] font-medium text-text-secondary sm:w-[248px]"> + {apiBaseUrl} + </div> <div className="mx-1 h-[14px] w-px bg-divider-regular"></div> <CopyFeedback content={apiBaseUrl} /> </div> <div className="mr-2 flex h-8 items-center rounded-lg border-[0.5px] border-[#D1FADF] bg-[#ECFDF3] px-3 text-xs font-semibold text-[#039855]"> - {t($ => $.ok, { ns: 'appApi' })} + {t(($) => $.ok, { ns: 'appApi' })} </div> - <SecretKeyButton - className="h-8! shrink-0" - appId={appId} - canManage={canManageApiKey} - /> + <SecretKeyButton className="h-8! shrink-0" appId={appId} canManage={canManageApiKey} /> </div> ) } diff --git a/web/app/components/develop/__tests__/ApiServer.spec.tsx b/web/app/components/develop/__tests__/ApiServer.spec.tsx index 3d2fe4e53dd737..f1ab2a91a9f37d 100644 --- a/web/app/components/develop/__tests__/ApiServer.spec.tsx +++ b/web/app/components/develop/__tests__/ApiServer.spec.tsx @@ -4,15 +4,14 @@ import { act } from 'react' import ApiServer from '../ApiServer' vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ - default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( - isShow - ? ( - <div role="dialog" aria-label="Secret key"> - <button type="button" onClick={onClose}>Close Modal</button> - </div> - ) - : null - ), + default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) => + isShow ? ( + <div role="dialog" aria-label="Secret key"> + <button type="button" onClick={onClose}> + Close Modal + </button> + </div> + ) : null, })) describe('ApiServer', () => { diff --git a/web/app/components/develop/__tests__/code.spec.tsx b/web/app/components/develop/__tests__/code.spec.tsx index 8508095411c096..76d07e1567b415 100644 --- a/web/app/components/develop/__tests__/code.spec.tsx +++ b/web/app/components/develop/__tests__/code.spec.tsx @@ -29,7 +29,11 @@ describe('code.tsx components', () => { }) it('should pass through additional props', () => { - render(<Embed value="content" data-testid="embed-test" className="embed-class">children</Embed>) + render( + <Embed value="content" data-testid="embed-test" className="embed-class"> + children + </Embed>, + ) const embed = screen.getByTestId('embed-test') expect(embed).toHaveClass('embed-class') }) @@ -46,10 +50,12 @@ describe('code.tsx components', () => { it('should render code from targetCode string', () => { render( <CodeGroup targetCode="const hello = 'world'"> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) - expect(screen.getByText('const hello = \'world\'')).toBeInTheDocument() + expect(screen.getByText("const hello = 'world'")).toBeInTheDocument() }) }) @@ -58,7 +64,9 @@ describe('code.tsx components', () => { const examples = [{ code: 'single example' }] render( <CodeGroup targetCode={examples}> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByText('single example')).toBeInTheDocument() @@ -71,7 +79,9 @@ describe('code.tsx components', () => { ] render( <CodeGroup targetCode={examples}> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByRole('tab', { name: 'JavaScript' })).toBeInTheDocument() @@ -85,7 +95,9 @@ describe('code.tsx components', () => { ] render( <CodeGroup targetCode={examples}> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByText('first content')).toBeInTheDocument() @@ -99,7 +111,9 @@ describe('code.tsx components', () => { ] render( <CodeGroup targetCode={examples}> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) await act(async () => { @@ -118,13 +132,12 @@ describe('code.tsx components', () => { }) it('should use "Code" as default title when title not provided', () => { - const examples = [ - { code: 'example 1' }, - { code: 'example 2' }, - ] + const examples = [{ code: 'example 1' }, { code: 'example 2' }] render( <CodeGroup targetCode={examples}> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) const codeTabs = screen.getAllByRole('tab', { name: 'Code' }) @@ -136,7 +149,9 @@ describe('code.tsx components', () => { it('should render title in an h3 heading', () => { render( <CodeGroup title="API Example" targetCode="code"> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) const h3 = screen.getByRole('heading', { level: 3 }) @@ -148,7 +163,9 @@ describe('code.tsx components', () => { it('should render tag in code panel header', () => { render( <CodeGroup tag="GET" targetCode="code"> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByText('GET')).toBeInTheDocument() @@ -157,7 +174,9 @@ describe('code.tsx components', () => { it('should render label in code panel header', () => { render( <CodeGroup label="/api/users" targetCode="code"> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByText('/api/users')).toBeInTheDocument() @@ -166,7 +185,9 @@ describe('code.tsx components', () => { it('should render both tag and label together', () => { render( <CodeGroup tag="POST" label="/api/create" targetCode="code"> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByText('POST')).toBeInTheDocument() @@ -178,7 +199,9 @@ describe('code.tsx components', () => { it('should show "Copy" text initially', () => { render( <CodeGroup targetCode="code"> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByText('Copy')).toBeInTheDocument() @@ -190,7 +213,9 @@ describe('code.tsx components', () => { render( <CodeGroup targetCode="code to copy"> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) await act(async () => { @@ -214,7 +239,9 @@ describe('code.tsx components', () => { render( <CodeGroup targetCode="code"> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) await act(async () => { @@ -244,7 +271,9 @@ describe('code.tsx components', () => { it('should render children when no targetCode provided', () => { render( <CodeGroup> - <pre><code>child code content</code></pre> + <pre> + <code>child code content</code> + </pre> </CodeGroup>, ) expect(screen.getByText('child code content')).toBeInTheDocument() @@ -256,7 +285,9 @@ describe('code.tsx components', () => { it('should render when tag is provided', () => { render( <CodeGroup tag="GET" targetCode="code"> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByText('GET')).toBeInTheDocument() @@ -265,7 +296,9 @@ describe('code.tsx components', () => { it('should render when label is provided', () => { render( <CodeGroup label="/api/endpoint" targetCode="code"> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByText('/api/endpoint')).toBeInTheDocument() @@ -280,7 +313,9 @@ describe('code.tsx components', () => { ] render( <CodeGroup targetCode={examples}> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByRole('tablist')).toHaveClass('-mb-px', 'gap-4', 'bg-transparent') @@ -292,7 +327,9 @@ describe('code.tsx components', () => { it('should render code in a pre element', () => { render( <CodeGroup targetCode="pre content"> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) const preElement = screen.getByText('pre content').closest('pre') @@ -304,7 +341,9 @@ describe('code.tsx components', () => { it('should render clipboard SVG icon in copy button', () => { render( <CodeGroup targetCode="code"> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) const copyButton = screen.getByRole('button') @@ -318,7 +357,9 @@ describe('code.tsx components', () => { it('should handle empty string targetCode', () => { render( <CodeGroup targetCode=""> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByRole('button')).toBeInTheDocument() @@ -328,7 +369,9 @@ describe('code.tsx components', () => { const specialCode = '<div class="test">&</div>' render( <CodeGroup targetCode={specialCode}> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByText(specialCode)).toBeInTheDocument() @@ -340,7 +383,9 @@ line2 line3` render( <CodeGroup targetCode={multilineCode}> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByText(/line1/)).toBeInTheDocument() @@ -349,12 +394,12 @@ line3` }) it('should handle examples with tag property', () => { - const examples = [ - { title: 'Example', tag: 'v1', code: 'versioned code' }, - ] + const examples = [{ title: 'Example', tag: 'v1', code: 'versioned code' }] render( <CodeGroup targetCode={examples}> - <pre><code>fallback</code></pre> + <pre> + <code>fallback</code> + </pre> </CodeGroup>, ) expect(screen.getByText('versioned code')).toBeInTheDocument() diff --git a/web/app/components/develop/__tests__/doc.spec.tsx b/web/app/components/develop/__tests__/doc.spec.tsx index b5db99974ace3e..6277e1088faea9 100644 --- a/web/app/components/develop/__tests__/doc.spec.tsx +++ b/web/app/components/develop/__tests__/doc.spec.tsx @@ -60,14 +60,15 @@ vi.mock('@/i18n-config/language', () => ({ })) describe('Doc', () => { - const makeAppDetail = (mode: AppModeEnum, variables: Array<{ key: string, name: string }> = []) => ({ - mode, - model_config: { - configs: { - prompt_variables: variables, + const makeAppDetail = (mode: AppModeEnum, variables: Array<{ key: string; name: string }> = []) => + ({ + mode, + model_config: { + configs: { + prompt_variables: variables, + }, }, - }, - }) as unknown as Parameters<typeof Doc>[0]['appDetail'] + }) as unknown as Parameters<typeof Doc>[0]['appDetail'] beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/develop/__tests__/index.spec.tsx b/web/app/components/develop/__tests__/index.spec.tsx index 61dd43c0949012..48f7b59423d54b 100644 --- a/web/app/components/develop/__tests__/index.spec.tsx +++ b/web/app/components/develop/__tests__/index.spec.tsx @@ -11,21 +11,14 @@ vi.mock('@/app/components/app/store', () => ({ vi.mock('@/app/components/develop/doc', () => ({ default: ({ appDetail }: { appDetail: { name?: string } | null }) => ( - <div data-testid="doc-component"> - Doc Component - - {appDetail?.name} - </div> + <div data-testid="doc-component">Doc Component -{appDetail?.name}</div> ), })) vi.mock('@/app/components/develop/ApiServer', () => ({ - default: ({ apiBaseUrl, appId }: { apiBaseUrl: string, appId: string }) => ( + default: ({ apiBaseUrl, appId }: { apiBaseUrl: string; appId: string }) => ( <div data-testid="api-server"> - API Server - - {apiBaseUrl} - {' '} - - - {appId} + API Server -{apiBaseUrl} -{appId} </div> ), })) diff --git a/web/app/components/develop/__tests__/md.spec.tsx b/web/app/components/develop/__tests__/md.spec.tsx index 10854c1cee79cd..4e3144229d9e6f 100644 --- a/web/app/components/develop/__tests__/md.spec.tsx +++ b/web/app/components/develop/__tests__/md.spec.tsx @@ -322,89 +322,53 @@ describe('md.tsx components', () => { } it('should render name in code element', () => { - render( - <Property {...defaultProps}> - User identifier - </Property>, - ) + render(<Property {...defaultProps}>User identifier</Property>) const code = screen.getByText('user_id') expect(code.tagName).toBe('CODE') }) it('should render type', () => { - render( - <Property {...defaultProps}> - User identifier - </Property>, - ) + render(<Property {...defaultProps}>User identifier</Property>) expect(screen.getByText('string')).toBeInTheDocument() }) it('should render children as description', () => { - render( - <Property {...defaultProps}> - User identifier - </Property>, - ) + render(<Property {...defaultProps}>User identifier</Property>) expect(screen.getByText('User identifier')).toBeInTheDocument() }) it('should render as li element', () => { - const { container } = render( - <Property {...defaultProps}> - Description - </Property>, - ) + const { container } = render(<Property {...defaultProps}>Description</Property>) expect(container.querySelector('li')).toBeInTheDocument() }) it('should have m-0 class on li', () => { - const { container } = render( - <Property {...defaultProps}> - Description - </Property>, - ) + const { container } = render(<Property {...defaultProps}>Description</Property>) const li = container.querySelector('li')! expect(li.className).toContain('m-0') }) it('should have padding classes on li', () => { - const { container } = render( - <Property {...defaultProps}> - Description - </Property>, - ) + const { container } = render(<Property {...defaultProps}>Description</Property>) const li = container.querySelector('li')! expect(li.className).toContain('px-0') expect(li.className).toContain('py-4') }) it('should have first:pt-0 and last:pb-0 classes', () => { - const { container } = render( - <Property {...defaultProps}> - Description - </Property>, - ) + const { container } = render(<Property {...defaultProps}>Description</Property>) const li = container.querySelector('li')! expect(li.className).toContain('first:pt-0') expect(li.className).toContain('last:pb-0') }) it('should render dl element with proper structure', () => { - const { container } = render( - <Property {...defaultProps}> - Description - </Property>, - ) + const { container } = render(<Property {...defaultProps}>Description</Property>) expect(container.querySelector('dl')).toBeInTheDocument() }) it('should have sr-only dt elements for accessibility', () => { - const { container } = render( - <Property {...defaultProps}> - User identifier - </Property>, - ) + const { container } = render(<Property {...defaultProps}>User identifier</Property>) const dtElements = container.querySelectorAll('dt') expect(dtElements.length).toBe(3) dtElements.forEach((dt) => { @@ -413,11 +377,7 @@ describe('md.tsx components', () => { }) it('should have font-mono class on type', () => { - render( - <Property {...defaultProps}> - Description - </Property>, - ) + render(<Property {...defaultProps}>Description</Property>) const typeElement = screen.getByText('string') expect(typeElement.className).toContain('font-mono') expect(typeElement.className).toContain('text-xs') @@ -432,87 +392,53 @@ describe('md.tsx components', () => { } it('should render name in code element', () => { - render( - <SubProperty {...defaultProps}> - Sub field description - </SubProperty>, - ) + render(<SubProperty {...defaultProps}>Sub field description</SubProperty>) const code = screen.getByText('sub_field') expect(code.tagName).toBe('CODE') }) it('should render type', () => { - render( - <SubProperty {...defaultProps}> - Sub field description - </SubProperty>, - ) + render(<SubProperty {...defaultProps}>Sub field description</SubProperty>) expect(screen.getByText('number')).toBeInTheDocument() }) it('should render children as description', () => { - render( - <SubProperty {...defaultProps}> - Sub field description - </SubProperty>, - ) + render(<SubProperty {...defaultProps}>Sub field description</SubProperty>) expect(screen.getByText('Sub field description')).toBeInTheDocument() }) it('should render as li element', () => { - const { container } = render( - <SubProperty {...defaultProps}> - Description - </SubProperty>, - ) + const { container } = render(<SubProperty {...defaultProps}>Description</SubProperty>) expect(container.querySelector('li')).toBeInTheDocument() }) it('should have m-0 class on li', () => { - const { container } = render( - <SubProperty {...defaultProps}> - Description - </SubProperty>, - ) + const { container } = render(<SubProperty {...defaultProps}>Description</SubProperty>) const li = container.querySelector('li')! expect(li.className).toContain('m-0') }) it('should have different padding than Property (py-1 vs py-4)', () => { - const { container } = render( - <SubProperty {...defaultProps}> - Description - </SubProperty>, - ) + const { container } = render(<SubProperty {...defaultProps}>Description</SubProperty>) const li = container.querySelector('li')! expect(li.className).toContain('px-0') expect(li.className).toContain('py-1') }) it('should have last:pb-0 class', () => { - const { container } = render( - <SubProperty {...defaultProps}> - Description - </SubProperty>, - ) + const { container } = render(<SubProperty {...defaultProps}>Description</SubProperty>) const li = container.querySelector('li')! expect(li.className).toContain('last:pb-0') }) it('should render dl element with proper structure', () => { - const { container } = render( - <SubProperty {...defaultProps}> - Description - </SubProperty>, - ) + const { container } = render(<SubProperty {...defaultProps}>Description</SubProperty>) expect(container.querySelector('dl')).toBeInTheDocument() }) it('should have sr-only dt elements for accessibility', () => { const { container } = render( - <SubProperty {...defaultProps}> - Sub field description - </SubProperty>, + <SubProperty {...defaultProps}>Sub field description</SubProperty>, ) const dtElements = container.querySelectorAll('dt') expect(dtElements.length).toBe(3) @@ -522,11 +448,7 @@ describe('md.tsx components', () => { }) it('should have font-mono and text-xs on type', () => { - render( - <SubProperty {...defaultProps}> - Description - </SubProperty>, - ) + render(<SubProperty {...defaultProps}>Description</SubProperty>) const typeElement = screen.getByText('number') expect(typeElement.className).toContain('font-mono') expect(typeElement.className).toContain('text-xs') diff --git a/web/app/components/develop/__tests__/tag.spec.tsx b/web/app/components/develop/__tests__/tag.spec.tsx index 10cc5b57b20895..b48a7feb00c142 100644 --- a/web/app/components/develop/__tests__/tag.spec.tsx +++ b/web/app/components/develop/__tests__/tag.spec.tsx @@ -141,7 +141,11 @@ describe('Tag', () => { describe('color styles for medium variant', () => { it('should apply full emerald medium styles', () => { - render(<Tag color="emerald" variant="medium">TEST</Tag>) + render( + <Tag color="emerald" variant="medium"> + TEST + </Tag>, + ) const tag = screen.getByText('TEST') expect(tag.className).toContain('ring-emerald-300') expect(tag.className).toContain('bg-emerald-400/10') @@ -149,7 +153,11 @@ describe('Tag', () => { }) it('should apply full sky medium styles', () => { - render(<Tag color="sky" variant="medium">TEST</Tag>) + render( + <Tag color="sky" variant="medium"> + TEST + </Tag>, + ) const tag = screen.getByText('TEST') expect(tag.className).toContain('ring-sky-300') expect(tag.className).toContain('bg-sky-400/10') @@ -157,7 +165,11 @@ describe('Tag', () => { }) it('should apply full amber medium styles', () => { - render(<Tag color="amber" variant="medium">TEST</Tag>) + render( + <Tag color="amber" variant="medium"> + TEST + </Tag>, + ) const tag = screen.getByText('TEST') expect(tag.className).toContain('ring-amber-300') expect(tag.className).toContain('bg-amber-400/10') @@ -165,7 +177,11 @@ describe('Tag', () => { }) it('should apply full rose medium styles', () => { - render(<Tag color="rose" variant="medium">TEST</Tag>) + render( + <Tag color="rose" variant="medium"> + TEST + </Tag>, + ) const tag = screen.getByText('TEST') expect(tag.className).toContain('ring-rose-200') expect(tag.className).toContain('bg-rose-50') @@ -173,7 +189,11 @@ describe('Tag', () => { }) it('should apply full zinc medium styles', () => { - render(<Tag color="zinc" variant="medium">TEST</Tag>) + render( + <Tag color="zinc" variant="medium"> + TEST + </Tag>, + ) const tag = screen.getByText('TEST') expect(tag.className).toContain('ring-zinc-200') expect(tag.className).toContain('bg-zinc-50') @@ -183,7 +203,11 @@ describe('Tag', () => { describe('color styles for small variant', () => { it('should apply emerald small styles', () => { - render(<Tag color="emerald" variant="small">TEST</Tag>) + render( + <Tag color="emerald" variant="small"> + TEST + </Tag>, + ) const tag = screen.getByText('TEST') expect(tag.className).toContain('text-emerald-500') expect(tag.className).not.toContain('bg-emerald-400/10') @@ -191,25 +215,41 @@ describe('Tag', () => { }) it('should apply sky small styles', () => { - render(<Tag color="sky" variant="small">TEST</Tag>) + render( + <Tag color="sky" variant="small"> + TEST + </Tag>, + ) const tag = screen.getByText('TEST') expect(tag.className).toContain('text-sky-500') }) it('should apply amber small styles', () => { - render(<Tag color="amber" variant="small">TEST</Tag>) + render( + <Tag color="amber" variant="small"> + TEST + </Tag>, + ) const tag = screen.getByText('TEST') expect(tag.className).toContain('text-amber-500') }) it('should apply rose small styles', () => { - render(<Tag color="rose" variant="small">TEST</Tag>) + render( + <Tag color="rose" variant="small"> + TEST + </Tag>, + ) const tag = screen.getByText('TEST') expect(tag.className).toContain('text-red-500') }) it('should apply zinc small styles', () => { - render(<Tag color="zinc" variant="small">TEST</Tag>) + render( + <Tag color="zinc" variant="small"> + TEST + </Tag>, + ) const tag = screen.getByText('TEST') expect(tag.className).toContain('text-zinc-400') }) diff --git a/web/app/components/develop/__tests__/toc-panel.spec.tsx b/web/app/components/develop/__tests__/toc-panel.spec.tsx index 1c5143320fb1dc..5d48558569fc32 100644 --- a/web/app/components/develop/__tests__/toc-panel.spec.tsx +++ b/web/app/components/develop/__tests__/toc-panel.spec.tsx @@ -126,9 +126,7 @@ describe('TocPanel', () => { }) it('should apply active indicator dot to active item', () => { - render( - <TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="endpoints" />, - ) + render(<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="endpoints" />) const activeLink = screen.getByText('Endpoints').closest('a') const activeDot = activeLink?.querySelector('span:first-child') @@ -140,24 +138,20 @@ describe('TocPanel', () => { describe('item click handling', () => { it('should call onItemClick with the event and item when a link is clicked', () => { const onItemClick = vi.fn() - render( - <TocPanel {...defaultProps} isTocExpanded toc={sampleToc} onItemClick={onItemClick} />, - ) + render(<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} onItemClick={onItemClick} />) fireEvent.click(screen.getByText('Authentication')) expect(onItemClick).toHaveBeenCalledTimes(1) - expect(onItemClick).toHaveBeenCalledWith( - expect.any(Object), - { href: '#authentication', text: 'Authentication' }, - ) + expect(onItemClick).toHaveBeenCalledWith(expect.any(Object), { + href: '#authentication', + text: 'Authentication', + }) }) it('should call onItemClick for each clicked item independently', () => { const onItemClick = vi.fn() - render( - <TocPanel {...defaultProps} isTocExpanded toc={sampleToc} onItemClick={onItemClick} />, - ) + render(<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} onItemClick={onItemClick} />) fireEvent.click(screen.getByText('Introduction')) fireEvent.click(screen.getByText('Endpoints')) diff --git a/web/app/components/develop/__tests__/use-doc-toc.spec.ts b/web/app/components/develop/__tests__/use-doc-toc.spec.ts index b20c2c8ecfe34a..c624146be7f5e1 100644 --- a/web/app/components/develop/__tests__/use-doc-toc.spec.ts +++ b/web/app/components/develop/__tests__/use-doc-toc.spec.ts @@ -60,9 +60,7 @@ describe('useDocToc', () => { vi.runAllTimers() }) - expect(result.current.toc).toEqual([ - { href: '#section-1', text: 'Section 1' }, - ]) + expect(result.current.toc).toEqual([{ href: '#section-1', text: 'Section 1' }]) expect(result.current.activeSection).toBe('section-1') document.body.removeChild(article) @@ -116,10 +114,9 @@ describe('useDocToc', () => { const article = document.createElement('article') document.body.appendChild(article) - const { result, rerender } = renderHook( - props => useDocToc(props), - { initialProps: defaultOptions }, - ) + const { result, rerender } = renderHook((props) => useDocToc(props), { + initialProps: defaultOptions, + }) await act(async () => { vi.runAllTimers() @@ -158,10 +155,9 @@ describe('useDocToc', () => { article.appendChild(h2) document.body.appendChild(article) - const { result, rerender } = renderHook( - props => useDocToc(props), - { initialProps: defaultOptions }, - ) + const { result, rerender } = renderHook((props) => useDocToc(props), { + initialProps: defaultOptions, + }) await act(async () => { vi.runAllTimers() @@ -219,7 +215,9 @@ describe('useDocToc', () => { const { result } = renderHook(() => useDocToc(defaultOptions)) - const mockEvent = { preventDefault: vi.fn() } as unknown as React.MouseEvent<HTMLAnchorElement> + const mockEvent = { + preventDefault: vi.fn(), + } as unknown as React.MouseEvent<HTMLAnchorElement> act(() => { result.current.handleTocClick(mockEvent, { href: '#target-section', text: 'Target' }) }) @@ -236,7 +234,9 @@ describe('useDocToc', () => { it('should do nothing when target element does not exist', () => { const { result } = renderHook(() => useDocToc(defaultOptions)) - const mockEvent = { preventDefault: vi.fn() } as unknown as React.MouseEvent<HTMLAnchorElement> + const mockEvent = { + preventDefault: vi.fn(), + } as unknown as React.MouseEvent<HTMLAnchorElement> act(() => { result.current.handleTocClick(mockEvent, { href: '#nonexistent', text: 'Missing' }) }) @@ -248,7 +248,7 @@ describe('useDocToc', () => { // Covers scroll-based active section tracking describe('scroll tracking', () => { // Helper: set up DOM with scroll container, article headings, and matching target elements - const setupScrollDOM = (sections: Array<{ id: string, text: string, top: number }>) => { + const setupScrollDOM = (sections: Array<{ id: string; text: string; top: number }>) => { const scrollContainer = document.createElement('div') scrollContainer.className = 'overflow-auto' document.body.appendChild(scrollContainer) diff --git a/web/app/components/develop/code.tsx b/web/app/components/develop/code.tsx index a5cf8aefd2c7db..74de97fe35c2b1 100644 --- a/web/app/components/develop/code.tsx +++ b/web/app/components/develop/code.tsx @@ -1,18 +1,8 @@ 'use client' import type { PropsWithChildren, ReactElement } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - Tabs, - TabsList, - TabsPanel, - TabsTab, -} from '@langgenius/dify-ui/tabs' -import { - Children, - useEffect, - useRef, - useState, -} from 'react' +import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs' +import { Children, useEffect, useRef, useState } from 'react' import { writeTextToClipboard } from '@/utils/clipboard' import { Tag } from './tag' @@ -53,25 +43,34 @@ function CopyButton({ code }: { code: string }) { return ( <button type="button" - className={cn('group/button absolute top-1.5 right-4 overflow-hidden rounded-full py-1 pr-3 pl-2 text-2xs font-medium opacity-0 backdrop-blur-sm transition group-hover:opacity-100 focus:opacity-100', copied - ? 'bg-emerald-400/10 inset-ring-1 inset-ring-emerald-400/20' - : 'bg-white/5 hover:bg-white/7.5 dark:bg-white/2.5 dark:hover:bg-white/5')} + className={cn( + 'group/button absolute top-1.5 right-4 overflow-hidden rounded-full py-1 pr-3 pl-2 text-2xs font-medium opacity-0 backdrop-blur-sm transition group-hover:opacity-100 focus:opacity-100', + copied + ? 'bg-emerald-400/10 inset-ring-1 inset-ring-emerald-400/20' + : 'bg-white/5 hover:bg-white/7.5 dark:bg-white/2.5 dark:hover:bg-white/5', + )} onClick={() => { writeTextToClipboard(code).then(() => { - setCopyCount(count => count + 1) + setCopyCount((count) => count + 1) }) }} > <span aria-hidden={copied} - className={cn('pointer-events-none flex items-center gap-0.5 text-zinc-400 transition duration-300', copied && '-translate-y-1.5 opacity-0')} + className={cn( + 'pointer-events-none flex items-center gap-0.5 text-zinc-400 transition duration-300', + copied && '-translate-y-1.5 opacity-0', + )} > <ClipboardIcon className="size-5 fill-zinc-500/20 stroke-zinc-500 transition-colors group-hover/button:stroke-zinc-400" /> Copy </span> <span aria-hidden={!copied} - className={cn('pointer-events-none absolute inset-0 flex items-center justify-center text-emerald-400 transition duration-300', !copied && 'translate-y-1.5 opacity-0')} + className={cn( + 'pointer-events-none absolute inset-0 flex items-center justify-center text-emerald-400 transition duration-300', + !copied && 'translate-y-1.5 opacity-0', + )} > Copied! </span> @@ -79,9 +78,8 @@ function CopyButton({ code }: { code: string }) { ) } -function CodePanelHeader({ tag, label }: { tag?: string, label?: string }) { - if (!tag && !label) - return null +function CodePanelHeader({ tag, label }: { tag?: string; label?: string }) { + if (!tag && !label) return null return ( <div className="flex h-9 items-center gap-2 border-y border-t-transparent border-b-white/7.5 bg-white/2.5 bg-zinc-900 px-4 dark:border-b-white/5 dark:bg-white/1"> @@ -90,12 +88,8 @@ function CodePanelHeader({ tag, label }: { tag?: string, label?: string }) { <Tag variant="small">{tag}</Tag> </div> )} - {tag && label && ( - <span className="size-0.5 rounded-full bg-zinc-500" /> - )} - {label && ( - <span className="font-mono text-xs text-zinc-400">{label}</span> - )} + {tag && label && <span className="size-0.5 rounded-full bg-zinc-500" />} + {label && <span className="font-mono text-xs text-zinc-400">{label}</span>} </div> ) } @@ -125,22 +119,13 @@ function CodePanel({ tag, label, children, targetCode }: ICodePanelProps) { return ( <div className="group dark:bg-white/2.5"> - <CodePanelHeader - tag={tag} - label={label} - /> + <CodePanelHeader tag={tag} label={label} /> <div className="relative"> {/* <pre className="p-4 overflow-x-auto text-xs text-white">{children}</pre> */} {/* <CopyButton code={child.props.code ?? code} /> */} {/* <CopyButton code={child.props.children.props.children} /> */} <pre className="overflow-x-auto p-4 text-xs text-white"> - {targetCode?.code - ? ( - <code>{targetCode?.code}</code> - ) - : ( - child - )} + {targetCode?.code ? <code>{targetCode?.code}</code> : child} </pre> <CopyButton code={targetCode?.code ?? child.props.children.props.children} /> </div> @@ -158,16 +143,10 @@ function CodeGroupHeader({ title, tabs }: CodeGroupHeaderProps) { return ( <div className="flex min-h-[calc(--spacing(12)+1px)] flex-wrap items-start gap-x-4 border-b border-zinc-700 bg-zinc-800 px-4 dark:border-zinc-800 dark:bg-transparent"> - {title && ( - <h3 className="mr-auto pt-3 text-xs font-semibold text-white"> - {title} - </h3> - )} + {title && <h3 className="mr-auto pt-3 text-xs font-semibold text-white">{title}</h3>} {hasTabs && ( - <TabsList - className="-mb-px flex gap-4 rounded-none bg-transparent p-0 text-xs font-medium" - > - {tabs!.map(tab => ( + <TabsList className="-mb-px flex gap-4 rounded-none bg-transparent p-0 text-xs font-medium"> + {tabs!.map((tab) => ( <TabsTab key={tab.value} value={tab.value} @@ -205,7 +184,11 @@ function CodeGroupPanels({ children, targetCode, tabs, ...props }: ICodeGroupPan ) } - return <CodePanel {...props} targetCode={targetCode?.[0]}>{children}</CodePanel> + return ( + <CodePanel {...props} targetCode={targetCode?.[0]}> + {children} + </CodePanel> + ) } function usePreventLayoutShift() { @@ -214,8 +197,7 @@ function usePreventLayoutShift() { useEffect(() => { return () => { - if (rafRef.current) - window.cancelAnimationFrame(rafRef.current) + if (rafRef.current) window.cancelAnimationFrame(rafRef.current) } }, []) @@ -232,8 +214,7 @@ function usePreventLayoutShift() { callback() rafRef.current = window.requestAnimationFrame(() => { - if (!positionRef.current) - return + if (!positionRef.current) return const newTop = positionRef.current.getBoundingClientRect().top window.scrollBy(0, newTop - initialTop) @@ -245,20 +226,16 @@ function usePreventLayoutShift() { function useTabGroupProps(tabValues: string[]) { const [selectedValue, setSelectedValue] = useState(tabValues[0] ?? '') const { positionRef, preventLayoutShift } = usePreventLayoutShift() - const value = tabValues.includes(selectedValue) - ? selectedValue - : tabValues[0] ?? '' + const value = tabValues.includes(selectedValue) ? selectedValue : (tabValues[0] ?? '') return { ref: positionRef, value, onValueChange: (newValue: string | number | null) => { - if (newValue == null) - return + if (newValue == null) return const nextValue = String(newValue) - if (!tabValues.includes(nextValue)) - return + if (!tabValues.includes(nextValue)) return preventLayoutShift(() => { setSelectedValue(nextValue) @@ -279,34 +256,36 @@ type CodeGroupProps = PropsWithChildren<{ }> export function CodeGroup({ children, title, targetCode, ...props }: CodeGroupProps) { - const examples = typeof targetCode === 'string' ? [{ code: targetCode }] as CodeExample[] : targetCode - const tabs = examples?.map(({ title }, index) => ({ - title: title || 'Code', - value: String(index), - })) || [] - const tabGroupProps = useTabGroupProps(tabs.map(tab => tab.value)) + const examples = + typeof targetCode === 'string' ? ([{ code: targetCode }] as CodeExample[]) : targetCode + const tabs = + examples?.map(({ title }, index) => ({ + title: title || 'Code', + value: String(index), + })) || [] + const tabGroupProps = useTabGroupProps(tabs.map((tab) => tab.value)) const hasTabs = tabs.length > 1 const content = ( <> <CodeGroupHeader title={title} tabs={hasTabs ? tabs : undefined} /> - <CodeGroupPanels {...props} targetCode={examples} tabs={hasTabs ? tabs : undefined}>{children}</CodeGroupPanels> + <CodeGroupPanels {...props} targetCode={examples} tabs={hasTabs ? tabs : undefined}> + {children} + </CodeGroupPanels> </> ) - return hasTabs - ? ( - <Tabs - {...tabGroupProps} - className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10" - > - {content} - </Tabs> - ) - : ( - <div className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10"> - {content} - </div> - ) + return hasTabs ? ( + <Tabs + {...tabGroupProps} + className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10" + > + {content} + </Tabs> + ) : ( + <div className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10"> + {content} + </div> + ) } export function Embed({ value, ...props }: IChildrenProps) { diff --git a/web/app/components/develop/doc.tsx b/web/app/components/develop/doc.tsx index 2ebce59b54f629..c3f0ba3dce485d 100644 --- a/web/app/components/develop/doc.tsx +++ b/web/app/components/develop/doc.tsx @@ -23,7 +23,7 @@ import TemplateWorkflowZh from './template/template_workflow.zh.mdx' import TocPanel from './toc-panel' type AppDetail = App & Partial<AppSSO> -type PromptVariable = { key: string, name: string } +type PromptVariable = { key: string; name: string } type IDocProps = { appDetail: AppDetail @@ -42,17 +42,26 @@ type TemplateProps = { const TEMPLATE_MAP = { [AppModeEnum.CHAT]: { zh: TemplateChatZh, ja: TemplateChatJa, en: TemplateChatEn }, [AppModeEnum.AGENT_CHAT]: { zh: TemplateChatZh, ja: TemplateChatJa, en: TemplateChatEn }, - [AppModeEnum.ADVANCED_CHAT]: { zh: TemplateAdvancedChatZh, ja: TemplateAdvancedChatJa, en: TemplateAdvancedChatEn }, - [AppModeEnum.WORKFLOW]: { zh: TemplateWorkflowZh, ja: TemplateWorkflowJa, en: TemplateWorkflowEn }, + [AppModeEnum.ADVANCED_CHAT]: { + zh: TemplateAdvancedChatZh, + ja: TemplateAdvancedChatJa, + en: TemplateAdvancedChatEn, + }, + [AppModeEnum.WORKFLOW]: { + zh: TemplateWorkflowZh, + ja: TemplateWorkflowJa, + en: TemplateWorkflowEn, + }, [AppModeEnum.COMPLETION]: { zh: TemplateZh, ja: TemplateJa, en: TemplateEn }, } as Record<string, Record<string, ComponentType<TemplateProps>>> -const resolveTemplate = (mode: string | undefined, locale: string): ComponentType<TemplateProps> | null => { - if (!mode) - return null +const resolveTemplate = ( + mode: string | undefined, + locale: string, +): ComponentType<TemplateProps> | null => { + if (!mode) return null const langTemplates = TEMPLATE_MAP[mode] - if (!langTemplates) - return null + if (!langTemplates) return null const docLang = getDocLanguage(locale) return langTemplates[docLang] ?? langTemplates.en ?? null } @@ -60,12 +69,18 @@ const resolveTemplate = (mode: string | undefined, locale: string): ComponentTyp const Doc = ({ appDetail }: IDocProps) => { const locale = useLocale() const { theme } = useTheme() - const { toc, isTocExpanded, setIsTocExpanded, activeSection, handleTocClick } = useDocToc({ appDetail, locale }) + const { toc, isTocExpanded, setIsTocExpanded, activeSection, handleTocClick } = useDocToc({ + appDetail, + locale, + }) // model_config.configs.prompt_variables exists in the raw API response but is not modeled in ModelConfig type - const variables: PromptVariable[] = ( - appDetail?.model_config as unknown as Record<string, Record<string, PromptVariable[]>> | undefined - )?.configs?.prompt_variables ?? [] + const variables: PromptVariable[] = + ( + appDetail?.model_config as unknown as + | Record<string, Record<string, PromptVariable[]>> + | undefined + )?.configs?.prompt_variables ?? [] const inputs = variables.reduce<Record<string, string>>((res, variable) => { res[variable.key] = variable.name || '' return res @@ -78,7 +93,9 @@ const Doc = ({ appDetail }: IDocProps) => { return ( <div className="flex"> - <div className={`fixed top-32 right-20 z-10 transition-all duration-150 ease-out ${isTocExpanded ? 'w-[280px]' : 'w-11'}`}> + <div + className={`fixed top-32 right-20 z-10 transition-all duration-150 ease-out ${isTocExpanded ? 'w-[280px]' : 'w-11'}`} + > <TocPanel toc={toc} activeSection={activeSection} @@ -88,7 +105,9 @@ const Doc = ({ appDetail }: IDocProps) => { /> </div> <article className={cn('prose-xl prose', theme === Theme.dark && 'prose-invert')}> - {TemplateComponent && <TemplateComponent appDetail={appDetail} variables={variables} inputs={inputs} />} + {TemplateComponent && ( + <TemplateComponent appDetail={appDetail} variables={variables} inputs={inputs} /> + )} </article> </div> ) diff --git a/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx b/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx index b153f24179af27..aff8bad3bc8cba 100644 --- a/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx +++ b/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx @@ -3,16 +3,19 @@ import { act, renderHook } from '@testing-library/react' import { useDocToc } from '../use-doc-toc' const mockMatchMedia = (matches: boolean) => { - vi.stubGlobal('matchMedia', vi.fn().mockImplementation((query: string) => ({ - matches, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - }))) + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ) } const setupDocument = () => { @@ -57,10 +60,12 @@ describe('useDocToc', () => { setupDocument() mockMatchMedia(true) - const { result } = renderHook(() => useDocToc({ - appDetail: { id: 'app-1' }, - locale: 'en', - })) + const { result } = renderHook(() => + useDocToc({ + appDetail: { id: 'app-1' }, + locale: 'en', + }), + ) act(() => { vi.runAllTimers() @@ -78,13 +83,15 @@ describe('useDocToc', () => { const { scrollContainer, intro, details } = setupDocument() Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }) - intro.getBoundingClientRect = vi.fn(() => ({ top: 500 } as DOMRect)) - details.getBoundingClientRect = vi.fn(() => ({ top: 300 } as DOMRect)) + intro.getBoundingClientRect = vi.fn(() => ({ top: 500 }) as DOMRect) + details.getBoundingClientRect = vi.fn(() => ({ top: 300 }) as DOMRect) - const { result } = renderHook(() => useDocToc({ - appDetail: { id: 'app-1' }, - locale: 'en', - })) + const { result } = renderHook(() => + useDocToc({ + appDetail: { id: 'app-1' }, + locale: 'en', + }), + ) act(() => { vi.runAllTimers() @@ -99,10 +106,12 @@ describe('useDocToc', () => { it('should scroll the container to the clicked heading offset', async () => { const { scrollContainer } = setupDocument() - const { result } = renderHook(() => useDocToc({ - appDetail: { id: 'app-1' }, - locale: 'en', - })) + const { result } = renderHook(() => + useDocToc({ + appDetail: { id: 'app-1' }, + locale: 'en', + }), + ) act(() => { vi.runAllTimers() diff --git a/web/app/components/develop/hooks/use-doc-toc.ts b/web/app/components/develop/hooks/use-doc-toc.ts index 1eba8ca8e3c548..38c6b19b020aa9 100644 --- a/web/app/components/develop/hooks/use-doc-toc.ts +++ b/web/app/components/develop/hooks/use-doc-toc.ts @@ -20,14 +20,12 @@ const getTargetId = (href: string) => href.replace('#', '') */ const extractTocFromArticle = (): TocItem[] => { const article = document.querySelector('article') - if (!article) - return [] + if (!article) return [] return Array.from(article.querySelectorAll('h2')) .map((heading) => { const anchor = heading.querySelector('a') - if (!anchor) - return null + if (!anchor) return null return { href: anchor.getAttribute('href') || '', text: anchor.textContent || '', @@ -45,8 +43,7 @@ const extractTocFromArticle = (): TocItem[] => { export const useDocToc = ({ appDetail, locale }: UseDocTocOptions) => { const [toc, setToc] = useState<TocItem[]>([]) const [isTocExpanded, setIsTocExpanded] = useState(() => { - if (typeof window === 'undefined') - return false + if (typeof window === 'undefined') return false return window.matchMedia('(min-width: 1280px)').matches }) const [activeSection, setActiveSection] = useState<string>('') @@ -56,8 +53,7 @@ export const useDocToc = ({ appDetail, locale }: UseDocTocOptions) => { const timer = setTimeout(() => { const tocItems = extractTocFromArticle() setToc(tocItems) - if (tocItems.length > 0) - setActiveSection(getTargetId(tocItems[0]!.href)) + if (tocItems.length > 0) setActiveSection(getTargetId(tocItems[0]!.href)) }, 0) return () => clearTimeout(timer) }, [appDetail, locale]) @@ -65,8 +61,7 @@ export const useDocToc = ({ appDetail, locale }: UseDocTocOptions) => { // Track active section based on scroll position useEffect(() => { const scrollContainer = document.querySelector(SCROLL_CONTAINER_SELECTOR) - if (!scrollContainer || toc.length === 0) - return + if (!scrollContainer || toc.length === 0) return const handleScroll = () => { let currentSection = '' @@ -75,13 +70,11 @@ export const useDocToc = ({ appDetail, locale }: UseDocTocOptions) => { const element = document.getElementById(targetId) if (element) { const rect = element.getBoundingClientRect() - if (rect.top <= window.innerHeight / 2) - currentSection = targetId + if (rect.top <= window.innerHeight / 2) currentSection = targetId } } - if (currentSection && currentSection !== activeSection) - setActiveSection(currentSection) + if (currentSection && currentSection !== activeSection) setActiveSection(currentSection) } scrollContainer.addEventListener('scroll', handleScroll) @@ -93,8 +86,7 @@ export const useDocToc = ({ appDetail, locale }: UseDocTocOptions) => { e.preventDefault() const targetId = getTargetId(item.href) const element = document.getElementById(targetId) - if (!element) - return + if (!element) return const scrollContainer = document.querySelector(SCROLL_CONTAINER_SELECTOR) if (scrollContainer) { diff --git a/web/app/components/develop/index.tsx b/web/app/components/develop/index.tsx index 14d5ca7d504fcc..5b927586179497 100644 --- a/web/app/components/develop/index.tsx +++ b/web/app/components/develop/index.tsx @@ -13,7 +13,7 @@ type IDevelopMainProps = { } const DevelopMain = ({ appId }: IDevelopMainProps) => { - const appDetail = useAppStore(state => state.appDetail) + const appDetail = useAppStore((state) => state.appDetail) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const appACLCapabilities = getAppACLCapabilities(appDetail?.permission_keys, { @@ -34,7 +34,11 @@ const DevelopMain = ({ appId }: IDevelopMainProps) => { <div data-testid="develop-main" className="relative flex h-full flex-col overflow-hidden"> <div className="flex shrink-0 items-center justify-between border-b border-solid border-b-divider-regular px-6 py-2"> <div className="text-lg font-medium text-text-primary"></div> - <ApiServer apiBaseUrl={appDetail.api_base_url} appId={appId} canManageApiKey={appACLCapabilities.canEdit} /> + <ApiServer + apiBaseUrl={appDetail.api_base_url} + appId={appId} + canManageApiKey={appACLCapabilities.canEdit} + /> </div> <div className="grow overflow-auto p-4 sm:px-10"> <Doc appDetail={appDetail} /> diff --git a/web/app/components/develop/md.tsx b/web/app/components/develop/md.tsx index d10abe3268a82e..e853f6f3fd9088 100644 --- a/web/app/components/develop/md.tsx +++ b/web/app/components/develop/md.tsx @@ -16,43 +16,48 @@ type IHeaderingProps = { name: string } -export const Heading = function H2({ - url, - method, - title, - name, -}: IHeaderingProps) { +export const Heading = function H2({ url, method, title, name }: IHeaderingProps) { let style = '' switch (method) { case 'PUT': - style = 'ring-amber-300 bg-amber-400/10 text-amber-500 dark:ring-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400' + style = + 'ring-amber-300 bg-amber-400/10 text-amber-500 dark:ring-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400' break case 'DELETE': - style = 'ring-rose-200 bg-rose-50 text-red-500 dark:ring-rose-500/20 dark:bg-rose-400/10 dark:text-rose-400' + style = + 'ring-rose-200 bg-rose-50 text-red-500 dark:ring-rose-500/20 dark:bg-rose-400/10 dark:text-rose-400' break case 'POST': - style = 'ring-sky-300 bg-sky-400/10 text-sky-500 dark:ring-sky-400/30 dark:bg-sky-400/10 dark:text-sky-400' + style = + 'ring-sky-300 bg-sky-400/10 text-sky-500 dark:ring-sky-400/30 dark:bg-sky-400/10 dark:text-sky-400' break case 'PATCH': - style = 'ring-violet-300 bg-violet-400/10 text-violet-500 dark:ring-violet-400/30 dark:bg-violet-400/10 dark:text-violet-400' + style = + 'ring-violet-300 bg-violet-400/10 text-violet-500 dark:ring-violet-400/30 dark:bg-violet-400/10 dark:text-violet-400' break default: - style = 'ring-emerald-300 dark:ring-emerald-400/30 bg-emerald-400/10 text-emerald-500 dark:text-emerald-400' + style = + 'ring-emerald-300 dark:ring-emerald-400/30 bg-emerald-400/10 text-emerald-500 dark:text-emerald-400' break } return ( <> <span id={name?.replace(/^#/, '')} className="relative -top-28" /> <div className="flex items-center gap-x-3"> - <span className={`rounded-lg px-1.5 font-mono text-2xs/6 font-semibold ring-1 ring-inset ${style}`}>{method}</span> + <span + className={`rounded-lg px-1.5 font-mono text-2xs/6 font-semibold ring-1 ring-inset ${style}`} + > + {method} + </span> {/* <span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600"></span> */} <span className="font-mono text-xs text-zinc-400">{url}</span> </div> <h2 className="mt-2 scroll-mt-32"> - <a href={name} className="group text-inherit no-underline hover:text-inherit">{title}</a> + <a href={name} className="group text-inherit no-underline hover:text-inherit"> + {title} + </a> </h2> </> - ) } @@ -69,9 +74,7 @@ type IColProps = IChildrenProps & { } export function Col({ children, sticky = false }: IColProps) { return ( - <div - className={cn('*:first:mt-0 *:last:mb-0', sticky && 'xl:sticky xl:top-24')} - > + <div className={cn('*:first:mt-0 *:last:mb-0', sticky && 'xl:sticky xl:top-24')}> {children} </div> ) @@ -103,13 +106,9 @@ export function Property({ name, type, children }: IProperty) { <code>{name}</code> </dd> <dt className="sr-only">Type</dt> - <dd className="font-mono text-xs text-zinc-400 dark:text-zinc-500"> - {type} - </dd> + <dd className="font-mono text-xs text-zinc-400 dark:text-zinc-500">{type}</dd> <dt className="sr-only">Description</dt> - <dd className="w-full flex-none *:first:mt-0 *:last:mb-0"> - {children} - </dd> + <dd className="w-full flex-none *:first:mt-0 *:last:mb-0">{children}</dd> </dl> </li> ) @@ -128,13 +127,9 @@ export function SubProperty({ name, type, children }: ISubProperty) { <code>{name}</code> </dd> <dt className="sr-only">Type</dt> - <dd className="font-mono text-xs text-zinc-400 dark:text-zinc-500"> - {type} - </dd> + <dd className="font-mono text-xs text-zinc-400 dark:text-zinc-500">{type}</dd> <dt className="sr-only">Description</dt> - <dd className="w-full flex-none *:first:mt-0 *:last:mb-0"> - {children} - </dd> + <dd className="w-full flex-none *:first:mt-0 *:last:mb-0">{children}</dd> </dl> </li> ) diff --git a/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx index accd1063058d98..ffe158a38c418e 100644 --- a/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx @@ -56,7 +56,9 @@ describe('InputCopy', () => { describe('styling', () => { it('should apply custom className', async () => { - const { container } = await renderAndFlush(<InputCopy value="test" className="custom-class" />) + const { container } = await renderAndFlush( + <InputCopy value="test" className="custom-class" />, + ) const wrapper = container.firstChild as HTMLElement expect(wrapper.className).toContain('custom-class') }) diff --git a/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx index 6e91454fbdb2a4..3bb2392921c965 100644 --- a/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx @@ -3,17 +3,26 @@ import userEvent from '@testing-library/user-event' import SecretKeyButton from '../secret-key-button' vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ - default: ({ isShow, onClose, appId, canManage }: { isShow: boolean, onClose: () => void, appId?: string, canManage: boolean }) => ( - isShow - ? ( - <div data-testid="secret-key-modal"> - <span data-testid="modal-app-id">{`Modal for ${appId || 'no-app'}`}</span> - <span data-testid="modal-can-manage">{String(canManage)}</span> - <button onClick={onClose} data-testid="close-modal">Close</button> - </div> - ) - : null - ), + default: ({ + isShow, + onClose, + appId, + canManage, + }: { + isShow: boolean + onClose: () => void + appId?: string + canManage: boolean + }) => + isShow ? ( + <div data-testid="secret-key-modal"> + <span data-testid="modal-app-id">{`Modal for ${appId || 'no-app'}`}</span> + <span data-testid="modal-can-manage">{String(canManage)}</span> + <button onClick={onClose} data-testid="close-modal"> + Close + </button> + </div> + ) : null, })) describe('SecretKeyButton', () => { diff --git a/web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx index 7df86917ed9cb4..218b8ce89070cc 100644 --- a/web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx @@ -56,7 +56,9 @@ describe('SecretKeyGenerateModal', () => { }) it('should render InputCopy component', async () => { - await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token-123')} />) + await renderModal( + <SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token-123')} />, + ) expect(screen.getByText('test-token-123')).toBeInTheDocument() }) }) @@ -70,7 +72,9 @@ describe('SecretKeyGenerateModal', () => { describe('newKey prop', () => { it('should display the token when newKey is provided', async () => { - await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('sk-abc123xyz')} />) + await renderModal( + <SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('sk-abc123xyz')} />, + ) expect(screen.getByText('sk-abc123xyz')).toBeInTheDocument() }) @@ -86,7 +90,9 @@ describe('SecretKeyGenerateModal', () => { it('should display long tokens correctly', async () => { const longToken = `sk-${'a'.repeat(100)}` - await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey(longToken)} />) + await renderModal( + <SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey(longToken)} />, + ) expect(screen.getByText(longToken)).toBeInTheDocument() }) }) @@ -123,17 +129,13 @@ describe('SecretKeyGenerateModal', () => { describe('className prop', () => { it('should apply custom className', async () => { - await renderModal( - <SecretKeyGenerateModal {...defaultProps} className="custom-modal-class" />, - ) + await renderModal(<SecretKeyGenerateModal {...defaultProps} className="custom-modal-class" />) const modal = document.body.querySelector('.custom-modal-class') expect(modal).toBeInTheDocument() }) it('should apply shrink-0 class', async () => { - await renderModal( - <SecretKeyGenerateModal {...defaultProps} className="shrink-0" />, - ) + await renderModal(<SecretKeyGenerateModal {...defaultProps} className="shrink-0" />) const modal = document.body.querySelector('.shrink-0') expect(modal).toBeInTheDocument() }) @@ -217,12 +219,16 @@ describe('SecretKeyGenerateModal', () => { describe('InputCopy section', () => { it('should render InputCopy with token value', async () => { - await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token')} />) + await renderModal( + <SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token')} />, + ) expect(screen.getByText('test-token')).toBeInTheDocument() }) it('should have w-full class on InputCopy', async () => { - await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test')} />) + await renderModal( + <SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test')} />, + ) const inputText = screen.getByText('test') const inputContainer = inputText.closest('.w-full') expect(inputContainer).toBeInTheDocument() diff --git a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx index 22c9d8db80949a..9086381100dc7c 100644 --- a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx @@ -74,7 +74,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -203,7 +204,12 @@ describe('SecretKeyModal', () => { describe('API keys list for app', () => { const apiKeys = [ - { id: 'key-1', token: 'sk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 }, + { + id: 'key-1', + token: 'sk-abc123def456ghi789', + created_at: 1700000000, + last_used_at: 1700100000, + }, { id: 'key-2', token: 'sk-xyz987wvu654tsr321', created_at: 1700050000, last_used_at: null }, ] @@ -263,7 +269,12 @@ describe('SecretKeyModal', () => { describe('API keys list for dataset', () => { const datasetKeys = [ - { id: 'dk-1', token: 'dk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 }, + { + id: 'dk-1', + token: 'dk-abc123def456ghi789', + created_at: 1700000000, + last_used_at: 1700100000, + }, ] beforeEach(() => { @@ -346,7 +357,12 @@ describe('SecretKeyModal', () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) mockAppApiKeysData.mockReturnValue({ data: [ - { id: 'key-1', token: 'sk-abc123def456ghi789', created_at: 1700000000, last_used_at: null }, + { + id: 'key-1', + token: 'sk-abc123def456ghi789', + created_at: 1700000000, + last_used_at: null, + }, ], }) await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) @@ -360,16 +376,26 @@ describe('SecretKeyModal', () => { expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument() }) - const parentDialog = screen.getByText('appApi.apiKeyModal.apiSecretKeyTips').closest('[role="dialog"]') - const generatedKeyDialog = screen.getByText('appApi.apiKeyModal.generateTips').closest('[role="dialog"]') + const parentDialog = screen + .getByText('appApi.apiKeyModal.apiSecretKeyTips') + .closest('[role="dialog"]') + const generatedKeyDialog = screen + .getByText('appApi.apiKeyModal.generateTips') + .closest('[role="dialog"]') const backdrops = document.body.querySelectorAll('.bg-background-overlay') const generatedKeyBackdrop = backdrops[1] expect(parentDialog).toBeInTheDocument() expect(generatedKeyDialog).toBeInTheDocument() expect(backdrops).toHaveLength(2) - expect(parentDialog!.compareDocumentPosition(generatedKeyBackdrop!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - expect(generatedKeyBackdrop!.compareDocumentPosition(generatedKeyDialog!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect( + parentDialog!.compareDocumentPosition(generatedKeyBackdrop!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + expect( + generatedKeyBackdrop!.compareDocumentPosition(generatedKeyDialog!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() }) it('should invalidate app API keys after creating', async () => { @@ -404,7 +430,9 @@ describe('SecretKeyModal', () => { mockCurrentWorkspace.mockReturnValue({ id: '', name: '' }) await renderModal(<SecretKeyModal {...defaultProps} />) - const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button') + const createButton = screen + .getByText('appApi.apiKeyModal.createNewSecretKey') + .closest('button') expect(createButton).toBeDisabled() }) @@ -412,7 +440,9 @@ describe('SecretKeyModal', () => { mockIsCurrentWorkspaceEditor.mockReturnValue(false) await renderModal(<SecretKeyModal {...defaultProps} />) - const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button') + const createButton = screen + .getByText('appApi.apiKeyModal.createNewSecretKey') + .closest('button') expect(createButton).not.toBeDisabled() }) @@ -420,14 +450,21 @@ describe('SecretKeyModal', () => { mockIsCurrentWorkspaceEditor.mockReturnValue(true) await renderModal(<SecretKeyModal {...defaultProps} canManage={false} />) - const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button') + const createButton = screen + .getByText('appApi.apiKeyModal.createNewSecretKey') + .closest('button') expect(createButton).toBeDisabled() }) }) describe('delete key', () => { const apiKeys = [ - { id: 'key-1', token: 'sk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 }, + { + id: 'key-1', + token: 'sk-abc123def456ghi789', + created_at: 1700000000, + last_used_at: 1700100000, + }, ] beforeEach(() => { @@ -597,7 +634,12 @@ describe('SecretKeyModal', () => { describe('delete key for dataset', () => { const datasetKeys = [ - { id: 'dk-1', token: 'dk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 }, + { + id: 'dk-1', + token: 'dk-abc123def456ghi789', + created_at: 1700000000, + last_used_at: 1700100000, + }, ] beforeEach(() => { @@ -665,7 +707,12 @@ describe('SecretKeyModal', () => { describe('token truncation', () => { it('should truncate token correctly', async () => { const apiKeys = [ - { id: 'key-1', token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', created_at: 1700000000, last_used_at: null }, + { + id: 'key-1', + token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', + created_at: 1700000000, + last_used_at: null, + }, ] mockAppApiKeysData.mockReturnValue({ data: apiKeys }) diff --git a/web/app/components/develop/secret-key/input-copy.tsx b/web/app/components/develop/secret-key/input-copy.tsx index e02ff9287c45f9..83788f012868e9 100644 --- a/web/app/components/develop/secret-key/input-copy.tsx +++ b/web/app/components/develop/secret-key/input-copy.tsx @@ -12,13 +12,11 @@ type IInputCopyProps = { children?: React.ReactNode } -const InputCopy = ({ - value = '', - className, - children, -}: IInputCopyProps) => { +const InputCopy = ({ value = '', className, children }: IInputCopyProps) => { const [isCopied, setIsCopied] = useState(false) - const copyLabel = isCopied ? `${t($ => $.copied, { ns: 'appApi' })}` : `${t($ => $.copy, { ns: 'appApi' })}` + const copyLabel = isCopied + ? `${t(($) => $.copied, { ns: 'appApi' })}` + : `${t(($) => $.copy, { ns: 'appApi' })}` const handleCopy = () => { writeTextToClipboard(value).then(() => { setIsCopied(true) @@ -38,7 +36,9 @@ const InputCopy = ({ }, [isCopied]) return ( - <div className={`flex items-center rounded-lg bg-components-input-bg-normal py-2 hover:bg-state-base-hover ${className}`}> + <div + className={`flex items-center rounded-lg bg-components-input-bg-normal py-2 hover:bg-state-base-hover ${className}`} + > <div className="flex h-5 grow items-center"> {children} <div className="relative h-full grow text-[13px]"> @@ -49,17 +49,15 @@ const InputCopy = ({ onClick={handleCopy} > <Tooltip> - <TooltipTrigger - render={<span className="text-text-secondary">{value}</span>} - /> - <TooltipContent placement="bottom"> - {copyLabel} - </TooltipContent> + <TooltipTrigger render={<span className="text-text-secondary">{value}</span>} /> + <TooltipContent placement="bottom">{copyLabel}</TooltipContent> </Tooltip> </button> </div> <div className="h-4 w-px shrink-0 bg-divider-regular" /> - <div className="mx-1"><CopyFeedback content={value} /></div> + <div className="mx-1"> + <CopyFeedback content={value} /> + </div> </div> </div> ) diff --git a/web/app/components/develop/secret-key/secret-key-button.tsx b/web/app/components/develop/secret-key/secret-key-button.tsx index b777d7948d1eae..7826e12d6dc75d 100644 --- a/web/app/components/develop/secret-key/secret-key-button.tsx +++ b/web/app/components/develop/secret-key/secret-key-button.tsx @@ -11,7 +11,12 @@ type ISecretKeyButtonProps = { canManage?: boolean } -const SecretKeyButton = ({ className, appId, textCls, canManage = false }: ISecretKeyButtonProps) => { +const SecretKeyButton = ({ + className, + appId, + textCls, + canManage = false, +}: ISecretKeyButtonProps) => { const [isVisible, setIsVisible] = useState(false) const { t } = useTranslation() @@ -27,9 +32,16 @@ const SecretKeyButton = ({ className, appId, textCls, canManage = false }: ISecr <div className="flex size-3.5 items-center justify-center"> <span className="i-ri-key-2-line size-3.5 text-text-tertiary" /> </div> - <div className={`px-[3px] system-xs-medium text-text-tertiary ${textCls}`}>{t($ => $.apiKey, { ns: 'appApi' })}</div> + <div className={`px-[3px] system-xs-medium text-text-tertiary ${textCls}`}> + {t(($) => $.apiKey, { ns: 'appApi' })} + </div> </Button> - <SecretKeyModal isShow={isVisible} onClose={() => setIsVisible(false)} appId={appId} canManage={canManage} /> + <SecretKeyModal + isShow={isVisible} + onClose={() => setIsVisible(false)} + appId={appId} + canManage={canManage} + /> </> ) } diff --git a/web/app/components/develop/secret-key/secret-key-generate.tsx b/web/app/components/develop/secret-key/secret-key-generate.tsx index 5b1f51bb0e4d37..5b1224ad8f72bb 100644 --- a/web/app/components/develop/secret-key/secret-key-generate.tsx +++ b/web/app/components/develop/secret-key/secret-key-generate.tsx @@ -26,28 +26,35 @@ const SecretKeyGenerateModal = ({ <Dialog open={isShow} onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > - <DialogContent className={cn('w-full max-w-[480px] overflow-hidden! border-none px-8 text-left align-middle', className)}> + <DialogContent + className={cn( + 'w-full max-w-[480px] overflow-hidden! border-none px-8 text-left align-middle', + className, + )} + > <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {`${t($ => $['apiKeyModal.apiSecretKey'], { ns: 'appApi' })}`} + {`${t(($) => $['apiKeyModal.apiSecretKey'], { ns: 'appApi' })}`} </DialogTitle> <div className="-mt-6 -mr-2 mb-4 flex justify-end"> <XMarkIcon className="size-6 cursor-pointer text-text-tertiary" onClick={onClose} /> </div> - <p className="mt-1 text-[13px] leading-5 font-normal text-text-tertiary">{t($ => $['apiKeyModal.generateTips'], { ns: 'appApi' })}</p> + <p className="mt-1 text-[13px] leading-5 font-normal text-text-tertiary"> + {t(($) => $['apiKeyModal.generateTips'], { ns: 'appApi' })} + </p> <div className="my-4"> <InputCopy className="w-full" value={newKey?.token} /> </div> <div className="my-4 flex justify-end"> <Button className={`shrink-0 ${s.w64}`} onClick={onClose}> - <span className="text-xs font-medium text-text-secondary">{t($ => $['actionMsg.ok'], { ns: 'appApi' })}</span> + <span className="text-xs font-medium text-text-secondary"> + {t(($) => $['actionMsg.ok'], { ns: 'appApi' })} + </span> </Button> </div> - </DialogContent> </Dialog> ) diff --git a/web/app/components/develop/secret-key/secret-key-modal.tsx b/web/app/components/develop/secret-key/secret-key-modal.tsx index 9581507915f631..b39ed7a16a2364 100644 --- a/web/app/components/develop/secret-key/secret-key-modal.tsx +++ b/web/app/components/develop/secret-key/secret-key-modal.tsx @@ -13,19 +13,14 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { useAtomValue } from 'jotai' -import { - useState, -} from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import CopyFeedback from '@/app/components/base/copy-feedback' import Loading from '@/app/components/base/loading' import { currentWorkspaceAtom } from '@/context/workspace-state' import useTimestamp from '@/hooks/use-timestamp' -import { - createApikey as createAppApikey, - delApikey as delAppApikey, -} from '@/service/apps' +import { createApikey as createAppApikey, delApikey as delAppApikey } from '@/service/apps' import { createApikey as createDatasetApikey, delApikey as delDatasetApikey, @@ -42,12 +37,7 @@ type ISecretKeyModalProps = { onClose: () => void } -const SecretKeyModal = ({ - isShow = false, - appId, - canManage, - onClose, -}: ISecretKeyModalProps) => { +const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKeyModalProps) => { const { t } = useTranslation() const { formatTime } = useTimestamp() const currentWorkspace = useAtomValue(currentWorkspaceAtom) @@ -56,8 +46,12 @@ const SecretKeyModal = ({ const [newKey, setNewKey] = useState<CreateApiKeyResponse | undefined>(undefined) const invalidateAppApiKeys = useInvalidateAppApiKeys() const invalidateDatasetApiKeys = useInvalidateDatasetApiKeys() - const { data: appApiKeys, isLoading: isAppApiKeysLoading } = useAppApiKeys(appId, { enabled: !!appId && isShow }) - const { data: datasetApiKeys, isLoading: isDatasetApiKeysLoading } = useDatasetApiKeys({ enabled: !appId && isShow }) + const { data: appApiKeys, isLoading: isAppApiKeysLoading } = useAppApiKeys(appId, { + enabled: !!appId && isShow, + }) + const { data: datasetApiKeys, isLoading: isDatasetApiKeysLoading } = useDatasetApiKeys({ + enabled: !appId && isShow, + }) const apiKeysList = appId ? appApiKeys : datasetApiKeys const isApiKeysLoading = appId ? isAppApiKeysLoading : isDatasetApiKeysLoading @@ -65,25 +59,20 @@ const SecretKeyModal = ({ const onDel = async () => { setShowConfirmDelete(false) - if (!canManage) - return - if (!delKeyID) - return + if (!canManage) return + if (!delKeyID) return const delApikey = appId ? delAppApikey : delDatasetApikey const params = appId ? { url: `/apps/${appId}/api-keys/${delKeyID}`, params: {} } : { url: `/datasets/api-keys/${delKeyID}`, params: {} } await delApikey(params) - if (appId) - invalidateAppApiKeys(appId) - else - invalidateDatasetApiKeys() + if (appId) invalidateAppApiKeys(appId) + else invalidateDatasetApiKeys() } const onCreate = async () => { - if (!currentWorkspace.id || !canManage) - return + if (!currentWorkspace.id || !canManage) return const params = appId ? { url: `/apps/${appId}/api-keys`, body: {} } @@ -92,10 +81,8 @@ const SecretKeyModal = ({ const res = await createApikey(params) setIsVisible(true) setNewKey(res) - if (appId) - invalidateAppApiKeys(appId) - else - invalidateDatasetApiKeys() + if (appId) invalidateAppApiKeys(appId) + else invalidateDatasetApiKeys() } const generateToken = (token: string) => { @@ -103,8 +90,7 @@ const SecretKeyModal = ({ } const handleDeleteConfirmOpenChange = (open: boolean) => { - if (open) - return + if (open) return setDelKeyId('') setShowConfirmDelete(false) @@ -120,86 +106,123 @@ const SecretKeyModal = ({ <Dialog open={isShow} onOpenChange={(open) => { - if (!open) - handleClose() + if (!open) handleClose() }} > - <DialogContent className={cn('max-h-[calc(100vh-80px)]! w-full max-w-[800px]! overflow-hidden! border-none text-left align-middle', `${s.customModal} flex flex-col px-8`)}> + <DialogContent + className={cn( + 'max-h-[calc(100vh-80px)]! w-full max-w-[800px]! overflow-hidden! border-none text-left align-middle', + `${s.customModal} flex flex-col px-8`, + )} + > <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {`${t($ => $['apiKeyModal.apiSecretKey'], { ns: 'appApi' })}`} + {`${t(($) => $['apiKeyModal.apiSecretKey'], { ns: 'appApi' })}`} </DialogTitle> <div className="-mt-6 -mr-2 mb-4 flex justify-end"> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="flex size-6 cursor-pointer items-center justify-center text-text-tertiary" onClick={handleClose} > - <span className="i-heroicons-x-mark-20-solid size-6 cursor-pointer" aria-hidden="true" /> + <span + className="i-heroicons-x-mark-20-solid size-6 cursor-pointer" + aria-hidden="true" + /> </button> </div> - <p className="mt-1 shrink-0 text-[13px] leading-5 font-normal text-text-tertiary">{t($ => $['apiKeyModal.apiSecretKeyTips'], { ns: 'appApi' })}</p> - {isApiKeysLoading && <div className="mt-4"><Loading /></div>} - { - !!apiKeysList?.data?.length && ( - <div className="mt-4 flex grow flex-col overflow-hidden"> - <div className="flex h-9 shrink-0 items-center border-b border-divider-regular text-xs font-semibold text-text-tertiary"> - <div className="w-64 shrink-0 px-3">{t($ => $['apiKeyModal.secretKey'], { ns: 'appApi' })}</div> - <div className="w-[200px] shrink-0 px-3">{t($ => $['apiKeyModal.created'], { ns: 'appApi' })}</div> - <div className="w-[200px] shrink-0 px-3">{t($ => $['apiKeyModal.lastUsed'], { ns: 'appApi' })}</div> - <div className="grow px-3"></div> + <p className="mt-1 shrink-0 text-[13px] leading-5 font-normal text-text-tertiary"> + {t(($) => $['apiKeyModal.apiSecretKeyTips'], { ns: 'appApi' })} + </p> + {isApiKeysLoading && ( + <div className="mt-4"> + <Loading /> + </div> + )} + {!!apiKeysList?.data?.length && ( + <div className="mt-4 flex grow flex-col overflow-hidden"> + <div className="flex h-9 shrink-0 items-center border-b border-divider-regular text-xs font-semibold text-text-tertiary"> + <div className="w-64 shrink-0 px-3"> + {t(($) => $['apiKeyModal.secretKey'], { ns: 'appApi' })} </div> - <div className="grow overflow-auto"> - {apiKeysList.data.map(api => ( - <div className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary" key={api.id}> - <div className="w-64 shrink-0 truncate px-3 font-mono">{generateToken(api.token)}</div> - <div className="w-[200px] shrink-0 truncate px-3">{formatTime(Number(api.created_at), t($ => $.dateTimeFormat, { ns: 'appLog' }) as string)}</div> - <div className="w-[200px] shrink-0 truncate px-3">{api.last_used_at ? formatTime(Number(api.last_used_at), t($ => $.dateTimeFormat, { ns: 'appLog' }) as string) : t($ => $.never, { ns: 'appApi' })}</div> - <div className="flex grow space-x-2 px-3"> - <CopyFeedback content={api.token} /> - {canManage && ( - <ActionButton - onClick={() => { - setDelKeyId(api.id) - setShowConfirmDelete(true) - }} - > - <span className="i-ri-delete-bin-line size-4" /> - </ActionButton> - )} - </div> - </div> - ))} + <div className="w-[200px] shrink-0 px-3"> + {t(($) => $['apiKeyModal.created'], { ns: 'appApi' })} </div> + <div className="w-[200px] shrink-0 px-3"> + {t(($) => $['apiKeyModal.lastUsed'], { ns: 'appApi' })} + </div> + <div className="grow px-3"></div> </div> - ) - } + <div className="grow overflow-auto"> + {apiKeysList.data.map((api) => ( + <div + className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary" + key={api.id} + > + <div className="w-64 shrink-0 truncate px-3 font-mono"> + {generateToken(api.token)} + </div> + <div className="w-[200px] shrink-0 truncate px-3"> + {formatTime( + Number(api.created_at), + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + )} + </div> + <div className="w-[200px] shrink-0 truncate px-3"> + {api.last_used_at + ? formatTime( + Number(api.last_used_at), + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + ) + : t(($) => $.never, { ns: 'appApi' })} + </div> + <div className="flex grow space-x-2 px-3"> + <CopyFeedback content={api.token} /> + {canManage && ( + <ActionButton + onClick={() => { + setDelKeyId(api.id) + setShowConfirmDelete(true) + }} + > + <span className="i-ri-delete-bin-line size-4" /> + </ActionButton> + )} + </div> + </div> + ))} + </div> + </div> + )} <div className="flex"> - <Button className={`mt-4 flex shrink-0 ${s.autoWidth}`} onClick={onCreate} disabled={!currentWorkspace.id || !canManage}> + <Button + className={`mt-4 flex shrink-0 ${s.autoWidth}`} + onClick={onCreate} + disabled={!currentWorkspace.id || !canManage} + > <span className="mr-1 i-heroicons-plus-20-solid flex size-4 shrink-0" /> - <div className="text-xs font-medium text-text-secondary">{t($ => $['apiKeyModal.createNewSecretKey'], { ns: 'appApi' })}</div> + <div className="text-xs font-medium text-text-secondary"> + {t(($) => $['apiKeyModal.createNewSecretKey'], { ns: 'appApi' })} + </div> </Button> </div> - <AlertDialog - open={showConfirmDelete} - onOpenChange={handleDeleteConfirmOpenChange} - > + <AlertDialog open={showConfirmDelete} onOpenChange={handleDeleteConfirmOpenChange}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['actionMsg.deleteConfirmTitle'], { ns: 'appApi' })} + {t(($) => $['actionMsg.deleteConfirmTitle'], { ns: 'appApi' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['actionMsg.deleteConfirmTips'], { ns: 'appApi' })} + {t(($) => $['actionMsg.deleteConfirmTips'], { ns: 'appApi' })} </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={onDel}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> @@ -207,7 +230,12 @@ const SecretKeyModal = ({ </DialogContent> </Dialog> {isShow && ( - <SecretKeyGenerateModal className="shrink-0" isShow={isVisible} onClose={() => setIsVisible(false)} newKey={newKey} /> + <SecretKeyGenerateModal + className="shrink-0" + isShow={isVisible} + onClose={() => setIsVisible(false)} + newKey={newKey} + /> )} </> ) diff --git a/web/app/components/develop/secret-key/style.module.css b/web/app/components/develop/secret-key/style.module.css index f13161c888a56b..3a16187d8df3d0 100644 --- a/web/app/components/develop/secret-key/style.module.css +++ b/web/app/components/develop/secret-key/style.module.css @@ -1,57 +1,57 @@ .customModal { - max-width: 800px !important; - max-height: calc(100vh - 80px); + max-width: 800px !important; + max-height: calc(100vh - 80px); } .close { - top: 1.5rem; - right: 1.5rem; + top: 1.5rem; + right: 1.5rem; } .trash { - color: transparent; + color: transparent; } .w64 { - width: 4rem; + width: 4rem; } .customApi { - font-size: 11px; + font-size: 11px; } .autoWidth { - width: auto; + width: auto; } .trashIcon { - background-color: transparent; - background-image: url(./assets/trash-gray.svg); - background-position: center; - background-repeat: no-repeat; - background-size: 16px 16px; + background-color: transparent; + background-image: url(./assets/trash-gray.svg); + background-position: center; + background-repeat: no-repeat; + background-size: 16px 16px; } .trashIcon:hover { - background-color: rgba(254, 228, 226, 1); - background-image: url(./assets/trash-red.svg); - background-position: center; - background-repeat: no-repeat; - background-size: 16px 16px; + background-color: rgba(254, 228, 226, 1); + background-image: url(./assets/trash-red.svg); + background-position: center; + background-repeat: no-repeat; + background-size: 16px 16px; } .copyIcon { - background-image: url(./assets/copy.svg); - background-position: center; - background-repeat: no-repeat; + background-image: url(./assets/copy.svg); + background-position: center; + background-repeat: no-repeat; } .copyIcon:hover { - background-image: url(./assets/copy-hover.svg); - background-position: center; - background-repeat: no-repeat; + background-image: url(./assets/copy-hover.svg); + background-position: center; + background-repeat: no-repeat; } .copyIcon.copied { - background-image: url(./assets/copied.svg); + background-image: url(./assets/copied.svg); } diff --git a/web/app/components/develop/tag.tsx b/web/app/components/develop/tag.tsx index ae7e0bc6e1a92a..e3b5112ba3813b 100644 --- a/web/app/components/develop/tag.tsx +++ b/web/app/components/develop/tag.tsx @@ -53,7 +53,11 @@ export function Tag({ }: ITagProps) { return ( <span - className={cn('font-mono text-2xs/6 font-semibold', variantStyles[variant], colorStyles[color]![variant])} + className={cn( + 'font-mono text-2xs/6 font-semibold', + variantStyles[variant], + colorStyles[color]![variant], + )} > {children} </span> diff --git a/web/app/components/develop/toc-panel.tsx b/web/app/components/develop/toc-panel.tsx index a45c83bd8e98b5..0953973b320c62 100644 --- a/web/app/components/develop/toc-panel.tsx +++ b/web/app/components/develop/toc-panel.tsx @@ -31,7 +31,7 @@ const TocPanel = ({ toc, activeSection, isTocExpanded, onToggle, onItemClick }: <nav className="toc flex max-h-[calc(100vh-150px)] w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-background-default-hover shadow-xl"> <div className="relative z-10 flex items-center justify-between border-b border-components-panel-border-subtle bg-background-default-hover px-4 py-2.5"> <span className="text-xs font-medium tracking-wide text-text-tertiary uppercase"> - {t($ => $['develop.toc'], { ns: 'appApi' })} + {t(($) => $['develop.toc'], { ns: 'appApi' })} </span> <button type="button" @@ -47,45 +47,41 @@ const TocPanel = ({ toc, activeSection, isTocExpanded, onToggle, onItemClick }: <div className="pointer-events-none absolute top-[43px] right-0 left-0 z-10 h-3 bg-linear-to-b from-background-default-hover to-transparent"></div> <div className="relative flex-1 overflow-y-auto p-3 pt-1"> - {toc.length === 0 - ? ( - <div className="px-2 py-8 text-center text-xs text-text-quaternary"> - {t($ => $['develop.noContent'], { ns: 'appApi' })} - </div> - ) - : ( - <ul className="space-y-0.5"> - {toc.map((item) => { - const isActive = activeSection === item.href.replace('#', '') - return ( - <li key={item.href}> - <a - href={item.href} - onClick={e => onItemClick(e, item)} - className={cn( - 'group relative flex items-center rounded-md px-3 py-2 text-[13px] transition-all duration-200', - isActive - ? 'bg-state-base-hover font-medium text-text-primary' - : 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', - )} - > - <span - className={cn( - 'mr-2 size-1.5 rounded-full transition-all duration-200', - isActive - ? 'scale-100 bg-text-accent' - : 'scale-75 bg-components-panel-border', - )} - /> - <span className="flex-1 truncate"> - {item.text} - </span> - </a> - </li> - ) - })} - </ul> - )} + {toc.length === 0 ? ( + <div className="px-2 py-8 text-center text-xs text-text-quaternary"> + {t(($) => $['develop.noContent'], { ns: 'appApi' })} + </div> + ) : ( + <ul className="space-y-0.5"> + {toc.map((item) => { + const isActive = activeSection === item.href.replace('#', '') + return ( + <li key={item.href}> + <a + href={item.href} + onClick={(e) => onItemClick(e, item)} + className={cn( + 'group relative flex items-center rounded-md px-3 py-2 text-[13px] transition-all duration-200', + isActive + ? 'bg-state-base-hover font-medium text-text-primary' + : 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', + )} + > + <span + className={cn( + 'mr-2 size-1.5 rounded-full transition-all duration-200', + isActive + ? 'scale-100 bg-text-accent' + : 'scale-75 bg-components-panel-border', + )} + /> + <span className="flex-1 truncate">{item.text}</span> + </a> + </li> + ) + })} + </ul> + )} </div> <div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-4 rounded-b-xl bg-linear-to-t from-background-default-hover to-transparent"></div> diff --git a/web/app/components/devtools/agentation-loader.tsx b/web/app/components/devtools/agentation-loader.tsx index 87e1b44c87d559..4a46ac3d56528f 100644 --- a/web/app/components/devtools/agentation-loader.tsx +++ b/web/app/components/devtools/agentation-loader.tsx @@ -3,11 +3,12 @@ import { IS_DEV } from '@/config' import dynamic from '@/next/dynamic' -const Agentation = dynamic(() => import('agentation').then(module => module.Agentation), { ssr: false }) +const Agentation = dynamic(() => import('agentation').then((module) => module.Agentation), { + ssr: false, +}) export function AgentationLoader() { - if (!IS_DEV) - return null + if (!IS_DEV) return null return <Agentation /> } diff --git a/web/app/components/devtools/react-scan/loader.tsx b/web/app/components/devtools/react-scan/loader.tsx index 54eb2732c5296f..0a192288b38f37 100644 --- a/web/app/components/devtools/react-scan/loader.tsx +++ b/web/app/components/devtools/react-scan/loader.tsx @@ -2,8 +2,7 @@ import { IS_DEV } from '@/config' import Script from '@/next/script' export function ReactScanLoader() { - if (!IS_DEV) - return null + if (!IS_DEV) return null return ( <Script diff --git a/web/app/components/explore/__tests__/category.spec.tsx b/web/app/components/explore/__tests__/category.spec.tsx index 14797c8a3af4bb..feef68fbb97460 100644 --- a/web/app/components/explore/__tests__/category.spec.tsx +++ b/web/app/components/explore/__tests__/category.spec.tsx @@ -72,9 +72,17 @@ describe('Category', () => { it('should render categories as a radio group', () => { renderComponent({ value: 'Writing' }) - expect(screen.getByRole('radiogroup', { name: 'explore.tryApp.category' })).toHaveClass('bg-transparent') - expect(screen.getByRole('radio', { name: /explore\.apps\.allCategories/ })).toHaveAttribute('aria-checked', 'false') - expect(screen.getByRole('radio', { name: 'explore.category.Writing' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('radiogroup', { name: 'explore.tryApp.category' })).toHaveClass( + 'bg-transparent', + ) + expect(screen.getByRole('radio', { name: /explore\.apps\.allCategories/ })).toHaveAttribute( + 'aria-checked', + 'false', + ) + expect(screen.getByRole('radio', { name: 'explore.category.Writing' })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) }) }) diff --git a/web/app/components/explore/__tests__/index.spec.tsx b/web/app/components/explore/__tests__/index.spec.tsx index 2e3065f1fc4234..9d0927042f78aa 100644 --- a/web/app/components/explore/__tests__/index.spec.tsx +++ b/web/app/components/explore/__tests__/index.spec.tsx @@ -48,21 +48,21 @@ describe('Explore', () => { describe('Rendering', () => { it('should render children', () => { - render(( + render( <Explore> <div>child</div> - </Explore> - )) + </Explore>, + ) expect(screen.getByText('child')).toBeInTheDocument() }) it('should not render the legacy explore sidebar on desktop', () => { - render(( + render( <Explore> <div>child</div> - </Explore> - )) + </Explore>, + ) expect(screen.queryByText('explore.sidebar.title')).not.toBeInTheDocument() }) @@ -70,11 +70,11 @@ describe('Explore', () => { it('should keep the legacy explore sidebar on mobile', () => { mockMediaType = MediaType.mobile - render(( + render( <Explore> <div>child</div> - </Explore> - )) + </Explore>, + ) expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument() }) @@ -82,11 +82,11 @@ describe('Explore', () => { describe('Effects', () => { it('should not redirect at component level', () => { - render(( + render( <Explore> <div>child</div> - </Explore> - )) + </Explore>, + ) expect(mockReplace).not.toHaveBeenCalled() }) @@ -94,11 +94,11 @@ describe('Explore', () => { it('should not redirect on mobile', () => { mockMediaType = MediaType.mobile - render(( + render( <Explore> <div>child</div> - </Explore> - )) + </Explore>, + ) expect(mockReplace).not.toHaveBeenCalled() }) diff --git a/web/app/components/explore/app-card/index.tsx b/web/app/components/explore/app-card/index.tsx index 7c2d5cef272eea..e138b41fd0617f 100644 --- a/web/app/components/explore/app-card/index.tsx +++ b/web/app/components/explore/app-card/index.tsx @@ -18,13 +18,7 @@ export type AppCardProps = { isExplore?: boolean } -const AppCard = ({ - app, - canCreate, - onCreate, - onTry, - isExplore = true, -}: AppCardProps) => { +const AppCard = ({ app, canCreate, onCreate, onTry, isExplore = true }: AppCardProps) => { const { t } = useTranslation() const nameId = useId() const descriptionId = useId() @@ -47,8 +41,7 @@ const AppCard = ({ return } - if (canCreate) - onCreate() + if (canCreate) onCreate() } return ( @@ -84,19 +77,44 @@ const AppCard = ({ </div> <div className="flex w-0 grow flex-col gap-1 py-px"> <div className="flex items-center system-md-semibold text-text-secondary"> - <div id={nameId} className="truncate" title={appBasicInfo.name}>{appBasicInfo.name}</div> + <div id={nameId} className="truncate" title={appBasicInfo.name}> + {appBasicInfo.name} + </div> </div> <div className="flex items-center system-2xs-medium-uppercase text-text-tertiary"> - {appBasicInfo.mode === AppModeEnum.ADVANCED_CHAT && <div className="truncate">{t($ => $['types.advanced'], { ns: 'app' }).toUpperCase()}</div>} - {appBasicInfo.mode === AppModeEnum.CHAT && <div className="truncate">{t($ => $['types.chatbot'], { ns: 'app' }).toUpperCase()}</div>} - {appBasicInfo.mode === AppModeEnum.AGENT_CHAT && <div className="truncate">{t($ => $['types.agent'], { ns: 'app' }).toUpperCase()}</div>} - {appBasicInfo.mode === AppModeEnum.WORKFLOW && <div className="truncate">{t($ => $['types.workflow'], { ns: 'app' }).toUpperCase()}</div>} - {appBasicInfo.mode === AppModeEnum.COMPLETION && <div className="truncate">{t($ => $['types.completion'], { ns: 'app' }).toUpperCase()}</div>} + {appBasicInfo.mode === AppModeEnum.ADVANCED_CHAT && ( + <div className="truncate"> + {t(($) => $['types.advanced'], { ns: 'app' }).toUpperCase()} + </div> + )} + {appBasicInfo.mode === AppModeEnum.CHAT && ( + <div className="truncate"> + {t(($) => $['types.chatbot'], { ns: 'app' }).toUpperCase()} + </div> + )} + {appBasicInfo.mode === AppModeEnum.AGENT_CHAT && ( + <div className="truncate"> + {t(($) => $['types.agent'], { ns: 'app' }).toUpperCase()} + </div> + )} + {appBasicInfo.mode === AppModeEnum.WORKFLOW && ( + <div className="truncate"> + {t(($) => $['types.workflow'], { ns: 'app' }).toUpperCase()} + </div> + )} + {appBasicInfo.mode === AppModeEnum.COMPLETION && ( + <div className="truncate"> + {t(($) => $['types.completion'], { ns: 'app' }).toUpperCase()} + </div> + )} </div> </div> </div> <div className="flex shrink-0 items-start px-4 py-1"> - <div id={descriptionId} className="line-clamp-2 min-h-8 flex-1 system-xs-regular text-text-tertiary"> + <div + id={descriptionId} + className="line-clamp-2 min-h-8 flex-1 system-xs-regular text-text-tertiary" + > {app.description} </div> </div> diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index 42484e3937c5bf..b3e36dd39f6658 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -19,7 +19,10 @@ const mockAppContextState = vi.hoisted(() => ({ workspacePermissionKeys: [] as string[], })) -let mockExploreData: { categories: string[], allList: App[] } | undefined = { categories: [], allList: [] } +let mockExploreData: { categories: string[]; allList: App[] } | undefined = { + categories: [], + allList: [], +} let mockLearnDifyApps: App[] = [] let mockLearnDifyLoading = false let mockWorkspaceApps: WorkspaceApp[] = [] @@ -33,15 +36,26 @@ const mockHandleImportDSLConfirm = vi.fn() const mockTrackCreateApp = vi.fn() const toastMocks = vi.hoisted(() => { const record = vi.fn() - const api = Object.assign(vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })), { - success: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'success', message, ...options })), - error: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'error', message, ...options })), - warning: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'warning', message, ...options })), - info: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'info', message, ...options })), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }) + const api = Object.assign( + vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })), + { + success: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'success', message, ...options }), + ), + error: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'info', message, ...options }), + ), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), + }, + ) return { record, api } }) @@ -112,12 +126,24 @@ vi.mock('@/service/client', () => ({ explore: { apps: { get: { - queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'apps', 'get', input], + queryKey: ({ input }: { input?: unknown } = {}) => [ + 'console', + 'explore', + 'apps', + 'get', + input, + ], }, }, banners: { get: { - queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'banners', 'get', input], + queryKey: ({ input }: { input?: unknown } = {}) => [ + 'console', + 'explore', + 'banners', + 'get', + input, + ], }, }, }, @@ -151,7 +177,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -191,48 +218,61 @@ vi.mock('@/config', async (importOriginal) => { vi.mock('@/app/components/explore/create-app-modal', () => ({ default: (props: CreateAppModalProps) => { - if (!props.show) - return null + if (!props.show) return null return ( <div data-testid="create-app-modal"> <button data-testid="confirm-create" - onClick={() => props.onConfirm({ - name: 'New App', - icon_type: 'emoji', - icon: '🤖', - icon_background: '#fff', - description: 'desc', - })} + onClick={() => + props.onConfirm({ + name: 'New App', + icon_type: 'emoji', + icon: '🤖', + icon_background: '#fff', + description: 'desc', + }) + } > confirm </button> - <button data-testid="hide-create" onClick={props.onHide}>hide</button> + <button data-testid="hide-create" onClick={props.onHide}> + hide + </button> </div> ) }, })) vi.mock('../../try-app', () => ({ - default: ({ onCreate, onClose }: { onCreate: () => void, onClose: () => void }) => ( + default: ({ onCreate, onClose }: { onCreate: () => void; onClose: () => void }) => ( <div data-testid="try-app-panel"> - <button data-testid="try-app-create" onClick={onCreate}>create</button> - <button data-testid="try-app-close" onClick={onClose}>close</button> + <button data-testid="try-app-create" onClick={onCreate}> + create + </button> + <button data-testid="try-app-close" onClick={onClose}> + close + </button> </div> ), })) vi.mock('../../banner/banner', () => ({ default: ({ banners }: { banners: BannerType[] }) => ( - <div data-testid="explore-banner" data-banner-count={banners.length}>banner</div> + <div data-testid="explore-banner" data-banner-count={banners.length}> + banner + </div> ), })) vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({ - default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => ( + default: ({ onConfirm, onCancel }: { onConfirm: () => void; onCancel: () => void }) => ( <div data-testid="dsl-confirm-modal"> - <button data-testid="dsl-confirm" onClick={onConfirm}>confirm</button> - <button data-testid="dsl-cancel" onClick={onCancel}>cancel</button> + <button data-testid="dsl-confirm" onClick={onConfirm}> + confirm + </button> + <button data-testid="dsl-cancel" onClick={onCancel}> + cancel + </button> </div> ), })) @@ -264,41 +304,42 @@ const createApp = (overrides: Partial<App> = {}): App => ({ is_agent: overrides.is_agent ?? false, }) -const createWorkspaceApp = (overrides: Partial<WorkspaceApp> = {}): WorkspaceApp => ({ - id: overrides.id ?? 'workspace-app-1', - name: overrides.name ?? 'Workspace App', - description: overrides.description ?? 'Workspace app description', - author_name: overrides.author_name ?? 'Evan', - icon_type: overrides.icon_type ?? 'emoji', - icon: overrides.icon ?? '😀', - icon_background: overrides.icon_background ?? '#fff', - icon_url: overrides.icon_url ?? null, - use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false, - mode: overrides.mode ?? AppModeEnum.CHAT, - created_at: overrides.created_at ?? 1704067200, - updated_at: overrides.updated_at ?? 1704153600, - enable_site: overrides.enable_site ?? false, - enable_api: overrides.enable_api ?? false, - api_rpm: overrides.api_rpm ?? 60, - api_rph: overrides.api_rph ?? 3600, - is_demo: overrides.is_demo ?? false, - model_config: overrides.model_config, - app_model_config: overrides.app_model_config, - site: overrides.site, - api_base_url: overrides.api_base_url ?? '', - tags: overrides.tags ?? [], - access_mode: overrides.access_mode, - permission_keys: overrides.permission_keys, -} as WorkspaceApp) +const createWorkspaceApp = (overrides: Partial<WorkspaceApp> = {}): WorkspaceApp => + ({ + id: overrides.id ?? 'workspace-app-1', + name: overrides.name ?? 'Workspace App', + description: overrides.description ?? 'Workspace app description', + author_name: overrides.author_name ?? 'Evan', + icon_type: overrides.icon_type ?? 'emoji', + icon: overrides.icon ?? '😀', + icon_background: overrides.icon_background ?? '#fff', + icon_url: overrides.icon_url ?? null, + use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false, + mode: overrides.mode ?? AppModeEnum.CHAT, + created_at: overrides.created_at ?? 1704067200, + updated_at: overrides.updated_at ?? 1704153600, + enable_site: overrides.enable_site ?? false, + enable_api: overrides.enable_api ?? false, + api_rpm: overrides.api_rpm ?? 60, + api_rph: overrides.api_rph ?? 3600, + is_demo: overrides.is_demo ?? false, + model_config: overrides.model_config, + app_model_config: overrides.app_model_config, + site: overrides.site, + api_base_url: overrides.api_base_url ?? '', + tags: overrides.tags ?? [], + access_mode: overrides.access_mode, + permission_keys: overrides.permission_keys, + }) as WorkspaceApp const createBanner = (overrides: Partial<BannerType> = {}): BannerType => ({ id: overrides.id ?? 'banner-1', status: overrides.status ?? 'enabled', link: overrides.link ?? 'https://example.com', content: overrides.content ?? { - 'category': 'Featured', - 'title': 'Explore Banner', - 'description': 'Banner description', + category: 'Featured', + title: 'Explore Banner', + description: 'Banner description', 'img-src': 'https://example.com/banner.png', }, sort: overrides.sort ?? 1, @@ -306,7 +347,9 @@ const createBanner = (overrides: Partial<BannerType> = {}): BannerType => ({ }) const mockAppCreatePermission = (hasEditPermission: boolean) => { - mockAppContextState.workspacePermissionKeys = hasEditPermission ? ['app.create_and_management'] : [] + mockAppContextState.workspacePermissionKeys = hasEditPermission + ? ['app.create_and_management'] + : [] } type RenderOptions = { @@ -344,11 +387,9 @@ const renderAppList = ( if (mockIsLoading) { mockFetchAppList.mockImplementation(() => new Promise(() => {})) - } - else if (mockIsError) { + } else if (mockIsError) { mockFetchAppList.mockRejectedValue(new Error('Failed to load explore apps')) - } - else { + } else { mockFetchAppList.mockResolvedValue({ categories: mockExploreData?.categories ?? [], recommended_apps: mockExploreData?.allList ?? [], @@ -357,8 +398,7 @@ const renderAppList = ( if (mockBannersLoading) { mockFetchBanners.mockImplementation(() => new Promise(() => {})) - } - else { + } else { mockFetchBanners.mockResolvedValue(mockBanners) } @@ -368,7 +408,9 @@ const renderAppList = ( </JotaiProvider> ) const rendered = renderWithNuqs( - <Wrapped><AppList onSuccess={onSuccess} /></Wrapped>, + <Wrapped> + <AppList onSuccess={onSuccess} /> + </Wrapped>, { searchParams }, ) return { ...rendered, queryClient } @@ -430,7 +472,9 @@ describe('AppList', () => { renderAppList() - expect(screen.queryByRole('heading', { name: 'explore.continueWork.title' })).not.toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'explore.continueWork.title' }), + ).not.toBeInTheDocument() expect(screen.getAllByRole('status', { name: 'common.loading' })).toHaveLength(1) }) @@ -444,7 +488,9 @@ describe('AppList', () => { renderAppList() - expect(screen.queryByRole('heading', { name: 'explore.learnDify.title' })).not.toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'explore.learnDify.title' }), + ).not.toBeInTheDocument() expect(screen.queryByRole('status', { name: 'common.loading' })).not.toBeInTheDocument() }) @@ -459,14 +505,23 @@ describe('AppList', () => { renderAppList() - expect(screen.queryByRole('heading', { name: 'explore.learnDify.title' })).not.toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'explore.learnDify.title' }), + ).not.toBeInTheDocument() expect(screen.queryByRole('status', { name: 'common.loading' })).not.toBeInTheDocument() }) it('should render app cards when data is available', () => { mockExploreData = { categories: ['Writing', 'Translate'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, categories: ['Translate'] })], + allList: [ + createApp(), + createApp({ + app_id: 'app-2', + app: { ...createApp().app, name: 'Beta' }, + categories: ['Translate'], + }), + ], } renderAppList() @@ -482,7 +537,12 @@ describe('AppList', () => { allList: [createApp()], } mockWorkspaceApps = [ - createWorkspaceApp({ id: 'app-1', name: 'Email Reply', author_name: 'Evan', permission_keys: [AppACLPermission.Monitor] }), + createWorkspaceApp({ + id: 'app-1', + name: 'Email Reply', + author_name: 'Evan', + permission_keys: [AppACLPermission.Monitor], + }), createWorkspaceApp({ id: 'app-2', name: 'Feature Copilot', author_name: 'Maggie' }), createWorkspaceApp({ id: 'app-3', name: 'Book Translation', author_name: 'Alex' }), createWorkspaceApp({ id: 'app-4', name: 'Logo Design', author_name: 'Taylor' }), @@ -495,7 +555,9 @@ describe('AppList', () => { renderAppList() - expect(screen.getByRole('heading', { name: 'explore.continueWork.title' })).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'explore.continueWork.title' }), + ).toBeInTheDocument() expect(screen.getByText('Email Reply')).toBeInTheDocument() expect(screen.getByText('Feature Copilot')).toBeInTheDocument() expect(screen.getByText('Book Translation')).toBeInTheDocument() @@ -506,9 +568,16 @@ describe('AppList', () => { expect(screen.getByText('Support Draft')).toBeInTheDocument() expect(screen.queryByText('Hidden Ninth App')).not.toBeInTheDocument() expect(screen.getByText('Maggie')).toBeInTheDocument() - expect(screen.getAllByText('explore.continueWork.editedAt:{"time":"3 minutes ago"}')).toHaveLength(8) - expect(screen.getByRole('link', { name: /Email Reply/ })).toHaveAttribute('href', '/app/app-1/overview') - expect(screen.getByRole('link', { name: 'explore.continueWork.exploreStudio' })).toHaveAttribute('href', '/apps') + expect( + screen.getAllByText('explore.continueWork.editedAt:{"time":"3 minutes ago"}'), + ).toHaveLength(8) + expect(screen.getByRole('link', { name: /Email Reply/ })).toHaveAttribute( + 'href', + '/app/app-1/overview', + ) + expect( + screen.getByRole('link', { name: 'explore.continueWork.exploreStudio' }), + ).toHaveAttribute('href', '/apps') }) it('should render preview-only continue work app as a dimmed card and warn on click', () => { @@ -550,7 +619,9 @@ describe('AppList', () => { renderAppList() - expect(screen.queryByRole('heading', { name: 'explore.continueWork.title' })).not.toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'explore.continueWork.title' }), + ).not.toBeInTheDocument() }) it('should render learn dify templates without badges or template metadata', () => { @@ -564,7 +635,9 @@ describe('AppList', () => { expect(screen.getByRole('heading', { name: 'explore.learnDify.title' })).toBeInTheDocument() expect(screen.getByText('Learn Workflow Basics')).toBeInTheDocument() expect(screen.getByText('Learn Agent Basics')).toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'explore.learnDify.moreTemplates' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'explore.learnDify.moreTemplates' }), + ).not.toBeInTheDocument() expect(screen.queryByText('Run this first')).not.toBeInTheDocument() expect(screen.queryByText('Then try this')).not.toBeInTheDocument() expect(screen.queryByText('workflow')).not.toBeInTheDocument() @@ -579,7 +652,9 @@ describe('AppList', () => { renderAppList(false, undefined, undefined, { enableLearnApp: false }) - expect(screen.queryByRole('heading', { name: 'explore.learnDify.title' })).not.toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'explore.learnDify.title' }), + ).not.toBeInTheDocument() expect(screen.queryByText('Learn Workflow Basics')).not.toBeInTheDocument() }) @@ -593,7 +668,9 @@ describe('AppList', () => { fireEvent.click(screen.getByRole('button', { name: 'explore.learnDify.hide' })) - const learnDifySection = screen.getByRole('heading', { name: 'explore.learnDify.title' }).closest('section') + const learnDifySection = screen + .getByRole('heading', { name: 'explore.learnDify.title' }) + .closest('section') expect(learnDifySection).toHaveClass('z-50', 'opacity-20') expect(learnDifySection).toHaveStyle({ transform: 'scale(0.08)' }) @@ -601,7 +678,9 @@ describe('AppList', () => { await vi.advanceTimersByTimeAsync(800) }) - expect(screen.queryByRole('heading', { name: 'explore.learnDify.title' })).not.toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'explore.learnDify.title' }), + ).not.toBeInTheDocument() expect(localStorage.getItem(LEARN_DIFY_HIDDEN_STORAGE_KEY)).toBe('true') }) }) @@ -610,7 +689,14 @@ describe('AppList', () => { it('should filter apps by selected category', () => { mockExploreData = { categories: ['Writing', 'Translate'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, categories: ['Translate'] })], + allList: [ + createApp(), + createApp({ + app_id: 'app-2', + app: { ...createApp().app, name: 'Beta' }, + categories: ['Translate'], + }), + ], } renderAppList(false, undefined, { category: 'Writing' }) @@ -634,7 +720,14 @@ describe('AppList', () => { it('should keep selected category when clearing search text', async () => { mockExploreData = { categories: ['Writing', 'Translate'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, categories: ['Translate'] })], + allList: [ + createApp(), + createApp({ + app_id: 'app-2', + app: { ...createApp().app, name: 'Beta' }, + categories: ['Translate'], + }), + ], } renderAppList(false, undefined, { category: 'Writing' }) @@ -658,7 +751,10 @@ describe('AppList', () => { it('should filter apps by search keywords', async () => { mockExploreData = { categories: ['Writing'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], + allList: [ + createApp(), + createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } }), + ], } renderAppList() @@ -679,14 +775,21 @@ describe('AppList', () => { mockExploreData = { categories: ['Writing'], allList: [createApp()], - }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT }) - mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => { - options.onPending?.() - }) - mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { - options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) + } + ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + export_data: 'yaml-content', + mode: AppModeEnum.CHAT, }) + mockHandleImportDSL.mockImplementation( + async (_payload: unknown, options: { onSuccess?: () => void; onPending?: () => void }) => { + options.onPending?.() + }, + ) + mockHandleImportDSLConfirm.mockImplementation( + async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { + options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) + }, + ) renderAppList(true, onSuccess) fireEvent.click(screen.getByRole('button', { name: 'Alpha' })) @@ -715,11 +818,19 @@ describe('AppList', () => { mockExploreData = { categories: ['Writing'], allList: [createApp()], - }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT }) - mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { - options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) + } + ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + export_data: 'yaml-content', + mode: AppModeEnum.CHAT, }) + mockHandleImportDSL.mockImplementation( + async ( + _payload: unknown, + options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }, + ) => { + options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) + }, + ) renderAppList(true) fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' })) @@ -735,7 +846,10 @@ describe('AppList', () => { it('should reset search results when clear icon is clicked', async () => { mockExploreData = { categories: ['Writing'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], + allList: [ + createApp(), + createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } }), + ], } renderAppList() @@ -781,8 +895,11 @@ describe('AppList', () => { mockExploreData = { categories: ['Writing'], allList: [createApp()], - }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) + } + ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + export_data: 'yaml', + mode: AppModeEnum.CHAT, + }) renderAppList(true) fireEvent.click(screen.getByRole('button', { name: 'Alpha' })) @@ -800,11 +917,19 @@ describe('AppList', () => { mockExploreData = { categories: ['Writing'], allList: [createApp()], - }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) - mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { - options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) + } + ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + export_data: 'yaml', + mode: AppModeEnum.CHAT, }) + mockHandleImportDSL.mockImplementation( + async ( + _payload: unknown, + options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }, + ) => { + options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) + }, + ) renderAppList(true) fireEvent.click(screen.getByRole('button', { name: 'Alpha' })) @@ -820,11 +945,16 @@ describe('AppList', () => { mockExploreData = { categories: ['Writing'], allList: [createApp()], - }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) - mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { - options.onPending?.() + } + ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + export_data: 'yaml', + mode: AppModeEnum.CHAT, }) + mockHandleImportDSL.mockImplementation( + async (_payload: unknown, options: { onPending?: () => void }) => { + options.onPending?.() + }, + ) renderAppList(true) fireEvent.click(screen.getByRole('button', { name: 'Alpha' })) @@ -866,11 +996,19 @@ describe('AppList', () => { mockExploreData = { categories: ['Writing'], allList: [createApp()], - }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) - mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { - options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) + } + ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + export_data: 'yaml', + mode: AppModeEnum.CHAT, }) + mockHandleImportDSL.mockImplementation( + async ( + _payload: unknown, + options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }, + ) => { + options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) + }, + ) renderAppList(true, undefined, undefined, { isCloudEdition: true }) diff --git a/web/app/components/explore/app-list/explore-app-list-header.tsx b/web/app/components/explore/app-list/explore-app-list-header.tsx index 49de51fc2cce78..424089b26dacd1 100644 --- a/web/app/components/explore/app-list/explore-app-list-header.tsx +++ b/web/app/components/explore/app-list/explore-app-list-header.tsx @@ -25,7 +25,7 @@ export function ExploreAppListHeader({ <div className="sticky top-0 z-10 bg-background-body"> <div className="flex items-center gap-2 px-8 pt-6"> <div className="min-w-0 flex-1 truncate system-xl-medium text-text-primary"> - {t($ => $['apps.title'], { ns: 'explore' })} + {t(($) => $['apps.title'], { ns: 'explore' })} </div> <a href="https://marketplace.dify.ai/templates" @@ -33,7 +33,7 @@ export function ExploreAppListHeader({ rel="noopener noreferrer" className="flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary hover:text-text-secondary" > - {t($ => $['apps.viewMore'], { ns: 'explore' })} + {t(($) => $['apps.viewMore'], { ns: 'explore' })} <span className="i-ri-arrow-right-line size-3 shrink-0" aria-hidden="true" /> </a> </div> diff --git a/web/app/components/explore/app-list/explore-recommendations.tsx b/web/app/components/explore/app-list/explore-recommendations.tsx index e9dce2379cd9f0..3cd29493989c4b 100644 --- a/web/app/components/explore/app-list/explore-recommendations.tsx +++ b/web/app/components/explore/app-list/explore-recommendations.tsx @@ -22,12 +22,7 @@ export function ExploreRecommendations({ return ( <> <ContinueWork apps={continueWorkApps} /> - <LearnDify - canCreate={canCreate} - className="pb-0" - onCreate={onCreate} - onTry={onTry} - /> + <LearnDify canCreate={canCreate} className="pb-0" onCreate={onCreate} onTry={onTry} /> </> ) } diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 64fb6f68790d6d..5f0964fd5cf13d 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -52,9 +52,7 @@ const homeContinueWorkAppsInput = { const disabledBannersQueryKey = ['explore', 'home', 'banners', 'disabled'] as const function getLocaleQueryInput(locale?: string) { - return locale - ? { query: { language: locale } } - : {} + return locale ? { query: { language: locale } } : {} } function getExploreAppListQueryOptions(locale?: string) { @@ -103,9 +101,7 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { const { t } = useTranslation() const locale = useLocale() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const { data: systemFeatures } = useSuspenseQuery( - systemFeaturesQueryOptions(), - ) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const homeQueries = useQueries({ queries: [ getExploreAppListQueryOptions(locale), @@ -118,11 +114,14 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { appListData: exploreAppListQuery.data, continueWorkApps: continueWorkAppsQuery.data ?? [], banners: bannersQuery.data ?? [], - isPending: exploreAppListQuery.isPending || continueWorkAppsQuery.isPending || bannersQuery.isPending, - isAppListError: exploreAppListQuery.isError || (!exploreAppListQuery.isPending && !exploreAppListQuery.data), + isPending: + exploreAppListQuery.isPending || continueWorkAppsQuery.isPending || bannersQuery.isPending, + isAppListError: + exploreAppListQuery.isError || + (!exploreAppListQuery.isPending && !exploreAppListQuery.data), }), }) - const allCategoriesEn = t($ => $['apps.allCategories'], { ns: 'explore', lng: 'en' }) + const allCategoriesEn = t(($) => $['apps.allCategories'], { ns: 'explore', lng: 'en' }) const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') const [keywords, setKeywords] = useState('') @@ -145,55 +144,43 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { }) const visibleCategories = useMemo(() => { - if (!homeQueries.appListData) - return [] + if (!homeQueries.appListData) return [] const categoriesWithApps = new Set<string>() homeQueries.appListData.allList.forEach((app) => { - app.categories.forEach(category => categoriesWithApps.add(category)) + app.categories.forEach((category) => categoriesWithApps.add(category)) }) - return homeQueries.appListData.categories.filter(category => categoriesWithApps.has(category)) + return homeQueries.appListData.categories.filter((category) => categoriesWithApps.has(category)) }, [homeQueries.appListData]) - const activeCategory = visibleCategories.includes(currCategory) - ? currCategory - : allCategoriesEn + const activeCategory = visibleCategories.includes(currCategory) ? currCategory : allCategoriesEn const filteredList = useMemo(() => { - if (!homeQueries.appListData) - return [] + if (!homeQueries.appListData) return [] return homeQueries.appListData.allList.filter( - item => - activeCategory === allCategoriesEn - || item.categories?.includes(activeCategory), + (item) => activeCategory === allCategoriesEn || item.categories?.includes(activeCategory), ) }, [homeQueries.appListData, activeCategory, allCategoriesEn]) const searchFilteredList = useMemo(() => { - if (!searchKeywords || !filteredList || filteredList.length === 0) - return filteredList + if (!searchKeywords || !filteredList || filteredList.length === 0) return filteredList const lowerCaseSearchKeywords = searchKeywords.toLowerCase() return filteredList.filter( - item => - item.app - && item.app.name - && item.app.name.toLowerCase().includes(lowerCaseSearchKeywords), + (item) => + item.app && item.app.name && item.app.name.toLowerCase().includes(lowerCaseSearchKeywords), ) }, [searchKeywords, filteredList]) const [currApp, setCurrApp] = useState<App | null>(null) const [isShowCreateModal, setIsShowCreateModal] = useState(false) - const { handleImportDSL, handleImportDSLConfirm, versions, isFetching } - = useImportDSL() + const { handleImportDSL, handleImportDSLConfirm, versions, isFetching } = useImportDSL() const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) - const [currentTryApp, setCurrentTryApp] = useState< - TryAppSelection | undefined - >(undefined) + const [currentTryApp, setCurrentTryApp] = useState<TryAppSelection | undefined>(undefined) const currentCreateAppModeRef = useRef<App['app']['mode'] | null>(null) const currentCreateAppTrackingRef = useRef<Pick< TrackCreateAppParams, @@ -226,30 +213,25 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { setCurrApp(app) setIsShowCreateModal(true) }, []) - const trackCurrentCreateApp = useCallback( - (appMode?: App['app']['mode'] | null) => { - const currentCreateAppTracking = currentCreateAppTrackingRef.current - const resolvedAppMode = appMode ?? currentCreateAppModeRef.current - if (!resolvedAppMode || !currentCreateAppTracking) - return - - trackCreateApp({ - ...currentCreateAppTracking, - appMode: resolvedAppMode, - }) - currentCreateAppTrackingRef.current = null - currentCreateAppModeRef.current = null - }, - [], - ) + const trackCurrentCreateApp = useCallback((appMode?: App['app']['mode'] | null) => { + const currentCreateAppTracking = currentCreateAppTrackingRef.current + const resolvedAppMode = appMode ?? currentCreateAppModeRef.current + if (!resolvedAppMode || !currentCreateAppTracking) return + + trackCreateApp({ + ...currentCreateAppTracking, + appMode: resolvedAppMode, + }) + currentCreateAppTrackingRef.current = null + currentCreateAppModeRef.current = null + }, []) const onCreate: CreateAppModalProps['onConfirm'] = useCallback( async ({ name, icon_type, icon, icon_background, description }) => { hideTryAppPanel() const appId = currApp?.app.id - if (!appId) - return + if (!appId) return const { export_data, mode } = await fetchAppDetail(appId) currentCreateAppModeRef.current = mode @@ -284,8 +266,7 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { }) }, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp]) - if (homeQueries.isAppListError) - return null + if (homeQueries.isAppListError) return null return ( <div @@ -294,51 +275,42 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { )} > <div className="flex flex-1 flex-col overflow-y-auto"> - {homeQueries.isPending - ? ( - <ExploreHomeSkeleton showBanner={systemFeatures.enable_explore_banner} /> - ) - : ( - <> - {systemFeatures.enable_explore_banner && ( - <Banner banners={homeQueries.banners} /> - )} - <ExploreRecommendations - canCreate={canCreateApp} - continueWorkApps={homeQueries.continueWorkApps} - onCreate={handleCreateFromLearnDify} - onTry={handleTryApp} - /> - - <ExploreAppListHeader - allCategoriesEn={allCategoriesEn} - categories={visibleCategories} - currCategory={activeCategory} - keywords={keywords} - onCategoryChange={setCurrCategory} - onKeywordsChange={handleKeywordsChange} - /> - - <div className={cn('relative flex flex-1 shrink-0 grow flex-col pb-6')}> - <nav - className={cn( - s.appList, - 'grid shrink-0 content-start gap-3 px-8', - )} - > - {searchFilteredList.map(app => ( - <AppCard - key={app.app_id} - app={app} - canCreate={canCreateApp} - onCreate={() => handleCreateFromAppList(app)} - onTry={handleTryApp} - /> - ))} - </nav> - </div> - </> - )} + {homeQueries.isPending ? ( + <ExploreHomeSkeleton showBanner={systemFeatures.enable_explore_banner} /> + ) : ( + <> + {systemFeatures.enable_explore_banner && <Banner banners={homeQueries.banners} />} + <ExploreRecommendations + canCreate={canCreateApp} + continueWorkApps={homeQueries.continueWorkApps} + onCreate={handleCreateFromLearnDify} + onTry={handleTryApp} + /> + + <ExploreAppListHeader + allCategoriesEn={allCategoriesEn} + categories={visibleCategories} + currCategory={activeCategory} + keywords={keywords} + onCategoryChange={setCurrCategory} + onKeywordsChange={handleKeywordsChange} + /> + + <div className={cn('relative flex flex-1 shrink-0 grow flex-col pb-6')}> + <nav className={cn(s.appList, 'grid shrink-0 content-start gap-3 px-8')}> + {searchFilteredList.map((app) => ( + <AppCard + key={app.app_id} + app={app} + canCreate={canCreateApp} + onCreate={() => handleCreateFromAppList(app)} + onTry={handleTryApp} + /> + ))} + </nav> + </div> + </> + )} </div> {isShowCreateModal && ( <CreateAppModal diff --git a/web/app/components/explore/app-list/loading-skeletons.tsx b/web/app/components/explore/app-list/loading-skeletons.tsx index 1a508394703b38..727a80c0c435e5 100644 --- a/web/app/components/explore/app-list/loading-skeletons.tsx +++ b/web/app/components/explore/app-list/loading-skeletons.tsx @@ -47,7 +47,10 @@ function RecommendationSectionSkeletonBody({ </div> <div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5"> {Array.from({ length: 4 }, (_, index) => ( - <div key={index} className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg px-4 pt-4 pb-4 shadow-xs"> + <div + key={index} + className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg px-4 pt-4 pb-4 shadow-xs" + > <div className="flex flex-col items-start gap-2 pb-1"> <SkeletonRectangle className="size-10 shrink-0 animate-pulse rounded-[10px]" /> <SkeletonRectangle className="h-4 w-3/4 animate-pulse" /> @@ -72,7 +75,10 @@ function RecommendationSectionSkeletonBody({ </div> <div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-3"> {Array.from({ length: 4 }, (_, index) => ( - <div key={index} className="rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-4 py-3 shadow-md"> + <div + key={index} + className="rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-4 py-3 shadow-md" + > <SkeletonRow> <SkeletonRectangle className="size-10 shrink-0 animate-pulse rounded-lg" /> <div className="flex min-w-0 flex-1 flex-col gap-1"> @@ -130,15 +136,11 @@ function BannerSkeletonBody() { ) } -export function ExploreHomeSkeleton({ - showBanner, -}: { - showBanner: boolean -}) { +export function ExploreHomeSkeleton({ showBanner }: { showBanner: boolean }) { const { t } = useTranslation() return ( - <div role="status" aria-label={t($ => $.loading, { ns: 'common' })} className="contents"> + <div role="status" aria-label={t(($) => $.loading, { ns: 'common' })} className="contents"> {showBanner && <BannerSkeletonBody />} <section className="px-8 pb-5"> <RecommendationSectionSkeletonBody /> diff --git a/web/app/components/explore/app-list/style.module.css b/web/app/components/explore/app-list/style.module.css index c9b41680e3536c..76427ae00e1d72 100644 --- a/web/app/components/explore/app-list/style.module.css +++ b/web/app/components/explore/app-list/style.module.css @@ -7,17 +7,17 @@ } .appList { - grid-template-columns: repeat(1, minmax(0, 1fr)) + grid-template-columns: repeat(1, minmax(0, 1fr)); } @media (min-width: 1280px) { .appList { - grid-template-columns: repeat(4, minmax(0, 1fr)) + grid-template-columns: repeat(4, minmax(0, 1fr)); } } @media (min-width: 640px) and (max-width: 1279px) { .appList { - grid-template-columns: repeat(2, minmax(0, 1fr)) + grid-template-columns: repeat(2, minmax(0, 1fr)); } } diff --git a/web/app/components/explore/banner/__tests__/banner-item.spec.tsx b/web/app/components/explore/banner/__tests__/banner-item.spec.tsx index 721c24c95c7acd..35cd248fabc8e1 100644 --- a/web/app/components/explore/banner/__tests__/banner-item.spec.tsx +++ b/web/app/components/explore/banner/__tests__/banner-item.spec.tsx @@ -22,25 +22,25 @@ vi.mock('@/app/components/base/carousel', () => ({ }), })) -const createMockBanner = (overrides: Partial<Banner> = {}): Banner => ({ - id: 'banner-1', - status: 'enabled', - link: 'https://example.com', - content: { - 'category': 'Featured', - 'title': 'Test Banner Title', - 'description': 'Test banner description text', - 'img-src': 'https://example.com/image.png', - }, - ...overrides, -} as Banner) +const createMockBanner = (overrides: Partial<Banner> = {}): Banner => + ({ + id: 'banner-1', + status: 'enabled', + link: 'https://example.com', + content: { + category: 'Featured', + title: 'Test Banner Title', + description: 'Test banner description text', + 'img-src': 'https://example.com/image.png', + }, + ...overrides, + }) as Banner const mockResizeObserverObserve = vi.fn() const mockResizeObserverDisconnect = vi.fn() class MockResizeObserver { - constructor(_callback: ResizeObserverCallback) { - } + constructor(_callback: ResizeObserverCallback) {} observe(...args: Parameters<ResizeObserver['observe']>) { mockResizeObserverObserve(...args) @@ -50,8 +50,7 @@ class MockResizeObserver { mockResizeObserverDisconnect() } - unobserve() { - } + unobserve() {} } const renderBannerItem = ( @@ -59,13 +58,7 @@ const renderBannerItem = ( props: Partial<ComponentProps<typeof BannerItem>> = {}, ) => { return render( - <BannerItem - banner={banner} - autoplayDelay={5000} - sort={1} - language="en-US" - {...props} - />, + <BannerItem banner={banner} autoplayDelay={5000} sort={1} language="en-US" {...props} />, ) } @@ -128,19 +121,24 @@ describe('BannerItem', () => { const banner = createMockBanner({ link: 'https://test-link.com' }) renderBannerItem(banner, { sort: 2, language: 'zh-Hans', accountId: 'account-123' }) - const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]') + const bannerElement = screen + .getByText('Test Banner Title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(bannerElement!) - expect(mockTrackEvent).toHaveBeenCalledWith('explore_banner_click', expect.objectContaining({ - banner_id: 'banner-1', - title: 'Test Banner Title', - sort: 2, - link: 'https://test-link.com', - page: 'explore', - language: 'zh-Hans', - account_id: 'account-123', - event_time: expect.any(Number), - })) + expect(mockTrackEvent).toHaveBeenCalledWith( + 'explore_banner_click', + expect.objectContaining({ + banner_id: 'banner-1', + title: 'Test Banner Title', + sort: 2, + link: 'https://test-link.com', + page: 'explore', + language: 'zh-Hans', + account_id: 'account-123', + event_time: expect.any(Number), + }), + ) expect(mockWindowOpen).toHaveBeenCalledWith( 'https://test-link.com', '_blank', @@ -152,12 +150,17 @@ describe('BannerItem', () => { const banner = createMockBanner({ link: '' }) renderBannerItem(banner) - const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]') + const bannerElement = screen + .getByText('Test Banner Title') + .closest('div[class*="cursor-pointer"]') fireEvent.click(bannerElement!) - expect(mockTrackEvent).toHaveBeenCalledWith('explore_banner_click', expect.objectContaining({ - link: '', - })) + expect(mockTrackEvent).toHaveBeenCalledWith( + 'explore_banner_click', + expect.objectContaining({ + link: '', + }), + ) expect(mockWindowOpen).not.toHaveBeenCalled() }) }) @@ -256,9 +259,9 @@ describe('BannerItem', () => { it('renders long category text', () => { const banner = createMockBanner({ content: { - 'category': 'Very Long Category Name', - 'title': 'Title', - 'description': 'Description', + category: 'Very Long Category Name', + title: 'Title', + description: 'Description', 'img-src': 'https://example.com/img.png', }, } as Partial<Banner>) @@ -270,9 +273,9 @@ describe('BannerItem', () => { it('renders category outside the title and description layout', () => { const banner = createMockBanner({ content: { - 'category': 'Category', - 'title': 'Title', - 'description': 'Description', + category: 'Category', + title: 'Title', + description: 'Description', 'img-src': 'https://example.com/img.png', }, } as Partial<Banner>) @@ -291,24 +294,30 @@ describe('BannerItem', () => { it('renders long title with truncation class', () => { const banner = createMockBanner({ content: { - 'category': 'Category', - 'title': 'A Very Long Title That Should Be Truncated Eventually', - 'description': 'Description', + category: 'Category', + title: 'A Very Long Title That Should Be Truncated Eventually', + description: 'Description', 'img-src': 'https://example.com/img.png', }, } as Partial<Banner>) renderBannerItem(banner) const titleElement = screen.getByText('A Very Long Title That Should Be Truncated Eventually') - expect(titleElement).toHaveClass('line-clamp-2', 'min-h-[3.6rem]', 'w-full', 'wrap-break-word') + expect(titleElement).toHaveClass( + 'line-clamp-2', + 'min-h-[3.6rem]', + 'w-full', + 'wrap-break-word', + ) }) it('renders long description with truncation class', () => { const banner = createMockBanner({ content: { - 'category': 'Category', - 'title': 'Title', - 'description': 'A very long description that should be limited to a certain number of lines for proper display in the banner component.', + category: 'Category', + title: 'Title', + description: + 'A very long description that should be limited to a certain number of lines for proper display in the banner component.', 'img-src': 'https://example.com/img.png', }, } as Partial<Banner>) @@ -349,9 +358,9 @@ describe('BannerItem', () => { it('keeps the desktop height even when text content is empty', () => { const banner = createMockBanner({ content: { - 'category': '', - 'title': '', - 'description': '', + category: '', + title: '', + description: '', 'img-src': 'https://example.com/img.png', }, } as Partial<Banner>) diff --git a/web/app/components/explore/banner/__tests__/banner.spec.tsx b/web/app/components/explore/banner/__tests__/banner.spec.tsx index c1f9588de80e49..2c7fb64f885a7e 100644 --- a/web/app/components/explore/banner/__tests__/banner.spec.tsx +++ b/web/app/components/explore/banner/__tests__/banner.spec.tsx @@ -18,7 +18,7 @@ const mockAppContextState = vi.hoisted(() => ({ const setMockSelectedIndex = (index: number) => { mockSelectedIndex = index - mockCarouselListeners.forEach(listener => listener()) + mockCarouselListeners.forEach((listener) => listener()) } vi.mock('@/context/account-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') @@ -47,7 +47,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -58,30 +59,22 @@ vi.mock('@/app/components/base/amplitude', () => ({ vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ i18n: { language: 'en-US' }, t: withSelectorKey((key: string, opts?: Record<string, unknown>) => { - if (key === 'banner.greeting') - return `Welcome back, ${opts?.name} 👋` - if (key === 'banner.tagline') - return 'What if… this is where your next idea begins.' + if (key === 'banner.greeting') return `Welcome back, ${opts?.name} 👋` + if (key === 'banner.tagline') return 'What if… this is where your next idea begins.' return key }), }), - }) + } }) vi.mock('@/app/components/base/carousel', () => ({ Carousel: Object.assign( - ({ children, className }: { - children: React.ReactNode - className?: string - }) => ( - <div - data-testid="carousel" - className={className} - > + ({ children, className }: { children: React.ReactNode; className?: string }) => ( + <div data-testid="carousel" className={className}> {children} </div> ), @@ -118,7 +111,14 @@ vi.mock('@/app/components/base/carousel', () => ({ })) vi.mock('../banner-item', () => ({ - BannerItem: ({ banner, autoplayDelay, isPaused, sort, language, accountId }: { + BannerItem: ({ + banner, + autoplayDelay, + isPaused, + sort, + language, + accountId, + }: { banner: BannerType autoplayDelay: number sort: number @@ -135,24 +135,27 @@ vi.mock('../banner-item', () => ({ data-language={language} data-account-id={accountId} > - BannerItem: - {' '} - {banner.content.title} + BannerItem: {banner.content.title} </div> ), })) -const createMockBanner = (id: string, status: string = 'enabled', title: string = 'Test Banner'): BannerType => ({ - id, - status, - link: 'https://example.com', - content: { - 'category': 'Featured', - title, - 'description': 'Test description', - 'img-src': `https://example.com/image-${id}.png`, - }, -} as BannerType) +const createMockBanner = ( + id: string, + status: string = 'enabled', + title: string = 'Test Banner', +): BannerType => + ({ + id, + status, + link: 'https://example.com', + content: { + category: 'Featured', + title, + description: 'Test description', + 'img-src': `https://example.com/image-${id}.png`, + }, + }) as BannerType const renderBanner = () => { const query = mockUseGetBanners() @@ -223,10 +226,7 @@ describe('Banner', () => { it('renders the greeting shell without slider when all banners are disabled', () => { mockUseGetBanners.mockReturnValue({ - data: [ - createMockBanner('1', 'disabled'), - createMockBanner('2', 'disabled'), - ], + data: [createMockBanner('1', 'disabled'), createMockBanner('2', 'disabled')], isLoading: false, isError: false, }) @@ -358,32 +358,40 @@ describe('Banner', () => { renderBanner() expect(mockTrackEvent).toHaveBeenCalledTimes(1) - expect(mockTrackEvent).toHaveBeenNthCalledWith(1, 'explore_banner_impression', expect.objectContaining({ - banner_id: '1', - title: 'Enabled Banner 1', - sort: 1, - link: 'https://example.com', - page: 'explore', - language: 'en-US', - account_id: 'account-123', - event_time: expect.any(Number), - })) + expect(mockTrackEvent).toHaveBeenNthCalledWith( + 1, + 'explore_banner_impression', + expect.objectContaining({ + banner_id: '1', + title: 'Enabled Banner 1', + sort: 1, + link: 'https://example.com', + page: 'explore', + language: 'en-US', + account_id: 'account-123', + event_time: expect.any(Number), + }), + ) act(() => { setMockSelectedIndex(1) }) expect(mockTrackEvent).toHaveBeenCalledTimes(2) - expect(mockTrackEvent).toHaveBeenNthCalledWith(2, 'explore_banner_impression', expect.objectContaining({ - banner_id: '3', - title: 'Enabled Banner 2', - sort: 2, - link: 'https://example.com', - page: 'explore', - language: 'en-US', - account_id: 'account-123', - event_time: expect.any(Number), - })) + expect(mockTrackEvent).toHaveBeenNthCalledWith( + 2, + 'explore_banner_impression', + expect.objectContaining({ + banner_id: '3', + title: 'Enabled Banner 2', + sort: 2, + link: 'https://example.com', + page: 'explore', + language: 'en-US', + account_id: 'account-123', + event_time: expect.any(Number), + }), + ) }) it('does not track impressions when account id is unavailable', () => { diff --git a/web/app/components/explore/banner/banner-item.tsx b/web/app/components/explore/banner/banner-item.tsx index f020d1f9ebf233..7b36568083f214 100644 --- a/web/app/components/explore/banner/banner-item.tsx +++ b/web/app/components/explore/banner/banner-item.tsx @@ -44,28 +44,30 @@ export function BannerItem({ return { slides, totalSlides, nextIndex } }, [api, selectedIndex]) const indicatorItems = useMemo( - () => slideInfo.slides.map((slide, index) => ({ - id: slide.dataset?.bannerId ?? `${banner.id}-${index}`, - index, - })), + () => + slideInfo.slides.map((slide, index) => ({ + id: slide.dataset?.bannerId ?? `${banner.id}-${index}`, + index, + })), [banner.id, slideInfo.slides], ) const indicatorsWidth = useMemo(() => { const count = slideInfo.totalSlides - if (count === 0) - return 0 + if (count === 0) return 0 // Calculate: indicator buttons + gaps + extra spacing (3 * 20px for divider and padding) return (count + 2) * INDICATOR_WIDTH + (count - 1) * INDICATOR_GAP }, [slideInfo.totalSlides]) const viewMoreStyle = useMemo(() => { - if (!maxWidth) - return undefined + if (!maxWidth) return undefined const availableWidth = maxWidth - indicatorsWidth return { maxWidth: `${maxWidth}px`, - minWidth: indicatorsWidth && availableWidth > 0 ? `${Math.min(availableWidth, MIN_VIEW_MORE_WIDTH)}px` : undefined, + minWidth: + indicatorsWidth && availableWidth > 0 + ? `${Math.min(availableWidth, MIN_VIEW_MORE_WIDTH)}px` + : undefined, } }, [maxWidth, indicatorsWidth]) @@ -74,15 +76,14 @@ export function BannerItem({ [maxWidth], ) - const incrementResetKey = useCallback(() => setResetKey(prev => prev + 1), []) + const incrementResetKey = useCallback(() => setResetKey((prev) => prev + 1), []) useEffect(() => { const updateMaxWidth = () => { if (window.innerWidth < RESPONSIVE_BREAKPOINT && textAreaRef.current) { const textAreaWidth = textAreaRef.current.offsetWidth setMaxWidth(Math.min(textAreaWidth, MAX_RESPONSIVE_WIDTH)) - } - else { + } else { setMaxWidth(undefined) } } @@ -90,8 +91,7 @@ export function BannerItem({ updateMaxWidth() const resizeObserver = new ResizeObserver(updateMaxWidth) - if (textAreaRef.current) - resizeObserver.observe(textAreaRef.current) + if (textAreaRef.current) resizeObserver.observe(textAreaRef.current) window.addEventListener('resize', updateMaxWidth) @@ -105,10 +105,13 @@ export function BannerItem({ incrementResetKey() }, [selectedIndex, incrementResetKey]) - const handleIndicatorClick = useCallback((index: number) => { - incrementResetKey() - api?.scrollTo(index) - }, [api, incrementResetKey]) + const handleIndicatorClick = useCallback( + (index: number) => { + incrementResetKey() + api?.scrollTo(index) + }, + [api, incrementResetKey], + ) const handleBannerClick = useCallback(() => { incrementResetKey() @@ -124,8 +127,7 @@ export function BannerItem({ event_time: Date.now(), }) - if (banner.link) - window.open(banner.link, '_blank', 'noopener,noreferrer') + if (banner.link) window.open(banner.link, '_blank', 'noopener,noreferrer') }, [accountId, banner, incrementResetKey, language, sort]) return ( @@ -170,7 +172,7 @@ export function BannerItem({ <span className="i-ri-arrow-right-line h-3 w-3 text-text-primary-on-surface" /> </div> <span className="system-sm-semibold-uppercase text-text-accent"> - {t($ => $['banner.viewMore'], { ns: 'explore' })} + {t(($) => $['banner.viewMore'], { ns: 'explore' })} </span> </div> diff --git a/web/app/components/explore/banner/banner.tsx b/web/app/components/explore/banner/banner.tsx index 8e558c23573e34..8cb28dfdecb926 100644 --- a/web/app/components/explore/banner/banner.tsx +++ b/web/app/components/explore/banner/banner.tsx @@ -28,12 +28,10 @@ function BannerImpressionTracker({ const { selectedIndex } = useCarousel() useEffect(() => { - if (!accountId) - return + if (!accountId) return const currentBanner = banners[selectedIndex] - if (!currentBanner || trackedBannerIdsRef.current.has(currentBanner.id)) - return + if (!currentBanner || trackedBannerIdsRef.current.has(currentBanner.id)) return trackEvent('explore_banner_impression', { banner_id: currentBanner.id, @@ -55,9 +53,7 @@ type BannerProps = { banners: BannerType[] } -function Banner({ - banners, -}: BannerProps) { +function Banner({ banners }: BannerProps) { const { t } = useTranslation() const locale = useLocale() const userProfile = useAtomValue(userProfileAtom) @@ -69,7 +65,7 @@ function Banner({ const trackedBannerIdsRef = useRef<Set<string>>(new Set()) const enabledBanners = useMemo( - () => banners?.filter(banner => banner.status === 'enabled') ?? [], + () => banners?.filter((banner) => banner.status === 'enabled') ?? [], [banners], ) @@ -81,8 +77,7 @@ function Banner({ const handleResize = () => { setIsResizing(true) - if (resizeTimerRef.current) - clearTimeout(resizeTimerRef.current) + if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current) resizeTimerRef.current = setTimeout(() => { setIsResizing(false) @@ -93,8 +88,7 @@ function Banner({ return () => { window.removeEventListener('resize', handleResize) - if (resizeTimerRef.current) - clearTimeout(resizeTimerRef.current) + if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current) } }, []) @@ -106,49 +100,48 @@ function Banner({ > <div className="flex w-full flex-col gap-1"> <p className="truncate title-3xl-semi-bold text-text-primary"> - {t($ => $['banner.greeting'], { name: userName, ns: 'explore' })} + {t(($) => $['banner.greeting'], { name: userName, ns: 'explore' })} </p> <p className="truncate body-sm-regular text-text-secondary"> - {t($ => $['banner.tagline'], { ns: 'explore' })} + {t(($) => $['banner.tagline'], { ns: 'explore' })} </p> </div> - {!notShowSlider - && ( - <Carousel - opts={{ loop: true }} - plugins={[ - Carousel.Plugin.Fade(), - Carousel.Plugin.Autoplay({ - delay: AUTOPLAY_DELAY, - stopOnInteraction: false, - stopOnMouseEnter: true, - }), - ]} - className="w-full rounded-2xl" - > - <BannerImpressionTracker - banners={enabledBanners} - accountId={accountId} - language={locale} - trackedBannerIdsRef={trackedBannerIdsRef} - /> - <Carousel.Content> - {enabledBanners.map((banner, index) => ( - <Carousel.Item key={banner.id} data-banner-id={banner.id}> - <BannerItem - banner={banner} - autoplayDelay={AUTOPLAY_DELAY} - isPaused={isPaused} - sort={index + 1} - language={locale} - accountId={accountId} - /> - </Carousel.Item> - ))} - </Carousel.Content> - </Carousel> - )} + {!notShowSlider && ( + <Carousel + opts={{ loop: true }} + plugins={[ + Carousel.Plugin.Fade(), + Carousel.Plugin.Autoplay({ + delay: AUTOPLAY_DELAY, + stopOnInteraction: false, + stopOnMouseEnter: true, + }), + ]} + className="w-full rounded-2xl" + > + <BannerImpressionTracker + banners={enabledBanners} + accountId={accountId} + language={locale} + trackedBannerIdsRef={trackedBannerIdsRef} + /> + <Carousel.Content> + {enabledBanners.map((banner, index) => ( + <Carousel.Item key={banner.id} data-banner-id={banner.id}> + <BannerItem + banner={banner} + autoplayDelay={AUTOPLAY_DELAY} + isPaused={isPaused} + sort={index + 1} + language={locale} + accountId={accountId} + /> + </Carousel.Item> + ))} + </Carousel.Content> + </Carousel> + )} </div> ) } diff --git a/web/app/components/explore/banner/indicator-button.tsx b/web/app/components/explore/banner/indicator-button.tsx index 23595f70b9f606..f16a3eee5e54e1 100644 --- a/web/app/components/explore/banner/indicator-button.tsx +++ b/web/app/components/explore/banner/indicator-button.tsx @@ -35,8 +35,7 @@ export const IndicatorButton: FC<IndicatorButtonProps> = ({ useEffect(() => { if (!isNextSlide) { setProgress(0) - if (frameIdRef.current) - cancelAnimationFrame(frameIdRef.current) + if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current) return } @@ -49,27 +48,26 @@ export const IndicatorButton: FC<IndicatorButtonProps> = ({ const newProgress = Math.min((elapsed / autoplayDelay) * PROGRESS_MAX, PROGRESS_MAX) setProgress(newProgress) - if (newProgress < PROGRESS_MAX) - frameIdRef.current = requestAnimationFrame(animate) - } - else { + if (newProgress < PROGRESS_MAX) frameIdRef.current = requestAnimationFrame(animate) + } else { frameIdRef.current = requestAnimationFrame(animate) } } - if (shouldAnimate) - frameIdRef.current = requestAnimationFrame(animate) + if (shouldAnimate) frameIdRef.current = requestAnimationFrame(animate) return () => { - if (frameIdRef.current) - cancelAnimationFrame(frameIdRef.current) + if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current) } }, [isNextSlide, autoplayDelay, resetKey, isPaused]) - const handleClick = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - onClick() - }, [onClick]) + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onClick() + }, + [onClick], + ) const progressDegrees = progress * DEGREES_PER_PERCENT @@ -104,9 +102,7 @@ export const IndicatorButton: FC<IndicatorButtonProps> = ({ )} {/* number content */} - <span className="relative z-10"> - {String(index + 1).padStart(2, '0')} - </span> + <span className="relative z-10">{String(index + 1).padStart(2, '0')}</span> </button> ) } diff --git a/web/app/components/explore/category.tsx b/web/app/components/explore/category.tsx index 7f2a9bfb0a3203..9fcd90b4be54e4 100644 --- a/web/app/components/explore/category.tsx +++ b/web/app/components/explore/category.tsx @@ -16,42 +16,44 @@ type ICategoryProps = { allCategoriesEn: string } -function Category({ - className, - list, - value, - onChange, - allCategoriesEn, -}: ICategoryProps) { +function Category({ className, list, value, onChange, allCategoriesEn }: ICategoryProps) { const { t } = useTranslation() const isAllCategories = !list.includes(value as AppCategory) || value === allCategoriesEn const selectedCategory = isAllCategories ? allCategoriesEn : value const renderCategoryName = (name: AppCategory) => { const categoryKey = `category.${name}` as keyof typeof exploreI18n - return categoryKey in exploreI18n ? t($ => $[categoryKey], { ns: 'explore' }) : name + return categoryKey in exploreI18n ? t(($) => $[categoryKey], { ns: 'explore' }) : name } const handleValueChange = (nextCategory: string) => { - if (nextCategory) - onChange(nextCategory) + if (nextCategory) onChange(nextCategory) } return ( <RadioGroup - aria-label={t($ => $['tryApp.category'], { ns: 'explore' })} - className={cn(className, 'flex max-w-full flex-wrap items-start gap-1 overflow-visible rounded-none bg-transparent p-0 text-[13px]')} + aria-label={t(($) => $['tryApp.category'], { ns: 'explore' })} + className={cn( + className, + 'flex max-w-full flex-wrap items-start gap-1 overflow-visible rounded-none bg-transparent p-0 text-[13px]', + )} value={selectedCategory} onValueChange={handleValueChange} > {[ - { name: allCategoriesEn, label: t($ => $['apps.allCategories'], { ns: 'explore' }), isAll: true }, - ...list.filter(name => name !== allCategoriesEn).map(name => ({ - name, - label: renderCategoryName(name), - isAll: false, - })), - ].map(item => ( + { + name: allCategoriesEn, + label: t(($) => $['apps.allCategories'], { ns: 'explore' }), + isAll: true, + }, + ...list + .filter((name) => name !== allCategoriesEn) + .map((name) => ({ + name, + label: renderCategoryName(name), + isAll: false, + })), + ].map((item) => ( <RadioItem key={item.isAll ? 'all' : item.name} value={item.name} diff --git a/web/app/components/explore/continue-work/__tests__/item.spec.tsx b/web/app/components/explore/continue-work/__tests__/item.spec.tsx index 4fe976a61dd6d5..5f052e64990d84 100644 --- a/web/app/components/explore/continue-work/__tests__/item.spec.tsx +++ b/web/app/components/explore/continue-work/__tests__/item.spec.tsx @@ -45,7 +45,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -68,8 +69,10 @@ vi.mock('@/next/link', () => ({ href, className, ...props - }: AnchorHTMLAttributes<HTMLAnchorElement> & { children?: ReactNode, href: string }) => ( - <a href={href} className={className} {...props}>{children}</a> + }: AnchorHTMLAttributes<HTMLAnchorElement> & { children?: ReactNode; href: string }) => ( + <a href={href} className={className} {...props}> + {children} + </a> ), })) @@ -104,7 +107,9 @@ const createApp = (overrides: Partial<App> = {}): App => ({ const renderItem = ( app: App, - systemFeatures: NonNullable<Parameters<typeof renderWithSystemFeatures>[1]>['systemFeatures'] = { rbac_enabled: true }, + systemFeatures: NonNullable<Parameters<typeof renderWithSystemFeatures>[1]>['systemFeatures'] = { + rbac_enabled: true, + }, ) => renderWithSystemFeatures(<ContinueWorkItem app={app} />, { systemFeatures }) describe('ContinueWorkItem', () => { @@ -122,7 +127,9 @@ describe('ContinueWorkItem', () => { expect(link).toHaveAttribute('href', '/app/app-1/configuration') expect(screen.getByText('Alice')).toBeInTheDocument() - expect(screen.getByText('explore.continueWork.editedAt:{"time":"5 minutes ago"}')).toBeInTheDocument() + expect( + screen.getByText('explore.continueWork.editedAt:{"time":"5 minutes ago"}'), + ).toBeInTheDocument() expect(mockFormatTimeFromNow).toHaveBeenCalledWith(200000) }) @@ -135,13 +142,21 @@ describe('ContinueWorkItem', () => { it('should link to access config when RBAC is enabled and only access config permission is available', () => { renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] })) - expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute('href', '/app/app-1/access-config') + expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute( + 'href', + '/app/app-1/access-config', + ) }) it('should fall back to develop when RBAC is disabled for an access-config-only app', () => { - renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }), { rbac_enabled: false }) - - expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute('href', '/app/app-1/develop') + renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }), { + rbac_enabled: false, + }) + + expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute( + 'href', + '/app/app-1/develop', + ) }) it('should render preview-only apps as disabled buttons and warn on click', () => { diff --git a/web/app/components/explore/continue-work/index.tsx b/web/app/components/explore/continue-work/index.tsx index 8933bd0b8b048b..8f13cb04028a30 100644 --- a/web/app/components/explore/continue-work/index.tsx +++ b/web/app/components/explore/continue-work/index.tsx @@ -12,31 +12,30 @@ type ContinueWorkProps = { className?: string } -const ContinueWork = ({ - apps, - className, -}: ContinueWorkProps) => { +const ContinueWork = ({ apps, className }: ContinueWorkProps) => { const { t } = useTranslation() - if (apps.length === 0) - return null + if (apps.length === 0) return null return ( <section className={cn('px-8 pb-5', className)} aria-labelledby="continue-work-title"> <div className="flex items-center justify-between pt-2"> - <h2 id="continue-work-title" className="min-w-0 truncate system-xl-medium text-text-primary"> - {t($ => $['continueWork.title'], { ns: 'explore' })} + <h2 + id="continue-work-title" + className="min-w-0 truncate system-xl-medium text-text-primary" + > + {t(($) => $['continueWork.title'], { ns: 'explore' })} </h2> <Link href="/apps" className="ml-4 flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary" > - {t($ => $['continueWork.exploreStudio'], { ns: 'explore' })} + {t(($) => $['continueWork.exploreStudio'], { ns: 'explore' })} <span className="i-ri-arrow-right-line size-3 shrink-0" aria-hidden="true" /> </Link> </div> <div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5 pt-2"> - {apps.map(app => ( + {apps.map((app) => ( <ContinueWorkItem key={app.id} app={app} /> ))} </div> diff --git a/web/app/components/explore/continue-work/item.tsx b/web/app/components/explore/continue-work/item.tsx index 1294795ebab464..0dd8629e6e8f4b 100644 --- a/web/app/components/explore/continue-work/item.tsx +++ b/web/app/components/explore/continue-work/item.tsx @@ -21,9 +21,7 @@ type ContinueWorkItemProps = { app: App } -const ContinueWorkItem = ({ - app, -}: ContinueWorkItemProps) => { +const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => { const { t } = useTranslation() const { formatTimeFromNow } = useFormatTimeFromNow() const currentUserId = useAtomValue(userProfileIdAtom) @@ -44,12 +42,11 @@ const ContinueWorkItem = ({ ) const showPreviewOnlyAccessWarning = () => { - toast.warning(t($ => $.noAccessResourcePermission, { ns: 'app' })) + toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) } const handlePreviewOnlyCardKeyDown = (event: React.KeyboardEvent<HTMLElement>) => { - if (event.key !== 'Enter' && event.key !== ' ') - return + if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() showPreviewOnlyAccessWarning() @@ -72,13 +69,16 @@ const ContinueWorkItem = ({ /> </div> <div className="min-w-0 py-px"> - <h3 className="truncate system-md-semibold text-text-secondary"> - {app.name} - </h3> + <h3 className="truncate system-md-semibold text-text-secondary">{app.name}</h3> <div className="flex min-w-0 items-center gap-1 system-xs-regular text-text-tertiary"> <span className="shrink-0">{app.author_name}</span> <span className="shrink-0">·</span> - <span className="min-w-0 truncate">{t($ => $['continueWork.editedAt'], { ns: 'explore', time: formatTimeFromNow(updatedAt) })}</span> + <span className="min-w-0 truncate"> + {t(($) => $['continueWork.editedAt'], { + ns: 'explore', + time: formatTimeFromNow(updatedAt), + })} + </span> </div> </div> </> diff --git a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx index 29c8228f08f602..792157e7c53ddd 100644 --- a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx @@ -2,13 +2,17 @@ import type { CreateAppModalProps } from '../index' import type { UsagePlanInfo } from '@/app/components/billing/type' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import * as React from 'react' -import { createMockPlan, createMockPlanTotal, createMockPlanUsage } from '@/__mocks__/provider-context' +import { + createMockPlan, + createMockPlanTotal, + createMockPlanUsage, +} from '@/__mocks__/provider-context' import { Plan } from '@/app/components/billing/type' import { AppModeEnum } from '@/types/app' import CreateAppModal from '../index' const hotkeyMocks = vi.hoisted(() => ({ - handlers: new Map<string, { handler: () => void, options?: { enabled?: boolean } }>(), + handlers: new Map<string, { handler: () => void; options?: { enabled?: boolean } }>(), })) vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { @@ -23,8 +27,7 @@ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { const triggerHotkey = (hotkey: string) => { const registration = hotkeyMocks.handlers.get(hotkey) - if (registration?.options?.enabled === false) - return + if (registration?.options?.enabled === false) return registration?.handler() } @@ -34,9 +37,7 @@ vi.mock('emoji-mart', () => ({ })) vi.mock('@emoji-mart/data', () => ({ default: { - categories: [ - { id: 'people', emojis: ['😀'] }, - ], + categories: [{ id: 'people', emojis: ['😀'] }], }, })) @@ -102,8 +103,7 @@ const getAppIconTrigger = (): HTMLElement => { const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder') const iconRow = nameInput.parentElement const iconTrigger = iconRow?.firstElementChild - if (!(iconTrigger instanceof HTMLElement)) - throw new Error('Failed to locate app icon trigger') + if (!(iconTrigger instanceof HTMLElement)) throw new Error('Failed to locate app icon trigger') return iconTrigger } @@ -148,11 +148,14 @@ describe('CreateAppModal', () => { expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5') }) - it.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', async (mode) => { - await setup({ isEditModal: true, appMode: mode }) + it.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])( + 'should render answer icon switch when editing %s app', + async (mode) => { + await setup({ isEditModal: true, appMode: mode }) - expect(screen.getByRole('switch'))!.toBeInTheDocument() - }) + expect(screen.getByRole('switch'))!.toBeInTheDocument() + }, + ) it('should not render answer icon switch when editing a non-chat app', async () => { await setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION }) @@ -163,7 +166,9 @@ describe('CreateAppModal', () => { it('should not render modal content when hidden', async () => { await setup({ show: false }) - expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /common\.operation\.create/ }), + ).not.toBeInTheDocument() }) }) @@ -185,14 +190,21 @@ describe('CreateAppModal', () => { it('should default description to empty string when appDescription is empty', async () => { await setup({ appDescription: '' }) - expect((screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder') as HTMLTextAreaElement).value).toBe('') + expect( + (screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder') as HTMLTextAreaElement) + .value, + ).toBe('') }) it('should render i18n key placeholders when translations are available', async () => { await setup() - expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('app.newApp.appNamePlaceholder') - expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('app.newApp.appDescriptionPlaceholder') + expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe( + 'app.newApp.appNamePlaceholder', + ) + expect( + (screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder, + ).toBe('app.newApp.appDescriptionPlaceholder') }) }) @@ -323,12 +335,16 @@ describe('CreateAppModal', () => { const pickerDialog = openAppIconPicker() - expect(within(pickerDialog).getByRole('button', { name: 'app.iconPicker.cancel' }))!.toBeInTheDocument() + expect( + within(pickerDialog).getByRole('button', { name: 'app.iconPicker.cancel' }), + )!.toBeInTheDocument() fireEvent.click(within(pickerDialog).getByRole('button', { name: 'app.iconPicker.cancel' })) await waitFor(() => { - expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'app.iconPicker.cancel' }), + ).not.toBeInTheDocument() }) }) @@ -359,8 +375,7 @@ describe('CreateAppModal', () => { icon: '😀', icon_background: '#FFEAD5', }) - } - finally { + } finally { vi.useRealTimers() } }) @@ -391,8 +406,7 @@ describe('CreateAppModal', () => { icon: '🤖', icon_background: '#E4FBCC', }) - } - finally { + } finally { vi.useRealTimers() } }) @@ -439,7 +453,9 @@ describe('CreateAppModal', () => { it('should include updated description when textarea is changed before submitting', async () => { const { onConfirm } = await setup({ appDescription: 'Old description' }) - fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } }) + fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { + target: { value: 'Updated description' }, + }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) await act(async () => { vi.advanceTimersByTime(300) @@ -522,7 +538,9 @@ describe('CreateAppModal', () => { const { onConfirm, onHide } = await setup({ appName: 'My App' }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) - fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } }) + fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { + target: { value: ' ' }, + }) await act(async () => { vi.advanceTimersByTime(300) diff --git a/web/app/components/explore/create-app-modal/index.tsx b/web/app/components/explore/create-app-modal/index.tsx index 6f4f98ab2c8a79..7746c78c129fdc 100644 --- a/web/app/components/explore/create-app-modal/index.tsx +++ b/web/app/components/explore/create-app-modal/index.tsx @@ -64,8 +64,8 @@ const CreateAppModal = ({ const { t } = useTranslation() const [name, setName] = React.useState(appName) - const [appIcon, setAppIcon] = useState( - () => appIconType === 'image' + const [appIcon, setAppIcon] = useState(() => + appIconType === 'image' ? { type: 'image' as const, fileId: _appIcon, url: appIconUrl } : { type: 'emoji' as const, icon: _appIcon, background: appIconBackground }, ) @@ -74,15 +74,20 @@ const CreateAppModal = ({ const [useIconAsAnswerIcon, setUseIconAsAnswerIcon] = useState(appUseIconAsAnswerIcon || false) const [maxActiveRequestsInput, setMaxActiveRequestsInput] = useState( - max_active_requests !== null && max_active_requests !== undefined ? String(max_active_requests) : '', + max_active_requests !== null && max_active_requests !== undefined + ? String(max_active_requests) + : '', ) const { plan, enableBilling } = useProviderContext() - const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) + const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps const submit = useCallback(() => { if (!name.trim()) { - toast(t($ => $['appCustomize.nameRequired'], { ns: 'explore' }), { type: 'error' }) + toast( + t(($) => $['appCustomize.nameRequired'], { ns: 'explore' }), + { type: 'error' }, + ) return } const parsedMaxActiveRequests = Number(maxActiveRequestsInput) @@ -95,41 +100,61 @@ const CreateAppModal = ({ description, use_icon_as_answer_icon: useIconAsAnswerIcon, } - if (isValid) - payload.max_active_requests = parsedMaxActiveRequests + if (isValid) payload.max_active_requests = parsedMaxActiveRequests onConfirm(payload) onHide() - }, [name, appIcon, description, useIconAsAnswerIcon, onConfirm, onHide, t, maxActiveRequestsInput]) + }, [ + name, + appIcon, + description, + useIconAsAnswerIcon, + onConfirm, + onHide, + t, + maxActiveRequestsInput, + ]) const { run: handleSubmit } = useDebounceFn(submit, { wait: 300 }) - useHotkey('Mod+Enter', () => { - handleSubmit() - }, { - enabled: show && !(!isEditModal && isAppsFull) && !!name.trim(), - ignoreInputs: false, - }) + useHotkey( + 'Mod+Enter', + () => { + handleSubmit() + }, + { + enabled: show && !(!isEditModal && isAppsFull) && !!name.trim(), + ignoreInputs: false, + }, + ) return ( <> - <Dialog open={show} onOpenChange={open => !open && onHide()} disablePointerDismissal> + <Dialog open={show} onOpenChange={(open) => !open && onHide()} disablePointerDismissal> <DialogContent backdropProps={{ forceRender: true }} className="px-8"> <DialogCloseButton /> {isEditModal && ( - <DialogTitle className="text-xl leading-7.5 font-semibold text-text-primary">{t($ => $.editAppTitle, { ns: 'app' })}</DialogTitle> + <DialogTitle className="text-xl leading-7.5 font-semibold text-text-primary"> + {t(($) => $.editAppTitle, { ns: 'app' })} + </DialogTitle> )} {!isEditModal && ( - <DialogTitle className="text-xl leading-7.5 font-semibold text-text-primary">{t($ => $['appCustomize.title'], { ns: 'explore', name: appName })}</DialogTitle> + <DialogTitle className="text-xl leading-7.5 font-semibold text-text-primary"> + {t(($) => $['appCustomize.title'], { ns: 'explore', name: appName })} + </DialogTitle> )} <div className="mb-9"> {/* icon & name */} <div className="pt-2"> - <div className="py-2 text-sm leading-5 font-medium text-text-primary">{t($ => $['newApp.captionName'], { ns: 'app' })}</div> + <div className="py-2 text-sm leading-5 font-medium text-text-primary"> + {t(($) => $['newApp.captionName'], { ns: 'app' })} + </div> <div className="flex items-center justify-between space-x-2"> <AppIcon size="large" - onClick={() => { setShowAppIconPicker(true) }} + onClick={() => { + setShowAppIconPicker(true) + }} className="cursor-pointer" iconType={appIcon.type} icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} @@ -138,50 +163,63 @@ const CreateAppModal = ({ /> <Input value={name} - onChange={e => setName(e.target.value)} - placeholder={t($ => $['newApp.appNamePlaceholder'], { ns: 'app' }) || ''} + onChange={(e) => setName(e.target.value)} + placeholder={t(($) => $['newApp.appNamePlaceholder'], { ns: 'app' }) || ''} className="h-10 grow" /> </div> </div> {/* description */} <div className="pt-2"> - <div className="py-2 text-sm leading-5 font-medium text-text-primary">{t($ => $['newApp.captionDescription'], { ns: 'app' })}</div> + <div className="py-2 text-sm leading-5 font-medium text-text-primary"> + {t(($) => $['newApp.captionDescription'], { ns: 'app' })} + </div> <Textarea - aria-label={t($ => $['newApp.captionDescription'], { ns: 'app' })} + aria-label={t(($) => $['newApp.captionDescription'], { ns: 'app' })} className="resize-none" - placeholder={t($ => $['newApp.appDescriptionPlaceholder'], { ns: 'app' }) || ''} + placeholder={t(($) => $['newApp.appDescriptionPlaceholder'], { ns: 'app' }) || ''} value={description} - onValueChange={value => setDescription(value)} + onValueChange={(value) => setDescription(value)} /> </div> {/* answer icon */} - {isEditModal && (appMode === AppModeEnum.CHAT || appMode === AppModeEnum.ADVANCED_CHAT || appMode === AppModeEnum.AGENT_CHAT) && ( - <div className="pt-2"> - <div className="flex items-center justify-between"> - <div className="py-2 text-sm leading-5 font-medium text-text-primary">{t($ => $['answerIcon.title'], { ns: 'app' })}</div> - <Switch - checked={useIconAsAnswerIcon} - onCheckedChange={v => setUseIconAsAnswerIcon(v)} - /> + {isEditModal && + (appMode === AppModeEnum.CHAT || + appMode === AppModeEnum.ADVANCED_CHAT || + appMode === AppModeEnum.AGENT_CHAT) && ( + <div className="pt-2"> + <div className="flex items-center justify-between"> + <div className="py-2 text-sm leading-5 font-medium text-text-primary"> + {t(($) => $['answerIcon.title'], { ns: 'app' })} + </div> + <Switch + checked={useIconAsAnswerIcon} + onCheckedChange={(v) => setUseIconAsAnswerIcon(v)} + /> + </div> + <p className="body-xs-regular text-text-tertiary"> + {t(($) => $['answerIcon.descriptionInExplore'], { ns: 'app' })} + </p> </div> - <p className="body-xs-regular text-text-tertiary">{t($ => $['answerIcon.descriptionInExplore'], { ns: 'app' })}</p> - </div> - )} + )} {isEditModal && ( <div className="pt-2"> - <div className="mt-2 mb-2 text-sm leading-5 font-medium text-text-primary">{t($ => $.maxActiveRequests, { ns: 'app' })}</div> + <div className="mt-2 mb-2 text-sm leading-5 font-medium text-text-primary"> + {t(($) => $.maxActiveRequests, { ns: 'app' })} + </div> <Input type="number" min={1} - placeholder={t($ => $.maxActiveRequestsPlaceholder, { ns: 'app' })} + placeholder={t(($) => $.maxActiveRequestsPlaceholder, { ns: 'app' })} value={maxActiveRequestsInput} onChange={(e) => { setMaxActiveRequestsInput(e.target.value) }} className="h-10 w-full" /> - <p className="mt-2 mb-0 body-xs-regular text-text-tertiary">{t($ => $.maxActiveRequestsTip, { ns: 'app' })}</p> + <p className="mt-2 mb-0 body-xs-regular text-text-tertiary"> + {t(($) => $.maxActiveRequestsTip, { ns: 'app' })} + </p> </div> )} {!isEditModal && isAppsFull && <AppsFull className="mt-4" loc="app-explore-create" />} @@ -193,23 +231,33 @@ const CreateAppModal = ({ variant="primary" onClick={handleSubmit} > - <span>{!isEditModal ? t($ => $['operation.create'], { ns: 'common' }) : t($ => $['operation.save'], { ns: 'common' })}</span> + <span> + {!isEditModal + ? t(($) => $['operation.create'], { ns: 'common' }) + : t(($) => $['operation.save'], { ns: 'common' })} + </span> <KbdGroup> - {['Mod', 'Enter'].map(key => ( - <Kbd key={key} color="white">{formatForDisplay(key)}</Kbd> + {['Mod', 'Enter'].map((key) => ( + <Kbd key={key} color="white"> + {formatForDisplay(key)} + </Kbd> ))} </KbdGroup> </Button> - <Button className="w-24" onClick={onHide}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> + <Button className="w-24" onClick={onHide}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> </div> </DialogContent> </Dialog> {showAppIconPicker && ( <AppIconPicker open={showAppIconPicker} - initialEmoji={appIcon.type === 'emoji' - ? { icon: appIcon.icon, background: appIcon.background } - : undefined} + initialEmoji={ + appIcon.type === 'emoji' + ? { icon: appIcon.icon, background: appIcon.background } + : undefined + } onOpenChange={setShowAppIconPicker} onSelect={(payload) => { setAppIcon(payload) diff --git a/web/app/components/explore/index.tsx b/web/app/components/explore/index.tsx index 9717ff3df8a35f..8d786c839dff64 100644 --- a/web/app/components/explore/index.tsx +++ b/web/app/components/explore/index.tsx @@ -3,20 +3,14 @@ import * as React from 'react' import Sidebar from '@/app/components/explore/sidebar' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -const Explore = ({ - children, -}: { - children: React.ReactNode -}) => { +const Explore = ({ children }: { children: React.ReactNode }) => { const media = useBreakpoints() const isMobile = media === MediaType.mobile return ( <div className="flex h-full overflow-hidden border-t border-divider-regular bg-background-body"> {isMobile && <Sidebar />} - <div className="h-full min-h-0 w-0 grow"> - {children} - </div> + <div className="h-full min-h-0 w-0 grow">{children}</div> </div> ) } diff --git a/web/app/components/explore/installed-app/__tests__/index.spec.tsx b/web/app/components/explore/installed-app/__tests__/index.spec.tsx index 36268eced8b9b3..de4da4766d2c50 100644 --- a/web/app/components/explore/installed-app/__tests__/index.spec.tsx +++ b/web/app/components/explore/installed-app/__tests__/index.spec.tsx @@ -1,11 +1,15 @@ import type { Mock } from 'vitest' import type { InstalledApp as InstalledAppType } from '@/models/explore' import { render, screen, waitFor } from '@testing-library/react' - import { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' -import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore' +import { + useGetInstalledAppAccessModeByAppId, + useGetInstalledAppMeta, + useGetInstalledAppParams, + useGetInstalledApps, +} from '@/service/use-explore' import { AppModeEnum } from '@/types/app' import InstalledApp from '../index' @@ -23,7 +27,11 @@ vi.mock('@/service/use-explore', () => ({ })) vi.mock('@/app/components/share/text-generation', () => ({ - default: ({ isInstalledApp, installedAppInfo, isWorkflow }: { + default: ({ + isInstalledApp, + installedAppInfo, + isWorkflow, + }: { isInstalledApp?: boolean installedAppInfo?: InstalledAppType isWorkflow?: boolean @@ -37,14 +45,15 @@ vi.mock('@/app/components/share/text-generation', () => ({ })) vi.mock('@/app/components/base/chat/chat-with-history', () => ({ - default: ({ installedAppInfo, className }: { + default: ({ + installedAppInfo, + className, + }: { installedAppInfo?: InstalledAppType className?: string }) => ( <div data-testid="chat-with-history" className={className}> - Chat With History - - {' '} - {installedAppInfo?.id} + Chat With History - {installedAppInfo?.id} </div> ), })) @@ -98,10 +107,7 @@ describe('InstalledApp', () => { isFetching?: boolean } = {}, ) => { - const { - isPending = false, - isFetching = false, - } = options + const { isPending = false, isFetching = false } = options ;(useGetInstalledApps as Mock).mockReturnValue({ data: { installed_apps: installedApps }, @@ -115,24 +121,26 @@ describe('InstalledApp', () => { setupMocks() - ;(useWebAppStore as unknown as Mock).mockImplementation(( - selector: (state: { - updateAppInfo: Mock - updateWebAppAccessMode: Mock - updateAppParams: Mock - updateWebAppMeta: Mock - updateUserCanAccessApp: Mock - }) => unknown, - ) => { - const state = { - updateAppInfo: mockUpdateAppInfo, - updateWebAppAccessMode: mockUpdateWebAppAccessMode, - updateAppParams: mockUpdateAppParams, - updateWebAppMeta: mockUpdateWebAppMeta, - updateUserCanAccessApp: mockUpdateUserCanAccessApp, - } - return selector(state) - }) + ;(useWebAppStore as unknown as Mock).mockImplementation( + ( + selector: (state: { + updateAppInfo: Mock + updateWebAppAccessMode: Mock + updateAppParams: Mock + updateWebAppMeta: Mock + updateUserCanAccessApp: Mock + }) => unknown, + ) => { + const state = { + updateAppInfo: mockUpdateAppInfo, + updateWebAppAccessMode: mockUpdateWebAppAccessMode, + updateAppParams: mockUpdateAppParams, + updateWebAppMeta: mockUpdateWebAppMeta, + updateUserCanAccessApp: mockUpdateUserCanAccessApp, + } + return selector(state) + }, + ) ;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({ isPending: false, diff --git a/web/app/components/explore/installed-app/index.tsx b/web/app/components/explore/installed-app/index.tsx index 852f97a7cf9b67..ae5c95b5943189 100644 --- a/web/app/components/explore/installed-app/index.tsx +++ b/web/app/components/explore/installed-app/index.tsx @@ -8,19 +8,25 @@ import TextGenerationApp from '@/app/components/share/text-generation' import { useWebAppStore } from '@/context/web-app-context' import dynamic from '@/next/dynamic' import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' -import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore' +import { + useGetInstalledAppAccessModeByAppId, + useGetInstalledAppMeta, + useGetInstalledAppParams, + useGetInstalledApps, +} from '@/service/use-explore' import { AppModeEnum } from '@/types/app' import AppUnavailable from '../../base/app-unavailable' -const ChatWithHistory = dynamic(() => import('@/app/components/base/chat/chat-with-history'), { ssr: false }) +const ChatWithHistory = dynamic(() => import('@/app/components/base/chat/chat-with-history'), { + ssr: false, +}) const InstalledAppFrame = ({ children }: { children: React.ReactNode }) => ( - <div className="h-full bg-background-body pt-2 pl-2"> - {children} - </div> + <div className="h-full bg-background-body pt-2 pl-2">{children}</div> ) -const installedAppSurfaceClassName = 'rounded-tr-none rounded-bl-none border-t-4 border-l-4 border-components-chat-input-border' +const installedAppSurfaceClassName = + 'rounded-tr-none rounded-bl-none border-t-4 border-l-4 border-components-chat-input-border' const InstalledTextGenerationSurface = ({ children }: { children: React.ReactNode }) => ( <div className={`h-full overflow-hidden rounded-2xl shadow-md ${installedAppSurfaceClassName}`}> @@ -28,28 +34,42 @@ const InstalledTextGenerationSurface = ({ children }: { children: React.ReactNod </div> ) -const InstalledApp = ({ - id, -}: { - id: string -}) => { - const { data, isPending: isPendingInstalledApps, isFetching: isFetchingInstalledApps } = useGetInstalledApps() - const installedApp = data?.installed_apps?.find(item => item.id === id) - const updateAppInfo = useWebAppStore(s => s.updateAppInfo) - const updateWebAppAccessMode = useWebAppStore(s => s.updateWebAppAccessMode) - const updateAppParams = useWebAppStore(s => s.updateAppParams) - const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta) - const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp) - const { isPending: isPendingWebAppAccessMode, data: webAppAccessMode, error: webAppAccessModeError } = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null) - const { isPending: isPendingAppParams, data: appParams, error: appParamsError } = useGetInstalledAppParams(installedApp?.id ?? null) - const { isPending: isPendingAppMeta, data: appMeta, error: appMetaError } = useGetInstalledAppMeta(installedApp?.id ?? null) - const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: installedApp?.app.id, isInstalledApp: true }) +const InstalledApp = ({ id }: { id: string }) => { + const { + data, + isPending: isPendingInstalledApps, + isFetching: isFetchingInstalledApps, + } = useGetInstalledApps() + const installedApp = data?.installed_apps?.find((item) => item.id === id) + const updateAppInfo = useWebAppStore((s) => s.updateAppInfo) + const updateWebAppAccessMode = useWebAppStore((s) => s.updateWebAppAccessMode) + const updateAppParams = useWebAppStore((s) => s.updateAppParams) + const updateWebAppMeta = useWebAppStore((s) => s.updateWebAppMeta) + const updateUserCanAccessApp = useWebAppStore((s) => s.updateUserCanAccessApp) + const { + isPending: isPendingWebAppAccessMode, + data: webAppAccessMode, + error: webAppAccessModeError, + } = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null) + const { + isPending: isPendingAppParams, + data: appParams, + error: appParamsError, + } = useGetInstalledAppParams(installedApp?.id ?? null) + const { + isPending: isPendingAppMeta, + data: appMeta, + error: appMetaError, + } = useGetInstalledAppMeta(installedApp?.id ?? null) + const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ + appId: installedApp?.app.id, + isInstalledApp: true, + }) useEffect(() => { if (!installedApp) { updateAppInfo(null) - } - else { + } else { const { id, app } = installedApp updateAppInfo({ app_id: id, @@ -70,14 +90,25 @@ const InstalledApp = ({ } as AppData) } - if (appParams) - updateAppParams(appParams) - if (appMeta) - updateWebAppMeta(appMeta) + if (appParams) updateAppParams(appParams) + if (appMeta) updateWebAppMeta(appMeta) if (webAppAccessMode) updateWebAppAccessMode((webAppAccessMode as { accessMode: AccessMode }).accessMode) - updateUserCanAccessApp(Boolean(userCanAccessApp && (userCanAccessApp as { result: boolean })?.result)) - }, [installedApp, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp, webAppAccessMode, updateWebAppAccessMode]) + updateUserCanAccessApp( + Boolean(userCanAccessApp && (userCanAccessApp as { result: boolean })?.result), + ) + }, [ + installedApp, + appMeta, + appParams, + updateAppInfo, + updateAppParams, + updateUserCanAccessApp, + updateWebAppMeta, + userCanAccessApp, + webAppAccessMode, + updateWebAppAccessMode, + ]) if (appParamsError) { return ( @@ -125,9 +156,9 @@ const InstalledApp = ({ ) } if ( - isPendingInstalledApps - || (!installedApp && isFetchingInstalledApps) - || (installedApp && (isPendingAppParams || isPendingAppMeta || isPendingWebAppAccessMode)) + isPendingInstalledApps || + (!installedApp && isFetchingInstalledApps) || + (installedApp && (isPendingAppParams || isPendingAppMeta || isPendingWebAppAccessMode)) ) { return ( <InstalledAppFrame> @@ -148,9 +179,13 @@ const InstalledApp = ({ } return ( <InstalledAppFrame> - {installedApp?.app.mode !== AppModeEnum.COMPLETION && installedApp?.app.mode !== AppModeEnum.WORKFLOW && ( - <ChatWithHistory installedAppInfo={installedApp} className={`overflow-hidden rounded-2xl shadow-md ${installedAppSurfaceClassName}`} /> - )} + {installedApp?.app.mode !== AppModeEnum.COMPLETION && + installedApp?.app.mode !== AppModeEnum.WORKFLOW && ( + <ChatWithHistory + installedAppInfo={installedApp} + className={`overflow-hidden rounded-2xl shadow-md ${installedAppSurfaceClassName}`} + /> + )} {installedApp?.app.mode === AppModeEnum.COMPLETION && ( <InstalledTextGenerationSurface> <TextGenerationApp isInstalledApp installedAppInfo={installedApp} /> diff --git a/web/app/components/explore/installed-app/routes.ts b/web/app/components/explore/installed-app/routes.ts index e0797ee1653fa1..82d5e6cdc8433c 100644 --- a/web/app/components/explore/installed-app/routes.ts +++ b/web/app/components/explore/installed-app/routes.ts @@ -11,8 +11,10 @@ export const isInstalledAppPath = (pathname: string, appId?: string) => { const installedPath = appId ? buildInstalledAppPath(appId) : '/installed' const legacyInstalledPath = appId ? buildLegacyInstalledAppPath(appId) : '/explore/installed' - return normalizedPathname === installedPath - || normalizedPathname.startsWith(`${installedPath}/`) - || normalizedPathname === legacyInstalledPath - || normalizedPathname.startsWith(`${legacyInstalledPath}/`) + return ( + normalizedPathname === installedPath || + normalizedPathname.startsWith(`${installedPath}/`) || + normalizedPathname === legacyInstalledPath || + normalizedPathname.startsWith(`${legacyInstalledPath}/`) + ) } diff --git a/web/app/components/explore/item-operation/__tests__/index.spec.tsx b/web/app/components/explore/item-operation/__tests__/index.spec.tsx index a8f7e8f225401e..86e579181be910 100644 --- a/web/app/components/explore/item-operation/__tests__/index.spec.tsx +++ b/web/app/components/explore/item-operation/__tests__/index.spec.tsx @@ -3,28 +3,26 @@ import * as React from 'react' import ItemOperation from '../index' vi.mock('@langgenius/dify-ui/dropdown-menu', () => { - const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + const DropdownMenuContext = React.createContext<{ + isOpen: boolean + setOpen: (open: boolean) => void + } | null>(null) const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } return { - DropdownMenu: ({ - children, - modal, - }: { - children: React.ReactNode - modal?: boolean - }) => { + DropdownMenu: ({ children, modal }: { children: React.ReactNode; modal?: boolean }) => { const [isOpen, setIsOpen] = React.useState(false) return ( <DropdownMenuContext value={{ isOpen, setOpen: setIsOpen }}> - <div data-modal={modal} data-open={isOpen} data-testid="dropdown-menu">{children}</div> + <div data-modal={modal} data-open={isOpen} data-testid="dropdown-menu"> + {children} + </div> </DropdownMenuContext> ) }, @@ -55,10 +53,13 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => { popupProps?: React.HTMLAttributes<HTMLDivElement> }) => { const { isOpen } = useDropdownMenuContext() - if (!isOpen) - return null + if (!isOpen) return null - return <div data-testid="dropdown-content" {...popupProps}>{children}</div> + return ( + <div data-testid="dropdown-content" {...popupProps}> + {children} + </div> + ) }, DropdownMenuItem: ({ children, @@ -190,12 +191,7 @@ describe('ItemOperation', () => { render( <div onClick={onParentClick}> - <ItemOperation - isPinned={false} - isShowDelete - togglePin={togglePin} - onDelete={vi.fn()} - /> + <ItemOperation isPinned={false} isShowDelete togglePin={togglePin} onDelete={vi.fn()} /> </div>, ) diff --git a/web/app/components/explore/item-operation/index.tsx b/web/app/components/explore/item-operation/index.tsx index 0c7ae48e0e7654..b740e6daccf4f8 100644 --- a/web/app/components/explore/item-operation/index.tsx +++ b/web/app/components/explore/item-operation/index.tsx @@ -45,14 +45,13 @@ function ItemOperation({ e.stopPropagation() }} > - <span className="sr-only">{tCommon($ => $['operation.more'])}</span> - <span aria-hidden className="i-ri-more-fill size-4 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 group-focus-visible/operation:opacity-100 group-data-popup-open/operation:opacity-100" /> + <span className="sr-only">{tCommon(($) => $['operation.more'])}</span> + <span + aria-hidden + className="i-ri-more-fill size-4 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 group-focus-visible/operation:opacity-100 group-data-popup-open/operation:opacity-100" + /> </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="min-w-[120px]" - > + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[120px]"> <DropdownMenuItem className={cn(s.actionItem, 'gap-2 px-3')} onClick={(e) => { @@ -61,7 +60,9 @@ function ItemOperation({ }} > <Pin02 className="size-4 shrink-0 text-text-secondary" /> - <span className={s.actionName}>{isPinned ? t($ => $['sidebar.action.unpin']) : t($ => $['sidebar.action.pin'])}</span> + <span className={s.actionName}> + {isPinned ? t(($) => $['sidebar.action.unpin']) : t(($) => $['sidebar.action.pin'])} + </span> </DropdownMenuItem> {isShowRenameConversation && ( <DropdownMenuItem @@ -72,19 +73,31 @@ function ItemOperation({ }} > <span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-secondary" /> - <span className={s.actionName}>{t($ => $['sidebar.action.rename'])}</span> + <span className={s.actionName}>{t(($) => $['sidebar.action.rename'])}</span> </DropdownMenuItem> )} {isShowDelete && ( <DropdownMenuItem - className={cn(s.actionItem, s.deleteActionItem, 'gap-2 px-3 data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive')} + className={cn( + s.actionItem, + s.deleteActionItem, + 'gap-2 px-3 data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive', + )} onClick={(e) => { e.stopPropagation() onDelete() }} > - <span aria-hidden className={cn(s.deleteActionItemChild, 'i-ri-delete-bin-line size-4 shrink-0 text-inherit')} /> - <span className={cn(s.actionName, s.deleteActionItemChild, 'text-inherit')}>{t($ => $['sidebar.action.delete'])}</span> + <span + aria-hidden + className={cn( + s.deleteActionItemChild, + 'i-ri-delete-bin-line size-4 shrink-0 text-inherit', + )} + /> + <span className={cn(s.actionName, s.deleteActionItemChild, 'text-inherit')}> + {t(($) => $['sidebar.action.delete'])} + </span> </DropdownMenuItem> )} </DropdownMenuContent> diff --git a/web/app/components/explore/item-operation/style.module.css b/web/app/components/explore/item-operation/style.module.css index 7d42b21eb935b3..78d270db2d3ace 100644 --- a/web/app/components/explore/item-operation/style.module.css +++ b/web/app/components/explore/item-operation/style.module.css @@ -1,11 +1,11 @@ @reference "../../../styles/globals.css"; .actionItem { - @apply h-9 py-2 px-3 mx-1 flex items-center gap-2 rounded-lg cursor-pointer; + @apply mx-1 flex h-9 cursor-pointer items-center gap-2 rounded-lg px-3 py-2; } .actionName { - @apply text-text-secondary text-sm; + @apply text-sm text-text-secondary; } .deleteActionItem:hover .deleteActionItemChild { diff --git a/web/app/components/explore/learn-dify/__tests__/item.spec.tsx b/web/app/components/explore/learn-dify/__tests__/item.spec.tsx index b95fab47029f2d..5ca768b39184e2 100644 --- a/web/app/components/explore/learn-dify/__tests__/item.spec.tsx +++ b/web/app/components/explore/learn-dify/__tests__/item.spec.tsx @@ -58,14 +58,7 @@ describe('LearnDifyItem', () => { }) it('should not render hover action buttons', () => { - render( - <LearnDifyItem - canCreate - item={createApp()} - onCreate={vi.fn()} - onTry={vi.fn()} - />, - ) + render(<LearnDifyItem canCreate item={createApp()} onCreate={vi.fn()} onTry={vi.fn()} />) expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() expect(screen.queryByText('explore.appCard.try')).not.toBeInTheDocument() @@ -76,14 +69,7 @@ describe('LearnDifyItem', () => { const app = createApp() const onCreate = vi.fn() - render( - <LearnDifyItem - canCreate - item={app} - onCreate={onCreate} - onTry={vi.fn()} - />, - ) + render(<LearnDifyItem canCreate item={app} onCreate={onCreate} onTry={vi.fn()} />) fireEvent.click(screen.getByRole('button', { name: 'Learn Dify App' })) @@ -94,13 +80,7 @@ describe('LearnDifyItem', () => { it('should not make the card clickable outside cloud edition when create is unavailable', () => { mockConfig.isCloudEdition = false - render( - <LearnDifyItem - canCreate={false} - item={createApp()} - onTry={vi.fn()} - />, - ) + render(<LearnDifyItem canCreate={false} item={createApp()} onTry={vi.fn()} />) expect(screen.queryByRole('button', { name: 'Learn Dify App' })).not.toBeInTheDocument() }) @@ -109,13 +89,7 @@ describe('LearnDifyItem', () => { const onTry = vi.fn() const app = createApp() - render( - <LearnDifyItem - canCreate={false} - item={app} - onTry={onTry} - />, - ) + render(<LearnDifyItem canCreate={false} item={app} onTry={onTry} />) fireEvent.click(screen.getByRole('button', { name: 'Learn Dify App' })) @@ -133,13 +107,7 @@ describe('LearnDifyItem', () => { const onTry = vi.fn() const app = createApp() - render( - <LearnDifyItem - canCreate={false} - item={app} - onTry={onTry} - />, - ) + render(<LearnDifyItem canCreate={false} item={app} onTry={onTry} />) fireEvent.keyDown(screen.getByRole('button', { name: 'Learn Dify App' }), { key: 'Enter' }) diff --git a/web/app/components/explore/learn-dify/index.tsx b/web/app/components/explore/learn-dify/index.tsx index 07df2c74446901..6aa41641c9678e 100644 --- a/web/app/components/explore/learn-dify/index.tsx +++ b/web/app/components/explore/learn-dify/index.tsx @@ -48,23 +48,25 @@ const LearnDifyContent = ({ useEffect(() => { return () => { - if (hideTimerRef.current) - clearTimeout(hideTimerRef.current) + if (hideTimerRef.current) clearTimeout(hideTimerRef.current) } }, []) const handleHide = () => { const sectionRect = sectionRef.current?.getBoundingClientRect() - const helpTargetRect = document.querySelector('[data-learn-dify-help-target]')?.getBoundingClientRect() + const helpTargetRect = document + .querySelector('[data-learn-dify-help-target]') + ?.getBoundingClientRect() if (sectionRect && helpTargetRect) { const sectionCenterX = sectionRect.left + sectionRect.width / 2 const sectionCenterY = sectionRect.top + sectionRect.height / 2 const helpCenterX = helpTargetRect.left + helpTargetRect.width / 2 const helpCenterY = helpTargetRect.top + helpTargetRect.height / 2 - setCollapseTransform(`translate3d(${helpCenterX - sectionCenterX}px, ${helpCenterY - sectionCenterY}px, 0) scale(0.08)`) - } - else { + setCollapseTransform( + `translate3d(${helpCenterX - sectionCenterX}px, ${helpCenterY - sectionCenterY}px, 0) scale(0.08)`, + ) + } else { setCollapseTransform('scale(0.08)') } setIsClosing(true) @@ -76,12 +78,10 @@ const LearnDifyContent = ({ } const visibleItems = itemLimit ? learnDifyItems.slice(0, itemLimit) : learnDifyItems - const sectionTitle = title ?? t($ => $['learnDify.title'], { ns: 'explore' }) + const sectionTitle = title ?? t(($) => $['learnDify.title'], { ns: 'explore' }) - if (isLoading) - return loadingFallback - if (visibleItems.length === 0) - return null + if (isLoading) return loadingFallback + if (visibleItems.length === 0) return null return ( <section @@ -91,18 +91,24 @@ const LearnDifyContent = ({ isClosing && 'pointer-events-none relative z-50 opacity-20', className, )} - style={isClosing ? { transform: collapseTransform, transformOrigin: 'center center' } : undefined} + style={ + isClosing ? { transform: collapseTransform, transformOrigin: 'center center' } : undefined + } aria-labelledby="learn-dify-title" > <div className="-mx-4 rounded-2xl bg-background-section p-4"> <div className="flex items-start justify-between gap-4 pb-2.5"> <div className="min-w-0"> - <h2 id="learn-dify-title" className="truncate system-xl-medium text-text-primary" title={sectionTitle}> + <h2 + id="learn-dify-title" + className="truncate system-xl-medium text-text-primary" + title={sectionTitle} + > {sectionTitle} </h2> {showDescription && ( <p className="mt-0.5 truncate system-xs-regular text-text-tertiary"> - {t($ => $['learnDify.description'], { ns: 'explore' })} + {t(($) => $['learnDify.description'], { ns: 'explore' })} </p> )} </div> @@ -110,7 +116,7 @@ const LearnDifyContent = ({ <button type="button" className="flex size-8 shrink-0 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden" - aria-label={t($ => $['learnDify.hide'], { ns: 'explore' })} + aria-label={t(($) => $['learnDify.hide'], { ns: 'explore' })} onClick={handleHide} > <span className="i-ri-close-line size-4" aria-hidden="true" /> @@ -118,7 +124,7 @@ const LearnDifyContent = ({ )} </div> <div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5"> - {visibleItems.map(item => ( + {visibleItems.map((item) => ( <LearnDifyItem key={item.app_id} canCreate={canCreate} @@ -137,8 +143,7 @@ const DismissibleLearnDify = (props: LearnDifyProps) => { const hidden = useLearnDifyHiddenValue() const setHidden = useSetLearnDifyHidden() - if (hidden) - return null + if (hidden) return null return <LearnDifyContent {...props} onHide={() => setHidden(true)} /> } @@ -146,11 +151,9 @@ const DismissibleLearnDify = (props: LearnDifyProps) => { const LearnDify = (props: LearnDifyProps) => { const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - if (!systemFeatures.enable_learn_app) - return null + if (!systemFeatures.enable_learn_app) return null - if (props.dismissible === false) - return <LearnDifyContent {...props} /> + if (props.dismissible === false) return <LearnDifyContent {...props} /> return <DismissibleLearnDify {...props} /> } diff --git a/web/app/components/explore/learn-dify/item.tsx b/web/app/components/explore/learn-dify/item.tsx index 2516cfbd133a44..122c6e1b00538a 100644 --- a/web/app/components/explore/learn-dify/item.tsx +++ b/web/app/components/explore/learn-dify/item.tsx @@ -16,12 +16,7 @@ type LearnDifyItemProps = { onTry?: (params: TryAppSelection) => void } -const LearnDifyItem = ({ - canCreate, - item, - onCreate, - onTry, -}: LearnDifyItemProps) => { +const LearnDifyItem = ({ canCreate, item, onCreate, onTry }: LearnDifyItemProps) => { const appBasicInfo = item.app const canViewApp = IS_CLOUD_EDITION const canShowCreate = canCreate && !!onCreate @@ -43,12 +38,10 @@ const LearnDifyItem = ({ return } - if (canShowCreate) - onCreate?.(item) + if (canShowCreate) onCreate?.(item) } const handleCardKeyDown = (event: KeyboardEvent<HTMLElement>) => { - if (event.key !== 'Enter' && event.key !== ' ') - return + if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() handleCardClick() diff --git a/web/app/components/explore/learn-dify/storage.ts b/web/app/components/explore/learn-dify/storage.ts index 479a4b287a3d0a..56876423e69db5 100644 --- a/web/app/components/explore/learn-dify/storage.ts +++ b/web/app/components/explore/learn-dify/storage.ts @@ -4,13 +4,7 @@ import { createLocalStorageState } from 'foxact/create-local-storage-state' export const LEARN_DIFY_HIDDEN_STORAGE_KEY = 'explore-learn-dify-hidden' -const [ - _useLearnDifyHidden, - useLearnDifyHiddenValue, - useSetLearnDifyHidden, -] = createLocalStorageState<boolean>(LEARN_DIFY_HIDDEN_STORAGE_KEY, false) +const [_useLearnDifyHidden, useLearnDifyHiddenValue, useSetLearnDifyHidden] = + createLocalStorageState<boolean>(LEARN_DIFY_HIDDEN_STORAGE_KEY, false) -export { - useLearnDifyHiddenValue, - useSetLearnDifyHidden, -} +export { useLearnDifyHiddenValue, useSetLearnDifyHidden } diff --git a/web/app/components/explore/sidebar/__tests__/index.spec.tsx b/web/app/components/explore/sidebar/__tests__/index.spec.tsx index 6c0e2f7ac87ea8..c6e015d7d08195 100644 --- a/web/app/components/explore/sidebar/__tests__/index.spec.tsx +++ b/web/app/components/explore/sidebar/__tests__/index.spec.tsx @@ -120,8 +120,16 @@ describe('SideBar', () => { it('should render divider between pinned and unpinned apps', () => { mockInstalledApps = [ - createInstalledApp({ id: 'app-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned' } }), - createInstalledApp({ id: 'app-2', is_pinned: false, app: { ...createInstalledApp().app, name: 'Unpinned' } }), + createInstalledApp({ + id: 'app-1', + is_pinned: true, + app: { ...createInstalledApp().app, name: 'Pinned' }, + }), + createInstalledApp({ + id: 'app-2', + is_pinned: false, + app: { ...createInstalledApp().app, name: 'Unpinned' }, + }), ] const { container } = renderSideBar() @@ -135,7 +143,9 @@ describe('SideBar', () => { const toggleButton = screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }) fireEvent.click(toggleButton) - expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' }), + ).toBeInTheDocument() }) it('should render icon-only content when folded', () => { @@ -146,7 +156,9 @@ describe('SideBar', () => { expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument() expect(screen.getByRole('link', { name: 'My App' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' }), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/explore/sidebar/app-nav-item/index.tsx b/web/app/components/explore/sidebar/app-nav-item/index.tsx index be53903e6dc210..4c76b719f4b79f 100644 --- a/web/app/components/explore/sidebar/app-nav-item/index.tsx +++ b/web/app/components/explore/sidebar/app-nav-item/index.tsx @@ -1,6 +1,5 @@ 'use client' import type { AppIconType } from '@/types/app' - import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import AppIcon from '@/app/components/base/app-icon' @@ -59,12 +58,36 @@ export default function AppNavItem({ aria-current={isSelected ? 'page' : undefined} aria-label={ariaLabel ?? name} title={name} - className={cn(isMainNav ? 'flex min-w-0 flex-1 items-center gap-2 outline-hidden' : 'flex w-0 grow items-center space-x-2 outline-hidden group-data-[folded=true]/sidebar:w-auto group-data-[folded=true]/sidebar:justify-center group-data-[folded=true]/sidebar:space-x-0')} + className={cn( + isMainNav + ? 'flex min-w-0 flex-1 items-center gap-2 outline-hidden' + : 'flex w-0 grow items-center space-x-2 outline-hidden group-data-[folded=true]/sidebar:w-auto group-data-[folded=true]/sidebar:justify-center group-data-[folded=true]/sidebar:space-x-0', + )} > - <AppIcon size="tiny" className={cn(isMainNav && 'size-5 rounded-md text-sm')} iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} /> - <div className={cn(isMainNav ? 'min-w-0 flex-1 truncate py-1 pr-1 system-sm-regular' : 'truncate system-sm-regular text-components-menu-item-text group-data-[folded=true]/sidebar:hidden')} title={name}>{name}</div> + <AppIcon + size="tiny" + className={cn(isMainNav && 'size-5 rounded-md text-sm')} + iconType={icon_type} + icon={icon} + background={icon_background} + imageUrl={icon_url} + /> + <div + className={cn( + isMainNav + ? 'min-w-0 flex-1 truncate py-1 pr-1 system-sm-regular' + : 'truncate system-sm-regular text-components-menu-item-text group-data-[folded=true]/sidebar:hidden', + )} + title={name} + > + {name} + </div> </Link> - <div className={cn(isMainNav ? 'h-6 shrink-0' : 'h-6 shrink-0 group-data-[folded=true]/sidebar:hidden')}> + <div + className={cn( + isMainNav ? 'h-6 shrink-0' : 'h-6 shrink-0 group-data-[folded=true]/sidebar:hidden', + )} + > <ItemOperation isPinned={isPinned} togglePin={togglePin} diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx index 0ed8672ccb528e..0fb9ae10b5a19d 100644 --- a/web/app/components/explore/sidebar/index.tsx +++ b/web/app/components/explore/sidebar/index.tsx @@ -33,9 +33,7 @@ const SideBar = () => { const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp() const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus() - const [isFold, { - toggle: toggleIsFold, - }] = useBoolean(false) + const [isFold, { toggle: toggleIsFold }] = useBoolean(false) const [showConfirm, setShowConfirm] = useState(false) const [currId, setCurrId] = useState('') @@ -43,103 +41,134 @@ const SideBar = () => { const id = currId await uninstallApp(id) setShowConfirm(false) - toast.success(t($ => $['api.remove'], { ns: 'common' })) + toast.success(t(($) => $['api.remove'], { ns: 'common' })) } const handleUpdatePinStatus = async (id: string, isPinned: boolean) => { await updatePinStatus({ appId: id, isPinned }) - toast.success(t($ => $['api.success'], { ns: 'common' })) + toast.success(t(($) => $['api.success'], { ns: 'common' })) } const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length const webAppsLabelId = React.useId() - const installedAppItems = installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => ( - <React.Fragment key={id}> - <Item - name={name} - icon_type={icon_type} - icon={icon} - icon_background={icon_background} - icon_url={icon_url} - id={id} - isSelected={lastSegment?.toLowerCase() === id} - isPinned={is_pinned} - togglePin={() => handleUpdatePinStatus(id, !is_pinned)} - uninstallable={uninstallable} - onDelete={(id) => { - setCurrId(id) - setShowConfirm(true) - }} - /> - {index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && <Divider />} - </React.Fragment> - )) + const installedAppItems = installedApps.map( + ( + { id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, + index, + ) => ( + <React.Fragment key={id}> + <Item + name={name} + icon_type={icon_type} + icon={icon} + icon_background={icon_background} + icon_url={icon_url} + id={id} + isSelected={lastSegment?.toLowerCase() === id} + isPinned={is_pinned} + togglePin={() => handleUpdatePinStatus(id, !is_pinned)} + uninstallable={uninstallable} + onDelete={(id) => { + setCurrId(id) + setShowConfirm(true) + }} + /> + {index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && <Divider />} + </React.Fragment> + ), + ) return ( <div data-folded={isFold ? 'true' : undefined} - className={cn('group/sidebar flex h-full w-fit shrink-0 cursor-pointer flex-col px-3 pt-6 sm:w-[240px]', isFold && 'sm:w-[56px]')} + className={cn( + 'group/sidebar flex h-full w-fit shrink-0 cursor-pointer flex-col px-3 pt-6 sm:w-[240px]', + isFold && 'sm:w-[56px]', + )} > <div className={cn(isDiscoverySelected ? 'text-text-accent' : 'text-text-tertiary')}> <Link href="/" - aria-label={isFold ? t($ => $['sidebar.title'], { ns: 'explore' }) : undefined} - className={cn(isDiscoverySelected ? 'bg-state-base-active' : 'hover:bg-state-base-hover', 'flex h-8 w-full items-center justify-start gap-2 rounded-lg px-1')} + aria-label={isFold ? t(($) => $['sidebar.title'], { ns: 'explore' }) : undefined} + className={cn( + isDiscoverySelected ? 'bg-state-base-active' : 'hover:bg-state-base-hover', + 'flex h-8 w-full items-center justify-start gap-2 rounded-lg px-1', + )} > <div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid"> - <span aria-hidden="true" className="i-ri-apps-fill size-3.5 text-components-avatar-shape-fill-stop-100" /> + <span + aria-hidden="true" + className="i-ri-apps-fill size-3.5 text-components-avatar-shape-fill-stop-100" + /> </div> - {!isFold && <div className={cn('truncate', isDiscoverySelected ? 'system-sm-semibold text-components-menu-item-text-active' : 'system-sm-regular text-components-menu-item-text')}>{t($ => $['sidebar.title'], { ns: 'explore' })}</div>} + {!isFold && ( + <div + className={cn( + 'truncate', + isDiscoverySelected + ? 'system-sm-semibold text-components-menu-item-text-active' + : 'system-sm-regular text-components-menu-item-text', + )} + > + {t(($) => $['sidebar.title'], { ns: 'explore' })} + </div> + )} </Link> </div> - {!isPending && installedApps.length === 0 && !isFold - && ( - <div className="mt-5"> - <NoApps /> - </div> - )} + {!isPending && installedApps.length === 0 && !isFold && ( + <div className="mt-5"> + <NoApps /> + </div> + )} {installedApps.length > 0 && ( <div className="mt-5 flex min-h-0 flex-1 flex-col"> - {!isFold && <p id={webAppsLabelId} className="mb-1.5 pl-2 system-xs-medium-uppercase break-all text-text-tertiary uppercase">{t($ => $['sidebar.webApps'], { ns: 'explore' })}</p>} - {!isFold - ? ( - <div className="min-h-0 flex-1"> - <ScrollArea - className="h-full" - slotClassNames={{ - viewport: 'overscroll-contain', - content: 'space-y-0.5 pr-3', - }} - labelledBy={webAppsLabelId} - > - {installedAppItems} - </ScrollArea> - </div> - ) - : ( - <div - className="h-full min-h-0 flex-1 space-y-0.5 overflow-x-hidden overflow-y-auto" - > - {installedAppItems} - </div> - )} + {!isFold && ( + <p + id={webAppsLabelId} + className="mb-1.5 pl-2 system-xs-medium-uppercase break-all text-text-tertiary uppercase" + > + {t(($) => $['sidebar.webApps'], { ns: 'explore' })} + </p> + )} + {!isFold ? ( + <div className="min-h-0 flex-1"> + <ScrollArea + className="h-full" + slotClassNames={{ + viewport: 'overscroll-contain', + content: 'space-y-0.5 pr-3', + }} + labelledBy={webAppsLabelId} + > + {installedAppItems} + </ScrollArea> + </div> + ) : ( + <div className="h-full min-h-0 flex-1 space-y-0.5 overflow-x-hidden overflow-y-auto"> + {installedAppItems} + </div> + )} </div> )} <div className="mt-auto flex py-3"> <button type="button" - aria-label={isFold ? t($ => $['sidebar.expandSidebar'], { ns: 'layout' }) : t($ => $['sidebar.collapseSidebar'], { ns: 'layout' })} + aria-label={ + isFold + ? t(($) => $['sidebar.expandSidebar'], { ns: 'layout' }) + : t(($) => $['sidebar.collapseSidebar'], { ns: 'layout' }) + } className="flex size-8 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-state-base-hover focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-hover focus-visible:outline-hidden" onClick={toggleIsFold} > - {isFold - ? <span aria-hidden="true" className="i-ri-expand-right-line" /> - : ( - <span aria-hidden="true" className="i-ri-layout-left-2-line" /> - )} + {isFold ? ( + <span aria-hidden="true" className="i-ri-expand-right-line" /> + ) : ( + <span aria-hidden="true" className="i-ri-layout-left-2-line" /> + )} </button> </div> @@ -147,18 +176,22 @@ const SideBar = () => { <AlertDialogContent> <div className="flex flex-col items-start gap-2 self-stretch px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary"> - {t($ => $['sidebar.delete.title'], { ns: 'explore' })} + {t(($) => $['sidebar.delete.title'], { ns: 'explore' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['sidebar.delete.content'], { ns: 'explore' })} + {t(($) => $['sidebar.delete.content'], { ns: 'explore' })} </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton disabled={isUninstalling}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> - <AlertDialogConfirmButton loading={isUninstalling} disabled={isUninstalling} onClick={handleDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + <AlertDialogConfirmButton + loading={isUninstalling} + disabled={isUninstalling} + onClick={handleDelete} + > + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/explore/sidebar/no-apps/index.tsx b/web/app/components/explore/sidebar/no-apps/index.tsx index fddb82619ddf0d..0319197522fbaf 100644 --- a/web/app/components/explore/sidebar/no-apps/index.tsx +++ b/web/app/components/explore/sidebar/no-apps/index.tsx @@ -16,10 +16,26 @@ const NoApps: FC = () => { const docLink = useDocLink() return ( <div className="rounded-xl bg-background-default-subtle p-4"> - <div className={cn('h-[35px] w-[86px] bg-contain bg-center bg-no-repeat', theme === Theme.dark ? s.dark : s.light)}></div> - <div className="mt-2 system-sm-semibold text-text-secondary">{t($ => $[`${i18nPrefix}.title`], { ns: 'explore' })}</div> - <div className="my-1 system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.description`], { ns: 'explore' })}</div> - <a className="system-xs-regular text-text-accent" target="_blank" rel="noopener noreferrer" href={docLink('/use-dify/publish/README')}>{t($ => $[`${i18nPrefix}.learnMore`], { ns: 'explore' })}</a> + <div + className={cn( + 'h-[35px] w-[86px] bg-contain bg-center bg-no-repeat', + theme === Theme.dark ? s.dark : s.light, + )} + ></div> + <div className="mt-2 system-sm-semibold text-text-secondary"> + {t(($) => $[`${i18nPrefix}.title`], { ns: 'explore' })} + </div> + <div className="my-1 system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.description`], { ns: 'explore' })} + </div> + <a + className="system-xs-regular text-text-accent" + target="_blank" + rel="noopener noreferrer" + href={docLink('/use-dify/publish/README')} + > + {t(($) => $[`${i18nPrefix}.learnMore`], { ns: 'explore' })} + </a> </div> ) } diff --git a/web/app/components/explore/try-app/__tests__/index.spec.tsx b/web/app/components/explore/try-app/__tests__/index.spec.tsx index 52de1356cf5c69..4ecd6def49c2f5 100644 --- a/web/app/components/explore/try-app/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/__tests__/index.spec.tsx @@ -6,7 +6,7 @@ import TryApp from '../index' import { TypeEnum } from '../types' vi.mock('@/config', async (importOriginal) => { - const actual = await importOriginal() as object + const actual = (await importOriginal()) as object return { ...actual, IS_CLOUD_EDITION: true, @@ -20,7 +20,7 @@ vi.mock('@/service/use-try-app', () => ({ })) vi.mock('../app', () => ({ - default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => ( + default: ({ appId, appDetail }: { appId: string; appDetail: TryAppInfo }) => ( <div data-testid="app-component" data-app-id={appId} data-mode={appDetail?.mode}> App Component </div> @@ -28,7 +28,7 @@ vi.mock('../app', () => ({ })) vi.mock('../preview', () => ({ - default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => ( + default: ({ appId, appDetail }: { appId: string; appDetail: TryAppInfo }) => ( <div data-testid="preview-component" data-app-id={appId} data-mode={appDetail?.mode}> Preview Component </div> @@ -42,50 +42,57 @@ vi.mock('../app-info', () => ({ categories, className, onCreate, - }: { appId: string, appDetail: TryAppInfo, categories?: string[], className?: string, onCreate: () => void }) => ( + }: { + appId: string + appDetail: TryAppInfo + categories?: string[] + className?: string + onCreate: () => void + }) => ( <div data-testid="app-info-component" data-app-id={appId} data-categories={categories?.join(',')} className={className} > - <button data-testid="create-button" onClick={onCreate}>Create</button> - App Info: - {' '} - {appDetail?.name} + <button data-testid="create-button" onClick={onCreate}> + Create + </button> + App Info: {appDetail?.name} </div> ), })) -const createMockAppDetail = (mode: string = 'chat'): TryAppInfo => ({ - id: 'test-app-id', - name: 'Test App Name', - description: 'Test Description', - mode, - site: { - title: 'Test Site Title', - icon: '🚀', - icon_type: 'emoji', - icon_background: '#FFFFFF', - icon_url: '', - }, - model_config: { - model: { - provider: 'langgenius/openai/openai', - name: 'gpt-4', - mode: 'chat', +const createMockAppDetail = (mode: string = 'chat'): TryAppInfo => + ({ + id: 'test-app-id', + name: 'Test App Name', + description: 'Test Description', + mode, + site: { + title: 'Test Site Title', + icon: '🚀', + icon_type: 'emoji', + icon_background: '#FFFFFF', + icon_url: '', }, - dataset_configs: { - datasets: { - datasets: [], + model_config: { + model: { + provider: 'langgenius/openai/openai', + name: 'gpt-4', + mode: 'chat', }, + dataset_configs: { + datasets: { + datasets: [], + }, + }, + agent_mode: { + tools: [], + }, + user_input_form: [], }, - agent_mode: { - tools: [], - }, - user_input_form: [], - }, -} as unknown as TryAppInfo) + }) as unknown as TryAppInfo describe('TryApp (main index.tsx)', () => { beforeEach(() => { @@ -110,13 +117,7 @@ describe('TryApp (main index.tsx)', () => { isLoading: true, }) - render( - <TryApp - appId="test-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) expect(document.body.querySelector('[role="status"]')).toBeInTheDocument() }) @@ -128,13 +129,7 @@ describe('TryApp (main index.tsx)', () => { error: new Error('App is unavailable'), }) - render( - <TryApp - appId="test-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) expect(screen.getByText('App is unavailable')).toBeInTheDocument() }) @@ -146,13 +141,7 @@ describe('TryApp (main index.tsx)', () => { isError: false, }) - render( - <TryApp - appId="test-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) expect(screen.getByText('share.common.appUnknownError')).toBeInTheDocument() }) @@ -160,13 +149,7 @@ describe('TryApp (main index.tsx)', () => { describe('content rendering', () => { it('renders Tab component', async () => { - render( - <TryApp - appId="test-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) await waitFor(() => { expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument() @@ -175,42 +158,28 @@ describe('TryApp (main index.tsx)', () => { }) it('renders App component by default (TRY mode)', async () => { - render( - <TryApp - appId="test-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) await waitFor(() => { expect(document.body.querySelector('[data-testid="app-component"]')).toBeInTheDocument() - expect(document.body.querySelector('[data-testid="preview-component"]')).not.toBeInTheDocument() + expect( + document.body.querySelector('[data-testid="preview-component"]'), + ).not.toBeInTheDocument() }) }) it('renders AppInfo component', async () => { - render( - <TryApp - appId="test-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) await waitFor(() => { - expect(document.body.querySelector('[data-testid="app-info-component"]')).toBeInTheDocument() + expect( + document.body.querySelector('[data-testid="app-info-component"]'), + ).toBeInTheDocument() }) }) it('renders close button', async () => { - render( - <TryApp - appId="test-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) await waitFor(() => { expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() @@ -220,13 +189,7 @@ describe('TryApp (main index.tsx)', () => { describe('tab switching', () => { it('switches to Preview when Detail tab is clicked', async () => { - render( - <TryApp - appId="test-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) await waitFor(() => { expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() @@ -241,13 +204,7 @@ describe('TryApp (main index.tsx)', () => { }) it('switches back to App when Try tab is clicked', async () => { - render( - <TryApp - appId="test-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) await waitFor(() => { expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() @@ -271,13 +228,7 @@ describe('TryApp (main index.tsx)', () => { it('calls onClose when close button is clicked', async () => { const mockOnClose = vi.fn() - render( - <TryApp - appId="test-app-id" - onClose={mockOnClose} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={mockOnClose} onCreate={vi.fn()} />) await waitFor(() => { expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() @@ -291,13 +242,7 @@ describe('TryApp (main index.tsx)', () => { it('calls onClose when the dialog requests close', async () => { const mockOnClose = vi.fn() - render( - <TryApp - appId="test-app-id" - onClose={mockOnClose} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={mockOnClose} onCreate={vi.fn()} />) await waitFor(() => { expect(screen.getByRole('dialog')).toBeInTheDocument() @@ -313,20 +258,13 @@ describe('TryApp (main index.tsx)', () => { it('calls onCreate when create button in AppInfo is clicked', async () => { const mockOnCreate = vi.fn() - render( - <TryApp - appId="test-app-id" - onClose={vi.fn()} - onCreate={mockOnCreate} - />, - ) + render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={mockOnCreate} />) await waitFor(() => { const createButton = document.body.querySelector('[data-testid="create-button"]') expect(createButton).toBeInTheDocument() - if (createButton) - fireEvent.click(createButton) + if (createButton) fireEvent.click(createButton) }) expect(mockOnCreate).toHaveBeenCalledTimes(1) @@ -351,13 +289,7 @@ describe('TryApp (main index.tsx)', () => { }) it('does not pass categories to AppInfo when not provided', async () => { - render( - <TryApp - appId="test-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) await waitFor(() => { const appInfo = document.body.querySelector('[data-testid="app-info-component"]') @@ -368,13 +300,7 @@ describe('TryApp (main index.tsx)', () => { describe('hook calls', () => { it('calls useGetTryAppInfo with correct appId', () => { - render( - <TryApp - appId="my-specific-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="my-specific-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) expect(mockUseGetTryAppInfo).toHaveBeenCalledWith('my-specific-app-id') }) @@ -382,13 +308,7 @@ describe('TryApp (main index.tsx)', () => { describe('props passing', () => { it('passes appId to App component', async () => { - render( - <TryApp - appId="my-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="my-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) await waitFor(() => { const appComponent = document.body.querySelector('[data-testid="app-component"]') @@ -397,13 +317,7 @@ describe('TryApp (main index.tsx)', () => { }) it('passes appId to Preview component when in Detail mode', async () => { - render( - <TryApp - appId="my-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="my-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) await waitFor(() => { expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() @@ -418,13 +332,7 @@ describe('TryApp (main index.tsx)', () => { }) it('passes appId to AppInfo component', async () => { - render( - <TryApp - appId="my-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="my-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) await waitFor(() => { const appInfoComponent = document.body.querySelector('[data-testid="app-info-component"]') @@ -433,13 +341,7 @@ describe('TryApp (main index.tsx)', () => { }) it('passes appDetail to AppInfo component', async () => { - render( - <TryApp - appId="test-app-id" - onClose={vi.fn()} - onCreate={vi.fn()} - />, - ) + render(<TryApp appId="test-app-id" onClose={vi.fn()} onCreate={vi.fn()} />) await waitFor(() => { const appInfoComponent = document.body.querySelector('[data-testid="app-info-component"]') diff --git a/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx index e517dbb9800440..e048b47e8dc421 100644 --- a/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx @@ -10,36 +10,37 @@ vi.mock('../use-get-requirements', () => ({ default: (...args: unknown[]) => mockUseGetRequirements(...args), })) -const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({ - id: 'test-app-id', - name: 'Test App Name', - description: 'Test App Description', - mode, - site: { - title: 'Test Site Title', - icon: '🚀', - icon_type: 'emoji', - icon_background: '#FFFFFF', - icon_url: '', - }, - model_config: { - model: { - provider: 'langgenius/openai/openai', - name: 'gpt-4', - mode: 'chat', +const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => + ({ + id: 'test-app-id', + name: 'Test App Name', + description: 'Test App Description', + mode, + site: { + title: 'Test Site Title', + icon: '🚀', + icon_type: 'emoji', + icon_background: '#FFFFFF', + icon_url: '', }, - dataset_configs: { - datasets: { - datasets: [], + model_config: { + model: { + provider: 'langgenius/openai/openai', + name: 'gpt-4', + mode: 'chat', }, + dataset_configs: { + datasets: { + datasets: [], + }, + }, + agent_mode: { + tools: [], + }, + user_input_form: [], }, - agent_mode: { - tools: [], - }, - user_input_form: [], - }, - ...overrides, -} as unknown as TryAppInfo) + ...overrides, + }) as unknown as TryAppInfo describe('AppInfo', () => { beforeEach(() => { @@ -58,13 +59,7 @@ describe('AppInfo', () => { const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) expect(screen.getByText('Test App Name')).toBeInTheDocument() }) @@ -75,13 +70,7 @@ describe('AppInfo', () => { } as Partial<TryAppInfo>) const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) const nameElement = screen.getByText('Very Long App Name That Should Be Truncated') expect(nameElement).toHaveAttribute('title', 'Very Long App Name That Should Be Truncated') @@ -93,13 +82,7 @@ describe('AppInfo', () => { const appDetail = createMockAppDetail('advanced-chat') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) expect(screen.getByText('APP.TYPES.ADVANCED')).toBeInTheDocument() }) @@ -108,13 +91,7 @@ describe('AppInfo', () => { const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) expect(screen.getByText('APP.TYPES.CHATBOT')).toBeInTheDocument() }) @@ -123,13 +100,7 @@ describe('AppInfo', () => { const appDetail = createMockAppDetail('agent-chat') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) expect(screen.getByText('APP.TYPES.AGENT')).toBeInTheDocument() }) @@ -138,13 +109,7 @@ describe('AppInfo', () => { const appDetail = createMockAppDetail('workflow') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) expect(screen.getByText('APP.TYPES.WORKFLOW')).toBeInTheDocument() }) @@ -153,13 +118,7 @@ describe('AppInfo', () => { const appDetail = createMockAppDetail('completion') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) expect(screen.getByText('APP.TYPES.COMPLETION')).toBeInTheDocument() }) @@ -172,13 +131,7 @@ describe('AppInfo', () => { } as Partial<TryAppInfo>) const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) expect(screen.getByText('This is a test description')).toBeInTheDocument() }) @@ -190,11 +143,7 @@ describe('AppInfo', () => { const mockOnCreate = vi.fn() const { container } = render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, + <AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />, ) const descriptionElements = container.querySelectorAll('.system-sm-regular.mt-\\[14px\\]') @@ -207,13 +156,7 @@ describe('AppInfo', () => { const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) expect(screen.getByText('explore.tryApp.createFromSampleApp')).toBeInTheDocument() }) @@ -222,13 +165,7 @@ describe('AppInfo', () => { const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) fireEvent.click(screen.getByText('explore.tryApp.createFromSampleApp')) expect(mockOnCreate).toHaveBeenCalledTimes(1) @@ -258,13 +195,7 @@ describe('AppInfo', () => { const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) expect(screen.queryByText('explore.tryApp.category')).not.toBeInTheDocument() }) @@ -282,13 +213,7 @@ describe('AppInfo', () => { const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) expect(screen.getByText('explore.tryApp.requirements')).toBeInTheDocument() expect(screen.getByText('OpenAI GPT-4')).toBeInTheDocument() @@ -303,33 +228,21 @@ describe('AppInfo', () => { const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) expect(screen.queryByText('explore.tryApp.requirements')).not.toBeInTheDocument() }) it('renders requirement icons with correct image src', () => { mockUseGetRequirements.mockReturnValue({ - requirements: [ - { name: 'Test Tool', iconUrl: 'https://example.com/test-icon.png' }, - ], + requirements: [{ name: 'Test Tool', iconUrl: 'https://example.com/test-icon.png' }], }) const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() const { container } = render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, + <AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />, ) const iconElement = container.querySelector('img[src="https://example.com/test-icon.png"]') @@ -338,21 +251,13 @@ describe('AppInfo', () => { it('falls back to default icon when requirement image fails to load', () => { mockUseGetRequirements.mockReturnValue({ - requirements: [ - { name: 'Broken Tool', iconUrl: 'https://example.com/broken-icon.png' }, - ], + requirements: [{ name: 'Broken Tool', iconUrl: 'https://example.com/broken-icon.png' }], }) const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="test-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) const requirementRow = screen.getByText('Broken Tool').parentElement as HTMLElement const iconImage = requirementRow.querySelector('img') as HTMLImageElement @@ -361,7 +266,9 @@ describe('AppInfo', () => { fireEvent.error(iconImage) expect(requirementRow.querySelector('img')).not.toBeInTheDocument() - expect(requirementRow.querySelector('.i-custom-public-other-default-tool-icon')).toBeInTheDocument() + expect( + requirementRow.querySelector('.i-custom-public-other-default-tool-icon'), + ).toBeInTheDocument() }) }) @@ -388,13 +295,7 @@ describe('AppInfo', () => { const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() - render( - <AppInfo - appId="my-app-id" - appDetail={appDetail} - onCreate={mockOnCreate} - />, - ) + render(<AppInfo appId="my-app-id" appDetail={appDetail} onCreate={mockOnCreate} />) expect(mockUseGetRequirements).toHaveBeenCalledWith({ appDetail, diff --git a/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts b/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts index 076278b3edf5f1..2ad92fb54cdd68 100644 --- a/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts +++ b/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts @@ -13,36 +13,37 @@ vi.mock('@/config', () => ({ MARKETPLACE_API_PREFIX: 'https://marketplace.api', })) -const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({ - id: 'test-app-id', - name: 'Test App', - description: 'Test Description', - mode, - site: { - title: 'Test Site Title', - icon: 'icon', - icon_type: 'emoji', - icon_background: '#FFFFFF', - icon_url: '', - }, - model_config: { - model: { - provider: 'langgenius/openai/openai', - name: 'gpt-4', - mode: 'chat', +const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => + ({ + id: 'test-app-id', + name: 'Test App', + description: 'Test Description', + mode, + site: { + title: 'Test Site Title', + icon: 'icon', + icon_type: 'emoji', + icon_background: '#FFFFFF', + icon_url: '', }, - dataset_configs: { - datasets: { - datasets: [], + model_config: { + model: { + provider: 'langgenius/openai/openai', + name: 'gpt-4', + mode: 'chat', }, + dataset_configs: { + datasets: { + datasets: [], + }, + }, + agent_mode: { + tools: [], + }, + user_input_form: [], }, - agent_mode: { - tools: [], - }, - user_input_form: [], - }, - ...overrides, -} as unknown as TryAppInfo) + ...overrides, + }) as unknown as TryAppInfo describe('useGetRequirements', () => { afterEach(() => { @@ -54,13 +55,13 @@ describe('useGetRequirements', () => { mockUseGetTryAppFlowPreview.mockReturnValue({ data: null }) const appDetail = createMockAppDetail('chat') - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) expect(result.current.requirements).toHaveLength(1) expect(result.current.requirements[0]!.name).toBe('openai') - expect(result.current.requirements[0]!.iconUrl).toBe('https://marketplace.api/plugins/langgenius/openai/icon') + expect(result.current.requirements[0]!.iconUrl).toBe( + 'https://marketplace.api/plugins/langgenius/openai/icon', + ) }) it('returns model provider for completion mode', () => { @@ -79,9 +80,7 @@ describe('useGetRequirements', () => { }, } as unknown as Partial<TryAppInfo>) - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) expect(result.current.requirements).toHaveLength(1) expect(result.current.requirements[0]!.name).toBe('claude') @@ -121,15 +120,13 @@ describe('useGetRequirements', () => { }, } as unknown as Partial<TryAppInfo>) - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) expect(result.current.requirements).toHaveLength(3) - expect(result.current.requirements.map(r => r.name)).toContain('openai') - expect(result.current.requirements.map(r => r.name)).toContain('Google Search') - expect(result.current.requirements.map(r => r.name)).toContain('Web Scraper') - expect(result.current.requirements.map(r => r.name)).not.toContain('Disabled Tool') + expect(result.current.requirements.map((r) => r.name)).toContain('openai') + expect(result.current.requirements.map((r) => r.name)).toContain('Google Search') + expect(result.current.requirements.map((r) => r.name)).toContain('Web Scraper') + expect(result.current.requirements.map((r) => r.name)).not.toContain('Disabled Tool') }) it('filters out disabled tools in agent mode', () => { @@ -161,9 +158,7 @@ describe('useGetRequirements', () => { }, } as unknown as Partial<TryAppInfo>) - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) expect(result.current.requirements).toHaveLength(1) expect(result.current.requirements[0]!.name).toBe('openai') @@ -198,13 +193,11 @@ describe('useGetRequirements', () => { }) const appDetail = createMockAppDetail('workflow') - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) expect(result.current.requirements).toHaveLength(2) - expect(result.current.requirements.map(r => r.name)).toContain('gpt-4') - expect(result.current.requirements.map(r => r.name)).toContain('Google Tool') + expect(result.current.requirements.map((r) => r.name)).toContain('gpt-4') + expect(result.current.requirements.map((r) => r.name)).toContain('Google Tool') }) it('returns requirements from flow data for advanced-chat mode', () => { @@ -227,9 +220,7 @@ describe('useGetRequirements', () => { }) const appDetail = createMockAppDetail('advanced-chat') - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) expect(result.current.requirements).toHaveLength(1) expect(result.current.requirements[0]!.name).toBe('claude-3-opus') @@ -245,9 +236,7 @@ describe('useGetRequirements', () => { }) const appDetail = createMockAppDetail('workflow') - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) expect(result.current.requirements).toHaveLength(0) }) @@ -258,9 +247,7 @@ describe('useGetRequirements', () => { }) const appDetail = createMockAppDetail('workflow') - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) expect(result.current.requirements).toHaveLength(0) }) @@ -294,13 +281,11 @@ describe('useGetRequirements', () => { }) const appDetail = createMockAppDetail('workflow') - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) expect(result.current.requirements).toHaveLength(2) - expect(result.current.requirements.map(r => r.name)).toContain('gpt-4') - expect(result.current.requirements.map(r => r.name)).toContain('claude-3') + expect(result.current.requirements.map((r) => r.name)).toContain('gpt-4') + expect(result.current.requirements.map((r) => r.name)).toContain('claude-3') }) it('extracts multiple tool nodes from flow data', () => { @@ -328,13 +313,11 @@ describe('useGetRequirements', () => { }) const appDetail = createMockAppDetail('workflow') - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) expect(result.current.requirements).toHaveLength(2) - expect(result.current.requirements.map(r => r.name)).toContain('Tool 1') - expect(result.current.requirements.map(r => r.name)).toContain('Tool 2') + expect(result.current.requirements.map((r) => r.name)).toContain('Tool 1') + expect(result.current.requirements.map((r) => r.name)).toContain('Tool 2') }) }) @@ -368,9 +351,7 @@ describe('useGetRequirements', () => { }) const appDetail = createMockAppDetail('workflow') - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) expect(result.current.requirements).toHaveLength(1) expect(result.current.requirements[0]!.name).toBe('gpt-4') @@ -394,11 +375,11 @@ describe('useGetRequirements', () => { }, } as unknown as Partial<TryAppInfo>) - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) - expect(result.current.requirements[0]!.iconUrl).toBe('https://marketplace.api/plugins/org/plugin/icon') + expect(result.current.requirements[0]!.iconUrl).toBe( + 'https://marketplace.api/plugins/org/plugin/icon', + ) }) it('maps google model provider to gemini plugin icon URL', () => { @@ -417,11 +398,11 @@ describe('useGetRequirements', () => { }, } as unknown as Partial<TryAppInfo>) - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) - expect(result.current.requirements[0]!.iconUrl).toBe('https://marketplace.api/plugins/langgenius/gemini/icon') + expect(result.current.requirements[0]!.iconUrl).toBe( + 'https://marketplace.api/plugins/langgenius/gemini/icon', + ) }) it('maps special builtin tool providers to *_tool plugin icon URL', () => { @@ -448,12 +429,14 @@ describe('useGetRequirements', () => { }, } as unknown as Partial<TryAppInfo>) - const { result } = renderHook(() => - useGetRequirements({ appDetail, appId: 'test-app-id' }), - ) + const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) - const toolRequirement = result.current.requirements.find(item => item.name === 'Jina Search') - expect(toolRequirement?.iconUrl).toBe('https://marketplace.api/plugins/langgenius/jina_tool/icon') + const toolRequirement = result.current.requirements.find( + (item) => item.name === 'Jina Search', + ) + expect(toolRequirement?.iconUrl).toBe( + 'https://marketplace.api/plugins/langgenius/jina_tool/icon', + ) }) }) diff --git a/web/app/components/explore/try-app/app-info/index.tsx b/web/app/components/explore/try-app/app-info/index.tsx index 770ef58b1ff533..a7105a7c6d4d03 100644 --- a/web/app/components/explore/try-app/app-info/index.tsx +++ b/web/app/components/explore/try-app/app-info/index.tsx @@ -49,13 +49,7 @@ const RequirementIcon: FC<RequirementIconProps> = ({ iconUrl }) => { ) } -const AppInfo: FC<Props> = ({ - appId, - className, - categories, - appDetail, - onCreate, -}) => { +const AppInfo: FC<Props> = ({ appId, className, categories, appDetail, onCreate }) => { const { t } = useTranslation() const mode = appDetail?.mode const visibleCategories = Array.from(new Set(categories?.filter(Boolean) ?? [])) @@ -80,30 +74,56 @@ const AppInfo: FC<Props> = ({ </div> <div className="w-0 grow py-px"> <div className="flex items-center text-sm/5 font-semibold text-text-secondary"> - <div className="truncate" title={appDetail.name}>{appDetail.name}</div> + <div className="truncate" title={appDetail.name}> + {appDetail.name} + </div> </div> <div className="flex items-center text-[10px] leading-[18px] font-medium text-text-tertiary"> - {mode === 'advanced-chat' && <div className="truncate">{t($ => $['types.advanced'], { ns: 'app' }).toUpperCase()}</div>} - {mode === 'chat' && <div className="truncate">{t($ => $['types.chatbot'], { ns: 'app' }).toUpperCase()}</div>} - {mode === 'agent-chat' && <div className="truncate">{t($ => $['types.agent'], { ns: 'app' }).toUpperCase()}</div>} - {mode === 'workflow' && <div className="truncate">{t($ => $['types.workflow'], { ns: 'app' }).toUpperCase()}</div>} - {mode === 'completion' && <div className="truncate">{t($ => $['types.completion'], { ns: 'app' }).toUpperCase()}</div>} + {mode === 'advanced-chat' && ( + <div className="truncate"> + {t(($) => $['types.advanced'], { ns: 'app' }).toUpperCase()} + </div> + )} + {mode === 'chat' && ( + <div className="truncate"> + {t(($) => $['types.chatbot'], { ns: 'app' }).toUpperCase()} + </div> + )} + {mode === 'agent-chat' && ( + <div className="truncate"> + {t(($) => $['types.agent'], { ns: 'app' }).toUpperCase()} + </div> + )} + {mode === 'workflow' && ( + <div className="truncate"> + {t(($) => $['types.workflow'], { ns: 'app' }).toUpperCase()} + </div> + )} + {mode === 'completion' && ( + <div className="truncate"> + {t(($) => $['types.completion'], { ns: 'app' }).toUpperCase()} + </div> + )} </div> </div> </div> {appDetail.description && ( - <div className="mt-[14px] shrink-0 system-sm-regular text-text-secondary">{appDetail.description}</div> + <div className="mt-[14px] shrink-0 system-sm-regular text-text-secondary"> + {appDetail.description} + </div> )} <Button variant="primary" className="mt-3 flex w-full max-w-full" onClick={onCreate}> <span className="mr-1 i-ri-add-line size-4 shrink-0" /> - <span className="truncate">{t($ => $['tryApp.createFromSampleApp'], { ns: 'explore' })}</span> + <span className="truncate"> + {t(($) => $['tryApp.createFromSampleApp'], { ns: 'explore' })} + </span> </Button> {visibleCategories.length > 0 && ( <div className="mt-6 shrink-0"> - <div className={headerClassName}>{t($ => $['tryApp.category'], { ns: 'explore' })}</div> + <div className={headerClassName}>{t(($) => $['tryApp.category'], { ns: 'explore' })}</div> <div className="flex flex-wrap gap-1.5"> - {visibleCategories.map(category => ( + {visibleCategories.map((category) => ( <span key={category} className="rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-2 py-0.5 system-xs-medium text-text-secondary shadow-xs" @@ -116,18 +136,21 @@ const AppInfo: FC<Props> = ({ )} {requirements.length > 0 && ( <div className="mt-5 grow overflow-y-auto"> - <div className={headerClassName}>{t($ => $['tryApp.requirements'], { ns: 'explore' })}</div> + <div className={headerClassName}> + {t(($) => $['tryApp.requirements'], { ns: 'explore' })} + </div> <div className="space-y-0.5"> - {requirements.map(item => ( + {requirements.map((item) => ( <div className="flex items-center space-x-2 py-1" key={item.name}> <RequirementIcon iconUrl={item.iconUrl} /> - <div className="w-0 grow truncate system-md-regular text-text-secondary">{item.name}</div> + <div className="w-0 grow truncate system-md-regular text-text-secondary"> + {item.name} + </div> </div> ))} </div> </div> )} - </div> ) } diff --git a/web/app/components/explore/try-app/app-info/use-get-requirements.ts b/web/app/components/explore/try-app/app-info/use-get-requirements.ts index 70ec4c80c2cad7..be62caf1154933 100644 --- a/web/app/components/explore/try-app/app-info/use-get-requirements.ts +++ b/web/app/components/explore/try-app/app-info/use-get-requirements.ts @@ -36,8 +36,7 @@ const PROVIDER_PLUGIN_ALIASES: Record<ProviderType, Record<string, string>> = { const parseProviderId = (providerId: string): ProviderInfo | null => { const segments = providerId.split('/').filter(Boolean) - if (!segments.length) - return null + if (!segments.length) return null if (segments.length === 1) { return { @@ -58,8 +57,7 @@ const getPluginName = (providerName: string, type: ProviderType) => { const getIconUrl = (providerId: string, type: ProviderType) => { const parsed = parseProviderId(providerId) - if (!parsed) - return '' + if (!parsed) return '' const organization = encodeURIComponent(parsed.organization) const pluginName = encodeURIComponent(getPluginName(parsed.providerName, type)) @@ -71,31 +69,33 @@ const isRecord = (value: unknown): value is Record<string, unknown> => { } const getGraphNodes = (graph: unknown): unknown[] => { - if (!isRecord(graph) || !Array.isArray(graph.nodes)) - return [] + if (!isRecord(graph) || !Array.isArray(graph.nodes)) return [] return graph.nodes } const isAgentTool = (value: unknown): value is AgentTool => { - if (!isRecord(value)) - return false + if (!isRecord(value)) return false - return typeof value.provider_id === 'string' - && typeof value.tool_label === 'string' - && value.enabled === true + return ( + typeof value.provider_id === 'string' && + typeof value.tool_label === 'string' && + value.enabled === true + ) } -const hasLLMRequirementData = (value: unknown): value is { model: { name: string, provider: string } } => { - if (!isRecord(value) || !isRecord(value.model)) - return false +const hasLLMRequirementData = ( + value: unknown, +): value is { model: { name: string; provider: string } } => { + if (!isRecord(value) || !isRecord(value.model)) return false return typeof value.model.name === 'string' && typeof value.model.provider === 'string' } -const hasToolRequirementData = (value: unknown): value is { provider_id: string, tool_label: string } => { - if (!isRecord(value)) - return false +const hasToolRequirementData = ( + value: unknown, +): value is { provider_id: string; tool_label: string } => { + if (!isRecord(value)) return false return typeof value.provider_id === 'string' && typeof value.tool_label === 'string' } @@ -118,34 +118,42 @@ const useGetRequirements = ({ appDetail, appId }: Params) => { }) } if (isAgent && modelConfig?.agent_mode?.tools) { - requirements.push(...modelConfig.agent_mode.tools.filter(isAgentTool).map(tool => ({ - name: tool.tool_label, - iconUrl: getIconUrl(tool.provider_id, 'tool'), - }))) + requirements.push( + ...modelConfig.agent_mode.tools.filter(isAgentTool).map((tool) => ({ + name: tool.tool_label, + iconUrl: getIconUrl(tool.provider_id, 'tool'), + })), + ) } const nodes = getGraphNodes(flowData?.graph) if (isAdvanced && nodes.length > 0) { - requirements.push(...nodes.flatMap((node) => { - const data = isRecord(node) && isRecord(node.data) ? node.data : null - if (data?.type !== BlockEnum.LLM || !hasLLMRequirementData(data)) - return [] - - return [{ - name: data.model.name, - iconUrl: getIconUrl(data.model.provider, 'model'), - }] - })) - - requirements.push(...nodes.flatMap((node) => { - const data = isRecord(node) && isRecord(node.data) ? node.data : null - if (data?.type !== BlockEnum.Tool || !hasToolRequirementData(data)) - return [] - - return [{ - name: data.tool_label, - iconUrl: getIconUrl(data.provider_id, 'tool'), - }] - })) + requirements.push( + ...nodes.flatMap((node) => { + const data = isRecord(node) && isRecord(node.data) ? node.data : null + if (data?.type !== BlockEnum.LLM || !hasLLMRequirementData(data)) return [] + + return [ + { + name: data.model.name, + iconUrl: getIconUrl(data.model.provider, 'model'), + }, + ] + }), + ) + + requirements.push( + ...nodes.flatMap((node) => { + const data = isRecord(node) && isRecord(node.data) ? node.data : null + if (data?.type !== BlockEnum.Tool || !hasToolRequirementData(data)) return [] + + return [ + { + name: data.tool_label, + iconUrl: getIconUrl(data.provider_id, 'tool'), + }, + ] + }), + ) } const uniqueRequirements = uniqBy(requirements, 'name') diff --git a/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx b/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx index 8fc2cb530e681e..8d0d1bd7882e82 100644 --- a/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx +++ b/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx @@ -33,36 +33,37 @@ vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropd default: () => <div data-testid="view-form-dropdown">ViewFormDropdown</div>, })) -const createMockAppDetail = (overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({ - id: 'test-app-id', - name: 'Test Chat App', - description: 'Test Description', - mode: 'chat', - site: { - title: 'Test Site Title', - icon: '💬', - icon_type: 'emoji', - icon_background: '#4F46E5', - icon_url: '', - }, - model_config: { - model: { - provider: 'langgenius/openai/openai', - name: 'gpt-4', - mode: 'chat', +const createMockAppDetail = (overrides: Partial<TryAppInfo> = {}): TryAppInfo => + ({ + id: 'test-app-id', + name: 'Test Chat App', + description: 'Test Description', + mode: 'chat', + site: { + title: 'Test Site Title', + icon: '💬', + icon_type: 'emoji', + icon_background: '#4F46E5', + icon_url: '', }, - dataset_configs: { - datasets: { - datasets: [], + model_config: { + model: { + provider: 'langgenius/openai/openai', + name: 'gpt-4', + mode: 'chat', }, + dataset_configs: { + datasets: { + datasets: [], + }, + }, + agent_mode: { + tools: [], + }, + user_input_form: [], }, - agent_mode: { - tools: [], - }, - user_input_form: [], - }, - ...overrides, -} as unknown as TryAppInfo) + ...overrides, + }) as unknown as TryAppInfo describe('TryApp (chat.tsx)', () => { beforeEach(() => { @@ -83,13 +84,7 @@ describe('TryApp (chat.tsx)', () => { it('renders app name', () => { const appDetail = createMockAppDetail() - render( - <TryApp - appId="test-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="test-app-id" appDetail={appDetail} className="test-class" />) expect(screen.getByText('Test Chat App')).toBeInTheDocument() }) @@ -97,13 +92,7 @@ describe('TryApp (chat.tsx)', () => { it('renders app name with title attribute', () => { const appDetail = createMockAppDetail({ name: 'Long App Name' } as Partial<TryAppInfo>) - render( - <TryApp - appId="test-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="test-app-id" appDetail={appDetail} className="test-class" />) const nameElement = screen.getByText('Long App Name') expect(nameElement).toHaveAttribute('title', 'Long App Name') @@ -112,13 +101,7 @@ describe('TryApp (chat.tsx)', () => { it('renders ChatWrapper', () => { const appDetail = createMockAppDetail() - render( - <TryApp - appId="test-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="test-app-id" appDetail={appDetail} className="test-class" />) expect(screen.getByTestId('chat-wrapper')).toBeInTheDocument() }) @@ -126,13 +109,7 @@ describe('TryApp (chat.tsx)', () => { it('renders alert with try info', () => { const appDetail = createMockAppDetail() - render( - <TryApp - appId="test-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="test-app-id" appDetail={appDetail} className="test-class" />) expect(screen.getByText('explore.tryApp.tryInfo')).toBeInTheDocument() }) @@ -141,11 +118,7 @@ describe('TryApp (chat.tsx)', () => { const appDetail = createMockAppDetail() const { container } = render( - <TryApp - appId="test-app-id" - appDetail={appDetail} - className="custom-class" - />, + <TryApp appId="test-app-id" appDetail={appDetail} className="custom-class" />, ) const innerDiv = container.querySelector('.custom-class') @@ -164,13 +137,7 @@ describe('TryApp (chat.tsx)', () => { const appDetail = createMockAppDetail() - render( - <TryApp - appId="test-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="test-app-id" appDetail={appDetail} className="test-class" />) expect(screen.queryByRole('button', { name: 'share.chat.resetChat' })).not.toBeInTheDocument() }) @@ -185,13 +152,7 @@ describe('TryApp (chat.tsx)', () => { const appDetail = createMockAppDetail() - render( - <TryApp - appId="test-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="test-app-id" appDetail={appDetail} className="test-class" />) expect(screen.getByRole('button', { name: 'share.chat.resetChat' })).toBeInTheDocument() }) @@ -206,13 +167,7 @@ describe('TryApp (chat.tsx)', () => { const appDetail = createMockAppDetail() - render( - <TryApp - appId="test-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="test-app-id" appDetail={appDetail} className="test-class" />) fireEvent.click(screen.getByRole('button', { name: 'share.chat.resetChat' })) @@ -232,13 +187,7 @@ describe('TryApp (chat.tsx)', () => { const appDetail = createMockAppDetail() - render( - <TryApp - appId="test-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="test-app-id" appDetail={appDetail} className="test-class" />) expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument() }) @@ -253,13 +202,7 @@ describe('TryApp (chat.tsx)', () => { const appDetail = createMockAppDetail() - render( - <TryApp - appId="test-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="test-app-id" appDetail={appDetail} className="test-class" />) expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument() }) @@ -274,13 +217,7 @@ describe('TryApp (chat.tsx)', () => { const appDetail = createMockAppDetail() - render( - <TryApp - appId="test-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="test-app-id" appDetail={appDetail} className="test-class" />) expect(screen.getByTestId('view-form-dropdown')).toBeInTheDocument() }) @@ -290,15 +227,11 @@ describe('TryApp (chat.tsx)', () => { it('hides alert when onHide is called', () => { const appDetail = createMockAppDetail() - render( - <TryApp - appId="test-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="test-app-id" appDetail={appDetail} className="test-class" />) - const alertElement = screen.getByText('explore.tryApp.tryInfo').closest('[class*="alert"]')?.parentElement + const alertElement = screen + .getByText('explore.tryApp.tryInfo') + .closest('[class*="alert"]')?.parentElement const hideButton = alertElement?.querySelector('button, [role="button"], svg') if (hideButton) { @@ -312,13 +245,7 @@ describe('TryApp (chat.tsx)', () => { it('calls useEmbeddedChatbot with correct parameters', () => { const appDetail = createMockAppDetail() - render( - <TryApp - appId="my-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="my-app-id" appDetail={appDetail} className="test-class" />) expect(mockUseEmbeddedChatbot).toHaveBeenCalledWith('tryApp', 'my-app-id') }) @@ -326,13 +253,7 @@ describe('TryApp (chat.tsx)', () => { it('calls removeConversationIdInfo on mount', () => { const appDetail = createMockAppDetail() - render( - <TryApp - appId="my-app-id" - appDetail={appDetail} - className="test-class" - />, - ) + render(<TryApp appId="my-app-id" appDetail={appDetail} className="test-class" />) expect(mockRemoveConversationIdInfo).toHaveBeenCalledWith('my-app-id') }) diff --git a/web/app/components/explore/try-app/app/__tests__/index.spec.tsx b/web/app/components/explore/try-app/app/__tests__/index.spec.tsx index 9064fa1c2ae3dc..49db38d2b9bdb2 100644 --- a/web/app/components/explore/try-app/app/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/app/__tests__/index.spec.tsx @@ -8,8 +8,21 @@ vi.mock('@/hooks/use-document-title', () => ({ })) vi.mock('../chat', () => ({ - default: ({ appId, appDetail, className }: { appId: string, appDetail: TryAppInfo, className: string }) => ( - <div data-testid="chat-component" data-app-id={appId} data-mode={appDetail.mode} className={className}> + default: ({ + appId, + appDetail, + className, + }: { + appId: string + appDetail: TryAppInfo + className: string + }) => ( + <div + data-testid="chat-component" + data-app-id={appId} + data-mode={appDetail.mode} + className={className} + > Chat Component </div> ), @@ -21,7 +34,12 @@ vi.mock('../text-generation', () => ({ className, isWorkflow, appData, - }: { appId: string, className: string, isWorkflow: boolean, appData: { mode: string } }) => ( + }: { + appId: string + className: string + isWorkflow: boolean + appData: { mode: string } + }) => ( <div data-testid="text-generation-component" data-app-id={appId} @@ -34,35 +52,36 @@ vi.mock('../text-generation', () => ({ ), })) -const createMockAppDetail = (mode: string): TryAppInfo => ({ - id: 'test-app-id', - name: 'Test App', - description: 'Test Description', - mode, - site: { - title: 'Test Site Title', - icon: 'icon', - icon_type: 'emoji', - icon_background: '#FFFFFF', - icon_url: '', - }, - model_config: { - model: { - provider: 'test/provider', - name: 'test-model', - mode: 'chat', +const createMockAppDetail = (mode: string): TryAppInfo => + ({ + id: 'test-app-id', + name: 'Test App', + description: 'Test Description', + mode, + site: { + title: 'Test Site Title', + icon: 'icon', + icon_type: 'emoji', + icon_background: '#FFFFFF', + icon_url: '', }, - dataset_configs: { - datasets: { - datasets: [], + model_config: { + model: { + provider: 'test/provider', + name: 'test-model', + mode: 'chat', }, + dataset_configs: { + datasets: { + datasets: [], + }, + }, + agent_mode: { + tools: [], + }, + user_input_form: [], }, - agent_mode: { - tools: [], - }, - user_input_form: [], - }, -} as unknown as TryAppInfo) + }) as unknown as TryAppInfo describe('TryApp (app/index.tsx)', () => { afterEach(() => { diff --git a/web/app/components/explore/try-app/app/__tests__/text-generation.spec.tsx b/web/app/components/explore/try-app/app/__tests__/text-generation.spec.tsx index ddc3eb72a8d1d7..5cc99de40e1210 100644 --- a/web/app/components/explore/try-app/app/__tests__/text-generation.spec.tsx +++ b/web/app/components/explore/try-app/app/__tests__/text-generation.spec.tsx @@ -46,11 +46,22 @@ vi.mock('@/app/components/share/text-generation/run-once', () => ({ siteInfo, onSend, onInputsChange, - }: { siteInfo: { title: string }, onSend: () => void, onInputsChange: (inputs: Record<string, unknown>) => void }) => ( + }: { + siteInfo: { title: string } + onSend: () => void + onInputsChange: (inputs: Record<string, unknown>) => void + }) => ( <div data-testid="run-once"> <span data-testid="site-title">{siteInfo?.title}</span> - <button data-testid="send-button" onClick={onSend}>Send</button> - <button data-testid="inputs-change-button" onClick={() => onInputsChange({ testInput: 'testValue' })}>Change Inputs</button> + <button data-testid="send-button" onClick={onSend}> + Send + </button> + <button + data-testid="inputs-change-button" + onClick={() => onInputsChange({ testInput: 'testValue' })} + > + Change Inputs + </button> </div> ), })) @@ -61,34 +72,44 @@ vi.mock('@/app/components/share/text-generation/result', () => ({ appId, onCompleted, onRunStart, - }: { isWorkflow: boolean, appId: string, onCompleted: () => void, onRunStart: () => void }) => ( + }: { + isWorkflow: boolean + appId: string + onCompleted: () => void + onRunStart: () => void + }) => ( <div data-testid="result-component" data-is-workflow={isWorkflow} data-app-id={appId}> - <button data-testid="complete-button" onClick={onCompleted}>Complete</button> - <button data-testid="run-start-button" onClick={onRunStart}>Run Start</button> + <button data-testid="complete-button" onClick={onCompleted}> + Complete + </button> + <button data-testid="run-start-button" onClick={onRunStart}> + Run Start + </button> </div> ), })) -const createMockAppData = (overrides: Partial<AppData> = {}): AppData => ({ - app_id: 'test-app-id', - site: { - title: 'Test App Title', - description: 'Test App Description', - icon: '🚀', - icon_type: 'emoji', - icon_background: '#FFFFFF', - icon_url: '', - default_language: 'en', - prompt_public: true, - copyright: '', - privacy_policy: '', - custom_disclaimer: '', - }, - custom_config: { - remove_webapp_brand: false, - }, - ...overrides, -} as AppData) +const createMockAppData = (overrides: Partial<AppData> = {}): AppData => + ({ + app_id: 'test-app-id', + site: { + title: 'Test App Title', + description: 'Test App Description', + icon: '🚀', + icon_type: 'emoji', + icon_background: '#FFFFFF', + icon_url: '', + default_language: 'en', + prompt_public: true, + copyright: '', + privacy_policy: '', + custom_disclaimer: '', + }, + custom_config: { + remove_webapp_brand: false, + }, + ...overrides, + }) as AppData describe('TextGeneration', () => { beforeEach(() => { @@ -106,12 +127,7 @@ describe('TextGeneration', () => { describe('loading state', () => { it('renders loading when appData is null', () => { - render( - <TextGeneration - appId="test-app-id" - appData={null} - />, - ) + render(<TextGeneration appId="test-app-id" appData={null} />) expect(screen.getByRole('status')).toBeInTheDocument() }) @@ -122,12 +138,7 @@ describe('TextGeneration', () => { data: null, }) - render( - <TextGeneration - appId="test-app-id" - appData={createMockAppData()} - />, - ) + render(<TextGeneration appId="test-app-id" appData={createMockAppData()} />) expect(screen.getByRole('status')).toBeInTheDocument() }) @@ -137,12 +148,7 @@ describe('TextGeneration', () => { it('renders app title', async () => { const appData = createMockAppData() - render( - <TextGeneration - appId="test-app-id" - appData={appData} - />, - ) + render(<TextGeneration appId="test-app-id" appData={appData} />) await waitFor(() => { const titles = screen.getAllByText('Test App Title') @@ -167,12 +173,7 @@ describe('TextGeneration', () => { }, } as unknown as Partial<AppData>) - render( - <TextGeneration - appId="test-app-id" - appData={appData} - />, - ) + render(<TextGeneration appId="test-app-id" appData={appData} />) await waitFor(() => { expect(screen.getByText('This is a description')).toBeInTheDocument() @@ -182,12 +183,7 @@ describe('TextGeneration', () => { it('renders RunOnce component', async () => { const appData = createMockAppData() - render( - <TextGeneration - appId="test-app-id" - appData={appData} - />, - ) + render(<TextGeneration appId="test-app-id" appData={appData} />) await waitFor(() => { expect(screen.getByTestId('run-once')).toBeInTheDocument() @@ -197,12 +193,7 @@ describe('TextGeneration', () => { it('renders Result component', async () => { const appData = createMockAppData() - render( - <TextGeneration - appId="test-app-id" - appData={appData} - />, - ) + render(<TextGeneration appId="test-app-id" appData={appData} />) await waitFor(() => { expect(screen.getByTestId('result-component')).toBeInTheDocument() @@ -214,13 +205,7 @@ describe('TextGeneration', () => { it('passes isWorkflow=true to Result when isWorkflow prop is true', async () => { const appData = createMockAppData() - render( - <TextGeneration - appId="test-app-id" - appData={appData} - isWorkflow - />, - ) + render(<TextGeneration appId="test-app-id" appData={appData} isWorkflow />) await waitFor(() => { const resultComponent = screen.getByTestId('result-component') @@ -231,13 +216,7 @@ describe('TextGeneration', () => { it('passes isWorkflow=false to Result when isWorkflow prop is false', async () => { const appData = createMockAppData() - render( - <TextGeneration - appId="test-app-id" - appData={appData} - isWorkflow={false} - />, - ) + render(<TextGeneration appId="test-app-id" appData={appData} isWorkflow={false} />) await waitFor(() => { const resultComponent = screen.getByTestId('result-component') @@ -250,12 +229,7 @@ describe('TextGeneration', () => { it('triggers send when RunOnce sends', async () => { const appData = createMockAppData() - render( - <TextGeneration - appId="test-app-id" - appData={appData} - />, - ) + render(<TextGeneration appId="test-app-id" appData={appData} />) await waitFor(() => { expect(screen.getByTestId('send-button')).toBeInTheDocument() @@ -271,12 +245,7 @@ describe('TextGeneration', () => { it('shows alert after completion', async () => { const appData = createMockAppData() - render( - <TextGeneration - appId="test-app-id" - appData={appData} - />, - ) + render(<TextGeneration appId="test-app-id" appData={appData} />) await waitFor(() => { expect(screen.getByTestId('complete-button')).toBeInTheDocument() @@ -295,11 +264,7 @@ describe('TextGeneration', () => { const appData = createMockAppData() const { container } = render( - <TextGeneration - appId="test-app-id" - appData={appData} - className="custom-class" - />, + <TextGeneration appId="test-app-id" appData={appData} className="custom-class" />, ) await waitFor(() => { @@ -313,12 +278,7 @@ describe('TextGeneration', () => { it('calls updateAppInfo when appData changes', async () => { const appData = createMockAppData() - render( - <TextGeneration - appId="test-app-id" - appData={appData} - />, - ) + render(<TextGeneration appId="test-app-id" appData={appData} />) await waitFor(() => { expect(mockUpdateAppInfo).toHaveBeenCalledWith(appData) @@ -328,12 +288,7 @@ describe('TextGeneration', () => { it('calls updateAppParams when tryAppParams changes', async () => { const appData = createMockAppData() - render( - <TextGeneration - appId="test-app-id" - appData={appData} - />, - ) + render(<TextGeneration appId="test-app-id" appData={appData} />) await waitFor(() => { expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams) @@ -343,12 +298,7 @@ describe('TextGeneration', () => { it('calls useGetTryAppParams with correct appId', () => { const appData = createMockAppData() - render( - <TextGeneration - appId="my-app-id" - appData={appData} - />, - ) + render(<TextGeneration appId="my-app-id" appData={appData} />) expect(mockUseGetTryAppParams).toHaveBeenCalledWith('my-app-id') }) @@ -358,12 +308,7 @@ describe('TextGeneration', () => { it('shows result panel after run starts', async () => { const appData = createMockAppData() - render( - <TextGeneration - appId="test-app-id" - appData={appData} - />, - ) + render(<TextGeneration appId="test-app-id" appData={appData} />) await waitFor(() => { expect(screen.getByTestId('run-start-button')).toBeInTheDocument() @@ -379,12 +324,7 @@ describe('TextGeneration', () => { it('handles input changes from RunOnce', async () => { const appData = createMockAppData() - render( - <TextGeneration - appId="test-app-id" - appData={appData} - />, - ) + render(<TextGeneration appId="test-app-id" appData={appData} />) await waitFor(() => { expect(screen.getByTestId('inputs-change-button')).toBeInTheDocument() @@ -401,12 +341,7 @@ describe('TextGeneration', () => { mockMediaType = 'mobile' const appData = createMockAppData() - const { container } = render( - <TextGeneration - appId="test-app-id" - appData={appData} - />, - ) + const { container } = render(<TextGeneration appId="test-app-id" appData={appData} />) await waitFor(() => { const togglePanel = container.querySelector('.cursor-grab') @@ -418,12 +353,7 @@ describe('TextGeneration', () => { mockMediaType = 'mobile' const appData = createMockAppData() - const { container } = render( - <TextGeneration - appId="test-app-id" - appData={appData} - />, - ) + const { container } = render(<TextGeneration appId="test-app-id" appData={appData} />) await waitFor(() => { const togglePanel = container.querySelector('.cursor-grab') diff --git a/web/app/components/explore/try-app/app/chat.tsx b/web/app/components/explore/try-app/app/chat.tsx index 9d0f430e7d7fc4..c45d9b131fd3b9 100644 --- a/web/app/components/explore/try-app/app/chat.tsx +++ b/web/app/components/explore/try-app/app/chat.tsx @@ -1,8 +1,6 @@ 'use client' import type { FC } from 'react' -import type { - EmbeddedChatbotContextValue, -} from '@/app/components/base/chat/embedded-chatbot/context' +import type { EmbeddedChatbotContextValue } from '@/app/components/base/chat/embedded-chatbot/context' import type { TryAppInfo } from '@/service/try-app' import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' @@ -15,12 +13,8 @@ import ActionButton from '@/app/components/base/action-button' import Alert from '@/app/components/base/alert' import AppIcon from '@/app/components/base/app-icon' import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper' -import { - EmbeddedChatbotContext, -} from '@/app/components/base/chat/embedded-chatbot/context' -import { - useEmbeddedChatbot, -} from '@/app/components/base/chat/embedded-chatbot/hooks' +import { EmbeddedChatbotContext } from '@/app/components/base/chat/embedded-chatbot/context' +import { useEmbeddedChatbot } from '@/app/components/base/chat/embedded-chatbot/hooks' import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { AppSourceType } from '@/service/share' @@ -32,11 +26,7 @@ type Props = Readonly<{ className: string }> -const TryApp: FC<Props> = ({ - appId, - appDetail, - className, -}) => { +const TryApp: FC<Props> = ({ appId, appDetail, className }) => { const { t } = useTranslation() const media = useBreakpoints() const isMobile = media === MediaType.mobile @@ -45,24 +35,24 @@ const TryApp: FC<Props> = ({ const currentConversationId = chatData.currentConversationId const inputsForms = chatData.inputsForms useEffect(() => { - if (appId) - removeConversationIdInfo(appId) + if (appId) removeConversationIdInfo(appId) }, [appId]) - const [isHideTryNotice, { - setTrue: hideTryNotice, - }] = useBoolean(false) + const [isHideTryNotice, { setTrue: hideTryNotice }] = useBoolean(false) const handleNewConversation = () => { removeConversationIdInfo(appId) chatData.handleNewConversation() } return ( - <EmbeddedChatbotContext.Provider value={{ - ...chatData, - disableFeedback: true, - isMobile, - themeBuilder, - } as EmbeddedChatbotContextValue} + <EmbeddedChatbotContext.Provider + value={ + { + ...chatData, + disableFeedback: true, + isMobile, + themeBuilder, + } as EmbeddedChatbotContextValue + } > <div className={cn('flex h-full flex-col rounded-2xl bg-background-section-burn', className)}> <div className="flex shrink-0 justify-between p-3"> @@ -74,35 +64,40 @@ const TryApp: FC<Props> = ({ background={appDetail.site.icon_background ?? undefined} imageUrl={appDetail.site.icon_url ?? undefined} /> - <div className="grow truncate system-md-semibold text-text-primary" title={appDetail.name}>{appDetail.name}</div> + <div + className="grow truncate system-md-semibold text-text-primary" + title={appDetail.name} + > + {appDetail.name} + </div> </div> <div className="flex items-center gap-1"> {currentConversationId && ( <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton size="l" - aria-label={t($ => $['chat.resetChat'], { ns: 'share' })} + aria-label={t(($) => $['chat.resetChat'], { ns: 'share' })} onClick={handleNewConversation} > <RiResetLeftLine className="h-[18px] w-[18px]" aria-hidden="true" /> </ActionButton> - )} + } /> - <TooltipContent> - {t($ => $['chat.resetChat'], { ns: 'share' })} - </TooltipContent> + <TooltipContent>{t(($) => $['chat.resetChat'], { ns: 'share' })}</TooltipContent> </Tooltip> )} - {currentConversationId && inputsForms.length > 0 && ( - <ViewFormDropdown /> - )} + {currentConversationId && inputsForms.length > 0 && <ViewFormDropdown />} </div> </div> <div className="mx-auto mt-4 flex h-0 w-[769px] grow flex-col"> {!isHideTryNotice && ( - <Alert className="mb-4 shrink-0" message={t($ => $['tryApp.tryInfo'], { ns: 'explore' })} onHide={hideTryNotice} /> + <Alert + className="mb-4 shrink-0" + message={t(($) => $['tryApp.tryInfo'], { ns: 'explore' })} + onHide={hideTryNotice} + /> )} <ChatWrapper /> </div> diff --git a/web/app/components/explore/try-app/app/index.tsx b/web/app/components/explore/try-app/app/index.tsx index ecb894b6db72da..76741603a47d23 100644 --- a/web/app/components/explore/try-app/app/index.tsx +++ b/web/app/components/explore/try-app/app/index.tsx @@ -12,10 +12,7 @@ type Props = Readonly<{ appDetail: TryAppInfo }> -const TryApp: FC<Props> = ({ - appId, - appDetail, -}) => { +const TryApp: FC<Props> = ({ appId, appDetail }) => { const mode = appDetail?.mode const isChat = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!) const isCompletion = !isChat @@ -23,19 +20,19 @@ const TryApp: FC<Props> = ({ useDocumentTitle(appDetail?.site?.title || '') return ( <div className="flex size-full"> - {isChat && ( - <Chat appId={appId} appDetail={appDetail} className="h-full grow" /> - )} + {isChat && <Chat appId={appId} appDetail={appDetail} className="h-full grow" />} {isCompletion && ( <TextGeneration appId={appId} className="h-full grow" isWorkflow={mode === 'workflow'} - appData={{ - app_id: appId, - custom_config: {}, - ...appDetail, - } as AppData} + appData={ + { + app_id: appId, + custom_config: {}, + ...appDetail, + } as AppData + } /> )} </div> diff --git a/web/app/components/explore/try-app/app/text-generation.tsx b/web/app/components/explore/try-app/app/text-generation.tsx index ed70590d34aff9..6adba1045a510c 100644 --- a/web/app/components/explore/try-app/app/text-generation.tsx +++ b/web/app/components/explore/try-app/app/text-generation.tsx @@ -32,12 +32,7 @@ type Props = Readonly<{ appData: AppData | null }> -const TextGeneration: FC<Props> = ({ - appId, - className, - isWorkflow, - appData, -}) => { +const TextGeneration: FC<Props> = ({ appId, className, isWorkflow, appData }) => { const { t } = useTranslation() const [descExpanded, setDescExpanded] = useState(false) const [showDescToggle, setShowDescToggle] = useState(false) @@ -54,14 +49,16 @@ const TextGeneration: FC<Props> = ({ inputsRef.current = newInputs }, []) - const updateAppInfo = useWebAppStore(s => s.updateAppInfo) + const updateAppInfo = useWebAppStore((s) => s.updateAppInfo) const { data: tryAppParams } = useGetTryAppParams(appId) - const updateAppParams = useWebAppStore(s => s.updateAppParams) - const appParams = useWebAppStore(s => s.appParams) + const updateAppParams = useWebAppStore((s) => s.updateAppParams) + const appParams = useWebAppStore((s) => s.appParams) const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null) const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null) - const [customConfig, setCustomConfig] = useState<Record<string, CustomConfigValueType> | null>(null) + const [customConfig, setCustomConfig] = useState<Record<string, CustomConfigValueType> | null>( + null, + ) const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null) const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null) const [controlSend, setControlSend] = useState(0) @@ -72,7 +69,8 @@ const TextGeneration: FC<Props> = ({ transfer_methods: [TransferMethod.local_file], }) const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([]) - const [isShowResultPanel, { setTrue: doShowResultPanel, setFalse: hideResultPanel }] = useBoolean(false) + const [isShowResultPanel, { setTrue: doShowResultPanel, setFalse: hideResultPanel }] = + useBoolean(false) const showResultPanel = () => { // fix: useClickAway hideResSidebar will close sidebar setTimeout(() => { @@ -88,21 +86,18 @@ const TextGeneration: FC<Props> = ({ const [resultExisted, setResultExisted] = useState(false) useEffect(() => { - if (!appData) - return + if (!appData) return updateAppInfo(appData) }, [appData, updateAppInfo]) useEffect(() => { - if (!tryAppParams) - return + if (!tryAppParams) return updateAppParams(tryAppParams) }, [tryAppParams, updateAppParams]) useEffect(() => { - (async () => { - if (!appData || !appParams) - return + ;(async () => { + if (!appData || !appParams) return const { site: siteInfo, custom_config } = appData setSiteInfo(siteInfo as SiteInfo) setCustomConfig(custom_config) @@ -111,7 +106,8 @@ const TextGeneration: FC<Props> = ({ setVisionConfig({ // legacy of image upload compatible ...file_upload, - transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods, + transfer_methods: + file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods, // legacy of image upload compatible image_file_size_limit: appParams?.system_parameters.image_file_size_limit, fileUploadConfig: appParams?.system_parameters, @@ -131,9 +127,7 @@ const TextGeneration: FC<Props> = ({ const handleCompleted = useCallback(() => { setIsCompleted(true) }, []) - const [isHideTryNotice, { - setTrue: hideTryNotice, - }] = useBoolean(false) + const [isHideTryNotice, { setTrue: hideTryNotice }] = useBoolean(false) const renderRes = (task?: Task) => ( <Res @@ -162,18 +156,14 @@ const TextGeneration: FC<Props> = ({ ) const renderResWrap = ( - <div - className={cn( - 'relative flex h-full flex-col', - 'rounded-r-2xl bg-chatbot-bg', - )} - > - <div className={cn( - 'flex h-0 grow flex-col overflow-y-auto p-6', - )} - > + <div className={cn('relative flex h-full flex-col', 'rounded-r-2xl bg-chatbot-bg')}> + <div className={cn('flex h-0 grow flex-col overflow-y-auto p-6')}> {isCompleted && !isHideTryNotice && ( - <Alert className="mb-3 shrink-0" message={t($ => $['tryApp.tryInfo'], { ns: 'explore' })} onHide={hideTryNotice} /> + <Alert + className="mb-3 shrink-0" + message={t(($) => $['tryApp.tryInfo'], { ns: 'explore' })} + onHide={hideTryNotice} + /> )} {renderRes()} </div> @@ -189,20 +179,22 @@ const TextGeneration: FC<Props> = ({ } return ( - <div className={cn( - 'rounded-2xl border border-components-panel-border bg-background-section-burn', - isPC && 'flex', - !isPC && 'flex-col', - 'h-full rounded-2xl shadow-md', - className, - )} + <div + className={cn( + 'rounded-2xl border border-components-panel-border bg-background-section-burn', + isPC && 'flex', + !isPC && 'flex-col', + 'h-full rounded-2xl shadow-md', + className, + )} > {/* Left */} - <div className={cn( - 'relative flex h-full shrink-0 flex-col', - isPC && 'w-[600px] max-w-[50%]', - 'rounded-l-2xl bg-components-panel-bg', - )} + <div + className={cn( + 'relative flex h-full shrink-0 flex-col', + isPC && 'w-[600px] max-w-[50%]', + 'rounded-l-2xl bg-components-panel-bg', + )} > {/* Header */} <div className={cn('shrink-0 space-y-4 pb-2', isPC ? 'p-8 pb-0' : 'p-4 pb-0')}> @@ -214,7 +206,9 @@ const TextGeneration: FC<Props> = ({ background={siteInfo.icon_background || appDefaultIconBackground} imageUrl={siteInfo.icon_url} /> - <div className="grow truncate system-md-semibold text-text-secondary">{siteInfo.title}</div> + <div className="grow truncate system-md-semibold text-text-secondary"> + {siteInfo.title} + </div> </div> {siteInfo.description && ( <div> @@ -235,32 +229,34 @@ const TextGeneration: FC<Props> = ({ <button type="button" className="mt-0.5 flex items-center gap-0.5 system-xs-regular text-text-accent hover:opacity-80" - onClick={() => setDescExpanded(v => !v)} + onClick={() => setDescExpanded((v) => !v)} > - {descExpanded - ? ( - <> - <RiArrowUpSLine className="size-3" /> - {t($ => $['chat.collapse'], { ns: 'share' })} - </> - ) - : ( - <> - <RiArrowDownSLine className="size-3" /> - {t($ => $['chat.expand'], { ns: 'share' })} - </> - )} + {descExpanded ? ( + <> + <RiArrowUpSLine className="size-3" /> + {t(($) => $['chat.collapse'], { ns: 'share' })} + </> + ) : ( + <> + <RiArrowDownSLine className="size-3" /> + {t(($) => $['chat.expand'], { ns: 'share' })} + </> + )} </button> )} </div> )} </div> {/* form */} - <div className={cn( - 'h-0 grow overflow-y-auto', - isPC ? 'px-8' : 'px-4', - !isPC && resultExisted && customConfig?.remove_webapp_brand && 'rounded-b-2xl border-b-[0.5px] border-divider-regular', - )} + <div + className={cn( + 'h-0 grow overflow-y-auto', + isPC ? 'px-8' : 'px-4', + !isPC && + resultExisted && + customConfig?.remove_webapp_brand && + 'rounded-b-2xl border-b-[0.5px] border-divider-regular', + )} > <RunOnce siteInfo={siteInfo} @@ -285,10 +281,8 @@ const TextGeneration: FC<Props> = ({ : 'absolute top-0 left-0 z-10 flex w-full items-center justify-center px-2 pt-[3px] pb-[57px]', )} onClick={() => { - if (isShowResultPanel) - hideResultPanel() - else - showResultPanel() + if (isShowResultPanel) hideResultPanel() + else showResultPanel() }} > <div className="h-1 w-8 cursor-grab rounded-sm bg-divider-solid" /> diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx index 74cb8235c4b5c1..29ee7f8f2cabf1 100644 --- a/web/app/components/explore/try-app/index.tsx +++ b/web/app/components/explore/try-app/index.tsx @@ -26,13 +26,7 @@ type Props = Readonly<{ onCreate: () => void }> -const TryApp: FC<Props> = ({ - appId, - app, - categories, - onClose, - onCreate, -}) => { +const TryApp: FC<Props> = ({ appId, app, categories, onClose, onCreate }) => { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app) @@ -45,19 +39,21 @@ const TryApp: FC<Props> = ({ <Dialog open onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DialogContent className="h-[calc(100dvh-32px)] max-h-[calc(100dvh-32px)] w-full max-w-[calc(100vw-32px)] min-w-[1280px] overflow-hidden overflow-x-auto border-none p-2 text-left align-middle"> - {isLoading ? ( <div className="flex h-full items-center justify-center"> <Loading type="area" /> </div> ) : isError ? ( <div className="flex h-full items-center justify-center"> - <AppUnavailable className="size-auto" isUnknownReason={!error} unknownReason={error instanceof Error ? error.message : undefined} /> + <AppUnavailable + className="size-auto" + isUnknownReason={!error} + unknownReason={error instanceof Error ? error.message : undefined} + /> </div> ) : !appDetail ? ( <div className="flex h-full items-center justify-center"> @@ -66,7 +62,7 @@ const TryApp: FC<Props> = ({ ) : ( <Tabs value={activeType} - onValueChange={selectedValue => setType(selectedValue)} + onValueChange={(selectedValue) => setType(selectedValue)} className="flex h-full flex-col" > <div className="flex shrink-0 justify-between pl-4"> @@ -77,20 +73,24 @@ const TryApp: FC<Props> = ({ disabled={app ? !isTrialApp : false} className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500" > - <span className="system-md-semibold-uppercase">{t($ => $['tryApp.tabHeader.try'], { ns: 'explore' })}</span> + <span className="system-md-semibold-uppercase"> + {t(($) => $['tryApp.tabHeader.try'], { ns: 'explore' })} + </span> </TabsTab> )} <TabsTab value={TypeEnum.DETAIL} className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500" > - <span className="system-md-semibold-uppercase">{t($ => $['tryApp.tabHeader.detail'], { ns: 'explore' })}</span> + <span className="system-md-semibold-uppercase"> + {t(($) => $['tryApp.tabHeader.detail'], { ns: 'explore' })} + </span> </TabsTab> </TabsList> <Button size="large" variant="tertiary" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="flex size-7 items-center justify-center rounded-[10px] p-0 text-components-button-tertiary-text" onClick={onClose} > diff --git a/web/app/components/explore/try-app/preview/__tests__/basic-app-preview.spec.tsx b/web/app/components/explore/try-app/preview/__tests__/basic-app-preview.spec.tsx index 2ef74301d0e984..03dccac2e8b579 100644 --- a/web/app/components/explore/try-app/preview/__tests__/basic-app-preview.spec.tsx +++ b/web/app/components/explore/try-app/preview/__tests__/basic-app-preview.spec.tsx @@ -34,9 +34,15 @@ vi.mock('@/app/components/app/configuration/config', async () => { function MockConfig() { const { modelConfig } = useDebugConfigurationContext() - const hasDeletedTool = modelConfig.agentConfig.tools.some(tool => 'tool_name' in tool && tool.isDeleted === true) - - return <div data-testid="config-component" data-has-deleted-tool={String(hasDeletedTool)}>Config</div> + const hasDeletedTool = modelConfig.agentConfig.tools.some( + (tool) => 'tool_name' in tool && tool.isDeleted === true, + ) + + return ( + <div data-testid="config-component" data-has-deleted-tool={String(hasDeletedTool)}> + Config + </div> + ) } return { @@ -360,10 +366,7 @@ describe('BasicAppPreview', () => { const modelConfig = appWithDatasets.model_config as Record<string, unknown> modelConfig.dataset_configs = { datasets: { - datasets: [ - { dataset: { id: 'dataset-1' } }, - { dataset: { id: 'dataset-2' } }, - ], + datasets: [{ dataset: { id: 'dataset-1' } }, { dataset: { id: 'dataset-2' } }], }, } @@ -507,7 +510,10 @@ describe('BasicAppPreview', () => { render(<BasicAppPreview appId="test-app-id" />) await waitFor(() => { - expect(screen.getByTestId('config-component')).toHaveAttribute('data-has-deleted-tool', 'true') + expect(screen.getByTestId('config-component')).toHaveAttribute( + 'data-has-deleted-tool', + 'true', + ) }) }) }) diff --git a/web/app/components/explore/try-app/preview/__tests__/flow-app-preview.spec.tsx b/web/app/components/explore/try-app/preview/__tests__/flow-app-preview.spec.tsx index 9332042d03e746..5af22c19686409 100644 --- a/web/app/components/explore/try-app/preview/__tests__/flow-app-preview.spec.tsx +++ b/web/app/components/explore/try-app/preview/__tests__/flow-app-preview.spec.tsx @@ -15,7 +15,13 @@ vi.mock('@/app/components/workflow/workflow-preview', () => ({ nodes, edges, viewport, - }: { className?: string, miniMapToRight?: boolean, nodes?: unknown[], edges?: unknown[], viewport?: unknown }) => ( + }: { + className?: string + miniMapToRight?: boolean + nodes?: unknown[] + edges?: unknown[] + viewport?: unknown + }) => ( <div data-testid="workflow-preview" className={className} diff --git a/web/app/components/explore/try-app/preview/__tests__/index.spec.tsx b/web/app/components/explore/try-app/preview/__tests__/index.spec.tsx index f1f66a962f78c0..ce40dba65a0f84 100644 --- a/web/app/components/explore/try-app/preview/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/preview/__tests__/index.spec.tsx @@ -12,42 +12,43 @@ vi.mock('../basic-app-preview', () => ({ })) vi.mock('../flow-app-preview', () => ({ - default: ({ appId, className }: { appId: string, className?: string }) => ( + default: ({ appId, className }: { appId: string; className?: string }) => ( <div data-testid="flow-app-preview" data-app-id={appId} className={className}> FlowAppPreview </div> ), })) -const createMockAppDetail = (mode: string): TryAppInfo => ({ - id: 'test-app-id', - name: 'Test App', - description: 'Test Description', - mode, - site: { - title: 'Test Site Title', - icon: 'icon', - icon_type: 'emoji', - icon_background: '#FFFFFF', - icon_url: '', - }, - model_config: { - model: { - provider: 'test/provider', - name: 'test-model', - mode: 'chat', +const createMockAppDetail = (mode: string): TryAppInfo => + ({ + id: 'test-app-id', + name: 'Test App', + description: 'Test Description', + mode, + site: { + title: 'Test Site Title', + icon: 'icon', + icon_type: 'emoji', + icon_background: '#FFFFFF', + icon_url: '', }, - dataset_configs: { - datasets: { - datasets: [], + model_config: { + model: { + provider: 'test/provider', + name: 'test-model', + mode: 'chat', }, + dataset_configs: { + datasets: { + datasets: [], + }, + }, + agent_mode: { + tools: [], + }, + user_input_form: [], }, - agent_mode: { - tools: [], - }, - user_input_form: [], - }, -} as unknown as TryAppInfo) + }) as unknown as TryAppInfo describe('Preview', () => { afterEach(() => { diff --git a/web/app/components/explore/try-app/preview/basic-app-preview.tsx b/web/app/components/explore/try-app/preview/basic-app-preview.tsx index bea8e348ee42bd..395be784b722d3 100644 --- a/web/app/components/explore/try-app/preview/basic-app-preview.tsx +++ b/web/app/components/explore/try-app/preview/basic-app-preview.tsx @@ -18,7 +18,12 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { CollectionType } from '@/app/components/tools/types' import { SupportUploadFileTypes } from '@/app/components/workflow/types' -import { ANNOTATION_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' +import { + ANNOTATION_DEFAULT, + DEFAULT_AGENT_SETTING, + DEFAULT_CHAT_PROMPT_CONFIG, + DEFAULT_COMPLETION_PROMPT_CONFIG, +} from '@/config' import ConfigContext from '@/context/debug-configuration' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { PromptMode } from '@/models/debug' @@ -88,15 +93,18 @@ const getOptionalString = (value: unknown) => { } const getStringArray = (value: unknown) => { - return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : undefined + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : undefined } const getTransferMethods = (value: unknown) => { - if (!Array.isArray(value)) - return undefined + if (!Array.isArray(value)) return undefined const transferMethods = new Set<string>(Object.values(TransferMethod)) - return value.filter((item): item is TransferMethod => typeof item === 'string' && transferMethods.has(item)) + return value.filter( + (item): item is TransferMethod => typeof item === 'string' && transferMethods.has(item), + ) } const getResolution = (value: unknown) => { @@ -104,8 +112,7 @@ const getResolution = (value: unknown) => { } const normalizeEnabledConfig = (value: unknown): { enabled: boolean } | null => { - if (!isRecord(value)) - return null + if (!isRecord(value)) return null return { ...value, @@ -114,13 +121,13 @@ const normalizeEnabledConfig = (value: unknown): { enabled: boolean } | null => } const normalizeTextToSpeech = (value: unknown): ModelConfig['text_to_speech'] => { - if (!isRecord(value)) - return null + if (!isRecord(value)) return null const autoPlayValue = getString(value.autoPlay) - const autoPlay = autoPlayValue === TtsAutoPlay.enabled || autoPlayValue === TtsAutoPlay.disabled - ? autoPlayValue - : undefined + const autoPlay = + autoPlayValue === TtsAutoPlay.enabled || autoPlayValue === TtsAutoPlay.disabled + ? autoPlayValue + : undefined return { ...value, @@ -132,8 +139,7 @@ const normalizeTextToSpeech = (value: unknown): ModelConfig['text_to_speech'] => } const normalizeAnnotationReply = (value: unknown): ModelConfig['annotation_reply'] => { - if (!isRecord(value)) - return null + if (!isRecord(value)) return null const embeddingModel = isRecord(value.embedding_model) ? value.embedding_model : {} return { @@ -149,8 +155,7 @@ const normalizeAnnotationReply = (value: unknown): ModelConfig['annotation_reply } const normalizeModeration = (value: unknown): ModelConfig['sensitive_word_avoidance'] => { - if (!isRecord(value)) - return null + if (!isRecord(value)) return null return { ...value, @@ -160,9 +165,10 @@ const normalizeModeration = (value: unknown): ModelConfig['sensitive_word_avoida } as ModelConfig['sensitive_word_avoidance'] } -const normalizeSuggestedQuestionsAfterAnswer = (value: unknown): ModelConfig['suggested_questions_after_answer'] => { - if (!isRecord(value)) - return null +const normalizeSuggestedQuestionsAfterAnswer = ( + value: unknown, +): ModelConfig['suggested_questions_after_answer'] => { + if (!isRecord(value)) return null return { ...value, @@ -172,8 +178,7 @@ const normalizeSuggestedQuestionsAfterAnswer = (value: unknown): ModelConfig['su } const normalizeFileUploadSection = (value: unknown, includeDetail = false) => { - if (!isRecord(value)) - return undefined + if (!isRecord(value)) return undefined return { ...value, @@ -185,8 +190,7 @@ const normalizeFileUploadSection = (value: unknown, includeDetail = false) => { } const normalizeFileUpload = (value: unknown): ModelConfig['file_upload'] => { - if (!isRecord(value)) - return null + if (!isRecord(value)) return null return { ...value, @@ -203,38 +207,48 @@ const normalizeFileUpload = (value: unknown): ModelConfig['file_upload'] => { } as ModelConfig['file_upload'] } -const normalizeExternalDataTools = (items?: Record<string, unknown>[]): NonNullable<ModelConfig['external_data_tools']> => { - return items?.map(item => ({ - ...item, - type: getOptionalString(item.type), - label: getOptionalString(item.label), - icon: getOptionalString(item.icon), - icon_background: getOptionalString(item.icon_background), - variable: getOptionalString(item.variable), - enabled: getBoolean(item.enabled), - config: isRecord(item.config) - ? { - ...item.config, - api_based_extension_id: getOptionalString(item.config.api_based_extension_id), - } - : undefined, - } as NonNullable<ModelConfig['external_data_tools']>[number])) ?? [] +const normalizeExternalDataTools = ( + items?: Record<string, unknown>[], +): NonNullable<ModelConfig['external_data_tools']> => { + return ( + items?.map( + (item) => + ({ + ...item, + type: getOptionalString(item.type), + label: getOptionalString(item.label), + icon: getOptionalString(item.icon), + icon_background: getOptionalString(item.icon_background), + variable: getOptionalString(item.variable), + enabled: getBoolean(item.enabled), + config: isRecord(item.config) + ? { + ...item.config, + api_based_extension_id: getOptionalString(item.config.api_based_extension_id), + } + : undefined, + }) as NonNullable<ModelConfig['external_data_tools']>[number], + ) ?? [] + ) } const normalizeCollectionType = (value: unknown): AgentToolItem['provider_type'] => { const type = getString(value) const collectionTypes = new Set<string>(Object.values(CollectionType)) - return collectionTypes.has(type) ? type as AgentToolItem['provider_type'] : CollectionType.builtIn + return collectionTypes.has(type) + ? (type as AgentToolItem['provider_type']) + : CollectionType.builtIn } const normalizeAgentStrategy = (value: unknown): AgentStrategy => { const strategy = getString(value) - return strategy === AgentStrategy.react || strategy === AgentStrategy.functionCall ? strategy : DEFAULT_AGENT_SETTING.strategy + return strategy === AgentStrategy.react || strategy === AgentStrategy.functionCall + ? strategy + : DEFAULT_AGENT_SETTING.strategy } const getAgentTools = (agentMode: unknown) => { - if (!isRecord(agentMode) || !Array.isArray(agentMode.tools)) - return [] + if (!isRecord(agentMode) || !Array.isArray(agentMode.tools)) return [] return agentMode.tools.filter(isRecord) } @@ -244,18 +258,20 @@ const isEnabledDatasetTool = (tool: Record<string, unknown>) => { } const getDatasetConfigItems = (datasetConfigs: unknown) => { - if (!isRecord(datasetConfigs) || !isRecord(datasetConfigs.datasets) || !Array.isArray(datasetConfigs.datasets.datasets)) + if ( + !isRecord(datasetConfigs) || + !isRecord(datasetConfigs.datasets) || + !Array.isArray(datasetConfigs.datasets.datasets) + ) return [] return datasetConfigs.datasets.datasets.filter(isRecord) } const getDatasetId = (value: Record<string, unknown>) => { - if (typeof value.id === 'string') - return value.id + if (typeof value.id === 'string') return value.id - if (isRecord(value.dataset) && typeof value.dataset.id === 'string') - return value.dataset.id + if (isRecord(value.dataset) && typeof value.dataset.id === 'string') return value.dataset.id return null } @@ -284,47 +300,51 @@ const normalizeAgentTool = ( const providerName = getString(tool.provider_name) const providerType = normalizeCollectionType(tool.provider_type) const toolName = getString(tool.tool_name) - const toolInCollectionList = collectionList?.find(c => providerId === c.id) + const toolInCollectionList = collectionList?.find((c) => providerId === c.id) return { ...tool, - provider_id: providerType === CollectionType.builtIn - ? correctToolProvider(providerName, !!toolInCollectionList) - : providerId, - provider_name: providerType === CollectionType.builtIn - ? correctToolProvider(providerName, !!toolInCollectionList) - : providerName, + provider_id: + providerType === CollectionType.builtIn + ? correctToolProvider(providerName, !!toolInCollectionList) + : providerId, + provider_name: + providerType === CollectionType.builtIn + ? correctToolProvider(providerName, !!toolInCollectionList) + : providerName, provider_type: providerType, tool_name: toolName, tool_label: getString(tool.tool_label) || toolName, tool_parameters: isRecord(tool.tool_parameters) ? tool.tool_parameters : {}, enabled: getBoolean(tool.enabled), - isDeleted: deletedTools?.some(deletedTool => deletedTool.provider_id === providerId && deletedTool.tool_name === toolName), + isDeleted: deletedTools?.some( + (deletedTool) => deletedTool.provider_id === providerId && deletedTool.tool_name === toolName, + ), notAuthor: toolInCollectionList?.is_team_authorization === false, credential_id: getOptionalString(tool.credential_id), } as AgentToolItem } -const BasicAppPreview: FC<Props> = ({ - appId, -}) => { +const BasicAppPreview: FC<Props> = ({ appId }) => { const media = useBreakpoints() const isMobile = media === MediaType.mobile const { data: appDetail, isLoading: isLoadingAppDetail } = useGetTryAppInfo(appId) - const { data: collectionListFromServer, isLoading: isLoadingToolProviders } = useAllToolProviders() + const { data: collectionListFromServer, isLoading: isLoadingToolProviders } = + useAllToolProviders() const collectionList = collectionListFromServer?.map((item) => { return { ...item, - icon: basePath && typeof item.icon == 'string' && !item.icon.includes(basePath) ? `${basePath}${item.icon}` : item.icon, + icon: + basePath && typeof item.icon == 'string' && !item.icon.includes(basePath) + ? `${basePath}${item.icon}` + : item.icon, } }) const datasetIds = (() => { - if (isLoadingAppDetail) - return [] + if (isLoadingAppDetail) return [] const modelConfig = appDetail?.model_config - if (!modelConfig) - return [] + if (!modelConfig) return [] const agentDatasetTools = getAgentTools(modelConfig.agent_mode).filter(isEnabledDatasetTool) const datasetConfigItems = getDatasetConfigItems(modelConfig.dataset_configs) @@ -335,16 +355,21 @@ const BasicAppPreview: FC<Props> = ({ return [] })() - const { data: dataSetData, isLoading: isLoadingDatasets } = useGetTryAppDataSets(appId, datasetIds) + const { data: dataSetData, isLoading: isLoadingDatasets } = useGetTryAppDataSets( + appId, + datasetIds, + ) const dataSets = dataSetData?.data || [] const isLoading = isLoadingAppDetail || isLoadingDatasets || isLoadingToolProviders const modelConfig: ModelConfig = ((modelConfig?: TryAppInfo['model_config']) => { - if (isLoading || !modelConfig?.model) - return defaultModelConfig + if (isLoading || !modelConfig?.model) return defaultModelConfig const model = modelConfig.model - const mode = model.mode === ModelModeType.chat || model.mode === ModelModeType.completion ? model.mode : ModelModeType.unset + const mode = + model.mode === ModelModeType.chat || model.mode === ModelModeType.completion + ? model.mode + : ModelModeType.unset const agentMode = isRecord(modelConfig.agent_mode) ? modelConfig.agent_mode : {} const newModelConfig: ModelConfig = { @@ -356,11 +381,9 @@ const BasicAppPreview: FC<Props> = ({ prompt_variables: userInputsFormToPromptVariables( [ ...(modelConfig.user_input_form || []), - ...( - modelConfig.external_data_tools?.length - ? modelConfig.external_data_tools.map(normalizeExternalDataToolFormItem) - : [] - ), + ...(modelConfig.external_data_tools?.length + ? modelConfig.external_data_tools.map(normalizeExternalDataToolFormItem) + : []), ], modelConfig.dataset_query_variable ?? undefined, ), @@ -372,22 +395,28 @@ const BasicAppPreview: FC<Props> = ({ speech_to_text: normalizeEnabledConfig(modelConfig.speech_to_text), text_to_speech: normalizeTextToSpeech(modelConfig.text_to_speech), file_upload: normalizeFileUpload(modelConfig.file_upload), - suggested_questions_after_answer: normalizeSuggestedQuestionsAfterAnswer(modelConfig.suggested_questions_after_answer), + suggested_questions_after_answer: normalizeSuggestedQuestionsAfterAnswer( + modelConfig.suggested_questions_after_answer, + ), retriever_resource: normalizeEnabledConfig(modelConfig.retriever_resource), annotation_reply: normalizeAnnotationReply(modelConfig.annotation_reply), external_data_tools: normalizeExternalDataTools(modelConfig.external_data_tools), system_parameters: DEFAULT_SYSTEM_PARAMETERS, dataSets, - agentConfig: appDetail?.mode === 'agent-chat' - ? ({ - max_iteration: DEFAULT_AGENT_SETTING.max_iteration, - // remove dataset - enabled: true, // modelConfig.agent_mode?.enabled is not correct. old app: the value of app with dataset's is always true - strategy: normalizeAgentStrategy(agentMode.strategy), - tools: getAgentTools(modelConfig.agent_mode).filter((tool) => { - return !tool.dataset - }).map(tool => normalizeAgentTool(tool, appDetail?.deleted_tools, collectionList)), - }) : DEFAULT_AGENT_SETTING, + agentConfig: + appDetail?.mode === 'agent-chat' + ? { + max_iteration: DEFAULT_AGENT_SETTING.max_iteration, + // remove dataset + enabled: true, // modelConfig.agent_mode?.enabled is not correct. old app: the value of app with dataset's is always true + strategy: normalizeAgentStrategy(agentMode.strategy), + tools: getAgentTools(modelConfig.agent_mode) + .filter((tool) => { + return !tool.dataset + }) + .map((tool) => normalizeAgentTool(tool, appDetail?.deleted_tools, collectionList)), + } + : DEFAULT_AGENT_SETTING, } return newModelConfig })(appDetail?.model_config) @@ -395,15 +424,24 @@ const BasicAppPreview: FC<Props> = ({ // const isChatApp = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!) // chat configuration - const promptMode = modelConfig?.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple + const promptMode = + modelConfig?.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple const isAdvancedMode = promptMode === PromptMode.advanced const isAgent = mode === 'agent-chat' - const chatPromptConfig = isAdvancedMode ? (modelConfig?.chat_prompt_config || clone(DEFAULT_CHAT_PROMPT_CONFIG)) : undefined + const chatPromptConfig = isAdvancedMode + ? modelConfig?.chat_prompt_config || clone(DEFAULT_CHAT_PROMPT_CONFIG) + : undefined const suggestedQuestions = modelConfig?.suggested_questions || [] const moreLikeThisConfig = modelConfig?.more_like_this || { enabled: false } - const suggestedQuestionsAfterAnswerConfig = modelConfig?.suggested_questions_after_answer || { enabled: false } + const suggestedQuestionsAfterAnswerConfig = modelConfig?.suggested_questions_after_answer || { + enabled: false, + } const speechToTextConfig = modelConfig?.speech_to_text || { enabled: false } - const textToSpeechConfig = modelConfig?.text_to_speech || { enabled: false, voice: '', language: '' } + const textToSpeechConfig = modelConfig?.text_to_speech || { + enabled: false, + voice: '', + language: '', + } const citationConfig = modelConfig?.retriever_resource || { enabled: false } const annotationConfig = modelConfig?.annotation_reply || { id: '', @@ -416,21 +454,18 @@ const BasicAppPreview: FC<Props> = ({ } const moderationConfig = modelConfig?.sensitive_word_avoidance || { enabled: false } // completion configuration - const completionPromptConfig = modelConfig?.completion_prompt_config || clone(DEFAULT_COMPLETION_PROMPT_CONFIG) as any + const completionPromptConfig = + modelConfig?.completion_prompt_config || (clone(DEFAULT_COMPLETION_PROMPT_CONFIG) as any) // prompt & model config const inputs = {} const query = '' const completionParams = useState<FormValue>({}) - const { - currentModel: currModel, - } = useTextGenerationCurrentProviderAndModelAndModelList( - { - provider: modelConfig.provider, - model: modelConfig.model_id, - }, - ) + const { currentModel: currModel } = useTextGenerationCurrentProviderAndModelAndModelList({ + provider: modelConfig.provider, + model: modelConfig.model_id, + }) const isShowVisionConfig = !!currModel?.features?.includes(ModelFeatureEnum.vision) const isShowDocumentConfig = !!currModel?.features?.includes(ModelFeatureEnum.document) @@ -459,13 +494,25 @@ const BasicAppPreview: FC<Props> = ({ detail: modelConfig.file_upload?.image?.detail || Resolution.high, enabled: !!modelConfig.file_upload?.image?.enabled, number_limits: modelConfig.file_upload?.image?.number_limits || 3, - transfer_methods: modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + transfer_methods: modelConfig.file_upload?.image?.transfer_methods || [ + 'local_file', + 'remote_url', + ], }, enabled: !!(modelConfig.file_upload?.enabled || modelConfig.file_upload?.image?.enabled), allowed_file_types: modelConfig.file_upload?.allowed_file_types || [], - allowed_file_extensions: modelConfig.file_upload?.allowed_file_extensions || [...(FILE_EXTS[SupportUploadFileTypes.image] ?? []), ...(FILE_EXTS[SupportUploadFileTypes.video] ?? [])].map(ext => `.${ext}`), - allowed_file_upload_methods: modelConfig.file_upload?.allowed_file_upload_methods || modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], - number_limits: modelConfig.file_upload?.number_limits || modelConfig.file_upload?.image?.number_limits || 3, + allowed_file_extensions: + modelConfig.file_upload?.allowed_file_extensions || + [ + ...(FILE_EXTS[SupportUploadFileTypes.image] ?? []), + ...(FILE_EXTS[SupportUploadFileTypes.video] ?? []), + ].map((ext) => `.${ext}`), + allowed_file_upload_methods: modelConfig.file_upload?.allowed_file_upload_methods || + modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + number_limits: + modelConfig.file_upload?.number_limits || + modelConfig.file_upload?.image?.number_limits || + 3, fileUploadConfig: {}, } as FileUpload, suggested: modelConfig.suggested_questions_after_answer || { enabled: false }, @@ -566,7 +613,10 @@ const BasicAppPreview: FC<Props> = ({ <Config /> </div> {!isMobile && ( - <div className="relative flex h-full w-1/2 grow flex-col overflow-y-auto" style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }}> + <div + className="relative flex h-full w-1/2 grow flex-col overflow-y-auto" + style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }} + > <div className="flex grow flex-col rounded-tl-2xl border-t-[0.5px] border-l-[0.5px] border-components-panel-border bg-chatbot-bg"> <Debug isAPIKeySet diff --git a/web/app/components/explore/try-app/preview/flow-app-preview.tsx b/web/app/components/explore/try-app/preview/flow-app-preview.tsx index d761849a67d0ae..5d51b1933a3893 100644 --- a/web/app/components/explore/try-app/preview/flow-app-preview.tsx +++ b/web/app/components/explore/try-app/preview/flow-app-preview.tsx @@ -15,33 +15,33 @@ type Props = { } const blockTypeMap: Record<string, BlockEnum> = { - 'agent': BlockEnum.Agent, + agent: BlockEnum.Agent, 'agent-v2': BlockEnum.AgentV2, - 'answer': BlockEnum.Answer, - 'assigner': BlockEnum.Assigner, - 'code': BlockEnum.Code, - 'datasource': BlockEnum.DataSource, + answer: BlockEnum.Answer, + assigner: BlockEnum.Assigner, + code: BlockEnum.Code, + datasource: BlockEnum.DataSource, 'datasource-empty': BlockEnum.DataSourceEmpty, 'document-extractor': BlockEnum.DocExtractor, - 'end': BlockEnum.End, + end: BlockEnum.End, 'http-request': BlockEnum.HttpRequest, 'human-input': BlockEnum.HumanInput, 'if-else': BlockEnum.IfElse, - 'iteration': BlockEnum.Iteration, + iteration: BlockEnum.Iteration, 'iteration-start': BlockEnum.IterationStart, 'knowledge-index': BlockEnum.KnowledgeBase, 'knowledge-retrieval': BlockEnum.KnowledgeRetrieval, 'list-operator': BlockEnum.ListFilter, - 'llm': BlockEnum.LLM, - 'loop': BlockEnum.Loop, + llm: BlockEnum.LLM, + loop: BlockEnum.Loop, 'loop-end': BlockEnum.LoopEnd, 'loop-start': BlockEnum.LoopStart, 'parameter-extractor': BlockEnum.ParameterExtractor, 'question-classifier': BlockEnum.QuestionClassifier, - 'start': BlockEnum.Start, + start: BlockEnum.Start, 'start-placeholder': BlockEnum.StartPlaceholder, 'template-transform': BlockEnum.TemplateTransform, - 'tool': BlockEnum.Tool, + tool: BlockEnum.Tool, 'trigger-plugin': BlockEnum.TriggerPlugin, 'trigger-schedule': BlockEnum.TriggerSchedule, 'trigger-webhook': BlockEnum.TriggerWebhook, @@ -69,10 +69,10 @@ const getPosition = (value: unknown) => { const getViewport = (value: unknown) => { if ( - !isRecord(value) - || typeof value.x !== 'number' - || typeof value.y !== 'number' - || typeof value.zoom !== 'number' + !isRecord(value) || + typeof value.x !== 'number' || + typeof value.y !== 'number' || + typeof value.zoom !== 'number' ) { return { x: 0, y: 0, zoom: 1 } } @@ -85,8 +85,7 @@ const getViewport = (value: unknown) => { } const getBlockType = (value: unknown) => { - if (typeof value !== 'string') - return null + if (typeof value !== 'string') return null return blockTypeMap[value] || null } @@ -96,54 +95,54 @@ const normalizeWorkflowPreviewGraph = (graph: JsonObject2) => { const edgesData = Array.isArray(graph.edges) ? graph.edges : [] const nodes: Node[] = nodesData.flatMap((node) => { - if (!isRecord(node)) - return [] + if (!isRecord(node)) return [] const id = getString(node.id) - if (!id) - return [] + if (!id) return [] const data = isRecord(node.data) ? node.data : {} const type = getBlockType(data.type) || BlockEnum.Start const title = getString(data.title) || '' - return [{ - id, - position: getPosition(node.position), - ...(getString(node.type) ? { type: getString(node.type) } : {}), - data: { - ...data, - desc: getString(data.desc) || '', - title, - type, + return [ + { + id, + position: getPosition(node.position), + ...(getString(node.type) ? { type: getString(node.type) } : {}), + data: { + ...data, + desc: getString(data.desc) || '', + title, + type, + }, }, - }] + ] }) const edges: Edge[] = edgesData.flatMap((edge) => { - if (!isRecord(edge)) - return [] + if (!isRecord(edge)) return [] const id = getString(edge.id) - if (!id) - return [] + if (!id) return [] const data = isRecord(edge.data) ? edge.data : {} const sourceType = getBlockType(data.sourceType) || BlockEnum.Start const targetType = getBlockType(data.targetType) || BlockEnum.Start - return [{ - id, - source: getString(edge.source) || '', - target: getString(edge.target) || '', - ...(getString(edge.type) ? { type: getString(edge.type) } : {}), - ...(getString(edge.sourceHandle) ? { sourceHandle: getString(edge.sourceHandle) } : {}), - ...(getString(edge.targetHandle) ? { targetHandle: getString(edge.targetHandle) } : {}), - data: { - ...data, - sourceType, - targetType, + return [ + { + id, + source: getString(edge.source) || '', + target: getString(edge.target) || '', + ...(getString(edge.type) ? { type: getString(edge.type) } : {}), + ...(getString(edge.sourceHandle) ? { sourceHandle: getString(edge.sourceHandle) } : {}), + ...(getString(edge.targetHandle) ? { targetHandle: getString(edge.targetHandle) } : {}), + data: { + ...data, + sourceType, + targetType, + }, }, - }] + ] }) return { @@ -153,10 +152,7 @@ const normalizeWorkflowPreviewGraph = (graph: JsonObject2) => { } } -const FlowAppPreview: FC<Props> = ({ - appId, - className, -}) => { +const FlowAppPreview: FC<Props> = ({ appId, className }) => { const { data, isLoading } = useGetTryAppFlowPreview(appId) if (isLoading) { @@ -166,16 +162,11 @@ const FlowAppPreview: FC<Props> = ({ </div> ) } - if (!data) - return null + if (!data) return null const previewGraph = normalizeWorkflowPreviewGraph(data.graph) return ( <div className="size-full"> - <WorkflowPreview - {...previewGraph} - className={cn(className)} - miniMapToRight - /> + <WorkflowPreview {...previewGraph} className={cn(className)} miniMapToRight /> </div> ) } diff --git a/web/app/components/explore/try-app/preview/index.tsx b/web/app/components/explore/try-app/preview/index.tsx index 91a6bc42e216de..e0a8d034840aa2 100644 --- a/web/app/components/explore/try-app/preview/index.tsx +++ b/web/app/components/explore/try-app/preview/index.tsx @@ -10,15 +10,16 @@ type Props = { readonly appDetail: TryAppInfo } -const Preview: FC<Props> = ({ - appId, - appDetail, -}) => { +const Preview: FC<Props> = ({ appId, appDetail }) => { const isBasicApp = ['agent-chat', 'chat', 'completion'].includes(appDetail.mode) return ( <div className="size-full"> - {isBasicApp ? <BasicAppPreview appId={appId} /> : <FlowAppPreview appId={appId} className="h-full" />} + {isBasicApp ? ( + <BasicAppPreview appId={appId} /> + ) : ( + <FlowAppPreview appId={appId} className="h-full" /> + )} </div> ) } diff --git a/web/app/components/explore/try-app/types.ts b/web/app/components/explore/try-app/types.ts index 81a50893db5937..77ca89b4e1b8f9 100644 --- a/web/app/components/explore/try-app/types.ts +++ b/web/app/components/explore/try-app/types.ts @@ -3,4 +3,4 @@ export const TypeEnum = { DETAIL: 'detail', } as const -export type TypeEnum = typeof TypeEnum[keyof typeof TypeEnum] +export type TypeEnum = (typeof TypeEnum)[keyof typeof TypeEnum] diff --git a/web/app/components/external-attribution-recorder.tsx b/web/app/components/external-attribution-recorder.tsx index 95418dd6a1252c..838c233381b7b0 100644 --- a/web/app/components/external-attribution-recorder.tsx +++ b/web/app/components/external-attribution-recorder.tsx @@ -8,7 +8,14 @@ import { rememberCreateAppExternalAttribution } from '@/utils/create-app-trackin const UTM_INFO_COOKIE = 'utm_info' const UTM_INFO_COOKIE_EXPIRES_DAYS = 1 -const UTM_INFO_QUERY_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'slug'] as const +const UTM_INFO_QUERY_KEYS = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term', + 'slug', +] as const type SearchParamReader = { get: (name: string) => string | null @@ -27,23 +34,21 @@ const parseRedirectUrlSearchParams = (redirectUrl: string) => { try { const url = new URL(redirectUrl, baseUrl) - if (url.origin !== baseUrl) - return null + if (url.origin !== baseUrl) return null return url.searchParams - } - catch { + } catch { return null } } -const resolveAttributionSearchParams = (searchParams: SearchParamReader): SearchParamReader | null => { - if (getSearchParamValue(searchParams, 'utm_source')) - return searchParams +const resolveAttributionSearchParams = ( + searchParams: SearchParamReader, +): SearchParamReader | null => { + if (getSearchParamValue(searchParams, 'utm_source')) return searchParams const redirectUrl = getSearchParamValue(searchParams, 'redirect_url') - if (!redirectUrl) - return null + if (!redirectUrl) return null return parseRedirectUrlSearchParams(redirectUrl) } @@ -67,16 +72,13 @@ const ExternalAttributionRecorder = () => { const searchParams = useSearchParams() useEffect(() => { - if (!IS_CLOUD_EDITION) - return + if (!IS_CLOUD_EDITION) return const attributionSearchParams = resolveAttributionSearchParams(searchParams) - if (!attributionSearchParams) - return + if (!attributionSearchParams) return const utmSource = getSearchParamValue(attributionSearchParams, 'utm_source') - if (!utmSource) - return + if (!utmSource) return // create_app conversion attribution (utm_source + slug). rememberCreateAppExternalAttribution({ searchParams: attributionSearchParams }) @@ -88,8 +90,7 @@ const ExternalAttributionRecorder = () => { const utmInfo: Record<string, string> = {} UTM_INFO_QUERY_KEYS.forEach((key) => { const value = getSearchParamValue(attributionSearchParams, key) - if (value) - utmInfo[key] = value + if (value) utmInfo[key] = value }) Cookies.set(UTM_INFO_COOKIE, JSON.stringify(utmInfo), { diff --git a/web/app/components/goto-anything/__tests__/command-selector.spec.tsx b/web/app/components/goto-anything/__tests__/command-selector.spec.tsx index dedd57b7c03e99..7a7cc20f2e50f7 100644 --- a/web/app/components/goto-anything/__tests__/command-selector.spec.tsx +++ b/web/app/components/goto-anything/__tests__/command-selector.spec.tsx @@ -9,12 +9,14 @@ vi.mock('@/next/navigation', () => ({ usePathname: () => '/app', })) -const slashCommandsMock = [{ - name: 'docs', - description: 'Docs', - mode: 'direct', - isAvailable: () => true, -}] +const slashCommandsMock = [ + { + name: 'docs', + description: 'Docs', + mode: 'direct', + isAvailable: () => true, + }, +] vi.mock('../actions/commands/registry', () => ({ slashCommandRegistry: { diff --git a/web/app/components/goto-anything/__tests__/context.spec.tsx b/web/app/components/goto-anything/__tests__/context.spec.tsx index 70a30786df0e2b..5406c30e718473 100644 --- a/web/app/components/goto-anything/__tests__/context.spec.tsx +++ b/web/app/components/goto-anything/__tests__/context.spec.tsx @@ -16,9 +16,7 @@ const ContextConsumer = () => { const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext() return ( <div data-testid="status"> - {String(isWorkflowPage)} - | - {String(isRagPipelinePage)} + {String(isWorkflowPage)}|{String(isRagPipelinePage)} </div> ) } @@ -121,9 +119,7 @@ describe('useGotoAnythingContext', () => { const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext() return ( <div data-testid="context"> - {String(isWorkflowPage)} - | - {String(isRagPipelinePage)} + {String(isWorkflowPage)}|{String(isRagPipelinePage)} </div> ) } diff --git a/web/app/components/goto-anything/__tests__/index.spec.tsx b/web/app/components/goto-anything/__tests__/index.spec.tsx index dcb1bbba427722..63c86c9e278b0f 100644 --- a/web/app/components/goto-anything/__tests__/index.spec.tsx +++ b/web/app/components/goto-anything/__tests__/index.spec.tsx @@ -62,16 +62,24 @@ const triggerKeyPress = (combo: string) => { } } -let mockQueryResult = { data: [] as TestSearchResult[], isLoading: false, isError: false, error: null as Error | null } +let mockQueryResult = { + data: [] as TestSearchResult[], + isLoading: false, + isError: false, + error: null as Error | null, +} vi.mock('@tanstack/react-query', () => ({ useQuery: () => mockQueryResult, })) -vi.mock('@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', () => ({ - default: () => ({ - canInstallPlugin: true, - currentDifyVersion: '1.0.0', +vi.mock( + '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', + () => ({ + default: () => ({ + canInstallPlugin: true, + currentDifyVersion: '1.0.0', + }), }), -})) +) const contextValue = { isWorkflowPage: false, isRagPipelinePage: false } vi.mock('../context', () => ({ @@ -128,11 +136,19 @@ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({ })) vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({ - default: (props: { manifest?: { name?: string }, onClose: () => void, onSuccess: () => void }) => ( + default: (props: { + manifest?: { name?: string } + onClose: () => void + onSuccess: () => void + }) => ( <div data-testid="install-modal"> <span>{props.manifest?.name}</span> - <button onClick={props.onClose} data-testid="close-install">close</button> - <button onClick={props.onSuccess} data-testid="success-install">success</button> + <button onClick={props.onClose} data-testid="close-install"> + close + </button> + <button onClick={props.onSuccess} data-testid="success-install"> + success + </button> </div> ), })) @@ -140,17 +156,13 @@ vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({ const renderGotoAnything = (ui: React.ReactElement) => { const store = createStore() - return render( - <Provider store={store}> - {ui} - </Provider>, - ) + return render(<Provider store={store}>{ui}</Provider>) } describe('GotoAnything', () => { beforeEach(() => { routerPush.mockClear() - Object.keys(hotkeyHandlers).forEach(key => delete hotkeyHandlers[key]) + Object.keys(hotkeyHandlers).forEach((key) => delete hotkeyHandlers[key]) mockQueryResult = { data: [], isLoading: false, isError: false, error: null } matchActionMock.mockReset() searchAnythingMock.mockClear() @@ -164,7 +176,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) }) @@ -174,12 +188,16 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) await user.keyboard('{Escape}') await waitFor(() => { - expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).not.toBeInTheDocument() }) }) @@ -188,12 +206,16 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).not.toBeInTheDocument() }) }) @@ -204,7 +226,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) await user.keyboard('{Escape}') @@ -219,7 +243,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -227,7 +253,9 @@ describe('GotoAnything', () => { await user.keyboard('{Escape}') await waitFor(() => { - expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).not.toBeInTheDocument() }) triggerKeyPress('ctrl.k') @@ -242,15 +270,17 @@ describe('GotoAnything', () => { it('should navigate to selected result', async () => { const user = userEvent.setup() mockQueryResult = { - data: [{ - id: 'app-1', - type: 'app', - title: 'Sample App', - description: 'desc', - path: '/apps/1', - icon: <div data-testid="icon">🧩</div>, - data: {}, - }], + data: [ + { + id: 'app-1', + type: 'app', + title: 'Sample App', + description: 'desc', + path: '/apps/1', + icon: <div data-testid="icon">🧩</div>, + data: {}, + }, + ], isLoading: false, isError: false, error: null, @@ -260,7 +290,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -278,7 +310,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -302,7 +336,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -326,7 +362,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -340,7 +378,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) expect(screen.getByText('app.gotoAnything.searchTitle')).toBeInTheDocument() @@ -359,7 +399,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -373,18 +415,20 @@ describe('GotoAnything', () => { it('should open plugin installer when selecting plugin result', async () => { const user = userEvent.setup() mockQueryResult = { - data: [{ - id: 'plugin-1', - type: 'plugin', - title: 'Plugin Item', - description: 'desc', - path: '', - icon: <div />, - data: { - name: 'Plugin Item', - latest_package_identifier: 'pkg', + data: [ + { + id: 'plugin-1', + type: 'plugin', + title: 'Plugin Item', + description: 'desc', + path: '', + icon: <div />, + data: { + name: 'Plugin Item', + latest_package_identifier: 'pkg', + }, }, - }], + ], isLoading: false, isError: false, error: null, @@ -394,7 +438,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -409,18 +455,20 @@ describe('GotoAnything', () => { it('should close plugin installer via close button', async () => { const user = userEvent.setup() mockQueryResult = { - data: [{ - id: 'plugin-1', - type: 'plugin', - title: 'Plugin Item', - description: 'desc', - path: '', - icon: <div />, - data: { - name: 'Plugin Item', - latest_package_identifier: 'pkg', + data: [ + { + id: 'plugin-1', + type: 'plugin', + title: 'Plugin Item', + description: 'desc', + path: '', + icon: <div />, + data: { + name: 'Plugin Item', + latest_package_identifier: 'pkg', + }, }, - }], + ], isLoading: false, isError: false, error: null, @@ -430,7 +478,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -450,18 +500,20 @@ describe('GotoAnything', () => { it('should close plugin installer on success', async () => { const user = userEvent.setup() mockQueryResult = { - data: [{ - id: 'plugin-1', - type: 'plugin', - title: 'Plugin Item', - description: 'desc', - path: '', - icon: <div />, - data: { - name: 'Plugin Item', - latest_package_identifier: 'pkg', + data: [ + { + id: 'plugin-1', + type: 'plugin', + title: 'Plugin Item', + description: 'desc', + path: '', + icon: <div />, + data: { + name: 'Plugin Item', + latest_package_identifier: 'pkg', + }, }, - }], + ], isLoading: false, isError: false, error: null, @@ -471,7 +523,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -503,7 +557,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -526,7 +582,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -548,7 +606,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -570,7 +630,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -578,7 +640,9 @@ describe('GotoAnything', () => { await user.keyboard('{Enter}') await waitFor(() => { - expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).not.toBeInTheDocument() }) }) }) @@ -587,15 +651,17 @@ describe('GotoAnything', () => { it('should handle knowledge result navigation', async () => { const user = userEvent.setup() mockQueryResult = { - data: [{ - id: 'kb-1', - type: 'knowledge', - title: 'Knowledge Base', - description: 'desc', - path: '/datasets/kb-1', - icon: <div />, - data: {}, - }], + data: [ + { + id: 'kb-1', + type: 'knowledge', + title: 'Knowledge Base', + description: 'desc', + path: '/datasets/kb-1', + icon: <div />, + data: {}, + }, + ], isLoading: false, isError: false, error: null, @@ -605,7 +671,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -620,15 +688,17 @@ describe('GotoAnything', () => { it('should NOT navigate when result has no path', async () => { const user = userEvent.setup() mockQueryResult = { - data: [{ - id: 'item-1', - type: 'app', - title: 'No Path Item', - description: 'desc', - path: '', - icon: <div />, - data: {}, - }], + data: [ + { + id: 'item-1', + type: 'app', + title: 'No Path Item', + description: 'desc', + path: '', + icon: <div />, + data: {}, + }, + ], isLoading: false, isError: false, error: null, @@ -638,7 +708,9 @@ describe('GotoAnything', () => { triggerKeyPress('ctrl.k') await waitFor(() => { - expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), + ).toBeInTheDocument() }) const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') diff --git a/web/app/components/goto-anything/actions/__tests__/app.spec.ts b/web/app/components/goto-anything/actions/__tests__/app.spec.ts index b8d58512efe760..eb99f2812fc945 100644 --- a/web/app/components/goto-anything/actions/__tests__/app.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/app.spec.ts @@ -27,7 +27,16 @@ describe('appAction', () => { const { fetchAppList } = await import('@/service/apps') vi.mocked(fetchAppList).mockResolvedValue({ data: [ - { id: 'app-1', name: 'My App', description: 'A great app', mode: 'chat', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App, + { + id: 'app-1', + name: 'My App', + description: 'A great app', + mode: 'chat', + icon: '', + icon_type: 'emoji', + icon_background: '', + icon_url: '', + } as unknown as App, ], has_more: false, limit: 10, @@ -47,7 +56,7 @@ describe('appAction', () => { title: 'My App', type: 'app', }) - expect(results.slice(1).map(r => r.id)).toEqual([ + expect(results.slice(1).map((r) => r.id)).toEqual([ 'app-1:configuration', 'app-1:overview', 'app-1:logs', @@ -59,7 +68,16 @@ describe('appAction', () => { const { fetchAppList } = await import('@/service/apps') vi.mocked(fetchAppList).mockResolvedValue({ data: [ - { id: 'wf-1', name: 'Flow', description: '', mode: 'workflow', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App, + { + id: 'wf-1', + name: 'Flow', + description: '', + mode: 'workflow', + icon: '', + icon_type: 'emoji', + icon_background: '', + icon_url: '', + } as unknown as App, ], has_more: false, limit: 10, @@ -70,7 +88,7 @@ describe('appAction', () => { const results = await appAction.search('@app', '', 'en') expect(results).toHaveLength(4) - expect(results.slice(1).map(r => r.id)).toEqual([ + expect(results.slice(1).map((r) => r.id)).toEqual([ 'wf-1:workflow', 'wf-1:overview', 'wf-1:logs', @@ -81,8 +99,26 @@ describe('appAction', () => { const { fetchAppList } = await import('@/service/apps') vi.mocked(fetchAppList).mockResolvedValue({ data: [ - { id: 'app-1', name: 'My App', description: '', mode: 'chat', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App, - { id: 'app-2', name: 'Other', description: '', mode: 'chat', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App, + { + id: 'app-1', + name: 'My App', + description: '', + mode: 'chat', + icon: '', + icon_type: 'emoji', + icon_background: '', + icon_url: '', + } as unknown as App, + { + id: 'app-2', + name: 'Other', + description: '', + mode: 'chat', + icon: '', + icon_type: 'emoji', + icon_background: '', + icon_url: '', + } as unknown as App, ], has_more: false, limit: 10, @@ -93,12 +129,18 @@ describe('appAction', () => { const results = await appAction.search('my app', 'my app', 'en') expect(results).toHaveLength(2) - expect(results.map(r => r.id)).toEqual(['app-1', 'app-2']) + expect(results.map((r) => r.id)).toEqual(['app-1', 'app-2']) }) it('returns empty array when response has no data', async () => { const { fetchAppList } = await import('@/service/apps') - vi.mocked(fetchAppList).mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 }) + vi.mocked(fetchAppList).mockResolvedValue({ + data: [], + has_more: false, + limit: 10, + page: 1, + total: 0, + }) const results = await appAction.search('@app', '', 'en') expect(results).toEqual([]) diff --git a/web/app/components/goto-anything/actions/__tests__/index.spec.ts b/web/app/components/goto-anything/actions/__tests__/index.spec.ts index ec6758b8b95a7a..9010ba047ea1fe 100644 --- a/web/app/components/goto-anything/actions/__tests__/index.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/index.spec.ts @@ -173,33 +173,61 @@ describe('searchAnything', () => { ] const dynamicActions: Record<string, ActionItem> = { - slash: { key: '/', shortcut: '/', title: 'Slash', description: '', search: vi.fn().mockResolvedValue([]) }, - app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockResolvedValue(appResults) }, - knowledge: { key: '@knowledge', shortcut: '@kb', title: 'KB', description: '', search: vi.fn().mockResolvedValue(kbResults) }, + slash: { + key: '/', + shortcut: '/', + title: 'Slash', + description: '', + search: vi.fn().mockResolvedValue([]), + }, + app: { + key: '@app', + shortcut: '@app', + title: 'App', + description: '', + search: vi.fn().mockResolvedValue(appResults), + }, + knowledge: { + key: '@knowledge', + shortcut: '@kb', + title: 'KB', + description: '', + search: vi.fn().mockResolvedValue(kbResults), + }, } const results = await searchAnything('en', 'my query', undefined, dynamicActions) expect(dynamicActions.slash!.search).not.toHaveBeenCalled() expect(results).toHaveLength(2) - expect(results).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: 'a1' }), - expect.objectContaining({ id: 'k1' }), - ])) + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'a1' }), + expect.objectContaining({ id: 'k1' }), + ]), + ) }) it('handles partial search failures in global search gracefully', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const dynamicActions: Record<string, ActionItem> = { - app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockRejectedValue(new Error('fail')) }, + app: { + key: '@app', + shortcut: '@app', + title: 'App', + description: '', + search: vi.fn().mockRejectedValue(new Error('fail')), + }, knowledge: { key: '@knowledge', shortcut: '@kb', title: 'KB', description: '', - search: vi.fn().mockResolvedValue([ - { id: 'k1', title: 'KB1', type: 'knowledge', data: {} as unknown as DataSet }, - ]), + search: vi + .fn() + .mockResolvedValue([ + { id: 'k1', title: 'KB1', type: 'knowledge', data: {} as unknown as DataSet }, + ]), }, } @@ -215,8 +243,20 @@ describe('searchAnything', () => { describe('matchAction', () => { const actions: Record<string, ActionItem> = { app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn() }, - knowledge: { key: '@knowledge', shortcut: '@kb', title: 'KB', description: '', search: vi.fn() }, - plugin: { key: '@plugin', shortcut: '@plugin', title: 'Plugin', description: '', search: vi.fn() }, + knowledge: { + key: '@knowledge', + shortcut: '@kb', + title: 'KB', + description: '', + search: vi.fn(), + }, + plugin: { + key: '@plugin', + shortcut: '@plugin', + title: 'Plugin', + description: '', + search: vi.fn(), + }, slash: { key: '/', shortcut: '/', title: 'Slash', description: '', search: vi.fn() }, } diff --git a/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts b/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts index 2124eccd81692e..575e901152136a 100644 --- a/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts @@ -23,7 +23,13 @@ describe('knowledgeAction', () => { const { fetchDatasets } = await import('@/service/datasets') vi.mocked(fetchDatasets).mockResolvedValue({ data: [ - { id: 'ds-1', name: 'My Knowledge', description: 'A KB', provider: 'vendor', embedding_available: true } as unknown as DataSet, + { + id: 'ds-1', + name: 'My Knowledge', + description: 'A KB', + provider: 'vendor', + embedding_available: true, + } as unknown as DataSet, ], has_more: false, limit: 10, @@ -49,7 +55,13 @@ describe('knowledgeAction', () => { const { fetchDatasets } = await import('@/service/datasets') vi.mocked(fetchDatasets).mockResolvedValue({ data: [ - { id: 'ds-ext', name: 'External', description: '', provider: 'external', embedding_available: true } as unknown as DataSet, + { + id: 'ds-ext', + name: 'External', + description: '', + provider: 'external', + embedding_available: true, + } as unknown as DataSet, ], has_more: false, limit: 10, @@ -66,7 +78,13 @@ describe('knowledgeAction', () => { const { fetchDatasets } = await import('@/service/datasets') vi.mocked(fetchDatasets).mockResolvedValue({ data: [ - { id: 'ds-2', name: 'Internal', description: '', provider: 'vendor', embedding_available: true } as unknown as DataSet, + { + id: 'ds-2', + name: 'Internal', + description: '', + provider: 'vendor', + embedding_available: true, + } as unknown as DataSet, ], has_more: false, limit: 10, diff --git a/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts b/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts index 1594662691ad52..bbf25f8c1119af 100644 --- a/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts @@ -51,7 +51,9 @@ describe('ragPipelineNodesAction', () => { ] ragPipelineNodesAction.searchFn = vi.fn().mockReturnValue(results) - await expect(ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en')).resolves.toEqual(results) + await expect( + ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en'), + ).resolves.toEqual(results) expect(ragPipelineNodesAction.searchFn).toHaveBeenCalledWith('retrieve') }) @@ -61,7 +63,9 @@ describe('ragPipelineNodesAction', () => { throw new Error('failed') }) - await expect(ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en')).resolves.toEqual([]) + await expect( + ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en'), + ).resolves.toEqual([]) expect(warnSpy).toHaveBeenCalledWith('RAG pipeline nodes search failed:', expect.any(Error)) warnSpy.mockRestore() diff --git a/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts b/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts index 23ce798ba60483..024c162e2a1817 100644 --- a/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts @@ -6,8 +6,7 @@ vi.mock('@/service/base', () => ({ vi.mock('@/i18n-config', () => ({ renderI18nObject: vi.fn((obj: Record<string, string> | string, locale: string) => { - if (typeof obj === 'string') - return obj + if (typeof obj === 'string') return obj return obj[locale] || obj.en_US || '' }), })) @@ -17,7 +16,7 @@ vi.mock('../../../plugins/card/base/card-icon', () => ({ })) vi.mock('../../../plugins/marketplace/utils', () => ({ - getFormattedPlugin: vi.fn(plugin => ({ ...plugin, icon: 'icon-url' })), + getFormattedPlugin: vi.fn((plugin) => ({ ...plugin, icon: 'icon-url' })), })) describe('pluginAction', () => { @@ -35,7 +34,12 @@ describe('pluginAction', () => { vi.mocked(postMarketplace).mockResolvedValue({ data: { plugins: [ - { name: 'plugin-1', label: { en_US: 'My Plugin' }, brief: { en_US: 'A plugin' }, icon: 'icon.png' }, + { + name: 'plugin-1', + label: { en_US: 'My Plugin' }, + brief: { en_US: 'A plugin' }, + icon: 'icon.png', + }, ], total: 1, }, diff --git a/web/app/components/goto-anything/actions/__tests__/recent-store.spec.ts b/web/app/components/goto-anything/actions/__tests__/recent-store.spec.ts index da1dc86124488c..b45a82f043890c 100644 --- a/web/app/components/goto-anything/actions/__tests__/recent-store.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/recent-store.spec.ts @@ -11,9 +11,7 @@ describe('recent-store', () => { }) it('parses stored items from localStorage', () => { - const items = [ - { id: 'app-1', title: 'App 1', path: '/app/1', originalType: 'app' as const }, - ] + const items = [{ id: 'app-1', title: 'App 1', path: '/app/1', originalType: 'app' as const }] localStorage.setItem('goto-anything:recent', JSON.stringify(items)) expect(getRecentItems()).toEqual(items) @@ -41,7 +39,7 @@ describe('recent-store', () => { addRecentItem({ id: 'b', title: 'B', path: '/b', originalType: 'knowledge' }) const stored = getRecentItems() - expect(stored.map(i => i.id)).toEqual(['b', 'a']) + expect(stored.map((i) => i.id)).toEqual(['b', 'a']) }) it('deduplicates by id, moving the existing entry to the front', () => { @@ -50,7 +48,7 @@ describe('recent-store', () => { addRecentItem({ id: 'a', title: 'A updated', path: '/a', originalType: 'app' }) const stored = getRecentItems() - expect(stored.map(i => i.id)).toEqual(['a', 'b']) + expect(stored.map((i) => i.id)).toEqual(['a', 'b']) expect(stored[0]!.title).toBe('A updated') }) diff --git a/web/app/components/goto-anything/actions/app.tsx b/web/app/components/goto-anything/actions/app.tsx index 7b156fba3e67d0..cea05e5d6c0fa0 100644 --- a/web/app/components/goto-anything/actions/app.tsx +++ b/web/app/components/goto-anything/actions/app.tsx @@ -1,6 +1,12 @@ import type { ActionItem, AppSearchResult, SearchResult } from './types' import type { App } from '@/types/app' -import { RiFileListLine, RiLayoutLine, RiLineChartLine, RiNodeTree, RiTerminalBoxLine } from '@remixicon/react' +import { + RiFileListLine, + RiLayoutLine, + RiLineChartLine, + RiNodeTree, + RiTerminalBoxLine, +} from '@remixicon/react' import * as React from 'react' import { fetchAppList } from '@/service/apps' import { AppModeEnum } from '@/types/app' @@ -10,7 +16,7 @@ import AppIcon from '../../base/app-icon' const WORKFLOW_MODES = new Set([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT]) -type AppSection = { id: string, label: string, path: string, icon: React.ElementType } +type AppSection = { id: string; label: string; path: string; icon: React.ElementType } const getAppSections = (app: App): AppSection[] => { const base = `/app/${app.id}` @@ -22,7 +28,12 @@ const getAppSections = (app: App): AppSection[] => { ] } return [ - { id: 'configuration', label: 'Configuration', path: `${base}/configuration`, icon: RiLayoutLine }, + { + id: 'configuration', + label: 'Configuration', + path: `${base}/configuration`, + icon: RiLayoutLine, + }, { id: 'overview', label: 'Overview', path: `${base}/overview`, icon: RiLineChartLine }, { id: 'logs', label: 'Logs', path: `${base}/logs`, icon: RiFileListLine }, { id: 'develop', label: 'Develop', path: `${base}/develop`, icon: RiTerminalBoxLine }, @@ -47,7 +58,7 @@ const appIcon = (app: App) => ( ) const parser = (apps: App[]): AppSearchResult[] => { - return apps.map(app => ({ + return apps.map((app) => ({ id: app.id, title: app.name, description: app.description, @@ -107,8 +118,7 @@ export const appAction: ActionItem = { }) const apps = response?.data || [] return isScoped ? parserWithSections(apps) : parser(apps) - } - catch (error) { + } catch (error) { console.warn('App search failed:', error) return [] } diff --git a/web/app/components/goto-anything/actions/commands/__tests__/create.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/create.spec.tsx index e666dfaef83de6..c9aefa778891e9 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/create.spec.tsx +++ b/web/app/components/goto-anything/actions/commands/__tests__/create.spec.tsx @@ -19,7 +19,7 @@ vi.mock('@/app/components/workflow/workflow-generator/store', () => ({ // Controllable app-store state — the handler reads `appDetail` to decide // whether to thread the current Studio app through to the generator. Mutated // per-test; getState() reads it lazily so updates land after the mock factory. -const mockAppStore: { appDetail: { id: string, mode: string } | undefined } = { +const mockAppStore: { appDetail: { id: string; mode: string } | undefined } = { appDetail: undefined, } vi.mock('@/app/components/app/store', () => ({ @@ -48,14 +48,18 @@ describe('/create slash command', () => { // uses this to render its initial list when the user types just `/create`. it('should surface auto, workflow and chatflow when args is empty', async () => { const results = await createCommand.search('') - expect(results.map(r => r.id)).toEqual(['create-auto', 'create-workflow', 'create-chatflow']) + expect(results.map((r) => r.id)).toEqual([ + 'create-auto', + 'create-workflow', + 'create-chatflow', + ]) }) // Typing a partial keyword should narrow the list and each result should // carry the right command-bus payload so the navigation hook can fire it. it('should filter by query and attach the right command payload', async () => { const results = await createCommand.search('chat') - expect(results.map(r => r.id)).toEqual(['create-chatflow']) + expect(results.map((r) => r.id)).toEqual(['create-chatflow']) expect(results[0]!.data.command).toBe('create.open') expect(results[0]!.data.args).toEqual({ mode: 'advanced-chat', auto: false, instruction: '' }) }) @@ -88,15 +92,19 @@ describe('/create slash command', () => { // appears only in the (mocked) title key still narrows the list. it('should filter by the localised label, not just the id', async () => { const results = await createCommand.search('createChatflow') - expect(results.map(r => r.id)).toEqual(['create-chatflow']) + expect(results.map((r) => r.id)).toEqual(['create-chatflow']) }) // Inline capture: a leading mode word selects that option and the rest of // the text becomes the pre-filled instruction surfaced as the description. it('should capture a trailing instruction when the first word names a mode', async () => { const results = await createCommand.search('workflow summarize a URL') - expect(results.map(r => r.id)).toEqual(['create-workflow']) - expect(results[0]!.data.args).toEqual({ mode: 'workflow', auto: false, instruction: 'summarize a URL' }) + expect(results.map((r) => r.id)).toEqual(['create-workflow']) + expect(results[0]!.data.args).toEqual({ + mode: 'workflow', + auto: false, + instruction: 'summarize a URL', + }) expect(results[0]!.description).toBe('summarize a URL') }) @@ -104,7 +112,11 @@ describe('/create slash command', () => { // pre-filled with the full text so the user just picks the type. it('should keep all options with the full text when no leading mode word', async () => { const results = await createCommand.search('summarize a URL') - expect(results.map(r => r.id)).toEqual(['create-auto', 'create-workflow', 'create-chatflow']) + expect(results.map((r) => r.id)).toEqual([ + 'create-auto', + 'create-workflow', + 'create-chatflow', + ]) results.forEach((r) => { expect((r.data.args as { instruction: string }).instruction).toBe('summarize a URL') }) @@ -125,14 +137,22 @@ describe('/create slash command', () => { it('should open the generator with the requested mode when no Studio app is open', async () => { await executeCommand('create.open', { mode: 'workflow' }) - expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow', autoMode: false, initialInstruction: '' }) + expect(mockOpenGenerator).toHaveBeenCalledWith({ + mode: 'workflow', + autoMode: false, + initialInstruction: '', + }) }) // Inline-captured instruction threads through to the modal. it('should thread the captured instruction through to the generator', async () => { await executeCommand('create.open', { mode: 'workflow', instruction: 'summarize a URL' }) - expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow', autoMode: false, initialInstruction: 'summarize a URL' }) + expect(mockOpenGenerator).toHaveBeenCalledWith({ + mode: 'workflow', + autoMode: false, + initialInstruction: 'summarize a URL', + }) }) // Auto-mode always creates a new app, even with a matching Studio open, @@ -142,7 +162,11 @@ describe('/create slash command', () => { await executeCommand('create.open', { mode: 'advanced-chat', auto: true }) - expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'advanced-chat', autoMode: true, initialInstruction: '' }) + expect(mockOpenGenerator).toHaveBeenCalledWith({ + mode: 'advanced-chat', + autoMode: true, + initialInstruction: '', + }) }) // In-Studio create-and-apply: a matching graph-based app threads id + mode @@ -167,7 +191,11 @@ describe('/create slash command', () => { await executeCommand('create.open', { mode: 'advanced-chat' }) - expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'advanced-chat', autoMode: false, initialInstruction: '' }) + expect(mockOpenGenerator).toHaveBeenCalledWith({ + mode: 'advanced-chat', + autoMode: false, + initialInstruction: '', + }) }) // Non-graph Studio apps (Chat / Agent / Completion) have no canvas to apply @@ -177,14 +205,22 @@ describe('/create slash command', () => { await executeCommand('create.open', { mode: 'workflow' }) - expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow', autoMode: false, initialInstruction: '' }) + expect(mockOpenGenerator).toHaveBeenCalledWith({ + mode: 'workflow', + autoMode: false, + initialInstruction: '', + }) }) // Defensive fallback: a missing mode still opens the generator safely. it('should default to workflow mode when no args are passed', async () => { await executeCommand('create.open') - expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow', autoMode: false, initialInstruction: '' }) + expect(mockOpenGenerator).toHaveBeenCalledWith({ + mode: 'workflow', + autoMode: false, + initialInstruction: '', + }) }) }) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts index d30165715fb6cd..bb10791912b12a 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts +++ b/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts @@ -15,12 +15,12 @@ vi.mock('../command-bus') const mockT = vi.fn((key: string) => key) vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { getI18n: () => ({ t: withSelectorKey((key: string) => mockT(key)), language: 'en', }), - }) + } }) vi.mock('@/context/i18n', () => ({ @@ -29,7 +29,7 @@ vi.mock('@/context/i18n', () => ({ })) vi.mock('@/i18n-config/language', () => ({ - getDocLanguage: (locale: string) => locale === 'en' ? 'en' : locale, + getDocLanguage: (locale: string) => (locale === 'en' ? 'en' : locale), })) describe('docsCommand', () => { @@ -68,9 +68,7 @@ describe('docsCommand', () => { }) it('search uses fallback description when i18n returns empty', async () => { - mockT.mockImplementation((key: string) => - key.includes('docDesc') ? '' : key, - ) + mockT.mockImplementation((key: string) => (key.includes('docDesc') ? '' : key)) const results = await docsCommand.search('', 'en') @@ -185,9 +183,7 @@ describe('communityCommand', () => { }) it('search uses fallback description when i18n returns empty', async () => { - mockT.mockImplementation((key: string) => - key.includes('communityDesc') ? '' : key, - ) + mockT.mockImplementation((key: string) => (key.includes('communityDesc') ? '' : key)) const results = await communityCommand.search('', 'en') @@ -216,7 +212,11 @@ describe('communityCommand', () => { const handlers = vi.mocked(registerCommands).mock.calls[0]![0] await handlers['navigation.community']!() - expect(openSpy).toHaveBeenCalledWith('https://discord.gg/5AEfbxcd9k', '_blank', 'noopener,noreferrer') + expect(openSpy).toHaveBeenCalledWith( + 'https://discord.gg/5AEfbxcd9k', + '_blank', + 'noopener,noreferrer', + ) openSpy.mockRestore() }) @@ -242,11 +242,7 @@ describe('forumCommand', () => { forumCommand.execute?.() - expect(openSpy).toHaveBeenCalledWith( - 'https://forum.dify.ai', - '_blank', - 'noopener,noreferrer', - ) + expect(openSpy).toHaveBeenCalledWith('https://forum.dify.ai', '_blank', 'noopener,noreferrer') openSpy.mockRestore() }) @@ -262,9 +258,7 @@ describe('forumCommand', () => { }) it('search uses fallback description when i18n returns empty', async () => { - mockT.mockImplementation((key: string) => - key.includes('feedbackDesc') ? '' : key, - ) + mockT.mockImplementation((key: string) => (key.includes('feedbackDesc') ? '' : key)) const results = await forumCommand.search('', 'en') @@ -283,7 +277,11 @@ describe('forumCommand', () => { const handlers = vi.mocked(registerCommands).mock.calls[0]![0] await handlers['navigation.forum']!({ url: 'https://custom-forum.com' }) - expect(openSpy).toHaveBeenCalledWith('https://custom-forum.com', '_blank', 'noopener,noreferrer') + expect(openSpy).toHaveBeenCalledWith( + 'https://custom-forum.com', + '_blank', + 'noopener,noreferrer', + ) openSpy.mockRestore() }) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx index 82cdfa330c69d6..62d8d11644f382 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx +++ b/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx @@ -26,7 +26,7 @@ describe('goCommand', () => { it('returns all navigation items when query is empty', async () => { const results = await goCommand.search('', 'en') - expect(results.map(r => r.id)).toEqual([ + expect(results.map((r) => r.id)).toEqual([ 'go-apps', 'go-datasets', 'go-plugins', diff --git a/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts index 23afc290ce9557..670e58c785f942 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts +++ b/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts @@ -28,7 +28,7 @@ describe('languageCommand', () => { const results = await languageCommand.search('', 'en') expect(results).toHaveLength(3) // 3 supported languages - expect(results.every(r => r.type === 'command')).toBe(true) + expect(results.every((r) => r.type === 'command')).toBe(true) }) it('filters languages by name query', async () => { diff --git a/web/app/components/goto-anything/actions/commands/__tests__/refine.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/refine.spec.tsx index d1739aa1d65346..4fb958eff6a66b 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/refine.spec.tsx +++ b/web/app/components/goto-anything/actions/commands/__tests__/refine.spec.tsx @@ -15,7 +15,7 @@ vi.mock('@/app/components/workflow/workflow-generator/store', () => ({ // Controllable app-store state — /refine reads appDetail to gate availability // and to pick the mode + id it refines. Mutated per-test; read lazily. -const mockAppStore: { appDetail: { id: string, mode: string } | undefined } = { +const mockAppStore: { appDetail: { id: string; mode: string } | undefined } = { appDetail: undefined, } vi.mock('@/app/components/app/store', () => ({ diff --git a/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts index d2623be434879d..ee4936f4f5f2ae 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts +++ b/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts @@ -147,7 +147,9 @@ describe('SlashCommandRegistry', () => { }) it('delegates to exact-match handler for "/theme dark"', async () => { - const mockResults = [{ id: 'dark', title: 'Dark', description: '', type: 'command' as const, data: {} }] + const mockResults = [ + { id: 'dark', title: 'Dark', description: '', type: 'command' as const, data: {} }, + ] const handler = createHandler({ name: 'theme', search: vi.fn().mockResolvedValue(mockResults), @@ -170,7 +172,9 @@ describe('SlashCommandRegistry', () => { }) it('uses partial match when no exact match found', async () => { - const mockResults = [{ id: '1', title: 'T', description: '', type: 'command' as const, data: {} }] + const mockResults = [ + { id: '1', title: 'T', description: '', type: 'command' as const, data: {} }, + ] const handler = createHandler({ name: 'theme', search: vi.fn().mockResolvedValue(mockResults), @@ -183,7 +187,9 @@ describe('SlashCommandRegistry', () => { }) it('uses alias partial match', async () => { - const mockResults = [{ id: '1', title: 'L', description: '', type: 'command' as const, data: {} }] + const mockResults = [ + { id: '1', title: 'L', description: '', type: 'command' as const, data: {} }, + ] const handler = createHandler({ name: 'language', aliases: ['lang'], @@ -206,7 +212,9 @@ describe('SlashCommandRegistry', () => { }) it('fuzzy search also matches aliases', async () => { - registry.register(createHandler({ name: 'language', aliases: ['lang'], description: 'Set language' })) + registry.register( + createHandler({ name: 'language', aliases: ['lang'], description: 'Set language' }), + ) const handler = registry.findCommand('language') await registry.search('/lan') diff --git a/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx index ea364c068e9b5b..f13ff88aba94d9 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx +++ b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx @@ -87,12 +87,16 @@ describe('slashAction', () => { }) it('should delegate search to the slash command registry with the active language', async () => { - mockSearch.mockResolvedValue([{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }]) + mockSearch.mockResolvedValue([ + { id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }, + ]) const results = await slashAction.search('/theme dark', 'dark') expect(mockSearch).toHaveBeenCalledWith('/theme dark', 'ja') - expect(results).toEqual([{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }]) + expect(results).toEqual([ + { id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }, + ]) }) }) @@ -106,7 +110,7 @@ describe('SlashCommandProvider', () => { it('should not register the /create and /refine preview commands when the feature flag is off', () => { const { unmount } = render(<SlashCommandProvider />) - expect(mockRegister.mock.calls.map(call => call[0].name)).toEqual([ + expect(mockRegister.mock.calls.map((call) => call[0].name)).toEqual([ 'theme', 'language', 'forum', @@ -115,14 +119,18 @@ describe('SlashCommandProvider', () => { 'account', 'go', ]) - expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme }) - expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale }) + expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { + setTheme: mockSetTheme, + }) + expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { + setLocale: mockSetLocale, + }) unmount() // Unregister is always called for the preview commands (a no-op when they // were never registered) so toggling the flag off mid-session stays clean. - expect(mockUnregister.mock.calls.map(call => call[0])).toEqual([ + expect(mockUnregister.mock.calls.map((call) => call[0])).toEqual([ 'theme', 'language', 'forum', @@ -140,7 +148,7 @@ describe('SlashCommandProvider', () => { const { unmount } = render(<SlashCommandProvider />) - expect(mockRegister.mock.calls.map(call => call[0].name)).toEqual([ + expect(mockRegister.mock.calls.map((call) => call[0].name)).toEqual([ 'theme', 'language', 'forum', @@ -154,7 +162,7 @@ describe('SlashCommandProvider', () => { unmount() - expect(mockUnregister.mock.calls.map(call => call[0])).toEqual([ + expect(mockUnregister.mock.calls.map((call) => call[0])).toEqual([ 'theme', 'language', 'forum', diff --git a/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts index ebf028b4c19805..a6f75cd7a84055 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts +++ b/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts @@ -18,7 +18,7 @@ describe('themeCommand', () => { const results = await themeCommand.search('', 'en') expect(results).toHaveLength(3) - expect(results.map(r => r.id)).toEqual(['system', 'light', 'dark']) + expect(results.map((r) => r.id)).toEqual(['system', 'light', 'dark']) }) it('returns all theme options with correct type', async () => { @@ -26,7 +26,10 @@ describe('themeCommand', () => { results.forEach((r) => { expect(r.type).toBe('command') - expect(r.data).toEqual({ command: 'theme.set', args: expect.objectContaining({ value: expect.any(String) }) }) + expect(r.data).toEqual({ + command: 'theme.set', + args: expect.objectContaining({ value: expect.any(String) }), + }) }) }) diff --git a/web/app/components/goto-anything/actions/commands/account.tsx b/web/app/components/goto-anything/actions/commands/account.tsx index 572d6a923d2e9f..396aaf45c42043 100644 --- a/web/app/components/goto-anything/actions/commands/account.tsx +++ b/web/app/components/goto-anything/actions/commands/account.tsx @@ -22,18 +22,23 @@ export const accountCommand: SlashCommandHandler<AccountDeps> = { async search(args: string, locale: string = 'en') { const i18n = getI18n() - return [{ - id: 'account', - title: i18n.t($ => $['account.account'], { ns: 'common', lng: locale }), - description: i18n.t($ => $['gotoAnything.actions.accountDesc'], { ns: 'app', lng: locale }), - type: 'command' as const, - icon: ( - <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> - <RiUser3Line className="size-4 text-text-tertiary" /> - </div> - ), - data: { command: 'navigation.account', args: {} }, - }] + return [ + { + id: 'account', + title: i18n.t(($) => $['account.account'], { ns: 'common', lng: locale }), + description: i18n.t(($) => $['gotoAnything.actions.accountDesc'], { + ns: 'app', + lng: locale, + }), + type: 'command' as const, + icon: ( + <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> + <RiUser3Line className="size-4 text-text-tertiary" /> + </div> + ), + data: { command: 'navigation.account', args: {} }, + }, + ] }, register(_deps: AccountDeps) { diff --git a/web/app/components/goto-anything/actions/commands/command-bus.ts b/web/app/components/goto-anything/actions/commands/command-bus.ts index 9252a6638f4477..a396ae312f742b 100644 --- a/web/app/components/goto-anything/actions/commands/command-bus.ts +++ b/web/app/components/goto-anything/actions/commands/command-bus.ts @@ -12,8 +12,7 @@ const unregisterCommand = (name: string) => { export const executeCommand = async (name: string, args?: Record<string, any>) => { const handler = handlers.get(name) - if (!handler) - return + if (!handler) return await handler(args) } diff --git a/web/app/components/goto-anything/actions/commands/community.tsx b/web/app/components/goto-anything/actions/commands/community.tsx index d9c686a1040f73..34f6ae06be0ab3 100644 --- a/web/app/components/goto-anything/actions/commands/community.tsx +++ b/web/app/components/goto-anything/actions/commands/community.tsx @@ -23,18 +23,22 @@ export const communityCommand: SlashCommandHandler<CommunityDeps> = { async search(args: string, locale: string = 'en') { const i18n = getI18n() - return [{ - id: 'community', - title: i18n.t($ => $['userProfile.community'], { ns: 'common', lng: locale }), - description: i18n.t($ => $['gotoAnything.actions.communityDesc'], { ns: 'app', lng: locale }) || 'Open Discord community', - type: 'command' as const, - icon: ( - <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> - <RiDiscordLine className="size-4 text-text-tertiary" /> - </div> - ), - data: { command: 'navigation.community', args: { url: 'https://discord.gg/5AEfbxcd9k' } }, - }] + return [ + { + id: 'community', + title: i18n.t(($) => $['userProfile.community'], { ns: 'common', lng: locale }), + description: + i18n.t(($) => $['gotoAnything.actions.communityDesc'], { ns: 'app', lng: locale }) || + 'Open Discord community', + type: 'command' as const, + icon: ( + <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> + <RiDiscordLine className="size-4 text-text-tertiary" /> + </div> + ), + data: { command: 'navigation.community', args: { url: 'https://discord.gg/5AEfbxcd9k' } }, + }, + ] }, register(_deps: CommunityDeps) { diff --git a/web/app/components/goto-anything/actions/commands/create.tsx b/web/app/components/goto-anything/actions/commands/create.tsx index 30a051f04d93f9..bd29d533d41481 100644 --- a/web/app/components/goto-anything/actions/commands/create.tsx +++ b/web/app/components/goto-anything/actions/commands/create.tsx @@ -79,7 +79,7 @@ export const createCommand: SlashCommandHandler = { async search(args: string, locale?: string) { const i18n = getI18n() const tr = (key: (typeof OPTIONS)[number]['titleKey' | 'descKey']) => - i18n.t($ => $[key], { ns: 'app', lng: locale }) + i18n.t(($) => $[key], { ns: 'app', lng: locale }) const renderIcon = (Icon: CreateOption['icon']) => ( <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> @@ -105,19 +105,20 @@ export const createCommand: SlashCommandHandler = { // submenu-filter behaviour, no instruction captured. if (tokens.length <= 1) { const query = trimmed.toLowerCase() - return OPTIONS - .filter(opt => !query || opt.id.includes(query) || tr(opt.titleKey).toLowerCase().includes(query)) - .map(opt => toResult(opt, '')) + return OPTIONS.filter( + (opt) => !query || opt.id.includes(query) || tr(opt.titleKey).toLowerCase().includes(query), + ).map((opt) => toResult(opt, '')) } // Multi-token: inline capture. If the first word names a mode, use it and // treat the rest as the instruction; otherwise keep every option with the // full text as the instruction so the user just picks the type. const first = tokens[0]!.toLowerCase() - const matched = OPTIONS.find(opt => opt.id === first || tr(opt.titleKey).toLowerCase() === first) - if (matched) - return [toResult(matched, tokens.slice(1).join(' '))] - return OPTIONS.map(opt => toResult(opt, trimmed)) + const matched = OPTIONS.find( + (opt) => opt.id === first || tr(opt.titleKey).toLowerCase() === first, + ) + if (matched) return [toResult(matched, tokens.slice(1).join(' '))] + return OPTIONS.map((opt) => toResult(opt, trimmed)) }, register() { @@ -135,8 +136,8 @@ export const createCommand: SlashCommandHandler = { // different from the open Studio, so applying to the current draft is // unsafe. const appDetail = useAppStore.getState().appDetail - const currentAppMode: WorkflowGeneratorMode | null - = appDetail?.mode === AppModeEnum.WORKFLOW + const currentAppMode: WorkflowGeneratorMode | null = + appDetail?.mode === AppModeEnum.WORKFLOW ? 'workflow' : appDetail?.mode === AppModeEnum.ADVANCED_CHAT ? 'advanced-chat' diff --git a/web/app/components/goto-anything/actions/commands/docs.tsx b/web/app/components/goto-anything/actions/commands/docs.tsx index 81fbfadc524967..12bcdf1b4e5244 100644 --- a/web/app/components/goto-anything/actions/commands/docs.tsx +++ b/web/app/components/goto-anything/actions/commands/docs.tsx @@ -31,18 +31,22 @@ export const docsCommand: SlashCommandHandler<DocDeps> = { async search(args: string, locale: string = 'en') { const i18n = getI18n() - return [{ - id: 'doc', - title: i18n.t($ => $['userProfile.helpCenter'], { ns: 'common', lng: locale }), - description: i18n.t($ => $['gotoAnything.actions.docDesc'], { ns: 'app', lng: locale }) || 'Open help documentation', - type: 'command' as const, - icon: ( - <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> - <RiBookOpenLine className="size-4 text-text-tertiary" /> - </div> - ), - data: { command: 'navigation.doc', args: {} }, - }] + return [ + { + id: 'doc', + title: i18n.t(($) => $['userProfile.helpCenter'], { ns: 'common', lng: locale }), + description: + i18n.t(($) => $['gotoAnything.actions.docDesc'], { ns: 'app', lng: locale }) || + 'Open help documentation', + type: 'command' as const, + icon: ( + <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> + <RiBookOpenLine className="size-4 text-text-tertiary" /> + </div> + ), + data: { command: 'navigation.doc', args: {} }, + }, + ] }, register(_deps: DocDeps) { diff --git a/web/app/components/goto-anything/actions/commands/forum.tsx b/web/app/components/goto-anything/actions/commands/forum.tsx index 181d2aa80a2106..861a67c370130f 100644 --- a/web/app/components/goto-anything/actions/commands/forum.tsx +++ b/web/app/components/goto-anything/actions/commands/forum.tsx @@ -23,18 +23,22 @@ export const forumCommand: SlashCommandHandler<ForumDeps> = { async search(args: string, locale: string = 'en') { const i18n = getI18n() - return [{ - id: 'forum', - title: i18n.t($ => $['userProfile.forum'], { ns: 'common', lng: locale }), - description: i18n.t($ => $['gotoAnything.actions.feedbackDesc'], { ns: 'app', lng: locale }) || 'Open community feedback discussions', - type: 'command' as const, - icon: ( - <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> - <RiFeedbackLine className="size-4 text-text-tertiary" /> - </div> - ), - data: { command: 'navigation.forum', args: { url: 'https://forum.dify.ai' } }, - }] + return [ + { + id: 'forum', + title: i18n.t(($) => $['userProfile.forum'], { ns: 'common', lng: locale }), + description: + i18n.t(($) => $['gotoAnything.actions.feedbackDesc'], { ns: 'app', lng: locale }) || + 'Open community feedback discussions', + type: 'command' as const, + icon: ( + <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> + <RiFeedbackLine className="size-4 text-text-tertiary" /> + </div> + ), + data: { command: 'navigation.forum', args: { url: 'https://forum.dify.ai' } }, + }, + ] }, register(_deps: ForumDeps) { diff --git a/web/app/components/goto-anything/actions/commands/go.tsx b/web/app/components/goto-anything/actions/commands/go.tsx index 095f1922767c6f..ce035daf212a91 100644 --- a/web/app/components/goto-anything/actions/commands/go.tsx +++ b/web/app/components/goto-anything/actions/commands/go.tsx @@ -31,9 +31,9 @@ export const goCommand: SlashCommandHandler = { async search(args: string, _locale: string = 'en') { const query = args.trim().toLowerCase() const items = NAV_ITEMS.filter( - item => !query || item.id.includes(query) || item.label.toLowerCase().includes(query), + (item) => !query || item.id.includes(query) || item.label.toLowerCase().includes(query), ) - return items.map(item => ({ + return items.map((item) => ({ id: `go-${item.id}`, title: item.label, description: item.path, @@ -50,8 +50,7 @@ export const goCommand: SlashCommandHandler = { register() { registerCommands({ 'navigation.go': async (args) => { - if (args?.path) - window.location.href = args.path + if (args?.path) window.location.href = args.path }, }) }, diff --git a/web/app/components/goto-anything/actions/commands/language.tsx b/web/app/components/goto-anything/actions/commands/language.tsx index dd295ba6385664..d180c10b92d5b8 100644 --- a/web/app/components/goto-anything/actions/commands/language.tsx +++ b/web/app/components/goto-anything/actions/commands/language.tsx @@ -11,14 +11,16 @@ type LanguageDeps = { const buildLanguageCommands = (query: string): CommandSearchResult[] => { const q = query.toLowerCase() - const list = languages.filter(item => item.supported && ( - !q || item.name.toLowerCase().includes(q) || String(item.value).toLowerCase().includes(q) - )) + const list = languages.filter( + (item) => + item.supported && + (!q || item.name.toLowerCase().includes(q) || String(item.value).toLowerCase().includes(q)), + ) const i18n = getI18n() - return list.map(item => ({ + return list.map((item) => ({ id: `lang-${item.value}`, title: item.name, - description: i18n.t($ => $['gotoAnything.actions.languageChangeDesc'], { ns: 'app' }), + description: i18n.t(($) => $['gotoAnything.actions.languageChangeDesc'], { ns: 'app' }), type: 'command' as const, data: { command: 'i18n.set', args: { locale: item.value } }, })) @@ -43,8 +45,7 @@ export const languageCommand: SlashCommandHandler<LanguageDeps> = { registerCommands({ 'i18n.set': async (args) => { const locale = args?.locale - if (locale) - await deps.setLocale?.(locale) + if (locale) await deps.setLocale?.(locale) }, }) }, diff --git a/web/app/components/goto-anything/actions/commands/refine.tsx b/web/app/components/goto-anything/actions/commands/refine.tsx index 7993661c0e92d4..2eb73f680dc1ba 100644 --- a/web/app/components/goto-anything/actions/commands/refine.tsx +++ b/web/app/components/goto-anything/actions/commands/refine.tsx @@ -16,10 +16,8 @@ import { registerCommands, unregisterCommands } from './command-bus' */ const currentStudioMode = (): WorkflowGeneratorMode | null => { const appMode = useAppStore.getState().appDetail?.mode - if (appMode === AppModeEnum.WORKFLOW) - return 'workflow' - if (appMode === AppModeEnum.ADVANCED_CHAT) - return 'advanced-chat' + if (appMode === AppModeEnum.WORKFLOW) return 'workflow' + if (appMode === AppModeEnum.ADVANCED_CHAT) return 'advanced-chat' return null } @@ -33,8 +31,7 @@ const currentStudioMode = (): WorkflowGeneratorMode | null => { const openRefineGenerator = () => { const appDetail = useAppStore.getState().appDetail const mode = currentStudioMode() - if (!appDetail || !mode) - return + if (!appDetail || !mode) return useWorkflowGeneratorStore.getState().openGenerator({ intent: 'refine', mode, @@ -65,18 +62,23 @@ export const refineCommand: SlashCommandHandler = { async search(_args: string, locale?: string) { const i18n = getI18n() - return [{ - id: 'refine-current', - title: i18n.t($ => $['gotoAnything.actions.refineTitle'], { ns: 'app', lng: locale }), - description: i18n.t($ => $['gotoAnything.actions.refineDesc'], { ns: 'app', lng: locale }), - type: 'command' as const, - icon: ( - <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> - <RiSparkling2Line className="size-4 text-text-tertiary" /> - </div> - ), - data: { command: 'refine.open', args: {} }, - }] + return [ + { + id: 'refine-current', + title: i18n.t(($) => $['gotoAnything.actions.refineTitle'], { ns: 'app', lng: locale }), + description: i18n.t(($) => $['gotoAnything.actions.refineDesc'], { + ns: 'app', + lng: locale, + }), + type: 'command' as const, + icon: ( + <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> + <RiSparkling2Line className="size-4 text-text-tertiary" /> + </div> + ), + data: { command: 'refine.open', args: {} }, + }, + ] }, register() { diff --git a/web/app/components/goto-anything/actions/commands/registry.ts b/web/app/components/goto-anything/actions/commands/registry.ts index 51beef4c0b40b8..bbc43a8c21b40d 100644 --- a/web/app/components/goto-anything/actions/commands/registry.ts +++ b/web/app/components/goto-anything/actions/commands/registry.ts @@ -70,8 +70,7 @@ export class SlashCommandRegistry { // First check if any alias starts with this const aliasMatch = this.findHandlerByAliasPrefix(lowerPartial) - if (aliasMatch && this.isCommandAvailable(aliasMatch)) - return aliasMatch + if (aliasMatch && this.isCommandAvailable(aliasMatch)) return aliasMatch // Then check if command name starts with this const nameMatch = this.findHandlerByNamePrefix(lowerPartial) @@ -83,8 +82,7 @@ export class SlashCommandRegistry { */ private findHandlerByAliasPrefix(prefix: string): SlashCommandHandler | undefined { for (const handler of this.getAllCommands()) { - if (handler.aliases?.some(alias => alias.toLowerCase().startsWith(prefix))) - return handler + if (handler.aliases?.some((alias) => alias.toLowerCase().startsWith(prefix))) return handler } return undefined } @@ -93,9 +91,7 @@ export class SlashCommandRegistry { * Find handler by name prefix */ private findHandlerByNamePrefix(prefix: string): SlashCommandHandler | undefined { - return this.getAllCommands().find(handler => - handler.name.toLowerCase().startsWith(prefix), - ) + return this.getAllCommands().find((handler) => handler.name.toLowerCase().startsWith(prefix)) } /** @@ -114,7 +110,7 @@ export class SlashCommandRegistry { * Commands without isAvailable method are considered always available */ getAvailableCommands(): SlashCommandHandler[] { - return this.getAllCommands().filter(handler => this.isCommandAvailable(handler)) + return this.getAllCommands().filter((handler) => this.isCommandAvailable(handler)) } /** @@ -126,8 +122,7 @@ export class SlashCommandRegistry { const trimmed = query.trim() // Handle root level search "/" - if (trimmed === '/' || !trimmed.replace('/', '').trim()) - return await this.getRootCommands() + if (trimmed === '/' || !trimmed.replace('/', '').trim()) return await this.getRootCommands() // Parse command and arguments const afterSlash = trimmed.substring(1).trim() @@ -140,8 +135,7 @@ export class SlashCommandRegistry { if (handler && this.isCommandAvailable(handler)) { try { return await handler.search(args, locale) - } - catch (error) { + } catch (error) { console.warn(`Command search failed for ${commandName}:`, error) return [] } @@ -152,8 +146,7 @@ export class SlashCommandRegistry { if (handler && this.isCommandAvailable(handler)) { try { return await handler.search(args, locale) - } - catch (error) { + } catch (error) { console.warn(`Command search failed for ${handler.name}:`, error) return [] } @@ -168,7 +161,7 @@ export class SlashCommandRegistry { * Only shows commands that are available in current context */ private async getRootCommands(): Promise<CommandSearchResult[]> { - return this.getAvailableCommands().map(handler => ({ + return this.getAvailableCommands().map((handler) => ({ id: `root-${handler.name}`, title: `/${handler.name}`, description: handler.description, diff --git a/web/app/components/goto-anything/actions/commands/slash-provider.tsx b/web/app/components/goto-anything/actions/commands/slash-provider.tsx index 59ecf22f542aa4..c4b50095384995 100644 --- a/web/app/components/goto-anything/actions/commands/slash-provider.tsx +++ b/web/app/components/goto-anything/actions/commands/slash-provider.tsx @@ -21,7 +21,9 @@ type SlashCommandDeps = { const registerSlashCommands = (deps: SlashCommandDeps) => { slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme }) - slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale as (locale: string) => Promise<void> }) + slashCommandRegistry.register(languageCommand, { + setLocale: deps.setLocale as (locale: string) => Promise<void>, + }) slashCommandRegistry.register(forumCommand, {}) slashCommandRegistry.register(docsCommand, {}) slashCommandRegistry.register(communityCommand, {}) diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index 9e68ea7c64ebf7..5fb2ed678a2630 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -8,15 +8,14 @@ export const slashAction: ActionItem = { shortcut: '/', get title() { const i18n = getI18n() - return i18n.t($ => $['gotoAnything.actions.slashTitle'], { ns: 'app' }) + return i18n.t(($) => $['gotoAnything.actions.slashTitle'], { ns: 'app' }) }, get description() { const i18n = getI18n() - return i18n.t($ => $['gotoAnything.actions.slashDesc'], { ns: 'app' }) + return i18n.t(($) => $['gotoAnything.actions.slashDesc'], { ns: 'app' }) }, action: (result) => { - if (result.type !== 'command') - return + if (result.type !== 'command') return const { command, args } = result.data executeCommand(command, args) }, diff --git a/web/app/components/goto-anything/actions/commands/theme.tsx b/web/app/components/goto-anything/actions/commands/theme.tsx index 660123633d0d9f..63b74c0a19e05b 100644 --- a/web/app/components/goto-anything/actions/commands/theme.tsx +++ b/web/app/components/goto-anything/actions/commands/theme.tsx @@ -34,15 +34,19 @@ const THEME_ITEMS = [ const buildThemeCommands = (query: string, locale?: string): CommandSearchResult[] => { const i18n = getI18n() const q = query.toLowerCase() - const list = THEME_ITEMS.filter(item => - !q - || i18n.t($ => $[item.titleKey], { ns: 'app', lng: locale }).toLowerCase().includes(q) - || item.id.includes(q), + const list = THEME_ITEMS.filter( + (item) => + !q || + i18n + .t(($) => $[item.titleKey], { ns: 'app', lng: locale }) + .toLowerCase() + .includes(q) || + item.id.includes(q), ) - return list.map(item => ({ + return list.map((item) => ({ id: item.id, - title: i18n.t($ => $[item.titleKey], { ns: 'app', lng: locale }), - description: i18n.t($ => $[item.descKey], { ns: 'app', lng: locale }), + title: i18n.t(($) => $[item.titleKey], { ns: 'app', lng: locale }), + description: i18n.t(($) => $[item.descKey], { ns: 'app', lng: locale }), type: 'command' as const, icon: ( <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index b7e7ef4cabeea4..4a5a198c1dfa2d 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -187,8 +187,7 @@ export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolea ...baseActions, node: ragPipelineNodesAction, } - } - else if (isWorkflowPage) { + } else if (isWorkflowPage) { return { ...baseActions, node: workflowNodesAction, @@ -218,31 +217,30 @@ export const searchAnything = async ( if (actionItem) { const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const prefixPattern = new RegExp(`^(${escapeRegExp(actionItem.key)}|${escapeRegExp(actionItem.shortcut)})\\s*`) + const prefixPattern = new RegExp( + `^(${escapeRegExp(actionItem.key)}|${escapeRegExp(actionItem.shortcut)})\\s*`, + ) const searchTerm = trimmedQuery.replace(prefixPattern, '').trim() try { return await actionItem.search(query, searchTerm, locale) - } - catch (error) { + } catch (error) { console.warn(`Search failed for ${actionItem.key}:`, error) return [] } } - if (trimmedQuery.startsWith('@') || trimmedQuery.startsWith('/')) - return [] + if (trimmedQuery.startsWith('@') || trimmedQuery.startsWith('/')) return [] const globalSearchActions = Object.values(dynamicActions || Actions) // Exclude slash commands from general search results - .filter(action => action.key !== '/') + .filter((action) => action.key !== '/') // Use Promise.allSettled to handle partial failures gracefully const searchPromises = globalSearchActions.map(async (action) => { try { const results = await action.search(query, query, locale) return { success: true, data: results, actionType: action.key } - } - catch (error) { + } catch (error) { console.warn(`Search failed for ${action.key}:`, error) return { success: false, data: [], actionType: action.key, error } } @@ -256,8 +254,7 @@ export const searchAnything = async ( settledResults.forEach((result, index) => { if (result.status === 'fulfilled' && result.value.success) { allResults.push(...result.value.data) - } - else { + } else { const actionKey = globalSearchActions[index]?.key || 'unknown' failedActions.push(actionKey) } @@ -281,8 +278,7 @@ export const matchAction = (query: string, actions: Record<string, ActionItem>) const cmdPattern = `/${cmd.name}` // For direct mode commands, don't match (keep in command selector) - if (cmd.mode === 'direct') - return false + if (cmd.mode === 'direct') return false // For submenu mode commands, match when complete command is entered return query === cmdPattern || query.startsWith(`${cmdPattern} `) diff --git a/web/app/components/goto-anything/actions/knowledge.tsx b/web/app/components/goto-anything/actions/knowledge.tsx index 6b3d5ca78d0f87..ff5770ab44d708 100644 --- a/web/app/components/goto-anything/actions/knowledge.tsx +++ b/web/app/components/goto-anything/actions/knowledge.tsx @@ -9,7 +9,9 @@ const isExternalProvider = (provider: string): boolean => provider === EXTERNAL_ const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => { return datasets.map((dataset) => { - const path = isExternalProvider(dataset.provider) ? `/datasets/${dataset.id}/hitTesting` : `/datasets/${dataset.id}/documents` + const path = isExternalProvider(dataset.provider) + ? `/datasets/${dataset.id}/hitTesting` + : `/datasets/${dataset.id}/documents` return { id: dataset.id, title: dataset.name, @@ -17,10 +19,11 @@ const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => { type: 'knowledge' as const, path, icon: ( - <div className={cn( - 'flex shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#E0EAFF] bg-[#F5F8FF] p-2.5', - !dataset.embedding_available && 'opacity-50 hover:opacity-100', - )} + <div + className={cn( + 'flex shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#E0EAFF] bg-[#F5F8FF] p-2.5', + !dataset.embedding_available && 'opacity-50 hover:opacity-100', + )} > <Folder className="h-5 w-5 text-[#444CE7]" /> </div> @@ -48,8 +51,7 @@ export const knowledgeAction: ActionItem = { }) const datasets = response?.data || [] return parser(datasets) - } - catch (error) { + } catch (error) { console.warn('Knowledge search failed:', error) return [] } diff --git a/web/app/components/goto-anything/actions/plugin.tsx b/web/app/components/goto-anything/actions/plugin.tsx index 078666020a4264..9038d318190ba2 100644 --- a/web/app/components/goto-anything/actions/plugin.tsx +++ b/web/app/components/goto-anything/actions/plugin.tsx @@ -26,24 +26,26 @@ export const pluginAction: ActionItem = { description: 'Search and navigate to your plugins', search: async (_, searchTerm = '', locale) => { try { - const response = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>('/plugins/search/advanced', { - body: { - page: 1, - page_size: 10, - query: searchTerm, - type: 'plugin', + const response = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>( + '/plugins/search/advanced', + { + body: { + page: 1, + page_size: 10, + query: searchTerm, + type: 'plugin', + }, }, - }) + ) if (!response?.data?.plugins) { console.warn('Plugin search: Unexpected response structure', response) return [] } - const list = response.data.plugins.map(plugin => getFormattedPlugin(plugin)) + const list = response.data.plugins.map((plugin) => getFormattedPlugin(plugin)) return parser(list, locale!) - } - catch (error) { + } catch (error) { console.warn('Plugin search failed:', error) return [] } diff --git a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx index dc632e4999ae77..d9c03c5824cc32 100644 --- a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx +++ b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx @@ -10,13 +10,11 @@ export const ragPipelineNodesAction: ActionItem = { search: async (_, searchTerm = '', _locale) => { try { // Use the searchFn if available (set by useRagPipelineSearch hook) - if (ragPipelineNodesAction.searchFn) - return ragPipelineNodesAction.searchFn(searchTerm) + if (ragPipelineNodesAction.searchFn) return ragPipelineNodesAction.searchFn(searchTerm) // If not in RAG pipeline context, return empty array return [] - } - catch (error) { + } catch (error) { console.warn('RAG pipeline nodes search failed:', error) return [] } diff --git a/web/app/components/goto-anything/actions/recent-store.ts b/web/app/components/goto-anything/actions/recent-store.ts index 0946818091753f..966825f7f85ece 100644 --- a/web/app/components/goto-anything/actions/recent-store.ts +++ b/web/app/components/goto-anything/actions/recent-store.ts @@ -4,8 +4,7 @@ const MAX_RECENT_ITEMS = 8 export function getRecentItems() { try { const stored = localStorage.getItem(RECENT_ITEMS_KEY) - if (!stored) - return [] + if (!stored) return [] return JSON.parse(stored) as Array<{ id: string title: string @@ -13,8 +12,7 @@ export function getRecentItems() { path: string originalType: 'app' | 'knowledge' }> - } - catch { + } catch { return [] } } @@ -22,9 +20,8 @@ export function getRecentItems() { export function addRecentItem(item: ReturnType<typeof getRecentItems>[number]): void { try { const recent = getRecentItems() - const filtered = recent.filter(r => r.id !== item.id) + const filtered = recent.filter((r) => r.id !== item.id) const updated = [item, ...filtered].slice(0, MAX_RECENT_ITEMS) localStorage.setItem(RECENT_ITEMS_KEY, JSON.stringify(updated)) - } - catch {} + } catch {} } diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index da3ca8eb628203..b03b2e2773b4e4 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -39,14 +39,20 @@ type WorkflowNodeSearchResult = { export type CommandSearchResult = { type: 'command' -} & BaseSearchResult<{ command: string, args?: Record<string, any> }> +} & BaseSearchResult<{ command: string; args?: Record<string, any> }> export type RecentSearchResult = { type: 'recent' originalType: 'app' | 'knowledge' } & BaseSearchResult<{ path: string }> -export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult | RecentSearchResult +export type SearchResult = + | AppSearchResult + | PluginSearchResult + | KnowledgeSearchResult + | WorkflowNodeSearchResult + | CommandSearchResult + | RecentSearchResult export type ActionItem = { key: '@app' | '@knowledge' | '@plugin' | '@node' | '/' @@ -59,5 +65,5 @@ export type ActionItem = { query: string, searchTerm: string, locale?: string, - ) => (Promise<SearchResult[]> | SearchResult[]) + ) => Promise<SearchResult[]> | SearchResult[] } diff --git a/web/app/components/goto-anything/actions/workflow-nodes.tsx b/web/app/components/goto-anything/actions/workflow-nodes.tsx index b9aa61705bef85..a641b721a9da0e 100644 --- a/web/app/components/goto-anything/actions/workflow-nodes.tsx +++ b/web/app/components/goto-anything/actions/workflow-nodes.tsx @@ -10,13 +10,11 @@ export const workflowNodesAction: ActionItem = { search: async (_, searchTerm = '', _locale) => { try { // Use the searchFn if available (set by useWorkflowSearch hook) - if (workflowNodesAction.searchFn) - return workflowNodesAction.searchFn(searchTerm) + if (workflowNodesAction.searchFn) return workflowNodesAction.searchFn(searchTerm) // If not in workflow context, return empty array return [] - } - catch (error) { + } catch (error) { console.warn('Workflow nodes search failed:', error) return [] } diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index ef9670251b848e..30b7584bd92445 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -32,7 +32,14 @@ const actionDescriptionKeys = { '@node': 'gotoAnything.actions.searchWorkflowNodesDesc', } as const -const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, commandValue, onCommandValueChange, originalQuery }) => { +const CommandSelector: FC<Props> = ({ + actions, + onCommandSelect, + searchFilter, + commandValue, + onCommandValueChange, + originalQuery, +}) => { const { t } = useTranslation() // Check if we're in slash command mode @@ -40,34 +47,31 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co // Get slash commands from registry const slashCommands = useMemo(() => { - if (!isSlashMode) - return [] + if (!isSlashMode) return [] const availableCommands = slashCommandRegistry.getAvailableCommands() const filter = searchFilter?.toLowerCase() || '' // searchFilter already has '/' removed - return availableCommands.filter((cmd) => { - if (!filter) - return true - return cmd.name.toLowerCase().includes(filter) - }).map(cmd => ({ - key: `/${cmd.name}`, - shortcut: `/${cmd.name}`, - title: cmd.name, - description: cmd.description, - })) + return availableCommands + .filter((cmd) => { + if (!filter) return true + return cmd.name.toLowerCase().includes(filter) + }) + .map((cmd) => ({ + key: `/${cmd.name}`, + shortcut: `/${cmd.name}`, + title: cmd.name, + description: cmd.description, + })) }, [isSlashMode, searchFilter]) const filteredActions = useMemo(() => { - if (isSlashMode) - return [] + if (isSlashMode) return [] return Object.values(actions).filter((action) => { // Exclude slash action when in @ mode - if (action.key === '/') - return false - if (!searchFilter) - return true + if (action.key === '/') return false + if (!searchFilter) return true const filterLower = searchFilter.toLowerCase() return action.shortcut.toLowerCase().includes(filterLower) }) @@ -77,9 +81,8 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co useEffect(() => { if (allItems.length > 0 && onCommandValueChange) { - const currentValueExists = allItems.some(item => item.shortcut === commandValue) - if (!currentValueExists) - onCommandValueChange(allItems[0]!.shortcut) + const currentValueExists = allItems.some((item) => item.shortcut === commandValue) + if (!currentValueExists) onCommandValueChange(allItems[0]!.shortcut) } }, [allItems, commandValue, onCommandValueChange]) @@ -89,10 +92,10 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co <div className="flex items-center justify-center py-8 text-center text-text-tertiary"> <div> <div className="text-sm font-medium text-text-tertiary"> - {t($ => $['gotoAnything.noMatchingCommands'], { ns: 'app' })} + {t(($) => $['gotoAnything.noMatchingCommands'], { ns: 'app' })} </div> <div className="mt-1 text-xs text-text-quaternary"> - {t($ => $['gotoAnything.tryDifferentSearch'], { ns: 'app' })} + {t(($) => $['gotoAnything.tryDifferentSearch'], { ns: 'app' })} </div> </div> </div> @@ -103,17 +106,16 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co return ( <div className="px-4 py-3"> <div className="mb-2 text-left text-sm font-medium text-text-secondary"> - {isSlashMode ? t($ => $['gotoAnything.groups.commands'], { ns: 'app' }) : t($ => $['gotoAnything.selectSearchType'], { ns: 'app' })} + {isSlashMode + ? t(($) => $['gotoAnything.groups.commands'], { ns: 'app' }) + : t(($) => $['gotoAnything.selectSearchType'], { ns: 'app' })} </div> <Command.Group className="space-y-1"> - {allItems.map(item => ( + {allItems.map((item) => ( <Command.Item key={item.key} value={item.shortcut} - className="flex cursor-pointer items-center rounded-md - p-2 - transition-all - duration-150 hover:bg-state-base-hover aria-selected:bg-state-base-hover-alt" + className="flex cursor-pointer items-center rounded-md p-2 transition-all duration-150 hover:bg-state-base-hover aria-selected:bg-state-base-hover-alt" onSelect={() => onCommandSelect(item.shortcut)} > <span className="min-w-18 text-left font-mono text-xs text-text-tertiary"> @@ -121,8 +123,19 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co </span> <span className="ml-3 text-sm text-text-secondary"> {isSlashMode - ? t($ => $[slashCommandDescriptionKeys[item.key as keyof typeof slashCommandDescriptionKeys] || item.description], { ns: 'app' }) - : t($ => $[actionDescriptionKeys[item.key as keyof typeof actionDescriptionKeys]], { ns: 'app' })} + ? t( + ($) => + $[ + slashCommandDescriptionKeys[ + item.key as keyof typeof slashCommandDescriptionKeys + ] || item.description + ], + { ns: 'app' }, + ) + : t( + ($) => $[actionDescriptionKeys[item.key as keyof typeof actionDescriptionKeys]], + { ns: 'app' }, + )} </span> </Command.Item> ))} diff --git a/web/app/components/goto-anything/components/__tests__/empty-state.spec.tsx b/web/app/components/goto-anything/components/__tests__/empty-state.spec.tsx index 8921f5b89722c0..aab213dde55082 100644 --- a/web/app/components/goto-anything/components/__tests__/empty-state.spec.tsx +++ b/web/app/components/goto-anything/components/__tests__/empty-state.spec.tsx @@ -79,7 +79,11 @@ describe('EmptyState', () => { } as unknown as Record<string, import('../../actions/types').ActionItem> render(<EmptyState variant="no-results" searchMode="general" Actions={Actions} />) - expect(screen.getByText('app.gotoAnything.emptyState.trySpecificSearch:{"shortcuts":"@app, @plugin"}')).toBeInTheDocument() + expect( + screen.getByText( + 'app.gotoAnything.emptyState.trySpecificSearch:{"shortcuts":"@app, @plugin"}', + ), + ).toBeInTheDocument() }) }) @@ -109,7 +113,9 @@ describe('EmptyState', () => { it('should render no knowledge bases found message', () => { render(<EmptyState variant="no-results" searchMode="@knowledge" />) - expect(screen.getByText('app.gotoAnything.emptyState.noKnowledgeBasesFound')).toBeInTheDocument() + expect( + screen.getByText('app.gotoAnything.emptyState.noKnowledgeBasesFound'), + ).toBeInTheDocument() }) }) @@ -117,7 +123,9 @@ describe('EmptyState', () => { it('should render no workflow nodes found message', () => { render(<EmptyState variant="no-results" searchMode="@node" />) - expect(screen.getByText('app.gotoAnything.emptyState.noWorkflowNodesFound')).toBeInTheDocument() + expect( + screen.getByText('app.gotoAnything.emptyState.noWorkflowNodesFound'), + ).toBeInTheDocument() }) }) @@ -140,7 +148,9 @@ describe('EmptyState', () => { it('should use empty object as default Actions', () => { render(<EmptyState variant="no-results" searchMode="general" />) - expect(screen.getByText('app.gotoAnything.emptyState.trySpecificSearch:{"shortcuts":""}')).toBeInTheDocument() + expect( + screen.getByText('app.gotoAnything.emptyState.trySpecificSearch:{"shortcuts":""}'), + ).toBeInTheDocument() }) }) }) diff --git a/web/app/components/goto-anything/components/__tests__/result-item.spec.tsx b/web/app/components/goto-anything/components/__tests__/result-item.spec.tsx index 068e5db3e70869..d3f42069e9587e 100644 --- a/web/app/components/goto-anything/components/__tests__/result-item.spec.tsx +++ b/web/app/components/goto-anything/components/__tests__/result-item.spec.tsx @@ -29,10 +29,7 @@ describe('ResultItem', () => { it('renders description when provided', () => { renderInCommandRoot( - <ResultItem - result={createResult({ description: 'A great app' })} - onSelect={vi.fn()} - />, + <ResultItem result={createResult({ description: 'A great app' })} onSelect={vi.fn()} />, ) expect(screen.getByText('A great app')).toBeInTheDocument() @@ -42,27 +39,21 @@ describe('ResultItem', () => { const result = createResult() delete (result as Record<string, unknown>).description - renderInCommandRoot( - <ResultItem result={result} onSelect={vi.fn()} />, - ) + renderInCommandRoot(<ResultItem result={result} onSelect={vi.fn()} />) expect(screen.getByText('Test Result')).toBeInTheDocument() expect(screen.getByText('app')).toBeInTheDocument() }) it('renders result type label', () => { - renderInCommandRoot( - <ResultItem result={createResult({ type: 'plugin' })} onSelect={vi.fn()} />, - ) + renderInCommandRoot(<ResultItem result={createResult({ type: 'plugin' })} onSelect={vi.fn()} />) expect(screen.getByText('plugin')).toBeInTheDocument() }) it('renders icon when provided', () => { const icon = <span data-testid="custom-icon">icon</span> - renderInCommandRoot( - <ResultItem result={createResult({ icon })} onSelect={vi.fn()} />, - ) + renderInCommandRoot(<ResultItem result={createResult({ icon })} onSelect={vi.fn()} />) expect(screen.getByTestId('custom-icon')).toBeInTheDocument() }) @@ -71,9 +62,7 @@ describe('ResultItem', () => { const user = userEvent.setup() const onSelect = vi.fn() - renderInCommandRoot( - <ResultItem result={createResult()} onSelect={onSelect} />, - ) + renderInCommandRoot(<ResultItem result={createResult()} onSelect={onSelect} />) await user.click(screen.getByText('Test Result')) diff --git a/web/app/components/goto-anything/components/__tests__/result-list.spec.tsx b/web/app/components/goto-anything/components/__tests__/result-list.spec.tsx index 746e6110b8ccf7..1405244036fafc 100644 --- a/web/app/components/goto-anything/components/__tests__/result-list.spec.tsx +++ b/web/app/components/goto-anything/components/__tests__/result-list.spec.tsx @@ -25,9 +25,7 @@ describe('ResultList', () => { plugin: [createResult({ id: 'p1', title: 'Plugin One', type: 'plugin' })], } - renderInCommandRoot( - <ResultList groupedResults={grouped} onSelect={vi.fn()} />, - ) + renderInCommandRoot(<ResultList groupedResults={grouped} onSelect={vi.fn()} />) expect(screen.getByText('App One')).toBeInTheDocument() expect(screen.getByText('Plugin One')).toBeInTheDocument() @@ -41,9 +39,7 @@ describe('ResultList', () => { ], } - renderInCommandRoot( - <ResultList groupedResults={grouped} onSelect={vi.fn()} />, - ) + renderInCommandRoot(<ResultList groupedResults={grouped} onSelect={vi.fn()} />) expect(screen.getByText('App One')).toBeInTheDocument() expect(screen.getByText('App Two')).toBeInTheDocument() @@ -54,9 +50,7 @@ describe('ResultList', () => { const onSelect = vi.fn() const result = createResult({ id: 'a1', title: 'Click Me', type: 'app' }) - renderInCommandRoot( - <ResultList groupedResults={{ app: [result] }} onSelect={onSelect} />, - ) + renderInCommandRoot(<ResultList groupedResults={{ app: [result] }} onSelect={onSelect} />) await user.click(screen.getByText('Click Me')) @@ -64,9 +58,7 @@ describe('ResultList', () => { }) it('renders empty when no grouped results provided', () => { - const { container } = renderInCommandRoot( - <ResultList groupedResults={{}} onSelect={vi.fn()} />, - ) + const { container } = renderInCommandRoot(<ResultList groupedResults={{}} onSelect={vi.fn()} />) const groups = container.querySelectorAll('[cmdk-group]') expect(groups).toHaveLength(0) @@ -77,9 +69,7 @@ describe('ResultList', () => { command: [createResult({ id: 'c1', title: 'Cmd', type: 'command' })], } - renderInCommandRoot( - <ResultList groupedResults={grouped} onSelect={vi.fn()} />, - ) + renderInCommandRoot(<ResultList groupedResults={grouped} onSelect={vi.fn()} />) expect(screen.getByText('app.gotoAnything.groups.commands')).toBeInTheDocument() }) diff --git a/web/app/components/goto-anything/components/empty-state.tsx b/web/app/components/goto-anything/components/empty-state.tsx index f22d1e60a45865..ad70c5cb605be4 100644 --- a/web/app/components/goto-anything/components/empty-state.tsx +++ b/web/app/components/goto-anything/components/empty-state.tsx @@ -26,7 +26,7 @@ const EmptyState: FC<EmptyStateProps> = ({ <div className="flex items-center justify-center py-8 text-center text-text-tertiary"> <div className="flex items-center gap-2"> <div className="size-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600"></div> - <span className="text-sm">{t($ => $['gotoAnything.searching'], { ns: 'app' })}</span> + <span className="text-sm">{t(($) => $['gotoAnything.searching'], { ns: 'app' })}</span> </div> </div> ) @@ -38,11 +38,12 @@ const EmptyState: FC<EmptyStateProps> = ({ <div> <div className="text-sm font-medium text-red-500"> {error?.message - ? t($ => $['gotoAnything.searchFailed'], { ns: 'app' }) - : t($ => $['gotoAnything.searchTemporarilyUnavailable'], { ns: 'app' })} + ? t(($) => $['gotoAnything.searchFailed'], { ns: 'app' }) + : t(($) => $['gotoAnything.searchTemporarilyUnavailable'], { ns: 'app' })} </div> <div className="mt-1 text-xs text-text-quaternary"> - {error?.message || t($ => $['gotoAnything.servicesUnavailableMessage'], { ns: 'app' })} + {error?.message || + t(($) => $['gotoAnything.servicesUnavailableMessage'], { ns: 'app' })} </div> </div> </div> @@ -53,11 +54,13 @@ const EmptyState: FC<EmptyStateProps> = ({ return ( <div className="flex items-center justify-center py-8 text-center text-text-tertiary"> <div> - <div className="text-sm font-medium">{t($ => $['gotoAnything.searchTitle'], { ns: 'app' })}</div> + <div className="text-sm font-medium"> + {t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })} + </div> <div className="mt-3 space-y-1 text-xs text-text-quaternary"> - <div>{t($ => $['gotoAnything.searchHint'], { ns: 'app' })}</div> - <div>{t($ => $['gotoAnything.commandHint'], { ns: 'app' })}</div> - <div>{t($ => $['gotoAnything.slashHint'], { ns: 'app' })}</div> + <div>{t(($) => $['gotoAnything.searchHint'], { ns: 'app' })}</div> + <div>{t(($) => $['gotoAnything.commandHint'], { ns: 'app' })}</div> + <div>{t(($) => $['gotoAnything.slashHint'], { ns: 'app' })}</div> </div> </div> </div> @@ -70,7 +73,7 @@ const EmptyState: FC<EmptyStateProps> = ({ const getNoResultsMessage = () => { if (!isCommandSearch) { - return t($ => $['gotoAnything.noResults'], { ns: 'app' }) + return t(($) => $['gotoAnything.noResults'], { ns: 'app' }) } const keyMap = { @@ -80,16 +83,20 @@ const EmptyState: FC<EmptyStateProps> = ({ node: 'gotoAnything.emptyState.noWorkflowNodesFound', } as const - return t($ => $[keyMap[commandType as keyof typeof keyMap] || 'gotoAnything.noResults'], { ns: 'app' }) + return t(($) => $[keyMap[commandType as keyof typeof keyMap] || 'gotoAnything.noResults'], { + ns: 'app', + }) } const getHintMessage = () => { if (isCommandSearch) { - return t($ => $['gotoAnything.emptyState.tryDifferentTerm'], { ns: 'app' }) + return t(($) => $['gotoAnything.emptyState.tryDifferentTerm'], { ns: 'app' }) } - const shortcuts = Object.values(Actions).map(action => action.shortcut).join(', ') - return t($ => $['gotoAnything.emptyState.trySpecificSearch'], { ns: 'app', shortcuts }) + const shortcuts = Object.values(Actions) + .map((action) => action.shortcut) + .join(', ') + return t(($) => $['gotoAnything.emptyState.trySpecificSearch'], { ns: 'app', shortcuts }) } return ( diff --git a/web/app/components/goto-anything/components/footer.tsx b/web/app/components/goto-anything/components/footer.tsx index 4d1c525a5ead56..0eaf005e7ded42 100644 --- a/web/app/components/goto-anything/components/footer.tsx +++ b/web/app/components/goto-anything/components/footer.tsx @@ -25,17 +25,20 @@ const Footer: FC<FooterProps> = ({ if (isError) { return ( <span className="text-red-500"> - {t($ => $['gotoAnything.someServicesUnavailable'], { ns: 'app' })} + {t(($) => $['gotoAnything.someServicesUnavailable'], { ns: 'app' })} </span> ) } return ( <> - {t($ => $['gotoAnything.resultCount'], { ns: 'app', count: resultCount })} + {t(($) => $['gotoAnything.resultCount'], { ns: 'app', count: resultCount })} {searchMode !== 'general' && ( <span className="ml-2 opacity-60"> - {t($ => $['gotoAnything.inScope'], { ns: 'app', scope: searchMode.replace('@', '') })} + {t(($) => $['gotoAnything.inScope'], { + ns: 'app', + scope: searchMode.replace('@', ''), + })} </span> )} </> @@ -45,13 +48,11 @@ const Footer: FC<FooterProps> = ({ return ( <span className="opacity-60"> {(() => { - if (isCommandsMode) - return t($ => $['gotoAnything.selectToNavigate'], { ns: 'app' }) + if (isCommandsMode) return t(($) => $['gotoAnything.selectToNavigate'], { ns: 'app' }) - if (hasQuery) - return t($ => $['gotoAnything.searching'], { ns: 'app' }) + if (hasQuery) return t(($) => $['gotoAnything.searching'], { ns: 'app' }) - return t($ => $['gotoAnything.startTyping'], { ns: 'app' }) + return t(($) => $['gotoAnything.startTyping'], { ns: 'app' }) })()} </span> ) @@ -62,8 +63,8 @@ const Footer: FC<FooterProps> = ({ return ( <span className="opacity-60"> {searchMode !== 'general' - ? t($ => $['gotoAnything.clearToSearchAll'], { ns: 'app' }) - : t($ => $['gotoAnything.useAtForSpecific'], { ns: 'app' })} + ? t(($) => $['gotoAnything.clearToSearchAll'], { ns: 'app' }) + : t(($) => $['gotoAnything.useAtForSpecific'], { ns: 'app' })} </span> ) } @@ -71,8 +72,8 @@ const Footer: FC<FooterProps> = ({ return ( <span className="opacity-60"> {hasQuery || isCommandsMode - ? t($ => $['gotoAnything.tips'], { ns: 'app' }) - : t($ => $['gotoAnything.pressEscToClose'], { ns: 'app' })} + ? t(($) => $['gotoAnything.tips'], { ns: 'app' }) + : t(($) => $['gotoAnything.pressEscToClose'], { ns: 'app' })} </span> ) } diff --git a/web/app/components/goto-anything/components/result-item.tsx b/web/app/components/goto-anything/components/result-item.tsx index 1739486ccc81d0..413382c2691ccf 100644 --- a/web/app/components/goto-anything/components/result-item.tsx +++ b/web/app/components/goto-anything/components/result-item.tsx @@ -19,18 +19,12 @@ const ResultItem: FC<ResultItemProps> = ({ result, onSelect }) => { > {result.icon} <div className="min-w-0 flex-1"> - <div className="truncate font-medium text-text-secondary"> - {result.title} - </div> + <div className="truncate font-medium text-text-secondary">{result.title}</div> {result.description && ( - <div className="mt-0.5 truncate text-xs text-text-quaternary"> - {result.description} - </div> + <div className="mt-0.5 truncate text-xs text-text-quaternary">{result.description}</div> )} </div> - <div className="text-xs text-text-quaternary capitalize"> - {result.type} - </div> + <div className="text-xs text-text-quaternary capitalize">{result.type}</div> </Command.Item> ) } diff --git a/web/app/components/goto-anything/components/result-list.tsx b/web/app/components/goto-anything/components/result-list.tsx index e5114452060b12..ca113e1b5dbe14 100644 --- a/web/app/components/goto-anything/components/result-list.tsx +++ b/web/app/components/goto-anything/components/result-list.tsx @@ -16,14 +16,14 @@ const ResultList: FC<ResultListProps> = ({ groupedResults, onSelect }) => { const getGroupHeading = (type: string) => { const typeMap = { - 'app': 'gotoAnything.groups.apps', - 'plugin': 'gotoAnything.groups.plugins', - 'knowledge': 'gotoAnything.groups.knowledgeBases', + app: 'gotoAnything.groups.apps', + plugin: 'gotoAnything.groups.plugins', + knowledge: 'gotoAnything.groups.knowledgeBases', 'workflow-node': 'gotoAnything.groups.workflowNodes', - 'command': 'gotoAnything.groups.commands', - 'recent': 'gotoAnything.groups.recent', + command: 'gotoAnything.groups.commands', + recent: 'gotoAnything.groups.recent', } as const - return t($ => $[typeMap[type as keyof typeof typeMap] || `${type}s`], { ns: 'app' }) + return t(($) => $[typeMap[type as keyof typeof typeMap] || `${type}s`], { ns: 'app' }) } return ( @@ -34,7 +34,7 @@ const ResultList: FC<ResultListProps> = ({ groupedResults, onSelect }) => { heading={getGroupHeading(type)} className="p-2 text-text-secondary capitalize" > - {results.map(result => ( + {results.map((result) => ( <ResultItem key={`${result.type}-${result.id}`} result={result} diff --git a/web/app/components/goto-anything/components/search-input.tsx b/web/app/components/goto-anything/components/search-input.tsx index 8b9453c945c9a7..e1ac951ce31e23 100644 --- a/web/app/components/goto-anything/components/search-input.tsx +++ b/web/app/components/goto-anything/components/search-input.tsx @@ -27,12 +27,9 @@ const SearchInput: FC<SearchInputProps> = ({ const { t } = useTranslation() const getModeLabel = () => { - if (searchMode === 'scopes') - return 'SCOPES' - else if (searchMode === 'commands') - return 'COMMANDS' - else - return searchMode.replace('@', '').toUpperCase() + if (searchMode === 'scopes') return 'SCOPES' + else if (searchMode === 'commands') return 'COMMANDS' + else return searchMode.replace('@', '').toUpperCase() } return ( @@ -42,8 +39,8 @@ const SearchInput: FC<SearchInputProps> = ({ <Input ref={inputRef} value={value} - placeholder={placeholder || t($ => $['gotoAnything.searchPlaceholder'], { ns: 'app' })} - onChange={e => onChange(e.target.value)} + placeholder={placeholder || t(($) => $['gotoAnything.searchPlaceholder'], { ns: 'app' })} + onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown} className="flex-1 border-0! bg-transparent! shadow-none!" wrapperClassName="flex-1 border-0! bg-transparent!" @@ -56,7 +53,7 @@ const SearchInput: FC<SearchInputProps> = ({ )} </div> <KbdGroup> - {['Mod', 'K'].map(key => ( + {['Mod', 'K'].map((key) => ( <Kbd key={key}>{formatForDisplay(key)}</Kbd> ))} </KbdGroup> diff --git a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts index 67af966b99ba1f..0f6219d45f91fd 100644 --- a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts @@ -11,7 +11,7 @@ type KeyPressEvent = { type HotkeyRegistration = { handler: (event: KeyPressEvent) => void - options?: { enabled?: boolean, ignoreInputs?: boolean } + options?: { enabled?: boolean; ignoreInputs?: boolean } } const hotkeyHandlers: Record<string, HotkeyRegistration> = {} @@ -28,8 +28,7 @@ vi.mock('@tanstack/react-hotkeys', () => ({ const triggerHotkey = (hotkey: string, event: KeyPressEvent) => { const registration = hotkeyHandlers[hotkey] - if (registration?.options?.enabled === false) - return + if (registration?.options?.enabled === false) return registration?.handler(event) } @@ -44,7 +43,7 @@ const renderGotoAnythingModalHook = () => { describe('useGotoAnythingModal', () => { beforeEach(() => { - Object.keys(hotkeyHandlers).forEach(key => delete hotkeyHandlers[key]) + Object.keys(hotkeyHandlers).forEach((key) => delete hotkeyHandlers[key]) vi.useFakeTimers() }) diff --git a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts index b3dc0342164b5e..d26071baf2f6a0 100644 --- a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts @@ -250,7 +250,10 @@ describe('useGotoAnythingNavigation', () => { it('should set activePlugin for plugin type', () => { const options = createMockOptions() - const pluginData = { name: 'My Plugin', latest_package_identifier: 'pkg' } as unknown as Plugin + const pluginData = { + name: 'My Plugin', + latest_package_identifier: 'pkg', + } as unknown as Plugin const { result } = renderHook(() => useGotoAnythingNavigation(options)) @@ -426,7 +429,10 @@ describe('useGotoAnythingNavigation', () => { it('should update activePlugin state', () => { const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions())) - const plugin = { name: 'Test Plugin', latest_package_identifier: 'test-pkg' } as unknown as Plugin + const plugin = { + name: 'Test Plugin', + latest_package_identifier: 'test-pkg', + } as unknown as Plugin act(() => { result.current.setActivePlugin(plugin) }) @@ -438,7 +444,10 @@ describe('useGotoAnythingNavigation', () => { const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions())) act(() => { - result.current.setActivePlugin({ name: 'Plugin', latest_package_identifier: 'pkg' } as unknown as Plugin) + result.current.setActivePlugin({ + name: 'Plugin', + latest_package_identifier: 'pkg', + } as unknown as Plugin) }) expect(result.current.activePlugin).toBeDefined() diff --git a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts index 240185f77a0159..1451fde2229f2a 100644 --- a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts @@ -3,7 +3,7 @@ import { renderHook } from '@testing-library/react' import { useGotoAnythingResults } from '../use-goto-anything-results' type MockQueryResult = { - data: Array<{ id: string, type: string, title: string }> | undefined + data: Array<{ id: string; type: string; title: string }> | undefined isLoading: boolean isError: boolean error: Error | null @@ -199,10 +199,14 @@ describe('useGotoAnythingResults', () => { error: null, } - renderHook(() => useGotoAnythingResults(createMockOptions({ - cmdVal: 'non-existent', - setCmdVal, - }))) + renderHook(() => + useGotoAnythingResults( + createMockOptions({ + cmdVal: 'non-existent', + setCmdVal, + }), + ), + ) expect(setCmdVal).toHaveBeenCalledWith('app-1') }) @@ -216,10 +220,14 @@ describe('useGotoAnythingResults', () => { error: null, } - renderHook(() => useGotoAnythingResults(createMockOptions({ - isCommandsMode: true, - setCmdVal, - }))) + renderHook(() => + useGotoAnythingResults( + createMockOptions({ + isCommandsMode: true, + setCmdVal, + }), + ), + ) expect(setCmdVal).not.toHaveBeenCalled() }) @@ -228,9 +236,13 @@ describe('useGotoAnythingResults', () => { const setCmdVal = vi.fn() mockQueryResult = { data: [], isLoading: false, isError: false, error: null } - renderHook(() => useGotoAnythingResults(createMockOptions({ - setCmdVal, - }))) + renderHook(() => + useGotoAnythingResults( + createMockOptions({ + setCmdVal, + }), + ), + ) expect(setCmdVal).not.toHaveBeenCalled() }) @@ -247,10 +259,14 @@ describe('useGotoAnythingResults', () => { error: null, } - renderHook(() => useGotoAnythingResults(createMockOptions({ - cmdVal: 'app-2', - setCmdVal, - }))) + renderHook(() => + useGotoAnythingResults( + createMockOptions({ + cmdVal: 'app-2', + setCmdVal, + }), + ), + ) expect(setCmdVal).not.toHaveBeenCalled() }) @@ -301,13 +317,23 @@ describe('useGotoAnythingResults', () => { describe('recent results', () => { it('surfaces recent items when the search query is empty', () => { mockGetRecentItems.mockReturnValue([ - { id: 'app-1', title: 'My App', description: 'Desc', path: '/app/app-1', originalType: 'app' }, + { + id: 'app-1', + title: 'My App', + description: 'Desc', + path: '/app/app-1', + originalType: 'app', + }, { id: 'kb-1', title: 'My KB', path: '/datasets/kb-1', originalType: 'knowledge' }, ]) - const { result } = renderHook(() => useGotoAnythingResults(createMockOptions({ - searchQueryDebouncedValue: '', - }))) + const { result } = renderHook(() => + useGotoAnythingResults( + createMockOptions({ + searchQueryDebouncedValue: '', + }), + ), + ) expect(result.current.dedupedResults).toHaveLength(2) expect(result.current.dedupedResults[0]).toMatchObject({ @@ -331,11 +357,15 @@ describe('useGotoAnythingResults', () => { error: null, } - const { result } = renderHook(() => useGotoAnythingResults(createMockOptions({ - searchQueryDebouncedValue: 'foo', - }))) + const { result } = renderHook(() => + useGotoAnythingResults( + createMockOptions({ + searchQueryDebouncedValue: 'foo', + }), + ), + ) - expect(result.current.dedupedResults.map(r => r.id)).toEqual(['s1']) + expect(result.current.dedupedResults.map((r) => r.id)).toEqual(['s1']) }) it('does not surface recent items in commands mode', () => { @@ -343,9 +373,13 @@ describe('useGotoAnythingResults', () => { { id: 'app-1', title: 'My App', path: '/app/app-1', originalType: 'app' }, ]) - const { result } = renderHook(() => useGotoAnythingResults(createMockOptions({ - isCommandsMode: true, - }))) + const { result } = renderHook(() => + useGotoAnythingResults( + createMockOptions({ + isCommandsMode: true, + }), + ), + ) expect(result.current.dedupedResults).toEqual([]) }) @@ -357,10 +391,14 @@ describe('useGotoAnythingResults', () => { mockMatchAction.mockReturnValue({ key: '@app' }) mockSearchAnything.mockResolvedValue([]) - renderHook(() => useGotoAnythingResults(createMockOptions({ - searchQueryDebouncedValue: 'TEST QUERY', - Actions: mockActions, - }))) + renderHook(() => + useGotoAnythingResults( + createMockOptions({ + searchQueryDebouncedValue: 'TEST QUERY', + Actions: mockActions, + }), + ), + ) expect(capturedQueryFn).toBeDefined() await capturedQueryFn!() @@ -374,10 +412,14 @@ describe('useGotoAnythingResults', () => { mockMatchAction.mockReturnValue(mockAction) mockSearchAnything.mockResolvedValue([{ id: '1', type: 'app', title: 'Result' }]) - renderHook(() => useGotoAnythingResults(createMockOptions({ - searchQueryDebouncedValue: 'My Query', - Actions: mockActions, - }))) + renderHook(() => + useGotoAnythingResults( + createMockOptions({ + searchQueryDebouncedValue: 'My Query', + Actions: mockActions, + }), + ), + ) expect(capturedQueryFn).toBeDefined() const result = await capturedQueryFn!() @@ -394,9 +436,13 @@ describe('useGotoAnythingResults', () => { mockMatchAction.mockReturnValue(null) mockSearchAnything.mockResolvedValue(expectedResults) - renderHook(() => useGotoAnythingResults(createMockOptions({ - searchQueryDebouncedValue: 'search term', - }))) + renderHook(() => + useGotoAnythingResults( + createMockOptions({ + searchQueryDebouncedValue: 'search term', + }), + ), + ) expect(capturedQueryFn).toBeDefined() const result = await capturedQueryFn!() diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts b/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts index 2da09bad986b7a..50f1cb5abf9109 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts +++ b/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts @@ -16,12 +16,16 @@ export const useGotoAnythingModal = (): UseGotoAnythingModalReturn => { const setOpen = useSetGotoAnythingOpen() const inputRef = useRef<HTMLInputElement>(null) - useHotkey('Mod+K', (e) => { - e.preventDefault() - setOpen(prev => !prev) - }, { - ignoreInputs: !open, - }) + useHotkey( + 'Mod+K', + (e) => { + e.preventDefault() + setOpen((prev) => !prev) + }, + { + ignoreInputs: !open, + }, + ) useEffect(() => { if (open) { diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts b/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts index a3be296c8c973f..aece64f5e31c77 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts +++ b/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts @@ -27,81 +27,78 @@ type UseGotoAnythingNavigationOptions = { export const useGotoAnythingNavigation = ( options: UseGotoAnythingNavigationOptions, ): UseGotoAnythingNavigationReturn => { - const { - Actions, - setSearchQuery, - clearSelection, - inputRef, - onClose, - } = options + const { Actions, setSearchQuery, clearSelection, inputRef, onClose } = options const router = useRouter() const [activePlugin, setActivePlugin] = useState<Plugin>() - const handleCommandSelect = useCallback((commandKey: string) => { - // Check if it's a slash command - if (commandKey.startsWith('/')) { - const commandName = commandKey.substring(1) - const handler = slashCommandRegistry.findCommand(commandName) + const handleCommandSelect = useCallback( + (commandKey: string) => { + // Check if it's a slash command + if (commandKey.startsWith('/')) { + const commandName = commandKey.substring(1) + const handler = slashCommandRegistry.findCommand(commandName) - // If it's a direct mode command, execute immediately - if (handler?.mode === 'direct' && handler.execute) { - handler.execute() - onClose() - setSearchQuery('') - return + // If it's a direct mode command, execute immediately + if (handler?.mode === 'direct' && handler.execute) { + handler.execute() + onClose() + setSearchQuery('') + return + } } - } - // Otherwise, proceed with the normal flow (submenu mode) - setSearchQuery(`${commandKey} `) - clearSelection() - setTimeout(() => { - inputRef.current?.focus() - }, 0) - }, [onClose, setSearchQuery, clearSelection, inputRef]) + // Otherwise, proceed with the normal flow (submenu mode) + setSearchQuery(`${commandKey} `) + clearSelection() + setTimeout(() => { + inputRef.current?.focus() + }, 0) + }, + [onClose, setSearchQuery, clearSelection, inputRef], + ) // Handle navigation to selected result - const handleNavigate = useCallback((result: SearchResult) => { - onClose() - setSearchQuery('') + const handleNavigate = useCallback( + (result: SearchResult) => { + onClose() + setSearchQuery('') - switch (result.type) { - case 'command': { - // Execute slash commands - const action = Actions.slash - action?.action?.(result) - break - } - case 'plugin': - setActivePlugin(result.data) - break - case 'workflow-node': - // Handle workflow node selection and navigation - if (result.metadata?.nodeId) - selectWorkflowNode(result.metadata.nodeId, true) + switch (result.type) { + case 'command': { + // Execute slash commands + const action = Actions.slash + action?.action?.(result) + break + } + case 'plugin': + setActivePlugin(result.data) + break + case 'workflow-node': + // Handle workflow node selection and navigation + if (result.metadata?.nodeId) selectWorkflowNode(result.metadata.nodeId, true) - break - case 'recent': - if (result.path) - router.push(result.path) + break + case 'recent': + if (result.path) router.push(result.path) - break - default: - // Record to recent history for app and knowledge results - if ((result.type === 'app' || result.type === 'knowledge') && result.path) { - addRecentItem({ - id: result.id, - title: result.title, - description: result.description, - path: result.path, - originalType: result.type, - }) - } - if (result.path) - router.push(result.path) - } - }, [router, Actions, onClose, setSearchQuery]) + break + default: + // Record to recent history for app and knowledge results + if ((result.type === 'app' || result.type === 'knowledge') && result.path) { + addRecentItem({ + id: result.id, + title: result.title, + description: result.description, + path: result.path, + originalType: result.type, + }) + } + if (result.path) router.push(result.path) + } + }, + [router, Actions, onClose, setSearchQuery], + ) return { handleCommandSelect, diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-results.ts b/web/app/components/goto-anything/hooks/use-goto-anything-results.ts index 2f9174fc538272..c12b90382c5c36 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-results.ts +++ b/web/app/components/goto-anything/hooks/use-goto-anything-results.ts @@ -49,35 +49,36 @@ export const useGotoAnythingResults = ( // (Actions contains functions which are not serializable) const actionKeys = useMemo(() => Object.keys(Actions).sort(), [Actions]) - const { data: searchResults = [], isLoading, isError, error } = useQuery( - { - - queryKey: [ - 'goto-anything', - 'search-result', - searchQueryDebouncedValue, - searchMode, - isWorkflowPage, - isRagPipelinePage, - defaultLocale, - actionKeys, - ], - queryFn: async () => { - const query = searchQueryDebouncedValue.toLowerCase() - const action = matchAction(query, Actions) - return await searchAnything(defaultLocale, query, action, Actions) - }, - enabled: !!searchQueryDebouncedValue && !isCommandsMode, - staleTime: 30000, - gcTime: 300000, + const { + data: searchResults = [], + isLoading, + isError, + error, + } = useQuery({ + queryKey: [ + 'goto-anything', + 'search-result', + searchQueryDebouncedValue, + searchMode, + isWorkflowPage, + isRagPipelinePage, + defaultLocale, + actionKeys, + ], + queryFn: async () => { + const query = searchQueryDebouncedValue.toLowerCase() + const action = matchAction(query, Actions) + return await searchAnything(defaultLocale, query, action, Actions) }, - ) + enabled: !!searchQueryDebouncedValue && !isCommandsMode, + staleTime: 30000, + gcTime: 300000, + }) // Build recent items to show when search is empty const recentResults = useMemo((): RecentSearchResult[] => { - if (searchQueryDebouncedValue || isCommandsMode) - return [] - return getRecentItems().map(item => ({ + if (searchQueryDebouncedValue || isCommandsMode) return [] + return getRecentItems().map((item) => ({ id: `recent-${item.id}`, title: item.title, description: item.description, @@ -86,7 +87,10 @@ export const useGotoAnythingResults = ( path: item.path, icon: React.createElement( 'div', - { className: 'flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg' }, + { + className: + 'flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg', + }, React.createElement(RiTimeLine, { className: 'h-4 w-4 text-text-tertiary' }), ), data: { path: item.path }, @@ -98,34 +102,38 @@ export const useGotoAnythingResults = ( const seen = new Set<string>() return allResults.filter((result) => { const key = `${result.type}-${result.id}` - if (seen.has(key)) - return false + if (seen.has(key)) return false seen.add(key) return true }) }, [searchResults, recentResults]) // Group results by type - const groupedResults = useMemo(() => dedupedResults.reduce((acc, result) => { - if (!acc[result.type]) - acc[result.type] = [] - - acc[result.type]!.push(result) - return acc - }, {} as Record<string, SearchResult[]>), [dedupedResults]) + const groupedResults = useMemo( + () => + dedupedResults.reduce( + (acc, result) => { + if (!acc[result.type]) acc[result.type] = [] + + acc[result.type]!.push(result) + return acc + }, + {} as Record<string, SearchResult[]>, + ), + [dedupedResults], + ) // Auto-select first result when results change useEffect(() => { - if (isCommandsMode) - return + if (isCommandsMode) return - if (!dedupedResults.length) - return + if (!dedupedResults.length) return - const currentValueExists = dedupedResults.some(result => `${result.type}-${result.id}` === cmdVal) + const currentValueExists = dedupedResults.some( + (result) => `${result.type}-${result.id}` === cmdVal, + ) - if (!currentValueExists) - setCmdVal(`${dedupedResults[0]!.type}-${dedupedResults[0]!.id}`) + if (!currentValueExists) setCmdVal(`${dedupedResults[0]!.type}-${dedupedResults[0]!.id}`) }, [isCommandsMode, dedupedResults, cmdVal, setCmdVal]) return { diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-search.ts b/web/app/components/goto-anything/hooks/use-goto-anything-search.ts index 486da787ebacff..2c0a5a3d4614b2 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-search.ts +++ b/web/app/components/goto-anything/hooks/use-goto-anything-search.ts @@ -34,26 +34,26 @@ export const useGotoAnythingSearch = (): UseGotoAnythingSearchReturn => { const isCommandsMode = useMemo(() => { const trimmed = searchQuery.trim() - return trimmed === '@' || trimmed === '/' - || (trimmed.startsWith('@') && !matchAction(trimmed, Actions)) - || (trimmed.startsWith('/') && !matchAction(trimmed, Actions)) + return ( + trimmed === '@' || + trimmed === '/' || + (trimmed.startsWith('@') && !matchAction(trimmed, Actions)) || + (trimmed.startsWith('/') && !matchAction(trimmed, Actions)) + ) }, [searchQuery, Actions]) const searchMode = useMemo(() => { if (isCommandsMode) { // Distinguish between @ (scopes) and / (commands) mode - if (searchQuery.trim().startsWith('@')) - return 'scopes' - else if (searchQuery.trim().startsWith('/')) - return 'commands' + if (searchQuery.trim().startsWith('@')) return 'scopes' + else if (searchQuery.trim().startsWith('/')) return 'commands' return 'commands' // default fallback } const query = searchQueryDebouncedValue.toLowerCase() const action = matchAction(query, Actions) - if (!action) - return 'general' + if (!action) return 'general' return action.key === '/' ? '@command' : action.key }, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery]) diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index c647a4bca353c3..5e8e1442d7ca16 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -22,9 +22,7 @@ type Props = Readonly<{ onHide?: () => void }> -const GotoAnythingDialog: FC<Props> = ({ - onHide, -}) => { +const GotoAnythingDialog: FC<Props> = ({ onHide }) => { const { t } = useTranslation() const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext() const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission() @@ -44,19 +42,14 @@ const GotoAnythingDialog: FC<Props> = ({ } = useGotoAnythingSearch() // Modal state management - const { - open, - onOpenChange, - inputRef, - } = useGotoAnythingModal() + const { open, onOpenChange, inputRef } = useGotoAnythingModal() // Reset state when modal opens/closes useEffect(() => { if (open && !prevShowRef.current) { // Modal just opened - reset search setSearchQuery('') - } - else if (!open && prevShowRef.current) { + } else if (!open && prevShowRef.current) { // Modal just closed setSearchQuery('') clearSelection() @@ -66,13 +59,7 @@ const GotoAnythingDialog: FC<Props> = ({ }, [open, setSearchQuery, clearSelection, onHide]) // Results fetching and processing - const { - dedupedResults, - groupedResults, - isLoading, - isError, - error, - } = useGotoAnythingResults({ + const { dedupedResults, groupedResults, isLoading, isError, error } = useGotoAnythingResults({ searchQueryDebouncedValue, searchMode, isCommandsMode, @@ -84,69 +71,64 @@ const GotoAnythingDialog: FC<Props> = ({ }) // Navigation handlers - const { - handleCommandSelect, - handleNavigate, - activePlugin, - setActivePlugin, - } = useGotoAnythingNavigation({ - Actions, - setSearchQuery, - clearSelection, - inputRef, - onClose: () => onOpenChange(false), - }) + const { handleCommandSelect, handleNavigate, activePlugin, setActivePlugin } = + useGotoAnythingNavigation({ + Actions, + setSearchQuery, + clearSelection, + inputRef, + onClose: () => onOpenChange(false), + }) // Handle search input change - const handleSearchChange = useCallback((value: string) => { - setSearchQuery(value) - if (!value.startsWith('@') && !value.startsWith('/')) - clearSelection() - }, [setSearchQuery, clearSelection]) + const handleSearchChange = useCallback( + (value: string) => { + setSearchQuery(value) + if (!value.startsWith('@') && !value.startsWith('/')) clearSelection() + }, + [setSearchQuery, clearSelection], + ) // Handle search input keydown for slash commands - const handleSearchKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => { - if (e.key === 'Enter') { - const query = searchQuery.trim() - // Check if it's a complete slash command - if (query.startsWith('/')) { - const commandName = query.substring(1).split(' ')[0] - const handler = slashCommandRegistry.findCommand(commandName!) - - // If it's a direct mode command, execute immediately - const isAvailable = handler?.isAvailable?.() ?? true - if (handler?.mode === 'direct' && handler.execute && isAvailable) { - e.preventDefault() - handler.execute() - onOpenChange(false) - setSearchQuery('') + const handleSearchKeyDown = useCallback( + (e: KeyboardEvent<HTMLInputElement>) => { + if (e.key === 'Enter') { + const query = searchQuery.trim() + // Check if it's a complete slash command + if (query.startsWith('/')) { + const commandName = query.substring(1).split(' ')[0] + const handler = slashCommandRegistry.findCommand(commandName!) + + // If it's a direct mode command, execute immediately + const isAvailable = handler?.isAvailable?.() ?? true + if (handler?.mode === 'direct' && handler.execute && isAvailable) { + e.preventDefault() + handler.execute() + onOpenChange(false) + setSearchQuery('') + } } } - } - }, [searchQuery, onOpenChange, setSearchQuery]) + }, + [searchQuery, onOpenChange, setSearchQuery], + ) // Determine which empty state to show const emptyStateVariant = useMemo(() => { - if (isLoading) - return 'loading' - if (isError) - return 'error' + if (isLoading) return 'loading' + if (isError) return 'error' if (!searchQuery.trim()) { // Show default hint only when there are no recent items to display return dedupedResults.length === 0 ? 'default' : null } - if (dedupedResults.length === 0 && !isCommandsMode) - return 'no-results' + if (dedupedResults.length === 0 && !isCommandsMode) return 'no-results' return null }, [isLoading, isError, searchQuery, dedupedResults.length, isCommandsMode]) return ( <> <SlashCommandProvider /> - <Dialog - open={open} - onOpenChange={onOpenChange} - > + <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="w-[480px]! overflow-hidden p-0!"> <Command className="outline-hidden" @@ -161,44 +143,31 @@ const GotoAnythingDialog: FC<Props> = ({ onChange={handleSearchChange} onKeyDown={handleSearchKeyDown} searchMode={searchMode} - placeholder={t($ => $['gotoAnything.searchPlaceholder'], { ns: 'app' })} + placeholder={t(($) => $['gotoAnything.searchPlaceholder'], { ns: 'app' })} /> <Command.List className="h-[240px] overflow-y-auto"> - {emptyStateVariant === 'loading' && ( - <EmptyState variant="loading" /> - )} + {emptyStateVariant === 'loading' && <EmptyState variant="loading" />} - {emptyStateVariant === 'error' && ( - <EmptyState variant="error" error={error} /> - )} + {emptyStateVariant === 'error' && <EmptyState variant="error" error={error} />} {!isLoading && !isError && ( <> - {isCommandsMode - ? ( - <CommandSelector - actions={Actions} - onCommandSelect={handleCommandSelect} - searchFilter={searchQuery.trim().substring(1)} - commandValue={cmdVal} - onCommandValueChange={setCmdVal} - originalQuery={searchQuery.trim()} - /> - ) - : ( - <ResultList - groupedResults={groupedResults} - onSelect={handleNavigate} - /> - )} + {isCommandsMode ? ( + <CommandSelector + actions={Actions} + onCommandSelect={handleCommandSelect} + searchFilter={searchQuery.trim().substring(1)} + commandValue={cmdVal} + onCommandValueChange={setCmdVal} + originalQuery={searchQuery.trim()} + /> + ) : ( + <ResultList groupedResults={groupedResults} onSelect={handleNavigate} /> + )} {!isCommandsMode && emptyStateVariant === 'no-results' && ( - <EmptyState - variant="no-results" - searchMode={searchMode} - Actions={Actions} - /> + <EmptyState variant="no-results" searchMode={searchMode} Actions={Actions} /> )} {!isCommandsMode && emptyStateVariant === 'default' && ( diff --git a/web/app/components/header/__tests__/maintenance-notice.spec.tsx b/web/app/components/header/__tests__/maintenance-notice.spec.tsx index 203dd55fa91f94..14fb43f265c412 100644 --- a/web/app/components/header/__tests__/maintenance-notice.spec.tsx +++ b/web/app/components/header/__tests__/maintenance-notice.spec.tsx @@ -16,12 +16,9 @@ vi.mock('@/env', () => ({ env: mockEnv, })) -vi.mock( - '@/app/components/header/account-setting/model-provider-page/hooks', - () => ({ - useLanguage: vi.fn(), - }), -) +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: vi.fn(), +})) vi.mock('@/i18n-config/language', async (importOriginal) => { const actual = (await importOriginal()) as Record<string, unknown> @@ -42,9 +39,7 @@ vi.mock('@/i18n-config/language', async (importOriginal) => { }) describe('MaintenanceNotice', () => { - const windowOpenSpy = vi - .spyOn(window, 'open') - .mockImplementation(() => null) + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null) const setNoticeHref = (href: string) => { NOTICE_I18N.href = href } @@ -108,10 +103,7 @@ describe('MaintenanceNotice', () => { const desc = screen.getByRole('button', { name: 'Notice Description' }) fireEvent.click(desc) - expect(windowOpenSpy).toHaveBeenCalledWith( - 'https://dify.ai/notice', - '_blank', - ) + expect(windowOpenSpy).toHaveBeenCalledWith('https://dify.ai/notice', '_blank') }) it('should not jump when href is #', () => { diff --git a/web/app/components/header/account-about/__tests__/index.spec.tsx b/web/app/components/header/account-about/__tests__/index.spec.tsx index 76bd3f9bf88f41..d9a71c73018489 100644 --- a/web/app/components/header/account-about/__tests__/index.spec.tsx +++ b/web/app/components/header/account-about/__tests__/index.spec.tsx @@ -8,7 +8,9 @@ vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal<typeof import('@/config')>() return { ...actual, - get IS_CE_EDITION() { return mockIsCEEdition }, + get IS_CE_EDITION() { + return mockIsCEEdition + }, } }) @@ -36,18 +38,24 @@ describe('AccountAbout', () => { describe('Rendering', () => { it('should render correctly with version information', () => { - renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />, { - systemFeatures: { branding: { enabled: false } }, - }) + renderWithSystemFeatures( + <AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />, + { + systemFeatures: { branding: { enabled: false } }, + }, + ) expect(screen.getByText(/^Version/)).toBeInTheDocument() expect(screen.getAllByText(/0.6.0/).length).toBeGreaterThan(0) }) it('should render branding logo if enabled', () => { - renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />, { - systemFeatures: { branding: { enabled: true, workspace_logo: 'custom-logo.png' } }, - }) + renderWithSystemFeatures( + <AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />, + { + systemFeatures: { branding: { enabled: true, workspace_logo: 'custom-logo.png' } }, + }, + ) const img = screen.getByAltText('logo') expect(img).toBeInTheDocument() @@ -57,7 +65,9 @@ describe('AccountAbout', () => { describe('Version Logic', () => { it('should show "Latest Available" when current version equals latest', () => { - renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />) + renderWithSystemFeatures( + <AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />, + ) expect(screen.getByText(/about.latestAvailable/)).toBeInTheDocument() }) @@ -65,7 +75,9 @@ describe('AccountAbout', () => { it('should show "Now Available" when current version is behind', () => { const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' } - renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={behindVersionInfo} onCancel={mockOnCancel} />) + renderWithSystemFeatures( + <AccountAbout langGeniusVersionInfo={behindVersionInfo} onCancel={mockOnCancel} />, + ) expect(screen.getByText(/about.nowAvailable/)).toBeInTheDocument() expect(screen.getByText(/about.updateNow/)).toBeInTheDocument() @@ -76,7 +88,9 @@ describe('AccountAbout', () => { it('should render correctly in Community Edition', () => { mockIsCEEdition = true - renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />) + renderWithSystemFeatures( + <AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />, + ) expect(screen.getByText(/Open Source License/)).toBeInTheDocument() }) @@ -85,7 +99,9 @@ describe('AccountAbout', () => { mockIsCEEdition = true const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' } - renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={behindVersionInfo} onCancel={mockOnCancel} />) + renderWithSystemFeatures( + <AccountAbout langGeniusVersionInfo={behindVersionInfo} onCancel={mockOnCancel} />, + ) expect(screen.queryByText(/about.updateNow/)).not.toBeInTheDocument() }) @@ -93,7 +109,9 @@ describe('AccountAbout', () => { describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { - renderWithSystemFeatures(<AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />) + renderWithSystemFeatures( + <AccountAbout langGeniusVersionInfo={mockVersionInfo} onCancel={mockOnCancel} />, + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) diff --git a/web/app/components/header/account-about/index.tsx b/web/app/components/header/account-about/index.tsx index b0c40803774f3a..f93746869b3c06 100644 --- a/web/app/components/header/account-about/index.tsx +++ b/web/app/components/header/account-about/index.tsx @@ -8,7 +8,6 @@ import dayjs from 'dayjs' import { useTranslation } from 'react-i18next' import DifyLogo from '@/app/components/base/logo/dify-logo' import { IS_CE_EDITION } from '@/config' - import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' @@ -17,10 +16,7 @@ type IAccountSettingProps = { onCancel: () => void } -export default function AccountAbout({ - langGeniusVersionInfo, - onCancel, -}: IAccountSettingProps) { +export default function AccountAbout({ langGeniusVersionInfo, onCancel }: IAccountSettingProps) { const { t } = useTranslation() const isLatest = langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) @@ -29,66 +25,71 @@ export default function AccountAbout({ <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DialogContent className="w-[calc(100vw-2rem)]! max-w-[480px]! overflow-hidden! border-none px-6! py-4! text-left align-middle"> - <div className="relative"> <button type="button" className="absolute top-0 right-0 flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onCancel} > <RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" /> </button> <div className="flex flex-col items-center gap-4 py-8"> - {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo - ? ( - <img - src={systemFeatures.branding.workspace_logo} - className="block h-7 w-auto object-contain" - alt="logo" - /> - ) - : <DifyLogo size="large" className="mx-auto" />} + {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo ? ( + <img + src={systemFeatures.branding.workspace_logo} + className="block h-7 w-auto object-contain" + alt="logo" + /> + ) : ( + <DifyLogo size="large" className="mx-auto" /> + )} <div className="text-center text-xs font-normal text-text-tertiary"> Version {langGeniusVersionInfo?.current_version} </div> <div className="flex flex-col items-center gap-2 text-center text-xs font-normal text-text-secondary"> - <div> - © - {dayjs().year()} - {' '} - LangGenius, Inc., Contributors. - </div> + <div>©{dayjs().year()} LangGenius, Inc., Contributors.</div> <div className="text-text-accent"> - { - IS_CE_EDITION - ? <Link href="https://github.com/langgenius/dify/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">Open Source License</Link> - : ( - <> - <Link href="https://dify.ai/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</Link> - ,  - <Link href="https://dify.ai/terms" target="_blank" rel="noopener noreferrer">Terms of Service</Link> - </> - ) - } + {IS_CE_EDITION ? ( + <Link + href="https://github.com/langgenius/dify/blob/main/LICENSE" + target="_blank" + rel="noopener noreferrer" + > + Open Source License + </Link> + ) : ( + <> + <Link href="https://dify.ai/privacy" target="_blank" rel="noopener noreferrer"> + Privacy Policy + </Link> + ,  + <Link href="https://dify.ai/terms" target="_blank" rel="noopener noreferrer"> + Terms of Service + </Link> + </> + )} </div> </div> </div> <div className="-mx-6 mb-4 h-[0.5px] bg-divider-regular" /> <div className="flex items-center justify-between gap-3"> <div className="min-w-0 text-xs font-medium text-text-tertiary"> - { - isLatest - ? t($ => $['about.latestAvailable'], { ns: 'common', version: langGeniusVersionInfo.latest_version }) - : t($ => $['about.nowAvailable'], { ns: 'common', version: langGeniusVersionInfo.latest_version }) - } + {isLatest + ? t(($) => $['about.latestAvailable'], { + ns: 'common', + version: langGeniusVersionInfo.latest_version, + }) + : t(($) => $['about.nowAvailable'], { + ns: 'common', + version: langGeniusVersionInfo.latest_version, + })} </div> <div className="flex shrink-0 items-center"> <Button className="mr-2" size="small"> @@ -97,22 +98,20 @@ export default function AccountAbout({ target="_blank" rel="noopener noreferrer" > - {t($ => $['about.changeLog'], { ns: 'common' })} + {t(($) => $['about.changeLog'], { ns: 'common' })} </Link> </Button> - { - !isLatest && !IS_CE_EDITION && ( - <Button variant="primary" size="small"> - <Link - href={langGeniusVersionInfo.release_notes} - target="_blank" - rel="noopener noreferrer" - > - {t($ => $['about.updateNow'], { ns: 'common' })} - </Link> - </Button> - ) - } + {!isLatest && !IS_CE_EDITION && ( + <Button variant="primary" size="small"> + <Link + href={langGeniusVersionInfo.release_notes} + target="_blank" + rel="noopener noreferrer" + > + {t(($) => $['about.updateNow'], { ns: 'common' })} + </Link> + </Button> + )} </div> </div> </div> diff --git a/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx b/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx index 86d88a621233f7..98806d6b603c2a 100644 --- a/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx @@ -1,5 +1,9 @@ import type { ModalContextState } from '@/context/modal-context' -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -67,11 +71,7 @@ describe('Compliance', () => { }) const renderWithQueryClient = (ui: React.ReactElement) => { - return render( - <QueryClientProvider client={queryClient}> - {ui} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>) } const renderCompliance = () => { @@ -181,7 +181,7 @@ describe('Compliance', () => { type: Plan.team, }, }) - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) // Act openMenuAndRender() @@ -233,9 +233,12 @@ describe('Compliance', () => { it('should show spinner and guard against duplicate download when isPending is true', async () => { // Arrange let resolveDownload: (value: { url: string }) => void - vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => { - resolveDownload = resolve - })) + vi.mocked(getDocDownloadUrl).mockImplementation( + () => + new Promise((resolve) => { + resolveDownload = resolve + }), + ) vi.mocked(useProviderContext).mockReturnValue({ ...baseProviderContextValue, plan: { @@ -251,12 +254,15 @@ describe('Compliance', () => { fireEvent.click(menuItem!) // Assert - button should enter the loading-disabled state while mutation is pending - await waitFor(() => { - const loadingButton = menuItem!.querySelector('button[aria-disabled="true"]') - expect(loadingButton).not.toBeNull() - expectLoadingButton(loadingButton) - expect(loadingButton!.querySelector('.animate-spin')).not.toBeNull() - }, { timeout: 10000 }) + await waitFor( + () => { + const loadingButton = menuItem!.querySelector('button[aria-disabled="true"]') + expect(loadingButton).not.toBeNull() + expectLoadingButton(loadingButton) + expect(loadingButton!.querySelector('.animate-spin')).not.toBeNull() + }, + { timeout: 10000 }, + ) // Cleanup: resolve the pending promise resolveDownload!({ url: 'http://example.com/doc.pdf' }) @@ -267,9 +273,12 @@ describe('Compliance', () => { it('should not call downloadCompliance again while pending', async () => { let resolveDownload: (value: { url: string }) => void - vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => { - resolveDownload = resolve - })) + vi.mocked(getDocDownloadUrl).mockImplementation( + () => + new Promise((resolve) => { + resolveDownload = resolve + }), + ) vi.mocked(useProviderContext).mockReturnValue({ ...baseProviderContextValue, plan: { @@ -286,20 +295,26 @@ describe('Compliance', () => { fireEvent.click(menuItem!) // Wait for mutation to start and React to re-render (isPending=true) - await waitFor(() => { - const loadingButton = menuItem!.querySelector('button[aria-disabled="true"]') - expect(loadingButton).not.toBeNull() - expectLoadingButton(loadingButton) - expect(getDocDownloadUrl).toHaveBeenCalledTimes(1) - }, { timeout: 10000 }) + await waitFor( + () => { + const loadingButton = menuItem!.querySelector('button[aria-disabled="true"]') + expect(loadingButton).not.toBeNull() + expectLoadingButton(loadingButton) + expect(getDocDownloadUrl).toHaveBeenCalledTimes(1) + }, + { timeout: 10000 }, + ) // Second click while pending - should be guarded by isPending check fireEvent.click(menuItem!) resolveDownload!({ url: 'http://example.com/doc.pdf' }) - await waitFor(() => { - expect(downloadUrl).toHaveBeenCalledTimes(1) - }, { timeout: 10000 }) + await waitFor( + () => { + expect(downloadUrl).toHaveBeenCalledTimes(1) + }, + { timeout: 10000 }, + ) // getDocDownloadUrl should still have only been called once expect(getDocDownloadUrl).toHaveBeenCalledTimes(1) }, 20000) 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 9e5ba55dd05bb6..0d67357e9492e2 100644 --- a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx @@ -12,11 +12,12 @@ import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' import AppSelector from '../index' -type DeepPartial<T> = T extends Array<infer U> - ? Array<U> - : T extends object - ? { [K in keyof T]?: DeepPartial<T[K]> } - : T +type DeepPartial<T> = + T extends Array<infer U> + ? Array<U> + : T extends object + ? { [K in keyof T]?: DeepPartial<T[K]> } + : T vi.mock('../../account-setting', () => ({ default: () => <div data-testid="account-setting">AccountSetting</div>, @@ -36,7 +37,11 @@ vi.mock('@/app/components/header/github-star', () => ({ })) vi.mock('@/app/components/base/theme-switcher', () => ({ - default: () => <button type="button" data-testid="theme-switcher-button">Theme switcher</button>, + default: () => ( + <button type="button" data-testid="theme-switcher-button"> + Theme switcher + </button> + ), })) const { mockSetTheme } = vi.hoisted(() => ({ @@ -76,7 +81,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -88,8 +94,8 @@ vi.mock('@/context/modal-context', () => ({ useModalContext: vi.fn(), })) -vi.mock('@/service/use-common', async importOriginal => ({ - ...await importOriginal<typeof import('@/service/use-common')>(), +vi.mock('@/service/use-common', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/service/use-common')>()), useLogout: vi.fn(), })) @@ -119,11 +125,21 @@ vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal<typeof import('@/config')>() return { ...actual, - get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION }, - get AMPLITUDE_API_KEY() { return mockConfig.AMPLITUDE_API_KEY }, - get isAmplitudeEnabled() { return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY }, - get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY }, - get SUPPORT_EMAIL_ADDRESS() { return mockConfig.SUPPORT_EMAIL_ADDRESS }, + get IS_CLOUD_EDITION() { + return mockConfig.IS_CLOUD_EDITION + }, + get AMPLITUDE_API_KEY() { + return mockConfig.AMPLITUDE_API_KEY + }, + get isAmplitudeEnabled() { + return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY + }, + get ZENDESK_WIDGET_KEY() { + return mockConfig.ZENDESK_WIDGET_KEY + }, + get SUPPORT_EMAIL_ADDRESS() { + return mockConfig.SUPPORT_EMAIL_ADDRESS + }, IS_DEV: false, IS_CE_EDITION: false, } @@ -273,7 +289,9 @@ describe('AccountDropdown', () => { fireEvent.click(screen.getByText('common.settings.preferences')) // Assert - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PREFERENCES }) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.PREFERENCES, + }) }) it('should show Appearance after Preferences in the main nav account dropdown', () => { @@ -286,7 +304,9 @@ describe('AccountDropdown', () => { // Assert expect(preferences.compareDocumentPosition(appearance)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(screen.getByRole('menuitem', { name: 'common.account.appearanceLabel' })).toBeInTheDocument() + expect( + screen.getByRole('menuitem', { name: 'common.account.appearanceLabel' }), + ).toBeInTheDocument() }) it('should show Compliance in Cloud Edition for workspace owner', () => { @@ -296,7 +316,11 @@ describe('AccountDropdown', () => { ...baseAppContextValue, userProfile: { ...baseAppContextValue.userProfile, name: 'User' }, isCurrentWorkspaceOwner: true, - langGeniusVersionInfo: { ...baseAppContextValue.langGeniusVersionInfo, current_version: '0.6.0', latest_version: '0.6.0' }, + langGeniusVersionInfo: { + ...baseAppContextValue.langGeniusVersionInfo, + current_version: '0.6.0', + latest_version: '0.6.0', + }, }) // Act @@ -348,7 +372,11 @@ describe('AccountDropdown', () => { fireEvent.click(screen.getByRole('button')) // Assert - expect(screen.getByRole('menuitem', { name: 'common.userProfile.logout' }).querySelector('.i-ri-shut-down-line')).toBeInTheDocument() + expect( + screen + .getByRole('menuitem', { name: 'common.userProfile.logout' }) + .querySelector('.i-ri-shut-down-line'), + ).toBeInTheDocument() }) it('should show About section when about button is clicked and can close it', () => { @@ -422,7 +450,9 @@ describe('AccountDropdown', () => { fireEvent.click(screen.getByRole('button')) // Assert - expect(document.querySelector('.bg-components-badge-status-light-warning-bg')).toBeInTheDocument() + expect( + document.querySelector('.bg-components-badge-status-light-warning-bg'), + ).toBeInTheDocument() }) it('should show green indicator when version is latest', () => { @@ -442,7 +472,9 @@ describe('AccountDropdown', () => { fireEvent.click(screen.getByRole('button')) // Assert - expect(document.querySelector('.bg-components-badge-status-light-success-bg')).toBeInTheDocument() + expect( + document.querySelector('.bg-components-badge-status-light-success-bg'), + ).toBeInTheDocument() }) }) }) diff --git a/web/app/components/header/account-dropdown/compliance.tsx b/web/app/components/header/account-dropdown/compliance.tsx index 2200d32fee0b69..93d3689b60d5aa 100644 --- a/web/app/components/header/account-dropdown/compliance.tsx +++ b/web/app/components/header/account-dropdown/compliance.tsx @@ -1,6 +1,12 @@ import type { ReactNode } from 'react' import { Button } from '@langgenius/dify-ui/button' -import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@langgenius/dify-ui/dropdown-menu' +import { + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useMutation } from '@tanstack/react-query' @@ -25,7 +31,7 @@ const DocName = { ISO_27001: 'ISO_27001', GDPR: 'GDPR', } as const -type DocName = typeof DocName[keyof typeof DocName] +type DocName = (typeof DocName)[keyof typeof DocName] type ComplianceDocActionVisualProps = { isCurrentPlanCanDownload: boolean @@ -52,7 +58,9 @@ function ComplianceDocActionVisual({ className="pointer-events-none flex items-center gap-px" > <span className="i-ri-arrow-down-circle-line size-[14px] text-components-button-secondary-text-disabled" /> - <span className="px-[3px] system-xs-medium text-components-button-secondary-text">{downloadText}</span> + <span className="px-[3px] system-xs-medium text-components-button-secondary-text"> + {downloadText} + </span> </Button> ) } @@ -63,20 +71,17 @@ function ComplianceDocActionVisual({ <Tooltip> <TooltipTrigger disabled={!canShowUpgradeTooltip} - render={( + render={ <PremiumBadge color="blue" allowHover={true}> - <SparklesSoft aria-hidden="true" className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> - <div className="px-1 system-xs-medium"> - {upgradeText} - </div> + <SparklesSoft + aria-hidden="true" + className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" + /> + <div className="px-1 system-xs-medium">{upgradeText}</div> </PremiumBadge> - )} + } /> - {canShowUpgradeTooltip && ( - <TooltipContent> - {tooltipText} - </TooltipContent> - )} + {canShowUpgradeTooltip && <TooltipContent>{tooltipText}</TooltipContent>} </Tooltip> ) } @@ -87,11 +92,7 @@ type ComplianceDocRowItemProps = { docName: DocName } -function ComplianceDocRowItem({ - icon, - label, - docName, -}: ComplianceDocRowItemProps) { +function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProps) { const { t } = useTranslation() const { plan } = useProviderContext() const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() @@ -103,11 +104,10 @@ function ComplianceDocRowItem({ try { const ret = await getDocDownloadUrl(docName) downloadUrl({ url: ret.url }) - toast.success(t($ => $['operation.downloadSuccess'], { ns: 'common' })) - } - catch (error) { + toast.success(t(($) => $['operation.downloadSuccess'], { ns: 'common' })) + } catch (error) { console.error(error) - toast.error(t($ => $['operation.downloadFailed'], { ns: 'common' })) + toast.error(t(($) => $['operation.downloadFailed'], { ns: 'common' })) } }, }) @@ -123,20 +123,24 @@ function ComplianceDocRowItem({ const handleSelect = useCallback(() => { if (isCurrentPlanCanDownload) { - if (!isPending) - downloadCompliance() + if (!isPending) downloadCompliance() return } - if (isFreePlan) - setShowPricingModal() - else - setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) - }, [downloadCompliance, isCurrentPlanCanDownload, isFreePlan, isPending, setShowAccountSettingModal, setShowPricingModal]) + if (isFreePlan) setShowPricingModal() + else setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) + }, [ + downloadCompliance, + isCurrentPlanCanDownload, + isFreePlan, + isPending, + setShowAccountSettingModal, + setShowPricingModal, + ]) const upgradeTooltip: Record<Plan, string> = { - [Plan.sandbox]: t($ => $['compliance.sandboxUpgradeTooltip'], { ns: 'common' }), - [Plan.professional]: t($ => $['compliance.professionalUpgradeTooltip'], { ns: 'common' }), + [Plan.sandbox]: t(($) => $['compliance.sandboxUpgradeTooltip'], { ns: 'common' }), + [Plan.professional]: t(($) => $['compliance.professionalUpgradeTooltip'], { ns: 'common' }), [Plan.team]: '', [Plan.enterprise]: '', } @@ -149,13 +153,15 @@ function ComplianceDocRowItem({ onClick={handleSelect} > {icon} - <div className="grow truncate px-1 system-md-regular text-text-secondary" title={labelTitle}>{label}</div> + <div className="grow truncate px-1 system-md-regular text-text-secondary" title={labelTitle}> + {label} + </div> <ComplianceDocActionVisual isCurrentPlanCanDownload={isCurrentPlanCanDownload} isPending={isPending} tooltipText={upgradeTooltip[plan.type]} - downloadText={t($ => $['operation.download'], { ns: 'common' })} - upgradeText={t($ => $['upgradeBtn.encourageShort'], { ns: 'billing' })} + downloadText={t(($) => $['operation.download'], { ns: 'common' })} + upgradeText={t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })} /> </DropdownMenuItem> ) @@ -170,31 +176,29 @@ export default function Compliance() { <DropdownMenuSubTrigger className="mx-0 h-8 gap-1 px-3 py-1"> <MenuItemContent iconClassName="i-ri-verified-badge-line" - label={t($ => $['userProfile.compliance'], { ns: 'common' })} + label={t(($) => $['userProfile.compliance'], { ns: 'common' })} /> </DropdownMenuSubTrigger> - <DropdownMenuSubContent - popupClassName="w-[337px] divide-y divide-divider-subtle bg-components-panel-bg-blur! py-0! backdrop-blur-xs" - > + <DropdownMenuSubContent popupClassName="w-[337px] divide-y divide-divider-subtle bg-components-panel-bg-blur! py-0! backdrop-blur-xs"> <DropdownMenuGroup className="py-1"> <ComplianceDocRowItem icon={<Soc2 aria-hidden className="size-7 shrink-0" />} - label={t($ => $['compliance.soc2Type1'], { ns: 'common' })} + label={t(($) => $['compliance.soc2Type1'], { ns: 'common' })} docName={DocName.SOC2_Type_I} /> <ComplianceDocRowItem icon={<Soc2 aria-hidden className="size-7 shrink-0" />} - label={t($ => $['compliance.soc2Type2'], { ns: 'common' })} + label={t(($) => $['compliance.soc2Type2'], { ns: 'common' })} docName={DocName.SOC2_Type_II} /> <ComplianceDocRowItem icon={<Iso aria-hidden className="size-7 shrink-0" />} - label={t($ => $['compliance.iso27001'], { ns: 'common' })} + label={t(($) => $['compliance.iso27001'], { ns: 'common' })} docName={DocName.ISO_27001} /> <ComplianceDocRowItem icon={<Gdpr aria-hidden className="size-7 shrink-0" />} - label={t($ => $['compliance.gdpr'], { ns: 'common' })} + label={t(($) => $['compliance.gdpr'], { ns: 'common' })} docName={DocName.GDPR} /> </DropdownMenuGroup> 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 49161c14e04df7..06497ee17a88ab 100644 --- a/web/app/components/header/account-dropdown/default-menu-content.tsx +++ b/web/app/components/header/account-dropdown/default-menu-content.tsx @@ -36,17 +36,9 @@ type AccountMenuRouteItemProps = { trailing?: ReactNode } -function AccountMenuRouteItem({ - href, - iconClassName, - label, - trailing, -}: AccountMenuRouteItemProps) { +function AccountMenuRouteItem({ href, iconClassName, label, trailing }: AccountMenuRouteItemProps) { return ( - <DropdownMenuLinkItem - className="justify-between" - render={<Link href={href} />} - > + <DropdownMenuLinkItem className="justify-between" render={<Link href={href} />}> <MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} /> </DropdownMenuLinkItem> ) @@ -91,10 +83,7 @@ function AccountMenuActionItem({ trailing, }: AccountMenuActionItemProps) { return ( - <DropdownMenuItem - className="justify-between" - onClick={onClick} - > + <DropdownMenuItem className="justify-between" onClick={onClick}> <MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} /> </DropdownMenuItem> ) @@ -142,19 +131,21 @@ export function DefaultMenuContent({ </PremiumBadge> )} </div> - <div className="system-xs-regular break-all text-text-tertiary">{userProfile.email}</div> + <div className="system-xs-regular break-all text-text-tertiary"> + {userProfile.email} + </div> </div> <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" /> </div> <AccountMenuRouteItem href="/account" iconClassName="i-ri-account-circle-line" - label={t($ => $['account.account'], { ns: 'common' })} + label={t(($) => $['account.account'], { ns: 'common' })} trailing={<ExternalLinkIndicator />} /> <AccountMenuActionItem iconClassName="i-ri-settings-3-line" - label={t($ => $['userProfile.settings'], { ns: 'common' })} + label={t(($) => $['userProfile.settings'], { ns: 'common' })} onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })} /> </DropdownMenuGroup> @@ -165,7 +156,7 @@ export function DefaultMenuContent({ <AccountMenuExternalItem href={docLink('/use-dify/getting-started/introduction')} iconClassName="i-ri-book-open-line" - label={t($ => $['userProfile.helpCenter'], { ns: 'common' })} + label={t(($) => $['userProfile.helpCenter'], { ns: 'common' })} trailing={<ExternalLinkIndicator />} /> {IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />} @@ -175,34 +166,43 @@ export function DefaultMenuContent({ <AccountMenuExternalItem href="https://roadmap.dify.ai" iconClassName="i-ri-map-2-line" - label={t($ => $['userProfile.roadmap'], { ns: 'common' })} + label={t(($) => $['userProfile.roadmap'], { ns: 'common' })} trailing={<ExternalLinkIndicator />} /> <AccountMenuExternalItem href="https://github.com/langgenius/dify" iconClassName="i-ri-github-line" - label={t($ => $['userProfile.github'], { ns: 'common' })} - trailing={( + label={t(($) => $['userProfile.github'], { ns: 'common' })} + trailing={ <div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]"> <span aria-hidden className="i-ri-star-line size-3 shrink-0 text-text-tertiary" /> <GithubStar className="system-2xs-medium-uppercase text-text-tertiary" /> </div> - )} + } /> {env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && ( <AccountMenuActionItem iconClassName="i-ri-information-2-line" - label={t($ => $['userProfile.about'], { ns: 'common' })} + label={t(($) => $['userProfile.about'], { ns: 'common' })} onClick={() => { onShowAbout() closeAccountDropdown() }} - trailing={( + trailing={ <div className="flex shrink-0 items-center"> - <div className="mr-2 system-xs-regular text-text-tertiary">{langGeniusVersionInfo.current_version}</div> - <StatusDot status={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'success' : 'warning'} /> + <div className="mr-2 system-xs-regular text-text-tertiary"> + {langGeniusVersionInfo.current_version} + </div> + <StatusDot + status={ + langGeniusVersionInfo.current_version === + langGeniusVersionInfo.latest_version + ? 'success' + : 'warning' + } + /> </div> - )} + } /> )} </AccountMenuSection> @@ -216,7 +216,7 @@ export function DefaultMenuContent({ > <MenuItemContent iconClassName="i-ri-t-shirt-2-line" - label={t($ => $['theme.theme'], { ns: 'common' })} + label={t(($) => $['theme.theme'], { ns: 'common' })} trailing={<ThemeSwitcher />} /> </DropdownMenuItem> @@ -225,7 +225,7 @@ export function DefaultMenuContent({ <AccountMenuSection> <AccountMenuActionItem iconClassName="i-ri-logout-box-r-line" - label={t($ => $['userProfile.logout'], { ns: 'common' })} + label={t(($) => $['userProfile.logout'], { ns: 'common' })} onClick={() => { void onLogout() }} diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index c5da39472c4c87..635b95d5055b4c 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -12,7 +12,11 @@ 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 { + useSetEducationExpiredHasNoticed, + useSetEducationReverifyHasNoticed, + useSetEducationReverifyPrevExpireAt, +} from '@/app/education-apply/storage' import { userProfileAtom } from '@/context/account-state' import { langGeniusVersionInfoAtom } from '@/context/version-state' import { useRouter } from '@/next/navigation' @@ -22,19 +26,14 @@ import { DefaultMenuContent } from './default-menu-content' import { MainNavMenuContent } from './main-nav-menu-content' type AccountDropdownProps = { - trigger?: (props: { - isOpen: boolean - ariaLabel: string - }) => ReactElement + trigger?: (props: { isOpen: boolean; ariaLabel: string }) => ReactElement variant?: 'default' | 'mainNav' } -const mainNavMenuPopupClassName = 'w-60 max-w-80 overflow-hidden bg-components-panel-bg-blur! p-0! backdrop-blur-[5px]' +const mainNavMenuPopupClassName = + 'w-60 max-w-80 overflow-hidden bg-components-panel-bg-blur! p-0! backdrop-blur-[5px]' -export default function AppSelector({ - trigger, - variant = 'default', -}: AccountDropdownProps = {}) { +export default function AppSelector({ trigger, variant = 'default' }: AccountDropdownProps = {}) { const router = useRouter() const [aboutVisible, setAboutVisible] = useState(false) const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) @@ -63,41 +62,51 @@ export default function AppSelector({ return ( <div> <DropdownMenu open={isAccountMenuOpen} onOpenChange={setIsAccountMenuOpen}> - {trigger - ? ( - <DropdownMenuTrigger - render={trigger({ - isOpen: isAccountMenuOpen, - ariaLabel: t($ => $['account.account'], { ns: 'common' }), - })} - /> - ) - : ( - <DropdownMenuTrigger - aria-label={t($ => $['account.account'], { ns: 'common' })} - className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', isAccountMenuOpen && 'bg-background-default-dodge')} - > - <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" /> - </DropdownMenuTrigger> + {trigger ? ( + <DropdownMenuTrigger + render={trigger({ + isOpen: isAccountMenuOpen, + ariaLabel: t(($) => $['account.account'], { ns: 'common' }), + })} + /> + ) : ( + <DropdownMenuTrigger + aria-label={t(($) => $['account.account'], { ns: 'common' })} + className={cn( + 'inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', + isAccountMenuOpen && 'bg-background-default-dodge', )} + > + <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" /> + </DropdownMenuTrigger> + )} <DropdownMenuContent placement={variant === 'mainNav' ? 'top-start' : 'bottom-end'} sideOffset={6} alignOffset={variant === 'mainNav' ? 4 : 0} - popupClassName={variant === 'mainNav' ? mainNavMenuPopupClassName : 'w-60 max-w-80 bg-components-panel-bg-blur! py-0! backdrop-blur-xs'} + popupClassName={ + variant === 'mainNav' + ? mainNavMenuPopupClassName + : 'w-60 max-w-80 bg-components-panel-bg-blur! py-0! backdrop-blur-xs' + } > - {variant === 'mainNav' - ? <MainNavMenuContent onLogout={handleLogout} /> - : ( - <DefaultMenuContent - closeAccountDropdown={() => setIsAccountMenuOpen(false)} - onShowAbout={() => setAboutVisible(true)} - onLogout={handleLogout} - /> - )} + {variant === 'mainNav' ? ( + <MainNavMenuContent onLogout={handleLogout} /> + ) : ( + <DefaultMenuContent + closeAccountDropdown={() => setIsAccountMenuOpen(false)} + onShowAbout={() => setAboutVisible(true)} + onLogout={handleLogout} + /> + )} </DropdownMenuContent> </DropdownMenu> - {aboutVisible && <AccountAbout onCancel={() => setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} />} + {aboutVisible && ( + <AccountAbout + onCancel={() => setAboutVisible(false)} + langGeniusVersionInfo={langGeniusVersionInfo} + /> + )} </div> ) } 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 fbab077eb60061..0746c49cc3eb08 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 @@ -32,16 +32,20 @@ type MainNavRadioItemContentProps = { label: ReactNode } -function MainNavRadioItemContent({ - iconClassName, - label, -}: MainNavRadioItemContentProps) { +function MainNavRadioItemContent({ iconClassName, label }: MainNavRadioItemContentProps) { const labelTitle = typeof label === 'string' ? label : undefined return ( <> - {iconClassName && <span aria-hidden className={cn('size-4 shrink-0 text-text-tertiary', iconClassName)} />} - <span className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary" title={labelTitle}>{label}</span> + {iconClassName && ( + <span aria-hidden className={cn('size-4 shrink-0 text-text-tertiary', iconClassName)} /> + )} + <span + className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary" + title={labelTitle} + > + {label} + </span> <DropdownMenuRadioItemIndicator /> </> ) @@ -56,7 +60,7 @@ function AppearanceSubmenu() { <DropdownMenuSubTrigger className="mx-0 h-8 gap-1 px-3 py-1"> <MenuItemContent iconClassName="i-ri-sun-line" - label={t($ => $['account.appearanceLabel'], { ns: 'common' })} + label={t(($) => $['account.appearanceLabel'], { ns: 'common' })} /> </DropdownMenuSubTrigger> <DropdownMenuSubContent @@ -64,15 +68,27 @@ function AppearanceSubmenu() { sideOffset={6} popupClassName="w-[139px] max-h-[360px] bg-components-panel-bg-blur p-1 backdrop-blur-[5px]" > - <DropdownMenuRadioGroup value={theme || 'system'} onValueChange={value => setTheme(value as Theme)}> + <DropdownMenuRadioGroup + value={theme || 'system'} + onValueChange={(value) => setTheme(value as Theme)} + > <DropdownMenuRadioItem value="light" closeOnClick className="mx-0 h-8 gap-1 px-2 py-1"> - <MainNavRadioItemContent iconClassName="i-ri-sun-line" label={t($ => $['account.appearanceLight'], { ns: 'common' })} /> + <MainNavRadioItemContent + iconClassName="i-ri-sun-line" + label={t(($) => $['account.appearanceLight'], { ns: 'common' })} + /> </DropdownMenuRadioItem> <DropdownMenuRadioItem value="dark" closeOnClick className="mx-0 h-8 gap-1 px-2 py-1"> - <MainNavRadioItemContent iconClassName="i-ri-moon-line" label={t($ => $['account.appearanceDark'], { ns: 'common' })} /> + <MainNavRadioItemContent + iconClassName="i-ri-moon-line" + label={t(($) => $['account.appearanceDark'], { ns: 'common' })} + /> </DropdownMenuRadioItem> <DropdownMenuRadioItem value="system" closeOnClick className="mx-0 h-8 gap-1 px-2 py-1"> - <MainNavRadioItemContent iconClassName="i-ri-computer-line" label={t($ => $['account.appearanceSystem'], { ns: 'common' })} /> + <MainNavRadioItemContent + iconClassName="i-ri-computer-line" + label={t(($) => $['account.appearanceSystem'], { ns: 'common' })} + /> </DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuSubContent> @@ -84,9 +100,7 @@ type MainNavMenuContentProps = { onLogout: () => Promise<void> } -export function MainNavMenuContent({ - onLogout, -}: MainNavMenuContentProps) { +export function MainNavMenuContent({ onLogout }: MainNavMenuContentProps) { const { t } = useTranslation() const userProfile = useAtomValue(userProfileAtom) const { isEducationAccount } = useProviderContext() @@ -98,7 +112,12 @@ export function MainNavMenuContent({ <div className="flex items-center gap-3 rounded-xl bg-gradient-to-b from-background-section-burn to-background-section p-3"> <div className="flex min-w-0 grow flex-col gap-1"> <div className="flex min-w-0 items-center gap-1"> - <div className="min-w-0 flex-1 truncate body-md-medium text-text-primary" title={userProfile.name}>{userProfile.name}</div> + <div + className="min-w-0 flex-1 truncate body-md-medium text-text-primary" + title={userProfile.name} + > + {userProfile.name} + </div> {isEducationAccount && ( <PremiumBadge size="s" color="blue" className="shrink-0 px-2!"> <span aria-hidden className="mr-1 i-ri-graduation-cap-fill h-3 w-3" /> @@ -106,9 +125,19 @@ export function MainNavMenuContent({ </PremiumBadge> )} </div> - <div className="truncate system-xs-regular text-text-tertiary" title={userProfile.email}>{userProfile.email}</div> + <div + className="truncate system-xs-regular text-text-tertiary" + title={userProfile.email} + > + {userProfile.email} + </div> </div> - <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" className="shrink-0" /> + <Avatar + avatar={userProfile.avatar_url} + name={userProfile.name} + size="lg" + className="shrink-0" + /> </div> </DropdownMenuGroup> <DropdownMenuGroup className="p-1"> @@ -118,7 +147,7 @@ export function MainNavMenuContent({ > <MenuItemContent iconClassName="i-ri-account-circle-line" - label={t($ => $['account.account'], { ns: 'common' })} + label={t(($) => $['account.account'], { ns: 'common' })} trailing={<ExternalLinkIndicator />} /> </DropdownMenuLinkItem> @@ -128,7 +157,7 @@ export function MainNavMenuContent({ > <MenuItemContent iconClassName="i-ri-equalizer-2-line" - label={t($ => $['settings.preferences'], { ns: 'common' })} + label={t(($) => $['settings.preferences'], { ns: 'common' })} /> </DropdownMenuItem> <AppearanceSubmenu /> @@ -143,7 +172,7 @@ export function MainNavMenuContent({ > <MenuItemContent iconClassName="i-ri-shut-down-line" - label={t($ => $['userProfile.logout'], { ns: 'common' })} + label={t(($) => $['userProfile.logout'], { ns: 'common' })} /> </DropdownMenuItem> </DropdownMenuGroup> diff --git a/web/app/components/header/account-dropdown/menu-item-content.tsx b/web/app/components/header/account-dropdown/menu-item-content.tsx index 42831c48a78a76..23ea5b63e5849a 100644 --- a/web/app/components/header/account-dropdown/menu-item-content.tsx +++ b/web/app/components/header/account-dropdown/menu-item-content.tsx @@ -12,17 +12,15 @@ type MenuItemContentProps = { trailing?: ReactNode } -export function MenuItemContent({ - iconClassName, - label, - trailing, -}: MenuItemContentProps) { +export function MenuItemContent({ iconClassName, label, trailing }: MenuItemContentProps) { const labelTitle = typeof label === 'string' ? label : undefined return ( <> <span aria-hidden className={cn(menuLeadingIconClassName, iconClassName)} /> - <div className={menuLabelClassName} title={labelTitle}>{label}</div> + <div className={menuLabelClassName} title={labelTitle}> + {label} + </div> {trailing} </> ) diff --git a/web/app/components/header/account-dropdown/workplace-selector/index.tsx b/web/app/components/header/account-dropdown/workplace-selector/index.tsx index a57be05fbfa397..e58f7bf24f7044 100644 --- a/web/app/components/header/account-dropdown/workplace-selector/index.tsx +++ b/web/app/components/header/account-dropdown/workplace-selector/index.tsx @@ -26,9 +26,7 @@ function isWorkspacePlan(plan: string | null | undefined): plan is Plan { return !!plan && workspacePlans.has(plan) } -const WorkplaceSelectorItem = memo(({ - workspace, -}: WorkplaceSelectorItemProps) => { +const WorkplaceSelectorItem = memo(({ workspace }: WorkplaceSelectorItemProps) => { const workspaceName = workspace.name || workspace.id const workspacePlan = isWorkspacePlan(workspace.plan) ? workspace.plan : Plan.sandbox @@ -46,23 +44,25 @@ const WorkplaceSelectorItem = memo(({ }) WorkplaceSelectorItem.displayName = 'WorkplaceSelectorItem' -export const WorkplaceSelectorContent = memo(({ - workspaces, - popupClassName = 'w-[280px] transition-none data-starting-style:scale-100 data-starting-style:opacity-100 data-ending-style:scale-100 data-ending-style:opacity-100', -}: WorkplaceSelectorContentProps) => { - const { t } = useTranslation() +export const WorkplaceSelectorContent = memo( + ({ + workspaces, + popupClassName = 'w-[280px] transition-none data-starting-style:scale-100 data-starting-style:opacity-100 data-ending-style:scale-100 data-ending-style:opacity-100', + }: WorkplaceSelectorContentProps) => { + const { t } = useTranslation() - return ( - <SelectContent popupClassName={popupClassName}> - <SelectGroup> - <SelectGroupLabel> - {t($ => $['userProfile.workspace'], { ns: 'common' })} - </SelectGroupLabel> - {workspaces.map(workspace => ( - <WorkplaceSelectorItem key={workspace.id} workspace={workspace} /> - ))} - </SelectGroup> - </SelectContent> - ) -}) + return ( + <SelectContent popupClassName={popupClassName}> + <SelectGroup> + <SelectGroupLabel> + {t(($) => $['userProfile.workspace'], { ns: 'common' })} + </SelectGroupLabel> + {workspaces.map((workspace) => ( + <WorkplaceSelectorItem key={workspace.id} workspace={workspace} /> + ))} + </SelectGroup> + </SelectContent> + ) + }, +) WorkplaceSelectorContent.displayName = 'WorkplaceSelectorContent' diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index 0f2b3384920500..37dbb6e7b25396 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -44,7 +44,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -259,8 +260,12 @@ describe('AccountSetting', () => { expect(screen.getAllByText('common.settings.workspace').length).toBeGreaterThan(0) expect(screen.queryByText('common.settings.provider'))!.not.toBeInTheDocument() expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0) - expect(screen.getByRole('button', { name: 'common.settings.rolesAndPermissions' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.settings.permissionSet' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.settings.rolesAndPermissions' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.settings.permissionSet' }), + ).toBeInTheDocument() expect(screen.getByText('common.settings.billing'))!.toBeInTheDocument() expect(screen.queryByText('common.settings.dataSource'))!.not.toBeInTheDocument() expect(screen.queryByText('common.settings.customEndpoint'))!.not.toBeInTheDocument() @@ -375,18 +380,26 @@ describe('AccountSetting', () => { // Assert expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.settings.rolesAndPermissions' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.settings.permissionSet' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.settings.rolesAndPermissions' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.settings.permissionSet' }), + ).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.settings.billing' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'custom.custom' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.settings.preferences' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.settings.preferences' }), + ).toBeInTheDocument() }) it('should keep moved integrations hidden when api extension permission is missing', () => { // Arrange const contextWithoutApiExtensionPermission = { ...baseAppContextValue, - workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys!.filter(key => key !== 'api_extension.manage'), + workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys!.filter( + (key) => key !== 'api_extension.manage', + ), } mockUseAppContext.mockReturnValue(contextWithoutApiExtensionPermission) mockAppContextState.current = contextWithoutApiExtensionPermission @@ -404,7 +417,9 @@ describe('AccountSetting', () => { // Arrange const contextWithoutCustomizationPermission = { ...baseAppContextValue, - workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys!.filter(key => key !== 'customization.manage'), + workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys!.filter( + (key) => key !== 'customization.manage', + ), } mockUseAppContext.mockReturnValue(contextWithoutCustomizationPermission) mockAppContextState.current = contextWithoutCustomizationPermission @@ -423,7 +438,9 @@ describe('AccountSetting', () => { // Arrange const contextWithoutRoleManagePermission = { ...baseAppContextValue, - workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys!.filter(key => key !== 'workspace.role.manage'), + workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys!.filter( + (key) => key !== 'workspace.role.manage', + ), } mockUseAppContext.mockReturnValue(contextWithoutRoleManagePermission) mockAppContextState.current = contextWithoutRoleManagePermission @@ -433,8 +450,12 @@ describe('AccountSetting', () => { // Assert expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.settings.rolesAndPermissions' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.settings.permissionSet' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.settings.rolesAndPermissions' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.settings.permissionSet' }), + ).not.toBeInTheDocument() }) it('should hide role and permission set entries when RBAC is disabled', () => { @@ -443,8 +464,12 @@ describe('AccountSetting', () => { // Assert expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.settings.rolesAndPermissions' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.settings.permissionSet' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.settings.rolesAndPermissions' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.settings.permissionSet' }), + ).not.toBeInTheDocument() }) it('should not render direct role pages when RBAC is disabled', () => { @@ -478,7 +503,9 @@ describe('AccountSetting', () => { // Arrange const contextWithoutBillingViewPermission = { ...baseAppContextValue, - workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys!.filter(key => key !== 'billing.view'), + workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys!.filter( + (key) => key !== 'billing.view', + ), } mockUseAppContext.mockReturnValue(contextWithoutBillingViewPermission) mockAppContextState.current = contextWithoutBillingViewPermission @@ -487,14 +514,18 @@ describe('AccountSetting', () => { renderAccountSetting() // Assert - expect(screen.queryByRole('button', { name: 'common.settings.billing' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.settings.billing' }), + ).not.toBeInTheDocument() }) it('should not render billing page when active billing tab lacks billing view permission', () => { // Arrange const contextWithoutBillingViewPermission = { ...baseAppContextValue, - workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys!.filter(key => key !== 'billing.view'), + workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys!.filter( + (key) => key !== 'billing.view', + ), } mockUseAppContext.mockReturnValue(contextWithoutBillingViewPermission) mockAppContextState.current = contextWithoutBillingViewPermission diff --git a/web/app/components/header/account-setting/__tests__/menu-dialog.dialog.spec.tsx b/web/app/components/header/account-setting/__tests__/menu-dialog.dialog.spec.tsx index 3ed0b70968e7e9..cf0e66a7572bf3 100644 --- a/web/app/components/header/account-setting/__tests__/menu-dialog.dialog.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/menu-dialog.dialog.spec.tsx @@ -15,7 +15,7 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({ latestOnOpenChange = onOpenChange return <div data-testid="dialog">{children}</div> }, - DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => ( + DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => ( <div className={className}>{children}</div> ), })) diff --git a/web/app/components/header/account-setting/__tests__/permission-group-list.spec.tsx b/web/app/components/header/account-setting/__tests__/permission-group-list.spec.tsx index 4f6908d6c573fd..062fa3cc61a796 100644 --- a/web/app/components/header/account-setting/__tests__/permission-group-list.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/permission-group-list.spec.tsx @@ -37,9 +37,11 @@ const permissionGroups = [ }), ] -const getPermissionRow = (permissionName: string) => screen.getByText(permissionName).closest('div')! +const getPermissionRow = (permissionName: string) => + screen.getByText(permissionName).closest('div')! -const getPermissionCheckbox = (permissionName: string) => within(getPermissionRow(permissionName)).getByRole('checkbox') +const getPermissionCheckbox = (permissionName: string) => + within(getPermissionRow(permissionName)).getByRole('checkbox') describe('PermissionGroupList', () => { beforeEach(() => { @@ -50,11 +52,7 @@ describe('PermissionGroupList', () => { describe('Rendering', () => { it('should render the list as a flexible scroll region', () => { const { container } = render( - <PermissionGroupList - groups={permissionGroups} - value={[]} - onChange={vi.fn()} - />, + <PermissionGroupList groups={permissionGroups} value={[]} onChange={vi.fn()} />, ) expect(container.firstElementChild).toHaveClass('min-h-0', 'flex-1') @@ -64,7 +62,12 @@ describe('PermissionGroupList', () => { it('should render an empty state when there are no permission groups', () => { render(<PermissionGroupList groups={[]} value={[]} onChange={vi.fn()} />) - expect(screen.getByText('permission.permissionList.noPermissionsFound')).toHaveClass('flex', 'h-full', 'items-center', 'justify-center') + expect(screen.getByText('permission.permissionList.noPermissionsFound')).toHaveClass( + 'flex', + 'h-full', + 'items-center', + 'justify-center', + ) }) it('should expand the first selected group by default', () => { @@ -76,7 +79,10 @@ describe('PermissionGroupList', () => { />, ) - expect(screen.getByRole('button', { name: /API access/ })).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('button', { name: /API access/ })).toHaveAttribute( + 'aria-expanded', + 'true', + ) expect(getPermissionCheckbox('View API')).toHaveAttribute('aria-checked', 'true') expect(screen.queryByText('Import DSL')).not.toBeInTheDocument() }) @@ -106,7 +112,9 @@ describe('PermissionGroupList', () => { const apiGroupButton = screen.getByRole('button', { name: /API access/ }) expect(apiGroupButton).toHaveAttribute('aria-expanded', 'false') - await user.click(screen.getByRole('button', { name: 'permission.permissionList.expandGroup' })) + await user.click( + screen.getByRole('button', { name: 'permission.permissionList.expandGroup' }), + ) expect(apiGroupButton).toHaveAttribute('aria-expanded', 'true') }) @@ -171,7 +179,11 @@ describe('PermissionGroupList', () => { const appManagementButton = screen.getByRole('button', { name: /App management/ }) const appManagementRow = appManagementButton.parentElement! - await user.click(within(appManagementRow).getByRole('button', { name: 'permission.permissionList.selectAll' })) + await user.click( + within(appManagementRow).getByRole('button', { + name: 'permission.permissionList.selectAll', + }), + ) expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith(['app.dsl.import', 'app.dsl.export']) @@ -191,7 +203,11 @@ describe('PermissionGroupList', () => { ) const appManagementRow = screen.getByRole('button', { name: /App management/ }).parentElement! - await user.click(within(appManagementRow).getByRole('button', { name: 'permission.permissionList.clearAll' })) + await user.click( + within(appManagementRow).getByRole('button', { + name: 'permission.permissionList.clearAll', + }), + ) expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith(['app.api.view']) @@ -210,8 +226,12 @@ describe('PermissionGroupList', () => { />, ) - expect(screen.queryByRole('button', { name: 'permission.permissionList.selectAll' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'permission.permissionList.clearAll' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'permission.permissionList.selectAll' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'permission.permissionList.clearAll' }), + ).not.toBeInTheDocument() }) it('should not change permissions when clicking a row or checkbox in read-only mode', async () => { @@ -237,12 +257,7 @@ describe('PermissionGroupList', () => { const user = userEvent.setup() render( - <PermissionGroupList - groups={permissionGroups} - value={[]} - onChange={vi.fn()} - readonly - />, + <PermissionGroupList groups={permissionGroups} value={[]} onChange={vi.fn()} readonly />, ) const apiGroupButton = screen.getByRole('button', { name: /API access/ }) diff --git a/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx b/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx index 1458453b316303..c62c6cf6ae006e 100644 --- a/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx @@ -1,6 +1,9 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' +import { + AUTO_UPDATE_MODE, + AUTO_UPDATE_STRATEGY, +} from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { ACCOUNT_SETTING_TAB } from '../constants' import UpdateSettingDialogForm from '../update-setting-dialog-form' @@ -8,14 +11,18 @@ import UpdateSettingDialogForm from '../update-setting-dialog-form' const mockSetShowAccountSettingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: (selector: (s: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => typeof mockSetShowAccountSettingModal) => { + useModalContextSelector: ( + selector: (s: { + setShowAccountSettingModal: typeof mockSetShowAccountSettingModal + }) => typeof mockSetShowAccountSettingModal, + ) => { return selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }) }, })) vi.mock('react-i18next', async () => { const { withSelectorKey, withSelectorKeyProps } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: (defaultNs?: string) => ({ t: withSelectorKey((key: string, options?: Record<string, unknown>) => { const ns = (options?.ns as string | undefined) ?? defaultNs @@ -26,26 +33,33 @@ vi.mock('react-i18next', async () => { changeLanguage: vi.fn(), }, }), - Trans: withSelectorKeyProps(({ i18nKey, components }: { - i18nKey: string - components?: Record<string, React.ReactElement> - }) => { - const setTimezone = components?.setTimezone - if (setTimezone) - return React.cloneElement(setTimezone, undefined, i18nKey) + Trans: withSelectorKeyProps( + ({ + i18nKey, + components, + }: { + i18nKey: string + components?: Record<string, React.ReactElement> + }) => { + const setTimezone = components?.setTimezone + if (setTimezone) return React.cloneElement(setTimezone, undefined, i18nKey) - return <span>{i18nKey}</span> - }), - }) + return <span>{i18nKey}</span> + }, + ), + } }) vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({ default: () => <div data-testid="time-picker" />, })) -vi.mock('@/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker', () => ({ - default: () => <div data-testid="plugins-picker" />, -})) +vi.mock( + '@/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker', + () => ({ + default: () => <div data-testid="plugins-picker" />, + }), +) describe('UpdateSettingDialogForm', () => { beforeEach(() => { @@ -66,15 +80,11 @@ describe('UpdateSettingDialogForm', () => { }} category={PluginCategoryEnum.tool} plugins={[]} - scopeOptions={[ - { value: AUTO_UPDATE_MODE.update_all, label: 'All' }, - ]} - strategyOptions={[ - { value: AUTO_UPDATE_STRATEGY.fixOnly, label: 'Fix only' }, - ]} + scopeOptions={[{ value: AUTO_UPDATE_MODE.update_all, label: 'All' }]} + strategyOptions={[{ value: AUTO_UPDATE_STRATEGY.fixOnly, label: 'Fix only' }]} timezone="UTC" updateTimeValue="00:00" - minuteFilter={minutes => minutes} + minuteFilter={(minutes) => minutes} onAutoUpgradeChange={vi.fn()} onPluginsChange={vi.fn()} onRequestClose={onRequestClose} @@ -86,6 +96,8 @@ describe('UpdateSettingDialogForm', () => { fireEvent.click(screen.getByText('autoUpdate.changeTimezone')) expect(onRequestClose).toHaveBeenCalledTimes(1) - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PREFERENCES }) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.PREFERENCES, + }) }) }) diff --git a/web/app/components/header/account-setting/__tests__/use-integrations-setting.spec.ts b/web/app/components/header/account-setting/__tests__/use-integrations-setting.spec.ts index e8b5536b40ec0b..2e0af7180468d1 100644 --- a/web/app/components/header/account-setting/__tests__/use-integrations-setting.spec.ts +++ b/web/app/components/header/account-setting/__tests__/use-integrations-setting.spec.ts @@ -48,7 +48,10 @@ describe('useIntegrationsSetting', () => { result.current({ payload: ACCOUNT_SETTING_TAB.PROVIDER, source: 'agent' }) }) - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider', source: 'agent' }) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: 'provider', + source: 'agent', + }) }) it('should preserve the cancel callback for migrated integrations settings', () => { @@ -59,6 +62,9 @@ describe('useIntegrationsSetting', () => { result.current({ payload: ACCOUNT_SETTING_TAB.PROVIDER, onCancelCallback }) }) - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider', onCancelCallback }) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: 'provider', + onCancelCallback, + }) }) }) diff --git a/web/app/components/header/account-setting/__tests__/workspace-role-checkbox-list.spec.tsx b/web/app/components/header/account-setting/__tests__/workspace-role-checkbox-list.spec.tsx index 3ff3cbf403269c..e9d6ba41c3a385 100644 --- a/web/app/components/header/account-setting/__tests__/workspace-role-checkbox-list.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/workspace-role-checkbox-list.spec.tsx @@ -28,15 +28,17 @@ describe('WorkspaceRoleCheckboxList', () => { vi.clearAllMocks() vi.mocked(useWorkspaceRoleList).mockReturnValue({ data: { - pages: [{ - data: mockRoles, - pagination: { - total_count: 2, - per_page: 20, - current_page: 1, - total_pages: 1, + pages: [ + { + data: mockRoles, + pagination: { + total_count: 2, + per_page: 20, + current_page: 1, + total_pages: 1, + }, }, - }], + ], pageParams: [1], }, isLoading: false, @@ -77,20 +79,22 @@ describe('WorkspaceRoleCheckboxList', () => { it('should show legacy role descriptions when only one role is allowed', () => { vi.mocked(useWorkspaceRoleList).mockReturnValue({ data: { - pages: [{ - data: [ - createRole({ id: 'admin', name: 'admin' }), - createRole({ id: 'editor', name: 'editor' }), - createRole({ id: 'normal', name: 'normal' }), - createRole({ id: 'dataset_operator', name: 'dataset_operator' }), - ], - pagination: { - total_count: 4, - per_page: 20, - current_page: 1, - total_pages: 1, + pages: [ + { + data: [ + createRole({ id: 'admin', name: 'admin' }), + createRole({ id: 'editor', name: 'editor' }), + createRole({ id: 'normal', name: 'normal' }), + createRole({ id: 'dataset_operator', name: 'dataset_operator' }), + ], + pagination: { + total_count: 4, + per_page: 20, + current_page: 1, + total_pages: 1, + }, }, - }], + ], pageParams: [1], }, isLoading: false, 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 a8e375d198e9f4..3151647033e49f 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 @@ -41,7 +41,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -59,19 +60,23 @@ const rule: AccessPolicyWithBindings = { created_at: '2026-01-01', updated_at: '2026-01-01', }, - roles: [{ - role_id: 'role-1', - role_name: 'Admin', - binding_id: 'role-binding-1', - is_locked: true, - role_tag: '', - }], - accounts: [{ - account_id: 'account-1', - account_name: 'Levi', - binding_id: 'account-binding-1', - is_locked: false, - }], + roles: [ + { + role_id: 'role-1', + role_name: 'Admin', + binding_id: 'role-binding-1', + is_locked: true, + role_tag: '', + }, + ], + accounts: [ + { + account_id: 'account-1', + account_name: 'Levi', + binding_id: 'account-binding-1', + is_locked: false, + }, + ], } let intersectionObserverCallback: IntersectionObserverCallback | null = null @@ -93,11 +98,7 @@ const renderWithQueryClient = (children: ReactNode) => { }, }) - return render( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) } describe('AccessRuleSection', () => { @@ -105,7 +106,8 @@ describe('AccessRuleSection', () => { vi.clearAllMocks() mocks.workspacePermissionKeys = [] intersectionObserverCallback = null - globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver + globalThis.IntersectionObserver = + MockIntersectionObserver as unknown as typeof IntersectionObserver }) it('should render a collapsed group and expand it when the header is clicked', async () => { @@ -143,13 +145,15 @@ describe('AccessRuleSection', () => { render( <AccessRuleSection title="App Access Rules" - rules={[{ - ...rule, - policy: { - ...rule.policy, - description: '', + rules={[ + { + ...rule, + policy: { + ...rule.policy, + description: '', + }, }, - }]} + ]} totalCount={1} isLoadingRules={false} defaultExpanded @@ -220,7 +224,9 @@ describe('AccessRuleSection', () => { />, ) - await userEvent.click(screen.getByRole('button', { name: /permission\.accessRule\.newPermissionSet/ })) + await userEvent.click( + screen.getByRole('button', { name: /permission\.accessRule\.newPermissionSet/ }), + ) expect(onCreate).toHaveBeenCalledTimes(1) }) @@ -251,6 +257,8 @@ describe('AccessRuleSection', () => { />, ) - expect(screen.queryByRole('button', { name: /permission\.accessRule\.newPermissionSet/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /permission\.accessRule\.newPermissionSet/ }), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/header/account-setting/access-rules-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/access-rules-page/__tests__/index.spec.tsx index 1658b8510926da..6d498d4fe85104 100644 --- a/web/app/components/header/account-setting/access-rules-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/access-rules-page/__tests__/index.spec.tsx @@ -67,18 +67,20 @@ vi.mock('../permission-set-modal', () => ({ }: { mode: string resourceType: string - initialValues?: { name: string, description: string, permissionKeys: string[] } - onSubmit: (values: { name: string, description: string, permissionKeys: string[] }) => void + initialValues?: { name: string; description: string; permissionKeys: string[] } + onSubmit: (values: { name: string; description: string; permissionKeys: string[] }) => void }) => ( <div role="dialog" aria-label={`${resourceType} ${mode}`}> <span>{initialValues?.name}</span> <button type="button" - onClick={() => onSubmit({ - name: `${resourceType} permission`, - description: `${resourceType} description`, - permissionKeys: [`${resourceType}.acl.edit`], - })} + onClick={() => + onSubmit({ + name: `${resourceType} permission`, + description: `${resourceType} description`, + permissionKeys: [`${resourceType}.acl.edit`], + }) + } > submit permission set </button> @@ -100,19 +102,23 @@ const appRule: AccessPolicyWithBindings = { created_at: '2026-01-01', updated_at: '2026-01-01', }, - roles: [{ - role_id: 'role-1', - role_name: 'Role 1', - binding_id: 'role-binding-1', - is_locked: false, - role_tag: '', - }], - accounts: [{ - account_id: 'member-1', - account_name: 'Member 1', - binding_id: 'member-binding-1', - is_locked: false, - }], + roles: [ + { + role_id: 'role-1', + role_name: 'Role 1', + binding_id: 'role-binding-1', + is_locked: false, + role_tag: '', + }, + ], + accounts: [ + { + account_id: 'member-1', + account_name: 'Member 1', + binding_id: 'member-binding-1', + is_locked: false, + }, + ], } const datasetRule: AccessPolicyWithBindings = { @@ -124,19 +130,23 @@ const datasetRule: AccessPolicyWithBindings = { name: 'Dataset rule', permission_keys: ['dataset.acl.edit'], }, - roles: [{ - role_id: 'dataset-role-1', - role_name: 'Dataset Role 1', - binding_id: 'dataset-role-binding-1', - is_locked: false, - role_tag: '', - }], - accounts: [{ - account_id: 'dataset-member-1', - account_name: 'Dataset Member 1', - binding_id: 'dataset-member-binding-1', - is_locked: false, - }], + roles: [ + { + role_id: 'dataset-role-1', + role_name: 'Dataset Role 1', + binding_id: 'dataset-role-binding-1', + is_locked: false, + role_tag: '', + }, + ], + accounts: [ + { + account_id: 'dataset-member-1', + account_name: 'Dataset Member 1', + binding_id: 'dataset-member-binding-1', + is_locked: false, + }, + ], } const mockMutation = (mock: ReturnType<typeof vi.fn>) => { @@ -175,8 +185,12 @@ const setupHooks = () => { hasNextPage: false, error: null, } as unknown as ReturnType<typeof useInfiniteWorkspaceDatasetAccessRules>) - vi.mocked(useCreateAccessRule).mockReturnValue(mockMutation(mocks.createAccessRule) as unknown as ReturnType<typeof useCreateAccessRule>) - vi.mocked(useUpdateAccessRule).mockReturnValue(mockMutation(mocks.updateAccessRule) as unknown as ReturnType<typeof useUpdateAccessRule>) + vi.mocked(useCreateAccessRule).mockReturnValue( + mockMutation(mocks.createAccessRule) as unknown as ReturnType<typeof useCreateAccessRule>, + ) + vi.mocked(useUpdateAccessRule).mockReturnValue( + mockMutation(mocks.updateAccessRule) as unknown as ReturnType<typeof useUpdateAccessRule>, + ) } describe('AccessRulesPage', () => { @@ -188,54 +202,73 @@ describe('AccessRulesPage', () => { it('renders app and dataset access rule sections', () => { render(<AccessRulesPage />) - expect(screen.getByRole('heading', { name: 'permission.accessRule.appTitle' })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'permission.accessRule.datasetTitle' })).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'permission.accessRule.appTitle' }), + ).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'permission.accessRule.datasetTitle' }), + ).toBeInTheDocument() }) it('creates app permission sets with app resource type', async () => { render(<AccessRulesPage />) - await userEvent.click(screen.getByRole('button', { name: 'create permission.accessRule.appTitle' })) + await userEvent.click( + screen.getByRole('button', { name: 'create permission.accessRule.appTitle' }), + ) await userEvent.click(screen.getByRole('button', { name: 'submit permission set' })) - expect(mocks.createAccessRule).toHaveBeenCalledWith({ - name: 'app permission', - description: 'app description', - permission_keys: ['app.acl.edit'], - resourceType: 'app', - }, expect.any(Object)) + expect(mocks.createAccessRule).toHaveBeenCalledWith( + { + name: 'app permission', + description: 'app description', + permission_keys: ['app.acl.edit'], + resourceType: 'app', + }, + expect.any(Object), + ) expect(toast.success).toHaveBeenCalledWith('permission.accessRule.created') }) it('creates dataset permission sets with dataset resource type', async () => { render(<AccessRulesPage />) - await userEvent.click(screen.getByRole('button', { name: 'create permission.accessRule.datasetTitle' })) + await userEvent.click( + screen.getByRole('button', { name: 'create permission.accessRule.datasetTitle' }), + ) await userEvent.click(screen.getByRole('button', { name: 'submit permission set' })) - expect(mocks.createAccessRule).toHaveBeenCalledWith({ - name: 'dataset permission', - description: 'dataset description', - permission_keys: ['dataset.acl.edit'], - resourceType: 'dataset', - }, expect.any(Object)) + expect(mocks.createAccessRule).toHaveBeenCalledWith( + { + name: 'dataset permission', + description: 'dataset description', + permission_keys: ['dataset.acl.edit'], + resourceType: 'dataset', + }, + expect.any(Object), + ) expect(toast.success).toHaveBeenCalledWith('permission.accessRule.created') }) it('updates dataset permission sets with dataset resource type', async () => { render(<AccessRulesPage />) - await userEvent.click(screen.getByRole('button', { name: 'edit permission.accessRule.datasetTitle' })) + await userEvent.click( + screen.getByRole('button', { name: 'edit permission.accessRule.datasetTitle' }), + ) expect(screen.getByText('Dataset rule')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'submit permission set' })) - expect(mocks.updateAccessRule).toHaveBeenCalledWith({ - id: 'dataset-rule-1', - name: 'dataset permission', - description: 'dataset description', - permission_keys: ['dataset.acl.edit'], - resourceType: 'dataset', - }, expect.any(Object)) + expect(mocks.updateAccessRule).toHaveBeenCalledWith( + { + id: 'dataset-rule-1', + name: 'dataset permission', + description: 'dataset description', + permission_keys: ['dataset.acl.edit'], + resourceType: 'dataset', + }, + expect.any(Object), + ) expect(toast.success).toHaveBeenCalledWith('permission.accessRule.updated') }) }) diff --git a/web/app/components/header/account-setting/access-rules-page/access-rule-row-menu.tsx b/web/app/components/header/account-setting/access-rules-page/access-rule-row-menu.tsx index 9cd6b2914f7772..20fda9555aebe6 100644 --- a/web/app/components/header/account-setting/access-rules-page/access-rule-row-menu.tsx +++ b/web/app/components/header/account-setting/access-rules-page/access-rule-row-menu.tsx @@ -21,7 +21,10 @@ import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import { useCopyAccessRule, useDeleteAccessRule } from '@/service/access-control/use-workspace-access-rules' +import { + useCopyAccessRule, + useDeleteAccessRule, +} from '@/service/access-control/use-workspace-access-rules' type AccessRuleRowMenuProps = { rule: AccessPolicy @@ -29,17 +32,15 @@ type AccessRuleRowMenuProps = { onEdit?: () => void } -const AccessRuleRowMenu = ({ - rule, - onView, - onEdit, -}: AccessRuleRowMenuProps) => { +const AccessRuleRowMenu = ({ rule, onView, onEdit }: AccessRuleRowMenuProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const { mutateAsync: copyAccessRule } = useCopyAccessRule(rule.resource_type) - const { mutateAsync: deleteAccessRule, isPending: isDeletingAccessRule } = useDeleteAccessRule(rule.resource_type) + const { mutateAsync: deleteAccessRule, isPending: isDeletingAccessRule } = useDeleteAccessRule( + rule.resource_type, + ) const handleView = useCallback(() => { onView?.() @@ -48,7 +49,7 @@ const AccessRuleRowMenu = ({ const handleCopyRules = useCallback(() => { copyAccessRule(rule.id, { onSuccess: () => { - toast.success(t($ => $['accessRule.copied'], { ns: 'permission' })) + toast.success(t(($) => $['accessRule.copied'], { ns: 'permission' })) setOpen(false) }, }) @@ -62,7 +63,7 @@ const AccessRuleRowMenu = ({ const handleDelete = useCallback(() => { deleteAccessRule(rule.id, { onSuccess: () => { - toast.success(t($ => $['accessRule.deleted'], { ns: 'permission' })) + toast.success(t(($) => $['accessRule.deleted'], { ns: 'permission' })) setShowDeleteConfirm(false) }, }) @@ -74,43 +75,34 @@ const AccessRuleRowMenu = ({ <> <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger - render={( + render={ <ActionButton size="l" className={open ? 'bg-state-base-hover' : ''} - aria-label={t($ => $['operation.moreActions'], { ns: 'common' })} + aria-label={t(($) => $['operation.moreActions'], { ns: 'common' })} /> - )} + } > <span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" /> </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="min-w-[140px]" - > - {isBuiltIn - ? ( - <DropdownMenuItem - className="system-sm-semibold text-text-secondary" - onClick={handleView} - > - {t($ => $['operation.view'], { ns: 'common' })} - </DropdownMenuItem> - ) - : ( - <DropdownMenuItem - className="system-sm-semibold text-text-secondary" - onClick={onEdit} - > - {t($ => $['operation.edit'], { ns: 'common' })} - </DropdownMenuItem> - )} + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[140px]"> + {isBuiltIn ? ( + <DropdownMenuItem + className="system-sm-semibold text-text-secondary" + onClick={handleView} + > + {t(($) => $['operation.view'], { ns: 'common' })} + </DropdownMenuItem> + ) : ( + <DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={onEdit}> + {t(($) => $['operation.edit'], { ns: 'common' })} + </DropdownMenuItem> + )} <DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={handleCopyRules} > - {t($ => $['common.duplicateAction'], { ns: 'permission' })} + {t(($) => $['common.duplicateAction'], { ns: 'permission' })} </DropdownMenuItem> {!isBuiltIn && ( <> @@ -120,29 +112,31 @@ const AccessRuleRowMenu = ({ className="system-sm-semibold" onClick={openDeleteConfirm} > - {t($ => $['operation.delete'], { ns: 'common' })} + {t(($) => $['operation.delete'], { ns: 'common' })} </DropdownMenuItem> </> )} </DropdownMenuContent> </DropdownMenu> - <AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && setShowDeleteConfirm(false)}> + <AlertDialog + open={showDeleteConfirm} + onOpenChange={(open) => !open && setShowDeleteConfirm(false)} + > <AlertDialogContent backdropProps={{ forceRender: true }}> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['accessRule.deleteTitle'], { ns: 'permission', name: rule.name })} + {t(($) => $['accessRule.deleteTitle'], { ns: 'permission', name: rule.name })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['accessRule.deleteDescription'], { ns: 'permission' })} + {t(($) => $['accessRule.deleteDescription'], { ns: 'permission' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton - disabled={isDeletingAccessRule} - onClick={handleDelete} - > - {t($ => $['operation.delete'], { ns: 'common' })} + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton disabled={isDeletingAccessRule} onClick={handleDelete}> + {t(($) => $['operation.delete'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/header/account-setting/access-rules-page/access-rule-row.tsx b/web/app/components/header/account-setting/access-rules-page/access-rule-row.tsx index efea76e3886351..b5a8e7df5a566a 100644 --- a/web/app/components/header/account-setting/access-rules-page/access-rule-row.tsx +++ b/web/app/components/header/account-setting/access-rules-page/access-rule-row.tsx @@ -25,7 +25,8 @@ const AccessRuleRow = ({ }: AccessRuleRowProps) => { const { t } = useTranslation() const { policy } = rule - const description = policy.description.trim() || t($ => $['accessRule.noDescription'], { ns: 'permission' }) + const description = + policy.description.trim() || t(($) => $['accessRule.noDescription'], { ns: 'permission' }) const handleView = useCallback(() => onView?.(rule), [onView, rule]) const handleEdit = useCallback(() => onEdit?.(rule), [onEdit, rule]) @@ -36,16 +37,10 @@ const AccessRuleRow = ({ <div className="flex h-6 items-center system-sm-semibold text-text-primary"> {policy.name} </div> - <p className="system-xs-regular leading-4 text-text-tertiary"> - {description} - </p> + <p className="system-xs-regular leading-4 text-text-tertiary">{description}</p> </div> {showMenu && canManage && ( - <AccessRuleRowMenu - onView={handleView} - onEdit={handleEdit} - rule={policy} - /> + <AccessRuleRowMenu onView={handleView} onEdit={handleEdit} rule={policy} /> )} </div> ) 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 417856fe8aa3f8..5d244bcf1a3413 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 @@ -55,20 +55,28 @@ const AccessRuleSection = ({ const hasMore = hasNextPage ?? true let observer: IntersectionObserver | undefined - if (!expanded || error || !fetchNextPage) - return + if (!expanded || error || !fetchNextPage) return if (anchorRef.current && listRef.current) { const containerHeight = listRef.current.clientHeight const dynamicMargin = Math.max(48, Math.min(containerHeight * 0.2, 120)) - observer = new IntersectionObserver((entries) => { - if (entries[0]!.isIntersecting && !isLoadingRules && !isFetchingNextPage && !error && hasMore) - fetchNextPage() - }, { - root: listRef.current, - rootMargin: `${dynamicMargin}px`, - }) + observer = new IntersectionObserver( + (entries) => { + if ( + entries[0]!.isIntersecting && + !isLoadingRules && + !isFetchingNextPage && + !error && + hasMore + ) + fetchNextPage() + }, + { + root: listRef.current, + rootMargin: `${dynamicMargin}px`, + }, + ) observer.observe(anchorRef.current) } @@ -76,39 +84,41 @@ const AccessRuleSection = ({ }, [error, expanded, fetchNextPage, hasNextPage, isFetchingNextPage, isLoadingRules]) return ( - <section className={cn('overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg', className)}> + <section + className={cn( + 'overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg', + className, + )} + > <div className="flex items-center gap-4 p-4"> <button type="button" className="min-w-0 flex-1 text-left" - onClick={() => setExpanded(expanded => !expanded)} + onClick={() => setExpanded((expanded) => !expanded)} aria-expanded={expanded} > <div className="flex min-w-0 items-center gap-4"> - <span className="truncate system-sm-semibold text-text-primary"> - {title} - </span> + <span className="truncate system-sm-semibold text-text-primary">{title}</span> <span className="shrink-0 system-xs-regular text-text-tertiary"> - {t($ => $['accessRule.summary'], { ns: 'permission', count: ruleCount })} + {t(($) => $['accessRule.summary'], { ns: 'permission', count: ruleCount })} </span> </div> </button> <div className="flex shrink-0 items-center gap-3"> {canManage && ( - <Button - variant="primary" - size="medium" - onClick={onCreate} - disabled={isLoadingRules} - > + <Button variant="primary" size="medium" onClick={onCreate} disabled={isLoadingRules}> <span className="mr-0.5 i-ri-add-line size-3.5" /> - <span>{t($ => $['accessRule.newPermissionSet'], { ns: 'permission' })}</span> + <span>{t(($) => $['accessRule.newPermissionSet'], { ns: 'permission' })}</span> </Button> )} <ActionButton size="l" - aria-label={expanded ? t($ => $['accessRule.collapseSection'], { ns: 'permission', title }) : t($ => $['accessRule.expandSection'], { ns: 'permission', title })} - onClick={() => setExpanded(expanded => !expanded)} + aria-label={ + expanded + ? t(($) => $['accessRule.collapseSection'], { ns: 'permission', title }) + : t(($) => $['accessRule.expandSection'], { ns: 'permission', title }) + } + onClick={() => setExpanded((expanded) => !expanded)} > <span aria-hidden @@ -125,38 +135,34 @@ const AccessRuleSection = ({ ref={listRef} className="max-h-105 overflow-y-auto overscroll-contain border-t border-divider-deep px-4" > - {isLoadingRules - ? ( - <div className="px-1 py-8 text-center"> + {isLoadingRules ? ( + <div className="px-1 py-8 text-center"> + <Loading type="app" /> + </div> + ) : rules.length === 0 ? ( + <div className="px-1 py-8 text-center system-sm-regular text-text-tertiary"> + {t(($) => $['accessRule.noRules'], { ns: 'permission' })} + </div> + ) : ( + <> + {rules.map((rule, index) => ( + <AccessRuleRow + key={rule.policy.id} + rule={rule} + canManage={canManage} + className={cn(index > 0 && 'border-t border-divider-regular')} + onView={onViewRule} + onEdit={onEditRule} + /> + ))} + <div ref={anchorRef} className="h-1" /> + {isFetchingNextPage && ( + <div className="px-1 py-3 text-center system-xs-regular text-text-tertiary"> <Loading type="app" /> </div> - ) - : rules.length === 0 - ? ( - <div className="px-1 py-8 text-center system-sm-regular text-text-tertiary"> - {t($ => $['accessRule.noRules'], { ns: 'permission' })} - </div> - ) - : ( - <> - {rules.map((rule, index) => ( - <AccessRuleRow - key={rule.policy.id} - rule={rule} - canManage={canManage} - className={cn(index > 0 && 'border-t border-divider-regular')} - onView={onViewRule} - onEdit={onEditRule} - /> - ))} - <div ref={anchorRef} className="h-1" /> - {isFetchingNextPage && ( - <div className="px-1 py-3 text-center system-xs-regular text-text-tertiary"> - <Loading type="app" /> - </div> - )} - </> - )} + )} + </> + )} </div> )} </section> diff --git a/web/app/components/header/account-setting/access-rules-page/app-access-rule-section.tsx b/web/app/components/header/account-setting/access-rules-page/app-access-rule-section.tsx index eb5a2ee9688b1b..93620bbc74a703 100644 --- a/web/app/components/header/account-setting/access-rules-page/app-access-rule-section.tsx +++ b/web/app/components/header/account-setting/access-rules-page/app-access-rule-section.tsx @@ -25,12 +25,11 @@ type PermissionSetModalState = { const RULES_PER_PAGE = 20 -const AppAccessRuleSection = ({ - className, -}: AppAccessRuleSectionProps) => { +const AppAccessRuleSection = ({ className }: AppAccessRuleSectionProps) => { const { t } = useTranslation() const locale = useLocale() - const [permissionSetModalState, setPermissionSetModalState] = useState<PermissionSetModalState | null>(null) + const [permissionSetModalState, setPermissionSetModalState] = + useState<PermissionSetModalState | null>(null) const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale]) const { @@ -48,7 +47,10 @@ const AppAccessRuleSection = ({ const { mutateAsync: createAccessRule } = useCreateAccessRule() const { mutateAsync: updateAccessRule } = useUpdateAccessRule() - const appAccessRules = useMemo(() => appAccessRulesResponse?.pages.flatMap(page => page.items) || [], [appAccessRulesResponse?.pages]) + const appAccessRules = useMemo( + () => appAccessRulesResponse?.pages.flatMap((page) => page.items) || [], + [appAccessRulesResponse?.pages], + ) const totalCount = appAccessRulesResponse?.pages[0]?.pagination.total_count || 0 const closePermissionSetModal = useCallback(() => { @@ -84,44 +86,51 @@ const AppAccessRuleSection = ({ }) }, []) - const handlePermissionSetSubmit = useCallback((values: PermissionSetFormValues) => { - if (!permissionSetModalState) - return + const handlePermissionSetSubmit = useCallback( + (values: PermissionSetFormValues) => { + if (!permissionSetModalState) return - const { name, description, permissionKeys } = values - if (permissionSetModalState.mode === 'create') { - createAccessRule({ - name, - description, - permission_keys: permissionKeys, - resourceType: 'app', - }, { - onSuccess: () => { - toast.success(t($ => $['accessRule.created'], { ns: 'permission' })) - closePermissionSetModal() - }, - }) - } - else if (permissionSetModalState.mode === 'edit') { - updateAccessRule({ - id: permissionSetModalState.ruleId!, - name, - description, - permission_keys: permissionKeys, - resourceType: 'app', - }, { - onSuccess: () => { - toast.success(t($ => $['accessRule.updated'], { ns: 'permission' })) - closePermissionSetModal() - }, - }) - } - }, [closePermissionSetModal, createAccessRule, permissionSetModalState, t, updateAccessRule]) + const { name, description, permissionKeys } = values + if (permissionSetModalState.mode === 'create') { + createAccessRule( + { + name, + description, + permission_keys: permissionKeys, + resourceType: 'app', + }, + { + onSuccess: () => { + toast.success(t(($) => $['accessRule.created'], { ns: 'permission' })) + closePermissionSetModal() + }, + }, + ) + } else if (permissionSetModalState.mode === 'edit') { + updateAccessRule( + { + id: permissionSetModalState.ruleId!, + name, + description, + permission_keys: permissionKeys, + resourceType: 'app', + }, + { + onSuccess: () => { + toast.success(t(($) => $['accessRule.updated'], { ns: 'permission' })) + closePermissionSetModal() + }, + }, + ) + } + }, + [closePermissionSetModal, createAccessRule, permissionSetModalState, t, updateAccessRule], + ) return ( <> <AccessRuleSection - title={t($ => $['accessRule.appTitle'], { ns: 'permission' })} + title={t(($) => $['accessRule.appTitle'], { ns: 'permission' })} rules={appAccessRules} totalCount={totalCount} isLoadingRules={isLoading} diff --git a/web/app/components/header/account-setting/access-rules-page/dataset-access-rule-section.tsx b/web/app/components/header/account-setting/access-rules-page/dataset-access-rule-section.tsx index 974f45d9071793..a0f825f1a95cb2 100644 --- a/web/app/components/header/account-setting/access-rules-page/dataset-access-rule-section.tsx +++ b/web/app/components/header/account-setting/access-rules-page/dataset-access-rule-section.tsx @@ -25,12 +25,11 @@ type PermissionSetModalState = { const RULES_PER_PAGE = 20 -const DatasetAccessRuleSection = ({ - className, -}: DatasetAccessRuleSectionProps) => { +const DatasetAccessRuleSection = ({ className }: DatasetAccessRuleSectionProps) => { const { t } = useTranslation() const locale = useLocale() - const [permissionSetModalState, setPermissionSetModalState] = useState<PermissionSetModalState | null>(null) + const [permissionSetModalState, setPermissionSetModalState] = + useState<PermissionSetModalState | null>(null) const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale]) const { @@ -48,7 +47,10 @@ const DatasetAccessRuleSection = ({ const { mutateAsync: createAccessRule } = useCreateAccessRule() const { mutateAsync: updateAccessRule } = useUpdateAccessRule() - const datasetAccessRules = useMemo(() => datasetAccessRulesResponse?.pages.flatMap(page => page.items) || [], [datasetAccessRulesResponse?.pages]) + const datasetAccessRules = useMemo( + () => datasetAccessRulesResponse?.pages.flatMap((page) => page.items) || [], + [datasetAccessRulesResponse?.pages], + ) const totalCount = datasetAccessRulesResponse?.pages[0]?.pagination.total_count || 0 const closePermissionSetModal = useCallback(() => { @@ -84,44 +86,51 @@ const DatasetAccessRuleSection = ({ }) }, []) - const handlePermissionSetSubmit = useCallback((values: PermissionSetFormValues) => { - if (!permissionSetModalState) - return + const handlePermissionSetSubmit = useCallback( + (values: PermissionSetFormValues) => { + if (!permissionSetModalState) return - const { name, description, permissionKeys } = values - if (permissionSetModalState.mode === 'create') { - createAccessRule({ - name, - description, - permission_keys: permissionKeys, - resourceType: 'dataset', - }, { - onSuccess: () => { - toast.success(t($ => $['accessRule.created'], { ns: 'permission' })) - closePermissionSetModal() - }, - }) - } - else if (permissionSetModalState.mode === 'edit') { - updateAccessRule({ - id: permissionSetModalState.ruleId!, - name, - description, - permission_keys: permissionKeys, - resourceType: 'dataset', - }, { - onSuccess: () => { - toast.success(t($ => $['accessRule.updated'], { ns: 'permission' })) - closePermissionSetModal() - }, - }) - } - }, [closePermissionSetModal, createAccessRule, permissionSetModalState, t, updateAccessRule]) + const { name, description, permissionKeys } = values + if (permissionSetModalState.mode === 'create') { + createAccessRule( + { + name, + description, + permission_keys: permissionKeys, + resourceType: 'dataset', + }, + { + onSuccess: () => { + toast.success(t(($) => $['accessRule.created'], { ns: 'permission' })) + closePermissionSetModal() + }, + }, + ) + } else if (permissionSetModalState.mode === 'edit') { + updateAccessRule( + { + id: permissionSetModalState.ruleId!, + name, + description, + permission_keys: permissionKeys, + resourceType: 'dataset', + }, + { + onSuccess: () => { + toast.success(t(($) => $['accessRule.updated'], { ns: 'permission' })) + closePermissionSetModal() + }, + }, + ) + } + }, + [closePermissionSetModal, createAccessRule, permissionSetModalState, t, updateAccessRule], + ) return ( <> <AccessRuleSection - title={t($ => $['accessRule.datasetTitle'], { ns: 'permission' })} + title={t(($) => $['accessRule.datasetTitle'], { ns: 'permission' })} rules={datasetAccessRules} totalCount={totalCount} isLoadingRules={isLoading} diff --git a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx index bb893525126cb1..3e255fac2fd654 100644 --- a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx @@ -16,7 +16,8 @@ const expectedAppACLPermissionKeys = [ 'app.acl.access_config', ] -const getPermissionKeyMatcher = (permissionKey: string) => new RegExp(permissionKey.replaceAll('.', '\\.')) +const getPermissionKeyMatcher = (permissionKey: string) => + new RegExp(permissionKey.replaceAll('.', '\\.')) const mockCatalogs = vi.hoisted(() => ({ app: { @@ -40,7 +41,7 @@ const createPermissionGroup = (overrides: Partial<PermissionGroup> = {}): Permis group_key: 'app_management', group_name: 'App management', description: '', - permissions: expectedAppACLPermissionKeys.map(permissionKey => ({ + permissions: expectedAppACLPermissionKeys.map((permissionKey) => ({ key: permissionKey, name: permissionKey, description: '', @@ -80,10 +81,15 @@ describe('PermissionSetModal', () => { />, ) - expect(screen.getByText('permission.permissionSet.modal.create.app.title')).toBeInTheDocument() + expect( + screen.getByText('permission.permissionSet.modal.create.app.title'), + ).toBeInTheDocument() expect(screen.getByLabelText(/permission\.permissionSet\.nameLabel/)).toBeInTheDocument() expect(screen.getByLabelText('permission.permissionSet.descriptionLabel')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /App management/ })).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('button', { name: /App management/ })).toHaveAttribute( + 'aria-expanded', + 'true', + ) expect(screen.getByText(/app\.acl\.edit/)).toBeInTheDocument() }) @@ -113,7 +119,9 @@ describe('PermissionSetModal', () => { />, ) - expect(screen.getByText('permission.permissionSet.modal.create.dataset.title')).toBeInTheDocument() + expect( + screen.getByText('permission.permissionSet.modal.create.dataset.title'), + ).toBeInTheDocument() expect(screen.getByRole('button', { name: /Dataset management/ })).toBeInTheDocument() expect(screen.getByText(/dataset\.acl\.edit/)).toBeInTheDocument() }) @@ -136,8 +144,14 @@ describe('PermissionSetModal', () => { />, ) - await user.type(screen.getByLabelText(/permission\.permissionSet\.nameLabel/), ' Custom app rule ') - await user.type(screen.getByLabelText('permission.permissionSet.descriptionLabel'), ' Can edit apps ') + await user.type( + screen.getByLabelText(/permission\.permissionSet\.nameLabel/), + ' Custom app rule ', + ) + await user.type( + screen.getByLabelText('permission.permissionSet.descriptionLabel'), + ' Can edit apps ', + ) await user.click(screen.getByText(/app\.acl\.release_and_version/)) await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) @@ -203,9 +217,13 @@ describe('PermissionSetModal', () => { expect(screen.getByLabelText(/permission\.permissionSet\.nameLabel/)).toBeDisabled() expect(screen.getByLabelText('permission.permissionSet.descriptionLabel')).toBeDisabled() - expect(screen.queryByRole('button', { name: 'common.operation.confirm' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.confirm' }), + ).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'permission.permissionList.clearAll' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'permission.permissionList.clearAll' }), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/hooks.ts b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/hooks.ts index a9c304791944f7..1ca283e75842a9 100644 --- a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/hooks.ts +++ b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/hooks.ts @@ -2,7 +2,10 @@ import type { SelectorKey } from 'i18next' import type { AccessPolicyResourceType } from '@/models/access-control' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useAppPermissionCatalog, useDatasetPermissionCatalog } from '@/service/access-control/use-permission-catalog' +import { + useAppPermissionCatalog, + useDatasetPermissionCatalog, +} from '@/service/access-control/use-permission-catalog' export const usePermissionsGroups = (resourceType: AccessPolicyResourceType) => { const { t } = useTranslation() @@ -13,29 +16,28 @@ export const usePermissionsGroups = (resourceType: AccessPolicyResourceType) => const groups = useMemo(() => { // Permission keys come from the catalog API, so this is a reviewed open-key boundary with a server-provided fallback. - const translatePermissionName = (key: string, defaultValue: string) => t(key as SelectorKey, { - ns: 'permissionKeys', - defaultValue, - }) + const translatePermissionName = (key: string, defaultValue: string) => + t(key as SelectorKey, { + ns: 'permissionKeys', + defaultValue, + }) - return (permissionCatalog?.groups || []).map(group => ({ + return (permissionCatalog?.groups || []).map((group) => ({ ...group, - group_name: t($ => $[`group.${resourceType}_acl`], { + group_name: t(($) => $[`group.${resourceType}_acl`], { ns: 'permission', defaultValue: group.group_name, }), - permissions: group.permissions.map(permission => ({ + permissions: group.permissions.map((permission) => ({ ...permission, name: translatePermissionName(permission.key, permission.name), })), })) }, [permissionCatalog?.groups, resourceType, t]) - const allPermissions = groups.flatMap(g => g.permissions) || [] + const allPermissions = groups.flatMap((g) => g.permissions) || [] - const permissionMap = Object.fromEntries( - allPermissions.map(p => [p.key, p]), - ) + const permissionMap = Object.fromEntries(allPermissions.map((p) => [p.key, p])) return { groups, diff --git a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/index.tsx b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/index.tsx index f8620d06aa8bc8..93c3819e7d0120 100644 --- a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/index.tsx +++ b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/index.tsx @@ -48,15 +48,16 @@ const PermissionSetModalBody = ({ const docLanguage = getDocLanguage(locale) const [name, setName] = useState(initialValues?.name ?? '') const [description, setDescription] = useState(initialValues?.description ?? '') - const [permissionKeys, setPermissionKeys] = useState<string[]>(initialValues?.permissionKeys ?? []) + const [permissionKeys, setPermissionKeys] = useState<string[]>( + initialValues?.permissionKeys ?? [], + ) const trimmedName = name.trim() const readonly = mode === 'view' const canSubmit = trimmedName.length > 0 const handleConfirm = () => { - if (readonly || !canSubmit) - return + if (readonly || !canSubmit) return onSubmit({ name: trimmedName, description: description.trim(), @@ -74,10 +75,12 @@ const PermissionSetModalBody = ({ <DialogCloseButton /> <div className="pr-8"> <DialogTitle className="system-xl-semibold text-text-primary"> - {t($ => $[`permissionSet.modal.${mode}.${resourceType}.title`], { ns: 'permission' })} + {t(($) => $[`permissionSet.modal.${mode}.${resourceType}.title`], { ns: 'permission' })} </DialogTitle> <DialogDescription className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $[`permissionSet.modal.${mode}.${resourceType}.description`], { ns: 'permission' })} + {t(($) => $[`permissionSet.modal.${mode}.${resourceType}.description`], { + ns: 'permission', + })} </DialogDescription> </div> </div> @@ -87,34 +90,41 @@ const PermissionSetModalBody = ({ <div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-hidden px-6 py-5"> <div className="flex shrink-0 flex-col gap-1"> <label htmlFor="permission-set-name" className="system-sm-medium text-text-secondary"> - {t($ => $['permissionSet.nameLabel'], { ns: 'permission' })} - <span aria-hidden className="ml-0.5 text-text-destructive">*</span> + {t(($) => $['permissionSet.nameLabel'], { ns: 'permission' })} + <span aria-hidden className="ml-0.5 text-text-destructive"> + * + </span> </label> <Input id="permission-set-name" value={name} - onChange={e => setName(e.target.value)} - placeholder={t($ => $['permissionSet.namePlaceholder'], { ns: 'permission' })} + onChange={(e) => setName(e.target.value)} + placeholder={t(($) => $['permissionSet.namePlaceholder'], { ns: 'permission' })} disabled={readonly} /> </div> <div className="flex shrink-0 flex-col gap-1"> - <label htmlFor="permission-set-description" className="system-sm-medium text-text-secondary"> - {t($ => $['permissionSet.descriptionLabel'], { ns: 'permission' })} + <label + htmlFor="permission-set-description" + className="system-sm-medium text-text-secondary" + > + {t(($) => $['permissionSet.descriptionLabel'], { ns: 'permission' })} </label> <Textarea id="permission-set-description" value={description} - onValueChange={value => setDescription(value)} - placeholder={t($ => $['permissionSet.descriptionPlaceholder'], { ns: 'permission' })} + onValueChange={(value) => setDescription(value)} + placeholder={t(($) => $['permissionSet.descriptionPlaceholder'], { ns: 'permission' })} className="min-h-20 resize-none" disabled={readonly} /> </div> <div className="flex min-h-0 flex-1 flex-col gap-2"> - <div className="system-sm-medium text-text-secondary">{t($ => $['permissionSet.permissions'], { ns: 'permission' })}</div> + <div className="system-sm-medium text-text-secondary"> + {t(($) => $['permissionSet.permissions'], { ns: 'permission' })} + </div> <PermissionPicker resourceType={resourceType} value={permissionKeys} @@ -131,20 +141,18 @@ const PermissionSetModalBody = ({ rel="noreferrer" className="inline-flex items-center gap-1 system-xs-medium text-text-accent hover:underline" > - <span>{t($ => $['permissionSet.learnMore'], { ns: 'permission' })}</span> + <span>{t(($) => $['permissionSet.learnMore'], { ns: 'permission' })}</span> <span aria-hidden className="i-ri-external-link-line h-3.5 w-3.5" /> </a> <div className="flex items-center gap-2"> <Button variant="secondary" onClick={onClose}> - {readonly ? t($ => $['operation.close'], { ns: 'common' }) : t($ => $['operation.cancel'], { ns: 'common' })} + {readonly + ? t(($) => $['operation.close'], { ns: 'common' }) + : t(($) => $['operation.cancel'], { ns: 'common' })} </Button> {!readonly && ( - <Button - variant="primary" - disabled={!canSubmit} - onClick={handleConfirm} - > - {t($ => $['operation.confirm'], { ns: 'common' })} + <Button variant="primary" disabled={!canSubmit} onClick={handleConfirm}> + {t(($) => $['operation.confirm'], { ns: 'common' })} </Button> )} </div> @@ -165,8 +173,7 @@ const PermissionSetModal = ({ <Dialog open={open} onOpenChange={(nextOpen) => { - if (!nextOpen) - onClose() + if (!nextOpen) onClose() }} > <PermissionSetModalBody diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/empty.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/empty.spec.tsx index 5a7edff09bd4f2..51de06d698653e 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/empty.spec.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/empty.spec.tsx @@ -12,7 +12,10 @@ describe('Empty State', () => { const link = screen.getByText('common.apiBasedExtension.link') expect(link).toBeInTheDocument() // The real useDocLink includes language and product prefixes in tests. - expect(link.closest('a')).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension') + expect(link.closest('a')).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension', + ) }) }) }) 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 0ecb1ee1575b86..b490bf3407f461 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 @@ -50,7 +50,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -80,7 +81,7 @@ vi.mock('@tanstack/react-query', () => ({ useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({ isPending: false, mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => { - options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data)) + options.mutationFn(variables).then((data) => mutationOptions?.onSuccess?.(data)) }, })), })) @@ -146,17 +147,20 @@ describe('ApiBasedExtensionPage', () => { // Act render( - <ApiBasedExtensionPage layout={({ body, toolbar }) => ( - <> - <div data-testid="toolbar">{toolbar}</div> - <div>{body}</div> - </> - )} + <ApiBasedExtensionPage + layout={({ body, toolbar }) => ( + <> + <div data-testid="toolbar">{toolbar}</div> + <div>{body}</div> + </> + )} />, ) // Assert - expect(screen.getByTestId('toolbar')).toContainElement(screen.getByPlaceholderText('common.operation.search')) + expect(screen.getByTestId('toolbar')).toContainElement( + screen.getByPlaceholderText('common.operation.search'), + ) expect(screen.getByTestId('toolbar')).toHaveTextContent('common.apiBasedExtension.add') expect(screen.getByText('Extension 1'))!.toBeInTheDocument() }) @@ -164,8 +168,18 @@ describe('ApiBasedExtensionPage', () => { it('should filter extensions by search keywords', () => { // Arrange const mockData: ApiBasedExtensionResponse[] = [ - { id: '1', name: 'Alpha Extension', api_endpoint: 'https://alpha.example.com', api_key: 'key1' }, - { id: '2', name: 'Beta Extension', api_endpoint: 'https://beta.example.com', api_key: 'key2' }, + { + id: '1', + name: 'Alpha Extension', + api_endpoint: 'https://alpha.example.com', + api_key: 'key1', + }, + { + id: '2', + name: 'Beta Extension', + api_endpoint: 'https://beta.example.com', + api_key: 'key2', + }, ] mockApiBasedExtensionsQuery.mockReturnValue({ data: mockData, @@ -174,7 +188,9 @@ describe('ApiBasedExtensionPage', () => { // Act render(<ApiBasedExtensionPage />) - fireEvent.change(screen.getByPlaceholderText('common.operation.search'), { target: { value: 'alpha' } }) + fireEvent.change(screen.getByPlaceholderText('common.operation.search'), { + target: { value: 'alpha' }, + }) // Assert expect(screen.getByText('Alpha Extension'))!.toBeInTheDocument() @@ -184,7 +200,12 @@ describe('ApiBasedExtensionPage', () => { it('should render a search empty state without showing the onboarding empty state', () => { // Arrange const mockData: ApiBasedExtensionResponse[] = [ - { id: '1', name: 'Alpha Extension', api_endpoint: 'https://alpha.example.com', api_key: 'key1' }, + { + id: '1', + name: 'Alpha Extension', + api_endpoint: 'https://alpha.example.com', + api_key: 'key1', + }, ] mockApiBasedExtensionsQuery.mockReturnValue({ data: mockData, @@ -193,10 +214,14 @@ describe('ApiBasedExtensionPage', () => { // Act render(<ApiBasedExtensionPage />) - fireEvent.change(screen.getByPlaceholderText('common.operation.search'), { target: { value: 'missing' } }) + fireEvent.change(screen.getByPlaceholderText('common.operation.search'), { + target: { value: 'missing' }, + }) // Assert - expect(screen.getByText('common.dataSource.notion.selector.noSearchResult'))!.toBeInTheDocument() + expect( + screen.getByText('common.dataSource.notion.selector.noSearchResult'), + )!.toBeInTheDocument() expect(screen.queryByText('common.apiBasedExtension.title')).not.toBeInTheDocument() expect(screen.queryByText('Alpha Extension')).not.toBeInTheDocument() }) @@ -220,7 +245,12 @@ describe('ApiBasedExtensionPage', () => { it('should disable management actions when api extension permission is missing', () => { // Arrange mockWorkspacePermissionKeys.current = [] - const extension: ApiBasedExtensionResponse = { id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'key1' } + const extension: ApiBasedExtensionResponse = { + id: '1', + name: 'Extension 1', + api_endpoint: 'url1', + api_key: 'key1', + } mockApiBasedExtensionsQuery.mockReturnValue({ data: [extension], isPending: false, @@ -249,7 +279,9 @@ describe('ApiBasedExtensionPage', () => { fireEvent.click(screen.getByText('common.apiBasedExtension.add')) // Assert - expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument() + expect( + screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' }), + ).toBeInTheDocument() }) it('should close add modal when create mutation succeeds', async () => { @@ -268,9 +300,18 @@ describe('ApiBasedExtensionPage', () => { // Act render(<ApiBasedExtensionPage />) fireEvent.click(screen.getByText('common.apiBasedExtension.add')) - fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } }) - fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } }) - fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } }) + fireEvent.change( + screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), + { target: { value: 'New Ext' } }, + ) + fireEvent.change( + screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), + { target: { value: 'https://api.test' } }, + ) + fireEvent.change( + screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), + { target: { value: 'secret-key' } }, + ) fireEvent.click(screen.getByText('common.operation.save')) // Assert @@ -282,13 +323,20 @@ describe('ApiBasedExtensionPage', () => { api_key: 'secret-key', }, }) - expect(screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.title' }), + ).not.toBeInTheDocument() }) }) it('should close edit modal when update mutation succeeds', async () => { // Arrange - const extension: ApiBasedExtensionResponse = { id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'long-api-key' } + const extension: ApiBasedExtensionResponse = { + id: '1', + name: 'Extension 1', + api_endpoint: 'url1', + api_key: 'long-api-key', + } mockUpdateApiBasedExtension.mockResolvedValue({ ...extension, name: 'Updated' }) mockApiBasedExtensionsQuery.mockReturnValue({ data: [extension], @@ -313,7 +361,9 @@ describe('ApiBasedExtensionPage', () => { api_key: '[__HIDDEN__]', }, }) - expect(screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.editTitle' })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.editTitle' }), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/item.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/item.spec.tsx index 5143009429b9da..013c2a0ac21b89 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/item.spec.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/item.spec.tsx @@ -25,7 +25,7 @@ vi.mock('@tanstack/react-query', () => ({ useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({ isPending: false, mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => { - options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data)) + options.mutationFn(variables).then((data) => mutationOptions?.onSuccess?.(data)) }, })), })) @@ -100,7 +100,9 @@ describe('Item Component', () => { // Assert expect(mockOnEdit).not.toHaveBeenCalled() - expect(screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i)).not.toBeInTheDocument() + expect( + screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i), + ).not.toBeInTheDocument() }) }) @@ -112,7 +114,9 @@ describe('Item Component', () => { // Assert // Assert - expect(screen.getByText(/common\.operation\.delete.*Test Extension.*\?/i))!.toBeInTheDocument() + expect( + screen.getByText(/common\.operation\.delete.*Test Extension.*\?/i), + )!.toBeInTheDocument() }) it('should call delete mutation when confirming deletion', async () => { @@ -153,7 +157,9 @@ describe('Item Component', () => { // Assert await waitFor(() => { - expect(screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i)).not.toBeInTheDocument() + expect( + screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i), + ).not.toBeInTheDocument() }) }) @@ -165,7 +171,9 @@ describe('Item Component', () => { // Assert await waitFor(() => { - expect(screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i)).not.toBeInTheDocument() + expect( + screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i), + ).not.toBeInTheDocument() }) }) @@ -192,8 +200,7 @@ describe('Item Component', () => { useTranslationSpy.mockReturnValue({ ...originalValue, t: withSelectorKey((key: string) => { - if (key === 'operation.delete') - return '' + if (key === 'operation.delete') return '' return `common.${key}` }, 'common') as unknown as TFunction, } as unknown as ReturnType<typeof reactI18next.useTranslation>) @@ -202,9 +209,8 @@ describe('Item Component', () => { render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />) const allButtons = screen.getAllByRole('button') const editBtn = screen.getByText('common.operation.edit') - const deleteBtn = allButtons.find(btn => btn !== editBtn) - if (deleteBtn) - fireEvent.click(deleteBtn) + const deleteBtn = allButtons.find((btn) => btn !== editBtn) + if (deleteBtn) fireEvent.click(deleteBtn) // Assert // Assert diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/modal.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/modal.spec.tsx index 5b7cd3e7904021..440197c724e070 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/modal.spec.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/modal.spec.tsx @@ -45,7 +45,7 @@ vi.mock('@tanstack/react-query', () => ({ useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({ isPending: false, mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => { - options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data)) + options.mutationFn(variables).then((data) => mutationOptions?.onSuccess?.(data)) }, })), })) @@ -58,7 +58,9 @@ describe('ApiBasedExtensionModal', () => { const mockOnOpenChange = vi.fn() const mockOnSaved = vi.fn() const mockDocLink = vi.fn((path?: string) => `https://docs.dify.ai${path || ''}`) - const mockExtension = (overrides: Partial<ApiBasedExtensionResponse> = {}): ApiBasedExtensionResponse => ({ + const mockExtension = ( + overrides: Partial<ApiBasedExtensionResponse> = {}, + ): ApiBasedExtensionResponse => ({ id: '1', name: 'Existing', api_endpoint: 'url', @@ -67,13 +69,17 @@ describe('ApiBasedExtensionModal', () => { }) const render = (ui: ReactElement) => RTLRender(ui) - const renderModal = (props: { - open?: boolean - } | { - mode: 'edit' - apiBasedExtension: ApiBasedExtensionResponse - open?: boolean - } = {}) => { + const renderModal = ( + props: + | { + open?: boolean + } + | { + mode: 'edit' + apiBasedExtension: ApiBasedExtensionResponse + open?: boolean + } = {}, + ) => { if ('mode' in props) { return render( <ApiBasedExtensionModal @@ -111,11 +117,19 @@ describe('ApiBasedExtensionModal', () => { renderModal() // Assert - expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument() + expect( + screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' }), + ).toBeInTheDocument() expect(screen.getByText('common.apiBasedExtension.modal.title')).toBeInTheDocument() - expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.name.title' })).toHaveAttribute('required') - expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiEndpoint.title' })).toHaveAccessibleDescription('common.apiBasedExtension.link') - expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiKey.title' })).toHaveAttribute('required') + expect( + screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.name.title' }), + ).toHaveAttribute('required') + expect( + screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiEndpoint.title' }), + ).toHaveAccessibleDescription('common.apiBasedExtension.link') + expect( + screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiKey.title' }), + ).toHaveAttribute('required') }) it('should render correctly for editing an existing extension', () => { @@ -154,9 +168,18 @@ describe('ApiBasedExtensionModal', () => { renderModal() // Act - fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } }) - fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } }) - fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } }) + fireEvent.change( + screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), + { target: { value: 'New Ext' } }, + ) + fireEvent.change( + screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), + { target: { value: 'https://api.test' } }, + ) + fireEvent.change( + screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), + { target: { value: 'secret-key' } }, + ) fireEvent.click(screen.getByText('common.operation.save')) // Assert @@ -231,15 +254,28 @@ describe('ApiBasedExtensionModal', () => { renderModal() // Act - fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'Ext' } }) - fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'url' } }) - fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: '123' } }) + fireEvent.change( + screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), + { target: { value: 'Ext' } }, + ) + fireEvent.change( + screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), + { target: { value: 'url' } }, + ) + fireEvent.change( + screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), + { target: { value: '123' } }, + ) fireEvent.click(screen.getByText('common.operation.save')) // Assert await waitFor(() => { - expect(screen.getByText('common.apiBasedExtension.modal.apiKey.lengthError')).toBeInTheDocument() - expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiKey.title' })).toHaveAttribute('aria-invalid', 'true') + expect( + screen.getByText('common.apiBasedExtension.modal.apiKey.lengthError'), + ).toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiKey.title' }), + ).toHaveAttribute('aria-invalid', 'true') }) expect(mockToast.error).not.toHaveBeenCalled() expect(mockCreateApiBasedExtension).not.toHaveBeenCalled() @@ -317,8 +353,7 @@ describe('ApiBasedExtensionModal', () => { useTranslationSpy.mockReturnValue({ ...originalValue, t: withSelectorKey((key: string) => { - if (missingKeys.includes(key)) - return '' + if (missingKeys.includes(key)) return '' return `common.${key}` }, 'common') as unknown as TFunction, } as unknown as ReturnType<typeof reactI18next.useTranslation>) diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx index acfc6301005709..dc06f70e02d294 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx @@ -5,15 +5,12 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con import { useModalContext } from '@/context/modal-context' import { ApiBasedExtensionSelector } from '../selector' -const { - mockApiBasedExtensionsQuery, - mockCreateApiBasedExtension, - mockUpdateApiBasedExtension, -} = vi.hoisted(() => ({ - mockApiBasedExtensionsQuery: vi.fn(), - mockCreateApiBasedExtension: vi.fn(), - mockUpdateApiBasedExtension: vi.fn(), -})) +const { mockApiBasedExtensionsQuery, mockCreateApiBasedExtension, mockUpdateApiBasedExtension } = + vi.hoisted(() => ({ + mockApiBasedExtensionsQuery: vi.fn(), + mockCreateApiBasedExtension: vi.fn(), + mockUpdateApiBasedExtension: vi.fn(), + })) vi.mock('@/context/modal-context', () => ({ useModalContext: vi.fn(), @@ -42,7 +39,7 @@ vi.mock('@tanstack/react-query', () => ({ useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({ isPending: false, mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => { - options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data)) + options.mutationFn(variables).then((data) => mutationOptions?.onSuccess?.(data)) }, })), })) @@ -99,7 +96,9 @@ describe('ApiBasedExtensionSelector', () => { // Assert // Assert - expect(await screen.findByText('common.apiBasedExtension.selector.title'))!.toBeInTheDocument() + expect( + await screen.findByText('common.apiBasedExtension.selector.title'), + )!.toBeInTheDocument() }) it('should call onChange and closes dropdown when an extension is selected', async () => { @@ -145,9 +144,18 @@ describe('ApiBasedExtensionSelector', () => { const addButton = await screen.findByText('common.operation.add') fireEvent.click(addButton) - fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } }) - fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } }) - fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } }) + fireEvent.change( + screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), + { target: { value: 'New Ext' } }, + ) + fireEvent.change( + screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), + { target: { value: 'https://api.test' } }, + ) + fireEvent.change( + screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), + { target: { value: 'secret-key' } }, + ) fireEvent.click(screen.getByText('common.operation.save')) // Assert @@ -159,7 +167,9 @@ describe('ApiBasedExtensionSelector', () => { api_key: 'secret-key', }, }) - expect(screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.title' }), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/header/account-setting/api-based-extension-page/empty.tsx b/web/app/components/header/account-setting/api-based-extension-page/empty.tsx index 993dbd8fc2e13e..704e5bd4f9717c 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/empty.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/empty.tsx @@ -8,17 +8,22 @@ export function Empty() { return ( <div className="mb-2 flex flex-col items-start gap-3 rounded-xl bg-background-section p-6"> <div className="flex size-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg-alt shadow-lg backdrop-blur-xs"> - <span aria-hidden className="i-custom-vender-workflow-api-aggregate size-5 text-text-tertiary" /> + <span + aria-hidden + className="i-custom-vender-workflow-api-aggregate size-5 text-text-tertiary" + /> </div> <div className="flex w-full flex-col gap-1"> - <div className="system-xs-regular text-text-primary">{t($ => $['apiBasedExtension.title'], { ns: 'common' })}</div> + <div className="system-xs-regular text-text-primary"> + {t(($) => $['apiBasedExtension.title'], { ns: 'common' })} + </div> <a className="flex items-center gap-1 system-xs-regular text-text-accent" href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" > - {t($ => $['apiBasedExtension.link'], { ns: 'common' })} + {t(($) => $['apiBasedExtension.link'], { ns: 'common' })} <span aria-hidden className="i-ri-external-link-line size-3" /> </a> </div> 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 aaaa0a4591a66a..867a6ffa747576 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 @@ -14,24 +14,30 @@ import { Empty } from './empty' import { Item } from './item' import { ApiBasedExtensionModal } from './modal' -type ApiBasedExtensionDialogState = { - mode: 'create' -} | { - mode: 'edit' - apiBasedExtension: ApiBasedExtensionResponse -} | null +type ApiBasedExtensionDialogState = + | { + mode: 'create' + } + | { + mode: 'edit' + apiBasedExtension: ApiBasedExtensionResponse + } + | null type ApiBasedExtensionPageProps = { - layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode + layout?: (parts: { body: ReactNode; toolbar: ReactNode }) => ReactNode } function ApiBasedExtensionListSkeleton() { const { t } = useTranslation() return ( - <div role="status" aria-label={t($ => $.loading, { ns: 'common' })} className="space-y-2"> + <div role="status" aria-label={t(($) => $.loading, { ns: 'common' })} className="space-y-2"> {Array.from({ length: 2 }, (_, index) => ( - <div key={index} className="rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4 shadow-xs"> + <div + key={index} + className="rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4 shadow-xs" + > <SkeletonContainer className="h-16"> <SkeletonRow> <SkeletonRectangle className="size-8 shrink-0 animate-pulse rounded-lg" /> @@ -48,40 +54,39 @@ function ApiBasedExtensionListSkeleton() { ) } -export function ApiBasedExtensionPage({ - layout, -}: ApiBasedExtensionPageProps = {}) { +export function ApiBasedExtensionPage({ layout }: ApiBasedExtensionPageProps = {}) { const { t } = useTranslation() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManage = hasPermission(workspacePermissionKeys, 'api_extension.manage') - const { data: apiBasedExtensions = [], isPending: isLoading } = useQuery(consoleQuery.apiBasedExtension.get.queryOptions()) + const { data: apiBasedExtensions = [], isPending: isLoading } = useQuery( + consoleQuery.apiBasedExtension.get.queryOptions(), + ) const [dialogState, setDialogState] = useState<ApiBasedExtensionDialogState>(null) const [keywords, setKeywords] = useState('') const filteredApiBasedExtensions = useMemo(() => { const query = keywords.trim().toLowerCase() - if (!query) - return apiBasedExtensions + if (!query) return apiBasedExtensions return apiBasedExtensions.filter((apiBasedExtension) => { - return apiBasedExtension.name.toLowerCase().includes(query) - || apiBasedExtension.api_endpoint.toLowerCase().includes(query) + return ( + apiBasedExtension.name.toLowerCase().includes(query) || + apiBasedExtension.api_endpoint.toLowerCase().includes(query) + ) }) }, [apiBasedExtensions, keywords]) const hasApiBasedExtensions = apiBasedExtensions.length > 0 const hasSearchKeywords = keywords.trim().length > 0 const handleOpenApiBasedExtensionModal = () => { - if (!canManage) - return + if (!canManage) return setDialogState({ mode: 'create', }) } const handleEditApiBasedExtension = (apiBasedExtension: ApiBasedExtensionResponse) => { - if (!canManage) - return + if (!canManage) return setDialogState({ mode: 'edit', @@ -92,85 +97,62 @@ export function ApiBasedExtensionPage({ setDialogState(null) } const handleApiBasedExtensionModalOpenChange = (open: boolean) => { - if (!open) - setDialogState(null) + if (!open) setDialogState(null) } const toolbar = ( <div className="flex w-full items-center justify-between gap-2"> - <SearchInput - className="w-[200px]" - value={keywords} - onValueChange={setKeywords} - /> - <Button - variant="secondary" - disabled={!canManage} - onClick={handleOpenApiBasedExtensionModal} - > + <SearchInput className="w-[200px]" value={keywords} onValueChange={setKeywords} /> + <Button variant="secondary" disabled={!canManage} onClick={handleOpenApiBasedExtensionModal}> <span className="mr-1 i-ri-add-line size-4" aria-hidden="true" /> - {t($ => $['apiBasedExtension.add'], { ns: 'common' })} + {t(($) => $['apiBasedExtension.add'], { ns: 'common' })} </Button> </div> ) const body = ( <> - { - isLoading && ( - <ApiBasedExtensionListSkeleton /> - ) - } - { - !isLoading && !hasApiBasedExtensions && ( - <Empty /> - ) - } - { - !isLoading && hasApiBasedExtensions && hasSearchKeywords && !filteredApiBasedExtensions.length && ( + {isLoading && <ApiBasedExtensionListSkeleton />} + {!isLoading && !hasApiBasedExtensions && <Empty />} + {!isLoading && + hasApiBasedExtensions && + hasSearchKeywords && + !filteredApiBasedExtensions.length && ( <div className="py-10 text-center system-sm-regular text-text-tertiary"> - {t($ => $['dataSource.notion.selector.noSearchResult'], { ns: 'common' })} + {t(($) => $['dataSource.notion.selector.noSearchResult'], { ns: 'common' })} </div> - ) - } - { - !isLoading && !!filteredApiBasedExtensions.length && ( - filteredApiBasedExtensions.map(item => ( - <Item - key={item.id} - apiBasedExtension={item} - onEdit={handleEditApiBasedExtension} - canManage={canManage} - /> - )) - ) - } - { - dialogState?.mode === 'create' && ( - <ApiBasedExtensionModal - open - mode="create" - onOpenChange={handleApiBasedExtensionModalOpenChange} - onSaved={handleApiBasedExtensionSaved} - /> - ) - } - { - dialogState?.mode === 'edit' && ( - <ApiBasedExtensionModal - open - mode="edit" - apiBasedExtension={dialogState.apiBasedExtension} - onOpenChange={handleApiBasedExtensionModalOpenChange} - onSaved={handleApiBasedExtensionSaved} + )} + {!isLoading && + !!filteredApiBasedExtensions.length && + filteredApiBasedExtensions.map((item) => ( + <Item + key={item.id} + apiBasedExtension={item} + onEdit={handleEditApiBasedExtension} + canManage={canManage} /> - ) - } + ))} + {dialogState?.mode === 'create' && ( + <ApiBasedExtensionModal + open + mode="create" + onOpenChange={handleApiBasedExtensionModalOpenChange} + onSaved={handleApiBasedExtensionSaved} + /> + )} + {dialogState?.mode === 'edit' && ( + <ApiBasedExtensionModal + open + mode="edit" + apiBasedExtension={dialogState.apiBasedExtension} + onOpenChange={handleApiBasedExtensionModalOpenChange} + onSaved={handleApiBasedExtensionSaved} + /> + )} </> ) - if (layout) - return layout({ body, toolbar }) + if (layout) return layout({ body, toolbar }) return ( <> diff --git a/web/app/components/header/account-setting/api-based-extension-page/item.tsx b/web/app/components/header/account-setting/api-based-extension-page/item.tsx index f36a24206e1b55..cca77424afd3dd 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/item.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/item.tsx @@ -18,72 +18,72 @@ type ItemProps = { onEdit: (apiBasedExtension: ApiBasedExtensionResponse) => void canManage?: boolean } -export function Item({ - apiBasedExtension, - onEdit, - canManage = true, -}: ItemProps) { +export function Item({ apiBasedExtension, onEdit, canManage = true }: ItemProps) { const { t } = useTranslation() const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const deleteApiBasedExtensionMutation = useMutation(consoleQuery.apiBasedExtension.byId.delete.mutationOptions()) + const deleteApiBasedExtensionMutation = useMutation( + consoleQuery.apiBasedExtension.byId.delete.mutationOptions(), + ) const handleOpenApiBasedExtensionModal = () => { - if (!canManage) - return + if (!canManage) return onEdit(apiBasedExtension) } const handleDeleteApiBasedExtension = () => { - if (!canManage) - return + if (!canManage) return - deleteApiBasedExtensionMutation.mutate({ - params: { - id: apiBasedExtension.id, + deleteApiBasedExtensionMutation.mutate( + { + params: { + id: apiBasedExtension.id, + }, }, - }, { - onSuccess: () => { - setShowDeleteConfirm(false) + { + onSuccess: () => { + setShowDeleteConfirm(false) + }, }, - }) + ) } return ( <div className="group mb-2 flex items-center rounded-xl border-[0.5px] border-transparent bg-components-input-bg-normal px-4 py-2 focus-within:border-components-input-border-active focus-within:shadow-xs hover:border-components-input-border-active hover:shadow-xs"> <div className="min-w-0 grow"> - <div className="mb-0.5 text-[13px] font-medium text-text-secondary">{apiBasedExtension.name}</div> + <div className="mb-0.5 text-[13px] font-medium text-text-secondary"> + {apiBasedExtension.name} + </div> <div className="truncate text-xs text-text-tertiary">{apiBasedExtension.api_endpoint}</div> </div> <div className="pointer-events-none flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"> - <Button - disabled={!canManage} - onClick={handleOpenApiBasedExtensionModal} - > + <Button disabled={!canManage} onClick={handleOpenApiBasedExtensionModal}> <span className="mr-1 i-ri-edit-line size-4" aria-hidden="true" /> - {t($ => $['operation.edit'], { ns: 'common' })} + {t(($) => $['operation.edit'], { ns: 'common' })} </Button> - <Button - disabled={!canManage} - onClick={() => canManage && setShowDeleteConfirm(true)} - > + <Button disabled={!canManage} onClick={() => canManage && setShowDeleteConfirm(true)}> <span className="mr-1 i-ri-delete-bin-line size-4" aria-hidden="true" /> - {t($ => $['operation.delete'], { ns: 'common' })} + {t(($) => $['operation.delete'], { ns: 'common' })} </Button> </div> - <AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && setShowDeleteConfirm(false)}> + <AlertDialog + open={showDeleteConfirm} + onOpenChange={(open) => !open && setShowDeleteConfirm(false)} + > <AlertDialogContent backdropProps={{ forceRender: true }}> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {`${t($ => $['operation.delete'], { ns: 'common' })} \u201C${apiBasedExtension.name}\u201D?`} + {`${t(($) => $['operation.delete'], { ns: 'common' })} \u201C${apiBasedExtension.name}\u201D?`} </AlertDialogTitle> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton disabled={!canManage || deleteApiBasedExtensionMutation.isPending} onClick={handleDeleteApiBasedExtension} > - {t($ => $['operation.delete'], { ns: 'common' }) || ''} + {t(($) => $['operation.delete'], { ns: 'common' }) || ''} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/header/account-setting/api-based-extension-page/modal.tsx b/web/app/components/header/account-setting/api-based-extension-page/modal.tsx index 17561ff264dce9..a2ad08f2ca739c 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/modal.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/modal.tsx @@ -4,7 +4,13 @@ import type { } from '@dify/contracts/api/console/api-based-extension/types.gen' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' -import { Field, FieldControl, FieldDescription, FieldError, FieldLabel } from '@langgenius/dify-ui/field' +import { + Field, + FieldControl, + FieldDescription, + FieldError, + FieldLabel, +} from '@langgenius/dify-ui/field' import { Form } from '@langgenius/dify-ui/form' import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' @@ -16,24 +22,34 @@ type ApiBasedExtensionModalProps = { open: boolean onOpenChange: (open: boolean) => void onSaved: () => void -} & ({ - mode: 'create' -} | { - mode: 'edit' - apiBasedExtension: ApiBasedExtensionResponse -}) +} & ( + | { + mode: 'create' + } + | { + mode: 'edit' + apiBasedExtension: ApiBasedExtensionResponse + } +) export function ApiBasedExtensionModal(props: ApiBasedExtensionModalProps) { const { open, mode, onOpenChange, onSaved } = props const { t } = useTranslation() const docLink = useDocLink() - const createApiBasedExtensionMutation = useMutation(consoleQuery.apiBasedExtension.post.mutationOptions()) - const updateApiBasedExtensionMutation = useMutation(consoleQuery.apiBasedExtension.byId.post.mutationOptions()) + const createApiBasedExtensionMutation = useMutation( + consoleQuery.apiBasedExtension.post.mutationOptions(), + ) + const updateApiBasedExtensionMutation = useMutation( + consoleQuery.apiBasedExtension.byId.post.mutationOptions(), + ) const editingApiBasedExtension = mode === 'edit' ? props.apiBasedExtension : null - const isSaving = createApiBasedExtensionMutation.isPending || updateApiBasedExtensionMutation.isPending - const nameLabel = t($ => $['apiBasedExtension.modal.name.title'], { ns: 'common' }) - const apiEndpointLabel = t($ => $['apiBasedExtension.modal.apiEndpoint.title'], { ns: 'common' }) - const apiKeyLabel = t($ => $['apiBasedExtension.modal.apiKey.title'], { ns: 'common' }) + const isSaving = + createApiBasedExtensionMutation.isPending || updateApiBasedExtensionMutation.isPending + const nameLabel = t(($) => $['apiBasedExtension.modal.name.title'], { ns: 'common' }) + const apiEndpointLabel = t(($) => $['apiBasedExtension.modal.apiEndpoint.title'], { + ns: 'common', + }) + const apiKeyLabel = t(($) => $['apiBasedExtension.modal.apiKey.title'], { ns: 'common' }) const handleSubmit = (formValues: ApiBasedExtensionPayload) => { const body: ApiBasedExtensionPayload = { @@ -43,28 +59,35 @@ export function ApiBasedExtensionModal(props: ApiBasedExtensionModalProps) { } if (editingApiBasedExtension) { - updateApiBasedExtensionMutation.mutate({ - params: { - id: editingApiBasedExtension.id, - }, - body: { - ...body, - api_key: editingApiBasedExtension.api_key === body.api_key ? '[__HIDDEN__]' : body.api_key, + updateApiBasedExtensionMutation.mutate( + { + params: { + id: editingApiBasedExtension.id, + }, + body: { + ...body, + api_key: + editingApiBasedExtension.api_key === body.api_key ? '[__HIDDEN__]' : body.api_key, + }, }, - }, { - onSuccess: () => { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - onSaved() + { + onSuccess: () => { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + onSaved() + }, }, - }) + ) return } - createApiBasedExtensionMutation.mutate({ - body, - }, { - onSuccess: onSaved, - }) + createApiBasedExtensionMutation.mutate( + { + body, + }, + { + onSuccess: onSaved, + }, + ) } return ( @@ -77,8 +100,8 @@ export function ApiBasedExtensionModal(props: ApiBasedExtensionModalProps) { <DialogTitle className="mb-2 pr-8 text-xl font-semibold text-text-primary"> {mode === 'edit' - ? t($ => $['apiBasedExtension.modal.editTitle'], { ns: 'common' }) - : t($ => $['apiBasedExtension.modal.title'], { ns: 'common' })} + ? t(($) => $['apiBasedExtension.modal.editTitle'], { ns: 'common' }) + : t(($) => $['apiBasedExtension.modal.title'], { ns: 'common' })} </DialogTitle> <Form<ApiBasedExtensionPayload> className="grid gap-4 pt-2" onFormSubmit={handleSubmit}> <Field name="name"> @@ -86,9 +109,13 @@ export function ApiBasedExtensionModal(props: ApiBasedExtensionModalProps) { <FieldControl required defaultValue={editingApiBasedExtension?.name || ''} - placeholder={t($ => $['apiBasedExtension.modal.name.placeholder'], { ns: 'common' }) || ''} + placeholder={ + t(($) => $['apiBasedExtension.modal.name.placeholder'], { ns: 'common' }) || '' + } /> - <FieldError match="valueMissing">{t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: nameLabel })}</FieldError> + <FieldError match="valueMissing"> + {t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: nameLabel })} + </FieldError> </Field> <Field name="api_endpoint"> @@ -96,7 +123,10 @@ export function ApiBasedExtensionModal(props: ApiBasedExtensionModalProps) { <FieldControl required defaultValue={editingApiBasedExtension?.api_endpoint || ''} - placeholder={t($ => $['apiBasedExtension.modal.apiEndpoint.placeholder'], { ns: 'common' }) || ''} + placeholder={ + t(($) => $['apiBasedExtension.modal.apiEndpoint.placeholder'], { ns: 'common' }) || + '' + } /> <FieldDescription> <a @@ -105,18 +135,23 @@ export function ApiBasedExtensionModal(props: ApiBasedExtensionModalProps) { rel="noopener noreferrer" className="inline-flex w-fit items-center text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" > - <span className="mr-1 i-custom-vender-line-education-book-open-01 size-3" aria-hidden="true" /> - {t($ => $['apiBasedExtension.link'], { ns: 'common' })} + <span + className="mr-1 i-custom-vender-line-education-book-open-01 size-3" + aria-hidden="true" + /> + {t(($) => $['apiBasedExtension.link'], { ns: 'common' })} </a> </FieldDescription> - <FieldError match="valueMissing">{t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: apiEndpointLabel })}</FieldError> + <FieldError match="valueMissing"> + {t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: apiEndpointLabel })} + </FieldError> </Field> <Field name="api_key" validate={(value) => { if (typeof value === 'string' && value.length > 0 && value.length < 5) - return t($ => $['apiBasedExtension.modal.apiKey.lengthError'], { ns: 'common' }) + return t(($) => $['apiBasedExtension.modal.apiKey.lengthError'], { ns: 'common' }) return null }} @@ -125,18 +160,22 @@ export function ApiBasedExtensionModal(props: ApiBasedExtensionModalProps) { <FieldControl required defaultValue={editingApiBasedExtension?.api_key || ''} - placeholder={t($ => $['apiBasedExtension.modal.apiKey.placeholder'], { ns: 'common' }) || ''} + placeholder={ + t(($) => $['apiBasedExtension.modal.apiKey.placeholder'], { ns: 'common' }) || '' + } /> - <FieldError match="valueMissing">{t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: apiKeyLabel })}</FieldError> + <FieldError match="valueMissing"> + {t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: apiKeyLabel })} + </FieldError> <FieldError match="customError" /> </Field> <div className="mt-2 flex items-center justify-end gap-2"> <Button type="button" onClick={() => onOpenChange(false)}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button type="submit" variant="primary" disabled={isSaving}> - {t($ => $['operation.save'], { ns: 'common' })} + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </Form> diff --git a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx index 6cc1f0fea395ab..fb16e6ef1dcb63 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx @@ -1,8 +1,4 @@ -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -16,10 +12,7 @@ type ApiBasedExtensionSelectorProps = { onChange: (value: string) => void } -export function ApiBasedExtensionSelector({ - value, - onChange, -}: ApiBasedExtensionSelectorProps) { +export function ApiBasedExtensionSelector({ value, onChange }: ApiBasedExtensionSelectorProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) const [addModalOpen, setAddModalOpen] = useState(false) @@ -32,55 +25,47 @@ export function ApiBasedExtensionSelector({ setOpen(false) } - const currentItem = apiBasedExtensions.find(item => item.id === value) + const currentItem = apiBasedExtensions.find((item) => item.id === value) const handleApiBasedExtensionSaved = () => { setAddModalOpen(false) } const handleAddModalOpenChange = (nextOpen: boolean) => { - if (!nextOpen) - setAddModalOpen(false) + if (!nextOpen) setAddModalOpen(false) } return ( <> <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( - <button - type="button" - className="block w-full border-0 bg-transparent p-0 text-left" - > - {currentItem - ? ( - <div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3"> - <div className="text-sm text-text-primary"> - {currentItem.name} - </div> - <div className="flex items-center"> - <div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary"> - {currentItem.api_endpoint} - </div> - <span - className={`i-ri-arrow-down-s-line size-4 text-text-secondary ${!open && 'opacity-60'}`} - aria-hidden="true" - /> - </div> + render={ + <button type="button" className="block w-full border-0 bg-transparent p-0 text-left"> + {currentItem ? ( + <div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3"> + <div className="text-sm text-text-primary">{currentItem.name}</div> + <div className="flex items-center"> + <div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary"> + {currentItem.api_endpoint} </div> - ) - : ( - <div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary"> - {t($ => $['apiBasedExtension.selector.placeholder'], { - ns: 'common', - })} - <span - className={`i-ri-arrow-down-s-line h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} - aria-hidden="true" - /> - </div> - )} + <span + className={`i-ri-arrow-down-s-line size-4 text-text-secondary ${!open && 'opacity-60'}`} + aria-hidden="true" + /> + </div> + </div> + ) : ( + <div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary"> + {t(($) => $['apiBasedExtension.selector.placeholder'], { + ns: 'common', + })} + <span + className={`i-ri-arrow-down-s-line h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} + aria-hidden="true" + /> + </div> + )} </button> - )} + } /> <PopoverContent placement="bottom-start" @@ -92,7 +77,7 @@ export function ApiBasedExtensionSelector({ <div className="p-1"> <div className="flex items-center justify-between px-3 pt-2 pb-1"> <div className="text-xs font-medium text-text-tertiary"> - {t($ => $['apiBasedExtension.selector.title'], { ns: 'common' })} + {t(($) => $['apiBasedExtension.selector.title'], { ns: 'common' })} </div> <button type="button" @@ -104,7 +89,7 @@ export function ApiBasedExtensionSelector({ }) }} > - {t($ => $['apiBasedExtension.selector.manage'], { ns: 'common' })} + {t(($) => $['apiBasedExtension.selector.manage'], { ns: 'common' })} <span className="ml-0.5 i-custom-vender-line-arrows-arrow-up-right size-3" aria-hidden="true" @@ -112,7 +97,7 @@ export function ApiBasedExtensionSelector({ </button> </div> <div className="max-h-[250px] overflow-y-auto"> - {apiBasedExtensions.map(item => ( + {apiBasedExtensions.map((item) => ( <button type="button" key={item.id} @@ -120,9 +105,7 @@ export function ApiBasedExtensionSelector({ onClick={() => handleSelect(item.id)} > <div className="text-sm text-text-primary">{item.name}</div> - <div className="text-xs text-text-tertiary"> - {item.api_endpoint} - </div> + <div className="text-xs text-text-tertiary">{item.api_endpoint}</div> </button> ))} </div> @@ -137,11 +120,8 @@ export function ApiBasedExtensionSelector({ setAddModalOpen(true) }} > - <span - className="mr-2 i-ri-add-line size-4" - aria-hidden="true" - /> - {t($ => $['operation.add'], { ns: 'common' })} + <span className="mr-2 i-ri-add-line size-4" aria-hidden="true" /> + {t(($) => $['operation.add'], { ns: 'common' })} </button> </div> </div> diff --git a/web/app/components/header/account-setting/collapse/__tests__/index.spec.tsx b/web/app/components/header/account-setting/collapse/__tests__/index.spec.tsx index 4cce58d83ad710..871ae76d14f9e4 100644 --- a/web/app/components/header/account-setting/collapse/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/collapse/__tests__/index.spec.tsx @@ -8,11 +8,7 @@ describe('Collapse', () => { { key: '2', name: 'Item 2' }, ] - const mockRenderItem = (item: IItem) => ( - <div data-testid={`item-${item.key}`}> - {item.name} - </div> - ) + const mockRenderItem = (item: IItem) => <div data-testid={`item-${item.key}`}>{item.name}</div> const mockOnSelect = vi.fn() @@ -24,11 +20,7 @@ describe('Collapse', () => { it('should render title and initially closed state', () => { // Act const { container } = render( - <Collapse - title="Test Title" - items={mockItems} - renderItem={mockRenderItem} - />, + <Collapse title="Test Title" items={mockItems} renderItem={mockRenderItem} />, ) // Assert @@ -56,13 +48,7 @@ describe('Collapse', () => { describe('Interactions', () => { it('should toggle content open and closed', () => { // Act & Assert - render( - <Collapse - title="Test Title" - items={mockItems} - renderItem={mockRenderItem} - />, - ) + render(<Collapse title="Test Title" items={mockItems} renderItem={mockRenderItem} />) // Initially closed expect(screen.queryByTestId('item-1')).not.toBeInTheDocument() @@ -100,13 +86,7 @@ describe('Collapse', () => { it('should not crash when onSelect is undefined and item is clicked', () => { // Arrange - render( - <Collapse - title="Test Title" - items={mockItems} - renderItem={mockRenderItem} - />, - ) + render(<Collapse title="Test Title" items={mockItems} renderItem={mockRenderItem} />) // Act fireEvent.click(screen.getByRole('button', { name: 'Test Title' })) diff --git a/web/app/components/header/account-setting/collapse/index.tsx b/web/app/components/header/account-setting/collapse/index.tsx index e135d4aeea0289..130af858d17fe7 100644 --- a/web/app/components/header/account-setting/collapse/index.tsx +++ b/web/app/components/header/account-setting/collapse/index.tsx @@ -13,13 +13,7 @@ type ICollapse = { onSelect?: (item: IItem) => void wrapperClassName?: string } -const Collapse = ({ - title, - items, - renderItem, - onSelect, - wrapperClassName, -}: ICollapse) => { +const Collapse = ({ title, items, renderItem, onSelect, wrapperClassName }: ICollapse) => { const [open, setOpen] = useState(false) const toggle = () => setOpen(!open) @@ -32,30 +26,32 @@ const Collapse = ({ onClick={toggle} > {title} - { - open - ? <ChevronDownIcon className="size-3 text-components-button-tertiary-text" aria-hidden="true" /> - : <ChevronRightIcon className="size-3 text-components-button-tertiary-text" aria-hidden="true" /> - } + {open ? ( + <ChevronDownIcon + className="size-3 text-components-button-tertiary-text" + aria-hidden="true" + /> + ) : ( + <ChevronRightIcon + className="size-3 text-components-button-tertiary-text" + aria-hidden="true" + /> + )} </button> - { - open && ( - <div className="mx-1 mb-1 rounded-lg border-t border-divider-subtle bg-components-panel-on-panel-item-bg py-1"> - { - items.map(item => ( - <button - key={item.key} - type="button" - className="block w-full border-none bg-transparent p-0 text-left" - onClick={() => onSelect?.(item)} - > - {renderItem(item)} - </button> - )) - } - </div> - ) - } + {open && ( + <div className="mx-1 mb-1 rounded-lg border-t border-divider-subtle bg-components-panel-on-panel-item-bg py-1"> + {items.map((item) => ( + <button + key={item.key} + type="button" + className="block w-full border-none bg-transparent p-0 text-left" + onClick={() => onSelect?.(item)} + > + {renderItem(item)} + </button> + ))} + </div> + )} </div> ) } diff --git a/web/app/components/header/account-setting/constants.ts b/web/app/components/header/account-setting/constants.ts index 074511ad31b7d9..bf7d2c19acfaa2 100644 --- a/web/app/components/header/account-setting/constants.ts +++ b/web/app/components/header/account-setting/constants.ts @@ -16,7 +16,7 @@ export const ACCOUNT_SETTING_TAB = { LANGUAGE: 'language', } as const -export type AccountSettingTab = typeof ACCOUNT_SETTING_TAB[keyof typeof ACCOUNT_SETTING_TAB] +export type AccountSettingTab = (typeof ACCOUNT_SETTING_TAB)[keyof typeof ACCOUNT_SETTING_TAB] export const DEFAULT_ACCOUNT_SETTING_TAB = ACCOUNT_SETTING_TAB.MEMBERS @@ -28,14 +28,14 @@ const WORKSPACE_SETTING_TAB_VALUES = [ ACCOUNT_SETTING_TAB.CUSTOM, ] as const -export type WorkspaceSettingTab = typeof WORKSPACE_SETTING_TAB_VALUES[number] +export type WorkspaceSettingTab = (typeof WORKSPACE_SETTING_TAB_VALUES)[number] const USER_SETTING_TAB_VALUES = [ ACCOUNT_SETTING_TAB.PREFERENCES, ACCOUNT_SETTING_TAB.LANGUAGE, ] as const -export type UserSettingTab = typeof USER_SETTING_TAB_VALUES[number] +export type UserSettingTab = (typeof USER_SETTING_TAB_VALUES)[number] export type IntegrationSettingTab = IntegrationSection @@ -45,27 +45,23 @@ export const SETTINGS_TAB_VALUES = [ ...INTEGRATION_SECTION_VALUES, ] as const -export type SettingsTab = typeof SETTINGS_TAB_VALUES[number] +export type SettingsTab = (typeof SETTINGS_TAB_VALUES)[number] export const isValidSettingsTab = (tab: string | null): tab is SettingsTab => { - if (!tab) - return false + if (!tab) return false return SETTINGS_TAB_VALUES.includes(tab as SettingsTab) } export const isWorkspaceSettingTab = (tab: SettingsTab | null): tab is WorkspaceSettingTab => { - if (!tab) - return false + if (!tab) return false return WORKSPACE_SETTING_TAB_VALUES.includes(tab as WorkspaceSettingTab) } export const isUserSettingTab = (tab: SettingsTab | null): tab is UserSettingTab => { - if (!tab) - return false + if (!tab) return false return USER_SETTING_TAB_VALUES.includes(tab as UserSettingTab) } export const isIntegrationSettingTab = (tab: SettingsTab | null): tab is IntegrationSettingTab => { - if (!tab) - return false + if (!tab) return false return INTEGRATION_SECTION_VALUES.includes(tab as IntegrationSection) } diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx index e8d0c125336da8..91892af4c145df 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx @@ -6,12 +6,21 @@ import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' import { CollectionType } from '@/app/components/tools/types' import { useRenderI18nObject } from '@/hooks/use-i18n' import { openOAuthPopup } from '@/hooks/use-oauth' -import { useGetDataSourceOAuthUrl, useInvalidDataSourceAuth, useInvalidDataSourceListAuth, useInvalidDefaultDataSourceListAuth } from '@/service/use-datasource' +import { + useGetDataSourceOAuthUrl, + useInvalidDataSourceAuth, + useInvalidDataSourceListAuth, + useInvalidDefaultDataSourceListAuth, +} from '@/service/use-datasource' import { useInvalidDataSourceList } from '@/service/use-pipeline' import Card from '../card' import { useDataSourceAuthUpdate } from '../hooks' -let mockWorkspacePermissionKeys: string[] = ['credential.use', 'credential.create', 'credential.manage'] +let mockWorkspacePermissionKeys: string[] = [ + 'credential.use', + 'credential.create', + 'credential.manage', +] vi.mock('@/context/account-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') @@ -45,25 +54,54 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('@/app/components/plugins/plugin-auth', () => ({ - ApiKeyModal: vi.fn(({ onClose, onUpdate, onRemove, disabled, editValues }: { onClose: () => void, onUpdate: () => void, onRemove: () => void, disabled: boolean, editValues: Record<string, unknown> }) => ( - <div data-testid="mock-api-key-modal" data-disabled={disabled}> - <button data-testid="modal-close" onClick={onClose}>Close</button> - <button data-testid="modal-update" onClick={onUpdate}>Update</button> - <button data-testid="modal-remove" onClick={onRemove}>Remove</button> - <div data-testid="edit-values">{JSON.stringify(editValues)}</div> - </div> - )), + ApiKeyModal: vi.fn( + ({ + onClose, + onUpdate, + onRemove, + disabled, + editValues, + }: { + onClose: () => void + onUpdate: () => void + onRemove: () => void + disabled: boolean + editValues: Record<string, unknown> + }) => ( + <div data-testid="mock-api-key-modal" data-disabled={disabled}> + <button data-testid="modal-close" onClick={onClose}> + Close + </button> + <button data-testid="modal-update" onClick={onUpdate}> + Update + </button> + <button data-testid="modal-remove" onClick={onRemove}> + Remove + </button> + <div data-testid="edit-values">{JSON.stringify(editValues)}</div> + </div> + ), + ), usePluginAuthAction: vi.fn(), AuthCategory: { datasource: 'datasource', }, - AddApiKeyButton: ({ onUpdate, disabled }: { onUpdate: () => void, disabled?: boolean }) => <button disabled={disabled} onClick={onUpdate}>Add API Key</button>, - AddOAuthButton: ({ onUpdate, disabled }: { onUpdate: () => void, disabled?: boolean }) => <button disabled={disabled} onClick={onUpdate}>Add OAuth</button>, + AddApiKeyButton: ({ onUpdate, disabled }: { onUpdate: () => void; disabled?: boolean }) => ( + <button disabled={disabled} onClick={onUpdate}> + Add API Key + </button> + ), + AddOAuthButton: ({ onUpdate, disabled }: { onUpdate: () => void; disabled?: boolean }) => ( + <button disabled={disabled} onClick={onUpdate}> + Add OAuth + </button> + ), })) vi.mock('@/hooks/use-i18n', () => ({ @@ -107,7 +145,9 @@ describe('Card Component', () => { mockInvalidateDataSourceAuth() }) - const createMockPluginAuthActionReturn = (overrides: Partial<UsePluginAuthActionReturn> = {}): UsePluginAuthActionReturn => ({ + const createMockPluginAuthActionReturn = ( + overrides: Partial<UsePluginAuthActionReturn> = {}, + ): UsePluginAuthActionReturn => ({ deleteCredentialId: null, doingAction: false, handleConfirm: vi.fn(), @@ -161,13 +201,19 @@ describe('Card Component', () => { vi.mocked(useDataSourceAuthUpdate).mockReturnValue({ handleAuthUpdate: mockHandleAuthUpdate }) vi.mocked(useInvalidDataSourceListAuth).mockReturnValue(mockInvalidateDataSourceListAuth) - vi.mocked(useInvalidDefaultDataSourceListAuth).mockReturnValue(mockInvalidDefaultDataSourceListAuth) + vi.mocked(useInvalidDefaultDataSourceListAuth).mockReturnValue( + mockInvalidDefaultDataSourceListAuth, + ) vi.mocked(useInvalidDataSourceList).mockReturnValue(mockInvalidateDataSourceList) vi.mocked(useInvalidDataSourceAuth).mockReturnValue(mockInvalidateDataSourceAuth) vi.mocked(usePluginAuthAction).mockReturnValue(mockPluginAuthActionReturn) - vi.mocked(useRenderI18nObject).mockReturnValue(mockRenderI18nObjectResult as unknown as UseRenderI18nObjectReturn) - vi.mocked(useGetDataSourceOAuthUrl).mockReturnValue({ mutateAsync: mockGetPluginOAuthUrl } as unknown as UseGetDataSourceOAuthUrlReturn) + vi.mocked(useRenderI18nObject).mockReturnValue( + mockRenderI18nObjectResult as unknown as UseRenderI18nObjectReturn, + ) + vi.mocked(useGetDataSourceOAuthUrl).mockReturnValue({ + mutateAsync: mockGetPluginOAuthUrl, + } as unknown as UseGetDataSourceOAuthUrlReturn) }) const expectAuthUpdated = () => { @@ -188,7 +234,10 @@ describe('Card Component', () => { expect(screen.queryByText(/Test Author/))!.not.toBeInTheDocument() expect(screen.queryByText(/test-name/))!.not.toBeInTheDocument() expect(screen.getByText('1.2.0'))!.toBeInTheDocument() - expect(screen.getByRole('img', { name: 'Test Label' }))!.toHaveAttribute('src', 'test-icon-url') + expect(screen.getByRole('img', { name: 'Test Label' }))!.toHaveAttribute( + 'src', + 'test-icon-url', + ) expect(screen.getByText('Credential 1'))!.toBeInTheDocument() expect(screen.getByText(/plugin.auth.default/))!.toBeInTheDocument() @@ -266,10 +315,12 @@ describe('Card Component', () => { // Arrange const oAuthItem = { ...mockItem, - credentials_list: [{ - ...mockItem.credentials_list[0]!, - type: CredentialTypeEnum.OAUTH2, - }], + credentials_list: [ + { + ...mockItem.credentials_list[0]!, + type: CredentialTypeEnum.OAUTH2, + }, + ], } render(<Card item={oAuthItem} />) @@ -295,10 +346,12 @@ describe('Card Component', () => { // Arrange const oAuthItem = { ...mockItem, - credentials_list: [{ - ...mockItem.credentials_list[0]!, - type: CredentialTypeEnum.OAUTH2, - }], + credentials_list: [ + { + ...mockItem.credentials_list[0]!, + type: CredentialTypeEnum.OAUTH2, + }, + ], } mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.url' }) render(<Card item={oAuthItem} />) @@ -318,10 +371,12 @@ describe('Card Component', () => { // Arrange const oAuthItem = { ...mockItem, - credentials_list: [{ - ...mockItem.credentials_list[0]!, - type: CredentialTypeEnum.OAUTH2, - }], + credentials_list: [ + { + ...mockItem.credentials_list[0]!, + type: CredentialTypeEnum.OAUTH2, + }, + ], } mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: '' }) render(<Card item={oAuthItem} />) @@ -343,10 +398,12 @@ describe('Card Component', () => { const oAuthItem = { ...mockItem, - credentials_list: [{ - ...mockItem.credentials_list[0]!, - type: CredentialTypeEnum.OAUTH2, - }], + credentials_list: [ + { + ...mockItem.credentials_list[0]!, + type: CredentialTypeEnum.OAUTH2, + }, + ], } render(<Card item={oAuthItem} />) @@ -395,7 +452,10 @@ describe('Card Component', () => { describe('Modals', () => { it('should show Confirm dialog when deleteCredentialId is set and handle its actions', () => { // Arrange - const mockReturn = createMockPluginAuthActionReturn({ deleteCredentialId: 'c1', doingAction: false }) + const mockReturn = createMockPluginAuthActionReturn({ + deleteCredentialId: 'c1', + doingAction: false, + }) vi.mocked(usePluginAuthAction).mockReturnValue(mockReturn) // Act @@ -418,7 +478,10 @@ describe('Card Component', () => { it('should show ApiKeyModal when editValues is set and handle its actions', () => { // Arrange - const mockReturn = createMockPluginAuthActionReturn({ editValues: { some: 'value' }, doingAction: false }) + const mockReturn = createMockPluginAuthActionReturn({ + editValues: { some: 'value' }, + doingAction: false, + }) vi.mocked(usePluginAuthAction).mockReturnValue(mockReturn) render(<Card item={mockItem} disabled={false} />) @@ -437,7 +500,10 @@ describe('Card Component', () => { it('should disable ApiKeyModal when doingAction is true', () => { // Arrange - const mockReturnDoing = createMockPluginAuthActionReturn({ editValues: { some: 'value' }, doingAction: true }) + const mockReturnDoing = createMockPluginAuthActionReturn({ + editValues: { some: 'value' }, + doingAction: true, + }) vi.mocked(usePluginAuthAction).mockReturnValue(mockReturnDoing) // Act @@ -451,7 +517,10 @@ describe('Card Component', () => { it('should disable ApiKeyModal when user lacks credential.manage', () => { // Arrange mockWorkspacePermissionKeys = ['credential.use'] - const mockReturn = createMockPluginAuthActionReturn({ editValues: { some: 'value' }, doingAction: false }) + const mockReturn = createMockPluginAuthActionReturn({ + editValues: { some: 'value' }, + doingAction: false, + }) vi.mocked(usePluginAuthAction).mockReturnValue(mockReturn) // Act @@ -467,7 +536,9 @@ describe('Card Component', () => { // Arrange const configurableItem: DataSourceAuth = { ...mockItem, - credential_schema: [{ name: 'api_key', type: FormTypeEnum.textInput, label: 'API Key', required: true }], + credential_schema: [ + { name: 'api_key', type: FormTypeEnum.textInput, label: 'API Key', required: true }, + ], } // Act @@ -486,9 +557,13 @@ describe('Card Component', () => { mockWorkspacePermissionKeys = ['credential.use'] const configurableItem: DataSourceAuth = { ...mockItem, - credential_schema: [{ name: 'api_key', type: FormTypeEnum.textInput, label: 'API Key', required: true }], + credential_schema: [ + { name: 'api_key', type: FormTypeEnum.textInput, label: 'API Key', required: true }, + ], oauth_schema: { - client_schema: [{ name: 'oauth_key', type: FormTypeEnum.textInput, label: 'OAuth Key', required: true }], + client_schema: [ + { name: 'oauth_key', type: FormTypeEnum.textInput, label: 'OAuth Key', required: true }, + ], }, } diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx index 07344343f8eff5..6cb39efdb97bd7 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx @@ -1,7 +1,11 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react' import type { DataSourceAuth } from '../types' import type { FormSchema } from '@/app/components/base/form/types' -import type { AddApiKeyButtonProps, AddOAuthButtonProps, PluginPayload } from '@/app/components/plugins/plugin-auth/types' +import type { + AddApiKeyButtonProps, + AddOAuthButtonProps, + PluginPayload, +} from '@/app/components/plugins/plugin-auth/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { FormTypeEnum } from '@/app/components/base/form/types' import { AuthCategory } from '@/app/components/plugins/plugin-auth/types' @@ -9,10 +13,11 @@ import Configure from '../configure' vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { children?: ReactNode }) => ( - <button {...props}> - {children} - </button> + Button: ({ + children, + ...props + }: ButtonHTMLAttributes<HTMLButtonElement> & { children?: ReactNode }) => ( + <button {...props}>{children}</button> ), })) @@ -23,12 +28,20 @@ vi.mock('@langgenius/dify-ui/button', () => ({ // Mock plugin auth components to isolate the unit test for Configure. vi.mock('@/app/components/plugins/plugin-auth', () => ({ - AddApiKeyButton: vi.fn(({ onUpdate, disabled, buttonText }: AddApiKeyButtonProps & { onUpdate: () => void }) => ( - <button data-testid="add-api-key" onClick={onUpdate} disabled={disabled}>{buttonText}</button> - )), - AddOAuthButton: vi.fn(({ onUpdate, disabled, buttonText }: AddOAuthButtonProps & { onUpdate: () => void }) => ( - <button data-testid="add-oauth" onClick={onUpdate} disabled={disabled}>{buttonText}</button> - )), + AddApiKeyButton: vi.fn( + ({ onUpdate, disabled, buttonText }: AddApiKeyButtonProps & { onUpdate: () => void }) => ( + <button data-testid="add-api-key" onClick={onUpdate} disabled={disabled}> + {buttonText} + </button> + ), + ), + AddOAuthButton: vi.fn( + ({ onUpdate, disabled, buttonText }: AddOAuthButtonProps & { onUpdate: () => void }) => ( + <button data-testid="add-oauth" onClick={onUpdate} disabled={disabled}> + {buttonText} + </button> + ), + ), })) describe('Configure Component', () => { @@ -150,7 +163,13 @@ describe('Configure Component', () => { ...mockItemBase, credential_schema: [mockFormSchema], } - render(<Configure item={itemWithApiKey} pluginPayload={mockPluginPayload} onUpdate={mockOnUpdate} />) + render( + <Configure + item={itemWithApiKey} + pluginPayload={mockPluginPayload} + onUpdate={mockOnUpdate} + />, + ) // Act: Open and click update fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i })) @@ -205,7 +224,9 @@ describe('Configure Component', () => { it('should handle edge cases for missing, empty, or partial item data', () => { // Act & Assert (Missing schemas) - const { rerender } = render(<Configure item={mockItemBase} pluginPayload={mockPluginPayload} />) + const { rerender } = render( + <Configure item={mockItemBase} pluginPayload={mockPluginPayload} />, + ) fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i })) expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument() expect(screen.queryByTestId('add-oauth')).not.toBeInTheDocument() @@ -244,8 +265,7 @@ describe('Configure Component', () => { oauth_schema: { get client_schema() { count++ - if (count % 2 !== 0) - return [mockFormSchema] + if (count % 2 !== 0) return [mockFormSchema] return undefined }, is_oauth_custom_client_enabled: false, diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx index 9f2d782601ebef..4fc6e543e45fc4 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx @@ -7,7 +7,11 @@ import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks' import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth' import { useRenderI18nObject } from '@/hooks/use-i18n' -import { useGetDataSourceListAuth, useGetDataSourceOAuthUrl, useInvalidDataSourceListAuth } from '@/service/use-datasource' +import { + useGetDataSourceListAuth, + useGetDataSourceOAuthUrl, + useInvalidDataSourceListAuth, +} from '@/service/use-datasource' import { useInvalidDataSourceList } from '@/service/use-pipeline' import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins' import { useDataSourceAuthUpdate, useMarketplaceAllPlugins } from '../hooks' @@ -47,7 +51,9 @@ vi.mock('@/app/components/plugins/hooks', () => ({ })) vi.mock('../plugin-actions', () => ({ - default: ({ detail }: { detail: { plugin_id: string } }) => <button data-testid={`plugin-actions-${detail.plugin_id}`}>Actions</button>, + default: ({ detail }: { detail: { plugin_id: string } }) => ( + <button data-testid={`plugin-actions-${detail.plugin_id}`}>Actions</button> + ), })) vi.mock('../hooks', () => ({ @@ -166,13 +172,23 @@ describe('DataSourcePage Component', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useTheme).mockReturnValue({ theme: 'light' } as unknown as ReturnType<typeof useTheme>) - vi.mocked(useRenderI18nObject).mockReturnValue((obj: Record<string, string>) => obj?.en_US || '') - vi.mocked(useGetDataSourceOAuthUrl).mockReturnValue({ mutateAsync: vi.fn() } as unknown as ReturnType<typeof useGetDataSourceOAuthUrl>) + vi.mocked(useTheme).mockReturnValue({ theme: 'light' } as unknown as ReturnType< + typeof useTheme + >) + vi.mocked(useRenderI18nObject).mockReturnValue( + (obj: Record<string, string>) => obj?.en_US || '', + ) + vi.mocked(useGetDataSourceOAuthUrl).mockReturnValue({ + mutateAsync: vi.fn(), + } as unknown as ReturnType<typeof useGetDataSourceOAuthUrl>) vi.mocked(useInvalidDataSourceListAuth).mockReturnValue(vi.fn()) vi.mocked(useInvalidDataSourceList).mockReturnValue(vi.fn()) - vi.mocked(useInstalledPluginList).mockReturnValue({ data: { plugins: [], total: 0 } } as unknown as ReturnType<typeof useInstalledPluginList>) - vi.mocked(usePluginsWithLatestVersion).mockImplementation((plugins = []) => plugins as PluginDetail[]) + vi.mocked(useInstalledPluginList).mockReturnValue({ + data: { plugins: [], total: 0 }, + } as unknown as ReturnType<typeof useInstalledPluginList>) + vi.mocked(usePluginsWithLatestVersion).mockImplementation( + (plugins = []) => plugins as PluginDetail[], + ) vi.mocked(useInvalidateInstalledPluginList).mockReturnValue(vi.fn()) vi.mocked(useDataSourceAuthUpdate).mockReturnValue({ handleAuthUpdate: vi.fn() }) vi.mocked(useMarketplaceAllPlugins).mockReturnValue({ plugins: [], isLoading: false }) @@ -207,7 +223,14 @@ describe('DataSourcePage Component', () => { // Assert expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() - expect(screen.getByPlaceholderText('common.operation.search').closest('.sticky')).toHaveClass('top-0', 'z-10', '-mx-6', 'bg-components-panel-bg', 'px-6', 'pb-2') + expect(screen.getByPlaceholderText('common.operation.search').closest('.sticky')).toHaveClass( + 'top-0', + 'z-10', + '-mx-6', + 'bg-components-panel-bg', + 'px-6', + 'pb-2', + ) expect(screen.getByText('plugin.autoUpdate.autoUpdate')).toBeInTheDocument() expect(screen.getAllByText('plugin.autoUpdate.strategy.fixOnly.name')[0]).toBeInTheDocument() expect(screen.queryByText('Dify Source')).not.toBeInTheDocument() diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx index c8434077ed0ffd..ef876a0500f9cc 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx @@ -17,19 +17,28 @@ vi.mock('next-themes', () => ({ })) vi.mock('@/next/link', () => ({ - default: ({ children, href }: { children: React.ReactNode, href: string }) => ( - <a href={href} data-testid="mock-link">{children}</a> + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + <a href={href} data-testid="mock-link"> + {children} + </a> ), })) vi.mock('@/utils/var', () => ({ - getMarketplaceUrl: vi.fn((path: string, { theme }: { theme: string }) => `https://marketplace.url${path}?theme=${theme}`), + getMarketplaceUrl: vi.fn( + (path: string, { theme }: { theme: string }) => `https://marketplace.url${path}?theme=${theme}`, + ), })) // Mock marketplace components vi.mock('@/app/components/plugins/marketplace/list', () => ({ - default: ({ plugins, cardRender, cardContainerClassName, emptyClassName }: { + default: ({ + plugins, + cardRender, + cardContainerClassName, + emptyClassName, + }: { plugins: Plugin[] cardRender: (p: Plugin) => React.ReactNode cardContainerClassName?: string @@ -37,7 +46,7 @@ vi.mock('@/app/components/plugins/marketplace/list', () => ({ }) => ( <div data-testid="mock-list" className={cardContainerClassName}> {plugins.length === 0 && <div className={emptyClassName} aria-label="empty-state" />} - {plugins.map(plugin => ( + {plugins.map((plugin) => ( <div key={plugin.plugin_id} data-testid={`list-item-${plugin.plugin_id}`}> {cardRender(plugin)} </div> @@ -47,7 +56,7 @@ vi.mock('@/app/components/plugins/marketplace/list', () => ({ })) vi.mock('@/app/components/plugins/provider-card', () => ({ - default: ({ className, payload }: { className?: string, payload: Plugin }) => ( + default: ({ className, payload }: { className?: string; payload: Plugin }) => ( <div data-testid={`mock-provider-card-${payload.plugin_id}`} className={className}> {payload.name} </div> @@ -120,7 +129,10 @@ describe('InstallFromMarketplace Component', () => { // Assert expect(screen.getByText('common.modelProvider.installDataSource')).toBeInTheDocument() expect(screen.getByText('common.modelProvider.discoverMore')).toBeInTheDocument() - expect(screen.getByTestId('mock-link')).toHaveAttribute('href', 'https://marketplace.url/plugins/datasource?theme=light') + expect(screen.getByTestId('mock-link')).toHaveAttribute( + 'href', + 'https://marketplace.url/plugins/datasource?theme=light', + ) expect(screen.getByTestId('mock-list')).toBeInTheDocument() expect(screen.getByTestId('mock-list')).toHaveClass('grid', 'grid-cols-3', 'gap-2') expect(screen.getByTestId('mock-provider-card-plugin-1')).toHaveClass('h-[146px]') @@ -188,14 +200,22 @@ describe('InstallFromMarketplace Component', () => { isLoading: false, }) const onOpenMarketplace = vi.fn() - render(<InstallFromMarketplace providers={mockProviders} searchText="" onOpenMarketplace={onOpenMarketplace} />) + render( + <InstallFromMarketplace + providers={mockProviders} + searchText="" + onOpenMarketplace={onOpenMarketplace} + />, + ) // Act fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.difyMarketplace' })) // Assert expect(onOpenMarketplace).toHaveBeenCalledTimes(1) - expect(screen.queryByRole('link', { name: 'plugin.marketplace.difyMarketplace' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'plugin.marketplace.difyMarketplace' }), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx index fdcb954c145929..28c047eaba4308 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx @@ -67,7 +67,9 @@ describe('Item Component', () => { describe('Rename Mode Interactions', () => { it('should switch to rename mode when Trigger Rename is clicked', async () => { // Arrange - render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} canManageCredential />) + render( + <Item credentialItem={mockCredentialItem} onAction={mockOnAction} canManageCredential />, + ) // Act await triggerRename() @@ -78,7 +80,9 @@ describe('Item Component', () => { it('should update rename input value when changed', async () => { // Arrange - render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} canManageCredential />) + render( + <Item credentialItem={mockCredentialItem} onAction={mockOnAction} canManageCredential />, + ) await triggerRename() const input = screen.getByPlaceholderText('common.placeholder.input') @@ -91,7 +95,9 @@ describe('Item Component', () => { it('should call onAction with "rename" and correct payload when Save is clicked', async () => { // Arrange - render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} canManageCredential />) + render( + <Item credentialItem={mockCredentialItem} onAction={mockOnAction} canManageCredential />, + ) await triggerRename() const input = screen.getByPlaceholderText('common.placeholder.input') fireEvent.change(input, { target: { value: 'New Name' } }) @@ -100,14 +106,10 @@ describe('Item Component', () => { fireEvent.click(screen.getByText('common.operation.save')) // Assert - expect(mockOnAction).toHaveBeenCalledWith( - 'rename', - mockCredentialItem, - { - credential_id: 'test-id', - name: 'New Name', - }, - ) + expect(mockOnAction).toHaveBeenCalledWith('rename', mockCredentialItem, { + credential_id: 'test-id', + name: 'New Name', + }) // Should switch back to view mode expect(screen.queryByPlaceholderText('common.placeholder.input')).not.toBeInTheDocument() expect(screen.getByText('Test Credential')).toBeInTheDocument() @@ -115,7 +117,9 @@ describe('Item Component', () => { it('should exit rename mode without calling onAction when Cancel is clicked', async () => { // Arrange - render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} canManageCredential />) + render( + <Item credentialItem={mockCredentialItem} onAction={mockOnAction} canManageCredential />, + ) await triggerRename() const input = screen.getByPlaceholderText('common.placeholder.input') fireEvent.change(input, { target: { value: 'Cancelled Name' } }) diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx index 1cbe3717b0d433..e03a5af55633e7 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx @@ -33,7 +33,9 @@ describe('Operator Component', () => { const credential = createMockCredential(CredentialTypeEnum.API_KEY) // Act - render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />) + render( + <Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />, + ) await userEvent.setup().click(screen.getByRole('button')) // Assert @@ -41,7 +43,9 @@ describe('Operator Component', () => { expect(screen.getByText('common.operation.edit')).toBeInTheDocument() expect(screen.getByText('common.operation.remove')).toBeInTheDocument() expect(screen.queryByText('common.operation.rename')).not.toBeInTheDocument() - expect(screen.queryByText('common.dataSource.notion.changeAuthorizedPages')).not.toBeInTheDocument() + expect( + screen.queryByText('common.dataSource.notion.changeAuthorizedPages'), + ).not.toBeInTheDocument() }) it('should render correct actions for OAUTH2 type', async () => { @@ -49,7 +53,9 @@ describe('Operator Component', () => { const credential = createMockCredential(CredentialTypeEnum.OAUTH2) // Act - render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />) + render( + <Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />, + ) await userEvent.setup().click(screen.getByRole('button')) // Assert @@ -65,7 +71,14 @@ describe('Operator Component', () => { it('should call onRename when "rename" action is selected', async () => { // Arrange const credential = createMockCredential(CredentialTypeEnum.OAUTH2) - render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} canManageCredential />) + render( + <Operator + credentialItem={credential} + onAction={mockOnAction} + onRename={mockOnRename} + canManageCredential + />, + ) // Act await userEvent.setup().click(screen.getByRole('button')) @@ -92,7 +105,14 @@ describe('Operator Component', () => { it('should call onAction for "setDefault" action', async () => { // Arrange const credential = createMockCredential(CredentialTypeEnum.API_KEY) - render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} canUseCredential />) + render( + <Operator + credentialItem={credential} + onAction={mockOnAction} + onRename={mockOnRename} + canUseCredential + />, + ) // Act await userEvent.setup().click(screen.getByRole('button')) @@ -107,7 +127,14 @@ describe('Operator Component', () => { it('should call onAction for "edit" action', async () => { // Arrange const credential = createMockCredential(CredentialTypeEnum.API_KEY) - render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} canManageCredential />) + render( + <Operator + credentialItem={credential} + onAction={mockOnAction} + onRename={mockOnRename} + canManageCredential + />, + ) // Act await userEvent.setup().click(screen.getByRole('button')) @@ -122,7 +149,14 @@ describe('Operator Component', () => { it('should call onAction for "change" action', async () => { // Arrange const credential = createMockCredential(CredentialTypeEnum.OAUTH2) - render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} canManageCredential />) + render( + <Operator + credentialItem={credential} + onAction={mockOnAction} + onRename={mockOnRename} + canManageCredential + />, + ) // Act await userEvent.setup().click(screen.getByRole('button')) @@ -137,7 +171,14 @@ describe('Operator Component', () => { it('should call onAction for "delete" action', async () => { // Arrange const credential = createMockCredential(CredentialTypeEnum.API_KEY) - render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} canManageCredential />) + render( + <Operator + credentialItem={credential} + onAction={mockOnAction} + onRename={mockOnRename} + canManageCredential + />, + ) // Act await userEvent.setup().click(screen.getByRole('button')) @@ -151,7 +192,9 @@ describe('Operator Component', () => { it('should not call actions by default when credential permissions are omitted', async () => { const credential = createMockCredential(CredentialTypeEnum.API_KEY) - render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />) + render( + <Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />, + ) await userEvent.setup().click(screen.getByRole('button')) fireEvent.click(await screen.findByText('plugin.auth.setDefault')) diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/plugin-actions.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/plugin-actions.spec.tsx index 15600bb2185918..6d92762951a6b7 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/plugin-actions.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/plugin-actions.spec.tsx @@ -18,9 +18,12 @@ const { })) vi.mock('@/app/components/plugins/readme-panel/store', () => ({ - useReadmePanelStore: (selector: (value: { openReadmePanel: typeof mockOpenReadmePanel }) => unknown) => selector({ - openReadmePanel: mockOpenReadmePanel, - }), + useReadmePanelStore: ( + selector: (value: { openReadmePanel: typeof mockOpenReadmePanel }) => unknown, + ) => + selector({ + openReadmePanel: mockOpenReadmePanel, + }), })) vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/hooks', () => ({ @@ -63,8 +66,10 @@ vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({ })) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => ( - <button type="button" onClick={onClick}>{children}</button> + Button: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + <button type="button" onClick={onClick}> + {children} + </button> ), })) @@ -95,7 +100,7 @@ const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail name: 'Data Source Plugin', plugin_id: 'datasource-plugin', plugin_unique_identifier: 'datasource-plugin:1.0.0@checksum', - declaration: ({ + declaration: { author: 'acme', category: PluginCategoryEnum.datasource, name: 'datasource-provider', @@ -114,7 +119,7 @@ const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail }, credentials_schema: [], }, - } as unknown) as PluginDetail['declaration'], + } as unknown as PluginDetail['declaration'], installation_id: 'install-1', tenant_id: 'tenant-1', endpoints_setups: 0, @@ -140,12 +145,16 @@ describe('DataSourcePluginActions', () => { renderWithSystemFeatures(<DataSourcePluginActions detail={detail} />, { systemFeatures: { enable_marketplace: true }, }) - fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.moreActions' })) + fireEvent.click( + screen.getByRole('button', { name: 'plugin.detailPanel.operation.moreActions' }), + ) fireEvent.click(screen.getByText('plugin.detailPanel.operation.viewReadme')) - expect(mockOpenReadmePanel).toHaveBeenCalledWith(expect.objectContaining({ - detail, - triggerId: expect.any(String), - })) + expect(mockOpenReadmePanel).toHaveBeenCalledWith( + expect.objectContaining({ + detail, + triggerId: expect.any(String), + }), + ) }) }) diff --git a/web/app/components/header/account-setting/data-source-page-new/card.tsx b/web/app/components/header/account-setting/data-source-page-new/card.tsx index 57c56cc0c2f1b0..5225e1a69d4235 100644 --- a/web/app/components/header/account-setting/data-source-page-new/card.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/card.tsx @@ -1,7 +1,4 @@ -import type { - DataSourceAuth, - DataSourceCredential, -} from './types' +import type { DataSourceAuth, DataSourceCredential } from './types' import type { PluginDetail } from '@/app/components/plugins/types' import { AlertDialog, @@ -11,17 +8,10 @@ import { AlertDialogContent, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' -import { - memo, - useCallback, - useRef, -} from 'react' +import { memo, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' -import { - ApiKeyModal, - usePluginAuthAction, -} from '@/app/components/plugins/plugin-auth' +import { ApiKeyModal, usePluginAuthAction } from '@/app/components/plugins/plugin-auth' import { AuthCategory } from '@/app/components/plugins/plugin-auth/types' import { CollectionType } from '@/app/components/tools/types' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' @@ -43,21 +33,11 @@ type CardProps = { pluginDetail?: PluginDetail onPluginUpdate?: (isDelete?: boolean) => void } -const Card = ({ - item, - disabled, - pluginDetail, - onPluginUpdate, -}: CardProps) => { +const Card = ({ item, disabled, pluginDetail, onPluginUpdate }: CardProps) => { const { t } = useTranslation() const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() const renderI18nObject = useRenderI18nObject() - const { - icon, - label, - credentials_list, - credential_schema, - } = item + const { icon, label, credentials_list, credential_schema } = item const providerLabel = renderI18nObject(label) const fallbackPluginVersion = getPluginVersion(item.plugin_unique_identifier) const pluginPayload = { @@ -84,54 +64,40 @@ const Card = ({ pendingOperationCredentialId, } = usePluginAuthAction(pluginPayload, handleAuthUpdate) const changeCredentialIdRef = useRef<string | undefined>(undefined) - const { - mutateAsync: getPluginOAuthUrl, - } = useGetDataSourceOAuthUrl(pluginPayload.provider) + const { mutateAsync: getPluginOAuthUrl } = useGetDataSourceOAuthUrl(pluginPayload.provider) const handleOAuth = useCallback(async () => { const { authorization_url } = await getPluginOAuthUrl(changeCredentialIdRef.current) if (authorization_url) { - openOAuthPopup( - authorization_url, - handleAuthUpdate, - ) + openOAuthPopup(authorization_url, handleAuthUpdate) } }, [getPluginOAuthUrl, handleAuthUpdate]) - const handleAction = useCallback(( - action: string, - credentialItem: DataSourceCredential, - renamePayload?: { credential_id: string, name: string }, - ) => { - if (action === 'edit') { - handleEdit( - credentialItem.id, - { + const handleAction = useCallback( + ( + action: string, + credentialItem: DataSourceCredential, + renamePayload?: { credential_id: string; name: string }, + ) => { + if (action === 'edit') { + handleEdit(credentialItem.id, { ...credentialItem.credential, __name__: credentialItem.name, __credential_id__: credentialItem.id, - }, - ) - } - if (action === 'delete') - openConfirm(credentialItem.id) + }) + } + if (action === 'delete') openConfirm(credentialItem.id) - if (action === 'setDefault') - handleSetDefault(credentialItem.id) + if (action === 'setDefault') handleSetDefault(credentialItem.id) - if (action === 'rename' && renamePayload) - handleRename(renamePayload) + if (action === 'rename' && renamePayload) handleRename(renamePayload) - if (action === 'change') { - changeCredentialIdRef.current = credentialItem.id - handleOAuth() - } - }, [ - openConfirm, - handleEdit, - handleSetDefault, - handleRename, - handleOAuth, - ]) + if (action === 'change') { + changeCredentialIdRef.current = credentialItem.id + handleOAuth() + } + }, + [openConfirm, handleEdit, handleSetDefault, handleRename, handleOAuth], + ) return ( <div className="rounded-xl bg-background-section-burn"> @@ -146,34 +112,30 @@ const Card = ({ /> </div> <div className="flex min-w-0 grow items-center gap-2"> - <div className="truncate system-md-medium text-text-primary"> - {providerLabel} - </div> + <div className="truncate system-md-medium text-text-primary">{providerLabel}</div> <div className="flex shrink-0 items-center gap-1"> - { - pluginDetail - ? ( - <DataSourcePluginActions - detail={pluginDetail} - onUpdate={onPluginUpdate} - /> - ) - : !!fallbackPluginVersion && ( - <Badge - className="h-5 px-1.5" - text={( - <> - <div>{fallbackPluginVersion}</div> - <span aria-hidden className="ml-1 i-ri-arrow-left-right-line h-3 w-3 shrink-0 text-text-tertiary" /> - </> - )} - uppercase={false} - /> - ) - } + {pluginDetail ? ( + <DataSourcePluginActions detail={pluginDetail} onUpdate={onPluginUpdate} /> + ) : ( + !!fallbackPluginVersion && ( + <Badge + className="h-5 px-1.5" + text={ + <> + <div>{fallbackPluginVersion}</div> + <span + aria-hidden + className="ml-1 i-ri-arrow-left-right-line h-3 w-3 shrink-0 text-text-tertiary" + /> + </> + } + uppercase={false} + /> + ) + )} </div> </div> - <div onClick={e => e.stopPropagation()}> + <div onClick={(e) => e.stopPropagation()}> <Configure pluginPayload={pluginPayload} item={item} @@ -183,66 +145,63 @@ const Card = ({ </div> </div> <div className="flex h-4 items-center pl-3 system-xs-medium text-text-tertiary"> - {t($ => $['auth.connectedWorkspace'], { ns: 'plugin' })} + {t(($) => $['auth.connectedWorkspace'], { ns: 'plugin' })} <div className="ml-3 h-px grow bg-divider-subtle"></div> </div> - { - !!credentials_list.length && ( - <div className="space-y-1 p-3 pt-2" onClick={e => e.stopPropagation()}> - { - credentials_list.map(credentialItem => ( - <Item - key={credentialItem.id} - credentialItem={credentialItem} - onAction={handleAction} - canUseCredential={canUseCredential} - canManageCredential={canManageCredential} - /> - )) - } - </div> - ) - } - { - !credentials_list.length && ( - <div className="p-3 pt-1"> - <div className="flex h-10 items-center justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary"> - {t($ => $['auth.emptyAuth'], { ns: 'plugin' })} - </div> + {!!credentials_list.length && ( + <div className="space-y-1 p-3 pt-2" onClick={(e) => e.stopPropagation()}> + {credentials_list.map((credentialItem) => ( + <Item + key={credentialItem.id} + credentialItem={credentialItem} + onAction={handleAction} + canUseCredential={canUseCredential} + canManageCredential={canManageCredential} + /> + ))} + </div> + )} + {!credentials_list.length && ( + <div className="p-3 pt-1"> + <div className="flex h-10 items-center justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary"> + {t(($) => $['auth.emptyAuth'], { ns: 'plugin' })} </div> - ) - } - <AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirm()}> + </div> + )} + <AlertDialog open={!!deleteCredentialId} onOpenChange={(open) => !open && closeConfirm()}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['list.delete.title'], { ns: 'datasetDocuments' })} + {t(($) => $['list.delete.title'], { ns: 'datasetDocuments' })} </AlertDialogTitle> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton disabled={doingAction || !canManageCredential} onClick={handleConfirm}> - {t($ => $['operation.confirm'], { ns: 'common' })} + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton + disabled={doingAction || !canManageCredential} + onClick={handleConfirm} + > + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> </AlertDialog> - { - !!editValues && ( - <ApiKeyModal - pluginPayload={pluginPayload} - onClose={() => { - setEditValues(null) - pendingOperationCredentialId.current = null - }} - onUpdate={handleAuthUpdate} - formSchemas={credential_schema} - editValues={editValues} - onRemove={handleRemove} - disabled={disabled || doingAction || !canManageCredential} - /> - ) - } + {!!editValues && ( + <ApiKeyModal + pluginPayload={pluginPayload} + onClose={() => { + setEditValues(null) + pendingOperationCredentialId.current = null + }} + onUpdate={handleAuthUpdate} + formSchemas={credential_schema} + editValues={editValues} + onRemove={handleRemove} + disabled={disabled || doingAction || !canManageCredential} + /> + )} </div> ) } diff --git a/web/app/components/header/account-setting/data-source-page-new/configure.tsx b/web/app/components/header/account-setting/data-source-page-new/configure.tsx index 7c5eb2ae107a55..e5ff6dc0cbb99a 100644 --- a/web/app/components/header/account-setting/data-source-page-new/configure.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/configure.tsx @@ -5,25 +5,11 @@ import type { PluginPayload, } from '@/app/components/plugins/plugin-auth/types' import { Button } from '@langgenius/dify-ui/button' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - RiAddLine, -} from '@remixicon/react' -import { - memo, - useCallback, - useMemo, - useState, -} from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { RiAddLine } from '@remixicon/react' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - AddApiKeyButton, - AddOAuthButton, -} from '@/app/components/plugins/plugin-auth' +import { AddApiKeyButton, AddOAuthButton } from '@/app/components/plugins/plugin-auth' type ConfigureProps = { item: DataSourceAuth @@ -31,12 +17,7 @@ type ConfigureProps = { onUpdate?: () => void disabled?: boolean } -const Configure = ({ - item, - pluginPayload, - onUpdate, - disabled, -}: ConfigureProps) => { +const Configure = ({ item, pluginPayload, onUpdate, disabled }: ConfigureProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const canApiKey = item.credential_schema?.length @@ -44,7 +25,7 @@ const Configure = ({ const canOAuth = oAuthData.client_schema?.length const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => { return { - buttonText: t($ => $['auth.addOAuth'], { ns: 'plugin' }), + buttonText: t(($) => $['auth.addOAuth'], { ns: 'plugin' }), pluginPayload, } }, [pluginPayload, t]) @@ -52,7 +33,7 @@ const Configure = ({ const apiKeyButtonProps: AddApiKeyButtonProps = useMemo(() => { return { pluginPayload, - buttonText: t($ => $['auth.addApi'], { ns: 'plugin' }), + buttonText: t(($) => $['auth.addApi'], { ns: 'plugin' }), } }, [pluginPayload, t]) @@ -63,20 +44,14 @@ const Configure = ({ return ( <> - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( - <Button - className="h-8" - variant="secondary-accent" - > + render={ + <Button className="h-8" variant="secondary-accent"> <RiAddLine className="size-4" /> - {t($ => $['dataSource.configure'], { ns: 'common' })} + {t(($) => $['dataSource.configure'], { ns: 'common' })} </Button> - )} + } /> <PopoverContent placement="bottom-end" @@ -85,41 +60,35 @@ const Configure = ({ popupClassName="border-none bg-transparent shadow-none" > <div className="w-[240px] space-y-1.5 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg"> - { - !!canOAuth && ( - <AddOAuthButton - {...oAuthButtonProps} - onUpdate={handleUpdate} - oAuthData={{ - schema: oAuthData.client_schema || [], - is_oauth_custom_client_enabled: oAuthData.is_oauth_custom_client_enabled, - is_system_oauth_params_exists: oAuthData.is_system_oauth_params_exists, - client_params: oAuthData.oauth_custom_client_params, - redirect_uri: oAuthData.redirect_uri, - }} - disabled={disabled} - /> - ) - } - { - !!canApiKey && !!canOAuth && ( - <div className="flex h-4 items-center p-2 system-2xs-medium-uppercase text-text-quaternary"> - <div className="mr-2 h-px grow bg-linear-to-l from-[rgba(16,24,40,0.08)]" /> - OR - <div className="ml-2 h-px grow bg-linear-to-r from-[rgba(16,24,40,0.08)]" /> - </div> - ) - } - { - !!canApiKey && ( - <AddApiKeyButton - {...apiKeyButtonProps} - formSchemas={item.credential_schema} - onUpdate={handleUpdate} - disabled={disabled} - /> - ) - } + {!!canOAuth && ( + <AddOAuthButton + {...oAuthButtonProps} + onUpdate={handleUpdate} + oAuthData={{ + schema: oAuthData.client_schema || [], + is_oauth_custom_client_enabled: oAuthData.is_oauth_custom_client_enabled, + is_system_oauth_params_exists: oAuthData.is_system_oauth_params_exists, + client_params: oAuthData.oauth_custom_client_params, + redirect_uri: oAuthData.redirect_uri, + }} + disabled={disabled} + /> + )} + {!!canApiKey && !!canOAuth && ( + <div className="flex h-4 items-center p-2 system-2xs-medium-uppercase text-text-quaternary"> + <div className="mr-2 h-px grow bg-linear-to-l from-[rgba(16,24,40,0.08)]" /> + OR + <div className="ml-2 h-px grow bg-linear-to-r from-[rgba(16,24,40,0.08)]" /> + </div> + )} + {!!canApiKey && ( + <AddApiKeyButton + {...apiKeyButtonProps} + formSchemas={item.credential_schema} + onUpdate={handleUpdate} + disabled={disabled} + /> + )} </div> </PopoverContent> </Popover> diff --git a/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-data-source-auth-update.spec.ts b/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-data-source-auth-update.spec.ts index 3cbaedd6e18bd7..dad12da7b43a55 100644 --- a/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-data-source-auth-update.spec.ts +++ b/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-data-source-auth-update.spec.ts @@ -33,7 +33,9 @@ describe('useDataSourceAuthUpdate', () => { vi.mocked(useInvalidDataSourceAuth).mockReturnValue(mockInvalidateDataSourceAuth) vi.mocked(useInvalidDataSourceListAuth).mockReturnValue(mockInvalidateDataSourceListAuth) - vi.mocked(useInvalidDefaultDataSourceListAuth).mockReturnValue(mockInvalidDefaultDataSourceListAuth) + vi.mocked(useInvalidDefaultDataSourceListAuth).mockReturnValue( + mockInvalidDefaultDataSourceListAuth, + ) vi.mocked(useInvalidDataSourceList).mockReturnValue(mockInvalidateDataSourceList) }) @@ -42,10 +44,12 @@ describe('useDataSourceAuthUpdate', () => { // Arrange const pluginId = 'test-plugin-id' const provider = 'test-provider' - const { result } = renderHook(() => useDataSourceAuthUpdate({ - pluginId, - provider, - })) + const { result } = renderHook(() => + useDataSourceAuthUpdate({ + pluginId, + provider, + }), + ) // Assert Initialization expect(useInvalidDataSourceAuth).toHaveBeenCalledWith({ pluginId, provider }) diff --git a/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts b/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts index ed6b496715a84b..8e0dc0675d8840 100644 --- a/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts +++ b/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts @@ -13,7 +13,9 @@ import { useMarketplaceAllPlugins } from '../use-marketplace-all-plugins' */ type UseMarketplacePluginsReturn = ReturnType<typeof useMarketplacePlugins> -type UseMarketplacePluginsByCollectionIdReturn = ReturnType<typeof useMarketplacePluginsByCollectionId> +type UseMarketplacePluginsByCollectionIdReturn = ReturnType< + typeof useMarketplacePluginsByCollectionId +> vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ useMarketplacePlugins: vi.fn(), @@ -27,27 +29,33 @@ describe('useMarketplaceAllPlugins', () => { const mockCancelQueryPluginsWithDebounced = vi.fn() const mockFetchNextPage = vi.fn() - const createBasePluginsMock = (overrides: Partial<UseMarketplacePluginsReturn> = {}): UseMarketplacePluginsReturn => ({ - plugins: [], - total: 0, - resetPlugins: mockResetPlugins, - queryPlugins: mockQueryPlugins, - queryPluginsWithDebounced: mockQueryPluginsWithDebounced, - cancelQueryPluginsWithDebounced: mockCancelQueryPluginsWithDebounced, - isLoading: false, - isFetchingNextPage: false, - hasNextPage: false, - fetchNextPage: mockFetchNextPage, - page: 1, - ...overrides, - } as UseMarketplacePluginsReturn) - - const createBaseCollectionMock = (overrides: Partial<UseMarketplacePluginsByCollectionIdReturn> = {}): UseMarketplacePluginsByCollectionIdReturn => ({ - plugins: [], - isLoading: false, - isSuccess: true, - ...overrides, - } as UseMarketplacePluginsByCollectionIdReturn) + const createBasePluginsMock = ( + overrides: Partial<UseMarketplacePluginsReturn> = {}, + ): UseMarketplacePluginsReturn => + ({ + plugins: [], + total: 0, + resetPlugins: mockResetPlugins, + queryPlugins: mockQueryPlugins, + queryPluginsWithDebounced: mockQueryPluginsWithDebounced, + cancelQueryPluginsWithDebounced: mockCancelQueryPluginsWithDebounced, + isLoading: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: mockFetchNextPage, + page: 1, + ...overrides, + }) as UseMarketplacePluginsReturn + + const createBaseCollectionMock = ( + overrides: Partial<UseMarketplacePluginsByCollectionIdReturn> = {}, + ): UseMarketplacePluginsByCollectionIdReturn => + ({ + plugins: [], + isLoading: false, + isSuccess: true, + ...overrides, + }) as UseMarketplacePluginsByCollectionIdReturn beforeEach(() => { vi.clearAllMocks() @@ -121,7 +129,7 @@ describe('useMarketplaceAllPlugins', () => { // Assert: pExcluded is removed, p1 is duplicated (so kept once), p2 is added, p3 is bundle (skipped) expect(result.current.plugins).toHaveLength(2) - expect(result.current.plugins.map(p => p.plugin_id)).toEqual(['p1', 'p2']) + expect(result.current.plugins.map((p) => p.plugin_id)).toEqual(['p1', 'p2']) }) it('should handle undefined plugins gracefully', () => { diff --git a/web/app/components/header/account-setting/data-source-page-new/hooks/use-data-source-auth-update.ts b/web/app/components/header/account-setting/data-source-page-new/hooks/use-data-source-auth-update.ts index 48916ad1c74b74..bc01e66e5accd2 100644 --- a/web/app/components/header/account-setting/data-source-page-new/hooks/use-data-source-auth-update.ts +++ b/web/app/components/header/account-setting/data-source-page-new/hooks/use-data-source-auth-update.ts @@ -1,5 +1,9 @@ import { useCallback } from 'react' -import { useInvalidDataSourceAuth, useInvalidDataSourceListAuth, useInvalidDefaultDataSourceListAuth } from '@/service/use-datasource' +import { + useInvalidDataSourceAuth, + useInvalidDataSourceListAuth, + useInvalidDefaultDataSourceListAuth, +} from '@/service/use-datasource' import { useInvalidDataSourceList } from '@/service/use-pipeline' export const useDataSourceAuthUpdate = ({ @@ -21,7 +25,12 @@ export const useDataSourceAuthUpdate = ({ invalidDefaultDataSourceListAuth() invalidateDataSourceList() invalidateDataSourceAuth() - }, [invalidateDataSourceListAuth, invalidateDataSourceList, invalidateDataSourceAuth, invalidDefaultDataSourceListAuth]) + }, [ + invalidateDataSourceListAuth, + invalidateDataSourceList, + invalidateDataSourceAuth, + invalidDefaultDataSourceListAuth, + ]) return { handleAuthUpdate, diff --git a/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts b/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts index 7e2dc572fc62bc..03e64d26d21657 100644 --- a/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts +++ b/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts @@ -1,7 +1,4 @@ -import { - useEffect, - useMemo, -} from 'react' +import { useEffect, useMemo } from 'react' import { useMarketplacePlugins, useMarketplacePluginsByCollectionId, @@ -10,12 +7,10 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types' export const useMarketplaceAllPlugins = (providers: any[], searchText: string) => { const exclude = useMemo(() => { - return providers.map(provider => provider.plugin_id) + return providers.map((provider) => provider.plugin_id) }, [providers]) - const { - plugins: collectionPlugins = [], - isLoading: isCollectionLoading, - } = useMarketplacePluginsByCollectionId('__datasource-settings-pinned-datasources') + const { plugins: collectionPlugins = [], isLoading: isCollectionLoading } = + useMarketplacePluginsByCollectionId('__datasource-settings-pinned-datasources') const { plugins, queryPlugins, @@ -33,8 +28,7 @@ export const useMarketplaceAllPlugins = (providers: any[], searchText: string) = sort_by: 'install_count', sort_order: 'DESC', }) - } - else { + } else { queryPlugins({ query: '', category: PluginCategoryEnum.datasource, @@ -48,13 +42,13 @@ export const useMarketplaceAllPlugins = (providers: any[], searchText: string) = }, [queryPlugins, queryPluginsWithDebounced, searchText, exclude]) const allPlugins = useMemo(() => { - const allPlugins = collectionPlugins.filter(plugin => !exclude.includes(plugin.plugin_id)) + const allPlugins = collectionPlugins.filter((plugin) => !exclude.includes(plugin.plugin_id)) if (plugins?.length) { for (let i = 0; i < plugins.length; i++) { const plugin = plugins[i] - if (plugin!.type !== 'bundle' && !allPlugins.find(p => p.plugin_id === plugin!.plugin_id)) + if (plugin!.type !== 'bundle' && !allPlugins.find((p) => p.plugin_id === plugin!.plugin_id)) allPlugins.push(plugin!) } } diff --git a/web/app/components/header/account-setting/data-source-page-new/index.tsx b/web/app/components/header/account-setting/data-source-page-new/index.tsx index 7abb48170ac830..2f6435990cbb96 100644 --- a/web/app/components/header/account-setting/data-source-page-new/index.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/index.tsx @@ -17,7 +17,7 @@ import Card from './card' import InstallFromMarketplace from './install-from-marketplace' type DataSourcePageProps = { - layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode + layout?: (parts: { body: ReactNode; toolbar: ReactNode }) => ReactNode onOpenMarketplace?: () => void stickyToolbar?: boolean } @@ -44,7 +44,7 @@ function DataSourceListSkeleton() { const { t } = useTranslation() return ( - <div role="status" aria-label={t($ => $.loading, { ns: 'common' })} className="space-y-2"> + <div role="status" aria-label={t(($) => $.loading, { ns: 'common' })} className="space-y-2"> {Array.from({ length: 2 }, (_, index) => ( <DataSourceCardSkeleton key={index} /> ))} @@ -52,20 +52,14 @@ function DataSourceListSkeleton() { ) } -const DataSourcePage = ({ - layout, - onOpenMarketplace, - stickyToolbar, -}: DataSourcePageProps) => { +const DataSourcePage = ({ layout, onOpenMarketplace, stickyToolbar }: DataSourcePageProps) => { const { t } = useTranslation() const renderI18nObject = useRenderI18nObject() const [searchText, setSearchText] = useState('') - const { - canSetPluginPreferences, - } = usePluginSettingsAccess() + const { canSetPluginPreferences } = usePluginSettingsAccess() const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const { data, isLoading: isDataSourceListLoading } = useGetDataSourceListAuth() const { data: installedPluginList } = useInstalledPluginList() @@ -75,12 +69,13 @@ const DataSourcePage = ({ const invalidateDataSourceList = useInvalidDataSourceList() const dataSources = useMemo(() => data?.result ?? [], [data?.result]) const dataSourcePluginDetails = useMemo(() => { - return pluginListWithLatestVersion.filter(plugin => plugin.declaration.category === PluginCategoryEnum.datasource) + return pluginListWithLatestVersion.filter( + (plugin) => plugin.declaration.category === PluginCategoryEnum.datasource, + ) }, [pluginListWithLatestVersion]) const filteredDataSources = useMemo(() => { const normalizedSearchText = searchText.trim().toLowerCase() - if (!normalizedSearchText) - return dataSources + if (!normalizedSearchText) return dataSources return dataSources.filter((item) => { const searchableText = [ @@ -89,7 +84,9 @@ const DataSourcePage = ({ item.author, renderI18nObject(item.label), renderI18nObject(item.description), - ].join(' ').toLowerCase() + ] + .join(' ') + .toLowerCase() return searchableText.includes(normalizedSearchText) }) @@ -101,23 +98,22 @@ const DataSourcePage = ({ }, [invalidateDataSourceList, invalidateDataSourceListAuth, invalidateInstalledPluginList]) const toolbar = ( - <div className={stickyToolbar - ? layout - ? 'flex w-full items-center justify-between gap-3' - : 'sticky top-0 z-10 -mx-6 mb-2 flex items-center justify-between gap-3 bg-components-panel-bg px-6 pb-2' - : 'mb-2 flex items-center justify-between gap-3'} + <div + className={ + stickyToolbar + ? layout + ? 'flex w-full items-center justify-between gap-3' + : 'sticky top-0 z-10 -mx-6 mb-2 flex items-center justify-between gap-3 bg-components-panel-bg px-6 pb-2' + : 'mb-2 flex items-center justify-between gap-3' + } > <SearchInput className="w-[200px]" - placeholder={t($ => $['operation.search'], { ns: 'common' })} + placeholder={t(($) => $['operation.search'], { ns: 'common' })} value={searchText} onValueChange={setSearchText} /> - {canSetPluginPreferences && ( - <UpdateSettingDialog - category={PluginCategoryEnum.datasource} - /> - )} + {canSetPluginPreferences && <UpdateSettingDialog category={PluginCategoryEnum.datasource} />} </div> ) @@ -131,7 +127,7 @@ const DataSourcePage = ({ </div> <div className="mt-2 system-sm-medium text-text-secondary"> <Trans - i18nKey={$ => $['dataSourcePage.notSetUpTitle']} + i18nKey={($) => $['dataSourcePage.notSetUpTitle']} ns="common" components={{ highlight: <span className="text-text-primary" />, @@ -139,37 +135,35 @@ const DataSourcePage = ({ /> </div> <div className="mt-1 system-xs-regular text-text-tertiary"> - {t($ => $['dataSourcePage.installFirst'], { ns: 'common' })} + {t(($) => $['dataSourcePage.installFirst'], { ns: 'common' })} </div> </div> )} {!isDataSourceListLoading && !!filteredDataSources.length && ( <div className="space-y-2"> - { - filteredDataSources.map((item) => { - const pluginDetail = dataSourcePluginDetails.find(plugin => plugin.plugin_id === item.plugin_id) + {filteredDataSources.map((item) => { + const pluginDetail = dataSourcePluginDetails.find( + (plugin) => plugin.plugin_id === item.plugin_id, + ) - return ( - <Card - key={item.plugin_unique_identifier} - item={item} - pluginDetail={pluginDetail} - onPluginUpdate={handlePluginUpdate} - /> - ) - }) - } + return ( + <Card + key={item.plugin_unique_identifier} + item={item} + pluginDetail={pluginDetail} + onPluginUpdate={handlePluginUpdate} + /> + ) + })} </div> )} - { - !isDataSourceListLoading && enable_marketplace && ( - <InstallFromMarketplace - providers={dataSources} - searchText={searchText} - onOpenMarketplace={onOpenMarketplace} - /> - ) - } + {!isDataSourceListLoading && enable_marketplace && ( + <InstallFromMarketplace + providers={dataSources} + searchText={searchText} + onOpenMarketplace={onOpenMarketplace} + /> + )} </> ) diff --git a/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx b/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx index d7d0251fda586c..31f8d105d70fba 100644 --- a/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx @@ -2,11 +2,7 @@ import type { DataSourceAuth } from './types' import type { Plugin } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' import { useTheme } from 'next-themes' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import Loading from '@/app/components/base/loading' @@ -16,9 +12,7 @@ import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/us import ProviderCard from '@/app/components/plugins/provider-card' import { PluginCategoryEnum } from '@/app/components/plugins/types' import Link from '@/next/link' -import { - useMarketplaceAllPlugins, -} from './hooks' +import { useMarketplaceAllPlugins } from './hooks' type InstallFromMarketplaceProps = { onOpenMarketplace?: () => void @@ -34,14 +28,13 @@ const InstallFromMarketplace = ({ const { theme } = useTheme() const { canInstallPlugin } = usePluginSettingsAccess() const [collapse, setCollapse] = useState(false) - const { - plugins: allPlugins, - isLoading: isAllPluginsLoading, - } = useMarketplaceAllPlugins(providers, searchText) + const { plugins: allPlugins, isLoading: isAllPluginsLoading } = useMarketplaceAllPlugins( + providers, + searchText, + ) const cardRender = useCallback((plugin: Plugin) => { - if (plugin.type === 'bundle') - return null + if (plugin.type === 'bundle') return null return <ProviderCard key={plugin.plugin_id} className="h-[146px]" payload={plugin} /> }, []) @@ -56,44 +49,50 @@ const InstallFromMarketplace = ({ className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left system-md-semibold text-text-primary" onClick={() => setCollapse(!collapse)} > - <span className={cn('i-ri-arrow-down-s-line size-4', collapse && '-rotate-90')} aria-hidden="true" /> - {t($ => $['modelProvider.installDataSource'], { ns: 'common' })} + <span + className={cn('i-ri-arrow-down-s-line size-4', collapse && '-rotate-90')} + aria-hidden="true" + /> + {t(($) => $['modelProvider.installDataSource'], { ns: 'common' })} </button> <div className="mb-2 flex items-center pt-2"> - <span className="pr-1 system-sm-regular text-text-tertiary">{t($ => $['modelProvider.discoverMore'], { ns: 'common' })}</span> - {onOpenMarketplace - ? ( - <button - type="button" - className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent" - onClick={onOpenMarketplace} - > - {t($ => $['marketplace.difyMarketplace'], { ns: 'plugin' })} - <span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" /> - </button> - ) - : ( - <Link target="_blank" rel="noopener noreferrer" href={getMarketplaceCategoryUrl(PluginCategoryEnum.datasource, { theme })} className="inline-flex items-center system-sm-medium text-text-accent"> - {t($ => $['marketplace.difyMarketplace'], { ns: 'plugin' })} - <span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" /> - </Link> - )} + <span className="pr-1 system-sm-regular text-text-tertiary"> + {t(($) => $['modelProvider.discoverMore'], { ns: 'common' })} + </span> + {onOpenMarketplace ? ( + <button + type="button" + className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent" + onClick={onOpenMarketplace} + > + {t(($) => $['marketplace.difyMarketplace'], { ns: 'plugin' })} + <span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" /> + </button> + ) : ( + <Link + target="_blank" + rel="noopener noreferrer" + href={getMarketplaceCategoryUrl(PluginCategoryEnum.datasource, { theme })} + className="inline-flex items-center system-sm-medium text-text-accent" + > + {t(($) => $['marketplace.difyMarketplace'], { ns: 'plugin' })} + <span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" /> + </Link> + )} </div> </div> {!collapse && isAllPluginsLoading && <Loading type="area" />} - { - !isAllPluginsLoading && !collapse && ( - <List - marketplaceCollections={[]} - marketplaceCollectionPluginsMap={{}} - plugins={allPlugins} - showInstallButton={canInstallPlugin} - cardContainerClassName="grid grid-cols-3 gap-2" - cardRender={cardRender} - emptyClassName="h-auto" - /> - ) - } + {!isAllPluginsLoading && !collapse && ( + <List + marketplaceCollections={[]} + marketplaceCollectionPluginsMap={{}} + plugins={allPlugins} + showInstallButton={canInstallPlugin} + cardContainerClassName="grid grid-cols-3 gap-2" + cardRender={cardRender} + emptyClassName="h-auto" + /> + )} </div> ) } diff --git a/web/app/components/header/account-setting/data-source-page-new/item.tsx b/web/app/components/header/account-setting/data-source-page-new/item.tsx index 35a4ca4abcdcf3..b18aead30542d5 100644 --- a/web/app/components/header/account-setting/data-source-page-new/item.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/item.tsx @@ -1,19 +1,18 @@ -import type { - DataSourceCredential, -} from './types' +import type { DataSourceCredential } from './types' import { Button } from '@langgenius/dify-ui/button' import { Input } from '@langgenius/dify-ui/input' import { StatusDot } from '@langgenius/dify-ui/status-dot' -import { - memo, - useState, -} from 'react' +import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' import Operator from './operator' type ItemProps = { credentialItem: DataSourceCredential - onAction: (action: string, credentialItem: DataSourceCredential, renamePayload?: { credential_id: string, name: string }) => void + onAction: ( + action: string, + credentialItem: DataSourceCredential, + renamePayload?: { credential_id: string; name: string }, + ) => void canUseCredential?: boolean canManageCredential?: boolean } @@ -29,84 +28,72 @@ const Item = ({ return ( <div className="flex h-10 items-center gap-3 overflow-hidden rounded-lg bg-components-panel-on-panel-item-bg py-1 pr-1 pl-3 shadow-xs"> - { - renaming && ( - <div className="flex w-full items-center space-x-1"> - <Input - className="h-6 min-w-0 grow" - value={renameValue} - onChange={e => setRenameValue(e.target.value)} - placeholder={t($ => $['placeholder.input'], { ns: 'common' })} - onClick={e => e.stopPropagation()} - /> - <Button - size="small" - variant="primary" - onClick={(e) => { - e.stopPropagation() - onAction?.( - 'rename', - credentialItem, - { - credential_id: credentialItem.id, - name: renameValue, - }, - ) - setRenaming(false) - }} - > - {t($ => $['operation.save'], { ns: 'common' })} - </Button> - <Button - size="small" - onClick={(e) => { - e.stopPropagation() - setRenaming(false) - }} - > - {t($ => $['operation.cancel'], { ns: 'common' })} - </Button> + {renaming && ( + <div className="flex w-full items-center space-x-1"> + <Input + className="h-6 min-w-0 grow" + value={renameValue} + onChange={(e) => setRenameValue(e.target.value)} + placeholder={t(($) => $['placeholder.input'], { ns: 'common' })} + onClick={(e) => e.stopPropagation()} + /> + <Button + size="small" + variant="primary" + onClick={(e) => { + e.stopPropagation() + onAction?.('rename', credentialItem, { + credential_id: credentialItem.id, + name: renameValue, + }) + setRenaming(false) + }} + > + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> + <Button + size="small" + onClick={(e) => { + e.stopPropagation() + setRenaming(false) + }} + > + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + </div> + )} + {!renaming && ( + <> + <div className="min-w-0 grow truncate system-sm-medium text-text-secondary"> + {credentialItem.name} </div> - ) - } - { - !renaming && ( - <> - <div className="min-w-0 grow truncate system-sm-medium text-text-secondary"> - {credentialItem.name} + {credentialItem.is_default && ( + <div className="shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['auth.default'], { ns: 'plugin' })} </div> - { - credentialItem.is_default && ( - <div className="shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['auth.default'], { ns: 'plugin' })} - </div> - ) - } - </> - ) - } - { - !renaming && ( - <> - <div className="ml-auto flex shrink-0 items-center"> - <div className="mr-1 flex h-3 w-3 items-center justify-center"> - <StatusDot status="success" /> - </div> - <div className="system-xs-semibold-uppercase text-util-colors-green-green-600"> - {t($ => $['dataSource.notion.connected'], { ns: 'common' })} - </div> + )} + </> + )} + {!renaming && ( + <> + <div className="ml-auto flex shrink-0 items-center"> + <div className="mr-1 flex h-3 w-3 items-center justify-center"> + <StatusDot status="success" /> </div> - <div className="mr-1 ml-2 h-3 w-px bg-divider-regular"></div> - <Operator - credentialItem={credentialItem} - onAction={onAction} - onRename={() => setRenaming(true)} - canUseCredential={canUseCredential} - canManageCredential={canManageCredential} - /> - </> - ) - } + <div className="system-xs-semibold-uppercase text-util-colors-green-green-600"> + {t(($) => $['dataSource.notion.connected'], { ns: 'common' })} + </div> + </div> + <div className="mr-1 ml-2 h-3 w-px bg-divider-regular"></div> + <Operator + credentialItem={credentialItem} + onAction={onAction} + onRename={() => setRenaming(true)} + canUseCredential={canUseCredential} + canManageCredential={canManageCredential} + /> + </> + )} </div> ) } diff --git a/web/app/components/header/account-setting/data-source-page-new/operator.tsx b/web/app/components/header/account-setting/data-source-page-new/operator.tsx index 4a7bc1b91466c0..6cd6d1460381d6 100644 --- a/web/app/components/header/account-setting/data-source-page-new/operator.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/operator.tsx @@ -1,6 +1,4 @@ -import type { - DataSourceCredential, -} from './types' +import type { DataSourceCredential } from './types' import { DropdownMenu, DropdownMenuContent, @@ -8,10 +6,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' @@ -31,63 +26,92 @@ const Operator = ({ canManageCredential = false, }: OperatorProps) => { const { t } = useTranslation() - const { - type, - } = credentialItem - const handleAction = useCallback((action: string, allowed: boolean) => { - if (!allowed) - return + const { type } = credentialItem + const handleAction = useCallback( + (action: string, allowed: boolean) => { + if (!allowed) return - queueMicrotask(() => { - if (action === 'rename') { - onRename?.() - return - } - onAction(action, credentialItem) - }) - }, [credentialItem, onAction, onRename]) + queueMicrotask(() => { + if (action === 'rename') { + onRename?.() + return + } + onAction(action, credentialItem) + }) + }, + [credentialItem, onAction, onRename], + ) return ( <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <ActionButton size="l" className="focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover" - aria-label={t($ => $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.more'], { ns: 'common' })} > <span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" /> </ActionButton> - )} + } /> <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[200px]"> - <DropdownMenuItem disabled={!canUseCredential} className="h-auto gap-2 py-2" onClick={() => handleAction('setDefault', canUseCredential)}> + <DropdownMenuItem + disabled={!canUseCredential} + className="h-auto gap-2 py-2" + onClick={() => handleAction('setDefault', canUseCredential)} + > <span aria-hidden className="i-ri-home-9-line size-4 text-text-tertiary" /> - <div className="system-sm-semibold text-text-secondary">{t($ => $['auth.setDefault'], { ns: 'plugin' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['auth.setDefault'], { ns: 'plugin' })} + </div> </DropdownMenuItem> {type === CredentialTypeEnum.OAUTH2 && ( - <DropdownMenuItem disabled={!canManageCredential} className="h-auto gap-2 py-2" onClick={() => handleAction('rename', canManageCredential)}> + <DropdownMenuItem + disabled={!canManageCredential} + className="h-auto gap-2 py-2" + onClick={() => handleAction('rename', canManageCredential)} + > <span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" /> - <div className="system-sm-semibold text-text-secondary">{t($ => $['operation.rename'], { ns: 'common' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['operation.rename'], { ns: 'common' })} + </div> </DropdownMenuItem> )} {type === CredentialTypeEnum.API_KEY && ( - <DropdownMenuItem disabled={!canManageCredential} className="h-auto gap-2 py-2" onClick={() => handleAction('edit', canManageCredential)}> + <DropdownMenuItem + disabled={!canManageCredential} + className="h-auto gap-2 py-2" + onClick={() => handleAction('edit', canManageCredential)} + > <span aria-hidden className="i-ri-equalizer-2-line size-4 text-text-tertiary" /> - <div className="system-sm-semibold text-text-secondary">{t($ => $['operation.edit'], { ns: 'common' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['operation.edit'], { ns: 'common' })} + </div> </DropdownMenuItem> )} {type === CredentialTypeEnum.OAUTH2 && ( - <DropdownMenuItem disabled={!canManageCredential} className="h-auto gap-2 py-2" onClick={() => handleAction('change', canManageCredential)}> + <DropdownMenuItem + disabled={!canManageCredential} + className="h-auto gap-2 py-2" + onClick={() => handleAction('change', canManageCredential)} + > <span aria-hidden className="i-ri-sticky-note-add-line size-4 text-text-tertiary" /> - <div className="mb-1 system-sm-semibold text-text-secondary">{t($ => $['dataSource.notion.changeAuthorizedPages'], { ns: 'common' })}</div> + <div className="mb-1 system-sm-semibold text-text-secondary"> + {t(($) => $['dataSource.notion.changeAuthorizedPages'], { ns: 'common' })} + </div> </DropdownMenuItem> )} <DropdownMenuSeparator /> - <DropdownMenuItem disabled={!canManageCredential} variant="destructive" className="h-auto gap-2 py-2" onClick={() => handleAction('delete', canManageCredential)}> + <DropdownMenuItem + disabled={!canManageCredential} + variant="destructive" + className="h-auto gap-2 py-2" + onClick={() => handleAction('delete', canManageCredential)} + > <span aria-hidden className="i-ri-delete-bin-line size-4" /> <div className="system-sm-semibold"> - {t($ => $['operation.remove'], { ns: 'common' })} + {t(($) => $['operation.remove'], { ns: 'common' })} </div> </DropdownMenuItem> </DropdownMenuContent> diff --git a/web/app/components/header/account-setting/data-source-page-new/plugin-actions.tsx b/web/app/components/header/account-setting/data-source-page-new/plugin-actions.tsx index 66dc475d54cc1b..781772ca4023ff 100644 --- a/web/app/components/header/account-setting/data-source-page-new/plugin-actions.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/plugin-actions.tsx @@ -7,7 +7,10 @@ import { memo, useId } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { HeaderModals } from '@/app/components/plugins/plugin-detail-panel/detail-header/components' -import { useDetailHeaderState, usePluginOperations } from '@/app/components/plugins/plugin-detail-panel/detail-header/hooks' +import { + useDetailHeaderState, + usePluginOperations, +} from '@/app/components/plugins/plugin-detail-panel/detail-header/hooks' import { OperationDropdown } from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting' import { useReadmePanelStore } from '@/app/components/plugins/readme-panel/store' @@ -24,16 +27,11 @@ type Props = { const usePluginDetailHeader = useDetailHeaderState -const getDetailUrl = ( - detail: PluginDetail, - locale: string, - theme: string, -) => { +const getDetailUrl = (detail: PluginDetail, locale: string, theme: string) => { const { source, meta } = detail const { author, name } = detail.declaration || detail - if (source === PluginSource.github) - return meta?.repo ? `https://github.com/${meta.repo}` : '' + if (source === PluginSource.github) return meta?.repo ? `https://github.com/${meta.repo}` : '' if (source === PluginSource.marketplace) return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme }) @@ -41,15 +39,12 @@ const getDetailUrl = ( return '' } -const DataSourcePluginActions = ({ - detail, - onUpdate, -}: Props) => { +const DataSourcePluginActions = ({ detail, onUpdate }: Props) => { const { t } = useTranslation() const { theme } = useTheme() const locale = useLocale() const readmeTriggerId = useId() - const openReadmePanel = useReadmePanelStore(s => s.openReadmePanel) + const openReadmePanel = useReadmePanelStore((s) => s.openReadmePanel) const { canDeletePlugin, canUpdatePlugin } = usePluginSettingsAccess() const detailHeaderState = usePluginDetailHeader(detail) const { @@ -60,11 +55,7 @@ const DataSourcePluginActions = ({ isFromGitHub, isFromMarketplace, } = detailHeaderState - const { - handleUpdate, - handleUpdatedFromMarketplace, - handleDelete, - } = usePluginOperations({ + const { handleUpdate, handleUpdatedFromMarketplace, handleDelete } = usePluginOperations({ detail, modalStates, versionPicker, @@ -75,7 +66,11 @@ const DataSourcePluginActions = ({ }) const displayVersion = isFromGitHub ? (detail.meta?.version ?? detail.version) : detail.version - const handleVersionSelect = (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => { + const handleVersionSelect = (state: { + version: string + unique_identifier: string + isDowngrade?: boolean + }) => { versionPicker.setTargetVersion(state) handleUpdate(state.isDowngrade) } @@ -97,7 +92,7 @@ const DataSourcePluginActions = ({ } return ( - <div className="flex shrink-0 items-center gap-1" onClick={e => e.stopPropagation()}> + <div className="flex shrink-0 items-center gap-1" onClick={(e) => e.stopPropagation()}> {!!displayVersion && ( <PluginVersionPicker disabled={!canUpdatePlugin || !isFromMarketplace} @@ -106,38 +101,43 @@ const DataSourcePluginActions = ({ pluginID={detail.plugin_id} currentVersion={detail.version} onSelect={handleVersionSelect} - trigger={( + trigger={ <Badge className="h-5 px-1.5" - text={( + text={ <> <div>{displayVersion}</div> - {canUpdatePlugin && isFromMarketplace && <span aria-hidden className="ml-1 i-ri-arrow-left-right-line h-3 w-3 shrink-0 text-text-tertiary" />} + {canUpdatePlugin && isFromMarketplace && ( + <span + aria-hidden + className="ml-1 i-ri-arrow-left-right-line h-3 w-3 shrink-0 text-text-tertiary" + /> + )} </> - )} + } hasRedCornerMark={hasNewVersion} uppercase={false} /> - )} + } /> )} {canUpdatePlugin && (hasNewVersion || isFromGitHub) && ( <Tooltip> <TooltipTrigger delay={300} - render={( + render={ <Button variant="secondary-accent" size="small" className="h-5 rounded-md px-1.5 py-0 system-xs-medium" onClick={handleTriggerLatestUpdate} > - {t($ => $['detailPanel.operation.update'], { ns: 'plugin' })} + {t(($) => $['detailPanel.operation.update'], { ns: 'plugin' })} </Button> - )} + } /> <TooltipContent> - {t($ => $['detailPanel.operation.updateTooltip'], { ns: 'plugin' })} + {t(($) => $['detailPanel.operation.updateTooltip'], { ns: 'plugin' })} </TooltipContent> </Tooltip> )} diff --git a/web/app/components/header/account-setting/data-source-page-new/types.ts b/web/app/components/header/account-setting/data-source-page-new/types.ts index ce34498cf09b2e..d24a15f01afe2e 100644 --- a/web/app/components/header/account-setting/data-source-page-new/types.ts +++ b/web/app/components/header/account-setting/data-source-page-new/types.ts @@ -1,7 +1,4 @@ -import type { - FormSchema, - TypeWithI18N, -} from '@/app/components/base/form/types' +import type { FormSchema, TypeWithI18N } from '@/app/components/base/form/types' import type { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' export type DataSourceCredential = { diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index fa5339c5e634f3..a0f7ac8b386b3d 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -9,9 +9,7 @@ import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import BillingPage from '@/app/components/billing/billing-page' import CustomPage from '@/app/components/custom/custom-page' -import { - ACCOUNT_SETTING_TAB, -} from '@/app/components/header/account-setting/constants' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import MenuDialog from '@/app/components/header/account-setting/menu-dialog' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useProviderContext } from '@/context/provider-context' @@ -57,14 +55,21 @@ export default function AccountSetting({ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled - const canManageWorkspaceRoles = isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage') - const canViewBilling = enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View) + const canManageWorkspaceRoles = + isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage') + const canViewBilling = + enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View) // Keep legacy `language` deep links opening Preferences during the tab rename migration. - const normalizedActiveTab = activeTab === ACCOUNT_SETTING_TAB.LANGUAGE ? ACCOUNT_SETTING_TAB.PREFERENCES : activeTab + const normalizedActiveTab = + activeTab === ACCOUNT_SETTING_TAB.LANGUAGE ? ACCOUNT_SETTING_TAB.PREFERENCES : activeTab const activeMenu = (() => { if (normalizedActiveTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling) return ACCOUNT_SETTING_TAB.PREFERENCES - if ((normalizedActiveTab === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS || normalizedActiveTab === ACCOUNT_SETTING_TAB.PERMISSION_SET) && !canManageWorkspaceRoles) + if ( + (normalizedActiveTab === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS || + normalizedActiveTab === ACCOUNT_SETTING_TAB.PERMISSION_SET) && + !canManageWorkspaceRoles + ) return ACCOUNT_SETTING_TAB.MEMBERS return normalizedActiveTab })() @@ -73,63 +78,63 @@ export default function AccountSetting({ const settingItems: GroupItem[] = [ { key: ACCOUNT_SETTING_TAB.PROVIDER, - name: t($ => $['settings.provider'], { ns: 'common' }), + name: t(($) => $['settings.provider'], { ns: 'common' }), icon: <span className={cn('i-ri-brain-2-line', iconClassName)} />, activeIcon: <span className={cn('i-ri-brain-2-fill', iconClassName)} />, }, { key: ACCOUNT_SETTING_TAB.MEMBERS, - name: t($ => $['settings.members'], { ns: 'common' }), + name: t(($) => $['settings.members'], { ns: 'common' }), icon: <span className={cn('i-ri-group-2-line', iconClassName)} />, activeIcon: <span className={cn('i-ri-group-2-fill', iconClassName)} />, }, { key: ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS, - name: t($ => $['settings.rolesAndPermissions'], { ns: 'common' }), + name: t(($) => $['settings.rolesAndPermissions'], { ns: 'common' }), icon: <span className={cn('i-ri-shield-user-line', iconClassName)} />, activeIcon: <span className={cn('i-ri-shield-user-fill', iconClassName)} />, }, { key: ACCOUNT_SETTING_TAB.PERMISSION_SET, - name: t($ => $['settings.permissionSet'], { ns: 'common' }), - description: t($ => $['settings.permissionSetDescription'], { ns: 'common' }), + name: t(($) => $['settings.permissionSet'], { ns: 'common' }), + description: t(($) => $['settings.permissionSetDescription'], { ns: 'common' }), icon: <span className={cn('i-ri-lock-2-line', iconClassName)} />, activeIcon: <span className={cn('i-ri-lock-2-fill', iconClassName)} />, }, { key: ACCOUNT_SETTING_TAB.BILLING, - name: t($ => $['settings.billing'], { ns: 'common' }), - description: t($ => $['plansCommon.receiptInfo'], { ns: 'billing' }), + name: t(($) => $['settings.billing'], { ns: 'common' }), + description: t(($) => $['plansCommon.receiptInfo'], { ns: 'billing' }), icon: <span className={cn('i-ri-money-dollar-circle-line', iconClassName)} />, activeIcon: <span className={cn('i-ri-money-dollar-circle-fill', iconClassName)} />, }, { key: ACCOUNT_SETTING_TAB.DATA_SOURCE, - name: t($ => $['settings.dataSource'], { ns: 'common' }), + name: t(($) => $['settings.dataSource'], { ns: 'common' }), icon: <span className={cn('i-ri-database-2-line', iconClassName)} />, activeIcon: <span className={cn('i-ri-database-2-fill', iconClassName)} />, }, { key: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION, - name: t($ => $['settings.customEndpoint'], { ns: 'common' }), + name: t(($) => $['settings.customEndpoint'], { ns: 'common' }), icon: <span className={cn('i-ri-puzzle-2-line', iconClassName)} />, activeIcon: <span className={cn('i-ri-puzzle-2-fill', iconClassName)} />, }, { key: ACCOUNT_SETTING_TAB.CUSTOM, - name: t($ => $.custom, { ns: 'custom' }), + name: t(($) => $.custom, { ns: 'custom' }), icon: <span className={cn('i-ri-color-filter-line', iconClassName)} />, activeIcon: <span className={cn('i-ri-color-filter-fill', iconClassName)} />, }, { key: ACCOUNT_SETTING_TAB.PREFERENCES, - name: t($ => $['settings.preferences'], { ns: 'common' }), - title: t($ => $['account.general'], { ns: 'common' }), + name: t(($) => $['settings.preferences'], { ns: 'common' }), + title: t(($) => $['account.general'], { ns: 'common' }), icon: <span className={cn('i-ri-equalizer-2-line', iconClassName)} />, activeIcon: <span className={cn('i-ri-equalizer-2-fill', iconClassName)} />, }, ] - const activeItem = settingItems.find(item => item.key === activeMenu) + const activeItem = settingItems.find((item) => item.key === activeMenu) const visibleSettingItems: GroupItem[] = (() => { const visibleTabs: AccountSettingTab[] = [] @@ -141,25 +146,23 @@ export default function AccountSetting({ visibleTabs.push(ACCOUNT_SETTING_TAB.PERMISSION_SET) } - if (canViewBilling) - visibleTabs.push(ACCOUNT_SETTING_TAB.BILLING) + if (canViewBilling) visibleTabs.push(ACCOUNT_SETTING_TAB.BILLING) - if (enableReplaceWebAppLogo || enableBilling) - visibleTabs.push(ACCOUNT_SETTING_TAB.CUSTOM) + if (enableReplaceWebAppLogo || enableBilling) visibleTabs.push(ACCOUNT_SETTING_TAB.CUSTOM) return visibleTabs - .map(tab => settingItems.find(item => item.key === tab)) + .map((tab) => settingItems.find((item) => item.key === tab)) .filter((item): item is GroupItem => Boolean(item)) })() const media = useBreakpoints() const isMobile = media === MediaType.mobile - const preferenceItem = settingItems.find(item => item.key === ACCOUNT_SETTING_TAB.PREFERENCES) + const preferenceItem = settingItems.find((item) => item.key === ACCOUNT_SETTING_TAB.PREFERENCES) const menuItems = [ { key: 'workspace-group', - name: t($ => $['settings.workspace'], { ns: 'common' }), + name: t(($) => $['settings.workspace'], { ns: 'common' }), items: visibleSettingItems, }, { @@ -170,12 +173,14 @@ export default function AccountSetting({ const [searchValue, setSearchValue] = useState<string>('') - const handleTabChange = useCallback((tab: AccountSettingTab) => { - if (tab === ACCOUNT_SETTING_TAB.PROVIDER) - resetModelProviderListExpanded() + const handleTabChange = useCallback( + (tab: AccountSettingTab) => { + if (tab === ACCOUNT_SETTING_TAB.PROVIDER) resetModelProviderListExpanded() - onTabChangeAction(tab) - }, [onTabChangeAction, resetModelProviderListExpanded]) + onTabChangeAction(tab) + }, + [onTabChangeAction, resetModelProviderListExpanded], + ) const handleClose = useCallback(() => { resetModelProviderListExpanded() @@ -183,47 +188,51 @@ export default function AccountSetting({ }, [onCancelAction, resetModelProviderListExpanded]) return ( - <MenuDialog - show - onClose={handleClose} - > + <MenuDialog show onClose={handleClose}> <div className="flex h-screen w-full max-w-full pl-0 sm:pl-[232px]"> <div className="flex w-[44px] shrink-0 flex-col pr-6 pl-4 sm:w-[224px]"> - <div className="mt-6 mb-8 flex h-[38px] items-center px-3 title-2xl-semi-bold whitespace-nowrap text-text-primary">{t($ => $['settings.settings'], { ns: 'common' })}</div> + <div className="mt-6 mb-8 flex h-[38px] items-center px-3 title-2xl-semi-bold whitespace-nowrap text-text-primary"> + {t(($) => $['settings.settings'], { ns: 'common' })} + </div> <div className="w-full"> - { - menuItems.map(menuItem => ( - <div key={menuItem.key} className={cn(menuItem.key === 'workspace-group' ? 'mb-2' : 'mt-2')}> - {menuItem.name && !isMobile && ( - <div className="flex h-7 items-center px-3 system-xs-medium-uppercase text-text-tertiary"> - {menuItem.name} - </div> - )} - <div className={cn(menuItem.key === 'user-group' && 'border-t border-divider-subtle pt-3')}> - { - menuItem.items.map(item => ( - <button - type="button" - key={item.key} - className={cn( - 'mb-0.5 flex h-8 w-full items-center rounded-lg px-3 text-left text-sm', - activeMenu === item.key ? 'bg-state-base-active system-sm-semibold text-components-menu-item-text-active' : 'system-sm-medium text-components-menu-item-text', - )} - aria-label={item.name} - title={item.name} - onClick={() => { - handleTabChange(item.key) - }} - > - {activeMenu === item.key ? item.activeIcon : item.icon} - {!isMobile && <div className="truncate">{item.name}</div>} - </button> - )) - } + {menuItems.map((menuItem) => ( + <div + key={menuItem.key} + className={cn(menuItem.key === 'workspace-group' ? 'mb-2' : 'mt-2')} + > + {menuItem.name && !isMobile && ( + <div className="flex h-7 items-center px-3 system-xs-medium-uppercase text-text-tertiary"> + {menuItem.name} </div> + )} + <div + className={cn( + menuItem.key === 'user-group' && 'border-t border-divider-subtle pt-3', + )} + > + {menuItem.items.map((item) => ( + <button + type="button" + key={item.key} + className={cn( + 'mb-0.5 flex h-8 w-full items-center rounded-lg px-3 text-left text-sm', + activeMenu === item.key + ? 'bg-state-base-active system-sm-semibold text-components-menu-item-text-active' + : 'system-sm-medium text-components-menu-item-text', + )} + aria-label={item.name} + title={item.name} + onClick={() => { + handleTabChange(item.key) + }} + > + {activeMenu === item.key ? item.activeIcon : item.icon} + {!isMobile && <div className="truncate">{item.name}</div>} + </button> + ))} </div> - )) - } + </div> + ))} </div> </div> <div className="relative flex min-h-0 w-[824px]"> @@ -239,7 +248,9 @@ export default function AccountSetting({ <div className="min-w-0 flex-1 title-2xl-semi-bold text-text-primary"> {activeItem?.title ?? activeItem?.name} {activeItem?.description && ( - <div className="mt-1 system-sm-regular wrap-break-word whitespace-normal text-text-tertiary">{activeItem?.description}</div> + <div className="mt-1 system-sm-regular wrap-break-word whitespace-normal text-text-tertiary"> + {activeItem?.description} + </div> )} </div> <div className="fixed top-6 right-6 flex shrink-0 flex-col items-center"> @@ -247,7 +258,7 @@ export default function AccountSetting({ variant="tertiary" size="large" className="px-2" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={handleClose} > <span className="i-ri-close-line size-5" /> @@ -257,13 +268,12 @@ export default function AccountSetting({ </div> <div className="px-4 pt-6 sm:px-8"> {activeMenu === ACCOUNT_SETTING_TAB.PROVIDER && ( - <ModelProviderPage - searchText={searchValue} - onSearchTextChange={setSearchValue} - /> + <ModelProviderPage searchText={searchValue} onSearchTextChange={setSearchValue} /> )} {activeMenu === ACCOUNT_SETTING_TAB.MEMBERS && <MembersPage />} - {activeMenu === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS && <PermissionsPage containerRef={scrollContainerRef} />} + {activeMenu === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS && ( + <PermissionsPage containerRef={scrollContainerRef} /> + )} {activeMenu === ACCOUNT_SETTING_TAB.PERMISSION_SET && <AccessRulesPage />} {activeMenu === ACCOUNT_SETTING_TAB.BILLING && <BillingPage />} {activeMenu === ACCOUNT_SETTING_TAB.DATA_SOURCE && <DataSourcePage />} diff --git a/web/app/components/header/account-setting/key-validator/ValidateStatus.tsx b/web/app/components/header/account-setting/key-validator/ValidateStatus.tsx index 94fd5f9f411995..be9bea52fda13f 100644 --- a/web/app/components/header/account-setting/key-validator/ValidateStatus.tsx +++ b/web/app/components/header/account-setting/key-validator/ValidateStatus.tsx @@ -4,7 +4,7 @@ export const ValidatingTip = () => { const { t } = useTranslation() return ( <div className="mt-2 text-xs font-normal text-primary-600"> - {t($ => $['provider.validating'], { ns: 'common' })} + {t(($) => $['provider.validating'], { ns: 'common' })} </div> ) } diff --git a/web/app/components/header/account-setting/key-validator/__tests__/ValidateStatus.spec.tsx b/web/app/components/header/account-setting/key-validator/__tests__/ValidateStatus.spec.tsx index 5bc87504d74735..d35414ee0589d0 100644 --- a/web/app/components/header/account-setting/key-validator/__tests__/ValidateStatus.spec.tsx +++ b/web/app/components/header/account-setting/key-validator/__tests__/ValidateStatus.spec.tsx @@ -1,7 +1,5 @@ import { render, screen } from '@testing-library/react' -import { - ValidatingTip, -} from '../ValidateStatus' +import { ValidatingTip } from '../ValidateStatus' describe('ValidateStatus', () => { beforeEach(() => { 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 55ebf8b4efc3ad..85e85812a13c2f 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 @@ -39,7 +39,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('@/context/provider-context') @@ -47,13 +48,15 @@ vi.mock('@/hooks/use-format-time-from-now') vi.mock('@/service/access-control/use-member-roles') vi.mock('@/service/use-common') -const renderMembersPage = () => renderWithSystemFeatures(<MembersPage />, { - systemFeatures: { is_email_setup: true }, -}) +const renderMembersPage = () => + renderWithSystemFeatures(<MembersPage />, { + systemFeatures: { is_email_setup: true }, + }) -const getMemberDetailsButton = (memberId: string) => within(screen.getByTestId(`member-row-${memberId}`)).getByRole('button', { - name: /members\.memberDetails\.openAria/i, -}) +const getMemberDetailsButton = (memberId: string) => + within(screen.getByTestId(`member-row-${memberId}`)).getByRole('button', { + name: /members\.memberDetails\.openAria/i, + }) const createRole = (overrides: Partial<Role>): Role => ({ id: 'role-1', @@ -82,16 +85,30 @@ vi.mock('../edit-workspace-modal', () => ({ ), })) vi.mock('../invite-button', () => ({ - default: ({ onClick, disabled }: { onClick: () => void, disabled: boolean }) => ( - <button onClick={onClick} disabled={disabled}>Invite</button> + default: ({ onClick, disabled }: { onClick: () => void; disabled: boolean }) => ( + <button onClick={onClick} disabled={disabled}> + Invite + </button> ), })) vi.mock('../invite-modal', () => ({ - default: ({ onCancel, onSend }: { onCancel: () => void, onSend: (results: Array<{ email: string, status: 'success', url: string }>) => void }) => ( + default: ({ + onCancel, + onSend, + }: { + onCancel: () => void + onSend: (results: Array<{ email: string; status: 'success'; url: string }>) => void + }) => ( <div> <div>Invite Modal</div> <button onClick={onCancel}>Close Invite Modal</button> - <button onClick={() => onSend([{ email: 'sent@example.com', status: 'success', url: 'http://invite/link' }])}>Send Invite Results</button> + <button + onClick={() => + onSend([{ email: 'sent@example.com', status: 'success', url: 'http://invite/link' }]) + } + > + Send Invite Results + </button> </div> ), })) @@ -121,9 +138,7 @@ vi.mock('../member-menu', () => ({ canTransferOwnership?: boolean }) => ( <div data-testid="member-menu"> - {member.role !== 'owner' && !isCurrentUser && ( - <div>{`Member Operation ${member.role}`}</div> - )} + {member.role !== 'owner' && !isCurrentUser && <div>{`Member Operation ${member.role}`}</div>} {canTransferOwnership && member.role === 'owner' && onTransferOwnership && ( <button onClick={(e) => { @@ -161,10 +176,13 @@ vi.mock('../member-details-modal', () => ({ <div>Member Details Modal</div> <div data-testid="details-member-name">{member.name}</div> <div data-testid="details-can-assign">{String(canAssignRoles)}</div> - <button onClick={() => onAssignSubmit?.([ - createRole({ id: 'role-next', name: 'Next role' }), - createRole({ id: 'role-extra', name: 'Extra role' }), - ])} + <button + onClick={() => + onAssignSubmit?.([ + createRole({ id: 'role-next', name: 'Next role' }), + createRole({ id: 'role-extra', name: 'Extra role' }), + ]) + } > Submit Member Roles </button> @@ -233,10 +251,12 @@ describe('MembersPage', () => { mutateAsync: mockUpdateRolesOfMember, } as unknown as ReturnType<typeof useUpdateRolesOfMember>) - vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ - enableBilling: false, - isAllowTransferWorkspace: true, - })) + vi.mocked(useProviderContext).mockReturnValue( + createMockProviderContextValue({ + enableBilling: false, + isAllowTransferWorkspace: true, + }), + ) vi.mocked(useFormatTimeFromNow).mockReturnValue({ formatTimeFromNow: mockFormatTimeFromNow, @@ -254,8 +274,12 @@ describe('MembersPage', () => { it('should render fixed name column and flexible role column layout', () => { renderMembersPage() - expect(screen.getByText('common.members.name', { selector: '.system-xs-medium-uppercase' }))!.toHaveClass('w-65', 'shrink-0') - expect(screen.getByText('common.members.role', { selector: '.system-xs-medium-uppercase' }))!.toHaveClass('min-w-0', 'grow') + expect( + screen.getByText('common.members.name', { selector: '.system-xs-medium-uppercase' }), + )!.toHaveClass('w-65', 'shrink-0') + expect( + screen.getByText('common.members.role', { selector: '.system-xs-medium-uppercase' }), + )!.toHaveClass('min-w-0', 'grow') expect(getMemberDetailsButton('1').children[0])!.toHaveClass('w-65', 'shrink-0') expect(getMemberDetailsButton('1').children[2])!.toHaveClass('min-w-0', 'grow') }) @@ -268,8 +292,12 @@ describe('MembersPage', () => { }, }) - expect(screen.getByText('common.members.roles', { selector: '.system-xs-medium-uppercase' }))!.toHaveClass('min-w-0', 'grow') - expect(screen.queryByText('common.members.role', { selector: '.system-xs-medium-uppercase' })).not.toBeInTheDocument() + expect( + screen.getByText('common.members.roles', { selector: '.system-xs-medium-uppercase' }), + )!.toHaveClass('min-w-0', 'grow') + expect( + screen.queryByText('common.members.role', { selector: '.system-xs-medium-uppercase' }), + ).not.toBeInTheDocument() }) it('should open and close invite modal', async () => { @@ -309,10 +337,12 @@ describe('MembersPage', () => { }) it('should show non-interactive owner role when transfer ownership is not allowed', () => { - vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ - enableBilling: false, - isAllowTransferWorkspace: false, - })) + vi.mocked(useProviderContext).mockReturnValue( + createMockProviderContextValue({ + enableBilling: false, + isAllowTransferWorkspace: false, + }), + ) renderMembersPage() @@ -375,13 +405,17 @@ describe('MembersPage', () => { }) it('should show billing information for limited plan', () => { - vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ - enableBilling: true, - plan: { - type: Plan.sandbox, - total: { teamMembers: 5 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'], - } as unknown as ReturnType<typeof useProviderContext>['plan'], - })) + vi.mocked(useProviderContext).mockReturnValue( + createMockProviderContextValue({ + enableBilling: true, + plan: { + type: Plan.sandbox, + total: { teamMembers: 5 } as unknown as ReturnType< + typeof useProviderContext + >['plan']['total'], + } as unknown as ReturnType<typeof useProviderContext>['plan'], + }), + ) renderMembersPage() @@ -392,13 +426,17 @@ describe('MembersPage', () => { }) it('should show unlimited billing information', () => { - vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ - enableBilling: true, - plan: { - type: Plan.sandbox, - total: { teamMembers: -1 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'], - } as unknown as ReturnType<typeof useProviderContext>['plan'], - })) + vi.mocked(useProviderContext).mockReturnValue( + createMockProviderContextValue({ + enableBilling: true, + plan: { + type: Plan.sandbox, + total: { teamMembers: -1 } as unknown as ReturnType< + typeof useProviderContext + >['plan']['total'], + } as unknown as ReturnType<typeof useProviderContext>['plan'], + }), + ) renderMembersPage() @@ -406,13 +444,17 @@ describe('MembersPage', () => { }) it('should show non-billing member format for team plan even when billing is enabled', () => { - vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ - enableBilling: true, - plan: { - type: Plan.team, - total: { teamMembers: 50 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'], - } as unknown as ReturnType<typeof useProviderContext>['plan'], - })) + vi.mocked(useProviderContext).mockReturnValue( + createMockProviderContextValue({ + enableBilling: true, + plan: { + type: Plan.team, + total: { teamMembers: 50 } as unknown as ReturnType< + typeof useProviderContext + >['plan']['total'], + } as unknown as ReturnType<typeof useProviderContext>['plan'], + }), + ) renderMembersPage() @@ -449,10 +491,34 @@ describe('MembersPage', () => { accounts: [ mockAccounts[0], mockAccounts[1], - { ...mockAccounts[1]!, id: '3', email: 'editor@example.com', name: 'Editor User', role: 'editor' }, - { ...mockAccounts[1]!, id: '4', email: 'normal@example.com', name: 'Normal User', role: 'normal' }, - { ...mockAccounts[1]!, id: '5', email: 'dataset@example.com', name: 'Dataset User', role: 'dataset_operator' }, - { ...mockAccounts[1]!, id: '6', email: 'other-admin@example.com', name: 'Other Admin User', role: 'admin' }, + { + ...mockAccounts[1]!, + id: '3', + email: 'editor@example.com', + name: 'Editor User', + role: 'editor', + }, + { + ...mockAccounts[1]!, + id: '4', + email: 'normal@example.com', + name: 'Normal User', + role: 'normal', + }, + { + ...mockAccounts[1]!, + id: '5', + email: 'dataset@example.com', + name: 'Dataset User', + role: 'dataset_operator', + }, + { + ...mockAccounts[1]!, + id: '6', + email: 'other-admin@example.com', + name: 'Other Admin User', + role: 'admin', + }, ], }, refetch: mockRefetch, @@ -488,13 +554,17 @@ describe('MembersPage', () => { data: { accounts: [mockAccounts[0]] }, refetch: mockRefetch, } as unknown as ReturnType<typeof useMembers>) - vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ - enableBilling: true, - plan: { - type: Plan.sandbox, - total: { teamMembers: 5 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'], - } as unknown as ReturnType<typeof useProviderContext>['plan'], - })) + vi.mocked(useProviderContext).mockReturnValue( + createMockProviderContextValue({ + enableBilling: true, + plan: { + type: Plan.sandbox, + total: { teamMembers: 5 } as unknown as ReturnType< + typeof useProviderContext + >['plan']['total'], + } as unknown as ReturnType<typeof useProviderContext>['plan'], + }), + ) renderMembersPage() @@ -541,7 +611,10 @@ describe('MembersPage', () => { expect(row).not.toHaveAttribute('role', 'button') expect(row).not.toHaveClass('hover:bg-state-base-hover') expect(detailsButton).toHaveAttribute('type', 'button') - expect(detailsButton).toHaveClass('hover:bg-state-base-hover', 'focus-visible:bg-state-base-hover') + expect(detailsButton).toHaveClass( + 'hover:bg-state-base-hover', + 'focus-visible:bg-state-base-hover', + ) expect(detailsButton).not.toContainElement(memberMenu) }) @@ -606,10 +679,13 @@ describe('MembersPage', () => { await user.click(getMemberDetailsButton('2')) await user.click(screen.getByRole('button', { name: 'Submit Member Roles' })) - expect(mockUpdateRolesOfMember).toHaveBeenCalledWith({ - memberId: '2', - roleIds: ['role-next'], - }, expect.any(Object)) + expect(mockUpdateRolesOfMember).toHaveBeenCalledWith( + { + memberId: '2', + roleIds: ['role-next'], + }, + expect.any(Object), + ) expect(mockRefetch).toHaveBeenCalled() expect(screen.getByText('Member Details Modal')).toBeInTheDocument() expect(screen.getByTestId('details-member-name')).toHaveTextContent('Admin User') @@ -628,10 +704,13 @@ describe('MembersPage', () => { await user.click(getMemberDetailsButton('2')) await user.click(screen.getByRole('button', { name: 'Submit Member Roles' })) - expect(mockUpdateRolesOfMember).toHaveBeenCalledWith({ - memberId: '2', - roleIds: ['role-next', 'role-extra'], - }, expect.any(Object)) + expect(mockUpdateRolesOfMember).toHaveBeenCalledWith( + { + memberId: '2', + roleIds: ['role-next', 'role-extra'], + }, + expect.any(Object), + ) }) it('should not open member details when clicking the member menu area', async () => { @@ -645,13 +724,17 @@ describe('MembersPage', () => { }) it('should show upgrade button when member limit is full', () => { - vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ - enableBilling: true, - plan: { - type: Plan.sandbox, - total: { teamMembers: 2 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'], - } as unknown as ReturnType<typeof useProviderContext>['plan'], - })) + vi.mocked(useProviderContext).mockReturnValue( + createMockProviderContextValue({ + enableBilling: true, + plan: { + type: Plan.sandbox, + total: { teamMembers: 2 } as unknown as ReturnType< + typeof useProviderContext + >['plan']['total'], + } as unknown as ReturnType<typeof useProviderContext>['plan'], + }), + ) renderMembersPage() 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 384aeeb5b2ebcc..72683cb6ce10ce 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 @@ -39,7 +39,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { })) }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('@/service/use-workspace') diff --git a/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx index bd59735ea9f50e..b0160dc7530355 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx @@ -57,17 +57,18 @@ const member: Member = { describe('MemberMenu', () => { const mockUpdateRolesOfMember = vi.fn() - const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: Infinity, - }, - mutations: { - retry: false, + const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + mutations: { + retry: false, + }, }, - }, - }) + }) beforeEach(() => { vi.clearAllMocks() @@ -78,15 +79,17 @@ describe('MemberMenu', () => { } as unknown as ReturnType<typeof useUpdateRolesOfMember>) vi.mocked(useWorkspaceRoleList).mockReturnValue({ data: { - pages: [{ - data: roles, - pagination: { - total_count: 2, - per_page: 20, - current_page: 1, - total_pages: 1, + pages: [ + { + data: roles, + pagination: { + total_count: 2, + per_page: 20, + current_page: 1, + total_pages: 1, + }, }, - }], + ], pageParams: [1], }, isLoading: false, @@ -101,11 +104,7 @@ describe('MemberMenu', () => { const user = userEvent.setup() renderWithSystemFeatures( - <MemberMenu - member={member} - isCurrentUser={false} - allowMultipleRoles={false} - />, + <MemberMenu member={member} isCurrentUser={false} allowMultipleRoles={false} />, { systemFeatures: { rbac_enabled: false, @@ -116,7 +115,9 @@ describe('MemberMenu', () => { await user.click(screen.getByRole('button', { name: /members\.memberActions/i })) expect(screen.getByRole('menuitem', { name: /common\.members\.editRole/i })).toBeInTheDocument() - expect(screen.queryByRole('menuitem', { name: /common\.members\.assignRoles/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('menuitem', { name: /common\.members\.assignRoles/i }), + ).not.toBeInTheDocument() await user.click(screen.getByRole('menuitem', { name: /common\.members\.editRole/i })) @@ -127,11 +128,7 @@ describe('MemberMenu', () => { const user = userEvent.setup() renderWithSystemFeatures( - <MemberMenu - member={member} - isCurrentUser={false} - allowMultipleRoles={false} - />, + <MemberMenu member={member} isCurrentUser={false} allowMultipleRoles={false} />, { systemFeatures: { rbac_enabled: false, @@ -144,10 +141,13 @@ describe('MemberMenu', () => { await user.click(screen.getByRole('radio', { name: /Second role/i })) await user.click(screen.getByRole('button', { name: /common\.operation\.confirm/i })) - expect(mockUpdateRolesOfMember).toHaveBeenCalledWith({ - memberId: 'member-1', - roleIds: ['role-2'], - }, expect.any(Object)) + expect(mockUpdateRolesOfMember).toHaveBeenCalledWith( + { + memberId: 'member-1', + roleIds: ['role-2'], + }, + expect.any(Object), + ) }) it('should require confirmation before removing a member', async () => { @@ -156,15 +156,9 @@ describe('MemberMenu', () => { const membersQueryKey = [...commonQueryKeys.members, 'en-US'] queryClient.setQueryData(membersQueryKey, { accounts: [member] }) - renderWithSystemFeatures( - <MemberMenu - member={member} - isCurrentUser={false} - />, - { - queryClient, - }, - ) + renderWithSystemFeatures(<MemberMenu member={member} isCurrentUser={false} />, { + queryClient, + }) await user.click(screen.getByRole('button', { name: /members\.memberActions/i })) await user.click(screen.getByRole('menuitem', { name: /members\.removeFromTeam/i })) diff --git a/web/app/components/header/account-setting/members-page/__tests__/role-badges.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/role-badges.spec.tsx index 809bf1247a1cd1..20d6a97da60d65 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/role-badges.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/role-badges.spec.tsx @@ -6,7 +6,9 @@ describe('RoleBadges', () => { it('should expose visible role names as badge titles', () => { render(<RoleBadges roleNames={['Very long custom role name', 'Admin']} />) - expect(screen.getByTitle('Very long custom role name'))!.toHaveTextContent('Very long custom role name') + expect(screen.getByTitle('Very long custom role name'))!.toHaveTextContent( + 'Very long custom role name', + ) expect(screen.getByTitle('Admin'))!.toHaveTextContent('Admin') }) diff --git a/web/app/components/header/account-setting/members-page/assign-roles-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/assign-roles-modal/__tests__/index.spec.tsx index 74d11921270da9..135d32ffce4f65 100644 --- a/web/app/components/header/account-setting/members-page/assign-roles-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/assign-roles-modal/__tests__/index.spec.tsx @@ -29,15 +29,17 @@ describe('AssignRolesModal', () => { vi.clearAllMocks() vi.mocked(useWorkspaceRoleList).mockReturnValue({ data: { - pages: [{ - data: roles, - pagination: { - total_count: 2, - per_page: 20, - current_page: 1, - total_pages: 1, + pages: [ + { + data: roles, + pagination: { + total_count: 2, + per_page: 20, + current_page: 1, + total_pages: 1, + }, }, - }], + ], pageParams: [1], }, isLoading: false, @@ -59,7 +61,9 @@ describe('AssignRolesModal', () => { />, ) - expect(screen.queryByText(/common\.members\.assignRolesModal\.selectedCount/i)).not.toBeInTheDocument() + expect( + screen.queryByText(/common\.members\.assignRolesModal\.selectedCount/i), + ).not.toBeInTheDocument() }) it('should show single-role description when multiple roles are disabled', () => { @@ -72,20 +76,18 @@ describe('AssignRolesModal', () => { />, ) - expect(screen.getByText(/common\.members\.assignRolesModal\.singleDescription/i)).toBeInTheDocument() - expect(screen.queryByText(/common\.members\.assignRolesModal\.description/i)).not.toBeInTheDocument() + expect( + screen.getByText(/common\.members\.assignRolesModal\.singleDescription/i), + ).toBeInTheDocument() + expect( + screen.queryByText(/common\.members\.assignRolesModal\.description/i), + ).not.toBeInTheDocument() }) it('should disable confirm when the last selected role is unchecked', async () => { const user = userEvent.setup() - render( - <AssignRolesModal - selectedRoles={[roles[0]!]} - onClose={vi.fn()} - onSubmit={vi.fn()} - />, - ) + render(<AssignRolesModal selectedRoles={[roles[0]!]} onClose={vi.fn()} onSubmit={vi.fn()} />) const confirmButton = screen.getByRole('button', { name: /common\.operation\.confirm/i }) diff --git a/web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx b/web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx index aa6dd69ff3eb17..461f728c5872c0 100644 --- a/web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx @@ -30,25 +30,24 @@ const AssignRolesModalBody = ({ }: AssignRolesModalBodyProps) => { const { t } = useTranslation() const [selected, setSelected] = useState(selectedRoles) - const selectedRoleIds = selected.map(role => role.id) + const selectedRoleIds = selected.map((role) => role.id) const isConfirmDisabled = selected.length === 0 const title = allowMultipleRoles - ? t($ => $['members.assignRolesModal.title'], { ns: 'common', defaultValue: 'Assign Roles' }) - : t($ => $['members.editRole'], { ns: 'common', defaultValue: 'Edit Role' }) + ? t(($) => $['members.assignRolesModal.title'], { ns: 'common', defaultValue: 'Assign Roles' }) + : t(($) => $['members.editRole'], { ns: 'common', defaultValue: 'Edit Role' }) const description = allowMultipleRoles - ? t($ => $['members.assignRolesModal.description'], { + ? t(($) => $['members.assignRolesModal.description'], { ns: 'common', defaultValue: 'Select roles to assign to this member. All permissions from selected roles will be combined.', }) - : t($ => $['members.assignRolesModal.singleDescription'], { + : t(($) => $['members.assignRolesModal.singleDescription'], { ns: 'common', defaultValue: 'Select one role to assign to this member.', }) const handleConfirm = () => { - if (isConfirmDisabled) - return + if (isConfirmDisabled) return onSubmit(selected) onClose() @@ -62,9 +61,7 @@ const AssignRolesModalBody = ({ <div className="relative shrink-0 px-6 pt-6 pb-4"> <DialogCloseButton /> <div className="pr-8"> - <DialogTitle className="system-xl-semibold text-text-primary"> - {title} - </DialogTitle> + <DialogTitle className="system-xl-semibold text-text-primary">{title}</DialogTitle> <DialogDescription className="mt-1 system-sm-regular text-text-tertiary"> {description} </DialogDescription> @@ -81,7 +78,7 @@ const AssignRolesModalBody = ({ <div className="flex shrink-0 items-center gap-3 border-t border-divider-subtle px-6 py-4"> {allowMultipleRoles && ( <div className="system-xs-regular text-text-tertiary"> - {t($ => $['members.assignRolesModal.selectedCount'], { + {t(($) => $['members.assignRolesModal.selectedCount'], { ns: 'common', count: selected.length, })} @@ -89,10 +86,10 @@ const AssignRolesModalBody = ({ )} <div className="ml-auto flex items-center gap-2"> <Button variant="secondary" onClick={onClose}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button variant="primary" disabled={isConfirmDisabled} onClick={handleConfirm}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </Button> </div> </div> @@ -110,8 +107,7 @@ const AssignRolesModal = ({ <Dialog open onOpenChange={(nextOpen) => { - if (!nextOpen) - onClose() + if (!nextOpen) onClose() }} > <AssignRolesModalBody 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 dd232b1b87d6b3..7ef065ad7b39c5 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 @@ -21,10 +21,10 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({ return <div data-testid="dialog">{children}</div> }, DialogCloseButton: ({ ...props }: Record<string, unknown>) => <button {...props} />, - DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => ( + DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => ( <div className={className}>{children}</div> ), - DialogTitle: ({ children, className }: { children: ReactNode, className?: string }) => ( + DialogTitle: ({ children, className }: { children: ReactNode; className?: string }) => ( <div className={className}>{children}</div> ), })) @@ -51,7 +51,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) 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 ec299c4580f009..080700b20818ad 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 @@ -37,7 +37,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('@/service/common') @@ -72,11 +73,12 @@ describe('EditWorkspaceModal', () => { vi.unstubAllGlobals() }) - const renderModal = () => render( - <> - <EditWorkspaceModal onCancel={mockOnCancel} /> - </>, - ) + const renderModal = () => + render( + <> + <EditWorkspaceModal onCancel={mockOnCancel} /> + </>, + ) it('should show current workspace name in the input', async () => { renderModal() @@ -105,7 +107,11 @@ describe('EditWorkspaceModal', () => { it('should submit update when confirming as owner', async () => { const user = userEvent.setup() const mockAssign = vi.fn() - vi.stubGlobal('location', { ...window.location, assign: mockAssign, origin: 'http://localhost' }) + vi.stubGlobal('location', { + ...window.location, + assign: mockAssign, + origin: 'http://localhost', + }) vi.mocked(updateWorkspaceInfo).mockResolvedValue({} as ICurrentWorkspace) renderModal() @@ -139,9 +145,11 @@ describe('EditWorkspaceModal', () => { await user.click(getSaveButton()) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) }) 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 79b0465cb5256b..04457c9879520b 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 @@ -26,16 +26,14 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { const hasError = normalizedName.length === 0 const isSaveDisabled = !isCurrentWorkspaceOwner || !hasChanges || hasError || isSubmitting const nameErrorMessage = useMemo(() => { - if (!hasError) - return '' - return t($ => $['errorMsg.fieldRequired'], { + if (!hasError) return '' + return t(($) => $['errorMsg.fieldRequired'], { ns: 'common', - field: t($ => $['account.workspaceName'], { ns: 'common' }), + field: t(($) => $['account.workspaceName'], { ns: 'common' }), }) }, [hasError, t]) const changeWorkspaceInfo = async () => { - if (isSaveDisabled) - return + if (isSaveDisabled) return setIsSubmitting(true) try { await updateWorkspaceInfo({ @@ -44,13 +42,11 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { name: normalizedName, }, }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) location.assign(`${location.origin}`) - } - catch { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - } - finally { + } catch { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } finally { setIsSubmitting(false) } } @@ -58,8 +54,7 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DialogContent backdropProps={{ forceRender: true }}> @@ -73,29 +68,40 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { }} > <div className="mb-4 pr-8"> - <DialogTitle className="text-xl font-semibold text-text-primary" data-testid="edit-workspace-title"> - {t($ => $['account.editWorkspaceInfo'], { ns: 'common' })} + <DialogTitle + className="text-xl font-semibold text-text-primary" + data-testid="edit-workspace-title" + > + {t(($) => $['account.editWorkspaceInfo'], { ns: 'common' })} </DialogTitle> </div> <div className="space-y-2"> <label htmlFor={inputId} className="block text-sm font-medium text-text-primary"> - {t($ => $['account.workspaceName'], { ns: 'common' })} + {t(($) => $['account.workspaceName'], { ns: 'common' })} </label> <Input id={inputId} value={name} - placeholder={t($ => $['account.workspaceNamePlaceholder'], { ns: 'common' })} + placeholder={t(($) => $['account.workspaceNamePlaceholder'], { ns: 'common' })} onChange={(e) => { setName(e.target.value) }} aria-invalid={hasError} aria-describedby={hasError ? errorId : undefined} - className={cn(hasError && 'border-components-input-border-destructive bg-components-input-bg-destructive hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive')} + className={cn( + hasError && + 'border-components-input-border-destructive bg-components-input-bg-destructive hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive', + )} /> <div className="min-h-6"> {hasError && ( - <p id={errorId} data-testid="edit-workspace-error" className="system-xs-regular text-text-destructive" role="alert"> + <p + id={errorId} + data-testid="edit-workspace-error" + className="system-xs-regular text-text-destructive" + role="alert" + > {nameErrorMessage} </p> )} @@ -104,10 +110,16 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { <div className="sticky bottom-0 -mx-2 mt-2 flex flex-wrap items-center justify-end gap-x-2 bg-components-panel-bg px-2 pt-4"> <Button size="large" type="button" onClick={onCancel}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button size="large" type="submit" variant="primary" disabled={isSaveDisabled} loading={isSubmitting}> - {t($ => $[isSubmitting ? 'operation.saving' : 'operation.save'], { ns: 'common' })} + <Button + size="large" + type="submit" + variant="primary" + disabled={isSaveDisabled} + loading={isSubmitting} + > + {t(($) => $[isSubmitting ? 'operation.saving' : 'operation.save'], { ns: 'common' })} </Button> </div> </form> 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 d6e5d0b843377a..ebc341c2bbbcda 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -44,16 +44,18 @@ const MembersPage = () => { const [invitedModalVisible, setInvitedModalVisible] = useState(false) const accounts = data?.accounts || [] const { plan, enableBilling, isAllowTransferWorkspace } = useProviderContext() - const isNotUnlimitedMemberPlan = enableBilling && plan.type !== Plan.team && plan.type !== Plan.enterprise - const isMemberFull = enableBilling && isNotUnlimitedMemberPlan && accounts.length >= plan.total.teamMembers + const isNotUnlimitedMemberPlan = + enableBilling && plan.type !== Plan.team && plan.type !== Plan.enterprise + const isMemberFull = + enableBilling && isNotUnlimitedMemberPlan && accounts.length >= plan.total.teamMembers const [editWorkspaceModalVisible, setEditWorkspaceModalVisible] = useState(false) const [showTransferOwnershipModal, setShowTransferOwnershipModal] = useState(false) const [detailsMember, setDetailsMember] = useState<Member | null>(null) const canManageMembers = hasPermission(workspacePermissionKeys, 'workspace.member.manage') const roleColumnLabel = systemFeatures.rbac_enabled - ? t($ => $['members.roles'], { ns: 'common' }) - : t($ => $['members.role'], { ns: 'common' }) + ? t(($) => $['members.roles'], { ns: 'common' }) + : t(($) => $['members.role'], { ns: 'common' }) const handleOpenDetails = useCallback((member: Member) => { setDetailsMember(member) @@ -67,18 +69,21 @@ const MembersPage = () => { const handleAssignRolesSubmit = (roles: Role[]) => { const roleIds = systemFeatures.rbac_enabled - ? roles.map(role => role.id) - : roles.slice(0, 1).map(role => role.id) + ? roles.map((role) => role.id) + : roles.slice(0, 1).map((role) => role.id) - updateRolesOfMember({ - memberId: detailsMember!.id, - roleIds, - }, { - onSuccess: () => { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - refetch() + updateRolesOfMember( + { + memberId: detailsMember!.id, + roleIds, + }, + { + onSuccess: () => { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + refetch() + }, }, - }) + ) } const handleTransferOwnership = useCallback(() => { @@ -101,10 +106,10 @@ const MembersPage = () => { <span> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['account.editWorkspaceInfo'], { ns: 'common' })} + aria-label={t(($) => $['account.editWorkspaceInfo'], { ns: 'common' })} className="cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-black/5" onClick={() => { setEditWorkspaceModalVisible(true) @@ -115,55 +120,62 @@ const MembersPage = () => { className="i-ri-pencil-line size-4 text-text-tertiary" /> </button> - )} + } /> <TooltipContent> - {t($ => $['account.editWorkspaceInfo'], { ns: 'common' })} + {t(($) => $['account.editWorkspaceInfo'], { ns: 'common' })} </TooltipContent> </Tooltip> </span> )} </div> <div className="mt-1 system-xs-medium text-text-tertiary"> - {enableBilling && isNotUnlimitedMemberPlan - ? ( - <div className="flex space-x-1"> - <div> - {t($ => $['plansCommon.member'], { ns: 'billing' })} - {locale !== LanguagesSupported[1] && accounts.length > 1 && 's'} - </div> - <div className="">{accounts.length}</div> - <div>/</div> - <div>{plan.total.teamMembers === NUM_INFINITE ? t($ => $['plansCommon.unlimited'], { ns: 'billing' }) : plan.total.teamMembers}</div> - </div> - ) - : ( - <div className="flex space-x-1"> - <div>{accounts.length}</div> - <div> - {t($ => $['plansCommon.memberAfter'], { ns: 'billing' })} - {locale !== LanguagesSupported[1] && accounts.length > 1 && 's'} - </div> - </div> - )} + {enableBilling && isNotUnlimitedMemberPlan ? ( + <div className="flex space-x-1"> + <div> + {t(($) => $['plansCommon.member'], { ns: 'billing' })} + {locale !== LanguagesSupported[1] && accounts.length > 1 && 's'} + </div> + <div className="">{accounts.length}</div> + <div>/</div> + <div> + {plan.total.teamMembers === NUM_INFINITE + ? t(($) => $['plansCommon.unlimited'], { ns: 'billing' }) + : plan.total.teamMembers} + </div> + </div> + ) : ( + <div className="flex space-x-1"> + <div>{accounts.length}</div> + <div> + {t(($) => $['plansCommon.memberAfter'], { ns: 'billing' })} + {locale !== LanguagesSupported[1] && accounts.length > 1 && 's'} + </div> + </div> + )} </div> - </div> - {isMemberFull && ( - <UpgradeBtn className="mr-2" loc="member-invite" /> - )} + {isMemberFull && <UpgradeBtn className="mr-2" loc="member-invite" />} <div className="shrink-0"> - {canManageMembers && <InviteButton disabled={isMemberFull} onClick={() => setInviteModalVisible(true)} />} + {canManageMembers && ( + <InviteButton disabled={isMemberFull} onClick={() => setInviteModalVisible(true)} /> + )} </div> </div> <div className="overflow-visible lg:overflow-visible"> <div className="flex min-w-120 items-center border-b border-divider-regular py-1.75"> - <div className="w-65 shrink-0 px-3 system-xs-medium-uppercase text-text-tertiary">{t($ => $['members.name'], { ns: 'common' })}</div> - <div className="w-30 shrink-0 system-xs-medium-uppercase text-text-tertiary">{t($ => $['members.lastActive'], { ns: 'common' })}</div> - <div className="min-w-0 grow px-3 system-xs-medium-uppercase text-text-tertiary">{roleColumnLabel}</div> + <div className="w-65 shrink-0 px-3 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['members.name'], { ns: 'common' })} + </div> + <div className="w-30 shrink-0 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['members.lastActive'], { ns: 'common' })} + </div> + <div className="min-w-0 grow px-3 system-xs-medium-uppercase text-text-tertiary"> + {roleColumnLabel} + </div> </div> <div className="relative min-w-120"> - {accounts.map(account => ( + {accounts.map((account) => ( <MemberRow key={account.id} member={account} @@ -179,34 +191,26 @@ const MembersPage = () => { </div> </div> </div> - { - inviteModalVisible && ( - <InviteModal - isEmailSetup={systemFeatures.is_email_setup} - onCancel={() => setInviteModalVisible(false)} - onSend={(invitationResults) => { - setInvitedModalVisible(true) - setInvitationResults(invitationResults) - refetch() - }} - /> - ) - } - { - invitedModalVisible && ( - <InvitedModal - invitationResults={invitationResults} - onCancel={() => setInvitedModalVisible(false)} - /> - ) - } - { - editWorkspaceModalVisible && ( - <EditWorkspaceModal - onCancel={() => setEditWorkspaceModalVisible(false)} - /> - ) - } + {inviteModalVisible && ( + <InviteModal + isEmailSetup={systemFeatures.is_email_setup} + onCancel={() => setInviteModalVisible(false)} + onSend={(invitationResults) => { + setInvitedModalVisible(true) + setInvitationResults(invitationResults) + refetch() + }} + /> + )} + {invitedModalVisible && ( + <InvitedModal + invitationResults={invitationResults} + onCancel={() => setInvitedModalVisible(false)} + /> + )} + {editWorkspaceModalVisible && ( + <EditWorkspaceModal onCancel={() => setEditWorkspaceModalVisible(false)} /> + )} {showTransferOwnershipModal && ( <TransferOwnershipModal show={showTransferOwnershipModal} @@ -217,9 +221,9 @@ const MembersPage = () => { <MemberDetailsModal member={detailsMember} canAssignRoles={ - canManageMembers - && detailsMember.role !== 'owner' - && userProfileEmail !== detailsMember.email + canManageMembers && + detailsMember.role !== 'owner' && + 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 23c3c55b703f5c..ab3553fbfb9165 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 @@ -17,7 +17,8 @@ const InviteButton = (props: InviteButtonProps) => { const { t } = useTranslation() const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspaceId, systemFeatures.branding.enabled) + const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = + useWorkspacePermissions(currentWorkspaceId, systemFeatures.branding.enabled) if (systemFeatures.branding.enabled) { if (isFetchingWorkspacePermissions) { return <Loading /> @@ -29,7 +30,7 @@ const InviteButton = (props: InviteButtonProps) => { return ( <Button variant="primary" {...props}> <RiUserAddLine className="mr-1 size-4" /> - {t($ => $['members.invite'], { ns: 'common' })} + {t(($) => $['members.invite'], { ns: 'common' })} </Button> ) } diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx index ea8cf1288450d9..95d1e6669fca10 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx @@ -29,15 +29,29 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('react-multi-email', () => ({ - ReactMultiEmail: ({ emails, onChange, getLabel }: { emails: string[], onChange: (emails: string[]) => void, getLabel: (email: string, index: number, removeEmail: (index: number) => void) => React.ReactNode }) => ( + ReactMultiEmail: ({ + emails, + onChange, + getLabel, + }: { + emails: string[] + onChange: (emails: string[]) => void + getLabel: ( + email: string, + index: number, + removeEmail: (index: number) => void, + ) => React.ReactNode + }) => ( <div> <input data-testid="mock-email-input" - onChange={e => onChange(e.target.value ? e.target.value.split(',') : [])} + onChange={(e) => onChange(e.target.value ? e.target.value.split(',') : [])} /> {emails.map((email: string, index: number) => ( <div key={email}> - {getLabel(email, index, (idx: number) => onChange(emails.filter((_: string, i: number) => i !== idx)))} + {getLabel(email, index, (idx: number) => + onChange(emails.filter((_: string, i: number) => i !== idx)), + )} </div> ))} </div> @@ -49,55 +63,58 @@ describe('InviteModal', () => { const mockOnSend = vi.fn() const mockRefreshLicenseLimit = vi.fn() - const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: Infinity, - }, - mutations: { - retry: false, + const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + mutations: { + retry: false, + }, }, - }, - }) + }) beforeEach(() => { vi.clearAllMocks() vi.mocked(useWorkspaceRoleList).mockReturnValue({ data: { - pages: [{ - data: [ - { - id: 'admin', - tenant_id: 'tenant-id', - type: 'workspace', - category: 'global_system_default', - name: 'Admin', - description: 'Can manage workspace settings', - is_builtin: true, - permission_keys: [], - role_tag: '', - }, - { - id: 'normal', - tenant_id: 'tenant-id', - type: 'workspace', - category: 'global_system_default', - name: 'Normal', - description: 'Can use apps', - is_builtin: true, - permission_keys: [], - role_tag: '', + pages: [ + { + data: [ + { + id: 'admin', + tenant_id: 'tenant-id', + type: 'workspace', + category: 'global_system_default', + name: 'Admin', + description: 'Can manage workspace settings', + is_builtin: true, + permission_keys: [], + role_tag: '', + }, + { + id: 'normal', + tenant_id: 'tenant-id', + type: 'workspace', + category: 'global_system_default', + name: 'Normal', + description: 'Can use apps', + is_builtin: true, + permission_keys: [], + role_tag: '', + }, + ], + pagination: { + total_count: 2, + per_page: 20, + current_page: 1, + total_pages: 1, }, - ], - pagination: { - total_count: 2, - per_page: 20, - current_page: 1, - total_pages: 1, }, - }], + ], pageParams: [1], }, isLoading: false, @@ -107,10 +124,12 @@ describe('InviteModal', () => { fetchNextPage: vi.fn(), } as unknown as ReturnType<typeof useWorkspaceRoleList>) - vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ - licenseLimit: { workspace_members: { size: 5, limit: 10 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, - } as unknown as Parameters<typeof selector>[0])) + vi.mocked(useProviderContextSelector).mockImplementation((selector) => + selector({ + licenseLimit: { workspace_members: { size: 5, limit: 10 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters<typeof selector>[0]), + ) }) const renderModal = (isEmailSetup = true, queryClient = createQueryClient()) => ({ @@ -239,10 +258,12 @@ describe('InviteModal', () => { }) it('should keep send button disabled when license limit is exceeded', async () => { - vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ - licenseLimit: { workspace_members: { size: 10, limit: 10 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, - } as unknown as Parameters<typeof selector>[0])) + vi.mocked(useProviderContextSelector).mockImplementation((selector) => + selector({ + licenseLimit: { workspace_members: { size: 10, limit: 10 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters<typeof selector>[0]), + ) renderModal() @@ -288,10 +309,12 @@ describe('InviteModal', () => { }) it('should show unlimited label when workspace member limit is zero', async () => { - vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ - licenseLimit: { workspace_members: { size: 5, limit: 0 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, - } as unknown as Parameters<typeof selector>[0])) + vi.mocked(useProviderContextSelector).mockImplementation((selector) => + selector({ + licenseLimit: { workspace_members: { size: 5, limit: 0 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters<typeof selector>[0]), + ) renderModal() @@ -299,10 +322,12 @@ describe('InviteModal', () => { }) it('should initialize usedSize to zero when workspace_members.size is null', async () => { - vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ - licenseLimit: { workspace_members: { size: null, limit: 10 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, - } as unknown as Parameters<typeof selector>[0])) + vi.mocked(useProviderContextSelector).mockImplementation((selector) => + selector({ + licenseLimit: { workspace_members: { size: null, limit: 10 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters<typeof selector>[0]), + ) renderModal() @@ -331,10 +356,12 @@ describe('InviteModal', () => { }) it('should show destructive text color when used size exceeds limit', async () => { - vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ - licenseLimit: { workspace_members: { size: 10, limit: 10 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, - } as unknown as Parameters<typeof selector>[0])) + vi.mocked(useProviderContextSelector).mockImplementation((selector) => + selector({ + licenseLimit: { workspace_members: { size: 10, limit: 10 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters<typeof selector>[0]), + ) renderModal() @@ -379,10 +406,12 @@ describe('InviteModal', () => { it('should show destructive color and disable send button when limit is exactly met with one email', async () => { // size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10 - vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ - licenseLimit: { workspace_members: { size: 10, limit: 10 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, - } as unknown as Parameters<typeof selector>[0])) + vi.mocked(useProviderContextSelector).mockImplementation((selector) => + selector({ + licenseLimit: { workspace_members: { size: 10, limit: 10 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters<typeof selector>[0]), + ) renderModal() @@ -426,10 +455,12 @@ describe('InviteModal', () => { it('should not show error text color when isLimited is false even with many emails', async () => { // size=0, limit=0 → isLimited=false, usedSize=emails.length - vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ - licenseLimit: { workspace_members: { size: 0, limit: 0 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, - } as unknown as Parameters<typeof selector>[0])) + vi.mocked(useProviderContextSelector).mockImplementation((selector) => + selector({ + licenseLimit: { workspace_members: { size: 0, limit: 0 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters<typeof selector>[0]), + ) renderModal() diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx index 84d593efccc27d..a0e4b35fc740c0 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx @@ -69,9 +69,11 @@ const RoleSelectorWrapper = ({ initialRole = 'normal' }: WrapperProps) => { return <RoleSelector value={role} onChange={setRole} /> } -const getTrigger = () => screen.getByRole('button', { name: /members\.(invitedAsRole|selectRole)/i }) +const getTrigger = () => + screen.getByRole('button', { name: /members\.(invitedAsRole|selectRole)/i }) const getRoleMenu = () => screen.getByRole('menu') -const getRoleOption = (role: string) => within(getRoleMenu()).getByRole('menuitemradio', { name: new RegExp(role, 'i') }) +const getRoleOption = (role: string) => + within(getRoleMenu()).getByRole('menuitemradio', { name: new RegExp(role, 'i') }) describe('RoleSelector', () => { beforeEach(() => { @@ -118,20 +120,22 @@ describe('RoleSelector', () => { const user = userEvent.setup() mockUseWorkspaceRoleList({ - pages: [{ - data: [ - createRole({ id: 'admin', name: 'admin', description: '' }), - createRole({ id: 'editor', name: 'editor', description: '' }), - createRole({ id: 'normal', name: 'normal', description: '' }), - createRole({ id: 'dataset_operator', name: 'dataset_operator', description: '' }), - ], - pagination: { - total_count: 4, - per_page: 20, - current_page: 1, - total_pages: 1, + pages: [ + { + data: [ + createRole({ id: 'admin', name: 'admin', description: '' }), + createRole({ id: 'editor', name: 'editor', description: '' }), + createRole({ id: 'normal', name: 'normal', description: '' }), + createRole({ id: 'dataset_operator', name: 'dataset_operator', description: '' }), + ], + pagination: { + total_count: 4, + per_page: 20, + current_page: 1, + total_pages: 1, + }, }, - }], + ], }) render(<RoleSelectorWrapper initialRole="" />) @@ -178,10 +182,18 @@ describe('RoleSelector', () => { callbacks.push(callback) } - observe() { /* noop */ } - unobserve() { /* noop */ } - disconnect() { /* noop */ } - takeRecords(): IntersectionObserverEntry[] { return [] } + observe() { + /* noop */ + } + unobserve() { + /* noop */ + } + disconnect() { + /* noop */ + } + takeRecords(): IntersectionObserverEntry[] { + return [] + } } mockUseWorkspaceRoleList({ hasNextPage: true, fetchNextPage }) @@ -194,7 +206,10 @@ describe('RoleSelector', () => { }) await act(async () => { - callbacks[0]!([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver) + callbacks[0]!( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) }) expect(fetchNextPage).toHaveBeenCalledTimes(1) @@ -205,15 +220,17 @@ describe('RoleSelector', () => { it('should render an empty state when there are no roles', async () => { const user = userEvent.setup() mockUseWorkspaceRoleList({ - pages: [{ - data: [], - pagination: { - total_count: 0, - per_page: 20, - current_page: 1, - total_pages: 0, + pages: [ + { + data: [], + pagination: { + total_count: 0, + per_page: 20, + current_page: 1, + total_pages: 0, + }, }, - }], + ], }) render(<RoleSelectorWrapper initialRole="" />) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx index 1594e38ee5f48d..1a31e8e23d1cd8 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx @@ -23,31 +23,23 @@ type IInviteModalProps = { onSend: (invitationResults: InvitationResult[]) => void } -const InviteModal = ({ - isEmailSetup, - onCancel, - onSend, -}: IInviteModalProps) => { +const InviteModal = ({ isEmailSetup, onCancel, onSend }: IInviteModalProps) => { const { t } = useTranslation() const queryClient = useQueryClient() - const licenseLimit = useProviderContextSelector(s => s.licenseLimit) - const refreshLicenseLimit = useProviderContextSelector(s => s.refreshLicenseLimit) + const licenseLimit = useProviderContextSelector((s) => s.licenseLimit) + const refreshLicenseLimit = useProviderContextSelector((s) => s.refreshLicenseLimit) const [emails, setEmails] = useState<string[]>([]) const isLimited = licenseLimit.workspace_members.limit > 0 const usedSize = emails.length + licenseLimit.workspace_members.size - const isLimitExceeded = isLimited && (usedSize > licenseLimit.workspace_members.limit) + const isLimitExceeded = isLimited && usedSize > licenseLimit.workspace_members.limit const locale = useLocale() const [role, setRole] = useState<string>('') - const [isSubmitting, { - setTrue: setIsSubmitting, - setFalse: setIsSubmitted, - }] = useBoolean(false) + const [isSubmitting, { setTrue: setIsSubmitting, setFalse: setIsSubmitted }] = useBoolean(false) const handleSend = useCallback(async () => { - if (isLimitExceeded || isSubmitting) - return + if (isLimitExceeded || isSubmitting) return setIsSubmitting() if (emails.map((email: string) => emailRegex.test(email)).every(Boolean)) { try { @@ -62,21 +54,31 @@ const InviteModal = ({ onCancel() onSend(invitation_results) } - } - catch { } - } - else { - toast.error(t($ => $['members.emailInvalid'], { ns: 'common' })) + } catch {} + } else { + toast.error(t(($) => $['members.emailInvalid'], { ns: 'common' })) } setIsSubmitted() - }, [isLimitExceeded, emails, role, locale, onCancel, onSend, t, isSubmitting, refreshLicenseLimit, queryClient, setIsSubmitted, setIsSubmitting]) + }, [ + isLimitExceeded, + emails, + role, + locale, + onCancel, + onSend, + t, + isSubmitting, + refreshLicenseLimit, + queryClient, + setIsSubmitted, + setIsSubmitting, + ]) return ( <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DialogContent @@ -86,20 +88,28 @@ const InviteModal = ({ <DialogCloseButton className="top-6 right-8" /> <div className="mb-2 pr-8"> <DialogTitle className="text-xl font-semibold text-text-primary"> - {t($ => $['members.inviteTeamMember'], { ns: 'common' })} + {t(($) => $['members.inviteTeamMember'], { ns: 'common' })} </DialogTitle> </div> - <div className="mb-3 text-[13px] text-text-tertiary">{t($ => $['members.inviteTeamMemberTip'], { ns: 'common' })}</div> + <div className="mb-3 text-[13px] text-text-tertiary"> + {t(($) => $['members.inviteTeamMemberTip'], { ns: 'common' })} + </div> {!isEmailSetup && ( <div className="grow basis-0 overflow-y-auto pb-4"> <div className="relative mb-1 rounded-xl border border-components-panel-border p-2 shadow-xs"> - <div className="absolute top-0 left-0 size-full rounded-xl opacity-40" style={{ background: 'linear-gradient(92deg, rgba(255, 171, 0, 0.25) 18.12%, rgba(255, 255, 255, 0.00) 167.31%)' }}></div> + <div + className="absolute top-0 left-0 size-full rounded-xl opacity-40" + style={{ + background: + 'linear-gradient(92deg, rgba(255, 171, 0, 0.25) 18.12%, rgba(255, 255, 255, 0.00) 167.31%)', + }} + ></div> <div className="relative flex size-full items-start"> <div className="mr-0.5 shrink-0 p-0.5"> <div className="i-ri-error-warning-fill size-5 text-text-warning" /> </div> <div className="system-xs-medium text-text-primary"> - <span>{t($ => $['members.emailNotSetup'], { ns: 'common' })}</span> + <span>{t(($) => $['members.emailNotSetup'], { ns: 'common' })}</span> </div> </div> </div> @@ -107,10 +117,15 @@ const InviteModal = ({ )} <div> - <div className="mb-2 text-sm font-medium text-text-primary">{t($ => $['members.email'], { ns: 'common' })}</div> + <div className="mb-2 text-sm font-medium text-text-primary"> + {t(($) => $['members.email'], { ns: 'common' })} + </div> <div className="mb-8 flex h-36 flex-col items-stretch"> <ReactMultiEmail - className={cn('size-full border-components-input-border-active bg-components-input-bg-normal! px-3 pt-2 outline-hidden', 'appearance-none overflow-y-auto rounded-lg text-sm text-text-primary!')} + className={cn( + 'size-full border-components-input-border-active bg-components-input-bg-normal! px-3 pt-2 outline-hidden', + 'appearance-none overflow-y-auto rounded-lg text-sm text-text-primary!', + )} autoFocus emails={emails} inputClassName="bg-transparent" @@ -121,7 +136,7 @@ const InviteModal = ({ <button type="button" data-tag-handle - aria-label={`${t($ => $['operation.remove'], { ns: 'common' })} ${email}`} + aria-label={`${t(($) => $['operation.remove'], { ns: 'common' })} ${email}`} className="border-none bg-transparent p-0 text-inherit" onClick={() => removeEmail(index)} > @@ -129,15 +144,23 @@ const InviteModal = ({ </button> </div> )} - placeholder={t($ => $['members.emailPlaceholder'], { ns: 'common' }) || ''} + placeholder={t(($) => $['members.emailPlaceholder'], { ns: 'common' }) || ''} /> - <div className={ - cn('flex items-center justify-end system-xs-regular text-text-tertiary', (isLimited && usedSize > licenseLimit.workspace_members.limit) ? 'text-text-destructive' : '') - } + <div + className={cn( + 'flex items-center justify-end system-xs-regular text-text-tertiary', + isLimited && usedSize > licenseLimit.workspace_members.limit + ? 'text-text-destructive' + : '', + )} > <span>{usedSize}</span> <span>/</span> - <span>{isLimited ? licenseLimit.workspace_members.limit : t($ => $['license.unlimited'], { ns: 'common' })}</span> + <span> + {isLimited + ? licenseLimit.workspace_members.limit + : t(($) => $['license.unlimited'], { ns: 'common' })} + </span> </div> </div> <div className="mb-6"> @@ -150,7 +173,7 @@ const InviteModal = ({ disabled={!emails.length || !role || isLimitExceeded || isSubmitting} variant="primary" > - {t($ => $['members.sendInvite'], { ns: 'common' })} + {t(($) => $['members.sendInvite'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx index f7b9a96c485feb..8bbb3ba5f65a73 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx @@ -35,10 +35,7 @@ const isLegacyRoleKey = (value: string): value is LegacyRoleKey => Object.prototype.hasOwnProperty.call(LEGACY_ROLE_DESCRIPTION_KEY_MAP, value) const getLegacyRoleDescriptionKey = (role: Role) => { - const candidateKeys = [ - normalizeLegacyRoleKey(role.name), - normalizeLegacyRoleKey(role.id), - ] + const candidateKeys = [normalizeLegacyRoleKey(role.name), normalizeLegacyRoleKey(role.id)] return candidateKeys.find(isLegacyRoleKey) } @@ -69,41 +66,49 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => { const hasMore = hasNextPage ?? true let observer: IntersectionObserver | undefined - if (error || !open) - return + if (error || !open) return if (anchorRef.current && containerRef.current) { const containerHeight = containerRef.current.clientHeight const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) - observer = new IntersectionObserver((entries) => { - if (entries[0]!.isIntersecting && !rolesLoading && !isFetchingNextPage && !error && hasMore) - fetchNextPage() - }, { - root: containerRef.current, - rootMargin: `${dynamicMargin}px`, - }) + observer = new IntersectionObserver( + (entries) => { + if ( + entries[0]!.isIntersecting && + !rolesLoading && + !isFetchingNextPage && + !error && + hasMore + ) + fetchNextPage() + }, + { + root: containerRef.current, + rootMargin: `${dynamicMargin}px`, + }, + ) observer.observe(anchorRef.current) } return () => observer?.disconnect() }, [rolesLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, open, observerReadyKey]) - const roles = useMemo(() => rolesData?.pages.flatMap(page => page.data) ?? [], [rolesData]) - const selectedRole = roles.find(role => role.id === value) + const roles = useMemo(() => rolesData?.pages.flatMap((page) => page.data) ?? [], [rolesData]) + const selectedRole = roles.find((role) => role.id === value) const selectedRoleName = selectedRole?.name || value const triggerLabel = selectedRoleName - ? t($ => $['members.invitedAsRole'], { ns: 'common', role: selectedRoleName }) - : t($ => $['members.selectRole'], { ns: 'common' }) + ? t(($) => $['members.invitedAsRole'], { ns: 'common', role: selectedRoleName }) + : t(($) => $['members.selectRole'], { ns: 'common' }) const setContainerElement = useCallback((node: HTMLDivElement | null) => { containerRef.current = node - setObserverReadyKey(key => key + 1) + setObserverReadyKey((key) => key + 1) }, []) const setAnchorElement = useCallback((node: HTMLDivElement | null) => { anchorRef.current = node - setObserverReadyKey(key => key + 1) + setObserverReadyKey((key) => key + 1) }, []) const handleRoleChange = (roleId: string) => { @@ -112,31 +117,26 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => { } const getRoleDescription = (role: Role) => { - if (role.description) - return role.description + if (role.description) return role.description const legacyRoleDescriptionKey = getLegacyRoleDescriptionKey(role) switch (legacyRoleDescriptionKey) { case 'admin': - return t($ => $['members.adminTip'], { ns: 'common' }) + return t(($) => $['members.adminTip'], { ns: 'common' }) case 'editor': - return t($ => $['members.editorTip'], { ns: 'common' }) + return t(($) => $['members.editorTip'], { ns: 'common' }) case 'normal': - return t($ => $['members.normalTip'], { ns: 'common' }) + return t(($) => $['members.normalTip'], { ns: 'common' }) case 'dataset_operator': - return t($ => $['members.datasetOperatorTip'], { ns: 'common' }) + return t(($) => $['members.datasetOperatorTip'], { ns: 'common' }) } - return t($ => $['role.noDescription'], { ns: 'permission' }) + return t(($) => $['role.noDescription'], { ns: 'permission' }) } return ( - <DropdownMenu - open={open} - onOpenChange={setOpen} - modal={false} - > + <DropdownMenu open={open} onOpenChange={setOpen} modal={false}> <DropdownMenuTrigger data-testid="role-selector-trigger" className={cn( @@ -145,7 +145,12 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => { )} > <div className="mr-2 grow text-sm/5 text-text-primary">{triggerLabel}</div> - <div className={cn('size-4 shrink-0 text-text-secondary', open ? 'i-ri-arrow-up-s-line' : 'i-ri-arrow-down-s-line')} /> + <div + className={cn( + 'size-4 shrink-0 text-text-secondary', + open ? 'i-ri-arrow-up-s-line' : 'i-ri-arrow-down-s-line', + )} + /> </DropdownMenuTrigger> <DropdownMenuContent placement="bottom-start" @@ -158,46 +163,43 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => { slotClassNames={{ viewport: 'overscroll-contain' }} > <div className="p-1"> - {rolesLoading - ? ( - <div className="px-3 py-6 text-center system-sm-regular text-text-tertiary"> - {t($ => $.loading, { ns: 'common' })} - </div> - ) - : roles.length === 0 - ? ( - <div className="px-3 py-6 text-center system-sm-regular text-text-tertiary"> - {t($ => $['dynamicSelect.noData'], { ns: 'common' })} - </div> - ) - : ( - <> - <DropdownMenuRadioGroup - value={value} - onValueChange={handleRoleChange} - > - {roles.map(role => ( - <DropdownMenuRadioItem - key={role.id} - value={role.id} - className="mx-0 h-auto w-full cursor-pointer items-start gap-0 rounded-lg border-none bg-transparent p-2 text-left hover:bg-state-base-hover data-highlighted:bg-state-base-hover" - > - <div className="relative min-w-0 pl-5"> - <div className="truncate text-sm leading-5 text-text-secondary">{role.name}</div> - <div className="line-clamp-2 text-xs leading-4.5 text-text-tertiary">{getRoleDescription(role)}</div> - {value === role.id && ( - <div - aria-hidden="true" - className="absolute top-0.5 left-0 i-custom-vender-line-general-check h-4 w-4 text-text-accent" - /> - )} - </div> - </DropdownMenuRadioItem> - ))} - </DropdownMenuRadioGroup> - <div ref={setAnchorElement} className="h-0" /> - </> - )} + {rolesLoading ? ( + <div className="px-3 py-6 text-center system-sm-regular text-text-tertiary"> + {t(($) => $.loading, { ns: 'common' })} + </div> + ) : roles.length === 0 ? ( + <div className="px-3 py-6 text-center system-sm-regular text-text-tertiary"> + {t(($) => $['dynamicSelect.noData'], { ns: 'common' })} + </div> + ) : ( + <> + <DropdownMenuRadioGroup value={value} onValueChange={handleRoleChange}> + {roles.map((role) => ( + <DropdownMenuRadioItem + key={role.id} + value={role.id} + className="mx-0 h-auto w-full cursor-pointer items-start gap-0 rounded-lg border-none bg-transparent p-2 text-left hover:bg-state-base-hover data-highlighted:bg-state-base-hover" + > + <div className="relative min-w-0 pl-5"> + <div className="truncate text-sm leading-5 text-text-secondary"> + {role.name} + </div> + <div className="line-clamp-2 text-xs leading-4.5 text-text-tertiary"> + {getRoleDescription(role)} + </div> + {value === role.id && ( + <div + aria-hidden="true" + className="absolute top-0.5 left-0 i-custom-vender-line-general-check h-4 w-4 text-text-accent" + /> + )} + </div> + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + <div ref={setAnchorElement} className="h-0" /> + </> + )} </div> </ScrollArea> </DropdownMenuContent> diff --git a/web/app/components/header/account-setting/members-page/invited-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/invited-modal/__tests__/index.spec.tsx index 27ba8e4b41fc4b..3fe46147067761 100644 --- a/web/app/components/header/account-setting/members-page/invited-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invited-modal/__tests__/index.spec.tsx @@ -14,7 +14,11 @@ describe('InvitedModal', () => { const mockOnCancel = vi.fn() const results: InvitationResult[] = [ { email: 'success@example.com', status: 'success', url: 'http://invite.com/1' }, - { email: 'member@example.com', status: 'already_member', message: 'Account already in workspace.' }, + { + email: 'member@example.com', + status: 'already_member', + message: 'Account already in workspace.', + }, { email: 'failed@example.com', status: 'failed', message: 'Error msg' }, ] @@ -103,9 +107,7 @@ describe('InvitedModal (non-CE edition)', () => { }) it('should show already-member details when IS_CE_EDITION is false', () => { - const results: InvitationResult[] = [ - { email: 'member@example.com', status: 'already_member' }, - ] + const results: InvitationResult[] = [{ email: 'member@example.com', status: 'already_member' }] render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />) diff --git a/web/app/components/header/account-setting/members-page/invited-modal/index.tsx b/web/app/components/header/account-setting/members-page/invited-modal/index.tsx index 915f9c3ad98e1f..7317be8615ca92 100644 --- a/web/app/components/header/account-setting/members-page/invited-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invited-modal/index.tsx @@ -14,10 +14,7 @@ type IInvitedModalProps = { invitationResults: InvitationResult[] onCancel: () => void } -const InvitedModal = ({ - invitationResults, - onCancel, -}: IInvitedModalProps) => { +const InvitedModal = ({ invitationResults, onCancel }: IInvitedModalProps) => { const { t } = useTranslation() const successInvitationResults = invitationResults.filter( @@ -29,117 +26,106 @@ const InvitedModal = ({ const failedInvitationResults = invitationResults.filter( (item): item is FailedInvitationResult => item.status === 'failed', ) - const onlyAlreadyMembers = alreadyMemberInvitationResults.length > 0 && successInvitationResults.length === 0 && failedInvitationResults.length === 0 - const description = t($ => $[onlyAlreadyMembers ? 'members.alreadyInTeamTip' : 'members.invitationSentTip'], { ns: 'common' }) + const onlyAlreadyMembers = + alreadyMemberInvitationResults.length > 0 && + successInvitationResults.length === 0 && + failedInvitationResults.length === 0 + const description = t( + ($) => $[onlyAlreadyMembers ? 'members.alreadyInTeamTip' : 'members.invitationSentTip'], + { ns: 'common' }, + ) return ( <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > - <DialogContent - backdropProps={{ forceRender: true }} - className="w-[480px] p-8" - > + <DialogContent backdropProps={{ forceRender: true }} className="w-[480px] p-8"> <DialogCloseButton className="top-8 right-8" /> <div className="mb-3 flex justify-between"> - <div className=" - flex h-12 w-12 items-center justify-center rounded-xl - border-[0.5px] border-components-panel-border bg-background-section-burn - shadow-xl - " - > + <div className="flex h-12 w-12 items-center justify-center rounded-xl border-[0.5px] border-components-panel-border bg-background-section-burn shadow-xl"> <div className="i-heroicons-check-circle-solid h-[22px] w-[22px] text-[#039855]" /> </div> </div> <DialogTitle className="mb-1 text-xl font-semibold text-text-primary"> - {t($ => $[onlyAlreadyMembers ? 'members.noNewInvitationsSent' : 'members.invitationSent'], { ns: 'common' })} + {t( + ($) => + $[onlyAlreadyMembers ? 'members.noNewInvitationsSent' : 'members.invitationSent'], + { ns: 'common' }, + )} </DialogTitle> - {!IS_CE_EDITION && ( - <div className="mb-5 text-sm text-text-tertiary">{description}</div> - )} + {!IS_CE_EDITION && <div className="mb-5 text-sm text-text-tertiary">{description}</div>} {(IS_CE_EDITION || !!alreadyMemberInvitationResults.length) && ( <> - {IS_CE_EDITION && ( - <div className="mb-5 text-sm text-text-tertiary">{description}</div> - )} + {IS_CE_EDITION && <div className="mb-5 text-sm text-text-tertiary">{description}</div>} <div className="mb-9 flex flex-col gap-2"> - { - IS_CE_EDITION && !!successInvitationResults.length - && ( - <> - <div className="py-2 text-sm font-medium text-text-primary">{t($ => $['members.invitationLink'], { ns: 'common' })}</div> - {successInvitationResults.map(item => - <InvitationLink key={item.email} value={item} />)} - </> - ) - } - { - !!alreadyMemberInvitationResults.length - && ( - <> - <div className="py-2 text-sm font-medium text-text-primary">{t($ => $['members.alreadyInTeam'], { ns: 'common' })}</div> - {!onlyAlreadyMembers && ( - <div className="text-sm text-text-tertiary">{t($ => $['members.alreadyInTeamTip'], { ns: 'common' })}</div> - )} - <div className="flex flex-wrap justify-between gap-y-1"> - { - alreadyMemberInvitationResults.map(item => ( - <div - key={item.email} - className="flex justify-center rounded-md border border-components-panel-border bg-background-section-burn px-1 text-sm text-text-secondary" - > - {item.email} - </div> - )) - } - </div> - </> - ) - } - { - IS_CE_EDITION && !!failedInvitationResults.length - && ( - <> - <div className="py-2 text-sm font-medium text-text-primary">{t($ => $['members.failedInvitationEmails'], { ns: 'common' })}</div> - <div className="flex flex-wrap justify-between gap-y-1"> - { - failedInvitationResults.map(item => ( - <div key={item.email} className="flex justify-center rounded-md border border-red-300 bg-orange-50 px-1"> - <Tooltip> - <TooltipTrigger - render={( - <div className="flex items-center justify-center gap-1 text-sm"> - {item.email} - <div className="i-ri-question-line size-4 text-red-300" /> - </div> - )} - /> - <TooltipContent> - {item.message} - </TooltipContent> - </Tooltip> - </div> - ), - ) - } + {IS_CE_EDITION && !!successInvitationResults.length && ( + <> + <div className="py-2 text-sm font-medium text-text-primary"> + {t(($) => $['members.invitationLink'], { ns: 'common' })} + </div> + {successInvitationResults.map((item) => ( + <InvitationLink key={item.email} value={item} /> + ))} + </> + )} + {!!alreadyMemberInvitationResults.length && ( + <> + <div className="py-2 text-sm font-medium text-text-primary"> + {t(($) => $['members.alreadyInTeam'], { ns: 'common' })} + </div> + {!onlyAlreadyMembers && ( + <div className="text-sm text-text-tertiary"> + {t(($) => $['members.alreadyInTeamTip'], { ns: 'common' })} </div> - </> - ) - } + )} + <div className="flex flex-wrap justify-between gap-y-1"> + {alreadyMemberInvitationResults.map((item) => ( + <div + key={item.email} + className="flex justify-center rounded-md border border-components-panel-border bg-background-section-burn px-1 text-sm text-text-secondary" + > + {item.email} + </div> + ))} + </div> + </> + )} + {IS_CE_EDITION && !!failedInvitationResults.length && ( + <> + <div className="py-2 text-sm font-medium text-text-primary"> + {t(($) => $['members.failedInvitationEmails'], { ns: 'common' })} + </div> + <div className="flex flex-wrap justify-between gap-y-1"> + {failedInvitationResults.map((item) => ( + <div + key={item.email} + className="flex justify-center rounded-md border border-red-300 bg-orange-50 px-1" + > + <Tooltip> + <TooltipTrigger + render={ + <div className="flex items-center justify-center gap-1 text-sm"> + {item.email} + <div className="i-ri-question-line size-4 text-red-300" /> + </div> + } + /> + <TooltipContent>{item.message}</TooltipContent> + </Tooltip> + </div> + ))} + </div> + </> + )} </div> </> )} <div className="flex justify-end"> - <Button - className="w-[96px]" - onClick={onCancel} - variant="primary" - > - {t($ => $['members.ok'], { ns: 'common' })} + <Button className="w-[96px]" onClick={onCancel} variant="primary"> + {t(($) => $['members.ok'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx b/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx index a4d9b5f614d798..aedeef42626071 100644 --- a/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx +++ b/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx @@ -11,9 +11,7 @@ type IInvitationLinkProps = { value: SuccessInvitationResult } -const InvitationLink = ({ - value, -}: IInvitationLinkProps) => { +const InvitationLink = ({ value }: IInvitationLinkProps) => { const { t } = useTranslation() const [isCopied, setIsCopied] = useState(false) @@ -41,7 +39,7 @@ const InvitationLink = ({ <div className="relative h-full grow text-[13px]"> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" className="absolute inset-x-0 top-0 block w-full cursor-pointer truncate border-none bg-transparent p-0 px-2 text-left text-text-primary" @@ -49,29 +47,29 @@ const InvitationLink = ({ > {value.url} </button> - )} + } /> <TooltipContent> - {isCopied ? t($ => $.copied, { ns: 'appApi' }) : t($ => $.copy, { ns: 'appApi' })} + {isCopied ? t(($) => $.copied, { ns: 'appApi' }) : t(($) => $.copy, { ns: 'appApi' })} </TooltipContent> </Tooltip> </div> <div className="h-4 shrink-0 border border-divider-regular bg-divider-regular" /> <Tooltip> <TooltipTrigger - render={( + render={ <div className="shrink-0 px-0.5"> <button type="button" - aria-label={t($ => $.copy, { ns: 'appApi' })} + aria-label={t(($) => $.copy, { ns: 'appApi' })} className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg border-none bg-transparent p-0 hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle} /> </div> - )} + } /> <TooltipContent> - {isCopied ? t($ => $.copied, { ns: 'appApi' }) : t($ => $.copy, { ns: 'appApi' })} + {isCopied ? t(($) => $.copied, { ns: 'appApi' }) : t(($) => $.copy, { ns: 'appApi' })} </TooltipContent> </Tooltip> </div> diff --git a/web/app/components/header/account-setting/members-page/member-details-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/member-details-modal/__tests__/index.spec.tsx index 71acdd68e3e745..c75bfd90130406 100644 --- a/web/app/components/header/account-setting/members-page/member-details-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/member-details-modal/__tests__/index.spec.tsx @@ -30,9 +30,7 @@ const member: Member = { avatar: '', avatar_url: '', role: 'admin', - roles: [ - createRole({ id: 'role-1', name: 'Custom role' }), - ], + roles: [createRole({ id: 'role-1', name: 'Custom role' })], last_active_at: '1731000000', last_login_at: '1731000000', created_at: '1731000000', @@ -45,26 +43,26 @@ describe('MemberDetailsModal', () => { vi.mocked(useRolesOfMember).mockReturnValue({ data: { account_id: member.id, - roles: [ - createRole({ id: 'role-1', name: 'Custom role' }), - ], + roles: [createRole({ id: 'role-1', name: 'Custom role' })], }, isLoading: false, } as unknown as ReturnType<typeof useRolesOfMember>) vi.mocked(useWorkspaceRoleList).mockReturnValue({ data: { - pages: [{ - data: [ - createRole({ id: 'role-1', name: 'Custom role' }), - createRole({ id: 'role-2', name: 'Second role' }), - ], - pagination: { - total_count: 2, - per_page: 20, - current_page: 1, - total_pages: 1, + pages: [ + { + data: [ + createRole({ id: 'role-1', name: 'Custom role' }), + createRole({ id: 'role-2', name: 'Second role' }), + ], + pagination: { + total_count: 2, + per_page: 20, + current_page: 1, + total_pages: 1, + }, }, - }], + ], pageParams: [1], }, isLoading: false, @@ -90,7 +88,9 @@ describe('MemberDetailsModal', () => { const editButton = screen.getByRole('button', { name: /common\.operation\.edit/i }) expect(editButton).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /members\.memberDetails\.assign/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /members\.memberDetails\.assign/i }), + ).not.toBeInTheDocument() expect(editButton.querySelector('.i-ri-edit-line')).toBeInTheDocument() expect(editButton.querySelector('.i-ri-add-line')).not.toBeInTheDocument() }) @@ -106,7 +106,9 @@ describe('MemberDetailsModal', () => { ) expect(screen.getByText(/common\.members\.memberDetails\.assignedRole:/i)).toBeInTheDocument() - expect(screen.queryByText(/common\.members\.memberDetails\.assignedRoles/i)).not.toBeInTheDocument() + expect( + screen.queryByText(/common\.members\.memberDetails\.assignedRoles/i), + ).not.toBeInTheDocument() }) it('should render role loading state without assigned role chips or count', () => { @@ -125,7 +127,9 @@ describe('MemberDetailsModal', () => { ) expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /members\.memberDetails\.assign/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /members\.memberDetails\.assign/i }), + ).toBeInTheDocument() expect(screen.queryByText('Custom role')).not.toBeInTheDocument() expect(screen.queryByText('1')).not.toBeInTheDocument() }) @@ -149,7 +153,9 @@ describe('MemberDetailsModal', () => { await user.click(screen.getByRole('button', { name: /^Custom role$/i })) - expect(screen.queryByRole('button', { name: /common\.operation\.remove/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /common\.operation\.remove/i }), + ).not.toBeInTheDocument() }) it('should not show role removal controls when role assignment is not allowed', () => { @@ -162,8 +168,12 @@ describe('MemberDetailsModal', () => { />, ) - expect(screen.queryByRole('button', { name: /members\.memberDetails\.assign/i })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: /members\.memberDetails\.removeRoleAria/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /members\.memberDetails\.assign/i }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /members\.memberDetails\.removeRoleAria/i }), + ).not.toBeInTheDocument() }) it('should submit pending role changes only after save is clicked', async () => { @@ -189,7 +199,9 @@ describe('MemberDetailsModal', () => { />, ) - await user.click(screen.getByRole('button', { name: /common\.operation\.remove.*Custom role/i })) + await user.click( + screen.getByRole('button', { name: /common\.operation\.remove.*Custom role/i }), + ) expect(handleAssignSubmit).not.toHaveBeenCalled() diff --git a/web/app/components/header/account-setting/members-page/member-details-modal/index.tsx b/web/app/components/header/account-setting/members-page/member-details-modal/index.tsx index 00c22404afc2ff..6021fcf453bf72 100644 --- a/web/app/components/header/account-setting/members-page/member-details-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/member-details-modal/index.tsx @@ -4,12 +4,7 @@ import type { Role } from '@/models/access-control' import type { Member } from '@/models/common' import { Avatar } from '@langgenius/dify-ui/avatar' import { Button } from '@langgenius/dify-ui/button' -import { - Dialog, - DialogCloseButton, - DialogContent, - DialogTitle, -} from '@langgenius/dify-ui/dialog' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' @@ -39,50 +34,65 @@ const MemberDetailsModal = ({ const [assignOpen, setAssignOpen] = useState(false) const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale]) - const { data: rolesOfMember, isLoading: isLoadingRolesOfMember } = useRolesOfMember(member.id, language) + const { data: rolesOfMember, isLoading: isLoadingRolesOfMember } = useRolesOfMember( + member.id, + language, + ) const [pendingRoles, setPendingRoles] = useState<Role[]>() const roles = useMemo(() => rolesOfMember?.roles ?? [], [rolesOfMember?.roles]) const selectedRoles = pendingRoles ?? roles - const selectedRoleIds = useMemo(() => selectedRoles.map(role => role.id), [selectedRoles]) + const selectedRoleIds = useMemo(() => selectedRoles.map((role) => role.id), [selectedRoles]) const canRemoveRoles = canAssignRoles && allowMultipleRoles - const assignedRolesLabel = selectedRoleIds.length === 1 - ? t($ => $['members.memberDetails.assignedRole'], { - ns: 'common', - defaultValue: 'Assigned Role', - }) - : t($ => $['members.memberDetails.assignedRoles'], { - ns: 'common', - defaultValue: 'Assigned Roles', - }) + const assignedRolesLabel = + selectedRoleIds.length === 1 + ? t(($) => $['members.memberDetails.assignedRole'], { + ns: 'common', + defaultValue: 'Assigned Role', + }) + : t(($) => $['members.memberDetails.assignedRoles'], { + ns: 'common', + defaultValue: 'Assigned Roles', + }) const assignActionIconClassName = allowMultipleRoles ? 'mr-0.5 i-ri-add-line h-3.5 w-3.5' : 'mr-0.5 i-ri-edit-line h-3.5 w-3.5' const assignActionLabel = allowMultipleRoles - ? t($ => $['members.memberDetails.assign'], { + ? t(($) => $['members.memberDetails.assign'], { ns: 'common', defaultValue: 'Assign', }) - : t($ => $['operation.edit'], { ns: 'common' }) + : t(($) => $['operation.edit'], { ns: 'common' }) - const builtinRoles = useMemo(() => selectedRoles.filter(role => role.is_builtin), [selectedRoles]) - const customRoles = useMemo(() => selectedRoles.filter(role => !role.is_builtin), [selectedRoles]) + const builtinRoles = useMemo( + () => selectedRoles.filter((role) => role.is_builtin), + [selectedRoles], + ) + const customRoles = useMemo( + () => selectedRoles.filter((role) => !role.is_builtin), + [selectedRoles], + ) const handleClose = useCallback(() => { setAssignOpen(false) }, []) - const handleAssignSubmit = useCallback((roles: Role[]) => { - setPendingRoles(allowMultipleRoles ? roles : roles.slice(0, 1)) - setAssignOpen(false) - }, [allowMultipleRoles]) + const handleAssignSubmit = useCallback( + (roles: Role[]) => { + setPendingRoles(allowMultipleRoles ? roles : roles.slice(0, 1)) + setAssignOpen(false) + }, + [allowMultipleRoles], + ) - const handleRemove = useCallback((roleId: string) => { - if (!allowMultipleRoles && selectedRoles.length <= 1) - return + const handleRemove = useCallback( + (roleId: string) => { + if (!allowMultipleRoles && selectedRoles.length <= 1) return - setPendingRoles(selectedRoles.filter(selectedRole => selectedRole.id !== roleId)) - }, [allowMultipleRoles, selectedRoles]) + setPendingRoles(selectedRoles.filter((selectedRole) => selectedRole.id !== roleId)) + }, + [allowMultipleRoles, selectedRoles], + ) const handleSave = useCallback(() => { onAssignSubmit?.(allowMultipleRoles ? selectedRoles : selectedRoles.slice(0, 1)) @@ -94,33 +104,24 @@ const MemberDetailsModal = ({ <Dialog open onOpenChange={(next) => { - if (!next) - onClose() + if (!next) onClose() }} > <DialogContent className="w-110 overflow-visible p-0" backdropProps={{ forceRender: true }}> <div className="relative px-6 pt-6 pb-5"> <DialogCloseButton /> <DialogTitle className="pr-8 system-xl-semibold text-text-primary"> - {t($ => $['members.memberDetails.title'], { + {t(($) => $['members.memberDetails.title'], { ns: 'common', defaultValue: 'Member Details', })} </DialogTitle> <div className="mt-5 flex items-center gap-3"> - <Avatar - avatar={member.avatar_url} - name={member.name} - size="2xl" - /> + <Avatar avatar={member.avatar_url} name={member.name} size="2xl" /> <div className="min-w-0 flex-1"> - <div className="truncate system-md-semibold text-text-primary"> - {member.name} - </div> - <div className="truncate system-xs-regular text-text-tertiary"> - {member.email} - </div> + <div className="truncate system-md-semibold text-text-primary">{member.name}</div> + <div className="truncate system-xs-regular text-text-tertiary">{member.email}</div> </div> </div> </div> @@ -128,9 +129,7 @@ const MemberDetailsModal = ({ <div className="border-t border-divider-subtle px-6 py-4"> <div className="flex items-center justify-between"> <div className="flex items-center gap-1.5 system-sm-semibold text-text-secondary"> - <span> - {assignedRolesLabel} - </span> + <span>{assignedRolesLabel}</span> {!isLoadingRolesOfMember && ( <span className="system-xs-medium text-text-tertiary"> {selectedRoleIds.length} @@ -138,85 +137,72 @@ const MemberDetailsModal = ({ )} </div> {canAssignRoles && ( - <Button - variant="ghost" - size="small" - onClick={() => setAssignOpen(true)} - > - <span - aria-hidden - className={assignActionIconClassName} - /> + <Button variant="ghost" size="small" onClick={() => setAssignOpen(true)}> + <span aria-hidden className={assignActionIconClassName} /> {assignActionLabel} </Button> )} </div> - {isLoadingRolesOfMember - ? ( + {isLoadingRolesOfMember ? ( + <div className="mt-4"> + <Loading /> + </div> + ) : ( + <> + {builtinRoles.length > 0 && ( + <div className="mt-4"> + <div className="mb-2 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['members.memberDetails.generalGroup'], { + ns: 'common', + })} + </div> + <div className="flex flex-wrap gap-1.5"> + {builtinRoles.map((role) => ( + <PermissionRoleChip + key={role.id} + roleId={role.id} + label={role.name} + isOwner={role.role_tag === 'owner'} + permissionKeys={role.permission_keys} + onRemove={canRemoveRoles ? handleRemove : undefined} + /> + ))} + </div> + </div> + )} + {customRoles.length > 0 && ( <div className="mt-4"> - <Loading /> + <div className="mb-2 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['members.memberDetails.customGroup'], { + ns: 'common', + })} + </div> + <div className="flex flex-wrap gap-1.5"> + {customRoles.map((role) => ( + <PermissionRoleChip + key={role.id} + roleId={role.id} + label={role.name} + isOwner={role.role_tag === 'owner'} + permissionKeys={role.permission_keys} + onRemove={canRemoveRoles ? handleRemove : undefined} + /> + ))} + </div> </div> - ) - : ( - <> - {builtinRoles.length > 0 && ( - <div className="mt-4"> - <div className="mb-2 system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['members.memberDetails.generalGroup'], { - ns: 'common', - })} - </div> - <div className="flex flex-wrap gap-1.5"> - {builtinRoles.map(role => ( - <PermissionRoleChip - key={role.id} - roleId={role.id} - label={role.name} - isOwner={role.role_tag === 'owner'} - permissionKeys={role.permission_keys} - onRemove={canRemoveRoles ? handleRemove : undefined} - /> - ))} - </div> - </div> - )} - {customRoles.length > 0 && ( - <div className="mt-4"> - <div className="mb-2 system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['members.memberDetails.customGroup'], { - ns: 'common', - })} - </div> - <div className="flex flex-wrap gap-1.5"> - {customRoles.map(role => ( - <PermissionRoleChip - key={role.id} - roleId={role.id} - label={role.name} - isOwner={role.role_tag === 'owner'} - permissionKeys={role.permission_keys} - onRemove={canRemoveRoles ? handleRemove : undefined} - /> - ))} - </div> - </div> - )} - </> )} + </> + )} </div> {canAssignRoles && ( <div className="flex items-center justify-end gap-2 px-6 pt-2 pb-4"> <Button variant="secondary" onClick={onClose}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button - variant="primary" - onClick={handleSave} - disabled={!onAssignSubmit} - > - {t($ => $['operation.save'], { ns: 'common' })} + <Button variant="primary" onClick={handleSave} disabled={!onAssignSubmit}> + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> )} diff --git a/web/app/components/header/account-setting/members-page/member-details-modal/permission-role-chip.tsx b/web/app/components/header/account-setting/members-page/member-details-modal/permission-role-chip.tsx index 48ffbb691d49ac..5d50da56e04571 100644 --- a/web/app/components/header/account-setting/members-page/member-details-modal/permission-role-chip.tsx +++ b/web/app/components/header/account-setting/members-page/member-details-modal/permission-role-chip.tsx @@ -2,11 +2,7 @@ import type { SelectorKey } from 'i18next' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { memo } from 'react' import { Trans, useTranslation } from 'react-i18next' @@ -31,13 +27,12 @@ const PermissionRoleChip = ({ const permissions = permissionKeys const canRemoveRole = !isOwner && !!onRemove // Permission keys come from the catalog API, so this is a reviewed open-key boundary with a server-provided fallback. - const translatePermissionName = (key: string) => t(key as SelectorKey, { - ns: 'permissionKeys', - defaultValue: key, - }) - const permissionLabels = permissions - .map(translatePermissionName) - .join(', ') + const translatePermissionName = (key: string) => + t(key as SelectorKey, { + ns: 'permissionKeys', + defaultValue: key, + }) + const permissionLabels = permissions.map(translatePermissionName).join(', ') const hasPermissionLabels = permissionLabels.length > 0 const chipRootClassName = cn( @@ -47,7 +42,7 @@ const PermissionRoleChip = ({ className, ) - const removeLabel = `${t($ => $['operation.remove'], { ns: 'common' })} ${label}` + const removeLabel = `${t(($) => $['operation.remove'], { ns: 'common' })} ${label}` const chip = ( <span className={chipRootClassName}> @@ -55,14 +50,14 @@ const PermissionRoleChip = ({ openOnHover delay={300} closeDelay={200} - render={( + render={ <button type="button" className="min-w-0 truncate rounded-sm border-none bg-transparent p-0 text-start leading-4 outline-hidden" > {label} </button> - )} + } /> {canRemoveRole && ( <button @@ -83,24 +78,24 @@ const PermissionRoleChip = ({ {label} </div> <div className="body-xs-regular text-text-secondary"> - {hasPermissionLabels - ? ( - <Trans - i18nKey={$ => $['members.memberDetails.rolePermissionSummary']} - ns="common" - values={{ - role: label, - permissions: permissionLabels, - }} - components={{ - permissionList: <span className="text-text-accent" />, - }} - /> - ) - : t($ => $['members.memberDetails.roleNoPermissionSummary'], { - ns: 'common', - defaultValue: 'Current role has no permissions.', - })} + {hasPermissionLabels ? ( + <Trans + i18nKey={($) => $['members.memberDetails.rolePermissionSummary']} + ns="common" + values={{ + role: label, + permissions: permissionLabels, + }} + components={{ + permissionList: <span className="text-text-accent" />, + }} + /> + ) : ( + t(($) => $['members.memberDetails.roleNoPermissionSummary'], { + ns: 'common', + defaultValue: 'Current role has no permissions.', + }) + )} </div> </div> ) diff --git a/web/app/components/header/account-setting/members-page/member-menu.tsx b/web/app/components/header/account-setting/members-page/member-menu.tsx index 64f821c6f508cd..36b238b303fde8 100644 --- a/web/app/components/header/account-setting/members-page/member-menu.tsx +++ b/web/app/components/header/account-setting/members-page/member-menu.tsx @@ -57,8 +57,8 @@ const MemberMenu = ({ const selectedRoles = member.roles || [] const memberName = member.name || member.email const assignRolesLabel = allowMultipleRoles - ? t($ => $['members.assignRoles'], { ns: 'common', defaultValue: 'Assign Roles' }) - : t($ => $['members.editRole'], { ns: 'common', defaultValue: 'Edit Role' }) + ? t(($) => $['members.assignRoles'], { ns: 'common', defaultValue: 'Assign Roles' }) + : t(($) => $['members.editRole'], { ns: 'common', defaultValue: 'Edit Role' }) const handleOpenAssignRoles = useCallback(() => { setOpen(false) @@ -67,20 +67,26 @@ const MemberMenu = ({ const { mutateAsync: updateRolesOfMember } = useUpdateRolesOfMember() - const handleAssignRolesSubmit = useCallback((roles: Role[]) => { - const roleIds = allowMultipleRoles - ? roles.map(role => role.id) - : roles.slice(0, 1).map(role => role.id) + const handleAssignRolesSubmit = useCallback( + (roles: Role[]) => { + const roleIds = allowMultipleRoles + ? roles.map((role) => role.id) + : roles.slice(0, 1).map((role) => role.id) - updateRolesOfMember({ - memberId: member.id, - roleIds, - }, { - onSuccess: () => { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - }, - }) - }, [allowMultipleRoles, member.id, t, updateRolesOfMember]) + updateRolesOfMember( + { + memberId: member.id, + roleIds, + }, + { + onSuccess: () => { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + }, + }, + ) + }, + [allowMultipleRoles, member.id, t, updateRolesOfMember], + ) const handleOpenRemoveConfirm = useCallback(() => { setOpen(false) @@ -92,12 +98,10 @@ const MemberMenu = ({ try { await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` }) void queryClient.invalidateQueries({ queryKey: commonQueryKeys.members }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) setRemoveConfirmOpen(false) - } - catch { - } - finally { + } catch { + } finally { setRemoving(false) } }, [member.id, queryClient, t]) @@ -107,20 +111,22 @@ const MemberMenu = ({ onTransferOwnership?.() }, [onTransferOwnership]) - if (!canAssignRoles && !canRemove && !showTransferOwnership) - return null + if (!canAssignRoles && !canRemove && !showTransferOwnership) return null return ( <div role="presentation"> <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger - render={( + render={ <ActionButton size="l" className="data-popup-open:bg-state-base-hover" - aria-label={t($ => $['members.memberActions'], { ns: 'common', defaultValue: 'Member actions' })} + aria-label={t(($) => $['members.memberActions'], { + ns: 'common', + defaultValue: 'Member actions', + })} /> - )} + } > <span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" /> </DropdownMenuTrigger> @@ -142,40 +148,40 @@ const MemberMenu = ({ className="system-sm-medium text-text-secondary" onClick={handleTransferOwnership} > - {t($ => $['members.transferOwnership'], { ns: 'common' })} + {t(($) => $['members.transferOwnership'], { ns: 'common' })} </DropdownMenuItem> )} - {(canAssignRoles || showTransferOwnership) && canRemove && ( - <DropdownMenuSeparator /> - )} + {(canAssignRoles || showTransferOwnership) && canRemove && <DropdownMenuSeparator />} {canRemove && ( <DropdownMenuItem variant="destructive" className="system-sm-medium" onClick={handleOpenRemoveConfirm} > - {t($ => $['members.removeFromTeam'], { ns: 'common' })} + {t(($) => $['members.removeFromTeam'], { ns: 'common' })} </DropdownMenuItem> )} </DropdownMenuContent> </DropdownMenu> - <AlertDialog open={removeConfirmOpen} onOpenChange={open => !open && setRemoveConfirmOpen(false)}> + <AlertDialog + open={removeConfirmOpen} + onOpenChange={(open) => !open && setRemoveConfirmOpen(false)} + > <AlertDialogContent backdropProps={{ forceRender: true }}> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['members.removeFromTeamConfirmTitle'], { ns: 'common', memberName })} + {t(($) => $['members.removeFromTeamConfirmTitle'], { ns: 'common', memberName })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['members.removeFromTeamConfirmDescription'], { ns: 'common' })} + {t(($) => $['members.removeFromTeamConfirmDescription'], { ns: 'common' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton - disabled={removing} - onClick={handleRemove} - > - {t($ => $['operation.confirm'], { ns: 'common' })} + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton disabled={removing} onClick={handleRemove}> + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/header/account-setting/members-page/member-row.tsx b/web/app/components/header/account-setting/members-page/member-row.tsx index 9b144db39388a9..8a8b3bde982ea8 100644 --- a/web/app/components/header/account-setting/members-page/member-row.tsx +++ b/web/app/components/header/account-setting/members-page/member-row.tsx @@ -35,7 +35,7 @@ const MemberRow = ({ const { t } = useTranslation() const { formatTimeFromNow } = useFormatTimeFromNow() - const roleNames = roles.map(role => role.name) + const roleNames = roles.map((role) => role.name) const openDetails = useCallback(() => { onOpenDetails(member) @@ -48,7 +48,7 @@ const MemberRow = ({ > <button type="button" - aria-label={t($ => $['members.memberDetails.openAria'], { + aria-label={t(($) => $['members.memberDetails.openAria'], { ns: 'common', name: member.name, defaultValue: 'Open member details for {{name}}', @@ -66,12 +66,12 @@ const MemberRow = ({ {member.name} {member.status === 'pending' && ( <span className="ml-1 system-xs-medium text-text-warning"> - {t($ => $['members.pending'], { ns: 'common' })} + {t(($) => $['members.pending'], { ns: 'common' })} </span> )} {isCurrentUser && ( <span className="system-xs-regular text-text-tertiary"> - {t($ => $['members.you'], { ns: 'common' })} + {t(($) => $['members.you'], { ns: 'common' })} </span> )} </span> @@ -79,22 +79,13 @@ const MemberRow = ({ </span> </span> <span className="flex w-30 shrink-0 items-center py-2 system-sm-regular text-text-secondary"> - {formatTimeFromNow(Number((member.last_active_at || member.created_at)) * 1000)} + {formatTimeFromNow(Number(member.last_active_at || member.created_at) * 1000)} </span> - <span - className="flex min-w-0 grow items-center gap-2 px-3" - role="presentation" - > - <RoleBadges - className="grow" - roleNames={roleNames} - /> + <span className="flex min-w-0 grow items-center gap-2 px-3" role="presentation"> + <RoleBadges className="grow" roleNames={roleNames} /> </span> </button> - <div - className="absolute inset-y-0 right-0 flex items-center px-3" - role="presentation" - > + <div className="absolute inset-y-0 right-0 flex items-center px-3" role="presentation"> {canManage && ( <MemberMenu member={member} diff --git a/web/app/components/header/account-setting/members-page/role-badges.tsx b/web/app/components/header/account-setting/members-page/role-badges.tsx index 217f824669f5cf..e9dd6189608bc2 100644 --- a/web/app/components/header/account-setting/members-page/role-badges.tsx +++ b/web/app/components/header/account-setting/members-page/role-badges.tsx @@ -34,13 +34,11 @@ const RoleBadges = ({ roleNames, max = 2, className }: RoleBadgesProps) => { return ( <span className={cn('flex min-w-0 items-center gap-1', className)}> - {visible.map(role => ( + {visible.map((role) => ( <RoleBadge key={role} label={role} /> ))} {overflow.length > 0 && ( - <span - className="inline-flex h-5 shrink-0 cursor-default items-center rounded-md bg-background-body px-1.5 system-xs-medium text-text-tertiary shadow-xs" - > + <span className="inline-flex h-5 shrink-0 cursor-default items-center rounded-md bg-background-body px-1.5 system-xs-medium text-text-tertiary shadow-xs"> {`+${overflow.length}`} </span> )} 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 78d3f80ca51739..81f06169e9d984 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 @@ -36,7 +36,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('@/service/common') @@ -93,11 +94,12 @@ describe('TransferOwnershipModal', () => { vi.useRealTimers() }) - const renderModal = () => render( - <> - <TransferOwnershipModal show onClose={mockOnClose} /> - </>, - ) + const renderModal = () => + render( + <> + <TransferOwnershipModal show onClose={mockOnClose} /> + </>, + ) const mockEmailVerification = ({ isValid = true, @@ -118,7 +120,9 @@ describe('TransferOwnershipModal', () => { } const goToTransferStep = async (user: ReturnType<typeof userEvent.setup>) => { - await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) + await user.click( + screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }), + ) const input = await screen.findByTestId('transfer-modal-code-input') await user.type(input, '123456') await user.click(screen.getByTestId('transfer-modal-continue')) @@ -141,20 +145,28 @@ describe('TransferOwnershipModal', () => { expect(await screen.findByText(/members\.transferModal\.transferLabel/i)).toBeInTheDocument() await selectNewOwnerAndSubmit(user) - await waitFor(() => { - expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' }) - expect(window.location.reload).toHaveBeenCalled() - }, { timeout: 10000 }) + await waitFor( + () => { + expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' }) + expect(window.location.reload).toHaveBeenCalled() + }, + { timeout: 10000 }, + ) }, 15000) it('should handle timer countdown and resend', async () => { vi.useFakeTimers() - vi.mocked(sendOwnerEmail).mockResolvedValue({ data: 'token', result: 'success' } as unknown as Awaited<ReturnType<typeof sendOwnerEmail>>) + vi.mocked(sendOwnerEmail).mockResolvedValue({ + data: 'token', + result: 'success', + } as unknown as Awaited<ReturnType<typeof sendOwnerEmail>>) renderModal() // Trigger the email send (which starts the timer) await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) + fireEvent.click( + screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }), + ) }) // Step Verify shows up @@ -188,10 +200,12 @@ describe('TransferOwnershipModal', () => { await goToTransferStep(user) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'Verifying email failed', - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'Verifying email failed', + }), + ) }) }) @@ -204,10 +218,12 @@ describe('TransferOwnershipModal', () => { await goToTransferStep(user) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: expect.stringContaining('verification crash'), - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: expect.stringContaining('verification crash'), + }), + ) }) }) @@ -215,7 +231,9 @@ describe('TransferOwnershipModal', () => { const user = userEvent.setup() vi.mocked(sendOwnerEmail).mockRejectedValue(new Error('network error')) renderModal() - await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) + await user.click( + screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }), + ) // The base service layer surfaces the real backend error. The modal itself // must NOT show an additional toast (e.g. "Error sending verification code: undefined"). @@ -224,7 +242,9 @@ describe('TransferOwnershipModal', () => { }) expect(mockNotify).not.toHaveBeenCalled() // Should remain on the start step instead of advancing to the verify step. - expect(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }), + ).toBeInTheDocument() }) it('should show error when ownership transfer fails', async () => { @@ -236,10 +256,12 @@ describe('TransferOwnershipModal', () => { await selectNewOwnerAndSubmit(user) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: expect.stringContaining('transfer failed'), - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: expect.stringContaining('transfer failed'), + }), + ) }) }) @@ -251,7 +273,9 @@ describe('TransferOwnershipModal', () => { } as unknown as Awaited<ReturnType<typeof sendOwnerEmail>>) renderModal() - await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) + await user.click( + screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }), + ) // Should advance to verify step even with null data await waitFor(() => { @@ -264,13 +288,17 @@ describe('TransferOwnershipModal', () => { vi.mocked(sendOwnerEmail).mockRejectedValue(null) renderModal() - await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) + await user.click( + screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }), + ) await waitFor(() => { expect(sendOwnerEmail).toHaveBeenCalled() }) expect(mockNotify).not.toHaveBeenCalled() - expect(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }), + ).toBeInTheDocument() }) it('should show fallback error prefix when verifyOwnerEmail throws null', async () => { @@ -282,10 +310,12 @@ describe('TransferOwnershipModal', () => { await goToTransferStep(user) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: expect.stringContaining('Error verifying email:'), - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: expect.stringContaining('Error verifying email:'), + }), + ) }) }) @@ -299,10 +329,12 @@ describe('TransferOwnershipModal', () => { await selectNewOwnerAndSubmit(user) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: expect.stringContaining('Error ownership transfer:'), - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: expect.stringContaining('Error ownership transfer:'), + }), + ) }) }) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/member-selector.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/member-selector.spec.tsx index c222be8bc9c63c..9336aea92ea217 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/member-selector.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/member-selector.spec.tsx @@ -91,7 +91,9 @@ describe('MemberSelector', () => { }) it('should handle missing data gracefully', () => { - vi.mocked(useMembers).mockReturnValue({ data: undefined } as unknown as ReturnType<typeof useMembers>) + vi.mocked(useMembers).mockReturnValue({ data: undefined } as unknown as ReturnType< + typeof useMembers + >) render(<MemberSelector onSelect={mockOnSelect} />) expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument() }) @@ -99,7 +101,12 @@ describe('MemberSelector', () => { it('should filter by email when account name is empty', async () => { const user = userEvent.setup() vi.mocked(useMembers).mockReturnValue({ - data: { accounts: [...mockAccounts, { id: '4', name: '', email: 'noname@example.com', avatar_url: '' }] }, + data: { + accounts: [ + ...mockAccounts, + { id: '4', name: '', email: 'noname@example.com', avatar_url: '' }, + ], + }, } as unknown as ReturnType<typeof useMembers>) render(<MemberSelector onSelect={mockOnSelect} />) 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 dc38d539e68a9a..68d80e0c8b8216 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 @@ -20,7 +20,7 @@ const STEP = { verify: 'verify', transfer: 'transfer', } -type Step = typeof STEP[keyof typeof STEP] +type Step = (typeof STEP)[keyof typeof STEP] const getErrorMessage = (error: unknown) => { return error instanceof Error ? error.message : '' @@ -38,32 +38,31 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { const [isTransfer, setIsTransfer] = useState<boolean>(false) const timerIdRef = React.useRef<number | undefined>(undefined) const retimeCountdown = useCallback((timerId?: number) => { - if (timerIdRef.current !== undefined) - window.clearInterval(timerIdRef.current) + if (timerIdRef.current !== undefined) window.clearInterval(timerIdRef.current) timerIdRef.current = timerId }, []) React.useEffect(() => { - if (!show) - retimeCountdown() + if (!show) retimeCountdown() return retimeCountdown }, [retimeCountdown, show]) const startCount = () => { setTime(60) - retimeCountdown(window.setInterval(() => { - setTime((prev) => { - if (prev <= 1) { - retimeCountdown() - return 0 - } - return prev - 1 - }) - }, 1000)) + retimeCountdown( + window.setInterval(() => { + setTime((prev) => { + if (prev <= 1) { + retimeCountdown() + return 0 + } + return prev - 1 + }) + }, 1000), + ) } const sendEmail = async () => { const res = await sendOwnerEmail({}) startCount() - if (res.data) - setStepToken(res.data) + if (res.data) setStepToken(res.data) } const verifyEmailAddress = async (code: string, token: string, callback?: () => void) => { try { @@ -74,12 +73,10 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { if (res.is_valid) { setStepToken(res.token) callback?.() - } - else { + } else { toast.error('Verifying email failed') } - } - catch (error) { + } catch (error) { toast.error(`Error verifying email: ${getErrorMessage(error)}`) } } @@ -87,8 +84,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { try { await sendEmail() setStep(STEP.verify) - } - catch { + } catch { // The base service layer already surfaces the backend error (e.g. rate-limit) as a toast. } } @@ -103,11 +99,9 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { token: stepToken, }) globalThis.location.reload() - } - catch (error) { + } catch (error) { toast.error(`Error ownership transfer: ${getErrorMessage(error)}`) - } - finally { + } finally { setIsTransfer(false) } } @@ -117,63 +111,105 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { <button type="button" className="absolute top-5 right-5 cursor-pointer border-none bg-transparent p-1.5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onClose} > <span className="i-ri-close-line size-5 text-text-tertiary" aria-hidden="true" /> </button> {step === STEP.start && ( <> - <div className="pb-3 title-2xl-semi-bold text-text-primary">{t($ => $['members.transferModal.title'], { ns: 'common' })}</div> + <div className="pb-3 title-2xl-semi-bold text-text-primary"> + {t(($) => $['members.transferModal.title'], { ns: 'common' })} + </div> <div className="space-y-1 pt-1 pb-2"> - <div className="body-md-medium text-text-destructive">{t($ => $['members.transferModal.warning'], { ns: 'common', workspace: currentWorkspace.name.replace(/'/g, '’') })}</div> - <div className="body-md-regular text-text-secondary">{t($ => $['members.transferModal.warningTip'], { ns: 'common' })}</div> + <div className="body-md-medium text-text-destructive"> + {t(($) => $['members.transferModal.warning'], { + ns: 'common', + workspace: currentWorkspace.name.replace(/'/g, '’'), + })} + </div> + <div className="body-md-regular text-text-secondary"> + {t(($) => $['members.transferModal.warningTip'], { ns: 'common' })} + </div> <div className="body-md-regular text-text-secondary"> - <Trans i18nKey={$ => $['members.transferModal.sendTip']} ns="common" components={{ email: <span className="body-md-medium text-text-primary"></span> }} values={{ email: userProfile.email }} /> + <Trans + i18nKey={($) => $['members.transferModal.sendTip']} + ns="common" + components={{ email: <span className="body-md-medium text-text-primary"></span> }} + values={{ email: userProfile.email }} + /> </div> </div> <div className="pt-3"></div> <div className="space-y-2"> <Button className="w-full!" variant="primary" onClick={sendCodeToOriginEmail}> - {t($ => $['members.transferModal.sendVerifyCode'], { ns: 'common' })} + {t(($) => $['members.transferModal.sendVerifyCode'], { ns: 'common' })} </Button> <Button data-testid="transfer-modal-cancel" className="w-full!" onClick={onClose}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> </div> </> )} {step === STEP.verify && ( <> - <div className="pb-3 title-2xl-semi-bold text-text-primary">{t($ => $['members.transferModal.verifyEmail'], { ns: 'common' })}</div> + <div className="pb-3 title-2xl-semi-bold text-text-primary"> + {t(($) => $['members.transferModal.verifyEmail'], { ns: 'common' })} + </div> <div className="pt-1 pb-2"> <div className="body-md-regular text-text-secondary"> - <Trans i18nKey={$ => $['members.transferModal.verifyContent']} ns="common" components={{ email: <span className="body-md-medium text-text-primary"></span> }} values={{ email: userProfile.email }} /> + <Trans + i18nKey={($) => $['members.transferModal.verifyContent']} + ns="common" + components={{ email: <span className="body-md-medium text-text-primary"></span> }} + values={{ email: userProfile.email }} + /> + </div> + <div className="body-md-regular text-text-secondary"> + {t(($) => $['members.transferModal.verifyContent2'], { ns: 'common' })} </div> - <div className="body-md-regular text-text-secondary">{t($ => $['members.transferModal.verifyContent2'], { ns: 'common' })}</div> </div> <div className="pt-3"> - <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">{t($ => $['members.transferModal.codeLabel'], { ns: 'common' })}</div> - <Input data-testid="transfer-modal-code-input" className="w-full!" placeholder={t($ => $['members.transferModal.codePlaceholder'], { ns: 'common' })} value={code} onChange={e => setCode(e.target.value)} maxLength={6} /> + <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> + {t(($) => $['members.transferModal.codeLabel'], { ns: 'common' })} + </div> + <Input + data-testid="transfer-modal-code-input" + className="w-full!" + placeholder={t(($) => $['members.transferModal.codePlaceholder'], { ns: 'common' })} + value={code} + onChange={(e) => setCode(e.target.value)} + maxLength={6} + /> </div> <div className="mt-3 space-y-2"> - <Button data-testid="transfer-modal-continue" disabled={code.length !== 6} className="w-full!" variant="primary" onClick={handleVerifyOriginEmail}> - {t($ => $['members.transferModal.continue'], { ns: 'common' })} + <Button + data-testid="transfer-modal-continue" + disabled={code.length !== 6} + className="w-full!" + variant="primary" + onClick={handleVerifyOriginEmail} + > + {t(($) => $['members.transferModal.continue'], { ns: 'common' })} </Button> <Button data-testid="transfer-modal-cancel" className="w-full!" onClick={onClose}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> </div> <div className="mt-3 flex items-center gap-1 system-xs-regular text-text-tertiary"> - <span>{t($ => $['members.transferModal.resendTip'], { ns: 'common' })}</span> - {time > 0 && (<span>{t($ => $['members.transferModal.resendCount'], { ns: 'common', count: time })}</span>)} + <span>{t(($) => $['members.transferModal.resendTip'], { ns: 'common' })}</span> + {time > 0 && ( + <span> + {t(($) => $['members.transferModal.resendCount'], { ns: 'common', count: time })} + </span> + )} {!time && ( <button type="button" onClick={sendCodeToOriginEmail} className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-medium text-text-accent-secondary" > - {t($ => $['members.transferModal.resend'], { ns: 'common' })} + {t(($) => $['members.transferModal.resend'], { ns: 'common' })} </button> )} </div> @@ -181,21 +217,39 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { )} {step === STEP.transfer && ( <> - <div className="pb-3 title-2xl-semi-bold text-text-primary">{t($ => $['members.transferModal.title'], { ns: 'common' })}</div> + <div className="pb-3 title-2xl-semi-bold text-text-primary"> + {t(($) => $['members.transferModal.title'], { ns: 'common' })} + </div> <div className="space-y-1 pt-1 pb-2"> - <div className="body-md-medium text-text-destructive">{t($ => $['members.transferModal.warning'], { ns: 'common', workspace: currentWorkspace.name.replace(/'/g, '’') })}</div> - <div className="body-md-regular text-text-secondary">{t($ => $['members.transferModal.warningTip'], { ns: 'common' })}</div> + <div className="body-md-medium text-text-destructive"> + {t(($) => $['members.transferModal.warning'], { + ns: 'common', + workspace: currentWorkspace.name.replace(/'/g, '’'), + })} + </div> + <div className="body-md-regular text-text-secondary"> + {t(($) => $['members.transferModal.warningTip'], { ns: 'common' })} + </div> </div> <div className="pt-3"> - <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">{t($ => $['members.transferModal.transferLabel'], { ns: 'common' })}</div> + <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> + {t(($) => $['members.transferModal.transferLabel'], { ns: 'common' })} + </div> <MemberSelector exclude={[userProfile.id]} value={newOwner} onSelect={setNewOwner} /> </div> <div className="mt-4 space-y-2"> - <Button data-testid="transfer-modal-submit" disabled={!newOwner || isTransfer} className="w-full!" variant="primary" tone="destructive" onClick={handleTransfer}> - {t($ => $['members.transferModal.transfer'], { ns: 'common' })} + <Button + data-testid="transfer-modal-submit" + disabled={!newOwner || isTransfer} + className="w-full!" + variant="primary" + tone="destructive" + onClick={handleTransfer} + > + {t(($) => $['members.transferModal.transfer'], { ns: 'common' })} </Button> <Button data-testid="transfer-modal-cancel" className="w-full!" onClick={onClose}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> </div> </> diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx index 34dfac4120d032..cb85d400c3e7bc 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx @@ -1,11 +1,7 @@ 'use client' import type { FC } from 'react' import { Avatar } from '@langgenius/dify-ui/avatar' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -18,11 +14,7 @@ type Props = Readonly<{ exclude?: string[] }> -const MemberSelector: FC<Props> = ({ - value, - onSelect, - exclude = [], -}) => { +const MemberSelector: FC<Props> = ({ value, onSelect, exclude = [] }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const [searchValue, setSearchValue] = useState('') @@ -30,46 +22,51 @@ const MemberSelector: FC<Props> = ({ const { data } = useMembers() const currentValue = useMemo(() => { - if (!data?.accounts || !value) - return null - return data.accounts.find(account => account.id === value) ?? null + if (!data?.accounts || !value) return null + return data.accounts.find((account) => account.id === value) ?? null }, [data, value]) const filteredList = useMemo(() => { - if (!data?.accounts) - return [] + if (!data?.accounts) return [] const accounts = data.accounts - if (!searchValue) - return accounts.filter(account => !exclude.includes(account.id)) - return accounts.filter((account) => { - const name = account.name || '' - const email = account.email || '' - return name.toLowerCase().includes(searchValue.toLowerCase()) - || email.toLowerCase().includes(searchValue.toLowerCase()) - }).filter(account => !exclude.includes(account.id)) + if (!searchValue) return accounts.filter((account) => !exclude.includes(account.id)) + return accounts + .filter((account) => { + const name = account.name || '' + const email = account.email || '' + return ( + name.toLowerCase().includes(searchValue.toLowerCase()) || + email.toLowerCase().includes(searchValue.toLowerCase()) + ) + }) + .filter((account) => !exclude.includes(account.id)) }, [data, exclude, searchValue]) return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <div data-testid="member-selector-trigger" className="group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt" > {!currentValue && ( - <div className="grow p-1 system-sm-regular text-components-input-text-placeholder">{t($ => $['members.transferModal.transferPlaceholder'], { ns: 'common' })}</div> + <div className="grow p-1 system-sm-regular text-components-input-text-placeholder"> + {t(($) => $['members.transferModal.transferPlaceholder'], { ns: 'common' })} + </div> )} {currentValue && ( <> <Avatar avatar={currentValue.avatar_url} size="sm" name={currentValue.name} /> - <div className="grow truncate system-sm-medium text-text-secondary">{currentValue.name}</div> + <div className="grow truncate system-sm-medium text-text-secondary"> + {currentValue.name} + </div> <div className="system-xs-regular text-text-quaternary">{currentValue.email}</div> </> )} <div className="i-ri-arrow-down-s-line size-4 text-text-quaternary group-hover:text-text-secondary group-data-popup-open:text-text-secondary" /> </div> - )} + } /> <PopoverContent placement="bottom" @@ -82,11 +79,11 @@ const MemberSelector: FC<Props> = ({ data-testid="member-selector-search" showLeftIcon value={searchValue} - onChange={e => setSearchValue(e.target.value)} + onChange={(e) => setSearchValue(e.target.value)} /> </div> <div className="p-1"> - {filteredList.map(account => ( + {filteredList.map((account) => ( <div key={account.id} data-testid="member-selector-item" @@ -97,7 +94,9 @@ const MemberSelector: FC<Props> = ({ }} > <Avatar avatar={account.avatar_url} size="sm" name={account.name} /> - <div className="grow truncate system-sm-medium text-text-secondary">{account.name}</div> + <div className="grow truncate system-sm-medium text-text-secondary"> + {account.name} + </div> <div className="system-xs-regular text-text-quaternary">{account.email}</div> </div> ))} diff --git a/web/app/components/header/account-setting/menu-dialog.tsx b/web/app/components/header/account-setting/menu-dialog.tsx index e801b60e0b9301..c0226cf49cc32e 100644 --- a/web/app/components/header/account-setting/menu-dialog.tsx +++ b/web/app/components/header/account-setting/menu-dialog.tsx @@ -11,21 +11,14 @@ type DialogProps = { onClose?: () => void } -const MenuDialog = ({ - backdropClassName, - className, - children, - show, - onClose, -}: DialogProps) => { +const MenuDialog = ({ backdropClassName, className, children, show, onClose }: DialogProps) => { const close = useCallback(() => onClose?.(), [onClose]) return ( <Dialog open={show} onOpenChange={(open) => { - if (!open) - close() + if (!open) close() }} > <DialogContent diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/atoms.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/atoms.spec.tsx index aa27583daf2488..890aeade4c8ca1 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/atoms.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/atoms.spec.tsx @@ -10,9 +10,7 @@ import { } from '../atoms' const createWrapper = () => { - return ({ children }: { children: ReactNode }) => ( - <Provider>{children}</Provider> - ) + return ({ children }: { children: ReactNode }) => <Provider>{children}</Provider> } describe('atoms', () => { @@ -25,19 +23,15 @@ describe('atoms', () => { // Read hook: returns whether a specific provider is expanded describe('useModelProviderListExpanded', () => { it('should return false when provider has not been expanded', () => { - const { result } = renderHook( - () => useModelProviderListExpanded('openai'), - { wrapper }, - ) + const { result } = renderHook(() => useModelProviderListExpanded('openai'), { wrapper }) expect(result.current).toBe(false) }) it('should return false for any unknown provider name', () => { - const { result } = renderHook( - () => useModelProviderListExpanded('nonexistent-provider'), - { wrapper }, - ) + const { result } = renderHook(() => useModelProviderListExpanded('nonexistent-provider'), { + wrapper, + }) expect(result.current).toBe(false) }) @@ -383,10 +377,9 @@ describe('atoms', () => { { wrapper: wrapper1 }, ) - const { result: result2 } = renderHook( - () => useModelProviderListExpanded('openai'), - { wrapper: wrapper2 }, - ) + const { result: result2 } = renderHook(() => useModelProviderListExpanded('openai'), { + wrapper: wrapper2, + }) act(() => { result1.current.setExpanded(true) diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/derive-model-status.spec.ts b/web/app/components/header/account-setting/model-provider-page/__tests__/derive-model-status.spec.ts index 8ef80e5025dcb6..eca7d891121e94 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/derive-model-status.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/derive-model-status.spec.ts @@ -1,13 +1,11 @@ import type { Model, ModelItem, ModelProvider } from '../declarations' import type { CredentialPanelState } from '../provider-added-card/use-credential-panel-state' -import { - ConfigurationMethodEnum, - ModelStatusEnum, - ModelTypeEnum, -} from '../declarations' +import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '../declarations' import { deriveModelStatus } from '../derive-model-status' -const createCredentialState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({ +const createCredentialState = ( + overrides: Partial<CredentialPanelState> = {}, +): CredentialPanelState => ({ variant: 'credits-active', priority: 'credits', supportsCredits: true, @@ -30,8 +28,7 @@ const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({ ...overrides, }) -const createModelProvider = (): ModelProvider => - ({ provider: 'openai' } as ModelProvider) +const createModelProvider = (): ModelProvider => ({ provider: 'openai' }) as ModelProvider const createModel = (overrides: Partial<Model> = {}): Model => ({ provider: 'openai', @@ -45,22 +42,46 @@ const createModel = (overrides: Partial<Model> = {}): Model => ({ describe('deriveModelStatus', () => { it('should return empty when model id or provider name is missing', () => { expect( - deriveModelStatus('', 'openai', createModelProvider(), createModelItem(), createCredentialState()), + deriveModelStatus( + '', + 'openai', + createModelProvider(), + createModelItem(), + createCredentialState(), + ), ).toBe('empty') expect( - deriveModelStatus('text-embedding-3-large', '', createModelProvider(), createModelItem(), createCredentialState()), + deriveModelStatus( + 'text-embedding-3-large', + '', + createModelProvider(), + createModelItem(), + createCredentialState(), + ), ).toBe('empty') }) it('should return incompatible when provider plugin is missing', () => { expect( - deriveModelStatus('text-embedding-3-large', 'openai', undefined, createModelItem(), createCredentialState()), + deriveModelStatus( + 'text-embedding-3-large', + 'openai', + undefined, + createModelItem(), + createCredentialState(), + ), ).toBe('incompatible') }) it('should return incompatible when model is missing from the provider list', () => { expect( - deriveModelStatus('text-embedding-3-large', 'openai', createModel(), undefined, createCredentialState()), + deriveModelStatus( + 'text-embedding-3-large', + 'openai', + createModel(), + undefined, + createCredentialState(), + ), ).toBe('incompatible') }) @@ -82,13 +103,25 @@ describe('deriveModelStatus', () => { it('should return configure-required when the model status is no-configure', () => { expect( - deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem({ status: ModelStatusEnum.noConfigure }), createCredentialState()), + deriveModelStatus( + 'text-embedding-3-large', + 'openai', + createModelProvider(), + createModelItem({ status: ModelStatusEnum.noConfigure }), + createCredentialState(), + ), ).toBe('configure-required') }) it('should return disabled when the model status is disabled', () => { expect( - deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem({ status: ModelStatusEnum.disabled }), createCredentialState()), + deriveModelStatus( + 'text-embedding-3-large', + 'openai', + createModelProvider(), + createModelItem({ status: ModelStatusEnum.disabled }), + createCredentialState(), + ), ).toBe('disabled') }) @@ -154,7 +187,13 @@ describe('deriveModelStatus', () => { it('should return active when model and credential state are available', () => { expect( - deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem(), createCredentialState()), + deriveModelStatus( + 'text-embedding-3-large', + 'openai', + createModelProvider(), + createModelItem(), + createCredentialState(), + ), ).toBe('active') }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts b/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts index 0c5367ed4599b0..4a912dc4862a00 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts @@ -95,7 +95,8 @@ vi.mock('../atoms', () => ({ const { useQuery, useQueryClient } = await import('@tanstack/react-query') const { useProviderContext } = await import('@/context/provider-context') const { useModalContextSelector } = await import('@/context/modal-context') -const { useMarketplacePlugins, useMarketplacePluginsByCollectionId } = await import('@/app/components/plugins/marketplace/hooks') +const { useMarketplacePlugins, useMarketplacePluginsByCollectionId } = + await import('@/app/components/plugins/marketplace/hooks') const { useExpandModelProviderList } = await import('../atoms') describe('hooks', () => { @@ -105,57 +106,59 @@ describe('hooks', () => { describe('useLanguage', () => { it('should replace hyphen with underscore in locale', () => { - ; (useLocale as Mock).mockReturnValue('en-US') + ;(useLocale as Mock).mockReturnValue('en-US') const { result } = renderHook(() => useLanguage()) expect(result.current).toBe('en_US') }) it('should return locale as is if no hyphen exists', () => { - ; (useLocale as Mock).mockReturnValue('enUS') + ;(useLocale as Mock).mockReturnValue('enUS') const { result } = renderHook(() => useLanguage()) expect(result.current).toBe('enUS') }) it('should handle Chinese locale', () => { - ; (useLocale as Mock).mockReturnValue('zh-Hans') + ;(useLocale as Mock).mockReturnValue('zh-Hans') const { result } = renderHook(() => useLanguage()) expect(result.current).toBe('zh_Hans') }) it('should only replace the first hyphen when multiple exist', () => { - ; (useLocale as Mock).mockReturnValue('en-GB-custom') + ;(useLocale as Mock).mockReturnValue('en-GB-custom') const { result } = renderHook(() => useLanguage()) expect(result.current).toBe('en_GB-custom') }) }) describe('useSystemDefaultModelAndModelList', () => { - const createMockModelList = (): Model[] => [{ - provider: 'openai', - icon_small: { en_US: 'icon', zh_Hans: 'icon' }, - label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - models: [ - { - model: 'gpt-3.5-turbo', - label: { en_US: 'GPT-3.5', zh_Hans: 'GPT-3.5' }, - model_type: ModelTypeEnum.textGeneration, - fetch_from: ConfigurationMethodEnum.predefinedModel, - status: ModelStatusEnum.active, - model_properties: {}, - load_balancing_enabled: false, - }, - { - model: 'gpt-4', - label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, - model_type: ModelTypeEnum.textGeneration, - fetch_from: ConfigurationMethodEnum.predefinedModel, - status: ModelStatusEnum.active, - model_properties: {}, - load_balancing_enabled: false, - }, - ], - status: ModelStatusEnum.active, - }] + const createMockModelList = (): Model[] => [ + { + provider: 'openai', + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [ + { + model: 'gpt-3.5-turbo', + label: { en_US: 'GPT-3.5', zh_Hans: 'GPT-3.5' }, + model_type: ModelTypeEnum.textGeneration, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + }, + { + model: 'gpt-4', + label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, + model_type: ModelTypeEnum.textGeneration, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + }, + ], + status: ModelStatusEnum.active, + }, + ] const createMockDefaultModel = (model = 'gpt-3.5-turbo'): DefaultModelResponse => ({ provider: { @@ -169,7 +172,9 @@ describe('hooks', () => { it('should return default model state when model exists', () => { const defaultModel = createMockDefaultModel() const modelList = createMockModelList() - const { result } = renderHook(() => useSystemDefaultModelAndModelList(defaultModel, modelList)) + const { result } = renderHook(() => + useSystemDefaultModelAndModelList(defaultModel, modelList), + ) expect(result.current[0]).toEqual({ model: 'gpt-3.5-turbo', provider: 'openai' }) }) @@ -191,7 +196,9 @@ describe('hooks', () => { model_type: ModelTypeEnum.textGeneration, } as DefaultModelResponse const modelList = createMockModelList() - const { result } = renderHook(() => useSystemDefaultModelAndModelList(defaultModel, modelList)) + const { result } = renderHook(() => + useSystemDefaultModelAndModelList(defaultModel, modelList), + ) expect(result.current[0]).toBeUndefined() }) @@ -199,7 +206,9 @@ describe('hooks', () => { it('should return undefined when model not found in provider', () => { const defaultModel = createMockDefaultModel('gpt-5') const modelList = createMockModelList() - const { result } = renderHook(() => useSystemDefaultModelAndModelList(defaultModel, modelList)) + const { result } = renderHook(() => + useSystemDefaultModelAndModelList(defaultModel, modelList), + ) expect(result.current[0]).toBeUndefined() }) @@ -207,7 +216,9 @@ describe('hooks', () => { it('should update default model state', () => { const defaultModel = createMockDefaultModel() const modelList = createMockModelList() - const { result } = renderHook(() => useSystemDefaultModelAndModelList(defaultModel, modelList)) + const { result } = renderHook(() => + useSystemDefaultModelAndModelList(defaultModel, modelList), + ) const newModel = { model: 'gpt-4', provider: 'openai' } act(() => { @@ -249,7 +260,7 @@ describe('hooks', () => { it('should fetch model list successfully', async () => { const refetch = vi.fn() - ; (useQuery as Mock).mockReturnValue({ + ;(useQuery as Mock).mockReturnValue({ data: { data: mockModelData }, isPending: false, refetch, @@ -261,7 +272,9 @@ describe('hooks', () => { expect(result.current.isLoading).toBe(false) // Coverage for queryFn - const queryCall = (useQuery as Mock).mock.calls.find(call => Array.isArray(call[0].queryKey) && call[0].queryKey[0] === 'model-list') + const queryCall = (useQuery as Mock).mock.calls.find( + (call) => Array.isArray(call[0].queryKey) && call[0].queryKey[0] === 'model-list', + ) if (queryCall) { await queryCall[0].queryFn() expect(fetchModelList).toHaveBeenCalled() @@ -269,7 +282,7 @@ describe('hooks', () => { }) it('should return empty array when data is undefined', () => { - (useQuery as Mock).mockReturnValue({ + ;(useQuery as Mock).mockReturnValue({ data: undefined, isPending: false, refetch: vi.fn(), @@ -281,7 +294,7 @@ describe('hooks', () => { }) it('should handle loading state', () => { - (useQuery as Mock).mockReturnValue({ + ;(useQuery as Mock).mockReturnValue({ data: undefined, isPending: true, refetch: vi.fn(), @@ -294,7 +307,7 @@ describe('hooks', () => { it('should call mutate to refetch data', () => { const refetch = vi.fn() - ; (useQuery as Mock).mockReturnValue({ + ;(useQuery as Mock).mockReturnValue({ data: { data: mockModelData }, isPending: false, refetch, @@ -310,7 +323,7 @@ describe('hooks', () => { }) it('should work with different model types', () => { - (useQuery as Mock).mockReturnValue({ + ;(useQuery as Mock).mockReturnValue({ data: { data: [] }, isPending: false, refetch: vi.fn(), @@ -335,7 +348,7 @@ describe('hooks', () => { it('should fetch default model successfully', async () => { const refetch = vi.fn() - ; (useQuery as Mock).mockReturnValue({ + ;(useQuery as Mock).mockReturnValue({ data: { data: mockDefaultModel }, isPending: false, refetch, @@ -347,7 +360,9 @@ describe('hooks', () => { expect(result.current.isLoading).toBe(false) // Coverage for queryFn - const queryCall = (useQuery as Mock).mock.calls.find(call => Array.isArray(call[0].queryKey) && call[0].queryKey[0] === 'default-model') + const queryCall = (useQuery as Mock).mock.calls.find( + (call) => Array.isArray(call[0].queryKey) && call[0].queryKey[0] === 'default-model', + ) if (queryCall) { await queryCall[0].queryFn() expect(fetchDefaultModal).toHaveBeenCalled() @@ -355,7 +370,7 @@ describe('hooks', () => { }) it('should return undefined when data is not available', () => { - (useQuery as Mock).mockReturnValue({ + ;(useQuery as Mock).mockReturnValue({ data: undefined, isPending: false, refetch: vi.fn(), @@ -367,7 +382,7 @@ describe('hooks', () => { }) it('should handle loading state', () => { - (useQuery as Mock).mockReturnValue({ + ;(useQuery as Mock).mockReturnValue({ data: undefined, isPending: true, refetch: vi.fn(), @@ -380,7 +395,7 @@ describe('hooks', () => { it('should call mutate to refetch data', () => { const refetch = vi.fn() - ; (useQuery as Mock).mockReturnValue({ + ;(useQuery as Mock).mockReturnValue({ data: { data: mockDefaultModel }, isPending: false, refetch, @@ -397,32 +412,34 @@ describe('hooks', () => { }) describe('useCurrentProviderAndModel', () => { - const createModelList = (): Model[] => [{ - provider: 'openai', - icon_small: { en_US: 'icon', zh_Hans: 'icon' }, - label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - models: [ - { - model: 'gpt-3.5-turbo', - label: { en_US: 'GPT-3.5', zh_Hans: 'GPT-3.5' }, - model_type: ModelTypeEnum.textGeneration, - fetch_from: ConfigurationMethodEnum.predefinedModel, - status: ModelStatusEnum.active, - model_properties: {}, - load_balancing_enabled: false, - }, - { - model: 'gpt-4', - label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, - model_type: ModelTypeEnum.textGeneration, - fetch_from: ConfigurationMethodEnum.predefinedModel, - status: ModelStatusEnum.active, - model_properties: {}, - load_balancing_enabled: false, - }, - ], - status: ModelStatusEnum.active, - }] + const createModelList = (): Model[] => [ + { + provider: 'openai', + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [ + { + model: 'gpt-3.5-turbo', + label: { en_US: 'GPT-3.5', zh_Hans: 'GPT-3.5' }, + model_type: ModelTypeEnum.textGeneration, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + }, + { + model: 'gpt-4', + label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, + model_type: ModelTypeEnum.textGeneration, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + }, + ], + status: ModelStatusEnum.active, + }, + ] it('should find current provider and model', () => { const modelList = createModelList() @@ -479,42 +496,48 @@ describe('hooks', () => { provider: 'openai', icon_small: { en_US: 'icon', zh_Hans: 'icon' }, label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - models: [{ - model: 'gpt-4', - label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, - model_type: ModelTypeEnum.textGeneration, - fetch_from: ConfigurationMethodEnum.predefinedModel, - status: ModelStatusEnum.active, - model_properties: {}, - load_balancing_enabled: false, - }], + models: [ + { + model: 'gpt-4', + label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, + model_type: ModelTypeEnum.textGeneration, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + }, + ], status: ModelStatusEnum.active, }, { provider: 'anthropic', icon_small: { en_US: 'icon', zh_Hans: 'icon' }, label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' }, - models: [{ - model: 'claude-3', - label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' }, - model_type: ModelTypeEnum.textGeneration, - fetch_from: ConfigurationMethodEnum.predefinedModel, - status: ModelStatusEnum.disabled, - model_properties: {}, - load_balancing_enabled: false, - }], + models: [ + { + model: 'claude-3', + label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' }, + model_type: ModelTypeEnum.textGeneration, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.disabled, + model_properties: {}, + load_balancing_enabled: false, + }, + ], status: ModelStatusEnum.disabled, }, ] it('should return all text generation model lists', () => { const modelList = createModelList() - ; (useProviderContext as Mock).mockReturnValue({ + ;(useProviderContext as Mock).mockReturnValue({ textGenerationModelList: modelList, }) const defaultModel = { provider: 'openai', model: 'gpt-4' } - const { result } = renderHook(() => useTextGenerationCurrentProviderAndModelAndModelList(defaultModel)) + const { result } = renderHook(() => + useTextGenerationCurrentProviderAndModelAndModelList(defaultModel), + ) expect(result.current.textGenerationModelList).toEqual(modelList) expect(result.current.activeTextGenerationModelList).toHaveLength(1) @@ -523,7 +546,7 @@ describe('hooks', () => { it('should filter active models correctly', () => { const modelList = createModelList() - ; (useProviderContext as Mock).mockReturnValue({ + ;(useProviderContext as Mock).mockReturnValue({ textGenerationModelList: modelList, }) @@ -535,19 +558,21 @@ describe('hooks', () => { it('should find current provider and model', () => { const modelList = createModelList() - ; (useProviderContext as Mock).mockReturnValue({ + ;(useProviderContext as Mock).mockReturnValue({ textGenerationModelList: modelList, }) const defaultModel = { provider: 'openai', model: 'gpt-4' } - const { result } = renderHook(() => useTextGenerationCurrentProviderAndModelAndModelList(defaultModel)) + const { result } = renderHook(() => + useTextGenerationCurrentProviderAndModelAndModelList(defaultModel), + ) expect(result.current.currentProvider?.provider).toBe('openai') expect(result.current.currentModel?.model).toBe('gpt-4') }) it('should handle empty model list', () => { - ; (useProviderContext as Mock).mockReturnValue({ + ;(useProviderContext as Mock).mockReturnValue({ textGenerationModelList: [], }) @@ -562,10 +587,13 @@ describe('hooks', () => { it('should return both model list and default model', () => { const mockModelData = [{ provider: 'openai', models: [] }] const mockDefaultModel = { model: 'gpt-4', provider: { provider: 'openai' } } - - ; (useQuery as Mock) + ;(useQuery as Mock) .mockReturnValueOnce({ data: { data: mockModelData }, isPending: false, refetch: vi.fn() }) - .mockReturnValueOnce({ data: { data: mockDefaultModel }, isPending: false, refetch: vi.fn() }) + .mockReturnValueOnce({ + data: { data: mockDefaultModel }, + isPending: false, + refetch: vi.fn(), + }) const { result } = renderHook(() => useModelListAndDefaultModel(ModelTypeEnum.textGeneration)) @@ -574,7 +602,7 @@ describe('hooks', () => { }) it('should handle undefined values', () => { - ; (useQuery as Mock) + ;(useQuery as Mock) .mockReturnValueOnce({ data: undefined, isPending: false, refetch: vi.fn() }) .mockReturnValueOnce({ data: undefined, isPending: false, refetch: vi.fn() }) @@ -587,32 +615,41 @@ describe('hooks', () => { describe('useModelListAndDefaultModelAndCurrentProviderAndModel', () => { it('should return complete data structure', () => { - const mockModelData = [{ - provider: 'openai', - icon_small: { en_US: 'icon', zh_Hans: 'icon' }, - label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - models: [{ - model: 'gpt-4', - label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, - model_type: ModelTypeEnum.textGeneration, - fetch_from: ConfigurationMethodEnum.predefinedModel, + const mockModelData = [ + { + provider: 'openai', + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [ + { + model: 'gpt-4', + label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, + model_type: ModelTypeEnum.textGeneration, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + }, + ], status: ModelStatusEnum.active, - model_properties: {}, - load_balancing_enabled: false, - }], - status: ModelStatusEnum.active, - }] + }, + ] const mockDefaultModel = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration, provider: { provider: 'openai', icon_small: { en_US: 'icon', zh_Hans: 'icon' } }, } - - ; (useQuery as Mock) + ;(useQuery as Mock) .mockReturnValueOnce({ data: { data: mockModelData }, isPending: false, refetch: vi.fn() }) - .mockReturnValueOnce({ data: { data: mockDefaultModel }, isPending: false, refetch: vi.fn() }) - - const { result } = renderHook(() => useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)) + .mockReturnValueOnce({ + data: { data: mockDefaultModel }, + isPending: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => + useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration), + ) expect(result.current.modelList).toEqual(mockModelData) expect(result.current.defaultModel).toEqual(mockDefaultModel) @@ -621,17 +658,20 @@ describe('hooks', () => { }) it('should handle missing default model', () => { - const mockModelData = [{ - provider: 'openai', - models: [], - status: ModelStatusEnum.active, - }] - - ; (useQuery as Mock) + const mockModelData = [ + { + provider: 'openai', + models: [], + status: ModelStatusEnum.active, + }, + ] + ;(useQuery as Mock) .mockReturnValueOnce({ data: { data: mockModelData }, isPending: false, refetch: vi.fn() }) .mockReturnValueOnce({ data: undefined, isPending: false, refetch: vi.fn() }) - const { result } = renderHook(() => useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)) + const { result } = renderHook(() => + useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration), + ) expect(result.current.currentProvider).toBeUndefined() expect(result.current.currentModel).toBeUndefined() @@ -641,7 +681,7 @@ describe('hooks', () => { describe('useUpdateModelList', () => { it('should invalidate model list queries', () => { const invalidateQueries = vi.fn() - ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) const { result } = renderHook(() => useUpdateModelList()) @@ -656,7 +696,7 @@ describe('hooks', () => { it('should handle multiple model types', () => { const invalidateQueries = vi.fn() - ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) const { result } = renderHook(() => useUpdateModelList()) @@ -673,7 +713,7 @@ describe('hooks', () => { describe('useInvalidateDefaultModel', () => { it('should invalidate default model queries', () => { const invalidateQueries = vi.fn() - ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) const { result } = renderHook(() => useInvalidateDefaultModel()) @@ -688,7 +728,7 @@ describe('hooks', () => { it('should handle multiple model types', () => { const invalidateQueries = vi.fn() - ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) const { result } = renderHook(() => useInvalidateDefaultModel()) @@ -705,7 +745,7 @@ describe('hooks', () => { describe('useUpdateModelProviders', () => { it('should invalidate model providers queries', () => { const invalidateQueries = vi.fn() - ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) const { result } = renderHook(() => useUpdateModelProviders()) @@ -720,7 +760,7 @@ describe('hooks', () => { it('should be callable multiple times', () => { const invalidateQueries = vi.fn() - ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) const { result } = renderHook(() => useUpdateModelProviders()) @@ -735,40 +775,42 @@ describe('hooks', () => { }) describe('useMarketplaceAllPlugins', () => { - const createMockProviders = (): ModelProvider[] => [{ - provider: 'openai', - label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - icon_small: { en_US: 'icon', zh_Hans: 'icon' }, - supported_model_types: [ModelTypeEnum.textGeneration], - configurate_methods: [ConfigurationMethodEnum.predefinedModel], - provider_credential_schema: { credential_form_schemas: [] }, - model_credential_schema: { - model: { - label: { en_US: 'Model', zh_Hans: '模型' }, - placeholder: { en_US: 'Select model', zh_Hans: '选择模型' }, + const createMockProviders = (): ModelProvider[] => [ + { + provider: 'openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + supported_model_types: [ModelTypeEnum.textGeneration], + configurate_methods: [ConfigurationMethodEnum.predefinedModel], + provider_credential_schema: { credential_form_schemas: [] }, + model_credential_schema: { + model: { + label: { en_US: 'Model', zh_Hans: '模型' }, + placeholder: { en_US: 'Select model', zh_Hans: '选择模型' }, + }, + credential_form_schemas: [], }, - credential_form_schemas: [], - }, - preferred_provider_type: PreferredProviderTypeEnum.system, - custom_configuration: { - status: CustomConfigurationStatusEnum.noConfigure, - }, - system_configuration: { - enabled: true, - current_quota_type: CurrentSystemQuotaTypeEnum.trial, - quota_configurations: [], - }, - help: { - title: { - en_US: '', - zh_Hans: '', + preferred_provider_type: PreferredProviderTypeEnum.system, + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, }, - url: { - en_US: '', - zh_Hans: '', + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.trial, + quota_configurations: [], + }, + help: { + title: { + en_US: '', + zh_Hans: '', + }, + url: { + en_US: '', + zh_Hans: '', + }, }, }, - }] + ] const createMockPlugins = () => [ { plugin_id: 'plugin1', type: 'plugin' }, @@ -779,12 +821,11 @@ describe('hooks', () => { const providers = createMockProviders() const collectionPlugins = [{ plugin_id: 'collection1', type: 'plugin' }] const regularPlugins = createMockPlugins() - - ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ + ;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ plugins: collectionPlugins, isLoading: false, }) - ; (useMarketplacePlugins as Mock).mockReturnValue({ + ;(useMarketplacePlugins as Mock).mockReturnValue({ plugins: regularPlugins, queryPlugins: vi.fn(), queryPluginsWithDebounced: vi.fn(), @@ -803,12 +844,11 @@ describe('hooks', () => { { plugin_id: 'openai', type: 'plugin' }, { plugin_id: 'other', type: 'plugin' }, ] - - ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ + ;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ plugins: collectionPlugins, isLoading: false, }) - ; (useMarketplacePlugins as Mock).mockReturnValue({ + ;(useMarketplacePlugins as Mock).mockReturnValue({ plugins: [], queryPlugins: vi.fn(), queryPluginsWithDebounced: vi.fn(), @@ -823,13 +863,13 @@ describe('hooks', () => { it('should use search when searchText is provided', () => { const queryPluginsWithDebounced = vi.fn() - ; (useMarketplacePlugins as Mock).mockReturnValue({ + ;(useMarketplacePlugins as Mock).mockReturnValue({ plugins: [], queryPlugins: vi.fn(), queryPluginsWithDebounced, isLoading: false, }) - ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ + ;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ plugins: [], isLoading: false, }) @@ -844,12 +884,11 @@ describe('hooks', () => { { plugin_id: 'plugin1', type: 'plugin' }, { plugin_id: 'bundle1', type: 'bundle' }, ] - - ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ + ;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ plugins: [], isLoading: false, }) - ; (useMarketplacePlugins as Mock).mockReturnValue({ + ;(useMarketplacePlugins as Mock).mockReturnValue({ plugins, queryPlugins: vi.fn(), queryPluginsWithDebounced: vi.fn(), @@ -864,12 +903,11 @@ describe('hooks', () => { it('should deduplicate plugins that exist in both collections and regular plugins', () => { const duplicatePlugin = { plugin_id: 'shared-plugin', type: 'plugin' } - - ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ + ;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ plugins: [duplicatePlugin], isLoading: false, }) - ; (useMarketplacePlugins as Mock).mockReturnValue({ + ;(useMarketplacePlugins as Mock).mockReturnValue({ plugins: [{ ...duplicatePlugin }, { plugin_id: 'unique-plugin', type: 'plugin' }], queryPlugins: vi.fn(), queryPluginsWithDebounced: vi.fn(), @@ -879,15 +917,15 @@ describe('hooks', () => { const { result } = renderHook(() => useMarketplaceAllPlugins([], '')) expect(result.current.plugins).toHaveLength(2) - expect(result.current.plugins!.filter(p => p.plugin_id === 'shared-plugin')).toHaveLength(1) + expect(result.current.plugins!.filter((p) => p.plugin_id === 'shared-plugin')).toHaveLength(1) }) it('should handle loading states', () => { - ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ + ;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ plugins: [], isLoading: true, }) - ; (useMarketplacePlugins as Mock).mockReturnValue({ + ;(useMarketplacePlugins as Mock).mockReturnValue({ plugins: [], queryPlugins: vi.fn(), queryPluginsWithDebounced: vi.fn(), @@ -900,11 +938,11 @@ describe('hooks', () => { }) it('should not crash when plugins is undefined', () => { - ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ + ;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ plugins: [], isLoading: false, }) - ; (useMarketplacePlugins as Mock).mockReturnValue({ + ;(useMarketplacePlugins as Mock).mockReturnValue({ plugins: undefined, queryPlugins: vi.fn(), queryPluginsWithDebounced: vi.fn(), @@ -920,12 +958,11 @@ describe('hooks', () => { it('should return search plugins (not allPlugins) when searchText is truthy', () => { const searchPlugins = [{ plugin_id: 'search-result', type: 'plugin' }] const collectionPlugins = [{ plugin_id: 'collection-only', type: 'plugin' }] - - ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ + ;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ plugins: collectionPlugins, isLoading: false, }) - ; (useMarketplacePlugins as Mock).mockReturnValue({ + ;(useMarketplacePlugins as Mock).mockReturnValue({ plugins: searchPlugins, queryPlugins: vi.fn(), queryPluginsWithDebounced: vi.fn(), @@ -935,7 +972,7 @@ describe('hooks', () => { const { result } = renderHook(() => useMarketplaceAllPlugins([], 'openai')) expect(result.current.plugins).toEqual(searchPlugins) - expect(result.current.plugins?.some(p => p.plugin_id === 'collection-only')).toBe(false) + expect(result.current.plugins?.some((p) => p.plugin_id === 'collection-only')).toBe(false) }) it('should skip marketplace queries when disabled', () => { @@ -943,12 +980,11 @@ describe('hooks', () => { const queryPluginsWithDebounced = vi.fn() const cancelQueryPluginsWithDebounced = vi.fn() const resetPlugins = vi.fn() - - ; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ + ;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({ plugins: [{ plugin_id: 'collection-only', type: 'plugin' }], isLoading: true, }) - ; (useMarketplacePlugins as Mock).mockReturnValue({ + ;(useMarketplacePlugins as Mock).mockReturnValue({ plugins: [{ plugin_id: 'search-result', type: 'plugin' }], queryPlugins, queryPluginsWithDebounced, @@ -1007,17 +1043,17 @@ describe('hooks', () => { it('should refresh providers and model lists', () => { const invalidateQueries = vi.fn() - - ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) const provider = createMockProvider() - const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ - input: { - params: { - provider: provider.provider, + const modelProviderModelListQueryKey = + consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ + input: { + params: { + provider: provider.provider, + }, }, - }, - }) + }) const { result } = renderHook(() => useRefreshModel()) act(() => { @@ -1030,29 +1066,33 @@ describe('hooks', () => { refetchType: 'none', }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-providers'] }) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textGeneration] }) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textEmbedding] }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['model-list', ModelTypeEnum.textGeneration], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['model-list', ModelTypeEnum.textEmbedding], + }) }) it('should expand target provider list when refreshModelList is true and custom config is active', () => { const invalidateQueries = vi.fn() const expandModelProviderList = vi.fn() - - ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) - ; (useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList) + ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ;(useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList) const provider = createMockProvider() const customFields: CustomConfigurationModelFixedFields = { __model_name: 'gpt-4', __model_type: ModelTypeEnum.textGeneration, } - const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ - input: { - params: { - provider: provider.provider, + const modelProviderModelListQueryKey = + consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ + input: { + params: { + provider: provider.provider, + }, }, - }, - }) + }) const { result } = renderHook(() => useRefreshModel()) @@ -1066,24 +1106,29 @@ describe('hooks', () => { exact: true, refetchType: 'active', }) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textGeneration] }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['model-list', ModelTypeEnum.textGeneration], + }) }) it('should not expand provider list when custom config is not active', () => { const invalidateQueries = vi.fn() const expandModelProviderList = vi.fn() + ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ;(useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList) - ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) - ; (useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList) - - const provider = { ...createMockProvider(), custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure } } - const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ - input: { - params: { - provider: provider.provider, + const provider = { + ...createMockProvider(), + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + } + const modelProviderModelListQueryKey = + consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ + input: { + params: { + provider: provider.provider, + }, }, - }, - }) + }) const { result } = renderHook(() => useRefreshModel()) @@ -1101,16 +1146,17 @@ describe('hooks', () => { it('should refetch active model provider list when custom refresh callback is absent', () => { const invalidateQueries = vi.fn() - ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) const provider = createMockProvider() - const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ - input: { - params: { - provider: provider.provider, + const modelProviderModelListQueryKey = + consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ + input: { + params: { + provider: provider.provider, + }, }, - }, - }) + }) const { result } = renderHook(() => useRefreshModel()) act(() => { @@ -1126,11 +1172,13 @@ describe('hooks', () => { it('should invalidate all supported model types when __model_type is undefined', () => { const invalidateQueries = vi.fn() - - ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) const provider = createMockProvider() - const customFields = { __model_name: 'my-model', __model_type: undefined } as unknown as CustomConfigurationModelFixedFields + const customFields = { + __model_name: 'my-model', + __model_type: undefined, + } as unknown as CustomConfigurationModelFixedFields const { result } = renderHook(() => useRefreshModel()) @@ -1140,15 +1188,14 @@ describe('hooks', () => { // When __model_type is undefined, all supported model types are invalidated. const modelListCalls = invalidateQueries.mock.calls.filter( - call => call[0]?.queryKey?.[0] === 'model-list', + (call) => call[0]?.queryKey?.[0] === 'model-list', ) expect(modelListCalls).toHaveLength(provider.supported_model_types.length) }) it('should handle provider with single model type', () => { const invalidateQueries = vi.fn() - - ; (useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) const provider = { ...createMockProvider(), @@ -1162,8 +1209,12 @@ describe('hooks', () => { }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-providers'] }) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textGeneration] }) - expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textEmbedding] }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['model-list', ModelTypeEnum.textGeneration], + }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: ['model-list', ModelTypeEnum.textEmbedding], + }) }) }) @@ -1205,7 +1256,7 @@ describe('hooks', () => { it('should open model modal with basic configuration', () => { const setShowModelModal = vi.fn() - ; (useModalContextSelector as Mock).mockReturnValue(setShowModelModal) + ;(useModalContextSelector as Mock).mockReturnValue(setShowModelModal) const provider = createMockProvider() const { result } = renderHook(() => useModelModalHandler()) @@ -1230,7 +1281,7 @@ describe('hooks', () => { it('should open model modal with custom configuration', () => { const setShowModelModal = vi.fn() - ; (useModalContextSelector as Mock).mockReturnValue(setShowModelModal) + ;(useModalContextSelector as Mock).mockReturnValue(setShowModelModal) const provider = createMockProvider() const customFields: CustomConfigurationModelFixedFields = { @@ -1260,7 +1311,7 @@ describe('hooks', () => { it('should open model modal with extra options', () => { const setShowModelModal = vi.fn() - ; (useModalContextSelector as Mock).mockReturnValue(setShowModelModal) + ;(useModalContextSelector as Mock).mockReturnValue(setShowModelModal) const provider = createMockProvider() const credential: Credential = { credential_id: 'cred-1' } @@ -1270,18 +1321,13 @@ describe('hooks', () => { const { result } = renderHook(() => useModelModalHandler()) act(() => { - result.current( - provider, - ConfigurationMethodEnum.predefinedModel, - undefined, - { - isModelCredential: true, - credential, - model, - onUpdate, - mode: ModelModalModeEnum.configProviderCredential, - }, - ) + result.current(provider, ConfigurationMethodEnum.predefinedModel, undefined, { + isModelCredential: true, + credential, + model, + onUpdate, + mode: ModelModalModeEnum.configProviderCredential, + }) }) expect(setShowModelModal).toHaveBeenCalledWith({ @@ -1300,7 +1346,7 @@ describe('hooks', () => { it('should call onUpdate callback when modal is saved', () => { const setShowModelModal = vi.fn() - ; (useModalContextSelector as Mock).mockReturnValue(setShowModelModal) + ;(useModalContextSelector as Mock).mockReturnValue(setShowModelModal) const provider = createMockProvider() const onUpdate = vi.fn() @@ -1308,12 +1354,7 @@ describe('hooks', () => { const { result } = renderHook(() => useModelModalHandler()) act(() => { - result.current( - provider, - ConfigurationMethodEnum.predefinedModel, - undefined, - { onUpdate }, - ) + result.current(provider, ConfigurationMethodEnum.predefinedModel, undefined, { onUpdate }) }) const callArgs = setShowModelModal.mock.calls[0]![0] @@ -1329,7 +1370,7 @@ describe('hooks', () => { it('should handle modal without onUpdate callback', () => { const setShowModelModal = vi.fn() - ; (useModalContextSelector as Mock).mockReturnValue(setShowModelModal) + ;(useModalContextSelector as Mock).mockReturnValue(setShowModelModal) const provider = createMockProvider() diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx index 8896c4575c85d0..78c1ebd0c6ffcf 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx @@ -26,16 +26,18 @@ vi.mock('@/config', async (importOriginal) => { vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ - modelProviders: [{ - provider: 'openai', - label: { en_US: 'OpenAI' }, - custom_configuration: { status: CustomConfigurationStatusEnum.active }, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.free, - quota_configurations: [mockQuotaConfig], + modelProviders: [ + { + provider: 'openai', + label: { en_US: 'OpenAI' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, }, - }], + ], }), })) @@ -135,7 +137,15 @@ vi.mock('@/service/client', async (importOriginal) => { ids: { post: { queryOptions: () => ({ - queryKey: ['workspaces', 'current', 'plugin', 'list', 'installations', 'ids', 'post'], + queryKey: [ + 'workspaces', + 'current', + 'plugin', + 'list', + 'installations', + 'ids', + 'post', + ], queryFn: () => new Promise(() => {}), }), }, @@ -144,7 +154,14 @@ vi.mock('@/service/client', async (importOriginal) => { latestVersions: { post: { queryOptions: () => ({ - queryKey: ['workspaces', 'current', 'plugin', 'list', 'latestVersions', 'post'], + queryKey: [ + 'workspaces', + 'current', + 'plugin', + 'list', + 'latestVersions', + 'post', + ], queryFn: () => new Promise(() => {}), }), }, diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx index 742f576661adbe..b5b8165c8ed114 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx @@ -74,14 +74,11 @@ const renderModelProviderPage = ( } = {}, ) => { const { searchText = '', enableMarketplace = true, stickyToolbar = true } = props - return renderWithSystemFeatures(( - <ModelProviderPage - searchText={searchText} - stickyToolbar={stickyToolbar} - /> - ), { - systemFeatures: { enable_marketplace: enableMarketplace }, - }, + return renderWithSystemFeatures( + <ModelProviderPage searchText={searchText} stickyToolbar={stickyToolbar} />, + { + systemFeatures: { enable_marketplace: enableMarketplace }, + }, ) } @@ -89,7 +86,9 @@ const saveUpdateSettings = () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) } -const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ +const createPluginDeclaration = ( + overrides: Partial<PluginDeclaration> = {}, +): PluginDeclaration => ({ plugin_unique_identifier: 'langgenius/debug-model:1.0.0', version: '1.0.0', author: 'langgenius', @@ -184,12 +183,12 @@ vi.mock('@/context/provider-context', () => ({ }), })) -const mockDefaultModels: Record<string, { data: unknown, isLoading: boolean }> = { - 'llm': { data: null, isLoading: false }, +const mockDefaultModels: Record<string, { data: unknown; isLoading: boolean }> = { + llm: { data: null, isLoading: false }, 'text-embedding': { data: null, isLoading: false }, - 'rerank': { data: null, isLoading: false }, - 'speech2text': { data: null, isLoading: false }, - 'tts': { data: null, isLoading: false }, + rerank: { data: null, isLoading: false }, + speech2text: { data: null, isLoading: false }, + tts: { data: null, isLoading: false }, } vi.mock('../hooks', () => ({ @@ -209,7 +208,7 @@ vi.mock('../provider-added-card', () => ({ }: { notConfigured?: boolean provider: { provider: string } - pluginDetail?: { plugin_id: string, source?: string } + pluginDetail?: { plugin_id: string; source?: string } }) => ( <div data-testid="provider-card" @@ -227,7 +226,7 @@ vi.mock('../provider-added-card/quota-panel', () => ({ })) vi.mock('../system-model-selector', () => ({ - default: ({ className, notConfigured }: { className?: string, notConfigured?: boolean }) => ( + default: ({ className, notConfigured }: { className?: string; notConfigured?: boolean }) => ( <div data-testid="system-model-selector" data-not-configured={String(notConfigured)} @@ -287,8 +286,9 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({ })) vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof mockSetAccountSettingModal }) => unknown) => - selector({ setShowAccountSettingModal: mockSetAccountSettingModal }), + useModalContextSelector: ( + selector: (state: { setShowAccountSettingModal: typeof mockSetAccountSettingModal }) => unknown, + ) => selector({ setShowAccountSettingModal: mockSetAccountSettingModal }), })) vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({ @@ -298,8 +298,12 @@ vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({ renderTrigger, }: { value?: string | { format: (format: string) => string } - onChange: (value: { hour: () => number, minute: () => number }) => void - renderTrigger: (params: { inputElem: ReactNode, onClick: () => void, isOpen: boolean }) => ReactNode + onChange: (value: { hour: () => number; minute: () => number }) => void + renderTrigger: (params: { + inputElem: ReactNode + onClick: () => void + isOpen: boolean + }) => ReactNode }) => { const displayValue = typeof value === 'string' ? value : value?.format('HH:mm') @@ -312,10 +316,12 @@ vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({ })} <button type="button" - onClick={() => onChange({ - hour: () => 1, - minute: () => 15, - })} + onClick={() => + onChange({ + hour: () => 1, + minute: () => 15, + }) + } > set update time </button> @@ -344,7 +350,15 @@ vi.mock('@/service/client', async (importOriginal) => { ids: { post: { queryOptions: () => ({ - queryKey: ['workspaces', 'current', 'plugin', 'list', 'installations', 'ids', 'post'], + queryKey: [ + 'workspaces', + 'current', + 'plugin', + 'list', + 'installations', + 'ids', + 'post', + ], queryFn: () => new Promise(() => {}), }), }, @@ -353,7 +367,14 @@ vi.mock('@/service/client', async (importOriginal) => { latestVersions: { post: { queryOptions: () => ({ - queryKey: ['workspaces', 'current', 'plugin', 'list', 'latestVersions', 'post'], + queryKey: [ + 'workspaces', + 'current', + 'plugin', + 'list', + 'latestVersions', + 'post', + ], queryFn: () => new Promise(() => {}), }), }, @@ -388,25 +409,30 @@ describe('ModelProviderPage', () => { Object.keys(mockDefaultModels).forEach((key) => { mockDefaultModels[key] = { data: null, isLoading: false } }) - mockProviders.splice(0, mockProviders.length, { - provider: 'openai', - label: { en_US: 'OpenAI' }, - custom_configuration: { status: CustomConfigurationStatusEnum.active }, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.free, - quota_configurations: [mockQuotaConfig], + mockProviders.splice( + 0, + mockProviders.length, + { + provider: 'openai', + label: { en_US: 'OpenAI' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, }, - }, { - provider: 'anthropic', - label: { en_US: 'Anthropic' }, - custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.free, - quota_configurations: [mockQuotaConfig], + { + provider: 'anthropic', + label: { en_US: 'Anthropic' }, + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, }, - }) + ) }) afterEach(() => { @@ -420,7 +446,10 @@ describe('ModelProviderPage', () => { const systemModelSelector = screen.getByTestId('system-model-selector') expect(autoUpdateButton).toBeInTheDocument() expect(systemModelSelector).toBeInTheDocument() - expect(systemModelSelector.compareDocumentPosition(autoUpdateButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect( + systemModelSelector.compareDocumentPosition(autoUpdateButton) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument() }) @@ -429,7 +458,17 @@ describe('ModelProviderPage', () => { expect(container.firstElementChild).toHaveClass('relative') expect(container.firstElementChild).not.toHaveClass('-mt-2', 'pt-1') - expect(container.firstElementChild?.firstElementChild).toHaveClass('sticky', 'top-0', 'z-10', '-mx-6', 'mb-2', 'flex', 'bg-components-panel-bg', 'px-6', 'pb-2') + expect(container.firstElementChild?.firstElementChild).toHaveClass( + 'sticky', + 'top-0', + 'z-10', + '-mx-6', + 'mb-2', + 'flex', + 'bg-components-panel-bg', + 'px-6', + 'pb-2', + ) expect(container.firstElementChild?.firstElementChild).not.toHaveClass('mb-4') }) @@ -438,12 +477,16 @@ describe('ModelProviderPage', () => { expect(screen.getAllByText('plugin.autoUpdate.strategy.latest.name')[0]).toBeInTheDocument() expect(screen.getAllByTestId('update-setting-dialog')[0]).toBeInTheDocument() - expect(screen.getByRole('radiogroup', { name: 'plugin.autoUpdate.autoUpdate' })).toBeInTheDocument() + expect( + screen.getByRole('radiogroup', { name: 'plugin.autoUpdate.autoUpdate' }), + ).toBeInTheDocument() expect(screen.getByText('plugin.autoUpdate.scope')).toBeInTheDocument() expect(screen.getByText('plugin.autoUpdate.updateTime')).toBeInTheDocument() expect(screen.getByTestId('update-time-picker')).toBeInTheDocument() expect(screen.getByText('plugin.autoUpdate.changeTimezone')).toBeInTheDocument() - expect(screen.getByRole('radio', { name: 'plugin.autoUpdate.strategy.fixOnly.name' })).toBeInTheDocument() + expect( + screen.getByRole('radio', { name: 'plugin.autoUpdate.strategy.fixOnly.name' }), + ).toBeInTheDocument() }) it('should not expose editable update settings while backend auto-upgrade data is loading', () => { @@ -467,7 +510,9 @@ describe('ModelProviderPage', () => { renderModelProviderPage() expect(screen.getByText('common.api.actionFailed')).toBeInTheDocument() - expect(screen.queryByRole('radiogroup', { name: 'plugin.autoUpdate.autoUpdate' })).not.toBeInTheDocument() + expect( + screen.queryByRole('radiogroup', { name: 'plugin.autoUpdate.autoUpdate' }), + ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'common.operation.save' })).not.toBeInTheDocument() expect(mockSaveAutoUpgrade).not.toHaveBeenCalled() }) @@ -538,8 +583,13 @@ describe('ModelProviderPage', () => { renderModelProviderPage() - expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, { category: PluginCategoryEnum.model }) - expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-id', 'langgenius/openai') + expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, { + category: PluginCategoryEnum.model, + }) + expect(screen.getByTestId('provider-card')).toHaveAttribute( + 'data-plugin-id', + 'langgenius/openai', + ) expect(screen.queryByText('OpenAI Plugin')).not.toBeInTheDocument() }) @@ -549,7 +599,9 @@ describe('ModelProviderPage', () => { plugin_id: 'langgenius/debug-model', declaration: createPluginDeclaration({ label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'], - description: { en_US: 'Debug model provider' } as unknown as PluginDeclaration['description'], + description: { + en_US: 'Debug model provider', + } as unknown as PluginDeclaration['description'], }), }), ] @@ -558,7 +610,9 @@ describe('ModelProviderPage', () => { expect(screen.queryByText('Debug Model')).not.toBeInTheDocument() expect(screen.queryByText('langgenius/debug-model')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'plugin actions langgenius/debug-model' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'plugin actions langgenius/debug-model' }), + ).not.toBeInTheDocument() }) it('should refresh model providers once when a debugging model plugin is missing from providers', () => { @@ -610,8 +664,14 @@ describe('ModelProviderPage', () => { renderModelProviderPage() - expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-id', 'langgenius/openai') - expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-source', PluginSource.debugging) + expect(screen.getByTestId('provider-card')).toHaveAttribute( + 'data-plugin-id', + 'langgenius/openai', + ) + expect(screen.getByTestId('provider-card')).toHaveAttribute( + 'data-plugin-source', + PluginSource.debugging, + ) expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1) }) @@ -691,7 +751,11 @@ describe('ModelProviderPage', () => { it('should not show warning when some default models are set', () => { mockDefaultModels.llm = { - data: { model: 'gpt-4', model_type: 'llm', provider: { provider: 'openai', icon_small: { en_US: '' } } }, + data: { + model: 'gpt-4', + model_type: 'llm', + provider: { provider: 'openai', icon_small: { en_US: '' } }, + }, isLoading: false, } @@ -702,7 +766,11 @@ describe('ModelProviderPage', () => { it('should not show warning when all default models are configured', () => { const makeModel = (model: string, type: string) => ({ - data: { model, model_type: type, provider: { provider: 'openai', icon_small: { en_US: '' } } }, + data: { + model, + model_type: type, + provider: { provider: 'openai', icon_small: { en_US: '' } }, + }, isLoading: false, }) mockDefaultModels.llm = makeModel('gpt-4', 'llm') @@ -730,38 +798,44 @@ describe('ModelProviderPage', () => { }) it('should prioritize fixed providers in visible order', () => { - mockProviders.splice(0, mockProviders.length, { - provider: 'zeta-provider', - label: { en_US: 'Zeta Provider' }, - custom_configuration: { status: CustomConfigurationStatusEnum.active }, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.free, - quota_configurations: [mockQuotaConfig], + mockProviders.splice( + 0, + mockProviders.length, + { + provider: 'zeta-provider', + label: { en_US: 'Zeta Provider' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, }, - }, { - provider: 'langgenius/anthropic/anthropic', - label: { en_US: 'Anthropic Fixed' }, - custom_configuration: { status: CustomConfigurationStatusEnum.active }, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.free, - quota_configurations: [mockQuotaConfig], + { + provider: 'langgenius/anthropic/anthropic', + label: { en_US: 'Anthropic Fixed' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, }, - }, { - provider: 'langgenius/openai/openai', - label: { en_US: 'OpenAI Fixed' }, - custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, - system_configuration: { - enabled: true, - current_quota_type: CurrentSystemQuotaTypeEnum.free, - quota_configurations: [mockQuotaConfig], + { + provider: 'langgenius/openai/openai', + label: { en_US: 'OpenAI Fixed' }, + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, }, - }) + ) renderModelProviderPage() - const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent) + const renderedProviders = screen.getAllByTestId('provider-card').map((item) => item.textContent) expect(renderedProviders).toEqual([ 'langgenius/openai/openai', 'langgenius/anthropic/anthropic', @@ -771,43 +845,50 @@ describe('ModelProviderPage', () => { }) it('should prioritize debugging model plugins within their provider section', () => { - mockProviders.splice(0, mockProviders.length, { - provider: 'langgenius/openai/openai', - label: { en_US: 'OpenAI Fixed' }, - custom_configuration: { status: CustomConfigurationStatusEnum.active }, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.free, - quota_configurations: [mockQuotaConfig], + mockProviders.splice( + 0, + mockProviders.length, + { + provider: 'langgenius/openai/openai', + label: { en_US: 'OpenAI Fixed' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, }, - }, { - provider: 'zeta-provider', - label: { en_US: 'Zeta Provider' }, - custom_configuration: { status: CustomConfigurationStatusEnum.active }, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.free, - quota_configurations: [mockQuotaConfig], + { + provider: 'zeta-provider', + label: { en_US: 'Zeta Provider' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, }, - }, { - provider: 'langgenius/normal-model/normal-model', - label: { en_US: 'Normal Model' }, - custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.free, - quota_configurations: [mockQuotaConfig], + { + provider: 'langgenius/normal-model/normal-model', + label: { en_US: 'Normal Model' }, + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, }, - }, { - provider: 'langgenius/debug-model/debug-model', - label: { en_US: 'Debug Model' }, - custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.free, - quota_configurations: [mockQuotaConfig], + { + provider: 'langgenius/debug-model/debug-model', + label: { en_US: 'Debug Model' }, + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, }, - }) + ) mockInstalledModelPlugins.value = [ createPluginDetail({ plugin_id: 'langgenius/debug-model', @@ -821,7 +902,7 @@ describe('ModelProviderPage', () => { renderModelProviderPage() - const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent) + const renderedProviders = screen.getAllByTestId('provider-card').map((item) => item.textContent) expect(renderedProviders).toEqual([ 'langgenius/openai/openai', 'zeta-provider', diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/install-from-marketplace.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/install-from-marketplace.spec.tsx index 0741ca035cc201..b7b9c8928db192 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/install-from-marketplace.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/install-from-marketplace.spec.tsx @@ -1,23 +1,23 @@ import type { Mock } from 'vitest' import type { ModelProvider } from '../declarations' import { fireEvent, render, screen } from '@testing-library/react' - import { describe, expect, it, vi } from 'vitest' import { useMarketplaceAllPlugins } from '../hooks' import InstallFromMarketplace from '../install-from-marketplace' // Mock dependencies vi.mock('@/next/link', () => ({ - default: ({ children, href }: { children: React.ReactNode, href: string }) => <a href={href}>{children}</a>, + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + <a href={href}>{children}</a> + ), })) -vi.mock('@/utils/var', async importOriginal => ({ +vi.mock('@/utils/var', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/utils/var')>()), getMarketplaceUrl: (path = '', params?: Record<string, string | undefined>) => { const searchParams = new URLSearchParams() Object.entries(params ?? {}).forEach(([key, value]) => { - if (value !== undefined) - searchParams.append(key, value) + if (value !== undefined) searchParams.append(key, value) }) const query = searchParams.toString() return `https://marketplace.test${path}${query ? `?${query}` : ''}` @@ -37,9 +37,15 @@ vi.mock('@/app/components/base/loading', () => ({ })) vi.mock('@/app/components/plugins/marketplace/list', () => ({ - default: ({ plugins, cardRender }: { plugins: { plugin_id: string, name: string, type?: string }[], cardRender: (plugin: { plugin_id: string, name: string, type?: string }) => React.ReactNode }) => ( + default: ({ + plugins, + cardRender, + }: { + plugins: { plugin_id: string; name: string; type?: string }[] + cardRender: (plugin: { plugin_id: string; name: string; type?: string }) => React.ReactNode + }) => ( <div data-testid="plugin-list"> - {plugins.map(p => ( + {plugins.map((p) => ( <div key={p.plugin_id} data-testid="plugin-item"> {cardRender(p)} </div> @@ -92,7 +98,7 @@ describe('InstallFromMarketplace', () => { }) it('should show loading state', () => { - (useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({ + ;(useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({ plugins: [], isLoading: true, }) @@ -103,7 +109,7 @@ describe('InstallFromMarketplace', () => { }) it('should list plugins', () => { - (useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({ + ;(useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({ plugins: [{ plugin_id: '1', name: 'Plugin 1' }], isLoading: false, }) @@ -114,7 +120,7 @@ describe('InstallFromMarketplace', () => { }) it('should hide bundle plugins from the list', () => { - (useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({ + ;(useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({ plugins: [ { plugin_id: '1', name: 'Plugin 1', type: 'plugin' }, { plugin_id: '2', name: 'Bundle 1', type: 'bundle' }, @@ -130,17 +136,28 @@ describe('InstallFromMarketplace', () => { it('should render discovery link', () => { render(<InstallFromMarketplace providers={mockProviders} searchText="" />) - expect(screen.getByText('plugin.marketplace.difyMarketplace')).toHaveAttribute('href', 'https://marketplace.test/plugins/model?theme=light') + expect(screen.getByText('plugin.marketplace.difyMarketplace')).toHaveAttribute( + 'href', + 'https://marketplace.test/plugins/model?theme=light', + ) }) it('should use the marketplace callback action when provided', () => { const onOpenMarketplace = vi.fn() - render(<InstallFromMarketplace providers={mockProviders} searchText="" onOpenMarketplace={onOpenMarketplace} />) + render( + <InstallFromMarketplace + providers={mockProviders} + searchText="" + onOpenMarketplace={onOpenMarketplace} + />, + ) fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.difyMarketplace' })) expect(onOpenMarketplace).toHaveBeenCalledTimes(1) - expect(screen.queryByRole('link', { name: 'plugin.marketplace.difyMarketplace' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'plugin.marketplace.difyMarketplace' }), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/supports-credits.spec.ts b/web/app/components/header/account-setting/model-provider-page/__tests__/supports-credits.spec.ts index b8ed478a9305c1..3497d6bda8dbcb 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/supports-credits.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/supports-credits.spec.ts @@ -7,15 +7,16 @@ vi.mock('@/config', async (importOriginal) => { return { ...actual, IS_CLOUD_EDITION: true } }) -const makeProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({ - provider: 'langgenius/openai/openai', - system_configuration: { - enabled: true, - current_quota_type: CurrentSystemQuotaTypeEnum.trial, - quota_configurations: [], - }, - ...overrides, -} as ModelProvider) +const makeProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => + ({ + provider: 'langgenius/openai/openai', + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.trial, + quota_configurations: [], + }, + ...overrides, + }) as ModelProvider describe('providerSupportsCredits', () => { it('returns true when the provider is system-enabled and listed in trial_models', () => { @@ -27,13 +28,18 @@ describe('providerSupportsCredits', () => { }) it('returns false when system hosting is disabled', () => { - expect(providerSupportsCredits(makeProvider({ - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.trial, - quota_configurations: [], - }, - }), ['langgenius/openai/openai'])).toBe(false) + expect( + providerSupportsCredits( + makeProvider({ + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.trial, + quota_configurations: [], + }, + }), + ['langgenius/openai/openai'], + ), + ).toBe(false) }) it('returns false for an undefined provider', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/utils.spec.ts b/web/app/components/header/account-setting/model-provider-page/__tests__/utils.spec.ts index 53ecba31bdccf9..33f556cc9c2687 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/utils.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/utils.spec.ts @@ -1,8 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { - FormTypeEnum, - ModelTypeEnum, -} from '../declarations' +import { FormTypeEnum, ModelTypeEnum } from '../declarations' import { genModelNameFormSchema, genModelTypeFormSchema, diff --git a/web/app/components/header/account-setting/model-provider-page/atoms.ts b/web/app/components/header/account-setting/model-provider-page/atoms.ts index 66737c5eb28343..af53f66b54b213 100644 --- a/web/app/components/header/account-setting/model-provider-page/atoms.ts +++ b/web/app/components/header/account-setting/model-provider-page/atoms.ts @@ -6,17 +6,14 @@ const expandedAtom = atom<Record<string, boolean>>({}) export function useModelProviderListExpanded(providerName: string) { return useAtomValue( - useMemo( - () => selectAtom(expandedAtom, s => !!s[providerName]), - [providerName], - ), + useMemo(() => selectAtom(expandedAtom, (s) => !!s[providerName]), [providerName]), ) } export function useSetModelProviderListExpanded(providerName: string) { const set = useSetAtom(expandedAtom) return useCallback( - (expanded: boolean) => set(prev => ({ ...prev, [providerName]: expanded })), + (expanded: boolean) => set((prev) => ({ ...prev, [providerName]: expanded })), [providerName, set], ) } @@ -24,7 +21,7 @@ export function useSetModelProviderListExpanded(providerName: string) { export function useExpandModelProviderList() { const set = useSetAtom(expandedAtom) return useCallback( - (providerName: string) => set(prev => ({ ...prev, [providerName]: true })), + (providerName: string) => set((prev) => ({ ...prev, [providerName]: true })), [set], ) } diff --git a/web/app/components/header/account-setting/model-provider-page/declarations.ts b/web/app/components/header/account-setting/model-provider-page/declarations.ts index 36a05ba8a91496..3c3b38be4c472c 100644 --- a/web/app/components/header/account-setting/model-provider-page/declarations.ts +++ b/web/app/components/header/account-setting/model-provider-page/declarations.ts @@ -122,11 +122,24 @@ export type CredentialFormSchemaTextInput = CredentialFormSchemaBase & { type: string } } -export type CredentialFormSchemaNumberInput = CredentialFormSchemaBase & { min?: number, max?: number, placeholder?: TypeWithI18N } -export type CredentialFormSchemaSelect = CredentialFormSchemaBase & { options: FormOption[], placeholder?: TypeWithI18N } +export type CredentialFormSchemaNumberInput = CredentialFormSchemaBase & { + min?: number + max?: number + placeholder?: TypeWithI18N +} +export type CredentialFormSchemaSelect = CredentialFormSchemaBase & { + options: FormOption[] + placeholder?: TypeWithI18N +} export type CredentialFormSchemaRadio = CredentialFormSchemaBase & { options: FormOption[] } -export type CredentialFormSchemaSecretInput = CredentialFormSchemaBase & { placeholder?: TypeWithI18N } -export type CredentialFormSchema = CredentialFormSchemaTextInput | CredentialFormSchemaSelect | CredentialFormSchemaRadio | CredentialFormSchemaSecretInput +export type CredentialFormSchemaSecretInput = CredentialFormSchemaBase & { + placeholder?: TypeWithI18N +} +export type CredentialFormSchema = + | CredentialFormSchemaTextInput + | CredentialFormSchemaSelect + | CredentialFormSchemaRadio + | CredentialFormSchemaSecretInput export type ModelItem = { model: string diff --git a/web/app/components/header/account-setting/model-provider-page/derive-model-status.ts b/web/app/components/header/account-setting/model-provider-page/derive-model-status.ts index ce73d11548024e..9079dba81fdbd2 100644 --- a/web/app/components/header/account-setting/model-provider-page/derive-model-status.ts +++ b/web/app/components/header/account-setting/model-provider-page/derive-model-status.ts @@ -2,27 +2,27 @@ import type { Model, ModelItem, ModelProvider } from './declarations' import type { CredentialPanelState } from './provider-added-card/use-credential-panel-state' import { ModelStatusEnum } from './declarations' -type DerivedModelStatus - = | 'empty' - | 'active' - | 'configure-required' - | 'credits-exhausted' - | 'api-key-unavailable' - | 'disabled' - | 'incompatible' +type DerivedModelStatus = + | 'empty' + | 'active' + | 'configure-required' + | 'credits-exhausted' + | 'api-key-unavailable' + | 'disabled' + | 'incompatible' export const DERIVED_MODEL_STATUS_BADGE_I18N = { 'configure-required': 'modelProvider.selector.configureRequired', 'credits-exhausted': 'modelProvider.selector.creditsExhausted', 'api-key-unavailable': 'modelProvider.selector.apiKeyUnavailable', - 'disabled': 'modelProvider.selector.disabled', - 'incompatible': 'modelProvider.selector.incompatible', + disabled: 'modelProvider.selector.disabled', + incompatible: 'modelProvider.selector.incompatible', } as const satisfies Partial<Record<DerivedModelStatus, string>> export const DERIVED_MODEL_STATUS_TOOLTIP_I18N = { 'credits-exhausted': 'modelProvider.selector.creditsExhaustedTip', 'api-key-unavailable': 'modelProvider.selector.apiKeyUnavailableTip', - 'incompatible': 'modelProvider.selector.incompatibleTip', + incompatible: 'modelProvider.selector.incompatibleTip', } as const satisfies Partial<Record<DerivedModelStatus, string>> export const deriveModelStatus = ( @@ -32,27 +32,24 @@ export const deriveModelStatus = ( currentModel: ModelItem | undefined, credentialState: CredentialPanelState, ): DerivedModelStatus => { - if (!modelId || !providerName) - return 'empty' + if (!modelId || !providerName) return 'empty' - if (!currentModelProvider) - return 'incompatible' + if (!currentModelProvider) return 'incompatible' - const isCreditsExhaustedWithoutApiKey = credentialState.supportsCredits - && credentialState.isCreditsExhausted - && !credentialState.hasCredentials - const isCreditsPriorityExhausted = credentialState.priority === 'credits' - && credentialState.supportsCredits - && credentialState.isCreditsExhausted + const isCreditsExhaustedWithoutApiKey = + credentialState.supportsCredits && + credentialState.isCreditsExhausted && + !credentialState.hasCredentials + const isCreditsPriorityExhausted = + credentialState.priority === 'credits' && + credentialState.supportsCredits && + credentialState.isCreditsExhausted - if (isCreditsPriorityExhausted || isCreditsExhaustedWithoutApiKey) - return 'credits-exhausted' + if (isCreditsPriorityExhausted || isCreditsExhaustedWithoutApiKey) return 'credits-exhausted' - if (!currentModel) - return 'incompatible' + if (!currentModel) return 'incompatible' - if (credentialState.variant === 'api-unavailable') - return 'api-key-unavailable' + if (credentialState.variant === 'api-unavailable') return 'api-key-unavailable' switch (currentModel.status) { case ModelStatusEnum.active: diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.ts b/web/app/components/header/account-setting/model-provider-page/hooks.ts index 9f16da5d9d02df..b4a0ee813700bc 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.ts @@ -8,16 +8,10 @@ import type { Model, ModelModalModeEnum, ModelProvider, - ModelTypeEnum, } from './declarations' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { - useCallback, - useEffect, - useMemo, - useState, -} from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useMarketplacePlugins, useMarketplacePluginsByCollectionId, @@ -27,16 +21,10 @@ import { useLocale } from '@/context/i18n' import { useModalContextSelector } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { consoleQuery } from '@/service/client' -import { - fetchDefaultModal, - fetchModelList, -} from '@/service/common' +import { fetchDefaultModal, fetchModelList } from '@/service/common' import { commonQueryKeys } from '@/service/use-common' import { useExpandModelProviderList } from './atoms' -import { - CustomConfigurationStatusEnum, - ModelStatusEnum, -} from './declarations' +import { CustomConfigurationStatusEnum, ModelStatusEnum } from './declarations' type UseDefaultModelAndModelList = ( defaultModel: DefaultModelResponse | undefined, @@ -47,28 +35,37 @@ export const useSystemDefaultModelAndModelList: UseDefaultModelAndModelList = ( modelList, ) => { const currentDefaultModel = useMemo(() => { - const currentProvider = modelList.find(provider => provider.provider === defaultModel?.provider.provider) - const currentModel = currentProvider?.models.find(model => model.model === defaultModel?.model) - const currentDefaultModel = currentProvider && currentModel && { - model: currentModel.model, - provider: currentProvider.provider, - } + const currentProvider = modelList.find( + (provider) => provider.provider === defaultModel?.provider.provider, + ) + const currentModel = currentProvider?.models.find( + (model) => model.model === defaultModel?.model, + ) + const currentDefaultModel = currentProvider && + currentModel && { + model: currentModel.model, + provider: currentProvider.provider, + } return currentDefaultModel }, [defaultModel, modelList]) const currentDefaultModelKey = currentDefaultModel ? `${currentDefaultModel.provider}:${currentDefaultModel.model}` : '' - const [defaultModelState, setDefaultModelState] = useState<DefaultModel | undefined>(currentDefaultModel) + const [defaultModelState, setDefaultModelState] = useState<DefaultModel | undefined>( + currentDefaultModel, + ) const [defaultModelSourceKey, setDefaultModelSourceKey] = useState(currentDefaultModelKey) - const selectedDefaultModel = defaultModelSourceKey === currentDefaultModelKey - ? defaultModelState - : currentDefaultModel - - const handleDefaultModelChange = useCallback((model: DefaultModel) => { - setDefaultModelSourceKey(currentDefaultModelKey) - setDefaultModelState(model) - }, [currentDefaultModelKey]) + const selectedDefaultModel = + defaultModelSourceKey === currentDefaultModelKey ? defaultModelState : currentDefaultModel + + const handleDefaultModelChange = useCallback( + (model: DefaultModel) => { + setDefaultModelSourceKey(currentDefaultModelKey) + setDefaultModelState(model) + }, + [currentDefaultModelKey], + ) return [selectedDefaultModel, handleDefaultModelChange] } @@ -104,8 +101,8 @@ export const useDefaultModel = (type: ModelTypeEnum) => { } export const useCurrentProviderAndModel = (modelList: Model[], defaultModel?: DefaultModel) => { - const currentProvider = modelList.find(provider => provider.provider === defaultModel?.provider) - const currentModel = currentProvider?.models.find(model => model.model === defaultModel?.model) + const currentProvider = modelList.find((provider) => provider.provider === defaultModel?.provider) + const currentModel = currentProvider?.models.find((model) => model.model === defaultModel?.model) return { currentProvider, @@ -113,13 +110,17 @@ export const useCurrentProviderAndModel = (modelList: Model[], defaultModel?: De } } -export const useTextGenerationCurrentProviderAndModelAndModelList = (defaultModel?: DefaultModel) => { +export const useTextGenerationCurrentProviderAndModelAndModelList = ( + defaultModel?: DefaultModel, +) => { const { textGenerationModelList } = useProviderContext() - const activeTextGenerationModelList = textGenerationModelList.filter(model => model.status === ModelStatusEnum.active) - const { - currentProvider, - currentModel, - } = useCurrentProviderAndModel(textGenerationModelList, defaultModel) + const activeTextGenerationModelList = textGenerationModelList.filter( + (model) => model.status === ModelStatusEnum.active, + ) + const { currentProvider, currentModel } = useCurrentProviderAndModel( + textGenerationModelList, + defaultModel, + ) return { currentProvider, @@ -141,10 +142,10 @@ export const useModelListAndDefaultModel = (type: ModelTypeEnum) => { export const useModelListAndDefaultModelAndCurrentProviderAndModel = (type: ModelTypeEnum) => { const { modelList, defaultModel } = useModelListAndDefaultModel(type) - const { currentProvider, currentModel } = useCurrentProviderAndModel( - modelList, - { provider: defaultModel?.provider.provider || '', model: defaultModel?.model || '' }, - ) + const { currentProvider, currentModel } = useCurrentProviderAndModel(modelList, { + provider: defaultModel?.provider.provider || '', + model: defaultModel?.model || '', + }) return { modelList, @@ -157,9 +158,12 @@ export const useModelListAndDefaultModelAndCurrentProviderAndModel = (type: Mode export const useUpdateModelList = () => { const queryClient = useQueryClient() - const updateModelList = useCallback((type: ModelTypeEnum) => { - queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelList(type) }) - }, [queryClient]) + const updateModelList = useCallback( + (type: ModelTypeEnum) => { + queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelList(type) }) + }, + [queryClient], + ) return updateModelList } @@ -167,9 +171,12 @@ export const useUpdateModelList = () => { export const useInvalidateDefaultModel = () => { const queryClient = useQueryClient() - return useCallback((type: ModelTypeEnum) => { - queryClient.invalidateQueries({ queryKey: commonQueryKeys.defaultModel(type) }) - }, [queryClient]) + return useCallback( + (type: ModelTypeEnum) => { + queryClient.invalidateQueries({ queryKey: commonQueryKeys.defaultModel(type) }) + }, + [queryClient], + ) } export const useUpdateModelProviders = () => { const queryClient = useQueryClient() @@ -181,14 +188,16 @@ export const useUpdateModelProviders = () => { return updateModelProviders } -export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText: string, enabled = true) => { +export const useMarketplaceAllPlugins = ( + providers: ModelProvider[], + searchText: string, + enabled = true, +) => { const exclude = useMemo(() => { - return providers.map(provider => provider.provider.replace(/(.+)\/([^/]+)$/, '$1')) + return providers.map((provider) => provider.provider.replace(/(.+)\/([^/]+)$/, '$1')) }, [providers]) - const { - plugins: collectionPlugins = [], - isLoading: isCollectionLoading, - } = useMarketplacePluginsByCollectionId(enabled ? '__model-settings-pinned-models' : undefined) + const { plugins: collectionPlugins = [], isLoading: isCollectionLoading } = + useMarketplacePluginsByCollectionId(enabled ? '__model-settings-pinned-models' : undefined) const { plugins, queryPlugins, @@ -214,8 +223,7 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText: sort_by: 'install_count', sort_order: 'DESC', }) - } - else { + } else { queryPlugins({ query: '', category: PluginCategoryEnum.model, @@ -226,19 +234,26 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText: sort_order: 'DESC', }) } - }, [cancelQueryPluginsWithDebounced, enabled, queryPlugins, queryPluginsWithDebounced, resetPlugins, searchText, exclude]) + }, [ + cancelQueryPluginsWithDebounced, + enabled, + queryPlugins, + queryPluginsWithDebounced, + resetPlugins, + searchText, + exclude, + ]) const allPlugins = useMemo(() => { - if (!enabled) - return [] + if (!enabled) return [] - const allPlugins = collectionPlugins.filter(plugin => !exclude.includes(plugin.plugin_id)) + const allPlugins = collectionPlugins.filter((plugin) => !exclude.includes(plugin.plugin_id)) if (plugins?.length) { for (let i = 0; i < plugins.length; i++) { const plugin = plugins[i] - if (plugin!.type !== 'bundle' && !allPlugins.find(p => p.plugin_id === plugin!.plugin_id)) + if (plugin!.type !== 'bundle' && !allPlugins.find((p) => p.plugin_id === plugin!.plugin_id)) allPlugins.push(plugin!) } } @@ -257,42 +272,49 @@ export const useRefreshModel = () => { const queryClient = useQueryClient() const updateModelProviders = useUpdateModelProviders() const updateModelList = useUpdateModelList() - const handleRefreshModel = useCallback(( - provider: ModelProvider, - CustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields, - refreshModelList?: boolean, - ) => { - const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ - input: { - params: { - provider: provider.provider, - }, - }, - }) - queryClient.invalidateQueries({ - queryKey: modelProviderModelListQueryKey, - exact: true, - refetchType: 'none', - }) - - updateModelProviders() - - provider.supported_model_types.forEach((type) => { - updateModelList(type) - }) - - if (refreshModelList && provider.custom_configuration.status === CustomConfigurationStatusEnum.active) { - expandModelProviderList(provider.provider) + const handleRefreshModel = useCallback( + ( + provider: ModelProvider, + CustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields, + refreshModelList?: boolean, + ) => { + const modelProviderModelListQueryKey = + consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ + input: { + params: { + provider: provider.provider, + }, + }, + }) queryClient.invalidateQueries({ queryKey: modelProviderModelListQueryKey, exact: true, - refetchType: 'active', + refetchType: 'none', }) - if (CustomConfigurationModelFixedFields?.__model_type) - updateModelList(CustomConfigurationModelFixedFields.__model_type) - } - }, [expandModelProviderList, queryClient, updateModelList, updateModelProviders]) + updateModelProviders() + + provider.supported_model_types.forEach((type) => { + updateModelList(type) + }) + + if ( + refreshModelList && + provider.custom_configuration.status === CustomConfigurationStatusEnum.active + ) { + expandModelProviderList(provider.provider) + queryClient.invalidateQueries({ + queryKey: modelProviderModelListQueryKey, + exact: true, + refetchType: 'active', + }) + + if (CustomConfigurationModelFixedFields?.__model_type) + updateModelList(CustomConfigurationModelFixedFields.__model_type) + } + }, + [expandModelProviderList, queryClient, updateModelList, updateModelProviders], + ) return { handleRefreshModel, @@ -300,7 +322,7 @@ export const useRefreshModel = () => { } export const useModelModalHandler = () => { - const setShowModelModal = useModalContextSelector(state => state.setShowModelModal) + const setShowModelModal = useModalContextSelector((state) => state.setShowModelModal) return ( provider: ModelProvider, diff --git a/web/app/components/header/account-setting/model-provider-page/index.tsx b/web/app/components/header/account-setting/model-provider-page/index.tsx index deb9a35d5ad3c5..042078b5b0b0a1 100644 --- a/web/app/components/header/account-setting/model-provider-page/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/index.tsx @@ -1,7 +1,5 @@ import type { ReactNode } from 'react' -import type { - ModelProvider, -} from './declarations' +import type { ModelProvider } from './declarations' import type { PluginDetail } from '@/app/components/plugins/types' import { useSuspenseQuery } from '@tanstack/react-query' import { useDebounce } from 'ahooks' @@ -16,20 +14,19 @@ import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useInstalledPluginList } from '@/service/use-plugins' import UpdateSettingDialog from '../update-setting-dialog' -import { - CustomConfigurationStatusEnum, - ModelTypeEnum, -} from './declarations' -import { - useDefaultModel, -} from './hooks' +import { CustomConfigurationStatusEnum, ModelTypeEnum } from './declarations' +import { useDefaultModel } from './hooks' import ModelProviderPageBody from './model-provider-page-body' import SystemModelSelector from './system-model-selector' -type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured' +type SystemModelConfigStatus = + | 'no-provider' + | 'none-configured' + | 'partially-configured' + | 'fully-configured' type Props = Readonly<{ - layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode + layout?: (parts: { body: ReactNode; toolbar: ReactNode }) => ReactNode onOpenMarketplace?: () => void onSearchTextChange?: (value: string) => void searchText: string @@ -49,15 +46,24 @@ const ModelProviderPage = ({ }: Props) => { const debouncedSearchText = useDebounce(searchText, { wait: 500 }) const { t } = useTranslation() + const { canSetPluginPreferences } = usePluginSettingsAccess() + const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = + useDefaultModel(ModelTypeEnum.textGeneration) + const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = + useDefaultModel(ModelTypeEnum.textEmbedding) + const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel( + ModelTypeEnum.rerank, + ) + const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = + useDefaultModel(ModelTypeEnum.speech2text) + const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel( + ModelTypeEnum.tts, + ) const { - canSetPluginPreferences, - } = usePluginSettingsAccess() - const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration) - const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding) - const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank) - const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text) - const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts) - const { modelProviders: providers, isLoadingModelProviders, refreshModelProviders } = useProviderContext() + modelProviders: providers, + isLoadingModelProviders, + refreshModelProviders, + } = useProviderContext() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: installedModelPlugins } = useInstalledPluginList(false, 100, { @@ -75,8 +81,8 @@ const ModelProviderPage = ({ }, [enrichedPlugins]) const debuggingModelPluginKey = useMemo(() => { const debuggingModelPluginIds = enrichedPlugins - .filter(plugin => plugin.source === PluginSource.debugging) - .map(plugin => `${plugin.plugin_id}:${plugin.plugin_unique_identifier}`) + .filter((plugin) => plugin.source === PluginSource.debugging) + .map((plugin) => `${plugin.plugin_id}:${plugin.plugin_unique_identifier}`) .sort() return debuggingModelPluginIds.join(',') @@ -88,44 +94,43 @@ const ModelProviderPage = ({ return } - if (refreshedDebuggingModelPluginKeyRef.current === debuggingModelPluginKey) - return + if (refreshedDebuggingModelPluginKeyRef.current === debuggingModelPluginKey) return refreshedDebuggingModelPluginKeyRef.current = debuggingModelPluginKey refreshModelProviders?.() }, [debuggingModelPluginKey, refreshModelProviders]) const enableMarketplace = systemFeatures.enable_marketplace - const isDefaultModelLoading = isTextGenerationDefaultModelLoading - || isEmbeddingsDefaultModelLoading - || isRerankDefaultModelLoading - || isSpeech2textDefaultModelLoading - || isTTSDefaultModelLoading + const isDefaultModelLoading = + isTextGenerationDefaultModelLoading || + isEmbeddingsDefaultModelLoading || + isRerankDefaultModelLoading || + isSpeech2textDefaultModelLoading || + isTTSDefaultModelLoading const [configuredProviders, notConfiguredProviders] = useMemo(() => { const configuredProviders: ModelProvider[] = [] const notConfiguredProviders: ModelProvider[] = [] providers.forEach((provider) => { if ( - provider.custom_configuration.status === CustomConfigurationStatusEnum.active - || ( - provider.system_configuration.enabled === true - && provider.system_configuration.quota_configurations.some(item => item.quota_type === provider.system_configuration.current_quota_type) - ) + provider.custom_configuration.status === CustomConfigurationStatusEnum.active || + (provider.system_configuration.enabled === true && + provider.system_configuration.quota_configurations.some( + (item) => item.quota_type === provider.system_configuration.current_quota_type, + )) ) { configuredProviders.push(provider) - } - else { + } else { notConfiguredProviders.push(provider) } }) configuredProviders.sort((a, b) => { if (FixedModelProvider.includes(a.provider) && FixedModelProvider.includes(b.provider)) - return FixedModelProvider.indexOf(a.provider) - FixedModelProvider.indexOf(b.provider) > 0 ? 1 : -1 - else if (FixedModelProvider.includes(a.provider)) - return -1 - else if (FixedModelProvider.includes(b.provider)) - return 1 + return FixedModelProvider.indexOf(a.provider) - FixedModelProvider.indexOf(b.provider) > 0 + ? 1 + : -1 + else if (FixedModelProvider.includes(a.provider)) return -1 + else if (FixedModelProvider.includes(b.provider)) return 1 return 0 }) @@ -133,18 +138,28 @@ const ModelProviderPage = ({ }, [providers]) const systemModelConfigStatus: SystemModelConfigStatus = useMemo(() => { - const defaultModels = [textGenerationDefaultModel, embeddingsDefaultModel, rerankDefaultModel, speech2textDefaultModel, ttsDefaultModel] + const defaultModels = [ + textGenerationDefaultModel, + embeddingsDefaultModel, + rerankDefaultModel, + speech2textDefaultModel, + ttsDefaultModel, + ] const configuredCount = defaultModels.filter(Boolean).length - if (configuredCount === 0 && configuredProviders.length === 0) - return 'no-provider' - if (configuredCount === 0) - return 'none-configured' - if (configuredCount < defaultModels.length) - return 'partially-configured' + if (configuredCount === 0 && configuredProviders.length === 0) return 'no-provider' + if (configuredCount === 0) return 'none-configured' + if (configuredCount < defaultModels.length) return 'partially-configured' return 'fully-configured' - }, [configuredProviders, textGenerationDefaultModel, embeddingsDefaultModel, rerankDefaultModel, speech2textDefaultModel, ttsDefaultModel]) - const warningTextKey - = systemModelConfigStatus === 'no-provider' || systemModelConfigStatus === 'none-configured' + }, [ + configuredProviders, + textGenerationDefaultModel, + embeddingsDefaultModel, + rerankDefaultModel, + speech2textDefaultModel, + ttsDefaultModel, + ]) + const warningTextKey = + systemModelConfigStatus === 'no-provider' || systemModelConfigStatus === 'none-configured' ? 'modelProvider.noneConfigured' : null const showWarning = !isLoadingModelProviders && !isDefaultModelLoading && !!warningTextKey @@ -165,55 +180,67 @@ const ModelProviderPage = ({ const [filteredConfiguredProviders, filteredNotConfiguredProviders] = useMemo(() => { const filteredConfiguredProviders = configuredProviders.filter( - provider => provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase()) - || Object.values(provider.label).some(text => text.toLowerCase().includes(debouncedSearchText.toLowerCase())), + (provider) => + provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase()) || + Object.values(provider.label).some((text) => + text.toLowerCase().includes(debouncedSearchText.toLowerCase()), + ), ) const filteredNotConfiguredProviders = notConfiguredProviders.filter( - provider => provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase()) - || Object.values(provider.label).some(text => text.toLowerCase().includes(debouncedSearchText.toLowerCase())), + (provider) => + provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase()) || + Object.values(provider.label).some((text) => + text.toLowerCase().includes(debouncedSearchText.toLowerCase()), + ), ) return [filteredConfiguredProviders, filteredNotConfiguredProviders] }, [configuredProviders, debouncedSearchText, notConfiguredProviders]) const showEmptyProvider = !isLoadingModelProviders && !configuredProviders.length const showConfiguredProviders = !isLoadingModelProviders && !!filteredConfiguredProviders?.length - const showNotConfiguredProviders = !isLoadingModelProviders && !!filteredNotConfiguredProviders?.length + const showNotConfiguredProviders = + !isLoadingModelProviders && !!filteredNotConfiguredProviders?.length const showMarketplace = !isLoadingModelProviders && enableMarketplace const toolbar = ( - <div className={stickyToolbar - ? layout - ? 'flex w-full items-center justify-between gap-3' - : 'sticky top-0 z-10 -mx-6 mb-2 flex items-center justify-between gap-3 bg-components-panel-bg px-6 pb-2' - : 'mb-2 flex items-center justify-between gap-3'} + <div + className={ + stickyToolbar + ? layout + ? 'flex w-full items-center justify-between gap-3' + : 'sticky top-0 z-10 -mx-6 mb-2 flex items-center justify-between gap-3 bg-components-panel-bg px-6 pb-2' + : 'mb-2 flex items-center justify-between gap-3' + } > <SearchInput className="w-50 shrink-0" - placeholder={t($ => $['modelProvider.searchModels'], { ns: 'common' })} + placeholder={t(($) => $['modelProvider.searchModels'], { ns: 'common' })} value={searchText} onValueChange={onSearchTextChange ?? noop} /> <div className="flex shrink-0 items-center justify-end gap-2"> - {showWarning - ? ( - <div className="relative inline-flex shrink-0 items-center gap-2 overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 pr-1 pl-2.5 shadow-xs backdrop-blur-[5px]"> - <div className="pointer-events-none absolute inset-[-1px] bg-[linear-gradient(119deg,rgba(247,144,9,0.25)_0%,rgba(255,255,255,0)_100%)] opacity-40" /> - <div className="relative flex shrink-0 items-center gap-1"> - <span aria-hidden className="i-ri-alert-fill size-4 shrink-0 text-text-warning-secondary" /> - <span className="shrink-0 system-sm-medium whitespace-nowrap text-text-primary" title={t($ => $[warningTextKey], { ns: 'common' })}> - {t($ => $[warningTextKey], { ns: 'common' })} - </span> - </div> - <div className="relative shrink-0"> - {systemModelSelector('h-6 px-1.5 text-xs font-medium')} - </div> - </div> - ) - : systemModelSelector('h-8 px-3 system-sm-medium')} - {canSetPluginPreferences && ( - <UpdateSettingDialog - category={PluginCategoryEnum.model} - /> + {showWarning ? ( + <div className="relative inline-flex shrink-0 items-center gap-2 overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 pr-1 pl-2.5 shadow-xs backdrop-blur-[5px]"> + <div className="pointer-events-none absolute inset-[-1px] bg-[linear-gradient(119deg,rgba(247,144,9,0.25)_0%,rgba(255,255,255,0)_100%)] opacity-40" /> + <div className="relative flex shrink-0 items-center gap-1"> + <span + aria-hidden + className="i-ri-alert-fill size-4 shrink-0 text-text-warning-secondary" + /> + <span + className="shrink-0 system-sm-medium whitespace-nowrap text-text-primary" + title={t(($) => $[warningTextKey], { ns: 'common' })} + > + {t(($) => $[warningTextKey], { ns: 'common' })} + </span> + </div> + <div className="relative shrink-0"> + {systemModelSelector('h-6 px-1.5 text-xs font-medium')} + </div> + </div> + ) : ( + systemModelSelector('h-8 px-3 system-sm-medium') )} + {canSetPluginPreferences && <UpdateSettingDialog category={PluginCategoryEnum.model} />} </div> </div> ) diff --git a/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx b/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx index 18d4351b7808ff..6e5fef8536904a 100644 --- a/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx +++ b/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx @@ -1,6 +1,4 @@ -import type { - ModelProvider, -} from './declarations' +import type { ModelProvider } from './declarations' import type { Plugin } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' import { useTheme } from 'next-themes' @@ -14,9 +12,7 @@ import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/us import ProviderCard from '@/app/components/plugins/provider-card' import { PluginCategoryEnum } from '@/app/components/plugins/types' import Link from '@/next/link' -import { - useMarketplaceAllPlugins, -} from './hooks' +import { useMarketplaceAllPlugins } from './hooks' type InstallFromMarketplaceProps = { onOpenMarketplace?: () => void @@ -32,14 +28,13 @@ const InstallFromMarketplace = ({ const { theme } = useTheme() const { canInstallPlugin } = usePluginSettingsAccess() const [collapse, setCollapse] = useState(false) - const { - plugins: allPlugins, - isLoading: isAllPluginsLoading, - } = useMarketplaceAllPlugins(providers, searchText) + const { plugins: allPlugins, isLoading: isAllPluginsLoading } = useMarketplaceAllPlugins( + providers, + searchText, + ) const cardRender = useCallback((plugin: Plugin) => { - if (plugin.type === 'bundle') - return null + if (plugin.type === 'bundle') return null return <ProviderCard key={plugin.plugin_id} className="h-[146px]" payload={plugin} /> }, []) @@ -51,52 +46,50 @@ const InstallFromMarketplace = ({ <button type="button" className="flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-left system-md-semibold text-text-primary" - onClick={() => setCollapse(prev => !prev)} + onClick={() => setCollapse((prev) => !prev)} aria-expanded={!collapse} > <span className={cn('i-ri-arrow-down-s-line size-4', collapse && '-rotate-90')} /> - {t($ => $['modelProvider.installProvider'], { ns: 'common' })} + {t(($) => $['modelProvider.installProvider'], { ns: 'common' })} </button> <div className="flex items-center gap-1"> - <span className="system-sm-regular text-text-tertiary">{t($ => $['modelProvider.discoverMore'], { ns: 'common' })}</span> - {onOpenMarketplace - ? ( - <button - type="button" - className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent" - onClick={onOpenMarketplace} - > - {t($ => $['marketplace.difyMarketplace'], { ns: 'plugin' })} - <span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" /> - </button> - ) - : ( - <Link - target="_blank" - rel="noopener noreferrer" - href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })} - className="inline-flex items-center system-sm-medium text-text-accent" - > - {t($ => $['marketplace.difyMarketplace'], { ns: 'plugin' })} - <span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" /> - </Link> - )} + <span className="system-sm-regular text-text-tertiary"> + {t(($) => $['modelProvider.discoverMore'], { ns: 'common' })} + </span> + {onOpenMarketplace ? ( + <button + type="button" + className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent" + onClick={onOpenMarketplace} + > + {t(($) => $['marketplace.difyMarketplace'], { ns: 'plugin' })} + <span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" /> + </button> + ) : ( + <Link + target="_blank" + rel="noopener noreferrer" + href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })} + className="inline-flex items-center system-sm-medium text-text-accent" + > + {t(($) => $['marketplace.difyMarketplace'], { ns: 'plugin' })} + <span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" /> + </Link> + )} </div> </div> {!collapse && isAllPluginsLoading && <Loading type="area" />} - { - !isAllPluginsLoading && !collapse && ( - <List - marketplaceCollections={[]} - marketplaceCollectionPluginsMap={{}} - plugins={allPlugins} - showInstallButton={canInstallPlugin} - cardContainerClassName="grid grid-cols-3 gap-2" - cardRender={cardRender} - emptyClassName="h-auto" - /> - ) - } + {!isAllPluginsLoading && !collapse && ( + <List + marketplaceCollections={[]} + marketplaceCollectionPluginsMap={{}} + plugins={allPlugins} + showInstallButton={canInstallPlugin} + cardContainerClassName="grid grid-cols-3 gap-2" + cardRender={cardRender} + emptyClassName="h-auto" + /> + )} </div> ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-credential-in-load-balancing.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-credential-in-load-balancing.spec.tsx index 88d482d9602170..08d0e54241ad50 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-credential-in-load-balancing.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-credential-in-load-balancing.spec.tsx @@ -1,6 +1,13 @@ -import type { CustomModel, ModelCredential, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + CustomModel, + ModelCredential, + ModelProvider, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { fireEvent, render, screen } from '@testing-library/react' -import { ConfigurationMethodEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + ConfigurationMethodEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import AddCredentialInLoadBalancing from '../add-credential-in-load-balancing' const mockWorkspacePermissionKeys = vi.hoisted(() => ({ @@ -39,7 +46,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -54,8 +62,8 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth' }: { renderTrigger: (open?: boolean) => React.ReactNode authParams?: { onUpdate?: (payload?: unknown, formValues?: Record<string, unknown>) => void } - items: Array<{ credentials: Array<{ credential_id: string, credential_name: string }> }> - onItemClick?: (credential: { credential_id: string, credential_name: string }) => void + items: Array<{ credentials: Array<{ credential_id: string; credential_name: string }> }> + onItemClick?: (credential: { credential_id: string; credential_name: string }) => void hideAddAction?: boolean triggerOnlyOpenModal?: boolean }) => ( @@ -65,7 +73,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth' data-trigger-only-open-modal={String(!!triggerOnlyOpenModal)} > {renderTrigger(false)} - <button onClick={() => authParams?.onUpdate?.({ provider: 'x' }, { key: 'value' })}>Run update</button> + <button onClick={() => authParams?.onUpdate?.({ provider: 'x' }, { key: 'value' })}> + Run update + </button> <button onClick={() => onItemClick?.(items[0]!.credentials[0]!)}>Select first</button> </div> ), @@ -83,9 +93,7 @@ describe('AddCredentialInLoadBalancing', () => { } as CustomModel const modelCredential = { - available_credentials: [ - { credential_id: 'cred-1', credential_name: 'Key 1' }, - ], + available_credentials: [{ credential_id: 'cred-1', credential_name: 'Key 1' }], credentials: {}, load_balancing: { enabled: false, configs: [] }, } as ModelCredential @@ -161,7 +169,10 @@ describe('AddCredentialInLoadBalancing', () => { expect(screen.getByText(/modelProvider.auth.addCredential/i))!.toBeInTheDocument() expect(screen.getByTestId('authorized-mock')).toHaveAttribute('data-hide-add-action', 'true') - expect(screen.getByTestId('authorized-mock')).toHaveAttribute('data-trigger-only-open-modal', 'false') + expect(screen.getByTestId('authorized-mock')).toHaveAttribute( + 'data-trigger-only-open-modal', + 'false', + ) }) it('should render nothing for manage-only users without existing credentials', () => { @@ -188,11 +199,7 @@ describe('AddCredentialInLoadBalancing', () => { // renderTrigger with open=true: bg-state-base-hover style applied it('should apply hover background when trigger is rendered with open=true', async () => { vi.doMock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ - Authorized: ({ - renderTrigger, - }: { - renderTrigger: (open?: boolean) => React.ReactNode - }) => ( + Authorized: ({ renderTrigger }: { renderTrigger: (open?: boolean) => React.ReactNode }) => ( <div data-testid="open-trigger">{renderTrigger(true)}</div> ), })) @@ -217,8 +224,7 @@ describe('AddCredentialInLoadBalancing', () => { const triggerDiv = container.querySelector('[data-testid="open-trigger"] > div') expect(triggerDiv)!.toBeInTheDocument() expect(triggerDiv!.className).toContain('bg-state-base-hover') - } - finally { + } finally { vi.doUnmock('@/app/components/header/account-setting/model-provider-page/model-auth') vi.resetModules() } diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx index 2101dad9e58fc0..bbdef9031e14d8 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx @@ -8,7 +8,12 @@ const mockHandleOpenModalForAddNewCustomModel = vi.fn() const mockHandleOpenModalForAddCustomModelToModelList = vi.fn() vi.mock('../hooks/use-auth', () => ({ - useAuth: (_provider: unknown, _configMethod: unknown, _fixedFields: unknown, options: { mode: string }) => { + useAuth: ( + _provider: unknown, + _configMethod: unknown, + _fixedFields: unknown, + options: { mode: string }, + ) => { if (options.mode === 'config-custom-model') { return { handleOpenModal: mockHandleOpenModalForAddNewCustomModel } } @@ -19,7 +24,7 @@ vi.mock('../hooks/use-auth', () => ({ }, })) -let mockCanAddedModels: { model: string, model_type: string }[] = [] +let mockCanAddedModels: { model: string; model_type: string }[] = [] vi.mock('../hooks/use-custom-models', () => ({ useCanAddedModels: () => mockCanAddedModels, })) @@ -60,7 +65,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -76,9 +82,7 @@ vi.mock('@remixicon/react', () => ({ vi.mock('@langgenius/dify-ui/tooltip', () => ({ Tooltip: ({ children }: { children: React.ReactNode }) => ( - <div data-testid="tooltip-mock"> - {children} - </div> + <div data-testid="tooltip-mock">{children}</div> ), TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>, TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/credential-selector.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/credential-selector.spec.tsx index 408465fbb2a6d6..63ac5d84e59cca 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/credential-selector.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/credential-selector.spec.tsx @@ -3,7 +3,13 @@ import userEvent from '@testing-library/user-event' import CredentialSelector from '../credential-selector' vi.mock('../authorized/credential-item', () => ({ - default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick?: (c: unknown) => void }) => ( + default: ({ + credential, + onItemClick, + }: { + credential: { credential_name: string } + onItemClick?: (c: unknown) => void + }) => ( <button type="button" onClick={() => onItemClick?.(credential)}> {credential.credential_name} </button> @@ -44,24 +50,14 @@ describe('CredentialSelector', () => { }) it('should render placeholder when selectedCredential is missing', () => { - render( - <CredentialSelector - credentials={mockCredentials} - onSelect={mockOnSelect} - />, - ) + render(<CredentialSelector credentials={mockCredentials} onSelect={mockOnSelect} />) expect(screen.getByText(/modelProvider.auth.selectModelCredential/)).toBeInTheDocument() }) it('should call onSelect when a credential item is clicked', async () => { const user = userEvent.setup() - render( - <CredentialSelector - credentials={mockCredentials} - onSelect={mockOnSelect} - />, - ) + render(<CredentialSelector credentials={mockCredentials} onSelect={mockOnSelect} />) await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/)) await user.click(screen.getByRole('button', { name: 'Key 2' })) @@ -71,31 +67,22 @@ describe('CredentialSelector', () => { it('should call onSelect with add-new payload when add action is clicked', async () => { const user = userEvent.setup() - render( - <CredentialSelector - credentials={mockCredentials} - onSelect={mockOnSelect} - />, - ) + render(<CredentialSelector credentials={mockCredentials} onSelect={mockOnSelect} />) await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/)) await user.click(screen.getByText(/modelProvider.auth.addNewModelCredential/)) - expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({ - credential_id: '__add_new_credential', - addNewCredential: true, - })) + expect(mockOnSelect).toHaveBeenCalledWith( + expect.objectContaining({ + credential_id: '__add_new_credential', + addNewCredential: true, + }), + ) }) it('should not open options when disabled is true', async () => { const user = userEvent.setup() - render( - <CredentialSelector - disabled - credentials={mockCredentials} - onSelect={mockOnSelect} - />, - ) + render(<CredentialSelector disabled credentials={mockCredentials} onSelect={mockOnSelect} />) await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/)) expect(screen.queryByRole('button', { name: 'Key 1' })).not.toBeInTheDocument() diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts index 7387234c67e44b..c18ed5c1eb60f4 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts @@ -6,7 +6,9 @@ vi.mock('../authorized', () => ({ default: 'Authorized' })) vi.mock('../config-model', () => ({ default: 'ConfigModel' })) vi.mock('../credential-selector', () => ({ default: 'CredentialSelector' })) vi.mock('../manage-custom-model-credentials', () => ({ default: 'ManageCustomModelCredentials' })) -vi.mock('../switch-credential-in-load-balancing', () => ({ default: 'SwitchCredentialInLoadBalancing' })) +vi.mock('../switch-credential-in-load-balancing', () => ({ + default: 'SwitchCredentialInLoadBalancing', +})) describe('model-auth index exports', () => { it('should re-export the model auth entry points', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/manage-custom-model-credentials.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/manage-custom-model-credentials.spec.tsx index 9758c502d8cbb6..4583efcc17b938 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/manage-custom-model-credentials.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/manage-custom-model-credentials.spec.tsx @@ -33,7 +33,9 @@ vi.mock('../authorized', () => ({ <div data-testid="items-selected"> {items.map((item, index) => ( <span - key={item.model?.model ?? item.selectedCredential?.credential_id ?? `missing-${popupTitle}`} + key={ + item.model?.model ?? item.selectedCredential?.credential_id ?? `missing-${popupTitle}` + } data-testid={`selected-${index}`} > {item.selectedCredential ? 'has-cred' : 'no-cred'} @@ -79,7 +81,9 @@ describe('ManageCustomModelCredentials', () => { expect(screen.getByTestId('authorized-mock')).toBeInTheDocument() expect(screen.getAllByText(/modelProvider.auth.manageCredentials/).length).toBeGreaterThan(0) expect(screen.getByTestId('items-count')).toHaveTextContent('2') - expect(screen.getByTestId('popup-title')).toHaveTextContent('modelProvider.auth.customModelCredentials') + expect(screen.getByTestId('popup-title')).toHaveTextContent( + 'modelProvider.auth.customModelCredentials', + ) }) it('should render trigger in both open and closed states', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx index 5e4a256f4ecefe..9c7f5736312265 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx @@ -1,4 +1,7 @@ -import type { CustomModel, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + CustomModel, + ModelProvider, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -40,7 +43,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -230,7 +234,11 @@ describe('SwitchCredentialInLoadBalancing', () => { // not_allowed_to_use=true: indicator is red and destructive button text is shown it('should show red indicator and unavailable button text when credential has not_allowed_to_use=true', () => { - const unavailableCredential = { credential_id: 'cred-1', credential_name: 'Key 1', not_allowed_to_use: true } + const unavailableCredential = { + credential_id: 'cred-1', + credential_name: 'Key 1', + not_allowed_to_use: true, + } render( <SwitchCredentialInLoadBalancing @@ -248,7 +256,11 @@ describe('SwitchCredentialInLoadBalancing', () => { // from_enterprise=true on the selected credential: Enterprise badge appears in the trigger it('should show Enterprise badge when selected credential has from_enterprise=true', () => { - const enterpriseCredential = { credential_id: 'cred-1', credential_name: 'Enterprise Key', from_enterprise: true } + const enterpriseCredential = { + credential_id: 'cred-1', + credential_name: 'Enterprise Key', + from_enterprise: true, + } render( <SwitchCredentialInLoadBalancing diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx index b3af6279b1e64a..cfda5547750697 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx @@ -6,12 +6,12 @@ import type { ModelProvider, } from '@/app/components/header/account-setting/model-provider-page/declarations' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { ConfigurationMethodEnum, ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + ConfigurationMethodEnum, + ModelModalModeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { Authorized } from '@/app/components/header/account-setting/model-provider-page/model-auth' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' @@ -36,33 +36,40 @@ const AddCredentialInLoadBalancing = ({ }: AddCredentialInLoadBalancingProps) => { const { t } = useTranslation() const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() - const { - available_credentials, - } = modelCredential - const canOpenCredentialMenu = canUseCredential || canCreateCredential || (canManageCredential && !!available_credentials?.length) + const { available_credentials } = modelCredential + const canOpenCredentialMenu = + canUseCredential || + canCreateCredential || + (canManageCredential && !!available_credentials?.length) const isCustomModel = configurationMethod === ConfigurationMethodEnum.customizableModel const notAllowCustomCredential = provider.allow_custom_token === false - const handleUpdate = useCallback((payload?: unknown, formValues?: Record<string, unknown>) => { - onUpdate?.(payload, formValues) - }, [onUpdate]) + const handleUpdate = useCallback( + (payload?: unknown, formValues?: Record<string, unknown>) => { + onUpdate?.(payload, formValues) + }, + [onUpdate], + ) - const renderTrigger = useCallback((open?: boolean) => { - const Item = ( - <div className={cn( - 'flex h-8 items-center rounded-lg px-3 system-sm-medium text-text-accent hover:bg-state-base-hover', - open && 'bg-state-base-hover', - )} - > - <span className="mr-2 i-ri-add-line size-4" /> - {t($ => $['modelProvider.auth.addCredential'], { ns: 'common' })} - </div> - ) + const renderTrigger = useCallback( + (open?: boolean) => { + const Item = ( + <div + className={cn( + 'flex h-8 items-center rounded-lg px-3 system-sm-medium text-text-accent hover:bg-state-base-hover', + open && 'bg-state-base-hover', + )} + > + <span className="mr-2 i-ri-add-line size-4" /> + {t(($) => $['modelProvider.auth.addCredential'], { ns: 'common' })} + </div> + ) - return Item - }, [t]) + return Item + }, + [t], + ) - if (!canOpenCredentialMenu) - return null + if (!canOpenCredentialMenu) return null return ( <Authorized @@ -74,26 +81,32 @@ const AddCredentialInLoadBalancing = ({ onUpdate: handleUpdate, onRemove, }} - triggerOnlyOpenModal={!available_credentials?.length && !notAllowCustomCredential && canCreateCredential} + triggerOnlyOpenModal={ + !available_credentials?.length && !notAllowCustomCredential && canCreateCredential + } items={[ { - title: isCustomModel ? '' : t($ => $['modelProvider.auth.apiKeys'], { ns: 'common' }), + title: isCustomModel ? '' : t(($) => $['modelProvider.auth.apiKeys'], { ns: 'common' }), model: isCustomModel ? model : undefined, credentials: available_credentials ?? [], }, ]} showModelTitle={!isCustomModel} configurationMethod={configurationMethod} - currentCustomConfigurationModelFixedFields={isCustomModel - ? { - __model_name: model.model, - __model_type: model.model_type, - } - : undefined} + currentCustomConfigurationModelFixedFields={ + isCustomModel + ? { + __model_name: model.model, + __model_type: model.model_type, + } + : undefined + } onItemClick={onSelectCredential} hideAddAction={!canCreateCredential} placement="bottom-start" - popupTitle={isCustomModel ? t($ => $['modelProvider.auth.modelCredentials'], { ns: 'common' }) : ''} + popupTitle={ + isCustomModel ? t(($) => $['modelProvider.auth.modelCredentials'], { ns: 'common' }) : '' + } /> ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx index 1f6635fb7fc3d4..1e2c5fbf2c2f42 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx @@ -3,25 +3,11 @@ import type { CustomConfigurationModelFixedFields, ModelProvider, } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { - Button, -} from '@langgenius/dify-ui/button' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' -import { - memo, - useCallback, - useState, -} from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' @@ -46,9 +32,7 @@ const AddCustomModel = ({ const canAddedModels = useCanAddedModels(provider) const noModels = !canAddedModels.length const { canUseCredential, canCreateCredential } = useCredentialPermissions() - const { - handleOpenModal: handleOpenModalForAddNewCustomModel, - } = useAuth( + const { handleOpenModal: handleOpenModalForAddNewCustomModel } = useAuth( provider, configurationMethod, currentCustomConfigurationModelFixedFields, @@ -57,9 +41,7 @@ const AddCustomModel = ({ mode: ModelModalModeEnum.configCustomModel, }, ) - const { - handleOpenModal: handleOpenModalForAddCustomModelToModelList, - } = useAuth( + const { handleOpenModal: handleOpenModalForAddCustomModelToModelList } = useAuth( provider, configurationMethod, currentCustomConfigurationModelFixedFields, @@ -69,46 +51,51 @@ const AddCustomModel = ({ }, ) const notAllowCustomCredential = provider.allow_custom_token === false - const renderTrigger = useCallback((open?: boolean, onClick?: () => void) => { - const disabled = noModels - ? !canCreateCredential - : !canUseCredential && !canCreateCredential - const item = ( - <Button - variant="ghost" - size="small" - onClick={onClick} - className={cn( - 'text-text-tertiary', - open && 'bg-components-button-ghost-bg-hover', - notAllowCustomCredential && !!noModels && 'cursor-not-allowed opacity-50', - disabled && 'cursor-not-allowed opacity-50', - )} - > - <span className="mr-1 i-ri-add-circle-fill size-3.5" /> - {t($ => $['modelProvider.addModel'], { ns: 'common' })} - </Button> - ) - if ((notAllowCustomCredential && !!noModels) || disabled) { - return ( - <Tooltip> - <TooltipTrigger render={item} /> - <TooltipContent>{t($ => $['auth.credentialUnavailable'], { ns: 'plugin' })}</TooltipContent> - </Tooltip> + const renderTrigger = useCallback( + (open?: boolean, onClick?: () => void) => { + const disabled = noModels ? !canCreateCredential : !canUseCredential && !canCreateCredential + const item = ( + <Button + variant="ghost" + size="small" + onClick={onClick} + className={cn( + 'text-text-tertiary', + open && 'bg-components-button-ghost-bg-hover', + notAllowCustomCredential && !!noModels && 'cursor-not-allowed opacity-50', + disabled && 'cursor-not-allowed opacity-50', + )} + > + <span className="mr-1 i-ri-add-circle-fill size-3.5" /> + {t(($) => $['modelProvider.addModel'], { ns: 'common' })} + </Button> ) - } - return item - }, [canCreateCredential, canUseCredential, t, notAllowCustomCredential, noModels]) + if ((notAllowCustomCredential && !!noModels) || disabled) { + return ( + <Tooltip> + <TooltipTrigger render={item} /> + <TooltipContent> + {t(($) => $['auth.credentialUnavailable'], { ns: 'plugin' })} + </TooltipContent> + </Tooltip> + ) + } + return item + }, + [canCreateCredential, canUseCredential, t, notAllowCustomCredential, noModels], + ) if (noModels) { - return renderTrigger(false, notAllowCustomCredential || !canCreateCredential ? undefined : handleOpenModalForAddNewCustomModel) + return renderTrigger( + false, + notAllowCustomCredential || !canCreateCredential + ? undefined + : handleOpenModalForAddNewCustomModel, + ) } return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton={false} render={<div className="inline-block">{renderTrigger(open)}</div>} @@ -120,53 +107,50 @@ const AddCustomModel = ({ > <div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg"> <div className="max-h-[304px] overflow-y-auto p-1"> - { - canAddedModels.map(model => ( - <div - key={model.model} - className={cn( - 'flex h-8 items-center rounded-lg px-2', - canUseCredential ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed opacity-50', - )} - aria-disabled={!canUseCredential} - onClick={() => { - if (!canUseCredential) - return - - setOpen(false) - handleOpenModalForAddCustomModelToModelList(undefined, model) - }} - > - <ModelIcon - className="mr-1 size-5 shrink-0" - iconClassName="h-5 w-5" - provider={provider} - modelName={model.model} - /> - <div - className="grow truncate system-md-regular text-text-primary" - title={model.model} - > - {model.model} - </div> - </div> - )) - } - </div> - { - !notAllowCustomCredential && canCreateCredential && ( + {canAddedModels.map((model) => ( <div - className="flex cursor-pointer items-center border-t border-t-divider-subtle p-3 system-xs-medium text-text-accent-light-mode-only" + key={model.model} + className={cn( + 'flex h-8 items-center rounded-lg px-2', + canUseCredential + ? 'cursor-pointer hover:bg-state-base-hover' + : 'cursor-not-allowed opacity-50', + )} + aria-disabled={!canUseCredential} onClick={() => { + if (!canUseCredential) return + setOpen(false) - handleOpenModalForAddNewCustomModel() + handleOpenModalForAddCustomModelToModelList(undefined, model) }} > - <span className="mr-1 i-ri-add-line size-4" /> - {t($ => $['modelProvider.auth.addNewModel'], { ns: 'common' })} + <ModelIcon + className="mr-1 size-5 shrink-0" + iconClassName="h-5 w-5" + provider={provider} + modelName={model.model} + /> + <div + className="grow truncate system-md-regular text-text-primary" + title={model.model} + > + {model.model} + </div> </div> - ) - } + ))} + </div> + {!notAllowCustomCredential && canCreateCredential && ( + <div + className="flex cursor-pointer items-center border-t border-t-divider-subtle p-3 system-xs-medium text-text-accent-light-mode-only" + onClick={() => { + setOpen(false) + handleOpenModalForAddNewCustomModel() + }} + > + <span className="mr-1 i-ri-add-line size-4" /> + {t(($) => $['modelProvider.auth.addNewModel'], { ns: 'common' })} + </div> + )} </div> </PopoverContent> </Popover> diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/authorized-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/authorized-item.spec.tsx index c0ce70a5d00f1f..7f5c06b7d8b438 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/authorized-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/authorized-item.spec.tsx @@ -4,11 +4,18 @@ import { ModelTypeEnum } from '../../../declarations' import { AuthorizedItem } from '../authorized-item' vi.mock('../../../model-icon', () => ({ - default: ({ modelName }: { modelName: string }) => <div data-testid="model-icon">{modelName}</div>, + default: ({ modelName }: { modelName: string }) => ( + <div data-testid="model-icon">{modelName}</div> + ), })) vi.mock('../credential-item', () => ({ - default: ({ credential, onEdit, onDelete, onItemClick }: { + default: ({ + credential, + onEdit, + onDelete, + onItemClick, + }: { credential: Credential onEdit?: (credential: Credential) => void onDelete?: (credential: Credential) => void @@ -44,12 +51,7 @@ describe('AuthorizedItem', () => { describe('Rendering', () => { it('should render credentials list', () => { - render( - <AuthorizedItem - provider={mockProvider} - credentials={mockCredentials} - />, - ) + render(<AuthorizedItem provider={mockProvider} credentials={mockCredentials} />) expect(screen.getByTestId('credential-item-cred-1'))!.toBeInTheDocument() expect(screen.getByTestId('credential-item-cred-2'))!.toBeInTheDocument() @@ -73,11 +75,7 @@ describe('AuthorizedItem', () => { it('should not render model title when showModelTitle is false', () => { render( - <AuthorizedItem - provider={mockProvider} - credentials={mockCredentials} - model={mockModel} - />, + <AuthorizedItem provider={mockProvider} credentials={mockCredentials} model={mockModel} />, ) expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument() @@ -98,12 +96,7 @@ describe('AuthorizedItem', () => { }) it('should handle empty credentials array', () => { - const { container } = render( - <AuthorizedItem - provider={mockProvider} - credentials={[]} - />, - ) + const { container } = render(<AuthorizedItem provider={mockProvider} credentials={[]} />) expect(container.querySelector('[data-testid^="credential-item-"]')).not.toBeInTheDocument() }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/credential-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/credential-item.spec.tsx index d9ccb9a96e973e..2c01640fb21a02 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/credential-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/credential-item.spec.tsx @@ -3,7 +3,9 @@ import { fireEvent, render, screen } from '@testing-library/react' import CredentialItem from '../credential-item' vi.mock('@langgenius/dify-ui/status-dot', () => ({ - StatusDot: ({ status }: { status?: string }) => <div data-testid="indicator" data-status={status} />, + StatusDot: ({ status }: { status?: string }) => ( + <div data-testid="indicator" data-status={status} /> + ), })) describe('CredentialItem', () => { @@ -42,7 +44,12 @@ describe('CredentialItem', () => { it('should not call onItemClick when credential is unavailable', () => { const onItemClick = vi.fn() - render(<CredentialItem credential={{ ...credential, not_allowed_to_use: true }} onItemClick={onItemClick} />) + render( + <CredentialItem + credential={{ ...credential, not_allowed_to_use: true }} + onItemClick={onItemClick} + />, + ) fireEvent.click(screen.getByText('Test API Key')) @@ -69,8 +76,8 @@ describe('CredentialItem', () => { render(<CredentialItem credential={credential} onEdit={onEdit} onDelete={onDelete} />) const buttons = screen.getAllByRole('button') - const editButton = buttons.find(b => b.querySelector('.i-ri-equalizer-2-line'))! - const deleteButton = buttons.find(b => b.querySelector('.i-ri-delete-bin-line'))! + const editButton = buttons.find((b) => b.querySelector('.i-ri-equalizer-2-line'))! + const deleteButton = buttons.find((b) => b.querySelector('.i-ri-delete-bin-line'))! fireEvent.click(editButton) fireEvent.click(deleteButton) @@ -92,8 +99,9 @@ describe('CredentialItem', () => { />, ) - const deleteButton = screen.getAllByRole('button') - .find(b => b.querySelector('.i-ri-delete-bin-line'))! + const deleteButton = screen + .getAllByRole('button') + .find((b) => b.querySelector('.i-ri-delete-bin-line'))! fireEvent.click(deleteButton) @@ -135,8 +143,9 @@ describe('CredentialItem', () => { render(<CredentialItem credential={credential} disabled onDelete={onDelete} />) - const deleteButton = screen.getAllByRole('button') - .find(b => b.querySelector('.i-ri-delete-bin-line'))! + const deleteButton = screen + .getAllByRole('button') + .find((b) => b.querySelector('.i-ri-delete-bin-line'))! fireEvent.click(deleteButton) expect(onDelete).not.toHaveBeenCalled() @@ -145,11 +154,7 @@ describe('CredentialItem', () => { // showSelectedIcon=true: check icon area is always rendered; check icon only appears when IDs match it('should render check icon area when showSelectedIcon=true and selectedCredentialId matches', () => { const { container } = render( - <CredentialItem - credential={credential} - showSelectedIcon - selectedCredentialId="cred-1" - />, + <CredentialItem credential={credential} showSelectedIcon selectedCredentialId="cred-1" />, ) expect(container.querySelector('.i-ri-check-line')).toBeInTheDocument() @@ -157,11 +162,7 @@ describe('CredentialItem', () => { it('should not render check icon when showSelectedIcon=true but selectedCredentialId does not match', () => { render( - <CredentialItem - credential={credential} - showSelectedIcon - selectedCredentialId="other-cred" - />, + <CredentialItem credential={credential} showSelectedIcon selectedCredentialId="other-cred" />, ) expect(screen.queryByTestId('check-icon')).not.toBeInTheDocument() diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/index.spec.tsx index 90d86161537dba..7eb857c3e834b8 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/index.spec.tsx @@ -45,7 +45,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -62,7 +63,16 @@ vi.mock('../../hooks', () => ({ })) vi.mock('../authorized-item', () => ({ - default: ({ credentials, model, disabled, disableEdit, disableDelete, onEdit, onDelete, onItemClick }: { + default: ({ + credentials, + model, + disabled, + disableEdit, + disableDelete, + onEdit, + onDelete, + onItemClick, + }: { credentials: Credential[] model?: CustomModel disabled?: boolean @@ -76,9 +86,15 @@ vi.mock('../authorized-item', () => ({ {credentials.map((cred: Credential) => ( <div key={cred.credential_id}> <span>{cred.credential_name}</span> - <button disabled={disabled || disableEdit} onClick={() => onEdit?.(cred, model)}>Edit</button> - <button disabled={disabled || disableDelete} onClick={() => onDelete?.(cred, model)}>Delete</button> - <button disabled={disabled} onClick={() => onItemClick?.(cred, model)}>Select</button> + <button disabled={disabled || disableEdit} onClick={() => onEdit?.(cred, model)}> + Edit + </button> + <button disabled={disabled || disableDelete} onClick={() => onDelete?.(cred, model)}> + Delete + </button> + <button disabled={disabled} onClick={() => onItemClick?.(cred, model)}> + Select + </button> </div> ))} </div> @@ -346,6 +362,8 @@ describe('Authorized', () => { ) const dialog = screen.getByRole('alertdialog') - expect(within(dialog).getByRole('button', { name: /common.operation.confirm/i }))!.toBeDisabled() + expect( + within(dialog).getByRole('button', { name: /common.operation.confirm/i }), + )!.toBeDisabled() }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/authorized-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/authorized-item.tsx index 55c2f2d7cf37a7..0b39febb19f0dd 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/authorized-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/authorized-item.tsx @@ -4,10 +4,7 @@ import type { CustomModelCredential, ModelProvider, } from '../../declarations' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' import ModelIcon from '../../model-icon' import CredentialItem from './credential-item' @@ -47,59 +44,60 @@ export const AuthorizedItem = ({ disableEdit, disableDelete, }: AuthorizedItemProps) => { - const handleEdit = useCallback((credential?: Credential) => { - onEdit?.(credential, model) - }, [onEdit, model]) - const handleDelete = useCallback((credential?: Credential) => { - onDelete?.(credential, model) - }, [onDelete, model]) - const handleItemClick = useCallback((credential: Credential) => { - onItemClick?.(credential, model) - }, [onItemClick, model]) + const handleEdit = useCallback( + (credential?: Credential) => { + onEdit?.(credential, model) + }, + [onEdit, model], + ) + const handleDelete = useCallback( + (credential?: Credential) => { + onDelete?.(credential, model) + }, + [onDelete, model], + ) + const handleItemClick = useCallback( + (credential: Credential) => { + onItemClick?.(credential, model) + }, + [onItemClick, model], + ) return ( <div className="p-1"> - { - showModelTitle && ( + {showModelTitle && ( + <div className="flex h-9 items-center px-2"> + {model?.model && ( + <ModelIcon + className="mr-1 size-5 shrink-0" + provider={provider} + modelName={model.model} + /> + )} <div - className="flex h-9 items-center px-2" + className="mx-1 grow truncate system-md-medium text-text-primary" + title={title ?? model?.model} > - { - model?.model && ( - <ModelIcon - className="mr-1 size-5 shrink-0" - provider={provider} - modelName={model.model} - /> - ) - } - <div - className="mx-1 grow truncate system-md-medium text-text-primary" - title={title ?? model?.model} - > - {title ?? model?.model} - </div> + {title ?? model?.model} </div> - ) - } - { - credentials.map(credential => ( - <CredentialItem - key={credential.credential_id} - credential={credential} - disabled={disabled} - onDelete={handleDelete} - onEdit={handleEdit} - disableEdit={disableEdit} - disableDelete={disableDelete} - showSelectedIcon={showItemSelectedIcon} - selectedCredentialId={selectedCredentialId} - onItemClick={handleItemClick} - disableDeleteButShowAction={disableDeleteButShowAction} - disableDeleteTip={disableDeleteTip} - /> - )) - } + </div> + )} + {credentials.map((credential) => ( + <CredentialItem + key={credential.credential_id} + credential={credential} + disabled={disabled} + onDelete={handleDelete} + onEdit={handleEdit} + disableEdit={disableEdit} + disableDelete={disableDelete} + showSelectedIcon={showItemSelectedIcon} + selectedCredentialId={selectedCredentialId} + onItemClick={handleItemClick} + disableDeleteButShowAction={disableDeleteButShowAction} + disableDeleteTip={disableDeleteTip} + /> + ))} </div> ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.tsx index 53b8477eee864d..8c1093246155ee 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.tsx @@ -2,10 +2,7 @@ import type { Credential } from '../../declarations' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge' @@ -52,26 +49,25 @@ const CredentialItem = ({ key={credential.credential_id} className={cn( 'group flex h-8 items-center rounded-lg p-1 hover:bg-state-base-hover', - disabled ? 'cursor-not-allowed opacity-50' : isUnavailable ? 'cursor-not-allowed' : onItemClick && 'cursor-pointer', + disabled + ? 'cursor-not-allowed opacity-50' + : isUnavailable + ? 'cursor-not-allowed' + : onItemClick && 'cursor-pointer', )} onClick={() => { - if (disabled || isUnavailable) - return + if (disabled || isUnavailable) return onItemClick?.(credential) }} > <div className="flex w-0 grow items-center gap-1.5"> - { - showSelectedIcon && ( - <div className="size-4"> - { - selectedCredentialId === credential.credential_id && ( - <span className="i-ri-check-line size-4 text-text-accent" /> - ) - } - </div> - ) - } + {showSelectedIcon && ( + <div className="size-4"> + {selectedCredentialId === credential.credential_id && ( + <span className="i-ri-check-line size-4 text-text-accent" /> + )} + </div> + )} <StatusDot className="shrink-0" size="small" status={isUnavailable ? 'error' : 'success'} /> <div className="truncate system-md-regular text-text-secondary" @@ -80,75 +76,63 @@ const CredentialItem = ({ {credential.credential_name} </div> </div> - { - credential.from_enterprise && ( - <Badge className="shrink-0"> - Enterprise - </Badge> - ) - } - { - isUnavailable && ( - <div className="ml-2 shrink-0 pr-1 system-xs-medium text-text-destructive"> - {t($ => $['modelProvider.card.unavailable'], { ns: 'common' })} - </div> - ) - } - { - showAction && !credential.from_enterprise && !isUnavailable && ( - <div className="ml-2 hidden shrink-0 items-center group-hover:flex"> - { - !disableEdit && ( - <Tooltip> - <TooltipTrigger - render={( - <ActionButton - disabled={disabled} - onClick={(e) => { - e.stopPropagation() - onEdit?.(credential) - }} - > - <span className="i-ri-equalizer-2-line size-4 text-text-tertiary" /> - </ActionButton> - )} - /> - <TooltipContent>{t($ => $['operation.edit'], { ns: 'common' })}</TooltipContent> - </Tooltip> - ) - } - { - !disableDelete && ( - <Tooltip> - <TooltipTrigger - render={( - <ActionButton - className="hover:bg-transparent" - onClick={(e) => { - if (disabled || disableDeleteWhenSelected) - return - e.stopPropagation() - onDelete?.(credential) - }} - > - <span className={cn( - 'i-ri-delete-bin-line size-4 text-text-tertiary', - !disableDeleteWhenSelected && 'hover:text-text-destructive', - disableDeleteWhenSelected && 'opacity-50', - )} - /> - </ActionButton> - )} - /> - <TooltipContent> - {disableDeleteWhenSelected ? disableDeleteTip : t($ => $['operation.delete'], { ns: 'common' })} - </TooltipContent> - </Tooltip> - ) - } - </div> - ) - } + {credential.from_enterprise && <Badge className="shrink-0">Enterprise</Badge>} + {isUnavailable && ( + <div className="ml-2 shrink-0 pr-1 system-xs-medium text-text-destructive"> + {t(($) => $['modelProvider.card.unavailable'], { ns: 'common' })} + </div> + )} + {showAction && !credential.from_enterprise && !isUnavailable && ( + <div className="ml-2 hidden shrink-0 items-center group-hover:flex"> + {!disableEdit && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton + disabled={disabled} + onClick={(e) => { + e.stopPropagation() + onEdit?.(credential) + }} + > + <span className="i-ri-equalizer-2-line size-4 text-text-tertiary" /> + </ActionButton> + } + /> + <TooltipContent>{t(($) => $['operation.edit'], { ns: 'common' })}</TooltipContent> + </Tooltip> + )} + {!disableDelete && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton + className="hover:bg-transparent" + onClick={(e) => { + if (disabled || disableDeleteWhenSelected) return + e.stopPropagation() + onDelete?.(credential) + }} + > + <span + className={cn( + 'i-ri-delete-bin-line size-4 text-text-tertiary', + !disableDeleteWhenSelected && 'hover:text-text-destructive', + disableDeleteWhenSelected && 'opacity-50', + )} + /> + </ActionButton> + } + /> + <TooltipContent> + {disableDeleteWhenSelected + ? disableDeleteTip + : t(($) => $['operation.delete'], { ns: 'common' })} + </TooltipContent> + </Tooltip> + )} + </div> + )} </div> ) @@ -156,7 +140,9 @@ const CredentialItem = ({ return ( <Tooltip> <TooltipTrigger render={Item} /> - <TooltipContent>{t($ => $['auth.customCredentialUnavailable'], { ns: 'plugin' })}</TooltipContent> + <TooltipContent> + {t(($) => $['auth.customCredentialUnavailable'], { ns: 'plugin' })} + </TooltipContent> </Tooltip> ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx index aead15200eae58..343ed989c781d0 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx @@ -18,17 +18,8 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - Fragment, - memo, - useCallback, - useState, -} from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { Fragment, memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import { useAuth } from '../hooks' @@ -103,18 +94,15 @@ const Authorized = ({ const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() const [isLocalOpen, setIsLocalOpen] = useState(false) const mergedIsOpen = isOpen ?? isLocalOpen - const setMergedIsOpen = useCallback((open: boolean) => { - if (onOpenChange) - onOpenChange(open) + const setMergedIsOpen = useCallback( + (open: boolean) => { + if (onOpenChange) onOpenChange(open) - setIsLocalOpen(open) - }, [onOpenChange]) - const { - isModelCredential, - onUpdate, - onRemove, - mode, - } = authParams || {} + setIsLocalOpen(open) + }, + [onOpenChange], + ) + const { isModelCredential, onUpdate, onRemove, mode } = authParams || {} const { openConfirmDelete, closeConfirmDelete, @@ -123,74 +111,77 @@ const Authorized = ({ handleConfirmDelete, deleteCredentialId, handleOpenModal, - } = useAuth( - provider, - configurationMethod, - currentCustomConfigurationModelFixedFields, - { - isModelCredential, - onUpdate, - onRemove, - mode, - }, - ) + } = useAuth(provider, configurationMethod, currentCustomConfigurationModelFixedFields, { + isModelCredential, + onUpdate, + onRemove, + mode, + }) - const handleEdit = useCallback((credential?: Credential, model?: CustomModel) => { - if (credential ? !canManageCredential : !canCreateCredential) - return + const handleEdit = useCallback( + (credential?: Credential, model?: CustomModel) => { + if (credential ? !canManageCredential : !canCreateCredential) return - setMergedIsOpen(false) - handleOpenModal(credential, model) - }, [canCreateCredential, canManageCredential, handleOpenModal, setMergedIsOpen]) - const handleDelete = useCallback((credential?: Credential, model?: CustomModel) => { - if (!canManageCredential) - return + setMergedIsOpen(false) + handleOpenModal(credential, model) + }, + [canCreateCredential, canManageCredential, handleOpenModal, setMergedIsOpen], + ) + const handleDelete = useCallback( + (credential?: Credential, model?: CustomModel) => { + if (!canManageCredential) return - setMergedIsOpen(false) - openConfirmDelete(credential, model) - }, [canManageCredential, openConfirmDelete, setMergedIsOpen]) + setMergedIsOpen(false) + openConfirmDelete(credential, model) + }, + [canManageCredential, openConfirmDelete, setMergedIsOpen], + ) - const handleItemClick = useCallback((credential: Credential, model?: CustomModel) => { - if (!canUseCredential) - return + const handleItemClick = useCallback( + (credential: Credential, model?: CustomModel) => { + if (!canUseCredential) return - if (disableItemClick) - return + if (disableItemClick) return - if (onItemClick) - onItemClick(credential, model) - else - handleActiveCredential(credential, model) + if (onItemClick) onItemClick(credential, model) + else handleActiveCredential(credential, model) - setMergedIsOpen(false) - }, [canUseCredential, handleActiveCredential, onItemClick, setMergedIsOpen, disableItemClick]) + setMergedIsOpen(false) + }, + [canUseCredential, handleActiveCredential, onItemClick, setMergedIsOpen, disableItemClick], + ) const notAllowCustomCredential = provider.allow_custom_token === false const resolvedOffset = typeof offset === 'number' ? undefined : offset - const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0 - const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0 + const sideOffset = typeof offset === 'number' ? offset : (resolvedOffset?.mainAxis ?? 0) + const alignOffset = + typeof offset === 'number' + ? 0 + : (resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0) const popupProps = triggerPopupSameWidth ? { style: { width: 'var(--anchor-width, auto)' } } : undefined - const handleTriggerClick = useCallback((event: MouseEvent<HTMLElement>) => { - if (!triggerOnlyOpenModal) - return + const handleTriggerClick = useCallback( + (event: MouseEvent<HTMLElement>) => { + if (!triggerOnlyOpenModal) return - event.preventDefault() - if (!canCreateCredential) - return + event.preventDefault() + if (!canCreateCredential) return - handleOpenModal() - }, [canCreateCredential, handleOpenModal, triggerOnlyOpenModal]) + handleOpenModal() + }, + [canCreateCredential, handleOpenModal, triggerOnlyOpenModal], + ) return ( <> - <Popover - open={mergedIsOpen} - onOpenChange={setMergedIsOpen} - > + <Popover open={mergedIsOpen} onOpenChange={setMergedIsOpen}> <PopoverTrigger nativeButton={false} - render={<div className={triggerPopupSameWidth ? 'w-full' : 'inline-block'}>{renderTrigger(mergedIsOpen)}</div>} + render={ + <div className={triggerPopupSameWidth ? 'w-full' : 'inline-block'}> + {renderTrigger(mergedIsOpen)} + </div> + } onClick={handleTriggerClick} /> <PopoverContent @@ -200,94 +191,101 @@ const Authorized = ({ popupProps={popupProps} popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none" > - <div className={cn( - 'w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]', - popupClassName, - )} + <div + className={cn( + 'w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]', + popupClassName, + )} > - { - popupTitle && ( - <div className="px-3 pt-[10px] pb-0.5 system-xs-medium text-text-tertiary"> - {popupTitle} - </div> - ) - } + {popupTitle && ( + <div className="px-3 pt-[10px] pb-0.5 system-xs-medium text-text-tertiary"> + {popupTitle} + </div> + )} <div className="max-h-[304px] overflow-y-auto"> - { - items.map(item => ( - <Fragment key={item.model?.model ?? item.title ?? item.credentials.map(credential => credential.credential_id).join('-')}> - <AuthorizedItem - provider={provider} - title={item.title} - model={item.model} - credentials={item.credentials} - disabled={disabled} - disableEdit={!canManageCredential} - disableDelete={!canManageCredential} - onDelete={handleDelete} - disableDeleteButShowAction={disableDeleteButShowAction} - disableDeleteTip={disableDeleteTip} - onEdit={handleEdit} - showItemSelectedIcon={showItemSelectedIcon} - selectedCredentialId={item.selectedCredential?.credential_id} - onItemClick={canUseCredential ? handleItemClick : undefined} - showModelTitle={showModelTitle} - /> - { - item !== items[items.length - 1] && ( - <div className="h-px bg-divider-subtle"></div> - ) - } - </Fragment> - )) - } + {items.map((item) => ( + <Fragment + key={ + item.model?.model ?? + item.title ?? + item.credentials.map((credential) => credential.credential_id).join('-') + } + > + <AuthorizedItem + provider={provider} + title={item.title} + model={item.model} + credentials={item.credentials} + disabled={disabled} + disableEdit={!canManageCredential} + disableDelete={!canManageCredential} + onDelete={handleDelete} + disableDeleteButShowAction={disableDeleteButShowAction} + disableDeleteTip={disableDeleteTip} + onEdit={handleEdit} + showItemSelectedIcon={showItemSelectedIcon} + selectedCredentialId={item.selectedCredential?.credential_id} + onItemClick={canUseCredential ? handleItemClick : undefined} + showModelTitle={showModelTitle} + /> + {item !== items[items.length - 1] && ( + <div className="h-px bg-divider-subtle"></div> + )} + </Fragment> + ))} </div> <div className="h-px bg-divider-subtle"></div> - { - isModelCredential && !notAllowCustomCredential && !hideAddAction && canCreateCredential && ( + {isModelCredential && + !notAllowCustomCredential && + !hideAddAction && + canCreateCredential && ( <div - onClick={() => handleEdit( - undefined, - currentCustomConfigurationModelFixedFields - ? { - model: currentCustomConfigurationModelFixedFields.__model_name, - model_type: currentCustomConfigurationModelFixedFields.__model_type, - } - : undefined, - )} + onClick={() => + handleEdit( + undefined, + currentCustomConfigurationModelFixedFields + ? { + model: currentCustomConfigurationModelFixedFields.__model_name, + model_type: currentCustomConfigurationModelFixedFields.__model_type, + } + : undefined, + ) + } className="flex h-[40px] cursor-pointer items-center px-3 system-xs-medium text-text-accent-light-mode-only" > <span className="mr-1 i-ri-add-line size-4" /> - {t($ => $['modelProvider.auth.addModelCredential'], { ns: 'common' })} + {t(($) => $['modelProvider.auth.addModelCredential'], { ns: 'common' })} </div> - ) - } - { - !isModelCredential && !notAllowCustomCredential && !hideAddAction && canCreateCredential && ( + )} + {!isModelCredential && + !notAllowCustomCredential && + !hideAddAction && + canCreateCredential && ( <div className="p-2"> - <Button - onClick={() => handleEdit()} - className="w-full" - > - {t($ => $['modelProvider.auth.addApiKey'], { ns: 'common' })} + <Button onClick={() => handleEdit()} className="w-full"> + {t(($) => $['modelProvider.auth.addApiKey'], { ns: 'common' })} </Button> </div> - ) - } + )} </div> </PopoverContent> </Popover> - <AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirmDelete()}> + <AlertDialog + open={!!deleteCredentialId} + onOpenChange={(open) => !open && closeConfirmDelete()} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['modelProvider.confirmDelete'], { ns: 'common' })} + {t(($) => $['modelProvider.confirmDelete'], { ns: 'common' })} </AlertDialogTitle> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton disabled={doingAction} onClick={handleConfirmDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx index a472bf71c7c7b4..1dfab146223229 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx @@ -1,10 +1,7 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' -import { - RiEqualizer2Line, - RiScales3Line, -} from '@remixicon/react' +import { RiEqualizer2Line, RiScales3Line } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' @@ -29,7 +26,7 @@ const ConfigModel = ({ onClick={onClick} > <RiScales3Line className="mr-0.5 size-3" /> - {t($ => $['modelProvider.auth.authorizationError'], { ns: 'common' })} + {t(($) => $['modelProvider.auth.authorizationError'], { ns: 'common' })} <StatusDot status="warning" className="absolute -top-px -right-px size-1.5" /> </div> ) @@ -39,36 +36,27 @@ const ConfigModel = ({ <Button variant="secondary" size="small" - className={cn( - 'hidden shrink-0 group-hover:flex', - credentialRemoved && 'flex', - )} + className={cn('hidden shrink-0 group-hover:flex', credentialRemoved && 'flex')} onClick={onClick} > - { - credentialRemoved && ( - <> - {t($ => $['modelProvider.auth.credentialRemoved'], { ns: 'common' })} - <StatusDot status="error" className="ml-2" /> - </> - ) - } - { - !loadBalancingEnabled && !credentialRemoved && !loadBalancingInvalid && ( - <> - <RiEqualizer2Line className="mr-1 size-4" /> - {t($ => $['operation.config'], { ns: 'common' })} - </> - ) - } - { - loadBalancingEnabled && !credentialRemoved && !loadBalancingInvalid && ( - <> - <RiScales3Line className="mr-1 size-4" /> - {t($ => $['modelProvider.auth.configLoadBalancing'], { ns: 'common' })} - </> - ) - } + {credentialRemoved && ( + <> + {t(($) => $['modelProvider.auth.credentialRemoved'], { ns: 'common' })} + <StatusDot status="error" className="ml-2" /> + </> + )} + {!loadBalancingEnabled && !credentialRemoved && !loadBalancingInvalid && ( + <> + <RiEqualizer2Line className="mr-1 size-4" /> + {t(($) => $['operation.config'], { ns: 'common' })} + </> + )} + {loadBalancingEnabled && !credentialRemoved && !loadBalancingInvalid && ( + <> + <RiScales3Line className="mr-1 size-4" /> + {t(($) => $['modelProvider.auth.configLoadBalancing'], { ns: 'common' })} + </> + )} </Button> ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx index b08270a37e82cd..fbeb1f59519f3d 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx @@ -1,19 +1,8 @@ import type { Credential } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { StatusDot } from '@langgenius/dify-ui/status-dot' -import { - RiAddLine, - RiArrowDownSLine, -} from '@remixicon/react' -import { - memo, - useCallback, - useState, -} from 'react' +import { RiAddLine, RiArrowDownSLine } from '@remixicon/react' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import CredentialItem from './authorized/credential-item' @@ -34,48 +23,47 @@ const CredentialSelector = ({ }: CredentialSelectorProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) - const handleSelect = useCallback((credential: Credential & { addNewCredential?: boolean }) => { - setOpen(false) - onSelect(credential) - }, [onSelect]) + const handleSelect = useCallback( + (credential: Credential & { addNewCredential?: boolean }) => { + setOpen(false) + onSelect(credential) + }, + [onSelect], + ) const handleAddNewCredential = useCallback(() => { handleSelect({ credential_id: '__add_new_credential', addNewCredential: true, - credential_name: t($ => $['modelProvider.auth.addNewModelCredential'], { ns: 'common' }), + credential_name: t(($) => $['modelProvider.auth.addNewModelCredential'], { ns: 'common' }), }) }, [handleSelect, t]) return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton={false} disabled={disabled} - render={<div className="flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2 system-sm-regular" />} + render={ + <div className="flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2 system-sm-regular" /> + } > - { - selectedCredential && ( - <div className="flex items-center"> - { - !selectedCredential.addNewCredential && <StatusDot className="mr-2 ml-1 shrink-0" /> - } - <div className="truncate system-sm-regular text-components-input-text-filled" title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div> - { - selectedCredential.from_enterprise && ( - <Badge className="shrink-0">Enterprise</Badge> - ) - } + {selectedCredential && ( + <div className="flex items-center"> + {!selectedCredential.addNewCredential && <StatusDot className="mr-2 ml-1 shrink-0" />} + <div + className="truncate system-sm-regular text-components-input-text-filled" + title={selectedCredential.credential_name} + > + {selectedCredential.credential_name} </div> - ) - } - { - !selectedCredential && ( - <div className="grow truncate system-sm-regular text-components-input-text-placeholder">{t($ => $['modelProvider.auth.selectModelCredential'], { ns: 'common' })}</div> - ) - } + {selectedCredential.from_enterprise && <Badge className="shrink-0">Enterprise</Badge>} + </div> + )} + {!selectedCredential && ( + <div className="grow truncate system-sm-regular text-components-input-text-placeholder"> + {t(($) => $['modelProvider.auth.selectModelCredential'], { ns: 'common' })} + </div> + )} <RiArrowDownSLine className="size-4 text-text-quaternary" /> </PopoverTrigger> <PopoverContent @@ -84,32 +72,28 @@ const CredentialSelector = ({ popupProps={{ style: { width: 'var(--anchor-width, auto)' } }} > <div className="max-h-[320px] overflow-y-auto p-1"> - { - credentials.map(credential => ( - <CredentialItem - key={credential.credential_id} - credential={credential} - disableDelete - disableEdit - disableRename - onItemClick={handleSelect} - showSelectedIcon - selectedCredentialId={selectedCredential?.credential_id} - /> - )) - } + {credentials.map((credential) => ( + <CredentialItem + key={credential.credential_id} + credential={credential} + disableDelete + disableEdit + disableRename + onItemClick={handleSelect} + showSelectedIcon + selectedCredentialId={selectedCredential?.credential_id} + /> + ))} </div> - { - !notAllowAddNewCredential && ( - <div - className="flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 system-xs-medium text-text-accent-light-mode-only" - onClick={handleAddNewCredential} - > - <RiAddLine className="mr-1 size-4" /> - {t($ => $['modelProvider.auth.addNewModelCredential'], { ns: 'common' })} - </div> - ) - } + {!notAllowAddNewCredential && ( + <div + className="flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 system-xs-medium text-text-accent-light-mode-only" + onClick={handleAddNewCredential} + > + <RiAddLine className="mr-1 size-4" /> + {t(($) => $['modelProvider.auth.addNewModelCredential'], { ns: 'common' })} + </div> + )} </PopoverContent> </Popover> ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth-service.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth-service.spec.tsx index 7cc14450414b16..d5bc1a5604740c 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth-service.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth-service.spec.tsx @@ -41,30 +41,63 @@ describe('useAuthService hooks', () => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const mockMutationReturn = { mutateAsync: vi.fn() } - vi.mocked(useAddProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useAddProviderCredential>) - vi.mocked(useEditProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useEditProviderCredential>) - vi.mocked(useDeleteProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useDeleteProviderCredential>) - vi.mocked(useActiveProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useActiveProviderCredential>) - vi.mocked(useAddModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useAddModelCredential>) - vi.mocked(useEditModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useEditModelCredential>) - vi.mocked(useDeleteModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useDeleteModelCredential>) - vi.mocked(useActiveModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useActiveModelCredential>) + vi.mocked(useAddProviderCredential).mockReturnValue( + mockMutationReturn as unknown as ReturnType<typeof useAddProviderCredential>, + ) + vi.mocked(useEditProviderCredential).mockReturnValue( + mockMutationReturn as unknown as ReturnType<typeof useEditProviderCredential>, + ) + vi.mocked(useDeleteProviderCredential).mockReturnValue( + mockMutationReturn as unknown as ReturnType<typeof useDeleteProviderCredential>, + ) + vi.mocked(useActiveProviderCredential).mockReturnValue( + mockMutationReturn as unknown as ReturnType<typeof useActiveProviderCredential>, + ) + vi.mocked(useAddModelCredential).mockReturnValue( + mockMutationReturn as unknown as ReturnType<typeof useAddModelCredential>, + ) + vi.mocked(useEditModelCredential).mockReturnValue( + mockMutationReturn as unknown as ReturnType<typeof useEditModelCredential>, + ) + vi.mocked(useDeleteModelCredential).mockReturnValue( + mockMutationReturn as unknown as ReturnType<typeof useDeleteModelCredential>, + ) + vi.mocked(useActiveModelCredential).mockReturnValue( + mockMutationReturn as unknown as ReturnType<typeof useActiveModelCredential>, + ) }) it('useGetCredential selects correct source and params', () => { const mockData = { data: 'test' } - vi.mocked(useGetProviderCredential).mockReturnValue(mockData as unknown as ReturnType<typeof useGetProviderCredential>) - vi.mocked(useGetModelCredential).mockReturnValue(mockData as unknown as ReturnType<typeof useGetModelCredential>) + vi.mocked(useGetProviderCredential).mockReturnValue( + mockData as unknown as ReturnType<typeof useGetProviderCredential>, + ) + vi.mocked(useGetModelCredential).mockReturnValue( + mockData as unknown as ReturnType<typeof useGetModelCredential>, + ) // Provider case - const { result: providerRes } = renderHook(() => useGetCredential('openai', false, 'cred-123'), { wrapper }) + const { result: providerRes } = renderHook( + () => useGetCredential('openai', false, 'cred-123'), + { wrapper }, + ) expect(useGetProviderCredential).toHaveBeenCalledWith(true, 'openai', 'cred-123') expect(providerRes.current).toBe(mockData) // Model case const mockModel = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } as CustomModel - const { result: modelRes } = renderHook(() => useGetCredential('openai', true, 'cred-123', mockModel, 'src'), { wrapper }) - expect(useGetModelCredential).toHaveBeenCalledWith(true, 'openai', 'cred-123', 'gpt-4', ModelTypeEnum.textGeneration, 'src') + const { result: modelRes } = renderHook( + () => useGetCredential('openai', true, 'cred-123', mockModel, 'src'), + { wrapper }, + ) + expect(useGetModelCredential).toHaveBeenCalledWith( + true, + 'openai', + 'cred-123', + 'gpt-4', + ModelTypeEnum.textGeneration, + 'src', + ) expect(modelRes.current).toBe(mockData) // Early return cases @@ -73,22 +106,45 @@ describe('useAuthService hooks', () => { // Branch: isModelCredential true but no id/model renderHook(() => useGetCredential('openai', true), { wrapper }) - expect(useGetModelCredential).toHaveBeenCalledWith(false, 'openai', undefined, undefined, undefined, undefined) + expect(useGetModelCredential).toHaveBeenCalledWith( + false, + 'openai', + undefined, + undefined, + undefined, + undefined, + ) }) it('useAuthService provides correct services for provider and model', () => { const { result } = renderHook(() => useAuthService('openai'), { wrapper }) // Provider services - expect(result.current.getAddCredentialService(false)).toBe(vi.mocked(useAddProviderCredential).mock.results[0]!.value.mutateAsync) - expect(result.current.getEditCredentialService(false)).toBe(vi.mocked(useEditProviderCredential).mock.results[0]!.value.mutateAsync) - expect(result.current.getDeleteCredentialService(false)).toBe(vi.mocked(useDeleteProviderCredential).mock.results[0]!.value.mutateAsync) - expect(result.current.getActiveCredentialService(false)).toBe(vi.mocked(useActiveProviderCredential).mock.results[0]!.value.mutateAsync) + expect(result.current.getAddCredentialService(false)).toBe( + vi.mocked(useAddProviderCredential).mock.results[0]!.value.mutateAsync, + ) + expect(result.current.getEditCredentialService(false)).toBe( + vi.mocked(useEditProviderCredential).mock.results[0]!.value.mutateAsync, + ) + expect(result.current.getDeleteCredentialService(false)).toBe( + vi.mocked(useDeleteProviderCredential).mock.results[0]!.value.mutateAsync, + ) + expect(result.current.getActiveCredentialService(false)).toBe( + vi.mocked(useActiveProviderCredential).mock.results[0]!.value.mutateAsync, + ) // Model services - expect(result.current.getAddCredentialService(true)).toBe(vi.mocked(useAddModelCredential).mock.results[0]!.value.mutateAsync) - expect(result.current.getEditCredentialService(true)).toBe(vi.mocked(useEditModelCredential).mock.results[0]!.value.mutateAsync) - expect(result.current.getDeleteCredentialService(true)).toBe(vi.mocked(useDeleteModelCredential).mock.results[0]!.value.mutateAsync) - expect(result.current.getActiveCredentialService(true)).toBe(vi.mocked(useActiveModelCredential).mock.results[0]!.value.mutateAsync) + expect(result.current.getAddCredentialService(true)).toBe( + vi.mocked(useAddModelCredential).mock.results[0]!.value.mutateAsync, + ) + expect(result.current.getEditCredentialService(true)).toBe( + vi.mocked(useEditModelCredential).mock.results[0]!.value.mutateAsync, + ) + expect(result.current.getDeleteCredentialService(true)).toBe( + vi.mocked(useDeleteModelCredential).mock.results[0]!.value.mutateAsync, + ) + expect(result.current.getActiveCredentialService(true)).toBe( + vi.mocked(useActiveModelCredential).mock.results[0]!.value.mutateAsync, + ) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth.spec.tsx index 2f87bf9a629c96..540879d975e1ab 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth.spec.tsx @@ -1,9 +1,5 @@ import type { ReactNode } from 'react' -import type { - Credential, - CustomModel, - ModelProvider, -} from '../../../declarations' +import type { Credential, CustomModel, ModelProvider } from '../../../declarations' import { act, renderHook } from '@testing-library/react' import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../../declarations' import { useAuth } from '../use-auth' @@ -48,10 +44,14 @@ vi.mock('@/service/use-models', () => ({ vi.mock('../use-auth-service', () => ({ useAuthService: () => ({ - getDeleteCredentialService: (isModel: boolean) => (isModel ? mockDeleteModelCredential : mockDeleteProviderCredential), - getActiveCredentialService: (isModel: boolean) => (isModel ? mockActiveModelCredential : mockActiveProviderCredential), - getEditCredentialService: (isModel: boolean) => (isModel ? mockEditModelCredential : mockEditProviderCredential), - getAddCredentialService: (isModel: boolean) => (isModel ? mockAddModelCredential : mockAddProviderCredential), + getDeleteCredentialService: (isModel: boolean) => + isModel ? mockDeleteModelCredential : mockDeleteProviderCredential, + getActiveCredentialService: (isModel: boolean) => + isModel ? mockActiveModelCredential : mockActiveProviderCredential, + getEditCredentialService: (isModel: boolean) => + isModel ? mockEditModelCredential : mockEditProviderCredential, + getAddCredentialService: (isModel: boolean) => + isModel ? mockAddModelCredential : mockAddProviderCredential, }), })) @@ -79,11 +79,7 @@ describe('useAuth', () => { model_type: ModelTypeEnum.textGeneration, } - const createWrapper = ({ children }: { children: ReactNode }) => ( - <> - {children} - </> - ) + const createWrapper = ({ children }: { children: ReactNode }) => <>{children}</> beforeEach(() => { vi.clearAllMocks() @@ -99,7 +95,10 @@ describe('useAuth', () => { }) it('should open and close delete confirmation state', () => { - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + const { result } = renderHook( + () => useAuth(provider, ConfigurationMethodEnum.predefinedModel), + { wrapper: createWrapper }, + ) act(() => { result.current.openConfirmDelete(credential, model) @@ -119,7 +118,10 @@ describe('useAuth', () => { }) it('should activate credential, notify success, and refresh models', async () => { - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel), { wrapper: createWrapper }) + const { result } = renderHook( + () => useAuth(provider, ConfigurationMethodEnum.customizableModel), + { wrapper: createWrapper }, + ) await act(async () => { await result.current.handleActiveCredential(credential, model) @@ -130,16 +132,21 @@ describe('useAuth', () => { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration, }) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - message: 'common.api.actionSuccess', - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + message: 'common.api.actionSuccess', + }), + ) expect(mockHandleRefreshModel).toHaveBeenCalledWith(provider, undefined, true) expect(result.current.doingAction).toBe(false) }) it('should close delete dialog without calling services when nothing is pending', async () => { - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + const { result } = renderHook( + () => useAuth(provider, ConfigurationMethodEnum.predefinedModel), + { wrapper: createWrapper }, + ) await act(async () => { await result.current.handleConfirmDelete() @@ -153,10 +160,14 @@ describe('useAuth', () => { it('should delete credential and call onRemove callback', async () => { const onRemove = vi.fn() - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel, undefined, { - isModelCredential: false, - onRemove, - }), { wrapper: createWrapper }) + const { result } = renderHook( + () => + useAuth(provider, ConfigurationMethodEnum.predefinedModel, undefined, { + isModelCredential: false, + onRemove, + }), + { wrapper: createWrapper }, + ) act(() => { result.current.openConfirmDelete(credential, model) @@ -176,9 +187,13 @@ describe('useAuth', () => { it('should delete model when pending operation has no credential id', async () => { const onRemove = vi.fn() - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, undefined, { - onRemove, - }), { wrapper: createWrapper }) + const { result } = renderHook( + () => + useAuth(provider, ConfigurationMethodEnum.customizableModel, undefined, { + onRemove, + }), + { wrapper: createWrapper }, + ) act(() => { result.current.openConfirmDelete(undefined, model) @@ -196,7 +211,10 @@ describe('useAuth', () => { }) it('should add or edit credentials and refresh on successful save', async () => { - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + const { result } = renderHook( + () => useAuth(provider, ConfigurationMethodEnum.predefinedModel), + { wrapper: createWrapper }, + ) await act(async () => { await result.current.handleSaveCredential({ api_key: 'new-key' }) @@ -209,7 +227,10 @@ describe('useAuth', () => { await result.current.handleSaveCredential({ credential_id: 'cred-1', api_key: 'updated-key' }) }) - expect(mockEditProviderCredential).toHaveBeenCalledWith({ credential_id: 'cred-1', api_key: 'updated-key' }) + expect(mockEditProviderCredential).toHaveBeenCalledWith({ + credential_id: 'cred-1', + api_key: 'updated-key', + }) expect(mockHandleRefreshModel).toHaveBeenCalledWith(provider, undefined, false) }) @@ -217,7 +238,10 @@ describe('useAuth', () => { const deferred = createDeferred<{ result: string }>() mockAddProviderCredential.mockReturnValueOnce(deferred.promise) - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + const { result } = renderHook( + () => useAuth(provider, ConfigurationMethodEnum.predefinedModel), + { wrapper: createWrapper }, + ) let first!: Promise<void> let second!: Promise<void> @@ -239,11 +263,15 @@ describe('useAuth', () => { __model_name: 'gpt-4', __model_type: ModelTypeEnum.textGeneration, } - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, fixedFields, { - isModelCredential: true, - onUpdate, - mode: ModelModalModeEnum.configModelCredential, - }), { wrapper: createWrapper }) + const { result } = renderHook( + () => + useAuth(provider, ConfigurationMethodEnum.customizableModel, fixedFields, { + isModelCredential: true, + onUpdate, + mode: ModelModalModeEnum.configModelCredential, + }), + { wrapper: createWrapper }, + ) act(() => { result.current.handleOpenModal(credential, model) @@ -265,7 +293,10 @@ describe('useAuth', () => { it('should not notify or refresh when handleSaveCredential returns non-success result', async () => { mockAddProviderCredential.mockResolvedValue({ result: 'error' }) - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + const { result } = renderHook( + () => useAuth(provider, ConfigurationMethodEnum.predefinedModel), + { wrapper: createWrapper }, + ) await act(async () => { await result.current.handleSaveCredential({ api_key: 'some-key' }) @@ -277,7 +308,10 @@ describe('useAuth', () => { }) it('should pass undefined model and model_type when handleActiveCredential is called without a model parameter', async () => { - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + const { result } = renderHook( + () => useAuth(provider, ConfigurationMethodEnum.predefinedModel), + { wrapper: createWrapper }, + ) await act(async () => { await result.current.handleActiveCredential(credential) @@ -292,7 +326,10 @@ describe('useAuth', () => { // openConfirmDelete with credential only (no model): deleteCredentialId set, deleteModel stays null it('should only set deleteCredentialId when openConfirmDelete is called without a model', () => { - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + const { result } = renderHook( + () => useAuth(provider, ConfigurationMethodEnum.predefinedModel), + { wrapper: createWrapper }, + ) act(() => { result.current.openConfirmDelete(credential, undefined) @@ -309,7 +346,10 @@ describe('useAuth', () => { const deferred = createDeferred<{ result: string }>() mockDeleteProviderCredential.mockReturnValueOnce(deferred.promise) - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + const { result } = renderHook( + () => useAuth(provider, ConfigurationMethodEnum.predefinedModel), + { wrapper: createWrapper }, + ) act(() => { result.current.openConfirmDelete(credential, model) @@ -333,7 +373,10 @@ describe('useAuth', () => { const deferred = createDeferred<{ result: string }>() mockActiveProviderCredential.mockReturnValueOnce(deferred.promise) - const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper }) + const { result } = renderHook( + () => useAuth(provider, ConfigurationMethodEnum.predefinedModel), + { wrapper: createWrapper }, + ) let first!: Promise<void> let second!: Promise<void> diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-credential-data.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-credential-data.spec.tsx index 0ab32ddddbe61b..6e44045886ef92 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-credential-data.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-credential-data.spec.tsx @@ -21,40 +21,74 @@ describe('useCredentialData', () => { }) it('determines correct config source and parameters', () => { - vi.mocked(useGetCredential).mockReturnValue({ isLoading: false, data: {} } as unknown as ReturnType<typeof useGetCredential>) + vi.mocked(useGetCredential).mockReturnValue({ + isLoading: false, + data: {}, + } as unknown as ReturnType<typeof useGetCredential>) const mockProvider = { provider: 'openai' } as unknown as ModelProvider // Predefined source renderHook(() => useCredentialData(mockProvider, true), { wrapper }) - expect(useGetCredential).toHaveBeenCalledWith('openai', undefined, undefined, undefined, 'predefined-model') + expect(useGetCredential).toHaveBeenCalledWith( + 'openai', + undefined, + undefined, + undefined, + 'predefined-model', + ) // Custom source renderHook(() => useCredentialData(mockProvider, false), { wrapper }) - expect(useGetCredential).toHaveBeenCalledWith('openai', undefined, undefined, undefined, 'custom-model') + expect(useGetCredential).toHaveBeenCalledWith( + 'openai', + undefined, + undefined, + undefined, + 'custom-model', + ) }) it('returns appropriate loading and data states', () => { const mockData = { api_key: 'test' } - vi.mocked(useGetCredential).mockReturnValue({ isLoading: true, data: undefined } as unknown as ReturnType<typeof useGetCredential>) + vi.mocked(useGetCredential).mockReturnValue({ + isLoading: true, + data: undefined, + } as unknown as ReturnType<typeof useGetCredential>) const mockProvider = { provider: 'openai' } as unknown as ModelProvider - const { result: loadingRes } = renderHook(() => useCredentialData(mockProvider, true), { wrapper }) + const { result: loadingRes } = renderHook(() => useCredentialData(mockProvider, true), { + wrapper, + }) expect(loadingRes.current.isLoading).toBe(true) expect(loadingRes.current.credentialData).toEqual({}) - vi.mocked(useGetCredential).mockReturnValue({ isLoading: false, data: mockData } as unknown as ReturnType<typeof useGetCredential>) + vi.mocked(useGetCredential).mockReturnValue({ + isLoading: false, + data: mockData, + } as unknown as ReturnType<typeof useGetCredential>) const { result: dataRes } = renderHook(() => useCredentialData(mockProvider, true), { wrapper }) expect(dataRes.current.isLoading).toBe(false) expect(dataRes.current.credentialData).toBe(mockData) }) it('passes credential and model identifier correctly', () => { - vi.mocked(useGetCredential).mockReturnValue({ isLoading: false, data: {} } as unknown as ReturnType<typeof useGetCredential>) + vi.mocked(useGetCredential).mockReturnValue({ + isLoading: false, + data: {}, + } as unknown as ReturnType<typeof useGetCredential>) const mockProvider = { provider: 'openai' } as unknown as ModelProvider const mockCredential = { credential_id: 'cred-123' } as unknown as Credential const mockModel = { model: 'gpt-4' } as unknown as CustomModelCredential - renderHook(() => useCredentialData(mockProvider, true, true, mockCredential, mockModel), { wrapper }) - expect(useGetCredential).toHaveBeenCalledWith('openai', true, 'cred-123', mockModel, 'predefined-model') + renderHook(() => useCredentialData(mockProvider, true, true, mockCredential, mockModel), { + wrapper, + }) + expect(useGetCredential).toHaveBeenCalledWith( + 'openai', + true, + 'cred-123', + mockModel, + 'predefined-model', + ) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-credential-status.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-credential-status.spec.tsx index ac35be3ef7f780..54f95fbd07a977 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-credential-status.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-credential-status.spec.tsx @@ -49,7 +49,9 @@ describe('useCredentialStatus', () => { }) it('handles undefined custom configuration gracefully', () => { - const { result } = renderHook(() => useCredentialStatus({ custom_configuration: {} } as ModelProvider)) + const { result } = renderHook(() => + useCredentialStatus({ custom_configuration: {} } as ModelProvider), + ) expect(result.current.hasCredential).toBe(false) expect(result.current.available_credentials).toBeUndefined() }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-custom-models.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-custom-models.spec.tsx index c98ead095f6076..6538a9d3440636 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-custom-models.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-custom-models.spec.tsx @@ -17,7 +17,9 @@ describe('useCustomModels and useCanAddedModels', () => { expect(result.current).toHaveLength(2) expect(result.current[0]!.model).toBe('gpt-4') - const { result: emptyRes } = renderHook(() => useCustomModels({ custom_configuration: {} } as unknown as ModelProvider)) + const { result: emptyRes } = renderHook(() => + useCustomModels({ custom_configuration: {} } as unknown as ModelProvider), + ) expect(emptyRes.current).toEqual([]) }) @@ -32,7 +34,9 @@ describe('useCustomModels and useCanAddedModels', () => { expect(result.current).toHaveLength(1) expect(result.current[0]!.model).toBe('gpt-4-turbo') - const { result: emptyRes } = renderHook(() => useCanAddedModels({ custom_configuration: {} } as unknown as ModelProvider)) + const { result: emptyRes } = renderHook(() => + useCanAddedModels({ custom_configuration: {} } as unknown as ModelProvider), + ) expect(emptyRes.current).toEqual([]) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-model-form-schemas.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-model-form-schemas.spec.tsx index dbe513163b8026..0dbcffaeb3af34 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-model-form-schemas.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-model-form-schemas.spec.tsx @@ -1,8 +1,4 @@ -import type { - Credential, - CustomModelCredential, - ModelProvider, -} from '../../../declarations' +import type { Credential, CustomModelCredential, ModelProvider } from '../../../declarations' import { renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { FormTypeEnum } from '@/app/components/base/form/types' @@ -41,26 +37,39 @@ describe('useModelFormSchemas', () => { it('selects correct form schemas based on providerFormSchemaPredefined', () => { const { result: providerResult } = renderHook(() => useModelFormSchemas(mockProvider, true)) - expect(providerResult.current.formSchemas.some(s => s.variable === 'api_key')).toBe(true) + expect(providerResult.current.formSchemas.some((s) => s.variable === 'api_key')).toBe(true) const { result: modelResult } = renderHook(() => useModelFormSchemas(mockProvider, false)) - expect(modelResult.current.formSchemas.some(s => s.variable === 'model_key')).toBe(true) + expect(modelResult.current.formSchemas.some((s) => s.variable === 'model_key')).toBe(true) - const { result: emptyResult } = renderHook(() => useModelFormSchemas({} as unknown as ModelProvider, true)) + const { result: emptyResult } = renderHook(() => + useModelFormSchemas({} as unknown as ModelProvider, true), + ) expect(emptyResult.current.formSchemas).toHaveLength(1) // only __authorization_name__ }) it('computes form values correctly for credentials and models', () => { const mockCredential = { credential_name: 'Test' } as unknown as Credential - const mockModel = { model: 'gpt-4', model_type: 'text-generation' } as unknown as CustomModelCredential - const { result } = renderHook(() => useModelFormSchemas(mockProvider, true, { api_key: 'val' }, mockCredential, mockModel)) + const mockModel = { + model: 'gpt-4', + model_type: 'text-generation', + } as unknown as CustomModelCredential + const { result } = renderHook(() => + useModelFormSchemas(mockProvider, true, { api_key: 'val' }, mockCredential, mockModel), + ) expect((result.current.formValues as Record<string, unknown>).api_key).toBe('val') - expect((result.current.formValues as Record<string, unknown>).__authorization_name__).toBe('Test') + expect((result.current.formValues as Record<string, unknown>).__authorization_name__).toBe( + 'Test', + ) expect((result.current.formValues as Record<string, unknown>).__model_name).toBe('gpt-4') // Branch: credential present but credentials (param) missing - const { result: emptyCredsRes } = renderHook(() => useModelFormSchemas(mockProvider, true, undefined, mockCredential)) - expect((emptyCredsRes.current.formValues as Record<string, unknown>).__authorization_name__).toBe('Test') + const { result: emptyCredsRes } = renderHook(() => + useModelFormSchemas(mockProvider, true, undefined, mockCredential), + ) + expect( + (emptyCredsRes.current.formValues as Record<string, unknown>).__authorization_name__, + ).toBe('Test') }) it('handles model name and type schemas for custom models', () => { @@ -72,7 +81,11 @@ describe('useModelFormSchemas', () => { expect(custom.current.modelNameAndTypeFormSchemas[0]!.variable).toBe('__model_name') const mockModel = { model: 'custom', model_type: 'text' } as unknown as CustomModelCredential - const { result: customWithVal } = renderHook(() => useModelFormSchemas(mockProvider, false, undefined, undefined, mockModel)) - expect((customWithVal.current.modelNameAndTypeFormValues as Record<string, unknown>).__model_name).toBe('custom') + const { result: customWithVal } = renderHook(() => + useModelFormSchemas(mockProvider, false, undefined, undefined, mockModel), + ) + expect( + (customWithVal.current.modelNameAndTypeFormValues as Record<string, unknown>).__model_name, + ).toBe('custom') }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth-service.ts b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth-service.ts index a05e8627e09920..a2dfc4f1b13c0f 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth-service.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth-service.ts @@ -1,6 +1,4 @@ -import type { - CustomModel, -} from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { CustomModel } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useCallback } from 'react' import { useActiveModelCredential, @@ -15,9 +13,26 @@ import { useGetProviderCredential, } from '@/service/use-models' -export const useGetCredential = (provider: string, isModelCredential?: boolean, credentialId?: string, model?: CustomModel, configFrom?: string) => { - const providerData = useGetProviderCredential(!isModelCredential && !!credentialId, provider, credentialId) - const modelData = useGetModelCredential(!!isModelCredential && (!!credentialId || !!model), provider, credentialId, model?.model, model?.model_type, configFrom) +export const useGetCredential = ( + provider: string, + isModelCredential?: boolean, + credentialId?: string, + model?: CustomModel, + configFrom?: string, +) => { + const providerData = useGetProviderCredential( + !isModelCredential && !!credentialId, + provider, + credentialId, + ) + const modelData = useGetModelCredential( + !!isModelCredential && (!!credentialId || !!model), + provider, + credentialId, + model?.model, + model?.model_type, + configFrom, + ) return isModelCredential ? modelData : providerData } @@ -32,21 +47,33 @@ export const useAuthService = (provider: string) => { const { mutateAsync: deleteModelCredential } = useDeleteModelCredential(provider) const { mutateAsync: editModelCredential } = useEditModelCredential(provider) - const getAddCredentialService = useCallback((isModel: boolean) => { - return isModel ? addModelCredential : addProviderCredential - }, [addModelCredential, addProviderCredential]) + const getAddCredentialService = useCallback( + (isModel: boolean) => { + return isModel ? addModelCredential : addProviderCredential + }, + [addModelCredential, addProviderCredential], + ) - const getEditCredentialService = useCallback((isModel: boolean) => { - return isModel ? editModelCredential : editProviderCredential - }, [editModelCredential, editProviderCredential]) + const getEditCredentialService = useCallback( + (isModel: boolean) => { + return isModel ? editModelCredential : editProviderCredential + }, + [editModelCredential, editProviderCredential], + ) - const getDeleteCredentialService = useCallback((isModel: boolean) => { - return isModel ? deleteModelCredential : deleteProviderCredential - }, [deleteModelCredential, deleteProviderCredential]) + const getDeleteCredentialService = useCallback( + (isModel: boolean) => { + return isModel ? deleteModelCredential : deleteProviderCredential + }, + [deleteModelCredential, deleteProviderCredential], + ) - const getActiveCredentialService = useCallback((isModel: boolean) => { - return isModel ? activeModelCredential : activeProviderCredential - }, [activeModelCredential, activeProviderCredential]) + const getActiveCredentialService = useCallback( + (isModel: boolean) => { + return isModel ? activeModelCredential : activeProviderCredential + }, + [activeModelCredential, activeProviderCredential], + ) return { getAddCredentialService, diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.ts b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.ts index 234a90f403de16..7918092c7c36d4 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.ts @@ -1,8 +1,18 @@ -import type { ConfigurationMethodEnum, Credential, CustomConfigurationModelFixedFields, CustomModel, ModelModalModeEnum, ModelProvider } from '../../declarations' +import type { + ConfigurationMethodEnum, + Credential, + CustomConfigurationModelFixedFields, + CustomModel, + ModelModalModeEnum, + ModelProvider, +} from '../../declarations' import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useModelModalHandler, useRefreshModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { + useModelModalHandler, + useRefreshModel, +} from '@/app/components/header/account-setting/model-provider-page/hooks' import { useDeleteModel } from '@/service/use-models' import { useAuthService } from './use-auth-service' @@ -16,15 +26,25 @@ type ModelCredentialOperationPayload = { model_type: CustomModel['model_type'] } -export const useAuth = (provider: ModelProvider, configurationMethod: ConfigurationMethodEnum, currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields, extra: { - isModelCredential?: boolean - onUpdate?: (newPayload?: any, formValues?: Record<string, any>) => void - onRemove?: (credentialId: string) => void - mode?: ModelModalModeEnum -} = {}) => { +export const useAuth = ( + provider: ModelProvider, + configurationMethod: ConfigurationMethodEnum, + currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields, + extra: { + isModelCredential?: boolean + onUpdate?: (newPayload?: any, formValues?: Record<string, any>) => void + onRemove?: (credentialId: string) => void + mode?: ModelModalModeEnum + } = {}, +) => { const { isModelCredential, onUpdate, onRemove, mode } = extra const { t } = useTranslation() - const { getDeleteCredentialService, getActiveCredentialService, getEditCredentialService, getAddCredentialService } = useAuthService(provider.provider) + const { + getDeleteCredentialService, + getActiveCredentialService, + getEditCredentialService, + getAddCredentialService, + } = useAuthService(provider.provider) const { mutateAsync: deleteModelService } = useDeleteModel(provider.provider) const handleOpenModelModal = useModelModalHandler() const { handleRefreshModel } = useRefreshModel() @@ -40,23 +60,22 @@ export const useAuth = (provider: ModelProvider, configurationMethod: Configurat setDeleteModel(model) pendingOperationModel.current = model }, []) - const resolveModelContext = useCallback((model?: CustomModel | null): CustomModel | undefined => { - if (model) - return model + const resolveModelContext = useCallback( + (model?: CustomModel | null): CustomModel | undefined => { + if (model) return model - if (!currentCustomConfigurationModelFixedFields) - return undefined + if (!currentCustomConfigurationModelFixedFields) return undefined - return { - model: currentCustomConfigurationModelFixedFields.__model_name, - model_type: currentCustomConfigurationModelFixedFields.__model_type, - } - }, [currentCustomConfigurationModelFixedFields]) + return { + model: currentCustomConfigurationModelFixedFields.__model_name, + model_type: currentCustomConfigurationModelFixedFields.__model_type, + } + }, + [currentCustomConfigurationModelFixedFields], + ) const openConfirmDelete = useCallback((credential?: Credential, model?: CustomModel) => { - if (credential) - handleSetDeleteCredentialId(credential.credential_id) - if (model) - handleSetDeleteModel(model) + if (credential) handleSetDeleteCredentialId(credential.credential_id) + if (model) handleSetDeleteModel(model) }, []) const closeConfirmDelete = useCallback(() => { handleSetDeleteCredentialId(null) @@ -68,40 +87,39 @@ export const useAuth = (provider: ModelProvider, configurationMethod: Configurat doingActionRef.current = doing setDoingAction(doing) }, []) - const handleActiveCredential = useCallback(async (credential: Credential, model?: CustomModel) => { - if (doingActionRef.current) - return - try { - handleSetDoingAction(true) - const modelContext = model ?? (isModelCredential ? resolveModelContext() : undefined) - if (modelContext) { - const activeModelCredential = getActiveCredentialService(true) as ( - payload: ModelCredentialOperationPayload, - ) => Promise<unknown> - await activeModelCredential({ - credential_id: credential.credential_id, - model: modelContext.model, - model_type: modelContext.model_type, - }) - } - else { - const activeProviderCredential = getActiveCredentialService(false) as ( - payload: ProviderCredentialOperationPayload, - ) => Promise<unknown> - await activeProviderCredential({ - credential_id: credential.credential_id, - }) + const handleActiveCredential = useCallback( + async (credential: Credential, model?: CustomModel) => { + if (doingActionRef.current) return + try { + handleSetDoingAction(true) + const modelContext = model ?? (isModelCredential ? resolveModelContext() : undefined) + if (modelContext) { + const activeModelCredential = getActiveCredentialService(true) as ( + payload: ModelCredentialOperationPayload, + ) => Promise<unknown> + await activeModelCredential({ + credential_id: credential.credential_id, + model: modelContext.model, + model_type: modelContext.model_type, + }) + } else { + const activeProviderCredential = getActiveCredentialService(false) as ( + payload: ProviderCredentialOperationPayload, + ) => Promise<unknown> + await activeProviderCredential({ + credential_id: credential.credential_id, + }) + } + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + handleRefreshModel(provider, undefined, true) + } finally { + handleSetDoingAction(false) } - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - handleRefreshModel(provider, undefined, true) - } - finally { - handleSetDoingAction(false) - } - }, [getActiveCredentialService, isModelCredential, resolveModelContext, t, handleSetDoingAction]) + }, + [getActiveCredentialService, isModelCredential, resolveModelContext, t, handleSetDoingAction], + ) const handleConfirmDelete = useCallback(async () => { - if (doingActionRef.current) - return + if (doingActionRef.current) return if (!pendingOperationCredentialId.current && !pendingOperationModel.current) { closeConfirmDelete() return @@ -112,8 +130,7 @@ export const useAuth = (provider: ModelProvider, configurationMethod: Configurat if (pendingOperationCredentialId.current) { if (isModelCredential) { const modelContext = resolveModelContext(pendingOperationModel.current) - if (!modelContext) - return + if (!modelContext) return payload = { credential_id: pendingOperationCredentialId.current, @@ -124,8 +141,7 @@ export const useAuth = (provider: ModelProvider, configurationMethod: Configurat payload: ModelCredentialOperationPayload, ) => Promise<unknown> await deleteModelCredential(payload) - } - else { + } else { payload = { credential_id: pendingOperationCredentialId.current, } @@ -142,53 +158,71 @@ export const useAuth = (provider: ModelProvider, configurationMethod: Configurat } await deleteModelService(payload) } - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) handleRefreshModel(provider, undefined, true) onRemove?.(pendingOperationCredentialId.current ?? '') closeConfirmDelete() - } - finally { - handleSetDoingAction(false) - } - }, [t, handleSetDoingAction, getDeleteCredentialService, isModelCredential, closeConfirmDelete, handleRefreshModel, provider, configurationMethod, deleteModelService, resolveModelContext]) - const handleSaveCredential = useCallback(async (payload: Record<string, any>) => { - if (doingActionRef.current) - return - try { - handleSetDoingAction(true) - let res: { - result?: string - } = {} - if (payload.credential_id) - res = await getEditCredentialService(!!isModelCredential)(payload as any) - else - res = await getAddCredentialService(!!isModelCredential)(payload as any) - if (res.result === 'success') { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - handleRefreshModel(provider, undefined, !payload.credential_id) - } - } - finally { + } finally { handleSetDoingAction(false) } - }, [t, handleSetDoingAction, getEditCredentialService, getAddCredentialService]) - const handleOpenModal = useCallback((credential?: Credential, model?: CustomModel) => { - handleOpenModelModal(provider, configurationMethod, currentCustomConfigurationModelFixedFields, { - isModelCredential, - credential, - model, - onUpdate, - mode, - }) }, [ - handleOpenModelModal, + t, + handleSetDoingAction, + getDeleteCredentialService, + isModelCredential, + closeConfirmDelete, + handleRefreshModel, provider, configurationMethod, - currentCustomConfigurationModelFixedFields, - isModelCredential, - onUpdate, - mode, + deleteModelService, + resolveModelContext, ]) + const handleSaveCredential = useCallback( + async (payload: Record<string, any>) => { + if (doingActionRef.current) return + try { + handleSetDoingAction(true) + let res: { + result?: string + } = {} + if (payload.credential_id) + res = await getEditCredentialService(!!isModelCredential)(payload as any) + else res = await getAddCredentialService(!!isModelCredential)(payload as any) + if (res.result === 'success') { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + handleRefreshModel(provider, undefined, !payload.credential_id) + } + } finally { + handleSetDoingAction(false) + } + }, + [t, handleSetDoingAction, getEditCredentialService, getAddCredentialService], + ) + const handleOpenModal = useCallback( + (credential?: Credential, model?: CustomModel) => { + handleOpenModelModal( + provider, + configurationMethod, + currentCustomConfigurationModelFixedFields, + { + isModelCredential, + credential, + model, + onUpdate, + mode, + }, + ) + }, + [ + handleOpenModelModal, + provider, + configurationMethod, + currentCustomConfigurationModelFixedFields, + isModelCredential, + onUpdate, + mode, + ], + ) return { pendingOperationCredentialId, pendingOperationModel, diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-data.ts b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-data.ts index 48e23f58419319..690a3e543ffac5 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-data.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-data.ts @@ -6,16 +6,24 @@ import type { import { useMemo } from 'react' import { useGetCredential } from './use-auth-service' -export const useCredentialData = (provider: ModelProvider, providerFormSchemaPredefined: boolean, isModelCredential?: boolean, credential?: Credential, model?: CustomModelCredential) => { +export const useCredentialData = ( + provider: ModelProvider, + providerFormSchemaPredefined: boolean, + isModelCredential?: boolean, + credential?: Credential, + model?: CustomModelCredential, +) => { const configFrom = useMemo(() => { - if (providerFormSchemaPredefined) - return 'predefined-model' + if (providerFormSchemaPredefined) return 'predefined-model' return 'custom-model' }, [providerFormSchemaPredefined]) - const { - isLoading, - data: credentialData = {}, - } = useGetCredential(provider.provider, isModelCredential, credential?.credential_id, model, configFrom) + const { isLoading, data: credentialData = {} } = useGetCredential( + provider.provider, + isModelCredential, + credential?.credential_id, + model, + configFrom, + ) return { isLoading, diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-status.ts b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-status.ts index f5474f283001a6..515481dc2cd7d0 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-status.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-status.ts @@ -1,27 +1,35 @@ -import type { - ModelProvider, -} from '../../declarations' +import type { ModelProvider } from '../../declarations' import { useMemo } from 'react' export const useCredentialStatus = (provider: ModelProvider | undefined) => { - const { - current_credential_id, - current_credential_name, - available_credentials, - } = provider?.custom_configuration ?? {} + const { current_credential_id, current_credential_name, available_credentials } = + provider?.custom_configuration ?? {} const hasCredential = !!available_credentials?.length const authRemoved = hasCredential && !current_credential_id && !current_credential_name - const currentCredential = available_credentials?.find(credential => credential.credential_id === current_credential_id) + const currentCredential = available_credentials?.find( + (credential) => credential.credential_id === current_credential_id, + ) const notAllowedToUse = currentCredential?.not_allowed_to_use const authorized = !!(current_credential_id && current_credential_name && !notAllowedToUse) - return useMemo(() => ({ - hasCredential, - authorized, - authRemoved, - current_credential_id, - current_credential_name, - available_credentials, - notAllowedToUse, - }), [hasCredential, authorized, authRemoved, current_credential_id, current_credential_name, available_credentials, notAllowedToUse]) + return useMemo( + () => ({ + hasCredential, + authorized, + authRemoved, + current_credential_id, + current_credential_name, + available_credentials, + notAllowedToUse, + }), + [ + hasCredential, + authorized, + authRemoved, + current_credential_id, + current_credential_name, + available_credentials, + notAllowedToUse, + ], + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-custom-models.ts b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-custom-models.ts index 6abf6f51b61567..fe7a063b000e41 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-custom-models.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-custom-models.ts @@ -1,6 +1,4 @@ -import type { - ModelProvider, -} from '../../declarations' +import type { ModelProvider } from '../../declarations' export const useCustomModels = (provider: ModelProvider) => { const { custom_models } = provider.custom_configuration diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-model-form-schemas.ts b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-model-form-schemas.ts index 4a8d3d4982ba20..7234a3615601f9 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-model-form-schemas.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-model-form-schemas.ts @@ -1,15 +1,8 @@ -import type { - Credential, - CustomModelCredential, - ModelProvider, -} from '../../declarations' +import type { Credential, CustomModelCredential, ModelProvider } from '../../declarations' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { FormTypeEnum } from '@/app/components/base/form/types' -import { - genModelNameFormSchema, - genModelTypeFormSchema, -} from '../../utils' +import { genModelNameFormSchema, genModelTypeFormSchema } from '../../utils' export const useModelFormSchemas = ( provider: ModelProvider, @@ -19,11 +12,7 @@ export const useModelFormSchemas = ( model?: CustomModelCredential, ) => { const { t } = useTranslation() - const { - provider_credential_schema, - supported_model_types, - model_credential_schema, - } = provider + const { provider_credential_schema, supported_model_types, model_credential_schema } = provider const formSchemas = useMemo(() => { const schemas = providerFormSchemaPredefined ? provider_credential_schema?.credential_form_schemas @@ -42,14 +31,11 @@ export const useModelFormSchemas = ( const authorizationNameSchema = { type: FormTypeEnum.textInput, variable: '__authorization_name__', - label: t($ => $['auth.authorizationName'], { ns: 'plugin' }), + label: t(($) => $['auth.authorizationName'], { ns: 'plugin' }), required: false, } - return [ - authorizationNameSchema, - ...formSchemas, - ] + return [authorizationNameSchema, ...formSchemas] }, [formSchemas, t]) const formValues = useMemo(() => { @@ -59,33 +45,25 @@ export const useModelFormSchemas = ( }) if (credential) { result = { ...result, __authorization_name__: credential?.credential_name } - if (credentials) - result = { ...result, ...credentials } + if (credentials) result = { ...result, ...credentials } } - if (model) - result = { ...result, __model_name: model?.model, __model_type: model?.model_type } + if (model) result = { ...result, __model_name: model?.model, __model_type: model?.model_type } return result }, [credentials, credential, model, formSchemas]) const modelNameAndTypeFormSchemas = useMemo(() => { - if (providerFormSchemaPredefined) - return [] + if (providerFormSchemaPredefined) return [] const modelNameSchema = genModelNameFormSchema(model_credential_schema?.model) const modelTypeSchema = genModelTypeFormSchema(supported_model_types) - return [ - modelNameSchema, - modelTypeSchema, - ] + return [modelNameSchema, modelTypeSchema] }, [supported_model_types, model_credential_schema?.model, providerFormSchemaPredefined]) const modelNameAndTypeFormValues = useMemo(() => { let result = {} - if (providerFormSchemaPredefined) - return result + if (providerFormSchemaPredefined) return result - if (model) - result = { ...result, __model_name: model?.model, __model_type: model?.model_type } + if (model) result = { ...result, __model_name: model?.model, __model_type: model?.model_type } return result }, [model, providerFormSchemaPredefined]) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/manage-custom-model-credentials.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/manage-custom-model-credentials.tsx index adda49f02a19c0..5d849139d422a9 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/manage-custom-model-credentials.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/manage-custom-model-credentials.tsx @@ -2,23 +2,16 @@ import type { CustomConfigurationModelFixedFields, ModelProvider, } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { - Button, -} from '@langgenius/dify-ui/button' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { ConfigurationMethodEnum, ModelModalModeEnum, } from '@/app/components/header/account-setting/model-provider-page/declarations' import Authorized from './authorized' -import { - useCustomModels, -} from './hooks' +import { useCustomModels } from './hooks' type ManageCustomModelCredentialsProps = { provider: ModelProvider @@ -32,31 +25,30 @@ const ManageCustomModelCredentials = ({ const customModels = useCustomModels(provider) const noModels = !customModels.length - const renderTrigger = useCallback((open?: boolean) => { - const Item = ( - <Button - variant="ghost" - size="small" - className={cn( - 'mr-0.5 text-text-tertiary', - open && 'bg-components-button-ghost-bg-hover', - )} - > - {t($ => $['modelProvider.auth.manageCredentials'], { ns: 'common' })} - </Button> - ) - return Item - }, [t]) + const renderTrigger = useCallback( + (open?: boolean) => { + const Item = ( + <Button + variant="ghost" + size="small" + className={cn('mr-0.5 text-text-tertiary', open && 'bg-components-button-ghost-bg-hover')} + > + {t(($) => $['modelProvider.auth.manageCredentials'], { ns: 'common' })} + </Button> + ) + return Item + }, + [t], + ) - if (noModels) - return null + if (noModels) return null return ( <Authorized provider={provider} configurationMethod={ConfigurationMethodEnum.customizableModel} currentCustomConfigurationModelFixedFields={currentCustomConfigurationModelFixedFields} - items={customModels.map(model => ({ + items={customModels.map((model) => ({ model, credentials: model.available_model_credentials ?? [], selectedCredential: model.current_credential_id @@ -73,10 +65,12 @@ const ManageCustomModelCredentials = ({ }} hideAddAction disableItemClick - popupTitle={t($ => $['modelProvider.auth.customModelCredentials'], { ns: 'common' })} + popupTitle={t(($) => $['modelProvider.auth.customModelCredentials'], { ns: 'common' })} showModelTitle disableDeleteButShowAction - disableDeleteTip={t($ => $['modelProvider.auth.customModelCredentialsDeleteTip'], { ns: 'common' })} + disableDeleteTip={t(($) => $['modelProvider.auth.customModelCredentialsDeleteTip'], { + ns: 'common', + })} /> ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx index 4bb06df83b45e0..c9dbc5868353e0 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx @@ -1,21 +1,17 @@ import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot' import type { Dispatch, SetStateAction } from 'react' -import type { - Credential, - CustomModel, - ModelProvider, -} from '../declarations' +import type { Credential, CustomModel, ModelProvider } from '../declarations' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' -import { ConfigurationMethodEnum, ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + ConfigurationMethodEnum, + ModelModalModeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import Authorized from './authorized' @@ -41,23 +37,24 @@ const SwitchCredentialInLoadBalancing = ({ const notAllowCustomCredential = provider.allow_custom_token === false const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() const canOpenCredentialMenu = canUseCredential || canCreateCredential || canManageCredential - const handleItemClick = useCallback((credential: Credential) => { - if (!canUseCredential) - return + const handleItemClick = useCallback( + (credential: Credential) => { + if (!canUseCredential) return - setCustomModelCredential(credential) - }, [canUseCredential, setCustomModelCredential]) + setCustomModelCredential(credential) + }, + [canUseCredential, setCustomModelCredential], + ) const renderTrigger = useCallback(() => { const selectedCredentialId = customModelCredential?.credential_id - const currentCredential = credentials?.find(c => c.credential_id === selectedCredentialId) + const currentCredential = credentials?.find((c) => c.credential_id === selectedCredentialId) const empty = !credentials?.length const authRemoved = selectedCredentialId && !currentCredential && !empty const unavailable = currentCredential?.not_allowed_to_use let color: StatusDotStatus = 'success' - if (authRemoved || unavailable) - color = 'error' + if (authRemoved || unavailable) color = 'error' const Item = ( <Button @@ -65,37 +62,22 @@ const SwitchCredentialInLoadBalancing = ({ className={cn( 'shrink-0 space-x-1', (authRemoved || unavailable) && 'text-components-button-destructive-secondary-text', - (!canOpenCredentialMenu || (empty && !canCreateCredential)) && 'cursor-not-allowed opacity-50', + (!canOpenCredentialMenu || (empty && !canCreateCredential)) && + 'cursor-not-allowed opacity-50', )} > - { - !empty && ( - <StatusDot - className="mr-2" - status={color} - /> - ) - } - { - authRemoved && t($ => $['modelProvider.auth.authRemoved'], { ns: 'common' }) - } - { - unavailable && t($ => $['auth.credentialUnavailableInButton'], { ns: 'plugin' }) - } - { - empty && canCreateCredential && !notAllowCustomCredential && t($ => $['modelProvider.auth.addCredential'], { ns: 'common' }) - } - { - empty && (!canCreateCredential || notAllowCustomCredential) && t($ => $['auth.credentialUnavailableInButton'], { ns: 'plugin' }) - } - { - !authRemoved && !unavailable && !empty && customModelCredential?.credential_name - } - { - currentCredential?.from_enterprise && ( - <Badge className="ml-2">Enterprise</Badge> - ) - } + {!empty && <StatusDot className="mr-2" status={color} />} + {authRemoved && t(($) => $['modelProvider.auth.authRemoved'], { ns: 'common' })} + {unavailable && t(($) => $['auth.credentialUnavailableInButton'], { ns: 'plugin' })} + {empty && + canCreateCredential && + !notAllowCustomCredential && + t(($) => $['modelProvider.auth.addCredential'], { ns: 'common' })} + {empty && + (!canCreateCredential || notAllowCustomCredential) && + t(($) => $['auth.credentialUnavailableInButton'], { ns: 'plugin' })} + {!authRemoved && !unavailable && !empty && customModelCredential?.credential_name} + {currentCredential?.from_enterprise && <Badge className="ml-2">Enterprise</Badge>} <span className="i-ri-arrow-down-s-line size-4" /> </Button> ) @@ -104,24 +86,33 @@ const SwitchCredentialInLoadBalancing = ({ <Tooltip> <TooltipTrigger render={Item} /> <TooltipContent> - {t($ => $['auth.credentialUnavailable'], { ns: 'plugin' })} + {t(($) => $['auth.credentialUnavailable'], { ns: 'plugin' })} </TooltipContent> </Tooltip> ) } return Item - }, [canCreateCredential, canOpenCredentialMenu, customModelCredential, t, credentials, notAllowCustomCredential]) + }, [ + canCreateCredential, + canOpenCredentialMenu, + customModelCredential, + t, + credentials, + notAllowCustomCredential, + ]) return ( <Authorized provider={provider} configurationMethod={ConfigurationMethodEnum.customizableModel} - currentCustomConfigurationModelFixedFields={model - ? { - __model_name: model.model, - __model_type: model.model_type, - } - : undefined} + currentCustomConfigurationModelFixedFields={ + model + ? { + __model_name: model.model, + __model_type: model.model_type, + } + : undefined + } authParams={{ isModelCredential: true, mode: ModelModalModeEnum.configModelCredential, @@ -145,7 +136,7 @@ const SwitchCredentialInLoadBalancing = ({ enableAddModelCredential showItemSelectedIcon hideAddAction={!canCreateCredential} - popupTitle={t($ => $['modelProvider.auth.modelCredentials'], { ns: 'common' })} + popupTitle={t(($) => $['modelProvider.auth.modelCredentials'], { ns: 'common' })} triggerOnlyOpenModal={!credentials?.length && canCreateCredential} /> ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-badge/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-badge/index.tsx index 4f9fe389d60e44..c6d663b9a856f3 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-badge/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-badge/index.tsx @@ -5,12 +5,14 @@ type ModelBadgeProps = { className?: string children?: ReactNode } -const ModelBadge: FC<ModelBadgeProps> = ({ - className, - children, -}) => { +const ModelBadge: FC<ModelBadgeProps> = ({ className, children }) => { return ( - <div className={cn('inline-flex h-[18px] shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary', className)}> + <div + className={cn( + 'inline-flex h-[18px] shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary', + className, + )} + > {children} </div> ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-icon/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-icon/__tests__/index.spec.tsx index 0d6c84c7dbc669..9f0bf05ffeec09 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-icon/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-icon/__tests__/index.spec.tsx @@ -1,11 +1,7 @@ import type { Model } from '../../declarations' import { render, screen } from '@testing-library/react' import { Theme } from '@/types/app' -import { - ConfigurationMethodEnum, - ModelStatusEnum, - ModelTypeEnum, -} from '../../declarations' +import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '../../declarations' import ModelIcon from '../index' type I18nText = { @@ -65,7 +61,10 @@ describe('ModelIcon', () => { render(<ModelIcon provider={provider} />) - expect(screen.getByRole('img', { name: /model-icon/i })).toHaveAttribute('src', 'light-only.png') + expect(screen.getByRole('img', { name: /model-icon/i })).toHaveAttribute( + 'src', + 'light-only.png', + ) }) // Theme selection diff --git a/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx index a558ea0da7d1cf..61cb95a3b9b634 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx @@ -1,8 +1,5 @@ import type { FC } from 'react' -import type { - Model, - ModelProvider, -} from '../declarations' +import type { Model, ModelProvider } from '../declarations' import { cn } from '@langgenius/dify-ui/cn' import { OpenaiYellow } from '@/app/components/base/icons/src/public/llm' import useTheme from '@/hooks/use-theme' @@ -27,29 +24,42 @@ const ModelIcon: FC<ModelIconProps> = ({ const { theme } = useTheme() const language = useLanguage() const lightIconUrl = provider?.icon_small ? renderI18nObject(provider.icon_small, language) : '' - const darkIconUrl = provider?.icon_small_dark ? renderI18nObject(provider.icon_small_dark, language) : '' + const darkIconUrl = provider?.icon_small_dark + ? renderI18nObject(provider.icon_small_dark, language) + : '' const iconUrl = theme === Theme.dark ? darkIconUrl || lightIconUrl : lightIconUrl - if (provider?.provider && ['openai', 'langgenius/openai/openai'].includes(provider.provider) && modelName?.startsWith('o')) - return <div className="flex items-center justify-center"><OpenaiYellow className={cn('size-5', className)} /></div> + if ( + provider?.provider && + ['openai', 'langgenius/openai/openai'].includes(provider.provider) && + modelName?.startsWith('o') + ) + return ( + <div className="flex items-center justify-center"> + <OpenaiYellow className={cn('size-5', className)} /> + </div> + ) if (iconUrl) { return ( - <div className={cn('flex size-5 items-center justify-center', isDeprecated && 'opacity-50', className)}> - <img - alt="model-icon" - src={iconUrl} - className={iconClassName} - /> + <div + className={cn( + 'flex size-5 items-center justify-center', + isDeprecated && 'opacity-50', + className, + )} + > + <img alt="model-icon" src={iconUrl} className={iconClassName} /> </div> ) } return ( - <div className={cn( - 'flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle', - className, - )} + <div + className={cn( + 'flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle', + className, + )} > <div className={cn('flex size-5 items-center justify-center opacity-35', iconClassName)}> <span aria-hidden className="i-custom-vender-other-group size-3 text-text-tertiary" /> diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx index 8e92f077fe9a05..d08c98eafffb6f 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx @@ -9,14 +9,20 @@ import type { CredentialFormSchemaTextInput, FormValue, } from '../declarations' -import type { - NodeOutPutVar, -} from '@/app/components/workflow/types' +import type { NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { Field, FieldItem, FieldLabel } from '@langgenius/dify-ui/field' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' import { Radio, RadioGroup } from '@langgenius/dify-ui/radio' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectLabel, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { useCallback, useState } from 'react' import { Infotip } from '@/app/components/base/infotip' import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector' @@ -69,7 +75,13 @@ type FormProps< props: Omit<FormProps<CustomFormSchema>, 'override' | 'customRenderField'>, ) => ReactNode // If return falsy value, this field will fallback to default render - override?: [Array<FormTypeEnum>, (formSchema: CredentialFormSchema, props: Omit<FormProps<CustomFormSchema>, 'override' | 'customRenderField'>) => ReactNode] + override?: [ + Array<FormTypeEnum>, + ( + formSchema: CredentialFormSchema, + props: Omit<FormProps<CustomFormSchema>, 'override' | 'customRenderField'>, + ) => ReactNode, + ] nodeId?: string nodeOutputVars?: NodeOutPutVar[] availableNodes?: Node[] @@ -119,85 +131,103 @@ function Form< } const handleFormChange = (key: string, val: FormValue[string]) => { - if (isEditMode && (key === '__model_type' || key === '__model_name')) - return + if (isEditMode && (key === '__model_type' || key === '__model_name')) return setChangeKey(key) const shouldClearVariable: Record<string, string | undefined> = {} if (showOnVariableMap[key]?.length) { showOnVariableMap[key].forEach((clearVariable) => { - const schema = formSchemas.find(it => it.variable === clearVariable) + const schema = formSchemas.find((it) => it.variable === clearVariable) shouldClearVariable[clearVariable] = schema ? schema.default : undefined }) } onChange({ ...value, [key]: val, ...shouldClearVariable }) } - const handleModelChanged = useCallback((key: string, model: ModelSelectorValue) => { - const newValue = { - ...value[key], - ...model, - type: FormTypeEnum.modelSelector, - } - onChange({ ...value, [key]: newValue }) - }, [onChange, value]) + const handleModelChanged = useCallback( + (key: string, model: ModelSelectorValue) => { + const newValue = { + ...value[key], + ...model, + type: FormTypeEnum.modelSelector, + } + onChange({ ...value, [key]: newValue }) + }, + [onChange, value], + ) const renderField = (formSchema: CredentialFormSchema | CustomFormSchema) => { const infotip = formSchema.tooltip const infotipText = infotip?.[language] || infotip?.en_US - const infotipContent = (infotipText && ( - <Infotip - aria-label={infotipText} - className="ml-1" - popupClassName="w-[200px] max-w-[200px]" - > + const infotipContent = infotipText && ( + <Infotip aria-label={infotipText} className="ml-1" popupClassName="w-[200px] max-w-[200px]"> {infotipText} </Infotip> - )) + ) if (override) { const [overrideTypes, overrideRender] = override if (overrideTypes.includes(formSchema.type as FormTypeEnum)) { const node = overrideRender(formSchema as CredentialFormSchema, filteredProps) - if (node) - return node + if (node) return node } } - if (formSchema.type === FormTypeEnum.textInput || formSchema.type === FormTypeEnum.secretInput || formSchema.type === FormTypeEnum.textNumber) { - const { - variable, - label, - placeholder, - required, - show_on, - } = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput) - - if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)) + if ( + formSchema.type === FormTypeEnum.textInput || + formSchema.type === FormTypeEnum.secretInput || + formSchema.type === FormTypeEnum.textNumber + ) { + const { variable, label, placeholder, required, show_on } = formSchema as + | CredentialFormSchemaTextInput + | CredentialFormSchemaSecretInput + + if ( + show_on.length && + !show_on.every((showOnItem) => value[showOnItem.variable] === showOnItem.value) + ) return null - const disabled = readonly || (isEditMode && (variable === '__model_type' || variable === '__model_name')) + const disabled = + readonly || (isEditMode && (variable === '__model_type' || variable === '__model_name')) return ( <div key={variable} className={cn(itemClassName, 'py-3')}> - <div className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}> - {label[language] || label.en_US} - {required && ( - <span className="ml-1 text-red-500">*</span> + <div + className={cn( + fieldLabelClassName, + 'flex items-center py-2 system-sm-semibold text-text-secondary', )} + > + {label[language] || label.en_US} + {required && <span className="ml-1 text-red-500">*</span>} {infotipContent} </div> <Input className={cn(inputClassName, `${disabled && 'cursor-not-allowed opacity-60'}`)} - value={(isShowDefaultValue && ((value[variable] as string) === '' || value[variable] === undefined || value[variable] === null)) ? formSchema.default : value[variable]} - onChange={val => handleFormChange(variable, val)} + value={ + isShowDefaultValue && + ((value[variable] as string) === '' || + value[variable] === undefined || + value[variable] === null) + ? formSchema.default + : value[variable] + } + onChange={(val) => handleFormChange(variable, val)} validated={validatedSuccess} placeholder={placeholder?.[language] || placeholder?.en_US} disabled={disabled} - type={formSchema.type === FormTypeEnum.secretInput - ? 'password' - : formSchema.type === FormTypeEnum.textNumber - ? 'number' - : 'text'} - {...(formSchema.type === FormTypeEnum.textNumber ? { min: (formSchema as CredentialFormSchemaNumberInput).min, max: (formSchema as CredentialFormSchemaNumberInput).max } : {})} + type={ + formSchema.type === FormTypeEnum.secretInput + ? 'password' + : formSchema.type === FormTypeEnum.textNumber + ? 'number' + : 'text' + } + {...(formSchema.type === FormTypeEnum.textNumber + ? { + min: (formSchema as CredentialFormSchemaNumberInput).min, + max: (formSchema as CredentialFormSchemaNumberInput).max, + } + : {})} /> {fieldMoreInfo?.(formSchema)} {validating && changeKey === variable && <ValidatingTip />} @@ -206,15 +236,13 @@ function Form< } if (formSchema.type === FormTypeEnum.radio) { - const { - options, - variable, - label, - show_on, - required, - } = formSchema as CredentialFormSchemaRadio + const { options, variable, label, show_on, required } = + formSchema as CredentialFormSchemaRadio - if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)) + if ( + show_on.length && + !show_on.every((showOnItem) => value[showOnItem.variable] === showOnItem.value) + ) return null const disabled = isEditMode && (variable === '__model_type' || variable === '__model_name') @@ -225,41 +253,46 @@ function Form< return ( <Field key={variable} name={variable} className="contents"> <Fieldset - render={( + render={ <RadioGroup value={selectedValue} - onValueChange={val => handleFormChange(variable, val)} + onValueChange={(val) => handleFormChange(variable, val)} className={cn(itemClassName, 'grid gap-3 py-3', gridColumnsClassName)} /> - )} + } > - <FieldsetLegend className={cn(fieldLabelClassName, 'col-span-full flex items-center py-2 system-sm-semibold text-text-secondary')}> - <span>{translatedLabel}</span> - {required && ( - <span className="ml-1 text-red-500">*</span> + <FieldsetLegend + className={cn( + fieldLabelClassName, + 'col-span-full flex items-center py-2 system-sm-semibold text-text-secondary', )} + > + <span>{translatedLabel}</span> + {required && <span className="ml-1 text-red-500">*</span>} {infotipContent} </FieldsetLegend> - {options.filter((option) => { - if (option.show_on.length) - return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) - - return true - }).map(option => ( - <FieldItem key={`${variable}-${option.value}`} className="min-w-0"> - <FieldLabel - className={` - flex cursor-pointer items-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg px-3 py-2 - ${value[variable] === option.value && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm'} - ${disabled && 'cursor-not-allowed! opacity-60'} - `} - > - <Radio value={option.value} disabled={disabled} /> - - <div className="system-sm-regular text-text-secondary">{option.label[language] || option.label.en_US}</div> - </FieldLabel> - </FieldItem> - ))} + {options + .filter((option) => { + if (option.show_on.length) + return option.show_on.every( + (showOnItem) => value[showOnItem.variable] === showOnItem.value, + ) + + return true + }) + .map((option) => ( + <FieldItem key={`${variable}-${option.value}`} className="min-w-0"> + <FieldLabel + className={`flex cursor-pointer items-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg px-3 py-2 ${value[variable] === option.value && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm'} ${disabled && 'cursor-not-allowed! opacity-60'} `} + > + <Radio value={option.value} disabled={disabled} /> + + <div className="system-sm-regular text-text-secondary"> + {option.label[language] || option.label.en_US} + </div> + </FieldLabel> + </FieldItem> + ))} <div className="col-span-full"> {fieldMoreInfo?.(formSchema)} {validating && changeKey === variable && <ValidatingTip />} @@ -270,46 +303,56 @@ function Form< } if (formSchema.type === FormTypeEnum.select) { - const { - options, - variable, - label, - show_on, - required, - placeholder, - } = formSchema as CredentialFormSchemaSelect + const { options, variable, label, show_on, required, placeholder } = + formSchema as CredentialFormSchemaSelect - if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)) + if ( + show_on.length && + !show_on.every((showOnItem) => value[showOnItem.variable] === showOnItem.value) + ) return null - const filteredOptions = options.filter((option) => { - if (option.show_on.length) - return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) - - return true - }).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US })) - const currentValue = (isShowDefaultValue && ((value[variable] as string) === '' || value[variable] === undefined || value[variable] === null)) - ? formSchema.default - : value[variable] - const selectedOption = filteredOptions.find(option => option.value === currentValue) + const filteredOptions = options + .filter((option) => { + if (option.show_on.length) + return option.show_on.every( + (showOnItem) => value[showOnItem.variable] === showOnItem.value, + ) + + return true + }) + .map((option) => ({ + value: option.value, + name: option.label[language] || option.label.en_US, + })) + const currentValue = + isShowDefaultValue && + ((value[variable] as string) === '' || + value[variable] === undefined || + value[variable] === null) + ? formSchema.default + : value[variable] + const selectedOption = filteredOptions.find((option) => option.value === currentValue) const translatedLabel = label[language] || label.en_US return ( <div key={variable} className={cn(itemClassName, 'py-3')}> - <div className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}> + <div + className={cn( + fieldLabelClassName, + 'flex items-center py-2 system-sm-semibold text-text-secondary', + )} + > {translatedLabel} - {required && ( - <span className="ml-1 text-red-500">*</span> - )} + {required && <span className="ml-1 text-red-500">*</span>} {infotipContent} </div> <Select disabled={readonly} value={selectedOption?.value ?? null} onValueChange={(nextValue) => { - if (!nextValue) - return + if (!nextValue) return handleFormChange(variable, nextValue) }} > @@ -318,7 +361,7 @@ function Form< {selectedOption?.name ?? placeholder?.[language] ?? placeholder?.en_US} </SelectTrigger> <SelectContent> - {filteredOptions.map(option => ( + {filteredOptions.map((option) => ( <SelectItem key={option.value} value={option.value}> <SelectItemText>{option.name}</SelectItemText> <SelectItemIndicator /> @@ -333,14 +376,12 @@ function Form< } if (formSchema.type === FormTypeEnum.checkbox) { - const { - variable, - label, - show_on, - required, - } = formSchema as CredentialFormSchemaRadio + const { variable, label, show_on, required } = formSchema as CredentialFormSchemaRadio - if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)) + if ( + show_on.length && + !show_on.every((showOnItem) => value[showOnItem.variable] === showOnItem.value) + ) return null const booleanValue = typeof value[variable] === 'boolean' ? value[variable] : undefined const translatedLabel = label[language] || label.en_US @@ -349,19 +390,22 @@ function Form< <div key={variable} className={cn(itemClassName, 'py-3')}> <Field name={variable} className="contents"> <Fieldset - render={( + render={ <RadioGroup<boolean> className="flex items-center justify-between gap-3 py-2" value={booleanValue} - onValueChange={val => handleFormChange(variable, val)} + onValueChange={(val) => handleFormChange(variable, val)} /> - )} + } > - <FieldsetLegend className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}> - <span>{translatedLabel}</span> - {required && ( - <span className="ml-1 text-red-500">*</span> + <FieldsetLegend + className={cn( + fieldLabelClassName, + 'flex items-center py-2 system-sm-semibold text-text-secondary', )} + > + <span>{translatedLabel}</span> + {required && <span className="ml-1 text-red-500">*</span>} {infotipContent} </FieldsetLegend> <div className="flex items-center gap-3"> @@ -386,19 +430,19 @@ function Form< } if (formSchema.type === FormTypeEnum.modelSelector) { - const { - variable, - label, - required, - scope, - } = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput) + const { variable, label, required, scope } = formSchema as + | CredentialFormSchemaTextInput + | CredentialFormSchemaSecretInput return ( <div key={variable} className={cn(itemClassName, 'py-3')}> - <div className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}> - {label[language] || label.en_US} - {required && ( - <span className="ml-1 text-red-500">*</span> + <div + className={cn( + fieldLabelClassName, + 'flex items-center py-2 system-sm-semibold text-text-secondary', )} + > + {label[language] || label.en_US} + {required && <span className="ml-1 text-red-500">*</span>} {infotipContent} </div> <ModelParameterModal @@ -407,7 +451,7 @@ function Form< isInWorkflow isAgentStrategy={isAgentStrategy} value={value[variable]} - setModel={model => handleModelChanged(variable, model)} + setModel={(model) => handleModelChanged(variable, model)} readonly={readonly} scope={scope} /> @@ -418,19 +462,19 @@ function Form< } if (formSchema.type === FormTypeEnum.toolSelector) { - const { - variable, - label, - required, - scope, - } = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput) + const { variable, label, required, scope } = formSchema as + | CredentialFormSchemaTextInput + | CredentialFormSchemaSecretInput return ( <div key={variable} className={cn(itemClassName, 'py-3')}> - <div className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}> - {label[language] || label.en_US} - {required && ( - <span className="ml-1 text-red-500">*</span> + <div + className={cn( + fieldLabelClassName, + 'flex items-center py-2 system-sm-semibold text-text-secondary', )} + > + {label[language] || label.en_US} + {required && <span className="ml-1 text-red-500">*</span>} {infotipContent} </div> <ToolSelector @@ -441,7 +485,7 @@ function Form< disabled={readonly} value={value[variable]} // selectedTools={value[variable] ? [value[variable]] : []} - onSelect={item => handleFormChange(variable, item)} + onSelect={(item) => handleFormChange(variable, item)} onDelete={() => handleFormChange(variable, null)} /> {fieldMoreInfo?.(formSchema)} @@ -457,7 +501,7 @@ function Form< tooltip: infotip, required, scope, - } = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput) + } = formSchema as CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput return ( <div key={variable} className={cn(itemClassName, 'py-3')}> @@ -471,7 +515,7 @@ function Form< required={required} tooltip={infotip?.[language] || infotip?.en_US} value={value[variable] || []} - onChange={item => handleFormChange(variable, item)} + onChange={(item) => handleFormChange(variable, item)} supportCollapse /> {fieldMoreInfo?.(formSchema)} @@ -481,27 +525,29 @@ function Form< } if (formSchema.type === FormTypeEnum.appSelector) { - const { - variable, - label, - required, - scope, - } = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput) + const { variable, label, required, scope } = formSchema as + | CredentialFormSchemaTextInput + | CredentialFormSchemaSecretInput return ( <div key={variable} className={cn(itemClassName, 'py-3')}> - <div className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}> - {label[language] || label.en_US} - {required && ( - <span className="ml-1 text-red-500">*</span> + <div + className={cn( + fieldLabelClassName, + 'flex items-center py-2 system-sm-semibold text-text-secondary', )} + > + {label[language] || label.en_US} + {required && <span className="ml-1 text-red-500">*</span>} {infotipContent} </div> <AppSelector disabled={readonly} scope={scope} value={value[variable]} - onSelect={item => handleFormChange(variable, { ...item, type: FormTypeEnum.appSelector })} + onSelect={(item) => + handleFormChange(variable, { ...item, type: FormTypeEnum.appSelector }) + } /> {fieldMoreInfo?.(formSchema)} {validating && changeKey === variable && <ValidatingTip />} @@ -510,20 +556,20 @@ function Form< } if (formSchema.type === FormTypeEnum.any) { - const { - variable, - label, - required, - scope, - } = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput) + const { variable, label, required, scope } = formSchema as + | CredentialFormSchemaTextInput + | CredentialFormSchemaSecretInput return ( <div key={variable} className={cn(itemClassName, 'py-3')}> - <div className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}> - {label[language] || label.en_US} - {required && ( - <span className="ml-1 text-red-500">*</span> + <div + className={cn( + fieldLabelClassName, + 'flex items-center py-2 system-sm-semibold text-text-secondary', )} + > + {label[language] || label.en_US} + {required && <span className="ml-1 text-red-500">*</span>} {infotipContent} </div> <VarReferencePicker @@ -531,10 +577,9 @@ function Form< isShowNodeName nodeId={nodeId || ''} value={value[variable] || []} - onChange={item => handleFormChange(variable, item)} + onChange={(item) => handleFormChange(variable, item)} filterVar={(varPayload) => { - if (!scope) - return true + if (!scope) return true return scope.split('&').includes(varPayload.type) }} /> @@ -549,11 +594,7 @@ function Form< return customRenderField?.(formSchema as CustomFormSchema, filteredProps) } - return ( - <div className={className}> - {formSchemas.map(formSchema => renderField(formSchema))} - </div> - ) + return <div className={className}>{formSchemas.map((formSchema) => renderField(formSchema))}</div> } export default Form diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Input.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Input.tsx index 6c6d6110f1ad8e..5712a47a7ba256 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Input.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Input.tsx @@ -33,8 +33,7 @@ const Input: FC<InputProps> = ({ onChange(`${min}`) return } - if (!isNaN(maxNum) && Number.parseFloat(v) > maxNum) - onChange(`${max}`) + if (!isNaN(maxNum) && Number.parseFloat(v) > maxNum) onChange(`${max}`) } return ( @@ -42,18 +41,10 @@ const Input: FC<InputProps> = ({ <input tabIndex={0} // Do not set autoComplete for security - prevents browser from storing sensitive API keys - className={` - block h-8 w-full appearance-none rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-sm - text-components-input-text-filled caret-primary-600 outline-hidden - placeholder:text-sm placeholder:text-text-tertiary - hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active - focus:bg-components-input-bg-active focus:shadow-xs - ${validated ? 'pr-[30px]' : ''} - ${className || ''} - `} + className={`block h-8 w-full appearance-none rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-sm text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-sm placeholder:text-text-tertiary hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs ${validated ? 'pr-[30px]' : ''} ${className || ''} `} placeholder={placeholder || ''} - onChange={e => onChange(e.target.value)} - onBlur={e => toLimit(e.target.value)} + onChange={(e) => onChange(e.target.value)} + onBlur={(e) => toLimit(e.target.value)} onFocus={onFocus} value={value} disabled={disabled} diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Form.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Form.spec.tsx index 3dc9e0f1d9c887..ebefaab3bee64b 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Form.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Form.spec.tsx @@ -31,26 +31,32 @@ vi.mock('../../hooks', () => ({ vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ AppSelector: ({ onSelect }: { onSelect: (item: AppSelectorValue) => void }) => ( - <button type="button" onClick={() => onSelect({ app_id: 'app-1', inputs: {}, files: [] })}>Select App</button> + <button type="button" onClick={() => onSelect({ app_id: 'app-1', inputs: {}, files: [] })}> + Select App + </button> ), })) vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ default: (props: { - setModel: (model: { model: string, model_type: string }) => void + setModel: (model: { model: string; model_type: string }) => void isAgentStrategy?: boolean readonly?: boolean }) => { modelSelectorPropsSpy(props) return ( - <button type="button" onClick={() => props.setModel({ model: 'gpt-1', model_type: 'llm' })}>Select Model</button> + <button type="button" onClick={() => props.setModel({ model: 'gpt-1', model_type: 'llm' })}> + Select Model + </button> ) }, })) vi.mock('@/app/components/plugins/plugin-detail-panel/multiple-tool-selector', () => ({ default: ({ onChange }: { onChange: (items: Array<{ id: string }>) => void }) => ( - <button type="button" onClick={() => onChange([{ id: 'tool-1' }])}>Select Tools</button> + <button type="button" onClick={() => onChange([{ id: 'tool-1' }])}> + Select Tools + </button> ), })) @@ -65,22 +71,34 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ toolSelectorPropsSpy(props) return ( <div> - <button type="button" onClick={() => props.onSelect({ id: 'tool-1' })}>Select Tool</button> - <button type="button" onClick={props.onDelete}>Remove Tool</button> + <button type="button" onClick={() => props.onSelect({ id: 'tool-1' })}> + Select Tool + </button> + <button type="button" onClick={props.onDelete}> + Remove Tool + </button> </div> ) }, })) vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ - default: ({ filterVar, onChange }: { filterVar?: (payload: MockVarPayload) => boolean, onChange: (items: Array<{ name: string }>) => void }) => { + default: ({ + filterVar, + onChange, + }: { + filterVar?: (payload: MockVarPayload) => boolean + onChange: (items: Array<{ name: string }>) => void + }) => { const allowed = filterVar ? filterVar({ type: 'text' }) : true const blocked = filterVar ? filterVar({ type: 'image' }) : false return ( <div> <div>{allowed ? 'allowed' : 'blocked'}</div> <div>{blocked ? 'allowed' : 'blocked'}</div> - <button type="button" onClick={() => onChange([{ name: 'var-1' }])}>Pick Variable</button> + <button type="button" onClick={() => onChange([{ name: 'var-1' }])}> + Pick Variable + </button> </div> ) }, @@ -91,7 +109,8 @@ vi.mock('../../../key-validator/ValidateStatus', () => ({ })) const createI18n = (text: string) => ({ en_US: text, zh_Hans: text }) -const createPartialI18n = (text: string) => ({ en_US: text } as unknown as ReturnType<typeof createI18n>) +const createPartialI18n = (text: string) => + ({ en_US: text }) as unknown as ReturnType<typeof createI18n> const createBaseSchema = ( type: FormTypeEnum, @@ -106,8 +125,12 @@ const createBaseSchema = ( ...overrides, }) -const createTextSchema = (overrides: Partial<CredentialFormSchemaTextInput> & { type?: FormTypeEnum }) => ({ - ...createBaseSchema(overrides.type ?? FormTypeEnum.textInput, { variable: overrides.variable ?? 'text' }), +const createTextSchema = ( + overrides: Partial<CredentialFormSchemaTextInput> & { type?: FormTypeEnum }, +) => ({ + ...createBaseSchema(overrides.type ?? FormTypeEnum.textInput, { + variable: overrides.variable ?? 'text', + }), placeholder: createI18n('Input'), ...overrides, }) @@ -247,23 +270,23 @@ describe('Form', () => { label: createI18n('Region'), options: [ { label: createI18n('US'), value: 'us', show_on: [] }, - { label: createI18n('EU'), value: 'eu', show_on: [{ variable: 'toggle', value: 'on' }] }, + { + label: createI18n('EU'), + value: 'eu', + show_on: [{ variable: 'toggle', value: 'on' }], + }, ], }), createRadioSchema({ variable: 'hidden_region', label: createI18n('Hidden Region'), show_on: [{ variable: 'toggle', value: 'hidden' }], - options: [ - { label: createI18n('Hidden A'), value: 'a', show_on: [] }, - ], + options: [{ label: createI18n('Hidden A'), value: 'a', show_on: [] }], }), createRadioSchema({ variable: '__model_name', label: createI18n('Locked'), - options: [ - { label: createI18n('Locked A'), value: 'a', show_on: [] }, - ], + options: [{ label: createI18n('Locked A'), value: 'a', show_on: [] }], }), ] const value: FormValue = { region: 'us', toggle: 'on', __model_name: 'a' } @@ -300,7 +323,11 @@ describe('Form', () => { show_on: [{ variable: 'toggle', value: 'on' }], options: [ { label: createI18n('Select A'), value: 'a', show_on: [] }, - { label: createI18n('Select B'), value: 'b', show_on: [{ variable: 'toggle', value: 'on' }] }, + { + label: createI18n('Select B'), + value: 'b', + show_on: [{ variable: 'toggle', value: 'on' }], + }, ], }), createRadioSchema({ @@ -375,7 +402,12 @@ describe('Form', () => { label: createI18n('App Selector'), }), ] - const value: FormValue = { model_selector: {}, tool_selector: null, multi_tool: [], app_selector: null } + const value: FormValue = { + model_selector: {}, + tool_selector: null, + multi_tool: [], + app_selector: null, + } const onChange = vi.fn() render( @@ -396,21 +428,31 @@ describe('Form', () => { fireEvent.click(screen.getByText('Select Tools')) fireEvent.click(screen.getByText('Select App')) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - model_selector: { model: 'gpt-1', model_type: 'llm', type: FormTypeEnum.modelSelector }, - })) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - tool_selector: { id: 'tool-1' }, - })) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - tool_selector: null, - })) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - multi_tool: [{ id: 'tool-1' }], - })) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - app_selector: { app_id: 'app-1', inputs: {}, files: [], type: FormTypeEnum.appSelector }, - })) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + model_selector: { model: 'gpt-1', model_type: 'llm', type: FormTypeEnum.modelSelector }, + }), + ) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + tool_selector: { id: 'tool-1' }, + }), + ) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + tool_selector: null, + }), + ) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + multi_tool: [{ id: 'tool-1' }], + }), + ) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + app_selector: { app_id: 'app-1', inputs: {}, files: [], type: FormTypeEnum.appSelector }, + }), + ) }) it('should render variable picker and custom render overrides', () => { @@ -439,7 +481,12 @@ describe('Form', () => { type: 'custom-type', }, ] - const value: FormValue = { override: '', any_var: [], any_without_scope: [], custom_field: '' } + const value: FormValue = { + override: '', + any_var: [], + any_without_scope: [], + custom_field: '', + } const onChange = vi.fn() render( @@ -452,8 +499,11 @@ describe('Form', () => { showOnVariableMap={{}} isEditMode={false} fieldMoreInfo={() => <div>Extra Info</div>} - override={[[FormTypeEnum.textInput], () => <div key="override-field">Override Field</div>]} - customRenderField={schema => ( + override={[ + [FormTypeEnum.textInput], + () => <div key="override-field">Override Field</div>, + ]} + customRenderField={(schema) => ( <div key={schema.variable}> Custom Render: {schema.variable} @@ -469,7 +519,12 @@ describe('Form', () => { fireEvent.click(screen.getAllByText('Pick Variable')[0]!) - expect(onChange).toHaveBeenCalledWith({ override: '', any_var: [{ name: 'var-1' }], any_without_scope: [], custom_field: '' }) + expect(onChange).toHaveBeenCalledWith({ + override: '', + any_var: [{ name: 'var-1' }], + any_without_scope: [], + custom_field: '', + }) expect(screen.getAllByText('Extra Info')).toHaveLength(2) }) @@ -632,7 +687,7 @@ describe('Form', () => { // Label with missing language key → en_US fallback used it('should fall back to en_US label when current language key is missing', () => { - // Arrange + // Arrange mockLanguageRef.value = 'fr_FR' const formSchemas: AnyFormSchema[] = [ createTextSchema({ @@ -702,7 +757,11 @@ describe('Form', () => { label: createI18n('Choice'), options: [ { label: createI18n('Always Visible'), value: 'a', show_on: [] }, - { label: createI18n('Conditional'), value: 'b', show_on: [{ variable: 'toggle', value: 'yes' }] }, + { + label: createI18n('Conditional'), + value: 'b', + show_on: [{ variable: 'toggle', value: 'yes' }], + }, ], }), ] @@ -751,7 +810,9 @@ describe('Form', () => { />, ) - fireEvent.change(screen.getByPlaceholderText('Model Name'), { target: { value: 'new-model' } }) + fireEvent.change(screen.getByPlaceholderText('Model Name'), { + target: { value: 'new-model' }, + }) expect(onChange).not.toHaveBeenCalled() }) @@ -918,7 +979,11 @@ describe('Form', () => { placeholder: createI18n('Pick one'), options: [ { label: createI18n('Always'), value: 'a', show_on: [] }, - { label: createI18n('Conditional'), value: 'b', show_on: [{ variable: 'toggle', value: 'yes' }] }, + { + label: createI18n('Conditional'), + value: 'b', + show_on: [{ variable: 'toggle', value: 'yes' }], + }, ], }), ] @@ -1030,9 +1095,7 @@ describe('Form', () => { createRadioSchema({ variable: '__model_type', label: createI18n('Model Type Radio'), - options: [ - { label: createI18n('Type A'), value: 'a', show_on: [] }, - ], + options: [{ label: createI18n('Type A'), value: 'a', show_on: [] }], }), ] const value: FormValue = { __model_type: 'a' } @@ -1680,10 +1743,12 @@ describe('Form', () => { ) expect(screen.getByText('Select Tool'))!.toBeInTheDocument() - expect(toolSelectorPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ - nodeOutputVars, - availableNodes, - })) + expect(toolSelectorPropsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + nodeOutputVars, + availableNodes, + }), + ) }) it('should pass isAgentStrategy to modelSelector', () => { @@ -1711,9 +1776,11 @@ describe('Form', () => { ) expect(screen.getByText('Select Model'))!.toBeInTheDocument() - expect(modelSelectorPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ - isAgentStrategy: true, - })) + expect(modelSelectorPropsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + isAgentStrategy: true, + }), + ) }) it('should use empty array fallback for multiToolSelector when value is null', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Input.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Input.spec.tsx index f652c148151496..7a7a290d5f6507 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Input.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Input.spec.tsx @@ -8,25 +8,13 @@ describe('Input', () => { // Rendering basics it('should render with the provided placeholder and value', () => { - render( - <Input - value="hello" - placeholder="API Key" - onChange={vi.fn()} - />, - ) + render(<Input value="hello" placeholder="API Key" onChange={vi.fn()} />) expect(screen.getByPlaceholderText('API Key')).toHaveValue('hello') }) it('should render password inputs without autocomplete attributes', () => { - render( - <Input - type="password" - placeholder="Secret" - onChange={vi.fn()} - />, - ) + render(<Input type="password" placeholder="Secret" onChange={vi.fn()} />) const input = screen.getByPlaceholderText('Secret') @@ -38,12 +26,7 @@ describe('Input', () => { it('should call onChange when the user types', () => { const onChange = vi.fn() - render( - <Input - placeholder="API Key" - onChange={onChange} - />, - ) + render(<Input placeholder="API Key" onChange={onChange} />) fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'next' } }) @@ -54,14 +37,7 @@ describe('Input', () => { it('should clamp to the min value when the input is below min on blur-sm', () => { const onChange = vi.fn() - render( - <Input - placeholder="Limit" - onChange={onChange} - min={2} - max={6} - />, - ) + render(<Input placeholder="Limit" onChange={onChange} min={2} max={6} />) const input = screen.getByPlaceholderText('Limit') fireEvent.change(input, { target: { value: '1' } }) @@ -73,14 +49,7 @@ describe('Input', () => { it('should clamp to the max value when the input is above max on blur-sm', () => { const onChange = vi.fn() - render( - <Input - placeholder="Limit" - onChange={onChange} - min={2} - max={6} - />, - ) + render(<Input placeholder="Limit" onChange={onChange} min={2} max={6} />) const input = screen.getByPlaceholderText('Limit') fireEvent.change(input, { target: { value: '8' } }) @@ -92,14 +61,7 @@ describe('Input', () => { it('should keep the value when it is within the min/max range on blur-sm', () => { const onChange = vi.fn() - render( - <Input - placeholder="Limit" - onChange={onChange} - min={2} - max={6} - />, - ) + render(<Input placeholder="Limit" onChange={onChange} min={2} max={6} />) const input = screen.getByPlaceholderText('Limit') fireEvent.change(input, { target: { value: '4' } }) @@ -112,12 +74,7 @@ describe('Input', () => { it('should not clamp when min and max are not provided', () => { const onChange = vi.fn() - render( - <Input - placeholder="Free" - onChange={onChange} - />, - ) + render(<Input placeholder="Free" onChange={onChange} />) const input = screen.getByPlaceholderText('Free') fireEvent.change(input, { target: { value: '999' } }) @@ -129,39 +86,21 @@ describe('Input', () => { }) it('should show check circle icon when validated is true', () => { - const { container } = render( - <Input - placeholder="Key" - onChange={vi.fn()} - validated - />, - ) + const { container } = render(<Input placeholder="Key" onChange={vi.fn()} validated />) expect(screen.getByPlaceholderText('Key')).toBeInTheDocument() expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).toBeInTheDocument() }) it('should not show check circle icon when validated is false', () => { - const { container } = render( - <Input - placeholder="Key" - onChange={vi.fn()} - validated={false} - />, - ) + const { container } = render(<Input placeholder="Key" onChange={vi.fn()} validated={false} />) expect(screen.getByPlaceholderText('Key')).toBeInTheDocument() expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).not.toBeInTheDocument() }) it('should apply disabled attribute when disabled prop is true', () => { - render( - <Input - placeholder="Disabled" - onChange={vi.fn()} - disabled - />, - ) + render(<Input placeholder="Disabled" onChange={vi.fn()} disabled />) expect(screen.getByPlaceholderText('Disabled')).toBeDisabled() }) @@ -169,26 +108,14 @@ describe('Input', () => { it('should call onFocus when input receives focus', () => { const onFocus = vi.fn() - render( - <Input - placeholder="Focus" - onChange={vi.fn()} - onFocus={onFocus} - />, - ) + render(<Input placeholder="Focus" onChange={vi.fn()} onFocus={onFocus} />) fireEvent.focus(screen.getByPlaceholderText('Focus')) expect(onFocus).toHaveBeenCalledTimes(1) }) it('should render with custom className', () => { - render( - <Input - placeholder="Styled" - onChange={vi.fn()} - className="custom-class" - />, - ) + render(<Input placeholder="Styled" onChange={vi.fn()} className="custom-class" />) expect(screen.getByPlaceholderText('Styled')).toHaveClass('custom-class') }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx index ab2e04a0856d22..4968a7fc88ef8a 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx @@ -28,7 +28,9 @@ vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({ })) vi.mock('../../model-auth', () => ({ - CredentialSelector: ({ credentials }: { credentials: Credential[] }) => <div>{`credentials:${credentials.length}`}</div>, + CredentialSelector: ({ credentials }: { credentials: Credential[] }) => ( + <div>{`credentials:${credentials.length}`}</div> + ), })) vi.mock('@langgenius/dify-ui/dialog', () => ({ @@ -46,8 +48,20 @@ vi.mock('@langgenius/dify-ui/alert-dialog', () => ({ return <div>{children}</div> }, AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>, - AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>, - AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>, + AlertDialogCancelButton: ({ children }: { children: ReactNode }) => ( + <button type="button">{children}</button> + ), + AlertDialogConfirmButton: ({ + children, + onClick, + }: { + children: ReactNode + onClick?: () => void + }) => ( + <button type="button" onClick={onClick}> + {children} + </button> + ), AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>, AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>, })) @@ -85,35 +99,39 @@ vi.mock('../../hooks', () => ({ useLanguage: () => mockLanguage, })) -const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({ - provider: 'openai', - label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - help: { - title: { en_US: 'Help', zh_Hans: '帮助' }, - url: { en_US: 'https://example.com', zh_Hans: 'https://example.cn' }, - }, - icon_small: { en_US: '', zh_Hans: '' }, - supported_model_types: [], - configurate_methods: [], - provider_credential_schema: { credential_form_schemas: [] }, - model_credential_schema: { - model: { label: { en_US: 'Model', zh_Hans: '模型' }, placeholder: { en_US: 'Select', zh_Hans: '选择' } }, - credential_form_schemas: [], - }, - custom_configuration: { - status: 'active', - available_credentials: [], - custom_models: [], - can_added_models: [], - }, - system_configuration: { - enabled: true, - current_quota_type: 'trial', - quota_configurations: [], - }, - allow_custom_token: true, - ...overrides, -} as unknown as ModelProvider) +const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => + ({ + provider: 'openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + help: { + title: { en_US: 'Help', zh_Hans: '帮助' }, + url: { en_US: 'https://example.com', zh_Hans: 'https://example.cn' }, + }, + icon_small: { en_US: '', zh_Hans: '' }, + supported_model_types: [], + configurate_methods: [], + provider_credential_schema: { credential_form_schemas: [] }, + model_credential_schema: { + model: { + label: { en_US: 'Model', zh_Hans: '模型' }, + placeholder: { en_US: 'Select', zh_Hans: '选择' }, + }, + credential_form_schemas: [], + }, + custom_configuration: { + status: 'active', + available_credentials: [], + custom_models: [], + can_added_models: [], + }, + system_configuration: { + enabled: true, + current_quota_type: 'trial', + quota_configurations: [], + }, + allow_custom_token: true, + ...overrides, + }) as unknown as ModelProvider describe('ModelModal dialog branches', () => { beforeEach(() => { @@ -239,7 +257,10 @@ describe('ModelModal dialog branches', () => { />, ) - expect(screen.getByRole('link', { name: 'https://example.cn' })).toHaveAttribute('href', 'https://example.cn') + expect(screen.getByRole('link', { name: 'https://example.cn' })).toHaveAttribute( + 'href', + 'https://example.cn', + ) rerender( <ModelModal diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx index b46f2a13dc616d..0fe1deffceb766 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx @@ -107,7 +107,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -121,30 +122,52 @@ vi.mock('../../hooks', () => ({ vi.mock('@/app/components/base/form/form-scenarios/auth', async () => { const React = await import('react') - const AuthForm = React.forwardRef(({ - onChange, - }: { - onChange?: (field: string, value: string) => void - }, ref: React.ForwardedRef<{ getFormValues: () => FormResponse, getForm: () => { setFieldValue: (field: string, value: string) => void } }>) => { - React.useImperativeHandle(ref, () => ({ - getFormValues: () => mockFormState.responses.shift() || { isCheckValidated: false, values: {} }, - getForm: () => ({ setFieldValue: mockFormState.setFieldValue }), - })) - return ( - <div> - <button type="button" onClick={() => onChange?.('__model_name', 'updated-model')}>Model Name Change</button> - </div> - ) - }) + const AuthForm = React.forwardRef( + ( + { + onChange, + }: { + onChange?: (field: string, value: string) => void + }, + ref: React.ForwardedRef<{ + getFormValues: () => FormResponse + getForm: () => { setFieldValue: (field: string, value: string) => void } + }>, + ) => { + React.useImperativeHandle(ref, () => ({ + getFormValues: () => + mockFormState.responses.shift() || { isCheckValidated: false, values: {} }, + getForm: () => ({ setFieldValue: mockFormState.setFieldValue }), + })) + return ( + <div> + <button type="button" onClick={() => onChange?.('__model_name', 'updated-model')}> + Model Name Change + </button> + </div> + ) + }, + ) return { default: AuthForm } }) vi.mock('../../model-auth', () => ({ - CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => ( + CredentialSelector: ({ + onSelect, + }: { + onSelect: (credential: Credential & { addNewCredential?: boolean }) => void + }) => ( <div> - <button type="button" onClick={() => onSelect({ credential_id: 'existing' })}>Choose Existing</button> - <button type="button" onClick={() => onSelect({ credential_id: 'new', addNewCredential: true })}>Add New</button> + <button type="button" onClick={() => onSelect({ credential_id: 'existing' })}> + Choose Existing + </button> + <button + type="button" + onClick={() => onSelect({ credential_id: 'new', addNewCredential: true })} + > + Add New + </button> </div> ), })) @@ -234,12 +257,17 @@ describe('ModelModal', () => { expect(screen.getByRole('button', { name: 'common.operation.save' }))!.toBeDisabled() predefined.unmount() - const customizable = renderModal({ configurateMethod: ConfigurationMethodEnum.customizableModel }) + const customizable = renderModal({ + configurateMethod: ConfigurationMethodEnum.customizableModel, + }) expect(screen.queryByText('common.modelProvider.auth.apiKeyModal.desc')).not.toBeInTheDocument() customizable.unmount() mockState.credentialData = { credentials: {}, available_credentials: [] } - renderModal({ mode: ModelModalModeEnum.configModelCredential, model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } }) + renderModal({ + mode: ModelModalModeEnum.configModelCredential, + model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }, + }) expect(screen.getByText('common.modelProvider.auth.addModelCredential'))!.toBeInTheDocument() }) @@ -279,18 +307,30 @@ describe('ModelModal', () => { const alertDialog = screen.getByRole('alertdialog', { hidden: true }) expect(alertDialog)!.toHaveTextContent('common.modelProvider.confirmDelete') - fireEvent.click(within(alertDialog).getByRole('button', { hidden: true, name: 'common.operation.confirm' })) + fireEvent.click( + within(alertDialog).getByRole('button', { hidden: true, name: 'common.operation.confirm' }), + ) expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1) expect(onCancel).toHaveBeenCalledTimes(1) }) it('should handle save flows for different modal modes', async () => { - mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema] - mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema] + mockState.modelNameAndTypeFormSchemas = [ + { variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema, + ] + mockState.formSchemas = [ + { variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema, + ] mockFormState.responses = [ - { isCheckValidated: true, values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration } }, - { isCheckValidated: true, values: { __authorization_name__: 'Auth Name', api_key: 'secret' } }, + { + isCheckValidated: true, + values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration }, + }, + { + isCheckValidated: true, + values: { __authorization_name__: 'Auth Name', api_key: 'secret' }, + }, ] const configCustomModel = renderModal({ mode: ModelModalModeEnum.configCustomModel }) fireEvent.click(screen.getAllByText('Model Name Change')[0]!) @@ -306,10 +346,15 @@ describe('ModelModal', () => { model_type: ModelTypeEnum.textGeneration, }) }) - expect(configCustomModel.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Auth Name', api_key: 'secret' }) + expect(configCustomModel.onSave).toHaveBeenCalledWith({ + __authorization_name__: 'Auth Name', + api_key: 'secret', + }) configCustomModel.unmount() - mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }] + mockFormState.responses = [ + { isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }, + ] const model = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } const configModelCredential = renderModal({ mode: ModelModalModeEnum.configModelCredential, @@ -326,11 +371,21 @@ describe('ModelModal', () => { model_type: ModelTypeEnum.textGeneration, }) }) - expect(configModelCredential.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Model Auth', api_key: 'abc' }) + expect(configModelCredential.onSave).toHaveBeenCalledWith({ + __authorization_name__: 'Model Auth', + api_key: 'abc', + }) configModelCredential.unmount() - mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' } }] - const configProviderCredential = renderModal({ mode: ModelModalModeEnum.configProviderCredential }) + mockFormState.responses = [ + { + isCheckValidated: true, + values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' }, + }, + ] + const configProviderCredential = renderModal({ + mode: ModelModalModeEnum.configProviderCredential, + }) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) await waitFor(() => { expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({ @@ -347,11 +402,19 @@ describe('ModelModal', () => { }) fireEvent.click(screen.getByText('Choose Existing')) fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) - expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith({ credential_id: 'existing' }, model) + expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith( + { credential_id: 'existing' }, + model, + ) expect(addToModelList.onCancel).toHaveBeenCalled() addToModelList.unmount() - mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'New Auth', api_key: 'new-key' } }] + mockFormState.responses = [ + { + isCheckValidated: true, + values: { __authorization_name__: 'New Auth', api_key: 'new-key' }, + }, + ] const addToModelListWithNew = renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model, @@ -381,14 +444,22 @@ describe('ModelModal', () => { mockState.formValues = { api_key: 'value' } const removable = renderModal({ credential: { credential_id: 'remove-1' } }) fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' })) - expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith({ credential_id: 'remove-1' }, undefined) + expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith( + { credential_id: 'remove-1' }, + undefined, + ) removable.unmount() }) it('should use fixed model context when saving a model credential without model prop', async () => { - mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema] + mockState.formSchemas = [ + { variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema, + ] mockFormState.responses = [ - { isCheckValidated: true, values: { __authorization_name__: 'Xinference Auth', api_key: 'secret' } }, + { + isCheckValidated: true, + values: { __authorization_name__: 'Xinference Auth', api_key: 'secret' }, + }, ] renderModal({ @@ -412,9 +483,14 @@ describe('ModelModal', () => { }) it('should not submit model credential payload when model context is missing', async () => { - mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema] + mockState.formSchemas = [ + { variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema, + ] mockFormState.responses = [ - { isCheckValidated: true, values: { __authorization_name__: 'Missing Model Auth', api_key: 'secret' } }, + { + isCheckValidated: true, + values: { __authorization_name__: 'Missing Model Auth', api_key: 'secret' }, + }, ] renderModal({ mode: ModelModalModeEnum.configModelCredential }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx index 2f2708eb9f6762..ae48f2a9aedff0 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx @@ -5,10 +5,7 @@ import type { CustomModel, ModelProvider, } from '../declarations' -import type { - FormRefObject, - FormSchema, -} from '@/app/components/base/form/types' +import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' import { AlertDialog, AlertDialogActions, @@ -18,18 +15,8 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' -import { - Dialog, - DialogCloseButton, - DialogContent, -} from '@langgenius/dify-ui/dialog' -import { - memo, - useCallback, - useMemo, - useRef, - useState, -} from 'react' +import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import AuthForm from '@/app/components/base/form/form-scenarios/auth' @@ -43,14 +30,8 @@ import { import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import { useRenderI18nObject } from '@/hooks/use-i18n' -import { - ConfigurationMethodEnum, - FormTypeEnum, - ModelModalModeEnum, -} from '../declarations' -import { - useLanguage, -} from '../hooks' +import { ConfigurationMethodEnum, FormTypeEnum, ModelModalModeEnum } from '../declarations' +import { useLanguage } from '../hooks' import { CredentialSelector } from '../model-auth' import { useModelFormSchemas } from '../model-auth/hooks' @@ -80,10 +61,13 @@ const ModelModal: FC<ModelModalProps> = ({ }) => { const renderI18nObject = useRenderI18nObject() const providerFormSchemaPredefined = configurateMethod === ConfigurationMethodEnum.predefinedModel - const { - isLoading, - credentialData, - } = useCredentialData(provider, providerFormSchemaPredefined, isModelCredential, credential, model) + const { isLoading, credentialData } = useCredentialData( + provider, + providerFormSchemaPredefined, + isModelCredential, + credential, + model, + ) const { handleSaveCredential, handleConfirmDelete, @@ -92,40 +76,36 @@ const ModelModal: FC<ModelModalProps> = ({ openConfirmDelete, doingAction, handleActiveCredential, - } = useAuth( - provider, - configurateMethod, - currentCustomConfigurationModelFixedFields, - { - isModelCredential, - mode, - }, - ) - const { - credentials: formSchemasValue, - available_credentials, - } = credentialData as any + } = useAuth(provider, configurateMethod, currentCustomConfigurationModelFixedFields, { + isModelCredential, + mode, + }) + const { credentials: formSchemasValue, available_credentials } = credentialData as any const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() const { t } = useTranslation() const language = useLanguage() - const { - formSchemas, - formValues, - modelNameAndTypeFormSchemas, - modelNameAndTypeFormValues, - } = useModelFormSchemas(provider, providerFormSchemaPredefined, formSchemasValue, credential, model) + const { formSchemas, formValues, modelNameAndTypeFormSchemas, modelNameAndTypeFormValues } = + useModelFormSchemas(provider, providerFormSchemaPredefined, formSchemasValue, credential, model) const formRef1 = useRef<FormRefObject>(null) - const [selectedCredential, setSelectedCredential] = useState<Credential & { addNewCredential?: boolean } | undefined>() + const [selectedCredential, setSelectedCredential] = useState< + (Credential & { addNewCredential?: boolean }) | undefined + >() const formRef2 = useRef<FormRefObject>(null) - const isEditMode = !!credential && !!Object.keys(formSchemasValue || {}).filter((key) => { - return key !== '__model_name' && key !== '__model_type' && !!formValues[key] - }).length && canManageCredential + const isEditMode = + !!credential && + !!Object.keys(formSchemasValue || {}).filter((key) => { + return key !== '__model_name' && key !== '__model_type' && !!formValues[key] + }).length && + canManageCredential const handleSave = useCallback(async () => { - if (mode === ModelModalModeEnum.addCustomModelToModelList && selectedCredential && !selectedCredential?.addNewCredential) { - if (!canUseCredential) - return + if ( + mode === ModelModalModeEnum.addCustomModelToModelList && + selectedCredential && + !selectedCredential?.addNewCredential + ) { + if (!canUseCredential) return handleActiveCredential(selectedCredential, model) onCancel() @@ -133,8 +113,7 @@ const ModelModal: FC<ModelModalProps> = ({ } const canSubmitCredentialForm = credential ? canManageCredential : canCreateCredential - if (!canSubmitCredentialForm) - return + if (!canSubmitCredentialForm) return let modelNameAndTypeIsCheckValidated = true let modelNameAndTypeValues: Record<string, any> = {} @@ -148,47 +127,40 @@ const ModelModal: FC<ModelModalProps> = ({ } if ( - mode === ModelModalModeEnum.configModelCredential - || (mode === ModelModalModeEnum.addCustomModelToModelList && selectedCredential?.addNewCredential) + mode === ModelModalModeEnum.configModelCredential || + (mode === ModelModalModeEnum.addCustomModelToModelList && + selectedCredential?.addNewCredential) ) { - const modelContext = model ?? (currentCustomConfigurationModelFixedFields - ? { - model: currentCustomConfigurationModelFixedFields.__model_name, - model_type: currentCustomConfigurationModelFixedFields.__model_type, - } - : undefined) - if (!modelContext) - return + const modelContext = + model ?? + (currentCustomConfigurationModelFixedFields + ? { + model: currentCustomConfigurationModelFixedFields.__model_name, + model_type: currentCustomConfigurationModelFixedFields.__model_type, + } + : undefined) + if (!modelContext) return modelNameAndTypeValues = { __model_name: modelContext.model, __model_type: modelContext.model_type, } } - const { - isCheckValidated, - values, - } = formRef2.current?.getFormValues({ + const { isCheckValidated, values } = formRef2.current?.getFormValues({ needCheckValidatedValues: true, needTransformWhenSecretFieldIsPristine: true, }) || { isCheckValidated: false, values: {} } - if (!isCheckValidated || !modelNameAndTypeIsCheckValidated) - return + if (!isCheckValidated || !modelNameAndTypeIsCheckValidated) return - const { - __model_name, - __model_type, - } = modelNameAndTypeValues - const { - __authorization_name__, - ...rest - } = values - const shouldSaveModelCredential = mode === ModelModalModeEnum.configCustomModel - || mode === ModelModalModeEnum.configModelCredential - || (mode === ModelModalModeEnum.addCustomModelToModelList && selectedCredential?.addNewCredential) + const { __model_name, __model_type } = modelNameAndTypeValues + const { __authorization_name__, ...rest } = values + const shouldSaveModelCredential = + mode === ModelModalModeEnum.configCustomModel || + mode === ModelModalModeEnum.configModelCredential || + (mode === ModelModalModeEnum.addCustomModelToModelList && + selectedCredential?.addNewCredential) if (shouldSaveModelCredential) { - if (!__model_name || !__model_type) - return + if (!__model_name || !__model_type) return await handleSaveCredential({ credential_id: credential?.credential_id, @@ -197,8 +169,7 @@ const ModelModal: FC<ModelModalProps> = ({ model: __model_name, model_type: __model_type, }) - } - else { + } else { await handleSaveCredential({ credential_id: credential?.credential_id, credentials: rest, @@ -206,32 +177,43 @@ const ModelModal: FC<ModelModalProps> = ({ }) } onSave(values) - }, [mode, selectedCredential, model, currentCustomConfigurationModelFixedFields, canUseCredential, canCreateCredential, canManageCredential, onSave, handleActiveCredential, onCancel, handleSaveCredential, credential]) + }, [ + mode, + selectedCredential, + model, + currentCustomConfigurationModelFixedFields, + canUseCredential, + canCreateCredential, + canManageCredential, + onSave, + handleActiveCredential, + onCancel, + handleSaveCredential, + credential, + ]) const modalTitle = useMemo(() => { - let label = t($ => $['modelProvider.auth.apiKeyModal.title'], { ns: 'common' }) + let label = t(($) => $['modelProvider.auth.apiKeyModal.title'], { ns: 'common' }) - if (mode === ModelModalModeEnum.configCustomModel || mode === ModelModalModeEnum.addCustomModelToModelList) - label = t($ => $['modelProvider.auth.addModel'], { ns: 'common' }) + if ( + mode === ModelModalModeEnum.configCustomModel || + mode === ModelModalModeEnum.addCustomModelToModelList + ) + label = t(($) => $['modelProvider.auth.addModel'], { ns: 'common' }) if (mode === ModelModalModeEnum.configModelCredential) { if (credential) - label = t($ => $['modelProvider.auth.editModelCredential'], { ns: 'common' }) - else - label = t($ => $['modelProvider.auth.addModelCredential'], { ns: 'common' }) + label = t(($) => $['modelProvider.auth.editModelCredential'], { ns: 'common' }) + else label = t(($) => $['modelProvider.auth.addModelCredential'], { ns: 'common' }) } - return ( - <div className="title-2xl-semi-bold text-text-primary"> - {label} - </div> - ) + return <div className="title-2xl-semi-bold text-text-primary">{label}</div> }, [t, mode, credential]) const modalDesc = useMemo(() => { if (providerFormSchemaPredefined) { return ( <div className="mt-1 system-xs-regular text-text-tertiary"> - {t($ => $['modelProvider.auth.apiKeyModal.desc'], { ns: 'common' })} + {t(($) => $['modelProvider.auth.apiKeyModal.desc'], { ns: 'common' })} </div> ) } @@ -243,22 +225,21 @@ const ModelModal: FC<ModelModalProps> = ({ if (mode === ModelModalModeEnum.configCustomModel) { return ( <div className="mt-2 flex items-center"> - <ModelIcon - className="mr-2 size-4 shrink-0" - provider={provider} - /> - <div className="mr-1 system-md-regular text-text-secondary">{renderI18nObject(provider.label)}</div> + <ModelIcon className="mr-2 size-4 shrink-0" provider={provider} /> + <div className="mr-1 system-md-regular text-text-secondary"> + {renderI18nObject(provider.label)} + </div> </div> ) } - if (model && (mode === ModelModalModeEnum.configModelCredential || mode === ModelModalModeEnum.addCustomModelToModelList)) { + if ( + model && + (mode === ModelModalModeEnum.configModelCredential || + mode === ModelModalModeEnum.addCustomModelToModelList) + ) { return ( <div className="mt-2 flex items-center"> - <ModelIcon - className="mr-2 size-4 shrink-0" - provider={provider} - modelName={model.model} - /> + <ModelIcon className="mr-2 size-4 shrink-0" provider={provider} modelName={model.model} /> <div className="mr-1 system-md-regular text-text-secondary">{model.model}</div> <Badge>{model.model_type}</Badge> </div> @@ -269,24 +250,30 @@ const ModelModal: FC<ModelModalProps> = ({ }, [model, provider, mode, renderI18nObject]) const showCredentialLabel = useMemo(() => { - if (mode === ModelModalModeEnum.configCustomModel) - return true + if (mode === ModelModalModeEnum.configCustomModel) return true if (mode === ModelModalModeEnum.addCustomModelToModelList) return selectedCredential?.addNewCredential }, [mode, selectedCredential]) const showCredentialForm = useMemo(() => { - if (mode !== ModelModalModeEnum.addCustomModelToModelList) - return true + if (mode !== ModelModalModeEnum.addCustomModelToModelList) return true return selectedCredential?.addNewCredential }, [mode, selectedCredential]) const saveButtonText = useMemo(() => { - if (mode === ModelModalModeEnum.addCustomModelToModelList || mode === ModelModalModeEnum.configCustomModel) - return t($ => $['operation.add'], { ns: 'common' }) - return t($ => $['operation.save'], { ns: 'common' }) + if ( + mode === ModelModalModeEnum.addCustomModelToModelList || + mode === ModelModalModeEnum.configCustomModel + ) + return t(($) => $['operation.add'], { ns: 'common' }) + return t(($) => $['operation.save'], { ns: 'common' }) }, [mode, t]) - const canSaveCredentialChange = mode === ModelModalModeEnum.addCustomModelToModelList && selectedCredential && !selectedCredential.addNewCredential - ? canUseCredential - : credential ? canManageCredential : canCreateCredential + const canSaveCredentialChange = + mode === ModelModalModeEnum.addCustomModelToModelList && + selectedCredential && + !selectedCredential.addNewCredential + ? canUseCredential + : credential + ? canManageCredential + : canCreateCredential const handleDeleteCredential = useCallback(() => { handleConfirmDelete() @@ -294,23 +281,24 @@ const ModelModal: FC<ModelModalProps> = ({ }, [handleConfirmDelete, onCancel]) const handleModelNameAndTypeChange = useCallback((field: string, value: any) => { - const { - getForm, - } = formRef2.current as FormRefObject || {} - if (getForm()) - getForm()?.setFieldValue(field, value) + const { getForm } = (formRef2.current as FormRefObject) || {} + if (getForm()) getForm()?.setFieldValue(field, value) }, []) const notAllowCustomCredential = provider.allow_custom_token === false - const handleOpenChange = useCallback((open: boolean) => { - if (!open) - onCancel() - }, [onCancel]) + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) onCancel() + }, + [onCancel], + ) - const handleConfirmOpenChange = useCallback((open: boolean) => { - if (!open) - closeConfirmDelete() - }, [closeConfirmDelete]) + const handleConfirmOpenChange = useCallback( + (open: boolean) => { + if (!open) closeConfirmDelete() + }, + [closeConfirmDelete], + ) return ( <Dialog open onOpenChange={handleOpenChange}> @@ -325,101 +313,88 @@ const ModelModal: FC<ModelModalProps> = ({ {modalModel} </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> - { - mode === ModelModalModeEnum.configCustomModel && ( - <AuthForm - formSchemas={modelNameAndTypeFormSchemas.map((formSchema) => { + {mode === ModelModalModeEnum.configCustomModel && ( + <AuthForm + formSchemas={ + modelNameAndTypeFormSchemas.map((formSchema) => { return { ...formSchema, name: formSchema.variable, } - }) as FormSchema[]} - defaultValues={modelNameAndTypeFormValues} - inputClassName="justify-start" - ref={formRef1} - onChange={handleModelNameAndTypeChange} - /> - ) - } - { - mode === ModelModalModeEnum.addCustomModelToModelList && ( - <CredentialSelector - credentials={available_credentials || []} - onSelect={setSelectedCredential} - selectedCredential={selectedCredential} - disabled={isLoading} - notAllowAddNewCredential={notAllowCustomCredential || !canCreateCredential} - /> - ) - } - { - showCredentialLabel && ( - <div className="mt-6 mb-3 flex items-center system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['modelProvider.auth.modelCredential'], { ns: 'common' })} - <div className="ml-2 h-px grow bg-linear-to-r from-divider-regular to-background-gradient-mask-transparent" /> - </div> - ) - } - { - isLoading && ( - <div className="mt-3 flex items-center justify-center"> - <Loading /> - </div> - ) - } - { - !isLoading - && showCredentialForm - && ( - <AuthForm - formSchemas={formSchemas.map((formSchema) => { + }) as FormSchema[] + } + defaultValues={modelNameAndTypeFormValues} + inputClassName="justify-start" + ref={formRef1} + onChange={handleModelNameAndTypeChange} + /> + )} + {mode === ModelModalModeEnum.addCustomModelToModelList && ( + <CredentialSelector + credentials={available_credentials || []} + onSelect={setSelectedCredential} + selectedCredential={selectedCredential} + disabled={isLoading} + notAllowAddNewCredential={notAllowCustomCredential || !canCreateCredential} + /> + )} + {showCredentialLabel && ( + <div className="mt-6 mb-3 flex items-center system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['modelProvider.auth.modelCredential'], { ns: 'common' })} + <div className="ml-2 h-px grow bg-linear-to-r from-divider-regular to-background-gradient-mask-transparent" /> + </div> + )} + {isLoading && ( + <div className="mt-3 flex items-center justify-center"> + <Loading /> + </div> + )} + {!isLoading && showCredentialForm && ( + <AuthForm + formSchemas={ + formSchemas.map((formSchema) => { return { ...formSchema, name: formSchema.variable, showRadioUI: formSchema.type === FormTypeEnum.radio, } - }) as FormSchema[]} - defaultValues={formValues} - inputClassName="justify-start" - ref={formRef2} - /> - ) - } + }) as FormSchema[] + } + defaultValues={formValues} + inputClassName="justify-start" + ref={formRef2} + /> + )} </div> <div className="flex shrink-0 justify-between p-6 pt-5"> - { - (provider.help && (provider.help.title || provider.help.url)) - ? ( - <a - href={provider.help?.url[language] || provider.help?.url.en_US} - target="_blank" - rel="noopener noreferrer" - className="mt-2 inline-block align-middle system-xs-regular text-text-accent" - onClick={e => !provider.help.url && e.preventDefault()} - > - {provider.help.title?.[language] || provider.help.url[language] || provider.help.title?.en_US || provider.help.url.en_US} - <LinkExternal02 className="mt-[-2px] ml-1 inline-block h-3 w-3" /> - </a> - ) - : <div /> - } - <div className="ml-2 flex items-center justify-end space-x-2"> - { - isEditMode && ( - <Button - variant="primary" - tone="destructive" - onClick={() => openConfirmDelete(credential, model)} - > - {t($ => $['operation.remove'], { ns: 'common' })} - </Button> - ) - } - <Button - onClick={onCancel} + {provider.help && (provider.help.title || provider.help.url) ? ( + <a + href={provider.help?.url[language] || provider.help?.url.en_US} + target="_blank" + rel="noopener noreferrer" + className="mt-2 inline-block align-middle system-xs-regular text-text-accent" + onClick={(e) => !provider.help.url && e.preventDefault()} > - {t($ => $['operation.cancel'], { ns: 'common' })} - </Button> + {provider.help.title?.[language] || + provider.help.url[language] || + provider.help.title?.en_US || + provider.help.url.en_US} + <LinkExternal02 className="mt-[-2px] ml-1 inline-block h-3 w-3" /> + </a> + ) : ( + <div /> + )} + <div className="ml-2 flex items-center justify-end space-x-2"> + {isEditMode && ( + <Button + variant="primary" + tone="destructive" + onClick={() => openConfirmDelete(credential, model)} + > + {t(($) => $['operation.remove'], { ns: 'common' })} + </Button> + )} + <Button onClick={onCancel}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> <Button variant="primary" onClick={handleSave} @@ -429,40 +404,38 @@ const ModelModal: FC<ModelModalProps> = ({ </Button> </div> </div> - { - (mode === ModelModalModeEnum.configCustomModel || mode === ModelModalModeEnum.configProviderCredential) && ( - <div className="shrink-0 border-t-[0.5px] border-t-divider-regular"> - <div className="flex items-center justify-center rounded-b-2xl bg-background-section-burn py-3 text-xs text-text-tertiary"> - <Lock01 className="mr-1 size-3 text-text-tertiary" /> - {t($ => $['modelProvider.encrypted.front'], { ns: 'common' })} - <a - className="mx-1 text-text-accent" - target="_blank" - rel="noopener noreferrer" - href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html" - > - PKCS1_OAEP - </a> - {t($ => $['modelProvider.encrypted.back'], { ns: 'common' })} - </div> + {(mode === ModelModalModeEnum.configCustomModel || + mode === ModelModalModeEnum.configProviderCredential) && ( + <div className="shrink-0 border-t-[0.5px] border-t-divider-regular"> + <div className="flex items-center justify-center rounded-b-2xl bg-background-section-burn py-3 text-xs text-text-tertiary"> + <Lock01 className="mr-1 size-3 text-text-tertiary" /> + {t(($) => $['modelProvider.encrypted.front'], { ns: 'common' })} + <a + className="mx-1 text-text-accent" + target="_blank" + rel="noopener noreferrer" + href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html" + > + PKCS1_OAEP + </a> + {t(($) => $['modelProvider.encrypted.back'], { ns: 'common' })} </div> - ) - } + </div> + )} </DialogContent> <AlertDialog open={!!deleteCredentialId} onOpenChange={handleConfirmOpenChange}> <AlertDialogContent backdropProps={{ forceRender: true }}> <div className="flex flex-col gap-2 p-6 pb-4"> <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['modelProvider.confirmDelete'], { ns: 'common' })} + {t(($) => $['modelProvider.confirmDelete'], { ns: 'common' })} </AlertDialogTitle> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton - disabled={doingAction} - onClick={handleDeleteCredential} - > - {t($ => $['operation.confirm'], { ns: 'common' })} + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton disabled={doingAction} onClick={handleDeleteCredential}> + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/header/account-setting/model-provider-page/model-name/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-name/__tests__/index.spec.tsx index ed9e962128c678..b1f7a9d6e6defc 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-name/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-name/__tests__/index.spec.tsx @@ -90,14 +90,7 @@ describe('ModelName', () => { }, }) - render( - <ModelName - modelItem={modelItem} - showModelType - showMode - showContextSize - />, - ) + render(<ModelName modelItem={modelItem} showModelType showMode showContextSize />) expect(screen.getByText('TEXT EMBEDDING')).toBeInTheDocument() expect(screen.getByText('CHAT')).toBeInTheDocument() @@ -109,13 +102,7 @@ describe('ModelName', () => { features: [ModelFeatureEnum.vision, ModelFeatureEnum.audio], }) - render( - <ModelName - modelItem={modelItem} - showFeatures - showFeaturesLabel - />, - ) + render(<ModelName modelItem={modelItem} showFeatures showFeaturesLabel />) expect(screen.getByText('Vision')).toBeInTheDocument() expect(screen.getByText('Audio')).toBeInTheDocument() diff --git a/web/app/components/header/account-setting/model-provider-page/model-name/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-name/index.tsx index 66dc46326642d5..cb702309c4a701 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-name/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-name/index.tsx @@ -4,10 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { useLanguage } from '../hooks' import ModelBadge from '../model-badge' import FeatureIcon from '../model-selector/feature-icon' -import { - modelTypeFormat, - sizeFormat, -} from '../utils' +import { modelTypeFormat, sizeFormat } from '../utils' type ModelNameProps = PropsWithChildren<{ modelItem: ModelItem @@ -38,10 +35,14 @@ const ModelName: FC<ModelNameProps> = ({ }) => { const language = useLanguage() - if (!modelItem) - return null + if (!modelItem) return null return ( - <div className={cn('flex items-center gap-0.5 truncate overflow-hidden system-sm-regular text-ellipsis text-components-input-text-filled', className)}> + <div + className={cn( + 'flex items-center gap-0.5 truncate overflow-hidden system-sm-regular text-ellipsis text-components-input-text-filled', + className, + )} + > <div className={cn('truncate', nameClassName)} title={modelItem.label[language] || modelItem.label.en_US} @@ -49,37 +50,28 @@ const ModelName: FC<ModelNameProps> = ({ {modelItem.label[language] || modelItem.label.en_US} </div> <div className="flex items-center gap-0.5"> - { - !!(showModelType && modelItem.model_type) && ( - <ModelBadge className={modelTypeClassName}> - {modelTypeFormat(modelItem.model_type)} - </ModelBadge> - ) - } - { - !!(modelItem.model_properties.mode && showMode) && ( - <ModelBadge className={modeClassName}> - {(modelItem.model_properties.mode as string).toLocaleUpperCase()} - </ModelBadge> - ) - } - { - !!(showContextSize && modelItem.model_properties.context_size) && ( - <ModelBadge> - {sizeFormat(modelItem.model_properties.context_size as number)} - </ModelBadge> - ) - } - { - showFeatures && modelItem.features?.map(feature => ( + {!!(showModelType && modelItem.model_type) && ( + <ModelBadge className={modelTypeClassName}> + {modelTypeFormat(modelItem.model_type)} + </ModelBadge> + )} + {!!(modelItem.model_properties.mode && showMode) && ( + <ModelBadge className={modeClassName}> + {(modelItem.model_properties.mode as string).toLocaleUpperCase()} + </ModelBadge> + )} + {!!(showContextSize && modelItem.model_properties.context_size) && ( + <ModelBadge>{sizeFormat(modelItem.model_properties.context_size as number)}</ModelBadge> + )} + {showFeatures && + modelItem.features?.map((feature) => ( <FeatureIcon key={feature} feature={feature} className={featuresClassName} showFeaturesLabel={showFeaturesLabel} /> - )) - } + ))} </div> {children} </div> diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/agent-model-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/agent-model-trigger.spec.tsx index 0df4b0790bfbe8..11acfc8514ce91 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/agent-model-trigger.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/agent-model-trigger.spec.tsx @@ -50,7 +50,13 @@ vi.mock('../status-indicators', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({ - InstallPluginButton: ({ onClick, onSuccess }: { onClick: (event: MouseEvent<HTMLButtonElement>) => void, onSuccess: () => void }) => ( + InstallPluginButton: ({ + onClick, + onSuccess, + }: { + onClick: (event: MouseEvent<HTMLButtonElement>) => void + onSuccess: () => void + }) => ( <button onClick={(event) => { onClick(event) @@ -73,38 +79,32 @@ describe('AgentModelTrigger', () => { it('should render loading state when plugin info is still fetching', () => { pluginLoading = true - render( - <AgentModelTrigger - modelId="gpt-4" - providerName="openai" - />, - ) + render(<AgentModelTrigger modelId="gpt-4" providerName="openai" />) expect(screen.getByRole('status')).toBeInTheDocument() }) it('should render model actions for configured provider', () => { - modelProviders = [{ - provider: 'openai', - custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, - system_configuration: { - enabled: true, - current_quota_type: CurrentSystemQuotaTypeEnum.paid, - quota_configurations: [{ - quota_type: CurrentSystemQuotaTypeEnum.paid, - quota_unit: QuotaUnitEnum.times, - quota_limit: 10, - quota_used: 1, - last_used: 1, - is_valid: true, - }], + modelProviders = [ + { + provider: 'openai', + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.paid, + quota_configurations: [ + { + quota_type: CurrentSystemQuotaTypeEnum.paid, + quota_unit: QuotaUnitEnum.times, + quota_limit: 10, + quota_used: 1, + last_used: 1, + is_valid: true, + }, + ], + }, }, - }] as unknown as ModelProvider[] - render( - <AgentModelTrigger - modelId="gpt-4" - providerName="openai" - />, - ) + ] as unknown as ModelProvider[] + render(<AgentModelTrigger modelId="gpt-4" providerName="openai" />) expect(screen.getByText('ModelDisplay')).toBeInTheDocument() expect(screen.getByText('StatusIndicators')).toBeInTheDocument() }) @@ -127,22 +127,19 @@ describe('AgentModelTrigger', () => { }) it('should show configuration action when provider requires setup', () => { - modelProviders = [{ - provider: 'openai', - custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.paid, - quota_configurations: [], + modelProviders = [ + { + provider: 'openai', + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.paid, + quota_configurations: [], + }, }, - }] as unknown as ModelProvider[] + ] as unknown as ModelProvider[] - render( - <AgentModelTrigger - modelId="gpt-4" - providerName="openai" - />, - ) + render(<AgentModelTrigger modelId="gpt-4" providerName="openai" />) expect(screen.getByText('workflow.nodes.agent.notAuthorized')).toBeInTheDocument() }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/configuration-button.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/configuration-button.spec.tsx index 6caa6d6bae624b..c5d51dbef45617 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/configuration-button.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/configuration-button.spec.tsx @@ -11,7 +11,9 @@ describe('ConfigurationButton', () => { render( <ConfigurationButton - modelProvider={modelProvider as unknown as ComponentProps<typeof ConfigurationButton>['modelProvider']} + modelProvider={ + modelProvider as unknown as ComponentProps<typeof ConfigurationButton>['modelProvider'] + } handleOpenModal={handleOpenModal} />, ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx index fbb5e9336af577..77a9530d9d85e5 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx @@ -14,7 +14,10 @@ let parameterRules: Array<Record<string, unknown>> | undefined = [ ] let isRulesLoading = false let isRulesPending = false -let currentProvider: Record<string, unknown> | undefined = { provider: 'openai', label: { en_US: 'OpenAI' } } +let currentProvider: Record<string, unknown> | undefined = { + provider: 'openai', + label: { en_US: 'OpenAI' }, +} let currentModel: Record<string, unknown> | undefined = { model: 'gpt-3.5-turbo', status: 'active', @@ -63,8 +66,14 @@ vi.mock('../../hooks', () => ({ })) vi.mock('../parameter-item', () => ({ - default: ({ parameterRule, onChange, onSwitch, nodesOutputVars, availableNodes }: { - parameterRule: { name: string, label: { en_US: string } } + default: ({ + parameterRule, + onChange, + onSwitch, + nodesOutputVars, + availableNodes, + }: { + parameterRule: { name: string; label: { en_US: string } } onChange: (v: number) => void onSwitch: (checked: boolean, val: unknown) => void nodesOutputVars?: unknown[] @@ -84,18 +93,31 @@ vi.mock('../parameter-item', () => ({ })) vi.mock('../../model-selector', () => ({ - default: ({ onHide, onSelect }: { onHide: () => void, onSelect: (value: { provider: string, model: string }) => void }) => ( + default: ({ + onHide, + onSelect, + }: { + onHide: () => void + onSelect: (value: { provider: string; model: string }) => void + }) => ( <div data-testid="model-selector"> - <button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button> + <button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}> + Select GPT-4.1 + </button> <button onClick={onHide}>hide</button> </div> ), })) vi.mock('../presets-parameter', () => ({ - default: ({ onSelect, supportedParameterNames }: { onSelect: (id: number) => void, supportedParameterNames?: string[] }) => { - if (supportedParameterNames && !supportedParameterNames.includes('temperature')) - return null + default: ({ + onSelect, + supportedParameterNames, + }: { + onSelect: (id: number) => void + supportedParameterNames?: string[] + }) => { + if (supportedParameterNames && !supportedParameterNames.includes('temperature')) return null return <button onClick={() => onSelect(1)}>Preset 1</button> }, @@ -103,8 +125,7 @@ vi.mock('../presets-parameter', () => ({ vi.mock('../presets-parameter-utils', () => ({ getSupportedPresetConfig: (_toneId: number, supportedParameterNames?: string[]) => { - if (supportedParameterNames && !supportedParameterNames.includes('temperature')) - return {} + if (supportedParameterNames && !supportedParameterNames.includes('temperature')) return {} return { temperature: 0.8 } }, @@ -123,7 +144,8 @@ vi.mock('@/config', async (importOriginal) => { }) describe('ModelParameterModal', () => { - const openSettings = () => fireEvent.click(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })) + const openSettings = () => + fireEvent.click(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })) const defaultProps = { isAdvancedMode: false, modelId: 'gpt-3.5-turbo', @@ -293,13 +315,7 @@ describe('ModelParameterModal', () => { isRulesPending = true parameterRules = [] - render( - <ModelParameterModal - {...defaultProps} - provider="" - modelId="" - />, - ) + render(<ModelParameterModal {...defaultProps} provider="" modelId="" />) openSettings() expect(screen.queryByRole('status')).not.toBeInTheDocument() @@ -364,13 +380,7 @@ describe('ModelParameterModal', () => { }) it('should append the stop parameter in advanced mode and show the single-model debug label', () => { - render( - <ModelParameterModal - {...defaultProps} - isAdvancedMode - debugWithMultipleModel - />, - ) + render(<ModelParameterModal {...defaultProps} isAdvancedMode debugWithMultipleModel />) openSettings() diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.select.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.select.spec.tsx index 20f7089f73be0e..82054f433bcd19 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.select.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.select.spec.tsx @@ -11,10 +11,20 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { return { ...actual, - Select: ({ children, onValueChange }: { children: ReactNode, onValueChange: (value: string | undefined) => void }) => ( + Select: ({ + children, + onValueChange, + }: { + children: ReactNode + onValueChange: (value: string | undefined) => void + }) => ( <div> - <button type="button" onClick={() => onValueChange('updated')}>select-updated</button> - <button type="button" onClick={() => onValueChange(undefined)}>select-empty</button> + <button type="button" onClick={() => onValueChange('updated')}> + select-updated + </button> + <button type="button" onClick={() => onValueChange(undefined)}> + select-empty + </button> {children} </div> ), diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.spec.tsx index a57884df1d3e0a..890c88ecb44f3c 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.spec.tsx @@ -1,8 +1,5 @@ import type { ModelParameterRule } from '../../declarations' -import type { - Node, - NodeOutPutVar, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { fireEvent, render, screen } from '@testing-library/react' import { BlockEnum } from '@/app/components/workflow/types' import ParameterItem from '../parameter-item' @@ -13,33 +10,45 @@ vi.mock('../../hooks', () => ({ vi.mock('@langgenius/dify-ui/slider', () => ({ Slider: ({ onValueChange }: { onValueChange: (v: number) => void }) => ( - <button onClick={() => onValueChange(2)} data-testid="slider-btn">Slide 2</button> + <button onClick={() => onValueChange(2)} data-testid="slider-btn"> + Slide 2 + </button> ), })) vi.mock('@/app/components/base/tag-input', () => ({ default: ({ onChange }: { onChange: (v: string[]) => void }) => ( - <button onClick={() => onChange(['tag1', 'tag2'])} data-testid="tag-input">Tag</button> + <button onClick={() => onChange(['tag1', 'tag2'])} data-testid="tag-input"> + Tag + </button> ), })) let promptEditorOnChange: ((text: string) => void) | undefined -let capturedWorkflowNodesMap: Record<string, { title: string, type: string }> | undefined +let capturedWorkflowNodesMap: Record<string, { title: string; type: string }> | undefined vi.mock('@/app/components/base/prompt-editor', () => ({ - default: ({ value, onChange, workflowVariableBlock }: { + default: ({ + value, + onChange, + workflowVariableBlock, + }: { value: string onChange: (text: string) => void workflowVariableBlock?: { show: boolean variables: NodeOutPutVar[] - workflowNodesMap?: Record<string, { title: string, type: string }> + workflowNodesMap?: Record<string, { title: string; type: string }> } }) => { promptEditorOnChange = onChange capturedWorkflowNodesMap = workflowVariableBlock?.workflowNodesMap return ( - <div data-testid="prompt-editor" data-value={value} data-has-workflow-vars={!!workflowVariableBlock?.variables}> + <div + data-testid="prompt-editor" + data-value={value} + data-has-workflow-vars={!!workflowVariableBlock?.variables} + > {value} </div> ) @@ -64,7 +73,13 @@ describe('ParameterItem', () => { it('should render float controls and clamp numeric input to max', () => { const onChange = vi.fn() - render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} value={0.7} onChange={onChange} />) + render( + <ParameterItem + parameterRule={createRule({ type: 'float', min: 0, max: 1 })} + value={0.7} + onChange={onChange} + />, + ) const input = screen.getByRole('spinbutton') fireEvent.change(input, { target: { value: '1.4' } }) expect(onChange).toHaveBeenCalledWith(1) @@ -73,7 +88,13 @@ describe('ParameterItem', () => { it('should clamp float numeric input to min', () => { const onChange = vi.fn() - render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0.1, max: 1 })} value={0.7} onChange={onChange} />) + render( + <ParameterItem + parameterRule={createRule({ type: 'float', min: 0.1, max: 1 })} + value={0.7} + onChange={onChange} + />, + ) const input = screen.getByRole('spinbutton') fireEvent.change(input, { target: { value: '0.05' } }) expect(onChange).toHaveBeenCalledWith(0.1) @@ -81,7 +102,13 @@ describe('ParameterItem', () => { it('should render int controls and clamp numeric input', () => { const onChange = vi.fn() - render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 10 })} value={5} onChange={onChange} />) + render( + <ParameterItem + parameterRule={createRule({ type: 'int', min: 0, max: 10 })} + value={5} + onChange={onChange} + />, + ) const input = screen.getByRole('spinbutton') fireEvent.change(input, { target: { value: '15' } }) expect(onChange).toHaveBeenCalledWith(10) @@ -90,13 +117,19 @@ describe('ParameterItem', () => { }) it('should adjust step based on max for int type', () => { - const { rerender } = render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 50 })} value={5} />) + const { rerender } = render( + <ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 50 })} value={5} />, + ) expect(screen.getByRole('spinbutton'))!.toHaveAttribute('step', '1') - rerender(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 500 })} value={50} />) + rerender( + <ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 500 })} value={50} />, + ) expect(screen.getByRole('spinbutton'))!.toHaveAttribute('step', '10') - rerender(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 2000 })} value={50} />) + rerender( + <ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 2000 })} value={50} />, + ) expect(screen.getByRole('spinbutton'))!.toHaveAttribute('step', '100') }) @@ -108,7 +141,13 @@ describe('ParameterItem', () => { it('should handle slide change and clamp values', () => { const onChange = vi.fn() - render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 10 })} value={0.7} onChange={onChange} />) + render( + <ParameterItem + parameterRule={createRule({ type: 'float', min: 0, max: 10 })} + value={0.7} + onChange={onChange} + />, + ) fireEvent.click(screen.getByTestId('slider-btn')) expect(onChange).toHaveBeenCalledWith(2) @@ -116,14 +155,26 @@ describe('ParameterItem', () => { it('should render exact string input and propagate text changes', () => { const onChange = vi.fn() - render(<ParameterItem parameterRule={createRule({ type: 'string', name: 'prompt' })} value="initial" onChange={onChange} />) + render( + <ParameterItem + parameterRule={createRule({ type: 'string', name: 'prompt' })} + value="initial" + onChange={onChange} + />, + ) fireEvent.change(screen.getByRole('textbox'), { target: { value: 'updated' } }) expect(onChange).toHaveBeenCalledWith('updated') }) it('should render textarea for text type', () => { const onChange = vi.fn() - const { container } = render(<ParameterItem parameterRule={createRule({ type: 'text' })} value="long text" onChange={onChange} />) + const { container } = render( + <ParameterItem + parameterRule={createRule({ type: 'text' })} + value="long text" + onChange={onChange} + />, + ) const textarea = container.querySelector('textarea')! expect(textarea)!.toBeInTheDocument() fireEvent.change(textarea, { target: { value: 'new long text' } }) @@ -131,13 +182,27 @@ describe('ParameterItem', () => { }) it('should render select for string with options', () => { - render(<ParameterItem parameterRule={createRule({ type: 'string', options: ['a', 'b'] })} value="a" />) + render( + <ParameterItem + parameterRule={createRule({ type: 'string', options: ['a', 'b'] })} + value="a" + />, + ) expect(screen.getByText('a'))!.toBeInTheDocument() }) it('should render tag input for tag type', () => { const onChange = vi.fn() - render(<ParameterItem parameterRule={createRule({ type: 'tag', tagPlaceholder: { en_US: 'placeholder', zh_Hans: 'placeholder' } })} value={['a']} onChange={onChange} />) + render( + <ParameterItem + parameterRule={createRule({ + type: 'tag', + tagPlaceholder: { en_US: 'placeholder', zh_Hans: 'placeholder' }, + })} + value={['a']} + onChange={onChange} + />, + ) expect(screen.getByText('placeholder'))!.toBeInTheDocument() fireEvent.click(screen.getByTestId('tag-input')) expect(onChange).toHaveBeenCalledWith(['tag1', 'tag2']) @@ -145,7 +210,13 @@ describe('ParameterItem', () => { it('should render boolean radios and update value on click', () => { const onChange = vi.fn() - render(<ParameterItem parameterRule={createRule({ type: 'boolean', default: false })} value={true} onChange={onChange} />) + render( + <ParameterItem + parameterRule={createRule({ type: 'boolean', default: false })} + value={true} + onChange={onChange} + />, + ) fireEvent.click(screen.getByText('False')) expect(onChange).toHaveBeenCalledWith(false) }) @@ -158,14 +229,23 @@ describe('ParameterItem', () => { }) it('should not render switch if required or name is stop', () => { - const { rerender } = render(<ParameterItem parameterRule={createRule({ required: true as unknown as false })} value={1} />) + const { rerender } = render( + <ParameterItem + parameterRule={createRule({ required: true as unknown as false })} + value={1} + />, + ) expect(screen.queryByRole('switch')).not.toBeInTheDocument() - rerender(<ParameterItem parameterRule={createRule({ name: 'stop', required: false })} value={1} />) + rerender( + <ParameterItem parameterRule={createRule({ name: 'stop', required: false })} value={1} />, + ) expect(screen.queryByRole('switch')).not.toBeInTheDocument() }) it('should use default values if value is undefined', () => { - const { rerender } = render(<ParameterItem parameterRule={createRule({ type: 'float', default: 0.5 })} />) + const { rerender } = render( + <ParameterItem parameterRule={createRule({ type: 'float', default: 0.5 })} />, + ) expect(screen.getByRole('spinbutton'))!.toHaveValue(0.5) rerender(<ParameterItem parameterRule={createRule({ type: 'string', default: 'hello' })} />) @@ -188,18 +268,31 @@ describe('ParameterItem', () => { }) it('should render no input for unsupported parameter type', () => { - render(<ParameterItem parameterRule={createRule({ type: 'unsupported' as unknown as string })} value={0.7} />) + render( + <ParameterItem + parameterRule={createRule({ type: 'unsupported' as unknown as string })} + value={0.7} + />, + ) expect(screen.queryByRole('textbox')).not.toBeInTheDocument() expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument() }) describe('workflow variable reference', () => { - const mockNodesOutputVars: NodeOutPutVar[] = [ - { nodeId: 'node1', title: 'LLM Node', vars: [] }, - ] + const mockNodesOutputVars: NodeOutPutVar[] = [{ nodeId: 'node1', title: 'LLM Node', vars: [] }] const mockAvailableNodes: Node[] = [ - { id: 'node1', type: 'custom', position: { x: 0, y: 0 }, data: { title: 'LLM Node', type: BlockEnum.LLM } } as Node, - { id: 'start', type: 'custom', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } } as Node, + { + id: 'node1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { title: 'LLM Node', type: BlockEnum.LLM }, + } as Node, + { + id: 'start', + type: 'custom', + position: { x: 0, y: 0 }, + data: { title: 'Start', type: BlockEnum.Start }, + } as Node, ] it('should build workflowNodesMap and render PromptEditor for string type', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/presets-parameter.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/presets-parameter.spec.tsx index 67ad4c6915b180..ecc41f34faeb78 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/presets-parameter.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/presets-parameter.spec.tsx @@ -52,13 +52,17 @@ describe('PresetsParameter', () => { it('should render presets when at least one preset parameter is supported', () => { render(<PresetsParameter onSelect={vi.fn()} supportedParameterNames={['temperature']} />) - expect(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }), + ).toBeInTheDocument() }) it('should not render presets when no preset parameters are supported', () => { render(<PresetsParameter onSelect={vi.fn()} supportedParameterNames={['max_tokens']} />) - expect(screen.queryByRole('button', { name: /common\.modelProvider\.loadPresets/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /common\.modelProvider\.loadPresets/i }), + ).not.toBeInTheDocument() }) it('should return only supported preset config keys', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/status-indicators.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/status-indicators.spec.tsx index 09936e4b7eab66..e044b2972ff380 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/status-indicators.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/status-indicators.spec.tsx @@ -11,7 +11,9 @@ vi.mock('@/service/use-plugins', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({ - SwitchPluginVersion: ({ uniqueIdentifier }: { uniqueIdentifier: string }) => <div>{`SwitchVersion:${uniqueIdentifier}`}</div>, + SwitchPluginVersion: ({ uniqueIdentifier }: { uniqueIdentifier: string }) => ( + <div>{`SwitchVersion:${uniqueIdentifier}`}</div> + ), })) const t = withSelectorKey((key: string) => key, 'workflow') @@ -57,7 +59,9 @@ describe('StatusIndicators', () => { await user.hover(getPopoverTrigger('nodes.agent.modelSelectorTooltips.deprecated')) - expect(await screen.findByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument() + expect( + await screen.findByText('nodes.agent.modelSelectorTooltips.deprecated'), + ).toBeInTheDocument() }) it('should render model-not-support tooltip when disabled model is not in model list and has no pluginInfo', async () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.tsx index 9798727f84550e..e0d82c78efefea 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.tsx @@ -1,8 +1,5 @@ import type { FC } from 'react' -import type { - ModelItem, - ModelProvider, -} from '../declarations' +import type { ModelItem, ModelProvider } from '../declarations' import type { WorkflowTranslate } from './status-indicators' import { cn } from '@langgenius/dify-ui/cn' import { useMemo, useState } from 'react' @@ -10,16 +7,13 @@ import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' import { useProviderContext } from '@/context/provider-context' -import { useInvalidateInstalledPluginList, useModelInList, usePluginInfo } from '@/service/use-plugins' import { - CustomConfigurationStatusEnum, - ModelTypeEnum, -} from '../declarations' -import { - useModelModalHandler, - useUpdateModelList, - useUpdateModelProviders, -} from '../hooks' + useInvalidateInstalledPluginList, + useModelInList, + usePluginInfo, +} from '@/service/use-plugins' +import { CustomConfigurationStatusEnum, ModelTypeEnum } from '../declarations' +import { useModelModalHandler, useUpdateModelList, useUpdateModelProviders } from '../hooks' import ModelIcon from '../model-icon' import ConfigurationButton from './configuration-button' import ModelDisplay from './model-display' @@ -51,13 +45,15 @@ const AgentModelTrigger: FC<AgentModelTriggerProps> = ({ const updateModelProviders = useUpdateModelProviders() const updateModelList = useUpdateModelList() const { modelProvider, needsConfiguration } = useMemo(() => { - const modelProvider = modelProviders.find(item => item.provider === providerName) - const needsConfiguration = modelProvider?.custom_configuration.status === CustomConfigurationStatusEnum.noConfigure && !( - modelProvider.system_configuration.enabled === true - && modelProvider.system_configuration.quota_configurations.find( - item => item.quota_type === modelProvider.system_configuration.current_quota_type, + const modelProvider = modelProviders.find((item) => item.provider === providerName) + const needsConfiguration = + modelProvider?.custom_configuration.status === CustomConfigurationStatusEnum.noConfigure && + !( + modelProvider.system_configuration.enabled === true && + modelProvider.system_configuration.quota_configurations.find( + (item) => item.quota_type === modelProvider.system_configuration.current_quota_type, + ) ) - ) return { modelProvider, needsConfiguration, @@ -70,8 +66,7 @@ const AgentModelTrigger: FC<AgentModelTriggerProps> = ({ const { data: inModelList = false } = useModelInList(currentProvider, modelId) const { data: pluginInfo, isLoading: isPluginLoading } = usePluginInfo(providerName) - if (modelId && isPluginLoading) - return <Loading /> + if (modelId && isPluginLoading) return <Loading /> return ( <div @@ -79,76 +74,66 @@ const AgentModelTrigger: FC<AgentModelTriggerProps> = ({ 'group relative flex grow cursor-pointer items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 hover:bg-state-base-hover-alt', )} > - {modelId - ? ( - <> - <ModelIcon - className="p-0.5" - provider={currentProvider || modelProvider} - modelName={currentModel?.model || modelId} - isDeprecated={hasDeprecated} - /> - <ModelDisplay - currentModel={currentModel} - modelId={modelId} - /> - {needsConfiguration && ( - <ConfigurationButton - modelProvider={modelProvider} - handleOpenModal={handleOpenModal} - /> - )} - <StatusIndicators - needsConfiguration={needsConfiguration} - modelProvider={!!modelProvider} - inModelList={inModelList} - disabled={!!disabled} - pluginInfo={pluginInfo} - t={translateWorkflow} - /> - {!installed && !modelProvider && pluginInfo && ( - <InstallPluginButton - onClick={e => e.stopPropagation()} - size="small" - uniqueIdentifier={pluginInfo.latest_package_identifier} - onSuccess={() => { - [ - ModelTypeEnum.textGeneration, - ModelTypeEnum.textEmbedding, - ModelTypeEnum.rerank, - ModelTypeEnum.moderation, - ModelTypeEnum.speech2text, - ModelTypeEnum.tts, - ].forEach((type: ModelTypeEnum) => { - if (scope?.includes(type)) - updateModelList(type) - }, - ) - updateModelProviders() - invalidateInstalledPluginList() - setInstalled(true) - }} - /> - )} - {modelProvider && !disabled && !needsConfiguration && ( - <div className="flex items-center pr-1"> - <span className="i-ri-equalizer-2-line size-4 text-text-tertiary group-hover:text-text-secondary" /> - </div> - )} - </> - ) - : ( - <> - <div className="flex grow items-center gap-1 p-1 pl-2"> - <span className="truncate system-sm-regular text-components-input-text-placeholder"> - {t($ => $['nodes.agent.configureModel'], { ns: 'workflow' })} - </span> - </div> - <div className="flex items-center pr-1"> - <span className="i-ri-equalizer-2-line size-4 text-text-tertiary group-hover:text-text-secondary" /> - </div> - </> + {modelId ? ( + <> + <ModelIcon + className="p-0.5" + provider={currentProvider || modelProvider} + modelName={currentModel?.model || modelId} + isDeprecated={hasDeprecated} + /> + <ModelDisplay currentModel={currentModel} modelId={modelId} /> + {needsConfiguration && ( + <ConfigurationButton modelProvider={modelProvider} handleOpenModal={handleOpenModal} /> + )} + <StatusIndicators + needsConfiguration={needsConfiguration} + modelProvider={!!modelProvider} + inModelList={inModelList} + disabled={!!disabled} + pluginInfo={pluginInfo} + t={translateWorkflow} + /> + {!installed && !modelProvider && pluginInfo && ( + <InstallPluginButton + onClick={(e) => e.stopPropagation()} + size="small" + uniqueIdentifier={pluginInfo.latest_package_identifier} + onSuccess={() => { + ;[ + ModelTypeEnum.textGeneration, + ModelTypeEnum.textEmbedding, + ModelTypeEnum.rerank, + ModelTypeEnum.moderation, + ModelTypeEnum.speech2text, + ModelTypeEnum.tts, + ].forEach((type: ModelTypeEnum) => { + if (scope?.includes(type)) updateModelList(type) + }) + updateModelProviders() + invalidateInstalledPluginList() + setInstalled(true) + }} + /> )} + {modelProvider && !disabled && !needsConfiguration && ( + <div className="flex items-center pr-1"> + <span className="i-ri-equalizer-2-line size-4 text-text-tertiary group-hover:text-text-secondary" /> + </div> + )} + </> + ) : ( + <> + <div className="flex grow items-center gap-1 p-1 pl-2"> + <span className="truncate system-sm-regular text-components-input-text-placeholder"> + {t(($) => $['nodes.agent.configureModel'], { ns: 'workflow' })} + </span> + </div> + <div className="flex items-center pr-1"> + <span className="i-ri-equalizer-2-line size-4 text-text-tertiary group-hover:text-text-secondary" /> + </div> + </> + )} </div> ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx index d16ace1ad72342..e3354a74ee6128 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx @@ -19,7 +19,7 @@ const ConfigurationButton = ({ modelProvider, handleOpenModal }: ConfigurationBu }} > <div className="flex items-center justify-center gap-1 px-[3px]"> - {t($ => $['nodes.agent.notAuthorized'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.notAuthorized'], { ns: 'workflow' })} </div> <div className="flex h-[14px] w-[14px] items-center justify-center"> <StatusDot status="warning" /> diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx index 32d96053718ac5..afa0bad04dc549 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx @@ -1,34 +1,17 @@ -import type { - FC, - ReactNode, -} from 'react' -import type { - DefaultModel, - FormValue, - ModelParameterRule, -} from '../declarations' +import type { FC, ReactNode } from 'react' +import type { DefaultModel, FormValue, ModelParameterRule } from '../declarations' import type { ParameterValue } from './parameter-item' import type { TriggerProps } from './types' -import type { - Node, - NodeOutPutVar, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverClose, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows' import Loading from '@/app/components/base/loading' import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config' import { useModelParameterRules } from '@/service/use-common' -import { - useTextGenerationCurrentProviderAndModelAndModelList, -} from '../hooks' +import { useTextGenerationCurrentProviderAndModelAndModelList } from '../hooks' import ModelSelector from '../model-selector' import ParameterItem from './parameter-item' import PresetsParameter from './presets-parameter' @@ -39,7 +22,12 @@ export type ModelParameterModalProps = { isAdvancedMode: boolean modelId: string provider: string - setModel: (model: { modelId: string, provider: string, mode?: string, features?: string[] }) => void + setModel: (model: { + modelId: string + provider: string + mode?: string + features?: string[] + }) => void completionParams: FormValue onCompletionParamsChange: (newParams: FormValue) => void hideDebugWithMultipleModel?: boolean @@ -72,24 +60,16 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({ }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) - const { - data: parameterRulesData, - isLoading, - } = useModelParameterRules(provider, modelId) + const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId) const isRulesLoading = !!provider && !!modelId && isLoading - const { - currentProvider, - currentModel, - activeTextGenerationModelList, - } = useTextGenerationCurrentProviderAndModelAndModelList( - { provider, model: modelId }, - ) + const { currentProvider, currentModel, activeTextGenerationModelList } = + useTextGenerationCurrentProviderAndModelAndModelList({ provider, model: modelId }) const parameterRules: ModelParameterRule[] = useMemo(() => { return parameterRulesData?.data || [] }, [parameterRulesData]) const supportedPresetParameterNames = useMemo(() => { - return parameterRules.map(parameterRule => parameterRule.name) + return parameterRules.map((parameterRule) => parameterRule.name) }, [parameterRules]) const handleParamChange = (key: string, value: ParameterValue) => { @@ -100,8 +80,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({ } const handleChangeModel = ({ provider, model }: DefaultModel) => { - const targetProvider = activeTextGenerationModelList.find(modelItem => modelItem.provider === provider) - const targetModelItem = targetProvider?.models.find(modelItem => modelItem.model === model) + const targetProvider = activeTextGenerationModelList.find( + (modelItem) => modelItem.provider === provider, + ) + const targetModelItem = targetProvider?.models.find((modelItem) => modelItem.model === model) setModel({ modelId: model, provider, @@ -138,61 +120,63 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({ <Popover open={open} onOpenChange={(newOpen) => { - if (readonly) - return + if (readonly) return setOpen(newOpen) }} > - {renderTrigger - ? ( - <PopoverTrigger - render={( - <button type="button" className="block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]"> - {renderTrigger({ - open, - currentProvider, - currentModel, - providerName: provider, - modelId, - })} - </button> + {renderTrigger ? ( + <PopoverTrigger + render={ + <button + type="button" + className="block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]" + > + {renderTrigger({ + open, + currentProvider, + currentModel, + providerName: provider, + modelId, + })} + </button> + } + /> + ) : ( + <div className="flex h-8 min-w-[296px] items-center gap-px overflow-hidden rounded-lg"> + <div className="min-w-0 flex-1"> + <ModelSelector + defaultModel={provider || modelId ? { provider, model: modelId } : undefined} + modelList={activeTextGenerationModelList} + readonly={readonly} + triggerClassName={cn( + 'h-8! w-full rounded-r-none!', + isInWorkflow && + 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg', )} + onSelect={handleChangeModel} /> - ) - : ( - <div className="flex h-8 min-w-[296px] items-center gap-px overflow-hidden rounded-lg"> - <div className="min-w-0 flex-1"> - <ModelSelector - defaultModel={(provider || modelId) ? { provider, model: modelId } : undefined} - modelList={activeTextGenerationModelList} - readonly={readonly} - triggerClassName={cn( - 'h-8! w-full rounded-r-none!', - isInWorkflow && 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg', - )} - onSelect={handleChangeModel} - /> - </div> - <PopoverTrigger - aria-label={t($ => $['modelProvider.modelSettings'], { ns: 'common' })} - disabled={readonly || !hasSelectedModel} - className={cn( - 'flex size-8 shrink-0 items-center justify-center rounded-l-none rounded-r-lg border-0 bg-components-button-tertiary-bg p-0 text-text-tertiary outline-hidden hover:bg-components-button-tertiary-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled', - isInWorkflow && 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg', - )} - > - <span aria-hidden className="i-ri-equalizer-2-line size-4" /> - </PopoverTrigger> - </div> - )} + </div> + <PopoverTrigger + aria-label={t(($) => $['modelProvider.modelSettings'], { ns: 'common' })} + disabled={readonly || !hasSelectedModel} + className={cn( + 'flex size-8 shrink-0 items-center justify-center rounded-l-none rounded-r-lg border-0 bg-components-button-tertiary-bg p-0 text-text-tertiary outline-hidden hover:bg-components-button-tertiary-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled', + isInWorkflow && + 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg', + )} + > + <span aria-hidden className="i-ri-equalizer-2-line size-4" /> + </PopoverTrigger> + </div> + )} <PopoverContent - placement={isInWorkflow ? 'left' : (renderTrigger ? 'bottom-end' : 'left-start')} + placement={isInWorkflow ? 'left' : renderTrigger ? 'bottom-end' : 'left-start'} sideOffset={4} popupClassName={cn(popupClassName, 'w-[400px] rounded-2xl')} > <div className="relative px-3 pt-3.5 pb-1"> <div className="pr-8 pl-1 system-xl-semibold text-text-primary"> - {t($ => $['modelProvider.modelSettings'], { ns: 'common' })} + {t(($) => $['modelProvider.modelSettings'], { ns: 'common' })} </div> <PopoverClose className="absolute top-2.5 right-2.5 flex items-center justify-center rounded-lg p-1.5 hover:bg-state-base-hover"> <span className="i-ri-close-line size-4 text-text-tertiary" /> @@ -209,60 +193,62 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({ /> </div> )} - { - !!parameterRules.length && ( - <div className={cn('flex flex-col gap-2 px-4 pt-3 pb-4', renderTrigger && 'border-t border-divider-subtle')}> - <div className="flex items-center gap-1"> - <div className="flex flex-1 items-center system-sm-semibold-uppercase text-text-secondary">{t($ => $['modelProvider.parameters'], { ns: 'common' })}</div> - { - PROVIDER_WITH_PRESET_TONE.includes(provider) && ( - <PresetsParameter - onSelect={handleSelectPresetParameter} - supportedParameterNames={supportedPresetParameterNames} - /> - ) - } + {!!parameterRules.length && ( + <div + className={cn( + 'flex flex-col gap-2 px-4 pt-3 pb-4', + renderTrigger && 'border-t border-divider-subtle', + )} + > + <div className="flex items-center gap-1"> + <div className="flex flex-1 items-center system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['modelProvider.parameters'], { ns: 'common' })} </div> - { - isRulesLoading - ? <div className="py-5"><Loading /></div> - : ( - [ - ...parameterRules, - ...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []), - ].map(parameter => ( - <ParameterItem - key={`${modelId}-${parameter.name}`} - parameterRule={parameter} - value={completionParams?.[parameter.name]} - onChange={v => handleParamChange(parameter.name, v)} - onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)} - isInWorkflow={isInWorkflow} - nodesOutputVars={nodesOutputVars} - availableNodes={availableNodes} - /> - )) - ) - } + {PROVIDER_WITH_PRESET_TONE.includes(provider) && ( + <PresetsParameter + onSelect={handleSelectPresetParameter} + supportedParameterNames={supportedPresetParameterNames} + /> + )} </div> - ) - } - { - !parameterRules.length && isRulesLoading && ( - <div className="px-4 py-5"><Loading /></div> - ) - } + {isRulesLoading ? ( + <div className="py-5"> + <Loading /> + </div> + ) : ( + [...parameterRules, ...(isAdvancedMode ? [STOP_PARAMETER_RULE] : [])].map( + (parameter) => ( + <ParameterItem + key={`${modelId}-${parameter.name}`} + parameterRule={parameter} + value={completionParams?.[parameter.name]} + onChange={(v) => handleParamChange(parameter.name, v)} + onSwitch={(checked, assignValue) => + handleSwitch(parameter.name, checked, assignValue) + } + isInWorkflow={isInWorkflow} + nodesOutputVars={nodesOutputVars} + availableNodes={availableNodes} + /> + ), + ) + )} + </div> + )} + {!parameterRules.length && isRulesLoading && ( + <div className="px-4 py-5"> + <Loading /> + </div> + )} </div> {!hideDebugWithMultipleModel && ( <div className="flex h-[50px] cursor-pointer items-center justify-between rounded-b-xl border-t border-t-divider-subtle px-4 system-sm-regular text-text-accent" onClick={() => onDebugWithMultipleModelChange?.()} > - { - debugWithMultipleModel - ? t($ => $.debugAsSingleModel, { ns: 'appDebug' }) - : t($ => $.debugAsMultipleModel, { ns: 'appDebug' }) - } + {debugWithMultipleModel + ? t(($) => $.debugAsSingleModel, { ns: 'appDebug' }) + : t(($) => $.debugAsMultipleModel, { ns: 'appDebug' })} <ArrowNarrowLeft className="size-3 rotate-180" /> </div> )} diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx index 8a50a8de5e8385..374d93ab6a279c 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx @@ -6,22 +6,20 @@ type ModelDisplayProps = { } const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => { - return currentModel - ? ( - <ModelName - className="flex grow items-center gap-1 px-1 py-[3px]" - modelItem={currentModel} - showMode - showFeatures - /> - ) - : ( - <div className="flex grow items-center gap-1 truncate px-1 py-[3px] opacity-50"> - <div className="overflow-hidden system-sm-regular text-ellipsis text-components-input-text-filled"> - {modelId} - </div> - </div> - ) + return currentModel ? ( + <ModelName + className="flex grow items-center gap-1 px-1 py-[3px]" + modelItem={currentModel} + showMode + showFeatures + /> + ) : ( + <div className="flex grow items-center gap-1 truncate px-1 py-[3px] opacity-50"> + <div className="overflow-hidden system-sm-regular text-ellipsis text-components-input-text-filled"> + {modelId} + </div> + </div> + ) } export default ModelDisplay diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx index d8012bdc8664f4..993fa3f9141d7e 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx @@ -1,13 +1,19 @@ import type { ModelParameterRule } from '../declarations' -import type { - Node, - NodeOutPutVar, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { Field, FieldItem, FieldLabel } from '@langgenius/dify-ui/field' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' import { Radio, RadioGroup } from '@langgenius/dify-ui/radio' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@langgenius/dify-ui/select' import { Slider } from '@langgenius/dify-ui/slider' import { Switch } from '@langgenius/dify-ui/switch' import { useEffect, useMemo, useRef, useState } from 'react' @@ -46,29 +52,33 @@ function ParameterItem({ const numberInputRef = useRef<HTMLInputElement>(null) const workflowNodesMap = useMemo(() => { - if (!isInWorkflow || !availableNodes.length) - return undefined + if (!isInWorkflow || !availableNodes.length) return undefined - return availableNodes.reduce<Record<string, Pick<Node['data'], 'title' | 'type'>>>((acc, node) => { - acc[node.id] = { - title: node.data.title, - type: node.data.type, - } - if (node.data.type === BlockEnum.Start) { - acc.sys = { - title: t($ => $['blocks.start'], { ns: 'workflow' }), - type: BlockEnum.Start, + return availableNodes.reduce<Record<string, Pick<Node['data'], 'title' | 'type'>>>( + (acc, node) => { + acc[node.id] = { + title: node.data.title, + type: node.data.type, } - } - return acc - }, {}) + if (node.data.type === BlockEnum.Start) { + acc.sys = { + title: t(($) => $['blocks.start'], { ns: 'workflow' }), + type: BlockEnum.Start, + } + } + return acc + }, + {}, + ) }, [availableNodes, isInWorkflow, t]) const getDefaultValue = () => { let defaultValue: ParameterValue if (parameterRule.type === 'int' || parameterRule.type === 'float') - defaultValue = isNullOrUndefined(parameterRule.default) ? (parameterRule.min || 0) : parameterRule.default + defaultValue = isNullOrUndefined(parameterRule.default) + ? parameterRule.min || 0 + : parameterRule.default else if (parameterRule.type === 'string' || parameterRule.type === 'text') defaultValue = parameterRule.default || '' else if (parameterRule.type === 'boolean') @@ -85,7 +95,10 @@ function ParameterItem({ const handleInputChange = (newValue: ParameterValue) => { setLocalValue(newValue) - if (onChange && (parameterRule.name === 'stop' || !isNullOrUndefined(value) || parameterRule.required)) + if ( + onChange && + (parameterRule.name === 'stop' || !isNullOrUndefined(value) || parameterRule.required) + ) onChange(newValue) } @@ -104,8 +117,7 @@ function ParameterItem({ } const handleNumberInputBlur = () => { - if (numberInputRef.current) - numberInputRef.current.value = renderValue as string + if (numberInputRef.current) numberInputRef.current.value = renderValue as string } const handleSlideChange = (num: number) => { @@ -129,7 +141,9 @@ function ParameterItem({ handleInputChange(v) } - const handleStringInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { + const handleStringInputChange = ( + e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, + ) => { handleInputChange(e.target.value) } @@ -151,17 +165,16 @@ function ParameterItem({ }, [value, parameterRule.type, renderValue]) const renderInput = () => { - const numberInputWithSlide = (parameterRule.type === 'int' || parameterRule.type === 'float') - && !isNullOrUndefined(parameterRule.min) - && !isNullOrUndefined(parameterRule.max) + const numberInputWithSlide = + (parameterRule.type === 'int' || parameterRule.type === 'float') && + !isNullOrUndefined(parameterRule.min) && + !isNullOrUndefined(parameterRule.max) if (parameterRule.type === 'int') { let step = 100 if (parameterRule.max) { - if (parameterRule.max < 100) - step = 1 - else if (parameterRule.max < 1000) - step = 10 + if (parameterRule.max < 100) step = 1 + else if (parameterRule.max < 1000) step = 10 } if (!numberInputWithSlide) { @@ -258,13 +271,13 @@ function ParameterItem({ return ( <Field name={parameterRule.name} className="contents"> <Fieldset - render={( + render={ <RadioGroup<boolean> className="w-[150px] gap-3" value={booleanValue} onValueChange={handleRadioChange} /> - )} + } > <FieldsetLegend className="sr-only">{translatedLabel}</FieldsetLegend> <FieldItem> @@ -292,7 +305,9 @@ function ParameterItem({ compact className="min-h-[22px] text-[13px]" value={renderValue as string} - onChange={(text) => { handleInputChange(text) }} + onChange={(text) => { + handleInputChange(text) + }} workflowVariableBlock={{ show: true, variables: nodesOutputVars, @@ -306,7 +321,10 @@ function ParameterItem({ return ( <input - className={cn(isInWorkflow ? 'w-[150px]' : 'w-full', 'ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 system-sm-regular text-components-input-text-filled outline-hidden')} + className={cn( + isInWorkflow ? 'w-[150px]' : 'w-full', + 'ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 system-sm-regular text-components-input-text-filled outline-hidden', + )} value={renderValue as string} onChange={handleStringInputChange} /> @@ -321,7 +339,9 @@ function ParameterItem({ compact className="min-h-[56px] text-[13px]" value={renderValue as string} - onChange={(text) => { handleInputChange(text) }} + onChange={(text) => { + handleInputChange(text) + }} workflowVariableBlock={{ show: true, variables: nodesOutputVars, @@ -346,14 +366,14 @@ function ParameterItem({ return ( <Select value={renderValue as string} - onValueChange={v => handleInputChange(v ?? undefined)} + onValueChange={(v) => handleInputChange(v ?? undefined)} > <SelectLabel className="sr-only">{sliderLabel}</SelectLabel> <SelectTrigger className="w-full"> <SelectValue /> </SelectTrigger> <SelectContent> - {parameterRule.options!.map(option => ( + {parameterRule.options!.map((option) => ( <SelectItem key={option} value={option}> <SelectItemText>{option}</SelectItemText> <SelectItemIndicator /> @@ -385,42 +405,36 @@ function ParameterItem({ <div className="mb-2 flex items-center justify-between"> <div className="shrink-0 basis-1/2"> <div className={cn('flex w-full shrink-0 items-center')}> - { - !parameterRule.required && parameterRule.name !== 'stop' && ( - <div className="mr-2 w-7"> - <Switch - checked={!isNullOrUndefined(value)} - onCheckedChange={handleSwitch} - size="md" - /> - </div> - ) - } + {!parameterRule.required && parameterRule.name !== 'stop' && ( + <div className="mr-2 w-7"> + <Switch + checked={!isNullOrUndefined(value)} + onCheckedChange={handleSwitch} + size="md" + /> + </div> + )} <div className="mr-0.5 truncate system-xs-regular text-text-secondary" title={sliderLabel} > {sliderLabel} </div> - { - parameterRule.help && ( - <Infotip - aria-label={parameterRule.help[language] || parameterRule.help.en_US} - className="mr-1" - popupClassName="w-[150px] whitespace-pre-wrap" - > - {parameterRule.help[language] || parameterRule.help.en_US} - </Infotip> - ) - } + {parameterRule.help && ( + <Infotip + aria-label={parameterRule.help[language] || parameterRule.help.en_US} + className="mr-1" + popupClassName="w-[150px] whitespace-pre-wrap" + > + {parameterRule.help[language] || parameterRule.help.en_US} + </Infotip> + )} </div> - { - parameterRule.type === 'tag' && ( - <div className={cn(!isInWorkflow && 'w-[150px]', 'system-xs-regular text-text-tertiary')}> - {parameterRule?.tagPlaceholder?.[language]} - </div> - ) - } + {parameterRule.type === 'tag' && ( + <div className={cn(!isInWorkflow && 'w-[150px]', 'system-xs-regular text-text-tertiary')}> + {parameterRule?.tagPlaceholder?.[language]} + </div> + )} </div> {renderInput()} </div> diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils.ts b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils.ts index 88aa5fb4ce7322..30a03c21ee76e1 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils.ts @@ -1,18 +1,15 @@ import { TONE_LIST } from '@/config' export const getSupportedPresetConfig = (toneId: number, supportedParameterNames?: string[]) => { - const tone = TONE_LIST.find(tone => tone.id === toneId) - if (!tone?.config) - return {} + const tone = TONE_LIST.find((tone) => tone.id === toneId) + if (!tone?.config) return {} - if (!supportedParameterNames) - return { ...tone.config } + if (!supportedParameterNames) return { ...tone.config } const supportedParameterNameSet = new Set(supportedParameterNames) return Object.entries(tone.config).reduce<Record<string, number>>((acc, [key, value]) => { - if (supportedParameterNameSet.has(key)) - acc[key] = value + if (supportedParameterNameSet.has(key)) acc[key] = value return acc }, {}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx index d9630afe0b8f61..702c2303ac9363 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx @@ -34,33 +34,36 @@ type PresetsParameterProps = { function PresetsParameter({ onSelect, supportedParameterNames }: PresetsParameterProps) { const { t } = useTranslation() - const supportedParameterNameSet = supportedParameterNames ? new Set(supportedParameterNames) : undefined + const supportedParameterNameSet = supportedParameterNames + ? new Set(supportedParameterNames) + : undefined const visiblePresetTones = supportedParameterNameSet - ? PRESET_TONE_LIST.filter(tone => Object.keys(tone.config ?? {}).some(key => supportedParameterNameSet.has(key))) + ? PRESET_TONE_LIST.filter((tone) => + Object.keys(tone.config ?? {}).some((key) => supportedParameterNameSet.has(key)), + ) : PRESET_TONE_LIST - if (!visiblePresetTones.length) - return null + if (!visiblePresetTones.length) return null return ( <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <Button size="small" variant="secondary" className="data-popup-open:bg-state-base-hover" /> - )} + } > - {t($ => $['modelProvider.loadPresets'], { ns: 'common' })} + {t(($) => $['modelProvider.loadPresets'], { ns: 'common' })} <span className="ml-0.5 i-ri-arrow-down-s-line size-3.5" /> </DropdownMenuTrigger> <DropdownMenuContent> - {visiblePresetTones.map(tone => ( + {visiblePresetTones.map((tone) => ( <DropdownMenuItem key={tone.id} onClick={() => onSelect(tone.id)}> {TONE_ICONS[tone.id]} - {t($ => $[toneI18nKeyMap[tone.name]], { ns: 'common' })} + {t(($) => $[toneI18nKeyMap[tone.name]], { ns: 'common' })} </DropdownMenuItem> ))} </DropdownMenuContent> diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx index ca97965e2fbd9b..d52fdfdd94c2d8 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx @@ -32,26 +32,42 @@ const StatusPopover = ({ ariaLabel, content, children }: StatusPopoverProps) => openOnHover aria-label={ariaLabel} className="inline-flex border-0 bg-transparent p-0" - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > {children} </PopoverTrigger> - <PopoverContent placement="top" popupClassName="rounded-md px-3 py-2 system-xs-regular text-text-tertiary"> + <PopoverContent + placement="top" + popupClassName="rounded-md px-3 py-2 system-xs-regular text-text-tertiary" + > {content} </PopoverContent> </Popover> ) -const StatusIndicators = ({ needsConfiguration, modelProvider, inModelList, disabled, pluginInfo, t }: StatusIndicatorsProps) => { +const StatusIndicators = ({ + needsConfiguration, + modelProvider, + inModelList, + disabled, + pluginInfo, + t, +}: StatusIndicatorsProps) => { const { data: pluginList } = useInstalledPluginList() - const renderTooltipContent = (title: string, description?: string, linkText?: string, linkHref?: string) => { + const renderTooltipContent = ( + title: string, + description?: string, + linkText?: string, + linkHref?: string, + ) => { return ( - <div className="flex w-[240px] max-w-[240px] flex-col gap-1 px-1 py-1.5" onClick={e => e.stopPropagation()}> + <div + className="flex w-[240px] max-w-[240px] flex-col gap-1 px-1 py-1.5" + onClick={(e) => e.stopPropagation()} + > <div className="title-xs-semi-bold text-text-primary">{title}</div> {description && ( - <div className="min-w-[200px] body-xs-regular text-text-secondary"> - {description} - </div> + <div className="min-w-[200px] body-xs-regular text-text-secondary">{description}</div> )} {linkText && linkHref && ( <div className="cursor-pointer body-xs-regular text-text-accent"> @@ -75,47 +91,50 @@ const StatusIndicators = ({ needsConfiguration, modelProvider, inModelList, disa {/* plugin installed from github/local and model is not in model list */} {!needsConfiguration && modelProvider && disabled && ( <> - {inModelList - ? ( - <StatusPopover - ariaLabel={t($ => $['nodes.agent.modelSelectorTooltips.deprecated'], { ns: 'workflow' })} - content={t($ => $['nodes.agent.modelSelectorTooltips.deprecated'], { ns: 'workflow' })} - > - <RiErrorWarningFill className="size-4 text-text-destructive" /> - </StatusPopover> - ) - : !pluginInfo - ? ( - <StatusPopover - ariaLabel={t($ => $['nodes.agent.modelNotSupport.title'], { ns: 'workflow' })} - content={renderTooltipContent( - t($ => $['nodes.agent.modelNotSupport.title'], { ns: 'workflow' }), - t($ => $['nodes.agent.modelNotSupport.desc'], { ns: 'workflow' }), - t($ => $['nodes.agent.linkToPlugin'], { ns: 'workflow' }), - '/plugins', - )} - > - <RiErrorWarningFill className="size-4 text-text-destructive" /> - </StatusPopover> - ) - : ( - <SwitchPluginVersion - tooltip={renderTooltipContent( - t($ => $['nodes.agent.modelNotSupport.title'], { ns: 'workflow' }), - t($ => $['nodes.agent.modelNotSupport.descForVersionSwitch'], { ns: 'workflow' }), - )} - uniqueIdentifier={pluginList?.plugins.find(plugin => plugin.name === pluginInfo.name)?.plugin_unique_identifier ?? ''} - /> - )} + {inModelList ? ( + <StatusPopover + ariaLabel={t(($) => $['nodes.agent.modelSelectorTooltips.deprecated'], { + ns: 'workflow', + })} + content={t(($) => $['nodes.agent.modelSelectorTooltips.deprecated'], { + ns: 'workflow', + })} + > + <RiErrorWarningFill className="size-4 text-text-destructive" /> + </StatusPopover> + ) : !pluginInfo ? ( + <StatusPopover + ariaLabel={t(($) => $['nodes.agent.modelNotSupport.title'], { ns: 'workflow' })} + content={renderTooltipContent( + t(($) => $['nodes.agent.modelNotSupport.title'], { ns: 'workflow' }), + t(($) => $['nodes.agent.modelNotSupport.desc'], { ns: 'workflow' }), + t(($) => $['nodes.agent.linkToPlugin'], { ns: 'workflow' }), + '/plugins', + )} + > + <RiErrorWarningFill className="size-4 text-text-destructive" /> + </StatusPopover> + ) : ( + <SwitchPluginVersion + tooltip={renderTooltipContent( + t(($) => $['nodes.agent.modelNotSupport.title'], { ns: 'workflow' }), + t(($) => $['nodes.agent.modelNotSupport.descForVersionSwitch'], { ns: 'workflow' }), + )} + uniqueIdentifier={ + pluginList?.plugins.find((plugin) => plugin.name === pluginInfo.name) + ?.plugin_unique_identifier ?? '' + } + /> + )} </> )} {!modelProvider && !pluginInfo && ( <StatusPopover - ariaLabel={t($ => $['nodes.agent.modelNotInMarketplace.title'], { ns: 'workflow' })} + ariaLabel={t(($) => $['nodes.agent.modelNotInMarketplace.title'], { ns: 'workflow' })} content={renderTooltipContent( - t($ => $['nodes.agent.modelNotInMarketplace.title'], { ns: 'workflow' }), - t($ => $['nodes.agent.modelNotInMarketplace.desc'], { ns: 'workflow' }), - t($ => $['nodes.agent.linkToPlugin'], { ns: 'workflow' }), + t(($) => $['nodes.agent.modelNotInMarketplace.title'], { ns: 'workflow' }), + t(($) => $['nodes.agent.modelNotInMarketplace.desc'], { ns: 'workflow' }), + t(($) => $['nodes.agent.linkToPlugin'], { ns: 'workflow' }), '/plugins', )} > diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/types.ts b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/types.ts index 37aa56b2821a7b..8bcc97b7cc562b 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/types.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/types.ts @@ -1,8 +1,4 @@ -import type { - Model, - ModelItem, - ModelProvider, -} from '../declarations' +import type { Model, ModelItem, ModelProvider } from '../declarations' export type TriggerProps = { open?: boolean diff --git a/web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx b/web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx index c05cf9a8ed20c2..14992363bf505d 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx @@ -50,7 +50,7 @@ function ModelProviderListSkeleton() { const { t } = useTranslation() return ( - <div role="status" aria-label={t($ => $.loading, { ns: 'common' })} className="space-y-2"> + <div role="status" aria-label={t(($) => $.loading, { ns: 'common' })} className="space-y-2"> {Array.from({ length: 3 }, (_, index) => ( <ModelProviderCardSkeleton key={index} /> ))} @@ -58,11 +58,7 @@ function ModelProviderListSkeleton() { ) } -function EmptyProviderState({ - enableMarketplace, -}: { - enableMarketplace: boolean -}) { +function EmptyProviderState({ enableMarketplace }: { enableMarketplace: boolean }) { const { t } = useTranslation() return ( @@ -70,25 +66,27 @@ function EmptyProviderState({ <div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-sm"> <span aria-hidden className="i-ri-brain-2-line size-5 text-text-primary" /> </div> - <div className="mt-2 system-sm-medium text-text-secondary">{t($ => $['modelProvider.emptyProviderTitle'], { ns: 'common' })}</div> + <div className="mt-2 system-sm-medium text-text-secondary"> + {t(($) => $['modelProvider.emptyProviderTitle'], { ns: 'common' })} + </div> <p className="mt-1 system-xs-regular text-text-tertiary"> - {enableMarketplace - ? ( - <Trans - i18nKey={$ => $['modelProvider.emptyProviderTipWithMarketplace']} - ns="common" - components={{ - marketplace: ( - <a - href="#model-provider-marketplace" - aria-label={t($ => $['marketplace.difyMarketplace'], { ns: 'plugin' })} - className="system-xs-medium text-text-accent hover:underline" - /> - ), - }} - /> - ) - : t($ => $['modelProvider.emptyProviderTip'], { ns: 'common' })} + {enableMarketplace ? ( + <Trans + i18nKey={($) => $['modelProvider.emptyProviderTipWithMarketplace']} + ns="common" + components={{ + marketplace: ( + <a + href="#model-provider-marketplace" + aria-label={t(($) => $['marketplace.difyMarketplace'], { ns: 'plugin' })} + className="system-xs-medium text-text-accent hover:underline" + /> + ), + }} + /> + ) : ( + t(($) => $['modelProvider.emptyProviderTip'], { ns: 'common' }) + )} </p> </div> ) @@ -101,24 +99,20 @@ type ProviderCardListProps = { } function isDebuggingProvider(provider: ModelProvider, pluginDetailMap: Map<string, PluginDetail>) { - return pluginDetailMap.get(providerToPluginId(provider.provider))?.source === PluginSource.debugging + return ( + pluginDetailMap.get(providerToPluginId(provider.provider))?.source === PluginSource.debugging + ) } -function ProviderCardList({ - providers, - pluginDetailMap, - notConfigured, -}: ProviderCardListProps) { - const sortedProviders = [...providers] - .sort((a, b) => { - const aIsDebuggingPlugin = isDebuggingProvider(a, pluginDetailMap) - const bIsDebuggingPlugin = isDebuggingProvider(b, pluginDetailMap) +function ProviderCardList({ providers, pluginDetailMap, notConfigured }: ProviderCardListProps) { + const sortedProviders = [...providers].sort((a, b) => { + const aIsDebuggingPlugin = isDebuggingProvider(a, pluginDetailMap) + const bIsDebuggingPlugin = isDebuggingProvider(b, pluginDetailMap) - if (aIsDebuggingPlugin === bIsDebuggingPlugin) - return 0 + if (aIsDebuggingPlugin === bIsDebuggingPlugin) return 0 - return aIsDebuggingPlugin ? -1 : 1 - }) + return aIsDebuggingPlugin ? -1 : 1 + }) return ( <div className="relative flex flex-col gap-2"> @@ -175,7 +169,9 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({ )} {showNotConfiguredProviders && ( <div className="flex flex-col gap-2 pt-2"> - <div className="flex h-5 items-center system-md-semibold text-text-primary">{t($ => $['modelProvider.toBeConfigured'], { ns: 'common' })}</div> + <div className="flex h-5 items-center system-md-semibold text-text-primary"> + {t(($) => $['modelProvider.toBeConfigured'], { ns: 'common' })} + </div> <ProviderCardList providers={filteredNotConfiguredProviders} notConfigured diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/feature-icon.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/feature-icon.spec.tsx index ffdbf6978070a9..487120c78a1ba8 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/feature-icon.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/feature-icon.spec.tsx @@ -1,9 +1,6 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { - ModelFeatureEnum, - ModelFeatureTextEnum, -} from '../../declarations' +import { ModelFeatureEnum, ModelFeatureTextEnum } from '../../declarations' import FeatureIcon from '../feature-icon' describe('FeatureIcon', () => { @@ -28,7 +25,7 @@ describe('FeatureIcon', () => { }) it('should show tooltip content on hover when showFeaturesLabel is false', async () => { - const cases: Array<{ feature: ModelFeatureEnum, text: string }> = [ + const cases: Array<{ feature: ModelFeatureEnum; text: string }> = [ { feature: ModelFeatureEnum.vision, text: ModelFeatureTextEnum.vision }, { feature: ModelFeatureEnum.document, text: ModelFeatureTextEnum.document }, { feature: ModelFeatureEnum.audio, text: ModelFeatureTextEnum.audio }, @@ -38,8 +35,9 @@ describe('FeatureIcon', () => { for (const { feature, text } of cases) { const { container, unmount } = render(<FeatureIcon feature={feature} />) await userEvent.hover(container.firstElementChild as HTMLElement) - expect(await screen.findByText(`common.modelProvider.featureSupported:{"feature":"${text}"}`)) - .toBeInTheDocument() + expect( + await screen.findByText(`common.modelProvider.featureSupported:{"feature":"${text}"}`), + ).toBeInTheDocument() unmount() } }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx index 41721774e68973..f015512a434488 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx @@ -2,11 +2,7 @@ import type { ReactNode } from 'react' import type { DefaultModel, Model, ModelItem } from '../../declarations' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' -import { - ConfigurationMethodEnum, - ModelStatusEnum, - ModelTypeEnum, -} from '../../declarations' +import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '../../declarations' import ModelSelector from '../index' vi.mock('../model-selector-trigger', () => ({ @@ -14,26 +10,34 @@ vi.mock('../model-selector-trigger', () => ({ currentProvider, currentModel, defaultModel, - }: { currentProvider?: Model, currentModel?: ModelItem, defaultModel?: DefaultModel }) => { - if (currentProvider && currentModel) - return <div>model-trigger</div> + }: { + currentProvider?: Model + currentModel?: ModelItem + defaultModel?: DefaultModel + }) => { + if (currentProvider && currentModel) return <div>model-trigger</div> - if (defaultModel) - return <div>{`deprecated:${defaultModel.model}`}</div> + if (defaultModel) return <div>{`deprecated:${defaultModel.model}`}</div> return <div>empty-trigger</div> }, })) vi.mock('../popup', async () => { - const { ComboboxItem } = await vi.importActual<typeof import('@langgenius/dify-ui/combobox')>('@langgenius/dify-ui/combobox') + const { ComboboxItem } = await vi.importActual<typeof import('@langgenius/dify-ui/combobox')>( + '@langgenius/dify-ui/combobox', + ) return { - default: ({ onConfigureEmptyState, onHide }: { onConfigureEmptyState?: () => void, onHide: () => void }) => ( + default: ({ + onConfigureEmptyState, + onHide, + }: { + onConfigureEmptyState?: () => void + onHide: () => void + }) => ( <> - <ComboboxItem value={{ provider: 'openai', model: 'gpt-4' }}> - select - </ComboboxItem> + <ComboboxItem value={{ provider: 'openai', model: 'gpt-4' }}>select</ComboboxItem> <button type="button" onClick={onHide}> hide </button> @@ -67,20 +71,17 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({ ...overrides, }) -const createTestQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, -}) +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) const renderWithQueryClient = (node: ReactNode) => { const queryClient = createTestQueryClient() - return render( - <QueryClientProvider client={queryClient}> - {node} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>) } describe('ModelSelector', () => { @@ -129,7 +130,9 @@ describe('ModelSelector', () => { it('should close popup before running the empty-state configure action', () => { const onConfigureEmptyState = vi.fn() - renderWithQueryClient(<ModelSelector modelList={[makeModel()]} onConfigureEmptyState={onConfigureEmptyState} />) + renderWithQueryClient( + <ModelSelector modelList={[makeModel()]} onConfigureEmptyState={onConfigureEmptyState} />, + ) const triggerButton = screen.getByRole('combobox') fireEvent.click(triggerButton) @@ -151,9 +154,9 @@ describe('ModelSelector', () => { fireEvent.click(screen.getByRole('combobox')) expect( - Array.from(document.body.querySelectorAll('[class]')).some(element => - element.className.includes('w-[432px]') - && element.className.includes('max-w-[432px]'), + Array.from(document.body.querySelectorAll('[class]')).some( + (element) => + element.className.includes('w-[432px]') && element.className.includes('max-w-[432px]'), ), ).toBe(true) }) @@ -177,10 +180,7 @@ describe('ModelSelector', () => { unmount() renderWithQueryClient( - <ModelSelector - defaultModel={{ provider: '', model: '' }} - modelList={[makeModel()]} - />, + <ModelSelector defaultModel={{ provider: '', model: '' }} modelList={[makeModel()]} />, ) expect(screen.getByText('deprecated:')).toBeInTheDocument() }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/model-selector-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/model-selector-trigger.spec.tsx index 85b0292c962f4d..b17e583e175499 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/model-selector-trigger.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/model-selector-trigger.spec.tsx @@ -77,10 +77,7 @@ describe('ModelSelectorTrigger', () => { const currentProvider = createModel() const currentModel = createModelItem() const { container } = render( - <ModelSelectorTrigger - currentProvider={currentProvider} - currentModel={currentModel} - />, + <ModelSelectorTrigger currentProvider={currentProvider} currentModel={currentModel} />, ) expect(screen.getByText('GPT-4')).toBeInTheDocument() @@ -91,9 +88,7 @@ describe('ModelSelectorTrigger', () => { it('should render deprecated default model and disabled style when selection is missing', () => { const { container } = render( - <ModelSelectorTrigger - defaultModel={{ provider: 'openai', model: 'legacy-model' }} - />, + <ModelSelectorTrigger defaultModel={{ provider: 'openai', model: 'legacy-model' }} />, ) expect(screen.getByText('legacy-model')).toBeInTheDocument() @@ -144,7 +139,9 @@ describe('ModelSelectorTrigger', () => { />, ) - expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument() + expect( + screen.getByText('common.modelProvider.selector.configureRequired'), + ).toBeInTheDocument() }) it('should apply credits exhausted badge style when model quota is exceeded', () => { @@ -160,13 +157,12 @@ describe('ModelSelectorTrigger', () => { }) render( - <ModelSelectorTrigger - currentProvider={createModel()} - currentModel={createModelItem()} - />, + <ModelSelectorTrigger currentProvider={createModel()} currentModel={createModelItem()} />, ) - expect(screen.getByText('common.modelProvider.selector.creditsExhausted').parentElement).toHaveClass('bg-components-badge-bg-dimm') + expect( + screen.getByText('common.modelProvider.selector.creditsExhausted').parentElement, + ).toHaveClass('bg-components-badge-bg-dimm') expect(screen.queryByText('CHAT')).not.toBeInTheDocument() }) @@ -183,13 +179,12 @@ describe('ModelSelectorTrigger', () => { }) render( - <ModelSelectorTrigger - currentProvider={createModel()} - currentModel={createModelItem()} - />, + <ModelSelectorTrigger currentProvider={createModel()} currentModel={createModelItem()} />, ) - expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument() + expect( + screen.getByText('common.modelProvider.selector.apiKeyUnavailable'), + ).toBeInTheDocument() expect(screen.queryByText('CHAT')).not.toBeInTheDocument() }) @@ -225,7 +220,9 @@ describe('ModelSelectorTrigger', () => { />, ) - expect(screen.queryByText('common.modelProvider.selector.configureRequired')).not.toBeInTheDocument() + expect( + screen.queryByText('common.modelProvider.selector.configureRequired'), + ).not.toBeInTheDocument() }) it('should show incompatible tooltip when hovering no-permission status badge', async () => { @@ -240,7 +237,9 @@ describe('ModelSelectorTrigger', () => { expect(screen.queryByText('CHAT')).not.toBeInTheDocument() await user.hover(screen.getByText('common.modelProvider.selector.incompatible')) - expect(await screen.findByText('common.modelProvider.selector.incompatibleTip')).toBeInTheDocument() + expect( + await screen.findByText('common.modelProvider.selector.incompatibleTip'), + ).toBeInTheDocument() }) it('should show incompatible badge when selected model fails the compatibility predicate', () => { @@ -261,21 +260,19 @@ describe('ModelSelectorTrigger', () => { describe('Edge Cases', () => { it('should show incompatible badge for deprecated selection', async () => { const user = userEvent.setup() - render( - <ModelSelectorTrigger - defaultModel={{ provider: 'openai', model: 'legacy-model' }} - />, - ) + render(<ModelSelectorTrigger defaultModel={{ provider: 'openai', model: 'legacy-model' }} />) expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument() await user.hover(screen.getByText('common.modelProvider.selector.incompatible')) - expect(await screen.findByText('common.modelProvider.selector.incompatibleTip')).toBeInTheDocument() + expect( + await screen.findByText('common.modelProvider.selector.incompatibleTip'), + ).toBeInTheDocument() }) it('should show credits exhausted badge for deprecated selection when ai credits are exhausted without api key', async () => { const user = userEvent.setup() - mockUseCredentialPanelState.mockImplementation(provider => ({ + mockUseCredentialPanelState.mockImplementation((provider) => ({ variant: provider ? 'no-usage' : 'credits-active', priority: provider ? 'apiKey' : 'credits', supportsCredits: !!provider, @@ -286,17 +283,17 @@ describe('ModelSelectorTrigger', () => { credits: provider ? 0 : 100, })) - render( - <ModelSelectorTrigger - defaultModel={{ provider: 'openai', model: 'legacy-model' }} - />, - ) + render(<ModelSelectorTrigger defaultModel={{ provider: 'openai', model: 'legacy-model' }} />) - expect(mockUseCredentialPanelState).toHaveBeenCalledWith(expect.objectContaining({ provider: 'openai' })) + expect(mockUseCredentialPanelState).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'openai' }), + ) expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument() await user.hover(screen.getByText('common.modelProvider.selector.creditsExhausted')) - expect(await screen.findByText('common.modelProvider.selector.creditsExhaustedTip')).toBeInTheDocument() + expect( + await screen.findByText('common.modelProvider.selector.creditsExhaustedTip'), + ).toBeInTheDocument() }) it('should render fallback icon when deprecated provider is not found', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx index 528a5416ee2010..ebed0493f4da23 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx @@ -9,11 +9,9 @@ vi.mock('../../hooks', () => ({ })) vi.mock('../model-selector-trigger', () => ({ - default: ({ open, readonly }: { open: boolean, readonly?: boolean }) => ( + default: ({ open, readonly }: { open: boolean; readonly?: boolean }) => ( <span> - {open ? 'open' : 'closed'} - - - {readonly ? 'readonly' : 'editable'} + {open ? 'open' : 'closed'}-{readonly ? 'readonly' : 'editable'} </span> ), })) @@ -21,7 +19,9 @@ vi.mock('../model-selector-trigger', () => ({ vi.mock('../popup', () => ({ default: ({ onHide }: { onHide: () => void }) => ( <div data-testid="popup"> - <button type="button" onClick={onHide}>hide-popup</button> + <button type="button" onClick={onHide}> + hide-popup + </button> </div> ), })) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx index 3d58ea7c79d01c..91837f94771975 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx @@ -68,7 +68,11 @@ vi.mock('../../provider-added-card/use-change-provider-priority', () => ({ })) vi.mock('../../provider-added-card/model-auth-dropdown/dropdown-content', () => ({ - default: ({ onClose }: { onClose: () => void }) => <button type="button" onClick={onClose}>close dropdown</button>, + default: ({ onClose }: { onClose: () => void }) => ( + <button type="button" onClick={onClose}> + close dropdown + </button> + ), })) const mockSetShowModelModal = vi.hoisted(() => vi.fn()) @@ -120,7 +124,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -160,22 +165,14 @@ const previewCardProps = () => ({ onPreviewCardClose: vi.fn(), }) -const createComboboxNode = ( - node: ReactElement, - onValueChange = vi.fn(), -) => ( +const createComboboxNode = (node: ReactElement, onValueChange = vi.fn()) => ( <Combobox filter={null} open onValueChange={onValueChange}> {node} </Combobox> ) -const renderWithCombobox = ( - node: ReactElement, - onValueChange = vi.fn(), -) => { - return render( - createComboboxNode(node, onValueChange), - ) +const renderWithCombobox = (node: ReactElement, onValueChange = vi.fn()) => { + return render(createComboboxNode(node, onValueChange)) } describe('PopupItem', () => { @@ -215,7 +212,10 @@ describe('PopupItem', () => { it('should select the combobox value when clicking an active model', () => { const onValueChange = vi.fn() - renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />, onValueChange) + renderWithCombobox( + <PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />, + onValueChange, + ) fireEvent.click(screen.getByText('GPT-4')) @@ -312,13 +312,19 @@ describe('PopupItem', () => { await userEvent.hover(suggestionIcon) - expect(await screen.findByText('common.modelProvider.selector.suggestionTip')).toBeInTheDocument() + expect( + await screen.findByText('common.modelProvider.selector.suggestionTip'), + ).toBeInTheDocument() }) it('should open model modal when clicking add on unconfigured model', () => { const onValueChange = vi.fn() const { rerender } = renderWithCombobox( - <PopupItem {...previewCardProps()} model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.noConfigure })] })} onHide={vi.fn()} />, + <PopupItem + {...previewCardProps()} + model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.noConfigure })] })} + onHide={vi.fn()} + />, onValueChange, ) @@ -334,18 +340,27 @@ describe('PopupItem', () => { expect(mockUpdateModelProviders).toHaveBeenCalled() expect(mockUpdateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration) - rerender(createComboboxNode( - <PopupItem - {...previewCardProps()} - model={makeModel({ - models: [makeModelItem({ status: ModelStatusEnum.noConfigure, model_type: undefined as unknown as ModelTypeEnum })], - })} - onHide={vi.fn()} - />, - )) + rerender( + createComboboxNode( + <PopupItem + {...previewCardProps()} + model={makeModel({ + models: [ + makeModelItem({ + status: ModelStatusEnum.noConfigure, + model_type: undefined as unknown as ModelTypeEnum, + }), + ], + })} + onHide={vi.fn()} + />, + ), + ) fireEvent.click(screen.getByText('COMMON.OPERATION.ADD')) - const call2 = mockSetShowModelModal.mock.calls.at(-1)?.[0] as { onSaveCallback?: () => void } | undefined + const call2 = mockSetShowModelModal.mock.calls.at(-1)?.[0] as + | { onSaveCallback?: () => void } + | undefined call2?.onSaveCallback?.() expect(mockUpdateModelProviders).toHaveBeenCalled() @@ -424,12 +439,14 @@ describe('PopupItem', () => { it('should show configure required when no credential name', () => { mockUseProviderContext.mockReturnValue({ - modelProviders: [makeProvider({ - custom_configuration: { - status: CustomConfigurationStatusEnum.noConfigure, - current_credential_name: '', - }, - })], + modelProviders: [ + makeProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + current_credential_name: '', + }, + }), + ], }) mockCredentialPanelState.mockReturnValue({ variant: 'api-required-configure', @@ -449,9 +466,11 @@ describe('PopupItem', () => { it('should show credits info when using system provider with remaining credits', () => { mockUseProviderContext.mockReturnValue({ - modelProviders: [makeProvider({ - preferred_provider_type: PreferredProviderTypeEnum.system, - })], + modelProviders: [ + makeProvider({ + preferred_provider_type: PreferredProviderTypeEnum.system, + }), + ], }) mockCredentialPanelState.mockReturnValue({ variant: 'credits-active', @@ -471,9 +490,11 @@ describe('PopupItem', () => { it('should show credits exhausted when system provider has no credits', () => { mockUseProviderContext.mockReturnValue({ - modelProviders: [makeProvider({ - preferred_provider_type: PreferredProviderTypeEnum.system, - })], + modelProviders: [ + makeProvider({ + preferred_provider_type: PreferredProviderTypeEnum.system, + }), + ], }) mockUseAppContext.mockReturnValue({ currentWorkspace: { trial_credits: 100, trial_credits_used: 100 }, diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx index 4c3c90558b6534..26ee80f5389c7d 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx @@ -30,9 +30,12 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: () => mockSearchParams.current, })) -vi.mock('@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', () => ({ - default: () => ({ canInstallPlugin: true }), -})) +vi.mock( + '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', + () => ({ + default: () => ({ canInstallPlugin: true }), + }), +) const mockSupportFunctionCall = vi.hoisted(() => vi.fn()) vi.mock('@/utils/tool-call', () => ({ @@ -44,7 +47,15 @@ type MockMarketplacePlugin = { latest_package_identifier: string } -type MockContextProvider = Pick<ModelProvider, 'provider' | 'label' | 'icon_small' | 'icon_small_dark' | 'custom_configuration' | 'system_configuration'> +type MockContextProvider = Pick< + ModelProvider, + | 'provider' + | 'label' + | 'icon_small' + | 'icon_small_dark' + | 'custom_configuration' + | 'system_configuration' +> const mockMarketplacePlugins = vi.hoisted(() => ({ current: [] as MockMarketplacePlugin[], @@ -72,7 +83,7 @@ vi.mock('../popup-item', () => ({ default: ({ model }: { model: Model }) => ( <div> <span>{model.provider}</span> - {model.models.map(modelItem => ( + {model.models.map((modelItem) => ( <span key={modelItem.model}>{modelItem.model}</span> ))} </div> @@ -94,15 +105,10 @@ function PopupHarness(props: PopupTestProps) { inputValue={inputValue} open onInputValueChange={(newInputValue, details) => { - if (details.reason !== 'item-press') - setInputValue(newInputValue) + if (details.reason !== 'item-press') setInputValue(newInputValue) }} > - <Popup - {...props} - inputValue={inputValue} - onInputValueChange={setInputValue} - /> + <Popup {...props} inputValue={inputValue} onInputValueChange={setInputValue} /> </Combobox> ) } @@ -110,16 +116,18 @@ function PopupHarness(props: PopupTestProps) { const renderPopup = ( ui: ReactElement<PopupTestProps>, options: Parameters<typeof renderWithSystemFeatures>[1] = {}, -) => renderWithSystemFeatures(ui, { - ...options, - systemFeatures: options.systemFeatures === null - ? null - : { - enable_marketplace: true, - ...(options.systemFeatures ?? {}), - }, - trialModels: options.trialModels ?? mockTrialModels.current, -}) +) => + renderWithSystemFeatures(ui, { + ...options, + systemFeatures: + options.systemFeatures === null + ? null + : { + enable_marketplace: true, + ...(options.systemFeatures ?? {}), + }, + trialModels: options.trialModels ?? mockTrialModels.current, + }) const mockTrialCredits = vi.hoisted(() => ({ credits: 200, @@ -134,7 +142,10 @@ vi.mock('../../provider-added-card/use-trial-credits', () => ({ vi.mock('../../provider-added-card/model-auth-dropdown/credits-exhausted-alert', () => ({ default: ({ hasApiKeyFallback }: { hasApiKeyFallback: boolean }) => ( - <div data-testid="credits-exhausted-alert" data-has-api-key-fallback={String(hasApiKeyFallback)} /> + <div + data-testid="credits-exhausted-alert" + data-has-api-key-fallback={String(hasApiKeyFallback)} + /> ), })) @@ -173,8 +184,12 @@ vi.mock('../../utils', async () => { ...actual, MODEL_PROVIDER_QUOTA_GET_PAID: ['test-openai', 'test-anthropic'], providerIconMap: { - 'test-openai': ({ className }: { className?: string }) => <span className={className}>OAI</span>, - 'test-anthropic': ({ className }: { className?: string }) => <span className={className}>ANT</span>, + 'test-openai': ({ className }: { className?: string }) => ( + <span className={className}>OAI</span> + ), + 'test-anthropic': ({ className }: { className?: string }) => ( + <span className={className}>ANT</span> + ), }, modelNameMap: { 'test-openai': 'TestOpenAI', @@ -207,7 +222,9 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({ ...overrides, }) -const makeContextProvider = (overrides: Partial<MockContextProvider> = {}): MockContextProvider => ({ +const makeContextProvider = ( + overrides: Partial<MockContextProvider> = {}, +): MockContextProvider => ({ provider: 'test-openai', label: { en_US: 'Test OpenAI', zh_Hans: 'Test OpenAI' }, icon_small: { en_US: '', zh_Hans: '' }, @@ -243,19 +260,16 @@ describe('Popup', () => { it('should filter models by search and allow clearing search without blurring the input', async () => { const user = userEvent.setup() - renderPopup( - <PopupHarness - modelList={[makeModel()]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />) expect(screen.getByText('openai'))!.toBeInTheDocument() const input = screen.getByPlaceholderText('datasetSettings.form.searchModel') await user.click(input) await user.keyboard('not-found') - expect(screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch/))!.toBeInTheDocument() + expect( + screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch/), + )!.toBeInTheDocument() const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) expect(clearButton)!.toBeInTheDocument() @@ -270,28 +284,36 @@ describe('Popup', () => { <PopupHarness modelList={[ makeModel({ - models: [makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } })], + models: [ + makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }), + ], }), makeModel({ provider: 'anthropic', label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' }, - models: [makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } })], + models: [ + makeModelItem({ + model: 'claude-3', + label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' }, + }), + ], }), ]} onHide={vi.fn()} />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'claude' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'claude' }, + }) expect(screen.queryByText('openai')).not.toBeInTheDocument() expect(screen.getByText('anthropic')).toBeInTheDocument() expect(screen.getByText('claude-3')).toBeInTheDocument() expect(screen.queryByText('gpt-4')).not.toBeInTheDocument() - expect(screen.queryByText(/common\.modelProvider\.selector\.noModelFoundForSearch/)).not.toBeInTheDocument() + expect( + screen.queryByText(/common\.modelProvider\.selector\.noModelFoundForSearch/), + ).not.toBeInTheDocument() }) it('should show empty search placeholder when direct props have no provider or model match', () => { @@ -309,12 +331,13 @@ describe('Popup', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'mistral' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'mistral' }, + }) - expect(screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch/))!.toBeInTheDocument() + expect( + screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch/), + )!.toBeInTheDocument() expect(screen.queryByText('openai')).not.toBeInTheDocument() expect(screen.queryByText('gpt-4')).not.toBeInTheDocument() }) @@ -335,7 +358,10 @@ describe('Popup', () => { provider: 'anthropic', label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' }, models: [ - makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } }), + makeModelItem({ + model: 'claude-3', + label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' }, + }), ], }), ]} @@ -343,10 +369,9 @@ describe('Popup', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'openai' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'openai' }, + }) expect(screen.getByText('openai'))!.toBeInTheDocument() expect(screen.getByText('gpt-4'))!.toBeInTheDocument() @@ -371,7 +396,10 @@ describe('Popup', () => { provider: 'anthropic', label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' }, models: [ - makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } }), + makeModelItem({ + model: 'claude-3', + label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' }, + }), ], }), ]} @@ -379,10 +407,9 @@ describe('Popup', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'opnai' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'opnai' }, + }) expect(screen.getByText('openai'))!.toBeInTheDocument() expect(screen.getByText('gpt-4'))!.toBeInTheDocument() @@ -405,8 +432,14 @@ describe('Popup', () => { provider: 'anthropic', label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' }, models: [ - makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } }), - makeModelItem({ model: 'claude-instant', label: { en_US: 'Claude Instant', zh_Hans: 'Claude Instant' } }), + makeModelItem({ + model: 'claude-3', + label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' }, + }), + makeModelItem({ + model: 'claude-instant', + label: { en_US: 'Claude Instant', zh_Hans: 'Claude Instant' }, + }), ], }), ]} @@ -414,10 +447,9 @@ describe('Popup', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'claude3' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'claude3' }, + }) expect(screen.queryByText('openai')).not.toBeInTheDocument() expect(screen.getByText('anthropic'))!.toBeInTheDocument() @@ -434,10 +466,22 @@ describe('Popup', () => { label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, models: [ makeModelItem({ model: 'gpt-5.4', label: { en_US: 'gpt-5.4', zh_Hans: 'gpt-5.4' } }), - makeModelItem({ model: 'gpt-5.4-2026-03-05', label: { en_US: 'gpt-5.4-2026-03-05', zh_Hans: 'gpt-5.4-2026-03-05' } }), - makeModelItem({ model: 'gpt-5.4-mini', label: { en_US: 'gpt-5.4-mini', zh_Hans: 'gpt-5.4-mini' } }), - makeModelItem({ model: 'gpt-5.4-nano', label: { en_US: 'gpt-5.4-nano', zh_Hans: 'gpt-5.4-nano' } }), - makeModelItem({ model: 'gpt-5.3-chat-latest', label: { en_US: 'gpt-5.3-chat-latest', zh_Hans: 'gpt-5.3-chat-latest' } }), + makeModelItem({ + model: 'gpt-5.4-2026-03-05', + label: { en_US: 'gpt-5.4-2026-03-05', zh_Hans: 'gpt-5.4-2026-03-05' }, + }), + makeModelItem({ + model: 'gpt-5.4-mini', + label: { en_US: 'gpt-5.4-mini', zh_Hans: 'gpt-5.4-mini' }, + }), + makeModelItem({ + model: 'gpt-5.4-nano', + label: { en_US: 'gpt-5.4-nano', zh_Hans: 'gpt-5.4-nano' }, + }), + makeModelItem({ + model: 'gpt-5.3-chat-latest', + label: { en_US: 'gpt-5.3-chat-latest', zh_Hans: 'gpt-5.3-chat-latest' }, + }), makeModelItem({ model: 'gpt-5.2', label: { en_US: 'gpt-5.2', zh_Hans: 'gpt-5.2' } }), makeModelItem({ model: 'gpt-4.1', label: { en_US: 'gpt-4.1', zh_Hans: 'gpt-4.1' } }), ], @@ -447,10 +491,9 @@ describe('Popup', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'gpt5.4' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'gpt5.4' }, + }) expect(screen.getByText('gpt-5.4'))!.toBeInTheDocument() expect(screen.getByText('gpt-5.4-2026-03-05'))!.toBeInTheDocument() @@ -468,30 +511,43 @@ describe('Popup', () => { makeModel({ provider: 'langgenius/openai/openai', label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - models: [makeModelItem({ model: 'gpt-5.4', label: { en_US: 'gpt-5.4', zh_Hans: 'gpt-5.4' } })], + models: [ + makeModelItem({ model: 'gpt-5.4', label: { en_US: 'gpt-5.4', zh_Hans: 'gpt-5.4' } }), + ], }), makeModel({ provider: 'langgenius/openrouter/openrouter', label: { en_US: 'OpenRouter', zh_Hans: 'OpenRouter' }, - models: [makeModelItem({ model: 'openrouter-model', label: { en_US: 'OpenRouter Model', zh_Hans: 'OpenRouter Model' } })], + models: [ + makeModelItem({ + model: 'openrouter-model', + label: { en_US: 'OpenRouter Model', zh_Hans: 'OpenRouter Model' }, + }), + ], }), makeModel({ provider: 'langgenius/openai_api_compatible/openai_api_compatible', label: { en_US: 'OpenAI-API-compatible', zh_Hans: 'OpenAI-API-compatible' }, - models: [makeModelItem({ model: 'compatible-model', label: { en_US: 'Compatible Model', zh_Hans: 'Compatible Model' } })], + models: [ + makeModelItem({ + model: 'compatible-model', + label: { en_US: 'Compatible Model', zh_Hans: 'Compatible Model' }, + }), + ], }), ]} onHide={vi.fn()} />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'openai' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'openai' }, + }) expect(screen.getByText('langgenius/openai/openai'))!.toBeInTheDocument() - expect(screen.getByText('langgenius/openai_api_compatible/openai_api_compatible'))!.toBeInTheDocument() + expect( + screen.getByText('langgenius/openai_api_compatible/openai_api_compatible'), + )!.toBeInTheDocument() expect(screen.queryByText('langgenius/openrouter/openrouter')).not.toBeInTheDocument() }) @@ -502,27 +558,38 @@ describe('Popup', () => { makeModel({ provider: 'langgenius/zhipuai/zhipuai', label: { en_US: 'ZHIPU AI', zh_Hans: '智谱 AI' }, - models: [makeModelItem({ model: 'glm-4.7', label: { en_US: 'GLM-4.7', zh_Hans: 'GLM-4.7' } })], + models: [ + makeModelItem({ model: 'glm-4.7', label: { en_US: 'GLM-4.7', zh_Hans: 'GLM-4.7' } }), + ], }), makeModel({ provider: 'langgenius/gemini/google', label: { en_US: 'Gemini', zh_Hans: 'Gemini' }, - models: [makeModelItem({ model: 'gemini-3-flash-preview', label: { en_US: 'gemini-3-flash-preview', zh_Hans: 'gemini-3-flash-preview' } })], + models: [ + makeModelItem({ + model: 'gemini-3-flash-preview', + label: { en_US: 'gemini-3-flash-preview', zh_Hans: 'gemini-3-flash-preview' }, + }), + ], }), makeModel({ provider: 'langgenius/tongyi/tongyi', label: { en_US: 'Tongyi', zh_Hans: '通义' }, - models: [makeModelItem({ model: 'qwen-plus', label: { en_US: 'qwen-plus', zh_Hans: 'qwen-plus' } })], + models: [ + makeModelItem({ + model: 'qwen-plus', + label: { en_US: 'qwen-plus', zh_Hans: 'qwen-plus' }, + }), + ], }), ]} onHide={vi.fn()} />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'gemni' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'gemni' }, + }) expect(screen.getByText('langgenius/gemini/google'))!.toBeInTheDocument() expect(screen.queryByText('langgenius/zhipuai/zhipuai')).not.toBeInTheDocument() @@ -545,10 +612,9 @@ describe('Popup', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'openai' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'openai' }, + }) expect(screen.getByText('azure_openai'))!.toBeInTheDocument() expect(screen.getByText('gpt-4'))!.toBeInTheDocument() @@ -574,12 +640,13 @@ describe('Popup', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'openai' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'openai' }, + }) - expect(screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch.*openai/))!.toBeInTheDocument() + expect( + screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch.*openai/), + )!.toBeInTheDocument() expect(screen.queryByText('gpt-4')).not.toBeInTheDocument() expect(screen.queryByText('gpt-4-tool')).not.toBeInTheDocument() }) @@ -605,20 +672,34 @@ describe('Popup', () => { expect(screen.getByText('gpt-4o')).toBeInTheDocument() expect(screen.queryByText('gpt-4')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.modelProvider.selector.showIncompatibleModels' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.modelProvider.selector.showIncompatibleModels' }), + ).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'common.modelProvider.selector.showIncompatibleModels' })) + await user.click( + screen.getByRole('button', { name: 'common.modelProvider.selector.showIncompatibleModels' }), + ) expect(screen.getByText('gpt-4o')).toBeInTheDocument() expect(screen.getByText('gpt-4')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.modelProvider.selector.showIncompatibleModels' })).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.modelProvider.selector.hideIncompatibleModels' })).toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'common.modelProvider.selector.showIncompatibleModels', + }), + ).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.modelProvider.selector.hideIncompatibleModels' }), + ).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'common.modelProvider.selector.hideIncompatibleModels' })) + await user.click( + screen.getByRole('button', { name: 'common.modelProvider.selector.hideIncompatibleModels' }), + ) expect(screen.getByText('gpt-4o')).toBeInTheDocument() expect(screen.queryByText('gpt-4')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.modelProvider.selector.showIncompatibleModels' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.modelProvider.selector.showIncompatibleModels' }), + ).toBeInTheDocument() }) it('should show matching models when searching by model name', () => { @@ -626,28 +707,36 @@ describe('Popup', () => { <PopupHarness modelList={[ makeModel({ - models: [makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } })], + models: [ + makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }), + ], }), makeModel({ provider: 'anthropic', label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' }, - models: [makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } })], + models: [ + makeModelItem({ + model: 'claude-3', + label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' }, + }), + ], }), ]} onHide={vi.fn()} />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'claude' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'claude' }, + }) expect(screen.queryByText('openai')).not.toBeInTheDocument() expect(screen.getByText('anthropic')).toBeInTheDocument() expect(screen.getByText('claude-3')).toBeInTheDocument() expect(screen.queryByText('gpt-4')).not.toBeInTheDocument() - expect(screen.queryByText(/common\.modelProvider\.selector\.noModelFoundForSearch.*claude/)).not.toBeInTheDocument() + expect( + screen.queryByText(/common\.modelProvider\.selector\.noModelFoundForSearch.*claude/), + ).not.toBeInTheDocument() }) it('should show empty search placeholder when no provider or model name matches', () => { @@ -665,12 +754,13 @@ describe('Popup', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'mistral' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'mistral' }, + }) - expect(screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch.*mistral/))!.toBeInTheDocument() + expect( + screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch.*mistral/), + )!.toBeInTheDocument() expect(screen.queryByText('openai')).not.toBeInTheDocument() expect(screen.queryByText('gpt-4')).not.toBeInTheDocument() }) @@ -691,7 +781,10 @@ describe('Popup', () => { provider: 'anthropic', label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' }, models: [ - makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } }), + makeModelItem({ + model: 'claude-3', + label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' }, + }), ], }), ]} @@ -699,10 +792,9 @@ describe('Popup', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'openai' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'openai' }, + }) expect(screen.getByText('openai'))!.toBeInTheDocument() expect(screen.getByText('gpt-4'))!.toBeInTheDocument() @@ -727,10 +819,9 @@ describe('Popup', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'openai' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'openai' }, + }) expect(screen.getByText('azure_openai'))!.toBeInTheDocument() expect(screen.getByText('gpt-4'))!.toBeInTheDocument() @@ -756,25 +847,23 @@ describe('Popup', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'openai' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'openai' }, + }) - expect(screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch/))!.toBeInTheDocument() + expect( + screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch/), + )!.toBeInTheDocument() expect(screen.queryByText('gpt-4')).not.toBeInTheDocument() expect(screen.queryByText('gpt-4-tool')).not.toBeInTheDocument() }) it('should not show compatible-only helper text when no scope features are applied', () => { - renderPopup( - <PopupHarness - modelList={[makeModel()]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />) - expect(screen.queryByText('common.modelProvider.selector.onlyCompatibleModelsShown')).not.toBeInTheDocument() + expect( + screen.queryByText('common.modelProvider.selector.onlyCompatibleModelsShown'), + ).not.toBeInTheDocument() }) it('should show compatible-only helper text when scope features are applied', () => { @@ -787,7 +876,9 @@ describe('Popup', () => { ) expect(screen.getByTestId('compatible-models-banner'))!.toBeInTheDocument() - expect(screen.getByText('common.modelProvider.selector.onlyCompatibleModelsShown'))!.toBeInTheDocument() + expect( + screen.getByText('common.modelProvider.selector.onlyCompatibleModelsShown'), + )!.toBeInTheDocument() }) it('should keep search and footer outside the scrollable model list', () => { @@ -801,7 +892,9 @@ describe('Popup', () => { const scrollRegion = screen.getByRole('region', { name: 'common.modelProvider.models' }) const searchInput = screen.getByPlaceholderText('datasetSettings.form.searchModel') - const settingsButton = screen.getByRole('button', { name: /common\.modelProvider\.selector\.modelProviderSettings/ }) + const settingsButton = screen.getByRole('button', { + name: /common\.modelProvider\.selector\.modelProviderSettings/, + }) expect(scrollRegion)!.toBeInTheDocument() expect(scrollRegion).not.toContainElement(searchInput) @@ -811,7 +904,9 @@ describe('Popup', () => { it('should filter by scope features including toolCall and non-toolCall checks', () => { const modelList = [ - makeModel({ models: [makeModelItem({ features: [ModelFeatureEnum.toolCall, ModelFeatureEnum.vision] })] }), + makeModel({ + models: [makeModelItem({ features: [ModelFeatureEnum.toolCall, ModelFeatureEnum.vision] })], + }), ] mockSupportFunctionCall.mockReturnValue(false) @@ -822,7 +917,9 @@ describe('Popup', () => { scopeFeatures={[ModelFeatureEnum.toolCall, ModelFeatureEnum.vision]} />, ) - expect(screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch/))!.toBeInTheDocument() + expect( + screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch/), + )!.toBeInTheDocument() unmount() mockSupportFunctionCall.mockReturnValue(true) @@ -853,7 +950,9 @@ describe('Popup', () => { scopeFeatures={[ModelFeatureEnum.vision]} />, ) - expect(screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch/))!.toBeInTheDocument() + expect( + screen.getByText(/common\.modelProvider\.selector\.noModelFoundForSearch/), + )!.toBeInTheDocument() }) it('should match model labels from fallback languages when current language key is missing', () => { @@ -874,10 +973,9 @@ describe('Popup', () => { />, ) - fireEvent.change( - screen.getByPlaceholderText('datasetSettings.form.searchModel'), - { target: { value: 'openai' } }, - ) + fireEvent.change(screen.getByPlaceholderText('datasetSettings.form.searchModel'), { + target: { value: 'openai' }, + }) expect(screen.getByText('openai'))!.toBeInTheDocument() }) @@ -897,14 +995,12 @@ describe('Popup', () => { }), ] - renderPopup( - <PopupHarness - modelList={[makeModel()]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />) - expect(screen.getByTestId('credits-exhausted-alert'))!.toHaveAttribute('data-has-api-key-fallback', 'false') + expect(screen.getByTestId('credits-exhausted-alert'))!.toHaveAttribute( + 'data-has-api-key-fallback', + 'false', + ) }) it('should not show credits exhausted alert when only non-trial system providers are exhausted', () => { @@ -923,12 +1019,7 @@ describe('Popup', () => { }), ] - renderPopup( - <PopupHarness - modelList={[makeModel()]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />) expect(screen.queryByTestId('credits-exhausted-alert')).not.toBeInTheDocument() }) @@ -952,24 +1043,14 @@ describe('Popup', () => { }), ] - renderPopup( - <PopupHarness - modelList={[makeModel()]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />) expect(screen.queryByTestId('credits-exhausted-alert')).not.toBeInTheDocument() }) it('should open provider settings when clicking footer link', () => { const onHide = vi.fn() - renderPopup( - <PopupHarness - modelList={[makeModel()]} - onHide={onHide} - />, - ) + renderPopup(<PopupHarness modelList={[makeModel()]} onHide={onHide} />) fireEvent.click(screen.getByText('common.modelProvider.selector.modelProviderSettings')) @@ -982,39 +1063,33 @@ describe('Popup', () => { it('should hide provider settings footer when current account settings tab is provider', () => { mockSearchParams.current = new URLSearchParams('action=showSettings&tab=provider') - renderPopup( - <PopupHarness - modelList={[makeModel()]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />) - expect(screen.queryByText('common.modelProvider.selector.modelProviderSettings')).not.toBeInTheDocument() + expect( + screen.queryByText('common.modelProvider.selector.modelProviderSettings'), + ).not.toBeInTheDocument() }) it('should hide provider settings footer when requested by the caller', () => { renderPopup( - <PopupHarness - hideProviderSettingsFooter - modelList={[makeModel()]} - onHide={vi.fn()} - />, + <PopupHarness hideProviderSettingsFooter modelList={[makeModel()]} onHide={vi.fn()} />, ) - expect(screen.queryByText('common.modelProvider.selector.modelProviderSettings')).not.toBeInTheDocument() + expect( + screen.queryByText('common.modelProvider.selector.modelProviderSettings'), + ).not.toBeInTheDocument() }) it('should open provider settings from empty state when no providers are configured', () => { const onHide = vi.fn() - renderPopup( - <PopupHarness - modelList={[]} - onHide={onHide} - />, - ) + renderPopup(<PopupHarness modelList={[]} onHide={onHide} />) - expect(screen.getByText(/modelProvider\.selector\.noProviderConfigured(?!Desc)/))!.toBeInTheDocument() - expect(screen.getByText(/modelProvider\.selector\.noProviderConfiguredDesc/))!.toBeInTheDocument() + expect( + screen.getByText(/modelProvider\.selector\.noProviderConfigured(?!Desc)/), + )!.toBeInTheDocument() + expect( + screen.getByText(/modelProvider\.selector\.noProviderConfiguredDesc/), + )!.toBeInTheDocument() fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/)) expect(onHide).toHaveBeenCalled() @@ -1026,12 +1101,7 @@ describe('Popup', () => { it('should only close the empty state selector when current account settings tab is provider', () => { mockSearchParams.current = new URLSearchParams('action=showSettings&tab=provider') const onHide = vi.fn() - renderPopup( - <PopupHarness - modelList={[]} - onHide={onHide} - />, - ) + renderPopup(<PopupHarness modelList={[]} onHide={onHide} />) fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/)) @@ -1042,28 +1112,25 @@ describe('Popup', () => { it('should render marketplace providers that are not installed', () => { mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })] - renderPopup( - <PopupHarness - modelList={[]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />) expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument() expect(screen.getByText('TestAnthropic'))!.toBeInTheDocument() expect(screen.getByText(/modelProvider\.selector\.fromMarketplace/))!.toBeInTheDocument() - expect(screen.getByText(/modelProvider\.selector\.discoverMoreInMarketplace/))!.toBeInTheDocument() - expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('/plugins/model', expect.objectContaining({ theme: expect.any(String) })) + expect( + screen.getByText(/modelProvider\.selector\.discoverMoreInMarketplace/), + )!.toBeInTheDocument() + expect(mockGetMarketplaceUrl).toHaveBeenCalledWith( + '/plugins/model', + expect.objectContaining({ theme: expect.any(String) }), + ) }) it('should hide marketplace providers when marketplace is disabled', () => { mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })] renderPopup( - <PopupHarness - modelList={[makeModel({ provider: 'test-openai' })]} - onHide={vi.fn()} - />, + <PopupHarness modelList={[makeModel({ provider: 'test-openai' })]} onHide={vi.fn()} />, { systemFeatures: { enable_marketplace: false }, }, @@ -1072,24 +1139,23 @@ describe('Popup', () => { expect(screen.getByText('test-openai'))!.toBeInTheDocument() expect(screen.queryByText('TestAnthropic')).not.toBeInTheDocument() expect(screen.queryByText(/modelProvider\.selector\.fromMarketplace/)).not.toBeInTheDocument() - expect(screen.queryByText(/modelProvider\.selector\.discoverMoreInMarketplace/)).not.toBeInTheDocument() + expect( + screen.queryByText(/modelProvider\.selector\.discoverMoreInMarketplace/), + ).not.toBeInTheDocument() expect(screen.queryByText(/common\.modelProvider\.selector\.install/)).not.toBeInTheDocument() }) it('should show installed marketplace providers without models when AI credits are available', () => { - mockContextModelProviders.current = [makeContextProvider({ - provider: 'test-anthropic', - system_configuration: { - enabled: true, - } as MockContextProvider['system_configuration'], - })] + mockContextModelProviders.current = [ + makeContextProvider({ + provider: 'test-anthropic', + system_configuration: { + enabled: true, + } as MockContextProvider['system_configuration'], + }), + ] - renderPopup( - <PopupHarness - modelList={[]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />) expect(screen.getByText('test-anthropic'))!.toBeInTheDocument() expect(screen.getByText('TestOpenAI'))!.toBeInTheDocument() @@ -1101,19 +1167,16 @@ describe('Popup', () => { totalCredits: 200, isExhausted: true, }) - mockContextModelProviders.current = [makeContextProvider({ - provider: 'test-anthropic', - system_configuration: { - enabled: true, - } as MockContextProvider['system_configuration'], - })] + mockContextModelProviders.current = [ + makeContextProvider({ + provider: 'test-anthropic', + system_configuration: { + enabled: true, + } as MockContextProvider['system_configuration'], + }), + ] - renderPopup( - <PopupHarness - modelList={[]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />) expect(screen.queryByText('test-anthropic')).not.toBeInTheDocument() expect(screen.queryByText('TestAnthropic')).not.toBeInTheDocument() @@ -1121,12 +1184,7 @@ describe('Popup', () => { }) it('should toggle marketplace section collapse', () => { - renderPopup( - <PopupHarness - modelList={[]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />) expect(screen.getByText('TestOpenAI'))!.toBeInTheDocument() @@ -1145,12 +1203,7 @@ describe('Popup', () => { ] mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' }) - renderPopup( - <PopupHarness - modelList={[]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />) const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/) fireEvent.click(installButtons[0]!) @@ -1167,12 +1220,7 @@ describe('Popup', () => { ] mockInstallMutateAsync.mockRejectedValue(new Error('Install failed')) - renderPopup( - <PopupHarness - modelList={[]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />) const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/) fireEvent.click(installButtons[0]!) @@ -1181,7 +1229,9 @@ describe('Popup', () => { expect(mockInstallMutateAsync).toHaveBeenCalled() }) - expect(screen.getAllByText(/common\.modelProvider\.selector\.install/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/common\.modelProvider\.selector\.install/).length).toBeGreaterThan( + 0, + ) }) it('should run checkTaskStatus when not all_installed', async () => { @@ -1191,12 +1241,7 @@ describe('Popup', () => { mockInstallMutateAsync.mockResolvedValue({ all_installed: false, task_id: 'task-1' }) mockCheck.mockResolvedValue(undefined) - renderPopup( - <PopupHarness - modelList={[]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />) const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/) fireEvent.click(installButtons[0]!) @@ -1216,12 +1261,7 @@ describe('Popup', () => { ] mockMarketplacePlugins.isLoading = true - renderPopup( - <PopupHarness - modelList={[]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />) fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0]!) @@ -1233,12 +1273,7 @@ describe('Popup', () => { it('should skip install requests when the marketplace plugin cannot be found', async () => { mockMarketplacePlugins.current = [] - renderPopup( - <PopupHarness - modelList={[]} - onHide={vi.fn()} - />, - ) + renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />) fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0]!) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx index 107284a1e4ab31..b1051ed19221a3 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx @@ -9,19 +9,13 @@ type FeatureIconProps = { className?: string showFeaturesLabel?: boolean } -function FeatureIcon({ - className, - feature, - showFeaturesLabel, -}: FeatureIconProps) { +function FeatureIcon({ className, feature, showFeaturesLabel }: FeatureIconProps) { const { t } = useTranslation() if (feature === ModelFeatureEnum.vision) { if (showFeaturesLabel) { return ( - <ModelBadge - className={cn('gap-x-0.5', className)} - > + <ModelBadge className={cn('gap-x-0.5', className)}> <span className="i-ri-image-circle-ai-line size-3" aria-hidden="true" /> <span>{ModelFeatureTextEnum.vision}</span> </ModelBadge> @@ -31,21 +25,19 @@ function FeatureIcon({ return ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="inline-block cursor-help"> - <ModelBadge - className={cn( - 'w-4.5 justify-center px-0!', - className, - )} - > + <ModelBadge className={cn('w-4.5 justify-center px-0!', className)}> <span className="i-ri-image-circle-ai-line size-3" aria-hidden="true" /> </ModelBadge> </div> - )} + } /> <TooltipContent> - {t($ => $['modelProvider.featureSupported'], { ns: 'common', feature: ModelFeatureTextEnum.vision })} + {t(($) => $['modelProvider.featureSupported'], { + ns: 'common', + feature: ModelFeatureTextEnum.vision, + })} </TooltipContent> </Tooltip> ) @@ -54,9 +46,7 @@ function FeatureIcon({ if (feature === ModelFeatureEnum.document) { if (showFeaturesLabel) { return ( - <ModelBadge - className={cn('gap-x-0.5', className)} - > + <ModelBadge className={cn('gap-x-0.5', className)}> <span className="i-ri-file-text-line size-3" aria-hidden="true" /> <span>{ModelFeatureTextEnum.document}</span> </ModelBadge> @@ -66,21 +56,19 @@ function FeatureIcon({ return ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="inline-block cursor-help"> - <ModelBadge - className={cn( - 'w-4.5 justify-center px-0!', - className, - )} - > + <ModelBadge className={cn('w-4.5 justify-center px-0!', className)}> <span className="i-ri-file-text-line size-3" aria-hidden="true" /> </ModelBadge> </div> - )} + } /> <TooltipContent> - {t($ => $['modelProvider.featureSupported'], { ns: 'common', feature: ModelFeatureTextEnum.document })} + {t(($) => $['modelProvider.featureSupported'], { + ns: 'common', + feature: ModelFeatureTextEnum.document, + })} </TooltipContent> </Tooltip> ) @@ -89,9 +77,7 @@ function FeatureIcon({ if (feature === ModelFeatureEnum.audio) { if (showFeaturesLabel) { return ( - <ModelBadge - className={cn('gap-x-0.5', className)} - > + <ModelBadge className={cn('gap-x-0.5', className)}> <span className="i-ri-voice-ai-fill size-3" aria-hidden="true" /> <span>{ModelFeatureTextEnum.audio}</span> </ModelBadge> @@ -101,21 +87,19 @@ function FeatureIcon({ return ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="inline-block cursor-help"> - <ModelBadge - className={cn( - 'w-4.5 justify-center px-0!', - className, - )} - > + <ModelBadge className={cn('w-4.5 justify-center px-0!', className)}> <span className="i-ri-voice-ai-fill size-3" aria-hidden="true" /> </ModelBadge> </div> - )} + } /> <TooltipContent> - {t($ => $['modelProvider.featureSupported'], { ns: 'common', feature: ModelFeatureTextEnum.audio })} + {t(($) => $['modelProvider.featureSupported'], { + ns: 'common', + feature: ModelFeatureTextEnum.audio, + })} </TooltipContent> </Tooltip> ) @@ -124,9 +108,7 @@ function FeatureIcon({ if (feature === ModelFeatureEnum.video) { if (showFeaturesLabel) { return ( - <ModelBadge - className={cn('gap-x-0.5', className)} - > + <ModelBadge className={cn('gap-x-0.5', className)}> <span className="i-ri-film-ai-line size-3" aria-hidden="true" /> <span>{ModelFeatureTextEnum.video}</span> </ModelBadge> @@ -136,21 +118,19 @@ function FeatureIcon({ return ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="inline-block cursor-help"> - <ModelBadge - className={cn( - 'w-4.5 justify-center px-0!', - className, - )} - > + <ModelBadge className={cn('w-4.5 justify-center px-0!', className)}> <span className="i-ri-film-ai-line size-3" aria-hidden="true" /> </ModelBadge> </div> - )} + } /> <TooltipContent> - {t($ => $['modelProvider.featureSupported'], { ns: 'common', feature: ModelFeatureTextEnum.video })} + {t(($) => $['modelProvider.featureSupported'], { + ns: 'common', + feature: ModelFeatureTextEnum.video, + })} </TooltipContent> </Tooltip> ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx index ab75816ac6edee..5f4f9ab771c5f5 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx @@ -14,8 +14,7 @@ import { getModelSelectorValueLabel, isSameModelSelectorValue } from './types' const getModelProviderPluginId = (provider: string) => { const [organization, pluginName] = provider.split('/').filter(Boolean) - if (organization && pluginName) - return `${organization}/${pluginName}` + if (organization && pluginName) return `${organization}/${pluginName}` return provider ? `langgenius/${provider}` : '' } @@ -61,16 +60,9 @@ function ModelSelector({ const { t } = useTranslation() const [open, setOpen] = useState(false) const [inputValue, setInputValue] = useState('') - const { - currentProvider, - currentModel, - } = useCurrentProviderAndModel( - modelList, - defaultModel, - ) + const { currentProvider, currentModel } = useCurrentProviderAndModel(modelList, defaultModel) const currentValue = useMemo<ModelSelectorValue | null>(() => { - if (!currentProvider || !currentModel) - return null + if (!currentProvider || !currentModel) return null return { provider: currentProvider.provider, @@ -78,47 +70,53 @@ function ModelSelector({ } }, [currentModel, currentProvider]) - const handleOpenChange = useCallback((newOpen: boolean) => { - if (readonly) - return + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (readonly) return - setOpen(newOpen) - if (!newOpen) - setInputValue('') - }, [readonly]) + setOpen(newOpen) + if (!newOpen) setInputValue('') + }, + [readonly], + ) - const handleSelect = useCallback((provider: string, model: ModelItem) => { - setOpen(false) - setInputValue('') + const handleSelect = useCallback( + (provider: string, model: ModelItem) => { + setOpen(false) + setInputValue('') - if (onSelect) { - onSelect({ - provider, - model: model.model, - plugin_id: getModelProviderPluginId(provider), - }) - } - }, [onSelect]) + if (onSelect) { + onSelect({ + provider, + model: model.model, + plugin_id: getModelProviderPluginId(provider), + }) + } + }, + [onSelect], + ) - const handleValueChange = useCallback((value: ModelSelectorValue | null) => { - if (!value) - return + const handleValueChange = useCallback( + (value: ModelSelectorValue | null) => { + if (!value) return - const provider = modelList.find(model => model.provider === value.provider) - const model = provider?.models.find(model => model.model === value.model) + const provider = modelList.find((model) => model.provider === value.provider) + const model = provider?.models.find((model) => model.model === value.model) - if (!provider || !model) - return - if (model.status !== ModelStatusEnum.active) - return + if (!provider || !model) return + if (model.status !== ModelStatusEnum.active) return - handleSelect(provider.provider, model) - }, [handleSelect, modelList]) + handleSelect(provider.provider, model) + }, + [handleSelect, modelList], + ) - const handleInputValueChange = useCallback((inputValue: string, details: ComboboxChangeEventDetails) => { - if (details.reason !== 'item-press') - setInputValue(inputValue) - }, []) + const handleInputValueChange = useCallback( + (inputValue: string, details: ComboboxChangeEventDetails) => { + if (details.reason !== 'item-press') setInputValue(inputValue) + }, + [], + ) const handleHide = useCallback(() => { setOpen(false) @@ -144,7 +142,7 @@ function ModelSelector({ onValueChange={handleValueChange} > <ComboboxTrigger - aria-label={t($ => $['detailPanel.configureModel'], { ns: 'plugin' })} + aria-label={t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })} icon={false} className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-popup-open:bg-transparent" disabled={readonly} @@ -159,7 +157,11 @@ function ModelSelector({ deprecatedClassName={deprecatedClassName} showDeprecatedWarnIcon={showDeprecatedWarnIcon} showModelMeta={showModelMeta} - isModelCompatible={currentProvider && currentModel ? modelPredicate?.(currentProvider, currentModel) : undefined} + isModelCompatible={ + currentProvider && currentModel + ? modelPredicate?.(currentProvider, currentModel) + : undefined + } /> </ComboboxTrigger> <ComboboxContent diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx index dacf6208f6b3db..e4eb048b2ba690 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx @@ -31,8 +31,7 @@ function MarketplaceSection({ }: MarketplaceSectionProps) { const { t } = useTranslation() - if (marketplaceProviders.length === 0) - return null + if (marketplaceProviders.length === 0) return null return ( <> @@ -46,8 +45,13 @@ function MarketplaceSection({ className="flex flex-1 cursor-pointer items-center border-0 bg-transparent p-0 text-left system-sm-medium text-text-primary" onClick={() => onMarketplaceCollapsedChange(!marketplaceCollapsed)} > - {t($ => $['modelProvider.selector.fromMarketplace'], { ns: 'common' })} - <span className={cn('i-custom-vender-solid-general-arrow-down-round-fill size-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} /> + {t(($) => $['modelProvider.selector.fromMarketplace'], { ns: 'common' })} + <span + className={cn( + 'i-custom-vender-solid-general-arrow-down-round-fill size-4 text-text-quaternary', + marketplaceCollapsed && '-rotate-90', + )} + /> </button> </div> {!marketplaceCollapsed && ( @@ -62,7 +66,9 @@ function MarketplaceSection({ > <div className="flex flex-1 items-center gap-2 py-0.5"> <Icon className="size-5 shrink-0 rounded-md" /> - <span className="system-sm-regular text-text-secondary">{modelNameMap[key]}</span> + <span className="system-sm-regular text-text-secondary"> + {modelNameMap[key]} + </span> </div> {canInstallPlugin && ( <Button @@ -75,41 +81,51 @@ function MarketplaceSection({ disabled={isInstalling || isMarketplacePluginsLoading} onClick={() => onInstallPlugin(key)} > - {isInstalling && <span className="i-ri-loader-2-line size-3.5 animate-spin" />} + {isInstalling && ( + <span className="i-ri-loader-2-line size-3.5 animate-spin" /> + )} {isInstalling - ? t($ => $['installModal.installing'], { ns: 'plugin' }) - : t($ => $['modelProvider.selector.install'], { ns: 'common' })} + ? t(($) => $['installModal.installing'], { ns: 'plugin' }) + : t(($) => $['modelProvider.selector.install'], { ns: 'common' })} </Button> )} </div> ) })} - {onOpenMarketplace - ? ( - <button - type="button" - className="flex w-full cursor-pointer items-center gap-0.5 border-0 bg-transparent px-3 py-1.5 text-left" - onClick={onOpenMarketplace} - > - <span className="flex-1 system-xs-regular text-text-accent"> - {t($ => $['modelProvider.selector.discoverMoreInMarketplace'], { ns: 'common' })} - </span> - <span className="i-ri-arrow-right-up-line size-3! text-text-accent" aria-hidden="true" /> - </button> - ) - : ( - <a - className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5" - href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })} - target="_blank" - rel="noopener noreferrer" - > - <span className="flex-1 system-xs-regular text-text-accent"> - {t($ => $['modelProvider.selector.discoverMoreInMarketplace'], { ns: 'common' })} - </span> - <span className="i-ri-arrow-right-up-line size-3! text-text-accent" aria-hidden="true" /> - </a> - )} + {onOpenMarketplace ? ( + <button + type="button" + className="flex w-full cursor-pointer items-center gap-0.5 border-0 bg-transparent px-3 py-1.5 text-left" + onClick={onOpenMarketplace} + > + <span className="flex-1 system-xs-regular text-text-accent"> + {t(($) => $['modelProvider.selector.discoverMoreInMarketplace'], { + ns: 'common', + })} + </span> + <span + className="i-ri-arrow-right-up-line size-3! text-text-accent" + aria-hidden="true" + /> + </button> + ) : ( + <a + className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5" + href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })} + target="_blank" + rel="noopener noreferrer" + > + <span className="flex-1 system-xs-regular text-text-accent"> + {t(($) => $['modelProvider.selector.discoverMoreInMarketplace'], { + ns: 'common', + })} + </span> + <span + className="i-ri-arrow-right-up-line size-3! text-text-accent" + aria-hidden="true" + /> + </a> + )} </div> )} </div> diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts b/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts index 4c3a1138d3b4fa..46f184874613b5 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts @@ -51,53 +51,45 @@ const modelSearchOptions = { ignoreDiacritics: true, shouldSort: false, useExtendedSearch: true, - keys: [ - 'normalizedLabels', - ], + keys: ['normalizedLabels'], } -const normalizeModelSearchValue = (value: string) => ( +const normalizeModelSearchValue = (value: string) => value .toLowerCase() .normalize('NFKD') .replace(/[^\p{Letter}\p{Number}]+/gu, '') -) const looksLikeModelQuery = (value: string) => /\d/.test(value) const getLabelSearchValues = (label: TypeWithI18N, language: string) => { - if (label[language] !== undefined) - return [label[language]] + if (label[language] !== undefined) return [label[language]] return Array.from(new Set(Object.values(label))) } const getProviderKeySearchValues = (provider: string) => { - const keys = provider - .split('/') - .filter(part => part && part !== 'langgenius') - - return Array.from(new Set([ - ...keys, - ...keys.map(normalizeModelSearchValue), - ])) + const keys = provider.split('/').filter((part) => part && part !== 'langgenius') + + return Array.from(new Set([...keys, ...keys.map(normalizeModelSearchValue)])) } const createModelSearchKey = (provider: string, model: string) => `${provider}/${model}` const modelSupportsScopeFeatures = (modelItem: ModelItem, scopeFeatures: ModelFeatureEnum[]) => { - if (scopeFeatures.length === 0) - return true + if (scopeFeatures.length === 0) return true return scopeFeatures.every((feature) => { - if (feature === ModelFeatureEnum.toolCall) - return supportFunctionCall(modelItem.features) + if (feature === ModelFeatureEnum.toolCall) return supportFunctionCall(modelItem.features) return modelItem.features?.includes(feature) ?? false }) } -export const createModelSelectorSearchIndex = (installedModelList: Model[], language: string): ModelSelectorSearchIndex => { +export const createModelSelectorSearchIndex = ( + installedModelList: Model[], + language: string, +): ModelSelectorSearchIndex => { const providerEntries = installedModelList.map<ProviderSearchEntry>((model) => { return { provider: model.provider, @@ -105,17 +97,16 @@ export const createModelSelectorSearchIndex = (installedModelList: Model[], lang providerKeys: getProviderKeySearchValues(model.provider), } }) - const modelEntries = installedModelList.flatMap<ModelSearchEntry>(model => + const modelEntries = installedModelList.flatMap<ModelSearchEntry>((model) => model.models.map((modelItem) => { const labels = getLabelSearchValues(modelItem.label, language) return { provider: model.provider, model: modelItem.model, - normalizedLabels: Array.from(new Set([ - modelItem.model, - ...labels, - ].map(normalizeModelSearchValue))), + normalizedLabels: Array.from( + new Set([modelItem.model, ...labels].map(normalizeModelSearchValue)), + ), } }), ) @@ -126,8 +117,7 @@ export const createModelSelectorSearchIndex = (installedModelList: Model[], lang search: (query) => { const trimmedQuery = query.trim() - if (!trimmedQuery) - return { providers: new Set(), models: new Set() } + if (!trimmedQuery) return { providers: new Set(), models: new Set() } const normalizedQuery = normalizeModelSearchValue(trimmedQuery) const providerMatches = looksLikeModelQuery(trimmedQuery) @@ -163,27 +153,30 @@ export const filterModelSelectorModels = ({ ? searchIndex.search(trimmedInputValue) : { providers: new Set<string>(), models: new Set<string>() } - const filtered = installedModelList.map((model) => { - const providerMatched = matches.providers.has(model.provider) - const filteredModels = model.models - .filter((modelItem) => { - if (!trimmedInputValue || providerMatched) - return true - - return matches.models.has(createModelSearchKey(model.provider, modelItem.model)) - }) - .filter(modelItem => modelSupportsScopeFeatures(modelItem, scopeFeatures)) - .filter(modelItem => modelPredicate?.(model, modelItem) ?? true) - - if ( - (trimmedInputValue && filteredModels.length === 0) - || (!trimmedInputValue && filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider)) - ) { - return null - } + const filtered = installedModelList + .map((model) => { + const providerMatched = matches.providers.has(model.provider) + const filteredModels = model.models + .filter((modelItem) => { + if (!trimmedInputValue || providerMatched) return true + + return matches.models.has(createModelSearchKey(model.provider, modelItem.model)) + }) + .filter((modelItem) => modelSupportsScopeFeatures(modelItem, scopeFeatures)) + .filter((modelItem) => modelPredicate?.(model, modelItem) ?? true) + + if ( + (trimmedInputValue && filteredModels.length === 0) || + (!trimmedInputValue && + filteredModels.length === 0 && + !aiCreditVisibleProviders.has(model.provider)) + ) { + return null + } - return { ...model, models: filteredModels } - }).filter((model): model is Model => model !== null) + return { ...model, models: filteredModels } + }) + .filter((model): model is Model => model !== null) if (defaultModel?.provider) { filtered.sort((a, b) => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx index a3cb9d95d2dab3..2fe1494c55e6ba 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx @@ -3,7 +3,11 @@ import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' import { useProviderContext } from '@/context/provider-context' -import { DERIVED_MODEL_STATUS_BADGE_I18N, DERIVED_MODEL_STATUS_TOOLTIP_I18N, deriveModelStatus } from '../derive-model-status' +import { + DERIVED_MODEL_STATUS_BADGE_I18N, + DERIVED_MODEL_STATUS_TOOLTIP_I18N, + deriveModelStatus, +} from '../derive-model-status' import ModelIcon from '../model-icon' import ModelName from '../model-name' import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state' @@ -40,10 +44,10 @@ function ModelSelectorTrigger({ const isDeprecated = !isSelected && !!defaultModel const isEmpty = !isSelected && !defaultModel const selectedProvider = isSelected - ? modelProviders.find(provider => provider.provider === currentProvider.provider) + ? modelProviders.find((provider) => provider.provider === currentProvider.provider) : undefined const deprecatedProvider = isDeprecated - ? modelProviders.find(p => p.provider === defaultModel.provider) + ? modelProviders.find((p) => p.provider === defaultModel.provider) : undefined const resolvedProvider = isSelected ? selectedProvider : deprecatedProvider const selectedProviderState = useCredentialPanelState(resolvedProvider) @@ -58,48 +62,55 @@ function ModelSelectorTrigger({ const isActive = status === 'active' const isDisabled = status !== 'active' && status !== 'empty' - const statusI18nKey = DERIVED_MODEL_STATUS_BADGE_I18N[status as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N] - const tooltipI18nKey = DERIVED_MODEL_STATUS_TOOLTIP_I18N[status as keyof typeof DERIVED_MODEL_STATUS_TOOLTIP_I18N] - const statusLabel = isModelCompatible && statusI18nKey - ? t($ => $[statusI18nKey], { ns: 'common' }) - : t($ => $['modelProvider.selector.incompatible'], { ns: 'common' }) - const tooltipLabel = isModelCompatible && tooltipI18nKey - ? t($ => $[tooltipI18nKey], { ns: 'common' }) - : t($ => $['modelProvider.selector.incompatibleTip'], { ns: 'common' }) + const statusI18nKey = + DERIVED_MODEL_STATUS_BADGE_I18N[status as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N] + const tooltipI18nKey = + DERIVED_MODEL_STATUS_TOOLTIP_I18N[status as keyof typeof DERIVED_MODEL_STATUS_TOOLTIP_I18N] + const statusLabel = + isModelCompatible && statusI18nKey + ? t(($) => $[statusI18nKey], { ns: 'common' }) + : t(($) => $['modelProvider.selector.incompatible'], { ns: 'common' }) + const tooltipLabel = + isModelCompatible && tooltipI18nKey + ? t(($) => $[tooltipI18nKey], { ns: 'common' }) + : t(($) => $['modelProvider.selector.incompatibleTip'], { ns: 'common' }) const isCreditsExhausted = status === 'credits-exhausted' const shouldShowModelMeta = showModelMeta && status === 'active' && isModelCompatible - const deprecatedStatusLabel = statusLabel || t($ => $['modelProvider.selector.incompatible'], { ns: 'common' }) - const deprecatedTooltipLabel = tooltipLabel || t($ => $['modelProvider.selector.incompatibleTip'], { ns: 'common' }) + const deprecatedStatusLabel = + statusLabel || t(($) => $['modelProvider.selector.incompatible'], { ns: 'common' }) + const deprecatedTooltipLabel = + tooltipLabel || t(($) => $['modelProvider.selector.incompatibleTip'], { ns: 'common' }) return ( <div className={cn( 'group flex h-8 items-center gap-0.5 rounded-lg p-1', - isDisabled - ? 'bg-components-input-bg-disabled' - : 'bg-components-input-bg-normal', + isDisabled ? 'bg-components-input-bg-disabled' : 'bg-components-input-bg-normal', !readonly && !isDisabled && 'cursor-pointer hover:bg-components-input-bg-hover', open && !isDisabled && 'bg-components-input-bg-hover', className, )} > - {isEmpty - ? ( - <div className="flex size-6 items-center justify-center"> - <div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle"> - <span className="i-ri-brain-2-line size-3.5 text-text-quaternary" /> - </div> - </div> - ) - : ( - <ModelIcon - className="p-0.5" - provider={isSelected ? currentProvider : deprecatedProvider} - modelName={isSelected ? currentModel.model : defaultModel?.model} - /> - )} + {isEmpty ? ( + <div className="flex size-6 items-center justify-center"> + <div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle"> + <span className="i-ri-brain-2-line size-3.5 text-text-quaternary" /> + </div> + </div> + ) : ( + <ModelIcon + className="p-0.5" + provider={isSelected ? currentProvider : deprecatedProvider} + modelName={isSelected ? currentModel.model : defaultModel?.model} + /> + )} - <div className={cn('flex grow items-center gap-1 truncate px-1 py-0.75', isDeprecated && deprecatedClassName)}> + <div + className={cn( + 'flex grow items-center gap-1 truncate px-1 py-0.75', + isDeprecated && deprecatedClassName, + )} + > {isSelected && ( <ModelName className="grow" @@ -116,7 +127,7 @@ function ModelSelectorTrigger({ )} {isEmpty && ( <div className="grow truncate text-[13px] text-components-input-text-placeholder"> - {t($ => $['detailPanel.configureModel'], { ns: 'plugin' })} + {t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })} </div> )} @@ -124,7 +135,7 @@ function ModelSelectorTrigger({ <Tooltip> <TooltipTrigger disabled={!tooltipLabel} - render={( + render={ <div className={cn( 'flex shrink-0 items-center gap-0.75 rounded-md border border-text-warning px-1.25 py-0.5', @@ -136,31 +147,25 @@ function ModelSelectorTrigger({ {statusLabel} </span> </div> - )} + } /> - {tooltipLabel && ( - <TooltipContent placement="top"> - {tooltipLabel} - </TooltipContent> - )} + {tooltipLabel && <TooltipContent placement="top">{tooltipLabel}</TooltipContent>} </Tooltip> )} {isDeprecated && showDeprecatedWarnIcon && ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="flex shrink-0 items-center gap-0.75 rounded-md border border-text-warning bg-components-badge-bg-dimm px-1.25 py-0.5"> <span className="i-ri-alert-fill size-3 text-text-warning" /> <span className="system-xs-medium whitespace-nowrap text-text-warning"> {deprecatedStatusLabel} </span> </div> - )} + } /> - <TooltipContent placement="top"> - {deprecatedTooltipLabel} - </TooltipContent> + <TooltipContent placement="top">{deprecatedTooltipLabel}</TooltipContent> </Tooltip> )} diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx index 52858c83ea7c68..6a390a8d4b06ec 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx @@ -5,9 +5,7 @@ type ModelSelectorEmptyStateProps = { onConfigure: () => void } -function ModelSelectorEmptyState({ - onConfigure, -}: ModelSelectorEmptyStateProps) { +function ModelSelectorEmptyState({ onConfigure }: ModelSelectorEmptyStateProps) { const { t } = useTranslation() return ( @@ -17,18 +15,14 @@ function ModelSelectorEmptyState({ </div> <div className="flex flex-col gap-1"> <p className="system-sm-medium text-text-secondary"> - {t($ => $['modelProvider.selector.noProviderConfigured'], { ns: 'common' })} + {t(($) => $['modelProvider.selector.noProviderConfigured'], { ns: 'common' })} </p> <p className="system-xs-regular text-text-tertiary"> - {t($ => $['modelProvider.selector.noProviderConfiguredDesc'], { ns: 'common' })} + {t(($) => $['modelProvider.selector.noProviderConfiguredDesc'], { ns: 'common' })} </p> </div> - <Button - variant="primary" - className="w-[108px]" - onClick={onConfigure} - > - {t($ => $['modelProvider.selector.configure'], { ns: 'common' })} + <Button variant="primary" className="w-[108px]" onClick={onConfigure}> + {t(($) => $['modelProvider.selector.configure'], { ns: 'common' })} <span className="i-ri-arrow-right-line size-4" /> </Button> </div> diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx index ed16ffa399c4ad..6225be6b1a5567 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx @@ -50,20 +50,18 @@ function PopupItem({ const [dropdownOpen, setDropdownOpen] = useState(false) const { t } = useTranslation() const language = useLanguage() - const suggestionTip = t($ => $['modelProvider.selector.suggestionTip'], { ns: 'common' }) + const suggestionTip = t(($) => $['modelProvider.selector.suggestionTip'], { ns: 'common' }) const { setShowModelModal } = useModalContext() const { modelProviders } = useProviderContext() const updateModelList = useUpdateModelList() const updateModelProviders = useUpdateModelProviders() - const currentProvider = modelProviders.find(provider => provider.provider === model.provider) + const currentProvider = modelProviders.find((provider) => provider.provider === model.provider) const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() const canOpenCredentialDropdown = canUseCredential || canCreateCredential || canManageCredential const handleOpenModelModal = () => { - if (!canCreateCredential) - return + if (!canCreateCredential) return - if (!currentProvider) - return + if (!currentProvider) return setShowModelModal({ payload: { currentProvider, @@ -74,20 +72,23 @@ function PopupItem({ const modelType = model.models[0]!.model_type - if (modelType) - updateModelList(modelType) + if (modelType) updateModelList(modelType) }, }) } const state = useCredentialPanelState(currentProvider) const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider) - const groupItems = useMemo(() => model.models - .filter(modelItem => modelItem.status !== ModelStatusEnum.noConfigure) - .map(modelItem => ({ - provider: model.provider, - model: modelItem.model, - })), [model.models, model.provider]) + const groupItems = useMemo( + () => + model.models + .filter((modelItem) => modelItem.status !== ModelStatusEnum.noConfigure) + .map((modelItem) => ({ + provider: model.provider, + model: modelItem.model, + })), + [model.models, model.provider], + ) const isUsingCredits = state.priority === 'credits' const hasCredits = !state.isCreditsExhausted @@ -99,8 +100,7 @@ function PopupItem({ onHide() }, [onHide]) - if (!currentProvider) - return null + if (!currentProvider) return null return ( <ComboboxGroup className="mb-1" items={groupItems}> @@ -108,48 +108,58 @@ function PopupItem({ <button type="button" className="flex min-w-0 cursor-pointer items-center border-0 bg-transparent p-0 text-left" - onClick={() => setCollapsed(prev => !prev)} + onClick={() => setCollapsed((prev) => !prev)} > <span className="truncate">{model.label[language] || model.label.en_US}</span> - <span className={cn('i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 text-text-quaternary', collapsed && '-rotate-90')} /> + <span + className={cn( + 'i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 text-text-quaternary', + collapsed && '-rotate-90', + )} + /> </button> <Popover open={dropdownOpen} onOpenChange={setDropdownOpen}> <PopoverTrigger disabled={!canOpenCredentialDropdown} - render={( - <button type="button" className="flex max-w-[50%] min-w-0 shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary hover:bg-components-button-ghost-bg-hover"> - {isUsingCredits - ? ( - hasCredits - ? ( - <> - <CreditsCoin className="size-3" /> - <span className="ml-1 truncate">{t($ => $['modelProvider.selector.aiCredits'], { ns: 'common' })}</span> - </> - ) - : ( - <> - <span className="i-ri-alert-fill size-3 shrink-0 text-text-warning-secondary" /> - <span className="ml-1 truncate text-text-warning">{t($ => $['modelProvider.selector.creditsExhausted'], { ns: 'common' })}</span> - </> - ) - ) - : credentialName - ? ( - <> - <StatusDot size="small" status={isApiKeyActive ? 'success' : 'error'} /> - <span className="ml-1 truncate text-text-tertiary">{credentialName}</span> - </> - ) - : ( - <> - <StatusDot size="small" status="disabled" /> - <span className="ml-1 truncate text-text-tertiary">{t($ => $['modelProvider.selector.configureRequired'], { ns: 'common' })}</span> - </> - )} - {canOpenCredentialDropdown && <span className="i-ri-arrow-down-s-line size-3.5! shrink-0 translate-y-px text-text-tertiary" />} + render={ + <button + type="button" + className="flex max-w-[50%] min-w-0 shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary hover:bg-components-button-ghost-bg-hover" + > + {isUsingCredits ? ( + hasCredits ? ( + <> + <CreditsCoin className="size-3" /> + <span className="ml-1 truncate"> + {t(($) => $['modelProvider.selector.aiCredits'], { ns: 'common' })} + </span> + </> + ) : ( + <> + <span className="i-ri-alert-fill size-3 shrink-0 text-text-warning-secondary" /> + <span className="ml-1 truncate text-text-warning"> + {t(($) => $['modelProvider.selector.creditsExhausted'], { ns: 'common' })} + </span> + </> + ) + ) : credentialName ? ( + <> + <StatusDot size="small" status={isApiKeyActive ? 'success' : 'error'} /> + <span className="ml-1 truncate text-text-tertiary">{credentialName}</span> + </> + ) : ( + <> + <StatusDot size="small" status="disabled" /> + <span className="ml-1 truncate text-text-tertiary"> + {t(($) => $['modelProvider.selector.configureRequired'], { ns: 'common' })} + </span> + </> + )} + {canOpenCredentialDropdown && ( + <span className="i-ri-arrow-down-s-line size-3.5! shrink-0 translate-y-px text-text-tertiary" /> + )} </button> - )} + } /> <PopoverContent placement="bottom-end"> <DropdownContent @@ -162,63 +172,62 @@ function PopupItem({ </PopoverContent> </Popover> </div> - {!collapsed && model.models.map((modelItem) => { - const isModelCompatible = modelPredicate?.(model, modelItem) ?? true - const isModelSuggested = modelSuggestionPredicate?.(model, modelItem) ?? false - const rowClassName = cn( - 'group relative mx-1 flex h-8 min-w-0 items-center gap-1 rounded-lg px-3 py-1.5 text-left', - modelItem.status === ModelStatusEnum.active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed hover:bg-state-base-hover-alt', - ) - const rowContent = ( - <> - <div className="flex min-w-0 items-center gap-2"> - <ModelIcon - className={cn('size-5 shrink-0')} - provider={model} - modelName={modelItem.model} - /> - <ModelName - className={cn( - 'system-sm-medium text-text-secondary', - !isModelCompatible && 'text-text-quaternary', - modelItem.status !== ModelStatusEnum.active && 'opacity-60', - )} - modelItem={modelItem} - nameClassName={modelItem.deprecated ? 'line-through' : undefined} - > - {isModelSuggested && ( - <Tooltip> - <TooltipTrigger - render={( - <span - aria-label={suggestionTip} - className="i-ri-shield-star-line size-3.5 shrink-0 text-text-accent-secondary" - /> - )} + {!collapsed && + model.models.map((modelItem) => { + const isModelCompatible = modelPredicate?.(model, modelItem) ?? true + const isModelSuggested = modelSuggestionPredicate?.(model, modelItem) ?? false + const rowClassName = cn( + 'group relative mx-1 flex h-8 min-w-0 items-center gap-1 rounded-lg px-3 py-1.5 text-left', + modelItem.status === ModelStatusEnum.active + ? 'cursor-pointer hover:bg-state-base-hover' + : 'cursor-not-allowed hover:bg-state-base-hover-alt', + ) + const rowContent = ( + <> + <div className="flex min-w-0 items-center gap-2"> + <ModelIcon + className={cn('size-5 shrink-0')} + provider={model} + modelName={modelItem.model} + /> + <ModelName + className={cn( + 'system-sm-medium text-text-secondary', + !isModelCompatible && 'text-text-quaternary', + modelItem.status !== ModelStatusEnum.active && 'opacity-60', + )} + modelItem={modelItem} + nameClassName={modelItem.deprecated ? 'line-through' : undefined} + > + {isModelSuggested && ( + <Tooltip> + <TooltipTrigger + render={ + <span + aria-label={suggestionTip} + className="i-ri-shield-star-line size-3.5 shrink-0 text-text-accent-secondary" + /> + } + /> + <TooltipContent placement="top">{suggestionTip}</TooltipContent> + </Tooltip> + )} + </ModelName> + </div> + {defaultModel?.model === modelItem.model && + defaultModel.provider === currentProvider.provider && ( + <ComboboxItemIndicator className="shrink-0 text-text-accent"> + <span + className="i-custom-vender-line-general-check size-4" + aria-hidden="true" /> - <TooltipContent placement="top"> - {suggestionTip} - </TooltipContent> - </Tooltip> + </ComboboxItemIndicator> )} - </ModelName> - </div> - { - defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && ( - <ComboboxItemIndicator className="shrink-0 text-text-accent"> - <span className="i-custom-vender-line-general-check size-4" aria-hidden="true" /> - </ComboboxItemIndicator> - ) - } - </> - ) - const itemRender = modelItem.status === ModelStatusEnum.noConfigure - ? ( - <div - className={rowClassName} - aria-disabled="true" - onPointerDown={onPreviewCardClose} - > + </> + ) + const itemRender = + modelItem.status === ModelStatusEnum.noConfigure ? ( + <div className={rowClassName} aria-disabled="true" onPointerDown={onPreviewCardClose}> {rowContent} {canCreateCredential && ( <button @@ -226,12 +235,11 @@ function PopupItem({ className="hidden cursor-pointer text-xs font-medium text-text-accent group-hover:block" onClick={handleOpenModelModal} > - {t($ => $['operation.add'], { ns: 'common' }).toLocaleUpperCase()} + {t(($) => $['operation.add'], { ns: 'common' }).toLocaleUpperCase()} </button> )} </div> - ) - : ( + ) : ( <ComboboxItem value={{ provider: model.provider, @@ -245,17 +253,17 @@ function PopupItem({ </ComboboxItem> ) - return ( - <PreviewCardTrigger - key={modelItem.model} - delay={150} - closeDelay={150} - handle={previewCardHandle} - payload={{ provider: model, modelItem }} - render={itemRender} - /> - ) - })} + return ( + <PreviewCardTrigger + key={modelItem.model} + delay={150} + closeDelay={150} + handle={previewCardHandle} + payload={{ provider: model, modelItem }} + render={itemRender} + /> + ) + })} </ComboboxGroup> ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx index 3af754af12b2dd..c27191144a29cd 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx @@ -14,9 +14,7 @@ type ModelSelectorPopupFrameProps = { children: ReactNode } -export function ModelSelectorPopupFrame({ - children, -}: ModelSelectorPopupFrameProps) { +export function ModelSelectorPopupFrame({ children }: ModelSelectorPopupFrameProps) { return ( <div className="flex max-h-[min(624px,var(--available-height,624px))] flex-col overflow-hidden rounded-xl bg-components-panel-bg"> {children} @@ -46,30 +44,25 @@ export function ModelSelectorSearchHeader({ )} > <span - className={` - mr-0.5 i-ri-search-line size-4 shrink-0 - ${inputValue ? 'text-text-tertiary' : 'text-text-quaternary'} - `} + className={`mr-0.5 i-ri-search-line size-4 shrink-0 ${inputValue ? 'text-text-tertiary' : 'text-text-quaternary'} `} aria-hidden="true" /> <ComboboxInput - aria-label={t($ => $['form.searchModel'], { ns: 'datasetSettings' }) || ''} + aria-label={t(($) => $['form.searchModel'], { ns: 'datasetSettings' }) || ''} className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary" - placeholder={t($ => $['form.searchModel'], { ns: 'datasetSettings' }) || ''} + placeholder={t(($) => $['form.searchModel'], { ns: 'datasetSettings' }) || ''} /> - { - inputValue && ( - <button - type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' }) || 'Clear'} - className="ml-1.5 flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-none text-text-quaternary outline-hidden hover:bg-transparent hover:text-text-quaternary focus-visible:bg-transparent focus-visible:ring-1 focus-visible:ring-components-input-border-active" - onClick={() => onInputValueChange('')} - onPointerDown={event => event.preventDefault()} - > - <span className="i-custom-vender-solid-general-x-circle size-3.5" aria-hidden="true" /> - </button> - ) - } + {inputValue && ( + <button + type="button" + aria-label={t(($) => $['operation.clear'], { ns: 'common' }) || 'Clear'} + className="ml-1.5 flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-none text-text-quaternary outline-hidden hover:bg-transparent hover:text-text-quaternary focus-visible:bg-transparent focus-visible:ring-1 focus-visible:ring-components-input-border-active" + onClick={() => onInputValueChange('')} + onPointerDown={(event) => event.preventDefault()} + > + <span className="i-custom-vender-solid-general-x-circle size-3.5" aria-hidden="true" /> + </button> + )} </ComboboxInputGroup> </div> ) @@ -80,10 +73,7 @@ type ModelSelectorScrollBodyProps = { label: string } -export function ModelSelectorScrollBody({ - children, - label, -}: ModelSelectorScrollBodyProps) { +export function ModelSelectorScrollBody({ children, label }: ModelSelectorScrollBodyProps) { return ( <ScrollAreaRoot className="relative min-h-0 overflow-hidden overscroll-contain"> <ScrollAreaViewport @@ -108,7 +98,7 @@ export function CompatibleModelsNotice() { data-testid="compatible-models-banner" className="px-4 py-2 system-xs-regular text-text-tertiary" > - {t($ => $['modelProvider.selector.onlyCompatibleModelsShown'], { ns: 'common' })} + {t(($) => $['modelProvider.selector.onlyCompatibleModelsShown'], { ns: 'common' })} </div> ) } @@ -132,8 +122,8 @@ export function ShowIncompatibleModelsButton({ > <span className="min-w-0 truncate"> {showIncompatibleModels - ? t($ => $['modelProvider.selector.hideIncompatibleModels'], { ns: 'common' }) - : t($ => $['modelProvider.selector.showIncompatibleModels'], { ns: 'common' })} + ? t(($) => $['modelProvider.selector.hideIncompatibleModels'], { ns: 'common' }) + : t(($) => $['modelProvider.selector.showIncompatibleModels'], { ns: 'common' })} </span> </button> ) @@ -143,9 +133,7 @@ type ModelProviderSettingsFooterProps = { onOpenSettings: () => void } -export function ModelProviderSettingsFooter({ - onOpenSettings, -}: ModelProviderSettingsFooterProps) { +export function ModelProviderSettingsFooter({ onOpenSettings }: ModelProviderSettingsFooterProps) { const { t } = useTranslation() return ( @@ -156,7 +144,9 @@ export function ModelProviderSettingsFooter({ onClick={onOpenSettings} > <span className="i-ri-equalizer-2-line size-4 shrink-0" /> - <span className="system-xs-medium">{t($ => $['modelProvider.selector.modelProviderSettings'], { ns: 'common' })}</span> + <span className="system-xs-medium"> + {t(($) => $['modelProvider.selector.modelProviderSettings'], { ns: 'common' })} + </span> </button> </div> ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx index f3f659032b6678..463af087248f8c 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx @@ -3,12 +3,19 @@ import type { ModelSelectorPreviewPayload } from './popup-item' import type { ModelSelectorModelPredicate } from './types' import type { ModelProviderQuotaGetPaid } from '@/types/model-provider' import { ComboboxList } from '@langgenius/dify-ui/combobox' -import { createPreviewCardHandle, PreviewCard, PreviewCardContent } from '@langgenius/dify-ui/preview-card' +import { + createPreviewCardHandle, + PreviewCard, + PreviewCardContent, +} from '@langgenius/dify-ui/preview-card' import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useTheme } from 'next-themes' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ACCOUNT_SETTING_MODAL_ACTION, ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' +import { + ACCOUNT_SETTING_MODAL_ACTION, + ACCOUNT_SETTING_TAB, +} from '@/app/components/header/account-setting/constants' import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting' import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' @@ -19,20 +26,37 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useSearchParams } from '@/next/navigation' import { consoleQuery } from '@/service/client' import { useInstallPackageFromMarketPlace } from '@/service/use-plugins' -import { CustomConfigurationStatusEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '../declarations' +import { + CustomConfigurationStatusEnum, + ModelFeatureEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '../declarations' import { useLanguage, useMarketplaceAllPlugins } from '../hooks' import ModelBadge from '../model-badge' import ModelIcon from '../model-icon' import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert' import { useTrialCredits } from '../provider-added-card/use-trial-credits' import { providerSupportsCredits } from '../supports-credits' -import { MODEL_PROVIDER_QUOTA_GET_PAID, modelTypeFormat, providerKeyToPluginId, sizeFormat } from '../utils' +import { + MODEL_PROVIDER_QUOTA_GET_PAID, + modelTypeFormat, + providerKeyToPluginId, + sizeFormat, +} from '../utils' import FeatureIcon from './feature-icon' import MarketplaceSection from './marketplace-section' import { createModelSelectorSearchIndex, filterModelSelectorModels } from './model-search' import ModelSelectorEmptyState from './popup-empty-state' import PopupItem from './popup-item' -import { CompatibleModelsNotice, ModelProviderSettingsFooter, ModelSelectorPopupFrame, ModelSelectorScrollBody, ModelSelectorSearchHeader, ShowIncompatibleModelsButton } from './popup-layout' +import { + CompatibleModelsNotice, + ModelProviderSettingsFooter, + ModelSelectorPopupFrame, + ModelSelectorScrollBody, + ModelSelectorSearchHeader, + ShowIncompatibleModelsButton, +} from './popup-layout' export type PopupProps = { defaultModel?: DefaultModel @@ -66,98 +90,125 @@ function Popup({ const searchParams = useSearchParams() const { theme } = useTheme() const language = useLanguage() - const previewCardHandle = useMemo(() => createPreviewCardHandle<ModelSelectorPreviewPayload>(), []) + const previewCardHandle = useMemo( + () => createPreviewCardHandle<ModelSelectorPreviewPayload>(), + [], + ) const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false) const [showIncompatibleModels, setShowIncompatibleModels] = useState(false) const openIntegrationsSetting = useIntegrationsSetting() const { modelProviders } = useProviderContext() const { data: enableMarketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: systemFeatures => systemFeatures.enable_marketplace, + select: (systemFeatures) => systemFeatures.enable_marketplace, }) - const { - plugins: allPlugins, - isLoading: isMarketplacePluginsLoading, - } = useMarketplaceAllPlugins(modelProviders, '', enableMarketplace) + const { plugins: allPlugins, isLoading: isMarketplacePluginsLoading } = useMarketplaceAllPlugins( + modelProviders, + '', + enableMarketplace, + ) const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace() const { refreshPluginList } = useRefreshPluginList() const { canInstallPlugin } = useWorkspacePluginInstallPermission() - const [installingProvider, setInstallingProvider] = useState<ModelProviderQuotaGetPaid | null>(null) + const [installingProvider, setInstallingProvider] = useState<ModelProviderQuotaGetPaid | null>( + null, + ) const { isExhausted: isCreditsExhausted } = useTrialCredits() - const { data: trialModels = [] } = useQuery(consoleQuery.trialModels.get.queryOptions({ - enabled: IS_CLOUD_EDITION, - select: data => data.trial_models, - })) - const installedProviderMap = useMemo(() => new Map( - modelProviders.map(provider => [provider.provider, provider]), - ), [modelProviders]) + const { data: trialModels = [] } = useQuery( + consoleQuery.trialModels.get.queryOptions({ + enabled: IS_CLOUD_EDITION, + select: (data) => data.trial_models, + }), + ) + const installedProviderMap = useMemo( + () => new Map(modelProviders.map((provider) => [provider.provider, provider])), + [modelProviders], + ) const aiCreditVisibleProviders = useMemo(() => { - if (!enableMarketplace || isCreditsExhausted) - return new Set<string>() + if (!enableMarketplace || isCreditsExhausted) return new Set<string>() return new Set( modelProviders - .filter(provider => providerSupportsCredits(provider, trialModels)) - .map(provider => provider.provider), + .filter((provider) => providerSupportsCredits(provider, trialModels)) + .map((provider) => provider.provider), ) }, [enableMarketplace, isCreditsExhausted, modelProviders, trialModels]) - const showCreditsExhaustedAlert = enableMarketplace - && isCreditsExhausted - && modelProviders.some(provider => providerSupportsCredits(provider, trialModels)) + const showCreditsExhaustedAlert = + enableMarketplace && + isCreditsExhausted && + modelProviders.some((provider) => providerSupportsCredits(provider, trialModels)) const hasApiKeyFallback = modelProviders.some((provider) => { - const isApiKeyActive = provider.custom_configuration?.status === CustomConfigurationStatusEnum.active + const isApiKeyActive = + provider.custom_configuration?.status === CustomConfigurationStatusEnum.active return isApiKeyActive && providerSupportsCredits(provider, trialModels) }) - const handleInstallPlugin = useCallback(async (key: ModelProviderQuotaGetPaid) => { - if (!enableMarketplace || !canInstallPlugin || !allPlugins || isMarketplacePluginsLoading || installingProvider) - return - const pluginId = providerKeyToPluginId[key] - const plugin = allPlugins.find(p => p.plugin_id === pluginId) - if (!plugin) - return + const handleInstallPlugin = useCallback( + async (key: ModelProviderQuotaGetPaid) => { + if ( + !enableMarketplace || + !canInstallPlugin || + !allPlugins || + isMarketplacePluginsLoading || + installingProvider + ) + return + const pluginId = providerKeyToPluginId[key] + const plugin = allPlugins.find((p) => p.plugin_id === pluginId) + if (!plugin) return - const uniqueIdentifier = plugin.latest_package_identifier - setInstallingProvider(key) - try { - const { all_installed, task_id } = await installPackageFromMarketPlace(uniqueIdentifier) - if (!all_installed) { - const { check } = checkTaskStatus() - await check({ taskId: task_id, pluginUniqueIdentifier: uniqueIdentifier }) + const uniqueIdentifier = plugin.latest_package_identifier + setInstallingProvider(key) + try { + const { all_installed, task_id } = await installPackageFromMarketPlace(uniqueIdentifier) + if (!all_installed) { + const { check } = checkTaskStatus() + await check({ taskId: task_id, pluginUniqueIdentifier: uniqueIdentifier }) + } + refreshPluginList(plugin) + } catch { + } finally { + setInstallingProvider(null) } - refreshPluginList(plugin) - } - catch { } - finally { - setInstallingProvider(null) - } - }, [allPlugins, enableMarketplace, canInstallPlugin, installPackageFromMarketPlace, installingProvider, isMarketplacePluginsLoading, refreshPluginList]) + }, + [ + allPlugins, + enableMarketplace, + canInstallPlugin, + installPackageFromMarketPlace, + installingProvider, + isMarketplacePluginsLoading, + refreshPluginList, + ], + ) const installedModelList = useMemo(() => { - const modelMap = new Map(modelList.map(model => [model.provider, model])) + const modelMap = new Map(modelList.map((model) => [model.provider, model])) const installedMarketplaceModels = MODEL_PROVIDER_QUOTA_GET_PAID.flatMap((providerKey) => { const installedProvider = installedProviderMap.get(providerKey) - if (!installedProvider) - return [] + if (!installedProvider) return [] const matchedModel = modelMap.get(providerKey) - if (matchedModel) - return [matchedModel] + if (matchedModel) return [matchedModel] - if (!aiCreditVisibleProviders.has(providerKey)) - return [] + if (!aiCreditVisibleProviders.has(providerKey)) return [] - return [{ - provider: installedProvider.provider, - icon_small: installedProvider.icon_small, - icon_small_dark: installedProvider.icon_small_dark, - label: installedProvider.label, - models: [], - status: ModelStatusEnum.active, - }] + return [ + { + provider: installedProvider.provider, + icon_small: installedProvider.icon_small, + icon_small_dark: installedProvider.icon_small_dark, + label: installedProvider.label, + models: [], + status: ModelStatusEnum.active, + }, + ] }) - const otherModels = modelList.filter(model => !MODEL_PROVIDER_QUOTA_GET_PAID.includes(model.provider as ModelProviderQuotaGetPaid)) + const otherModels = modelList.filter( + (model) => + !MODEL_PROVIDER_QUOTA_GET_PAID.includes(model.provider as ModelProviderQuotaGetPaid), + ) return [...installedMarketplaceModels, ...otherModels] }, [aiCreditVisibleProviders, installedProviderMap, modelList]) @@ -166,82 +217,91 @@ function Popup({ () => createModelSelectorSearchIndex(installedModelList, language), [installedModelList, language], ) - const filteredModelList = useMemo(() => filterModelSelectorModels({ - aiCreditVisibleProviders, - defaultModel, - inputValue, - installedModelList, - modelPredicate: showIncompatibleModels ? undefined : modelPredicate, - scopeFeatures, - searchIndex, - }), [aiCreditVisibleProviders, defaultModel, inputValue, installedModelList, modelPredicate, scopeFeatures, searchIndex, showIncompatibleModels]) + const filteredModelList = useMemo( + () => + filterModelSelectorModels({ + aiCreditVisibleProviders, + defaultModel, + inputValue, + installedModelList, + modelPredicate: showIncompatibleModels ? undefined : modelPredicate, + scopeFeatures, + searchIndex, + }), + [ + aiCreditVisibleProviders, + defaultModel, + inputValue, + installedModelList, + modelPredicate, + scopeFeatures, + searchIndex, + showIncompatibleModels, + ], + ) const shouldShowModelPredicateReveal = !!modelPredicate const marketplaceProviders = useMemo(() => { - if (!enableMarketplace) - return [] + if (!enableMarketplace) return [] - const installedProviders = new Set(modelProviders.map(provider => provider.provider)) - return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key)) + const installedProviders = new Set(modelProviders.map((provider) => provider.provider)) + return MODEL_PROVIDER_QUOTA_GET_PAID.filter((key) => !installedProviders.has(key)) }, [enableMarketplace, modelProviders]) const handleOpenSettings = useCallback(() => { onHide() - openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER, source: providerSettingsSource }) + openIntegrationsSetting({ + payload: ACCOUNT_SETTING_TAB.PROVIDER, + source: providerSettingsSource, + }) }, [onHide, openIntegrationsSetting, providerSettingsSource]) const handleClosePreviewCard = useCallback(() => { previewCardHandle.close() }, [previewCardHandle]) - const isProviderSettingsCurrentPage = searchParams?.get('action') === ACCOUNT_SETTING_MODAL_ACTION - && searchParams?.get('tab') === ACCOUNT_SETTING_TAB.PROVIDER - const handleConfigureEmptyState = onConfigureEmptyState ?? (isProviderSettingsCurrentPage ? onHide : handleOpenSettings) + const isProviderSettingsCurrentPage = + searchParams?.get('action') === ACCOUNT_SETTING_MODAL_ACTION && + searchParams?.get('tab') === ACCOUNT_SETTING_TAB.PROVIDER + const handleConfigureEmptyState = + onConfigureEmptyState ?? (isProviderSettingsCurrentPage ? onHide : handleOpenSettings) return ( <ModelSelectorPopupFrame> - <ModelSelectorSearchHeader - inputValue={inputValue} - onInputValueChange={onInputValueChange} - /> - {showCreditsExhaustedAlert && ( - <CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} /> - )} - <ModelSelectorScrollBody label={t($ => $['modelProvider.models'], { ns: 'common' })}> + <ModelSelectorSearchHeader inputValue={inputValue} onInputValueChange={onInputValueChange} /> + {showCreditsExhaustedAlert && <CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />} + <ModelSelectorScrollBody label={t(($) => $['modelProvider.models'], { ns: 'common' })}> <ComboboxList className="max-h-none overflow-visible p-0"> <div className="pb-1"> - { - filteredModelList.map(model => ( - <PopupItem - key={model.provider} - defaultModel={defaultModel} - model={model} - modelPredicate={modelPredicate} - modelSuggestionPredicate={modelSuggestionPredicate} - previewCardHandle={previewCardHandle} - onPreviewCardClose={handleClosePreviewCard} - onHide={onHide} - /> - )) - } + {filteredModelList.map((model) => ( + <PopupItem + key={model.provider} + defaultModel={defaultModel} + model={model} + modelPredicate={modelPredicate} + modelSuggestionPredicate={modelSuggestionPredicate} + previewCardHandle={previewCardHandle} + onPreviewCardClose={handleClosePreviewCard} + onHide={onHide} + /> + ))} </div> </ComboboxList> <div className="pb-1"> {!filteredModelList.length && !installedModelList.length && ( - <ModelSelectorEmptyState - onConfigure={handleConfigureEmptyState} - /> + <ModelSelectorEmptyState onConfigure={handleConfigureEmptyState} /> )} {!filteredModelList.length && installedModelList.length > 0 && ( <div className="px-3 py-1.5 text-center text-xs/4.5 break-all text-text-tertiary"> - {t($ => $['modelProvider.selector.noModelFoundForSearch'], { ns: 'common', query: inputValue })} + {t(($) => $['modelProvider.selector.noModelFoundForSearch'], { + ns: 'common', + query: inputValue, + })} </div> )} - {scopeFeatures.length > 0 && ( - <CompatibleModelsNotice /> - )} + {scopeFeatures.length > 0 && <CompatibleModelsNotice />} {shouldShowModelPredicateReveal && ( <ShowIncompatibleModelsButton showIncompatibleModels={showIncompatibleModels} - onClick={() => setShowIncompatibleModels(value => !value)} + onClick={() => setShowIncompatibleModels((value) => !value)} /> )} {enableMarketplace && ( @@ -262,7 +322,7 @@ function Popup({ <PreviewCard handle={previewCardHandle}> {({ payload }) => ( <ModelSelectorPreviewCard - capabilitiesLabel={t($ => $['model.capabilities'], { ns: 'common' })} + capabilitiesLabel={t(($) => $['model.capabilities'], { ns: 'common' })} language={language} payload={payload as ModelSelectorPreviewPayload | undefined} /> @@ -286,8 +346,7 @@ function ModelSelectorPreviewCard({ language, payload, }: ModelSelectorPreviewCardProps) { - if (!payload) - return null + if (!payload) return null const { provider, modelItem } = payload @@ -298,18 +357,14 @@ function ModelSelectorPreviewCard({ > <div className="flex flex-col gap-1"> <div className="flex flex-col items-start gap-2"> - <ModelIcon - className="size-5 shrink-0" - provider={provider} - modelName={modelItem.model} - /> - <div className="system-md-medium text-wrap wrap-break-word text-text-primary">{modelItem.label[language] || modelItem.label.en_US}</div> + <ModelIcon className="size-5 shrink-0" provider={provider} modelName={modelItem.model} /> + <div className="system-md-medium text-wrap wrap-break-word text-text-primary"> + {modelItem.label[language] || modelItem.label.en_US} + </div> </div> <div className="flex flex-wrap gap-1"> {!!modelItem.model_type && ( - <ModelBadge> - {modelTypeFormat(modelItem.model_type)} - </ModelBadge> + <ModelBadge>{modelTypeFormat(modelItem.model_type)}</ModelBadge> )} {!!modelItem.model_properties.mode && ( <ModelBadge> @@ -317,23 +372,27 @@ function ModelSelectorPreviewCard({ </ModelBadge> )} {!!modelItem.model_properties.context_size && ( - <ModelBadge> - {sizeFormat(modelItem.model_properties.context_size as number)} - </ModelBadge> + <ModelBadge>{sizeFormat(modelItem.model_properties.context_size as number)}</ModelBadge> )} </div> - {[ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].includes(modelItem.model_type as ModelTypeEnum) - && modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature)) - && ( + {[ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].includes( + modelItem.model_type as ModelTypeEnum, + ) && + modelItem.features?.some((feature) => + [ + ModelFeatureEnum.vision, + ModelFeatureEnum.audio, + ModelFeatureEnum.video, + ModelFeatureEnum.document, + ].includes(feature), + ) && ( <div className="pt-2"> - <div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">{capabilitiesLabel}</div> + <div className="mb-1 system-2xs-medium-uppercase text-text-tertiary"> + {capabilitiesLabel} + </div> <div className="flex flex-wrap gap-1"> - {modelItem.features?.map(feature => ( - <FeatureIcon - key={feature} - feature={feature} - showFeaturesLabel - /> + {modelItem.features?.map((feature) => ( + <FeatureIcon key={feature} feature={feature} showFeaturesLabel /> ))} </div> </div> diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx index cd472d97c926fe..1bec6ece763ff7 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx @@ -19,7 +19,13 @@ const { mockToastNotify: vi.fn(), mockUpdateModelList: vi.fn(), mockUpdateModelProviders: vi.fn(), - mockTrialCredits: { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false, nextCreditResetDate: undefined }, + mockTrialCredits: { + credits: 100, + totalCredits: 10_000, + isExhausted: false, + isLoading: false, + nextCreditResetDate: undefined, + }, mockChangePriorityFn: vi.fn().mockResolvedValue({ result: 'success' }), })) @@ -44,7 +50,12 @@ vi.mock('@/service/client', async (importOriginal) => { byProvider: { models: { get: { - queryKey: ({ input }: { input: { params: { provider: string } } }) => ['console', 'modelProviders', 'models', input.params.provider], + queryKey: ({ input }: { input: { params: { provider: string } } }) => [ + 'console', + 'modelProviders', + 'models', + input.params.provider, + ], }, }, preferredProviderType: { @@ -84,7 +95,13 @@ vi.mock('../use-trial-credits', () => ({ })) vi.mock('../model-auth-dropdown', () => ({ - default: ({ state, onChangePriority }: { state: { variant: string, hasCredentials: boolean }, onChangePriority: (key: string) => void }) => ( + default: ({ + state, + onChangePriority, + }: { + state: { variant: string; hasCredentials: boolean } + onChangePriority: (key: string) => void + }) => ( <div data-testid="model-auth-dropdown" data-variant={state.variant}> <button data-testid="change-priority-btn" onClick={() => onChangePriority('custom')}> Change Priority @@ -94,28 +111,33 @@ vi.mock('../model-auth-dropdown', () => ({ })) vi.mock('@langgenius/dify-ui/status-dot', () => ({ - StatusDot: ({ status }: { status: string }) => <div data-testid="indicator" data-status={status} />, + StatusDot: ({ status }: { status: string }) => ( + <div data-testid="indicator" data-status={status} /> + ), })) vi.mock('@/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning', () => ({ - default: (props: Record<string, unknown>) => <div data-testid="warning-icon" className={props.className as string} />, + default: (props: Record<string, unknown>) => ( + <div data-testid="warning-icon" className={props.className as string} /> + ), })) -const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({ - provider: 'langgenius/openai/openai', - provider_credential_schema: { credential_form_schemas: [] }, - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - current_credential_id: 'cred-1', - current_credential_name: 'test-credential', - available_credentials: [{ credential_id: 'cred-1', credential_name: 'test-credential' }], - }, - system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, - preferred_provider_type: PreferredProviderTypeEnum.system, - configurate_methods: [ConfigurationMethodEnum.predefinedModel], - supported_model_types: ['llm'], - ...overrides, -} as unknown as ModelProvider) +const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => + ({ + provider: 'langgenius/openai/openai', + provider_credential_schema: { credential_form_schemas: [] }, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: 'cred-1', + current_credential_name: 'test-credential', + available_credentials: [{ credential_id: 'cred-1', credential_name: 'test-credential' }], + }, + system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, + preferred_provider_type: PreferredProviderTypeEnum.system, + configurate_methods: [ConfigurationMethodEnum.predefinedModel], + supported_model_types: ['llm'], + ...overrides, + }) as unknown as ModelProvider const renderWithQueryClient = (provider: ModelProvider) => { return renderWithSystemFeatures(<CredentialPanel provider={provider} />, { @@ -126,7 +148,12 @@ const renderWithQueryClient = (provider: ModelProvider) => { describe('CredentialPanel', () => { beforeEach(() => { vi.clearAllMocks() - Object.assign(mockTrialCredits, { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false }) + Object.assign(mockTrialCredits, { + credits: 100, + totalCredits: 10_000, + isExhausted: false, + isLoading: false, + }) }) describe('Text label variants', () => { @@ -138,60 +165,70 @@ describe('CredentialPanel', () => { it('should show "Credits exhausted" for credits-exhausted variant (no credentials)', () => { mockTrialCredits.isExhausted = true mockTrialCredits.credits = 0 - renderWithQueryClient(createProvider({ - custom_configuration: { - status: CustomConfigurationStatusEnum.noConfigure, - available_credentials: [], - }, - })) + renderWithQueryClient( + createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + }), + ) expect(screen.getByText(/quotaExhausted/)).toBeInTheDocument() }) it('should show "No available usage" for no-usage variant (exhausted + credential unauthorized)', () => { mockTrialCredits.isExhausted = true - renderWithQueryClient(createProvider({ - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - current_credential_id: undefined, - current_credential_name: undefined, - available_credentials: [{ credential_id: 'cred-1' }], - }, - })) + renderWithQueryClient( + createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: undefined, + available_credentials: [{ credential_id: 'cred-1' }], + }, + }), + ) expect(screen.getByText(/noAvailableUsage/)).toBeInTheDocument() }) it('should show "AI credits in use" with warning for credits-fallback (custom priority, no credentials, credits available)', () => { - renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - custom_configuration: { - status: CustomConfigurationStatusEnum.noConfigure, - available_credentials: [], - }, - })) + renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + }), + ) expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument() }) it('should show "AI credits in use" with warning for credits-fallback (custom priority, credential unauthorized, credits available)', () => { - renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - current_credential_id: undefined, - current_credential_name: undefined, - available_credentials: [{ credential_id: 'cred-1' }], - }, - })) + renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: undefined, + available_credentials: [{ credential_id: 'cred-1' }], + }, + }), + ) expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument() }) it('should show warning icon for credits-fallback variant', () => { - renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - custom_configuration: { - status: CustomConfigurationStatusEnum.noConfigure, - available_credentials: [], - }, - })) + renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + }), + ) expect(screen.getByTestId('warning-icon')).toBeInTheDocument() }) }) @@ -211,31 +248,37 @@ describe('CredentialPanel', () => { }) it('should show green indicator for api-active (custom priority + authorized)', () => { - renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - })) + renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + }), + ) expect(screen.getByTestId('indicator')).toHaveAttribute('data-status', 'success') expect(screen.getByText('test-credential')).toBeInTheDocument() }) it('should NOT show warning icon for api-active variant', () => { - renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - })) + renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + }), + ) expect(screen.queryByTestId('warning-icon')).not.toBeInTheDocument() }) it('should show red indicator and credential name for api-unavailable (exhausted + named unauthorized key)', () => { mockTrialCredits.isExhausted = true - renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - current_credential_id: undefined, - current_credential_name: 'Bad Key', - available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], - }, - })) + renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: 'Bad Key', + available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], + }, + }), + ) expect(screen.getByTestId('indicator')).toHaveAttribute('data-status', 'error') expect(screen.getByText('Bad Key')).toBeInTheDocument() }) @@ -244,39 +287,45 @@ describe('CredentialPanel', () => { describe('Destructive styling', () => { it('should apply destructive container for credits-exhausted', () => { mockTrialCredits.isExhausted = true - const { container } = renderWithQueryClient(createProvider({ - custom_configuration: { - status: CustomConfigurationStatusEnum.noConfigure, - available_credentials: [], - }, - })) + const { container } = renderWithQueryClient( + createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + }), + ) expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy() }) it('should apply destructive container for no-usage variant', () => { mockTrialCredits.isExhausted = true - const { container } = renderWithQueryClient(createProvider({ - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - current_credential_id: undefined, - current_credential_name: undefined, - available_credentials: [{ credential_id: 'cred-1' }], - }, - })) + const { container } = renderWithQueryClient( + createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: undefined, + available_credentials: [{ credential_id: 'cred-1' }], + }, + }), + ) expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy() }) it('should apply destructive container for api-unavailable variant', () => { mockTrialCredits.isExhausted = true - const { container } = renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - current_credential_id: undefined, - current_credential_name: 'Bad Key', - available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], - }, - })) + const { container } = renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: 'Bad Key', + available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], + }, + }), + ) expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy() }) @@ -286,9 +335,11 @@ describe('CredentialPanel', () => { }) it('should apply default container for api-active', () => { - const { container } = renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - })) + const { container } = renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + }), + ) expect(container.querySelector('[class*="bg-white"]')).toBeTruthy() }) @@ -302,12 +353,14 @@ describe('CredentialPanel', () => { describe('Text color', () => { it('should use destructive text color for credits-exhausted label', () => { mockTrialCredits.isExhausted = true - const { container } = renderWithQueryClient(createProvider({ - custom_configuration: { - status: CustomConfigurationStatusEnum.noConfigure, - available_credentials: [], - }, - })) + const { container } = renderWithQueryClient( + createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + }), + ) expect(container.querySelector('.text-text-destructive')).toBeTruthy() }) @@ -318,22 +371,26 @@ describe('CredentialPanel', () => { it('should use destructive text color for api-unavailable credential name', () => { mockTrialCredits.isExhausted = true - renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - current_credential_id: undefined, - current_credential_name: 'Bad Key', - available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], - }, - })) + renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: 'Bad Key', + available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], + }, + }), + ) expect(screen.getByText('Bad Key')).toHaveClass('text-text-destructive') }) it('should use secondary text color for api-active credential name', () => { - renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - })) + renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + }), + ) expect(screen.getByText('test-credential')).toHaveClass('text-text-secondary') }) }) @@ -362,9 +419,7 @@ describe('CredentialPanel', () => { }) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'success' }), - ) + expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) expect(mockUpdateModelProviders).toHaveBeenCalled() expect(mockUpdateModelList).toHaveBeenCalledWith('llm') }) @@ -374,90 +429,136 @@ describe('CredentialPanel', () => { describe('ModelAuthDropdown integration', () => { it('should pass credits-active variant to dropdown when credits available', () => { renderWithQueryClient(createProvider()) - expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-active') + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute( + 'data-variant', + 'credits-active', + ) }) it('should pass api-fallback variant to dropdown when exhausted with valid key', () => { mockTrialCredits.isExhausted = true renderWithQueryClient(createProvider()) - expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-fallback') + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute( + 'data-variant', + 'api-fallback', + ) }) it('should pass credits-exhausted variant when exhausted with no credentials', () => { mockTrialCredits.isExhausted = true - renderWithQueryClient(createProvider({ - custom_configuration: { - status: CustomConfigurationStatusEnum.noConfigure, - available_credentials: [], - }, - })) - expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-exhausted') + renderWithQueryClient( + createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + }), + ) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute( + 'data-variant', + 'credits-exhausted', + ) }) it('should pass api-active variant for custom priority with authorized key', () => { - renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - })) - expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active') + renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + }), + ) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute( + 'data-variant', + 'api-active', + ) }) it('should pass credits-fallback variant for custom priority with no credentials and credits available', () => { - renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - custom_configuration: { - status: CustomConfigurationStatusEnum.noConfigure, - available_credentials: [], - }, - })) - expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-fallback') + renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + }), + ) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute( + 'data-variant', + 'credits-fallback', + ) }) it('should pass credits-fallback variant for custom priority with named unauthorized key and credits available', () => { - renderWithQueryClient(createProvider({ - preferred_provider_type: PreferredProviderTypeEnum.custom, - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - current_credential_id: undefined, - current_credential_name: 'Bad Key', - available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], - }, - })) - expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-fallback') + renderWithQueryClient( + createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: 'Bad Key', + available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], + }, + }), + ) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute( + 'data-variant', + 'credits-fallback', + ) }) it('should pass no-usage variant when exhausted + credential but unauthorized', () => { mockTrialCredits.isExhausted = true - renderWithQueryClient(createProvider({ - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - current_credential_id: undefined, - current_credential_name: undefined, - available_credentials: [{ credential_id: 'cred-1' }], - }, - })) + renderWithQueryClient( + createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: undefined, + available_credentials: [{ credential_id: 'cred-1' }], + }, + }), + ) expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'no-usage') }) }) describe('apiKeyOnly priority (system disabled)', () => { it('should derive api-required-add when system config disabled and no credentials', () => { - renderWithQueryClient(createProvider({ - system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] }, - preferred_provider_type: PreferredProviderTypeEnum.system, - custom_configuration: { - status: CustomConfigurationStatusEnum.noConfigure, - available_credentials: [], - }, - })) - expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-required-add') + renderWithQueryClient( + createProvider({ + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.trial, + quota_configurations: [], + }, + preferred_provider_type: PreferredProviderTypeEnum.system, + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + }), + ) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute( + 'data-variant', + 'api-required-add', + ) expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument() }) it('should derive api-active when system config disabled but has authorized key', () => { - renderWithQueryClient(createProvider({ - system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] }, - })) - expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active') + renderWithQueryClient( + createProvider({ + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.trial, + quota_configurations: [], + }, + }), + ) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute( + 'data-variant', + 'api-active', + ) }) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx index 2989e0e3d837e1..acd2ee41605143 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx @@ -8,13 +8,20 @@ import { ConfigurationMethodEnum } from '../../declarations' import ProviderAddedCard from '../index' let mockIsCurrentWorkspaceManager = true -let mockWorkspacePermissionKeys: string[] = ['plugin.model_config', 'credential.use', 'credential.create', 'credential.manage'] +let mockWorkspacePermissionKeys: string[] = [ + 'plugin.model_config', + 'credential.use', + 'credential.create', + 'credential.manage', +] const mockFetchModelProviderModels = vi.fn() -const mockQueryOptions = vi.fn(({ input, ...options }: { input: { params: { provider: string } }, enabled?: boolean }) => ({ - queryKey: ['console', 'modelProviders', 'models', input.params.provider], - queryFn: () => mockFetchModelProviderModels(input.params.provider), - ...options, -})) +const mockQueryOptions = vi.fn( + ({ input, ...options }: { input: { params: { provider: string } }; enabled?: boolean }) => ({ + queryKey: ['console', 'modelProviders', 'models', input.params.provider], + queryFn: () => mockFetchModelProviderModels(input.params.provider), + ...options, + }), +) vi.mock('@/service/client', () => ({ consoleQuery: { @@ -24,7 +31,10 @@ vi.mock('@/service/client', () => ({ byProvider: { models: { get: { - queryOptions: (options: { input: { params: { provider: string } }, enabled?: boolean }) => mockQueryOptions(options), + queryOptions: (options: { + input: { params: { provider: string } } + enabled?: boolean + }) => mockQueryOptions(options), }, }, }, @@ -71,7 +81,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -81,10 +92,20 @@ vi.mock('../credential-panel', () => ({ })) vi.mock('../model-list', () => ({ - default: ({ onCollapse, onChange }: { onCollapse: () => void, onChange: (provider: string) => void }) => ( + default: ({ + onCollapse, + onChange, + }: { + onCollapse: () => void + onChange: (provider: string) => void + }) => ( <div data-testid="model-list"> - <button type="button" onClick={onCollapse}>collapse list</button> - <button type="button" onClick={() => onChange('langgenius/openai/openai')}>refresh list</button> + <button type="button" onClick={onCollapse}> + collapse list + </button> + <button type="button" onClick={() => onChange('langgenius/openai/openai')}> + refresh list + </button> </div> ), })) @@ -102,20 +123,19 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth' ManageCustomModelCredentials: () => <div data-testid="manage-custom-model" />, })) -const createTestQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false, gcTime: 0 }, - }, -}) +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + }, + }) const renderWithQueryClient = (node: ReactNode) => { const queryClient = createTestQueryClient() const store = createStore() return render( <JotaiProvider store={store}> - <QueryClientProvider client={queryClient}> - {node} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{node}</QueryClientProvider> </JotaiProvider>, ) } @@ -124,10 +144,18 @@ const ExternalExpandControls = () => { const expandModelProviderList = useExpandModelProviderList() return ( <> - <button type="button" data-testid="expand-other-provider" onClick={() => expandModelProviderList('langgenius/anthropic/anthropic')}> + <button + type="button" + data-testid="expand-other-provider" + onClick={() => expandModelProviderList('langgenius/anthropic/anthropic')} + > expand other </button> - <button type="button" data-testid="expand-current-provider" onClick={() => expandModelProviderList('langgenius/openai/openai')}> + <button + type="button" + data-testid="expand-current-provider" + onClick={() => expandModelProviderList('langgenius/openai/openai')} + > expand current </button> </> @@ -135,22 +163,24 @@ const ExternalExpandControls = () => { } const modelProviderModelsResponse = { - data: [{ - model: 'gpt-4', - label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, - model_type: 'llm', - features: [], - fetch_from: 'predefined-model', - status: 'active', - model_properties: {}, - load_balancing_enabled: false, - provider: { - provider: 'langgenius/openai/openai', - label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - supported_model_types: ['llm'], - tenant_id: 'tenant-id', + data: [ + { + model: 'gpt-4', + label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, + model_type: 'llm', + features: [], + fetch_from: 'predefined-model', + status: 'active', + model_properties: {}, + load_balancing_enabled: false, + provider: { + provider: 'langgenius/openai/openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + supported_model_types: ['llm'], + tenant_id: 'tenant-id', + }, }, - }], + ], } describe('ProviderAddedCard', () => { @@ -164,7 +194,12 @@ describe('ProviderAddedCard', () => { beforeEach(() => { vi.clearAllMocks() mockIsCurrentWorkspaceManager = true - mockWorkspacePermissionKeys = ['plugin.model_config', 'credential.use', 'credential.create', 'credential.manage'] + mockWorkspacePermissionKeys = [ + 'plugin.model_config', + 'credential.use', + 'credential.create', + 'credential.manage', + ] }) it('should render provider added card component', () => { @@ -204,7 +239,7 @@ describe('ProviderAddedCard', () => { }) it('should handle concurrent getModelList calls (loading state coverage)', async () => { - let resolveOuter: (value: unknown) => void = () => { } + let resolveOuter: (value: unknown) => void = () => {} const promise = new Promise((resolve) => { resolveOuter = resolve }) 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 621d0fe6a5d848..5ead6cc60ad36c 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 @@ -48,7 +48,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -73,12 +74,18 @@ vi.mock('../../model-icon', () => ({ })) vi.mock('../../model-name', () => ({ - default: ({ children, nameClassName }: { children: React.ReactNode, nameClassName?: string }) => <div data-testid="model-name" className={nameClassName}>{children}</div>, + default: ({ children, nameClassName }: { children: React.ReactNode; nameClassName?: string }) => ( + <div data-testid="model-name" className={nameClassName}> + {children} + </div> + ), })) vi.mock('../../model-auth', () => ({ ConfigModel: ({ onClick }: { onClick: () => void }) => ( - <button type="button" onClick={onClick}>modify load balancing</button> + <button type="button" onClick={onClick}> + modify load balancing + </button> ), })) @@ -105,14 +112,9 @@ describe('ModelListItem', () => { }) it('should render model item with icon and name', () => { - render( - <ModelListItem - model={mockModel} - provider={mockProvider} - isConfigurable={false} - />, - { wrapper: createWrapper() }, - ) + render(<ModelListItem model={mockModel} provider={mockProvider} isConfigurable={false} />, { + wrapper: createWrapper(), + }) expect(screen.getByTestId('model-icon')).toBeInTheDocument() expect(screen.getByTestId('model-name')).toBeInTheDocument() }) @@ -130,10 +132,13 @@ describe('ModelListItem', () => { ) fireEvent.click(screen.getByRole('switch')) - await waitFor(() => { - expect(disableModel).toHaveBeenCalled() - expect(onChange).toHaveBeenCalledWith('test-provider') - }, { timeout: 2000 }) + await waitFor( + () => { + expect(disableModel).toHaveBeenCalled() + expect(onChange).toHaveBeenCalledWith('test-provider') + }, + { timeout: 2000 }, + ) }) it('should enable a disabled model when switch is clicked', async () => { @@ -150,10 +155,13 @@ describe('ModelListItem', () => { ) fireEvent.click(screen.getByRole('switch')) - await waitFor(() => { - expect(enableModel).toHaveBeenCalled() - expect(onChange).toHaveBeenCalledWith('test-provider') - }, { timeout: 2000 }) + await waitFor( + () => { + expect(enableModel).toHaveBeenCalled() + expect(onChange).toHaveBeenCalledWith('test-provider') + }, + { timeout: 2000 }, + ) }) it('should open load balancing config action when available', () => { @@ -218,11 +226,7 @@ describe('ModelListItem', () => { // Act const { container } = render( - <ModelListItem - model={deprecatedModel} - provider={mockProvider} - isConfigurable={false} - />, + <ModelListItem model={deprecatedModel} provider={mockProvider} isConfigurable={false} />, { wrapper: createWrapper() }, ) @@ -244,14 +248,9 @@ describe('ModelListItem', () => { } as unknown as ModelItem // Act - render( - <ModelListItem - model={lbModel} - provider={mockProvider} - isConfigurable={false} - />, - { wrapper: createWrapper() }, - ) + render(<ModelListItem model={lbModel} provider={mockProvider} isConfigurable={false} />, { + wrapper: createWrapper(), + }) // Assert - Badge component should render const badge = document.querySelector('.border-text-accent-secondary') @@ -265,14 +264,9 @@ describe('ModelListItem', () => { mockPlanType = 'sandbox' // Act - render( - <ModelListItem - model={mockModel} - provider={mockProvider} - isConfigurable={false} - />, - { wrapper: createWrapper() }, - ) + render(<ModelListItem model={mockModel} provider={mockProvider} isConfigurable={false} />, { + wrapper: createWrapper(), + }) // Assert - ConfigModel should show because plan.type === 'sandbox' expect(screen.getByRole('button', { name: 'modify load balancing' })).toBeInTheDocument() @@ -285,14 +279,9 @@ describe('ModelListItem', () => { mockPlanType = 'pro' // Act - render( - <ModelListItem - model={mockModel} - provider={mockProvider} - isConfigurable={false} - />, - { wrapper: createWrapper() }, - ) + render(<ModelListItem model={mockModel} provider={mockProvider} isConfigurable={false} />, { + wrapper: createWrapper(), + }) // Assert - ConfigModel should NOT show because plan.type !== 'sandbox' and load balancing is disabled expect(screen.queryByRole('button', { name: 'modify load balancing' })).not.toBeInTheDocument() @@ -301,18 +290,16 @@ describe('ModelListItem', () => { // model.status=credentialRemoved: switch disabled, no ConfigModel it('should disable switch and hide ConfigModel when status is credentialRemoved', () => { // Arrange - const removedModel = { ...mockModel, status: ModelStatusEnum.credentialRemoved } as unknown as ModelItem + const removedModel = { + ...mockModel, + status: ModelStatusEnum.credentialRemoved, + } as unknown as ModelItem mockModelLoadBalancingEnabled = true // Act - render( - <ModelListItem - model={removedModel} - provider={mockProvider} - isConfigurable={false} - />, - { wrapper: createWrapper() }, - ) + render(<ModelListItem model={removedModel} provider={mockProvider} isConfigurable={false} />, { + wrapper: createWrapper(), + }) // Assert - ConfigModel should not render because status is not active/disabled expect(screen.queryByRole('button', { name: 'modify load balancing' })).not.toBeInTheDocument() @@ -328,15 +315,13 @@ describe('ModelListItem', () => { it('should apply hover class when isConfigurable is true', () => { // Act const { container } = render( - <ModelListItem - model={mockModel} - provider={mockProvider} - isConfigurable={true} - />, + <ModelListItem model={mockModel} provider={mockProvider} isConfigurable={true} />, { wrapper: createWrapper() }, ) // Assert - expect(container.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover')).toBeInTheDocument() + expect( + container.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover'), + ).toBeInTheDocument() }) }) 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 43ac342a714807..dc7178b4186d04 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 @@ -4,7 +4,11 @@ import { ConfigurationMethodEnum } from '../../declarations' import ModelList from '../model-list' const mockSetShowModelLoadBalancingModal = vi.fn() -let mockWorkspacePermissionKeys: string[] = ['plugin.model_config', 'credential.manage', 'credential.use'] +let mockWorkspacePermissionKeys: string[] = [ + 'plugin.model_config', + 'credential.manage', + 'credential.use', +] vi.mock('@/context/account-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') @@ -38,17 +42,27 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + 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 }), + useModalContextSelector: ( + selector: (state: { + setShowModelLoadBalancingModal: typeof mockSetShowModelLoadBalancingModal + }) => unknown, + ) => selector({ setShowModelLoadBalancingModal: mockSetShowModelLoadBalancingModal }), })) vi.mock('../model-list-item', () => ({ - default: ({ model, onModifyLoadBalancing }: { model: ModelItem, onModifyLoadBalancing: (model: ModelItem) => void }) => ( + default: ({ + model, + onModifyLoadBalancing, + }: { + model: ModelItem + onModifyLoadBalancing: (model: ModelItem) => void + }) => ( <button type="button" onClick={() => onModifyLoadBalancing(model)}> {model.model} </button> diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-configs.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-configs.spec.tsx index ae36a841542ccd..1c0c01c6d77b57 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-configs.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-configs.spec.tsx @@ -19,45 +19,62 @@ vi.mock('@/config', () => ({ })) vi.mock('@/context/provider-context', () => ({ - useProviderContextSelector: (selector: (state: { modelLoadBalancingEnabled: boolean }) => boolean) => selector({ modelLoadBalancingEnabled: mockModelLoadBalancingEnabled }), + useProviderContextSelector: ( + selector: (state: { modelLoadBalancingEnabled: boolean }) => boolean, + ) => selector({ modelLoadBalancingEnabled: mockModelLoadBalancingEnabled }), })) vi.mock('../cooldown-timer', () => ({ - default: ({ secondsRemaining, onFinish }: { secondsRemaining?: number, onFinish?: () => void }) => ( + default: ({ + secondsRemaining, + onFinish, + }: { + secondsRemaining?: number + onFinish?: () => void + }) => ( <button type="button" onClick={onFinish} data-testid="cooldown-timer"> - {secondsRemaining} - s + {secondsRemaining}s </button> ), })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ - AddCredentialInLoadBalancing: vi.fn(({ onSelectCredential, onUpdate, onRemove }: { - onSelectCredential: (credential: Credential) => void - onUpdate?: (payload?: unknown, formValues?: Record<string, unknown>) => void - onRemove?: (credentialId: string) => void - }) => ( - <div> - <button - type="button" - onClick={() => onSelectCredential({ credential_id: 'cred-2', credential_name: 'Key 2' } as Credential)} - > - add credential - </button> - <button - type="button" - onClick={() => onUpdate?.({ credential: { credential_id: 'cred-2' } }, { __authorization_name__: 'Key 2' })} - > - trigger update - </button> - <button - type="button" - onClick={() => onRemove?.('cred-2')} - > - trigger remove - </button> - </div> - )), + AddCredentialInLoadBalancing: vi.fn( + ({ + onSelectCredential, + onUpdate, + onRemove, + }: { + onSelectCredential: (credential: Credential) => void + onUpdate?: (payload?: unknown, formValues?: Record<string, unknown>) => void + onRemove?: (credentialId: string) => void + }) => ( + <div> + <button + type="button" + onClick={() => + onSelectCredential({ credential_id: 'cred-2', credential_name: 'Key 2' } as Credential) + } + > + add credential + </button> + <button + type="button" + onClick={() => + onUpdate?.( + { credential: { credential_id: 'cred-2' } }, + { __authorization_name__: 'Key 2' }, + ) + } + > + trigger update + </button> + <button type="button" onClick={() => onRemove?.('cred-2')}> + trigger remove + </button> + </div> + ), + ), })) vi.mock('@/app/components/billing/upgrade-btn', () => ({ @@ -89,17 +106,18 @@ describe('ModelLoadBalancingConfigs', () => { ], } as unknown as ModelCredential - const createDraftConfig = (enabled = true): ModelLoadBalancingConfig => ({ - enabled, - configs: [ - { - id: 'cfg-1', - credential_id: 'cred-1', - enabled: true, - name: 'Key 1', - }, - ], - } as ModelLoadBalancingConfig) + const createDraftConfig = (enabled = true): ModelLoadBalancingConfig => + ({ + enabled, + configs: [ + { + id: 'cfg-1', + credential_id: 'cred-1', + enabled: true, + name: 'Key 1', + }, + ], + }) as ModelLoadBalancingConfig const StatefulHarness = ({ initialConfig, @@ -114,7 +132,9 @@ describe('ModelLoadBalancingConfigs', () => { onRemove?: (credentialId: string) => void configurationMethod?: ConfigurationMethodEnum }) => { - const [draftConfig, setDraftConfig] = useState<ModelLoadBalancingConfig | undefined>(initialConfig) + const [draftConfig, setDraftConfig] = useState<ModelLoadBalancingConfig | undefined>( + initialConfig, + ) return ( <ModelLoadBalancingConfigs draftConfig={draftConfig} @@ -217,7 +237,14 @@ describe('ModelLoadBalancingConfigs', () => { const cooldownConfig: ModelLoadBalancingConfig = { enabled: true, configs: [ - { id: 'cfg-1', credential_id: 'cred-1', enabled: true, name: 'Key 1', in_cooldown: true, ttl: 30 }, + { + id: 'cfg-1', + credential_id: 'cred-1', + enabled: true, + name: 'Key 1', + in_cooldown: true, + ttl: 30, + }, ], } as unknown as ModelLoadBalancingConfig render(<StatefulHarness initialConfig={cooldownConfig} />) @@ -232,7 +259,13 @@ describe('ModelLoadBalancingConfigs', () => { const user = userEvent.setup() const onUpdate = vi.fn() const onRemove = vi.fn() - render(<StatefulHarness initialConfig={createDraftConfig(true)} onUpdate={onUpdate} onRemove={onRemove} />) + render( + <StatefulHarness + initialConfig={createDraftConfig(true)} + onUpdate={onUpdate} + onRemove={onRemove} + />, + ) // Add await user.click(screen.getByRole('button', { name: 'add credential' })) @@ -251,11 +284,14 @@ describe('ModelLoadBalancingConfigs', () => { it('should show "Provider Managed" badge for inherit config in predefined method', () => { const inheritConfig: ModelLoadBalancingConfig = { enabled: true, - configs: [ - { id: 'cfg-inherit', credential_id: '', enabled: true, name: '__inherit__' }, - ], + configs: [{ id: 'cfg-inherit', credential_id: '', enabled: true, name: '__inherit__' }], } as ModelLoadBalancingConfig - render(<StatefulHarness initialConfig={inheritConfig} configurationMethod={ConfigurationMethodEnum.predefinedModel} />) + render( + <StatefulHarness + initialConfig={inheritConfig} + configurationMethod={ConfigurationMethodEnum.predefinedModel} + />, + ) expect(screen.getByText('common.modelProvider.providerManaged')).toBeInTheDocument() expect(screen.getByText('common.modelProvider.defaultConfig')).toBeInTheDocument() @@ -309,9 +345,7 @@ describe('ModelLoadBalancingConfigs', () => { it('should not show provider badge when isProviderManaged=true but configurationMethod is customizableModel', () => { const inheritConfig: ModelLoadBalancingConfig = { enabled: true, - configs: [ - { id: 'cfg-inherit', credential_id: '', enabled: true, name: '__inherit__' }, - ], + configs: [{ id: 'cfg-inherit', credential_id: '', enabled: true, name: '__inherit__' }], } as ModelLoadBalancingConfig render( @@ -352,7 +386,12 @@ describe('ModelLoadBalancingConfigs', () => { const restrictedConfig: ModelLoadBalancingConfig = { enabled: true, configs: [ - { id: 'cfg-restricted', credential_id: 'cred-restricted', enabled: true, name: 'Restricted Key' }, + { + id: 'cfg-restricted', + credential_id: 'cred-restricted', + enabled: true, + name: 'Restricted Key', + }, ], } as ModelLoadBalancingConfig @@ -385,9 +424,14 @@ describe('ModelLoadBalancingConfigs', () => { it('should handle edge cases where draftConfig becomes null during callbacks', async () => { let capturedAdd: ((credential: Credential) => void) | null = null - let capturedUpdate: ((payload?: unknown, formValues?: Record<string, unknown>) => void) | null = null + let capturedUpdate: ((payload?: unknown, formValues?: Record<string, unknown>) => void) | null = + null let capturedRemove: ((credentialId: string) => void) | null = null - const MockChild = ({ onSelectCredential, onUpdate, onRemove }: { + const MockChild = ({ + onSelectCredential, + onUpdate, + onRemove, + }: { onSelectCredential: (credential: Credential) => void onUpdate?: (payload?: unknown, formValues?: Record<string, unknown>) => void onRemove?: (credentialId: string) => void @@ -397,7 +441,9 @@ describe('ModelLoadBalancingConfigs', () => { capturedRemove = onRemove || null return null } - vi.mocked(AddCredentialInLoadBalancing).mockImplementation(MockChild as unknown as typeof AddCredentialInLoadBalancing) + vi.mocked(AddCredentialInLoadBalancing).mockImplementation( + MockChild as unknown as typeof AddCredentialInLoadBalancing, + ) const { rerender } = render(<StatefulHarness initialConfig={createDraftConfig(true)} />) @@ -411,11 +457,15 @@ describe('ModelLoadBalancingConfigs', () => { // Trigger callbacks act(() => { if (capturedAdd) - (capturedAdd as (credential: Credential) => void)({ credential_id: 'new', credential_name: 'New' }) + (capturedAdd as (credential: Credential) => void)({ + credential_id: 'new', + credential_name: 'New', + }) if (capturedUpdate) - (capturedUpdate as (payload?: unknown, formValues?: Record<string, unknown>) => void)({ some: 'payload' }) - if (capturedRemove) - (capturedRemove as (credentialId: string) => void)('cred-1') + (capturedUpdate as (payload?: unknown, formValues?: Record<string, unknown>) => void)({ + some: 'payload', + }) + if (capturedRemove) (capturedRemove as (credentialId: string) => void)('cred-1') }) // Should not throw and just return prev (which is undefined) @@ -438,19 +488,29 @@ describe('ModelLoadBalancingConfigs', () => { it('should return early from addConfigEntry setDraftConfig when prev is undefined', async () => { // Arrange: use a controlled wrapper that exposes a way to force draftConfig to undefined let capturedAdd: ((credential: Credential) => void) | null = null - const MockChild = ({ onSelectCredential }: { + const MockChild = ({ + onSelectCredential, + }: { onSelectCredential: (credential: Credential) => void }) => { capturedAdd = onSelectCredential return null } - vi.mocked(AddCredentialInLoadBalancing).mockImplementation(MockChild as unknown as typeof AddCredentialInLoadBalancing) + vi.mocked(AddCredentialInLoadBalancing).mockImplementation( + MockChild as unknown as typeof AddCredentialInLoadBalancing, + ) // Use a setDraftConfig spy that tracks calls and simulates null prev - const setDraftConfigSpy = vi.fn((updater: ((prev: ModelLoadBalancingConfig | undefined) => ModelLoadBalancingConfig | undefined) | ModelLoadBalancingConfig | undefined) => { - if (typeof updater === 'function') - updater(undefined) - }) + const setDraftConfigSpy = vi.fn( + ( + updater: + | ((prev: ModelLoadBalancingConfig | undefined) => ModelLoadBalancingConfig | undefined) + | ModelLoadBalancingConfig + | undefined, + ) => { + if (typeof updater === 'function') updater(undefined) + }, + ) render( <ModelLoadBalancingConfigs @@ -466,7 +526,10 @@ describe('ModelLoadBalancingConfigs', () => { // Act: trigger addConfigEntry with undefined prev via the spy act(() => { if (capturedAdd) - (capturedAdd as (credential: Credential) => void)({ credential_id: 'new', credential_name: 'New' } as Credential) + (capturedAdd as (credential: Credential) => void)({ + credential_id: 'new', + credential_name: 'New', + } as Credential) }) // Assert: setDraftConfig was called and the updater returned early (prev was undefined) @@ -475,10 +538,16 @@ describe('ModelLoadBalancingConfigs', () => { it('should return early from updateConfigEntry setDraftConfig when prev is undefined', async () => { // Arrange: use setDraftConfig spy that invokes updater with undefined prev - const setDraftConfigSpy = vi.fn((updater: ((prev: ModelLoadBalancingConfig | undefined) => ModelLoadBalancingConfig | undefined) | ModelLoadBalancingConfig | undefined) => { - if (typeof updater === 'function') - updater(undefined) - }) + const setDraftConfigSpy = vi.fn( + ( + updater: + | ((prev: ModelLoadBalancingConfig | undefined) => ModelLoadBalancingConfig | undefined) + | ModelLoadBalancingConfig + | undefined, + ) => { + if (typeof updater === 'function') updater(undefined) + }, + ) render( <ModelLoadBalancingConfigs diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx index 002908ce8b64e7..cdad3b6138d04f 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx @@ -16,7 +16,7 @@ type CredentialData = { }> } current_credential_id: string - available_credentials: Array<{ credential_id: string, credential_name: string }> + available_credentials: Array<{ credential_id: string; credential_name: string }> current_credential_name: string } @@ -32,8 +32,20 @@ let mockCredentialData: CredentialData | undefined = { load_balancing: { enabled: true, configs: [ - { id: 'cfg-1', credential_id: 'cred-1', enabled: true, name: 'Default', credentials: { api_key: 'same-key' } }, - { id: 'cfg-2', credential_id: 'cred-2', enabled: true, name: 'Backup', credentials: { api_key: 'backup-key' } }, + { + id: 'cfg-1', + credential_id: 'cred-1', + enabled: true, + name: 'Default', + credentials: { api_key: 'same-key' }, + }, + { + id: 'cfg-2', + credential_id: 'cred-2', + enabled: true, + name: 'Backup', + credentials: { api_key: 'backup-key' }, + }, ], }, current_credential_id: 'cred-1', @@ -86,21 +98,43 @@ vi.mock('../../hooks', () => ({ })) vi.mock('../model-load-balancing-configs', () => ({ - default: ({ onUpdate, onRemove }: { + default: ({ + onUpdate, + onRemove, + }: { onUpdate?: (payload?: unknown, formValues?: Record<string, unknown>) => void onRemove?: (credentialId: string) => void }) => ( <div> - <button type="button" onClick={() => onUpdate?.(undefined, { __authorization_name__: 'New Key' })}>config add credential</button> - <button type="button" onClick={() => onUpdate?.({ credential: { credential_id: 'cred-1' } }, { __authorization_name__: 'Renamed Key' })}>config rename credential</button> - <button type="button" onClick={() => onRemove?.('cred-1')}>config remove</button> + <button + type="button" + onClick={() => onUpdate?.(undefined, { __authorization_name__: 'New Key' })} + > + config add credential + </button> + <button + type="button" + onClick={() => + onUpdate?.( + { credential: { credential_id: 'cred-1' } }, + { __authorization_name__: 'Renamed Key' }, + ) + } + > + config rename credential + </button> + <button type="button" onClick={() => onRemove?.('cred-1')}> + config remove + </button> </div> ), })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ SwitchCredentialInLoadBalancing: ({ onUpdate }: { onUpdate: () => void }) => ( - <button type="button" onClick={onUpdate}>switch credential</button> + <button type="button" onClick={onUpdate}> + switch credential + </button> ), })) @@ -131,11 +165,7 @@ describe('ModelLoadBalancingModal', () => { fetch_from: 'predefined-model', } as unknown as ModelItem - const renderModal = (node: Parameters<typeof render>[0]) => render( - <> - {node} - </>, - ) + const renderModal = (node: Parameters<typeof render>[0]) => render(<>{node}</>) beforeEach(() => { vi.clearAllMocks() @@ -145,8 +175,20 @@ describe('ModelLoadBalancingModal', () => { load_balancing: { enabled: true, configs: [ - { id: 'cfg-1', credential_id: 'cred-1', enabled: true, name: 'Default', credentials: { api_key: 'same-key' } }, - { id: 'cfg-2', credential_id: 'cred-2', enabled: true, name: 'Backup', credentials: { api_key: 'backup-key' } }, + { + id: 'cfg-1', + credential_id: 'cred-1', + enabled: true, + name: 'Default', + credentials: { api_key: 'same-key' }, + }, + { + id: 'cfg-2', + credential_id: 'cred-2', + enabled: true, + name: 'Backup', + credentials: { api_key: 'backup-key' }, + }, ], }, current_credential_id: 'cred-1', @@ -233,7 +275,9 @@ describe('ModelLoadBalancingModal', () => { await waitFor(() => { expect(mockRefetch).toHaveBeenCalled() expect(mockMutateAsync).toHaveBeenCalled() - const payload = mockMutateAsync.mock.calls[0]![0] as { load_balancing: { configs: Array<{ credentials: { api_key: string } }> } } + const payload = mockMutateAsync.mock.calls[0]![0] as { + load_balancing: { configs: Array<{ credentials: { api_key: string } }> } + } expect(payload.load_balancing.configs[0]!.credentials.api_key).toBe('[__HIDDEN__]') expect(mockNotify).toHaveBeenCalled() expect(mockHandleRefreshModel).toHaveBeenCalled() @@ -410,8 +454,20 @@ describe('ModelLoadBalancingModal', () => { load_balancing: { enabled: true, configs: [ - { id: 'cfg-1', credential_id: 'cred-1', enabled: true, name: 'Only One', credentials: { api_key: 'key' } }, - { id: 'cfg-2', credential_id: 'cred-2', enabled: false, name: 'Disabled', credentials: { api_key: 'key2' } }, + { + id: 'cfg-1', + credential_id: 'cred-1', + enabled: true, + name: 'Only One', + credentials: { api_key: 'key' }, + }, + { + id: 'cfg-2', + credential_id: 'cred-2', + enabled: false, + name: 'Disabled', + credentials: { api_key: 'key2' }, + }, ], }, } @@ -434,8 +490,20 @@ describe('ModelLoadBalancingModal', () => { load_balancing: { enabled: true, configs: [ - { id: '', credential_id: 'cred-new', enabled: true, name: 'New Entry', credentials: { api_key: 'new-key' } }, - { id: 'cfg-2', credential_id: 'cred-2', enabled: true, name: 'Backup', credentials: { api_key: 'backup-key' } }, + { + id: '', + credential_id: 'cred-new', + enabled: true, + name: 'New Entry', + credentials: { api_key: 'new-key' }, + }, + { + id: 'cfg-2', + credential_id: 'cred-2', + enabled: true, + name: 'Backup', + credentials: { api_key: 'backup-key' }, + }, ], }, } @@ -455,7 +523,9 @@ describe('ModelLoadBalancingModal', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalled() - const payload = mockMutateAsync.mock.calls[0]![0] as { load_balancing: { configs: Array<{ credentials: { api_key: string } }> } } + const payload = mockMutateAsync.mock.calls[0]![0] as { + load_balancing: { configs: Array<{ credentials: { api_key: string } }> } + } // Entry without id should NOT be encoded as hidden expect(payload.load_balancing.configs[0]!.credentials.api_key).toBe('new-key') }) @@ -464,9 +534,7 @@ describe('ModelLoadBalancingModal', () => { it('should add new credential to draft config when update finds matching credential', async () => { mockRefetch.mockResolvedValue({ data: { - available_credentials: [ - { credential_id: 'cred-new', credential_name: 'New Key' }, - ], + available_credentials: [{ credential_id: 'cred-new', credential_name: 'New Key' }], }, }) @@ -498,9 +566,7 @@ describe('ModelLoadBalancingModal', () => { it('should not update draft config when handleUpdate credential name does not match any available credential', async () => { mockRefetch.mockResolvedValue({ data: { - available_credentials: [ - { credential_id: 'cred-other', credential_name: 'Other Key' }, - ], + available_credentials: [{ credential_id: 'cred-other', credential_name: 'Other Key' }], }, }) @@ -528,7 +594,9 @@ describe('ModelLoadBalancingModal', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalled() // The payload configs should only have the original 2 entries (no new one added) - const payload = mockMutateAsync.mock.calls[0]![0] as { load_balancing: { configs: unknown[] } } + const payload = mockMutateAsync.mock.calls[0]![0] as { + load_balancing: { configs: unknown[] } + } expect(payload.load_balancing.configs).toHaveLength(2) }) }) @@ -548,7 +616,10 @@ describe('ModelLoadBalancingModal', () => { expect(screen.getByText(/modelProvider\.auth\.configLoadBalancing/))!.toBeInTheDocument() // Clicking the card when enabled=true toggles to disabled - const card = screen.getByText(/modelProvider\.auth\.providerManaged$/).closest('div[class]')!.closest('div[class]')! + const card = screen + .getByText(/modelProvider\.auth\.providerManaged$/) + .closest('div[class]')! + .closest('div[class]')! await user.click(card) // After toggling, title should show configModel (disabled state) @@ -574,7 +645,11 @@ describe('ModelLoadBalancingModal', () => { open onSave={onSave} onClose={onClose} - credential={{ credential_id: 'cred-1', credential_name: 'Default' } as unknown as Parameters<typeof ModelLoadBalancingModal>[0]['credential']} + credential={ + { credential_id: 'cred-1', credential_name: 'Default' } as unknown as Parameters< + typeof ModelLoadBalancingModal + >[0]['credential'] + } />, ) @@ -685,16 +760,16 @@ describe('ModelLoadBalancingModal', () => { ) // Assert: component renders without error (extendedSecretFormSchemas = []) - expect(screen.getAllByText(/modelProvider\.auth\.specifyModelCredential/).length).toBeGreaterThan(0) + expect( + screen.getAllByText(/modelProvider\.auth\.specifyModelCredential/).length, + ).toBeGreaterThan(0) }) it('should not update draft config when rename finds no matching index in prevIndex', async () => { // Arrange: credential in payload does not match any config (prevIndex = -1) mockRefetch.mockResolvedValue({ data: { - available_credentials: [ - { credential_id: 'cred-99', credential_name: 'Unknown' }, - ], + available_credentials: [{ credential_id: 'cred-99', credential_name: 'Unknown' }], }, }) @@ -722,7 +797,9 @@ describe('ModelLoadBalancingModal', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalled() - const payload = mockMutateAsync.mock.calls[0]![0] as { load_balancing: { configs: unknown[] } } + const payload = mockMutateAsync.mock.calls[0]![0] as { + load_balancing: { configs: unknown[] } + } // Config count unchanged (still 2 from original) expect(payload.load_balancing.configs).toHaveLength(2) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/provider-card-actions.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/provider-card-actions.spec.tsx index e83ec82a8a5449..b75a47315a026f 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/provider-card-actions.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/provider-card-actions.spec.tsx @@ -49,8 +49,12 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/hooks', () = })) vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/components', () => ({ - HeaderModals: ({ targetVersion, isDowngrade, isAutoUpgradeEnabled }: { - targetVersion?: { version: string, unique_identifier: string } + HeaderModals: ({ + targetVersion, + isDowngrade, + isAutoUpgradeEnabled, + }: { + targetVersion?: { version: string; unique_identifier: string } isDowngrade: boolean isAutoUpgradeEnabled: boolean }) => ( @@ -74,16 +78,22 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ })) vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({ - default: ({ trigger, onSelect, disabled }: { + default: ({ + trigger, + onSelect, + disabled, + }: { trigger: ReactNode - onSelect: (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => void + onSelect: (state: { version: string; unique_identifier: string; isDowngrade?: boolean }) => void disabled?: boolean }) => ( <div data-testid="plugin-version-picker" data-disabled={String(Boolean(disabled))}> {trigger} <button type="button" - onClick={() => onSelect({ version: '2.0.0', unique_identifier: 'plugin@2.0.0', isDowngrade: true })} + onClick={() => + onSelect({ version: '2.0.0', unique_identifier: 'plugin@2.0.0', isDowngrade: true }) + } > select version </button> @@ -98,21 +108,22 @@ vi.mock('@/utils/var', () => ({ getMarketplaceUrl: (...args: unknown[]) => mockGetMarketplaceUrl(...args), })) -const createDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({ - plugin_id: 'plugin-id', - plugin_unique_identifier: 'plugin-id@1.0.0', - name: 'provider-plugin', - source: PluginSource.marketplace, - version: '1.0.0', - latest_version: '2.0.0', - latest_unique_identifier: 'plugin-id@2.0.0', - declaration: { - author: 'langgenius', +const createDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => + ({ + plugin_id: 'plugin-id', + plugin_unique_identifier: 'plugin-id@1.0.0', name: 'provider-plugin', - }, - meta: undefined, - ...overrides, -} as PluginDetail) + source: PluginSource.marketplace, + version: '1.0.0', + latest_version: '2.0.0', + latest_unique_identifier: 'plugin-id@2.0.0', + declaration: { + author: 'langgenius', + name: 'provider-plugin', + }, + meta: undefined, + ...overrides, + }) as PluginDetail describe('ProviderCardActions', () => { beforeEach(() => { @@ -134,7 +145,9 @@ describe('ProviderCardActions', () => { isFromMarketplace: true, isFromGitHub: false, } - mockGetMarketplaceUrl.mockReturnValue('https://marketplace.example.com/plugins/langgenius/provider-plugin') + mockGetMarketplaceUrl.mockReturnValue( + 'https://marketplace.example.com/plugins/langgenius/provider-plugin', + ) }) it('should render version controls for marketplace plugins and handle manual version selection', () => { @@ -159,7 +172,9 @@ describe('ProviderCardActions', () => { const version = screen.getByText('1.0.0') const debugBadge = screen.getByText('appDebug.operation.debugConfig') - expect(version.compareDocumentPosition(debugBadge) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect( + version.compareDocumentPosition(debugBadge) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() }) it('should trigger the latest marketplace update when clicking the update button', () => { @@ -182,10 +197,9 @@ describe('ProviderCardActions', () => { theme: 'light', }) openActionsMenu() - expect(screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' })).toHaveAttribute( - 'href', - 'https://marketplace.example.com/plugins/langgenius/provider-plugin', - ) + expect( + screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' }), + ).toHaveAttribute('href', 'https://marketplace.example.com/plugins/langgenius/provider-plugin') }) it('should relay the marketplace remove action', () => { @@ -206,20 +220,23 @@ describe('ProviderCardActions', () => { } render( - <ProviderCardActions detail={createDetail({ - source: PluginSource.github, - meta: { - repo: 'langgenius/provider-plugin', - version: '1.0.0', - package: 'provider-plugin.difypkg', - }, - })} + <ProviderCardActions + detail={createDetail({ + source: PluginSource.github, + meta: { + repo: 'langgenius/provider-plugin', + version: '1.0.0', + package: 'provider-plugin.difypkg', + }, + })} />, ) expect(screen.getByTestId('plugin-version-picker')).toHaveAttribute('data-disabled', 'true') openActionsMenu() - expect(screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' })).toHaveAttribute('href', 'https://github.com/langgenius/provider-plugin') + expect( + screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' }), + ).toHaveAttribute('href', 'https://github.com/langgenius/provider-plugin') fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.update' })) @@ -236,14 +253,15 @@ describe('ProviderCardActions', () => { } render( - <ProviderCardActions detail={createDetail({ - source: PluginSource.github, - meta: { - repo: 'langgenius/provider-plugin', - version: '1.0.0', - package: 'provider-plugin.difypkg', - }, - })} + <ProviderCardActions + detail={createDetail({ + source: PluginSource.github, + meta: { + repo: 'langgenius/provider-plugin', + version: '1.0.0', + package: 'provider-plugin.difypkg', + }, + })} />, ) @@ -285,7 +303,9 @@ describe('ProviderCardActions', () => { ) openActionsMenu() - expect(screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' })).toHaveAttribute('href', '') + expect( + screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' }), + ).toHaveAttribute('href', '') rerender( <ProviderCardActions diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/quota-panel.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/quota-panel.spec.tsx index daaebf53724f2d..c0298c2ab12b1f 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/quota-panel.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/quota-panel.spec.tsx @@ -4,21 +4,25 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import QuotaPanel from '../quota-panel' -let mockWorkspaceData: { - trial_credits: number - trial_credits_used: number - next_credit_reset_date: number -} | undefined = { +let mockWorkspaceData: + | { + trial_credits: number + trial_credits_used: number + next_credit_reset_date: number + } + | undefined = { trial_credits: 100, trial_credits_used: 30, next_credit_reset_date: 1735603200, } let mockWorkspaceIsPending = false let mockTrialModels: string[] | undefined = ['langgenius/openai/openai'] -let mockPlugins = [{ - plugin_id: 'langgenius/openai', - latest_package_identifier: 'openai@1.0.0', -}] +let mockPlugins = [ + { + plugin_id: 'langgenius/openai', + latest_package_identifier: 'openai@1.0.0', + }, +] vi.mock('@/app/components/base/icons/src/public/llm', () => { const Icon = ({ label }: { label: string }) => <span>{label}</span> @@ -46,9 +50,10 @@ vi.mock('../use-trial-credits', () => ({ }, })) -const renderQuotaPanel = (ui: ReactElement) => renderWithSystemFeatures(ui, { - trialModels: mockTrialModels ?? [], -}) +const renderQuotaPanel = (ui: ReactElement) => + renderWithSystemFeatures(ui, { + trialModels: mockTrialModels ?? [], + }) vi.mock('../../hooks', () => ({ useMarketplaceAllPlugins: () => ({ @@ -56,13 +61,16 @@ vi.mock('../../hooks', () => ({ }), })) -vi.mock('@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', () => ({ - default: () => ({ - canInstallPlugin: true, - canUpdatePlugin: true, - currentDifyVersion: '1.0.0', +vi.mock( + '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', + () => ({ + default: () => ({ + canInstallPlugin: true, + canUpdatePlugin: true, + currentDifyVersion: '1.0.0', + }), }), -})) +) vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ @@ -74,7 +82,9 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () = default: ({ onClose }: { onClose: () => void }) => ( <div> <span>install modal</span> - <button type="button" onClick={onClose}>close install</button> + <button type="button" onClick={onClose}> + close install + </button> </div> ), })) @@ -109,11 +119,7 @@ describe('QuotaPanel', () => { }) it('should show remaining credits and reset date', () => { - renderQuotaPanel( - <QuotaPanel - providers={mockProviders} - />, - ) + renderQuotaPanel(<QuotaPanel providers={mockProviders} />) expect(screen.getByText(/modelProvider\.quota/)).toBeInTheDocument() expect(screen.getByText('70')).toBeInTheDocument() @@ -183,13 +189,16 @@ describe('QuotaPanel', () => { it('should show the supported-model tooltip for installed non-custom providers', () => { renderQuotaPanel( - <QuotaPanel providers={[ - { - provider: 'langgenius/openai/openai', - preferred_provider_type: 'system', - custom_configuration: { available_credentials: [] }, - }, - ] as unknown as ModelProvider[]} + <QuotaPanel + providers={ + [ + { + provider: 'langgenius/openai/openai', + preferred_provider_type: 'system', + custom_configuration: { available_credentials: [] }, + }, + ] as unknown as ModelProvider[] + } />, ) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-change-provider-priority.spec.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-change-provider-priority.spec.ts index cf9125d4afc793..14b72b399cb90b 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-change-provider-priority.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-change-provider-priority.spec.ts @@ -3,13 +3,21 @@ import type { ModelProvider } from '../../declarations' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react' import * as React from 'react' -import { ConfigurationMethodEnum, ModelTypeEnum, PreferredProviderTypeEnum } from '../../declarations' +import { + ConfigurationMethodEnum, + ModelTypeEnum, + PreferredProviderTypeEnum, +} from '../../declarations' import { useChangeProviderPriority } from '../use-change-provider-priority' const mockUpdateModelList = vi.fn() const mockUpdateModelProviders = vi.fn() const mockNotify = vi.fn() -const mockQueryKey = vi.fn(({ input }: { input: { params: { provider: string } } }) => ['model-providers', 'models', input.params.provider]) +const mockQueryKey = vi.fn(({ input }: { input: { params: { provider: string } } }) => [ + 'model-providers', + 'models', + input.params.provider, +]) const mockChangePreferredProviderType = vi.fn() const mockMutationOptions = vi.fn((options: Record<string, unknown>) => ({ mutationFn: (variables: unknown) => mockChangePreferredProviderType(variables), @@ -36,7 +44,8 @@ vi.mock('@/service/client', () => ({ byProvider: { models: { get: { - queryKey: (options: { input: { params: { provider: string } } }) => mockQueryKey(options), + queryKey: (options: { input: { params: { provider: string } } }) => + mockQueryKey(options), }, }, preferredProviderType: { @@ -56,37 +65,38 @@ vi.mock('../../hooks', () => ({ useUpdateModelProviders: () => mockUpdateModelProviders, })) -const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({ - provider: 'langgenius/openai/openai', - configurate_methods: [ - ConfigurationMethodEnum.customizableModel, - ConfigurationMethodEnum.predefinedModel, - ], - supported_model_types: [ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding], - label: { en_US: 'OpenAI' }, - icon_small: { en_US: 'https://example.com/icon.png' }, - provider_credential_schema: { credential_form_schemas: [] }, - model_credential_schema: { - model: { - label: { en_US: 'Model' }, - placeholder: { en_US: 'Select model' }, +const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => + ({ + provider: 'langgenius/openai/openai', + configurate_methods: [ + ConfigurationMethodEnum.customizableModel, + ConfigurationMethodEnum.predefinedModel, + ], + supported_model_types: [ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding], + label: { en_US: 'OpenAI' }, + icon_small: { en_US: 'https://example.com/icon.png' }, + provider_credential_schema: { credential_form_schemas: [] }, + model_credential_schema: { + model: { + label: { en_US: 'Model' }, + placeholder: { en_US: 'Select model' }, + }, + credential_form_schemas: [], }, - credential_form_schemas: [], - }, - ...overrides, -} as ModelProvider) - -const createTestQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false, gcTime: 0 }, - mutations: { retry: false }, - }, -}) + ...overrides, + }) as ModelProvider + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }) const createWrapper = (queryClient: QueryClient) => { - return ({ children }: { children: ReactNode }) => ( + return ({ children }: { children: ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children) - ) } describe('useChangeProviderPriority', () => { @@ -98,7 +108,9 @@ describe('useChangeProviderPriority', () => { describe('when changing provider priority', () => { it('should submit the selected preferred provider type for the current provider', async () => { const queryClient = createTestQueryClient() - const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined) + const invalidateQueries = vi + .spyOn(queryClient, 'invalidateQueries') + .mockResolvedValue(undefined) const provider = createProvider() const { result } = renderHook(() => useChangeProviderPriority(provider), { wrapper: createWrapper(queryClient), @@ -141,7 +153,9 @@ describe('useChangeProviderPriority', () => { it('should tolerate an undefined provider and still submit a request without refreshing model lists', async () => { const queryClient = createTestQueryClient() - const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined) + const invalidateQueries = vi + .spyOn(queryClient, 'invalidateQueries') + .mockResolvedValue(undefined) const { result } = renderHook(() => useChangeProviderPriority(undefined), { wrapper: createWrapper(queryClient), }) @@ -170,7 +184,9 @@ describe('useChangeProviderPriority', () => { describe('when the mutation is not successful immediately', () => { it('should show an error toast when the mutation fails', async () => { const queryClient = createTestQueryClient() - const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined) + const invalidateQueries = vi + .spyOn(queryClient, 'invalidateQueries') + .mockResolvedValue(undefined) mockChangePreferredProviderType.mockRejectedValueOnce(new Error('network error')) const { result } = renderHook(() => useChangeProviderPriority(createProvider()), { wrapper: createWrapper(queryClient), @@ -195,9 +211,12 @@ describe('useChangeProviderPriority', () => { it('should expose the pending mutation state while the request is in flight', async () => { let resolveMutation: (() => void) | undefined - mockChangePreferredProviderType.mockImplementationOnce(() => new Promise<void>((resolve) => { - resolveMutation = resolve - })) + mockChangePreferredProviderType.mockImplementationOnce( + () => + new Promise<void>((resolve) => { + resolveMutation = resolve + }), + ) const queryClient = createTestQueryClient() const { result } = renderHook(() => useChangeProviderPriority(createProvider()), { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-credential-panel-state.spec.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-credential-panel-state.spec.ts index 170200dfec8167..47e54dc0a8dbb9 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-credential-panel-state.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-credential-panel-state.spec.ts @@ -8,7 +8,13 @@ import { } from '../../declarations' import { isDestructiveVariant, useCredentialPanelState } from '../use-credential-panel-state' -const mockTrialCredits = { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false, nextCreditResetDate: undefined } +const mockTrialCredits = { + credits: 100, + totalCredits: 10_000, + isExhausted: false, + isLoading: false, + nextCreditResetDate: undefined, +} const mockTrialModels = ['langgenius/openai/openai', 'langgenius/anthropic/anthropic'] vi.mock('../use-trial-credits', () => ({ @@ -27,26 +33,32 @@ const renderPanelHook = (provider: ModelProvider | undefined) => { }) } -const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({ - provider: 'langgenius/openai/openai', - provider_credential_schema: { credential_form_schemas: [] }, - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - current_credential_id: 'cred-1', - current_credential_name: 'My Key', - available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }], - }, - system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, - preferred_provider_type: PreferredProviderTypeEnum.system, - configurate_methods: [ConfigurationMethodEnum.predefinedModel], - supported_model_types: ['llm'], - ...overrides, -} as unknown as ModelProvider) +const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => + ({ + provider: 'langgenius/openai/openai', + provider_credential_schema: { credential_form_schemas: [] }, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: 'cred-1', + current_credential_name: 'My Key', + available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }], + }, + system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, + preferred_provider_type: PreferredProviderTypeEnum.system, + configurate_methods: [ConfigurationMethodEnum.predefinedModel], + supported_model_types: ['llm'], + ...overrides, + }) as unknown as ModelProvider describe('useCredentialPanelState', () => { beforeEach(() => { vi.clearAllMocks() - Object.assign(mockTrialCredits, { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false }) + Object.assign(mockTrialCredits, { + credits: 100, + totalCredits: 10_000, + isExhausted: false, + isLoading: false, + }) }) // Credits priority variants @@ -92,7 +104,9 @@ describe('useCredentialPanelState', () => { status: CustomConfigurationStatusEnum.active, current_credential_id: 'cred-1', current_credential_name: 'Bad Key', - available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key', not_allowed_to_use: true }], + available_credentials: [ + { credential_id: 'cred-1', credential_name: 'Bad Key', not_allowed_to_use: true }, + ], }, }) @@ -202,7 +216,9 @@ describe('useCredentialPanelState', () => { status: CustomConfigurationStatusEnum.active, current_credential_id: 'cred-1', current_credential_name: 'Bad Key', - available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key', not_allowed_to_use: true }], + available_credentials: [ + { credential_id: 'cred-1', credential_name: 'Bad Key', not_allowed_to_use: true }, + ], }, }) @@ -234,7 +250,11 @@ describe('useCredentialPanelState', () => { describe('apiKeyOnly priority (non-cloud / system disabled / not in trial_models)', () => { it('should return apiKeyOnly when system config disabled', () => { const provider = createProvider({ - system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.trial, + quota_configurations: [], + }, }) const { result } = renderPanelHook(provider) @@ -246,7 +266,11 @@ describe('useCredentialPanelState', () => { it('should return apiKeyOnly when provider not in trial_models even if system enabled', () => { const provider = createProvider({ provider: 'langgenius/minimax/minimax', - system_configuration: { enabled: true, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] }, + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.trial, + quota_configurations: [], + }, preferred_provider_type: PreferredProviderTypeEnum.system, }) @@ -282,7 +306,11 @@ describe('useCredentialPanelState', () => { it('should hide priority switcher when system config disabled', () => { const provider = createProvider({ - system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.trial, + quota_configurations: [], + }, }) const { result } = renderPanelHook(provider) @@ -293,7 +321,11 @@ describe('useCredentialPanelState', () => { it('should hide priority switcher when provider not in trial_models', () => { const provider = createProvider({ provider: 'langgenius/zhipuai/zhipuai', - system_configuration: { enabled: true, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] }, + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.trial, + quota_configurations: [], + }, }) const { result } = renderPanelHook(provider) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-trial-credits.spec.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-trial-credits.spec.ts index e0be3ed557aac3..fda5d6547cfad2 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-trial-credits.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/use-trial-credits.spec.ts @@ -26,11 +26,13 @@ vi.mock('@/service/client', () => ({ describe('useTrialCredits', () => { const mockTrialCreditsQuery = ( - data: { - trial_credits?: number - trial_credits_used?: number - next_credit_reset_date?: number - } | undefined, + data: + | { + trial_credits?: number + trial_credits_used?: number + next_credit_reset_date?: number + } + | undefined, isPending = false, ) => { mockUseQuery.mockImplementation((options: { select?: (value: typeof data) => unknown }) => ({ @@ -62,11 +64,14 @@ describe('useTrialCredits', () => { }) it('should keep the hook out of loading state during a background refetch', () => { - mockTrialCreditsQuery({ - trial_credits: 80, - trial_credits_used: 20, - next_credit_reset_date: 1777593600, - }, true) + mockTrialCreditsQuery( + { + trial_credits: 80, + trial_credits_used: 20, + next_credit_reset_date: 1777593600, + }, + true, + ) const { result } = renderHook(() => useTrialCredits()) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx index 8804a73462e0df..57087153e86a9c 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx @@ -36,8 +36,7 @@ const CooldownTimer = ({ secondsRemaining, onFinish }: CooldownTimerProps) => { if (now <= targetTime.current) { setCurrentTime(Date.now()) countdown() - } - else { + } else { onFinishRef.current?.() clearCountdown() } @@ -52,20 +51,18 @@ const CooldownTimer = ({ secondsRemaining, onFinish }: CooldownTimerProps) => { return clearCountdown }, [clearCountdown, countdown, secondsRemaining]) - return displayTime - ? ( - <Tooltip> - <TooltipTrigger - render={( - <SimplePieChart percentage={Math.round(displayTime / 60 * 100)} className="size-3" /> - )} - /> - <TooltipContent> - {t($ => $['modelProvider.apiKeyRateLimit'], { ns: 'common', seconds: displayTime })} - </TooltipContent> - </Tooltip> - ) - : null + return displayTime ? ( + <Tooltip> + <TooltipTrigger + render={ + <SimplePieChart percentage={Math.round((displayTime / 60) * 100)} className="size-3" /> + } + /> + <TooltipContent> + {t(($) => $['modelProvider.apiKeyRateLimit'], { ns: 'common', seconds: displayTime })} + </TooltipContent> + </Tooltip> + ) : null } export default memo(CooldownTimer) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx index 8264c8aa05fded..cd4186417f58e7 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx @@ -51,29 +51,29 @@ const CredentialPanelContent = ({ return ( <SystemQuotaCard variant={isDestructive ? 'destructive' : 'default'}> <SystemQuotaCard.Label className={needsGap ? 'gap-1' : undefined}> - {isTextLabel - ? <TextLabel variant={variant} /> - : <CredentialStatus variant={variant} credentialName={credentialName} />} + {isTextLabel ? ( + <TextLabel variant={variant} /> + ) : ( + <CredentialStatus variant={variant} credentialName={credentialName} /> + )} </SystemQuotaCard.Label> <SystemQuotaCard.Actions> - {renderActions - ? renderActions({ provider, state, isChangingPriority, onChangePriority }) - : ( - <ModelAuthDropdown - provider={provider} - state={state} - isChangingPriority={isChangingPriority} - onChangePriority={onChangePriority} - /> - )} + {renderActions ? ( + renderActions({ provider, state, isChangingPriority, onChangePriority }) + ) : ( + <ModelAuthDropdown + provider={provider} + state={state} + isChangingPriority={isChangingPriority} + onChangePriority={onChangePriority} + /> + )} </SystemQuotaCard.Actions> </SystemQuotaCard> ) } -const CredentialPanel = ({ - provider, -}: CredentialPanelProps) => { +const CredentialPanel = ({ provider }: CredentialPanelProps) => { // eslint-disable-next-line react/use-state -- This is a domain hook, not React's useState. const credentialPanelInfo = useCredentialPanelState(provider) const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(provider) @@ -105,16 +105,17 @@ function TextLabel({ variant }: { variant: CardVariant }) { return ( <> <span className={isDestructive ? 'text-text-destructive' : 'text-text-secondary'}> - {t($ => $[labelKey], { ns: 'common' })} + {t(($) => $[labelKey], { ns: 'common' })} </span> - {variant === 'credits-fallback' && ( - <Warning className="size-3 shrink-0 text-text-warning" /> - )} + {variant === 'credits-fallback' && <Warning className="size-3 shrink-0 text-text-warning" />} </> ) } -function CredentialStatus({ variant, credentialName }: { +function CredentialStatus({ + variant, + credentialName, +}: { variant: CardVariant credentialName: string | undefined }) { @@ -131,9 +132,7 @@ function CredentialStatus({ variant, credentialName }: { > {credentialName} </span> - {showWarning && ( - <Warning className="ml-auto size-3 shrink-0 text-text-warning" /> - )} + {showWarning && <Warning className="ml-auto size-3 shrink-0 text-text-warning" />} </> ) } 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 96b537ec94e7f6..7eb36f84cbd77b 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 @@ -1,10 +1,7 @@ import type { FC } from 'react' -import type { - ModelProvider, -} from '../declarations' +import type { ModelProvider } from '../declarations' import type { ModelProviderQuotaGetPaid } from '../utils' 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' @@ -49,47 +46,56 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({ }) => { const { t } = useTranslation() const language = useLanguage() - const refreshModelProviders = useProviderContextSelector(state => state.refreshModelProviders) + const refreshModelProviders = useProviderContextSelector((state) => state.refreshModelProviders) const currentProviderName = provider.provider const expanded = useModelProviderListExpanded(currentProviderName) const setExpanded = useSetModelProviderListExpanded(currentProviderName) - const supportsPredefinedModel = provider.configurate_methods.includes(ConfigurationMethodEnum.predefinedModel) - const supportsCustomizableModel = provider.configurate_methods.includes(ConfigurationMethodEnum.customizableModel) + const supportsPredefinedModel = provider.configurate_methods.includes( + ConfigurationMethodEnum.predefinedModel, + ) + const supportsCustomizableModel = provider.configurate_methods.includes( + ConfigurationMethodEnum.customizableModel, + ) const systemConfig = provider.system_configuration const { data: modelList = [], isFetching: loading, isSuccess: hasFetchedModelList, refetch: refetchModelList, - } = useQuery(consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({ - input: { params: { provider: currentProviderName } }, - enabled: expanded, - refetchOnWindowFocus: false, - select: normalizeModelProviderModelsResponse, - })) + } = useQuery( + consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({ + input: { params: { provider: currentProviderName } }, + enabled: expanded, + refetchOnWindowFocus: false, + select: normalizeModelProviderModelsResponse, + }), + ) const hasModelList = hasFetchedModelList && !!modelList.length const showCollapsedSection = !expanded || !hasFetchedModelList const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const showModelProvider = systemConfig.enabled && MODEL_PROVIDER_QUOTA_GET_PAID.includes(currentProviderName as ModelProviderQuotaGetPaid) && !IS_CE_EDITION + 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() const canAccessCredentials = canUseCredential || canCreateCredential || canManageCredential const showCredential = supportsPredefinedModel && canAccessCredentials const showCustomModelActions = supportsCustomizableModel && canConfigureModels - const refreshModelList = useCallback((targetProviderName: string) => { - if (targetProviderName !== currentProviderName) - return + const refreshModelList = useCallback( + (targetProviderName: string) => { + if (targetProviderName !== currentProviderName) return - if (!expanded) - setExpanded(true) + if (!expanded) setExpanded(true) - refetchModelList().catch(() => {}) - }, [currentProviderName, expanded, refetchModelList, setExpanded]) + refetchModelList().catch(() => {}) + }, + [currentProviderName, expanded, refetchModelList, setExpanded], + ) const handleOpenModelList = useCallback(() => { - if (loading) - return + if (loading) return if (!expanded) { setExpanded(true) @@ -101,7 +107,10 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({ const providerLabel = renderI18nObject(provider.label, language) const description = renderI18nObject( - provider.description || pluginDetail?.declaration.description || provider.help?.title || provider.label, + provider.description || + pluginDetail?.declaration.description || + provider.help?.title || + provider.label, language, ) const organization = pluginDetail?.declaration.author || currentProviderName.split('/')[0] @@ -112,7 +121,8 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({ className={cn( 'group relative mb-0 min-h-[120px] overflow-hidden rounded-xl border-[0.5px] border-divider-regular bg-components-panel-on-panel-item-bg shadow-xs', currentProviderName === 'langgenius/openai/openai' && 'bg-third-party-model-bg-openai', - currentProviderName === 'langgenius/anthropic/anthropic' && 'bg-third-party-model-bg-anthropic', + currentProviderName === 'langgenius/anthropic/anthropic' && + 'bg-third-party-model-bg-anthropic', )} > <div className="p-4 pb-3"> @@ -128,18 +138,20 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({ </div> <div className="min-w-0 flex-1"> <div className="flex h-5 min-w-0 items-center gap-1"> - <div className="truncate system-md-semibold text-text-secondary" title={providerLabel}> + <div + className="truncate system-md-semibold text-text-secondary" + title={providerLabel} + > {providerLabel} </div> {pluginDetail && ( - <ProviderCardActions - detail={pluginDetail} - onUpdate={refreshModelProviders} - /> + <ProviderCardActions detail={pluginDetail} onUpdate={refreshModelProviders} /> )} </div> <div className="mt-0.5 flex h-4 min-w-0 items-center gap-2 system-xs-regular text-text-tertiary"> - <span className="truncate" title={organization}>{organization}</span> + <span className="truncate" title={organization}> + {organization} + </span> </div> </div> </div> @@ -147,10 +159,8 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({ {description} </div> <div className="mt-3 flex min-w-0 gap-0.5 overflow-hidden"> - {provider.supported_model_types.slice(0, 4).map(modelType => ( - <ModelBadge key={modelType}> - {modelTypeFormat(modelType)} - </ModelBadge> + {provider.supported_model_types.slice(0, 4).map((modelType) => ( + <ModelBadge key={modelType}>{modelTypeFormat(modelType)}</ModelBadge> ))} </div> </div> @@ -159,31 +169,34 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({ <button type="button" className="flex h-8 min-w-0 flex-1 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text shadow-xs hover:bg-components-button-secondary-bg-hover" - aria-label={t($ => $['modelProvider.showModels'], { ns: 'common' })} + aria-label={t(($) => $['modelProvider.showModels'], { ns: 'common' })} onClick={handleOpenModelList} > <span className="truncate"> - { - hasModelList - ? t($ => $['modelProvider.modelsNum'], { ns: 'common', num: modelList.length }) - : t($ => $['modelProvider.showModels'], { ns: 'common' }) - } + {hasModelList + ? t(($) => $['modelProvider.modelsNum'], { ns: 'common', num: modelList.length }) + : t(($) => $['modelProvider.showModels'], { ns: 'common' })} </span> - {!loading && <span aria-hidden className="ml-1 i-ri-arrow-right-s-line size-4 shrink-0" />} - {loading && <span aria-hidden className="ml-1 i-ri-loader-2-line size-3 animate-spin" />} + {!loading && ( + <span aria-hidden className="ml-1 i-ri-arrow-right-s-line size-4 shrink-0" /> + )} + {loading && ( + <span aria-hidden className="ml-1 i-ri-loader-2-line size-3 animate-spin" /> + )} </button> )} {!showModelProvider && notConfigured && ( <div className="flex h-8 min-w-0 flex-1 items-center justify-center rounded-lg bg-background-default-subtle px-2"> - <span aria-hidden className="mr-1 i-ri-information-2-fill size-4 shrink-0 text-text-accent" /> - <span className="truncate system-xs-medium text-text-secondary">{t($ => $['modelProvider.configureTip'], { ns: 'common' })}</span> + <span + aria-hidden + className="mr-1 i-ri-information-2-fill size-4 shrink-0 text-text-accent" + /> + <span className="truncate system-xs-medium text-text-secondary"> + {t(($) => $['modelProvider.configureTip'], { ns: 'common' })} + </span> </div> )} - {showCredential && ( - <CredentialPanel - provider={provider} - /> - )} + {showCredential && <CredentialPanel provider={provider} />} {showCustomModelActions && ( <div className="flex shrink-0"> <ManageCustomModelCredentials @@ -218,7 +231,8 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({ className={cn( 'rounded-xl border-[0.5px] border-divider-regular bg-third-party-model-bg-default shadow-xs', currentProviderName === 'langgenius/openai/openai' && 'bg-third-party-model-bg-openai', - currentProviderName === 'langgenius/anthropic/anthropic' && 'bg-third-party-model-bg-anthropic', + currentProviderName === 'langgenius/anthropic/anthropic' && + 'bg-third-party-model-bg-anthropic', )} > <div className="flex rounded-t-xl py-2 pr-2 pl-3"> @@ -226,83 +240,64 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({ <div className="mb-2 flex items-center gap-1"> <ProviderIcon provider={provider} /> {pluginDetail && ( - <ProviderCardActions - detail={pluginDetail} - onUpdate={refreshModelProviders} - /> + <ProviderCardActions detail={pluginDetail} onUpdate={refreshModelProviders} /> )} </div> <div className="flex gap-0.5"> - {provider.supported_model_types.map(modelType => ( - <ModelBadge key={modelType}> - {modelTypeFormat(modelType)} - </ModelBadge> + {provider.supported_model_types.map((modelType) => ( + <ModelBadge key={modelType}>{modelTypeFormat(modelType)}</ModelBadge> ))} </div> </div> - {showCredential && ( - <CredentialPanel - provider={provider} - /> - )} + {showCredential && <CredentialPanel provider={provider} />} </div> - { - showCollapsedSection && ( - <div className="group flex items-center justify-between border-t border-t-divider-subtle py-1.5 pr-2.75 pl-2 system-xs-medium text-text-tertiary"> - {(showModelProvider || !notConfigured) && ( - <button - type="button" - className="flex h-6 items-center rounded-lg border-none bg-transparent pr-1.5 pl-1 text-left hover:bg-components-button-ghost-bg-hover" - aria-label={t($ => $['modelProvider.showModels'], { ns: 'common' })} - onClick={handleOpenModelList} - > - { - hasModelList - ? t($ => $['modelProvider.modelsNum'], { ns: 'common', num: modelList.length }) - : t($ => $['modelProvider.showModels'], { ns: 'common' }) - } - {!loading && <div className="i-ri-arrow-right-s-line size-4" aria-hidden="true" />} - { - loading && ( - <div className="ml-0.5 i-ri-loader-2-line size-3 animate-spin" /> - ) - } - </button> - )} - {!showModelProvider && notConfigured && ( - <div className="flex h-6 items-center pr-1.5 pl-1"> - <div className="mr-1 i-ri-information-2-fill size-4 text-text-accent" /> - <span className="system-xs-medium text-text-secondary">{t($ => $['modelProvider.configureTip'], { ns: 'common' })}</span> - </div> - )} - { - showCustomModelActions && ( - <div className="flex grow justify-end"> - <ManageCustomModelCredentials - provider={provider} - currentCustomConfigurationModelFixedFields={undefined} - /> - <AddCustomModel - provider={provider} - configurationMethod={ConfigurationMethodEnum.customizableModel} - currentCustomConfigurationModelFixedFields={undefined} - /> - </div> - ) - } - </div> - ) - } - { - !showCollapsedSection && ( - <ModelList - provider={provider} - models={modelList} - onCollapse={() => setExpanded(false)} - onChange={refreshModelList} - /> - ) - } + {showCollapsedSection && ( + <div className="group flex items-center justify-between border-t border-t-divider-subtle py-1.5 pr-2.75 pl-2 system-xs-medium text-text-tertiary"> + {(showModelProvider || !notConfigured) && ( + <button + type="button" + className="flex h-6 items-center rounded-lg border-none bg-transparent pr-1.5 pl-1 text-left hover:bg-components-button-ghost-bg-hover" + aria-label={t(($) => $['modelProvider.showModels'], { ns: 'common' })} + onClick={handleOpenModelList} + > + {hasModelList + ? t(($) => $['modelProvider.modelsNum'], { ns: 'common', num: modelList.length }) + : t(($) => $['modelProvider.showModels'], { ns: 'common' })} + {!loading && <div className="i-ri-arrow-right-s-line size-4" aria-hidden="true" />} + {loading && <div className="ml-0.5 i-ri-loader-2-line size-3 animate-spin" />} + </button> + )} + {!showModelProvider && notConfigured && ( + <div className="flex h-6 items-center pr-1.5 pl-1"> + <div className="mr-1 i-ri-information-2-fill size-4 text-text-accent" /> + <span className="system-xs-medium text-text-secondary"> + {t(($) => $['modelProvider.configureTip'], { ns: 'common' })} + </span> + </div> + )} + {showCustomModelActions && ( + <div className="flex grow justify-end"> + <ManageCustomModelCredentials + provider={provider} + currentCustomConfigurationModelFixedFields={undefined} + /> + <AddCustomModel + provider={provider} + configurationMethod={ConfigurationMethodEnum.customizableModel} + currentCustomConfigurationModelFixedFields={undefined} + /> + </div> + )} + </div> + )} + {!showCollapsedSection && ( + <ModelList + provider={provider} + models={modelList} + onCollapse={() => setExpanded(false)} + onChange={refreshModelList} + /> + )} </div> ) } diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/api-key-section.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/api-key-section.spec.tsx index 4ca83cbb57720d..b3dc4e1b955458 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/api-key-section.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/api-key-section.spec.tsx @@ -35,7 +35,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -45,17 +46,18 @@ const createCredential = (overrides: Partial<Credential> = {}): Credential => ({ ...overrides, }) -const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({ - provider: 'test-provider', - allow_custom_token: true, - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - available_credentials: [], - }, - system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, - preferred_provider_type: PreferredProviderTypeEnum.system, - ...overrides, -} as unknown as ModelProvider) +const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => + ({ + provider: 'test-provider', + allow_custom_token: true, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + available_credentials: [], + }, + system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, + preferred_provider_type: PreferredProviderTypeEnum.system, + ...overrides, + }) as unknown as ModelProvider describe('ApiKeySection', () => { const handlers = { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-exhausted-alert.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-exhausted-alert.spec.tsx index 73ddadbde2a7c8..c46d17952749e4 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-exhausted-alert.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-exhausted-alert.spec.tsx @@ -2,7 +2,13 @@ import type { ReactNode } from 'react' import { fireEvent, render, screen } from '@testing-library/react' import CreditsExhaustedAlert from '../credits-exhausted-alert' -const mockTrialCredits = { credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false, nextCreditResetDate: undefined } +const mockTrialCredits = { + credits: 0, + totalCredits: 10_000, + isExhausted: true, + isLoading: false, + nextCreditResetDate: undefined, +} const mockSetShowPricingModal = vi.fn() vi.mock('@/config', async (importOriginal) => { @@ -21,18 +27,14 @@ vi.mock('react-i18next', async (importOriginal) => { useTranslation: () => ({ t: withSelectorKey((key: string) => key), }), - Trans: withSelectorKeyProps(({ - i18nKey, - components, - }: { - i18nKey?: string - components: { upgradeLink: ReactNode } - }) => ( - <> - {i18nKey} - {components.upgradeLink} - </> - )), + Trans: withSelectorKeyProps( + ({ i18nKey, components }: { i18nKey?: string; components: { upgradeLink: ReactNode } }) => ( + <> + {i18nKey} + {components.upgradeLink} + </> + ), + ), } }) @@ -102,7 +104,9 @@ describe('CreditsExhaustedAlert', () => { const { container } = render(<CreditsExhaustedAlert hasApiKeyFallback={false} />) - expect(container.querySelector('.bg-components-progress-error-progress')).toHaveStyle({ width: '100%' }) + expect(container.querySelector('.bg-components-progress-error-progress')).toHaveStyle({ + width: '100%', + }) }) it('should open the pricing modal when the upgrade link is clicked', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx index 2d634d86736e4b..92918d251ec66d 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx @@ -5,14 +5,20 @@ describe('CreditsFallbackAlert', () => { it('should render the credential fallback copy and description when credentials exist', () => { render(<CreditsFallbackAlert hasCredentials />) - expect(screen.getByText('common.modelProvider.card.apiKeyUnavailableFallback')).toBeInTheDocument() - expect(screen.getByText('common.modelProvider.card.apiKeyUnavailableFallbackDescription')).toBeInTheDocument() + expect( + screen.getByText('common.modelProvider.card.apiKeyUnavailableFallback'), + ).toBeInTheDocument() + expect( + screen.getByText('common.modelProvider.card.apiKeyUnavailableFallbackDescription'), + ).toBeInTheDocument() }) it('should render the no-credentials fallback copy without the description', () => { render(<CreditsFallbackAlert hasCredentials={false} />) expect(screen.getByText('common.modelProvider.card.noApiKeysFallback')).toBeInTheDocument() - expect(screen.queryByText('common.modelProvider.card.apiKeyUnavailableFallbackDescription')).not.toBeInTheDocument() + expect( + screen.queryByText('common.modelProvider.card.apiKeyUnavailableFallbackDescription'), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/dialog.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/dialog.spec.tsx index 85ae2d7e52baf4..95dfc9fca18bab 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/dialog.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/dialog.spec.tsx @@ -40,18 +40,38 @@ vi.mock('@langgenius/dify-ui/alert-dialog', () => ({ return <div>{children}</div> }, AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>, - AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>, - AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>, + AlertDialogCancelButton: ({ children }: { children: ReactNode }) => ( + <button type="button">{children}</button> + ), + AlertDialogConfirmButton: ({ + children, + onClick, + }: { + children: ReactNode + onClick?: () => void + }) => ( + <button type="button" onClick={onClick}> + {children} + </button> + ), AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>, AlertDialogDescription: () => <div />, AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>, })) vi.mock('../api-key-section', () => ({ - default: ({ credentials, onDelete }: { credentials: unknown[], onDelete: (credential?: unknown) => void }) => ( + default: ({ + credentials, + onDelete, + }: { + credentials: unknown[] + onDelete: (credential?: unknown) => void + }) => ( <div> <span>{`credentials:${credentials.length}`}</span> - <button type="button" onClick={() => onDelete(undefined)}>delete-undefined</button> + <button type="button" onClick={() => onDelete(undefined)}> + delete-undefined + </button> </div> ), })) @@ -68,20 +88,21 @@ vi.mock('../usage-priority-section', () => ({ default: () => <div>priority section</div>, })) -const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({ - provider: 'test', - custom_configuration: { - available_credentials: undefined, - }, - system_configuration: { - enabled: true, - quota_configurations: [], - current_quota_type: 'trial', - }, - configurate_methods: [], - supported_model_types: [], - ...overrides, -} as unknown as ModelProvider) +const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => + ({ + provider: 'test', + custom_configuration: { + available_credentials: undefined, + }, + system_configuration: { + enabled: true, + quota_configurations: [], + current_quota_type: 'trial', + }, + configurate_methods: [], + supported_model_types: [], + ...overrides, + }) as unknown as ModelProvider const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({ variant: 'api-active', diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/dropdown-content.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/dropdown-content.spec.tsx index f278d40ea82877..618b11a848d3b0 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/dropdown-content.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/dropdown-content.spec.tsx @@ -13,7 +13,12 @@ let mockDeleteCredentialId: string | null = null let mockWorkspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage'] vi.mock('../../use-trial-credits', () => ({ - useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }), + useTrialCredits: () => ({ + credits: 0, + totalCredits: 10_000, + isExhausted: true, + isLoading: false, + }), })) vi.mock('../use-activate-credential', () => ({ @@ -67,13 +72,22 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('../../../model-auth/authorized/credential-item', () => ({ - default: ({ credential, disabled, disableEdit, disableDelete, onItemClick, onEdit, onDelete }: { - credential: { credential_id: string, credential_name: string } + default: ({ + credential, + disabled, + disableEdit, + disableDelete, + onItemClick, + onEdit, + onDelete, + }: { + credential: { credential_id: string; credential_name: string } disabled?: boolean disableEdit?: boolean disableDelete?: boolean @@ -83,30 +97,49 @@ vi.mock('../../../model-auth/authorized/credential-item', () => ({ }) => ( <div data-testid={`credential-${credential.credential_id}`}> <span>{credential.credential_name}</span> - <button data-testid={`click-${credential.credential_id}`} disabled={disabled} onClick={() => onItemClick?.(credential)}>select</button> - <button data-testid={`edit-${credential.credential_id}`} disabled={disabled || disableEdit} onClick={() => onEdit?.(credential)}>edit</button> - <button data-testid={`delete-${credential.credential_id}`} disabled={disabled || disableDelete} onClick={() => onDelete?.(credential)}>delete</button> + <button + data-testid={`click-${credential.credential_id}`} + disabled={disabled} + onClick={() => onItemClick?.(credential)} + > + select + </button> + <button + data-testid={`edit-${credential.credential_id}`} + disabled={disabled || disableEdit} + onClick={() => onEdit?.(credential)} + > + edit + </button> + <button + data-testid={`delete-${credential.credential_id}`} + disabled={disabled || disableDelete} + onClick={() => onDelete?.(credential)} + > + delete + </button> </div> ), })) -const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({ - provider: 'test', - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - current_credential_id: 'cred-1', - current_credential_name: 'My Key', - available_credentials: [ - { credential_id: 'cred-1', credential_name: 'My Key' }, - { credential_id: 'cred-2', credential_name: 'Other Key' }, - ], - }, - system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, - preferred_provider_type: PreferredProviderTypeEnum.system, - configurate_methods: ['predefined-model'], - supported_model_types: ['llm'], - ...overrides, -} as unknown as ModelProvider) +const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => + ({ + provider: 'test', + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: 'cred-1', + current_credential_name: 'My Key', + available_credentials: [ + { credential_id: 'cred-1', credential_name: 'My Key' }, + { credential_id: 'cred-2', credential_name: 'Other Key' }, + ], + }, + system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, + preferred_provider_type: PreferredProviderTypeEnum.system, + configurate_methods: ['predefined-model'], + supported_model_types: ['llm'], + ...overrides, + }) as unknown as ModelProvider const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({ variant: 'api-active', diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/index.spec.tsx index ea281e774fa200..e43b501a0992cc 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/index.spec.tsx @@ -24,19 +24,25 @@ vi.mock('../use-activate-credential', () => ({ })) vi.mock('../../use-trial-credits', () => ({ - useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }), + useTrialCredits: () => ({ + credits: 0, + totalCredits: 10_000, + isExhausted: true, + isLoading: false, + }), })) -const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({ - provider: 'test', - custom_configuration: { - status: CustomConfigurationStatusEnum.active, - available_credentials: [], - }, - system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, - preferred_provider_type: PreferredProviderTypeEnum.system, - ...overrides, -} as unknown as ModelProvider) +const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => + ({ + provider: 'test', + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + available_credentials: [], + }, + system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, + preferred_provider_type: PreferredProviderTypeEnum.system, + ...overrides, + }) as unknown as ModelProvider const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({ variant: 'credits-active', diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx index aec9c9f6911566..d9331e04e8f7d4 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx @@ -20,24 +20,26 @@ vi.mock('../../../hooks', () => ({ useUpdateModelList: () => mockUpdateModelList, })) -const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({ - provider: 'langgenius/openai/openai', - supported_model_types: ['llm', 'text-embedding'], - custom_configuration: { - current_credential_id: 'cred-1', - available_credentials: [ - { credential_id: 'cred-1', credential_name: 'Primary' }, - { credential_id: 'cred-2', credential_name: 'Backup' }, - ], - }, - ...overrides, -} as unknown as ModelProvider) - -const createCredential = (overrides: Partial<Credential> = {}): Credential => ({ - credential_id: 'cred-2', - credential_name: 'Backup', - ...overrides, -} as Credential) +const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => + ({ + provider: 'langgenius/openai/openai', + supported_model_types: ['llm', 'text-embedding'], + custom_configuration: { + current_credential_id: 'cred-1', + available_credentials: [ + { credential_id: 'cred-1', credential_name: 'Primary' }, + { credential_id: 'cred-2', credential_name: 'Backup' }, + ], + }, + ...overrides, + }) as unknown as ModelProvider + +const createCredential = (overrides: Partial<Credential> = {}): Credential => + ({ + credential_id: 'cred-2', + credential_name: 'Backup', + ...overrides, + }) as Credential describe('useActivateCredential', () => { const toastSuccessSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success') diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx index 6bdb510e1ee24b..218ac1db90a0d4 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx @@ -36,19 +36,16 @@ function ApiKeySection({ <div className="rounded-[10px] bg-linear-to-r from-state-base-hover to-transparent p-4"> <div className="flex flex-col gap-1"> <div className="system-sm-medium text-text-secondary"> - {t($ => $['modelProvider.card.noApiKeysTitle'], { ns: 'common' })} + {t(($) => $['modelProvider.card.noApiKeysTitle'], { ns: 'common' })} </div> <div className="system-xs-regular text-text-tertiary"> - {t($ => $['modelProvider.card.noApiKeysDescription'], { ns: 'common' })} + {t(($) => $['modelProvider.card.noApiKeysDescription'], { ns: 'common' })} </div> </div> </div> {!notAllowCustomCredential && canCreateCredential && ( - <Button - onClick={onAdd} - className="w-full" - > - {t($ => $['modelProvider.auth.addApiKey'], { ns: 'common' })} + <Button onClick={onAdd} className="w-full"> + {t(($) => $['modelProvider.auth.addApiKey'], { ns: 'common' })} </Button> )} </div> @@ -59,10 +56,10 @@ function ApiKeySection({ <div className="border-t border-t-divider-subtle"> <div className="px-1"> <div className="pt-3 pr-2 pb-1 pl-7 system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['modelProvider.auth.apiKeys'], { ns: 'common' })} + {t(($) => $['modelProvider.auth.apiKeys'], { ns: 'common' })} </div> <div className="max-h-50 overflow-y-auto"> - {credentials.map(credential => ( + {credentials.map((credential) => ( <CredentialItem key={credential.credential_id} credential={credential} @@ -80,11 +77,8 @@ function ApiKeySection({ </div> {!notAllowCustomCredential && canCreateCredential && ( <div className="p-2"> - <Button - onClick={onAdd} - className="w-full" - > - {t($ => $['modelProvider.auth.addApiKey'], { ns: 'common' })} + <Button onClick={onAdd} className="w-full"> + {t(($) => $['modelProvider.auth.addApiKey'], { ns: 'common' })} </Button> </div> )} diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/button-config.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/button-config.ts index 0c3be4af9a59db..d0c299dc097e99 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/button-config.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/button-config.ts @@ -4,21 +4,21 @@ import type { CardVariant } from '../use-credential-panel-state' export function getButtonConfig(variant: CardVariant, hasCredentials: boolean, t: TFunction) { if (variant === 'api-required-add') { return { - text: t($ => $['modelProvider.auth.addApiKey'], { ns: 'common' }), + text: t(($) => $['modelProvider.auth.addApiKey'], { ns: 'common' }), variant: 'primary' as const, } } if (variant === 'api-required-configure') { return { - text: t($ => $['operation.config'], { ns: 'common' }), + text: t(($) => $['operation.config'], { ns: 'common' }), variant: 'primary' as const, } } const text = hasCredentials - ? t($ => $['operation.config'], { ns: 'common' }) - : t($ => $['modelProvider.auth.addApiKey'], { ns: 'common' }) + ? t(($) => $['operation.config'], { ns: 'common' }) + : t(($) => $['modelProvider.auth.addApiKey'], { ns: 'common' }) return { text, variant: 'secondary' as const } } diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.stories.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.stories.tsx index b5e8f64d6995d5..98f86ab61d46d4 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.stories.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.stories.tsx @@ -21,7 +21,10 @@ function createSeededQueryClient(overrides?: Partial<ICurrentWorkspace>) { const qc = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, retry: false } }, }) - qc.setQueryData(consoleQuery.workspaces.current.post.queryKey(), { ...baseWorkspace, ...overrides }) + qc.setQueryData(consoleQuery.workspaces.current.post.queryKey(), { + ...baseWorkspace, + ...overrides, + }) return qc } @@ -32,7 +35,8 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Alert shown when trial credits are exhausted, with usage progress bar and upgrade link.', + component: + 'Alert shown when trial credits are exhausted, with usage progress bar and upgrade link.', }, }, }, @@ -68,7 +72,9 @@ export const PartialUsage: Story = { decorators: [ (Story) => { return ( - <QueryClientProvider client={createSeededQueryClient({ trial_credits: 500, trial_credits_used: 480 })}> + <QueryClientProvider + client={createSeededQueryClient({ trial_credits: 500, trial_credits_used: 480 })} + > <div className="w-[320px]"> <Story /> </div> diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.tsx index 0b1576e5cca9c6..2d686cfb769edb 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.tsx @@ -12,9 +12,13 @@ type CreditsExhaustedAlertProps = { totalCredits?: number } -export default function CreditsExhaustedAlert({ hasApiKeyFallback, credits: creditsOverride, totalCredits: totalCreditsOverride }: CreditsExhaustedAlertProps) { +export default function CreditsExhaustedAlert({ + hasApiKeyFallback, + credits: creditsOverride, + totalCredits: totalCreditsOverride, +}: CreditsExhaustedAlertProps) { const { t } = useTranslation() - const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal) + const setShowPricingModal = useModalContextSelector((s) => s.setShowPricingModal) const trialCredits = useTrialCredits() const credits = creditsOverride ?? trialCredits.credits const totalCredits = totalCreditsOverride ?? trialCredits.totalCredits @@ -35,22 +39,22 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback, credits: cred <div className="mx-2 mt-0.5 mb-1 rounded-lg bg-background-section-burn p-3"> <div className="flex flex-col gap-1"> <div className="system-sm-medium text-text-primary"> - {t($ => $[titleKey], { ns: 'common' })} + {t(($) => $[titleKey], { ns: 'common' })} </div> <div className="system-xs-regular text-text-tertiary"> <Trans - i18nKey={$ => $[descriptionKey]} + i18nKey={($) => $[descriptionKey]} ns="common" components={{ - upgradeLink: IS_CLOUD_EDITION - ? ( - <button - type="button" - className="cursor-pointer border-0 bg-transparent p-0 text-left system-xs-medium text-text-accent" - onClick={() => setShowPricingModal()} - /> - ) - : <span />, + upgradeLink: IS_CLOUD_EDITION ? ( + <button + type="button" + className="cursor-pointer border-0 bg-transparent p-0 text-left system-xs-medium text-text-accent" + onClick={() => setShowPricingModal()} + /> + ) : ( + <span /> + ), }} /> </div> @@ -58,15 +62,13 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback, credits: cred <Meter value={meterValue} max={meterMax} className="mt-3 flex flex-col gap-1"> <div className="flex items-center justify-between"> <MeterLabel className="system-xs-medium text-text-tertiary"> - {t($ => $['modelProvider.card.usageLabel'], { ns: 'common' })} + {t(($) => $['modelProvider.card.usageLabel'], { ns: 'common' })} </MeterLabel> <div className="flex items-center gap-0.5 system-xs-regular text-text-tertiary"> {/* eslint-disable-next-line hyoban/prefer-tailwind-icons -- This generated icon class is not available to Tailwind. */} <CreditsCoin className="size-3" /> <span> - {formatNumber(usedCredits)} - / - {formatNumber(totalCredits)} + {formatNumber(usedCredits)}/{formatNumber(totalCredits)} </span> </div> </div> diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-fallback-alert.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-fallback-alert.tsx index ed74b7ed5f0f4e..61e0ab04bc8e7d 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-fallback-alert.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-fallback-alert.tsx @@ -15,11 +15,13 @@ export default function CreditsFallbackAlert({ hasCredentials }: CreditsFallback <div className="mx-2 mt-0.5 mb-1 rounded-lg bg-background-section-burn p-3"> <div className="flex flex-col gap-1"> <div className="system-sm-medium text-text-primary"> - {t($ => $[titleKey], { ns: 'common' })} + {t(($) => $[titleKey], { ns: 'common' })} </div> {hasCredentials && ( <div className="system-xs-regular text-text-tertiary"> - {t($ => $['modelProvider.card.apiKeyUnavailableFallbackDescription'], { ns: 'common' })} + {t(($) => $['modelProvider.card.apiKeyUnavailableFallbackDescription'], { + ns: 'common', + })} </div> )} </div> diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx index 259f85267142d3..04f65fb36de508 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.tsx @@ -52,37 +52,41 @@ function DropdownContent({ const { selectedCredentialId, isActivating, activate } = useActivateCredential(provider) - const handleEdit = useCallback((credential?: Credential) => { - if (credential ? !canManageCredential : !canCreateCredential) - return + const handleEdit = useCallback( + (credential?: Credential) => { + if (credential ? !canManageCredential : !canCreateCredential) return - handleOpenModal(credential) - onClose() - }, [canCreateCredential, canManageCredential, handleOpenModal, onClose]) + handleOpenModal(credential) + onClose() + }, + [canCreateCredential, canManageCredential, handleOpenModal, onClose], + ) - const handleDelete = useCallback((credential?: Credential) => { - if (!canManageCredential) - return + const handleDelete = useCallback( + (credential?: Credential) => { + if (!canManageCredential) return - if (credential) - openConfirmDelete(credential) - }, [canManageCredential, openConfirmDelete]) + if (credential) openConfirmDelete(credential) + }, + [canManageCredential, openConfirmDelete], + ) const handleAdd = useCallback(() => { - if (!canCreateCredential) - return + if (!canCreateCredential) return handleOpenModal() onClose() }, [canCreateCredential, handleOpenModal, onClose]) const showCreditsExhaustedAlert = state.isCreditsExhausted && state.supportsCredits - const hasApiKeyFallback = state.variant === 'api-fallback' - || (state.variant === 'api-active' && state.priority === 'apiKey') - const showCreditsFallbackAlert = state.priority === 'apiKey' - && state.supportsCredits - && !state.isCreditsExhausted - && state.variant !== 'api-active' + const hasApiKeyFallback = + state.variant === 'api-fallback' || + (state.variant === 'api-active' && state.priority === 'apiKey') + const showCreditsFallbackAlert = + state.priority === 'apiKey' && + state.supportsCredits && + !state.isCreditsExhausted && + state.variant !== 'api-active' return ( <> @@ -94,9 +98,7 @@ function DropdownContent({ onSelect={onChangePriority} /> )} - {showCreditsFallbackAlert && ( - <CreditsFallbackAlert hasCredentials={state.hasCredentials} /> - )} + {showCreditsFallbackAlert && <CreditsFallbackAlert hasCredentials={state.hasCredentials} />} {showCreditsExhaustedAlert && ( <CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} /> )} @@ -114,23 +116,22 @@ function DropdownContent({ <AlertDialog open={!!deleteCredentialId} onOpenChange={(open) => { - if (!open) - closeConfirmDelete() + if (!open) closeConfirmDelete() }} > <AlertDialogContent> <div className="p-6 pb-0"> <AlertDialogTitle className="system-xl-semibold text-text-primary"> - {t($ => $['modelProvider.confirmDelete'], { ns: 'common' })} + {t(($) => $['modelProvider.confirmDelete'], { ns: 'common' })} </AlertDialogTitle> <AlertDialogDescription className="mt-1 system-sm-regular text-text-secondary" /> </div> <AlertDialogActions> <AlertDialogCancelButton disabled={doingAction}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton disabled={doingAction} onClick={handleConfirmDelete}> - {t($ => $['operation.delete'], { ns: 'common' })} + {t(($) => $['operation.delete'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx index 8d675f8ad6f1c4..99b634a77edf9d 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx @@ -1,11 +1,7 @@ import type { ModelProvider, PreferredProviderTypeEnum } from '../../declarations' import type { CredentialPanelState } from '../use-credential-panel-state' import { Button } from '@langgenius/dify-ui/button' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { getButtonConfig } from './button-config' @@ -34,7 +30,7 @@ function ModelAuthDropdown({ return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <Button className="flex w-full min-w-0 justify-center px-2" size="small" @@ -42,11 +38,9 @@ function ModelAuthDropdown({ title={buttonConfig.text} > <span className="mr-1 i-ri-equalizer-2-line size-3.5 shrink-0" /> - <span className="min-w-0 truncate"> - {buttonConfig.text} - </span> + <span className="min-w-0 truncate">{buttonConfig.text}</span> </Button> - )} + } /> <PopoverContent placement="bottom-end"> <DropdownContent diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx index f3c3636d5499fa..9b2907be3cd79a 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx @@ -15,26 +15,30 @@ const options = [ { key: PreferredProviderTypeEnum.custom, labelKey: 'modelProvider.card.apiKeyOption' }, ] as const -export default function UsagePrioritySection({ value, disabled, onSelect }: UsagePrioritySectionProps) { +export default function UsagePrioritySection({ + value, + disabled, + onSelect, +}: UsagePrioritySectionProps) { const { t } = useTranslation() - const selectedKey = value === 'credits' - ? PreferredProviderTypeEnum.system - : PreferredProviderTypeEnum.custom - const usagePriorityTip = t($ => $['modelProvider.card.usagePriorityTip'], { ns: 'common' }) + const selectedKey = + value === 'credits' ? PreferredProviderTypeEnum.system : PreferredProviderTypeEnum.custom + const usagePriorityTip = t(($) => $['modelProvider.card.usagePriorityTip'], { ns: 'common' }) return ( <div className="p-1"> <div className="flex items-center gap-1 rounded-lg p-1"> <div className="shrink-0 px-0.5 py-1"> - <span aria-hidden="true" className="i-ri-arrow-up-double-line block size-4 text-text-tertiary" /> + <span + aria-hidden="true" + className="i-ri-arrow-up-double-line block size-4 text-text-tertiary" + /> </div> <div className="flex min-w-0 flex-1 items-center gap-0.5 py-0.5"> <span className="truncate system-sm-medium text-text-secondary"> - {t($ => $['modelProvider.card.usagePriority'], { ns: 'common' })} + {t(($) => $['modelProvider.card.usagePriority'], { ns: 'common' })} </span> - <Infotip aria-label={usagePriorityTip}> - {usagePriorityTip} - </Infotip> + <Infotip aria-label={usagePriorityTip}>{usagePriorityTip}</Infotip> </div> <div className="flex shrink-0 items-center gap-1"> {options.map((option) => { @@ -54,7 +58,7 @@ export default function UsagePrioritySection({ value, disabled, onSelect }: Usag disabled={disabled} onClick={() => onSelect(option.key)} > - {t($ => $[option.labelKey], { ns: 'common' })} + {t(($) => $[option.labelKey], { ns: 'common' })} </button> ) })} diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts index 49ed20e434714a..2c959336168ab0 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts @@ -17,22 +17,27 @@ export function useActivateCredential(provider: ModelProvider) { selectedIdRef.current = selectedCredentialId const supportedModelTypesRef = useRef(provider.supported_model_types) supportedModelTypesRef.current = provider.supported_model_types - const activate = useCallback((credential: Credential) => { - if (credential.credential_id === selectedIdRef.current) - return - setOptimisticId(credential.credential_id) - mutate({ credential_id: credential.credential_id }, { - onSuccess: () => { - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - updateModelProviders() - supportedModelTypesRef.current.forEach(type => updateModelList(type)) - }, - onError: () => { - setOptimisticId(undefined) - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - }, - }) - }, [mutate, t, updateModelProviders, updateModelList]) + const activate = useCallback( + (credential: Credential) => { + if (credential.credential_id === selectedIdRef.current) return + setOptimisticId(credential.credential_id) + mutate( + { credential_id: credential.credential_id }, + { + onSuccess: () => { + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + updateModelProviders() + supportedModelTypesRef.current.forEach((type) => updateModelList(type)) + }, + onError: () => { + setOptimisticId(undefined) + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + }, + }, + ) + }, + [mutate, t, updateModelProviders, updateModelList], + ) return { selectedCredentialId, isActivating: isPending, 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 4d458dca8532ef..76d84e772da6f2 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 @@ -29,53 +29,84 @@ type ModelListItemProps = { onModifyLoadBalancing?: (model: ModelItem) => void } -const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoadBalancing }: ModelListItemProps) => { +const ModelListItem = ({ + model, + provider, + isConfigurable, + onChange, + onModifyLoadBalancing, +}: ModelListItemProps) => { const { t } = useTranslation() const { plan } = useProviderContext() - const modelLoadBalancingEnabled = useProviderContextSelector(state => state.modelLoadBalancingEnabled) + const modelLoadBalancingEnabled = useProviderContextSelector( + (state) => state.modelLoadBalancingEnabled, + ) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config') const queryClient = useQueryClient() const updateModelList = useUpdateModelList() - const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ - input: { - params: { - provider: provider.provider, + const modelProviderModelListQueryKey = + consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ + input: { + params: { + provider: provider.provider, + }, }, - }, - }) + }) - const toggleModelEnablingStatus = useCallback(async (enabled: boolean) => { - if (enabled) - await enableModel(`/workspaces/current/model-providers/${provider.provider}/models/enable`, { model: model.model, model_type: model.model_type }) - else - await disableModel(`/workspaces/current/model-providers/${provider.provider}/models/disable`, { model: model.model, model_type: model.model_type }) + const toggleModelEnablingStatus = useCallback( + async (enabled: boolean) => { + if (enabled) + await enableModel( + `/workspaces/current/model-providers/${provider.provider}/models/enable`, + { model: model.model, model_type: model.model_type }, + ) + else + await disableModel( + `/workspaces/current/model-providers/${provider.provider}/models/disable`, + { model: model.model, model_type: model.model_type }, + ) - queryClient.invalidateQueries({ - queryKey: modelProviderModelListQueryKey, - exact: true, - refetchType: 'none', - }) - updateModelList(model.model_type) - onChange?.(provider.provider) - }, [model.model, model.model_type, modelProviderModelListQueryKey, onChange, provider.provider, queryClient, updateModelList]) + queryClient.invalidateQueries({ + queryKey: modelProviderModelListQueryKey, + exact: true, + refetchType: 'none', + }) + updateModelList(model.model_type) + onChange?.(provider.provider) + }, + [ + model.model, + model.model_type, + modelProviderModelListQueryKey, + onChange, + provider.provider, + queryClient, + updateModelList, + ], + ) - const { run: debouncedToggleModelEnablingStatus } = useDebounceFn(toggleModelEnablingStatus, { wait: 500 }) + const { run: debouncedToggleModelEnablingStatus } = useDebounceFn(toggleModelEnablingStatus, { + wait: 500, + }) - const onEnablingStateChange = useCallback(async (value: boolean) => { - debouncedToggleModelEnablingStatus(value) - }, [debouncedToggleModelEnablingStatus]) + const onEnablingStateChange = useCallback( + async (value: boolean) => { + debouncedToggleModelEnablingStatus(value) + }, + [debouncedToggleModelEnablingStatus], + ) return ( <div key={`${model.model}-${model.fetch_from}`} - className={cn('group flex h-8 items-center rounded-lg pr-2.5 pl-2', isConfigurable && 'hover:bg-components-panel-on-panel-item-bg-hover', model.deprecated && 'opacity-60')} + className={cn( + 'group flex h-8 items-center rounded-lg pr-2.5 pl-2', + isConfigurable && 'hover:bg-components-panel-on-panel-item-bg-hover', + model.deprecated && 'opacity-60', + )} > - <ModelIcon - className="mr-2 shrink-0" - provider={provider} - modelName={model.model} - /> + <ModelIcon className="mr-2 shrink-0" provider={provider} modelName={model.model} /> <ModelName className="grow system-md-regular text-text-secondary" modelItem={model} @@ -85,44 +116,53 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad showContextSize showFeatures showFeaturesLabel - > - </ModelName> + ></ModelName> <div className="flex shrink-0 items-center"> - {modelLoadBalancingEnabled && !model.deprecated && model.load_balancing_enabled && !model.has_invalid_load_balancing_configs && ( - <Badge className="mr-1 h-4.5 w-4.5 items-center justify-center border-text-accent-secondary p-0"> - <Balance className="size-3 text-text-accent-secondary" /> - </Badge> - )} - { - (canConfigureModels && (modelLoadBalancingEnabled || plan.type === Plan.sandbox) && !model.deprecated && [ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status)) && ( + {modelLoadBalancingEnabled && + !model.deprecated && + model.load_balancing_enabled && + !model.has_invalid_load_balancing_configs && ( + <Badge className="mr-1 h-4.5 w-4.5 items-center justify-center border-text-accent-secondary p-0"> + <Balance className="size-3 text-text-accent-secondary" /> + </Badge> + )} + {canConfigureModels && + (modelLoadBalancingEnabled || plan.type === Plan.sandbox) && + !model.deprecated && + [ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status) && ( <ConfigModel onClick={() => onModifyLoadBalancing?.(model)} loadBalancingEnabled={model.load_balancing_enabled} loadBalancingInvalid={model.has_invalid_load_balancing_configs} credentialRemoved={model.status === ModelStatusEnum.credentialRemoved} /> + )} + {model.deprecated ? ( + <Popover> + <PopoverTrigger + nativeButton={false} + openOnHover + render={ + <span> + <Switch checked={false} disabled size="md" /> + </span> + } + /> + <PopoverContent popupClassName="px-3 py-2 font-semibold system-xs-regular text-text-tertiary"> + {t(($) => $['modelProvider.modelHasBeenDeprecated'], { ns: 'common' })} + </PopoverContent> + </Popover> + ) : ( + canConfigureModels && ( + <Switch + className="ml-2" + checked={model?.status === ModelStatusEnum.active} + disabled={![ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status)} + size="md" + onCheckedChange={onEnablingStateChange} + /> ) - } - { - model.deprecated - ? ( - <Popover> - <PopoverTrigger nativeButton={false} openOnHover render={<span><Switch checked={false} disabled size="md" /></span>} /> - <PopoverContent popupClassName="px-3 py-2 font-semibold system-xs-regular text-text-tertiary"> - {t($ => $['modelProvider.modelHasBeenDeprecated'], { ns: 'common' })} - </PopoverContent> - </Popover> - ) - : (canConfigureModels && ( - <Switch - className="ml-2" - checked={model?.status === ModelStatusEnum.active} - disabled={![ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status)} - size="md" - onCheckedChange={onEnablingStateChange} - /> - )) - } + )} </div> </div> ) 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 ca14f727d371f7..298210b4faa6d8 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 @@ -1,9 +1,5 @@ import type { FC } from 'react' -import type { - Credential, - ModelItem, - ModelProvider, -} from '../declarations' +import type { Credential, ModelItem, ModelProvider } from '../declarations' import { useAtomValue } from 'jotai' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -14,9 +10,7 @@ import { import { useModalContextSelector } from '@/context/modal-context' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { hasPermission } from '@/utils/permission' -import { - ConfigurationMethodEnum, -} from '../declarations' +import { ConfigurationMethodEnum } from '../declarations' // import Tab from './tab' import ModelListItem from './model-list-item' @@ -26,29 +20,31 @@ type ModelListProps = { onCollapse: () => void onChange?: (provider: string) => void } -const ModelList: FC<ModelListProps> = ({ - provider, - models, - onCollapse, - onChange, -}) => { +const ModelList: FC<ModelListProps> = ({ provider, models, onCollapse, onChange }) => { const { t } = useTranslation() - const configurativeMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote) + const configurativeMethods = provider.configurate_methods.filter( + (method) => method !== ConfigurationMethodEnum.fetchFromRemote, + ) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config') const isConfigurable = configurativeMethods.includes(ConfigurationMethodEnum.customizableModel) - const setShowModelLoadBalancingModal = useModalContextSelector(state => state.setShowModelLoadBalancingModal) - const onModifyLoadBalancing = useCallback((model: ModelItem, credential?: Credential) => { - setShowModelLoadBalancingModal({ - provider, - credential, - configurateMethod: model.fetch_from, - model: model!, - open: !!model, - onClose: () => setShowModelLoadBalancingModal(null), - onSave: onChange, - }) - }, [onChange, provider, setShowModelLoadBalancingModal]) + const setShowModelLoadBalancingModal = useModalContextSelector( + (state) => state.setShowModelLoadBalancingModal, + ) + const onModifyLoadBalancing = useCallback( + (model: ModelItem, credential?: Credential) => { + setShowModelLoadBalancingModal({ + provider, + credential, + configurateMethod: model.fetch_from, + model: model!, + open: !!model, + onClose: () => setShowModelLoadBalancingModal(null), + onSave: onChange, + }) + }, + [onChange, provider, setShowModelLoadBalancingModal], + ) return ( <div className="rounded-b-xl px-2 pb-2"> @@ -56,7 +52,7 @@ const ModelList: FC<ModelListProps> = ({ <div className="flex items-center pr-0.75 pl-1"> <span className="group mr-2 flex shrink-0 items-center"> <span className="inline-flex h-6 items-center pr-1.5 pl-1 system-xs-medium text-text-tertiary group-hover:hidden"> - {t($ => $['modelProvider.modelsNum'], { ns: 'common', num: models.length })} + {t(($) => $['modelProvider.modelsNum'], { ns: 'common', num: models.length })} <span className="mr-0.5 i-ri-arrow-right-s-line size-4 rotate-90" /> </span> <button @@ -64,40 +60,36 @@ const ModelList: FC<ModelListProps> = ({ className="hidden h-6 cursor-pointer items-center rounded-lg border-none bg-state-base-hover pr-1.5 pl-1 system-xs-medium text-text-tertiary group-hover:inline-flex" onClick={() => onCollapse()} > - {t($ => $['modelProvider.modelsNum'], { ns: 'common', num: models.length })} + {t(($) => $['modelProvider.modelsNum'], { ns: 'common', num: models.length })} <span className="mr-0.5 i-ri-arrow-right-s-line size-4 rotate-90" /> </button> </span> - { - isConfigurable && canConfigureModels && ( - <div className="flex grow justify-end"> - <ManageCustomModelCredentials - provider={provider} - currentCustomConfigurationModelFixedFields={undefined} - /> - <AddCustomModel - provider={provider} - configurationMethod={ConfigurationMethodEnum.customizableModel} - currentCustomConfigurationModelFixedFields={undefined} - /> - </div> - ) - } + {isConfigurable && canConfigureModels && ( + <div className="flex grow justify-end"> + <ManageCustomModelCredentials + provider={provider} + currentCustomConfigurationModelFixedFields={undefined} + /> + <AddCustomModel + provider={provider} + configurationMethod={ConfigurationMethodEnum.customizableModel} + currentCustomConfigurationModelFixedFields={undefined} + /> + </div> + )} </div> - { - models.map(model => ( - <ModelListItem - key={`${model.model}-${model.model_type}-${model.fetch_from}`} - {...{ - model, - provider, - isConfigurable, - onChange, - onModifyLoadBalancing, - }} - /> - )) - } + {models.map((model) => ( + <ModelListItem + key={`${model.model}-${model.model_type}-${model.fetch_from}`} + {...{ + model, + provider, + isConfigurable, + onChange, + onModifyLoadBalancing, + }} + /> + ))} </div> </div> ) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx index 7e5013515e82d2..2e1523fce7ad63 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx @@ -53,8 +53,11 @@ const ModelLoadBalancingConfigs = ({ onRemove, }: ModelLoadBalancingConfigsProps) => { const { t } = useTranslation() - const providerFormSchemaPredefined = configurationMethod === ConfigurationMethodEnum.predefinedModel - const modelLoadBalancingEnabled = useProviderContextSelector(state => state.modelLoadBalancingEnabled) + const providerFormSchemaPredefined = + configurationMethod === ConfigurationMethodEnum.predefinedModel + const modelLoadBalancingEnabled = useProviderContextSelector( + (state) => state.modelLoadBalancingEnabled, + ) const updateConfigEntry = useCallback( ( @@ -62,14 +65,11 @@ const ModelLoadBalancingConfigs = ({ modifier: (entry: ModelLoadBalancingConfigEntry) => ModelLoadBalancingConfigEntry | undefined, ) => { setDraftConfig((prev) => { - if (!prev) - return prev + if (!prev) return prev const newConfigs = [...prev.configs] const modifiedConfig = modifier(newConfigs[index]!) - if (modifiedConfig) - newConfigs[index] = modifiedConfig - else - newConfigs.splice(index, 1) + if (modifiedConfig) newConfigs[index] = modifiedConfig + else newConfigs.splice(index, 1) return { ...prev, configs: newConfigs, @@ -79,71 +79,97 @@ const ModelLoadBalancingConfigs = ({ [setDraftConfig], ) - const addConfigEntry = useCallback((credential: Credential) => { - setDraftConfig((prev: any) => { - if (!prev) - return prev - return { - ...prev, - configs: [...prev.configs, { - credential_id: credential.credential_id, - enabled: true, - name: credential.credential_name, - }], - } - }) - }, [setDraftConfig]) - - const toggleModalBalancing = useCallback((enabled: boolean) => { - if ((modelLoadBalancingEnabled || !enabled) && draftConfig) { - setDraftConfig({ - ...draftConfig, - enabled, + const addConfigEntry = useCallback( + (credential: Credential) => { + setDraftConfig((prev: any) => { + if (!prev) return prev + return { + ...prev, + configs: [ + ...prev.configs, + { + credential_id: credential.credential_id, + enabled: true, + name: credential.credential_name, + }, + ], + } }) - } - }, [draftConfig, modelLoadBalancingEnabled, setDraftConfig]) + }, + [setDraftConfig], + ) - const toggleConfigEntryEnabled = useCallback((index: number, state?: boolean) => { - updateConfigEntry(index, entry => ({ - ...entry, - enabled: typeof state === 'boolean' ? state : !entry.enabled, - })) - }, [updateConfigEntry]) + const toggleModalBalancing = useCallback( + (enabled: boolean) => { + if ((modelLoadBalancingEnabled || !enabled) && draftConfig) { + setDraftConfig({ + ...draftConfig, + enabled, + }) + } + }, + [draftConfig, modelLoadBalancingEnabled, setDraftConfig], + ) - const clearCountdown = useCallback((index: number) => { - updateConfigEntry(index, ({ ttl: _, ...entry }) => { - return { + const toggleConfigEntryEnabled = useCallback( + (index: number, state?: boolean) => { + updateConfigEntry(index, (entry) => ({ ...entry, - in_cooldown: false, - } - }) - }, [updateConfigEntry]) + enabled: typeof state === 'boolean' ? state : !entry.enabled, + })) + }, + [updateConfigEntry], + ) + + const clearCountdown = useCallback( + (index: number) => { + updateConfigEntry(index, ({ ttl: _, ...entry }) => { + return { + ...entry, + in_cooldown: false, + } + }) + }, + [updateConfigEntry], + ) const validDraftConfigList = useMemo(() => { - if (!draftConfig) - return [] + if (!draftConfig) return [] return draftConfig.configs }, [draftConfig]) - const handleUpdate = useCallback((payload?: any, formValues?: Record<string, any>) => { - onUpdate?.(payload, formValues) - }, [onUpdate]) + const handleUpdate = useCallback( + (payload?: any, formValues?: Record<string, any>) => { + onUpdate?.(payload, formValues) + }, + [onUpdate], + ) - const handleRemove = useCallback((credentialId: string) => { - const index = draftConfig?.configs.findIndex(item => item.credential_id === credentialId && item.name !== '__inherit__') - if (typeof index === 'number' && index > -1) - updateConfigEntry(index, () => undefined) - onRemove?.(credentialId) - }, [draftConfig?.configs, updateConfigEntry, onRemove]) + const handleRemove = useCallback( + (credentialId: string) => { + const index = draftConfig?.configs.findIndex( + (item) => item.credential_id === credentialId && item.name !== '__inherit__', + ) + if (typeof index === 'number' && index > -1) updateConfigEntry(index, () => undefined) + onRemove?.(credentialId) + }, + [draftConfig?.configs, updateConfigEntry, onRemove], + ) - if (!draftConfig) - return null + if (!draftConfig) return null return ( <> <div - className={cn('min-h-16 rounded-xl border bg-components-panel-bg transition-colors', (withSwitch || !draftConfig.enabled) ? 'border-components-panel-border' : 'border-util-colors-blue-blue-600', (withSwitch || draftConfig.enabled) ? 'cursor-default' : 'cursor-pointer', className)} - onClick={(!withSwitch && !draftConfig.enabled) ? () => toggleModalBalancing(true) : undefined} + className={cn( + 'min-h-16 rounded-xl border bg-components-panel-bg transition-colors', + withSwitch || !draftConfig.enabled + ? 'border-components-panel-border' + : 'border-util-colors-blue-blue-600', + withSwitch || draftConfig.enabled ? 'cursor-default' : 'cursor-pointer', + className, + )} + onClick={!withSwitch && !draftConfig.enabled ? () => toggleModalBalancing(true) : undefined} data-testid="load-balancing-main-panel" > <div className="flex items-center gap-2 px-[15px] py-3 select-none"> @@ -152,68 +178,76 @@ const ModelLoadBalancingConfigs = ({ </div> <div className="grow"> <div className="flex items-center gap-1 text-sm text-text-primary"> - {t($ => $['modelProvider.loadBalancing'], { ns: 'common' })} + {t(($) => $['modelProvider.loadBalancing'], { ns: 'common' })} <Infotip - aria-label={t($ => $['modelProvider.loadBalancingInfo'], { ns: 'common' })} + aria-label={t(($) => $['modelProvider.loadBalancingInfo'], { ns: 'common' })} className="size-3" iconSize="small" popupClassName="max-w-[300px]" > - {t($ => $['modelProvider.loadBalancingInfo'], { ns: 'common' })} + {t(($) => $['modelProvider.loadBalancingInfo'], { ns: 'common' })} </Infotip> </div> - <div className="text-xs text-text-tertiary">{t($ => $['modelProvider.loadBalancingDescription'], { ns: 'common' })}</div> + <div className="text-xs text-text-tertiary"> + {t(($) => $['modelProvider.loadBalancingDescription'], { ns: 'common' })} + </div> </div> - { - withSwitch && ( - <Switch - checked={Boolean(draftConfig.enabled)} - size="lg" - className="ml-3 justify-self-end" - disabled={!modelLoadBalancingEnabled && !draftConfig.enabled} - onCheckedChange={value => toggleModalBalancing(value)} - data-testid="load-balancing-switch-main" - /> - ) - } + {withSwitch && ( + <Switch + checked={Boolean(draftConfig.enabled)} + size="lg" + className="ml-3 justify-self-end" + disabled={!modelLoadBalancingEnabled && !draftConfig.enabled} + onCheckedChange={(value) => toggleModalBalancing(value)} + data-testid="load-balancing-switch-main" + /> + )} </div> {draftConfig.enabled && ( <div className="flex flex-col gap-1 px-3 pb-3"> {validDraftConfigList.map((config, index) => { const isProviderManaged = config.name === '__inherit__' - const credential = modelCredential.available_credentials.find(c => c.credential_id === config.credential_id) + const credential = modelCredential.available_credentials.find( + (c) => c.credential_id === config.credential_id, + ) return ( - <div key={config.id || index} className="group flex h-10 items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3 shadow-xs"> + <div + key={config.id || index} + className="group flex h-10 items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3 shadow-xs" + > <div className="flex grow items-center"> <div className="mr-2 flex size-3 items-center justify-center"> - {(config.in_cooldown && Boolean(config.ttl)) - ? ( - <CooldownTimer secondsRemaining={config.ttl} onFinish={() => clearCountdown(index)} /> - ) - : ( - <Tooltip> - <TooltipTrigger - render={( - <StatusDot status={credential?.not_allowed_to_use ? 'disabled' : 'success'} /> - )} + {config.in_cooldown && Boolean(config.ttl) ? ( + <CooldownTimer + secondsRemaining={config.ttl} + onFinish={() => clearCountdown(index)} + /> + ) : ( + <Tooltip> + <TooltipTrigger + render={ + <StatusDot + status={credential?.not_allowed_to_use ? 'disabled' : 'success'} /> - <TooltipContent> - {t($ => $['modelProvider.apiKeyStatusNormal'], { ns: 'common' })} - </TooltipContent> - </Tooltip> - )} + } + /> + <TooltipContent> + {t(($) => $['modelProvider.apiKeyStatusNormal'], { ns: 'common' })} + </TooltipContent> + </Tooltip> + )} </div> <div className="mr-1 text-[13px] text-text-secondary"> - {isProviderManaged ? t($ => $['modelProvider.defaultConfig'], { ns: 'common' }) : config.name} + {isProviderManaged + ? t(($) => $['modelProvider.defaultConfig'], { ns: 'common' }) + : config.name} </div> {isProviderManaged && providerFormSchemaPredefined && ( - <Badge className="ml-2">{t($ => $['modelProvider.providerManaged'], { ns: 'common' })}</Badge> + <Badge className="ml-2"> + {t(($) => $['modelProvider.providerManaged'], { ns: 'common' })} + </Badge> )} - { - credential?.from_enterprise && ( - <Badge className="ml-2">Enterprise</Badge> - ) - } + {credential?.from_enterprise && <Badge className="ml-2">Enterprise</Badge>} </div> <div className="flex items-center gap-1"> {!isProviderManaged && ( @@ -221,7 +255,7 @@ const ModelLoadBalancingConfigs = ({ <div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <Tooltip> <TooltipTrigger - render={( + render={ <span className="flex size-8 cursor-pointer items-center justify-center rounded-lg bg-components-button-secondary-bg text-text-tertiary transition-colors hover:bg-components-button-secondary-bg-hover" onClick={() => updateConfigEntry(index, () => undefined)} @@ -229,30 +263,28 @@ const ModelLoadBalancingConfigs = ({ > <div className="i-ri-indeterminate-circle-line size-4" /> </span> - )} + } /> <TooltipContent> - {t($ => $['operation.remove'], { ns: 'common' })} + {t(($) => $['operation.remove'], { ns: 'common' })} </TooltipContent> </Tooltip> </div> </> )} - { - (config.credential_id || config.name === '__inherit__') && ( - <> - <span className="mr-2 h-3 border-r border-r-divider-subtle" /> - <Switch - checked={credential?.not_allowed_to_use ? false : Boolean(config.enabled)} - size="md" - className="justify-self-end" - onCheckedChange={value => toggleConfigEntryEnabled(index, value)} - disabled={credential?.not_allowed_to_use} - data-testid={`load-balancing-switch-${config.id || index}`} - /> - </> - ) - } + {(config.credential_id || config.name === '__inherit__') && ( + <> + <span className="mr-2 h-3 border-r border-r-divider-subtle" /> + <Switch + checked={credential?.not_allowed_to_use ? false : Boolean(config.enabled)} + size="md" + className="justify-self-end" + onCheckedChange={(value) => toggleConfigEntryEnabled(index, value)} + disabled={credential?.not_allowed_to_use} + data-testid={`load-balancing-switch-${config.id || index}`} + /> + </> + )} </div> </div> ) @@ -268,23 +300,19 @@ const ModelLoadBalancingConfigs = ({ /> </div> )} - { - draftConfig.enabled && validDraftConfigList.length < 2 && ( - <div className="flex h-[34px] items-center rounded-b-xl border-t border-t-divider-subtle bg-components-panel-bg px-6 text-xs text-text-secondary"> - <div className="mr-1 i-custom-vender-solid-alertsAndFeedback-alert-triangle h-3 w-3 text-[#f79009]" /> - {t($ => $['modelProvider.loadBalancingLeastKeyWarning'], { ns: 'common' })} - </div> - ) - } + {draftConfig.enabled && validDraftConfigList.length < 2 && ( + <div className="flex h-[34px] items-center rounded-b-xl border-t border-t-divider-subtle bg-components-panel-bg px-6 text-xs text-text-secondary"> + <div className="mr-1 i-custom-vender-solid-alertsAndFeedback-alert-triangle h-3 w-3 text-[#f79009]" /> + {t(($) => $['modelProvider.loadBalancingLeastKeyWarning'], { ns: 'common' })} + </div> + )} </div> {!modelLoadBalancingEnabled && !IS_CE_EDITION && ( <GridMask canvasClassName="rounded-xl!"> <div className="mt-2 flex h-14 items-center justify-between rounded-xl border-[0.5px] border-components-panel-border px-4 shadow-md"> - <div - className={cn('text-gradient text-sm/tight font-semibold', s.textGradient)} - > - {t($ => $['modelProvider.upgradeForLoadBalancing'], { ns: 'common' })} + <div className={cn('text-gradient text-sm/tight font-semibold', s.textGradient)}> + {t(($) => $['modelProvider.upgradeForLoadBalancing'], { ns: 'common' })} </div> <UpgradeBtn /> </div> diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx index fd7b16ee15c067..87616f7098d0ad 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx @@ -1,4 +1,11 @@ -import type { Credential, CustomConfigurationModelFixedFields, ModelItem, ModelLoadBalancingConfig, ModelLoadBalancingConfigEntry, ModelProvider } from '../declarations' +import type { + Credential, + CustomConfigurationModelFixedFields, + ModelItem, + ModelLoadBalancingConfig, + ModelLoadBalancingConfigEntry, + ModelProvider, +} from '../declarations' import { AlertDialog, AlertDialogActions, @@ -34,64 +41,106 @@ export type ModelLoadBalancingModalProps = { onSave?: (provider: string) => void } // model balancing config modal -const ModelLoadBalancingModal = ({ provider, configurateMethod, currentCustomConfigurationModelFixedFields, model, credential, open = false, onClose, onSave }: ModelLoadBalancingModalProps) => { +const ModelLoadBalancingModal = ({ + provider, + configurateMethod, + currentCustomConfigurationModelFixedFields, + model, + credential, + open = false, + onClose, + onSave, +}: ModelLoadBalancingModalProps) => { const { t } = useTranslation() - const { doingAction, deleteModel, openConfirmDelete, closeConfirmDelete, handleConfirmDelete } = useAuth(provider, configurateMethod, currentCustomConfigurationModelFixedFields, { - isModelCredential: true, - }) + const { doingAction, deleteModel, openConfirmDelete, closeConfirmDelete, handleConfirmDelete } = + useAuth(provider, configurateMethod, currentCustomConfigurationModelFixedFields, { + isModelCredential: true, + }) const [loading, setLoading] = useState(false) const providerFormSchemaPredefined = configurateMethod === ConfigurationMethodEnum.predefinedModel const configFrom = providerFormSchemaPredefined ? 'predefined-model' : 'custom-model' - const { isLoading, data, refetch } = useGetModelCredential(true, provider.provider, credential?.credential_id, model.model, model.model_type, configFrom) + const { isLoading, data, refetch } = useGetModelCredential( + true, + provider.provider, + credential?.credential_id, + model.model, + model.model_type, + configFrom, + ) const modelCredential = data - const { load_balancing, current_credential_id, available_credentials, current_credential_name } = modelCredential ?? {} + const { load_balancing, current_credential_id, available_credentials, current_credential_name } = + modelCredential ?? {} const originalConfig = load_balancing const [draftConfig, setDraftConfig] = useState<ModelLoadBalancingConfig>() const originalConfigMap = useMemo(() => { - if (!originalConfig) - return {} - return originalConfig?.configs.reduce((prev, config) => { - if (config.id) - prev[config.id] = config - return prev - }, {} as Record<string, ModelLoadBalancingConfigEntry>) + if (!originalConfig) return {} + return originalConfig?.configs.reduce( + (prev, config) => { + if (config.id) prev[config.id] = config + return prev + }, + {} as Record<string, ModelLoadBalancingConfigEntry>, + ) }, [originalConfig]) useEffect(() => { - if (originalConfig) - setDraftConfig(originalConfig) + if (originalConfig) setDraftConfig(originalConfig) }, [originalConfig]) - const toggleModalBalancing = useCallback((enabled: boolean) => { - if (draftConfig) { - setDraftConfig({ - ...draftConfig, - enabled, - }) - } - }, [draftConfig]) + const toggleModalBalancing = useCallback( + (enabled: boolean) => { + if (draftConfig) { + setDraftConfig({ + ...draftConfig, + enabled, + }) + } + }, + [draftConfig], + ) const extendedSecretFormSchemas = useMemo(() => { if (providerFormSchemaPredefined) { - return provider?.provider_credential_schema?.credential_form_schemas?.filter(({ type }) => type === FormTypeEnum.secretInput) ?? [] + return ( + provider?.provider_credential_schema?.credential_form_schemas?.filter( + ({ type }) => type === FormTypeEnum.secretInput, + ) ?? [] + ) } - return provider?.model_credential_schema?.credential_form_schemas?.filter(({ type }) => type === FormTypeEnum.secretInput) ?? [] - }, [provider?.model_credential_schema?.credential_form_schemas, provider?.provider_credential_schema?.credential_form_schemas, providerFormSchemaPredefined]) - const encodeConfigEntrySecretValues = useCallback((entry: ModelLoadBalancingConfigEntry) => { - const result = { ...entry } - extendedSecretFormSchemas.forEach(({ variable }) => { - if (entry.id && result.credentials[variable] === originalConfigMap[entry.id]?.credentials?.[variable]) - result.credentials[variable] = '[__HIDDEN__]' - }) - return result - }, [extendedSecretFormSchemas, originalConfigMap]) - const { mutateAsync: updateModelLoadBalancingConfig } = useUpdateModelLoadBalancingConfig(provider.provider) + return ( + provider?.model_credential_schema?.credential_form_schemas?.filter( + ({ type }) => type === FormTypeEnum.secretInput, + ) ?? [] + ) + }, [ + provider?.model_credential_schema?.credential_form_schemas, + provider?.provider_credential_schema?.credential_form_schemas, + providerFormSchemaPredefined, + ]) + const encodeConfigEntrySecretValues = useCallback( + (entry: ModelLoadBalancingConfigEntry) => { + const result = { ...entry } + extendedSecretFormSchemas.forEach(({ variable }) => { + if ( + entry.id && + result.credentials[variable] === originalConfigMap[entry.id]?.credentials?.[variable] + ) + result.credentials[variable] = '[__HIDDEN__]' + }) + return result + }, + [extendedSecretFormSchemas, originalConfigMap], + ) + const { mutateAsync: updateModelLoadBalancingConfig } = useUpdateModelLoadBalancingConfig( + provider.provider, + ) const initialCustomModelCredential = useMemo(() => { - if (!current_credential_id) - return undefined + if (!current_credential_id) return undefined return { credential_id: current_credential_id, credential_name: current_credential_name, } }, [current_credential_id, current_credential_name]) - const [customModelCredential, setCustomModelCredential] = useState<Credential | undefined>(initialCustomModelCredential) + const [customModelCredential, setCustomModelCredential] = useState<Credential | undefined>( + initialCustomModelCredential, + ) const { handleRefreshModel } = useRefreshModel() const handleSave = async () => { try { @@ -108,21 +157,26 @@ const ModelLoadBalancingModal = ({ provider, configurateMethod, currentCustomCon }, }) if (res.result === 'success') { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) handleRefreshModel(provider, currentCustomConfigurationModelFixedFields, false) onSave?.(provider.provider) onClose?.() + } else { + toast.error( + ( + res as { + error?: string + } + )?.error || t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }), + ) } - else { - toast.error((res as { - error?: string - })?.error || t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - } - } - catch (error) { - toast.error(error instanceof Error ? error.message : t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - } - finally { + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }), + ) + } finally { setLoading(false) } } @@ -130,61 +184,69 @@ const ModelLoadBalancingModal = ({ provider, configurateMethod, currentCustomCon await handleConfirmDelete() onClose?.() }, [handleConfirmDelete, onClose]) - const handleUpdate = useCallback(async (payload?: any, formValues?: Record<string, any>) => { - const result = await refetch() - const available_credentials = result.data?.available_credentials || [] - const credentialName = formValues?.__authorization_name__ - const modelCredential = payload?.credential - if (!available_credentials.length) { - onClose?.() - return - } - if (!modelCredential) { - const currentCredential = available_credentials.find(c => c.credential_name === credentialName) - if (currentCredential) { - setDraftConfig((prev: any) => { - if (!prev) - return prev + const handleUpdate = useCallback( + async (payload?: any, formValues?: Record<string, any>) => { + const result = await refetch() + const available_credentials = result.data?.available_credentials || [] + const credentialName = formValues?.__authorization_name__ + const modelCredential = payload?.credential + if (!available_credentials.length) { + onClose?.() + return + } + if (!modelCredential) { + const currentCredential = available_credentials.find( + (c) => c.credential_name === credentialName, + ) + if (currentCredential) { + setDraftConfig((prev: any) => { + if (!prev) return prev + return { + ...prev, + configs: [ + ...prev.configs, + { + credential_id: currentCredential.credential_id, + enabled: true, + name: currentCredential.credential_name, + }, + ], + } + }) + } + } else { + setDraftConfig((prev) => { + if (!prev) return prev + const newConfigs = [...prev.configs] + const prevIndex = newConfigs.findIndex( + (item) => + item.credential_id === modelCredential.credential_id && item.name !== '__inherit__', + ) + const newIndex = available_credentials.findIndex( + (c) => c.credential_id === modelCredential.credential_id, + ) + if (newIndex > -1 && prevIndex > -1) + newConfigs[prevIndex]!.name = available_credentials[newIndex]!.credential_name || '' return { ...prev, - configs: [...prev.configs, { - credential_id: currentCredential.credential_id, - enabled: true, - name: currentCredential.credential_name, - }], + configs: newConfigs, } }) } - } - else { - setDraftConfig((prev) => { - if (!prev) - return prev - const newConfigs = [...prev.configs] - const prevIndex = newConfigs.findIndex(item => item.credential_id === modelCredential.credential_id && item.name !== '__inherit__') - const newIndex = available_credentials.findIndex(c => c.credential_id === modelCredential.credential_id) - if (newIndex > -1 && prevIndex > -1) - newConfigs[prevIndex]!.name = available_credentials[newIndex]!.credential_name || '' - return { - ...prev, - configs: newConfigs, - } - }) - } - }, [refetch, onClose]) + }, + [refetch, onClose], + ) const handleUpdateWhenSwitchCredential = useCallback(async () => { const result = await refetch() const available_credentials = result.data?.available_credentials || [] - if (!available_credentials.length) - onClose?.() + if (!available_credentials.length) onClose?.() }, [refetch, onClose]) return ( <> <Dialog open={Boolean(model) && open} onOpenChange={(open) => { - if (!open) - onClose?.() + if (!open) onClose?.() }} > <DialogContent className="w-[640px] max-w-none border-none px-8 pt-8 text-left align-middle"> @@ -192,100 +254,157 @@ const ModelLoadBalancingModal = ({ provider, configurateMethod, currentCustomCon <div className="pb-3 font-semibold"> <div className="h-[30px]"> {draftConfig?.enabled - ? t($ => $['modelProvider.auth.configLoadBalancing'], { ns: 'common' }) - : t($ => $['modelProvider.auth.configModel'], { ns: 'common' })} + ? t(($) => $['modelProvider.auth.configLoadBalancing'], { ns: 'common' }) + : t(($) => $['modelProvider.auth.configModel'], { ns: 'common' })} </div> {Boolean(model) && ( <div className="flex h-5 items-center"> - <ModelIcon className="mr-2 shrink-0" provider={provider} modelName={model!.model} /> - <ModelName className="grow system-md-regular text-text-secondary" modelItem={model!} showModelType showMode showContextSize /> + <ModelIcon + className="mr-2 shrink-0" + provider={provider} + modelName={model!.model} + /> + <ModelName + className="grow system-md-regular text-text-secondary" + modelItem={model!} + showModelType + showMode + showContextSize + /> </div> )} </div> </DialogTitle> - {!draftConfig - ? <Loading type="area" /> - : ( - <> - <div className="py-2"> - <div className={cn('min-h-16 rounded-xl border bg-components-panel-bg transition-colors', draftConfig.enabled ? 'cursor-pointer border-components-panel-border' : 'cursor-default border-util-colors-blue-blue-600')} onClick={draftConfig.enabled ? () => toggleModalBalancing(false) : undefined}> - <div className="flex items-center gap-2 px-[15px] py-3 select-none"> - <div className="flex size-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-components-card-border bg-components-card-bg"> - {Boolean(model) && (<ModelIcon className="shrink-0" provider={provider} modelName={model!.model} />)} - </div> - <div className="grow"> - <div className="text-sm text-text-secondary"> - {providerFormSchemaPredefined - ? t($ => $['modelProvider.auth.providerManaged'], { ns: 'common' }) - : t($ => $['modelProvider.auth.specifyModelCredential'], { ns: 'common' })} - </div> - <div className="text-xs text-text-tertiary"> - {providerFormSchemaPredefined - ? t($ => $['modelProvider.auth.providerManagedTip'], { ns: 'common' }) - : t($ => $['modelProvider.auth.specifyModelCredentialTip'], { ns: 'common' })} - </div> - </div> - {!providerFormSchemaPredefined && (<SwitchCredentialInLoadBalancing provider={provider} customModelCredential={customModelCredential ?? initialCustomModelCredential} setCustomModelCredential={setCustomModelCredential} model={model} credentials={available_credentials} onUpdate={handleUpdateWhenSwitchCredential} onRemove={handleUpdateWhenSwitchCredential} />)} + {!draftConfig ? ( + <Loading type="area" /> + ) : ( + <> + <div className="py-2"> + <div + className={cn( + 'min-h-16 rounded-xl border bg-components-panel-bg transition-colors', + draftConfig.enabled + ? 'cursor-pointer border-components-panel-border' + : 'cursor-default border-util-colors-blue-blue-600', + )} + onClick={draftConfig.enabled ? () => toggleModalBalancing(false) : undefined} + > + <div className="flex items-center gap-2 px-[15px] py-3 select-none"> + <div className="flex size-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-components-card-border bg-components-card-bg"> + {Boolean(model) && ( + <ModelIcon + className="shrink-0" + provider={provider} + modelName={model!.model} + /> + )} + </div> + <div className="grow"> + <div className="text-sm text-text-secondary"> + {providerFormSchemaPredefined + ? t(($) => $['modelProvider.auth.providerManaged'], { ns: 'common' }) + : t(($) => $['modelProvider.auth.specifyModelCredential'], { + ns: 'common', + })} + </div> + <div className="text-xs text-text-tertiary"> + {providerFormSchemaPredefined + ? t(($) => $['modelProvider.auth.providerManagedTip'], { ns: 'common' }) + : t(($) => $['modelProvider.auth.specifyModelCredentialTip'], { + ns: 'common', + })} </div> </div> - {modelCredential && ( - <ModelLoadBalancingConfigs {...{ - draftConfig, - setDraftConfig, - provider, - currentCustomConfigurationModelFixedFields: { - __model_name: model.model, - __model_type: model.model_type, - }, - configurationMethod: model.fetch_from, - className: 'mt-2', - modelCredential, - onUpdate: handleUpdate, - onRemove: handleUpdateWhenSwitchCredential, - model: { - model: model.model, - model_type: model.model_type, - }, - }} + {!providerFormSchemaPredefined && ( + <SwitchCredentialInLoadBalancing + provider={provider} + customModelCredential={ + customModelCredential ?? initialCustomModelCredential + } + setCustomModelCredential={setCustomModelCredential} + model={model} + credentials={available_credentials} + onUpdate={handleUpdateWhenSwitchCredential} + onRemove={handleUpdateWhenSwitchCredential} /> )} </div> + </div> + {modelCredential && ( + <ModelLoadBalancingConfigs + {...{ + draftConfig, + setDraftConfig, + provider, + currentCustomConfigurationModelFixedFields: { + __model_name: model.model, + __model_type: model.model_type, + }, + configurationMethod: model.fetch_from, + className: 'mt-2', + modelCredential, + onUpdate: handleUpdate, + onRemove: handleUpdateWhenSwitchCredential, + model: { + model: model.model, + model_type: model.model_type, + }, + }} + /> + )} + </div> - <div className="mt-6 flex items-center justify-between gap-2"> - <div> - {!providerFormSchemaPredefined && ( - <Button onClick={() => openConfirmDelete(undefined, { model: model.model, model_type: model.model_type })} className="text-components-button-destructive-secondary-text"> - {t($ => $['modelProvider.auth.removeModel'], { ns: 'common' })} - </Button> - )} - </div> - <div className="space-x-2"> - <Button onClick={onClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button - variant="primary" - onClick={handleSave} - disabled={loading - || (draftConfig?.enabled && (draftConfig?.configs.filter(config => config.enabled).length ?? 0) < 2) - || isLoading} - > - {t($ => $['operation.save'], { ns: 'common' })} - </Button> - </div> - </div> - </> - )} - <AlertDialog open={!!deleteModel} onOpenChange={open => !open && closeConfirmDelete()}> + <div className="mt-6 flex items-center justify-between gap-2"> + <div> + {!providerFormSchemaPredefined && ( + <Button + onClick={() => + openConfirmDelete(undefined, { + model: model.model, + model_type: model.model_type, + }) + } + className="text-components-button-destructive-secondary-text" + > + {t(($) => $['modelProvider.auth.removeModel'], { ns: 'common' })} + </Button> + )} + </div> + <div className="space-x-2"> + <Button onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button + variant="primary" + onClick={handleSave} + disabled={ + loading || + (draftConfig?.enabled && + (draftConfig?.configs.filter((config) => config.enabled).length ?? 0) < + 2) || + isLoading + } + > + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> + </div> + </div> + </> + )} + <AlertDialog open={!!deleteModel} onOpenChange={(open) => !open && closeConfirmDelete()}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['modelProvider.confirmDelete'], { ns: 'common' })} + {t(($) => $['modelProvider.confirmDelete'], { ns: 'common' })} </AlertDialogTitle> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton disabled={doingAction} onClick={handleDeleteModel}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx index 52c6ea0da86d4f..df81c049067319 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx @@ -7,7 +7,10 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { HeaderModals } from '@/app/components/plugins/plugin-detail-panel/detail-header/components' -import { useDetailHeaderState, usePluginOperations } from '@/app/components/plugins/plugin-detail-panel/detail-header/hooks' +import { + useDetailHeaderState, + usePluginOperations, +} from '@/app/components/plugins/plugin-detail-panel/detail-header/hooks' import { OperationDropdown } from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting' import { PluginSource } from '@/app/components/plugins/types' @@ -41,11 +44,7 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => { isFromGitHub, } = useDetailHeaderState(detail) - const { - handleUpdate, - handleUpdatedFromMarketplace, - handleDelete, - } = usePluginOperations({ + const { handleUpdate, handleUpdatedFromMarketplace, handleDelete } = usePluginOperations({ detail, modalStates, versionPicker, @@ -55,7 +54,11 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => { onUpdate, }) - const handleVersionSelect = (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => { + const handleVersionSelect = (state: { + version: string + unique_identifier: string + isDowngrade?: boolean + }) => { versionPicker.setTargetVersion(state) handleUpdate(state.isDowngrade) } @@ -71,8 +74,7 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => { } const detailUrl = useMemo(() => { - if (source === PluginSource.github) - return meta?.repo ? `https://github.com/${meta.repo}` : '' + if (source === PluginSource.github) return meta?.repo ? `https://github.com/${meta.repo}` : '' if (source === PluginSource.marketplace) return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme }) return '' @@ -91,28 +93,32 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => { onSelect={handleVersionSelect} sideOffset={4} alignOffset={0} - trigger={( + trigger={ <Badge className={cn( - canUpdatePlugin && isFromMarketplace && 'cursor-pointer hover:bg-state-base-hover', + canUpdatePlugin && + isFromMarketplace && + 'cursor-pointer hover:bg-state-base-hover', )} uppercase={false} - text={( + text={ <> <span>{version}</span> - {canUpdatePlugin && isFromMarketplace && <span className="ml-1 i-ri-arrow-left-right-line size-3" />} + {canUpdatePlugin && isFromMarketplace && ( + <span className="ml-1 i-ri-arrow-left-right-line size-3" /> + )} </> - )} + } hasRedCornerMark={hasNewVersion} /> - )} + } /> {isDebuggingPlugin && ( <Badge className="border-state-warning-active bg-state-warning-hover text-text-warning" size="xs" uppercase={false} - text={t($ => $['operation.debugConfig'], { ns: 'appDebug' })} + text={t(($) => $['operation.debugConfig'], { ns: 'appDebug' })} /> )} </> @@ -122,19 +128,19 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => { <Tooltip> <TooltipTrigger delay={300} - render={( + render={ <Button variant="secondary-accent" size="small" className="h-5!" onClick={handleTriggerLatestUpdate} > - {t($ => $['detailPanel.operation.update'], { ns: 'plugin' })} + {t(($) => $['detailPanel.operation.update'], { ns: 'plugin' })} </Button> - )} + } /> <TooltipContent> - {t($ => $['detailPanel.operation.updateTooltip'], { ns: 'plugin' })} + {t(($) => $['detailPanel.operation.updateTooltip'], { ns: 'plugin' })} </TooltipContent> </Tooltip> )} diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx index 3bf835c8a30f77..ef6a8fa4bee53d 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx @@ -20,11 +20,16 @@ import { consoleQuery } from '@/service/client' import { formatNumber } from '@/utils/format' import { PreferredProviderTypeEnum } from '../declarations' import { useMarketplaceAllPlugins } from '../hooks' -import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils' +import { + MODEL_PROVIDER_QUOTA_GET_PAID, + modelNameMap, + providerIconMap, + providerKeyToPluginId, +} from '../utils' import styles from './quota-panel.module.css' import { useTrialCredits } from './use-trial-credits' -const allProviders = MODEL_PROVIDER_QUOTA_GET_PAID.map(key => ({ +const allProviders = MODEL_PROVIDER_QUOTA_GET_PAID.map((key) => ({ key, Icon: providerIconMap[key], })) @@ -48,7 +53,10 @@ const QuotaInfotip: FC<QuotaInfotipProps> = ({ tipText }) => { onClick={handleClick} className="ml-0.5 inline-flex size-3 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden" > - <span aria-hidden className="i-ri-information-2-line size-3 text-text-tertiary hover:text-text-secondary" /> + <span + aria-hidden + className="i-ri-information-2-line size-3 text-text-tertiary hover:text-text-secondary" + /> </PopoverTrigger> <PopoverContent placement="top" @@ -63,49 +71,53 @@ const QuotaInfotip: FC<QuotaInfotipProps> = ({ tipText }) => { type QuotaPanelProps = { providers: ModelProvider[] } -const QuotaPanel: FC<QuotaPanelProps> = ({ - providers, -}) => { +const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => { const { t } = useTranslation() const { credits, isExhausted, isLoading, nextCreditResetDate } = useTrialCredits() - const { data: trialModels = [] } = useQuery(consoleQuery.trialModels.get.queryOptions({ - enabled: IS_CLOUD_EDITION, - select: data => data.trial_models, - })) - const providerMap = useMemo(() => new Map( - providers.map(p => [p.provider, p.preferred_provider_type]), - ), [providers]) - const installedProvidersMap = useMemo(() => new Map( - providers.map(p => [p.provider, p.custom_configuration.available_credentials]), - ), [providers]) + const { data: trialModels = [] } = useQuery( + consoleQuery.trialModels.get.queryOptions({ + enabled: IS_CLOUD_EDITION, + select: (data) => data.trial_models, + }), + ) + const providerMap = useMemo( + () => new Map(providers.map((p) => [p.provider, p.preferred_provider_type])), + [providers], + ) + const installedProvidersMap = useMemo( + () => new Map(providers.map((p) => [p.provider, p.custom_configuration.available_credentials])), + [providers], + ) const { formatTime } = useTimestamp() - const { - plugins: allPlugins, - } = useMarketplaceAllPlugins(providers, '') + const { plugins: allPlugins } = useMarketplaceAllPlugins(providers, '') const [selectedPlugin, setSelectedPlugin] = useState<Plugin | null>(null) - const [isShowInstallModal, { - setTrue: showInstallFromMarketplace, - setFalse: hideInstallFromMarketplace, - }] = useBoolean(false) - const { canInstallPlugin, canUpdatePlugin, currentDifyVersion } = useWorkspacePluginInstallPermission() + const [ + isShowInstallModal, + { setTrue: showInstallFromMarketplace, setFalse: hideInstallFromMarketplace }, + ] = useBoolean(false) + const { canInstallPlugin, canUpdatePlugin, currentDifyVersion } = + useWorkspacePluginInstallPermission() const selectedPluginIdRef = useRef<string | null>(null) - const handleIconClick = useCallback((key: ModelProviderQuotaGetPaid) => { - const isInstalled = providerMap.get(key) - if (!isInstalled && allPlugins && canInstallPlugin) { - const pluginId = providerKeyToPluginId[key] - const plugin = allPlugins.find(p => p.plugin_id === pluginId) - if (plugin) { - setSelectedPlugin(plugin) - selectedPluginIdRef.current = pluginId - showInstallFromMarketplace() + const handleIconClick = useCallback( + (key: ModelProviderQuotaGetPaid) => { + const isInstalled = providerMap.get(key) + if (!isInstalled && allPlugins && canInstallPlugin) { + const pluginId = providerKeyToPluginId[key] + const plugin = allPlugins.find((p) => p.plugin_id === pluginId) + if (plugin) { + setSelectedPlugin(plugin) + selectedPluginIdRef.current = pluginId + showInstallFromMarketplace() + } } - } - }, [allPlugins, canInstallPlugin, providerMap, showInstallFromMarketplace]) + }, + [allPlugins, canInstallPlugin, providerMap, showInstallFromMarketplace], + ) useEffect(() => { if (isShowInstallModal && selectedPluginIdRef.current) { - const isInstalled = providers.some(p => p.provider.startsWith(selectedPluginIdRef.current!)) + const isInstalled = providers.some((p) => p.provider.startsWith(selectedPluginIdRef.current!)) if (isInstalled) { hideInstallFromMarketplace() selectedPluginIdRef.current = null @@ -113,9 +125,12 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ } }, [providers, isShowInstallModal, hideInstallFromMarketplace]) - const tipText = t($ => $['modelProvider.card.tip'], { + const tipText = t(($) => $['modelProvider.card.tip'], { ns: 'common', - modelNames: trialModels.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', '), + modelNames: trialModels + .map((key) => modelNameMap[key as keyof typeof modelNameMap]) + .filter(Boolean) + .join(', '), }) if (isLoading) { @@ -127,76 +142,86 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ } return ( - <div className={cn( - 'relative h-16 min-w-[72px] shrink-0 overflow-hidden rounded-xl border-[0.5px] pt-3 pr-2.5 pb-2.5 pl-4 shadow-xs', - isExhausted - ? 'border-state-destructive-border hover:bg-state-destructive-hover' - : 'border-components-panel-border bg-third-party-model-bg-default', - )} + <div + className={cn( + 'relative h-16 min-w-[72px] shrink-0 overflow-hidden rounded-xl border-[0.5px] pt-3 pr-2.5 pb-2.5 pl-4 shadow-xs', + isExhausted + ? 'border-state-destructive-border hover:bg-state-destructive-hover' + : 'border-components-panel-border bg-third-party-model-bg-default', + )} > <div className={cn('pointer-events-none absolute inset-0', styles.gridBg)} /> <div className="relative"> <div className="mb-0.5 flex h-4 items-center system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['modelProvider.quotaLabel'], { ns: 'common' })} + {t(($) => $['modelProvider.quotaLabel'], { ns: 'common' })} <QuotaInfotip tipText={tipText} /> </div> <div className="flex h-6 items-center justify-between"> <div className="flex items-center gap-1"> - {credits > 0 - ? <span className="mr-0.5 system-xl-semibold text-text-secondary">{formatNumber(credits)}</span> - : <span className="mr-0.5 system-xl-semibold text-text-destructive">{t($ => $['modelProvider.card.quotaExhausted'], { ns: 'common' })}</span>} + {credits > 0 ? ( + <span className="mr-0.5 system-xl-semibold text-text-secondary"> + {formatNumber(credits)} + </span> + ) : ( + <span className="mr-0.5 system-xl-semibold text-text-destructive"> + {t(($) => $['modelProvider.card.quotaExhausted'], { ns: 'common' })} + </span> + )} <div className="flex h-[18px] items-start gap-1 pt-0.5 system-xs-regular text-text-tertiary"> - <span>{t($ => $['modelProvider.credits'], { ns: 'common' })}</span> - {nextCreditResetDate - ? ( - <> - <span className="text-text-quaternary">·</span> - <span> - {t($ => $['modelProvider.resetDate'], { - ns: 'common', - date: formatTime(nextCreditResetDate, 'YYYY-MM-DD'), - interpolation: { escapeValue: false }, - })} - </span> - </> - ) - : null} + <span>{t(($) => $['modelProvider.credits'], { ns: 'common' })}</span> + {nextCreditResetDate ? ( + <> + <span className="text-text-quaternary">·</span> + <span> + {t(($) => $['modelProvider.resetDate'], { + ns: 'common', + date: formatTime(nextCreditResetDate, 'YYYY-MM-DD'), + interpolation: { escapeValue: false }, + })} + </span> + </> + ) : null} </div> </div> <div className="flex shrink-0 items-center gap-1"> - {allProviders.filter(({ key }) => trialModels.includes(key)).map(({ key, Icon }) => { - const providerType = providerMap.get(key) - const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0 - const getTooltipKey = () => { - if (!providerType) - return 'modelProvider.card.modelNotSupported' - if (isConfigured && providerType === PreferredProviderTypeEnum.custom) - return 'modelProvider.card.modelAPI' - return 'modelProvider.card.modelSupported' - } - const tooltipText = t($ => $[getTooltipKey()], { modelName: modelNameMap[key], ns: 'common' }) - return ( - <Tooltip key={key}> - <TooltipTrigger - aria-label={tooltipText} - render={( - <div - className={cn('relative size-6', !providerType && canInstallPlugin && 'cursor-pointer hover:opacity-80')} - onClick={() => handleIconClick(key)} - > - <Icon className="size-6 rounded-lg" /> - {!providerType && ( - <div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" /> - )} - </div> - )} - /> - <TooltipContent> - {tooltipText} - </TooltipContent> - </Tooltip> - ) - })} + {allProviders + .filter(({ key }) => trialModels.includes(key)) + .map(({ key, Icon }) => { + const providerType = providerMap.get(key) + const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0 + const getTooltipKey = () => { + if (!providerType) return 'modelProvider.card.modelNotSupported' + if (isConfigured && providerType === PreferredProviderTypeEnum.custom) + return 'modelProvider.card.modelAPI' + return 'modelProvider.card.modelSupported' + } + const tooltipText = t(($) => $[getTooltipKey()], { + modelName: modelNameMap[key], + ns: 'common', + }) + return ( + <Tooltip key={key}> + <TooltipTrigger + aria-label={tooltipText} + render={ + <div + className={cn( + 'relative size-6', + !providerType && canInstallPlugin && 'cursor-pointer hover:opacity-80', + )} + onClick={() => handleIconClick(key)} + > + <Icon className="size-6 rounded-lg" /> + {!providerType && ( + <div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" /> + )} + </div> + } + /> + <TooltipContent>{tooltipText}</TooltipContent> + </Tooltip> + ) + })} </div> </div> </div> diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/system-quota-card.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/system-quota-card.tsx index b0d2747b27fb0e..fe501c3abf99ba 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/system-quota-card.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/system-quota-card.tsx @@ -22,16 +22,14 @@ type SystemQuotaCardProps = { children: ReactNode } -const SystemQuotaCard = ({ - variant = 'default', - children, -}: SystemQuotaCardProps) => { +const SystemQuotaCard = ({ variant = 'default', children }: SystemQuotaCardProps) => { return ( <VariantContext.Provider value={variant}> - <div className={cn( - 'relative isolate ml-1 flex w-[128px] shrink-0 flex-col justify-between rounded-lg border-[0.5px] p-1 shadow-xs', - containerVariants[variant], - )} + <div + className={cn( + 'relative isolate ml-1 flex w-[128px] shrink-0 flex-col justify-between rounded-lg border-[0.5px] p-1 shadow-xs', + containerVariants[variant], + )} > <div className={cn('pointer-events-none absolute inset-0 rounded-[7px]', styles.gridBg)} /> {children} @@ -40,13 +38,14 @@ const SystemQuotaCard = ({ ) } -const Label = ({ children, className }: { children: ReactNode, className?: string }) => { +const Label = ({ children, className }: { children: ReactNode; className?: string }) => { const variant = use(VariantContext) return ( - <div className={cn( - 'relative z-1 flex min-w-0 items-center gap-1 overflow-hidden px-1.5 pt-1 system-xs-medium', - className ?? labelVariants[variant], - )} + <div + className={cn( + 'relative z-1 flex min-w-0 items-center gap-1 overflow-hidden px-1.5 pt-1 system-xs-medium', + className ?? labelVariants[variant], + )} > {children} </div> @@ -54,11 +53,7 @@ const Label = ({ children, className }: { children: ReactNode, className?: strin } const Actions = ({ children }: { children: ReactNode }) => { - return ( - <div className="relative z-1 flex w-full min-w-0 items-center gap-0.5"> - {children} - </div> - ) + return <div className="relative z-1 flex w-full min-w-0 items-center gap-0.5">{children}</div> } SystemQuotaCard.Label = Label diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-change-provider-priority.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-change-provider-priority.ts index 8fc67339957360..a074df3be84915 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-change-provider-priority.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-change-provider-priority.ts @@ -12,31 +12,36 @@ export function useChangeProviderPriority(provider: ModelProvider | undefined) { const updateModelList = useUpdateModelList() const updateModelProviders = useUpdateModelProviders() const providerName = provider?.provider ?? '' - const modelProviderModelListQueryKey = consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ - input: { - params: { - provider: providerName, + const modelProviderModelListQueryKey = + consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryKey({ + input: { + params: { + provider: providerName, + }, }, - }, - }) - const { mutate: changePriority, isPending: isChangingPriority } = useMutation(consoleQuery.workspaces.current.modelProviders.byProvider.preferredProviderType.post.mutationOptions({ - onSuccess: () => { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - queryClient.invalidateQueries({ - queryKey: modelProviderModelListQueryKey, - exact: true, - refetchType: 'none', - }) - updateModelProviders() - provider?.configurate_methods.forEach((method) => { - if (method === ConfigurationMethodEnum.predefinedModel) - provider?.supported_model_types.forEach(modelType => updateModelList(modelType)) - }) - }, - onError: () => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - }, - })) + }) + const { mutate: changePriority, isPending: isChangingPriority } = useMutation( + consoleQuery.workspaces.current.modelProviders.byProvider.preferredProviderType.post.mutationOptions( + { + onSuccess: () => { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + queryClient.invalidateQueries({ + queryKey: modelProviderModelListQueryKey, + exact: true, + refetchType: 'none', + }) + updateModelProviders() + provider?.configurate_methods.forEach((method) => { + if (method === ConfigurationMethodEnum.predefinedModel) + provider?.supported_model_types.forEach((modelType) => updateModelList(modelType)) + }) + }, + onError: () => { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + }, + }, + ), + ) const handleChangePriority = (key: PreferredProviderTypeEnum) => { changePriority({ params: { provider: providerName }, diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.ts index 63c2d54b13c2dc..75ec31d6faeaf1 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.ts @@ -3,24 +3,22 @@ import { useQuery } from '@tanstack/react-query' import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks' import { IS_CLOUD_EDITION } from '@/config' import { consoleQuery } from '@/service/client' -import { - PreferredProviderTypeEnum, -} from '../declarations' +import { PreferredProviderTypeEnum } from '../declarations' import { providerSupportsCredits } from '../supports-credits' import { useTrialCredits } from './use-trial-credits' export type UsagePriority = 'credits' | 'apiKey' | 'apiKeyOnly' -export type CardVariant - = | 'credits-active' - | 'credits-fallback' - | 'credits-exhausted' - | 'no-usage' - | 'api-fallback' - | 'api-active' - | 'api-required-add' - | 'api-required-configure' - | 'api-unavailable' +export type CardVariant = + | 'credits-active' + | 'credits-fallback' + | 'credits-exhausted' + | 'no-usage' + | 'api-fallback' + | 'api-active' + | 'api-required-add' + | 'api-required-configure' + | 'api-unavailable' export type CredentialPanelState = { variant: CardVariant @@ -39,8 +37,7 @@ const DESTRUCTIVE_VARIANTS = new Set<CardVariant>([ 'api-unavailable', ]) -export const isDestructiveVariant = (variant: CardVariant) => - DESTRUCTIVE_VARIANTS.has(variant) +export const isDestructiveVariant = (variant: CardVariant) => DESTRUCTIVE_VARIANTS.has(variant) function deriveVariant( priority: UsagePriority, @@ -50,23 +47,17 @@ function deriveVariant( credentialName: string | undefined, ): CardVariant { if (priority === 'credits') { - if (!isExhausted) - return 'credits-active' - if (hasCredential && authorized) - return 'api-fallback' - if (hasCredential && !authorized) - return 'no-usage' + if (!isExhausted) return 'credits-active' + if (hasCredential && authorized) return 'api-fallback' + if (hasCredential && !authorized) return 'no-usage' return 'credits-exhausted' } - if (hasCredential && authorized) - return 'api-active' + if (hasCredential && authorized) return 'api-active' - if (priority === 'apiKey' && !isExhausted) - return 'credits-fallback' + if (priority === 'apiKey' && !isExhausted) return 'credits-fallback' - if (priority === 'apiKey' && !hasCredential) - return 'no-usage' + if (priority === 'apiKey' && !hasCredential) return 'no-usage' if (hasCredential && !authorized) return credentialName ? 'api-unavailable' : 'api-required-configure' @@ -75,16 +66,14 @@ function deriveVariant( export function useCredentialPanelState(provider: ModelProvider | undefined): CredentialPanelState { const { isExhausted, credits } = useTrialCredits() - const { - hasCredential, - authorized, - current_credential_name, - } = useCredentialStatus(provider) + const { hasCredential, authorized, current_credential_name } = useCredentialStatus(provider) - const { data: trialModels = [] } = useQuery(consoleQuery.trialModels.get.queryOptions({ - enabled: IS_CLOUD_EDITION, - select: data => data.trial_models, - })) + const { data: trialModels = [] } = useQuery( + consoleQuery.trialModels.get.queryOptions({ + enabled: IS_CLOUD_EDITION, + select: (data) => data.trial_models, + }), + ) const preferredType = provider?.preferred_provider_type @@ -98,7 +87,13 @@ export function useCredentialPanelState(provider: ModelProvider | undefined): Cr const showPrioritySwitcher = supportsCredits - const variant = deriveVariant(priority, isExhausted, hasCredential, !!authorized, current_credential_name) + const variant = deriveVariant( + priority, + isExhausted, + hasCredential, + !!authorized, + current_credential_name, + ) return { variant, diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-trial-credits.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-trial-credits.ts index a082af60081bd2..f118eb9c3d3c68 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-trial-credits.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-trial-credits.ts @@ -18,9 +18,11 @@ const selectTrialCredits = (workspace: { } export const useTrialCredits = () => { - const trialCreditsQuery = useQuery(consoleQuery.workspaces.current.post.queryOptions({ - select: selectTrialCredits, - })) + const trialCreditsQuery = useQuery( + consoleQuery.workspaces.current.post.queryOptions({ + select: selectTrialCredits, + }), + ) const trialCredits = trialCreditsQuery.data return { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-icon/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-icon/__tests__/index.spec.tsx index d7f9bc832218ed..794dcf03c39ef0 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-icon/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-icon/__tests__/index.spec.tsx @@ -8,18 +8,23 @@ import ProviderIcon from '../index' type UseThemeReturnType = ReturnType<typeof useTheme> vi.mock('@/app/components/base/icons/src/public/llm', () => ({ - AnthropicDark: ({ className }: { className: string }) => <div data-testid="anthropic-dark" className={className} />, - AnthropicLight: ({ className }: { className: string }) => <div data-testid="anthropic-light" className={className} />, + AnthropicDark: ({ className }: { className: string }) => ( + <div data-testid="anthropic-dark" className={className} /> + ), + AnthropicLight: ({ className }: { className: string }) => ( + <div data-testid="anthropic-light" className={className} /> + ), })) vi.mock('@/app/components/base/icons/src/vender/other', () => ({ - Openai: ({ className }: { className: string }) => <div data-testid="openai-icon" className={className} />, + Openai: ({ className }: { className: string }) => ( + <div data-testid="openai-icon" className={className} /> + ), })) vi.mock('@/i18n-config', () => ({ renderI18nObject: (obj: Record<string, string> | string, lang: string) => { - if (typeof obj === 'string') - return obj + if (typeof obj === 'string') return obj return obj[lang] || obj.en_US || Object.values(obj)[0] }, })) @@ -33,37 +38,59 @@ vi.mock('../../hooks', () => ({ useLanguage: vi.fn(() => 'en_US'), })) -const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({ - provider: 'some/provider', - label: { en_US: 'Provider', zh_Hans: '提供商' }, - help: { title: { en_US: 'Help', zh_Hans: '帮助' }, url: { en_US: 'https://example.com', zh_Hans: 'https://example.com' } }, - icon_small: { en_US: 'https://example.com/icon.png', zh_Hans: 'https://example.com/icon.png' }, - supported_model_types: [], - configurate_methods: [], - provider_credential_schema: { credential_form_schemas: [] }, - model_credential_schema: { model: { label: { en_US: 'Model', zh_Hans: '模型' }, placeholder: { en_US: 'Select', zh_Hans: '选择' } }, credential_form_schemas: [] }, - preferred_provider_type: undefined, - ...overrides, -} as ModelProvider) +const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => + ({ + provider: 'some/provider', + label: { en_US: 'Provider', zh_Hans: '提供商' }, + help: { + title: { en_US: 'Help', zh_Hans: '帮助' }, + url: { en_US: 'https://example.com', zh_Hans: 'https://example.com' }, + }, + icon_small: { en_US: 'https://example.com/icon.png', zh_Hans: 'https://example.com/icon.png' }, + supported_model_types: [], + configurate_methods: [], + provider_credential_schema: { credential_form_schemas: [] }, + model_credential_schema: { + model: { + label: { en_US: 'Model', zh_Hans: '模型' }, + placeholder: { en_US: 'Select', zh_Hans: '选择' }, + }, + credential_form_schemas: [], + }, + preferred_provider_type: undefined, + ...overrides, + }) as ModelProvider describe('ProviderIcon', () => { beforeEach(() => { vi.clearAllMocks() const mockTheme = vi.mocked(useTheme) const mockLang = vi.mocked(useLanguage) - mockTheme.mockReturnValue({ theme: Theme.light, themes: [], setTheme: vi.fn() } as UseThemeReturnType) + mockTheme.mockReturnValue({ + theme: Theme.light, + themes: [], + setTheme: vi.fn(), + } as UseThemeReturnType) mockLang.mockReturnValue('en_US') }) it('should render Anthropic icon based on theme', () => { const mockTheme = vi.mocked(useTheme) - mockTheme.mockReturnValue({ theme: Theme.dark, themes: [], setTheme: vi.fn() } as UseThemeReturnType) + mockTheme.mockReturnValue({ + theme: Theme.dark, + themes: [], + setTheme: vi.fn(), + } as UseThemeReturnType) const provider = createProvider({ provider: 'langgenius/anthropic/anthropic' }) render(<ProviderIcon provider={provider} />) expect(screen.getByTestId('anthropic-light')).toBeInTheDocument() - mockTheme.mockReturnValue({ theme: Theme.light, themes: [], setTheme: vi.fn() } as UseThemeReturnType) + mockTheme.mockReturnValue({ + theme: Theme.light, + themes: [], + setTheme: vi.fn(), + } as UseThemeReturnType) render(<ProviderIcon provider={provider} />) expect(screen.getByTestId('anthropic-dark')).toBeInTheDocument() }) @@ -105,9 +132,16 @@ describe('ProviderIcon', () => { it('should use dark icon in dark theme for generic provider', () => { const mockTheme = vi.mocked(useTheme) - mockTheme.mockReturnValue({ theme: Theme.dark, themes: [], setTheme: vi.fn() } as UseThemeReturnType) + mockTheme.mockReturnValue({ + theme: Theme.dark, + themes: [], + setTheme: vi.fn(), + } as UseThemeReturnType) const provider = createProvider({ - icon_small_dark: { en_US: 'https://example.com/dark.png', zh_Hans: 'https://example.com/dark.png' }, + icon_small_dark: { + en_US: 'https://example.com/dark.png', + zh_Hans: 'https://example.com/dark.png', + }, }) render(<ProviderIcon provider={provider} />) @@ -117,9 +151,16 @@ describe('ProviderIcon', () => { it('should fall back to light icon when dark icon is empty', () => { const mockTheme = vi.mocked(useTheme) - mockTheme.mockReturnValue({ theme: Theme.dark, themes: [], setTheme: vi.fn() } as UseThemeReturnType) + mockTheme.mockReturnValue({ + theme: Theme.dark, + themes: [], + setTheme: vi.fn(), + } as UseThemeReturnType) const provider = createProvider({ - icon_small: { en_US: 'https://example.com/light.png', zh_Hans: 'https://example.com/light.png' }, + icon_small: { + en_US: 'https://example.com/light.png', + zh_Hans: 'https://example.com/light.png', + }, icon_small_dark: { en_US: '', zh_Hans: '' }, }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx index 972e82b344e635..f06797b40425fc 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx @@ -12,14 +12,13 @@ type ProviderIconProps = { provider: ModelProvider className?: string } -const ProviderIcon: FC<ProviderIconProps> = ({ - provider, - className, -}) => { +const ProviderIcon: FC<ProviderIconProps> = ({ provider, className }) => { const { theme } = useTheme() const language = useLanguage() const lightIconUrl = renderI18nObject(provider.icon_small, language) - const darkIconUrl = provider.icon_small_dark ? renderI18nObject(provider.icon_small_dark, language) : '' + const darkIconUrl = provider.icon_small_dark + ? renderI18nObject(provider.icon_small_dark, language) + : '' const iconUrl = theme === Theme.dark ? darkIconUrl || lightIconUrl : lightIconUrl if (provider.provider === 'langgenius/anthropic/anthropic') { @@ -41,19 +40,13 @@ const ProviderIcon: FC<ProviderIconProps> = ({ return ( <div className={cn('inline-flex items-center gap-2', className)}> - {iconUrl - ? ( - <img - alt="provider-icon" - src={iconUrl} - className="size-6" - /> - ) - : ( - <div className="flex size-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle"> - <span aria-hidden className="i-custom-vender-other-group size-4 text-text-tertiary" /> - </div> - )} + {iconUrl ? ( + <img alt="provider-icon" src={iconUrl} className="size-6" /> + ) : ( + <div className="flex size-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle"> + <span aria-hidden className="i-custom-vender-other-group size-4 text-text-tertiary" /> + </div> + )} <div className="system-md-semibold text-text-primary"> {renderI18nObject(provider.label, language)} </div> diff --git a/web/app/components/header/account-setting/model-provider-page/supports-credits.ts b/web/app/components/header/account-setting/model-provider-page/supports-credits.ts index 2e9fcea12c3775..d981e98e96c397 100644 --- a/web/app/components/header/account-setting/model-provider-page/supports-credits.ts +++ b/web/app/components/header/account-setting/model-provider-page/supports-credits.ts @@ -7,8 +7,7 @@ export const providerSupportsCredits = ( provider: CreditAwareProvider | undefined, trialModels: readonly string[] | undefined, ): boolean => { - if (!IS_CLOUD_EDITION || !provider?.system_configuration.enabled) - return false + if (!IS_CLOUD_EDITION || !provider?.system_configuration.enabled) return false return !!provider.provider && !!trialModels?.includes(provider.provider) } 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 b7124ab81c8ac8..79c3917c97a160 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 @@ -30,7 +30,14 @@ const mockToastSuccess = vi.hoisted(() => vi.fn()) const mockUpdateModelList = vi.hoisted(() => vi.fn()) const mockInvalidateDefaultModel = vi.hoisted(() => vi.fn()) const mockUpdateDefaultModel = vi.hoisted(() => vi.fn(() => Promise.resolve({ result: 'success' }))) -const mockModelSelectorProps = vi.hoisted(() => [] as Array<{ hideProviderSettingsFooter?: boolean, onConfigureEmptyState?: () => void, showModelMeta?: boolean }>) +const mockModelSelectorProps = vi.hoisted( + () => + [] as Array<{ + hideProviderSettingsFooter?: boolean + onConfigureEmptyState?: () => void + showModelMeta?: boolean + }>, +) let mockWorkspacePermissionKeys = ['plugin.model_config'] @@ -66,7 +73,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -92,7 +100,10 @@ vi.mock('../../hooks', () => ({ data: [], }), useSystemDefaultModelAndModelList: (defaultModel: DefaultModelResponse | undefined) => [ - defaultModel || { model: '', provider: { provider: '', icon_small: { en_US: '', zh_Hans: '' } } }, + defaultModel || { + model: '', + provider: { provider: '', icon_small: { en_US: '', zh_Hans: '' } }, + }, vi.fn(), ], useUpdateModelList: () => mockUpdateModelList, @@ -104,12 +115,21 @@ vi.mock('@/service/common', () => ({ })) vi.mock('../../model-selector', () => ({ - default: (props: { hideProviderSettingsFooter?: boolean, onConfigureEmptyState?: () => void, showModelMeta?: boolean, onSelect: (model: { model: string, provider: string }) => void }) => { + default: (props: { + hideProviderSettingsFooter?: boolean + onConfigureEmptyState?: () => void + showModelMeta?: boolean + onSelect: (model: { model: string; provider: string }) => void + }) => { mockModelSelectorProps.push(props) return ( <div> - <button onClick={() => props.onSelect({ model: 'test', provider: 'test' })}>Mock Model Selector</button> - {props.onConfigureEmptyState && <button onClick={props.onConfigureEmptyState}>Mock Configure Empty State</button>} + <button onClick={() => props.onSelect({ model: 'test', provider: 'test' })}> + Mock Model Selector + </button> + {props.onConfigureEmptyState && ( + <button onClick={props.onConfigureEmptyState}>Mock Configure Empty State</button> + )} </div> ) }, @@ -181,7 +201,7 @@ describe('SystemModel', () => { }) const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' }) - selectorButtons.forEach(button => fireEvent.click(button)) + selectorButtons.forEach((button) => fireEvent.click(button)) fireEvent.click(screen.getByRole('button', { name: /save/i })) @@ -232,7 +252,7 @@ describe('SystemModel', () => { expect(mockModelSelectorProps).toHaveLength(5) }) - expect(mockModelSelectorProps.every(props => props.hideProviderSettingsFooter)).toBe(true) + expect(mockModelSelectorProps.every((props) => props.hideProviderSettingsFooter)).toBe(true) }) it('should hide model metadata in default model selectors', async () => { @@ -243,7 +263,7 @@ describe('SystemModel', () => { expect(mockModelSelectorProps).toHaveLength(5) }) - expect(mockModelSelectorProps.every(props => props.showModelMeta === false)).toBe(true) + expect(mockModelSelectorProps.every((props) => props.showModelMeta === false)).toBe(true) }) it('should close the dialog from the empty selector configure action', async () => { 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 697425dc00d9ba..a7f835c1ca75ae 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 @@ -1,16 +1,8 @@ import type { FC } from 'react' -import type { - DefaultModel, - DefaultModelResponse, -} from '../declarations' +import type { DefaultModel, DefaultModelResponse } from '../declarations' 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 { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { useAtomValue } from 'jotai' import { useState } from 'react' @@ -42,19 +34,19 @@ type SystemModelSelectorProps = { onOpenMarketplace?: () => void } -type SystemModelLabelKey - = | 'modelProvider.systemReasoningModel.key' - | 'modelProvider.embeddingModel.key' - | 'modelProvider.rerankModel.key' - | 'modelProvider.speechToTextModel.key' - | 'modelProvider.ttsModel.key' +type SystemModelLabelKey = + | 'modelProvider.systemReasoningModel.key' + | 'modelProvider.embeddingModel.key' + | 'modelProvider.rerankModel.key' + | 'modelProvider.speechToTextModel.key' + | 'modelProvider.ttsModel.key' -type SystemModelTipKey - = | 'modelProvider.systemReasoningModel.tip' - | 'modelProvider.embeddingModel.tip' - | 'modelProvider.rerankModel.tip' - | 'modelProvider.speechToTextModel.tip' - | 'modelProvider.ttsModel.tip' +type SystemModelTipKey = + | 'modelProvider.systemReasoningModel.tip' + | 'modelProvider.embeddingModel.tip' + | 'modelProvider.rerankModel.tip' + | 'modelProvider.speechToTextModel.tip' + | 'modelProvider.ttsModel.tip' const SystemModel: FC<SystemModelSelectorProps> = ({ className, @@ -79,38 +71,35 @@ const SystemModel: FC<SystemModelSelectorProps> = ({ const { data: speech2textModelList } = useModelList(ModelTypeEnum.speech2text) const { data: ttsModelList } = useModelList(ModelTypeEnum.tts) const [changedModelTypes, setChangedModelTypes] = useState<ModelTypeEnum[]>([]) - const [currentTextGenerationDefaultModel, changeCurrentTextGenerationDefaultModel] = useSystemDefaultModelAndModelList(textGenerationDefaultModel, textGenerationModelList) - const [currentEmbeddingsDefaultModel, changeCurrentEmbeddingsDefaultModel] = useSystemDefaultModelAndModelList(embeddingsDefaultModel, embeddingModelList) - const [currentRerankDefaultModel, changeCurrentRerankDefaultModel] = useSystemDefaultModelAndModelList(rerankDefaultModel, rerankModelList) - const [currentSpeech2textDefaultModel, changeCurrentSpeech2textDefaultModel] = useSystemDefaultModelAndModelList(speech2textDefaultModel, speech2textModelList) - const [currentTTSDefaultModel, changeCurrentTTSDefaultModel] = useSystemDefaultModelAndModelList(ttsDefaultModel, ttsModelList) + const [currentTextGenerationDefaultModel, changeCurrentTextGenerationDefaultModel] = + useSystemDefaultModelAndModelList(textGenerationDefaultModel, textGenerationModelList) + const [currentEmbeddingsDefaultModel, changeCurrentEmbeddingsDefaultModel] = + useSystemDefaultModelAndModelList(embeddingsDefaultModel, embeddingModelList) + const [currentRerankDefaultModel, changeCurrentRerankDefaultModel] = + useSystemDefaultModelAndModelList(rerankDefaultModel, rerankModelList) + const [currentSpeech2textDefaultModel, changeCurrentSpeech2textDefaultModel] = + useSystemDefaultModelAndModelList(speech2textDefaultModel, speech2textModelList) + const [currentTTSDefaultModel, changeCurrentTTSDefaultModel] = useSystemDefaultModelAndModelList( + ttsDefaultModel, + ttsModelList, + ) const [open, setOpen] = useState(false) const getCurrentDefaultModelByModelType = (modelType: ModelTypeEnum) => { - if (modelType === ModelTypeEnum.textGeneration) - return currentTextGenerationDefaultModel - else if (modelType === ModelTypeEnum.textEmbedding) - return currentEmbeddingsDefaultModel - else if (modelType === ModelTypeEnum.rerank) - return currentRerankDefaultModel - else if (modelType === ModelTypeEnum.speech2text) - return currentSpeech2textDefaultModel - else if (modelType === ModelTypeEnum.tts) - return currentTTSDefaultModel + if (modelType === ModelTypeEnum.textGeneration) return currentTextGenerationDefaultModel + else if (modelType === ModelTypeEnum.textEmbedding) return currentEmbeddingsDefaultModel + else if (modelType === ModelTypeEnum.rerank) return currentRerankDefaultModel + else if (modelType === ModelTypeEnum.speech2text) return currentSpeech2textDefaultModel + else if (modelType === ModelTypeEnum.tts) return currentTTSDefaultModel return undefined } const handleChangeDefaultModel = (modelType: ModelTypeEnum, model: DefaultModel) => { - if (modelType === ModelTypeEnum.textGeneration) - changeCurrentTextGenerationDefaultModel(model) - else if (modelType === ModelTypeEnum.textEmbedding) - changeCurrentEmbeddingsDefaultModel(model) - else if (modelType === ModelTypeEnum.rerank) - changeCurrentRerankDefaultModel(model) - else if (modelType === ModelTypeEnum.speech2text) - changeCurrentSpeech2textDefaultModel(model) - else if (modelType === ModelTypeEnum.tts) - changeCurrentTTSDefaultModel(model) + if (modelType === ModelTypeEnum.textGeneration) changeCurrentTextGenerationDefaultModel(model) + else if (modelType === ModelTypeEnum.textEmbedding) changeCurrentEmbeddingsDefaultModel(model) + else if (modelType === ModelTypeEnum.rerank) changeCurrentRerankDefaultModel(model) + else if (modelType === ModelTypeEnum.speech2text) changeCurrentSpeech2textDefaultModel(model) + else if (modelType === ModelTypeEnum.tts) changeCurrentTTSDefaultModel(model) if (!changedModelTypes.includes(modelType)) setChangedModelTypes([...changedModelTypes, modelType]) @@ -119,7 +108,13 @@ const SystemModel: FC<SystemModelSelectorProps> = ({ const res = await updateDefaultModel({ url: '/workspaces/current/default-model', body: { - model_settings: [ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank, ModelTypeEnum.speech2text, ModelTypeEnum.tts].map((modelType) => { + model_settings: [ + ModelTypeEnum.textGeneration, + ModelTypeEnum.textEmbedding, + ModelTypeEnum.rerank, + ModelTypeEnum.speech2text, + ModelTypeEnum.tts, + ].map((modelType) => { return { model_type: modelType, provider: getCurrentDefaultModelByModelType(modelType)?.provider, @@ -129,21 +124,27 @@ const SystemModel: FC<SystemModelSelectorProps> = ({ }, }) if (res.result === 'success') { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) setOpen(false) - const allModelTypes = [ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank, ModelTypeEnum.speech2text, ModelTypeEnum.tts] - allModelTypes.forEach(type => invalidateDefaultModel(type)) - changedModelTypes.forEach(type => updateModelList(type)) + const allModelTypes = [ + ModelTypeEnum.textGeneration, + ModelTypeEnum.textEmbedding, + ModelTypeEnum.rerank, + ModelTypeEnum.speech2text, + ModelTypeEnum.tts, + ] + allModelTypes.forEach((type) => invalidateDefaultModel(type)) + changedModelTypes.forEach((type) => updateModelList(type)) } } const renderModelLabel = (labelKey: SystemModelLabelKey, tipKey: SystemModelTipKey) => { - const tipText = t($ => $[tipKey], { ns: 'common' }) + const tipText = t(($) => $[tipKey], { ns: 'common' }) return ( <div className="flex min-h-6 items-center text-[13px] font-medium text-text-secondary"> - {t($ => $[labelKey], { ns: 'common' })} + {t(($) => $[labelKey], { ns: 'common' })} <Infotip aria-label={tipText} className="ml-0.5 text-text-tertiary" @@ -164,10 +165,12 @@ const SystemModel: FC<SystemModelSelectorProps> = ({ disabled={isLoading} onClick={() => setOpen(true)} > - {isLoading - ? <span className="mr-0.5 i-ri-loader-2-line size-3.5 animate-spin" /> - : <span className="mr-0.5 i-ri-brain-2-line size-3.5" />} - {t($ => $['modelProvider.systemModelSettings'], { ns: 'common' })} + {isLoading ? ( + <span className="mr-0.5 i-ri-loader-2-line size-3.5 animate-spin" /> + ) : ( + <span className="mr-0.5 i-ri-brain-2-line size-3.5" /> + )} + {t(($) => $['modelProvider.systemModelSettings'], { ns: 'common' })} </Button> <Dialog open={open} onOpenChange={setOpen}> <DialogContent @@ -177,16 +180,19 @@ const SystemModel: FC<SystemModelSelectorProps> = ({ <DialogCloseButton className="top-5 right-5" /> <div className="shrink-0 px-6 pt-6 pr-14 pb-3"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['modelProvider.systemModelSettingsTitle'], { ns: 'common' })} + {t(($) => $['modelProvider.systemModelSettingsTitle'], { ns: 'common' })} </DialogTitle> <p className="mt-1 system-xs-regular text-text-tertiary"> - {t($ => $['modelProvider.systemModelSettingsDesc'], { ns: 'common' })} + {t(($) => $['modelProvider.systemModelSettingsDesc'], { ns: 'common' })} </p> </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> <div className="flex flex-col gap-4"> <div className="flex flex-col gap-1"> - {renderModelLabel('modelProvider.systemReasoningModel.key', 'modelProvider.systemReasoningModel.tip')} + {renderModelLabel( + 'modelProvider.systemReasoningModel.key', + 'modelProvider.systemReasoningModel.tip', + )} <div> <ModelSelector defaultModel={currentTextGenerationDefaultModel} @@ -195,12 +201,17 @@ const SystemModel: FC<SystemModelSelectorProps> = ({ onOpenMarketplace={onOpenMarketplace} onConfigureEmptyState={() => setOpen(false)} showModelMeta={false} - onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)} + onSelect={(model) => + handleChangeDefaultModel(ModelTypeEnum.textGeneration, model) + } /> </div> </div> <div className="flex flex-col gap-1"> - {renderModelLabel('modelProvider.embeddingModel.key', 'modelProvider.embeddingModel.tip')} + {renderModelLabel( + 'modelProvider.embeddingModel.key', + 'modelProvider.embeddingModel.tip', + )} <div> <ModelSelector defaultModel={currentEmbeddingsDefaultModel} @@ -209,7 +220,9 @@ const SystemModel: FC<SystemModelSelectorProps> = ({ onOpenMarketplace={onOpenMarketplace} onConfigureEmptyState={() => setOpen(false)} showModelMeta={false} - onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)} + onSelect={(model) => + handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model) + } /> </div> </div> @@ -223,12 +236,15 @@ const SystemModel: FC<SystemModelSelectorProps> = ({ onOpenMarketplace={onOpenMarketplace} onConfigureEmptyState={() => setOpen(false)} showModelMeta={false} - onSelect={model => handleChangeDefaultModel(ModelTypeEnum.rerank, model)} + onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.rerank, model)} /> </div> </div> <div className="flex flex-col gap-1"> - {renderModelLabel('modelProvider.speechToTextModel.key', 'modelProvider.speechToTextModel.tip')} + {renderModelLabel( + 'modelProvider.speechToTextModel.key', + 'modelProvider.speechToTextModel.tip', + )} <div> <ModelSelector defaultModel={currentSpeech2textDefaultModel} @@ -237,7 +253,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({ onOpenMarketplace={onOpenMarketplace} onConfigureEmptyState={() => setOpen(false)} showModelMeta={false} - onSelect={model => handleChangeDefaultModel(ModelTypeEnum.speech2text, model)} + onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.speech2text, model)} /> </div> </div> @@ -251,18 +267,15 @@ const SystemModel: FC<SystemModelSelectorProps> = ({ onOpenMarketplace={onOpenMarketplace} onConfigureEmptyState={() => setOpen(false)} showModelMeta={false} - onSelect={model => handleChangeDefaultModel(ModelTypeEnum.tts, model)} + onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.tts, model)} /> </div> </div> </div> </div> <div className="flex h-[76px] shrink-0 items-center justify-end gap-2 px-6 pt-5 pb-6"> - <Button - className="min-w-[72px]" - onClick={() => setOpen(false)} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button className="min-w-[72px]" onClick={() => setOpen(false)}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button className="min-w-[72px]" @@ -270,7 +283,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({ onClick={handleSave} disabled={!canManageSystemDefaultModel} > - {t($ => $['operation.save'], { ns: 'common' })} + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/app/components/header/account-setting/model-provider-page/utils.ts b/web/app/components/header/account-setting/model-provider-page/utils.ts index 9defa2cda67c64..5598385449c87c 100644 --- a/web/app/components/header/account-setting/model-provider-page/utils.ts +++ b/web/app/components/header/account-setting/model-provider-page/utils.ts @@ -12,8 +12,14 @@ import type { ModelItem, TypeWithI18N, } from './declarations' -import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from '@/app/components/base/icons/src/public/llm' - +import { + AnthropicShortLight, + Deepseek, + Gemini, + Grok, + OpenaiSmall, + Tongyi, +} from '@/app/components/base/icons/src/public/llm' import { ModelProviderQuotaGetPaid } from '@/types/model-provider' import { ConfigurationMethodEnum, @@ -31,9 +37,19 @@ export const providerToPluginId = (providerKey: string): string => { return lastSlash > 0 ? providerKey.slice(0, lastSlash) : '' } -export const MODEL_PROVIDER_QUOTA_GET_PAID = [ModelProviderQuotaGetPaid.OPENAI, ModelProviderQuotaGetPaid.ANTHROPIC, ModelProviderQuotaGetPaid.GEMINI, ModelProviderQuotaGetPaid.X, ModelProviderQuotaGetPaid.DEEPSEEK, ModelProviderQuotaGetPaid.TONGYI] - -export const providerIconMap: Record<ModelProviderQuotaGetPaid, ComponentType<{ className?: string }>> = { +export const MODEL_PROVIDER_QUOTA_GET_PAID = [ + ModelProviderQuotaGetPaid.OPENAI, + ModelProviderQuotaGetPaid.ANTHROPIC, + ModelProviderQuotaGetPaid.GEMINI, + ModelProviderQuotaGetPaid.X, + ModelProviderQuotaGetPaid.DEEPSEEK, + ModelProviderQuotaGetPaid.TONGYI, +] + +export const providerIconMap: Record< + ModelProviderQuotaGetPaid, + ComponentType<{ className?: string }> +> = { [ModelProviderQuotaGetPaid.OPENAI]: OpenaiSmall, [ModelProviderQuotaGetPaid.ANTHROPIC]: AnthropicShortLight, [ModelProviderQuotaGetPaid.GEMINI]: Gemini, @@ -66,20 +82,19 @@ export const isNullOrUndefined = (value: unknown): value is null | undefined => export const sizeFormat = (size: number) => { const remainder = Math.floor(size / 1000) - if (remainder < 1) - return `${size}` - else - return `${remainder}K` + if (remainder < 1) return `${size}` + else return `${remainder}K` } export const modelTypeFormat = (modelType: ModelTypeEnum) => { - if (modelType === ModelTypeEnum.textEmbedding) - return 'TEXT EMBEDDING' + if (modelType === ModelTypeEnum.textEmbedding) return 'TEXT EMBEDDING' return modelType.toLocaleUpperCase() } -export const genModelTypeFormSchema = (modelTypes: ModelTypeEnum[]): Omit<CredentialFormSchemaSelect, 'name'> => { +export const genModelTypeFormSchema = ( + modelTypes: ModelTypeEnum[], +): Omit<CredentialFormSchemaSelect, 'name'> => { return { type: FormTypeEnum.select, label: { @@ -103,7 +118,9 @@ export const genModelTypeFormSchema = (modelTypes: ModelTypeEnum[]): Omit<Creden } } -export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInput, 'label' | 'placeholder'>): Omit<CredentialFormSchemaTextInput, 'name'> => { +export const genModelNameFormSchema = ( + model?: Pick<CredentialFormSchemaTextInput, 'label' | 'placeholder'>, +): Omit<CredentialFormSchemaTextInput, 'name'> => { return { type: FormTypeEnum.textInput, label: model?.label || { @@ -121,25 +138,25 @@ export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInpu } const modelTypeMap: Record<ModelType, ModelTypeEnum> = { - 'llm': ModelTypeEnum.textGeneration, - 'moderation': ModelTypeEnum.moderation, - 'rerank': ModelTypeEnum.rerank, - 'speech2text': ModelTypeEnum.speech2text, + llm: ModelTypeEnum.textGeneration, + moderation: ModelTypeEnum.moderation, + rerank: ModelTypeEnum.rerank, + speech2text: ModelTypeEnum.speech2text, 'text-embedding': ModelTypeEnum.textEmbedding, - 'tts': ModelTypeEnum.tts, + tts: ModelTypeEnum.tts, } const modelFeatureMap: Record<ModelFeature, ModelFeatureEnum> = { 'agent-thought': ModelFeatureEnum.agentThought, - 'audio': ModelFeatureEnum.audio, - 'document': ModelFeatureEnum.document, + audio: ModelFeatureEnum.audio, + document: ModelFeatureEnum.document, 'multi-tool-call': ModelFeatureEnum.multiToolCall, - 'polling': ModelFeatureEnum.polling, + polling: ModelFeatureEnum.polling, 'stream-tool-call': ModelFeatureEnum.streamToolCall, 'structured-output': ModelFeatureEnum.StructuredOutput, 'tool-call': ModelFeatureEnum.toolCall, - 'video': ModelFeatureEnum.video, - 'vision': ModelFeatureEnum.vision, + video: ModelFeatureEnum.video, + vision: ModelFeatureEnum.vision, } const fetchFromMap: Record<FetchFrom, ConfigurationMethodEnum> = { @@ -148,9 +165,9 @@ const fetchFromMap: Record<FetchFrom, ConfigurationMethodEnum> = { } const modelStatusMap: Record<ModelStatus, ModelStatusEnum> = { - 'active': ModelStatusEnum.active, + active: ModelStatusEnum.active, 'credential-removed': ModelStatusEnum.credentialRemoved, - 'disabled': ModelStatusEnum.disabled, + disabled: ModelStatusEnum.disabled, 'no-configure': ModelStatusEnum.noConfigure, 'no-permission': ModelStatusEnum.noPermission, 'quota-exceeded': ModelStatusEnum.quotaExceeded, @@ -167,8 +184,7 @@ const normalizeModelProperties = ( const normalized: ModelItem['model_properties'] = {} Object.entries(modelProperties).forEach(([key, value]) => { - if (typeof value === 'string' || typeof value === 'number') - normalized[key] = value + if (typeof value === 'string' || typeof value === 'number') normalized[key] = value }) return normalized @@ -178,7 +194,7 @@ const normalizeModelProviderModel = (model: ModelWithProviderEntityResponse): Mo model: model.model, label: normalizeModelLabel(model.label), model_type: modelTypeMap[model.model_type], - features: model.features?.map(feature => modelFeatureMap[feature]), + features: model.features?.map((feature) => modelFeatureMap[feature]), fetch_from: fetchFromMap[model.fetch_from], status: modelStatusMap[model.status], model_properties: normalizeModelProperties(model.model_properties), @@ -187,6 +203,6 @@ const normalizeModelProviderModel = (model: ModelWithProviderEntityResponse): Mo has_invalid_load_balancing_configs: model.has_invalid_load_balancing_configs, }) -export const normalizeModelProviderModelsResponse = ( - response: { data: ModelWithProviderEntityResponse[] }, -): ModelItem[] => response.data.map(normalizeModelProviderModel) +export const normalizeModelProviderModelsResponse = (response: { + data: ModelWithProviderEntityResponse[] +}): ModelItem[] => response.data.map(normalizeModelProviderModel) diff --git a/web/app/components/header/account-setting/permission-group-list.tsx b/web/app/components/header/account-setting/permission-group-list.tsx index 9c7a26be98b7b8..a8af9f7bb08d76 100644 --- a/web/app/components/header/account-setting/permission-group-list.tsx +++ b/web/app/components/header/account-setting/permission-group-list.tsx @@ -28,10 +28,9 @@ const PermissionGroupList = ({ const selectedSet = useMemo(() => new Set(value), [value]) const defaultExpandedGroupKeys = useMemo(() => { - if (groups.length === 0) - return new Set<string>() - const firstSelectedGroup = groups.find(group => - group.permissions.some(permission => selectedSet.has(permission.key)), + if (groups.length === 0) return new Set<string>() + const firstSelectedGroup = groups.find((group) => + group.permissions.some((permission) => selectedSet.has(permission.key)), ) return new Set([(firstSelectedGroup ?? groups[0]!).group_key]) }, [groups, selectedSet]) @@ -41,42 +40,41 @@ const PermissionGroupList = ({ const toggleGroupExpanded = (groupKey: string) => { setExpandedGroupKeys((prev) => { const next = new Set(prev ?? expandedGroupKeysMerged) - if (next.has(groupKey)) - next.delete(groupKey) - else - next.add(groupKey) + if (next.has(groupKey)) next.delete(groupKey) + else next.add(groupKey) return next }) } const togglePermission = (permissionKey: string) => { - if (readonly) - return + if (readonly) return - if (selectedSet.has(permissionKey)) - onChange(value.filter(key => key !== permissionKey)) - else - onChange([...value, permissionKey]) + if (selectedSet.has(permissionKey)) onChange(value.filter((key) => key !== permissionKey)) + else onChange([...value, permissionKey]) } const toggleGroupSelection = (group: PermissionGroup, selectedCount: number) => { - if (readonly) - return + if (readonly) return - const permissionKeys = group.permissions.map(permission => permission.key) + const permissionKeys = group.permissions.map((permission) => permission.key) if (selectedCount === permissionKeys.length) { const groupPermissionKeySet = new Set(permissionKeys) - onChange(value.filter(key => !groupPermissionKeySet.has(key))) + onChange(value.filter((key) => !groupPermissionKeySet.has(key))) return } const next = new Set(value) - permissionKeys.forEach(key => next.add(key)) + permissionKeys.forEach((key) => next.add(key)) onChange(Array.from(next)) } return ( - <div className={cn('min-h-0 flex-1 rounded-xl border border-components-panel-border bg-components-panel-bg shadow-xs', className)}> + <div + className={cn( + 'min-h-0 flex-1 rounded-xl border border-components-panel-border bg-components-panel-bg shadow-xs', + className, + )} + > <ScrollArea className="h-full overflow-hidden rounded-xl" slotClassNames={{ @@ -84,104 +82,106 @@ const PermissionGroupList = ({ content: groups.length === 0 ? 'h-full' : undefined, }} > - {groups.length === 0 - ? ( - <div className="flex h-full items-center justify-center px-3 py-6 text-center system-sm-regular text-text-tertiary"> - {t($ => $['permissionList.noPermissionsFound'], { ns: 'permission' })} - </div> - ) - : ( - <div className="flex flex-col"> - {groups.map((group) => { - const expanded = expandedGroupKeysMerged.has(group.group_key) - const selectedCount = group.permissions.reduce( - (count, permission) => count + (selectedSet.has(permission.key) ? 1 : 0), - 0, - ) - const totalCount = group.permissions.length - const allSelected = totalCount > 0 && selectedCount === totalCount - - return ( - <div key={group.group_key} className="border-b border-b-divider-subtle"> - <div className="group/permission-category flex h-12 w-full items-center gap-3 px-3 hover:bg-state-base-hover"> - <button - type="button" - className="min-w-0 flex-1 truncate text-left system-md-medium text-text-secondary focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active" - aria-expanded={expanded} - onClick={() => toggleGroupExpanded(group.group_key)} - > - {group.group_name} - </button> - {!readonly && totalCount > 0 && ( - <button - type="button" - className="shrink-0 rounded-md px-1.5 py-0.5 system-sm-medium text-text-accent opacity-0 group-hover/permission-category:opacity-100 hover:bg-state-accent-hover focus-visible:opacity-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active" - onClick={() => toggleGroupSelection(group, selectedCount)} - > - {allSelected - ? t($ => $['permissionList.clearAll'], { ns: 'permission' }) - : t($ => $['permissionList.selectAll'], { ns: 'permission' })} - </button> + {groups.length === 0 ? ( + <div className="flex h-full items-center justify-center px-3 py-6 text-center system-sm-regular text-text-tertiary"> + {t(($) => $['permissionList.noPermissionsFound'], { ns: 'permission' })} + </div> + ) : ( + <div className="flex flex-col"> + {groups.map((group) => { + const expanded = expandedGroupKeysMerged.has(group.group_key) + const selectedCount = group.permissions.reduce( + (count, permission) => count + (selectedSet.has(permission.key) ? 1 : 0), + 0, + ) + const totalCount = group.permissions.length + const allSelected = totalCount > 0 && selectedCount === totalCount + + return ( + <div key={group.group_key} className="border-b border-b-divider-subtle"> + <div className="group/permission-category flex h-12 w-full items-center gap-3 px-3 hover:bg-state-base-hover"> + <button + type="button" + className="min-w-0 flex-1 truncate text-left system-md-medium text-text-secondary focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active" + aria-expanded={expanded} + onClick={() => toggleGroupExpanded(group.group_key)} + > + {group.group_name} + </button> + {!readonly && totalCount > 0 && ( + <button + type="button" + className="shrink-0 rounded-md px-1.5 py-0.5 system-sm-medium text-text-accent opacity-0 group-hover/permission-category:opacity-100 hover:bg-state-accent-hover focus-visible:opacity-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active" + onClick={() => toggleGroupSelection(group, selectedCount)} + > + {allSelected + ? t(($) => $['permissionList.clearAll'], { ns: 'permission' }) + : t(($) => $['permissionList.selectAll'], { ns: 'permission' })} + </button> + )} + {selectedCount > 0 && ( + <span className="shrink-0 rounded-md bg-util-colors-blue-blue-100 px-2 py-0.5 system-sm-medium text-text-accent"> + {selectedCount}/{totalCount} + </span> + )} + <button + type="button" + className="flex h-6 w-6 shrink-0 items-center justify-center focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active" + aria-label={ + expanded + ? t(($) => $['permissionList.collapseGroup'], { ns: 'permission' }) + : t(($) => $['permissionList.expandGroup'], { ns: 'permission' }) + } + onClick={() => toggleGroupExpanded(group.group_key)} + > + <span + aria-hidden + className={cn( + 'i-ri-arrow-right-s-fill h-4 w-4 text-text-tertiary transition-transform', + expanded && 'rotate-90', )} - {selectedCount > 0 && ( - <span className="shrink-0 rounded-md bg-util-colors-blue-blue-100 px-2 py-0.5 system-sm-medium text-text-accent"> - {selectedCount} - / - {totalCount} - </span> - )} - <button - type="button" - className="flex h-6 w-6 shrink-0 items-center justify-center focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active" - aria-label={expanded ? t($ => $['permissionList.collapseGroup'], { ns: 'permission' }) : t($ => $['permissionList.expandGroup'], { ns: 'permission' })} - onClick={() => toggleGroupExpanded(group.group_key)} - > - <span - aria-hidden + /> + </button> + </div> + {expanded && ( + <div className="flex flex-col"> + {group.permissions.map((permission) => { + const checked = selectedSet.has(permission.key) + + return ( + <div + key={permission.key} className={cn( - 'i-ri-arrow-right-s-fill h-4 w-4 text-text-tertiary transition-transform', - expanded && 'rotate-90', + 'flex min-h-9 items-center gap-3 px-3 py-1.5', + readonly + ? 'cursor-default' + : 'cursor-pointer hover:bg-state-base-hover', + checked && 'bg-state-accent-hover', + checked && !readonly && 'hover:bg-state-accent-hover', )} - /> - </button> - </div> - {expanded && ( - <div className="flex flex-col"> - {group.permissions.map((permission) => { - const checked = selectedSet.has(permission.key) - - return ( - <div - key={permission.key} - className={cn( - 'flex min-h-9 items-center gap-3 px-3 py-1.5', - readonly ? 'cursor-default' : 'cursor-pointer hover:bg-state-base-hover', - checked && 'bg-state-accent-hover', - checked && !readonly && 'hover:bg-state-accent-hover', - )} - onClick={() => togglePermission(permission.key)} - > - <Checkbox - checked={checked} - disabled={readonly} - className="shrink-0" - onCheckedChange={() => { - togglePermission(permission.key) - }} - /> - <span className="min-w-0 flex-1 truncate system-md-regular text-text-secondary"> - {permission.name} - </span> - </div> - ) - })} - </div> - )} + onClick={() => togglePermission(permission.key)} + > + <Checkbox + checked={checked} + disabled={readonly} + className="shrink-0" + onCheckedChange={() => { + togglePermission(permission.key) + }} + /> + <span className="min-w-0 flex-1 truncate system-md-regular text-text-secondary"> + {permission.name} + </span> + </div> + ) + })} </div> - ) - })} - </div> - )} + )} + </div> + ) + })} + </div> + )} </ScrollArea> </div> ) 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 747e0e72c86711..cd217213775534 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 @@ -2,7 +2,10 @@ import type { Role } from '@/models/access-control' import { toast } from '@langgenius/dify-ui/toast' import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useCreateWorkspaceRole, useUpdateWorkspaceRole } from '@/service/access-control/use-workspace-roles' +import { + useCreateWorkspaceRole, + useUpdateWorkspaceRole, +} from '@/service/access-control/use-workspace-roles' import { useRoleGroups } from '../hooks' import PermissionsPage from '../index' @@ -50,7 +53,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -86,8 +90,12 @@ vi.mock('../role-list', () => ({ {role && ( <> <span>{role.name}</span> - <button type="button" onClick={() => onView(role)}>view role</button> - <button type="button" onClick={() => onEdit(role)}>edit role</button> + <button type="button" onClick={() => onView(role)}> + view role + </button> + <button type="button" onClick={() => onEdit(role)}> + edit role + </button> </> )} </div> @@ -103,17 +111,19 @@ vi.mock('../role-modal', () => ({ }: { mode: string role?: Role - onSubmit: (data: { name: string, description: string, permissionKeys: string[] }) => void + onSubmit: (data: { name: string; description: string; permissionKeys: string[] }) => void }) => ( <div role="dialog" aria-label={`${mode} role`}> <span>{role?.name}</span> <button type="button" - onClick={() => onSubmit({ - name: `${mode} name`, - description: `${mode} description`, - permissionKeys: ['workspace.member.manage'], - })} + onClick={() => + onSubmit({ + name: `${mode} name`, + description: `${mode} description`, + permissionKeys: ['workspace.member.manage'], + }) + } > submit role </button> @@ -160,26 +170,38 @@ describe('PermissionsPage', () => { vi.clearAllMocks() mocks.workspacePermissionKeys = [] vi.mocked(useRoleGroups).mockReturnValue({ - roleGroups: [{ - id: 'custom', - category: 'global_custom', - title: 'Custom roles', - items: [role], - }], + roleGroups: [ + { + id: 'custom', + category: 'global_custom', + title: 'Custom roles', + items: [role], + }, + ], isLoading: false, isFetchingNextPage: false, fetchNextPage: vi.fn(), hasNextPage: false, error: null, } as ReturnType<typeof useRoleGroups>) - vi.mocked(useCreateWorkspaceRole).mockReturnValue(mockMutation(mocks.createWorkspaceRole) as unknown as ReturnType<typeof useCreateWorkspaceRole>) - vi.mocked(useUpdateWorkspaceRole).mockReturnValue(mockMutation(mocks.updateWorkspaceRole) as unknown as ReturnType<typeof useUpdateWorkspaceRole>) + vi.mocked(useCreateWorkspaceRole).mockReturnValue( + mockMutation(mocks.createWorkspaceRole) as unknown as ReturnType< + typeof useCreateWorkspaceRole + >, + ) + vi.mocked(useUpdateWorkspaceRole).mockReturnValue( + mockMutation(mocks.updateWorkspaceRole) as unknown as ReturnType< + typeof useUpdateWorkspaceRole + >, + ) }) it('hides role creation without workspace role manage permission', () => { renderPermissionsPage() - expect(screen.queryByRole('button', { name: 'permission.role.addRole' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'permission.role.addRole' }), + ).not.toBeInTheDocument() expect(screen.getByText('Custom manager')).toBeInTheDocument() }) @@ -227,11 +249,14 @@ describe('PermissionsPage', () => { expect(screen.getByRole('dialog', { name: 'create role' })).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'submit role' })) - expect(mocks.createWorkspaceRole).toHaveBeenCalledWith({ - name: 'create name', - description: 'create description', - permission_keys: ['workspace.member.manage'], - }, expect.any(Object)) + expect(mocks.createWorkspaceRole).toHaveBeenCalledWith( + { + name: 'create name', + description: 'create description', + permission_keys: ['workspace.member.manage'], + }, + expect.any(Object), + ) expect(toast.success).toHaveBeenCalledWith('permission.role.created') }) @@ -244,12 +269,15 @@ describe('PermissionsPage', () => { expect(within(dialog).getByText('Custom manager')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'submit role' })) - expect(mocks.updateWorkspaceRole).toHaveBeenCalledWith({ - id: 'role-1', - name: 'edit name', - description: 'edit description', - permission_keys: ['workspace.member.manage'], - }, expect.any(Object)) + expect(mocks.updateWorkspaceRole).toHaveBeenCalledWith( + { + id: 'role-1', + name: 'edit name', + description: 'edit description', + permission_keys: ['workspace.member.manage'], + }, + expect.any(Object), + ) expect(toast.success).toHaveBeenCalledWith('permission.role.updated') }) }) diff --git a/web/app/components/header/account-setting/permissions-page/helpers.ts b/web/app/components/header/account-setting/permissions-page/helpers.ts index f302814fb63ed1..23652f002e74ff 100644 --- a/web/app/components/header/account-setting/permissions-page/helpers.ts +++ b/web/app/components/header/account-setting/permissions-page/helpers.ts @@ -2,13 +2,14 @@ import type { InfiniteData } from '@tanstack/react-query' import type { RoleListGroup } from './role-list' import type { RoleListResponse } from '@/models/access-control' -export const formatRoleGroups = (roleListResponse: InfiniteData<RoleListResponse> | undefined): RoleListGroup[] => { - if (!roleListResponse) - return [] - const roles = roleListResponse.pages.flatMap(page => page.data) +export const formatRoleGroups = ( + roleListResponse: InfiniteData<RoleListResponse> | undefined, +): RoleListGroup[] => { + if (!roleListResponse) return [] + const roles = roleListResponse.pages.flatMap((page) => page.data) const result: RoleListGroup[] = [] - const builtinRoles = roles.filter(role => role.is_builtin) - const customRoles = roles.filter(role => !role.is_builtin) + const builtinRoles = roles.filter((role) => role.is_builtin) + const customRoles = roles.filter((role) => !role.is_builtin) if (builtinRoles.length > 0) { result.push({ id: 'builtin', 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 14bcfaaea2a11e..37dfb402823cbf 100644 --- a/web/app/components/header/account-setting/permissions-page/index.tsx +++ b/web/app/components/header/account-setting/permissions-page/index.tsx @@ -10,7 +10,10 @@ import { useTranslation } from 'react-i18next' import { useLocale } from '@/context/i18n' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { getAccessControlTemplateLanguage } from '@/i18n-config/language' -import { useCreateWorkspaceRole, useUpdateWorkspaceRole } from '@/service/access-control/use-workspace-roles' +import { + useCreateWorkspaceRole, + useUpdateWorkspaceRole, +} from '@/service/access-control/use-workspace-roles' import { hasPermission } from '@/utils/permission' import { useRoleGroups } from './hooks' import RoleList from './role-list' @@ -37,19 +40,13 @@ const PermissionsPage = ({ containerRef }: PermissionsPageProps) => { const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale]) - const { - roleGroups, - isLoading, - isFetchingNextPage, - fetchNextPage, - hasNextPage, - error, - } = useRoleGroups({ - page: 1, - limit: PAGE_SIZE, - include_owner: 1, - language, - }) + const { roleGroups, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, error } = + useRoleGroups({ + page: 1, + limit: PAGE_SIZE, + include_owner: 1, + language, + }) const { mutateAsync: createWorkspaceRole } = useCreateWorkspaceRole() const { mutateAsync: updateWorkspaceRole } = useUpdateWorkspaceRole() @@ -74,20 +71,25 @@ const PermissionsPage = ({ containerRef }: PermissionsPageProps) => { const mode = modalState?.mode ?? '' const roleId = modalState?.role?.id ?? '' if (mode === 'create') { - createWorkspaceRole({ name, description, permission_keys: permissionKeys }, { - onSuccess: () => { - toast.success(t($ => $['role.created'], { ns: 'permission' })) - closeModal() + createWorkspaceRole( + { name, description, permission_keys: permissionKeys }, + { + onSuccess: () => { + toast.success(t(($) => $['role.created'], { ns: 'permission' })) + closeModal() + }, }, - }) - } - else if (mode === 'edit') { - updateWorkspaceRole({ id: roleId, name, description, permission_keys: permissionKeys }, { - onSuccess: () => { - toast.success(t($ => $['role.updated'], { ns: 'permission' })) - closeModal() + ) + } else if (mode === 'edit') { + updateWorkspaceRole( + { id: roleId, name, description, permission_keys: permissionKeys }, + { + onSuccess: () => { + toast.success(t(($) => $['role.updated'], { ns: 'permission' })) + closeModal() + }, }, - }) + ) } }, [createWorkspaceRole, updateWorkspaceRole, closeModal, modalState, t], @@ -98,8 +100,7 @@ const PermissionsPage = ({ containerRef }: PermissionsPageProps) => { let observer: IntersectionObserver | undefined if (error) { - if (observer) - observer.disconnect() + if (observer) observer.disconnect() return } @@ -107,13 +108,16 @@ const PermissionsPage = ({ containerRef }: PermissionsPageProps) => { const containerHeight = containerRef.current.clientHeight const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) - observer = new IntersectionObserver((entries) => { - if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore) - fetchNextPage() - }, { - root: containerRef.current, - rootMargin: `${dynamicMargin}px`, - }) + observer = new IntersectionObserver( + (entries) => { + if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore) + fetchNextPage() + }, + { + root: containerRef.current, + rootMargin: `${dynamicMargin}px`, + }, + ) observer.observe(anchorRef.current) } return () => observer?.disconnect() @@ -127,21 +131,16 @@ const PermissionsPage = ({ containerRef }: PermissionsPageProps) => { <div className="flex min-h-[67px] min-w-0 items-center gap-3 overflow-hidden rounded-xl border-t-[0.5px] border-l-[0.5px] border-divider-regular bg-linear-to-b from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 px-4 py-3"> <div className="flex min-w-0 grow flex-col gap-y-1 overflow-hidden"> <div className="truncate system-md-semibold text-text-secondary"> - {t($ => $['role.workspaceRoles.title'], { ns: 'permission' })} + {t(($) => $['role.workspaceRoles.title'], { ns: 'permission' })} </div> <div className="truncate system-xs-regular text-text-tertiary"> - {t($ => $['role.workspaceRoles.description'], { ns: 'permission' })} + {t(($) => $['role.workspaceRoles.description'], { ns: 'permission' })} </div> </div> {canManageRoles && ( <div className="flex shrink-0 items-center"> - <Button - variant="primary" - size="small" - onClick={openCreate} - disabled={isLoading} - > - {t($ => $['role.addRole'], { ns: 'permission' })} + <Button variant="primary" size="small" onClick={openCreate} disabled={isLoading}> + {t(($) => $['role.addRole'], { ns: 'permission' })} </Button> </div> )} diff --git a/web/app/components/header/account-setting/permissions-page/role-list/__tests__/index.spec.tsx b/web/app/components/header/account-setting/permissions-page/role-list/__tests__/index.spec.tsx index c1bf2f46dd412a..e28921556af054 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/__tests__/index.spec.tsx @@ -49,12 +49,7 @@ describe('RoleList', () => { describe('Rendering', () => { it('shows a loading status while the first page is loading', () => { - render( - <RoleList - groups={[]} - isLoading - />, - ) + render(<RoleList groups={[]} isLoading />) expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() expect(screen.queryByText(/permission\.role\.groups/)).not.toBeInTheDocument() @@ -74,13 +69,15 @@ describe('RoleList', () => { id: 'custom', category: 'global_custom', title: 'Custom Roles', - items: [createRole({ - id: 'role-custom', - category: 'global_custom', - name: 'Executive', - description: 'Unrestricted access to all workspace operations', - is_builtin: false, - })], + items: [ + createRole({ + id: 'role-custom', + category: 'global_custom', + name: 'Executive', + description: 'Unrestricted access to all workspace operations', + is_builtin: false, + }), + ], }, ]} />, @@ -156,13 +153,15 @@ describe('RoleList', () => { id: 'custom', category: 'global_custom', title: 'Custom Roles', - items: [createRole({ - id: 'role-custom', - category: 'global_custom', - name: 'Partner', - description: '', - is_builtin: false, - })], + items: [ + createRole({ + id: 'role-custom', + category: 'global_custom', + name: 'Partner', + description: '', + is_builtin: false, + }), + ], }, ]} />, 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 44b816102928a9..6597024aa6b4b6 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 @@ -7,9 +7,11 @@ const mockWorkspacePermissionKeys = vi.hoisted(() => ({ value: ['workspace.role.manage'] as string[], })) -const mockCopyRole = vi.hoisted(() => vi.fn((_variables, options?: { onSuccess?: () => void }) => { - options?.onSuccess?.() -})) +const mockCopyRole = vi.hoisted(() => + vi.fn((_variables, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }), +) const mockDeleteRole = vi.hoisted(() => vi.fn()) const membersOfRoleQueryMock = vi.hoisted(() => { const response = { @@ -62,7 +64,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -110,29 +113,38 @@ const openMenu = async () => { describe('RowMenu', () => { beforeEach(() => { vi.clearAllMocks() - document.body.querySelectorAll('[role="menu"], [role="menuitem"]').forEach(element => element.remove()) + document.body + .querySelectorAll('[role="menu"], [role="menuitem"]') + .forEach((element) => element.remove()) mockWorkspacePermissionKeys.value = ['workspace.role.manage'] }) afterEach(() => { - document.body.querySelectorAll('[role="menu"], [role="menuitem"]').forEach(element => element.remove()) + document.body + .querySelectorAll('[role="menu"], [role="menuitem"]') + .forEach((element) => element.remove()) }) describe('Rendering', () => { it('should render view action for the owner system role', async () => { render( - <RowMenu - roleCategory="global_system_default" - role={createRole({ role_tag: 'owner' })} - />, + <RowMenu roleCategory="global_system_default" role={createRole({ role_tag: 'owner' })} />, ) const { menu } = await openMenu() - expect(within(menu).getByRole('menuitem', { name: 'common.operation.view' })).toBeInTheDocument() - expect(within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' })).toBeInTheDocument() - expect(within(menu).queryByRole('menuitem', { name: 'common.operation.edit' })).not.toBeInTheDocument() - expect(within(menu).queryByRole('menuitem', { name: 'common.operation.delete' })).not.toBeInTheDocument() + expect( + within(menu).getByRole('menuitem', { name: 'common.operation.view' }), + ).toBeInTheDocument() + expect( + within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' }), + ).toBeInTheDocument() + expect( + within(menu).queryByRole('menuitem', { name: 'common.operation.edit' }), + ).not.toBeInTheDocument() + expect( + within(menu).queryByRole('menuitem', { name: 'common.operation.delete' }), + ).not.toBeInTheDocument() }) it('should render view action for non-owner system roles', async () => { @@ -145,8 +157,12 @@ describe('RowMenu', () => { const { menu } = await openMenu() - expect(within(menu).getByRole('menuitem', { name: 'common.operation.view' })).toBeInTheDocument() - expect(within(menu).queryByRole('menuitem', { name: 'common.operation.edit' })).not.toBeInTheDocument() + expect( + within(menu).getByRole('menuitem', { name: 'common.operation.view' }), + ).toBeInTheDocument() + expect( + within(menu).queryByRole('menuitem', { name: 'common.operation.edit' }), + ).not.toBeInTheDocument() }) it('should hide view action for custom roles', async () => { @@ -164,10 +180,18 @@ describe('RowMenu', () => { const { menu } = await openMenu() - expect(within(menu).queryByRole('menuitem', { name: 'common.operation.view' })).not.toBeInTheDocument() - expect(within(menu).getByRole('menuitem', { name: 'common.operation.edit' })).toBeInTheDocument() - expect(within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' })).toBeInTheDocument() - expect(within(menu).getByRole('menuitem', { name: 'common.operation.delete' })).toBeInTheDocument() + expect( + within(menu).queryByRole('menuitem', { name: 'common.operation.view' }), + ).not.toBeInTheDocument() + expect( + within(menu).getByRole('menuitem', { name: 'common.operation.edit' }), + ).toBeInTheDocument() + expect( + within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' }), + ).toBeInTheDocument() + expect( + within(menu).getByRole('menuitem', { name: 'common.operation.delete' }), + ).toBeInTheDocument() }) it('should keep custom role management actions visible without manage permission', async () => { @@ -187,9 +211,16 @@ describe('RowMenu', () => { const { menu } = await openMenu() - expect(within(menu).getByRole('menuitem', { name: 'common.operation.edit' })).toHaveAttribute('aria-disabled', 'true') - expect(within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' })).toHaveAttribute('aria-disabled', 'true') - expect(within(menu).getByRole('menuitem', { name: 'common.operation.delete' })).toHaveAttribute('aria-disabled', 'true') + expect(within(menu).getByRole('menuitem', { name: 'common.operation.edit' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect( + within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' }), + ).toHaveAttribute('aria-disabled', 'true') + expect( + within(menu).getByRole('menuitem', { name: 'common.operation.delete' }), + ).toHaveAttribute('aria-disabled', 'true') }) }) @@ -197,13 +228,7 @@ describe('RowMenu', () => { it('should call onView when clicking the owner view action', async () => { const onView = vi.fn() const role = createRole({ role_tag: 'owner' }) - render( - <RowMenu - roleCategory="global_system_default" - role={role} - onView={onView} - />, - ) + render(<RowMenu roleCategory="global_system_default" role={role} onView={onView} />) const { user, menu } = await openMenu() await user.click(within(menu).getByRole('menuitem', { name: 'common.operation.view' })) @@ -214,19 +239,18 @@ describe('RowMenu', () => { it('should ask before duplicating a system role and skipping member assignments', async () => { const role = createRole({ id: 'role-default-editor', name: 'Editor' }) - render( - <RowMenu - roleCategory="global_system_default" - role={role} - />, - ) + render(<RowMenu roleCategory="global_system_default" role={role} />) const { user, menu } = await openMenu() - await user.click(within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' })) + await user.click( + within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' }), + ) const dialog = screen.getByRole('alertdialog') expect(dialog).toHaveTextContent('permission.role.copyMembersTitle') - expect(dialog).toHaveTextContent('permission.role.copyMembersDescription:{"name":"Editor","count":3}') + expect(dialog).toHaveTextContent( + 'permission.role.copyMembersDescription:{"name":"Editor","count":3}', + ) expect(membersOfRoleQueryMock.useGetMembersOfRole).toHaveBeenCalledWith({ roleId: role.id, page: 1, @@ -236,36 +260,44 @@ describe('RowMenu', () => { await user.click(within(dialog).getByRole('button', { name: 'common.operation.skip' })) expect(mockCopyRole).toHaveBeenCalledTimes(1) - expect(mockCopyRole).toHaveBeenCalledWith({ - roleId: role.id, - copy_member: false, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect(mockCopyRole).toHaveBeenCalledWith( + { + roleId: role.id, + copy_member: false, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) }) it('should copy member assignments when confirming duplicate with copy', async () => { - const role = createRole({ id: 'role-custom', category: 'global_custom', name: 'Support', is_builtin: false }) - render( - <RowMenu - roleCategory="global_custom" - role={role} - />, - ) + const role = createRole({ + id: 'role-custom', + category: 'global_custom', + name: 'Support', + is_builtin: false, + }) + render(<RowMenu roleCategory="global_custom" role={role} />) const { user, menu } = await openMenu() - await user.click(within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' })) + await user.click( + within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' }), + ) const dialog = screen.getByRole('alertdialog') await user.click(within(dialog).getByRole('button', { name: 'common.operation.copy' })) expect(mockCopyRole).toHaveBeenCalledTimes(1) - expect(mockCopyRole).toHaveBeenCalledWith({ - roleId: role.id, - copy_member: true, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect(mockCopyRole).toHaveBeenCalledWith( + { + roleId: role.id, + copy_member: true, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) }) it('should close the copy member assignments dialog when clicking the backdrop', async () => { @@ -277,7 +309,9 @@ describe('RowMenu', () => { ) const { user, menu } = await openMenu() - await user.click(within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' })) + await user.click( + within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' }), + ) expect(screen.getByRole('alertdialog')).toBeInTheDocument() @@ -299,18 +333,14 @@ describe('RowMenu', () => { name: 'Custom role', is_builtin: false, }) - render( - <RowMenu - roleCategory="global_custom" - role={role} - onEdit={onEdit} - />, - ) + render(<RowMenu roleCategory="global_custom" role={role} onEdit={onEdit} />) const { menu } = await openMenu() fireEvent.click(within(menu).getByRole('menuitem', { name: 'common.operation.edit' })) - fireEvent.click(within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' })) + fireEvent.click( + within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' }), + ) fireEvent.click(within(menu).getByRole('menuitem', { name: 'common.operation.delete' })) expect(onEdit).not.toHaveBeenCalled() diff --git a/web/app/components/header/account-setting/permissions-page/role-list/copy-members-confirm-dialog.tsx b/web/app/components/header/account-setting/permissions-page/role-list/copy-members-confirm-dialog.tsx index c4c53a99c85031..3e0712c438992e 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/copy-members-confirm-dialog.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/copy-members-confirm-dialog.tsx @@ -45,18 +45,16 @@ export function CopyMembersConfirmDialog({ > <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary"> - {t($ => $['role.copyMembersTitle'], { ns: 'permission' })} + {t(($) => $['role.copyMembersTitle'], { ns: 'permission' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-secondary"> - { - isLoadingMemberCount - ? t($ => $['role.copyMembersLoading'], { ns: 'permission' }) - : t($ => $['role.copyMembersDescription'], { - ns: 'permission', - name: role.name, - count: memberCount, - }) - } + {isLoadingMemberCount + ? t(($) => $['role.copyMembersLoading'], { ns: 'permission' }) + : t(($) => $['role.copyMembersDescription'], { + ns: 'permission', + name: role.name, + count: memberCount, + })} </AlertDialogDescription> </div> <AlertDialogActions> @@ -65,7 +63,7 @@ export function CopyMembersConfirmDialog({ disabled={isActionDisabled} onClick={() => onDuplicate(false)} > - {t($ => $['operation.skip'], { ns: 'common' })} + {t(($) => $['operation.skip'], { ns: 'common' })} </Button> <Button variant="primary" @@ -73,7 +71,7 @@ export function CopyMembersConfirmDialog({ loading={isCopyingRole} onClick={() => onDuplicate(true)} > - {t($ => $['operation.copy'], { ns: 'common' })} + {t(($) => $['operation.copy'], { ns: 'common' })} </Button> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/header/account-setting/permissions-page/role-list/index.tsx b/web/app/components/header/account-setting/permissions-page/role-list/index.tsx index ad0ab830cf84b7..a8eacabb84bff8 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/index.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/index.tsx @@ -43,16 +43,16 @@ const RoleList = ({ return ( <div className={cn('flex min-w-0 flex-col gap-y-6', className)}> - {groups.map(group => ( - <section - key={group.id} - className="flex min-w-0 flex-col gap-y-1" - > + {groups.map((group) => ( + <section key={group.id} className="flex min-w-0 flex-col gap-y-1"> <div className="flex min-h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $[`role.groups.${group.id}`], { ns: 'permission', defaultValue: group.title })} + {t(($) => $[`role.groups.${group.id}`], { + ns: 'permission', + defaultValue: group.title, + })} </div> <div className="flex flex-col"> - {group.items.map(row => ( + {group.items.map((row) => ( <Row key={row.id} name={row.name} 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 cb367704ee15e0..a38a0359e8b69b 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 @@ -23,7 +23,10 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { useCopyWorkspaceRole, useDeleteWorkspaceRole } from '@/service/access-control/use-workspace-roles' +import { + useCopyWorkspaceRole, + useDeleteWorkspaceRole, +} from '@/service/access-control/use-workspace-roles' import { hasPermission } from '@/utils/permission' import { CopyMembersConfirmDialog } from './copy-members-confirm-dialog' @@ -34,12 +37,7 @@ type RowMenuProps = { onEdit?: (role: Role) => void } -const RowMenu = ({ - roleCategory, - role, - onView, - onEdit, -}: RowMenuProps) => { +const RowMenu = ({ roleCategory, role, onView, onEdit }: RowMenuProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) @@ -51,8 +49,7 @@ const RowMenu = ({ const handleView = useCallback(() => onView?.(role), [onView, role]) const handleEdit = useCallback(() => { - if (!canManageRoles) - return + if (!canManageRoles) return onEdit?.(role) }, [canManageRoles, onEdit, role]) @@ -60,45 +57,47 @@ const RowMenu = ({ const { mutate: copyRole, isPending: isCopyingRole } = useCopyWorkspaceRole() const openCopyMembersConfirm = useCallback(() => { - if (!canManageRoles) - return + if (!canManageRoles) return setShowCopyMembersConfirm(true) setOpen(false) }, [canManageRoles]) - const handleDuplicate = useCallback((copyMember: boolean) => { - if (!canManageRoles) - return - - copyRole({ - roleId: role.id, - copy_member: copyMember, - }, { - onSuccess: () => { - toast.success(t($ => $['role.duplicated'], { ns: 'permission' })) - setShowCopyMembersConfirm(false) - }, - }) - }, [canManageRoles, copyRole, role.id, t]) + const handleDuplicate = useCallback( + (copyMember: boolean) => { + if (!canManageRoles) return + + copyRole( + { + roleId: role.id, + copy_member: copyMember, + }, + { + onSuccess: () => { + toast.success(t(($) => $['role.duplicated'], { ns: 'permission' })) + setShowCopyMembersConfirm(false) + }, + }, + ) + }, + [canManageRoles, copyRole, role.id, t], + ) const { mutateAsync: deleteRole, isPending: isDeletingRole } = useDeleteWorkspaceRole() const openDeleteConfirm = useCallback(() => { - if (!canManageRoles) - return + if (!canManageRoles) return setShowDeleteConfirm(true) setOpen(false) }, [canManageRoles]) const handleDelete = useCallback(() => { - if (!canManageRoles) - return + if (!canManageRoles) return deleteRole(role.id, { onSuccess: () => { - toast.success(t($ => $['role.deleted'], { ns: 'permission' })) + toast.success(t(($) => $['role.deleted'], { ns: 'permission' })) setShowDeleteConfirm(false) }, }) @@ -111,69 +110,76 @@ const RowMenu = ({ return ( <> <DropdownMenu open={open} onOpenChange={setOpen}> - <DropdownMenuTrigger render={<ActionButton size="m" className={cn('shrink-0', open && 'bg-state-base-hover')} aria-label={t($ => $['operation.moreActions'], { ns: 'common' })} />}> + <DropdownMenuTrigger + render={ + <ActionButton + size="m" + className={cn('shrink-0', open && 'bg-state-base-hover')} + aria-label={t(($) => $['operation.moreActions'], { ns: 'common' })} + /> + } + > <span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" /> </DropdownMenuTrigger> <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[160px]"> - { - hasViewAction && ( - <DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={handleView}> - {t($ => $['operation.view'], { ns: 'common' })} - </DropdownMenuItem> - ) - } - { - hasEditAction && ( - <DropdownMenuItem - disabled={!canManageRoles} - className="system-sm-semibold text-text-secondary" - onClick={handleEdit} - > - {t($ => $['operation.edit'], { ns: 'common' })} - </DropdownMenuItem> - ) - } + {hasViewAction && ( + <DropdownMenuItem + className="system-sm-semibold text-text-secondary" + onClick={handleView} + > + {t(($) => $['operation.view'], { ns: 'common' })} + </DropdownMenuItem> + )} + {hasEditAction && ( + <DropdownMenuItem + disabled={!canManageRoles} + className="system-sm-semibold text-text-secondary" + onClick={handleEdit} + > + {t(($) => $['operation.edit'], { ns: 'common' })} + </DropdownMenuItem> + )} <DropdownMenuItem disabled={!canManageRoles} className="system-sm-semibold text-text-secondary" onClick={openCopyMembersConfirm} > - {t($ => $['common.duplicateAction'], { ns: 'permission' })} + {t(($) => $['common.duplicateAction'], { ns: 'permission' })} </DropdownMenuItem> - { - hasDeleteAction && ( - <> - <DropdownMenuSeparator /> - <DropdownMenuItem - disabled={!canManageRoles} - variant="destructive" - className="system-sm-semibold" - onClick={openDeleteConfirm} - > - {t($ => $['operation.delete'], { ns: 'common' })} - </DropdownMenuItem> - </> - ) - } + {hasDeleteAction && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + disabled={!canManageRoles} + variant="destructive" + className="system-sm-semibold" + onClick={openDeleteConfirm} + > + {t(($) => $['operation.delete'], { ns: 'common' })} + </DropdownMenuItem> + </> + )} </DropdownMenuContent> </DropdownMenu> - <AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && setShowDeleteConfirm(false)}> + <AlertDialog + open={showDeleteConfirm} + onOpenChange={(open) => !open && setShowDeleteConfirm(false)} + > <AlertDialogContent backdropProps={{ forceRender: true }}> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['role.deleteTitle'], { ns: 'permission', name: role.name })} + {t(($) => $['role.deleteTitle'], { ns: 'permission', name: role.name })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['role.deleteDescription'], { ns: 'permission' })} + {t(($) => $['role.deleteDescription'], { ns: 'permission' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton - disabled={isDeletingRole} - onClick={handleDelete} - > - {t($ => $['operation.delete'], { ns: 'common' })} + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton disabled={isDeletingRole} onClick={handleDelete}> + {t(($) => $['operation.delete'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/header/account-setting/permissions-page/role-list/row.tsx b/web/app/components/header/account-setting/permissions-page/role-list/row.tsx index 673f0153867261..d9301bbfcd57ab 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/row.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/row.tsx @@ -14,15 +14,7 @@ type RowProps = { onEdit?: (role: Role) => void } -const Row = ({ - className, - name, - description, - roleCategory, - role, - onView, - onEdit, -}: RowProps) => { +const Row = ({ className, name, description, roleCategory, role, onView, onEdit }: RowProps) => { const { t } = useTranslation() return ( @@ -33,19 +25,12 @@ const Row = ({ )} > <div className="min-w-0 flex-1 space-y-1"> - <div className="truncate system-sm-semibold text-text-primary"> - {name} - </div> + <div className="truncate system-sm-semibold text-text-primary">{name}</div> <p className="truncate system-xs-regular text-text-secondary"> - {description || t($ => $['role.noDescription'], { ns: 'permission' })} + {description || t(($) => $['role.noDescription'], { ns: 'permission' })} </p> </div> - <RowMenu - roleCategory={roleCategory} - role={role} - onView={onView} - onEdit={onEdit} - /> + <RowMenu roleCategory={roleCategory} role={role} onView={onView} onEdit={onEdit} /> </div> ) } diff --git a/web/app/components/header/account-setting/permissions-page/role-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/permissions-page/role-modal/__tests__/index.spec.tsx index 15de6c990146cc..3fca3fa1a67229 100644 --- a/web/app/components/header/account-setting/permissions-page/role-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-modal/__tests__/index.spec.tsx @@ -50,31 +50,23 @@ describe('RoleModal', () => { describe('Rendering', () => { it('should render edit mode with role values and selected permissions', () => { render( - <RoleModal - open - mode="edit" - role={createRole()} - onClose={vi.fn()} - onSubmit={vi.fn()} - />, + <RoleModal open mode="edit" role={createRole()} onClose={vi.fn()} onSubmit={vi.fn()} />, ) expect(screen.getByText('permission.role.modal.edit.title')).toBeInTheDocument() expect(screen.getByLabelText('permission.role.modal.nameLabel')).toHaveValue('Operator') - expect(screen.getByLabelText('permission.role.modal.descriptionLabel')).toHaveValue('Can operate workspace') - expect(screen.getByRole('button', { name: /Workspace management/ })).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByLabelText('permission.role.modal.descriptionLabel')).toHaveValue( + 'Can operate workspace', + ) + expect(screen.getByRole('button', { name: /Workspace management/ })).toHaveAttribute( + 'aria-expanded', + 'true', + ) expect(screen.getByText(/workspace\.member\.manage/)).toBeInTheDocument() }) it('should disable confirm action when role name is empty', () => { - render( - <RoleModal - open - mode="create" - onClose={vi.fn()} - onSubmit={vi.fn()} - />, - ) + render(<RoleModal open mode="create" onClose={vi.fn()} onSubmit={vi.fn()} />) expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeDisabled() }) @@ -87,17 +79,13 @@ describe('RoleModal', () => { const handleClose = vi.fn() const handleSubmit = vi.fn() - render( - <RoleModal - open - mode="create" - onClose={handleClose} - onSubmit={handleSubmit} - />, - ) + render(<RoleModal open mode="create" onClose={handleClose} onSubmit={handleSubmit} />) await user.type(screen.getByLabelText('permission.role.modal.nameLabel'), ' Support role ') - await user.type(screen.getByLabelText('permission.role.modal.descriptionLabel'), ' Helps members ') + await user.type( + screen.getByLabelText('permission.role.modal.descriptionLabel'), + ' Helps members ', + ) await user.click(screen.getByText(/workspace\.member\.manage/)) await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) @@ -145,20 +133,18 @@ describe('RoleModal', () => { describe('Read-only Mode', () => { it('should render role details as read-only in view mode', () => { render( - <RoleModal - open - mode="view" - role={createRole()} - onClose={vi.fn()} - onSubmit={vi.fn()} - />, + <RoleModal open mode="view" role={createRole()} onClose={vi.fn()} onSubmit={vi.fn()} />, ) expect(screen.getByLabelText('permission.role.modal.nameLabel')).toBeDisabled() expect(screen.getByLabelText('permission.role.modal.descriptionLabel')).toBeDisabled() - expect(screen.queryByRole('button', { name: 'common.operation.confirm' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.confirm' }), + ).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'permission.permissionList.clearAll' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'permission.permissionList.clearAll' }), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/header/account-setting/permissions-page/role-modal/hooks.ts b/web/app/components/header/account-setting/permissions-page/role-modal/hooks.ts index d16248eff46727..a5b2e587089e60 100644 --- a/web/app/components/header/account-setting/permissions-page/role-modal/hooks.ts +++ b/web/app/components/header/account-setting/permissions-page/role-modal/hooks.ts @@ -9,30 +9,30 @@ export const useWorkspacePermissionGroups = () => { const groups = useMemo(() => { // Permission keys come from the catalog API, so these are reviewed open-key boundaries with server-provided fallbacks. - const translatePermissionGroupName = (groupKey: string, defaultValue: string) => t(`group.${groupKey}` as SelectorKey, { - ns: 'permission', - defaultValue, - }) - const translatePermissionName = (key: string, defaultValue: string) => t(key as SelectorKey, { - ns: 'permissionKeys', - defaultValue, - }) + const translatePermissionGroupName = (groupKey: string, defaultValue: string) => + t(`group.${groupKey}` as SelectorKey, { + ns: 'permission', + defaultValue, + }) + const translatePermissionName = (key: string, defaultValue: string) => + t(key as SelectorKey, { + ns: 'permissionKeys', + defaultValue, + }) - return (workspacePermissionCatalog?.groups || []).map(group => ({ + return (workspacePermissionCatalog?.groups || []).map((group) => ({ ...group, group_name: translatePermissionGroupName(group.group_key, group.group_name), - permissions: group.permissions.map(permission => ({ + permissions: group.permissions.map((permission) => ({ ...permission, name: translatePermissionName(permission.key, permission.name), })), })) }, [t, workspacePermissionCatalog?.groups]) - const allPermissions = groups.flatMap(g => g.permissions) || [] + const allPermissions = groups.flatMap((g) => g.permissions) || [] - const permissionMap = Object.fromEntries( - allPermissions.map(p => [p.key, p]), - ) + const permissionMap = Object.fromEntries(allPermissions.map((p) => [p.key, p])) return { groups, diff --git a/web/app/components/header/account-setting/permissions-page/role-modal/index.tsx b/web/app/components/header/account-setting/permissions-page/role-modal/index.tsx index 811636f7728452..6950017f9ed0f2 100644 --- a/web/app/components/header/account-setting/permissions-page/role-modal/index.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-modal/index.tsx @@ -33,13 +33,7 @@ type RoleModalProps = { onSubmit?: (data: submitRoleData) => void } -const RoleModal = ({ - mode, - open, - role, - onClose, - onSubmit, -}: RoleModalProps) => { +const RoleModal = ({ mode, open, role, onClose, onSubmit }: RoleModalProps) => { const { t } = useTranslation() const locale = useLocale() const docLanguage = getDocLanguage(locale) @@ -66,8 +60,7 @@ const RoleModal = ({ <Dialog open={open} onOpenChange={(nextOpen) => { - if (!nextOpen) - onClose() + if (!nextOpen) onClose() }} > <DialogContent @@ -78,10 +71,10 @@ const RoleModal = ({ <DialogCloseButton /> <div className="pr-8"> <DialogTitle className="system-xl-semibold text-text-primary"> - {t($ => $[`role.modal.${mode}.title`], { ns: 'permission' })} + {t(($) => $[`role.modal.${mode}.title`], { ns: 'permission' })} </DialogTitle> <DialogDescription className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $[`role.modal.${mode}.description`], { ns: 'permission' })} + {t(($) => $[`role.modal.${mode}.description`], { ns: 'permission' })} </DialogDescription> </div> </div> @@ -89,25 +82,25 @@ const RoleModal = ({ <div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-hidden px-6 py-5"> <div className="flex shrink-0 flex-col gap-1"> <label htmlFor="role-name" className="system-sm-medium text-text-secondary"> - {t($ => $['role.modal.nameLabel'], { ns: 'permission' })} + {t(($) => $['role.modal.nameLabel'], { ns: 'permission' })} </label> <Input id="role-name" value={name} onChange={onRoleNameChange} - placeholder={t($ => $['role.modal.namePlaceholder'], { ns: 'permission' })} + placeholder={t(($) => $['role.modal.namePlaceholder'], { ns: 'permission' })} disabled={readonly} /> </div> <div className="flex shrink-0 flex-col gap-1"> <label htmlFor="role-description" className="system-sm-medium text-text-secondary"> - {t($ => $['role.modal.descriptionLabel'], { ns: 'permission' })} + {t(($) => $['role.modal.descriptionLabel'], { ns: 'permission' })} </label> <Textarea id="role-description" value={desc} onValueChange={onRoleDescChange} - placeholder={t($ => $['role.modal.descriptionPlaceholder'], { ns: 'permission' })} + placeholder={t(($) => $['role.modal.descriptionPlaceholder'], { ns: 'permission' })} disabled={readonly} className="min-h-24 resize-none" /> @@ -125,20 +118,18 @@ const RoleModal = ({ rel="noreferrer" className="inline-flex items-center gap-1 system-xs-medium text-text-accent hover:underline" > - <span>{t($ => $['permissionSet.learnMore'], { ns: 'permission' })}</span> + <span>{t(($) => $['permissionSet.learnMore'], { ns: 'permission' })}</span> <span aria-hidden className="i-ri-external-link-line h-3.5 w-3.5" /> </a> <div className="flex items-center gap-2"> <Button variant="secondary" onClick={onClose}> - {readonly ? t($ => $['operation.close'], { ns: 'common' }) : t($ => $['operation.cancel'], { ns: 'common' })} + {readonly + ? t(($) => $['operation.close'], { ns: 'common' }) + : t(($) => $['operation.cancel'], { ns: 'common' })} </Button> {!readonly && ( - <Button - variant="primary" - disabled={!name.trim()} - onClick={handleSubmit} - > - {t($ => $['operation.confirm'], { ns: 'common' })} + <Button variant="primary" disabled={!name.trim()} onClick={handleSubmit}> + {t(($) => $['operation.confirm'], { ns: 'common' })} </Button> )} </div> diff --git a/web/app/components/header/account-setting/permissions-page/role-modal/permission-field.tsx b/web/app/components/header/account-setting/permissions-page/role-modal/permission-field.tsx index 5cdb227c39279f..38925399d82a2e 100644 --- a/web/app/components/header/account-setting/permissions-page/role-modal/permission-field.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-modal/permission-field.tsx @@ -9,16 +9,14 @@ type PermissionFieldProps = { readonly?: boolean } -const PermissionField = ({ - value, - onChange, - readonly = false, -}: PermissionFieldProps) => { +const PermissionField = ({ value, onChange, readonly = false }: PermissionFieldProps) => { const { t } = useTranslation() return ( <div className="flex min-h-0 flex-1 flex-col gap-2"> - <div className="system-sm-medium text-text-secondary">{t($ => $['permissionSet.permissions'], { ns: 'permission' })}</div> + <div className="system-sm-medium text-text-secondary"> + {t(($) => $['permissionSet.permissions'], { ns: 'permission' })} + </div> <PermissionPicker value={value} onChange={onChange} readonly={readonly} /> </div> ) 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 74cb982e9bf5de..7c58550bdc9662 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 @@ -41,20 +41,33 @@ vi.mock('@langgenius/dify-ui/select', async () => { <button type="button" disabled={context.disabled}> {children} </button> - <button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}> + <button + data-testid="select-empty" + type="button" + onClick={() => context.onValueChange?.('')} + > empty value </button> - <button data-testid="select-invalid" type="button" onClick={() => context.onValueChange?.('__missing__')}> + <button + data-testid="select-invalid" + type="button" + onClick={() => context.onValueChange?.('__missing__')} + > invalid value </button> </div> ) }, SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => { const context = React.useContext(SelectContext) return ( - <button type="button" role="option" aria-selected={false} onClick={() => context.onValueChange?.(value)}> + <button + type="button" + role="option" + aria-selected={false} + onClick={() => context.onValueChange?.(value)} + > {children} </button> ) @@ -105,7 +118,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -123,7 +137,9 @@ vi.mock('@/i18n-config', () => ({ const updateUserProfileMock = vi.mocked(updateUserProfile) -const createUserProfile = (overrides: Partial<GetAccountProfileResponse> = {}): GetAccountProfileResponse => ({ +const createUserProfile = ( + overrides: Partial<GetAccountProfileResponse> = {}, +): GetAccountProfileResponse => ({ id: 'user-id', name: 'Test User', email: 'test@example.com', @@ -147,8 +163,7 @@ const renderPage = () => { const getSectionByLabel = (sectionLabel: string) => { const label = screen.getByText(sectionLabel) const section = label.closest('div')?.parentElement - if (!section) - throw new Error(`Missing select section: ${sectionLabel}`) + if (!section) throw new Error(`Missing select section: ${sectionLabel}`) return section } @@ -163,16 +178,14 @@ const selectOption = async (sectionLabel: string, optionName: string) => { } const getLanguageOption = (value: string) => { - const option = languages.find(item => item.value === value) - if (!option) - throw new Error(`Missing language option: ${value}`) + const option = languages.find((item) => item.value === value) + if (!option) throw new Error(`Missing language option: ${value}`) return option } const getTimezoneOption = (value: string) => { - const option = timezones.find(item => item.value === value) - if (!option) - throw new Error(`Missing timezone option: ${value}`) + const option = timezones.find((item) => item.value === value) + if (!option) throw new Error(`Missing timezone option: ${value}`) return option } 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 c5b6d9afb9be83..389509f70c01c2 100644 --- a/web/app/components/header/account-setting/preference-page/index.tsx +++ b/web/app/components/header/account-setting/preference-page/index.tsx @@ -1,6 +1,13 @@ 'use client' import type { Locale } from '@/i18n-config' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +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' @@ -28,7 +35,7 @@ const titleClassName = ` mb-1 system-sm-medium text-text-secondary ` const themes = ['system', 'light', 'dark'] as const -type ThemeOption = typeof themes[number] +type ThemeOption = (typeof themes)[number] const isThemeOption = (value: string): value is ThemeOption => { return (themes as readonly string[]).includes(value) @@ -42,18 +49,19 @@ export default function PreferencePage() { const { t } = useTranslation() const router = useRouter() const { theme, setTheme } = useTheme() - const languageOptions: SelectOption[] = languages.filter(item => item.supported) + const languageOptions: SelectOption[] = languages.filter((item) => item.supported) const themeOptions: SelectOption[] = [ - { value: 'system', name: t($ => $['account.appearanceFollowSystem'], { ns: 'common' }) }, - { value: 'light', name: t($ => $['account.appearanceLight'], { ns: 'common' }) }, - { value: 'dark', name: t($ => $['account.appearanceDark'], { ns: 'common' }) }, + { value: 'system', name: t(($) => $['account.appearanceFollowSystem'], { ns: 'common' }) }, + { value: 'light', name: t(($) => $['account.appearanceLight'], { ns: 'common' }) }, + { value: 'dark', name: t(($) => $['account.appearanceDark'], { ns: 'common' }) }, ] - const selectedLanguage = languageOptions.find(item => item.value === (locale || userProfile.interface_language)) - const selectedTheme = themeOptions.find(item => item.value === (theme || 'system')) - const selectedTimezone = timezones.find(item => item.value === userProfile.timezone) + const selectedLanguage = languageOptions.find( + (item) => item.value === (locale || userProfile.interface_language), + ) + const selectedTheme = themeOptions.find((item) => item.value === (theme || 'system')) + const selectedTimezone = timezones.find((item) => item.value === userProfile.timezone) const handleSelectTheme = (item: SelectOption) => { - if (isThemeOption(item.value)) - setTheme(item.value) + if (isThemeOption(item.value)) setTheme(item.value) } const handleSelectLanguage = async (item: SelectOption) => { const url = '/account/interface-language' @@ -61,14 +69,12 @@ export default function PreferencePage() { setEditing(true) try { await updateUserProfile({ url, body: { [bodyKey]: item.value } }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) setLocaleOnClient(item.value.toString() as Locale, false) router.refresh() - } - catch (e) { + } catch (e) { toast.error((e as Error).message) - } - finally { + } finally { setEditing(false) } } @@ -78,35 +84,33 @@ export default function PreferencePage() { setEditing(true) try { await updateUserProfile({ url, body: { [bodyKey]: item.value } }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) refreshUserProfile() - } - catch (e) { + } catch (e) { toast.error((e as Error).message) - } - finally { + } finally { setEditing(false) } } return ( <> <div className="mb-6"> - <div className={titleClassName}>{t($ => $['account.appearanceLabel'], { ns: 'common' })}</div> + <div className={titleClassName}> + {t(($) => $['account.appearanceLabel'], { ns: 'common' })} + </div> <Select value={selectedTheme?.value ?? 'system'} onValueChange={(nextValue) => { - if (!nextValue) - return - const nextItem = themeOptions.find(item => item.value === nextValue) - if (nextItem) - handleSelectTheme(nextItem) + if (!nextValue) return + const nextItem = themeOptions.find((item) => item.value === nextValue) + if (nextItem) handleSelectTheme(nextItem) }} > <SelectTrigger size="medium"> - {selectedTheme?.name ?? t($ => $['account.appearanceFollowSystem'], { ns: 'common' })} + {selectedTheme?.name ?? t(($) => $['account.appearanceFollowSystem'], { ns: 'common' })} </SelectTrigger> <SelectContent> - {themeOptions.map(item => ( + {themeOptions.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -116,23 +120,23 @@ export default function PreferencePage() { </Select> </div> <div className="mb-6"> - <div className={titleClassName}>{t($ => $['language.displayLanguage'], { ns: 'common' })}</div> + <div className={titleClassName}> + {t(($) => $['language.displayLanguage'], { ns: 'common' })} + </div> <Select value={selectedLanguage?.value ?? null} disabled={editing} onValueChange={(nextValue) => { - if (nextValue == null) - return - const nextItem = languageOptions.find(item => item.value === nextValue) - if (nextItem) - handleSelectLanguage(nextItem) + if (nextValue == null) return + const nextItem = languageOptions.find((item) => item.value === nextValue) + if (nextItem) handleSelectLanguage(nextItem) }} > <SelectTrigger size="medium"> - {selectedLanguage?.name ?? t($ => $['placeholder.select'], { ns: 'common' })} + {selectedLanguage?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })} </SelectTrigger> <SelectContent> - {languageOptions.map(item => ( + {languageOptions.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -142,23 +146,21 @@ export default function PreferencePage() { </Select> </div> <div className="mb-6"> - <div className={titleClassName}>{t($ => $['language.timezone'], { ns: 'common' })}</div> + <div className={titleClassName}>{t(($) => $['language.timezone'], { ns: 'common' })}</div> <Select value={selectedTimezone?.value ?? null} disabled={editing} onValueChange={(nextValue) => { - if (!nextValue) - return - const nextItem = timezones.find(item => item.value === nextValue) - if (nextItem) - handleSelectTimezone(nextItem) + if (!nextValue) return + const nextItem = timezones.find((item) => item.value === nextValue) + if (nextItem) handleSelectTimezone(nextItem) }} > <SelectTrigger size="medium"> - {selectedTimezone?.name ?? t($ => $['placeholder.select'], { ns: 'common' })} + {selectedTimezone?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })} </SelectTrigger> <SelectContent> - {timezones.map(item => ( + {timezones.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> diff --git a/web/app/components/header/account-setting/update-setting-dialog-form.tsx b/web/app/components/header/account-setting/update-setting-dialog-form.tsx index d813f7c28a321e..aa02f372b72fd2 100644 --- a/web/app/components/header/account-setting/update-setting-dialog-form.tsx +++ b/web/app/components/header/account-setting/update-setting-dialog-form.tsx @@ -10,7 +10,10 @@ import { Trans, useTranslation } from 'react-i18next' import TimePicker from '@/app/components/base/date-and-time-picker/time-picker' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import PluginsPicker from '@/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker' -import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' +import { + AUTO_UPDATE_MODE, + AUTO_UPDATE_STRATEGY, +} from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' import { convertLocalSecondsToUTCDaySeconds } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/utils' import { useModalContextSelector } from '@/context/modal-context' import UpdateSettingOptionCard from './update-setting-option-card' @@ -36,7 +39,8 @@ type UpdateSettingDialogFormProps = { renderTimePickerTrigger: (params: TriggerParams) => ReactElement } -const updateSettingFormLabelClassName = 'flex min-h-6 w-full items-center system-sm-medium text-text-secondary' +const updateSettingFormLabelClassName = + 'flex min-h-6 w-full items-center system-sm-medium text-text-secondary' function SettingTimeZone({ children, @@ -45,7 +49,7 @@ function SettingTimeZone({ children?: ReactNode onRequestClose: () => void }) { - const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) + const setShowAccountSettingModal = useModalContextSelector((s) => s.setShowAccountSettingModal) return ( <button @@ -82,11 +86,11 @@ const UpdateSettingDialogForm = ({ const getStrategyDescription = (strategy: AUTO_UPDATE_STRATEGY) => { switch (strategy) { case AUTO_UPDATE_STRATEGY.disabled: - return t($ => $['autoUpdate.strategy.disabled.description'], { ns: 'plugin' }) + return t(($) => $['autoUpdate.strategy.disabled.description'], { ns: 'plugin' }) case AUTO_UPDATE_STRATEGY.fixOnly: - return t($ => $['autoUpdate.strategy.fixOnly.description'], { ns: 'plugin' }) + return t(($) => $['autoUpdate.strategy.fixOnly.description'], { ns: 'plugin' }) case AUTO_UPDATE_STRATEGY.latest: - return t($ => $['autoUpdate.strategy.latest.description'], { ns: 'plugin' }) + return t(($) => $['autoUpdate.strategy.latest.description'], { ns: 'plugin' }) default: return '' } @@ -98,15 +102,15 @@ const UpdateSettingDialogForm = ({ <div className="flex w-full flex-col items-start gap-1"> <div className="flex w-full flex-col items-start gap-1"> <div className={updateSettingFormLabelClassName}> - {t($ => $['autoUpdate.autoUpdate'], { ns: 'plugin' })} + {t(($) => $['autoUpdate.autoUpdate'], { ns: 'plugin' })} </div> <RadioGroup<AUTO_UPDATE_STRATEGY> - aria-label={t($ => $['autoUpdate.autoUpdate'], { ns: 'plugin' })} + aria-label={t(($) => $['autoUpdate.autoUpdate'], { ns: 'plugin' })} className="flex w-full gap-2" value={autoUpgrade.strategy_setting} - onValueChange={strategy_setting => onAutoUpgradeChange({ strategy_setting })} + onValueChange={(strategy_setting) => onAutoUpgradeChange({ strategy_setting })} > - {strategyOptions.map(option => ( + {strategyOptions.map((option) => ( <UpdateSettingOptionCard<AUTO_UPDATE_STRATEGY> key={option.value} value={option.value} @@ -118,9 +122,7 @@ const UpdateSettingDialogForm = ({ /> ))} </RadioGroup> - <div className="w-full body-xs-regular text-text-tertiary"> - {strategyDescription} - </div> + <div className="w-full body-xs-regular text-text-tertiary">{strategyDescription}</div> </div> </div> {autoUpgrade.strategy_setting !== AUTO_UPDATE_STRATEGY.disabled && ( @@ -129,11 +131,11 @@ const UpdateSettingDialogForm = ({ <div className="flex w-full flex-col items-start gap-1"> <div className="flex w-full items-center gap-2"> <div className={cn(updateSettingFormLabelClassName, 'min-w-0 flex-1')}> - {t($ => $['autoUpdate.updateTime'], { ns: 'plugin' })} + {t(($) => $['autoUpdate.updateTime'], { ns: 'plugin' })} </div> <div className="body-xs-regular text-text-tertiary"> <Trans - i18nKey={$ => $['autoUpdate.changeTimezone']} + i18nKey={($) => $['autoUpdate.changeTimezone']} ns="plugin" components={{ setTimezone: <SettingTimeZone onRequestClose={onRequestClose} />, @@ -145,10 +147,12 @@ const UpdateSettingDialogForm = ({ value={updateTimeValue} timezone={timezone} onChange={onUpdateTimeChange} - onClear={() => onAutoUpgradeChange({ - upgrade_time_of_day: convertLocalSecondsToUTCDaySeconds(0, timezone), - })} - title={t($ => $['autoUpdate.updateTime'], { ns: 'plugin' })} + onClear={() => + onAutoUpgradeChange({ + upgrade_time_of_day: convertLocalSecondsToUTCDaySeconds(0, timezone), + }) + } + title={t(($) => $['autoUpdate.updateTime'], { ns: 'plugin' })} minuteFilter={minuteFilter} renderTrigger={renderTimePickerTrigger} placement="bottom-start" @@ -157,15 +161,15 @@ const UpdateSettingDialogForm = ({ <div className="flex w-full flex-col items-start gap-2"> <div className="flex h-[60px] w-full flex-col items-start gap-1"> <div className={updateSettingFormLabelClassName}> - {t($ => $['autoUpdate.scope'], { ns: 'plugin' })} + {t(($) => $['autoUpdate.scope'], { ns: 'plugin' })} </div> <RadioGroup<AUTO_UPDATE_MODE> - aria-label={t($ => $['autoUpdate.scope'], { ns: 'plugin' })} + aria-label={t(($) => $['autoUpdate.scope'], { ns: 'plugin' })} className="flex w-full gap-2" value={autoUpgrade.upgrade_mode} - onValueChange={upgrade_mode => onAutoUpgradeChange({ upgrade_mode })} + onValueChange={(upgrade_mode) => onAutoUpgradeChange({ upgrade_mode })} > - {scopeOptions.map(option => ( + {scopeOptions.map((option) => ( <UpdateSettingOptionCard<AUTO_UPDATE_MODE> key={option.value} value={option.value} 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 dd4784a7a4d694..83c625dd780320 100644 --- a/web/app/components/header/account-setting/update-setting-dialog.tsx +++ b/web/app/components/header/account-setting/update-setting-dialog.tsx @@ -5,16 +5,33 @@ import type { AutoUpdateConfig } from '@/app/components/plugins/reference-settin import type { PluginCategoryEnum } from '@/app/components/plugins/types' 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 { + 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 { + 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 { userProfileAtom } from '@/context/account-state' -import { useMutationPluginAutoUpgradeSettings, usePluginAutoUpgradeSettings } from '@/service/use-plugins' +import { + useMutationPluginAutoUpgradeSettings, + usePluginAutoUpgradeSettings, +} from '@/service/use-plugins' import UpdateSettingDialogForm from './update-setting-dialog-form' type Props = { @@ -22,10 +39,7 @@ type Props = { disabled?: boolean } -const UpdateSettingDialog = ({ - category, - disabled = false, -}: Props) => { +const UpdateSettingDialog = ({ category, disabled = false }: Props) => { const { t } = useTranslation() const userProfile = useAtomValue(userProfileAtom) const timezone = userProfile.timezone || 'UTC' @@ -35,34 +49,37 @@ const UpdateSettingDialog = ({ isFetching, isLoading, } = usePluginAutoUpgradeSettings(category) - const { mutate: saveAutoUpgrade, isPending: isSavePending } = useMutationPluginAutoUpgradeSettings({ - category, - onSuccess: () => { - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - }, - }) + const { mutate: saveAutoUpgrade, isPending: isSavePending } = + useMutationPluginAutoUpgradeSettings({ + category, + onSuccess: () => { + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + }, + }) const savedAutoUpgrade = autoUpgradeSetting?.auto_upgrade const [isOpen, setIsOpen] = useState(false) const [draftAutoUpgrade, setDraftAutoUpgrade] = useState<AutoUpdateConfig>() const isSettingsLoading = !savedAutoUpgrade && !error && (isLoading || isFetching) const autoUpgrade = draftAutoUpgrade ?? savedAutoUpgrade const hasSettings = !!autoUpgrade - const getStrategyLabel = useCallback((strategy: AUTO_UPDATE_STRATEGY) => { - switch (strategy) { - case AUTO_UPDATE_STRATEGY.disabled: - return t($ => $['autoUpdate.strategy.disabled.name'], { ns: 'plugin' }) - case AUTO_UPDATE_STRATEGY.fixOnly: - return t($ => $['autoUpdate.strategy.fixOnly.name'], { ns: 'plugin' }) - case AUTO_UPDATE_STRATEGY.latest: - return t($ => $['autoUpdate.strategy.latest.name'], { ns: 'plugin' }) - default: - return '' - } - }, [t]) + const getStrategyLabel = useCallback( + (strategy: AUTO_UPDATE_STRATEGY) => { + switch (strategy) { + case AUTO_UPDATE_STRATEGY.disabled: + return t(($) => $['autoUpdate.strategy.disabled.name'], { ns: 'plugin' }) + case AUTO_UPDATE_STRATEGY.fixOnly: + return t(($) => $['autoUpdate.strategy.fixOnly.name'], { ns: 'plugin' }) + case AUTO_UPDATE_STRATEGY.latest: + return t(($) => $['autoUpdate.strategy.latest.name'], { ns: 'plugin' }) + default: + return '' + } + }, + [t], + ) const selectedStrategyLabel = autoUpgrade ? getStrategyLabel(autoUpgrade.strategy_setting) : '' const plugins = useMemo(() => { - if (!autoUpgrade) - return [] + if (!autoUpgrade) return [] switch (autoUpgrade.upgrade_mode) { case AUTO_UPDATE_MODE.partial: @@ -74,154 +91,169 @@ const UpdateSettingDialog = ({ } }, [autoUpgrade]) const updateTimeValue = useMemo(() => { - if (!autoUpgrade) - return '' + if (!autoUpgrade) return '' - const localSeconds = convertUTCDaySecondsToLocalSeconds(autoUpgrade.upgrade_time_of_day, timezone) + const localSeconds = convertUTCDaySecondsToLocalSeconds( + autoUpgrade.upgrade_time_of_day, + timezone, + ) return timeOfDayToDayjs(localSeconds).format('HH:mm') }, [autoUpgrade, timezone]) - const strategyOptions = useMemo(() => [ - { - value: AUTO_UPDATE_STRATEGY.disabled, - label: getStrategyLabel(AUTO_UPDATE_STRATEGY.disabled), - }, - { - value: AUTO_UPDATE_STRATEGY.fixOnly, - label: getStrategyLabel(AUTO_UPDATE_STRATEGY.fixOnly), - }, - { - value: AUTO_UPDATE_STRATEGY.latest, - label: getStrategyLabel(AUTO_UPDATE_STRATEGY.latest), - }, - ], [getStrategyLabel]) - const scopeOptions = useMemo(() => [ - { - value: AUTO_UPDATE_MODE.update_all, - label: t($ => $['autoUpdate.scopeMode.all'], { ns: 'plugin' }), - }, - { - value: AUTO_UPDATE_MODE.exclude, - label: t($ => $['autoUpdate.scopeMode.exclude'], { ns: 'plugin' }), - }, - { - value: AUTO_UPDATE_MODE.partial, - label: t($ => $['autoUpdate.upgradeMode.partial'], { ns: 'plugin' }), - }, - ], [t]) + const strategyOptions = useMemo( + () => [ + { + value: AUTO_UPDATE_STRATEGY.disabled, + label: getStrategyLabel(AUTO_UPDATE_STRATEGY.disabled), + }, + { + value: AUTO_UPDATE_STRATEGY.fixOnly, + label: getStrategyLabel(AUTO_UPDATE_STRATEGY.fixOnly), + }, + { + value: AUTO_UPDATE_STRATEGY.latest, + label: getStrategyLabel(AUTO_UPDATE_STRATEGY.latest), + }, + ], + [getStrategyLabel], + ) + const scopeOptions = useMemo( + () => [ + { + value: AUTO_UPDATE_MODE.update_all, + label: t(($) => $['autoUpdate.scopeMode.all'], { ns: 'plugin' }), + }, + { + value: AUTO_UPDATE_MODE.exclude, + label: t(($) => $['autoUpdate.scopeMode.exclude'], { ns: 'plugin' }), + }, + { + value: AUTO_UPDATE_MODE.partial, + label: t(($) => $['autoUpdate.upgradeMode.partial'], { ns: 'plugin' }), + }, + ], + [t], + ) - const updateAutoUpgrade = useCallback((payload: Partial<AutoUpdateConfig>) => { - setDraftAutoUpgrade((currentAutoUpgrade) => { - const baseAutoUpgrade = currentAutoUpgrade ?? savedAutoUpgrade - if (!baseAutoUpgrade) - return undefined + const updateAutoUpgrade = useCallback( + (payload: Partial<AutoUpdateConfig>) => { + setDraftAutoUpgrade((currentAutoUpgrade) => { + const baseAutoUpgrade = currentAutoUpgrade ?? savedAutoUpgrade + if (!baseAutoUpgrade) return undefined - return { - ...baseAutoUpgrade, - ...payload, - } - }) - }, [savedAutoUpgrade]) + return { + ...baseAutoUpgrade, + ...payload, + } + }) + }, + [savedAutoUpgrade], + ) - const handlePluginsChange = useCallback((newPlugins: string[]) => { - if (!autoUpgrade) - return + const handlePluginsChange = useCallback( + (newPlugins: string[]) => { + if (!autoUpgrade) return - if (autoUpgrade.upgrade_mode === AUTO_UPDATE_MODE.partial) { - updateAutoUpgrade({ - include_plugins: newPlugins, - }) - } - else if (autoUpgrade.upgrade_mode === AUTO_UPDATE_MODE.exclude) { - updateAutoUpgrade({ - exclude_plugins: newPlugins, - }) - } - }, [autoUpgrade, updateAutoUpgrade]) + if (autoUpgrade.upgrade_mode === AUTO_UPDATE_MODE.partial) { + updateAutoUpgrade({ + include_plugins: newPlugins, + }) + } else if (autoUpgrade.upgrade_mode === AUTO_UPDATE_MODE.exclude) { + updateAutoUpgrade({ + exclude_plugins: newPlugins, + }) + } + }, + [autoUpgrade, updateAutoUpgrade], + ) const minuteFilter = useCallback((minutes: string[]) => { return minutes.filter((m) => { const time = Number.parseInt(m, 10) return time % 15 === 0 }) }, []) - const handleUpdateTimeChange = useCallback((value: Parameters<typeof dayjsToTimeOfDay>[0]) => { - updateAutoUpgrade({ - upgrade_time_of_day: convertLocalSecondsToUTCDaySeconds(dayjsToTimeOfDay(value), timezone), - }) - }, [timezone, updateAutoUpgrade]) - const handleOpenChange = useCallback((open: boolean) => { - if (disabled) { - setIsOpen(false) - return - } + const handleUpdateTimeChange = useCallback( + (value: Parameters<typeof dayjsToTimeOfDay>[0]) => { + updateAutoUpgrade({ + upgrade_time_of_day: convertLocalSecondsToUTCDaySeconds(dayjsToTimeOfDay(value), timezone), + }) + }, + [timezone, updateAutoUpgrade], + ) + const handleOpenChange = useCallback( + (open: boolean) => { + if (disabled) { + setIsOpen(false) + return + } - setIsOpen(open) - if (open) - setDraftAutoUpgrade(savedAutoUpgrade) - else - setDraftAutoUpgrade(undefined) - }, [disabled, savedAutoUpgrade]) + setIsOpen(open) + if (open) setDraftAutoUpgrade(savedAutoUpgrade) + else setDraftAutoUpgrade(undefined) + }, + [disabled, savedAutoUpgrade], + ) const handleCancel = useCallback(() => { setDraftAutoUpgrade(undefined) setIsOpen(false) }, []) const handleSave = useCallback(() => { - if (!autoUpgrade) - return + if (!autoUpgrade) return saveAutoUpgrade(autoUpgrade) setDraftAutoUpgrade(undefined) setIsOpen(false) }, [autoUpgrade, saveAutoUpgrade]) - const renderTimePickerTrigger = useCallback(({ inputElem, onClick, isOpen }: TriggerParams) => { - return ( - <button - type="button" - className="group flex h-8 w-full cursor-pointer items-center gap-1 rounded-lg border-none bg-components-input-bg-normal px-2 py-1 text-left shadow-none hover:bg-state-base-hover-alt focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={onClick} - > - <span - aria-hidden - className={cn( - 'i-ri-time-line size-4 shrink-0 text-text-tertiary', - isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', - )} - /> - <span className="min-w-0 flex-1 p-1 system-sm-regular text-components-input-text-filled"> - {inputElem} - </span> - <span className="shrink-0 pr-0.5 system-sm-regular text-text-tertiary"> - {convertTimezoneToOffsetStr(timezone)} - </span> - </button> - ) - }, [timezone]) + const renderTimePickerTrigger = useCallback( + ({ inputElem, onClick, isOpen }: TriggerParams) => { + return ( + <button + type="button" + className="group flex h-8 w-full cursor-pointer items-center gap-1 rounded-lg border-none bg-components-input-bg-normal px-2 py-1 text-left shadow-none hover:bg-state-base-hover-alt focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onClick} + > + <span + aria-hidden + className={cn( + 'i-ri-time-line size-4 shrink-0 text-text-tertiary', + isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', + )} + /> + <span className="min-w-0 flex-1 p-1 system-sm-regular text-components-input-text-filled"> + {inputElem} + </span> + <span className="shrink-0 pr-0.5 system-sm-regular text-text-tertiary"> + {convertTimezoneToOffsetStr(timezone)} + </span> + </button> + ) + }, + [timezone], + ) return ( <Dialog open={isOpen} onOpenChange={handleOpenChange}> <DialogTrigger - render={( + render={ <Button variant="secondary" className="h-8 gap-0.5 px-3 system-sm-medium" disabled={disabled} > <span aria-hidden className="i-custom-vender-system-auto-update-line size-4" /> - <span className="px-0.5">{t($ => $['autoUpdate.autoUpdate'], { ns: 'plugin' })}</span> + <span className="px-0.5">{t(($) => $['autoUpdate.autoUpdate'], { ns: 'plugin' })}</span> {selectedStrategyLabel && ( <span className="flex min-w-4 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> {selectedStrategyLabel} </span> )} </Button> - )} + } /> {!disabled && ( - <DialogContent - className="flex w-[480px] max-w-[calc(100vw-32px)] flex-col overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl" - > + <DialogContent className="flex w-[480px] max-w-[calc(100vw-32px)] flex-col overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl"> <div className="relative flex w-full items-start gap-2 px-6 pt-6 pr-14 pb-3"> <DialogTitle className="min-w-0 flex-1 title-2xl-semi-bold text-text-primary"> - {t($ => $['autoUpdate.autoUpdateSettings'], { ns: 'plugin' })} + {t(($) => $['autoUpdate.autoUpdateSettings'], { ns: 'plugin' })} </DialogTitle> <DialogCloseButton className="top-5 right-5 size-8 rounded-lg" /> </div> @@ -230,13 +262,16 @@ const UpdateSettingDialog = ({ role="status" className="flex min-h-[260px] items-center justify-center gap-2 px-6 py-6 system-sm-regular text-text-tertiary" > - <span aria-hidden className="i-ri-loader-2-line size-4 animate-spin motion-reduce:animate-none" /> - <span>{t($ => $.loading, { ns: 'common' })}</span> + <span + aria-hidden + className="i-ri-loader-2-line size-4 animate-spin motion-reduce:animate-none" + /> + <span>{t(($) => $.loading, { ns: 'common' })}</span> </div> )} {!isSettingsLoading && !hasSettings && ( <div className="flex min-h-[260px] items-center justify-center px-6 py-6 text-center system-sm-regular text-text-tertiary"> - {t($ => $['api.actionFailed'], { ns: 'common' })} + {t(($) => $['api.actionFailed'], { ns: 'common' })} </div> )} {!isSettingsLoading && hasSettings && autoUpgrade && ( @@ -257,12 +292,8 @@ const UpdateSettingDialog = ({ renderTimePickerTrigger={renderTimePickerTrigger} /> <div className="flex h-[76px] items-center justify-end gap-2 px-6 pt-5 pb-6"> - <Button - variant="secondary" - className="min-w-[72px]" - onClick={handleCancel} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button variant="secondary" className="min-w-[72px]" onClick={handleCancel}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button variant="primary" @@ -270,7 +301,7 @@ const UpdateSettingDialog = ({ onClick={handleSave} disabled={isSavePending} > - {t($ => $['operation.save'], { ns: 'common' })} + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </> diff --git a/web/app/components/header/account-setting/use-integrations-setting.ts b/web/app/components/header/account-setting/use-integrations-setting.ts index 06181a5f56f4b7..f16d9c7cbab44f 100644 --- a/web/app/components/header/account-setting/use-integrations-setting.ts +++ b/web/app/components/header/account-setting/use-integrations-setting.ts @@ -6,25 +6,28 @@ import { useCallback } from 'react' import { useModalContext } from '@/context/modal-context' import { integrationSectionByMovedAccountSettingTab } from './destinations' -type IntegrationsSettingState - = | { payload: MovedAccountSettingTab, source?: 'agent', onCancelCallback?: () => void } - | { section: IntegrationSection, source?: 'agent', onCancelCallback?: () => void } +type IntegrationsSettingState = + | { payload: MovedAccountSettingTab; source?: 'agent'; onCancelCallback?: () => void } + | { section: IntegrationSection; source?: 'agent'; onCancelCallback?: () => void } export const useIntegrationsSetting = () => { const { setShowAccountSettingModal } = useModalContext() - return useCallback((state: IntegrationsSettingState) => { - const section - = 'section' in state - ? state.section - : integrationSectionByMovedAccountSettingTab[state.payload] + return useCallback( + (state: IntegrationsSettingState) => { + const section = + 'section' in state + ? state.section + : integrationSectionByMovedAccountSettingTab[state.payload] - if (section) { - setShowAccountSettingModal({ - payload: section, - ...(state.source ? { source: state.source } : {}), - ...(state.onCancelCallback ? { onCancelCallback: state.onCancelCallback } : {}), - }) - } - }, [setShowAccountSettingModal]) + if (section) { + setShowAccountSettingModal({ + payload: section, + ...(state.source ? { source: state.source } : {}), + ...(state.onCancelCallback ? { onCancelCallback: state.onCancelCallback } : {}), + }) + } + }, + [setShowAccountSettingModal], + ) } diff --git a/web/app/components/header/account-setting/workspace-role-checkbox-list.tsx b/web/app/components/header/account-setting/workspace-role-checkbox-list.tsx index d31326b99266e6..763d4a76f9a7c3 100644 --- a/web/app/components/header/account-setting/workspace-role-checkbox-list.tsx +++ b/web/app/components/header/account-setting/workspace-role-checkbox-list.tsx @@ -50,10 +50,7 @@ const isLegacyRoleKey = (value: string): value is LegacyRoleKey => Object.prototype.hasOwnProperty.call(LEGACY_ROLE_DESCRIPTION_KEY_MAP, value) const getLegacyRoleDescriptionKey = (role: Role) => { - const candidateKeys = [ - normalizeLegacyRoleKey(role.name), - normalizeLegacyRoleKey(role.id), - ] + const candidateKeys = [normalizeLegacyRoleKey(role.name), normalizeLegacyRoleKey(role.id)] return candidateKeys.find(isLegacyRoleKey) } @@ -91,86 +88,105 @@ const WorkspaceRoleCheckboxList = ({ const hasMore = hasNextPage ?? true let observer: IntersectionObserver | undefined - if (error) - return + if (error) return if (anchorRef.current && containerRef.current) { const containerHeight = containerRef.current.clientHeight const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) - observer = new IntersectionObserver((entries) => { - if (entries[0]!.isIntersecting && !rolesLoading && !isFetchingNextPage && !error && hasMore) - fetchNextPage() - }, { - root: containerRef.current, - rootMargin: `${dynamicMargin}px`, - }) + observer = new IntersectionObserver( + (entries) => { + if ( + entries[0]!.isIntersecting && + !rolesLoading && + !isFetchingNextPage && + !error && + hasMore + ) + fetchNextPage() + }, + { + root: containerRef.current, + rootMargin: `${dynamicMargin}px`, + }, + ) observer.observe(anchorRef.current) } return () => observer?.disconnect() }, [rolesLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage]) - const roles = useMemo(() => rolesData?.pages.flatMap(page => page.data) ?? [], [rolesData]) + const roles = useMemo(() => rolesData?.pages.flatMap((page) => page.data) ?? [], [rolesData]) const roleById = useMemo(() => { - return new Map(roles.map(role => [role.id, role])) + return new Map(roles.map((role) => [role.id, role])) }, [roles]) const selectedRoleIdSet = useMemo(() => new Set(selectedRoleIds), [selectedRoleIds]) const disabledRoleIdSet = useMemo(() => new Set(disabledRoleIds), [disabledRoleIds]) const selectedRoleObjects = useMemo(() => { - if (selectedRoles) - return selectedRoles + if (selectedRoles) return selectedRoles - return selectedRoleIds.map(roleId => roleById.get(roleId) ?? createSelectedRolePlaceholder(roleId)) + return selectedRoleIds.map( + (roleId) => roleById.get(roleId) ?? createSelectedRolePlaceholder(roleId), + ) }, [roleById, selectedRoleIds, selectedRoles]) const filteredRoles = useMemo(() => { const trimmed = keyword.trim().toLowerCase() - if (!trimmed) - return roles + if (!trimmed) return roles return roles.filter( - role => - role.name.toLowerCase().includes(trimmed) - || role.description?.toLowerCase().includes(trimmed), + (role) => + role.name.toLowerCase().includes(trimmed) || + role.description?.toLowerCase().includes(trimmed), ) }, [roles, keyword]) - const toggleRole = useCallback((role: Role) => { - if (disabledRoleIdSet.has(role.id)) - return - - if (!allowMultipleRoles) { - onSelectedRolesChange(selectedRoleIdSet.has(role.id) ? selectedRoleObjects : [role]) - return - } - - onSelectedRolesChange( - selectedRoleIdSet.has(role.id) - ? selectedRoleObjects.filter(selectedRole => selectedRole.id !== role.id) - : [...selectedRoleObjects, role], - ) - }, [allowMultipleRoles, disabledRoleIdSet, onSelectedRolesChange, selectedRoleIdSet, selectedRoleObjects]) + const toggleRole = useCallback( + (role: Role) => { + if (disabledRoleIdSet.has(role.id)) return + + if (!allowMultipleRoles) { + onSelectedRolesChange(selectedRoleIdSet.has(role.id) ? selectedRoleObjects : [role]) + return + } + + onSelectedRolesChange( + selectedRoleIdSet.has(role.id) + ? selectedRoleObjects.filter((selectedRole) => selectedRole.id !== role.id) + : [...selectedRoleObjects, role], + ) + }, + [ + allowMultipleRoles, + disabledRoleIdSet, + onSelectedRolesChange, + selectedRoleIdSet, + selectedRoleObjects, + ], + ) - const handleRadioValueChange = useCallback((roleId: string) => { - const role = roleById.get(roleId) - if (!role || disabledRoleIdSet.has(role.id)) - return + const handleRadioValueChange = useCallback( + (roleId: string) => { + const role = roleById.get(roleId) + if (!role || disabledRoleIdSet.has(role.id)) return - onSelectedRolesChange([role]) - }, [disabledRoleIdSet, onSelectedRolesChange, roleById]) + onSelectedRolesChange([role]) + }, + [disabledRoleIdSet, onSelectedRolesChange, roleById], + ) const getRoleDescription = (role: Role) => { - if (role.description) - return role.description + if (role.description) return role.description const legacyRoleDescriptionKey = allowMultipleRoles ? undefined : getLegacyRoleDescriptionKey(role) if (legacyRoleDescriptionKey) - return t($ => $[LEGACY_ROLE_DESCRIPTION_KEY_MAP[legacyRoleDescriptionKey]], { ns: 'common' }) + return t(($) => $[LEGACY_ROLE_DESCRIPTION_KEY_MAP[legacyRoleDescriptionKey]], { + ns: 'common', + }) - return t($ => $['role.noDescription'], { ns: 'permission' }) + return t(($) => $['role.noDescription'], { ns: 'permission' }) } const renderRoleText = (role: Role) => { @@ -178,12 +194,8 @@ const WorkspaceRoleCheckboxList = ({ return ( <div className="min-w-0 flex-1"> - <div className="system-sm-semibold text-text-secondary"> - {role.name} - </div> - <div className="mt-0.5 system-xs-regular text-text-tertiary"> - {description} - </div> + <div className="system-sm-semibold text-text-secondary">{role.name}</div> + <div className="mt-0.5 system-xs-regular text-text-tertiary">{description}</div> </div> ) } @@ -198,15 +210,15 @@ const WorkspaceRoleCheckboxList = ({ /> <Input value={keyword} - onChange={e => setKeyword(e.target.value)} - placeholder={t($ => $['role.searchPlaceholder'], { ns: 'permission' })} + onChange={(e) => setKeyword(e.target.value)} + placeholder={t(($) => $['role.searchPlaceholder'], { ns: 'permission' })} className="pr-8 pl-8" /> {keyword && ( <button type="button" className="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-components-input-border-active" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} onClick={() => setKeyword('')} > <span aria-hidden className="i-ri-close-line size-4" /> @@ -220,99 +232,93 @@ const WorkspaceRoleCheckboxList = ({ className="min-h-0 flex-1" slotClassNames={{ viewport: 'px-3 overscroll-contain' }} > - {rolesLoading - ? ( - <div className="px-3 py-6 text-center system-sm-regular text-text-tertiary"> - {t($ => $['role.loading'], { ns: 'permission' })} - </div> - ) - : filteredRoles.length === 0 - ? ( - <div className="px-3 py-6 text-center system-sm-regular text-text-tertiary"> - {t($ => $['role.noMatchingRoles'], { ns: 'permission' })} - </div> - ) - : ( - <> - {allowMultipleRoles - ? ( - <ul className="flex flex-col gap-0.5 pb-2"> - {filteredRoles.map((role) => { - const checked = selectedRoleIdSet.has(role.id) - const disabled = disabledRoleIdSet.has(role.id) - const handleToggle = () => toggleRole(role) - - return ( - <li key={role.id}> - <div - role="checkbox" - aria-checked={checked} - aria-disabled={disabled} - tabIndex={disabled ? -1 : 0} - className={cn( - 'flex cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 hover:bg-state-base-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active', - checked && 'bg-state-accent-hover hover:bg-state-accent-hover', - disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent', - )} - onClick={handleToggle} - onKeyDown={(e) => { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault() - handleToggle() - } - }} - > - <Checkbox - checked={checked} - disabled={disabled} - className="pointer-events-none mt-0.5" - /> - {renderRoleText(role)} - </div> - </li> - ) - })} - </ul> - ) - : ( - <RadioGroup - value={selectedRoleIds[0] ?? ''} - onValueChange={handleRadioValueChange} - className="flex-col items-stretch gap-0.5 pb-2" - render={<ul />} - > - {filteredRoles.map((role) => { - const checked = selectedRoleIdSet.has(role.id) - const disabled = disabledRoleIdSet.has(role.id) - - return ( - <li key={role.id}> - <RadioItem - value={role.id} - disabled={disabled} - nativeButton - render={( - <button - type="button" - className={cn( - 'flex w-full cursor-pointer items-start gap-3 rounded-lg border-0 bg-transparent px-3 py-2.5 text-left hover:bg-state-base-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active', - checked && 'bg-state-accent-hover hover:bg-state-accent-hover', - disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent', - )} - /> - )} - > - <RadioControl className="pointer-events-none mt-0.5" /> - {renderRoleText(role)} - </RadioItem> - </li> - ) - })} - </RadioGroup> - )} - <div ref={anchorRef} className="h-0" /> - </> - )} + {rolesLoading ? ( + <div className="px-3 py-6 text-center system-sm-regular text-text-tertiary"> + {t(($) => $['role.loading'], { ns: 'permission' })} + </div> + ) : filteredRoles.length === 0 ? ( + <div className="px-3 py-6 text-center system-sm-regular text-text-tertiary"> + {t(($) => $['role.noMatchingRoles'], { ns: 'permission' })} + </div> + ) : ( + <> + {allowMultipleRoles ? ( + <ul className="flex flex-col gap-0.5 pb-2"> + {filteredRoles.map((role) => { + const checked = selectedRoleIdSet.has(role.id) + const disabled = disabledRoleIdSet.has(role.id) + const handleToggle = () => toggleRole(role) + + return ( + <li key={role.id}> + <div + role="checkbox" + aria-checked={checked} + aria-disabled={disabled} + tabIndex={disabled ? -1 : 0} + className={cn( + 'flex cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 hover:bg-state-base-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active', + checked && 'bg-state-accent-hover hover:bg-state-accent-hover', + disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent', + )} + onClick={handleToggle} + onKeyDown={(e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + handleToggle() + } + }} + > + <Checkbox + checked={checked} + disabled={disabled} + className="pointer-events-none mt-0.5" + /> + {renderRoleText(role)} + </div> + </li> + ) + })} + </ul> + ) : ( + <RadioGroup + value={selectedRoleIds[0] ?? ''} + onValueChange={handleRadioValueChange} + className="flex-col items-stretch gap-0.5 pb-2" + render={<ul />} + > + {filteredRoles.map((role) => { + const checked = selectedRoleIdSet.has(role.id) + const disabled = disabledRoleIdSet.has(role.id) + + return ( + <li key={role.id}> + <RadioItem + value={role.id} + disabled={disabled} + nativeButton + render={ + <button + type="button" + className={cn( + 'flex w-full cursor-pointer items-start gap-3 rounded-lg border-0 bg-transparent px-3 py-2.5 text-left hover:bg-state-base-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active', + checked && 'bg-state-accent-hover hover:bg-state-accent-hover', + disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent', + )} + /> + } + > + <RadioControl className="pointer-events-none mt-0.5" /> + {renderRoleText(role)} + </RadioItem> + </li> + ) + })} + </RadioGroup> + )} + <div ref={anchorRef} className="h-0" /> + </> + )} </ScrollArea> </> ) 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 982595ae688db0..feeb1b201ffc83 100644 --- a/web/app/components/header/env-nav/__tests__/index.spec.tsx +++ b/web/app/components/header/env-nav/__tests__/index.spec.tsx @@ -30,7 +30,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -75,8 +76,6 @@ describe('EnvNav', () => { mockUseAppContext.mockReturnValue(appContextValue) render(<EnvNav />) - expect( - screen.getByText('common.environment.development'), - ).toBeInTheDocument() + expect(screen.getByText('common.environment.development')).toBeInTheDocument() }) }) diff --git a/web/app/components/header/env-nav/index.tsx b/web/app/components/header/env-nav/index.tsx index 2a3c6a6f5c8556..58b3c937ec74be 100644 --- a/web/app/components/header/env-nav/index.tsx +++ b/web/app/components/header/env-nav/index.tsx @@ -14,33 +14,32 @@ const headerEnvClassName: { [k: string]: string } = { const EnvNav = () => { const { t } = useTranslation() const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) - const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT' + const showEnvTag = + langGeniusVersionInfo.current_env === 'TESTING' || + langGeniusVersionInfo.current_env === 'DEVELOPMENT' - if (!showEnvTag) - return null + if (!showEnvTag) return null return ( - <div className={` - mr-1 flex h-[22px] items-center rounded-md border px-2 text-xs font-medium - ${headerEnvClassName[langGeniusVersionInfo.current_env]} - `} + <div + className={`mr-1 flex h-[22px] items-center rounded-md border px-2 text-xs font-medium ${headerEnvClassName[langGeniusVersionInfo.current_env]} `} > - { - langGeniusVersionInfo.current_env === 'TESTING' && ( - <> - <Beaker02 className="size-3" /> - <div className="ml-1 max-[1280px]:hidden">{t($ => $['environment.testing'], { ns: 'common' })}</div> - </> - ) - } - { - langGeniusVersionInfo.current_env === 'DEVELOPMENT' && ( - <> - <TerminalSquare className="size-3" /> - <div className="ml-1 max-[1280px]:hidden">{t($ => $['environment.development'], { ns: 'common' })}</div> - </> - ) - } + {langGeniusVersionInfo.current_env === 'TESTING' && ( + <> + <Beaker02 className="size-3" /> + <div className="ml-1 max-[1280px]:hidden"> + {t(($) => $['environment.testing'], { ns: 'common' })} + </div> + </> + )} + {langGeniusVersionInfo.current_env === 'DEVELOPMENT' && ( + <> + <TerminalSquare className="size-3" /> + <div className="ml-1 max-[1280px]:hidden"> + {t(($) => $['environment.development'], { ns: 'common' })} + </div> + </> + )} </div> ) } diff --git a/web/app/components/header/github-star/__tests__/index.spec.tsx b/web/app/components/header/github-star/__tests__/index.spec.tsx index b800622137ca00..174cb6dbca0d51 100644 --- a/web/app/components/header/github-star/__tests__/index.spec.tsx +++ b/web/app/components/header/github-star/__tests__/index.spec.tsx @@ -41,9 +41,9 @@ describe('GithubStar', () => { // Covers the fetched star count shown after a successful request. it('should render fetched star count', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - createJsonResponse({ repo: { stars: 123456 } }), - ) + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(createJsonResponse({ repo: { stars: 123456 } })) renderWithQueryClient() @@ -53,9 +53,7 @@ describe('GithubStar', () => { // Covers the fallback star count shown when the request fails. it('should render default star count on error', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - createJsonResponse({}, 500), - ) + vi.spyOn(globalThis, 'fetch').mockResolvedValue(createJsonResponse({}, 500)) renderWithQueryClient() diff --git a/web/app/components/header/github-star/index.tsx b/web/app/components/header/github-star/index.tsx index 44e8c5ac6ffcec..94a73349174f65 100644 --- a/web/app/components/header/github-star/index.tsx +++ b/web/app/components/header/github-star/index.tsx @@ -15,8 +15,7 @@ const defaultData: GithubStarResponse = { const getStar = async () => { const res = await fetch('https://ungh.cc/repos/langgenius/dify') - if (!res.ok) - throw new Error('Failed to fetch github star') + if (!res.ok) throw new Error('Failed to fetch github star') return res.json() } @@ -32,8 +31,7 @@ const GithubStar: FC<{ className: string }> = (props) => { if (isFetching) return <span className="i-ri-loader-2-line size-3 shrink-0 animate-spin text-text-tertiary" /> - if (isError) - return <span {...props}>{defaultData.repo.stars.toLocaleString()}</span> + if (isError) return <span {...props}>{defaultData.repo.stars.toLocaleString()}</span> return <span {...props}>{data?.repo.stars.toLocaleString()}</span> } diff --git a/web/app/components/header/header-wrapper.tsx b/web/app/components/header/header-wrapper.tsx index efff8432174339..4c7df5f1a6c44c 100644 --- a/web/app/components/header/header-wrapper.tsx +++ b/web/app/components/header/header-wrapper.tsx @@ -12,9 +12,7 @@ type HeaderWrapperProps = { children: React.ReactNode } -const HeaderWrapper = ({ - children, -}: HeaderWrapperProps) => { +const HeaderWrapper = ({ children }: HeaderWrapperProps) => { const pathname = usePathname() const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname) const inWorkflowCanvas = pathname.endsWith('/workflow') @@ -25,12 +23,23 @@ const HeaderWrapper = ({ const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((value: EventEmitterValue) => { - if (typeof value === 'object' && value.type === 'workflow-canvas-maximize' && typeof value.payload === 'boolean') + if ( + typeof value === 'object' && + value.type === 'workflow-canvas-maximize' && + typeof value.payload === 'boolean' + ) setEventHideHeader(value.payload) }) return ( - <div className={cn('sticky top-0 right-0 left-0 z-30 flex min-h-[56px] shrink-0 grow-0 basis-auto flex-col', s.header, isBordered ? 'border-b border-divider-regular' : '', hideHeader && (inWorkflowCanvas || isPipelineCanvas) && 'hidden')}> + <div + className={cn( + 'sticky top-0 right-0 left-0 z-30 flex min-h-[56px] shrink-0 grow-0 basis-auto flex-col', + s.header, + isBordered ? 'border-b border-divider-regular' : '', + hideHeader && (inWorkflowCanvas || isPipelineCanvas) && 'hidden', + )} + > {children} </div> ) diff --git a/web/app/components/header/index.module.css b/web/app/components/header/index.module.css index 9e23bc10a6b706..299ea9148006f2 100644 --- a/web/app/components/header/index.module.css +++ b/web/app/components/header/index.module.css @@ -1,11 +1,11 @@ .header-DEVELOPMENT { background: linear-gradient(180deg, rgba(253, 176, 34, 0.08) 0%, rgba(253, 176, 34, 0) 100%); - border-top: 4px solid #FDB022; + border-top: 4px solid #fdb022; } .header-TESTING { background: linear-gradient(180deg, rgba(6, 174, 212, 0.08) 0%, rgba(6, 174, 212, 0) 100%); - border-top: 4px solid #06AED4; + border-top: 4px solid #06aed4; } .alpha { diff --git a/web/app/components/header/license-env/index.tsx b/web/app/components/header/license-env/index.tsx index da6e3b9ce84290..d966f72fd5761d 100644 --- a/web/app/components/header/license-env/index.tsx +++ b/web/app/components/header/license-env/index.tsx @@ -17,9 +17,20 @@ const LicenseNav = () => { const count = dayjs(expiredAt).diff(dayjs(), 'days') return ( <PremiumBadge color="orange" className="select-none"> - <RiHourglass2Fill aria-hidden="true" className="flex size-3 items-center pl-0.5 text-components-premium-badge-indigo-text-stop-0" /> - {count <= 1 && <span className="px-0.5 system-xs-medium">{t($ => $['license.expiring'], { ns: 'common', count })}</span>} - {count > 1 && <span className="px-0.5 system-xs-medium">{t($ => $['license.expiring_plural'], { ns: 'common', count })}</span>} + <RiHourglass2Fill + aria-hidden="true" + className="flex size-3 items-center pl-0.5 text-components-premium-badge-indigo-text-stop-0" + /> + {count <= 1 && ( + <span className="px-0.5 system-xs-medium"> + {t(($) => $['license.expiring'], { ns: 'common', count })} + </span> + )} + {count > 1 && ( + <span className="px-0.5 system-xs-medium"> + {t(($) => $['license.expiring_plural'], { ns: 'common', count })} + </span> + )} </PremiumBadge> ) } diff --git a/web/app/components/header/maintenance-notice.tsx b/web/app/components/header/maintenance-notice.tsx index efcf02a315d343..ea12a93f5a49a9 100644 --- a/web/app/components/header/maintenance-notice.tsx +++ b/web/app/components/header/maintenance-notice.tsx @@ -9,8 +9,7 @@ import { NOTICE_I18N } from '@/i18n-config/language' import { useHideMaintenanceNotice } from './storage' function MaintenanceNotice() { - if (!env.NEXT_PUBLIC_MAINTENANCE_NOTICE) - return null + if (!env.NEXT_PUBLIC_MAINTENANCE_NOTICE) return null return <MaintenanceNoticeContent /> } @@ -35,28 +34,27 @@ function MaintenanceNoticeContent() { const titleByLocale: { [key: string]: string } = NOTICE_I18N.title const descByLocale: { [key: string]: string } = NOTICE_I18N.desc - if (!showNotice) - return null + if (!showNotice) return null return ( <div className="z-20 flex h-[38px] shrink-0 items-center border-[0.5px] border-b border-b-[#FEF0C7] bg-[#FFFAEB] px-4"> - <div className="mr-2 flex h-[22px] shrink-0 items-center rounded-xl bg-[#F79009] px-2 text-[11px] font-medium text-white">{titleByLocale[locale]}</div> - { - (NOTICE_I18N.href && NOTICE_I18N.href !== '#') - ? ( - <button - type="button" - className="grow cursor-pointer border-none bg-transparent p-0 text-left text-xs font-medium text-gray-700" - onClick={handleJumpNotice} - > - {descByLocale[locale]} - </button> - ) - : <div className="grow text-xs font-medium text-gray-700">{descByLocale[locale]}</div> - } + <div className="mr-2 flex h-[22px] shrink-0 items-center rounded-xl bg-[#F79009] px-2 text-[11px] font-medium text-white"> + {titleByLocale[locale]} + </div> + {NOTICE_I18N.href && NOTICE_I18N.href !== '#' ? ( + <button + type="button" + className="grow cursor-pointer border-none bg-transparent p-0 text-left text-xs font-medium text-gray-700" + onClick={handleJumpNotice} + > + {descByLocale[locale]} + </button> + ) : ( + <div className="grow text-xs font-medium text-gray-700">{descByLocale[locale]}</div> + )} <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-4 shrink-0 cursor-pointer border-none bg-transparent p-0 text-gray-500" onClick={handleCloseNotice} > diff --git a/web/app/components/header/plan-badge/__tests__/index.spec.tsx b/web/app/components/header/plan-badge/__tests__/index.spec.tsx index 356555ea03c94b..f64afa4be6a628 100644 --- a/web/app/components/header/plan-badge/__tests__/index.spec.tsx +++ b/web/app/components/header/plan-badge/__tests__/index.spec.tsx @@ -30,29 +30,21 @@ describe('PlanBadge', () => { }) it('should return null if isFetchedPlan is false', () => { - mockUseProviderContext.mockReturnValue( - createMockProviderContextValue({ isFetchedPlan: false }), - ) + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: false })) const { container } = render(<PlanBadge plan={Plan.sandbox} />) expect(container.firstChild).toBeNull() }) it('should render upgrade badge when plan is sandbox and sandboxAsUpgrade is true', () => { - mockUseProviderContext.mockReturnValue( - createMockProviderContextValue({ isFetchedPlan: true }), - ) + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true })) render(<PlanBadge plan={Plan.sandbox} sandboxAsUpgrade={true} />) - expect( - screen.getByText('billing.upgradeBtn.encourageShort'), - ).toBeInTheDocument() + expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() expect(screen.queryByRole('button')).not.toBeInTheDocument() }) it('should render upgrade action as a button when onClick is provided', () => { const handleClick = vi.fn() - mockUseProviderContext.mockReturnValue( - createMockProviderContextValue({ isFetchedPlan: true }), - ) + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true })) render(<PlanBadge plan={Plan.sandbox} sandboxAsUpgrade={true} onClick={handleClick} />) @@ -63,9 +55,7 @@ describe('PlanBadge', () => { it('should render sandbox badge instead of upgrade badge in self-hosted edition', () => { mockConfig.isCloudEdition = false - mockUseProviderContext.mockReturnValue( - createMockProviderContextValue({ isFetchedPlan: true }), - ) + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true })) render(<PlanBadge plan={Plan.sandbox} sandboxAsUpgrade={true} />) @@ -75,18 +65,14 @@ describe('PlanBadge', () => { }) it('should render sandbox badge when plan is sandbox and sandboxAsUpgrade is false', () => { - mockUseProviderContext.mockReturnValue( - createMockProviderContextValue({ isFetchedPlan: true }), - ) + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true })) render(<PlanBadge plan={Plan.sandbox} sandboxAsUpgrade={false} />) expect(screen.getByText(Plan.sandbox)).toBeInTheDocument() expect(screen.queryByRole('button')).not.toBeInTheDocument() }) it('should render professional badge when plan is professional', () => { - mockUseProviderContext.mockReturnValue( - createMockProviderContextValue({ isFetchedPlan: true }), - ) + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true })) render(<PlanBadge plan={Plan.professional} />) expect(screen.getByText('pro')).toBeInTheDocument() }) @@ -105,38 +91,28 @@ describe('PlanBadge', () => { }) it('should render team badge when plan is team', () => { - mockUseProviderContext.mockReturnValue( - createMockProviderContextValue({ isFetchedPlan: true }), - ) + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true })) render(<PlanBadge plan={Plan.team} />) expect(screen.getByText(Plan.team)).toBeInTheDocument() }) it('should return null when plan is enterprise', () => { - mockUseProviderContext.mockReturnValue( - createMockProviderContextValue({ isFetchedPlan: true }), - ) + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true })) const { container } = render(<PlanBadge plan={Plan.enterprise} />) expect(container.firstChild).toBeNull() }) it('should trigger onClick when clicked', () => { const handleClick = vi.fn() - mockUseProviderContext.mockReturnValue( - createMockProviderContextValue({ isFetchedPlan: true }), - ) + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true })) render(<PlanBadge plan={Plan.team} onClick={handleClick} />) fireEvent.click(screen.getByRole('button', { name: Plan.team })) expect(handleClick).toHaveBeenCalledTimes(1) }) it('should handle allowHover prop', () => { - mockUseProviderContext.mockReturnValue( - createMockProviderContextValue({ isFetchedPlan: true }), - ) - const { container } = render( - <PlanBadge plan={Plan.team} allowHover={true} />, - ) + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true })) + const { container } = render(<PlanBadge plan={Plan.team} allowHover={true} />) expect(container.firstChild).not.toBeNull() }) diff --git a/web/app/components/header/plan-badge/index.tsx b/web/app/components/header/plan-badge/index.tsx index 327e3bc52fd865..fc61be8566a8c6 100644 --- a/web/app/components/header/plan-badge/index.tsx +++ b/web/app/components/header/plan-badge/index.tsx @@ -1,7 +1,5 @@ import type { ReactNode } from 'react' -import { - RiGraduationCapFill, -} from '@remixicon/react' +import { RiGraduationCapFill } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { IS_CLOUD_EDITION } from '@/config' import { useProviderContext } from '@/context/provider-context' @@ -29,7 +27,13 @@ function PlanBadgeShell({ }) { if (onClick) { return ( - <PremiumBadgeButton className="select-none" size={size} color={color} allowHover={allowHover} onClick={onClick}> + <PremiumBadgeButton + className="select-none" + size={size} + color={color} + allowHover={allowHover} + onClick={onClick} + > {children} </PremiumBadgeButton> ) @@ -46,15 +50,17 @@ export function PlanBadge({ plan, allowHover, sandboxAsUpgrade = false, onClick const { isFetchedPlan, isEducationWorkspace } = useProviderContext() const { t } = useTranslation() - if (!isFetchedPlan) - return null + if (!isFetchedPlan) return null if (plan === Plan.sandbox && sandboxAsUpgrade && IS_CLOUD_EDITION) { return ( <PlanBadgeShell color="blue" allowHover={allowHover} onClick={onClick}> - <SparklesSoft aria-hidden="true" className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> + <SparklesSoft + aria-hidden="true" + className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" + /> <div className="system-xs-medium"> <span className="p-1 whitespace-nowrap"> - {t($ => $['upgradeBtn.encourageShort'], { ns: 'billing' })} + {t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })} </span> </div> </PlanBadgeShell> @@ -64,9 +70,7 @@ export function PlanBadge({ plan, allowHover, sandboxAsUpgrade = false, onClick return ( <PlanBadgeShell size="s" color="gray" allowHover={allowHover} onClick={onClick}> <div className="system-2xs-medium-uppercase"> - <span className="p-1"> - {plan} - </span> + <span className="p-1">{plan}</span> </div> </PlanBadgeShell> ) @@ -87,9 +91,7 @@ export function PlanBadge({ plan, allowHover, sandboxAsUpgrade = false, onClick return ( <PlanBadgeShell size="s" color="indigo" allowHover={allowHover} onClick={onClick}> <div className="system-2xs-medium-uppercase"> - <span className="p-1"> - {plan} - </span> + <span className="p-1">{plan}</span> </div> </PlanBadgeShell> ) diff --git a/web/app/components/header/plugins-nav/downloading-icon.tsx b/web/app/components/header/plugins-nav/downloading-icon.tsx index 866be626633346..b82ea8b50da563 100644 --- a/web/app/components/header/plugins-nav/downloading-icon.tsx +++ b/web/app/components/header/plugins-nav/downloading-icon.tsx @@ -5,17 +5,32 @@ type DownloadingIconProps = { className?: string } -const DownloadingIcon = ({ - active = true, - className, -}: DownloadingIconProps) => { +const DownloadingIcon = ({ active = true, className }: DownloadingIconProps) => { return ( - <span className={cn('inline-flex size-4 shrink-0 text-components-button-secondary-text', className)}> - <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="install-icon size-4"> + <span + className={cn('inline-flex size-4 shrink-0 text-components-button-secondary-text', className)} + > + <svg + width="16" + height="16" + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className="install-icon size-4" + > <g id="install-line"> - <path d="M5.33333 1.33333V2.66666H3.33333L3.33267 9.33333H12.666L12.6667 2.66666H10.6667V1.33333H13.3333C13.7015 1.33333 14 1.63181 14 1.99999V14C14 14.3682 13.7015 14.6667 13.3333 14.6667H2.66667C2.29848 14.6667 2 14.3682 2 14V1.99999C2 1.63181 2.29848 1.33333 2.66667 1.33333H5.33333ZM12.666 10.6667H3.33267L3.33333 13.3333H12.6667L12.666 10.6667Z" fill="currentColor" /> - <path d="M11.3333 12.6667V11.3333H10V12.6667H11.3333Z" fill={active ? '#17B26A' : 'currentColor'} /> - <path d="M8.66666 1.33333V4.66666H10.6667L8 7.33333L5.33333 4.66666H7.33333V1.33333H8.66666Z" fill="currentColor" /> + <path + d="M5.33333 1.33333V2.66666H3.33333L3.33267 9.33333H12.666L12.6667 2.66666H10.6667V1.33333H13.3333C13.7015 1.33333 14 1.63181 14 1.99999V14C14 14.3682 13.7015 14.6667 13.3333 14.6667H2.66667C2.29848 14.6667 2 14.3682 2 14V1.99999C2 1.63181 2.29848 1.33333 2.66667 1.33333H5.33333ZM12.666 10.6667H3.33267L3.33333 13.3333H12.6667L12.666 10.6667Z" + fill="currentColor" + /> + <path + d="M11.3333 12.6667V11.3333H10V12.6667H11.3333Z" + fill={active ? '#17B26A' : 'currentColor'} + /> + <path + d="M8.66666 1.33333V4.66666H10.6667L8 7.33333L5.33333 4.66666H7.33333V1.33333H8.66666Z" + fill="currentColor" + /> </g> </svg> </span> diff --git a/web/app/components/header/storage.ts b/web/app/components/header/storage.ts index 0c3b099808d884..be4201c855253a 100644 --- a/web/app/components/header/storage.ts +++ b/web/app/components/header/storage.ts @@ -1,18 +1,9 @@ import { createLocalStorageState } from 'foxact/create-local-storage-state' -const [ - useHideMaintenanceNotice, - _useHideMaintenanceNoticeValue, - _useSetHideMaintenanceNotice, -] = createLocalStorageState<string>('hide-maintenance-notice', '0', { raw: true }) +const [useHideMaintenanceNotice, _useHideMaintenanceNoticeValue, _useSetHideMaintenanceNotice] = + createLocalStorageState<string>('hide-maintenance-notice', '0', { raw: true }) -const [ - _useWorkflowCanvasMaximize, - useWorkflowCanvasMaximizeValue, - _useSetWorkflowCanvasMaximize, -] = createLocalStorageState<boolean>('workflow-canvas-maximize', false) +const [_useWorkflowCanvasMaximize, useWorkflowCanvasMaximizeValue, _useSetWorkflowCanvasMaximize] = + createLocalStorageState<boolean>('workflow-canvas-maximize', false) -export { - useHideMaintenanceNotice, - useWorkflowCanvasMaximizeValue, -} +export { useHideMaintenanceNotice, useWorkflowCanvasMaximizeValue } diff --git a/web/app/components/header/utils/util.ts b/web/app/components/header/utils/util.ts index 19e2eeb03c3563..bc14c4d6e1221c 100644 --- a/web/app/components/header/utils/util.ts +++ b/web/app/components/header/utils/util.ts @@ -1,16 +1,19 @@ export const generateMailToLink = (email: string, subject?: string, body?: string): string => { let mailtoLink = `mailto:${email}` - if (subject) - mailtoLink += `?subject=${encodeURIComponent(subject)}` + if (subject) mailtoLink += `?subject=${encodeURIComponent(subject)}` - if (body) - mailtoLink += `&body=${encodeURIComponent(body)}` + if (body) mailtoLink += `&body=${encodeURIComponent(body)}` return mailtoLink } -export const mailToSupport = (account: string, plan: string, version: string, supportEmailAddress?: string) => { +export const mailToSupport = ( + account: string, + plan: string, + version: string, + supportEmailAddress?: string, +) => { const subject = `Technical Support Request ${plan} ${account}` const body = ` Please do not remove the following information: diff --git a/web/app/components/integrations/__tests__/page.spec.tsx b/web/app/components/integrations/__tests__/page.spec.tsx index fb10eeb73be785..366c7cb05d7d04 100644 --- a/web/app/components/integrations/__tests__/page.spec.tsx +++ b/web/app/components/integrations/__tests__/page.spec.tsx @@ -77,7 +77,9 @@ vi.mock('@/app/components/plugins/plugin-page/debug-info', () => ({ triggerClassName?: string triggerContent?: React.ReactNode }) => ( - <button type="button" aria-label="plugin debug" className={triggerClassName}>{triggerContent ?? 'debug'}</button> + <button type="button" aria-label="plugin debug" className={triggerClassName}> + {triggerContent ?? 'debug'} + </button> ), })) @@ -85,7 +87,9 @@ vi.mock('@/app/components/plugins/reference-setting-modal', () => ({ __esModule: true, default: ({ onHide }: { onHide: () => void }) => ( <div data-testid="reference-setting-modal"> - <button type="button" onClick={onHide}>close</button> + <button type="button" onClick={onHide}> + close + </button> </div> ), })) @@ -93,10 +97,7 @@ vi.mock('@/app/components/plugins/reference-setting-modal', () => ({ vi.mock('@/app/components/header/account-setting/update-setting-dialog', () => ({ __esModule: true, default: () => ( - <button - type="button" - data-testid="update-setting-dialog" - > + <button type="button" data-testid="update-setting-dialog"> plugin.autoUpdate.autoUpdate <span>plugin.autoUpdate.strategy.fixOnly.name</span> </button> @@ -131,13 +132,20 @@ vi.mock('@/app/components/plugins/plugin-page/install-plugin-dropdown', () => ({ vi.mock('@/app/components/plugins/plugin-page/plugin-tasks', () => ({ __esModule: true, - default: () => <button type="button" aria-label="plugin tasks">tasks</button>, + default: () => ( + <button type="button" aria-label="plugin tasks"> + tasks + </button> + ), })) vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace-query', () => ({ __esModule: true, default: ({ installContextCategory }: { installContextCategory?: string }) => ( - <div data-testid="install-from-marketplace-query" data-install-context-category={installContextCategory} /> + <div + data-testid="install-from-marketplace-query" + data-install-context-category={installContextCategory} + /> ), })) @@ -149,7 +157,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({ onSearchTextChange, searchText, }: { - layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode + layout?: (parts: { body: React.ReactNode; toolbar: React.ReactNode }) => React.ReactNode onOpenMarketplace?: () => void onSearchTextChange?: (value: string) => void searchText: string @@ -159,25 +167,26 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({ <input aria-label="search" value={searchText} - onChange={event => onSearchTextChange?.(event.target.value)} + onChange={(event) => onSearchTextChange?.(event.target.value)} /> </div> ) const body = ( <div data-testid="model-provider-page"> - <button type="button" aria-label="model provider marketplace" onClick={onOpenMarketplace}>marketplace</button> + <button type="button" aria-label="model provider marketplace" onClick={onOpenMarketplace}> + marketplace + </button> </div> ) - if (layout) - return layout({ body, toolbar }) + if (layout) return layout({ body, toolbar }) return ( <div data-testid="model-provider-page"> <input aria-label="search" value={searchText} - onChange={event => onSearchTextChange?.(event.target.value)} + onChange={(event) => onSearchTextChange?.(event.target.value)} /> </div> ) @@ -186,16 +195,23 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({ vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({ __esModule: true, - default: ({ layout, onOpenMarketplace }: { layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode, onOpenMarketplace?: () => void }) => { + default: ({ + layout, + onOpenMarketplace, + }: { + layout?: (parts: { body: React.ReactNode; toolbar: React.ReactNode }) => React.ReactNode + onOpenMarketplace?: () => void + }) => { const toolbar = <div data-testid="data-source-toolbar" /> const body = ( <div data-testid="data-source-page"> - <button type="button" aria-label="data source marketplace" onClick={onOpenMarketplace}>marketplace</button> + <button type="button" aria-label="data source marketplace" onClick={onOpenMarketplace}> + marketplace + </button> </div> ) - if (layout) - return layout({ body, toolbar }) + if (layout) return layout({ body, toolbar }) return body }, @@ -203,12 +219,15 @@ vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({ vi.mock('@/app/components/header/account-setting/api-based-extension-page', () => ({ __esModule: true, - ApiBasedExtensionPage: ({ layout }: { layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode }) => { + ApiBasedExtensionPage: ({ + layout, + }: { + layout?: (parts: { body: React.ReactNode; toolbar: React.ReactNode }) => React.ReactNode + }) => { const toolbar = <div data-testid="api-extension-toolbar" /> const body = <div data-testid="api-extension-page" /> - if (layout) - return layout({ body, toolbar }) + if (layout) return layout({ body, toolbar }) return body }, @@ -217,13 +236,22 @@ vi.mock('@/app/components/header/account-setting/api-based-extension-page', () = vi.mock('../tool-provider-list', async () => { const { useState } = await vi.importActual<typeof import('react')>('react') - const MockProviderList = ({ category, layout }: { category?: string, layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode }) => { + const MockProviderList = ({ + category, + layout, + }: { + category?: string + layout?: (parts: { body: React.ReactNode; toolbar: React.ReactNode }) => React.ReactNode + }) => { const [mountedCategory] = useState(category) const toolbar = <div data-testid="tool-provider-toolbar" /> - const body = <div data-testid="tool-provider-list" data-mounted-category={mountedCategory}>{category}</div> + const body = ( + <div data-testid="tool-provider-list" data-mounted-category={mountedCategory}> + {category} + </div> + ) - if (layout) - return layout({ body, toolbar }) + if (layout) return layout({ body, toolbar }) return body } @@ -236,16 +264,32 @@ vi.mock('../tool-provider-list', async () => { vi.mock('../plugin-category-page', () => ({ __esModule: true, - default: ({ canInstall, category, layout, onSwitchToMarketplace, toolbarAction }: { canInstall?: boolean, category: string, layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode, onSwitchToMarketplace?: () => void, toolbarAction?: React.ReactNode }) => { + default: ({ + canInstall, + category, + layout, + onSwitchToMarketplace, + toolbarAction, + }: { + canInstall?: boolean + category: string + layout?: (parts: { body: React.ReactNode; toolbar: React.ReactNode }) => React.ReactNode + onSwitchToMarketplace?: () => void + toolbarAction?: React.ReactNode + }) => { const toolbar = <div data-testid="plugin-category-toolbar">{toolbarAction}</div> const body = ( - <div data-can-install={canInstall ? 'true' : 'false'} data-testid={`plugin-category-${category}`}> - <button type="button" aria-label="empty marketplace" onClick={onSwitchToMarketplace}>marketplace</button> + <div + data-can-install={canInstall ? 'true' : 'false'} + data-testid={`plugin-category-${category}`} + > + <button type="button" aria-label="empty marketplace" onClick={onSwitchToMarketplace}> + marketplace + </button> </div> ) - if (layout) - return layout({ body, toolbar }) + if (layout) return layout({ body, toolbar }) return ( <> @@ -258,11 +302,11 @@ vi.mock('../plugin-category-page', () => ({ const renderIntegrationsPage = ( searchParams?: Record<string, string>, - sectionOrProps?: React.ComponentProps<typeof IntegrationsPage>['section'] | Partial<React.ComponentProps<typeof IntegrationsPage>>, + sectionOrProps?: + | React.ComponentProps<typeof IntegrationsPage>['section'] + | Partial<React.ComponentProps<typeof IntegrationsPage>>, ) => { - const props = typeof sectionOrProps === 'string' - ? { section: sectionOrProps } - : sectionOrProps + const props = typeof sectionOrProps === 'string' ? { section: sectionOrProps } : sectionOrProps return renderWithNuqs(<IntegrationsPage {...props} />, { searchParams }) } @@ -303,14 +347,31 @@ describe('IntegrationsPage', () => { renderIntegrationsPage({ section: 'provider' }) expect(screen.getByTestId('model-provider-page')).toBeInTheDocument() - expect(screen.getByTestId('install-from-marketplace-query')).toHaveAttribute('data-install-context-category', 'model') - expect(screen.getByTestId('model-provider-toolbar').closest('[class*="max-w-[1600px]"]')).toHaveClass('px-6', 'pt-3', 'pb-2') - expect(within(screen.getByTestId('model-provider-toolbar').closest('section')!).getByText('common.settings.provider')).toHaveClass('title-2xl-semi-bold') - expect(screen.getByTestId('model-provider-page').parentElement).toHaveClass('max-w-[1600px]', 'px-6') + expect(screen.getByTestId('install-from-marketplace-query')).toHaveAttribute( + 'data-install-context-category', + 'model', + ) + expect( + screen.getByTestId('model-provider-toolbar').closest('[class*="max-w-[1600px]"]'), + ).toHaveClass('px-6', 'pt-3', 'pb-2') + expect( + within(screen.getByTestId('model-provider-toolbar').closest('section')!).getByText( + 'common.settings.provider', + ), + ).toHaveClass('title-2xl-semi-bold') + expect(screen.getByTestId('model-provider-page').parentElement).toHaveClass( + 'max-w-[1600px]', + 'px-6', + ) expect(screen.getByTestId('model-provider-page').parentElement).not.toHaveClass('pt-2') expect(screen.getAllByText('common.settings.provider')).toHaveLength(2) - expect(screen.getByRole('link', { name: 'common.settings.provider' })).toHaveAttribute('aria-current', 'page') - expect(screen.getByRole('link', { name: 'common.settings.dataSource' })).not.toHaveAttribute('aria-current') + expect(screen.getByRole('link', { name: 'common.settings.provider' })).toHaveAttribute( + 'aria-current', + 'page', + ) + expect(screen.getByRole('link', { name: 'common.settings.dataSource' })).not.toHaveAttribute( + 'aria-current', + ) expect(screen.getByRole('textbox', { name: 'search' })).toBeInTheDocument() }) @@ -319,68 +380,115 @@ describe('IntegrationsPage', () => { const navText = screen.getByRole('navigation').textContent ?? '' - expect(navText.indexOf('common.settings.provider')).toBeLessThan(navText.indexOf('common.menus.tools')) - expect(navText.indexOf('common.menus.tools')).toBeLessThan(navText.indexOf('common.settings.dataSource')) - expect(navText.indexOf('common.settings.dataSource')).toBeLessThan(navText.indexOf('plugin.categorySingle.trigger')) - expect(navText.indexOf('plugin.categorySingle.trigger')).toBeLessThan(navText.indexOf('plugin.categorySingle.agent')) - expect(navText.indexOf('plugin.categorySingle.agent')).toBeLessThan(navText.indexOf('plugin.categorySingle.extension')) - expect(navText.indexOf('plugin.categorySingle.extension')).toBeLessThan(navText.indexOf('common.settings.customEndpoint')) + expect(navText.indexOf('common.settings.provider')).toBeLessThan( + navText.indexOf('common.menus.tools'), + ) + expect(navText.indexOf('common.menus.tools')).toBeLessThan( + navText.indexOf('common.settings.dataSource'), + ) + expect(navText.indexOf('common.settings.dataSource')).toBeLessThan( + navText.indexOf('plugin.categorySingle.trigger'), + ) + expect(navText.indexOf('plugin.categorySingle.trigger')).toBeLessThan( + navText.indexOf('plugin.categorySingle.agent'), + ) + expect(navText.indexOf('plugin.categorySingle.agent')).toBeLessThan( + navText.indexOf('plugin.categorySingle.extension'), + ) + expect(navText.indexOf('plugin.categorySingle.extension')).toBeLessThan( + navText.indexOf('common.settings.customEndpoint'), + ) }) it('keeps sidebar item icons outlined when the item is active', () => { const providerView = renderIntegrationsPage({ section: 'provider' }) - expect(screen.getByRole('link', { name: 'common.settings.dataSource' }).querySelector('.i-ri-database-2-line')).toBeInTheDocument() + expect( + screen + .getByRole('link', { name: 'common.settings.dataSource' }) + .querySelector('.i-ri-database-2-line'), + ).toBeInTheDocument() providerView.rerender(<IntegrationsPage section="data-source" />) - expect(screen.getByRole('link', { name: 'common.settings.dataSource' }).querySelector('.i-ri-database-2-line')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'common.settings.dataSource' }).querySelector('.i-ri-database-2-fill')).not.toBeInTheDocument() + expect( + screen + .getByRole('link', { name: 'common.settings.dataSource' }) + .querySelector('.i-ri-database-2-line'), + ).toBeInTheDocument() + expect( + screen + .getByRole('link', { name: 'common.settings.dataSource' }) + .querySelector('.i-ri-database-2-fill'), + ).not.toBeInTheDocument() }) it('renders plugin category sections from the section query', () => { const toolView = renderIntegrationsPage({ section: 'builtin' }) expect(screen.getByTestId('plugin-category-tool')).toBeInTheDocument() - expect(screen.getByTestId('plugin-category-tool').parentElement).toHaveClass('flex', 'flex-col', 'overflow-hidden') - expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toHaveAttribute('href', '/integrations/tools/built-in') + expect(screen.getByTestId('plugin-category-tool').parentElement).toHaveClass( + 'flex', + 'flex-col', + 'overflow-hidden', + ) + expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toHaveAttribute( + 'href', + '/integrations/tools/built-in', + ) toolView.unmount() const triggerView = renderIntegrationsPage({ section: 'trigger' }) expect(screen.getByTestId('plugin-category-trigger')).toBeInTheDocument() - expect(screen.getByTestId('plugin-category-trigger').parentElement).toHaveClass('flex', 'flex-col', 'overflow-hidden') - expect(screen.getByRole('link', { name: 'plugin.categorySingle.trigger' })).toHaveAttribute('href', '/integrations/trigger') + expect(screen.getByTestId('plugin-category-trigger').parentElement).toHaveClass( + 'flex', + 'flex-col', + 'overflow-hidden', + ) + expect(screen.getByRole('link', { name: 'plugin.categorySingle.trigger' })).toHaveAttribute( + 'href', + '/integrations/trigger', + ) triggerView.unmount() const agentStrategyView = renderIntegrationsPage({ section: 'agent-strategy' }) expect(screen.getByTestId('plugin-category-agent-strategy')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'plugin.categorySingle.agent' })).toHaveAttribute('href', '/integrations/agent-strategy') + expect(screen.getByRole('link', { name: 'plugin.categorySingle.agent' })).toHaveAttribute( + 'href', + '/integrations/agent-strategy', + ) agentStrategyView.unmount() renderIntegrationsPage({ section: 'extension' }) expect(screen.getByTestId('plugin-category-extension')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'plugin.categorySingle.extension' })).toHaveAttribute('href', '/integrations/extension') + expect(screen.getByRole('link', { name: 'plugin.categorySingle.extension' })).toHaveAttribute( + 'href', + '/integrations/extension', + ) }) it.each([ ['provider', 'model provider marketplace', '/plugins/model'], ['data-source', 'data source marketplace', '/plugins/datasource'], ['extension', 'empty marketplace', '/plugins/extension'], - ] as const)('opens the %s marketplace path from integrations', (section, buttonName, marketplacePath) => { - renderIntegrationsPage({ section }) - - fireEvent.click(screen.getByRole('button', { name: buttonName })) - - expect(mockWindowOpen).toHaveBeenCalledWith( - expect.stringContaining(`${marketplacePath}?source=`), - '_blank', - 'noopener,noreferrer', - ) - expect(mockRouterPush).not.toHaveBeenCalled() - }) + ] as const)( + 'opens the %s marketplace path from integrations', + (section, buttonName, marketplacePath) => { + renderIntegrationsPage({ section }) + + fireEvent.click(screen.getByRole('button', { name: buttonName })) + + expect(mockWindowOpen).toHaveBeenCalledWith( + expect.stringContaining(`${marketplacePath}?source=`), + '_blank', + 'noopener,noreferrer', + ) + expect(mockRouterPush).not.toHaveBeenCalled() + }, + ) it('passes marketplace platform paths to external marketplace callbacks', () => { const onSwitchToMarketplace = vi.fn() @@ -396,8 +504,13 @@ describe('IntegrationsPage', () => { const { unmount } = renderIntegrationsPage({ section: 'data-source' }) expect(screen.getByTestId('data-source-page')).toBeInTheDocument() - expect(screen.getByTestId('install-from-marketplace-query')).toHaveAttribute('data-install-context-category', 'datasource') - expect(screen.getByRole('button', { name: 'plugin debug' })).toHaveTextContent('plugin.debugInfo.title') + expect(screen.getByTestId('install-from-marketplace-query')).toHaveAttribute( + 'data-install-context-category', + 'datasource', + ) + expect(screen.getByRole('button', { name: 'plugin debug' })).toHaveTextContent( + 'plugin.debugInfo.title', + ) unmount() renderIntegrationsPage({ section: 'custom-endpoint' }) @@ -411,7 +524,9 @@ describe('IntegrationsPage', () => { renderIntegrationsPage({ section: 'data-source' }) - expect(screen.queryByLabelText('plugin.privilege.noDebugPermissionTooltip')).not.toBeInTheDocument() + expect( + screen.queryByLabelText('plugin.privilege.noDebugPermissionTooltip'), + ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'plugin debug' })).not.toBeInTheDocument() }) @@ -424,7 +539,11 @@ describe('IntegrationsPage', () => { const mcpView = renderIntegrationsPage(undefined, 'mcp') expect(screen.getByTestId('tool-provider-list')).toHaveTextContent('mcp') - expect(screen.getByTestId('tool-provider-list').parentElement).toHaveClass('flex', 'flex-col', 'overflow-hidden') + expect(screen.getByTestId('tool-provider-list').parentElement).toHaveClass( + 'flex', + 'flex-col', + 'overflow-hidden', + ) mcpView.unmount() renderIntegrationsPage(undefined, 'data-source') @@ -440,13 +559,16 @@ describe('IntegrationsPage', () => { expect(screen.getByTestId('tool-provider-list')).toHaveTextContent('mcp') }) - it.each(['custom-tool', 'workflow-tool'] as const)('renders the %s route as read-only without tool.manage', (section) => { - mockAppContextState.workspacePermissionKeys = ['mcp.manage'] + it.each(['custom-tool', 'workflow-tool'] as const)( + 'renders the %s route as read-only without tool.manage', + (section) => { + mockAppContextState.workspacePermissionKeys = ['mcp.manage'] - renderIntegrationsPage(undefined, section) + renderIntegrationsPage(undefined, section) - expect(screen.getByTestId('tool-provider-list')).toBeInTheDocument() - }) + expect(screen.getByTestId('tool-provider-list')).toBeInTheDocument() + }, + ) it('remounts the tools section content when the route section changes', () => { const view = renderIntegrationsPage(undefined, 'builtin') @@ -462,60 +584,115 @@ describe('IntegrationsPage', () => { renderIntegrationsPage({ category: 'mcp' }) expect(screen.getByTestId('tool-provider-list')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.menus.tools' })).not.toHaveClass('bg-state-base-active') - expect(screen.getByRole('button', { name: 'common.menus.tools' })).not.toHaveAttribute('aria-current') - expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toHaveAttribute('href', '/integrations/tools/built-in') + expect(screen.getByRole('button', { name: 'common.menus.tools' })).not.toHaveClass( + 'bg-state-base-active', + ) + expect(screen.getByRole('button', { name: 'common.menus.tools' })).not.toHaveAttribute( + 'aria-current', + ) + expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toHaveAttribute( + 'href', + '/integrations/tools/built-in', + ) expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toHaveClass('pl-8') - expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' }).querySelector('.i-custom-vender-integrations-tools')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'MCP' })).toHaveAttribute('href', '/integrations/tools/mcp') + expect( + screen + .getByRole('link', { name: 'common.toolsPage.toolPlugin' }) + .querySelector('.i-custom-vender-integrations-tools'), + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'MCP' })).toHaveAttribute( + 'href', + '/integrations/tools/mcp', + ) expect(screen.getByRole('link', { name: 'MCP' })).toHaveClass('bg-state-base-active') expect(screen.getByRole('link', { name: 'MCP' })).toHaveAttribute('aria-current', 'page') - expect(screen.getByRole('link', { name: 'common.settings.swaggerAPIAsTool' })).toHaveAttribute('href', '/integrations/tools/api') - expect(screen.getByRole('link', { name: 'workflow.common.workflowAsTool' })).toHaveAttribute('href', '/integrations/tools/workflow') - const workflowToolIcon = screen.getByRole('link', { name: 'workflow.common.workflowAsTool' }).querySelector('.i-custom-vender-integrations-workflow-as-tool') + expect(screen.getByRole('link', { name: 'common.settings.swaggerAPIAsTool' })).toHaveAttribute( + 'href', + '/integrations/tools/api', + ) + expect(screen.getByRole('link', { name: 'workflow.common.workflowAsTool' })).toHaveAttribute( + 'href', + '/integrations/tools/workflow', + ) + const workflowToolIcon = screen + .getByRole('link', { name: 'workflow.common.workflowAsTool' }) + .querySelector('.i-custom-vender-integrations-workflow-as-tool') expect(workflowToolIcon).toBeInTheDocument() expect(workflowToolIcon).toHaveClass('size-4') - expect(screen.getByRole('link', { name: 'workflow.common.workflowAsTool' }).querySelector('.i-ri-node-tree')).not.toBeInTheDocument() - expect(screen.getByRole('link', { name: 'workflow.common.workflowAsTool' }).compareDocumentPosition(screen.getByRole('link', { name: 'common.settings.swaggerAPIAsTool' }))).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect( + screen + .getByRole('link', { name: 'workflow.common.workflowAsTool' }) + .querySelector('.i-ri-node-tree'), + ).not.toBeInTheDocument() + expect( + screen + .getByRole('link', { name: 'workflow.common.workflowAsTool' }) + .compareDocumentPosition( + screen.getByRole('link', { name: 'common.settings.swaggerAPIAsTool' }), + ), + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) }) it('uses hover-only arrows for the tools parent icon', () => { const view = renderIntegrationsPage({ section: 'provider' }) const collapsedToolsButton = screen.getByRole('button', { name: 'common.menus.tools' }) - const collapsedDisclosureIcon = collapsedToolsButton.querySelector('svg[viewBox="0 0 12 14.0003"]') + const collapsedDisclosureIcon = collapsedToolsButton.querySelector( + 'svg[viewBox="0 0 12 14.0003"]', + ) expect(collapsedToolsButton).toHaveAttribute('aria-expanded', 'false') expect(collapsedDisclosureIcon).toBeInTheDocument() expect(collapsedDisclosureIcon).toHaveClass('h-3.5', 'w-3', 'group-hover:hidden') expect(collapsedToolsButton.querySelector('[data-icon="MagicBox"]')).not.toBeInTheDocument() - expect(collapsedToolsButton.querySelector('.i-custom-vender-solid-mediaAndDevices-magic-box')).not.toBeInTheDocument() - expect(collapsedToolsButton.querySelector('.i-custom-vender-plugin-box-sparkle-fill')).not.toBeInTheDocument() - expect(collapsedToolsButton.querySelector('.i-ri-arrow-down-s-line')).toHaveClass('hidden', 'group-hover:inline-block') + expect( + collapsedToolsButton.querySelector('.i-custom-vender-solid-mediaAndDevices-magic-box'), + ).not.toBeInTheDocument() + expect( + collapsedToolsButton.querySelector('.i-custom-vender-plugin-box-sparkle-fill'), + ).not.toBeInTheDocument() + expect(collapsedToolsButton.querySelector('.i-ri-arrow-down-s-line')).toHaveClass( + 'hidden', + 'group-hover:inline-block', + ) expect(collapsedToolsButton.querySelector('.i-ri-arrow-up-s-line')).not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'common.toolsPage.toolPlugin' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'common.toolsPage.toolPlugin' }), + ).not.toBeInTheDocument() view.unmount() renderIntegrationsPage({ section: 'mcp' }) const expandedToolsButton = screen.getByRole('button', { name: 'common.menus.tools' }) - const expandedDisclosureIcon = expandedToolsButton.querySelector('svg[viewBox="0 0 12 14.0003"]') + const expandedDisclosureIcon = expandedToolsButton.querySelector( + 'svg[viewBox="0 0 12 14.0003"]', + ) expect(expandedToolsButton).toHaveAttribute('aria-expanded', 'true') expect(expandedToolsButton).not.toHaveClass('bg-state-base-active') expect(expandedToolsButton).not.toHaveAttribute('aria-current') expect(expandedDisclosureIcon).toBeInTheDocument() - expect(expandedToolsButton.querySelector('.i-ri-arrow-up-s-line')).toHaveClass('hidden', 'group-hover:inline-block') + expect(expandedToolsButton.querySelector('.i-ri-arrow-up-s-line')).toHaveClass( + 'hidden', + 'group-hover:inline-block', + ) expect(expandedToolsButton.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument() - expect(expandedToolsButton.querySelector('.i-custom-vender-integrations-tools-active')).not.toBeInTheDocument() - expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toHaveAttribute('href', '/integrations/tools/built-in') + expect( + expandedToolsButton.querySelector('.i-custom-vender-integrations-tools-active'), + ).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toHaveAttribute( + 'href', + '/integrations/tools/built-in', + ) }) it('toggles the tools submenu without other nav items closing it', () => { const onSectionChange = vi.fn() renderWithNuqs(<IntegrationsPage section="provider" onSectionChange={onSectionChange} />) - expect(screen.getByRole('button', { name: 'common.settings.provider' })).toHaveClass('bg-state-base-active') + expect(screen.getByRole('button', { name: 'common.settings.provider' })).toHaveClass( + 'bg-state-base-active', + ) const toolsButton = screen.getByRole('button', { name: 'common.menus.tools' }) @@ -550,8 +727,12 @@ describe('IntegrationsPage', () => { expect(screen.getByRole('button', { name: 'common.toolsPage.toolPlugin' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'MCP' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'workflow.common.workflowAsTool' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.settings.swaggerAPIAsTool' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'workflow.common.workflowAsTool' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.settings.swaggerAPIAsTool' }), + ).toBeInTheDocument() }) it('opens tools to the tools plugin page when the parent tools nav is clicked', () => { @@ -566,22 +747,35 @@ describe('IntegrationsPage', () => { const view = renderIntegrationsPage(undefined, 'mcp') expect(screen.getByTestId('tool-provider-list')).toHaveAttribute('data-mounted-category', 'mcp') - expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute( + 'aria-expanded', + 'true', + ) expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toBeInTheDocument() expect(screen.getByRole('link', { name: 'MCP' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'common.menus.tools' })) expect(screen.getByTestId('tool-provider-list')).toHaveAttribute('data-mounted-category', 'mcp') - expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByRole('link', { name: 'common.toolsPage.toolPlugin' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + expect( + screen.queryByRole('link', { name: 'common.toolsPage.toolPlugin' }), + ).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: 'MCP' })).not.toBeInTheDocument() view.rerender(<IntegrationsPage section="provider" />) expect(screen.getByTestId('model-provider-page')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByRole('link', { name: 'common.toolsPage.toolPlugin' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + expect( + screen.queryByRole('link', { name: 'common.toolsPage.toolPlugin' }), + ).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: 'MCP' })).not.toBeInTheDocument() }) @@ -590,8 +784,13 @@ describe('IntegrationsPage', () => { expect(screen.getAllByText('common.toolsPage.toolPlugin')).toHaveLength(2) expect(screen.getByText('common.toolsPage.description')).toBeInTheDocument() - expect(screen.getByText('common.toolsPage.description').closest('[class*="max-w-[1600px]"]')).toHaveClass('px-6') - expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools') + expect( + screen.getByText('common.toolsPage.description').closest('[class*="max-w-[1600px]"]'), + ).toHaveClass('px-6') + expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools', + ) }) it('aligns model provider headers to the unified content frame', () => { @@ -599,13 +798,17 @@ describe('IntegrationsPage', () => { const description = screen.getByText('common.modelProvider.pageDesc') expect(description.closest('[class*="max-w-[1600px]"]')).toHaveClass('px-6') - expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toBeInTheDocument() + expect( + screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i }), + ).toBeInTheDocument() }) it('aligns plugin category headers to the unified content frame', () => { renderIntegrationsPage({ section: 'trigger' }) - expect(screen.getByText('common.triggerPage.description').closest('[class*="max-w-[1600px]"]')).toHaveClass('px-6') + expect( + screen.getByText('common.triggerPage.description').closest('[class*="max-w-[1600px]"]'), + ).toHaveClass('px-6') }) it('renders the mcp header for the mcp section', () => { @@ -613,7 +816,10 @@ describe('IntegrationsPage', () => { expect(screen.getAllByText('MCP')).toHaveLength(2) expect(screen.getByText('common.mcpPage.description')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#mcp') + expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#mcp', + ) expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument() }) @@ -622,18 +828,29 @@ describe('IntegrationsPage', () => { expect(screen.getAllByText('common.settings.swaggerAPIAsTool')).toHaveLength(2) expect(screen.getByText('common.swaggerAPIAsToolPage.description')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#swagger-api') + expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#swagger-api', + ) expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument() }) it.each([ - ['data-source', 'common.settings.dataSource', 'common.dataSourcePage.description', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/datasource-plugin#data-source-plugin-types'], + [ + 'data-source', + 'common.settings.dataSource', + 'common.dataSourcePage.description', + 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/datasource-plugin#data-source-plugin-types', + ], ] as const)('renders the %s header with a docs link', (section, title, description, href) => { renderIntegrationsPage({ section }) expect(screen.getAllByText(title)).toHaveLength(2) expect(screen.getByText(description)).toBeInTheDocument() - expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', href) + expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( + 'href', + href, + ) expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument() }) @@ -643,20 +860,41 @@ describe('IntegrationsPage', () => { expect(screen.getAllByText('common.settings.customEndpoint')).toHaveLength(2) expect(screen.getByText('common.apiBasedExtensionPage.description')).toBeInTheDocument() expect(screen.getByTestId('api-extension-toolbar')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/endpoint') + expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/endpoint', + ) expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument() }) it.each([ - ['trigger', 'plugin.categorySingle.trigger', 'common.triggerPage.description', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/trigger-plugin'], - ['extension', 'plugin.categorySingle.extension', 'common.extensionPage.description', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension'], - ['agent-strategy', 'plugin.categorySingle.agent', 'common.agentStrategyPage.description', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/agent-strategy-plugin'], + [ + 'trigger', + 'plugin.categorySingle.trigger', + 'common.triggerPage.description', + 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/trigger-plugin', + ], + [ + 'extension', + 'plugin.categorySingle.extension', + 'common.extensionPage.description', + 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension', + ], + [ + 'agent-strategy', + 'plugin.categorySingle.agent', + 'common.agentStrategyPage.description', + 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/agent-strategy-plugin', + ], ] as const)('renders the %s header with a docs link', (section, title, description, href) => { renderIntegrationsPage({ section }) expect(screen.getAllByText(title)).toHaveLength(2) expect(screen.getByText(description)).toBeInTheDocument() - expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', href) + expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( + 'href', + href, + ) expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument() }) @@ -665,7 +903,10 @@ describe('IntegrationsPage', () => { expect(screen.getAllByText('workflow.common.workflowAsTool')).toHaveLength(2) expect(screen.getByText('common.workflowAsToolPage.description')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow') + expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow', + ) expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument() }) @@ -682,15 +923,20 @@ describe('IntegrationsPage', () => { ] as const)('renders an unbordered header for %s', (section, description) => { renderIntegrationsPage({ section }) - expect(screen.getByText(description).parentElement?.parentElement?.parentElement).not.toHaveClass('border-b', 'border-divider-subtle') + expect( + screen.getByText(description).parentElement?.parentElement?.parentElement, + ).not.toHaveClass('border-b', 'border-divider-subtle') }) - it.each(['builtin', 'trigger', 'extension', 'agent-strategy'] as const)('renders plugin update settings action in the category toolbar for %s', (section) => { - renderIntegrationsPage({ section }) + it.each(['builtin', 'trigger', 'extension', 'agent-strategy'] as const)( + 'renders plugin update settings action in the category toolbar for %s', + (section) => { + renderIntegrationsPage({ section }) - expect(screen.getByText('plugin.autoUpdate.autoUpdate')).toBeInTheDocument() - expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.name')).toBeInTheDocument() - }) + expect(screen.getByText('plugin.autoUpdate.autoUpdate')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.name')).toBeInTheDocument() + }, + ) it('opens the integrations marketplace path from the install dropdown marketplace action', () => { renderIntegrationsPage({ section: 'builtin' }) @@ -710,9 +956,14 @@ describe('IntegrationsPage', () => { renderIntegrationsPage({ section: 'trigger' }) - expect(screen.queryByLabelText('plugin.privilege.noInstallPermissionTooltip')).not.toBeInTheDocument() + expect( + screen.queryByLabelText('plugin.privilege.noInstallPermissionTooltip'), + ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'plugin install' })).not.toBeInTheDocument() - expect(screen.getByTestId('plugin-category-trigger')).toHaveAttribute('data-can-install', 'false') + expect(screen.getByTestId('plugin-category-trigger')).toHaveAttribute( + 'data-can-install', + 'false', + ) }) it('hides the debug action when debug permission is unavailable', () => { @@ -720,7 +971,9 @@ describe('IntegrationsPage', () => { renderIntegrationsPage({ section: 'trigger' }) - expect(screen.queryByLabelText('plugin.privilege.noDebugPermissionTooltip')).not.toBeInTheDocument() + expect( + screen.queryByLabelText('plugin.privilege.noDebugPermissionTooltip'), + ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'plugin debug' })).not.toBeInTheDocument() }) @@ -742,10 +995,20 @@ describe('IntegrationsPage', () => { expect(screen.getByText('plugin.privilege.quickWhoCanDebug')).toBeInTheDocument() const dialog = screen.getByRole('dialog') - expect(within(dialog).getByText('plugin.privilege.permissions').closest('.w-\\[360px\\]')).toHaveClass('rounded-2xl', 'shadow-2xl') - expect(screen.getByRole('radio', { name: 'plugin.privilege.quickWhoCanInstall: plugin.privilege.everyone' })).toHaveClass('w-[104px]', 'h-8') - - fireEvent.click(screen.getByRole('radio', { name: 'plugin.privilege.quickWhoCanInstall: plugin.privilege.noone' })) + expect( + within(dialog).getByText('plugin.privilege.permissions').closest('.w-\\[360px\\]'), + ).toHaveClass('rounded-2xl', 'shadow-2xl') + expect( + screen.getByRole('radio', { + name: 'plugin.privilege.quickWhoCanInstall: plugin.privilege.everyone', + }), + ).toHaveClass('w-[104px]', 'h-8') + + fireEvent.click( + screen.getByRole('radio', { + name: 'plugin.privilege.quickWhoCanInstall: plugin.privilege.noone', + }), + ) expect(mockSetReferenceSettings).toHaveBeenCalledWith({ install_permission: 'noone', @@ -757,7 +1020,9 @@ describe('IntegrationsPage', () => { mockCanSetPermissions.mockReturnValue(false) renderIntegrationsPage({ section: 'provider' }) - expect(screen.queryByRole('button', { name: 'plugin.privilege.permissions' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'plugin.privilege.permissions' }), + ).not.toBeInTheDocument() expect(screen.queryByText('plugin.privilege.quickWhoCanInstall')).not.toBeInTheDocument() expect(screen.queryByText('plugin.privilege.quickWhoCanDebug')).not.toBeInTheDocument() }) @@ -767,8 +1032,12 @@ describe('IntegrationsPage', () => { renderIntegrationsPage({ section: 'provider' }) - expect(screen.getByText('common.settings.integrations').parentElement?.parentElement).toHaveClass('mb-3', 'pt-1', 'pb-0.5') - expect(screen.getByRole('link', { name: 'common.settings.provider' }).parentElement).toHaveClass('py-4') + expect( + screen.getByText('common.settings.integrations').parentElement?.parentElement, + ).toHaveClass('mb-3', 'pt-1', 'pb-0.5') + expect( + screen.getByRole('link', { name: 'common.settings.provider' }).parentElement, + ).toHaveClass('py-4') }) it('keeps the integrations sidebar expanded without a collapse control', () => { @@ -779,21 +1048,60 @@ describe('IntegrationsPage', () => { }) expect(screen.getByText('common.settings.integrations')).toBeInTheDocument() - expect(screen.getByText('common.settings.integrations')).toHaveClass('title-2xl-semi-bold', 'text-text-primary') - expect(screen.getByText('common.settings.integrations').parentElement).toHaveClass('h-6', 'items-center') - expect(screen.getByRole('button', { name: 'plugin install' })).toHaveAttribute('data-show-trigger-arrow', 'false') + expect(screen.getByText('common.settings.integrations')).toHaveClass( + 'title-2xl-semi-bold', + 'text-text-primary', + ) + expect(screen.getByText('common.settings.integrations').parentElement).toHaveClass( + 'h-6', + 'items-center', + ) + expect(screen.getByRole('button', { name: 'plugin install' })).toHaveAttribute( + 'data-show-trigger-arrow', + 'false', + ) expect(screen.getByRole('button', { name: 'plugin install' })).toHaveClass('justify-start') expect(screen.getByRole('button', { name: 'plugin tasks' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'plugin debug' })).toHaveTextContent('plugin.debugInfo.title') - expect(screen.getByRole('button', { name: 'plugin debug' })).toHaveClass('h-8', 'w-full', 'gap-2', 'rounded-lg', 'py-1', 'pr-1', 'pl-2', 'system-sm-medium') + expect(screen.getByRole('button', { name: 'plugin debug' })).toHaveTextContent( + 'plugin.debugInfo.title', + ) + expect(screen.getByRole('button', { name: 'plugin debug' })).toHaveClass( + 'h-8', + 'w-full', + 'gap-2', + 'rounded-lg', + 'py-1', + 'pr-1', + 'pl-2', + 'system-sm-medium', + ) expect(screen.getByRole('button', { name: 'plugin debug' }).parentElement).toHaveClass('w-46') - expect(screen.getByRole('button', { name: 'plugin.privilege.permissions' })).toHaveTextContent('plugin.privilege.permissions') - expect(screen.getByRole('button', { name: 'plugin.privilege.permissions' })).toHaveClass('h-8', 'w-full', 'gap-2', 'rounded-lg', 'py-1', 'pr-1', 'pl-2', 'system-sm-medium') + expect(screen.getByRole('button', { name: 'plugin.privilege.permissions' })).toHaveTextContent( + 'plugin.privilege.permissions', + ) + expect(screen.getByRole('button', { name: 'plugin.privilege.permissions' })).toHaveClass( + 'h-8', + 'w-full', + 'gap-2', + 'rounded-lg', + 'py-1', + 'pr-1', + 'pl-2', + 'system-sm-medium', + ) expect(screen.queryByText('common.settings.swaggerAPIAsTool')).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: 'MCP' })).not.toBeInTheDocument() - expect(screen.getByRole('link', { name: 'common.settings.customEndpoint' })).toHaveAttribute('href', '/integrations/custom-endpoint') - expect(screen.getByRole('link', { name: 'plugin.categorySingle.trigger' })).toHaveAttribute('href', '/integrations/trigger') - expect(screen.queryByRole('button', { name: 'common.settings.collapse' })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'common.settings.customEndpoint' })).toHaveAttribute( + 'href', + '/integrations/custom-endpoint', + ) + expect(screen.getByRole('link', { name: 'plugin.categorySingle.trigger' })).toHaveAttribute( + 'href', + '/integrations/trigger', + ) + expect( + screen.queryByRole('button', { name: 'common.settings.collapse' }), + ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'common.settings.expand' })).not.toBeInTheDocument() }) }) diff --git a/web/app/components/integrations/__tests__/plugin-category-page.spec.tsx b/web/app/components/integrations/__tests__/plugin-category-page.spec.tsx index c93b8f1d6367fa..2e1dd5eccadf6b 100644 --- a/web/app/components/integrations/__tests__/plugin-category-page.spec.tsx +++ b/web/app/components/integrations/__tests__/plugin-category-page.spec.tsx @@ -37,14 +37,26 @@ vi.mock('@/app/components/plugins/plugin-page/context-provider', () => ({ })) vi.mock('@/app/components/plugins/plugin-page/context', () => ({ - usePluginPageContext: (selector: (value: { - containerRef: typeof mockContainerRef - }) => unknown) => selector({ containerRef: mockContainerRef }), + usePluginPageContext: (selector: (value: { containerRef: typeof mockContainerRef }) => unknown) => + selector({ containerRef: mockContainerRef }), })) vi.mock('@/app/components/plugins/plugin-page/plugins-panel', () => ({ - default: ({ canInstall, fixedCategory, onSwitchToMarketplace }: { canInstall?: boolean, fixedCategory: PluginCategoryEnum, onSwitchToMarketplace?: () => void }) => ( - <div data-can-install={canInstall ? 'true' : 'false'} data-fixed-category={fixedCategory} data-has-marketplace-action={onSwitchToMarketplace ? 'true' : 'false'} data-testid="plugins-panel" /> + default: ({ + canInstall, + fixedCategory, + onSwitchToMarketplace, + }: { + canInstall?: boolean + fixedCategory: PluginCategoryEnum + onSwitchToMarketplace?: () => void + }) => ( + <div + data-can-install={canInstall ? 'true' : 'false'} + data-fixed-category={fixedCategory} + data-has-marketplace-action={onSwitchToMarketplace ? 'true' : 'false'} + data-testid="plugins-panel" + /> ), })) @@ -53,7 +65,13 @@ vi.mock('@/app/components/plugins/plugin-page/use-uploader', () => ({ })) vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({ - default: ({ file, installContextCategory }: { file: File, installContextCategory?: PluginCategoryEnum }) => ( + default: ({ + file, + installContextCategory, + }: { + file: File + installContextCategory?: PluginCategoryEnum + }) => ( <div data-testid="install-from-local-package" data-file-name={file.name} @@ -77,7 +95,9 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () = data-install-context-category={installContextCategory} data-unique-identifier={uniqueIdentifier} > - <button type="button" onClick={onClose}>close</button> + <button type="button" onClick={onClose}> + close + </button> </div> ), })) @@ -98,7 +118,10 @@ describe('PluginCategoryPage', () => { beforeEach(() => { vi.clearAllMocks() mockPluginInstallationPermission.restrict_to_marketplace_only = false - mockUsePluginInstallation.mockReturnValue([{ packageId: null, bundleInfo: null }, mockSetInstallState]) + mockUsePluginInstallation.mockReturnValue([ + { packageId: null, bundleInfo: null }, + mockSetInstallState, + ]) }) it.each([ @@ -109,9 +132,11 @@ describe('PluginCategoryPage', () => { ])('sets drop install availability for %s', (category, enabled) => { render(<PluginCategoryPage category={category} />) - expect(mockUseUploader).toHaveBeenCalledWith(expect.objectContaining({ - enabled, - })) + expect(mockUseUploader).toHaveBeenCalledWith( + expect.objectContaining({ + enabled, + }), + ) }) it('uses the Figma panel background for scoped integration categories', () => { @@ -123,9 +148,17 @@ describe('PluginCategoryPage', () => { it('passes the marketplace action to the plugins panel', () => { const onSwitchToMarketplace = vi.fn() - render(<PluginCategoryPage category={PluginCategoryEnum.extension} onSwitchToMarketplace={onSwitchToMarketplace} />) - - expect(screen.getByTestId('plugins-panel')).toHaveAttribute('data-has-marketplace-action', 'true') + render( + <PluginCategoryPage + category={PluginCategoryEnum.extension} + onSwitchToMarketplace={onSwitchToMarketplace} + />, + ) + + expect(screen.getByTestId('plugins-panel')).toHaveAttribute( + 'data-has-marketplace-action', + 'true', + ) }) it('disables drop install when local packages are restricted', () => { @@ -133,25 +166,39 @@ describe('PluginCategoryPage', () => { render(<PluginCategoryPage category={PluginCategoryEnum.agent} />) - expect(mockUseUploader).toHaveBeenCalledWith(expect.objectContaining({ - enabled: false, - })) + expect(mockUseUploader).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }), + ) }) it('disables panel and drop installs when install permission is unavailable', () => { render(<PluginCategoryPage canInstall={false} category={PluginCategoryEnum.agent} />) expect(screen.getByTestId('plugins-panel')).toHaveAttribute('data-can-install', 'false') - expect(mockUseUploader).toHaveBeenCalledWith(expect.objectContaining({ - enabled: false, - })) + expect(mockUseUploader).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }), + ) }) it('keeps marketplace install params while install permission is loading', async () => { - const packageId = 'junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2' - mockUsePluginInstallation.mockReturnValue([{ packageId, bundleInfo: null }, mockSetInstallState]) - - render(<PluginCategoryPage canInstall={false} isInstallPermissionLoading category={PluginCategoryEnum.agent} />) + const packageId = + 'junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2' + mockUsePluginInstallation.mockReturnValue([ + { packageId, bundleInfo: null }, + mockSetInstallState, + ]) + + render( + <PluginCategoryPage + canInstall={false} + isInstallPermissionLoading + category={PluginCategoryEnum.agent} + />, + ) await waitFor(() => { expect(mockUseUploader).toHaveBeenCalled() @@ -162,8 +209,12 @@ describe('PluginCategoryPage', () => { }) it('clears marketplace install params when install permission is unavailable after loading', async () => { - const packageId = 'junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2' - mockUsePluginInstallation.mockReturnValue([{ packageId, bundleInfo: null }, mockSetInstallState]) + const packageId = + 'junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2' + mockUsePluginInstallation.mockReturnValue([ + { packageId, bundleInfo: null }, + mockSetInstallState, + ]) render(<PluginCategoryPage canInstall={false} category={PluginCategoryEnum.agent} />) @@ -182,13 +233,23 @@ describe('PluginCategoryPage', () => { uploaderOptions.onFileChange(new File(['test'], 'tool.difypkg')) }) - expect(screen.getByTestId('install-from-local-package')).toHaveAttribute('data-file-name', 'tool.difypkg') - expect(screen.getByTestId('install-from-local-package')).toHaveAttribute('data-install-context-category', PluginCategoryEnum.tool) + expect(screen.getByTestId('install-from-local-package')).toHaveAttribute( + 'data-file-name', + 'tool.difypkg', + ) + expect(screen.getByTestId('install-from-local-package')).toHaveAttribute( + 'data-install-context-category', + PluginCategoryEnum.tool, + ) }) it('opens the marketplace installer from package id query params', async () => { - const packageId = 'langgenius/telegram_trigger:0.0.6@923a18de89d8cdb7f419d0dff60bf08a8b81b65fef6bf606cf0ce4b0ee56a9ca' - mockUsePluginInstallation.mockReturnValue([{ packageId, bundleInfo: null }, mockSetInstallState]) + const packageId = + 'langgenius/telegram_trigger:0.0.6@923a18de89d8cdb7f419d0dff60bf08a8b81b65fef6bf606cf0ce4b0ee56a9ca' + mockUsePluginInstallation.mockReturnValue([ + { packageId, bundleInfo: null }, + mockSetInstallState, + ]) mockFetchManifestFromMarketPlace.mockResolvedValue({ data: { plugin: { @@ -204,9 +265,15 @@ describe('PluginCategoryPage', () => { await waitFor(() => { expect(mockFetchManifestFromMarketPlace).toHaveBeenCalledWith(encodeURIComponent(packageId)) - expect(screen.getByTestId('install-from-marketplace')).toHaveAttribute('data-unique-identifier', packageId) + expect(screen.getByTestId('install-from-marketplace')).toHaveAttribute( + 'data-unique-identifier', + packageId, + ) }) - expect(screen.getByTestId('install-from-marketplace')).toHaveAttribute('data-install-context-category', PluginCategoryEnum.trigger) + expect(screen.getByTestId('install-from-marketplace')).toHaveAttribute( + 'data-install-context-category', + PluginCategoryEnum.trigger, + ) fireEvent.click(screen.getByRole('button', { name: 'close' })) diff --git a/web/app/components/integrations/__tests__/routes.spec.ts b/web/app/components/integrations/__tests__/routes.spec.ts index 0ca0af78a145c3..7029d2dae189a4 100644 --- a/web/app/components/integrations/__tests__/routes.spec.ts +++ b/web/app/components/integrations/__tests__/routes.spec.ts @@ -10,32 +10,32 @@ import { describe('integration routes', () => { it('maps integration sections to canonical paths', () => { expect(integrationPathBySection).toEqual({ - 'provider': '/integrations/model-provider', - 'builtin': '/integrations/tools/built-in', + provider: '/integrations/model-provider', + builtin: '/integrations/tools/built-in', 'custom-tool': '/integrations/tools/api', 'workflow-tool': '/integrations/tools/workflow', - 'mcp': '/integrations/tools/mcp', + mcp: '/integrations/tools/mcp', 'data-source': '/integrations/data-source', 'custom-endpoint': '/integrations/custom-endpoint', - 'trigger': '/integrations/trigger', + trigger: '/integrations/trigger', 'agent-strategy': '/integrations/agent-strategy', - 'extension': '/integrations/extension', + extension: '/integrations/extension', }) expect(buildIntegrationPath('custom-tool')).toBe('/integrations/tools/api') }) it('maps integration sections to marketplace platform paths', () => { expect(marketplaceUrlPathByIntegrationSection).toEqual({ - 'provider': '/plugins/model', - 'builtin': '/plugins/tool', - 'mcp': '/plugins/tool', + provider: '/plugins/model', + builtin: '/plugins/tool', + mcp: '/plugins/tool', 'custom-tool': '/plugins/tool', 'workflow-tool': '/plugins/tool', 'data-source': '/plugins/datasource', 'custom-endpoint': '/plugins/extension', - 'trigger': '/plugins/trigger', + trigger: '/plugins/trigger', 'agent-strategy': '/plugins/agent-strategy', - 'extension': '/plugins/extension', + extension: '/plugins/extension', }) expect(buildMarketplaceUrlPathByIntegrationSection('provider')).toBe('/plugins/model') expect(buildMarketplaceUrlPathByIntegrationSection('custom-tool')).toBe('/plugins/tool') @@ -46,7 +46,10 @@ describe('integration routes', () => { [undefined, { type: 'redirect', destination: '/integrations/model-provider' }], [[], { type: 'redirect', destination: '/integrations/model-provider' }], [['model-provider'], { type: 'section', section: 'provider' }], - [['model-provider', 'plugins'], { type: 'redirect', destination: '/integrations/model-provider' }], + [ + ['model-provider', 'plugins'], + { type: 'redirect', destination: '/integrations/model-provider' }, + ], [['tools'], { type: 'redirect', destination: '/integrations/tools/built-in' }], [['tools', 'built-in'], { type: 'section', section: 'builtin' }], [['tool', 'api'], { type: 'redirect', destination: '/integrations/tools/api' }], @@ -58,9 +61,15 @@ describe('integration routes', () => { [['trigger'], { type: 'section', section: 'trigger' }], [['agent-strategy'], { type: 'section', section: 'agent-strategy' }], [['extension'], { type: 'section', section: 'extension' }], - [['agent-strategy', 'plugins'], { type: 'redirect', destination: '/integrations/agent-strategy' }], + [ + ['agent-strategy', 'plugins'], + { type: 'redirect', destination: '/integrations/agent-strategy' }, + ], [['trigger', 'plugins'], { type: 'redirect', destination: '/integrations/trigger' }], - [['tools', 'built-in', 'plugins'], { type: 'redirect', destination: '/integrations/tools/built-in' }], + [ + ['tools', 'built-in', 'plugins'], + { type: 'redirect', destination: '/integrations/tools/built-in' }, + ], [['data-source', 'plugins'], { type: 'redirect', destination: '/integrations/data-source' }], [['model-providers'], { type: 'not-found' }], [['data-sources'], { type: 'not-found' }], @@ -77,36 +86,45 @@ describe('integration routes', () => { }) it('preserves query params when redirecting legacy custom tool route', () => { - expect(getIntegrationRouteTargetBySlug(['tool', 'api'], { - q: 'slack', - tags: ['a', 'b'], - })).toEqual({ + expect( + getIntegrationRouteTargetBySlug(['tool', 'api'], { + q: 'slack', + tags: ['a', 'b'], + }), + ).toEqual({ type: 'redirect', destination: '/integrations/tools/api?q=slack&tags=a&tags=b', }) }) it('preserves marketplace install query params when redirecting nested marketplace callbacks', () => { - expect(getIntegrationRouteTargetBySlug(['agent-strategy', 'plugins'], { - 'package-ids': '["junjiem/mcp_see_agent"]', - })).toEqual({ + expect( + getIntegrationRouteTargetBySlug(['agent-strategy', 'plugins'], { + 'package-ids': '["junjiem/mcp_see_agent"]', + }), + ).toEqual({ type: 'redirect', destination: '/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%22%5D', }) }) it('preserves marketplace install query params when redirecting model and data source callbacks', () => { - expect(getIntegrationRouteTargetBySlug(['model-provider', 'plugins'], { - 'package-ids': '["langgenius/openai"]', - })).toEqual({ + expect( + getIntegrationRouteTargetBySlug(['model-provider', 'plugins'], { + 'package-ids': '["langgenius/openai"]', + }), + ).toEqual({ type: 'redirect', destination: '/integrations/model-provider?package-ids=%5B%22langgenius%2Fopenai%22%5D', }) - expect(getIntegrationRouteTargetBySlug(['data-source', 'plugins'], { - 'package-ids': '["langgenius/notion_datasource"]', - })).toEqual({ + expect( + getIntegrationRouteTargetBySlug(['data-source', 'plugins'], { + 'package-ids': '["langgenius/notion_datasource"]', + }), + ).toEqual({ type: 'redirect', - destination: '/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D', + destination: + '/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D', }) }) @@ -120,7 +138,10 @@ describe('integration routes', () => { [{ category: 'mcp' }, '/integrations/tools/mcp'], [{ section: 'data-source' }, '/integrations/data-source'], [{ section: 'custom-endpoint' }, '/integrations/custom-endpoint'], - [{ section: 'custom-tool', category: 'api', q: 'slack', tags: ['a', 'b'] }, '/integrations/tools/api?q=slack&tags=a&tags=b'], + [ + { section: 'custom-tool', category: 'api', q: 'slack', tags: ['a', 'b'] }, + '/integrations/tools/api?q=slack&tags=a&tags=b', + ], ])('builds legacy /tools redirect for search params %j', (searchParams, expected) => { expect(getIntegrationRedirectPathByLegacyToolsSearchParams(searchParams)).toBe(expected) }) diff --git a/web/app/components/integrations/__tests__/tool-provider-card.spec.tsx b/web/app/components/integrations/__tests__/tool-provider-card.spec.tsx index d736e08add94e4..1bb200def2ef4a 100644 --- a/web/app/components/integrations/__tests__/tool-provider-card.spec.tsx +++ b/web/app/components/integrations/__tests__/tool-provider-card.spec.tsx @@ -5,12 +5,14 @@ import IntegrationsToolProviderCard from '../tool-provider-card' vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ i18n: { language: 'en-US' }, - t: withSelectorKey((key: string, options?: { count?: number, ns?: string }) => options?.ns ? `${options.ns}.${key}${options.count ? `:${options.count}` : ''}` : key), + t: withSelectorKey((key: string, options?: { count?: number; ns?: string }) => + options?.ns ? `${options.ns}.${key}${options.count ? `:${options.count}` : ''}` : key, + ), }), - }) + } }) vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ @@ -41,7 +43,9 @@ describe('IntegrationsToolProviderCard', () => { it('shows a built-in corner badge for builtin tools', () => { render(<IntegrationsToolProviderCard collection={createCollection()} showBuiltInBadge />) - expect(screen.getByTestId('corner-mark')).toHaveTextContent('dataset.metadata.datasetMetadata.builtIn') + expect(screen.getByTestId('corner-mark')).toHaveTextContent( + 'dataset.metadata.datasetMetadata.builtIn', + ) }) it('does not infer built-in badge from a missing plugin id', () => { @@ -57,19 +61,22 @@ describe('IntegrationsToolProviderCard', () => { }) it('does not show a built-in corner badge for marketplace plugin tools', () => { - render(<IntegrationsToolProviderCard collection={createCollection({ plugin_id: 'author/provider' })} showBuiltInBadge />) + render( + <IntegrationsToolProviderCard + collection={createCollection({ plugin_id: 'author/provider' })} + showBuiltInBadge + />, + ) expect(screen.queryByTestId('corner-mark')).not.toBeInTheDocument() }) it('shows the built-in tool count without using endpoint copy', () => { render( - <IntegrationsToolProviderCard collection={createCollection({ - tools: [ - { name: 'tool-a' }, - { name: 'tool-b' }, - ] as Collection['tools'], - })} + <IntegrationsToolProviderCard + collection={createCollection({ + tools: [{ name: 'tool-a' }, { name: 'tool-b' }] as Collection['tools'], + })} />, ) @@ -83,15 +90,17 @@ describe('IntegrationsToolProviderCard', () => { collection={createCollection({ author: 'Evan', labels: ['Productivity', 'Utilities'], - tools: [ - { name: 'tool-a' }, - ] as Collection['tools'], + tools: [{ name: 'tool-a' }] as Collection['tools'], })} variant="labeled" />, ) - expect(screen.getByTestId('card-builtin-provider')).toHaveClass('shadow-xs', 'hover:bg-components-panel-on-panel-item-bg-hover', 'hover:shadow-md') + expect(screen.getByTestId('card-builtin-provider')).toHaveClass( + 'shadow-xs', + 'hover:bg-components-panel-on-panel-item-bg-hover', + 'hover:shadow-md', + ) expect(screen.getByText('tools.author Evan')).toBeInTheDocument() expect(screen.getByText('Productivity')).toBeInTheDocument() expect(screen.getByText('Utilities')).toBeInTheDocument() diff --git a/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx b/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx index d152d8e621061d..784af405349d2c 100644 --- a/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx +++ b/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx @@ -130,11 +130,12 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) -let mockCheckedInstalledData: { plugins: { id: string, name: string }[] } | null = null +let mockCheckedInstalledData: { plugins: { id: string; name: string }[] } | null = null const mockInvalidateInstalledPluginList = vi.fn() vi.mock('@/service/use-plugins', () => ({ useCheckInstalled: ({ enabled }: { enabled: boolean }) => ({ @@ -143,27 +144,25 @@ vi.mock('@/service/use-plugins', () => ({ useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, })) -const { - mockCanSetPermissions, - mockReferenceSetting, - mockSetReferenceSettings, -} = vi.hoisted(() => ({ - mockCanSetPermissions: vi.fn(() => true), - mockReferenceSetting: vi.fn(() => ({ - permission: { - install_permission: 'everyone', - debug_permission: 'admins', - }, - auto_upgrade: { - strategy_setting: 'fix_only', - upgrade_time_of_day: 0, - upgrade_mode: 'all', - exclude_plugins: [], - include_plugins: [], - }, - })), - mockSetReferenceSettings: vi.fn(), -})) +const { mockCanSetPermissions, mockReferenceSetting, mockSetReferenceSettings } = vi.hoisted( + () => ({ + mockCanSetPermissions: vi.fn(() => true), + mockReferenceSetting: vi.fn(() => ({ + permission: { + install_permission: 'everyone', + debug_permission: 'admins', + }, + auto_upgrade: { + strategy_setting: 'fix_only', + upgrade_time_of_day: 0, + upgrade_mode: 'all', + exclude_plugins: [], + include_plugins: [], + }, + })), + mockSetReferenceSettings: vi.fn(), + }), +) vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ useCanSetPluginSettings: () => ({ @@ -197,7 +196,13 @@ vi.mock('@/app/components/header/account-setting/update-setting-dialog', () => ( })) vi.mock('@/app/components/plugins/card', () => ({ - default: ({ payload, className }: { payload: { from?: string, name: string, org?: string }, className?: string }) => ( + default: ({ + payload, + className, + }: { + payload: { from?: string; name: string; org?: string } + className?: string + }) => ( <div data-testid={`card-${payload.name}`} data-from={payload.from} @@ -213,21 +218,29 @@ vi.mock('@/app/components/tools/provider/tool-card-skeleton', () => ({ default: ({ variant }: { variant?: string }) => ( <> {Array.from({ length: 6 }, (_, index) => ( - <div key={index} data-testid="tool-card-skeleton" data-variant={variant}>Loading tool</div> + <div key={index} data-testid="tool-card-skeleton" data-variant={variant}> + Loading tool + </div> ))} </> ), })) vi.mock('@/app/components/plugins/card/card-more-info', () => ({ - default: ({ tags }: { tags: string[] }) => <div data-testid="card-more-info">{tags.join(', ')}</div>, + default: ({ tags }: { tags: string[] }) => ( + <div data-testid="card-more-info">{tags.join(', ')}</div> + ), })) vi.mock('@/app/components/tools/labels/filter', () => ({ - default: ({ value, onChange }: { value: string[], onChange: (v: string[]) => void }) => ( + default: ({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) => ( <div data-testid="label-filter"> - <button data-testid="add-filter" onClick={() => onChange(['search'])}>Add filter</button> - <button data-testid="clear-filter" onClick={() => onChange([])}>Clear filter</button> + <button data-testid="add-filter" onClick={() => onChange(['search'])}> + Add filter + </button> + <button data-testid="clear-filter" onClick={() => onChange([])}> + Clear filter + </button> <span>{value.join(', ')}</span> </div> ), @@ -235,14 +248,20 @@ vi.mock('@/app/components/tools/labels/filter', () => ({ vi.mock('@/app/components/tools/provider/custom-create-card', () => ({ default: () => <div data-testid="custom-create-card">Create Custom Tool</div>, - NewCustomToolButton: () => <button type="button" data-testid="toolbar-add-custom-tool">tools.addSwaggerAPIAsTool</button>, + NewCustomToolButton: () => ( + <button type="button" data-testid="toolbar-add-custom-tool"> + tools.addSwaggerAPIAsTool + </button> + ), })) vi.mock('@/app/components/tools/provider/detail', () => ({ - default: ({ collection, onHide }: { collection: { name: string }, onHide: () => void }) => ( + default: ({ collection, onHide }: { collection: { name: string }; onHide: () => void }) => ( <div data-testid="provider-detail"> <span>{collection.name}</span> - <button data-testid="detail-close" onClick={onHide}>Close</button> + <button data-testid="detail-close" onClick={onHide}> + Close + </button> </div> ), })) @@ -252,25 +271,32 @@ vi.mock('@/app/components/tools/provider/empty', () => ({ })) vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ - default: ({ detail, onUpdate, onHide }: { detail: unknown, onUpdate: () => void, onHide: () => void }) => - detail - ? ( - <div data-testid="plugin-detail-panel"> - <button data-testid="plugin-update" onClick={onUpdate}>Update</button> - <button data-testid="plugin-close" onClick={onHide}>Close</button> - </div> - ) - : null, + default: ({ + detail, + onUpdate, + onHide, + }: { + detail: unknown + onUpdate: () => void + onHide: () => void + }) => + detail ? ( + <div data-testid="plugin-detail-panel"> + <button data-testid="plugin-update" onClick={onUpdate}> + Update + </button> + <button data-testid="plugin-close" onClick={onHide}> + Close + </button> + </div> + ) : null, })) vi.mock('@/app/components/plugins/marketplace/empty', () => ({ default: ({ text }: { text: string }) => <div data-testid="empty">{text}</div>, })) -const { - mockHandleScroll, - mockUseMarketplace, -} = vi.hoisted(() => { +const { mockHandleScroll, mockUseMarketplace } = vi.hoisted(() => { const handleScroll = vi.fn() return { mockHandleScroll: handleScroll, @@ -285,7 +311,11 @@ const { } }) vi.mock('@/app/components/tools/marketplace', () => ({ - default: ({ showMarketplacePanel, isMarketplaceArrowVisible, contentInset }: { + default: ({ + showMarketplacePanel, + isMarketplaceArrowVisible, + contentInset, + }: { showMarketplacePanel: () => void isMarketplaceArrowVisible: boolean contentInset?: string @@ -303,8 +333,20 @@ vi.mock('@/app/components/tools/marketplace/hooks', () => ({ })) vi.mock('@/app/components/tools/mcp', () => ({ - default: ({ searchText, contentInset, showCreateCard }: { searchText: string, contentInset?: string, showCreateCard?: boolean }) => ( - <div data-testid="mcp-list" data-content-inset={contentInset} data-show-create-card={String(showCreateCard)}> + default: ({ + searchText, + contentInset, + showCreateCard, + }: { + searchText: string + contentInset?: string + showCreateCard?: boolean + }) => ( + <div + data-testid="mcp-list" + data-content-inset={contentInset} + data-show-create-card={String(showCreateCard)} + > MCP List: {searchText} </div> @@ -312,7 +354,11 @@ vi.mock('@/app/components/tools/mcp', () => ({ })) vi.mock('@/app/components/tools/mcp/create-card', () => ({ - NewMCPButton: ({ handleCreate }: { handleCreate: (provider: { id: string, name: string, type: string }) => void }) => ( + NewMCPButton: ({ + handleCreate, + }: { + handleCreate: (provider: { id: string; name: string; type: string }) => void + }) => ( <button type="button" data-testid="toolbar-add-mcp" @@ -347,7 +393,9 @@ const renderProviderList = ( <SystemFeaturesWrapper>{children}</SystemFeaturesWrapper> ) return renderWithNuqs( - <Wrapped><ProviderList category={category} contentInset={contentInset} /></Wrapped>, + <Wrapped> + <ProviderList category={category} contentInset={contentInset} /> + </Wrapped>, { searchParams }, ) } @@ -474,8 +522,14 @@ describe('ProviderList', () => { expect(toolbar).toHaveClass('px-12', 'pt-2', 'pb-0', 'bg-components-panel-bg') expect(toolbar).toHaveClass('max-w-[1600px]') expect(toolbar).not.toHaveClass('sticky') - expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass('px-12', 'gap-2', 'pt-2') - expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass('max-w-[1600px]') + expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass( + 'px-12', + 'gap-2', + 'pt-2', + ) + expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass( + 'max-w-[1600px]', + ) }) it('uses compact content inset when rendered by integrations layout', () => { @@ -493,8 +547,16 @@ describe('ProviderList', () => { it('uses a two-column grid in compact integrations pages', () => { renderProviderList(undefined, 'builtin', 'compact') - expect(screen.getByTestId('tool-provider-grid')).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-2') - expect(screen.getByTestId('tool-provider-grid')).not.toHaveClass('flex', 'flex-wrap', 'md:grid-cols-3') + expect(screen.getByTestId('tool-provider-grid')).toHaveClass( + 'grid', + 'grid-cols-1', + 'lg:grid-cols-2', + ) + expect(screen.getByTestId('tool-provider-grid')).not.toHaveClass( + 'flex', + 'flex-wrap', + 'md:grid-cols-3', + ) expect(screen.getByTestId('card-google-search').parentElement).toHaveClass('min-w-0') expect(screen.getByTestId('card-google-search').parentElement).not.toHaveClass('flex-1') }) @@ -573,14 +635,13 @@ describe('ProviderList', () => { expect(screen.getByTestId('label-filter')).toBeInTheDocument() }) - it.each([ - ['api'], - ['workflow'], - ['mcp'], - ] as const)('hides label filter for the %s tool page', (category) => { - renderProviderList({ category }) - expect(screen.queryByTestId('label-filter')).not.toBeInTheDocument() - }) + it.each([['api'], ['workflow'], ['mcp']] as const)( + 'hides label filter for the %s tool page', + (category) => { + renderProviderList({ category }) + expect(screen.queryByTestId('label-filter')).not.toBeInTheDocument() + }, + ) it('renders search input', () => { renderProviderList() @@ -603,21 +664,22 @@ describe('ProviderList', () => { expect(screen.queryByTestId('update-setting-dialog')).not.toBeInTheDocument() }) - it.each([ - ['mcp'], - ['api'], - ['workflow'], - ] as const)('hides plugin update settings on the %s tool page', (category) => { - renderProviderList({ category }) + it.each([['mcp'], ['api'], ['workflow']] as const)( + 'hides plugin update settings on the %s tool page', + (category) => { + renderProviderList({ category }) - expect(screen.queryByText('plugin.autoUpdate.autoUpdate')).not.toBeInTheDocument() - expect(screen.queryByText('plugin.autoUpdate.strategy.fixOnly.name')).not.toBeInTheDocument() - }) + expect(screen.queryByText('plugin.autoUpdate.autoUpdate')).not.toBeInTheDocument() + expect( + screen.queryByText('plugin.autoUpdate.strategy.fixOnly.name'), + ).not.toBeInTheDocument() + }, + ) }) describe('Custom Tab', () => { it('keeps custom creation in the empty card when there are no API tools', () => { - mockCollectionData = createDefaultCollections().filter(c => c.type !== 'api') + mockCollectionData = createDefaultCollections().filter((c) => c.type !== 'api') renderProviderList({ category: 'api' }) expect(screen.getByTestId('custom-create-card')).toBeInTheDocument() @@ -634,7 +696,14 @@ describe('ProviderList', () => { it('uses responsive grid columns for custom tool cards', () => { renderProviderList({ category: 'api' }) - expect(screen.getByTestId('tool-provider-grid')).toHaveClass('grid', 'grid-cols-1', 'md:grid-cols-2', 'xl:grid-cols-3', 'gap-2.5', 'pt-1') + expect(screen.getByTestId('tool-provider-grid')).toHaveClass( + 'grid', + 'grid-cols-1', + 'md:grid-cols-2', + 'xl:grid-cols-3', + 'gap-2.5', + 'pt-1', + ) expect(screen.getByTestId('tool-provider-grid')).not.toHaveClass('flex', 'flex-wrap') expect(screen.getByTestId('card-my-api').parentElement).toHaveClass('min-w-0') expect(screen.getByTestId('card-my-api').parentElement).not.toHaveClass('flex-1') @@ -662,7 +731,10 @@ describe('ProviderList', () => { mockIsLoadingToolProviders = true renderProviderList(undefined, 'api', 'compact') - expect(screen.getAllByTestId('tool-card-skeleton')[0]).toHaveAttribute('data-variant', 'integrations-labeled') + expect(screen.getAllByTestId('tool-card-skeleton')[0]).toHaveAttribute( + 'data-variant', + 'integrations-labeled', + ) }) }) @@ -675,7 +747,12 @@ describe('ProviderList', () => { it('uses a three-column responsive grid in compact integrations pages', () => { renderProviderList(undefined, 'workflow', 'compact') - expect(screen.getByTestId('tool-provider-grid')).toHaveClass('grid', 'grid-cols-1', 'sm:grid-cols-2', 'md:grid-cols-3') + expect(screen.getByTestId('tool-provider-grid')).toHaveClass( + 'grid', + 'grid-cols-1', + 'sm:grid-cols-2', + 'md:grid-cols-3', + ) expect(screen.getByTestId('tool-provider-grid')).not.toHaveClass('lg:grid-cols-2') expect(screen.getByTestId('card-wf-tool').parentElement).toHaveClass('min-w-0') }) @@ -683,7 +760,11 @@ describe('ProviderList', () => { it('does not show the built-in badge on workflow cards', () => { renderProviderList(undefined, 'workflow', 'compact') - expect(within(screen.getByTestId('card-wf-tool')).queryByText('dataset.metadata.datasetMetadata.builtIn')).not.toBeInTheDocument() + expect( + within(screen.getByTestId('card-wf-tool')).queryByText( + 'dataset.metadata.datasetMetadata.builtIn', + ), + ).not.toBeInTheDocument() }) it('shows workflow card author and label tags from collection labels', () => { @@ -697,7 +778,7 @@ describe('ProviderList', () => { }) it('shows empty state when no workflow collections exist', () => { - mockCollectionData = createDefaultCollections().filter(c => c.type !== 'workflow') + mockCollectionData = createDefaultCollections().filter((c) => c.type !== 'workflow') renderProviderList({ category: 'workflow' }) expect(screen.getByTestId('workflow-empty')).toBeInTheDocument() }) @@ -724,13 +805,16 @@ describe('ProviderList', () => { mockIsLoadingToolProviders = true renderProviderList(undefined, 'workflow', 'compact') - expect(screen.getAllByTestId('tool-card-skeleton')[0]).toHaveAttribute('data-variant', 'integrations-labeled') + expect(screen.getAllByTestId('tool-card-skeleton')[0]).toHaveAttribute( + 'data-variant', + 'integrations-labeled', + ) }) }) describe('Builtin Tab Empty State', () => { it('shows empty component when no builtin collections', () => { - mockCollectionData = createDefaultCollections().filter(c => c.type !== 'builtin') + mockCollectionData = createDefaultCollections().filter((c) => c.type !== 'builtin') renderProviderList() expect(screen.getByTestId('empty')).toBeInTheDocument() }) @@ -747,22 +831,27 @@ describe('ProviderList', () => { mockIsLoadingToolProviders = true renderProviderList(undefined, 'builtin', 'compact') - expect(screen.getAllByTestId('tool-card-skeleton')[0]).toHaveAttribute('data-variant', 'integrations-default') + expect(screen.getAllByTestId('tool-card-skeleton')[0]).toHaveAttribute( + 'data-variant', + 'integrations-default', + ) }) it('renders collection that has no labels property', () => { - mockCollectionData = [{ - id: 'no-labels', - name: 'no-label-tool', - author: 'Dify', - description: { en_US: 'Tool', zh_Hans: '工具' }, - icon: 'icon', - label: { en_US: 'No Label Tool', zh_Hans: '无标签工具' }, - type: 'builtin', - team_credentials: {}, - is_team_authorization: false, - allow_delete: false, - }] as unknown as ReturnType<typeof createDefaultCollections> + mockCollectionData = [ + { + id: 'no-labels', + name: 'no-label-tool', + author: 'Dify', + description: { en_US: 'Tool', zh_Hans: '工具' }, + icon: 'icon', + label: { en_US: 'No Label Tool', zh_Hans: '无标签工具' }, + type: 'builtin', + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + }, + ] as unknown as ReturnType<typeof createDefaultCollections> renderProviderList() expect(screen.getByTestId('card-no-label-tool')).toBeInTheDocument() }) @@ -783,27 +872,39 @@ describe('ProviderList', () => { it('shows only the built-in source label on integrations tool cards', () => { renderProviderList(undefined, 'builtin', 'compact') - expect(within(screen.getByTestId('card-google-search')).getByText('dataset.metadata.datasetMetadata.builtIn')).toBeInTheDocument() - expect(within(screen.getByTestId('card-google-search')).queryByText('plugin.from')).not.toBeInTheDocument() - expect(within(screen.getByTestId('card-plugin-tool')).queryByText('plugin.from')).not.toBeInTheDocument() - expect(within(screen.getByTestId('card-plugin-tool')).queryByText('plugin.source.marketplace')).not.toBeInTheDocument() + expect( + within(screen.getByTestId('card-google-search')).getByText( + 'dataset.metadata.datasetMetadata.builtIn', + ), + ).toBeInTheDocument() + expect( + within(screen.getByTestId('card-google-search')).queryByText('plugin.from'), + ).not.toBeInTheDocument() + expect( + within(screen.getByTestId('card-plugin-tool')).queryByText('plugin.from'), + ).not.toBeInTheDocument() + expect( + within(screen.getByTestId('card-plugin-tool')).queryByText('plugin.source.marketplace'), + ).not.toBeInTheDocument() }) it('falls back to the collection name when plugin_id has no package segment', () => { - mockCollectionData = [{ - id: 'builtin-plugin-with-short-id', - name: 'fallback-plugin-name', - author: 'Dify', - description: { en_US: 'Plugin Tool', zh_Hans: '插件工具' }, - icon: 'icon-plugin', - label: { en_US: 'Plugin Tool', zh_Hans: '插件工具' }, - type: 'builtin', - team_credentials: {}, - is_team_authorization: false, - allow_delete: false, - labels: [], - plugin_id: 'openai', - }] + mockCollectionData = [ + { + id: 'builtin-plugin-with-short-id', + name: 'fallback-plugin-name', + author: 'Dify', + description: { en_US: 'Plugin Tool', zh_Hans: '插件工具' }, + icon: 'icon-plugin', + label: { en_US: 'Plugin Tool', zh_Hans: '插件工具' }, + type: 'builtin', + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: [], + plugin_id: 'openai', + }, + ] renderProviderList() @@ -954,7 +1055,9 @@ describe('ProviderList', () => { it('delegates scroll events to marketplace handleScroll', () => { mockEnableMarketplace = true renderProviderList() - const scrollContainer = screen.getByRole('region', { name: 'common.menus.tools' }) as HTMLDivElement + const scrollContainer = screen.getByRole('region', { + name: 'common.menus.tools', + }) as HTMLDivElement fireEvent.scroll(scrollContainer) expect(mockHandleScroll).toHaveBeenCalled() }) @@ -963,7 +1066,9 @@ describe('ProviderList', () => { mockEnableMarketplace = true renderProviderList() expect(screen.getByTestId('marketplace-arrow')).toHaveTextContent('arrow-visible') - const scrollContainer = screen.getByRole('region', { name: 'common.menus.tools' }) as HTMLDivElement + const scrollContainer = screen.getByRole('region', { + name: 'common.menus.tools', + }) as HTMLDivElement fireEvent.scroll(scrollContainer) expect(screen.getByTestId('marketplace-arrow')).toHaveTextContent('arrow-hidden') }) diff --git a/web/app/components/integrations/hooks/use-integration-nav.ts b/web/app/components/integrations/hooks/use-integration-nav.ts index 0daf41f2fb43dd..1f60598dc08456 100644 --- a/web/app/components/integrations/hooks/use-integration-nav.ts +++ b/web/app/components/integrations/hooks/use-integration-nav.ts @@ -10,40 +10,45 @@ export type IntegrationHeader = { } export const getPluginCategoryBySection = (section: IntegrationSection) => { - if (section === 'builtin') - return PluginCategoryEnum.tool - if (section === 'trigger') - return PluginCategoryEnum.trigger - if (section === 'agent-strategy') - return PluginCategoryEnum.agent - if (section === 'extension') - return PluginCategoryEnum.extension + if (section === 'builtin') return PluginCategoryEnum.tool + if (section === 'trigger') return PluginCategoryEnum.trigger + if (section === 'agent-strategy') return PluginCategoryEnum.agent + if (section === 'extension') return PluginCategoryEnum.extension } export function useIntegrationNav(section: IntegrationSection) { const { t } = useTranslation() - const providerItem = useMemo<IntegrationSidebarNavItemData>(() => ({ - section: 'provider', - label: t($ => $['settings.provider'], { ns: 'common' }), - icon: 'i-ri-brain-2-line', - }), [t]) - const dataSourceItem = useMemo<IntegrationSidebarNavItemData>(() => ({ - section: 'data-source', - label: t($ => $['settings.dataSource'], { ns: 'common' }), - icon: 'i-ri-database-2-line', - iconClassName: 'size-4', - }), [t]) - const customEndpointItem = useMemo<IntegrationSidebarNavItemData>(() => ({ - section: 'custom-endpoint', - label: t($ => $['settings.customEndpoint'], { ns: 'common' }), - icon: 'i-custom-vender-integrations-api-extension', - iconClassName: 'h-[13px] w-3.5', - }), [t]) + const providerItem = useMemo<IntegrationSidebarNavItemData>( + () => ({ + section: 'provider', + label: t(($) => $['settings.provider'], { ns: 'common' }), + icon: 'i-ri-brain-2-line', + }), + [t], + ) + const dataSourceItem = useMemo<IntegrationSidebarNavItemData>( + () => ({ + section: 'data-source', + label: t(($) => $['settings.dataSource'], { ns: 'common' }), + icon: 'i-ri-database-2-line', + iconClassName: 'size-4', + }), + [t], + ) + const customEndpointItem = useMemo<IntegrationSidebarNavItemData>( + () => ({ + section: 'custom-endpoint', + label: t(($) => $['settings.customEndpoint'], { ns: 'common' }), + icon: 'i-custom-vender-integrations-api-extension', + iconClassName: 'h-[13px] w-3.5', + }), + [t], + ) const toolItems = useMemo<IntegrationSidebarNavItemData[]>(() => { const items: IntegrationSidebarNavItemData[] = [ { section: 'builtin', - label: t($ => $['toolsPage.toolPlugin'], { ns: 'common' }), + label: t(($) => $['toolsPage.toolPlugin'], { ns: 'common' }), icon: 'i-custom-vender-integrations-tools', iconClassName: 'h-[14px] w-[12.5px]', className: 'pl-8', @@ -60,14 +65,14 @@ export function useIntegrationNav(section: IntegrationSection) { }, { section: 'workflow-tool', - label: t($ => $['common.workflowAsTool'], { ns: 'workflow' }), + label: t(($) => $['common.workflowAsTool'], { ns: 'workflow' }), icon: 'i-custom-vender-integrations-workflow-as-tool', iconClassName: 'size-4', className: 'pl-8', }, { section: 'custom-tool', - label: t($ => $['settings.swaggerAPIAsTool'], { ns: 'common' }), + label: t(($) => $['settings.swaggerAPIAsTool'], { ns: 'common' }), icon: 'i-custom-vender-integrations-custom-tool', iconClassName: 'h-[14.5px] w-[12.5px]', className: 'pl-8', @@ -76,73 +81,82 @@ export function useIntegrationNav(section: IntegrationSection) { return items }, [t]) - const secondaryItems = useMemo<IntegrationSidebarNavItemData[]>(() => [ - { - section: 'trigger', - label: t($ => $['categorySingle.trigger'], { ns: 'plugin' }), - icon: 'i-custom-vender-integrations-trigger', - iconClassName: 'h-[13.5px] w-[13.5px]', - }, - { - section: 'agent-strategy', - label: t($ => $['categorySingle.agent'], { ns: 'plugin' }), - icon: 'i-custom-vender-integrations-agent-strategy', - iconClassName: 'h-[14.5px] w-[15.5px]', - }, - { - section: 'extension', - label: t($ => $['categorySingle.extension'], { ns: 'plugin' }), - icon: 'i-custom-vender-integrations-extension', - iconClassName: 'h-[13.5px] w-3', - }, - ], [t]) - const activeItem = [providerItem, dataSourceItem, ...toolItems, ...secondaryItems, customEndpointItem].find(item => item.section === section) + const secondaryItems = useMemo<IntegrationSidebarNavItemData[]>( + () => [ + { + section: 'trigger', + label: t(($) => $['categorySingle.trigger'], { ns: 'plugin' }), + icon: 'i-custom-vender-integrations-trigger', + iconClassName: 'h-[13.5px] w-[13.5px]', + }, + { + section: 'agent-strategy', + label: t(($) => $['categorySingle.agent'], { ns: 'plugin' }), + icon: 'i-custom-vender-integrations-agent-strategy', + iconClassName: 'h-[14.5px] w-[15.5px]', + }, + { + section: 'extension', + label: t(($) => $['categorySingle.extension'], { ns: 'plugin' }), + icon: 'i-custom-vender-integrations-extension', + iconClassName: 'h-[13.5px] w-3', + }, + ], + [t], + ) + const activeItem = [ + providerItem, + dataSourceItem, + ...toolItems, + ...secondaryItems, + customEndpointItem, + ].find((item) => item.section === section) const integrationHeader = useMemo<IntegrationHeader | null>(() => { switch (section) { case 'builtin': return { - title: t($ => $['toolsPage.toolPlugin'], { ns: 'common' }), - description: t($ => $['toolsPage.description'], { ns: 'common' }), + title: t(($) => $['toolsPage.toolPlugin'], { ns: 'common' }), + description: t(($) => $['toolsPage.description'], { ns: 'common' }), } case 'mcp': return { title: 'MCP', - description: t($ => $['mcpPage.description'], { ns: 'common' }), + description: t(($) => $['mcpPage.description'], { ns: 'common' }), } case 'custom-tool': return { - title: t($ => $['settings.swaggerAPIAsTool'], { ns: 'common' }), - description: t($ => $['swaggerAPIAsToolPage.description'], { ns: 'common' }), + title: t(($) => $['settings.swaggerAPIAsTool'], { ns: 'common' }), + description: t(($) => $['swaggerAPIAsToolPage.description'], { ns: 'common' }), } case 'workflow-tool': return { - title: t($ => $['common.workflowAsTool'], { ns: 'workflow' }), - description: t($ => $['workflowAsToolPage.description'], { ns: 'common' }), + title: t(($) => $['common.workflowAsTool'], { ns: 'workflow' }), + description: t(($) => $['workflowAsToolPage.description'], { ns: 'common' }), } case 'custom-endpoint': return { - title: t($ => $['settings.customEndpoint'], { ns: 'common' }), - description: t($ => $['apiBasedExtensionPage.description'], { ns: 'common' }), + title: t(($) => $['settings.customEndpoint'], { ns: 'common' }), + description: t(($) => $['apiBasedExtensionPage.description'], { ns: 'common' }), } case 'data-source': return { - title: t($ => $['settings.dataSource'], { ns: 'common' }), - description: t($ => $['dataSourcePage.description'], { ns: 'common' }), + title: t(($) => $['settings.dataSource'], { ns: 'common' }), + description: t(($) => $['dataSourcePage.description'], { ns: 'common' }), } case 'trigger': return { - title: t($ => $['categorySingle.trigger'], { ns: 'plugin' }), - description: t($ => $['triggerPage.description'], { ns: 'common' }), + title: t(($) => $['categorySingle.trigger'], { ns: 'plugin' }), + description: t(($) => $['triggerPage.description'], { ns: 'common' }), } case 'extension': return { - title: t($ => $['categorySingle.extension'], { ns: 'plugin' }), - description: t($ => $['extensionPage.description'], { ns: 'common' }), + title: t(($) => $['categorySingle.extension'], { ns: 'plugin' }), + description: t(($) => $['extensionPage.description'], { ns: 'common' }), } case 'agent-strategy': return { - title: t($ => $['categorySingle.agent'], { ns: 'plugin' }), - description: t($ => $['agentStrategyPage.description'], { ns: 'common' }), + title: t(($) => $['categorySingle.agent'], { ns: 'plugin' }), + description: t(($) => $['agentStrategyPage.description'], { ns: 'common' }), } default: return null diff --git a/web/app/components/integrations/hooks/use-integration-permissions.ts b/web/app/components/integrations/hooks/use-integration-permissions.ts index 869a5065aa1e7d..c9f9f1bf16dd78 100644 --- a/web/app/components/integrations/hooks/use-integration-permissions.ts +++ b/web/app/components/integrations/hooks/use-integration-permissions.ts @@ -4,7 +4,12 @@ import type { PermissionType } from '@/app/components/plugins/types' import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting' const isPluginCategorySection = (section: IntegrationSection) => { - return section === 'builtin' || section === 'trigger' || section === 'agent-strategy' || section === 'extension' + return ( + section === 'builtin' || + section === 'trigger' || + section === 'agent-strategy' || + section === 'extension' + ) } export function useIntegrationPermissions(section: IntegrationSection) { @@ -25,8 +30,7 @@ export function useIntegrationPermissions(section: IntegrationSection) { const showPermissionQuickPanel = canSetPermissions && !!permission const handlePermissionChange = (key: PermissionSettingKey, value: PermissionType) => { - if (!permission) - return + if (!permission) return setPluginPermissionSettings({ ...permission, diff --git a/web/app/components/integrations/hooks/use-integration-section.ts b/web/app/components/integrations/hooks/use-integration-section.ts index b2e6a63e91e7eb..b8ab5ca0ecf919 100644 --- a/web/app/components/integrations/hooks/use-integration-section.ts +++ b/web/app/components/integrations/hooks/use-integration-section.ts @@ -13,5 +13,9 @@ export function useIntegrationSection(routeSection?: IntegrationSection) { const [sectionParam] = useQueryState('section', parseAsIntegrationSection) const [categoryParam] = useQueryState('category', parseAsToolCategory) - return routeSection ?? sectionParam ?? (categoryParam ? sectionByToolCategory[categoryParam] : 'provider') + return ( + routeSection ?? + sectionParam ?? + (categoryParam ? sectionByToolCategory[categoryParam] : 'provider') + ) } diff --git a/web/app/components/integrations/hooks/use-tool-marketplace-panel.ts b/web/app/components/integrations/hooks/use-tool-marketplace-panel.ts index dcd28e5630e96c..87e45e1df21ffb 100644 --- a/web/app/components/integrations/hooks/use-tool-marketplace-panel.ts +++ b/web/app/components/integrations/hooks/use-tool-marketplace-panel.ts @@ -20,27 +20,28 @@ export function useToolMarketplacePanel({ const showMarketplacePanel = useCallback(() => { containerRef.current?.scrollTo({ - top: toolListTailRef.current - ? toolListTailRef.current.offsetTop - 80 - : 0, + top: toolListTailRef.current ? toolListTailRef.current.offsetTop - 80 : 0, behavior: 'smooth', }) }, [containerRef]) - const onContainerScroll = useCallback((e: Event) => { - handleScroll(e) - if (containerRef.current && toolListTailRef.current) - setIsMarketplaceArrowVisible(containerRef.current.scrollTop < (toolListTailRef.current.offsetTop - 80)) - }, [containerRef, handleScroll]) + const onContainerScroll = useCallback( + (e: Event) => { + handleScroll(e) + if (containerRef.current && toolListTailRef.current) + setIsMarketplaceArrowVisible( + containerRef.current.scrollTop < toolListTailRef.current.offsetTop - 80, + ) + }, + [containerRef, handleScroll], + ) useEffect(() => { const container = containerRef.current - if (container) - container.addEventListener('scroll', onContainerScroll) + if (container) container.addEventListener('scroll', onContainerScroll) return () => { - if (container) - container.removeEventListener('scroll', onContainerScroll) + if (container) container.removeEventListener('scroll', onContainerScroll) } }, [onContainerScroll]) diff --git a/web/app/components/integrations/hooks/use-tool-provider-category.ts b/web/app/components/integrations/hooks/use-tool-provider-category.ts index af3011229af627..56e9e65e485a6a 100644 --- a/web/app/components/integrations/hooks/use-tool-provider-category.ts +++ b/web/app/components/integrations/hooks/use-tool-provider-category.ts @@ -8,8 +8,8 @@ const isToolProviderCategory = (value: string): value is ToolCategory => { return toolProviderCategorySet.has(value) } -const parseAsToolProviderCategory = parseAsStringLiteral(TOOL_CATEGORY_VALUES) - .withDefault('builtin') +const parseAsToolProviderCategory = + parseAsStringLiteral(TOOL_CATEGORY_VALUES).withDefault('builtin') export function useToolProviderCategory(category?: ToolCategory) { const [categoryParam, setCategoryParam] = useQueryState('category', parseAsToolProviderCategory) @@ -17,13 +17,11 @@ export function useToolProviderCategory(category?: ToolCategory) { const isRouteCategory = !!category const handleCategoryChange = (state: string, onCategoryChanged?: () => void) => { - if (!isToolProviderCategory(state)) - return + if (!isToolProviderCategory(state)) return setCategoryParam(state) - if (state !== activeTab) - onCategoryChanged?.() + if (state !== activeTab) onCategoryChanged?.() } return { diff --git a/web/app/components/integrations/page-header.tsx b/web/app/components/integrations/page-header.tsx index 01a633adf2eae2..82325efbc5f130 100644 --- a/web/app/components/integrations/page-header.tsx +++ b/web/app/components/integrations/page-header.tsx @@ -22,29 +22,29 @@ export function IntegrationPageHeader({ const showToolbar = toolbar !== undefined && toolbar !== null return ( - <div className={cn('flex shrink-0', align === 'start' ? 'items-start' : 'min-h-14 items-center justify-between')}> - <div className={cn( - 'flex min-w-0 flex-1', - showToolbar ? 'flex-col gap-3' : 'justify-between', - align === 'start' ? 'items-start pt-3 pb-2' : 'items-center py-2', - frameClassName, + <div + className={cn( + 'flex shrink-0', + align === 'start' ? 'items-start' : 'min-h-14 items-center justify-between', )} + > + <div + className={cn( + 'flex min-w-0 flex-1', + showToolbar ? 'flex-col gap-3' : 'justify-between', + align === 'start' ? 'items-start pt-3 pb-2' : 'items-center py-2', + frameClassName, + )} > <div className="flex min-w-0 flex-col gap-0.5"> - <div className="title-2xl-semi-bold text-text-primary"> - {title} - </div> + <div className="title-2xl-semi-bold text-text-primary">{title}</div> {showDescription && ( <div className={cn(descriptionClassName ?? 'system-sm-regular', 'text-text-tertiary')}> {description} </div> )} </div> - {showToolbar && ( - <div className="flex w-full items-center justify-between"> - {toolbar} - </div> - )} + {showToolbar && <div className="flex w-full items-center justify-between">{toolbar}</div>} </div> </div> ) diff --git a/web/app/components/integrations/page.tsx b/web/app/components/integrations/page.tsx index 0c62679fe02c90..c36972e5e01a76 100644 --- a/web/app/components/integrations/page.tsx +++ b/web/app/components/integrations/page.tsx @@ -22,9 +22,7 @@ import { useIntegrationPermissions } from './hooks/use-integration-permissions' import { useIntegrationSection } from './hooks/use-integration-section' import IntegrationSectionRenderer from './section-renderer' import { IntegrationSidebarActions, IntegrationSidebarUtilityActions } from './sidebar-actions' -import { - IntegrationSidebarNavItem, -} from './sidebar-nav-item' +import { IntegrationSidebarNavItem } from './sidebar-nav-item' import { integrationSidebarInactiveNavItemClassName, integrationSidebarNavItemClassName, @@ -37,15 +35,16 @@ type IntegrationsPageProps = { } const headerDescriptionDocPaths = { - 'provider': '/use-dify/workspace/model-providers', - 'data-source': '/develop-plugin/dev-guides-and-walkthroughs/datasource-plugin#data-source-plugin-types', - 'builtin': '/use-dify/workspace/tools', + provider: '/use-dify/workspace/model-providers', + 'data-source': + '/develop-plugin/dev-guides-and-walkthroughs/datasource-plugin#data-source-plugin-types', + builtin: '/use-dify/workspace/tools', 'custom-tool': '/use-dify/workspace/tools#swagger-api', 'workflow-tool': '/use-dify/workspace/tools#workflow', - 'mcp': '/use-dify/workspace/tools#mcp', + mcp: '/use-dify/workspace/tools#mcp', 'custom-endpoint': '/develop-plugin/dev-guides-and-walkthroughs/endpoint', - 'trigger': '/develop-plugin/dev-guides-and-walkthroughs/trigger-plugin', - 'extension': '/use-dify/workspace/api-extension/api-extension', + trigger: '/develop-plugin/dev-guides-and-walkthroughs/trigger-plugin', + extension: '/use-dify/workspace/api-extension/api-extension', 'agent-strategy': '/develop-plugin/dev-guides-and-walkthroughs/agent-strategy-plugin', } satisfies Partial<Record<IntegrationSection, DocPathWithoutLang>> @@ -55,16 +54,14 @@ type DescriptionWithLearnMoreProps = { label: string } -const DescriptionWithLearnMore = ({ - children, - href, - label, -}: DescriptionWithLearnMoreProps) => { +const DescriptionWithLearnMore = ({ children, href, label }: DescriptionWithLearnMoreProps) => { const title = typeof children === 'string' ? children : undefined return ( <span className="inline-flex min-w-0 items-center gap-0.5"> - <span className="truncate" title={title}>{children}</span> + <span className="truncate" title={title}> + {children} + </span> <Link className="inline-flex shrink-0 items-center text-text-accent" href={href} @@ -88,9 +85,20 @@ function ToolsDisclosureIcon({ className }: { className?: string }) { xmlns="http://www.w3.org/2000/svg" > <path d="M8 10.0003H4V8.66693H8V10.0003Z" fill="currentColor" /> - <path fillRule="evenodd" clipRule="evenodd" d="M11.5814 2.43842L9.84375 5.3336H11.3333C11.7015 5.3336 12 5.63208 12 6.00027V13.3336C11.9998 13.7016 11.7014 14.0003 11.3333 14.0003H0.666667C0.298582 14.0003 0.000170884 13.7016 0 13.3336V6.00027C0 5.63208 0.298477 5.3336 0.666667 5.3336H8.28906L10.4382 1.75222L11.5814 2.43842ZM1.33333 12.6669H10.6667V6.66693H1.33333V12.6669Z" fill="currentColor" /> - <path d="M2.79297 1.4612C2.87822 1.2907 3.12178 1.2907 3.20703 1.4612L3.50521 2.05821C3.52758 2.10284 3.56408 2.13873 3.60872 2.16107L4.20573 2.4599C4.37584 2.54523 4.37584 2.78798 4.20573 2.87331L3.60872 3.17214C3.564 3.19452 3.52757 3.23092 3.50521 3.27566L3.20703 3.87201C3.12178 4.04251 2.87822 4.04251 2.79297 3.87201L2.49479 3.27566C2.47243 3.23092 2.436 3.19452 2.39128 3.17214L1.79427 2.87331C1.62416 2.78798 1.62416 2.54523 1.79427 2.4599L2.39128 2.16107C2.43592 2.13873 2.47242 2.10284 2.49479 2.05821L2.79297 1.4612Z" fill="currentColor" /> - <path d="M6.4082 0.159771C6.51476 -0.0532568 6.81858 -0.0532568 6.92513 0.159771L7.29818 0.905864C7.32618 0.96178 7.37176 1.0068 7.42773 1.03477L8.17318 1.40782C8.38631 1.51438 8.38631 1.81884 8.17318 1.9254L7.42773 2.29844C7.37177 2.32641 7.32618 2.37144 7.29818 2.42735L6.92513 3.17344C6.81858 3.38649 6.51475 3.38649 6.4082 3.17344L6.03516 2.42735C6.00715 2.37144 5.96157 2.32641 5.9056 2.29844L5.16016 1.9254C4.94702 1.81884 4.94702 1.51438 5.16016 1.40782L5.9056 1.03477C5.96157 1.0068 6.00715 0.96178 6.03516 0.905864L6.4082 0.159771Z" fill="currentColor" /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M11.5814 2.43842L9.84375 5.3336H11.3333C11.7015 5.3336 12 5.63208 12 6.00027V13.3336C11.9998 13.7016 11.7014 14.0003 11.3333 14.0003H0.666667C0.298582 14.0003 0.000170884 13.7016 0 13.3336V6.00027C0 5.63208 0.298477 5.3336 0.666667 5.3336H8.28906L10.4382 1.75222L11.5814 2.43842ZM1.33333 12.6669H10.6667V6.66693H1.33333V12.6669Z" + fill="currentColor" + /> + <path + d="M2.79297 1.4612C2.87822 1.2907 3.12178 1.2907 3.20703 1.4612L3.50521 2.05821C3.52758 2.10284 3.56408 2.13873 3.60872 2.16107L4.20573 2.4599C4.37584 2.54523 4.37584 2.78798 4.20573 2.87331L3.60872 3.17214C3.564 3.19452 3.52757 3.23092 3.50521 3.27566L3.20703 3.87201C3.12178 4.04251 2.87822 4.04251 2.79297 3.87201L2.49479 3.27566C2.47243 3.23092 2.436 3.19452 2.39128 3.17214L1.79427 2.87331C1.62416 2.78798 1.62416 2.54523 1.79427 2.4599L2.39128 2.16107C2.43592 2.13873 2.47242 2.10284 2.49479 2.05821L2.79297 1.4612Z" + fill="currentColor" + /> + <path + d="M6.4082 0.159771C6.51476 -0.0532568 6.81858 -0.0532568 6.92513 0.159771L7.29818 0.905864C7.32618 0.96178 7.37176 1.0068 7.42773 1.03477L8.17318 1.40782C8.38631 1.51438 8.38631 1.81884 8.17318 1.9254L7.42773 2.29844C7.37177 2.32641 7.32618 2.37144 7.29818 2.42735L6.92513 3.17344C6.81858 3.38649 6.51475 3.38649 6.4082 3.17344L6.03516 2.42735C6.00715 2.37144 5.96157 2.32641 5.9056 2.29844L5.16016 1.9254C4.94702 1.81884 4.94702 1.51438 5.16016 1.40782L5.9056 1.03477C5.96157 1.0068 6.00715 0.96178 6.03516 0.905864L6.4082 0.159771Z" + fill="currentColor" + /> </svg> ) } @@ -130,40 +138,50 @@ export default function IntegrationsPage({ } = useIntegrationNav(section) const isToolSection = Boolean(toolCategoryBySection[section]) const [isToolsExpanded, setIsToolsExpanded] = useState(isToolSection) - const useFillLayout = section === 'provider' || section === 'data-source' || section === 'custom-endpoint' || isToolSection || isPluginCategory + const useFillLayout = + section === 'provider' || + section === 'data-source' || + section === 'custom-endpoint' || + isToolSection || + isPluginCategory const scrollAreaLabel = integrationHeader?.title ?? activeItem?.label const sidebarWidthStyle = { '--integrations-sidebar-width': '200px', '--model-provider-warning-left': 'calc(240px + 200px)', - } as CSSProperties & Record<'--integrations-sidebar-width' | '--model-provider-warning-left', string> + } as CSSProperties & + Record<'--integrations-sidebar-width' | '--model-provider-warning-left', string> const pluginSettingCategory = getPluginCategoryBySection(section) - const pluginSettingAction = showPluginCategorySetting && pluginSettingCategory - ? ( - <UpdateSettingDialog - category={pluginSettingCategory} - /> - ) - : undefined + const pluginSettingAction = + showPluginCategorySetting && pluginSettingCategory ? ( + <UpdateSettingDialog category={pluginSettingCategory} /> + ) : undefined const marketplaceUrlPath = buildMarketplaceUrlPathByIntegrationSection(section) - const headerDescription = integrationHeader?.description ?? (section === 'provider' ? t($ => $['modelProvider.pageDesc'], { ns: 'common' }) : undefined) + const headerDescription = + integrationHeader?.description ?? + (section === 'provider' ? t(($) => $['modelProvider.pageDesc'], { ns: 'common' }) : undefined) const headerDescriptionDocPath = headerDescriptionDocPaths[section] - const headerDescriptionWithLink = headerDescription && headerDescriptionDocPath - ? ( - <DescriptionWithLearnMore - href={docLink(headerDescriptionDocPath)} - label={t($ => $['modelProvider.learnMore'], { ns: 'common' })} - > - {headerDescription} - </DescriptionWithLearnMore> - ) - : headerDescription + const headerDescriptionWithLink = + headerDescription && headerDescriptionDocPath ? ( + <DescriptionWithLearnMore + href={docLink(headerDescriptionDocPath)} + label={t(($) => $['modelProvider.learnMore'], { ns: 'common' })} + > + {headerDescription} + </DescriptionWithLearnMore> + ) : ( + headerDescription + ) const handleSwitchToMarketplace = () => { if (onSwitchToMarketplace) { onSwitchToMarketplace(marketplaceUrlPath) return } - window.open(getMarketplaceUrl(marketplaceUrlPath, undefined, { source: window.location.origin }), '_blank', 'noopener,noreferrer') + window.open( + getMarketplaceUrl(marketplaceUrlPath, undefined, { source: window.location.origin }), + '_blank', + 'noopener,noreferrer', + ) } const handleSelectSection = (nextSection: IntegrationSection) => { if (onSectionChange) { @@ -176,8 +194,7 @@ export default function IntegrationsPage({ const handleToggleTools = () => { const willExpand = !isToolsExpanded setIsToolsExpanded(willExpand) - if (willExpand && section !== 'builtin') - handleSelectSection('builtin') + if (willExpand && section !== 'builtin') handleSelectSection('builtin') } const toolsNavItemClassName = cn( integrationSidebarNavItemClassName, @@ -188,24 +205,30 @@ export default function IntegrationsPage({ <> <span aria-hidden className="flex size-5 shrink-0 items-center justify-center"> <ToolsDisclosureIcon className="h-3.5 w-3 group-hover:hidden" /> - {isToolsExpanded - ? <span className="i-ri-arrow-up-s-line hidden size-4 group-hover:inline-block" /> - : <span className="i-ri-arrow-down-s-line hidden size-4 group-hover:inline-block" />} + {isToolsExpanded ? ( + <span className="i-ri-arrow-up-s-line hidden size-4 group-hover:inline-block" /> + ) : ( + <span className="i-ri-arrow-down-s-line hidden size-4 group-hover:inline-block" /> + )} + </span> + <span className="min-w-0 flex-1 truncate"> + {t(($) => $['menus.tools'], { ns: 'common' })} </span> - <span className="min-w-0 flex-1 truncate">{t($ => $['menus.tools'], { ns: 'common' })}</span> </> ) return ( - <div className="flex h-full min-h-0 w-full flex-1 bg-components-panel-bg" style={sidebarWidthStyle}> - <aside className={cn( - 'flex shrink-0 flex-col border-r border-divider-burn bg-components-panel-bg px-2 py-2 transition-[width]', - 'w-50 items-end', - )} + <div + className="flex h-full min-h-0 w-full flex-1 bg-components-panel-bg" + style={sidebarWidthStyle} + > + <aside + className={cn( + 'flex shrink-0 flex-col border-r border-divider-burn bg-components-panel-bg px-2 py-2 transition-[width]', + 'w-50 items-end', + )} > - <div - className="flex min-h-0 w-46 flex-1 flex-col gap-0.5 pb-4" - > + <div className="flex min-h-0 w-46 flex-1 flex-col gap-0.5 pb-4"> <div className={cn( 'flex shrink-0 items-start pr-0 pl-2.5', @@ -214,7 +237,7 @@ export default function IntegrationsPage({ > <div className="flex h-6 min-w-0 flex-1 items-center justify-center"> <div className="min-w-0 flex-1 title-2xl-semi-bold text-text-primary"> - {t($ => $['settings.integrations'], { ns: 'common' })} + {t(($) => $['settings.integrations'], { ns: 'common' })} </div> </div> </div> @@ -226,11 +249,15 @@ export default function IntegrationsPage({ /> )} <nav className={cn('shrink-0 space-y-px', showInstallAction ? 'mt-6' : 'py-4')}> - <IntegrationSidebarNavItem item={providerItem} onSelect={onSectionChange} section={section} /> + <IntegrationSidebarNavItem + item={providerItem} + onSelect={onSectionChange} + section={section} + /> <div> <button type="button" - aria-label={t($ => $['menus.tools'], { ns: 'common' })} + aria-label={t(($) => $['menus.tools'], { ns: 'common' })} aria-expanded={isToolsExpanded} className={cn(toolsNavItemClassName, 'border-none bg-transparent')} onClick={handleToggleTools} @@ -239,7 +266,7 @@ export default function IntegrationsPage({ </button> {isToolsExpanded && ( <div className="relative space-y-px before:absolute before:top-[-1px] before:bottom-0 before:left-[17.5px] before:w-px before:bg-divider-regular"> - {toolItems.map(item => ( + {toolItems.map((item) => ( <IntegrationSidebarNavItem key={item.label} item={item} @@ -250,8 +277,12 @@ export default function IntegrationsPage({ </div> )} </div> - <IntegrationSidebarNavItem item={dataSourceItem} onSelect={onSectionChange} section={section} /> - {secondaryItems.map(item => ( + <IntegrationSidebarNavItem + item={dataSourceItem} + onSelect={onSectionChange} + section={section} + /> + {secondaryItems.map((item) => ( <IntegrationSidebarNavItem key={item.label} item={item} @@ -259,7 +290,11 @@ export default function IntegrationsPage({ section={section} /> ))} - <IntegrationSidebarNavItem item={customEndpointItem} onSelect={onSectionChange} section={section} /> + <IntegrationSidebarNavItem + item={customEndpointItem} + onSelect={onSectionChange} + section={section} + /> </nav> </div> {showUtilityActions && ( @@ -272,51 +307,49 @@ export default function IntegrationsPage({ )} </aside> <section className="flex min-w-0 flex-1 flex-col overflow-hidden"> - {useFillLayout - ? ( - <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> - <IntegrationSectionRenderer - key={section} - section={section} - title={integrationHeader?.title ?? activeItem?.label} - description={headerDescriptionWithLink} - scrollAreaLabel={scrollAreaLabel} - providerSearchText={providerSearchText} - onProviderSearchTextChange={setProviderSearchText} - onSwitchToMarketplace={handleSwitchToMarketplace} - canInstallPlugin={canInstallPlugin} - canDeletePlugin={canDeletePlugin} - isInstallPermissionLoading={isReferenceSettingLoading} - canUpdatePlugin={canUpdatePlugin} - pluginCategoryToolbarAction={pluginSettingAction} - /> - </div> - ) - : ( - <ScrollArea - className="min-h-0 flex-1 overflow-hidden" - label={scrollAreaLabel} - slotClassNames={{ - viewport: 'overscroll-contain', - content: 'min-h-full', - }} - > - <IntegrationSectionRenderer - key={section} - section={section} - title={integrationHeader?.title ?? activeItem?.label} - description={headerDescriptionWithLink} - providerSearchText={providerSearchText} - onProviderSearchTextChange={setProviderSearchText} - onSwitchToMarketplace={handleSwitchToMarketplace} - canInstallPlugin={canInstallPlugin} - canDeletePlugin={canDeletePlugin} - isInstallPermissionLoading={isReferenceSettingLoading} - canUpdatePlugin={canUpdatePlugin} - pluginCategoryToolbarAction={pluginSettingAction} - /> - </ScrollArea> - )} + {useFillLayout ? ( + <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> + <IntegrationSectionRenderer + key={section} + section={section} + title={integrationHeader?.title ?? activeItem?.label} + description={headerDescriptionWithLink} + scrollAreaLabel={scrollAreaLabel} + providerSearchText={providerSearchText} + onProviderSearchTextChange={setProviderSearchText} + onSwitchToMarketplace={handleSwitchToMarketplace} + canInstallPlugin={canInstallPlugin} + canDeletePlugin={canDeletePlugin} + isInstallPermissionLoading={isReferenceSettingLoading} + canUpdatePlugin={canUpdatePlugin} + pluginCategoryToolbarAction={pluginSettingAction} + /> + </div> + ) : ( + <ScrollArea + className="min-h-0 flex-1 overflow-hidden" + label={scrollAreaLabel} + slotClassNames={{ + viewport: 'overscroll-contain', + content: 'min-h-full', + }} + > + <IntegrationSectionRenderer + key={section} + section={section} + title={integrationHeader?.title ?? activeItem?.label} + description={headerDescriptionWithLink} + providerSearchText={providerSearchText} + onProviderSearchTextChange={setProviderSearchText} + onSwitchToMarketplace={handleSwitchToMarketplace} + canInstallPlugin={canInstallPlugin} + canDeletePlugin={canDeletePlugin} + isInstallPermissionLoading={isReferenceSettingLoading} + canUpdatePlugin={canUpdatePlugin} + pluginCategoryToolbarAction={pluginSettingAction} + /> + </ScrollArea> + )} </section> </div> ) diff --git a/web/app/components/integrations/permission-quick-panel.tsx b/web/app/components/integrations/permission-quick-panel.tsx index d1a68cf606ad3c..bca8e5603c0477 100644 --- a/web/app/components/integrations/permission-quick-panel.tsx +++ b/web/app/components/integrations/permission-quick-panel.tsx @@ -27,10 +27,7 @@ const permissionOptionCardClassName = cn( 'data-checked:border-[1.5px] data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:font-medium data-checked:text-text-primary data-checked:shadow-xs data-checked:shadow-shadow-shadow-3', ) -export function PermissionQuickPanel({ - permission, - onChange, -}: PermissionQuickPanelProps) { +export function PermissionQuickPanel({ permission, onChange }: PermissionQuickPanelProps) { const { t } = useTranslation() const rows: Array<{ key: PermissionSettingKey @@ -39,20 +36,20 @@ export function PermissionQuickPanel({ }> = [ { key: 'install_permission', - label: t($ => $['privilege.quickWhoCanInstall'], { ns: 'plugin' }), + label: t(($) => $['privilege.quickWhoCanInstall'], { ns: 'plugin' }), value: permission.install_permission || PermissionType.noOne, }, { key: 'debug_permission', - label: t($ => $['privilege.quickWhoCanDebug'], { ns: 'plugin' }), + label: t(($) => $['privilege.quickWhoCanDebug'], { ns: 'plugin' }), value: permission.debug_permission || PermissionType.noOne, }, ] return ( - <PluginSidecarPanel title={t($ => $['privilege.permissions'], { ns: 'plugin' })}> + <PluginSidecarPanel title={t(($) => $['privilege.permissions'], { ns: 'plugin' })}> <div className="flex w-full shrink-0 flex-col items-start justify-center gap-3 px-4 pt-2 pb-4"> - {rows.map(row => ( + {rows.map((row) => ( <div key={row.key} className="flex w-full shrink-0 flex-col items-start gap-1"> <div className="flex min-h-6 items-center system-sm-medium whitespace-nowrap text-text-secondary"> {row.label} @@ -60,14 +57,13 @@ export function PermissionQuickPanel({ <RadioGroup<PermissionType> value={row.value} onValueChange={(nextValue) => { - if (nextValue) - onChange(row.key, nextValue) + if (nextValue) onChange(row.key, nextValue) }} aria-label={row.label} className="w-full gap-2" > {permissionSettingOptions.map((option) => { - const optionLabel = t($ => $[`privilege.${option}`], { ns: 'plugin' }) + const optionLabel = t(($) => $[`privilege.${option}`], { ns: 'plugin' }) return ( <RadioItem<PermissionType> diff --git a/web/app/components/integrations/plugin-category-page.tsx b/web/app/components/integrations/plugin-category-page.tsx index 0056e749d44a97..ec64dcbef0f409 100644 --- a/web/app/components/integrations/plugin-category-page.tsx +++ b/web/app/components/integrations/plugin-category-page.tsx @@ -20,7 +20,7 @@ type PluginCategoryPageProps = { isInstallPermissionLoading?: boolean canUpdatePlugin?: boolean category: PluginCategoryEnum - layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode + layout?: (parts: { body: ReactNode; toolbar: ReactNode }) => ReactNode onSwitchToMarketplace?: () => void toolbarAction?: ReactNode } @@ -38,13 +38,18 @@ const PluginCategoryPageContent = ({ toolbarAction, }: PluginCategoryPageProps) => { const [currentFile, setCurrentFile] = useState<File | null>(null) - const containerRef = usePluginPageContext(v => v.containerRef) + const containerRef = usePluginPageContext((v) => v.containerRef) const { data: pluginInstallationPermission } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.plugin_installation_permission, + select: (s) => s.plugin_installation_permission, }) - const supportsDropInstall = category === PluginCategoryEnum.tool || category === PluginCategoryEnum.trigger || category === PluginCategoryEnum.agent || category === PluginCategoryEnum.extension - const canDropLocalPackage = canInstall && supportsDropInstall && !pluginInstallationPermission.restrict_to_marketplace_only + const supportsDropInstall = + category === PluginCategoryEnum.tool || + category === PluginCategoryEnum.trigger || + category === PluginCategoryEnum.agent || + category === PluginCategoryEnum.extension + const canDropLocalPackage = + canInstall && supportsDropInstall && !pluginInstallationPermission.restrict_to_marketplace_only const handleFileChange = (file: File | null) => { if (!canInstall) { @@ -52,26 +57,27 @@ const PluginCategoryPageContent = ({ return } - if (!file || !supportedLocalPackageExtensions.some(extension => file.name.endsWith(extension))) { + if ( + !file || + !supportedLocalPackageExtensions.some((extension) => file.name.endsWith(extension)) + ) { setCurrentFile(null) return } setCurrentFile(file) } - const { - dragging, - fileUploader, - fileChangeHandle, - removeFile, - } = useUploader({ + const { dragging, fileUploader, fileChangeHandle, removeFile } = useUploader({ onFileChange: handleFileChange, containerRef, enabled: canDropLocalPackage, }) return ( - <div ref={containerRef} className="relative flex h-0 grow flex-col overflow-hidden bg-components-panel-bg"> + <div + ref={containerRef} + className="relative flex h-0 grow flex-col overflow-hidden bg-components-panel-bg" + > <PluginsPanel canInstall={canInstall} canDeletePlugin={canDeletePlugin} @@ -83,10 +89,7 @@ const PluginCategoryPageContent = ({ toolbarAction={toolbarAction} /> {dragging && ( - <div - className="absolute inset-0 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent - bg-[rgba(21,90,239,0.14)] p-2" - /> + <div className="absolute inset-0 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2" /> )} {currentFile && ( <InstallFromLocalPackage @@ -123,11 +126,14 @@ const PluginCategoryPage = ({ onSwitchToMarketplace, toolbarAction, }: PluginCategoryPageProps) => { - const initialFilters = useMemo(() => ({ - categories: [category], - tags: [], - searchQuery: '', - }), [category]) + const initialFilters = useMemo( + () => ({ + categories: [category], + tags: [], + searchQuery: '', + }), + [category], + ) return ( <PluginPageContextProvider key={category} initialFilters={initialFilters}> diff --git a/web/app/components/integrations/routes.ts b/web/app/components/integrations/routes.ts index a2005494762160..0adbb41e3660d9 100644 --- a/web/app/components/integrations/routes.ts +++ b/web/app/components/integrations/routes.ts @@ -11,10 +11,10 @@ export const INTEGRATION_SECTION_VALUES = [ 'extension', ] as const -export type IntegrationSection = typeof INTEGRATION_SECTION_VALUES[number] +export type IntegrationSection = (typeof INTEGRATION_SECTION_VALUES)[number] export const TOOL_CATEGORY_VALUES = ['builtin', 'api', 'workflow', 'mcp'] as const -export type ToolCategory = typeof TOOL_CATEGORY_VALUES[number] +export type ToolCategory = (typeof TOOL_CATEGORY_VALUES)[number] export type LegacyToolsSearchParams = Record<string, string | string[] | undefined> export type IntegrationRouteSearchParams = Record<string, string | string[] | undefined> @@ -31,8 +31,7 @@ const isToolCategory = (value: string): value is ToolCategory => { } const getFirstSearchParamValue = (value: string | string[] | undefined) => { - if (Array.isArray(value)) - return value[0] + if (Array.isArray(value)) return value[0] return value } @@ -41,11 +40,10 @@ const appendSearchParams = (path: string, searchParams: IntegrationRouteSearchPa const params = new URLSearchParams() Object.entries(searchParams).forEach(([key, value]) => { - if (value === undefined) - return + if (value === undefined) return if (Array.isArray(value)) { - value.forEach(item => params.append(key, item)) + value.forEach((item) => params.append(key, item)) return } @@ -57,8 +55,8 @@ const appendSearchParams = (path: string, searchParams: IntegrationRouteSearchPa } export const toolCategoryBySection: Partial<Record<IntegrationSection, ToolCategory>> = { - 'builtin': 'builtin', - 'mcp': 'mcp', + builtin: 'builtin', + mcp: 'mcp', 'custom-tool': 'api', 'workflow-tool': 'workflow', } @@ -71,29 +69,29 @@ export const sectionByToolCategory: Record<ToolCategory, IntegrationSection> = { } export const marketplaceUrlPathByIntegrationSection: Partial<Record<IntegrationSection, string>> = { - 'provider': '/plugins/model', - 'builtin': '/plugins/tool', - 'mcp': '/plugins/tool', + provider: '/plugins/model', + builtin: '/plugins/tool', + mcp: '/plugins/tool', 'custom-tool': '/plugins/tool', 'workflow-tool': '/plugins/tool', 'data-source': '/plugins/datasource', 'custom-endpoint': '/plugins/extension', - 'trigger': '/plugins/trigger', + trigger: '/plugins/trigger', 'agent-strategy': '/plugins/agent-strategy', - 'extension': '/plugins/extension', + extension: '/plugins/extension', } export const integrationPathBySection: Record<IntegrationSection, string> = { - 'provider': '/integrations/model-provider', - 'builtin': '/integrations/tools/built-in', + provider: '/integrations/model-provider', + builtin: '/integrations/tools/built-in', 'custom-tool': '/integrations/tools/api', 'workflow-tool': '/integrations/tools/workflow', - 'mcp': '/integrations/tools/mcp', + mcp: '/integrations/tools/mcp', 'data-source': '/integrations/data-source', 'custom-endpoint': '/integrations/custom-endpoint', - 'trigger': '/integrations/trigger', + trigger: '/integrations/trigger', 'agent-strategy': '/integrations/agent-strategy', - 'extension': '/integrations/extension', + extension: '/integrations/extension', } export const buildIntegrationPath = (section: IntegrationSection) => { @@ -108,19 +106,19 @@ export const getIntegrationRedirectPathByLegacyToolsSearchParams = ( ) => { const sectionParam = getFirstSearchParamValue(searchParams.section) const categoryParam = getFirstSearchParamValue(searchParams.category) - const section = sectionParam && isIntegrationSection(sectionParam) - ? sectionParam - : categoryParam && isToolCategory(categoryParam) - ? sectionByToolCategory[categoryParam] - : 'builtin' + const section = + sectionParam && isIntegrationSection(sectionParam) + ? sectionParam + : categoryParam && isToolCategory(categoryParam) + ? sectionByToolCategory[categoryParam] + : 'builtin' const preservedSearchParams = new URLSearchParams() Object.entries(searchParams).forEach(([key, value]) => { - if (key === 'section' || key === 'category' || value === undefined) - return + if (key === 'section' || key === 'category' || value === undefined) return if (Array.isArray(value)) { - value.forEach(item => preservedSearchParams.append(key, item)) + value.forEach((item) => preservedSearchParams.append(key, item)) return } @@ -131,10 +129,10 @@ export const getIntegrationRedirectPathByLegacyToolsSearchParams = ( return query ? `${buildIntegrationPath(section)}?${query}` : buildIntegrationPath(section) } -type IntegrationRouteTarget - = | { type: 'redirect', destination: string } - | { type: 'section', section: IntegrationSection } - | { type: 'not-found' } +type IntegrationRouteTarget = + | { type: 'redirect'; destination: string } + | { type: 'section'; section: IntegrationSection } + | { type: 'not-found' } const getSectionByCanonicalPath = (path: string) => { const entry = Object.entries(integrationPathBySection).find(([, sectionPath]) => { @@ -144,7 +142,10 @@ const getSectionByCanonicalPath = (path: string) => { return entry?.[0] as IntegrationSection | undefined } -export const getIntegrationRouteTargetBySlug = (slug?: string[], searchParams?: IntegrationRouteSearchParams): IntegrationRouteTarget => { +export const getIntegrationRouteTargetBySlug = ( + slug?: string[], + searchParams?: IntegrationRouteSearchParams, +): IntegrationRouteTarget => { const path = slug?.join('/') ?? '' const nestedMarketplaceCallbackPath = path.endsWith('/plugins') ? path.slice(0, -'/plugins'.length) @@ -156,7 +157,10 @@ export const getIntegrationRouteTargetBySlug = (slug?: string[], searchParams?: if (nestedMarketplaceCallbackSection) { return { type: 'redirect', - destination: appendSearchParams(buildIntegrationPath(nestedMarketplaceCallbackSection), searchParams), + destination: appendSearchParams( + buildIntegrationPath(nestedMarketplaceCallbackSection), + searchParams, + ), } } @@ -170,7 +174,10 @@ export const getIntegrationRouteTargetBySlug = (slug?: string[], searchParams?: case 'tools/built-in': return { type: 'section', section: 'builtin' } case 'tool/api': - return { type: 'redirect', destination: appendSearchParams(buildIntegrationPath('custom-tool'), searchParams) } + return { + type: 'redirect', + destination: appendSearchParams(buildIntegrationPath('custom-tool'), searchParams), + } case 'tools/api': return { type: 'section', section: 'custom-tool' } case 'tools/workflow': diff --git a/web/app/components/integrations/section-layout.tsx b/web/app/components/integrations/section-layout.tsx index 2713a8bab48f39..6dddb8100c90e7 100644 --- a/web/app/components/integrations/section-layout.tsx +++ b/web/app/components/integrations/section-layout.tsx @@ -23,9 +23,7 @@ export function IntegrationSectionLayout({ content: 'min-h-full', }} > - <div className={bodyClassName}> - {children} - </div> + <div className={bodyClassName}>{children}</div> </ScrollArea> ) } diff --git a/web/app/components/integrations/section-renderer.tsx b/web/app/components/integrations/section-renderer.tsx index 0b83d7954e5bf7..f19a6482c43f96 100644 --- a/web/app/components/integrations/section-renderer.tsx +++ b/web/app/components/integrations/section-renderer.tsx @@ -7,7 +7,10 @@ import DataSourcePage from '@/app/components/header/account-setting/data-source- import ModelProviderPage from '@/app/components/header/account-setting/model-provider-page' import InstallFromMarketplaceQuery from '@/app/components/plugins/install-plugin/install-from-marketplace-query' import { PluginCategoryEnum } from '@/app/components/plugins/types' -import { toolsContentFrameClassNames, toolsContentInsetClassNames } from '@/app/components/tools/content-inset' +import { + toolsContentFrameClassNames, + toolsContentInsetClassNames, +} from '@/app/components/tools/content-inset' import { IntegrationPageHeader } from './page-header' import PluginCategoryPage from './plugin-category-page' import { IntegrationSectionLayout } from './section-layout' @@ -61,13 +64,13 @@ const IntegrationSectionRenderer = ({ {body} </IntegrationSectionLayout> ) - const renderScrollableLayout = ({ body, toolbar }: { body: ReactNode, toolbar: ReactNode }) => ( + const renderScrollableLayout = ({ body, toolbar }: { body: ReactNode; toolbar: ReactNode }) => ( <> {renderHeader(toolbar)} {renderScrollBody(body)} </> ) - const renderDirectLayout = ({ body, toolbar }: { body: ReactNode, toolbar: ReactNode }) => ( + const renderDirectLayout = ({ body, toolbar }: { body: ReactNode; toolbar: ReactNode }) => ( <> {renderHeader(toolbar)} {body} @@ -115,11 +118,17 @@ const IntegrationSectionRenderer = ({ case 'custom-tool': return <ToolProviderList category="api" contentInset="compact" layout={renderDirectLayout} /> case 'workflow-tool': - return <ToolProviderList category="workflow" contentInset="compact" layout={renderDirectLayout} /> + return ( + <ToolProviderList category="workflow" contentInset="compact" layout={renderDirectLayout} /> + ) case 'data-source': return ( <> - <DataSourcePage stickyToolbar layout={renderScrollableLayout} onOpenMarketplace={onSwitchToMarketplace} /> + <DataSourcePage + stickyToolbar + layout={renderScrollableLayout} + onOpenMarketplace={onSwitchToMarketplace} + /> {renderMarketplaceInstallQuery(PluginCategoryEnum.datasource)} </> ) diff --git a/web/app/components/integrations/sidebar-actions.tsx b/web/app/components/integrations/sidebar-actions.tsx index d4bcaad14f57fc..a01be88433ebf2 100644 --- a/web/app/components/integrations/sidebar-actions.tsx +++ b/web/app/components/integrations/sidebar-actions.tsx @@ -2,7 +2,11 @@ import type { ReactNode } from 'react' import type { PermissionSettingKey } from './permission-quick-panel' -import type { Permissions, PermissionType, PluginCategoryEnum } from '@/app/components/plugins/types' +import type { + Permissions, + PermissionType, + PluginCategoryEnum, +} from '@/app/components/plugins/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' @@ -36,21 +40,19 @@ function PermissionTooltipWrapper({ show, }: PermissionTooltipWrapperProps) { const trigger = ( - <span - aria-label={show ? content : undefined} - className={cn('inline-flex', className)} - > + <span aria-label={show ? content : undefined} className={cn('inline-flex', className)}> {children} </span> ) - if (!show) - return trigger + if (!show) return trigger return ( <Tooltip> <TooltipTrigger render={trigger} /> - <TooltipContent placement={placement} sideOffset={8} className={permissionTooltipClassName}>{content}</TooltipContent> + <TooltipContent placement={placement} sideOffset={8} className={permissionTooltipClassName}> + {content} + </TooltipContent> </Tooltip> ) } @@ -70,8 +72,8 @@ export function IntegrationSidebarActions({ <IntegrationSidebarInstallActions canManagement={canManagement} installContextCategory={installContextCategory} - installLabel={t($ => $.installAction, { ns: 'plugin' })} - permissionTooltip={t($ => $['privilege.noInstallPermissionTooltip'], { ns: 'plugin' })} + installLabel={t(($) => $.installAction, { ns: 'plugin' })} + permissionTooltip={t(($) => $['privilege.noInstallPermissionTooltip'], { ns: 'plugin' })} onSwitchToMarketplace={onSwitchToMarketplace} /> ) @@ -140,8 +142,8 @@ export function IntegrationSidebarUtilityActions({ onPermissionChange: (key: PermissionSettingKey, value: PermissionType) => void }) { const { t } = useTranslation() - const debugLabel = t($ => $['debugInfo.title'], { ns: 'plugin' }) - const permissionsLabel = t($ => $['privilege.permissions'], { ns: 'plugin' }) + const debugLabel = t(($) => $['debugInfo.title'], { ns: 'plugin' }) + const permissionsLabel = t(($) => $['privilege.permissions'], { ns: 'plugin' }) return ( <div className="flex w-46 shrink-0 flex-col gap-px pt-2 pb-2.5"> @@ -150,20 +152,20 @@ export function IntegrationSidebarUtilityActions({ popupPlacement="top-start" triggerVariant="ghost" triggerClassName={sidebarUtilityActionClassName} - triggerContent={( + triggerContent={ <> <span aria-hidden className="flex size-5 shrink-0 items-center justify-center"> <span className="i-ri-bug-line size-4" /> </span> <span className="min-w-0 truncate">{debugLabel}</span> </> - )} + } /> )} {showPermissionQuickPanel && permission && ( <Popover> <PopoverTrigger - render={( + render={ <Button variant="ghost" className={sidebarUtilityActionClassName} @@ -174,17 +176,14 @@ export function IntegrationSidebarUtilityActions({ </span> <span className="min-w-0 truncate">{permissionsLabel}</span> </Button> - )} + } /> <PopoverContent placement="top-start" sideOffset={4} popupClassName="border-0 bg-transparent p-0 shadow-none" > - <PermissionQuickPanel - permission={permission} - onChange={onPermissionChange} - /> + <PermissionQuickPanel permission={permission} onChange={onPermissionChange} /> </PopoverContent> </Popover> )} diff --git a/web/app/components/integrations/sidebar-nav-item-styles.ts b/web/app/components/integrations/sidebar-nav-item-styles.ts index b37f5f33f90def..c900991d53cb0c 100644 --- a/web/app/components/integrations/sidebar-nav-item-styles.ts +++ b/web/app/components/integrations/sidebar-nav-item-styles.ts @@ -1,4 +1,8 @@ -export const integrationSidebarNavItemClassName = 'flex h-8 w-full items-center gap-2 rounded-lg py-1 pr-1 pl-2 text-left system-sm-medium transition-colors' -export const integrationSidebarActiveNavItemClassName = 'bg-state-base-active text-components-menu-item-text-active' -export const integrationSidebarInactiveNavItemClassName = 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover data-popup-open:bg-state-base-hover data-popup-open:text-components-menu-item-text-hover' -export const integrationSidebarDisabledNavItemClassName = 'cursor-not-allowed text-components-menu-item-text-disabled' +export const integrationSidebarNavItemClassName = + 'flex h-8 w-full items-center gap-2 rounded-lg py-1 pr-1 pl-2 text-left system-sm-medium transition-colors' +export const integrationSidebarActiveNavItemClassName = + 'bg-state-base-active text-components-menu-item-text-active' +export const integrationSidebarInactiveNavItemClassName = + 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover data-popup-open:bg-state-base-hover data-popup-open:text-components-menu-item-text-hover' +export const integrationSidebarDisabledNavItemClassName = + 'cursor-not-allowed text-components-menu-item-text-disabled' diff --git a/web/app/components/integrations/sidebar-nav-item.tsx b/web/app/components/integrations/sidebar-nav-item.tsx index 39f94aad62d8e0..d18c68dae5eeca 100644 --- a/web/app/components/integrations/sidebar-nav-item.tsx +++ b/web/app/components/integrations/sidebar-nav-item.tsx @@ -25,8 +25,7 @@ export type IntegrationSidebarNavItemData = { } const renderIcon = (icon: IconComponent | string, className = 'size-4') => { - if (typeof icon === 'string') - return <span className={cn(className, icon)} /> + if (typeof icon === 'string') return <span className={cn(className, icon)} /> const Icon = icon return <Icon className={className} /> @@ -48,7 +47,9 @@ export function IntegrationSidebarNavItem({ const className = cn( integrationSidebarNavItemClassName, - isActive ? integrationSidebarActiveNavItemClassName : integrationSidebarInactiveNavItemClassName, + isActive + ? integrationSidebarActiveNavItemClassName + : integrationSidebarInactiveNavItemClassName, item.className, ) @@ -66,7 +67,9 @@ export function IntegrationSidebarNavItem({ <span aria-hidden className="flex size-5 shrink-0 items-center justify-center"> {renderIcon(item.icon, item.iconClassName)} </span> - <span className="min-w-0 truncate" title={item.label}>{item.label}</span> + <span className="min-w-0 truncate" title={item.label}> + {item.label} + </span> </div> ) } @@ -76,7 +79,9 @@ export function IntegrationSidebarNavItem({ <span aria-hidden className="flex size-5 shrink-0 items-center justify-center"> {renderIcon(icon, item.iconClassName)} </span> - <span className="min-w-0 truncate" title={item.label}>{item.label}</span> + <span className="min-w-0 truncate" title={item.label}> + {item.label} + </span> </> ) diff --git a/web/app/components/integrations/tool-provider-card.tsx b/web/app/components/integrations/tool-provider-card.tsx index 6002c45a9ac6d5..f793eb7144979f 100644 --- a/web/app/components/integrations/tool-provider-card.tsx +++ b/web/app/components/integrations/tool-provider-card.tsx @@ -45,7 +45,7 @@ function IntegrationsToolProviderCard({ const description = renderI18nObject(collection.description, language) const { org, name } = getCollectionPluginIdentity(collection) const toolsCount = collection.tools?.length ?? 0 - const builtInLabel = t($ => $['metadata.datasetMetadata.builtIn'], { ns: 'dataset' }) + const builtInLabel = t(($) => $['metadata.datasetMetadata.builtIn'], { ns: 'dataset' }) const shouldShowBuiltInBadge = showBuiltInBadge && !collection.plugin_id const isLabeledVariant = variant === 'labeled' @@ -57,7 +57,8 @@ function IntegrationsToolProviderCard({ data-org={collection.plugin_id ? org : ''} className={cn( 'group/tool-provider relative flex min-w-0 cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-md', - current && 'after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:inset-ring-[1.5px] after:inset-ring-components-option-card-option-selected-border after:content-[\'\']', + current && + "after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:inset-ring-[1.5px] after:inset-ring-components-option-card-option-selected-border after:content-['']", )} > <div className="flex w-full shrink-0 items-center gap-3 px-4 pt-4 pb-2"> @@ -66,22 +67,34 @@ function IntegrationsToolProviderCard({ <div className="min-w-0 truncate system-md-semibold text-text-secondary" title={title}> {title} </div> - <div className="h-4 truncate system-xs-regular text-text-tertiary" title={collection.author ? `${t($ => $.author, { ns: 'tools' })} ${collection.author}` : undefined}> - {collection.author && `${t($ => $.author, { ns: 'tools' })} ${collection.author}`} + <div + className="h-4 truncate system-xs-regular text-text-tertiary" + title={ + collection.author + ? `${t(($) => $.author, { ns: 'tools' })} ${collection.author}` + : undefined + } + > + {collection.author && `${t(($) => $.author, { ns: 'tools' })} ${collection.author}`} </div> </div> </div> <div className="w-full px-4 pt-1 pb-2"> - <div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary" title={description}> + <div + className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary" + title={description} + > {description} </div> </div> <div className="flex h-6 w-full shrink-0 items-center px-4 py-1"> <div className="flex h-4 min-w-0 flex-1 flex-wrap items-start gap-x-2 gap-y-1 overflow-hidden system-xs-regular whitespace-nowrap"> - {collection.labels?.map(label => ( + {collection.labels?.map((label) => ( <div key={label} className="flex max-w-[120px] shrink-0 items-center gap-0.5"> <span className="text-text-quaternary">#</span> - <span className="min-w-0 truncate text-text-tertiary" title={label}>{label}</span> + <span className="min-w-0 truncate text-text-tertiary" title={label}> + {label} + </span> </div> ))} </div> @@ -97,7 +110,8 @@ function IntegrationsToolProviderCard({ data-org={collection.plugin_id ? org : ''} className={cn( 'group/tool-provider relative flex min-w-[min(100%,496px)] flex-1 cursor-pointer flex-col overflow-hidden rounded-xl bg-background-section-burn p-[3px]', - current && 'after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:inset-ring-[1.5px] after:inset-ring-components-option-card-option-selected-border after:content-[\'\']', + current && + "after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:inset-ring-[1.5px] after:inset-ring-components-option-card-option-selected-border after:content-['']", )} > <div className="relative flex w-full items-center gap-3 overflow-hidden rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-3 group-hover/tool-provider:bg-components-panel-on-panel-item-bg-hover"> @@ -117,11 +131,15 @@ function IntegrationsToolProviderCard({ <div className="flex h-4 min-w-0 shrink-0 items-center gap-0.5 system-xs-regular"> {!!org && ( <> - <div className="truncate text-text-tertiary" title={org}>{org}</div> + <div className="truncate text-text-tertiary" title={org}> + {org} + </div> <div className="text-text-quaternary">/</div> </> )} - <div className="truncate text-text-tertiary" title={name}>{name}</div> + <div className="truncate text-text-tertiary" title={name}> + {name} + </div> </div> {toolsCount > 0 && ( <> @@ -129,7 +147,7 @@ function IntegrationsToolProviderCard({ <div className="flex min-w-0 flex-1 items-center gap-1"> <RiLoginCircleLine className="size-3 shrink-0 text-text-tertiary" /> <div className="truncate system-xs-regular text-text-tertiary"> - {t($ => $['mcp.toolsCount'], { ns: 'tools', count: toolsCount })} + {t(($) => $['mcp.toolsCount'], { ns: 'tools', count: toolsCount })} </div> </div> </> diff --git a/web/app/components/integrations/tool-provider-create-action.tsx b/web/app/components/integrations/tool-provider-create-action.tsx index 3b06b006f39dc3..c1b9554cf64b6d 100644 --- a/web/app/components/integrations/tool-provider-create-action.tsx +++ b/web/app/components/integrations/tool-provider-create-action.tsx @@ -21,7 +21,11 @@ const ToolProviderCreateAction = ({ onMCPProviderCreated, }: ToolProviderCreateActionProps) => { if (activeTab === 'mcp' && hasCategoryCollections) - return <NewMCPButton handleCreate={(provider: ToolWithProvider) => onMCPProviderCreated(provider.id)} /> + return ( + <NewMCPButton + handleCreate={(provider: ToolWithProvider) => onMCPProviderCreated(provider.id)} + /> + ) if (activeTab === 'api' && !isCollectionListLoading && hasCategoryCollections) return <NewCustomToolButton onRefreshData={onCustomToolCreated} /> diff --git a/web/app/components/integrations/tool-provider-list.tsx b/web/app/components/integrations/tool-provider-list.tsx index a11aba77429517..e101cfef252d4d 100644 --- a/web/app/components/integrations/tool-provider-list.tsx +++ b/web/app/components/integrations/tool-provider-list.tsx @@ -19,8 +19,14 @@ import { useTags } from '@/app/components/plugins/hooks' import Empty from '@/app/components/plugins/marketplace/empty' import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel' import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting' -import { toolsContentInsetClassNames, toolsUnifiedContentFrameClassName } from '@/app/components/tools/content-inset' -import { useCanManageMCP, useCanManageTools } from '@/app/components/tools/hooks/use-tool-permissions' +import { + toolsContentInsetClassNames, + toolsUnifiedContentFrameClassName, +} from '@/app/components/tools/content-inset' +import { + useCanManageMCP, + useCanManageTools, +} from '@/app/components/tools/hooks/use-tool-permissions' import Marketplace from '@/app/components/tools/marketplace' import MCPList from '@/app/components/tools/mcp' import ProviderDetail from '@/app/components/tools/provider/detail' @@ -36,7 +42,7 @@ import { ToolProviderToolbar } from './tool-provider-toolbar' type ProviderListProps = { category?: ToolCategory contentInset?: ToolsContentInset - layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode + layout?: (parts: { body: ReactNode; toolbar: ReactNode }) => ReactNode } type BuiltinMarketplacePanelProps = { @@ -52,16 +58,12 @@ const BuiltinMarketplacePanel = ({ keywords, tagFilterValue, }: BuiltinMarketplacePanelProps) => { - const { - isMarketplaceArrowVisible, - marketplaceContext, - showMarketplacePanel, - toolListTailRef, - } = useToolMarketplacePanel({ - containerRef, - keywords, - tagFilterValue, - }) + const { isMarketplaceArrowVisible, marketplaceContext, showMarketplacePanel, toolListTailRef } = + useToolMarketplacePanel({ + containerRef, + keywords, + tagFilterValue, + }) return ( <> @@ -78,25 +80,17 @@ const BuiltinMarketplacePanel = ({ ) } -const ProviderList = ({ - category, - contentInset = 'default', - layout, -}: ProviderListProps) => { +const ProviderList = ({ category, contentInset = 'default', layout }: ProviderListProps) => { // const searchParams = useSearchParams() // searchParams.get('category') === 'workflow' const { t } = useTranslation() const { getTagLabel } = useTags() - const { - canDeletePlugin, - canSetPluginPreferences, - canUpdatePlugin, - } = usePluginSettingsAccess() + const { canDeletePlugin, canSetPluginPreferences, canUpdatePlugin } = usePluginSettingsAccess() const canManageTools = useCanManageTools() const canManageMCP = useCanManageMCP() const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const { activeTab, handleCategoryChange, isRouteCategory } = useToolProviderCategory(category) const contentPaddingClassName = toolsContentInsetClassNames[contentInset] @@ -104,9 +98,9 @@ const ProviderList = ({ const showToolsUpdateSetting = activeTab === 'builtin' && canSetPluginPreferences const showLabelFilter = activeTab === 'builtin' const options = [ - { value: 'builtin', text: t($ => $['type.builtIn'], { ns: 'tools' }) }, - { value: 'api', text: t($ => $['type.custom'], { ns: 'tools' }) }, - { value: 'workflow', text: t($ => $['type.workflow'], { ns: 'tools' }) }, + { value: 'builtin', text: t(($) => $['type.builtIn'], { ns: 'tools' }) }, + { value: 'api', text: t(($) => $['type.custom'], { ns: 'tools' }) }, + { value: 'workflow', text: t(($) => $['type.workflow'], { ns: 'tools' }) }, { value: 'mcp', text: 'MCP' }, ] const [tagFilterValue, setTagFilterValue] = useState<string[]>([]) @@ -124,22 +118,33 @@ const ProviderList = ({ const handleCreatedMCPProviderHandled = useCallback(() => { setCreatedMCPProviderId(undefined) }, []) - const { data: collectionList = [], isLoading: isCollectionListLoading, refetch } = useAllToolProviders() + const { + data: collectionList = [], + isLoading: isCollectionListLoading, + refetch, + } = useAllToolProviders() const activeTabCollectionList = useMemo(() => { - return collectionList.filter(collection => collection.type === activeTab) + return collectionList.filter((collection) => collection.type === activeTab) }, [activeTab, collectionList]) const hasCategoryCollections = activeTabCollectionList.length > 0 - const shouldShowCustomToolCreateCard = canManageTools && !(activeTab === 'api' && !isCollectionListLoading && hasCategoryCollections) + const shouldShowCustomToolCreateCard = + canManageTools && !(activeTab === 'api' && !isCollectionListLoading && hasCategoryCollections) const shouldShowMCPCreateCard = canManageMCP && !(activeTab === 'mcp' && hasCategoryCollections) - const shouldShowToolbarCreateAction - = (activeTab === 'mcp' && canManageMCP && hasCategoryCollections) - || (activeTab === 'api' && canManageTools && !isCollectionListLoading && hasCategoryCollections) + const shouldShowToolbarCreateAction = + (activeTab === 'mcp' && canManageMCP && hasCategoryCollections) || + (activeTab === 'api' && canManageTools && !isCollectionListLoading && hasCategoryCollections) const filteredCollectionList = useMemo(() => { return activeTabCollectionList.filter((collection) => { - if (showLabelFilter && tagFilterValue.length > 0 && (!collection.labels || collection.labels.every(label => !tagFilterValue.includes(label)))) + if ( + showLabelFilter && + tagFilterValue.length > 0 && + (!collection.labels || collection.labels.every((label) => !tagFilterValue.includes(label))) + ) return false if (keywords) - return Object.values(collection.label).some(value => value.toLowerCase().includes(keywords.toLowerCase())) + return Object.values(collection.label).some((value) => + value.toLowerCase().includes(keywords.toLowerCase()), + ) return true }) }, [activeTabCollectionList, showLabelFilter, tagFilterValue, keywords]) @@ -153,7 +158,7 @@ const ProviderList = ({ const [currentProviderId, setCurrentProviderId] = useState<string | undefined>() const currentProvider = useMemo<Collection | undefined>(() => { - return filteredCollectionList.find(collection => collection.id === currentProviderId) + return filteredCollectionList.find((collection) => collection.id === currentProviderId) }, [currentProviderId, filteredCollectionList]) const { data: checkedInstalledData } = useCheckInstalled({ pluginIds: currentProvider?.plugin_id ? [currentProvider.plugin_id] : [], @@ -176,18 +181,20 @@ const ProviderList = ({ showLabelFilter={showLabelFilter} showToolsUpdateSetting={showToolsUpdateSetting} tagFilterValue={tagFilterValue} - toolbarAction={shouldShowToolbarCreateAction - ? ( - <ToolProviderCreateAction - activeTab={activeTab} - hasCategoryCollections={hasCategoryCollections} - isCollectionListLoading={isCollectionListLoading} - onCustomToolCreated={refetch} - onMCPProviderCreated={handleMCPProviderCreated} - /> - ) - : undefined} - onCategoryChange={state => handleCategoryChange(state, () => setCurrentProviderId(undefined))} + toolbarAction={ + shouldShowToolbarCreateAction ? ( + <ToolProviderCreateAction + activeTab={activeTab} + hasCategoryCollections={hasCategoryCollections} + isCollectionListLoading={isCollectionListLoading} + onCustomToolCreated={refetch} + onMCPProviderCreated={handleMCPProviderCreated} + /> + ) : undefined + } + onCategoryChange={(state) => + handleCategoryChange(state, () => setCurrentProviderId(undefined)) + } onKeywordsChange={handleKeywordsChange} onTagsChange={handleTagsChange} /> @@ -199,7 +206,7 @@ const ProviderList = ({ <ScrollAreaRoot className="relative min-h-0 grow overflow-hidden bg-components-panel-bg"> <ScrollAreaViewport ref={containerRef} - aria-label={t($ => $['menus.tools'], { ns: 'common' })} + aria-label={t(($) => $['menus.tools'], { ns: 'common' })} className="overscroll-contain" role="region" > @@ -220,9 +227,15 @@ const ProviderList = ({ onSelectProvider={setCurrentProviderId} /> )} - {!isCollectionListLoading && !activeTabCollectionList.length && activeTab === 'builtin' && ( - <Empty lightCard text={t($ => $.noTools, { ns: 'tools' })} className={cn('h-[224px] shrink-0', toolListFrameClassName)} /> - )} + {!isCollectionListLoading && + !activeTabCollectionList.length && + activeTab === 'builtin' && ( + <Empty + lightCard + text={t(($) => $.noTools, { ns: 'tools' })} + className={cn('h-[224px] shrink-0', toolListFrameClassName)} + /> + )} {isCollectionSearchEmpty && activeTab === 'builtin' && ( <div className={cn('h-[224px] shrink-0', toolListFrameClassName)} /> )} @@ -267,8 +280,7 @@ const ProviderList = ({ </> ) - if (layout) - return layout({ body, toolbar }) + if (layout) return layout({ body, toolbar }) return ( <> diff --git a/web/app/components/integrations/tool-provider-toolbar.tsx b/web/app/components/integrations/tool-provider-toolbar.tsx index b2245554f2cc91..e6671bd49af707 100644 --- a/web/app/components/integrations/tool-provider-toolbar.tsx +++ b/web/app/components/integrations/tool-provider-toolbar.tsx @@ -52,28 +52,16 @@ export function ToolProviderToolbar({ )} > {!isRouteCategory && ( - <TabSliderNew - value={activeTab} - onChange={onCategoryChange} - options={options} - /> + <TabSliderNew value={activeTab} onChange={onCategoryChange} options={options} /> )} <div className="flex min-w-[200px] flex-1 items-center justify-between gap-2"> <div className="flex min-w-0 items-center gap-2"> - {showLabelFilter && ( - <LabelFilter value={tagFilterValue} onChange={onTagsChange} /> - )} - <SearchInput - className="w-[200px]" - value={keywords} - onValueChange={onKeywordsChange} - /> + {showLabelFilter && <LabelFilter value={tagFilterValue} onChange={onTagsChange} />} + <SearchInput className="w-[200px]" value={keywords} onValueChange={onKeywordsChange} /> </div> {toolbarAction} {!toolbarAction && showToolsUpdateSetting && ( - <UpdateSettingDialog - category={PluginCategoryEnum.tool} - /> + <UpdateSettingDialog category={PluginCategoryEnum.tool} /> )} </div> </div> diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 9fec2b83cc4de8..fed291a03553e8 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -7,7 +7,10 @@ import type { ICurrentWorkspace, IWorkspace } from '@/models/common' import type { InstalledApp } from '@/models/explore' import { fireEvent, screen, waitFor } from '@testing-library/react' import { createStore, Provider as JotaiProvider } from 'jotai' -import { createTestQueryClient, renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import { + createTestQueryClient, + renderWithSystemFeatures, +} from '@/__tests__/utils/mock-system-features' import { Plan } from '@/app/components/billing/type' import { DETAIL_SIDEBAR_STORAGE_KEY } from '@/app/components/detail-sidebar/storage' import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage' @@ -59,7 +62,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -247,7 +251,10 @@ const appContextValue: AppContextStateMockState = { workspacePermissionKeys: ownerWorkspacePermissionKeys, } -type MainNavSystemFeatures = Exclude<NonNullable<Parameters<typeof renderWithSystemFeatures>[1]>['systemFeatures'], null | undefined> +type MainNavSystemFeatures = Exclude< + NonNullable<Parameters<typeof renderWithSystemFeatures>[1]>['systemFeatures'], + null | undefined +> const defaultMainNavSystemFeatures: MainNavSystemFeatures = { branding: { enabled: false }, @@ -256,12 +263,15 @@ const defaultMainNavSystemFeatures: MainNavSystemFeatures = { const renderMainNav = ( systemFeatures: MainNavSystemFeatures = defaultMainNavSystemFeatures, - options: { store?: ReturnType<typeof createStore>, extra?: ReactNode } = {}, + options: { store?: ReturnType<typeof createStore>; extra?: ReactNode } = {}, ) => { const queryClient = createTestQueryClient() const currentAppContext = mockAppContextState.current ?? appContextValue mockAppContextState.current = currentAppContext - queryClient.setQueryData(consoleQuery.workspaces.current.post.queryKey(), currentAppContext.currentWorkspace as ICurrentWorkspace) + queryClient.setQueryData( + consoleQuery.workspaces.current.post.queryKey(), + currentAppContext.currentWorkspace as ICurrentWorkspace, + ) queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces }) const resolvedSystemFeatures = { ...defaultMainNavSystemFeatures, @@ -293,8 +303,22 @@ describe('MainNav', () => { mockInstalledApps = [] mockInstalledAppsPending = false mockWorkspaces = [ - { id: 'workspace-1', name: 'Solar Studio', plan: Plan.team, status: 'normal', created_at: 0, current: true }, - { id: 'workspace-2', name: 'Evan Workspace', plan: Plan.sandbox, status: 'normal', created_at: 0, current: false }, + { + id: 'workspace-1', + name: 'Solar Studio', + plan: Plan.team, + status: 'normal', + created_at: 0, + current: true, + }, + { + id: 'workspace-2', + name: 'Evan Workspace', + plan: Plan.sandbox, + status: 'normal', + created_at: 0, + current: false, + }, ] mockIsAgentV2Enabled.mockReturnValue(true) @@ -337,14 +361,25 @@ describe('MainNav', () => { renderMainNav() expect(screen.getAllByText(Plan.team)).toHaveLength(1) - expect(screen.getByRole('button', { name: 'common.account.account' })).not.toHaveTextContent(Plan.team) + expect(screen.getByRole('button', { name: 'common.account.account' })).not.toHaveTextContent( + Plan.team, + ) expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/') expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps') expect(screen.getByRole('link', { name: /Agents/ })).toHaveAttribute('href', '/agents') expect(screen.getByRole('link', { name: /Agents common.menus.status/ })).toBeInTheDocument() - expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets') - expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/integrations/model-provider') - expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/marketplace') + expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute( + 'href', + '/datasets', + ) + expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute( + 'href', + '/integrations/model-provider', + ) + expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute( + 'href', + '/marketplace', + ) }) it('hides the roster entry when Agent v2 is disabled', () => { @@ -358,7 +393,9 @@ describe('MainNav', () => { it('hides the marketplace entry when marketplace is disabled', () => { renderMainNav({ enable_marketplace: false }) - expect(screen.queryByRole('link', { name: /common.mainNav.marketplace/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: /common.mainNav.marketplace/ }), + ).not.toBeInTheDocument() }) it('renders deployments in primary navigation when app deploy is enabled', () => { @@ -368,7 +405,9 @@ describe('MainNav', () => { const deploymentsLink = screen.getByRole('link', { name: /common.menus.deployments/ }) expect(deploymentsLink).toHaveAttribute('href', '/deployments') - expect(marketplaceLink.compareDocumentPosition(deploymentsLink)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(marketplaceLink.compareDocumentPosition(deploymentsLink)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ) }) it('hides deployments in primary navigation when app deploy is disabled', () => { @@ -476,11 +515,22 @@ describe('MainNav', () => { expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/') expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps') expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument() - expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets') - expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/integrations/model-provider') - expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/marketplace') + expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute( + 'href', + '/datasets', + ) + expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute( + 'href', + '/integrations/model-provider', + ) + expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute( + 'href', + '/marketplace', + ) expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'explore.sidebar.webApps' }), + ).not.toBeInTheDocument() }) it('keeps unrestricted main routes visible without route permission keys', () => { @@ -516,7 +566,9 @@ describe('MainNav', () => { const datasetsLink = screen.getByRole('link', { name: /common.menus.datasets/ }) expect(datasetsLink).toHaveClass(activeGradientMaskClassName) expect(datasetsLink).toHaveAttribute('aria-current', 'page') - expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current') + expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute( + 'aria-current', + ) }) it('keeps Studio active on snippets routes', () => { @@ -527,7 +579,9 @@ describe('MainNav', () => { const studioLink = screen.getByRole('link', { name: /common.menus.apps/ }) expect(studioLink).toHaveClass(activeGradientMaskClassName) expect(studioLink).toHaveAttribute('aria-current', 'page') - expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current') + expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute( + 'aria-current', + ) }) it('keeps roster detail navigation hidden when Agent v2 is disabled', () => { @@ -541,19 +595,24 @@ describe('MainNav', () => { expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument() }) - it.each([ - '/deployments', - '/deployments/create', - ])('keeps global navigation on deployment collection route %s', (pathname) => { - mockPathname = pathname - - renderMainNav({ branding: { enabled: false }, enable_app_deploy: true }) - - expect(screen.queryByTestId('deployment-detail-top')).not.toBeInTheDocument() - expect(screen.queryByTestId('deployment-detail-section')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: /common.menus.deployments/ })).toHaveAttribute('href', '/deployments') - }) + it.each(['/deployments', '/deployments/create'])( + 'keeps global navigation on deployment collection route %s', + (pathname) => { + mockPathname = pathname + + renderMainNav({ branding: { enabled: false }, enable_app_deploy: true }) + + expect(screen.queryByTestId('deployment-detail-top')).not.toBeInTheDocument() + expect(screen.queryByTestId('deployment-detail-section')).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }), + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: /common.menus.deployments/ })).toHaveAttribute( + 'href', + '/deployments', + ) + }, + ) it.each([ '/datasets/create', @@ -568,8 +627,13 @@ describe('MainNav', () => { expect(screen.queryByTestId('dataset-detail-top')).not.toBeInTheDocument() expect(screen.queryByTestId('dataset-detail-section')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets') + expect( + screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }), + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute( + 'href', + '/datasets', + ) }) it('marks marketplace active on marketplace routes', () => { @@ -611,9 +675,15 @@ describe('MainNav', () => { expect(homeLink).toHaveAttribute('aria-current', 'page') mockPathname = '/installed/installed-1' - rerender(<JotaiProvider><MainNav /></JotaiProvider>) - - expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current') + rerender( + <JotaiProvider> + <MainNav /> + </JotaiProvider>, + ) + + expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute( + 'aria-current', + ) }) it('opens goto anything from the search button', () => { @@ -632,7 +702,9 @@ describe('MainNav', () => { renderMainNav({ enable_learn_app: true }) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) - const learnDifyItem = await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.learnDify' }) + const learnDifyItem = await screen.findByRole('menuitemcheckbox', { + name: 'common.mainNav.help.learnDify', + }) expect(learnDifyItem).toHaveAttribute('aria-checked', 'false') fireEvent.click(learnDifyItem) @@ -649,7 +721,9 @@ describe('MainNav', () => { fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) await screen.findByText('common.mainNav.help.docs') - expect(screen.queryByRole('menuitemcheckbox', { name: 'common.mainNav.help.learnDify' })).not.toBeInTheDocument() + expect( + screen.queryByRole('menuitemcheckbox', { name: 'common.mainNav.help.learnDify' }), + ).not.toBeInTheDocument() }) it('orders help menu items to match the nav shell design', async () => { @@ -667,7 +741,7 @@ describe('MainNav', () => { 'common.userProfile.github', 'common.userProfile.about', ] - const nodes = await Promise.all(labels.map(label => screen.findByText(label))) + const nodes = await Promise.all(labels.map((label) => screen.findByText(label))) nodes.slice(1).forEach((node, index) => { expect(nodes[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) @@ -681,7 +755,9 @@ describe('MainNav', () => { const contactUsItem = await screen.findByRole('menuitem', { name: 'common.userProfile.contactUs billing.upgradeBtn.encourageShort', }) - expect(screen.queryByRole('button', { name: 'billing.upgradeBtn.encourageShort' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'billing.upgradeBtn.encourageShort' }), + ).not.toBeInTheDocument() fireEvent.click(contactUsItem) @@ -694,25 +770,35 @@ describe('MainNav', () => { it('hides the help menu when branding is enabled', () => { renderMainNav({ branding: { enabled: true } }) - expect(screen.queryByRole('button', { name: 'common.mainNav.help.openMenu' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.mainNav.help.openMenu' }), + ).not.toBeInTheDocument() }) it('opens workspace settings, members, plan, and workspace switching actions', async () => { renderMainNav() - expect(screen.getByRole('link', { name: /common\.mainNav\.workspace\.credits|7,500 credits/ })).toHaveAttribute('href', '/integrations/model-provider') - expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) + expect( + screen.getByRole('link', { name: /common\.mainNav\.workspace\.credits|7,500 credits/ }), + ).toHaveAttribute('href', '/integrations/model-provider') + expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.PROVIDER, + }) fireEvent.click(screen.getByText('billing.upgradeBtn.plain')) expect(mockSetShowPricingModal).toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) fireEvent.click(await screen.findByText('common.mainNav.workspace.settings')) - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.BILLING }) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.BILLING, + }) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) fireEvent.click(await screen.findByText('common.mainNav.workspace.inviteMembers')) - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.MEMBERS }) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.MEMBERS, + }) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) fireEvent.click(await screen.findByText('Evan Workspace')) @@ -723,7 +809,14 @@ describe('MainNav', () => { it('shows the upgrade shortcut for sandbox workspaces', () => { mockWorkspaces = [ - { id: 'workspace-1', name: 'Solar Studio', plan: Plan.sandbox, status: 'normal', created_at: 0, current: true }, + { + id: 'workspace-1', + name: 'Solar Studio', + plan: Plan.sandbox, + status: 'normal', + created_at: 0, + current: true, + }, ] renderMainNav() @@ -746,7 +839,9 @@ describe('MainNav', () => { expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() fireEvent.click(screen.getByText('billing.upgradeBtn.plain')) expect(mockSetShowPricingModal).toHaveBeenCalled() - expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.BILLING }) + expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.BILLING, + }) }) it('limits invite members by member management permission', async () => { @@ -758,7 +853,9 @@ describe('MainNav', () => { }, isCurrentWorkspaceManager: false, isCurrentWorkspaceOwner: false, - workspacePermissionKeys: ownerWorkspacePermissionKeys.filter(key => key !== 'workspace.member.manage'), + workspacePermissionKeys: ownerWorkspacePermissionKeys.filter( + (key) => key !== 'workspace.member.manage', + ), } renderMainNav() @@ -793,8 +890,14 @@ describe('MainNav', () => { it('filters installed web apps and renders installed app navigation link', () => { mockInstalledApps = [ - createInstalledApp({ id: 'installed-1', app: { ...createInstalledApp().app, name: 'Alpha App' } }), - createInstalledApp({ id: 'installed-2', app: { ...createInstalledApp().app, name: 'Beta Tool' } }), + createInstalledApp({ + id: 'installed-1', + app: { ...createInstalledApp().app, name: 'Alpha App' }, + }), + createInstalledApp({ + id: 'installed-2', + app: { ...createInstalledApp().app, name: 'Beta Tool' }, + }), ] renderMainNav() @@ -806,7 +909,9 @@ describe('MainNav', () => { expect(screen.queryByText('Alpha App')).not.toBeInTheDocument() expect(screen.getByText('Beta Tool')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'common.mainNav.webApps.openApp:{"name":"Beta Tool"}' })).toHaveAttribute('href', '/installed/installed-2') + expect( + screen.getByRole('link', { name: 'common.mainNav.webApps.openApp:{"name":"Beta Tool"}' }), + ).toHaveAttribute('href', '/installed/installed-2') }) it('renders web app skeleton rows while installed apps are loading', () => { @@ -814,9 +919,16 @@ describe('MainNav', () => { renderMainNav() - expect(screen.getByRole('region', { name: 'explore.sidebar.webApps' })).toHaveAttribute('aria-busy', 'true') - expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.operation.search' })).not.toBeInTheDocument() + expect(screen.getByRole('region', { name: 'explore.sidebar.webApps' })).toHaveAttribute( + 'aria-busy', + 'true', + ) + expect( + screen.queryByRole('button', { name: 'explore.sidebar.webApps' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.search' }), + ).not.toBeInTheDocument() expect(screen.queryByText('common.loading')).not.toBeInTheDocument() expect(screen.queryByText('Alpha App')).not.toBeInTheDocument() }) @@ -824,16 +936,30 @@ describe('MainNav', () => { it('hides the installed web apps section when no web apps are available', () => { renderMainNav() - expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument() - expect(screen.queryByRole('region', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'explore.sidebar.webApps' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('region', { name: 'explore.sidebar.webApps' }), + ).not.toBeInTheDocument() expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.operation.search' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.search' }), + ).not.toBeInTheDocument() }) it('separates pinned and unpinned installed web apps', () => { mockInstalledApps = [ - createInstalledApp({ id: 'installed-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' } }), - createInstalledApp({ id: 'installed-2', is_pinned: false, app: { ...createInstalledApp().app, name: 'Unpinned App' } }), + createInstalledApp({ + id: 'installed-1', + is_pinned: true, + app: { ...createInstalledApp().app, name: 'Pinned App' }, + }), + createInstalledApp({ + id: 'installed-2', + is_pinned: false, + app: { ...createInstalledApp().app, name: 'Unpinned App' }, + }), ] renderMainNav() @@ -846,7 +972,10 @@ describe('MainNav', () => { it('keeps long installed web app names truncated in the main nav item', () => { const longName = 'A very long installed web app name that should stay on one line and truncate' mockInstalledApps = [ - createInstalledApp({ id: 'installed-1', app: { ...createInstalledApp().app, name: longName } }), + createInstalledApp({ + id: 'installed-1', + app: { ...createInstalledApp().app, name: longName }, + }), ] renderMainNav() @@ -855,9 +984,13 @@ describe('MainNav', () => { }) it('virtualizes large installed web app lists', async () => { - const offsetHeightSpy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(320) - const offsetWidthSpy = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(240) - mockInstalledApps = Array.from({ length: 100 }, (_, index) => ( + const offsetHeightSpy = vi + .spyOn(HTMLElement.prototype, 'offsetHeight', 'get') + .mockReturnValue(320) + const offsetWidthSpy = vi + .spyOn(HTMLElement.prototype, 'offsetWidth', 'get') + .mockReturnValue(240) + mockInstalledApps = Array.from({ length: 100 }, (_, index) => createInstalledApp({ id: `installed-${index}`, app: { @@ -865,16 +998,15 @@ describe('MainNav', () => { id: `app-${index}`, name: `Web App ${index}`, }, - }) - )) + }), + ) try { renderMainNav() expect(await screen.findByText('Web App 0')).toBeInTheDocument() expect(screen.queryByText('Web App 99')).not.toBeInTheDocument() - } - finally { + } finally { offsetHeightSpy.mockRestore() offsetWidthSpy.mockRestore() } diff --git a/web/app/components/main-nav/__tests__/layout.spec.tsx b/web/app/components/main-nav/__tests__/layout.spec.tsx index dbbc13f55c75bd..c1a7cdb4b789d8 100644 --- a/web/app/components/main-nav/__tests__/layout.spec.tsx +++ b/web/app/components/main-nav/__tests__/layout.spec.tsx @@ -19,7 +19,9 @@ vi.mock('@/app/components/header', () => ({ })) vi.mock('@/app/components/header/header-wrapper', () => ({ - default: ({ children }: { children: ReactNode }) => <div data-testid="header-wrapper">{children}</div>, + default: ({ children }: { children: ReactNode }) => ( + <div data-testid="header-wrapper">{children}</div> + ), })) vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal<typeof import('@tanstack/react-query')>() @@ -51,7 +53,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -68,7 +71,11 @@ vi.mock('@/next/navigation', async (importOriginal) => { }) vi.mock('../index', () => ({ - MainNav: ({ className }: { className?: string }) => <aside className={className} data-testid="main-nav">MainNav</aside>, + MainNav: ({ className }: { className?: string }) => ( + <aside className={className} data-testid="main-nav"> + MainNav + </aside> + ), })) describe('MainNavLayout', () => { @@ -90,7 +97,11 @@ describe('MainNavLayout', () => { }) it('renders desktop main nav instead of the desktop header', () => { - render(<MainNavLayout><div>content</div></MainNavLayout>) + render( + <MainNavLayout> + <div>content</div> + </MainNavLayout>, + ) expect(screen.getByTestId('main-nav')).toBeInTheDocument() expect(screen.queryByTestId('desktop-header')).not.toBeInTheDocument() @@ -98,7 +109,11 @@ describe('MainNavLayout', () => { }) it('uses the main nav without the desktop header wrapper', () => { - render(<MainNavLayout><div>content</div></MainNavLayout>) + render( + <MainNavLayout> + <div>content</div> + </MainNavLayout>, + ) expect(screen.getByTestId('main-nav')).toBeInTheDocument() expect(screen.queryByTestId('header-wrapper')).not.toBeInTheDocument() @@ -106,29 +121,53 @@ describe('MainNavLayout', () => { }) it('renders one main landmark as the skip navigation target', () => { - render(<MainNavLayout><div>content</div></MainNavLayout>) + render( + <MainNavLayout> + <div>content</div> + </MainNavLayout>, + ) const main = screen.getByRole('main') expect(screen.getAllByRole('main')).toHaveLength(1) expect(main).toHaveAttribute('id', 'main-content') expect(main).toHaveAttribute('tabIndex', '-1') - expect(main).toHaveClass('outline-hidden', 'focus:outline-hidden', 'focus-visible:outline-hidden') + expect(main).toHaveClass( + 'outline-hidden', + 'focus:outline-hidden', + 'focus-visible:outline-hidden', + ) expect(main).toHaveTextContent('content') }) it('renders skip navigation before the repeated main navigation', () => { - const { container } = render(<MainNavLayout><div>content</div></MainNavLayout>) + const { container } = render( + <MainNavLayout> + <div>content</div> + </MainNavLayout>, + ) const skipLink = screen.getByRole('link', { name: /(?:^|\.)navigation\.skipToMain(?=$|:)/ }) expect(skipLink).toHaveAttribute('href', '#main-content') - expect(skipLink).toHaveClass('outline-hidden', 'focus-visible:ring-2', 'focus-visible:ring-state-accent-solid') - expect(container.querySelector('a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])')).toBe(skipLink) + expect(skipLink).toHaveClass( + 'outline-hidden', + 'focus-visible:ring-2', + 'focus-visible:ring-state-accent-solid', + ) + expect( + container.querySelector( + 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ).toBe(skipLink) }) it('moves focus to the main content when skip navigation is activated', () => { - render(<MainNavLayout><div>content</div></MainNavLayout>) + render( + <MainNavLayout> + <div>content</div> + </MainNavLayout>, + ) const skipLink = screen.getByRole('link', { name: /(?:^|\.)navigation\.skipToMain(?=$|:)/ }) const main = screen.getByRole('main') @@ -138,44 +177,45 @@ describe('MainNavLayout', () => { expect(main).toHaveFocus() }) - it.each([ - '/datasets/dataset-1/documents', - '/datasets/dataset-1/documents/document-1/settings', - ])('renders the detail sidebar slot outside the single skip navigation target on route %s', (pathname) => { - ;(usePathname as Mock).mockReturnValue(pathname) - - render( - <MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}> - <div>dataset detail</div> - </MainNavLayout>, - ) - - const main = screen.getByRole('main') - const detailSidebar = screen.getByRole('complementary', { name: 'Detail sidebar' }) - - expect(screen.queryByTestId('main-nav')).not.toBeInTheDocument() - expect(screen.getAllByRole('main')).toHaveLength(1) - expect(main).toHaveAttribute('id', 'main-content') - expect(main).toHaveTextContent('dataset detail') - expect(main).not.toContainElement(detailSidebar) - }) - - it.each([ - '/datasets/create', - '/datasets/dataset-1/documents/create', - '/deployments/create', - ])('keeps the global main nav on collection and creation route %s', (pathname) => { - ;(usePathname as Mock).mockReturnValue(pathname) - - render( - <MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}> - <div>content</div> - </MainNavLayout>, - ) - - expect(screen.getByTestId('main-nav')).toBeInTheDocument() - expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument() - }) + it.each(['/datasets/dataset-1/documents', '/datasets/dataset-1/documents/document-1/settings'])( + 'renders the detail sidebar slot outside the single skip navigation target on route %s', + (pathname) => { + ;(usePathname as Mock).mockReturnValue(pathname) + + render( + <MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}> + <div>dataset detail</div> + </MainNavLayout>, + ) + + const main = screen.getByRole('main') + const detailSidebar = screen.getByRole('complementary', { name: 'Detail sidebar' }) + + expect(screen.queryByTestId('main-nav')).not.toBeInTheDocument() + expect(screen.getAllByRole('main')).toHaveLength(1) + expect(main).toHaveAttribute('id', 'main-content') + expect(main).toHaveTextContent('dataset detail') + expect(main).not.toContainElement(detailSidebar) + }, + ) + + it.each(['/datasets/create', '/datasets/dataset-1/documents/create', '/deployments/create'])( + 'keeps the global main nav on collection and creation route %s', + (pathname) => { + ;(usePathname as Mock).mockReturnValue(pathname) + + render( + <MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}> + <div>content</div> + </MainNavLayout>, + ) + + expect(screen.getByTestId('main-nav')).toBeInTheDocument() + expect( + screen.queryByRole('complementary', { name: 'Detail sidebar' }), + ).not.toBeInTheDocument() + }, + ) it.each([ { @@ -231,10 +271,16 @@ describe('MainNavLayout', () => { }) it('clears app detail state after leaving app routes', () => { - useAppStore.getState().setAppDetail({ id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail']) + useAppStore + .getState() + .setAppDetail({ id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail']) ;(usePathname as Mock).mockReturnValue('/datasets') - render(<MainNavLayout><div>content</div></MainNavLayout>) + render( + <MainNavLayout> + <div>content</div> + </MainNavLayout>, + ) expect(useAppStore.getState().appDetail).toBeUndefined() }) diff --git a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx index 8a36350cb77319..246cf138372334 100644 --- a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx @@ -1,5 +1,9 @@ import type { Mock } from 'vitest' -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { fireEvent, render, screen } from '@testing-library/react' import { openZendeskWindow } from '@/app/components/base/zendesk/utils' import { Plan } from '@/app/components/billing/type' @@ -8,16 +12,17 @@ import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import SupportMenu from '../support-menu' -const { mockConfig, mockOpenZendeskWindow, mockMailToSupport, mockSetShowPricingModal } = vi.hoisted(() => ({ - mockConfig: { - isCloudEdition: true, - supportEmailAddress: '', - zendeskWidgetKey: 'zendesk-key', - }, - mockOpenZendeskWindow: vi.fn(), - mockMailToSupport: vi.fn(), - mockSetShowPricingModal: vi.fn(), -})) +const { mockConfig, mockOpenZendeskWindow, mockMailToSupport, mockSetShowPricingModal } = + vi.hoisted(() => ({ + mockConfig: { + isCloudEdition: true, + supportEmailAddress: '', + zendeskWidgetKey: 'zendesk-key', + }, + mockOpenZendeskWindow: vi.fn(), + mockMailToSupport: vi.fn(), + mockSetShowPricingModal: vi.fn(), + })) const mockAppContextState = vi.hoisted(() => ({ current: { langGeniusVersionInfo: { current_version: '1.0.0' }, @@ -71,7 +76,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -105,7 +111,7 @@ describe('SupportMenu', () => { const renderSupportMenu = (onContactUsClick = vi.fn()) => { return render( - <DropdownMenu open={true} onOpenChange={() => { }}> + <DropdownMenu open={true} onOpenChange={() => {}}> <DropdownMenuTrigger>open</DropdownMenuTrigger> <DropdownMenuContent> <SupportMenu onContactUsClick={onContactUsClick} /> @@ -121,8 +127,15 @@ describe('SupportMenu', () => { expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument() expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument() expect(screen.getByText('common.userProfile.community')).toBeInTheDocument() - expect(screen.getByText('common.userProfile.contactUs').compareDocumentPosition(screen.getByText('common.userProfile.forum'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(screen.getByRole('menuitem', { name: 'common.userProfile.forum' })).toHaveClass('mx-0', 'px-3') + expect( + screen + .getByText('common.userProfile.contactUs') + .compareDocumentPosition(screen.getByText('common.userProfile.forum')), + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(screen.getByRole('menuitem', { name: 'common.userProfile.forum' })).toHaveClass( + 'mx-0', + 'px-3', + ) fireEvent.click(screen.getByRole('menuitem', { name: 'common.userProfile.contactUs' })) @@ -140,11 +153,20 @@ describe('SupportMenu', () => { renderSupportMenu(onContactUsClick) expect(screen.getByText('common.userProfile.contactUs')).toHaveClass('text-text-disabled') - expect(screen.getByText('billing.upgradeBtn.encourageShort')).toHaveClass('system-xs-semibold-uppercase', 'text-saas-dify-blue-accessible') + expect(screen.getByText('billing.upgradeBtn.encourageShort')).toHaveClass( + 'system-xs-semibold-uppercase', + 'text-saas-dify-blue-accessible', + ) expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'billing.upgradeBtn.encourageShort' })).not.toBeInTheDocument() - - fireEvent.click(screen.getByRole('menuitem', { name: 'common.userProfile.contactUs billing.upgradeBtn.encourageShort' })) + expect( + screen.queryByRole('button', { name: 'billing.upgradeBtn.encourageShort' }), + ).not.toBeInTheDocument() + + fireEvent.click( + screen.getByRole('menuitem', { + name: 'common.userProfile.contactUs billing.upgradeBtn.encourageShort', + }), + ) expect(mockSetShowPricingModal).toHaveBeenCalled() expect(openZendeskWindow).not.toHaveBeenCalled() @@ -195,7 +217,12 @@ describe('SupportMenu', () => { expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() - expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.sandbox, '1.0.0', 'support@example.com') + expect(mailToSupport).toHaveBeenCalledWith( + 'user@example.com', + Plan.sandbox, + '1.0.0', + 'support@example.com', + ) }) it('hides dedicated support channels for non-Cloud sandbox plan without support email', () => { @@ -220,7 +247,9 @@ describe('SupportMenu', () => { expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument() expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.team, '1.0.0', '') - expect(screen.getByRole('menuitem', { name: 'common.userProfile.emailSupport' })).toHaveAttribute('href', 'mailto:support@example.com') + expect( + screen.getByRole('menuitem', { name: 'common.userProfile.emailSupport' }), + ).toHaveAttribute('href', 'mailto:support@example.com') }) it('has correct forum and community links', () => { diff --git a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx index c4642082cf471c..bdb19ba666843e 100644 --- a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx @@ -2,7 +2,10 @@ import type { ModalContextState } from '@/context/modal-context' import type { ProviderContextState } from '@/context/provider-context' import type { ICurrentWorkspace, IWorkspace } from '@/models/common' import { fireEvent, screen, waitFor, within } from '@testing-library/react' -import { createTestQueryClient, renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import { + createTestQueryClient, + renderWithSystemFeatures, +} from '@/__tests__/utils/mock-system-features' import { Plan } from '@/app/components/billing/type' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useModalContext } from '@/context/modal-context' @@ -11,7 +14,12 @@ import { LicenseStatus } from '@/features/system-features/constants' import { consoleQuery } from '@/service/client' import { WorkspaceCard } from '../workspace-card' -const { mockSwitchWorkspace, mockIsCloudEdition, mockCurrentWorkspaceQueryKey, mockWorkspacesQueryKey } = vi.hoisted(() => ({ +const { + mockSwitchWorkspace, + mockIsCloudEdition, + mockCurrentWorkspaceQueryKey, + mockWorkspacesQueryKey, +} = vi.hoisted(() => ({ mockSwitchWorkspace: vi.fn(), mockIsCloudEdition: { value: false }, mockCurrentWorkspaceQueryKey: ['console', 'workspaces', 'current', 'post'] as const, @@ -59,7 +67,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -129,7 +138,10 @@ const mockSetShowAccountSettingModal = vi.fn() let mockCurrentWorkspace: ICurrentWorkspace | undefined = currentWorkspaceValue let mockWorkspaces: IWorkspace[] = [] -const mockCurrentWorkspaceQuery = (data: ICurrentWorkspace | undefined = currentWorkspaceValue, isPending = false) => { +const mockCurrentWorkspaceQuery = ( + data: ICurrentWorkspace | undefined = currentWorkspaceValue, + isPending = false, +) => { mockCurrentWorkspace = isPending ? undefined : data } @@ -162,8 +174,22 @@ describe('WorkspaceCard', () => { vi.clearAllMocks() mockIsCloudEdition.value = false mockWorkspaces = [ - { id: 'workspace-1', name: 'Solar Studio', plan: Plan.sandbox, status: 'normal', created_at: 0, current: true }, - { id: 'workspace-2', name: 'Evan Workspace', plan: Plan.team, status: 'normal', created_at: 0, current: false }, + { + id: 'workspace-1', + name: 'Solar Studio', + plan: Plan.sandbox, + status: 'normal', + created_at: 0, + current: true, + }, + { + id: 'workspace-2', + name: 'Evan Workspace', + plan: Plan.team, + status: 'normal', + created_at: 0, + current: false, + }, ] mockSwitchWorkspace.mockReturnValue(new Promise(() => {})) mockCurrentWorkspaceQuery() @@ -184,8 +210,12 @@ describe('WorkspaceCard', () => { it('hides cloud-only credits and upgrade actions outside cloud edition', () => { renderWorkspaceCard() - expect(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })).toBeInTheDocument() - expect(screen.queryByRole('link', { name: /common\.mainNav\.workspace\.credits/ })).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }), + ).toBeInTheDocument() + expect( + screen.queryByRole('link', { name: /common\.mainNav\.workspace\.credits/ }), + ).not.toBeInTheDocument() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() }) @@ -194,7 +224,9 @@ describe('WorkspaceCard', () => { renderWorkspaceCard() - expect(screen.getByRole('link', { name: /common\.mainNav\.workspace\.credits/ })).toHaveAttribute('href', '/integrations/model-provider') + expect( + screen.getByRole('link', { name: /common\.mainNav\.workspace\.credits/ }), + ).toHaveAttribute('href', '/integrations/model-provider') }) it('renders a stable skeleton while the current workspace is loading', () => { @@ -202,14 +234,18 @@ describe('WorkspaceCard', () => { renderWorkspaceCard() - expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' }), + ).not.toBeInTheDocument() expect(screen.queryByText('Evan Workspace')).not.toBeInTheDocument() }) it('renders a skeleton while the workspaces query has no data', () => { renderWorkspaceCard({ seedWorkspaces: false }) - expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' }), + ).not.toBeInTheDocument() expect(screen.queryByText('Solar Studio')).not.toBeInTheDocument() }) @@ -238,7 +274,14 @@ describe('WorkspaceCard', () => { it('uses the original paid plan badge for paid workspaces', () => { mockIsCloudEdition.value = true mockWorkspaces = [ - { id: 'workspace-1', name: 'Solar Studio', plan: Plan.team, status: 'normal', created_at: 0, current: true }, + { + id: 'workspace-1', + name: 'Solar Studio', + plan: Plan.team, + status: 'normal', + created_at: 0, + current: true, + }, ] vi.mocked(useProviderContext).mockReturnValue({ enableBilling: true, @@ -283,11 +326,19 @@ describe('WorkspaceCard', () => { const panel = await screen.findByRole('dialog', { name: 'Solar Studio' }) expect(panel).toBeInTheDocument() expect(panel).toHaveClass('w-[280px]') - expect(within(panel).getByRole('button', { name: 'common.mainNav.workspace.settings' })).toBeInTheDocument() - expect(within(panel).getByRole('button', { name: 'common.mainNav.workspace.inviteMembers' })).toBeInTheDocument() + expect( + within(panel).getByRole('button', { name: 'common.mainNav.workspace.settings' }), + ).toBeInTheDocument() + expect( + within(panel).getByRole('button', { name: 'common.mainNav.workspace.inviteMembers' }), + ).toBeInTheDocument() expect(within(panel).getByText('common.userProfile.workspace')).toBeInTheDocument() - expect(within(panel).getByRole('button', { name: 'common.mainNav.workspace.sort.openMenu' })).toBeInTheDocument() - expect(within(panel).getByRole('button', { name: 'common.operation.search' })).toBeInTheDocument() + expect( + within(panel).getByRole('button', { name: 'common.mainNav.workspace.sort.openMenu' }), + ).toBeInTheDocument() + expect( + within(panel).getByRole('button', { name: 'common.operation.search' }), + ).toBeInTheDocument() const workspaceItem = within(panel).getByRole('button', { name: 'Evan Workspace' }) expect(workspaceItem).toBeInTheDocument() expect(workspaceItem.parentElement).toHaveClass('max-h-[240px]', 'overflow-y-auto') @@ -300,8 +351,12 @@ describe('WorkspaceCard', () => { fireEvent.click(await screen.findByRole('button', { name: 'common.operation.search' })) expect(screen.getByText('common.userProfile.workspace')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.mainNav.workspace.sort.openMenu' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.search' })).toHaveClass('bg-state-base-hover') + expect( + screen.getByRole('button', { name: 'common.mainNav.workspace.sort.openMenu' }), + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.search' })).toHaveClass( + 'bg-state-base-hover', + ) fireEvent.change(screen.getByPlaceholderText('common.mainNav.workspace.searchPlaceholder'), { target: { value: 'evan' }, @@ -314,36 +369,80 @@ describe('WorkspaceCard', () => { it('sorts workspaces by last opened and can sort by created time', async () => { mockWorkspaces = [ - { id: 'workspace-1', name: 'Solar Studio', plan: Plan.sandbox, status: 'normal', created_at: 1, last_opened_at: 20, current: true }, - { id: 'workspace-2', name: 'Evan Workspace', plan: Plan.team, status: 'normal', created_at: 3, last_opened_at: null, current: false }, - { id: 'workspace-3', name: 'Atlas Workspace', plan: Plan.team, status: 'normal', created_at: 2, last_opened_at: 30, current: false }, + { + id: 'workspace-1', + name: 'Solar Studio', + plan: Plan.sandbox, + status: 'normal', + created_at: 1, + last_opened_at: 20, + current: true, + }, + { + id: 'workspace-2', + name: 'Evan Workspace', + plan: Plan.team, + status: 'normal', + created_at: 3, + last_opened_at: null, + current: false, + }, + { + id: 'workspace-3', + name: 'Atlas Workspace', + plan: Plan.team, + status: 'normal', + created_at: 2, + last_opened_at: 30, + current: false, + }, ] renderWorkspaceCard() fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) const panel = await screen.findByRole('dialog', { name: 'Solar Studio' }) - const defaultWorkspaceOptions = within(panel).getAllByRole('button').map(item => item.getAttribute('title')).filter(Boolean) + const defaultWorkspaceOptions = within(panel) + .getAllByRole('button') + .map((item) => item.getAttribute('title')) + .filter(Boolean) expect(defaultWorkspaceOptions).toEqual(['Atlas Workspace', 'Solar Studio', 'Evan Workspace']) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.sort.openMenu' })) - expect(await screen.findByRole('menuitemradio', { name: 'common.mainNav.workspace.sort.lastOpened' })).toBeInTheDocument() - fireEvent.click(screen.getByRole('menuitemradio', { name: 'common.mainNav.workspace.sort.createdTime' })) - - const createdTimeWorkspaceOptions = within(panel).getAllByRole('button').map(item => item.getAttribute('title')).filter(Boolean) - - expect(createdTimeWorkspaceOptions).toEqual(['Evan Workspace', 'Atlas Workspace', 'Solar Studio']) + expect( + await screen.findByRole('menuitemradio', { + name: 'common.mainNav.workspace.sort.lastOpened', + }), + ).toBeInTheDocument() + fireEvent.click( + screen.getByRole('menuitemradio', { name: 'common.mainNav.workspace.sort.createdTime' }), + ) + + const createdTimeWorkspaceOptions = within(panel) + .getAllByRole('button') + .map((item) => item.getAttribute('title')) + .filter(Boolean) + + expect(createdTimeWorkspaceOptions).toEqual([ + 'Evan Workspace', + 'Atlas Workspace', + 'Solar Studio', + ]) }) it('opens account settings from workspace menu actions', async () => { renderWorkspaceCard() fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) - fireEvent.click(await screen.findByRole('button', { name: 'common.mainNav.workspace.settings' })) + fireEvent.click( + await screen.findByRole('button', { name: 'common.mainNav.workspace.settings' }), + ) - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.BILLING }) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.BILLING, + }) }) it('opens members settings from workspace menu when billing is disabled', async () => { @@ -358,10 +457,16 @@ describe('WorkspaceCard', () => { renderWorkspaceCard() fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) - fireEvent.click(await screen.findByRole('button', { name: 'common.mainNav.workspace.settings' })) + fireEvent.click( + await screen.findByRole('button', { name: 'common.mainNav.workspace.settings' }), + ) - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.MEMBERS }) - expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.BILLING }) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.MEMBERS, + }) + expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.BILLING, + }) }) it('switches workspace from the workspace switcher item', async () => { @@ -370,7 +475,9 @@ describe('WorkspaceCard', () => { fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) fireEvent.click(await screen.findByRole('button', { name: 'Evan Workspace' })) - await waitFor(() => expect(mockSwitchWorkspace).toHaveBeenCalledWith({ body: { tenant_id: 'workspace-2' } })) + await waitFor(() => + expect(mockSwitchWorkspace).toHaveBeenCalledWith({ body: { tenant_id: 'workspace-2' } }), + ) }) it('keeps workspace settings visible for dataset operators without member management permission', async () => { @@ -386,8 +493,12 @@ describe('WorkspaceCard', () => { const panel = await screen.findByRole('dialog', { name: 'Solar Studio' }) expect(panel).toBeInTheDocument() - expect(within(panel).getByRole('button', { name: 'common.mainNav.workspace.settings' })).toBeInTheDocument() - expect(within(panel).queryByRole('button', { name: 'common.mainNav.workspace.inviteMembers' })).not.toBeInTheDocument() + expect( + within(panel).getByRole('button', { name: 'common.mainNav.workspace.settings' }), + ).toBeInTheDocument() + expect( + within(panel).queryByRole('button', { name: 'common.mainNav.workspace.inviteMembers' }), + ).not.toBeInTheDocument() }) it('shows invite members when member management permission is available', async () => { @@ -402,8 +513,12 @@ describe('WorkspaceCard', () => { fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) const panel = await screen.findByRole('dialog', { name: 'Solar Studio' }) - expect(within(panel).getByRole('button', { name: 'common.mainNav.workspace.settings' })).toBeInTheDocument() - expect(within(panel).getByRole('button', { name: 'common.mainNav.workspace.inviteMembers' })).toBeInTheDocument() + expect( + within(panel).getByRole('button', { name: 'common.mainNav.workspace.settings' }), + ).toBeInTheDocument() + expect( + within(panel).getByRole('button', { name: 'common.mainNav.workspace.inviteMembers' }), + ).toBeInTheDocument() }) it('hides invite members when member management permission is missing', async () => { @@ -414,7 +529,11 @@ describe('WorkspaceCard', () => { fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) const panel = await screen.findByRole('dialog', { name: 'Solar Studio' }) - expect(within(panel).getByRole('button', { name: 'common.mainNav.workspace.settings' })).toBeInTheDocument() - expect(within(panel).queryByRole('button', { name: 'common.mainNav.workspace.inviteMembers' })).not.toBeInTheDocument() + expect( + within(panel).getByRole('button', { name: 'common.mainNav.workspace.settings' }), + ).toBeInTheDocument() + expect( + within(panel).queryByRole('button', { name: 'common.mainNav.workspace.inviteMembers' }), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/main-nav/components/account-section.tsx b/web/app/components/main-nav/components/account-section.tsx index 1c60f9aef3172b..3dac4faacdb46f 100644 --- a/web/app/components/main-nav/components/account-section.tsx +++ b/web/app/components/main-nav/components/account-section.tsx @@ -10,9 +10,7 @@ type AccountSectionProps = { compact?: boolean } -const AccountSection = ({ - compact = false, -}: AccountSectionProps) => { +const AccountSection = ({ compact = false }: AccountSectionProps) => { const userProfile = useAtomValue(userProfileAtom) return ( @@ -29,8 +27,17 @@ const AccountSection = ({ isOpen && 'bg-state-base-hover', )} > - <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="md" className="size-7" /> - {!compact && <span className="min-w-0 flex-1 truncate system-md-medium" title={userProfile.name}>{userProfile.name}</span>} + <Avatar + avatar={userProfile.avatar_url} + name={userProfile.name} + size="md" + className="size-7" + /> + {!compact && ( + <span className="min-w-0 flex-1 truncate system-md-medium" title={userProfile.name}> + {userProfile.name} + </span> + )} </button> )} /> diff --git a/web/app/components/main-nav/components/help-menu.tsx b/web/app/components/main-nav/components/help-menu.tsx index a49cf20308075d..72da41938324b9 100644 --- a/web/app/components/main-nav/components/help-menu.tsx +++ b/web/app/components/main-nav/components/help-menu.tsx @@ -16,10 +16,16 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useLearnDifyHiddenValue, useSetLearnDifyHidden } from '@/app/components/explore/learn-dify/storage' +import { + useLearnDifyHiddenValue, + useSetLearnDifyHidden, +} from '@/app/components/explore/learn-dify/storage' import AccountAbout from '@/app/components/header/account-about' import Compliance from '@/app/components/header/account-dropdown/compliance' -import { ExternalLinkIndicator, MenuItemContent } from '@/app/components/header/account-dropdown/menu-item-content' +import { + ExternalLinkIndicator, + MenuItemContent, +} from '@/app/components/header/account-dropdown/menu-item-content' import GithubStar from '@/app/components/header/github-star' import { IS_CLOUD_EDITION } from '@/config' import { useDocLink } from '@/context/i18n' @@ -35,21 +41,24 @@ type HelpMenuProps = { } const defaultTriggerIcon = ( - <svg - aria-hidden - className="size-6 shrink-0" - viewBox="0 0 24 24" - fill="none" - > - <path d="M11.9666 16.9985V17.011" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> - <path d="M11.9665 13.2485C11.9665 11.1995 14.4665 11.9134 14.4665 9.49854C14.4665 8.11782 13.3473 6.99854 11.9665 6.99854C11.0412 6.99854 10.2333 7.50129 9.80103 8.24854" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> + <svg aria-hidden className="size-6 shrink-0" viewBox="0 0 24 24" fill="none"> + <path + d="M11.9666 16.9985V17.011" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + /> + <path + d="M11.9665 13.2485C11.9665 11.1995 14.4665 11.9134 14.4665 9.49854C14.4665 8.11782 13.3473 6.99854 11.9665 6.99854C11.0412 6.99854 10.2333 7.50129 9.80103 8.24854" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> </svg> ) -const HelpMenu = ({ - triggerIcon = defaultTriggerIcon, - triggerClassName, -}: HelpMenuProps) => { +const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMenuProps) => { const { t } = useTranslation() const docLink = useDocLink() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) @@ -61,14 +70,13 @@ const HelpMenu = ({ const [open, setOpen] = useState(false) const shouldShowLearnDifySwitch = systemFeatures.enable_learn_app - if (systemFeatures.branding.enabled) - return null + if (systemFeatures.branding.enabled) return null return ( <> <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger - aria-label={t($ => $['mainNav.help.openMenu'], { ns: 'common' })} + aria-label={t(($) => $['mainNav.help.openMenu'], { ns: 'common' })} data-learn-dify-help-target className={cn( 'inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-components-card-border bg-components-card-bg p-0 text-text-tertiary shadow-xs transition-colors hover:bg-components-card-bg-alt hover:text-saas-dify-blue-inverted focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', @@ -85,17 +93,27 @@ const HelpMenu = ({ > <> <DropdownMenuGroup className="p-1"> - <DropdownMenuLinkItem href={docLink('/use-dify/getting-started/introduction')} target="_blank" rel="noopener noreferrer" className="mx-0 h-8 gap-1 px-3 py-1"> + <DropdownMenuLinkItem + href={docLink('/use-dify/getting-started/introduction')} + target="_blank" + rel="noopener noreferrer" + className="mx-0 h-8 gap-1 px-3 py-1" + > <MenuItemContent iconClassName="i-ri-book-open-line" - label={t($ => $['mainNav.help.docs'], { ns: 'common' })} + label={t(($) => $['mainNav.help.docs'], { ns: 'common' })} trailing={<ExternalLinkIndicator />} /> </DropdownMenuLinkItem> - <DropdownMenuLinkItem href="https://roadmap.dify.ai" target="_blank" rel="noopener noreferrer" className="mx-0 h-8 gap-1 px-3 py-1"> + <DropdownMenuLinkItem + href="https://roadmap.dify.ai" + target="_blank" + rel="noopener noreferrer" + className="mx-0 h-8 gap-1 px-3 py-1" + > <MenuItemContent iconClassName="i-ri-map-2-line" - label={t($ => $['userProfile.roadmap'], { ns: 'common' })} + label={t(($) => $['userProfile.roadmap'], { ns: 'common' })} trailing={<ExternalLinkIndicator />} /> </DropdownMenuLinkItem> @@ -104,17 +122,22 @@ const HelpMenu = ({ checked={!learnDifyHidden} closeOnClick={false} className="mx-0 h-8 gap-1 px-0 py-1 pr-2 pl-3" - onCheckedChange={checked => setLearnDifyHidden(!checked)} + onCheckedChange={(checked) => setLearnDifyHidden(!checked)} > - <span aria-hidden className="i-custom-vender-workflow-docs-extractor size-4 shrink-0 text-text-tertiary" /> + <span + aria-hidden + className="i-custom-vender-workflow-docs-extractor size-4 shrink-0 text-text-tertiary" + /> <span className="min-w-0 flex-1 truncate px-1 py-0.5 system-md-regular text-text-secondary"> - {t($ => $['mainNav.help.learnDify'], { ns: 'common' })} + {t(($) => $['mainNav.help.learnDify'], { ns: 'common' })} </span> <span aria-hidden className={cn( 'relative inline-flex h-4 w-7 shrink-0 items-center rounded-[5px] p-0.5 transition-colors', - !learnDifyHidden ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked', + !learnDifyHidden + ? 'bg-components-toggle-bg' + : 'bg-components-toggle-bg-unchecked', )} > <span @@ -134,16 +157,24 @@ const HelpMenu = ({ </DropdownMenuGroup> <DropdownMenuSeparator className="my-0!" /> <DropdownMenuGroup className="p-1"> - <DropdownMenuLinkItem href="https://github.com/langgenius/dify" target="_blank" rel="noopener noreferrer" className="mx-0 h-8 gap-1 px-3 py-1.5"> + <DropdownMenuLinkItem + href="https://github.com/langgenius/dify" + target="_blank" + rel="noopener noreferrer" + className="mx-0 h-8 gap-1 px-3 py-1.5" + > <MenuItemContent iconClassName="i-ri-github-line" - label={t($ => $['userProfile.github'], { ns: 'common' })} - trailing={( + label={t(($) => $['userProfile.github'], { ns: 'common' })} + trailing={ <div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]"> - <span aria-hidden className="i-ri-star-line size-3 shrink-0 text-text-tertiary" /> + <span + aria-hidden + className="i-ri-star-line size-3 shrink-0 text-text-tertiary" + /> <GithubStar className="system-2xs-medium-uppercase text-text-tertiary" /> </div> - )} + } /> </DropdownMenuLinkItem> {env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && ( @@ -156,12 +187,17 @@ const HelpMenu = ({ > <MenuItemContent iconClassName="i-ri-information-2-line" - label={t($ => $['userProfile.about'], { ns: 'common' })} - trailing={( + label={t(($) => $['userProfile.about'], { ns: 'common' })} + trailing={ <div className="flex shrink-0 items-center"> - <div className="system-xs-regular text-text-tertiary">{t($ => $['about.version'], { ns: 'common', version: langGeniusVersionInfo.current_version })}</div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['about.version'], { + ns: 'common', + version: langGeniusVersionInfo.current_version, + })} + </div> </div> - )} + } /> </DropdownMenuItem> )} @@ -169,7 +205,12 @@ const HelpMenu = ({ </> </DropdownMenuContent> </DropdownMenu> - {aboutVisible && <AccountAbout onCancel={() => setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} />} + {aboutVisible && ( + <AccountAbout + onCancel={() => setAboutVisible(false)} + langGeniusVersionInfo={langGeniusVersionInfo} + /> + )} </> ) } diff --git a/web/app/components/main-nav/components/nav-link.tsx b/web/app/components/main-nav/components/nav-link.tsx index 85afdff0b59d5f..328437933db5e3 100644 --- a/web/app/components/main-nav/components/nav-link.tsx +++ b/web/app/components/main-nav/components/nav-link.tsx @@ -5,13 +5,7 @@ import type { MainNavItem } from '../types' import { cn } from '@langgenius/dify-ui/cn' import Link from '@/next/link' -const NavIcon = ({ - icon, - className, -}: { - icon: string - className?: string -}) => ( +const NavIcon = ({ icon, className }: { icon: string; className?: string }) => ( <span aria-hidden className={cn(icon, 'h-5 w-5 shrink-0', className)} /> ) @@ -21,11 +15,7 @@ type MainNavLinkProps = { children?: ReactNode } -const MainNavLink = ({ - item, - pathname, - children, -}: MainNavLinkProps) => { +const MainNavLink = ({ item, pathname, children }: MainNavLinkProps) => { const activated = item.active(pathname) return ( @@ -41,7 +31,9 @@ const MainNavLink = ({ > <NavIcon icon={item.icon} className="group-aria-[current=page]:hidden" /> <NavIcon icon={item.activeIcon} className="hidden group-aria-[current=page]:block" /> - <span className="min-w-0 truncate group-aria-[current=page]:text-shadow-[0px_0px_8px_var(--color-components-main-nav-glass-text-glow)]">{item.label}</span> + <span className="min-w-0 truncate group-aria-[current=page]:text-shadow-[0px_0px_8px_var(--color-components-main-nav-glass-text-glow)]"> + {item.label} + </span> {children} </Link> ) diff --git a/web/app/components/main-nav/components/search-button.tsx b/web/app/components/main-nav/components/search-button.tsx index 8a488f5f4122ec..7444f66690eb2a 100644 --- a/web/app/components/main-nav/components/search-button.tsx +++ b/web/app/components/main-nav/components/search-button.tsx @@ -14,13 +14,13 @@ export function MainNavSearchButton() { return ( <button type="button" - aria-label={t($ => $['gotoAnything.searchTitle'], { ns: 'app' })} + aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })} className="flex h-8 items-center gap-1.5 overflow-hidden rounded-[10px] p-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={() => setGotoAnythingOpen(true)} > <span aria-hidden className="i-custom-vender-main-nav-quick-search h-4 w-4" /> <Kbd className="h-4.5 min-w-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> - {searchShortcut.map(key => ( + {searchShortcut.map((key) => ( <span key={key}>{formatForDisplay(key)}</span> ))} </Kbd> diff --git a/web/app/components/main-nav/components/support-menu.tsx b/web/app/components/main-nav/components/support-menu.tsx index f36050dff8df0a..25dfada4c8a37c 100644 --- a/web/app/components/main-nav/components/support-menu.tsx +++ b/web/app/components/main-nav/components/support-menu.tsx @@ -3,7 +3,10 @@ import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { openZendeskWindow } from '@/app/components/base/zendesk/utils' import { Plan } from '@/app/components/billing/type' -import { ExternalLinkIndicator, MenuItemContent } from '@/app/components/header/account-dropdown/menu-item-content' +import { + ExternalLinkIndicator, + MenuItemContent, +} from '@/app/components/header/account-dropdown/menu-item-content' import { mailToSupport } from '@/app/components/header/utils/util' import { IS_CLOUD_EDITION, SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config' import { userProfileAtom } from '@/context/account-state' @@ -22,14 +25,15 @@ export default function SupportMenu({ onContactUsClick }: SupportMenuProps) { const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) const { setShowPricingModal } = useModalContext() const hasDedicatedChannel = plan.type !== Plan.sandbox || Boolean(SUPPORT_EMAIL_ADDRESS.trim()) - const shouldShowUpgradeContact = IS_CLOUD_EDITION && enableBilling && plan.type === Plan.sandbox && !hasDedicatedChannel + const shouldShowUpgradeContact = + IS_CLOUD_EDITION && enableBilling && plan.type === Plan.sandbox && !hasDedicatedChannel const hasZendeskWidget = Boolean(ZENDESK_WIDGET_KEY.trim()) return ( <> {shouldShowUpgradeContact && ( <DropdownMenuItem - aria-label={`${t($ => $['userProfile.contactUs'], { ns: 'common' })} ${t($ => $['upgradeBtn.encourageShort'], { ns: 'billing' })}`} + aria-label={`${t(($) => $['userProfile.contactUs'], { ns: 'common' })} ${t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}`} className="mx-0 h-8 gap-1 px-3 py-1" onClick={() => { setShowPricingModal() @@ -38,19 +42,19 @@ export default function SupportMenu({ onContactUsClick }: SupportMenuProps) { > <MenuItemContent iconClassName="i-ri-chat-smile-2-line text-text-disabled" - label={( + label={ <span className="text-text-disabled"> - {t($ => $['userProfile.contactUs'], { ns: 'common' })} + {t(($) => $['userProfile.contactUs'], { ns: 'common' })} </span> - )} - trailing={( + } + trailing={ <span aria-hidden className="max-w-30 shrink-0 truncate px-1 system-xs-semibold-uppercase text-saas-dify-blue-accessible" > - {t($ => $['upgradeBtn.encourageShort'], { ns: 'billing' })} + {t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })} </span> - )} + } /> </DropdownMenuItem> )} @@ -64,20 +68,25 @@ export default function SupportMenu({ onContactUsClick }: SupportMenuProps) { > <MenuItemContent iconClassName="i-ri-chat-smile-2-line" - label={t($ => $['userProfile.contactUs'], { ns: 'common' })} + label={t(($) => $['userProfile.contactUs'], { ns: 'common' })} /> </DropdownMenuItem> )} {!shouldShowUpgradeContact && hasDedicatedChannel && !hasZendeskWidget && ( <DropdownMenuLinkItem className="mx-0 h-8 gap-1 px-3 py-1" - href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)} + href={mailToSupport( + userProfile.email, + plan.type, + langGeniusVersionInfo?.current_version, + SUPPORT_EMAIL_ADDRESS, + )} rel="noopener noreferrer" target="_blank" > <MenuItemContent iconClassName="i-ri-mail-send-line" - label={t($ => $['userProfile.emailSupport'], { ns: 'common' })} + label={t(($) => $['userProfile.emailSupport'], { ns: 'common' })} trailing={<ExternalLinkIndicator />} /> </DropdownMenuLinkItem> @@ -90,7 +99,7 @@ export default function SupportMenu({ onContactUsClick }: SupportMenuProps) { > <MenuItemContent iconClassName="i-ri-discuss-line" - label={t($ => $['userProfile.forum'], { ns: 'common' })} + label={t(($) => $['userProfile.forum'], { ns: 'common' })} trailing={<ExternalLinkIndicator />} /> </DropdownMenuLinkItem> @@ -102,7 +111,7 @@ export default function SupportMenu({ onContactUsClick }: SupportMenuProps) { > <MenuItemContent iconClassName="i-ri-discord-line" - label={t($ => $['userProfile.community'], { ns: 'common' })} + label={t(($) => $['userProfile.community'], { ns: 'common' })} trailing={<ExternalLinkIndicator />} /> </DropdownMenuLinkItem> diff --git a/web/app/components/main-nav/components/web-apps-section.tsx b/web/app/components/main-nav/components/web-apps-section.tsx index 58a21ff1f3384d..0170048f605def 100644 --- a/web/app/components/main-nav/components/web-apps-section.tsx +++ b/web/app/components/main-nav/components/web-apps-section.tsx @@ -36,7 +36,8 @@ const appNavItemHeight = 32 const appNavItemGap = 2 const appNavSeparatorHeight = 17 const virtualizationThreshold = 50 -const webAppSkeletonClassName = 'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none' +const webAppSkeletonClassName = + 'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none' const webAppSkeletonWidths = ['w-24', 'w-32', 'w-28'] function WebAppsHeaderSkeleton() { @@ -51,7 +52,7 @@ function WebAppsHeaderSkeleton() { function WebAppsSkeleton() { return ( <div aria-hidden="true" className="space-y-0.5 pb-2"> - {webAppSkeletonWidths.map(width => ( + {webAppSkeletonWidths.map((width) => ( <div key={width} className="flex h-8 items-center gap-2 rounded-lg py-0.5 pr-0.5 pl-2"> <div className={cn(webAppSkeletonClassName, 'size-5 shrink-0 rounded-md')} /> <div className="min-w-0 flex-1 py-1 pr-1"> @@ -64,16 +65,16 @@ function WebAppsSkeleton() { ) } -type WebAppListRow - = | { - key: string - kind: 'app' - app: InstalledApp - } +type WebAppListRow = | { - key: string - kind: 'separator' - } + key: string + kind: 'app' + app: InstalledApp + } + | { + key: string + kind: 'separator' + } const WebAppsSectionContent = () => { const { t } = useTranslation() @@ -91,10 +92,9 @@ const WebAppsSectionContent = () => { const filteredApps = useMemo(() => { const normalizedSearch = searchText.trim().toLowerCase() - if (!normalizedSearch) - return installedApps + if (!normalizedSearch) return installedApps - return installedApps.filter(item => item.app.name.toLowerCase().includes(normalizedSearch)) + return installedApps.filter((item) => item.app.name.toLowerCase().includes(normalizedSearch)) }, [installedApps, searchText]) const webAppRows = useMemo<WebAppListRow[]>(() => { const pinnedAppsCount = filteredApps.filter(({ is_pinned }) => is_pinned).length @@ -122,9 +122,10 @@ const WebAppsSectionContent = () => { const rowVirtualizer = useVirtualizer({ count: webAppRows.length, - estimateSize: index => webAppRows[index]?.kind === 'separator' ? appNavSeparatorHeight : appNavItemHeight, + estimateSize: (index) => + webAppRows[index]?.kind === 'separator' ? appNavSeparatorHeight : appNavItemHeight, gap: appNavItemGap, - getItemKey: index => webAppRows[index]?.key ?? index, + getItemKey: (index) => webAppRows[index]?.key ?? index, getScrollElement: () => scrollRef.current, overscan: 6, paddingEnd: 8, @@ -134,23 +135,27 @@ const WebAppsSectionContent = () => { const handleDelete = async () => { await uninstallApp(currentId) setShowConfirm(false) - toast.success(t($ => $['api.remove'], { ns: 'common' })) + toast.success(t(($) => $['api.remove'], { ns: 'common' })) } const handleUpdatePinStatus = async (id: string, isPinned: boolean) => { await updatePinStatus({ appId: id, isPinned }) - toast.success(t($ => $['api.success'], { ns: 'common' })) + toast.success(t(($) => $['api.success'], { ns: 'common' })) } - if (!isPending && installedApps.length === 0) - return null + if (!isPending && installedApps.length === 0) return null - const renderAppNavItem = ({ id, is_pinned, uninstallable, app }: (typeof filteredApps)[number]) => ( + const renderAppNavItem = ({ + id, + is_pinned, + uninstallable, + app, + }: (typeof filteredApps)[number]) => ( <AppNavItem key={id} variant="mainNav" name={app.name} - ariaLabel={t($ => $['mainNav.webApps.openApp'], { ns: 'common', name: app.name })} + ariaLabel={t(($) => $['mainNav.webApps.openApp'], { ns: 'common', name: app.name })} icon_type={app.icon_type} icon={app.icon} icon_background={app.icon_background} @@ -169,50 +174,58 @@ const WebAppsSectionContent = () => { /> ) const renderRow = (row: WebAppListRow) => { - if (row.kind === 'separator') - return <Divider /> + if (row.kind === 'separator') return <Divider /> return renderAppNavItem(row.app) } return ( <div className="flex min-h-0 flex-1 flex-col"> - {isPending - ? <WebAppsHeaderSkeleton /> - : ( - <div className="flex items-center justify-between py-1 pr-2 pl-2"> - <button - type="button" - aria-expanded={appsExpanded} - className="flex min-w-0 items-center rounded-md px-2 py-1 text-left system-xs-medium-uppercase text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid" - onClick={() => setAppsExpanded(value => !value)} - > - <span>{t($ => $['sidebar.webApps'], { ns: 'explore' })}</span> - <span aria-hidden className={cn('i-ri-arrow-down-s-fill h-4 w-4 shrink-0 transition-transform', !appsExpanded && '-rotate-90')} /> - </button> - <div className="flex items-center gap-0.5"> - <button - type="button" - aria-label={t($ => $['operation.search'], { ns: 'common' })} - className={cn('flex h-6 w-6 items-center justify-center rounded-md p-0.5 text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid', searchVisible && 'bg-state-base-hover text-text-secondary')} - onClick={() => { - setAppsExpanded(true) - setSearchVisible(value => !value) - }} - > - <span className="flex size-5 shrink-0 items-center justify-center"> - <span aria-hidden className="i-ri-search-line size-3.5" /> - </span> - </button> - </div> - </div> - )} + {isPending ? ( + <WebAppsHeaderSkeleton /> + ) : ( + <div className="flex items-center justify-between py-1 pr-2 pl-2"> + <button + type="button" + aria-expanded={appsExpanded} + className="flex min-w-0 items-center rounded-md px-2 py-1 text-left system-xs-medium-uppercase text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid" + onClick={() => setAppsExpanded((value) => !value)} + > + <span>{t(($) => $['sidebar.webApps'], { ns: 'explore' })}</span> + <span + aria-hidden + className={cn( + 'i-ri-arrow-down-s-fill h-4 w-4 shrink-0 transition-transform', + !appsExpanded && '-rotate-90', + )} + /> + </button> + <div className="flex items-center gap-0.5"> + <button + type="button" + aria-label={t(($) => $['operation.search'], { ns: 'common' })} + className={cn( + 'flex h-6 w-6 items-center justify-center rounded-md p-0.5 text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid', + searchVisible && 'bg-state-base-hover text-text-secondary', + )} + onClick={() => { + setAppsExpanded(true) + setSearchVisible((value) => !value) + }} + > + <span className="flex size-5 shrink-0 items-center justify-center"> + <span aria-hidden className="i-ri-search-line size-3.5" /> + </span> + </button> + </div> + </div> + )} {!isPending && appsExpanded && searchVisible && ( <div className="px-2 pb-2"> <SearchInput value={searchText} onValueChange={setSearchText} - placeholder={t($ => $['mainNav.webApps.searchPlaceholder'], { ns: 'common' })} + placeholder={t(($) => $['mainNav.webApps.searchPlaceholder'], { ns: 'common' })} // eslint-disable-next-line jsx-a11y/no-autofocus -- The field is mounted after an explicit search action. autoFocus /> @@ -223,25 +236,21 @@ const WebAppsSectionContent = () => { <ScrollAreaViewport ref={scrollRef} aria-busy={isPending} - aria-label={t($ => $['sidebar.webApps'], { ns: 'explore' })} + aria-label={t(($) => $['sidebar.webApps'], { ns: 'explore' })} className="overflow-x-hidden" role="region" > <ScrollAreaContent className="w-full max-w-full min-w-0! px-2"> - {isPending && ( - <WebAppsSkeleton /> - )} + {isPending && <WebAppsSkeleton />} {!isPending && filteredApps.length === 0 && ( <div className="px-2 py-1 system-xs-regular"> - {t($ => $['mainNav.webApps.noResults'], { ns: 'common' })} + {t(($) => $['mainNav.webApps.noResults'], { ns: 'common' })} </div> )} {!isPending && webAppRows.length > 0 && !shouldVirtualize && ( <div className="space-y-0.5 pb-2"> - {webAppRows.map(row => ( - <Fragment key={row.key}> - {renderRow(row)} - </Fragment> + {webAppRows.map((row) => ( + <Fragment key={row.key}>{renderRow(row)}</Fragment> ))} </div> )} @@ -281,18 +290,22 @@ const WebAppsSectionContent = () => { <AlertDialogContent> <div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-6 pb-4 pl-6"> <AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary"> - {t($ => $['sidebar.delete.title'], { ns: 'explore' })} + {t(($) => $['sidebar.delete.title'], { ns: 'explore' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['sidebar.delete.content'], { ns: 'explore' })} + {t(($) => $['sidebar.delete.content'], { ns: 'explore' })} </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton disabled={isUninstalling}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> - <AlertDialogConfirmButton loading={isUninstalling} disabled={isUninstalling} onClick={handleDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + <AlertDialogConfirmButton + loading={isUninstalling} + disabled={isUninstalling} + onClick={handleDelete} + > + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> @@ -305,8 +318,7 @@ const WebAppsSection = () => { const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canAccessAppLibrary = hasPermission(workspacePermissionKeys, 'app_library.access') - if (!canAccessAppLibrary) - return null + if (!canAccessAppLibrary) return null return <WebAppsSectionContent /> } diff --git a/web/app/components/main-nav/components/workspace-card.tsx b/web/app/components/main-nav/components/workspace-card.tsx index 782b341f39532c..eb3e84e728d203 100644 --- a/web/app/components/main-nav/components/workspace-card.tsx +++ b/web/app/components/main-nav/components/workspace-card.tsx @@ -2,12 +2,7 @@ import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTitle, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' @@ -32,7 +27,8 @@ import { WorkspaceSwitcher } from './workspace-switcher' const workspaceMenuTriggerHeight = 36 const workspaceMenuAlignOffset = -28 -const workspaceCardSkeletonClassName = 'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none' +const workspaceCardSkeletonClassName = + 'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none' const workspacePlans = new Set<string>(Object.values(Plan)) function isWorkspacePlan(plan: string | null | undefined): plan is Plan { @@ -72,13 +68,7 @@ function WorkspaceCardSkeleton({ ) } -function WorkspaceCreditsLabel({ - credits, - unit, -}: { - credits: string - unit: string -}) { +function WorkspaceCreditsLabel({ credits, unit }: { credits: string; unit: string }) { const label = `${credits} ${unit}` return ( @@ -111,14 +101,14 @@ function WorkspaceCardTrigger({ onPlanClick: () => void }) { const { t } = useTranslation() - const creditsUnit = t($ => $['mainNav.workspace.creditsUnit'], { ns: 'common' }) + const creditsUnit = t(($) => $['mainNav.workspace.creditsUnit'], { ns: 'common' }) const formattedCredits = formatCredits(credits) const showStatus = status !== undefined && status !== null return ( <div className="overflow-hidden rounded-xl border border-components-card-border bg-components-card-bg text-left shadow-xs transition-colors hover:bg-components-card-bg-alt"> <PopoverTrigger - aria-label={t($ => $['mainNav.workspace.openMenu'], { ns: 'common' })} + aria-label={t(($) => $['mainNav.workspace.openMenu'], { ns: 'common' })} title={name} className={cn( 'flex w-full items-center gap-1.5 py-1.5 pr-3 pl-1.5 text-left transition-colors focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden', @@ -129,18 +119,26 @@ function WorkspaceCardTrigger({ <WorkspaceIcon name={name} className="h-6 w-6 rounded-lg" /> <div className="min-w-0 grow"> <div className="flex min-w-0 items-center gap-1 pr-0.5"> - <span className="max-w-[120px] min-w-0 shrink truncate system-sm-medium text-text-primary" title={name}>{name}</span> + <span + className="max-w-[120px] min-w-0 shrink truncate system-sm-medium text-text-primary" + title={name} + > + {name} + </span> {showStatus && <span className="flex shrink-0 items-center">{status}</span>} </div> </div> - <span aria-hidden className="i-ri-expand-up-down-line h-4 w-4 shrink-0 text-text-tertiary" /> + <span + aria-hidden + className="i-ri-expand-up-down-line h-4 w-4 shrink-0 text-text-tertiary" + /> </PopoverTrigger> {showCloudBilling && ( <div className="flex items-center justify-center gap-1.5 border-t border-divider-subtle py-2 pr-2.5 pl-2"> <Link href={creditsHref} className="flex min-w-0 flex-1 items-center gap-0.5 px-1 text-left text-text-tertiary transition-colors hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden" - aria-label={t($ => $['mainNav.workspace.credits'], { ns: 'common', count: credits })} + aria-label={t(($) => $['mainNav.workspace.credits'], { ns: 'common', count: credits })} > <span className="i-custom-vender-main-nav-credits h-3 w-3 shrink-0" aria-hidden /> <WorkspaceCreditsLabel credits={formattedCredits} unit={creditsUnit} /> @@ -183,7 +181,12 @@ function WorkspaceMenuHeader({ <div className="rounded-xl border-[0.5px] border-components-panel-border bg-linear-to-b from-background-section-burn to-background-section pb-2"> <div className="flex h-16 items-center gap-2 px-3"> <div className="flex min-w-0 flex-1 flex-col items-start justify-center gap-1"> - <PopoverTitle className="w-full min-w-0 truncate text-base/5 font-medium text-text-primary" title={name}>{name}</PopoverTitle> + <PopoverTitle + className="w-full min-w-0 truncate text-base/5 font-medium text-text-primary" + title={name} + > + {name} + </PopoverTitle> {status} </div> <WorkspaceIcon name={name} className="h-9 w-9 shrink-0" /> @@ -193,7 +196,12 @@ function WorkspaceMenuHeader({ className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid" onClick={onOpenSettings} > - <WorkspaceMenuItemContent icon={<span aria-hidden className="i-custom-vender-main-nav-workspace-settings h-4 w-4" />} label={settingsLabel} /> + <WorkspaceMenuItemContent + icon={ + <span aria-hidden className="i-custom-vender-main-nav-workspace-settings h-4 w-4" /> + } + label={settingsLabel} + /> </button> {showInviteMembers && ( <button @@ -201,7 +209,10 @@ function WorkspaceMenuHeader({ className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid" onClick={onInviteMembers} > - <WorkspaceMenuItemContent icon={<span aria-hidden className="i-ri-user-add-line h-4 w-4" />} label={inviteMembersLabel} /> + <WorkspaceMenuItemContent + icon={<span aria-hidden className="i-ri-user-add-line h-4 w-4" />} + label={inviteMembersLabel} + /> </button> )} </div> @@ -224,22 +235,32 @@ const selectCurrentWorkspaceCardData = (workspace: { export function WorkspaceCard() { const { t } = useTranslation() - const currentWorkspaceQuery = useQuery(consoleQuery.workspaces.current.post.queryOptions({ - select: selectCurrentWorkspaceCardData, - })) + const currentWorkspaceQuery = useQuery( + consoleQuery.workspaces.current.post.queryOptions({ + select: selectCurrentWorkspaceCardData, + }), + ) const workspacesQuery = useQuery(consoleQuery.workspaces.get.queryOptions()) const switchWorkspaceMutation = useMutation(consoleQuery.workspaces.switch.post.mutationOptions()) const currentWorkspace = currentWorkspaceQuery.data const workspacesData = workspacesQuery.data const workspaces = workspacesData?.workspaces - const currentWorkspaceInList = workspaces?.find(workspace => workspace.current) + const currentWorkspaceInList = workspaces?.find((workspace) => workspace.current) const { enableBilling } = useProviderContext() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() const showCloudBilling = IS_CLOUD_EDITION && enableBilling const [open, setOpen] = useState(false) - if (currentWorkspaceQuery.isPending || workspacesQuery.isPending || !currentWorkspace?.name || !currentWorkspace.role || !workspaces || !currentWorkspaceInList || !isWorkspacePlan(currentWorkspaceInList.plan)) { + if ( + currentWorkspaceQuery.isPending || + workspacesQuery.isPending || + !currentWorkspace?.name || + !currentWorkspace.role || + !workspaces || + !currentWorkspaceInList || + !isWorkspacePlan(currentWorkspaceInList.plan) + ) { return ( <WorkspaceCardSkeleton showCloudBilling={showCloudBilling} @@ -251,21 +272,23 @@ export function WorkspaceCard() { const workspacePlan = currentWorkspaceInList.plan const isFreePlan = workspacePlan === Plan.sandbox const showPlanAction = showCloudBilling - const planActionLabel = t($ => $[isFreePlan ? 'upgradeBtn.encourageShort' : 'upgradeBtn.plain'], { ns: 'billing' }) + const planActionLabel = t( + ($) => $[isFreePlan ? 'upgradeBtn.encourageShort' : 'upgradeBtn.plain'], + { ns: 'billing' }, + ) const showInviteMembers = hasPermission(workspacePermissionKeys, 'workspace.member.manage') - const renderWorkspaceStatus = () => enableBilling ? <WorkspacePlanBadge plan={workspacePlan} /> : <LicenseNav /> + const renderWorkspaceStatus = () => + enableBilling ? <WorkspacePlanBadge plan={workspacePlan} /> : <LicenseNav /> const handleSwitchWorkspace = async (tenant_id: string) => { try { - if (currentWorkspace.id === tenant_id) - return + if (currentWorkspace.id === tenant_id) return await switchWorkspaceMutation.mutateAsync({ body: { tenant_id } }) - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) location.assign(`${location.origin}${basePath}`) - } - catch { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } catch { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) } } @@ -293,14 +316,12 @@ export function WorkspaceCard() { name={currentWorkspace.name} status={renderWorkspaceStatus()} showInviteMembers={showInviteMembers} - settingsLabel={t($ => $['mainNav.workspace.settings'], { ns: 'common' })} - inviteMembersLabel={t($ => $['mainNav.workspace.inviteMembers'], { ns: 'common' })} + settingsLabel={t(($) => $['mainNav.workspace.settings'], { ns: 'common' })} + inviteMembersLabel={t(($) => $['mainNav.workspace.inviteMembers'], { ns: 'common' })} onOpenSettings={() => { setOpen(false) setShowAccountSettingModal({ - payload: enableBilling - ? ACCOUNT_SETTING_TAB.BILLING - : ACCOUNT_SETTING_TAB.MEMBERS, + payload: enableBilling ? ACCOUNT_SETTING_TAB.BILLING : ACCOUNT_SETTING_TAB.MEMBERS, }) }} onInviteMembers={() => { diff --git a/web/app/components/main-nav/components/workspace-menu-content.tsx b/web/app/components/main-nav/components/workspace-menu-content.tsx index 12371f71223e07..01c9fa36a87045 100644 --- a/web/app/components/main-nav/components/workspace-menu-content.tsx +++ b/web/app/components/main-nav/components/workspace-menu-content.tsx @@ -2,15 +2,14 @@ import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { getWorkspaceInitial } from '../utils' -export function WorkspaceIcon({ - name, - className, -}: { - name?: string - className?: string -}) { +export function WorkspaceIcon({ name, className }: { name?: string; className?: string }) { return ( - <div className={cn('flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-components-icon-bg-orange-dark-solid text-white shadow-xs', className)}> + <div + className={cn( + 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-components-icon-bg-orange-dark-solid text-white shadow-xs', + className, + )} + > <span className="system-md-semibold">{getWorkspaceInitial(name)}</span> </div> ) @@ -30,9 +29,21 @@ export function WorkspaceMenuItemContent({ return ( <> - <span aria-hidden className="flex h-4 w-4 shrink-0 items-center justify-center text-text-tertiary">{icon}</span> - <span className="min-w-0 flex-1 truncate text-left system-md-regular text-text-secondary" title={labelTitle}>{label}</span> - {showTrailing && <span className="flex h-4 w-4 shrink-0 items-center justify-center">{trailing}</span>} + <span + aria-hidden + className="flex h-4 w-4 shrink-0 items-center justify-center text-text-tertiary" + > + {icon} + </span> + <span + className="min-w-0 flex-1 truncate text-left system-md-regular text-text-secondary" + title={labelTitle} + > + {label} + </span> + {showTrailing && ( + <span className="flex h-4 w-4 shrink-0 items-center justify-center">{trailing}</span> + )} </> ) } diff --git a/web/app/components/main-nav/components/workspace-plan-badge.tsx b/web/app/components/main-nav/components/workspace-plan-badge.tsx index 8bebb708074de4..1ee7aee7b66a92 100644 --- a/web/app/components/main-nav/components/workspace-plan-badge.tsx +++ b/web/app/components/main-nav/components/workspace-plan-badge.tsx @@ -6,11 +6,8 @@ type WorkspacePlanBadgeProps = { plan: Plan } -const WorkspacePlanBadge = ({ - plan, -}: WorkspacePlanBadgeProps) => { - if (plan !== Plan.sandbox) - return <PlanBadge plan={plan} /> +const WorkspacePlanBadge = ({ plan }: WorkspacePlanBadgeProps) => { + if (plan !== Plan.sandbox) return <PlanBadge plan={plan} /> return ( <Badge size="xs" variant="dimm" className="shrink-0"> diff --git a/web/app/components/main-nav/components/workspace-switcher.tsx b/web/app/components/main-nav/components/workspace-switcher.tsx index f021d7b1dd35c6..cf64183ac24ae7 100644 --- a/web/app/components/main-nav/components/workspace-switcher.tsx +++ b/web/app/components/main-nav/components/workspace-switcher.tsx @@ -15,7 +15,8 @@ import { useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' import { WorkspaceIcon, WorkspaceMenuItemContent } from './workspace-menu-content' -const workspaceSwitchActionButtonClassName = 'flex shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const workspaceSwitchActionButtonClassName = + 'flex shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid' const workspaceSwitchActionIconWrapClassName = 'flex size-5 shrink-0 items-center justify-center' const workspaceSwitchActionIconClassName = 'size-3.5 shrink-0' const workspaceSwitchListClassName = 'max-h-[240px] overflow-y-auto overscroll-contain scroll-py-1' @@ -24,7 +25,8 @@ type WorkspaceSort = 'lastOpened' | 'createdAt' const getWorkspaceName = (workspace: TenantListItemResponse) => workspace.name || workspace.id const getWorkspaceCreatedAt = (workspace: TenantListItemResponse) => workspace.created_at ?? 0 -const getWorkspaceLastOpenedAt = (workspace: TenantListItemResponse) => workspace.last_opened_at ?? 0 +const getWorkspaceLastOpenedAt = (workspace: TenantListItemResponse) => + workspace.last_opened_at ?? 0 function WorkspaceSwitchControls({ searchText, @@ -40,10 +42,22 @@ function WorkspaceSwitchControls({ const { t } = useTranslation() const [searchVisible, setSearchVisible] = useState(false) const [sortMenuOpen, setSortMenuOpen] = useState(false) - const sortMenuLabel = t($ => $[workspaceSwitchI18nKey('mainNav.workspace.sort.openMenu')], { ns: 'common' }) - const sortOptions: Array<{ value: WorkspaceSort, label: string }> = [ - { value: 'lastOpened', label: t($ => $[workspaceSwitchI18nKey('mainNav.workspace.sort.lastOpened')], { ns: 'common' }) }, - { value: 'createdAt', label: t($ => $[workspaceSwitchI18nKey('mainNav.workspace.sort.createdTime')], { ns: 'common' }) }, + const sortMenuLabel = t(($) => $[workspaceSwitchI18nKey('mainNav.workspace.sort.openMenu')], { + ns: 'common', + }) + const sortOptions: Array<{ value: WorkspaceSort; label: string }> = [ + { + value: 'lastOpened', + label: t(($) => $[workspaceSwitchI18nKey('mainNav.workspace.sort.lastOpened')], { + ns: 'common', + }), + }, + { + value: 'createdAt', + label: t(($) => $[workspaceSwitchI18nKey('mainNav.workspace.sort.createdTime')], { + ns: 'common', + }), + }, ] return ( @@ -51,7 +65,7 @@ function WorkspaceSwitchControls({ <div className="flex items-start gap-0.5 py-1 pr-2 pl-3"> <div className="flex min-w-0 flex-1 items-center justify-center py-1"> <span className="min-w-0 flex-1 truncate system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['userProfile.workspace'], { ns: 'common' })} + {t(($) => $['userProfile.workspace'], { ns: 'common' })} </span> </div> <DropdownMenu open={sortMenuOpen} onOpenChange={setSortMenuOpen}> @@ -78,8 +92,12 @@ function WorkspaceSwitchControls({ setSortMenuOpen(false) }} > - {sortOptions.map(option => ( - <DropdownMenuRadioItem key={option.value} value={option.value} className="mx-0 h-8 gap-1 px-2 py-1"> + {sortOptions.map((option) => ( + <DropdownMenuRadioItem + key={option.value} + value={option.value} + className="mx-0 h-8 gap-1 px-2 py-1" + > <span className="flex size-4 shrink-0 items-center justify-center"> <DropdownMenuRadioItemIndicator className="ml-0" /> </span> @@ -93,12 +111,12 @@ function WorkspaceSwitchControls({ </DropdownMenu> <button type="button" - aria-label={t($ => $['operation.search'], { ns: 'common' })} + aria-label={t(($) => $['operation.search'], { ns: 'common' })} className={cn( workspaceSwitchActionButtonClassName, searchVisible && 'bg-state-base-hover text-text-secondary', )} - onClick={() => setSearchVisible(visible => !visible)} + onClick={() => setSearchVisible((visible) => !visible)} > <span aria-hidden className={workspaceSwitchActionIconWrapClassName}> <span className={cn('i-ri-search-line', workspaceSwitchActionIconClassName)} /> @@ -110,7 +128,10 @@ function WorkspaceSwitchControls({ <SearchInput value={searchText} onValueChange={onSearchTextChange} - placeholder={t($ => $[workspaceSwitchI18nKey('mainNav.workspace.searchPlaceholder')], { ns: 'common' })} + placeholder={t( + ($) => $[workspaceSwitchI18nKey('mainNav.workspace.searchPlaceholder')], + { ns: 'common' }, + )} autoFocus /> </div> @@ -124,24 +145,25 @@ type WorkspaceSwitcherProps = { onSwitchWorkspace: (workspaceId: string) => void } -export function WorkspaceSwitcher({ - workspaces, - onSwitchWorkspace, -}: WorkspaceSwitcherProps) { +export function WorkspaceSwitcher({ workspaces, onSwitchWorkspace }: WorkspaceSwitcherProps) { const [workspaceSearchText, setWorkspaceSearchText] = useState('') const [workspaceSort, setWorkspaceSort] = useState<WorkspaceSort>('lastOpened') const displayedWorkspaces = useMemo(() => { const normalizedSearchText = workspaceSearchText.trim().toLowerCase() const filteredWorkspaces = normalizedSearchText - ? workspaces.filter(workspace => getWorkspaceName(workspace).toLowerCase().includes(normalizedSearchText)) + ? workspaces.filter((workspace) => + getWorkspaceName(workspace).toLowerCase().includes(normalizedSearchText), + ) : [...workspaces] if (workspaceSort === 'createdAt') return filteredWorkspaces.sort((a, b) => getWorkspaceCreatedAt(b) - getWorkspaceCreatedAt(a)) return filteredWorkspaces.sort((a, b) => { - return getWorkspaceLastOpenedAt(b) - getWorkspaceLastOpenedAt(a) - || getWorkspaceCreatedAt(b) - getWorkspaceCreatedAt(a) + return ( + getWorkspaceLastOpenedAt(b) - getWorkspaceLastOpenedAt(a) || + getWorkspaceCreatedAt(b) - getWorkspaceCreatedAt(a) + ) }) }, [workspaceSearchText, workspaceSort, workspaces]) @@ -174,7 +196,11 @@ export function WorkspaceSwitcher({ <WorkspaceMenuItemContent icon={<WorkspaceIcon name={workspaceName} className="h-5 w-5 rounded-md" />} label={workspaceName} - trailing={workspace.current ? <span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" /> : undefined} + trailing={ + workspace.current ? ( + <span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" /> + ) : undefined + } /> </button> ) diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index b516e37468960e..b34b0548725013 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -10,7 +10,10 @@ import Badge from '@/app/components/base/badge' import DifyLogo from '@/app/components/base/logo/dify-logo' import EnvNav from '@/app/components/header/env-nav' import { langGeniusVersionInfoAtom } from '@/context/version-state' -import { isCurrentWorkspaceDatasetOperatorAtom, isCurrentWorkspaceEditorAtom } from '@/context/workspace-state' +import { + isCurrentWorkspaceDatasetOperatorAtom, + isCurrentWorkspaceEditorAtom, +} from '@/context/workspace-state' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import dynamic from '@/next/dynamic' @@ -25,9 +28,7 @@ import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes' const WebAppsSection = dynamic(() => import('./components/web-apps-section'), { ssr: false }) -export function MainNav({ - className, -}: MainNavProps) { +export function MainNav({ className }: MainNavProps) { const { t } = useTranslation() const pathname = usePathname() const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) @@ -35,26 +36,41 @@ export function MainNav({ const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const agentV2Enabled = isAgentV2Enabled() - const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT' + const showEnvTag = + langGeniusVersionInfo.current_env === 'TESTING' || + langGeniusVersionInfo.current_env === 'DEVELOPMENT' const canUseAppDeploy = isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy - const navItems = useMemo<MainNavItem[]>(() => MAIN_NAV_ROUTES - .filter(route => isMainNavRouteVisible(route, { + const navItems = useMemo<MainNavItem[]>( + () => + MAIN_NAV_ROUTES.filter((route) => + isMainNavRouteVisible(route, { + agentV2Enabled, + canUseAppDeploy, + isCurrentWorkspaceDatasetOperator, + marketplaceEnabled: systemFeatures.enable_marketplace, + }), + ).map((route) => ({ + href: route.href, + label: 'label' in route ? route.label : t(($) => $[route.labelKey], { ns: 'common' }), + active: route.active, + icon: route.icon, + activeIcon: route.activeIcon, + })), + [ agentV2Enabled, canUseAppDeploy, isCurrentWorkspaceDatasetOperator, - marketplaceEnabled: systemFeatures.enable_marketplace, - })) - .map(route => ({ - href: route.href, - label: 'label' in route ? route.label : t($ => $[route.labelKey], { ns: 'common' }), - active: route.active, - icon: route.icon, - activeIcon: route.activeIcon, - })), [agentV2Enabled, canUseAppDeploy, isCurrentWorkspaceDatasetOperator, systemFeatures.enable_marketplace, t]) + systemFeatures.enable_marketplace, + t, + ], + ) const renderLogo = () => { - const appTitle = systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify' + const appTitle = + systemFeatures.branding.enabled && systemFeatures.branding.application_title + ? systemFeatures.branding.application_title + : 'Dify' return ( <Link @@ -62,15 +78,15 @@ export function MainNav({ className="flex h-8 shrink-0 items-center overflow-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" aria-label={appTitle} > - {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo - ? ( - <img - src={systemFeatures.branding.workspace_logo} - className="block h-5.5 w-auto object-contain" - alt="" - /> - ) - : <DifyLogo alt="" />} + {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo ? ( + <img + src={systemFeatures.branding.workspace_logo} + className="block h-5.5 w-auto object-contain" + alt="" + /> + ) : ( + <DifyLogo alt="" /> + )} </Link> ) } @@ -91,13 +107,13 @@ export function MainNav({ <WorkspaceCard /> </div> <nav className="isolate flex flex-col gap-px p-2"> - {navItems.map(item => ( + {navItems.map((item) => ( <MainNavLink key={item.href} item={item} pathname={pathname}> {item.href === '/agents' && ( <Badge size="xs" variant="dimm" - text={t($ => $['menus.status'], { ns: 'common' })} + text={t(($) => $['menus.status'], { ns: 'common' })} className="ml-auto shrink-0" /> )} diff --git a/web/app/components/main-nav/layout.tsx b/web/app/components/main-nav/layout.tsx index 945fcda659be7c..ce88eccd8da7cb 100644 --- a/web/app/components/main-nav/layout.tsx +++ b/web/app/components/main-nav/layout.tsx @@ -7,7 +7,10 @@ import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' -import { isCurrentWorkspaceDatasetOperatorAtom, isCurrentWorkspaceEditorAtom } from '@/context/workspace-state' +import { + isCurrentWorkspaceDatasetOperatorAtom, + isCurrentWorkspaceEditorAtom, +} from '@/context/workspace-state' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { usePathname } from '@/next/navigation' @@ -22,14 +25,15 @@ type MainNavLayoutProps = { function AppDetailStoreCleanup() { const pathname = usePathname() - const { hasAppDetail, setAppDetail } = useAppStore(useShallow(state => ({ - hasAppDetail: !!state.appDetail, - setAppDetail: state.setAppDetail, - }))) + const { hasAppDetail, setAppDetail } = useAppStore( + useShallow((state) => ({ + hasAppDetail: !!state.appDetail, + setAppDetail: state.setAppDetail, + })), + ) useEffect(() => { - if (pathname.startsWith('/app/') || !hasAppDetail) - return + if (pathname.startsWith('/app/') || !hasAppDetail) return setAppDetail() }, [hasAppDetail, pathname, setAppDetail]) @@ -37,10 +41,7 @@ function AppDetailStoreCleanup() { return null } -const MainNavLayout = ({ - children, - detailSidebar, -}: MainNavLayoutProps) => { +const MainNavLayout = ({ children, detailSidebar }: MainNavLayoutProps) => { const { t } = useTranslation('common') const pathname = usePathname() const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom) @@ -54,7 +55,7 @@ const MainNavLayout = ({ return ( <div className="flex h-0 min-h-0 min-w-0 grow overflow-hidden bg-background-body"> - <SkipNav>{t($ => $['navigation.skipToMain'])}</SkipNav> + <SkipNav>{t(($) => $['navigation.skipToMain'])}</SkipNav> <AppDetailStoreCleanup /> {shouldHideMainNav ? detailSidebar : <MainNav />} <main diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index 4f51efef8db3e6..ab042da25a65a8 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -14,10 +14,7 @@ export type MainNavRouteConfig = { activeIcon: string visibility: MainNavRouteVisibility feature?: 'agentV2' | 'marketplace' -} & ( - | { label: string, labelKey?: never } - | { label?: never, labelKey: string } -) +} & ({ label: string; labelKey?: never } | { label?: never; labelKey: string }) export type MainNavRouteVisibilityOptions = { agentV2Enabled: boolean @@ -49,7 +46,10 @@ export const MAIN_NAV_ROUTES = [ key: 'apps', href: '/apps', labelKey: 'menus.apps', - active: (path: string) => isPathUnderRoute(path, '/apps') || isPathUnderRoute(path, '/app') || isPathUnderRoute(path, '/snippets'), + active: (path: string) => + isPathUnderRoute(path, '/apps') || + isPathUnderRoute(path, '/app') || + isPathUnderRoute(path, '/snippets'), icon: 'i-custom-vender-main-nav-studio', activeIcon: 'i-custom-vender-main-nav-studio-active', visibility: 'all', @@ -77,7 +77,8 @@ export const MAIN_NAV_ROUTES = [ key: 'integrations', href: buildIntegrationPath('provider'), labelKey: 'mainNav.integrations', - active: (path: string) => isPathUnderRoute(path, '/integrations') || isPathUnderRoute(path, '/tools'), + active: (path: string) => + isPathUnderRoute(path, '/integrations') || isPathUnderRoute(path, '/tools'), icon: 'i-custom-vender-main-nav-integrations', activeIcon: 'i-custom-vender-main-nav-integrations-active', visibility: 'all', @@ -86,7 +87,8 @@ export const MAIN_NAV_ROUTES = [ key: 'marketplace', href: '/marketplace', labelKey: 'mainNav.marketplace', - active: (path: string) => isPathUnderRoute(path, '/marketplace') || isPathUnderRoute(path, '/plugins'), + active: (path: string) => + isPathUnderRoute(path, '/marketplace') || isPathUnderRoute(path, '/plugins'), icon: 'i-custom-vender-main-nav-marketplace', activeIcon: 'i-custom-vender-main-nav-marketplace-active', visibility: 'all', @@ -103,18 +105,17 @@ export const MAIN_NAV_ROUTES = [ }, ] as const satisfies readonly MainNavRouteConfig[] -export function isMainNavRouteVisible(route: MainNavRouteConfig, options: MainNavRouteVisibilityOptions) { - if (route.feature === 'agentV2' && !options.agentV2Enabled) - return false +export function isMainNavRouteVisible( + route: MainNavRouteConfig, + options: MainNavRouteVisibilityOptions, +) { + if (route.feature === 'agentV2' && !options.agentV2Enabled) return false - if (route.feature === 'marketplace' && !options.marketplaceEnabled) - return false + if (route.feature === 'marketplace' && !options.marketplaceEnabled) return false - if (route.visibility === 'all') - return true + if (route.visibility === 'all') return true - if (route.visibility === 'notDatasetOperator') - return !options.isCurrentWorkspaceDatasetOperator + if (route.visibility === 'notDatasetOperator') return !options.isCurrentWorkspaceDatasetOperator return options.canUseAppDeploy } @@ -126,11 +127,9 @@ function isAppDetailPathname(pathname: string) { function isDatasetDetailPathname(pathname: string) { const [section, datasetId, subSection, action] = pathname.split('/').filter(Boolean) - if (section !== 'datasets' || !datasetId) - return false + if (section !== 'datasets' || !datasetId) return false - if (DATASET_COLLECTION_ROUTES.has(datasetId)) - return false + if (DATASET_COLLECTION_ROUTES.has(datasetId)) return false if (subSection === 'documents' && action && DATASET_DOCUMENT_CREATION_ROUTES.has(action)) return false @@ -147,7 +146,9 @@ function isAgentDetailPathname(pathname: string) { function isDeploymentDetailPathname(pathname: string) { const [section, appInstanceId] = pathname.split('/').filter(Boolean) - return section === 'deployments' && !!appInstanceId && !DEPLOYMENT_COLLECTION_ROUTES.has(appInstanceId) + return ( + section === 'deployments' && !!appInstanceId && !DEPLOYMENT_COLLECTION_ROUTES.has(appInstanceId) + ) } function isSnippetDetailPathname(pathname: string) { @@ -157,17 +158,13 @@ function isSnippetDetailPathname(pathname: string) { } export function shouldUseDetailSidebar(pathname: string, options: DetailSidebarVisibilityOptions) { - if (isDatasetDetailPathname(pathname) || isSnippetDetailPathname(pathname)) - return true + if (isDatasetDetailPathname(pathname) || isSnippetDetailPathname(pathname)) return true - if (options.isCurrentWorkspaceDatasetOperator) - return false + if (options.isCurrentWorkspaceDatasetOperator) return false - if (isAppDetailPathname(pathname)) - return true + if (isAppDetailPathname(pathname)) return true - if (options.agentV2Enabled && isAgentDetailPathname(pathname)) - return true + if (options.agentV2Enabled && isAgentDetailPathname(pathname)) return true return options.canUseAppDeploy && isDeploymentDetailPathname(pathname) } diff --git a/web/app/components/main-nav/skip-nav.tsx b/web/app/components/main-nav/skip-nav.tsx index ddf798e31781d7..9d63984b9ca074 100644 --- a/web/app/components/main-nav/skip-nav.tsx +++ b/web/app/components/main-nav/skip-nav.tsx @@ -6,17 +6,11 @@ import { cn } from '@langgenius/dify-ui/cn' export const MAIN_CONTENT_ID = 'main-content' const MAIN_CONTENT_HREF = `#${MAIN_CONTENT_ID}` -export function SkipNav({ - className, - children, - onClick, - ...props -}: ComponentProps<'a'>) { +export function SkipNav({ className, children, onClick, ...props }: ComponentProps<'a'>) { const handleClick: MouseEventHandler<HTMLAnchorElement> = (event) => { onClick?.(event) - if (event.defaultPrevented) - return + if (event.defaultPrevented) return document.getElementById(MAIN_CONTENT_ID)?.focus() } diff --git a/web/app/components/next-route-state/atoms.ts b/web/app/components/next-route-state/atoms.ts index d319defa5a16ae..833380d0b3dd56 100644 --- a/web/app/components/next-route-state/atoms.ts +++ b/web/app/components/next-route-state/atoms.ts @@ -31,16 +31,16 @@ function routeParamsKey(params: NextRouteParams) { return JSON.stringify(normalizedParamEntries(params)) } -export const nextParamsAtom = atom(get => get(nextRouteStateAtom).params) -export const nextPathnameAtom = atom(get => get(nextRouteStateAtom).pathname) +export const nextParamsAtom = atom((get) => get(nextRouteStateAtom).params) +export const nextPathnameAtom = atom((get) => get(nextRouteStateAtom).pathname) export const setNextRouteStateAtom = atom(null, (get, set, routeState: NextRouteState) => { const nextParams = normalizeNextRouteParams(routeState.params) const currentRouteState = get(nextRouteStateAtom) if ( - currentRouteState.pathname !== routeState.pathname - || routeParamsKey(currentRouteState.params) !== routeParamsKey(nextParams) + currentRouteState.pathname !== routeState.pathname || + routeParamsKey(currentRouteState.params) !== routeParamsKey(nextParams) ) { set(nextRouteStateAtom, { pathname: routeState.pathname, diff --git a/web/app/components/next-route-state/index.tsx b/web/app/components/next-route-state/index.tsx index cc0bec4f4cb55a..0293b653038ac5 100644 --- a/web/app/components/next-route-state/index.tsx +++ b/web/app/components/next-route-state/index.tsx @@ -4,22 +4,24 @@ import type { ReactNode } from 'react' import type { NextRouteParams } from './atoms' import { useHydrateAtoms } from 'jotai/utils' import { useParams, usePathname } from '@/next/navigation' -import { - setNextRouteStateAtom, -} from './atoms' +import { setNextRouteStateAtom } from './atoms' -export function NextRouteStateBridge({ children }: { - children: ReactNode -}) { +export function NextRouteStateBridge({ children }: { children: ReactNode }) { const pathname = usePathname() const params = useParams<NextRouteParams>() - useHydrateAtoms([ - [setNextRouteStateAtom, { - pathname, - params, - }], - ] as const, { dangerouslyForceHydrate: true }) + useHydrateAtoms( + [ + [ + setNextRouteStateAtom, + { + pathname, + params, + }, + ], + ] as const, + { dangerouslyForceHydrate: true }, + ) return children } diff --git a/web/app/components/oauth-registration-analytics.tsx b/web/app/components/oauth-registration-analytics.tsx index ff6b3b036c0abe..fd7eb3bc5427b6 100644 --- a/web/app/components/oauth-registration-analytics.tsx +++ b/web/app/components/oauth-registration-analytics.tsx @@ -23,8 +23,7 @@ export function OAuthRegistrationAnalytics() { const handledParamRef = useRef<string | null>(null) useEffect(() => { - if (oauthNewUserParam === null || handledParamRef.current === oauthNewUserParam) - return + if (oauthNewUserParam === null || handledParamRef.current === oauthNewUserParam) return handledParamRef.current = oauthNewUserParam const oauthNewUser = oauthNewUserParam === 'true' @@ -38,10 +37,8 @@ export function OAuthRegistrationAnalytics() { if (utmInfoStr) { try { const parsed: unknown = JSON.parse(utmInfoStr) - if (isRecord(parsed)) - utmInfo = parsed - } - catch (e) { + if (isRecord(parsed)) utmInfo = parsed + } catch (e) { console.error('Failed to parse utm_info cookie:', e) } } diff --git a/web/app/components/plugins/__tests__/hooks.spec.ts b/web/app/components/plugins/__tests__/hooks.spec.ts index 1e4f18ec5d241c..7113a709743e53 100644 --- a/web/app/components/plugins/__tests__/hooks.spec.ts +++ b/web/app/components/plugins/__tests__/hooks.spec.ts @@ -53,9 +53,9 @@ describe('useTags', () => { it('should return same structure on re-render', () => { const { result, rerender } = renderHook(() => useTags()) - const firstTagNames = result.current.tags.map(t => t.name) + const firstTagNames = result.current.tags.map((t) => t.name) rerender() - expect(result.current.tags.map(t => t.name)).toEqual(firstTagNames) + expect(result.current.tags.map((t) => t.name)).toEqual(firstTagNames) }) }) @@ -94,16 +94,18 @@ describe('useCategories', () => { const { result } = renderHook(() => useCategories(true)) expect(result.current.categoriesMap.tool!.label).toBe('plugin.categorySingle.tool') - expect(result.current.categoriesMap['agent-strategy']!.label).toBe('plugin.categorySingle.agent') + expect(result.current.categoriesMap['agent-strategy']!.label).toBe( + 'plugin.categorySingle.agent', + ) }) }) it('should return same structure on re-render', () => { const { result, rerender } = renderHook(() => useCategories()) - const firstCategoryNames = result.current.categories.map(c => c.name) + const firstCategoryNames = result.current.categories.map((c) => c.name) rerender() - expect(result.current.categories.map(c => c.name)).toEqual(firstCategoryNames) + expect(result.current.categories.map((c) => c.name)).toEqual(firstCategoryNames) }) }) @@ -117,7 +119,10 @@ describe('usePluginPageTabs', () => { expect(result.current).toHaveLength(2) expect(result.current[0]).toEqual({ value: 'plugins', text: 'common.menus.plugins' }) - expect(result.current[1]).toEqual({ value: 'discover', text: 'common.menus.exploreMarketplace' }) + expect(result.current[1]).toEqual({ + value: 'discover', + text: 'common.menus.exploreMarketplace', + }) }) it('should have consistent structure across re-renders', () => { diff --git a/web/app/components/plugins/__tests__/plugin-routes.spec.ts b/web/app/components/plugins/__tests__/plugin-routes.spec.ts index 53251665eed002..eea3d5a0d309ad 100644 --- a/web/app/components/plugins/__tests__/plugin-routes.spec.ts +++ b/web/app/components/plugins/__tests__/plugin-routes.spec.ts @@ -1,4 +1,10 @@ -import { getFirstPackageIdFromSearchParams, getInstallRedirectPathByPluginCategory, getInstallRedirectPathFromSearchParams, getLegacyPluginRedirectPath, shouldResolveInstallCategoryRedirect } from '../plugin-routes' +import { + getFirstPackageIdFromSearchParams, + getInstallRedirectPathByPluginCategory, + getInstallRedirectPathFromSearchParams, + getLegacyPluginRedirectPath, + shouldResolveInstallCategoryRedirect, +} from '../plugin-routes' describe('plugin routes', () => { it.each([ @@ -14,7 +20,11 @@ describe('plugin routes', () => { [{ tab: 'agent-strategy' }, '/integrations/agent-strategy'], [{ tab: 'extension' }, '/integrations/extension'], [ - { 'tab': 'trigger', 'package-ids': '["langgenius/telegram_trigger"]', 'source': 'https://marketplace.dify.ai' }, + { + tab: 'trigger', + 'package-ids': '["langgenius/telegram_trigger"]', + source: 'https://marketplace.dify.ai', + }, '/integrations/trigger?package-ids=%5B%22langgenius%2Ftelegram_trigger%22%5D', ], ])('redirects legacy plugin category URLs for search params %j', (searchParams, expected) => { @@ -23,26 +33,34 @@ describe('plugin routes', () => { it.each([ { 'package-ids': '["langgenius/telegram_trigger"]' }, - { 'tab': 'plugins', 'package-ids': '["langgenius/telegram_trigger"]' }, + { tab: 'plugins', 'package-ids': '["langgenius/telegram_trigger"]' }, { 'bundle-info': '{"org":"langgenius","name":"bundle","version":"1.0.0"}' }, ])('keeps install deep links on the legacy plugin page for search params %j', (searchParams) => { expect(getLegacyPluginRedirectPath(searchParams)).toBeUndefined() }) it('parses the first package id from marketplace install search params', () => { - expect(getFirstPackageIdFromSearchParams({ - 'package-ids': '["junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2"]', - })).toBe('junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2') + expect( + getFirstPackageIdFromSearchParams({ + 'package-ids': + '["junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2"]', + }), + ).toBe( + 'junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2', + ) }) it.each([ [{ 'package-ids': '["junjiem/mcp_see_agent"]' }, true], - [{ 'tab': 'plugins', 'package-ids': '["junjiem/mcp_see_agent"]' }, true], - [{ 'tab': 'trigger', 'package-ids': '["langgenius/telegram_trigger"]' }, false], + [{ tab: 'plugins', 'package-ids': '["junjiem/mcp_see_agent"]' }, true], + [{ tab: 'trigger', 'package-ids': '["langgenius/telegram_trigger"]' }, false], [{ 'bundle-info': '{"org":"langgenius","name":"bundle","version":"1.0.0"}' }, false], - ])('detects package install deep links that need category resolution for search params %j', (searchParams, expected) => { - expect(shouldResolveInstallCategoryRedirect(searchParams)).toBe(expected) - }) + ])( + 'detects package install deep links that need category resolution for search params %j', + (searchParams, expected) => { + expect(shouldResolveInstallCategoryRedirect(searchParams)).toBe(expected) + }, + ) it.each([ [ @@ -57,7 +75,11 @@ describe('plugin routes', () => { ], [ 'trigger', - { 'tab': 'plugins', 'package-ids': '["langgenius/telegram_trigger"]', 'source': 'https://marketplace.dify.ai' }, + { + tab: 'plugins', + 'package-ids': '["langgenius/telegram_trigger"]', + source: 'https://marketplace.dify.ai', + }, '/integrations/trigger?package-ids=%5B%22langgenius%2Ftelegram_trigger%22%5D', ], [ @@ -65,38 +87,54 @@ describe('plugin routes', () => { { 'package-ids': '["langgenius/notion_datasource"]' }, '/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D', ], - ])('builds install redirect paths from marketplace plugin category %s', (category, searchParams, expected) => { - expect(getInstallRedirectPathByPluginCategory(category, searchParams)).toBe(expected) - }) + ])( + 'builds install redirect paths from marketplace plugin category %s', + (category, searchParams, expected) => { + expect(getInstallRedirectPathByPluginCategory(category, searchParams)).toBe(expected) + }, + ) it.each([ [ - { 'category': 'agent-strategy', 'package-ids': '["junjiem/mcp_see_agent"]', 'source': 'https://marketplace.dify.ai' }, + { + category: 'agent-strategy', + 'package-ids': '["junjiem/mcp_see_agent"]', + source: 'https://marketplace.dify.ai', + }, '/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%22%5D', ], [ - { 'tab': 'trigger', 'package-ids': '["langgenius/telegram_trigger"]', 'source': 'https://marketplace.dify.ai' }, + { + tab: 'trigger', + 'package-ids': '["langgenius/telegram_trigger"]', + source: 'https://marketplace.dify.ai', + }, '/integrations/trigger?package-ids=%5B%22langgenius%2Ftelegram_trigger%22%5D', ], [ - { 'category': 'model', 'package-ids': '["langgenius/openai"]', 'source': 'https://marketplace.dify.ai' }, + { + category: 'model', + 'package-ids': '["langgenius/openai"]', + source: 'https://marketplace.dify.ai', + }, '/integrations/model-provider?package-ids=%5B%22langgenius%2Fopenai%22%5D', ], [ - { 'category': 'datasource', 'package-ids': '["langgenius/notion_datasource"]', 'source': 'https://marketplace.dify.ai' }, + { + category: 'datasource', + 'package-ids': '["langgenius/notion_datasource"]', + source: 'https://marketplace.dify.ai', + }, '/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D', ], - [ - { 'category': 'bundle', 'package-ids': '["langgenius/bundle"]' }, - undefined, - ], - [ - { category: 'agent-strategy' }, - undefined, - ], - ])('builds install redirect paths directly from install search params %j', (searchParams, expected) => { - expect(getInstallRedirectPathFromSearchParams(searchParams)).toBe(expected) - }) + [{ category: 'bundle', 'package-ids': '["langgenius/bundle"]' }, undefined], + [{ category: 'agent-strategy' }, undefined], + ])( + 'builds install redirect paths directly from install search params %j', + (searchParams, expected) => { + expect(getInstallRedirectPathFromSearchParams(searchParams)).toBe(expected) + }, + ) it.each([ [{ tab: 'discover' }, '/marketplace'], @@ -111,10 +149,10 @@ describe('plugin routes', () => { expect(getLegacyPluginRedirectPath(searchParams)).toBe(expected) }) - it.each([ - { tab: 'unsupported' }, - { tab: 'toString' }, - ])('does not redirect unsupported plugin URLs for search params %j', (searchParams) => { - expect(getLegacyPluginRedirectPath(searchParams)).toBeUndefined() - }) + it.each([{ tab: 'unsupported' }, { tab: 'toString' }])( + 'does not redirect unsupported plugin URLs for search params %j', + (searchParams) => { + expect(getLegacyPluginRedirectPath(searchParams)).toBeUndefined() + }, + ) }) diff --git a/web/app/components/plugins/__tests__/provider-card.spec.tsx b/web/app/components/plugins/__tests__/provider-card.spec.tsx index a8a49056f448c4..daf213906db3eb 100644 --- a/web/app/components/plugins/__tests__/provider-card.spec.tsx +++ b/web/app/components/plugins/__tests__/provider-card.spec.tsx @@ -12,7 +12,9 @@ vi.mock('@/hooks/use-i18n', () => ({ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="install-modal"> - <button data-testid="close-install-modal" onClick={onClose}>close</button> + <button data-testid="close-install-modal" onClick={onClose}> + close + </button> </div> ), })) @@ -35,7 +37,9 @@ vi.mock('../card/base/description', () => ({ })) vi.mock('../card/base/download-count', () => ({ - default: ({ downloadCount }: { downloadCount: number }) => <div data-testid="download-count">{downloadCount}</div>, + default: ({ downloadCount }: { downloadCount: number }) => ( + <div data-testid="download-count">{downloadCount}</div> + ), })) vi.mock('../card/base/title', () => ({ @@ -71,11 +75,12 @@ describe('ProviderCard', () => { vi.clearAllMocks() }) - const renderProviderCard = () => render( - <ThemeProvider forcedTheme="light"> - <ProviderCard payload={payload} /> - </ThemeProvider>, - ) + const renderProviderCard = () => + render( + <ThemeProvider forcedTheme="light"> + <ProviderCard payload={payload} /> + </ThemeProvider>, + ) it('renders provider information, tags, and detail link', () => { renderProviderCard() @@ -86,10 +91,9 @@ describe('ProviderCard', () => { expect(screen.getByTestId('description')).toHaveTextContent('Provider description') expect(screen.getByText('search')).toBeInTheDocument() expect(screen.getByText('rag')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /plugin.detailPanel.operation.detail/i })).toHaveAttribute( - 'href', - '/marketplace/dify/provider-one?language=en-US&theme=system', - ) + expect( + screen.getByRole('link', { name: /plugin.detailPanel.operation.detail/i }), + ).toHaveAttribute('href', '/marketplace/dify/provider-one?language=en-US&theme=system') }) it('opens and closes the install modal', () => { diff --git a/web/app/components/plugins/__tests__/use-plugins-with-latest-version.spec.ts b/web/app/components/plugins/__tests__/use-plugins-with-latest-version.spec.ts index 741fea0f07d8c2..0aca3286a70f8a 100644 --- a/web/app/components/plugins/__tests__/use-plugins-with-latest-version.spec.ts +++ b/web/app/components/plugins/__tests__/use-plugins-with-latest-version.spec.ts @@ -57,13 +57,13 @@ describe('usePluginsWithLatestVersion', () => { }) it('should disable latest-version querying when there are no marketplace plugins', () => { - const plugins = [ - createPlugin({ plugin_id: 'github-plugin', source: PluginSource.github }), - ] + const plugins = [createPlugin({ plugin_id: 'github-plugin', source: PluginSource.github })] const { result } = renderHook(() => usePluginsWithLatestVersion(plugins)) - expect(consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions).toHaveBeenCalledWith({ + expect( + consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions, + ).toHaveBeenCalledWith({ input: { body: { plugin_ids: [] } }, enabled: false, }) @@ -117,7 +117,9 @@ describe('usePluginsWithLatestVersion', () => { const { result } = renderHook(() => usePluginsWithLatestVersion(plugins)) - expect(consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions).toHaveBeenCalledWith({ + expect( + consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions, + ).toHaveBeenCalledWith({ input: { body: { plugin_ids: ['plugin-1'] } }, enabled: true, }) diff --git a/web/app/components/plugins/__tests__/utils.spec.ts b/web/app/components/plugins/__tests__/utils.spec.ts index 37f6051a8e6912..1323792f6d27f3 100644 --- a/web/app/components/plugins/__tests__/utils.spec.ts +++ b/web/app/components/plugins/__tests__/utils.spec.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest' import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config' import { getPluginCardIconUrl } from '../utils' -const createPlugin = (overrides: Partial<Pick<Plugin, 'from' | 'name' | 'org' | 'type'>> = {}): Pick<Plugin, 'from' | 'name' | 'org' | 'type'> => ({ +const createPlugin = ( + overrides: Partial<Pick<Plugin, 'from' | 'name' | 'org' | 'type'>> = {}, +): Pick<Plugin, 'from' | 'name' | 'org' | 'type'> => ({ from: 'github', name: 'demo-plugin', org: 'langgenius', @@ -18,15 +20,25 @@ describe('plugins/utils', () => { }) it('returns absolute urls and root-relative urls as-is', () => { - expect(getPluginCardIconUrl(createPlugin(), 'https://example.com/icon.png', 'tenant-1')).toBe('https://example.com/icon.png') - expect(getPluginCardIconUrl(createPlugin(), '/icons/demo.png', 'tenant-1')).toBe('/icons/demo.png') + expect(getPluginCardIconUrl(createPlugin(), 'https://example.com/icon.png', 'tenant-1')).toBe( + 'https://example.com/icon.png', + ) + expect(getPluginCardIconUrl(createPlugin(), '/icons/demo.png', 'tenant-1')).toBe( + '/icons/demo.png', + ) }) it('builds the marketplace icon url for plugins and bundles', () => { - expect(getPluginCardIconUrl(createPlugin({ from: 'marketplace' }), 'icon.png', 'tenant-1')) - .toBe(`${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon`) - expect(getPluginCardIconUrl(createPlugin({ from: 'marketplace', type: 'bundle' }), 'icon.png', 'tenant-1')) - .toBe(`${MARKETPLACE_API_PREFIX}/bundles/langgenius/demo-plugin/icon`) + expect( + getPluginCardIconUrl(createPlugin({ from: 'marketplace' }), 'icon.png', 'tenant-1'), + ).toBe(`${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon`) + expect( + getPluginCardIconUrl( + createPlugin({ from: 'marketplace', type: 'bundle' }), + 'icon.png', + 'tenant-1', + ), + ).toBe(`${MARKETPLACE_API_PREFIX}/bundles/langgenius/demo-plugin/icon`) }) it('falls back to the raw icon when tenant id is missing for non-marketplace plugins', () => { @@ -34,8 +46,9 @@ describe('plugins/utils', () => { }) it('builds the workspace icon url for tenant-scoped plugins', () => { - expect(getPluginCardIconUrl(createPlugin(), 'icon.png', 'tenant-1')) - .toBe(`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=tenant-1&filename=icon.png`) + expect(getPluginCardIconUrl(createPlugin(), 'icon.png', 'tenant-1')).toBe( + `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=tenant-1&filename=icon.png`, + ) }) }) }) diff --git a/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx b/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx index 52bb72c113b7cb..559b425ccf9ef9 100644 --- a/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx +++ b/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx @@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import DeprecationNotice from '../deprecation-notice' vi.mock('@/next/link', () => ({ - default: ({ children, href }: { children: React.ReactNode, href: string }) => ( - <a data-testid="link" href={href}>{children}</a> + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + <a data-testid="link" href={href}> + {children} + </a> ), })) diff --git a/web/app/components/plugins/base/__tests__/key-value-item.spec.tsx b/web/app/components/plugins/base/__tests__/key-value-item.spec.tsx index fd0964c90d419b..86cec41594cb15 100644 --- a/web/app/components/plugins/base/__tests__/key-value-item.spec.tsx +++ b/web/app/components/plugins/base/__tests__/key-value-item.spec.tsx @@ -7,12 +7,10 @@ vi.mock('../../../base/icons/src/vender/line/files', () => ({ })) vi.mock('@/app/components/base/action-button', () => ({ - default: ({ - children, - onClick, - ...props - }: React.ButtonHTMLAttributes<HTMLButtonElement>) => ( - <button data-testid="action-button" onClick={onClick} {...props}>{children}</button> + default: ({ children, onClick, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => ( + <button data-testid="action-button" onClick={onClick} {...props}> + {children} + </button> ), })) diff --git a/web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx b/web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx index 22cfb337c77542..ba617b4900789d 100644 --- a/web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx +++ b/web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx @@ -4,11 +4,15 @@ import { Theme } from '@/types/app' import IconWithTooltip from '../icon-with-tooltip' const MockLightIcon = ({ className }: { className?: string }) => ( - <div data-testid="light-icon" className={className}>Light Icon</div> + <div data-testid="light-icon" className={className}> + Light Icon + </div> ) const MockDarkIcon = ({ className }: { className?: string }) => ( - <div data-testid="dark-icon" className={className}>Dark Icon</div> + <div data-testid="dark-icon" className={className}> + Dark Icon + </div> ) describe('IconWithTooltip', () => { diff --git a/web/app/components/plugins/base/badges/__tests__/partner.spec.tsx b/web/app/components/plugins/base/badges/__tests__/partner.spec.tsx index 1685564018a329..5b93546a4cdabb 100644 --- a/web/app/components/plugins/base/badges/__tests__/partner.spec.tsx +++ b/web/app/components/plugins/base/badges/__tests__/partner.spec.tsx @@ -21,7 +21,10 @@ vi.mock('../icon-with-tooltip', () => ({ const Icon = isDark ? BadgeIconDark : BadgeIconLight return ( <div data-testid="icon-with-tooltip" data-popup-content={popupContent} data-theme={theme}> - <Icon className={className} data-testid={isDark ? 'partner-dark-icon' : 'partner-light-icon'} /> + <Icon + className={className} + data-testid={isDark ? 'partner-dark-icon' : 'partner-light-icon'} + /> </div> ) }, @@ -30,13 +33,17 @@ vi.mock('../icon-with-tooltip', () => ({ // Mock Partner icons vi.mock('@/app/components/base/icons/src/public/plugins/PartnerDark', () => ({ default: ({ className, ...rest }: { className?: string }) => ( - <div data-testid="partner-dark-icon" className={className} {...rest}>PartnerDark</div> + <div data-testid="partner-dark-icon" className={className} {...rest}> + PartnerDark + </div> ), })) vi.mock('@/app/components/base/icons/src/public/plugins/PartnerLight', () => ({ default: ({ className, ...rest }: { className?: string }) => ( - <div data-testid="partner-light-icon" className={className} {...rest}>PartnerLight</div> + <div data-testid="partner-light-icon" className={className} {...rest}> + PartnerLight + </div> ), })) diff --git a/web/app/components/plugins/base/badges/__tests__/verified.spec.tsx b/web/app/components/plugins/base/badges/__tests__/verified.spec.tsx index 809922a80186a4..a294895d7918b6 100644 --- a/web/app/components/plugins/base/badges/__tests__/verified.spec.tsx +++ b/web/app/components/plugins/base/badges/__tests__/verified.spec.tsx @@ -15,7 +15,12 @@ vi.mock('@/hooks/use-theme', () => ({ })) vi.mock('../icon-with-tooltip', () => ({ - default: ({ popupContent, BadgeIconLight, BadgeIconDark, theme }: { + default: ({ + popupContent, + BadgeIconLight, + BadgeIconDark, + theme, + }: { popupContent: string BadgeIconLight: React.FC BadgeIconDark: React.FC diff --git a/web/app/components/plugins/base/badges/icon-with-tooltip.tsx b/web/app/components/plugins/base/badges/icon-with-tooltip.tsx index f1b30b6a8c40f8..8c8f2bc8c6f5b2 100644 --- a/web/app/components/plugins/base/badges/icon-with-tooltip.tsx +++ b/web/app/components/plugins/base/badges/icon-with-tooltip.tsx @@ -23,16 +23,12 @@ const IconWithTooltip: FC<IconWithTooltipProps> = ({ const iconClassName = cn('size-5', className) const Icon = isDark ? BadgeIconDark : BadgeIconLight const icon = ( - <span - aria-label={popupContent} - className="flex shrink-0 items-center justify-center" - > + <span aria-label={popupContent} className="flex shrink-0 items-center justify-center"> <Icon className={iconClassName} /> </span> ) - if (!popupContent) - return icon + if (!popupContent) return icon return ( <Tooltip> diff --git a/web/app/components/plugins/base/badges/partner.tsx b/web/app/components/plugins/base/badges/partner.tsx index 32fea3c030ff8d..6d97d3b4898065 100644 --- a/web/app/components/plugins/base/badges/partner.tsx +++ b/web/app/components/plugins/base/badges/partner.tsx @@ -9,10 +9,7 @@ type PartnerProps = { text: string } -const Partner: FC<PartnerProps> = ({ - className, - text, -}) => { +const Partner: FC<PartnerProps> = ({ className, text }) => { const { theme } = useTheme() return ( diff --git a/web/app/components/plugins/base/badges/verified.tsx b/web/app/components/plugins/base/badges/verified.tsx index 3c46b68e433611..7e68761d7fbc2d 100644 --- a/web/app/components/plugins/base/badges/verified.tsx +++ b/web/app/components/plugins/base/badges/verified.tsx @@ -9,10 +9,7 @@ type VerifiedProps = { text: string } -const Verified: FC<VerifiedProps> = ({ - className, - text, -}) => { +const Verified: FC<VerifiedProps> = ({ className, text }) => { const { theme } = useTheme() return ( diff --git a/web/app/components/plugins/base/deprecation-notice.tsx b/web/app/components/plugins/base/deprecation-notice.tsx index 27161cce1c37fc..4bebda2dae424b 100644 --- a/web/app/components/plugins/base/deprecation-notice.tsx +++ b/web/app/components/plugins/base/deprecation-notice.tsx @@ -22,7 +22,11 @@ type DeprecationNoticeProps = { const i18nPrefix = 'detailPanel.deprecation' type DeprecatedReasonKey = 'businessAdjustments' | 'ownershipTransferred' | 'noMaintainer' -const validReasonKeys: DeprecatedReasonKey[] = ['businessAdjustments', 'ownershipTransferred', 'noMaintainer'] +const validReasonKeys: DeprecatedReasonKey[] = [ + 'businessAdjustments', + 'ownershipTransferred', + 'noMaintainer', +] function isValidReasonKey(key: string): key is DeprecatedReasonKey { return (validReasonKeys as string[]).includes(key) @@ -41,67 +45,68 @@ const DeprecationNotice: FC<DeprecationNoticeProps> = ({ const { t } = useTranslation('plugin') const deprecatedReasonKey = useMemo(() => { - if (!deprecatedReason) - return null + if (!deprecatedReason) return null const key = camelCase(deprecatedReason) - if (isValidReasonKey(key)) - return key + if (isValidReasonKey(key)) return key return null }, [deprecatedReason]) // Check if the deprecatedReasonKey exists in i18n const hasValidDeprecatedReason = deprecatedReasonKey !== null - if (status !== 'deleted') - return null + if (status !== 'deleted') return null return ( <div className={cn('w-full', className)}> - <div className={cn( - 'relative flex items-start gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]', - innerWrapperClassName, - )} + <div + className={cn( + 'relative flex items-start gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]', + innerWrapperClassName, + )} > <div className="absolute top-0 left-0 -z-10 size-full bg-toast-warning-bg opacity-40" /> - <div className={cn('flex size-6 shrink-0 items-center justify-center', iconWrapperClassName)}> + <div + className={cn('flex size-6 shrink-0 items-center justify-center', iconWrapperClassName)} + > <RiAlertFill className="size-4 text-text-warning-secondary" /> </div> <div className={cn('grow py-1 system-xs-regular text-text-primary', textClassName)}> - { - hasValidDeprecatedReason && alternativePluginId && ( - <Trans - t={t} - i18nKey={$ => $[`${i18nPrefix}.fullMessage`]} - ns="plugin" - components={{ - CustomLink: ( - <Link - href={alternativePluginURL} - target="_blank" - rel="noopener noreferrer" - className="underline" - /> - ), - }} - values={{ - deprecatedReason: deprecatedReasonKey ? t($ => $[`${i18nPrefix}.reason.${deprecatedReasonKey}`], { ns: 'plugin' }) : '', - alternativePluginId, - }} - /> - ) - } - { - hasValidDeprecatedReason && !alternativePluginId && ( - <span> - {t($ => $[`${i18nPrefix}.onlyReason`], { ns: 'plugin', deprecatedReason: deprecatedReasonKey ? t($ => $[`${i18nPrefix}.reason.${deprecatedReasonKey}`], { ns: 'plugin' }) : '' })} - </span> - ) - } - { - !hasValidDeprecatedReason && ( - <span>{t($ => $[`${i18nPrefix}.noReason`], { ns: 'plugin' })}</span> - ) - } + {hasValidDeprecatedReason && alternativePluginId && ( + <Trans + t={t} + i18nKey={($) => $[`${i18nPrefix}.fullMessage`]} + ns="plugin" + components={{ + CustomLink: ( + <Link + href={alternativePluginURL} + target="_blank" + rel="noopener noreferrer" + className="underline" + /> + ), + }} + values={{ + deprecatedReason: deprecatedReasonKey + ? t(($) => $[`${i18nPrefix}.reason.${deprecatedReasonKey}`], { ns: 'plugin' }) + : '', + alternativePluginId, + }} + /> + )} + {hasValidDeprecatedReason && !alternativePluginId && ( + <span> + {t(($) => $[`${i18nPrefix}.onlyReason`], { + ns: 'plugin', + deprecatedReason: deprecatedReasonKey + ? t(($) => $[`${i18nPrefix}.reason.${deprecatedReasonKey}`], { ns: 'plugin' }) + : '', + })} + </span> + )} + {!hasValidDeprecatedReason && ( + <span>{t(($) => $[`${i18nPrefix}.noReason`], { ns: 'plugin' })}</span> + )} </div> </div> </div> diff --git a/web/app/components/plugins/base/key-value-item.tsx b/web/app/components/plugins/base/key-value-item.tsx index 09c5d452fb0422..3da2420113fd3f 100644 --- a/web/app/components/plugins/base/key-value-item.tsx +++ b/web/app/components/plugins/base/key-value-item.tsx @@ -42,28 +42,40 @@ const KeyValueItem: FC<Props> = ({ } }, [isCopied]) - const copyLabel = t($ => $[`operation.${isCopied ? 'copied' : 'copy'}`], { ns: 'common' }) + const copyLabel = t(($) => $[`operation.${isCopied ? 'copied' : 'copy'}`], { ns: 'common' }) return ( <div className="flex items-center gap-1"> - <span className={cn('flex flex-col items-start justify-center system-xs-medium text-text-tertiary', labelWidthClassName)}>{label}</span> + <span + className={cn( + 'flex flex-col items-start justify-center system-xs-medium text-text-tertiary', + labelWidthClassName, + )} + > + {label} + </span> <div className="flex items-center justify-center gap-0.5"> - <span className={cn(valueMaxWidthClassName, 'truncate system-xs-medium text-text-secondary')}> + <span + className={cn(valueMaxWidthClassName, 'truncate system-xs-medium text-text-secondary')} + > {maskedValue || value} </span> <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton aria-label={copyLabel} onClick={handleCopy}> - {isCopied - ? <CopyCheck aria-hidden className="size-3.5 shrink-0 text-text-tertiary" /> - : <span aria-hidden className="i-ri-clipboard-line size-3.5 shrink-0 text-text-tertiary" />} + {isCopied ? ( + <CopyCheck aria-hidden className="size-3.5 shrink-0 text-text-tertiary" /> + ) : ( + <span + aria-hidden + className="i-ri-clipboard-line size-3.5 shrink-0 text-text-tertiary" + /> + )} </ActionButton> - )} + } /> - <TooltipContent placement="top"> - {copyLabel} - </TooltipContent> + <TooltipContent placement="top">{copyLabel}</TooltipContent> </Tooltip> </div> </div> diff --git a/web/app/components/plugins/card/__tests__/index.spec.tsx b/web/app/components/plugins/card/__tests__/index.spec.tsx index 51979d44fe8cbe..21b934a7e160c2 100644 --- a/web/app/components/plugins/card/__tests__/index.spec.tsx +++ b/web/app/components/plugins/card/__tests__/index.spec.tsx @@ -22,13 +22,13 @@ vi.mock('@/i18n-config/language', () => ({ })) const mockCategoriesMap: Record<string, { label: string }> = { - 'tool': { label: 'Tool' }, - 'model': { label: 'Model' }, - 'extension': { label: 'Extension' }, + tool: { label: 'Tool' }, + model: { label: 'Model' }, + extension: { label: 'Extension' }, 'agent-strategy': { label: 'Agent' }, - 'datasource': { label: 'Datasource' }, - 'trigger': { label: 'Trigger' }, - 'bundle': { label: 'Bundle' }, + datasource: { label: 'Datasource' }, + trigger: { label: 'Trigger' }, + bundle: { label: 'Bundle' }, } vi.mock('../../hooks', () => ({ @@ -73,16 +73,24 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('@/utils/mcp', () => ({ - shouldUseMcpIcon: (src: unknown) => typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗', + shouldUseMcpIcon: (src: unknown) => + typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗', })) vi.mock('@/app/components/base/app-icon', () => ({ - default: ({ icon, background, innerIcon, size, iconType }: { + default: ({ + icon, + background, + innerIcon, + size, + iconType, + }: { icon?: string background?: string innerIcon?: React.ReactNode @@ -103,28 +111,38 @@ vi.mock('@/app/components/base/app-icon', () => ({ vi.mock('@/app/components/base/icons/src/vender/other', () => ({ Mcp: ({ className }: { className?: string }) => ( - <div data-testid="mcp-icon" className={className}>MCP</div> + <div data-testid="mcp-icon" className={className}> + MCP + </div> ), Group: ({ className }: { className?: string }) => ( - <div data-testid="group-icon" className={className}>Group</div> + <div data-testid="group-icon" className={className}> + Group + </div> ), })) vi.mock('../../../base/icons/src/vender/plugin', () => ({ LeftCorner: ({ className }: { className?: string }) => ( - <div data-testid="left-corner" className={className}>LeftCorner</div> + <div data-testid="left-corner" className={className}> + LeftCorner + </div> ), })) vi.mock('../../base/badges/partner', () => ({ - default: ({ className, text }: { className?: string, text?: string }) => ( - <div data-testid="partner-badge" className={className} title={text}>Partner</div> + default: ({ className, text }: { className?: string; text?: string }) => ( + <div data-testid="partner-badge" className={className} title={text}> + Partner + </div> ), })) vi.mock('../../base/badges/verified', () => ({ - default: ({ className, text }: { className?: string, text?: string }) => ( - <div data-testid="verified-badge" className={className} title={text}>Verified</div> + default: ({ className, text }: { className?: string; text?: string }) => ( + <div data-testid="verified-badge" className={className} title={text}> + Verified + </div> ), })) @@ -136,8 +154,10 @@ vi.mock('@/app/components/base/skeleton', () => ({ SkeletonRectangle: ({ className }: { className?: string }) => ( <div data-testid="skeleton-rectangle" className={className} /> ), - SkeletonRow: ({ children, className }: { children: React.ReactNode, className?: string }) => ( - <div data-testid="skeleton-row" className={className}>{children}</div> + SkeletonRow: ({ children, className }: { children: React.ReactNode; className?: string }) => ( + <div data-testid="skeleton-row" className={className}> + {children} + </div> ), })) @@ -310,9 +330,7 @@ describe('Card', () => { describe('Props', () => { it('should apply custom className', () => { const plugin = createMockPlugin() - const { container } = render( - <Card payload={plugin} className="custom-class" />, - ) + const { container } = render(<Card payload={plugin} className="custom-class" />) expect(container.querySelector('.custom-class')).toBeInTheDocument() }) @@ -361,9 +379,7 @@ describe('Card', () => { it('should render titleLeft when provided', () => { const plugin = createMockPlugin() - render( - <Card payload={plugin} titleLeft={<span data-testid="title-left">v1.0</span>} />, - ) + render(<Card payload={plugin} titleLeft={<span data-testid="title-left">v1.0</span>} />) expect(screen.getByTestId('title-left')).toBeInTheDocument() }) @@ -371,9 +387,7 @@ describe('Card', () => { it('should use custom descriptionLineRows', () => { const plugin = createMockPlugin() - const { container } = render( - <Card payload={plugin} descriptionLineRows={1} />, - ) + const { container } = render(<Card payload={plugin} descriptionLineRows={1} />) // Check for h-4 truncate class when descriptionLineRows is 1 expect(container.querySelector('.h-4.truncate')).toBeInTheDocument() diff --git a/web/app/components/plugins/card/base/__tests__/card-icon.spec.tsx b/web/app/components/plugins/card/base/__tests__/card-icon.spec.tsx index 7eacd1c5ee6b4f..5f02adb209b8f3 100644 --- a/web/app/components/plugins/card/base/__tests__/card-icon.spec.tsx +++ b/web/app/components/plugins/card/base/__tests__/card-icon.spec.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import Icon from '../card-icon' vi.mock('@/app/components/base/app-icon', () => ({ - default: ({ icon, background }: { icon: string, background: string }) => ( + default: ({ icon, background }: { icon: string; background: string }) => ( <div data-testid="app-icon" data-icon={icon} data-bg={background} /> ), })) diff --git a/web/app/components/plugins/card/base/__tests__/corner-mark.spec.tsx b/web/app/components/plugins/card/base/__tests__/corner-mark.spec.tsx index e903c0810631d1..67bcb7508431cc 100644 --- a/web/app/components/plugins/card/base/__tests__/corner-mark.spec.tsx +++ b/web/app/components/plugins/card/base/__tests__/corner-mark.spec.tsx @@ -3,7 +3,9 @@ import { describe, expect, it, vi } from 'vitest' import CornerMark from '../corner-mark' vi.mock('../../../../base/icons/src/vender/plugin', () => ({ - LeftCorner: ({ className }: { className: string }) => <svg data-testid="left-corner" className={className} />, + LeftCorner: ({ className }: { className: string }) => ( + <svg data-testid="left-corner" className={className} /> + ), })) describe('CornerMark', () => { diff --git a/web/app/components/plugins/card/base/__tests__/download-count.spec.tsx b/web/app/components/plugins/card/base/__tests__/download-count.spec.tsx index 6bb52f8528d401..7fe3cb90985010 100644 --- a/web/app/components/plugins/card/base/__tests__/download-count.spec.tsx +++ b/web/app/components/plugins/card/base/__tests__/download-count.spec.tsx @@ -4,8 +4,7 @@ import DownloadCount from '../download-count' vi.mock('@/utils/format', () => ({ formatNumber: (n: number) => { - if (n >= 1000) - return `${(n / 1000).toFixed(1)}k` + if (n >= 1000) return `${(n / 1000).toFixed(1)}k` return String(n) }, })) diff --git a/web/app/components/plugins/card/base/__tests__/placeholder.spec.tsx b/web/app/components/plugins/card/base/__tests__/placeholder.spec.tsx index e5dc9e22702455..44369b9f4eafdc 100644 --- a/web/app/components/plugins/card/base/__tests__/placeholder.spec.tsx +++ b/web/app/components/plugins/card/base/__tests__/placeholder.spec.tsx @@ -7,7 +7,9 @@ vi.mock('../title', () => ({ })) vi.mock('../../../../base/icons/src/vender/other', () => ({ - Group: ({ className }: { className: string }) => <span data-testid="group-icon" className={className} />, + Group: ({ className }: { className: string }) => ( + <span data-testid="group-icon" className={className} /> + ), })) vi.mock('@langgenius/dify-ui/cn', () => ({ diff --git a/web/app/components/plugins/card/base/card-icon.tsx b/web/app/components/plugins/card/base/card-icon.tsx index 868ab336a12718..cf3e16b572629d 100644 --- a/web/app/components/plugins/card/base/card-icon.tsx +++ b/web/app/components/plugins/card/base/card-icon.tsx @@ -19,15 +19,18 @@ const Icon = ({ size = 'large', }: { className?: string - src: string | { - content: string - background: string - } + src: + | string + | { + content: string + background: string + } installed?: boolean installFailed?: boolean size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large' }) => { - const iconClassName = 'flex justify-center items-center gap-2 absolute bottom-[-4px] right-[-4px] w-[18px] h-[18px] rounded-full border-2 border-components-panel-bg' + const iconClassName = + 'flex justify-center items-center gap-2 absolute bottom-[-4px] right-[-4px] w-[18px] h-[18px] rounded-full border-2 border-components-panel-bg' if (typeof src === 'object') { return ( <div className={cn('relative', className)}> @@ -37,7 +40,11 @@ const Icon = ({ icon={src.content} background={src.background} className="rounded-md" - innerIcon={shouldUseMcpIcon(src) ? <Mcp className="size-8 text-text-primary-on-surface" /> : undefined} + innerIcon={ + shouldUseMcpIcon(src) ? ( + <Mcp className="size-8 text-text-primary-on-surface" /> + ) : undefined + } /> </div> ) @@ -45,27 +52,25 @@ const Icon = ({ return ( <div - className={cn('relative shrink-0 rounded-md bg-contain bg-center bg-no-repeat', iconSizeMap[size], className)} + className={cn( + 'relative shrink-0 rounded-md bg-contain bg-center bg-no-repeat', + iconSizeMap[size], + className, + )} style={{ backgroundImage: `url(${src})`, }} > - { - installed - && ( - <div className={cn(iconClassName, 'bg-state-success-solid')}> - <RiCheckLine className="size-3 text-text-primary-on-surface" /> - </div> - ) - } - { - installFailed - && ( - <div className={cn(iconClassName, 'bg-state-destructive-solid')}> - <RiCloseLine className="size-3 text-text-primary-on-surface" /> - </div> - ) - } + {installed && ( + <div className={cn(iconClassName, 'bg-state-success-solid')}> + <RiCheckLine className="size-3 text-text-primary-on-surface" /> + </div> + )} + {installFailed && ( + <div className={cn(iconClassName, 'bg-state-destructive-solid')}> + <RiCloseLine className="size-3 text-text-primary-on-surface" /> + </div> + )} </div> ) } diff --git a/web/app/components/plugins/card/base/corner-mark.tsx b/web/app/components/plugins/card/base/corner-mark.tsx index c60e7b261305f7..0a78fff6f43d66 100644 --- a/web/app/components/plugins/card/base/corner-mark.tsx +++ b/web/app/components/plugins/card/base/corner-mark.tsx @@ -1,11 +1,13 @@ import { cn } from '@langgenius/dify-ui/cn' import { LeftCorner } from '../../../base/icons/src/vender/plugin' -const CornerMark = ({ className, text }: { className?: string, text: string }) => { +const CornerMark = ({ className, text }: { className?: string; text: string }) => { return ( <div className={cn('absolute top-0 right-0 flex pl-[13px]', className)}> <LeftCorner className="text-background-section" /> - <div className="h-5 rounded-tr-xl bg-background-section pr-2 system-2xs-medium-uppercase leading-5 whitespace-nowrap text-text-tertiary">{text}</div> + <div className="h-5 rounded-tr-xl bg-background-section pr-2 system-2xs-medium-uppercase leading-5 whitespace-nowrap text-text-tertiary"> + {text} + </div> </div> ) } diff --git a/web/app/components/plugins/card/base/description.tsx b/web/app/components/plugins/card/base/description.tsx index 681058f5439e78..efc536126f132a 100644 --- a/web/app/components/plugins/card/base/description.tsx +++ b/web/app/components/plugins/card/base/description.tsx @@ -9,21 +9,17 @@ type Props = Readonly<{ descriptionLineRows: number }> -const Description: FC<Props> = ({ - className, - text, - descriptionLineRows, -}) => { +const Description: FC<Props> = ({ className, text, descriptionLineRows }) => { const lineClassName = useMemo(() => { - if (descriptionLineRows === 1) - return 'h-4 truncate' - else if (descriptionLineRows === 2) - return 'h-8 line-clamp-2' - else - return 'h-12 line-clamp-3' + if (descriptionLineRows === 1) return 'h-4 truncate' + else if (descriptionLineRows === 2) return 'h-8 line-clamp-2' + else return 'h-12 line-clamp-3' }, [descriptionLineRows]) return ( - <div className={cn('system-xs-regular text-text-tertiary', lineClassName, className)} title={text}> + <div + className={cn('system-xs-regular text-text-tertiary', lineClassName, className)} + title={text} + > {text} </div> ) diff --git a/web/app/components/plugins/card/base/download-count.tsx b/web/app/components/plugins/card/base/download-count.tsx index 916d59802b3526..159f97c01411d4 100644 --- a/web/app/components/plugins/card/base/download-count.tsx +++ b/web/app/components/plugins/card/base/download-count.tsx @@ -6,9 +6,7 @@ type Props = Readonly<{ downloadCount: number }> -const DownloadCountComponent = ({ - downloadCount, -}: Props) => { +const DownloadCountComponent = ({ downloadCount }: Props) => { return ( <div className="flex items-center space-x-1 text-text-tertiary"> <RiInstallLine className="size-3 shrink-0" /> diff --git a/web/app/components/plugins/card/base/org-info.tsx b/web/app/components/plugins/card/base/org-info.tsx index 1112ea044e1d61..492eaeec9d4daa 100644 --- a/web/app/components/plugins/card/base/org-info.tsx +++ b/web/app/components/plugins/card/base/org-info.tsx @@ -7,12 +7,7 @@ type Props = Readonly<{ packageNameClassName?: string }> -const OrgInfo = ({ - className, - orgName, - packageName, - packageNameClassName, -}: Props) => { +const OrgInfo = ({ className, orgName, packageName, packageNameClassName }: Props) => { return ( <div className={cn('flex h-4 items-center space-x-0.5', className)}> {orgName && ( @@ -21,7 +16,12 @@ const OrgInfo = ({ <span className="shrink-0 system-xs-regular text-text-quaternary">/</span> </> )} - <span className={cn('w-0 shrink-0 grow truncate system-xs-regular text-text-tertiary', packageNameClassName)}> + <span + className={cn( + 'w-0 shrink-0 grow truncate system-xs-regular text-text-tertiary', + packageNameClassName, + )} + > {packageName} </span> </div> diff --git a/web/app/components/plugins/card/base/placeholder.tsx b/web/app/components/plugins/card/base/placeholder.tsx index a686f3fd981da1..cb4db56b6c02d4 100644 --- a/web/app/components/plugins/card/base/placeholder.tsx +++ b/web/app/components/plugins/card/base/placeholder.tsx @@ -1,5 +1,10 @@ import { cn } from '@langgenius/dify-ui/cn' -import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' +import { + SkeletonContainer, + SkeletonPoint, + SkeletonRectangle, + SkeletonRow, +} from '@/app/components/base/skeleton' import { Group } from '../../../base/icons/src/vender/other' import Title from './title' @@ -12,17 +17,11 @@ export const LoadingPlaceholder = ({ className }: { className?: string }) => ( <div className={cn('h-2 rounded-xs bg-text-quaternary opacity-20', className)} /> ) -const Placeholder = ({ - wrapClassName, - loadingFileName, -}: Props) => { +const Placeholder = ({ wrapClassName, loadingFileName }: Props) => { return ( <div className={cn(wrapClassName, 'p-3')}> <SkeletonRow> - <div - className="flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px] - border-components-panel-border bg-background-default p-1 backdrop-blur-xs" - > + <div className="flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-background-default p-1 backdrop-blur-xs"> <div className="flex size-5 items-center justify-center"> <Group className="text-text-tertiary" /> </div> @@ -30,13 +29,11 @@ const Placeholder = ({ <div className="grow"> <SkeletonContainer> <div className="flex h-5 items-center"> - {loadingFileName - ? ( - <Title title={loadingFileName} /> - ) - : ( - <SkeletonRectangle className="w-[260px]" /> - )} + {loadingFileName ? ( + <Title title={loadingFileName} /> + ) : ( + <SkeletonRectangle className="w-[260px]" /> + )} </div> <SkeletonRow className="h-4"> <SkeletonRectangle className="w-[41px]" /> diff --git a/web/app/components/plugins/card/base/title.tsx b/web/app/components/plugins/card/base/title.tsx index 8e2fd36da7091b..4d1758414dc09c 100644 --- a/web/app/components/plugins/card/base/title.tsx +++ b/web/app/components/plugins/card/base/title.tsx @@ -1,8 +1,4 @@ -const Title = ({ - title, -}: { - title: string -}) => { +const Title = ({ title }: { title: string }) => { return ( <div className="truncate system-md-semibold text-text-secondary" title={title}> {title} diff --git a/web/app/components/plugins/card/card-more-info.tsx b/web/app/components/plugins/card/card-more-info.tsx index 23a36e28f531cd..cb0d0581682eac 100644 --- a/web/app/components/plugins/card/card-more-info.tsx +++ b/web/app/components/plugins/card/card-more-info.tsx @@ -8,16 +8,12 @@ type Props = Readonly<{ variant?: 'default' | 'marketplace' }> -const CardMoreInfoComponent = ({ - downloadCount, - tags, - variant = 'default', -}: Props) => { +const CardMoreInfoComponent = ({ downloadCount, tags, variant = 'default' }: Props) => { if (variant === 'marketplace') { return ( <div className="flex min-h-7 items-center py-1"> <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1 overflow-hidden"> - {tags.map(tag => ( + {tags.map((tag) => ( <div key={tag} className={cn( @@ -38,11 +34,13 @@ const CardMoreInfoComponent = ({ return ( <div className="flex h-5 items-center"> {downloadCount !== undefined && <DownloadCount downloadCount={downloadCount} />} - {downloadCount !== undefined && tags && tags.length > 0 && <div className="mx-2 system-xs-regular text-text-quaternary">·</div>} + {downloadCount !== undefined && tags && tags.length > 0 && ( + <div className="mx-2 system-xs-regular text-text-quaternary">·</div> + )} {tags && tags.length > 0 && ( <> <div className="flex h-4 flex-wrap space-x-2 overflow-hidden"> - {tags.map(tag => ( + {tags.map((tag) => ( <div key={tag} className="flex max-w-[120px] space-x-1 overflow-hidden system-xs-regular" diff --git a/web/app/components/plugins/card/index.tsx b/web/app/components/plugins/card/index.tsx index dbbeec31380971..829366d8965367 100644 --- a/web/app/components/plugins/card/index.tsx +++ b/web/app/components/plugins/card/index.tsx @@ -7,9 +7,7 @@ import { useTranslation } from '#i18n' import { useGetLanguage } from '@/context/i18n' import { currentWorkspaceIdAtom } from '@/context/workspace-state' import useTheme from '@/hooks/use-theme' -import { - renderI18nObject, -} from '@/i18n-config' +import { renderI18nObject } from '@/i18n-config' import { Theme } from '@/types/app' import { formatNumber } from '@/utils/format' import Partner from '../base/badges/partner' @@ -24,8 +22,8 @@ import Placeholder from './base/placeholder' import Title from './base/title' export type CardPayload = Omit<Plugin, 'icon' | 'icon_dark'> & { - icon: string | { content: string, background: string } - icon_dark?: string | { content: string, background: string } + icon: string | { content: string; background: string } + icon_dark?: string | { content: string; background: string } } type Props = Readonly<{ @@ -81,16 +79,12 @@ const Card = ({ const wrapClassName = cn( // eslint-disable-next-line tailwindcss/no-unknown-classes -- Used by page feedback tooling to identify plugin cards. 'hover-bg-components-panel-on-panel-item-bg relative overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', - isMarketplaceVariant && 'h-[148px] transition-all group-hover:bg-components-panel-on-panel-item-bg-hover group-hover:shadow-md', + isMarketplaceVariant && + 'h-[148px] transition-all group-hover:bg-components-panel-on-panel-item-bg-hover group-hover:shadow-md', className, ) if (isLoading) { - return ( - <Placeholder - wrapClassName={wrapClassName} - loadingFileName={loadingFileName!} - /> - ) + return <Placeholder wrapClassName={wrapClassName} loadingFileName={loadingFileName!} /> } if (isMarketplaceVariant) { @@ -105,21 +99,38 @@ const Card = ({ <div className="truncate system-md-medium text-text-primary"> {getLocalizedText(label)} </div> - {isPartner && <Partner className="ml-0.5 size-4" text={t($ => $['marketplace.partnerTip'], { ns: 'plugin' })} />} - {verified && <Verified className="ml-0.5 size-4" text={t($ => $['marketplace.verifiedTip'], { ns: 'plugin' })} />} + {isPartner && ( + <Partner + className="ml-0.5 size-4" + text={t(($) => $['marketplace.partnerTip'], { ns: 'plugin' })} + /> + )} + {verified && ( + <Verified + className="ml-0.5 size-4" + text={t(($) => $['marketplace.verifiedTip'], { ns: 'plugin' })} + /> + )} {titleLeft} </div> <div className="flex h-4 min-w-0 items-center gap-2 system-xs-regular text-text-tertiary"> {org && ( <div className="flex min-w-0 items-center gap-1"> - <span className="shrink-0 lowercase">{t($ => $.author, { ns: 'tools' })}</span> + <span className="shrink-0 lowercase"> + {t(($) => $.author, { ns: 'tools' })} + </span> <span className="truncate">{org}</span> </div> )} - {org && payload.install_count !== undefined && <span className="shrink-0 text-text-quaternary">·</span>} + {org && payload.install_count !== undefined && ( + <span className="shrink-0 text-text-quaternary">·</span> + )} {payload.install_count !== undefined && ( <span className="shrink-0"> - {t($ => $.install, { ns: 'plugin', num: formatNumber(payload.install_count) })} + {t(($) => $.install, { + ns: 'plugin', + num: formatNumber(payload.install_count), + })} </span> )} </div> @@ -146,17 +157,21 @@ const Card = ({ <div className="ml-3 w-0 grow"> <div className="flex h-5 items-center"> <Title title={getLocalizedText(label)} /> - {isPartner && <Partner className="ml-0.5 size-4" text={t($ => $['marketplace.partnerTip'], { ns: 'plugin' })} />} - {verified && <Verified className="ml-0.5 size-4" text={t($ => $['marketplace.verifiedTip'], { ns: 'plugin' })} />} - {titleLeft} - {' '} - {/* This can be version badge */} + {isPartner && ( + <Partner + className="ml-0.5 size-4" + text={t(($) => $['marketplace.partnerTip'], { ns: 'plugin' })} + /> + )} + {verified && ( + <Verified + className="ml-0.5 size-4" + text={t(($) => $['marketplace.verifiedTip'], { ns: 'plugin' })} + /> + )} + {titleLeft} {/* This can be version badge */} </div> - <OrgInfo - className="mt-0.5" - orgName={org} - packageName={name} - /> + <OrgInfo className="mt-0.5" orgName={org} packageName={name} /> </div> </div> <Description @@ -166,15 +181,17 @@ const Card = ({ /> {!!footer && <div>{footer}</div>} </div> - {limitedInstall - && ( - <div className="relative flex h-8 items-center gap-x-2 px-3 after:absolute after:inset-0 after:bg-toast-warning-bg after:opacity-40"> - <span aria-hidden className="i-ri-alert-fill size-3 shrink-0 text-text-warning-secondary" /> - <p className="z-10 grow system-xs-regular text-text-secondary"> - {t($ => $['installModal.installWarning'], { ns: 'plugin' })} - </p> - </div> - )} + {limitedInstall && ( + <div className="relative flex h-8 items-center gap-x-2 px-3 after:absolute after:inset-0 after:bg-toast-warning-bg after:opacity-40"> + <span + aria-hidden + className="i-ri-alert-fill size-3 shrink-0 text-text-warning-secondary" + /> + <p className="z-10 grow system-xs-regular text-text-secondary"> + {t(($) => $['installModal.installWarning'], { ns: 'plugin' })} + </p> + </div> + )} </div> ) } diff --git a/web/app/components/plugins/constants.ts b/web/app/components/plugins/constants.ts index f618e68764096d..79cfe452dab160 100644 --- a/web/app/components/plugins/constants.ts +++ b/web/app/components/plugins/constants.ts @@ -21,7 +21,7 @@ export const tagKeys = [ 'other', ] as const -export type TagKey = typeof tagKeys[number] +export type TagKey = (typeof tagKeys)[number] export const categoryKeys = [ PluginCategoryEnum.model, @@ -33,4 +33,4 @@ export const categoryKeys = [ PluginCategoryEnum.trigger, ] as const -export type CategoryKey = typeof categoryKeys[number] +export type CategoryKey = (typeof categoryKeys)[number] diff --git a/web/app/components/plugins/hooks.ts b/web/app/components/plugins/hooks.ts index 4ca6276aa3b056..6bd07c908042e8 100644 --- a/web/app/components/plugins/hooks.ts +++ b/web/app/components/plugins/hooks.ts @@ -4,10 +4,7 @@ import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' -import { - categoryKeys, - tagKeys, -} from './constants' +import { categoryKeys, tagKeys } from './constants' import { PluginCategoryEnum, PluginSource } from './types' export type Tag = { @@ -22,22 +19,24 @@ export const useTags = () => { return tagKeys.map((tag) => { return { name: tag, - label: t($ => $[`tags.${tag}`], { ns: 'pluginTags' }), + label: t(($) => $[`tags.${tag}`], { ns: 'pluginTags' }), } }) }, [t]) const tagsMap = useMemo(() => { - return tags.reduce((acc, tag) => { - acc[tag.name] = tag - return acc - }, {} as Record<string, Tag>) + return tags.reduce( + (acc, tag) => { + acc[tag.name] = tag + return acc + }, + {} as Record<string, Tag>, + ) }, [tags]) const getTagLabel = useMemo(() => { return (name: string) => { - if (!tagsMap[name]) - return name + if (!tagsMap[name]) return name return tagsMap[name].label } }, [tagsMap]) @@ -62,21 +61,28 @@ export const useCategories = (isSingle?: boolean) => { if (category === PluginCategoryEnum.agent) { return { name: PluginCategoryEnum.agent, - label: isSingle ? t($ => $['categorySingle.agent'], { ns: 'plugin' }) : t($ => $['category.agents'], { ns: 'plugin' }), + label: isSingle + ? t(($) => $['categorySingle.agent'], { ns: 'plugin' }) + : t(($) => $['category.agents'], { ns: 'plugin' }), } } return { name: category, - label: isSingle ? t($ => $[`categorySingle.${category}`], { ns: 'plugin' }) : t($ => $[`category.${category}s`], { ns: 'plugin' }), + label: isSingle + ? t(($) => $[`categorySingle.${category}`], { ns: 'plugin' }) + : t(($) => $[`category.${category}s`], { ns: 'plugin' }), } }) }, [t, isSingle]) const categoriesMap = useMemo(() => { - return categories.reduce((acc, category) => { - acc[category.name] = category - return acc - }, {} as Record<string, Category>) + return categories.reduce( + (acc, category) => { + acc[category.name] = category + return acc + }, + {} as Record<string, Category>, + ) }, [categories]) return { @@ -93,36 +99,39 @@ export const PLUGIN_PAGE_TABS_MAP = { export const usePluginPageTabs = () => { const { t } = useTranslation() const tabs = [ - { value: PLUGIN_PAGE_TABS_MAP.plugins, text: t($ => $['menus.plugins'], { ns: 'common' }) }, - { value: PLUGIN_PAGE_TABS_MAP.marketplace, text: t($ => $['menus.exploreMarketplace'], { ns: 'common' }) }, + { value: PLUGIN_PAGE_TABS_MAP.plugins, text: t(($) => $['menus.plugins'], { ns: 'common' }) }, + { + value: PLUGIN_PAGE_TABS_MAP.marketplace, + text: t(($) => $['menus.exploreMarketplace'], { ns: 'common' }), + }, ] return tabs } const EMPTY_PLUGINS: PluginDetail[] = [] -export function usePluginsWithLatestVersion(plugins: PluginDetail[] = EMPTY_PLUGINS): PluginDetail[] { +export function usePluginsWithLatestVersion( + plugins: PluginDetail[] = EMPTY_PLUGINS, +): PluginDetail[] { const marketplacePluginIds = useMemo( - () => plugins - .filter(p => p.source === PluginSource.marketplace) - .map(p => p.plugin_id), + () => plugins.filter((p) => p.source === PluginSource.marketplace).map((p) => p.plugin_id), [plugins], ) - const { data: latestVersionData } = useQuery(consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions({ - input: { body: { plugin_ids: marketplacePluginIds } }, - enabled: !!marketplacePluginIds.length, - })) + const { data: latestVersionData } = useQuery( + consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions({ + input: { body: { plugin_ids: marketplacePluginIds } }, + enabled: !!marketplacePluginIds.length, + }), + ) return useMemo(() => { const versions = latestVersionData?.versions - if (!versions) - return plugins + if (!versions) return plugins return plugins.map((plugin) => { const info = versions[plugin.plugin_id] - if (!info) - return plugin + if (!info) return plugin return { ...plugin, latest_version: info.version, diff --git a/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts b/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts index 385b9e01e1c2a5..3fb39c7492a92f 100644 --- a/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts +++ b/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts @@ -32,14 +32,15 @@ describe('install-plugin/hooks', () => { it('fetches releases from GitHub API and formats them', async () => { mockFetch.mockResolvedValue({ ok: true, - json: () => Promise.resolve({ - releases: [ - { - tag: 'v1.0.0', - assets: [{ downloadUrl: 'https://example.com/plugin.zip' }], - }, - ], - }), + json: () => + Promise.resolve({ + releases: [ + { + tag: 'v1.0.0', + assets: [{ downloadUrl: 'https://example.com/plugin.zip' }], + }, + ], + }), }) const releases = await fetchReleases('owner', 'repo') @@ -74,9 +75,7 @@ describe('install-plugin/hooks', () => { }) it('returns no update when current is latest', () => { - const releases = [ - { tag_name: 'v1.0.0', assets: [] }, - ] + const releases = [{ tag_name: 'v1.0.0', assets: [] }] const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0') expect(needUpdate).toBe(false) expect(toastProps.type).toBe('info') @@ -122,9 +121,7 @@ describe('install-plugin/hooks', () => { it('shows toast on upload error', async () => { mockUploadGitHub.mockRejectedValue(new Error('Upload failed')) - await expect( - handleUpload('url', 'v1', 'pkg'), - ).rejects.toThrow('Upload failed') + await expect(handleUpload('url', 'v1', 'pkg')).rejects.toThrow('Upload failed') expect(mockNotify).toHaveBeenCalledWith('Error uploading package') }) }) diff --git a/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts b/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts index b13ebffe2f280b..85ac53f5469d42 100644 --- a/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts +++ b/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts @@ -11,16 +11,16 @@ import { // Mock es-toolkit/compat vi.mock('es-toolkit/compat', () => ({ isEmpty: (obj: unknown) => { - if (obj === null || obj === undefined) - return true - if (typeof obj === 'object') - return Object.keys(obj).length === 0 + if (obj === null || obj === undefined) return true + if (typeof obj === 'object') return Object.keys(obj).length === 0 return false }, })) describe('pluginManifestToCardPluginProps', () => { - const createMockPluginDeclaration = (overrides?: Partial<PluginDeclaration>): PluginDeclaration => ({ + const createMockPluginDeclaration = ( + overrides?: Partial<PluginDeclaration>, + ): PluginDeclaration => ({ plugin_unique_identifier: 'test-plugin-123', version: '1.0.0', author: 'test-author', @@ -93,11 +93,7 @@ describe('pluginManifestToCardPluginProps', () => { }) const result = pluginManifestToCardPluginProps(manifest) - expect(result.tags).toEqual([ - { name: 'search' }, - { name: 'image' }, - { name: 'api' }, - ]) + expect(result.tags).toEqual([{ name: 'search' }, { name: 'image' }, { name: 'api' }]) }) it('should handle empty tags array', () => { @@ -204,7 +200,9 @@ describe('pluginManifestToCardPluginProps', () => { }) describe('pluginManifestInMarketToPluginProps', () => { - const createMockPluginManifestInMarket = (overrides?: Partial<PluginManifestInMarket>): PluginManifestInMarket => ({ + const createMockPluginManifestInMarket = ( + overrides?: Partial<PluginManifestInMarket>, + ): PluginManifestInMarket => ({ plugin_unique_identifier: 'market-plugin-123', name: 'market-plugin', org: 'market-org', diff --git a/web/app/components/plugins/install-plugin/base/__tests__/check-task-status.spec.ts b/web/app/components/plugins/install-plugin/base/__tests__/check-task-status.spec.ts index 7b33dff8098510..4d95f87f857bf3 100644 --- a/web/app/components/plugins/install-plugin/base/__tests__/check-task-status.spec.ts +++ b/web/app/components/plugins/install-plugin/base/__tests__/check-task-status.spec.ts @@ -39,7 +39,11 @@ describe('checkTaskStatus', () => { mockCheckTaskStatus.mockResolvedValue({ task: { plugins: [ - { plugin_unique_identifier: 'test-plugin', status: TaskStatus.failed, message: 'Install failed' }, + { + plugin_unique_identifier: 'test-plugin', + status: TaskStatus.failed, + message: 'Install failed', + }, ], }, }) @@ -139,8 +143,14 @@ describe('checkTaskStatus', () => { }, }) - const result1 = await checker1.check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' }) - const result2 = await checker2.check({ taskId: 'task-2', pluginUniqueIdentifier: 'test-plugin' }) + const result1 = await checker1.check({ + taskId: 'task-1', + pluginUniqueIdentifier: 'test-plugin', + }) + const result2 = await checker2.check({ + taskId: 'task-2', + pluginUniqueIdentifier: 'test-plugin', + }) expect(result1.status).toBe(TaskStatus.success) expect(result2.status).toBe(TaskStatus.success) diff --git a/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx b/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx index 2ca570b01a41c6..201f544aaebe1a 100644 --- a/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx +++ b/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx @@ -4,8 +4,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '../../../types' vi.mock('../../../card', () => ({ - default: ({ installed, installFailed, titleLeft }: { installed: boolean, installFailed: boolean, titleLeft?: React.ReactNode }) => ( - <div data-testid="card" data-installed={installed} data-failed={installFailed}>{titleLeft}</div> + default: ({ + installed, + installFailed, + titleLeft, + }: { + installed: boolean + installFailed: boolean + titleLeft?: React.ReactNode + }) => ( + <div data-testid="card" data-installed={installed} data-failed={installFailed}> + {titleLeft} + </div> ), })) @@ -74,7 +84,14 @@ describe('Installed', () => { name: 'test-plugin', version: '1.0.0', } as never - render(<Installed payload={payload} installContextCategory={PluginCategoryEnum.extension} isFailed={false} onCancel={mockOnCancel} />) + render( + <Installed + payload={payload} + installContextCategory={PluginCategoryEnum.extension} + isFailed={false} + onCancel={mockOnCancel} + />, + ) fireEvent.click(screen.getByText('common.operation.close')) @@ -89,14 +106,26 @@ describe('Installed', () => { name: 'test-plugin', version: '1.0.0', } as never - render(<Installed payload={payload} installContextCategory={PluginCategoryEnum.trigger} isFailed={false} onCancel={mockOnCancel} />) - - expect(screen.getByText('plugin.installModal.installedSuccessfullyWithPageDesc')).toBeInTheDocument() + render( + <Installed + payload={payload} + installContextCategory={PluginCategoryEnum.trigger} + isFailed={false} + onCancel={mockOnCancel} + />, + ) + + expect( + screen.getByText('plugin.installModal.installedSuccessfullyWithPageDesc'), + ).toBeInTheDocument() fireEvent.click(screen.getByText('plugin.installModal.viewDetails')) expect(mockOnCancel).toHaveBeenCalled() - expect(screen.getByText('plugin.installModal.viewDetails').closest('a')).toHaveAttribute('href', '/integrations/extension') + expect(screen.getByText('plugin.installModal.viewDetails').closest('a')).toHaveAttribute( + 'href', + '/integrations/extension', + ) expect(document.querySelector('.i-ri-arrow-right-up-line')).toBeInTheDocument() }) diff --git a/web/app/components/plugins/install-plugin/base/__tests__/loading-error.spec.tsx b/web/app/components/plugins/install-plugin/base/__tests__/loading-error.spec.tsx index 656f76191cd4e8..5cc12be4fcd429 100644 --- a/web/app/components/plugins/install-plugin/base/__tests__/loading-error.spec.tsx +++ b/web/app/components/plugins/install-plugin/base/__tests__/loading-error.spec.tsx @@ -7,7 +7,9 @@ vi.mock('@/app/components/plugins/card/base/placeholder', () => ({ })) vi.mock('../../../../base/icons/src/vender/other', () => ({ - Group: ({ className }: { className: string }) => <span data-testid="group-icon" className={className} />, + Group: ({ className }: { className: string }) => ( + <span data-testid="group-icon" className={className} /> + ), })) describe('LoadingError', () => { diff --git a/web/app/components/plugins/install-plugin/base/__tests__/use-get-icon.spec.ts b/web/app/components/plugins/install-plugin/base/__tests__/use-get-icon.spec.ts index bf95fe49349439..68d9e6248e1189 100644 --- a/web/app/components/plugins/install-plugin/base/__tests__/use-get-icon.spec.ts +++ b/web/app/components/plugins/install-plugin/base/__tests__/use-get-icon.spec.ts @@ -27,8 +27,7 @@ vi.mock('@/context/system-features-state', () => ({ vi.mock('jotai', () => { return { useAtomValue: (atom: unknown) => { - if (atom === mockCurrentWorkspaceIdAtom) - return 'workspace-123' + if (atom === mockCurrentWorkspaceIdAtom) return 'workspace-123' throw new Error('Unexpected atom') }, diff --git a/web/app/components/plugins/install-plugin/base/__tests__/version.spec.tsx b/web/app/components/plugins/install-plugin/base/__tests__/version.spec.tsx index bc61d660919072..58607d53bb0c1e 100644 --- a/web/app/components/plugins/install-plugin/base/__tests__/version.spec.tsx +++ b/web/app/components/plugins/install-plugin/base/__tests__/version.spec.tsx @@ -18,25 +18,13 @@ describe('Version', () => { }) it('should show upgrade version badge for existing install', () => { - render( - <Version - hasInstalled={true} - installedVersion="1.0.0" - toInstallVersion="2.0.0" - />, - ) + render(<Version hasInstalled={true} installedVersion="1.0.0" toInstallVersion="2.0.0" />) expect(screen.getByText('1.0.0 -> 2.0.0')).toBeInTheDocument() }) it('should handle downgrade version display', () => { - render( - <Version - hasInstalled={true} - installedVersion="2.0.0" - toInstallVersion="1.0.0" - />, - ) + render(<Version hasInstalled={true} installedVersion="2.0.0" toInstallVersion="1.0.0" />) expect(screen.getByText('2.0.0 -> 1.0.0')).toBeInTheDocument() }) diff --git a/web/app/components/plugins/install-plugin/base/check-task-status.ts b/web/app/components/plugins/install-plugin/base/check-task-status.ts index 68ec0cd309214c..1a9c9a3f246157 100644 --- a/web/app/components/plugins/install-plugin/base/check-task-status.ts +++ b/web/app/components/plugins/install-plugin/base/check-task-status.ts @@ -4,7 +4,8 @@ import { sleep } from '@/utils' import { TaskStatus } from '../../types' const INTERVAL = 10 * 1000 // 10 seconds -const isUnfinishedStatus = (status: TaskStatus) => status === TaskStatus.pending || status === TaskStatus.running +const isUnfinishedStatus = (status: TaskStatus) => + status === TaskStatus.pending || status === TaskStatus.running type Params = { taskId: string @@ -15,10 +16,7 @@ function checkTaskStatus() { let nextStatus = TaskStatus.running let isStop = false - const doCheckStatus = async ({ - taskId, - pluginUniqueIdentifier, - }: Params) => { + const doCheckStatus = async ({ taskId, pluginUniqueIdentifier }: Params) => { if (isStop) { return { status: TaskStatus.success, @@ -26,7 +24,9 @@ function checkTaskStatus() { } const res = await fetchCheckTaskStatus(taskId) const { plugins } = res.task - const plugin = plugins.find((p: PluginStatus) => p.plugin_unique_identifier === pluginUniqueIdentifier) + const plugin = plugins.find( + (p: PluginStatus) => p.plugin_unique_identifier === pluginUniqueIdentifier, + ) if (!plugin) { nextStatus = TaskStatus.failed return { @@ -48,9 +48,9 @@ function checkTaskStatus() { error: plugin.message, } } - return ({ + return { status: TaskStatus.success, - }) + } } return { diff --git a/web/app/components/plugins/install-plugin/base/installed.tsx b/web/app/components/plugins/install-plugin/base/installed.tsx index c87b59dcf7fd1b..9e607992247055 100644 --- a/web/app/components/plugins/install-plugin/base/installed.tsx +++ b/web/app/components/plugins/install-plugin/base/installed.tsx @@ -21,7 +21,13 @@ type Props = Readonly<{ }> type CategoryTarget = { - labelKey: 'menus.tools' | 'settings.agentStrategy' | 'settings.dataSource' | 'settings.extension' | 'settings.provider' | 'settings.trigger' + labelKey: + | 'menus.tools' + | 'settings.agentStrategy' + | 'settings.dataSource' + | 'settings.extension' + | 'settings.provider' + | 'settings.trigger' path: string } @@ -62,10 +68,11 @@ const Installed: FC<Props> = ({ }) => { const { t } = useTranslation('plugin') const installedCategory = payload?.category - const categoryTarget = !isFailed && installContextCategory && installedCategory !== installContextCategory - ? categoryTargetMap[installedCategory as PluginCategoryEnum] - : undefined - const categoryName = categoryTarget ? t($ => $[categoryTarget.labelKey], { ns: 'common' }) : '' + const categoryTarget = + !isFailed && installContextCategory && installedCategory !== installContextCategory + ? categoryTargetMap[installedCategory as PluginCategoryEnum] + : undefined + const categoryName = categoryTarget ? t(($) => $[categoryTarget.labelKey], { ns: 'common' }) : '' const handleClose = () => { onCancel() @@ -74,30 +81,43 @@ const Installed: FC<Props> = ({ <> <div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3"> <p className="system-md-regular text-text-secondary"> - {(isFailed && errMsg) - ? errMsg - : categoryTarget - ? ( - <Trans - t={t} - i18nKey={$ => $['installModal.installedSuccessfullyWithPageDesc']} - ns="plugin" - components={{ - categoryName: <span className="system-sm-semibold text-text-secondary" />, - }} - values={{ categoryName }} - /> - ) - : t($ => $[`installModal.${isFailed ? 'installFailedDesc' : 'installedSuccessfullyDesc'}`], { ns: 'plugin' })} + {isFailed && errMsg ? ( + errMsg + ) : categoryTarget ? ( + <Trans + t={t} + i18nKey={($) => $['installModal.installedSuccessfullyWithPageDesc']} + ns="plugin" + components={{ + categoryName: <span className="system-sm-semibold text-text-secondary" />, + }} + values={{ categoryName }} + /> + ) : ( + t( + ($) => + $[`installModal.${isFailed ? 'installFailedDesc' : 'installedSuccessfullyDesc'}`], + { ns: 'plugin' }, + ) + )} </p> {payload && ( <div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2"> <Card className="w-full" - payload={isMarketPayload ? pluginManifestInMarketToPluginProps(payload as PluginManifestInMarket) : pluginManifestToCardPluginProps(payload as PluginDeclaration)} + payload={ + isMarketPayload + ? pluginManifestInMarketToPluginProps(payload as PluginManifestInMarket) + : pluginManifestToCardPluginProps(payload as PluginDeclaration) + } installed={!isFailed} installFailed={isFailed} - titleLeft={<Badge className="mx-1" size="s" state={BadgeState.Default}>{(payload as PluginDeclaration).version || (payload as PluginManifestInMarket).latest_version}</Badge>} + titleLeft={ + <Badge className="mx-1" size="s" state={BadgeState.Default}> + {(payload as PluginDeclaration).version || + (payload as PluginManifestInMarket).latest_version} + </Badge> + } compact /> </div> @@ -111,14 +131,14 @@ const Installed: FC<Props> = ({ render={categoryTarget ? <Link href={categoryTarget.path} /> : undefined} onClick={handleClose} > - {categoryTarget - ? ( - <> - <span>{t($ => $['installModal.viewDetails'], { ns: 'plugin' })}</span> - <span className="i-ri-arrow-right-up-line size-4 shrink-0" aria-hidden="true" /> - </> - ) - : t($ => $['operation.close'], { ns: 'common' })} + {categoryTarget ? ( + <> + <span>{t(($) => $['installModal.viewDetails'], { ns: 'plugin' })}</span> + <span className="i-ri-arrow-right-up-line size-4 shrink-0" aria-hidden="true" /> + </> + ) : ( + t(($) => $['operation.close'], { ns: 'common' }) + )} </Button> </div> </> diff --git a/web/app/components/plugins/install-plugin/base/loading-error.tsx b/web/app/components/plugins/install-plugin/base/loading-error.tsx index 592c45d70a8655..d2a4669d333ced 100644 --- a/web/app/components/plugins/install-plugin/base/loading-error.tsx +++ b/web/app/components/plugins/install-plugin/base/loading-error.tsx @@ -11,15 +11,10 @@ const LoadingError: FC = () => { const { t } = useTranslation() return ( <div className="flex items-center space-x-2"> - <CheckboxSkeleton - className="shrink-0" - /> + <CheckboxSkeleton className="shrink-0" /> <div className="relative grow rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs"> <div className="flex"> - <div - className="relative flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px] - border-state-destructive-border bg-state-destructive-hover p-1 backdrop-blur-xs" - > + <div className="relative flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px] border-state-destructive-border bg-state-destructive-hover p-1 backdrop-blur-xs"> <div className="flex size-5 items-center justify-center"> <Group className="text-text-quaternary" /> </div> @@ -29,10 +24,10 @@ const LoadingError: FC = () => { </div> <div className="ml-3 grow"> <div className="flex h-5 items-center system-md-semibold text-text-destructive"> - {t($ => $['installModal.pluginLoadError'], { ns: 'plugin' })} + {t(($) => $['installModal.pluginLoadError'], { ns: 'plugin' })} </div> <div className="mt-0.5 system-xs-regular text-text-tertiary"> - {t($ => $['installModal.pluginLoadErrorDesc'], { ns: 'plugin' })} + {t(($) => $['installModal.pluginLoadErrorDesc'], { ns: 'plugin' })} </div> </div> </div> diff --git a/web/app/components/plugins/install-plugin/base/loading.tsx b/web/app/components/plugins/install-plugin/base/loading.tsx index 50b07c441dd205..8441eb3b6573aa 100644 --- a/web/app/components/plugins/install-plugin/base/loading.tsx +++ b/web/app/components/plugins/install-plugin/base/loading.tsx @@ -6,13 +6,9 @@ import Placeholder from '../../card/base/placeholder' const Loading = () => { return ( <div className="flex items-center space-x-2"> - <CheckboxSkeleton - className="shrink-0" - /> + <CheckboxSkeleton className="shrink-0" /> <div className="relative grow rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs"> - <Placeholder - wrapClassName="w-full" - /> + <Placeholder wrapClassName="w-full" /> </div> </div> ) diff --git a/web/app/components/plugins/install-plugin/base/use-get-icon.ts b/web/app/components/plugins/install-plugin/base/use-get-icon.ts index 76a73a40369e96..f64d1730664207 100644 --- a/web/app/components/plugins/install-plugin/base/use-get-icon.ts +++ b/web/app/components/plugins/install-plugin/base/use-get-icon.ts @@ -5,9 +5,12 @@ import { currentWorkspaceIdAtom } from '@/context/workspace-state' const useGetIcon = () => { const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) - const getIconUrl = useCallback((fileName: string) => { - return `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${currentWorkspaceId}&filename=${fileName}` - }, [currentWorkspaceId]) + const getIconUrl = useCallback( + (fileName: string) => { + return `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${currentWorkspaceId}&filename=${fileName}` + }, + [currentWorkspaceId], + ) return { getIconUrl, diff --git a/web/app/components/plugins/install-plugin/base/version.tsx b/web/app/components/plugins/install-plugin/base/version.tsx index d7bf042ab52b85..ca1b9401c6aff9 100644 --- a/web/app/components/plugins/install-plugin/base/version.tsx +++ b/web/app/components/plugins/install-plugin/base/version.tsx @@ -4,30 +4,24 @@ import type { VersionProps } from '../../types' import * as React from 'react' import Badge, { BadgeState } from '@/app/components/base/badge/index' -const Version: FC<VersionProps> = ({ - hasInstalled, - installedVersion, - toInstallVersion, -}) => { +const Version: FC<VersionProps> = ({ hasInstalled, installedVersion, toInstallVersion }) => { return ( <> - { - !hasInstalled - ? ( - <Badge className="mx-1" size="s" state={BadgeState.Default}>{toInstallVersion}</Badge> - ) - : ( - <> - <Badge className="mx-1" size="s" state={BadgeState.Warning}> - {`${installedVersion} -> ${toInstallVersion}`} - </Badge> - {/* <div className='flex px-0.5 justify-center items-center gap-0.5'> + {!hasInstalled ? ( + <Badge className="mx-1" size="s" state={BadgeState.Default}> + {toInstallVersion} + </Badge> + ) : ( + <> + <Badge className="mx-1" size="s" state={BadgeState.Warning}> + {`${installedVersion} -> ${toInstallVersion}`} + </Badge> + {/* <div className='flex px-0.5 justify-center items-center gap-0.5'> <div className='text-text-warning system-xs-medium'>Used in 3 apps</div> <RiInformation2Line className='w-4 h-4 text-text-tertiary' /> </div> */} - </> - ) - } + </> + )} </> ) } diff --git a/web/app/components/plugins/install-plugin/components/__tests__/plugin-install-permission-provider.spec.tsx b/web/app/components/plugins/install-plugin/components/__tests__/plugin-install-permission-provider.spec.tsx index 453ef2c91b1c7b..d548984a63b253 100644 --- a/web/app/components/plugins/install-plugin/components/__tests__/plugin-install-permission-provider.spec.tsx +++ b/web/app/components/plugins/install-plugin/components/__tests__/plugin-install-permission-provider.spec.tsx @@ -6,11 +6,7 @@ import { } from '../plugin-install-permission-provider' const PermissionProbe = () => { - const { - canInstallPlugin, - canUpdatePlugin, - currentDifyVersion, - } = usePluginInstallPermission() + const { canInstallPlugin, canUpdatePlugin, currentDifyVersion } = usePluginInstallPermission() return ( <div> diff --git a/web/app/components/plugins/install-plugin/components/plugin-install-permission-provider.tsx b/web/app/components/plugins/install-plugin/components/plugin-install-permission-provider.tsx index a9c980d67bc8ee..49fa121a061ae1 100644 --- a/web/app/components/plugins/install-plugin/components/plugin-install-permission-provider.tsx +++ b/web/app/components/plugins/install-plugin/components/plugin-install-permission-provider.tsx @@ -54,8 +54,7 @@ export const PluginInstallPermissionProviderGuard = ({ }: PluginInstallPermissionProviderProps) => { const store = use(PluginInstallPermissionContext) - if (store) - return children + if (store) return children return ( <PluginInstallPermissionProvider diff --git a/web/app/components/plugins/install-plugin/hooks.ts b/web/app/components/plugins/install-plugin/hooks.ts index e7086a7fba0b5f..f6b0abf28dd045 100644 --- a/web/app/components/plugins/install-plugin/hooks.ts +++ b/web/app/components/plugins/install-plugin/hooks.ts @@ -22,25 +22,25 @@ export const fetchReleases = async (owner: string, repo: string) => { try { // Fetch releases without authentication from client const res = await fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`) - if (!res.ok) - throw new Error('Failed to fetch repository releases') + if (!res.ok) throw new Error('Failed to fetch repository releases') const data = await res.json() return formatReleases(data.releases) - } - catch (error) { + } catch (error) { if (error instanceof Error) { toast.error(error.message) - } - else { + } else { toast.error('Failed to fetch repository releases') } return [] } } -export const checkForUpdates = (fetchedReleases: GitHubRepoReleaseResponse[], currentVersion: string) => { +export const checkForUpdates = ( + fetchedReleases: GitHubRepoReleaseResponse[], + currentVersion: string, +) => { let needUpdate = false - const toastProps: { type?: 'success' | 'error' | 'info' | 'warning', message: string } = { + const toastProps: { type?: 'success' | 'error' | 'info' | 'warning'; message: string } = { type: 'info', message: 'No new version available', } @@ -49,14 +49,12 @@ export const checkForUpdates = (fetchedReleases: GitHubRepoReleaseResponse[], cu toastProps.message = 'Input releases is empty' return { needUpdate, toastProps } } - const versions = fetchedReleases.map(release => release.tag_name) + const versions = fetchedReleases.map((release) => release.tag_name) const latestVersion = getLatestVersion(versions) try { needUpdate = compareVersion(latestVersion!, currentVersion) === 1 - if (needUpdate) - toastProps.message = `New version available: ${latestVersion}` - } - catch { + if (needUpdate) toastProps.message = `New version available: ${latestVersion}` + } catch { needUpdate = false toastProps.type = 'error' toastProps.message = 'Fail to compare versions, please check the version format' @@ -68,7 +66,7 @@ export const handleUpload = async ( repoUrl: string, selectedVersion: string, selectedPackage: string, - onSuccess?: (GitHubPackage: { manifest: any, unique_identifier: string }) => void, + onSuccess?: (GitHubPackage: { manifest: any; unique_identifier: string }) => void, ) => { try { const response = await uploadGitHub(repoUrl, selectedVersion, selectedPackage) @@ -76,11 +74,9 @@ export const handleUpload = async ( manifest: response.manifest, unique_identifier: response.unique_identifier, } - if (onSuccess) - onSuccess(GitHubPackage) + if (onSuccess) onSuccess(GitHubPackage) return GitHubPackage - } - catch (error) { + } catch (error) { toast.error('Error uploading package') throw error } diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx b/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx index 232856e65161a2..43234bf84ec8d7 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx @@ -18,7 +18,7 @@ const mockPlugins = [ ] vi.mock('@/service/use-plugins', () => ({ - useCheckInstalled: ({ pluginIds, enabled }: { pluginIds: string[], enabled: boolean }) => ({ + useCheckInstalled: ({ pluginIds, enabled }: { pluginIds: string[]; enabled: boolean }) => ({ data: enabled && pluginIds.length > 0 ? { plugins: mockPlugins } : undefined, isLoading: false, error: null, @@ -31,10 +31,12 @@ describe('useCheckInstalled', () => { }) it('should return installed info when enabled and has plugin IDs', () => { - const { result } = renderHook(() => useCheckInstalled({ - pluginIds: ['plugin-1', 'plugin-2'], - enabled: true, - })) + const { result } = renderHook(() => + useCheckInstalled({ + pluginIds: ['plugin-1', 'plugin-2'], + enabled: true, + }), + ) expect(result.current.installedInfo).toBeDefined() expect(result.current.installedInfo?.['plugin-1']).toEqual({ @@ -50,28 +52,34 @@ describe('useCheckInstalled', () => { }) it('should return undefined installedInfo when disabled', () => { - const { result } = renderHook(() => useCheckInstalled({ - pluginIds: ['plugin-1'], - enabled: false, - })) + const { result } = renderHook(() => + useCheckInstalled({ + pluginIds: ['plugin-1'], + enabled: false, + }), + ) expect(result.current.installedInfo).toBeUndefined() }) it('should return undefined installedInfo with empty plugin IDs', () => { - const { result } = renderHook(() => useCheckInstalled({ - pluginIds: [], - enabled: true, - })) + const { result } = renderHook(() => + useCheckInstalled({ + pluginIds: [], + enabled: true, + }), + ) expect(result.current.installedInfo).toBeUndefined() }) it('should return isLoading and error states', () => { - const { result } = renderHook(() => useCheckInstalled({ - pluginIds: ['plugin-1'], - enabled: true, - })) + const { result } = renderHook(() => + useCheckInstalled({ + pluginIds: ['plugin-1'], + enabled: true, + }), + ) expect(result.current.isLoading).toBe(false) expect(result.current.error).toBeNull() diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-fold-anim-into.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-fold-anim-into.spec.ts index a128c1f16f18b4..3cb3ba732952d0 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-fold-anim-into.spec.ts +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-fold-anim-into.spec.ts @@ -13,8 +13,9 @@ describe('useFoldAnimInto', () => { afterEach(() => { vi.useRealTimers() - document.querySelectorAll('.install-modal, #plugin-task-trigger, .plugins-nav-button') - .forEach(el => el.remove()) + document + .querySelectorAll('.install-modal, #plugin-task-trigger, .plugins-nav-button') + .forEach((el) => el.remove()) }) it('should return modalClassName and functions', async () => { diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts index a3e8c22b2f0292..ba1a1795dbaeea 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts @@ -129,7 +129,10 @@ describe('pluginInstallLimit', () => { describe('usePluginInstallLimit', () => { it('should return canInstall from pluginInstallLimit using global store', async () => { const { default: usePluginInstallLimit } = await import('../use-install-plugin-limit') - const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } } + const plugin = { + from: 'marketplace' as const, + verification: { authorized_category: 'langgenius' }, + } const { result } = renderHook(() => usePluginInstallLimit(plugin as never)) @@ -138,12 +141,14 @@ describe('usePluginInstallLimit', () => { it('should return a loading install limit state while system features are pending', async () => { const { default: usePluginInstallLimit } = await import('../use-install-plugin-limit') - const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } } + const plugin = { + from: 'marketplace' as const, + verification: { authorized_category: 'langgenius' }, + } - const { result } = renderHook( - () => usePluginInstallLimit(plugin as never), - { systemFeatures: null }, - ) + const { result } = renderHook(() => usePluginInstallLimit(plugin as never), { + systemFeatures: null, + }) expect(result.current.canInstall).toBe(false) expect(result.current.isLoading).toBe(true) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-plugin-install-permission.spec.tsx b/web/app/components/plugins/install-plugin/hooks/__tests__/use-plugin-install-permission.spec.tsx index bba2c65f9af74c..77c37fed2038ec 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-plugin-install-permission.spec.tsx +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-plugin-install-permission.spec.tsx @@ -28,9 +28,7 @@ describe('usePluginInstallPermission', () => { currentDifyVersion: '1.2.3', }) const wrapper = ({ children }: { children: ReactNode }) => ( - <PluginInstallPermissionContext value={store}> - {children} - </PluginInstallPermissionContext> + <PluginInstallPermissionContext value={store}>{children}</PluginInstallPermissionContext> ) const { result } = renderHook(() => usePluginInstallPermission(), { wrapper }) @@ -44,7 +42,7 @@ describe('usePluginInstallPermission', () => { it('should throw when provider is missing', () => { expect(() => { - renderHook(() => usePluginInstallPermissionStore(state => state.canInstallPlugin)) + renderHook(() => usePluginInstallPermissionStore((state) => state.canInstallPlugin)) }).toThrow('Missing PluginInstallPermissionProvider in the tree') }) }) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts index 44400aa9cc772d..bc784aac817865 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts @@ -24,17 +24,23 @@ vi.mock('@/service/use-plugins', () => ({ })) vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({ - ModelTypeEnum: { textGeneration: 'llm', textEmbedding: 'text-embedding', rerank: 'rerank', speech2text: 'speech2text', tts: 'tts' }, + ModelTypeEnum: { + textGeneration: 'llm', + textEmbedding: 'text-embedding', + rerank: 'rerank', + speech2text: 'speech2text', + tts: 'tts', + }, })) vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useModelList: (type: string) => { const map: Record<string, { mutate: ReturnType<typeof vi.fn> }> = { - 'llm': { mutate: mockRefetchLLMModelList }, + llm: { mutate: mockRefetchLLMModelList }, 'text-embedding': { mutate: mockRefetchEmbeddingModelList }, - 'rerank': { mutate: mockRefetchRerankModelList }, - 'speech2text': { mutate: mockRefetchSpeech2textModelList }, - 'tts': { mutate: mockRefetchTTSModelList }, + rerank: { mutate: mockRefetchRerankModelList }, + speech2text: { mutate: mockRefetchSpeech2textModelList }, + tts: { mutate: mockRefetchTTSModelList }, } return map[type] ?? { mutate: vi.fn() } }, diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-workspace-plugin-install-permission.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-workspace-plugin-install-permission.spec.ts index e592d8c509b020..a9b654163ff79c 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-workspace-plugin-install-permission.spec.ts +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-workspace-plugin-install-permission.spec.ts @@ -81,7 +81,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) diff --git a/web/app/components/plugins/install-plugin/hooks/use-check-installed.tsx b/web/app/components/plugins/install-plugin/hooks/use-check-installed.tsx index 899bb5c7d78d49..089de741c2268f 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-check-installed.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-check-installed.tsx @@ -1,5 +1,4 @@ import type { VersionInfo } from '../../types' - import { useMemo } from 'react' import { useCheckInstalled as useDoCheckInstalled } from '@/service/use-plugins' @@ -11,8 +10,7 @@ const useCheckInstalled = (props: Props) => { const { data, isLoading, error } = useDoCheckInstalled(props) const installedInfo = useMemo(() => { - if (!data) - return undefined + if (!data) return undefined const res: Record<string, VersionInfo> = {} data?.plugins.forEach((plugin) => { diff --git a/web/app/components/plugins/install-plugin/hooks/use-fold-anim-into.ts b/web/app/components/plugins/install-plugin/hooks/use-fold-anim-into.ts index 4b8d5e82936a1b..dd29c6d4613015 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-fold-anim-into.ts +++ b/web/app/components/plugins/install-plugin/hooks/use-fold-anim-into.ts @@ -21,7 +21,9 @@ const useFoldAnimInto = (onClose: () => void) => { const foldIntoAnim = async () => { clearCountDown() const modalElem = document.querySelector(`.${modalClassName}`) as HTMLElement - const pluginTaskTriggerElem = document.getElementById('plugin-task-trigger') || document.querySelector('.plugins-nav-button') + const pluginTaskTriggerElem = + document.getElementById('plugin-task-trigger') || + document.querySelector('.plugins-nav-button') if (!modalElem || !pluginTaskTriggerElem) { onClose() diff --git a/web/app/components/plugins/install-plugin/hooks/use-hide-logic.ts b/web/app/components/plugins/install-plugin/hooks/use-hide-logic.ts index 5a1c6a81255c56..bdb4da8aa3a73e 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-hide-logic.ts +++ b/web/app/components/plugins/install-plugin/hooks/use-hide-logic.ts @@ -10,11 +10,13 @@ const useHideLogic = (onClose: () => void) => { } = useFoldAnimInto(onClose) const [isInstalling, doSetIsInstalling] = useState(false) - const setIsInstalling = useCallback((isInstalling: boolean) => { - if (!isInstalling) - clearCountDown() - doSetIsInstalling(isInstalling) - }, [clearCountDown]) + const setIsInstalling = useCallback( + (isInstalling: boolean) => { + if (!isInstalling) clearCountDown() + doSetIsInstalling(isInstalling) + }, + [clearCountDown], + ) const foldAnimInto = useCallback(() => { if (isInstalling) { diff --git a/web/app/components/plugins/install-plugin/hooks/use-install-from-marketplace-query.ts b/web/app/components/plugins/install-plugin/hooks/use-install-from-marketplace-query.ts index ee2150b6337274..881f740eb9f930 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-install-from-marketplace-query.ts +++ b/web/app/components/plugins/install-plugin/hooks/use-install-from-marketplace-query.ts @@ -28,11 +28,9 @@ export const useInstallFromMarketplaceQuery = ({ const [isShowInstallFromMarketplace, setIsShowInstallFromMarketplace] = useState(false) useEffect(() => { - if (!packageId && !bundleInfo) - return + if (!packageId && !bundleInfo) return - if (isPermissionLoading) - return + if (isPermissionLoading) return if (!canInstallPlugin) { setInstallState(null) @@ -45,13 +43,11 @@ export const useInstallFromMarketplaceQuery = ({ if (packageId) { try { const { data } = await fetchManifestFromMarketPlace(encodeURIComponent(packageId)) - if (ignore) - return + if (ignore) return const { plugin, version } = data const redirected = onPackageCategoryResolved?.(plugin.category, packageId) - if (redirected) - return + if (redirected) return setMarketplaceInstall({ uniqueIdentifier: packageId, @@ -62,10 +58,8 @@ export const useInstallFromMarketplaceQuery = ({ }, }) setIsShowInstallFromMarketplace(true) - } - catch (error) { - if (!ignore) - console.error('Failed to load marketplace plugin manifest:', error) + } catch (error) { + if (!ignore) console.error('Failed to load marketplace plugin manifest:', error) } return } @@ -73,15 +67,12 @@ export const useInstallFromMarketplaceQuery = ({ if (bundleInfo) { try { const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo) - if (ignore) - return + if (ignore) return setDependencies(data.version.dependencies) setIsShowInstallFromMarketplace(true) - } - catch (error) { - if (!ignore) - console.error('Failed to load bundle info:', error) + } catch (error) { + if (!ignore) console.error('Failed to load bundle info:', error) } } } @@ -91,7 +82,14 @@ export const useInstallFromMarketplaceQuery = ({ return () => { ignore = true } - }, [bundleInfo, canInstallPlugin, isPermissionLoading, onPackageCategoryResolved, packageId, setInstallState]) + }, [ + bundleInfo, + canInstallPlugin, + isPermissionLoading, + onPackageCategoryResolved, + packageId, + setInstallState, + ]) const hideInstallFromMarketplace = () => { setMarketplaceInstall(null) @@ -105,6 +103,7 @@ export const useInstallFromMarketplaceQuery = ({ dependencies, hideInstallFromMarketplace, isShowInstallFromMarketplace, - marketplaceInstall: marketplaceInstall?.uniqueIdentifier === packageId ? marketplaceInstall : null, + marketplaceInstall: + marketplaceInstall?.uniqueIdentifier === packageId ? marketplaceInstall : null, } } diff --git a/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx b/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx index 1311525fc64390..5121d0601aab8d 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx @@ -4,7 +4,9 @@ import { useQuery } from '@tanstack/react-query' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { InstallationScope } from '@/features/system-features/constants' -type PluginProps = (Plugin | PluginManifestInMarket) & { from: 'github' | 'marketplace' | 'package' } +type PluginProps = (Plugin | PluginManifestInMarket) & { + from: 'github' | 'marketplace' | 'package' +} type PluginInstallLimitResult = { canInstall: boolean isLoading: boolean @@ -12,16 +14,21 @@ type PluginInstallLimitResult = { export function pluginInstallLimit(plugin: PluginProps, systemFeatures: GetSystemFeaturesResponse) { if (systemFeatures.plugin_installation_permission.restrict_to_marketplace_only) { - if (plugin.from === 'github' || plugin.from === 'package') - return { canInstall: false } + if (plugin.from === 'github' || plugin.from === 'package') return { canInstall: false } } - if (systemFeatures.plugin_installation_permission.plugin_installation_scope === InstallationScope.ALL) { + if ( + systemFeatures.plugin_installation_permission.plugin_installation_scope === + InstallationScope.ALL + ) { return { canInstall: true, } } - if (systemFeatures.plugin_installation_permission.plugin_installation_scope === InstallationScope.NONE) { + if ( + systemFeatures.plugin_installation_permission.plugin_installation_scope === + InstallationScope.NONE + ) { return { canInstall: false, } @@ -30,14 +37,22 @@ export function pluginInstallLimit(plugin: PluginProps, systemFeatures: GetSyste if (!plugin.verification || !plugin.verification.authorized_category) verification.authorized_category = 'langgenius' - if (systemFeatures.plugin_installation_permission.plugin_installation_scope === InstallationScope.OFFICIAL_ONLY) { + if ( + systemFeatures.plugin_installation_permission.plugin_installation_scope === + InstallationScope.OFFICIAL_ONLY + ) { return { canInstall: verification.authorized_category === 'langgenius', } } - if (systemFeatures.plugin_installation_permission.plugin_installation_scope === InstallationScope.OFFICIAL_AND_PARTNER) { + if ( + systemFeatures.plugin_installation_permission.plugin_installation_scope === + InstallationScope.OFFICIAL_AND_PARTNER + ) { return { - canInstall: verification.authorized_category === 'langgenius' || verification.authorized_category === 'partner', + canInstall: + verification.authorized_category === 'langgenius' || + verification.authorized_category === 'partner', } } return { @@ -46,10 +61,7 @@ export function pluginInstallLimit(plugin: PluginProps, systemFeatures: GetSyste } export default function usePluginInstallLimit(plugin: PluginProps): PluginInstallLimitResult { - const { - data: systemFeatures, - isPending, - } = useQuery(systemFeaturesQueryOptions()) + const { data: systemFeatures, isPending } = useQuery(systemFeaturesQueryOptions()) if (!systemFeatures) { return { canInstall: false, diff --git a/web/app/components/plugins/install-plugin/hooks/use-plugin-install-permission.ts b/web/app/components/plugins/install-plugin/hooks/use-plugin-install-permission.ts index 2bb7f7f45eefd9..ba959a6d0d6e5b 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-plugin-install-permission.ts +++ b/web/app/components/plugins/install-plugin/hooks/use-plugin-install-permission.ts @@ -13,7 +13,8 @@ type PluginInstallPermissionAction = { setPluginInstallPermission: (state: PluginInstallPermissionState) => void } -export type PluginInstallPermissionStoreState = PluginInstallPermissionState & PluginInstallPermissionAction +export type PluginInstallPermissionStoreState = PluginInstallPermissionState & + PluginInstallPermissionAction export type PluginInstallPermissionStore = StoreApi<PluginInstallPermissionStoreState> @@ -22,33 +23,39 @@ const defaultPluginInstallPermissionState: PluginInstallPermissionState = { canUpdatePlugin: true, } -export const createPluginInstallPermissionStore = (initProps?: Partial<PluginInstallPermissionState>) => { - const canInstallPlugin = initProps?.canInstallPlugin ?? defaultPluginInstallPermissionState.canInstallPlugin +export const createPluginInstallPermissionStore = ( + initProps?: Partial<PluginInstallPermissionState>, +) => { + const canInstallPlugin = + initProps?.canInstallPlugin ?? defaultPluginInstallPermissionState.canInstallPlugin - return createStore<PluginInstallPermissionStoreState>()(set => ({ + return createStore<PluginInstallPermissionStoreState>()((set) => ({ ...defaultPluginInstallPermissionState, ...initProps, canUpdatePlugin: initProps?.canUpdatePlugin ?? canInstallPlugin, - setPluginInstallPermission: state => set(() => state), + setPluginInstallPermission: (state) => set(() => state), })) } const fallbackPluginInstallPermissionStore = createPluginInstallPermissionStore() -export const PluginInstallPermissionContext = createContext<PluginInstallPermissionStore | null>(null) +export const PluginInstallPermissionContext = createContext<PluginInstallPermissionStore | null>( + null, +) -export const usePluginInstallPermissionStore = <T>(selector: (state: PluginInstallPermissionStoreState) => T): T => { +export const usePluginInstallPermissionStore = <T>( + selector: (state: PluginInstallPermissionStoreState) => T, +): T => { const store = use(PluginInstallPermissionContext) - if (!store) - throw new Error('Missing PluginInstallPermissionProvider in the tree') + if (!store) throw new Error('Missing PluginInstallPermissionProvider in the tree') return useStore(store, selector) } const usePluginInstallPermission = () => { - const canInstallPlugin = usePluginInstallPermissionStore(state => state.canInstallPlugin) - const canUpdatePlugin = usePluginInstallPermissionStore(state => state.canUpdatePlugin) - const currentDifyVersion = usePluginInstallPermissionStore(state => state.currentDifyVersion) + const canInstallPlugin = usePluginInstallPermissionStore((state) => state.canInstallPlugin) + const canUpdatePlugin = usePluginInstallPermissionStore((state) => state.canUpdatePlugin) + const currentDifyVersion = usePluginInstallPermissionStore((state) => state.currentDifyVersion) return { canInstallPlugin, @@ -59,9 +66,9 @@ const usePluginInstallPermission = () => { export const useOptionalPluginInstallPermission = () => { const store = use(PluginInstallPermissionContext) ?? fallbackPluginInstallPermissionStore - const canInstallPlugin = useStore(store, state => state.canInstallPlugin) - const canUpdatePlugin = useStore(store, state => state.canUpdatePlugin) - const currentDifyVersion = useStore(store, state => state.currentDifyVersion) + const canInstallPlugin = useStore(store, (state) => state.canInstallPlugin) + const canUpdatePlugin = useStore(store, (state) => state.canUpdatePlugin) + const currentDifyVersion = useStore(store, (state) => state.currentDifyVersion) return { canInstallPlugin, diff --git a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx index f02dcf7e81955d..9c29cc8e8cf595 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx @@ -1,12 +1,19 @@ import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useInvalidateDefaultModel, useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { + useInvalidateDefaultModel, + useModelList, +} from '@/app/components/header/account-setting/model-provider-page/hooks' import { useProviderContext } from '@/context/provider-context' import { useInvalidDataSourceListAuth } from '@/service/use-datasource' import { useInvalidDataSourceList } from '@/service/use-pipeline' import { useInvalidateInstalledPluginList } from '@/service/use-plugins' import { useInvalidateStrategyProviders } from '@/service/use-strategy' -import { useInvalidateAllBuiltInTools, useInvalidateAllToolProviders, useInvalidateRAGRecommendedPlugins } from '@/service/use-tools' +import { + useInvalidateAllBuiltInTools, + useInvalidateAllToolProviders, + useInvalidateRAGRecommendedPlugins, +} from '@/service/use-tools' import { useInvalidateAllTriggerPlugins } from '@/service/use-triggers' import { PluginCategoryEnum } from '../../types' @@ -44,7 +51,10 @@ const useRefreshPluginList = () => { const invalidateRAGRecommendedPlugins = useInvalidateRAGRecommendedPlugins() return { - refreshPluginList: (manifest?: PluginManifestInMarket | Plugin | PluginDeclaration | PluginCategoryPayload | null, refreshAllType?: boolean) => { + refreshPluginList: ( + manifest?: PluginManifestInMarket | Plugin | PluginDeclaration | PluginCategoryPayload | null, + refreshAllType?: boolean, + ) => { // installed list invalidateInstalledPluginList() @@ -59,7 +69,10 @@ const useRefreshPluginList = () => { if ((manifest && PluginCategoryEnum.trigger.includes(manifest.category)) || refreshAllType) invalidateAllTriggerPlugins() - if ((manifest && PluginCategoryEnum.datasource.includes(manifest.category)) || refreshAllType) { + if ( + (manifest && PluginCategoryEnum.datasource.includes(manifest.category)) || + refreshAllType + ) { invalidateAllDataSources() invalidateDataSourceListAuth() } @@ -72,7 +85,7 @@ const useRefreshPluginList = () => { refetchRerankModelList() refetchSpeech2textModelList() refetchTTSModelList() - SYSTEM_MODEL_TYPES.forEach(type => invalidateDefaultModel(type)) + SYSTEM_MODEL_TYPES.forEach((type) => invalidateDefaultModel(type)) } // agent select diff --git a/web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx index 160d216f5a5449..8d77dd319dbb2c 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx @@ -1,4 +1,12 @@ -import type { Dependency, GitHubItemAndMarketPlaceDependency, InstallStatus, PackageDependency, Plugin, PluginDeclaration, VersionProps } from '../../../types' +import type { + Dependency, + GitHubItemAndMarketPlaceDependency, + InstallStatus, + PackageDependency, + Plugin, + PluginDeclaration, + VersionProps, +} from '../../../types' import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' @@ -86,13 +94,14 @@ const createMockPackageDependency = (): PackageDependency => ({ }, }) -const createMockDependency = (overrides: Partial<Dependency> = {}): Dependency => ({ - type: 'marketplace', - value: { - plugin_unique_identifier: 'test-plugin-uid', - }, - ...overrides, -} as Dependency) +const createMockDependency = (overrides: Partial<Dependency> = {}): Dependency => + ({ + type: 'marketplace', + value: { + plugin_unique_identifier: 'test-plugin-uid', + }, + ...overrides, + }) as Dependency const createMockDependencies = (): Dependency[] => [ { @@ -164,7 +173,8 @@ vi.mock('../../hooks/use-install-plugin-limit', () => ({ // Mock useUploadGitHub hook const mockUseUploadGitHub = vi.fn() vi.mock('@/service/use-plugins', () => ({ - useUploadGitHub: (params: { repo: string, version: string, package: string }) => mockUseUploadGitHub(params), + useUploadGitHub: (params: { repo: string; version: string; package: string }) => + mockUseUploadGitHub(params), useInstallOrUpdate: () => ({ mutate: vi.fn(), isPending: false }), usePluginTaskList: () => ({ handleRefetch: vi.fn() }), useFetchPluginsInMarketPlaceByInfo: () => ({ isLoading: false, data: null, error: null }), @@ -224,13 +234,33 @@ vi.mock('../ready-to-install', () => ({ <div data-testid="ready-to-install"> <span data-testid="current-step">{step}</span> <span data-testid="plugins-count">{allPlugins?.length || 0}</span> - <button data-testid="start-install-btn" onClick={onStartToInstall}>Start Install</button> - <button data-testid="set-installing-true" onClick={() => setIsInstalling(true)}>Set Installing True</button> - <button data-testid="set-installing-false" onClick={() => setIsInstalling(false)}>Set Installing False</button> - <button data-testid="change-to-installed" onClick={() => onStepChange(InstallStep.installed)}>Change to Installed</button> - <button data-testid="change-to-upload-failed" onClick={() => onStepChange(InstallStep.uploadFailed)}>Change to Upload Failed</button> - <button data-testid="change-to-ready" onClick={() => onStepChange(InstallStep.readyToInstall)}>Change to Ready</button> - <button data-testid="close-btn" onClick={onClose}>Close</button> + <button data-testid="start-install-btn" onClick={onStartToInstall}> + Start Install + </button> + <button data-testid="set-installing-true" onClick={() => setIsInstalling(true)}> + Set Installing True + </button> + <button data-testid="set-installing-false" onClick={() => setIsInstalling(false)}> + Set Installing False + </button> + <button data-testid="change-to-installed" onClick={() => onStepChange(InstallStep.installed)}> + Change to Installed + </button> + <button + data-testid="change-to-upload-failed" + onClick={() => onStepChange(InstallStep.uploadFailed)} + > + Change to Upload Failed + </button> + <button + data-testid="change-to-ready" + onClick={() => onStepChange(InstallStep.readyToInstall)} + > + Change to Ready + </button> + <button data-testid="close-btn" onClick={onClose}> + Close + </button> </div> ), })) @@ -264,7 +294,9 @@ describe('InstallBundle', () => { it('should constrain modal height to the viewport', () => { render(<InstallBundle {...defaultProps} />) - expect(screen.getByText('plugin.installModal.installPlugin').parentElement?.parentElement).toHaveClass('max-h-[calc(100dvh-48px)]') + expect( + screen.getByText('plugin.installModal.installPlugin').parentElement?.parentElement, + ).toHaveClass('max-h-[calc(100dvh-48px)]') }) it('should render ReadyToInstall component', () => { @@ -497,9 +529,7 @@ describe('InstallBundle', () => { const onClose = vi.fn() const payload = createMockDependencies() - const { rerender } = render( - <InstallBundle fromDSLPayload={payload} onClose={onClose} />, - ) + const { rerender } = render(<InstallBundle fromDSLPayload={payload} onClose={onClose} />) // Re-render with same props reference rerender(<InstallBundle fromDSLPayload={payload} onClose={onClose} />) @@ -650,9 +680,7 @@ describe('InstallBundle', () => { const types = [InstallType.fromLocal, InstallType.fromMarketplace, InstallType.fromDSL] types.forEach((type) => { - const { unmount } = render( - <InstallBundle {...defaultProps} installType={type} />, - ) + const { unmount } = render(<InstallBundle {...defaultProps} installType={type} />) expect(screen.getByTestId('ready-to-install')).toBeInTheDocument() unmount() }) @@ -709,7 +737,12 @@ describe('InstallBundle', () => { }) it('should use default installType when not provided', () => { - render(<InstallBundle fromDSLPayload={defaultProps.fromDSLPayload} onClose={defaultProps.onClose} />) + render( + <InstallBundle + fromDSLPayload={defaultProps.fromDSLPayload} + onClose={defaultProps.onClose} + />, + ) // Default is fromMarketplace which results in readyToInstall expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall) @@ -983,7 +1016,9 @@ describe('Installed', () => { it('should not render close button when isHideButton is true', () => { render(<Installed {...defaultInstalledProps} isHideButton={true} />) - expect(screen.queryByRole('button', { name: 'common.operation.close' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.close' }), + ).not.toBeInTheDocument() }) }) @@ -1053,7 +1088,8 @@ describe('LoadedItem', () => { vi.clearAllMocks() }) - const getCheckbox = () => screen.getByRole('checkbox', { name: defaultLoadedItemProps.payload.name }) + const getCheckbox = () => + screen.getByRole('checkbox', { name: defaultLoadedItemProps.payload.name }) // ================================ // Rendering Tests @@ -1139,7 +1175,8 @@ describe('MarketplaceItem', () => { vi.clearAllMocks() }) - const getCheckbox = () => screen.getByRole('checkbox', { name: defaultMarketplaceItemProps.payload.name }) + const getCheckbox = () => + screen.getByRole('checkbox', { name: defaultMarketplaceItemProps.payload.name }) // ================================ // Rendering Tests diff --git a/web/app/components/plugins/install-plugin/install-bundle/__tests__/ready-to-install.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/__tests__/ready-to-install.spec.tsx index ba0ad8fb4b36a5..bd0a8a49f33186 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/__tests__/ready-to-install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/__tests__/ready-to-install.spec.tsx @@ -5,7 +5,9 @@ import { InstallStep } from '../../../types' import ReadyToInstall from '../ready-to-install' // Track the onInstalled callback from the Install component -let capturedOnInstalled: ((plugins: Plugin[], installStatus: InstallStatus[], versionInfo: VersionProps[]) => void) | null = null +let capturedOnInstalled: + | ((plugins: Plugin[], installStatus: InstallStatus[], versionInfo: VersionProps[]) => void) + | null = null vi.mock('../steps/install', () => ({ default: ({ @@ -18,7 +20,11 @@ vi.mock('../steps/install', () => ({ allPlugins: Dependency[] onCancel: () => void onStartToInstall: () => void - onInstalled: (plugins: Plugin[], installStatus: InstallStatus[], versionInfo: VersionProps[]) => void + onInstalled: ( + plugins: Plugin[], + installStatus: InstallStatus[], + versionInfo: VersionProps[], + ) => void isFromMarketPlace?: boolean }) => { capturedOnInstalled = onInstalled @@ -26,15 +32,21 @@ vi.mock('../steps/install', () => ({ <div data-testid="install-step"> <span data-testid="install-plugins-count">{allPlugins?.length}</span> <span data-testid="install-from-marketplace">{String(!!isFromMarketPlace)}</span> - <button data-testid="install-cancel-btn" onClick={onCancel}>Cancel</button> - <button data-testid="install-start-btn" onClick={onStartToInstall}>Start</button> + <button data-testid="install-cancel-btn" onClick={onCancel}> + Cancel + </button> + <button data-testid="install-start-btn" onClick={onStartToInstall}> + Start + </button> <button data-testid="install-complete-btn" - onClick={() => onInstalled( - [{ plugin_id: 'p1', name: 'Plugin 1' } as Plugin], - [{ success: true, isFromMarketPlace: true }], - [{ hasInstalled: false, toInstallVersion: '1.0.0' }], - )} + onClick={() => + onInstalled( + [{ plugin_id: 'p1', name: 'Plugin 1' } as Plugin], + [{ success: true, isFromMarketPlace: true }], + [{ hasInstalled: false, toInstallVersion: '1.0.0' }], + ) + } > Complete </button> @@ -59,7 +71,9 @@ vi.mock('../steps/installed', () => ({ <span data-testid="installed-count">{list.length}</span> <span data-testid="installed-status-count">{installStatus.length}</span> <span data-testid="installed-version-count">{versionInfo?.length ?? 0}</span> - <button data-testid="installed-close-btn" onClick={onCancel}>Close</button> + <button data-testid="installed-close-btn" onClick={onCancel}> + Close + </button> </div> ), })) @@ -158,12 +172,7 @@ describe('ReadyToInstall', () => { fireEvent.click(screen.getByTestId('install-complete-btn')) // Re-render with step=installed to show Installed component - rerender( - <ReadyToInstall - {...defaultProps} - step={InstallStep.installed} - />, - ) + rerender(<ReadyToInstall {...defaultProps} step={InstallStep.installed} />) expect(screen.getByTestId('installed-step')).toBeInTheDocument() expect(screen.getByTestId('installed-count')).toHaveTextContent('1') @@ -177,10 +186,7 @@ describe('ReadyToInstall', () => { expect(capturedOnInstalled).toBeTruthy() act(() => { capturedOnInstalled!( - [ - { plugin_id: 'p1', name: 'P1' } as Plugin, - { plugin_id: 'p2', name: 'P2' } as Plugin, - ], + [{ plugin_id: 'p1', name: 'P1' } as Plugin, { plugin_id: 'p2', name: 'P2' } as Plugin], [ { success: true, isFromMarketPlace: true }, { success: false, isFromMarketPlace: false }, @@ -196,12 +202,7 @@ describe('ReadyToInstall', () => { expect(mockSetIsInstalling).toHaveBeenCalledWith(false) // Re-render at installed step - rerender( - <ReadyToInstall - {...defaultProps} - step={InstallStep.installed} - />, - ) + rerender(<ReadyToInstall {...defaultProps} step={InstallStep.installed} />) expect(screen.getByTestId('installed-count')).toHaveTextContent('2') expect(screen.getByTestId('installed-status-count')).toHaveTextContent('2') @@ -211,24 +212,14 @@ describe('ReadyToInstall', () => { describe('installed step', () => { it('should render Installed component when step is installed', () => { - render( - <ReadyToInstall - {...defaultProps} - step={InstallStep.installed} - />, - ) + render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} />) expect(screen.queryByTestId('install-step')).not.toBeInTheDocument() expect(screen.getByTestId('installed-step')).toBeInTheDocument() }) it('should pass onClose to Installed component', () => { - render( - <ReadyToInstall - {...defaultProps} - step={InstallStep.installed} - />, - ) + render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} />) fireEvent.click(screen.getByTestId('installed-close-btn')) @@ -236,12 +227,7 @@ describe('ReadyToInstall', () => { }) it('should render empty installed list initially', () => { - render( - <ReadyToInstall - {...defaultProps} - step={InstallStep.installed} - />, - ) + render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} />) expect(screen.getByTestId('installed-count')).toHaveTextContent('0') expect(screen.getByTestId('installed-status-count')).toHaveTextContent('0') @@ -251,10 +237,7 @@ describe('ReadyToInstall', () => { describe('edge cases', () => { it('should render nothing when step is neither readyToInstall nor installed', () => { const { container } = render( - <ReadyToInstall - {...defaultProps} - step={InstallStep.uploading} - />, + <ReadyToInstall {...defaultProps} step={InstallStep.uploading} />, ) expect(screen.queryByTestId('install-step')).not.toBeInTheDocument() @@ -264,12 +247,7 @@ describe('ReadyToInstall', () => { }) it('should handle empty allPlugins array', () => { - render( - <ReadyToInstall - {...defaultProps} - allPlugins={[]} - />, - ) + render(<ReadyToInstall {...defaultProps} allPlugins={[]} />) expect(screen.getByTestId('install-plugins-count')).toHaveTextContent('0') }) diff --git a/web/app/components/plugins/install-plugin/install-bundle/index.tsx b/web/app/components/plugins/install-plugin/install-bundle/index.tsx index a845efa11e3bec..b32670092d80ec 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/index.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/index.tsx @@ -31,42 +31,45 @@ const InstallBundle: FC<Props> = ({ onClose, }) => { const { t } = useTranslation() - const [step, setStep] = useState<InstallStep>(installType === InstallType.fromMarketplace ? InstallStep.readyToInstall : InstallStep.uploading) + const [step, setStep] = useState<InstallStep>( + installType === InstallType.fromMarketplace + ? InstallStep.readyToInstall + : InstallStep.uploading, + ) - const { - modalClassName, - foldAnimInto, - setIsInstalling, - handleStartToInstall, - } = useHideLogic(onClose) + const { modalClassName, foldAnimInto, setIsInstalling, handleStartToInstall } = + useHideLogic(onClose) const getTitle = useCallback(() => { if (step === InstallStep.uploadFailed) - return t($ => $[`${i18nPrefix}.uploadFailed`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.uploadFailed`], { ns: 'plugin' }) if (step === InstallStep.installed) - return t($ => $[`${i18nPrefix}.installedSuccessfully`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.installedSuccessfully`], { ns: 'plugin' }) - return t($ => $[`${i18nPrefix}.installPlugin`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.installPlugin`], { ns: 'plugin' }) }, [step, t]) return ( <Dialog open onOpenChange={(open) => { - if (!open) - foldAnimInto() + if (!open) foldAnimInto() }} > <DialogContent backdropProps={{ forceRender: true }} - className={cn('w-full max-w-[480px] overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))} + className={cn( + 'w-full max-w-[480px] overflow-hidden! text-left align-middle', + cn( + modalClassName, + 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0', + ), + )} > <DialogCloseButton /> <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> - <div className="self-stretch title-2xl-semi-bold text-text-primary"> - {getTitle()} - </div> + <div className="self-stretch title-2xl-semi-bold text-text-primary">{getTitle()}</div> </div> <ReadyToInstall step={step} diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx index 12cd89765a177c..35dd91b7bd0dd5 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx @@ -9,11 +9,13 @@ const mockPluginManifestToCardPluginProps = vi.fn() const mockLoadedItem = vi.fn() vi.mock('@/service/use-plugins', () => ({ - useUploadGitHub: (params: { repo: string, version: string, package: string }) => mockUseUploadGitHub(params), + useUploadGitHub: (params: { repo: string; version: string; package: string }) => + mockUseUploadGitHub(params), })) vi.mock('../../../utils', () => ({ - pluginManifestToCardPluginProps: (manifest: unknown) => mockPluginManifestToCardPluginProps(manifest), + pluginManifestToCardPluginProps: (manifest: unknown) => + mockPluginManifestToCardPluginProps(manifest), })) vi.mock('../../../base/loading', () => ({ @@ -104,14 +106,16 @@ describe('GithubItem', () => { expect(screen.getByTestId('loaded-item')).toBeInTheDocument() }) - expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({ - checked: true, - versionInfo, - payload: expect.objectContaining({ - ...payload, - from: 'github', + expect(mockLoadedItem).toHaveBeenCalledWith( + expect.objectContaining({ + checked: true, + versionInfo, + payload: expect.objectContaining({ + ...payload, + from: 'github', + }), }), - })) + ) }) it('reports fetch error from upload hook', async () => { diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/loaded-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/loaded-item.spec.tsx index 89dc295cafb3f1..edf32433e6de8a 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/loaded-item.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/loaded-item.spec.tsx @@ -16,11 +16,7 @@ vi.mock('@/config', () => ({ vi.mock('../../../../card', () => ({ default: (props: { titleLeft?: React.ReactNode }) => { mockCard(props) - return ( - <div data-testid="card"> - {props.titleLeft} - </div> - ) + return <div data-testid="card">{props.titleLeft}</div> }, })) @@ -63,29 +59,28 @@ describe('LoadedItem', () => { it('uses local icon url and forwards version title for non-marketplace plugins', () => { render( - <LoadedItem - checked - onCheckedChange={vi.fn()} - payload={payload} - versionInfo={versionInfo} - />, + <LoadedItem checked onCheckedChange={vi.fn()} payload={payload} versionInfo={versionInfo} />, ) expect(screen.getByTestId('card')).toBeInTheDocument() expect(mockUsePluginInstallLimit).toHaveBeenCalledWith(payload) - expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({ - limitedInstall: false, - payload: expect.objectContaining({ - ...payload, - icon: 'https://api.example.com/icon.png', + expect(mockCard).toHaveBeenCalledWith( + expect.objectContaining({ + limitedInstall: false, + payload: expect.objectContaining({ + ...payload, + icon: 'https://api.example.com/icon.png', + }), + titleLeft: expect.anything(), }), - titleLeft: expect.anything(), - })) - expect(mockVersion).toHaveBeenCalledWith(expect.objectContaining({ - hasInstalled: false, - installedVersion: '', - toInstallVersion: '1.0.0', - })) + ) + expect(mockVersion).toHaveBeenCalledWith( + expect.objectContaining({ + hasInstalled: false, + installedVersion: '', + toInstallVersion: '1.0.0', + }), + ) }) it('uses marketplace icon url and disables checkbox when install limit is reached', () => { @@ -101,13 +96,18 @@ describe('LoadedItem', () => { />, ) - expect(screen.getByRole('checkbox', { name: 'Loaded Plugin' })).toHaveAttribute('aria-disabled', 'true') - expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({ - limitedInstall: true, - payload: expect.objectContaining({ - icon: 'https://marketplace.example.com/plugins/dify/Loaded Plugin/icon', + expect(screen.getByRole('checkbox', { name: 'Loaded Plugin' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(mockCard).toHaveBeenCalledWith( + expect.objectContaining({ + limitedInstall: true, + payload: expect.objectContaining({ + icon: 'https://marketplace.example.com/plugins/dify/Loaded Plugin/icon', + }), }), - })) + ) }) it('calls onCheckedChange with payload when checkbox is toggled', () => { @@ -137,8 +137,10 @@ describe('LoadedItem', () => { />, ) - expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({ - titleLeft: null, - })) + expect(mockCard).toHaveBeenCalledWith( + expect.objectContaining({ + titleLeft: null, + }), + ) }) }) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/marketplace-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/marketplace-item.spec.tsx index b6c1763ac5af68..972d6d70fd093e 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/marketplace-item.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/marketplace-item.spec.tsx @@ -56,14 +56,16 @@ describe('MarketPlaceItem', () => { ) expect(screen.getByTestId('loaded-item')).toBeInTheDocument() - expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({ - checked: true, - isFromMarketPlace: true, - versionInfo, - payload: expect.objectContaining({ - ...payload, - version: '2.0.0', + expect(mockLoadedItem).toHaveBeenCalledWith( + expect.objectContaining({ + checked: true, + isFromMarketPlace: true, + versionInfo, + payload: expect.objectContaining({ + ...payload, + version: '2.0.0', + }), }), - })) + ) }) }) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/package-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/package-item.spec.tsx index e92faeb77ff423..e6ba579dd8b3a1 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/package-item.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/package-item.spec.tsx @@ -9,7 +9,8 @@ const mockPluginManifestToCardPluginProps = vi.fn() const mockLoadedItem = vi.fn() vi.mock('../../../utils', () => ({ - pluginManifestToCardPluginProps: (manifest: unknown) => mockPluginManifestToCardPluginProps(manifest), + pluginManifestToCardPluginProps: (manifest: unknown) => + mockPluginManifestToCardPluginProps(manifest), })) vi.mock('../../../base/loading-error', () => ({ @@ -111,14 +112,16 @@ describe('PackageItem', () => { ) expect(screen.getByTestId('loaded-item')).toBeInTheDocument() - expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({ - checked: true, - isFromMarketPlace: true, - versionInfo, - payload: expect.objectContaining({ - plugin_id: 'plugin-1', - from: 'package', + expect(mockLoadedItem).toHaveBeenCalledWith( + expect.objectContaining({ + checked: true, + isFromMarketPlace: true, + versionInfo, + payload: expect.objectContaining({ + plugin_id: 'plugin-1', + from: 'package', + }), }), - })) + ) }) }) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/github-item.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/github-item.tsx index 8dfde85722a672..38325ec3f8ef11 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/github-item.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/github-item.tsx @@ -44,11 +44,9 @@ const Item: FC<Props> = ({ } }, [data]) useEffect(() => { - if (error) - onFetchError() + if (error) onFetchError() }, [error]) - if (!payload) - return <Loading /> + if (!payload) return <Loading /> return ( <LoadedItem payload={payload} diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/loaded-item.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/loaded-item.tsx index b9a60802e2cada..a297bf724e6903 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/loaded-item.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/loaded-item.tsx @@ -43,7 +43,9 @@ const LoadedItem: FC<Props> = ({ className="grow" payload={{ ...payload, - icon: isFromMarketPlace ? `${MARKETPLACE_API_PREFIX}/plugins/${payload.org}/${payload.name}/icon` : getIconUrl(payload.icon), + icon: isFromMarketPlace + ? `${MARKETPLACE_API_PREFIX}/plugins/${payload.org}/${payload.name}/icon` + : getIconUrl(payload.icon), }} titleLeft={payload.version ? <Version {...versionInfo} /> : null} limitedInstall={!canInstall} diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/marketplace-item.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/marketplace-item.tsx index 4fd596f6626b92..5ca7c45ff0d5d1 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/marketplace-item.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/marketplace-item.tsx @@ -21,8 +21,7 @@ const MarketPlaceItem: FC<Props> = ({ version, versionInfo, }) => { - if (!payload) - return <Loading /> + if (!payload) return <Loading /> return ( <LoadedItem checked={checked} diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/package-item.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/package-item.tsx index 88a6da207a1836..56858e1310cd1a 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/package-item.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/package-item.tsx @@ -22,8 +22,7 @@ const PackageItem: FC<Props> = ({ isFromMarketPlace, versionInfo, }) => { - if (!payload.value?.manifest) - return <LoadingError /> + if (!payload.value?.manifest) return <LoadingError /> const plugin = pluginManifestToCardPluginProps(payload.value.manifest) return ( diff --git a/web/app/components/plugins/install-plugin/install-bundle/ready-to-install.tsx b/web/app/components/plugins/install-plugin/install-bundle/ready-to-install.tsx index 0de5b416b9db9c..99e7a1f1c121eb 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/ready-to-install.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/ready-to-install.tsx @@ -29,13 +29,16 @@ const ReadyToInstall: FC<Props> = ({ const [installedPlugins, setInstalledPlugins] = useState<Plugin[]>([]) const [installStatus, setInstallStatus] = useState<InstallStatus[]>([]) const [installedVersionInfo, setInstalledVersionInfo] = useState<VersionProps[]>([]) - const handleInstalled = useCallback((plugins: Plugin[], installStatus: InstallStatus[], versionInfo: VersionProps[]) => { - setInstallStatus(installStatus) - setInstalledPlugins(plugins) - setInstalledVersionInfo(versionInfo) - onStepChange(InstallStep.installed) - setIsInstalling(false) - }, [onStepChange, setIsInstalling]) + const handleInstalled = useCallback( + (plugins: Plugin[], installStatus: InstallStatus[], versionInfo: VersionProps[]) => { + setInstallStatus(installStatus) + setInstalledPlugins(plugins) + setInstalledVersionInfo(versionInfo) + onStepChange(InstallStep.installed) + setIsInstalling(false) + }, + [onStepChange, setIsInstalling], + ) return ( <> {step === InstallStep.readyToInstall && ( diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx index 5a2bbf7ea93064..82c9ac24580b5a 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx @@ -1,4 +1,10 @@ -import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../../types' +import type { + Dependency, + GitHubItemAndMarketPlaceDependency, + PackageDependency, + Plugin, + VersionInfo, +} from '../../../../types' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -64,99 +70,110 @@ vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit' // Mock child components vi.mock('../../item/github-item', () => ({ - default: vi.fn().mockImplementation(({ - checked, - onCheckedChange, - dependency, - onFetchedPayload, - }: { - checked: boolean - onCheckedChange: () => void - dependency: GitHubItemAndMarketPlaceDependency - onFetchedPayload: (plugin: Plugin) => void - }) => { - // Simulate successful fetch - use ref to avoid dependency - const fetchedRef = React.useRef(false) - React.useEffect(() => { - if (fetchedRef.current) - return - fetchedRef.current = true - const mockPlugin: Plugin = { - type: 'plugin', - org: 'test-org', - name: 'GitHub Plugin', - plugin_id: 'github-plugin-id', - version: '1.0.0', - latest_version: '1.0.0', - latest_package_identifier: 'github-pkg-id', - icon: 'icon.png', - verified: true, - label: { 'en-US': 'GitHub Plugin' }, - brief: { 'en-US': 'Brief' }, - description: { 'en-US': 'Description' }, - introduction: 'Intro', - repository: 'https://github.com/test/plugin', - category: PluginCategoryEnum.tool, - install_count: 100, - endpoint: { settings: [] }, - tags: [], - badges: [], - verification: { authorized_category: 'community' }, - from: 'github', - } - onFetchedPayload(mockPlugin) - }, [onFetchedPayload]) - - return ( - <div data-testid="github-item" onClick={onCheckedChange}> - <span data-testid="github-item-checked">{checked ? 'checked' : 'unchecked'}</span> - <span data-testid="github-item-repo">{dependency.value.repo}</span> - </div> - ) - }), + default: vi + .fn() + .mockImplementation( + ({ + checked, + onCheckedChange, + dependency, + onFetchedPayload, + }: { + checked: boolean + onCheckedChange: () => void + dependency: GitHubItemAndMarketPlaceDependency + onFetchedPayload: (plugin: Plugin) => void + }) => { + // Simulate successful fetch - use ref to avoid dependency + const fetchedRef = React.useRef(false) + React.useEffect(() => { + if (fetchedRef.current) return + fetchedRef.current = true + const mockPlugin: Plugin = { + type: 'plugin', + org: 'test-org', + name: 'GitHub Plugin', + plugin_id: 'github-plugin-id', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'github-pkg-id', + icon: 'icon.png', + verified: true, + label: { 'en-US': 'GitHub Plugin' }, + brief: { 'en-US': 'Brief' }, + description: { 'en-US': 'Description' }, + introduction: 'Intro', + repository: 'https://github.com/test/plugin', + category: PluginCategoryEnum.tool, + install_count: 100, + endpoint: { settings: [] }, + tags: [], + badges: [], + verification: { authorized_category: 'community' }, + from: 'github', + } + onFetchedPayload(mockPlugin) + }, [onFetchedPayload]) + + return ( + <div data-testid="github-item" onClick={onCheckedChange}> + <span data-testid="github-item-checked">{checked ? 'checked' : 'unchecked'}</span> + <span data-testid="github-item-repo">{dependency.value.repo}</span> + </div> + ) + }, + ), })) vi.mock('../../item/marketplace-item', () => ({ - default: vi.fn().mockImplementation(({ - checked, - onCheckedChange, - payload, - version, - _versionInfo, - }: { - checked: boolean - onCheckedChange: () => void - payload: Plugin - version: string - _versionInfo: VersionInfo - }) => ( - <div data-testid="marketplace-item" onClick={onCheckedChange}> - <span data-testid="marketplace-item-checked">{checked ? 'checked' : 'unchecked'}</span> - <span data-testid="marketplace-item-name">{payload?.name || 'Loading'}</span> - <span data-testid="marketplace-item-version">{version}</span> - </div> - )), + default: vi + .fn() + .mockImplementation( + ({ + checked, + onCheckedChange, + payload, + version, + _versionInfo, + }: { + checked: boolean + onCheckedChange: () => void + payload: Plugin + version: string + _versionInfo: VersionInfo + }) => ( + <div data-testid="marketplace-item" onClick={onCheckedChange}> + <span data-testid="marketplace-item-checked">{checked ? 'checked' : 'unchecked'}</span> + <span data-testid="marketplace-item-name">{payload?.name || 'Loading'}</span> + <span data-testid="marketplace-item-version">{version}</span> + </div> + ), + ), })) vi.mock('../../item/package-item', () => ({ - default: vi.fn().mockImplementation(({ - checked, - onCheckedChange, - payload, - _isFromMarketPlace, - _versionInfo, - }: { - checked: boolean - onCheckedChange: () => void - payload: PackageDependency - _isFromMarketPlace: boolean - _versionInfo: VersionInfo - }) => ( - <div data-testid="package-item" onClick={onCheckedChange}> - <span data-testid="package-item-checked">{checked ? 'checked' : 'unchecked'}</span> - <span data-testid="package-item-name">{payload.value.manifest.name}</span> - </div> - )), + default: vi + .fn() + .mockImplementation( + ({ + checked, + onCheckedChange, + payload, + _isFromMarketPlace, + _versionInfo, + }: { + checked: boolean + onCheckedChange: () => void + payload: PackageDependency + _isFromMarketPlace: boolean + _versionInfo: VersionInfo + }) => ( + <div data-testid="package-item" onClick={onCheckedChange}> + <span data-testid="package-item-checked">{checked ? 'checked' : 'unchecked'}</span> + <span data-testid="package-item-name">{payload.value.manifest.name}</span> + </div> + ), + ), })) vi.mock('../../../base/loading-error', () => ({ @@ -208,32 +225,33 @@ const createGitHubDependency = (index: number): GitHubItemAndMarketPlaceDependen }, }) -const createPackageDependency = (index: number) => ({ - type: 'package', - value: { - unique_identifier: `package-plugin-${index}-uid`, - manifest: { - plugin_unique_identifier: `package-plugin-${index}-uid`, - version: '1.0.0', - author: 'test-author', - icon: 'icon.png', - name: `Package Plugin ${index}`, - category: PluginCategoryEnum.tool, - label: { 'en-US': `Package Plugin ${index}` }, - description: { 'en-US': 'Test package plugin' }, - created_at: '2024-01-01', - resource: {}, - plugins: [], - verified: true, - endpoint: { settings: [], endpoints: [] }, - model: null, - tags: [], - agent_strategy: null, - meta: { version: '1.0.0' }, - trigger: {}, +const createPackageDependency = (index: number) => + ({ + type: 'package', + value: { + unique_identifier: `package-plugin-${index}-uid`, + manifest: { + plugin_unique_identifier: `package-plugin-${index}-uid`, + version: '1.0.0', + author: 'test-author', + icon: 'icon.png', + name: `Package Plugin ${index}`, + category: PluginCategoryEnum.tool, + label: { 'en-US': `Package Plugin ${index}` }, + description: { 'en-US': 'Test package plugin' }, + created_at: '2024-01-01', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {}, + }, }, - }, -} as unknown as PackageDependency) + }) as unknown as PackageDependency // ==================== InstallMulti Component Tests ==================== describe('InstallMulti Component', () => { @@ -296,10 +314,7 @@ describe('InstallMulti Component', () => { it('should render multiple items for mixed dependency types', async () => { const mixedProps = { ...defaultProps, - allPlugins: [ - createPackageDependency(0), - createGitHubDependency(1), - ] as Dependency[], + allPlugins: [createPackageDependency(0), createGitHubDependency(1)] as Dependency[], } render(<InstallMulti {...mixedProps} />) @@ -364,7 +379,9 @@ describe('InstallMulti Component', () => { // ==================== useImperativeHandle Tests ==================== describe('Imperative Handle', () => { it('should expose selectAllPlugins function', async () => { - const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null } + const ref: { + current: { selectAllPlugins: () => void; deSelectAllPlugins: () => void } | null + } = { current: null } render(<InstallMulti {...defaultProps} ref={ref} />) @@ -380,7 +397,9 @@ describe('InstallMulti Component', () => { }) it('should expose deSelectAllPlugins function', async () => { - const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null } + const ref: { + current: { selectAllPlugins: () => void; deSelectAllPlugins: () => void } | null + } = { current: null } render(<InstallMulti {...defaultProps} ref={ref} />) @@ -565,7 +584,9 @@ describe('InstallMulti Component', () => { // Component should render await waitFor(() => { - expect(screen.queryByTestId('marketplace-item') || screen.queryByTestId('loading-error')).toBeTruthy() + expect( + screen.queryByTestId('marketplace-item') || screen.queryByTestId('loading-error'), + ).toBeTruthy() }) }) }) @@ -573,15 +594,14 @@ describe('InstallMulti Component', () => { // ==================== selectAllPlugins Edge Cases ==================== describe('selectAllPlugins Edge Cases', () => { it('should skip plugins that are not loaded', async () => { - const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null } + const ref: { + current: { selectAllPlugins: () => void; deSelectAllPlugins: () => void } | null + } = { current: null } // Use mixed plugins where some might not be loaded const mixedProps = { ...defaultProps, - allPlugins: [ - createPackageDependency(0), - createMarketplaceDependency(1), - ] as Dependency[], + allPlugins: [createPackageDependency(0), createMarketplaceDependency(1)] as Dependency[], } render(<InstallMulti {...mixedProps} ref={ref} />) @@ -664,8 +684,7 @@ describe('InstallMulti Component', () => { await waitFor(() => { // Either loading error or marketplace item should be present expect( - screen.queryByTestId('loading-error') - || screen.queryByTestId('marketplace-item'), + screen.queryByTestId('loading-error') || screen.queryByTestId('marketplace-item'), ).toBeTruthy() }) }) @@ -684,8 +703,7 @@ describe('InstallMulti Component', () => { // Component should handle error gracefully await waitFor(() => { expect( - screen.queryByTestId('loading-error') - || screen.queryByTestId('marketplace-item'), + screen.queryByTestId('loading-error') || screen.queryByTestId('marketplace-item'), ).toBeTruthy() }) }) @@ -697,7 +715,10 @@ describe('InstallMulti Component', () => { const marketplaceProps = { ...defaultProps, - allPlugins: [createMarketplaceDependency(0), createMarketplaceDependency(1)] as Dependency[], + allPlugins: [ + createMarketplaceDependency(0), + createMarketplaceDependency(1), + ] as Dependency[], } render(<InstallMulti {...marketplaceProps} />) @@ -780,7 +801,9 @@ describe('InstallMulti Component', () => { // ==================== Plugin Not Loaded Scenario ==================== describe('Plugin Not Loaded', () => { it('should skip undefined plugins in selectAllPlugins', async () => { - const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null } + const ref: { + current: { selectAllPlugins: () => void; deSelectAllPlugins: () => void } | null + } = { current: null } // Create a scenario where some plugins might not be loaded const mixedProps = { @@ -812,10 +835,7 @@ describe('InstallMulti Component', () => { it('should filter plugins based on canInstall when selecting', async () => { const mixedProps = { ...defaultProps, - allPlugins: [ - createPackageDependency(0), - createPackageDependency(1), - ] as Dependency[], + allPlugins: [createPackageDependency(0), createPackageDependency(1)] as Dependency[], } render(<InstallMulti {...mixedProps} />) @@ -900,10 +920,7 @@ describe('InstallMulti Component', () => { it('should handle multiple GitHub plugin fetches', async () => { const multiGithub = { ...defaultProps, - allPlugins: [ - createGitHubDependency(0), - createGitHubDependency(1), - ] as Dependency[], + allPlugins: [createGitHubDependency(0), createGitHubDependency(1)] as Dependency[], } render(<InstallMulti {...multiGithub} />) @@ -918,7 +935,9 @@ describe('InstallMulti Component', () => { // ==================== canInstall false scenario ==================== describe('canInstall False Scenario', () => { it('should skip plugins that cannot be installed in selectAllPlugins', async () => { - const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null } + const ref: { + current: { selectAllPlugins: () => void; deSelectAllPlugins: () => void } | null + } = { current: null } const multiplePlugins = { ...defaultProps, diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx index 830da0d7d08864..18547318c86c7c 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx @@ -16,10 +16,8 @@ vi.mock('@/service/use-plugins', () => ({ mockInstallOrUpdate.mockImplementation((params: { payload: Dependency[] }) => { // Call onSuccess with mock response based on mockInstallResponse const getStatus = () => { - if (mockInstallResponse === 'success') - return TaskStatus.success - if (mockInstallResponse === 'failed') - return TaskStatus.failed + if (mockInstallResponse === 'success') return TaskStatus.success + if (mockInstallResponse === 'failed') return TaskStatus.failed return TaskStatus.running } const mockResponse: InstallStatusResponse[] = params.payload.map(() => ({ @@ -96,101 +94,110 @@ vi.mock('../install-multi', async () => { from: 'marketplace', }) - const MockInstallMulti = React.forwardRef((props: { - allPlugins: { length: number }[] - selectedPlugins: { plugin_id: string }[] - onSelect: (plugin: ReturnType<typeof createPlugin>, index: number, total: number) => void - onSelectAll: (plugins: ReturnType<typeof createPlugin>[], indexes: number[]) => void - onDeSelectAll: () => void - onLoadedAllPlugin: (info: Record<string, unknown>) => void - }, ref: React.ForwardedRef<{ selectAllPlugins: () => void, deSelectAllPlugins: () => void }>) => { - const { - allPlugins, - selectedPlugins, - onSelect, - onSelectAll, - onDeSelectAll, - onLoadedAllPlugin, - } = props - - const allPluginsRef = React.useRef(allPlugins) - React.useEffect(() => { - allPluginsRef.current = allPlugins - }, [allPlugins]) - - // Expose ref methods - React.useImperativeHandle(ref, () => ({ - selectAllPlugins: () => { - const plugins = allPluginsRef.current.map((_, i) => createPlugin(i)) - const indexes = allPluginsRef.current.map((_, i) => i) - onSelectAll(plugins, indexes) + const MockInstallMulti = React.forwardRef( + ( + props: { + allPlugins: { length: number }[] + selectedPlugins: { plugin_id: string }[] + onSelect: (plugin: ReturnType<typeof createPlugin>, index: number, total: number) => void + onSelectAll: (plugins: ReturnType<typeof createPlugin>[], indexes: number[]) => void + onDeSelectAll: () => void + onLoadedAllPlugin: (info: Record<string, unknown>) => void }, - deSelectAllPlugins: () => { - onDeSelectAll() - }, - }), [onSelectAll, onDeSelectAll]) - - // Simulate loading completion when mounted - React.useEffect(() => { - const installedInfo = {} - onLoadedAllPlugin(installedInfo) - }, [onLoadedAllPlugin]) - - return ( - <div data-testid="install-multi"> - <span data-testid="all-plugins-count">{allPlugins.length}</span> - <span data-testid="selected-plugins-count">{selectedPlugins.length}</span> - <button - data-testid="select-plugin-0" - onClick={() => { - onSelect(createPlugin(0), 0, allPlugins.length) - }} - > - Select Plugin 0 - </button> - <button - data-testid="select-plugin-1" - onClick={() => { - onSelect(createPlugin(1), 1, allPlugins.length) - }} - > - Select Plugin 1 - </button> - <button - data-testid="toggle-plugin-0" - onClick={() => { - const plugin = createPlugin(0) - onSelect(plugin, 0, allPlugins.length) - }} - > - Toggle Plugin 0 - </button> - <button - data-testid="select-all-plugins" - onClick={() => { - const plugins = allPlugins.map((_, i) => createPlugin(i)) - const indexes = allPlugins.map((_, i) => i) + ref: React.ForwardedRef<{ selectAllPlugins: () => void; deSelectAllPlugins: () => void }>, + ) => { + const { + allPlugins, + selectedPlugins, + onSelect, + onSelectAll, + onDeSelectAll, + onLoadedAllPlugin, + } = props + + const allPluginsRef = React.useRef(allPlugins) + React.useEffect(() => { + allPluginsRef.current = allPlugins + }, [allPlugins]) + + // Expose ref methods + React.useImperativeHandle( + ref, + () => ({ + selectAllPlugins: () => { + const plugins = allPluginsRef.current.map((_, i) => createPlugin(i)) + const indexes = allPluginsRef.current.map((_, i) => i) onSelectAll(plugins, indexes) - }} - > - Select All - </button> - <button - data-testid="deselect-all-plugins" - onClick={() => onDeSelectAll()} - > - Deselect All - </button> - </div> - ) - }) + }, + deSelectAllPlugins: () => { + onDeSelectAll() + }, + }), + [onSelectAll, onDeSelectAll], + ) + + // Simulate loading completion when mounted + React.useEffect(() => { + const installedInfo = {} + onLoadedAllPlugin(installedInfo) + }, [onLoadedAllPlugin]) + + return ( + <div data-testid="install-multi"> + <span data-testid="all-plugins-count">{allPlugins.length}</span> + <span data-testid="selected-plugins-count">{selectedPlugins.length}</span> + <button + data-testid="select-plugin-0" + onClick={() => { + onSelect(createPlugin(0), 0, allPlugins.length) + }} + > + Select Plugin 0 + </button> + <button + data-testid="select-plugin-1" + onClick={() => { + onSelect(createPlugin(1), 1, allPlugins.length) + }} + > + Select Plugin 1 + </button> + <button + data-testid="toggle-plugin-0" + onClick={() => { + const plugin = createPlugin(0) + onSelect(plugin, 0, allPlugins.length) + }} + > + Toggle Plugin 0 + </button> + <button + data-testid="select-all-plugins" + onClick={() => { + const plugins = allPlugins.map((_, i) => createPlugin(i)) + const indexes = allPlugins.map((_, i) => i) + onSelectAll(plugins, indexes) + }} + > + Select All + </button> + <button data-testid="deselect-all-plugins" onClick={() => onDeSelectAll()}> + Deselect All + </button> + </div> + ) + }, + ) return { default: MockInstallMulti } }) // ==================== Test Utilities ==================== -const createMockDependency = (type: 'marketplace' | 'github' | 'package' = 'marketplace', index = 0): Dependency => { +const createMockDependency = ( + type: 'marketplace' | 'github' | 'package' = 'marketplace', + index = 0, +): Dependency => { if (type === 'marketplace') { return { type: 'marketplace', @@ -318,9 +325,9 @@ describe('Install Component', () => { it('should show cancel button when canInstall is false', () => { // Create a fresh component that hasn't loaded yet vi.doMock('./install-multi', () => ({ - default: vi.fn().mockImplementation(() => ( - <div data-testid="install-multi">Loading...</div> - )), + default: vi + .fn() + .mockImplementation(() => <div data-testid="install-multi">Loading...</div>), })) // Since InstallMulti doesn't call onLoadedAllPlugin, canInstall stays false @@ -407,8 +414,7 @@ describe('Install Component', () => { const deSelectText = screen.getByText(/common\.operation\.deSelectAll/i) const checkboxContainer = deSelectText.parentElement await act(async () => { - if (checkboxContainer) - fireEvent.click(checkboxContainer) + if (checkboxContainer) fireEvent.click(checkboxContainer) }) // Should now show selectAll again (deSelectAllPlugins was called) @@ -582,9 +588,9 @@ describe('Install Component', () => { // Override the mock to NOT call onLoadedAllPlugin immediately // This keeps canInstall = false so the cancel button is visible vi.doMock('./install-multi', () => ({ - default: vi.fn().mockImplementation(() => ( - <div data-testid="install-multi-no-load">Loading...</div> - )), + default: vi + .fn() + .mockImplementation(() => <div data-testid="install-multi-no-load">Loading...</div>), })) // For this test, we just verify the cancel behavior diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/installed.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/installed.spec.tsx index f85d5d67e53465..af8bbed735eea2 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/installed.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/installed.spec.tsx @@ -13,11 +13,7 @@ vi.mock('@/config', () => ({ vi.mock('@/app/components/plugins/card', () => ({ default: (props: { titleLeft?: React.ReactNode }) => { mockCard(props) - return ( - <div data-testid="card"> - {props.titleLeft} - </div> - ) + return <div data-testid="card">{props.titleLeft}</div> }, })) @@ -68,36 +64,38 @@ describe('Installed', () => { ) expect(screen.getAllByTestId('card')).toHaveLength(2) - expect(screen.getByText('plugin.installModal.installedSuccessfullyCountDesc:{"num":2}')).toBeInTheDocument() + expect( + screen.getByText('plugin.installModal.installedSuccessfullyCountDesc:{"num":2}'), + ).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() expect(screen.getByText('0.9.0 -> 1.0.0')).toBeInTheDocument() expect(screen.getByText('2.0.0')).toBeInTheDocument() - expect(mockCard).toHaveBeenNthCalledWith(1, expect.objectContaining({ - installed: true, - installFailed: false, - payload: expect.objectContaining({ - icon: 'https://marketplace.example.com/plugins/dify/Plugin One/icon', + expect(mockCard).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + installed: true, + installFailed: false, + payload: expect.objectContaining({ + icon: 'https://marketplace.example.com/plugins/dify/Plugin One/icon', + }), }), - })) - expect(mockCard).toHaveBeenNthCalledWith(2, expect.objectContaining({ - installed: false, - installFailed: true, - payload: expect.objectContaining({ - icon: 'https://api.example.com/icon-2.png', + ) + expect(mockCard).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + installed: false, + installFailed: true, + payload: expect.objectContaining({ + icon: 'https://api.example.com/icon-2.png', + }), }), - })) + ) }) it('calls onCancel when close button is clicked', () => { const onCancel = vi.fn() - render( - <Installed - list={plugins} - installStatus={installStatus} - onCancel={onCancel} - />, - ) + render(<Installed list={plugins} installStatus={installStatus} onCancel={onCancel} />) fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) @@ -106,12 +104,7 @@ describe('Installed', () => { it('hides action button when isHideButton is true', () => { render( - <Installed - list={plugins} - installStatus={installStatus} - onCancel={vi.fn()} - isHideButton - />, + <Installed list={plugins} installStatus={installStatus} onCancel={vi.fn()} isHideButton />, ) expect(screen.queryByRole('button', { name: 'common.operation.close' })).not.toBeInTheDocument() diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts index 9506ca1b0b7ba0..095ce7a0cd2b0b 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts @@ -1,4 +1,10 @@ -import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '@/app/components/plugins/types' +import type { + Dependency, + GitHubItemAndMarketPlaceDependency, + PackageDependency, + Plugin, + VersionInfo, +} from '@/app/components/plugins/types' import { act, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderHookWithSystemFeatures as renderHook } from '@/__tests__/utils/mock-system-features' @@ -53,32 +59,33 @@ const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({ ...overrides, }) -const createPackageDependency = (index: number) => ({ - type: 'package', - value: { - unique_identifier: `package-plugin-${index}-uid`, - manifest: { - plugin_unique_identifier: `package-plugin-${index}-uid`, - version: '1.0.0', - author: 'test-author', - icon: 'icon.png', - name: `Package Plugin ${index}`, - category: PluginCategoryEnum.tool, - label: { 'en-US': `Package Plugin ${index}` }, - description: { 'en-US': 'Test package plugin' }, - created_at: '2024-01-01', - resource: {}, - plugins: [], - verified: true, - endpoint: { settings: [], endpoints: [] }, - model: null, - tags: [], - agent_strategy: null, - meta: { version: '1.0.0' }, - trigger: {}, +const createPackageDependency = (index: number) => + ({ + type: 'package', + value: { + unique_identifier: `package-plugin-${index}-uid`, + manifest: { + plugin_unique_identifier: `package-plugin-${index}-uid`, + version: '1.0.0', + author: 'test-author', + icon: 'icon.png', + name: `Package Plugin ${index}`, + category: PluginCategoryEnum.tool, + label: { 'en-US': `Package Plugin ${index}` }, + description: { 'en-US': 'Test package plugin' }, + created_at: '2024-01-01', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {}, + }, }, - }, -} as unknown as PackageDependency) + }) as unknown as PackageDependency const createMarketplaceDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({ type: 'marketplace', @@ -100,7 +107,7 @@ const createGitHubDependency = (index: number): GitHubItemAndMarketPlaceDependen const createMarketplaceApiData = (indexes: number[]) => ({ data: { - list: indexes.map(i => ({ + list: indexes.map((i) => ({ plugin: { plugin_id: `test-org/plugin-${i}`, org: 'test-org', @@ -184,10 +191,7 @@ describe('useInstallMultiState', () => { it('should return undefined for non-package items in mixed dependencies', () => { const params = createDefaultParams({ - allPlugins: [ - createPackageDependency(0), - createGitHubDependency(1), - ] as Dependency[], + allPlugins: [createPackageDependency(0), createGitHubDependency(1)] as Dependency[], }) const { result } = renderHook(() => useInstallMultiState(params)) @@ -267,18 +271,20 @@ describe('useInstallMultiState', () => { it('should fall back to latest_version when marketplace plugin version is missing', async () => { mockMarketplaceData = { data: { - list: [{ - plugin: { - plugin_id: 'test-org/plugin-0', - org: 'test-org', - name: 'Test Plugin 0', - version: '', - latest_version: '2.0.0', + list: [ + { + plugin: { + plugin_id: 'test-org/plugin-0', + org: 'test-org', + name: 'Test Plugin 0', + version: '', + latest_version: '2.0.0', + }, + version: { + unique_identifier: 'plugin-0-uid', + }, }, - version: { - unique_identifier: 'plugin-0-uid', - }, - }], + ], }, } @@ -395,10 +401,7 @@ describe('useInstallMultiState', () => { mockMarketplaceError = new Error('Fetch failed') const params = createDefaultParams({ - allPlugins: [ - createPackageDependency(0), - createMarketplaceDependency(1), - ] as Dependency[], + allPlugins: [createPackageDependency(0), createMarketplaceDependency(1)] as Dependency[], }) const { result } = renderHook(() => useInstallMultiState(params)) @@ -412,7 +415,10 @@ describe('useInstallMultiState', () => { const duplicatedMarketplaceDependency = createMarketplaceDependency(0) const allPlugins = [duplicatedMarketplaceDependency] as Dependency[] - allPlugins.filter = vi.fn(() => [duplicatedMarketplaceDependency, duplicatedMarketplaceDependency]) as typeof allPlugins.filter + allPlugins.filter = vi.fn(() => [ + duplicatedMarketplaceDependency, + duplicatedMarketplaceDependency, + ]) as typeof allPlugins.filter const params = createDefaultParams({ allPlugins }) const { result } = renderHook(() => useInstallMultiState(params)) @@ -436,10 +442,7 @@ describe('useInstallMultiState', () => { it('should not call onLoadedAllPlugin when not all plugins resolved', () => { // GitHub plugin not fetched yet → isLoadedAllData = false const params = createDefaultParams({ - allPlugins: [ - createPackageDependency(0), - createGitHubDependency(1), - ] as Dependency[], + allPlugins: [createPackageDependency(0), createGitHubDependency(1)] as Dependency[], }) renderHook(() => useInstallMultiState(params)) @@ -479,10 +482,7 @@ describe('useInstallMultiState', () => { it('should not affect other plugin slots', async () => { const params = createDefaultParams({ - allPlugins: [ - createPackageDependency(0), - createGitHubDependency(1), - ] as Dependency[], + allPlugins: [createPackageDependency(0), createGitHubDependency(1)] as Dependency[], }) const { result } = renderHook(() => useInstallMultiState(params)) const originalPlugin0 = result.current.plugins[0] @@ -514,10 +514,7 @@ describe('useInstallMultiState', () => { it('should accumulate multiple error indexes without stale closure', async () => { const params = createDefaultParams({ - allPlugins: [ - createGitHubDependency(0), - createGitHubDependency(1), - ] as Dependency[], + allPlugins: [createGitHubDependency(0), createGitHubDependency(1)] as Dependency[], }) const { result } = renderHook(() => useInstallMultiState(params)) @@ -574,19 +571,12 @@ describe('useInstallMultiState', () => { result.current.handleSelect(0)() }) - expect(params.onSelect).toHaveBeenCalledWith( - result.current.plugins[0], - 0, - expect.any(Number), - ) + expect(params.onSelect).toHaveBeenCalledWith(result.current.plugins[0], 0, expect.any(Number)) }) it('should filter installable plugins using pluginInstallLimit', async () => { const params = createDefaultParams({ - allPlugins: [ - createPackageDependency(0), - createPackageDependency(1), - ] as Dependency[], + allPlugins: [createPackageDependency(0), createPackageDependency(1)] as Dependency[], }) const { result } = renderHook(() => useInstallMultiState(params)) @@ -595,11 +585,7 @@ describe('useInstallMultiState', () => { }) // mockCanInstall is true, so all 2 plugins are installable - expect(params.onSelect).toHaveBeenCalledWith( - expect.anything(), - 0, - 2, - ) + expect(params.onSelect).toHaveBeenCalledWith(expect.anything(), 0, 2) }) }) @@ -639,10 +625,7 @@ describe('useInstallMultiState', () => { it('should return all plugins when canInstall is true', () => { mockCanInstall = true const params = createDefaultParams({ - allPlugins: [ - createPackageDependency(0), - createPackageDependency(1), - ] as Dependency[], + allPlugins: [createPackageDependency(0), createPackageDependency(1)] as Dependency[], }) const { result } = renderHook(() => useInstallMultiState(params)) @@ -668,10 +651,7 @@ describe('useInstallMultiState', () => { it('should skip unloaded (undefined) plugins', () => { mockCanInstall = true const params = createDefaultParams({ - allPlugins: [ - createPackageDependency(0), - createGitHubDependency(1), - ] as Dependency[], + allPlugins: [createPackageDependency(0), createGitHubDependency(1)] as Dependency[], }) const { result } = renderHook(() => useInstallMultiState(params)) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts index 99df622d503299..3b50ea0d55fabf 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts @@ -1,6 +1,12 @@ 'use client' -import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '@/app/components/plugins/types' +import type { + Dependency, + GitHubItemAndMarketPlaceDependency, + PackageDependency, + Plugin, + VersionInfo, +} from '@/app/components/plugins/types' import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useState } from 'react' import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' @@ -33,17 +39,14 @@ export function getPluginKey(plugin: Plugin | undefined): string { } function parseMarketplaceIdentifier(identifier?: string): MarketplacePluginInfo | null { - if (!identifier) - return null + if (!identifier) return null const withoutHash = identifier.split('@')[0] const [organization, nameAndVersionPart] = withoutHash!.split('/') - if (!organization || !nameAndVersionPart) - return null + if (!organization || !nameAndVersionPart) return null const [plugin, version] = nameAndVersionPart.split(':') - if (!plugin) - return null + if (!plugin) return null return { organization, plugin, version } } @@ -54,11 +57,9 @@ function getMarketplacePluginInfo( const parsedInfo = parseMarketplaceIdentifier( value.marketplace_plugin_unique_identifier || value.plugin_unique_identifier, ) - if (parsedInfo) - return parsedInfo + if (parsedInfo) return parsedInfo - if (!value.organization || !value.plugin) - return null + if (!value.organization || !value.plugin) return null return { organization: value.organization, @@ -68,12 +69,10 @@ function getMarketplacePluginInfo( } function initPluginsFromDependencies(allPlugins: Dependency[]): (Plugin | undefined)[] { - if (!allPlugins.some(d => d.type === 'package')) - return [] + if (!allPlugins.some((d) => d.type === 'package')) return [] return allPlugins.map((d) => { - if (d.type !== 'package') - return undefined + if (d.type !== 'package') return undefined const { manifest, unique_identifier } = (d as PackageDependency).value return { ...manifest, @@ -92,14 +91,14 @@ export function useInstallMultiState({ // Marketplace plugins filtering and index mapping const marketplacePlugins = useMemo( - () => allPlugins.filter((d): d is GitHubItemAndMarketPlaceDependency => d.type === 'marketplace'), + () => + allPlugins.filter((d): d is GitHubItemAndMarketPlaceDependency => d.type === 'marketplace'), [allPlugins], ) const marketPlaceInDSLIndex = useMemo(() => { return allPlugins.reduce<number[]>((acc, d, index) => { - if (d.type === 'marketplace') - acc.push(index) + if (d.type === 'marketplace') acc.push(index) return acc }, []) }, [allPlugins]) @@ -108,22 +107,22 @@ export function useInstallMultiState({ return marketplacePlugins.reduce<{ marketplaceRequests: MarketplaceRequest[] invalidMarketplaceIndexes: number[] - }>((acc, dependency, marketplaceIndex) => { - const dslIndex = marketPlaceInDSLIndex[marketplaceIndex] - if (dslIndex === undefined) - return acc + }>( + (acc, dependency, marketplaceIndex) => { + const dslIndex = marketPlaceInDSLIndex[marketplaceIndex] + if (dslIndex === undefined) return acc - const marketplaceInfo = getMarketplacePluginInfo(dependency.value) - if (!marketplaceInfo) - acc.invalidMarketplaceIndexes.push(dslIndex) - else - acc.marketplaceRequests.push({ dslIndex, dependency, info: marketplaceInfo }) + const marketplaceInfo = getMarketplacePluginInfo(dependency.value) + if (!marketplaceInfo) acc.invalidMarketplaceIndexes.push(dslIndex) + else acc.marketplaceRequests.push({ dslIndex, dependency, info: marketplaceInfo }) - return acc - }, { - marketplaceRequests: [], - invalidMarketplaceIndexes: [], - }) + return acc + }, + { + marketplaceRequests: [], + invalidMarketplaceIndexes: [], + }, + ) }, [marketPlaceInDSLIndex, marketplacePlugins]) // Marketplace data fetching: by normalized marketplace info @@ -131,9 +130,7 @@ export function useInstallMultiState({ isLoading: isFetchingById, data: infoGetById, error: infoByIdError, - } = useFetchPluginsInMarketPlaceByInfo( - marketplaceRequests.map(request => request.info), - ) + } = useFetchPluginsInMarketPlaceByInfo(marketplaceRequests.map((request) => request.info)) // Derive marketplace plugin data and errors from API responses const { marketplacePluginMap, marketplaceErrorIndexes } = useMemo(() => { @@ -143,34 +140,43 @@ export function useInstallMultiState({ // Process "by ID" response if (!isFetchingById && infoGetById?.data.list) { const payloads = infoGetById.data.list - const pluginById = new Map( - payloads.map(item => [item.plugin.plugin_id, item.plugin]), - ) + const pluginById = new Map(payloads.map((item) => [item.plugin.plugin_id, item.plugin])) marketplaceRequests.forEach((request, requestIndex) => { const pluginId = ( - request.dependency.value.marketplace_plugin_unique_identifier - || request.dependency.value.plugin_unique_identifier + request.dependency.value.marketplace_plugin_unique_identifier || + request.dependency.value.plugin_unique_identifier )?.split(':')[0] - const pluginInfo = (pluginId ? pluginById.get(pluginId) : undefined) || payloads[requestIndex]?.plugin + const pluginInfo = + (pluginId ? pluginById.get(pluginId) : undefined) || payloads[requestIndex]?.plugin if (pluginInfo) { - pluginMap.set(request.dslIndex, getFormattedPlugin({ - ...pluginInfo, - from: request.dependency.type, - version: pluginInfo.version || pluginInfo.latest_version, - })) + pluginMap.set( + request.dslIndex, + getFormattedPlugin({ + ...pluginInfo, + from: request.dependency.type, + version: pluginInfo.version || pluginInfo.latest_version, + }), + ) + } else { + errorSet.add(request.dslIndex) } - else { errorSet.add(request.dslIndex) } }) } // Mark all marketplace indexes as errors on fetch failure - if (infoByIdError) - marketPlaceInDSLIndex.forEach(index => errorSet.add(index)) + if (infoByIdError) marketPlaceInDSLIndex.forEach((index) => errorSet.add(index)) return { marketplacePluginMap: pluginMap, marketplaceErrorIndexes: errorSet } - }, [invalidMarketplaceIndexes, isFetchingById, infoGetById, infoByIdError, marketPlaceInDSLIndex, marketplaceRequests]) + }, [ + invalidMarketplaceIndexes, + isFetchingById, + infoGetById, + infoByIdError, + marketPlaceInDSLIndex, + marketplaceRequests, + ]) // GitHub-fetched plugins and errors (imperative state from child callbacks) const [githubPluginMap, setGithubPluginMap] = useState<Map<number, Plugin>>(() => new Map()) @@ -195,59 +201,67 @@ export function useInstallMultiState({ }, [marketplaceErrorIndexes, githubErrorIndexes]) // Check installed status after all data is loaded - const isLoadedAllData = (plugins.filter(Boolean).length + errorIndexes.length) === allPlugins.length + const isLoadedAllData = plugins.filter(Boolean).length + errorIndexes.length === allPlugins.length const { installedInfo } = useCheckInstalled({ - pluginIds: plugins.filter(Boolean).map(d => getPluginKey(d)) || [], + pluginIds: plugins.filter(Boolean).map((d) => getPluginKey(d)) || [], enabled: isLoadedAllData, }) // Notify parent when all plugin data and install info is ready useEffect(() => { - if (isLoadedAllData && installedInfo) - onLoadedAllPlugin(installedInfo!) - // eslint-disable-next-line react-hooks/exhaustive-deps + if (isLoadedAllData && installedInfo) onLoadedAllPlugin(installedInfo!) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoadedAllData, installedInfo]) // Callback: handle GitHub plugin fetch success const handleGitHubPluginFetched = useCallback((index: number) => { return (p: Plugin) => { - setGithubPluginMap(prev => new Map(prev).set(index, p)) + setGithubPluginMap((prev) => new Map(prev).set(index, p)) } }, []) // Callback: handle GitHub plugin fetch error const handleGitHubPluginFetchError = useCallback((index: number) => { return () => { - setGithubErrorIndexes(prev => [...prev, index]) + setGithubErrorIndexes((prev) => [...prev, index]) } }, []) // Callback: get version info for a plugin by its key - const getVersionInfo = useCallback((pluginId: string) => { - const pluginDetail = installedInfo?.[pluginId] - return { - hasInstalled: !!pluginDetail, - installedVersion: pluginDetail?.installedVersion, - toInstallVersion: '', - } - }, [installedInfo]) + const getVersionInfo = useCallback( + (pluginId: string) => { + const pluginDetail = installedInfo?.[pluginId] + return { + hasInstalled: !!pluginDetail, + installedVersion: pluginDetail?.installedVersion, + toInstallVersion: '', + } + }, + [installedInfo], + ) // Callback: handle plugin selection - const handleSelect = useCallback((index: number) => { - return () => { - const canSelectPlugins = plugins.filter((p) => { - const { canInstall } = pluginInstallLimit(p!, systemFeatures) - return canInstall - }) - onSelect(plugins[index]!, index, canSelectPlugins.length) - } - }, [onSelect, plugins, systemFeatures]) + const handleSelect = useCallback( + (index: number) => { + return () => { + const canSelectPlugins = plugins.filter((p) => { + const { canInstall } = pluginInstallLimit(p!, systemFeatures) + return canInstall + }) + onSelect(plugins[index]!, index, canSelectPlugins.length) + } + }, + [onSelect, plugins, systemFeatures], + ) // Callback: check if a plugin at given index is selected - const isPluginSelected = useCallback((index: number) => { - return !!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id) - }, [selectedPlugins, plugins]) + const isPluginSelected = useCallback( + (index: number) => { + return !!selectedPlugins.find((p) => p.plugin_id === plugins[index]?.plugin_id) + }, + [selectedPlugins, plugins], + ) // Callback: get all installable plugins with their indexes const getInstallablePlugins = useCallback(() => { @@ -255,8 +269,7 @@ export function useInstallMultiState({ const installablePlugins: Plugin[] = [] allPlugins.forEach((_d, index) => { const p = plugins[index] - if (!p) - return + if (!p) return const { canInstall } = pluginInstallLimit(p, systemFeatures) if (canInstall) { selectedIndexes.push(index) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx index 6cac57a37acff3..433b66cb86ffb7 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx @@ -1,5 +1,11 @@ 'use client' -import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../types' +import type { + Dependency, + GitHubItemAndMarketPlaceDependency, + PackageDependency, + Plugin, + VersionInfo, +} from '../../../types' import * as React from 'react' import { useImperativeHandle } from 'react' import LoadingError from '../../base/loading-error' @@ -61,8 +67,7 @@ const InstallByDSLList = ({ return ( <> {allPlugins.map((d, index) => { - if (errorIndexes.includes(index)) - return <LoadingError key={index} /> + if (errorIndexes.includes(index)) return <LoadingError key={index} /> const plugin = plugins[index] const checked = isPluginSelected(index) @@ -89,7 +94,9 @@ const InstallByDSLList = ({ checked={checked} onCheckedChange={handleSelect(index)} payload={{ ...plugin, from: d.type } as Plugin} - version={(d as GitHubItemAndMarketPlaceDependency).value.version! || plugin?.version || ''} + version={ + (d as GitHubItemAndMarketPlaceDependency).value.version! || plugin?.version || '' + } versionInfo={versionInfo} /> ) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx index 364c8d585b237c..420f348ded5cda 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx @@ -1,6 +1,13 @@ 'use client' import type { FC } from 'react' -import type { Dependency, InstallStatus, InstallStatusResponse, Plugin, VersionInfo, VersionProps } from '../../../types' +import type { + Dependency, + InstallStatus, + InstallStatusResponse, + Plugin, + VersionInfo, + VersionProps, +} from '../../../types' import type { ExposeRefs } from './install-multi' import { Button } from '@langgenius/dify-ui/button' import { Checkbox } from '@langgenius/dify-ui/checkbox' @@ -11,11 +18,7 @@ import { useTranslation } from 'react-i18next' import { useCanInstallPluginFromMarketplace } from '@/app/components/plugins/plugin-page/use-reference-setting' import { useMittContextSelector } from '@/context/mitt-context' import { useInstallOrUpdate, usePluginTaskList } from '@/service/use-plugins' -import { - - TaskStatus, - -} from '../../../types' +import { TaskStatus } from '../../../types' import checkTaskStatus from '../../base/check-task-status' import useRefreshPluginList from '../../hooks/use-refresh-plugin-list' import { getPluginKey } from './hooks/use-install-multi-state' @@ -26,7 +29,11 @@ const i18nPrefix = 'installModal' type Props = Readonly<{ allPlugins: Dependency[] onStartToInstall?: () => void - onInstalled: (plugins: Plugin[], installStatus: InstallStatus[], versionInfo: VersionProps[]) => void + onInstalled: ( + plugins: Plugin[], + installStatus: InstallStatus[], + versionInfo: VersionProps[], + ) => void onCancel: () => void isFromMarketPlace?: boolean isHideButton?: boolean @@ -41,7 +48,7 @@ const Install: FC<Props> = ({ isHideButton, }) => { const { t } = useTranslation() - const emit = useMittContextSelector(s => s.emit) + const emit = useMittContextSelector((s) => s.emit) const [selectedPlugins, setSelectedPlugins] = React.useState<Plugin[]>([]) const [selectedIndexes, setSelectedIndexes] = React.useState<number[]>([]) const selectedPluginsNum = selectedPlugins.length @@ -49,24 +56,24 @@ const Install: FC<Props> = ({ const { refreshPluginList } = useRefreshPluginList() const [isSelectAll, setIsSelectAll] = useState(false) const handleClickSelectAll = useCallback(() => { - if (isSelectAll) - installMultiRef.current?.deSelectAllPlugins() - else - installMultiRef.current?.selectAllPlugins() + if (isSelectAll) installMultiRef.current?.deSelectAllPlugins() + else installMultiRef.current?.selectAllPlugins() }, [isSelectAll]) const [canInstall, setCanInstall] = React.useState(false) - const [installedInfo, setInstalledInfo] = useState<Record<string, VersionInfo> | undefined>(undefined) + const [installedInfo, setInstalledInfo] = useState<Record<string, VersionInfo> | undefined>( + undefined, + ) - const handleLoadedAllPlugin = useCallback((installedInfo: Record<string, VersionInfo> | undefined) => { - handleClickSelectAll() - setInstalledInfo(installedInfo) - setCanInstall(true) - }, []) + const handleLoadedAllPlugin = useCallback( + (installedInfo: Record<string, VersionInfo> | undefined) => { + handleClickSelectAll() + setInstalledInfo(installedInfo) + setCanInstall(true) + }, + [], + ) - const { - check, - stop, - } = checkTaskStatus() + const { check, stop } = checkTaskStatus() const handleCancel = useCallback(() => { stop() @@ -88,48 +95,62 @@ const Install: FC<Props> = ({ // Install from marketplace and github const { mutate: installOrUpdate, isPending: isInstalling } = useInstallOrUpdate({ onSuccess: async (res: InstallStatusResponse[]) => { - const isAllSettled = res.every(r => r.status === TaskStatus.success || r.status === TaskStatus.failed) + const isAllSettled = res.every( + (r) => r.status === TaskStatus.success || r.status === TaskStatus.failed, + ) // if all settled, return the install status if (isAllSettled) { - onInstalled(selectedPlugins, res.map((r, i) => { - return ({ - success: r.status === TaskStatus.success, - isFromMarketPlace: allPlugins[selectedIndexes[i]!]!.type === 'marketplace', - }) - }), getSelectedVersionInfo()) - const hasInstallSuccess = res.some(r => r.status === TaskStatus.success) + onInstalled( + selectedPlugins, + res.map((r, i) => { + return { + success: r.status === TaskStatus.success, + isFromMarketPlace: allPlugins[selectedIndexes[i]!]!.type === 'marketplace', + } + }), + getSelectedVersionInfo(), + ) + const hasInstallSuccess = res.some((r) => r.status === TaskStatus.success) if (hasInstallSuccess) { refreshPluginList(undefined, true) - emit('plugin:install:success', selectedPlugins.map((p) => { - return `${p.plugin_id}/${p.name}` - })) + emit( + 'plugin:install:success', + selectedPlugins.map((p) => { + return `${p.plugin_id}/${p.name}` + }), + ) } return } // if not all settled, keep checking the status of the plugins handleRefetch() - const installStatus = await Promise.all(res.map(async (item, index) => { - if (item.status !== TaskStatus.running) { + const installStatus = await Promise.all( + res.map(async (item, index) => { + if (item.status !== TaskStatus.running) { + return { + success: item.status === TaskStatus.success, + isFromMarketPlace: allPlugins[selectedIndexes[index]!]!.type === 'marketplace', + } + } + const { status } = await check({ + taskId: item.taskId, + pluginUniqueIdentifier: item.uniqueIdentifier, + }) return { - success: item.status === TaskStatus.success, + success: status === TaskStatus.success, isFromMarketPlace: allPlugins[selectedIndexes[index]!]!.type === 'marketplace', } - } - const { status } = await check({ - taskId: item.taskId, - pluginUniqueIdentifier: item.uniqueIdentifier, - }) - return { - success: status === TaskStatus.success, - isFromMarketPlace: allPlugins[selectedIndexes[index]!]!.type === 'marketplace', - } - })) + }), + ) onInstalled(selectedPlugins, installStatus, getSelectedVersionInfo()) - const hasInstallSuccess = installStatus.some(r => r.success) + const hasInstallSuccess = installStatus.some((r) => r.success) if (hasInstallSuccess) { - emit('plugin:install:success', selectedPlugins.map((p) => { - return `${p.plugin_id}/${p.name}` - })) + emit( + 'plugin:install:success', + selectedPlugins.map((p) => { + return `${p.plugin_id}/${p.name}` + }), + ) } }, }) @@ -155,36 +176,46 @@ const Install: FC<Props> = ({ setIsIndeterminate(false) }, []) - const handleSelect = useCallback((plugin: Plugin, selectedIndex: number, allPluginsLength: number) => { - const isSelected = !!selectedPlugins.find(p => p.plugin_id === plugin.plugin_id) - let nextSelectedPlugins - if (isSelected) - nextSelectedPlugins = selectedPlugins.filter(p => p.plugin_id !== plugin.plugin_id) - else - nextSelectedPlugins = [...selectedPlugins, plugin] - setSelectedPlugins(nextSelectedPlugins) - const nextSelectedIndexes = isSelected ? selectedIndexes.filter(i => i !== selectedIndex) : [...selectedIndexes, selectedIndex] - setSelectedIndexes(nextSelectedIndexes) - if (nextSelectedPlugins.length === 0) { - setIsSelectAll(false) - setIsIndeterminate(false) - } - else if (nextSelectedPlugins.length === allPluginsLength) { - setIsSelectAll(true) - setIsIndeterminate(false) - } - else { - setIsIndeterminate(true) - setIsSelectAll(false) - } - }, [selectedPlugins, selectedIndexes]) + const handleSelect = useCallback( + (plugin: Plugin, selectedIndex: number, allPluginsLength: number) => { + const isSelected = !!selectedPlugins.find((p) => p.plugin_id === plugin.plugin_id) + let nextSelectedPlugins + if (isSelected) + nextSelectedPlugins = selectedPlugins.filter((p) => p.plugin_id !== plugin.plugin_id) + else nextSelectedPlugins = [...selectedPlugins, plugin] + setSelectedPlugins(nextSelectedPlugins) + const nextSelectedIndexes = isSelected + ? selectedIndexes.filter((i) => i !== selectedIndex) + : [...selectedIndexes, selectedIndex] + setSelectedIndexes(nextSelectedIndexes) + if (nextSelectedPlugins.length === 0) { + setIsSelectAll(false) + setIsIndeterminate(false) + } else if (nextSelectedPlugins.length === allPluginsLength) { + setIsSelectAll(true) + setIsIndeterminate(false) + } else { + setIsIndeterminate(true) + setIsSelectAll(false) + } + }, + [selectedPlugins, selectedIndexes], + ) const { canInstallPluginFromMarketplace } = useCanInstallPluginFromMarketplace() return ( <div className="flex min-h-0 flex-1 flex-col self-stretch overflow-hidden"> <div className="flex min-h-0 flex-1 flex-col items-start justify-center gap-4 self-stretch overflow-hidden px-6 py-3"> <div className="system-md-regular text-text-secondary"> - <p>{t($ => $[`${i18nPrefix}.${selectedPluginsNum > 1 ? 'readyToInstallPackages' : 'readyToInstallPackage'}`], { ns: 'plugin', num: selectedPluginsNum })}</p> + <p> + {t( + ($) => + $[ + `${i18nPrefix}.${selectedPluginsNum > 1 ? 'readyToInstallPackages' : 'readyToInstallPackage'}` + ], + { ns: 'plugin', num: selectedPluginsNum }, + )} + </p> </div> <div className="flex min-h-0 w-full flex-1 flex-col gap-1 overflow-y-auto rounded-2xl bg-background-section-burn p-2"> <InstallMulti @@ -205,30 +236,46 @@ const Install: FC<Props> = ({ <div className="px-2"> {canInstall && ( <label className="flex cursor-pointer items-center gap-x-2"> - <Checkbox checked={isSelectAll} indeterminate={isIndeterminate} onCheckedChange={() => handleClickSelectAll()} /> - <span className="system-sm-medium text-text-secondary">{isSelectAll ? t($ => $['operation.deSelectAll'], { ns: 'common' }) : t($ => $['operation.selectAll'], { ns: 'common' })}</span> + <Checkbox + checked={isSelectAll} + indeterminate={isIndeterminate} + onCheckedChange={() => handleClickSelectAll()} + /> + <span className="system-sm-medium text-text-secondary"> + {isSelectAll + ? t(($) => $['operation.deSelectAll'], { ns: 'common' }) + : t(($) => $['operation.selectAll'], { ns: 'common' })} + </span> </label> )} </div> <div className="flex items-center justify-end gap-2 self-stretch"> {!canInstall && ( <Button variant="secondary" className="min-w-[72px]" onClick={handleCancel}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> )} <Button variant="primary" className="flex min-w-[72px] space-x-0.5" - disabled={!canInstall || isInstalling || selectedPlugins.length === 0 || !canInstallPluginFromMarketplace} + disabled={ + !canInstall || + isInstalling || + selectedPlugins.length === 0 || + !canInstallPluginFromMarketplace + } onClick={handleInstall} > {isInstalling && <RiLoader2Line className="size-4 animate-spin-slow" />} - <span>{t($ => $[`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`], { ns: 'plugin' })}</span> + <span> + {t(($) => $[`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`], { + ns: 'plugin', + })} + </span> </Button> </div> </div> )} - </div> ) } diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/installed.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/installed.tsx index afc8a48fd4b989..4323f6c22aee3a 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/installed.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/installed.tsx @@ -18,20 +18,17 @@ type Props = Readonly<{ isHideButton?: boolean }> -const Installed: FC<Props> = ({ - list, - installStatus, - versionInfo, - onCancel, - isHideButton, -}) => { +const Installed: FC<Props> = ({ list, installStatus, versionInfo, onCancel, isHideButton }) => { const { t } = useTranslation() const { getIconUrl } = useGetIcon() return ( <> <div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3"> <p className="system-md-regular text-text-secondary"> - {t($ => $['installModal.installedSuccessfullyCountDesc'], { ns: 'plugin', num: list.length })} + {t(($) => $['installModal.installedSuccessfullyCountDesc'], { + ns: 'plugin', + num: list.length, + })} </p> <div className="flex flex-wrap content-start items-start gap-1 space-y-1 self-stretch rounded-2xl bg-background-section-burn p-2"> {list.map((plugin, index) => { @@ -42,15 +39,23 @@ const Installed: FC<Props> = ({ className="w-full" payload={{ ...plugin, - icon: installStatus[index]!.isFromMarketPlace ? `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon` : getIconUrl(plugin.icon), + icon: installStatus[index]!.isFromMarketPlace + ? `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon` + : getIconUrl(plugin.icon), }} installed={installStatus[index]!.success} installFailed={!installStatus[index]!.success} - titleLeft={plugin.version - ? pluginVersionInfo - ? <Version {...pluginVersionInfo} /> - : <Badge className="mx-1" size="s" state={BadgeState.Default}>{plugin.version}</Badge> - : null} + titleLeft={ + plugin.version ? ( + pluginVersionInfo ? ( + <Version {...pluginVersionInfo} /> + ) : ( + <Badge className="mx-1" size="s" state={BadgeState.Default}> + {plugin.version} + </Badge> + ) + ) : null + } compact /> ) @@ -60,12 +65,8 @@ const Installed: FC<Props> = ({ {/* Action Buttons */} {!isHideButton && ( <div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5"> - <Button - variant="primary" - className="min-w-[72px]" - onClick={onCancel} - > - {t($ => $['operation.close'], { ns: 'common' })} + <Button variant="primary" className="min-w-[72px]" onClick={onCancel}> + {t(($) => $['operation.close'], { ns: 'common' })} </Button> </div> )} diff --git a/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx index 4b62041a7d3f05..a95fbf9af2d601 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx @@ -1,8 +1,18 @@ -import type { GitHubRepoReleaseResponse, PluginDeclaration, PluginManifestInMarket, UpdateFromGitHubPayload } from '../../../types' +import type { + GitHubRepoReleaseResponse, + PluginDeclaration, + PluginManifestInMarket, + UpdateFromGitHubPayload, +} from '../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '../../../types' -import { convertRepoToUrl, parseGitHubUrl, pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../../utils' +import { + convertRepoToUrl, + parseGitHubUrl, + pluginManifestInMarketToPluginProps, + pluginManifestToCardPluginProps, +} from '../../utils' import InstallFromGitHub from '../index' // Factory functions for test data (defined before mocks that use them) @@ -32,19 +42,36 @@ const createMockReleases = (): GitHubRepoReleaseResponse[] => [ { tag_name: 'v1.0.0', assets: [ - { id: 1, name: 'plugin-v1.0.0.zip', browser_download_url: 'https://github.com/test/repo/releases/download/v1.0.0/plugin-v1.0.0.zip' }, - { id: 2, name: 'plugin-v1.0.0.tar.gz', browser_download_url: 'https://github.com/test/repo/releases/download/v1.0.0/plugin-v1.0.0.tar.gz' }, + { + id: 1, + name: 'plugin-v1.0.0.zip', + browser_download_url: + 'https://github.com/test/repo/releases/download/v1.0.0/plugin-v1.0.0.zip', + }, + { + id: 2, + name: 'plugin-v1.0.0.tar.gz', + browser_download_url: + 'https://github.com/test/repo/releases/download/v1.0.0/plugin-v1.0.0.tar.gz', + }, ], }, { tag_name: 'v0.9.0', assets: [ - { id: 3, name: 'plugin-v0.9.0.zip', browser_download_url: 'https://github.com/test/repo/releases/download/v0.9.0/plugin-v0.9.0.zip' }, + { + id: 3, + name: 'plugin-v0.9.0.zip', + browser_download_url: + 'https://github.com/test/repo/releases/download/v0.9.0/plugin-v0.9.0.zip', + }, ], }, ] -const createUpdatePayload = (overrides: Partial<UpdateFromGitHubPayload> = {}): UpdateFromGitHubPayload => ({ +const createUpdatePayload = ( + overrides: Partial<UpdateFromGitHubPayload> = {}, +): UpdateFromGitHubPayload => ({ originalPackageInfo: { id: 'original-id', repo: 'owner/repo', @@ -58,7 +85,7 @@ const createUpdatePayload = (overrides: Partial<UpdateFromGitHubPayload> = {}): // Mock external dependencies const mockNotify = vi.fn() vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: Object.assign((props: { type: string, message: string }) => mockNotify(props), { + toast: Object.assign((props: { type: string; message: string }) => mockNotify(props), { success: (message: string) => mockNotify({ type: 'success', message }), error: (message: string) => mockNotify({ type: 'error', message }), warning: (message: string) => mockNotify({ type: 'warning', message }), @@ -116,12 +143,12 @@ vi.mock('../steps/selectPackage', () => ({ }: { repoUrl: string selectedVersion: string - versions: { value: string, name: string }[] - onSelectVersion: (item: { value: string, name: string }) => void + versions: { value: string; name: string }[] + onSelectVersion: (item: { value: string; name: string }) => void selectedPackage: string - packages: { value: string, name: string }[] - onSelectPackage: (item: { value: string, name: string }) => void - onUploaded: (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void + packages: { value: string; name: string }[] + onSelectPackage: (item: { value: string; name: string }) => void + onUploaded: (result: { uniqueIdentifier: string; manifest: PluginDeclaration }) => void onFailed: (errorMsg: string) => void onBack: () => void }) => ( @@ -145,20 +172,21 @@ vi.mock('../steps/selectPackage', () => ({ </button> <button data-testid="trigger-upload-btn" - onClick={() => onUploaded({ - uniqueIdentifier: 'test-unique-id', - manifest: createMockManifest(), - })} + onClick={() => + onUploaded({ + uniqueIdentifier: 'test-unique-id', + manifest: createMockManifest(), + }) + } > Trigger Upload </button> - <button - data-testid="trigger-upload-fail-btn" - onClick={() => onFailed('Upload failed error')} - > + <button data-testid="trigger-upload-fail-btn" onClick={() => onFailed('Upload failed error')}> Trigger Upload Fail </button> - <button data-testid="back-btn" onClick={onBack}>Back</button> + <button data-testid="back-btn" onClick={onBack}> + Back + </button> </div> ), })) @@ -191,18 +219,35 @@ vi.mock('../steps/loaded', () => ({ <span data-testid="loaded-repo-url">{repoUrl}</span> <span data-testid="loaded-version">{selectedVersion}</span> <span data-testid="loaded-package">{selectedPackage}</span> - <button data-testid="loaded-back-btn" onClick={onBack}>Back</button> - <button data-testid="start-install-btn" onClick={onStartToInstall}>Start Install</button> - <button data-testid="install-success-btn" onClick={() => onInstalled()}>Install Success</button> - <button data-testid="install-success-no-refresh-btn" onClick={() => onInstalled(true)}>Install Success No Refresh</button> - <button data-testid="install-fail-btn" onClick={() => onFailed('Install failed')}>Install Fail</button> - <button data-testid="install-fail-no-msg-btn" onClick={() => onFailed()}>Install Fail No Msg</button> + <button data-testid="loaded-back-btn" onClick={onBack}> + Back + </button> + <button data-testid="start-install-btn" onClick={onStartToInstall}> + Start Install + </button> + <button data-testid="install-success-btn" onClick={() => onInstalled()}> + Install Success + </button> + <button data-testid="install-success-no-refresh-btn" onClick={() => onInstalled(true)}> + Install Success No Refresh + </button> + <button data-testid="install-fail-btn" onClick={() => onFailed('Install failed')}> + Install Fail + </button> + <button data-testid="install-fail-no-msg-btn" onClick={() => onFailed()}> + Install Fail No Msg + </button> </div> ), })) vi.mock('../../base/installed', () => ({ - default: ({ payload, isFailed, errMsg, onCancel }: { + default: ({ + payload, + isFailed, + errMsg, + onCancel, + }: { payload: PluginDeclaration | null isFailed: boolean errMsg: string | null @@ -212,7 +257,9 @@ vi.mock('../../base/installed', () => ({ <span data-testid="installed-payload">{payload?.name || 'no-payload'}</span> <span data-testid="is-failed">{isFailed ? 'true' : 'false'}</span> <span data-testid="error-msg">{errMsg || 'no-error'}</span> - <button data-testid="installed-close-btn" onClick={onCancel}>Close</button> + <button data-testid="installed-close-btn" onClick={onCancel}> + Close + </button> </div> ), })) @@ -256,7 +303,9 @@ describe('InstallFromGitHub', () => { render(<InstallFromGitHub {...defaultProps} updatePayload={updatePayload} />) expect(screen.getByTestId('select-package-step')).toBeInTheDocument() - expect(screen.getByTestId('repo-url-display')).toHaveTextContent('https://github.com/owner/repo') + expect(screen.getByTestId('repo-url-display')).toHaveTextContent( + 'https://github.com/owner/repo', + ) }) it('should render install note text in non-terminal steps', () => { @@ -658,7 +707,9 @@ describe('InstallFromGitHub', () => { // ================================ describe('Callback Stability', () => { it('should maintain stable handleUploadFail callback reference', async () => { - const { rerender } = render(<InstallFromGitHub {...defaultProps} updatePayload={createUpdatePayload()} />) + const { rerender } = render( + <InstallFromGitHub {...defaultProps} updatePayload={createUpdatePayload()} />, + ) const firstRender = screen.getByTestId('select-package-step') expect(firstRender).toBeInTheDocument() @@ -807,7 +858,9 @@ describe('InstallFromGitHub', () => { }) // Verify URL is preserved - expect(screen.getByTestId('repo-url-display')).toHaveTextContent('https://github.com/test/myrepo') + expect(screen.getByTestId('repo-url-display')).toHaveTextContent( + 'https://github.com/test/myrepo', + ) // Select version and package fireEvent.click(screen.getByTestId('select-version-btn')) @@ -821,7 +874,9 @@ describe('InstallFromGitHub', () => { }) // Verify all data is preserved - expect(screen.getByTestId('loaded-repo-url')).toHaveTextContent('https://github.com/test/myrepo') + expect(screen.getByTestId('loaded-repo-url')).toHaveTextContent( + 'https://github.com/test/myrepo', + ) expect(screen.getByTestId('loaded-version')).toHaveTextContent('v1.0.0') expect(screen.getByTestId('loaded-package')).toHaveTextContent('package.zip') }) @@ -913,7 +968,9 @@ describe('InstallFromGitHub', () => { fireEvent.click(screen.getByTestId('install-success-btn')) await waitFor(() => { - expect(screen.getByText('plugin.installFromGitHub.installedSuccessfully')).toBeInTheDocument() + expect( + screen.getByText('plugin.installFromGitHub.installedSuccessfully'), + ).toBeInTheDocument() }) }) @@ -998,14 +1055,25 @@ describe('InstallFromGitHub', () => { // Start from selectPackage step expect(screen.getByTestId('select-package-step')).toBeInTheDocument() - expect(screen.getByTestId('repo-url-display')).toHaveTextContent('https://github.com/owner/repo') + expect(screen.getByTestId('repo-url-display')).toHaveTextContent( + 'https://github.com/owner/repo', + ) }) it('should use releases from updatePayload', () => { const customReleases: GitHubRepoReleaseResponse[] = [ - { tag_name: 'v2.0.0', assets: [{ id: 1, name: 'custom.zip', browser_download_url: 'url' }] }, - { tag_name: 'v1.5.0', assets: [{ id: 2, name: 'custom2.zip', browser_download_url: 'url2' }] }, - { tag_name: 'v1.0.0', assets: [{ id: 3, name: 'custom3.zip', browser_download_url: 'url3' }] }, + { + tag_name: 'v2.0.0', + assets: [{ id: 1, name: 'custom.zip', browser_download_url: 'url' }], + }, + { + tag_name: 'v1.5.0', + assets: [{ id: 2, name: 'custom2.zip', browser_download_url: 'url2' }], + }, + { + tag_name: 'v1.0.0', + assets: [{ id: 3, name: 'custom3.zip', browser_download_url: 'url3' }], + }, ] const updatePayload = createUpdatePayload({ @@ -1036,7 +1104,9 @@ describe('InstallFromGitHub', () => { render(<InstallFromGitHub {...defaultProps} updatePayload={updatePayload} />) - expect(screen.getByTestId('repo-url-display')).toHaveTextContent('https://github.com/myorg/myrepo') + expect(screen.getByTestId('repo-url-display')).toHaveTextContent( + 'https://github.com/myorg/myrepo', + ) }) }) @@ -1070,7 +1140,9 @@ describe('InstallFromGitHub', () => { await waitFor(() => { expect(screen.getByTestId('installed-step')).toBeInTheDocument() expect(screen.getByTestId('is-failed')).toHaveTextContent('true') - expect(screen.getByTestId('error-msg')).toHaveTextContent('plugin.installModal.installFailedDesc') + expect(screen.getByTestId('error-msg')).toHaveTextContent( + 'plugin.installModal.installFailedDesc', + ) }) }) }) @@ -1577,11 +1649,7 @@ describe('SelectPackage Component', () => { }) render( - <InstallFromGitHub - onClose={vi.fn()} - onSuccess={vi.fn()} - updatePayload={updatePayload} - />, + <InstallFromGitHub onClose={vi.fn()} onSuccess={vi.fn()} updatePayload={updatePayload} />, ) expect(screen.getByTestId('versions-count')).toHaveTextContent('0') @@ -1599,11 +1667,7 @@ describe('SelectPackage Component', () => { }) render( - <InstallFromGitHub - onClose={vi.fn()} - onSuccess={vi.fn()} - updatePayload={updatePayload} - />, + <InstallFromGitHub onClose={vi.fn()} onSuccess={vi.fn()} updatePayload={updatePayload} />, ) // Select the empty version @@ -1723,7 +1787,9 @@ describe('Loaded Component', () => { fireEvent.click(screen.getByTestId('trigger-upload-btn')) await waitFor(() => { - expect(screen.getByTestId('loaded-repo-url')).toHaveTextContent('https://github.com/owner/repo') + expect(screen.getByTestId('loaded-repo-url')).toHaveTextContent( + 'https://github.com/owner/repo', + ) }) }) @@ -1872,11 +1938,7 @@ describe('Loaded Component', () => { const updatePayload = createUpdatePayload() render( - <InstallFromGitHub - onClose={vi.fn()} - onSuccess={onSuccess} - updatePayload={updatePayload} - />, + <InstallFromGitHub onClose={vi.fn()} onSuccess={onSuccess} updatePayload={updatePayload} />, ) // Navigate to loaded step diff --git a/web/app/components/plugins/install-plugin/install-from-github/index.tsx b/web/app/components/plugins/install-plugin/install-from-github/index.tsx index beb9dd9b681c9d..c42058292356cc 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/index.tsx @@ -35,17 +35,18 @@ type InstallFromGitHubProps = { onSuccess: () => void } -const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, installContextCategory, onClose, onSuccess }) => { +const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ + updatePayload, + installContextCategory, + onClose, + onSuccess, +}) => { const { t } = useTranslation() const { getIconUrl } = useGetIcon() const { refreshPluginList } = useRefreshPluginList() - const { - modalClassName, - foldAnimInto, - setIsInstalling, - handleStartToInstall, - } = useHideLogic(onClose) + const { modalClassName, foldAnimInto, setIsInstalling, handleStartToInstall } = + useHideLogic(onClose) const [state, setState] = useState<InstallState>({ step: updatePayload ? InstallStepFromGitHub.selectPackage : InstallStepFromGitHub.setUrl, @@ -60,58 +61,61 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, in const [manifest, setManifest] = useState<PluginDeclaration | null>(null) const [errorMsg, setErrorMsg] = useState<string | null>(null) - const versions: SelectOption[] = state.releases.map(release => ({ + const versions: SelectOption[] = state.releases.map((release) => ({ value: release.tag_name, name: release.tag_name, })) const packages: SelectOption[] = state.selectedVersion - ? (state.releases - .find(release => release.tag_name === state.selectedVersion) - ?.assets - .map(asset => ({ + ? state.releases + .find((release) => release.tag_name === state.selectedVersion) + ?.assets.map((asset) => ({ value: asset.name, name: asset.name, - })) || []) + })) || [] : [] const getTitle = useCallback(() => { if (state.step === InstallStepFromGitHub.installed) - return t($ => $[`${i18nPrefix}.installedSuccessfully`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.installedSuccessfully`], { ns: 'plugin' }) if (state.step === InstallStepFromGitHub.installFailed) - return t($ => $[`${i18nPrefix}.installFailed`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.installFailed`], { ns: 'plugin' }) - return updatePayload ? t($ => $[`${i18nPrefix}.updatePlugin`], { ns: 'plugin' }) : t($ => $[`${i18nPrefix}.installPlugin`], { ns: 'plugin' }) + return updatePayload + ? t(($) => $[`${i18nPrefix}.updatePlugin`], { ns: 'plugin' }) + : t(($) => $[`${i18nPrefix}.installPlugin`], { ns: 'plugin' }) }, [state.step, t, updatePayload]) const handleUrlSubmit = async () => { const { isValid, owner, repo } = parseGitHubUrl(state.repoUrl) if (!isValid || !owner || !repo) { - toast.error(t($ => $['error.inValidGitHubUrl'], { ns: 'plugin' })) + toast.error(t(($) => $['error.inValidGitHubUrl'], { ns: 'plugin' })) return } try { const fetchedReleases = await fetchReleases(owner, repo) if (fetchedReleases.length > 0) { - setState(prevState => ({ + setState((prevState) => ({ ...prevState, releases: fetchedReleases, step: InstallStepFromGitHub.selectPackage, })) + } else { + toast.error(t(($) => $['error.noReleasesFound'], { ns: 'plugin' })) } - else { - toast.error(t($ => $['error.noReleasesFound'], { ns: 'plugin' })) - } - } - catch { - toast.error(t($ => $['error.fetchReleasesError'], { ns: 'plugin' })) + } catch { + toast.error(t(($) => $['error.fetchReleasesError'], { ns: 'plugin' })) } } const handleError = (e: any, isInstall: boolean) => { - const message = e?.response?.message || t($ => $['installModal.installFailedDesc'], { ns: 'plugin' }) + const message = + e?.response?.message || t(($) => $['installModal.installFailedDesc'], { ns: 'plugin' }) setErrorMsg(message) - setState(prevState => ({ ...prevState, step: isInstall ? InstallStepFromGitHub.installFailed : InstallStepFromGitHub.uploadFailed })) + setState((prevState) => ({ + ...prevState, + step: isInstall ? InstallStepFromGitHub.installFailed : InstallStepFromGitHub.uploadFailed, + })) } const handleUploaded = async (GitHubPackage: any) => { @@ -122,32 +126,35 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, in icon, }) setUniqueIdentifier(GitHubPackage.uniqueIdentifier) - setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.readyToInstall })) - } - catch (e) { + setState((prevState) => ({ ...prevState, step: InstallStepFromGitHub.readyToInstall })) + } catch (e) { handleError(e, false) } } const handleUploadFail = useCallback((errorMsg: string) => { setErrorMsg(errorMsg) - setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.uploadFailed })) + setState((prevState) => ({ ...prevState, step: InstallStepFromGitHub.uploadFailed })) }, []) - const handleInstalled = useCallback((notRefresh?: boolean) => { - setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.installed })) - if (!notRefresh) - refreshPluginList(manifest) - setIsInstalling(false) - onSuccess() - }, [manifest, onSuccess, refreshPluginList, setIsInstalling]) + const handleInstalled = useCallback( + (notRefresh?: boolean) => { + setState((prevState) => ({ ...prevState, step: InstallStepFromGitHub.installed })) + if (!notRefresh) refreshPluginList(manifest) + setIsInstalling(false) + onSuccess() + }, + [manifest, onSuccess, refreshPluginList, setIsInstalling], + ) - const handleFailed = useCallback((errorMsg?: string) => { - setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.installFailed })) - setIsInstalling(false) - if (errorMsg) - setErrorMsg(errorMsg) - }, [setIsInstalling]) + const handleFailed = useCallback( + (errorMsg?: string) => { + setState((prevState) => ({ ...prevState, step: InstallStepFromGitHub.installFailed })) + setIsInstalling(false) + if (errorMsg) setErrorMsg(errorMsg) + }, + [setIsInstalling], + ) const handleBack = () => { setState((prevState) => { @@ -166,108 +173,128 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, in <Dialog open onOpenChange={(open) => { - if (!open) - foldAnimInto() + if (!open) foldAnimInto() }} > <DialogContent backdropProps={{ forceRender: true }} - className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, `shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] - border-components-panel-border bg-components-panel-bg p-0`))} + className={cn( + 'w-[560px] max-w-none! overflow-hidden! text-left align-middle', + cn( + modalClassName, + `shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0`, + ), + )} > <DialogCloseButton /> <div className="flex shrink-0 items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> <div className="flex grow flex-col items-start gap-1"> - <div className="self-stretch title-2xl-semi-bold text-text-primary"> - {getTitle()} - </div> + <div className="self-stretch title-2xl-semi-bold text-text-primary">{getTitle()}</div> <div className="self-stretch system-xs-regular text-text-tertiary"> - {!([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step)) && t($ => $['installFromGitHub.installNote'], { ns: 'plugin' })} + {![ + InstallStepFromGitHub.uploadFailed, + InstallStepFromGitHub.installed, + InstallStepFromGitHub.installFailed, + ].includes(state.step) && + t(($) => $['installFromGitHub.installNote'], { ns: 'plugin' })} </div> </div> </div> - {([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step)) - ? ( - <Installed - payload={manifest} - isFailed={[InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installFailed].includes(state.step)} - errMsg={errorMsg} - installContextCategory={installContextCategory} - onCancel={onClose} - /> - ) - : ( - <div className={`flex min-h-0 flex-1 flex-col items-start justify-center self-stretch overflow-y-auto px-6 py-3 ${state.step === InstallStepFromGitHub.installed ? 'gap-2' : 'gap-4'}`}> - {state.step === InstallStepFromGitHub.setUrl && ( - <Form - onFormSubmit={handleUrlSubmit} - className="flex flex-col items-start gap-4 self-stretch" - > - <Field name="repoUrl" className="gap-4 self-stretch"> - <FieldLabel className="flex w-full flex-col items-start justify-center p-0 text-text-secondary"> - <span className="system-sm-semibold">{t($ => $['installFromGitHub.gitHubRepo'], { ns: 'plugin' })}</span> - </FieldLabel> - <FieldControl - autoFocus - type="text" - inputMode="url" - value={state.repoUrl} - onValueChange={value => setState(prevState => ({ ...prevState, repoUrl: value }))} - className="flex grow items-center gap-0.5 self-stretch overflow-hidden rounded-lg border-components-input-border-active bg-components-input-bg-active p-2 text-ellipsis" - placeholder="Please enter GitHub repo URL" - /> - </Field> - <div className="mt-4 flex items-center justify-end gap-2 self-stretch"> - <Button - variant="secondary" - className="min-w-18" - onClick={onClose} - > - {t($ => $['installModal.cancel'], { ns: 'plugin' })} - </Button> - <Button - variant="primary" - type="submit" - className="min-w-18" - disabled={!state.repoUrl.trim()} - > - {t($ => $['installModal.next'], { ns: 'plugin' })} - </Button> - </div> - </Form> - )} - {state.step === InstallStepFromGitHub.selectPackage && ( - <SelectPackage - updatePayload={updatePayload!} - repoUrl={state.repoUrl} - selectedVersion={state.selectedVersion} - versions={versions} - onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: String(item.value) }))} - selectedPackage={state.selectedPackage} - packages={packages} - onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: String(item.value) }))} - onUploaded={handleUploaded} - onFailed={handleUploadFail} - onBack={handleBack} - /> - )} - {state.step === InstallStepFromGitHub.readyToInstall && manifest && uniqueIdentifier && ( - <Loaded - updatePayload={updatePayload!} - uniqueIdentifier={uniqueIdentifier} - payload={manifest} - repoUrl={state.repoUrl} - selectedVersion={state.selectedVersion} - selectedPackage={state.selectedPackage} - onBack={handleBack} - onStartToInstall={handleStartToInstall} - onInstalled={handleInstalled} - onFailed={handleFailed} + {[ + InstallStepFromGitHub.uploadFailed, + InstallStepFromGitHub.installed, + InstallStepFromGitHub.installFailed, + ].includes(state.step) ? ( + <Installed + payload={manifest} + isFailed={[ + InstallStepFromGitHub.uploadFailed, + InstallStepFromGitHub.installFailed, + ].includes(state.step)} + errMsg={errorMsg} + installContextCategory={installContextCategory} + onCancel={onClose} + /> + ) : ( + <div + className={`flex min-h-0 flex-1 flex-col items-start justify-center self-stretch overflow-y-auto px-6 py-3 ${state.step === InstallStepFromGitHub.installed ? 'gap-2' : 'gap-4'}`} + > + {state.step === InstallStepFromGitHub.setUrl && ( + <Form + onFormSubmit={handleUrlSubmit} + className="flex flex-col items-start gap-4 self-stretch" + > + <Field name="repoUrl" className="gap-4 self-stretch"> + <FieldLabel className="flex w-full flex-col items-start justify-center p-0 text-text-secondary"> + <span className="system-sm-semibold"> + {t(($) => $['installFromGitHub.gitHubRepo'], { ns: 'plugin' })} + </span> + </FieldLabel> + <FieldControl + autoFocus + type="text" + inputMode="url" + value={state.repoUrl} + onValueChange={(value) => + setState((prevState) => ({ ...prevState, repoUrl: value })) + } + className="flex grow items-center gap-0.5 self-stretch overflow-hidden rounded-lg border-components-input-border-active bg-components-input-bg-active p-2 text-ellipsis" + placeholder="Please enter GitHub repo URL" /> - )} - </div> + </Field> + <div className="mt-4 flex items-center justify-end gap-2 self-stretch"> + <Button variant="secondary" className="min-w-18" onClick={onClose}> + {t(($) => $['installModal.cancel'], { ns: 'plugin' })} + </Button> + <Button + variant="primary" + type="submit" + className="min-w-18" + disabled={!state.repoUrl.trim()} + > + {t(($) => $['installModal.next'], { ns: 'plugin' })} + </Button> + </div> + </Form> )} + {state.step === InstallStepFromGitHub.selectPackage && ( + <SelectPackage + updatePayload={updatePayload!} + repoUrl={state.repoUrl} + selectedVersion={state.selectedVersion} + versions={versions} + onSelectVersion={(item) => + setState((prevState) => ({ ...prevState, selectedVersion: String(item.value) })) + } + selectedPackage={state.selectedPackage} + packages={packages} + onSelectPackage={(item) => + setState((prevState) => ({ ...prevState, selectedPackage: String(item.value) })) + } + onUploaded={handleUploaded} + onFailed={handleUploadFail} + onBack={handleBack} + /> + )} + {state.step === InstallStepFromGitHub.readyToInstall && + manifest && + uniqueIdentifier && ( + <Loaded + updatePayload={updatePayload!} + uniqueIdentifier={uniqueIdentifier} + payload={manifest} + repoUrl={state.repoUrl} + selectedVersion={state.selectedVersion} + selectedPackage={state.selectedPackage} + onBack={handleBack} + onStartToInstall={handleStartToInstall} + onInstalled={handleInstalled} + onFailed={handleFailed} + /> + )} + </div> + )} </DialogContent> </Dialog> ) diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx index 20162dee9835f3..4f58e55f9e63c0 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx @@ -7,7 +7,7 @@ import Loaded from '../loaded' // Mock dependencies const mockUseCheckInstalled = vi.fn() vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ - default: (params: { pluginIds: string[], enabled: boolean }) => mockUseCheckInstalled(params), + default: (params: { pluginIds: string[]; enabled: boolean }) => mockUseCheckInstalled(params), })) const mockUpdateFromGitHub = vi.fn() @@ -29,7 +29,7 @@ vi.mock('../../../base/check-task-status', () => ({ // Mock Card component vi.mock('../../../../card', () => ({ - default: ({ payload, titleLeft }: { payload: Plugin, titleLeft?: React.ReactNode }) => ( + default: ({ payload, titleLeft }: { payload: Plugin; titleLeft?: React.ReactNode }) => ( <div data-testid="plugin-card"> <span data-testid="card-name">{payload.name}</span> {!!titleLeft && <span data-testid="title-left">{titleLeft}</span>} @@ -39,13 +39,19 @@ vi.mock('../../../../card', () => ({ // Mock Version component vi.mock('../../../base/version', () => ({ - default: ({ hasInstalled, installedVersion, toInstallVersion }: { + default: ({ + hasInstalled, + installedVersion, + toInstallVersion, + }: { hasInstalled: boolean installedVersion?: string toInstallVersion: string }) => ( <span data-testid="version-info"> - {hasInstalled ? `Update from ${installedVersion} to ${toInstallVersion}` : `Install ${toInstallVersion}`} + {hasInstalled + ? `Update from ${installedVersion} to ${toInstallVersion}` + : `Install ${toInstallVersion}`} </span> ), })) @@ -158,7 +164,9 @@ describe('Loaded', () => { it('should render install button', () => { render(<Loaded {...defaultProps} />) - expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /plugin.installModal.install/i }), + ).toBeInTheDocument() }) it('should show version info in card title', () => { @@ -203,7 +211,9 @@ describe('Loaded', () => { it('should enable install button when not loading', () => { render(<Loaded {...defaultProps} />) - expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /plugin.installModal.install/i }), + ).not.toBeDisabled() }) }) @@ -316,7 +326,10 @@ describe('Loaded', () => { fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) await waitFor(() => { - expect(mockHandleInstallTaskStart).toHaveBeenCalledWith({ all_installed: false, task_id: 'task-1' }) + expect(mockHandleInstallTaskStart).toHaveBeenCalledWith({ + all_installed: false, + task_id: 'task-1', + }) expect(mockCheck).toHaveBeenCalledWith({ taskId: 'task-1', pluginUniqueIdentifier: 'test-unique-id', @@ -400,7 +413,9 @@ describe('Loaded', () => { }) const onInstalled = vi.fn() - render(<Loaded {...defaultProps} payload={createMockPluginPayload()} onInstalled={onInstalled} />) + render( + <Loaded {...defaultProps} payload={createMockPluginPayload()} onInstalled={onInstalled} />, + ) expect(onInstalled).toHaveBeenCalled() }) @@ -417,7 +432,9 @@ describe('Loaded', () => { }) const onInstalled = vi.fn() - render(<Loaded {...defaultProps} payload={createMockPluginPayload()} onInstalled={onInstalled} />) + render( + <Loaded {...defaultProps} payload={createMockPluginPayload()} onInstalled={onInstalled} />, + ) expect(onInstalled).not.toHaveBeenCalled() }) @@ -428,27 +445,35 @@ describe('Loaded', () => { // ================================ describe('Installing State', () => { it('should hide back button while installing', async () => { - let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void - mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => { - resolveInstall = resolve - })) + let resolveInstall: (value: { all_installed: boolean; task_id: string }) => void + mockInstallPackageFromGitHub.mockImplementation( + () => + new Promise((resolve) => { + resolveInstall = resolve + }), + ) render(<Loaded {...defaultProps} />) fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) await waitFor(() => { - expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'plugin.installModal.back' }), + ).not.toBeInTheDocument() }) resolveInstall!({ all_installed: true, task_id: 'task-1' }) }) it('should show installing text while installing', async () => { - let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void - mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => { - resolveInstall = resolve - })) + let resolveInstall: (value: { all_installed: boolean; task_id: string }) => void + mockInstallPackageFromGitHub.mockImplementation( + () => + new Promise((resolve) => { + resolveInstall = resolve + }), + ) render(<Loaded {...defaultProps} />) @@ -462,10 +487,13 @@ describe('Loaded', () => { }) it('should not trigger install twice when already installing', async () => { - let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void - mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => { - resolveInstall = resolve - })) + let resolveInstall: (value: { all_installed: boolean; task_id: string }) => void + mockInstallPackageFromGitHub.mockImplementation( + () => + new Promise((resolve) => { + resolveInstall = resolve + }), + ) render(<Loaded {...defaultProps} />) diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/selectPackage.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/selectPackage.spec.tsx index 6c990722262bbe..1b5ec618c255b3 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/selectPackage.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/selectPackage.spec.tsx @@ -29,7 +29,11 @@ vi.mock('@langgenius/dify-ui/select', async () => { }>({}) return { - Select: ({ children, readOnly, onValueChange }: { + Select: ({ + children, + readOnly, + onValueChange, + }: { children: React.ReactNode readOnly?: boolean onValueChange?: (value: string) => void @@ -43,20 +47,31 @@ vi.mock('@langgenius/dify-ui/select', async () => { const context = React.useContext(SelectContext) return ( <div> - <div data-testid="select-trigger" className={context.readOnly ? 'cursor-not-allowed' : 'cursor-pointer'}> + <div + data-testid="select-trigger" + className={context.readOnly ? 'cursor-not-allowed' : 'cursor-pointer'} + > {children} </div> - <button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}> + <button + data-testid="select-empty" + type="button" + onClick={() => context.onValueChange?.('')} + > empty select value </button> - <button data-testid="select-invalid" type="button" onClick={() => context.onValueChange?.('__missing__')}> + <button + data-testid="select-invalid" + type="button" + onClick={() => context.onValueChange?.('__missing__')} + > invalid select value </button> </div> ) }, SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => { const context = React.useContext(SelectContext) return ( <button type="button" onClick={() => context.onValueChange?.(value)}> @@ -121,7 +136,7 @@ type TestProps = { selectedPackage?: string packages?: SelectOption[] onSelectPackage?: (item: SelectOption) => void - onUploaded?: (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void + onUploaded?: (result: { uniqueIdentifier: string; manifest: PluginDeclaration }) => void onFailed?: (errorMsg: string) => void onBack?: () => void } @@ -136,7 +151,10 @@ describe('SelectPackage', () => { selectedPackage: '', packages: createPackages(), onSelectPackage: vi.fn() as (item: SelectOption) => void, - onUploaded: vi.fn() as (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void, + onUploaded: vi.fn() as (result: { + uniqueIdentifier: string + manifest: PluginDeclaration + }) => void, onFailed: vi.fn() as (errorMsg: string) => void, onBack: vi.fn() as () => void, }) @@ -152,8 +170,7 @@ describe('SelectPackage', () => { const labelElement = screen.getByText(label) const labelContainer = labelElement.closest('label') ?? labelElement.parentElement const section = labelContainer?.parentElement - if (!(section instanceof HTMLElement)) - throw new Error(`Missing section for ${label}`) + if (!(section instanceof HTMLElement)) throw new Error(`Missing section for ${label}`) return section } @@ -187,7 +204,9 @@ describe('SelectPackage', () => { it('should not render back button when in edit mode', () => { renderSelectPackage({ updatePayload: createUpdatePayload() }) - expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'plugin.installModal.back' }), + ).not.toBeInTheDocument() }) it('should render next button', () => { @@ -338,7 +357,10 @@ describe('SelectPackage', () => { const section = getSection('plugin.installFromGitHub.selectPackage') fireEvent.click(within(section).getByRole('button', { name: 'plugin.tar.gz' })) - expect(onSelectPackage).toHaveBeenCalledWith({ value: 'plugin.tar.gz', name: 'plugin.tar.gz' }) + expect(onSelectPackage).toHaveBeenCalledWith({ + value: 'plugin.tar.gz', + name: 'plugin.tar.gz', + }) }) }) @@ -405,9 +427,12 @@ describe('SelectPackage', () => { it('should not call upload twice when already uploading', async () => { let resolveUpload: (value?: unknown) => void - mockHandleUpload.mockImplementation(() => new Promise((resolve) => { - resolveUpload = resolve - })) + mockHandleUpload.mockImplementation( + () => + new Promise((resolve) => { + resolveUpload = resolve + }), + ) renderSelectPackage({ selectedVersion: 'v1.0.0', @@ -431,9 +456,12 @@ describe('SelectPackage', () => { it('should disable back button while uploading', async () => { let resolveUpload: (value?: unknown) => void - mockHandleUpload.mockImplementation(() => new Promise((resolve) => { - resolveUpload = resolve - })) + mockHandleUpload.mockImplementation( + () => + new Promise((resolve) => { + resolveUpload = resolve + }), + ) renderSelectPackage({ selectedVersion: 'v1.0.0', @@ -491,7 +519,9 @@ describe('SelectPackage', () => { renderSelectPackage({ updatePayload: createUpdatePayload() }) // Should not show back button in edit mode - expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'plugin.installModal.back' }), + ).not.toBeInTheDocument() }) it('should re-enable buttons after upload completes', async () => { @@ -609,9 +639,12 @@ describe('SelectPackage', () => { it('should disable next button when uploading even with valid selections', async () => { let resolveUpload: (value?: unknown) => void - mockHandleUpload.mockImplementation(() => new Promise((resolve) => { - resolveUpload = resolve - })) + mockHandleUpload.mockImplementation( + () => + new Promise((resolve) => { + resolveUpload = resolve + }), + ) renderSelectPackage({ selectedVersion: 'v1.0.0', @@ -705,7 +738,9 @@ describe('SelectPackage', () => { renderSelectPackage({ updatePayload }) // Back button should not be rendered in edit mode - expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'plugin.installModal.back' }), + ).not.toBeInTheDocument() }) it('should set isEdit to false when updatePayload is undefined', () => { @@ -854,9 +889,12 @@ describe('SelectPackage', () => { describe('Upload State Management', () => { it('should set isUploading to true when upload starts', async () => { let resolveUpload: (value?: unknown) => void - mockHandleUpload.mockImplementation(() => new Promise((resolve) => { - resolveUpload = resolve - })) + mockHandleUpload.mockImplementation( + () => + new Promise((resolve) => { + resolveUpload = resolve + }), + ) renderSelectPackage({ selectedVersion: 'v1.0.0', @@ -910,9 +948,12 @@ describe('SelectPackage', () => { it('should not allow back button click while uploading', async () => { let resolveUpload: (value?: unknown) => void - mockHandleUpload.mockImplementation(() => new Promise((resolve) => { - resolveUpload = resolve - })) + mockHandleUpload.mockImplementation( + () => + new Promise((resolve) => { + resolveUpload = resolve + }), + ) const onBack = vi.fn() renderSelectPackage({ diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx index ecb8933550d272..12dbb6c63d41cd 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx @@ -59,13 +59,11 @@ const Loaded: React.FC<LoadedProps> = ({ const { check } = checkTaskStatus() useEffect(() => { - if (hasInstalled && uniqueIdentifier === installedInfoPayload.uniqueIdentifier) - onInstalled() + if (hasInstalled && uniqueIdentifier === installedInfoPayload.uniqueIdentifier) onInstalled() }, [hasInstalled]) const handleInstall = async () => { - if (isInstalling) - return + if (isInstalling) return setIsInstalling(true) onStartToInstall?.() @@ -86,8 +84,7 @@ const Loaded: React.FC<LoadedProps> = ({ taskId = task_id isInstalled = all_installed - } - else { + } else { if (hasInstalled) { const response = await updateFromGitHub( `${owner}/${repo}`, @@ -96,15 +93,11 @@ const Loaded: React.FC<LoadedProps> = ({ installedInfoPayload.uniqueIdentifier, uniqueIdentifier, ) - const { - all_installed, - task_id, - } = response + const { all_installed, task_id } = response handleInstallTaskStart(response) taskId = task_id isInstalled = all_installed - } - else { + } else { const response = await installPackageFromGitHub({ repoUrl: `${owner}/${repo}`, selectedVersion, @@ -132,15 +125,13 @@ const Loaded: React.FC<LoadedProps> = ({ return } onInstalled(true) - } - catch (e) { + } catch (e) { if (typeof e === 'string') { onFailed(e) return } onFailed() - } - finally { + } finally { setIsInstalling(false) } } @@ -148,25 +139,27 @@ const Loaded: React.FC<LoadedProps> = ({ return ( <> <div className="system-md-regular text-text-secondary"> - <p>{t($ => $[`${i18nPrefix}.readyToInstall`], { ns: 'plugin' })}</p> + <p>{t(($) => $[`${i18nPrefix}.readyToInstall`], { ns: 'plugin' })}</p> </div> <div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2"> <Card className="w-full" payload={pluginManifestToCardPluginProps(payload as PluginDeclaration)} - titleLeft={!isLoading && ( - <Version - hasInstalled={hasInstalled} - installedVersion={installedVersion} - toInstallVersion={toInstallVersion} - /> - )} + titleLeft={ + !isLoading && ( + <Version + hasInstalled={hasInstalled} + installedVersion={installedVersion} + toInstallVersion={toInstallVersion} + /> + ) + } /> </div> <div className="mt-4 flex items-center justify-end gap-2 self-stretch"> {!isInstalling && ( <Button variant="secondary" className="min-w-[72px]" onClick={onBack}> - {t($ => $['installModal.back'], { ns: 'plugin' })} + {t(($) => $['installModal.back'], { ns: 'plugin' })} </Button> )} <Button @@ -176,7 +169,11 @@ const Loaded: React.FC<LoadedProps> = ({ disabled={isInstalling || isLoading} > {isInstalling && <RiLoader2Line className="size-4 animate-spin-slow" />} - <span>{t($ => $[`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`], { ns: 'plugin' })}</span> + <span> + {t(($) => $[`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`], { + ns: 'plugin', + })} + </span> </Button> </div> </> diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx index a17dbb31ddabde..3a50d1a4d7a687 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx @@ -3,7 +3,15 @@ import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types' import { Button } from '@langgenius/dify-ui/button' import { Field } from '@langgenius/dify-ui/field' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectLabel, + SelectTrigger, +} from '@langgenius/dify-ui/select' import * as React from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' @@ -25,10 +33,7 @@ type SelectPackageProps = { selectedPackage: string packages: SelectOption[] onSelectPackage: (item: SelectOption) => void - onUploaded: (result: { - uniqueIdentifier: string - manifest: PluginDeclaration - }) => void + onUploaded: (result: { uniqueIdentifier: string; manifest: PluginDeclaration }) => void onFailed: (errorMsg: string) => void onBack: () => void } @@ -49,12 +54,11 @@ const SelectPackage: React.FC<SelectPackageProps> = ({ const { t } = useTranslation() const isEdit = Boolean(updatePayload) const [isUploading, setIsUploading] = React.useState(false) - const selectedVersionOption = versions.find(item => item.value === selectedVersion) ?? null - const selectedPackageOption = packages.find(item => item.value === selectedPackage) ?? null + const selectedVersionOption = versions.find((item) => item.value === selectedVersion) ?? null + const selectedPackageOption = packages.find((item) => item.value === selectedPackage) ?? null const handleUploadPackage = async () => { - if (isUploading) - return + if (isUploading) return setIsUploading(true) try { const repo = repoUrl.replace('https://github.com/', '') @@ -64,14 +68,10 @@ const SelectPackage: React.FC<SelectPackageProps> = ({ manifest: GitHubPackage.manifest, }) }) - } - catch (e: any) { - if (e.response?.message) - onFailed(e.response?.message) - else - onFailed(t($ => $[`${i18nPrefix}.uploadFailed`], { ns: 'plugin' })) - } - finally { + } catch (e: any) { + if (e.response?.message) onFailed(e.response?.message) + else onFailed(t(($) => $[`${i18nPrefix}.uploadFailed`], { ns: 'plugin' })) + } finally { setIsUploading(false) } } @@ -82,38 +82,42 @@ const SelectPackage: React.FC<SelectPackageProps> = ({ <Select value={selectedVersionOption?.value ?? null} onValueChange={(value) => { - if (value == null) - return - const selectedItem = versions.find(item => item.value === value) - if (selectedItem) - onSelectVersion(selectedItem) + if (value == null) return + const selectedItem = versions.find((item) => item.value === value) + if (selectedItem) onSelectVersion(selectedItem) }} > <SelectLabel className="flex w-full flex-col items-start justify-center p-0 text-text-secondary"> - <span className="system-sm-semibold">{t($ => $[`${i18nPrefix}.selectVersion`], { ns: 'plugin' })}</span> + <span className="system-sm-semibold"> + {t(($) => $[`${i18nPrefix}.selectVersion`], { ns: 'plugin' })} + </span> </SelectLabel> <SelectTrigger className="h-9 text-components-input-text-filled"> <div className="flex items-center justify-between gap-2"> <span className="truncate"> - {selectedVersionOption?.name ?? t($ => $[`${i18nPrefix}.selectVersionPlaceholder`], { ns: 'plugin' }) ?? ''} + {selectedVersionOption?.name ?? + t(($) => $[`${i18nPrefix}.selectVersionPlaceholder`], { ns: 'plugin' }) ?? + ''} </span> - {!!(updatePayload?.originalPackageInfo.version && selectedVersionOption && selectedVersionOption.value !== updatePayload.originalPackageInfo.version) && ( + {!!( + updatePayload?.originalPackageInfo.version && + selectedVersionOption && + selectedVersionOption.value !== updatePayload.originalPackageInfo.version + ) && ( <Badge> - {updatePayload.originalPackageInfo.version} - {' '} - {'->'} - {' '} - {selectedVersionOption.value} + {updatePayload.originalPackageInfo.version} {'->'} {selectedVersionOption.value} </Badge> )} </div> </SelectTrigger> <SelectContent popupClassName="w-[512px]"> - {versions.map(item => ( + {versions.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> {item.value === updatePayload?.originalPackageInfo.version && ( - <Badge uppercase={true} className="ml-1 shrink-0">INSTALLED</Badge> + <Badge uppercase={true} className="ml-1 shrink-0"> + INSTALLED + </Badge> )} <SelectItemIndicator /> </SelectItem> @@ -126,21 +130,23 @@ const SelectPackage: React.FC<SelectPackageProps> = ({ value={selectedPackageOption?.value ?? null} readOnly={!selectedVersion} onValueChange={(value) => { - if (value == null) - return - const selectedItem = packages.find(item => item.value === value) - if (selectedItem) - onSelectPackage(selectedItem) + if (value == null) return + const selectedItem = packages.find((item) => item.value === value) + if (selectedItem) onSelectPackage(selectedItem) }} > <SelectLabel className="flex w-full flex-col items-start justify-center p-0 text-text-secondary"> - <span className="system-sm-semibold">{t($ => $[`${i18nPrefix}.selectPackage`], { ns: 'plugin' })}</span> + <span className="system-sm-semibold"> + {t(($) => $[`${i18nPrefix}.selectPackage`], { ns: 'plugin' })} + </span> </SelectLabel> <SelectTrigger className="h-9 text-components-input-text-filled"> - {selectedPackageOption?.name ?? t($ => $[`${i18nPrefix}.selectPackagePlaceholder`], { ns: 'plugin' }) ?? ''} + {selectedPackageOption?.name ?? + t(($) => $[`${i18nPrefix}.selectPackagePlaceholder`], { ns: 'plugin' }) ?? + ''} </SelectTrigger> <SelectContent popupClassName="w-[512px]"> - {packages.map(item => ( + {packages.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -150,24 +156,23 @@ const SelectPackage: React.FC<SelectPackageProps> = ({ </Select> </Field> <div className="mt-4 flex items-center justify-end gap-2 self-stretch"> - {!isEdit - && ( - <Button - variant="secondary" - className="min-w-[72px]" - onClick={onBack} - disabled={isUploading} - > - {t($ => $['installModal.back'], { ns: 'plugin' })} - </Button> - )} + {!isEdit && ( + <Button + variant="secondary" + className="min-w-[72px]" + onClick={onBack} + disabled={isUploading} + > + {t(($) => $['installModal.back'], { ns: 'plugin' })} + </Button> + )} <Button variant="primary" className="min-w-[72px]" onClick={handleUploadPackage} disabled={!selectedVersion || !selectedPackage || isUploading} > - {t($ => $['installModal.next'], { ns: 'plugin' })} + {t(($) => $['installModal.next'], { ns: 'plugin' })} </Button> </div> </> diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx index 78ec1fff8739ac..a8cb48fc277b69 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx @@ -69,7 +69,9 @@ vi.mock('../../hooks/use-hide-logic', () => ({ })) // Mock child components -let uploadingOnPackageUploaded: ((result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void) | null = null +let uploadingOnPackageUploaded: + | ((result: { uniqueIdentifier: string; manifest: PluginDeclaration }) => void) + | null = null let uploadingOnBundleUploaded: ((result: Dependency[]) => void) | null = null let _uploadingOnFailed: ((errorMsg: string) => void) | null = null @@ -85,7 +87,7 @@ vi.mock('../steps/uploading', () => ({ isBundle: boolean file: File onCancel: () => void - onPackageUploaded: (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void + onPackageUploaded: (result: { uniqueIdentifier: string; manifest: PluginDeclaration }) => void onBundleUploaded: (result: Dependency[]) => void onFailed: (errorMsg: string) => void }) => { @@ -96,13 +98,17 @@ vi.mock('../steps/uploading', () => ({ <div data-testid="uploading-step"> <span data-testid="is-bundle">{isBundle ? 'true' : 'false'}</span> <span data-testid="file-name">{file.name}</span> - <button data-testid="cancel-upload-btn" onClick={onCancel}>Cancel</button> + <button data-testid="cancel-upload-btn" onClick={onCancel}> + Cancel + </button> <button data-testid="trigger-package-upload-btn" - onClick={() => onPackageUploaded({ - uniqueIdentifier: 'test-unique-id', - manifest: createMockManifest(), - })} + onClick={() => + onPackageUploaded({ + uniqueIdentifier: 'test-unique-id', + manifest: createMockManifest(), + }) + } > Trigger Package Upload </button> @@ -158,8 +164,12 @@ vi.mock('../ready-to-install', () => ({ <span data-testid="package-unique-identifier">{uniqueIdentifier || 'null'}</span> <span data-testid="package-manifest-name">{manifest?.name || 'null'}</span> <span data-testid="package-error-msg">{errorMsg || 'null'}</span> - <button data-testid="package-close-btn" onClick={onClose}>Close</button> - <button data-testid="package-start-install-btn" onClick={onStartToInstall}>Start Install</button> + <button data-testid="package-close-btn" onClick={onClose}> + Close + </button> + <button data-testid="package-start-install-btn" onClick={onStartToInstall}> + Start Install + </button> <button data-testid="package-step-installed-btn" onClick={() => onStepChange(InstallStep.installed)} @@ -178,10 +188,7 @@ vi.mock('../ready-to-install', () => ({ > Set Not Installing </button> - <button - data-testid="package-set-error-btn" - onClick={() => onError('Custom error message')} - > + <button data-testid="package-set-error-btn" onClick={() => onError('Custom error message')}> Set Error </button> </div> @@ -214,8 +221,12 @@ vi.mock('../../install-bundle/ready-to-install', () => ({ <div data-testid="ready-to-install-bundle"> <span data-testid="bundle-step">{step}</span> <span data-testid="bundle-plugins-count">{allPlugins.length}</span> - <button data-testid="bundle-close-btn" onClick={onClose}>Close</button> - <button data-testid="bundle-start-install-btn" onClick={onStartToInstall}>Start Install</button> + <button data-testid="bundle-close-btn" onClick={onClose}> + Close + </button> + <button data-testid="bundle-start-install-btn" onClick={onStartToInstall}> + Start Install + </button> <button data-testid="bundle-step-installed-btn" onClick={() => onStepChange(InstallStep.installed)} @@ -999,7 +1010,9 @@ describe('InstallFromLocalPackage', () => { it('should handle different file types correctly', () => { // Package file - const { rerender } = render(<InstallFromLocalPackage {...defaultProps} file={createMockFile('test.difypkg')} />) + const { rerender } = render( + <InstallFromLocalPackage {...defaultProps} file={createMockFile('test.difypkg')} />, + ) expect(screen.getByTestId('is-bundle')).toHaveTextContent('false') // Bundle file diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/ready-to-install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/ready-to-install.spec.tsx index 05b7625d023d49..cbf65f92a84c86 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/ready-to-install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/ready-to-install.spec.tsx @@ -65,7 +65,9 @@ vi.mock('../steps/install', () => ({ <div data-testid="install-step"> <span data-testid="install-uid">{uniqueIdentifier}</span> <span data-testid="install-payload-name">{payload.name}</span> - <button data-testid="install-cancel-btn" onClick={onCancel}>Cancel</button> + <button data-testid="install-cancel-btn" onClick={onCancel}> + Cancel + </button> <button data-testid="install-start-btn" onClick={() => onStartToInstall?.()}> Start Install </button> @@ -103,7 +105,9 @@ vi.mock('../../base/installed', () => ({ <span data-testid="installed-payload-name">{payload?.name || 'null'}</span> <span data-testid="installed-is-failed">{isFailed ? 'true' : 'false'}</span> <span data-testid="installed-err-msg">{errMsg || 'null'}</span> - <button data-testid="installed-cancel-btn" onClick={onCancel}>Close</button> + <button data-testid="installed-cancel-btn" onClick={onCancel}> + Close + </button> </div> ), })) @@ -188,11 +192,7 @@ describe('ReadyToInstall', () => { it('should pass errorMsg to Installed component', () => { render( - <ReadyToInstall - {...defaultProps} - step={InstallStep.installFailed} - errorMsg="Some error" - />, + <ReadyToInstall {...defaultProps} step={InstallStep.installFailed} errorMsg="Some error" />, ) expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('Some error') @@ -342,7 +342,11 @@ describe('ReadyToInstall', () => { it('should handle transition from readyToInstall to installed', () => { const onStepChange = vi.fn() const { rerender } = render( - <ReadyToInstall {...defaultProps} step={InstallStep.readyToInstall} onStepChange={onStepChange} />, + <ReadyToInstall + {...defaultProps} + step={InstallStep.readyToInstall} + onStepChange={onStepChange} + />, ) // Initially shows Install component @@ -354,7 +358,13 @@ describe('ReadyToInstall', () => { expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed) // Rerender with new step - rerender(<ReadyToInstall {...defaultProps} step={InstallStep.installed} onStepChange={onStepChange} />) + rerender( + <ReadyToInstall + {...defaultProps} + step={InstallStep.installed} + onStepChange={onStepChange} + />, + ) // Now shows Installed component expect(screen.getByTestId('installed-step')).toBeInTheDocument() @@ -363,7 +373,11 @@ describe('ReadyToInstall', () => { it('should handle transition from readyToInstall to installFailed', () => { const onStepChange = vi.fn() const { rerender } = render( - <ReadyToInstall {...defaultProps} step={InstallStep.readyToInstall} onStepChange={onStepChange} />, + <ReadyToInstall + {...defaultProps} + step={InstallStep.readyToInstall} + onStepChange={onStepChange} + />, ) // Initially shows Install component @@ -375,7 +389,13 @@ describe('ReadyToInstall', () => { expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed) // Rerender with new step - rerender(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} onStepChange={onStepChange} />) + rerender( + <ReadyToInstall + {...defaultProps} + step={InstallStep.installFailed} + onStepChange={onStepChange} + />, + ) // Now shows Installed component with failed state expect(screen.getByTestId('installed-step')).toBeInTheDocument() diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx index eb96942dd1f8fa..3ee20e96152079 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx @@ -36,46 +36,39 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({ const isBundle = file.name.endsWith('.difybndl') const [dependencies, setDependencies] = useState<Dependency[]>([]) - const { - modalClassName, - foldAnimInto, - setIsInstalling, - handleStartToInstall, - } = useHideLogic(onClose) + const { modalClassName, foldAnimInto, setIsInstalling, handleStartToInstall } = + useHideLogic(onClose) const getTitle = useCallback(() => { if (step === InstallStep.uploadFailed) - return t($ => $[`${i18nPrefix}.uploadFailed`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.uploadFailed`], { ns: 'plugin' }) if (isBundle && step === InstallStep.installed) - return t($ => $[`${i18nPrefix}.installedSuccessfully`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.installedSuccessfully`], { ns: 'plugin' }) if (step === InstallStep.installed) - return t($ => $[`${i18nPrefix}.installedSuccessfully`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.installedSuccessfully`], { ns: 'plugin' }) if (step === InstallStep.installFailed) - return t($ => $[`${i18nPrefix}.installFailed`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.installFailed`], { ns: 'plugin' }) - return t($ => $[`${i18nPrefix}.installPlugin`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.installPlugin`], { ns: 'plugin' }) }, [isBundle, step, t]) const { getIconUrl } = useGetIcon() - const handlePackageUploaded = useCallback(async (result: { - uniqueIdentifier: string - manifest: PluginDeclaration - }) => { - const { - manifest, - uniqueIdentifier, - } = result - const icon = await getIconUrl(manifest!.icon) - const iconDark = manifest.icon_dark ? await getIconUrl(manifest.icon_dark) : undefined - setUniqueIdentifier(uniqueIdentifier) - setManifest({ - ...manifest, - icon, - icon_dark: iconDark, - }) - setStep(InstallStep.readyToInstall) - }, [getIconUrl]) + const handlePackageUploaded = useCallback( + async (result: { uniqueIdentifier: string; manifest: PluginDeclaration }) => { + const { manifest, uniqueIdentifier } = result + const icon = await getIconUrl(manifest!.icon) + const iconDark = manifest.icon_dark ? await getIconUrl(manifest.icon_dark) : undefined + setUniqueIdentifier(uniqueIdentifier) + setManifest({ + ...manifest, + icon, + icon_dark: iconDark, + }) + setStep(InstallStep.readyToInstall) + }, + [getIconUrl], + ) const handleBundleUploaded = useCallback((result: Dependency[]) => { setDependencies(result) @@ -91,20 +84,23 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({ <Dialog open onOpenChange={(open) => { - if (!open) - foldAnimInto() + if (!open) foldAnimInto() }} > <DialogContent backdropProps={{ forceRender: true }} - className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))} + className={cn( + 'w-[560px] max-w-none! overflow-hidden! text-left align-middle', + cn( + modalClassName, + 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0', + ), + )} > <DialogCloseButton /> <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> - <div className="self-stretch title-2xl-semi-bold text-text-primary"> - {getTitle()} - </div> + <div className="self-stretch title-2xl-semi-bold text-text-primary">{getTitle()}</div> </div> {step === InstallStep.uploading && ( <Uploading @@ -116,31 +112,29 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({ onFailed={handleUploadFail} /> )} - {isBundle - ? ( - <ReadyToInstallBundle - step={step} - onStepChange={setStep} - onStartToInstall={handleStartToInstall} - setIsInstalling={setIsInstalling} - onClose={onClose} - allPlugins={dependencies} - /> - ) - : ( - <ReadyToInstallPackage - step={step} - onStepChange={setStep} - onStartToInstall={handleStartToInstall} - setIsInstalling={setIsInstalling} - onClose={onClose} - uniqueIdentifier={uniqueIdentifier} - manifest={manifest} - errorMsg={errorMsg} - installContextCategory={installContextCategory} - onError={setErrorMsg} - /> - )} + {isBundle ? ( + <ReadyToInstallBundle + step={step} + onStepChange={setStep} + onStartToInstall={handleStartToInstall} + setIsInstalling={setIsInstalling} + onClose={onClose} + allPlugins={dependencies} + /> + ) : ( + <ReadyToInstallPackage + step={step} + onStepChange={setStep} + onStartToInstall={handleStartToInstall} + setIsInstalling={setIsInstalling} + onClose={onClose} + uniqueIdentifier={uniqueIdentifier} + manifest={manifest} + errorMsg={errorMsg} + installContextCategory={installContextCategory} + onError={setErrorMsg} + /> + )} </DialogContent> </Dialog> ) diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.tsx index 11feeb54a19458..6cdc31fdd884c7 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.tsx @@ -35,45 +35,47 @@ const ReadyToInstall: FC<Props> = ({ }) => { const { refreshPluginList } = useRefreshPluginList() - const handleInstalled = useCallback((notRefresh?: boolean) => { - onStepChange(InstallStep.installed) - if (!notRefresh) - refreshPluginList(manifest) - setIsInstalling(false) - }, [manifest, onStepChange, refreshPluginList, setIsInstalling]) + const handleInstalled = useCallback( + (notRefresh?: boolean) => { + onStepChange(InstallStep.installed) + if (!notRefresh) refreshPluginList(manifest) + setIsInstalling(false) + }, + [manifest, onStepChange, refreshPluginList, setIsInstalling], + ) - const handleFailed = useCallback((errorMsg?: string) => { - onStepChange(InstallStep.installFailed) - setIsInstalling(false) - if (errorMsg) - onError(errorMsg) - }, [onError, onStepChange, setIsInstalling]) + const handleFailed = useCallback( + (errorMsg?: string) => { + onStepChange(InstallStep.installFailed) + setIsInstalling(false) + if (errorMsg) onError(errorMsg) + }, + [onError, onStepChange, setIsInstalling], + ) return ( <> - { - step === InstallStep.readyToInstall && ( - <Install - uniqueIdentifier={uniqueIdentifier!} - payload={manifest!} - onCancel={onClose} - onInstalled={handleInstalled} - onFailed={handleFailed} - onStartToInstall={onStartToInstall} - /> - ) - } - { - ([InstallStep.uploadFailed, InstallStep.installed, InstallStep.installFailed].includes(step)) && ( - <Installed - payload={manifest} - isFailed={[InstallStep.uploadFailed, InstallStep.installFailed].includes(step)} - errMsg={errorMsg} - installContextCategory={installContextCategory} - onCancel={onClose} - /> - ) - } + {step === InstallStep.readyToInstall && ( + <Install + uniqueIdentifier={uniqueIdentifier!} + payload={manifest!} + onCancel={onClose} + onInstalled={handleInstalled} + onFailed={handleFailed} + onStartToInstall={onStartToInstall} + /> + )} + {[InstallStep.uploadFailed, InstallStep.installed, InstallStep.installFailed].includes( + step, + ) && ( + <Installed + payload={manifest} + isFailed={[InstallStep.uploadFailed, InstallStep.installFailed].includes(step)} + errMsg={errorMsg} + installContextCategory={installContextCategory} + onCancel={onClose} + /> + )} </> ) } diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx index cd8c2d187dcad0..522411e86245bc 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx @@ -88,7 +88,10 @@ vi.mock('jotai', () => ({ })) vi.mock('../../../../card', () => ({ - default: ({ payload, titleLeft }: { + default: ({ + payload, + titleLeft, + }: { payload: Record<string, unknown> titleLeft?: React.ReactNode }) => ( @@ -100,7 +103,11 @@ vi.mock('../../../../card', () => ({ })) vi.mock('../../../base/version', () => ({ - default: ({ hasInstalled, installedVersion, toInstallVersion }: { + default: ({ + hasInstalled, + installedVersion, + toInstallVersion, + }: { hasInstalled: boolean installedVersion?: string toInstallVersion: string @@ -175,7 +182,9 @@ describe('Install', () => { it('should render install button', () => { render(<Install {...defaultProps} />) - expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'plugin.installModal.install' }), + ).toBeInTheDocument() }) it('should show version component when not loading', () => { @@ -296,7 +305,9 @@ describe('Install', () => { fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) await waitFor(() => { - expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.cancel' }), + ).not.toBeInTheDocument() }) }) }) @@ -490,7 +501,9 @@ describe('Install', () => { describe('Dify Version Compatibility', () => { it('should not show warning when dify version is compatible', () => { mockAppContextState.langGeniusVersionInfo.current_version = '1.0.0' - const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '0.8.0' } }) + const payload = createMockManifest({ + meta: { version: '1.0.0', minimum_dify_version: '0.8.0' }, + }) render(<Install {...defaultProps} payload={payload} />) @@ -499,7 +512,9 @@ describe('Install', () => { it('should show warning when dify version is incompatible', () => { mockAppContextState.langGeniusVersionInfo.current_version = '1.0.0' - const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } }) + const payload = createMockManifest({ + meta: { version: '1.0.0', minimum_dify_version: '2.0.0' }, + }) render(<Install {...defaultProps} payload={payload} />) @@ -517,7 +532,9 @@ describe('Install', () => { it('should be compatible when current_version is empty', () => { mockAppContextState.langGeniusVersionInfo.current_version = '' - const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } }) + const payload = createMockManifest({ + meta: { version: '1.0.0', minimum_dify_version: '2.0.0' }, + }) render(<Install {...defaultProps} payload={payload} />) @@ -527,7 +544,9 @@ describe('Install', () => { it('should be compatible when current_version is undefined', () => { mockAppContextState.langGeniusVersionInfo.current_version = undefined as unknown as string - const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } }) + const payload = createMockManifest({ + meta: { version: '1.0.0', minimum_dify_version: '2.0.0' }, + }) render(<Install {...defaultProps} payload={payload} />) @@ -560,7 +579,9 @@ describe('Install', () => { fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) await waitFor(() => { - expect(screen.getByRole('button', { name: /plugin.installModal.installing/ })).toBeDisabled() + expect( + screen.getByRole('button', { name: /plugin.installModal.installing/ }), + ).toBeDisabled() }) }) @@ -610,13 +631,7 @@ describe('Install', () => { }) const onInstalled = vi.fn() - render( - <Install - {...defaultProps} - onStartToInstall={undefined} - onInstalled={onInstalled} - />, - ) + render(<Install {...defaultProps} onStartToInstall={undefined} onInstalled={onInstalled} />) fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx index 674d6a451c2a06..8ca05de7d86946 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx @@ -49,7 +49,11 @@ vi.mock('@/service/plugins', () => ({ })) vi.mock('../../../../card', () => ({ - default: ({ payload, isLoading, loadingFileName }: { + default: ({ + payload, + isLoading, + loadingFileName, + }: { payload: { name: string } isLoading?: boolean loadingFileName?: string @@ -175,13 +179,7 @@ describe('Uploading', () => { }) const onPackageUploaded = vi.fn() - render( - <Uploading - {...defaultProps} - isBundle={false} - onPackageUploaded={onPackageUploaded} - />, - ) + render(<Uploading {...defaultProps} isBundle={false} onPackageUploaded={onPackageUploaded} />) await waitFor(() => { expect(onPackageUploaded).toHaveBeenCalledWith({ @@ -199,13 +197,7 @@ describe('Uploading', () => { mockUploadFile.mockResolvedValue(mockResult) const onPackageUploaded = vi.fn() - render( - <Uploading - {...defaultProps} - isBundle={false} - onPackageUploaded={onPackageUploaded} - />, - ) + render(<Uploading {...defaultProps} isBundle={false} onPackageUploaded={onPackageUploaded} />) await waitFor(() => { expect(onPackageUploaded).toHaveBeenCalledWith({ @@ -222,13 +214,7 @@ describe('Uploading', () => { }) const onBundleUploaded = vi.fn() - render( - <Uploading - {...defaultProps} - isBundle - onBundleUploaded={onBundleUploaded} - />, - ) + render(<Uploading {...defaultProps} isBundle onBundleUploaded={onBundleUploaded} />) await waitFor(() => { expect(onBundleUploaded).toHaveBeenCalledWith(mockDependencies) @@ -240,13 +226,7 @@ describe('Uploading', () => { mockUploadFile.mockResolvedValue(mockDependencies) const onBundleUploaded = vi.fn() - render( - <Uploading - {...defaultProps} - isBundle - onBundleUploaded={onBundleUploaded} - />, - ) + render(<Uploading {...defaultProps} isBundle onBundleUploaded={onBundleUploaded} />) await waitFor(() => { expect(onBundleUploaded).toHaveBeenCalledWith(mockDependencies) @@ -292,7 +272,9 @@ describe('Uploading', () => { render(<Uploading {...defaultProps} file={file} />) // The message includes the file name as a parameter - expect(screen.getByText(/plugin\.installModal\.uploadingPackage/)).toHaveTextContent('special-plugin.difypkg') + expect(screen.getByText(/plugin\.installModal\.uploadingPackage/)).toHaveTextContent( + 'special-plugin.difypkg', + ) }) }) @@ -307,7 +289,9 @@ describe('Uploading', () => { const onPackageUploaded = vi.fn() const onFailed = vi.fn() - render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />) + render( + <Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />, + ) await waitFor(() => { expect(onPackageUploaded).not.toHaveBeenCalled() @@ -322,7 +306,9 @@ describe('Uploading', () => { const onPackageUploaded = vi.fn() const onFailed = vi.fn() - render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />) + render( + <Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />, + ) await waitFor(() => { expect(onPackageUploaded).not.toHaveBeenCalled() @@ -337,7 +323,9 @@ describe('Uploading', () => { const onPackageUploaded = vi.fn() const onFailed = vi.fn() - render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />) + render( + <Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />, + ) await waitFor(() => { expect(onPackageUploaded).not.toHaveBeenCalled() diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx index c185e0b9ae365f..0f353066c2cb15 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx @@ -49,17 +49,13 @@ const Installed: FC<Props> = ({ const hasInstalled = !!installedVersion useEffect(() => { - if (hasInstalled && uniqueIdentifier === installedInfoPayload.uniqueIdentifier) - onInstalled() + if (hasInstalled && uniqueIdentifier === installedInfoPayload.uniqueIdentifier) onInstalled() }, [hasInstalled]) const [isInstalling, setIsInstalling] = React.useState(false) const { mutateAsync: installPackageFromLocal } = useInstallPackageFromLocal() - const { - check, - stop, - } = checkTaskStatus() + const { check, stop } = checkTaskStatus() const handleCancel = () => { stop() @@ -68,20 +64,15 @@ const Installed: FC<Props> = ({ const { handleInstallTaskStart } = usePluginTaskList(payload.category) const handleInstall = async () => { - if (isInstalling) - return + if (isInstalling) return setIsInstalling(true) onStartToInstall?.() try { - if (hasInstalled) - await uninstallPlugin(installedInfoPayload.installedId) + if (hasInstalled) await uninstallPlugin(installedInfoPayload.installedId) const response = await installPackageFromLocal(uniqueIdentifier) - const { - all_installed, - task_id, - } = response + const { all_installed, task_id } = response handleInstallTaskStart(response) const taskId = task_id const isInstalled = all_installed @@ -99,8 +90,7 @@ const Installed: FC<Props> = ({ return } onInstalled(true) - } - catch (e) { + } catch (e) { if (typeof e === 'string') { onFailed(e) return @@ -111,26 +101,31 @@ const Installed: FC<Props> = ({ const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) const isDifyVersionCompatible = useMemo(() => { - if (!langGeniusVersionInfo.current_version) - return true - return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version ?? '0.0.0') + if (!langGeniusVersionInfo.current_version) return true + return isEqualOrLaterThanVersion( + langGeniusVersionInfo.current_version, + payload.meta.minimum_dify_version ?? '0.0.0', + ) }, [langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version]) return ( <> <div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3"> <div className="system-md-regular text-text-secondary"> - <p>{t($ => $[`${i18nPrefix}.readyToInstall`], { ns: 'plugin' })}</p> + <p>{t(($) => $[`${i18nPrefix}.readyToInstall`], { ns: 'plugin' })}</p> <p> <Trans - i18nKey={$ => $[`${i18nPrefix}.fromTrustSource`]} + i18nKey={($) => $[`${i18nPrefix}.fromTrustSource`]} ns="plugin" components={{ trustSource: <span className="system-md-semibold" /> }} /> </p> {!isDifyVersionCompatible && ( <p className="flex items-center gap-1 system-md-regular text-text-warning"> - {t($ => $.difyVersionNotCompatible, { ns: 'plugin', minimalDifyVersion: payload.meta.minimum_dify_version })} + {t(($) => $.difyVersionNotCompatible, { + ns: 'plugin', + minimalDifyVersion: payload.meta.minimum_dify_version, + })} </p> )} </div> @@ -138,13 +133,15 @@ const Installed: FC<Props> = ({ <Card className="w-full" payload={pluginManifestToCardPluginProps(payload)} - titleLeft={!isLoading && ( - <Version - hasInstalled={hasInstalled} - installedVersion={installedVersion} - toInstallVersion={toInstallVersion} - /> - )} + titleLeft={ + !isLoading && ( + <Version + hasInstalled={hasInstalled} + installedVersion={installedVersion} + toInstallVersion={toInstallVersion} + /> + ) + } /> </div> </div> @@ -152,7 +149,7 @@ const Installed: FC<Props> = ({ <div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5"> {!isInstalling && ( <Button variant="secondary" className="min-w-[72px]" onClick={handleCancel}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> )} <Button @@ -162,7 +159,11 @@ const Installed: FC<Props> = ({ onClick={handleInstall} > {isInstalling && <RiLoader2Line className="size-4 animate-spin-slow" />} - <span>{t($ => $[`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`], { ns: 'plugin' })}</span> + <span> + {t(($) => $[`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`], { + ns: 'plugin', + })} + </span> </Button> </div> </> diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx index 890f9781ee4614..9b6a14355a90d3 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx @@ -23,22 +23,19 @@ function isObject(value: unknown): value is Record<string, unknown> { } function isPackageUploadResponse(value: unknown): value is PackageUploadResponse { - if (!isObject(value)) - return false + if (!isObject(value)) return false return typeof value.unique_identifier === 'string' && isObject(value.manifest) } function getRejectedResponse(error: unknown): unknown { - if (!isObject(error) || !('response' in error)) - return undefined + if (!isObject(error) || !('response' in error)) return undefined return error.response } function getUploadFailureMessage(response: unknown): string | undefined { - if (!isObject(response)) - return undefined + if (!isObject(response)) return undefined return (response as UploadFailureResponse).message } @@ -47,10 +44,7 @@ type Props = Readonly<{ isBundle: boolean file: File onCancel: () => void - onPackageUploaded: (result: { - uniqueIdentifier: string - manifest: PluginDeclaration - }) => void + onPackageUploaded: (result: { uniqueIdentifier: string; manifest: PluginDeclaration }) => void onBundleUploaded: (result: Dependency[]) => void onFailed: (errorMsg: string) => void }> @@ -65,32 +59,34 @@ const Uploading: FC<Props> = ({ }) => { const { t } = useTranslation() const fileName = file.name - const handleUploadedResponse = React.useCallback((response: unknown) => { - if (isBundle) { - if (Array.isArray(response)) { - onBundleUploaded(response as Dependency[]) + const handleUploadedResponse = React.useCallback( + (response: unknown) => { + if (isBundle) { + if (Array.isArray(response)) { + onBundleUploaded(response as Dependency[]) + return + } + onFailed(t(($) => $[`${i18nPrefix}.uploadFailed`], { ns: 'plugin' })) return } - onFailed(t($ => $[`${i18nPrefix}.uploadFailed`], { ns: 'plugin' })) - return - } - if (!isPackageUploadResponse(response)) { - onFailed(t($ => $[`${i18nPrefix}.uploadFailed`], { ns: 'plugin' })) - return - } + if (!isPackageUploadResponse(response)) { + onFailed(t(($) => $[`${i18nPrefix}.uploadFailed`], { ns: 'plugin' })) + return + } - onPackageUploaded({ - uniqueIdentifier: response.unique_identifier, - manifest: response.manifest, - }) - }, [isBundle, onBundleUploaded, onFailed, onPackageUploaded, t]) + onPackageUploaded({ + uniqueIdentifier: response.unique_identifier, + manifest: response.manifest, + }) + }, + [isBundle, onBundleUploaded, onFailed, onPackageUploaded, t], + ) const handleUpload = React.useCallback(async () => { try { handleUploadedResponse(await uploadFile(file, isBundle)) - } - catch (error: unknown) { + } catch (error: unknown) { const response = getRejectedResponse(error) const message = getUploadFailureMessage(response) if (message) { @@ -110,7 +106,7 @@ const Uploading: FC<Props> = ({ <div className="flex items-center gap-1 self-stretch"> <span className="i-ri-loader-2-line size-4 animate-spin-slow text-text-accent" /> <div className="system-md-regular text-text-secondary"> - {t($ => $[`${i18nPrefix}.uploadingPackage`], { + {t(($) => $[`${i18nPrefix}.uploadingPackage`], { ns: 'plugin', packageName: fileName, })} @@ -130,14 +126,10 @@ const Uploading: FC<Props> = ({ {/* Action Buttons */} <div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5"> <Button variant="secondary" className="min-w-[72px]" onClick={onCancel}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button - variant="primary" - className="min-w-[72px]" - disabled - > - {t($ => $[`${i18nPrefix}.install`], { ns: 'plugin' })} + <Button variant="primary" className="min-w-[72px]" disabled> + {t(($) => $[`${i18nPrefix}.install`], { ns: 'plugin' })} </Button> </div> </> diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace-query.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace-query.tsx index 7ca45a54616fd8..22d6ea09a38ce2 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace-query.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace-query.tsx @@ -21,11 +21,9 @@ const InstallFromMarketplaceQuery = ({ marketplaceInstall, } = useInstallFromMarketplaceQueryHook(options) - if (!isShowInstallFromMarketplace) - return null + if (!isShowInstallFromMarketplace) return null - if (!marketplaceInstall && !bundleInfo) - return null + if (!marketplaceInstall && !bundleInfo) return null return ( <InstallFromMarketplace diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/__tests__/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/__tests__/index.spec.tsx index aa65306fff9bd4..1b4ad803fa1149 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/__tests__/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/__tests__/index.spec.tsx @@ -6,7 +6,9 @@ import InstallFromMarketplace from '../index' // Factory functions for test data // Use type casting to avoid strict locale requirements in tests -const createMockManifest = (overrides: Partial<PluginManifestInMarket> = {}): PluginManifestInMarket => ({ +const createMockManifest = ( + overrides: Partial<PluginManifestInMarket> = {}, +): PluginManifestInMarket => ({ plugin_unique_identifier: 'test-unique-identifier', name: 'Test Plugin', org: 'test-org', @@ -103,12 +105,24 @@ vi.mock('../steps/install', () => ({ <div data-testid="install-step"> <span data-testid="unique-identifier">{uniqueIdentifier}</span> <span data-testid="payload-name">{payload?.name}</span> - <button data-testid="cancel-btn" onClick={onCancel}>Cancel</button> - <button data-testid="start-install-btn" onClick={onStartToInstall}>Start Install</button> - <button data-testid="install-success-btn" onClick={() => onInstalled()}>Install Success</button> - <button data-testid="install-success-no-refresh-btn" onClick={() => onInstalled(true)}>Install Success No Refresh</button> - <button data-testid="install-fail-btn" onClick={() => onFailed('Installation failed')}>Install Fail</button> - <button data-testid="install-fail-no-msg-btn" onClick={() => onFailed()}>Install Fail No Msg</button> + <button data-testid="cancel-btn" onClick={onCancel}> + Cancel + </button> + <button data-testid="start-install-btn" onClick={onStartToInstall}> + Start Install + </button> + <button data-testid="install-success-btn" onClick={() => onInstalled()}> + Install Success + </button> + <button data-testid="install-success-no-refresh-btn" onClick={() => onInstalled(true)}> + Install Success No Refresh + </button> + <button data-testid="install-fail-btn" onClick={() => onFailed('Installation failed')}> + Install Fail + </button> + <button data-testid="install-fail-no-msg-btn" onClick={() => onFailed()}> + Install Fail No Msg + </button> </div> ), })) @@ -135,12 +149,30 @@ vi.mock('../../install-bundle/ready-to-install', () => ({ <span data-testid="bundle-step-value">{step}</span> <span data-testid="bundle-plugins-count">{allPlugins?.length || 0}</span> <span data-testid="is-from-marketplace">{isFromMarketPlace ? 'true' : 'false'}</span> - <button data-testid="bundle-cancel-btn" onClick={onClose}>Cancel</button> - <button data-testid="bundle-start-install-btn" onClick={onStartToInstall}>Start Install</button> - <button data-testid="bundle-set-installing-true" onClick={() => setIsInstalling(true)}>Set Installing True</button> - <button data-testid="bundle-set-installing-false" onClick={() => setIsInstalling(false)}>Set Installing False</button> - <button data-testid="bundle-change-to-installed" onClick={() => onStepChange(InstallStep.installed)}>Change to Installed</button> - <button data-testid="bundle-change-to-failed" onClick={() => onStepChange(InstallStep.installFailed)}>Change to Failed</button> + <button data-testid="bundle-cancel-btn" onClick={onClose}> + Cancel + </button> + <button data-testid="bundle-start-install-btn" onClick={onStartToInstall}> + Start Install + </button> + <button data-testid="bundle-set-installing-true" onClick={() => setIsInstalling(true)}> + Set Installing True + </button> + <button data-testid="bundle-set-installing-false" onClick={() => setIsInstalling(false)}> + Set Installing False + </button> + <button + data-testid="bundle-change-to-installed" + onClick={() => onStepChange(InstallStep.installed)} + > + Change to Installed + </button> + <button + data-testid="bundle-change-to-failed" + onClick={() => onStepChange(InstallStep.installFailed)} + > + Change to Failed + </button> </div> ), })) @@ -164,7 +196,9 @@ vi.mock('../../base/installed', () => ({ <span data-testid="is-market-payload">{isMarketPayload ? 'true' : 'false'}</span> <span data-testid="is-failed">{isFailed ? 'true' : 'false'}</span> <span data-testid="error-msg">{errMsg || 'no-error'}</span> - <button data-testid="installed-close-btn" onClick={onCancel}>Close</button> + <button data-testid="installed-close-btn" onClick={onCancel}> + Close + </button> </div> ), })) @@ -201,7 +235,9 @@ describe('InstallFromMarketplace', () => { it('should expose the current step title as the dialog name', () => { render(<InstallFromMarketplace {...defaultProps} />) - expect(screen.getByRole('dialog', { name: 'plugin.installModal.installPlugin' })).toBeInTheDocument() + expect( + screen.getByRole('dialog', { name: 'plugin.installModal.installPlugin' }), + ).toBeInTheDocument() }) it('should initially focus the safe cancel action', async () => { @@ -215,11 +251,7 @@ describe('InstallFromMarketplace', () => { it('should render with bundle step when isBundle is true', () => { const dependencies = createMockDependencies() render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={dependencies} - />, + <InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={dependencies} />, ) expect(screen.getByTestId('bundle-step')).toBeInTheDocument() @@ -229,11 +261,7 @@ describe('InstallFromMarketplace', () => { it('should constrain bundle dialog height so dependency lists can scroll', () => { const dependencies = createMockDependencies() render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={dependencies} - />, + <InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={dependencies} />, ) expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-48px)]') @@ -242,11 +270,7 @@ describe('InstallFromMarketplace', () => { it('should pass isFromMarketPlace as true to bundle component', () => { const dependencies = createMockDependencies() render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={dependencies} - />, + <InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={dependencies} />, ) expect(screen.getByTestId('is-from-marketplace')).toHaveTextContent('true') @@ -287,11 +311,7 @@ describe('InstallFromMarketplace', () => { it('should show bundle complete title when bundle installation completes', async () => { const dependencies = createMockDependencies() render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={dependencies} - />, + <InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={dependencies} />, ) fireEvent.click(screen.getByTestId('bundle-change-to-installed')) @@ -307,7 +327,9 @@ describe('InstallFromMarketplace', () => { fireEvent.click(screen.getByTestId('install-fail-btn')) await waitFor(() => { - expect(screen.getByRole('dialog', { name: 'plugin.installModal.installFailed' })).toBeInTheDocument() + expect( + screen.getByRole('dialog', { name: 'plugin.installModal.installFailed' }), + ).toBeInTheDocument() }) }) }) @@ -356,11 +378,7 @@ describe('InstallFromMarketplace', () => { it('should update step via onStepChange in bundle mode', async () => { const dependencies = createMockDependencies() render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={dependencies} - />, + <InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={dependencies} />, ) fireEvent.click(screen.getByTestId('bundle-change-to-installed')) @@ -454,11 +472,7 @@ describe('InstallFromMarketplace', () => { it('should call onClose in bundle mode cancel', () => { const dependencies = createMockDependencies() render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={dependencies} - />, + <InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={dependencies} />, ) fireEvent.click(screen.getByTestId('bundle-cancel-btn')) @@ -519,11 +533,7 @@ describe('InstallFromMarketplace', () => { it('should pass setIsInstalling to bundle component', () => { const dependencies = createMockDependencies() render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={dependencies} - />, + <InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={dependencies} />, ) fireEvent.click(screen.getByTestId('bundle-set-installing-true')) @@ -585,35 +595,20 @@ describe('InstallFromMarketplace', () => { describe('Prop Variations', () => { it('should work with Plugin type manifest', () => { const plugin = createMockPlugin() - render( - <InstallFromMarketplace - {...defaultProps} - manifest={plugin} - />, - ) + render(<InstallFromMarketplace {...defaultProps} manifest={plugin} />) expect(screen.getByTestId('payload-name')).toHaveTextContent('Test Plugin') }) it('should work with PluginManifestInMarket type manifest', () => { const manifest = createMockManifest({ name: 'Market Plugin' }) - render( - <InstallFromMarketplace - {...defaultProps} - manifest={manifest} - />, - ) + render(<InstallFromMarketplace {...defaultProps} manifest={manifest} />) expect(screen.getByTestId('payload-name')).toHaveTextContent('Market Plugin') }) it('should handle different uniqueIdentifier values', () => { - render( - <InstallFromMarketplace - {...defaultProps} - uniqueIdentifier="custom-unique-id-123" - />, - ) + render(<InstallFromMarketplace {...defaultProps} uniqueIdentifier="custom-unique-id-123" />) expect(screen.getByTestId('unique-identifier')).toHaveTextContent('custom-unique-id-123') }) @@ -626,25 +621,14 @@ describe('InstallFromMarketplace', () => { }) it('should work with isBundle=false', () => { - render( - <InstallFromMarketplace - {...defaultProps} - isBundle={false} - />, - ) + render(<InstallFromMarketplace {...defaultProps} isBundle={false} />) expect(screen.getByTestId('install-step')).toBeInTheDocument() expect(screen.queryByTestId('bundle-step')).not.toBeInTheDocument() }) it('should work with empty dependencies array in bundle mode', () => { - render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={[]} - />, - ) + render(<InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={[]} />) expect(screen.getByTestId('bundle-step')).toBeInTheDocument() expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('0') @@ -660,12 +644,7 @@ describe('InstallFromMarketplace', () => { name: 'Minimal', version: '0.0.1', }) - render( - <InstallFromMarketplace - {...defaultProps} - manifest={minimalManifest} - />, - ) + render(<InstallFromMarketplace {...defaultProps} manifest={minimalManifest} />) expect(screen.getByTestId('payload-name')).toHaveTextContent('Minimal') }) @@ -687,11 +666,7 @@ describe('InstallFromMarketplace', () => { it('should handle bundle mode step changes', async () => { const dependencies = createMockDependencies() render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={dependencies} - />, + <InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={dependencies} />, ) // Change to installed step @@ -705,11 +680,7 @@ describe('InstallFromMarketplace', () => { it('should handle bundle mode failure step change', async () => { const dependencies = createMockDependencies() render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={dependencies} - />, + <InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={dependencies} />, ) fireEvent.click(screen.getByTestId('bundle-change-to-failed')) @@ -812,11 +783,7 @@ describe('InstallFromMarketplace', () => { it('should pass dependencies to bundle component', () => { const dependencies = createMockDependencies() render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={dependencies} - />, + <InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={dependencies} />, ) expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2') @@ -825,11 +792,7 @@ describe('InstallFromMarketplace', () => { it('should pass current step to bundle component', () => { const dependencies = createMockDependencies() render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={dependencies} - />, + <InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={dependencies} />, ) expect(screen.getByTestId('bundle-step-value')).toHaveTextContent(InstallStep.readyToInstall) @@ -928,11 +891,7 @@ describe('InstallFromMarketplace', () => { it('should return installComplete for bundle installed step', async () => { const dependencies = createMockDependencies() render( - <InstallFromMarketplace - {...defaultProps} - isBundle={true} - dependencies={dependencies} - />, + <InstallFromMarketplace {...defaultProps} isBundle={true} dependencies={dependencies} />, ) fireEvent.click(screen.getByTestId('bundle-change-to-installed')) diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx index 5dac9075046c95..cee68e8d7ffa2e 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx @@ -50,88 +50,89 @@ const InstallFromMarketplace: React.FC<InstallFromMarketplaceProps> = ({ const getTitle = useCallback(() => { if (isBundle && step === InstallStep.installed) - return t($ => $[`${i18nPrefix}.installedSuccessfully`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.installedSuccessfully`], { ns: 'plugin' }) if (step === InstallStep.installed) - return t($ => $[`${i18nPrefix}.installedSuccessfully`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.installedSuccessfully`], { ns: 'plugin' }) if (step === InstallStep.installFailed) - return t($ => $[`${i18nPrefix}.installFailed`], { ns: 'plugin' }) - return t($ => $[`${i18nPrefix}.installPlugin`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.installFailed`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.installPlugin`], { ns: 'plugin' }) }, [isBundle, step, t]) - const handleInstalled = useCallback((notRefresh?: boolean) => { - setStep(InstallStep.installed) - if (!notRefresh) - refreshPluginList(manifest) - setIsInstalling(false) - }, [manifest, refreshPluginList, setIsInstalling]) + const handleInstalled = useCallback( + (notRefresh?: boolean) => { + setStep(InstallStep.installed) + if (!notRefresh) refreshPluginList(manifest) + setIsInstalling(false) + }, + [manifest, refreshPluginList, setIsInstalling], + ) - const handleFailed = useCallback((errorMsg?: string) => { - setStep(InstallStep.installFailed) - setIsInstalling(false) - if (errorMsg) - setErrorMsg(errorMsg) - }, [setIsInstalling]) + const handleFailed = useCallback( + (errorMsg?: string) => { + setStep(InstallStep.installFailed) + setIsInstalling(false) + if (errorMsg) setErrorMsg(errorMsg) + }, + [setIsInstalling], + ) return ( <Dialog open onOpenChange={(open) => { - if (!open) - foldAnimInto() + if (!open) foldAnimInto() }} > <DialogContent backdropProps={{ forceRender: true }} - className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))} + className={cn( + 'w-[560px] max-w-none! overflow-hidden! text-left align-middle', + cn( + modalClassName, + 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0', + ), + )} > <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> <DialogTitle className="self-stretch title-2xl-semi-bold text-text-primary"> {getTitle()} </DialogTitle> </div> - { - isBundle - ? ( - <ReadyToInstallBundle - step={step} - onStepChange={setStep} - onStartToInstall={handleStartToInstall} - setIsInstalling={setIsInstalling} - onClose={onClose} - allPlugins={dependencies!} - isFromMarketPlace - /> - ) - : ( - <> - { - step === InstallStep.readyToInstall && ( - <Install - uniqueIdentifier={uniqueIdentifier} - payload={manifest!} - onCancel={onClose} - onInstalled={handleInstalled} - onFailed={handleFailed} - onStartToInstall={handleStartToInstall} - onTaskStarted={foldIntoTaskTrigger} - /> - ) - } - { - [InstallStep.installed, InstallStep.installFailed].includes(step) && ( - <Installed - payload={manifest!} - isMarketPayload - isFailed={step === InstallStep.installFailed} - errMsg={errorMsg} - installContextCategory={installContextCategory} - onCancel={onSuccess} - /> - ) - } - </> - ) - } + {isBundle ? ( + <ReadyToInstallBundle + step={step} + onStepChange={setStep} + onStartToInstall={handleStartToInstall} + setIsInstalling={setIsInstalling} + onClose={onClose} + allPlugins={dependencies!} + isFromMarketPlace + /> + ) : ( + <> + {step === InstallStep.readyToInstall && ( + <Install + uniqueIdentifier={uniqueIdentifier} + payload={manifest!} + onCancel={onClose} + onInstalled={handleInstalled} + onFailed={handleFailed} + onStartToInstall={handleStartToInstall} + onTaskStarted={foldIntoTaskTrigger} + /> + )} + {[InstallStep.installed, InstallStep.installFailed].includes(step) && ( + <Installed + payload={manifest!} + isMarketPayload + isFailed={step === InstallStep.installFailed} + errMsg={errorMsg} + installContextCategory={installContextCategory} + onCancel={onSuccess} + /> + )} + </> + )} <DialogCloseButton /> </DialogContent> </Dialog> diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx index 93151c02081a0d..f58366d420c737 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx @@ -6,7 +6,9 @@ import { PluginCategoryEnum, TaskStatus } from '../../../../types' import Install from '../install' // Factory functions for test data -const createMockManifest = (overrides: Partial<PluginManifestInMarket> = {}): PluginManifestInMarket => ({ +const createMockManifest = ( + overrides: Partial<PluginManifestInMarket> = {}, +): PluginManifestInMarket => ({ plugin_unique_identifier: 'test-unique-identifier', name: 'Test Plugin', org: 'test-org', @@ -51,7 +53,9 @@ const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({ }) // Mock variables for controlling test behavior -let mockInstalledInfo: Record<string, { installedId: string, installedVersion: string, uniqueIdentifier: string }> | undefined +let mockInstalledInfo: + | Record<string, { installedId: string; installedVersion: string; uniqueIdentifier: string }> + | undefined let mockIsLoading = false const mockInstallPackageFromMarketPlace = vi.fn() const mockUpdatePackageFromMarketPlace = vi.fn() @@ -67,7 +71,7 @@ const mockAppContextState = vi.hoisted(() => ({ // Mock useCheckInstalled vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ - default: ({ pluginIds: _pluginIds }: { pluginIds: string[], enabled: boolean }) => ({ + default: ({ pluginIds: _pluginIds }: { pluginIds: string[]; enabled: boolean }) => ({ installedInfo: mockInstalledInfo, isLoading: mockIsLoading, error: null, @@ -137,7 +141,12 @@ vi.mock('../../../hooks/use-install-plugin-limit', () => ({ // Mock Card component vi.mock('../../../../card', () => ({ - default: ({ payload, titleLeft, className: _className, limitedInstall }: { + default: ({ + payload, + titleLeft, + className: _className, + limitedInstall, + }: { payload: Record<string, unknown> titleLeft?: React.ReactNode className?: string @@ -153,7 +162,11 @@ vi.mock('../../../../card', () => ({ // Mock Version component vi.mock('../../../base/version', () => ({ - default: ({ hasInstalled, installedVersion, toInstallVersion }: { + default: ({ + hasInstalled, + installedVersion, + toInstallVersion, + }: { hasInstalled: boolean installedVersion?: string toInstallVersion: string @@ -284,7 +297,10 @@ describe('Install Component (steps/install.tsx)', () => { }) it('should fallback to latest_version when version is undefined', () => { - const manifest = createMockManifest({ version: undefined as unknown as string, latest_version: '3.0.0' }) + const manifest = createMockManifest({ + version: undefined as unknown as string, + latest_version: '3.0.0', + }) render(<Install {...defaultProps} payload={manifest} />) expect(screen.getByTestId('to-install-version')).toHaveTextContent('3.0.0') @@ -597,7 +613,7 @@ describe('Install Component (steps/install.tsx)', () => { render(<Install {...defaultProps} payload={plugin} />) // Wait a bit to ensure onInstalled is not called - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) expect(defaultProps.onInstalled).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx index afa6eea721e842..2d72cfb7d76e5a 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx @@ -9,7 +9,12 @@ import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' import { langGeniusVersionInfoAtom } from '@/context/version-state' -import { useInstallPackageFromMarketPlace, usePluginDeclarationFromMarketPlace, usePluginTaskList, useUpdatePackageFromMarketPlace } from '@/service/use-plugins' +import { + useInstallPackageFromMarketPlace, + usePluginDeclarationFromMarketPlace, + usePluginTaskList, + useUpdatePackageFromMarketPlace, +} from '@/service/use-plugins' import { isEqualOrLaterThanVersion } from '@/utils/semver' import Card from '../../../card' // import { RiInformation2Line } from '@remixicon/react' @@ -54,15 +59,11 @@ const Installed: FC<Props> = ({ const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace() const { mutateAsync: updatePackageFromMarketPlace } = useUpdatePackageFromMarketPlace() const [isInstalling, setIsInstalling] = React.useState(false) - const { - check, - stop, - } = checkTaskStatus() + const { check, stop } = checkTaskStatus() const { handleInstallTaskStart } = usePluginTaskList(payload.category) useEffect(() => { - if (hasInstalled && uniqueIdentifier === installedInfoPayload.uniqueIdentifier) - onInstalled() + if (hasInstalled && uniqueIdentifier === installedInfoPayload.uniqueIdentifier) onInstalled() }, [hasInstalled]) const handleCancel = () => { @@ -71,8 +72,7 @@ const Installed: FC<Props> = ({ } const handleInstall = async () => { - if (isInstalling) - return + if (isInstalling) return onStartToInstall?.() setIsInstalling(true) try { @@ -85,21 +85,14 @@ const Installed: FC<Props> = ({ new_plugin_unique_identifier: uniqueIdentifier, }) installResponse = response - const { - all_installed, - task_id, - } = response + const { all_installed, task_id } = response handleInstallTaskStart(response) taskId = task_id isInstalled = all_installed - } - else { + } else { const response = await installPackageFromMarketPlace(uniqueIdentifier) installResponse = response - const { - all_installed, - task_id, - } = response + const { all_installed, task_id } = response handleInstallTaskStart(response) taskId = task_id isInstalled = all_installed @@ -124,8 +117,7 @@ const Installed: FC<Props> = ({ return } onInstalled(true) - } - catch (e) { + } catch (e) { if (typeof e === 'string') { onFailed(e) return @@ -137,23 +129,28 @@ const Installed: FC<Props> = ({ const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) const { data: pluginDeclaration } = usePluginDeclarationFromMarketPlace(uniqueIdentifier) const isDifyVersionCompatible = useMemo(() => { - if (!pluginDeclaration || !langGeniusVersionInfo.current_version) - return true - return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0') + if (!pluginDeclaration || !langGeniusVersionInfo.current_version) return true + return isEqualOrLaterThanVersion( + langGeniusVersionInfo.current_version, + pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0', + ) }, [langGeniusVersionInfo.current_version, pluginDeclaration]) - const { - canInstall, - isLoading: isInstallLimitLoading, - } = useInstallPluginLimit({ ...payload, from: 'marketplace' }) + const { canInstall, isLoading: isInstallLimitLoading } = useInstallPluginLimit({ + ...payload, + from: 'marketplace', + }) return ( <> <div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3"> <div className="system-md-regular text-text-secondary"> - <p>{t($ => $[`${i18nPrefix}.readyToInstall`], { ns: 'plugin' })}</p> + <p>{t(($) => $[`${i18nPrefix}.readyToInstall`], { ns: 'plugin' })}</p> {!isDifyVersionCompatible && ( <p className="system-md-regular text-text-warning"> - {t($ => $.difyVersionNotCompatible, { ns: 'plugin', minimalDifyVersion: pluginDeclaration?.manifest.meta.minimum_dify_version })} + {t(($) => $.difyVersionNotCompatible, { + ns: 'plugin', + minimalDifyVersion: pluginDeclaration?.manifest.meta.minimum_dify_version, + })} </p> )} </div> @@ -161,13 +158,15 @@ const Installed: FC<Props> = ({ <Card className="w-full" payload={pluginManifestInMarketToPluginProps(payload as PluginManifestInMarket)} - titleLeft={!isLoading && ( - <Version - hasInstalled={hasInstalled} - installedVersion={installedVersion} - toInstallVersion={toInstallVersion} - /> - )} + titleLeft={ + !isLoading && ( + <Version + hasInstalled={hasInstalled} + installedVersion={installedVersion} + toInstallVersion={toInstallVersion} + /> + ) + } limitedInstall={!isInstallLimitLoading && !canInstall} /> </div> @@ -176,7 +175,7 @@ const Installed: FC<Props> = ({ <div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5"> {!isInstalling && ( <Button variant="secondary" className="min-w-[72px]" onClick={handleCancel}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> )} <Button @@ -186,7 +185,11 @@ const Installed: FC<Props> = ({ onClick={handleInstall} > {isInstalling && <RiLoader2Line className="size-4 animate-spin-slow" />} - <span>{t($ => $[`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`], { ns: 'plugin' })}</span> + <span> + {t(($) => $[`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`], { + ns: 'plugin', + })} + </span> </Button> </div> </> diff --git a/web/app/components/plugins/install-plugin/utils.ts b/web/app/components/plugins/install-plugin/utils.ts index 549bdc62415ec0..879454b27bc370 100644 --- a/web/app/components/plugins/install-plugin/utils.ts +++ b/web/app/components/plugins/install-plugin/utils.ts @@ -25,14 +25,16 @@ export const pluginManifestToCardPluginProps = (pluginManifest: PluginDeclaratio endpoint: { settings: [], }, - tags: pluginManifest.tags.map(tag => ({ name: tag })), + tags: pluginManifest.tags.map((tag) => ({ name: tag })), badges: [], verification: { authorized_category: 'langgenius' }, from: 'package', } } -export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManifestInMarket): Plugin => { +export const pluginManifestInMarketToPluginProps = ( + pluginManifest: PluginManifestInMarket, +): Plugin => { return { plugin_id: pluginManifest.plugin_unique_identifier, type: pluginManifest.category as Plugin['type'], @@ -55,7 +57,9 @@ export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManife }, tags: [], badges: pluginManifest.badges, - verification: isEmpty(pluginManifest.verification) ? { authorized_category: 'langgenius' } : pluginManifest.verification, + verification: isEmpty(pluginManifest.verification) + ? { authorized_category: 'langgenius' } + : pluginManifest.verification, from: pluginManifest.from, } } diff --git a/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx b/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx index 1760b393e34a71..e953c8f4f1f897 100644 --- a/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx @@ -18,9 +18,7 @@ const createWrapper = (searchParams = '') => { const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams }) const wrapper = ({ children }: { children: ReactNode }) => ( <JotaiProvider> - <NuqsWrapper> - {children} - </NuqsWrapper> + <NuqsWrapper>{children}</NuqsWrapper> </JotaiProvider> ) return { wrapper } @@ -185,11 +183,14 @@ describe('useMarketplaceMoreClick', () => { it('should do nothing when called with no params', () => { const { wrapper } = createWrapper() - const { result } = renderHook(() => ({ - handleMoreClick: useMarketplaceMoreClick(), - sort: useMarketplaceSortValue(), - searchText: useSearchPluginText()[0], - }), { wrapper }) + const { result } = renderHook( + () => ({ + handleMoreClick: useMarketplaceMoreClick(), + sort: useMarketplaceSortValue(), + searchText: useSearchPluginText()[0], + }), + { wrapper }, + ) const sortBefore = result.current.sort const searchTextBefore = result.current.searchText @@ -205,10 +206,13 @@ describe('useMarketplaceMoreClick', () => { it('should update search state when called with search params', () => { const { wrapper } = createWrapper() - const { result } = renderHook(() => ({ - handleMoreClick: useMarketplaceMoreClick(), - sort: useMarketplaceSortValue(), - }), { wrapper }) + const { result } = renderHook( + () => ({ + handleMoreClick: useMarketplaceMoreClick(), + sort: useMarketplaceSortValue(), + }), + { wrapper }, + ) act(() => { result.current.handleMoreClick({ @@ -223,10 +227,13 @@ describe('useMarketplaceMoreClick', () => { it('should use defaults when search params fields are missing', () => { const { wrapper } = createWrapper() - const { result } = renderHook(() => ({ - handleMoreClick: useMarketplaceMoreClick(), - sort: useMarketplaceSortValue(), - }), { wrapper }) + const { result } = renderHook( + () => ({ + handleMoreClick: useMarketplaceMoreClick(), + sort: useMarketplaceSortValue(), + }), + { wrapper }, + ) act(() => { result.current.handleMoreClick({}) diff --git a/web/app/components/plugins/marketplace/__tests__/hooks-integration.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hooks-integration.spec.tsx index ac583d66c566a9..10ea110d3d42df 100644 --- a/web/app/components/plugins/marketplace/__tests__/hooks-integration.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/hooks-integration.spec.tsx @@ -11,17 +11,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' let mockPostMarketplaceShouldFail = false const mockPostMarketplaceResponse = { data: { - plugins: [ - { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, - ], + plugins: [{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] }], total: 1, }, } vi.mock('@/service/base', () => ({ postMarketplace: vi.fn(async () => { - if (mockPostMarketplaceShouldFail) - throw new Error('Mock API error') + if (mockPostMarketplaceShouldFail) throw new Error('Mock API error') return mockPostMarketplaceResponse }), })) @@ -54,9 +51,7 @@ const createWrapper = () => { }, }) const Wrapper = ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) return { Wrapper, queryClient } } @@ -133,10 +128,9 @@ describe('useMarketplacePluginsByCollectionId (integration)', () => { it('should return empty when collectionId is undefined', async () => { const { useMarketplacePluginsByCollectionId } = await import('../hooks') const { Wrapper } = createWrapper() - const { result } = renderHook( - () => useMarketplacePluginsByCollectionId(undefined), - { wrapper: Wrapper }, - ) + const { result } = renderHook(() => useMarketplacePluginsByCollectionId(undefined), { + wrapper: Wrapper, + }) expect(result.current.plugins).toEqual([]) }) @@ -144,10 +138,9 @@ describe('useMarketplacePluginsByCollectionId (integration)', () => { it('should fetch plugins when collectionId is provided', async () => { const { useMarketplacePluginsByCollectionId } = await import('../hooks') const { Wrapper } = createWrapper() - const { result } = renderHook( - () => useMarketplacePluginsByCollectionId('collection-1'), - { wrapper: Wrapper }, - ) + const { result } = renderHook(() => useMarketplacePluginsByCollectionId('collection-1'), { + wrapper: Wrapper, + }) await waitFor(() => { expect(result.current.isSuccess).toBe(true) @@ -177,11 +170,18 @@ describe('useMarketplacePlugins (integration)', () => { it('should show isLoading during initial fetch', async () => { // Delay the response so we can observe the loading state const { postMarketplace } = await import('@/service/base') - vi.mocked(postMarketplace).mockImplementationOnce(() => new Promise((resolve) => { - setTimeout(() => resolve({ - data: { plugins: [], total: 0 }, - }), 200) - })) + vi.mocked(postMarketplace).mockImplementationOnce( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve({ + data: { plugins: [], total: 0 }, + }), + 200, + ) + }), + ) const { useMarketplacePlugins } = await import('../hooks') const { Wrapper } = createWrapper() @@ -228,7 +228,9 @@ describe('useMarketplacePlugins (integration)', () => { const bundleResponse = { data: { plugins: [], - bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }], + bundles: [ + { type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }, + ], total: 1, }, } @@ -312,16 +314,21 @@ describe('useMarketplacePlugins (integration)', () => { }) // Real useDebounceFn has 500ms wait, so increase timeout - await waitFor(() => { - expect(result.current.plugins).toBeDefined() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(result.current.plugins).toBeDefined() + }, + { timeout: 3000 }, + ) }) it('should handle response with bundles field (bundles || plugins fallback)', async () => { const { postMarketplace } = await import('@/service/base') vi.mocked(postMarketplace).mockResolvedValueOnce({ data: { - bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }], + bundles: [ + { type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }, + ], plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }], total: 2, }, diff --git a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx index 2555a41f6b3ca2..74e29326286dda 100644 --- a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx @@ -11,10 +11,7 @@ vi.mock('@/i18n-config/i18next-config', () => ({ const mockSetUrlFilters = vi.fn() vi.mock('@/hooks/use-query-params', () => ({ - useMarketplaceFilters: () => [ - { q: '', tags: [], category: '' }, - mockSetUrlFilters, - ], + useMarketplaceFilters: () => [{ q: '', tags: [], category: '' }, mockSetUrlFilters], })) vi.mock('@/service/use-plugins', () => ({ @@ -31,9 +28,7 @@ function createWrapper() { }, }) const Wrapper = ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) return { Wrapper, queryClient } } @@ -45,15 +40,14 @@ const mockPostMarketplaceResponse = { { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, { type: 'plugin', org: 'test', name: 'plugin2', tags: [] }, ], - bundles: [] as Array<{ type: string, org: string, name: string, tags: unknown[] }>, + bundles: [] as Array<{ type: string; org: string; name: string; tags: unknown[] }>, total: 2, }, } vi.mock('@/service/base', () => ({ postMarketplace: vi.fn(() => { - if (mockPostMarketplaceShouldFail) - return Promise.reject(new Error('Mock API error')) + if (mockPostMarketplaceShouldFail) return Promise.reject(new Error('Mock API error')) return Promise.resolve(mockPostMarketplaceResponse) }), })) @@ -89,16 +83,12 @@ vi.mock('@/service/client', () => ({ })), collectionPlugins: vi.fn(async () => ({ data: { - plugins: [ - { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, - ], + plugins: [{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] }], }, })), searchAdvanced: vi.fn(async () => ({ data: { - plugins: [ - { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, - ], + plugins: [{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] }], total: 1, }, })), @@ -133,10 +123,9 @@ describe('useMarketplacePluginsByCollectionId', () => { it('should return initial state when collectionId is undefined', async () => { const { useMarketplacePluginsByCollectionId } = await import('../hooks') const { Wrapper } = createWrapper() - const { result } = renderHook( - () => useMarketplacePluginsByCollectionId(undefined), - { wrapper: Wrapper }, - ) + const { result } = renderHook(() => useMarketplacePluginsByCollectionId(undefined), { + wrapper: Wrapper, + }) expect(result.current.plugins).toEqual([]) expect(result.current.isLoading).toBe(false) expect(result.current.isSuccess).toBe(false) @@ -145,10 +134,9 @@ describe('useMarketplacePluginsByCollectionId', () => { it('should return isLoading false when collectionId is provided and query completes', async () => { const { useMarketplacePluginsByCollectionId } = await import('../hooks') const { Wrapper } = createWrapper() - const { result } = renderHook( - () => useMarketplacePluginsByCollectionId('test-collection'), - { wrapper: Wrapper }, - ) + const { result } = renderHook(() => useMarketplacePluginsByCollectionId('test-collection'), { + wrapper: Wrapper, + }) await waitFor(() => { expect(result.current.isSuccess).toBe(true) }) @@ -159,10 +147,11 @@ describe('useMarketplacePluginsByCollectionId', () => { const { useMarketplacePluginsByCollectionId } = await import('../hooks') const { Wrapper } = createWrapper() const { result } = renderHook( - () => useMarketplacePluginsByCollectionId('test-collection', { - category: 'tool', - type: 'plugin', - }), + () => + useMarketplacePluginsByCollectionId('test-collection', { + category: 'tool', + type: 'plugin', + }), { wrapper: Wrapper }, ) await waitFor(() => { @@ -173,10 +162,9 @@ describe('useMarketplacePluginsByCollectionId', () => { it('should return plugins property from hook', async () => { const { useMarketplacePluginsByCollectionId } = await import('../hooks') const { Wrapper } = createWrapper() - const { result } = renderHook( - () => useMarketplacePluginsByCollectionId('collection-1'), - { wrapper: Wrapper }, - ) + const { result } = renderHook(() => useMarketplacePluginsByCollectionId('collection-1'), { + wrapper: Wrapper, + }) await waitFor(() => { expect(result.current.plugins).toBeDefined() }) diff --git a/web/app/components/plugins/marketplace/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/__tests__/index.spec.tsx index 5444d6ae6d7638..6d7e6538b430e1 100644 --- a/web/app/components/plugins/marketplace/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/index.spec.tsx @@ -30,13 +30,17 @@ vi.mock('../description', () => ({ vi.mock('../list/list-wrapper', () => ({ default: ({ showInstallButton }: { showInstallButton: boolean }) => ( - <div data-testid="list-wrapper" data-show-install={showInstallButton}>ListWrapper</div> + <div data-testid="list-wrapper" data-show-install={showInstallButton}> + ListWrapper + </div> ), })) vi.mock('../sticky-search-and-switch-wrapper', () => ({ default: ({ pluginTypeSwitchClassName }: { pluginTypeSwitchClassName?: string }) => ( - <div data-testid="sticky-wrapper" data-classname={pluginTypeSwitchClassName}>StickyWrapper</div> + <div data-testid="sticky-wrapper" data-classname={pluginTypeSwitchClassName}> + StickyWrapper + </div> ), })) diff --git a/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx b/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx index c7f338c333997b..6898ca56048917 100644 --- a/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx @@ -7,7 +7,7 @@ import PluginTypeSwitch from '../plugin-type-switch' vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey((key: string) => { const map: Record<string, string> = { @@ -24,16 +24,14 @@ vi.mock('#i18n', async () => { return map[key] || key }), }), - }) + } }) const createWrapper = (searchParams = '') => { const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams }) const Wrapper = ({ children }: { children: ReactNode }) => ( <JotaiProvider> - <NuqsWrapper> - {children} - </NuqsWrapper> + <NuqsWrapper>{children}</NuqsWrapper> </JotaiProvider> ) return { Wrapper } @@ -101,7 +99,9 @@ describe('PluginTypeSwitch', () => { it('should apply custom className', () => { const { Wrapper } = createWrapper() - const { container } = render(<PluginTypeSwitch className="custom-class" />, { wrapper: Wrapper }) + const { container } = render(<PluginTypeSwitch className="custom-class" />, { + wrapper: Wrapper, + }) const outerDiv = container.firstChild as HTMLElement expect(outerDiv.className).toContain('custom-class') @@ -151,7 +151,16 @@ describe('PluginTypeSwitch', () => { const { Wrapper } = createWrapper('?category=all') render(<PluginTypeSwitch />, { wrapper: Wrapper }) - const categories = ['All', 'Models', 'Tools', 'Data Sources', 'Triggers', 'Agents', 'Extensions', 'Bundles'] + const categories = [ + 'All', + 'Models', + 'Tools', + 'Data Sources', + 'Triggers', + 'Agents', + 'Extensions', + 'Bundles', + ] categories.forEach((category) => { fireEvent.click(screen.getByText(category)) diff --git a/web/app/components/plugins/marketplace/__tests__/query.spec.tsx b/web/app/components/plugins/marketplace/__tests__/query.spec.tsx index 5353cdba91bce1..7279c96615f2e3 100644 --- a/web/app/components/plugins/marketplace/__tests__/query.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/query.spec.tsx @@ -44,9 +44,7 @@ const createTestQueryClient = () => const createWrapper = () => { const queryClient = createTestQueryClient() const Wrapper = ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) return { Wrapper, queryClient } } @@ -60,9 +58,7 @@ describe('useMarketplaceCollectionsAndPlugins', () => { const mockCollectionData = [ { name: 'col-1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, ] - const mockPluginData = [ - { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, - ] + const mockPluginData = [{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] }] mockCollections.mockResolvedValue({ data: { collections: mockCollectionData } }) mockCollectionPlugins.mockResolvedValue({ data: { plugins: mockPluginData } }) @@ -87,10 +83,9 @@ describe('useMarketplaceCollectionsAndPlugins', () => { const { useMarketplaceCollectionsAndPlugins } = await import('../query') const { Wrapper } = createWrapper() - const { result } = renderHook( - () => useMarketplaceCollectionsAndPlugins({}), - { wrapper: Wrapper }, - ) + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins({}), { + wrapper: Wrapper, + }) await waitFor(() => { expect(result.current.isSuccess).toBe(true) @@ -106,10 +101,7 @@ describe('useMarketplacePlugins', () => { it('should not fetch when queryParams is undefined', async () => { const { useMarketplacePlugins } = await import('../query') const { Wrapper } = createWrapper() - const { result } = renderHook( - () => useMarketplacePlugins(undefined), - { wrapper: Wrapper }, - ) + const { result } = renderHook(() => useMarketplacePlugins(undefined), { wrapper: Wrapper }) // enabled is false, so should not fetch expect(result.current.data).toBeUndefined() @@ -127,14 +119,15 @@ describe('useMarketplacePlugins', () => { const { useMarketplacePlugins } = await import('../query') const { Wrapper } = createWrapper() const { result } = renderHook( - () => useMarketplacePlugins({ - query: 'test', - sort_by: 'install_count', - sort_order: 'DESC', - category: 'tool', - tags: [], - type: 'plugin', - }), + () => + useMarketplacePlugins({ + query: 'test', + sort_by: 'install_count', + sort_order: 'DESC', + category: 'tool', + tags: [], + type: 'plugin', + }), { wrapper: Wrapper }, ) @@ -157,10 +150,11 @@ describe('useMarketplacePlugins', () => { const { useMarketplacePlugins } = await import('../query') const { Wrapper } = createWrapper() const { result } = renderHook( - () => useMarketplacePlugins({ - query: 'bundle', - type: 'bundle', - }), + () => + useMarketplacePlugins({ + query: 'bundle', + type: 'bundle', + }), { wrapper: Wrapper }, ) @@ -175,9 +169,10 @@ describe('useMarketplacePlugins', () => { const { useMarketplacePlugins } = await import('../query') const { Wrapper } = createWrapper() const { result } = renderHook( - () => useMarketplacePlugins({ - query: 'fail', - }), + () => + useMarketplacePlugins({ + query: 'fail', + }), { wrapper: Wrapper }, ) @@ -206,10 +201,11 @@ describe('useMarketplacePlugins', () => { const { useMarketplacePlugins } = await import('../query') const { Wrapper } = createWrapper() const { result } = renderHook( - () => useMarketplacePlugins({ - query: 'paginated', - page_size: 40, - }), + () => + useMarketplacePlugins({ + query: 'paginated', + page_size: 40, + }), { wrapper: Wrapper }, ) diff --git a/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts b/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts index c13a4528fbd7b5..d3e8fea6781a5e 100644 --- a/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts +++ b/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts @@ -4,15 +4,24 @@ import { marketplaceSearchParamsParsers } from '../search-params' describe('marketplace search params', () => { it('applies the expected default values', () => { - expect(marketplaceSearchParamsParsers.category.parseServerSide(undefined)).toBe(PLUGIN_TYPE_SEARCH_MAP.all) + expect(marketplaceSearchParamsParsers.category.parseServerSide(undefined)).toBe( + PLUGIN_TYPE_SEARCH_MAP.all, + ) expect(marketplaceSearchParamsParsers.q.parseServerSide(undefined)).toBe('') expect(marketplaceSearchParamsParsers.tags.parseServerSide(undefined)).toEqual([]) }) it('parses supported query values with the configured parsers', () => { - expect(marketplaceSearchParamsParsers.category.parseServerSide(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe(PLUGIN_TYPE_SEARCH_MAP.tool) - expect(marketplaceSearchParamsParsers.category.parseServerSide('unsupported')).toBe(PLUGIN_TYPE_SEARCH_MAP.all) + expect( + marketplaceSearchParamsParsers.category.parseServerSide(PLUGIN_TYPE_SEARCH_MAP.tool), + ).toBe(PLUGIN_TYPE_SEARCH_MAP.tool) + expect(marketplaceSearchParamsParsers.category.parseServerSide('unsupported')).toBe( + PLUGIN_TYPE_SEARCH_MAP.all, + ) expect(marketplaceSearchParamsParsers.q.parseServerSide('keyword')).toBe('keyword') - expect(marketplaceSearchParamsParsers.tags.parseServerSide('rag,search')).toEqual(['rag', 'search']) + expect(marketplaceSearchParamsParsers.tags.parseServerSide('rag,search')).toEqual([ + 'rag', + 'search', + ]) }) }) diff --git a/web/app/components/plugins/marketplace/__tests__/state.spec.tsx b/web/app/components/plugins/marketplace/__tests__/state.spec.tsx index f4330f31b4e5f1..f47d3002017f2b 100644 --- a/web/app/components/plugins/marketplace/__tests__/state.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/state.spec.tsx @@ -46,9 +46,7 @@ const createWrapper = (searchParams = '') => { const Wrapper = ({ children }: { children: ReactNode }) => ( <JotaiProvider> <QueryClientProvider client={queryClient}> - <NuqsWrapper> - {children} - </NuqsWrapper> + <NuqsWrapper>{children}</NuqsWrapper> </QueryClientProvider> </JotaiProvider> ) @@ -185,9 +183,21 @@ describe('useMarketplaceData', () => { container.id = 'marketplace-container' document.body.appendChild(container) - Object.defineProperty(container, 'scrollTop', { value: 900, writable: true, configurable: true }) - Object.defineProperty(container, 'scrollHeight', { value: 1000, writable: true, configurable: true }) - Object.defineProperty(container, 'clientHeight', { value: 200, writable: true, configurable: true }) + Object.defineProperty(container, 'scrollTop', { + value: 900, + writable: true, + configurable: true, + }) + Object.defineProperty(container, 'scrollHeight', { + value: 1000, + writable: true, + configurable: true, + }) + Object.defineProperty(container, 'clientHeight', { + value: 200, + writable: true, + configurable: true, + }) const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper }) @@ -245,9 +255,21 @@ describe('useMarketplaceData', () => { container.id = 'marketplace-container' document.body.appendChild(container) - Object.defineProperty(container, 'scrollTop', { value: 900, writable: true, configurable: true }) - Object.defineProperty(container, 'scrollHeight', { value: 1000, writable: true, configurable: true }) - Object.defineProperty(container, 'clientHeight', { value: 200, writable: true, configurable: true }) + Object.defineProperty(container, 'scrollTop', { + value: 900, + writable: true, + configurable: true, + }) + Object.defineProperty(container, 'scrollHeight', { + value: 1000, + writable: true, + configurable: true, + }) + Object.defineProperty(container, 'clientHeight', { + value: 200, + writable: true, + configurable: true, + }) const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper }) diff --git a/web/app/components/plugins/marketplace/__tests__/sticky-search-and-switch-wrapper.spec.tsx b/web/app/components/plugins/marketplace/__tests__/sticky-search-and-switch-wrapper.spec.tsx index 4dd742dc7373c0..516bc73283a80a 100644 --- a/web/app/components/plugins/marketplace/__tests__/sticky-search-and-switch-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/sticky-search-and-switch-wrapper.spec.tsx @@ -18,9 +18,7 @@ const createWrapper = () => { const { wrapper: NuqsWrapper } = createNuqsTestWrapper() const Wrapper = ({ children }: { children: ReactNode }) => ( <JotaiProvider> - <NuqsWrapper> - {children} - </NuqsWrapper> + <NuqsWrapper>{children}</NuqsWrapper> </JotaiProvider> ) return { Wrapper } @@ -33,10 +31,7 @@ describe('StickySearchAndSwitchWrapper', () => { it('should render SearchBoxWrapper and PluginTypeSwitch', () => { const { Wrapper } = createWrapper() - const { getByTestId } = render( - <StickySearchAndSwitchWrapper />, - { wrapper: Wrapper }, - ) + const { getByTestId } = render(<StickySearchAndSwitchWrapper />, { wrapper: Wrapper }) expect(getByTestId('search-box-wrapper')).toBeInTheDocument() expect(getByTestId('plugin-type-switch')).toBeInTheDocument() @@ -44,10 +39,7 @@ describe('StickySearchAndSwitchWrapper', () => { it('should not apply sticky class when no pluginTypeSwitchClassName', () => { const { Wrapper } = createWrapper() - const { container } = render( - <StickySearchAndSwitchWrapper />, - { wrapper: Wrapper }, - ) + const { container } = render(<StickySearchAndSwitchWrapper />, { wrapper: Wrapper }) const outerDiv = container.firstChild as HTMLElement expect(outerDiv.className).toContain('mt-4') diff --git a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts index a28b5f3702c833..478a86ffa3906d 100644 --- a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts +++ b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts @@ -82,7 +82,9 @@ describe('getFormattedPlugin', () => { } as unknown as Plugin const formatted = getFormattedPlugin(rawPlugin) - expect(formatted.icon).toBe('https://marketplace.dify.ai/api/v1/plugins/test-org/test-plugin/icon') + expect(formatted.icon).toBe( + 'https://marketplace.dify.ai/api/v1/plugins/test-org/test-plugin/icon', + ) }) it('should format bundle with additional properties', async () => { @@ -96,7 +98,9 @@ describe('getFormattedPlugin', () => { } as unknown as Plugin const formatted = getFormattedPlugin(rawBundle) - expect(formatted.icon).toBe('https://marketplace.dify.ai/api/v1/bundles/test-org/test-bundle/icon') + expect(formatted.icon).toBe( + 'https://marketplace.dify.ai/api/v1/bundles/test-org/test-bundle/icon', + ) expect(formatted.brief).toBe('Bundle description') expect(formatted.label).toEqual({ 'en-US': 'Test Bundle' }) }) @@ -226,14 +230,17 @@ describe('getMarketplacePluginsByCollectionId', () => { const { getMarketplacePluginsByCollectionId } = await import('../utils') await getMarketplacePluginsByCollectionId('test-collection') - expect(mockCollectionPlugins).toHaveBeenCalledWith({ - params: { - collectionId: 'test-collection', + expect(mockCollectionPlugins).toHaveBeenCalledWith( + { + params: { + collectionId: 'test-collection', + }, + body: {}, }, - body: {}, - }, expect.objectContaining({ - signal: undefined, - })) + expect.objectContaining({ + signal: undefined, + }), + ) }) it('should pass abort signal when provided', async () => { @@ -297,7 +304,9 @@ describe('getMarketplaceCollectionsAndPlugins', () => { expect(mockCollections).toHaveBeenCalled() const call = mockCollections.mock.calls[0] - expect(call![0]).toMatchObject({ query: expect.objectContaining({ condition: 'category=tool', type: 'bundle' }) }) + expect(call![0]).toMatchObject({ + query: expect.objectContaining({ condition: 'category=tool', type: 'bundle' }), + }) }) }) @@ -345,15 +354,18 @@ describe('getMarketplacePlugins', () => { }) const { getMarketplacePlugins } = await import('../utils') - const result = await getMarketplacePlugins({ - query: 'test', - sort_by: 'install_count', - sort_order: 'DESC', - category: 'tool', - tags: ['search'], - type: 'plugin', - page_size: 20, - }, 1) + const result = await getMarketplacePlugins( + { + query: 'test', + sort_by: 'install_count', + sort_order: 'DESC', + category: 'tool', + tags: ['search'], + type: 'plugin', + page_size: 20, + }, + 1, + ) expect(result.plugins).toHaveLength(1) expect(result.total).toBe(1) @@ -364,16 +376,21 @@ describe('getMarketplacePlugins', () => { it('should use bundles endpoint when type is bundle', async () => { mockSearchAdvanced.mockResolvedValueOnce({ data: { - bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }], + bundles: [ + { type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }, + ], total: 1, }, }) const { getMarketplacePlugins } = await import('../utils') - const result = await getMarketplacePlugins({ - query: 'bundle', - type: 'bundle', - }, 1) + const result = await getMarketplacePlugins( + { + query: 'bundle', + type: 'bundle', + }, + 1, + ) expect(result.plugins).toHaveLength(1) const call = mockSearchAdvanced.mock.calls[0] @@ -386,10 +403,13 @@ describe('getMarketplacePlugins', () => { }) const { getMarketplacePlugins } = await import('../utils') - await getMarketplacePlugins({ - query: 'test', - category: 'all', - }, 1) + await getMarketplacePlugins( + { + query: 'test', + category: 'all', + }, + 1, + ) const call = mockSearchAdvanced.mock.calls[0] expect(call![0].body.category).toBe('') @@ -399,9 +419,12 @@ describe('getMarketplacePlugins', () => { mockSearchAdvanced.mockRejectedValueOnce(new Error('API error')) const { getMarketplacePlugins } = await import('../utils') - const result = await getMarketplacePlugins({ - query: 'fail', - }, 2) + const result = await getMarketplacePlugins( + { + query: 'fail', + }, + 2, + ) expect(result).toEqual({ plugins: [], diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index 7ae113f957dae0..a2118997a96c4d 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -34,25 +34,28 @@ export function useMarketplaceSearchMode() { const [activePluginType] = useActivePluginType() const searchMode = useAtomValue(searchModeAtom) - const isSearchMode = !!searchPluginText - || filterPluginTags.length > 0 - || (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType))) + const isSearchMode = + !!searchPluginText || + filterPluginTags.length > 0 || + (searchMode ?? !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType)) return isSearchMode } export function useMarketplaceMoreClick() { - const [,setQ] = useSearchPluginText() + const [, setQ] = useSearchPluginText() const setSort = useSetAtom(marketplaceSortAtom) const setSearchMode = useSetAtom(searchModeAtom) - return useCallback((searchParams?: SearchParamsFromCollection) => { - if (!searchParams) - return - setQ(searchParams?.query || '') - setSort({ - sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, - sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - }) - setSearchMode(true) - }, [setQ, setSort, setSearchMode]) + return useCallback( + (searchParams?: SearchParamsFromCollection) => { + if (!searchParams) return + setQ(searchParams?.query || '') + setSort({ + sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, + sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, + }) + setSearchMode(true) + }, + [setQ, setSort, setSearchMode], + ) } diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index 6613fbe3de12f5..5db8045a547dec 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -22,9 +22,7 @@ type ValueOf<T> = T[keyof T] export type ActivePluginType = ValueOf<typeof PLUGIN_TYPE_SEARCH_MAP> -export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>( - [ - PLUGIN_TYPE_SEARCH_MAP.all, - PLUGIN_TYPE_SEARCH_MAP.tool, - ], -) +export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>([ + PLUGIN_TYPE_SEARCH_MAP.all, + PLUGIN_TYPE_SEARCH_MAP.tool, +]) diff --git a/web/app/components/plugins/marketplace/description/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/description/__tests__/index.spec.tsx index 66e8d2b38030ff..5bedd96e344696 100644 --- a/web/app/components/plugins/marketplace/description/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/description/__tests__/index.spec.tsx @@ -41,28 +41,24 @@ const commonTranslations: Record<string, string> = { // Mock i18n hooks vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useLocale: vi.fn(() => mockDefaultLocale), useTranslation: vi.fn((ns: string) => ({ t: withSelectorKey((key: string, options?: { ns?: string }) => { const namespace = options?.ns ?? ns - if (namespace === 'plugin') - return pluginTranslations[key] || key - if (namespace === 'common') - return commonTranslations[key] || key + if (namespace === 'plugin') return pluginTranslations[key] || key + if (namespace === 'common') return commonTranslations[key] || key return key }), })), - }) + } }) const createWrapper = (searchParams = '') => { const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams }) const Wrapper = ({ children }: { children: ReactNode }) => ( <JotaiProvider> - <NuqsWrapper> - {children} - </NuqsWrapper> + <NuqsWrapper>{children}</NuqsWrapper> </JotaiProvider> ) return { Wrapper } @@ -132,8 +128,12 @@ describe('Description', () => { { wrapper: Wrapper }, ) - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Discover. Extend. Build.') - expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Use community-built plugins to power your AI development.') + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + 'Discover. Extend. Build.', + ) + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( + 'Use community-built plugins to power your AI development.', + ) expect(screen.getByTestId('marketplace-nav')).toBeInTheDocument() expect(screen.getByText('All plugins')).toBeInTheDocument() }) @@ -146,7 +146,9 @@ describe('Description', () => { const navWrapper = container.querySelector('.relative.z-20.flex.w-full.flex-col.items-start') const heroContentWrapper = container.querySelector('.relative.z-10.mx-5') const titleWrapper = screen.getByRole('heading', { level: 1 }).parentElement?.parentElement - const tabs = screen.getByText('All plugins').closest('.flex.shrink-0.items-center.gap-1.overflow-x-auto') + const tabs = screen + .getByText('All plugins') + .closest('.flex.shrink-0.items-center.gap-1.overflow-x-auto') const tabsWrapper = tabs?.parentElement expect(hero).toHaveClass('px-3') @@ -490,7 +492,9 @@ describe('Description', () => { const content = subheading.textContent || '' // Commas should exist between categories - expect(content).toMatch(/Models[^\n\r,\u2028\u2029]*,.*Tools[^\n\r,\u2028\u2029]*,.*Data Sources[^\n\r,\u2028\u2029]*,.*Triggers[^\n\r,\u2028\u2029]*,.*Agent Strategies[^\n\r,\u2028\u2029]*,.*Extensions/) + expect(content).toMatch( + /Models[^\n\r,\u2028\u2029]*,.*Tools[^\n\r,\u2028\u2029]*,.*Data Sources[^\n\r,\u2028\u2029]*,.*Triggers[^\n\r,\u2028\u2029]*,.*Agent Strategies[^\n\r,\u2028\u2029]*,.*Extensions/, + ) }) it('should have "and" before last category (Bundles)', () => { diff --git a/web/app/components/plugins/marketplace/description/index.tsx b/web/app/components/plugins/marketplace/description/index.tsx index 6af30273db4d4c..d2d5f8d50d1c58 100644 --- a/web/app/components/plugins/marketplace/description/index.tsx +++ b/web/app/components/plugins/marketplace/description/index.tsx @@ -43,12 +43,10 @@ const Description = ({ const smoothProgress = useSpring(progress, { stiffness: 260, damping: 34 }) useLayoutEffect(() => { - if (!isMarketplacePlatform) - return + if (!isMarketplacePlatform) return const node = titleContentRef.current - if (!node) - return + if (!node) return const updateHeight = () => { titleHeight.set(node.scrollHeight) @@ -56,8 +54,7 @@ const Description = ({ updateHeight() - if (typeof ResizeObserver === 'undefined') - return + if (typeof ResizeObserver === 'undefined') return const observer = new ResizeObserver(updateHeight) observer.observe(node) @@ -65,27 +62,21 @@ const Description = ({ }, [isMarketplacePlatform, titleHeight]) useEffect(() => { - if (!isMarketplacePlatform) - return + if (!isMarketplacePlatform) return const container = document.getElementById(scrollContainerId) - if (!container) - return + if (!container) return const handleScroll = () => { - if (rafRef.current) - cancelAnimationFrame(rafRef.current) + if (rafRef.current) cancelAnimationFrame(rafRef.current) rafRef.current = requestAnimationFrame(() => { const scrollTop = Math.round(container.scrollTop) const heightDelta = container.scrollHeight - container.clientHeight const effectiveMaxScroll = Math.max(1, Math.min(MAX_SCROLL, heightDelta)) const rawProgress = Math.min(Math.max(scrollTop / effectiveMaxScroll, 0), 1) - const snappedProgress = rawProgress >= 0.95 - ? 1 - : rawProgress <= 0.05 - ? 0 - : Math.round(rawProgress * 100) / 100 + const snappedProgress = + rawProgress >= 0.95 ? 1 : rawProgress <= 0.05 ? 0 : Math.round(rawProgress * 100) / 100 if (snappedProgress !== lastProgressRef.current) { lastProgressRef.current = snappedProgress @@ -99,19 +90,16 @@ const Description = ({ return () => { container.removeEventListener('scroll', handleScroll) - if (rafRef.current) - cancelAnimationFrame(rafRef.current) + if (rafRef.current) cancelAnimationFrame(rafRef.current) } }, [isMarketplacePlatform, progress, scrollContainerId]) useEffect(() => { - if (!isMarketplacePlatform) - return + if (!isMarketplacePlatform) return const container = document.getElementById(scrollContainerId) const header = headerRef.current - if (!container || !header) - return + if (!container || !header) return let maxHeaderHeight = 0 let lastAppliedOffset = 0 @@ -129,8 +117,7 @@ const Description = ({ container.style.setProperty('--marketplace-header-collapse-offset', `${nextOffset}px`) if (offsetDelta !== 0 && container.scrollTop > 0) container.scrollTop = Math.max(0, container.scrollTop + offsetDelta) - } - else { + } else { container.style.removeProperty('--marketplace-header-collapse-offset') } @@ -165,69 +152,75 @@ const Description = ({ return currentTitleHeight * (1 - currentProgress) }, ) - const tabsMarginTop = useTransform(smoothProgress, [0, 1], [EXPANDED_TABS_MARGIN_TOP, hasMarketplaceNav ? 16 : 0]) - const titleMarginTop = useTransform(smoothProgress, [0, 1], [hasMarketplaceNav ? EXPANDED_TITLE_MARGIN_TOP : 0, 0]) - const paddingTop = useTransform(smoothProgress, [0, 1], [hasMarketplaceNav ? COLLAPSED_PADDING_TOP : EXPANDED_PADDING_TOP, COLLAPSED_PADDING_TOP]) - const paddingBottom = useTransform(smoothProgress, [0, 1], [EXPANDED_PADDING_BOTTOM, COLLAPSED_PADDING_BOTTOM]) + const tabsMarginTop = useTransform( + smoothProgress, + [0, 1], + [EXPANDED_TABS_MARGIN_TOP, hasMarketplaceNav ? 16 : 0], + ) + const titleMarginTop = useTransform( + smoothProgress, + [0, 1], + [hasMarketplaceNav ? EXPANDED_TITLE_MARGIN_TOP : 0, 0], + ) + const paddingTop = useTransform( + smoothProgress, + [0, 1], + [hasMarketplaceNav ? COLLAPSED_PADDING_TOP : EXPANDED_PADDING_TOP, COLLAPSED_PADDING_TOP], + ) + const paddingBottom = useTransform( + smoothProgress, + [0, 1], + [EXPANDED_PADDING_BOTTOM, COLLAPSED_PADDING_BOTTOM], + ) if (!isMarketplacePlatform) { return ( <> <h1 className="mb-2 shrink-0 text-center title-4xl-semi-bold text-text-primary"> - {t($ => $['marketplace.empower'])} + {t(($) => $['marketplace.empower'])} </h1> <h2 className="flex shrink-0 items-center justify-center text-center body-md-regular text-text-tertiary"> - { - isZhHans && ( - <> - <span className="mr-1">{tCommon($ => $['operation.in'])}</span> - {t($ => $['marketplace.difyMarketplace'])} - {t($ => $['marketplace.discover'])} - </> - ) - } - { - !isZhHans && ( - <> - {t($ => $['marketplace.discover'])} - </> - ) - } + {isZhHans && ( + <> + <span className="mr-1">{tCommon(($) => $['operation.in'])}</span> + {t(($) => $['marketplace.difyMarketplace'])} + {t(($) => $['marketplace.discover'])} + </> + )} + {!isZhHans && <>{t(($) => $['marketplace.discover'])}</>} <span className="relative z-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.models'])} + {t(($) => $['category.models'])} </span> , <span className="relative z-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.tools'])} + {t(($) => $['category.tools'])} </span> , <span className="relative z-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.datasources'])} + {t(($) => $['category.datasources'])} </span> , <span className="relative z-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.triggers'])} + {t(($) => $['category.triggers'])} </span> , <span className="relative z-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.agents'])} + {t(($) => $['category.agents'])} </span> , <span className="relative z-1 mr-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.extensions'])} + {t(($) => $['category.extensions'])} </span> - {t($ => $['marketplace.and'])} + {t(($) => $['marketplace.and'])} <span className="relative z-1 mr-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.bundles'])} + {t(($) => $['category.bundles'])} </span> - { - !isZhHans && ( - <> - <span className="mr-1">{tCommon($ => $['operation.in'])}</span> - {t($ => $['marketplace.difyMarketplace'])} - </> - ) - } + {!isZhHans && ( + <> + <span className="mr-1">{tCommon(($) => $['operation.in'])}</span> + {t(($) => $['marketplace.difyMarketplace'])} + </> + )} </h2> </> ) @@ -240,7 +233,7 @@ const Description = ({ <div className="flex shrink-0 items-center gap-1.5"> <DifyLogo alt="" className="h-6 w-[52px]" /> <span className="max-w-0 overflow-hidden title-3xl-semi-bold whitespace-nowrap text-text-primary opacity-0 transition-all duration-200 md:max-w-[150px] md:opacity-100"> - {tCommon($ => $['mainNav.marketplace'])} + {tCommon(($) => $['mainNav.marketplace'])} </span> </div> </div> @@ -249,7 +242,7 @@ const Description = ({ inputClassName="h-9 w-full rounded-[10px]" inputElementClassName="text-[14px] leading-5 font-normal" searchIconClassName="size-4" - placeholder={tCommon($ => $['placeholder.search'])} + placeholder={tCommon(($) => $['placeholder.search'])} showTags={false} usedInMarketplace={false} /> @@ -298,10 +291,10 @@ const Description = ({ > <div ref={titleContentRef}> <h1 className="mb-2 shrink-0 text-[30px] leading-9 font-semibold text-text-primary-on-surface"> - {t($ => $['marketplace.pluginsHeroTitle'])} + {t(($) => $['marketplace.pluginsHeroTitle'])} </h1> <h2 className="shrink-0 body-md-medium text-text-secondary-on-surface"> - {t($ => $['marketplace.pluginsHeroSubtitle'])} + {t(($) => $['marketplace.pluginsHeroSubtitle'])} </h2> </div> </motion.div> diff --git a/web/app/components/plugins/marketplace/empty/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/empty/__tests__/index.spec.tsx index 9fcbce904f8370..cbe74ca6fe3dbc 100644 --- a/web/app/components/plugins/marketplace/empty/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/empty/__tests__/index.spec.tsx @@ -10,10 +10,10 @@ import Line from '../line' // Mock i18n translation hook vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey((key: string, options?: { ns?: string }) => { - // Build full key with namespace prefix if provided + // Build full key with namespace prefix if provided const fullKey = options?.ns ? `${options.ns}.${key}` : key const translations: Record<string, string> = { 'plugin.marketplace.noPluginFound': 'No plugin found', @@ -21,7 +21,7 @@ vi.mock('#i18n', async () => { return translations[fullKey] || key }), }), - }) + } }) // Mock useTheme hook with controllable theme value @@ -203,9 +203,7 @@ describe('Line', () => { }) it('should handle Tailwind utility classes', () => { - const { container } = render( - <Line className="absolute -right-px top-1/2 -translate-y-1/2" />, - ) + const { container } = render(<Line className="absolute top-1/2 -right-px -translate-y-1/2" />) const svg = container.querySelector('svg') expect(svg).toHaveClass('absolute') @@ -372,7 +370,8 @@ describe('Empty', () => { }) it('should render long custom text', () => { - const longText = 'This is a very long message that describes why there are no plugins found in the current search results and what the user might want to do next to find what they are looking for' + const longText = + 'This is a very long message that describes why there are no plugins found in the current search results and what the user might want to do next to find what they are looking for' render(<Empty text={longText} />) expect(screen.getByText(longText)).toBeInTheDocument() @@ -603,11 +602,7 @@ describe('Empty', () => { describe('Combined Props', () => { it('should handle all props together', () => { const { container } = render( - <Empty - text="Custom message" - lightCard - className="custom-wrapper" - />, + <Empty text="Custom message" lightCard className="custom-wrapper" />, ) expect(screen.getByText('Custom message')).toBeInTheDocument() @@ -616,18 +611,14 @@ describe('Empty', () => { }) it('should render correctly with lightCard false and custom text', () => { - const { container } = render( - <Empty text="No results" lightCard={false} />, - ) + const { container } = render(<Empty text="No results" lightCard={false} />) expect(screen.getByText('No results')).toBeInTheDocument() expect(container.querySelector('.bg-marketplace-plugin-empty')).toBeInTheDocument() }) it('should handle className with lightCard prop', () => { - const { container } = render( - <Empty className="test-class" lightCard />, - ) + const { container } = render(<Empty className="test-class" lightCard />) const element = container.querySelector('.test-class') expect(element).toBeInTheDocument() diff --git a/web/app/components/plugins/marketplace/empty/index.tsx b/web/app/components/plugins/marketplace/empty/index.tsx index 690e60ed1b18ca..359ec18eec405d 100644 --- a/web/app/components/plugins/marketplace/empty/index.tsx +++ b/web/app/components/plugins/marketplace/empty/index.tsx @@ -10,39 +10,23 @@ type Props = Readonly<{ className?: string }> -const Empty = ({ - text, - lightCard, - className, -}: Props) => { +const Empty = ({ text, lightCard, className }: Props) => { const { t } = useTranslation() return ( - <div - className={cn('relative flex h-0 grow flex-wrap overflow-hidden p-2', className)} - > - { - Array.from({ length: 16 }).map((_, index) => ( - <div - key={index} - className={cn( - 'mr-3 mb-3 h-[144px] w-[calc((100%-36px)/4)] rounded-xl bg-background-section-burn', - index % 4 === 3 && 'mr-0', - index > 11 && 'mb-0', - lightCard && 'bg-background-default-lighter opacity-75', - )} - > - </div> - )) - } - { - !lightCard && ( - <div - className="absolute inset-0 z-1 bg-marketplace-plugin-empty" - > - </div> - ) - } + <div className={cn('relative flex h-0 grow flex-wrap overflow-hidden p-2', className)}> + {Array.from({ length: 16 }).map((_, index) => ( + <div + key={index} + className={cn( + 'mr-3 mb-3 h-[144px] w-[calc((100%-36px)/4)] rounded-xl bg-background-section-burn', + index % 4 === 3 && 'mr-0', + index > 11 && 'mb-0', + lightCard && 'bg-background-default-lighter opacity-75', + )} + ></div> + ))} + {!lightCard && <div className="absolute inset-0 z-1 bg-marketplace-plugin-empty"></div>} <div className="absolute top-1/2 left-1/2 z-2 flex -translate-1/2 flex-col items-center"> <div className="relative mb-3 flex size-14 items-center justify-center rounded-xl border border-dashed border-divider-deep bg-components-card-bg shadow-lg"> <Group className="size-5 text-text-primary" /> @@ -52,7 +36,7 @@ const Empty = ({ <Line className="absolute top-full left-1/2 -translate-1/2 rotate-90" /> </div> <div className="text-center system-md-regular text-text-tertiary"> - {text || t($ => $['marketplace.noPluginFound'], { ns: 'plugin' })} + {text || t(($) => $['marketplace.noPluginFound'], { ns: 'plugin' })} </div> </div> </div> diff --git a/web/app/components/plugins/marketplace/empty/line.tsx b/web/app/components/plugins/marketplace/empty/line.tsx index 9c47155443a908..36b9d0398267a6 100644 --- a/web/app/components/plugins/marketplace/empty/line.tsx +++ b/web/app/components/plugins/marketplace/empty/line.tsx @@ -5,18 +5,30 @@ type LineProps = { className?: string } -const Line = ({ - className, -}: LineProps) => { +const Line = ({ className }: LineProps) => { const { theme } = useTheme() const isDarkMode = theme === 'dark' if (isDarkMode) { return ( - <svg xmlns="http://www.w3.org/2000/svg" width="2" height="240" viewBox="0 0 2 240" fill="none" className={className}> + <svg + xmlns="http://www.w3.org/2000/svg" + width="2" + height="240" + viewBox="0 0 2 240" + fill="none" + className={className} + > <path d="M1 0L1 240" stroke="url(#paint0_linear_6295_52176)" /> <defs> - <linearGradient id="paint0_linear_6295_52176" x1="-7.99584" y1="240" x2="-7.88094" y2="3.95539e-05" gradientUnits="userSpaceOnUse"> + <linearGradient + id="paint0_linear_6295_52176" + x1="-7.99584" + y1="240" + x2="-7.88094" + y2="3.95539e-05" + gradientUnits="userSpaceOnUse" + > <stop stopOpacity="0.01" /> <stop offset="0.503965" stopColor="#C8CEDA" stopOpacity="0.14" /> <stop offset="1" stopOpacity="0.01" /> @@ -27,10 +39,24 @@ const Line = ({ } return ( - <svg xmlns="http://www.w3.org/2000/svg" width="2" height="241" viewBox="0 0 2 241" fill="none" className={className}> + <svg + xmlns="http://www.w3.org/2000/svg" + width="2" + height="241" + viewBox="0 0 2 241" + fill="none" + className={className} + > <path d="M1 0.5L1 240.5" stroke="url(#paint0_linear_1989_74474)" /> <defs> - <linearGradient id="paint0_linear_1989_74474" x1="-7.99584" y1="240.5" x2="-7.88094" y2="0.50004" gradientUnits="userSpaceOnUse"> + <linearGradient + id="paint0_linear_1989_74474" + x1="-7.99584" + y1="240.5" + x2="-7.88094" + y2="0.50004" + gradientUnits="userSpaceOnUse" + > <stop stopColor="white" stopOpacity="0.01" /> <stop offset="0.503965" stopColor="#101828" stopOpacity="0.08" /> <stop offset="1" stopColor="white" stopOpacity="0.01" /> diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index a817a8d84a79aa..20f04a9a08c752 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -4,20 +4,10 @@ import type { PluginsFromMarketplaceResponse, PluginsSearchParams, } from '@dify/contracts/marketplace' -import type { - Plugin, -} from '../types' -import { - useInfiniteQuery, - useQuery, - useQueryClient, -} from '@tanstack/react-query' +import type { Plugin } from '../types' +import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' -import { - useCallback, - useEffect, - useState, -} from 'react' +import { useCallback, useEffect, useState } from 'react' import { postMarketplace } from '@/service/base' import { SCROLL_BOTTOM_THRESHOLD } from './constants' import { @@ -31,15 +21,12 @@ import { */ export const useMarketplaceCollectionsAndPlugins = () => { const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>() - const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>() - const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>() + const [marketplaceCollectionsOverride, setMarketplaceCollections] = + useState<MarketplaceCollection[]>() + const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = + useState<Record<string, Plugin[]>>() - const { - data, - isFetching, - isSuccess, - isPending, - } = useQuery({ + const { data, isFetching, isSuccess, isPending } = useQuery({ queryKey: ['marketplaceCollectionsAndPlugins', queryParams], queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }), enabled: queryParams !== undefined, @@ -48,15 +35,19 @@ export const useMarketplaceCollectionsAndPlugins = () => { retry: false, }) - const queryMarketplaceCollectionsAndPlugins = useCallback((query?: CollectionsAndPluginsSearchParams) => { - setQueryParams(query ? { ...query } : {}) - }, []) + const queryMarketplaceCollectionsAndPlugins = useCallback( + (query?: CollectionsAndPluginsSearchParams) => { + setQueryParams(query ? { ...query } : {}) + }, + [], + ) const isLoading = !!queryParams && (isFetching || isPending) return { marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections, setMarketplaceCollections, - marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap, + marketplaceCollectionPluginsMap: + marketplaceCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap, setMarketplaceCollectionPluginsMap, queryMarketplaceCollectionsAndPlugins, isLoading, @@ -68,16 +59,10 @@ export const useMarketplacePluginsByCollectionId = ( collectionId?: string, query?: CollectionsAndPluginsSearchParams, ) => { - const { - data, - isFetching, - isSuccess, - isPending, - } = useQuery({ + const { data, isFetching, isSuccess, isPending } = useQuery({ queryKey: ['marketplaceCollectionPlugins', collectionId, query], queryFn: ({ signal }) => { - if (!collectionId) - return Promise.resolve<Plugin[]>([]) + if (!collectionId) return Promise.resolve<Plugin[]>([]) return getMarketplacePluginsByCollectionId(collectionId, query, { signal }) }, enabled: !!collectionId, @@ -121,43 +106,36 @@ export const useMarketplacePlugins = () => { } const params = normalizeParams(queryParams) - const { - query, - sort_by, - sort_order, - category, - tags, - exclude, - type, - page_size, - } = params + const { query, sort_by, sort_order, category, tags, exclude, type, page_size } = params const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' try { - const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { - body: { - page: pageParam, - page_size, - query, - sort_by, - sort_order, - category: category !== 'all' ? category : '', - tags, - exclude, - type, + const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>( + `/${pluginOrBundle}/search/advanced`, + { + body: { + page: pageParam, + page_size, + query, + sort_by, + sort_order, + category: category !== 'all' ? category : '', + tags, + exclude, + type, + }, + signal, }, - signal, - }) + ) const resPlugins = res.data.bundles || res.data.plugins || [] return { - plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)), + plugins: resPlugins.map((plugin) => getFormattedPlugin(plugin)), total: res.data.total, page: pageParam, page_size, } - } - catch { + } catch { return { plugins: [], total: 0, @@ -185,26 +163,33 @@ export const useMarketplacePlugins = () => { }) }, [queryClient]) - const handleUpdatePlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => { - setQueryParams(normalizeParams(pluginsSearchParams)) - }, [normalizeParams]) + const handleUpdatePlugins = useCallback( + (pluginsSearchParams: PluginsSearchParams) => { + setQueryParams(normalizeParams(pluginsSearchParams)) + }, + [normalizeParams], + ) - const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => { - handleUpdatePlugins(pluginsSearchParams) - }, { - wait: 500, - }) + const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn( + (pluginsSearchParams: PluginsSearchParams) => { + handleUpdatePlugins(pluginsSearchParams) + }, + { + wait: 500, + }, + ) const hasQuery = !!queryParams const hasData = marketplacePluginsQuery.data !== undefined - const plugins = hasQuery && hasData - ? marketplacePluginsQuery.data.pages.flatMap(page => page.plugins) - : undefined + const plugins = + hasQuery && hasData + ? marketplacePluginsQuery.data.pages.flatMap((page) => page.plugins) + : undefined const total = hasQuery && hasData ? marketplacePluginsQuery.data.pages?.[0]?.total : undefined - const isPluginsLoading = hasQuery && ( - marketplacePluginsQuery.isPending - || (marketplacePluginsQuery.isFetching && !marketplacePluginsQuery.data) - ) + const isPluginsLoading = + hasQuery && + (marketplacePluginsQuery.isPending || + (marketplacePluginsQuery.isFetching && !marketplacePluginsQuery.data)) return { plugins, @@ -217,7 +202,9 @@ export const useMarketplacePlugins = () => { isFetchingNextPage: marketplacePluginsQuery.isFetchingNextPage, hasNextPage: marketplacePluginsQuery.hasNextPage, fetchNextPage: marketplacePluginsQuery.fetchNextPage, - page: marketplacePluginsQuery.data?.pages?.length || (marketplacePluginsQuery.isPending && hasQuery ? 1 : 0), + page: + marketplacePluginsQuery.data?.pages?.length || + (marketplacePluginsQuery.isPending && hasQuery ? 1 : 0), } } @@ -225,25 +212,22 @@ export const useMarketplaceContainerScroll = ( callback: () => void, scrollContainerId = 'marketplace-container', ) => { - const handleScroll = useCallback((e: Event) => { - const target = e.target as HTMLDivElement - const { - scrollTop, - scrollHeight, - clientHeight, - } = target - if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) - callback() - }, [callback]) + const handleScroll = useCallback( + (e: Event) => { + const target = e.target as HTMLDivElement + const { scrollTop, scrollHeight, clientHeight } = target + if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) + callback() + }, + [callback], + ) useEffect(() => { const container = document.getElementById(scrollContainerId) - if (container) - container.addEventListener('scroll', handleScroll) + if (container) container.addEventListener('scroll', handleScroll) return () => { - if (container) - container.removeEventListener('scroll', handleScroll) + if (container) container.removeEventListener('scroll', handleScroll) } }, [handleScroll]) } diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx index ef5fca3fd5ed8b..8592a1f4ac9c22 100644 --- a/web/app/components/plugins/marketplace/hydration-server.tsx +++ b/web/app/components/plugins/marketplace/hydration-server.tsx @@ -24,7 +24,9 @@ async function getDehydratedState(searchParams?: Promise<SearchParams>) { const queryClient = getQueryClientServer() await queryClient.prefetchQuery({ - queryKey: marketplaceQuery.collections.queryKey({ input: { query: getCollectionsParams(params.category) } }), + queryKey: marketplaceQuery.collections.queryKey({ + input: { query: getCollectionsParams(params.category) }, + }), queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), }) return dehydrate(queryClient) @@ -38,9 +40,5 @@ export async function HydrateQueryClient({ children: React.ReactNode }) { const dehydratedState = await getDehydratedState(searchParams) - return ( - <HydrationBoundary state={dehydratedState}> - {children} - </HydrationBoundary> - ) + return <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary> } diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 785669bd5958ff..9c140b9d73a9d5 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -32,16 +32,10 @@ const Marketplace = async ({ isMarketplacePlatform={isMarketplacePlatform} marketplaceNav={marketplaceNav} /> - { - !isMarketplacePlatform && ( - <StickySearchAndSwitchWrapper - pluginTypeSwitchClassName={pluginTypeSwitchClassName} - /> - ) - } - <ListWrapper - showInstallButton={showInstallButton} - /> + {!isMarketplacePlatform && ( + <StickySearchAndSwitchWrapper pluginTypeSwitchClassName={pluginTypeSwitchClassName} /> + )} + <ListWrapper showInstallButton={showInstallButton} /> </PluginInstallPermissionProviderGuard> </HydrateQueryClient> </TanstackQueryInitializer> diff --git a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx index 69c854ce26bf65..abbb5e61713135 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx @@ -13,7 +13,7 @@ vi.mock('@/app/components/plugins/hooks', () => ({ })) vi.mock('@/app/components/plugins/card', () => ({ - default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => ( + default: ({ payload, footer }: { payload: Plugin; footer?: React.ReactNode }) => ( <div data-testid="card"> <span>{payload.name}</span> {footer} @@ -22,11 +22,9 @@ vi.mock('@/app/components/plugins/card', () => ({ })) vi.mock('@/app/components/plugins/card/card-more-info', () => ({ - default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => ( + default: ({ downloadCount, tags }: { downloadCount: number; tags: string[] }) => ( <div data-testid="card-more-info"> - {downloadCount} - : - {tags.join('|')} + {downloadCount}:{tags.join('|')} </div> ), })) @@ -34,7 +32,9 @@ vi.mock('@/app/components/plugins/card/card-more-info', () => ({ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="install-modal"> - <button data-testid="close-install-modal" onClick={onClose}>close</button> + <button data-testid="close-install-modal" onClick={onClose}> + close + </button> </div> ), })) @@ -46,7 +46,8 @@ vi.mock('@/app/components/plugins/install-plugin/hooks/use-plugin-install-permis vi.mock('../../utils', () => ({ getPluginDetailLinkInMarketplace: (plugin: Plugin) => `/detail/${plugin.org}/${plugin.name}`, - getPluginLinkInMarketplace: (plugin: Plugin, params: Record<string, string>) => `/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`, + getPluginLinkInMarketplace: (plugin: Plugin, params: Record<string, string>) => + `/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`, })) const plugin = { @@ -78,11 +79,12 @@ describe('CardWrapper', () => { vi.clearAllMocks() }) - const renderCardWrapper = (props: Partial<ComponentProps<typeof CardWrapper>> = {}) => render( - <ThemeProvider forcedTheme="dark"> - <CardWrapper plugin={plugin} {...props} /> - </ThemeProvider>, - ) + const renderCardWrapper = (props: Partial<ComponentProps<typeof CardWrapper>> = {}) => + render( + <ThemeProvider forcedTheme="dark"> + <CardWrapper plugin={plugin} {...props} /> + </ThemeProvider>, + ) it('renders a non-navigating card when install button is hidden', () => { renderCardWrapper() @@ -94,8 +96,12 @@ describe('CardWrapper', () => { it('renders install and marketplace detail actions when install button is shown', () => { renderCardWrapper({ showInstallButton: true }) - expect(screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' }), + ).toBeInTheDocument() }) it('opens marketplace detail from the detail action', () => { diff --git a/web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx index 6f330b01c5f0bf..fec1564a36f536 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx @@ -13,10 +13,10 @@ import ListWrapper from '../list-wrapper' vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ - t: withSelectorKey((key: string, options?: { ns?: string, num?: number }) => { - // Build full key with namespace prefix if provided + t: withSelectorKey((key: string, options?: { ns?: string; num?: number }) => { + // Build full key with namespace prefix if provided const fullKey = options?.ns ? `${options.ns}.${key}` : key const translations: Record<string, string> = { 'plugin.marketplace.viewMore': 'View More', @@ -29,7 +29,7 @@ vi.mock('#i18n', async () => { }), }), useLocale: () => 'en-US', - }) + } }) const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => { @@ -61,12 +61,15 @@ const mockTags = [ vi.mock('@/app/components/plugins/hooks', () => ({ useTags: () => ({ tags: mockTags, - tagsMap: mockTags.reduce((acc, tag) => { - acc[tag.name] = tag - return acc - }, {} as Record<string, { name: string, label: string }>), + tagsMap: mockTags.reduce( + (acc, tag) => { + acc[tag.name] = tag + return acc + }, + {} as Record<string, { name: string; label: string }>, + ), getTagLabel: (name: string) => { - const tag = mockTags.find(t => t.name === name) + const tag = mockTags.find((t) => t.name === name) return tag?.label || name }, }), @@ -108,12 +111,11 @@ vi.mock('../../utils', () => ({ }, getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record<string, string | undefined>) => `/plugins/${plugin.org}/${plugin.name}`, - getPluginDetailLinkInMarketplace: (plugin: Plugin) => - `/plugins/${plugin.org}/${plugin.name}`, + getPluginDetailLinkInMarketplace: (plugin: Plugin) => `/plugins/${plugin.org}/${plugin.name}`, })) vi.mock('@/app/components/plugins/card', () => ({ - default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => ( + default: ({ payload, footer }: { payload: Plugin; footer?: React.ReactNode }) => ( <div data-testid={`card-${payload.name}`}> <div data-testid="card-name">{payload.name}</div> <div data-testid="card-label">{payload.label?.['en-US'] || payload.name}</div> @@ -123,7 +125,7 @@ vi.mock('@/app/components/plugins/card', () => ({ })) vi.mock('@/app/components/plugins/card/card-more-info', () => ({ - default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => ( + default: ({ downloadCount, tags }: { downloadCount: number; tags: string[] }) => ( <div data-testid="card-more-info"> <span data-testid="download-count">{downloadCount}</span> <span data-testid="tags">{tags.join(',')}</span> @@ -134,15 +136,15 @@ vi.mock('@/app/components/plugins/card/card-more-info', () => ({ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="install-from-marketplace"> - <button onClick={onClose} data-testid="close-install-modal">Close</button> + <button onClick={onClose} data-testid="close-install-modal"> + Close + </button> </div> ), })) vi.mock('../../sort-dropdown', () => ({ - default: () => ( - <div data-testid="sort-dropdown">Sort</div> - ), + default: () => <div data-testid="sort-dropdown">Sort</div>, })) vi.mock('../../empty', () => ({ @@ -192,9 +194,12 @@ const createMockPluginList = (count: number): Plugin[] => name: `plugin-${i}`, plugin_id: `plugin-id-${i}`, label: { 'en-US': `Plugin ${i}` }, - })) + }), + ) -const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({ +const createMockCollection = ( + overrides?: Partial<MarketplaceCollection>, +): MarketplaceCollection => ({ name: `collection-${Math.random().toString(36).substring(7)}`, label: { 'en-US': 'Test Collection' }, description: { 'en-US': 'Test collection description' }, @@ -212,7 +217,8 @@ const createMockCollectionList = (count: number): MarketplaceCollection[] => name: `collection-${i}`, label: { 'en-US': `Collection ${i}` }, description: { 'en-US': `Description for collection ${i}` }, - })) + }), + ) // ================================ // List Component Tests @@ -267,12 +273,7 @@ describe('List', () => { it('should render plugin cards when plugins array is provided', () => { const plugins = createMockPluginList(3) - render( - <List - {...defaultProps} - plugins={plugins} - />, - ) + render(<List {...defaultProps} plugins={plugins} />) // Should render plugin cards expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument() @@ -281,12 +282,7 @@ describe('List', () => { }) it('should render Empty component when plugins array is empty', () => { - render( - <List - {...defaultProps} - plugins={[]} - />, - ) + render(<List {...defaultProps} plugins={[]} />) expect(screen.getByTestId('empty-component')).toBeInTheDocument() }) @@ -318,24 +314,14 @@ describe('List', () => { it('should apply cardContainerClassName to grid container', () => { const plugins = createMockPluginList(2) const { container } = render( - <List - {...defaultProps} - plugins={plugins} - cardContainerClassName="custom-grid-class" - />, + <List {...defaultProps} plugins={plugins} cardContainerClassName="custom-grid-class" />, ) expect(container.querySelector('.custom-grid-class')).toBeInTheDocument() }) it('should apply emptyClassName to Empty component', () => { - render( - <List - {...defaultProps} - plugins={[]} - emptyClassName="custom-empty-class" - />, - ) + render(<List {...defaultProps} plugins={[]} emptyClassName="custom-empty-class" />) expect(screen.getByTestId('empty-component')).toHaveClass('custom-empty-class') }) @@ -344,11 +330,7 @@ describe('List', () => { const plugins = createMockPluginList(1) const { container } = render( - <List - {...defaultProps} - plugins={plugins} - showInstallButton={true} - />, + <List {...defaultProps} plugins={plugins} showInstallButton={true} />, ) // CardWrapper should be rendered (via Card mock) @@ -364,19 +346,11 @@ describe('List', () => { const plugins = createMockPluginList(2) const customCardRender = (plugin: Plugin) => ( <div key={plugin.name} data-testid={`custom-card-${plugin.name}`}> - Custom: - {' '} - {plugin.name} + Custom: {plugin.name} </div> ) - render( - <List - {...defaultProps} - plugins={plugins} - cardRender={customCardRender} - />, - ) + render(<List {...defaultProps} plugins={plugins} cardRender={customCardRender} />) expect(screen.getByTestId('custom-card-plugin-0')).toBeInTheDocument() expect(screen.getByTestId('custom-card-plugin-1')).toBeInTheDocument() @@ -386,8 +360,7 @@ describe('List', () => { it('should handle cardRender returning null', () => { const plugins = createMockPluginList(2) const customCardRender = (plugin: Plugin) => { - if (plugin.name === 'plugin-0') - return null + if (plugin.name === 'plugin-0') return null return ( <div key={plugin.name} data-testid={`custom-card-${plugin.name}`}> {plugin.name} @@ -395,13 +368,7 @@ describe('List', () => { ) } - render( - <List - {...defaultProps} - plugins={plugins} - cardRender={customCardRender} - />, - ) + render(<List {...defaultProps} plugins={plugins} cardRender={customCardRender} />) expect(screen.queryByTestId('custom-card-plugin-0')).not.toBeInTheDocument() expect(screen.getByTestId('custom-card-plugin-1')).toBeInTheDocument() @@ -414,11 +381,7 @@ describe('List', () => { describe('Edge Cases', () => { it('should handle empty marketplaceCollections', () => { render( - <List - {...defaultProps} - marketplaceCollections={[]} - marketplaceCollectionPluginsMap={{}} - />, + <List {...defaultProps} marketplaceCollections={[]} marketplaceCollectionPluginsMap={{}} />, ) // Should not throw and render nothing @@ -447,12 +410,7 @@ describe('List', () => { it('should handle large number of plugins', () => { const plugins = createMockPluginList(100) - const { container } = render( - <List - {...defaultProps} - plugins={plugins} - />, - ) + const { container } = render(<List {...defaultProps} plugins={plugins} />) // Should render all plugin cards const cards = container.querySelectorAll('[data-testid^="card-plugin-"]') @@ -465,12 +423,7 @@ describe('List', () => { org: 'test-org', }) - render( - <List - {...defaultProps} - plugins={[specialPlugin]} - />, - ) + render(<List {...defaultProps} plugins={[specialPlugin]} />) expect(screen.getByTestId('card-plugin-with-special-chars!@#')).toBeInTheDocument() }) @@ -569,11 +522,13 @@ describe('ListWithCollection', () => { // ================================ describe('View More Button', () => { it('should render View More button when collection is searchable', () => { - const collections = [createMockCollection({ - name: 'collection-0', - searchable: true, - search_params: { query: 'test' }, - })] + const collections = [ + createMockCollection({ + name: 'collection-0', + searchable: true, + search_params: { query: 'test' }, + }), + ] const pluginsMap: Record<string, Plugin[]> = { 'collection-0': createMockPluginList(1), } @@ -590,10 +545,12 @@ describe('ListWithCollection', () => { }) it('should not render View More button when collection is not searchable', () => { - const collections = [createMockCollection({ - name: 'collection-0', - searchable: false, - })] + const collections = [ + createMockCollection({ + name: 'collection-0', + searchable: false, + }), + ] const pluginsMap: Record<string, Plugin[]> = { 'collection-0': createMockPluginList(1), } @@ -610,12 +567,17 @@ describe('ListWithCollection', () => { }) it('should call moreClick hook with search_params when View More is clicked', () => { - const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' } - const collections = [createMockCollection({ - name: 'collection-0', - searchable: true, - search_params: searchParams, - })] + const searchParams: SearchParamsFromCollection = { + query: 'test-query', + sort_by: 'install_count', + } + const collections = [ + createMockCollection({ + name: 'collection-0', + searchable: true, + search_params: searchParams, + }), + ] const pluginsMap: Record<string, Plugin[]> = { 'collection-0': createMockPluginList(1), } @@ -646,9 +608,7 @@ describe('ListWithCollection', () => { } const customCardRender = (plugin: Plugin) => ( <div key={plugin.plugin_id} data-testid={`custom-${plugin.name}`}> - Custom: - {' '} - {plugin.name} + Custom: {plugin.name} </div> ) @@ -886,11 +846,13 @@ describe('ListWrapper', () => { }) it('should show View More button and call moreClick hook', () => { - mockMarketplaceData.marketplaceCollections = [createMockCollection({ - name: 'collection-0', - searchable: true, - search_params: { query: 'test' }, - })] + mockMarketplaceData.marketplaceCollections = [ + createMockCollection({ + name: 'collection-0', + searchable: true, + search_params: { query: 'test' }, + }), + ] mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } @@ -997,11 +959,7 @@ describe('CardWrapper (via List integration)', () => { ] render( - <List - marketplaceCollections={[]} - marketplaceCollectionPluginsMap={{}} - plugins={plugins} - />, + <List marketplaceCollections={[]} marketplaceCollectionPluginsMap={{}} plugins={plugins} />, ) expect(screen.getByTestId('card-plugin1')).toBeInTheDocument() @@ -1354,10 +1312,12 @@ describe('Accessibility', () => { }) it('should have clickable View More button', () => { - const collections = [createMockCollection({ - name: 'collection-0', - searchable: true, - })] + const collections = [ + createMockCollection({ + name: 'collection-0', + searchable: true, + }), + ] const pluginsMap: Record<string, Plugin[]> = { 'collection-0': createMockPluginList(1), } @@ -1378,11 +1338,7 @@ describe('Accessibility', () => { const plugins = createMockPluginList(4) const { container } = render( - <List - marketplaceCollections={[]} - marketplaceCollectionPluginsMap={{}} - plugins={plugins} - />, + <List marketplaceCollections={[]} marketplaceCollectionPluginsMap={{}} plugins={plugins} />, ) const grid = container.querySelector('.grid-cols-4') @@ -1403,11 +1359,7 @@ describe('Performance', () => { const startTime = performance.now() render( - <List - marketplaceCollections={[]} - marketplaceCollectionPluginsMap={{}} - plugins={plugins} - />, + <List marketplaceCollections={[]} marketplaceCollectionPluginsMap={{}} plugins={plugins} />, ) const endTime = performance.now() diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx index fd5c63cb9305ca..c291ca6192766c 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx @@ -106,7 +106,11 @@ describe('ListWithCollection', () => { <ListWithCollection marketplaceCollections={collections} marketplaceCollectionPluginsMap={pluginsMap} - cardRender={plugin => <div key={plugin.plugin_id} data-testid="custom-card">{plugin.name}</div>} + cardRender={(plugin) => ( + <div key={plugin.plugin_id} data-testid="custom-card"> + {plugin.name} + </div> + )} />, ) @@ -131,14 +135,17 @@ describe('ListWithCollection', () => { marketplaceCollections={[partnerCollection, collections[0]!]} marketplaceCollectionPluginsMap={{ 'partner-template': [{ plugin_id: 'partner-plugin', name: 'Partner Plugin' }] as Plugin[], - 'featured': [{ plugin_id: 'featured-plugin', name: 'Featured Plugin' }] as Plugin[], + featured: [{ plugin_id: 'featured-plugin', name: 'Featured Plugin' }] as Plugin[], }} />, ) const partnerLink = screen.getByRole('link', { name: 'plugin.marketplace.becomePartner' }) - expect(partnerLink).toHaveAttribute('href', 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk') + expect(partnerLink).toHaveAttribute( + 'href', + 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk', + ) expect(partnerLink).toHaveAttribute('target', '_blank') expect(partnerLink).toHaveAttribute('rel', 'noopener noreferrer') expect(partnerLink.querySelector('.i-ri-external-link-line')).toHaveClass('size-3') @@ -163,12 +170,16 @@ describe('ListWithCollection', () => { <ListWithCollection marketplaceCollections={[misspelledPartnerCollection]} marketplaceCollectionPluginsMap={{ - parters: [{ plugin_id: 'misspelled-partner-plugin', name: 'Misspelled Partner Plugin' }] as Plugin[], + parters: [ + { plugin_id: 'misspelled-partner-plugin', name: 'Misspelled Partner Plugin' }, + ] as Plugin[], }} />, ) - expect(screen.queryByRole('link', { name: 'plugin.marketplace.becomePartner' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'plugin.marketplace.becomePartner' }), + ).not.toBeInTheDocument() }) it('uses carousel navigation instead of view more when collection exceeds two rows', () => { diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx index 9b08338f3339d6..7901b175877531 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx @@ -16,14 +16,17 @@ const mockMarketplaceData = vi.hoisted(() => ({ vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ - t: withSelectorKey((key: string, options?: { ns?: string, num?: number }) => + t: withSelectorKey((key: string, options?: { ns?: string; num?: number }) => key === 'marketplace.pluginsResult' && options?.ns === 'plugin' ? `${options.num} plugins found` - : options?.ns ? `${options.ns}.${key}` : key), + : options?.ns + ? `${options.ns}.${key}` + : key, + ), }), - }) + } }) vi.mock('../../state', () => ({ @@ -31,7 +34,11 @@ vi.mock('../../state', () => ({ })) vi.mock('@/app/components/base/loading', () => ({ - default: ({ className }: { className?: string }) => <div data-testid="loading" className={className}>loading</div>, + default: ({ className }: { className?: string }) => ( + <div data-testid="loading" className={className}> + loading + </div> + ), })) vi.mock('../../sort-dropdown', () => ({ @@ -39,7 +46,9 @@ vi.mock('../../sort-dropdown', () => ({ })) vi.mock('../index', () => ({ - default: ({ plugins }: { plugins?: Plugin[] }) => <div data-testid="list">{plugins?.length ?? 'collections'}</div>, + default: ({ plugins }: { plugins?: Plugin[] }) => ( + <div data-testid="list">{plugins?.length ?? 'collections'}</div> + ), })) describe('ListWrapper', () => { diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index 77a18e622f7939..df4890143b7a18 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -17,50 +17,54 @@ type CardWrapperProps = { plugin: Plugin showInstallButton?: boolean } -const CardWrapperComponent = ({ - plugin, - showInstallButton, -}: CardWrapperProps) => { +const CardWrapperComponent = ({ plugin, showInstallButton }: CardWrapperProps) => { const { t } = useTranslation() const { theme } = useTheme() - const [isShowInstallFromMarketplace, { - setTrue: showInstallFromMarketplace, - setFalse: hideInstallFromMarketplace, - }] = useBoolean(false) + const [ + isShowInstallFromMarketplace, + { setTrue: showInstallFromMarketplace, setFalse: hideInstallFromMarketplace }, + ] = useBoolean(false) const { canInstallPlugin } = useOptionalPluginInstallPermission() const locale = useLocale() const { getTagLabel } = useTags() // Memoize marketplace link params to prevent unnecessary re-renders - const marketplaceLinkParams = useMemo(() => ({ - language: locale, - theme, - }), [locale, theme]) + const marketplaceLinkParams = useMemo( + () => ({ + language: locale, + theme, + }), + [locale, theme], + ) // Memoize tag labels to prevent recreating array on every render - const tagLabels = useMemo(() => - plugin.tags.map(tag => getTagLabel(tag.name)), [plugin.tags, getTagLabel]) + const tagLabels = useMemo( + () => plugin.tags.map((tag) => getTagLabel(tag.name)), + [plugin.tags, getTagLabel], + ) const handleOpenMarketplaceDetail = () => { - window.open(getPluginLinkInMarketplace(plugin, marketplaceLinkParams), '_blank', 'noopener,noreferrer') + window.open( + getPluginLinkInMarketplace(plugin, marketplaceLinkParams), + '_blank', + 'noopener,noreferrer', + ) } const showInstallAction = !!showInstallButton && canInstallPlugin if (showInstallAction) { return ( - <div - className="group relative cursor-pointer rounded-xl" - > + <div className="group relative cursor-pointer rounded-xl"> <Card key={plugin.name} payload={plugin} variant="marketplace" - footer={( + footer={ <CardMoreInfo downloadCount={plugin.install_count} tags={tagLabels} variant="marketplace" /> - )} + } /> <div className="pointer-events-none absolute right-[-0.5px] bottom-[-0.5px] left-[-0.5px] z-10 flex items-center gap-2 rounded-b-xl bg-linear-to-t from-components-panel-on-panel-item-bg-hover from-[60%] to-background-gradient-mask-transparent px-4 pt-8 pb-4 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100"> <Button @@ -68,45 +72,41 @@ const CardWrapperComponent = ({ className="min-w-0 flex-1 shadow-md" onClick={showInstallFromMarketplace} > - {t($ => $['detailPanel.operation.install'], { ns: 'plugin' })} + {t(($) => $['detailPanel.operation.install'], { ns: 'plugin' })} </Button> <Button className="min-w-0 flex-1 gap-0.5 shadow-xs backdrop-blur-[5px]" onClick={handleOpenMarketplaceDetail} > - {t($ => $['detailPanel.operation.detail'], { ns: 'plugin' })} + {t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })} <span aria-hidden className="ml-1 i-ri-arrow-right-up-line size-4" /> </Button> </div> - { - isShowInstallFromMarketplace && ( - <InstallFromMarketplace - manifest={plugin} - uniqueIdentifier={plugin.latest_package_identifier} - onClose={hideInstallFromMarketplace} - onSuccess={hideInstallFromMarketplace} - /> - ) - } + {isShowInstallFromMarketplace && ( + <InstallFromMarketplace + manifest={plugin} + uniqueIdentifier={plugin.latest_package_identifier} + onClose={hideInstallFromMarketplace} + onSuccess={hideInstallFromMarketplace} + /> + )} </div> ) } return ( - <div - className="group relative rounded-xl" - > + <div className="group relative rounded-xl"> <Card key={plugin.name} payload={plugin} variant="marketplace" - footer={( + footer={ <CardMoreInfo downloadCount={plugin.install_count} tags={tagLabels} variant="marketplace" /> - )} + } /> </div> ) diff --git a/web/app/components/plugins/marketplace/list/carousel.tsx b/web/app/components/plugins/marketplace/list/carousel.tsx index ff9485f19c6203..96038a4a3638ae 100644 --- a/web/app/components/plugins/marketplace/list/carousel.tsx +++ b/web/app/components/plugins/marketplace/list/carousel.tsx @@ -24,12 +24,7 @@ type NavButtonProps = { iconClassName: string } -const NavButton = ({ - direction, - disabled, - onClick, - iconClassName, -}: NavButtonProps) => ( +const NavButton = ({ direction, disabled, onClick, iconClassName }: NavButtonProps) => ( <button className={cn( 'flex cursor-pointer items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all hover:bg-components-button-secondary-bg-hover', @@ -39,7 +34,10 @@ const NavButton = ({ disabled={disabled} aria-label={`Scroll ${direction}`} > - <span aria-hidden className={cn('size-4 text-components-button-secondary-text', iconClassName)} /> + <span + aria-hidden + className={cn('size-4 text-components-button-secondary-text', iconClassName)} + /> </button> ) @@ -66,8 +64,7 @@ const CarouselControls = ({ })) const totalPages = scrollSnaps.length - if (totalPages <= 1) - return null + if (totalPages <= 1) return null return ( <div className="absolute -top-10 right-0 flex items-center gap-3"> @@ -115,8 +112,7 @@ const Carousel = ({ autoPlayInterval = 5000, }: CarouselProps) => { const plugins = useMemo(() => { - if (!autoPlay) - return [] + if (!autoPlay) return [] return [ Autoplay({ @@ -140,8 +136,7 @@ const Carousel = ({ }, [api]) useEffect(() => { - if (!api) - return + if (!api) return const handleSelect = () => { setSelectedIndex(api.selectedScrollSnap()) @@ -159,11 +154,7 @@ const Carousel = ({ }, [api]) return ( - <div - className={cn('relative', className)} - role="region" - aria-roledescription="carousel" - > + <div className={cn('relative', className)} role="region" aria-roledescription="carousel"> {showNavigation && ( <CarouselControls api={api} @@ -174,10 +165,7 @@ const Carousel = ({ scrollSnaps={scrollSnaps} /> )} - <div - ref={carouselRef} - className="overflow-hidden [border-radius:inherit]" - > + <div ref={carouselRef} className="overflow-hidden [border-radius:inherit]"> <div className="flex" style={{ columnGap: '12px' }}> {children} </div> diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx index 17bcfcb653b94c..1b5ade5110c68e 100644 --- a/web/app/components/plugins/marketplace/list/index.tsx +++ b/web/app/components/plugins/marketplace/list/index.tsx @@ -29,47 +29,32 @@ const List = ({ }: ListProps) => { return ( <PluginInstallPermissionProviderGuard canInstallPlugin={!!showInstallButton}> - { - !plugins && ( - <ListWithCollection - marketplaceCollections={marketplaceCollections} - marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap} - showInstallButton={showInstallButton} - cardContainerClassName={cardContainerClassName} - cardRender={cardRender} - onCollectionMoreClick={onCollectionMoreClick} - /> - ) - } - { - plugins && !!plugins.length && ( - <div className={cn( - 'grid grid-cols-4 gap-3', - cardContainerClassName, - )} - > - { - plugins.map((plugin) => { - if (cardRender) - return cardRender(plugin) + {!plugins && ( + <ListWithCollection + marketplaceCollections={marketplaceCollections} + marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap} + showInstallButton={showInstallButton} + cardContainerClassName={cardContainerClassName} + cardRender={cardRender} + onCollectionMoreClick={onCollectionMoreClick} + /> + )} + {plugins && !!plugins.length && ( + <div className={cn('grid grid-cols-4 gap-3', cardContainerClassName)}> + {plugins.map((plugin) => { + if (cardRender) return cardRender(plugin) - return ( - <CardWrapper - key={`${plugin.org}/${plugin.name}`} - plugin={plugin} - showInstallButton={showInstallButton} - /> - ) - }) - } - </div> - ) - } - { - plugins && !plugins.length && ( - <Empty className={emptyClassName} /> - ) - } + return ( + <CardWrapper + key={`${plugin.org}/${plugin.name}`} + plugin={plugin} + showInstallButton={showInstallButton} + /> + ) + })} + </div> + )} + {plugins && !plugins.length && <Empty className={emptyClassName} />} </PluginInstallPermissionProviderGuard> ) } diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index 66ba9b8c52962f..46cdfcbd48c1c1 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -20,15 +20,13 @@ import { const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk' const PARTNERS_COLLECTION_NAMES = new Set(['partners', 'partner-template', 'Partner Template']) -const getViewportWidth = () => typeof window === 'undefined' ? CAROUSEL_BREAKPOINTS.xl : window.innerWidth +const getViewportWidth = () => + typeof window === 'undefined' ? CAROUSEL_BREAKPOINTS.xl : window.innerWidth const getCarouselItemsPerPage = (viewportWidth: number) => { - if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl) - return CAROUSEL_PAGE_SIZE.xl - if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg) - return CAROUSEL_PAGE_SIZE.lg - if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm) - return CAROUSEL_PAGE_SIZE.sm + if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl) return CAROUSEL_PAGE_SIZE.xl + if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg) return CAROUSEL_PAGE_SIZE.lg + if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm) return CAROUSEL_PAGE_SIZE.sm return CAROUSEL_PAGE_SIZE.base } @@ -48,20 +46,10 @@ type PluginCardProps = { cardRender?: (plugin: Plugin) => React.JSX.Element | null } -const PluginCard = ({ - plugin, - showInstallButton, - cardRender, -}: PluginCardProps) => { - if (cardRender) - return cardRender(plugin) +const PluginCard = ({ plugin, showInstallButton, cardRender }: PluginCardProps) => { + if (cardRender) return cardRender(plugin) - return ( - <CardWrapper - plugin={plugin} - showInstallButton={showInstallButton} - /> - ) + return <CardWrapper plugin={plugin} showInstallButton={showInstallButton} /> } const ListWithCollection = ({ @@ -89,23 +77,23 @@ const ListWithCollection = ({ return ( <> - { - marketplaceCollections.filter((collection) => { + {marketplaceCollections + .filter((collection) => { return marketplaceCollectionPluginsMap[collection.name]?.length - }).map((collection) => { + }) + .map((collection) => { const plugins = marketplaceCollectionPluginsMap[collection.name]! const pages = buildCarouselPages(plugins, itemsPerPage) const hasMultiplePages = pages.length > 1 const isPartnersCollection = PARTNERS_COLLECTION_NAMES.has(collection.name) return ( - <div - key={collection.name} - className="py-3" - > + <div key={collection.name} className="py-3"> <div className="flex items-end justify-between"> <div> - <div className="title-xl-semi-bold text-text-primary">{collection.label[getLanguage(locale)]}</div> + <div className="title-xl-semi-bold text-text-primary"> + {collection.label[getLanguage(locale)]} + </div> <div className="flex items-center gap-x-2 system-xs-regular text-text-tertiary"> {collection.description[getLanguage(locale)]} {isPartnersCollection && ( @@ -117,74 +105,66 @@ const ListWithCollection = ({ rel="noopener noreferrer" className="flex items-center gap-x-0.5 text-text-accent hover:underline" > - <span>{t($ => $['marketplace.becomePartner'], { ns: 'plugin' })}</span> + <span>{t(($) => $['marketplace.becomePartner'], { ns: 'plugin' })}</span> <span aria-hidden className="i-ri-external-link-line size-3" /> </a> </> )} </div> </div> - { - collection.searchable && !hasMultiplePages && ( - <div - className="flex cursor-pointer items-center system-xs-medium text-text-accent" - onClick={() => handleMoreClick(collection.search_params)} - > - {t($ => $['marketplace.viewMore'], { ns: 'plugin' })} - <span aria-hidden className="i-ri-arrow-right-s-line size-4" /> - </div> - ) - } + {collection.searchable && !hasMultiplePages && ( + <div + className="flex cursor-pointer items-center system-xs-medium text-text-accent" + onClick={() => handleMoreClick(collection.search_params)} + > + {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })} + <span aria-hidden className="i-ri-arrow-right-s-line size-4" /> + </div> + )} </div> - {hasMultiplePages - ? ( - <Carousel - className="mt-2" - showNavigation - showPagination - autoPlay - autoPlayInterval={5000} + {hasMultiplePages ? ( + <Carousel + className="mt-2" + showNavigation + showPagination + autoPlay + autoPlayInterval={5000} + > + {pages.map((pageItems) => ( + <div + key={pageItems.map((plugin) => plugin.plugin_id).join('-')} + className={CAROUSEL_PAGE_CLASS} + style={{ scrollSnapAlign: 'start' }} > - {pages.map(pageItems => ( - <div - key={pageItems.map(plugin => plugin.plugin_id).join('-')} - className={CAROUSEL_PAGE_CLASS} - style={{ scrollSnapAlign: 'start' }} - > - <div className={cn(GRID_CLASS, cardContainerClassName)}> - {pageItems.map(plugin => ( - <div - key={plugin.plugin_id} - className="min-w-0 [&>*]:w-full" - > - <PluginCard - plugin={plugin} - showInstallButton={showInstallButton} - cardRender={cardRender} - /> - </div> - ))} + <div className={cn(GRID_CLASS, cardContainerClassName)}> + {pageItems.map((plugin) => ( + <div key={plugin.plugin_id} className="min-w-0 [&>*]:w-full"> + <PluginCard + plugin={plugin} + showInstallButton={showInstallButton} + cardRender={cardRender} + /> </div> - </div> - ))} - </Carousel> - ) - : ( - <div className={cn('mt-2', GRID_CLASS, cardContainerClassName)}> - {plugins.map(plugin => ( - <PluginCard - key={plugin.plugin_id} - plugin={plugin} - showInstallButton={showInstallButton} - cardRender={cardRender} - /> - ))} + ))} + </div> </div> - )} + ))} + </Carousel> + ) : ( + <div className={cn('mt-2', GRID_CLASS, cardContainerClassName)}> + {plugins.map((plugin) => ( + <PluginCard + key={plugin.plugin_id} + plugin={plugin} + showInstallButton={showInstallButton} + cardRender={cardRender} + /> + ))} + </div> + )} </div> ) - }) - } + })} </> ) } diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index d5a3a3c28b8faf..385bc9be9f41d8 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -8,9 +8,7 @@ import List from './index' type ListWrapperProps = { showInstallButton?: boolean } -const ListWrapper = ({ - showInstallButton, -}: ListWrapperProps) => { +const ListWrapper = ({ showInstallButton }: ListWrapperProps) => { const { t } = useTranslation() const { @@ -32,38 +30,30 @@ const ListWrapper = ({ className="relative flex grow flex-col bg-background-default-subtle px-8 py-2" > <div className="flex w-full grow flex-col"> - { - plugins && ( - <div className="mb-4 flex items-center pt-3"> - <div className="title-xl-semi-bold text-text-primary">{t($ => $['marketplace.pluginsResult'], { ns: 'plugin', num: pluginsTotal })}</div> - <div className="mx-3 h-3.5 w-px bg-divider-regular"></div> - <SortDropdown /> + {plugins && ( + <div className="mb-4 flex items-center pt-3"> + <div className="title-xl-semi-bold text-text-primary"> + {t(($) => $['marketplace.pluginsResult'], { ns: 'plugin', num: pluginsTotal })} </div> - ) - } - { - (!isLoading || page > 1) && ( - <List - marketplaceCollections={marketplaceCollections || []} - marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} - plugins={plugins} - showInstallButton={showInstallButton} - /> - ) - } - </div> - { - isLoading && page === 1 && ( - <div className="absolute top-1/2 left-1/2 -translate-1/2"> - <Loading /> + <div className="mx-3 h-3.5 w-px bg-divider-regular"></div> + <SortDropdown /> </div> - ) - } - { - isFetchingNextPage && ( - <Loading className="my-3" /> - ) - } + )} + {(!isLoading || page > 1) && ( + <List + marketplaceCollections={marketplaceCollections || []} + marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} + plugins={plugins} + showInstallButton={showInstallButton} + /> + )} + </div> + {isLoading && page === 1 && ( + <div className="absolute top-1/2 left-1/2 -translate-1/2"> + <Loading /> + </div> + )} + {isFetchingNextPage && <Loading className="my-3" />} </div> ) } diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index 85c3b14e7d8d9c..8ca737cd8daa08 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -21,10 +21,7 @@ type PluginTypeSwitchProps = { className?: string variant?: 'default' | 'hero' } -const PluginTypeSwitch = ({ - className, - variant = 'default', -}: PluginTypeSwitchProps) => { +const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchProps) => { const { t } = useTranslation() const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() const setSearchMode = useSetAtom(searchModeAtom) @@ -38,95 +35,96 @@ const PluginTypeSwitch = ({ }> = [ { value: PLUGIN_TYPE_SEARCH_MAP.all, - text: isHero ? t($ => $['marketplace.allPlugins'], { ns: 'plugin' }) : t($ => $['category.all'], { ns: 'plugin' }), + text: isHero + ? t(($) => $['marketplace.allPlugins'], { ns: 'plugin' }) + : t(($) => $['category.all'], { ns: 'plugin' }), icon: isHero ? <PluginIcon className={iconClassName} /> : null, }, { value: PLUGIN_TYPE_SEARCH_MAP.model, - text: t($ => $['category.models'], { ns: 'plugin' }), + text: t(($) => $['category.models'], { ns: 'plugin' }), icon: <RiBrain2Line className={iconClassName} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.tool, - text: t($ => $['category.tools'], { ns: 'plugin' }), + text: t(($) => $['category.tools'], { ns: 'plugin' }), icon: <RiHammerLine className={iconClassName} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.datasource, - text: t($ => $['category.datasources'], { ns: 'plugin' }), + text: t(($) => $['category.datasources'], { ns: 'plugin' }), icon: <RiDatabase2Line className={iconClassName} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.agent, - text: t($ => $['category.agents'], { ns: 'plugin' }), + text: t(($) => $['category.agents'], { ns: 'plugin' }), icon: <RiSpeakAiLine className={iconClassName} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.trigger, - text: t($ => $['category.triggers'], { ns: 'plugin' }), + text: t(($) => $['category.triggers'], { ns: 'plugin' }), icon: <TriggerIcon className={iconClassName} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.extension, - text: t($ => $['category.extensions'], { ns: 'plugin' }), + text: t(($) => $['category.extensions'], { ns: 'plugin' }), icon: <RiPuzzle2Line className={iconClassName} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.bundle, - text: t($ => $['category.bundles'], { ns: 'plugin' }), + text: t(($) => $['category.bundles'], { ns: 'plugin' }), icon: <RiArchive2Line className={iconClassName} />, }, ] return ( - <div className={cn( - isHero - ? 'flex shrink-0 items-center gap-1 overflow-x-auto' - : 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3', - className, - )} + <div + className={cn( + isHero + ? 'flex shrink-0 items-center gap-1 overflow-x-auto' + : 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3', + className, + )} > - { - options.map((option, index) => { - const isActive = activePluginType === option.value + {options.map((option, index) => { + const isActive = activePluginType === option.value - return ( - <Fragment key={option.value}> - <div - className={cn( - 'flex h-8 cursor-pointer items-center rounded-lg border border-transparent px-2.5 system-md-medium whitespace-nowrap', - isHero - ? 'text-text-primary-on-surface' - : 'text-text-tertiary', - !isActive && (isHero + return ( + <Fragment key={option.value}> + <div + className={cn( + 'flex h-8 cursor-pointer items-center rounded-lg border border-transparent px-2.5 system-md-medium whitespace-nowrap', + isHero ? 'text-text-primary-on-surface' : 'text-text-tertiary', + !isActive && + (isHero ? 'hover:bg-white/20' : 'hover:bg-state-base-hover hover:text-text-secondary'), - isActive && (isHero + isActive && + (isHero ? 'border-white/95 bg-components-main-nav-nav-button-bg-active text-saas-dify-blue-inverted shadow-md backdrop-blur-[5px]' : 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active! text-components-main-nav-nav-button-text-active! shadow-xs'), - )} - onClick={() => { - handleActivePluginTypeChange(option.value) - if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) { - setSearchMode(null) - } - }} + )} + onClick={() => { + handleActivePluginTypeChange(option.value) + if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) { + setSearchMode(null) + } + }} + > + {option.icon} + {option.text} + </div> + {isHero && index === 0 && ( + <div + aria-hidden + className="flex h-8 items-center justify-center px-2 system-md-regular text-text-primary-on-surface" > - {option.icon} - {option.text} + · </div> - {isHero && index === 0 && ( - <div - aria-hidden - className="flex h-8 items-center justify-center px-2 system-md-regular text-text-primary-on-surface" - > - · - </div> - )} - </Fragment> - ) - }) - } + )} + </Fragment> + ) + })} </div> ) } diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts index ce8353bfe5ca74..ff9663636865a9 100644 --- a/web/app/components/plugins/marketplace/query.ts +++ b/web/app/components/plugins/marketplace/query.ts @@ -12,9 +12,7 @@ export function useMarketplaceCollectionsAndPlugins( }) } -export function useMarketplacePlugins( - queryParams: PluginsSearchParams | undefined, -) { +export function useMarketplacePlugins(queryParams: PluginsSearchParams | undefined) { return useInfiniteQuery({ queryKey: marketplaceQuery.searchAdvanced.queryKey({ input: { diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx index adecc35e28b9d7..f51f61a0a7b0cd 100644 --- a/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx @@ -13,10 +13,10 @@ import ToolSelectorTrigger from '../trigger/tool-selector' // Mock i18n translation hook vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey((key: string, options?: { ns?: string }) => { - // Build full key with namespace prefix if provided + // Build full key with namespace prefix if provided const fullKey = options?.ns ? `${options.ns}.${key}` : key const translations: Record<string, string> = { 'pluginTags.allTags': 'All Tags', @@ -26,11 +26,16 @@ vi.mock('#i18n', async () => { return translations[fullKey] || key }), }), - }) + } }) // Mock marketplace state hooks -const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => { +const { + mockSearchPluginText, + mockHandleSearchPluginTextChange, + mockFilterPluginTags, + mockHandleFilterPluginTagsChange, +} = vi.hoisted(() => { return { mockSearchPluginText: '', mockHandleSearchPluginTextChange: vi.fn(), @@ -53,10 +58,13 @@ const mockTags: Tag[] = [ { name: 'videos', label: 'Videos' }, ] -const mockTagsMap: Record<string, Tag> = mockTags.reduce((acc, tag) => { - acc[tag.name] = tag - return acc -}, {} as Record<string, Tag>) +const mockTagsMap: Record<string, Tag> = mockTags.reduce( + (acc, tag) => { + acc[tag.name] = tag + return acc + }, + {} as Record<string, Tag>, +) vi.mock('@/app/components/plugins/hooks', () => ({ useTags: () => ({ @@ -70,7 +78,11 @@ let mockPortalOpenState = false let mockPopoverOnOpenChange: ((open: boolean) => void) | undefined vi.mock('@langgenius/dify-ui/popover', () => ({ - Popover: ({ children, open, onOpenChange }: { + Popover: ({ + children, + open, + onOpenChange, + }: { children: React.ReactNode open: boolean onOpenChange?: (open: boolean) => void @@ -83,7 +95,11 @@ vi.mock('@langgenius/dify-ui/popover', () => ({ </div> ) }, - PopoverTrigger: ({ children, render, className }: { + PopoverTrigger: ({ + children, + render, + className, + }: { children?: React.ReactNode render?: React.ReactNode className?: string @@ -96,13 +112,9 @@ vi.mock('@langgenius/dify-ui/popover', () => ({ {render ?? children} </div> ), - PopoverContent: ({ children, className }: { - children: React.ReactNode - className?: string - }) => { + PopoverContent: ({ children, className }: { children: React.ReactNode; className?: string }) => { // Only render content when portal is open - if (!mockPortalOpenState) - return null + if (!mockPortalOpenState) return null return ( <div data-testid="portal-content" className={className}> {children} @@ -138,9 +150,7 @@ describe('SearchBox', () => { }) it('should render with marketplace mode styling', () => { - const { container } = render( - <SearchBox {...defaultProps} usedInMarketplace />, - ) + const { container } = render(<SearchBox {...defaultProps} usedInMarketplace />) // In marketplace mode, TagsFilter comes before input // In marketplace mode, TagsFilter comes before input @@ -148,9 +158,7 @@ describe('SearchBox', () => { }) it('should render with non-marketplace mode styling', () => { - const { container } = render( - <SearchBox {...defaultProps} usedInMarketplace={false} />, - ) + const { container } = render(<SearchBox {...defaultProps} usedInMarketplace={false} />) // In non-marketplace mode, search icon appears first // In non-marketplace mode, search icon appears first @@ -215,13 +223,13 @@ describe('SearchBox', () => { // ================================ describe('Non-Marketplace Mode', () => { it('should render search icon at the beginning', () => { - const { container } = render( - <SearchBox {...defaultProps} usedInMarketplace={false} />, - ) + const { container } = render(<SearchBox {...defaultProps} usedInMarketplace={false} />) // Search icon should be present // Search icon should be present - expect(container.querySelector('.text-components-input-text-placeholder'))!.toBeInTheDocument() + expect( + container.querySelector('.text-components-input-text-placeholder'), + )!.toBeInTheDocument() }) it('should render clear button when search has value', () => { @@ -330,9 +338,7 @@ describe('SearchBox', () => { }) it('should not render add custom tool button when supportAddCustomTool is false', () => { - const { container } = render( - <SearchBox {...defaultProps} supportAddCustomTool={false} />, - ) + const { container } = render(<SearchBox {...defaultProps} supportAddCustomTool={false} />) // Check for the rounded-full button which is the add button const addButton = container.querySelector('.rounded-full') @@ -351,9 +357,7 @@ describe('SearchBox', () => { // Find the add button (it has rounded-full class) const buttons = screen.getAllByRole('button') - const addButton = buttons.find(btn => - btn.className.includes('rounded-full'), - ) + const addButton = buttons.find((btn) => btn.className.includes('rounded-full')) if (addButton) { fireEvent.click(addButton) @@ -522,9 +526,7 @@ describe('MarketplaceTrigger', () => { }) it('should show arrow down icon when no tags selected', () => { - const { container } = render( - <MarketplaceTrigger {...defaultProps} selectedTagsLength={0} />, - ) + const { container } = render(<MarketplaceTrigger {...defaultProps} selectedTagsLength={0} />) // Arrow down icon should be present // Arrow down icon should be present @@ -534,24 +536,14 @@ describe('MarketplaceTrigger', () => { describe('Selected Tags Display', () => { it('should show selected tag labels when tags are selected', () => { - render( - <MarketplaceTrigger - {...defaultProps} - selectedTagsLength={1} - tags={['agent']} - />, - ) + render(<MarketplaceTrigger {...defaultProps} selectedTagsLength={1} tags={['agent']} />) expect(screen.getByText('Agent'))!.toBeInTheDocument() }) it('should show multiple tag labels separated by comma', () => { render( - <MarketplaceTrigger - {...defaultProps} - selectedTagsLength={2} - tags={['agent', 'rag']} - />, + <MarketplaceTrigger {...defaultProps} selectedTagsLength={2} tags={['agent', 'rag']} />, ) expect(screen.getByText('Agent,RAG'))!.toBeInTheDocument() @@ -586,11 +578,7 @@ describe('MarketplaceTrigger', () => { describe('Clear Tags Button', () => { it('should show clear button when tags are selected', () => { const { container } = render( - <MarketplaceTrigger - {...defaultProps} - selectedTagsLength={1} - tags={['agent']} - />, + <MarketplaceTrigger {...defaultProps} selectedTagsLength={1} tags={['agent']} />, ) // RiCloseCircleFill icon should be present @@ -599,9 +587,7 @@ describe('MarketplaceTrigger', () => { }) it('should not show clear button when no tags selected', () => { - const { container } = render( - <MarketplaceTrigger {...defaultProps} selectedTagsLength={0} />, - ) + const { container } = render(<MarketplaceTrigger {...defaultProps} selectedTagsLength={0} />) // Clear button should not be present // Clear button should not be present @@ -668,22 +654,18 @@ describe('MarketplaceTrigger', () => { it('should apply border styling when tags are selected', () => { const { container } = render( - <MarketplaceTrigger - {...defaultProps} - selectedTagsLength={1} - tags={['agent']} - />, + <MarketplaceTrigger {...defaultProps} selectedTagsLength={1} tags={['agent']} />, ) - expect(container.querySelector('.border-components-button-secondary-border'))!.toBeInTheDocument() + expect( + container.querySelector('.border-components-button-secondary-border'), + )!.toBeInTheDocument() }) }) describe('Props Variations', () => { it('should handle empty tagsMap', () => { - const { container } = render( - <MarketplaceTrigger {...defaultProps} tagsMap={{}} tags={[]} />, - ) + const { container } = render(<MarketplaceTrigger {...defaultProps} tagsMap={{}} tags={[]} />) expect(container)!.toBeInTheDocument() }) @@ -722,24 +704,14 @@ describe('ToolSelectorTrigger', () => { describe('Selected Tags Display', () => { it('should show selected tag labels when tags are selected', () => { - render( - <ToolSelectorTrigger - {...defaultProps} - selectedTagsLength={1} - tags={['agent']} - />, - ) + render(<ToolSelectorTrigger {...defaultProps} selectedTagsLength={1} tags={['agent']} />) expect(screen.getByText('Agent'))!.toBeInTheDocument() }) it('should show multiple tag labels separated by comma', () => { render( - <ToolSelectorTrigger - {...defaultProps} - selectedTagsLength={2} - tags={['agent', 'rag']} - />, + <ToolSelectorTrigger {...defaultProps} selectedTagsLength={2} tags={['agent', 'rag']} />, ) expect(screen.getByText('Agent,RAG'))!.toBeInTheDocument() @@ -767,20 +739,14 @@ describe('ToolSelectorTrigger', () => { describe('Clear Tags Button', () => { it('should show clear button when tags are selected', () => { const { container } = render( - <ToolSelectorTrigger - {...defaultProps} - selectedTagsLength={1} - tags={['agent']} - />, + <ToolSelectorTrigger {...defaultProps} selectedTagsLength={1} tags={['agent']} />, ) expect(container.querySelector('.text-text-quaternary'))!.toBeInTheDocument() }) it('should not show clear button when no tags selected', () => { - const { container } = render( - <ToolSelectorTrigger {...defaultProps} selectedTagsLength={0} />, - ) + const { container } = render(<ToolSelectorTrigger {...defaultProps} selectedTagsLength={0} />) expect(container.querySelector('.text-text-quaternary')).not.toBeInTheDocument() }) @@ -839,29 +805,24 @@ describe('ToolSelectorTrigger', () => { it('should apply border styling when tags are selected', () => { const { container } = render( - <ToolSelectorTrigger - {...defaultProps} - selectedTagsLength={1} - tags={['agent']} - />, + <ToolSelectorTrigger {...defaultProps} selectedTagsLength={1} tags={['agent']} />, ) - expect(container.querySelector('.border-components-button-secondary-border'))!.toBeInTheDocument() + expect( + container.querySelector('.border-components-button-secondary-border'), + )!.toBeInTheDocument() }) it('should not apply hover styling when open but has tags', () => { const { container } = render( - <ToolSelectorTrigger - {...defaultProps} - open - selectedTagsLength={1} - tags={['agent']} - />, + <ToolSelectorTrigger {...defaultProps} open selectedTagsLength={1} tags={['agent']} />, ) // Should have border styling, not hover // Should have border styling, not hover - expect(container.querySelector('.border-components-button-secondary-border'))!.toBeInTheDocument() + expect( + container.querySelector('.border-components-button-secondary-border'), + )!.toBeInTheDocument() }) }) @@ -895,14 +856,7 @@ describe('TagsFilter', () => { describe('Integration with SearchBox', () => { it('should render TagsFilter within SearchBox', () => { - render( - <SearchBox - search="" - onSearchChange={vi.fn()} - tags={[]} - onTagsChange={vi.fn()} - />, - ) + render(<SearchBox search="" onSearchChange={vi.fn()} tags={[]} onTagsChange={vi.fn()} />) expect(screen.getByTestId('portal-elem'))!.toBeInTheDocument() }) @@ -940,14 +894,7 @@ describe('TagsFilter', () => { describe('Dropdown Behavior', () => { it('should open dropdown when trigger is clicked', async () => { - render( - <SearchBox - search="" - onSearchChange={vi.fn()} - tags={[]} - onTagsChange={vi.fn()} - />, - ) + render(<SearchBox search="" onSearchChange={vi.fn()} tags={[]} onTagsChange={vi.fn()} />) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -958,14 +905,7 @@ describe('TagsFilter', () => { }) it('should close dropdown when trigger is clicked again', async () => { - render( - <SearchBox - search="" - onSearchChange={vi.fn()} - tags={[]} - onTagsChange={vi.fn()} - />, - ) + render(<SearchBox search="" onSearchChange={vi.fn()} tags={[]} onTagsChange={vi.fn()} />) const trigger = screen.getByTestId('portal-trigger') @@ -985,14 +925,7 @@ describe('TagsFilter', () => { describe('Tag Selection', () => { it('should display tag options when dropdown is open', async () => { - render( - <SearchBox - search="" - onSearchChange={vi.fn()} - tags={[]} - onTagsChange={vi.fn()} - />, - ) + render(<SearchBox search="" onSearchChange={vi.fn()} tags={[]} onTagsChange={vi.fn()} />) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -1005,14 +938,7 @@ describe('TagsFilter', () => { it('should call onTagsChange when a tag is selected', async () => { const onTagsChange = vi.fn() - render( - <SearchBox - search="" - onSearchChange={vi.fn()} - tags={[]} - onTagsChange={onTagsChange} - />, - ) + render(<SearchBox search="" onSearchChange={vi.fn()} tags={[]} onTagsChange={onTagsChange} />) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -1080,14 +1006,7 @@ describe('TagsFilter', () => { describe('Search Tags Feature', () => { it('should render search input in dropdown', async () => { - render( - <SearchBox - search="" - onSearchChange={vi.fn()} - tags={[]} - onTagsChange={vi.fn()} - />, - ) + render(<SearchBox search="" onSearchChange={vi.fn()} tags={[]} onTagsChange={vi.fn()} />) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -1099,14 +1018,7 @@ describe('TagsFilter', () => { }) it('should filter tags based on search text', async () => { - render( - <SearchBox - search="" - onSearchChange={vi.fn()} - tags={[]} - onTagsChange={vi.fn()} - />, - ) + render(<SearchBox search="" onSearchChange={vi.fn()} tags={[]} onTagsChange={vi.fn()} />) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -1116,8 +1028,8 @@ describe('TagsFilter', () => { }) const inputs = screen.getAllByRole('textbox') - const searchInput = inputs.find(input => - input.getAttribute('placeholder') === 'Search tags', + const searchInput = inputs.find( + (input) => input.getAttribute('placeholder') === 'Search tags', ) if (searchInput) { @@ -1131,12 +1043,7 @@ describe('TagsFilter', () => { // Note: The Checkbox component is a custom div-based component, not native checkbox it('should display tag options with proper selection state', async () => { render( - <SearchBox - search="" - onSearchChange={vi.fn()} - tags={['agent']} - onTagsChange={vi.fn()} - />, + <SearchBox search="" onSearchChange={vi.fn()} tags={['agent']} onTagsChange={vi.fn()} />, ) const trigger = screen.getByTestId('portal-trigger') @@ -1153,14 +1060,7 @@ describe('TagsFilter', () => { }) it('should render tag options when dropdown is open', async () => { - render( - <SearchBox - search="" - onSearchChange={vi.fn()} - tags={[]} - onTagsChange={vi.fn()} - />, - ) + render(<SearchBox search="" onSearchChange={vi.fn()} tags={[]} onTagsChange={vi.fn()} />) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx index 1430f0f5e3b626..8988ec20b575f7 100644 --- a/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx @@ -22,15 +22,17 @@ describe('SearchBoxWrapper', () => { render(<SearchBoxWrapper />) expect(screen.getByTestId('search-box')).toBeInTheDocument() - expect(mockSearchBox).toHaveBeenCalledWith(expect.objectContaining({ - wrapperClassName: 'z-11 mx-auto w-[640px] shrink-0', - inputClassName: 'w-full', - search: 'plugin search', - onSearchChange: mockHandleSearchPluginTextChange, - tags: ['agent', 'rag'], - onTagsChange: mockHandleFilterPluginTagsChange, - placeholder: 'plugin.searchPlugins', - usedInMarketplace: true, - })) + expect(mockSearchBox).toHaveBeenCalledWith( + expect.objectContaining({ + wrapperClassName: 'z-11 mx-auto w-[640px] shrink-0', + inputClassName: 'w-full', + search: 'plugin search', + onSearchChange: mockHandleSearchPluginTextChange, + tags: ['agent', 'rag'], + onTagsChange: mockHandleFilterPluginTagsChange, + placeholder: 'plugin.searchPlugins', + usedInMarketplace: true, + }), + ) }) }) diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx index 9edd8985db7b65..f690264500f50c 100644 --- a/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx @@ -1,23 +1,20 @@ -import { - fireEvent, - render, - screen, - within, -} from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import TagsFilter from '../tags-filter' const { mockTranslate } = vi.hoisted(() => ({ - mockTranslate: vi.fn((key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key), + mockTranslate: vi.fn((key: string, options?: { ns?: string }) => + options?.ns ? `${options.ns}.${key}` : key, + ), })) vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey(mockTranslate), }), - }) + } }) vi.mock('@/app/components/plugins/hooks', () => ({ @@ -49,7 +46,7 @@ vi.mock('@/app/components/base/input', () => ({ aria-label="tags-search" value={value} placeholder={placeholder} - onChange={event => onChange({ target: { value: event.target.value } })} + onChange={(event) => onChange({ target: { value: event.target.value } })} /> ), })) @@ -84,7 +81,9 @@ describe('TagsFilter', () => { beforeEach(() => { vi.clearAllMocks() - mockTranslate.mockImplementation((key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key) + mockTranslate.mockImplementation((key: string, options?: { ns?: string }) => + options?.ns ? `${options.ns}.${key}` : key, + ) }) it('renders marketplace trigger when used in marketplace', () => { diff --git a/web/app/components/plugins/marketplace/search-box/index.tsx b/web/app/components/plugins/marketplace/search-box/index.tsx index 751a70fb4cfac6..5e9e271c1daacc 100644 --- a/web/app/components/plugins/marketplace/search-box/index.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.tsx @@ -39,98 +39,78 @@ const SearchBox = ({ showTags = true, }: SearchBoxProps) => { return ( - <div - className={cn('z-11 flex items-center', wrapperClassName)} - > - <div className={ - cn('flex items-center', usedInMarketplace && 'rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-1.5 shadow-md', !usedInMarketplace && 'rounded-lg border border-transparent bg-components-input-bg-normal focus-within:border-components-input-border-active hover:border-components-input-border-hover', inputClassName) - } + <div className={cn('z-11 flex items-center', wrapperClassName)}> + <div + className={cn( + 'flex items-center', + usedInMarketplace && + 'rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-1.5 shadow-md', + !usedInMarketplace && + 'rounded-lg border border-transparent bg-components-input-bg-normal focus-within:border-components-input-border-active hover:border-components-input-border-hover', + inputClassName, + )} > - { - usedInMarketplace && ( - <> - { - showTags && ( - <> - <TagsFilter - tags={tags} - onTagsChange={onTagsChange} - usedInMarketplace - /> - <Divider type="vertical" className="mx-1 h-3.5" /> - </> - ) - } - <div className="flex grow items-center gap-x-2 p-1"> - <input - className={cn( - 'inline-block grow appearance-none bg-transparent body-md-medium text-text-secondary outline-hidden', - inputElementClassName, - )} - value={search} - onChange={(e) => { - onSearchChange(e.target.value) - }} - placeholder={placeholder} - /> - { - search && ( - <ActionButton - onClick={() => onSearchChange('')} - className="shrink-0" - > - <RiCloseLine className="size-4" /> - </ActionButton> - ) - } - </div> - </> - ) - } - { - !usedInMarketplace && ( - <> - <div className="flex h-8 min-w-0 grow items-center pr-2 pl-2"> - <RiSearchLine className={cn('size-4 text-components-input-text-placeholder', searchIconClassName)} /> - <input - autoFocus={autoFocus} - className={cn( - 'mr-1 ml-1.5 inline-block min-w-0 grow appearance-none truncate bg-transparent system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder', - search && 'mr-2', - inputElementClassName, - )} - value={search} - onChange={(e) => { - onSearchChange(e.target.value) - }} - placeholder={placeholder} - /> - { - search && ( - <ActionButton - size="xs" - onClick={() => onSearchChange('')} - className="shrink-0" - > - <RiCloseLine className="size-4" /> - </ActionButton> - ) - } - </div> - { - showTags && ( - <> - <Divider type="vertical" className="mx-0 mr-0.5 h-3.5" /> - <TagsFilter - tags={tags} - onTagsChange={onTagsChange} - /> - </> - ) - } - </> - ) - } + {usedInMarketplace && ( + <> + {showTags && ( + <> + <TagsFilter tags={tags} onTagsChange={onTagsChange} usedInMarketplace /> + <Divider type="vertical" className="mx-1 h-3.5" /> + </> + )} + <div className="flex grow items-center gap-x-2 p-1"> + <input + className={cn( + 'inline-block grow appearance-none bg-transparent body-md-medium text-text-secondary outline-hidden', + inputElementClassName, + )} + value={search} + onChange={(e) => { + onSearchChange(e.target.value) + }} + placeholder={placeholder} + /> + {search && ( + <ActionButton onClick={() => onSearchChange('')} className="shrink-0"> + <RiCloseLine className="size-4" /> + </ActionButton> + )} + </div> + </> + )} + {!usedInMarketplace && ( + <> + <div className="flex h-8 min-w-0 grow items-center pr-2 pl-2"> + <RiSearchLine + className={cn('size-4 text-components-input-text-placeholder', searchIconClassName)} + /> + <input + autoFocus={autoFocus} + className={cn( + 'mr-1 ml-1.5 inline-block min-w-0 grow appearance-none truncate bg-transparent system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder', + search && 'mr-2', + inputElementClassName, + )} + value={search} + onChange={(e) => { + onSearchChange(e.target.value) + }} + placeholder={placeholder} + /> + {search && ( + <ActionButton size="xs" onClick={() => onSearchChange('')} className="shrink-0"> + <RiCloseLine className="size-4" /> + </ActionButton> + )} + </div> + {showTags && ( + <> + <Divider type="vertical" className="mx-0 mr-0.5 h-3.5" /> + <TagsFilter tags={tags} onTagsChange={onTagsChange} /> + </> + )} + </> + )} </div> {supportAddCustomTool && ( <div className="flex shrink-0 items-center"> diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index d1f75c9f34bae4..a1d3c76dfbcdf6 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -37,7 +37,7 @@ const SearchBoxWrapper = ({ onSearchChange={handleSearchPluginTextChange} tags={filterPluginTags} onTagsChange={handleFilterPluginTagsChange} - placeholder={placeholder ?? t($ => $.searchPlugins, { ns: 'plugin' })} + placeholder={placeholder ?? t(($) => $.searchPlugins, { ns: 'plugin' })} showTags={showTags} usedInMarketplace={usedInMarketplace} /> diff --git a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx index 45f2fe3277a339..699c73fca0dec3 100644 --- a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx +++ b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx @@ -2,11 +2,7 @@ import { Checkbox } from '@langgenius/dify-ui/checkbox' import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useState } from 'react' import { useTranslation } from '#i18n' import Input from '@/app/components/base/input' @@ -19,51 +15,42 @@ type TagsFilterProps = { onTagsChange: (tags: string[]) => void usedInMarketplace?: boolean } -const TagsFilter = ({ - tags, - onTagsChange, - usedInMarketplace = false, -}: TagsFilterProps) => { +const TagsFilter = ({ tags, onTagsChange, usedInMarketplace = false }: TagsFilterProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const [searchText, setSearchText] = useState('') const { tags: options, tagsMap } = useTags() - const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase())) + const filteredOptions = options.filter((option) => + option.label.toLowerCase().includes(searchText.toLowerCase()), + ) const selectedTagsLength = tags.length return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton={false} - render={( + render={ <div className="shrink-0"> - { - usedInMarketplace && ( - <MarketplaceTrigger - selectedTagsLength={selectedTagsLength} - open={open} - tags={tags} - tagsMap={tagsMap} - onTagsChange={onTagsChange} - /> - ) - } - { - !usedInMarketplace && ( - <ToolSelectorTrigger - selectedTagsLength={selectedTagsLength} - open={open} - tags={tags} - tagsMap={tagsMap} - onTagsChange={onTagsChange} - /> - ) - } + {usedInMarketplace && ( + <MarketplaceTrigger + selectedTagsLength={selectedTagsLength} + open={open} + tags={tags} + tagsMap={tagsMap} + onTagsChange={onTagsChange} + /> + )} + {!usedInMarketplace && ( + <ToolSelectorTrigger + selectedTagsLength={selectedTagsLength} + open={open} + tags={tags} + tagsMap={tagsMap} + onTagsChange={onTagsChange} + /> + )} </div> - )} + } /> <PopoverContent placement="bottom-start" @@ -76,32 +63,25 @@ const TagsFilter = ({ <Input showLeftIcon value={searchText} - onChange={e => setSearchText(e.target.value)} - placeholder={t($ => $.searchTags, { ns: 'pluginTags' }) || ''} + onChange={(e) => setSearchText(e.target.value)} + placeholder={t(($) => $.searchTags, { ns: 'pluginTags' }) || ''} /> </div> <CheckboxGroup - aria-label={t($ => $.allTags, { ns: 'pluginTags' })} + aria-label={t(($) => $.allTags, { ns: 'pluginTags' })} value={tags} - onValueChange={nextTags => onTagsChange(nextTags)} + onValueChange={(nextTags) => onTagsChange(nextTags)} className="max-h-[448px] overflow-y-auto p-1" > - { - filteredOptions.map(option => ( - <label - key={option.name} - className="flex h-7 cursor-pointer items-center rounded-lg px-2 py-1.5 select-none hover:bg-state-base-hover" - > - <Checkbox - className="mr-1" - value={option.name} - /> - <div className="px-1 system-sm-medium text-text-secondary"> - {option.label} - </div> - </label> - )) - } + {filteredOptions.map((option) => ( + <label + key={option.name} + className="flex h-7 cursor-pointer items-center rounded-lg px-2 py-1.5 select-none hover:bg-state-base-hover" + > + <Checkbox className="mr-1" value={option.name} /> + <div className="px-1 system-sm-medium text-text-secondary">{option.label}</div> + </label> + ))} </CheckboxGroup> </div> </PopoverContent> diff --git a/web/app/components/plugins/marketplace/search-box/trigger/marketplace.tsx b/web/app/components/plugins/marketplace/search-box/trigger/marketplace.tsx index 4a25a0d68c3c56..6368bc84b9151a 100644 --- a/web/app/components/plugins/marketplace/search-box/trigger/marketplace.tsx +++ b/web/app/components/plugins/marketplace/search-box/trigger/marketplace.tsx @@ -25,7 +25,8 @@ const MarketplaceTrigger = ({ <div className={cn( 'flex h-8 cursor-pointer items-center rounded-lg px-2 py-1 text-text-tertiary select-none', - !!selectedTagsLength && 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3', + !!selectedTagsLength && + 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3', open && !selectedTagsLength && 'bg-state-base-hover', )} > @@ -33,40 +34,30 @@ const MarketplaceTrigger = ({ <RiFilter3Line className={cn('size-4', !!selectedTagsLength && 'text-text-secondary')} /> </div> <div className="flex items-center gap-x-1 p-1 system-sm-medium"> - { - !selectedTagsLength && <span>{t($ => $.allTags, { ns: 'pluginTags' })}</span> - } - { - !!selectedTagsLength && ( - <span className="text-text-secondary"> - {tags.map(tag => tagsMap[tag]!.label).slice(0, 2).join(',')} - </span> - ) - } - { - selectedTagsLength > 2 && ( - <div className="system-xs-medium text-text-tertiary"> - + - {selectedTagsLength - 2} - </div> - ) - } + {!selectedTagsLength && <span>{t(($) => $.allTags, { ns: 'pluginTags' })}</span>} + {!!selectedTagsLength && ( + <span className="text-text-secondary"> + {tags + .map((tag) => tagsMap[tag]!.label) + .slice(0, 2) + .join(',')} + </span> + )} + {selectedTagsLength > 2 && ( + <div className="system-xs-medium text-text-tertiary">+{selectedTagsLength - 2}</div> + )} </div> - { - !!selectedTagsLength && ( - <RiCloseCircleFill - className="size-4 text-text-quaternary" - onClick={() => onTagsChange([])} - /> - ) - } - { - !selectedTagsLength && ( - <div className="p-0.5"> - <RiArrowDownSLine className="size-4 text-text-tertiary" /> - </div> - ) - } + {!!selectedTagsLength && ( + <RiCloseCircleFill + className="size-4 text-text-quaternary" + onClick={() => onTagsChange([])} + /> + )} + {!selectedTagsLength && ( + <div className="p-0.5"> + <RiArrowDownSLine className="size-4 text-text-tertiary" /> + </div> + )} </div> ) } diff --git a/web/app/components/plugins/marketplace/search-box/trigger/tool-selector.tsx b/web/app/components/plugins/marketplace/search-box/trigger/tool-selector.tsx index d351414ee80315..2add17987f47f0 100644 --- a/web/app/components/plugins/marketplace/search-box/trigger/tool-selector.tsx +++ b/web/app/components/plugins/marketplace/search-box/trigger/tool-selector.tsx @@ -19,44 +19,40 @@ const ToolSelectorTrigger = ({ onTagsChange, }: ToolSelectorTriggerProps) => { return ( - <div className={cn( - 'flex h-7 cursor-pointer items-center rounded-md p-0.5 text-text-tertiary select-none', - !selectedTagsLength && 'py-1 pr-2 pl-1.5', - !!selectedTagsLength && 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pr-1.5 pl-1 shadow-xs shadow-shadow-shadow-3', - open && !selectedTagsLength && 'bg-state-base-hover', - )} + <div + className={cn( + 'flex h-7 cursor-pointer items-center rounded-md p-0.5 text-text-tertiary select-none', + !selectedTagsLength && 'py-1 pr-2 pl-1.5', + !!selectedTagsLength && + 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pr-1.5 pl-1 shadow-xs shadow-shadow-shadow-3', + open && !selectedTagsLength && 'bg-state-base-hover', + )} > <div className="p-0.5"> <RiPriceTag3Line className={cn('size-4', !!selectedTagsLength && 'text-text-secondary')} /> </div> - { - !!selectedTagsLength && ( - <div className="flex items-center gap-x-0.5 px-0.5 py-1 system-sm-medium"> - <span className="text-text-secondary"> - {tags.map(tag => tagsMap[tag]!.label).slice(0, 2).join(',')} - </span> - { - selectedTagsLength > 2 && ( - <div className="system-xs-medium text-text-tertiary"> - + - {selectedTagsLength - 2} - </div> - ) - } - </div> - ) - } - { - !!selectedTagsLength && ( - <RiCloseCircleFill - className="size-4 text-text-quaternary" - onClick={(e) => { - e.stopPropagation() - onTagsChange([]) - }} - /> - ) - } + {!!selectedTagsLength && ( + <div className="flex items-center gap-x-0.5 px-0.5 py-1 system-sm-medium"> + <span className="text-text-secondary"> + {tags + .map((tag) => tagsMap[tag]!.label) + .slice(0, 2) + .join(',')} + </span> + {selectedTagsLength > 2 && ( + <div className="system-xs-medium text-text-tertiary">+{selectedTagsLength - 2}</div> + )} + </div> + )} + {!!selectedTagsLength && ( + <RiCloseCircleFill + className="size-4 text-text-quaternary" + onClick={(e) => { + e.stopPropagation() + onTagsChange([]) + }} + /> + )} </div> ) } diff --git a/web/app/components/plugins/marketplace/search-params.ts b/web/app/components/plugins/marketplace/search-params.ts index a7da306045584f..9538543ea4034a 100644 --- a/web/app/components/plugins/marketplace/search-params.ts +++ b/web/app/components/plugins/marketplace/search-params.ts @@ -4,7 +4,11 @@ import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server' import { PLUGIN_TYPE_SEARCH_MAP } from './constants' export const marketplaceSearchParamsParsers = { - category: parseAsStringEnum<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), + category: parseAsStringEnum<ActivePluginType>( + Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[], + ) + .withDefault('all') + .withOptions({ history: 'replace', clearOnDefault: false }), q: parseAsString.withDefault('').withOptions({ history: 'replace' }), tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), } diff --git a/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx index c94da44268ffba..bdd62b95299fb9 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx @@ -1,33 +1,32 @@ -import type { - MouseEventHandler, - ReactNode, -} from 'react' +import type { MouseEventHandler, ReactNode } from 'react' import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import SortDropdown from '../index' -const mockTranslation = vi.hoisted(() => vi.fn((key: string, options?: { ns?: string }) => { - const fullKey = options?.ns ? `${options.ns}.${key}` : key - const translations: Record<string, string> = { - 'plugin.marketplace.sortBy': 'Sort by', - 'plugin.marketplace.sortOption.mostPopular': 'Most Popular', - 'plugin.marketplace.sortOption.recentlyUpdated': 'Recently Updated', - 'plugin.marketplace.sortOption.newlyReleased': 'Newly Released', - 'plugin.marketplace.sortOption.firstReleased': 'First Released', - } - return translations[fullKey] || key -})) +const mockTranslation = vi.hoisted(() => + vi.fn((key: string, options?: { ns?: string }) => { + const fullKey = options?.ns ? `${options.ns}.${key}` : key + const translations: Record<string, string> = { + 'plugin.marketplace.sortBy': 'Sort by', + 'plugin.marketplace.sortOption.mostPopular': 'Most Popular', + 'plugin.marketplace.sortOption.recentlyUpdated': 'Recently Updated', + 'plugin.marketplace.sortOption.newlyReleased': 'Newly Released', + 'plugin.marketplace.sortOption.firstReleased': 'First Released', + } + return translations[fullKey] || key + }), +) vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey(mockTranslation), }), - }) + } }) -let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' } +let mockSort: { sortBy: string; sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' } const mockHandleSortChange = vi.fn() vi.mock('../../atoms', () => ({ @@ -36,24 +35,34 @@ vi.mock('../../atoms', () => ({ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') - const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + const DropdownMenuContext = React.createContext<{ + open: boolean + setOpen: (open: boolean) => void + } | null>(null) const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + DropdownMenu: ({ + children, + open, + onOpenChange, + }: { + children: ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( <DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}> <div data-testid="dropdown-wrapper" data-open={open}> {children} </div> </DropdownMenuContext> ), - DropdownMenuTrigger: ({ children, className }: { children: ReactNode, className?: string }) => { + DropdownMenuTrigger: ({ children, className }: { children: ReactNode; className?: string }) => { const { open, setOpen } = useDropdownMenuContext() return ( <button diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx index b1bfcbdb6c4a4c..32162f03f6d92b 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx @@ -15,48 +15,41 @@ const SortDropdown = () => { { value: 'install_count', order: 'DESC', - text: t($ => $['marketplace.sortOption.mostPopular'], { ns: 'plugin' }), + text: t(($) => $['marketplace.sortOption.mostPopular'], { ns: 'plugin' }), }, { value: 'version_updated_at', order: 'DESC', - text: t($ => $['marketplace.sortOption.recentlyUpdated'], { ns: 'plugin' }), + text: t(($) => $['marketplace.sortOption.recentlyUpdated'], { ns: 'plugin' }), }, { value: 'created_at', order: 'DESC', - text: t($ => $['marketplace.sortOption.newlyReleased'], { ns: 'plugin' }), + text: t(($) => $['marketplace.sortOption.newlyReleased'], { ns: 'plugin' }), }, { value: 'created_at', order: 'ASC', - text: t($ => $['marketplace.sortOption.firstReleased'], { ns: 'plugin' }), + text: t(($) => $['marketplace.sortOption.firstReleased'], { ns: 'plugin' }), }, ] const [sort, handleSortChange] = useMarketplaceSort() const [open, setOpen] = useState(false) - const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]! + const selectedOption = + options.find((option) => option.value === sort.sortBy && option.order === sort.sortOrder) ?? + options[0]! return ( - <DropdownMenu - open={open} - onOpenChange={setOpen} - > + <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger className="flex h-8 cursor-pointer items-center rounded-lg bg-state-base-hover-alt px-2 pr-3"> <span className="mr-1 system-sm-regular text-text-secondary"> - {t($ => $['marketplace.sortBy'], { ns: 'plugin' })} - </span> - <span className="mr-1 system-sm-medium text-text-primary"> - {selectedOption.text} + {t(($) => $['marketplace.sortBy'], { ns: 'plugin' })} </span> + <span className="mr-1 system-sm-medium text-text-primary">{selectedOption.text}</span> <span aria-hidden className="i-ri-arrow-down-s-line size-4 text-text-tertiary" /> </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-start" - sideOffset={4} - popupClassName="p-1" - > - {options.map(option => ( + <DropdownMenuContent placement="bottom-start" sideOffset={4} popupClassName="p-1"> + {options.map((option) => ( <DropdownMenuItem key={`${option.value}-${option.order}`} className="justify-between px-3 pr-2 system-md-regular text-text-primary" diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index 62f417c8e475bb..f9c723a648173a 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -1,7 +1,13 @@ import type { PluginsSearchParams } from '@dify/contracts/marketplace' import { useDebounce } from 'ahooks' import { useCallback, useMemo } from 'react' -import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms' +import { + useActivePluginType, + useFilterPluginTags, + useMarketplaceSearchMode, + useMarketplaceSortValue, + useSearchPluginText, +} from './atoms' import { PLUGIN_TYPE_SEARCH_MAP } from './constants' import { useMarketplaceContainerScroll } from './hooks' import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query' @@ -20,8 +26,7 @@ export function useMarketplaceData() { const sort = useMarketplaceSortValue() const isSearchMode = useMarketplaceSearchMode() const queryParams = useMemo((): PluginsSearchParams | undefined => { - if (!isSearchMode) - return undefined + if (!isSearchMode) return undefined return { query: searchPluginText, category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, @@ -36,8 +41,7 @@ export function useMarketplaceData() { const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = pluginsQuery const handlePageChange = useCallback(() => { - if (hasNextPage && !isFetching) - fetchNextPage() + if (hasNextPage && !isFetching) fetchNextPage() }, [fetchNextPage, hasNextPage, isFetching]) // Scroll pagination @@ -46,7 +50,7 @@ export function useMarketplaceData() { return { marketplaceCollections: collectionsQuery.data?.marketplaceCollections, marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, - plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins), + plugins: pluginsQuery.data?.pages.flatMap((page) => page.plugins), pluginsTotal: pluginsQuery.data?.pages[0]?.total, page: pluginsQuery.data?.pages.length || 1, isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading, diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 244e37b36763eb..affae9efa72683 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -7,9 +7,7 @@ import type { import type { ActivePluginType } from './constants' import type { Plugin } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types' -import { - MARKETPLACE_API_PREFIX, -} from '@/config' +import { MARKETPLACE_API_PREFIX } from '@/config' import { marketplaceClient } from '@/service/client' import { getMarketplaceUrl } from '@/utils/var' import { PLUGIN_TYPE_SEARCH_MAP } from './constants' @@ -21,15 +19,16 @@ type MarketplaceFetchOptions = { export function buildCarouselPages<T>(items: T[], itemsPerPage: number): T[][] { const pages: T[][] = [] - for (let i = 0; i < items.length; i += itemsPerPage) - pages.push(items.slice(i, i + itemsPerPage)) + for (let i = 0; i < items.length; i += itemsPerPage) pages.push(items.slice(i, i + itemsPerPage)) return pages } type MarketplacePluginPayload = MarketplacePlugin | (Plugin & { labels?: Plugin['label'] }) -export const getPluginIconInMarketplace = (plugin: Pick<MarketplacePluginPayload, 'name' | 'org' | 'type'>) => { +export const getPluginIconInMarketplace = ( + plugin: Pick<MarketplacePluginPayload, 'name' | 'org' | 'type'>, +) => { if (plugin.type === 'bundle') return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon` return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon` @@ -52,13 +51,19 @@ export const getFormattedPlugin = (payload: MarketplacePluginPayload): Plugin => } } -export const getPluginLinkInMarketplace = (plugin: Pick<MarketplacePluginPayload, 'name' | 'org' | 'type'>, params?: Record<string, string | undefined>) => { +export const getPluginLinkInMarketplace = ( + plugin: Pick<MarketplacePluginPayload, 'name' | 'org' | 'type'>, + params?: Record<string, string | undefined>, +) => { if (plugin.type === 'bundle') return getMarketplaceUrl(`/bundles/${plugin.org}/${plugin.name}`, params) return getMarketplaceUrl(`/plugins/${plugin.org}/${plugin.name}`, params) } -export const getMarketplaceCategoryUrl = (category?: string, params?: Record<string, string | undefined>) => { +export const getMarketplaceCategoryUrl = ( + category?: string, + params?: Record<string, string | undefined>, +) => { return getMarketplaceUrl(category ? `/plugins/${category}` : '/plugins', params) } export const getMarketplacePluginsByCollectionId = async ( @@ -69,18 +74,21 @@ export const getMarketplacePluginsByCollectionId = async ( let plugins: Plugin[] = [] try { - const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins({ - params: { - collectionId, + const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins( + { + params: { + collectionId, + }, + body: query ?? {}, }, - body: query ?? {}, - }, { - signal: options?.signal, - }) - plugins = (marketplaceCollectionPluginsDataJson.data?.plugins || []).map(plugin => getFormattedPlugin(plugin)) - } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { + { + signal: options?.signal, + }, + ) + plugins = (marketplaceCollectionPluginsDataJson.data?.plugins || []).map((plugin) => + getFormattedPlugin(plugin), + ) + } catch { plugins = [] } @@ -94,24 +102,27 @@ export const getMarketplaceCollectionsAndPlugins = async ( let marketplaceCollections: MarketplaceCollection[] = [] let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {} try { - const marketplaceCollectionsDataJson = await marketplaceClient.collections({ - query: { - ...query, - page: 1, - page_size: 100, + const marketplaceCollectionsDataJson = await marketplaceClient.collections( + { + query: { + ...query, + page: 1, + page_size: 100, + }, + }, + { + signal: options?.signal, }, - }, { - signal: options?.signal, - }) + ) marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || [] - await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => { - const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options) - - marketplaceCollectionPluginsMap[collection.name] = plugins - })) - } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { + await Promise.all( + marketplaceCollections.map(async (collection: MarketplaceCollection) => { + const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options) + + marketplaceCollectionPluginsMap[collection.name] = plugins + }), + ) + } catch { marketplaceCollections = [] marketplaceCollectionPluginsMap = {} } @@ -136,41 +147,35 @@ export const getMarketplacePlugins = async ( } } - const { - query, - sort_by, - sort_order, - category, - tags, - type, - page_size = 40, - } = queryParams + const { query, sort_by, sort_order, category, tags, type, page_size = 40 } = queryParams try { - const res = await marketplaceClient.searchAdvanced({ - params: { - kind: type === 'bundle' ? 'bundles' : 'plugins', + const res = await marketplaceClient.searchAdvanced( + { + params: { + kind: type === 'bundle' ? 'bundles' : 'plugins', + }, + body: { + page: pageParam, + page_size, + query, + sort_by, + sort_order, + category: category !== 'all' ? category : '', + tags, + }, }, - body: { - page: pageParam, - page_size, - query, - sort_by, - sort_order, - category: category !== 'all' ? category : '', - tags, - }, - }, { signal }) + { signal }, + ) const resPlugins = res.data.bundles || res.data.plugins || [] return { - plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)), + plugins: resPlugins.map((plugin) => getFormattedPlugin(plugin)), total: res.data.total, page: pageParam, page_size, } - } - catch { + } catch { return { plugins: [], total: 0, @@ -181,29 +186,35 @@ export const getMarketplacePlugins = async ( } export const getMarketplaceListCondition = (pluginType: string) => { - if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum)) + if ( + [ + PluginCategoryEnum.tool, + PluginCategoryEnum.agent, + PluginCategoryEnum.model, + PluginCategoryEnum.datasource, + PluginCategoryEnum.trigger, + ].includes(pluginType as PluginCategoryEnum) + ) return `category=${pluginType}` - if (pluginType === PluginCategoryEnum.extension) - return 'category=endpoint' + if (pluginType === PluginCategoryEnum.extension) return 'category=endpoint' - if (pluginType === 'bundle') - return 'type=bundle' + if (pluginType === 'bundle') return 'type=bundle' return '' } export const getMarketplaceListFilterType = (category: ActivePluginType) => { - if (category === PLUGIN_TYPE_SEARCH_MAP.all) - return undefined + if (category === PLUGIN_TYPE_SEARCH_MAP.all) return undefined - if (category === PLUGIN_TYPE_SEARCH_MAP.bundle) - return 'bundle' + if (category === PLUGIN_TYPE_SEARCH_MAP.bundle) return 'bundle' return 'plugin' } -export function getCollectionsParams(category: ActivePluginType): CollectionsAndPluginsSearchParams { +export function getCollectionsParams( + category: ActivePluginType, +): CollectionsAndPluginsSearchParams { if (category === PLUGIN_TYPE_SEARCH_MAP.all) { return {} } diff --git a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx index 76adaf5721a89f..bdd86305d438c5 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx @@ -3,7 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import AuthorizedInDataSourceNode from '../authorized-in-data-source-node' vi.mock('@langgenius/dify-ui/status-dot', () => ({ - StatusDot: ({ status }: { status: string }) => <span data-testid="indicator" data-status={status} />, + StatusDot: ({ status }: { status: string }) => ( + <span data-testid="indicator" data-status={status} /> + ), })) describe('AuthorizedInDataSourceNode', () => { diff --git a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx index 4d756f6f2bcf7c..08a9478412adf0 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx @@ -59,9 +59,7 @@ const createTestQueryClient = () => const createWrapper = () => { const testQueryClient = createTestQueryClient() return ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={testQueryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider> ) } @@ -102,10 +100,9 @@ describe('AuthorizedInNode Component', () => { it('should render with workspace default when no credentialId', async () => { const AuthorizedInNode = (await import('../authorized-in-node')).default const pluginPayload = createPluginPayload() - render( - <AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} />, - { wrapper: createWrapper() }, - ) + render(<AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('plugin.auth.workspaceDefault'))!.toBeInTheDocument() }) @@ -137,7 +134,11 @@ describe('AuthorizedInNode Component', () => { }) const pluginPayload = createPluginPayload() render( - <AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} credentialId="selected-id" />, + <AuthorizedInNode + pluginPayload={pluginPayload} + onAuthorizationItemClick={vi.fn()} + credentialId="selected-id" + />, { wrapper: createWrapper() }, ) expect(screen.getByText('My Credential'))!.toBeInTheDocument() @@ -152,7 +153,11 @@ describe('AuthorizedInNode Component', () => { }) const pluginPayload = createPluginPayload() render( - <AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} credentialId="non-existent" />, + <AuthorizedInNode + pluginPayload={pluginPayload} + onAuthorizationItemClick={vi.fn()} + credentialId="non-existent" + />, { wrapper: createWrapper() }, ) expect(screen.getByText('plugin.auth.authRemoved'))!.toBeInTheDocument() @@ -172,7 +177,11 @@ describe('AuthorizedInNode Component', () => { }) const pluginPayload = createPluginPayload() render( - <AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} credentialId="unavailable-id" />, + <AuthorizedInNode + pluginPayload={pluginPayload} + onAuthorizationItemClick={vi.fn()} + credentialId="unavailable-id" + />, { wrapper: createWrapper() }, ) const button = screen.getByRole('button') @@ -191,10 +200,9 @@ describe('AuthorizedInNode Component', () => { allow_custom_token: true, }) const pluginPayload = createPluginPayload() - render( - <AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} />, - { wrapper: createWrapper() }, - ) + render(<AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} />, { + wrapper: createWrapper(), + }) const button = screen.getByRole('button') expect(button.textContent).toContain('plugin.auth.unavailable') }) @@ -204,7 +212,10 @@ describe('AuthorizedInNode Component', () => { const onAuthorizationItemClick = vi.fn() const pluginPayload = createPluginPayload() render( - <AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={onAuthorizationItemClick} />, + <AuthorizedInNode + pluginPayload={pluginPayload} + onAuthorizationItemClick={onAuthorizationItemClick} + />, { wrapper: createWrapper() }, ) const buttons = screen.getAllByRole('button') diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx index 6b705550efec2e..c0d9a3c80e6716 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx @@ -36,7 +36,12 @@ vi.mock('@/service/use-tools', () => ({ })) const mockIsCurrentWorkspaceManager = vi.fn() -const mockUserProfile = { id: 'test-user', name: 'Test User', email: 'test@example.com', avatar_url: '' } +const mockUserProfile = { + id: 'test-user', + name: 'Test User', + email: 'test@example.com', + avatar_url: '', +} vi.mock('@/context/account-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') @@ -80,7 +85,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -106,9 +112,7 @@ const createTestQueryClient = () => const createWrapper = () => { const testQueryClient = createTestQueryClient() return ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={testQueryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider> ) } @@ -154,20 +158,14 @@ describe('PluginAuthInAgent Component', () => { allow_custom_token: true, }) const pluginPayload = createPluginPayload() - render( - <PluginAuthInAgent pluginPayload={pluginPayload} />, - { wrapper: createWrapper() }, - ) + render(<PluginAuthInAgent pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) expect(screen.getByRole('button'))!.toBeInTheDocument() }) it('should render Authorized with workspace default when authorized', async () => { const PluginAuthInAgent = (await import('../plugin-auth-in-agent')).default const pluginPayload = createPluginPayload() - render( - <PluginAuthInAgent pluginPayload={pluginPayload} />, - { wrapper: createWrapper() }, - ) + render(<PluginAuthInAgent pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) expect(screen.getByRole('button'))!.toBeInTheDocument() expect(screen.getByText('plugin.auth.workspaceDefault'))!.toBeInTheDocument() }) @@ -181,10 +179,9 @@ describe('PluginAuthInAgent Component', () => { allow_custom_token: true, }) const pluginPayload = createPluginPayload() - render( - <PluginAuthInAgent pluginPayload={pluginPayload} credentialId="selected-id" />, - { wrapper: createWrapper() }, - ) + render(<PluginAuthInAgent pluginPayload={pluginPayload} credentialId="selected-id" />, { + wrapper: createWrapper(), + }) expect(screen.getByText('Selected Credential'))!.toBeInTheDocument() }) @@ -196,10 +193,9 @@ describe('PluginAuthInAgent Component', () => { allow_custom_token: true, }) const pluginPayload = createPluginPayload() - render( - <PluginAuthInAgent pluginPayload={pluginPayload} credentialId="non-existent-id" />, - { wrapper: createWrapper() }, - ) + render(<PluginAuthInAgent pluginPayload={pluginPayload} credentialId="non-existent-id" />, { + wrapper: createWrapper(), + }) expect(screen.getByText('plugin.auth.authRemoved'))!.toBeInTheDocument() }) @@ -217,10 +213,9 @@ describe('PluginAuthInAgent Component', () => { allow_custom_token: true, }) const pluginPayload = createPluginPayload() - render( - <PluginAuthInAgent pluginPayload={pluginPayload} credentialId="unavailable-id" />, - { wrapper: createWrapper() }, - ) + render(<PluginAuthInAgent pluginPayload={pluginPayload} credentialId="unavailable-id" />, { + wrapper: createWrapper(), + }) const button = screen.getByRole('button') expect(button.textContent).toContain('plugin.auth.unavailable') }) @@ -230,7 +225,10 @@ describe('PluginAuthInAgent Component', () => { const onAuthorizationItemClick = vi.fn() const pluginPayload = createPluginPayload() render( - <PluginAuthInAgent pluginPayload={pluginPayload} onAuthorizationItemClick={onAuthorizationItemClick} />, + <PluginAuthInAgent + pluginPayload={pluginPayload} + onAuthorizationItemClick={onAuthorizationItemClick} + />, { wrapper: createWrapper() }, ) const buttons = screen.getAllByRole('button') @@ -249,13 +247,17 @@ describe('PluginAuthInAgent Component', () => { }) const pluginPayload = createPluginPayload() render( - <PluginAuthInAgent pluginPayload={pluginPayload} onAuthorizationItemClick={onAuthorizationItemClick} />, + <PluginAuthInAgent + pluginPayload={pluginPayload} + onAuthorizationItemClick={onAuthorizationItemClick} + />, { wrapper: createWrapper() }, ) const triggerButton = screen.getByRole('button') fireEvent.click(triggerButton) const workspaceDefaultItems = screen.getAllByText('plugin.auth.workspaceDefault') - const popupItem = workspaceDefaultItems.length > 1 ? workspaceDefaultItems[1] : workspaceDefaultItems[0] + const popupItem = + workspaceDefaultItems.length > 1 ? workspaceDefaultItems[1] : workspaceDefaultItems[0] fireEvent.click(popupItem!) expect(onAuthorizationItemClick).toHaveBeenCalledWith('') }) @@ -275,7 +277,10 @@ describe('PluginAuthInAgent Component', () => { }) const pluginPayload = createPluginPayload() render( - <PluginAuthInAgent pluginPayload={pluginPayload} onAuthorizationItemClick={onAuthorizationItemClick} />, + <PluginAuthInAgent + pluginPayload={pluginPayload} + onAuthorizationItemClick={onAuthorizationItemClick} + />, { wrapper: createWrapper() }, ) const triggerButton = screen.getByRole('button') diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-datasource-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-datasource-node.spec.tsx index 4fd899af4f11c3..827541153c5256 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-datasource-node.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-datasource-node.spec.tsx @@ -20,7 +20,9 @@ describe('PluginAuthInDataSourceNode', () => { it('renders connect button', () => { render(<PluginAuthInDataSourceNode onJumpToDataSourcePage={mockOnJump} />) - expect(screen.getByRole('button', { name: /common\.integrations\.connect/ })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /common\.integrations\.connect/ }), + ).toBeInTheDocument() }) it('calls onJumpToDataSourcePage when connect button is clicked', () => { diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx index 20c8b932e26bd0..b213035c864b49 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx @@ -55,7 +55,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -73,7 +74,11 @@ const defaultPayload = { describe('PluginAuth', () => { beforeEach(() => { vi.clearAllMocks() - mockAppContext.workspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage'] + mockAppContext.workspacePermissionKeys = [ + 'credential.use', + 'credential.create', + 'credential.manage', + ] }) afterEach(() => { @@ -139,7 +144,9 @@ describe('PluginAuth', () => { notAllowCustomCredential: false, }) - const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />) + const { container } = render( + <PluginAuth pluginPayload={defaultPayload} className="custom-class" />, + ) expect(container.innerHTML).toContain('custom-class') }) @@ -153,7 +160,9 @@ describe('PluginAuth', () => { notAllowCustomCredential: false, }) - const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />) + const { container } = render( + <PluginAuth pluginPayload={defaultPayload} className="custom-class" />, + ) expect(container.innerHTML).not.toContain('custom-class') }) @@ -187,7 +196,9 @@ describe('PluginAuth', () => { expect(screen.getByRole('button', { name: 'plugin.auth.useApiAuth' })).toBeDisabled() expect(screen.getByText('plugin.auth.permissionHint.title')).toBeInTheDocument() expect(screen.getByText('plugin.auth.permissionHint.description')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'plugin.auth.permissionHint.action' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'plugin.auth.permissionHint.action' }), + ).toBeInTheDocument() }) it('opens members settings when permission hint action is clicked', () => { diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx index 7caef505160224..ea51a320e5e287 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx @@ -17,8 +17,7 @@ vi.mock('../api-key-modal', () => ({ onUpdate?: () => void }) => { _mockModalOpen = open - if (!open) - return null + if (!open) return null const handleClose = () => { onOpenChange?.(false) @@ -27,8 +26,12 @@ vi.mock('../api-key-modal', () => ({ return ( <div data-testid="api-key-modal"> - <button data-testid="modal-close" onClick={handleClose}>Close</button> - <button data-testid="modal-update" onClick={onUpdate}>Update</button> + <button data-testid="modal-close" onClick={handleClose}> + Close + </button> + <button data-testid="modal-update" onClick={onUpdate}> + Update + </button> </div> ) }, diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx index dc05641a08800b..cadc7332e3d592 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx @@ -5,13 +5,16 @@ import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AuthCategory } from '../../types' -const mockGetPluginOAuthUrl = vi.fn().mockResolvedValue({ authorization_url: 'https://auth.example.com' }) +const mockGetPluginOAuthUrl = vi + .fn() + .mockResolvedValue({ authorization_url: 'https://auth.example.com' }) const mockOpenOAuthPopup = vi.fn() const mockWriteText = vi.fn() const mockOAuthClientSettingsProps: OAuthClientSettingsProps[] = [] vi.mock('@/hooks/use-i18n', () => ({ - useRenderI18nObject: () => (obj: Record<string, string> | string) => typeof obj === 'string' ? obj : obj.en_US || '', + useRenderI18nObject: () => (obj: Record<string, string> | string) => + typeof obj === 'string' ? obj : obj.en_US || '', })) vi.mock('@/hooks/use-oauth', () => ({ @@ -37,15 +40,9 @@ vi.mock('../../hooks/use-credential', () => ({ vi.mock('../oauth-client-settings', () => ({ default: (props: OAuthClientSettingsProps) => { mockOAuthClientSettingsProps.push(props) - const { - open = true, - onClose, - onOpenChange, - schemas, - } = props + const { open = true, onClose, onOpenChange, schemas } = props - if (!open) - return null + if (!open) return null const handleClose = () => { onOpenChange?.(false) @@ -54,8 +51,10 @@ vi.mock('../oauth-client-settings', () => ({ return ( <div data-testid="oauth-settings-modal"> - <button data-testid="oauth-settings-close" onClick={handleClose}>Close</button> - {schemas.map(schema => ( + <button data-testid="oauth-settings-close" onClick={handleClose}> + Close + </button> + {schemas.map((schema) => ( <div key={schema.name} data-testid={`oauth-schema-${schema.name}`}> <div data-testid={`oauth-schema-label-${schema.name}`}> {React.isValidElement(schema.label) ? schema.label : String(schema.label || '')} @@ -121,20 +120,23 @@ describe('AddOAuthButton', () => { it('should trigger OAuth flow on main button click', async () => { const mockOnUpdate = vi.fn() - render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" onUpdate={mockOnUpdate} />) + render( + <AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" onUpdate={mockOnUpdate} />, + ) const button = screen.getByText('Use OAuth').closest('button') - if (button) - fireEvent.click(button) + if (button) fireEvent.click(button) await waitFor(() => { - expect(mockOpenOAuthPopup).toHaveBeenCalledWith('https://auth.example.com', expect.any(Function)) + expect(mockOpenOAuthPopup).toHaveBeenCalledWith( + 'https://auth.example.com', + expect.any(Function), + ) }) const handleOAuthSuccess = mockOpenOAuthPopup.mock.calls[0]?.[1] expect(handleOAuthSuccess).toBeTypeOf('function') - if (typeof handleOAuthSuccess === 'function') - handleOAuthSuccess() + if (typeof handleOAuthSuccess === 'function') handleOAuthSuccess() expect(mockOnUpdate).toHaveBeenCalled() }) @@ -144,8 +146,7 @@ describe('AddOAuthButton', () => { render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />) const button = screen.getByText('Use OAuth').closest('button') - if (button) - fireEvent.click(button) + if (button) fireEvent.click(button) await waitFor(() => { expect(mockGetPluginOAuthUrl).toHaveBeenCalled() diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx index cb211adffff8ad..21f9d3b913de35 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx @@ -10,10 +10,14 @@ import { AuthCategory } from '../../types' const mockNotify = vi.fn() const mockToast = { - success: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'success', message, ...options }), - error: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'error', message, ...options }), - warning: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'warning', message, ...options }), - info: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'info', message, ...options }), + success: (message: string, options?: Record<string, unknown>) => + mockNotify({ type: 'success', message, ...options }), + error: (message: string, options?: Record<string, unknown>) => + mockNotify({ type: 'error', message, ...options }), + warning: (message: string, options?: Record<string, unknown>) => + mockNotify({ type: 'warning', message, ...options }), + info: (message: string, options?: Record<string, unknown>) => + mockNotify({ type: 'info', message, ...options }), dismiss: vi.fn(), update: vi.fn(), promise: vi.fn(), @@ -32,7 +36,10 @@ type MockFormValues = { values: Record<string, unknown> } -const defaultFormValues: MockFormValues = { isCheckValidated: true, values: { __name__: 'My Key', api_key: 'sk-123' } } +const defaultFormValues: MockFormValues = { + isCheckValidated: true, + values: { __name__: 'My Key', api_key: 'sk-123' }, +} let mockCredentialSchemas = defaultCredentialSchemas let mockIsSchemaLoading = false let mockFormValues = defaultFormValues @@ -60,7 +67,10 @@ vi.mock('@/app/components/base/encrypted-bottom', () => ({ })) vi.mock('@/app/components/base/form/form-scenarios/auth', () => { - const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => { + const MockAuthForm = ({ + ref, + ...props + }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => { mockAuthFormProps(props) React.useImperativeHandle(ref, () => ({ getFormValues: () => mockFormValues, @@ -106,8 +116,7 @@ const PopoverModalHarness = ({ open={open} onOpenChange={(nextOpen) => { setOpen(nextOpen) - if (!nextOpen) - onPopoverClose() + if (!nextOpen) onPopoverClose() }} > <PopoverTrigger render={<button type="button">Credentials</button>} /> @@ -186,14 +195,14 @@ describe('ApiKeyModal', () => { render(<ApiKeyModal pluginPayload={basePayload} formSchemas={customSchemas} />) - expect(mockAuthFormProps).toHaveBeenCalledWith(expect.objectContaining({ - formSchemas: expect.arrayContaining([ - expect.objectContaining({ name: 'custom_api_key' }), - ]), - defaultValues: expect.objectContaining({ - custom_api_key: 'default-key', + expect(mockAuthFormProps).toHaveBeenCalledWith( + expect.objectContaining({ + formSchemas: expect.arrayContaining([expect.objectContaining({ name: 'custom_api_key' })]), + defaultValues: expect.objectContaining({ + custom_api_key: 'default-key', + }), }), - })) + ) }) it('should not render auth form when credential schema is empty', () => { @@ -270,15 +279,19 @@ describe('ApiKeyModal', () => { it('should call addPluginCredential on confirm in add mode', async () => { const mockOnClose = vi.fn() const mockOnUpdate = vi.fn() - render(<ApiKeyModal pluginPayload={basePayload} onClose={mockOnClose} onUpdate={mockOnUpdate} />) + render( + <ApiKeyModal pluginPayload={basePayload} onClose={mockOnClose} onUpdate={mockOnUpdate} />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) await waitFor(() => { - expect(mockAddPluginCredential).toHaveBeenCalledWith(expect.objectContaining({ - type: 'api-key', - name: 'My Key', - })) + expect(mockAddPluginCredential).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'api-key', + name: 'My Key', + }), + ) }) }) @@ -290,9 +303,11 @@ describe('ApiKeyModal', () => { fireEvent.click(screen.getByTestId('modal-confirm')) await waitFor(() => { - expect(mockAddPluginCredential).toHaveBeenCalledWith(expect.objectContaining({ - name: '', - })) + expect(mockAddPluginCredential).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + }), + ) }) }) @@ -327,7 +342,12 @@ describe('ApiKeyModal', () => { }) it('should call updatePluginCredential on confirm in edit mode', async () => { - render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing', __credential_id__: 'cred-1' }} />) + render( + <ApiKeyModal + pluginPayload={basePayload} + editValues={{ api_key: 'existing', __credential_id__: 'cred-1' }} + />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) @@ -337,22 +357,38 @@ describe('ApiKeyModal', () => { }) it('should use empty credential name when authorization name is blank in edit mode', async () => { - mockFormValues = { isCheckValidated: true, values: { api_key: 'updated', __credential_id__: 'cred-1' } } + mockFormValues = { + isCheckValidated: true, + values: { api_key: 'updated', __credential_id__: 'cred-1' }, + } - render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing', __credential_id__: 'cred-1' }} />) + render( + <ApiKeyModal + pluginPayload={basePayload} + editValues={{ api_key: 'existing', __credential_id__: 'cred-1' }} + />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) await waitFor(() => { - expect(mockUpdatePluginCredential).toHaveBeenCalledWith(expect.objectContaining({ - name: '', - })) + expect(mockUpdatePluginCredential).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + }), + ) }) }) it('should call onRemove when remove button clicked', () => { const mockOnRemove = vi.fn() - render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing' }} onRemove={mockOnRemove} />) + render( + <ApiKeyModal + pluginPayload={basePayload} + editValues={{ api_key: 'existing' }} + onRemove={mockOnRemove} + />, + ) fireEvent.click(screen.getByTestId('modal-extra')) expect(mockOnRemove).toHaveBeenCalled() @@ -388,8 +424,7 @@ describe('ApiKeyModal', () => { render(<ControlledModalHarness ApiKeyModal={ApiKeyModal} onClose={mockOnClose} />) const backdrop = document.querySelector('.bg-background-overlay') - if (!backdrop) - throw new Error('Expected dialog backdrop to render') + if (!backdrop) throw new Error('Expected dialog backdrop to render') fireEvent.pointerDown(backdrop) fireEvent.mouseDown(backdrop) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx index 3601d95a6b7ce1..4853a07ceed004 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx @@ -20,9 +20,7 @@ const createTestQueryClient = () => const createWrapper = () => { const testQueryClient = createTestQueryClient() return ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={testQueryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider> ) } @@ -86,8 +84,7 @@ vi.mock('@/service/use-triggers', () => ({ const mockGetFormValues = vi.fn() vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({ default: vi.fn().mockImplementation(({ ref }: { ref: { current: unknown } }) => { - if (ref) - ref.current = { getFormValues: mockGetFormValues } + if (ref) ref.current = { getFormValues: mockGetFormValues } return <div data-testid="mock-auth-form">Auth Form</div> }), @@ -95,10 +92,14 @@ vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({ const mockNotify = vi.fn() const mockToast = { - success: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'success', message, ...options }), - error: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'error', message, ...options }), - warning: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'warning', message, ...options }), - info: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'info', message, ...options }), + success: (message: string, options?: Record<string, unknown>) => + mockNotify({ type: 'success', message, ...options }), + error: (message: string, options?: Record<string, unknown>) => + mockNotify({ type: 'error', message, ...options }), + warning: (message: string, options?: Record<string, unknown>) => + mockNotify({ type: 'warning', message, ...options }), + info: (message: string, options?: Record<string, unknown>) => + mockNotify({ type: 'info', message, ...options }), dismiss: vi.fn(), update: vi.fn(), promise: vi.fn(), @@ -146,13 +147,9 @@ describe('AddApiKeyButton', () => { it('should render button with custom text', () => { const pluginPayload = createPluginPayload() - render( - <AddApiKeyButton - pluginPayload={pluginPayload} - buttonText="Custom API Key" - />, - { wrapper: createWrapper() }, - ) + render(<AddApiKeyButton pluginPayload={pluginPayload} buttonText="Custom API Key" />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('button')).toHaveTextContent('Custom API Key') }) @@ -162,13 +159,9 @@ describe('AddApiKeyButton', () => { it('should disable button when disabled prop is true', () => { const pluginPayload = createPluginPayload() - render( - <AddApiKeyButton - pluginPayload={pluginPayload} - disabled={true} - />, - { wrapper: createWrapper() }, - ) + render(<AddApiKeyButton pluginPayload={pluginPayload} disabled={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('button')).toBeDisabled() }) @@ -176,13 +169,9 @@ describe('AddApiKeyButton', () => { it('should not disable button when disabled prop is false', () => { const pluginPayload = createPluginPayload() - render( - <AddApiKeyButton - pluginPayload={pluginPayload} - disabled={false} - />, - { wrapper: createWrapper() }, - ) + render(<AddApiKeyButton pluginPayload={pluginPayload} disabled={false} />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('button')).not.toBeDisabled() }) @@ -192,13 +181,9 @@ describe('AddApiKeyButton', () => { const formSchemas = [createFormSchema({ name: 'api_key', label: 'API Key' })] expect(() => { - render( - <AddApiKeyButton - pluginPayload={pluginPayload} - formSchemas={formSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<AddApiKeyButton pluginPayload={pluginPayload} formSchemas={formSchemas} />, { + wrapper: createWrapper(), + }) }).not.toThrow() }) }) @@ -222,13 +207,9 @@ describe('AddApiKeyButton', () => { it('should not open modal when button is disabled', () => { const pluginPayload = createPluginPayload() - render( - <AddApiKeyButton - pluginPayload={pluginPayload} - disabled={true} - />, - { wrapper: createWrapper() }, - ) + render(<AddApiKeyButton pluginPayload={pluginPayload} disabled={true} />, { + wrapper: createWrapper(), + }) const button = screen.getByRole('button') fireEvent.click(button) @@ -251,11 +232,18 @@ describe('AddApiKeyButton', () => { }) it('should handle all auth categories', () => { - const categories = [AuthCategory.tool, AuthCategory.datasource, AuthCategory.model, AuthCategory.trigger] + const categories = [ + AuthCategory.tool, + AuthCategory.datasource, + AuthCategory.model, + AuthCategory.trigger, + ] categories.forEach((category) => { const pluginPayload = createPluginPayload({ category }) - const { unmount } = render(<AddApiKeyButton pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) + const { unmount } = render(<AddApiKeyButton pluginPayload={pluginPayload} />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('button')).toBeInTheDocument() unmount() }) @@ -293,13 +281,9 @@ describe('AddApiKeyButton', () => { createFormSchema({ name: 'api_key', label: 'API Key' }), ]) - render( - <AddApiKeyButton - pluginPayload={pluginPayload} - onUpdate={onUpdate} - />, - { wrapper: createWrapper() }, - ) + render(<AddApiKeyButton pluginPayload={pluginPayload} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) // Open modal fireEvent.click(screen.getByRole('button')) @@ -360,13 +344,9 @@ describe('AddOAuthButton', () => { is_system_oauth_params_exists: true, }) - render( - <AddOAuthButton - pluginPayload={pluginPayload} - buttonText="Connect OAuth" - />, - { wrapper: createWrapper() }, - ) + render(<AddOAuthButton pluginPayload={pluginPayload} buttonText="Connect OAuth" />, { + wrapper: createWrapper(), + }) expect(screen.getByText('Connect OAuth')).toBeInTheDocument() }) @@ -379,13 +359,9 @@ describe('AddOAuthButton', () => { is_system_oauth_params_exists: false, }) - render( - <AddOAuthButton - pluginPayload={pluginPayload} - buttonText="OAuth" - />, - { wrapper: createWrapper() }, - ) + render(<AddOAuthButton pluginPayload={pluginPayload} buttonText="OAuth" />, { + wrapper: createWrapper(), + }) expect(screen.getByText('OAuth')).toBeInTheDocument() }) @@ -413,13 +389,9 @@ describe('AddOAuthButton', () => { is_system_oauth_params_exists: false, }) - render( - <AddOAuthButton - pluginPayload={pluginPayload} - disabled={true} - />, - { wrapper: createWrapper() }, - ) + render(<AddOAuthButton pluginPayload={pluginPayload} disabled={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('button')).toBeDisabled() }) @@ -432,13 +404,9 @@ describe('AddOAuthButton', () => { is_system_oauth_params_exists: false, }) - render( - <AddOAuthButton - pluginPayload={pluginPayload} - className="custom-class" - />, - { wrapper: createWrapper() }, - ) + render(<AddOAuthButton pluginPayload={pluginPayload} className="custom-class" />, { + wrapper: createWrapper(), + }) expect(screen.getByText('use oauth').closest('.custom-class')).toBeInTheDocument() }) @@ -453,13 +421,9 @@ describe('AddOAuthButton', () => { redirect_uri: 'https://custom.example.com/callback', } - render( - <AddOAuthButton - pluginPayload={pluginPayload} - oAuthData={oAuthData} - />, - { wrapper: createWrapper() }, - ) + render(<AddOAuthButton pluginPayload={pluginPayload} oAuthData={oAuthData} />, { + wrapper: createWrapper(), + }) // Should render configured button since oAuthData has is_system_oauth_params_exists=true expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument() @@ -475,15 +439,13 @@ describe('AddOAuthButton', () => { is_oauth_custom_client_enabled: true, is_system_oauth_params_exists: false, }) - mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' }) + mockGetPluginOAuthUrl.mockResolvedValue({ + authorization_url: 'https://oauth.example.com/auth', + }) - render( - <AddOAuthButton - pluginPayload={pluginPayload} - onUpdate={onUpdate} - />, - { wrapper: createWrapper() }, - ) + render(<AddOAuthButton pluginPayload={pluginPayload} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) // Click the main button area (left side) const buttonText = screen.getByText('use oauth') @@ -541,19 +503,17 @@ describe('AddOAuthButton', () => { is_oauth_custom_client_enabled: true, is_system_oauth_params_exists: false, }) - mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' }) + mockGetPluginOAuthUrl.mockResolvedValue({ + authorization_url: 'https://oauth.example.com/auth', + }) // Simulate openOAuthPopup calling the success callback mockOpenOAuthPopup.mockImplementation((url, callback) => { callback?.() }) - render( - <AddOAuthButton - pluginPayload={pluginPayload} - onUpdate={onUpdate} - />, - { wrapper: createWrapper() }, - ) + render(<AddOAuthButton pluginPayload={pluginPayload} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) const buttonText = screen.getByText('use oauth') fireEvent.click(buttonText) @@ -580,7 +540,9 @@ describe('AddOAuthButton', () => { render(<AddOAuthButton pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) - const settingsButton = screen.getByRole('button', { name: /plugin\.auth\.oauthClientSettings/i }) + const settingsButton = screen.getByRole('button', { + name: /plugin\.auth\.oauthClientSettings/i, + }) fireEvent.click(settingsButton) await waitFor(() => { @@ -863,13 +825,9 @@ describe('ApiKeyModal', () => { const pluginPayload = createPluginPayload() const onClose = vi.fn() - render( - <ApiKeyModal - pluginPayload={pluginPayload} - onClose={onClose} - />, - { wrapper: createWrapper() }, - ) + render(<ApiKeyModal pluginPayload={pluginPayload} onClose={onClose} />, { + wrapper: createWrapper(), + }) // Find and click cancel button const cancelButton = screen.getByText('common.operation.cancel') @@ -881,13 +839,9 @@ describe('ApiKeyModal', () => { it('should disable confirm button when disabled prop is true', () => { const pluginPayload = createPluginPayload() - render( - <ApiKeyModal - pluginPayload={pluginPayload} - disabled={true} - />, - { wrapper: createWrapper() }, - ) + render(<ApiKeyModal pluginPayload={pluginPayload} disabled={true} />, { + wrapper: createWrapper(), + }) const confirmButton = screen.getByText('common.operation.save') expect(confirmButton.closest('button')).toBeDisabled() @@ -901,30 +855,20 @@ describe('ApiKeyModal', () => { api_key: 'test-key', } - render( - <ApiKeyModal - pluginPayload={pluginPayload} - editValues={editValues} - />, - { wrapper: createWrapper() }, - ) + render(<ApiKeyModal pluginPayload={pluginPayload} editValues={editValues} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() }) it('should use formSchemas from props when provided', () => { const pluginPayload = createPluginPayload() - const customSchemas = [ - createFormSchema({ name: 'custom_field', label: 'Custom Field' }), - ] + const customSchemas = [createFormSchema({ name: 'custom_field', label: 'Custom Field' })] - render( - <ApiKeyModal - pluginPayload={pluginPayload} - formSchemas={customSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<ApiKeyModal pluginPayload={pluginPayload} formSchemas={customSchemas} />, { + wrapper: createWrapper(), + }) // AuthForm is mocked, verify modal renders expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() @@ -948,13 +892,9 @@ describe('ApiKeyModal', () => { api_key: 'existing-key', } - render( - <ApiKeyModal - pluginPayload={pluginPayload} - editValues={editValues} - />, - { wrapper: createWrapper() }, - ) + render(<ApiKeyModal pluginPayload={pluginPayload} editValues={editValues} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() }) @@ -981,14 +921,9 @@ describe('ApiKeyModal', () => { ]) mockAddPluginCredential.mockResolvedValue({}) - render( - <ApiKeyModal - pluginPayload={pluginPayload} - onClose={onClose} - onUpdate={onUpdate} - />, - { wrapper: createWrapper() }, - ) + render(<ApiKeyModal pluginPayload={pluginPayload} onClose={onClose} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) // Click confirm button const confirmButton = screen.getByText('common.operation.save') @@ -1049,14 +984,9 @@ describe('ApiKeyModal', () => { ]) mockAddPluginCredential.mockResolvedValue({}) - render( - <ApiKeyModal - pluginPayload={pluginPayload} - onClose={onClose} - onUpdate={onUpdate} - />, - { wrapper: createWrapper() }, - ) + render(<ApiKeyModal pluginPayload={pluginPayload} onClose={onClose} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) // Click confirm button const confirmButton = screen.getByText('common.operation.save') @@ -1078,10 +1008,7 @@ describe('ApiKeyModal', () => { values: {}, }) - render( - <ApiKeyModal pluginPayload={pluginPayload} />, - { wrapper: createWrapper() }, - ) + render(<ApiKeyModal pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) // Click confirm button const confirmButton = screen.getByText('common.operation.save') @@ -1097,13 +1024,12 @@ describe('ApiKeyModal', () => { createFormSchema({ name: 'api_key', label: 'API Key' }), ]) // Make the API call slow - mockAddPluginCredential.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) - - render( - <ApiKeyModal pluginPayload={pluginPayload} />, - { wrapper: createWrapper() }, + mockAddPluginCredential.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), ) + render(<ApiKeyModal pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) + // Click confirm button twice quickly const confirmButton = screen.getByText('common.operation.save') fireEvent.click(confirmButton) @@ -1137,10 +1063,7 @@ describe('ApiKeyModal', () => { return Promise.resolve({}) }) - render( - <ApiKeyModal pluginPayload={pluginPayload} />, - { wrapper: createWrapper() }, - ) + render(<ApiKeyModal pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) const confirmButton = screen.getByText('common.operation.save') @@ -1174,11 +1097,7 @@ describe('ApiKeyModal', () => { ]) render( - <ApiKeyModal - pluginPayload={pluginPayload} - editValues={editValues} - onRemove={onRemove} - />, + <ApiKeyModal pluginPayload={pluginPayload} editValues={editValues} onRemove={onRemove} />, { wrapper: createWrapper() }, ) @@ -1216,10 +1135,7 @@ describe('ApiKeyModal', () => { ]) expect(() => { - render( - <ApiKeyModal pluginPayload={pluginPayload} />, - { wrapper: createWrapper() }, - ) + render(<ApiKeyModal pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) }).not.toThrow() expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() @@ -1248,13 +1164,9 @@ describe('OAuthClientSettings', () => { it('should render modal with correct title', () => { const pluginPayload = createPluginPayload() - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={defaultSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={defaultSchemas} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() }) @@ -1262,13 +1174,9 @@ describe('OAuthClientSettings', () => { it('should render Save and Auth button', () => { const pluginPayload = createPluginPayload() - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={defaultSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={defaultSchemas} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('plugin.auth.saveAndAuth')).toBeInTheDocument() }) @@ -1276,13 +1184,9 @@ describe('OAuthClientSettings', () => { it('should render Save Only button', () => { const pluginPayload = createPluginPayload() - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={defaultSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={defaultSchemas} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('plugin.auth.saveOnly')).toBeInTheDocument() }) @@ -1290,13 +1194,9 @@ describe('OAuthClientSettings', () => { it('should render Cancel button', () => { const pluginPayload = createPluginPayload() - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={defaultSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={defaultSchemas} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() }) @@ -1304,13 +1204,9 @@ describe('OAuthClientSettings', () => { it('should render form from schemas', () => { const pluginPayload = createPluginPayload() - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={defaultSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={defaultSchemas} />, { + wrapper: createWrapper(), + }) // AuthForm is mocked expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() @@ -1451,14 +1347,9 @@ describe('OAuthClientSettings', () => { const pluginPayload = createPluginPayload() const onAuth = vi.fn().mockResolvedValue(undefined) - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={[]} - onAuth={onAuth} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={[]} onAuth={onAuth} />, { + wrapper: createWrapper(), + }) const saveAndAuthButton = screen.getByText('plugin.auth.saveAndAuth') expect(saveAndAuthButton).toBeInTheDocument() @@ -1541,13 +1432,9 @@ describe('OAuthClientSettings', () => { it('should handle form with empty values', () => { const pluginPayload = createPluginPayload() - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={defaultSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={defaultSchemas} />, { + wrapper: createWrapper(), + }) // Modal should render with save buttons expect(screen.getByText('plugin.auth.saveOnly')).toBeInTheDocument() @@ -1639,16 +1526,14 @@ describe('OAuthClientSettings', () => { it('should prevent double submission when doingAction is true', async () => { const pluginPayload = createPluginPayload() // Make the API call slow - mockSetPluginOAuthCustomClient.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) - - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={defaultSchemas} - />, - { wrapper: createWrapper() }, + mockSetPluginOAuthCustomClient.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={defaultSchemas} />, { + wrapper: createWrapper(), + }) + // Click Save Only button twice quickly const saveButton = screen.getByText('plugin.auth.saveOnly') fireEvent.click(saveButton) @@ -1674,13 +1559,9 @@ describe('OAuthClientSettings', () => { return Promise.resolve({}) }) - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={defaultSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={defaultSchemas} />, { + wrapper: createWrapper(), + }) const saveButton = screen.getByText('plugin.auth.saveOnly') @@ -1768,13 +1649,9 @@ describe('OAuthClientSettings', () => { const pluginPayload = createPluginPayload() expect(() => { - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={[]} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={[]} />, { + wrapper: createWrapper(), + }) }).not.toThrow() }) @@ -1786,10 +1663,7 @@ describe('OAuthClientSettings', () => { expect(() => { render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={schemasWithoutDefaults} - />, + <OAuthClientSettings pluginPayload={pluginPayload} schemas={schemasWithoutDefaults} />, { wrapper: createWrapper() }, ) }).not.toThrow() @@ -1816,16 +1690,16 @@ describe('OAuthClientSettings', () => { const pluginPayload = createPluginPayload() const schemasWithDefaults: FormSchema[] = [ createFormSchema({ name: 'client_id', label: 'Client ID', default: 'default-id' }), - createFormSchema({ name: 'client_secret', label: 'Client Secret', default: 'default-secret' }), + createFormSchema({ + name: 'client_secret', + label: 'Client Secret', + default: 'default-secret', + }), ] - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={schemasWithDefaults} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={schemasWithDefaults} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() }) @@ -1834,17 +1708,17 @@ describe('OAuthClientSettings', () => { const pluginPayload = createPluginPayload() const mixedSchemas: FormSchema[] = [ createFormSchema({ name: 'field_with_default', label: 'With Default', default: 'value' }), - createFormSchema({ name: 'field_without_default', label: 'Without Default', default: undefined }), + createFormSchema({ + name: 'field_without_default', + label: 'Without Default', + default: undefined, + }), createFormSchema({ name: 'field_with_empty', label: 'Empty Default', default: '' }), ] - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={mixedSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={mixedSchemas} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() }) @@ -1865,13 +1739,9 @@ describe('OAuthClientSettings', () => { const pluginPayload = createPluginPayload() mockSetPluginOAuthCustomClient.mockResolvedValue({}) - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={defaultSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={defaultSchemas} />, { + wrapper: createWrapper(), + }) fireEvent.click(screen.getByText('plugin.auth.saveOnly')) @@ -1895,13 +1765,9 @@ describe('OAuthClientSettings', () => { }, }) - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={defaultSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={defaultSchemas} />, { + wrapper: createWrapper(), + }) fireEvent.click(screen.getByText('plugin.auth.saveOnly')) @@ -2059,13 +1925,9 @@ describe('OAuthClientSettings', () => { } as unknown as PluginPayload['detail'], }) - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={defaultSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={defaultSchemas} />, { + wrapper: createWrapper(), + }) // ReadmeEntrance should be rendered (it's mocked in vitest.setup) expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() @@ -2074,13 +1936,9 @@ describe('OAuthClientSettings', () => { it('should not render ReadmeEntrance when pluginPayload has no detail', () => { const pluginPayload = createPluginPayload({ detail: undefined }) - render( - <OAuthClientSettings - pluginPayload={pluginPayload} - schemas={defaultSchemas} - />, - { wrapper: createWrapper() }, - ) + render(<OAuthClientSettings pluginPayload={pluginPayload} schemas={defaultSchemas} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() }) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx index e0ee8bb4585cff..460d26479355be 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx @@ -20,9 +20,7 @@ const createTestQueryClient = () => const createWrapper = () => { const testQueryClient = createTestQueryClient() return ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={testQueryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider> ) } @@ -96,7 +94,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -123,7 +122,11 @@ const createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayl describe('Authorize', () => { beforeEach(() => { vi.clearAllMocks() - mockAppContext.workspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage'] + mockAppContext.workspacePermissionKeys = [ + 'credential.use', + 'credential.create', + 'credential.manage', + ] mockGetPluginOAuthClientSchema.mockReturnValue({ schema: [], is_oauth_custom_client_enabled: false, @@ -136,14 +139,9 @@ describe('Authorize', () => { it('should render nothing when canOAuth and canApiKey are both false/undefined', () => { const pluginPayload = createPluginPayload() - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={false} - canApiKey={false} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={false} canApiKey={false} />, { + wrapper: createWrapper(), + }) expect(screen.queryByRole('button')).not.toBeInTheDocument() }) @@ -151,14 +149,9 @@ describe('Authorize', () => { it('should render only OAuth button when canOAuth is true and canApiKey is false', () => { const pluginPayload = createPluginPayload() - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={false} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={false} />, { + wrapper: createWrapper(), + }) // OAuth button should exist (either configured or setup button) expect(screen.getByRole('button')).toBeInTheDocument() @@ -167,14 +160,9 @@ describe('Authorize', () => { it('should render only API Key button when canApiKey is true and canOAuth is false', () => { const pluginPayload = createPluginPayload() - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={false} - canApiKey={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={false} canApiKey={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('button')).toBeInTheDocument() }) @@ -182,14 +170,9 @@ describe('Authorize', () => { it('should render both OAuth and API Key buttons when both are true', () => { const pluginPayload = createPluginPayload() - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />, { + wrapper: createWrapper(), + }) const buttons = screen.getAllByRole('button') expect(buttons.length).toBe(2) @@ -246,14 +229,9 @@ describe('Authorize', () => { it('should render divider by default (showDivider defaults to true)', () => { const pluginPayload = createPluginPayload() - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('or')).toBeInTheDocument() }) @@ -285,13 +263,9 @@ describe('Authorize', () => { const pluginPayload = createPluginPayload() mockAppContext.workspacePermissionKeys = ['credential.use'] - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('button')).toBeDisabled() }) @@ -300,13 +274,9 @@ describe('Authorize', () => { const pluginPayload = createPluginPayload() mockAppContext.workspacePermissionKeys = ['credential.use'] - render( - <Authorize - pluginPayload={pluginPayload} - canApiKey={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canApiKey={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('button')).toBeDisabled() }) @@ -314,14 +284,9 @@ describe('Authorize', () => { it('should not disable buttons when credential.create is present', () => { const pluginPayload = createPluginPayload() - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />, { + wrapper: createWrapper(), + }) const buttons = screen.getAllByRole('button') buttons.forEach((button) => { @@ -375,7 +340,7 @@ describe('Authorize', () => { ) const buttons = screen.getAllByRole('button') - buttons.forEach(button => expect(button).toBeDisabled()) + buttons.forEach((button) => expect(button).toBeDisabled()) }) }) }) @@ -387,24 +352,14 @@ describe('Authorize', () => { // When canApiKey is false, should show "useOAuthAuth" const { rerender } = render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={false} - />, + <Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={false} />, { wrapper: createWrapper() }, ) expect(screen.getByRole('button')).toHaveTextContent('plugin.auth') // When canApiKey is true, button text changes - rerender( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={true} - />, - ) + rerender(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />) const buttons = screen.getAllByRole('button') expect(buttons.length).toBe(2) @@ -447,23 +402,13 @@ describe('Authorize', () => { const pluginPayload = createPluginPayload() const { rerender } = render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={false} - />, + <Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={false} />, { wrapper: createWrapper() }, ) expect(screen.getAllByRole('button').length).toBe(1) - rerender( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={true} - />, - ) + rerender(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />) expect(screen.getAllByRole('button').length).toBe(2) }) @@ -472,23 +417,13 @@ describe('Authorize', () => { const pluginPayload = createPluginPayload() const { rerender } = render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={false} - canApiKey={true} - />, + <Authorize pluginPayload={pluginPayload} canOAuth={false} canApiKey={true} />, { wrapper: createWrapper() }, ) expect(screen.getAllByRole('button').length).toBe(1) - rerender( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={true} - />, - ) + rerender(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />) expect(screen.getAllByRole('button').length).toBe(2) }) @@ -497,23 +432,13 @@ describe('Authorize', () => { const pluginPayload = createPluginPayload() const { rerender } = render( - <Authorize - pluginPayload={pluginPayload} - canApiKey={true} - theme="primary" - />, + <Authorize pluginPayload={pluginPayload} canApiKey={true} theme="primary" />, { wrapper: createWrapper() }, ) const primaryClassName = screen.getByRole('button').className - rerender( - <Authorize - pluginPayload={pluginPayload} - canApiKey={true} - theme="secondary" - />, - ) + rerender(<Authorize pluginPayload={pluginPayload} canApiKey={true} theme="secondary" />) const secondaryClassName = screen.getByRole('button').className expect(primaryClassName).not.toBe(secondaryClassName) @@ -531,29 +456,25 @@ describe('Authorize', () => { } expect(() => { - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />, { + wrapper: createWrapper(), + }) }).not.toThrow() }) it('should handle all auth categories', () => { - const categories = [AuthCategory.tool, AuthCategory.datasource, AuthCategory.model, AuthCategory.trigger] + const categories = [ + AuthCategory.tool, + AuthCategory.datasource, + AuthCategory.model, + AuthCategory.trigger, + ] categories.forEach((category) => { const pluginPayload = createPluginPayload({ category }) const { unmount } = render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={true} - />, + <Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />, { wrapper: createWrapper() }, ) @@ -567,13 +488,9 @@ describe('Authorize', () => { const pluginPayload = createPluginPayload({ provider: '' }) expect(() => { - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={true} />, { + wrapper: createWrapper(), + }) }).not.toThrow() }) @@ -622,11 +539,7 @@ describe('Authorize', () => { expect(screen.getByRole('button')).not.toBeDisabled() rerender( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - notAllowCustomCredential={true} - />, + <Authorize pluginPayload={pluginPayload} canOAuth={true} notAllowCustomCredential={true} />, ) expect(screen.getByRole('button')).toBeDisabled() @@ -641,13 +554,9 @@ describe('Authorize', () => { category: AuthCategory.model, }) - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('button')).toBeInTheDocument() }) @@ -658,13 +567,9 @@ describe('Authorize', () => { category: AuthCategory.datasource, }) - render( - <Authorize - pluginPayload={pluginPayload} - canApiKey={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canApiKey={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('button')).toBeInTheDocument() }) @@ -678,14 +583,9 @@ describe('Authorize', () => { }) expect(() => { - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />, { + wrapper: createWrapper(), + }) }).not.toThrow() }) }) @@ -756,14 +656,9 @@ describe('Authorize', () => { it('should have accessible button elements', () => { const pluginPayload = createPluginPayload() - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />, { + wrapper: createWrapper(), + }) const buttons = screen.getAllByRole('button') expect(buttons.length).toBe(2) @@ -773,14 +668,9 @@ describe('Authorize', () => { const pluginPayload = createPluginPayload() mockAppContext.workspacePermissionKeys = ['credential.use'] - render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - canApiKey={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />, { + wrapper: createWrapper(), + }) const buttons = screen.getAllByRole('button') buttons.forEach((button) => { diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx index 1a0e63c45e8fda..ef4dc1639a5a38 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx @@ -9,10 +9,14 @@ import { AuthCategory } from '../../types' const mockNotify = vi.fn() const mockToast = { - success: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'success', message, ...options }), - error: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'error', message, ...options }), - warning: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'warning', message, ...options }), - info: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'info', message, ...options }), + success: (message: string, options?: Record<string, unknown>) => + mockNotify({ type: 'success', message, ...options }), + error: (message: string, options?: Record<string, unknown>) => + mockNotify({ type: 'error', message, ...options }), + warning: (message: string, options?: Record<string, unknown>) => + mockNotify({ type: 'warning', message, ...options }), + info: (message: string, options?: Record<string, unknown>) => + mockNotify({ type: 'info', message, ...options }), dismiss: vi.fn(), update: vi.fn(), promise: vi.fn(), @@ -24,7 +28,10 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ const mockSetPluginOAuthCustomClient = vi.fn().mockResolvedValue({}) const mockDeletePluginOAuthCustomClient = vi.fn().mockResolvedValue({}) const mockInvalidPluginOAuthClientSchema = vi.fn() -let mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } } +let mockFormValues = { + isCheckValidated: true, + values: { __oauth_client__: 'custom', client_id: 'test-id' }, +} let mockAuthFormProps: Record<string, unknown> | undefined vi.mock('../../hooks/use-credential', () => ({ @@ -42,7 +49,10 @@ vi.mock('../../../readme-panel/entrance', () => ({ })) vi.mock('@/app/components/base/form/form-scenarios/auth', () => { - const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => { + const MockAuthForm = ({ + ref, + ...props + }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => { mockAuthFormProps = props React.useImperativeHandle(ref, () => ({ getFormValues: () => mockFormValues, @@ -89,8 +99,7 @@ const PopoverSettingsHarness = ({ open={open} onOpenChange={(nextOpen) => { setOpen(nextOpen) - if (!nextOpen) - onPopoverClose() + if (!nextOpen) onPopoverClose() }} > <PopoverTrigger render={<button type="button">OAuth</button>} /> @@ -137,30 +146,23 @@ describe('OAuthClientSettings', () => { beforeEach(async () => { vi.clearAllMocks() - mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } } + mockFormValues = { + isCheckValidated: true, + values: { __oauth_client__: 'custom', client_id: 'test-id' }, + } mockAuthFormProps = undefined const mod = await import('../oauth-client-settings') OAuthClientSettings = mod.default }) it('should render modal with correct title', () => { - render( - <OAuthClientSettings - pluginPayload={basePayload} - schemas={defaultSchemas} - />, - ) + render(<OAuthClientSettings pluginPayload={basePayload} schemas={defaultSchemas} />) expect(screen.getByTestId('modal-title')).toHaveTextContent('plugin.auth.oauthClientSettings') }) it('should render auth form', () => { - render( - <OAuthClientSettings - pluginPayload={basePayload} - schemas={defaultSchemas} - />, - ) + render(<OAuthClientSettings pluginPayload={basePayload} schemas={defaultSchemas} />) expect(screen.getByTestId('auth-form')).toBeInTheDocument() }) @@ -169,10 +171,7 @@ describe('OAuthClientSettings', () => { render( <Dialog open> <DialogContent backdropClassName="bg-transparent"> - <OAuthClientSettings - pluginPayload={basePayload} - schemas={defaultSchemas} - /> + <OAuthClientSettings pluginPayload={basePayload} schemas={defaultSchemas} /> </DialogContent> </Dialog>, ) @@ -184,9 +183,17 @@ describe('OAuthClientSettings', () => { render( <OAuthClientSettings pluginPayload={basePayload} - schemas={[ - { name: 'client_id', label: 'Client ID', type: 'text-input', required: true, default: 'default-client-id' }, - ] as never} + schemas={ + [ + { + name: 'client_id', + label: 'Client ID', + type: 'text-input', + required: true, + default: 'default-client-id', + }, + ] as never + } />, ) @@ -211,7 +218,9 @@ describe('OAuthClientSettings', () => { it('should close through controlled open state when cancel clicked', async () => { const mockOnClose = vi.fn() - render(<ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />) + render( + <ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />, + ) fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/i })) @@ -223,7 +232,9 @@ describe('OAuthClientSettings', () => { it('should stay open when backdrop is clicked', () => { const mockOnClose = vi.fn() - render(<ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />) + render( + <ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />, + ) const backdrop = document.querySelector('.bg-background-overlay') expect(backdrop).toBeInTheDocument() @@ -249,18 +260,23 @@ describe('OAuthClientSettings', () => { fireEvent.click(screen.getByTestId('modal-cancel')) await waitFor(() => { - expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith(expect.objectContaining({ - enable_oauth_custom_client: true, - })) + expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith( + expect.objectContaining({ + enable_oauth_custom_client: true, + }), + ) }) }) it('should ignore duplicate save clicks while action is pending', async () => { const mockOnClose = vi.fn() let resolveSave: (value: object) => void = () => {} - mockSetPluginOAuthCustomClient.mockImplementationOnce(() => new Promise((resolve) => { - resolveSave = resolve - })) + mockSetPluginOAuthCustomClient.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSave = resolve + }), + ) render( <OAuthClientSettings @@ -326,20 +342,17 @@ describe('OAuthClientSettings', () => { expect(mockOnClose).toHaveBeenCalled() expect(mockOnUpdate).toHaveBeenCalled() expect(mockInvalidPluginOAuthClientSchema).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - message: 'common.api.actionSuccess', - type: 'success', - })) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'common.api.actionSuccess', + type: 'success', + }), + ) }) it('should render readme entrance when detail is provided', () => { const payload = { ...basePayload, detail: { name: 'Test' } as never } - render( - <OAuthClientSettings - pluginPayload={payload} - schemas={defaultSchemas} - />, - ) + render(<OAuthClientSettings pluginPayload={payload} schemas={defaultSchemas} />) expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() }) diff --git a/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx index 83d1d536964ac6..f826ebb1362b57 100644 --- a/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx @@ -2,10 +2,7 @@ import type { ButtonProps } from '@langgenius/dify-ui/button' import type { PluginPayload } from '../types' import type { FormSchema } from '@/app/components/base/form/types' import { Button } from '@langgenius/dify-ui/button' -import { - memo, - useState, -} from 'react' +import { memo, useState } from 'react' import ApiKeyModal from './api-key-modal' export type AddApiKeyButtonProps = { @@ -34,19 +31,16 @@ const AddApiKeyButton = ({ }: AddApiKeyButtonProps) => { const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false) const [isApiKeyModalMounted, setIsApiKeyModalMounted] = useState(false) - const handleClick = onClick ?? (() => { - setIsApiKeyModalMounted(true) - setIsApiKeyModalOpen(true) - }) + const handleClick = + onClick ?? + (() => { + setIsApiKeyModalMounted(true) + setIsApiKeyModalOpen(true) + }) return ( <> - <Button - className="w-full" - variant={buttonVariant} - onClick={handleClick} - disabled={disabled} - > + <Button className="w-full" variant={buttonVariant} onClick={handleClick} disabled={disabled}> {buttonText} </Button> { @@ -63,7 +57,6 @@ const AddApiKeyButton = ({ ) } </> - ) } diff --git a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx index 5e0fcb7879bb19..fe6db92b34bcbe 100644 --- a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx @@ -3,12 +3,7 @@ import type { PluginPayload } from '../types' import type { FormSchema } from '@/app/components/base/form/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useCallback, - useMemo, - useState, -} from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge' @@ -66,8 +61,7 @@ const AddOAuthButton = ({ const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload) const { data, isLoading } = useGetPluginOAuthClientSchemaHook(pluginPayload) const mergedOAuthData = useMemo<OAuthData>(() => { - if (oAuthData) - return oAuthData + if (oAuthData) return oAuthData return data || {} }, [oAuthData, data]) @@ -87,26 +81,23 @@ const AddOAuthButton = ({ const { authorization_url } = await getPluginOAuthUrl() if (authorization_url) { - openOAuthPopup( - authorization_url, - () => onUpdate?.(), - ) + openOAuthPopup(authorization_url, () => onUpdate?.()) } }, [getPluginOAuthUrl, onUpdate]) - const renderCustomLabel = useCallback((item: FormSchema) => { - return ( - <div className="w-full"> - <div className="mb-4 flex rounded-xl bg-background-section-burn p-4"> - <div className="mr-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg"> - <span className="i-ri-information-2-fill size-5 text-text-accent" /> - </div> - <div className="w-0 grow"> - <div className="mb-1.5 system-sm-regular"> - {t($ => $['auth.clientInfo'], { ns: 'plugin' })} + const renderCustomLabel = useCallback( + (item: FormSchema) => { + return ( + <div className="w-full"> + <div className="mb-4 flex rounded-xl bg-background-section-burn p-4"> + <div className="mr-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg"> + <span className="i-ri-information-2-fill size-5 text-text-accent" /> </div> - { - redirect_uri && ( + <div className="w-0 grow"> + <div className="mb-1.5 system-sm-regular"> + {t(($) => $['auth.clientInfo'], { ns: 'plugin' })} + </div> + {redirect_uri && ( <div className="flex w-full py-0.5 system-sm-medium"> <div className="w-0 grow wrap-break-word break-all">{redirect_uri}</div> <ActionButton @@ -118,21 +109,18 @@ const AddOAuthButton = ({ <span className="i-ri-clipboard-line size-4" /> </ActionButton> </div> - ) - } + )} + </div> + </div> + <div className="flex h-6 items-center system-sm-medium text-text-secondary"> + {renderI18nObject(item.label as Record<string, string>)} + {item.required && <span className="ml-1 text-text-destructive-secondary">*</span>} </div> </div> - <div className="flex h-6 items-center system-sm-medium text-text-secondary"> - {renderI18nObject(item.label as Record<string, string>)} - { - item.required && ( - <span className="ml-1 text-text-destructive-secondary">*</span> - ) - } - </div> - </div> - ) - }, [t, redirect_uri, renderI18nObject]) + ) + }, + [t, redirect_uri, renderI18nObject], + ) const memorizedSchemas = useMemo(() => { const result: FormSchema[] = (schema as FormSchema[]).map((item, index) => { return { @@ -144,15 +132,15 @@ const AddOAuthButton = ({ if (is_system_oauth_params_exists) { result.unshift({ name: '__oauth_client__', - label: t($ => $['auth.oauthClient'], { ns: 'plugin' }), + label: t(($) => $['auth.oauthClient'], { ns: 'plugin' }), type: FormTypeEnum.radio, options: [ { - label: t($ => $['auth.default'], { ns: 'plugin' }), + label: t(($) => $['auth.default'], { ns: 'plugin' }), value: 'default', }, { - label: t($ => $['auth.custom'], { ns: 'plugin' }), + label: t(($) => $['auth.custom'], { ns: 'plugin' }), value: 'custom', }, ], @@ -167,122 +155,112 @@ const AddOAuthButton = ({ value: 'custom', }, ] - if (client_params) - item.default = client_params[item.name] || item.default + if (client_params) item.default = client_params[item.name] || item.default } }) } return result - }, [schema, renderCustomLabel, t, is_system_oauth_params_exists, is_oauth_custom_client_enabled, client_params]) + }, [ + schema, + renderCustomLabel, + t, + is_system_oauth_params_exists, + is_oauth_custom_client_enabled, + client_params, + ]) const __auth_client__ = useMemo(() => { if (isConfigured) { - if (is_oauth_custom_client_enabled) - return 'custom' + if (is_oauth_custom_client_enabled) return 'custom' return 'default' - } - else { - if (is_system_oauth_params_exists) - return 'default' + } else { + if (is_system_oauth_params_exists) return 'default' return 'custom' } }, [isConfigured, is_oauth_custom_client_enabled, is_system_oauth_params_exists]) return ( <> - { - renderTrigger?.({ - disabled, - isConfigured, - onClick: isConfigured ? handleOAuth : openOAuthSettings, - }) - } - { - !renderTrigger && isConfigured && ( - <div className={cn('flex w-full', className)}> - <Button - variant={buttonVariant} - className={cn( - 'h-8 min-w-0 flex-1 rounded-r-none p-0 hover:bg-components-button-primary-bg-hover', - buttonLeftClassName, - )} - disabled={disabled} - onClick={handleOAuth} - > - <div - className="truncate" + {renderTrigger?.({ + disabled, + isConfigured, + onClick: isConfigured ? handleOAuth : openOAuthSettings, + })} + {!renderTrigger && isConfigured && ( + <div className={cn('flex w-full', className)}> + <Button + variant={buttonVariant} + className={cn( + 'h-8 min-w-0 flex-1 rounded-r-none p-0 hover:bg-components-button-primary-bg-hover', + buttonLeftClassName, + )} + disabled={disabled} + onClick={handleOAuth} + > + <div className="truncate">{buttonText}</div> + {is_oauth_custom_client_enabled && ( + <Badge + className={cn( + 'mr-0.5 ml-1', + buttonVariant === 'primary' && + 'border-text-primary-on-surface bg-components-badge-bg-dimm text-text-primary-on-surface', + )} > - {buttonText} - </div> - { - is_oauth_custom_client_enabled && ( - <Badge - className={cn( - 'mr-0.5 ml-1', - buttonVariant === 'primary' && 'border-text-primary-on-surface bg-components-badge-bg-dimm text-text-primary-on-surface', - )} - > - {t($ => $['auth.custom'], { ns: 'plugin' })} - </Badge> - ) - } - </Button> - <div className={cn( + {t(($) => $['auth.custom'], { ns: 'plugin' })} + </Badge> + )} + </Button> + <div + className={cn( 'h-4 w-px shrink-0 self-center bg-text-primary-on-surface opacity-[0.15]', dividerClassName, )} - > - </div> - <Button - variant={buttonVariant} - aria-label={t($ => $['auth.oauthClientSettings'], { ns: 'plugin' })} - className={cn( - 'size-8 shrink-0 rounded-l-none p-0 hover:bg-components-button-primary-bg-hover', - buttonRightClassName, - )} - disabled={disabled} - onClick={() => { - openOAuthSettings() - }} - > - <span className="i-ri-equalizer-2-line size-4" aria-hidden="true" /> - </Button> - </div> - ) - } - { - !renderTrigger && !isConfigured && ( + ></div> <Button variant={buttonVariant} - onClick={openOAuthSettings} + aria-label={t(($) => $['auth.oauthClientSettings'], { ns: 'plugin' })} + className={cn( + 'size-8 shrink-0 rounded-l-none p-0 hover:bg-components-button-primary-bg-hover', + buttonRightClassName, + )} disabled={disabled} - className="w-full" + onClick={() => { + openOAuthSettings() + }} > - <span className="mr-0.5 i-ri-equalizer-2-line size-4" /> - {t($ => $['auth.setupOAuth'], { ns: 'plugin' })} + <span className="i-ri-equalizer-2-line size-4" aria-hidden="true" /> </Button> - ) - } - { - isOAuthSettingsMounted && ( - <OAuthClientSettings - open={isOAuthSettingsOpen} - onOpenChange={setIsOAuthSettingsOpen} - pluginPayload={pluginPayload} - onClose={() => setIsOAuthSettingsOpen(false)} - disabled={disabled || isLoading} - schemas={memorizedSchemas} - onAuth={handleOAuth} - editValues={{ - ...client_params, - __oauth_client__: __auth_client__, - }} - hasOriginalClientParams={Object.keys(client_params || {}).length > 0} - onUpdate={onUpdate} - /> - ) - } + </div> + )} + {!renderTrigger && !isConfigured && ( + <Button + variant={buttonVariant} + onClick={openOAuthSettings} + disabled={disabled} + className="w-full" + > + <span className="mr-0.5 i-ri-equalizer-2-line size-4" /> + {t(($) => $['auth.setupOAuth'], { ns: 'plugin' })} + </Button> + )} + {isOAuthSettingsMounted && ( + <OAuthClientSettings + open={isOAuthSettingsOpen} + onOpenChange={setIsOAuthSettingsOpen} + pluginPayload={pluginPayload} + onClose={() => setIsOAuthSettingsOpen(false)} + disabled={disabled || isLoading} + schemas={memorizedSchemas} + onAuth={handleOAuth} + editValues={{ + ...client_params, + __oauth_client__: __auth_client__, + }} + hasOriginalClientParams={Object.keys(client_params || {}).length > 0} + onUpdate={onUpdate} + /> + )} </> ) } diff --git a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx index 1b068ea0564439..4d65777d2f6faa 100644 --- a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx @@ -1,18 +1,9 @@ import type { PluginPayload } from '../types' -import type { - FormRefObject, - FormSchema, -} from '@/app/components/base/form/types' +import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' -import { - memo, - useCallback, - useMemo, - useRef, - useState, -} from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { EncryptedBottom } from '@/app/components/base/encrypted-bottom' import AuthForm from '@/app/components/base/form/form-scenarios/auth' @@ -58,7 +49,10 @@ const ApiKeyModal = ({ doingActionRef.current = value setDoingAction(value) }, []) - const { data = [], isLoading } = useGetPluginCredentialSchemaHook(pluginPayload, CredentialTypeEnum.API_KEY) + const { data = [], isLoading } = useGetPluginCredentialSchemaHook( + pluginPayload, + CredentialTypeEnum.API_KEY, + ) const [permission, setPermission] = useState<PermissionLevel | undefined>( (editValues?.__visibility__ as PermissionLevel) ?? PermissionLevel.allTeamMembers, ) @@ -69,8 +63,7 @@ const ApiKeyModal = ({ const { data: membersData } = useMembers() const memberList = membersData?.accounts ?? [] const mergedData = useMemo(() => { - if (formSchemasFromProps?.length) - return formSchemasFromProps + if (formSchemasFromProps?.length) return formSchemasFromProps return data }, [formSchemasFromProps, data]) @@ -79,32 +72,29 @@ const ApiKeyModal = ({ { type: FormTypeEnum.textInput, name: '__name__', - label: t($ => $['auth.authorizationName'], { ns: 'plugin' }), + label: t(($) => $['auth.authorizationName'], { ns: 'plugin' }), required: false, }, ...mergedData, ] }, [mergedData, t]) - const defaultValues = formSchemas.reduce((acc, schema) => { - if (schema.default) - acc[schema.name] = schema.default - return acc - }, {} as Record<string, unknown>) + const defaultValues = formSchemas.reduce( + (acc, schema) => { + if (schema.default) acc[schema.name] = schema.default + return acc + }, + {} as Record<string, unknown>, + ) const { mutateAsync: addPluginCredential } = useAddPluginCredentialHook(pluginPayload) const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload) const formRef = useRef<FormRefObject>(null) const handleConfirm = useCallback(async () => { - if (doingActionRef.current) - return - const { - isCheckValidated, - values, - } = formRef.current?.getFormValues({ + if (doingActionRef.current) return + const { isCheckValidated, values } = formRef.current?.getFormValues({ needCheckValidatedValues: true, needTransformWhenSecretFieldIsPristine: true, }) || { isCheckValidated: false, values: {} } - if (!isCheckValidated) - return + if (!isCheckValidated) return try { const { @@ -124,12 +114,11 @@ const ApiKeyModal = ({ credential_id: __credential_id__, name: __name__ || '', }) - } - else { + } else { const permissionPayload = { visibility: permission, ...(permission === PermissionLevel.partialMembers - ? { partial_member_list: selectedMemberIDs.map(id => ({ user_id: id })) } + ? { partial_member_list: selectedMemberIDs.map((id) => ({ user_id: id })) } : {}), } await addPluginCredential({ @@ -139,77 +128,83 @@ const ApiKeyModal = ({ ...permissionPayload, }) } - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) onOpenChange?.(false) onClose?.() onUpdate?.() - } - finally { + } finally { handleSetDoingAction(false) } - }, [addPluginCredential, onClose, onOpenChange, onUpdate, updatePluginCredential, t, editValues, handleSetDoingAction, permission, selectedMemberIDs]) + }, [ + addPluginCredential, + onClose, + onOpenChange, + onUpdate, + updatePluginCredential, + t, + editValues, + handleSetDoingAction, + permission, + selectedMemberIDs, + ]) const isDisabled = disabled || isLoading || doingAction - const handleOpenChange = useCallback((nextOpen: boolean) => { - onOpenChange?.(nextOpen) - if (!nextOpen) - onClose?.() - }, [onClose, onOpenChange]) + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + onOpenChange?.(nextOpen) + if (!nextOpen) onClose?.() + }, + [onClose, onOpenChange], + ) return ( - <Dialog - open={open} - onOpenChange={handleOpenChange} - > + <Dialog open={open} onOpenChange={handleOpenChange}> <DialogContent backdropProps={{ forceRender: true }} className="w-[640px]! max-w-[calc(100vw-2rem)]! p-0!" > <div data-testid="modal" className="flex max-h-[80dvh] flex-col"> <div className="relative shrink-0 p-6 pr-14 pb-3"> - <DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary"> - {t($ => $['auth.useApiAuth'], { ns: 'plugin' })} + <DialogTitle + data-testid="modal-title" + className="title-2xl-semi-bold text-text-primary" + > + {t(($) => $['auth.useApiAuth'], { ns: 'plugin' })} </DialogTitle> <div className="mt-1 system-xs-regular text-text-tertiary"> - {t($ => $['auth.useApiAuthDesc'], { ns: 'plugin' })} + {t(($) => $['auth.useApiAuthDesc'], { ns: 'plugin' })} </div> - <DialogCloseButton - className="top-5 right-5 size-8 rounded-lg" - /> + <DialogCloseButton className="top-5 right-5 size-8 rounded-lg" /> </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> {pluginPayload.detail && ( <ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" /> )} - { - isLoading && ( - <div className="flex h-40 items-center justify-center"> - <Loading /> - </div> - ) - } - { - !isLoading && !!mergedData.length && ( - <AuthForm - ref={formRef} - formSchemas={formSchemas} - defaultValues={editValues || defaultValues} - disabled={disabled} - /> - ) - } + {isLoading && ( + <div className="flex h-40 items-center justify-center"> + <Loading /> + </div> + )} + {!isLoading && !!mergedData.length && ( + <AuthForm + ref={formRef} + formSchemas={formSchemas} + defaultValues={editValues || defaultValues} + disabled={disabled} + /> + )} {!isLoading && !editValues && ( <div className="mt-4 px-1"> <div className="mb-1 system-sm-semibold text-text-secondary"> - {t($ => $['auth.whoCanUse'], { ns: 'plugin' })} + {t(($) => $['auth.whoCanUse'], { ns: 'plugin' })} </div> <PermissionSelector disabled={disabled} permission={permission} value={selectedMemberIDs} memberList={memberList} - onChange={v => setPermission(v)} + onChange={(v) => setPermission(v)} onMemberSelect={setSelectedMemberIDs} hidePartialMembers /> @@ -227,16 +222,13 @@ const ApiKeyModal = ({ onClick={onRemove} disabled={isDisabled} > - {t($ => $['operation.remove'], { ns: 'common' })} + {t(($) => $['operation.remove'], { ns: 'common' })} </Button> <div className="mx-3 h-4 w-px bg-divider-regular"></div> </> )} - <Button - onClick={() => handleOpenChange(false)} - disabled={isDisabled} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button onClick={() => handleOpenChange(false)} disabled={isDisabled}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button data-testid="modal-confirm" @@ -245,7 +237,7 @@ const ApiKeyModal = ({ onClick={handleConfirm} disabled={isDisabled} > - {t($ => $['operation.save'], { ns: 'common' })} + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </div> diff --git a/web/app/components/plugins/plugin-auth/authorize/index.tsx b/web/app/components/plugins/plugin-auth/authorize/index.tsx index 2339cd19a75504..b0da11c70da8f8 100644 --- a/web/app/components/plugins/plugin-auth/authorize/index.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/index.tsx @@ -3,10 +3,7 @@ import type { AddApiKeyButtonProps } from './add-api-key-button' import type { AddOAuthButtonProps } from './add-oauth-button' import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import AddApiKeyButton from './add-api-key-button' @@ -44,7 +41,9 @@ const Authorize = ({ const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => { if (theme === 'secondary') { return { - buttonText: !canApiKey ? t($ => $['auth.useOAuthAuth'], { ns: 'plugin' }) : t($ => $['auth.addOAuth'], { ns: 'plugin' }), + buttonText: !canApiKey + ? t(($) => $['auth.useOAuthAuth'], { ns: 'plugin' }) + : t(($) => $['auth.addOAuth'], { ns: 'plugin' }), buttonVariant: 'secondary', className: 'hover:bg-components-button-secondary-bg', buttonLeftClassName: 'hover:bg-components-button-secondary-bg-hover', @@ -55,7 +54,9 @@ const Authorize = ({ } return { - buttonText: !canApiKey ? t($ => $['auth.useOAuthAuth'], { ns: 'plugin' }) : t($ => $['auth.addOAuth'], { ns: 'plugin' }), + buttonText: !canApiKey + ? t(($) => $['auth.useOAuthAuth'], { ns: 'plugin' }) + : t(($) => $['auth.addOAuth'], { ns: 'plugin' }), pluginPayload, } }, [canApiKey, theme, pluginPayload, t]) @@ -65,13 +66,17 @@ const Authorize = ({ return { pluginPayload, buttonVariant: 'secondary', - buttonText: !canOAuth ? t($ => $['auth.useApiAuth'], { ns: 'plugin' }) : t($ => $['auth.addApi'], { ns: 'plugin' }), + buttonText: !canOAuth + ? t(($) => $['auth.useApiAuth'], { ns: 'plugin' }) + : t(($) => $['auth.addApi'], { ns: 'plugin' }), onClick: onApiKeyClick, } } return { pluginPayload, - buttonText: !canOAuth ? t($ => $['auth.useApiAuth'], { ns: 'plugin' }) : t($ => $['auth.addApi'], { ns: 'plugin' }), + buttonText: !canOAuth + ? t(($) => $['auth.useApiAuth'], { ns: 'plugin' }) + : t(($) => $['auth.addApi'], { ns: 'plugin' }), buttonVariant: !canOAuth ? 'primary' : 'secondary-accent', onClick: onApiKeyClick, } @@ -93,7 +98,7 @@ const Authorize = ({ <Tooltip> <TooltipTrigger render={Item} /> <TooltipContent> - {t($ => $['auth.credentialUnavailable'], { ns: 'plugin' })} + {t(($) => $['auth.credentialUnavailable'], { ns: 'plugin' })} </TooltipContent> </Tooltip> ) @@ -117,7 +122,7 @@ const Authorize = ({ <Tooltip> <TooltipTrigger render={Item} /> <TooltipContent> - {t($ => $['auth.credentialUnavailable'], { ns: 'plugin' })} + {t(($) => $['auth.credentialUnavailable'], { ns: 'plugin' })} </TooltipContent> </Tooltip> ) @@ -128,25 +133,15 @@ const Authorize = ({ return ( <> <div className="flex items-center space-x-1.5"> - { - canOAuth && ( - OAuthButton - ) - } - { - showDivider && canOAuth && canApiKey && ( - <div className="flex shrink-0 flex-col items-center justify-between system-2xs-medium-uppercase text-text-tertiary"> - <div className="h-2 w-px bg-divider-subtle"></div> - or - <div className="h-2 w-px bg-divider-subtle"></div> - </div> - ) - } - { - canApiKey && ( - ApiKeyButton - ) - } + {canOAuth && OAuthButton} + {showDivider && canOAuth && canApiKey && ( + <div className="flex shrink-0 flex-col items-center justify-between system-2xs-medium-uppercase text-text-tertiary"> + <div className="h-2 w-px bg-divider-subtle"></div> + or + <div className="h-2 w-px bg-divider-subtle"></div> + </div> + )} + {canApiKey && ApiKeyButton} </div> </> ) diff --git a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx index f82459359f14f3..9c956bcd0bce42 100644 --- a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx @@ -1,21 +1,10 @@ import type { PluginPayload } from '../types' -import type { - FormRefObject, - FormSchema, -} from '@/app/components/base/form/types' +import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' -import { - useForm, - useStore, -} from '@tanstack/react-form' -import { - memo, - useCallback, - useRef, - useState, -} from 'react' +import { useForm, useStore } from '@tanstack/react-form' +import { memo, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AuthForm from '@/app/components/base/form/form-scenarios/auth' import { ReadmeEntrance } from '../../readme-panel/entrance' @@ -56,102 +45,109 @@ const OAuthClientSettings = ({ doingActionRef.current = value setDoingAction(value) }, []) - const handleOpenChange = useCallback((nextOpen: boolean) => { - onOpenChange?.(nextOpen) - if (!nextOpen) - onClose?.() - }, [onClose, onOpenChange]) - const defaultValues = schemas.reduce((acc, schema) => { - if (schema.default) - acc[schema.name] = schema.default - return acc - }, {} as Record<string, unknown>) - const { mutateAsync: setPluginOAuthCustomClient } = useSetPluginOAuthCustomClientHook(pluginPayload) + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + onOpenChange?.(nextOpen) + if (!nextOpen) onClose?.() + }, + [onClose, onOpenChange], + ) + const defaultValues = schemas.reduce( + (acc, schema) => { + if (schema.default) acc[schema.name] = schema.default + return acc + }, + {} as Record<string, unknown>, + ) + const { mutateAsync: setPluginOAuthCustomClient } = + useSetPluginOAuthCustomClientHook(pluginPayload) const invalidPluginOAuthClientSchema = useInvalidPluginOAuthClientSchemaHook(pluginPayload) const formRef = useRef<FormRefObject>(null) const handleConfirm = useCallback(async () => { - if (doingActionRef.current) - return + if (doingActionRef.current) return try { - const { - isCheckValidated, - values, - } = formRef.current?.getFormValues({ + const { isCheckValidated, values } = formRef.current?.getFormValues({ needCheckValidatedValues: true, needTransformWhenSecretFieldIsPristine: true, }) || { isCheckValidated: false, values: {} } - if (!isCheckValidated) - throw new Error('error') - const { - __oauth_client__, - ...restValues - } = values + if (!isCheckValidated) throw new Error('error') + const { __oauth_client__, ...restValues } = values handleSetDoingAction(true) await setPluginOAuthCustomClient({ client_params: restValues, enable_oauth_custom_client: __oauth_client__ === 'custom', }) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) onOpenChange?.(false) onClose?.() onUpdate?.() invalidPluginOAuthClientSchema() - } - finally { + } finally { handleSetDoingAction(false) } - }, [onClose, onOpenChange, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, t, handleSetDoingAction]) + }, [ + onClose, + onOpenChange, + onUpdate, + invalidPluginOAuthClientSchema, + setPluginOAuthCustomClient, + t, + handleSetDoingAction, + ]) const handleConfirmAndAuthorize = useCallback(async () => { await handleConfirm() - if (onAuth) - await onAuth() + if (onAuth) await onAuth() }, [handleConfirm, onAuth]) - const { mutateAsync: deletePluginOAuthCustomClient } = useDeletePluginOAuthCustomClientHook(pluginPayload) + const { mutateAsync: deletePluginOAuthCustomClient } = + useDeletePluginOAuthCustomClientHook(pluginPayload) const handleRemove = useCallback(async () => { - if (doingActionRef.current) - return + if (doingActionRef.current) return try { handleSetDoingAction(true) await deletePluginOAuthCustomClient() - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) onOpenChange?.(false) onClose?.() onUpdate?.() invalidPluginOAuthClientSchema() - } - finally { + } finally { handleSetDoingAction(false) } - }, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, t, handleSetDoingAction, onClose, onOpenChange]) + }, [ + onUpdate, + invalidPluginOAuthClientSchema, + deletePluginOAuthCustomClient, + t, + handleSetDoingAction, + onClose, + onOpenChange, + ]) const form = useForm({ defaultValues: editValues || defaultValues, }) - const __oauth_client__ = useStore(form.store, s => s.values.__oauth_client__) + const __oauth_client__ = useStore(form.store, (s) => s.values.__oauth_client__) const isDisabled = disabled || doingAction return ( - <Dialog - open={open} - disablePointerDismissal - onOpenChange={handleOpenChange} - > + <Dialog open={open} disablePointerDismissal onOpenChange={handleOpenChange}> <DialogContent backdropProps={{ forceRender: true }} className="w-[480px]! max-w-[calc(100vw-2rem)]! p-0!" > <div data-testid="modal" className="flex max-h-[80dvh] flex-col"> <div className="relative shrink-0 p-6 pr-14 pb-3"> - <DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary"> - {t($ => $['auth.oauthClientSettings'], { ns: 'plugin' })} + <DialogTitle + data-testid="modal-title" + className="title-2xl-semi-bold text-text-primary" + > + {t(($) => $['auth.oauthClientSettings'], { ns: 'plugin' })} </DialogTitle> - <DialogCloseButton - className="top-5 right-5 size-8 rounded-lg" - /> + <DialogCloseButton className="top-5 right-5 size-8 rounded-lg" /> </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 py-3 pt-0"> {pluginPayload.detail && ( @@ -175,7 +171,7 @@ const OAuthClientSettings = ({ disabled={isDisabled || !editValues} onClick={handleRemove} > - {t($ => $['operation.remove'], { ns: 'common' })} + {t(($) => $['operation.remove'], { ns: 'common' })} </Button> )} </div> @@ -185,15 +181,11 @@ const OAuthClientSettings = ({ onClick={() => handleOpenChange(false)} disabled={isDisabled} > - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <div className="mx-3 h-4 w-px bg-divider-regular"></div> - <Button - data-testid="modal-cancel" - onClick={handleConfirm} - disabled={isDisabled} - > - {t($ => $['auth.saveOnly'], { ns: 'plugin' })} + <Button data-testid="modal-cancel" onClick={handleConfirm} disabled={isDisabled}> + {t(($) => $['auth.saveOnly'], { ns: 'plugin' })} </Button> <Button data-testid="modal-confirm" @@ -202,7 +194,7 @@ const OAuthClientSettings = ({ onClick={handleConfirmAndAuthorize} disabled={isDisabled} > - {t($ => $['auth.saveAndAuth'], { ns: 'plugin' })} + {t(($) => $['auth.saveAndAuth'], { ns: 'plugin' })} </Button> </div> </div> diff --git a/web/app/components/plugins/plugin-auth/authorized-in-data-source-node.tsx b/web/app/components/plugins/plugin-auth/authorized-in-data-source-node.tsx index c60348a7917568..d716c0e480483d 100644 --- a/web/app/components/plugins/plugin-auth/authorized-in-data-source-node.tsx +++ b/web/app/components/plugins/plugin-auth/authorized-in-data-source-node.tsx @@ -2,9 +2,7 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { RiEqualizer2Line } from '@remixicon/react' -import { - memo, -} from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' type AuthorizedInDataSourceNodeProps = { @@ -18,24 +16,12 @@ const AuthorizedInDataSourceNode = ({ const { t } = useTranslation() return ( - <Button - size="small" - onClick={onJumpToDataSourcePage} - > - <StatusDot - className="mr-1.5" - status="success" - /> - { - authorizationsNum > 1 - ? t($ => $['auth.authorizations'], { ns: 'plugin' }) - : t($ => $['auth.authorization'], { ns: 'plugin' }) - } - <RiEqualizer2Line - className={cn( - 'size-3.5 text-components-button-ghost-text', - )} - /> + <Button size="small" onClick={onJumpToDataSourcePage}> + <StatusDot className="mr-1.5" status="success" /> + {authorizationsNum > 1 + ? t(($) => $['auth.authorizations'], { ns: 'plugin' }) + : t(($) => $['auth.authorization'], { ns: 'plugin' })} + <RiEqualizer2Line className={cn('size-3.5 text-components-button-ghost-text')} /> </Button> ) } diff --git a/web/app/components/plugins/plugin-auth/authorized-in-node.tsx b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx index 1a86b83c83710c..ad4117c7e6ea57 100644 --- a/web/app/components/plugins/plugin-auth/authorized-in-node.tsx +++ b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx @@ -1,23 +1,12 @@ import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot' -import type { - Credential, - PluginPayload, -} from './types' +import type { Credential, PluginPayload } from './types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { RiArrowDownSLine } from '@remixicon/react' -import { - memo, - useCallback, - useEffect, - useState, -} from 'react' +import { memo, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - Authorized, - usePluginAuth, -} from '.' +import { Authorized, usePluginAuth } from '.' type AuthorizedInNodeProps = { pluginPayload: PluginPayload @@ -40,94 +29,87 @@ const AuthorizedInNode = ({ invalidPluginCredentialInfo, notAllowCustomCredential, } = usePluginAuth(pluginPayload, true, credentialId ? [credentialId] : undefined) - const defaultCredentialId = credentials.find(c => c.is_default)?.id + const defaultCredentialId = credentials.find((c) => c.is_default)?.id useEffect(() => { onDefaultCredentialChange?.(defaultCredentialId) }, [defaultCredentialId, onDefaultCredentialChange]) - const renderTrigger = useCallback((open?: boolean) => { - let label = '' - let removed = false - let unavailable = false - let color: StatusDotStatus = 'success' - let defaultUnavailable = false - if (!credentialId) { - label = t($ => $['auth.workspaceDefault'], { ns: 'plugin' }) + const renderTrigger = useCallback( + (open?: boolean) => { + let label = '' + let removed = false + let unavailable = false + let color: StatusDotStatus = 'success' + let defaultUnavailable = false + if (!credentialId) { + label = t(($) => $['auth.workspaceDefault'], { ns: 'plugin' }) - const defaultCredential = credentials.find(c => c.is_default) + const defaultCredential = credentials.find((c) => c.is_default) - if (defaultCredential?.not_allowed_to_use) { - color = 'disabled' - defaultUnavailable = true - } - } - else { - const credential = credentials.find(c => c.id === credentialId) - label = credential ? credential.name : t($ => $['auth.authRemoved'], { ns: 'plugin' }) - removed = !credential - unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise + if (defaultCredential?.not_allowed_to_use) { + color = 'disabled' + defaultUnavailable = true + } + } else { + const credential = credentials.find((c) => c.id === credentialId) + label = credential ? credential.name : t(($) => $['auth.authRemoved'], { ns: 'plugin' }) + removed = !credential + unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise - if (removed) - color = 'error' - else if (unavailable) - color = 'disabled' - } - return ( - <Button - size="small" - className={cn( - open && !removed && 'bg-components-button-ghost-bg-hover', - removed && 'bg-transparent text-text-destructive', - )} - variant={(defaultUnavailable || unavailable) ? 'ghost' : 'secondary'} - > - <StatusDot - className="mr-1.5" - status={color} - /> - {label} - { - (unavailable || defaultUnavailable) && ( + if (removed) color = 'error' + else if (unavailable) color = 'disabled' + } + return ( + <Button + size="small" + className={cn( + open && !removed && 'bg-components-button-ghost-bg-hover', + removed && 'bg-transparent text-text-destructive', + )} + variant={defaultUnavailable || unavailable ? 'ghost' : 'secondary'} + > + <StatusDot className="mr-1.5" status={color} /> + {label} + {(unavailable || defaultUnavailable) && ( <>   - {t($ => $['auth.unavailable'], { ns: 'plugin' })} + {t(($) => $['auth.unavailable'], { ns: 'plugin' })} </> - ) - } - <RiArrowDownSLine - className={cn( - 'size-3.5 text-components-button-ghost-text', - removed && 'text-text-destructive', )} - /> - </Button> - ) - }, [credentialId, credentials, t]) - const defaultUnavailable = credentials.find(c => c.is_default)?.not_allowed_to_use + <RiArrowDownSLine + className={cn( + 'size-3.5 text-components-button-ghost-text', + removed && 'text-text-destructive', + )} + /> + </Button> + ) + }, + [credentialId, credentials, t], + ) + const defaultUnavailable = credentials.find((c) => c.is_default)?.not_allowed_to_use const extraAuthorizationItems: Credential[] = [ { id: '__workspace_default__', - name: t($ => $['auth.workspaceDefault'], { ns: 'plugin' }), + name: t(($) => $['auth.workspaceDefault'], { ns: 'plugin' }), provider: '', is_default: !credentialId, isWorkspaceDefault: true, not_allowed_to_use: defaultUnavailable, }, ] - const handleAuthorizationItemClick = useCallback((id: string) => { - onAuthorizationItemClick( - id === '__workspace_default__' && onDefaultCredentialChange - ? defaultCredentialId || id - : id, - ) - setIsOpen(false) - }, [ - defaultCredentialId, - onDefaultCredentialChange, - onAuthorizationItemClick, - setIsOpen, - ]) + const handleAuthorizationItemClick = useCallback( + (id: string) => { + onAuthorizationItemClick( + id === '__workspace_default__' && onDefaultCredentialChange + ? defaultCredentialId || id + : id, + ) + setIsOpen(false) + }, + [defaultCredentialId, onDefaultCredentialChange, onAuthorizationItemClick, setIsOpen], + ) return ( <Authorized diff --git a/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx index 10fc8745e952af..a73b6651344754 100644 --- a/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx @@ -59,10 +59,18 @@ const toastMocks = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { - success: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'success', message, ...options })), - error: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'error', message, ...options })), - warning: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'warning', message, ...options })), - info: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'info', message, ...options })), + success: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'success', message, ...options }), + ), + error: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'info', message, ...options }), + ), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -117,7 +125,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -149,9 +158,7 @@ const createTestQueryClient = () => const createWrapper = () => { const testQueryClient = createTestQueryClient() return ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={testQueryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider> ) } @@ -176,7 +183,11 @@ const createCredential = (overrides: Partial<Credential> = {}): Credential => ({ describe('Authorized Component', () => { beforeEach(() => { vi.clearAllMocks() - mockAppContext.workspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage'] + mockAppContext.workspacePermissionKeys = [ + 'credential.use', + 'credential.create', + 'credential.manage', + ] mockDeletePluginCredential.mockResolvedValue({}) mockSetPluginDefaultCredential.mockResolvedValue({}) mockUpdatePluginCredential.mockResolvedValue({}) @@ -188,13 +199,9 @@ describe('Authorized Component', () => { const pluginPayload = createPluginPayload() const credentials = [createCredential()] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} />, { + wrapper: createWrapper(), + }) expect(screen.getByRole('button'))!.toBeInTheDocument() }) @@ -207,7 +214,9 @@ describe('Authorized Component', () => { <Authorized pluginPayload={pluginPayload} credentials={credentials} - renderTrigger={open => <div data-testid="custom-trigger">{open ? 'Open' : 'Closed'}</div>} + renderTrigger={(open) => ( + <div data-testid="custom-trigger">{open ? 'Open' : 'Closed'}</div> + )} />, { wrapper: createWrapper() }, ) @@ -220,13 +229,9 @@ describe('Authorized Component', () => { const pluginPayload = createPluginPayload() const credentials = [createCredential()] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} />, { + wrapper: createWrapper(), + }) // Text is split by elements, use regex to find partial match // Text is split by elements, use regex to find partial match @@ -235,18 +240,11 @@ describe('Authorized Component', () => { it('should show plural authorizations text for multiple credentials', () => { const pluginPayload = createPluginPayload() - const credentials = [ - createCredential({ id: '1' }), - createCredential({ id: '2' }), - ] + const credentials = [createCredential({ id: '1' }), createCredential({ id: '2' })] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} />, { + wrapper: createWrapper(), + }) // Text is split by elements, use regex to find partial match // Text is split by elements, use regex to find partial match @@ -260,28 +258,19 @@ describe('Authorized Component', () => { createCredential({ id: '2', not_allowed_to_use: true }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} />, { + wrapper: createWrapper(), + }) expect(screen.getByText(/plugin\.auth\.unavailable/))!.toBeInTheDocument() }) it('should show gray indicator when default credential is unavailable', () => { const pluginPayload = createPluginPayload() - const credentials = [ - createCredential({ is_default: true, not_allowed_to_use: true }), - ] + const credentials = [createCredential({ is_default: true, not_allowed_to_use: true })] const { container } = render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - />, + <Authorized pluginPayload={pluginPayload} credentials={credentials} />, { wrapper: createWrapper() }, ) @@ -295,13 +284,9 @@ describe('Authorized Component', () => { const pluginPayload = createPluginPayload() const credentials = [createCredential()] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} />, { + wrapper: createWrapper(), + }) const trigger = screen.getByRole('button') fireEvent.click(trigger) @@ -341,13 +326,9 @@ describe('Authorized Component', () => { const pluginPayload = createPluginPayload() const credentials = [createCredential()] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} />, { + wrapper: createWrapper(), + }) const trigger = screen.getByRole('button') @@ -366,17 +347,16 @@ describe('Authorized Component', () => { it('should render OAuth credentials section when oAuthCredentials exist', () => { const pluginPayload = createPluginPayload() const credentials = [ - createCredential({ id: '1', credential_type: CredentialTypeEnum.OAUTH2, name: 'OAuth Cred' }), + createCredential({ + id: '1', + credential_type: CredentialTypeEnum.OAUTH2, + name: 'OAuth Cred', + }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('OAuth'))!.toBeInTheDocument() expect(screen.getByText('OAuth Cred'))!.toBeInTheDocument() @@ -385,17 +365,16 @@ describe('Authorized Component', () => { it('should render API Key credentials section when apiKeyCredentials exist', () => { const pluginPayload = createPluginPayload() const credentials = [ - createCredential({ id: '1', credential_type: CredentialTypeEnum.API_KEY, name: 'API Key Cred' }), + createCredential({ + id: '1', + credential_type: CredentialTypeEnum.API_KEY, + name: 'API Key Cred', + }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('API Keys'))!.toBeInTheDocument() expect(screen.getByText('API Key Cred'))!.toBeInTheDocument() @@ -404,18 +383,21 @@ describe('Authorized Component', () => { it('should render both OAuth and API Key sections when both exist', () => { const pluginPayload = createPluginPayload() const credentials = [ - createCredential({ id: '1', credential_type: CredentialTypeEnum.OAUTH2, name: 'OAuth Cred' }), - createCredential({ id: '2', credential_type: CredentialTypeEnum.API_KEY, name: 'API Key Cred' }), + createCredential({ + id: '1', + credential_type: CredentialTypeEnum.OAUTH2, + name: 'OAuth Cred', + }), + createCredential({ + id: '2', + credential_type: CredentialTypeEnum.API_KEY, + name: 'API Key Cred', + }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('OAuth'))!.toBeInTheDocument() expect(screen.getByText('API Keys'))!.toBeInTheDocument() @@ -424,9 +406,7 @@ describe('Authorized Component', () => { it('should render extra authorization items when provided', () => { const pluginPayload = createPluginPayload() const credentials = [createCredential()] - const extraItems = [ - createCredential({ id: 'extra-1', name: 'Extra Item' }), - ] + const extraItems = [createCredential({ id: 'extra-1', name: 'Extra Item' })] render( <Authorized @@ -468,14 +448,9 @@ describe('Authorized Component', () => { const pluginPayload = createPluginPayload() const credentials = [createCredential({ credential_type: CredentialTypeEnum.OAUTH2 })] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Find and click delete button in the credential item const deleteButton = document.querySelector('svg.ri-delete-bin-line')?.closest('button') @@ -493,14 +468,9 @@ describe('Authorized Component', () => { const pluginPayload = createPluginPayload() const credentials = [createCredential({ credential_type: CredentialTypeEnum.OAUTH2 })] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Wait for OAuth section to render await waitFor(() => { @@ -527,7 +497,9 @@ describe('Authorized Component', () => { // Dialog should close await waitFor(() => { - expect(screen.queryByText('datasetDocuments.list.delete.title')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetDocuments.list.delete.title'), + ).not.toBeInTheDocument() }) break } @@ -541,7 +513,9 @@ describe('Authorized Component', () => { it('should call deletePluginCredential when confirm is clicked', async () => { const pluginPayload = createPluginPayload() - const credentials = [createCredential({ id: 'delete-me', credential_type: CredentialTypeEnum.OAUTH2 })] + const credentials = [ + createCredential({ id: 'delete-me', credential_type: CredentialTypeEnum.OAUTH2 }), + ] const onUpdate = vi.fn() render( @@ -584,14 +558,9 @@ describe('Authorized Component', () => { const credentials: Credential[] = [] // This test verifies the edge case handling - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // No credentials to delete, so nothing to test here expect(mockDeletePluginCredential).not.toHaveBeenCalled() @@ -712,7 +681,11 @@ describe('Authorized Component', () => { if (btn.querySelector('svg.remixicon') && !btn.querySelector('svg.ri-delete-bin-line')) { // Check if this is an action button (not delete) const svg = btn.querySelector('svg') - if (svg && !svg.classList.contains('ri-delete-bin-line') && !svg.classList.contains('ri-arrow-down-s-line')) { + if ( + svg && + !svg.classList.contains('ri-delete-bin-line') && + !svg.classList.contains('ri-arrow-down-s-line') + ) { renameButton = btn break } @@ -744,8 +717,7 @@ describe('Authorized Component', () => { }) expect(onUpdate).toHaveBeenCalled() } - } - else { + } else { // Verify component renders properly // Verify component renders properly expect(screen.getByText('OAuth'))!.toBeInTheDocument() @@ -761,14 +733,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Verify component renders // Verify component renders @@ -840,11 +807,15 @@ describe('Authorized Component', () => { // Find all action buttons in the credential item // The rename button should be present for OAuth credentials - const actionButtons = Array.from(document.querySelectorAll('.group-hover\\:flex button, button')) + const actionButtons = Array.from( + document.querySelectorAll('.group-hover\\:flex button, button'), + ) // Find the rename trigger button (the one with edit icon, not delete) for (const btn of actionButtons) { - const hasDeleteIcon = btn.querySelector('svg path')?.getAttribute('d')?.includes('DELETE') || btn.querySelector('.ri-delete-bin-line') + const hasDeleteIcon = + btn.querySelector('svg path')?.getAttribute('d')?.includes('DELETE') || + btn.querySelector('.ri-delete-bin-line') const hasSvg = btn.querySelector('svg') if (hasSvg && !hasDeleteIcon && !btn.textContent?.includes('setDefault')) { @@ -898,14 +869,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Find edit button (RiEqualizer2Line) const editButton = document.querySelector('svg.ri-equalizer-2-line')?.closest('button') @@ -931,14 +897,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Open edit modal const editButton = document.querySelector('svg.ri-equalizer-2-line')?.closest('button') @@ -984,14 +945,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Find and click edit button const editButtons = Array.from(document.querySelectorAll('button')) @@ -1034,8 +990,7 @@ describe('Authorized Component', () => { expect(screen.getByText('API Keys'))!.toBeInTheDocument() }) } - } - else { + } else { // Verify component renders // Verify component renders expect(screen.getByText('API Keys'))!.toBeInTheDocument() @@ -1053,14 +1008,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Wait for component to render // Wait for component to render @@ -1077,10 +1027,13 @@ describe('Authorized Component', () => { }) // Wait for ApiKeyModal to render - await waitFor(() => { - const modals = document.querySelectorAll('.fixed') - expect(modals.length).toBeGreaterThan(0) - }, { timeout: 2000 }) + await waitFor( + () => { + const modals = document.querySelectorAll('.fixed') + expect(modals.length).toBeGreaterThan(0) + }, + { timeout: 2000 }, + ) // Find and click the close/cancel button // The modal should have a cancel button @@ -1115,14 +1068,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Wait for component to render // Wait for component to render @@ -1153,12 +1101,15 @@ describe('Authorized Component', () => { // After clicking remove, a confirm dialog should appear // because handleRemove sets deleteCredentialId - await waitFor(() => { - const confirmDialog = screen.queryByText('datasetDocuments.list.delete.title') - if (confirmDialog) { - expect(confirmDialog)!.toBeInTheDocument() - } - }, { timeout: 1000 }) + await waitFor( + () => { + const confirmDialog = screen.queryByText('datasetDocuments.list.delete.title') + if (confirmDialog) { + expect(confirmDialog)!.toBeInTheDocument() + } + }, + { timeout: 1000 }, + ) } } } @@ -1175,39 +1126,43 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Verify API Keys section is shown // Verify API Keys section is shown expect(screen.getByText('API Keys'))!.toBeInTheDocument() // Find edit button - look for buttons in the action area - const actionAreaButtons = Array.from(document.querySelectorAll('.group-hover\\:flex button, .hidden button')) + const actionAreaButtons = Array.from( + document.querySelectorAll('.group-hover\\:flex button, .hidden button'), + ) for (const btn of actionAreaButtons) { const svg = btn.querySelector('svg') - if (svg && !btn.textContent?.includes('setDefault') && !btn.textContent?.includes('delete')) { + if ( + svg && + !btn.textContent?.includes('setDefault') && + !btn.textContent?.includes('delete') + ) { await act(async () => { fireEvent.click(btn) }) // Check if modal opened - await waitFor(() => { - const modal = document.querySelector('.fixed') - if (modal) { - const cancelButton = screen.queryByText('common.operation.cancel') - if (cancelButton) { - fireEvent.click(cancelButton) + await waitFor( + () => { + const modal = document.querySelector('.fixed') + if (modal) { + const cancelButton = screen.queryByText('common.operation.cancel') + if (cancelButton) { + fireEvent.click(cancelButton) + } } - } - }, { timeout: 1000 }) + }, + { timeout: 1000 }, + ) break } } @@ -1228,14 +1183,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Verify component renders // Verify component renders @@ -1243,27 +1193,36 @@ describe('Authorized Component', () => { // Find edit button by looking for action buttons (not in the confirm dialog) // These are grouped in hidden elements that show on hover - const actionAreaButtons = Array.from(document.querySelectorAll('.group-hover\\:flex button, .hidden button')) + const actionAreaButtons = Array.from( + document.querySelectorAll('.group-hover\\:flex button, .hidden button'), + ) for (const btn of actionAreaButtons) { const svg = btn.querySelector('svg') // Look for a button that's not the delete button - if (svg && !btn.textContent?.includes('setDefault') && !btn.textContent?.includes('delete')) { + if ( + svg && + !btn.textContent?.includes('setDefault') && + !btn.textContent?.includes('delete') + ) { await act(async () => { fireEvent.click(btn) }) // Check if ApiKeyModal opened - await waitFor(() => { - const modal = document.querySelector('.fixed') - if (modal) { - // Find remove button - const removeButton = screen.queryByText('common.operation.remove') - if (removeButton) { - fireEvent.click(removeButton) + await waitFor( + () => { + const modal = document.querySelector('.fixed') + if (modal) { + // Find remove button + const removeButton = screen.queryByText('common.operation.remove') + if (removeButton) { + fireEvent.click(removeButton) + } } - } - }, { timeout: 1000 }) + }, + { timeout: 1000 }, + ) break } } @@ -1282,14 +1241,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Open edit modal const editButton = document.querySelector('svg.ri-equalizer-2-line')?.closest('button') @@ -1301,8 +1255,9 @@ describe('Authorized Component', () => { }) // Find remove button in modal (usually has delete/remove text) - const removeButton = screen.queryByText('common.operation.remove') - || screen.queryByText('common.operation.delete') + const removeButton = + screen.queryByText('common.operation.remove') || + screen.queryByText('common.operation.delete') if (removeButton) { fireEvent.click(removeButton) @@ -1326,14 +1281,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Open edit modal - find the edit button by looking for RiEqualizer2Line icon const allButtons = Array.from(document.querySelectorAll('button')) @@ -1374,8 +1324,7 @@ describe('Authorized Component', () => { expect(screen.getByText('API Keys'))!.toBeInTheDocument() }) } - } - else { + } else { // If no edit button found, just verify the component renders // If no edit button found, just verify the component renders expect(screen.getByText('API Keys'))!.toBeInTheDocument() @@ -1497,14 +1446,9 @@ describe('Authorized Component', () => { const credentials = [createCredential({ is_default: false })] mockAppContext.workspacePermissionKeys = ['credential.use'] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) const setDefaultButton = screen.queryByText('plugin.auth.setDefault') expect(setDefaultButton)!.toBeInTheDocument() @@ -1516,14 +1460,9 @@ describe('Authorized Component', () => { const credentials = [createCredential({ is_default: false })] mockAppContext.workspacePermissionKeys = [] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) const setDefaultButton = screen.queryByText('plugin.auth.setDefault') expect(setDefaultButton)!.toBeInTheDocument() @@ -1587,17 +1526,14 @@ describe('Authorized Component', () => { const credentials = [createCredential({ credential_type: CredentialTypeEnum.OAUTH2 })] // Make delete slow - mockDeletePluginCredential.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) - - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, + mockDeletePluginCredential.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) + // Trigger delete const deleteButton = document.querySelector('svg.ri-delete-bin-line')?.closest('button') if (deleteButton) { @@ -1625,17 +1561,14 @@ describe('Authorized Component', () => { const credentials = [createCredential({ is_default: false })] // Make set default slow - mockSetPluginDefaultCredential.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) - - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, + mockSetPluginDefaultCredential.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) + const setDefaultButton = screen.queryByText('plugin.auth.setDefault') if (setDefaultButton) { // Click twice quickly @@ -1657,17 +1590,14 @@ describe('Authorized Component', () => { ] // Make rename slow - mockUpdatePluginCredential.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) - - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, + mockUpdatePluginCredential.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) + // Enter rename mode const renameButton = document.querySelector('svg.ri-edit-line')?.closest('button') if (renameButton) { @@ -1692,13 +1622,9 @@ describe('Authorized Component', () => { const pluginPayload = createPluginPayload() const credentials: Credential[] = [] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} />, { + wrapper: createWrapper(), + }) // Should render with 0 count - the button should contain 0 const button = screen.getByRole('button') @@ -1710,13 +1636,9 @@ describe('Authorized Component', () => { const credentials = [createCredential({ credential_type: undefined })] expect(() => { - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} />, { + wrapper: createWrapper(), + }) }).not.toThrow() }) @@ -1725,14 +1647,9 @@ describe('Authorized Component', () => { const credentials = [createCredential()] // This tests the branch where credentialId is undefined - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Component should render without error // Component should render without error @@ -1840,8 +1757,7 @@ describe('Authorized Component', () => { await waitFor(() => { expect(screen.queryByText('datasetDocuments.list.delete.title')).not.toBeInTheDocument() }) - } - else { + } else { // Component should still render correctly // Component should still render correctly expect(screen.getByText('OAuth'))!.toBeInTheDocument() @@ -1857,14 +1773,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Verify component renders // Verify component renders @@ -1882,20 +1793,17 @@ describe('Authorized Component', () => { // Make delete very slow to keep doingAction true mockDeletePluginCredential.mockImplementation( - () => new Promise(resolve => setTimeout(resolve, 5000)), + () => new Promise((resolve) => setTimeout(resolve, 5000)), ) - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Find delete button in action area - const actionButtons = Array.from(document.querySelectorAll('.hidden button, [class*="group-hover"] button')) + const actionButtons = Array.from( + document.querySelectorAll('.hidden button, [class*="group-hover"] button'), + ) let foundDeleteButton = false for (const btn of actionButtons) { @@ -1938,14 +1846,9 @@ describe('Authorized Component', () => { const pluginPayload = createPluginPayload() const credentials: Credential[] = [] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // With no credentials, there's no way to trigger openConfirm, // so pendingOperationCredentialId stays null @@ -2057,14 +1960,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Wait for component to render await waitFor(() => { @@ -2072,7 +1970,9 @@ describe('Authorized Component', () => { }) // Find delete button in action area - const actionButtons = Array.from(document.querySelectorAll('.hidden button, [class*="group-hover"] button')) + const actionButtons = Array.from( + document.querySelectorAll('.hidden button, [class*="group-hover"] button'), + ) for (const btn of actionButtons) { await act(async () => { @@ -2107,21 +2007,18 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) await waitFor(() => { expect(screen.getByText('OAuth'))!.toBeInTheDocument() }) // Find and trigger delete to open confirm dialog - const actionButtons = Array.from(document.querySelectorAll('.hidden button, [class*="group-hover"] button')) + const actionButtons = Array.from( + document.querySelectorAll('.hidden button, [class*="group-hover"] button'), + ) for (const btn of actionButtons) { await act(async () => { @@ -2165,21 +2062,18 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) await waitFor(() => { expect(screen.getByText('OAuth'))!.toBeInTheDocument() }) // Find and trigger delete to open confirm dialog - const actionButtons = Array.from(document.querySelectorAll('.hidden button, [class*="group-hover"] button')) + const actionButtons = Array.from( + document.querySelectorAll('.hidden button, [class*="group-hover"] button'), + ) for (const btn of actionButtons) { await act(async () => { @@ -2211,21 +2105,18 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) await waitFor(() => { expect(screen.getByText('OAuth'))!.toBeInTheDocument() }) // Find and trigger delete to open confirm dialog - const actionButtons = Array.from(document.querySelectorAll('.hidden button, [class*="group-hover"] button')) + const actionButtons = Array.from( + document.querySelectorAll('.hidden button, [class*="group-hover"] button'), + ) for (const btn of actionButtons) { await act(async () => { @@ -2244,7 +2135,9 @@ describe('Authorized Component', () => { // Dialog should be closed await waitFor(() => { - expect(screen.queryByText('datasetDocuments.list.delete.title')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetDocuments.list.delete.title'), + ).not.toBeInTheDocument() }) } break @@ -2264,14 +2157,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Wait for component to render await waitFor(() => { @@ -2279,7 +2167,9 @@ describe('Authorized Component', () => { }) // Find edit button in action area - const actionButtons = Array.from(document.querySelectorAll('.hidden button, [class*="group-hover"] button')) + const actionButtons = Array.from( + document.querySelectorAll('.hidden button, [class*="group-hover"] button'), + ) for (const btn of actionButtons) { const svg = btn.querySelector('svg') @@ -2299,12 +2189,15 @@ describe('Authorized Component', () => { }) // handleRemove sets deleteCredentialId, which should show confirm dialog - await waitFor(() => { - const confirmTitle = screen.queryByText('datasetDocuments.list.delete.title') - if (confirmTitle) { - expect(confirmTitle)!.toBeInTheDocument() - } - }, { timeout: 2000 }) + await waitFor( + () => { + const confirmTitle = screen.queryByText('datasetDocuments.list.delete.title') + if (confirmTitle) { + expect(confirmTitle)!.toBeInTheDocument() + } + }, + { timeout: 2000 }, + ) } break } @@ -2326,14 +2219,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Wait for component to render await waitFor(() => { @@ -2341,7 +2229,9 @@ describe('Authorized Component', () => { }) // Find and click edit button to open ApiKeyModal - const actionButtons = Array.from(document.querySelectorAll('.hidden button, [class*="group-hover"] button')) + const actionButtons = Array.from( + document.querySelectorAll('.hidden button, [class*="group-hover"] button'), + ) for (const btn of actionButtons) { const svg = btn.querySelector('svg') @@ -2361,13 +2251,16 @@ describe('Authorized Component', () => { }) // Verify confirm dialog appears (handleRemove was called) - await waitFor(() => { - const confirmTitle = screen.queryByText('datasetDocuments.list.delete.title') - // If confirm dialog appears, handleRemove was called - if (confirmTitle) { - expect(confirmTitle)!.toBeInTheDocument() - } - }, { timeout: 1000 }) + await waitFor( + () => { + const confirmTitle = screen.queryByText('datasetDocuments.list.delete.title') + // If confirm dialog appears, handleRemove was called + if (confirmTitle) { + expect(confirmTitle)!.toBeInTheDocument() + } + }, + { timeout: 1000 }, + ) } break } @@ -2392,17 +2285,12 @@ describe('Authorized Component', () => { // Make update very slow to keep doingAction true mockUpdatePluginCredential.mockImplementation( - () => new Promise(resolve => setTimeout(resolve, 5000)), + () => new Promise((resolve) => setTimeout(resolve, 5000)), ) - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Wait for component to render await waitFor(() => { @@ -2410,7 +2298,9 @@ describe('Authorized Component', () => { }) // Find rename button in action area - const actionButtons = Array.from(document.querySelectorAll('.hidden button, [class*="group-hover"] button')) + const actionButtons = Array.from( + document.querySelectorAll('.hidden button, [class*="group-hover"] button'), + ) for (const btn of actionButtons) { await act(async () => { @@ -2455,26 +2345,24 @@ describe('Authorized Component', () => { // Make the first update very slow let resolveUpdate: (value: unknown) => void mockUpdatePluginCredential.mockImplementation( - () => new Promise((resolve) => { - resolveUpdate = resolve - }), + () => + new Promise((resolve) => { + resolveUpdate = resolve + }), ) - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) await waitFor(() => { expect(screen.getByText('OAuth'))!.toBeInTheDocument() }) // Find rename button - const actionButtons = Array.from(document.querySelectorAll('.hidden button, [class*="group-hover"] button')) + const actionButtons = Array.from( + document.querySelectorAll('.hidden button, [class*="group-hover"] button'), + ) for (const btn of actionButtons) { await act(async () => { @@ -2524,14 +2412,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Wait for component to render await waitFor(() => { @@ -2539,7 +2422,9 @@ describe('Authorized Component', () => { }) // Find and click edit button to open modal - const actionButtons = Array.from(document.querySelectorAll('.hidden button, [class*="group-hover"] button')) + const actionButtons = Array.from( + document.querySelectorAll('.hidden button, [class*="group-hover"] button'), + ) for (const btn of actionButtons) { const svg = btn.querySelector('svg') @@ -2581,14 +2466,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) await waitFor(() => { expect(screen.getByText('API Keys'))!.toBeInTheDocument() @@ -2642,14 +2522,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Find and click edit button to open modal const editIcon = document.querySelector('svg.ri-equalizer-2-line') @@ -2709,14 +2584,9 @@ describe('Authorized Component', () => { }), ] - render( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - isOpen={true} - />, - { wrapper: createWrapper() }, - ) + render(<Authorized pluginPayload={pluginPayload} credentials={credentials} isOpen={true} />, { + wrapper: createWrapper(), + }) // Click delete button which calls openConfirm with the credential id const deleteIcon = document.querySelector('svg.ri-delete-bin-line') diff --git a/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx index ff364dc62506d3..fcc6ac38f25614 100644 --- a/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx @@ -1,7 +1,6 @@ import type { Credential } from '../../types' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import { CredentialTypeEnum } from '../../types' import Item from '../item' @@ -42,7 +41,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -112,11 +112,7 @@ describe('Item Component', () => { const credential = createCredential({ id: 'selected-id' }) const { container } = render( - <Item - credential={credential} - showSelectedIcon={true} - selectedCredentialId="selected-id" - />, + <Item credential={credential} showSelectedIcon={true} selectedCredentialId="selected-id" />, ) expect(container.querySelector('.i-ri-check-line')).toBeInTheDocument() @@ -138,11 +134,7 @@ describe('Item Component', () => { cleanup() const { container: unselectedContainer } = render( - <Item - credential={credential} - showSelectedIcon={true} - selectedCredentialId="other-id" - />, + <Item credential={credential} showSelectedIcon={true} selectedCredentialId="other-id" />, ) const unselectedIcon = unselectedContainer.querySelector('.i-ri-check-line') @@ -161,7 +153,9 @@ describe('Item Component', () => { const onItemClick = vi.fn() const credential = createCredential() - const { container } = render(<Item credential={credential} onItemClick={onItemClick} disabled={true} />) + const { container } = render( + <Item credential={credential} onItemClick={onItemClick} disabled={true} />, + ) fireEvent.click(container.firstElementChild!) @@ -186,9 +180,7 @@ describe('Item Component', () => { const onItemClick = vi.fn() const credential = createCredential({ id: 'click-test-id' }) - const { container } = render( - <Item credential={credential} onItemClick={onItemClick} />, - ) + const { container } = render(<Item credential={credential} onItemClick={onItemClick} />) fireEvent.click(container.firstElementChild!) @@ -199,9 +191,7 @@ describe('Item Component', () => { const onItemClick = vi.fn() const credential = createCredential({ id: '__workspace_default__' }) - const { container } = render( - <Item credential={credential} onItemClick={onItemClick} />, - ) + const { container } = render(<Item credential={credential} onItemClick={onItemClick} />) fireEvent.click(container.firstElementChild!) @@ -384,7 +374,9 @@ describe('Item Component', () => { />, ) - const editButton = container.querySelector('.i-ri-equalizer-2-line')?.closest('button') as HTMLElement + const editButton = container + .querySelector('.i-ri-equalizer-2-line') + ?.closest('button') as HTMLElement fireEvent.click(editButton) expect(onEdit).toHaveBeenCalledWith('edit-test-id', { api_key: 'secret', @@ -448,7 +440,9 @@ describe('Item Component', () => { />, ) - const deleteButton = container.querySelector('.i-ri-delete-bin-line')?.closest('button') as HTMLElement + const deleteButton = container + .querySelector('.i-ri-delete-bin-line') + ?.closest('button') as HTMLElement fireEvent.click(deleteButton) expect(onDelete).toHaveBeenCalledWith('delete-test-id') }) @@ -565,7 +559,9 @@ describe('Item Component', () => { />, ) - const deleteButton = container.querySelector('.i-ri-delete-bin-line')?.closest('button') as HTMLElement + const deleteButton = container + .querySelector('.i-ri-delete-bin-line') + ?.closest('button') as HTMLElement fireEvent.click(deleteButton) expect(onDelete).toHaveBeenCalled() }) diff --git a/web/app/components/plugins/plugin-auth/authorized/index.tsx b/web/app/components/plugins/plugin-auth/authorized/index.tsx index c18eb974d62559..2a1257bc2645cd 100644 --- a/web/app/components/plugins/plugin-auth/authorized/index.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/index.tsx @@ -1,6 +1,4 @@ -import type { - OffsetOptions, -} from '@floating-ui/react' +import type { OffsetOptions } from '@floating-ui/react' import type { Placement } from '@langgenius/dify-ui/popover' import type { Credential, PluginPayload } from '../types' import { @@ -13,19 +11,10 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { toast } from '@langgenius/dify-ui/toast' -import { - memo, - useCallback, - useRef, - useState, -} from 'react' +import { memo, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import Authorize from '../authorize' @@ -82,27 +71,34 @@ const Authorized = ({ const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() const [isLocalOpen, setIsLocalOpen] = useState(false) const mergedIsOpen = isOpen ?? isLocalOpen - const setMergedIsOpen = useCallback((open: boolean) => { - if (onOpenChange) - onOpenChange(open) + const setMergedIsOpen = useCallback( + (open: boolean) => { + if (onOpenChange) onOpenChange(open) - setIsLocalOpen(open) - }, [onOpenChange]) - const oAuthCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.OAUTH2) - const apiKeyCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.API_KEY) + setIsLocalOpen(open) + }, + [onOpenChange], + ) + const oAuthCredentials = credentials.filter( + (credential) => credential.credential_type === CredentialTypeEnum.OAUTH2, + ) + const apiKeyCredentials = credentials.filter( + (credential) => credential.credential_type === CredentialTypeEnum.API_KEY, + ) const pendingOperationCredentialIdRef = useRef<string | null>(null) const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null) const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload) - const openConfirm = useCallback((credentialId?: string) => { - if (!canManageCredential) - return + const openConfirm = useCallback( + (credentialId?: string) => { + if (!canManageCredential) return - setMergedIsOpen(false) - if (credentialId) - pendingOperationCredentialIdRef.current = credentialId + setMergedIsOpen(false) + if (credentialId) pendingOperationCredentialIdRef.current = credentialId - setDeleteCredentialId(pendingOperationCredentialIdRef.current) - }, [canManageCredential, setMergedIsOpen]) + setDeleteCredentialId(pendingOperationCredentialIdRef.current) + }, + [canManageCredential, setMergedIsOpen], + ) const closeConfirm = useCallback(() => { setDeleteCredentialId(null) pendingOperationCredentialIdRef.current = null @@ -114,8 +110,7 @@ const Authorized = ({ setDoingAction(doing) }, []) const handleConfirm = useCallback(async () => { - if (doingActionRef.current || !canManageCredential) - return + if (doingActionRef.current || !canManageCredential) return if (!pendingOperationCredentialIdRef.current) { setDeleteCredentialId(null) return @@ -123,125 +118,122 @@ const Authorized = ({ try { handleSetDoingAction(true) await deletePluginCredential({ credential_id: pendingOperationCredentialIdRef.current }) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) onUpdate?.() setDeleteCredentialId(null) pendingOperationCredentialIdRef.current = null - } - finally { + } finally { handleSetDoingAction(false) } }, [canManageCredential, deletePluginCredential, onUpdate, t, handleSetDoingAction]) const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null) const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false) - const handleEdit = useCallback((id: string, values: Record<string, unknown>) => { - if (!canManageCredential) - return + const handleEdit = useCallback( + (id: string, values: Record<string, unknown>) => { + if (!canManageCredential) return - setMergedIsOpen(false) - pendingOperationCredentialIdRef.current = id - setEditValues(values) - setIsApiKeyModalOpen(true) - }, [canManageCredential, setMergedIsOpen]) + setMergedIsOpen(false) + pendingOperationCredentialIdRef.current = id + setEditValues(values) + setIsApiKeyModalOpen(true) + }, + [canManageCredential, setMergedIsOpen], + ) const handleApiKeyModalOpenChange = useCallback((open: boolean) => { setIsApiKeyModalOpen(open) - if (!open) - pendingOperationCredentialIdRef.current = null + if (!open) pendingOperationCredentialIdRef.current = null }, []) // Lifted state for the "+ Add API Key" modal so it isn't unmounted when the // popover closes due to outside-click detection on the modal's portal. const [isAddApiKeyOpen, setIsAddApiKeyOpen] = useState(false) const handleAddApiKeyClick = useCallback(() => { - if (!canCreateCredential) - return + if (!canCreateCredential) return setMergedIsOpen(false) setIsAddApiKeyOpen(true) }, [canCreateCredential, setMergedIsOpen]) const handleRemove = useCallback(() => { - if (!canManageCredential) - return + if (!canManageCredential) return setDeleteCredentialId(pendingOperationCredentialIdRef.current) }, [canManageCredential]) - const { mutateAsync: setPluginDefaultCredential } = useSetPluginDefaultCredentialHook(pluginPayload) - const handleSetDefault = useCallback(async (id: string) => { - if (doingActionRef.current || !canUseCredential) - return - try { - handleSetDoingAction(true) - await setPluginDefaultCredential(id) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - onUpdate?.() - } - finally { - handleSetDoingAction(false) - } - }, [canUseCredential, setPluginDefaultCredential, onUpdate, t, handleSetDoingAction]) + const { mutateAsync: setPluginDefaultCredential } = + useSetPluginDefaultCredentialHook(pluginPayload) + const handleSetDefault = useCallback( + async (id: string) => { + if (doingActionRef.current || !canUseCredential) return + try { + handleSetDoingAction(true) + await setPluginDefaultCredential(id) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + onUpdate?.() + } finally { + handleSetDoingAction(false) + } + }, + [canUseCredential, setPluginDefaultCredential, onUpdate, t, handleSetDoingAction], + ) const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload) - const handleRename = useCallback(async (payload: { - credential_id: string - name: string - }) => { - if (doingActionRef.current || !canManageCredential) - return - try { - handleSetDoingAction(true) - await updatePluginCredential(payload) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - onUpdate?.() - } - finally { - handleSetDoingAction(false) - } - }, [canManageCredential, updatePluginCredential, t, handleSetDoingAction, onUpdate]) - const unavailableCredentials = credentials.filter(credential => credential.not_allowed_to_use) - const unavailableCredential = credentials.find(credential => credential.not_allowed_to_use && credential.is_default) - const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset - const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0 - const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0 + const handleRename = useCallback( + async (payload: { credential_id: string; name: string }) => { + if (doingActionRef.current || !canManageCredential) return + try { + handleSetDoingAction(true) + await updatePluginCredential(payload) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + onUpdate?.() + } finally { + handleSetDoingAction(false) + } + }, + [canManageCredential, updatePluginCredential, t, handleSetDoingAction, onUpdate], + ) + const unavailableCredentials = credentials.filter((credential) => credential.not_allowed_to_use) + const unavailableCredential = credentials.find( + (credential) => credential.not_allowed_to_use && credential.is_default, + ) + const resolvedOffset = + typeof offset === 'number' || typeof offset === 'function' ? undefined : offset + const sideOffset = typeof offset === 'number' ? offset : (resolvedOffset?.mainAxis ?? 0) + const alignOffset = + typeof offset === 'number' + ? 0 + : (resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0) const popupProps = triggerPopupSameWidth ? { style: { width: 'var(--anchor-width, auto)' } } : undefined return ( <> - <Popover - open={mergedIsOpen} - onOpenChange={setMergedIsOpen} - > + <Popover open={mergedIsOpen} onOpenChange={setMergedIsOpen}> <PopoverTrigger - render={( + render={ <div className={triggerPopupSameWidth ? 'w-full' : 'inline-block'}> - { - renderTrigger - ? renderTrigger(mergedIsOpen) - : ( - <Button - className={cn( - 'w-full', - mergedIsOpen && 'bg-components-button-secondary-bg-hover', - )} - > - <StatusDot className="mr-2" status={unavailableCredential ? 'disabled' : 'success'} /> - {credentials.length} -  - { - credentials.length > 1 - ? t($ => $['auth.authorizations'], { ns: 'plugin' }) - : t($ => $['auth.authorization'], { ns: 'plugin' }) - } - { - !!unavailableCredentials.length && ( - ` (${unavailableCredentials.length} ${t($ => $['auth.unavailable'], { ns: 'plugin' })})` - ) - } - <span className="ml-0.5 i-ri-arrow-down-s-line size-4" /> - </Button> - ) - } + {renderTrigger ? ( + renderTrigger(mergedIsOpen) + ) : ( + <Button + className={cn( + 'w-full', + mergedIsOpen && 'bg-components-button-secondary-bg-hover', + )} + > + <StatusDot + className="mr-2" + status={unavailableCredential ? 'disabled' : 'success'} + /> + {credentials.length} +   + {credentials.length > 1 + ? t(($) => $['auth.authorizations'], { ns: 'plugin' }) + : t(($) => $['auth.authorization'], { ns: 'plugin' })} + {!!unavailableCredentials.length && + ` (${unavailableCredentials.length} ${t(($) => $['auth.unavailable'], { ns: 'plugin' })})`} + <span className="ml-0.5 i-ri-arrow-down-s-line size-4" /> + </Button> + )} </div> - )} + } /> <PopoverContent placement={placement} @@ -250,155 +242,145 @@ const Authorized = ({ popupProps={popupProps} popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none" > - <div className={cn( - 'max-h-[360px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg', - popupClassName, - )} + <div + className={cn( + 'max-h-[360px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg', + popupClassName, + )} > <div className="py-1"> - { - !!extraAuthorizationItems?.length && ( - <div className="p-1"> - { - extraAuthorizationItems.map(credential => ( - <Item - key={credential.id} - credential={credential} - onItemClick={onItemClick} - disableRename - disableEdit - disableDelete - disableSetDefault - showSelectedIcon={showItemSelectedIcon} - selectedCredentialId={selectedCredentialId} - /> - )) - } - </div> - ) - } - { - !!oAuthCredentials.length && ( - <div className="p-1"> - <div className={cn( + {!!extraAuthorizationItems?.length && ( + <div className="p-1"> + {extraAuthorizationItems.map((credential) => ( + <Item + key={credential.id} + credential={credential} + onItemClick={onItemClick} + disableRename + disableEdit + disableDelete + disableSetDefault + showSelectedIcon={showItemSelectedIcon} + selectedCredentialId={selectedCredentialId} + /> + ))} + </div> + )} + {!!oAuthCredentials.length && ( + <div className="p-1"> + <div + className={cn( 'px-3 pt-1 pb-0.5 system-xs-medium text-text-tertiary', showItemSelectedIcon && 'pl-7', )} - > - OAuth - </div> - { - oAuthCredentials.map(credential => ( - <Item - key={credential.id} - credential={credential} - disableEdit - onDelete={openConfirm} - onSetDefault={handleSetDefault} - onRename={handleRename} - disableSetDefault={disableSetDefault} - onItemClick={onItemClick} - showSelectedIcon={showItemSelectedIcon} - selectedCredentialId={selectedCredentialId} - /> - )) - } + > + OAuth </div> - ) - } - { - !!apiKeyCredentials.length && ( - <div className="p-1"> - <div className={cn( + {oAuthCredentials.map((credential) => ( + <Item + key={credential.id} + credential={credential} + disableEdit + onDelete={openConfirm} + onSetDefault={handleSetDefault} + onRename={handleRename} + disableSetDefault={disableSetDefault} + onItemClick={onItemClick} + showSelectedIcon={showItemSelectedIcon} + selectedCredentialId={selectedCredentialId} + /> + ))} + </div> + )} + {!!apiKeyCredentials.length && ( + <div className="p-1"> + <div + className={cn( 'px-3 pt-1 pb-0.5 system-xs-medium text-text-tertiary', showItemSelectedIcon && 'pl-7', )} - > - API Keys - </div> - { - apiKeyCredentials.map(credential => ( - <Item - key={credential.id} - credential={credential} - onDelete={openConfirm} - onEdit={handleEdit} - onSetDefault={handleSetDefault} - disableSetDefault={disableSetDefault} - disableRename - onItemClick={onItemClick} - onRename={handleRename} - showSelectedIcon={showItemSelectedIcon} - selectedCredentialId={selectedCredentialId} - /> - )) - } + > + API Keys </div> - ) - } - </div> - { - !notAllowCustomCredential && ( - <> - <div className="h-px bg-divider-subtle"></div> - <div className="p-2"> - <Authorize - pluginPayload={pluginPayload} - theme="secondary" - showDivider={false} - canOAuth={canOAuth} - canApiKey={canApiKey} - onUpdate={onUpdate} - onApiKeyClick={handleAddApiKeyClick} + {apiKeyCredentials.map((credential) => ( + <Item + key={credential.id} + credential={credential} + onDelete={openConfirm} + onEdit={handleEdit} + onSetDefault={handleSetDefault} + disableSetDefault={disableSetDefault} + disableRename + onItemClick={onItemClick} + onRename={handleRename} + showSelectedIcon={showItemSelectedIcon} + selectedCredentialId={selectedCredentialId} /> - </div> - </> - ) - } + ))} + </div> + )} + </div> + {!notAllowCustomCredential && ( + <> + <div className="h-px bg-divider-subtle"></div> + <div className="p-2"> + <Authorize + pluginPayload={pluginPayload} + theme="secondary" + showDivider={false} + canOAuth={canOAuth} + canApiKey={canApiKey} + onUpdate={onUpdate} + onApiKeyClick={handleAddApiKeyClick} + /> + </div> + </> + )} </div> </PopoverContent> </Popover> - <AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirm()}> + <AlertDialog open={!!deleteCredentialId} onOpenChange={(open) => !open && closeConfirm()}> <AlertDialogContent backdropProps={{ forceRender: true }}> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['list.delete.title'], { ns: 'datasetDocuments' })} + {t(($) => $['list.delete.title'], { ns: 'datasetDocuments' })} </AlertDialogTitle> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton disabled={!canManageCredential || doingAction} onClick={handleConfirm}> - {t($ => $['operation.confirm'], { ns: 'common' })} + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton + disabled={!canManageCredential || doingAction} + onClick={handleConfirm} + > + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> </AlertDialog> - { - !!editValues && ( - <ApiKeyModal - open={isApiKeyModalOpen} - onOpenChange={handleApiKeyModalOpenChange} - pluginPayload={pluginPayload} - editValues={editValues} - onClose={() => handleApiKeyModalOpenChange(false)} - onRemove={handleRemove} - disabled={!canManageCredential || doingAction} - onUpdate={onUpdate} - /> - ) - } - { - isAddApiKeyOpen && ( - <ApiKeyModal - open={isAddApiKeyOpen} - onOpenChange={setIsAddApiKeyOpen} - pluginPayload={pluginPayload} - onClose={() => setIsAddApiKeyOpen(false)} - disabled={!canCreateCredential || doingAction} - onUpdate={onUpdate} - /> - ) - } + {!!editValues && ( + <ApiKeyModal + open={isApiKeyModalOpen} + onOpenChange={handleApiKeyModalOpenChange} + pluginPayload={pluginPayload} + editValues={editValues} + onClose={() => handleApiKeyModalOpenChange(false)} + onRemove={handleRemove} + disabled={!canManageCredential || doingAction} + onUpdate={onUpdate} + /> + )} + {isAddApiKeyOpen && ( + <ApiKeyModal + open={isAddApiKeyOpen} + onOpenChange={setIsAddApiKeyOpen} + pluginPayload={pluginPayload} + onClose={() => setIsAddApiKeyOpen(false)} + disabled={!canCreateCredential || doingAction} + onUpdate={onUpdate} + /> + )} </> ) } diff --git a/web/app/components/plugins/plugin-auth/authorized/item.tsx b/web/app/components/plugins/plugin-auth/authorized/item.tsx index 6186ccb12c2b46..f36119c14fa5f6 100644 --- a/web/app/components/plugins/plugin-auth/authorized/item.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/item.tsx @@ -3,15 +3,9 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiInformationLine, -} from '@remixicon/react' +import { RiInformationLine } from '@remixicon/react' import { useAtomValue } from 'jotai' -import { - memo, - useMemo, - useState, -} from 'react' +import { memo, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge' @@ -25,10 +19,7 @@ type ItemProps = { onDelete?: (id: string) => void onEdit?: (id: string, values: Record<string, unknown>) => void onSetDefault?: (id: string) => void - onRename?: (payload: { - credential_id: string - name: string - }) => void + onRename?: (payload: { credential_id: string; name: string }) => void disableRename?: boolean disableEdit?: boolean disableDelete?: boolean @@ -65,10 +56,10 @@ const Item = ({ // Fallback heuristic (created_by mismatch on a selected row) is kept for backends // that don't yet emit the flag. const isSelected = showSelectedIcon && selectedCredentialId === credential.id - const isConfiguredByOther - = !!credential.created_by && !!currentUserId && credential.created_by !== currentUserId - const isBorrowed - = !!credential.from_other_member || (isSelected && isConfiguredByOther && isPersonal) + const isConfiguredByOther = + !!credential.created_by && !!currentUserId && credential.created_by !== currentUserId + const isBorrowed = + !!credential.from_other_member || (isSelected && isConfiguredByOther && isPersonal) const showSwitchAwayHint = isBorrowed const showAction = useMemo(() => { return !(disableRename && disableEdit && disableDelete && disableSetDefault) @@ -80,203 +71,173 @@ const Item = ({ className={cn( 'group flex h-8 items-center rounded-lg p-1 hover:bg-state-base-hover', renaming && 'bg-state-base-hover', - (disabled || !canUseCredential || credential.not_allowed_to_use) && 'cursor-not-allowed opacity-50', + (disabled || !canUseCredential || credential.not_allowed_to_use) && + 'cursor-not-allowed opacity-50', )} onClick={() => { - if (disabled || credential.not_allowed_to_use || !canUseCredential) - return + if (disabled || credential.not_allowed_to_use || !canUseCredential) return onItemClick?.(credential.id === '__workspace_default__' ? '' : credential.id) }} > - { - renaming && ( - <div className="flex w-full items-center space-x-1"> - <Input - wrapperClassName="grow rounded-md" - className="h-6" - value={renameValue} - onChange={e => setRenameValue(e.target.value)} - placeholder={t($ => $['placeholder.input'], { ns: 'common' })} - onClick={e => e.stopPropagation()} - /> - <Button - size="small" - variant="primary" - onClick={(e) => { - e.stopPropagation() - onRename?.({ - credential_id: credential.id, - name: renameValue, - }) - setRenaming(false) - }} - > - {t($ => $['operation.save'], { ns: 'common' })} - </Button> - <Button - size="small" - onClick={(e) => { - e.stopPropagation() - setRenaming(false) - }} - > - {t($ => $['operation.cancel'], { ns: 'common' })} - </Button> - </div> - ) - } - { - !renaming && ( - <div className="flex w-0 grow items-center space-x-1.5"> - { - showSelectedIcon && ( - <div className="size-4"> - { - selectedCredentialId === credential.id && ( - <span className="i-ri-check-line size-4 text-text-accent" /> - ) - } - </div> - ) - } - <StatusDot - className="mr-1.5 ml-2 shrink-0" - status={credential.not_allowed_to_use ? 'disabled' : 'success'} - /> - <div - className="truncate system-md-regular text-text-secondary" - title={credential.name} - > - {credential.name} + {renaming && ( + <div className="flex w-full items-center space-x-1"> + <Input + wrapperClassName="grow rounded-md" + className="h-6" + value={renameValue} + onChange={(e) => setRenameValue(e.target.value)} + placeholder={t(($) => $['placeholder.input'], { ns: 'common' })} + onClick={(e) => e.stopPropagation()} + /> + <Button + size="small" + variant="primary" + onClick={(e) => { + e.stopPropagation() + onRename?.({ + credential_id: credential.id, + name: renameValue, + }) + setRenaming(false) + }} + > + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> + <Button + size="small" + onClick={(e) => { + e.stopPropagation() + setRenaming(false) + }} + > + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + </div> + )} + {!renaming && ( + <div className="flex w-0 grow items-center space-x-1.5"> + {showSelectedIcon && ( + <div className="size-4"> + {selectedCredentialId === credential.id && ( + <span className="i-ri-check-line size-4 text-text-accent" /> + )} </div> - { - credential.is_default && ( - <Badge className="shrink-0"> - {t($ => $['auth.default'], { ns: 'plugin' })} - </Badge> - ) - } + )} + <StatusDot + className="mr-1.5 ml-2 shrink-0" + status={credential.not_allowed_to_use ? 'disabled' : 'success'} + /> + <div className="truncate system-md-regular text-text-secondary" title={credential.name}> + {credential.name} </div> - ) - } - { - showSwitchAwayHint && ( - <Tooltip> - <TooltipTrigger - render={( - <div className="ml-2 flex shrink-0 cursor-help items-center text-text-tertiary"> - <RiInformationLine className="size-4" /> - </div> - )} - /> - <TooltipContent> - {t($ => $['auth.onlyAtCreationHintTooltip'], { ns: 'plugin' })} - </TooltipContent> - </Tooltip> - ) - } - { - !showSwitchAwayHint && credential.from_enterprise && ( - <Badge className="shrink-0"> - {t($ => $['auth.enterprise'], { ns: 'plugin' })} - </Badge> - ) - } - { - showAction && !renaming && ( - <div className="ml-2 hidden shrink-0 items-center group-hover:flex"> - { - !credential.is_default && !disableSetDefault && !credential.not_allowed_to_use && !isBorrowed && ( - <Button - size="small" - disabled={disabled || !canUseCredential} - onClick={(e) => { - e.stopPropagation() - onSetDefault?.(credential.id) - }} - > - {t($ => $['auth.setDefault'], { ns: 'plugin' })} - </Button> - ) - } - { - !disableRename && !credential.from_enterprise && !credential.not_allowed_to_use && !isBorrowed && ( - <Tooltip> - <TooltipTrigger - render={( - <ActionButton - disabled={disabled || !canManageCredential} - onClick={(e) => { - e.stopPropagation() - setRenaming(true) - setRenameValue(credential.name) - }} - > - <span className="i-ri-edit-line size-4 text-text-tertiary" /> - </ActionButton> - )} - /> - <TooltipContent> - {t($ => $['operation.rename'], { ns: 'common' })} - </TooltipContent> - </Tooltip> - ) - } - { - !isOAuth && !disableEdit && !credential.from_enterprise && !credential.not_allowed_to_use && !isBorrowed && ( - <Tooltip> - <TooltipTrigger - render={( - <ActionButton - disabled={disabled || !canManageCredential} - onClick={(e) => { - e.stopPropagation() - onEdit?.( - credential.id, - { - ...credential.credentials, - __name__: credential.name, - __credential_id__: credential.id, - }, - ) - }} - > - <span className="i-ri-equalizer-2-line size-4 text-text-tertiary" /> - </ActionButton> - )} - /> - <TooltipContent> - {t($ => $['operation.edit'], { ns: 'common' })} - </TooltipContent> - </Tooltip> - ) - } - { - !disableDelete && !credential.from_enterprise && !isBorrowed && ( - <Tooltip> - <TooltipTrigger - render={( - <ActionButton - className="hover:bg-transparent" - disabled={disabled || !canManageCredential} - onClick={(e) => { - e.stopPropagation() - onDelete?.(credential.id) - }} - > - <span className="i-ri-delete-bin-line size-4 text-text-tertiary hover:text-text-destructive" /> - </ActionButton> - )} - /> - <TooltipContent> - {t($ => $['operation.delete'], { ns: 'common' })} - </TooltipContent> - </Tooltip> - ) + {credential.is_default && ( + <Badge className="shrink-0">{t(($) => $['auth.default'], { ns: 'plugin' })}</Badge> + )} + </div> + )} + {showSwitchAwayHint && ( + <Tooltip> + <TooltipTrigger + render={ + <div className="ml-2 flex shrink-0 cursor-help items-center text-text-tertiary"> + <RiInformationLine className="size-4" /> + </div> } - </div> - ) - } + /> + <TooltipContent> + {t(($) => $['auth.onlyAtCreationHintTooltip'], { ns: 'plugin' })} + </TooltipContent> + </Tooltip> + )} + {!showSwitchAwayHint && credential.from_enterprise && ( + <Badge className="shrink-0">{t(($) => $['auth.enterprise'], { ns: 'plugin' })}</Badge> + )} + {showAction && !renaming && ( + <div className="ml-2 hidden shrink-0 items-center group-hover:flex"> + {!credential.is_default && + !disableSetDefault && + !credential.not_allowed_to_use && + !isBorrowed && ( + <Button + size="small" + disabled={disabled || !canUseCredential} + onClick={(e) => { + e.stopPropagation() + onSetDefault?.(credential.id) + }} + > + {t(($) => $['auth.setDefault'], { ns: 'plugin' })} + </Button> + )} + {!disableRename && + !credential.from_enterprise && + !credential.not_allowed_to_use && + !isBorrowed && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton + disabled={disabled || !canManageCredential} + onClick={(e) => { + e.stopPropagation() + setRenaming(true) + setRenameValue(credential.name) + }} + > + <span className="i-ri-edit-line size-4 text-text-tertiary" /> + </ActionButton> + } + /> + <TooltipContent>{t(($) => $['operation.rename'], { ns: 'common' })}</TooltipContent> + </Tooltip> + )} + {!isOAuth && + !disableEdit && + !credential.from_enterprise && + !credential.not_allowed_to_use && + !isBorrowed && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton + disabled={disabled || !canManageCredential} + onClick={(e) => { + e.stopPropagation() + onEdit?.(credential.id, { + ...credential.credentials, + __name__: credential.name, + __credential_id__: credential.id, + }) + }} + > + <span className="i-ri-equalizer-2-line size-4 text-text-tertiary" /> + </ActionButton> + } + /> + <TooltipContent>{t(($) => $['operation.edit'], { ns: 'common' })}</TooltipContent> + </Tooltip> + )} + {!disableDelete && !credential.from_enterprise && !isBorrowed && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton + className="hover:bg-transparent" + disabled={disabled || !canManageCredential} + onClick={(e) => { + e.stopPropagation() + onDelete?.(credential.id) + }} + > + <span className="i-ri-delete-bin-line size-4 text-text-tertiary hover:text-text-destructive" /> + </ActionButton> + } + /> + <TooltipContent>{t(($) => $['operation.delete'], { ns: 'common' })}</TooltipContent> + </Tooltip> + )} + </div> + )} </div> ) @@ -285,15 +246,13 @@ const Item = ({ <Tooltip> <TooltipTrigger render={CredentialItem} /> <TooltipContent> - {t($ => $['auth.customCredentialUnavailable'], { ns: 'plugin' })} + {t(($) => $['auth.customCredentialUnavailable'], { ns: 'plugin' })} </TooltipContent> </Tooltip> ) } - return ( - CredentialItem - ) + return CredentialItem } export default memo(Item) diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts index e0e241cbbed247..83b8ab024b5a25 100644 --- a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts @@ -34,16 +34,19 @@ const mockInvalidToolsByType = vi.fn() vi.mock('@/service/use-plugins-auth', () => ({ useGetPluginCredentialInfo: (...args: unknown[]) => mockUseGetPluginCredentialInfo(...args), useDeletePluginCredential: (...args: unknown[]) => mockUseDeletePluginCredential(...args), - useInvalidPluginCredentialInfo: (...args: unknown[]) => mockUseInvalidPluginCredentialInfo(...args), + useInvalidPluginCredentialInfo: (...args: unknown[]) => + mockUseInvalidPluginCredentialInfo(...args), useSetPluginDefaultCredential: (...args: unknown[]) => mockUseSetPluginDefaultCredential(...args), useGetPluginCredentialSchema: (...args: unknown[]) => mockUseGetPluginCredentialSchema(...args), useAddPluginCredential: (...args: unknown[]) => mockUseAddPluginCredential(...args), useUpdatePluginCredential: (...args: unknown[]) => mockUseUpdatePluginCredential(...args), useGetPluginOAuthUrl: (...args: unknown[]) => mockUseGetPluginOAuthUrl(...args), useGetPluginOAuthClientSchema: (...args: unknown[]) => mockUseGetPluginOAuthClientSchema(...args), - useInvalidPluginOAuthClientSchema: (...args: unknown[]) => mockUseInvalidPluginOAuthClientSchema(...args), + useInvalidPluginOAuthClientSchema: (...args: unknown[]) => + mockUseInvalidPluginOAuthClientSchema(...args), useSetPluginOAuthCustomClient: (...args: unknown[]) => mockUseSetPluginOAuthCustomClient(...args), - useDeletePluginOAuthCustomClient: (...args: unknown[]) => mockUseDeletePluginOAuthCustomClient(...args), + useDeletePluginOAuthCustomClient: (...args: unknown[]) => + mockUseDeletePluginOAuthCustomClient(...args), })) vi.mock('@/service/use-tools', () => ({ diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-get-api.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-get-api.spec.ts index 6b1063dce5a9ba..e3ba11700656e0 100644 --- a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-get-api.spec.ts +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-get-api.spec.ts @@ -8,12 +8,22 @@ describe('useGetApi', () => { describe('tool category', () => { it('returns correct API paths for tool category', () => { const api = useGetApi({ category: AuthCategory.tool, provider }) - expect(api.getCredentialInfo).toBe(`/workspaces/current/tool-provider/builtin/${provider}/credential/info`) - expect(api.setDefaultCredential).toBe(`/workspaces/current/tool-provider/builtin/${provider}/default-credential`) - expect(api.getCredentials).toBe(`/workspaces/current/tool-provider/builtin/${provider}/credentials`) + expect(api.getCredentialInfo).toBe( + `/workspaces/current/tool-provider/builtin/${provider}/credential/info`, + ) + expect(api.setDefaultCredential).toBe( + `/workspaces/current/tool-provider/builtin/${provider}/default-credential`, + ) + expect(api.getCredentials).toBe( + `/workspaces/current/tool-provider/builtin/${provider}/credentials`, + ) expect(api.addCredential).toBe(`/workspaces/current/tool-provider/builtin/${provider}/add`) - expect(api.updateCredential).toBe(`/workspaces/current/tool-provider/builtin/${provider}/update`) - expect(api.deleteCredential).toBe(`/workspaces/current/tool-provider/builtin/${provider}/delete`) + expect(api.updateCredential).toBe( + `/workspaces/current/tool-provider/builtin/${provider}/update`, + ) + expect(api.deleteCredential).toBe( + `/workspaces/current/tool-provider/builtin/${provider}/delete`, + ) expect(api.getOauthUrl).toBe(`/oauth/plugin/${provider}/tool/authorization-url`) }) @@ -21,13 +31,19 @@ describe('useGetApi', () => { const api = useGetApi({ category: AuthCategory.tool, provider }) expect(typeof api.getCredentialSchema).toBe('function') const schemaUrl = api.getCredentialSchema('api-key' as never) - expect(schemaUrl).toBe(`/workspaces/current/tool-provider/builtin/${provider}/credential/schema/api-key`) + expect(schemaUrl).toBe( + `/workspaces/current/tool-provider/builtin/${provider}/credential/schema/api-key`, + ) }) it('includes OAuth client endpoints', () => { const api = useGetApi({ category: AuthCategory.tool, provider }) - expect(api.getOauthClientSchema).toBe(`/workspaces/current/tool-provider/builtin/${provider}/oauth/client-schema`) - expect(api.setCustomOauthClient).toBe(`/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`) + expect(api.getOauthClientSchema).toBe( + `/workspaces/current/tool-provider/builtin/${provider}/oauth/client-schema`, + ) + expect(api.setCustomOauthClient).toBe( + `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`, + ) }) }) @@ -73,7 +89,7 @@ describe('useGetApi', () => { describe('default category', () => { it('defaults to tool category when category is not specified', () => { - const api = useGetApi({ provider } as { category: AuthCategory, provider: string }) + const api = useGetApi({ provider } as { category: AuthCategory; provider: string }) expect(api.getCredentialInfo).toContain('tool-provider') }) }) diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts index 06af4a88ee4f8e..f33a2108371f4c 100644 --- a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts @@ -18,10 +18,18 @@ const toastMocks = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { - success: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'success', message, ...options })), - error: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'error', message, ...options })), - warning: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'warning', message, ...options })), - info: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'info', message, ...options })), + success: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'success', message, ...options }), + ), + error: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'info', message, ...options }), + ), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, diff --git a/web/app/components/plugins/plugin-auth/hooks/use-credential.ts b/web/app/components/plugins/plugin-auth/hooks/use-credential.ts index cceb3b10537902..3c82daf5141ba1 100644 --- a/web/app/components/plugins/plugin-auth/hooks/use-credential.ts +++ b/web/app/components/plugins/plugin-auth/hooks/use-credential.ts @@ -26,8 +26,7 @@ export const useGetPluginCredentialInfoHook = ( let url = enable ? apiMap.getCredentialInfo : '' if (url && ids.length > 0) { const qs = new URLSearchParams() - for (const id of ids) - qs.append('include_credential_ids', id) + for (const id of ids) qs.append('include_credential_ids', id) url = url + (url.includes('?') ? '&' : '?') + qs.toString() } return useGetPluginCredentialInfo(url) @@ -57,7 +56,10 @@ export const useSetPluginDefaultCredentialHook = (pluginPayload: PluginPayload) return useSetPluginDefaultCredential(apiMap.setDefaultCredential) } -export const useGetPluginCredentialSchemaHook = (pluginPayload: PluginPayload, credentialType: CredentialTypeEnum) => { +export const useGetPluginCredentialSchemaHook = ( + pluginPayload: PluginPayload, + credentialType: CredentialTypeEnum, +) => { const apiMap = useGetApi(pluginPayload) return useGetPluginCredentialSchema(apiMap.getCredentialSchema(credentialType)) diff --git a/web/app/components/plugins/plugin-auth/hooks/use-get-api.ts b/web/app/components/plugins/plugin-auth/hooks/use-get-api.ts index 0e36e0049b0b87..0eb18ebdd13f82 100644 --- a/web/app/components/plugins/plugin-auth/hooks/use-get-api.ts +++ b/web/app/components/plugins/plugin-auth/hooks/use-get-api.ts @@ -1,10 +1,5 @@ -import type { - CredentialTypeEnum, - PluginPayload, -} from '../types' -import { - AuthCategory, -} from '../types' +import type { CredentialTypeEnum, PluginPayload } from '../types' +import { AuthCategory } from '../types' export const useGetApi = ({ category = AuthCategory.tool, provider }: PluginPayload) => { if (category === AuthCategory.tool) { @@ -15,7 +10,8 @@ export const useGetApi = ({ category = AuthCategory.tool, provider }: PluginPayl addCredential: `/workspaces/current/tool-provider/builtin/${provider}/add`, updateCredential: `/workspaces/current/tool-provider/builtin/${provider}/update`, deleteCredential: `/workspaces/current/tool-provider/builtin/${provider}/delete`, - getCredentialSchema: (credential_type: CredentialTypeEnum) => `/workspaces/current/tool-provider/builtin/${provider}/credential/schema/${credential_type}`, + getCredentialSchema: (credential_type: CredentialTypeEnum) => + `/workspaces/current/tool-provider/builtin/${provider}/credential/schema/${credential_type}`, getOauthUrl: `/oauth/plugin/${provider}/tool/authorization-url`, getOauthClientSchema: `/workspaces/current/tool-provider/builtin/${provider}/oauth/client-schema`, setCustomOauthClient: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`, diff --git a/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth-action.ts b/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth-action.ts index 776b1fbdd46d31..f62ba05eb3515a 100644 --- a/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth-action.ts +++ b/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth-action.ts @@ -1,10 +1,6 @@ import type { PluginPayload } from '../types' import { toast } from '@langgenius/dify-ui/toast' -import { - useCallback, - useRef, - useState, -} from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDeletePluginCredentialHook, @@ -12,17 +8,13 @@ import { useUpdatePluginCredentialHook, } from '../hooks/use-credential' -export const usePluginAuthAction = ( - pluginPayload: PluginPayload, - onUpdate?: () => void, -) => { +export const usePluginAuthAction = (pluginPayload: PluginPayload, onUpdate?: () => void) => { const { t } = useTranslation() const pendingOperationCredentialId = useRef<string | null>(null) const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null) const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload) const openConfirm = useCallback((credentialId?: string) => { - if (credentialId) - pendingOperationCredentialId.current = credentialId + if (credentialId) pendingOperationCredentialId.current = credentialId setDeleteCredentialId(pendingOperationCredentialId.current) }, []) @@ -38,8 +30,7 @@ export const usePluginAuthAction = ( }, []) const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null) const handleConfirm = useCallback(async () => { - if (doingActionRef.current) - return + if (doingActionRef.current) return if (!pendingOperationCredentialId.current) { setDeleteCredentialId(null) return @@ -47,13 +38,12 @@ export const usePluginAuthAction = ( try { handleSetDoingAction(true) await deletePluginCredential({ credential_id: pendingOperationCredentialId.current }) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) onUpdate?.() setDeleteCredentialId(null) pendingOperationCredentialId.current = null setEditValues(null) - } - finally { + } finally { handleSetDoingAction(false) } }, [deletePluginCredential, onUpdate, t, handleSetDoingAction]) @@ -64,37 +54,37 @@ export const usePluginAuthAction = ( const handleRemove = useCallback(() => { setDeleteCredentialId(pendingOperationCredentialId.current) }, []) - const { mutateAsync: setPluginDefaultCredential } = useSetPluginDefaultCredentialHook(pluginPayload) - const handleSetDefault = useCallback(async (id: string) => { - if (doingActionRef.current) - return - try { - handleSetDoingAction(true) - await setPluginDefaultCredential(id) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - onUpdate?.() - } - finally { - handleSetDoingAction(false) - } - }, [setPluginDefaultCredential, onUpdate, t, handleSetDoingAction]) + const { mutateAsync: setPluginDefaultCredential } = + useSetPluginDefaultCredentialHook(pluginPayload) + const handleSetDefault = useCallback( + async (id: string) => { + if (doingActionRef.current) return + try { + handleSetDoingAction(true) + await setPluginDefaultCredential(id) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + onUpdate?.() + } finally { + handleSetDoingAction(false) + } + }, + [setPluginDefaultCredential, onUpdate, t, handleSetDoingAction], + ) const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload) - const handleRename = useCallback(async (payload: { - credential_id: string - name: string - }) => { - if (doingActionRef.current) - return - try { - handleSetDoingAction(true) - await updatePluginCredential(payload) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - onUpdate?.() - } - finally { - handleSetDoingAction(false) - } - }, [updatePluginCredential, t, handleSetDoingAction, onUpdate]) + const handleRename = useCallback( + async (payload: { credential_id: string; name: string }) => { + if (doingActionRef.current) return + try { + handleSetDoingAction(true) + await updatePluginCredential(payload) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + onUpdate?.() + } finally { + handleSetDoingAction(false) + } + }, + [updatePluginCredential, t, handleSetDoingAction, onUpdate], + ) return { doingAction, diff --git a/web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx b/web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx index 9077f59fed304e..7b33b71dcc76e5 100644 --- a/web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx +++ b/web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx @@ -1,17 +1,10 @@ import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot' -import type { - Credential, - PluginPayload, -} from './types' +import type { Credential, PluginPayload } from './types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { RiArrowDownSLine } from '@remixicon/react' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Authorize from './authorize' import Authorized from './authorized' @@ -41,93 +34,84 @@ const PluginAuthInAgent = ({ const extraAuthorizationItems: Credential[] = [ { id: '__workspace_default__', - name: t($ => $['auth.workspaceDefault'], { ns: 'plugin' }), + name: t(($) => $['auth.workspaceDefault'], { ns: 'plugin' }), provider: '', is_default: !credentialId, isWorkspaceDefault: true, }, ] - const handleAuthorizationItemClick = useCallback((id: string) => { - onAuthorizationItemClick?.(id) - setIsOpen(false) - }, [ - onAuthorizationItemClick, - setIsOpen, - ]) + const handleAuthorizationItemClick = useCallback( + (id: string) => { + onAuthorizationItemClick?.(id) + setIsOpen(false) + }, + [onAuthorizationItemClick, setIsOpen], + ) - const renderTrigger = useCallback((isOpen?: boolean) => { - let label = '' - let removed = false - let unavailable = false - let color: StatusDotStatus = 'success' - if (!credentialId) { - label = t($ => $['auth.workspaceDefault'], { ns: 'plugin' }) - } - else { - const credential = credentials.find(c => c.id === credentialId) - label = credential ? credential.name : t($ => $['auth.authRemoved'], { ns: 'plugin' }) - removed = !credential - unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise - if (removed) - color = 'error' - else if (unavailable) - color = 'disabled' - } - return ( - <Button - className={cn( - 'w-full', - isOpen && 'bg-components-button-secondary-bg-hover', - removed && 'text-text-destructive', - )} - > - <StatusDot - className="mr-2" - status={color} - /> - {label} - { - unavailable && t($ => $['auth.unavailable'], { ns: 'plugin' }) - } - <RiArrowDownSLine className="ml-0.5 size-4" /> - </Button> - ) - }, [credentialId, credentials, t]) + const renderTrigger = useCallback( + (isOpen?: boolean) => { + let label = '' + let removed = false + let unavailable = false + let color: StatusDotStatus = 'success' + if (!credentialId) { + label = t(($) => $['auth.workspaceDefault'], { ns: 'plugin' }) + } else { + const credential = credentials.find((c) => c.id === credentialId) + label = credential ? credential.name : t(($) => $['auth.authRemoved'], { ns: 'plugin' }) + removed = !credential + unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise + if (removed) color = 'error' + else if (unavailable) color = 'disabled' + } + return ( + <Button + className={cn( + 'w-full', + isOpen && 'bg-components-button-secondary-bg-hover', + removed && 'text-text-destructive', + )} + > + <StatusDot className="mr-2" status={color} /> + {label} + {unavailable && t(($) => $['auth.unavailable'], { ns: 'plugin' })} + <RiArrowDownSLine className="ml-0.5 size-4" /> + </Button> + ) + }, + [credentialId, credentials, t], + ) return ( <> - { - !isAuthorized && ( - <Authorize - pluginPayload={pluginPayload} - canOAuth={canOAuth} - canApiKey={canApiKey} - onUpdate={invalidPluginCredentialInfo} - notAllowCustomCredential={notAllowCustomCredential} - /> - ) - } - { - isAuthorized && ( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - canOAuth={canOAuth} - canApiKey={canApiKey} - disableSetDefault - onItemClick={handleAuthorizationItemClick} - extraAuthorizationItems={extraAuthorizationItems} - showItemSelectedIcon - renderTrigger={renderTrigger} - isOpen={isOpen} - onOpenChange={setIsOpen} - selectedCredentialId={credentialId || '__workspace_default__'} - onUpdate={invalidPluginCredentialInfo} - notAllowCustomCredential={notAllowCustomCredential} - /> - ) - } + {!isAuthorized && ( + <Authorize + pluginPayload={pluginPayload} + canOAuth={canOAuth} + canApiKey={canApiKey} + onUpdate={invalidPluginCredentialInfo} + notAllowCustomCredential={notAllowCustomCredential} + /> + )} + {isAuthorized && ( + <Authorized + pluginPayload={pluginPayload} + credentials={credentials} + canOAuth={canOAuth} + canApiKey={canApiKey} + disableSetDefault + onItemClick={handleAuthorizationItemClick} + extraAuthorizationItems={extraAuthorizationItems} + showItemSelectedIcon + renderTrigger={renderTrigger} + isOpen={isOpen} + onOpenChange={setIsOpen} + selectedCredentialId={credentialId || '__workspace_default__'} + onUpdate={invalidPluginCredentialInfo} + notAllowCustomCredential={notAllowCustomCredential} + /> + )} </> ) } diff --git a/web/app/components/plugins/plugin-auth/plugin-auth-in-datasource-node.tsx b/web/app/components/plugins/plugin-auth/plugin-auth-in-datasource-node.tsx index b0d93ebf383364..a2fd4f8a4f1ef5 100644 --- a/web/app/components/plugins/plugin-auth/plugin-auth-in-datasource-node.tsx +++ b/web/app/components/plugins/plugin-auth/plugin-auth-in-datasource-node.tsx @@ -17,20 +17,14 @@ const PluginAuthInDataSourceNode = ({ const { t } = useTranslation() return ( <> - { - !isAuthorized && ( - <div className="px-4 pb-2"> - <Button - className="w-full" - variant="primary" - onClick={onJumpToDataSourcePage} - > - <RiAddLine className="mr-1 size-4" /> - {t($ => $['integrations.connect'], { ns: 'common' })} - </Button> - </div> - ) - } + {!isAuthorized && ( + <div className="px-4 pb-2"> + <Button className="w-full" variant="primary" onClick={onJumpToDataSourcePage}> + <RiAddLine className="mr-1 size-4" /> + {t(($) => $['integrations.connect'], { ns: 'common' })} + </Button> + </div> + )} {isAuthorized && children} </> ) diff --git a/web/app/components/plugins/plugin-auth/plugin-auth.tsx b/web/app/components/plugins/plugin-auth/plugin-auth.tsx index c74e238ba79395..66441fa2d3368d 100644 --- a/web/app/components/plugins/plugin-auth/plugin-auth.tsx +++ b/web/app/components/plugins/plugin-auth/plugin-auth.tsx @@ -14,11 +14,7 @@ type PluginAuthProps = { children?: React.ReactNode className?: string } -const PluginAuth = ({ - pluginPayload, - children, - className, -}: PluginAuthProps) => { +const PluginAuth = ({ pluginPayload, children, className }: PluginAuthProps) => { const { t } = useTranslation() const { setShowAccountSettingModal } = useModalContext() const { @@ -29,10 +25,11 @@ const PluginAuth = ({ invalidPluginCredentialInfo, notAllowCustomCredential, } = usePluginAuth(pluginPayload, !!pluginPayload.provider) - const showPermissionHint = !isAuthorized - && !notAllowCustomCredential - && pluginPayload.category === AuthCategory.tool - && (canOAuth || canApiKey) + const showPermissionHint = + !isAuthorized && + !notAllowCustomCredential && + pluginPayload.category === AuthCategory.tool && + (canOAuth || canApiKey) const authorizeContent = ( <Authorize pluginPayload={pluginPayload} @@ -45,54 +42,51 @@ const PluginAuth = ({ return ( <div className={cn(!isAuthorized && className)}> - { - !isAuthorized && ( - <> - {authorizeContent} - { - showPermissionHint && ( - <div className="mt-2 rounded-lg border border-divider-subtle bg-background-section-burn px-2.5 py-2"> - <div className="flex items-start gap-2"> - <span aria-hidden className="mt-0.5 i-ri-lock-2-line size-3.5 shrink-0 text-text-tertiary" /> - <div className="min-w-0 grow"> - <div className="system-xs-medium text-text-secondary"> - {t($ => $['auth.permissionHint.title'], { ns: 'plugin' })} - </div> - <div className="mt-0.5 system-xs-regular text-text-tertiary"> - {t($ => $['auth.permissionHint.description'], { ns: 'plugin' })} - </div> - <div className="mt-1.5"> - <button - type="button" - className="-ml-1.5 rounded-md px-1.5 py-0.5 system-xs-medium text-text-accent hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })} - > - {t($ => $['auth.permissionHint.action'], { ns: 'plugin' })} - </button> - </div> - </div> + {!isAuthorized && ( + <> + {authorizeContent} + {showPermissionHint && ( + <div className="mt-2 rounded-lg border border-divider-subtle bg-background-section-burn px-2.5 py-2"> + <div className="flex items-start gap-2"> + <span + aria-hidden + className="mt-0.5 i-ri-lock-2-line size-3.5 shrink-0 text-text-tertiary" + /> + <div className="min-w-0 grow"> + <div className="system-xs-medium text-text-secondary"> + {t(($) => $['auth.permissionHint.title'], { ns: 'plugin' })} + </div> + <div className="mt-0.5 system-xs-regular text-text-tertiary"> + {t(($) => $['auth.permissionHint.description'], { ns: 'plugin' })} + </div> + <div className="mt-1.5"> + <button + type="button" + className="-ml-1.5 rounded-md px-1.5 py-0.5 system-xs-medium text-text-accent hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS }) + } + > + {t(($) => $['auth.permissionHint.action'], { ns: 'plugin' })} + </button> </div> </div> - ) - } - </> - ) - } - { - isAuthorized && !children && ( - <Authorized - pluginPayload={pluginPayload} - credentials={credentials} - canOAuth={canOAuth} - canApiKey={canApiKey} - onUpdate={invalidPluginCredentialInfo} - notAllowCustomCredential={notAllowCustomCredential} - /> - ) - } - { - isAuthorized && children - } + </div> + </div> + )} + </> + )} + {isAuthorized && !children && ( + <Authorized + pluginPayload={pluginPayload} + credentials={credentials} + canOAuth={canOAuth} + canApiKey={canApiKey} + onUpdate={invalidPluginCredentialInfo} + notAllowCustomCredential={notAllowCustomCredential} + /> + )} + {isAuthorized && children} </div> ) } diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/action-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/action-list.spec.tsx index a2ef24918d86a6..8e7f6542bd0b9d 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/action-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/action-list.spec.tsx @@ -21,9 +21,7 @@ vi.mock('@/service/use-tools', () => ({ })) vi.mock('@/app/components/tools/provider/tool-item', () => ({ - default: ({ tool }: { tool: { name: string } }) => ( - <div data-testid="tool-item">{tool.name}</div> - ), + default: ({ tool }: { tool: { name: string } }) => <div data-testid="tool-item">{tool.name}</div>, })) const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({ @@ -71,7 +69,9 @@ describe('ActionList', () => { const detail = createPluginDetail() render(<ActionList detail={detail} />) - expect(screen.getByText('plugin.detailPanel.actionNum:{"num":2,"action":"actions"}')).toBeInTheDocument() + expect( + screen.getByText('plugin.detailPanel.actionNum:{"num":2,"action":"actions"}'), + ).toBeInTheDocument() expect(screen.getAllByTestId('tool-item')).toHaveLength(2) }) @@ -113,7 +113,9 @@ describe('ActionList', () => { // The provider key is constructed from plugin_id and tool identity name // When they match the mock, it renders - expect(screen.getByText('plugin.detailPanel.actionNum:{"num":2,"action":"actions"}')).toBeInTheDocument() + expect( + screen.getByText('plugin.detailPanel.actionNum:{"num":2,"action":"actions"}'), + ).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/agent-strategy-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/agent-strategy-list.spec.tsx index 02a2431ead7c6f..3c72f9e191039c 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/agent-strategy-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/agent-strategy-list.spec.tsx @@ -19,7 +19,9 @@ const mockStrategies = [ }, ] as unknown as StrategyDetail[] -let mockStrategyProviderDetail: { declaration: { identity: unknown, strategies: StrategyDetail[] } } | undefined +let mockStrategyProviderDetail: + | { declaration: { identity: unknown; strategies: StrategyDetail[] } } + | undefined vi.mock('@/service/use-strategy', () => ({ useStrategyProviderDetail: () => ({ @@ -81,7 +83,9 @@ describe('AgentStrategyList', () => { it('should render strategy items when data is available', () => { render(<AgentStrategyList detail={createPluginDetail()} />) - expect(screen.getByText('plugin.detailPanel.strategyNum:{"num":1,"strategy":"strategy"}'))!.toBeInTheDocument() + expect( + screen.getByText('plugin.detailPanel.strategyNum:{"num":1,"strategy":"strategy"}'), + )!.toBeInTheDocument() expect(screen.getByTestId('strategy-item'))!.toBeInTheDocument() }) @@ -98,13 +102,18 @@ describe('AgentStrategyList', () => { identity: { author: 'test', name: 'test' }, strategies: [ ...mockStrategies, - { ...mockStrategies[0]!, identity: { ...mockStrategies[0]!.identity, name: 'strategy-2' } }, + { + ...mockStrategies[0]!, + identity: { ...mockStrategies[0]!.identity, name: 'strategy-2' }, + }, ], }, } render(<AgentStrategyList detail={createPluginDetail()} />) - expect(screen.getByText('plugin.detailPanel.strategyNum:{"num":2,"strategy":"strategies"}'))!.toBeInTheDocument() + expect( + screen.getByText('plugin.detailPanel.strategyNum:{"num":2,"strategy":"strategies"}'), + )!.toBeInTheDocument() expect(screen.getAllByTestId('strategy-item')).toHaveLength(2) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/datasource-action-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/datasource-action-list.spec.tsx index d5a8b6f473d63f..c3a941fc37d4b9 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/datasource-action-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/datasource-action-list.spec.tsx @@ -3,9 +3,7 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import DatasourceActionList from '../datasource-action-list' -const mockDataSourceList = [ - { plugin_id: 'test-plugin', name: 'Data Source 1' }, -] +const mockDataSourceList = [{ plugin_id: 'test-plugin', name: 'Data Source 1' }] let mockDataSourceListData: typeof mockDataSourceList | undefined @@ -62,7 +60,9 @@ describe('DatasourceActionList', () => { render(<DatasourceActionList detail={createPluginDetail()} />) // The component always shows "0 action" because data is hardcoded as empty array - expect(screen.getByText('plugin.detailPanel.actionNum:{"num":0,"action":"action"}')).toBeInTheDocument() + expect( + screen.getByText('plugin.detailPanel.actionNum:{"num":0,"action":"action"}'), + ).toBeInTheDocument() }) it('should return null when no provider found', () => { @@ -88,7 +88,9 @@ describe('DatasourceActionList', () => { render(<DatasourceActionList detail={detail} />) - expect(screen.getByText('plugin.detailPanel.actionNum:{"num":0,"action":"action"}')).toBeInTheDocument() + expect( + screen.getByText('plugin.detailPanel.actionNum:{"num":0,"action":"action"}'), + ).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx index 67e25b4f9cae7c..83ca1260cb428c 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx @@ -54,7 +54,10 @@ const { mockInvalidateAllToolProviders: vi.fn(), mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })), mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])), - mockCheckForUpdates: vi.fn(() => ({ needUpdate: true, toastProps: { type: 'success', message: 'Update available' } })), + mockCheckForUpdates: vi.fn(() => ({ + needUpdate: true, + toastProps: { type: 'success', message: 'Update available' }, + })), mockOpenReadmePanel: vi.fn(), } }) @@ -102,8 +105,9 @@ vi.mock('@/service/use-plugins', () => ({ })) vi.mock('../../readme-panel/store', () => ({ - useReadmePanelStore: (selector: (state: { openReadmePanel: typeof mockOpenReadmePanel }) => unknown) => - selector({ openReadmePanel: mockOpenReadmePanel }), + useReadmePanelStore: ( + selector: (state: { openReadmePanel: typeof mockOpenReadmePanel }) => unknown, + ) => selector({ openReadmePanel: mockOpenReadmePanel }), })) vi.mock('@/service/use-tools', () => ({ @@ -210,11 +214,15 @@ vi.mock('../../base/deprecation-notice', () => ({ // Enhanced update modal mock vi.mock('../../update-plugin/from-market-place', () => ({ - default: ({ onSave, onCancel }: { onSave: () => void, onCancel: () => void }) => { + default: ({ onSave, onCancel }: { onSave: () => void; onCancel: () => void }) => { return ( <div data-testid="update-modal"> - <button data-testid="update-modal-save" onClick={onSave}>Save</button> - <button data-testid="update-modal-cancel" onClick={onCancel}>Cancel</button> + <button data-testid="update-modal-save" onClick={onSave}> + Save + </button> + <button data-testid="update-modal-cancel" onClick={onCancel}> + Cancel + </button> </div> ) }, @@ -222,7 +230,15 @@ vi.mock('../../update-plugin/from-market-place', () => ({ // Enhanced version picker mock vi.mock('../../update-plugin/plugin-version-picker', () => ({ - default: ({ trigger, onSelect, onShowChange }: { trigger: React.ReactNode, onSelect: (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => void, onShowChange: (show: boolean) => void }) => ( + default: ({ + trigger, + onSelect, + onShowChange, + }: { + trigger: React.ReactNode + onSelect: (state: { version: string; unique_identifier: string; isDowngrade?: boolean }) => void + onShowChange: (show: boolean) => void + }) => ( <div data-testid="version-picker"> {trigger} <button @@ -250,7 +266,9 @@ vi.mock('../../update-plugin/plugin-version-picker', () => ({ vi.mock('../../plugin-page/plugin-info', () => ({ default: ({ onHide }: { onHide: () => void }) => ( <div data-testid="plugin-info"> - <button data-testid="plugin-info-close" onClick={onHide}>Close</button> + <button data-testid="plugin-info-close" onClick={onHide}> + Close + </button> </div> ), })) @@ -316,13 +334,17 @@ describe('DetailHeader', () => { describe('Rendering', () => { it('should render plugin title', () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) expect(screen.getByTestId('title'))!.toBeInTheDocument() }) it('should render plugin icon with correct src', () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) expect(screen.getByTestId('card-icon'))!.toBeInTheDocument() }) @@ -336,23 +358,37 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - expect(screen.getByTestId('card-icon'))!.toHaveAttribute('data-src', 'https://example.com/icon.png') + expect(screen.getByTestId('card-icon'))!.toHaveAttribute( + 'data-src', + 'https://example.com/icon.png', + ) }) it('should render description when not in readme view', () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) expect(screen.getByTestId('description'))!.toBeInTheDocument() }) it('should not render description in readme view', () => { - render(<DetailHeader detail={createPluginDetail()} isReadmeView onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader + detail={createPluginDetail()} + isReadmeView + onUpdate={mockOnUpdate} + onHide={mockOnHide} + />, + ) expect(screen.queryByTestId('description')).not.toBeInTheDocument() }) it('should render verified badge when verified', () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) expect(screen.getByTestId('verified-badge'))!.toBeInTheDocument() }) @@ -414,7 +450,9 @@ describe('DetailHeader', () => { upgrade_time_of_day: 36000, } - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) expect(screen.getByTestId('title'))!.toBeInTheDocument() }) @@ -428,7 +466,9 @@ describe('DetailHeader', () => { upgrade_time_of_day: 36000, } - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) expect(screen.getByTestId('title'))!.toBeInTheDocument() }) @@ -442,7 +482,9 @@ describe('DetailHeader', () => { upgrade_time_of_day: 36000, } - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) // Auto upgrade badge should be rendered // Auto upgrade badge should be rendered @@ -458,7 +500,9 @@ describe('DetailHeader', () => { upgrade_time_of_day: 36000, } - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) expect(screen.getByTestId('title'))!.toBeInTheDocument() }) @@ -472,7 +516,9 @@ describe('DetailHeader', () => { upgrade_time_of_day: 36000, } - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) expect(screen.getByTestId('title'))!.toBeInTheDocument() }) @@ -486,7 +532,9 @@ describe('DetailHeader', () => { upgrade_time_of_day: 36000, } - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) expect(screen.getByTestId('title'))!.toBeInTheDocument() }) @@ -500,7 +548,9 @@ describe('DetailHeader', () => { upgrade_time_of_day: 36000, } - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) expect(screen.getByTestId('title'))!.toBeInTheDocument() }) @@ -533,7 +583,9 @@ describe('DetailHeader', () => { upgrade_time_of_day: 36000, } - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) // Component should still render but auto upgrade should be disabled // Component should still render but auto upgrade should be disabled @@ -543,10 +595,14 @@ describe('DetailHeader', () => { describe('User Interactions', () => { it('should call onHide when close button clicked', () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) // Find the close button (ActionButton with action-btn class) - const actionButtons = screen.getAllByRole('button').filter(btn => btn.classList.contains('action-btn')) + const actionButtons = screen + .getAllByRole('button') + .filter((btn) => btn.classList.contains('action-btn')) fireEvent.click(actionButtons[actionButtons.length - 1]!) expect(mockOnHide).toHaveBeenCalled() @@ -592,7 +648,9 @@ describe('DetailHeader', () => { }) it('should have version picker select button', () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) const selectBtn = screen.getByTestId('select-version-btn') fireEvent.click(selectBtn) @@ -601,7 +659,9 @@ describe('DetailHeader', () => { }) it('should have downgrade button', () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) const downgradeBtn = screen.getByTestId('select-downgrade-btn') fireEvent.click(downgradeBtn) @@ -677,7 +737,9 @@ describe('DetailHeader', () => { describe('Delete Flow', () => { it('should have remove button available', () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) openActionsMenu() @@ -698,7 +760,9 @@ describe('DetailHeader', () => { }) it('should render correctly for tool plugin delete', () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) openActionsMenu() expect(screen.getByText('plugin.detailPanel.operation.remove')).toBeInTheDocument() @@ -731,7 +795,10 @@ describe('DetailHeader', () => { }) it('should not render deprecation notice for non-marketplace source', () => { - const detail = createPluginDetail({ source: PluginSource.github, meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' } }) + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.queryByTestId('deprecation-notice')).not.toBeInTheDocument() @@ -747,14 +814,23 @@ describe('DetailHeader', () => { render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) openActionsMenu() - expect(screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' })).toHaveAttribute('href', 'https://github.com/owner/repo') + expect( + screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' }), + ).toHaveAttribute('href', 'https://github.com/owner/repo') }) it('should render marketplace source correctly', () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) openActionsMenu() - expect(screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' })).toHaveAttribute('href', 'https://marketplace.example.com/plugins/test-author/test-plugin-name') + expect( + screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' }), + ).toHaveAttribute( + 'href', + 'https://marketplace.example.com/plugins/test-author/test-plugin-name', + ) }) it('should render local source correctly', () => { @@ -780,14 +856,26 @@ describe('DetailHeader', () => { }) it('should not expose README action for builtin tools', () => { - render(<DetailHeader detail={createPluginDetail({ id: 'code' })} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader + detail={createPluginDetail({ id: 'code' })} + onUpdate={mockOnUpdate} + onHide={mockOnHide} + />, + ) openActionsMenu() expect(screen.queryByText('plugin.detailPanel.operation.viewReadme')).not.toBeInTheDocument() }) it('should not expose README action when plugin unique identifier is missing', () => { - render(<DetailHeader detail={createPluginDetail({ plugin_unique_identifier: '' })} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader + detail={createPluginDetail({ plugin_unique_identifier: '' })} + onUpdate={mockOnUpdate} + onHide={mockOnHide} + />, + ) openActionsMenu() expect(screen.queryByText('plugin.detailPanel.operation.viewReadme')).not.toBeInTheDocument() @@ -796,7 +884,9 @@ describe('DetailHeader', () => { describe('Plugin Auth', () => { it('should render plugin auth for tool category', () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) expect(screen.getByTestId('plugin-auth'))!.toBeInTheDocument() }) @@ -814,7 +904,14 @@ describe('DetailHeader', () => { }) it('should not render plugin auth in readme view', () => { - render(<DetailHeader detail={createPluginDetail()} isReadmeView onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader + detail={createPluginDetail()} + isReadmeView + onUpdate={mockOnUpdate} + onHide={mockOnHide} + />, + ) expect(screen.queryByTestId('plugin-auth')).not.toBeInTheDocument() }) @@ -855,7 +952,9 @@ describe('DetailHeader', () => { describe('Delete Confirmation Flow', () => { it('should show delete confirm when remove button is clicked', async () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) clickOperation('plugin.detailPanel.operation.remove') @@ -865,7 +964,9 @@ describe('DetailHeader', () => { }) it('should hide delete confirm when cancel is clicked', async () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) clickOperation('plugin.detailPanel.operation.remove') await waitFor(() => { @@ -880,7 +981,9 @@ describe('DetailHeader', () => { }) it('should call uninstallPlugin when confirm delete is clicked', async () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) clickOperation('plugin.detailPanel.operation.remove') await waitFor(() => { @@ -895,7 +998,9 @@ describe('DetailHeader', () => { }) it('should call onUpdate with true after successful delete', async () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) clickOperation('plugin.detailPanel.operation.remove') await waitFor(() => { @@ -931,7 +1036,9 @@ describe('DetailHeader', () => { }) it('should invalidate tool providers when deleting tool plugin', async () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) clickOperation('plugin.detailPanel.operation.remove') await waitFor(() => { @@ -946,7 +1053,9 @@ describe('DetailHeader', () => { }) it('should track plugin uninstalled event after successful delete', async () => { - render(<DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + render( + <DetailHeader detail={createPluginDetail()} onUpdate={mockOnUpdate} onHide={mockOnHide} />, + ) clickOperation('plugin.detailPanel.operation.remove') await waitFor(() => { diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx index d2ac595c87c6bd..d28ed387e138ce 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx @@ -12,7 +12,8 @@ const mockToastNotify = vi.fn() vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( - (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), + (message: string, options?: { type?: string }) => + mockToastNotify({ type: options?.type, message }), { success: (message: string) => mockToastNotify({ type: 'success', message }), error: (message: string) => mockToastNotify({ type: 'error', message }), @@ -34,46 +35,40 @@ const failureFlags = { } vi.mock('@/service/use-endpoints', () => ({ - useEnableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({ + useEnableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void; onError: () => void }) => ({ mutate: (id: string) => { mockEnableEndpoint(id) - if (failureFlags.enable) - onError() - else - onSuccess() + if (failureFlags.enable) onError() + else onSuccess() }, }), - useDisableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({ + useDisableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void; onError: () => void }) => ({ mutate: (id: string) => { mockDisableEndpoint(id) - if (failureFlags.disable) - onError() - else - onSuccess() + if (failureFlags.disable) onError() + else onSuccess() }, }), - useDeleteEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({ + useDeleteEndpoint: ({ onSuccess, onError }: { onSuccess: () => void; onError: () => void }) => ({ mutate: (id: string) => { mockDeleteEndpoint(id) - if (failureFlags.delete) - onError() - else - onSuccess() + if (failureFlags.delete) onError() + else onSuccess() }, }), - useUpdateEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({ + useUpdateEndpoint: ({ onSuccess, onError }: { onSuccess: () => void; onError: () => void }) => ({ mutate: (data: unknown) => { mockUpdateEndpoint(data) - if (failureFlags.update) - onError() - else - onSuccess() + if (failureFlags.update) onError() + else onSuccess() }, }), })) vi.mock('@langgenius/dify-ui/status-dot', () => ({ - StatusDot: ({ status }: { status: string }) => <span data-testid="indicator" data-status={status} />, + StatusDot: ({ status }: { status: string }) => ( + <span data-testid="indicator" data-status={status} /> + ), })) vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ @@ -82,10 +77,14 @@ vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ })) vi.mock('../endpoint-modal', () => ({ - default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => ( + default: ({ onCancel, onSaved }: { onCancel: () => void; onSaved: (state: unknown) => void }) => ( <div data-testid="endpoint-modal"> - <button data-testid="modal-cancel" onClick={onCancel}>Cancel</button> - <button data-testid="modal-save" onClick={() => onSaved({ name: 'Updated' })}>Save</button> + <button data-testid="modal-cancel" onClick={onCancel}> + Cancel + </button> + <button data-testid="modal-save" onClick={() => onSaved({ name: 'Updated' })}> + Save + </button> </div> ), })) @@ -159,13 +158,25 @@ describe('EndpointCard', () => { describe('Rendering', () => { it('should render endpoint name', () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) expect(screen.getByText('Test Endpoint'))!.toBeInTheDocument() }) it('should render visible endpoints only', () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) expect(screen.getByText('GET'))!.toBeInTheDocument() expect(screen.getByText('https://api.example.com/api/test'))!.toBeInTheDocument() @@ -173,7 +184,13 @@ describe('EndpointCard', () => { }) it('should show active status when enabled', () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) expect(screen.getByText('plugin.detailPanel.serviceOk'))!.toBeInTheDocument() expect(screen.getByTestId('indicator'))!.toHaveAttribute('data-status', 'success') @@ -181,7 +198,13 @@ describe('EndpointCard', () => { it('should show disabled status when not enabled', () => { const disabledData = { ...mockEndpointData, enabled: false } - render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={disabledData} + handleChange={mockHandleChange} + />, + ) expect(screen.getByText('plugin.detailPanel.disabled'))!.toBeInTheDocument() expect(screen.getByTestId('indicator'))!.toHaveAttribute('data-status', 'disabled') @@ -190,7 +213,13 @@ describe('EndpointCard', () => { describe('User Interactions', () => { it('should show disable confirm when switching off', () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) fireEvent.click(screen.getByRole('switch')) @@ -198,7 +227,13 @@ describe('EndpointCard', () => { }) it('should call disableEndpoint when confirm disable', () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) fireEvent.click(screen.getByRole('switch')) // Click confirm button in the Confirm dialog @@ -208,7 +243,13 @@ describe('EndpointCard', () => { }) it('should show delete confirm when delete clicked', () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) const allButtons = screen.getAllByRole('button') fireEvent.click(allButtons[1]!) @@ -217,7 +258,13 @@ describe('EndpointCard', () => { }) it('should call deleteEndpoint when confirm delete', () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) const allButtons = screen.getAllByRole('button') fireEvent.click(allButtons[1]!) @@ -227,7 +274,13 @@ describe('EndpointCard', () => { }) it('should show edit modal when edit clicked', () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) const allButtons = screen.getAllByRole('button') fireEvent.click(allButtons[0]!) @@ -236,7 +289,13 @@ describe('EndpointCard', () => { }) it('should call updateEndpoint when save in modal', () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) const allButtons = screen.getAllByRole('button') fireEvent.click(allButtons[0]!) @@ -249,7 +308,13 @@ describe('EndpointCard', () => { describe('Copy Functionality', () => { it('should reset copy state after timeout', async () => { vi.useFakeTimers() - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) const allButtons = screen.getAllByRole('button') fireEvent.click(allButtons[2]!) @@ -268,14 +333,26 @@ describe('EndpointCard', () => { ...mockEndpointData, declaration: { settings: [], endpoints: [] }, } - render(<EndpointCard pluginDetail={mockPluginDetail} data={dataWithNoEndpoints} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={dataWithNoEndpoints} + handleChange={mockHandleChange} + />, + ) expect(screen.getByText('Test Endpoint'))!.toBeInTheDocument() }) it('should call handleChange after enable', () => { const disabledData = { ...mockEndpointData, enabled: false } - render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={disabledData} + handleChange={mockHandleChange} + />, + ) fireEvent.click(screen.getByRole('switch')) @@ -283,7 +360,13 @@ describe('EndpointCard', () => { }) it('should hide disable confirm and revert state when cancel clicked', async () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) fireEvent.click(screen.getByRole('switch')) expect(screen.getByText('plugin.detailPanel.endpointDisableTip'))!.toBeInTheDocument() @@ -295,7 +378,13 @@ describe('EndpointCard', () => { }) it('should hide delete confirm when cancel clicked', async () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) const allButtons = screen.getAllByRole('button') fireEvent.click(allButtons[1]!) @@ -306,7 +395,13 @@ describe('EndpointCard', () => { }) it('should hide edit modal when cancel clicked', () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) const allButtons = screen.getAllByRole('button') fireEvent.click(allButtons[0]!) @@ -322,7 +417,13 @@ describe('EndpointCard', () => { it('should show error toast when enable fails', () => { failureFlags.enable = true const disabledData = { ...mockEndpointData, enabled: false } - render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={disabledData} + handleChange={mockHandleChange} + />, + ) fireEvent.click(screen.getByRole('switch')) @@ -331,7 +432,13 @@ describe('EndpointCard', () => { it('should show error toast when disable fails', () => { failureFlags.disable = true - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) fireEvent.click(screen.getByRole('switch')) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) @@ -341,7 +448,13 @@ describe('EndpointCard', () => { it('should show error toast when delete fails', () => { failureFlags.delete = true - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) const allButtons = screen.getAllByRole('button') fireEvent.click(allButtons[1]!) @@ -351,7 +464,13 @@ describe('EndpointCard', () => { }) it('should show error toast when update fails', () => { - render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) + render( + <EndpointCard + pluginDetail={mockPluginDetail} + data={mockEndpointData} + handleChange={mockHandleChange} + />, + ) const allButtons = screen.getAllByRole('button') fireEvent.click(allButtons[0]!) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx index e01177a8b7ac1b..ffe4a228dfd5f5 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx @@ -8,7 +8,12 @@ vi.mock('@langgenius/dify-ui/cn', () => ({ })) const mockEndpoints = [ - { id: 'ep-1', name: 'Endpoint 1', url: 'https://api.example.com', declaration: { settings: [], endpoints: [] } }, + { + id: 'ep-1', + name: 'Endpoint 1', + url: 'https://api.example.com', + declaration: { settings: [], endpoints: [] }, + }, ] let mockEndpointListData: { endpoints: typeof mockEndpoints } | undefined @@ -43,10 +48,14 @@ vi.mock('../endpoint-card', () => ({ })) vi.mock('../endpoint-modal', () => ({ - default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => ( + default: ({ onCancel, onSaved }: { onCancel: () => void; onSaved: (state: unknown) => void }) => ( <div data-testid="endpoint-modal"> - <button data-testid="modal-cancel" onClick={onCancel}>Cancel</button> - <button data-testid="modal-save" onClick={() => onSaved({ name: 'New Endpoint' })}>Save</button> + <button data-testid="modal-cancel" onClick={onCancel}> + Cancel + </button> + <button data-testid="modal-save" onClick={() => onSaved({ name: 'New Endpoint' })}> + Save + </button> </div> ), })) @@ -77,7 +86,8 @@ const createPluginDetail = (): PluginDetail => ({ }) describe('EndpointList', () => { - const getAddButton = () => screen.getByRole('button', { name: 'plugin.detailPanel.endpointModalTitle' }) + const getAddButton = () => + screen.getByRole('button', { name: 'plugin.detailPanel.endpointModalTitle' }) beforeEach(() => { vi.clearAllMocks() @@ -162,8 +172,18 @@ describe('EndpointList', () => { it('should render multiple endpoint cards', () => { mockEndpointListData = { endpoints: [ - { id: 'ep-1', name: 'Endpoint 1', url: 'https://api1.example.com', declaration: { settings: [], endpoints: [] } }, - { id: 'ep-2', name: 'Endpoint 2', url: 'https://api2.example.com', declaration: { settings: [], endpoints: [] } }, + { + id: 'ep-1', + name: 'Endpoint 1', + url: 'https://api1.example.com', + declaration: { settings: [], endpoints: [] }, + }, + { + id: 'ep-2', + name: 'Endpoint 2', + url: 'https://api2.example.com', + declaration: { settings: [], endpoints: [] }, + }, ], } render(<EndpointList detail={createPluginDetail()} />) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx index 7514e03059ae22..892b21fcbe89b8 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx @@ -8,7 +8,8 @@ const mockToastNotify = vi.fn() vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( - (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), + (message: string, options?: { type?: string }) => + mockToastNotify({ type: options?.type, message }), { success: (message: string) => mockToastNotify({ type: 'success', message }), error: (message: string) => mockToastNotify({ type: 'error', message }), @@ -27,7 +28,11 @@ vi.mock('@/hooks/use-i18n', () => ({ })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({ - default: ({ value, onChange, fieldMoreInfo }: { + default: ({ + value, + onChange, + fieldMoreInfo, + }: { value: Record<string, unknown> onChange: (v: Record<string, unknown>) => void fieldMoreInfo?: (item: { url?: string }) => React.ReactNode @@ -36,8 +41,8 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal <div data-testid="form"> <input data-testid="form-input" - value={value.name as string || ''} - onChange={e => onChange({ ...value, name: e.target.value })} + value={(value.name as string) || ''} + onChange={(e) => onChange({ ...value, name: e.target.value })} /> {/* Render fieldMoreInfo to test url link */} {fieldMoreInfo && ( @@ -57,7 +62,13 @@ vi.mock('../../readme-panel/entrance', () => ({ const mockFormSchemas = [ { name: 'name', label: { en_US: 'Name' }, type: 'text-input', required: true, default: '' }, - { name: 'apiKey', label: { en_US: 'API Key' }, type: 'secret-input', required: false, default: '' }, + { + name: 'apiKey', + label: { en_US: 'API Key' }, + type: 'secret-input', + required: false, + default: '', + }, ] as unknown as FormSchema[] const mockPluginDetail: PluginDetail = { @@ -221,7 +232,13 @@ describe('EndpointModal', () => { it('should extract default values from schemas when no defaultValues', () => { const schemasWithDefaults = [ - { name: 'name', label: 'Name', type: 'text-input', required: true, default: 'Schema Default' }, + { + name: 'name', + label: 'Name', + type: 'text-input', + required: true, + default: 'Schema Default', + }, ] as unknown as FormSchema[] render( @@ -257,7 +274,13 @@ describe('EndpointModal', () => { describe('Validation - handleSave', () => { it('should show toast error when required field is empty', () => { const schemasWithRequired = [ - { name: 'name', label: { en_US: 'Name Field' }, type: 'text-input', required: true, default: '' }, + { + name: 'name', + label: { en_US: 'Name Field' }, + type: 'text-input', + required: true, + default: '', + }, ] as unknown as FormSchema[] render( diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/index.spec.tsx index d74c1c34beae23..01e3ae7724abbf 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/index.spec.tsx @@ -15,7 +15,11 @@ vi.mock('../store', () => ({ // Mock DetailHeader const mockDetailHeaderOnUpdate = vi.fn() vi.mock('../detail-header', () => ({ - default: ({ detail, onUpdate, onHide }: { + default: ({ + detail, + onUpdate, + onHide, + }: { detail: PluginDetail onUpdate: (isDelete?: boolean) => void onHide: () => void @@ -25,22 +29,13 @@ vi.mock('../detail-header', () => ({ return ( <div data-testid="detail-header"> <span data-testid="header-title">{detail.name}</span> - <button - data-testid="header-update-btn" - onClick={() => onUpdate()} - > + <button data-testid="header-update-btn" onClick={() => onUpdate()}> Update </button> - <button - data-testid="header-delete-btn" - onClick={() => onUpdate(true)} - > + <button data-testid="header-delete-btn" onClick={() => onUpdate(true)}> Delete </button> - <button - data-testid="header-hide-btn" - onClick={onHide} - > + <button data-testid="header-hide-btn" onClick={onHide}> Hide </button> </div> @@ -104,14 +99,18 @@ vi.mock('../subscription-list', () => ({ // Mock TriggerEventsList vi.mock('../trigger/event-list', () => ({ - TriggerEventsList: () => ( - <div data-testid="trigger-events-list">Events List</div> - ), + TriggerEventsList: () => <div data-testid="trigger-events-list">Events List</div>, })) // Mock ReadmeEntrance vi.mock('../../readme-panel/entrance', () => ({ - ReadmeEntrance: ({ pluginDetail, className }: { pluginDetail: PluginDetail, className?: string }) => ( + ReadmeEntrance: ({ + pluginDetail, + className, + }: { + pluginDetail: PluginDetail + className?: string + }) => ( <div data-testid="readme-entrance" className={className}> <span data-testid="readme-plugin-id">{pluginDetail.plugin_id}</span> </div> @@ -306,11 +305,7 @@ describe('PluginDetailPanel', () => { describe('Rendering', () => { it('should render nothing when detail is undefined', () => { const { container } = render( - <PluginDetailPanel - detail={undefined} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, + <PluginDetailPanel detail={undefined} onUpdate={mockOnUpdate} onHide={mockOnHide} />, ) expect(container).toBeEmptyDOMElement() @@ -320,13 +315,7 @@ describe('PluginDetailPanel', () => { it('should render drawer when detail is provided', () => { const detail = createPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) const dialog = screen.getByRole('dialog') @@ -344,13 +333,7 @@ describe('PluginDetailPanel', () => { it('should render detail header with plugin name', () => { const detail = createPluginDetail({ name: 'My Custom Plugin' }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByTestId('header-title')).toHaveTextContent('My Custom Plugin') }) @@ -358,13 +341,7 @@ describe('PluginDetailPanel', () => { it('should render readme entrance with plugin detail', () => { const detail = createPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() expect(screen.getByTestId('readme-plugin-id')).toHaveTextContent('test-plugin-id') @@ -373,13 +350,7 @@ describe('PluginDetailPanel', () => { it('should render drawer with correct styles', () => { const detail = createPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) const drawer = screen.getByRole('dialog') expect(drawer).toBeInTheDocument() @@ -390,13 +361,7 @@ describe('PluginDetailPanel', () => { it('should render ActionList for tool plugins', () => { const detail = createPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByTestId('action-list')).toBeInTheDocument() expect(screen.queryByTestId('model-list')).not.toBeInTheDocument() @@ -408,13 +373,7 @@ describe('PluginDetailPanel', () => { it('should render ModelList for model plugins', () => { const detail = createModelPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByTestId('model-list')).toBeInTheDocument() expect(screen.queryByTestId('action-list')).not.toBeInTheDocument() @@ -423,13 +382,7 @@ describe('PluginDetailPanel', () => { it('should render AgentStrategyList for agent strategy plugins', () => { const detail = createAgentStrategyPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByTestId('agent-strategy-list')).toBeInTheDocument() expect(screen.queryByTestId('action-list')).not.toBeInTheDocument() @@ -438,13 +391,7 @@ describe('PluginDetailPanel', () => { it('should render EndpointList for endpoint plugins', () => { const detail = createEndpointPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByTestId('endpoint-list')).toBeInTheDocument() expect(screen.queryByTestId('action-list')).not.toBeInTheDocument() @@ -453,13 +400,7 @@ describe('PluginDetailPanel', () => { it('should render DatasourceActionList for datasource plugins', () => { const detail = createDatasourcePluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByTestId('datasource-action-list')).toBeInTheDocument() expect(screen.queryByTestId('action-list')).not.toBeInTheDocument() @@ -468,13 +409,7 @@ describe('PluginDetailPanel', () => { it('should render SubscriptionList and TriggerEventsList for trigger plugins', () => { const detail = createTriggerPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByTestId('subscription-list')).toBeInTheDocument() expect(screen.getByTestId('trigger-events-list')).toBeInTheDocument() @@ -493,13 +428,7 @@ describe('PluginDetailPanel', () => { }, }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByTestId('action-list')).toBeInTheDocument() expect(screen.getByTestId('endpoint-list')).toBeInTheDocument() @@ -515,43 +444,29 @@ describe('PluginDetailPanel', () => { id: 'detail-id', }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(mockSetDetail).toHaveBeenCalledTimes(1) - expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({ - plugin_id: 'my-plugin-id', - plugin_unique_identifier: 'my-plugin-uid', - name: 'My Plugin', - id: 'detail-id', - provider: 'my-plugin-id/test-plugin', - })) + expect(mockSetDetail).toHaveBeenCalledWith( + expect.objectContaining({ + plugin_id: 'my-plugin-id', + plugin_unique_identifier: 'my-plugin-uid', + name: 'My Plugin', + id: 'detail-id', + provider: 'my-plugin-id/test-plugin', + }), + ) }) it('should call setDetail with undefined when detail becomes undefined', () => { const detail = createPluginDetail() const { rerender } = render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, + <PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />, ) expect(mockSetDetail).toHaveBeenCalledTimes(1) - rerender( - <PluginDetailPanel - detail={undefined} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + rerender(<PluginDetailPanel detail={undefined} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(mockSetDetail).toHaveBeenCalledTimes(2) expect(mockSetDetail).toHaveBeenLastCalledWith(undefined) @@ -562,46 +477,36 @@ describe('PluginDetailPanel', () => { const detail2 = createPluginDetail({ plugin_id: 'plugin-2' }) const { rerender } = render( - <PluginDetailPanel - detail={detail1} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, + <PluginDetailPanel detail={detail1} onUpdate={mockOnUpdate} onHide={mockOnHide} />, ) expect(mockSetDetail).toHaveBeenCalledTimes(1) - expect(mockSetDetail).toHaveBeenLastCalledWith(expect.objectContaining({ - plugin_id: 'plugin-1', - })) - - rerender( - <PluginDetailPanel - detail={detail2} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, + expect(mockSetDetail).toHaveBeenLastCalledWith( + expect.objectContaining({ + plugin_id: 'plugin-1', + }), ) + rerender(<PluginDetailPanel detail={detail2} onUpdate={mockOnUpdate} onHide={mockOnHide} />) + expect(mockSetDetail).toHaveBeenCalledTimes(2) - expect(mockSetDetail).toHaveBeenLastCalledWith(expect.objectContaining({ - plugin_id: 'plugin-2', - })) + expect(mockSetDetail).toHaveBeenLastCalledWith( + expect.objectContaining({ + plugin_id: 'plugin-2', + }), + ) }) it('should include declaration in setDetail call', () => { const detail = createPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({ - declaration: expect.any(Object), - })) + expect(mockSetDetail).toHaveBeenCalledWith( + expect.objectContaining({ + declaration: expect.any(Object), + }), + ) }) }) @@ -615,11 +520,7 @@ describe('PluginDetailPanel', () => { // it depends on onHide and onUpdate (tested in other tests) // This test verifies the basic rendering doesn't change the functionality const { rerender } = render( - <PluginDetailPanel - detail={detail} - onUpdate={onUpdate} - onHide={onHide} - />, + <PluginDetailPanel detail={detail} onUpdate={onUpdate} onHide={onHide} />, ) // Initial click should work @@ -627,13 +528,7 @@ describe('PluginDetailPanel', () => { expect(onUpdate).toHaveBeenCalledTimes(1) // Re-render with same props - rerender( - <PluginDetailPanel - detail={detail} - onUpdate={onUpdate} - onHide={onHide} - />, - ) + rerender(<PluginDetailPanel detail={detail} onUpdate={onUpdate} onHide={onHide} />) // Callback should still work after re-render fireEvent.click(screen.getByTestId('header-update-btn')) @@ -647,23 +542,13 @@ describe('PluginDetailPanel', () => { const onHide = vi.fn() const { rerender } = render( - <PluginDetailPanel - detail={detail} - onUpdate={onUpdate1} - onHide={onHide} - />, + <PluginDetailPanel detail={detail} onUpdate={onUpdate1} onHide={onHide} />, ) fireEvent.click(screen.getByTestId('header-update-btn')) expect(onUpdate1).toHaveBeenCalledTimes(1) - rerender( - <PluginDetailPanel - detail={detail} - onUpdate={onUpdate2} - onHide={onHide} - />, - ) + rerender(<PluginDetailPanel detail={detail} onUpdate={onUpdate2} onHide={onHide} />) fireEvent.click(screen.getByTestId('header-update-btn')) expect(onUpdate2).toHaveBeenCalledTimes(1) @@ -676,23 +561,13 @@ describe('PluginDetailPanel', () => { const onHide2 = vi.fn() const { rerender } = render( - <PluginDetailPanel - detail={detail} - onUpdate={onUpdate} - onHide={onHide1} - />, + <PluginDetailPanel detail={detail} onUpdate={onUpdate} onHide={onHide1} />, ) fireEvent.click(screen.getByTestId('header-delete-btn')) expect(onHide1).toHaveBeenCalledTimes(1) - rerender( - <PluginDetailPanel - detail={detail} - onUpdate={onUpdate} - onHide={onHide2} - />, - ) + rerender(<PluginDetailPanel detail={detail} onUpdate={onUpdate} onHide={onHide2} />) onUpdate.mockClear() fireEvent.click(screen.getByTestId('header-delete-btn')) @@ -704,13 +579,7 @@ describe('PluginDetailPanel', () => { it('should call onUpdate when update button is clicked', () => { const detail = createPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) fireEvent.click(screen.getByTestId('header-update-btn')) @@ -721,13 +590,7 @@ describe('PluginDetailPanel', () => { it('should call onHide and onUpdate when delete is triggered', () => { const detail = createPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) fireEvent.click(screen.getByTestId('header-delete-btn')) @@ -742,13 +605,7 @@ describe('PluginDetailPanel', () => { const detail = createPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={onUpdate} - onHide={onHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={onUpdate} onHide={onHide} />) fireEvent.click(screen.getByTestId('header-delete-btn')) @@ -758,13 +615,7 @@ describe('PluginDetailPanel', () => { it('should call only onUpdate when isDelete is false', () => { const detail = createPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) fireEvent.click(screen.getByTestId('header-update-btn')) @@ -775,13 +626,7 @@ describe('PluginDetailPanel', () => { it('should call onHide when hide button is clicked', () => { const detail = createPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) fireEvent.click(screen.getByTestId('header-hide-btn')) @@ -791,13 +636,7 @@ describe('PluginDetailPanel', () => { it('should call onHide when drawer close is triggered', () => { const detail = createPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) // Click the hide button in the header to close the drawer fireEvent.click(screen.getByTestId('header-hide-btn')) @@ -815,17 +654,13 @@ describe('PluginDetailPanel', () => { }, }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({ - provider: expect.stringContaining('/'), - })) + expect(mockSetDetail).toHaveBeenCalledWith( + expect.objectContaining({ + provider: expect.stringContaining('/'), + }), + ) }) it('should handle plugin with empty plugin_unique_identifier', () => { @@ -833,17 +668,13 @@ describe('PluginDetailPanel', () => { plugin_unique_identifier: '', }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({ - plugin_unique_identifier: '', - })) + expect(mockSetDetail).toHaveBeenCalledWith( + expect.objectContaining({ + plugin_unique_identifier: '', + }), + ) }) it('should handle plugin with undefined plugin_unique_identifier', () => { @@ -851,13 +682,7 @@ describe('PluginDetailPanel', () => { plugin_unique_identifier: undefined as unknown as string, }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByRole('dialog')).toBeInTheDocument() }) @@ -877,13 +702,7 @@ describe('PluginDetailPanel', () => { declaration: emptyDeclaration, }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByRole('dialog')).toBeInTheDocument() expect(screen.queryByTestId('action-list')).not.toBeInTheDocument() @@ -899,31 +718,15 @@ describe('PluginDetailPanel', () => { const detail3 = createPluginDetail({ plugin_id: 'plugin-3' }) const { rerender } = render( - <PluginDetailPanel - detail={detail1} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, + <PluginDetailPanel detail={detail1} onUpdate={mockOnUpdate} onHide={mockOnHide} />, ) act(() => { - rerender( - <PluginDetailPanel - detail={detail2} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + rerender(<PluginDetailPanel detail={detail2} onUpdate={mockOnUpdate} onHide={mockOnHide} />) }) act(() => { - rerender( - <PluginDetailPanel - detail={detail3} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + rerender(<PluginDetailPanel detail={detail3} onUpdate={mockOnUpdate} onHide={mockOnHide} />) }) expect(mockSetDetail).toHaveBeenCalledTimes(3) @@ -934,32 +737,16 @@ describe('PluginDetailPanel', () => { const detail = createPluginDetail() const { rerender, container } = render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, + <PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />, ) expect(screen.getByRole('dialog')).toBeInTheDocument() - rerender( - <PluginDetailPanel - detail={undefined} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + rerender(<PluginDetailPanel detail={undefined} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(container).toBeEmptyDOMElement() - rerender( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + rerender(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByRole('dialog')).toBeInTheDocument() }) @@ -969,13 +756,7 @@ describe('PluginDetailPanel', () => { it('should pass correct props to DetailHeader', () => { const detail = createPluginDetail({ name: 'Custom Plugin Name' }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByTestId('header-title')).toHaveTextContent('Custom Plugin Name') }) @@ -991,11 +772,7 @@ describe('PluginDetailPanel', () => { sources.forEach((source) => { const detail = createPluginDetail({ source }) const { unmount } = render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, + <PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />, ) expect(screen.getByRole('dialog')).toBeInTheDocument() @@ -1009,11 +786,7 @@ describe('PluginDetailPanel', () => { statuses.forEach((status) => { const detail = createPluginDetail({ status }) const { unmount } = render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, + <PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />, ) expect(screen.getByRole('dialog')).toBeInTheDocument() @@ -1027,13 +800,7 @@ describe('PluginDetailPanel', () => { alternative_plugin_id: 'alternative-plugin', }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByRole('dialog')).toBeInTheDocument() }) @@ -1048,13 +815,7 @@ describe('PluginDetailPanel', () => { }, }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByRole('dialog')).toBeInTheDocument() }) @@ -1066,13 +827,7 @@ describe('PluginDetailPanel', () => { latest_unique_identifier: 'new-uid', }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByRole('dialog')).toBeInTheDocument() }) @@ -1080,27 +835,17 @@ describe('PluginDetailPanel', () => { it('should pass pluginDetail to SubscriptionList for trigger plugins', () => { const detail = createTriggerPluginDetail({ plugin_id: 'trigger-plugin-123' }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - expect(screen.getByTestId('subscription-list-plugin-id')).toHaveTextContent('trigger-plugin-123') + expect(screen.getByTestId('subscription-list-plugin-id')).toHaveTextContent( + 'trigger-plugin-123', + ) }) it('should pass detail to ActionList for tool plugins', () => { const detail = createPluginDetail({ plugin_id: 'tool-plugin-456' }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(screen.getByTestId('action-list-plugin-id')).toHaveTextContent('tool-plugin-456') }) @@ -1116,29 +861,19 @@ describe('PluginDetailPanel', () => { }, }) - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({ - provider: 'my-org/my-plugin/my-tool-name', - })) + expect(mockSetDetail).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'my-org/my-plugin/my-tool-name', + }), + ) }) it('should include all required fields in setDetail payload', () => { const detail = createPluginDetail() - render( - <PluginDetailPanel - detail={detail} - onUpdate={mockOnUpdate} - onHide={mockOnHide} - />, - ) + render(<PluginDetailPanel detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) expect(mockSetDetail).toHaveBeenCalledWith({ plugin_id: detail.plugin_id, diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx index 3e0932e0ecc578..6d10f6c335f0a8 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx @@ -33,7 +33,9 @@ describe('OperationDropdown', () => { it('should render the actions trigger', () => { render(<OperationDropdown {...defaultProps} />) - expect(screen.getByRole('button', { name: 'plugin.detailPanel.operation.moreActions' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'plugin.detailPanel.operation.moreActions' }), + ).toBeInTheDocument() }) it('should render GitHub actions when marketplace is enabled', () => { @@ -89,7 +91,9 @@ describe('OperationDropdown', () => { />, ) - expect(screen.queryByRole('button', { name: 'plugin.detailPanel.operation.moreActions' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'plugin.detailPanel.operation.moreActions' }), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/store.spec.ts b/web/app/components/plugins/plugin-detail-panel/__tests__/store.spec.ts index 3afcc95288061c..80644f55cce13c 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/store.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/store.spec.ts @@ -287,7 +287,9 @@ describe('usePluginStore', () => { declaration: { trigger: { subscription_schema: [], - subscription_constructor: mockConstructor as unknown as NonNullable<SimpleDetail['declaration']['trigger']>['subscription_constructor'], + subscription_constructor: mockConstructor as unknown as NonNullable< + SimpleDetail['declaration']['trigger'] + >['subscription_constructor'], }, }, }) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx index 42ca6435855d72..a4c96d40c9008c 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx @@ -120,9 +120,10 @@ describe('StrategyDetail', () => { render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />) // Find the close button (ActionButton with action-btn class) - const closeButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) - if (closeButton) - fireEvent.click(closeButton) + const closeButton = screen + .getAllByRole('button') + .find((btn) => btn.classList.contains('action-btn')) + if (closeButton) fireEvent.click(closeButton) expect(mockOnHide).toHaveBeenCalledTimes(1) }) @@ -142,7 +143,9 @@ describe('StrategyDetail', () => { ...mockDetail, parameters: [{ ...mockDetail.parameters[0]!, type: 'number-input' }], } - render(<StrategyDetail provider={mockProvider} detail={detailWithNumber} onHide={mockOnHide} />) + render( + <StrategyDetail provider={mockProvider} detail={detailWithNumber} onHide={mockOnHide} />, + ) expect(screen.getByText('tools.setBuiltInTools.number'))!.toBeInTheDocument() }) @@ -152,7 +155,9 @@ describe('StrategyDetail', () => { ...mockDetail, parameters: [{ ...mockDetail.parameters[0]!, type: 'checkbox' }], } - render(<StrategyDetail provider={mockProvider} detail={detailWithCheckbox} onHide={mockOnHide} />) + render( + <StrategyDetail provider={mockProvider} detail={detailWithCheckbox} onHide={mockOnHide} />, + ) expect(screen.getByText('boolean'))!.toBeInTheDocument() }) @@ -172,7 +177,13 @@ describe('StrategyDetail', () => { ...mockDetail, parameters: [{ ...mockDetail.parameters[0]!, type: 'array[tools]' }], } - render(<StrategyDetail provider={mockProvider} detail={detailWithArrayTools} onHide={mockOnHide} />) + render( + <StrategyDetail + provider={mockProvider} + detail={detailWithArrayTools} + onHide={mockOnHide} + />, + ) expect(screen.getByText('multiple-tool-select'))!.toBeInTheDocument() }) @@ -182,7 +193,9 @@ describe('StrategyDetail', () => { ...mockDetail, parameters: [{ ...mockDetail.parameters[0]!, type: 'custom-type' }], } - render(<StrategyDetail provider={mockProvider} detail={detailWithUnknown} onHide={mockOnHide} />) + render( + <StrategyDetail provider={mockProvider} detail={detailWithUnknown} onHide={mockOnHide} />, + ) expect(screen.getByText('custom-type'))!.toBeInTheDocument() }) @@ -197,7 +210,10 @@ describe('StrategyDetail', () => { }) it('should handle no output schema', () => { - const detailNoOutput = { ...mockDetail, output_schema: undefined as unknown as Record<string, unknown> } + const detailNoOutput = { + ...mockDetail, + output_schema: undefined as unknown as Record<string, unknown>, + } render(<StrategyDetail provider={mockProvider} detail={detailNoOutput} onHide={mockOnHide} />) expect(screen.queryByText('OUTPUT')).not.toBeInTheDocument() diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx index f96f6adb78a9ae..51abc9a3dec4fd 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx @@ -14,7 +14,9 @@ vi.mock('@langgenius/dify-ui/cn', () => ({ vi.mock('../strategy-detail', () => ({ default: ({ onHide }: { onHide: () => void }) => ( <div data-testid="strategy-detail-panel"> - <button data-testid="hide-btn" onClick={onHide}>Hide</button> + <button data-testid="hide-btn" onClick={onHide}> + Hide + </button> </div> ), })) diff --git a/web/app/components/plugins/plugin-detail-panel/action-list.tsx b/web/app/components/plugins/plugin-detail-panel/action-list.tsx index 078f726e1bd9b4..026278363bfaf1 100644 --- a/web/app/components/plugins/plugin-detail-panel/action-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/action-list.tsx @@ -3,39 +3,37 @@ import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import ToolItem from '@/app/components/tools/provider/tool-item' -import { - useAllToolProviders, - useBuiltinTools, -} from '@/service/use-tools' +import { useAllToolProviders, useBuiltinTools } from '@/service/use-tools' type Props = Readonly<{ detail: PluginDetail }> -const ActionList = ({ - detail, -}: Props) => { +const ActionList = ({ detail }: Props) => { const { t } = useTranslation() const providerBriefInfo = detail.declaration?.tool?.identity const providerKey = providerBriefInfo ? `${detail.plugin_id}/${providerBriefInfo.name}` : '' const { data: collectionList = [] } = useAllToolProviders() const provider = useMemo(() => { - return collectionList.find(collection => collection.name === providerKey) + return collectionList.find((collection) => collection.name === providerKey) }, [collectionList, providerKey]) const { data } = useBuiltinTools(providerKey) - if (!providerKey || !data || !provider) - return null + if (!providerKey || !data || !provider) return null return ( <div className="px-4 pt-2 pb-4"> <div className="mb-1 py-1"> <div className="mb-1 flex h-6 items-center justify-between system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['detailPanel.actionNum'], { ns: 'plugin', num: data.length, action: data.length > 1 ? 'actions' : 'action' })} + {t(($) => $['detailPanel.actionNum'], { + ns: 'plugin', + num: data.length, + action: data.length > 1 ? 'actions' : 'action', + })} </div> </div> <div className="flex flex-col gap-2"> - {data.map(tool => ( + {data.map((tool) => ( <ToolItem key={`${detail.plugin_id}${tool.name}`} disabled={false} diff --git a/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.tsx b/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.tsx index 9e60619610ddab..417afddc75305c 100644 --- a/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.tsx @@ -3,17 +3,13 @@ import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import StrategyItem from '@/app/components/plugins/plugin-detail-panel/strategy-item' -import { - useStrategyProviderDetail, -} from '@/service/use-strategy' +import { useStrategyProviderDetail } from '@/service/use-strategy' type Props = Readonly<{ detail: PluginDetail }> -const AgentStrategyList = ({ - detail, -}: Props) => { +const AgentStrategyList = ({ detail }: Props) => { const { t } = useTranslation() const providerBriefInfo = detail.declaration.agent_strategy.identity const providerKey = `${detail.plugin_id}/${providerBriefInfo.name}` @@ -27,24 +23,26 @@ const AgentStrategyList = ({ }, [detail.tenant_id, strategyProviderDetail?.declaration.identity]) const strategyList = useMemo(() => { - if (!strategyProviderDetail) - return [] + if (!strategyProviderDetail) return [] return strategyProviderDetail.declaration.strategies }, [strategyProviderDetail]) - if (!strategyProviderDetail) - return null + if (!strategyProviderDetail) return null return ( <div className="px-4 pt-2 pb-4"> <div className="mb-1 py-1"> <div className="mb-1 flex h-6 items-center justify-between system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['detailPanel.strategyNum'], { ns: 'plugin', num: strategyList.length, strategy: strategyList.length > 1 ? 'strategies' : 'strategy' })} + {t(($) => $['detailPanel.strategyNum'], { + ns: 'plugin', + num: strategyList.length, + strategy: strategyList.length > 1 ? 'strategies' : 'strategy', + })} </div> </div> <div className="flex flex-col gap-2"> - {strategyList.map(strategyDetail => ( + {strategyList.map((strategyDetail) => ( <StrategyItem key={`${strategyDetail.identity.provider}${strategyDetail.identity.name}`} provider={providerDetail as any} diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-form.spec.tsx index 475b5b4ae01027..f67ef0a9496663 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-form.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-form.spec.tsx @@ -13,7 +13,10 @@ vi.mock('@/app/components/base/file-uploader', () => ({ }) => ( <div> <span data-testid="file-uploader-value">{JSON.stringify(value)}</span> - <button data-testid="file-uploader" onClick={() => onChange([{ id: 'file-1', name: 'demo.png' }])}> + <button + data-testid="file-uploader" + onClick={() => onChange([{ id: 'file-1', name: 'demo.png' }])} + > Upload </button> <button data-testid="file-uploader-empty" onClick={() => onChange([])}> @@ -30,7 +33,10 @@ vi.mock('@langgenius/dify-ui/select', async () => { }>({}) return { - Select: ({ children, onValueChange }: { + Select: ({ + children, + onValueChange, + }: { children: React.ReactNode onValueChange?: (value: string) => void }) => ( @@ -44,17 +50,26 @@ vi.mock('@langgenius/dify-ui/select', async () => { return ( <div> <button type="button">{children}</button> - <button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}> + <button + data-testid="select-empty" + type="button" + onClick={() => context.onValueChange?.('')} + > Empty Select </button> </div> ) }, SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => { const context = React.useContext(SelectContext) return ( - <button key={value} data-testid={`select-${value}`} type="button" onClick={() => context.onValueChange?.(value)}> + <button + key={value} + data-testid={`select-${value}`} + type="button" + onClick={() => context.onValueChange?.(value)} + > {children} </button> ) @@ -88,7 +103,14 @@ describe('AppInputsForm', () => { render( <AppInputsForm - inputsForms={[{ variable: 'question', label: 'Question', type: InputVarType.textInput, required: false }]} + inputsForms={[ + { + variable: 'question', + label: 'Question', + type: InputVarType.textInput, + required: false, + }, + ]} inputs={{ question: '' }} inputsRef={inputsRef} onFormChange={onFormChange} @@ -108,7 +130,9 @@ describe('AppInputsForm', () => { render( <AppInputsForm - inputsForms={[{ variable: 'count', label: 'Count', type: InputVarType.number, required: false }]} + inputsForms={[ + { variable: 'count', label: 'Count', type: InputVarType.number, required: false }, + ]} inputs={{ count: '' }} inputsRef={inputsRef} onFormChange={onFormChange} @@ -128,7 +152,15 @@ describe('AppInputsForm', () => { render( <AppInputsForm - inputsForms={[{ variable: 'tone', label: 'Tone', type: InputVarType.select, options: ['friendly', 'formal'], required: false }]} + inputsForms={[ + { + variable: 'tone', + label: 'Tone', + type: InputVarType.select, + options: ['friendly', 'formal'], + required: false, + }, + ]} inputs={{ tone: '' }} inputsRef={inputsRef} onFormChange={onFormChange} @@ -146,7 +178,15 @@ describe('AppInputsForm', () => { render( <AppInputsForm - inputsForms={[{ variable: 'tone', label: 'Tone', type: InputVarType.select, options: ['friendly', 'formal'], required: false }]} + inputsForms={[ + { + variable: 'tone', + label: 'Tone', + type: InputVarType.select, + options: ['friendly', 'formal'], + required: false, + }, + ]} inputs={{ tone: '' }} inputsRef={inputsRef} onFormChange={onFormChange} @@ -165,15 +205,17 @@ describe('AppInputsForm', () => { render( <AppInputsForm - inputsForms={[{ - variable: 'attachment', - label: 'Attachment', - type: InputVarType.singleFile, - required: false, - allowed_file_types: [], - allowed_file_extensions: ['.png'], - allowed_file_upload_methods: ['local_file'], - }]} + inputsForms={[ + { + variable: 'attachment', + label: 'Attachment', + type: InputVarType.singleFile, + required: false, + allowed_file_types: [], + allowed_file_extensions: ['.png'], + allowed_file_upload_methods: ['local_file'], + }, + ]} inputs={{ attachment: null }} inputsRef={inputsRef} onFormChange={onFormChange} @@ -193,7 +235,14 @@ describe('AppInputsForm', () => { render( <AppInputsForm - inputsForms={[{ variable: 'description', label: 'Description', type: InputVarType.paragraph, required: false }]} + inputsForms={[ + { + variable: 'description', + label: 'Description', + type: InputVarType.paragraph, + required: false, + }, + ]} inputs={{ description: '' }} inputsRef={inputsRef} onFormChange={onFormChange} @@ -216,16 +265,18 @@ describe('AppInputsForm', () => { render( <AppInputsForm - inputsForms={[{ - variable: 'files', - label: 'Files', - type: InputVarType.multiFiles, - required: true, - max_length: 3, - allowed_file_types: ['image'], - allowed_file_extensions: ['.png'], - allowed_file_upload_methods: ['local_file'], - }]} + inputsForms={[ + { + variable: 'files', + label: 'Files', + type: InputVarType.multiFiles, + required: true, + max_length: 3, + allowed_file_types: ['image'], + allowed_file_extensions: ['.png'], + allowed_file_upload_methods: ['local_file'], + }, + ]} inputs={{ files: existingFiles }} inputsRef={{ current: { files: existingFiles } }} onFormChange={onFormChange} @@ -245,15 +296,17 @@ describe('AppInputsForm', () => { render( <AppInputsForm - inputsForms={[{ - variable: 'attachment', - label: 'Attachment', - type: InputVarType.singleFile, - required: false, - allowed_file_types: ['image'], - allowed_file_extensions: ['.png'], - allowed_file_upload_methods: ['local_file'], - }]} + inputsForms={[ + { + variable: 'attachment', + label: 'Attachment', + type: InputVarType.singleFile, + required: false, + allowed_file_types: ['image'], + allowed_file_extensions: ['.png'], + allowed_file_upload_methods: ['local_file'], + }, + ]} inputs={{ attachment: existingFile }} inputsRef={{ current: { attachment: existingFile } }} onFormChange={onFormChange} diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx index 3e1c2a5a2a90d7..4a1488d5bbd7c3 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx @@ -12,20 +12,19 @@ vi.mock('@/app/components/base/loading', () => ({ })) vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form', () => ({ - default: ({ - onFormChange, - }: { - onFormChange: (value: Record<string, unknown>) => void - }) => ( + default: ({ onFormChange }: { onFormChange: (value: Record<string, unknown>) => void }) => ( <button data-testid="app-inputs-form" onClick={() => onFormChange({ topic: 'updated' })}> Form </button> ), })) -vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema', () => ({ - useAppInputsFormSchema: () => mockHookResult, -})) +vi.mock( + '@/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema', + () => ({ + useAppInputsFormSchema: () => mockHookResult, + }), +) describe('AppInputsPanel', () => { beforeEach(() => { diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx index e2423ed7a715b3..4c15abbd11fbc3 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx @@ -42,7 +42,7 @@ vi.mock('@/service/client', () => ({ placeholderData, }: { input: (pageParam: number) => { query: { name?: string } } - getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined + getNextPageParam: (lastPage: { has_more: boolean; page: number }) => number | undefined initialPageParam: number placeholderData: unknown }) => ({ @@ -51,7 +51,7 @@ vi.mock('@/service/client', () => ({ const query = input(Number(pageParam)).query const keyword = query.name?.toLowerCase() ?? '' const filteredApps = keyword - ? apps.filter(app => app.name.toLowerCase().includes(keyword)) + ? apps.filter((app) => app.name.toLowerCase().includes(keyword)) : apps return { @@ -72,7 +72,7 @@ vi.mock('@/service/client', () => ({ vi.mock('@/service/use-apps', () => ({ normalizeAppPagination: <T,>(response: T) => response, useAppDetail: (appId: string) => ({ - data: apps.find(app => app.id === appId), + data: apps.find((app) => app.id === appId), }), })) @@ -93,18 +93,10 @@ function renderWithQueryClient(children: ReactNode) { }, }) - return render( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) } -function StatefulAppSelector({ - onSelect, -}: { - onSelect: (value: AppSelectorValue) => void -}) { +function StatefulAppSelector({ onSelect }: { onSelect: (value: AppSelectorValue) => void }) { const [value, setValue] = useState<AppSelectorValue>() return ( @@ -181,7 +173,10 @@ describe('AppSelector', () => { await user.click(screen.getByText('Support Bot')) await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' })) - await user.type(screen.getByRole('combobox', { name: 'app.appSelector.placeholder' }), 'workflow') + await user.type( + screen.getByRole('combobox', { name: 'app.appSelector.placeholder' }), + 'workflow', + ) await waitFor(() => { expect(screen.queryByRole('option', { name: /Support Bot/ })).not.toBeInTheDocument() diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form.tsx index 3ad672ccf2000a..cf2cab7ad181a1 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form.tsx @@ -1,4 +1,11 @@ -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { Textarea } from '@langgenius/dify-ui/textarea' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -12,32 +19,26 @@ type Props = Readonly<{ inputsRef: any onFormChange: (value: Record<string, any>) => void }> -const AppInputsForm = ({ - inputsForms, - inputs, - inputsRef, - onFormChange, -}: Props) => { +const AppInputsForm = ({ inputsForms, inputs, inputsRef, onFormChange }: Props) => { const { t } = useTranslation() - const handleFormChange = useCallback((variable: string, value: any) => { - onFormChange({ - ...inputsRef.current, - [variable]: value, - }) - }, [onFormChange, inputsRef]) + const handleFormChange = useCallback( + (variable: string, value: any) => { + onFormChange({ + ...inputsRef.current, + [variable]: value, + }) + }, + [onFormChange, inputsRef], + ) const renderField = (form: any) => { - const { - label, - variable, - options, - } = form + const { label, variable, options } = form if (form.type === InputVarType.textInput) { return ( <Input value={inputs[variable] || ''} - onChange={e => handleFormChange(variable, e.target.value)} + onChange={(e) => handleFormChange(variable, e.target.value)} placeholder={label} /> ) @@ -47,7 +48,7 @@ const AppInputsForm = ({ <Input type="number" value={inputs[variable] || ''} - onChange={e => handleFormChange(variable, e.target.value)} + onChange={(e) => handleFormChange(variable, e.target.value)} placeholder={label} /> ) @@ -57,22 +58,26 @@ const AppInputsForm = ({ <Textarea aria-label={label} value={inputs[variable] || ''} - onValueChange={value => handleFormChange(variable, value)} + onValueChange={(value) => handleFormChange(variable, value)} placeholder={label} /> ) } if (form.type === InputVarType.select) { - const selectOptions: Array<{ value: string, name: string }> = options.map((option: string) => ({ value: option, name: option })) - const selectedOption = selectOptions.find(option => option.value === (inputs[variable] || '')) ?? null + const selectOptions: Array<{ value: string; name: string }> = options.map( + (option: string) => ({ value: option, name: option }), + ) + const selectedOption = + selectOptions.find((option) => option.value === (inputs[variable] || '')) ?? null return ( - <Select value={selectedOption?.value ?? null} onValueChange={value => value && handleFormChange(variable, value)}> - <SelectTrigger className="w-full"> - {selectedOption?.name ?? label} - </SelectTrigger> + <Select + value={selectedOption?.value ?? null} + onValueChange={(value) => value && handleFormChange(variable, value)} + > + <SelectTrigger className="w-full">{selectedOption?.name ?? label}</SelectTrigger> <SelectContent> - {selectOptions.map(option => ( + {selectOptions.map((option) => ( <SelectItem key={option.value} value={option.value}> <SelectItemText>{option.name}</SelectItemText> <SelectItemIndicator /> @@ -86,7 +91,7 @@ const AppInputsForm = ({ return ( <FileUploaderInAttachmentWrapper value={inputs[variable] ? [inputs[variable]] : []} - onChange={files => handleFormChange(variable, files[0])} + onChange={(files) => handleFormChange(variable, files[0])} fileConfig={{ allowed_file_types: form.allowed_file_types, allowed_file_extensions: form.allowed_file_extensions, @@ -101,7 +106,7 @@ const AppInputsForm = ({ return ( <FileUploaderInAttachmentWrapper value={inputs[variable]} - onChange={files => handleFormChange(variable, files)} + onChange={(files) => handleFormChange(variable, files)} fileConfig={{ allowed_file_types: form.allowed_file_types, allowed_file_extensions: form.allowed_file_extensions, @@ -114,16 +119,19 @@ const AppInputsForm = ({ } } - if (!inputsForms.length) - return null + if (!inputsForms.length) return null return ( <div className="flex flex-col gap-4 px-4 py-2"> - {inputsForms.map(form => ( + {inputsForms.map((form) => ( <div key={form.variable}> <div className="mb-1 flex h-6 items-center gap-1 system-sm-semibold text-text-secondary"> <div className="truncate">{form.label}</div> - {!form.required && <span className="system-xs-regular text-text-tertiary">{t($ => $['panel.optional'], { ns: 'workflow' })}</span>} + {!form.required && ( + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['panel.optional'], { ns: 'workflow' })} + </span> + )} </div> {renderField(form)} </div> diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx index c29655f501bf2c..9b38323423c0e8 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx @@ -16,11 +16,7 @@ type Props = Readonly<{ onFormChange: (value: Record<string, unknown>) => void }> -const AppInputsPanel = ({ - value, - appDetail, - onFormChange, -}: Props) => { +const AppInputsPanel = ({ value, appDetail, onFormChange }: Props) => { const { t } = useTranslation() const inputsRef = useRef<Record<string, unknown>>(value?.inputs || {}) @@ -34,17 +30,25 @@ const AppInputsPanel = ({ const hasInputs = inputFormSchema.length > 0 return ( - <div className={cn('flex max-h-[240px] flex-col rounded-b-2xl border-t border-divider-subtle pb-4')}> - {isLoading && <div className="pt-3"><Loading type="app" /></div>} + <div + className={cn( + 'flex max-h-[240px] flex-col rounded-b-2xl border-t border-divider-subtle pb-4', + )} + > + {isLoading && ( + <div className="pt-3"> + <Loading type="app" /> + </div> + )} {!isLoading && ( <div className="mt-3 mb-2 flex h-6 shrink-0 items-center px-4 system-sm-semibold text-text-secondary"> - {t($ => $['appSelector.params'], { ns: 'app' })} + {t(($) => $['appSelector.params'], { ns: 'app' })} </div> )} {!isLoading && !hasInputs && ( <div className="flex h-16 flex-col items-center justify-center"> <div className="system-sm-regular text-text-tertiary"> - {t($ => $['appSelector.noParams'], { ns: 'app' })} + {t(($) => $['appSelector.noParams'], { ns: 'app' })} </div> </div> )} diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx index b0d3d06794645b..3924183bcbbaa9 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx @@ -59,11 +59,7 @@ function getAppSearchText(app: App) { return `${app.name} ${app.id} ${getAppTypeLabel(app)}` } -function AppPickerOption({ - app, -}: { - app: App -}) { +function AppPickerOption({ app }: { app: App }) { return ( <ComboboxItem key={app.id} @@ -81,14 +77,12 @@ function AppPickerOption({ /> <span className="min-w-0 grow truncate system-sm-medium text-components-input-text-filled"> <span className="mr-1">{app.name}</span> - <span className="text-text-tertiary"> - ( - {app.id.slice(0, 8)} - ) - </span> + <span className="text-text-tertiary">({app.id.slice(0, 8)})</span> </span> </ComboboxItemText> - <span className="shrink-0 system-2xs-medium-uppercase text-text-tertiary">{getAppTypeLabel(app)}</span> + <span className="shrink-0 system-2xs-medium-uppercase text-text-tertiary"> + {getAppTypeLabel(app)} + </span> </ComboboxItem> ) } @@ -110,13 +104,15 @@ export function AppPicker({ }: AppPickerProps) { const { t } = useTranslation() - const handleValueChange = useCallback((app: App | null) => { - if (!app) - return + const handleValueChange = useCallback( + (app: App | null) => { + if (!app) return - onSelect(app) - onShowChange(false) - }, [onSelect, onShowChange]) + onSelect(app) + onShowChange(false) + }, + [onSelect, onShowChange], + ) return ( <Combobox<App> @@ -126,13 +122,13 @@ export function AppPicker({ onOpenChange={onShowChange} onInputValueChange={onSearchChange} onValueChange={handleValueChange} - itemToStringLabel={app => app?.name ?? ''} - itemToStringValue={app => app?.id ?? ''} + itemToStringLabel={(app) => app?.name ?? ''} + itemToStringValue={(app) => app?.id ?? ''} filter={(app, query) => getAppSearchText(app).toLowerCase().includes(query.toLowerCase())} disabled={disabled} > <ComboboxTrigger - aria-label={t($ => $['appSelector.label'], { ns: 'app' })} + aria-label={t(($) => $['appSelector.label'], { ns: 'app' })} icon={false} className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-popup-open:bg-transparent" > @@ -146,46 +142,42 @@ export function AppPicker({ <div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs"> <div className="p-2 pb-1"> <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> + <span + className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> <ComboboxInput - aria-label={t($ => $['appSelector.placeholder'], { ns: 'app' })} - placeholder={t($ => $['appSelector.placeholder'], { ns: 'app' })} + aria-label={t(($) => $['appSelector.placeholder'], { ns: 'app' })} + placeholder={t(($) => $['appSelector.placeholder'], { ns: 'app' })} className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary" /> {searchText && ( <button type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} className="ml-1.5 flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-none text-text-quaternary outline-hidden hover:bg-transparent hover:text-text-quaternary focus-visible:ring-1 focus-visible:ring-components-input-border-active" onClick={() => onSearchChange('')} > - <span className="i-custom-vender-solid-general-x-circle size-3.5" aria-hidden="true" /> + <span + className="i-custom-vender-solid-general-x-circle size-3.5" + aria-hidden="true" + /> </button> )} </ComboboxInputGroup> </div> <div className="min-h-0 flex-1 overflow-y-auto p-1"> - {isLoading && ( - <ComboboxStatus> - {t($ => $.loading, { ns: 'common' })} - </ComboboxStatus> - )} + {isLoading && <ComboboxStatus>{t(($) => $.loading, { ns: 'common' })}</ComboboxStatus>} <ComboboxList className="max-h-none p-0"> - {(app: App) => ( - <AppPickerOption key={app.id} app={app} /> - )} + {(app: App) => <AppPickerOption key={app.id} app={app} />} </ComboboxList> - <ComboboxEmpty> - {t($ => $.noData, { ns: 'common' })} - </ComboboxEmpty> + <ComboboxEmpty>{t(($) => $.noData, { ns: 'common' })}</ComboboxEmpty> {hasMore && ( <div className="flex justify-center px-3 py-2"> - <Button - size="small" - disabled={isLoading} - onClick={() => onLoadMore()} - > - {isLoading ? t($ => $.loading, { ns: 'common' }) : t($ => $['common.loadMore'], { ns: 'workflow' })} + <Button size="small" disabled={isLoading} onClick={() => onLoadMore()}> + {isLoading + ? t(($) => $.loading, { ns: 'common' }) + : t(($) => $['common.loadMore'], { ns: 'workflow' })} </Button> </div> )} diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-trigger.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-trigger.tsx index 5dff75a91a5d2b..bb9e48376d0f01 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-trigger.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-trigger.tsx @@ -10,10 +10,7 @@ type AppTriggerProps = { appDetail?: App } -export function AppTrigger({ - open, - appDetail, -}: AppTriggerProps) { +export function AppTrigger({ open, appDetail }: AppTriggerProps) { const { t } = useTranslation() return ( @@ -34,17 +31,15 @@ export function AppTrigger({ imageUrl={appDetail.icon_url} /> )} - {appDetail - ? ( - <span className="min-w-0 grow truncate system-sm-medium text-components-input-text-filled"> - {appDetail.name} - </span> - ) - : ( - <span className="min-w-0 grow truncate system-sm-regular text-components-input-text-placeholder"> - {t($ => $['appSelector.placeholder'], { ns: 'app' })} - </span> - )} + {appDetail ? ( + <span className="min-w-0 grow truncate system-sm-medium text-components-input-text-filled"> + {appDetail.name} + </span> + ) : ( + <span className="min-w-0 grow truncate system-sm-regular text-components-input-text-placeholder"> + {t(($) => $['appSelector.placeholder'], { ns: 'app' })} + </span> + )} <span className={cn( 'ml-0.5 i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary', diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts index d6a5b0323625d7..12cc51c8f88cb6 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts @@ -66,25 +66,29 @@ describe('useAppInputsFormSchema', () => { }, } - const { result } = renderHook(() => useAppInputsFormSchema({ - appDetail: { - id: 'app-1', - mode: AppModeEnum.COMPLETION, - } as never, - })) + const { result } = renderHook(() => + useAppInputsFormSchema({ + appDetail: { + id: 'app-1', + mode: AppModeEnum.COMPLETION, + } as never, + }), + ) expect(result.current.isLoading).toBe(false) - expect(result.current.inputFormSchema).toEqual(expect.arrayContaining([ - expect.objectContaining({ - variable: 'question', - type: 'text-input', - }), - expect.objectContaining({ - variable: '#image#', - type: InputVarType.singleFile, - allowed_file_extensions: ['.png'], - }), - ])) + expect(result.current.inputFormSchema).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + variable: 'question', + type: 'text-input', + }), + expect.objectContaining({ + variable: '#image#', + type: InputVarType.singleFile, + allowed_file_extensions: ['.png'], + }), + ]), + ) }) it('should build workflow schemas from start node variables', () => { @@ -112,12 +116,14 @@ describe('useAppInputsFormSchema', () => { features: {}, } - const { result } = renderHook(() => useAppInputsFormSchema({ - appDetail: { - id: 'app-2', - mode: AppModeEnum.WORKFLOW, - } as never, - })) + const { result } = renderHook(() => + useAppInputsFormSchema({ + appDetail: { + id: 'app-2', + mode: AppModeEnum.WORKFLOW, + } as never, + }), + ) expect(result.current.inputFormSchema).toEqual([ expect.objectContaining({ @@ -129,12 +135,14 @@ describe('useAppInputsFormSchema', () => { }) it('should return an empty schema when app detail is unavailable', () => { - const { result } = renderHook(() => useAppInputsFormSchema({ - appDetail: { - id: 'missing-app', - mode: AppModeEnum.CHAT, - } as never, - })) + const { result } = renderHook(() => + useAppInputsFormSchema({ + appDetail: { + id: 'missing-app', + mode: AppModeEnum.CHAT, + } as never, + }), + ) expect(result.current.inputFormSchema).toEqual([]) }) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema.ts b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema.ts index 8f3d5f0249f982..def3057717b355 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema.ts +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema.ts @@ -12,13 +12,13 @@ import { useAppWorkflow } from '@/service/use-workflow' import { AppModeEnum, Resolution } from '@/types/app' const BASIC_INPUT_TYPE_MAP: Record<string, string> = { - 'paragraph': 'paragraph', - 'number': 'number', - 'checkbox': 'checkbox', - 'select': 'select', + paragraph: 'paragraph', + number: 'number', + checkbox: 'checkbox', + select: 'select', 'file-list': 'file-list', - 'file': 'file', - 'json_object': 'json_object', + file: 'file', + json_object: 'json_object', } const FILE_INPUT_TYPES = new Set(['file-list', 'file']) @@ -52,11 +52,11 @@ function buildFileConfig(fileConfig: FileUpload | undefined) { }, enabled: !!(fileConfig?.enabled || fileConfig?.image?.enabled), allowed_file_types: fileConfig?.allowed_file_types || [SupportUploadFileTypes.image], - allowed_file_extensions: fileConfig?.allowed_file_extensions - || [...(FILE_EXTS[SupportUploadFileTypes.image] ?? [])].map(ext => `.${ext}`), - allowed_file_upload_methods: fileConfig?.allowed_file_upload_methods - || fileConfig?.image?.transfer_methods - || ['local_file', 'remote_url'], + allowed_file_extensions: + fileConfig?.allowed_file_extensions || + [...(FILE_EXTS[SupportUploadFileTypes.image] ?? [])].map((ext) => `.${ext}`), + allowed_file_upload_methods: fileConfig?.allowed_file_upload_methods || + fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'], number_limits: fileConfig?.number_limits || fileConfig?.image?.number_limits || 3, } } @@ -66,8 +66,7 @@ function mapBasicAppInputItem( fileUploadConfig?: FileUploadConfigResponse, ): InputSchemaItem | null { for (const [key, type] of Object.entries(BASIC_INPUT_TYPE_MAP)) { - if (!item[key]) - continue + if (!item[key]) continue const inputData = item[key] as Record<string, unknown> const needsFileConfig = FILE_INPUT_TYPES.has(key) @@ -81,8 +80,7 @@ function mapBasicAppInputItem( } const textInput = item['text-input'] as Record<string, unknown> | undefined - if (!textInput) - return null + if (!textInput) return null return { ...textInput, @@ -123,9 +121,10 @@ function buildBasicAppSchema( currentApp: App, fileUploadConfig?: FileUploadConfigResponse, ): InputSchemaItem[] { - const userInputForm = currentApp.model_config?.user_input_form as Array<Record<string, unknown>> | undefined - if (!userInputForm) - return [] + const userInputForm = currentApp.model_config?.user_input_form as + | Array<Record<string, unknown>> + | undefined + if (!userInputForm) return [] return userInputForm .filter((item: Record<string, unknown>) => !item.external_data_tool) @@ -137,16 +136,13 @@ function buildWorkflowSchema( workflow: FetchWorkflowDraftResponse, fileUploadConfig?: FileUploadConfigResponse, ): InputSchemaItem[] { - const startNode = workflow.graph?.nodes.find( - node => node.data.type === BlockEnum.Start, - ) as { data: { variables: Array<Record<string, unknown>> } } | undefined + const startNode = workflow.graph?.nodes.find((node) => node.data.type === BlockEnum.Start) as + | { data: { variables: Array<Record<string, unknown>> } } + | undefined - if (!startNode?.data.variables) - return [] + if (!startNode?.data.variables) return [] - return startNode.data.variables.map( - variable => mapWorkflowVariable(variable, fileUploadConfig), - ) + return startNode.data.variables.map((variable) => mapWorkflowVariable(variable, fileUploadConfig)) } type UseAppInputsFormSchemaParams = { @@ -173,11 +169,9 @@ export function useAppInputsFormSchema({ const isLoading = isAppLoading || isWorkflowLoading const inputFormSchema = useMemo(() => { - if (!currentApp) - return [] + if (!currentApp) return [] - if (!isBasicApp && !currentWorkflow) - return [] + if (!isBasicApp && !currentWorkflow) return [] // Build base schema based on app type // Note: currentWorkflow is guaranteed to be defined here due to the early return above @@ -185,22 +179,17 @@ export function useAppInputsFormSchema({ ? buildBasicAppSchema(currentApp, fileUploadConfig) : buildWorkflowSchema(currentWorkflow!, fileUploadConfig) - if (!supportsImageUpload(currentApp.mode)) - return baseSchema + if (!supportsImageUpload(currentApp.mode)) return baseSchema const rawFileConfig = isBasicApp - ? currentApp.model_config?.file_upload as FileUpload - : currentWorkflow?.features?.file_upload as FileUpload + ? (currentApp.model_config?.file_upload as FileUpload) + : (currentWorkflow?.features?.file_upload as FileUpload) const basicFileConfig = buildFileConfig(rawFileConfig) - if (!basicFileConfig.enabled) - return baseSchema + if (!basicFileConfig.enabled) return baseSchema - return [ - ...baseSchema, - createImageUploadSchema(basicFileConfig, fileUploadConfig), - ] + return [...baseSchema, createImageUploadSchema(basicFileConfig, fileUploadConfig)] }, [currentApp, currentWorkflow, fileUploadConfig, isBasicApp]) return { diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx index 8e19d5e6fb6057..2907690b20d0e1 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx @@ -2,11 +2,7 @@ import type { Placement } from '@langgenius/dify-ui/popover' import type { App } from '@/types/app' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -45,31 +41,28 @@ export function AppSelector({ const [isShowChooseApp, setIsShowChooseApp] = useState(false) const [searchText, setSearchText] = useState('') - const appListQuery = useMemo(() => ({ - page: 1, - limit: PAGE_SIZE, - name: searchText, - }), [searchText]) - - const { - data, - isLoading, - isFetchingNextPage, - fetchNextPage, - hasNextPage, - } = useInfiniteQuery({ + const appListQuery = useMemo( + () => ({ + page: 1, + limit: PAGE_SIZE, + name: searchText, + }), + [searchText], + ) + + const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteQuery({ ...consoleQuery.apps.get.infiniteOptions({ - input: pageParam => ({ + input: (pageParam) => ({ query: { ...appListQuery, page: Number(pageParam), }, }), - getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), initialPageParam: 1, placeholderData: keepPreviousData, }), - select: data => ({ + select: (data) => ({ ...data, pages: data.pages.map(normalizeAppPagination), }), @@ -82,58 +75,62 @@ export function AppSelector({ const { data: selectedAppDetail } = useAppDetail(value?.app_id || '') const currentAppInfo = useMemo(() => { - if (!value?.app_id) - return undefined + if (!value?.app_id) return undefined - return selectedAppDetail || displayedApps.find(app => app.id === value.app_id) + return selectedAppDetail || displayedApps.find((app) => app.id === value.app_id) }, [displayedApps, selectedAppDetail, value?.app_id]) const hasMore = hasNextPage ?? true - const handleSelectApp = useCallback((app: App) => { - const shouldClearValue = app.id !== value?.app_id + const handleSelectApp = useCallback( + (app: App) => { + const shouldClearValue = app.id !== value?.app_id - onSelect({ - app_id: app.id, - inputs: shouldClearValue ? {} : value?.inputs || {}, - files: shouldClearValue ? [] : value?.files || [], - }) - }, [onSelect, value?.app_id, value?.files, value?.inputs]) + onSelect({ + app_id: app.id, + inputs: shouldClearValue ? {} : value?.inputs || {}, + files: shouldClearValue ? [] : value?.files || [], + }) + }, + [onSelect, value?.app_id, value?.files, value?.inputs], + ) - const handleFormChange = useCallback((inputs: Record<string, unknown>) => { - const newFiles = inputs['#image#'] - const nextInputs = { ...inputs } - delete nextInputs['#image#'] + const handleFormChange = useCallback( + (inputs: Record<string, unknown>) => { + const newFiles = inputs['#image#'] + const nextInputs = { ...inputs } + delete nextInputs['#image#'] + + onSelect({ + app_id: value?.app_id || '', + inputs: nextInputs, + files: newFiles ? [newFiles] : value?.files || [], + }) + }, + [onSelect, value?.app_id, value?.files], + ) - onSelect({ + const formattedValue = useMemo( + () => ({ app_id: value?.app_id || '', - inputs: nextInputs, - files: newFiles ? [newFiles] : value?.files || [], - }) - }, [onSelect, value?.app_id, value?.files]) - - const formattedValue = useMemo(() => ({ - app_id: value?.app_id || '', - inputs: { - ...value?.inputs, - ...(value?.files?.length ? { '#image#': value.files[0] } : {}), - }, - }), [value]) + inputs: { + ...value?.inputs, + ...(value?.files?.length ? { '#image#': value.files[0] } : {}), + }, + }), + [value], + ) return ( - <Popover - open={isShow} - onOpenChange={setIsShow} - > + <Popover open={isShow} onOpenChange={setIsShow}> <PopoverTrigger - aria-label={t($ => $['appSelector.label'], { ns: 'app' })} + aria-label={t(($) => $['appSelector.label'], { ns: 'app' })} disabled={disabled} - render={<button type="button" className="block w-full border-0 bg-transparent p-0 text-left" />} + render={ + <button type="button" className="block w-full border-0 bg-transparent p-0 text-left" /> + } > - <AppTrigger - open={isShow} - appDetail={currentAppInfo} - /> + <AppTrigger open={isShow} appDetail={currentAppInfo} /> </PopoverTrigger> <PopoverContent placement={placement} @@ -142,16 +139,13 @@ export function AppSelector({ > <div className="relative min-h-20 w-[389px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs"> <div className="flex flex-col gap-1 px-4 py-3"> - <div className="flex h-6 items-center system-sm-semibold text-text-secondary">{t($ => $['appSelector.label'], { ns: 'app' })}</div> + <div className="flex h-6 items-center system-sm-semibold text-text-secondary"> + {t(($) => $['appSelector.label'], { ns: 'app' })} + </div> <AppPicker placement="bottom" offset={offset} - trigger={( - <AppTrigger - open={isShowChooseApp} - appDetail={currentAppInfo} - /> - )} + trigger={<AppTrigger open={isShowChooseApp} appDetail={currentAppInfo} />} isShow={isShowChooseApp} onShowChange={setIsShowChooseApp} disabled={false} diff --git a/web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx b/web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx index cba393345c8538..f4bccb4c777796 100644 --- a/web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx @@ -13,18 +13,15 @@ type Props = Readonly<{ detail: PluginDetail }> -const ActionList = ({ - detail, -}: Props) => { +const ActionList = ({ detail }: Props) => { const { t } = useTranslation() // const providerBriefInfo = detail.declaration.datasource?.identity // const providerKey = `${detail.plugin_id}/${providerBriefInfo?.name}` const { data: dataSourceList } = useDataSourceList(true) const provider = useMemo(() => { - const result = dataSourceList?.find(collection => collection.plugin_id === detail.plugin_id) + const result = dataSourceList?.find((collection) => collection.plugin_id === detail.plugin_id) - if (result) - return transformDataSourceToTool(result) + if (result) return transformDataSourceToTool(result) }, [detail.plugin_id, dataSourceList]) const data: any = [] // const { data } = useBuiltinTools(providerKey) @@ -43,14 +40,17 @@ const ActionList = ({ // onSuccess: handleCredentialSettingUpdate, // }) - if (!data || !provider) - return null + if (!data || !provider) return null return ( <div className="px-4 pt-2 pb-4"> <div className="mb-1 py-1"> <div className="mb-1 flex h-6 items-center justify-between system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['detailPanel.actionNum'], { ns: 'plugin', num: data.length, action: data.length > 1 ? 'actions' : 'action' })} + {t(($) => $['detailPanel.actionNum'], { + ns: 'plugin', + num: data.length, + action: data.length > 1 ? 'actions' : 'action', + })} {/* {provider.is_team_authorization && provider.allow_delete && ( <Button variant='secondary' diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx index 053e5e7f27c66a..18d13f40c46f70 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx @@ -26,11 +26,13 @@ vi.mock('@/hooks/use-theme', () => ({ vi.mock('@/service/use-tools', () => ({ useAllToolProviders: () => ({ - data: [{ - name: 'tool-plugin/provider-a', - type: 'builtin', - allow_delete: true, - }], + data: [ + { + name: 'tool-plugin/provider-a', + type: 'builtin', + allow_delete: true, + }, + ], }), })) @@ -39,19 +41,21 @@ vi.mock('@/utils/var', () => ({ })) vi.mock('@/app/components/base/action-button', () => ({ - default: ({ onClick, children }: { onClick?: () => void, children: React.ReactNode }) => ( - <button data-testid="close-button" onClick={onClick}>{children}</button> + default: ({ onClick, children }: { onClick?: () => void; children: React.ReactNode }) => ( + <button data-testid="close-button" onClick={onClick}> + {children} + </button> ), })) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( <button onClick={onClick}>{children}</button> ), })) vi.mock('@/app/components/base/badge', () => ({ - default: ({ text, children }: { text?: React.ReactNode, children?: React.ReactNode }) => ( + default: ({ text, children }: { text?: React.ReactNode; children?: React.ReactNode }) => ( <div data-testid="badge">{text ?? children}</div> ), })) @@ -72,15 +76,20 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({ })) vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({ - default: ({ onSelect, trigger }: { - onSelect: (value: { version: string, unique_identifier: string, isDowngrade?: boolean }) => void + default: ({ + onSelect, + trigger, + }: { + onSelect: (value: { version: string; unique_identifier: string; isDowngrade?: boolean }) => void trigger: React.ReactNode }) => ( <div> {trigger} <button data-testid="version-select" - onClick={() => onSelect({ version: '2.0.0', unique_identifier: 'uid-2', isDowngrade: true })} + onClick={() => + onSelect({ version: '2.0.0', unique_identifier: 'uid-2', isDowngrade: true }) + } > select version </button> @@ -132,7 +141,9 @@ vi.mock('@/app/components/plugins/reference-setting-modal/auto-update-setting/ut vi.mock('../components', () => ({ HeaderModals: () => <div data-testid="header-modals" />, - PluginSourceBadge: ({ source }: { source: string }) => <div data-testid="source-badge">{source}</div>, + PluginSourceBadge: ({ source }: { source: string }) => ( + <div data-testid="source-badge">{source}</div> + ), })) vi.mock('../hooks', () => ({ @@ -174,42 +185,43 @@ vi.mock('../hooks', () => ({ }), })) -const createDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({ - id: 'plugin-1', - created_at: '2024-01-01', - updated_at: '2024-01-02', - name: 'tool-plugin', - plugin_id: 'tool-plugin', - plugin_unique_identifier: 'tool-plugin@1.0.0', - declaration: { - author: 'acme', - category: PluginCategoryEnum.tool, - name: 'provider-a', - label: { en_US: 'Tool Plugin' }, - description: { en_US: 'Tool plugin description' }, - icon: 'icon.png', - icon_dark: 'icon-dark.png', - verified: true, - tool: { - identity: { - name: 'provider-a', +const createDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => + ({ + id: 'plugin-1', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'tool-plugin', + plugin_id: 'tool-plugin', + plugin_unique_identifier: 'tool-plugin@1.0.0', + declaration: { + author: 'acme', + category: PluginCategoryEnum.tool, + name: 'provider-a', + label: { en_US: 'Tool Plugin' }, + description: { en_US: 'Tool plugin description' }, + icon: 'icon.png', + icon_dark: 'icon-dark.png', + verified: true, + tool: { + identity: { + name: 'provider-a', + }, }, - }, - } as PluginDetail['declaration'], - installation_id: 'install-1', - tenant_id: 'tenant-1', - endpoints_setups: 0, - endpoints_active: 0, - version: '1.0.0', - latest_version: '2.0.0', - latest_unique_identifier: 'uid-2', - source: PluginSource.marketplace, - status: 'active', - deprecated_reason: 'Deprecated', - alternative_plugin_id: 'plugin-2', - meta: undefined, - ...overrides, -}) as PluginDetail + } as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '2.0.0', + latest_unique_identifier: 'uid-2', + source: PluginSource.marketplace, + status: 'active', + deprecated_reason: 'Deprecated', + alternative_plugin_id: 'plugin-2', + meta: undefined, + ...overrides, + }) as PluginDetail describe('DetailHeader', () => { beforeEach(() => { @@ -223,8 +235,12 @@ describe('DetailHeader', () => { expect(screen.getByTestId('description')).toHaveTextContent('Tool plugin description') expect(screen.getByTestId('source-badge')).toHaveTextContent('marketplace') expect(screen.getByTestId('plugin-auth')).toHaveTextContent('tool-plugin/provider-a') - fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.moreActions' })) - expect(screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' })).toHaveAttribute('href', 'https://marketplace.example.com/plugins/acme/provider-a') + fireEvent.click( + screen.getByRole('button', { name: 'plugin.detailPanel.operation.moreActions' }), + ) + expect( + screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' }), + ).toHaveAttribute('href', 'https://marketplace.example.com/plugins/acme/provider-a') expect(screen.getByTestId('header-modals')).toBeInTheDocument() }) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/header-modals.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/header-modals.spec.tsx index 90abb1bf88b6c1..21bec2516d1479 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/header-modals.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/header-modals.spec.tsx @@ -7,7 +7,12 @@ import { PluginSource } from '../../../../types' import HeaderModals from '../header-modals' vi.mock('@/app/components/plugins/plugin-page/plugin-info', () => ({ - default: ({ repository, release, packageName, onHide }: { + default: ({ + repository, + release, + packageName, + onHide, + }: { repository: string release: string packageName: string @@ -17,13 +22,20 @@ vi.mock('@/app/components/plugins/plugin-page/plugin-info', () => ({ <div data-testid="plugin-info-repo">{repository}</div> <div data-testid="plugin-info-release">{release}</div> <div data-testid="plugin-info-package">{packageName}</div> - <button data-testid="plugin-info-close" onClick={onHide}>Close</button> + <button data-testid="plugin-info-close" onClick={onHide}> + Close + </button> </div> ), })) vi.mock('@/app/components/plugins/update-plugin/from-market-place', () => ({ - default: ({ pluginId, onSave, onCancel, isShowDowngradeWarningModal }: { + default: ({ + pluginId, + onSave, + onCancel, + isShowDowngradeWarningModal, + }: { pluginId: string onSave: () => void onCancel: () => void @@ -32,8 +44,12 @@ vi.mock('@/app/components/plugins/update-plugin/from-market-place', () => ({ <div data-testid="update-modal"> <div data-testid="update-plugin-id">{pluginId}</div> <div data-testid="update-downgrade-warning">{String(isShowDowngradeWarningModal)}</div> - <button data-testid="update-modal-save" onClick={onSave}>Save</button> - <button data-testid="update-modal-cancel" onClick={onCancel}>Cancel</button> + <button data-testid="update-modal-save" onClick={onSave}> + Save + </button> + <button data-testid="update-modal-cancel" onClick={onCancel}> + Cancel + </button> </div> ), })) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx index 08f5f836f4771c..82f2e4e9d41d37 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx @@ -12,7 +12,9 @@ describe('PluginSourceBadge', () => { it('should render marketplace source badge', () => { render(<PluginSourceBadge source={PluginSource.marketplace} />) - expect(screen.getByLabelText('plugin.detailPanel.categoryTip.marketplace')).toBeInTheDocument() + expect( + screen.getByLabelText('plugin.detailPanel.categoryTip.marketplace'), + ).toBeInTheDocument() }) it('should render github source badge', () => { @@ -70,7 +72,9 @@ describe('PluginSourceBadge', () => { it('should show marketplace tooltip', () => { render(<PluginSourceBadge source={PluginSource.marketplace} />) - expect(screen.getByLabelText('plugin.detailPanel.categoryTip.marketplace')).toBeInTheDocument() + expect( + screen.getByLabelText('plugin.detailPanel.categoryTip.marketplace'), + ).toBeInTheDocument() }) it('should show github tooltip', () => { @@ -95,22 +99,30 @@ describe('PluginSourceBadge', () => { describe('Icon Element Structure', () => { it('should render icon inside tooltip for marketplace', () => { const { container } = render(<PluginSourceBadge source={PluginSource.marketplace} />) - expect(container.querySelector('[aria-label="plugin.detailPanel.categoryTip.marketplace"]')).toBeInTheDocument() + expect( + container.querySelector('[aria-label="plugin.detailPanel.categoryTip.marketplace"]'), + ).toBeInTheDocument() }) it('should render icon inside tooltip for github', () => { const { container } = render(<PluginSourceBadge source={PluginSource.github} />) - expect(container.querySelector('[aria-label="plugin.detailPanel.categoryTip.github"]')).toBeInTheDocument() + expect( + container.querySelector('[aria-label="plugin.detailPanel.categoryTip.github"]'), + ).toBeInTheDocument() }) it('should render icon inside tooltip for local', () => { const { container } = render(<PluginSourceBadge source={PluginSource.local} />) - expect(container.querySelector('[aria-label="plugin.detailPanel.categoryTip.local"]')).toBeInTheDocument() + expect( + container.querySelector('[aria-label="plugin.detailPanel.categoryTip.local"]'), + ).toBeInTheDocument() }) it('should render icon inside tooltip for debugging', () => { const { container } = render(<PluginSourceBadge source={PluginSource.debugging} />) - expect(container.querySelector('[aria-label="plugin.detailPanel.categoryTip.debugging"]')).toBeInTheDocument() + expect( + container.querySelector('[aria-label="plugin.detailPanel.categoryTip.debugging"]'), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx index bb9d9f1f6d95d2..83b78b8746a59f 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx @@ -70,27 +70,26 @@ const HeaderModals: FC<HeaderModalsProps> = ({ <AlertDialog open={isShowDeleteConfirm} onOpenChange={(open) => { - if (!open) - hideDeleteConfirm() + if (!open) hideDeleteConfirm() }} > <AlertDialogContent backdropProps={{ forceRender: true }}> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $[`${i18nPrefix}.delete`], { ns: 'plugin' })} + {t(($) => $[`${i18nPrefix}.delete`], { ns: 'plugin' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $[`${i18nPrefix}.deleteContentLeft`], { ns: 'plugin' })} + {t(($) => $[`${i18nPrefix}.deleteContentLeft`], { ns: 'plugin' })} <span className="system-md-semibold text-text-secondary">{label[locale]}</span> - {t($ => $[`${i18nPrefix}.deleteContentRight`], { ns: 'plugin' })} + {t(($) => $[`${i18nPrefix}.deleteContentRight`], { ns: 'plugin' })} </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton disabled={deleting}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={onDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx index 7b3aacf2ad6904..d34231b827d39f 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx @@ -20,19 +20,24 @@ type PluginSourceBadgeProps = { const SOURCE_CONFIG_MAP: Record<PluginSource, SourceConfig | null> = { [PluginSource.marketplace]: { icon: <BoxSparkleFill className="size-3.5 text-text-tertiary hover:text-text-accent" />, - tipSelector: $ => $['detailPanel.categoryTip.marketplace'], + tipSelector: ($) => $['detailPanel.categoryTip.marketplace'], }, [PluginSource.github]: { icon: <Github className="size-3.5 text-text-secondary hover:text-text-primary" />, - tipSelector: $ => $['detailPanel.categoryTip.github'], + tipSelector: ($) => $['detailPanel.categoryTip.github'], }, [PluginSource.local]: { icon: <span aria-hidden className="i-ri-hard-drive-3-line size-3.5 text-text-tertiary" />, - tipSelector: $ => $['detailPanel.categoryTip.local'], + tipSelector: ($) => $['detailPanel.categoryTip.local'], }, [PluginSource.debugging]: { - icon: <span aria-hidden className="i-ri-bug-line size-3.5 text-text-tertiary hover:text-text-warning" />, - tipSelector: $ => $['detailPanel.categoryTip.debugging'], + icon: ( + <span + aria-hidden + className="i-ri-bug-line size-3.5 text-text-tertiary hover:text-text-warning" + /> + ), + tipSelector: ($) => $['detailPanel.categoryTip.debugging'], }, } @@ -40,8 +45,7 @@ const PluginSourceBadge: FC<PluginSourceBadgeProps> = ({ source }) => { const { t } = useTranslation() const config = SOURCE_CONFIG_MAP[source] - if (!config) - return null + if (!config) return null const tip = t(config.tipSelector, { ns: 'plugin' }) return ( @@ -49,15 +53,13 @@ const PluginSourceBadge: FC<PluginSourceBadgeProps> = ({ source }) => { <div className="mr-0.5 ml-1 system-xs-regular text-text-quaternary">·</div> <Tooltip> <TooltipTrigger - render={( + render={ <span aria-label={tip} className="inline-flex"> {config.icon} </span> - )} + } /> - <TooltipContent> - {tip} - </TooltipContent> + <TooltipContent>{tip}</TooltipContent> </Tooltip> </> ) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts index ddc501200230d1..fb9b996db417e7 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts @@ -26,14 +26,18 @@ const { mockRefreshPluginList: vi.fn(), mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })), mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])), - mockCheckForUpdates: vi.fn(() => ({ needUpdate: true, toastProps: { type: 'success', message: 'Update available' } })), + mockCheckForUpdates: vi.fn(() => ({ + needUpdate: true, + toastProps: { type: 'success', message: 'Update available' }, + })), mockToastNotify: vi.fn(), } }) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( - (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), + (message: string, options?: { type?: string }) => + mockToastNotify({ type: options?.type, message }), { success: (message: string) => mockToastNotify({ type: 'success', message }), error: (message: string) => mockToastNotify({ type: 'error', message }), @@ -529,10 +533,13 @@ describe('usePluginOperations', () => { await result.current.handleDelete() }) - expect(amplitude.trackEvent).toHaveBeenCalledWith('plugin_uninstalled', expect.objectContaining({ - plugin_id: 'test-plugin', - plugin_name: 'test-plugin-name', - })) + expect(amplitude.trackEvent).toHaveBeenCalledWith( + 'plugin_uninstalled', + expect.objectContaining({ + plugin_id: 'test-plugin', + plugin_name: 'test-plugin-name', + }), + ) }) it('should not call onUpdate when delete fails', async () => { diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts index 90c0de72c6c9db..8b23ec2492d68c 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts @@ -51,18 +51,12 @@ type UseDetailHeaderStateReturn = { export const useDetailHeaderState = (detail: PluginDetail): UseDetailHeaderStateReturn => { const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const category = detail.declaration?.category ?? PluginCategoryEnum.tool const { referenceSetting } = useReferenceSetting(category) - const { - source, - version, - latest_version, - latest_unique_identifier, - plugin_id, - } = detail + const { source, version, latest_version, latest_unique_identifier, plugin_id } = detail const isFromGitHub = source === PluginSource.github const isFromMarketplace = source === PluginSource.marketplace @@ -73,37 +67,41 @@ export const useDetailHeaderState = (detail: PluginDetail): UseDetailHeaderState }) const [isDowngrade, setIsDowngrade] = useState(false) - const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = useBoolean(false) - const [isShowPluginInfo, { setTrue: showPluginInfo, setFalse: hidePluginInfo }] = useBoolean(false) - const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = useBoolean(false) + const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = + useBoolean(false) + const [isShowPluginInfo, { setTrue: showPluginInfo, setFalse: hidePluginInfo }] = + useBoolean(false) + const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = + useBoolean(false) const [deleting, { setTrue: showDeleting, setFalse: hideDeleting }] = useBoolean(false) const hasNewVersion = useMemo(() => { - if (isFromMarketplace) - return !!latest_version && latest_version !== version + if (isFromMarketplace) return !!latest_version && latest_version !== version return false }, [isFromMarketplace, latest_version, version]) const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {} const isAutoUpgradeEnabled = useMemo(() => { - if (!enable_marketplace || !autoUpgradeInfo || !isFromMarketplace) - return false - if (autoUpgradeInfo.strategy_setting === 'disabled') - return false - if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all) + if (!enable_marketplace || !autoUpgradeInfo || !isFromMarketplace) return false + if (autoUpgradeInfo.strategy_setting === 'disabled') return false + if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all) return true + if ( + autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && + autoUpgradeInfo.include_plugins.includes(plugin_id) + ) return true - if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id)) - return true - if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id)) + if ( + autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && + !autoUpgradeInfo.exclude_plugins.includes(plugin_id) + ) return true return false }, [autoUpgradeInfo, plugin_id, isFromMarketplace, enable_marketplace]) const handleSetTargetVersion = useCallback((version: VersionTarget) => { setTargetVersion(version) - if (version.isDowngrade !== undefined) - setIsDowngrade(version.isDowngrade) + if (version.isDowngrade !== undefined) setIsDowngrade(version.isDowngrade) }, []) return { diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts index 4b2cf696c4e60f..f7eec6a2a8ea11 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts @@ -48,69 +48,73 @@ export const usePluginOperations = ({ const { id, meta, plugin_id } = detail const { author, category, name } = detail.declaration || detail - const handlePluginUpdated = useCallback((isDelete?: boolean) => { - invalidateCheckInstalled() - onUpdate?.(isDelete) - }, [invalidateCheckInstalled, onUpdate]) - - const handleUpdate = useCallback(async (isDowngrade?: boolean) => { - if (!canUpdatePlugin) - return - - if (isFromMarketplace) { - versionPicker.setIsDowngrade(!!isDowngrade) - modalStates.showUpdateModal() - return - } - - if (!meta?.repo || !meta?.version || !meta?.package) { - toast.error('Missing plugin metadata for GitHub update') - return - } - - const owner = meta.repo.split('/')[0] || author - const repo = meta.repo.split('/')[1] || name - const fetchedReleases = await fetchReleases(owner, repo) - if (fetchedReleases.length === 0) - return - - const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta.version) - toast(toastProps.message, { type: toastProps.type }) - - if (needUpdate) { - setShowUpdatePluginModal({ - onSaveCallback: () => { - handlePluginUpdated() - }, - payload: { - type: PluginSource.github, - category, - github: { - originalPackageInfo: { - id: detail.plugin_unique_identifier, - repo: meta.repo, - version: meta.version, - package: meta.package, - releases: fetchedReleases, + const handlePluginUpdated = useCallback( + (isDelete?: boolean) => { + invalidateCheckInstalled() + onUpdate?.(isDelete) + }, + [invalidateCheckInstalled, onUpdate], + ) + + const handleUpdate = useCallback( + async (isDowngrade?: boolean) => { + if (!canUpdatePlugin) return + + if (isFromMarketplace) { + versionPicker.setIsDowngrade(!!isDowngrade) + modalStates.showUpdateModal() + return + } + + if (!meta?.repo || !meta?.version || !meta?.package) { + toast.error('Missing plugin metadata for GitHub update') + return + } + + const owner = meta.repo.split('/')[0] || author + const repo = meta.repo.split('/')[1] || name + const fetchedReleases = await fetchReleases(owner, repo) + if (fetchedReleases.length === 0) return + + const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta.version) + toast(toastProps.message, { type: toastProps.type }) + + if (needUpdate) { + setShowUpdatePluginModal({ + onSaveCallback: () => { + handlePluginUpdated() + }, + payload: { + type: PluginSource.github, + category, + github: { + originalPackageInfo: { + id: detail.plugin_unique_identifier, + repo: meta.repo, + version: meta.version, + package: meta.package, + releases: fetchedReleases, + }, }, }, - }, - }) - } - }, [ - canUpdatePlugin, - isFromMarketplace, - meta, - author, - name, - fetchReleases, - checkForUpdates, - setShowUpdatePluginModal, - detail, - handlePluginUpdated, - modalStates, - versionPicker, - ]) + }) + } + }, + [ + canUpdatePlugin, + isFromMarketplace, + meta, + author, + name, + fetchReleases, + checkForUpdates, + setShowUpdatePluginModal, + detail, + handlePluginUpdated, + modalStates, + versionPicker, + ], + ) const handleUpdatedFromMarketplace = useCallback(() => { handlePluginUpdated() @@ -118,8 +122,7 @@ export const usePluginOperations = ({ }, [handlePluginUpdated, modalStates]) const handleDelete = useCallback(async () => { - if (!canDeletePlugin) - return + if (!canDeletePlugin) return modalStates.showDeleting() const res = await uninstallPlugin(id) @@ -127,7 +130,7 @@ export const usePluginOperations = ({ if (res.success) { modalStates.hideDeleteConfirm() - toast.success(t($ => $['action.deleteSuccess'], { ns: 'plugin' })) + toast.success(t(($) => $['action.deleteSuccess'], { ns: 'plugin' })) handlePluginUpdated(true) refreshPluginList({ category }) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx index fd14fcc6f108bf..b79b0566347428 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx @@ -28,7 +28,10 @@ import Description from '../../card/base/description' import OrgInfo from '../../card/base/org-info' import Title from '../../card/base/title' import useReferenceSetting from '../../plugin-page/use-reference-setting' -import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../../reference-setting-modal/auto-update-setting/utils' +import { + convertUTCDaySecondsToLocalSeconds, + timeOfDayToDayjs, +} from '../../reference-setting-modal/auto-update-setting/utils' import { PluginCategoryEnum, PluginSource } from '../../types' import { HeaderModals, PluginSourceBadge } from './components' import { useDetailHeaderState, usePluginOperations } from './hooks' @@ -42,10 +45,14 @@ type Props = Readonly<{ onUpdate?: (isDelete?: boolean) => void }> -const getIconSrc = (icon: string | undefined, iconDark: string | undefined, theme: string, tenantId: string): string => { +const getIconSrc = ( + icon: string | undefined, + iconDark: string | undefined, + theme: string, + tenantId: string, +): string => { const iconFileName = theme === 'dark' && iconDark ? iconDark : icon - if (!iconFileName) - return '' + if (!iconFileName) return '' return iconFileName.startsWith('http') ? iconFileName : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenantId}&filename=${iconFileName}` @@ -61,8 +68,7 @@ const getDetailUrl = ( ): string => { if (source === PluginSource.github) { const repo = meta?.repo - if (!repo) - return '' + if (!repo) return '' return `https://github.com/${repo}` } if (source === PluginSource.marketplace) @@ -79,10 +85,10 @@ const DetailHeader = ({ onUpdate, }: Props) => { const { t } = useTranslation() - const openReadmePanel = useReadmePanelStore(s => s.openReadmePanel) + const openReadmePanel = useReadmePanelStore((s) => s.openReadmePanel) const { data: timezone } = useQuery({ ...userProfileQueryOptions(), - select: data => data.profile.timezone ?? undefined, + select: (data) => data.profile.timezone ?? undefined, }) const { theme } = useTheme() const locale = useGetLanguage() @@ -103,7 +109,8 @@ const DetailHeader = ({ alternative_plugin_id, } = detail - const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail + const { author, category, name, label, description, icon, icon_dark, verified, tool } = + detail.declaration || detail const { modalStates, @@ -114,11 +121,7 @@ const DetailHeader = ({ isFromMarketplace, } = useDetailHeaderState(detail) - const { - handleUpdate, - handleUpdatedFromMarketplace, - handleDelete, - } = usePluginOperations({ + const { handleUpdate, handleUpdatedFromMarketplace, handleDelete } = usePluginOperations({ detail, modalStates, versionPicker, @@ -133,12 +136,13 @@ const DetailHeader = ({ const providerKey = `${plugin_id}/${providerBriefInfo?.name}` const { data: collectionList = [] } = useAllToolProviders(isTool) const provider = useMemo(() => { - return collectionList.find(collection => collection.name === providerKey) + return collectionList.find((collection) => collection.name === providerKey) }, [collectionList, providerKey]) const iconSrc = getIconSrc(icon, icon_dark, theme, tenant_id) const detailUrl = getDetailUrl(source, meta, author, name, currentLocale, theme) - const canViewReadme = !!detail.plugin_unique_identifier && !BUILTIN_TOOLS_ARRAY.includes(detail.id) + const canViewReadme = + !!detail.plugin_unique_identifier && !BUILTIN_TOOLS_ARRAY.includes(detail.id) const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {} const handleViewReadme = useCallback(() => { openReadmePanel({ @@ -147,7 +151,11 @@ const DetailHeader = ({ }) }, [detail, openReadmePanel]) - const handleVersionSelect = (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => { + const handleVersionSelect = (state: { + version: string + unique_identifier: string + isDowngrade?: boolean + }) => { versionPicker.setTargetVersion(state) handleUpdate(state.isDowngrade) } @@ -163,10 +171,20 @@ const DetailHeader = ({ } return ( - <div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}> + <div + className={cn( + 'shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', + isReadmeView && 'border-b-0 bg-transparent p-0', + )} + > <div className="flex"> {/* Plugin Icon */} - <div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}> + <div + className={cn( + 'overflow-hidden rounded-xl border border-components-panel-border-subtle', + isReadmeView && 'bg-components-panel-bg', + )} + > <Icon src={iconSrc} /> </div> @@ -175,7 +193,12 @@ const DetailHeader = ({ {/* Title Row */} <div className="flex h-5 items-center"> <Title title={label[locale]} /> - {verified && !isReadmeView && <Verified className="ml-0.5 size-4" text={t($ => $['marketplace.verifiedTip'], { ns: 'plugin' })} />} + {verified && !isReadmeView && ( + <Verified + className="ml-0.5 size-4" + text={t(($) => $['marketplace.verifiedTip'], { ns: 'plugin' })} + /> + )} {/* Version Picker */} {!!version && ( @@ -186,23 +209,29 @@ const DetailHeader = ({ pluginID={plugin_id} currentVersion={version} onSelect={handleVersionSelect} - trigger={( + trigger={ <Badge className={cn( 'mx-1', versionPicker.isShow && 'bg-state-base-hover', - (versionPicker.isShow || (canUpdatePlugin && isFromMarketplace)) && 'hover:bg-state-base-hover', + (versionPicker.isShow || (canUpdatePlugin && isFromMarketplace)) && + 'hover:bg-state-base-hover', )} uppercase={false} - text={( + text={ <> <div>{isFromGitHub ? (meta?.version ?? version ?? '') : version}</div> - {canUpdatePlugin && isFromMarketplace && !isReadmeView && <span aria-hidden className="ml-1 i-ri-arrow-left-right-line size-3 text-text-tertiary" />} + {canUpdatePlugin && isFromMarketplace && !isReadmeView && ( + <span + aria-hidden + className="ml-1 i-ri-arrow-left-right-line size-3 text-text-tertiary" + /> + )} </> - )} + } hasRedCornerMark={hasNewVersion} /> - )} + } /> )} @@ -210,16 +239,24 @@ const DetailHeader = ({ {isAutoUpgradeEnabled && !isReadmeView && ( <Tooltip> <TooltipTrigger - render={( + render={ <div> <Badge className="mr-1 cursor-pointer px-1"> <AutoUpdateLine className="size-3" /> </Badge> </div> - )} + } /> <TooltipContent> - {t($ => $['autoUpdate.nextUpdateTime'], { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })} + {t(($) => $['autoUpdate.nextUpdateTime'], { + ns: 'plugin', + time: timeOfDayToDayjs( + convertUTCDaySecondsToLocalSeconds( + autoUpgradeInfo?.upgrade_time_of_day || 0, + timezone!, + ), + ).format('hh:mm A'), + })} </TooltipContent> </Tooltip> )} @@ -229,19 +266,19 @@ const DetailHeader = ({ <Tooltip> <TooltipTrigger delay={300} - render={( + render={ <Button variant="secondary-accent" size="small" className="h-5!" onClick={handleTriggerLatestUpdate} > - {t($ => $['detailPanel.operation.update'], { ns: 'plugin' })} + {t(($) => $['detailPanel.operation.update'], { ns: 'plugin' })} </Button> - )} + } /> <TooltipContent> - {t($ => $['detailPanel.operation.updateTooltip'], { ns: 'plugin' })} + {t(($) => $['detailPanel.operation.updateTooltip'], { ns: 'plugin' })} </TooltipContent> </Tooltip> )} @@ -253,7 +290,7 @@ const DetailHeader = ({ <OrgInfo packageNameClassName="w-auto" orgName={author} - packageName={name?.includes('/') ? (name.split('/').pop() || '') : name} + packageName={name?.includes('/') ? name.split('/').pop() || '' : name} /> {!!source && <PluginSourceBadge source={source} />} </div> @@ -286,13 +323,22 @@ const DetailHeader = ({ status={status} deprecatedReason={deprecated_reason} alternativePluginId={alternative_plugin_id} - alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })} + alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { + language: currentLocale, + theme, + })} className="mt-3" /> )} {/* Description */} - {!isReadmeView && <Description className="mt-3 mb-2 h-auto" text={description[locale]} descriptionLineRows={2} />} + {!isReadmeView && ( + <Description + className="mt-3 mb-2 h-auto" + text={description[locale]} + descriptionLineRows={2} + /> + )} {/* Plugin Auth for Tools */} {category === PluginCategoryEnum.tool && !isReadmeView && ( diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx index 7d892d035236bb..7376cbe59c5bc0 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx @@ -19,7 +19,10 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { CopyCheck } from '@/app/components/base/icons/src/vender/line/files' -import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema' +import { + addDefaultValue, + toolCredentialToFormSchemas, +} from '@/app/components/tools/utils/to-form-schema' import { useDeleteEndpoint, useDisableEndpoint, @@ -37,26 +40,20 @@ type Props = Readonly<{ handleChange: () => void }> -const EndpointCard = ({ - pluginDetail, - data, - handleChange, -}: Props) => { +const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => { const { t } = useTranslation() const [active, setActive] = useState(data.enabled) const endpointID = data.id // switch - const [isShowDisableConfirm, { - setTrue: showDisableConfirm, - setFalse: hideDisableConfirm, - }] = useBoolean(false) + const [isShowDisableConfirm, { setTrue: showDisableConfirm, setFalse: hideDisableConfirm }] = + useBoolean(false) const { mutate: enableEndpoint } = useEnableEndpoint({ onSuccess: async () => { await handleChange() }, onError: () => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) setActive(false) }, }) @@ -66,7 +63,7 @@ const EndpointCard = ({ hideDisableConfirm() }, onError: () => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) setActive(false) }, }) @@ -74,33 +71,30 @@ const EndpointCard = ({ if (state) { setActive(true) enableEndpoint(endpointID) - } - else { + } else { setActive(false) showDisableConfirm() } } // delete - const [isShowDeleteConfirm, { - setTrue: showDeleteConfirm, - setFalse: hideDeleteConfirm, - }] = useBoolean(false) + const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = + useBoolean(false) const { mutate: deleteEndpoint } = useDeleteEndpoint({ onSuccess: async () => { await handleChange() hideDeleteConfirm() }, onError: () => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) }, }) // update - const [isShowEndpointModal, { - setTrue: showEndpointModalConfirm, - setFalse: hideEndpointModalConfirm, - }] = useBoolean(false) + const [ + isShowEndpointModal, + { setTrue: showEndpointModalConfirm, setFalse: hideEndpointModalConfirm }, + ] = useBoolean(false) const formSchemas = useMemo(() => { return toolCredentialToFormSchemas([NAME_FIELD, ...data.declaration.settings]) }, [data.declaration.settings]) @@ -117,13 +111,14 @@ const EndpointCard = ({ hideEndpointModalConfirm() }, onError: () => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) }, }) - const handleUpdate = (state: Record<string, unknown>) => updateEndpoint({ - endpointID, - state, - }) + const handleUpdate = (state: Record<string, unknown>) => + updateEndpoint({ + endpointID, + state, + }) const [isCopied, setIsCopied] = useState(false) const handleCopy = (value: string) => { @@ -132,8 +127,7 @@ const EndpointCard = ({ } const handleDisableConfirmOpenChange = (open: boolean) => { - if (open) - return + if (open) return hideDisableConfirm() setActive(true) @@ -150,7 +144,7 @@ const EndpointCard = ({ } }, [isCopied]) - const copyLabel = t($ => $[`operation.${isCopied ? 'copied' : 'copy'}`], { ns: 'common' }) + const copyLabel = t(($) => $[`operation.${isCopied ? 'copied' : 'copy'}`], { ns: 'common' }) return ( <div className="rounded-xl bg-background-section-burn p-0.5"> @@ -164,95 +158,99 @@ const EndpointCard = ({ <ActionButton onClick={showEndpointModalConfirm}> <span aria-hidden className="i-ri-edit-line size-4" /> </ActionButton> - <ActionButton onClick={showDeleteConfirm} className="text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"> + <ActionButton + onClick={showDeleteConfirm} + className="text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive" + > <span aria-hidden className="i-ri-delete-bin-line size-4" /> </ActionButton> </div> </div> - {(data.declaration.endpoints ?? []).filter(endpoint => !endpoint.hidden).map((endpoint, index) => ( - <div key={index} className="flex h-6 items-center"> - <div className="w-12 shrink-0 system-xs-regular text-text-tertiary">{endpoint.method}</div> - <div className="group/item flex grow items-center truncate system-xs-regular text-text-secondary"> - <div className="truncate">{`${data.url}${endpoint.path}`}</div> - <Tooltip> - <TooltipTrigger - render={( - <ActionButton - aria-label={copyLabel} - className="ml-2 hidden shrink-0 group-hover/item:flex" - onClick={() => handleCopy(`${data.url}${endpoint.path}`)} - > - {isCopied - ? <CopyCheck aria-hidden className="size-3.5 text-text-tertiary" /> - : <span aria-hidden className="i-ri-clipboard-line size-3.5 text-text-tertiary" />} - </ActionButton> - )} - /> - <TooltipContent placement="top"> - {copyLabel} - </TooltipContent> - </Tooltip> + {(data.declaration.endpoints ?? []) + .filter((endpoint) => !endpoint.hidden) + .map((endpoint, index) => ( + <div key={index} className="flex h-6 items-center"> + <div className="w-12 shrink-0 system-xs-regular text-text-tertiary"> + {endpoint.method} + </div> + <div className="group/item flex grow items-center truncate system-xs-regular text-text-secondary"> + <div className="truncate">{`${data.url}${endpoint.path}`}</div> + <Tooltip> + <TooltipTrigger + render={ + <ActionButton + aria-label={copyLabel} + className="ml-2 hidden shrink-0 group-hover/item:flex" + onClick={() => handleCopy(`${data.url}${endpoint.path}`)} + > + {isCopied ? ( + <CopyCheck aria-hidden className="size-3.5 text-text-tertiary" /> + ) : ( + <span + aria-hidden + className="i-ri-clipboard-line size-3.5 text-text-tertiary" + /> + )} + </ActionButton> + } + /> + <TooltipContent placement="top">{copyLabel}</TooltipContent> + </Tooltip> + </div> </div> - </div> - ))} + ))} </div> <div className="flex items-center justify-between p-2 pl-3"> {active && ( <div className="flex items-center gap-1 system-xs-semibold-uppercase text-util-colors-green-green-600"> <StatusDot status="success" /> - {t($ => $['detailPanel.serviceOk'], { ns: 'plugin' })} + {t(($) => $['detailPanel.serviceOk'], { ns: 'plugin' })} </div> )} {!active && ( <div className="flex items-center gap-1 system-xs-semibold-uppercase text-text-tertiary"> <StatusDot status="disabled" /> - {t($ => $['detailPanel.disabled'], { ns: 'plugin' })} + {t(($) => $['detailPanel.disabled'], { ns: 'plugin' })} </div> )} - <Switch - className="ml-3" - checked={active} - onCheckedChange={handleSwitch} - size="sm" - /> + <Switch className="ml-3" checked={active} onCheckedChange={handleSwitch} size="sm" /> </div> - <AlertDialog - open={isShowDisableConfirm} - onOpenChange={handleDisableConfirmOpenChange} - > + <AlertDialog open={isShowDisableConfirm} onOpenChange={handleDisableConfirmOpenChange}> <AlertDialogContent backdropProps={{ forceRender: true }}> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['detailPanel.endpointDisableTip'], { ns: 'plugin' })} + {t(($) => $['detailPanel.endpointDisableTip'], { ns: 'plugin' })} </AlertDialogTitle> <div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['detailPanel.endpointDisableContent'], { ns: 'plugin', name: data.name })} + {t(($) => $['detailPanel.endpointDisableContent'], { ns: 'plugin', name: data.name })} </div> </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={() => disableEndpoint(endpointID)}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> </AlertDialog> - <AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}> + <AlertDialog open={isShowDeleteConfirm} onOpenChange={(open) => !open && hideDeleteConfirm()}> <AlertDialogContent backdropProps={{ forceRender: true }}> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['detailPanel.endpointDeleteTip'], { ns: 'plugin' })} + {t(($) => $['detailPanel.endpointDeleteTip'], { ns: 'plugin' })} </AlertDialogTitle> <div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['detailPanel.endpointDeleteContent'], { ns: 'plugin', name: data.name })} + {t(($) => $['detailPanel.endpointDeleteContent'], { ns: 'plugin', name: data.name })} </div> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={() => deleteEndpoint(endpointID)}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx index d95e338c4ea4d1..980d97f70af5c8 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx @@ -30,10 +30,7 @@ type EndpointListContentProps = Readonly<{ detail: PluginDetail }> -const EndpointListContent = ({ - declaration, - detail, -}: EndpointListContentProps) => { +const EndpointListContent = ({ declaration, detail }: EndpointListContentProps) => { const { t } = useTranslation() const docLink = useDocLink() const pluginUniqueID = detail.plugin_unique_identifier @@ -42,10 +39,8 @@ const EndpointListContent = ({ const invalidateEndpointList = useInvalidateEndpointList() const invalidateInstalledPluginList = useInvalidateInstalledPluginList() - const [isShowEndpointModal, { - setTrue: showEndpointModal, - setFalse: hideEndpointModal, - }] = useBoolean(false) + const [isShowEndpointModal, { setTrue: showEndpointModal, setFalse: hideEndpointModal }] = + useBoolean(false) const formSchemas = useMemo(() => { return toolCredentialToFormSchemas([NAME_FIELD, ...declaration.settings]) @@ -58,35 +53,38 @@ const EndpointListContent = ({ hideEndpointModal() }, onError: () => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) }, }) - const handleCreate = (state: Record<string, any>) => createEndpoint({ - pluginUniqueID, - state, - }) + const handleCreate = (state: Record<string, any>) => + createEndpoint({ + pluginUniqueID, + state, + }) - if (!data) - return null + if (!data) return null return ( <div className={cn('border-divider-subtle px-4 py-2', showTopBorder && 'border-t')}> <div className="mb-1 flex h-6 items-center justify-between system-sm-semibold-uppercase text-text-secondary"> <div className="flex items-center gap-0.5"> - {t($ => $['detailPanel.endpoints'], { ns: 'plugin' })} + {t(($) => $['detailPanel.endpoints'], { ns: 'plugin' })} <Popover> <PopoverTrigger openOnHover - aria-label={t($ => $['detailPanel.endpointsTip'], { ns: 'plugin' })} - render={( + aria-label={t(($) => $['detailPanel.endpointsTip'], { ns: 'plugin' })} + render={ <button type="button" className="flex size-4 shrink-0 items-center justify-center rounded-sm p-px outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover" > - <span aria-hidden className="i-ri-question-line size-3.5 text-text-quaternary hover:text-text-tertiary" /> + <span + aria-hidden + className="i-ri-question-line size-3.5 text-text-quaternary hover:text-text-tertiary" + /> </button> - )} + } /> <PopoverContent placement="right" @@ -96,7 +94,9 @@ const EndpointListContent = ({ <div className="flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle"> <span aria-hidden className="i-ri-apps-2-add-line size-4 text-text-tertiary" /> </div> - <div className="system-xs-regular text-text-tertiary">{t($ => $['detailPanel.endpointsTip'], { ns: 'plugin' })}</div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['detailPanel.endpointsTip'], { ns: 'plugin' })} + </div> <a href={docLink('/develop-plugin/getting-started/getting-started-dify-plugin')} target="_blank" @@ -104,24 +104,26 @@ const EndpointListContent = ({ className="inline-flex cursor-pointer items-center gap-1 system-xs-regular text-text-accent" > <span aria-hidden className="i-ri-book-open-line size-3" /> - {t($ => $['detailPanel.endpointsDocLink'], { ns: 'plugin' })} + {t(($) => $['detailPanel.endpointsDocLink'], { ns: 'plugin' })} </a> </div> </PopoverContent> </Popover> </div> <ActionButton - aria-label={t($ => $['detailPanel.endpointModalTitle'], { ns: 'plugin' })} + aria-label={t(($) => $['detailPanel.endpointModalTitle'], { ns: 'plugin' })} onClick={showEndpointModal} > <span aria-hidden className="i-ri-add-line size-4" /> </ActionButton> </div> {data.endpoints.length === 0 && ( - <div className="mb-1 flex justify-center rounded-[10px] bg-background-section p-3 system-xs-regular text-text-tertiary">{t($ => $['detailPanel.endpointsEmpty'], { ns: 'plugin' })}</div> + <div className="mb-1 flex justify-center rounded-[10px] bg-background-section p-3 system-xs-regular text-text-tertiary"> + {t(($) => $['detailPanel.endpointsEmpty'], { ns: 'plugin' })} + </div> )} <div className="flex flex-col gap-2"> - {data.endpoints.map(item => ( + {data.endpoints.map((item) => ( <EndpointCard key={item.id} data={item} @@ -147,15 +149,9 @@ const EndpointListContent = ({ const EndpointList = ({ detail }: Props) => { const declaration = detail.declaration.endpoint - if (!declaration) - return null + if (!declaration) return null - return ( - <EndpointListContent - declaration={declaration} - detail={detail} - /> - ) + return <EndpointListContent declaration={declaration} detail={detail} /> } export default EndpointList diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx index 1b46bba391e789..e52b4164cbcdd2 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx @@ -32,8 +32,7 @@ type Props = Readonly<{ const extractDefaultValues = (schemas: any[]) => { const result: Record<string, any> = {} for (const field of schemas) { - if (field.default !== undefined) - result[field.name] = field.default + if (field.default !== undefined) result[field.name] = field.default } return result } @@ -47,18 +46,22 @@ const EndpointModal: FC<Props> = ({ }) => { const getValueFromI18nObject = useRenderI18nObject() const { t } = useTranslation() - const initialValues = Object.keys(defaultValues).length > 0 - ? defaultValues - : extractDefaultValues(formSchemas) + const initialValues = + Object.keys(defaultValues).length > 0 ? defaultValues : extractDefaultValues(formSchemas) const [tempCredential, setTempCredential] = React.useState<any>(initialValues) const handleSave = () => { for (const field of formSchemas) { if (field.required && !tempCredential[field.name]) { - toast.error(t($ => $['errorMsg.fieldRequired'], { - ns: 'common', - field: typeof field.label === 'string' ? field.label : getValueFromI18nObject(field.label as Record<string, string>), - })) + toast.error( + t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: + typeof field.label === 'string' + ? field.label + : getValueFromI18nObject(field.label as Record<string, string>), + }), + ) return } } @@ -70,10 +73,8 @@ const EndpointModal: FC<Props> = ({ const value = processedCredential[field.name] if (typeof value === 'string') processedCredential[field.name] = value === 'true' || value === '1' || value === 'True' - else if (typeof value === 'number') - processedCredential[field.name] = value === 1 - else if (typeof value === 'boolean') - processedCredential[field.name] = value + else if (typeof value === 'number') processedCredential[field.name] = value === 1 + else if (typeof value === 'boolean') processedCredential[field.name] = value } }) @@ -86,23 +87,30 @@ const EndpointModal: FC<Props> = ({ modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DrawerPortal> <DrawerBackdrop className="bg-black/30" /> <DrawerViewport> - <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-[400px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerPopup + className={cn( + 'justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-[400px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border', + )} + > <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> <div className="p-4 pb-2"> <div className="flex items-center justify-between"> - <div className="system-xl-semibold text-text-primary">{t($ => $['detailPanel.endpointModalTitle'], { ns: 'plugin' })}</div> + <div className="system-xl-semibold text-text-primary"> + {t(($) => $['detailPanel.endpointModalTitle'], { ns: 'plugin' })} + </div> <ActionButton onClick={onCancel}> <RiCloseLine className="size-4" /> </ActionButton> </div> - <div className="mt-0.5 system-xs-regular text-text-tertiary">{t($ => $['detailPanel.endpointModalDesc'], { ns: 'plugin' })}</div> + <div className="mt-0.5 system-xs-regular text-text-tertiary"> + {t(($) => $['detailPanel.endpointModalDesc'], { ns: 'plugin' })} + </div> <ReadmeEntrance pluginDetail={pluginDetail} className="px-0 pt-3" /> </div> <div className="grow overflow-y-auto"> @@ -117,25 +125,29 @@ const EndpointModal: FC<Props> = ({ showOnVariableMap={{}} validating={false} inputClassName="bg-components-input-bg-normal hover:bg-components-input-bg-hover" - fieldMoreInfo={item => item.url - ? ( - <a - href={item.url} - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center body-xs-regular text-text-accent-secondary" - > - {t($ => $.howToGet, { ns: 'tools' })} - <RiArrowRightUpLine className="ml-1 size-3" /> - </a> - ) - : null} + fieldMoreInfo={(item) => + item.url ? ( + <a + href={item.url} + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center body-xs-regular text-text-accent-secondary" + > + {t(($) => $.howToGet, { ns: 'tools' })} + <RiArrowRightUpLine className="ml-1 size-3" /> + </a> + ) : null + } /> </div> <div className={cn('flex justify-end p-4 pt-0')}> <div className="flex gap-2"> - <Button onClick={onCancel}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" onClick={handleSave}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button onClick={onCancel}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button variant="primary" onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> </div> </div> diff --git a/web/app/components/plugins/plugin-detail-panel/index.tsx b/web/app/components/plugins/plugin-detail-panel/index.tsx index a64da14d5ba2aa..3fc325dbace815 100644 --- a/web/app/components/plugins/plugin-detail-panel/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/index.tsx @@ -38,29 +38,32 @@ const PluginDetailPanel: FC<Props> = ({ onUpdate, onHide, }) => { - const handleUpdate = useCallback((isDelete = false) => { - if (isDelete) - onHide() - onUpdate() - }, [onHide, onUpdate]) + const handleUpdate = useCallback( + (isDelete = false) => { + if (isDelete) onHide() + onUpdate() + }, + [onHide, onUpdate], + ) const { setDetail } = usePluginStore() useEffect(() => { - setDetail(!detail - ? undefined - : { - plugin_id: detail.plugin_id, - provider: `${detail.plugin_id}/${detail.declaration.name}`, - plugin_unique_identifier: detail.plugin_unique_identifier || '', - declaration: detail.declaration, - name: detail.name, - id: detail.id, - }) + setDetail( + !detail + ? undefined + : { + plugin_id: detail.plugin_id, + provider: `${detail.plugin_id}/${detail.declaration.name}`, + plugin_unique_identifier: detail.plugin_unique_identifier || '', + declaration: detail.declaration, + name: detail.name, + id: detail.id, + }, + ) }, [detail, setDetail]) - if (!detail) - return null + if (!detail) return null return ( <Drawer @@ -68,14 +71,17 @@ const PluginDetailPanel: FC<Props> = ({ modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DrawerPortal> <DrawerBackdrop className="bg-transparent" /> <DrawerViewport> - <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-[400px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerPopup + className={cn( + 'justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-[400px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border', + )} + > <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> {detail && ( <> @@ -96,10 +102,14 @@ const PluginDetailPanel: FC<Props> = ({ </> )} {!!detail.declaration.tool && <ActionList detail={detail} />} - {!!detail.declaration.agent_strategy && <AgentStrategyList detail={detail} />} + {!!detail.declaration.agent_strategy && ( + <AgentStrategyList detail={detail} /> + )} {!!detail.declaration.endpoint && <EndpointList detail={detail} />} {!!detail.declaration.model && <ModelList detail={detail} />} - {!!detail.declaration.datasource && <DatasourceActionList detail={detail} />} + {!!detail.declaration.datasource && ( + <DatasourceActionList detail={detail} /> + )} </div> <ReadmeEntrance pluginDetail={detail} className="mt-auto" /> </div> diff --git a/web/app/components/plugins/plugin-detail-panel/model-list.tsx b/web/app/components/plugins/plugin-detail-panel/model-list.tsx index e14fe8f114410b..7c4e6523b35acf 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-list.tsx @@ -9,20 +9,21 @@ type Props = Readonly<{ detail: PluginDetail }> -const ModelList = ({ - detail, -}: Props) => { +const ModelList = ({ detail }: Props) => { const { t } = useTranslation() - const { data: res } = useModelProviderModelList(`${detail.plugin_id}/${detail.declaration.model.provider}`) + const { data: res } = useModelProviderModelList( + `${detail.plugin_id}/${detail.declaration.model.provider}`, + ) - if (!res) - return null + if (!res) return null return ( <div className="px-4 py-2"> - <div className="mb-1 flex h-6 items-center system-sm-semibold-uppercase text-text-secondary">{t($ => $['detailPanel.modelNum'], { ns: 'plugin', num: res.data.length })}</div> + <div className="mb-1 flex h-6 items-center system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['detailPanel.modelNum'], { ns: 'plugin', num: res.data.length })} + </div> <div className="flex flex-col"> - {res.data.map(model => ( + {res.data.map((model) => ( <div key={model.model} className="flex h-6 items-center py-1"> <ModelIcon className="mr-2 shrink-0" diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx index 2dd3b6631c0d4c..bd08e58ce6240b 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx @@ -1,8 +1,14 @@ -import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + Model, + ModelItem, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' - +import { + ConfigurationMethodEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' // Import component after mocks import ModelParameterModal from '../index' @@ -11,7 +17,8 @@ import ModelParameterModal from '../index' const mockToastNotify = vi.fn() vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( - (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), + (message: string, options?: { type?: string }) => + mockToastNotify({ type: options?.type, message }), { success: (message: string) => mockToastNotify({ type: 'success', message }), error: (message: string) => mockToastNotify({ type: 'error', message }), @@ -65,21 +72,31 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () // Mock fetchAndMergeValidCompletionParams const mockFetchAndMergeValidCompletionParams = vi.fn() vi.mock('@/utils/completion-params', () => ({ - fetchAndMergeValidCompletionParams: (...args: unknown[]) => mockFetchAndMergeValidCompletionParams(...args), + fetchAndMergeValidCompletionParams: (...args: unknown[]) => + mockFetchAndMergeValidCompletionParams(...args), })) // Mock child components vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ defaultModel, modelList, scopeFeatures, triggerClassName, readonly, onSelect }: { - defaultModel?: { provider?: string, model?: string } + default: ({ + defaultModel, + modelList, + scopeFeatures, + triggerClassName, + readonly, + onSelect, + }: { + defaultModel?: { provider?: string; model?: string } modelList?: Model[] scopeFeatures?: string[] triggerClassName?: string readonly?: boolean - onSelect?: (model: { provider: string, model: string }) => void + onSelect?: (model: { provider: string; model: string }) => void }) => { - const currentProvider = modelList?.find(model => model.provider === defaultModel?.provider) - const currentModel = currentProvider?.models.find(model => model.model === defaultModel?.model) + const currentProvider = modelList?.find((model) => model.provider === defaultModel?.provider) + const currentModel = currentProvider?.models.find( + (model) => model.model === defaultModel?.model, + ) const hasDeprecated = !!defaultModel && (!currentProvider || !currentModel) const modelDisabled = currentModel?.status !== ModelStatusEnum.active @@ -110,33 +127,49 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec }, })) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger', () => ({ - default: ({ disabled, hasDeprecated, currentProvider, currentModel, providerName, modelId, scope }: { - disabled?: boolean - hasDeprecated?: boolean - currentProvider?: Model - currentModel?: ModelItem - providerName?: string - modelId?: string - scope?: string - }) => ( - <div - data-testid="agent-model-trigger" - data-disabled={disabled} - data-has-deprecated={hasDeprecated} - data-provider={providerName} - data-model={modelId} - data-scope={scope} - data-has-current-provider={!!currentProvider} - data-has-current-model={!!currentModel} - > - Agent Model Trigger - </div> - ), -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger', + () => ({ + default: ({ + disabled, + hasDeprecated, + currentProvider, + currentModel, + providerName, + modelId, + scope, + }: { + disabled?: boolean + hasDeprecated?: boolean + currentProvider?: Model + currentModel?: ModelItem + providerName?: string + modelId?: string + scope?: string + }) => ( + <div + data-testid="agent-model-trigger" + data-disabled={disabled} + data-has-deprecated={hasDeprecated} + data-provider={providerName} + data-model={modelId} + data-scope={scope} + data-has-current-provider={!!currentProvider} + data-has-current-model={!!currentModel} + > + Agent Model Trigger + </div> + ), + }), +) vi.mock('../llm-params-panel', () => ({ - default: ({ provider, modelId, onCompletionParamsChange, isAdvancedMode }: { + default: ({ + provider, + modelId, + onCompletionParamsChange, + isAdvancedMode, + }: { provider: string modelId: string completionParams?: Record<string, unknown> @@ -156,7 +189,11 @@ vi.mock('../llm-params-panel', () => ({ })) vi.mock('../tts-params-panel', () => ({ - default: ({ language, voice, onChange }: { + default: ({ + language, + voice, + onChange, + }: { currentModel?: ModelItem language?: string voice?: string @@ -205,7 +242,9 @@ const createModel = (overrides: Partial<Model> = {}): Model => ({ /** * Factory function to create default props */ -const createDefaultProps = (overrides: Partial<Parameters<typeof ModelParameterModal>[0]> = {}) => ({ +const createDefaultProps = ( + overrides: Partial<Parameters<typeof ModelParameterModal>[0]> = {}, +) => ({ isAdvancedMode: false, value: null, setModel: vi.fn(), @@ -215,14 +254,16 @@ const createDefaultProps = (overrides: Partial<Parameters<typeof ModelParameterM /** * Helper to set up model lists for testing */ -const setupModelLists = (config: { - textGeneration?: Model[] - textEmbedding?: Model[] - rerank?: Model[] - moderation?: Model[] - stt?: Model[] - tts?: Model[] -} = {}) => { +const setupModelLists = ( + config: { + textGeneration?: Model[] + textEmbedding?: Model[] + rerank?: Model[] + moderation?: Model[] + stt?: Model[] + tts?: Model[] + } = {}, +) => { mockTextGenerationList.length = 0 mockTextEmbeddingList.length = 0 mockRerankList.length = 0 @@ -230,24 +271,19 @@ const setupModelLists = (config: { mockSttList.length = 0 mockTtsList.length = 0 - if (config.textGeneration) - mockTextGenerationList.push(...config.textGeneration) - if (config.textEmbedding) - mockTextEmbeddingList.push(...config.textEmbedding) - if (config.rerank) - mockRerankList.push(...config.rerank) - if (config.moderation) - mockModerationList.push(...config.moderation) - if (config.stt) - mockSttList.push(...config.stt) - if (config.tts) - mockTtsList.push(...config.tts) + if (config.textGeneration) mockTextGenerationList.push(...config.textGeneration) + if (config.textEmbedding) mockTextEmbeddingList.push(...config.textEmbedding) + if (config.rerank) mockRerankList.push(...config.rerank) + if (config.moderation) mockModerationList.push(...config.moderation) + if (config.stt) mockSttList.push(...config.stt) + if (config.tts) mockTtsList.push(...config.tts) } // ==================== Tests ==================== describe('ModelParameterModal', () => { - const openSettings = () => fireEvent.click(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })) + const openSettings = () => + fireEvent.click(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })) beforeEach(() => { vi.clearAllMocks() @@ -688,7 +724,10 @@ describe('ModelParameterModal', () => { it('should set hasDeprecated to true when model is not found', () => { // Arrange - const model = createModel({ provider: 'openai', models: [createModelItem({ model: 'gpt-3.5' })] }) + const model = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'gpt-3.5' })], + }) setupModelLists({ textGeneration: [model] }) const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) @@ -845,10 +884,19 @@ describe('ModelParameterModal', () => { const setModel = vi.fn() const textGenModel = createModel({ provider: 'openai', - models: [createModelItem({ model: 'gpt-4', model_type: ModelTypeEnum.textGeneration, model_properties: { mode: 'chat' } })], + models: [ + createModelItem({ + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + model_properties: { mode: 'chat' }, + }), + ], }) setupModelLists({ textGeneration: [textGenModel] }) - mockFetchAndMergeValidCompletionParams.mockResolvedValue({ params: { temperature: 0.7 }, removedDetails: {} }) + mockFetchAndMergeValidCompletionParams.mockResolvedValue({ + params: { temperature: 0.7 }, + removedDetails: {}, + }) const props = createDefaultProps({ setModel, scope: ModelTypeEnum.textGeneration }) // Act @@ -870,7 +918,13 @@ describe('ModelParameterModal', () => { const setModel = vi.fn() const textGenModel = createModel({ provider: 'openai', - models: [createModelItem({ model: 'gpt-4', model_type: ModelTypeEnum.textGeneration, model_properties: { mode: 'chat' } })], + models: [ + createModelItem({ + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + model_properties: { mode: 'chat' }, + }), + ], }) setupModelLists({ textGeneration: [textGenModel] }) mockFetchAndMergeValidCompletionParams.mockResolvedValue({ @@ -902,7 +956,13 @@ describe('ModelParameterModal', () => { const setModel = vi.fn() const textGenModel = createModel({ provider: 'openai', - models: [createModelItem({ model: 'gpt-4', model_type: ModelTypeEnum.textGeneration, model_properties: { mode: 'chat' } })], + models: [ + createModelItem({ + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + model_properties: { mode: 'chat' }, + }), + ], }) setupModelLists({ textGeneration: [textGenModel] }) mockFetchAndMergeValidCompletionParams.mockRejectedValue(new Error('Network error')) @@ -929,11 +989,13 @@ describe('ModelParameterModal', () => { const setModel = vi.fn() const textGenModel = createModel({ provider: 'openai', - models: [createModelItem({ - model: 'gpt-4', - model_type: ModelTypeEnum.textGeneration, - status: ModelStatusEnum.active, - })], + models: [ + createModelItem({ + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + status: ModelStatusEnum.active, + }), + ], }) setupModelLists({ textGeneration: [textGenModel] }) const props = createDefaultProps({ @@ -966,11 +1028,13 @@ describe('ModelParameterModal', () => { const setModel = vi.fn() const ttsModel = createModel({ provider: 'openai', - models: [createModelItem({ - model: 'tts-1', - model_type: ModelTypeEnum.tts, - status: ModelStatusEnum.active, - })], + models: [ + createModelItem({ + model: 'tts-1', + model_type: ModelTypeEnum.tts, + status: ModelStatusEnum.active, + }), + ], }) setupModelLists({ tts: [ttsModel] }) const props = createDefaultProps({ @@ -1004,11 +1068,13 @@ describe('ModelParameterModal', () => { // Arrange const textGenModel = createModel({ provider: 'openai', - models: [createModelItem({ - model: 'gpt-4', - model_type: ModelTypeEnum.textGeneration, - status: ModelStatusEnum.active, - })], + models: [ + createModelItem({ + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + status: ModelStatusEnum.active, + }), + ], }) setupModelLists({ textGeneration: [textGenModel] }) const props = createDefaultProps({ @@ -1030,11 +1096,13 @@ describe('ModelParameterModal', () => { // Arrange const ttsModel = createModel({ provider: 'openai', - models: [createModelItem({ - model: 'tts-1', - model_type: ModelTypeEnum.tts, - status: ModelStatusEnum.active, - })], + models: [ + createModelItem({ + model: 'tts-1', + model_type: ModelTypeEnum.tts, + status: ModelStatusEnum.active, + }), + ], }) setupModelLists({ tts: [ttsModel] }) const props = createDefaultProps({ @@ -1056,11 +1124,13 @@ describe('ModelParameterModal', () => { // Arrange const embeddingModel = createModel({ provider: 'openai', - models: [createModelItem({ - model: 'text-embedding-ada', - model_type: ModelTypeEnum.textEmbedding, - status: ModelStatusEnum.active, - })], + models: [ + createModelItem({ + model: 'text-embedding-ada', + model_type: ModelTypeEnum.textEmbedding, + status: ModelStatusEnum.active, + }), + ], }) setupModelLists({ textEmbedding: [embeddingModel] }) const props = createDefaultProps({ @@ -1083,11 +1153,13 @@ describe('ModelParameterModal', () => { // Arrange const textGenModel = createModel({ provider: 'openai', - models: [createModelItem({ - model: 'gpt-4', - model_type: ModelTypeEnum.textGeneration, - status: ModelStatusEnum.active, - })], + models: [ + createModelItem({ + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + status: ModelStatusEnum.active, + }), + ], }) setupModelLists({ textGeneration: [textGenModel] }) const props = createDefaultProps({ @@ -1207,15 +1279,16 @@ describe('ModelParameterModal', () => { setupModelLists({ textGeneration: [model] }) // Act - const props = createDefaultProps({ value: { provider: `provider-${status}`, model: 'test' } }) + const props = createDefaultProps({ + value: { provider: `provider-${status}`, model: 'test' }, + }) const { unmount } = render(<ModelParameterModal {...props} />) // Assert const trigger = screen.getByTestId('trigger') if (status === ModelStatusEnum.active) expect(trigger).toHaveAttribute('data-model-disabled', 'false') - else - expect(trigger).toHaveAttribute('data-model-disabled', 'true') + else expect(trigger).toHaveAttribute('data-model-disabled', 'true') unmount() }) diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx index 8230ec5b1435e0..3604ae4996b51d 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx @@ -1,7 +1,9 @@ -import type { FormValue, ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + FormValue, + ModelParameterRule, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - // Import component after mocks import LLMParamsPanel from '../llm-params-panel' @@ -11,7 +13,8 @@ import LLMParamsPanel from '../llm-params-panel' // Mock useModelParameterRules hook const mockUseModelParameterRules = vi.fn() vi.mock('@/service/use-common', () => ({ - useModelParameterRules: (provider: string, modelId: string) => mockUseModelParameterRules(provider, modelId), + useModelParameterRules: (provider: string, modelId: string) => + mockUseModelParameterRules(provider, modelId), })) // Mock config constants with inline data @@ -74,82 +77,123 @@ vi.mock('@/config', () => ({ })) // Mock PresetsParameter component -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter', () => ({ - default: ({ onSelect, supportedParameterNames }: { onSelect: (toneId: number) => void, supportedParameterNames?: string[] }) => { - const hasSupportedParameter = !supportedParameterNames || supportedParameterNames.some(name => ['temperature', 'top_p', 'presence_penalty', 'frequency_penalty'].includes(name)) - if (!hasSupportedParameter) - return null - - return ( - <div data-testid="presets-parameter"> - <button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button> - <button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button> - <button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button> - <button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button> - </div> - ) - }, -})) - -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils', () => ({ - getSupportedPresetConfig: (toneId: number, supportedParameterNames?: string[]) => { - const toneConfigMap: Record<number, Record<string, number> | undefined> = { - 1: { - temperature: 0.8, - top_p: 0.9, - presence_penalty: 0.1, - frequency_penalty: 0.1, - }, - 2: { - temperature: 0.5, - top_p: 0.85, - presence_penalty: 0.2, - frequency_penalty: 0.3, - }, - 3: { - temperature: 0.2, - top_p: 0.75, - presence_penalty: 0.5, - frequency_penalty: 0.5, - }, - } - const toneConfig = toneConfigMap[toneId] - if (!toneConfig) - return {} +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter', + () => ({ + default: ({ + onSelect, + supportedParameterNames, + }: { + onSelect: (toneId: number) => void + supportedParameterNames?: string[] + }) => { + const hasSupportedParameter = + !supportedParameterNames || + supportedParameterNames.some((name) => + ['temperature', 'top_p', 'presence_penalty', 'frequency_penalty'].includes(name), + ) + if (!hasSupportedParameter) return null + + return ( + <div data-testid="presets-parameter"> + <button data-testid="preset-creative" onClick={() => onSelect(1)}> + Creative + </button> + <button data-testid="preset-balanced" onClick={() => onSelect(2)}> + Balanced + </button> + <button data-testid="preset-precise" onClick={() => onSelect(3)}> + Precise + </button> + <button data-testid="preset-custom" onClick={() => onSelect(4)}> + Custom + </button> + </div> + ) + }, + }), +) + +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils', + () => ({ + getSupportedPresetConfig: (toneId: number, supportedParameterNames?: string[]) => { + const toneConfigMap: Record<number, Record<string, number> | undefined> = { + 1: { + temperature: 0.8, + top_p: 0.9, + presence_penalty: 0.1, + frequency_penalty: 0.1, + }, + 2: { + temperature: 0.5, + top_p: 0.85, + presence_penalty: 0.2, + frequency_penalty: 0.3, + }, + 3: { + temperature: 0.2, + top_p: 0.75, + presence_penalty: 0.5, + frequency_penalty: 0.5, + }, + } + const toneConfig = toneConfigMap[toneId] + if (!toneConfig) return {} - if (!supportedParameterNames) - return toneConfig + if (!supportedParameterNames) return toneConfig - return Object.entries(toneConfig).reduce<Record<string, number>>((acc, [key, value]) => { - if (supportedParameterNames.includes(key)) - acc[key] = value + return Object.entries(toneConfig).reduce<Record<string, number>>((acc, [key, value]) => { + if (supportedParameterNames.includes(key)) acc[key] = value - return acc - }, {}) - }, -})) + return acc + }, {}) + }, + }), +) // Mock ParameterItem component -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item', () => ({ - default: ({ parameterRule, value, onChange, onSwitch, isInWorkflow }: { - parameterRule: { name: string, label: { en_US: string }, default?: unknown } - value: unknown - onChange: (v: unknown) => void - onSwitch: (checked: boolean, assignValue: unknown) => void - isInWorkflow?: boolean - }) => ( - <div - data-testid={`parameter-item-${parameterRule.name}`} - data-value={JSON.stringify(value)} - data-is-in-workflow={isInWorkflow} - > - <span>{parameterRule.label.en_US}</span> - <button data-testid={`change-${parameterRule.name}`} onClick={() => onChange(0.5)}>Change</button> - <button data-testid={`switch-on-${parameterRule.name}`} onClick={() => onSwitch(true, parameterRule.default)}>Switch On</button> - <button data-testid={`switch-off-${parameterRule.name}`} onClick={() => onSwitch(false, parameterRule.default)}>Switch Off</button> - </div> - ), -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item', + () => ({ + default: ({ + parameterRule, + value, + onChange, + onSwitch, + isInWorkflow, + }: { + parameterRule: { name: string; label: { en_US: string }; default?: unknown } + value: unknown + onChange: (v: unknown) => void + onSwitch: (checked: boolean, assignValue: unknown) => void + isInWorkflow?: boolean + }) => ( + <div + data-testid={`parameter-item-${parameterRule.name}`} + data-value={JSON.stringify(value)} + data-is-in-workflow={isInWorkflow} + > + <span>{parameterRule.label.en_US}</span> + <button data-testid={`change-${parameterRule.name}`} onClick={() => onChange(0.5)}> + Change + </button> + <button + data-testid={`switch-on-${parameterRule.name}`} + onClick={() => onSwitch(true, parameterRule.default)} + > + Switch On + </button> + <button + data-testid={`switch-off-${parameterRule.name}`} + onClick={() => onSwitch(false, parameterRule.default)} + > + Switch Off + </button> + </div> + ), + }), +) // ==================== Test Utilities ==================== @@ -171,13 +215,15 @@ const createParameterRule = (overrides: Partial<ModelParameterRule> = {}): Model /** * Factory function to create default props */ -const createDefaultProps = (overrides: Partial<{ - isAdvancedMode: boolean - provider: string - modelId: string - completionParams: FormValue - onCompletionParamsChange: (newParams: FormValue) => void -}> = {}) => ({ +const createDefaultProps = ( + overrides: Partial<{ + isAdvancedMode: boolean + provider: string + modelId: string + completionParams: FormValue + onCompletionParamsChange: (newParams: FormValue) => void + }> = {}, +) => ({ isAdvancedMode: false, provider: 'langgenius/openai/openai', modelId: 'gpt-4', @@ -189,11 +235,13 @@ const createDefaultProps = (overrides: Partial<{ /** * Setup mock for useModelParameterRules */ -const setupModelParameterRulesMock = (config: { - data?: ModelParameterRule[] - isPending?: boolean - isLoading?: boolean -} = {}) => { +const setupModelParameterRulesMock = ( + config: { + data?: ModelParameterRule[] + isPending?: boolean + isLoading?: boolean + } = {}, +) => { mockUseModelParameterRules.mockReturnValue({ data: config.data ? { data: config.data } : undefined, isPending: config.isPending ?? false, @@ -261,7 +309,10 @@ describe('LLMParamsPanel', () => { it('should render PresetsParameter for openai provider', () => { // Arrange - setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false }) + setupModelParameterRulesMock({ + data: [createParameterRule({ name: 'temperature' })], + isPending: false, + }) const props = createDefaultProps({ provider: 'langgenius/openai/openai' }) // Act @@ -273,7 +324,10 @@ describe('LLMParamsPanel', () => { it('should render PresetsParameter for azure_openai provider', () => { // Arrange - setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false }) + setupModelParameterRulesMock({ + data: [createParameterRule({ name: 'temperature' })], + isPending: false, + }) const props = createDefaultProps({ provider: 'langgenius/azure_openai/azure_openai' }) // Act @@ -285,7 +339,10 @@ describe('LLMParamsPanel', () => { it('should not render PresetsParameter when no visible parameter supports presets', () => { // Arrange - setupModelParameterRulesMock({ data: [createParameterRule({ name: 'max_tokens', type: 'int' })], isPending: false }) + setupModelParameterRulesMock({ + data: [createParameterRule({ name: 'max_tokens', type: 'int' })], + isPending: false, + }) const props = createDefaultProps({ provider: 'langgenius/openai/openai' }) // Act @@ -374,7 +431,10 @@ describe('LLMParamsPanel', () => { render(<LLMParamsPanel {...props} />) // Assert - expect(screen.getByTestId('parameter-item-temperature')).toHaveAttribute('data-is-in-workflow', 'true') + expect(screen.getByTestId('parameter-item-temperature')).toHaveAttribute( + 'data-is-in-workflow', + 'true', + ) }) }) @@ -525,7 +585,10 @@ describe('LLMParamsPanel', () => { it('should apply empty config for Custom preset (spreads undefined)', () => { // Arrange const onCompletionParamsChange = vi.fn() - setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false }) + setupModelParameterRulesMock({ + data: [createParameterRule({ name: 'temperature' })], + isPending: false, + }) const props = createDefaultProps({ provider: 'langgenius/openai/openai', onCompletionParamsChange, @@ -543,7 +606,10 @@ describe('LLMParamsPanel', () => { it('should apply only preset config keys supported by visible parameters', () => { // Arrange const onCompletionParamsChange = vi.fn() - setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false }) + setupModelParameterRulesMock({ + data: [createParameterRule({ name: 'temperature' })], + isPending: false, + }) const props = createDefaultProps({ provider: 'langgenius/openai/openai', onCompletionParamsChange, diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx index 1d000f7c356af8..af1175b6b737b4 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx @@ -1,7 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' - // Import component after mocks import TTSParamsPanel from '../tts-params-panel' @@ -49,8 +48,8 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { className, 'data-testid': testId, }: { - 'children': React.ReactNode - 'className'?: string + children: React.ReactNode + className?: string 'data-testid'?: string }) => ( <button data-testid={testId ?? 'select-trigger'} data-class={className}> @@ -72,19 +71,10 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { {children} </div> ), - SelectItem: ({ - children, - value, - }: { - children: React.ReactNode - value: string - }) => { + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => { const { onValueChange } = React.useContext(MockSelectContext) return ( - <button - data-testid={`select-item-${value}`} - onClick={() => onValueChange(value)} - > + <button data-testid={`select-item-${value}`} onClick={() => onValueChange(value)}> {children} </button> ) @@ -96,11 +86,9 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { children: React.ReactNode className?: string }) => <span data-class={className}>{children}</span>, - SelectItemIndicator: ({ - className, - }: { - className?: string - }) => <span data-testid="select-item-indicator" data-class={className} />, + SelectItemIndicator: ({ className }: { className?: string }) => ( + <span data-testid="select-item-indicator" data-class={className} /> + ), } }) @@ -109,7 +97,7 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { /** * Factory function to create a voice item */ -const createVoiceItem = (overrides: Partial<{ mode: string, name: string }> = {}) => ({ +const createVoiceItem = (overrides: Partial<{ mode: string; name: string }> = {}) => ({ mode: 'alloy', name: 'Alloy', ...overrides, @@ -118,7 +106,7 @@ const createVoiceItem = (overrides: Partial<{ mode: string, name: string }> = {} /** * Factory function to create a currentModel with voices */ -const createCurrentModel = (voices: Array<{ mode: string, name: string }> = []) => ({ +const createCurrentModel = (voices: Array<{ mode: string; name: string }> = []) => ({ model_properties: { voices, }, @@ -127,12 +115,14 @@ const createCurrentModel = (voices: Array<{ mode: string, name: string }> = []) /** * Factory function to create default props */ -const createDefaultProps = (overrides: Partial<{ - currentModel: { model_properties: { voices: Array<{ mode: string, name: string }> } } | null - language: string - voice: string - onChange: (language: string, voice: string) => void -}> = {}) => ({ +const createDefaultProps = ( + overrides: Partial<{ + currentModel: { model_properties: { voices: Array<{ mode: string; name: string }> } } | null + language: string + voice: string + onChange: (language: string, voice: string) => void + }> = {}, +) => ({ currentModel: createCurrentModel([ createVoiceItem({ mode: 'alloy', name: 'Alloy' }), createVoiceItem({ mode: 'echo', name: 'Echo' }), @@ -266,8 +256,14 @@ describe('TTSParamsPanel', () => { // Assert // Assert - expect(screen.getByTestId('tts-language-select-trigger'))!.toHaveAttribute('data-class', 'w-full') - expect(screen.getByTestId('tts-voice-select-trigger'))!.toHaveAttribute('data-class', 'w-full') + expect(screen.getByTestId('tts-language-select-trigger'))!.toHaveAttribute( + 'data-class', + 'w-full', + ) + expect(screen.getByTestId('tts-voice-select-trigger'))!.toHaveAttribute( + 'data-class', + 'w-full', + ) }) it('should apply popup className to SelectContent', () => { @@ -520,9 +516,7 @@ describe('TTSParamsPanel', () => { it('should handle currentModel with single voice', () => { // Arrange const props = createDefaultProps({ - currentModel: createCurrentModel([ - { mode: 'single-voice', name: 'Single Voice' }, - ]), + currentModel: createCurrentModel([{ mode: 'single-voice', name: 'Single Voice' }]), }) // Act @@ -645,9 +639,7 @@ describe('TTSParamsPanel', () => { it('should update voice list when currentModel changes', () => { // Arrange - const initialModel = createCurrentModel([ - { mode: 'alloy', name: 'Alloy' }, - ]) + const initialModel = createCurrentModel([{ mode: 'alloy', name: 'Alloy' }]) const props = createDefaultProps({ currentModel: initialModel }) // Act diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx index 9f7f33f2d6db2f..3c084bf0903f73 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx @@ -1,7 +1,4 @@ -import type { - FC, - ReactNode, -} from 'react' +import type { FC, ReactNode } from 'react' import type { DefaultModel, FormValue, @@ -9,18 +6,15 @@ import type { } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { TriggerProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/types' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { - useModelList, -} from '@/app/components/header/account-setting/model-provider-page/hooks' + ModelStatusEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import AgentModelTrigger from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import { useProviderContext } from '@/context/provider-context' @@ -56,16 +50,20 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({ const [open, setOpen] = useState(false) const scopeArray = scope.split('&') const scopeFeatures = useMemo((): ModelFeatureEnum[] => { - if (scopeArray.includes('all')) - return [] - return scopeArray.filter(item => ![ - ModelTypeEnum.textGeneration, - ModelTypeEnum.textEmbedding, - ModelTypeEnum.rerank, - ModelTypeEnum.moderation, - ModelTypeEnum.speech2text, - ModelTypeEnum.tts, - ].includes(item as ModelTypeEnum)).map(item => item as ModelFeatureEnum) + if (scopeArray.includes('all')) return [] + return scopeArray + .filter( + (item) => + ![ + ModelTypeEnum.textGeneration, + ModelTypeEnum.textEmbedding, + ModelTypeEnum.rerank, + ModelTypeEnum.moderation, + ModelTypeEnum.speech2text, + ModelTypeEnum.tts, + ].includes(item as ModelTypeEnum), + ) + .map((item) => item as ModelFeatureEnum) }, [scopeArray]) const { data: textGenerationList } = useModelList(ModelTypeEnum.textGeneration) @@ -87,24 +85,28 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({ ...moderationList, ] } - if (scopeArray.includes(ModelTypeEnum.textGeneration)) - return textGenerationList - if (scopeArray.includes(ModelTypeEnum.textEmbedding)) - return textEmbeddingList - if (scopeArray.includes(ModelTypeEnum.rerank)) - return rerankList - if (scopeArray.includes(ModelTypeEnum.moderation)) - return moderationList - if (scopeArray.includes(ModelTypeEnum.speech2text)) - return sttList - if (scopeArray.includes(ModelTypeEnum.tts)) - return ttsList + if (scopeArray.includes(ModelTypeEnum.textGeneration)) return textGenerationList + if (scopeArray.includes(ModelTypeEnum.textEmbedding)) return textEmbeddingList + if (scopeArray.includes(ModelTypeEnum.rerank)) return rerankList + if (scopeArray.includes(ModelTypeEnum.moderation)) return moderationList + if (scopeArray.includes(ModelTypeEnum.speech2text)) return sttList + if (scopeArray.includes(ModelTypeEnum.tts)) return ttsList return resultList - }, [scopeArray, textGenerationList, textEmbeddingList, rerankList, sttList, ttsList, moderationList]) + }, [ + scopeArray, + textGenerationList, + textEmbeddingList, + rerankList, + sttList, + ttsList, + moderationList, + ]) const { currentProvider, currentModel } = useMemo(() => { - const currentProvider = scopedModelList.find(item => item.provider === value?.provider) - const currentModel = currentProvider?.models.find((model: { model: string }) => model.model === value?.model) + const currentProvider = scopedModelList.find((item) => item.provider === value?.provider) + const currentModel = currentProvider?.models.find( + (model: { model: string }) => model.model === value?.model, + ) return { currentProvider, currentModel, @@ -115,8 +117,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({ const disabled = !isAPIKeySet || hasDeprecated || currentModel?.status !== ModelStatusEnum.active const handleChangeModel = async ({ provider, model }: DefaultModel) => { - const targetProvider = scopedModelList.find(modelItem => modelItem.provider === provider) - const targetModelItem = targetProvider?.models.find((modelItem: { model: string }) => modelItem.model === model) + const targetProvider = scopedModelList.find((modelItem) => modelItem.provider === provider) + const targetModelItem = targetProvider?.models.find( + (modelItem: { model: string }) => modelItem.model === model, + ) const model_type = targetModelItem?.model_type as string let nextCompletionParams: FormValue = {} @@ -133,11 +137,12 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({ const keys = Object.keys(removedDetails || {}) if (keys.length) { - toast.warning(`${t($ => $['modelProvider.parametersInvalidRemoved'], { ns: 'common' })}: ${keys.map(k => `${k} (${removedDetails[k]})`).join(', ')}`) + toast.warning( + `${t(($) => $['modelProvider.parametersInvalidRemoved'], { ns: 'common' })}: ${keys.map((k) => `${k} (${removedDetails[k]})`).join(', ')}`, + ) } - } - catch { - toast.error(t($ => $.error, { ns: 'common' })) + } catch { + toast.error(t(($) => $.error, { ns: 'common' })) } } @@ -180,67 +185,73 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({ <Popover open={open} onOpenChange={(newOpen) => { - if (readonly) - return + if (readonly) return setOpen(newOpen) }} > <div className="relative"> - {isSplitTrigger - ? ( - <div className="flex h-8 min-w-[296px] items-center gap-px overflow-hidden rounded-lg"> - <div className="min-w-0 flex-1"> - <ModelSelector - defaultModel={(value?.provider || value?.model) ? { provider: value?.provider, model: value?.model } : undefined} - modelList={scopedModelList} - readonly={readonly} - scopeFeatures={scopeFeatures} - triggerClassName={cn( - 'h-8! w-full rounded-r-none!', - isInWorkflow && 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg', - )} - onSelect={handleChangeModel} - /> - </div> - <PopoverTrigger - aria-label={t($ => $['modelProvider.modelSettings'], { ns: 'common' })} - disabled={readonly || !hasSelectedModel} - className={cn( - 'flex size-8 shrink-0 items-center justify-center rounded-l-none rounded-r-lg border-0 bg-components-button-tertiary-bg p-0 text-text-tertiary outline-hidden hover:bg-components-button-tertiary-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled', - isInWorkflow && 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg', - )} - > - <span aria-hidden className="i-ri-equalizer-2-line size-4" /> - </PopoverTrigger> - </div> - ) - : ( - <PopoverTrigger - render={( - <button type="button" className="block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]"> - {renderTrigger - ? renderTrigger({ - open, - currentProvider, - currentModel, - providerName: value?.provider, - modelId: value?.model, - }) - : ( - <AgentModelTrigger - disabled={disabled} - hasDeprecated={hasDeprecated} - currentProvider={currentProvider} - currentModel={currentModel} - providerName={value?.provider} - modelId={value?.model} - scope={scope} - /> - )} - </button> + {isSplitTrigger ? ( + <div className="flex h-8 min-w-[296px] items-center gap-px overflow-hidden rounded-lg"> + <div className="min-w-0 flex-1"> + <ModelSelector + defaultModel={ + value?.provider || value?.model + ? { provider: value?.provider, model: value?.model } + : undefined + } + modelList={scopedModelList} + readonly={readonly} + scopeFeatures={scopeFeatures} + triggerClassName={cn( + 'h-8! w-full rounded-r-none!', + isInWorkflow && + 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg', )} + onSelect={handleChangeModel} /> - )} + </div> + <PopoverTrigger + aria-label={t(($) => $['modelProvider.modelSettings'], { ns: 'common' })} + disabled={readonly || !hasSelectedModel} + className={cn( + 'flex size-8 shrink-0 items-center justify-center rounded-l-none rounded-r-lg border-0 bg-components-button-tertiary-bg p-0 text-text-tertiary outline-hidden hover:bg-components-button-tertiary-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled', + isInWorkflow && + 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg', + )} + > + <span aria-hidden className="i-ri-equalizer-2-line size-4" /> + </PopoverTrigger> + </div> + ) : ( + <PopoverTrigger + render={ + <button + type="button" + className="block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]" + > + {renderTrigger ? ( + renderTrigger({ + open, + currentProvider, + currentModel, + providerName: value?.provider, + modelId: value?.model, + }) + ) : ( + <AgentModelTrigger + disabled={disabled} + hasDeprecated={hasDeprecated} + currentProvider={currentProvider} + currentModel={currentModel} + providerName={value?.provider} + modelId={value?.model} + scope={scope} + /> + )} + </button> + } + /> + )} <PopoverContent placement={isInWorkflow ? 'left' : 'bottom-end'} sideOffset={4} @@ -250,19 +261,23 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({ {!isSplitTrigger && ( <div className="relative"> <div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary"> - {t($ => $['modelProvider.model'], { ns: 'common' }).toLocaleUpperCase()} + {t(($) => $['modelProvider.model'], { ns: 'common' }).toLocaleUpperCase()} </div> <ModelSelector - defaultModel={hasSelectedModel ? { provider: value.provider, model: value.model } : undefined} + defaultModel={ + hasSelectedModel ? { provider: value.provider, model: value.model } : undefined + } modelList={scopedModelList} scopeFeatures={scopeFeatures} onSelect={handleChangeModel} /> </div> )} - {!isSplitTrigger && (currentModel?.model_type === ModelTypeEnum.textGeneration || currentModel?.model_type === ModelTypeEnum.tts) && ( - <div className="my-3 h-px bg-divider-subtle" /> - )} + {!isSplitTrigger && + (currentModel?.model_type === ModelTypeEnum.textGeneration || + currentModel?.model_type === ModelTypeEnum.tts) && ( + <div className="my-3 h-px bg-divider-subtle" /> + )} {currentModel?.model_type === ModelTypeEnum.textGeneration && ( <LLMParamsPanel provider={value?.provider} diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.tsx index 0331ee86b4fef9..80e8f24b7ad39f 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.tsx @@ -37,7 +37,7 @@ const LLMParamsPanel = ({ return parameterRulesData?.data || [] }, [parameterRulesData]) const supportedPresetParameterNames = useMemo(() => { - return parameterRules.map(parameterRule => parameterRule.name) + return parameterRules.map((parameterRule) => parameterRule.name) }, [parameterRules]) const handleSelectPresetParameter = (toneId: number) => { @@ -69,37 +69,36 @@ const LLMParamsPanel = ({ if (isRulesLoading) { return ( - <div className="mt-5"><Loading /></div> + <div className="mt-5"> + <Loading /> + </div> ) } return ( <> <div className="mb-2 flex items-center justify-between"> - <div className={cn('flex h-6 items-center system-sm-semibold text-text-secondary')}>{t($ => $['modelProvider.parameters'], { ns: 'common' })}</div> - { - PROVIDER_WITH_PRESET_TONE.includes(provider) && ( - <PresetsParameter - onSelect={handleSelectPresetParameter} - supportedParameterNames={supportedPresetParameterNames} - /> - ) - } + <div className={cn('flex h-6 items-center system-sm-semibold text-text-secondary')}> + {t(($) => $['modelProvider.parameters'], { ns: 'common' })} + </div> + {PROVIDER_WITH_PRESET_TONE.includes(provider) && ( + <PresetsParameter + onSelect={handleSelectPresetParameter} + supportedParameterNames={supportedPresetParameterNames} + /> + )} </div> - {!!parameterRules.length && ( - [ - ...parameterRules, - ...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []), - ].map(parameter => ( + {!!parameterRules.length && + [...parameterRules, ...(isAdvancedMode ? [STOP_PARAMETER_RULE] : [])].map((parameter) => ( <ParameterItem key={`${modelId}-${parameter.name}`} parameterRule={parameter} value={completionParams?.[parameter.name]} - onChange={v => handleParamChange(parameter.name, v)} + onChange={(v) => handleParamChange(parameter.name, v)} onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)} isInWorkflow /> - )))} + ))} </> ) } diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx index 96ec0ff3661523..1ca1f8f773b00a 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx @@ -1,4 +1,12 @@ -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, + SelectValue, +} from '@langgenius/dify-ui/select' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -11,19 +19,13 @@ type Props = Readonly<{ onChange: (language: string, voice: string) => void }> -const supportedLanguages = languages.filter(item => item.supported) +const supportedLanguages = languages.filter((item) => item.supported) -const TTSParamsPanel = ({ - currentModel, - language, - voice, - onChange, -}: Props) => { +const TTSParamsPanel = ({ currentModel, language, voice, onChange }: Props) => { const { t } = useTranslation() - const voiceList = useMemo<Array<{ label: string, value: string }>>(() => { - if (!currentModel) - return [] - return currentModel.model_properties.voices.map((item: { mode: string, name: string }) => ({ + const voiceList = useMemo<Array<{ label: string; value: string }>>(() => { + if (!currentModel) return [] + return currentModel.model_properties.voices.map((item: { mode: string; name: string }) => ({ label: item.name, value: item.mode, })) @@ -38,25 +40,24 @@ const TTSParamsPanel = ({ <> <div className="mb-3"> <div className="mb-1 flex items-center py-1 system-sm-semibold text-text-secondary"> - {t($ => $['voice.voiceSettings.language'], { ns: 'appDebug' })} + {t(($) => $['voice.voiceSettings.language'], { ns: 'appDebug' })} </div> <Select value={language} onValueChange={(value) => { - if (value == null) - return + if (value == null) return setLanguage(value) }} > <SelectTrigger className="w-full" data-testid="tts-language-select-trigger" - aria-label={t($ => $['voice.voiceSettings.language'], { ns: 'appDebug' })} + aria-label={t(($) => $['voice.voiceSettings.language'], { ns: 'appDebug' })} > <SelectValue /> </SelectTrigger> <SelectContent popupClassName="w-[354px]"> - {supportedLanguages.map(item => ( + {supportedLanguages.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -67,25 +68,24 @@ const TTSParamsPanel = ({ </div> <div className="mb-3"> <div className="mb-1 flex items-center py-1 system-sm-semibold text-text-secondary"> - {t($ => $['voice.voiceSettings.voice'], { ns: 'appDebug' })} + {t(($) => $['voice.voiceSettings.voice'], { ns: 'appDebug' })} </div> <Select value={voice} onValueChange={(value) => { - if (value == null) - return + if (value == null) return setVoice(value) }} > <SelectTrigger className="w-full" data-testid="tts-voice-select-trigger" - aria-label={t($ => $['voice.voiceSettings.voice'], { ns: 'appDebug' })} + aria-label={t(($) => $['voice.voiceSettings.voice'], { ns: 'appDebug' })} > <SelectValue /> </SelectTrigger> <SelectContent popupClassName="w-[354px]"> - {voiceList.map(item => ( + {voiceList.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.label}</SelectItemText> <SelectItemIndicator /> diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx index c5defa3ab035f3..a965056eed6ce1 100644 --- a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx @@ -4,9 +4,7 @@ import type { NodeOutPutVar, ToolWithProvider } from '@/app/components/workflow/ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - // ==================== Imports (after mocks) ==================== - import { MCPToolAvailabilityProvider } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability' import MultipleToolSelector from '../index' @@ -65,19 +63,18 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ > Configure </button> - <button - data-testid={`delete-btn-${currentIndex}`} - onClick={() => onDelete?.()} - > + <button data-testid={`delete-btn-${currentIndex}`} onClick={() => onDelete?.()}> Delete </button> {onSelectMultiple && ( <button data-testid={`add-multiple-btn-${currentIndex}`} - onClick={() => onSelectMultiple([ - { ...value, tool_name: 'batch-tool-1', provider_name: 'batch-provider' }, - { ...value, tool_name: 'batch-tool-2', provider_name: 'batch-provider' }, - ])} + onClick={() => + onSelectMultiple([ + { ...value, tool_name: 'batch-tool-1', provider_name: 'batch-provider' }, + { ...value, tool_name: 'batch-tool-2', provider_name: 'batch-provider' }, + ]) + } > Add Multiple </button> @@ -86,8 +83,7 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ )} </div> ) - } - else { + } else { return ( <div data-testid="tool-selector-add" @@ -96,22 +92,36 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ > <button data-testid="add-tool-btn" - onClick={() => onSelect({ - provider_name: 'new-provider', - tool_name: 'new-tool', - tool_label: 'New Tool', - enabled: true, - })} + onClick={() => + onSelect({ + provider_name: 'new-provider', + tool_name: 'new-tool', + tool_label: 'New Tool', + enabled: true, + }) + } > Add Tool </button> {onSelectMultiple && ( <button data-testid="add-multiple-tools-btn" - onClick={() => onSelectMultiple([ - { provider_name: 'batch-p', tool_name: 'batch-t1', tool_label: 'Batch T1', enabled: true }, - { provider_name: 'batch-p', tool_name: 'batch-t2', tool_label: 'Batch T2', enabled: true }, - ])} + onClick={() => + onSelectMultiple([ + { + provider_name: 'batch-p', + tool_name: 'batch-t1', + tool_label: 'Batch T1', + enabled: true, + }, + { + provider_name: 'batch-p', + tool_name: 'batch-t2', + tool_label: 'Batch T2', + enabled: true, + }, + ]) + } > Add Multiple Tools </button> @@ -124,12 +134,13 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ // ==================== Test Utilities ==================== -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, -}) +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) const createToolValue = (overrides: Partial<ToolValue> = {}): ToolValue => ({ provider_name: 'test-provider', @@ -144,26 +155,33 @@ const createToolValue = (overrides: Partial<ToolValue> = {}): ToolValue => ({ ...overrides, }) -const createMCPTool = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({ - id: 'mcp-provider-1', - name: 'mcp-provider', - author: 'test-author', - type: 'mcp', - icon: 'test-icon.png', - label: { en_US: 'MCP Provider' } as unknown as ToolWithProvider['label'], - description: { en_US: 'MCP Provider description' } as unknown as ToolWithProvider['description'], - is_team_authorization: true, - allow_delete: false, - labels: [], - tools: [{ - name: 'mcp-tool-1', - label: { en_US: 'MCP Tool 1' } as unknown as ToolWithProvider['label'], - description: { en_US: 'MCP Tool 1 description' } as unknown as ToolWithProvider['description'], - parameters: [], - output_schema: {}, - }], - ...overrides, -} as ToolWithProvider) +const createMCPTool = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => + ({ + id: 'mcp-provider-1', + name: 'mcp-provider', + author: 'test-author', + type: 'mcp', + icon: 'test-icon.png', + label: { en_US: 'MCP Provider' } as unknown as ToolWithProvider['label'], + description: { + en_US: 'MCP Provider description', + } as unknown as ToolWithProvider['description'], + is_team_authorization: true, + allow_delete: false, + labels: [], + tools: [ + { + name: 'mcp-tool-1', + label: { en_US: 'MCP Tool 1' } as unknown as ToolWithProvider['label'], + description: { + en_US: 'MCP Tool 1 description', + } as unknown as ToolWithProvider['description'], + parameters: [], + output_schema: {}, + }, + ], + ...overrides, + }) as ToolWithProvider const createNodeOutputVar = (overrides: Partial<NodeOutPutVar> = {}): NodeOutPutVar => ({ nodeId: 'node-1', @@ -460,7 +478,10 @@ describe('MultipleToolSelector', () => { fireEvent.click(actionButton!) // Assert - Open state should change to true - expect(screen.getByTestId('tool-selector-add')).toHaveAttribute('data-controlled-state', 'true') + expect(screen.getByTestId('tool-selector-add')).toHaveAttribute( + 'data-controlled-state', + 'true', + ) }) }) @@ -661,9 +682,7 @@ describe('MultipleToolSelector', () => { it('should handle tools with missing enabled property', () => { // Arrange - const tools = [ - { ...createToolValue(), enabled: undefined } as ToolValue, - ] + const tools = [{ ...createToolValue(), enabled: undefined } as ToolValue] // Act renderComponent({ value: tools }) @@ -739,7 +758,8 @@ describe('MultipleToolSelector', () => { it('should handle multiple tools correctly', () => { // Arrange const tools = Array.from({ length: 5 }, (_, i) => - createToolValue({ tool_name: `tool-${i}`, tool_label: `Tool ${i}` })) + createToolValue({ tool_name: `tool-${i}`, tool_label: `Tool ${i}` }), + ) // Act renderComponent({ value: tools }) @@ -828,9 +848,7 @@ describe('MultipleToolSelector', () => { it('should deduplicate multiple tools in batch add', () => { // Arrange const onChange = vi.fn() - const existingTools = [ - createToolValue({ provider_name: 'batch-p', tool_name: 'batch-t1' }), - ] + const existingTools = [createToolValue({ provider_name: 'batch-p', tool_name: 'batch-t1' })] renderComponent({ value: existingTools, onChange }) // Act - Add multiple tools (batch-t1 is duplicate) @@ -879,9 +897,7 @@ describe('MultipleToolSelector', () => { fireEvent.click(screen.getByTestId('delete-btn-1')) // Assert - expect(onChange).toHaveBeenCalledWith([ - expect.objectContaining({ tool_name: 'tool-0' }), - ]) + expect(onChange).toHaveBeenCalledWith([expect.objectContaining({ tool_name: 'tool-0' })]) }) it('should result in empty array when deleting last remaining tool', () => { @@ -902,9 +918,7 @@ describe('MultipleToolSelector', () => { describe('Configure Functionality', () => { it('should update tool at specific index when configured', () => { // Arrange - const tools = [ - createToolValue({ tool_name: 'tool-1', enabled: true }), - ] + const tools = [createToolValue({ tool_name: 'tool-1', enabled: true })] const onChange = vi.fn() renderComponent({ value: tools, onChange }) diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx index aa6d8e2727eaef..e079f5c746c4f1 100644 --- a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx @@ -2,9 +2,7 @@ import type { Node } from 'reactflow' import type { ToolValue } from '@/app/components/workflow/block-selector/types' import type { NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - RiAddLine, -} from '@remixicon/react' +import { RiAddLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' @@ -46,16 +44,14 @@ const MultipleToolSelector = ({ const { allowed: isMCPToolAllowed } = useMCPToolAvailability() const { data: mcpTools } = useAllMCPTools() const enabledCount = value.filter((item) => { - const isMCPTool = mcpTools?.find(tool => tool.id === item.provider_name) - if (isMCPTool) - return item.enabled && isMCPToolAllowed + const isMCPTool = mcpTools?.find((tool) => tool.id === item.provider_name) + if (isMCPTool) return item.enabled && isMCPToolAllowed return item.enabled }).length // collapse control const [collapse, setCollapse] = React.useState(false) const handleCollapse = () => { - if (supportCollapse) - setCollapse(!collapse) + if (supportCollapse) setCollapse(!collapse) } // add tool @@ -65,7 +61,11 @@ const MultipleToolSelector = ({ const newValue = [...value, val] // deduplication const deduplication = newValue.reduce((acc, cur) => { - if (!acc.find(item => item.provider_name === cur.provider_name && item.tool_name === cur.tool_name)) + if ( + !acc.find( + (item) => item.provider_name === cur.provider_name && item.tool_name === cur.tool_name, + ) + ) acc.push(cur) return acc }, [] as ToolValue[]) @@ -78,7 +78,11 @@ const MultipleToolSelector = ({ const newValue = [...value, ...val] // deduplication const deduplication = newValue.reduce((acc, cur) => { - if (!acc.find(item => item.provider_name === cur.provider_name && item.tool_name === cur.tool_name)) + if ( + !acc.find( + (item) => item.provider_name === cur.provider_name && item.tool_name === cur.tool_name, + ) + ) acc.push(cur) return acc }, [] as ToolValue[]) @@ -105,21 +109,24 @@ const MultipleToolSelector = ({ <> <div className="mb-1 flex items-center"> <div - className={cn('relative flex grow items-center gap-0.5', supportCollapse && 'cursor-pointer')} + className={cn( + 'relative flex grow items-center gap-0.5', + supportCollapse && 'cursor-pointer', + )} onClick={handleCollapse} > - <div className="flex h-6 items-center system-sm-semibold-uppercase text-text-secondary">{label}</div> + <div className="flex h-6 items-center system-sm-semibold-uppercase text-text-secondary"> + {label} + </div> {required && <div className="text-red-500">*</div>} - {tooltip - ? ( - <Infotip - aria-label={typeof tooltip === 'string' ? tooltip : label} - className="size-3.5" - > - {tooltip} - </Infotip> - ) - : null} + {tooltip ? ( + <Infotip + aria-label={typeof tooltip === 'string' ? tooltip : label} + className="size-3.5" + > + {tooltip} + </Infotip> + ) : null} {supportCollapse && ( <ArrowDownRoundFill className={cn( @@ -133,7 +140,7 @@ const MultipleToolSelector = ({ <> <div className="flex items-center gap-1 system-xs-medium text-text-tertiary"> <span>{`${enabledCount}/${value.length}`}</span> - <span>{t($ => $['agent.tools.enabled'], { ns: 'appDebug' })}</span> + <span>{t(($) => $['agent.tools.enabled'], { ns: 'appDebug' })}</span> </div> <Divider type="vertical" className="mr-1 ml-3 h-3" /> </> @@ -154,25 +161,28 @@ const MultipleToolSelector = ({ {!collapse && ( <> {value.length === 0 && ( - <div className="flex justify-center rounded-[10px] bg-background-section p-3 system-xs-regular text-text-tertiary">{t($ => $['detailPanel.toolSelector.empty'], { ns: 'plugin' })}</div> - )} - {value.length > 0 && value.map((item, index) => ( - <div className="mb-1" key={index}> - <ToolSelector - nodeId={nodeId} - nodeOutputVars={nodeOutputVars} - availableNodes={availableNodes} - scope={scope} - value={item} - selectedTools={value} - onSelect={item => handleConfigure(item, index)} - onSelectMultiple={handleAddMultiple} - onDelete={() => handleDelete(index)} - supportEnableSwitch - isEdit - /> + <div className="flex justify-center rounded-[10px] bg-background-section p-3 system-xs-regular text-text-tertiary"> + {t(($) => $['detailPanel.toolSelector.empty'], { ns: 'plugin' })} </div> - ))} + )} + {value.length > 0 && + value.map((item, index) => ( + <div className="mb-1" key={index}> + <ToolSelector + nodeId={nodeId} + nodeOutputVars={nodeOutputVars} + availableNodes={availableNodes} + scope={scope} + value={item} + selectedTools={value} + onSelect={(item) => handleConfigure(item, index)} + onSelectMultiple={handleAddMultiple} + onDelete={() => handleDelete(index)} + supportEnableSwitch + isEdit + /> + </div> + ))} </> )} <ToolSelector @@ -185,9 +195,7 @@ const MultipleToolSelector = ({ onSelect={handleAdd} controlledState={open} onControlledStateChange={setOpen} - trigger={ - <div className=""></div> - } + trigger={<div className=""></div>} panelShowState={panelShowState} onPanelShowStateChange={setPanelShowState} isEdit={false} diff --git a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx index f37c89b95ce5d7..21c127630763e1 100644 --- a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx +++ b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx @@ -50,22 +50,32 @@ export function OperationDropdown({ const { t } = useTranslation() const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const showInfo = source === PluginSource.github const showCheckVersionAction = showCheckVersion && source === PluginSource.github - const showMarketplaceDetail = (source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace + const showMarketplaceDetail = + (source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace const showRemoveAction = showRemove const showSeparator = showRemoveAction && (showMarketplaceDetail || !!onViewReadme) - if (!showInfo && !showCheckVersionAction && !showMarketplaceDetail && !onViewReadme && !showRemoveAction) + if ( + !showInfo && + !showCheckVersionAction && + !showMarketplaceDetail && + !onViewReadme && + !showRemoveAction + ) return null return ( <DropdownMenu> <DropdownMenuTrigger - className={cn('action-btn data-popup-open:bg-state-base-hover', triggerSize === 'xs' ? 'action-btn-xs' : 'action-btn-m')} - aria-label={t($ => $['detailPanel.operation.moreActions'], { ns: 'plugin' })} + className={cn( + 'action-btn data-popup-open:bg-state-base-hover', + triggerSize === 'xs' ? 'action-btn-xs' : 'action-btn-m', + )} + aria-label={t(($) => $['detailPanel.operation.moreActions'], { ns: 'plugin' })} > <span aria-hidden className="i-ri-more-fill size-4" /> </DropdownMenuTrigger> @@ -76,13 +86,23 @@ export function OperationDropdown({ popupClassName={cn('w-[192px] py-1', popupClassName)} > {showInfo && ( - <DropdownMenuItem className="px-2 py-1 system-md-regular text-text-secondary" onClick={onInfo}> - <span className="min-w-0 grow truncate px-1 py-0.5">{t($ => $['detailPanel.operation.info'], { ns: 'plugin' })}</span> + <DropdownMenuItem + className="px-2 py-1 system-md-regular text-text-secondary" + onClick={onInfo} + > + <span className="min-w-0 grow truncate px-1 py-0.5"> + {t(($) => $['detailPanel.operation.info'], { ns: 'plugin' })} + </span> </DropdownMenuItem> )} {showCheckVersionAction && ( - <DropdownMenuItem className="px-2 py-1 system-md-regular text-text-secondary" onClick={onCheckVersion}> - <span className="min-w-0 grow truncate px-1 py-0.5">{t($ => $['detailPanel.operation.checkUpdate'], { ns: 'plugin' })}</span> + <DropdownMenuItem + className="px-2 py-1 system-md-regular text-text-secondary" + onClick={onCheckVersion} + > + <span className="min-w-0 grow truncate px-1 py-0.5"> + {t(($) => $['detailPanel.operation.checkUpdate'], { ns: 'plugin' })} + </span> </DropdownMenuItem> )} {showMarketplaceDetail && ( @@ -91,30 +111,41 @@ export function OperationDropdown({ href={detailUrl} target="_blank" rel="noopener noreferrer" - aria-label={t($ => $['detailPanel.operation.viewDetail'], { ns: 'plugin' })} + aria-label={t(($) => $['detailPanel.operation.viewDetail'], { ns: 'plugin' })} > - <span className="min-w-0 grow truncate px-1 py-0.5">{t($ => $['detailPanel.operation.viewDetail'], { ns: 'plugin' })}</span> + <span className="min-w-0 grow truncate px-1 py-0.5"> + {t(($) => $['detailPanel.operation.viewDetail'], { ns: 'plugin' })} + </span> <span className="i-ri-arrow-right-up-line size-3.5 shrink-0 text-text-tertiary" /> </DropdownMenuLinkItem> )} {onViewReadme && ( - <DropdownMenuItem className="px-2 py-1 system-md-regular text-text-secondary" onClick={onViewReadme}> - <span className="min-w-0 grow truncate px-1 py-0.5">{t($ => $['detailPanel.operation.viewReadme'], { ns: 'plugin' })}</span> + <DropdownMenuItem + className="px-2 py-1 system-md-regular text-text-secondary" + onClick={onViewReadme} + > + <span className="min-w-0 grow truncate px-1 py-0.5"> + {t(($) => $['detailPanel.operation.viewReadme'], { ns: 'plugin' })} + </span> </DropdownMenuItem> )} - {showSeparator && ( - <DropdownMenuSeparator /> - )} + {showSeparator && <DropdownMenuSeparator />} {showRemoveAction && ( <DropdownMenuItem className={cn( 'px-2 py-1 system-md-regular text-text-secondary', - destructiveRemove && 'data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive', + destructiveRemove && + 'data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive', )} onClick={onRemove} > - <span className={cn('min-w-0 grow truncate px-1 py-0.5', destructiveRemove && 'text-inherit')}> - {t($ => $['detailPanel.operation.remove'], { ns: 'plugin' })} + <span + className={cn( + 'min-w-0 grow truncate px-1 py-0.5', + destructiveRemove && 'text-inherit', + )} + > + {t(($) => $['detailPanel.operation.remove'], { ns: 'plugin' })} </span> </DropdownMenuItem> )} diff --git a/web/app/components/plugins/plugin-detail-panel/store.ts b/web/app/components/plugins/plugin-detail-panel/store.ts index 132fc8b1407f61..67da7958706b92 100644 --- a/web/app/components/plugins/plugin-detail-panel/store.ts +++ b/web/app/components/plugins/plugin-detail-panel/store.ts @@ -11,7 +11,10 @@ type TriggerDeclarationSummary = { subscription_constructor?: PluginTriggerSubscriptionConstructor | null } -export type SimpleDetail = Pick<PluginDetail, 'plugin_id' | 'name' | 'plugin_unique_identifier' | 'id'> & { +export type SimpleDetail = Pick< + PluginDetail, + 'plugin_id' | 'name' | 'plugin_unique_identifier' | 'id' +> & { provider: string declaration: Partial<Omit<PluginDeclaration, 'trigger'>> & { trigger?: TriggerDeclarationSummary @@ -23,7 +26,7 @@ type Shape = { setDetail: (detail?: SimpleDetail) => void } -export const usePluginStore = create<Shape>(set => ({ +export const usePluginStore = create<Shape>((set) => ({ detail: undefined, setDetail: (detail?: SimpleDetail) => set({ detail }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx b/web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx index fb9a2fdcc873ff..8048b0a76451d8 100644 --- a/web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx +++ b/web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx @@ -1,8 +1,6 @@ 'use client' import type { FC } from 'react' -import type { - StrategyDetail as StrategyDetailType, -} from '@/app/components/plugins/types' +import type { StrategyDetail as StrategyDetailType } from '@/app/components/plugins/types' import type { Locale } from '@/i18n-config' import { cn } from '@langgenius/dify-ui/cn' import { @@ -13,10 +11,7 @@ import { DrawerPortal, DrawerViewport, } from '@langgenius/dify-ui/drawer' -import { - RiArrowLeftLine, - RiCloseLine, -} from '@remixicon/react' +import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -41,25 +36,21 @@ type Props = Readonly<{ onHide: () => void }> -const StrategyDetail: FC<Props> = ({ - provider, - detail, - onHide, -}) => { +const StrategyDetail: FC<Props> = ({ provider, detail, onHide }) => { const getValueFromI18nObject = useRenderI18nObject() const { t } = useTranslation() const outputSchema = useMemo(() => { const res: any[] = [] - if (!detail.output_schema || !detail.output_schema.properties) - return [] + if (!detail.output_schema || !detail.output_schema.properties) return [] Object.keys(detail.output_schema.properties).forEach((outputKey) => { const output = detail.output_schema.properties[outputKey] res.push({ name: outputKey, - type: output.type === 'array' - ? `Array[${output.items?.type ? output.items.type.slice(0, 1).toLocaleUpperCase() + output.items.type.slice(1) : 'Unknown'}]` - : `${output.type ? output.type.slice(0, 1).toLocaleUpperCase() + output.type.slice(1) : 'Unknown'}`, + type: + output.type === 'array' + ? `Array[${output.items?.type ? output.items.type.slice(0, 1).toLocaleUpperCase() + output.items.type.slice(1) : 'Unknown'}]` + : `${output.type ? output.type.slice(0, 1).toLocaleUpperCase() + output.type.slice(1) : 'Unknown'}`, description: output.description, }) }) @@ -67,16 +58,11 @@ const StrategyDetail: FC<Props> = ({ }, [detail.output_schema]) const getType = (type: string) => { - if (type === 'number-input') - return t($ => $['setBuiltInTools.number'], { ns: 'tools' }) - if (type === 'text-input') - return t($ => $['setBuiltInTools.string'], { ns: 'tools' }) - if (type === 'checkbox') - return 'boolean' - if (type === 'file') - return t($ => $['setBuiltInTools.file'], { ns: 'tools' }) - if (type === 'array[tools]') - return 'multiple-tool-select' + if (type === 'number-input') return t(($) => $['setBuiltInTools.number'], { ns: 'tools' }) + if (type === 'text-input') return t(($) => $['setBuiltInTools.string'], { ns: 'tools' }) + if (type === 'checkbox') return 'boolean' + if (type === 'file') return t(($) => $['setBuiltInTools.file'], { ns: 'tools' }) + if (type === 'array[tools]') return 'multiple-tool-select' return type } @@ -86,14 +72,17 @@ const StrategyDetail: FC<Props> = ({ modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DrawerPortal> <DrawerBackdrop className="bg-transparent" /> <DrawerViewport> - <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-[400px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerPopup + className={cn( + 'justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-[400px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border', + )} + > <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> {/* header */} <div className="relative border-b border-divider-subtle p-4 pb-3"> @@ -110,28 +99,44 @@ const StrategyDetail: FC<Props> = ({ BACK </div> <div className="flex items-center gap-1"> - <Icon size="tiny" className="size-6" src={`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${provider.tenant_id}&filename=${provider.icon}`} /> + <Icon + size="tiny" + className="size-6" + src={`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${provider.tenant_id}&filename=${provider.icon}`} + /> <div className="">{getValueFromI18nObject(provider.label)}</div> </div> - <div className="mt-1 system-md-semibold text-text-primary">{getValueFromI18nObject(detail.identity.label)}</div> - <Description className="mt-3" text={getValueFromI18nObject(detail.description)} descriptionLineRows={2}></Description> + <div className="mt-1 system-md-semibold text-text-primary"> + {getValueFromI18nObject(detail.identity.label)} + </div> + <Description + className="mt-3" + text={getValueFromI18nObject(detail.description)} + descriptionLineRows={2} + ></Description> </div> {/* form */} <div className="h-full"> <div className="flex h-full flex-col overflow-y-auto"> - <div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary">{t($ => $['setBuiltInTools.parameters'], { ns: 'tools' })}</div> + <div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary"> + {t(($) => $['setBuiltInTools.parameters'], { ns: 'tools' })} + </div> <div className="px-4"> {detail.parameters.length > 0 && ( <div className="space-y-1 py-2"> {detail.parameters.map((item: any, index) => ( <div key={index} className="py-1"> <div className="flex items-center gap-2"> - <div className="code-sm-semibold text-text-secondary">{getValueFromI18nObject(item.label)}</div> + <div className="code-sm-semibold text-text-secondary"> + {getValueFromI18nObject(item.label)} + </div> <div className="system-xs-regular text-text-tertiary"> {getType(item.type)} </div> {item.required && ( - <div className="system-xs-medium text-text-warning-secondary">{t($ => $['setBuiltInTools.required'], { ns: 'tools' })}</div> + <div className="system-xs-medium text-text-warning-secondary"> + {t(($) => $['setBuiltInTools.required'], { ns: 'tools' })} + </div> )} </div> {item.human_description && ( @@ -149,14 +154,20 @@ const StrategyDetail: FC<Props> = ({ <div className="px-4"> <Divider className="mt-2!" /> </div> - <div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary">OUTPUT</div> + <div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary"> + OUTPUT + </div> {outputSchema.length > 0 && ( <div className="space-y-1 px-4 py-2"> {outputSchema.map((outputItem, index) => ( <div key={index} className="py-1"> <div className="flex items-center gap-2"> - <div className="code-sm-semibold text-text-secondary">{outputItem.name}</div> - <div className="system-xs-regular text-text-tertiary">{outputItem.type}</div> + <div className="code-sm-semibold text-text-secondary"> + {outputItem.name} + </div> + <div className="system-xs-regular text-text-tertiary"> + {outputItem.type} + </div> </div> {outputItem.description && ( <div className="mt-0.5 system-xs-regular text-text-tertiary"> diff --git a/web/app/components/plugins/plugin-detail-panel/strategy-item.tsx b/web/app/components/plugins/plugin-detail-panel/strategy-item.tsx index 1634525afa734d..f25ef995c6b60e 100644 --- a/web/app/components/plugins/plugin-detail-panel/strategy-item.tsx +++ b/web/app/components/plugins/plugin-detail-panel/strategy-item.tsx @@ -1,7 +1,5 @@ 'use client' -import type { - StrategyDetail, -} from '@/app/components/plugins/types' +import type { StrategyDetail } from '@/app/components/plugins/types' import type { Locale } from '@/i18n-config' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' @@ -22,21 +20,24 @@ type Props = Readonly<{ detail: StrategyDetail }> -const StrategyItem = ({ - provider, - detail, -}: Props) => { +const StrategyItem = ({ provider, detail }: Props) => { const getValueFromI18nObject = useRenderI18nObject() const [showDetail, setShowDetail] = useState(false) return ( <> <div - className={cn('bg-components-panel-item-bg mb-2 cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover')} + className={cn( + 'bg-components-panel-item-bg mb-2 cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', + )} onClick={() => setShowDetail(true)} > - <div className="pb-0.5 system-md-semibold text-text-secondary">{getValueFromI18nObject(detail.identity.label)}</div> - <div className="line-clamp-2 system-xs-regular text-text-tertiary">{getValueFromI18nObject(detail.description)}</div> + <div className="pb-0.5 system-md-semibold text-text-secondary"> + {getValueFromI18nObject(detail.identity.label)} + </div> + <div className="line-clamp-2 system-xs-regular text-text-tertiary"> + {getValueFromI18nObject(detail.description)} + </div> </div> {showDetail && ( <StrategyDetailPanel diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx index c2cc608b453fbe..0b6b00fcf8cf54 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx @@ -46,10 +46,16 @@ describe('DeleteConfirm', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ })) + fireEvent.click( + screen.getByRole('button', { + name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/, + }), + ) expect(mockDelete).not.toHaveBeenCalled() - expect(mockToastError).toHaveBeenCalledWith('pluginTrigger.subscription.list.item.actions.deleteConfirm.confirmInputWarning') + expect(mockToastError).toHaveBeenCalledWith( + 'pluginTrigger.subscription.list.item.actions.deleteConfirm.confirmInputWarning', + ) }) it('should allow deletion after matching input name', () => { @@ -66,11 +72,17 @@ describe('DeleteConfirm', () => { ) fireEvent.change( - screen.getByPlaceholderText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirmInputPlaceholder/), + screen.getByPlaceholderText( + /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirmInputPlaceholder/, + ), { target: { value: 'Subscription One' } }, ) - fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ })) + fireEvent.click( + screen.getByRole('button', { + name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/, + }), + ) expect(mockDelete).toHaveBeenCalledWith('sub-1', expect.any(Object)) expect(mockRefetch).toHaveBeenCalledTimes(1) @@ -92,7 +104,11 @@ describe('DeleteConfirm', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ })) + fireEvent.click( + screen.getByRole('button', { + name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/, + }), + ) expect(mockToastError).toHaveBeenCalledWith('network error') }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx index 19a2d5a9f1121d..157ba417f7d68a 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx @@ -7,12 +7,14 @@ import { createReactI18nextMock } from '@/test/i18n-mock' import { SubscriptionList } from '../index' import { SubscriptionListMode } from '../types' -vi.mock('react-i18next', () => createReactI18nextMock({ - 'errorBoundary.title': 'Something went wrong', - 'errorBoundary.message': 'An unexpected error occurred while rendering this component.', - 'errorBoundary.tryAgain': 'Try Again', - 'errorBoundary.reloadPage': 'Reload Page', -})) +vi.mock('react-i18next', () => + createReactI18nextMock({ + 'errorBoundary.title': 'Something went wrong', + 'errorBoundary.message': 'An unexpected error occurred while rendering this component.', + 'errorBoundary.tryAgain': 'Try Again', + 'errorBoundary.reloadPage': 'Reload Page', + }), +) const mockRefetch = vi.fn() let mockSubscriptionListError: Error | null = null @@ -26,15 +28,15 @@ let mockPluginDetail: PluginDetail | undefined vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => { - if (mockSubscriptionListError) - throw mockSubscriptionListError + if (mockSubscriptionListError) throw mockSubscriptionListError return mockSubscriptionListState }, })) vi.mock('../../../store', () => ({ - usePluginStore: (selector: (state: { detail: PluginDetail | undefined }) => PluginDetail | undefined) => - selector({ detail: mockPluginDetail }), + usePluginStore: ( + selector: (state: { detail: PluginDetail | undefined }) => PluginDetail | undefined, + ) => selector({ detail: mockPluginDetail }), })) const mockInitiateOAuth = vi.fn() @@ -146,10 +148,7 @@ describe('SubscriptionList', () => { it('should visually distinguish selected subscription from unselected', () => { const { rerender } = render( - <SubscriptionList - mode={SubscriptionListMode.SELECTOR} - selectedId="sub-1" - />, + <SubscriptionList mode={SubscriptionListMode.SELECTOR} selectedId="sub-1" />, ) const getRowClassName = () => @@ -157,12 +156,7 @@ describe('SubscriptionList', () => { const selectedClassName = getRowClassName() - rerender( - <SubscriptionList - mode={SubscriptionListMode.SELECTOR} - selectedId="other-id" - />, - ) + rerender(<SubscriptionList mode={SubscriptionListMode.SELECTOR} selectedId="other-id" />) expect(selectedClassName).not.toBe(getRowClassName()) }) @@ -172,12 +166,7 @@ describe('SubscriptionList', () => { it('should call onSelect with refetch callback when selecting a subscription', () => { const onSelect = vi.fn() - render( - <SubscriptionList - mode={SubscriptionListMode.SELECTOR} - onSelect={onSelect} - />, - ) + render(<SubscriptionList mode={SubscriptionListMode.SELECTOR} onSelect={onSelect} />) fireEvent.click(screen.getByRole('button', { name: 'Subscription One' })) @@ -201,10 +190,7 @@ describe('SubscriptionList', () => { it('should open delete confirm without triggering selection', () => { const onSelect = vi.fn() const { container } = render( - <SubscriptionList - mode={SubscriptionListMode.SELECTOR} - onSelect={onSelect} - />, + <SubscriptionList mode={SubscriptionListMode.SELECTOR} onSelect={onSelect} />, ) const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement @@ -212,7 +198,9 @@ describe('SubscriptionList', () => { fireEvent.click(deleteButton) expect(onSelect).not.toHaveBeenCalled() - expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/))!.toBeInTheDocument() + expect( + screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/), + )!.toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx index 3b179d08810a87..208d8e3186a5dd 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx @@ -8,7 +8,8 @@ const mockWriteText = vi.fn() vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( - (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), + (message: string, options?: { type?: string }) => + mockToastNotify({ type: options?.type, message }), { success: (message: string) => mockToastNotify({ type: 'success', message }), error: (message: string) => mockToastNotify({ type: 'error', message }), @@ -35,10 +36,10 @@ const createLog = (overrides: Partial<TriggerLogEntity> = {}): TriggerLogEntity method: 'POST', url: 'https://example.com', headers: { - 'Host': 'example.com', + Host: 'example.com', 'User-Agent': 'vitest', 'Content-Length': '0', - 'Accept': '*/*', + Accept: '*/*', 'Content-Type': 'application/json', 'X-Forwarded-For': '127.0.0.1', 'X-Forwarded-Host': 'example.com', @@ -90,7 +91,9 @@ describe('LogViewer', () => { it('should expand and render request/response payloads', () => { render(<LogViewer logs={[createLog()]} />) - fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })) + fireEvent.click( + screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }), + ) const editors = screen.getAllByTestId('code-editor') expect(editors.length).toBe(2) @@ -100,7 +103,9 @@ describe('LogViewer', () => { it('should collapse expanded content when clicked again', () => { render(<LogViewer logs={[createLog()]} />) - const trigger = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }) + const trigger = screen.getByRole('button', { + name: /pluginTrigger\.modal\.manual\.logs\.request/, + }) fireEvent.click(trigger) expect(screen.getAllByTestId('code-editor').length).toBe(2) @@ -116,9 +121,7 @@ describe('LogViewer', () => { cleanup() - const { container: okContainer } = render( - <LogViewer logs={[createLog()]} />, - ) + const { container: okContainer } = render(<LogViewer logs={[createLog()]} />) const okWrapperClass = okContainer.querySelector('[class*="border"]')?.className ?? '' expect(errorWrapperClass).not.toBe(okWrapperClass) @@ -132,12 +135,16 @@ describe('LogViewer', () => { render(<LogViewer logs={[rawLog]} />) - const toggleButton = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }) + const toggleButton = screen.getByRole('button', { + name: /pluginTrigger\.modal\.manual\.logs\.request/, + }) fireEvent.click(toggleButton) expect(screen.getByText('plain response')).toBeInTheDocument() - const copyButton = screen.getAllByRole('button').find(button => button !== toggleButton) as HTMLElement + const copyButton = screen + .getAllByRole('button') + .find((button) => button !== toggleButton) as HTMLElement expect(copyButton).toBeTruthy() fireEvent.click(copyButton) expect(mockWriteText).toHaveBeenCalledWith('plain response') @@ -149,7 +156,9 @@ describe('LogViewer', () => { render(<LogViewer logs={[log]} />) - fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })) + fireEvent.click( + screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }), + ) expect(screen.getAllByTestId('code-editor')[0]).toHaveTextContent('"hello":1') }) @@ -159,7 +168,9 @@ describe('LogViewer', () => { render(<LogViewer logs={[log]} />) - fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })) + fireEvent.click( + screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }), + ) expect(screen.getAllByTestId('code-editor')[0]).toHaveTextContent('payload=%E0%A4%A') }) @@ -169,7 +180,9 @@ describe('LogViewer', () => { render(<LogViewer logs={[log]} />) - fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })) + fireEvent.click( + screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }), + ) expect(screen.getAllByTestId('code-editor')[0]).toHaveTextContent('{invalid}') }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx index 1eb02fb15a2df3..c48ce82fcaf631 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx @@ -24,25 +24,16 @@ vi.mock('@langgenius/dify-ui/popover', async () => { const isControlled = controlledOpen !== undefined const open = isControlled ? !!controlledOpen : uncontrolledOpen const setOpen = (nextOpen: boolean) => { - if (!isControlled) - setUncontrolledOpen(nextOpen) + if (!isControlled) setUncontrolledOpen(nextOpen) onOpenChange?.(nextOpen) } - return ( - <PopoverContext.Provider value={{ open, setOpen }}> - {children} - </PopoverContext.Provider> - ) + return <PopoverContext.Provider value={{ open, setOpen }}>{children}</PopoverContext.Provider> } const PopoverTrigger = ({ render }: { render: React.ReactNode }) => { const { open, setOpen } = React.useContext(PopoverContext) - return ( - <div onClick={() => setOpen(!open)}> - {render} - </div> - ) + return <div onClick={() => setOpen(!open)}>{render}</div> } const PopoverContent = ({ children }: { children: React.ReactNode }) => { @@ -113,7 +104,9 @@ describe('SubscriptionSelectorEntry', () => { it('should render empty state label when no selection and closed', () => { render(<SubscriptionSelectorEntry selectedId={undefined} onSelect={vi.fn()} />) - expect(screen.getByText('pluginTrigger.subscription.noSubscriptionSelected')).toBeInTheDocument() + expect( + screen.getByText('pluginTrigger.subscription.noSubscriptionSelected'), + ).toBeInTheDocument() }) it('should render placeholder when open without selection', () => { @@ -144,7 +137,10 @@ describe('SubscriptionSelectorEntry', () => { fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByRole('button', { name: 'Subscription One' })) - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'sub-1', name: 'Subscription One' }), expect.any(Function)) + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: 'sub-1', name: 'Subscription One' }), + expect.any(Function), + ) expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx index 2256e90e9dbcf2..8056a0e64c71af 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx @@ -70,7 +70,9 @@ describe('SubscriptionSelectorView', () => { fireEvent.click(screen.getByRole('button', { name: 'Subscription One' })) - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'sub-1', name: 'Subscription One' })) + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: 'sub-1', name: 'Subscription One' }), + ) }) it('should handle missing onSelect without crashing', () => { @@ -111,7 +113,9 @@ describe('SubscriptionSelectorView', () => { expect(deleteButton).toBeTruthy() fireEvent.click(deleteButton) - expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument() + expect( + screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/), + ).toBeInTheDocument() }) it('should request selection reset after confirming delete', () => { @@ -121,7 +125,11 @@ describe('SubscriptionSelectorView', () => { const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement fireEvent.click(deleteButton) - fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ })) + fireEvent.click( + screen.getByRole('button', { + name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/, + }), + ) expect(mockDelete).toHaveBeenCalledWith('sub-1', expect.any(Object)) expect(onSelect).toHaveBeenCalledWith({ id: '', name: '' }) @@ -137,6 +145,8 @@ describe('SubscriptionSelectorView', () => { fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/ })) expect(onSelect).not.toHaveBeenCalled() - expect(screen.queryByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).not.toBeInTheDocument() + expect( + screen.queryByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx index 9a89aab7cf4496..27ea82f1ecf47e 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx @@ -18,7 +18,9 @@ vi.mock('../../../store', () => ({ name: 'Plugin', plugin_unique_identifier: 'plugin-uid', provider: 'provider-1', - declaration: { trigger: { subscription_constructor: { parameters: [], credentials_schema: [] } } }, + declaration: { + trigger: { subscription_constructor: { parameters: [], credentials_schema: [] } }, + }, }, }), })) @@ -69,7 +71,9 @@ describe('SubscriptionCard', () => { it('should render used-by text when workflows are present', () => { render(<SubscriptionCard data={createSubscription({ workflows_in_use: 2 })} />) - expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.usedByNum/))!.toBeInTheDocument() + expect( + screen.getByText(/pluginTrigger\.subscription\.list\.item\.usedByNum/), + )!.toBeInTheDocument() }) it('should open delete confirmation when delete action is clicked', () => { @@ -79,7 +83,9 @@ describe('SubscriptionCard', () => { expect(deleteButton).toBeTruthy() fireEvent.click(deleteButton) - expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/))!.toBeInTheDocument() + expect( + screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/), + )!.toBeInTheDocument() }) it('should open edit modal when edit action is clicked', () => { @@ -88,6 +94,8 @@ describe('SubscriptionCard', () => { const editButton = container.querySelectorAll('button')[0] fireEvent.click(editButton!) - expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/))!.toBeInTheDocument() + expect( + screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/), + )!.toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/use-subscription-list.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/use-subscription-list.spec.ts index fc8a0e4642460f..60b3f14bf90d29 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/use-subscription-list.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/use-subscription-list.spec.ts @@ -13,8 +13,9 @@ vi.mock('@/service/use-triggers', () => ({ })) vi.mock('../../store', () => ({ - usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) => - selector({ detail: mockDetail }), + usePluginStore: ( + selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined, + ) => selector({ detail: mockDetail }), })) beforeEach(() => { diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx index 2f050f27478381..fd1f55c692dde0 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx @@ -12,10 +12,20 @@ type PluginDetail = { name: string declaration?: { trigger?: { - subscription_schema?: Array<{ name: string, type: string, required?: boolean, description?: string }> + subscription_schema?: Array<{ + name: string + type: string + required?: boolean + description?: string + }> subscription_constructor?: { - credentials_schema?: Array<{ name: string, type: string, required?: boolean, help?: string }> - parameters?: Array<{ name: string, type: string, required?: boolean, description?: string }> + credentials_schema?: Array<{ + name: string + type: string + required?: boolean + help?: string + }> + parameters?: Array<{ name: string; type: string; required?: boolean; description?: string }> } } } @@ -46,7 +56,9 @@ function createMockPluginDetail(overrides: Partial<PluginDetail> = {}): PluginDe } } -function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBuilder> = {}): TriggerSubscriptionBuilder { +function createMockSubscriptionBuilder( + overrides: Partial<TriggerSubscriptionBuilder> = {}, +): TriggerSubscriptionBuilder { return { id: 'builder-123', name: 'Test Builder', @@ -93,7 +105,9 @@ const setMockPendingStates = (verifying: boolean, building: boolean) => { vi.mock('@/service/use-triggers', () => ({ useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({ mutate: mockVerifyCredentials, - get isPending() { return mockIsVerifyingCredentials }, + get isPending() { + return mockIsVerifyingCredentials + }, }), useCreateTriggerSubscriptionBuilder: () => ({ mutateAsync: mockCreateBuilder, @@ -101,7 +115,9 @@ vi.mock('@/service/use-triggers', () => ({ }), useBuildTriggerSubscription: () => ({ mutate: mockBuildSubscription, - get isPending() { return mockIsBuilding }, + get isPending() { + return mockIsBuilding + }, }), useUpdateTriggerSubscriptionBuilder: () => ({ mutate: mockUpdateBuilder, @@ -154,7 +170,11 @@ const setMockFormValuesConfig = (config: MockFormValuesConfig) => { const setMockGetFormReturnsNull = (value: boolean) => { mockGetFormReturnsNull = value } -const setMockFormValidation = (subscription: boolean, autoParams: boolean, manualProps: boolean) => { +const setMockFormValidation = ( + subscription: boolean, + autoParams: boolean, + manualProps: boolean, +) => { mockSubscriptionFormValidated = subscription mockAutoParamsFormValidated = autoParams mockManualPropsFormValidated = manualProps @@ -164,28 +184,43 @@ vi.mock('@/app/components/base/form/components/base', async () => { const React = await import('react') type MockFormRef = { - getFormValues: (options: Record<string, unknown>) => { values: Record<string, unknown>, isCheckValidated: boolean } - setFields: (fields: Array<{ name: string, errors?: string[], warnings?: string[] }>) => void + getFormValues: (options: Record<string, unknown>) => { + values: Record<string, unknown> + isCheckValidated: boolean + } + setFields: (fields: Array<{ name: string; errors?: string[]; warnings?: string[] }>) => void getForm: () => { setFieldValue: (name: string, value: unknown) => void } | null } - type MockBaseFormProps = { formSchemas: Array<{ name: string }>, onChange?: () => void } - - function MockBaseFormInner({ formSchemas, onChange }: MockBaseFormProps, ref: React.ForwardedRef<MockFormRef>) { - const isSubscriptionForm = formSchemas.some((s: { name: string }) => s.name === 'subscription_name') + type MockBaseFormProps = { formSchemas: Array<{ name: string }>; onChange?: () => void } + + function MockBaseFormInner( + { formSchemas, onChange }: MockBaseFormProps, + ref: React.ForwardedRef<MockFormRef>, + ) { + const isSubscriptionForm = formSchemas.some( + (s: { name: string }) => s.name === 'subscription_name', + ) const isAutoParamsForm = formSchemas.some((s: { name: string }) => - ['repo_name', 'branch', 'repo', 'text_field', 'dynamic_field', 'bool_field', 'text_input_field', 'unknown_field', 'count'].includes(s.name), + [ + 'repo_name', + 'branch', + 'repo', + 'text_field', + 'dynamic_field', + 'bool_field', + 'text_input_field', + 'unknown_field', + 'count', + ].includes(s.name), ) const isManualPropsForm = formSchemas.some((s: { name: string }) => s.name === 'webhook_url') React.useImperativeHandle(ref, () => ({ getFormValues: () => { let isValidated = mockFormValuesConfig.isCheckValidated - if (isSubscriptionForm) - isValidated = mockSubscriptionFormValidated - else if (isAutoParamsForm) - isValidated = mockAutoParamsFormValidated - else if (isManualPropsForm) - isValidated = mockManualPropsFormValidated + if (isSubscriptionForm) isValidated = mockSubscriptionFormValidated + else if (isAutoParamsForm) isValidated = mockAutoParamsFormValidated + else if (isManualPropsForm) isValidated = mockManualPropsFormValidated return { ...mockFormValuesConfig, @@ -193,9 +228,7 @@ vi.mock('@/app/components/base/form/components/base', async () => { } }, setFields: () => {}, - getForm: () => mockGetFormReturnsNull - ? null - : { setFieldValue: () => {} }, + getForm: () => (mockGetFormReturnsNull ? null : { setFieldValue: () => {} }), })) return ( <div data-testid="base-form"> @@ -223,8 +256,10 @@ vi.mock('@/app/components/base/encrypted-bottom', () => ({ vi.mock('../../log-viewer', () => ({ default: ({ logs }: { logs: TriggerLogEntity[] }) => ( <div data-testid="log-viewer"> - {logs.map(log => ( - <div key={log.id} data-testid={`log-${log.id}`}>{log.message}</div> + {logs.map((log) => ( + <div key={log.id} data-testid={`log-${log.id}`}> + {log.message} + </div> ))} </div> ), @@ -270,13 +305,17 @@ describe('CommonCreateModal', () => { it('should render modal with correct title for API Key method', () => { render(<CommonCreateModal {...defaultProps} />) - expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginTrigger.modal.apiKey.title') + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'pluginTrigger.modal.apiKey.title', + ) }) it('should render modal with correct title for Manual method', () => { render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />) - expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginTrigger.modal.manual.title') + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'pluginTrigger.modal.manual.title', + ) }) it('should render modal with correct title for OAuth method', () => { @@ -290,9 +329,7 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], }, }, }, @@ -353,9 +390,7 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], }, }, }, @@ -370,7 +405,9 @@ describe('CommonCreateModal', () => { it('should show verify button text initially', () => { render(<CommonCreateModal {...defaultProps} />) - expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.verify') + expect(screen.getByTestId('modal-confirm')).toHaveTextContent( + 'pluginTrigger.modal.common.verify', + ) }) }) @@ -408,9 +445,7 @@ describe('CommonCreateModal', () => { const detailWithManualSchema = createMockPluginDetail({ declaration: { trigger: { - subscription_schema: [ - { name: 'webhook_url', type: 'text', required: true }, - ], + subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], subscription_constructor: { credentials_schema: [], parameters: [], @@ -428,7 +463,9 @@ describe('CommonCreateModal', () => { it('should show create button text for Manual method', () => { render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />) - expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.create') + expect(screen.getByTestId('modal-confirm')).toHaveTextContent( + 'pluginTrigger.modal.common.create', + ) }) }) @@ -547,7 +584,9 @@ describe('CommonCreateModal', () => { describe('MODAL_TITLE_KEY_MAP', () => { it('should use correct title key for APIKEY', () => { render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.APIKEY} />) - expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginTrigger.modal.apiKey.title') + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'pluginTrigger.modal.apiKey.title', + ) }) it('should use correct title key for OAUTH', () => { @@ -557,7 +596,9 @@ describe('CommonCreateModal', () => { it('should use correct title key for MANUAL', () => { render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />) - expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginTrigger.modal.manual.title') + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'pluginTrigger.modal.manual.title', + ) }) }) @@ -567,9 +608,7 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], }, }, }, @@ -598,7 +637,9 @@ describe('CommonCreateModal', () => { }) await waitFor(() => { - expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.create') + expect(screen.getByTestId('modal-confirm')).toHaveTextContent( + 'pluginTrigger.modal.common.create', + ) }) }) @@ -607,9 +648,7 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], }, }, }, @@ -652,7 +691,13 @@ describe('CommonCreateModal', () => { onSuccess() }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) @@ -666,7 +711,13 @@ describe('CommonCreateModal', () => { onError(new Error('Build failed')) }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) @@ -681,7 +732,14 @@ describe('CommonCreateModal', () => { onSuccess() }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} onClose={mockOnClose} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + onClose={mockOnClose} + />, + ) // Verify component renders with builder expect(screen.getByTestId('modal')).toBeInTheDocument() @@ -694,9 +752,7 @@ describe('CommonCreateModal', () => { const detailWithManualSchema = createMockPluginDetail({ declaration: { trigger: { - subscription_schema: [ - { name: 'webhook_url', type: 'text', required: true }, - ], + subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], subscription_constructor: { credentials_schema: [], parameters: [], @@ -706,7 +762,13 @@ describe('CommonCreateModal', () => { }) mockUsePluginStore.mockReturnValue(detailWithManualSchema) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) const input = screen.getByTestId('form-field-webhook_url') fireEvent.change(input, { target: { value: 'https://example.com/webhook' } }) @@ -721,9 +783,7 @@ describe('CommonCreateModal', () => { const detailWithManualSchema = createMockPluginDetail({ declaration: { trigger: { - subscription_schema: [ - { name: 'webhook_url', type: 'text', required: true }, - ], + subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], subscription_constructor: { credentials_schema: [], parameters: [], @@ -749,9 +809,7 @@ describe('CommonCreateModal', () => { const detailWithManualSchema = createMockPluginDetail({ declaration: { trigger: { - subscription_schema: [ - { name: 'webhook_url', type: 'text', required: true }, - ], + subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], subscription_constructor: { credentials_schema: [], parameters: [], @@ -799,7 +857,13 @@ describe('CommonCreateModal', () => { endpoint: 'http://localhost:3000/callback', }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) // Verify component renders with the private address endpoint expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() @@ -813,7 +877,13 @@ describe('CommonCreateModal', () => { endpoint: 'https://example.com/callback', }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) // Verify component renders with public address endpoint expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() @@ -838,7 +908,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithAutoParams) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-repo_name')).toBeInTheDocument() expect(screen.getByTestId('form-field-branch')).toBeInTheDocument() @@ -850,9 +926,7 @@ describe('CommonCreateModal', () => { trigger: { subscription_constructor: { credentials_schema: [], - parameters: [ - { name: 'repo_name', type: 'string', required: true }, - ], + parameters: [{ name: 'repo_name', type: 'string', required: true }], }, }, }, @@ -886,7 +960,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithVariousTypes) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-text_field')).toBeInTheDocument() expect(screen.getByTestId('form-field-secret_field')).toBeInTheDocument() @@ -900,9 +980,7 @@ describe('CommonCreateModal', () => { trigger: { subscription_constructor: { credentials_schema: [], - parameters: [ - { name: 'count', type: 'integer' }, - ], + parameters: [{ name: 'count', type: 'integer' }], }, }, }, @@ -910,7 +988,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithInteger) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-count')).toBeInTheDocument() }) @@ -922,9 +1006,7 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], }, }, }, @@ -944,7 +1026,13 @@ describe('CommonCreateModal', () => { describe('Subscription Form in Configuration Step', () => { it('should render subscription name and callback URL fields', () => { const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument() expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() @@ -957,16 +1045,26 @@ describe('CommonCreateModal', () => { render(<CommonCreateModal {...defaultProps} />) - expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.verifying') + expect(screen.getByTestId('modal-confirm')).toHaveTextContent( + 'pluginTrigger.modal.common.verifying', + ) }) it('should show creating text when isBuilding is true', () => { setMockPendingStates(false, true) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) - expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.creating') + expect(screen.getByTestId('modal-confirm')).toHaveTextContent( + 'pluginTrigger.modal.common.creating', + ) }) it('should disable confirm button when verifying', () => { @@ -981,7 +1079,13 @@ describe('CommonCreateModal', () => { setMockPendingStates(false, true) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) expect(screen.getByTestId('modal-confirm')).toBeDisabled() }) @@ -1016,7 +1120,13 @@ describe('CommonCreateModal', () => { it('should not show EncryptedBottom in Configuration step', () => { const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) expect(screen.queryByTestId('encrypted-bottom')).not.toBeInTheDocument() }) @@ -1028,7 +1138,13 @@ describe('CommonCreateModal', () => { setMockFormValidation(false, true, true) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) @@ -1045,9 +1161,7 @@ describe('CommonCreateModal', () => { trigger: { subscription_constructor: { credentials_schema: [], - parameters: [ - { name: 'repo_name', type: 'string', required: true }, - ], + parameters: [{ name: 'repo_name', type: 'string', required: true }], }, }, }, @@ -1055,7 +1169,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithAutoParams) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) @@ -1070,9 +1190,7 @@ describe('CommonCreateModal', () => { const detailWithManualSchema = createMockPluginDetail({ declaration: { trigger: { - subscription_schema: [ - { name: 'webhook_url', type: 'text', required: true }, - ], + subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], subscription_constructor: { credentials_schema: [], parameters: [], @@ -1083,7 +1201,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithManualSchema) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) @@ -1100,9 +1224,7 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], }, }, }, @@ -1143,7 +1265,13 @@ describe('CommonCreateModal', () => { onError(new Error('Raw build error')) }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) @@ -1167,7 +1295,13 @@ describe('CommonCreateModal', () => { onError(new Error('Raw error')) }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) @@ -1185,9 +1319,7 @@ describe('CommonCreateModal', () => { const detailWithManualSchema = createMockPluginDetail({ declaration: { trigger: { - subscription_schema: [ - { name: 'webhook_url', type: 'text', required: true }, - ], + subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], subscription_constructor: { credentials_schema: [], parameters: [], @@ -1239,7 +1371,13 @@ describe('CommonCreateModal', () => { endpoint: 'https://example.com/callback', }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) // Component should render without errors even when getForm returns null expect(screen.getByTestId('modal')).toBeInTheDocument() @@ -1264,7 +1402,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithFormTypeEnum) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-text_input_field')).toBeInTheDocument() expect(screen.getByTestId('form-field-secret_input_field')).toBeInTheDocument() @@ -1276,9 +1420,7 @@ describe('CommonCreateModal', () => { trigger: { subscription_constructor: { credentials_schema: [], - parameters: [ - { name: 'unknown_field', type: 'unknown-type' }, - ], + parameters: [{ name: 'unknown_field', type: 'unknown-type' }], }, }, }, @@ -1286,7 +1428,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithUnknownType) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-unknown_field')).toBeInTheDocument() }) @@ -1298,9 +1446,7 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], }, }, }, @@ -1332,7 +1478,14 @@ describe('CommonCreateModal', () => { onSuccess() }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} onClose={mockOnClose} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + onClose={mockOnClose} + />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) @@ -1360,9 +1513,7 @@ describe('CommonCreateModal', () => { trigger: { subscription_constructor: { credentials_schema: [], - parameters: [ - { name: 'dynamic_field', type: 'dynamic-select', required: true }, - ], + parameters: [{ name: 'dynamic_field', type: 'dynamic-select', required: true }], }, }, }, @@ -1370,7 +1521,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithDynamicSelect) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-dynamic_field')).toBeInTheDocument() }) @@ -1383,9 +1540,7 @@ describe('CommonCreateModal', () => { trigger: { subscription_constructor: { credentials_schema: [], - parameters: [ - { name: 'bool_field', type: 'boolean', required: false }, - ], + parameters: [{ name: 'bool_field', type: 'boolean', required: false }], }, }, }, @@ -1393,7 +1548,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithBoolean) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-bool_field')).toBeInTheDocument() }) @@ -1410,9 +1571,7 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], }, }, }, @@ -1446,7 +1605,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithEmptyParams) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) // Should only have subscription form fields expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument() @@ -1506,7 +1671,12 @@ describe('CommonCreateModal', () => { subscription_constructor: { credentials_schema: [], parameters: [ - { name: 'repo_name', type: 'string', required: true, description: 'Repository name' }, + { + name: 'repo_name', + type: 'string', + required: true, + description: 'Repository name', + }, ], }, }, @@ -1515,7 +1685,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithDescription) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-repo_name')).toBeInTheDocument() }) @@ -1564,12 +1740,8 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], - parameters: [ - { name: 'repo', type: 'string', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], + parameters: [{ name: 'repo', type: 'string', required: true }], }, }, }, @@ -1621,7 +1793,13 @@ describe('CommonCreateModal', () => { }) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) @@ -1637,9 +1815,7 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], }, }, }, @@ -1657,9 +1833,7 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], }, }, }, @@ -1680,7 +1854,13 @@ describe('CommonCreateModal', () => { onSuccess() }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) fireEvent.click(screen.getByTestId('modal-confirm')) @@ -1709,7 +1889,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithMixedTypes) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-dynamic_field')).toBeInTheDocument() expect(screen.getByTestId('form-field-bool_field')).toBeInTheDocument() @@ -1733,7 +1919,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithNonDynamic) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-text_field')).toBeInTheDocument() expect(screen.getByTestId('form-field-number_field')).toBeInTheDocument() @@ -1756,7 +1948,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithNonBoolean) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-text_field')).toBeInTheDocument() expect(screen.getByTestId('form-field-secret_field')).toBeInTheDocument() @@ -1769,7 +1967,13 @@ describe('CommonCreateModal', () => { endpoint: undefined, }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builderWithoutEndpoint} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builderWithoutEndpoint} + />, + ) expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() }) @@ -1779,7 +1983,13 @@ describe('CommonCreateModal', () => { endpoint: '', }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builderWithEmptyEndpoint} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builderWithEmptyEndpoint} + />, + ) expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() }) @@ -1793,9 +2003,7 @@ describe('CommonCreateModal', () => { trigger: { subscription_constructor: { credentials_schema: [], - parameters: [ - { name: 'dynamic_field', type: 'dynamic-select', required: true }, - ], + parameters: [{ name: 'dynamic_field', type: 'dynamic-select', required: true }], }, }, }, @@ -1803,7 +2011,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithoutPluginId) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-dynamic_field')).toBeInTheDocument() }) @@ -1842,7 +2056,13 @@ describe('CommonCreateModal', () => { setMockPendingStates(false, true) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builder} + />, + ) expect(screen.getByTestId('modal')).toHaveAttribute('data-disabled', 'true') }) @@ -1855,9 +2075,7 @@ describe('CommonCreateModal', () => { trigger: { subscription_constructor: { credentials_schema: [], - parameters: [ - { name: 'text_type_field', type: 'text' }, - ], + parameters: [{ name: 'text_type_field', type: 'text' }], }, }, }, @@ -1865,7 +2083,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithText) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-text_type_field')).toBeInTheDocument() }) @@ -1876,9 +2100,7 @@ describe('CommonCreateModal', () => { trigger: { subscription_constructor: { credentials_schema: [], - parameters: [ - { name: 'secret_type_field', type: 'secret' }, - ], + parameters: [{ name: 'secret_type_field', type: 'secret' }], }, }, }, @@ -1886,7 +2108,13 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithSecret) const builder = createMockSubscriptionBuilder() - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.OAUTH} + builder={builder} + />, + ) expect(screen.getByTestId('form-field-secret_type_field')).toBeInTheDocument() }) @@ -1898,9 +2126,7 @@ describe('CommonCreateModal', () => { provider: '', declaration: { trigger: { - subscription_schema: [ - { name: 'webhook_url', type: 'text', required: true }, - ], + subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], subscription_constructor: { credentials_schema: [], parameters: [], @@ -1926,7 +2152,13 @@ describe('CommonCreateModal', () => { endpoint: '', }) - render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builderWithoutEndpoint} />) + render( + <CommonCreateModal + {...defaultProps} + createType={SupportedCreationMethods.MANUAL} + builder={builderWithoutEndpoint} + />, + ) // Component should render without errors expect(screen.getByTestId('modal')).toBeInTheDocument() @@ -1939,9 +2171,7 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], }, }, }, @@ -1949,7 +2179,9 @@ describe('CommonCreateModal', () => { mockUsePluginStore.mockReturnValue(detailWithCredentials) // Make createBuilder slow - mockCreateBuilder.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000))) + mockCreateBuilder.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 1000)), + ) render(<CommonCreateModal {...defaultProps} />) @@ -1967,12 +2199,8 @@ describe('CommonCreateModal', () => { declaration: { trigger: { subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true }, - ], - parameters: [ - { name: 'extra_param', type: 'string', required: true }, - ], + credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], + parameters: [{ name: 'extra_param', type: 'string', required: true }], }, }, }, @@ -2004,9 +2232,7 @@ describe('CommonCreateModal', () => { const detailWithManualSchema = createMockPluginDetail({ declaration: { trigger: { - subscription_schema: [ - { name: 'webhook_url', type: 'text', required: true }, - ], + subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], subscription_constructor: { credentials_schema: [], parameters: [], diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx index 726c6f32dce903..fe7999d569a92e 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx @@ -1,5 +1,10 @@ import type { SimpleDetail } from '../../../store' -import type { TriggerOAuthConfig, TriggerProviderApiEntity, TriggerSubscription, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import type { + TriggerOAuthConfig, + TriggerProviderApiEntity, + TriggerSubscription, + TriggerSubscriptionBuilder, +} from '@/app/components/workflow/block-selector/types' import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -22,8 +27,9 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ let mockStoreDetail: SimpleDetail | undefined vi.mock('../../../store', () => ({ - usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) => - selector({ detail: mockStoreDetail }), + usePluginStore: ( + selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined, + ) => selector({ detail: mockStoreDetail }), })) const mockSubscriptions: TriggerSubscription[] = [] @@ -36,7 +42,10 @@ vi.mock('../../use-subscription-list', () => ({ })) let mockProviderInfo: { data: TriggerProviderApiEntity | undefined } = { data: undefined } -let mockOAuthConfig: { data: TriggerOAuthConfig | undefined, refetch: () => void } = { data: undefined, refetch: vi.fn() } +let mockOAuthConfig: { data: TriggerOAuthConfig | undefined; refetch: () => void } = { + data: undefined, + refetch: vi.fn(), +} const mockInitiateOAuth = vi.fn() vi.mock('@/service/use-triggers', () => ({ @@ -54,14 +63,18 @@ vi.mock('@/hooks/use-oauth', () => ({ })) vi.mock('../common-modal', () => ({ - CommonCreateModal: ({ open, createType, onClose, builder }: { + CommonCreateModal: ({ + open, + createType, + onClose, + builder, + }: { open?: boolean createType: SupportedCreationMethods onClose: () => void builder?: TriggerSubscriptionBuilder }) => { - if (open === false) - return null + if (open === false) return null return ( <div @@ -69,10 +82,7 @@ vi.mock('../common-modal', () => ({ data-create-type={createType} data-has-builder={!!builder} > - <button - data-testid="close-modal" - onClick={onClose} - > + <button data-testid="close-modal" onClick={onClose}> Close </button> </div> @@ -81,34 +91,39 @@ vi.mock('../common-modal', () => ({ })) vi.mock('../oauth-client', () => ({ - OAuthClientSettingsModal: ({ open, oauthConfig, onOpenChange, showOAuthCreateModal }: { + OAuthClientSettingsModal: ({ + open, + oauthConfig, + onOpenChange, + showOAuthCreateModal, + }: { open: boolean oauthConfig?: TriggerOAuthConfig onOpenChange: (open: boolean) => void showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void }) => { - if (!open) - return null + if (!open) return null return ( - <div - data-testid="oauth-client-modal" - data-has-config={!!oauthConfig} - > - <button data-testid="close-oauth-modal" onClick={() => onOpenChange(false)}>Close</button> + <div data-testid="oauth-client-modal" data-has-config={!!oauthConfig}> + <button data-testid="close-oauth-modal" onClick={() => onOpenChange(false)}> + Close + </button> <button data-testid="show-create-modal" - onClick={() => showOAuthCreateModal({ - id: 'test-builder', - name: 'test', - provider: 'test-provider', - credential_type: TriggerCredentialTypeEnum.Oauth2, - credentials: {}, - endpoint: 'https://test.com', - parameters: {}, - properties: {}, - workflows_in_use: 0, - })} + onClick={() => + showOAuthCreateModal({ + id: 'test-builder', + name: 'test', + provider: 'test-provider', + credential_type: TriggerCredentialTypeEnum.Oauth2, + credentials: {}, + endpoint: 'https://test.com', + parameters: {}, + properties: {}, + workflows_in_use: 0, + }) + } > Show Create Modal </button> @@ -127,12 +142,16 @@ vi.mock('@langgenius/dify-ui/select', async () => { const countOptions = (children: React.ReactNode): number => { return React.Children.toArray(children).reduce<number>((count, child) => { - if (!React.isValidElement<{ children?: React.ReactNode }>(child)) - return count + if (!React.isValidElement<{ children?: React.ReactNode }>(child)) return count - return count + React.Children.toArray(child.props.children).filter((nestedChild) => { - return React.isValidElement<{ value?: string }>(nestedChild) && 'value' in nestedChild.props - }).length + return ( + count + + React.Children.toArray(child.props.children).filter((nestedChild) => { + return ( + React.isValidElement<{ value?: string }>(nestedChild) && 'value' in nestedChild.props + ) + }).length + ) }, 0) } @@ -152,8 +171,9 @@ vi.mock('@langgenius/dify-ui/select', async () => { }) => { const currentValue = value ?? DEFAULT_METHOD const optionsCount = countOptions(children) - const containerOpen - = currentValue === DEFAULT_METHOD || (currentValue === SupportedCreationMethods.OAUTH && optionsCount === 1) + const containerOpen = + currentValue === DEFAULT_METHOD || + (currentValue === SupportedCreationMethods.OAUTH && optionsCount === 1) ? undefined : String(open ?? false) @@ -171,7 +191,14 @@ vi.mock('@langgenius/dify-ui/select', async () => { </SelectContext.Provider> ) }, - SelectTrigger: ({ children, className }: { children: React.ReactNode, render?: React.ReactNode, className?: string }) => { + SelectTrigger: ({ + children, + className, + }: { + children: React.ReactNode + render?: React.ReactNode + className?: string + }) => { const context = React.useContext(SelectContext) return ( <div @@ -186,13 +213,10 @@ vi.mock('@langgenius/dify-ui/select', async () => { SelectContent: ({ children }: { children: React.ReactNode }) => ( <div data-testid="options-container">{children}</div> ), - SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => { const context = React.useContext(SelectContext) return ( - <div - data-testid={`option-${value}`} - onClick={() => context.onValueChange?.(value)} - > + <div data-testid={`option-${value}`} onClick={() => context.onValueChange?.(value)}> {children} </div> ) @@ -202,7 +226,9 @@ vi.mock('@langgenius/dify-ui/select', async () => { } }) -const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}): TriggerProviderApiEntity => ({ +const createProviderInfo = ( + overrides: Partial<TriggerProviderApiEntity> = {}, +): TriggerProviderApiEntity => ({ author: 'test-author', name: 'test-provider', label: { en_US: 'Test Provider', zh_Hans: 'Test Provider' }, @@ -253,26 +279,30 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg ...overrides, }) -const createDefaultProps = (overrides: Partial<Parameters<typeof CreateSubscriptionButton>[0]> = {}) => ({ +const createDefaultProps = ( + overrides: Partial<Parameters<typeof CreateSubscriptionButton>[0]> = {}, +) => ({ ...overrides, }) -const getCreateButton = () => screen.getByRole('button', { - name: /pluginTrigger\.subscription\.(createButton|empty\.button)/, -}) +const getCreateButton = () => + screen.getByRole('button', { + name: /pluginTrigger\.subscription\.(createButton|empty\.button)/, + }) -const setupMocks = (config: { - providerInfo?: TriggerProviderApiEntity - oauthConfig?: TriggerOAuthConfig - storeDetail?: SimpleDetail - subscriptions?: TriggerSubscription[] -} = {}) => { +const setupMocks = ( + config: { + providerInfo?: TriggerProviderApiEntity + oauthConfig?: TriggerOAuthConfig + storeDetail?: SimpleDetail + subscriptions?: TriggerSubscription[] + } = {}, +) => { mockProviderInfo = { data: config.providerInfo } mockOAuthConfig = { data: config.oauthConfig, refetch: vi.fn() } mockStoreDetail = config.storeDetail mockSubscriptions.length = 0 - if (config.subscriptions) - mockSubscriptions.push(...config.subscriptions) + if (config.subscriptions) mockSubscriptions.push(...config.subscriptions) } describe('CreateSubscriptionButton', () => { @@ -302,7 +332,9 @@ describe('CreateSubscriptionButton', () => { // Arrange setupMocks({ storeDetail: createStoreDetail(), - providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.MANUAL], + }), }) const props = createDefaultProps() @@ -348,7 +380,9 @@ describe('CreateSubscriptionButton', () => { // Arrange setupMocks({ storeDetail: createStoreDetail(), - providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.MANUAL], + }), }) const props = createDefaultProps() @@ -364,7 +398,9 @@ describe('CreateSubscriptionButton', () => { // Arrange setupMocks({ storeDetail: createStoreDetail(), - providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.MANUAL], + }), }) const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON }) @@ -382,7 +418,9 @@ describe('CreateSubscriptionButton', () => { // Arrange setupMocks({ storeDetail: createStoreDetail(), - providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.MANUAL], + }), }) const props = createDefaultProps() @@ -398,9 +436,14 @@ describe('CreateSubscriptionButton', () => { // Arrange setupMocks({ storeDetail: createStoreDetail(), - providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.MANUAL], + }), + }) + const props = createDefaultProps({ + buttonType: CreateButtonType.ICON_BUTTON, + shape: 'circle', }) - const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON, shape: 'circle' }) // Act render(<CreateSubscriptionButton {...props} />) @@ -417,7 +460,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY], + supported_creation_methods: [ + SupportedCreationMethods.MANUAL, + SupportedCreationMethods.APIKEY, + ], }), }) const props = createDefaultProps() @@ -432,7 +478,10 @@ describe('CreateSubscriptionButton', () => { // Assert await waitFor(() => { expect(screen.getByTestId('common-create-modal'))!.toBeInTheDocument() - expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute('data-create-type', SupportedCreationMethods.MANUAL) + expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute( + 'data-create-type', + SupportedCreationMethods.MANUAL, + ) }) }) @@ -441,7 +490,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY], + supported_creation_methods: [ + SupportedCreationMethods.MANUAL, + SupportedCreationMethods.APIKEY, + ], }), }) const props = createDefaultProps() @@ -511,9 +563,11 @@ describe('CreateSubscriptionButton', () => { fireEvent.click(screen.getByTestId('custom-trigger')) expect(screen.getByTestId('custom-select'))!.toHaveAttribute('data-open', 'true') - fireEvent.click(screen.getByRole('button', { - name: 'pluginTrigger.subscription.addType.options.oauth.clientSettings', - })) + fireEvent.click( + screen.getByRole('button', { + name: 'pluginTrigger.subscription.addType.options.oauth.clientSettings', + }), + ) // Assert await waitFor(() => { @@ -525,7 +579,10 @@ describe('CreateSubscriptionButton', () => { it('should close OAuthClientSettingsModal and refetch config when closed', async () => { // Arrange const mockRefetchOAuth = vi.fn() - mockOAuthConfig = { data: createOAuthConfig({ configured: false }), refetch: mockRefetchOAuth } + mockOAuthConfig = { + data: createOAuthConfig({ configured: false }), + refetch: mockRefetchOAuth, + } setupMocks({ storeDetail: createStoreDetail(), @@ -622,7 +679,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY], + supported_creation_methods: [ + SupportedCreationMethods.MANUAL, + SupportedCreationMethods.APIKEY, + ], }), }) const props = createDefaultProps() @@ -732,7 +792,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY], + supported_creation_methods: [ + SupportedCreationMethods.MANUAL, + SupportedCreationMethods.APIKEY, + ], }), }) const props = createDefaultProps() @@ -933,7 +996,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL], + supported_creation_methods: [ + SupportedCreationMethods.OAUTH, + SupportedCreationMethods.MANUAL, + ], }), oauthConfig: createOAuthConfig({ configured: false }), }) @@ -957,7 +1023,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL], + supported_creation_methods: [ + SupportedCreationMethods.OAUTH, + SupportedCreationMethods.MANUAL, + ], }), oauthConfig: createOAuthConfig({ configured: true }), }) @@ -981,7 +1050,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.APIKEY, SupportedCreationMethods.MANUAL], + supported_creation_methods: [ + SupportedCreationMethods.APIKEY, + SupportedCreationMethods.MANUAL, + ], }), }) const props = createDefaultProps() @@ -996,7 +1068,10 @@ describe('CreateSubscriptionButton', () => { // Assert await waitFor(() => { expect(screen.getByTestId('common-create-modal'))!.toBeInTheDocument() - expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute('data-create-type', SupportedCreationMethods.APIKEY) + expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute( + 'data-create-type', + SupportedCreationMethods.APIKEY, + ) }) }) @@ -1005,7 +1080,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY], + supported_creation_methods: [ + SupportedCreationMethods.MANUAL, + SupportedCreationMethods.APIKEY, + ], }), }) const props = createDefaultProps() @@ -1020,7 +1098,10 @@ describe('CreateSubscriptionButton', () => { // Assert await waitFor(() => { expect(screen.getByTestId('common-create-modal'))!.toBeInTheDocument() - expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute('data-create-type', SupportedCreationMethods.MANUAL) + expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute( + 'data-create-type', + SupportedCreationMethods.MANUAL, + ) }) }) }) @@ -1065,7 +1146,9 @@ describe('CreateSubscriptionButton', () => { // Arrange setupMocks({ storeDetail: createStoreDetail({ provider: 'my-provider' }), - providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.MANUAL], + }), }) const props = createDefaultProps() @@ -1107,7 +1190,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL], + supported_creation_methods: [ + SupportedCreationMethods.OAUTH, + SupportedCreationMethods.MANUAL, + ], }), oauthConfig: createOAuthConfig({ configured: true }), }) @@ -1123,20 +1209,28 @@ describe('CreateSubscriptionButton', () => { // Assert - modal should open with OAuth type and builder await waitFor(() => { expect(screen.getByTestId('common-create-modal'))!.toBeInTheDocument() - expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute('data-has-builder', 'true') + expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute( + 'data-has-builder', + 'true', + ) }) }) it('should handle OAuth initiation error', async () => { // Arrange - mockInitiateOAuth.mockImplementation((_provider: string, callbacks: { onError: () => void }) => { - callbacks.onError() - }) + mockInitiateOAuth.mockImplementation( + (_provider: string, callbacks: { onError: () => void }) => { + callbacks.onError() + }, + ) setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL], + supported_creation_methods: [ + SupportedCreationMethods.OAUTH, + SupportedCreationMethods.MANUAL, + ], }), oauthConfig: createOAuthConfig({ configured: true }), }) @@ -1162,7 +1256,9 @@ describe('CreateSubscriptionButton', () => { // Arrange setupMocks({ storeDetail: createStoreDetail(), - providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.MANUAL], + }), subscriptions: undefined, }) const props = createDefaultProps() @@ -1209,7 +1305,9 @@ describe('CreateSubscriptionButton', () => { // Arrange setupMocks({ storeDetail: undefined, - providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.MANUAL], + }), }) const props = createDefaultProps() @@ -1243,7 +1341,8 @@ describe('CreateSubscriptionButton', () => { it('should show max count tooltip when subscriptions reach limit', () => { // Arrange const maxSubscriptions = Array.from({ length: 10 }, (_, i) => - createSubscription({ id: `sub-${i}` })) + createSubscription({ id: `sub-${i}` }), + ) setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ @@ -1289,8 +1388,14 @@ describe('CreateSubscriptionButton', () => { // Assert - CommonCreateModal should be shown with OAuth type and builder await waitFor(() => { expect(screen.getByTestId('common-create-modal'))!.toBeInTheDocument() - expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute('data-create-type', SupportedCreationMethods.OAUTH) - expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute('data-has-builder', 'true') + expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute( + 'data-create-type', + SupportedCreationMethods.OAUTH, + ) + expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute( + 'data-has-builder', + 'true', + ) }) }) }) @@ -1340,7 +1445,8 @@ describe('CreateSubscriptionButton', () => { it('should apply disabled state when subscription count reaches max', () => { // Arrange const maxSubscriptions = Array.from({ length: 10 }, (_, i) => - createSubscription({ id: `sub-${i}` })) + createSubscription({ id: `sub-${i}` }), + ) setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ @@ -1366,7 +1472,10 @@ describe('CreateSubscriptionButton', () => { supported_creation_methods: [SupportedCreationMethods.MANUAL], }), }) - const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON, shape: 'circle' }) + const props = createDefaultProps({ + buttonType: CreateButtonType.ICON_BUTTON, + shape: 'circle', + }) // Act render(<CreateSubscriptionButton {...props} />) @@ -1384,7 +1493,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY], + supported_creation_methods: [ + SupportedCreationMethods.MANUAL, + SupportedCreationMethods.APIKEY, + ], }), }) const props = createDefaultProps() @@ -1501,7 +1613,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY], + supported_creation_methods: [ + SupportedCreationMethods.MANUAL, + SupportedCreationMethods.APIKEY, + ], }), }) const props = createDefaultProps() @@ -1525,7 +1640,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY], + supported_creation_methods: [ + SupportedCreationMethods.MANUAL, + SupportedCreationMethods.APIKEY, + ], }), }) const props = createDefaultProps() @@ -1574,7 +1692,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL], + supported_creation_methods: [ + SupportedCreationMethods.OAUTH, + SupportedCreationMethods.MANUAL, + ], }), oauthConfig: createOAuthConfig({ configured: true }), }) @@ -1626,7 +1747,10 @@ describe('CreateSubscriptionButton', () => { it('should refetch OAuth config when OAuthClientSettingsModal is closed', async () => { // Arrange const mockRefetchOAuth = vi.fn() - mockOAuthConfig = { data: createOAuthConfig({ configured: false }), refetch: mockRefetchOAuth } + mockOAuthConfig = { + data: createOAuthConfig({ configured: false }), + refetch: mockRefetchOAuth, + } setupMocks({ storeDetail: createStoreDetail(), @@ -1688,8 +1812,14 @@ describe('CreateSubscriptionButton', () => { // Assert - CommonCreateModal should appear with OAuth type and builder await waitFor(() => { expect(screen.getByTestId('common-create-modal'))!.toBeInTheDocument() - expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute('data-create-type', SupportedCreationMethods.OAUTH) - expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute('data-has-builder', 'true') + expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute( + 'data-create-type', + SupportedCreationMethods.OAUTH, + ) + expect(screen.getByTestId('common-create-modal'))!.toHaveAttribute( + 'data-has-builder', + 'true', + ) }) }) }) @@ -1699,10 +1829,12 @@ describe('CreateSubscriptionButton', () => { it('should not open modal when OAuth callback returns falsy data', async () => { // Arrange const { openOAuthPopup } = await import('@/hooks/use-oauth') - vi.mocked(openOAuthPopup).mockImplementation((url: string, callback: (data?: unknown) => void) => { - callback(undefined) // falsy callback data - return null - }) + vi.mocked(openOAuthPopup).mockImplementation( + (url: string, callback: (data?: unknown) => void) => { + callback(undefined) // falsy callback data + return null + }, + ) const mockBuilder: TriggerSubscriptionBuilder = { id: 'oauth-builder', @@ -1716,17 +1848,30 @@ describe('CreateSubscriptionButton', () => { workflows_in_use: 0, } - mockInitiateOAuth.mockImplementation((_provider: string, callbacks: { onSuccess: (response: { authorization_url: string, subscription_builder: TriggerSubscriptionBuilder }) => void }) => { - callbacks.onSuccess({ - authorization_url: 'https://oauth.test.com/authorize', - subscription_builder: mockBuilder, - }) - }) + mockInitiateOAuth.mockImplementation( + ( + _provider: string, + callbacks: { + onSuccess: (response: { + authorization_url: string + subscription_builder: TriggerSubscriptionBuilder + }) => void + }, + ) => { + callbacks.onSuccess({ + authorization_url: 'https://oauth.test.com/authorize', + subscription_builder: mockBuilder, + }) + }, + ) setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL], + supported_creation_methods: [ + SupportedCreationMethods.OAUTH, + SupportedCreationMethods.MANUAL, + ], }), oauthConfig: createOAuthConfig({ configured: true }), }) @@ -1754,7 +1899,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.APIKEY, SupportedCreationMethods.MANUAL], + supported_creation_methods: [ + SupportedCreationMethods.APIKEY, + SupportedCreationMethods.MANUAL, + ], }), }) const props = createDefaultProps() @@ -1796,7 +1944,10 @@ describe('CreateSubscriptionButton', () => { setupMocks({ storeDetail: createStoreDetail(), providerInfo: createProviderInfo({ - supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY], + supported_creation_methods: [ + SupportedCreationMethods.MANUAL, + SupportedCreationMethods.APIKEY, + ], }), subscriptions: [createSubscription()], // Not at max }) @@ -1876,7 +2027,12 @@ describe('CreateSubscriptionButton', () => { it('should handle providerInfo with null supported_creation_methods', () => { // Arrange - mockProviderInfo = { data: { ...createProviderInfo(), supported_creation_methods: null as unknown as SupportedCreationMethods[] } } + mockProviderInfo = { + data: { + ...createProviderInfo(), + supported_creation_methods: null as unknown as SupportedCreationMethods[], + }, + } mockOAuthConfig = { data: undefined, refetch: vi.fn() } mockStoreDetail = createStoreDetail() const props = createDefaultProps() diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx index 21c76ecdcab7ea..5428d0c53f4f40 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx @@ -1,4 +1,7 @@ -import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import type { + TriggerOAuthConfig, + TriggerSubscriptionBuilder, +} from '@/app/components/workflow/block-selector/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -23,8 +26,18 @@ function createMockOAuthConfig(overrides: Partial<TriggerOAuthConfig> = {}): Tri client_secret: 'default-client-secret', }, oauth_client_schema: [ - { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown }, - { name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown }, + { + name: 'client_id', + type: 'text-input' as unknown, + required: true, + label: { 'en-US': 'Client ID' } as unknown, + }, + { + name: 'client_secret', + type: 'secret-input' as unknown, + required: true, + label: { 'en-US': 'Client Secret' } as unknown, + }, ] as TriggerOAuthConfig['oauth_client_schema'], ...overrides, } @@ -39,7 +52,9 @@ function createMockPluginDetail(overrides: Partial<PluginDetail> = {}): PluginDe } } -function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBuilder> = {}): TriggerSubscriptionBuilder { +function createMockSubscriptionBuilder( + overrides: Partial<TriggerSubscriptionBuilder> = {}, +): TriggerSubscriptionBuilder { return { id: 'builder-123', name: 'Test Builder', @@ -82,13 +97,15 @@ vi.mock('@/service/use-triggers', () => ({ const mockOpenOAuthPopup = vi.fn() vi.mock('@/hooks/use-oauth', () => ({ - openOAuthPopup: (url: string, callback: (data: unknown) => void) => mockOpenOAuthPopup(url, callback), + openOAuthPopup: (url: string, callback: (data: unknown) => void) => + mockOpenOAuthPopup(url, callback), })) const mockToastNotify = vi.fn() vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( - (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), + (message: string, options?: { type?: string }) => + mockToastNotify({ type: options?.type, message }), { success: (message: string) => mockToastNotify({ type: 'success', message }), error: (message: string) => mockToastNotify({ type: 'error', message }), @@ -110,7 +127,7 @@ Object.defineProperty(navigator, 'clipboard', { writable: true, }) -let mockFormValues: { values: Record<string, string>, isCheckValidated: boolean } = { +let mockFormValues: { values: Record<string, string>; isCheckValidated: boolean } = { values: { client_id: 'test-client-id', client_secret: 'test-client-secret' }, isCheckValidated: true, } @@ -123,15 +140,17 @@ vi.mock('@/app/components/base/form/components/base', () => ({ formSchemas, ref, }: { - formSchemas: Array<{ name: string, default?: string }> - ref?: React.Ref<{ getFormValues: () => { values: Record<string, string>, isCheckValidated: boolean } }> + formSchemas: Array<{ name: string; default?: string }> + ref?: React.Ref<{ + getFormValues: () => { values: Record<string, string>; isCheckValidated: boolean } + }> }) => { React.useImperativeHandle(ref, () => ({ getFormValues: () => mockFormValues, })) return ( <div data-testid="base-form"> - {formSchemas.map(schema => ( + {formSchemas.map((schema) => ( <input key={schema.name} data-testid={`form-field-${schema.name}`} @@ -156,9 +175,10 @@ describe('OAuthClientSettingsModal', () => { const getCloseButton = () => screen.getByRole('button', { name: 'Close' }) const getCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' }) const getSaveOnlyButton = () => screen.getByRole('button', { name: 'plugin.auth.saveOnly' }) - const getConfirmButton = () => screen.getByRole('button', { - name: /plugin\.auth\.saveAndAuth|pluginTrigger\.modal\.common\.authorizing|pluginTrigger\.modal\.oauth\.authorization\.waitingJump/, - }) + const getConfirmButton = () => + screen.getByRole('button', { + name: /plugin\.auth\.saveAndAuth|pluginTrigger\.modal\.common\.authorizing|pluginTrigger\.modal\.oauth\.authorization\.waitingJump/, + }) beforeEach(() => { vi.clearAllMocks() @@ -191,8 +211,12 @@ describe('OAuthClientSettingsModal', () => { it('should render client type selector when system_configured is true', () => { render(<OAuthClientSettingsModal {...defaultProps} />) - expect(screen.getByText('pluginTrigger.subscription.addType.options.oauth.default')).toBeInTheDocument() - expect(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom')).toBeInTheDocument() + expect( + screen.getByText('pluginTrigger.subscription.addType.options.oauth.default'), + ).toBeInTheDocument() + expect( + screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom'), + ).toBeInTheDocument() }) it('should not render client type selector when system_configured is false', () => { @@ -200,9 +224,13 @@ describe('OAuthClientSettingsModal', () => { system_configured: false, }) - render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithoutSystemConfigured} />) + render( + <OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithoutSystemConfigured} />, + ) - expect(screen.queryByText('pluginTrigger.subscription.addType.options.oauth.default')).not.toBeInTheDocument() + expect( + screen.queryByText('pluginTrigger.subscription.addType.options.oauth.default'), + ).not.toBeInTheDocument() }) it('should render redirect URI info when custom client type is selected', () => { @@ -245,14 +273,18 @@ describe('OAuthClientSettingsModal', () => { it('should default to Default client type when system_configured is true', () => { render(<OAuthClientSettingsModal {...defaultProps} />) - const defaultCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.default').closest('div') + const defaultCard = screen + .getByText('pluginTrigger.subscription.addType.options.oauth.default') + .closest('div') expect(defaultCard).toHaveClass('border-[1.5px]') }) it('should switch to Custom client type when Custom card is clicked', () => { render(<OAuthClientSettingsModal {...defaultProps} />) - const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div') + const customCard = screen + .getByText('pluginTrigger.subscription.addType.options.oauth.custom') + .closest('div') fireEvent.click(customCard!) expect(customCard).toHaveClass('border-[1.5px]') @@ -261,10 +293,14 @@ describe('OAuthClientSettingsModal', () => { it('should switch back to Default client type when Default card is clicked', () => { render(<OAuthClientSettingsModal {...defaultProps} />) - const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div') + const customCard = screen + .getByText('pluginTrigger.subscription.addType.options.oauth.custom') + .closest('div') fireEvent.click(customCard!) - const defaultCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.default').closest('div') + const defaultCard = screen + .getByText('pluginTrigger.subscription.addType.options.oauth.default') + .closest('div') fireEvent.click(defaultCard!) expect(defaultCard).toHaveClass('border-[1.5px]') @@ -432,10 +468,7 @@ describe('OAuthClientSettingsModal', () => { const removeButton = screen.getByText('common.operation.remove') fireEvent.click(removeButton) - expect(mockDeleteOAuth).toHaveBeenCalledWith( - 'test-provider', - expect.any(Object), - ) + expect(mockDeleteOAuth).toHaveBeenCalledWith('test-provider', expect.any(Object)) }) it('should show success toast when remove succeeds', () => { @@ -591,10 +624,7 @@ describe('OAuthClientSettingsModal', () => { fireEvent.click(getConfirmButton()) // Verify OAuth flow was initiated - expect(mockInitiateOAuth).toHaveBeenCalledWith( - 'test-provider', - expect.any(Object), - ) + expect(mockInitiateOAuth).toHaveBeenCalledWith('test-provider', expect.any(Object)) }) it('should continue polling when verifyBuilder returns an error', async () => { @@ -778,7 +808,9 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} />) // Switch to custom - const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div') + const customCard = screen + .getByText('pluginTrigger.subscription.addType.options.oauth.custom') + .closest('div') fireEvent.click(customCard!) fireEvent.click(getSaveOnlyButton()) @@ -850,9 +882,24 @@ describe('OAuthClientSettingsModal', () => { client_secret: '', // empty value - will not be set as default }, oauth_client_schema: [ - { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown }, - { name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown }, - { name: 'extra_param', type: 'text-input' as unknown, required: false, label: { 'en-US': 'Extra Param' } as unknown }, + { + name: 'client_id', + type: 'text-input' as unknown, + required: true, + label: { 'en-US': 'Client ID' } as unknown, + }, + { + name: 'client_secret', + type: 'secret-input' as unknown, + required: true, + label: { 'en-US': 'Client Secret' } as unknown, + }, + { + name: 'extra_param', + type: 'text-input' as unknown, + required: false, + label: { 'en-US': 'Extra Param' } as unknown, + }, ] as TriggerOAuthConfig['oauth_client_schema'], }) @@ -916,7 +963,9 @@ describe('OAuthClientSettingsModal', () => { redirect_uri: '', }) - render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithEmptyRedirectUri} />) + render( + <OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithEmptyRedirectUri} />, + ) expect(screen.queryByText('pluginTrigger.modal.oauthRedirectInfo')).not.toBeInTheDocument() }) @@ -966,7 +1015,9 @@ describe('OAuthClientSettingsModal', () => { it('should render client type title', () => { render(<OAuthClientSettingsModal {...defaultProps} />) - expect(screen.getByText('pluginTrigger.subscription.addType.options.oauth.clientTitle')).toBeInTheDocument() + expect( + screen.getByText('pluginTrigger.subscription.addType.options.oauth.clientTitle'), + ).toBeInTheDocument() }) }) @@ -980,7 +1031,9 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} />) // Switch to custom type - const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')! + const customCard = screen + .getByText('pluginTrigger.subscription.addType.options.oauth.custom') + .closest('div')! fireEvent.click(customCard) fireEvent.click(getSaveOnlyButton()) @@ -1003,7 +1056,9 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} />) // Switch to custom type - fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!) + fireEvent.click( + screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!, + ) fireEvent.click(getSaveOnlyButton()) @@ -1030,7 +1085,9 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} />) // Switch to custom type - fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!) + fireEvent.click( + screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!, + ) fireEvent.click(getSaveOnlyButton()) @@ -1057,7 +1114,9 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} />) // Switch to custom type - fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!) + fireEvent.click( + screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!, + ) fireEvent.click(getSaveOnlyButton()) @@ -1084,7 +1143,9 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} />) // Switch to custom type - fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!) + fireEvent.click( + screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!, + ) fireEvent.click(getSaveOnlyButton()) @@ -1127,7 +1188,9 @@ describe('OAuthClientSettingsModal', () => { // Button text should show waitingJump after verified await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.oauth.authorization.waitingJump') + expect(getConfirmButton()).toHaveTextContent( + 'pluginTrigger.modal.oauth.authorization.waitingJump', + ) }) vi.useRealTimers() @@ -1177,13 +1240,30 @@ describe('OAuthClientSettingsModal', () => { client_secret: 'test-secret', }, oauth_client_schema: [ - { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown }, - { name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown }, - { name: 'extra_field', type: 'text-input' as unknown, required: false, label: { 'en-US': 'Extra' } as unknown }, + { + name: 'client_id', + type: 'text-input' as unknown, + required: true, + label: { 'en-US': 'Client ID' } as unknown, + }, + { + name: 'client_secret', + type: 'secret-input' as unknown, + required: true, + label: { 'en-US': 'Client Secret' } as unknown, + }, + { + name: 'extra_field', + type: 'text-input' as unknown, + required: false, + label: { 'en-US': 'Extra' } as unknown, + }, ] as TriggerOAuthConfig['oauth_client_schema'], }) - render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithSchemaNotInParams} />) + render( + <OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithSchemaNotInParams} />, + ) // extra_field should be rendered but without default value const extraInput = screen.getByTestId('form-field-extra_field') as HTMLInputElement @@ -1196,7 +1276,12 @@ describe('OAuthClientSettingsModal', () => { custom_enabled: true, params: undefined as unknown as TriggerOAuthConfig['params'], oauth_client_schema: [ - { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown }, + { + name: 'client_id', + type: 'text-input' as unknown, + required: true, + label: { 'en-US': 'Client ID' } as unknown, + }, ] as TriggerOAuthConfig['oauth_client_schema'], }) @@ -1212,7 +1297,12 @@ describe('OAuthClientSettingsModal', () => { custom_enabled: true, params: null as unknown as TriggerOAuthConfig['params'], oauth_client_schema: [ - { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown }, + { + name: 'client_id', + type: 'text-input' as unknown, + required: true, + label: { 'en-US': 'Client ID' } as unknown, + }, ] as TriggerOAuthConfig['oauth_client_schema'], }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx index 4f81b9bb101aaa..deaa542123e0f7 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx @@ -2,20 +2,11 @@ import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' 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 { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { useTranslation } from 'react-i18next' import { EncryptedBottom } from '@/app/components/base/encrypted-bottom' import { SupportedCreationMethods } from '@/app/components/plugins/types' -import { - ConfigurationStepContent, - MultiSteps, - VerifyStepContent, -} from './components/modal-steps' +import { ConfigurationStepContent, MultiSteps, VerifyStepContent } from './components/modal-steps' import { ApiKeyStep, MODAL_TITLE_KEY_MAP, @@ -31,11 +22,7 @@ type Props = Readonly<{ export const CommonCreateModal = ({ open = true, onClose, createType, builder }: Props) => { return ( - <Dialog - open={open} - onOpenChange={nextOpen => !nextOpen && onClose()} - disablePointerDismissal - > + <Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()} disablePointerDismissal> <DialogContent backdropProps={{ forceRender: true }} className={cn( @@ -45,11 +32,7 @@ export const CommonCreateModal = ({ open = true, onClose, createType, builder }: : 'w-[480px] max-w-[calc(100vw-2rem)]', )} > - <CommonCreateModalContent - createType={createType} - builder={builder} - onClose={onClose} - /> + <CommonCreateModalContent createType={createType} builder={builder} onClose={onClose} /> </DialogContent> </Dialog> ) @@ -94,7 +77,7 @@ function CommonCreateModalContent({ onClose, createType, builder }: Omit<Props, > <div className="relative shrink-0 p-6 pr-14 pb-3"> <DialogTitle className="title-2xl-semi-bold text-text-primary" data-testid="modal-title"> - {t($ => $[MODAL_TITLE_KEY_MAP[createType]], { ns: 'pluginTrigger' })} + {t(($) => $[MODAL_TITLE_KEY_MAP[createType]], { ns: 'pluginTrigger' })} </DialogTitle> <DialogCloseButton className="top-5 right-5 size-8 rounded-lg [&>span]:size-5" @@ -133,11 +116,8 @@ function CommonCreateModalContent({ onClose, createType, builder }: Omit<Props, <div className="flex shrink-0 justify-end p-6 pt-5"> <div className="flex items-center"> - <Button - disabled={isDisabled} - onClick={onClose} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button disabled={isDisabled} onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button className="ml-2" diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/__tests__/modal-steps.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/__tests__/modal-steps.spec.tsx index b5e2be71059522..52055687e466b4 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/__tests__/modal-steps.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/__tests__/modal-steps.spec.tsx @@ -6,11 +6,7 @@ import { FormTypeEnum } from '@/app/components/base/form/types' import { SupportedCreationMethods } from '@/app/components/plugins/types' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' import { ApiKeyStep } from '../../hooks/use-common-modal-state' -import { - ConfigurationStepContent, - MultiSteps, - VerifyStepContent, -} from '../modal-steps' +import { ConfigurationStepContent, MultiSteps, VerifyStepContent } from '../modal-steps' const mockBaseForm = vi.fn() vi.mock('@/app/components/base/form/components/base', () => ({ @@ -24,7 +20,7 @@ vi.mock('@/app/components/base/form/components/base', () => ({ mockBaseForm(formSchemas) return ( <div data-testid="base-form"> - {formSchemas.map(schema => ( + {formSchemas.map((schema) => ( <button key={schema.name} data-testid={`field-${schema.name}`} onClick={onChange}> {schema.name} </button> @@ -35,9 +31,11 @@ vi.mock('@/app/components/base/form/components/base', () => ({ })) vi.mock('../../../log-viewer', () => ({ - default: ({ logs }: { logs: Array<{ id: string, message: string }> }) => ( + default: ({ logs }: { logs: Array<{ id: string; message: string }> }) => ( <div data-testid="log-viewer"> - {logs.map(log => <span key={log.id}>{log.message}</span>)} + {logs.map((log) => ( + <span key={log.id}>{log.message}</span> + ))} </div> ), })) @@ -97,7 +95,15 @@ describe('modal-steps', () => { manualPropertiesSchema={[{ name: 'webhook_url', type: FormTypeEnum.textInput }]} manualPropertiesFormRef={formRef} onManualPropertiesChange={onManualPropertiesChange} - logs={[{ id: '1', message: 'log-entry', timestamp: 'now', level: 'info', response: { status_code: 200 } } as never]} + logs={[ + { + id: '1', + message: 'log-entry', + timestamp: 'now', + level: 'info', + response: { status_code: 200 }, + } as never, + ]} pluginId="plugin-id" pluginName="Plugin A" provider="provider-a" diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/modal-steps.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/modal-steps.tsx index e4b396ddfe65af..1c982bceb56276 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/modal-steps.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/modal-steps.tsx @@ -1,6 +1,9 @@ 'use client' import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' -import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import type { + TriggerLogEntity, + TriggerSubscriptionBuilder, +} from '@/app/components/workflow/block-selector/types' import { RiLoader2Line } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -10,9 +13,10 @@ import { SupportedCreationMethods } from '@/app/components/plugins/types' import LogViewer from '../../log-viewer' import { ApiKeyStep } from '../hooks/use-common-modal-state' -export type SchemaItem = Partial<FormSchema> & Record<string, unknown> & { - name: string -} +export type SchemaItem = Partial<FormSchema> & + Record<string, unknown> & { + name: string + } type StatusStepProps = { isActive: boolean @@ -21,13 +25,12 @@ type StatusStepProps = { const StatusStep = ({ isActive, text }: StatusStepProps) => { return ( - <div className={`flex items-center gap-1 system-2xs-semibold-uppercase ${isActive - ? 'text-state-accent-solid' - : 'text-text-tertiary'}`} + <div + className={`flex items-center gap-1 system-2xs-semibold-uppercase ${ + isActive ? 'text-state-accent-solid' : 'text-text-tertiary' + }`} > - {isActive && ( - <div className="size-1 rounded-full bg-state-accent-solid"></div> - )} + {isActive && <div className="size-1 rounded-full bg-state-accent-solid"></div>} {text} </div> ) @@ -41,9 +44,15 @@ export const MultiSteps = ({ currentStep }: MultiStepsProps) => { const { t } = useTranslation() return ( <div className="mb-6 flex w-1/3 items-center gap-2"> - <StatusStep isActive={currentStep === ApiKeyStep.Verify} text={t($ => $['modal.steps.verify'], { ns: 'pluginTrigger' })} /> + <StatusStep + isActive={currentStep === ApiKeyStep.Verify} + text={t(($) => $['modal.steps.verify'], { ns: 'pluginTrigger' })} + /> <div className="h-px w-3 shrink-0 bg-divider-deep"></div> - <StatusStep isActive={currentStep === ApiKeyStep.Configuration} text={t($ => $['modal.steps.configuration'], { ns: 'pluginTrigger' })} /> + <StatusStep + isActive={currentStep === ApiKeyStep.Configuration} + text={t(($) => $['modal.steps.configuration'], { ns: 'pluginTrigger' })} + /> </div> ) } @@ -59,8 +68,7 @@ export const VerifyStepContent = ({ apiKeyCredentialsFormRef, onChange, }: VerifyStepContentProps) => { - if (!apiKeyCredentialsSchema.length) - return null + if (!apiKeyCredentialsSchema.length) return null return ( <div className="mb-4"> @@ -81,32 +89,34 @@ type SubscriptionFormProps = { endpoint?: string } -const SubscriptionForm = ({ - subscriptionFormRef, - endpoint, -}: SubscriptionFormProps) => { +const SubscriptionForm = ({ subscriptionFormRef, endpoint }: SubscriptionFormProps) => { const { t } = useTranslation() - const formSchemas = React.useMemo(() => [ - { - name: 'subscription_name', - label: t($ => $['modal.form.subscriptionName.label'], { ns: 'pluginTrigger' }), - placeholder: t($ => $['modal.form.subscriptionName.placeholder'], { ns: 'pluginTrigger' }), - type: FormTypeEnum.textInput, - required: true, - }, - { - name: 'callback_url', - label: t($ => $['modal.form.callbackUrl.label'], { ns: 'pluginTrigger' }), - placeholder: t($ => $['modal.form.callbackUrl.placeholder'], { ns: 'pluginTrigger' }), - type: FormTypeEnum.textInput, - required: false, - default: endpoint || '', - disabled: true, - tooltip: t($ => $['modal.form.callbackUrl.tooltip'], { ns: 'pluginTrigger' }), - showCopy: true, - }, - ], [endpoint, t]) + const formSchemas = React.useMemo( + () => [ + { + name: 'subscription_name', + label: t(($) => $['modal.form.subscriptionName.label'], { ns: 'pluginTrigger' }), + placeholder: t(($) => $['modal.form.subscriptionName.placeholder'], { + ns: 'pluginTrigger', + }), + type: FormTypeEnum.textInput, + required: true, + }, + { + name: 'callback_url', + label: t(($) => $['modal.form.callbackUrl.label'], { ns: 'pluginTrigger' }), + placeholder: t(($) => $['modal.form.callbackUrl.placeholder'], { ns: 'pluginTrigger' }), + type: FormTypeEnum.textInput, + required: false, + default: endpoint || '', + disabled: true, + tooltip: t(($) => $['modal.form.callbackUrl.tooltip'], { ns: 'pluginTrigger' }), + showCopy: true, + }, + ], + [endpoint, t], + ) return ( <BaseForm @@ -119,8 +129,7 @@ const SubscriptionForm = ({ } const normalizeFormType = (type: FormTypeEnum | string): FormTypeEnum => { - if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) - return type as FormTypeEnum + if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) return type as FormTypeEnum const TYPE_MAP: Record<string, FormTypeEnum> = { string: FormTypeEnum.textInput, @@ -150,29 +159,37 @@ const AutoParametersForm = ({ provider, credentialId, }: AutoParametersFormProps) => { - const formSchemas = React.useMemo(() => - schemas.map((schema) => { - const normalizedType = normalizeFormType((schema.type || FormTypeEnum.textInput) as FormTypeEnum | string) - return { - ...schema, - tooltip: schema.description, - type: normalizedType, - dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect - ? { - plugin_id: pluginId, - provider, - action: 'provider', - parameter: schema.name, - credential_id: credentialId, - } - : undefined, - fieldClassName: normalizedType === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined, - labelClassName: normalizedType === FormTypeEnum.boolean ? 'mb-0' : undefined, - } - }) as FormSchema[], [schemas, pluginId, provider, credentialId]) + const formSchemas = React.useMemo( + () => + schemas.map((schema) => { + const normalizedType = normalizeFormType( + (schema.type || FormTypeEnum.textInput) as FormTypeEnum | string, + ) + return { + ...schema, + tooltip: schema.description, + type: normalizedType, + dynamicSelectParams: + normalizedType === FormTypeEnum.dynamicSelect + ? { + plugin_id: pluginId, + provider, + action: 'provider', + parameter: schema.name, + credential_id: credentialId, + } + : undefined, + fieldClassName: + normalizedType === FormTypeEnum.boolean + ? 'flex items-center justify-between' + : undefined, + labelClassName: normalizedType === FormTypeEnum.boolean ? 'mb-0' : undefined, + } + }) as FormSchema[], + [schemas, pluginId, provider, credentialId], + ) - if (!schemas.length) - return null + if (!schemas.length) return null return ( <BaseForm @@ -201,11 +218,14 @@ const ManualPropertiesSection = ({ }: ManualPropertiesSectionProps) => { const { t } = useTranslation() - const formSchemas = React.useMemo(() => - schemas.map(schema => ({ - ...schema, - tooltip: schema.description, - })) as FormSchema[], [schemas]) + const formSchemas = React.useMemo( + () => + schemas.map((schema) => ({ + ...schema, + tooltip: schema.description, + })) as FormSchema[], + [schemas], + ) return ( <> @@ -223,7 +243,7 @@ const ManualPropertiesSection = ({ <div className="mb-6"> <div className="mb-3 flex items-center gap-2"> <div className="system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['modal.manual.logs.title'], { ns: 'pluginTrigger' })} + {t(($) => $['modal.manual.logs.title'], { ns: 'pluginTrigger' })} </div> <div className="h-px flex-1 bg-linear-to-r from-divider-regular to-transparent" /> </div> @@ -233,7 +253,7 @@ const ManualPropertiesSection = ({ <RiLoader2Line className="size-full animate-spin" /> </div> <div className="system-xs-regular text-text-tertiary"> - {t($ => $['modal.manual.logs.loading'], { ns: 'pluginTrigger', pluginName })} + {t(($) => $['modal.manual.logs.loading'], { ns: 'pluginTrigger', pluginName })} </div> </div> <LogViewer logs={logs} /> diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts index 5881b66b0c8eab..d7cfb5e5da94bc 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts @@ -20,10 +20,7 @@ type BuilderResponse = { subscription_builder: TriggerSubscriptionBuilder } -const { - mockToastError, - mockIsPrivateOrLocalAddress, -} = vi.hoisted(() => ({ +const { mockToastError, mockIsPrivateOrLocalAddress } = vi.hoisted(() => ({ mockToastError: vi.fn(), mockIsPrivateOrLocalAddress: vi.fn(), })) @@ -49,18 +46,22 @@ describe('use-common-modal-state helpers', () => { }) it('returns form values from the form ref when available', () => { - expect(getFormValues({ - current: { - getFormValues: () => ({ values: { subscription_name: 'Sub' }, isCheckValidated: true }), - }, - } as unknown as React.RefObject<FormRefObject | null>)).toEqual({ + expect( + getFormValues({ + current: { + getFormValues: () => ({ values: { subscription_name: 'Sub' }, isCheckValidated: true }), + }, + } as unknown as React.RefObject<FormRefObject | null>), + ).toEqual({ values: { subscription_name: 'Sub' }, isCheckValidated: true, }) }) it('derives the first field name from values or schema fallback', () => { - expect(getFirstFieldName({ callback_url: 'https://example.com' }, [{ name: 'fallback' }])).toBe('callback_url') + expect(getFirstFieldName({ callback_url: 'https://example.com' }, [{ name: 'fallback' }])).toBe( + 'callback_url', + ) expect(getFirstFieldName({}, [{ name: 'fallback' }])).toBe('fallback') expect(getFirstFieldName({}, [])).toBe('') }) @@ -76,32 +77,39 @@ describe('use-common-modal-state helpers', () => { }) it('builds subscription payloads for automatic and manual creation', () => { - expect(buildSubscriptionPayload({ - provider: 'provider-a', - subscriptionBuilderId: 'builder-a', - createType: SupportedCreationMethods.APIKEY, - subscriptionFormValues: { values: { subscription_name: 'My Sub' }, isCheckValidated: true }, - autoCommonParametersSchemaLength: 1, - autoCommonParametersFormValues: { values: { api_key: '123' }, isCheckValidated: true }, - manualPropertiesSchemaLength: 0, - manualPropertiesFormValues: undefined, - })).toEqual({ + expect( + buildSubscriptionPayload({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + createType: SupportedCreationMethods.APIKEY, + subscriptionFormValues: { values: { subscription_name: 'My Sub' }, isCheckValidated: true }, + autoCommonParametersSchemaLength: 1, + autoCommonParametersFormValues: { values: { api_key: '123' }, isCheckValidated: true }, + manualPropertiesSchemaLength: 0, + manualPropertiesFormValues: undefined, + }), + ).toEqual({ provider: 'provider-a', subscriptionBuilderId: 'builder-a', name: 'My Sub', parameters: { api_key: '123' }, }) - expect(buildSubscriptionPayload({ - provider: 'provider-a', - subscriptionBuilderId: 'builder-a', - createType: SupportedCreationMethods.MANUAL, - subscriptionFormValues: { values: { subscription_name: 'Manual Sub' }, isCheckValidated: true }, - autoCommonParametersSchemaLength: 0, - autoCommonParametersFormValues: undefined, - manualPropertiesSchemaLength: 1, - manualPropertiesFormValues: { values: { custom: 'value' }, isCheckValidated: true }, - })).toEqual({ + expect( + buildSubscriptionPayload({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + createType: SupportedCreationMethods.MANUAL, + subscriptionFormValues: { + values: { subscription_name: 'Manual Sub' }, + isCheckValidated: true, + }, + autoCommonParametersSchemaLength: 0, + autoCommonParametersFormValues: undefined, + manualPropertiesSchemaLength: 1, + manualPropertiesFormValues: { values: { custom: 'value' }, isCheckValidated: true }, + }), + ).toEqual({ provider: 'provider-a', subscriptionBuilderId: 'builder-a', name: 'Manual Sub', @@ -109,34 +117,43 @@ describe('use-common-modal-state helpers', () => { }) it('returns null when required validation is missing', () => { - expect(buildSubscriptionPayload({ - provider: 'provider-a', - subscriptionBuilderId: 'builder-a', - createType: SupportedCreationMethods.APIKEY, - subscriptionFormValues: { values: {}, isCheckValidated: false }, - autoCommonParametersSchemaLength: 1, - autoCommonParametersFormValues: { values: {}, isCheckValidated: true }, - manualPropertiesSchemaLength: 0, - manualPropertiesFormValues: undefined, - })).toBeNull() + expect( + buildSubscriptionPayload({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + createType: SupportedCreationMethods.APIKEY, + subscriptionFormValues: { values: {}, isCheckValidated: false }, + autoCommonParametersSchemaLength: 1, + autoCommonParametersFormValues: { values: {}, isCheckValidated: true }, + manualPropertiesSchemaLength: 0, + manualPropertiesFormValues: undefined, + }), + ).toBeNull() }) it('builds confirm button text for verify and create states', () => { - const t = withSelectorKey((key: string, options?: Record<string, unknown>) => `${options?.ns}.${key}`, 'pluginTrigger') - - expect(getConfirmButtonText({ - isVerifyStep: true, - isVerifyingCredentials: false, - isBuilding: false, - t, - })).toBe('pluginTrigger.modal.common.verify') - - expect(getConfirmButtonText({ - isVerifyStep: false, - isVerifyingCredentials: false, - isBuilding: true, - t, - })).toBe('pluginTrigger.modal.common.creating') + const t = withSelectorKey( + (key: string, options?: Record<string, unknown>) => `${options?.ns}.${key}`, + 'pluginTrigger', + ) + + expect( + getConfirmButtonText({ + isVerifyStep: true, + isVerifyingCredentials: false, + isBuilding: false, + t, + }), + ).toBe('pluginTrigger.modal.common.verify') + + expect( + getConfirmButtonText({ + isVerifyStep: false, + isVerifyingCredentials: false, + isBuilding: true, + t, + }), + ).toBe('pluginTrigger.modal.common.creating') }) it('initializes the subscription builder once when provider is available', async () => { @@ -148,14 +165,19 @@ describe('use-common-modal-state helpers', () => { }) => Promise<BuilderResponse> const setSubscriptionBuilder = vi.fn() - renderHook(() => useInitializeSubscriptionBuilder({ - createBuilder, - credentialType: 'oauth', - provider: 'provider-a', - subscriptionBuilder: undefined, - setSubscriptionBuilder, - t: withSelectorKey((key: string, options?: Record<string, unknown>) => `${options?.ns}.${key}`, 'pluginTrigger'), - })) + renderHook(() => + useInitializeSubscriptionBuilder({ + createBuilder, + credentialType: 'oauth', + provider: 'provider-a', + subscriptionBuilder: undefined, + setSubscriptionBuilder, + t: withSelectorKey( + (key: string, options?: Record<string, unknown>) => `${options?.ns}.${key}`, + 'pluginTrigger', + ), + }), + ) await waitFor(() => { expect(createBuilder).toHaveBeenCalledWith({ @@ -179,19 +201,26 @@ describe('use-common-modal-state helpers', () => { }, } as unknown as RefObject<FormRefObject | null> - renderHook(() => useSyncSubscriptionEndpoint({ - endpoint: 'http://127.0.0.1/callback', - isConfigurationStep: true, - subscriptionFormRef, - t: withSelectorKey((key: string, options?: Record<string, unknown>) => `${options?.ns}.${key}`, 'pluginTrigger'), - })) + renderHook(() => + useSyncSubscriptionEndpoint({ + endpoint: 'http://127.0.0.1/callback', + isConfigurationStep: true, + subscriptionFormRef, + t: withSelectorKey( + (key: string, options?: Record<string, unknown>) => `${options?.ns}.${key}`, + 'pluginTrigger', + ), + }), + ) await waitFor(() => { expect(setFieldValue).toHaveBeenCalledWith('callback_url', 'http://127.0.0.1/callback') - expect(setFields).toHaveBeenCalledWith([{ - name: 'callback_url', - warnings: ['pluginTrigger.modal.form.callbackUrl.privateAddressWarning'], - }]) + expect(setFields).toHaveBeenCalledWith([ + { + name: 'callback_url', + warnings: ['pluginTrigger.modal.form.callbackUrl.privateAddressWarning'], + }, + ]) }) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts index 26e4a69baa706e..7cbeb8ff0f0bf7 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts @@ -12,16 +12,18 @@ type MockPluginDetail = { name: string declaration: { trigger: { - subscription_schema: Array<{ name: string, type: string, description?: string }> + subscription_schema: Array<{ name: string; type: string; description?: string }> subscription_constructor: { - credentials_schema: Array<{ name: string, type: string, help?: string }> - parameters: Array<{ name: string, type: string }> + credentials_schema: Array<{ name: string; type: string; help?: string }> + parameters: Array<{ name: string; type: string }> } } } } -const createMockBuilder = (overrides: Partial<TriggerSubscriptionBuilder> = {}): TriggerSubscriptionBuilder => ({ +const createMockBuilder = ( + overrides: Partial<TriggerSubscriptionBuilder> = {}, +): TriggerSubscriptionBuilder => ({ id: 'builder-1', name: 'builder', provider: 'provider-a', @@ -69,14 +71,18 @@ let mockIsBuilding = false vi.mock('@/service/use-triggers', () => ({ useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({ mutate: mockVerifyCredentials, - get isPending() { return mockIsVerifyingCredentials }, + get isPending() { + return mockIsVerifyingCredentials + }, }), useCreateTriggerSubscriptionBuilder: () => ({ mutateAsync: mockCreateBuilder, }), useBuildTriggerSubscription: () => ({ mutate: mockBuildSubscription, - get isPending() { return mockIsBuilding }, + get isPending() { + return mockIsBuilding + }, }), useUpdateTriggerSubscriptionBuilder: () => ({ mutate: mockUpdateBuilder, @@ -109,13 +115,14 @@ const createFormRef = ({ }: { values?: Record<string, unknown> isCheckValidated?: boolean -} = {}): FormRefObject => ({ - getFormValues: vi.fn().mockReturnValue({ values, isCheckValidated }), - setFields: vi.fn(), - getForm: vi.fn().mockReturnValue({ - setFieldValue: vi.fn(), - }), -} as unknown as FormRefObject) +} = {}): FormRefObject => + ({ + getFormValues: vi.fn().mockReturnValue({ values, isCheckValidated }), + setFields: vi.fn(), + getForm: vi.fn().mockReturnValue({ + setFieldValue: vi.fn(), + }), + }) as unknown as FormRefObject describe('useCommonModalState', () => { beforeEach(() => { @@ -128,10 +135,12 @@ describe('useCommonModalState', () => { }) it('should initialize api key builders and expose verify step state', async () => { - const { result } = renderHook(() => useCommonModalState({ - createType: SupportedCreationMethods.APIKEY, - onClose: vi.fn(), - })) + const { result } = renderHook(() => + useCommonModalState({ + createType: SupportedCreationMethods.APIKEY, + onClose: vi.fn(), + }), + ) await waitFor(() => { expect(result.current.subscriptionBuilder?.id).toBe('builder-1') @@ -154,13 +163,17 @@ describe('useCommonModalState', () => { }) const builder = createMockBuilder() - const { result } = renderHook(() => useCommonModalState({ - createType: SupportedCreationMethods.APIKEY, - builder, - onClose: vi.fn(), - })) + const { result } = renderHook(() => + useCommonModalState({ + createType: SupportedCreationMethods.APIKEY, + builder, + onClose: vi.fn(), + }), + ) - const credentialsFormRef = result.current.formRefs.apiKeyCredentialsFormRef as { current: FormRefObject | null } + const credentialsFormRef = result.current.formRefs.apiKeyCredentialsFormRef as { + current: FormRefObject | null + } credentialsFormRef.current = createFormRef({ values: { api_key: 'secret' }, }) @@ -169,31 +182,42 @@ describe('useCommonModalState', () => { result.current.handleVerify() }) - expect(mockVerifyCredentials).toHaveBeenCalledWith({ - provider: 'provider-a', - subscriptionBuilderId: builder.id, - credentials: { api_key: 'secret' }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - })) + expect(mockVerifyCredentials).toHaveBeenCalledWith( + { + provider: 'provider-a', + subscriptionBuilderId: builder.id, + credentials: { api_key: 'secret' }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) expect(result.current.currentStep).toBe(ApiKeyStep.Configuration) - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + }), + ) }) it('should build subscriptions with validated automatic parameters', () => { const onClose = vi.fn() const builder = createMockBuilder() - const { result } = renderHook(() => useCommonModalState({ - createType: SupportedCreationMethods.APIKEY, - builder, - onClose, - })) + const { result } = renderHook(() => + useCommonModalState({ + createType: SupportedCreationMethods.APIKEY, + builder, + onClose, + }), + ) - const subscriptionFormRef = result.current.formRefs.subscriptionFormRef as { current: FormRefObject | null } - const autoParamsFormRef = result.current.formRefs.autoCommonParametersFormRef as { current: FormRefObject | null } + const subscriptionFormRef = result.current.formRefs.subscriptionFormRef as { + current: FormRefObject | null + } + const autoParamsFormRef = result.current.formRefs.autoCommonParametersFormRef as { + current: FormRefObject | null + } subscriptionFormRef.current = createFormRef({ values: { subscription_name: 'Subscription A' }, @@ -206,15 +230,18 @@ describe('useCommonModalState', () => { result.current.handleCreate() }) - expect(mockBuildSubscription).toHaveBeenCalledWith({ - provider: 'provider-a', - subscriptionBuilderId: builder.id, - name: 'Subscription A', - parameters: { repo_name: 'repo-a' }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - })) + expect(mockBuildSubscription).toHaveBeenCalledWith( + { + provider: 'provider-a', + subscriptionBuilderId: builder.id, + name: 'Subscription A', + parameters: { repo_name: 'repo-a' }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) }) it('should debounce manual property updates', async () => { @@ -223,13 +250,17 @@ describe('useCommonModalState', () => { const builder = createMockBuilder({ credential_type: TriggerCredentialTypeEnum.Unauthorized, }) - const { result } = renderHook(() => useCommonModalState({ - createType: SupportedCreationMethods.MANUAL, - builder, - onClose: vi.fn(), - })) + const { result } = renderHook(() => + useCommonModalState({ + createType: SupportedCreationMethods.MANUAL, + builder, + onClose: vi.fn(), + }), + ) - const manualFormRef = result.current.formRefs.manualPropertiesFormRef as { current: FormRefObject | null } + const manualFormRef = result.current.formRefs.manualPropertiesFormRef as { + current: FormRefObject | null + } manualFormRef.current = createFormRef({ values: { webhook_url: 'https://hook.example.com' }, isCheckValidated: true, @@ -240,13 +271,16 @@ describe('useCommonModalState', () => { vi.advanceTimersByTime(500) }) - expect(mockUpdateBuilder).toHaveBeenCalledWith({ - provider: 'provider-a', - subscriptionBuilderId: builder.id, - properties: { webhook_url: 'https://hook.example.com' }, - }, expect.objectContaining({ - onError: expect.any(Function), - })) + expect(mockUpdateBuilder).toHaveBeenCalledWith( + { + provider: 'provider-a', + subscriptionBuilderId: builder.id, + properties: { webhook_url: 'https://hook.example.com' }, + }, + expect.objectContaining({ + onError: expect.any(Function), + }), + ) vi.useRealTimers() }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts index 4c88b1207cc570..265aa1be09aedb 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts @@ -1,4 +1,7 @@ -import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import type { + TriggerOAuthConfig, + TriggerSubscriptionBuilder, +} from '@/app/components/workflow/block-selector/types' import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' @@ -25,14 +28,26 @@ function createMockOAuthConfig(overrides: Partial<TriggerOAuthConfig> = {}): Tri client_secret: 'default-client-secret', }, oauth_client_schema: [ - { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown }, - { name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown }, + { + name: 'client_id', + type: 'text-input' as unknown, + required: true, + label: { 'en-US': 'Client ID' } as unknown, + }, + { + name: 'client_secret', + type: 'secret-input' as unknown, + required: true, + label: { 'en-US': 'Client Secret' } as unknown, + }, ] as TriggerOAuthConfig['oauth_client_schema'], ...overrides, } } -function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBuilder> = {}): TriggerSubscriptionBuilder { +function createMockSubscriptionBuilder( + overrides: Partial<TriggerSubscriptionBuilder> = {}, +): TriggerSubscriptionBuilder { return { id: 'builder-123', name: 'Test Builder', @@ -73,13 +88,15 @@ vi.mock('@/service/use-triggers', () => ({ const mockOpenOAuthPopup = vi.fn() vi.mock('@/hooks/use-oauth', () => ({ - openOAuthPopup: (url: string, callback: (data: unknown) => void) => mockOpenOAuthPopup(url, callback), + openOAuthPopup: (url: string, callback: (data: unknown) => void) => + mockOpenOAuthPopup(url, callback), })) const mockToastNotify = vi.fn() vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( - (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), + (message: string, options?: { type?: string }) => + mockToastNotify({ type: options?.type, message }), { success: (message: string) => mockToastNotify({ type: 'success', message }), error: (message: string) => mockToastNotify({ type: 'error', message }), @@ -158,10 +175,12 @@ describe('useOAuthClientState', () => { it('should default to Custom client type when system_configured is false', () => { const config = createMockOAuthConfig({ system_configured: false }) - const { result } = renderHook(() => useOAuthClientState({ - ...defaultParams, - oauthConfig: config, - })) + const { result } = renderHook(() => + useOAuthClientState({ + ...defaultParams, + oauthConfig: config, + }), + ) expect(result.current.clientType).toBe(ClientTypeEnum.Custom) }) @@ -188,10 +207,12 @@ describe('useOAuthClientState', () => { client_secret: 'my-secret', }, }) - const { result } = renderHook(() => useOAuthClientState({ - ...defaultParams, - oauthConfig: config, - })) + const { result } = renderHook(() => + useOAuthClientState({ + ...defaultParams, + oauthConfig: config, + }), + ) expect(result.current.oauthClientSchema).toHaveLength(2) expect(result.current.oauthClientSchema[0]!.default).toBe('my-client-id') @@ -202,10 +223,12 @@ describe('useOAuthClientState', () => { const config = createMockOAuthConfig({ oauth_client_schema: [], }) - const { result } = renderHook(() => useOAuthClientState({ - ...defaultParams, - oauthConfig: config, - })) + const { result } = renderHook(() => + useOAuthClientState({ + ...defaultParams, + oauthConfig: config, + }), + ) expect(result.current.oauthClientSchema).toEqual([]) }) @@ -214,10 +237,12 @@ describe('useOAuthClientState', () => { const config = createMockOAuthConfig({ params: undefined as unknown as TriggerOAuthConfig['params'], }) - const { result } = renderHook(() => useOAuthClientState({ - ...defaultParams, - oauthConfig: config, - })) + const { result } = renderHook(() => + useOAuthClientState({ + ...defaultParams, + oauthConfig: config, + }), + ) expect(result.current.oauthClientSchema).toEqual([]) }) @@ -229,14 +254,28 @@ describe('useOAuthClientState', () => { client_secret: '', // empty }, oauth_client_schema: [ - { name: 'client_id', type: 'text-input' as unknown, required: true, label: {} as unknown, default: 'original-default' }, - { name: 'extra_field', type: 'text-input' as unknown, required: false, label: {} as unknown, default: 'extra-default' }, + { + name: 'client_id', + type: 'text-input' as unknown, + required: true, + label: {} as unknown, + default: 'original-default', + }, + { + name: 'extra_field', + type: 'text-input' as unknown, + required: false, + label: {} as unknown, + default: 'extra-default', + }, ] as TriggerOAuthConfig['oauth_client_schema'], }) - const { result } = renderHook(() => useOAuthClientState({ - ...defaultParams, - oauthConfig: config, - })) + const { result } = renderHook(() => + useOAuthClientState({ + ...defaultParams, + oauthConfig: config, + }), + ) // client_id should be overridden expect(result.current.oauthClientSchema[0]!.default).toBe('only-client-id') @@ -304,20 +343,19 @@ describe('useOAuthClientState', () => { result.current.handleRemove() }) - expect(mockDeleteOAuth).toHaveBeenCalledWith( - 'test-provider', - expect.any(Object), - ) + expect(mockDeleteOAuth).toHaveBeenCalledWith('test-provider', expect.any(Object)) }) it('should call onOpenChange and show success toast on success', () => { mockDeleteOAuth.mockImplementation((provider, { onSuccess }) => onSuccess()) const onOpenChange = vi.fn() - const { result } = renderHook(() => useOAuthClientState({ - ...defaultParams, - onOpenChange, - })) + const { result } = renderHook(() => + useOAuthClientState({ + ...defaultParams, + onOpenChange, + }), + ) act(() => { result.current.handleRemove() @@ -371,10 +409,12 @@ describe('useOAuthClientState', () => { mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) const config = createMockOAuthConfig({ system_configured: false }) - const { result } = renderHook(() => useOAuthClientState({ - ...defaultParams, - oauthConfig: config, - })) + const { result } = renderHook(() => + useOAuthClientState({ + ...defaultParams, + oauthConfig: config, + }), + ) // Mock the form ref const mockFormRef = { @@ -402,10 +442,12 @@ describe('useOAuthClientState', () => { mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) const onOpenChange = vi.fn() - const { result } = renderHook(() => useOAuthClientState({ - ...defaultParams, - onOpenChange, - })) + const { result } = renderHook(() => + useOAuthClientState({ + ...defaultParams, + onOpenChange, + }), + ) act(() => { result.current.handleSave(false) @@ -433,10 +475,7 @@ describe('useOAuthClientState', () => { result.current.handleSave(true) }) - expect(mockInitiateOAuth).toHaveBeenCalledWith( - 'test-provider', - expect.any(Object), - ) + expect(mockInitiateOAuth).toHaveBeenCalledWith('test-provider', expect.any(Object)) }) }) @@ -511,11 +550,13 @@ describe('useOAuthClientState', () => { callback({ success: true }) }) - const { result } = renderHook(() => useOAuthClientState({ - ...defaultParams, - onOpenChange, - showOAuthCreateModal, - })) + const { result } = renderHook(() => + useOAuthClientState({ + ...defaultParams, + onOpenChange, + showOAuthCreateModal, + }), + ) act(() => { result.current.handleSave(true) @@ -544,11 +585,13 @@ describe('useOAuthClientState', () => { callback(null) }) - const { result } = renderHook(() => useOAuthClientState({ - ...defaultParams, - onOpenChange, - showOAuthCreateModal, - })) + const { result } = renderHook(() => + useOAuthClientState({ + ...defaultParams, + onOpenChange, + showOAuthCreateModal, + }), + ) act(() => { result.current.handleSave(true) @@ -693,20 +736,24 @@ describe('useOAuthClientState', () => { describe('Edge Cases', () => { it('should handle undefined oauthConfig', () => { - const { result } = renderHook(() => useOAuthClientState({ - ...defaultParams, - oauthConfig: undefined, - })) + const { result } = renderHook(() => + useOAuthClientState({ + ...defaultParams, + oauthConfig: undefined, + }), + ) expect(result.current.clientType).toBe(ClientTypeEnum.Custom) expect(result.current.oauthClientSchema).toEqual([]) }) it('should handle empty providerName', () => { - const { result } = renderHook(() => useOAuthClientState({ - ...defaultParams, - providerName: '', - })) + const { result } = renderHook(() => + useOAuthClientState({ + ...defaultParams, + providerName: '', + }), + ) // Should not throw expect(result.current.clientType).toBe(ClientTypeEnum.Default) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts index d4dbe79e24b958..ba4c9671e86ccd 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts @@ -62,8 +62,10 @@ export const getFirstFieldName = ( return Object.keys(values)[0] || fallbackSchema[0]?.name || '' } -export const toSchemaWithTooltip = <T extends { help?: unknown, name: string }>(schemas: T[] = []) => { - return schemas.map(schema => ({ +export const toSchemaWithTooltip = <T extends { help?: unknown; name: string }>( + schemas: T[] = [], +) => { + return schemas.map((schema) => ({ ...schema, tooltip: schema.help, })) @@ -79,8 +81,7 @@ export const buildSubscriptionPayload = ({ manualPropertiesSchemaLength, manualPropertiesFormValues, }: BuildPayloadParams): BuildTriggerSubscriptionPayload | null => { - if (!subscriptionFormValues?.isCheckValidated) - return null + if (!subscriptionFormValues?.isCheckValidated) return null const subscriptionNameValue = subscriptionFormValues.values.subscription_name as string @@ -91,18 +92,15 @@ export const buildSubscriptionPayload = ({ } if (createType !== SupportedCreationMethods.MANUAL) { - if (!autoCommonParametersSchemaLength) - return params + if (!autoCommonParametersSchemaLength) return params - if (!autoCommonParametersFormValues?.isCheckValidated) - return null + if (!autoCommonParametersFormValues?.isCheckValidated) return null params.parameters = autoCommonParametersFormValues.values return params } - if (manualPropertiesSchemaLength && !manualPropertiesFormValues?.isCheckValidated) - return null + if (manualPropertiesSchemaLength && !manualPropertiesFormValues?.isCheckValidated) return null return params } @@ -120,13 +118,13 @@ export const getConfirmButtonText = ({ }) => { if (isVerifyStep) { return isVerifyingCredentials - ? t($ => $['modal.common.verifying'], { ns: 'pluginTrigger' }) - : t($ => $['modal.common.verify'], { ns: 'pluginTrigger' }) + ? t(($) => $['modal.common.verifying'], { ns: 'pluginTrigger' }) + : t(($) => $['modal.common.verify'], { ns: 'pluginTrigger' }) } return isBuilding - ? t($ => $['modal.common.creating'], { ns: 'pluginTrigger' }) - : t($ => $['modal.common.create'], { ns: 'pluginTrigger' }) + ? t(($) => $['modal.common.creating'], { ns: 'pluginTrigger' }) + : t(($) => $['modal.common.create'], { ns: 'pluginTrigger' }) } export const useInitializeSubscriptionBuilder = ({ @@ -148,15 +146,13 @@ export const useInitializeSubscriptionBuilder = ({ credential_type: credentialType, }) setSubscriptionBuilder(response.subscription_builder) - } - catch (error) { + } catch (error) { console.error('createBuilder error:', error) - toast.error(t($ => $['modal.errors.createFailed'], { ns: 'pluginTrigger' })) + toast.error(t(($) => $['modal.errors.createFailed'], { ns: 'pluginTrigger' })) } } - if (!isInitializedRef.current && !subscriptionBuilder && provider) - initializeBuilder() + if (!isInitializedRef.current && !subscriptionBuilder && provider) initializeBuilder() }, [subscriptionBuilder, provider, credentialType, createBuilder, setSubscriptionBuilder, t]) } @@ -167,20 +163,20 @@ export const useSyncSubscriptionEndpoint = ({ t, }: SyncEndpointParams) => { useEffect(() => { - if (!endpoint || !subscriptionFormRef.current || !isConfigurationStep) - return + if (!endpoint || !subscriptionFormRef.current || !isConfigurationStep) return const form = subscriptionFormRef.current.getForm() - if (form) - form.setFieldValue('callback_url', endpoint) + if (form) form.setFieldValue('callback_url', endpoint) const warnings = isPrivateOrLocalAddress(endpoint) - ? [t($ => $['modal.form.callbackUrl.privateAddressWarning'], { ns: 'pluginTrigger' })] + ? [t(($) => $['modal.form.callbackUrl.privateAddressWarning'], { ns: 'pluginTrigger' })] : [] - subscriptionFormRef.current.setFields([{ - name: 'callback_url', - warnings, - }]) + subscriptionFormRef.current.setFields([ + { + name: 'callback_url', + warnings, + }, + ]) }, [endpoint, isConfigurationStep, subscriptionFormRef, t]) } diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts index 03ac1ce0daf393..95815ec82a1bcf 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts @@ -3,7 +3,10 @@ import type { SimpleDetail } from '../../../store' import type { SchemaItem } from '../components/modal-steps' import type { PluginTriggerTranslate } from './use-common-modal-state.helpers' import type { FormRefObject } from '@/app/components/base/form/types' -import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import type { + TriggerLogEntity, + TriggerSubscriptionBuilder, +} from '@/app/components/workflow/block-selector/types' import { toast } from '@langgenius/dify-ui/toast' import { debounce } from 'es-toolkit/compat' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -107,14 +110,16 @@ export const useCommonModalState = ({ (selector, options) => t(selector, options), [t], ) - const detail = usePluginStore(state => state.detail) + const detail = usePluginStore((state) => state.detail) const { refetch } = useSubscriptionList() // State const [currentStep, setCurrentStep] = useState<ApiKeyStep>( createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration, ) - const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder) + const [subscriptionBuilder, setSubscriptionBuilder] = useState< + TriggerSubscriptionBuilder | undefined + >(builder) // Form refs const manualPropertiesFormRef = useRef<FormRefObject>(null) @@ -123,17 +128,20 @@ export const useCommonModalState = ({ const apiKeyCredentialsFormRef = useRef<FormRefObject>(null) // Mutations - const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyAndUpdateTriggerSubscriptionBuilder() + const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = + useVerifyAndUpdateTriggerSubscriptionBuilder() const { mutateAsync: createBuilder } = useCreateTriggerSubscriptionBuilder() const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription() const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder() // Schemas const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || [] - const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || [] + const autoCommonParametersSchema = + detail?.declaration.trigger?.subscription_constructor?.parameters || [] const apiKeyCredentialsSchema = useMemo<SchemaItem[]>(() => { - const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || [] + const rawSchema = + detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || [] return toSchemaWithTooltip(rawSchema) as SchemaItem[] }, [detail?.declaration?.trigger?.subscription_constructor?.credentials_schema]) @@ -149,22 +157,25 @@ export const useCommonModalState = ({ // Debounced update for manual properties const debouncedUpdate = useMemo( - () => debounce((provider: string, builderId: string, properties: Record<string, unknown>) => { - updateBuilder( - { - provider, - subscriptionBuilderId: builderId, - properties, - }, - { - onError: async (error: unknown) => { - const errorMessage = await parsePluginErrorMessage(error) || t($ => $['modal.errors.updateFailed'], { ns: 'pluginTrigger' }) - console.error('Failed to update subscription builder:', error) - toast.error(errorMessage) + () => + debounce((provider: string, builderId: string, properties: Record<string, unknown>) => { + updateBuilder( + { + provider, + subscriptionBuilderId: builderId, + properties, }, - }, - ) - }, 500), + { + onError: async (error: unknown) => { + const errorMessage = + (await parsePluginErrorMessage(error)) || + t(($) => $['modal.errors.updateFailed'], { ns: 'pluginTrigger' }) + console.error('Failed to update subscription builder:', error) + toast.error(errorMessage) + }, + }, + ) + }, 500), [updateBuilder, t], ) @@ -193,23 +204,24 @@ export const useCommonModalState = ({ // Handle manual properties change const handleManualPropertiesChange = useCallback(() => { - if (!subscriptionBuilder || !detail?.provider) - return + if (!subscriptionBuilder || !detail?.provider) return - const formValues = manualPropertiesFormRef.current?.getFormValues({ needCheckValidatedValues: false }) - || { values: {}, isCheckValidated: true } + const formValues = manualPropertiesFormRef.current?.getFormValues({ + needCheckValidatedValues: false, + }) || { values: {}, isCheckValidated: true } debouncedUpdate(detail.provider, subscriptionBuilder.id, formValues.values) }, [subscriptionBuilder, detail?.provider, debouncedUpdate]) // Handle API key credentials change const handleApiKeyCredentialsChange = useCallback(() => { - if (!apiKeyCredentialsSchema.length) - return - apiKeyCredentialsFormRef.current?.setFields([{ - name: apiKeyCredentialsSchema[0]!.name, - errors: [], - }]) + if (!apiKeyCredentialsSchema.length) return + apiKeyCredentialsFormRef.current?.setFields([ + { + name: apiKeyCredentialsSchema[0]!.name, + errors: [], + }, + ]) }, [apiKeyCredentialsSchema]) // Handle verify @@ -230,10 +242,12 @@ export const useCommonModalState = ({ const credentialFieldName = getFirstFieldName(credentials, apiKeyCredentialsSchema) - apiKeyCredentialsFormRef.current?.setFields([{ - name: credentialFieldName, - errors: [], - }]) + apiKeyCredentialsFormRef.current?.setFields([ + { + name: credentialFieldName, + errors: [], + }, + ]) verifyCredentials( { @@ -243,15 +257,19 @@ export const useCommonModalState = ({ }, { onSuccess: () => { - toast.success(t($ => $['modal.apiKey.verify.success'], { ns: 'pluginTrigger' })) + toast.success(t(($) => $['modal.apiKey.verify.success'], { ns: 'pluginTrigger' })) setCurrentStep(ApiKeyStep.Configuration) }, onError: async (error: unknown) => { - const errorMessage = await parsePluginErrorMessage(error) || t($ => $['modal.apiKey.verify.error'], { ns: 'pluginTrigger' }) - apiKeyCredentialsFormRef.current?.setFields([{ - name: credentialFieldName, - errors: [errorMessage], - }]) + const errorMessage = + (await parsePluginErrorMessage(error)) || + t(($) => $['modal.apiKey.verify.error'], { ns: 'pluginTrigger' }) + apiKeyCredentialsFormRef.current?.setFields([ + { + name: credentialFieldName, + errors: [errorMessage], + }, + ]) }, }, ) @@ -275,23 +293,21 @@ export const useCommonModalState = ({ manualPropertiesFormValues: getFormValues(manualPropertiesFormRef), }) - if (!params) - return + if (!params) return - buildSubscription( - params, - { - onSuccess: () => { - toast.success(t($ => $['subscription.createSuccess'], { ns: 'pluginTrigger' })) - onClose() - refetch?.() - }, - onError: async (error: unknown) => { - const errorMessage = await parsePluginErrorMessage(error) || t($ => $['subscription.createFailed'], { ns: 'pluginTrigger' }) - toast.error(errorMessage) - }, + buildSubscription(params, { + onSuccess: () => { + toast.success(t(($) => $['subscription.createSuccess'], { ns: 'pluginTrigger' })) + onClose() + refetch?.() }, - ) + onError: async (error: unknown) => { + const errorMessage = + (await parsePluginErrorMessage(error)) || + t(($) => $['subscription.createFailed'], { ns: 'pluginTrigger' }) + toast.error(errorMessage) + }, + }) }, [ subscriptionBuilder, detail?.provider, @@ -306,10 +322,8 @@ export const useCommonModalState = ({ // Handle confirm (dispatch based on step) const handleConfirm = useCallback(() => { - if (currentStep === ApiKeyStep.Verify) - handleVerify() - else - handleCreate() + if (currentStep === ApiKeyStep.Verify) handleVerify() + else handleCreate() }, [currentStep, handleVerify, handleCreate]) // Confirm button text diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts index 5e2439b072817a..598563a63accb5 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts @@ -1,6 +1,10 @@ 'use client' import type { FormRefObject } from '@/app/components/base/form/types' -import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import type { + TriggerOAuthClientParams, + TriggerOAuthConfig, + TriggerSubscriptionBuilder, +} from '@/app/components/workflow/block-selector/types' import type { ConfigureTriggerOAuthPayload } from '@/service/use-triggers' import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -19,25 +23,24 @@ export const AuthorizationStatusEnum = { Failed: 'failed', } as const -export type AuthorizationStatusEnum = typeof AuthorizationStatusEnum[keyof typeof AuthorizationStatusEnum] +export type AuthorizationStatusEnum = + (typeof AuthorizationStatusEnum)[keyof typeof AuthorizationStatusEnum] export const ClientTypeEnum = { Default: 'default', Custom: 'custom', } as const -export type ClientTypeEnum = typeof ClientTypeEnum[keyof typeof ClientTypeEnum] +export type ClientTypeEnum = (typeof ClientTypeEnum)[keyof typeof ClientTypeEnum] const POLL_INTERVAL_MS = 3000 // Extract error message from various error formats export const getErrorMessage = (error: unknown, fallback: string): string => { - if (error instanceof Error && error.message) - return error.message + if (error instanceof Error && error.message) return error.message if (typeof error === 'object' && error && 'message' in error) { const message = (error as { message?: string }).message - if (typeof message === 'string' && message) - return message + if (typeof message === 'string' && message) return message } return fallback } @@ -77,7 +80,9 @@ export const useOAuthClientState = ({ const { t } = useTranslation() // State management - const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>() + const [subscriptionBuilder, setSubscriptionBuilder] = useState< + TriggerSubscriptionBuilder | undefined + >() const [authorizationStatus, setAuthorizationStatus] = useState<AuthorizationStatusEnum>() const [clientType, setClientType] = useState<ClientTypeEnum>( oauthConfig?.system_configured ? ClientTypeEnum.Default : ClientTypeEnum.Custom, @@ -94,11 +99,10 @@ export const useOAuthClientState = ({ // Compute OAuth client schema with default values const oauthClientSchema = useMemo(() => { const { oauth_client_schema, params } = oauthConfig || {} - if (!oauth_client_schema?.length || !params) - return [] + if (!oauth_client_schema?.length || !params) return [] const paramKeys = Object.keys(params) - return oauth_client_schema.map(schema => ({ + return oauth_client_schema.map((schema) => ({ ...schema, default: paramKeys.includes(schema.name) ? params[schema.name] : schema.default, })) @@ -107,10 +111,10 @@ export const useOAuthClientState = ({ // Compute confirm button text based on authorization status const confirmButtonText = useMemo(() => { if (authorizationStatus === AuthorizationStatusEnum.Pending) - return t($ => $['modal.common.authorizing'], { ns: 'pluginTrigger' }) + return t(($) => $['modal.common.authorizing'], { ns: 'pluginTrigger' }) if (authorizationStatus === AuthorizationStatusEnum.Success) - return t($ => $['modal.oauth.authorization.waitingJump'], { ns: 'pluginTrigger' }) - return t($ => $['auth.saveAndAuth'], { ns: 'plugin' }) + return t(($) => $['modal.oauth.authorization.waitingJump'], { ns: 'pluginTrigger' }) + return t(($) => $['auth.saveAndAuth'], { ns: 'plugin' }) }, [authorizationStatus, t]) // Authorization handler @@ -120,16 +124,17 @@ export const useOAuthClientState = ({ onSuccess: (response) => { setSubscriptionBuilder(response.subscription_builder) openOAuthPopup(response.authorization_url, (callbackData) => { - if (!callbackData) - return - toast.success(t($ => $['modal.oauth.authorization.authSuccess'], { ns: 'pluginTrigger' })) + if (!callbackData) return + toast.success( + t(($) => $['modal.oauth.authorization.authSuccess'], { ns: 'pluginTrigger' }), + ) onOpenChange(false) showOAuthCreateModal(response.subscription_builder) }) }, onError: () => { setAuthorizationStatus(AuthorizationStatusEnum.Failed) - toast.error(t($ => $['modal.oauth.authorization.authFailed'], { ns: 'pluginTrigger' })) + toast.error(t(($) => $['modal.oauth.authorization.authFailed'], { ns: 'pluginTrigger' })) }, }) }, [providerName, initiateOAuth, onOpenChange, showOAuthCreateModal, t]) @@ -139,59 +144,75 @@ export const useOAuthClientState = ({ deleteOAuth(providerName, { onSuccess: () => { onOpenChange(false) - toast.success(t($ => $['modal.oauth.remove.success'], { ns: 'pluginTrigger' })) + toast.success(t(($) => $['modal.oauth.remove.success'], { ns: 'pluginTrigger' })) }, onError: (error: unknown) => { - toast.error(getErrorMessage(error, t($ => $['modal.oauth.remove.failed'], { ns: 'pluginTrigger' }))) + toast.error( + getErrorMessage( + error, + t(($) => $['modal.oauth.remove.failed'], { ns: 'pluginTrigger' }), + ), + ) }, }) }, [providerName, deleteOAuth, onOpenChange, t]) // Save handler - const handleSave = useCallback((needAuth: boolean) => { - const isCustom = clientType === ClientTypeEnum.Custom - const params: ConfigureTriggerOAuthPayload = { - provider: providerName, - enabled: isCustom, - } - - if (isCustom && oauthClientSchema?.length) { - const clientFormValues = clientFormRef.current?.getFormValues({}) as { - values: TriggerOAuthClientParams - isCheckValidated: boolean - } | undefined - // Handle missing ref or form values - if (!clientFormValues || !clientFormValues.isCheckValidated) - return - const clientParams = { ...clientFormValues.values } - // Preserve hidden values if unchanged - if (clientParams.client_id === oauthConfig?.params.client_id) - clientParams.client_id = '[__HIDDEN__]' - if (clientParams.client_secret === oauthConfig?.params.client_secret) - clientParams.client_secret = '[__HIDDEN__]' - params.client_params = clientParams - } - - configureOAuth(params, { - onSuccess: () => { - if (needAuth) { - handleAuthorization() - return - } - onOpenChange(false) - toast.success(t($ => $['modal.oauth.save.success'], { ns: 'pluginTrigger' })) - }, - }) - }, [clientType, providerName, oauthClientSchema, oauthConfig?.params, configureOAuth, handleAuthorization, onOpenChange, t]) + const handleSave = useCallback( + (needAuth: boolean) => { + const isCustom = clientType === ClientTypeEnum.Custom + const params: ConfigureTriggerOAuthPayload = { + provider: providerName, + enabled: isCustom, + } + + if (isCustom && oauthClientSchema?.length) { + const clientFormValues = clientFormRef.current?.getFormValues({}) as + | { + values: TriggerOAuthClientParams + isCheckValidated: boolean + } + | undefined + // Handle missing ref or form values + if (!clientFormValues || !clientFormValues.isCheckValidated) return + const clientParams = { ...clientFormValues.values } + // Preserve hidden values if unchanged + if (clientParams.client_id === oauthConfig?.params.client_id) + clientParams.client_id = '[__HIDDEN__]' + if (clientParams.client_secret === oauthConfig?.params.client_secret) + clientParams.client_secret = '[__HIDDEN__]' + params.client_params = clientParams + } + + configureOAuth(params, { + onSuccess: () => { + if (needAuth) { + handleAuthorization() + return + } + onOpenChange(false) + toast.success(t(($) => $['modal.oauth.save.success'], { ns: 'pluginTrigger' })) + }, + }) + }, + [ + clientType, + providerName, + oauthClientSchema, + oauthConfig?.params, + configureOAuth, + handleAuthorization, + onOpenChange, + t, + ], + ) // Polling effect for authorization verification useEffect(() => { - const shouldPoll = providerName - && subscriptionBuilder - && authorizationStatus === AuthorizationStatusEnum.Pending + const shouldPoll = + providerName && subscriptionBuilder && authorizationStatus === AuthorizationStatusEnum.Pending - if (!shouldPoll) - return + if (!shouldPoll) return const pollInterval = setInterval(() => { verifyBuilder( diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx index 14815f0b635b8a..35e23782e67b51 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx @@ -1,7 +1,13 @@ import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useBoolean } from 'ahooks' @@ -11,7 +17,11 @@ import { ActionButton, ActionButtonState } from '@/app/components/base/action-bu import Badge from '@/app/components/base/badge' import { Infotip } from '@/app/components/base/infotip' import { openOAuthPopup } from '@/hooks/use-oauth' -import { useInitiateTriggerOAuth, useTriggerOAuthConfig, useTriggerProviderInfo } from '@/service/use-triggers' +import { + useInitiateTriggerOAuth, + useTriggerOAuthConfig, + useTriggerProviderInfo, +} from '@/service/use-triggers' import { SupportedCreationMethods } from '../../../types' import { usePluginStore } from '../../store' import { useSubscriptionList } from '../use-subscription-list' @@ -35,53 +45,77 @@ type CreateTypeOption = { tag?: React.ReactNode } -export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON, shape = 'square' }: Props) => { +export const CreateSubscriptionButton = ({ + buttonType = CreateButtonType.FULL_BUTTON, + shape = 'square', +}: Props) => { const { t } = useTranslation() const { subscriptions } = useSubscriptionList() const subscriptionCount = subscriptions?.length || 0 - const [selectedCreateInfo, setSelectedCreateInfo] = useState<{ type: SupportedCreationMethods, builder?: TriggerSubscriptionBuilder } | null>(null) + const [selectedCreateInfo, setSelectedCreateInfo] = useState<{ + type: SupportedCreationMethods + builder?: TriggerSubscriptionBuilder + } | null>(null) const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) - const detail = usePluginStore(state => state.detail) + const detail = usePluginStore((state) => state.detail) const [isMenuOpen, setIsMenuOpen] = useState(false) const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '') - const supportedMethods = useMemo(() => providerInfo?.supported_creation_methods || [], [providerInfo?.supported_creation_methods]) - const { data: oauthConfig, refetch: refetchOAuthConfig } = useTriggerOAuthConfig(detail?.provider || '', supportedMethods.includes(SupportedCreationMethods.OAUTH)) + const supportedMethods = useMemo( + () => providerInfo?.supported_creation_methods || [], + [providerInfo?.supported_creation_methods], + ) + const { data: oauthConfig, refetch: refetchOAuthConfig } = useTriggerOAuthConfig( + detail?.provider || '', + supportedMethods.includes(SupportedCreationMethods.OAUTH), + ) const { mutate: initiateOAuth } = useInitiateTriggerOAuth() const methodType = supportedMethods.length === 1 ? supportedMethods[0] : DEFAULT_METHOD - const [isShowClientSettingsModal, { - setTrue: showClientSettingsModal, - setFalse: hideClientSettingsModal, - }] = useBoolean(false) + const [ + isShowClientSettingsModal, + { setTrue: showClientSettingsModal, setFalse: hideClientSettingsModal }, + ] = useBoolean(false) const buttonTextMap = useMemo(() => { return { - [SupportedCreationMethods.OAUTH]: t($ => $['subscription.createButton.oauth'], { ns: 'pluginTrigger' }), - [SupportedCreationMethods.APIKEY]: t($ => $['subscription.createButton.apiKey'], { ns: 'pluginTrigger' }), - [SupportedCreationMethods.MANUAL]: t($ => $['subscription.createButton.manual'], { ns: 'pluginTrigger' }), - [DEFAULT_METHOD]: t($ => $['subscription.empty.button'], { ns: 'pluginTrigger' }), + [SupportedCreationMethods.OAUTH]: t(($) => $['subscription.createButton.oauth'], { + ns: 'pluginTrigger', + }), + [SupportedCreationMethods.APIKEY]: t(($) => $['subscription.createButton.apiKey'], { + ns: 'pluginTrigger', + }), + [SupportedCreationMethods.MANUAL]: t(($) => $['subscription.createButton.manual'], { + ns: 'pluginTrigger', + }), + [DEFAULT_METHOD]: t(($) => $['subscription.empty.button'], { ns: 'pluginTrigger' }), } }, [t]) - const onClickClientSettings = useCallback((e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => { - e.stopPropagation() - e.preventDefault() - setIsMenuOpen(false) - showClientSettingsModal() - }, [showClientSettingsModal]) - - const handleClientSettingsOpenChange = useCallback((open: boolean) => { - if (open) { + const onClickClientSettings = useCallback( + (e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => { + e.stopPropagation() + e.preventDefault() + setIsMenuOpen(false) showClientSettingsModal() - return - } + }, + [showClientSettingsModal], + ) + + const handleClientSettingsOpenChange = useCallback( + (open: boolean) => { + if (open) { + showClientSettingsModal() + return + } - hideClientSettingsModal() - refetchOAuthConfig() - }, [hideClientSettingsModal, refetchOAuthConfig, showClientSettingsModal]) + hideClientSettingsModal() + refetchOAuthConfig() + }, + [hideClientSettingsModal, refetchOAuthConfig, showClientSettingsModal], + ) const allOptions = useMemo<CreateTypeOption[]>(() => { const showCustomBadge = oauthConfig?.custom_enabled && oauthConfig?.custom_configured @@ -89,28 +123,28 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU return [ { value: SupportedCreationMethods.OAUTH, - label: t($ => $['subscription.addType.options.oauth.title'], { ns: 'pluginTrigger' }), - tag: !showCustomBadge - ? null - : ( - <Badge className="mr-0.5 ml-1"> - {t($ => $['auth.custom'], { ns: 'plugin' })} - </Badge> - ), + label: t(($) => $['subscription.addType.options.oauth.title'], { ns: 'pluginTrigger' }), + tag: !showCustomBadge ? null : ( + <Badge className="mr-0.5 ml-1">{t(($) => $['auth.custom'], { ns: 'plugin' })}</Badge> + ), extra: ( <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton - aria-label={t($ => $['subscription.addType.options.oauth.clientSettings'], { ns: 'pluginTrigger' })} + aria-label={t(($) => $['subscription.addType.options.oauth.clientSettings'], { + ns: 'pluginTrigger', + })} onClick={onClickClientSettings} > <span aria-hidden className="i-ri-equalizer-2-line size-4 text-text-tertiary" /> </ActionButton> - )} + } /> <TooltipContent> - {t($ => $['subscription.addType.options.oauth.clientSettings'], { ns: 'pluginTrigger' })} + {t(($) => $['subscription.addType.options.oauth.clientSettings'], { + ns: 'pluginTrigger', + })} </TooltipContent> </Tooltip> ), @@ -118,18 +152,22 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU }, { value: SupportedCreationMethods.APIKEY, - label: t($ => $['subscription.addType.options.apikey.title'], { ns: 'pluginTrigger' }), + label: t(($) => $['subscription.addType.options.apikey.title'], { ns: 'pluginTrigger' }), show: supportedMethods.includes(SupportedCreationMethods.APIKEY), }, { value: SupportedCreationMethods.MANUAL, - label: t($ => $['subscription.addType.options.manual.description'], { ns: 'pluginTrigger' }), + label: t(($) => $['subscription.addType.options.manual.description'], { + ns: 'pluginTrigger', + }), extra: ( <Infotip - aria-label={t($ => $['subscription.addType.options.manual.tip'], { ns: 'pluginTrigger' })} + aria-label={t(($) => $['subscription.addType.options.manual.tip'], { + ns: 'pluginTrigger', + })} className="size-3.5" > - {t($ => $['subscription.addType.options.manual.tip'], { ns: 'pluginTrigger' })} + {t(($) => $['subscription.addType.options.manual.tip'], { ns: 'pluginTrigger' })} </Infotip> ), show: supportedMethods.includes(SupportedCreationMethods.MANUAL), @@ -137,14 +175,19 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU ] }, [t, oauthConfig, supportedMethods, onClickClientSettings]) const visibleOptions = useMemo(() => { - return allOptions.filter(option => option.show) + return allOptions.filter((option) => option.show) }, [allOptions]) - const shouldAllowSelect = methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1) + const shouldAllowSelect = + methodType === DEFAULT_METHOD || + (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1) - const showCreateModal = useCallback((createInfo: { type: SupportedCreationMethods, builder?: TriggerSubscriptionBuilder }) => { - setSelectedCreateInfo(createInfo) - setIsCreateModalOpen(true) - }, []) + const showCreateModal = useCallback( + (createInfo: { type: SupportedCreationMethods; builder?: TriggerSubscriptionBuilder }) => { + setSelectedCreateInfo(createInfo) + setIsCreateModalOpen(true) + }, + [], + ) const hideCreateModal = useCallback(() => { setIsCreateModalOpen(false) @@ -157,7 +200,9 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU onSuccess: (response) => { openOAuthPopup(response.authorization_url, (callbackData) => { if (callbackData) { - toast.success(t($ => $['modal.oauth.authorization.authSuccess'], { ns: 'pluginTrigger' })) + toast.success( + t(($) => $['modal.oauth.authorization.authSuccess'], { ns: 'pluginTrigger' }), + ) showCreateModal({ type: SupportedCreationMethods.OAUTH, builder: response.subscription_builder, @@ -166,15 +211,15 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU }) }, onError: () => { - toast.error(t($ => $['modal.oauth.authorization.authFailed'], { ns: 'pluginTrigger' })) + toast.error( + t(($) => $['modal.oauth.authorization.authFailed'], { ns: 'pluginTrigger' }), + ) }, }) - } - else { + } else { showClientSettingsModal() } - } - else { + } else { showCreateModal({ type, }) @@ -182,9 +227,8 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU } const handleCreateTypeChange = (value: string | null) => { - const option = visibleOptions.find(item => item.value === value) - if (!option) - return + const option = visibleOptions.find((item) => item.value === value) + if (!option) return setIsMenuOpen(false) void onChooseCreateType(option.value) @@ -196,7 +240,10 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU return } - if (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) + if ( + methodType === DEFAULT_METHOD || + (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1) + ) return e.stopPropagation() @@ -204,8 +251,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU onChooseCreateType(methodType!) } - if (!supportedMethods.length) - return null + if (!supportedMethods.length) return null return ( <> @@ -218,73 +264,91 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU <SelectTrigger render={<div />} nativeButton={false} - className={cn('h-8 border-0 bg-transparent px-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden', buttonType === CreateButtonType.FULL_BUTTON && 'grow')} + className={cn( + 'h-8 border-0 bg-transparent px-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden', + buttonType === CreateButtonType.FULL_BUTTON && 'grow', + )} > - {buttonType === CreateButtonType.FULL_BUTTON - ? ( - <Button - variant="primary" - size="medium" - className="flex w-full items-center justify-between px-0" - onClick={onClickCreate} - > - <div className="flex flex-1 items-center justify-center"> - <span aria-hidden className="mr-2 i-ri-add-line size-4" /> - {buttonTextMap[methodType!]} - {methodType === SupportedCreationMethods.OAUTH && oauthConfig?.custom_enabled && oauthConfig?.custom_configured && ( - <Badge - className="mr-0.5 ml-1 border-text-primary-on-surface bg-components-badge-bg-dimm text-text-primary-on-surface" - > - {t($ => $['auth.custom'], { ns: 'plugin' })} - </Badge> - )} - </div> - {methodType === SupportedCreationMethods.OAUTH - && ( - <div className="ml-auto flex items-center"> - <div className="h-4 w-px bg-text-primary-on-surface opacity-15" /> - <Tooltip> - <TooltipTrigger - render={( - <div onClick={onClickClientSettings} className="p-2"> - <span aria-hidden className="i-ri-equalizer-2-line size-4 text-components-button-primary-text" /> - </div> - )} + {buttonType === CreateButtonType.FULL_BUTTON ? ( + <Button + variant="primary" + size="medium" + className="flex w-full items-center justify-between px-0" + onClick={onClickCreate} + > + <div className="flex flex-1 items-center justify-center"> + <span aria-hidden className="mr-2 i-ri-add-line size-4" /> + {buttonTextMap[methodType!]} + {methodType === SupportedCreationMethods.OAUTH && + oauthConfig?.custom_enabled && + oauthConfig?.custom_configured && ( + <Badge className="mr-0.5 ml-1 border-text-primary-on-surface bg-components-badge-bg-dimm text-text-primary-on-surface"> + {t(($) => $['auth.custom'], { ns: 'plugin' })} + </Badge> + )} + </div> + {methodType === SupportedCreationMethods.OAUTH && ( + <div className="ml-auto flex items-center"> + <div className="h-4 w-px bg-text-primary-on-surface opacity-15" /> + <Tooltip> + <TooltipTrigger + render={ + <div onClick={onClickClientSettings} className="p-2"> + <span + aria-hidden + className="i-ri-equalizer-2-line size-4 text-components-button-primary-text" /> - <TooltipContent> - {t($ => $['subscription.addType.options.oauth.clientSettings'], { ns: 'pluginTrigger' })} - </TooltipContent> - </Tooltip> - </div> + </div> + } + /> + <TooltipContent> + {t(($) => $['subscription.addType.options.oauth.clientSettings'], { + ns: 'pluginTrigger', + })} + </TooltipContent> + </Tooltip> + </div> + )} + </Button> + ) : ( + <Tooltip> + <TooltipTrigger + disabled={!(supportedMethods?.length === 1 || subscriptionCount >= MAX_COUNT)} + render={ + <ActionButton + aria-label={buttonTextMap[methodType!]} + onClick={onClickCreate} + className={cn( + 'float-right', + shape === 'circle' && + 'rounded-full! border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg-hover text-components-button-secondary-accent-text shadow-xs hover:border-components-button-secondary-border-disabled hover:bg-components-button-secondary-bg-disabled hover:text-components-button-secondary-accent-text-disabled', )} - </Button> - ) - : ( - <Tooltip> - <TooltipTrigger - disabled={!(supportedMethods?.length === 1 || subscriptionCount >= MAX_COUNT)} - render={( - <ActionButton - aria-label={buttonTextMap[methodType!]} - onClick={onClickCreate} - className={cn( - 'float-right', - shape === 'circle' && 'rounded-full! border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg-hover text-components-button-secondary-accent-text shadow-xs hover:border-components-button-secondary-border-disabled hover:bg-components-button-secondary-bg-disabled hover:text-components-button-secondary-accent-text-disabled', - )} - state={subscriptionCount >= MAX_COUNT ? ActionButtonState.Disabled : ActionButtonState.Default} - > - <span aria-hidden className="i-ri-add-line size-4" /> - </ActionButton> + state={ + subscriptionCount >= MAX_COUNT + ? ActionButtonState.Disabled + : ActionButtonState.Default + } + > + <span aria-hidden className="i-ri-add-line size-4" /> + </ActionButton> + } + /> + <TooltipContent> + {subscriptionCount >= MAX_COUNT + ? t(($) => $['subscription.maxCount'], { ns: 'pluginTrigger', num: MAX_COUNT }) + : t( + ($) => + $[ + `subscription.addType.options.${methodType!.toLowerCase() as Lowercase<SupportedCreationMethods>}.description` + ], + { ns: 'pluginTrigger' }, )} - /> - <TooltipContent> - {subscriptionCount >= MAX_COUNT ? t($ => $['subscription.maxCount'], { ns: 'pluginTrigger', num: MAX_COUNT }) : t($ => $[`subscription.addType.options.${methodType!.toLowerCase() as Lowercase<SupportedCreationMethods>}.description`], { ns: 'pluginTrigger' })} - </TooltipContent> - </Tooltip> - )} + </TooltipContent> + </Tooltip> + )} </SelectTrigger> <SelectContent placement="bottom-start" sideOffset={4}> - {visibleOptions.map(option => ( + {visibleOptions.map((option) => ( <SelectItem key={option.value} value={option.value}> <div className="mr-8 flex grow items-center gap-1 truncate px-1"> {option.label} @@ -296,31 +360,27 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU ))} </SelectContent> </Select> - {selectedCreateInfo - ? ( - <CommonCreateModal - open={isCreateModalOpen} - createType={selectedCreateInfo.type} - builder={selectedCreateInfo.builder} - onClose={hideCreateModal} - /> - ) - : null} - {isShowClientSettingsModal - ? ( - <OAuthClientSettingsModal - open={isShowClientSettingsModal} - oauthConfig={oauthConfig} - onOpenChange={handleClientSettingsOpenChange} - showOAuthCreateModal={(builder) => { - showCreateModal({ - type: SupportedCreationMethods.OAUTH, - builder, - }) - }} - /> - ) - : null} + {selectedCreateInfo ? ( + <CommonCreateModal + open={isCreateModalOpen} + createType={selectedCreateInfo.type} + builder={selectedCreateInfo.builder} + onClose={hideCreateModal} + /> + ) : null} + {isShowClientSettingsModal ? ( + <OAuthClientSettingsModal + open={isShowClientSettingsModal} + oauthConfig={oauthConfig} + onOpenChange={handleClientSettingsOpenChange} + showOAuthCreateModal={(builder) => { + showCreateModal({ + type: SupportedCreationMethods.OAUTH, + builder, + }) + }} + /> + ) : null} </> ) } diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx index ee4741fb0bb7b6..7e80506ad73128 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx @@ -1,5 +1,8 @@ 'use client' -import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import type { + TriggerOAuthConfig, + TriggerSubscriptionBuilder, +} from '@/app/components/workflow/block-selector/types' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' @@ -8,7 +11,10 @@ import { useTranslation } from 'react-i18next' import { BaseForm } from '@/app/components/base/form/components/base' import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' import { usePluginStore } from '../../store' -import { ClientTypeEnum, useOAuthClientState as useOAuthClientSettings } from './hooks/use-oauth-client-state' +import { + ClientTypeEnum, + useOAuthClientState as useOAuthClientSettings, +} from './hooks/use-oauth-client-state' type Props = Readonly<{ open: boolean @@ -19,9 +25,14 @@ type Props = Readonly<{ const CLIENT_TYPE_OPTIONS = [ClientTypeEnum.Default, ClientTypeEnum.Custom] as const -export const OAuthClientSettingsModal = ({ open, oauthConfig, onOpenChange, showOAuthCreateModal }: Props) => { +export const OAuthClientSettingsModal = ({ + open, + oauthConfig, + onOpenChange, + showOAuthCreateModal, +}: Props) => { const { t } = useTranslation() - const detail = usePluginStore(state => state.detail) + const detail = usePluginStore((state) => state.detail) const providerName = detail?.provider || '' const closeModal = useCallback(() => onOpenChange(false), [onOpenChange]) @@ -48,39 +59,39 @@ export const OAuthClientSettingsModal = ({ open, oauthConfig, onOpenChange, show const handleCopyRedirectUri = () => { navigator.clipboard.writeText(oauthConfig?.redirect_uri || '') - toast.success(t($ => $['actionMsg.copySuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.copySuccessfully'], { ns: 'common' })) } - const title = t($ => $['modal.oauth.title'], { ns: 'pluginTrigger' }) + const title = t(($) => $['modal.oauth.title'], { ns: 'pluginTrigger' }) return ( - <Dialog - open={open} - disablePointerDismissal - onOpenChange={onOpenChange} - > - <DialogContent - backdropProps={{ forceRender: true }} - className="p-0" - > + <Dialog open={open} disablePointerDismissal onOpenChange={onOpenChange}> + <DialogContent backdropProps={{ forceRender: true }} className="p-0"> <div data-testid="modal" className="flex max-h-[80dvh] flex-col"> <div className="relative shrink-0 p-6 pr-14 pb-3"> - <DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary"> + <DialogTitle + data-testid="modal-title" + className="title-2xl-semi-bold text-text-primary" + > {title} </DialogTitle> <DialogCloseButton className="top-5 right-5 size-8 rounded-lg" /> </div> <div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> <div className="mb-2 system-sm-medium text-text-secondary"> - {t($ => $['subscription.addType.options.oauth.clientTitle'], { ns: 'pluginTrigger' })} + {t(($) => $['subscription.addType.options.oauth.clientTitle'], { + ns: 'pluginTrigger', + })} </div> {oauthConfig?.system_configured && ( <div className="mb-4 flex w-full items-start justify-between gap-2"> - {CLIENT_TYPE_OPTIONS.map(option => ( + {CLIENT_TYPE_OPTIONS.map((option) => ( <OptionCard key={option} - title={t($ => $[`subscription.addType.options.oauth.${option}`], { ns: 'pluginTrigger' })} + title={t(($) => $[`subscription.addType.options.oauth.${option}`], { + ns: 'pluginTrigger', + })} onSelect={() => setClientType(option)} selected={clientType === option} className="flex-1" @@ -92,22 +103,24 @@ export const OAuthClientSettingsModal = ({ open, oauthConfig, onOpenChange, show {showRedirectInfo && ( <div className="mb-4 flex items-start gap-3 rounded-xl bg-background-section-burn p-4"> <div className="rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg p-2 shadow-xs shadow-shadow-shadow-3"> - <span aria-hidden="true" className="i-ri-information-2-fill size-5 shrink-0 text-text-accent" /> + <span + aria-hidden="true" + className="i-ri-information-2-fill size-5 shrink-0 text-text-accent" + /> </div> <div className="min-w-0 flex-1 text-text-secondary"> <div className="system-sm-regular leading-4 whitespace-pre-wrap"> - {t($ => $['modal.oauthRedirectInfo'], { ns: 'pluginTrigger' })} + {t(($) => $['modal.oauthRedirectInfo'], { ns: 'pluginTrigger' })} </div> <div className="my-1.5 system-sm-medium leading-4 break-all"> {oauthConfig?.redirect_uri} </div> - <Button - variant="secondary" - size="small" - onClick={handleCopyRedirectUri} - > - <span aria-hidden="true" className="mr-1 i-ri-clipboard-line h-[14px] w-[14px]" /> - {t($ => $['operation.copy'], { ns: 'common' })} + <Button variant="secondary" size="small" onClick={handleCopyRedirectUri}> + <span + aria-hidden="true" + className="mr-1 i-ri-clipboard-line h-[14px] w-[14px]" + /> + {t(($) => $['operation.copy'], { ns: 'common' })} </Button> </div> </div> @@ -130,24 +143,17 @@ export const OAuthClientSettingsModal = ({ open, oauthConfig, onOpenChange, show className="text-components-button-destructive-secondary-text" onClick={handleRemove} > - {t($ => $['operation.remove'], { ns: 'common' })} + {t(($) => $['operation.remove'], { ns: 'common' })} </Button> )} </div> <div className="flex items-center"> - <Button - data-testid="modal-extra" - variant="secondary" - onClick={closeModal} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button data-testid="modal-extra" variant="secondary" onClick={closeModal}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <div className="mx-3 h-4 w-px bg-divider-regular"></div> - <Button - data-testid="modal-cancel" - onClick={() => handleSave(false)} - > - {t($ => $['auth.saveOnly'], { ns: 'plugin' })} + <Button data-testid="modal-cancel" onClick={() => handleSave(false)}> + {t(($) => $['auth.saveOnly'], { ns: 'plugin' })} </Button> <Button data-testid="modal-confirm" diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx index 901b0d9a0530dd..0ebc09f3e06b9e 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx @@ -32,26 +32,28 @@ export const DeleteConfirm = (props: Props) => { const [inputName, setInputName] = useState('') const handleOpenChange = (open: boolean) => { - if (isDeleting) - return + if (isDeleting) return - if (!open) - onClose(false) + if (!open) onClose(false) } const onConfirm = () => { if (workflowsInUse > 0 && inputName !== currentName) { - toast.error(t($ => $[`${tPrefix}.confirmInputWarning`], { ns: 'pluginTrigger' })) + toast.error(t(($) => $[`${tPrefix}.confirmInputWarning`], { ns: 'pluginTrigger' })) return } deleteSubscription(currentId, { onSuccess: () => { - toast.success(t($ => $[`${tPrefix}.success`], { ns: 'pluginTrigger', name: currentName })) + toast.success(t(($) => $[`${tPrefix}.success`], { ns: 'pluginTrigger', name: currentName })) refetch?.() onClose(true) }, onError: (error: unknown) => { - toast.error(error instanceof Error ? error.message : t($ => $[`${tPrefix}.error`], { ns: 'pluginTrigger', name: currentName })) + toast.error( + error instanceof Error + ? error.message + : t(($) => $[`${tPrefix}.error`], { ns: 'pluginTrigger', name: currentName }), + ) }, }) } @@ -61,32 +63,41 @@ export const DeleteConfirm = (props: Props) => { <AlertDialogContent backdropProps={{ forceRender: true }}> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $[`${tPrefix}.title`], { ns: 'pluginTrigger', name: currentName })} + {t(($) => $[`${tPrefix}.title`], { ns: 'pluginTrigger', name: currentName })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> {workflowsInUse > 0 - ? t($ => $[`${tPrefix}.contentWithApps`], { ns: 'pluginTrigger', count: workflowsInUse }) - : t($ => $[`${tPrefix}.content`], { ns: 'pluginTrigger' })} + ? t(($) => $[`${tPrefix}.contentWithApps`], { + ns: 'pluginTrigger', + count: workflowsInUse, + }) + : t(($) => $[`${tPrefix}.content`], { ns: 'pluginTrigger' })} </AlertDialogDescription> {workflowsInUse > 0 && ( <div className="mt-6"> <div className="mb-2 system-sm-medium text-text-secondary"> - {t($ => $[`${tPrefix}.confirmInputTip`], { ns: 'pluginTrigger', name: currentName })} + {t(($) => $[`${tPrefix}.confirmInputTip`], { + ns: 'pluginTrigger', + name: currentName, + })} </div> <Input value={inputName} - onChange={e => setInputName(e.target.value)} - placeholder={t($ => $[`${tPrefix}.confirmInputPlaceholder`], { ns: 'pluginTrigger', name: currentName })} + onChange={(e) => setInputName(e.target.value)} + placeholder={t(($) => $[`${tPrefix}.confirmInputPlaceholder`], { + ns: 'pluginTrigger', + name: currentName, + })} /> </div> )} </div> <AlertDialogActions> <AlertDialogCancelButton disabled={isDeleting}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton loading={isDeleting} disabled={isDeleting} onClick={onConfirm}> - {t($ => $[`${tPrefix}.confirm`], { ns: 'pluginTrigger' })} + {t(($) => $[`${tPrefix}.confirm`], { ns: 'pluginTrigger' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx index 6e192e025cd412..73752a16f7435f 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx @@ -48,7 +48,7 @@ vi.mock('@/service/use-triggers', () => ({ })) vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: Object.assign((args: { type: string, message: string }) => mockToast(args), { + toast: Object.assign((args: { type: string; message: string }) => mockToast(args), { success: (message: string) => mockToast({ type: 'success', message }), error: (message: string) => mockToast({ type: 'error', message }), warning: (message: string) => mockToast({ type: 'warning', message }), @@ -88,9 +88,15 @@ describe('ApiKeyEditModal', () => { render(<ApiKeyEditModal subscription={createSubscription()} onClose={onClose} />) - expect(screen.getByRole('button', { name: 'pluginTrigger.modal.common.verify' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'pluginTrigger.modal.common.back' })).not.toBeInTheDocument() - expect(screen.getByText(content => content.includes('common.provider.encrypted.front'))).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'pluginTrigger.modal.common.verify' }), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'pluginTrigger.modal.common.back' }), + ).not.toBeInTheDocument() + expect( + screen.getByText((content) => content.includes('common.provider.encrypted.front')), + ).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx index dcf07e66a21d73..f15a7d727c508b 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx @@ -14,15 +14,19 @@ import { OAuthEditModal } from '../oauth-edit-modal' const mockToastNotify = vi.fn() vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: Object.assign((message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), { - success: (message: string) => mockToastNotify({ type: 'success', message }), - error: (message: string) => mockToastNotify({ type: 'error', message }), - warning: (message: string) => mockToastNotify({ type: 'warning', message }), - info: (message: string) => mockToastNotify({ type: 'info', message }), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }), + toast: Object.assign( + (message: string, options?: { type?: string }) => + mockToastNotify({ type: options?.type, message }), + { + success: (message: string) => mockToastNotify({ type: 'success', message }), + error: (message: string) => mockToastNotify({ type: 'error', message }), + warning: (message: string) => mockToastNotify({ type: 'warning', message }), + info: (message: string) => mockToastNotify({ type: 'info', message }), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), + }, + ), })) const mockParsePluginErrorMessage = vi.fn() @@ -77,9 +81,14 @@ vi.mock('../../../store', () => ({ })) const getCancelButton = () => screen.getByRole('button', { name: /common\.operation\.cancel/i }) -const getConfirmButton = () => screen.getByRole('button', { name: /common\.operation\.(save|saving)|pluginTrigger\.modal\.common\.verify/i }) -const getBackButton = () => screen.getByRole('button', { name: /pluginTrigger\.modal\.common\.back/i }) -const queryBackButton = () => screen.queryByRole('button', { name: /pluginTrigger\.modal\.common\.back/i }) +const getConfirmButton = () => + screen.getByRole('button', { + name: /common\.operation\.(save|saving)|pluginTrigger\.modal\.common\.verify/i, + }) +const getBackButton = () => + screen.getByRole('button', { name: /pluginTrigger\.modal\.common\.back/i }) +const queryBackButton = () => + screen.queryByRole('button', { name: /pluginTrigger\.modal\.common\.back/i }) const mockRefetch = vi.fn() vi.mock('../../use-subscription-list', () => ({ @@ -104,7 +113,9 @@ vi.mock('@/service/use-triggers', () => ({ vi.mock('@/app/components/plugins/readme-panel/entrance', () => ({ ReadmeEntrance: ({ pluginDetail }: { pluginDetail: PluginDetail }) => ( - <div data-testid="readme-entrance" data-plugin-id={pluginDetail.id}>ReadmeEntrance</div> + <div data-testid="readme-entrance" data-plugin-id={pluginDetail.id}> + ReadmeEntrance + </div> ), })) @@ -113,19 +124,20 @@ vi.mock('@/app/components/base/encrypted-bottom', () => ({ })) // Form values storage keyed by form identifier -const formValuesMap = new Map<string, { values: Record<string, unknown>, isCheckValidated: boolean }>() +const formValuesMap = new Map< + string, + { values: Record<string, unknown>; isCheckValidated: boolean } +>() // Track which modal is being tested to properly identify forms let currentModalType: 'manual' | 'oauth' | 'apikey' = 'manual' // Helper to get form identifier based on schemas and context const getFormId = (schemas: Array<{ name: string }>, preventDefaultSubmit?: boolean): string => { - if (preventDefaultSubmit) - return 'credentials' - if (schemas.some(s => s.name === 'subscription_name')) { + if (preventDefaultSubmit) return 'credentials' + if (schemas.some((s) => s.name === 'subscription_name')) { // For ApiKey modal step 2, basic form only has subscription_name and callback_url - if (currentModalType === 'apikey' && schemas.length === 2) - return 'basic' + if (currentModalType === 'apikey' && schemas.length === 2) return 'basic' // For ManualEditModal and OAuthEditModal, the main form always includes subscription_name return 'main' } @@ -146,26 +158,28 @@ vi.mock('@/app/components/base/form/components/base', () => ({ data-schemas-count={formSchemas?.length || 0} data-prevent-submit={preventDefaultSubmit} > - {formSchemas?.map((schema: { - name: string - type: string - default?: unknown - dynamicSelectParams?: unknown - fieldClassName?: string - labelClassName?: string - }) => ( - <div - key={schema.name} - data-testid={`form-field-${schema.name}`} - data-field-type={schema.type} - data-field-default={String(schema.default || '')} - data-has-dynamic-select={!!schema.dynamicSelectParams} - data-field-class={schema.fieldClassName || ''} - data-label-class={schema.labelClassName || ''} - > - {schema.name} - </div> - ))} + {formSchemas?.map( + (schema: { + name: string + type: string + default?: unknown + dynamicSelectParams?: unknown + fieldClassName?: string + labelClassName?: string + }) => ( + <div + key={schema.name} + data-testid={`form-field-${schema.name}`} + data-field-type={schema.type} + data-field-default={String(schema.default || '')} + data-has-dynamic-select={!!schema.dynamicSelectParams} + data-field-class={schema.fieldClassName || ''} + data-label-class={schema.labelClassName || ''} + > + {schema.name} + </div> + ), + )} </div> ) }), @@ -243,7 +257,11 @@ const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail ...overrides, }) -const createSchemaField = (name: string, type: string = 'string', overrides = {}): SubscriptionSchema => ({ +const createSchemaField = ( + name: string, + type: string = 'string', + overrides = {}, +): SubscriptionSchema => ({ name, label: { en_US: name }, type, @@ -258,7 +276,11 @@ const createSchemaField = (name: string, type: string = 'string', overrides = {} ...overrides, }) -const createCredentialSchema = (name: string, type: string = 'secret-input', overrides = {}): CredentialSchema => ({ +const createCredentialSchema = ( + name: string, + type: string = 'secret-input', + overrides = {}, +): CredentialSchema => ({ name, label: { en_US: name }, type, @@ -299,13 +321,23 @@ describe('Edit Modal Components', () => { { type: TriggerCredentialTypeEnum.Oauth2, name: 'OAuthEditModal' }, { type: TriggerCredentialTypeEnum.ApiKey, name: 'ApiKeyEditModal' }, ])('should render $name for $type credential type', ({ type }) => { - render(<EditModal onClose={vi.fn()} subscription={createSubscription({ credential_type: type })} />) + render( + <EditModal + onClose={vi.fn()} + subscription={createSubscription({ credential_type: type })} + />, + ) expect(screen.getByTestId('modal')).toBeInTheDocument() }) it('should render nothing for unknown credential type', () => { const { container } = render( - <EditModal onClose={vi.fn()} subscription={createSubscription({ credential_type: 'unknown' as TriggerCredentialTypeEnum })} />, + <EditModal + onClose={vi.fn()} + subscription={createSubscription({ + credential_type: 'unknown' as TriggerCredentialTypeEnum, + })} + />, ) expect(container).toBeEmptyDOMElement() }) @@ -319,7 +351,10 @@ describe('Edit Modal Components', () => { pluginDetail={pluginDetail} />, ) - expect(screen.getByTestId('readme-entrance')).toHaveAttribute('data-plugin-id', 'custom-plugin') + expect(screen.getByTestId('readme-entrance')).toHaveAttribute( + 'data-plugin-id', + 'custom-plugin', + ) }) }) @@ -374,24 +409,56 @@ describe('Edit Modal Components', () => { describe('Form Schema Default Values', () => { it('should use subscription name as default', () => { - render(<ManualEditModal {...createProps({ subscription: createSubscription({ name: 'My Sub' }) })} />) - expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute('data-field-default', 'My Sub') + render( + <ManualEditModal + {...createProps({ subscription: createSubscription({ name: 'My Sub' }) })} + />, + ) + expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute( + 'data-field-default', + 'My Sub', + ) }) it('should use endpoint as callback_url default', () => { - render(<ManualEditModal {...createProps({ subscription: createSubscription({ endpoint: 'https://test.com' }) })} />) - expect(screen.getByTestId('form-field-callback_url')).toHaveAttribute('data-field-default', 'https://test.com') + render( + <ManualEditModal + {...createProps({ subscription: createSubscription({ endpoint: 'https://test.com' }) })} + />, + ) + expect(screen.getByTestId('form-field-callback_url')).toHaveAttribute( + 'data-field-default', + 'https://test.com', + ) }) it('should use empty string when endpoint is empty', () => { - render(<ManualEditModal {...createProps({ subscription: createSubscription({ endpoint: '' }) })} />) - expect(screen.getByTestId('form-field-callback_url')).toHaveAttribute('data-field-default', '') + render( + <ManualEditModal + {...createProps({ subscription: createSubscription({ endpoint: '' }) })} + />, + ) + expect(screen.getByTestId('form-field-callback_url')).toHaveAttribute( + 'data-field-default', + '', + ) }) it('should use subscription properties as defaults for custom fields', () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [createSchemaField('custom')] - render(<ManualEditModal {...createProps({ subscription: createSubscription({ properties: { custom: 'value' } }) })} />) - expect(screen.getByTestId('form-field-custom')).toHaveAttribute('data-field-default', 'value') + mockPluginStoreDetail.declaration.trigger.subscription_schema = [ + createSchemaField('custom'), + ] + render( + <ManualEditModal + {...createProps({ + subscription: createSubscription({ properties: { custom: 'value' } }), + })} + />, + ) + expect(screen.getByTestId('form-field-custom')).toHaveAttribute( + 'data-field-default', + 'value', + ) }) it('should use schema default when subscription property is missing', () => { @@ -399,7 +466,10 @@ describe('Edit Modal Components', () => { createSchemaField('custom', 'string', { default: 'schema_default' }), ] render(<ManualEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-custom')).toHaveAttribute('data-field-default', 'schema_default') + expect(screen.getByTestId('form-field-custom')).toHaveAttribute( + 'data-field-default', + 'schema_default', + ) }) }) @@ -426,7 +496,10 @@ describe('Edit Modal Components', () => { }) it('should call updateSubscription when confirm is clicked with valid form', () => { - formValuesMap.set('main', { values: { subscription_name: 'New Name' }, isCheckValidated: true }) + formValuesMap.set('main', { + values: { subscription_name: 'New Name' }, + isCheckValidated: true, + }) render(<ManualEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) expect(mockUpdateSubscription).toHaveBeenCalledWith( @@ -493,23 +566,29 @@ describe('Edit Modal Components', () => { render(<ManualEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'Custom error', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'Custom error', + }), + ) }) }) it('should use error.message from object when available', async () => { formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: 'Object error' })) + mockUpdateSubscription.mockImplementation((_p, cb) => + cb.onError({ message: 'Object error' }), + ) render(<ManualEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'Object error', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'Object error', + }), + ) }) }) @@ -519,10 +598,12 @@ describe('Edit Modal Components', () => { render(<ManualEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.subscription.list.item.actions.edit.error', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'pluginTrigger.subscription.list.item.actions.edit.error', + }), + ) }) }) @@ -532,10 +613,12 @@ describe('Edit Modal Components', () => { render(<ManualEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.subscription.list.item.actions.edit.error', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'pluginTrigger.subscription.list.item.actions.edit.error', + }), + ) }) }) @@ -545,10 +628,12 @@ describe('Edit Modal Components', () => { render(<ManualEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.subscription.list.item.actions.edit.error', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'pluginTrigger.subscription.list.item.actions.edit.error', + }), + ) }) }) @@ -558,10 +643,12 @@ describe('Edit Modal Components', () => { render(<ManualEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.subscription.list.item.actions.edit.error', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'pluginTrigger.subscription.list.item.actions.edit.error', + }), + ) }) }) }) @@ -572,7 +659,10 @@ describe('Edit Modal Components', () => { createSchemaField('num_field', 'number'), ] render(<ManualEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-num_field')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber) + expect(screen.getByTestId('form-field-num_field')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.textNumber, + ) }) it('should normalize select type', () => { @@ -580,7 +670,10 @@ describe('Edit Modal Components', () => { createSchemaField('sel_field', 'select'), ] render(<ManualEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-sel_field')).toHaveAttribute('data-field-type', FormTypeEnum.select) + expect(screen.getByTestId('form-field-sel_field')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.select, + ) }) it('should return textInput for unknown type', () => { @@ -588,7 +681,10 @@ describe('Edit Modal Components', () => { createSchemaField('unknown_field', 'unknown-custom-type'), ] render(<ManualEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-unknown_field')).toHaveAttribute('data-field-type', FormTypeEnum.textInput) + expect(screen.getByTestId('form-field-unknown_field')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.textInput, + ) }) }) @@ -643,15 +739,19 @@ describe('Edit Modal Components', () => { createSchemaField('channel'), ] render( - <OAuthEditModal {...createProps({ - subscription: createSubscription({ - credential_type: TriggerCredentialTypeEnum.Oauth2, - parameters: { channel: 'general' }, - }), - })} + <OAuthEditModal + {...createProps({ + subscription: createSubscription({ + credential_type: TriggerCredentialTypeEnum.Oauth2, + parameters: { channel: 'general' }, + }), + })} />, ) - expect(screen.getByTestId('form-field-channel')).toHaveAttribute('data-field-default', 'general') + expect(screen.getByTestId('form-field-channel')).toHaveAttribute( + 'data-field-default', + 'general', + ) }) }) @@ -661,7 +761,10 @@ describe('Edit Modal Components', () => { createSchemaField('dynamic_field', FormTypeEnum.dynamicSelect), ] render(<OAuthEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-dynamic_field')).toHaveAttribute('data-has-dynamic-select', 'true') + expect(screen.getByTestId('form-field-dynamic_field')).toHaveAttribute( + 'data-has-dynamic-select', + 'true', + ) }) it('should not add dynamicSelectParams for non-dynamic-select fields', () => { @@ -669,7 +772,10 @@ describe('Edit Modal Components', () => { createSchemaField('text_field', 'string'), ] render(<OAuthEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-text_field')).toHaveAttribute('data-has-dynamic-select', 'false') + expect(screen.getByTestId('form-field-text_field')).toHaveAttribute( + 'data-has-dynamic-select', + 'false', + ) }) }) @@ -683,7 +789,10 @@ describe('Edit Modal Components', () => { 'data-field-class', 'flex items-center justify-between', ) - expect(screen.getByTestId('form-field-bool_field')).toHaveAttribute('data-label-class', 'mb-0') + expect(screen.getByTestId('form-field-bool_field')).toHaveAttribute( + 'data-label-class', + 'mb-0', + ) }) }) @@ -694,12 +803,13 @@ describe('Edit Modal Components', () => { isCheckValidated: true, }) render( - <OAuthEditModal {...createProps({ - subscription: createSubscription({ - credential_type: TriggerCredentialTypeEnum.Oauth2, - parameters: { channel: 'general' }, - }), - })} + <OAuthEditModal + {...createProps({ + subscription: createSubscription({ + credential_type: TriggerCredentialTypeEnum.Oauth2, + parameters: { channel: 'general' }, + }), + })} />, ) fireEvent.click(getConfirmButton()) @@ -715,12 +825,13 @@ describe('Edit Modal Components', () => { isCheckValidated: true, }) render( - <OAuthEditModal {...createProps({ - subscription: createSubscription({ - credential_type: TriggerCredentialTypeEnum.Oauth2, - parameters: { channel: 'old' }, - }), - })} + <OAuthEditModal + {...createProps({ + subscription: createSubscription({ + credential_type: TriggerCredentialTypeEnum.Oauth2, + parameters: { channel: 'old' }, + }), + })} />, ) fireEvent.click(getConfirmButton()) @@ -760,10 +871,12 @@ describe('Edit Modal Components', () => { render(<OAuthEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.subscription.list.item.actions.edit.error', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'pluginTrigger.subscription.list.item.actions.edit.error', + }), + ) }) }) @@ -773,10 +886,12 @@ describe('Edit Modal Components', () => { render(<OAuthEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.subscription.list.item.actions.edit.error', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'pluginTrigger.subscription.list.item.actions.edit.error', + }), + ) }) }) }) @@ -796,7 +911,10 @@ describe('Edit Modal Components', () => { createSchemaField('num_field', 'number'), ] render(<OAuthEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-num_field')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber) + expect(screen.getByTestId('form-field-num_field')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.textNumber, + ) }) it('should normalize integer type', () => { @@ -804,7 +922,10 @@ describe('Edit Modal Components', () => { createSchemaField('int_field', 'integer'), ] render(<OAuthEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-int_field')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber) + expect(screen.getByTestId('form-field-int_field')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.textNumber, + ) }) it('should normalize select type', () => { @@ -812,7 +933,10 @@ describe('Edit Modal Components', () => { createSchemaField('sel_field', 'select'), ] render(<OAuthEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-sel_field')).toHaveAttribute('data-field-type', FormTypeEnum.select) + expect(screen.getByTestId('form-field-sel_field')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.select, + ) }) it('should normalize password type', () => { @@ -820,7 +944,10 @@ describe('Edit Modal Components', () => { createSchemaField('pwd_field', 'password'), ] render(<OAuthEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-pwd_field')).toHaveAttribute('data-field-type', FormTypeEnum.secretInput) + expect(screen.getByTestId('form-field-pwd_field')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.secretInput, + ) }) it('should return textInput for unknown type', () => { @@ -828,7 +955,10 @@ describe('Edit Modal Components', () => { createSchemaField('unknown_field', 'custom-unknown-type'), ] render(<OAuthEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-unknown_field')).toHaveAttribute('data-field-type', FormTypeEnum.textInput) + expect(screen.getByTestId('form-field-unknown_field')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.textInput, + ) }) }) @@ -902,15 +1032,19 @@ describe('Edit Modal Components', () => { it('should use subscription credentials as defaults', () => { setupCredentialsSchema() render( - <ApiKeyEditModal {...createProps({ - subscription: createSubscription({ - credential_type: TriggerCredentialTypeEnum.ApiKey, - credentials: { api_key: '[__HIDDEN__]' }, - }), - })} + <ApiKeyEditModal + {...createProps({ + subscription: createSubscription({ + credential_type: TriggerCredentialTypeEnum.ApiKey, + credentials: { api_key: '[__HIDDEN__]' }, + }), + })} />, ) - expect(screen.getByTestId('form-field-api_key')).toHaveAttribute('data-field-default', '[__HIDDEN__]') + expect(screen.getByTestId('form-field-api_key')).toHaveAttribute( + 'data-field-default', + '[__HIDDEN__]', + ) }) }) @@ -920,7 +1054,10 @@ describe('Edit Modal Components', () => { }) it('should call verifyCredentials when confirm clicked in credentials step', () => { - formValuesMap.set('credentials', { values: { api_key: 'test-key' }, isCheckValidated: true }) + formValuesMap.set('credentials', { + values: { api_key: 'test-key' }, + isCheckValidated: true, + }) render(<ApiKeyEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) expect(mockVerifyCredentials).toHaveBeenCalledWith( @@ -946,10 +1083,12 @@ describe('Edit Modal Components', () => { render(<ApiKeyEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - message: 'pluginTrigger.modal.apiKey.verify.success', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + message: 'pluginTrigger.modal.apiKey.verify.success', + }), + ) }) // Should now be in step 2 expect(getConfirmButton()).toHaveTextContent('common.operation.save') @@ -962,10 +1101,12 @@ describe('Edit Modal Components', () => { render(<ApiKeyEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'Invalid API key', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'Invalid API key', + }), + ) }) }) @@ -976,16 +1117,24 @@ describe('Edit Modal Components', () => { render(<ApiKeyEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.modal.apiKey.verify.error', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'pluginTrigger.modal.apiKey.verify.error', + }), + ) }) }) it('should set verifiedCredentials to null when all credentials are hidden', async () => { - formValuesMap.set('credentials', { values: { api_key: '[__HIDDEN__]' }, isCheckValidated: true }) - formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true }) + formValuesMap.set('credentials', { + values: { api_key: '[__HIDDEN__]' }, + isCheckValidated: true, + }) + formValuesMap.set('basic', { + values: { subscription_name: 'Name' }, + isCheckValidated: true, + }) mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) render(<ApiKeyEditModal {...createProps()} />) @@ -1113,7 +1262,10 @@ describe('Edit Modal Components', () => { }) it('should call updateSubscription with verified credentials', async () => { - formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true }) + formValuesMap.set('basic', { + values: { subscription_name: 'Name' }, + isCheckValidated: true, + }) render(<ApiKeyEditModal {...createProps()} />) // Step 1: Verify @@ -1148,7 +1300,10 @@ describe('Edit Modal Components', () => { }) it('should show success toast and close on successful update', async () => { - formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true }) + formValuesMap.set('basic', { + values: { subscription_name: 'Name' }, + isCheckValidated: true, + }) mockUpdateSubscription.mockImplementation((_p, cb) => cb.onSuccess()) const onClose = vi.fn() render(<ApiKeyEditModal {...createProps({ onClose })} />) @@ -1160,17 +1315,22 @@ describe('Edit Modal Components', () => { fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - message: 'pluginTrigger.subscription.list.item.actions.edit.success', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + message: 'pluginTrigger.subscription.list.item.actions.edit.success', + }), + ) }) expect(mockRefetch).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() }) it('should show error toast on update failure', async () => { - formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true }) + formValuesMap.set('basic', { + values: { subscription_name: 'Name' }, + isCheckValidated: true, + }) mockParsePluginErrorMessage.mockResolvedValue('Update failed') mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(new Error('Failed'))) render(<ApiKeyEditModal {...createProps()} />) @@ -1182,10 +1342,12 @@ describe('Edit Modal Components', () => { fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'Update failed', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'Update failed', + }), + ) }) }) }) @@ -1201,15 +1363,19 @@ describe('Edit Modal Components', () => { }) it('should not send parameters when unchanged', async () => { - formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true }) + formValuesMap.set('basic', { + values: { subscription_name: 'Name' }, + isCheckValidated: true, + }) formValuesMap.set('parameters', { values: { param1: 'value' }, isCheckValidated: true }) render( - <ApiKeyEditModal {...createProps({ - subscription: createSubscription({ - credential_type: TriggerCredentialTypeEnum.ApiKey, - parameters: { param1: 'value' }, - }), - })} + <ApiKeyEditModal + {...createProps({ + subscription: createSubscription({ + credential_type: TriggerCredentialTypeEnum.ApiKey, + parameters: { param1: 'value' }, + }), + })} />, ) @@ -1226,15 +1392,19 @@ describe('Edit Modal Components', () => { }) it('should send parameters when changed', async () => { - formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true }) + formValuesMap.set('basic', { + values: { subscription_name: 'Name' }, + isCheckValidated: true, + }) formValuesMap.set('parameters', { values: { param1: 'new_value' }, isCheckValidated: true }) render( - <ApiKeyEditModal {...createProps({ - subscription: createSubscription({ - credential_type: TriggerCredentialTypeEnum.ApiKey, - parameters: { param1: 'old_value' }, - }), - })} + <ApiKeyEditModal + {...createProps({ + subscription: createSubscription({ + credential_type: TriggerCredentialTypeEnum.ApiKey, + parameters: { param1: 'old_value' }, + }), + })} />, ) @@ -1257,7 +1427,10 @@ describe('Edit Modal Components', () => { createCredentialSchema('port', 'number'), ] render(<ApiKeyEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-port')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber) + expect(screen.getByTestId('form-field-port')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.textNumber, + ) }) it('should normalize select type for credentials schema', () => { @@ -1265,7 +1438,10 @@ describe('Edit Modal Components', () => { createCredentialSchema('region', 'select'), ] render(<ApiKeyEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-region')).toHaveAttribute('data-field-type', FormTypeEnum.select) + expect(screen.getByTestId('form-field-region')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.select, + ) }) it('should normalize text type for credentials schema', () => { @@ -1273,7 +1449,10 @@ describe('Edit Modal Components', () => { createCredentialSchema('name', 'text'), ] render(<ApiKeyEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-name')).toHaveAttribute('data-field-type', FormTypeEnum.textInput) + expect(screen.getByTestId('form-field-name')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.textInput, + ) }) }) @@ -1295,7 +1474,10 @@ describe('Edit Modal Components', () => { expect(getConfirmButton()).toHaveTextContent('common.operation.save') }) - expect(screen.getByTestId('form-field-channel')).toHaveAttribute('data-has-dynamic-select', 'true') + expect(screen.getByTestId('form-field-channel')).toHaveAttribute( + 'data-has-dynamic-select', + 'true', + ) }) }) @@ -1330,7 +1512,10 @@ describe('Edit Modal Components', () => { createCredentialSchema('secret_key', 'password'), ] render(<ApiKeyEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-secret_key')).toHaveAttribute('data-field-type', FormTypeEnum.secretInput) + expect(screen.getByTestId('form-field-secret_key')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.secretInput, + ) }) it('should normalize secret type for credentials', () => { @@ -1338,7 +1523,10 @@ describe('Edit Modal Components', () => { createCredentialSchema('api_secret', 'secret'), ] render(<ApiKeyEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-api_secret')).toHaveAttribute('data-field-type', FormTypeEnum.secretInput) + expect(screen.getByTestId('form-field-api_secret')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.secretInput, + ) }) it('should normalize string type for credentials', () => { @@ -1346,7 +1534,10 @@ describe('Edit Modal Components', () => { createCredentialSchema('username', 'string'), ] render(<ApiKeyEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-username')).toHaveAttribute('data-field-type', FormTypeEnum.textInput) + expect(screen.getByTestId('form-field-username')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.textInput, + ) }) it('should normalize integer type for credentials', () => { @@ -1354,7 +1545,10 @@ describe('Edit Modal Components', () => { createCredentialSchema('timeout', 'integer'), ] render(<ApiKeyEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-timeout')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber) + expect(screen.getByTestId('form-field-timeout')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.textNumber, + ) }) it('should pass through valid FormTypeEnum for credentials', () => { @@ -1362,7 +1556,10 @@ describe('Edit Modal Components', () => { createCredentialSchema('file_field', FormTypeEnum.files), ] render(<ApiKeyEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-file_field')).toHaveAttribute('data-field-type', FormTypeEnum.files) + expect(screen.getByTestId('form-field-file_field')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.files, + ) }) it('should default to textInput for unknown credential types', () => { @@ -1370,7 +1567,10 @@ describe('Edit Modal Components', () => { createCredentialSchema('custom', 'unknown-type'), ] render(<ApiKeyEditModal {...createProps()} />) - expect(screen.getByTestId('form-field-custom')).toHaveAttribute('data-field-type', FormTypeEnum.textInput) + expect(screen.getByTestId('form-field-custom')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.textInput, + ) }) }) @@ -1385,7 +1585,10 @@ describe('Edit Modal Components', () => { }) it('should not update when parameters form validation fails', async () => { - formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true }) + formValuesMap.set('basic', { + values: { subscription_name: 'Name' }, + isCheckValidated: true, + }) formValuesMap.set('parameters', { values: {}, isCheckValidated: false }) render(<ApiKeyEditModal {...createProps()} />) @@ -1422,7 +1625,10 @@ describe('Edit Modal Components', () => { render(<ApiKeyEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('form-field-secret_param')).toHaveAttribute('data-field-type', FormTypeEnum.secretInput) + expect(screen.getByTestId('form-field-secret_param')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.secretInput, + ) }) }) @@ -1433,7 +1639,10 @@ describe('Edit Modal Components', () => { render(<ApiKeyEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('form-field-api_secret')).toHaveAttribute('data-field-type', FormTypeEnum.secretInput) + expect(screen.getByTestId('form-field-api_secret')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.secretInput, + ) }) }) @@ -1444,7 +1653,10 @@ describe('Edit Modal Components', () => { render(<ApiKeyEditModal {...createProps()} />) fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(screen.getByTestId('form-field-count')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber) + expect(screen.getByTestId('form-field-count')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.textNumber, + ) }) }) }) @@ -1466,22 +1678,34 @@ describe('Edit Modal Components', () => { testCases.forEach(({ input, expected }) => { it(`should normalize ${input} to ${expected}`, () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [createSchemaField('field', input)] + mockPluginStoreDetail.declaration.trigger.subscription_schema = [ + createSchemaField('field', input), + ] render(<ManualEditModal onClose={vi.fn()} subscription={createSubscription()} />) expect(screen.getByTestId('form-field-field')).toHaveAttribute('data-field-type', expected) }) }) it('should return textInput for unknown types', () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [createSchemaField('field', 'unknown')] + mockPluginStoreDetail.declaration.trigger.subscription_schema = [ + createSchemaField('field', 'unknown'), + ] render(<ManualEditModal onClose={vi.fn()} subscription={createSubscription()} />) - expect(screen.getByTestId('form-field-field')).toHaveAttribute('data-field-type', FormTypeEnum.textInput) + expect(screen.getByTestId('form-field-field')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.textInput, + ) }) it('should pass through valid FormTypeEnum values', () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [createSchemaField('field', FormTypeEnum.files)] + mockPluginStoreDetail.declaration.trigger.subscription_schema = [ + createSchemaField('field', FormTypeEnum.files), + ] render(<ManualEditModal onClose={vi.fn()} subscription={createSubscription()} />) - expect(screen.getByTestId('form-field-field')).toHaveAttribute('data-field-type', FormTypeEnum.files) + expect(screen.getByTestId('form-field-field')).toHaveAttribute( + 'data-field-type', + FormTypeEnum.files, + ) }) }) @@ -1490,17 +1714,36 @@ describe('Edit Modal Components', () => { describe('Edge Cases', () => { it('should handle empty subscription name', () => { render(<ManualEditModal onClose={vi.fn()} subscription={createSubscription({ name: '' })} />) - expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute('data-field-default', '') + expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute( + 'data-field-default', + '', + ) }) it('should handle special characters in subscription data', () => { - render(<ManualEditModal onClose={vi.fn()} subscription={createSubscription({ name: '<script>alert("xss")</script>' })} />) - expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute('data-field-default', '<script>alert("xss")</script>') + render( + <ManualEditModal + onClose={vi.fn()} + subscription={createSubscription({ name: '<script>alert("xss")</script>' })} + />, + ) + expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute( + 'data-field-default', + '<script>alert("xss")</script>', + ) }) it('should handle Unicode characters', () => { - render(<ManualEditModal onClose={vi.fn()} subscription={createSubscription({ name: '测试订阅 🚀' })} />) - expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute('data-field-default', '测试订阅 🚀') + render( + <ManualEditModal + onClose={vi.fn()} + subscription={createSubscription({ name: '测试订阅 🚀' })} + />, + ) + expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute( + 'data-field-default', + '测试订阅 🚀', + ) }) it('should handle multiple schema fields', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx index e5fa43268af252..b9ef43c684d9b5 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx @@ -31,7 +31,7 @@ vi.mock('@/service/use-triggers', () => ({ })) vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: Object.assign((args: { type: string, message: string }) => mockToast(args), { + toast: Object.assign((args: { type: string; message: string }) => mockToast(args), { success: (message: string) => mockToast({ type: 'success', message }), error: (message: string) => mockToast({ type: 'error', message }), warning: (message: string) => mockToast({ type: 'warning', message }), @@ -68,7 +68,9 @@ describe('ManualEditModal', () => { render(<ManualEditModal subscription={createSubscription()} onClose={onClose} />) - expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument() + expect( + screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/), + ).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx index c860cd28188382..40d76277fd4032 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx @@ -31,7 +31,7 @@ vi.mock('@/service/use-triggers', () => ({ })) vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: Object.assign((args: { type: string, message: string }) => mockToast(args), { + toast: Object.assign((args: { type: string; message: string }) => mockToast(args), { success: (message: string) => mockToast({ type: 'success', message }), error: (message: string) => mockToast({ type: 'error', message }), warning: (message: string) => mockToast({ type: 'warning', message }), @@ -68,7 +68,9 @@ describe('OAuthEditModal', () => { render(<OAuthEditModal subscription={createSubscription()} onClose={onClose} />) - expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument() + expect( + screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/), + ).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx index 7f8de059884556..27ad5608dff746 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx @@ -28,7 +28,7 @@ const EditStep = { EditConfiguration: 'edit_configuration', } as const -type EditStep = typeof EditStep[keyof typeof EditStep] +type EditStep = (typeof EditStep)[keyof typeof EditStep] const normalizeFormType = (type: string): FormTypeEnum => { switch (type) { @@ -46,8 +46,7 @@ const normalizeFormType = (type: string): FormTypeEnum => { case 'select': return FormTypeEnum.select default: - if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) - return type as FormTypeEnum + if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) return type as FormTypeEnum return FormTypeEnum.textInput } } @@ -55,63 +54,62 @@ const normalizeFormType = (type: string): FormTypeEnum => { const HIDDEN_SECRET_VALUE = '[__HIDDEN__]' const areAllCredentialsHidden = (credentials: Record<string, unknown>): boolean => { - return Object.values(credentials).every(value => value === HIDDEN_SECRET_VALUE) + return Object.values(credentials).every((value) => value === HIDDEN_SECRET_VALUE) } -const StatusStep = ({ isActive, text, onClick, clickable }: { +const StatusStep = ({ + isActive, + text, + onClick, + clickable, +}: { isActive: boolean text: string onClick?: () => void clickable?: boolean }) => { - const className = `flex items-center gap-1 system-2xs-semibold-uppercase ${isActive - ? 'text-state-accent-solid' - : 'text-text-tertiary'} ${clickable ? 'cursor-pointer rounded bg-transparent p-0 text-left hover:text-text-secondary focus-visible:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden' : ''}` + const className = `flex items-center gap-1 system-2xs-semibold-uppercase ${ + isActive ? 'text-state-accent-solid' : 'text-text-tertiary' + } ${clickable ? 'cursor-pointer rounded bg-transparent p-0 text-left hover:text-text-secondary focus-visible:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden' : ''}` const content = ( <> - {isActive - ? ( - <div className="size-1 rounded-full bg-state-accent-solid"></div> - ) - : null} + {isActive ? <div className="size-1 rounded-full bg-state-accent-solid"></div> : null} {text} </> ) if (clickable) { return ( - <button - type="button" - className={className} - onClick={onClick} - > + <button type="button" className={className} onClick={onClick}> {content} </button> ) } - return ( - <div className={className}> - {content} - </div> - ) + return <div className={className}>{content}</div> } -const MultiSteps = ({ currentStep, onStepClick }: { currentStep: EditStep, onStepClick?: (step: EditStep) => void }) => { +const MultiSteps = ({ + currentStep, + onStepClick, +}: { + currentStep: EditStep + onStepClick?: (step: EditStep) => void +}) => { const { t } = useTranslation() return ( <div className="mb-6 flex w-1/3 items-center gap-2"> <StatusStep isActive={currentStep === EditStep.EditCredentials} - text={t($ => $['modal.steps.verify'], { ns: 'pluginTrigger' })} + text={t(($) => $['modal.steps.verify'], { ns: 'pluginTrigger' })} onClick={() => onStepClick?.(EditStep.EditCredentials)} clickable={currentStep === EditStep.EditConfiguration} /> <div className="h-px w-3 shrink-0 bg-divider-deep"></div> <StatusStep isActive={currentStep === EditStep.EditConfiguration} - text={t($ => $['modal.steps.configuration'], { ns: 'pluginTrigger' })} + text={t(($) => $['modal.steps.configuration'], { ns: 'pluginTrigger' })} /> </div> ) @@ -119,11 +117,13 @@ const MultiSteps = ({ currentStep, onStepClick }: { currentStep: EditStep, onSte export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) => { const { t } = useTranslation() - const detail = usePluginStore(state => state.detail) + const detail = usePluginStore((state) => state.detail) const { refetch } = useSubscriptionList() const [currentStep, setCurrentStep] = useState<EditStep>(EditStep.EditCredentials) - const [verifiedCredentials, setVerifiedCredentials] = useState<Record<string, unknown> | null>(null) + const [verifiedCredentials, setVerifiedCredentials] = useState<Record<string, unknown> | null>( + null, + ) const { mutate: updateSubscription, isPending: isUpdating } = useUpdateTriggerSubscription() const { mutate: verifyCredentials, isPending: isVerifying } = useVerifyTriggerSubscription() @@ -134,8 +134,9 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) ) const apiKeyCredentialsSchema = useMemo(() => { - const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || [] - return rawSchema.map(schema => ({ + const rawSchema = + detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || [] + return rawSchema.map((schema) => ({ ...schema, tooltip: schema.help, })) @@ -150,8 +151,7 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) needTransformWhenSecretFieldIsPristine: true, }) || { values: {}, isCheckValidated: false } - if (!credentialsFormValues.isCheckValidated) - return + if (!credentialsFormValues.isCheckValidated) return const credentials = credentialsFormValues.values @@ -163,13 +163,15 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) }, { onSuccess: () => { - toast.success(t($ => $['modal.apiKey.verify.success'], { ns: 'pluginTrigger' })) + toast.success(t(($) => $['modal.apiKey.verify.success'], { ns: 'pluginTrigger' })) // Only save credentials if any field was modified (not all hidden) setVerifiedCredentials(areAllCredentialsHidden(credentials) ? null : credentials) setCurrentStep(EditStep.EditConfiguration) }, onError: async (error: unknown) => { - const errorMessage = await parsePluginErrorMessage(error) || t($ => $['modal.apiKey.verify.error'], { ns: 'pluginTrigger' }) + const errorMessage = + (await parsePluginErrorMessage(error)) || + t(($) => $['modal.apiKey.verify.error'], { ns: 'pluginTrigger' }) toast.error(errorMessage) }, }, @@ -178,8 +180,7 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) const handleUpdate = () => { const basicFormValues = basicFormRef.current?.getFormValues({}) - if (!basicFormValues?.isCheckValidated) - return + if (!basicFormValues?.isCheckValidated) return const name = basicFormValues.values.subscription_name as string @@ -189,8 +190,7 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) const paramsFormValues = parametersFormRef.current?.getFormValues({ needTransformWhenSecretFieldIsPristine: true, }) - if (!paramsFormValues?.isCheckValidated) - return + if (!paramsFormValues?.isCheckValidated) return // Only send parameters if changed const hasChanged = !isEqual(paramsFormValues.values, subscription.parameters || {}) @@ -206,12 +206,16 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) }, { onSuccess: () => { - toast.success(t($ => $['subscription.list.item.actions.edit.success'], { ns: 'pluginTrigger' })) + toast.success( + t(($) => $['subscription.list.item.actions.edit.success'], { ns: 'pluginTrigger' }), + ) refetch?.() onClose() }, onError: async (error: unknown) => { - const errorMessage = await parsePluginErrorMessage(error) || t($ => $['subscription.list.item.actions.edit.error'], { ns: 'pluginTrigger' }) + const errorMessage = + (await parsePluginErrorMessage(error)) || + t(($) => $['subscription.list.item.actions.edit.error'], { ns: 'pluginTrigger' }) toast.error(errorMessage) }, }, @@ -219,36 +223,39 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) } const handleConfirm = () => { - if (currentStep === EditStep.EditCredentials) - handleVerifyCredentials() - else - handleUpdate() + if (currentStep === EditStep.EditCredentials) handleVerifyCredentials() + else handleUpdate() } - const basicFormSchemas: FormSchema[] = useMemo(() => [ - { - name: 'subscription_name', - label: t($ => $['modal.form.subscriptionName.label'], { ns: 'pluginTrigger' }), - placeholder: t($ => $['modal.form.subscriptionName.placeholder'], { ns: 'pluginTrigger' }), - type: FormTypeEnum.textInput, - required: true, - default: subscription.name, - }, - { - name: 'callback_url', - label: t($ => $['modal.form.callbackUrl.label'], { ns: 'pluginTrigger' }), - placeholder: t($ => $['modal.form.callbackUrl.placeholder'], { ns: 'pluginTrigger' }), - type: FormTypeEnum.textInput, - required: false, - default: subscription.endpoint || '', - disabled: true, - tooltip: t($ => $['modal.form.callbackUrl.tooltip'], { ns: 'pluginTrigger' }), - showCopy: true, - }, - ], [t, subscription.name, subscription.endpoint]) + const basicFormSchemas: FormSchema[] = useMemo( + () => [ + { + name: 'subscription_name', + label: t(($) => $['modal.form.subscriptionName.label'], { ns: 'pluginTrigger' }), + placeholder: t(($) => $['modal.form.subscriptionName.placeholder'], { + ns: 'pluginTrigger', + }), + type: FormTypeEnum.textInput, + required: true, + default: subscription.name, + }, + { + name: 'callback_url', + label: t(($) => $['modal.form.callbackUrl.label'], { ns: 'pluginTrigger' }), + placeholder: t(($) => $['modal.form.callbackUrl.placeholder'], { ns: 'pluginTrigger' }), + type: FormTypeEnum.textInput, + required: false, + default: subscription.endpoint || '', + disabled: true, + tooltip: t(($) => $['modal.form.callbackUrl.tooltip'], { ns: 'pluginTrigger' }), + showCopy: true, + }, + ], + [t, subscription.name, subscription.endpoint], + ) const credentialsFormSchemas: FormSchema[] = useMemo(() => { - return apiKeyCredentialsSchema.map(schema => ({ + return apiKeyCredentialsSchema.map((schema) => ({ ...schema, type: normalizeFormType(schema.type as string), tooltip: schema.help, @@ -264,27 +271,40 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) type: normalizedType, tooltip: schema.description, default: subscription.parameters?.[schema.name] || schema.default, - dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect - ? { - plugin_id: detail?.plugin_id || '', - provider: detail?.provider || '', - action: 'provider', - parameter: schema.name, - credential_id: subscription.id, - credentials: verifiedCredentials || undefined, - } - : undefined, - fieldClassName: schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined, + dynamicSelectParams: + normalizedType === FormTypeEnum.dynamicSelect + ? { + plugin_id: detail?.plugin_id || '', + provider: detail?.provider || '', + action: 'provider', + parameter: schema.name, + credential_id: subscription.id, + credentials: verifiedCredentials || undefined, + } + : undefined, + fieldClassName: + schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined, labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined, } }) - }, [parametersSchema, subscription.parameters, subscription.id, detail?.plugin_id, detail?.provider, verifiedCredentials]) + }, [ + parametersSchema, + subscription.parameters, + subscription.id, + detail?.plugin_id, + detail?.provider, + verifiedCredentials, + ]) const confirmButtonText = (() => { if (currentStep === EditStep.EditCredentials) - return isVerifying ? t($ => $['modal.common.verifying'], { ns: 'pluginTrigger' }) : t($ => $['modal.common.verify'], { ns: 'pluginTrigger' }) + return isVerifying + ? t(($) => $['modal.common.verifying'], { ns: 'pluginTrigger' }) + : t(($) => $['modal.common.verify'], { ns: 'pluginTrigger' }) - return isUpdating ? t($ => $['operation.saving'], { ns: 'common' }) : t($ => $['operation.save'], { ns: 'common' }) + return isUpdating + ? t(($) => $['operation.saving'], { ns: 'common' }) + : t(($) => $['operation.save'], { ns: 'common' }) })() const handleBack = () => { @@ -293,89 +313,82 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) } const isDisabled = isUpdating || isVerifying - const title = t($ => $['subscription.list.item.actions.edit.title'], { ns: 'pluginTrigger' }) + const title = t(($) => $['subscription.list.item.actions.edit.title'], { ns: 'pluginTrigger' }) return ( <Dialog open disablePointerDismissal onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > - <DialogContent - backdropProps={{ forceRender: true }} - className="p-0" - > - <div data-testid="modal" data-title={title} data-disabled={isDisabled} className="flex max-h-[80dvh] flex-col"> + <DialogContent backdropProps={{ forceRender: true }} className="p-0"> + <div + data-testid="modal" + data-title={title} + data-disabled={isDisabled} + className="flex max-h-[80dvh] flex-col" + > <div className="relative shrink-0 p-6 pr-14 pb-3"> - <DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary"> + <DialogTitle + data-testid="modal-title" + className="title-2xl-semi-bold text-text-primary" + > {title} </DialogTitle> <DialogCloseButton className="top-5 right-5 size-8 rounded-lg" /> </div> <div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> - {pluginDetail && ( - <ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" /> - )} + {pluginDetail && <ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />} <MultiSteps currentStep={currentStep} onStepClick={handleBack} /> - {currentStep === EditStep.EditCredentials - ? ( - <div className="mb-4"> - {credentialsFormSchemas.length > 0 && ( - <BaseForm - formSchemas={credentialsFormSchemas} - ref={credentialsFormRef} - labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" - formClassName="space-y-4" - preventDefaultSubmit={true} - /> - )} - </div> - ) - : ( - <div className="max-h-[70vh]"> - <BaseForm - formSchemas={basicFormSchemas} - ref={basicFormRef} - labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" - formClassName="space-y-4 mb-4" - /> - - {parametersFormSchemas.length > 0 && ( - <BaseForm - formSchemas={parametersFormSchemas} - ref={parametersFormRef} - labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" - formClassName="space-y-4" - /> - )} - </div> + {currentStep === EditStep.EditCredentials ? ( + <div className="mb-4"> + {credentialsFormSchemas.length > 0 && ( + <BaseForm + formSchemas={credentialsFormSchemas} + ref={credentialsFormRef} + labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" + formClassName="space-y-4" + preventDefaultSubmit={true} + /> )} + </div> + ) : ( + <div className="max-h-[70vh]"> + <BaseForm + formSchemas={basicFormSchemas} + ref={basicFormRef} + labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" + formClassName="space-y-4 mb-4" + /> + + {parametersFormSchemas.length > 0 && ( + <BaseForm + formSchemas={parametersFormSchemas} + ref={parametersFormRef} + labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" + formClassName="space-y-4" + /> + )} + </div> + )} </div> <div className="flex shrink-0 justify-between p-6 pt-5"> <div /> <div className="flex items-center"> {currentStep === EditStep.EditConfiguration && ( <> - <Button - variant="secondary" - onClick={handleBack} - disabled={isDisabled} - > - {t($ => $['modal.common.back'], { ns: 'pluginTrigger' })} + <Button variant="secondary" onClick={handleBack} disabled={isDisabled}> + {t(($) => $['modal.common.back'], { ns: 'pluginTrigger' })} </Button> <div className="mx-3 h-4 w-px bg-divider-regular"></div> </> )} - <Button - onClick={onClose} - disabled={isDisabled} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button onClick={onClose} disabled={isDisabled}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button className="ml-2" diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.tsx index 199df8377bbef8..2c7df245222cac 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.tsx @@ -17,11 +17,25 @@ export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => { switch (credentialType) { case TriggerCredentialTypeEnum.Unauthorized: - return <ManualEditModal onClose={onClose} subscription={subscription} pluginDetail={pluginDetail} /> + return ( + <ManualEditModal + onClose={onClose} + subscription={subscription} + pluginDetail={pluginDetail} + /> + ) case TriggerCredentialTypeEnum.Oauth2: - return <OAuthEditModal onClose={onClose} subscription={subscription} pluginDetail={pluginDetail} /> + return ( + <OAuthEditModal onClose={onClose} subscription={subscription} pluginDetail={pluginDetail} /> + ) case TriggerCredentialTypeEnum.ApiKey: - return <ApiKeyEditModal onClose={onClose} subscription={subscription} pluginDetail={pluginDetail} /> + return ( + <ApiKeyEditModal + onClose={onClose} + subscription={subscription} + pluginDetail={pluginDetail} + /> + ) default: return null } diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx index df0f9daf22a238..86c2d0988eb6c9 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx @@ -37,26 +37,23 @@ const normalizeFormType = (type: string): FormTypeEnum => { case 'select': return FormTypeEnum.select default: - if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) - return type as FormTypeEnum + if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) return type as FormTypeEnum return FormTypeEnum.textInput } } export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props) => { const { t } = useTranslation() - const detail = usePluginStore(state => state.detail) + const detail = usePluginStore((state) => state.detail) const { refetch } = useSubscriptionList() const { mutate: updateSubscription, isPending: isUpdating } = useUpdateTriggerSubscription() const getErrorMessage = (error: unknown, fallback: string) => { - if (error instanceof Error && error.message) - return error.message + if (error instanceof Error && error.message) return error.message if (typeof error === 'object' && error && 'message' in error) { const message = (error as { message?: string }).message - if (typeof message === 'string' && message) - return message + if (typeof message === 'string' && message) return message } return fallback } @@ -72,8 +69,7 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props) const formValues = formRef.current?.getFormValues({ needTransformWhenSecretFieldIsPristine: true, }) - if (!formValues?.isCheckValidated) - return + if (!formValues?.isCheckValidated) return const name = formValues.values.subscription_name as string @@ -94,72 +90,88 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props) }, { onSuccess: () => { - toast.success(t($ => $['subscription.list.item.actions.edit.success'], { ns: 'pluginTrigger' })) + toast.success( + t(($) => $['subscription.list.item.actions.edit.success'], { ns: 'pluginTrigger' }), + ) refetch?.() onClose() }, onError: (error: unknown) => { - toast.error(getErrorMessage(error, t($ => $['subscription.list.item.actions.edit.error'], { ns: 'pluginTrigger' }))) + toast.error( + getErrorMessage( + error, + t(($) => $['subscription.list.item.actions.edit.error'], { ns: 'pluginTrigger' }), + ), + ) }, }, ) } - const formSchemas: FormSchema[] = useMemo(() => [ - { - name: 'subscription_name', - label: t($ => $['modal.form.subscriptionName.label'], { ns: 'pluginTrigger' }), - placeholder: t($ => $['modal.form.subscriptionName.placeholder'], { ns: 'pluginTrigger' }), - type: FormTypeEnum.textInput, - required: true, - default: subscription.name, - }, - { - name: 'callback_url', - label: t($ => $['modal.form.callbackUrl.label'], { ns: 'pluginTrigger' }), - placeholder: t($ => $['modal.form.callbackUrl.placeholder'], { ns: 'pluginTrigger' }), - type: FormTypeEnum.textInput, - required: false, - default: subscription.endpoint || '', - disabled: true, - tooltip: t($ => $['modal.form.callbackUrl.tooltip'], { ns: 'pluginTrigger' }), - showCopy: true, - }, - ...propertiesSchema.map((schema: ParametersSchema) => ({ - ...schema, - type: normalizeFormType(schema.type as string), - tooltip: schema.description, - default: subscription.properties?.[schema.name] || schema.default, - })), - ], [t, subscription.name, subscription.endpoint, subscription.properties, propertiesSchema]) + const formSchemas: FormSchema[] = useMemo( + () => [ + { + name: 'subscription_name', + label: t(($) => $['modal.form.subscriptionName.label'], { ns: 'pluginTrigger' }), + placeholder: t(($) => $['modal.form.subscriptionName.placeholder'], { + ns: 'pluginTrigger', + }), + type: FormTypeEnum.textInput, + required: true, + default: subscription.name, + }, + { + name: 'callback_url', + label: t(($) => $['modal.form.callbackUrl.label'], { ns: 'pluginTrigger' }), + placeholder: t(($) => $['modal.form.callbackUrl.placeholder'], { ns: 'pluginTrigger' }), + type: FormTypeEnum.textInput, + required: false, + default: subscription.endpoint || '', + disabled: true, + tooltip: t(($) => $['modal.form.callbackUrl.tooltip'], { ns: 'pluginTrigger' }), + showCopy: true, + }, + ...propertiesSchema.map((schema: ParametersSchema) => ({ + ...schema, + type: normalizeFormType(schema.type as string), + tooltip: schema.description, + default: subscription.properties?.[schema.name] || schema.default, + })), + ], + [t, subscription.name, subscription.endpoint, subscription.properties, propertiesSchema], + ) - const title = t($ => $['subscription.list.item.actions.edit.title'], { ns: 'pluginTrigger' }) - const confirmButtonText = isUpdating ? t($ => $['operation.saving'], { ns: 'common' }) : t($ => $['operation.save'], { ns: 'common' }) + const title = t(($) => $['subscription.list.item.actions.edit.title'], { ns: 'pluginTrigger' }) + const confirmButtonText = isUpdating + ? t(($) => $['operation.saving'], { ns: 'common' }) + : t(($) => $['operation.save'], { ns: 'common' }) return ( <Dialog open disablePointerDismissal onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > - <DialogContent - backdropProps={{ forceRender: true }} - className="p-0" - > - <div data-testid="modal" data-title={title} data-disabled={isUpdating} className="flex max-h-[80dvh] flex-col"> + <DialogContent backdropProps={{ forceRender: true }} className="p-0"> + <div + data-testid="modal" + data-title={title} + data-disabled={isUpdating} + className="flex max-h-[80dvh] flex-col" + > <div className="relative shrink-0 p-6 pr-14 pb-3"> - <DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary"> + <DialogTitle + data-testid="modal-title" + className="title-2xl-semi-bold text-text-primary" + > {title} </DialogTitle> <DialogCloseButton className="top-5 right-5 size-8 rounded-lg" /> </div> <div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> - {pluginDetail && ( - <ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" /> - )} + {pluginDetail && <ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />} <BaseForm formSchemas={formSchemas} ref={formRef} @@ -170,11 +182,8 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props) <div className="flex shrink-0 justify-between p-6 pt-5"> <div /> <div className="flex items-center"> - <Button - onClick={onClose} - disabled={isUpdating} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button onClick={onClose} disabled={isUpdating}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button className="ml-2" diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx index b6c0dadda07d67..96d51b63db18ec 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx @@ -37,26 +37,23 @@ const normalizeFormType = (type: string): FormTypeEnum => { case 'select': return FormTypeEnum.select default: - if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) - return type as FormTypeEnum + if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) return type as FormTypeEnum return FormTypeEnum.textInput } } export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) => { const { t } = useTranslation() - const detail = usePluginStore(state => state.detail) + const detail = usePluginStore((state) => state.detail) const { refetch } = useSubscriptionList() const { mutate: updateSubscription, isPending: isUpdating } = useUpdateTriggerSubscription() const getErrorMessage = (error: unknown, fallback: string) => { - if (error instanceof Error && error.message) - return error.message + if (error instanceof Error && error.message) return error.message if (typeof error === 'object' && error && 'message' in error) { const message = (error as { message?: string }).message - if (typeof message === 'string' && message) - return message + if (typeof message === 'string' && message) return message } return fallback } @@ -72,8 +69,7 @@ export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) = const formValues = formRef.current?.getFormValues({ needTransformWhenSecretFieldIsPristine: true, }) - if (!formValues?.isCheckValidated) - return + if (!formValues?.isCheckValidated) return const name = formValues.values.subscription_name as string @@ -94,86 +90,113 @@ export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) = }, { onSuccess: () => { - toast.success(t($ => $['subscription.list.item.actions.edit.success'], { ns: 'pluginTrigger' })) + toast.success( + t(($) => $['subscription.list.item.actions.edit.success'], { ns: 'pluginTrigger' }), + ) refetch?.() onClose() }, onError: (error: unknown) => { - toast.error(getErrorMessage(error, t($ => $['subscription.list.item.actions.edit.error'], { ns: 'pluginTrigger' }))) + toast.error( + getErrorMessage( + error, + t(($) => $['subscription.list.item.actions.edit.error'], { ns: 'pluginTrigger' }), + ), + ) }, }, ) } - const formSchemas: FormSchema[] = useMemo(() => [ - { - name: 'subscription_name', - label: t($ => $['modal.form.subscriptionName.label'], { ns: 'pluginTrigger' }), - placeholder: t($ => $['modal.form.subscriptionName.placeholder'], { ns: 'pluginTrigger' }), - type: FormTypeEnum.textInput, - required: true, - default: subscription.name, - }, - { - name: 'callback_url', - label: t($ => $['modal.form.callbackUrl.label'], { ns: 'pluginTrigger' }), - placeholder: t($ => $['modal.form.callbackUrl.placeholder'], { ns: 'pluginTrigger' }), - type: FormTypeEnum.textInput, - required: false, - default: subscription.endpoint || '', - disabled: true, - tooltip: t($ => $['modal.form.callbackUrl.tooltip'], { ns: 'pluginTrigger' }), - showCopy: true, - }, - ...parametersSchema.map((schema: ParametersSchema) => { - const normalizedType = normalizeFormType(schema.type as string) - return { - ...schema, - type: normalizedType, - tooltip: schema.description, - default: subscription.parameters?.[schema.name] || schema.default, - dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect - ? { - plugin_id: detail?.plugin_id || '', - provider: detail?.provider || '', - action: 'provider', - parameter: schema.name, - credential_id: subscription.id, - } - : undefined, - fieldClassName: schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined, - labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined, - } - }), - ], [t, subscription.name, subscription.endpoint, subscription.parameters, subscription.id, parametersSchema, detail?.plugin_id, detail?.provider]) + const formSchemas: FormSchema[] = useMemo( + () => [ + { + name: 'subscription_name', + label: t(($) => $['modal.form.subscriptionName.label'], { ns: 'pluginTrigger' }), + placeholder: t(($) => $['modal.form.subscriptionName.placeholder'], { + ns: 'pluginTrigger', + }), + type: FormTypeEnum.textInput, + required: true, + default: subscription.name, + }, + { + name: 'callback_url', + label: t(($) => $['modal.form.callbackUrl.label'], { ns: 'pluginTrigger' }), + placeholder: t(($) => $['modal.form.callbackUrl.placeholder'], { ns: 'pluginTrigger' }), + type: FormTypeEnum.textInput, + required: false, + default: subscription.endpoint || '', + disabled: true, + tooltip: t(($) => $['modal.form.callbackUrl.tooltip'], { ns: 'pluginTrigger' }), + showCopy: true, + }, + ...parametersSchema.map((schema: ParametersSchema) => { + const normalizedType = normalizeFormType(schema.type as string) + return { + ...schema, + type: normalizedType, + tooltip: schema.description, + default: subscription.parameters?.[schema.name] || schema.default, + dynamicSelectParams: + normalizedType === FormTypeEnum.dynamicSelect + ? { + plugin_id: detail?.plugin_id || '', + provider: detail?.provider || '', + action: 'provider', + parameter: schema.name, + credential_id: subscription.id, + } + : undefined, + fieldClassName: + schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined, + labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined, + } + }), + ], + [ + t, + subscription.name, + subscription.endpoint, + subscription.parameters, + subscription.id, + parametersSchema, + detail?.plugin_id, + detail?.provider, + ], + ) - const title = t($ => $['subscription.list.item.actions.edit.title'], { ns: 'pluginTrigger' }) - const confirmButtonText = isUpdating ? t($ => $['operation.saving'], { ns: 'common' }) : t($ => $['operation.save'], { ns: 'common' }) + const title = t(($) => $['subscription.list.item.actions.edit.title'], { ns: 'pluginTrigger' }) + const confirmButtonText = isUpdating + ? t(($) => $['operation.saving'], { ns: 'common' }) + : t(($) => $['operation.save'], { ns: 'common' }) return ( <Dialog open disablePointerDismissal onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > - <DialogContent - backdropProps={{ forceRender: true }} - className="p-0" - > - <div data-testid="modal" data-title={title} data-disabled={isUpdating} className="flex max-h-[80dvh] flex-col"> + <DialogContent backdropProps={{ forceRender: true }} className="p-0"> + <div + data-testid="modal" + data-title={title} + data-disabled={isUpdating} + className="flex max-h-[80dvh] flex-col" + > <div className="relative shrink-0 p-6 pr-14 pb-3"> - <DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary"> + <DialogTitle + data-testid="modal-title" + className="title-2xl-semi-bold text-text-primary" + > {title} </DialogTitle> <DialogCloseButton className="top-5 right-5 size-8 rounded-lg" /> </div> <div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> - {pluginDetail && ( - <ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" /> - )} + {pluginDetail && <ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />} <BaseForm formSchemas={formSchemas} ref={formRef} @@ -184,11 +207,8 @@ export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) = <div className="flex shrink-0 justify-between p-6 pt-5"> <div /> <div className="flex items-center"> - <Button - onClick={onClose} - disabled={isUpdating} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button onClick={onClose} disabled={isUpdating}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button className="ml-2" diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx index c152b8ffbf5946..867e2f36657fd0 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx @@ -16,31 +16,33 @@ type SubscriptionListProps = { export type { SimpleSubscription } from './types' -export const SubscriptionList = withErrorBoundary(({ - mode = SubscriptionListMode.PANEL, - selectedId, - onSelect, - pluginDetail, -}: SubscriptionListProps) => { - const { isLoading, refetch } = useSubscriptionList() - if (isLoading) { - return ( - <div className="flex items-center justify-center py-4"> - <Loading /> - </div> - ) - } +export const SubscriptionList = withErrorBoundary( + ({ + mode = SubscriptionListMode.PANEL, + selectedId, + onSelect, + pluginDetail, + }: SubscriptionListProps) => { + const { isLoading, refetch } = useSubscriptionList() + if (isLoading) { + return ( + <div className="flex items-center justify-center py-4"> + <Loading /> + </div> + ) + } - if (mode === SubscriptionListMode.SELECTOR) { - return ( - <SubscriptionSelectorView - selectedId={selectedId} - onSelect={(v) => { - onSelect?.(v, refetch) - }} - /> - ) - } + if (mode === SubscriptionListMode.SELECTOR) { + return ( + <SubscriptionSelectorView + selectedId={selectedId} + onSelect={(v) => { + onSelect?.(v, refetch) + }} + /> + ) + } - return <SubscriptionListView pluginDetail={pluginDetail} /> -}) + return <SubscriptionListView pluginDetail={pluginDetail} /> + }, +) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx index 400a7c2dada5ab..8a17186c7b7a0a 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx @@ -29,24 +29,26 @@ export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({ {subscriptionCount > 0 && ( <div className="flex h-8 shrink-0 items-center gap-1"> <span className="system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['subscription.listNum'], { ns: 'pluginTrigger', num: subscriptionCount })} + {t(($) => $['subscription.listNum'], { ns: 'pluginTrigger', num: subscriptionCount })} </span> <Infotip - aria-label={t($ => $['subscription.list.tip'], { ns: 'pluginTrigger' })} + aria-label={t(($) => $['subscription.list.tip'], { ns: 'pluginTrigger' })} className="size-3.5" > - {t($ => $['subscription.list.tip'], { ns: 'pluginTrigger' })} + {t(($) => $['subscription.list.tip'], { ns: 'pluginTrigger' })} </Infotip> </div> )} <CreateSubscriptionButton - buttonType={subscriptionCount > 0 ? CreateButtonType.ICON_BUTTON : CreateButtonType.FULL_BUTTON} + buttonType={ + subscriptionCount > 0 ? CreateButtonType.ICON_BUTTON : CreateButtonType.FULL_BUTTON + } /> </div> {subscriptionCount > 0 && ( <div className="flex flex-col gap-1"> - {subscriptions?.map(subscription => ( + {subscriptions?.map((subscription) => ( <SubscriptionCard key={subscription.id} data={subscription} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx index 3c89c7738363bb..7e35a401cf9e1d 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx @@ -32,10 +32,8 @@ const LogViewer = ({ logs, className }: Props) => { const toggleLogExpansion = (logId: string) => { const newExpanded = new Set(expandedLogs) - if (newExpanded.has(logId)) - newExpanded.delete(logId) - else - newExpanded.add(logId) + if (newExpanded.has(logId)) newExpanded.delete(logId) + else newExpanded.add(logId) setExpandedLogs(newExpanded) } @@ -45,25 +43,25 @@ const LogViewer = ({ logs, className }: Props) => { try { const urlDecoded = decodeURIComponent(data.substring(8)) // Remove 'payload=' return JSON.parse(urlDecoded) - } - catch { + } catch { return data } } - if (typeof data === 'object') - return data + if (typeof data === 'object') return data try { return JSON.parse(data) - } - catch { + } catch { return data } } const renderJsonContent = (originalData: any, title: LogTypeEnum) => { - const parsedData = title === LogTypeEnum.REQUEST ? { headers: originalData.headers, data: parseRequestData(originalData.data) } : originalData + const parsedData = + title === LogTypeEnum.REQUEST + ? { headers: originalData.headers, data: parseRequestData(originalData.data) } + : originalData const isJsonObject = typeof parsedData === 'object' if (isJsonObject) { @@ -82,14 +80,12 @@ const LogViewer = ({ logs, className }: Props) => { return ( <div className="rounded-md bg-components-input-bg-normal"> <div className="flex items-center justify-between px-2 py-1"> - <div className="system-xs-semibold-uppercase text-text-secondary"> - {title} - </div> + <div className="system-xs-semibold-uppercase text-text-secondary">{title}</div> <button onClick={(e) => { e.stopPropagation() navigator.clipboard.writeText(String(parsedData)) - toast.success(t($ => $['actionMsg.copySuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.copySuccessfully'], { ns: 'common' })) }} className="rounded-md p-0.5 hover:bg-components-panel-border" > @@ -105,8 +101,7 @@ const LogViewer = ({ logs, className }: Props) => { ) } - if (!logs || logs.length === 0) - return null + if (!logs || logs.length === 0) return null return ( <div className={cn('flex flex-col gap-1', className)}> @@ -128,14 +123,34 @@ const LogViewer = ({ logs, className }: Props) => { > {isError && ( <div className="pointer-events-none absolute top-0 left-0 h-7 w-[179px]"> - <svg xmlns="http://www.w3.org/2000/svg" width="179" height="28" viewBox="0 0 179 28" fill="none" className="size-full"> + <svg + xmlns="http://www.w3.org/2000/svg" + width="179" + height="28" + viewBox="0 0 179 28" + fill="none" + className="size-full" + > <g filter="url(#filter0_f_error_glow)"> <circle cx="27" cy="14" r="32" fill="#F04438" fillOpacity="0.25" /> </g> <defs> - <filter id="filter0_f_error_glow" x="-125" y="-138" width="304" height="304" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"> + <filter + id="filter0_f_error_glow" + x="-125" + y="-138" + width="304" + height="304" + filterUnits="userSpaceOnUse" + colorInterpolationFilters="sRGB" + > <feFlood floodOpacity="0" result="BackgroundImageFix" /> - <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> + <feBlend + mode="normal" + in="SourceGraphic" + in2="BackgroundImageFix" + result="shape" + /> <feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur" /> </filter> </defs> @@ -151,18 +166,14 @@ const LogViewer = ({ logs, className }: Props) => { )} > <div className="flex items-center gap-0"> - {isExpanded - ? ( - <RiArrowDownSLine className="size-4 text-text-tertiary" /> - ) - : ( - <RiArrowRightSLine className="size-4 text-text-tertiary" /> - )} + {isExpanded ? ( + <RiArrowDownSLine className="size-4 text-text-tertiary" /> + ) : ( + <RiArrowRightSLine className="size-4 text-text-tertiary" /> + )} <div className="system-xs-semibold-uppercase text-text-secondary"> - {t($ => $[`modal.manual.logs.${LogTypeEnum.REQUEST}`], { ns: 'pluginTrigger' })} - {' '} - # - {index + 1} + {t(($) => $[`modal.manual.logs.${LogTypeEnum.REQUEST}`], { ns: 'pluginTrigger' })}{' '} + #{index + 1} </div> </div> @@ -171,13 +182,11 @@ const LogViewer = ({ logs, className }: Props) => { {dayjs(log.created_at).format('HH:mm:ss')} </div> <div className="size-3.5"> - {isSuccess - ? ( - <RiCheckboxCircleFill className="size-full text-text-success" /> - ) - : ( - <RiErrorWarningFill className="size-full text-text-destructive" /> - )} + {isSuccess ? ( + <RiCheckboxCircleFill className="size-full text-text-success" /> + ) : ( + <RiErrorWarningFill className="size-full text-text-destructive" /> + )} </div> </div> </button> diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx index 68712cb1578a90..9760e65c182a2c 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx @@ -1,11 +1,7 @@ 'use client' import type { SimpleSubscription } from './types' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowDownSLine, RiWebhookLine } from '@remixicon/react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -31,22 +27,22 @@ const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({ if (!selectedId) { if (isOpen) { return { - label: t($ => $['subscription.selectPlaceholder'], { ns: 'pluginTrigger' }), + label: t(($) => $['subscription.selectPlaceholder'], { ns: 'pluginTrigger' }), color: 'yellow' as const, } } return { - label: t($ => $['subscription.noSubscriptionSelected'], { ns: 'pluginTrigger' }), + label: t(($) => $['subscription.noSubscriptionSelected'], { ns: 'pluginTrigger' }), color: 'red' as const, } } if (subscriptions && subscriptions.length > 0) { - const selectedSubscription = subscriptions.find(sub => sub.id === selectedId) + const selectedSubscription = subscriptions.find((sub) => sub.id === selectedId) if (!selectedSubscription) { return { - label: t($ => $['subscription.subscriptionRemoved'], { ns: 'pluginTrigger' }), + label: t(($) => $['subscription.subscriptionRemoved'], { ns: 'pluginTrigger' }), color: 'red' as const, } } @@ -58,7 +54,7 @@ const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({ } return { - label: t($ => $['subscription.noSubscriptionSelected'], { ns: 'pluginTrigger' }), + label: t(($) => $['subscription.noSubscriptionSelected'], { ns: 'pluginTrigger' }), color: 'red' as const, } }, [selectedId, subscriptions, t, isOpen]) @@ -73,8 +69,18 @@ const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({ className, )} > - <RiWebhookLine className={cn('size-3.5 shrink-0 text-text-secondary', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')} /> - <span className={cn('truncate system-xs-medium text-components-button-ghost-text', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')}> + <RiWebhookLine + className={cn( + 'size-3.5 shrink-0 text-text-secondary', + statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text', + )} + /> + <span + className={cn( + 'truncate system-xs-medium text-components-button-ghost-text', + statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text', + )} + > {statusConfig.label} </span> <RiArrowDownSLine @@ -88,7 +94,10 @@ const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({ ) } -export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: { +export const SubscriptionSelectorEntry = ({ + selectedId, + onSelect, +}: { selectedId?: string onSelect: (v: SimpleSubscription, callback?: () => void) => void }) => { @@ -97,14 +106,11 @@ export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: { return ( <Popover open={isOpen} onOpenChange={setIsOpen}> <PopoverTrigger - render={( + render={ <div> - <SubscriptionTriggerButton - selectedId={selectedId} - isOpen={isOpen} - /> + <SubscriptionTriggerButton selectedId={selectedId} isOpen={isOpen} /> </div> - )} + } /> <PopoverContent placement="bottom-start" diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx index b87b3beee4e76d..4b2797d6753a19 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx @@ -14,7 +14,7 @@ import { useSubscriptionList } from './use-subscription-list' type SubscriptionSelectorProps = { selectedId?: string - onSelect?: ({ id, name }: { id: string, name: string }) => void + onSelect?: ({ id, name }: { id: string; name: string }) => void } export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({ @@ -32,23 +32,20 @@ export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({ <div className="mr-1.5 ml-7 flex h-8 items-center justify-between"> <div className="flex shrink-0 items-center gap-1"> <span className="system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['subscription.listNum'], { ns: 'pluginTrigger', num: subscriptionCount })} + {t(($) => $['subscription.listNum'], { ns: 'pluginTrigger', num: subscriptionCount })} </span> <Infotip - aria-label={t($ => $['subscription.list.tip'], { ns: 'pluginTrigger' })} + aria-label={t(($) => $['subscription.list.tip'], { ns: 'pluginTrigger' })} className="size-3.5" > - {t($ => $['subscription.list.tip'], { ns: 'pluginTrigger' })} + {t(($) => $['subscription.list.tip'], { ns: 'pluginTrigger' })} </Infotip> </div> - <CreateSubscriptionButton - buttonType={CreateButtonType.ICON_BUTTON} - shape="circle" - /> + <CreateSubscriptionButton buttonType={CreateButtonType.ICON_BUTTON} shape="circle" /> </div> )} <div className="max-h-[320px] overflow-y-auto"> - {subscriptions?.map(subscription => ( + {subscriptions?.map((subscription) => ( <div key={subscription.id} className={cn( @@ -66,7 +63,12 @@ export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({ {selectedId === subscription.id && ( <RiCheckLine className="mr-2 size-4 shrink-0 text-text-accent" /> )} - <RiWebhookLine className={cn('mr-1.5 size-3.5 text-text-secondary', selectedId !== subscription.id && 'ml-6')} /> + <RiWebhookLine + className={cn( + 'mr-1.5 size-3.5 text-text-secondary', + selectedId !== subscription.id && 'ml-6', + )} + /> <span className="system-md-regular leading-6 text-text-secondary"> {subscription.name} </span> @@ -87,8 +89,7 @@ export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({ {deletedSubscription && ( <DeleteConfirm onClose={(deleted) => { - if (deleted) - onSelect?.({ id: '', name: '' }) + if (deleted) onSelect?.({ id: '', name: '' }) setDeletedSubscription(null) }} isShow={!!deletedSubscription} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx index b5f6ac80a0f3d1..e672aa7721ef08 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx @@ -3,11 +3,7 @@ import type { PluginDetail } from '@/app/components/plugins/types' import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { - RiDeleteBinLine, - RiEditLine, - RiWebhookLine, -} from '@remixicon/react' +import { RiDeleteBinLine, RiEditLine, RiWebhookLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' @@ -21,14 +17,9 @@ type Props = Readonly<{ const SubscriptionCard = ({ data, pluginDetail }: Props) => { const { t } = useTranslation() - const [isShowDeleteModal, { - setTrue: showDeleteModal, - setFalse: hideDeleteModal, - }] = useBoolean(false) - const [isShowEditModal, { - setTrue: showEditModal, - setFalse: hideEditModal, - }] = useBoolean(false) + const [isShowDeleteModal, { setTrue: showDeleteModal, setFalse: hideDeleteModal }] = + useBoolean(false) + const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false) return ( <> @@ -43,9 +34,7 @@ const SubscriptionCard = ({ data, pluginDetail }: Props) => { <div className="flex items-center justify-between"> <div className="flex h-6 items-center gap-1"> <RiWebhookLine className="size-4 text-text-secondary" /> - <span className="system-md-semibold text-text-secondary"> - {data.name} - </span> + <span className="system-md-semibold text-text-secondary">{data.name}</span> </div> <div className="hidden items-center gap-1 group-hover:flex"> @@ -65,29 +54,35 @@ const SubscriptionCard = ({ data, pluginDetail }: Props) => { </div> <div className="mt-1 flex items-center justify-between"> - {data.endpoint - ? ( - <Popover> - <PopoverTrigger - openOnHover - aria-label={data.endpoint} - className="flex-1 truncate border-0 bg-transparent p-0 text-left system-xs-regular text-text-tertiary" - > - {data.endpoint} - </PopoverTrigger> - <PopoverContent placement="left" popupClassName="max-w-[320px] break-all px-3 py-2 system-xs-regular text-text-tertiary"> - {data.endpoint} - </PopoverContent> - </Popover> - ) - : ( - <div className="flex-1 truncate system-xs-regular text-text-tertiary"> - {data.endpoint} - </div> - )} + {data.endpoint ? ( + <Popover> + <PopoverTrigger + openOnHover + aria-label={data.endpoint} + className="flex-1 truncate border-0 bg-transparent p-0 text-left system-xs-regular text-text-tertiary" + > + {data.endpoint} + </PopoverTrigger> + <PopoverContent + placement="left" + popupClassName="max-w-[320px] break-all px-3 py-2 system-xs-regular text-text-tertiary" + > + {data.endpoint} + </PopoverContent> + </Popover> + ) : ( + <div className="flex-1 truncate system-xs-regular text-text-tertiary"> + {data.endpoint} + </div> + )} <div className="mx-2 text-xs text-text-tertiary opacity-30">·</div> <div className="shrink-0 system-xs-regular text-text-tertiary"> - {data.workflows_in_use > 0 ? t($ => $['subscription.list.item.usedByNum'], { ns: 'pluginTrigger', num: data.workflows_in_use }) : t($ => $['subscription.list.item.noUsed'], { ns: 'pluginTrigger' })} + {data.workflows_in_use > 0 + ? t(($) => $['subscription.list.item.usedByNum'], { + ns: 'pluginTrigger', + num: data.workflows_in_use, + }) + : t(($) => $['subscription.list.item.noUsed'], { ns: 'pluginTrigger' })} </div> </div> </div> @@ -103,11 +98,7 @@ const SubscriptionCard = ({ data, pluginDetail }: Props) => { )} {isShowEditModal && ( - <EditModal - onClose={hideEditModal} - subscription={data} - pluginDetail={pluginDetail} - /> + <EditModal onClose={hideEditModal} subscription={data} pluginDetail={pluginDetail} /> )} </> ) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts index 9f95ff05a0b183..2d0dab4425453a 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts @@ -2,9 +2,13 @@ import { useTriggerSubscriptions } from '@/service/use-triggers' import { usePluginStore } from '../store' export const useSubscriptionList = () => { - const detail = usePluginStore(state => state.detail) + const detail = usePluginStore((state) => state.detail) - const { data: subscriptions, isLoading, refetch } = useTriggerSubscriptions(detail?.provider || '') + const { + data: subscriptions, + isLoading, + refetch, + } = useTriggerSubscriptions(detail?.provider || '') return { detail, diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.helpers.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.helpers.spec.ts index 55faa2682abf90..d3c721a65912e1 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.helpers.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.helpers.spec.ts @@ -58,18 +58,28 @@ describe('reasoning-config-form helpers', () => { }, ] - expect(getVisibleSelectOptions(options as never, { - mode: { value: { value: 'advanced' } }, - }, 'en_US')).toEqual([ + expect( + getVisibleSelectOptions( + options as never, + { + mode: { value: { value: 'advanced' } }, + }, + 'en_US', + ), + ).toEqual([ { value: 'one', name: 'One' }, { value: 'two', name: 'Two' }, ]) - expect(getVisibleSelectOptions(options as never, { - mode: { value: { value: 'basic' } }, - }, 'en_US')).toEqual([ - { value: 'one', name: 'One' }, - ]) + expect( + getVisibleSelectOptions( + options as never, + { + mode: { value: { value: 'basic' } }, + }, + 'en_US', + ), + ).toEqual([{ value: 'one', name: 'One' }]) }) it('updates reasoning values for auto, constant, variable, and merged states', () => { @@ -133,30 +143,36 @@ describe('reasoning-config-form helpers', () => { }) it('derives field flags and picker props from schema types', () => { - expect(getFieldFlags(FormTypeEnum.object, { type: VarKindType.constant })).toEqual(expect.objectContaining({ - isObject: true, - isShowJSONEditor: true, - showTypeSwitch: true, - isConstant: true, - })) - - expect(createPickerProps({ - type: FormTypeEnum.select, - value: {}, - language: 'en_US', - schema: { - options: [ - { - value: 'one', - label: { en_US: 'One', zh_Hans: 'One' }, - show_on: [], - }, - ], - } as never, - })).toEqual(expect.objectContaining({ - targetVarType: VarType.string, - selectItems: [{ value: 'one', name: 'One' }], - })) + expect(getFieldFlags(FormTypeEnum.object, { type: VarKindType.constant })).toEqual( + expect.objectContaining({ + isObject: true, + isShowJSONEditor: true, + showTypeSwitch: true, + isConstant: true, + }), + ) + + expect( + createPickerProps({ + type: FormTypeEnum.select, + value: {}, + language: 'en_US', + schema: { + options: [ + { + value: 'one', + label: { en_US: 'One', zh_Hans: 'One' }, + show_on: [], + }, + ], + } as never, + }), + ).toEqual( + expect.objectContaining({ + targetVarType: VarType.string, + selectItems: [{ value: 'one', name: 'One' }], + }), + ) }) it('provides label helpers', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx index 50db3887b05063..982e9564a5645d 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx @@ -9,8 +9,21 @@ import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/typ import ReasoningConfigForm from '../reasoning-config-form' vi.mock('@/app/components/base/input', () => ({ - default: ({ value, onChange, placeholder }: { value?: string, onChange: (e: { target: { value: string } }) => void, placeholder?: string }) => ( - <input data-testid="number-input" placeholder={placeholder} value={value} onChange={e => onChange({ target: { value: e.target.value } })} /> + default: ({ + value, + onChange, + placeholder, + }: { + value?: string + onChange: (e: { target: { value: string } }) => void + placeholder?: string + }) => ( + <input + data-testid="number-input" + placeholder={placeholder} + value={value} + onChange={(e) => onChange({ target: { value: e.target.value } })} + /> ), })) @@ -21,7 +34,10 @@ vi.mock('@langgenius/dify-ui/select', async () => { }>({}) return { - Select: ({ children, onValueChange }: { + Select: ({ + children, + onValueChange, + }: { children: React.ReactNode onValueChange?: (value: string) => void }) => ( @@ -33,10 +49,15 @@ vi.mock('@langgenius/dify-ui/select', async () => { <button type="button">{children}</button> ), SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => { const context = React.useContext(SelectContext) return ( - <button key={value} data-testid={`select-${value}`} type="button" onClick={() => context.onValueChange?.(value)}> + <button + key={value} + data-testid={`select-${value}`} + type="button" + onClick={() => context.onValueChange?.(value)} + > {children} </button> ) @@ -47,7 +68,13 @@ vi.mock('@langgenius/dify-ui/select', async () => { }) vi.mock('@langgenius/dify-ui/switch', () => ({ - Switch: ({ checked, onCheckedChange }: { checked: boolean, onCheckedChange: (checked: boolean) => void }) => ( + Switch: ({ + checked, + onCheckedChange, + }: { + checked: boolean + onCheckedChange: (checked: boolean) => void + }) => ( <button data-testid="auto-switch" onClick={() => onCheckedChange(!checked)}> {checked ? 'on' : 'off'} </button> @@ -59,7 +86,13 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ - AppSelector: ({ onSelect, scope }: { onSelect: (value: AppSelectorValue) => void, scope?: string }) => ( + AppSelector: ({ + onSelect, + scope, + }: { + onSelect: (value: AppSelectorValue) => void + scope?: string + }) => ( <button data-testid="app-selector" data-scope={scope} @@ -79,7 +112,13 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ onChange, placeholder }: { onChange: (value: string) => void, placeholder?: ReactNode }) => ( + default: ({ + onChange, + placeholder, + }: { + onChange: (value: string) => void + placeholder?: ReactNode + }) => ( <div> <div data-testid="code-editor-placeholder">{placeholder}</div> <button data-testid="code-editor" onClick={() => onChange('{"foo":"bar"}')}> @@ -106,8 +145,18 @@ vi.mock('@/app/components/workflow/nodes/_base/components/form-input-type-switch })) vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ - default: ({ onChange, value }: { onChange: (value: string) => void, value: string | string[] }) => ( - <button data-testid="var-picker" data-value={JSON.stringify(value)} onClick={() => onChange(['node', 'field'] as unknown as string)}> + default: ({ + onChange, + value, + }: { + onChange: (value: string) => void + value: string | string[] + }) => ( + <button + data-testid="var-picker" + data-value={JSON.stringify(value)} + onClick={() => onChange(['node', 'field'] as unknown as string)} + > Pick Variable </button> ), @@ -122,32 +171,40 @@ vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-inp })) vi.mock('../schema-modal', () => ({ - default: ({ isShow, rootName, onClose }: { isShow: boolean, rootName: string, onClose: () => void }) => ( - isShow - ? ( - <div data-testid="schema-modal"> - <span>{rootName}</span> - <button data-testid="close-schema" onClick={onClose}>Close</button> - </div> - ) - : null - ), + default: ({ + isShow, + rootName, + onClose, + }: { + isShow: boolean + rootName: string + onClose: () => void + }) => + isShow ? ( + <div data-testid="schema-modal"> + <span>{rootName}</span> + <button data-testid="close-schema" onClick={onClose}> + Close + </button> + </div> + ) : null, })) -const createSchema = (overrides: Partial<ToolFormSchema> = {}): ToolFormSchema => ({ - variable: 'field', - type: FormTypeEnum.textInput, - default: '', - required: false, - label: { en_US: 'Field', zh_Hans: '字段' }, - tooltip: { en_US: 'Tooltip', zh_Hans: '提示' }, - scope: 'all', - url: '', - input_schema: {}, - placeholder: { en_US: 'Placeholder', zh_Hans: '占位符' }, - options: [], - ...overrides, -} as ToolFormSchema) +const createSchema = (overrides: Partial<ToolFormSchema> = {}): ToolFormSchema => + ({ + variable: 'field', + type: FormTypeEnum.textInput, + default: '', + required: false, + label: { en_US: 'Field', zh_Hans: '字段' }, + tooltip: { en_US: 'Tooltip', zh_Hans: '提示' }, + scope: 'all', + url: '', + input_schema: {}, + placeholder: { en_US: 'Placeholder', zh_Hans: '占位符' }, + options: [], + ...overrides, + }) as ToolFormSchema describe('ReasoningConfigForm', () => { beforeEach(() => { @@ -201,7 +258,12 @@ describe('ReasoningConfigForm', () => { onChange={onChange} schemas={[ createSchema({ variable: 'field', type: FormTypeEnum.textInput }), - createSchema({ variable: 'count', type: FormTypeEnum.textNumber, default: '5', label: { en_US: 'Count', zh_Hans: '数量' } }), + createSchema({ + variable: 'count', + type: FormTypeEnum.textNumber, + default: '5', + label: { en_US: 'Count', zh_Hans: '数量' }, + }), ]} nodeOutputVars={[]} availableNodes={[]} @@ -212,18 +274,24 @@ describe('ReasoningConfigForm', () => { fireEvent.click(screen.getByTestId('mixed-input')) fireEvent.click(screen.getByTestId('type-switch')) - expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ - field: { - auto: 0, - value: { type: VarKindType.mixed, value: 'updated-text' }, - }, - })) - expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ - count: { - auto: 0, - value: { type: VarKindType.variable, value: '' }, - }, - })) + expect(onChange).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + field: { + auto: 0, + value: { type: VarKindType.mixed, value: 'updated-text' }, + }, + }), + ) + expect(onChange).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + count: { + auto: 0, + value: { type: VarKindType.variable, value: '' }, + }, + }), + ) }) it('should open schema modal for object fields and support app selection', () => { @@ -261,21 +329,25 @@ describe('ReasoningConfigForm', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.clickToViewParameterSchema' })) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.nodes.agent.clickToViewParameterSchema' }), + ) expect(screen.getByTestId('schema-modal')).toHaveTextContent('Config') fireEvent.click(screen.getByTestId('close-schema')) fireEvent.click(screen.getByTestId('app-selector')) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - app: { - auto: 0, - value: { - type: undefined, - value: { app_id: 'app-1', inputs: { topic: 'hello' }, files: [] }, + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + app: { + auto: 0, + value: { + type: undefined, + value: { app_id: 'app-1', inputs: { topic: 'hello' }, files: [] }, + }, }, - }, - })) + }), + ) }) it('should merge model selector values into the current field value', () => { @@ -418,24 +490,33 @@ describe('ReasoningConfigForm', () => { fireEvent.click(screen.getByTestId('boolean-input')) fireEvent.click(screen.getByTestId('select-beta')) - expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ - count: { - auto: 0, - value: { type: VarKindType.constant, value: '7' }, - }, - })) - expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ - enabled: { - auto: 0, - value: { type: VarKindType.constant, value: true }, - }, - })) - expect(onChange).toHaveBeenNthCalledWith(3, expect.objectContaining({ - choice: { - auto: 0, - value: { type: VarKindType.constant, value: 'beta' }, - }, - })) + expect(onChange).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + count: { + auto: 0, + value: { type: VarKindType.constant, value: '7' }, + }, + }), + ) + expect(onChange).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + enabled: { + auto: 0, + value: { type: VarKindType.constant, value: true }, + }, + }), + ) + expect(onChange).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + choice: { + auto: 0, + value: { type: VarKindType.constant, value: 'beta' }, + }, + }), + ) }) it('should render selected select values and update object json fields', () => { @@ -490,12 +571,14 @@ describe('ReasoningConfigForm', () => { fireEvent.click(screen.getByTestId('code-editor')) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - config: { - auto: 0, - value: { type: VarKindType.constant, value: '{"foo":"bar"}' }, - }, - })) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + config: { + auto: 0, + value: { type: VarKindType.constant, value: '{"foo":"bar"}' }, + }, + }), + ) }) it('should render json placeholders, default app scope, variable links, and helper urls', () => { @@ -545,6 +628,9 @@ describe('ReasoningConfigForm', () => { expect(screen.getByTestId('code-editor-placeholder')).toHaveTextContent('"foo": "bar"') expect(screen.getByTestId('app-selector')).toHaveAttribute('data-scope', 'all') expect(screen.getByTestId('var-picker')).toHaveAttribute('data-value', '[]') - expect(screen.getByRole('link', { name: 'tools.howToGet' })).toHaveAttribute('href', 'https://example.com/help') + expect(screen.getByRole('link', { name: 'tools.howToGet' })).toHaveAttribute( + 'href', + 'https://example.com/help', + ) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx index b2debdb10fddab..b946e01cb2bdf6 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx @@ -3,20 +3,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import SchemaModal from '../schema-modal' vi.mock('@langgenius/dify-ui/dialog', () => ({ - Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => + Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => open === false ? null : <>{children}</>, - DialogContent: ({ children }: { children: React.ReactNode }) => <div data-testid="modal">{children}</div>, + DialogContent: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="modal">{children}</div> + ), DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, })) -vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor', () => ({ - default: ({ rootName }: { rootName: string }) => <div data-testid="visual-editor">{rootName}</div>, -})) - -vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context', () => ({ - MittProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, - VisualEditorContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, -})) +vi.mock( + '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor', + () => ({ + default: ({ rootName }: { rootName: string }) => ( + <div data-testid="visual-editor">{rootName}</div> + ), + }), +) + +vi.mock( + '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context', + () => ({ + MittProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, + VisualEditorContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, + }), +) describe('SchemaModal', () => { beforeEach(() => { @@ -24,14 +34,7 @@ describe('SchemaModal', () => { }) it('does not render content when hidden', () => { - render( - <SchemaModal - isShow={false} - schema={{} as never} - rootName="root" - onClose={vi.fn()} - />, - ) + render(<SchemaModal isShow={false} schema={{} as never} rootName="root" onClose={vi.fn()} />) expect(screen.queryByTestId('modal')).not.toBeInTheDocument() }) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-authorization-section.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-authorization-section.spec.tsx index 03b684faacaf17..3f15a15a02bf47 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-authorization-section.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-authorization-section.spec.tsx @@ -8,26 +8,26 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({ AuthCategory: { tool: 'tool', }, - PluginAuthInAgent: ({ pluginPayload, credentialId }: { - pluginPayload: { provider: string, providerType: string } + PluginAuthInAgent: ({ + pluginPayload, + credentialId, + }: { + pluginPayload: { provider: string; providerType: string } credentialId?: string }) => ( <div data-testid="plugin-auth-in-agent"> - {pluginPayload.provider} - : - {pluginPayload.providerType} - : - {credentialId} + {pluginPayload.provider}:{pluginPayload.providerType}:{credentialId} </div> ), })) -const createProvider = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({ - name: 'provider-a', - type: CollectionType.builtIn, - allow_delete: true, - ...overrides, -}) as ToolWithProvider +const createProvider = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => + ({ + name: 'provider-a', + type: CollectionType.builtIn, + allow_delete: true, + ...overrides, + }) as ToolWithProvider describe('ToolAuthorizationSection', () => { it('returns null for providers that are not removable built-ins', () => { @@ -59,6 +59,8 @@ describe('ToolAuthorizationSection', () => { />, ) - expect(screen.getByTestId('plugin-auth-in-agent')).toHaveTextContent('provider-a:builtin:credential-1') + expect(screen.getByTestId('plugin-auth-in-agent')).toHaveTextContent( + 'provider-a:builtin:credential-1', + ) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-base-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-base-form.spec.tsx index 342136afcbf98b..0f30ab370681cc 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-base-form.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-base-form.spec.tsx @@ -12,7 +12,7 @@ vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({ })) vi.mock('../tool-trigger', () => ({ - default: ({ value, provider }: { open?: boolean, value?: unknown, provider?: unknown }) => ( + default: ({ value, provider }: { open?: boolean; value?: unknown; provider?: unknown }) => ( <div data-testid="tool-trigger" data-has-value={!!value} data-has-provider={!!provider} /> ), })) @@ -60,14 +60,22 @@ describe('ToolBaseForm', () => { }) it('should enable textarea when value has provider_name', () => { - const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never + const value = { + provider_name: 'test-provider', + tool_name: 'test', + extra: { description: 'Hello' }, + } as never render(<ToolBaseForm {...defaultProps} value={value} />) expect(screen.getByRole('textbox')).not.toBeDisabled() }) it('should call onDescriptionChange when textarea content changes', () => { - const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never + const value = { + provider_name: 'test-provider', + tool_name: 'test', + extra: { description: 'Hello' }, + } as never render(<ToolBaseForm {...defaultProps} value={value} />) fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated' } }) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-item.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-item.spec.tsx index e1e1228dee8026..b32d58cf0f1a00 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-item.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-item.spec.tsx @@ -35,23 +35,13 @@ describe('ToolItem', () => { it('shows auth status actions for no-auth and auth-removed states', () => { const { rerender } = render( - <ToolItem - open={false} - toolLabel="Search Tool" - providerName="acme/search" - noAuth - />, + <ToolItem open={false} toolLabel="Search Tool" providerName="acme/search" noAuth />, ) expect(screen.getByText('tools.notAuthorized')).toBeInTheDocument() rerender( - <ToolItem - open={false} - toolLabel="Search Tool" - providerName="acme/search" - authRemoved - />, + <ToolItem open={false} toolLabel="Search Tool" providerName="acme/search" authRemoved />, ) expect(screen.getByText('plugin.auth.authRemoved')).toBeInTheDocument() @@ -91,12 +81,7 @@ describe('ToolItem', () => { it('blocks unsupported MCP tools and still exposes error state', async () => { mcpAllowed = false const { rerender } = render( - <ToolItem - open={false} - toolLabel="Search Tool" - providerName="acme/search" - isMCPTool - />, + <ToolItem open={false} toolLabel="Search Tool" providerName="acme/search" isMCPTool />, ) expect(screen.getByTestId('mcp-tooltip')).toBeInTheDocument() diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-settings-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-settings-panel.spec.tsx index 56c98f695dd30a..262969aaed22c4 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-settings-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-settings-panel.spec.tsx @@ -8,23 +8,29 @@ vi.mock('@/app/components/base/tab-slider-plain', () => ({ options, onChange, }: { - options: Array<{ value: string, text: string }> + options: Array<{ value: string; text: string }> onChange: (value: string) => void }) => ( <div data-testid="tab-slider"> - {options.map(option => ( - <button key={option.value} onClick={() => onChange(option.value)}>{option.text}</button> + {options.map((option) => ( + <button key={option.value} onClick={() => onChange(option.value)}> + {option.text} + </button> ))} </div> ), })) vi.mock('@/app/components/workflow/nodes/tool/components/tool-form', () => ({ - default: ({ schema }: { schema: Array<{ name: string }> }) => <div data-testid="tool-form">{schema.map(item => item.name).join(',')}</div>, + default: ({ schema }: { schema: Array<{ name: string }> }) => ( + <div data-testid="tool-form">{schema.map((item) => item.name).join(',')}</div> + ), })) vi.mock('../reasoning-config-form', () => ({ - default: ({ schemas }: { schemas: Array<{ name: string }> }) => <div data-testid="reasoning-config-form">{schemas.map(item => item.name).join(',')}</div>, + default: ({ schemas }: { schemas: Array<{ name: string }> }) => ( + <div data-testid="reasoning-config-form">{schemas.map((item) => item.name).join(',')}</div> + ), })) const baseProps = { @@ -57,25 +63,14 @@ describe('ToolSettingsPanel', () => { expect(container).toBeEmptyDOMElement() - rerender( - <ToolSettingsPanel - {...baseProps} - settingsFormSchemas={[]} - paramsFormSchemas={[]} - />, - ) + rerender(<ToolSettingsPanel {...baseProps} settingsFormSchemas={[]} paramsFormSchemas={[]} />) expect(container).toBeEmptyDOMElement() }) it('renders the settings form and lets the tab slider switch to params', () => { const onCurrTypeChange = vi.fn() - render( - <ToolSettingsPanel - {...baseProps} - onCurrTypeChange={onCurrTypeChange} - />, - ) + render(<ToolSettingsPanel {...baseProps} onCurrTypeChange={onCurrTypeChange} />) expect(screen.getByTestId('tool-form')).toHaveTextContent('api_key') fireEvent.click(screen.getByText('plugin.detailPanel.toolSelector.params')) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts index d76fb3ba89d19f..31e4a0bf1207cf 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts @@ -20,33 +20,32 @@ type ReasoningConfigInput = { export type ReasoningConfigValue = Record<string, ReasoningConfigInput> export const getVarKindType = (type: string) => { - if (type === FormTypeEnum.file || type === FormTypeEnum.files) - return VarKindType.variable - - if ([FormTypeEnum.select, FormTypeEnum.checkbox, FormTypeEnum.textNumber, FormTypeEnum.array, FormTypeEnum.object].includes(type as FormTypeEnum)) + if (type === FormTypeEnum.file || type === FormTypeEnum.files) return VarKindType.variable + + if ( + [ + FormTypeEnum.select, + FormTypeEnum.checkbox, + FormTypeEnum.textNumber, + FormTypeEnum.array, + FormTypeEnum.object, + ].includes(type as FormTypeEnum) + ) return VarKindType.constant - if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) - return VarKindType.mixed + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) return VarKindType.mixed return undefined } export const resolveTargetVarType = (type: string) => { - if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) - return VarType.string - if (type === FormTypeEnum.textNumber) - return VarType.number - if (type === FormTypeEnum.files) - return VarType.arrayFile - if (type === FormTypeEnum.file) - return VarType.file - if (type === FormTypeEnum.checkbox) - return VarType.boolean - if (type === FormTypeEnum.object) - return VarType.object - if (type === FormTypeEnum.array) - return VarType.arrayObject + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) return VarType.string + if (type === FormTypeEnum.textNumber) return VarType.number + if (type === FormTypeEnum.files) return VarType.arrayFile + if (type === FormTypeEnum.file) return VarType.file + if (type === FormTypeEnum.checkbox) return VarType.boolean + if (type === FormTypeEnum.object) return VarType.object + if (type === FormTypeEnum.array) return VarType.arrayObject return VarType.string } @@ -56,7 +55,8 @@ export const createFilterVar = (type: string) => { return (varPayload: Var) => varPayload.type === VarType.number if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) - return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) + return (varPayload: Var) => + [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) if (type === FormTypeEnum.file || type === FormTypeEnum.files) return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type) @@ -64,11 +64,13 @@ export const createFilterVar = (type: string) => { if (type === FormTypeEnum.checkbox) return (varPayload: Var) => varPayload.type === VarType.boolean - if (type === FormTypeEnum.object) - return (varPayload: Var) => varPayload.type === VarType.object + if (type === FormTypeEnum.object) return (varPayload: Var) => varPayload.type === VarType.object if (type === FormTypeEnum.array) - return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type) + return (varPayload: Var) => + [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes( + varPayload.type, + ) return undefined } @@ -78,15 +80,19 @@ export const getVisibleSelectOptions = ( value: ReasoningConfigValue, language: string, ) => { - return options.filter((option) => { - if (option.show_on.length) - return option.show_on.every(showOnItem => value[showOnItem.variable]?.value?.value === showOnItem.value) - - return true - }).map(option => ({ - value: option.value, - name: option.label[language] || option.label.en_US, - })) + return options + .filter((option) => { + if (option.show_on.length) + return option.show_on.every( + (showOnItem) => value[showOnItem.variable]?.value?.value === showOnItem.value, + ) + + return true + }) + .map((option) => ({ + value: option.value, + name: option.label[language] || option.label.en_US, + })) } export const updateInputAutoState = ( @@ -99,7 +105,7 @@ export const updateInputAutoState = ( ...value, [variable]: { value: enabled ? null : { type: getVarKindType(type), value: null }, - auto: enabled ? 1 as const : 0 as const, + auto: enabled ? (1 as const) : (0 as const), }, } } diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx index 8ff7073c76700e..dc7ec48bfb838a 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx @@ -2,18 +2,19 @@ import type { Node } from 'reactflow' import type { ReasoningConfigValue as ReasoningConfigValueShape } from './reasoning-config-form.helpers' import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema' import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types' -import type { - NodeOutPutVar, - ValueSelector, -} from '@/app/components/workflow/types' +import type { NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { Switch } from '@langgenius/dify-ui/switch' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiArrowRightUpLine, - RiBracesLine, -} from '@remixicon/react' +import { RiArrowRightUpLine, RiBracesLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -69,49 +70,60 @@ const ReasoningConfigForm: React.FC<Props> = ({ onChange(updateInputAutoState(value, key, val, type)) } - const handleTypeChange = useCallback((variable: string, defaultValue: unknown) => { - return (newType: VarKindType) => { - onChange(updateVariableTypeValue(value, variable, newType, defaultValue)) - } - }, [onChange, value]) + const handleTypeChange = useCallback( + (variable: string, defaultValue: unknown) => { + return (newType: VarKindType) => { + onChange(updateVariableTypeValue(value, variable, newType, defaultValue)) + } + }, + [onChange, value], + ) - const handleValueChange = useCallback((variable: string, varType: string) => { - return (newValue: unknown) => { - onChange(updateReasoningValue(value, variable, varType, newValue)) - } - }, [onChange, value]) + const handleValueChange = useCallback( + (variable: string, varType: string) => { + return (newValue: unknown) => { + onChange(updateReasoningValue(value, variable, varType, newValue)) + } + }, + [onChange, value], + ) - const handleAppChange = useCallback((variable: string) => { - return (app: { - app_id: string - inputs: Record<string, unknown> - files?: unknown[] - }) => { - onChange(updateReasoningValue(value, variable, FormTypeEnum.appSelector, app)) - } - }, [onChange, value]) + const handleAppChange = useCallback( + (variable: string) => { + return (app: { app_id: string; inputs: Record<string, unknown>; files?: unknown[] }) => { + onChange(updateReasoningValue(value, variable, FormTypeEnum.appSelector, app)) + } + }, + [onChange, value], + ) - const handleModelChange = useCallback((variable: string) => { - return (model: Record<string, unknown>) => { - onChange(mergeReasoningValue(value, variable, model)) - } - }, [onChange, value]) + const handleModelChange = useCallback( + (variable: string) => { + return (model: Record<string, unknown>) => { + onChange(mergeReasoningValue(value, variable, model)) + } + }, + [onChange, value], + ) - const handleVariableSelectorChange = useCallback((variable: string) => { - return (newValue: ValueSelector | string) => { - onChange(updateVariableSelectorValue(value, variable, newValue)) - } - }, [onChange, value]) + const handleVariableSelectorChange = useCallback( + (variable: string) => { + return (newValue: ValueSelector | string) => { + onChange(updateVariableSelectorValue(value, variable, newValue)) + } + }, + [onChange, value], + ) - const [isShowSchema, { - setTrue: showSchema, - setFalse: hideSchema, - }] = useBoolean(false) + const [isShowSchema, { setTrue: showSchema, setFalse: hideSchema }] = useBoolean(false) const [schema, setSchema] = useState<SchemaRoot | null>(null) const [schemaRootName, setSchemaRootName] = useState<string>('') - const renderField = (schema: ToolFormSchema, showSchema: (schema: SchemaRoot, rootName: string) => void) => { + const renderField = ( + schema: ToolFormSchema, + showSchema: (schema: SchemaRoot, rootName: string) => void, + ) => { const { default: defaultValue, variable, @@ -129,11 +141,7 @@ const ReasoningConfigForm: React.FC<Props> = ({ const fieldTitle = getFieldTitle(label, language) const tooltipText = tooltip?.[language] || tooltip?.en_US const tooltipContent = tooltipText && ( - <Infotip - aria-label={tooltipText} - className="ml-0.5 size-4" - popupClassName="w-[200px]" - > + <Infotip aria-label={tooltipText} className="ml-0.5 size-4" popupClassName="w-[200px]"> {tooltipText} </Infotip> ) @@ -157,59 +165,71 @@ const ReasoningConfigForm: React.FC<Props> = ({ schema, }) const selectValue = typeof varInput?.value === 'string' ? varInput.value : null - const selectedOption = isSelect && options - ? pickerProps.selectItems.find(item => item.value === selectValue) ?? null - : null + const selectedOption = + isSelect && options + ? (pickerProps.selectItems.find((item) => item.value === selectValue) ?? null) + : null return ( <div key={variable} className="space-y-0.5"> <div className="flex items-center justify-between py-2 system-sm-semibold text-text-secondary"> <div className="flex items-center"> - <span className={cn('max-w-[140px] truncate code-sm-semibold text-text-secondary')}>{fieldTitle}</span> - {required && ( - <span className="ml-1 text-red-500">*</span> - )} + <span className={cn('max-w-[140px] truncate code-sm-semibold text-text-secondary')}> + {fieldTitle} + </span> + {required && <span className="ml-1 text-red-500">*</span>} {tooltipContent} <span className="mx-1 system-xs-regular text-text-quaternary">·</span> - <span className="system-xs-regular text-text-tertiary">{resolveTargetVarType(type)}</span> + <span className="system-xs-regular text-text-tertiary"> + {resolveTargetVarType(type)} + </span> {isShowJSONEditor && ( <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['nodes.agent.clickToViewParameterSchema'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.agent.clickToViewParameterSchema'], { + ns: 'workflow', + })} className="ml-0.5 cursor-pointer rounded-sm border-0 bg-transparent p-px text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" onClick={() => showSchema(input_schema as SchemaRoot, fieldTitle!)} > <RiBracesLine className="size-3.5" /> </button> - )} + } /> <TooltipContent className="system-xs-medium text-text-secondary"> - {t($ => $['nodes.agent.clickToViewParameterSchema'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.clickToViewParameterSchema'], { ns: 'workflow' })} </TooltipContent> </Tooltip> )} - </div> - <div className="flex cursor-pointer items-center gap-1 rounded-md border border-divider-subtle bg-background-default-lighter px-2 py-1 hover:bg-state-base-hover" onClick={() => handleAutomatic(variable, !auto, type)}> - <span className="system-xs-medium text-text-secondary">{t($ => $['detailPanel.toolSelector.auto'], { ns: 'plugin' })}</span> + <div + className="flex cursor-pointer items-center gap-1 rounded-md border border-divider-subtle bg-background-default-lighter px-2 py-1 hover:bg-state-base-hover" + onClick={() => handleAutomatic(variable, !auto, type)} + > + <span className="system-xs-medium text-text-secondary"> + {t(($) => $['detailPanel.toolSelector.auto'], { ns: 'plugin' })} + </span> <Switch size="xs" checked={!!auto} - onCheckedChange={val => handleAutomatic(variable, val, type)} + onCheckedChange={(val) => handleAutomatic(variable, val, type)} /> </div> </div> {auto === 0 && ( <div className={cn('gap-1', !(isShowJSONEditor && isConstant) && 'flex')}> {showTypeSwitch && ( - <FormInputTypeSwitch value={varInput?.type || VarKindType.constant} onChange={handleTypeChange(variable, defaultValue)} /> + <FormInputTypeSwitch + value={varInput?.type || VarKindType.constant} + onChange={handleTypeChange(variable, defaultValue)} + /> )} {isString && ( <MixedVariableTextInput - value={varInput?.value as string || ''} + value={(varInput?.value as string) || ''} onChange={handleValueChange(variable, type)} nodesOutputVars={nodeOutputVars} availableNodes={availableNodes} @@ -220,7 +240,7 @@ const ReasoningConfigForm: React.FC<Props> = ({ className="h-8 grow" type="number" value={(varInput?.value as string | number) || ''} - onChange={e => handleValueChange(variable, type)(e.target.value)} + onChange={(e) => handleValueChange(variable, type)(e.target.value)} placeholder={placeholder?.[language] || placeholder?.en_US} /> )} @@ -234,8 +254,7 @@ const ReasoningConfigForm: React.FC<Props> = ({ <Select<string> value={selectedOption?.value ?? null} onValueChange={(value) => { - if (value == null || value === '') - return + if (value == null || value === '') return handleValueChange(variable, type)(value) }} > @@ -243,7 +262,7 @@ const ReasoningConfigForm: React.FC<Props> = ({ {selectedOption?.name ?? placeholder?.[language] ?? placeholder?.en_US} </SelectTrigger> <SelectContent> - {pickerProps.selectItems.map(item => ( + {pickerProps.selectItems.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -263,7 +282,11 @@ const ReasoningConfigForm: React.FC<Props> = ({ language={CodeLanguage.json} onChange={handleValueChange(variable, type)} className="w-full" - placeholder={<div className="whitespace-pre">{placeholder?.[language] || placeholder?.en_US}</div>} + placeholder={ + <div className="whitespace-pre"> + {placeholder?.[language] || placeholder?.en_US} + </div> + } /> </div> )} @@ -271,7 +294,11 @@ const ReasoningConfigForm: React.FC<Props> = ({ <AppSelector disabled={false} scope={scope || 'all'} - value={varInput as { app_id: string, inputs: Record<string, unknown>, files?: unknown[] } | undefined} + value={ + varInput as + | { app_id: string; inputs: Record<string, unknown>; files?: unknown[] } + | undefined + } onSelect={handleAppChange(variable)} /> )} @@ -307,7 +334,7 @@ const ReasoningConfigForm: React.FC<Props> = ({ rel="noopener noreferrer" className="inline-flex items-center text-xs text-text-accent" > - {t($ => $.howToGet, { ns: 'tools' })} + {t(($) => $.howToGet, { ns: 'tools' })} <RiArrowRightUpLine className="ml-1 size-3" /> </a> )} @@ -316,11 +343,14 @@ const ReasoningConfigForm: React.FC<Props> = ({ } return ( <div className="space-y-3 px-4 py-2"> - {!isShowSchema && schemas.map(schema => renderField(schema, (s: SchemaRoot, rootName: string) => { - setSchema(s) - setSchemaRootName(rootName) - showSchema() - }))} + {!isShowSchema && + schemas.map((schema) => + renderField(schema, (s: SchemaRoot, rootName: string) => { + setSchema(s) + setSchemaRootName(rootName) + showSchema() + }), + )} {isShowSchema && ( <SchemaModal isShow={isShowSchema} diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx index 9aea11b29cbee5..9250d38ffc44f5 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx @@ -1,15 +1,14 @@ 'use client' import type { FC } from 'react' import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types' -import { - Dialog, - DialogContent, - DialogTitle, -} from '@langgenius/dify-ui/dialog' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useTranslation } from 'react-i18next' import VisualEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor' -import { MittProvider, VisualEditorContextProvider } from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context' +import { + MittProvider, + VisualEditorContextProvider, +} from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context' type Props = Readonly<{ isShow: boolean @@ -18,28 +17,20 @@ type Props = Readonly<{ onClose: () => void }> -const SchemaModal: FC<Props> = ({ - isShow, - schema, - rootName, - onClose, -}) => { +const SchemaModal: FC<Props> = ({ isShow, schema, rootName, onClose }) => { const { t } = useTranslation() return ( - <Dialog - open={isShow} - onOpenChange={open => !open && onClose()} - > + <Dialog open={isShow} onOpenChange={(open) => !open && onClose()}> <DialogContent className="w-full max-w-[960px] p-0"> <div className="pb-6"> {/* Header */} <div className="relative flex p-6 pr-14 pb-3"> <DialogTitle className="grow truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['nodes.agent.parameterSchema'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.parameterSchema'], { ns: 'workflow' })} </DialogTitle> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-5 right-5 flex size-8 items-center justify-center p-1.5" onClick={onClose} > @@ -55,8 +46,7 @@ const SchemaModal: FC<Props> = ({ schema={schema} rootName={rootName} readOnly - > - </VisualEditor> + ></VisualEditor> </VisualEditorContextProvider> </MittProvider> </div> diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-authorization-section.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-authorization-section.tsx index c8389dd1fdf9db..2e70ff29bd4a4e 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-authorization-section.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-authorization-section.tsx @@ -2,10 +2,7 @@ import type { FC } from 'react' import type { ToolWithProvider } from '@/app/components/workflow/types' import Divider from '@/app/components/base/divider' -import { - AuthCategory, - PluginAuthInAgent, -} from '@/app/components/plugins/plugin-auth' +import { AuthCategory, PluginAuthInAgent } from '@/app/components/plugins/plugin-auth' import { CollectionType } from '@/app/components/tools/types' type ToolAuthorizationSectionProps = { @@ -20,12 +17,12 @@ const ToolAuthorizationSection: FC<ToolAuthorizationSectionProps> = ({ onAuthorizationItemClick, }) => { // Only show for built-in providers that allow deletion - const shouldShow = currentProvider - && currentProvider.type === CollectionType.builtIn - && currentProvider.allow_delete + const shouldShow = + currentProvider && + currentProvider.type === CollectionType.builtIn && + currentProvider.allow_delete - if (!shouldShow) - return null + if (!shouldShow) return null return ( <> diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx index 3f397ad9e34d63..f79fe82c967bdd 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx @@ -48,7 +48,7 @@ const ToolBaseForm: FC<ToolBaseFormProps> = ({ {/* Tool picker */} <div className="flex flex-col gap-1"> <div className="flex h-6 items-center justify-between system-sm-semibold text-text-secondary"> - {t($ => $['detailPanel.toolSelector.toolLabel'], { ns: 'plugin' })} + {t(($) => $['detailPanel.toolSelector.toolLabel'], { ns: 'plugin' })} {currentProvider?.plugin_unique_identifier && ( <ReadmeEntrance pluginDetail={currentProvider as unknown as PluginDetail} @@ -60,15 +60,15 @@ const ToolBaseForm: FC<ToolBaseFormProps> = ({ <ToolPicker placement="bottom" offset={offset} - trigger={( + trigger={ <ToolTrigger open={panelShowState || isShowChooseTool} value={value} provider={currentProvider} /> - )} + } isShow={panelShowState || isShowChooseTool} - onShowChange={hasTrigger ? (onPanelShowStateChange || (() => {})) : onShowChange} + onShowChange={hasTrigger ? onPanelShowStateChange || (() => {}) : onShowChange} disabled={false} supportAddCustomTool onSelect={onSelectTool} @@ -81,12 +81,14 @@ const ToolBaseForm: FC<ToolBaseFormProps> = ({ {/* Description */} <div className="flex flex-col gap-1"> <div className="flex h-6 items-center system-sm-semibold text-text-secondary"> - {t($ => $['detailPanel.toolSelector.descriptionLabel'], { ns: 'plugin' })} + {t(($) => $['detailPanel.toolSelector.descriptionLabel'], { ns: 'plugin' })} </div> <Textarea className="resize-none" - aria-label={t($ => $['detailPanel.toolSelector.descriptionLabel'], { ns: 'plugin' })} - placeholder={t($ => $['detailPanel.toolSelector.descriptionPlaceholder'], { ns: 'plugin' })} + aria-label={t(($) => $['detailPanel.toolSelector.descriptionLabel'], { ns: 'plugin' })} + placeholder={t(($) => $['detailPanel.toolSelector.descriptionPlaceholder'], { + ns: 'plugin', + })} value={value?.extra?.description || ''} onValueChange={onDescriptionChange} disabled={!value?.provider_name} diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx index cd819dba3f4b80..b7adb21e371353 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx @@ -15,7 +15,7 @@ import McpToolNotSupportTooltip from '@/app/components/workflow/nodes/_base/comp import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/components/switch-plugin-version' type Props = Readonly<{ - icon?: string | { content?: string, background?: string } + icon?: string | { content?: string; background?: string } providerName?: string isMCPTool?: boolean providerShowName?: string @@ -63,31 +63,57 @@ const ToolItem = ({ const isShowCanNotChooseMCPTip = isMCPTool && !isMCPToolAllowed return ( - <div className={cn( - 'group flex cursor-default items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', - open && 'bg-components-panel-on-panel-item-bg-hover shadow-sm', - isDeleting && 'border-state-destructive-border shadow-xs hover:bg-state-destructive-hover', - )} + <div + className={cn( + 'group flex cursor-default items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', + open && 'bg-components-panel-on-panel-item-bg-hover shadow-sm', + isDeleting && 'border-state-destructive-border shadow-xs hover:bg-state-destructive-hover', + )} > {icon && ( - <div className={cn('shrink-0', isTransparent && 'opacity-50', isShowCanNotChooseMCPTip && 'opacity-30')}> - {typeof icon === 'string' && <div className="h-7 w-7 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge bg-cover bg-center" style={{ backgroundImage: `url(${icon})` }} />} - {typeof icon !== 'string' && <AppIcon className="h-7 w-7 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge" size="xs" icon={icon?.content} background={icon?.background} />} + <div + className={cn( + 'shrink-0', + isTransparent && 'opacity-50', + isShowCanNotChooseMCPTip && 'opacity-30', + )} + > + {typeof icon === 'string' && ( + <div + className="h-7 w-7 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge bg-cover bg-center" + style={{ backgroundImage: `url(${icon})` }} + /> + )} + {typeof icon !== 'string' && ( + <AppIcon + className="h-7 w-7 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge" + size="xs" + icon={icon?.content} + background={icon?.background} + /> + )} </div> )} {!icon && ( - <div className={cn( - 'flex h-7 w-7 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle', - isTransparent && 'opacity-50', - isShowCanNotChooseMCPTip && 'opacity-30', - )} + <div + className={cn( + 'flex h-7 w-7 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle', + isTransparent && 'opacity-50', + isShowCanNotChooseMCPTip && 'opacity-30', + )} > <div className="flex size-5 items-center justify-center opacity-35"> <span className="i-custom-vender-other-group text-text-tertiary" /> </div> </div> )} - <div className={cn('grow truncate pl-0.5', isTransparent && 'opacity-50', isShowCanNotChooseMCPTip && 'opacity-30')}> + <div + className={cn( + 'grow truncate pl-0.5', + isTransparent && 'opacity-50', + isShowCanNotChooseMCPTip && 'opacity-30', + )} + > <div className="system-2xs-medium-uppercase text-text-tertiary">{providerNameText}</div> <div className="system-xs-medium text-text-secondary">{toolLabel}</div> </div> @@ -109,43 +135,47 @@ const ToolItem = ({ <span className="i-ri-delete-bin-line size-4" /> </div> </div> - {!isError && !uninstalled && !noAuth && !versionMismatch && !isShowCanNotChooseMCPTip && showSwitch && ( - <div className="mr-1" onClick={e => e.stopPropagation()}> - <Switch - size="md" - checked={switchValue ?? false} - onCheckedChange={onSwitchChange} - /> - </div> - )} + {!isError && + !uninstalled && + !noAuth && + !versionMismatch && + !isShowCanNotChooseMCPTip && + showSwitch && ( + <div className="mr-1" onClick={(e) => e.stopPropagation()}> + <Switch size="md" checked={switchValue ?? false} onCheckedChange={onSwitchChange} /> + </div> + )} {isShowCanNotChooseMCPTip && <McpToolNotSupportTooltip />} {!isError && !uninstalled && !versionMismatch && noAuth && ( <Button variant="secondary" size="small"> - {t($ => $.notAuthorized, { ns: 'tools' })} + {t(($) => $.notAuthorized, { ns: 'tools' })} <StatusDot className="ml-2" status="warning" /> </Button> )} {!isError && !uninstalled && !versionMismatch && authRemoved && ( <Button variant="secondary" size="small"> - {t($ => $['auth.authRemoved'], { ns: 'plugin' })} + {t(($) => $['auth.authRemoved'], { ns: 'plugin' })} <StatusDot className="ml-2" status="error" /> </Button> )} {!isError && !uninstalled && versionMismatch && installInfo && ( - <div onClick={e => e.stopPropagation()}> + <div onClick={(e) => e.stopPropagation()}> <SwitchPluginVersion className="-mt-1" uniqueIdentifier={installInfo} - tooltip={( + tooltip={ <div className="w-45" data-testid="tooltip-content"> - <div className="mb-1.5 font-semibold text-text-secondary" data-testid="tooltip-content-title"> - {t($ => $['detailPanel.toolSelector.unsupportedTitle'], { ns: 'plugin' })} + <div + className="mb-1.5 font-semibold text-text-secondary" + data-testid="tooltip-content-title" + > + {t(($) => $['detailPanel.toolSelector.unsupportedTitle'], { ns: 'plugin' })} </div> <div className="mb-1.5 text-text-tertiary" data-testid="tooltip-content-body"> - {`${t($ => $['detailPanel.toolSelector.unsupportedContent'], { ns: 'plugin' })} ${t($ => $['detailPanel.toolSelector.unsupportedContent2'], { ns: 'plugin' })}`} + {`${t(($) => $['detailPanel.toolSelector.unsupportedContent'], { ns: 'plugin' })} ${t(($) => $['detailPanel.toolSelector.unsupportedContent2'], { ns: 'plugin' })}`} </div> </div> - )} + } onChange={() => { onInstall?.() }} @@ -154,7 +184,7 @@ const ToolItem = ({ )} {!isError && uninstalled && installInfo && ( <InstallPluginButton - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} size="small" uniqueIdentifier={installInfo} onSuccess={() => { @@ -166,7 +196,11 @@ const ToolItem = ({ <Popover> <PopoverTrigger openOnHover - aria-label={typeof errorTip === 'string' ? errorTip : t($ => $['detailPanel.toolSelector.unsupportedTitle'], { ns: 'plugin' })} + aria-label={ + typeof errorTip === 'string' + ? errorTip + : t(($) => $['detailPanel.toolSelector.unsupportedTitle'], { ns: 'plugin' }) + } className="inline-flex border-0 bg-transparent p-0" > <span className="i-ri-error-warning-fill size-4 text-text-destructive" /> diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-settings-panel.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-settings-panel.tsx index d10c35a59e0807..6c3576f76eb2f2 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-settings-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-settings-panel.tsx @@ -40,10 +40,10 @@ const ParamsTips: FC = () => { return ( <div className="pb-1"> <div className="system-xs-regular text-text-tertiary"> - {t($ => $['detailPanel.toolSelector.paramsTip1'], { ns: 'plugin' })} + {t(($) => $['detailPanel.toolSelector.paramsTip1'], { ns: 'plugin' })} </div> <div className="system-xs-regular text-text-tertiary"> - {t($ => $['detailPanel.toolSelector.paramsTip2'], { ns: 'plugin' })} + {t(($) => $['detailPanel.toolSelector.paramsTip2'], { ns: 'plugin' })} </div> </div> ) @@ -73,8 +73,7 @@ const ToolSettingsPanel: FC<ToolSettingsPanelProps> = ({ const hasParams = paramsFormSchemas.length > 0 const isTeamAuthorized = currentProvider?.is_team_authorization - if ((!hasSettings && !hasParams) || !isTeamAuthorized) - return null + if ((!hasSettings && !hasParams) || !isTeamAuthorized) return null return ( <> @@ -89,12 +88,17 @@ const ToolSettingsPanel: FC<ToolSettingsPanelProps> = ({ smallItem value={currType} onChange={(v) => { - if (v === 'settings' || v === 'params') - onCurrTypeChange(v) + if (v === 'settings' || v === 'params') onCurrTypeChange(v) }} options={[ - { value: 'settings', text: t($ => $['detailPanel.toolSelector.settings'], { ns: 'plugin' })! }, - { value: 'params', text: t($ => $['detailPanel.toolSelector.params'], { ns: 'plugin' })! }, + { + value: 'settings', + text: t(($) => $['detailPanel.toolSelector.settings'], { ns: 'plugin' })!, + }, + { + value: 'params', + text: t(($) => $['detailPanel.toolSelector.params'], { ns: 'plugin' })!, + }, ]} /> )} @@ -110,7 +114,7 @@ const ToolSettingsPanel: FC<ToolSettingsPanelProps> = ({ {userSettingsOnly && ( <div className="p-4 pb-1"> <div className="system-sm-semibold-uppercase text-text-primary"> - {t($ => $['detailPanel.toolSelector.settings'], { ns: 'plugin' })} + {t(($) => $['detailPanel.toolSelector.settings'], { ns: 'plugin' })} </div> </div> )} @@ -119,7 +123,7 @@ const ToolSettingsPanel: FC<ToolSettingsPanelProps> = ({ {nodeId && reasoningConfigOnly && ( <div className="mb-1 p-4 pb-1"> <div className="system-sm-semibold-uppercase text-text-primary"> - {t($ => $['detailPanel.toolSelector.params'], { ns: 'plugin' })} + {t(($) => $['detailPanel.toolSelector.params'], { ns: 'plugin' })} </div> <ParamsTips /> </div> diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-trigger.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-trigger.tsx index 72eba3475a9a24..b7b59b783c1610 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-trigger.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-trigger.tsx @@ -1,10 +1,7 @@ 'use client' import type { ToolWithProvider } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - RiArrowDownSLine, - RiEqualizer2Line, -} from '@remixicon/react' +import { RiArrowDownSLine, RiEqualizer2Line } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import BlockIcon from '@/app/components/workflow/block-icon' @@ -20,42 +17,48 @@ type Props = Readonly<{ isConfigure?: boolean }> -const ToolTrigger = ({ - open, - provider, - value, - isConfigure, -}: Props) => { +const ToolTrigger = ({ open, provider, value, isConfigure }: Props) => { const { t } = useTranslation() return ( - <div className={cn( - 'group flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal p-2 pl-3 hover:bg-state-base-hover-alt', - open && 'bg-state-base-hover-alt', - value?.provider_name && 'py-1.5 pl-1.5', - )} + <div + className={cn( + 'group flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal p-2 pl-3 hover:bg-state-base-hover-alt', + open && 'bg-state-base-hover-alt', + value?.provider_name && 'py-1.5 pl-1.5', + )} > {value?.provider_name && provider && ( <div className="mr-1 shrink-0 rounded-lg border border-components-panel-border bg-components-panel-bg p-px"> - <BlockIcon - className="size-4!" - type={BlockEnum.Tool} - toolIcon={provider.icon} - /> + <BlockIcon className="size-4!" type={BlockEnum.Tool} toolIcon={provider.icon} /> </div> )} {value?.tool_name && ( - <div className="grow system-sm-medium text-components-input-text-filled">{value.tool_name}</div> + <div className="grow system-sm-medium text-components-input-text-filled"> + {value.tool_name} + </div> )} {!value?.provider_name && ( <div className="grow system-sm-regular text-components-input-text-placeholder"> - {!isConfigure ? t($ => $['detailPanel.toolSelector.placeholder'], { ns: 'plugin' }) : t($ => $['detailPanel.configureTool'], { ns: 'plugin' })} + {!isConfigure + ? t(($) => $['detailPanel.toolSelector.placeholder'], { ns: 'plugin' }) + : t(($) => $['detailPanel.configureTool'], { ns: 'plugin' })} </div> )} {isConfigure && ( - <RiEqualizer2Line className={cn('ml-0.5 size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} /> + <RiEqualizer2Line + className={cn( + 'ml-0.5 size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary', + open && 'text-text-secondary', + )} + /> )} {!isConfigure && ( - <RiArrowDownSLine className={cn('ml-0.5 size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} /> + <RiArrowDownSLine + className={cn( + 'ml-0.5 size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary', + open && 'text-text-secondary', + )} + /> )} </div> ) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts index 3e0e9ce9b3236c..cf87105ccf2e1a 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts @@ -23,28 +23,32 @@ vi.mock('@/service/use-plugins', () => ({ describe('usePluginInstalledCheck', () => { beforeEach(() => { vi.clearAllMocks() - mockUseCheckInstalled.mockImplementation(({ pluginIds, enabled }: { pluginIds: string[], enabled: boolean }) => ({ - data: enabled && pluginIds.length > 0 - ? { plugins: [] } - : undefined, - })) + mockUseCheckInstalled.mockImplementation( + ({ pluginIds, enabled }: { pluginIds: string[]; enabled: boolean }) => ({ + data: enabled && pluginIds.length > 0 ? { plugins: [] } : undefined, + }), + ) mockUsePluginManifestInfo.mockImplementation((pluginID: string) => ({ data: pluginID ? mockManifest : undefined, })) }) it('should use the explicit pluginID', () => { - const { result } = renderHook(() => usePluginInstalledCheck({ - providerPluginId: 'org/plugin', - })) + const { result } = renderHook(() => + usePluginInstalledCheck({ + providerPluginId: 'org/plugin', + }), + ) expect(result.current.pluginID).toBe('org/plugin') }) it('should detect plugin in marketplace when manifest exists', () => { - const { result } = renderHook(() => usePluginInstalledCheck({ - providerPluginId: 'org/plugin', - })) + const { result } = renderHook(() => + usePluginInstalledCheck({ + providerPluginId: 'org/plugin', + }), + ) expect(result.current.inMarketPlace).toBe(true) expect(result.current.manifest).toEqual(mockManifest.data.plugin) @@ -60,26 +64,32 @@ describe('usePluginInstalledCheck', () => { it('should skip marketplace lookup when installed plugin source is local', () => { mockUseCheckInstalled.mockReturnValue({ data: { - plugins: [{ - source: PluginSource.local, - }], + plugins: [ + { + source: PluginSource.local, + }, + ], }, }) - const { result } = renderHook(() => usePluginInstalledCheck({ - providerPluginId: 'org/plugin', - enabled: true, - })) + const { result } = renderHook(() => + usePluginInstalledCheck({ + providerPluginId: 'org/plugin', + enabled: true, + }), + ) expect(mockUsePluginManifestInfo).toHaveBeenCalledWith('') expect(result.current.inMarketPlace).toBe(false) }) it('should skip all plugin checks for non-plugin providers', () => { - const { result } = renderHook(() => usePluginInstalledCheck({ - providerPluginId: null, - enabled: true, - })) + const { result } = renderHook(() => + usePluginInstalledCheck({ + providerPluginId: null, + enabled: true, + }), + ) expect(mockUseCheckInstalled).toHaveBeenCalledWith({ pluginIds: [], diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts index 8f647e094a0de8..0e0c891cd5963e 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts @@ -50,10 +50,11 @@ vi.mock('@/utils/get-icon', () => ({ })) vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ - toolParametersToFormSchemas: (params: unknown[]) => (params as Record<string, unknown>[]).map(p => ({ - ...p, - variable: p.name, - })), + toolParametersToFormSchemas: (params: unknown[]) => + (params as Record<string, unknown>[]).map((p) => ({ + ...p, + variable: p.name, + })), generateFormValue: (value: Record<string, unknown>) => value || {}, getPlainValue: (value: Record<string, unknown>) => value || {}, getStructureValue: (value: Record<string, unknown>) => value || {}, @@ -87,9 +88,7 @@ describe('useToolSelectorState', () => { }) it('should initialize with default panel states', () => { - const { result } = renderHook(() => - useToolSelectorState({ onSelect: mockOnSelect }), - ) + const { result } = renderHook(() => useToolSelectorState({ onSelect: mockOnSelect })) expect(result.current.isShow).toBe(false) expect(result.current.isShowChooseTool).toBe(false) @@ -139,9 +138,7 @@ describe('useToolSelectorState', () => { }) it('should toggle panel visibility', () => { - const { result } = renderHook(() => - useToolSelectorState({ onSelect: mockOnSelect }), - ) + const { result } = renderHook(() => useToolSelectorState({ onSelect: mockOnSelect })) act(() => { result.current.setIsShow(true) @@ -155,9 +152,7 @@ describe('useToolSelectorState', () => { }) it('should switch tab type', () => { - const { result } = renderHook(() => - useToolSelectorState({ onSelect: mockOnSelect }), - ) + const { result } = renderHook(() => useToolSelectorState({ onSelect: mockOnSelect })) act(() => { result.current.setCurrType('params') @@ -174,9 +169,11 @@ describe('useToolSelectorState', () => { result.current.handleDescriptionChange('New description') }) - expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({ - extra: expect.objectContaining({ description: 'New description' }), - })) + expect(mockOnSelect).toHaveBeenCalledWith( + expect.objectContaining({ + extra: expect.objectContaining({ description: 'New description' }), + }), + ) }) it('should handle enabled change', () => { @@ -188,9 +185,11 @@ describe('useToolSelectorState', () => { result.current.handleEnabledChange(false) }) - expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({ - enabled: false, - })) + expect(mockOnSelect).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }), + ) }) it('should handle authorization item click', () => { @@ -202,15 +201,15 @@ describe('useToolSelectorState', () => { result.current.handleAuthorizationItemClick('cred-123') }) - expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({ - credential_id: 'cred-123', - })) + expect(mockOnSelect).toHaveBeenCalledWith( + expect.objectContaining({ + credential_id: 'cred-123', + }), + ) }) it('should not call onSelect if value is undefined', () => { - const { result } = renderHook(() => - useToolSelectorState({ onSelect: mockOnSelect }), - ) + const { result } = renderHook(() => useToolSelectorState({ onSelect: mockOnSelect })) act(() => { result.current.handleEnabledChange(true) @@ -233,9 +232,7 @@ describe('useToolSelectorState', () => { }) it('should skip plugin checks after resolving the current provider and tool', () => { - renderHook(() => - useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }), - ) + renderHook(() => useToolSelectorState({ value: toolValue, onSelect: mockOnSelect })) expect(mockUsePluginInstalledCheck).toHaveBeenCalledWith({ providerPluginId: 'org/test-plugin', diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-plugin-installed-check.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-plugin-installed-check.ts index 92a45bf49b2c1c..c4e54314a86294 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-plugin-installed-check.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-plugin-installed-check.ts @@ -1,21 +1,13 @@ import { PluginSource } from '@/app/components/plugins/types' -import { - useCheckInstalled, - usePluginManifestInfo, -} from '@/service/use-plugins' +import { useCheckInstalled, usePluginManifestInfo } from '@/service/use-plugins' type UsePluginInstalledCheckOptions = { providerPluginId?: string | null enabled?: boolean } -export const usePluginInstalledCheck = ( - input: UsePluginInstalledCheckOptions = {}, -) => { - const { - providerPluginId, - enabled = true, - } = input +export const usePluginInstalledCheck = (input: UsePluginInstalledCheckOptions = {}) => { + const { providerPluginId, enabled = true } = input const pluginID = providerPluginId ?? '' const { data: installedPluginData } = useCheckInstalled({ @@ -23,9 +15,10 @@ export const usePluginInstalledCheck = ( enabled: enabled && !!pluginID, }) const installedPlugin = installedPluginData?.plugins.at(0) - const shouldQueryMarketplace = enabled - && !!pluginID - && (!installedPlugin || installedPlugin.source === PluginSource.marketplace) + const shouldQueryMarketplace = + enabled && + !!pluginID && + (!installedPlugin || installedPlugin.source === PluginSource.marketplace) const { data: manifest } = usePluginManifestInfo(shouldQueryMarketplace ? pluginID : '') return { diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector-state.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector-state.ts index b67a0a10646df2..67146ab9bcf300 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector-state.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector-state.ts @@ -5,7 +5,12 @@ import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/bloc import type { ResourceVarInputs } from '@/app/components/workflow/nodes/_base/types' import { useCallback, useMemo, useState } from 'react' import { CollectionType } from '@/app/components/tools/types' -import { generateFormValue, getPlainValue, getStructureValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' +import { + generateFormValue, + getPlainValue, + getStructureValue, + toolParametersToFormSchemas, +} from '@/app/components/tools/utils/to-form-schema' import { useInvalidateInstalledPluginList } from '@/service/use-plugins' import { useAllBuiltInTools, @@ -59,37 +64,32 @@ export const useToolSelectorState = ({ ...(workflowTools || []), ...(mcpTools || []), ] - return mergedTools.find(toolWithProvider => toolWithProvider.id === value?.provider_name) + return mergedTools.find((toolWithProvider) => toolWithProvider.id === value?.provider_name) }, [value, buildInTools, customTools, workflowTools, mcpTools]) const areToolProvidersSettled = [ buildInToolsQuery, customToolsQuery, workflowToolsQuery, mcpToolsQuery, - ].every(toolProvidersQuery => toolProvidersQuery.isFetched) + ].every((toolProvidersQuery) => toolProvidersQuery.isFetched) // Current tool from provider const currentTool = useMemo(() => { - return currentProvider?.tools.find(tool => tool.name === value?.tool_name) + return currentProvider?.tools.find((tool) => tool.name === value?.tool_name) }, [currentProvider?.tools, value?.tool_name]) const providerPluginId = useMemo(() => { - if (currentProvider) - return currentProvider.plugin_id ?? value?.plugin_id ?? null + if (currentProvider) return currentProvider.plugin_id ?? value?.plugin_id ?? null - if (value?.plugin_id) - return value.plugin_id + if (value?.plugin_id) return value.plugin_id - if (!areToolProvidersSettled || !value?.provider_name) - return undefined + if (!areToolProvidersSettled || !value?.provider_name) return undefined // Legacy tool values may only carry the built-in provider id, which remains // enough to recover the underlying plugin id for marketplace-backed tools. - if (value.type && value.type !== CollectionType.builtIn) - return null + if (value.type && value.type !== CollectionType.builtIn) return null const providerNameSegments = value.provider_name.split('/') - if (providerNameSegments.length !== 3) - return null + if (providerNameSegments.length !== 3) return null return providerNameSegments.slice(0, 2).join('/') }, [ @@ -103,28 +103,29 @@ export const useToolSelectorState = ({ // Plugin info check const { inMarketPlace, manifest, pluginID } = usePluginInstalledCheck({ providerPluginId, - enabled: !!value?.provider_name - && (!currentProvider || !currentTool) - && (currentProvider !== undefined || areToolProvidersSettled || !!value?.plugin_id), + enabled: + !!value?.provider_name && + (!currentProvider || !currentTool) && + (currentProvider !== undefined || areToolProvidersSettled || !!value?.plugin_id), }) // Tool settings and params const currentToolSettings = useMemo(() => { - if (!currentProvider) - return [] - return currentProvider.tools - .find(tool => tool.name === value?.tool_name) - ?.parameters - .filter(param => param.form !== 'llm') || [] + if (!currentProvider) return [] + return ( + currentProvider.tools + .find((tool) => tool.name === value?.tool_name) + ?.parameters.filter((param) => param.form !== 'llm') || [] + ) }, [currentProvider, value]) const currentToolParams = useMemo(() => { - if (!currentProvider) - return [] - return currentProvider.tools - .find(tool => tool.name === value?.tool_name) - ?.parameters - .filter(param => param.form === 'llm') || [] + if (!currentProvider) return [] + return ( + currentProvider.tools + .find((tool) => tool.name === value?.tool_name) + ?.parameters.filter((param) => param.form === 'llm') || [] + ) }, [currentProvider, value]) // Form schemas @@ -144,8 +145,7 @@ export const useToolSelectorState = ({ // Manifest icon URL const manifestIcon = useMemo(() => { - if (!manifest || !pluginID) - return '' + if (!manifest || !pluginID) return '' return getIconFromMarketPlace(pluginID) }, [manifest, pluginID]) @@ -153,11 +153,15 @@ export const useToolSelectorState = ({ const getToolValue = useCallback((tool: ToolDefaultValue): ToolValue => { const settingValues = generateFormValue( tool.params, - toolParametersToFormSchemas((tool.paramSchemas as ToolParameter[]).filter(param => param.form !== 'llm')), + toolParametersToFormSchemas( + (tool.paramSchemas as ToolParameter[]).filter((param) => param.form !== 'llm'), + ), ) const paramValues = generateFormValue( tool.params, - toolParametersToFormSchemas((tool.paramSchemas as ToolParameter[]).filter(param => param.form === 'llm')), + toolParametersToFormSchemas( + (tool.paramSchemas as ToolParameter[]).filter((param) => param.form === 'llm'), + ), true, ) return { @@ -178,82 +182,98 @@ export const useToolSelectorState = ({ }, []) // Event handlers - const handleSelectTool = useCallback((tool: ToolDefaultValue) => { - const toolValue = getToolValue(tool) - onSelect(toolValue) - }, [getToolValue, onSelect]) + const handleSelectTool = useCallback( + (tool: ToolDefaultValue) => { + const toolValue = getToolValue(tool) + onSelect(toolValue) + }, + [getToolValue, onSelect], + ) - const handleSelectMultipleTool = useCallback((tools: ToolDefaultValue[]) => { - const toolValues = tools.map(item => getToolValue(item)) - onSelectMultiple?.(toolValues) - }, [getToolValue, onSelectMultiple]) + const handleSelectMultipleTool = useCallback( + (tools: ToolDefaultValue[]) => { + const toolValues = tools.map((item) => getToolValue(item)) + onSelectMultiple?.(toolValues) + }, + [getToolValue, onSelectMultiple], + ) - const handleDescriptionChange = useCallback((description: string) => { - if (!value) - return - onSelect({ - ...value, - extra: { - ...value.extra, - description: description || '', - }, - }) - }, [value, onSelect]) + const handleDescriptionChange = useCallback( + (description: string) => { + if (!value) return + onSelect({ + ...value, + extra: { + ...value.extra, + description: description || '', + }, + }) + }, + [value, onSelect], + ) - const handleSettingsFormChange = useCallback((v: ResourceVarInputs) => { - if (!value) - return - const newValue = getStructureValue(v) - onSelect({ - ...value, - settings: newValue, - }) - }, [value, onSelect]) + const handleSettingsFormChange = useCallback( + (v: ResourceVarInputs) => { + if (!value) return + const newValue = getStructureValue(v) + onSelect({ + ...value, + settings: newValue, + }) + }, + [value, onSelect], + ) - const handleParamsFormChange = useCallback((v: ReasoningConfigValue) => { - if (!value) - return - onSelect({ - ...value, - parameters: v, - }) - }, [value, onSelect]) + const handleParamsFormChange = useCallback( + (v: ReasoningConfigValue) => { + if (!value) return + onSelect({ + ...value, + parameters: v, + }) + }, + [value, onSelect], + ) - const handleEnabledChange = useCallback((state: boolean) => { - if (!value) - return - onSelect({ - ...value, - enabled: state, - }) - }, [value, onSelect]) + const handleEnabledChange = useCallback( + (state: boolean) => { + if (!value) return + onSelect({ + ...value, + enabled: state, + }) + }, + [value, onSelect], + ) - const handleAuthorizationItemClick = useCallback((id: string) => { - if (!value) - return - onSelect({ - ...value, - credential_id: id, - }) - }, [value, onSelect]) + const handleAuthorizationItemClick = useCallback( + (id: string) => { + if (!value) return + onSelect({ + ...value, + credential_id: id, + }) + }, + [value, onSelect], + ) const handleInstall = useCallback(async () => { try { await invalidateAllBuiltinTools() - } - catch (error) { + } catch (error) { console.error('Failed to invalidate built-in tools cache', error) } try { await invalidateInstalledPluginList() - } - catch (error) { + } catch (error) { console.error('Failed to invalidate installed plugin list cache', error) } }, [invalidateAllBuiltinTools, invalidateInstalledPluginList]) const getSettingsValue = useCallback((): ResourceVarInputs => { - return getPlainValue((value?.settings || {}) as Record<string, { value: unknown }>) as ResourceVarInputs + return getPlainValue( + (value?.settings || {}) as Record<string, { value: unknown }>, + ) as ResourceVarInputs }, [value?.settings]) return { diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx index 09b7bda5f33db7..d0d3f3506de159 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx @@ -6,11 +6,7 @@ import type { Node } from 'reactflow' import type { ToolValue } from '@/app/components/workflow/block-selector/types' import type { NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useTranslation } from 'react-i18next' import { CollectionType } from '@/app/components/tools/types' @@ -69,8 +65,10 @@ const ToolSelector: FC<Props> = ({ nodeId = '', }) => { const { t } = useTranslation() - const sideOffset = typeof offset === 'number' ? offset : (typeof offset === 'function' ? 0 : (offset?.mainAxis ?? 0)) - const alignOffset = typeof offset === 'number' ? 0 : (typeof offset === 'function' ? 0 : (offset?.crossAxis ?? 0)) + const sideOffset = + typeof offset === 'number' ? offset : typeof offset === 'function' ? 0 : (offset?.mainAxis ?? 0) + const alignOffset = + typeof offset === 'number' ? 0 : typeof offset === 'function' ? 0 : (offset?.crossAxis ?? 0) // Use custom hook for state management const state = useToolSelectorState({ value, onSelect, onSelectMultiple }) @@ -106,8 +104,7 @@ const ToolSelector: FC<Props> = ({ const portalOpen = trigger ? controlledState : isShow const onPortalOpenChange = trigger ? onControlledStateChange : setIsShow const handlePortalOpenChange = (nextOpen: boolean) => { - if (nextOpen && (disabled || !currentProvider || !currentTool)) - return + if (nextOpen && (disabled || !currentProvider || !currentTool)) return onPortalOpenChange?.(nextOpen) } @@ -116,41 +113,30 @@ const ToolSelector: FC<Props> = ({ <div className="max-w-[240px] space-y-1 text-xs"> <h3 className="font-semibold text-text-primary"> {currentTool - ? t($ => $['detailPanel.toolSelector.uninstalledTitle'], { ns: 'plugin' }) - : t($ => $['detailPanel.toolSelector.unsupportedTitle'], { ns: 'plugin' })} + ? t(($) => $['detailPanel.toolSelector.uninstalledTitle'], { ns: 'plugin' }) + : t(($) => $['detailPanel.toolSelector.unsupportedTitle'], { ns: 'plugin' })} </h3> <p className="tracking-tight text-text-secondary"> {currentTool - ? t($ => $['detailPanel.toolSelector.uninstalledContent'], { ns: 'plugin' }) - : t($ => $['detailPanel.toolSelector.unsupportedContent'], { ns: 'plugin' })} + ? t(($) => $['detailPanel.toolSelector.uninstalledContent'], { ns: 'plugin' }) + : t(($) => $['detailPanel.toolSelector.unsupportedContent'], { ns: 'plugin' })} </p> <p> <Link href="/plugins" className="tracking-tight text-text-accent"> - {t($ => $['detailPanel.toolSelector.uninstalledLink'], { ns: 'plugin' })} + {t(($) => $['detailPanel.toolSelector.uninstalledLink'], { ns: 'plugin' })} </Link> </p> </div> ) return ( - <Popover - open={portalOpen} - onOpenChange={handlePortalOpenChange} - > - <PopoverTrigger - nativeButton={false} - render={<div className="w-full" />} - > + <Popover open={portalOpen} onOpenChange={handlePortalOpenChange}> + <PopoverTrigger nativeButton={false} render={<div className="w-full" />}> {trigger} {/* Default trigger - no value */} {!trigger && !value?.provider_name && ( - <ToolTrigger - isConfigure - open={isShow} - value={value} - provider={currentProvider} - /> + <ToolTrigger isConfigure open={isShow} value={value} provider={currentProvider} /> )} {/* Default trigger - with value */} @@ -183,15 +169,18 @@ const ToolSelector: FC<Props> = ({ alignOffset={alignOffset} popupClassName="border-none bg-transparent shadow-none" > - <div className={cn( - 'relative max-h-[642px] min-h-20 w-[361px] rounded-xl', - 'border-[0.5px] border-components-panel-border bg-components-panel-bg-blur', - 'overflow-y-auto pb-2 pb-4 shadow-lg backdrop-blur-xs', - )} + <div + className={cn( + 'relative max-h-[642px] min-h-20 w-[361px] rounded-xl', + 'border-[0.5px] border-components-panel-border bg-components-panel-bg-blur', + 'overflow-y-auto pb-2 pb-4 shadow-lg backdrop-blur-xs', + )} > {/* Header */} <div className="px-4 pt-3.5 pb-1 system-xl-semibold text-text-primary"> - {t($ => $[`detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`], { ns: 'plugin' })} + {t(($) => $[`detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`], { + ns: 'plugin', + })} </div> {/* Base form: tool picker + description */} diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx index 73c818652c263d..3a59df83b7addd 100644 --- a/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx @@ -26,7 +26,7 @@ vi.mock('@/app/components/plugins/card/base/org-info', () => ({ vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ triggerEventParametersToFormSchemas: (params: Array<Record<string, unknown>>) => - params.map(p => ({ + params.map((p) => ({ label: (p.label as Record<string, string>) || { en_US: p.name as string }, type: (p.type as string) || 'text-input', required: (p.required as boolean) || false, @@ -34,9 +34,12 @@ vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ })), })) -vi.mock('@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field', () => ({ - default: ({ name }: { name: string }) => <div data-testid="output-field">{name}</div>, -})) +vi.mock( + '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field', + () => ({ + default: ({ name }: { name: string }) => <div data-testid="output-field">{name}</div>, + }), +) const mockEventInfo = { name: 'test-event', @@ -89,7 +92,13 @@ describe('EventDetailDrawer', () => { describe('Rendering', () => { it('should render drawer', () => { - render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={mockEventInfo} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) const dialog = screen.getByRole('dialog') @@ -104,39 +113,75 @@ describe('EventDetailDrawer', () => { }) it('should render event title', () => { - render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={mockEventInfo} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('Test Event'))!.toBeInTheDocument() }) it('should render event description', () => { - render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={mockEventInfo} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByTestId('description'))!.toHaveTextContent('Test event description') }) it('should render org info', () => { - render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={mockEventInfo} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByTestId('org-info'))!.toBeInTheDocument() }) it('should render parameters section', () => { - render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={mockEventInfo} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('tools.setBuiltInTools.parameters'))!.toBeInTheDocument() expect(screen.getByText('Parameter 1'))!.toBeInTheDocument() }) it('should render output section', () => { - render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={mockEventInfo} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('pluginTrigger.events.output'))!.toBeInTheDocument() expect(screen.getByTestId('output-field'))!.toHaveTextContent('result') }) it('should render back button', () => { - render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={mockEventInfo} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('plugin.detailPanel.operation.back'))!.toBeInTheDocument() }) @@ -144,18 +189,31 @@ describe('EventDetailDrawer', () => { describe('User Interactions', () => { it('should call onClose when close button clicked', () => { - render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={mockEventInfo} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) // Find the close button (ActionButton with action-btn class) - const closeButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) - if (closeButton) - fireEvent.click(closeButton) + const closeButton = screen + .getAllByRole('button') + .find((btn) => btn.classList.contains('action-btn')) + if (closeButton) fireEvent.click(closeButton) expect(mockOnClose).toHaveBeenCalledTimes(1) }) it('should call onClose when back clicked', () => { - render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={mockEventInfo} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) fireEvent.click(screen.getByText('plugin.detailPanel.operation.back')) @@ -166,14 +224,26 @@ describe('EventDetailDrawer', () => { describe('Edge Cases', () => { it('should handle no parameters', () => { const eventWithNoParams = { ...mockEventInfo, parameters: [] } - render(<EventDetailDrawer eventInfo={eventWithNoParams} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={eventWithNoParams} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('pluginTrigger.events.item.noParameters'))!.toBeInTheDocument() }) it('should handle no output schema', () => { const eventWithNoOutput = { ...mockEventInfo, output_schema: {} } - render(<EventDetailDrawer eventInfo={eventWithNoOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={eventWithNoOutput} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('pluginTrigger.events.output'))!.toBeInTheDocument() expect(screen.queryByTestId('output-field')).not.toBeInTheDocument() @@ -186,7 +256,13 @@ describe('EventDetailDrawer', () => { ...mockEventInfo, parameters: [{ ...mockEventInfo.parameters[0]!, type: 'number-input' }], } - render(<EventDetailDrawer eventInfo={eventWithNumber} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={eventWithNumber} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('tools.setBuiltInTools.number'))!.toBeInTheDocument() }) @@ -196,7 +272,13 @@ describe('EventDetailDrawer', () => { ...mockEventInfo, parameters: [{ ...mockEventInfo.parameters[0]!, type: 'checkbox' }], } - render(<EventDetailDrawer eventInfo={eventWithCheckbox} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={eventWithCheckbox} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('boolean'))!.toBeInTheDocument() }) @@ -206,7 +288,13 @@ describe('EventDetailDrawer', () => { ...mockEventInfo, parameters: [{ ...mockEventInfo.parameters[0]!, type: 'file' }], } - render(<EventDetailDrawer eventInfo={eventWithFile} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={eventWithFile} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('tools.setBuiltInTools.file'))!.toBeInTheDocument() }) @@ -216,7 +304,13 @@ describe('EventDetailDrawer', () => { ...mockEventInfo, parameters: [{ ...mockEventInfo.parameters[0]!, type: 'custom-type' }], } - render(<EventDetailDrawer eventInfo={eventWithUnknown} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={eventWithUnknown} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('custom-type'))!.toBeInTheDocument() }) @@ -233,7 +327,13 @@ describe('EventDetailDrawer', () => { required: [], }, } - render(<EventDetailDrawer eventInfo={eventWithArrayOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={eventWithArrayOutput} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('pluginTrigger.events.output'))!.toBeInTheDocument() }) @@ -252,7 +352,13 @@ describe('EventDetailDrawer', () => { required: [], }, } - render(<EventDetailDrawer eventInfo={eventWithNestedOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={eventWithNestedOutput} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('pluginTrigger.events.output'))!.toBeInTheDocument() }) @@ -267,7 +373,13 @@ describe('EventDetailDrawer', () => { required: [], }, } - render(<EventDetailDrawer eventInfo={eventWithEnumOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={eventWithEnumOutput} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('pluginTrigger.events.output'))!.toBeInTheDocument() }) @@ -282,7 +394,13 @@ describe('EventDetailDrawer', () => { required: [], }, } - render(<EventDetailDrawer eventInfo={eventWithArrayType} providerInfo={mockProviderInfo} onClose={mockOnClose} />) + render( + <EventDetailDrawer + eventInfo={eventWithArrayType} + providerInfo={mockProviderInfo} + onClose={mockOnClose} + />, + ) expect(screen.getByText('pluginTrigger.events.output'))!.toBeInTheDocument() }) diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx index bed27c02924bf5..b94870ad415985 100644 --- a/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx @@ -25,7 +25,7 @@ const mockTriggerEvents = [ }, ] as unknown as TriggerEvent[] -let mockDetail: { plugin_id: string, provider: string } | undefined +let mockDetail: { plugin_id: string; provider: string } | undefined let mockProviderInfo: { events: TriggerEvent[] } | undefined vi.mock('../../store', () => ({ @@ -40,7 +40,9 @@ vi.mock('@/service/use-triggers', () => ({ vi.mock('../event-detail-drawer', () => ({ EventDetailDrawer: ({ onClose }: { onClose: () => void }) => ( <div data-testid="event-detail-drawer"> - <button data-testid="close-drawer" onClick={onClose}>Close</button> + <button data-testid="close-drawer" onClick={onClose}> + Close + </button> </div> ), })) @@ -56,7 +58,11 @@ describe('TriggerEventsList', () => { it('should render event count', () => { render(<TriggerEventsList />) - expect(screen.getByText('pluginTrigger.events.actionNum:{"num":1,"event":"pluginTrigger.events.event"}')).toBeInTheDocument() + expect( + screen.getByText( + 'pluginTrigger.events.actionNum:{"num":1,"event":"pluginTrigger.events.event"}', + ), + ).toBeInTheDocument() }) it('should render event cards', () => { @@ -130,7 +136,11 @@ describe('TriggerEventsList', () => { expect(screen.getByText('Event One')).toBeInTheDocument() expect(screen.getByText('Event Two')).toBeInTheDocument() - expect(screen.getByText('pluginTrigger.events.actionNum:{"num":2,"event":"pluginTrigger.events.events"}')).toBeInTheDocument() + expect( + screen.getByText( + 'pluginTrigger.events.actionNum:{"num":2,"event":"pluginTrigger.events.events"}', + ), + ).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx index ee9d34e56d3e40..5a15aaf7a8f786 100644 --- a/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx +++ b/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx @@ -12,10 +12,7 @@ import { DrawerPortal, DrawerViewport, } from '@langgenius/dify-ui/drawer' -import { - RiArrowLeftLine, - RiCloseLine, -} from '@remixicon/react' +import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Divider from '@/app/components/base/divider' @@ -33,14 +30,10 @@ type EventDetailDrawerProps = { } const getType = (type: string, t: TFunction) => { - if (type === 'number-input') - return t($ => $['setBuiltInTools.number'], { ns: 'tools' }) - if (type === 'text-input') - return t($ => $['setBuiltInTools.string'], { ns: 'tools' }) - if (type === 'checkbox') - return 'boolean' - if (type === 'file') - return t($ => $['setBuiltInTools.file'], { ns: 'tools' }) + if (type === 'number-input') return t(($) => $['setBuiltInTools.number'], { ns: 'tools' }) + if (type === 'text-input') return t(($) => $['setBuiltInTools.string'], { ns: 'tools' }) + if (type === 'checkbox') return 'boolean' + if (type === 'file') return t(($) => $['setBuiltInTools.file'], { ns: 'tools' }) return type } @@ -50,24 +43,23 @@ const convertSchemaToField = (schema: any): any => { type: Array.isArray(schema.type) ? schema.type[0] : schema.type || 'string', } - if (schema.description) - field.description = schema.description + if (schema.description) field.description = schema.description if (schema.properties) { - field.properties = Object.entries(schema.properties).reduce((acc, [key, value]: [string, any]) => ({ - ...acc, - [key]: convertSchemaToField(value), - }), {}) + field.properties = Object.entries(schema.properties).reduce( + (acc, [key, value]: [string, any]) => ({ + ...acc, + [key]: convertSchemaToField(value), + }), + {}, + ) } - if (schema.required) - field.required = schema.required + if (schema.required) field.required = schema.required - if (schema.items) - field.items = convertSchemaToField(schema.items) + if (schema.items) field.items = convertSchemaToField(schema.items) - if (schema.enum) - field.enum = schema.enum + if (schema.enum) field.enum = schema.enum return field } @@ -93,14 +85,17 @@ export const EventDetailDrawer: FC<EventDetailDrawerProps> = (props) => { modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DrawerPortal> <DrawerBackdrop className="bg-transparent" /> <DrawerViewport> - <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-[400px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerPopup + className={cn( + 'justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-[400px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border', + )} + > <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> <div className="relative border-b border-divider-subtle p-4 pb-3"> <div className="absolute top-3 right-3"> @@ -113,7 +108,7 @@ export const EventDetailDrawer: FC<EventDetailDrawerProps> = (props) => { onClick={onClose} > <RiArrowLeftLine className="size-4" /> - {t($ => $['detailPanel.operation.back'], { ns: 'plugin' })} + {t(($) => $['detailPanel.operation.back'], { ns: 'plugin' })} </div> <div className="flex items-center gap-1"> <Icon size="tiny" className="size-6" src={providerInfo.icon!} /> @@ -123,38 +118,54 @@ export const EventDetailDrawer: FC<EventDetailDrawerProps> = (props) => { packageName={providerInfo.name.split('/').pop() || ''} /> </div> - <div className="mt-1 system-md-semibold text-text-primary">{eventInfo?.identity?.label[language]}</div> - <Description className="mt-3 mb-2 h-auto" text={eventInfo.description[language]!} descriptionLineRows={2}></Description> + <div className="mt-1 system-md-semibold text-text-primary"> + {eventInfo?.identity?.label[language]} + </div> + <Description + className="mt-3 mb-2 h-auto" + text={eventInfo.description[language]!} + descriptionLineRows={2} + ></Description> </div> <div className="flex h-full flex-col gap-2 overflow-y-auto px-4 pt-4 pb-2"> - <div className="system-sm-semibold-uppercase text-text-secondary">{t($ => $['setBuiltInTools.parameters'], { ns: 'tools' })}</div> - {parametersSchemas.length > 0 - ? ( - parametersSchemas.map((item, index) => ( - <div key={index} className="py-1"> - <div className="flex items-center gap-2"> - <div className="code-sm-semibold text-text-secondary">{item.label[language]}</div> - <div className="system-xs-regular text-text-tertiary"> - {getType(item.type, t)} - </div> - {item.required && ( - <div className="system-xs-medium text-text-warning-secondary">{t($ => $['setBuiltInTools.required'], { ns: 'tools' })}</div> - )} + <div className="system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['setBuiltInTools.parameters'], { ns: 'tools' })} + </div> + {parametersSchemas.length > 0 ? ( + parametersSchemas.map((item, index) => ( + <div key={index} className="py-1"> + <div className="flex items-center gap-2"> + <div className="code-sm-semibold text-text-secondary"> + {item.label[language]} + </div> + <div className="system-xs-regular text-text-tertiary"> + {getType(item.type, t)} + </div> + {item.required && ( + <div className="system-xs-medium text-text-warning-secondary"> + {t(($) => $['setBuiltInTools.required'], { ns: 'tools' })} </div> - {item.description && ( - <div className="mt-0.5 system-xs-regular text-text-tertiary"> - {item.description?.[language]} - </div> - )} + )} + </div> + {item.description && ( + <div className="mt-0.5 system-xs-regular text-text-tertiary"> + {item.description?.[language]} </div> - )) - ) - : <div className="system-xs-regular text-text-tertiary">{t($ => $['events.item.noParameters'], { ns: 'pluginTrigger' })}</div>} + )} + </div> + )) + ) : ( + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['events.item.noParameters'], { ns: 'pluginTrigger' })} + </div> + )} <Divider className="mt-1 mb-2 h-px" /> <div className="flex flex-col gap-2"> - <div className="system-sm-semibold-uppercase text-text-secondary">{t($ => $['events.output'], { ns: 'pluginTrigger' })}</div> + <div className="system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['events.output'], { ns: 'pluginTrigger' })} + </div> <div className="relative left-[-7px]"> - {outputFields.map(item => ( + {outputFields.map((item) => ( <Field key={item.name} name={item.name} diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-list.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/event-list.tsx index 3dcd73af179a67..c0de69c8e5e1ef 100644 --- a/web/app/components/plugins/plugin-detail-panel/trigger/event-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/trigger/event-list.tsx @@ -22,7 +22,9 @@ const TriggerEventCard = ({ eventInfo, providerInfo }: TriggerEventCardProps) => return ( <> <div - className={cn('bg-components-panel-item-bg cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover')} + className={cn( + 'bg-components-panel-item-bg cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', + )} onClick={() => setShowDetail(true)} > <div className="pb-0.5 system-md-semibold text-text-secondary">{title}</div> @@ -41,31 +43,34 @@ const TriggerEventCard = ({ eventInfo, providerInfo }: TriggerEventCardProps) => export const TriggerEventsList = () => { const { t } = useTranslation() - const detail = usePluginStore(state => state.detail) + const detail = usePluginStore((state) => state.detail) const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '') const triggerEvents = providerInfo?.events || [] - if (!providerInfo || !triggerEvents.length) - return null + if (!providerInfo || !triggerEvents.length) return null return ( <div className="px-4 pt-2 pb-4"> <div className="mb-1 py-1"> <div className="mb-1 flex h-6 items-center justify-between system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['events.actionNum'], { ns: 'pluginTrigger', num: triggerEvents.length, event: t($ => $[`events.${triggerEvents.length > 1 ? 'events' : 'event'}`], { ns: 'pluginTrigger' }) })} + {t(($) => $['events.actionNum'], { + ns: 'pluginTrigger', + num: triggerEvents.length, + event: t(($) => $[`events.${triggerEvents.length > 1 ? 'events' : 'event'}`], { + ns: 'pluginTrigger', + }), + })} </div> </div> <div className="flex flex-col gap-2"> - { - triggerEvents.map((triggerEvent: TriggerEvent) => ( - <TriggerEventCard - key={`${detail?.plugin_id}${triggerEvent.identity?.name || ''}`} - eventInfo={triggerEvent} - providerInfo={providerInfo} - /> - )) - } + {triggerEvents.map((triggerEvent: TriggerEvent) => ( + <TriggerEventCard + key={`${detail?.plugin_id}${triggerEvent.identity?.name || ''}`} + eventInfo={triggerEvent} + providerInfo={providerInfo} + /> + ))} </div> </div> ) diff --git a/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx b/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx index 9ad199c139d8e6..d4ee261192093f 100644 --- a/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx +++ b/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx @@ -2,9 +2,7 @@ import type { MetaData, PluginCategoryEnum } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { expectLoadingButton } from '@/test/button' - // ==================== Imports (after mocks) ==================== - import { PluginSource } from '../../types' import Action from '../action' @@ -29,7 +27,8 @@ const { vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign( - (message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }), + (message: string, options?: { type?: string }) => + mockToastNotify({ type: options?.type, message }), { success: (message: string) => mockToastNotify({ type: 'success', message }), error: (message: string) => mockToastNotify({ type: 'error', message }), @@ -71,14 +70,26 @@ vi.mock('@/service/use-plugins', () => ({ // Mock PluginInfo component - has complex dependencies (Modal, KeyValueItem) vi.mock('../../plugin-page/plugin-info', () => ({ - default: ({ repository, release, packageName, onHide }: { + default: ({ + repository, + release, + packageName, + onHide, + }: { repository: string release: string packageName: string onHide: () => void }) => ( - <div data-testid="plugin-info-modal" data-repo={repository} data-release={release} data-package={packageName}> - <button data-testid="close-plugin-info" onClick={onHide}>Close</button> + <div + data-testid="plugin-info-modal" + data-repo={repository} + data-release={release} + data-package={packageName} + > + <button data-testid="close-plugin-info" onClick={onHide}> + Close + </button> </div> ), })) @@ -120,7 +131,8 @@ const createActionProps = (overrides: Partial<ActionProps> = {}): ActionProps => ...overrides, }) -const getDeleteConfirmButton = () => screen.getByRole('button', { name: /common\.operation\.confirm/ }) +const getDeleteConfirmButton = () => + screen.getByRole('button', { name: /common\.operation\.confirm/ }) const getDeleteCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' }) // ==================== Tests ==================== @@ -462,9 +474,15 @@ describe('Action Component', () => { // Assert // Assert expect(screen.getByTestId('plugin-info-modal'))!.toBeInTheDocument() - expect(screen.getByTestId('plugin-info-modal'))!.toHaveAttribute('data-repo', 'owner/repo-name') + expect(screen.getByTestId('plugin-info-modal'))!.toHaveAttribute( + 'data-repo', + 'owner/repo-name', + ) expect(screen.getByTestId('plugin-info-modal'))!.toHaveAttribute('data-release', '2.0.0') - expect(screen.getByTestId('plugin-info-modal'))!.toHaveAttribute('data-package', 'my-package.difypkg') + expect(screen.getByTestId('plugin-info-modal'))!.toHaveAttribute( + 'data-package', + 'my-package.difypkg', + ) }) it('should hide plugin info modal when close is clicked', () => { @@ -609,7 +627,10 @@ describe('Action Component', () => { // Assert - toast is called with the translated payload await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: 'Already up to date' }) + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'Already up to date', + }) }) }) @@ -928,7 +949,13 @@ describe('Action Component', () => { describe('Prop Variations', () => { it('should handle all category types', () => { // Arrange - const categories = ['tool', 'model', 'extension', 'agent-strategy', 'datasource'] as PluginCategoryEnum[] + const categories = [ + 'tool', + 'model', + 'extension', + 'agent-strategy', + 'datasource', + ] as PluginCategoryEnum[] categories.forEach((category) => { const props = createActionProps({ @@ -970,7 +997,11 @@ describe('Action Component', () => { combinations.forEach((flags) => { const props = createActionProps(flags) - const expectedCount = [flags.isShowFetchNewVersion, flags.isShowInfo, flags.isShowDelete].filter(Boolean).length + const expectedCount = [ + flags.isShowFetchNewVersion, + flags.isShowInfo, + flags.isShowDelete, + ].filter(Boolean).length const { unmount } = render(<Action {...props} />) const buttons = queryActionButtons() diff --git a/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx index 76239d4a1b952c..7d7eef8fe491aa 100644 --- a/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx @@ -23,12 +23,12 @@ vi.mock('@/hooks/use-i18n', () => ({ useRenderI18nObject: () => mockGetValueFromI18nObject, })) -const mockCategoriesMap: Record<string, { name: string, label: string }> = { - 'tool': { name: 'tool', label: 'Tools' }, - 'model': { name: 'model', label: 'Models' }, - 'extension': { name: 'extension', label: 'Extensions' }, +const mockCategoriesMap: Record<string, { name: string; label: string }> = { + tool: { name: 'tool', label: 'Tools' }, + model: { name: 'model', label: 'Models' }, + extension: { name: 'extension', label: 'Extensions' }, 'agent-strategy': { name: 'agent-strategy', label: 'Agents' }, - 'datasource': { name: 'datasource', label: 'Data Sources' }, + datasource: { name: 'datasource', label: 'Data Sources' }, } vi.mock('../../hooks', () => ({ useCategories: () => ({ @@ -106,14 +106,17 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('../action', () => ({ - default: ({ onDelete, pluginName }: { onDelete: () => void, pluginName: string }) => ( + default: ({ onDelete, pluginName }: { onDelete: () => void; pluginName: string }) => ( <div data-testid="plugin-action" data-plugin-name={pluginName}> - <button data-testid="delete-button" onClick={onDelete}>Delete</button> + <button data-testid="delete-button" onClick={onDelete}> + Delete + </button> </div> ), })) @@ -131,11 +134,9 @@ vi.mock('../../card/base/description', () => ({ })) vi.mock('../../card/base/org-info', () => ({ - default: ({ orgName, packageName }: { orgName: string, packageName: string }) => ( + default: ({ orgName, packageName }: { orgName: string; packageName: string }) => ( <div data-testid="org-info" data-org={orgName} data-package={packageName}> - {orgName} - / - {packageName} + {orgName}/{packageName} </div> ), })) @@ -145,12 +146,16 @@ vi.mock('../../base/badges/verified', () => ({ })) vi.mock('../../../base/badge', () => ({ - default: ({ text, hasRedCornerMark }: { text: string, hasRedCornerMark?: boolean }) => ( - <div data-testid="version-badge" data-has-update={hasRedCornerMark}>{text}</div> + default: ({ text, hasRedCornerMark }: { text: string; hasRedCornerMark?: boolean }) => ( + <div data-testid="version-badge" data-has-update={hasRedCornerMark}> + {text} + </div> ), })) -const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ +const createPluginDeclaration = ( + overrides: Partial<PluginDeclaration> = {}, +): PluginDeclaration => ({ plugin_unique_identifier: 'test-plugin-id', version: '1.0.0', author: 'test-author', @@ -694,7 +699,9 @@ describe('PluginItem', () => { // Assert const pluginContainer = container.firstChild as HTMLElement - expect(pluginContainer).not.toHaveClass('border-components-option-card-option-selected-border') + expect(pluginContainer).not.toHaveClass( + 'border-components-option-card-option-selected-border', + ) }) it('should stop propagation when action area is clicked', () => { diff --git a/web/app/components/plugins/plugin-item/action.tsx b/web/app/components/plugins/plugin-item/action.tsx index f5b29d8b90ac99..68e0410ce86d5d 100644 --- a/web/app/components/plugins/plugin-item/action.tsx +++ b/web/app/components/plugins/plugin-item/action.tsx @@ -52,14 +52,9 @@ const Action: FC<Props> = ({ meta, }) => { const { t } = useTranslation() - const [isShowPluginInfo, { - setTrue: showPluginInfo, - setFalse: hidePluginInfo, - }] = useBoolean(false) - const [deleting, { - setTrue: showDeleting, - setFalse: hideDeleting, - }] = useBoolean(false) + const [isShowPluginInfo, { setTrue: showPluginInfo, setFalse: hidePluginInfo }] = + useBoolean(false) + const [deleting, { setTrue: showDeleting, setFalse: hideDeleting }] = useBoolean(false) const { setShowUpdatePluginModal } = useModalContext() const invalidateInstalledPluginList = useInvalidateInstalledPluginList() @@ -67,8 +62,7 @@ const Action: FC<Props> = ({ const owner = meta!.repo.split('/')[0] || author const repo = meta!.repo.split('/')[1] || pluginName const fetchedReleases = await fetchReleases(owner, repo) - if (fetchedReleases.length === 0) - return + if (fetchedReleases.length === 0) return const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta!.version) toast(toastProps.message, { type: toastProps.type }) if (needUpdate) { @@ -93,10 +87,8 @@ const Action: FC<Props> = ({ } } - const [isShowDeleteConfirm, { - setTrue: showDeleteConfirm, - setFalse: hideDeleteConfirm, - }] = useBoolean(false) + const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = + useBoolean(false) const handleDelete = useCallback(async () => { showDeleting() @@ -107,69 +99,65 @@ const Action: FC<Props> = ({ invalidateInstalledPluginList() onDelete() } - } - catch (error) { + } catch (error) { console.error('uninstallPlugin error', error) - } - finally { + } finally { hideDeleting() } - }, [hideDeleteConfirm, hideDeleting, installationId, invalidateInstalledPluginList, onDelete, showDeleting]) + }, [ + hideDeleteConfirm, + hideDeleting, + installationId, + invalidateInstalledPluginList, + onDelete, + showDeleting, + ]) return ( <div className="flex space-x-1"> {/* Only plugin installed from GitHub need to check if it's the new version */} - {isShowFetchNewVersion - && ( - <Tooltip> - <TooltipTrigger - render={( - <ActionButton onClick={handleFetchNewVersion}> - <span className="i-ri-loop-left-line size-4 text-text-tertiary" /> - </ActionButton> - )} - /> - <TooltipContent> - {t($ => $[`${i18nPrefix}.checkForUpdates`], { ns: 'plugin' })} - </TooltipContent> - </Tooltip> - )} - { - isShowInfo - && ( - <Tooltip> - <TooltipTrigger - render={( - <ActionButton onClick={showPluginInfo}> - <span className="i-ri-information-2-line size-4 text-text-tertiary" /> - </ActionButton> - )} - /> - <TooltipContent> - {t($ => $[`${i18nPrefix}.pluginInfo`], { ns: 'plugin' })} - </TooltipContent> - </Tooltip> - ) - } - { - isShowDelete - && ( - <Tooltip> - <TooltipTrigger - render={( - <ActionButton - className="text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive" - onClick={showDeleteConfirm} - > - <span className="i-ri-delete-bin-line size-4" /> - </ActionButton> - )} - /> - <TooltipContent> - {t($ => $[`${i18nPrefix}.delete`], { ns: 'plugin' })} - </TooltipContent> - </Tooltip> - ) - } + {isShowFetchNewVersion && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton onClick={handleFetchNewVersion}> + <span className="i-ri-loop-left-line size-4 text-text-tertiary" /> + </ActionButton> + } + /> + <TooltipContent> + {t(($) => $[`${i18nPrefix}.checkForUpdates`], { ns: 'plugin' })} + </TooltipContent> + </Tooltip> + )} + {isShowInfo && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton onClick={showPluginInfo}> + <span className="i-ri-information-2-line size-4 text-text-tertiary" /> + </ActionButton> + } + /> + <TooltipContent> + {t(($) => $[`${i18nPrefix}.pluginInfo`], { ns: 'plugin' })} + </TooltipContent> + </Tooltip> + )} + {isShowDelete && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton + className="text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive" + onClick={showDeleteConfirm} + > + <span className="i-ri-delete-bin-line size-4" /> + </ActionButton> + } + /> + <TooltipContent>{t(($) => $[`${i18nPrefix}.delete`], { ns: 'plugin' })}</TooltipContent> + </Tooltip> + )} {isShowPluginInfo && ( <PluginInfo @@ -179,23 +167,25 @@ const Action: FC<Props> = ({ onHide={hidePluginInfo} /> )} - <AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}> + <AlertDialog open={isShowDeleteConfirm} onOpenChange={(open) => !open && hideDeleteConfirm()}> <AlertDialogContent backdropProps={{ forceRender: true }}> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $[`${i18nPrefix}.delete`], { ns: 'plugin' })} + {t(($) => $[`${i18nPrefix}.delete`], { ns: 'plugin' })} </AlertDialogTitle> <div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $[`${i18nPrefix}.deleteContentLeft`], { ns: 'plugin' })} + {t(($) => $[`${i18nPrefix}.deleteContentLeft`], { ns: 'plugin' })} <span className="system-md-semibold">{pluginName}</span> - {t($ => $[`${i18nPrefix}.deleteContentRight`], { ns: 'plugin' })} + {t(($) => $[`${i18nPrefix}.deleteContentRight`], { ns: 'plugin' })} <br /> </div> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={handleDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx index 519a0534037697..7bf563e2244396 100644 --- a/web/app/components/plugins/plugin-item/index.tsx +++ b/web/app/components/plugins/plugin-item/index.tsx @@ -48,8 +48,8 @@ const PluginItem: FC<Props> = ({ }) => { const { t } = useTranslation() const { theme } = useTheme() - const currentPluginID = usePluginPageContext(v => v.currentPluginID) - const setCurrentPluginID = usePluginPageContext(v => v.setCurrentPluginID) + const currentPluginID = usePluginPageContext((v) => v.currentPluginID) + const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID) const { refreshPluginList } = useRefreshPluginList() const { @@ -62,8 +62,19 @@ const PluginItem: FC<Props> = ({ status, deprecated_reason, } = plugin - const { category, author, name, label, description, icon, icon_dark, verified, meta: declarationMeta } = plugin.declaration - const endpointCount = plugin.declaration.endpoint?.endpoints?.filter(endpoint => !endpoint.hidden).length ?? 0 + const { + category, + author, + name, + label, + description, + icon, + icon_dark, + verified, + meta: declarationMeta, + } = plugin.declaration + const endpointCount = + plugin.declaration.endpoint?.endpoints?.filter((endpoint) => !endpoint.hidden).length ?? 0 const hasEndpointDeclaration = !!plugin.declaration.endpoint const orgName = useMemo(() => { @@ -73,9 +84,11 @@ const PluginItem: FC<Props> = ({ const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) const isDifyVersionCompatible = useMemo(() => { - if (!langGeniusVersionInfo.current_version) - return true - return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0') + if (!langGeniusVersionInfo.current_version) return true + return isEqualOrLaterThanVersion( + langGeniusVersionInfo.current_version, + declarationMeta.minimum_dify_version ?? '0.0.0', + ) }, [declarationMeta.minimum_dify_version, langGeniusVersionInfo.current_version]) const isDeprecated = useMemo(() => { @@ -91,11 +104,13 @@ const PluginItem: FC<Props> = ({ const descriptionText = getValueFromI18nObject(description) const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const iconFileName = theme === 'dark' && icon_dark ? icon_dark : icon const iconSrc = iconFileName - ? (iconFileName.startsWith('http') ? iconFileName : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${iconFileName}`) + ? iconFileName.startsWith('http') + ? iconFileName + : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${iconFileName}` : '' return ( @@ -111,7 +126,12 @@ const PluginItem: FC<Props> = ({ setCurrentPluginID(plugin.plugin_id) }} > - <div className={cn('relative rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs', className)}> + <div + className={cn( + 'relative rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs', + className, + )} + > {/* Header */} <div className="flex"> <div className="flex size-10 items-center justify-center overflow-hidden rounded-xl border border-components-panel-border-subtle"> @@ -124,32 +144,47 @@ const PluginItem: FC<Props> = ({ <div className="ml-3 w-0 grow"> <div className="flex h-5 items-center"> <Title title={title} /> - {verified && <Verified className="ml-0.5 size-4" text={t($ => $['marketplace.verifiedTip'], { ns: 'plugin' })} />} + {verified && ( + <Verified + className="ml-0.5 size-4" + text={t(($) => $['marketplace.verifiedTip'], { ns: 'plugin' })} + /> + )} {!isDifyVersionCompatible && ( <Popover> <PopoverTrigger openOnHover - aria-label={t($ => $.difyVersionNotCompatible, { ns: 'plugin', minimalDifyVersion: declarationMeta.minimum_dify_version })} + aria-label={t(($) => $.difyVersionNotCompatible, { + ns: 'plugin', + minimalDifyVersion: declarationMeta.minimum_dify_version, + })} className="ml-0.5 inline-flex size-4 shrink-0 border-0 bg-transparent p-0" > <RiErrorWarningLine color="red" className="size-4 text-text-accent" /> </PopoverTrigger> <PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary"> - {t($ => $.difyVersionNotCompatible, { ns: 'plugin', minimalDifyVersion: declarationMeta.minimum_dify_version })} + {t(($) => $.difyVersionNotCompatible, { + ns: 'plugin', + minimalDifyVersion: declarationMeta.minimum_dify_version, + })} </PopoverContent> </Popover> )} <Badge className="ml-1 shrink-0" text={source === PluginSource.github ? plugin.meta!.version : plugin.version} - hasRedCornerMark={(source === PluginSource.marketplace) && !!plugin.latest_version && plugin.latest_version !== plugin.version} + hasRedCornerMark={ + source === PluginSource.marketplace && + !!plugin.latest_version && + plugin.latest_version !== plugin.version + } /> </div> <div className="flex items-center justify-between"> <Description text={descriptionText} descriptionLineRows={1}></Description> <div className="opacity-0 transition-opacity group-hover/plugin-item:opacity-100 focus-within:opacity-100" - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > <Action pluginUniqueIdentifier={plugin_unique_identifier} @@ -184,9 +219,9 @@ const PluginItem: FC<Props> = ({ <RiLoginCircleLine className="size-3 shrink-0" /> <span className="truncate" - title={t($ => $.endpointsEnabled, { ns: 'plugin', num: endpointCount })} + title={t(($) => $.endpointsEnabled, { ns: 'plugin', num: endpointCount })} > - {t($ => $.endpointsEnabled, { ns: 'plugin', num: endpointCount })} + {t(($) => $.endpointsEnabled, { ns: 'plugin', num: endpointCount })} </span> </div> </> @@ -194,58 +229,63 @@ const PluginItem: FC<Props> = ({ </div> {/* Source */} <div className="flex shrink-0 items-center"> - {source === PluginSource.github - && ( - <> - <a href={`https://github.com/${meta!.repo}`} target="_blank" className="flex items-center gap-1"> - <div className="system-2xs-medium-uppercase text-text-tertiary">{t($ => $.from, { ns: 'plugin' })}</div> - <div className="flex items-center space-x-0.5 text-text-secondary"> - <Github className="size-3" /> - <div className="system-2xs-semibold-uppercase">GitHub</div> - <RiArrowRightUpLine className="size-3" /> - </div> - </a> - </> - )} - {source === PluginSource.marketplace && enable_marketplace - && ( - <> - <a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target="_blank" className="flex items-center gap-0.5"> - <div className="system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $.from, { ns: 'plugin' })} - {' '} - <span className="text-text-secondary">marketplace</span> - </div> - <RiArrowRightUpLine className="size-3 text-text-secondary" /> - </a> - </> - )} - {source === PluginSource.local - && ( - <> - <div className="flex items-center gap-1"> - <RiHardDrive3Line className="size-3 text-text-tertiary" /> - <div className="system-2xs-medium-uppercase text-text-tertiary">Local Plugin</div> + {source === PluginSource.github && ( + <> + <a + href={`https://github.com/${meta!.repo}`} + target="_blank" + className="flex items-center gap-1" + > + <div className="system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $.from, { ns: 'plugin' })} + </div> + <div className="flex items-center space-x-0.5 text-text-secondary"> + <Github className="size-3" /> + <div className="system-2xs-semibold-uppercase">GitHub</div> + <RiArrowRightUpLine className="size-3" /> + </div> + </a> + </> + )} + {source === PluginSource.marketplace && enable_marketplace && ( + <> + <a + href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} + target="_blank" + className="flex items-center gap-0.5" + > + <div className="system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $.from, { ns: 'plugin' })}{' '} + <span className="text-text-secondary">marketplace</span> </div> - </> - )} - {source === PluginSource.debugging - && ( - <> - <div className="flex items-center gap-1"> - <RiBugLine className="size-3 text-text-warning" /> - <div className="system-2xs-medium-uppercase text-text-warning">Debugging Plugin</div> + <RiArrowRightUpLine className="size-3 text-text-secondary" /> + </a> + </> + )} + {source === PluginSource.local && ( + <> + <div className="flex items-center gap-1"> + <RiHardDrive3Line className="size-3 text-text-tertiary" /> + <div className="system-2xs-medium-uppercase text-text-tertiary">Local Plugin</div> + </div> + </> + )} + {source === PluginSource.debugging && ( + <> + <div className="flex items-center gap-1"> + <RiBugLine className="size-3 text-text-warning" /> + <div className="system-2xs-medium-uppercase text-text-warning"> + Debugging Plugin </div> - </> - )} + </div> + </> + )} </div> {/* Deprecated */} {source === PluginSource.marketplace && enable_marketplace && isDeprecated && ( <div className="flex shrink-0 items-center gap-x-2 system-2xs-medium-uppercase"> <span className="text-text-tertiary">·</span> - <span className="text-text-warning"> - {t($ => $.deprecated, { ns: 'plugin' })} - </span> + <span className="text-text-warning">{t(($) => $.deprecated, { ns: 'plugin' })}</span> </div> )} </div> diff --git a/web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx index 9508e97f951d68..81789bf496c2a4 100644 --- a/web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx @@ -21,13 +21,13 @@ vi.mock('@/i18n-config/language', () => ({ })) const mockCategoriesMap: Record<string, { label: string }> = { - 'tool': { label: 'Tool' }, - 'model': { label: 'Model' }, - 'extension': { label: 'Extension' }, + tool: { label: 'Tool' }, + model: { label: 'Model' }, + extension: { label: 'Extension' }, 'agent-strategy': { label: 'Agent' }, - 'datasource': { label: 'Datasource' }, - 'trigger': { label: 'Trigger' }, - 'bundle': { label: 'Bundle' }, + datasource: { label: 'Datasource' }, + trigger: { label: 'Trigger' }, + bundle: { label: 'Bundle' }, } vi.mock('../../hooks', () => ({ @@ -42,9 +42,7 @@ vi.mock('@/utils/format', () => ({ vi.mock('@/utils/mcp', () => ({ shouldUseMcpIcon: (src: unknown) => - typeof src === 'object' - && src !== null - && (src as { content?: string })?.content === '🔗', + typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗', })) vi.mock('@/app/components/base/app-icon', () => ({ @@ -95,7 +93,7 @@ vi.mock('../../../base/icons/src/vender/plugin', () => ({ })) vi.mock('../../base/badges/partner', () => ({ - default: ({ className, text }: { className?: string, text?: string }) => ( + default: ({ className, text }: { className?: string; text?: string }) => ( <div data-testid="partner-badge" className={className} title={text}> Partner </div> @@ -103,7 +101,7 @@ vi.mock('../../base/badges/partner', () => ({ })) vi.mock('../../base/badges/verified', () => ({ - default: ({ className, text }: { className?: string, text?: string }) => ( + default: ({ className, text }: { className?: string; text?: string }) => ( <div data-testid="verified-badge" className={className} title={text}> Verified </div> @@ -118,13 +116,7 @@ vi.mock('@/app/components/base/skeleton', () => ({ SkeletonRectangle: ({ className }: { className?: string }) => ( <div data-testid="skeleton-rectangle" className={className} /> ), - SkeletonRow: ({ - children, - className, - }: { - children: React.ReactNode - className?: string - }) => ( + SkeletonRow: ({ children, className }: { children: React.ReactNode; className?: string }) => ( <div data-testid="skeleton-row" className={className}> {children} </div> @@ -165,9 +157,7 @@ type MockMutation = { isPending: boolean } -const createMockMutation = ( - overrides?: Partial<MockMutation>, -): MockMutation => ({ +const createMockMutation = (overrides?: Partial<MockMutation>): MockMutation => ({ isSuccess: false, isPending: false, ...overrides, @@ -238,9 +228,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - expect( - screen.getByText('Are you sure you want to update this plugin?'), - ).toBeInTheDocument() + expect(screen.getByText('Are you sure you want to update this plugin?')).toBeInTheDocument() }) it('should render plugin card with plugin info', () => { @@ -263,9 +251,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - expect( - screen.getByRole('button', { name: /Install Now/i }), - ).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Install Now/i })).toBeInTheDocument() }) it('should render cancel button when not pending', () => { @@ -276,9 +262,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - expect( - screen.getByRole('button', { name: /Cancel Installation/i }), - ).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Cancel Installation/i })).toBeInTheDocument() }) it('should render modal with closable prop', () => { @@ -306,9 +290,7 @@ describe('PluginMutationModal', () => { it('should render modalBottomLeft when provided', () => { const props = createDefaultProps({ - modalBottomLeft: ( - <span data-testid="bottom-left-content">Additional Info</span> - ), + modalBottomLeft: <span data-testid="bottom-left-content">Additional Info</span>, }) render(<PluginMutationModal {...props} />) @@ -323,9 +305,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - expect( - screen.queryByTestId('bottom-left-content'), - ).not.toBeInTheDocument() + expect(screen.queryByTestId('bottom-left-content')).not.toBeInTheDocument() }) it('should render custom ReactNode for modelTitle', () => { @@ -342,9 +322,7 @@ describe('PluginMutationModal', () => { const props = createDefaultProps({ description: ( <div data-testid="custom-description"> - <strong>Warning:</strong> - {' '} - This action is irreversible. + <strong>Warning:</strong> This action is irreversible. </div> ), }) @@ -358,9 +336,7 @@ describe('PluginMutationModal', () => { const props = createDefaultProps({ confirmButtonText: ( <span> - <span data-testid="confirm-icon">✓</span> - {' '} - Confirm Action + <span data-testid="confirm-icon">✓</span> Confirm Action </span> ), }) @@ -374,9 +350,7 @@ describe('PluginMutationModal', () => { const props = createDefaultProps({ cancelButtonText: ( <span> - <span data-testid="cancel-icon">✗</span> - {' '} - Abort + <span data-testid="cancel-icon">✗</span> Abort </span> ), }) @@ -454,9 +428,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - expect( - screen.queryByRole('button', { name: /Cancel/i }), - ).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Cancel/i })).not.toBeInTheDocument() }) it('should show loading state on confirm button', () => { @@ -479,9 +451,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - expect( - screen.getByRole('button', { name: /Cancel/i }), - ).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument() }) it('should enable confirm button', () => { @@ -528,9 +498,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - expect( - screen.queryByRole('button', { name: /Cancel/i }), - ).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Cancel/i })).not.toBeInTheDocument() expect(document.querySelector('.bg-state-success-solid')).not.toBeInTheDocument() }) @@ -541,9 +509,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - expect( - screen.getByRole('button', { name: /Cancel/i }), - ).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument() expect(document.querySelector('.bg-state-success-solid')).toBeInTheDocument() }) @@ -554,9 +520,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - expect( - screen.queryByRole('button', { name: /Cancel/i }), - ).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Cancel/i })).not.toBeInTheDocument() expect(document.querySelector('.bg-state-success-solid')).toBeInTheDocument() }) }) @@ -647,9 +611,14 @@ describe('PluginMutationModal', () => { it('should have displayName set', () => { // The component sets displayName = 'PluginMutationModal' - const displayName - = (PluginMutationModal as unknown as { type?: { displayName?: string }, displayName?: string }).type?.displayName - || (PluginMutationModal as unknown as { displayName?: string }).displayName + const displayName = + ( + PluginMutationModal as unknown as { + type?: { displayName?: string } + displayName?: string + } + ).type?.displayName || + (PluginMutationModal as unknown as { displayName?: string }).displayName expect(displayName).toBe('PluginMutationModal') }) @@ -912,8 +881,8 @@ describe('PluginMutationModal', () => { // Get all buttons and verify order const buttons = screen.getAllByRole('button') // Cancel button should come before Confirm button - const cancelIndex = buttons.findIndex(b => b.textContent?.includes('Cancel')) - const confirmIndex = buttons.findIndex(b => b.textContent?.includes('Confirm')) + const cancelIndex = buttons.findIndex((b) => b.textContent?.includes('Cancel')) + const confirmIndex = buttons.findIndex((b) => b.textContent?.includes('Confirm')) expect(cancelIndex).toBeLessThan(confirmIndex) }) }) @@ -1037,22 +1006,13 @@ describe('PluginMutationModal', () => { // Simulate rapid pending state changes rerender( - <PluginMutationModal - {...props} - mutation={createMockMutation({ isPending: true })} - />, + <PluginMutationModal {...props} mutation={createMockMutation({ isPending: true })} />, ) rerender( - <PluginMutationModal - {...props} - mutation={createMockMutation({ isPending: false })} - />, + <PluginMutationModal {...props} mutation={createMockMutation({ isPending: false })} />, ) rerender( - <PluginMutationModal - {...props} - mutation={createMockMutation({ isSuccess: true })} - />, + <PluginMutationModal {...props} mutation={createMockMutation({ isSuccess: true })} />, ) expect(document.querySelector('.bg-state-success-solid')).toBeInTheDocument() diff --git a/web/app/components/plugins/plugin-mutation-model/index.tsx b/web/app/components/plugins/plugin-mutation-model/index.tsx index a2a208233e4030..4d7441423ee5bf 100644 --- a/web/app/components/plugins/plugin-mutation-model/index.tsx +++ b/web/app/components/plugins/plugin-mutation-model/index.tsx @@ -36,19 +36,14 @@ const PluginMutationModal: FC<Props> = ({ <Dialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DialogContent className="w-full min-w-[560px] overflow-hidden! border-none text-left align-middle"> <DialogCloseButton /> - <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {modelTitle} - </DialogTitle> + <DialogTitle className="title-2xl-semi-bold text-text-primary">{modelTitle}</DialogTitle> - <div className="mt-3 mb-2 system-md-regular text-text-secondary"> - {description} - </div> + <div className="mt-3 mb-2 system-md-regular text-text-secondary">{description}</div> <div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2"> <Card installed={mutation.isSuccess} @@ -58,15 +53,9 @@ const PluginMutationModal: FC<Props> = ({ /> </div> <div className="flex items-center gap-2 self-stretch pt-5"> - <div> - {modalBottomLeft} - </div> + <div>{modalBottomLeft}</div> <div className="ml-auto flex gap-2"> - {!mutation.isPending && ( - <Button onClick={onCancel}> - {cancelButtonText} - </Button> - )} + {!mutation.isPending && <Button onClick={onCancel}>{cancelButtonText}</Button>} <Button variant="primary" loading={mutation.isPending} diff --git a/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx index ea56d75fc943d6..481c69410ff26c 100644 --- a/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx @@ -19,25 +19,23 @@ vi.mock('../../hooks', () => ({ const renderWithProviders = ( ui: ReactElement, - options: { enableMarketplace: boolean, searchParams?: string } = { enableMarketplace: true }, + options: { enableMarketplace: boolean; searchParams?: string } = { enableMarketplace: true }, ) => { const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({ systemFeatures: { enable_marketplace: options.enableMarketplace }, }) const Wrapper = ({ children }: { children: ReactNode }) => ( <SystemFeaturesWrapper> - <NuqsTestingAdapter searchParams={options.searchParams ?? ''}> - {children} - </NuqsTestingAdapter> + <NuqsTestingAdapter searchParams={options.searchParams ?? ''}>{children}</NuqsTestingAdapter> </SystemFeaturesWrapper> ) return render(ui, { wrapper: Wrapper }) } const Consumer = () => { - const currentPluginID = usePluginPageContext(v => v.currentPluginID) - const setCurrentPluginID = usePluginPageContext(v => v.setCurrentPluginID) - const options = usePluginPageContext(v => v.options) + const currentPluginID = usePluginPageContext((v) => v.currentPluginID) + const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID) + const options = usePluginPageContext((v) => v.options) return ( <div> diff --git a/web/app/components/plugins/plugin-page/__tests__/context.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/context.spec.tsx index e534776a85295c..d8fbaa302cf3b9 100644 --- a/web/app/components/plugins/plugin-page/__tests__/context.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/context.spec.tsx @@ -2,7 +2,6 @@ import type { ReactElement } from 'react' import { screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' - import { PluginPageContext, usePluginPageContext } from '../context' import { PluginPageContextProvider } from '../context-provider' @@ -32,17 +31,19 @@ const renderWithMarketplace = (ui: ReactElement, enableMarketplace: boolean) => // Test component that uses the context const TestConsumer = () => { - const containerRef = usePluginPageContext(v => v.containerRef) - const options = usePluginPageContext(v => v.options) - const activeTab = usePluginPageContext(v => v.activeTab) + const containerRef = usePluginPageContext((v) => v.containerRef) + const options = usePluginPageContext((v) => v.options) + const activeTab = usePluginPageContext((v) => v.activeTab) return ( <div> <span data-testid="has-container-ref">{containerRef ? 'true' : 'false'}</span> <span data-testid="options-count">{options.length}</span> <span data-testid="active-tab">{activeTab}</span> - {options.map((opt: { value: string, text: string }) => ( - <span key={opt.value} data-testid={`option-${opt.value}`}>{opt.text}</span> + {options.map((opt: { value: string; text: string }) => ( + <span key={opt.value} data-testid={`option-${opt.value}`}> + {opt.text} + </span> ))} </div> ) diff --git a/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx index 73fda74bc7a175..10dd4d52ab7480 100644 --- a/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx @@ -8,7 +8,7 @@ vi.mock('@/context/i18n', () => ({ })) const mockDebugKey = vi.hoisted(() => ({ - data: null as null | { key: string, host: string, port: number }, + data: null as null | { key: string; host: string; port: number }, isLoading: false, })) @@ -27,9 +27,7 @@ vi.mock('../../base/key-value-item', () => ({ maskedValue?: string }) => ( <div data-testid={`kv-${label}`}> - {label} - : - {maskedValue || value} + {label}:{maskedValue || value} </div> ), })) @@ -65,7 +63,14 @@ describe('DebugInfo', () => { const trigger = screen.getByRole('button', { name: 'Debugging' }) - expect(trigger).toHaveClass('h-8', 'w-full', 'py-1', 'pr-1', 'pl-2', 'text-components-menu-item-text') + expect(trigger).toHaveClass( + 'h-8', + 'w-full', + 'py-1', + 'pr-1', + 'pl-2', + 'text-components-menu-item-text', + ) expect(trigger).not.toHaveClass('p-2', 'text-components-button-secondary-text') }) @@ -88,7 +93,10 @@ describe('DebugInfo', () => { await user.click(trigger) expect(screen.getByText('plugin.debugInfo.title')).toBeInTheDocument() - expect(screen.getByText('plugin.debugInfo.title').closest('.w-\\[360px\\]')).toHaveClass('rounded-2xl', 'shadow-2xl') + expect(screen.getByText('plugin.debugInfo.title').closest('.w-\\[360px\\]')).toHaveClass( + 'rounded-2xl', + 'shadow-2xl', + ) expect(screen.getByRole('link')).toHaveAttribute( 'href', 'https://docs.example.com/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin', diff --git a/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx index 444711342cf529..16f21f09b4724d 100644 --- a/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx @@ -3,7 +3,6 @@ import type { PluginPageProps } from '../index' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { useQueryState } from 'nuqs' import { beforeEach, describe, expect, it, vi } from 'vitest' - import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import useDocumentTitle from '@/hooks/use-document-title' import { usePluginInstallation } from '@/hooks/use-query-params' @@ -61,7 +60,12 @@ vi.mock('@/context/account-state', async (importOriginal) => { release_notes: '', can_auto_update: false, }, - workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.debug', 'plugin.plugin_preferences'], + workspacePermissionKeys: [ + 'plugin.install', + 'plugin.delete', + 'plugin.debug', + 'plugin.plugin_preferences', + ], })) }) vi.mock('@/context/workspace-state', async (importOriginal) => { @@ -78,7 +82,12 @@ vi.mock('@/context/workspace-state', async (importOriginal) => { release_notes: '', can_auto_update: false, }, - workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.debug', 'plugin.plugin_preferences'], + workspacePermissionKeys: [ + 'plugin.install', + 'plugin.delete', + 'plugin.debug', + 'plugin.plugin_preferences', + ], })) }) vi.mock('@/context/permission-state', async (importOriginal) => { @@ -95,7 +104,12 @@ vi.mock('@/context/permission-state', async (importOriginal) => { release_notes: '', can_auto_update: false, }, - workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.debug', 'plugin.plugin_preferences'], + workspacePermissionKeys: [ + 'plugin.install', + 'plugin.delete', + 'plugin.debug', + 'plugin.plugin_preferences', + ], })) }) vi.mock('@/context/version-state', async (importOriginal) => { @@ -112,7 +126,12 @@ vi.mock('@/context/version-state', async (importOriginal) => { release_notes: '', can_auto_update: false, }, - workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.debug', 'plugin.plugin_preferences'], + workspacePermissionKeys: [ + 'plugin.install', + 'plugin.delete', + 'plugin.debug', + 'plugin.plugin_preferences', + ], })) }) vi.mock('@/context/system-features-state', async (importOriginal) => { @@ -129,23 +148,26 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { release_notes: '', can_auto_update: false, }, - workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.debug', 'plugin.plugin_preferences'], + workspacePermissionKeys: [ + 'plugin.install', + 'plugin.delete', + 'plugin.debug', + 'plugin.plugin_preferences', + ], })) }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('@/service/use-plugins', () => ({ hasPluginPermission: (permission: string | undefined, isAdmin: boolean) => { - if (!permission) - return false - if (permission === 'noone') - return false - if (permission === 'everyone') - return true + if (!permission) return false + if (permission === 'noone') return false + if (permission === 'everyone') return true return isAdmin }, useReferenceSettings: () => ({ @@ -420,12 +442,9 @@ describe('PluginPage Component', () => { // Override mock to disable management permission vi.doMock('@/service/use-plugins', () => ({ hasPluginPermission: (permission: string | undefined, isAdmin: boolean) => { - if (!permission) - return false - if (permission === 'noone') - return false - if (permission === 'everyone') - return true + if (!permission) return false + if (permission === 'noone') return false + if (permission === 'everyone') return true return isAdmin }, useReferenceSettings: () => ({ @@ -600,9 +619,12 @@ describe('PluginPage Component', () => { render(<PluginPageWithContext {...createDefaultProps()} />) - await waitFor(() => { - expect(screen.getByTestId('install-marketplace-modal')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByTestId('install-marketplace-modal')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) }) it('should redirect supported plugin categories to integrations before opening the modal', async () => { @@ -622,7 +644,9 @@ describe('PluginPage Component', () => { render(<PluginPageWithContext {...createDefaultProps()} />) await waitFor(() => { - expect(mockRouterReplace).toHaveBeenCalledWith('/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%3A0.2.4%40test%22%5D') + expect(mockRouterReplace).toHaveBeenCalledWith( + '/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%3A0.2.4%40test%22%5D', + ) }) expect(screen.queryByTestId('install-marketplace-modal')).not.toBeInTheDocument() }) @@ -885,9 +909,12 @@ describe('PluginPage Component', () => { render(<PluginPageWithContext {...createDefaultProps()} />) // Wait for modal to appear - await waitFor(() => { - expect(screen.getByTestId('install-marketplace-modal')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByTestId('install-marketplace-modal')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) // Close modal fireEvent.click(screen.getByText('Close')) @@ -1017,7 +1044,9 @@ describe('Uploader Hook Integration', () => { container.dispatchEvent(dragEnterEvent) }) - const file = new File(['content'], 'test-plugin.difypkg', { type: 'application/octet-stream' }) + const file = new File(['content'], 'test-plugin.difypkg', { + type: 'application/octet-stream', + }) const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) Object.defineProperty(dropEvent, 'dataTransfer', { value: { files: [file] }, @@ -1170,9 +1199,12 @@ describe('PluginPage Integration', () => { }) // Wait for modal - await waitFor(() => { - expect(screen.getByTestId('install-marketplace-modal')).toBeInTheDocument() - }, { timeout: 3000 }) + await waitFor( + () => { + expect(screen.getByTestId('install-marketplace-modal')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) // Close modal fireEvent.click(screen.getByText('Close')) diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx index a922be93cd60c4..6f1da74f110dfd 100644 --- a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx @@ -4,9 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import InstallPluginDropdown from '../install-plugin-dropdown' -const { - mockSystemFeatures, -} = vi.hoisted(() => ({ +const { mockSystemFeatures } = vi.hoisted(() => ({ mockSystemFeatures: { enable_marketplace: true, plugin_installation_permission: { @@ -39,8 +37,12 @@ vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({ })) vi.mock('@remixicon/react', () => ({ - RiAddCircleFill: ({ className }: { className?: string }) => <span data-testid="add-circle-fill-icon" className={className} />, - RiArrowDownSLine: ({ className }: { className?: string }) => <span data-testid="arrow-down-icon" className={className} />, + RiAddCircleFill: ({ className }: { className?: string }) => ( + <span data-testid="add-circle-fill-icon" className={className} /> + ), + RiArrowDownSLine: ({ className }: { className?: string }) => ( + <span data-testid="arrow-down-icon" className={className} /> + ), })) type MockButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { @@ -49,18 +51,29 @@ type MockButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, onClick, className, variant, ...props }: MockButtonProps) => ( - <button type="button" data-testid="button-content" data-variant={variant} className={className} onClick={onClick} {...props}>{children}</button> + <button + type="button" + data-testid="button-content" + data-variant={variant} + className={className} + onClick={onClick} + {...props} + > + {children} + </button> ), })) vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') - const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + const DropdownMenuContext = React.createContext<{ + isOpen: boolean + setOpen: (open: boolean) => void + } | null>(null) const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } @@ -79,14 +92,15 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const [internalOpen, setInternalOpen] = React.useState(open ?? false) const isOpen = open ?? internalOpen const setOpen = (nextOpen: boolean) => { - if (open === undefined) - setInternalOpen(nextOpen) + if (open === undefined) setInternalOpen(nextOpen) onOpenChange?.(nextOpen) } return ( <DropdownMenuContext value={{ isOpen, setOpen }}> - <div data-testid="dropdown-menu" data-open={isOpen} data-modal={modal}>{children}</div> + <div data-testid="dropdown-menu" data-open={isOpen} data-modal={modal}> + {children} + </div> </DropdownMenuContext> ) }, @@ -106,9 +120,17 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { } if (render) - return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record<string, unknown>, children) + return React.cloneElement( + render, + { 'data-testid': 'dropdown-trigger', onClick: handleClick } as Record<string, unknown>, + children, + ) - return <button data-testid="dropdown-trigger" onClick={handleClick}>{children}</button> + return ( + <button data-testid="dropdown-trigger" onClick={handleClick}> + {children} + </button> + ) }, DropdownMenuContent: ({ children, @@ -118,7 +140,11 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { popupClassName?: string }) => { const { isOpen } = useDropdownMenuContext() - return isOpen ? <div data-testid="dropdown-content" className={popupClassName}>{children}</div> : null + return isOpen ? ( + <div data-testid="dropdown-content" className={popupClassName}> + {children} + </div> + ) : null }, DropdownMenuItem: ({ children, @@ -147,22 +173,20 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="github-modal"> - <button data-testid="close-github-modal" onClick={onClose}>close</button> + <button data-testid="close-github-modal" onClick={onClose}> + close + </button> </div> ), })) vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({ - default: ({ - file, - onClose, - }: { - file: File - onClose: () => void - }) => ( + default: ({ file, onClose }: { file: File; onClose: () => void }) => ( <div data-testid="local-modal"> <span>{file.name}</span> - <button data-testid="close-local-modal" onClick={onClose}>close</button> + <button data-testid="close-local-modal" onClick={onClose}> + close + </button> </div> ), })) @@ -184,9 +208,18 @@ describe('InstallPluginDropdown', () => { expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() expect(screen.getByText('plugin.source.github')).toBeInTheDocument() expect(screen.getByText('plugin.source.local')).toBeInTheDocument() - expect(container.querySelector('.i-custom-vender-plugin-box-sparkle-fill')).toHaveClass('size-4', 'shrink-0') - expect(container.querySelector('.i-custom-vender-solid-general-github')).toHaveClass('size-4', 'shrink-0') - expect(container.querySelector('.i-custom-vender-solid-files-file-zip')).toHaveClass('size-4', 'shrink-0') + expect(container.querySelector('.i-custom-vender-plugin-box-sparkle-fill')).toHaveClass( + 'size-4', + 'shrink-0', + ) + expect(container.querySelector('.i-custom-vender-solid-general-github')).toHaveClass( + 'size-4', + 'shrink-0', + ) + expect(container.querySelector('.i-custom-vender-solid-files-file-zip')).toHaveClass( + 'size-4', + 'shrink-0', + ) }) it('applies custom trigger label and presentation props', () => { @@ -236,7 +269,9 @@ describe('InstallPluginDropdown', () => { it('keeps the trigger visible but disabled when install is unavailable', () => { const onSwitchToMarketplaceTab = vi.fn() - const { container } = render(<InstallPluginDropdown disabled onSwitchToMarketplaceTab={onSwitchToMarketplaceTab} />) + const { container } = render( + <InstallPluginDropdown disabled onSwitchToMarketplaceTab={onSwitchToMarketplaceTab} />, + ) const trigger = screen.getByTestId('dropdown-trigger') diff --git a/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx index 54818e4d02a82b..608b800eac5087 100644 --- a/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx @@ -3,22 +3,17 @@ import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@langgenius/dify-ui/dialog', () => ({ - Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => ( - open !== false - ? ( - <div data-testid="modal"> - {children} - </div> - ) - : null - ), + Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => + open !== false ? <div data-testid="modal">{children}</div> : null, DialogContent: ({ children }: { children: React.ReactNode }) => <>{children}</>, - DialogTitle: ({ children }: { children: React.ReactNode }) => <div data-testid="modal-title">{children}</div>, + DialogTitle: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="modal-title">{children}</div> + ), DialogCloseButton: () => <button type="button">Close</button>, })) vi.mock('../../base/key-value-item', () => ({ - default: ({ label, value }: { label: string, value: string }) => ( + default: ({ label, value }: { label: string; value: string }) => ( <div data-testid="key-value-item"> <span data-testid="kv-label">{label}</span> <span data-testid="kv-value">{value}</span> @@ -52,21 +47,21 @@ describe('PlugInfo', () => { const kvItems = screen.getAllByTestId('key-value-item') expect(kvItems.length).toBeGreaterThanOrEqual(1) const values = screen.getAllByTestId('kv-value') - expect(values.some(v => v.textContent?.includes('https://github.com/org/plugin'))).toBe(true) + expect(values.some((v) => v.textContent?.includes('https://github.com/org/plugin'))).toBe(true) }) it('should display release info', () => { render(<PlugInfo release="v1.0.0" onHide={vi.fn()} />) const values = screen.getAllByTestId('kv-value') - expect(values.some(v => v.textContent === 'v1.0.0')).toBe(true) + expect(values.some((v) => v.textContent === 'v1.0.0')).toBe(true) }) it('should display package name', () => { render(<PlugInfo packageName="my-plugin.difypkg" onHide={vi.fn()} />) const values = screen.getAllByTestId('kv-value') - expect(values.some(v => v.textContent === 'my-plugin.difypkg')).toBe(true) + expect(values.some((v) => v.textContent === 'my-plugin.difypkg')).toBe(true) }) it('should not show items for undefined props', () => { diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx index 6d9fe201f2fcfc..ff87c4ae5a6426 100644 --- a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx @@ -42,21 +42,29 @@ vi.mock('../../hooks', () => ({ })) vi.mock('../context', () => ({ - usePluginPageContext: (selector: (value: { - filters: typeof mockState.filters - setFilters: typeof mockSetFilters - currentPluginID: string | undefined - setCurrentPluginID: typeof mockSetCurrentPluginID - }) => unknown) => selector({ - filters: mockState.filters, - setFilters: mockSetFilters, - currentPluginID: mockState.currentPluginID, - setCurrentPluginID: mockSetCurrentPluginID, - }), + usePluginPageContext: ( + selector: (value: { + filters: typeof mockState.filters + setFilters: typeof mockSetFilters + currentPluginID: string | undefined + setCurrentPluginID: typeof mockSetCurrentPluginID + }) => unknown, + ) => + selector({ + filters: mockState.filters, + setFilters: mockSetFilters, + currentPluginID: mockState.currentPluginID, + setCurrentPluginID: mockSetCurrentPluginID, + }), })) vi.mock('../filter-management', () => ({ - default: ({ hideCategoryFilter, hideTagFilter, onFilterChange, rightSlot }: { + default: ({ + hideCategoryFilter, + hideTagFilter, + onFilterChange, + rightSlot, + }: { hideCategoryFilter?: boolean hideTagFilter?: boolean onFilterChange: (filters: typeof mockState.filters) => void @@ -67,11 +75,13 @@ vi.mock('../filter-management', () => ({ data-testid="filter-management" data-hide-category-filter={hideCategoryFilter ? 'true' : 'false'} data-hide-tag-filter={hideTagFilter ? 'true' : 'false'} - onClick={() => onFilterChange({ - categories: [], - tags: [], - searchQuery: 'beta', - })} + onClick={() => + onFilterChange({ + categories: [], + tags: [], + searchQuery: 'beta', + }) + } > filter </button> @@ -81,23 +91,60 @@ vi.mock('../filter-management', () => ({ })) vi.mock('../empty', () => ({ - default: ({ canInstall, contentInset, onSwitchToMarketplace, variant }: { canInstall?: boolean, contentInset?: string, onSwitchToMarketplace?: () => void, variant?: string }) => ( - <div data-can-install={canInstall ? 'true' : 'false'} data-content-inset={contentInset} data-has-marketplace-action={onSwitchToMarketplace ? 'true' : 'false'} data-testid="empty-state" data-variant={variant}>empty</div> + default: ({ + canInstall, + contentInset, + onSwitchToMarketplace, + variant, + }: { + canInstall?: boolean + contentInset?: string + onSwitchToMarketplace?: () => void + variant?: string + }) => ( + <div + data-can-install={canInstall ? 'true' : 'false'} + data-content-inset={contentInset} + data-has-marketplace-action={onSwitchToMarketplace ? 'true' : 'false'} + data-testid="empty-state" + data-variant={variant} + > + empty + </div> ), })) vi.mock('../list', () => ({ - default: ({ children, pluginList }: { children?: React.ReactNode, pluginList: PluginDetail[] }) => ( + default: ({ + children, + pluginList, + }: { + children?: React.ReactNode + pluginList: PluginDetail[] + }) => ( <div data-testid="plugin-list"> - {pluginList.map(plugin => <div key={plugin.plugin_id} data-testid="plugin-list-item">{plugin.plugin_id}</div>)} + {pluginList.map((plugin) => ( + <div key={plugin.plugin_id} data-testid="plugin-list-item"> + {plugin.plugin_id} + </div> + ))} {children} </div> ), })) vi.mock('@/app/components/integrations/tool-provider-card', () => ({ - default: ({ collection, showBuiltInBadge }: { collection: Collection, showBuiltInBadge?: boolean }) => ( - <div data-show-built-in-badge={showBuiltInBadge ? 'true' : 'false'} data-testid="builtin-tool-card"> + default: ({ + collection, + showBuiltInBadge, + }: { + collection: Collection + showBuiltInBadge?: boolean + }) => ( + <div + data-show-built-in-badge={showBuiltInBadge ? 'true' : 'false'} + data-testid="builtin-tool-card" + > {collection.id} </div> ), @@ -113,22 +160,38 @@ vi.mock('@/app/components/integrations/hooks/use-tool-marketplace-panel', () => })) vi.mock('@/app/components/tools/marketplace', () => ({ - default: ({ filterPluginTags, searchPluginText }: { filterPluginTags: string[], searchPluginText: string }) => ( - <div data-filter-plugin-tags={filterPluginTags.join(',')} data-search-plugin-text={searchPluginText} data-testid="tool-marketplace" /> + default: ({ + filterPluginTags, + searchPluginText, + }: { + filterPluginTags: string[] + searchPluginText: string + }) => ( + <div + data-filter-plugin-tags={filterPluginTags.join(',')} + data-search-plugin-text={searchPluginText} + data-testid="tool-marketplace" + /> ), })) vi.mock('@/app/components/tools/provider/detail', () => ({ - default: ({ collection, onHide }: { collection: Collection, onHide: () => void }) => ( + default: ({ collection, onHide }: { collection: Collection; onHide: () => void }) => ( <div data-testid="builtin-tool-detail"> <span>{collection.id}</span> - <button type="button" onClick={onHide}>hide builtin detail</button> + <button type="button" onClick={onHide}> + hide builtin detail + </button> </div> ), })) vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ - default: ({ detail, onHide, onUpdate }: { + default: ({ + detail, + onHide, + onUpdate, + }: { detail?: PluginDetail onHide: () => void onUpdate: () => void @@ -141,32 +204,38 @@ vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ ), })) -const createPlugin = (pluginId: string, label: string, tags: string[] = [], category: PluginCategoryEnum = PluginCategoryEnum.tool): PluginDetail => ({ - id: pluginId, - created_at: '2024-01-01', - updated_at: '2024-01-02', - name: label, - plugin_id: pluginId, - plugin_unique_identifier: `${pluginId}-uid`, - declaration: { - category, - name: pluginId, - label: { en_US: label }, - description: { en_US: `${label} description` }, - tags, - } as PluginDetail['declaration'], - installation_id: `${pluginId}-install`, - tenant_id: 'tenant-1', - endpoints_setups: 0, - endpoints_active: 0, - version: '1.0.0', - latest_version: '1.0.0', - latest_unique_identifier: `${pluginId}-uid`, - source: 'marketplace' as PluginDetail['source'], - status: 'active', - deprecated_reason: '', - alternative_plugin_id: '', -}) as PluginDetail +const createPlugin = ( + pluginId: string, + label: string, + tags: string[] = [], + category: PluginCategoryEnum = PluginCategoryEnum.tool, +): PluginDetail => + ({ + id: pluginId, + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: label, + plugin_id: pluginId, + plugin_unique_identifier: `${pluginId}-uid`, + declaration: { + category, + name: pluginId, + label: { en_US: label }, + description: { en_US: `${label} description` }, + tags, + } as PluginDetail['declaration'], + installation_id: `${pluginId}-install`, + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: `${pluginId}-uid`, + source: 'marketplace' as PluginDetail['source'], + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + }) as PluginDetail const createBuiltinTool = (id: string, label: string, labels: string[] = []): Collection => ({ id, @@ -240,7 +309,9 @@ describe('PluginsPanel', () => { render(<PluginsPanel />) expect(screen.getByTestId('filter-management-wrap').parentElement).toHaveClass('px-12') - expect(screen.getByTestId('filter-management-wrap').parentElement).not.toHaveClass('max-w-[1600px]') + expect(screen.getByTestId('filter-management-wrap').parentElement).not.toHaveClass( + 'max-w-[1600px]', + ) expect(screen.getByTestId('empty-state')).toHaveAttribute('data-content-inset', 'default') }) @@ -261,7 +332,10 @@ describe('PluginsPanel', () => { render(<PluginsPanel contentInset="compact" fixedCategory={PluginCategoryEnum.trigger} />) - expect(screen.getByTestId('filter-management')).toHaveAttribute('data-hide-category-filter', 'true') + expect(screen.getByTestId('filter-management')).toHaveAttribute( + 'data-hide-category-filter', + 'true', + ) expect(screen.getByTestId('filter-management')).toHaveAttribute('data-hide-tag-filter', 'false') expect(screen.getByTestId('plugin-list')).toHaveTextContent('trigger-plugin') expect(screen.getByTestId('plugin-list')).not.toHaveTextContent('tool-plugin') @@ -312,7 +386,10 @@ describe('PluginsPanel', () => { expect(screen.getByTestId('plugin-list')).not.toHaveTextContent('rag-tool-plugin') expect(screen.getByTestId('builtin-tool-card')).toHaveTextContent('search-builtin-tool') expect(screen.getByTestId('plugin-list')).not.toHaveTextContent('rag-builtin-tool') - expect(screen.getByTestId('tool-marketplace')).toHaveAttribute('data-filter-plugin-tags', 'search') + expect(screen.getByTestId('tool-marketplace')).toHaveAttribute( + 'data-filter-plugin-tags', + 'search', + ) }) it('renders builtin tools after plugin tools on the tool integrations page', () => { @@ -322,9 +399,7 @@ describe('PluginsPanel', () => { mockUseInstalledPluginList.mockReturnValue({ data: { plugins: [], - builtin_tools: [ - createBuiltinTool('builtin-tool', 'Builtin Tool'), - ], + builtin_tools: [createBuiltinTool('builtin-tool', 'Builtin Tool')], }, isLoading: false, isFetching: false, @@ -342,17 +417,20 @@ describe('PluginsPanel', () => { expect(builtinToolCard).toHaveTextContent('builtin-tool') expect(builtinToolCard).toHaveAttribute('data-show-built-in-badge', 'true') expect(pluginList).toContainElement(builtinToolCard) - expect(screen.getByRole('button', { name: 'builtin-tool' })).toHaveAttribute('aria-pressed', 'false') - expect(pluginItem.compareDocumentPosition(builtinToolCard) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(screen.getByRole('button', { name: 'builtin-tool' })).toHaveAttribute( + 'aria-pressed', + 'false', + ) + expect( + pluginItem.compareDocumentPosition(builtinToolCard) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() }) it('opens the builtin tool detail from the builtin tools list', () => { mockUseInstalledPluginList.mockReturnValue({ data: { plugins: [], - builtin_tools: [ - createBuiltinTool('builtin-tool', 'Builtin Tool'), - ], + builtin_tools: [createBuiltinTool('builtin-tool', 'Builtin Tool')], }, isLoading: false, isFetching: false, @@ -401,7 +479,12 @@ describe('PluginsPanel', () => { render(<PluginsPanel contentInset="compact" fixedCategory={PluginCategoryEnum.tool} />) expect(screen.getByTestId('tool-marketplace')).toBeInTheDocument() - expect(screen.getByTestId('plugin-list').compareDocumentPosition(screen.getByTestId('tool-marketplace')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect( + screen + .getByTestId('plugin-list') + .compareDocumentPosition(screen.getByTestId('tool-marketplace')) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() }) it('uses the Figma trigger toolbar frame and renders the toolbar action', () => { @@ -413,9 +496,20 @@ describe('PluginsPanel', () => { />, ) - expect(screen.getByTestId('filter-management-wrap').parentElement).toHaveClass('h-12', 'py-2', 'max-w-[1600px]', 'px-6') - expect(screen.getByTestId('filter-management-wrap').parentElement).not.toHaveClass('sticky', 'top-0', 'z-10') - expect(screen.getByTestId('filter-management-wrap').parentElement).toHaveClass('bg-components-panel-bg') + expect(screen.getByTestId('filter-management-wrap').parentElement).toHaveClass( + 'h-12', + 'py-2', + 'max-w-[1600px]', + 'px-6', + ) + expect(screen.getByTestId('filter-management-wrap').parentElement).not.toHaveClass( + 'sticky', + 'top-0', + 'z-10', + ) + expect(screen.getByTestId('filter-management-wrap').parentElement).toHaveClass( + 'bg-components-panel-bg', + ) expect(screen.getByText('update setting')).toBeInTheDocument() }) @@ -428,17 +522,40 @@ describe('PluginsPanel', () => { />, ) - expect(screen.getByTestId('filter-management-wrap').parentElement).toHaveClass('h-12', 'py-2', 'max-w-[1600px]', 'px-6') - expect(screen.getByTestId('filter-management-wrap').parentElement).not.toHaveClass('sticky', 'top-0', 'z-10') - expect(screen.getByTestId('filter-management-wrap').parentElement).toHaveClass('bg-components-panel-bg') - expect(screen.getByTestId('filter-management')).toHaveAttribute('data-hide-category-filter', 'true') + expect(screen.getByTestId('filter-management-wrap').parentElement).toHaveClass( + 'h-12', + 'py-2', + 'max-w-[1600px]', + 'px-6', + ) + expect(screen.getByTestId('filter-management-wrap').parentElement).not.toHaveClass( + 'sticky', + 'top-0', + 'z-10', + ) + expect(screen.getByTestId('filter-management-wrap').parentElement).toHaveClass( + 'bg-components-panel-bg', + ) + expect(screen.getByTestId('filter-management')).toHaveAttribute( + 'data-hide-category-filter', + 'true', + ) expect(screen.getByTestId('filter-management')).toHaveAttribute('data-hide-tag-filter', 'true') expect(screen.getByText('update setting')).toBeInTheDocument() - expect(screen.getByTestId('empty-state')).toHaveAttribute('data-variant', 'integrationsAgentStrategy') + expect(screen.getByTestId('empty-state')).toHaveAttribute( + 'data-variant', + 'integrationsAgentStrategy', + ) }) it('passes install permission to the integration category empty state', () => { - render(<PluginsPanel canInstall={false} contentInset="compact" fixedCategory={PluginCategoryEnum.trigger} />) + render( + <PluginsPanel + canInstall={false} + contentInset="compact" + fixedCategory={PluginCategoryEnum.trigger} + />, + ) expect(screen.getByTestId('empty-state')).toHaveAttribute('data-can-install', 'false') }) @@ -452,18 +569,39 @@ describe('PluginsPanel', () => { />, ) - expect(screen.getByTestId('filter-management-wrap').parentElement).toHaveClass('h-12', 'py-2', 'max-w-[1600px]', 'px-6') - expect(screen.getByTestId('filter-management-wrap').parentElement).not.toHaveClass('sticky', 'top-0', 'z-10') - expect(screen.getByTestId('filter-management')).toHaveAttribute('data-hide-category-filter', 'true') + expect(screen.getByTestId('filter-management-wrap').parentElement).toHaveClass( + 'h-12', + 'py-2', + 'max-w-[1600px]', + 'px-6', + ) + expect(screen.getByTestId('filter-management-wrap').parentElement).not.toHaveClass( + 'sticky', + 'top-0', + 'z-10', + ) + expect(screen.getByTestId('filter-management')).toHaveAttribute( + 'data-hide-category-filter', + 'true', + ) expect(screen.getByTestId('filter-management')).toHaveAttribute('data-hide-tag-filter', 'true') expect(screen.getByText('update setting')).toBeInTheDocument() - expect(screen.getByTestId('empty-state')).toHaveAttribute('data-variant', 'integrationsExtension') + expect(screen.getByTestId('empty-state')).toHaveAttribute( + 'data-variant', + 'integrationsExtension', + ) }) it('passes the marketplace action to the empty state', () => { const onSwitchToMarketplace = vi.fn() - render(<PluginsPanel contentInset="compact" fixedCategory={PluginCategoryEnum.extension} onSwitchToMarketplace={onSwitchToMarketplace} />) + render( + <PluginsPanel + contentInset="compact" + fixedCategory={PluginCategoryEnum.extension} + onSwitchToMarketplace={onSwitchToMarketplace} + />, + ) expect(screen.getByTestId('empty-state')).toHaveAttribute('data-has-marketplace-action', 'true') }) @@ -472,7 +610,12 @@ describe('PluginsPanel', () => { mockState.filters.tags = ['search'] mockPluginListWithLatestVersion.mockReturnValue([ createPlugin('rag-extension', 'Rag Extension', ['rag'], PluginCategoryEnum.extension), - createPlugin('search-extension', 'Search Extension', ['search'], PluginCategoryEnum.extension), + createPlugin( + 'search-extension', + 'Search Extension', + ['search'], + PluginCategoryEnum.extension, + ), ]) render(<PluginsPanel contentInset="compact" fixedCategory={PluginCategoryEnum.extension} />) @@ -528,9 +671,7 @@ describe('PluginsPanel', () => { it('renders the empty state and keeps the current plugin detail in sync', () => { mockState.currentPluginID = 'beta-tool' mockState.filters.searchQuery = 'missing' - mockPluginListWithLatestVersion.mockReturnValue([ - createPlugin('beta-tool', 'Beta Tool'), - ]) + mockPluginListWithLatestVersion.mockReturnValue([createPlugin('beta-tool', 'Beta Tool')]) render(<PluginsPanel />) diff --git a/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts index d55bad3c85b793..3704e5ce27e880 100644 --- a/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts +++ b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts @@ -5,8 +5,13 @@ import { toast } from '@langgenius/dify-ui/toast' import { waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderHookWithSystemFeatures as renderHook } from '@/__tests__/utils/mock-system-features' - -import { useInvalidateReferenceSettings, useMutationPluginPermissionSettings, useMutationReferenceSettings, usePluginAutoUpgradeSettings, usePluginPermissionSettings } from '@/service/use-plugins' +import { + useInvalidateReferenceSettings, + useMutationPluginPermissionSettings, + useMutationReferenceSettings, + usePluginAutoUpgradeSettings, + usePluginPermissionSettings, +} from '@/service/use-plugins' import { PermissionType, PluginCategoryEnum } from '../../types' import useReferenceSetting, { useCanInstallPluginFromMarketplace } from '../use-reference-setting' @@ -62,7 +67,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -353,8 +359,7 @@ describe('useReferenceSetting Hook', () => { renderHook(() => useReferenceSetting(PluginCategoryEnum.tool)) // Trigger the onSuccess callback - if (onSuccessCallback) - onSuccessCallback() + if (onSuccessCallback) onSuccessCallback() await waitFor(() => { expect(mockInvalidate).toHaveBeenCalled() diff --git a/web/app/components/plugins/plugin-page/__tests__/use-uploader.spec.ts b/web/app/components/plugins/plugin-page/__tests__/use-uploader.spec.ts index 3936117ead22cf..f30fb08c9c7a2e 100644 --- a/web/app/components/plugins/plugin-page/__tests__/use-uploader.spec.ts +++ b/web/app/components/plugins/plugin-page/__tests__/use-uploader.spec.ts @@ -19,8 +19,7 @@ describe('useUploader Hook', () => { }) afterEach(() => { - if (mockContainer.parentNode) - document.body.removeChild(mockContainer) + if (mockContainer.parentNode) document.body.removeChild(mockContainer) }) describe('Initial State', () => { diff --git a/web/app/components/plugins/plugin-page/context-provider.tsx b/web/app/components/plugins/plugin-page/context-provider.tsx index 3458d31a93cf69..457ca4386a8b79 100644 --- a/web/app/components/plugins/plugin-page/context-provider.tsx +++ b/web/app/components/plugins/plugin-page/context-provider.tsx @@ -5,17 +5,11 @@ import type { PluginPageTab } from './context' import type { FilterState } from './filter-management' import { useSuspenseQuery } from '@tanstack/react-query' import { parseAsStringEnum, useQueryState } from 'nuqs' -import { - useMemo, - useRef, - useState, -} from 'react' +import { useMemo, useRef, useState } from 'react' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { PLUGIN_PAGE_TABS_MAP, usePluginPageTabs } from '../hooks' import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants' -import { - PluginPageContext, -} from './context' +import { PluginPageContext } from './context' const PLUGIN_PAGE_TAB_VALUES: PluginPageTab[] = [ PLUGIN_PAGE_TABS_MAP.plugins, @@ -23,8 +17,9 @@ const PLUGIN_PAGE_TAB_VALUES: PluginPageTab[] = [ ...Object.values(PLUGIN_TYPE_SEARCH_MAP), ] -const parseAsPluginPageTab = parseAsStringEnum<PluginPageTab>(PLUGIN_PAGE_TAB_VALUES) - .withDefault(PLUGIN_PAGE_TABS_MAP.plugins) +const parseAsPluginPageTab = parseAsStringEnum<PluginPageTab>(PLUGIN_PAGE_TAB_VALUES).withDefault( + PLUGIN_PAGE_TABS_MAP.plugins, +) type PluginPageContextProviderProps = { children: ReactNode @@ -36,20 +31,24 @@ export const PluginPageContextProvider = ({ initialFilters, }: PluginPageContextProviderProps) => { const containerRef = useRef<HTMLDivElement | null>(null) - const [filters, setFilters] = useState<FilterState>(initialFilters ?? { - categories: [], - tags: [], - searchQuery: '', - }) + const [filters, setFilters] = useState<FilterState>( + initialFilters ?? { + categories: [], + tags: [], + searchQuery: '', + }, + ) const [currentPluginID, setCurrentPluginID] = useState<string | undefined>() const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const tabs = usePluginPageTabs() const options = useMemo(() => { - return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace) + return enable_marketplace + ? tabs + : tabs.filter((tab) => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace) }, [tabs, enable_marketplace]) const [activeTab, setActiveTab] = useQueryState('tab', parseAsPluginPageTab) diff --git a/web/app/components/plugins/plugin-page/context.ts b/web/app/components/plugins/plugin-page/context.ts index fe3706f4f72879..c90cd2893e63a4 100644 --- a/web/app/components/plugins/plugin-page/context.ts +++ b/web/app/components/plugins/plugin-page/context.ts @@ -4,13 +4,11 @@ import type { RefObject } from 'react' import type { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants' import type { FilterState } from './filter-management' import { noop } from 'es-toolkit/function' -import { - createContext, - useContextSelector, -} from 'use-context-selector' +import { createContext, useContextSelector } from 'use-context-selector' import { PLUGIN_PAGE_TABS_MAP } from '../hooks' -export type PluginPageTab = typeof PLUGIN_PAGE_TABS_MAP[keyof typeof PLUGIN_PAGE_TABS_MAP] +export type PluginPageTab = + | (typeof PLUGIN_PAGE_TABS_MAP)[keyof typeof PLUGIN_PAGE_TABS_MAP] | (typeof PLUGIN_TYPE_SEARCH_MAP)[keyof typeof PLUGIN_TYPE_SEARCH_MAP] type PluginPageContextValue = { @@ -21,7 +19,7 @@ type PluginPageContextValue = { setFilters: (filter: FilterState) => void activeTab: PluginPageTab setActiveTab: (tab: PluginPageTab) => void - options: Array<{ value: string, text: string }> + options: Array<{ value: string; text: string }> } const emptyContainerRef: RefObject<HTMLDivElement | null> = { current: null } diff --git a/web/app/components/plugins/plugin-page/debug-info.tsx b/web/app/components/plugins/plugin-page/debug-info.tsx index 72684490050d9f..f6c62075bf6647 100644 --- a/web/app/components/plugins/plugin-page/debug-info.tsx +++ b/web/app/components/plugins/plugin-page/debug-info.tsx @@ -4,10 +4,7 @@ import type { ComponentProps, ReactNode } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { - RiArrowRightUpLine, - RiBugLine, -} from '@remixicon/react' +import { RiArrowRightUpLine, RiBugLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' import { useDebugKey } from '@/service/use-plugins' @@ -41,8 +38,7 @@ function DebugInfo({ // info.key likes 4580bdb7-b878-471c-a8a4-bfd760263a53 mask the middle part using *. const maskedKey = info?.key?.replace(/(.{8})(.*)(.{8})/, '$1********$3') - if (isLoading) - return null + if (isLoading) return null if (!info) { return ( @@ -55,41 +51,40 @@ function DebugInfo({ return ( <Popover> <PopoverTrigger - render={( + render={ <Button variant={triggerVariant} className={triggerClassNames}> {trigger} </Button> - )} + } /> <PopoverContent placement={popupPlacement} popupClassName="border-0 bg-transparent p-0 shadow-none" > <PluginSidecarPanel - title={t($ => $[`${i18nPrefix}.title`], { ns: 'plugin' })} - footer={( + title={t(($) => $[`${i18nPrefix}.title`], { ns: 'plugin' })} + footer={ <div className="flex w-full shrink-0 flex-col items-start"> <div className="flex w-full shrink-0 items-center justify-end gap-2 px-4 pt-2 pb-4"> <div className="flex min-w-0 flex-1 items-center gap-1"> <a - href={docLink('/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin')} + href={docLink( + '/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin', + )} target="_blank" rel="noopener noreferrer" className="flex cursor-pointer items-center gap-1 system-xs-regular text-text-accent" > - <span>{t($ => $[`${i18nPrefix}.viewDocs`], { ns: 'plugin' })}</span> + <span>{t(($) => $[`${i18nPrefix}.viewDocs`], { ns: 'plugin' })}</span> <RiArrowRightUpLine className="size-3" /> </a> </div> </div> </div> - )} + } > <div className="flex w-full shrink-0 flex-col items-start justify-center gap-1 px-4 py-2"> - <KeyValueItem - label="Port" - value={`${info.host}:${info.port}`} - /> + <KeyValueItem label="Port" value={`${info.host}:${info.port}`} /> <KeyValueItem label="Key" value={info.key || ''} diff --git a/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx index cb1a13e1e022cb..84e301f608af97 100644 --- a/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx @@ -5,9 +5,7 @@ import { act, fireEvent, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { InstallationScope } from '@/features/system-features/constants' - // ==================== Imports (after mocks) ==================== - import Empty from '../index' // ==================== Mock Setup ==================== @@ -15,11 +13,7 @@ import Empty from '../index' // Use vi.hoisted to define ALL mock state and functions so the local render // helper below (and downstream `vi.mock` factories) can read from the same // shared object regardless of declaration order. -const { - mockSetActiveTab, - mockUseInstalledPluginList, - mockState, -} = vi.hoisted(() => { +const { mockSetActiveTab, mockUseInstalledPluginList, mockState } = vi.hoisted(() => { const state = { filters: { categories: [] as string[], @@ -33,7 +27,9 @@ const { restrict_to_marketplace_only: false, }, } as Partial<GetSystemFeaturesResponse>, - pluginList: { plugins: [] as Array<{ id: string }> } as { plugins: Array<{ id: string }> } | undefined, + pluginList: { plugins: [] as Array<{ id: string }> } as + | { plugins: Array<{ id: string }> } + | undefined, } return { mockSetActiveTab: vi.fn(), @@ -63,9 +59,11 @@ vi.mock('@/service/use-plugins', () => ({ // Mock InstallFromGitHub component vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({ - default: ({ onClose }: { onSuccess: () => void, onClose: () => void }) => ( + default: ({ onClose }: { onSuccess: () => void; onClose: () => void }) => ( <div data-testid="install-from-github-modal"> - <button data-testid="github-modal-close" onClick={onClose}>Close</button> + <button data-testid="github-modal-close" onClick={onClose}> + Close + </button> <button data-testid="github-modal-success">Success</button> </div> ), @@ -73,9 +71,11 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({ // Mock InstallFromLocalPackage component vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({ - default: ({ file, onClose }: { file: File, onSuccess: () => void, onClose: () => void }) => ( + default: ({ file, onClose }: { file: File; onSuccess: () => void; onClose: () => void }) => ( <div data-testid="install-from-local-modal" data-file-name={file.name}> - <button data-testid="local-modal-close" onClick={onClose}>Close</button> + <button data-testid="local-modal-close" onClick={onClose}> + Close + </button> <button data-testid="local-modal-success">Success</button> </div> ), @@ -83,7 +83,9 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () // Mock Line component vi.mock('../../../marketplace/empty/line', () => ({ - default: ({ className }: { className?: string }) => <div data-testid="line-component" className={className} />, + default: ({ className }: { className?: string }) => ( + <div data-testid="line-component" className={className} /> + ), })) // ==================== Test Utilities ==================== @@ -163,16 +165,22 @@ describe('Empty Component', () => { expect(screen.getByText('plugin.installModal.dropIntegrationToInstall')).toBeInTheDocument() expect(container.querySelector('.i-ri-drag-drop-line')).toBeInTheDocument() expect(container.firstElementChild).toHaveClass('bg-components-panel-bg') - expect(container.querySelector('.i-custom-vender-integrations-trigger-active')).toBeInTheDocument() - expect(container.querySelector('.i-custom-vender-integrations-trigger')).not.toBeInTheDocument() + expect( + container.querySelector('.i-custom-vender-integrations-trigger-active'), + ).toBeInTheDocument() + expect( + container.querySelector('.i-custom-vender-integrations-trigger'), + ).not.toBeInTheDocument() const buttons = screen.getAllByRole('button') - buttons.forEach(button => expect(button).toHaveClass('h-8', 'w-full', 'justify-start')) + buttons.forEach((button) => expect(button).toHaveClass('h-8', 'w-full', 'justify-start')) }) it('should render the Figma agent strategy empty layout at the shared center position', async () => { // Arrange & Act - const { container } = render(<Empty contentInset="compact" variant="integrationsAgentStrategy" />) + const { container } = render( + <Empty contentInset="compact" variant="integrationsAgentStrategy" />, + ) await flushEffects() // Assert @@ -183,7 +191,9 @@ describe('Empty Component', () => { expect(container.querySelector('.items-center')).toBeInTheDocument() expect(container.querySelector('.-translate-y-7')).not.toBeInTheDocument() - expect(container.querySelector('.i-custom-vender-integrations-agent-strategy-active')).toHaveClass('size-6', 'shrink-0') + expect( + container.querySelector('.i-custom-vender-integrations-agent-strategy-active'), + ).toHaveClass('size-6', 'shrink-0') }) it('should render the Figma extension empty layout with extension copy', async () => { @@ -196,7 +206,10 @@ describe('Empty Component', () => { expect(screen.getByText('plugin.installModal.dropIntegrationToInstall')).toBeInTheDocument() expect(container.querySelector('.i-ri-drag-drop-line')).toBeInTheDocument() - expect(container.querySelector('.i-custom-vender-integrations-extension-active')).toHaveClass('size-6', 'shrink-0') + expect(container.querySelector('.i-custom-vender-integrations-extension-active')).toHaveClass( + 'size-6', + 'shrink-0', + ) }) }) @@ -275,7 +288,7 @@ describe('Empty Component', () => { expect(screen.getByText('plugin.source.local')).toBeInTheDocument() // Verify button order - const buttonTexts = buttons.map(btn => btn.textContent) + const buttonTexts = buttons.map((btn) => btn.textContent) expect(buttonTexts[0]).toContain('plugin.source.marketplace') expect(buttonTexts[1]).toContain('plugin.source.github') expect(buttonTexts[2]).toContain('plugin.source.local') @@ -354,7 +367,9 @@ describe('Empty Component', () => { expect(screen.queryByText('plugin.source.marketplace')).not.toBeInTheDocument() expect(screen.queryByText('plugin.source.github')).not.toBeInTheDocument() expect(screen.queryByText('plugin.source.local')).not.toBeInTheDocument() - expect(screen.queryByText('plugin.installModal.dropIntegrationToInstall')).not.toBeInTheDocument() + expect( + screen.queryByText('plugin.installModal.dropIntegrationToInstall'), + ).not.toBeInTheDocument() }) }) @@ -375,7 +390,9 @@ describe('Empty Component', () => { it('should use the provided marketplace action when marketplace button is clicked', async () => { // Arrange const onSwitchToMarketplace = vi.fn() - render(<Empty onSwitchToMarketplace={onSwitchToMarketplace} variant="integrationsExtension" />) + render( + <Empty onSwitchToMarketplace={onSwitchToMarketplace} variant="integrationsExtension" />, + ) await flushEffects() // Act @@ -437,7 +454,10 @@ describe('Empty Component', () => { // Assert - modal is open with correct file expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument() - expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-plugin.difypkg') + expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute( + 'data-file-name', + 'test-plugin.difypkg', + ) // Act - close modal fireEvent.click(screen.getByTestId('local-modal-close')) @@ -467,7 +487,10 @@ describe('Empty Component', () => { const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement // Act - Object.defineProperty(fileInput, 'files', { value: [createMockFile('blocked-plugin.difypkg')], writable: true }) + Object.defineProperty(fileInput, 'files', { + value: [createMockFile('blocked-plugin.difypkg')], + writable: true, + }) fireEvent.change(fileInput) // Assert @@ -500,15 +523,27 @@ describe('Empty Component', () => { const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement // Act - select .difypkg file - Object.defineProperty(fileInput, 'files', { value: [createMockFile('my-plugin.difypkg')], writable: true }) + Object.defineProperty(fileInput, 'files', { + value: [createMockFile('my-plugin.difypkg')], + writable: true, + }) fireEvent.change(fileInput) - expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'my-plugin.difypkg') + expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute( + 'data-file-name', + 'my-plugin.difypkg', + ) // Close and select .difybndl file fireEvent.click(screen.getByTestId('local-modal-close')) - Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-bundle.difybndl')], writable: true }) + Object.defineProperty(fileInput, 'files', { + value: [createMockFile('test-bundle.difybndl')], + writable: true, + }) fireEvent.change(fileInput) - expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-bundle.difybndl') + expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute( + 'data-file-name', + 'test-bundle.difybndl', + ) }) }) @@ -591,7 +626,11 @@ describe('Empty Component', () => { // Assert expect(Empty).toBeDefined() expect((Empty as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') - expect((Empty as unknown as { displayName?: string, type?: { displayName?: string } }).displayName || (Empty as unknown as { type?: { displayName?: string } }).type?.displayName).toBeDefined() + expect( + (Empty as unknown as { displayName?: string; type?: { displayName?: string } }) + .displayName || + (Empty as unknown as { type?: { displayName?: string } }).type?.displayName, + ).toBeDefined() }) }) @@ -611,7 +650,10 @@ describe('Empty Component', () => { fireEvent.click(screen.getByTestId('github-modal-close')) const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-plugin.difypkg')], writable: true }) + Object.defineProperty(fileInput, 'files', { + value: [createMockFile('test-plugin.difypkg')], + writable: true, + }) fireEvent.change(fileInput) fireEvent.click(screen.getByTestId('local-modal-success')) diff --git a/web/app/components/plugins/plugin-page/empty/index.tsx b/web/app/components/plugins/plugin-page/empty/index.tsx index 8dbb730070bd9d..1180a3f3a0a5dc 100644 --- a/web/app/components/plugins/plugin-page/empty/index.tsx +++ b/web/app/components/plugins/plugin-page/empty/index.tsx @@ -18,7 +18,10 @@ import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useInstalledPluginList } from '@/service/use-plugins' import Line from '../../marketplace/empty/line' -import { pluginPageContentFrameClassNames, pluginPageContentInsetClassNames } from '../content-inset' +import { + pluginPageContentFrameClassNames, + pluginPageContentInsetClassNames, +} from '../content-inset' import { usePluginPageContext } from '../context' import { DropHintInstallSourceIcon, @@ -39,7 +42,10 @@ const TriggerEmptyIcon = () => ( ) const AgentStrategyEmptyIcon = () => ( - <span aria-hidden className="i-custom-vender-integrations-agent-strategy-active size-6 shrink-0" /> + <span + aria-hidden + className="i-custom-vender-integrations-agent-strategy-active size-6 shrink-0" + /> ) const ExtensionEmptyIcon = () => ( @@ -51,7 +57,11 @@ type EmptyProps = { contentInset?: PluginPageContentInset installContextCategory?: PluginCategoryEnum onSwitchToMarketplace?: () => void - variant?: 'default' | 'integrationsAgentStrategy' | 'integrationsExtension' | 'integrationsTrigger' + variant?: + | 'default' + | 'integrationsAgentStrategy' + | 'integrationsExtension' + | 'integrationsTrigger' } const Empty = ({ @@ -67,13 +77,13 @@ const Empty = ({ const [selectedFile, setSelectedFile] = useState<File | null>(null) const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const { data: plugin_installation_permission } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.plugin_installation_permission, + select: (s) => s.plugin_installation_permission, }) - const setActiveTab = usePluginPageContext(v => v.setActiveTab) + const setActiveTab = usePluginPageContext((v) => v.setActiveTab) const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { if (!canInstall) { @@ -88,37 +98,57 @@ const Empty = ({ setSelectedAction('local') } } - const filters = usePluginPageContext(v => v.filters) + const filters = usePluginPageContext((v) => v.filters) const { data: pluginList } = useInstalledPluginList() const text = useMemo(() => { - if (pluginList?.plugins.length === 0) - return t($ => $['list.noInstalled'], { ns: 'plugin' }) + if (pluginList?.plugins.length === 0) return t(($) => $['list.noInstalled'], { ns: 'plugin' }) if (filters.categories.length > 0 || filters.tags.length > 0 || filters.searchQuery) - return t($ => $['list.notFound'], { ns: 'plugin' }) - }, [pluginList?.plugins.length, t, filters.categories.length, filters.tags.length, filters.searchQuery]) + return t(($) => $['list.notFound'], { ns: 'plugin' }) + }, [ + pluginList?.plugins.length, + t, + filters.categories.length, + filters.tags.length, + filters.searchQuery, + ]) const installMethods = useMemo<InstallMethod[]>(() => { - if (!canInstall) - return [] + if (!canInstall) return [] const methods: InstallMethod[] = [] if (enable_marketplace) - methods.push({ icon: MagicBox, integrationIcon: MarketplaceInstallSourceIcon, text: t($ => $['source.marketplace'], { ns: 'plugin' }), action: 'marketplace' }) + methods.push({ + icon: MagicBox, + integrationIcon: MarketplaceInstallSourceIcon, + text: t(($) => $['source.marketplace'], { ns: 'plugin' }), + action: 'marketplace', + }) - if (plugin_installation_permission.restrict_to_marketplace_only) - return methods + if (plugin_installation_permission.restrict_to_marketplace_only) return methods - methods.push({ icon: Github, integrationIcon: GithubInstallSourceIcon, text: t($ => $['source.github'], { ns: 'plugin' }), action: 'github' }) - methods.push({ icon: FileZip, integrationIcon: LocalPackageInstallSourceIcon, text: t($ => $['source.local'], { ns: 'plugin' }), action: 'local' }) + methods.push({ + icon: Github, + integrationIcon: GithubInstallSourceIcon, + text: t(($) => $['source.github'], { ns: 'plugin' }), + action: 'github', + }) + methods.push({ + icon: FileZip, + integrationIcon: LocalPackageInstallSourceIcon, + text: t(($) => $['source.local'], { ns: 'plugin' }), + action: 'local', + }) return methods }, [canInstall, plugin_installation_permission, enable_marketplace, t]) const contentPaddingClassName = pluginPageContentInsetClassNames[contentInset] - const canInstallLocalPackage = canInstall && !plugin_installation_permission.restrict_to_marketplace_only + const canInstallLocalPackage = + canInstall && !plugin_installation_permission.restrict_to_marketplace_only const isIntegrationsTrigger = variant === 'integrationsTrigger' const isIntegrationsAgentStrategy = variant === 'integrationsAgentStrategy' const isIntegrationsExtension = variant === 'integrationsExtension' - const isIntegrationsCategory = isIntegrationsTrigger || isIntegrationsAgentStrategy || isIntegrationsExtension + const isIntegrationsCategory = + isIntegrationsTrigger || isIntegrationsAgentStrategy || isIntegrationsExtension const supportsDropInstall = isIntegrationsCategory const showDropInstallTip = supportsDropInstall && canInstallLocalPackage const contentFrameClassName = cn( @@ -126,11 +156,11 @@ const Empty = ({ contentPaddingClassName, ) const emptyText = isIntegrationsTrigger - ? t($ => $['list.noTriggerFound'], { ns: 'plugin' }) + ? t(($) => $['list.noTriggerFound'], { ns: 'plugin' }) : isIntegrationsAgentStrategy - ? t($ => $['list.noAgentStrategyFound'], { ns: 'plugin' }) + ? t(($) => $['list.noAgentStrategyFound'], { ns: 'plugin' }) : isIntegrationsExtension - ? t($ => $['list.noExtensionFound'], { ns: 'plugin' }) + ? t(($) => $['list.noExtensionFound'], { ns: 'plugin' }) : text const placeholderItemCount = isIntegrationsCategory ? 14 : 20 @@ -144,14 +174,25 @@ const Empty = ({ )} > {Array.from({ length: placeholderItemCount }, (_, i) => ( - <div key={i} className={cn(isIntegrationsCategory ? 'h-24 rounded-lg bg-background-section-burn/30' : 'h-24 rounded-xl bg-components-card-bg')} /> + <div + key={i} + className={cn( + isIntegrationsCategory + ? 'h-24 rounded-lg bg-background-section-burn/30' + : 'h-24 rounded-xl bg-components-card-bg', + )} + /> ))} </div> - <div aria-hidden className="pointer-events-none absolute inset-0 z-20 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg" /> - <div className={cn( - 'relative z-30 flex h-full', - showDropInstallTip ? 'flex-col' : 'items-center justify-center', - )} + <div + aria-hidden + className="pointer-events-none absolute inset-0 z-20 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg" + /> + <div + className={cn( + 'relative z-30 flex h-full', + showDropInstallTip ? 'flex-col' : 'items-center justify-center', + )} > <div className={cn( @@ -159,30 +200,34 @@ const Empty = ({ showDropInstallTip ? 'min-h-0 flex-1' : 'h-full w-full', )} > - <div className={cn( - 'flex flex-col items-center', - isIntegrationsCategory ? 'gap-y-6' : 'gap-y-3', - )} + <div + className={cn( + 'flex flex-col items-center', + isIntegrationsCategory ? 'gap-y-6' : 'gap-y-3', + )} > <div className="flex flex-col items-center gap-y-3"> - <div className={cn( - 'relative -z-10 flex items-center justify-center border-dashed bg-components-card-bg backdrop-blur-md', - isIntegrationsCategory - ? 'size-14 rounded-xl border border-divider-regular' - : 'size-14 rounded-xl border border-divider-deep shadow-xl shadow-shadow-shadow-5', - )} + <div + className={cn( + 'relative -z-10 flex items-center justify-center border-dashed bg-components-card-bg backdrop-blur-md', + isIntegrationsCategory + ? 'size-14 rounded-xl border border-divider-regular' + : 'size-14 rounded-xl border border-divider-deep shadow-xl shadow-shadow-shadow-5', + )} > - {isIntegrationsCategory - ? ( - <span className="text-text-tertiary"> - {isIntegrationsAgentStrategy - ? <AgentStrategyEmptyIcon /> - : isIntegrationsExtension - ? <ExtensionEmptyIcon /> - : <TriggerEmptyIcon />} - </span> - ) - : <Group className="size-5 text-text-tertiary" />} + {isIntegrationsCategory ? ( + <span className="text-text-tertiary"> + {isIntegrationsAgentStrategy ? ( + <AgentStrategyEmptyIcon /> + ) : isIntegrationsExtension ? ( + <ExtensionEmptyIcon /> + ) : ( + <TriggerEmptyIcon /> + )} + </span> + ) : ( + <Group className="size-5 text-text-tertiary" /> + )} {!isIntegrationsCategory && ( <> <Line className="absolute top-1/2 -right-px -translate-y-1/2" /> @@ -192,7 +237,13 @@ const Empty = ({ </> )} </div> - <div className={cn(isIntegrationsCategory ? 'system-sm-regular text-text-tertiary' : 'system-md-regular text-text-tertiary')}> + <div + className={cn( + isIntegrationsCategory + ? 'system-sm-regular text-text-tertiary' + : 'system-md-regular text-text-tertiary', + )} + > {emptyText} </div> </div> @@ -205,37 +256,39 @@ const Empty = ({ accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS} /> <div className="flex w-full flex-col gap-y-1"> - {installMethods.map(({ icon: Icon, integrationIcon: IntegrationIcon, text, action }) => ( - <Button - key={action} - variant="secondary" - title={text} - className="h-8 w-full justify-start gap-x-0.5 px-3 py-2 system-sm-medium" - onClick={() => { - if (action === 'local') - fileInputRef.current?.click() - else if (action === 'marketplace') - onSwitchToMarketplace ? onSwitchToMarketplace() : setActiveTab('discover') - else - setSelectedAction(action) - }} - > - {isIntegrationsCategory - ? <IntegrationIcon /> - : <Icon className="size-4 text-components-button-secondary-text" />} - <span className="min-w-0 flex-1 truncate px-0.5 text-left">{text}</span> - </Button> - ))} + {installMethods.map( + ({ icon: Icon, integrationIcon: IntegrationIcon, text, action }) => ( + <Button + key={action} + variant="secondary" + title={text} + className="h-8 w-full justify-start gap-x-0.5 px-3 py-2 system-sm-medium" + onClick={() => { + if (action === 'local') fileInputRef.current?.click() + else if (action === 'marketplace') + onSwitchToMarketplace ? onSwitchToMarketplace() : setActiveTab('discover') + else setSelectedAction(action) + }} + > + {isIntegrationsCategory ? ( + <IntegrationIcon /> + ) : ( + <Icon className="size-4 text-components-button-secondary-text" /> + )} + <span className="min-w-0 flex-1 truncate px-0.5 text-left">{text}</span> + </Button> + ), + )} </div> </div> </div> </div> {showDropInstallTip && ( - <div - className="flex shrink-0 items-center justify-center gap-2 px-6 py-4 text-text-quaternary" - > + <div className="flex shrink-0 items-center justify-center gap-2 px-6 py-4 text-text-quaternary"> <DropHintInstallSourceIcon /> - <span className="system-xs-regular">{t($ => $['installModal.dropIntegrationToInstall'], { ns: 'plugin' })}</span> + <span className="system-xs-regular"> + {t(($) => $['installModal.dropIntegrationToInstall'], { ns: 'plugin' })} + </span> </div> )} {selectedAction === 'github' && ( @@ -245,15 +298,14 @@ const Empty = ({ onClose={() => setSelectedAction(null)} /> )} - {selectedAction === 'local' && selectedFile - && ( - <InstallFromLocalPackage - file={selectedFile} - installContextCategory={installContextCategory} - onClose={() => setSelectedAction(null)} - onSuccess={noop} - /> - )} + {selectedAction === 'local' && selectedFile && ( + <InstallFromLocalPackage + file={selectedFile} + installContextCategory={installContextCategory} + onClose={() => setSelectedAction(null)} + onSuccess={noop} + /> + )} </div> </div> ) diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx index c04012c4981e61..0b369931faad7b 100644 --- a/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx @@ -95,7 +95,9 @@ describe('CategoriesFilter', () => { render(<CategoriesFilter value={[]} onChange={vi.fn()} />) fireEvent.click(screen.getByTestId('popover-trigger')) - fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), { target: { value: 'mod' } }) + fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), { + target: { value: 'mod' }, + }) expect(screen.queryByText('Tool')).not.toBeInTheDocument() expect(screen.getByText('Model')).toBeInTheDocument() diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx index bc9b2dcb6714c6..34973c8744d841 100644 --- a/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx @@ -2,9 +2,7 @@ import type { FilterState } from '../index' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { createContext, useContext } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' - // ==================== Imports (after mocks) ==================== - import CategoriesFilter from '../category-filter' // Import real components import FilterManagement from '../index' @@ -33,7 +31,7 @@ const mockCategories = [ { name: 'agent', label: 'Agents' }, ] -const mockCategoriesMap: Record<string, { name: string, label: string }> = { +const mockCategoriesMap: Record<string, { name: string; label: string }> = { model: { name: 'model', label: 'Models' }, tool: { name: 'tool', label: 'Tools' }, extension: { name: 'extension', label: 'Extensions' }, @@ -48,7 +46,7 @@ const mockTags = [ { name: 'image', label: 'Image' }, ] -const mockTagsMap: Record<string, { name: string, label: string }> = { +const mockTagsMap: Record<string, { name: string; label: string }> = { agent: { name: 'agent', label: 'Agent' }, rag: { name: 'rag', label: 'RAG' }, search: { name: 'search', label: 'Search' }, @@ -77,16 +75,26 @@ const MockPopoverContext = createContext<MockPopoverContextValue>({ }) vi.mock('@langgenius/dify-ui/popover', () => ({ - Popover: ({ children, open, onOpenChange }: { + Popover: ({ + children, + open, + onOpenChange, + }: { children: React.ReactNode open: boolean onOpenChange?: (open: boolean) => void }) => ( <MockPopoverContext.Provider value={{ open, onOpenChange }}> - <div data-testid="portal-container" data-open={open}>{children}</div> + <div data-testid="portal-container" data-open={open}> + {children} + </div> </MockPopoverContext.Provider> ), - PopoverTrigger: ({ children, render, className }: { + PopoverTrigger: ({ + children, + render, + className, + }: { children?: React.ReactNode render?: React.ReactNode className?: string @@ -103,14 +111,14 @@ vi.mock('@langgenius/dify-ui/popover', () => ({ </button> ) }, - PopoverContent: ({ children, className }: { - children: React.ReactNode - className?: string - }) => { + PopoverContent: ({ children, className }: { children: React.ReactNode; className?: string }) => { const { open } = useContext(MockPopoverContext) - if (!open) - return null - return <div data-testid="portal-content" className={className}>{children}</div> + if (!open) return null + return ( + <div data-testid="portal-content" className={className}> + {children} + </div> + ) }, })) @@ -123,7 +131,10 @@ const createFilterState = (overrides: Partial<FilterState> = {}): FilterState => ...overrides, }) -const renderFilterManagement = (onFilterChange = vi.fn(), props?: Partial<React.ComponentProps<typeof FilterManagement>>) => { +const renderFilterManagement = ( + onFilterChange = vi.fn(), + props?: Partial<React.ComponentProps<typeof FilterManagement>>, +) => { const result = render(<FilterManagement onFilterChange={onFilterChange} {...props} />) return { ...result, onFilterChange } } @@ -409,7 +420,9 @@ describe('CategoriesFilter Component', () => { it('should clear all selections when clear button is clicked', () => { // Arrange const handleChange = vi.fn() - const { container } = render(<CategoriesFilter value={['model', 'tool']} onChange={handleChange} />) + const { container } = render( + <CategoriesFilter value={['model', 'tool']} onChange={handleChange} />, + ) // Act - Find and click the close icon const closeIcon = container.querySelector('.text-text-quaternary') @@ -469,7 +482,10 @@ describe('CategoriesFilter Component', () => { fireEvent.click(screen.getByTestId('portal-trigger')) await waitFor(() => { - expect(screen.getByRole('checkbox', { name: 'Models' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('checkbox', { name: 'Models' })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) }) @@ -481,7 +497,10 @@ describe('CategoriesFilter Component', () => { fireEvent.click(screen.getByTestId('portal-trigger')) await waitFor(() => { - expect(screen.getByRole('checkbox', { name: 'Models' })).toHaveAttribute('aria-checked', 'false') + expect(screen.getByRole('checkbox', { name: 'Models' })).toHaveAttribute( + 'aria-checked', + 'false', + ) }) }) }) diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx index 9ce644c3e6960c..854354e35e21df 100644 --- a/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx @@ -9,11 +9,12 @@ vi.mock('../../../hooks', () => ({ { name: 'rag', label: 'RAG' }, { name: 'search', label: 'Search' }, ], - getTagLabel: (name: string) => ({ - agent: 'Agent', - rag: 'RAG', - search: 'Search', - }[name] ?? name), + getTagLabel: (name: string) => + ({ + agent: 'Agent', + rag: 'RAG', + search: 'Search', + })[name] ?? name, }), })) @@ -44,7 +45,9 @@ describe('TagFilter', () => { fireEvent.click(screen.getByTestId('popover-trigger')) const portal = screen.getByTestId('popover-content') - fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { target: { value: 'ra' } }) + fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { + target: { value: 'ra' }, + }) expect(within(portal).queryByText('Agent')).not.toBeInTheDocument() expect(within(portal).getByText('RAG')).toBeInTheDocument() diff --git a/web/app/components/plugins/plugin-page/filter-management/category-filter.tsx b/web/app/components/plugins/plugin-page/filter-management/category-filter.tsx index b324ade4ee2e50..2b92c3b5d229da 100644 --- a/web/app/components/plugins/plugin-page/filter-management/category-filter.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/category-filter.tsx @@ -3,15 +3,8 @@ import { Checkbox } from '@langgenius/dify-ui/checkbox' import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - RiArrowDownSLine, - RiCloseCircleFill, -} from '@remixicon/react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { RiArrowDownSLine, RiCloseCircleFill } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' @@ -21,70 +14,53 @@ type CategoriesFilterProps = { value: string[] onChange: (categories: string[]) => void } -const CategoriesFilter = ({ - value, - onChange, -}: CategoriesFilterProps) => { +const CategoriesFilter = ({ value, onChange }: CategoriesFilterProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const [searchText, setSearchText] = useState('') const { categories: options, categoriesMap } = useCategories() - const filteredOptions = options.filter(option => option.name.toLowerCase().includes(searchText.toLowerCase())) + const filteredOptions = options.filter((option) => + option.name.toLowerCase().includes(searchText.toLowerCase()), + ) const selectedTagsLength = value.length return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton={false} - render={( - <div className={cn( - 'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary hover:bg-state-base-hover-alt', - selectedTagsLength && 'text-text-secondary', - 'data-popup-open:bg-state-base-hover', - )} - > - <div className={cn( - 'flex items-center p-1 system-sm-medium', + render={ + <div + className={cn( + 'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary hover:bg-state-base-hover-alt', + selectedTagsLength && 'text-text-secondary', + 'data-popup-open:bg-state-base-hover', )} - > - { - !selectedTagsLength && t($ => $.allCategories, { ns: 'plugin' }) - } - { - !!selectedTagsLength && value.map(val => categoriesMap[val]!.label).slice(0, 2).join(',') - } - { - selectedTagsLength > 2 && ( - <div className="ml-1 system-xs-medium text-text-tertiary"> - + - {selectedTagsLength - 2} - </div> - ) - } + > + <div className={cn('flex items-center p-1 system-sm-medium')}> + {!selectedTagsLength && t(($) => $.allCategories, { ns: 'plugin' })} + {!!selectedTagsLength && + value + .map((val) => categoriesMap[val]!.label) + .slice(0, 2) + .join(',')} + {selectedTagsLength > 2 && ( + <div className="ml-1 system-xs-medium text-text-tertiary"> + +{selectedTagsLength - 2} + </div> + )} </div> - { - !!selectedTagsLength && ( - <RiCloseCircleFill - className="size-4 cursor-pointer text-text-quaternary" - onClick={ - (e) => { - e.stopPropagation() - onChange([]) - } - } - /> - ) - } - { - !selectedTagsLength && ( - <RiArrowDownSLine className="size-4" /> - ) - } + {!!selectedTagsLength && ( + <RiCloseCircleFill + className="size-4 cursor-pointer text-text-quaternary" + onClick={(e) => { + e.stopPropagation() + onChange([]) + }} + /> + )} + {!selectedTagsLength && <RiArrowDownSLine className="size-4" />} </div> - )} + } /> <PopoverContent placement="bottom-start" @@ -96,32 +72,25 @@ const CategoriesFilter = ({ <Input showLeftIcon value={searchText} - onChange={e => setSearchText(e.target.value)} - placeholder={t($ => $.searchCategories, { ns: 'plugin' })} + onChange={(e) => setSearchText(e.target.value)} + placeholder={t(($) => $.searchCategories, { ns: 'plugin' })} /> </div> <CheckboxGroup - aria-label={t($ => $.allCategories, { ns: 'plugin' })} + aria-label={t(($) => $.allCategories, { ns: 'plugin' })} value={value} - onValueChange={nextValue => onChange(nextValue)} + onValueChange={(nextValue) => onChange(nextValue)} className="max-h-[448px] overflow-y-auto p-1" > - { - filteredOptions.map(option => ( - <label - key={option.name} - className="flex h-7 cursor-pointer items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover" - > - <Checkbox - className="mr-1" - value={option.name} - /> - <div className="px-1 system-sm-medium text-text-secondary"> - {option.label} - </div> - </label> - )) - } + {filteredOptions.map((option) => ( + <label + key={option.name} + className="flex h-7 cursor-pointer items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover" + > + <Checkbox className="mr-1" value={option.name} /> + <div className="px-1 system-sm-medium text-text-secondary">{option.label}</div> + </label> + ))} </CheckboxGroup> </div> </PopoverContent> diff --git a/web/app/components/plugins/plugin-page/filter-management/index.tsx b/web/app/components/plugins/plugin-page/filter-management/index.tsx index 48b6bd1db03aa2..8d18b08d95642c 100644 --- a/web/app/components/plugins/plugin-page/filter-management/index.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/index.tsx @@ -24,7 +24,7 @@ const FilterManagement = ({ onFilterChange, rightSlot, }: FilterManagementProps) => { - const initFilters = usePluginPageContext(v => v.filters) as FilterState + const initFilters = usePluginPageContext((v) => v.filters) as FilterState const [filters, setFilters] = useState<FilterState>(initFilters) const showRightSlot = rightSlot !== undefined && rightSlot !== null @@ -39,24 +39,17 @@ const FilterManagement = ({ {!hideCategoryFilter && ( <CategoriesFilter value={filters.categories} - onChange={categories => updateFilters({ categories })} + onChange={(categories) => updateFilters({ categories })} /> )} {!hideTagFilter && ( - <TagFilter - value={filters.tags} - onChange={tags => updateFilters({ tags })} - /> + <TagFilter value={filters.tags} onChange={(tags) => updateFilters({ tags })} /> )} <SearchBox searchQuery={filters.searchQuery} - onChange={searchQuery => updateFilters({ searchQuery })} + onChange={(searchQuery) => updateFilters({ searchQuery })} /> - {showRightSlot && ( - <div className="ml-auto shrink-0"> - {rightSlot} - </div> - )} + {showRightSlot && <div className="ml-auto shrink-0">{rightSlot}</div>} </div> ) } diff --git a/web/app/components/plugins/plugin-page/filter-management/search-box.tsx b/web/app/components/plugins/plugin-page/filter-management/search-box.tsx index 854e5e4094e5f0..7e7a117dfa9da4 100644 --- a/web/app/components/plugins/plugin-page/filter-management/search-box.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/search-box.tsx @@ -8,10 +8,7 @@ type SearchBoxProps = { onChange: (query: string) => void } -const SearchBox: React.FC<SearchBoxProps> = ({ - searchQuery, - onChange, -}) => { +const SearchBox: React.FC<SearchBoxProps> = ({ searchQuery, onChange }) => { const { t } = useTranslation() return ( @@ -20,7 +17,7 @@ const SearchBox: React.FC<SearchBoxProps> = ({ className="bg-components-input-bg-normal" showLeftIcon value={searchQuery} - placeholder={t($ => $.search, { ns: 'plugin' })} + placeholder={t(($) => $.search, { ns: 'plugin' })} onChange={(e) => { onChange(e.target.value) }} diff --git a/web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx b/web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx index 15bb43c552ec1e..18849483bd7586 100644 --- a/web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx @@ -3,11 +3,7 @@ import { Checkbox } from '@langgenius/dify-ui/checkbox' import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' @@ -17,69 +13,54 @@ type TagsFilterProps = { value: string[] onChange: (tags: string[]) => void } -const TagsFilter = ({ - value, - onChange, -}: TagsFilterProps) => { +const TagsFilter = ({ value, onChange }: TagsFilterProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const [searchText, setSearchText] = useState('') const { tags: options, getTagLabel } = useTags() - const filteredOptions = options.filter(option => option.name.toLowerCase().includes(searchText.toLowerCase())) + const filteredOptions = options.filter((option) => + option.name.toLowerCase().includes(searchText.toLowerCase()), + ) const selectedTagsLength = value.length return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton={false} - render={( - <div className={cn( - 'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary select-none hover:bg-state-base-hover-alt', - selectedTagsLength && 'text-text-secondary', - 'data-popup-open:bg-state-base-hover', - )} - > - <div className={cn( - 'flex items-center p-1 system-sm-medium', + render={ + <div + className={cn( + 'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary select-none hover:bg-state-base-hover-alt', + selectedTagsLength && 'text-text-secondary', + 'data-popup-open:bg-state-base-hover', )} - > - { - !selectedTagsLength && t($ => $['tag.tags'], { ns: 'common' }) - } - { - !!selectedTagsLength && value.map(val => getTagLabel(val)).slice(0, 2).join(',') - } - { - selectedTagsLength > 2 && ( - <div className="ml-1 system-xs-medium text-text-tertiary"> - + - {selectedTagsLength - 2} - </div> - ) - } + > + <div className={cn('flex items-center p-1 system-sm-medium')}> + {!selectedTagsLength && t(($) => $['tag.tags'], { ns: 'common' })} + {!!selectedTagsLength && + value + .map((val) => getTagLabel(val)) + .slice(0, 2) + .join(',')} + {selectedTagsLength > 2 && ( + <div className="ml-1 system-xs-medium text-text-tertiary"> + +{selectedTagsLength - 2} + </div> + )} </div> - { - !!selectedTagsLength && ( - <span - aria-hidden - className="i-ri-close-circle-fill size-4 cursor-pointer text-text-quaternary" - onClick={(e) => { - e.stopPropagation() - onChange([]) - }} - /> - ) - } - { - !selectedTagsLength && ( - <span aria-hidden className="i-ri-arrow-down-s-line size-4" /> - ) - } + {!!selectedTagsLength && ( + <span + aria-hidden + className="i-ri-close-circle-fill size-4 cursor-pointer text-text-quaternary" + onClick={(e) => { + e.stopPropagation() + onChange([]) + }} + /> + )} + {!selectedTagsLength && <span aria-hidden className="i-ri-arrow-down-s-line size-4" />} </div> - )} + } /> <PopoverContent placement="bottom-start" @@ -91,31 +72,24 @@ const TagsFilter = ({ <SearchInput value={searchText} onValueChange={setSearchText} - placeholder={t($ => $.searchTags, { ns: 'pluginTags' })} + placeholder={t(($) => $.searchTags, { ns: 'pluginTags' })} /> </div> <CheckboxGroup - aria-label={t($ => $.allTags, { ns: 'pluginTags' })} + aria-label={t(($) => $.allTags, { ns: 'pluginTags' })} value={value} - onValueChange={nextValue => onChange(nextValue)} + onValueChange={(nextValue) => onChange(nextValue)} className="max-h-[448px] overflow-y-auto p-1" > - { - filteredOptions.map(option => ( - <label - key={option.name} - className="flex h-7 cursor-pointer items-center rounded-lg px-2 py-1.5 select-none hover:bg-state-base-hover" - > - <Checkbox - className="mr-1" - value={option.name} - /> - <div className="px-1 system-sm-medium text-text-secondary"> - {option.label} - </div> - </label> - )) - } + {filteredOptions.map((option) => ( + <label + key={option.name} + className="flex h-7 cursor-pointer items-center rounded-lg px-2 py-1.5 select-none hover:bg-state-base-hover" + > + <Checkbox className="mr-1" value={option.name} /> + <div className="px-1 system-sm-medium text-text-secondary">{option.label}</div> + </label> + ))} </CheckboxGroup> </div> </PopoverContent> diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index e3097ac47c3439..e8f42c38b3eca3 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -4,12 +4,7 @@ import type { PluginPageTab } from './context' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiBookOpenLine, - RiBugLine, - RiDragDropLine, - RiEqualizer2Line, -} from '@remixicon/react' +import { RiBookOpenLine, RiBugLine, RiDragDropLine, RiEqualizer2Line } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import { noop } from 'es-toolkit/function' @@ -80,10 +75,7 @@ type PluginPanelPermissionProps = { canDeletePlugin?: boolean canUpdatePlugin?: boolean } -const PluginPage = ({ - plugins, - marketplace, -}: PluginPageProps) => { +const PluginPage = ({ plugins, marketplace }: PluginPageProps) => { const { t } = useTranslation() const docLink = useDocLink() const { replace } = useRouter() @@ -102,27 +94,32 @@ const PluginPage = ({ setReferenceSettings, } = useReferenceSetting(PluginCategoryEnum.tool) - const handlePackageCategoryResolved = useCallback((category: string | undefined, packageId: string) => { - const installRedirectPath = getInstallRedirectPathByPluginCategory(category, getCurrentInstallSearchParams(packageId)) - if (!installRedirectPath) - return false + const handlePackageCategoryResolved = useCallback( + (category: string | undefined, packageId: string) => { + const installRedirectPath = getInstallRedirectPathByPluginCategory( + category, + getCurrentInstallSearchParams(packageId), + ) + if (!installRedirectPath) return false - replace(installRedirectPath) - return true - }, [replace]) + replace(installRedirectPath) + return true + }, + [replace], + ) - const [showPluginSettingModal, { - setTrue: setShowPluginSettingModal, - setFalse: setHidePluginSettingModal, - }] = useBoolean(false) + const [ + showPluginSettingModal, + { setTrue: setShowPluginSettingModal, setFalse: setHidePluginSettingModal }, + ] = useBoolean(false) const [currentFile, setCurrentFile] = useState<File | null>(null) - const containerRef = usePluginPageContext(v => v.containerRef) - const options = usePluginPageContext(v => v.options) - const activeTab = usePluginPageContext(v => v.activeTab) - const setActiveTab = usePluginPageContext(v => v.setActiveTab) + const containerRef = usePluginPageContext((v) => v.containerRef) + const options = usePluginPageContext((v) => v.options) + const activeTab = usePluginPageContext((v) => v.activeTab) + const setActiveTab = usePluginPageContext((v) => v.setActiveTab) const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const isPluginsTab = useMemo(() => activeTab === PLUGIN_PAGE_TABS_MAP.plugins, [activeTab]) @@ -130,9 +127,11 @@ const PluginPage = ({ const values = Object.values(PLUGIN_TYPE_SEARCH_MAP) return activeTab === PLUGIN_PAGE_TABS_MAP.marketplace || values.includes(activeTab) }, [activeTab]) - useDocumentTitle(isExploringMarketplace - ? t($ => $['mainNav.marketplace'], { ns: 'common' }) - : t($ => $['metadata.title'], { ns: 'plugin' })) + useDocumentTitle( + isExploringMarketplace + ? t(($) => $['mainNav.marketplace'], { ns: 'common' }) + : t(($) => $['metadata.title'], { ns: 'plugin' }), + ) const handleFileChange = (file: File | null) => { if (!canInstallPlugin) { @@ -155,8 +154,7 @@ const PluginPage = ({ const { dragging, fileUploader, fileChangeHandle, removeFile } = uploaderProps const pluginsWithPermission = useMemo(() => { - if (!isValidElement(plugins) || typeof plugins.type === 'string') - return plugins + if (!isValidElement(plugins) || typeof plugins.type === 'string') return plugins return cloneElement(plugins as React.ReactElement<PluginPanelPermissionProps>, { canInstall: canInstallPlugin, @@ -170,9 +168,10 @@ const PluginPage = ({ id="marketplace-container" ref={containerRef} style={{ scrollbarGutter: 'stable' }} - className={cn('relative flex grow flex-col overflow-y-auto border-t border-divider-subtle', isPluginsTab - ? 'rounded-t-xl bg-components-panel-bg' - : 'bg-background-body')} + className={cn( + 'relative flex grow flex-col overflow-y-auto border-t border-divider-subtle', + isPluginsTab ? 'rounded-t-xl bg-components-panel-bg' : 'bg-background-body', + )} > <div className={cn( @@ -185,43 +184,36 @@ const PluginPage = ({ <TabSlider value={isPluginsTab ? PLUGIN_PAGE_TABS_MAP.plugins : PLUGIN_PAGE_TABS_MAP.marketplace} onChange={(nextTab) => { - if (isPluginPageTab(nextTab)) - setActiveTab(nextTab) + if (isPluginPageTab(nextTab)) setActiveTab(nextTab) }} options={options} /> </div> <div className="flex shrink-0 items-center gap-1"> - { - isExploringMarketplace && ( - <> - <Link - href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml" - target="_blank" - > - <Button - variant="ghost" - className="text-text-tertiary" - > - {t($ => $.requestAPlugin, { ns: 'plugin' })} - </Button> - </Link> - <Link - href={docLink('/develop-plugin/publishing/marketplace-listing/release-to-dify-marketplace')} - target="_blank" - > - <Button - className="px-3" - variant="secondary-accent" - > - <RiBookOpenLine className="mr-1 size-4" /> - {t($ => $.publishPlugins, { ns: 'plugin' })} - </Button> - </Link> - <div className="mx-1 h-3.5 w-px shrink-0 bg-divider-regular"></div> - </> - ) - } + {isExploringMarketplace && ( + <> + <Link + href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml" + target="_blank" + > + <Button variant="ghost" className="text-text-tertiary"> + {t(($) => $.requestAPlugin, { ns: 'plugin' })} + </Button> + </Link> + <Link + href={docLink( + '/develop-plugin/publishing/marketplace-listing/release-to-dify-marketplace', + )} + target="_blank" + > + <Button className="px-3" variant="secondary-accent"> + <RiBookOpenLine className="mr-1 size-4" /> + {t(($) => $.publishPlugins, { ns: 'plugin' })} + </Button> + </Link> + <div className="mx-1 h-3.5 w-px shrink-0 bg-divider-regular"></div> + </> + )} <PluginTasks /> {(canInstallPlugin || isPermissionLoading) && ( <InstallPluginDropdown @@ -229,11 +221,7 @@ const PluginPage = ({ onSwitchToMarketplaceTab={() => setActiveTab('discover')} /> )} - { - canDebugger && ( - <DebugInfo /> - ) - } + {canDebugger && <DebugInfo />} {isPermissionLoading && ( <Button className="h-full w-full p-2 text-components-button-secondary-text" @@ -243,28 +231,24 @@ const PluginPage = ({ <RiBugLine className="h-4 w-4" /> </Button> )} - { - (canSetPermissions || canSetPluginPreferences) && ( - <Tooltip> - <TooltipTrigger - render={( - <Button - aria-label={t($ => $['privilege.title'], { ns: 'plugin' })} - className="group size-full p-2 text-components-button-secondary-text" - disabled={isReferenceSettingLoading || !referenceSetting} - loading={isReferenceSettingLoading} - onClick={setShowPluginSettingModal} - > - <RiEqualizer2Line className="size-4" aria-hidden="true" /> - </Button> - )} - /> - <TooltipContent> - {t($ => $['privilege.title'], { ns: 'plugin' })} - </TooltipContent> - </Tooltip> - ) - } + {(canSetPermissions || canSetPluginPreferences) && ( + <Tooltip> + <TooltipTrigger + render={ + <Button + aria-label={t(($) => $['privilege.title'], { ns: 'plugin' })} + className="group size-full p-2 text-components-button-secondary-text" + disabled={isReferenceSettingLoading || !referenceSetting} + loading={isReferenceSettingLoading} + onClick={setShowPluginSettingModal} + > + <RiEqualizer2Line className="size-4" aria-hidden="true" /> + </Button> + } + /> + <TooltipContent>{t(($) => $['privilege.title'], { ns: 'plugin' })}</TooltipContent> + </Tooltip> + )} </div> </div> </div> @@ -278,16 +262,16 @@ const PluginPage = ({ {pluginsWithPermission} </PluginInstallPermissionProvider> {dragging && ( - <div - className="absolute inset-0 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent - bg-[rgba(21,90,239,0.14)] p-2" - > - </div> + <div className="absolute inset-0 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2"></div> )} {canInstallPlugin && ( - <div className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}> + <div + className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`} + > <RiDragDropLine className="size-4" /> - <span className="system-xs-regular">{t($ => $['installModal.dropPluginToInstall'], { ns: 'plugin' })}</span> + <span className="system-xs-regular"> + {t(($) => $['installModal.dropPluginToInstall'], { ns: 'plugin' })} + </span> </div> )} {currentFile && ( @@ -307,17 +291,15 @@ const PluginPage = ({ /> </> )} - { - isExploringMarketplace && enable_marketplace && ( - <PluginInstallPermissionProvider - canInstallPlugin={canInstallPlugin} - canUpdatePlugin={canUpdatePlugin} - currentDifyVersion={currentDifyVersion} - > - {marketplace} - </PluginInstallPermissionProvider> - ) - } + {isExploringMarketplace && enable_marketplace && ( + <PluginInstallPermissionProvider + canInstallPlugin={canInstallPlugin} + canUpdatePlugin={canUpdatePlugin} + currentDifyVersion={currentDifyVersion} + > + {marketplace} + </PluginInstallPermissionProvider> + )} {showPluginSettingModal && referenceSetting && ( <ReferenceSettingModal diff --git a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx index c1b0843f43871c..028c6a4aa14ae4 100644 --- a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx +++ b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx @@ -61,21 +61,20 @@ const InstallPluginDropdown = ({ const [isMenuOpen, setIsMenuOpen] = useState(false) const [selectedAction, setSelectedAction] = useState<string | null>(null) const [selectedFile, setSelectedFile] = useState<File | null>(null) - const buttonLabel = triggerLabel ?? t($ => $.installPlugin, { ns: 'plugin' }) + const buttonLabel = triggerLabel ?? t(($) => $.installPlugin, { ns: 'plugin' }) const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const { data: plugin_installation_permission } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.plugin_installation_permission, + select: (s) => s.plugin_installation_permission, }) const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0] ?? null event.target.value = '' - if (disabled) - return + if (disabled) return if (file) { setSelectedFile(file) @@ -87,8 +86,7 @@ const InstallPluginDropdown = ({ const handleCloseLocalInstaller = () => { setSelectedAction(null) setSelectedFile(null) - if (fileInputRef.current) - fileInputRef.current.value = '' + if (fileInputRef.current) fileInputRef.current.value = '' } // TODO TEST INSTALL : uninstall @@ -107,19 +105,29 @@ const InstallPluginDropdown = ({ const installMethods = useMemo<InstallMethod[]>(() => { const methods: InstallMethod[] = [] if (enable_marketplace) - methods.push({ icon: MarketplaceInstallSourceIcon, text: t($ => $['source.marketplace'], { ns: 'plugin' }), action: 'marketplace' }) - - if (plugin_installation_permission.restrict_to_marketplace_only) - return methods - - methods.push({ icon: GithubInstallSourceIcon, text: t($ => $['source.github'], { ns: 'plugin' }), action: 'github' }) - methods.push({ icon: LocalPackageInstallSourceIcon, text: t($ => $['source.local'], { ns: 'plugin' }), action: 'local' }) + methods.push({ + icon: MarketplaceInstallSourceIcon, + text: t(($) => $['source.marketplace'], { ns: 'plugin' }), + action: 'marketplace', + }) + + if (plugin_installation_permission.restrict_to_marketplace_only) return methods + + methods.push({ + icon: GithubInstallSourceIcon, + text: t(($) => $['source.github'], { ns: 'plugin' }), + action: 'github', + }) + methods.push({ + icon: LocalPackageInstallSourceIcon, + text: t(($) => $['source.local'], { ns: 'plugin' }), + action: 'local', + }) return methods }, [plugin_installation_permission, enable_marketplace, t]) const handleInstallMethodSelect = (action: string) => { - if (disabled) - return + if (disabled) return if (action === 'local') { fileInputRef.current?.click() @@ -137,7 +145,11 @@ const InstallPluginDropdown = ({ } return ( - <DropdownMenu open={!disabled && isMenuOpen} onOpenChange={open => setIsMenuOpen(disabled ? false : open)} modal={false}> + <DropdownMenu + open={!disabled && isMenuOpen} + onOpenChange={(open) => setIsMenuOpen(disabled ? false : open)} + modal={false} + > <div className={cn('relative', rootClassName)}> <input type="file" @@ -148,7 +160,7 @@ const InstallPluginDropdown = ({ accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS} /> <DropdownMenuTrigger - render={( + render={ <Button variant={triggerVariant} disabled={disabled} @@ -160,7 +172,7 @@ const InstallPluginDropdown = ({ !disabled && isMenuOpen && triggerOpenClassName, )} /> - )} + } > <> <RiAddCircleFill className="size-4 shrink-0" /> @@ -176,7 +188,7 @@ const InstallPluginDropdown = ({ popupClassName={cn('w-[200px] pb-2', popupClassName)} > <span className="flex items-start self-stretch px-3 pt-1 pb-0.5 system-xs-medium-uppercase text-text-tertiary"> - {t($ => $.installFrom, { ns: 'plugin' })} + {t(($) => $.installFrom, { ns: 'plugin' })} </span> {installMethods.map(({ icon: Icon, text, action }) => ( <DropdownMenuItem @@ -199,17 +211,14 @@ const InstallPluginDropdown = ({ onClose={() => setSelectedAction(null)} /> )} - { - selectedAction === 'local' && selectedFile - && ( - <InstallFromLocalPackage - file={selectedFile} - installContextCategory={installContextCategory} - onClose={handleCloseLocalInstaller} - onSuccess={noop} - /> - ) - } + {selectedAction === 'local' && selectedFile && ( + <InstallFromLocalPackage + file={selectedFile} + installContextCategory={installContextCategory} + onClose={handleCloseLocalInstaller} + onSuccess={noop} + /> + )} {/* {pluginLists.map((item: any) => ( <div key={item.id} onClick={() => handleUninstall(item.id)}>{item.name} 卸载</div> ))} */} diff --git a/web/app/components/plugins/plugin-page/list/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/list/__tests__/index.spec.tsx index 53ac133b7e62ff..94fc6d45dc6dcf 100644 --- a/web/app/components/plugins/plugin-page/list/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/list/__tests__/index.spec.tsx @@ -2,9 +2,7 @@ import type { PluginDeclaration, PluginDetail } from '../../../types' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum, PluginSource } from '../../../types' - // ==================== Imports (after mocks) ==================== - import PluginList from '../index' // ==================== Mock Setup ==================== @@ -12,11 +10,7 @@ import PluginList from '../index' // Mock PluginItem component to avoid complex dependency chain vi.mock('../../../plugin-item', () => ({ default: ({ plugin }: { plugin: PluginDetail }) => ( - <div - data-testid="plugin-item" - data-plugin-id={plugin.plugin_id} - data-plugin-name={plugin.name} - > + <div data-testid="plugin-item" data-plugin-id={plugin.plugin_id} data-plugin-name={plugin.name}> {plugin.name} </div> ), @@ -27,7 +21,9 @@ vi.mock('../../../plugin-item', () => ({ /** * Factory function to create a PluginDeclaration with defaults */ -const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ +const createPluginDeclaration = ( + overrides: Partial<PluginDeclaration> = {}, +): PluginDeclaration => ({ plugin_unique_identifier: 'test-plugin-id', version: '1.0.0', author: 'test-author', @@ -86,14 +82,19 @@ const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail /** * Factory function to create a list of plugins */ -const createPluginList = (count: number, baseOverrides: Partial<PluginDetail> = {}): PluginDetail[] => { - return Array.from({ length: count }, (_, index) => createPluginDetail({ - id: `plugin-${index + 1}`, - plugin_id: `plugin-${index + 1}`, - name: `plugin-${index + 1}`, - plugin_unique_identifier: `test-author/plugin-${index + 1}@1.0.0`, - ...baseOverrides, - })) +const createPluginList = ( + count: number, + baseOverrides: Partial<PluginDetail> = {}, +): PluginDetail[] => { + return Array.from({ length: count }, (_, index) => + createPluginDetail({ + id: `plugin-${index + 1}`, + plugin_id: `plugin-${index + 1}`, + name: `plugin-${index + 1}`, + plugin_unique_identifier: `test-author/plugin-${index + 1}@1.0.0`, + ...baseOverrides, + }), + ) } // ==================== Tests ==================== diff --git a/web/app/components/plugins/plugin-page/list/index.tsx b/web/app/components/plugins/plugin-page/list/index.tsx index 103e2f057f4174..f06034627b6fc9 100644 --- a/web/app/components/plugins/plugin-page/list/index.tsx +++ b/web/app/components/plugins/plugin-page/list/index.tsx @@ -18,7 +18,7 @@ const PluginList: FC<IPluginListProps> = ({ return ( <div className="pb-3"> <div className="grid grid-cols-1 gap-3 lg:grid-cols-2"> - {pluginList.map(plugin => ( + {pluginList.map((plugin) => ( <PluginItem key={plugin.plugin_id} plugin={plugin} diff --git a/web/app/components/plugins/plugin-page/nav-operations.tsx b/web/app/components/plugins/plugin-page/nav-operations.tsx index a0b0f5077c73f9..5411dec2a3cc8d 100644 --- a/web/app/components/plugins/plugin-page/nav-operations.tsx +++ b/web/app/components/plugins/plugin-page/nav-operations.tsx @@ -22,11 +22,7 @@ type DropdownItemProps = { text: string } -function DropdownItem({ - href, - icon, - text, -}: DropdownItemProps) { +function DropdownItem({ href, icon, text }: DropdownItemProps) { return ( <DropdownMenuLinkItem href={href} @@ -41,7 +37,11 @@ function DropdownItem({ ) } -type OptionLabelKey = 'requestAPlugin' | 'pluginDevelopmentGuide' | 'pluginPublishGuide' | 'templatePublishingGuide' +type OptionLabelKey = + | 'requestAPlugin' + | 'pluginDevelopmentGuide' + | 'pluginPublishGuide' + | 'templatePublishingGuide' function getOptions(docLink: (path: DocPathWithoutLang) => string): { href: string @@ -76,9 +76,7 @@ type SubmitRequestDropdownProps = { dividerAfterFirst?: boolean } -export function SubmitRequestDropdown({ - dividerAfterFirst, -}: SubmitRequestDropdownProps) { +export function SubmitRequestDropdown({ dividerAfterFirst }: SubmitRequestDropdownProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) const docLink = useDocLink() @@ -87,9 +85,12 @@ export function SubmitRequestDropdown({ return ( <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger - render={( + render={ <Button - aria-label={t($ => $.requestSubmit, { ns: 'plugin', defaultValue: t($ => $.requestAPlugin, { ns: 'plugin' }) })} + aria-label={t(($) => $.requestSubmit, { + ns: 'plugin', + defaultValue: t(($) => $.requestAPlugin, { ns: 'plugin' }), + })} variant="ghost" className={cn( 'size-8 p-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', @@ -98,22 +99,16 @@ export function SubmitRequestDropdown({ > <span className="i-ri-book-open-line size-4 shrink-0" /> </Button> - )} + } /> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="min-w-[200px] p-1" - > + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[200px] p-1"> {options.map((option, index) => ( <Fragment key={option.href}> - {dividerAfterFirst && index === 1 && ( - <DropdownMenuSeparator className="my-1" /> - )} + {dividerAfterFirst && index === 1 && <DropdownMenuSeparator className="my-1" />} <DropdownItem href={option.href} icon={option.icon} - text={t($ => $[option.labelKey], { ns: 'plugin' })} + text={t(($) => $[option.labelKey], { ns: 'plugin' })} /> </Fragment> ))} diff --git a/web/app/components/plugins/plugin-page/plugin-info.tsx b/web/app/components/plugins/plugin-page/plugin-info.tsx index f06c207e7da2ab..1629d5b2b841ab 100644 --- a/web/app/components/plugins/plugin-page/plugin-info.tsx +++ b/web/app/components/plugins/plugin-page/plugin-info.tsx @@ -14,32 +14,45 @@ type Props = Readonly<{ onHide: () => void }> -const PlugInfo: FC<Props> = ({ - repository, - release, - packageName, - onHide, -}) => { +const PlugInfo: FC<Props> = ({ repository, release, packageName, onHide }) => { const { t } = useTranslation() const labelWidthClassName = 'w-[96px]' return ( <Dialog open onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none text-left align-middle"> <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $[`${i18nPrefix}.title`], { ns: 'plugin' })} + {t(($) => $[`${i18nPrefix}.title`], { ns: 'plugin' })} </DialogTitle> <div className="mt-5 space-y-3"> - {repository && <KeyValueItem label={t($ => $[`${i18nPrefix}.repository`], { ns: 'plugin' })} labelWidthClassName={labelWidthClassName} value={`${convertRepoToUrl(repository)}`} valueMaxWidthClassName="max-w-[190px]" />} - {release && <KeyValueItem label={t($ => $[`${i18nPrefix}.release`], { ns: 'plugin' })} labelWidthClassName={labelWidthClassName} value={release} />} - {packageName && <KeyValueItem label={t($ => $[`${i18nPrefix}.packageName`], { ns: 'plugin' })} labelWidthClassName={labelWidthClassName} value={packageName} />} + {repository && ( + <KeyValueItem + label={t(($) => $[`${i18nPrefix}.repository`], { ns: 'plugin' })} + labelWidthClassName={labelWidthClassName} + value={`${convertRepoToUrl(repository)}`} + valueMaxWidthClassName="max-w-[190px]" + /> + )} + {release && ( + <KeyValueItem + label={t(($) => $[`${i18nPrefix}.release`], { ns: 'plugin' })} + labelWidthClassName={labelWidthClassName} + value={release} + /> + )} + {packageName && ( + <KeyValueItem + label={t(($) => $[`${i18nPrefix}.packageName`], { ns: 'plugin' })} + labelWidthClassName={labelWidthClassName} + value={packageName} + /> + )} </div> </DialogContent> </Dialog> diff --git a/web/app/components/plugins/plugin-page/plugin-list-skeleton.tsx b/web/app/components/plugins/plugin-page/plugin-list-skeleton.tsx index 4039917b0c5427..4cbffc3ba683b8 100644 --- a/web/app/components/plugins/plugin-page/plugin-list-skeleton.tsx +++ b/web/app/components/plugins/plugin-page/plugin-list-skeleton.tsx @@ -33,15 +33,13 @@ type PluginListSkeletonProps = { contentFrameClassName: string } -const PluginListSkeleton = ({ - contentFrameClassName, -}: PluginListSkeletonProps) => { +const PluginListSkeleton = ({ contentFrameClassName }: PluginListSkeletonProps) => { const { t } = useTranslation() return ( <div role="status" - aria-label={t($ => $.loading, { ns: 'common' })} + aria-label={t(($) => $.loading, { ns: 'common' })} className={cn('min-h-0 grow self-stretch bg-components-panel-bg', contentFrameClassName)} > <div className="grid grid-cols-1 gap-3 pb-3 lg:grid-cols-2"> diff --git a/web/app/components/plugins/plugin-page/plugin-sidecar-panel.tsx b/web/app/components/plugins/plugin-page/plugin-sidecar-panel.tsx index 43d04c088dad02..7dbcc25016043c 100644 --- a/web/app/components/plugins/plugin-page/plugin-sidecar-panel.tsx +++ b/web/app/components/plugins/plugin-page/plugin-sidecar-panel.tsx @@ -10,11 +10,7 @@ type PluginSidecarPanelProps = { title: ReactNode } -export function PluginSidecarPanel({ - children, - footer, - title, -}: PluginSidecarPanelProps) { +export function PluginSidecarPanel({ children, footer, title }: PluginSidecarPanelProps) { const { t } = useTranslation() return ( @@ -22,21 +18,19 @@ export function PluginSidecarPanel({ <div className="relative flex w-full shrink-0 flex-col gap-0.5 px-3 pt-3.5 pb-1"> <div className="flex w-full shrink-0 items-start"> <div className="flex min-w-0 flex-1 flex-col items-start pr-8 pl-1"> - <div className="w-full system-xl-semibold text-text-primary"> - {title} - </div> + <div className="w-full system-xl-semibold text-text-primary">{title}</div> </div> </div> <PopoverClose - render={( + render={ <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-2.5 right-2.5 flex size-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" > <span aria-hidden className="i-ri-close-line size-4" /> </button> - )} + } /> </div> {children} diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts index e09e94244d5e21..73b957e6331dfc 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts @@ -19,9 +19,7 @@ vi.mock('@/service/use-plugins', () => ({ }, { id: 'task-2', - plugins: [ - { id: 'plugin-3', status: TaskStatus.failed, taskId: 'task-2' }, - ], + plugins: [{ id: 'plugin-3', status: TaskStatus.failed, taskId: 'task-2' }], }, ], handleRefetch: mockRefetch, diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx index 4e1f7c0b22402f..e887fbbac335ba 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx @@ -7,7 +7,6 @@ import { useMutationClearTaskPlugin, usePluginTaskList } from '@/service/use-plu import PluginTaskList from '../components/plugin-task-list' import TaskStatusIndicator from '../components/task-status-indicator' import { usePluginTaskStatus } from '../hooks' - import PluginTasks from '../index' // Mock external dependencies @@ -43,9 +42,20 @@ const setupMocks = (plugins: PluginStatus[] = []) => { const mockHandleRefetch = vi.fn() vi.mocked(usePluginTaskList).mockReturnValue({ - pluginTasks: plugins.length > 0 - ? [{ id: 'task-1', plugins, created_at: '', updated_at: '', status: TaskStatus.running, total_plugins: plugins.length, completed_plugins: 0 }] - : [], + pluginTasks: + plugins.length > 0 + ? [ + { + id: 'task-1', + plugins, + created_at: '', + updated_at: '', + status: TaskStatus.running, + total_plugins: plugins.length, + completed_plugins: 0, + }, + ] + : [], handleRefetch: mockHandleRefetch, } as unknown as ReturnType<typeof usePluginTaskList>) @@ -82,7 +92,9 @@ describe('usePluginTaskStatus Hook', () => { render(<TestComponent />) expect(screen.getByTestId('running-count'))!.toHaveTextContent('1') - expect(screen.getByTestId('running-id'))!.toHaveTextContent(runningPlugin.plugin_unique_identifier) + expect(screen.getByTestId('running-id'))!.toHaveTextContent( + runningPlugin.plugin_unique_identifier, + ) }) it('should categorize success plugins correctly', () => { @@ -102,7 +114,9 @@ describe('usePluginTaskStatus Hook', () => { render(<TestComponent />) expect(screen.getByTestId('success-count'))!.toHaveTextContent('1') - expect(screen.getByTestId('success-id'))!.toHaveTextContent(successPlugin.plugin_unique_identifier) + expect(screen.getByTestId('success-id'))!.toHaveTextContent( + successPlugin.plugin_unique_identifier, + ) }) it('should categorize error plugins correctly', () => { @@ -122,7 +136,9 @@ describe('usePluginTaskStatus Hook', () => { render(<TestComponent />) expect(screen.getByTestId('error-count'))!.toHaveTextContent('1') - expect(screen.getByTestId('error-id'))!.toHaveTextContent(errorPlugin.plugin_unique_identifier) + expect(screen.getByTestId('error-id'))!.toHaveTextContent( + errorPlugin.plugin_unique_identifier, + ) }) it('should categorize mixed plugins correctly', () => { @@ -134,7 +150,12 @@ describe('usePluginTaskStatus Hook', () => { setupMocks(plugins) const TestComponent = () => { - const { runningPluginsLength, successPluginsLength, errorPluginsLength, totalPluginsLength } = usePluginTaskStatus() + const { + runningPluginsLength, + successPluginsLength, + errorPluginsLength, + totalPluginsLength, + } = usePluginTaskStatus() return ( <div> <span data-testid="running">{runningPluginsLength}</span> @@ -159,7 +180,13 @@ describe('usePluginTaskStatus Hook', () => { setupMocks([createMockPlugin({ status: TaskStatus.running })]) const TestComponent = () => { - const { isInstalling, isInstallingWithSuccess, isInstallingWithError, isSuccess, isFailed } = usePluginTaskStatus() + const { + isInstalling, + isInstallingWithSuccess, + isInstallingWithError, + isSuccess, + isFailed, + } = usePluginTaskStatus() return ( <div> <span data-testid="isInstalling">{String(isInstalling)}</span> @@ -249,11 +276,7 @@ describe('usePluginTaskStatus Hook', () => { const TestComponent = () => { const { handleClearErrorPlugin } = usePluginTaskStatus() - return ( - <button onClick={() => handleClearErrorPlugin('task-1', 'plugin-1')}> - Clear - </button> - ) + return <button onClick={() => handleClearErrorPlugin('task-1', 'plugin-1')}>Clear</button> } render(<TestComponent />) @@ -370,14 +393,20 @@ describe('TaskStatusIndicator Component', () => { />, ) const trigger = document.getElementById('plugin-task-trigger') - expect(trigger)!.toHaveClass('border-components-panel-border-subtle', 'bg-components-panel-bg') + expect(trigger)!.toHaveClass( + 'border-components-panel-border-subtle', + 'bg-components-panel-bg', + ) expect(screen.getByTestId('task-status-success-badge')).toHaveClass('text-text-success') }) it('should show error icon when failed', () => { render(<TaskStatusIndicator {...defaultProps} isFailed />) const trigger = document.getElementById('plugin-task-trigger') - expect(trigger)!.toHaveClass('border-components-button-destructive-secondary-border-hover', 'bg-state-destructive-hover') + expect(trigger)!.toHaveClass( + 'border-components-button-destructive-secondary-border-hover', + 'bg-state-destructive-hover', + ) expect(screen.getByTestId('task-status-error-badge')).toHaveClass('text-text-destructive') }) }) @@ -452,7 +481,9 @@ describe('PluginTaskList Component', () => { }) it('should render error plugins section when plugins exist', () => { - const errorPlugins = [createMockPlugin({ status: TaskStatus.failed, message: 'Error occurred' })] + const errorPlugins = [ + createMockPlugin({ status: TaskStatus.failed, message: 'Error occurred' }), + ] render(<PluginTaskList {...defaultProps} errorPlugins={errorPlugins} />) expect(screen.getByText('Error occurred'))!.toBeInTheDocument() @@ -538,8 +569,14 @@ describe('PluginTaskList Component', () => { it('should display multiple plugins in each section', () => { const runningPlugins = [ - createMockPlugin({ status: TaskStatus.running, labels: { en_US: 'Plugin A' } as Record<string, string> }), - createMockPlugin({ status: TaskStatus.running, labels: { en_US: 'Plugin B' } as Record<string, string> }), + createMockPlugin({ + status: TaskStatus.running, + labels: { en_US: 'Plugin A' } as Record<string, string>, + }), + createMockPlugin({ + status: TaskStatus.running, + labels: { en_US: 'Plugin B' } as Record<string, string>, + }), ] render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />) @@ -631,7 +668,9 @@ describe('PluginTasks Component', () => { fireEvent.click(getTaskMenuTrigger()) - expect(document.getElementById('plugin-task-trigger'))!.toHaveClass('bg-state-destructive-hover-alt') + expect(document.getElementById('plugin-task-trigger'))!.toHaveClass( + 'bg-state-destructive-hover-alt', + ) }) it('should apply pointer cursor to the task menu trigger when it can open', () => { @@ -790,9 +829,11 @@ describe('PluginTasks Component', () => { it('should handle many plugins', () => { const manyPlugins = Array.from({ length: 10 }, (_, i) => createMockPlugin({ - status: i % 3 === 0 ? TaskStatus.running : i % 3 === 1 ? TaskStatus.success : TaskStatus.failed, + status: + i % 3 === 0 ? TaskStatus.running : i % 3 === 1 ? TaskStatus.success : TaskStatus.failed, plugin_unique_identifier: `plugin-${i}`, - })) + }), + ) setupMocks(manyPlugins) render(<PluginTasks />) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/error-plugin-item.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/error-plugin-item.spec.tsx index 762a0de9cfa71f..aef198a0c2732a 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/error-plugin-item.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/error-plugin-item.spec.tsx @@ -3,17 +3,24 @@ import type { PluginStatus } from '@/app/components/plugins/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types' import { fetchPluginInfoFromMarketPlace } from '@/service/plugins' - import ErrorPluginItem from '../error-plugin-item' vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ - default: ({ src, size }: { src: string, size: string }) => ( + default: ({ src, size }: { src: string; size: string }) => ( <div data-testid="card-icon" data-src={src} data-size={size} /> ), })) vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ - default: ({ uniqueIdentifier, onClose, onSuccess }: { uniqueIdentifier: string, onClose: () => void, onSuccess: () => void }) => ( + default: ({ + uniqueIdentifier, + onClose, + onSuccess, + }: { + uniqueIdentifier: string + onClose: () => void + onSuccess: () => void + }) => ( <div data-testid="install-modal" data-uid={uniqueIdentifier}> <button onClick={onClose}>Close modal</button> <button onClick={onSuccess}>Success</button> @@ -25,12 +32,15 @@ vi.mock('@/service/plugins', () => ({ fetchPluginInfoFromMarketPlace: vi.fn(), })) -vi.mock('@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', () => ({ - default: () => ({ - canInstallPlugin: true, - currentDifyVersion: '1.0.0', +vi.mock( + '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', + () => ({ + default: () => ({ + canInstallPlugin: true, + currentDifyVersion: '1.0.0', + }), }), -})) +) const mockFetch = vi.mocked(fetchPluginInfoFromMarketPlace) const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`) @@ -124,7 +134,10 @@ describe('ErrorPluginItem', () => { it('should show github error message for github plugins', () => { render( <ErrorPluginItem - plugin={createPlugin({ source: PluginSource.github, plugin_id: 'https://github.com/user/repo' })} + plugin={createPlugin({ + source: PluginSource.github, + plugin_id: 'https://github.com/user/repo', + })} getIconUrl={mockGetIconUrl} language="en_US" onClear={vi.fn()} @@ -178,7 +191,10 @@ describe('ErrorPluginItem', () => { it('should show "Install from GitHub" button for github plugins', () => { render( <ErrorPluginItem - plugin={createPlugin({ source: PluginSource.github, plugin_id: 'https://github.com/user/repo' })} + plugin={createPlugin({ + source: PluginSource.github, + plugin_id: 'https://github.com/user/repo', + })} getIconUrl={mockGetIconUrl} language="en_US" onClear={vi.fn()} @@ -230,7 +246,7 @@ describe('ErrorPluginItem', () => { // The clear button (×) is from PluginItem const buttons = screen.getAllByRole('button') - const clearButton = buttons.find(btn => !btn.textContent?.includes('plugin.task')) + const clearButton = buttons.find((btn) => !btn.textContent?.includes('plugin.task')) fireEvent.click(clearButton!) expect(onClear).toHaveBeenCalledTimes(1) @@ -322,7 +338,10 @@ describe('ErrorPluginItem', () => { it('should render github action when source is github even if plugin_id looks like a URL', () => { render( <ErrorPluginItem - plugin={createPlugin({ source: PluginSource.github, plugin_id: 'http://github.com/user/repo' })} + plugin={createPlugin({ + source: PluginSource.github, + plugin_id: 'http://github.com/user/repo', + })} getIconUrl={mockGetIconUrl} language="en_US" onClear={vi.fn()} diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-item.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-item.spec.tsx index e4dac6d4bfbf9d..9243cf968b1548 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-item.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-item.spec.tsx @@ -10,7 +10,7 @@ vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({ })) vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ - default: ({ src, size }: { src: string, size: string }) => ( + default: ({ src, size }: { src: string; size: string }) => ( <div data-testid="card-icon" data-src={src} data-size={size} /> ), })) @@ -95,7 +95,11 @@ describe('PluginItem', () => { const iconWrapper = cardIcon.parentElement expect(iconWrapper).toHaveClass('relative', 'self-start') - expect(screen.getByTestId('status-icon').parentElement).toHaveClass('absolute', '-bottom-0.5', '-right-0.5') + expect(screen.getByTestId('status-icon').parentElement).toHaveClass( + 'absolute', + '-bottom-0.5', + '-right-0.5', + ) }) it('should pass icon url to CardIcon', () => { @@ -128,8 +132,16 @@ describe('PluginItem', () => { expect(mockGetIconUrl).not.toHaveBeenCalled() expect(screen.queryByTestId('card-icon')).not.toBeInTheDocument() - expect(container.querySelector('[data-testid="magic-box-icon"]')).toHaveClass('size-8', 'text-text-tertiary') - expect(screen.getByTestId('status-icon').parentElement).toHaveClass('absolute', '-bottom-0.5', '-right-0.5', 'z-10') + expect(container.querySelector('[data-testid="magic-box-icon"]')).toHaveClass( + 'size-8', + 'text-text-tertiary', + ) + expect(screen.getByTestId('status-icon').parentElement).toHaveClass( + 'absolute', + '-bottom-0.5', + '-right-0.5', + 'z-10', + ) }) }) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-section.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-section.spec.tsx index 76f107be36e930..aab5be06b63272 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-section.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-section.spec.tsx @@ -4,7 +4,7 @@ import { PluginSource, TaskStatus } from '@/app/components/plugins/types' import PluginSection from '../plugin-section' vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ - default: ({ src, size }: { src: string, size: string }) => ( + default: ({ src, size }: { src: string; size: string }) => ( <div data-testid="card-icon" data-src={src} data-size={size} /> ), })) @@ -61,9 +61,7 @@ describe('PluginSection', () => { describe('Props', () => { it('should return null when plugins array is empty', () => { - const { container } = render( - <PluginSection {...defaultProps} plugins={[]} />, - ) + const { container } = render(<PluginSection {...defaultProps} plugins={[]} />) expect(container.innerHTML).toBe('') }) @@ -97,12 +95,7 @@ describe('PluginSection', () => { }) it('should render headerAction when provided', () => { - render( - <PluginSection - {...defaultProps} - headerAction={<button>Clear all</button>} - />, - ) + render(<PluginSection {...defaultProps} headerAction={<button>Clear all</button>} />) expect(screen.getByRole('button', { name: /clear all/i }))!.toBeInTheDocument() }) @@ -117,9 +110,7 @@ describe('PluginSection', () => { render( <PluginSection {...defaultProps} - renderItemAction={plugin => ( - <button>{`Action ${plugin.labels.en_US}`}</button> - )} + renderItemAction={(plugin) => <button>{`Action ${plugin.labels.en_US}`}</button>} />, ) @@ -131,12 +122,7 @@ describe('PluginSection', () => { describe('User Interactions', () => { it('should call onClearSingle with taskId and plugin identifier', () => { const onClearSingle = vi.fn() - render( - <PluginSection - {...defaultProps} - onClearSingle={onClearSingle} - />, - ) + render(<PluginSection {...defaultProps} onClearSingle={onClearSingle} />) // Clear buttons are rendered when onClearSingle is provided const clearButtons = screen.getAllByRole('button') diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-task-list.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-task-list.spec.tsx index 8c2fcddb97549a..e9d302cc7f60b1 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-task-list.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/plugin-task-list.spec.tsx @@ -4,7 +4,7 @@ import { PluginSource, TaskStatus } from '@/app/components/plugins/types' import PluginTaskList from '../plugin-task-list' vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ - default: ({ src, size }: { src: string, size: string }) => ( + default: ({ src, size }: { src: string; size: string }) => ( <div data-testid="card-icon" data-src={src} data-size={size} /> ), })) @@ -18,7 +18,11 @@ vi.mock('@/service/plugins', () => ({ })) const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`) -const createPlugin = (id: string, name: string, overrides: Partial<PluginStatus> = {}): PluginStatus => ({ +const createPlugin = ( + id: string, + name: string, + overrides: Partial<PluginStatus> = {}, +): PluginStatus => ({ plugin_unique_identifier: id, plugin_id: `org/${name.toLowerCase()}`, source: PluginSource.marketplace, @@ -39,9 +43,7 @@ const errorPlugins = [ createPlugin('e1', 'DALLE', { status: TaskStatus.failed, plugin_id: 'org/dalle' }), ] -const successPlugins = [ - createPlugin('s1', 'Google', { status: TaskStatus.success }), -] +const successPlugins = [createPlugin('s1', 'Google', { status: TaskStatus.success })] describe('PluginTaskList', () => { const defaultProps = { @@ -109,7 +111,9 @@ describe('PluginTaskList', () => { it('should show Clear all button in error section', () => { render(<PluginTaskList {...defaultProps} errorPlugins={errorPlugins} />) - expect(screen.getByRole('button', { name: /plugin\.task\.errorPlugins.*plugin\.task\.clearAll/ })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /plugin\.task\.errorPlugins.*plugin\.task\.clearAll/ }), + ).toBeInTheDocument() }) it('should call onClearErrors when error section Clear all is clicked', () => { @@ -122,7 +126,9 @@ describe('PluginTaskList', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /plugin\.task\.errorPlugins.*plugin\.task\.clearAll/ })) + fireEvent.click( + screen.getByRole('button', { name: /plugin\.task\.errorPlugins.*plugin\.task\.clearAll/ }), + ) expect(onClearErrors).toHaveBeenCalledTimes(1) }) @@ -135,8 +141,14 @@ describe('PluginTaskList', () => { />, ) - expect(screen.getByRole('button', { name: /plugin\.task\.successPlugins.*plugin\.task\.clearAll/ })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /plugin\.task\.errorPlugins.*plugin\.task\.clearAll/ })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: /plugin\.task\.successPlugins.*plugin\.task\.clearAll/, + }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /plugin\.task\.errorPlugins.*plugin\.task\.clearAll/ }), + ).toBeInTheDocument() }) }) @@ -175,13 +187,7 @@ describe('PluginTaskList', () => { describe('Edge Cases', () => { it('should not render sections for empty plugin arrays', () => { - render( - <PluginTaskList - {...defaultProps} - runningPlugins={[]} - errorPlugins={[]} - />, - ) + render(<PluginTaskList {...defaultProps} runningPlugins={[]} errorPlugins={[]} />) expect(screen.queryByText(/plugin\.task\.runningPlugins/)).not.toBeInTheDocument() expect(screen.queryByText(/plugin\.task\.errorPlugins/)).not.toBeInTheDocument() @@ -190,7 +196,10 @@ describe('PluginTaskList', () => { it('should render error section with multiple error plugins', () => { const multipleErrors = [ createPlugin('e1', 'PluginA', { status: TaskStatus.failed, plugin_id: 'org/a' }), - createPlugin('e2', 'PluginB', { status: TaskStatus.failed, plugin_id: 'https://github.com/b' }), + createPlugin('e2', 'PluginB', { + status: TaskStatus.failed, + plugin_id: 'https://github.com/b', + }), createPlugin('e3', 'PluginC', { status: TaskStatus.failed, plugin_id: 'local-only' }), ] diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/task-status-indicator.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/task-status-indicator.spec.tsx index b1464954a94cec..47161df9c49cf5 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/task-status-indicator.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/__tests__/task-status-indicator.spec.tsx @@ -2,7 +2,9 @@ import { fireEvent, render, screen } from '@testing-library/react' import TaskStatusIndicator from '../task-status-indicator' vi.mock('@/app/components/header/plugins-nav/downloading-icon', () => ({ - default: ({ active = true }: { active?: boolean }) => <span data-active={String(active)} data-testid="downloading-icon" />, + default: ({ active = true }: { active?: boolean }) => ( + <span data-active={String(active)} data-testid="downloading-icon" /> + ), })) const defaultProps = { @@ -51,9 +53,19 @@ describe('TaskStatusIndicator', () => { const button = document.getElementById('plugin-task-trigger')! - expect(button).toHaveClass('size-8', 'border-[0.5px]', 'border-components-panel-border-subtle', 'bg-components-panel-bg', 'p-2', 'rounded-lg') + expect(button).toHaveClass( + 'size-8', + 'border-[0.5px]', + 'border-components-panel-border-subtle', + 'bg-components-panel-bg', + 'p-2', + 'rounded-lg', + ) expect(screen.getByTestId('downloading-icon')).toHaveAttribute('data-active', 'false') - expect(screen.getByTestId('task-status-success-badge')).toHaveClass('size-3.5', 'text-text-success') + expect(screen.getByTestId('task-status-success-badge')).toHaveClass( + 'size-3.5', + 'text-text-success', + ) }) }) @@ -106,19 +118,18 @@ describe('TaskStatusIndicator', () => { expect(screen.queryByTestId('progress-circle')).not.toBeInTheDocument() const badgeIcon = screen.getByTestId('task-status-error-badge') expect(badgeIcon).toBeInTheDocument() - expect(badgeIcon.parentElement).toHaveClass('-top-1.5', '-right-1.5', 'box-content', 'size-3.5') + expect(badgeIcon.parentElement).toHaveClass( + '-top-1.5', + '-right-1.5', + 'box-content', + 'size-3.5', + ) expect(badgeIcon.parentElement).toHaveClass('border') expect(badgeIcon).toHaveClass('size-3.5') }) it('should handle zero totalPluginsLength without division error', () => { - render( - <TaskStatusIndicator - {...defaultProps} - isInstalling - totalPluginsLength={0} - />, - ) + render(<TaskStatusIndicator {...defaultProps} isInstalling totalPluginsLength={0} />) expect(screen.queryByTestId('progress-circle')).not.toBeInTheDocument() }) }) @@ -170,11 +181,7 @@ describe('TaskStatusIndicator', () => { describe('Failed state', () => { it('should show error icon when isFailed', () => { const { container } = render( - <TaskStatusIndicator - {...defaultProps} - isFailed - totalPluginsLength={2} - />, + <TaskStatusIndicator {...defaultProps} isFailed totalPluginsLength={2} />, ) const errorIcon = container.querySelector('.text-text-destructive') expect(errorIcon).toBeInTheDocument() @@ -196,37 +203,19 @@ describe('TaskStatusIndicator', () => { }) it('should keep the center install icon neutral in failed state', () => { - render( - <TaskStatusIndicator - {...defaultProps} - isFailed - totalPluginsLength={1} - />, - ) + render(<TaskStatusIndicator {...defaultProps} isFailed totalPluginsLength={1} />) expect(screen.getByTestId('downloading-icon')).toHaveAttribute('data-active', 'false') }) it('should apply destructive styling when isFailed', () => { - render( - <TaskStatusIndicator - {...defaultProps} - isFailed - totalPluginsLength={1} - />, - ) + render(<TaskStatusIndicator {...defaultProps} isFailed totalPluginsLength={1} />) const button = document.getElementById('plugin-task-trigger')! expect(button.className).toContain('bg-state-destructive-hover') }) it('should apply destructive styling when isInstallingWithError', () => { - render( - <TaskStatusIndicator - {...defaultProps} - isInstallingWithError - totalPluginsLength={2} - />, - ) + render(<TaskStatusIndicator {...defaultProps} isInstallingWithError totalPluginsLength={2} />) const button = document.getElementById('plugin-task-trigger')! expect(button.className).toContain('bg-state-destructive-hover') }) @@ -259,26 +248,13 @@ describe('TaskStatusIndicator', () => { }) it('should apply cursor-pointer for error states', () => { - render( - <TaskStatusIndicator - {...defaultProps} - isFailed - totalPluginsLength={1} - />, - ) + render(<TaskStatusIndicator {...defaultProps} isFailed totalPluginsLength={1} />) const button = document.getElementById('plugin-task-trigger')! expect(button.className).toContain('cursor-pointer') }) it('should apply open trigger styling when the task menu is expanded', () => { - render( - <TaskStatusIndicator - {...defaultProps} - isFailed - isOpen - totalPluginsLength={1} - />, - ) + render(<TaskStatusIndicator {...defaultProps} isFailed isOpen totalPluginsLength={1} />) const button = document.getElementById('plugin-task-trigger')! expect(button).toHaveClass('bg-state-destructive-hover-alt', 'shadow-xs') diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx index 0365412a1f027a..d7d1efc59f4901 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx @@ -22,14 +22,16 @@ const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, languag const { t } = useTranslation() const source = plugin.source const [showInstallModal, setShowInstallModal] = useState(false) - const [installPayload, setInstallPayload] = useState<{ uniqueIdentifier: string, manifest: Plugin } | null>(null) + const [installPayload, setInstallPayload] = useState<{ + uniqueIdentifier: string + manifest: Plugin + } | null>(null) const [isFetching, setIsFetching] = useState(false) const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission() const handleInstallFromMarketplace = useCallback(async () => { const parts = plugin.plugin_id.split('/') - if (parts.length < 2) - return + if (parts.length < 2) return const [org, name] = parts setIsFetching(true) try { @@ -60,29 +62,36 @@ const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, languag } setInstallPayload({ uniqueIdentifier: info.latest_package_identifier, manifest }) setShowInstallModal(true) - } - catch { + } catch { // silently fail - } - finally { + } finally { setIsFetching(false) } }, [plugin.plugin_id, plugin.labels, plugin.icon]) - const errorMsgKey: 'task.errorMsg.marketplace' | 'task.errorMsg.github' | 'task.errorMsg.unknown' = source === PluginSource.marketplace - ? 'task.errorMsg.marketplace' - : source === PluginSource.github - ? 'task.errorMsg.github' - : 'task.errorMsg.unknown' + const errorMsgKey: + | 'task.errorMsg.marketplace' + | 'task.errorMsg.github' + | 'task.errorMsg.unknown' = + source === PluginSource.marketplace + ? 'task.errorMsg.marketplace' + : source === PluginSource.github + ? 'task.errorMsg.github' + : 'task.errorMsg.unknown' - const errorMsg = t($ => $[errorMsgKey], { ns: 'plugin' }) + const errorMsg = t(($) => $[errorMsgKey], { ns: 'plugin' }) const renderAction = () => { if (source === PluginSource.marketplace && canInstallPlugin) { return ( <div className="pt-1"> - <Button variant="secondary" size="small" loading={isFetching} onClick={handleInstallFromMarketplace}> - {t($ => $['task.installFromMarketplace'], { ns: 'plugin' })} + <Button + variant="secondary" + size="small" + loading={isFetching} + onClick={handleInstallFromMarketplace} + > + {t(($) => $['task.installFromMarketplace'], { ns: 'plugin' })} </Button> </div> ) @@ -91,7 +100,7 @@ const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, languag return ( <div className="pt-1"> <Button variant="secondary" size="small"> - {t($ => $['task.installFromGithub'], { ns: 'plugin' })} + {t(($) => $['task.installFromGithub'], { ns: 'plugin' })} </Button> </div> ) @@ -105,16 +114,16 @@ const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, languag plugin={plugin} getIconUrl={getIconUrl} language={language} - statusIcon={( + statusIcon={ <span className="flex size-4 items-center justify-center rounded-full border border-components-panel-bg bg-components-panel-bg"> <span className="i-ri-error-warning-fill size-4 text-text-destructive" /> </span> - )} - statusText={( + } + statusText={ <span className="block max-w-full min-w-0 [overflow-wrap:anywhere] break-words whitespace-pre-wrap"> {plugin.message || errorMsg} </span> - )} + } statusClassName="text-text-destructive" action={renderAction()} onClear={onClear} diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx index a750be51b6681b..a10b5768fc5f92 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx @@ -31,24 +31,21 @@ const PluginItem: FC<PluginItemProps> = ({ return ( <div className="group/item flex w-full max-w-full min-w-0 gap-1 overflow-hidden rounded-lg p-2 hover:bg-state-base-hover"> <div className="relative shrink-0 self-start"> - {hasPluginIcon - ? ( - <CardIcon - size="small" - src={getIconUrl(plugin.icon)} - /> - ) + {hasPluginIcon ? ( + <CardIcon size="small" src={getIconUrl(plugin.icon)} /> + ) : ( // eslint-disable-next-line hyoban/prefer-tailwind-icons -- Reuse the same MagicBox component as the marketplace install button. - : <MagicBox className="size-8 text-text-tertiary" />} - <div className="absolute -right-0.5 -bottom-0.5 z-10"> - {statusIcon} - </div> + <MagicBox className="size-8 text-text-tertiary" /> + )} + <div className="absolute -right-0.5 -bottom-0.5 z-10">{statusIcon}</div> </div> <div className="flex min-w-0 flex-1 flex-col gap-0.5 px-1 [overflow-wrap:anywhere]"> <div className="truncate system-sm-medium text-text-secondary"> {plugin.labels[language]} </div> - <div className={`max-w-full min-w-0 system-xs-regular [overflow-wrap:anywhere] wrap-break-word ${statusClassName || 'text-text-tertiary'}`}> + <div + className={`max-w-full min-w-0 system-xs-regular [overflow-wrap:anywhere] wrap-break-word ${statusClassName || 'text-text-tertiary'}`} + > {statusText} </div> {action} diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx index 3c659e5df20c7e..d5a1395e7a83e8 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx @@ -31,18 +31,13 @@ function PluginSection({ renderItemAction, onClearSingle, }: PluginSectionProps) { - if (plugins.length === 0) - return null + if (plugins.length === 0) return null return ( <> <div className="sticky top-0 flex items-start justify-between pt-1 pr-1 pl-2 system-sm-semibold-uppercase text-text-secondary"> <span className="flex h-6 items-center"> - {title} - {' '} - ( - {count} - ) + {title} ({count}) </span> {headerAction} </div> @@ -54,7 +49,7 @@ function PluginSection({ content: 'min-w-0', }} > - {plugins.map(plugin => ( + {plugins.map((plugin) => ( <PluginItem key={plugin.plugin_unique_identifier} plugin={plugin} @@ -64,9 +59,11 @@ function PluginSection({ statusText={plugin.message || defaultStatusText} statusClassName={statusClassName} action={renderItemAction?.(plugin)} - onClear={onClearSingle - ? () => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier) - : undefined} + onClear={ + onClearSingle + ? () => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier) + : undefined + } /> ))} </ScrollArea> diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx index 033a8c64626b01..f2fb7ba83ef686 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx @@ -27,9 +27,9 @@ function PluginTaskList({ }: PluginTaskListProps) { const { t } = useTranslation() const language = useGetLanguage() - const runningSectionTitle = t($ => $['task.runningPlugins'], { ns: 'plugin' }) - const successSectionTitle = t($ => $['task.successPlugins'], { ns: 'plugin' }) - const errorSectionTitle = t($ => $['task.errorPlugins'], { ns: 'plugin' }) + const runningSectionTitle = t(($) => $['task.runningPlugins'], { ns: 'plugin' }) + const successSectionTitle = t(($) => $['task.successPlugins'], { ns: 'plugin' }) + const errorSectionTitle = t(($) => $['task.errorPlugins'], { ns: 'plugin' }) return ( <div @@ -38,7 +38,7 @@ function PluginTaskList({ > <ScrollArea className="max-h-[420px] overflow-hidden" - label={t($ => $['task.installing'], { ns: 'plugin' })} + label={t(($) => $['task.installing'], { ns: 'plugin' })} slotClassNames={{ viewport: 'max-h-[420px] overscroll-contain', content: 'w-full! max-w-full! min-w-0! overflow-x-hidden! pr-3', @@ -54,7 +54,7 @@ function PluginTaskList({ statusIcon={ <span className="i-ri-loader-2-line size-3.5 animate-spin text-text-accent" /> } - defaultStatusText={t($ => $['task.installingHint'], { ns: 'plugin' })} + defaultStatusText={t(($) => $['task.installingHint'], { ns: 'plugin' })} /> )} @@ -65,22 +65,20 @@ function PluginTaskList({ plugins={successPlugins} getIconUrl={getIconUrl} language={language} - statusIcon={ - <span className="i-ri-checkbox-circle-fill size-3.5 text-text-success" /> - } - defaultStatusText={t($ => $['task.installed'], { ns: 'plugin' })} + statusIcon={<span className="i-ri-checkbox-circle-fill size-3.5 text-text-success" />} + defaultStatusText={t(($) => $['task.installed'], { ns: 'plugin' })} statusClassName="text-text-success" - headerAction={( + headerAction={ <Button - aria-label={`${successSectionTitle} ${t($ => $['task.clearAll'], { ns: 'plugin' })}`} + aria-label={`${successSectionTitle} ${t(($) => $['task.clearAll'], { ns: 'plugin' })}`} className="shrink-0" size="small" variant="ghost" onClick={onClearAll} > - {t($ => $['task.clearAll'], { ns: 'plugin' })} + {t(($) => $['task.clearAll'], { ns: 'plugin' })} </Button> - )} + } onClearSingle={onClearSingle} /> )} @@ -88,19 +86,15 @@ function PluginTaskList({ {errorPlugins.length > 0 && ( <> <div className="sticky top-0 flex h-7 items-center justify-between px-2 pt-1 system-sm-semibold-uppercase text-text-secondary"> - {errorSectionTitle} - {' '} - ( - {errorPlugins.length} - ) + {errorSectionTitle} ({errorPlugins.length}) <Button - aria-label={`${errorSectionTitle} ${t($ => $['task.clearAll'], { ns: 'plugin' })}`} + aria-label={`${errorSectionTitle} ${t(($) => $['task.clearAll'], { ns: 'plugin' })}`} className="shrink-0" size="small" variant="ghost" onClick={onClearErrors} > - {t($ => $['task.clearAll'], { ns: 'plugin' })} + {t(($) => $['task.clearAll'], { ns: 'plugin' })} </Button> </div> <div @@ -108,7 +102,7 @@ function PluginTaskList({ className="w-full max-w-full min-w-0 overflow-hidden" role="region" > - {errorPlugins.map(plugin => ( + {errorPlugins.map((plugin) => ( <ErrorPluginItem key={plugin.plugin_unique_identifier} plugin={plugin} diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx index afcb00c37086f3..68d9f22572d493 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx @@ -71,7 +71,9 @@ function TaskStatusIndicator({ }: TaskStatusIndicatorProps) { const showErrorStyle = isInstallingWithError || isFailed const hasActiveInstall = isInstalling || isInstallingWithSuccess || isInstallingWithError - const showSuccessIcon = isSuccess || (!hasActiveInstall && !isFailed && successPluginsLength > 0 && runningPluginsLength === 0) + const showSuccessIcon = + isSuccess || + (!hasActiveInstall && !isFailed && successPluginsLength > 0 && runningPluginsLength === 0) const showSuccessBadge = showSuccessIcon && !isInstallingWithError && !isFailed const showBadge = isInstallingWithError || showSuccessBadge || isFailed const isClickable = !disabled && (hasActiveInstall || isSuccess || isFailed) @@ -79,7 +81,7 @@ function TaskStatusIndicator({ return ( <Tooltip> <TooltipTrigger - render={( + render={ <Button type="button" variant="secondary" @@ -90,8 +92,11 @@ function TaskStatusIndicator({ 'relative size-8 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-bg p-2 shadow-none', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid', isClickable ? 'cursor-pointer' : 'cursor-default', - showErrorStyle && 'cursor-pointer border-components-button-destructive-secondary-border-hover bg-state-destructive-hover text-components-button-destructive-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] hover:bg-state-destructive-hover-alt', - isOpen && !showErrorStyle && 'border-components-button-secondary-border-hover bg-components-button-secondary-bg-hover shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]', + showErrorStyle && + 'cursor-pointer border-components-button-destructive-secondary-border-hover bg-state-destructive-hover text-components-button-destructive-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] hover:bg-state-destructive-hover-alt', + isOpen && + !showErrorStyle && + 'border-components-button-secondary-border-hover bg-components-button-secondary-bg-hover shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]', isOpen && showErrorStyle && 'bg-state-destructive-hover-alt', )} id="plugin-task-trigger" @@ -101,19 +106,13 @@ function TaskStatusIndicator({ {showBadge && ( <div className="absolute -top-1.5 -right-1.5 box-content flex size-3.5 items-center justify-center rounded-full border border-components-panel-bg bg-components-panel-bg"> - {isInstallingWithError && ( - <ErrorBadgeIcon /> - )} - {showSuccessBadge && ( - <SuccessBadgeIcon /> - )} - {isFailed && ( - <ErrorBadgeIcon /> - )} + {isInstallingWithError && <ErrorBadgeIcon />} + {showSuccessBadge && <SuccessBadgeIcon />} + {isFailed && <ErrorBadgeIcon />} </div> )} </Button> - )} + } /> <TooltipContent sideOffset={8}>{tip}</TooltipContent> </Tooltip> diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts b/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts index f7909e39475d71..0cb84a859d678e 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts +++ b/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts @@ -1,57 +1,60 @@ import type { PluginStatus } from '@/app/components/plugins/types' -import { - useCallback, -} from 'react' +import { useCallback } from 'react' import { TaskStatus } from '@/app/components/plugins/types' -import { - useMutationClearTaskPlugin, - usePluginTaskList, -} from '@/service/use-plugins' +import { useMutationClearTaskPlugin, usePluginTaskList } from '@/service/use-plugins' -const isUnfinishedStatus = (status: PluginStatus['status']) => status === TaskStatus.pending || status === TaskStatus.running +const isUnfinishedStatus = (status: PluginStatus['status']) => + status === TaskStatus.pending || status === TaskStatus.running export const usePluginTaskStatus = () => { - const { - pluginTasks, - handleRefetch, - } = usePluginTaskList() + const { pluginTasks, handleRefetch } = usePluginTaskList() const { mutateAsync } = useMutationClearTaskPlugin() - const allPlugins = pluginTasks.map(task => task.plugins.map((plugin) => { - return { - ...plugin, - taskId: task.id, - } - })).flat() + const allPlugins = pluginTasks + .map((task) => + task.plugins.map((plugin) => { + return { + ...plugin, + taskId: task.id, + } + }), + ) + .flat() const errorPlugins: PluginStatus[] = [] const successPlugins: PluginStatus[] = [] const runningPlugins: PluginStatus[] = [] allPlugins.forEach((plugin) => { - if (isUnfinishedStatus(plugin.status)) - runningPlugins.push(plugin) - if (plugin.status === TaskStatus.failed) - errorPlugins.push(plugin) - if (plugin.status === TaskStatus.success) - successPlugins.push(plugin) + if (isUnfinishedStatus(plugin.status)) runningPlugins.push(plugin) + if (plugin.status === TaskStatus.failed) errorPlugins.push(plugin) + if (plugin.status === TaskStatus.success) successPlugins.push(plugin) }) - const handleClearErrorPlugin = useCallback(async (taskId: string, pluginId: string) => { - await mutateAsync({ - taskId, - pluginId, - }) - handleRefetch() - }, [mutateAsync, handleRefetch]) + const handleClearErrorPlugin = useCallback( + async (taskId: string, pluginId: string) => { + await mutateAsync({ + taskId, + pluginId, + }) + handleRefetch() + }, + [mutateAsync, handleRefetch], + ) const totalPluginsLength = allPlugins.length const runningPluginsLength = runningPlugins.length const errorPluginsLength = errorPlugins.length const successPluginsLength = successPlugins.length - const isInstalling = runningPluginsLength > 0 && errorPluginsLength === 0 && successPluginsLength === 0 - const isInstallingWithSuccess = runningPluginsLength > 0 && successPluginsLength > 0 && errorPluginsLength === 0 + const isInstalling = + runningPluginsLength > 0 && errorPluginsLength === 0 && successPluginsLength === 0 + const isInstallingWithSuccess = + runningPluginsLength > 0 && successPluginsLength > 0 && errorPluginsLength === 0 const isInstallingWithError = runningPluginsLength > 0 && errorPluginsLength > 0 const isSuccess = successPluginsLength === totalPluginsLength && totalPluginsLength > 0 - const isFailed = runningPluginsLength === 0 && (errorPluginsLength + successPluginsLength) === totalPluginsLength && totalPluginsLength > 0 && errorPluginsLength > 0 + const isFailed = + runningPluginsLength === 0 && + errorPluginsLength + successPluginsLength === totalPluginsLength && + totalPluginsLength > 0 && + errorPluginsLength > 0 return { errorPlugins, diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx index 3a3f15687addfe..58847c634d27c1 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx @@ -5,11 +5,7 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { - useCallback, - useMemo, - useState, -} from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' import PluginTaskList from './components/plugin-task-list' @@ -46,21 +42,33 @@ const PluginTasks = ({ } = usePluginTaskStatus() const { getIconUrl } = useGetIcon() const hasPluginTasks = totalPluginsLength > 0 - const canOpenMenu = isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess + const canOpenMenu = + isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess // Generate tooltip text based on status const tip = useMemo(() => { if (isInstallingWithError) - return t($ => $['task.installingWithError'], { ns: 'plugin', installingLength: runningPluginsLength, successLength: successPluginsLength, errorLength: errorPluginsLength }) + return t(($) => $['task.installingWithError'], { + ns: 'plugin', + installingLength: runningPluginsLength, + successLength: successPluginsLength, + errorLength: errorPluginsLength, + }) if (isInstallingWithSuccess) - return t($ => $['task.installingWithSuccess'], { ns: 'plugin', installingLength: runningPluginsLength, successLength: successPluginsLength }) - if (isInstalling) - return t($ => $['task.installing'], { ns: 'plugin' }) + return t(($) => $['task.installingWithSuccess'], { + ns: 'plugin', + installingLength: runningPluginsLength, + successLength: successPluginsLength, + }) + if (isInstalling) return t(($) => $['task.installing'], { ns: 'plugin' }) if (isFailed) - return t($ => $['task.installedError'], { ns: 'plugin', errorLength: errorPluginsLength }) + return t(($) => $['task.installedError'], { ns: 'plugin', errorLength: errorPluginsLength }) if (isSuccess) - return t($ => $['task.installSuccess'], { ns: 'plugin', successLength: successPluginsLength }) - return t($ => $['task.installed'], { ns: 'plugin' }) + return t(($) => $['task.installSuccess'], { + ns: 'plugin', + successLength: successPluginsLength, + }) + return t(($) => $['task.installed'], { ns: 'plugin' }) }, [ errorPluginsLength, isFailed, @@ -74,14 +82,14 @@ const PluginTasks = ({ ]) // Generic clear function that handles clearing and modal closing - const clearPluginsAndClose = useCallback(async ( - plugins: Array<{ taskId: string, plugin_unique_identifier: string }>, - ) => { - for (const plugin of plugins) - await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier) - if (runningPluginsLength === 0) - setOpen(false) - }, [handleClearErrorPlugin, runningPluginsLength]) + const clearPluginsAndClose = useCallback( + async (plugins: Array<{ taskId: string; plugin_unique_identifier: string }>) => { + for (const plugin of plugins) + await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier) + if (runningPluginsLength === 0) setOpen(false) + }, + [handleClearErrorPlugin, runningPluginsLength], + ) // Clear handlers using the generic function const handleClearAll = useCallback( @@ -95,7 +103,8 @@ const PluginTasks = ({ ) const handleClearSingle = useCallback( - (taskId: string, pluginId: string) => clearPluginsAndClose([{ taskId, plugin_unique_identifier: pluginId }]), + (taskId: string, pluginId: string) => + clearPluginsAndClose([{ taskId, plugin_unique_identifier: pluginId }]), [clearPluginsAndClose], ) @@ -107,17 +116,13 @@ const PluginTasks = ({ : 'flex items-center' if (!hasPluginTasks) { - if (animatedSlot) - return <div aria-hidden className={rootClassName} /> + if (animatedSlot) return <div aria-hidden className={rootClassName} /> return null } return ( <div className={rootClassName}> - <DropdownMenu - open={open} - onOpenChange={setOpen} - > + <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger nativeButton={false} render={<div className={canOpenMenu ? 'cursor-pointer' : 'cursor-default'} />} diff --git a/web/app/components/plugins/plugin-page/plugins-panel-results.tsx b/web/app/components/plugins/plugin-page/plugins-panel-results.tsx index 6508ff01b7f6b3..bd50b2a7224d62 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel-results.tsx +++ b/web/app/components/plugins/plugin-page/plugins-panel-results.tsx @@ -31,16 +31,12 @@ const BuiltinMarketplacePanel = ({ keywords, tagFilterValue, }: BuiltinMarketplacePanelProps) => { - const { - isMarketplaceArrowVisible, - marketplaceContext, - showMarketplacePanel, - toolListTailRef, - } = useToolMarketplacePanel({ - containerRef, - keywords, - tagFilterValue, - }) + const { isMarketplaceArrowVisible, marketplaceContext, showMarketplacePanel, toolListTailRef } = + useToolMarketplacePanel({ + containerRef, + keywords, + tagFilterValue, + }) return ( <> @@ -115,10 +111,8 @@ const PluginsPanelResults = ({ className="overscroll-contain" role={scrollAreaLabel ? 'region' : undefined} > - <ScrollAreaContent className={cn( - 'flex min-h-full flex-col', - isAgentStrategyIntegrationPage && 'pt-2', - )} + <ScrollAreaContent + className={cn('flex min-h-full flex-col', isAgentStrategyIntegrationPage && 'pt-2')} > {(hasVisiblePlugins || hasVisibleBuiltinTools) && ( <List @@ -126,7 +120,7 @@ const PluginsPanelResults = ({ canDeletePlugin={canDeletePlugin} canUpdatePlugin={canUpdatePlugin} > - {filteredBuiltinTools.map(collection => ( + {filteredBuiltinTools.map((collection) => ( <button key={collection.id} type="button" @@ -145,13 +139,13 @@ const PluginsPanelResults = ({ )} {!isLastPage && ( <div className="flex w-full justify-center py-4"> - {isFetching - ? <Loading className="size-8" /> - : ( - <Button onClick={loadNextPage}> - {t($ => $['common.loadMore'], { ns: 'workflow' })} - </Button> - )} + {isFetching ? ( + <Loading className="size-8" /> + ) : ( + <Button onClick={loadNextPage}> + {t(($) => $['common.loadMore'], { ns: 'workflow' })} + </Button> + )} </div> )} {hasToolMarketplacePanel && ( diff --git a/web/app/components/plugins/plugin-page/plugins-panel-utils.ts b/web/app/components/plugins/plugin-page/plugins-panel-utils.ts index 38b3fe6d3f6492..047114cd79365f 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel-utils.ts +++ b/web/app/components/plugins/plugin-page/plugins-panel-utils.ts @@ -3,24 +3,24 @@ import { renderI18nObject } from '@/i18n-config' export const EMPTY_BUILTIN_TOOLS: Collection[] = [] -export const filterBuiltinTools = (collections: Collection[], query: string, locale: string, tags: string[] = []) => { - if (!query && !tags.length) - return collections +export const filterBuiltinTools = ( + collections: Collection[], + query: string, + locale: string, + tags: string[] = [], +) => { + if (!query && !tags.length) return collections const lowerQuery = query.toLowerCase() return collections.filter((collection) => { - if (tags.length && collection.labels.every(label => !tags.includes(label))) - return false + if (tags.length && collection.labels.every((label) => !tags.includes(label))) return false - if (!query) - return true + if (!query) return true - if (collection.name.toLowerCase().includes(lowerQuery)) - return true + if (collection.name.toLowerCase().includes(lowerQuery)) return true const label = renderI18nObject(collection.label, locale) - if (label?.toLowerCase().includes(lowerQuery)) - return true + if (label?.toLowerCase().includes(lowerQuery)) return true const description = renderI18nObject(collection.description, locale) return !!description?.toLowerCase().includes(lowerQuery) diff --git a/web/app/components/plugins/plugin-page/plugins-panel.tsx b/web/app/components/plugins/plugin-page/plugins-panel.tsx index f268107075612d..7b70dece3380af 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel.tsx +++ b/web/app/components/plugins/plugin-page/plugins-panel.tsx @@ -25,28 +25,26 @@ import PluginListSkeleton from './plugin-list-skeleton' import PluginsPanelResults from './plugins-panel-results' import { EMPTY_BUILTIN_TOOLS, filterBuiltinTools } from './plugins-panel-utils' -const matchesSearchQuery = (plugin: PluginDetail & { latest_version: string }, query: string, locale: string): boolean => { - if (!query) - return true +const matchesSearchQuery = ( + plugin: PluginDetail & { latest_version: string }, + query: string, + locale: string, +): boolean => { + if (!query) return true const lowerQuery = query.toLowerCase() const { declaration } = plugin // Match plugin_id - if (plugin.plugin_id.toLowerCase().includes(lowerQuery)) - return true + if (plugin.plugin_id.toLowerCase().includes(lowerQuery)) return true // Match plugin name - if (plugin.name?.toLowerCase().includes(lowerQuery)) - return true + if (plugin.name?.toLowerCase().includes(lowerQuery)) return true // Match declaration name - if (declaration.name?.toLowerCase().includes(lowerQuery)) - return true + if (declaration.name?.toLowerCase().includes(lowerQuery)) return true // Match localized label const label = renderI18nObject(declaration.label, locale) - if (label?.toLowerCase().includes(lowerQuery)) - return true + if (label?.toLowerCase().includes(lowerQuery)) return true // Match localized description const description = renderI18nObject(declaration.description, locale) - if (description?.toLowerCase().includes(lowerQuery)) - return true + if (description?.toLowerCase().includes(lowerQuery)) return true return false } @@ -56,7 +54,7 @@ type PluginsPanelProps = { canUpdatePlugin?: boolean contentInset?: PluginPageContentInset fixedCategory?: PluginCategoryEnum - layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode + layout?: (parts: { body: ReactNode; toolbar: ReactNode }) => ReactNode onSwitchToMarketplace?: () => void toolbarAction?: ReactNode } @@ -73,19 +71,29 @@ const PluginsPanel = ({ }: PluginsPanelProps) => { const { t } = useTranslation() const locale = useGetLanguage() - const filters = usePluginPageContext(v => v.filters) as FilterState - const setFilters = usePluginPageContext(v => v.setFilters) + const filters = usePluginPageContext((v) => v.filters) as FilterState + const setFilters = usePluginPageContext((v) => v.setFilters) const isToolIntegrationPage = fixedCategory === PluginCategoryEnum.tool const isTriggerIntegrationPage = fixedCategory === PluginCategoryEnum.trigger const isAgentStrategyIntegrationPage = fixedCategory === PluginCategoryEnum.agent const isExtensionIntegrationPage = fixedCategory === PluginCategoryEnum.extension - const isIntegrationCategoryPage = isToolIntegrationPage || isTriggerIntegrationPage || isAgentStrategyIntegrationPage || isExtensionIntegrationPage + const isIntegrationCategoryPage = + isToolIntegrationPage || + isTriggerIntegrationPage || + isAgentStrategyIntegrationPage || + isExtensionIntegrationPage const supportsTagFilter = !fixedCategory || isToolIntegrationPage || isTriggerIntegrationPage const { data: enableMarketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) - const { data: pluginList, isLoading: isPluginListLoading, isFetching, isLastPage, loadNextPage } = useInstalledPluginList( + const { + data: pluginList, + isLoading: isPluginListLoading, + isFetching, + isLastPage, + loadNextPage, + } = useInstalledPluginList( false, 100, fixedCategory @@ -97,56 +105,70 @@ const PluginsPanel = ({ ) const pluginListWithLatestVersion = usePluginsWithLatestVersion(pluginList?.plugins) const invalidateInstalledPluginList = useInvalidateInstalledPluginList() - const currentPluginID = usePluginPageContext(v => v.currentPluginID) - const setCurrentPluginID = usePluginPageContext(v => v.setCurrentPluginID) + const currentPluginID = usePluginPageContext((v) => v.currentPluginID) + const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID) const [currentBuiltinToolID, setCurrentBuiltinToolID] = useState<string | undefined>() const containerRef = useRef<HTMLDivElement>(null) - const { run: handleFilterChange } = useDebounceFn((filters: FilterState) => { - setFilters(filters) - }, { wait: 500 }) + const { run: handleFilterChange } = useDebounceFn( + (filters: FilterState) => { + setFilters(filters) + }, + { wait: 500 }, + ) const categoryList = useMemo(() => { - if (!fixedCategory) - return pluginListWithLatestVersion + if (!fixedCategory) return pluginListWithLatestVersion - return pluginListWithLatestVersion.filter(plugin => plugin.declaration.category === fixedCategory) + return pluginListWithLatestVersion.filter( + (plugin) => plugin.declaration.category === fixedCategory, + ) }, [fixedCategory, pluginListWithLatestVersion]) const filteredList = useMemo(() => { const { categories, searchQuery, tags } = filters const filteredList = categoryList.filter((plugin) => { return ( - (fixedCategory || categories.length === 0 || categories.includes(plugin.declaration.category)) - && (!supportsTagFilter || tags.length === 0 || tags.some(tag => plugin.declaration.tags.includes(tag))) - && matchesSearchQuery(plugin, searchQuery, locale) + (fixedCategory || + categories.length === 0 || + categories.includes(plugin.declaration.category)) && + (!supportsTagFilter || + tags.length === 0 || + tags.some((tag) => plugin.declaration.tags.includes(tag))) && + matchesSearchQuery(plugin, searchQuery, locale) ) }) return filteredList }, [categoryList, fixedCategory, supportsTagFilter, filters, locale]) - const builtinTools = isToolIntegrationPage ? pluginList?.builtin_tools ?? EMPTY_BUILTIN_TOOLS : EMPTY_BUILTIN_TOOLS + const builtinTools = isToolIntegrationPage + ? (pluginList?.builtin_tools ?? EMPTY_BUILTIN_TOOLS) + : EMPTY_BUILTIN_TOOLS const filteredBuiltinTools = useMemo(() => { - if (!isToolIntegrationPage) - return [] + if (!isToolIntegrationPage) return [] return filterBuiltinTools(builtinTools, filters.searchQuery, locale, filters.tags) }, [builtinTools, filters.searchQuery, filters.tags, isToolIntegrationPage, locale]) const hasVisiblePlugins = (filteredList?.length ?? 0) > 0 const hasVisibleBuiltinTools = filteredBuiltinTools.length > 0 - const isFilteringCategory = !!filters.searchQuery.trim() || (supportsTagFilter && filters.tags.length > 0) - const isIntegrationCategorySearchEmpty = isIntegrationCategoryPage && isSearchResultEmpty({ - hasActiveFilter: isFilteringCategory, - isLoading: isPluginListLoading, - resultCount: filteredList.length + filteredBuiltinTools.length, - sourceCount: categoryList.length + builtinTools.length, - }) + const isFilteringCategory = + !!filters.searchQuery.trim() || (supportsTagFilter && filters.tags.length > 0) + const isIntegrationCategorySearchEmpty = + isIntegrationCategoryPage && + isSearchResultEmpty({ + hasActiveFilter: isFilteringCategory, + isLoading: isPluginListLoading, + resultCount: filteredList.length + filteredBuiltinTools.length, + sourceCount: categoryList.length + builtinTools.length, + }) const currentPluginDetail = useMemo(() => { - const detail = pluginListWithLatestVersion.find(plugin => plugin.plugin_id === currentPluginID) + const detail = pluginListWithLatestVersion.find( + (plugin) => plugin.plugin_id === currentPluginID, + ) return detail }, [currentPluginID, pluginListWithLatestVersion]) const currentBuiltinTool = useMemo(() => { - return filteredBuiltinTools.find(collection => collection.id === currentBuiltinToolID) + return filteredBuiltinTools.find((collection) => collection.id === currentBuiltinToolID) }, [currentBuiltinToolID, filteredBuiltinTools]) const handleHide = () => setCurrentPluginID(undefined) @@ -165,28 +187,31 @@ const PluginsPanel = ({ ? 'integrationsExtension' : 'default' const scrollAreaLabel = isTriggerIntegrationPage - ? t($ => $['categorySingle.trigger'], { ns: 'plugin' }) + ? t(($) => $['categorySingle.trigger'], { ns: 'plugin' }) : isAgentStrategyIntegrationPage - ? t($ => $['categorySingle.agent'], { ns: 'plugin' }) + ? t(($) => $['categorySingle.agent'], { ns: 'plugin' }) : isExtensionIntegrationPage - ? t($ => $['categorySingle.extension'], { ns: 'plugin' }) + ? t(($) => $['categorySingle.extension'], { ns: 'plugin' }) : isToolIntegrationPage - ? t($ => $['categorySingle.tool'], { ns: 'plugin' }) + ? t(($) => $['categorySingle.tool'], { ns: 'plugin' }) : undefined const toolbar = ( - <div className={cn( - layout - ? 'flex w-full items-center bg-components-panel-bg' - : [ - isIntegrationCategoryPage - ? 'flex h-12 shrink-0 items-center bg-components-panel-bg py-2' - : 'sticky top-0 z-10 flex flex-col items-start justify-center gap-3 self-stretch bg-components-panel-bg pt-1 pb-3', - contentFrameClassName, - ], - )} + <div + className={cn( + layout + ? 'flex w-full items-center bg-components-panel-bg' + : [ + isIntegrationCategoryPage + ? 'flex h-12 shrink-0 items-center bg-components-panel-bg py-2' + : 'sticky top-0 z-10 flex flex-col items-start justify-center gap-3 self-stretch bg-components-panel-bg pt-1 pb-3', + contentFrameClassName, + ], + )} > - {!layout && !isIntegrationCategoryPage && <div className="h-px self-stretch bg-divider-subtle"></div>} + {!layout && !isIntegrationCategoryPage && ( + <div className="h-px self-stretch bg-divider-subtle"></div> + )} <FilterManagement hideCategoryFilter={!!fixedCategory} hideTagFilter={!supportsTagFilter} @@ -201,43 +226,39 @@ const PluginsPanel = ({ {isPluginListLoading && <PluginListSkeleton contentFrameClassName={contentFrameClassName} />} {!isPluginListLoading && ( <> - {hasVisiblePlugins || hasVisibleBuiltinTools || hasToolMarketplacePanel - ? ( - <PluginsPanelResults - containerRef={containerRef} - contentFrameClassName={contentFrameClassName} - contentInset={contentInset} - currentBuiltinToolID={currentBuiltinToolID} - filteredBuiltinTools={filteredBuiltinTools} - filteredList={filteredList} - hasToolMarketplacePanel={hasToolMarketplacePanel} - hasVisibleBuiltinTools={hasVisibleBuiltinTools} - hasVisiblePlugins={hasVisiblePlugins} - isAgentStrategyIntegrationPage={isAgentStrategyIntegrationPage} - isFetching={isFetching} - isLastPage={isLastPage} - keywords={filters.searchQuery} - loadNextPage={loadNextPage} - scrollAreaLabel={scrollAreaLabel} - setCurrentBuiltinToolID={setCurrentBuiltinToolID} - tagFilterValue={filters.tags} - canDeletePlugin={canDeletePlugin} - canUpdatePlugin={canUpdatePlugin} - /> - ) - : isIntegrationCategorySearchEmpty - ? ( - <div className={cn('min-h-0 grow bg-components-panel-bg', contentFrameClassName)} /> - ) - : ( - <Empty - canInstall={canInstall} - contentInset={contentInset} - onSwitchToMarketplace={onSwitchToMarketplace} - installContextCategory={fixedCategory} - variant={emptyVariant} - /> - )} + {hasVisiblePlugins || hasVisibleBuiltinTools || hasToolMarketplacePanel ? ( + <PluginsPanelResults + containerRef={containerRef} + contentFrameClassName={contentFrameClassName} + contentInset={contentInset} + currentBuiltinToolID={currentBuiltinToolID} + filteredBuiltinTools={filteredBuiltinTools} + filteredList={filteredList} + hasToolMarketplacePanel={hasToolMarketplacePanel} + hasVisibleBuiltinTools={hasVisibleBuiltinTools} + hasVisiblePlugins={hasVisiblePlugins} + isAgentStrategyIntegrationPage={isAgentStrategyIntegrationPage} + isFetching={isFetching} + isLastPage={isLastPage} + keywords={filters.searchQuery} + loadNextPage={loadNextPage} + scrollAreaLabel={scrollAreaLabel} + setCurrentBuiltinToolID={setCurrentBuiltinToolID} + tagFilterValue={filters.tags} + canDeletePlugin={canDeletePlugin} + canUpdatePlugin={canUpdatePlugin} + /> + ) : isIntegrationCategorySearchEmpty ? ( + <div className={cn('min-h-0 grow bg-components-panel-bg', contentFrameClassName)} /> + ) : ( + <Empty + canInstall={canInstall} + contentInset={contentInset} + onSwitchToMarketplace={onSwitchToMarketplace} + installContextCategory={fixedCategory} + variant={emptyVariant} + /> + )} </> )} </> @@ -245,14 +266,14 @@ const PluginsPanel = ({ return ( <> - {layout - ? layout({ body, toolbar }) - : ( - <> - {toolbar} - {body} - </> - )} + {layout ? ( + layout({ body, toolbar }) + ) : ( + <> + {toolbar} + {body} + </> + )} <PluginDetailPanel detail={currentPluginDetail} onUpdate={() => invalidateInstalledPluginList()} diff --git a/web/app/components/plugins/plugin-page/use-reference-setting.ts b/web/app/components/plugins/plugin-page/use-reference-setting.ts index ef4ebc6a3c18fe..e02bc19897827e 100644 --- a/web/app/components/plugins/plugin-page/use-reference-setting.ts +++ b/web/app/components/plugins/plugin-page/use-reference-setting.ts @@ -4,11 +4,24 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { workspacePermissionKeysAtom, workspacePermissionKeysLoadingAtom } from '@/context/permission-state' +import { + workspacePermissionKeysAtom, + workspacePermissionKeysLoadingAtom, +} from '@/context/permission-state' import { langGeniusVersionInfoAtom } from '@/context/version-state' -import { currentWorkspaceLoadingAtom, isCurrentWorkspaceManagerAtom, isCurrentWorkspaceOwnerAtom } from '@/context/workspace-state' +import { + currentWorkspaceLoadingAtom, + isCurrentWorkspaceManagerAtom, + isCurrentWorkspaceOwnerAtom, +} from '@/context/workspace-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { useInvalidateReferenceSettings, useMutationPluginPermissionSettings, useMutationReferenceSettings, usePluginAutoUpgradeSettings, usePluginPermissionSettings } from '@/service/use-plugins' +import { + useInvalidateReferenceSettings, + useMutationPluginPermissionSettings, + useMutationReferenceSettings, + usePluginAutoUpgradeSettings, + usePluginPermissionSettings, +} from '@/service/use-plugins' import { hasPermission } from '@/utils/permission' import { hasLegacyPluginPermissionAccess } from '../plugin-permissions' @@ -16,9 +29,12 @@ const useCanSetPluginSettings = () => { const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { data: rbacEnabled } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.rbac_enabled, + select: (s) => s.rbac_enabled, }) - const canSetPluginPreferences = hasPermission(workspacePermissionKeys, 'plugin.plugin_preferences') + const canSetPluginPreferences = hasPermission( + workspacePermissionKeys, + 'plugin.plugin_preferences', + ) return { canSetPermissions: !rbacEnabled && canSetPluginPreferences, @@ -36,16 +52,17 @@ export const usePluginSettingsAccess = () => { const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) const { data: rbacEnabled } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.rbac_enabled, + select: (s) => s.rbac_enabled, }) const { canSetPermissions, canSetPluginPreferences } = useCanSetPluginSettings() const permissionQuery = usePluginPermissionSettings() const { data: permissions } = permissionQuery - const { mutate: setPluginPermissionSettings, isPending: isPermissionUpdatePending } = useMutationPluginPermissionSettings({ - onSuccess: () => { - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - }, - }) + const { mutate: setPluginPermissionSettings, isPending: isPermissionUpdatePending } = + useMutationPluginPermissionSettings({ + onSuccess: () => { + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + }, + }) const isAdminOrOwner = isCurrentWorkspaceManager || isCurrentWorkspaceOwner const legacyCanInstallPlugin = hasLegacyPluginPermissionAccess({ isAdminOrOwner, @@ -57,9 +74,12 @@ export const usePluginSettingsAccess = () => { permission: permissions?.debug_permission, rbacEnabled, }) - const canInstallPlugin = hasPermission(workspacePermissionKeys, 'plugin.install') && legacyCanInstallPlugin - const canUpdatePlugin = hasPermission(workspacePermissionKeys, 'plugin.install') && legacyCanInstallPlugin - const canDeletePlugin = hasPermission(workspacePermissionKeys, 'plugin.delete') && legacyCanInstallPlugin + const canInstallPlugin = + hasPermission(workspacePermissionKeys, 'plugin.install') && legacyCanInstallPlugin + const canUpdatePlugin = + hasPermission(workspacePermissionKeys, 'plugin.install') && legacyCanInstallPlugin + const canDeletePlugin = + hasPermission(workspacePermissionKeys, 'plugin.delete') && legacyCanInstallPlugin const canDebugPlugin = rbacEnabled ? hasPermission(workspacePermissionKeys, 'plugin.debug') : legacyCanDebugPlugin @@ -76,7 +96,11 @@ export const usePluginSettingsAccess = () => { canDebugger: canDebugPlugin, canSetPermissions, currentDifyVersion: langGeniusVersionInfo?.current_version, - isPermissionLoading: permissionQuery.isLoading || permissionQuery.isFetching || !!isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys, + isPermissionLoading: + permissionQuery.isLoading || + permissionQuery.isFetching || + !!isLoadingCurrentWorkspace || + !!isLoadingWorkspacePermissionKeys, permissionError: permissionQuery.error, isPermissionUpdatePending, } @@ -86,21 +110,23 @@ const useReferenceSetting = (category: PluginCategoryEnum) => { const { t } = useTranslation() const permissionAccess = usePluginSettingsAccess() const autoUpgradeQuery = usePluginAutoUpgradeSettings(category) - const data = permissionAccess.permission && autoUpgradeQuery.data?.auto_upgrade - ? { - permission: permissionAccess.permission, - auto_upgrade: autoUpgradeQuery.data.auto_upgrade, - } - : undefined + const data = + permissionAccess.permission && autoUpgradeQuery.data?.auto_upgrade + ? { + permission: permissionAccess.permission, + auto_upgrade: autoUpgradeQuery.data.auto_upgrade, + } + : undefined const invalidateReferenceSettings = useInvalidateReferenceSettings() - const { mutate: updateReferenceSetting, isPending: isUpdatePending } = useMutationReferenceSettings({ - category, - currentReferenceSetting: data, - onSuccess: () => { - invalidateReferenceSettings() - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - }, - }) + const { mutate: updateReferenceSetting, isPending: isUpdatePending } = + useMutationReferenceSettings({ + category, + currentReferenceSetting: data, + onSuccess: () => { + invalidateReferenceSettings() + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + }, + }) return { referenceSetting: data, @@ -136,7 +162,8 @@ export const useCanInstallPluginFromMarketplace = () => { permission: permissions?.install_permission, rbacEnabled, }) - const canInstallPlugin = hasPermission(workspacePermissionKeys, 'plugin.install') && legacyCanInstallPlugin + const canInstallPlugin = + hasPermission(workspacePermissionKeys, 'plugin.install') && legacyCanInstallPlugin const canInstallPluginFromMarketplace = useMemo(() => { return Boolean(marketplaceAccess && canInstallPlugin) diff --git a/web/app/components/plugins/plugin-page/use-uploader.ts b/web/app/components/plugins/plugin-page/use-uploader.ts index 17699e7b9d34d0..e2188304b80695 100644 --- a/web/app/components/plugins/plugin-page/use-uploader.ts +++ b/web/app/components/plugins/plugin-page/use-uploader.ts @@ -14,8 +14,7 @@ export const useUploader = ({ onFileChange, containerRef, enabled = true }: Uplo const handleDragEnter = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.dataTransfer?.types.includes('Files')) - setDragging(true) + if (e.dataTransfer?.types.includes('Files')) setDragging(true) } const handleDragOver = (e: DragEvent) => { @@ -34,11 +33,9 @@ export const useUploader = ({ onFileChange, containerRef, enabled = true }: Uplo e.preventDefault() e.stopPropagation() setDragging(false) - if (!e.dataTransfer) - return + if (!e.dataTransfer) return const files = Array.from(e.dataTransfer.files) - if (files.length > 0) - onFileChange(files[0]!) + if (files.length > 0) onFileChange(files[0]!) } const fileChangeHandle = enabled @@ -50,16 +47,14 @@ export const useUploader = ({ onFileChange, containerRef, enabled = true }: Uplo const removeFile = enabled ? () => { - if (fileUploader.current) - fileUploader.current.value = '' + if (fileUploader.current) fileUploader.current.value = '' onFileChange(null) } : null useEffect(() => { - if (!enabled) - return + if (!enabled) return const current = containerRef.current if (current) { diff --git a/web/app/components/plugins/plugin-permissions.ts b/web/app/components/plugins/plugin-permissions.ts index 4548e73df265b1..ca6077c794800d 100644 --- a/web/app/components/plugins/plugin-permissions.ts +++ b/web/app/components/plugins/plugin-permissions.ts @@ -11,17 +11,13 @@ export const hasLegacyPluginPermissionAccess = ({ permission, rbacEnabled, }: LegacyPluginPermissionAccessOptions) => { - if (rbacEnabled !== false) - return true + if (rbacEnabled !== false) return true - if (!permission) - return false + if (!permission) return false - if (permission === PermissionType.everyone) - return true + if (permission === PermissionType.everyone) return true - if (permission === PermissionType.admin) - return isAdminOrOwner + if (permission === PermissionType.admin) return isAdminOrOwner return false } diff --git a/web/app/components/plugins/plugin-routes.ts b/web/app/components/plugins/plugin-routes.ts index 46b81de6d901b6..bf8d7fbf52e566 100644 --- a/web/app/components/plugins/plugin-routes.ts +++ b/web/app/components/plugins/plugin-routes.ts @@ -32,20 +32,18 @@ const marketplacePluginTabs = new Set<string>([ ]) const getFirstSearchParamValue = (value: string | string[] | undefined) => { - if (Array.isArray(value)) - return value[0] + if (Array.isArray(value)) return value[0] return value } const hasInstallSearchParams = (searchParams: LegacyPluginsSearchParams) => { - return Object.keys(searchParams).some(key => installSearchParamKeys.has(key)) + return Object.keys(searchParams).some((key) => installSearchParamKeys.has(key)) } export const getFirstPackageIdFromSearchParams = (searchParams: LegacyPluginsSearchParams) => { const value = getFirstSearchParamValue(searchParams[PACKAGE_IDS_SEARCH_PARAM]) - if (!value) - return null + if (!value) return null try { const parsed: unknown = JSON.parse(value) @@ -53,8 +51,7 @@ export const getFirstPackageIdFromSearchParams = (searchParams: LegacyPluginsSea const first = parsed[0] return typeof first === 'string' ? first : null } - } - catch { + } catch { return value } @@ -63,7 +60,9 @@ export const getFirstPackageIdFromSearchParams = (searchParams: LegacyPluginsSea export const shouldResolveInstallCategoryRedirect = (searchParams: LegacyPluginsSearchParams) => { const tab = getFirstSearchParamValue(searchParams.tab) - return (!tab || tab === INSTALLED_PLUGINS_TAB) && !!getFirstPackageIdFromSearchParams(searchParams) + return ( + (!tab || tab === INSTALLED_PLUGINS_TAB) && !!getFirstPackageIdFromSearchParams(searchParams) + ) } const buildPreservedSearchParams = ( @@ -73,11 +72,10 @@ const buildPreservedSearchParams = ( const preservedSearchParams = new URLSearchParams() Object.entries(searchParams).forEach(([key, value]) => { - if (ignoredKeys.has(key) || value === undefined) - return + if (ignoredKeys.has(key) || value === undefined) return if (Array.isArray(value)) { - value.forEach(item => preservedSearchParams.append(key, item)) + value.forEach((item) => preservedSearchParams.append(key, item)) return } @@ -96,15 +94,11 @@ const buildRedirectPath = ( return query ? `${path}?${query}` : path } -const buildInstallRedirectPath = ( - path: string, - searchParams: LegacyPluginsSearchParams, -) => { +const buildInstallRedirectPath = (path: string, searchParams: LegacyPluginsSearchParams) => { const installSearchParams: LegacyPluginsSearchParams = {} Object.entries(searchParams).forEach(([key, value]) => { - if (installSearchParamKeys.has(key)) - installSearchParams[key] = value + if (installSearchParamKeys.has(key)) installSearchParams[key] = value }) return buildRedirectPath(path, installSearchParams, new Set()) @@ -114,35 +108,26 @@ export const getInstallRedirectPathByPluginCategory = ( category: string | undefined, searchParams: LegacyPluginsSearchParams, ) => { - if (!category) - return undefined + if (!category) return undefined const path = integrationPluginPathByInstallCategory.get(category) - if (!path) - return undefined + if (!path) return undefined return buildInstallRedirectPath(path, searchParams) } -export const getInstallRedirectPathFromSearchParams = ( - searchParams: LegacyPluginsSearchParams, -) => { - if (!hasInstallSearchParams(searchParams)) - return undefined +export const getInstallRedirectPathFromSearchParams = (searchParams: LegacyPluginsSearchParams) => { + if (!hasInstallSearchParams(searchParams)) return undefined const category = getFirstSearchParamValue(searchParams.category) const pathByCategory = getInstallRedirectPathByPluginCategory(category, searchParams) - if (pathByCategory) - return pathByCategory + if (pathByCategory) return pathByCategory const tab = getFirstSearchParamValue(searchParams.tab) return getInstallRedirectPathByPluginCategory(tab, searchParams) } -const buildMarketplaceRedirectPath = ( - searchParams: LegacyPluginsSearchParams, - tab: string, -) => { +const buildMarketplaceRedirectPath = (searchParams: LegacyPluginsSearchParams, tab: string) => { const path = '/marketplace' const ignoredKeys = new Set(['tab']) const preservedSearchParams = buildPreservedSearchParams(searchParams, ignoredKeys) @@ -154,16 +139,13 @@ const buildMarketplaceRedirectPath = ( return query ? `${path}?${query}` : path } -export const getLegacyPluginRedirectPath = ( - searchParams: LegacyPluginsSearchParams = {}, -) => { +export const getLegacyPluginRedirectPath = (searchParams: LegacyPluginsSearchParams = {}) => { const tab = getFirstSearchParamValue(searchParams.tab) if ((!tab || tab === INSTALLED_PLUGINS_TAB) && hasInstallSearchParams(searchParams)) return undefined - if (!tab || tab === INSTALLED_PLUGINS_TAB) - return '/integrations' + if (!tab || tab === INSTALLED_PLUGINS_TAB) return '/integrations' const integrationPluginPath = getIntegrationPluginPathByTab(tab) if (integrationPluginPath) { @@ -172,8 +154,7 @@ export const getLegacyPluginRedirectPath = ( : buildRedirectPath(integrationPluginPath, searchParams, new Set(['tab'])) } - if (marketplacePluginTabs.has(tab)) - return buildMarketplaceRedirectPath(searchParams, tab) + if (marketplacePluginTabs.has(tab)) return buildMarketplaceRedirectPath(searchParams, tab) return undefined } diff --git a/web/app/components/plugins/provider-card.tsx b/web/app/components/plugins/provider-card.tsx index bab5faa5c63db9..08f7691af738b7 100644 --- a/web/app/components/plugins/provider-card.tsx +++ b/web/app/components/plugins/provider-card.tsx @@ -24,17 +24,14 @@ type Props = Readonly<{ payload: Plugin }> -const ProviderCardComponent: FC<Props> = ({ - className, - payload, -}) => { +const ProviderCardComponent: FC<Props> = ({ className, payload }) => { const getValueFromI18nObject = useRenderI18nObject() const { t } = useTranslation() const { theme } = useTheme() - const [isShowInstallFromMarketplace, { - setTrue: showInstallFromMarketplace, - setFalse: hideInstallFromMarketplace, - }] = useBoolean(false) + const [ + isShowInstallFromMarketplace, + { setTrue: showInstallFromMarketplace, setFalse: hideInstallFromMarketplace }, + ] = useBoolean(false) const { canInstallPlugin } = usePluginInstallPermission() const { org, label } = payload const locale = useLocale() @@ -43,7 +40,12 @@ const ProviderCardComponent: FC<Props> = ({ const marketplaceLinkParams = useMemo(() => ({ language: locale, theme }), [locale, theme]) return ( - <div className={cn('group relative rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs hover:bg-components-panel-on-panel-item-bg', className)}> + <div + className={cn( + 'group relative rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs hover:bg-components-panel-on-panel-item-bg', + className, + )} + > {/* Header */} <div className="flex"> <Icon src={payload.icon} /> @@ -61,44 +63,41 @@ const ProviderCardComponent: FC<Props> = ({ </div> </div> </div> - <Description className="mt-3" text={getValueFromI18nObject(payload.brief)} descriptionLineRows={2}></Description> + <Description + className="mt-3" + text={getValueFromI18nObject(payload.brief)} + descriptionLineRows={2} + ></Description> <div className="mt-3 flex space-x-0.5"> - {payload.tags.map(tag => ( + {payload.tags.map((tag) => ( <Badge key={tag.name} text={tag.name} /> ))} </div> - <div - className="absolute inset-x-0 bottom-0 hidden items-center gap-2 rounded-xl bg-linear-to-tr from-components-panel-on-panel-item-bg to-background-gradient-mask-transparent p-4 pt-4 group-hover:flex" - > + <div className="absolute inset-x-0 bottom-0 hidden items-center gap-2 rounded-xl bg-linear-to-tr from-components-panel-on-panel-item-bg to-background-gradient-mask-transparent p-4 pt-4 group-hover:flex"> {canInstallPlugin && ( - <Button - className="grow" - variant="primary" - onClick={showInstallFromMarketplace} - > - {t($ => $['detailPanel.operation.install'], { ns: 'plugin' })} + <Button className="grow" variant="primary" onClick={showInstallFromMarketplace}> + {t(($) => $['detailPanel.operation.install'], { ns: 'plugin' })} </Button> )} - <Button - className="grow" - variant="secondary" - > - <a href={getPluginLinkInMarketplace(payload, marketplaceLinkParams)} target="_blank" className="flex items-center gap-0.5"> - {t($ => $['detailPanel.operation.detail'], { ns: 'plugin' })} + <Button className="grow" variant="secondary"> + <a + href={getPluginLinkInMarketplace(payload, marketplaceLinkParams)} + target="_blank" + className="flex items-center gap-0.5" + > + {t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })} <span className="i-ri-arrow-right-up-line size-4" /> </a> </Button> </div> - { - isShowInstallFromMarketplace && ( - <InstallFromMarketplace - manifest={payload} - uniqueIdentifier={payload.latest_package_identifier} - onClose={hideInstallFromMarketplace} - onSuccess={hideInstallFromMarketplace} - /> - ) - } + {isShowInstallFromMarketplace && ( + <InstallFromMarketplace + manifest={payload} + uniqueIdentifier={payload.latest_package_identifier} + onClose={hideInstallFromMarketplace} + onSuccess={hideInstallFromMarketplace} + /> + )} </div> ) } diff --git a/web/app/components/plugins/readme-panel/__tests__/constants.spec.ts b/web/app/components/plugins/readme-panel/__tests__/constants.spec.ts index 372211cc7783e2..6e50582b948b55 100644 --- a/web/app/components/plugins/readme-panel/__tests__/constants.spec.ts +++ b/web/app/components/plugins/readme-panel/__tests__/constants.spec.ts @@ -14,7 +14,6 @@ describe('BUILTIN_TOOLS_ARRAY', () => { }) it('should be an array of strings', () => { - for (const tool of BUILTIN_TOOLS_ARRAY) - expect(typeof tool).toBe('string') + for (const tool of BUILTIN_TOOLS_ARRAY) expect(typeof tool).toBe('string') }) }) diff --git a/web/app/components/plugins/readme-panel/__tests__/entrance.spec.tsx b/web/app/components/plugins/readme-panel/__tests__/entrance.spec.tsx index 35b91853b26d96..245be42051139a 100644 --- a/web/app/components/plugins/readme-panel/__tests__/entrance.spec.tsx +++ b/web/app/components/plugins/readme-panel/__tests__/entrance.spec.tsx @@ -9,14 +9,22 @@ describe('ReadmeEntrance', () => { }) it('should render readme button for non-builtin plugin with unique identifier', () => { - const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never + const pluginDetail = { + id: 'custom-plugin', + name: 'custom-plugin', + plugin_unique_identifier: 'org/custom-plugin', + } as never render(<ReadmeEntrance pluginDetail={pluginDetail} />) expect(screen.getByRole('button')).toBeInTheDocument() }) it('should open drawer presentation by default', () => { - const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never + const pluginDetail = { + id: 'custom-plugin', + name: 'custom-plugin', + plugin_unique_identifier: 'org/custom-plugin', + } as never render(<ReadmeEntrance pluginDetail={pluginDetail} />) const button = screen.getByRole('button') @@ -30,7 +38,11 @@ describe('ReadmeEntrance', () => { }) it('should open dialog presentation when requested', () => { - const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never + const pluginDetail = { + id: 'custom-plugin', + name: 'custom-plugin', + plugin_unique_identifier: 'org/custom-plugin', + } as never render(<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />) fireEvent.click(screen.getByRole('button')) diff --git a/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx b/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx index 433ac011c5b3b5..6744dba3c320d4 100644 --- a/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx +++ b/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx @@ -8,7 +8,7 @@ import { ReadmeEntrance } from '../entrance' import ReadmePanel from '../index' import { useReadmePanelStore } from '../store' -( +;( globalThis as typeof globalThis & { BASE_UI_ANIMATIONS_DISABLED: boolean } @@ -16,7 +16,8 @@ import { useReadmePanelStore } from '../store' const mockUsePluginReadme = vi.fn() vi.mock('@/service/use-plugins', () => ({ - usePluginReadme: (params: { plugin_unique_identifier: string, language?: string }) => mockUsePluginReadme(params), + usePluginReadme: (params: { plugin_unique_identifier: string; language?: string }) => + mockUsePluginReadme(params), })) let mockLanguage = 'en-US' @@ -25,7 +26,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('../../plugin-detail-panel/detail-header', () => ({ - default: ({ detail, isReadmeView }: { detail: PluginDetail, isReadmeView: boolean }) => ( + default: ({ detail, isReadmeView }: { detail: PluginDetail; isReadmeView: boolean }) => ( <div data-testid="detail-header" data-is-readme-view={isReadmeView}> {detail.name} </div> @@ -89,21 +90,18 @@ const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDe ...overrides, }) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, }, - }, -}) + }) const renderWithQueryClient = (ui: ReactElement) => { const queryClient = createQueryClient() - return render( - <QueryClientProvider client={queryClient}> - {ui} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>) } const openReadmePanel = ( @@ -200,9 +198,11 @@ describe('ReadmePanel', () => { }) it('should call usePluginReadme with the plugin identifier and selected language', () => { - openReadmePanel(createMockPluginDetail({ - plugin_unique_identifier: 'custom-plugin@2.0.0', - })) + openReadmePanel( + createMockPluginDetail({ + plugin_unique_identifier: 'custom-plugin@2.0.0', + }), + ) renderWithQueryClient(<ReadmePanel />) @@ -214,9 +214,11 @@ describe('ReadmePanel', () => { it('should pass undefined language for zh-Hans locale', () => { mockLanguage = 'zh-Hans' - openReadmePanel(createMockPluginDetail({ - plugin_unique_identifier: 'zh-plugin@1.0.0', - })) + openReadmePanel( + createMockPluginDetail({ + plugin_unique_identifier: 'zh-plugin@1.0.0', + }), + ) renderWithQueryClient(<ReadmePanel />) diff --git a/web/app/components/plugins/readme-panel/constants.ts b/web/app/components/plugins/readme-panel/constants.ts index 7d6782e66548fd..8e778cc3668cce 100644 --- a/web/app/components/plugins/readme-panel/constants.ts +++ b/web/app/components/plugins/readme-panel/constants.ts @@ -1,6 +1 @@ -export const BUILTIN_TOOLS_ARRAY = [ - 'code', - 'audio', - 'time', - 'webscraper', -] +export const BUILTIN_TOOLS_ARRAY = ['code', 'audio', 'time', 'webscraper'] diff --git a/web/app/components/plugins/readme-panel/content.tsx b/web/app/components/plugins/readme-panel/content.tsx index 4bc0f413f2c2a6..efcc42c8c49134 100644 --- a/web/app/components/plugins/readme-panel/content.tsx +++ b/web/app/components/plugins/readme-panel/content.tsx @@ -15,16 +15,16 @@ type ReadmePanelContentProps = { closeButton: ReactNode } -export function ReadmePanelContent({ - detail, - title, - closeButton, -}: ReadmePanelContentProps) { +export function ReadmePanelContent({ detail, title, closeButton }: ReadmePanelContentProps) { const { t } = useTranslation() const language = useLanguage() const pluginUniqueIdentifier = detail.plugin_unique_identifier || '' - const { data: readmeData, isLoading, error } = usePluginReadme({ + const { + data: readmeData, + isLoading, + error, + } = usePluginReadme({ plugin_unique_identifier: pluginUniqueIdentifier, language: language === 'zh-Hans' ? undefined : language, }) @@ -36,26 +36,23 @@ export function ReadmePanelContent({ <Loading type="area" /> </div> ) - } - else if (error) { + } else if (error) { readmeContent = ( <div className="py-8 text-center text-text-tertiary"> - <p>{t($ => $['readmeInfo.failedToFetch'], { ns: 'plugin' })}</p> + <p>{t(($) => $['readmeInfo.failedToFetch'], { ns: 'plugin' })}</p> </div> ) - } - else if (readmeData?.readme) { + } else if (readmeData?.readme) { readmeContent = ( <Markdown content={readmeData.readme} pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }} /> ) - } - else { + } else { readmeContent = ( <div className="py-8 text-center text-text-tertiary"> - <p>{t($ => $['readmeInfo.noReadmeAvailable'], { ns: 'plugin' })}</p> + <p>{t(($) => $['readmeInfo.noReadmeAvailable'], { ns: 'plugin' })}</p> </div> ) } @@ -65,7 +62,10 @@ export function ReadmePanelContent({ <div className="shrink-0 rounded-t-xl bg-background-body p-4"> <div className="mb-3 flex items-center justify-between gap-3"> <div className="flex min-w-0 items-center gap-1"> - <span aria-hidden="true" className="i-ri-book-read-line size-3 shrink-0 text-text-tertiary" /> + <span + aria-hidden="true" + className="i-ri-book-read-line size-3 shrink-0 text-text-tertiary" + /> {title} </div> {closeButton} diff --git a/web/app/components/plugins/readme-panel/dialog.tsx b/web/app/components/plugins/readme-panel/dialog.tsx index 5f13f2fd09e653..28c87e8db6787c 100644 --- a/web/app/components/plugins/readme-panel/dialog.tsx +++ b/web/app/components/plugins/readme-panel/dialog.tsx @@ -1,12 +1,7 @@ 'use client' import type { PluginDetail } from '../types' -import { - Dialog, - DialogCloseButton, - DialogContent, - DialogTitle, -} from '@langgenius/dify-ui/dialog' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { useTranslation } from 'react-i18next' import { ReadmePanelContent } from './content' @@ -17,34 +12,25 @@ type ReadmeDialogProps = { triggerId?: string } -export function ReadmeDialog({ - detail, - open, - onOpenChange, - triggerId, -}: ReadmeDialogProps) { +export function ReadmeDialog({ detail, open, onOpenChange, triggerId }: ReadmeDialogProps) { const { t } = useTranslation() return ( - <Dialog - open={open} - onOpenChange={onOpenChange} - triggerId={triggerId} - > + <Dialog open={open} onOpenChange={onOpenChange} triggerId={triggerId}> <DialogContent className="h-[calc(100dvh-16px)] w-full max-w-200 overflow-hidden p-0"> <ReadmePanelContent detail={detail} - title={( + title={ <DialogTitle className="truncate text-xs font-medium text-text-tertiary uppercase"> - {t($ => $['readmeInfo.title'], { ns: 'plugin' })} + {t(($) => $['readmeInfo.title'], { ns: 'plugin' })} </DialogTitle> - )} - closeButton={( + } + closeButton={ <DialogCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="static size-8 rounded-lg" /> - )} + } /> </DialogContent> </Dialog> diff --git a/web/app/components/plugins/readme-panel/drawer.tsx b/web/app/components/plugins/readme-panel/drawer.tsx index 80ed735db30036..70e7fa0e2416e7 100644 --- a/web/app/components/plugins/readme-panel/drawer.tsx +++ b/web/app/components/plugins/readme-panel/drawer.tsx @@ -21,12 +21,7 @@ type ReadmeDrawerProps = { triggerId?: string } -export function ReadmeDrawer({ - detail, - open, - onOpenChange, - triggerId, -}: ReadmeDrawerProps) { +export function ReadmeDrawer({ detail, open, onOpenChange, triggerId }: ReadmeDrawerProps) { const { t } = useTranslation() return ( @@ -44,14 +39,16 @@ export function ReadmeDrawer({ <DrawerContent className="flex min-h-0 flex-1 flex-col p-0"> <ReadmePanelContent detail={detail} - title={( + title={ <DrawerTitle className="truncate text-xs font-medium text-text-tertiary uppercase"> - {t($ => $['readmeInfo.title'], { ns: 'plugin' })} + {t(($) => $['readmeInfo.title'], { ns: 'plugin' })} </DrawerTitle> - )} - closeButton={( - <DrawerCloseButton aria-label={t($ => $['operation.close'], { ns: 'common' })} /> - )} + } + closeButton={ + <DrawerCloseButton + aria-label={t(($) => $['operation.close'], { ns: 'common' })} + /> + } /> </DrawerContent> </DrawerPopup> diff --git a/web/app/components/plugins/readme-panel/entrance.tsx b/web/app/components/plugins/readme-panel/entrance.tsx index 38167897ae518e..335f0fbc9eccd5 100644 --- a/web/app/components/plugins/readme-panel/entrance.tsx +++ b/web/app/components/plugins/readme-panel/entrance.tsx @@ -19,7 +19,7 @@ export const ReadmeEntrance = ({ }) => { const { t } = useTranslation() const triggerId = useId() - const openReadmePanel = useReadmePanelStore(s => s.openReadmePanel) + const openReadmePanel = useReadmePanelStore((s) => s.openReadmePanel) const handleReadmeClick = () => { if (pluginDetail) { @@ -30,11 +30,21 @@ export const ReadmeEntrance = ({ }) } } - if (!pluginDetail || !pluginDetail?.plugin_unique_identifier || BUILTIN_TOOLS_ARRAY.includes(pluginDetail.id)) + if ( + !pluginDetail || + !pluginDetail?.plugin_unique_identifier || + BUILTIN_TOOLS_ARRAY.includes(pluginDetail.id) + ) return null return ( - <div className={cn('flex flex-col items-start justify-center gap-2 pt-0 pb-4', presentation === 'drawer' && 'px-4', className)}> + <div + className={cn( + 'flex flex-col items-start justify-center gap-2 pt-0 pb-4', + presentation === 'drawer' && 'px-4', + className, + )} + > {!showShortTip && ( <div className="relative h-1 w-8 shrink-0"> <div className="h-px w-full bg-divider-regular"></div> @@ -51,7 +61,9 @@ export const ReadmeEntrance = ({ <span aria-hidden="true" className="i-ri-book-read-line size-3" /> </div> <span className="text-xs/4 font-normal"> - {!showShortTip ? t($ => $['readmeInfo.needHelpCheckReadme'], { ns: 'plugin' }) : t($ => $['readmeInfo.title'], { ns: 'plugin' })} + {!showShortTip + ? t(($) => $['readmeInfo.needHelpCheckReadme'], { ns: 'plugin' }) + : t(($) => $['readmeInfo.title'], { ns: 'plugin' })} </span> </button> </div> diff --git a/web/app/components/plugins/readme-panel/index.tsx b/web/app/components/plugins/readme-panel/index.tsx index 72f56c0d739232..e20ec3b4ddc70e 100644 --- a/web/app/components/plugins/readme-panel/index.tsx +++ b/web/app/components/plugins/readme-panel/index.tsx @@ -5,15 +5,13 @@ import { ReadmeDrawer } from './drawer' import { useReadmePanelStore } from './store' export default function ReadmePanel() { - const currentPanel = useReadmePanelStore(s => s.currentPanel) - const closeReadmePanel = useReadmePanelStore(s => s.closeReadmePanel) + const currentPanel = useReadmePanelStore((s) => s.currentPanel) + const closeReadmePanel = useReadmePanelStore((s) => s.closeReadmePanel) - if (!currentPanel) - return null + if (!currentPanel) return null const onOpenChange = (open: boolean) => { - if (!open) - closeReadmePanel() + if (!open) closeReadmePanel() } if (currentPanel.presentation === 'dialog') { diff --git a/web/app/components/plugins/readme-panel/store.ts b/web/app/components/plugins/readme-panel/store.ts index f3231c3bfb55d5..40eef7a41acef7 100644 --- a/web/app/components/plugins/readme-panel/store.ts +++ b/web/app/components/plugins/readme-panel/store.ts @@ -21,14 +21,15 @@ type Shape = { closeReadmePanel: () => void } -export const useReadmePanelStore = create<Shape>(set => ({ +export const useReadmePanelStore = create<Shape>((set) => ({ currentPanel: undefined, - openReadmePanel: ({ detail, presentation = 'drawer', triggerId }) => set({ - currentPanel: { - detail, - presentation, - triggerId, - }, - }), + openReadmePanel: ({ detail, presentation = 'drawer', triggerId }) => + set({ + currentPanel: { + detail, + presentation, + triggerId, + }, + }), closeReadmePanel: () => set({ currentPanel: undefined }), })) diff --git a/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx index 74ff07e9102b5e..0c51cd9f90a506 100644 --- a/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx @@ -20,7 +20,11 @@ const render = (ui: ReactElement) => let mockDialogOnOpenChange: ((open: boolean) => void) | undefined vi.mock('@langgenius/dify-ui/dialog', () => ({ - Dialog: ({ children, open, onOpenChange }: { + Dialog: ({ + children, + open, + onOpenChange, + }: { children: React.ReactNode open?: boolean onOpenChange?: (open: boolean) => void @@ -28,8 +32,10 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({ mockDialogOnOpenChange = onOpenChange return open === false ? null : <>{children}</> }, - DialogContent: ({ children, className }: { children: React.ReactNode, className?: string }) => ( - <div data-testid="modal" className={className}>{children}</div> + DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => ( + <div data-testid="modal" className={className}> + {children} + </div> ), DialogCloseButton: () => ( <button data-testid="modal-close" onClick={() => mockDialogOnOpenChange?.(false)}> @@ -40,7 +46,14 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({ // Mock OptionCard component vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({ - default: ({ title, onSelect, selected, className, disabled, tooltip }: { + default: ({ + title, + onSelect, + selected, + className, + disabled, + tooltip, + }: { title: string onSelect: () => void selected: boolean @@ -51,8 +64,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({ <button data-testid={`option-card-${title.toLowerCase().replace(/\s+/g, '-')}`} onClick={() => { - if (!disabled) - onSelect() + if (!disabled) onSelect() }} aria-pressed={selected} disabled={disabled} @@ -67,7 +79,10 @@ vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({ // Mock AutoUpdateSetting component const mockAutoUpdateSettingOnChange = vi.fn() vi.mock('../auto-update-setting', () => ({ - default: ({ payload, onChange }: { + default: ({ + payload, + onChange, + }: { payload: AutoUpdateConfig onChange: (payload: AutoUpdateConfig) => void }) => { @@ -78,10 +93,12 @@ vi.mock('../auto-update-setting', () => ({ <span data-testid="auto-update-mode">{payload.upgrade_mode}</span> <button data-testid="auto-update-change" - onClick={() => onChange({ - ...payload, - strategy_setting: AUTO_UPDATE_STRATEGY.latest, - })} + onClick={() => + onChange({ + ...payload, + strategy_setting: AUTO_UPDATE_STRATEGY.latest, + }) + } > Change Strategy </button> @@ -100,7 +117,9 @@ const createMockPermissions = (overrides: Partial<Permissions> = {}): Permission ...overrides, }) -const createMockAutoUpdateConfig = (overrides: Partial<AutoUpdateConfig> = {}): AutoUpdateConfig => ({ +const createMockAutoUpdateConfig = ( + overrides: Partial<AutoUpdateConfig> = {}, +): AutoUpdateConfig => ({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, upgrade_time_of_day: 36000, upgrade_mode: AUTO_UPDATE_MODE.update_all, @@ -109,7 +128,9 @@ const createMockAutoUpdateConfig = (overrides: Partial<AutoUpdateConfig> = {}): ...overrides, }) -const createMockReferenceSetting = (overrides: Partial<ReferenceSetting> = {}): ReferenceSetting => ({ +const createMockReferenceSetting = ( + overrides: Partial<ReferenceSetting> = {}, +): ReferenceSetting => ({ permission: createMockPermissions(), auto_upgrade: createMockAutoUpdateConfig(), ...overrides, @@ -267,9 +288,15 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) - expect(screen.getAllByLabelText('plugin.privilege.configurePermissionsInSettings')).toHaveLength(2) - expect(screen.queryByText('plugin.privilege.configurePermissionsInSettings')).not.toBeInTheDocument() - expect(screen.getAllByTestId(/option-card/).every(option => option.hasAttribute('disabled'))).toBe(true) + expect( + screen.getAllByLabelText('plugin.privilege.configurePermissionsInSettings'), + ).toHaveLength(2) + expect( + screen.queryByText('plugin.privilege.configurePermissionsInSettings'), + ).not.toBeInTheDocument() + expect( + screen.getAllByTestId(/option-card/).every((option) => option.hasAttribute('disabled')), + ).toBe(true) }) }) @@ -371,10 +398,12 @@ describe('reference-setting-modal', () => { // Assert await waitFor(() => { - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - permission: expect.any(Object), - auto_upgrade: expect.any(Object), - })) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + permission: expect.any(Object), + auto_upgrade: expect.any(Object), + }), + ) }) }) @@ -450,11 +479,13 @@ describe('reference-setting-modal', () => { // Assert await waitFor(() => { - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - auto_upgrade: expect.objectContaining({ - strategy_setting: AUTO_UPDATE_STRATEGY.latest, + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + auto_upgrade: expect.objectContaining({ + strategy_setting: AUTO_UPDATE_STRATEGY.latest, + }), }), - })) + ) }) }) @@ -479,12 +510,14 @@ describe('reference-setting-modal', () => { fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => { - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - permission: payload.permission, - auto_upgrade: expect.objectContaining({ - strategy_setting: AUTO_UPDATE_STRATEGY.latest, + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + permission: payload.permission, + auto_upgrade: expect.objectContaining({ + strategy_setting: AUTO_UPDATE_STRATEGY.latest, + }), }), - })) + ) }) }) @@ -509,12 +542,14 @@ describe('reference-setting-modal', () => { fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => { - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - permission: expect.objectContaining({ - install_permission: PermissionType.noOne, + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + permission: expect.objectContaining({ + install_permission: PermissionType.noOne, + }), + auto_upgrade: payload.auto_upgrade, }), - auto_upgrade: payload.auto_upgrade, - })) + ) }) }) }) @@ -565,7 +600,9 @@ describe('reference-setting-modal', () => { it('should be memoized with React.memo', () => { // Assert expect(ReferenceSettingModal).toBeDefined() - expect((ReferenceSettingModal as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') + expect((ReferenceSettingModal as { $$typeof?: symbol }).$$typeof?.toString()).toContain( + 'Symbol', + ) }) }) @@ -652,7 +689,11 @@ describe('reference-setting-modal', () => { describe('Props Variations', () => { it('should render with all PermissionType combinations', () => { // Test each permission type - const permissionTypes = [PermissionType.everyone, PermissionType.admin, PermissionType.noOne] + const permissionTypes = [ + PermissionType.everyone, + PermissionType.admin, + PermissionType.noOne, + ] permissionTypes.forEach((installPerm) => { permissionTypes.forEach((debugPerm) => { @@ -665,7 +706,9 @@ describe('reference-setting-modal', () => { }) // Act - const { unmount } = render(<ReferenceSettingModal {...defaultProps} payload={payload} />) + const { unmount } = render( + <ReferenceSettingModal {...defaultProps} payload={payload} />, + ) // Assert - should render without crashing // Assert - should render without crashing @@ -754,12 +797,14 @@ describe('reference-setting-modal', () => { // Assert - debug_permission should still be admin await waitFor(() => { - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - permission: expect.objectContaining({ - install_permission: PermissionType.noOne, - debug_permission: PermissionType.admin, + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + permission: expect.objectContaining({ + install_permission: PermissionType.noOne, + debug_permission: PermissionType.admin, + }), }), - })) + ) }) }) @@ -785,12 +830,14 @@ describe('reference-setting-modal', () => { // Assert - install_permission should still be admin await waitFor(() => { - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - permission: expect.objectContaining({ - install_permission: PermissionType.admin, - debug_permission: PermissionType.noOne, + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + permission: expect.objectContaining({ + install_permission: PermissionType.admin, + debug_permission: PermissionType.noOne, + }), }), - })) + ) }) }) @@ -814,14 +861,16 @@ describe('reference-setting-modal', () => { // Assert - both changes should be saved await waitFor(() => { - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - permission: expect.objectContaining({ - install_permission: PermissionType.everyone, - }), - auto_upgrade: expect.objectContaining({ - strategy_setting: AUTO_UPDATE_STRATEGY.latest, + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + permission: expect.objectContaining({ + install_permission: PermissionType.everyone, + }), + auto_upgrade: expect.objectContaining({ + strategy_setting: AUTO_UPDATE_STRATEGY.latest, + }), }), - })) + ) }) }) }) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx index 4fc44ccc9a8e32..2dd7205dfcb54b 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx @@ -37,7 +37,11 @@ const mockTimezone = 'America/New_York' // Mock modal context const mockSetShowAccountSettingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: (selector: (s: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => typeof mockSetShowAccountSettingModal) => { + useModalContextSelector: ( + selector: (s: { + setShowAccountSettingModal: typeof mockSetShowAccountSettingModal + }) => typeof mockSetShowAccountSettingModal, + ) => { return selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }) }, })) @@ -46,7 +50,7 @@ vi.mock('@/context/modal-context', () => ({ vi.mock('react-i18next', async () => { const { withSelectorKey, withSelectorKeyProps } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: (defaultNs?: string) => ({ t: withSelectorKey((key: string, options?: Record<string, unknown>) => { const ns = (options?.ns as string | undefined) ?? defaultNs @@ -60,17 +64,21 @@ vi.mock('react-i18next', async () => { changeLanguage: vi.fn(), }, }), - Trans: withSelectorKeyProps(({ i18nKey, components }: { - i18nKey: string - components?: Record<string, React.ReactElement> - }) => { - const setTimezone = components?.setTimezone - if (setTimezone) - return React.cloneElement(setTimezone, undefined, i18nKey) - - return <span>{i18nKey}</span> - }), - }) + Trans: withSelectorKeyProps( + ({ + i18nKey, + components, + }: { + i18nKey: string + components?: Record<string, React.ReactElement> + }) => { + const setTimezone = components?.setTimezone + if (setTimezone) return React.cloneElement(setTimezone, undefined, i18nKey) + + return <span>{i18nKey}</span> + }, + ), + } }) // Mock plugins service @@ -87,7 +95,11 @@ let mockPopoverOpen = false let forcePopoverContentVisible = false // Allow tests to force content visibility let mockPopoverOnOpenChange: ((open: boolean) => void) | undefined vi.mock('@langgenius/dify-ui/popover', () => ({ - Popover: ({ children, open = false, onOpenChange }: { + Popover: ({ + children, + open = false, + onOpenChange, + }: { children: React.ReactNode open?: boolean onOpenChange?: (open: boolean) => void @@ -95,10 +107,17 @@ vi.mock('@langgenius/dify-ui/popover', () => ({ mockPopoverOpen = open mockPopoverOnOpenChange = onOpenChange return ( - <div data-testid="popover" data-open={open}>{children}</div> + <div data-testid="popover" data-open={open}> + {children} + </div> ) }, - PopoverTrigger: ({ children, render, onClick, className }: { + PopoverTrigger: ({ + children, + render, + onClick, + className, + }: { children?: React.ReactNode render?: React.ReactNode onClick?: (e: React.MouseEvent) => void @@ -108,33 +127,51 @@ vi.mock('@langgenius/dify-ui/popover', () => ({ data-testid="popover-trigger" onClick={(e) => { onClick?.(e) - if (!onClick) - mockPopoverOnOpenChange?.(!mockPopoverOpen) + if (!onClick) mockPopoverOnOpenChange?.(!mockPopoverOpen) }} className={className} > {render ?? children} </div> ), - PopoverContent: ({ children, className, popupClassName }: { + PopoverContent: ({ + children, + className, + popupClassName, + }: { children: React.ReactNode className?: string popupClassName?: string }) => { - if (!mockPopoverOpen && !forcePopoverContentVisible) - return null - return <div data-testid="popover-content" className={[className, popupClassName].filter(Boolean).join(' ')}>{children}</div> + if (!mockPopoverOpen && !forcePopoverContentVisible) return null + return ( + <div + data-testid="popover-content" + className={[className, popupClassName].filter(Boolean).join(' ')} + > + {children} + </div> + ) }, })) // Mock TimePicker component - simplified stateless mock vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({ - default: ({ value, onChange, onClear, renderTrigger }: { + default: ({ + value, + onChange, + onClear, + renderTrigger, + }: { value: { format: (f: string) => string } onChange: (v: unknown) => void onClear: () => void title?: string - renderTrigger: (params: { inputElem: React.ReactNode, onClick: () => void, isOpen: boolean }) => React.ReactNode + renderTrigger: (params: { + inputElem: React.ReactNode + onClick: () => void + isOpen: boolean + }) => React.ReactNode }) => { const inputElem = <span data-testid="time-input">{value.format('HH:mm')}</span> @@ -171,17 +208,21 @@ vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({ // Mock utils from date-and-time-picker vi.mock('@/app/components/base/date-and-time-picker/utils/dayjs', () => ({ convertTimezoneToOffsetStr: (tz: string) => { - if (tz === 'America/New_York') - return 'GMT-5' - if (tz === 'Asia/Shanghai') - return 'GMT+8' + if (tz === 'America/New_York') return 'GMT-5' + if (tz === 'Asia/Shanghai') return 'GMT+8' return 'GMT+0' }, })) // Mock SearchBox component vi.mock('@/app/components/plugins/marketplace/search-box', () => ({ - default: ({ search, onSearchChange, tags: _tags, onTagsChange: _onTagsChange, placeholder }: { + default: ({ + search, + onSearchChange, + tags: _tags, + onTagsChange: _onTagsChange, + placeholder, + }: { search: string onSearchChange: (v: string) => void tags: string[] @@ -192,7 +233,7 @@ vi.mock('@/app/components/plugins/marketplace/search-box', () => ({ <input data-testid="search-input" value={search} - onChange={e => onSearchChange(e.target.value)} + onChange={(e) => onSearchChange(e.target.value)} placeholder={placeholder} /> </div> @@ -201,18 +242,26 @@ vi.mock('@/app/components/plugins/marketplace/search-box', () => ({ // Mock Icon component vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ - default: ({ size, src }: { size: string, src: string }) => ( + default: ({ size, src }: { size: string; src: string }) => ( <img data-testid="plugin-icon" data-size={size} src={src} alt="plugin icon" /> ), })) // Mock icons vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({ - SearchMenu: ({ className }: { className?: string }) => <span data-testid="search-menu-icon" className={className}>🔍</span>, + SearchMenu: ({ className }: { className?: string }) => ( + <span data-testid="search-menu-icon" className={className}> + 🔍 + </span> + ), })) vi.mock('@/app/components/base/icons/src/vender/other', () => ({ - Group: ({ className }: { className?: string }) => <span data-testid="group-icon" className={className}>📦</span>, + Group: ({ className }: { className?: string }) => ( + <span data-testid="group-icon" className={className}> + 📦 + </span> + ), })) // Mock PLUGIN_TYPE_SEARCH_MAP @@ -238,7 +287,9 @@ vi.mock('@/i18n-config', () => ({ // Test Data Factories // ================================ -const createMockPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ +const createMockPluginDeclaration = ( + overrides: Partial<PluginDeclaration> = {}, +): PluginDeclaration => ({ plugin_unique_identifier: 'test-plugin-id', version: '1.0.0', author: 'test-author', @@ -298,7 +349,9 @@ const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDe ...overrides, }) -const createMockAutoUpdateConfig = (overrides: Partial<AutoUpdateConfig> = {}): AutoUpdateConfig => ({ +const createMockAutoUpdateConfig = ( + overrides: Partial<AutoUpdateConfig> = {}, +): AutoUpdateConfig => ({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, upgrade_time_of_day: 36000, // 10:00 UTC upgrade_mode: AUTO_UPDATE_MODE.update_all, @@ -497,7 +550,9 @@ describe('auto-update-setting', () => { // Assert expect(screen.getByTestId('group-icon')).toBeInTheDocument() - expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noInstalled')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noInstalled'), + ).toBeInTheDocument() }) it('should render with noPlugins=false showing search icon', () => { @@ -506,7 +561,9 @@ describe('auto-update-setting', () => { // Assert expect(screen.getByTestId('search-menu-icon')).toBeInTheDocument() - expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noFound')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noFound'), + ).toBeInTheDocument() }) it('should render with noPlugins=undefined (default) showing search icon', () => { @@ -529,7 +586,9 @@ describe('auto-update-setting', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(NoDataPlaceholder).toBeDefined() - expect((NoDataPlaceholder as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') + expect((NoDataPlaceholder as { $$typeof?: symbol }).$$typeof?.toString()).toContain( + 'Symbol', + ) }) }) }) @@ -541,7 +600,9 @@ describe('auto-update-setting', () => { render(<NoPluginSelected updateMode={AUTO_UPDATE_MODE.partial} />) // Assert - expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.partial')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.partial'), + ).toBeInTheDocument() }) it('should render exclude mode placeholder', () => { @@ -549,7 +610,9 @@ describe('auto-update-setting', () => { render(<NoPluginSelected updateMode={AUTO_UPDATE_MODE.exclude} />) // Assert - expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.exclude')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.exclude'), + ).toBeInTheDocument() }) }) @@ -611,7 +674,9 @@ describe('auto-update-setting', () => { it('should apply custom className', () => { // Act - const { container } = render(<PluginsSelected plugins={['test']} className="custom-class" />) + const { container } = render( + <PluginsSelected plugins={['test']} className="custom-class" />, + ) // Assert expect(container.firstChild).toHaveClass('custom-class') @@ -755,11 +820,10 @@ describe('auto-update-setting', () => { const findOption = (key: 'disabled' | 'fixOnly' | 'latest') => { const options = screen.getAllByRole('button') - const option = options.find(item => + const option = options.find((item) => item.textContent?.includes(`plugin.autoUpdate.strategy.${key}.name`), ) - if (!option) - throw new Error(`Strategy option "${key}" not found`) + if (!option) throw new Error(`Strategy option "${key}" not found`) return option } @@ -767,13 +831,23 @@ describe('auto-update-setting', () => { it('should render the segmented control with all strategies', () => { render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />) - expect(screen.getByRole('group', { name: 'plugin.autoUpdate.automaticUpdates' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })).toBeInTheDocument() + expect( + screen.getByRole('group', { name: 'plugin.autoUpdate.automaticUpdates' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) }), + ).toBeInTheDocument() const options = screen.getAllByRole('button') expect(options).toHaveLength(3) - expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.disabled.name'))).toBe(true) - expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.fixOnly.name'))).toBe(true) - expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.latest.name'))).toBe(true) + expect( + options.some((o) => o.textContent?.includes('plugin.autoUpdate.strategy.disabled.name')), + ).toBe(true) + expect( + options.some((o) => o.textContent?.includes('plugin.autoUpdate.strategy.fixOnly.name')), + ).toBe(true) + expect( + options.some((o) => o.textContent?.includes('plugin.autoUpdate.strategy.latest.name')), + ).toBe(true) }) }) @@ -782,21 +856,24 @@ describe('auto-update-setting', () => { [AUTO_UPDATE_STRATEGY.disabled, 'fixOnly', AUTO_UPDATE_STRATEGY.fixOnly], [AUTO_UPDATE_STRATEGY.disabled, 'latest', AUTO_UPDATE_STRATEGY.latest], [AUTO_UPDATE_STRATEGY.fixOnly, 'disabled', AUTO_UPDATE_STRATEGY.disabled], - ])('should call onChange with %s -> %s when option is selected', async (initial, optionKey, expected) => { - const user = userEvent.setup() - const onChange = vi.fn() - render(<StrategyPicker value={initial} onChange={onChange} />) + ])( + 'should call onChange with %s -> %s when option is selected', + async (initial, optionKey, expected) => { + const user = userEvent.setup() + const onChange = vi.fn() + render(<StrategyPicker value={initial} onChange={onChange} />) - await user.click(findOption(optionKey)) + await user.click(findOption(optionKey)) - expect(onChange).toHaveBeenCalledWith(expected) - }) + expect(onChange).toHaveBeenCalledWith(expected) + }, + ) it('should mark only the currently selected option with aria-pressed', () => { render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />) const options = screen.getAllByRole('button') - const checked = options.filter(o => o.getAttribute('aria-pressed') === 'true') + const checked = options.filter((o) => o.getAttribute('aria-pressed') === 'true') expect(checked).toHaveLength(1) expect(checked[0]).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name') @@ -930,7 +1007,9 @@ describe('auto-update-setting', () => { createMockPluginDetail({ plugin_id: 'test-plugin', source: PluginSource.marketplace, - declaration: createMockPluginDeclaration({ label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'] }), + declaration: createMockPluginDeclaration({ + label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'], + }), }), ] const onChange = vi.fn() @@ -956,7 +1035,12 @@ describe('auto-update-setting', () => { // Act renderWithQueryClient( - <ToolPicker {...defaultProps} isShow={true} value={['test-plugin']} onChange={onChange} />, + <ToolPicker + {...defaultProps} + isShow={true} + value={['test-plugin']} + onChange={onChange} + />, ) fireEvent.click(screen.getByRole('checkbox')) @@ -1024,7 +1108,9 @@ describe('auto-update-setting', () => { render(<PluginsPicker {...defaultProps} />) // Assert - expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.partial')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.partial'), + ).toBeInTheDocument() }) it('should render selected plugins count and clear button when plugins selected', () => { @@ -1032,8 +1118,12 @@ describe('auto-update-setting', () => { render(<PluginsPicker {...defaultProps} value={['plugin-1', 'plugin-2']} />) // Assert - expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"count":2,"num":2}')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.partialUPdate:{"count":2,"num":2}'), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' }), + ).toBeInTheDocument() }) it('should render select button', () => { @@ -1055,7 +1145,9 @@ describe('auto-update-setting', () => { ) // Assert - expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"count":1,"num":1}')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.excludeUpdate:{"count":1,"num":1}'), + ).toBeInTheDocument() }) }) @@ -1066,13 +1158,11 @@ describe('auto-update-setting', () => { // Act render( - <PluginsPicker - {...defaultProps} - value={['plugin-1', 'plugin-2']} - onChange={onChange} - />, + <PluginsPicker {...defaultProps} value={['plugin-1', 'plugin-2']} onChange={onChange} />, + ) + fireEvent.click( + screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' }), ) - fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })) // Assert expect(onChange).toHaveBeenCalledWith([]) @@ -1125,7 +1215,9 @@ describe('auto-update-setting', () => { it('should show time picker when strategy is not disabled', () => { // Arrange - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, + }) // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) @@ -1137,7 +1229,9 @@ describe('auto-update-setting', () => { it('should hide time picker and plugins selection when strategy is disabled', () => { // Arrange - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.disabled }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.disabled, + }) // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) @@ -1179,36 +1273,50 @@ describe('auto-update-setting', () => { describe('Strategy Description', () => { it('should show fixOnly description when strategy is fixOnly', () => { // Arrange - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, + }) // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.selectedDescription')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.strategy.fixOnly.selectedDescription'), + ).toBeInTheDocument() }) it('should show latest description when strategy is latest', () => { // Arrange - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.latest }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.latest, + }) // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('plugin.autoUpdate.strategy.latest.selectedDescription')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.strategy.latest.selectedDescription'), + ).toBeInTheDocument() }) it('should show no description when strategy is disabled', () => { // Arrange - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.disabled }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.disabled, + }) // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.queryByText('plugin.autoUpdate.strategy.fixOnly.selectedDescription')).not.toBeInTheDocument() - expect(screen.queryByText('plugin.autoUpdate.strategy.latest.selectedDescription')).not.toBeInTheDocument() + expect( + screen.queryByText('plugin.autoUpdate.strategy.fixOnly.selectedDescription'), + ).not.toBeInTheDocument() + expect( + screen.queryByText('plugin.autoUpdate.strategy.latest.selectedDescription'), + ).not.toBeInTheDocument() }) }) @@ -1226,7 +1334,9 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"count":2,"num":2}')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.partialUPdate:{"count":2,"num":2}'), + ).toBeInTheDocument() }) it('should show exclude_plugins when mode is exclude', () => { @@ -1242,7 +1352,9 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"count":3,"num":3}')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.excludeUpdate:{"count":3,"num":3}'), + ).toBeInTheDocument() }) }) @@ -1251,7 +1363,9 @@ describe('auto-update-setting', () => { // Arrange const user = userEvent.setup() const onChange = vi.fn() - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, + }) // Act render(<AutoUpdateSetting payload={payload} onChange={onChange} />) @@ -1269,7 +1383,9 @@ describe('auto-update-setting', () => { it('should call onChange with updated time when time changes', () => { // Arrange const onChange = vi.fn() - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, + }) // Act render(<AutoUpdateSetting payload={payload} onChange={onChange} />) @@ -1287,7 +1403,9 @@ describe('auto-update-setting', () => { it('should call onChange with 0 when time is cleared', () => { // Arrange const onChange = vi.fn() - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, + }) // Act render(<AutoUpdateSetting payload={payload} onChange={onChange} />) @@ -1315,12 +1433,16 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Click clear all - fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })) + fireEvent.click( + screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' }), + ) // Assert - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - include_plugins: [], - })) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + include_plugins: [], + }), + ) }) it('should call onChange with exclude_plugins when in exclude mode', () => { @@ -1336,31 +1458,41 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Click clear all - fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })) + fireEvent.click( + screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' }), + ) // Assert - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - exclude_plugins: [], - })) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + exclude_plugins: [], + }), + ) }) it('should open account settings when timezone link is clicked', () => { // Arrange - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, + }) // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) fireEvent.click(screen.getByText('autoUpdate.changeTimezone')) // Assert - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PREFERENCES }) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.PREFERENCES, + }) }) }) describe('Callback Memoization', () => { it('minuteFilter should filter to 15 minute intervals', () => { // Arrange - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, + }) // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) @@ -1385,14 +1517,18 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Trigger a change (clear plugins) - fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })) + fireEvent.click( + screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' }), + ) // Assert - other values should be preserved - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, - upgrade_time_of_day: 36000, - upgrade_mode: AUTO_UPDATE_MODE.partial, - })) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, + upgrade_time_of_day: 36000, + upgrade_mode: AUTO_UPDATE_MODE.partial, + }), + ) }) it('handlePluginsChange should not update when mode is update_all', () => { @@ -1414,18 +1550,26 @@ describe('auto-update-setting', () => { describe('Memoization Logic', () => { it('strategyDescription should update when strategy_setting changes', () => { // Arrange - const payload1 = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly }) + const payload1 = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, + }) const { rerender } = render(<AutoUpdateSetting {...defaultProps} payload={payload1} />) // Assert initial - expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.selectedDescription')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.strategy.fixOnly.selectedDescription'), + ).toBeInTheDocument() // Act - change strategy - const payload2 = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.latest }) + const payload2 = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.latest, + }) rerender(<AutoUpdateSetting {...defaultProps} payload={payload2} />) // Assert updated - expect(screen.getByText('plugin.autoUpdate.strategy.latest.selectedDescription')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.strategy.latest.selectedDescription'), + ).toBeInTheDocument() }) it('plugins should reflect correct list based on upgrade_mode', () => { @@ -1436,10 +1580,14 @@ describe('auto-update-setting', () => { include_plugins: ['include-1', 'include-2'], exclude_plugins: ['exclude-1'], }) - const { rerender } = render(<AutoUpdateSetting {...defaultProps} payload={partialPayload} />) + const { rerender } = render( + <AutoUpdateSetting {...defaultProps} payload={partialPayload} />, + ) // Assert - partial mode shows include_plugins count - expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"count":2,"num":2}')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.partialUPdate:{"count":2,"num":2}'), + ).toBeInTheDocument() // Act - change to exclude mode const excludePayload = createMockAutoUpdateConfig({ @@ -1451,14 +1599,18 @@ describe('auto-update-setting', () => { rerender(<AutoUpdateSetting {...defaultProps} payload={excludePayload} />) // Assert - exclude mode shows exclude_plugins count - expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"count":1,"num":1}')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.excludeUpdate:{"count":1,"num":1}'), + ).toBeInTheDocument() }) }) describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(AutoUpdateSetting).toBeDefined() - expect((AutoUpdateSetting as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') + expect((AutoUpdateSetting as { $$typeof?: symbol }).$$typeof?.toString()).toContain( + 'Symbol', + ) }) }) @@ -1481,7 +1633,9 @@ describe('auto-update-setting', () => { it('should handle null timezone gracefully', () => { // This tests the timezone! non-null assertion in the component // The mock provides a valid timezone, so the component should work - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, + }) // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) @@ -1492,7 +1646,9 @@ describe('auto-update-setting', () => { it('should render timezone offset correctly', () => { // Arrange - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, + }) // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) @@ -1505,7 +1661,9 @@ describe('auto-update-setting', () => { describe('Upgrade Mode Options', () => { it('should render all three upgrade mode options', () => { // Arrange - const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly }) + const payload = createMockAutoUpdateConfig({ + strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly, + }) // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) @@ -1527,9 +1685,15 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByRole('button', { name: 'plugin.autoUpdate.upgradeMode.all' })).toHaveAttribute('aria-pressed', 'false') - expect(screen.getByRole('button', { name: 'plugin.autoUpdate.upgradeMode.exclude' })).toHaveAttribute('aria-pressed', 'false') - expect(screen.getByRole('button', { name: 'plugin.autoUpdate.upgradeMode.partial' })).toHaveAttribute('aria-pressed', 'true') + expect( + screen.getByRole('button', { name: 'plugin.autoUpdate.upgradeMode.all' }), + ).toHaveAttribute('aria-pressed', 'false') + expect( + screen.getByRole('button', { name: 'plugin.autoUpdate.upgradeMode.exclude' }), + ).toHaveAttribute('aria-pressed', 'false') + expect( + screen.getByRole('button', { name: 'plugin.autoUpdate.upgradeMode.partial' }), + ).toHaveAttribute('aria-pressed', 'true') }) it('should call onChange when upgrade mode is changed', () => { @@ -1548,9 +1712,11 @@ describe('auto-update-setting', () => { fireEvent.click(partialOption) // Assert - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - upgrade_mode: AUTO_UPDATE_MODE.partial, - })) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + upgrade_mode: AUTO_UPDATE_MODE.partial, + }), + ) }) }) }) @@ -1600,7 +1766,9 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Assert - partial mode shows include_plugins - expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"count":1,"num":1}')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.partialUPdate:{"count":1,"num":1}'), + ).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx index d205682690c2b3..efbf51fe460747 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx @@ -14,6 +14,8 @@ describe('NoDataPlaceholder', () => { const { container } = render(<NoDataPlaceholder className="min-h-10" noPlugins />) expect(container.querySelector('svg')).toBeInTheDocument() - expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noInstalled')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noInstalled'), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx index 91fd743b37a526..36a378ad8df4f5 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx @@ -29,19 +29,19 @@ vi.mock('@/service/use-plugins', () => ({ })) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ - children, - }: { - children: React.ReactNode - }) => <button>{children}</button>, + Button: ({ children }: { children: React.ReactNode }) => <button>{children}</button>, })) vi.mock('../no-plugin-selected', () => ({ - default: ({ updateMode }: { updateMode: AUTO_UPDATE_MODE }) => <div data-testid="no-plugin-selected">{updateMode}</div>, + default: ({ updateMode }: { updateMode: AUTO_UPDATE_MODE }) => ( + <div data-testid="no-plugin-selected">{updateMode}</div> + ), })) vi.mock('../plugins-selected', () => ({ - default: ({ plugins }: { plugins: string[] }) => <div data-testid="plugins-selected">{plugins.join(',')}</div>, + default: ({ plugins }: { plugins: string[] }) => ( + <div data-testid="plugins-selected">{plugins.join(',')}</div> + ), })) vi.mock('../tool-picker', () => ({ @@ -57,21 +57,17 @@ describe('PluginsPicker', () => { }) it('renders the empty state when no plugins are selected', () => { - render( - <PluginsPicker - updateMode={AUTO_UPDATE_MODE.partial} - value={[]} - onChange={vi.fn()} - />, - ) + render(<PluginsPicker updateMode={AUTO_UPDATE_MODE.partial} value={[]} onChange={vi.fn()} />) expect(screen.getByTestId('no-plugin-selected')).toHaveTextContent(AUTO_UPDATE_MODE.partial) expect(screen.queryByTestId('plugins-selected')).not.toBeInTheDocument() - expect(mockToolPicker).toHaveBeenCalledWith(expect.objectContaining({ - value: [], - isShow: false, - onShowChange: expect.any(Function), - })) + expect(mockToolPicker).toHaveBeenCalledWith( + expect.objectContaining({ + value: [], + isShow: false, + onShowChange: expect.any(Function), + }), + ) }) it('renders selected plugins summary and clears them', () => { @@ -84,7 +80,9 @@ describe('PluginsPicker', () => { />, ) - expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"count":2,"num":2}')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.excludeUpdate:{"count":2,"num":2}'), + ).toBeInTheDocument() expect(screen.getByTestId('plugins-selected')).toHaveTextContent('dify/plugin-1,dify/plugin-2') fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })) @@ -103,10 +101,12 @@ describe('PluginsPicker', () => { ) expect(screen.getByTestId('tool-picker')).toBeInTheDocument() - expect(mockToolPicker).toHaveBeenCalledWith(expect.objectContaining({ - trigger: expect.anything(), - integrationCategory: PluginCategoryEnum.model, - })) + expect(mockToolPicker).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: expect.anything(), + integrationCategory: PluginCategoryEnum.model, + }), + ) }) it('shows and edits only selected plugins from the provided integration category', () => { @@ -121,7 +121,9 @@ describe('PluginsPicker', () => { />, ) - expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"count":1,"num":1}')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.excludeUpdate:{"count":1,"num":1}'), + ).toBeInTheDocument() expect(screen.getByTestId('plugins-selected')).toHaveTextContent('dify/tool-plugin') expect(screen.getByTestId('plugins-selected')).not.toHaveTextContent('dify/model-plugin') @@ -132,7 +134,11 @@ describe('PluginsPicker', () => { expect(toolPickerProps.value).toEqual(['dify/tool-plugin']) toolPickerProps.onChange(['dify/new-tool-plugin']) - expect(onChange).toHaveBeenCalledWith(['dify/model-plugin', 'dify/datasource-plugin', 'dify/new-tool-plugin']) + expect(onChange).toHaveBeenCalledWith([ + 'dify/model-plugin', + 'dify/datasource-plugin', + 'dify/new-tool-plugin', + ]) fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' })) expect(onChange).toHaveBeenLastCalledWith(['dify/model-plugin', 'dify/datasource-plugin']) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx index cc4693f89c26f5..54df167c0b15b1 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx @@ -15,7 +15,9 @@ describe('PluginsSelected', () => { render(<PluginsSelected plugins={['dify/plugin-1', 'dify/plugin-2']} />) expect(screen.getAllByTestId('plugin-icon')).toHaveLength(2) - expect(screen.getByText('https://marketplace.example.com/plugins/dify/plugin-1/icon')).toBeInTheDocument() + expect( + screen.getByText('https://marketplace.example.com/plugins/dify/plugin-1/icon'), + ).toBeInTheDocument() expect(screen.queryByText('+1')).not.toBeInTheDocument() }) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx index 8b3752ae110874..034cc3d9a7e5b7 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx @@ -3,33 +3,26 @@ import { describe, expect, it, vi } from 'vitest' import StrategyPicker from '../strategy-picker' import { AUTO_UPDATE_STRATEGY } from '../types' -const triggerName = (key: string) => new RegExp(`plugin\\.autoUpdate\\.strategy\\.${key}\\.name`, 'i') +const triggerName = (key: string) => + new RegExp(`plugin\\.autoUpdate\\.strategy\\.${key}\\.name`, 'i') describe('StrategyPicker', () => { it('renders all strategy toggle options', () => { - render( - <StrategyPicker - value={AUTO_UPDATE_STRATEGY.fixOnly} - onChange={vi.fn()} - />, - ) - - expect(screen.getByRole('group', { name: 'plugin.autoUpdate.automaticUpdates' })).toBeInTheDocument() + render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />) + + expect( + screen.getByRole('group', { name: 'plugin.autoUpdate.automaticUpdates' }), + ).toBeInTheDocument() expect(screen.getByRole('button', { name: triggerName('disabled') })).toBeInTheDocument() expect(screen.getByRole('button', { name: triggerName('fixOnly') })).toBeInTheDocument() expect(screen.getByRole('button', { name: triggerName('latest') })).toBeInTheDocument() }) it('marks only the currently selected strategy as pressed', () => { - render( - <StrategyPicker - value={AUTO_UPDATE_STRATEGY.fixOnly} - onChange={vi.fn()} - />, - ) + render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />) const buttons = screen.getAllByRole('button') - const pressedOptions = buttons.filter(item => item.getAttribute('aria-pressed') === 'true') + const pressedOptions = buttons.filter((item) => item.getAttribute('aria-pressed') === 'true') expect(pressedOptions).toHaveLength(1) expect(pressedOptions[0]).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name') @@ -37,12 +30,7 @@ describe('StrategyPicker', () => { it('calls onChange when a new strategy is selected', async () => { const onChange = vi.fn() - render( - <StrategyPicker - value={AUTO_UPDATE_STRATEGY.disabled} - onChange={onChange} - />, - ) + render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />) fireEvent.click(screen.getByRole('button', { name: triggerName('latest') })) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx index 0ed77f8d7644f8..1f98f965e77b71 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx @@ -31,8 +31,13 @@ describe('ToolItem', () => { expect(screen.getByText('Plugin One')).toBeInTheDocument() expect(screen.getByText('Dify')).toBeInTheDocument() - expect(screen.getByText('https://marketplace.example.com/plugins/dify/plugin-1/icon')).toBeInTheDocument() - expect(screen.getByRole('checkbox', { name: 'Plugin One' })).toHaveAttribute('aria-checked', 'true') + expect( + screen.getByText('https://marketplace.example.com/plugins/dify/plugin-1/icon'), + ).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Plugin One' })).toHaveAttribute( + 'aria-checked', + 'true', + ) }) it('calls onCheckChange when checkbox is clicked', () => { diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx index a84059ef490447..d48d19c1c65a14 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx @@ -35,18 +35,16 @@ vi.mock('@langgenius/dify-ui/popover', async () => { open?: boolean onOpenChange?: (open: boolean) => void }) => ( - <PopoverContext.Provider value={{ open: !!open, setOpen: (nextOpen: boolean) => onOpenChange?.(nextOpen) }}> + <PopoverContext.Provider + value={{ open: !!open, setOpen: (nextOpen: boolean) => onOpenChange?.(nextOpen) }} + > {children} </PopoverContext.Provider> ) const PopoverTrigger = ({ render }: { render: React.ReactNode }) => { const { open, setOpen } = React.useContext(PopoverContext) - return ( - <div onClick={() => setOpen(!open)}> - {render} - </div> - ) + return <div onClick={() => setOpen(!open)}>{render}</div> } const PopoverContent = ({ @@ -57,7 +55,11 @@ vi.mock('@langgenius/dify-ui/popover', async () => { className?: string }) => { const { open } = React.useContext(PopoverContext) - return open ? <div data-testid="popover-content" className={className}>{children}</div> : null + return open ? ( + <div data-testid="popover-content" className={className}> + {children} + </div> + ) : null } return { @@ -85,18 +87,20 @@ vi.mock('@/app/components/plugins/marketplace/search-box', () => ({ <div>{placeholder}</div> <div data-testid="search-state">{search}</div> <div data-testid="tags-state">{tags.join(',')}</div> - <button data-testid="set-query" onClick={() => onSearchChange('tool-rag')}>set-query</button> - <button data-testid="set-tags" onClick={() => onTagsChange(['rag'])}>set-tags</button> + <button data-testid="set-query" onClick={() => onSearchChange('tool-rag')}> + set-query + </button> + <button data-testid="set-tags" onClick={() => onTagsChange(['rag'])}> + set-tags + </button> </div> ), })) vi.mock('../no-data-placeholder', () => ({ - default: ({ - noPlugins, - }: { - noPlugins?: boolean - }) => <div data-testid="no-data">{String(noPlugins)}</div>, + default: ({ noPlugins }: { noPlugins?: boolean }) => ( + <div data-testid="no-data">{String(noPlugins)}</div> + ), })) vi.mock('../tool-item', () => ({ @@ -112,7 +116,9 @@ vi.mock('../tool-item', () => ({ <div data-testid="tool-item"> <span>{payload.plugin_id}</span> <span>{String(isChecked)}</span> - <button data-testid={`toggle-${payload.plugin_id}`} onClick={onCheckChange}>toggle</button> + <button data-testid={`toggle-${payload.plugin_id}`} onClick={onCheckChange}> + toggle + </button> </div> ), })) @@ -122,14 +128,15 @@ const createPlugin = ( source: PluginDetail['source'], category: string, tags: string[], -): PluginDetail => ({ - plugin_id: pluginId, - source, - declaration: { - category, - tags, - }, -} as PluginDetail) +): PluginDetail => + ({ + plugin_id: pluginId, + source, + declaration: { + category, + tags, + }, + }) as PluginDetail describe('ToolPicker', () => { beforeEach(() => { diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/utils.spec.ts b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/utils.spec.ts index c23072021eaad6..02ecb4b22dccb2 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/utils.spec.ts +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/utils.spec.ts @@ -9,6 +9,11 @@ describe('convertLocalSecondsToUTCDaySeconds', () => { it('should convert local seconds to UTC day seconds for a specific time', () => { const localTimezone = 'Asia/Shanghai' - expect(convertUTCDaySecondsToLocalSeconds(convertLocalSecondsToUTCDaySeconds(0, localTimezone), localTimezone)).toBe(0) + expect( + convertUTCDaySecondsToLocalSeconds( + convertLocalSecondsToUTCDaySeconds(0, localTimezone), + localTimezone, + ), + ).toBe(0) }) }) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx index 5eb777c0826b77..c974a1f7505b07 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx @@ -18,7 +18,12 @@ import Label from '../label' import PluginsPicker from './plugins-picker' import StrategyPicker from './strategy-picker' import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from './types' -import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds, dayjsToTimeOfDay, timeOfDayToDayjs } from './utils' +import { + convertLocalSecondsToUTCDaySeconds, + convertUTCDaySecondsToLocalSeconds, + dayjsToTimeOfDay, + timeOfDayToDayjs, +} from './utils' const i18nPrefix = 'autoUpdate' @@ -29,10 +34,8 @@ type Props = Readonly<{ const SettingTimeZone: FC<{ children?: React.ReactNode -}> = ({ - children, -}) => { - const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) +}> = ({ children }) => { + const setShowAccountSettingModal = useModalContextSelector((s) => s.setShowAccountSettingModal) return ( <button type="button" @@ -43,23 +46,15 @@ const SettingTimeZone: FC<{ </button> ) } -const AutoUpdateSetting: FC<Props> = ({ - payload, - onChange, -}) => { +const AutoUpdateSetting: FC<Props> = ({ payload, onChange }) => { const { t } = useTranslation() const { data: timezone } = useQuery({ ...userProfileQueryOptions(), - select: data => data.profile.timezone ?? undefined, + select: (data) => data.profile.timezone ?? undefined, }) - const { - strategy_setting, - upgrade_time_of_day, - upgrade_mode, - exclude_plugins, - include_plugins, - } = payload + const { strategy_setting, upgrade_time_of_day, upgrade_mode, exclude_plugins, include_plugins } = + payload const minuteFilter = useCallback((minutes: string[]) => { return minutes.filter((m) => { @@ -70,9 +65,9 @@ const AutoUpdateSetting: FC<Props> = ({ const strategyDescription = useMemo(() => { switch (strategy_setting) { case AUTO_UPDATE_STRATEGY.fixOnly: - return t($ => $[`${i18nPrefix}.strategy.fixOnly.selectedDescription`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.strategy.fixOnly.selectedDescription`], { ns: 'plugin' }) case AUTO_UPDATE_STRATEGY.latest: - return t($ => $[`${i18nPrefix}.strategy.latest.selectedDescription`], { ns: 'plugin' }) + return t(($) => $[`${i18nPrefix}.strategy.latest.selectedDescription`], { ns: 'plugin' }) default: return '' } @@ -88,94 +83,129 @@ const AutoUpdateSetting: FC<Props> = ({ return [] } }, [upgrade_mode, exclude_plugins, include_plugins]) - const scopeOptions = useMemo(() => [ - { - value: AUTO_UPDATE_MODE.update_all, - label: t($ => $[`${i18nPrefix}.upgradeMode.${AUTO_UPDATE_MODE.update_all}`], { ns: 'plugin' }), - }, - { - value: AUTO_UPDATE_MODE.exclude, - label: t($ => $[`${i18nPrefix}.upgradeMode.${AUTO_UPDATE_MODE.exclude}`], { ns: 'plugin' }), + const scopeOptions = useMemo( + () => [ + { + value: AUTO_UPDATE_MODE.update_all, + label: t(($) => $[`${i18nPrefix}.upgradeMode.${AUTO_UPDATE_MODE.update_all}`], { + ns: 'plugin', + }), + }, + { + value: AUTO_UPDATE_MODE.exclude, + label: t(($) => $[`${i18nPrefix}.upgradeMode.${AUTO_UPDATE_MODE.exclude}`], { + ns: 'plugin', + }), + }, + { + value: AUTO_UPDATE_MODE.partial, + label: t(($) => $[`${i18nPrefix}.upgradeMode.${AUTO_UPDATE_MODE.partial}`], { + ns: 'plugin', + }), + }, + ], + [t], + ) + + const handlePluginsChange = useCallback( + (newPlugins: string[]) => { + if (upgrade_mode === AUTO_UPDATE_MODE.partial) { + onChange({ + ...payload, + include_plugins: newPlugins, + }) + } else if (upgrade_mode === AUTO_UPDATE_MODE.exclude) { + onChange({ + ...payload, + exclude_plugins: newPlugins, + }) + } }, - { - value: AUTO_UPDATE_MODE.partial, - label: t($ => $[`${i18nPrefix}.upgradeMode.${AUTO_UPDATE_MODE.partial}`], { ns: 'plugin' }), + [payload, upgrade_mode, onChange], + ) + const handleChange = useCallback( + (key: keyof AutoUpdateConfig) => { + return (value: AutoUpdateConfig[keyof AutoUpdateConfig]) => { + onChange({ + ...payload, + [key]: value, + }) + } }, - ], [t]) - - const handlePluginsChange = useCallback((newPlugins: string[]) => { - if (upgrade_mode === AUTO_UPDATE_MODE.partial) { - onChange({ - ...payload, - include_plugins: newPlugins, - }) - } - else if (upgrade_mode === AUTO_UPDATE_MODE.exclude) { - onChange({ - ...payload, - exclude_plugins: newPlugins, - }) - } - }, [payload, upgrade_mode, onChange]) - const handleChange = useCallback((key: keyof AutoUpdateConfig) => { - return (value: AutoUpdateConfig[keyof AutoUpdateConfig]) => { - onChange({ - ...payload, - [key]: value, - }) - } - }, [payload, onChange]) + [payload, onChange], + ) - const renderTimePickerTrigger = useCallback(({ inputElem, onClick, isOpen }: TriggerParams) => { - return ( - <button - type="button" - className="group flex h-8 w-[160px] cursor-pointer items-center justify-between rounded-lg border-none bg-components-input-bg-normal px-2 text-left hover:bg-state-base-hover-alt focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={onClick} - > - <div className="flex w-0 grow items-center gap-x-1"> - <RiTimeLine className={cn( - 'size-4 shrink-0 text-text-tertiary', - isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', - )} - /> - {inputElem} - </div> - <div className="system-sm-regular text-text-tertiary">{convertTimezoneToOffsetStr(timezone)}</div> - </button> - ) - }, [timezone]) + const renderTimePickerTrigger = useCallback( + ({ inputElem, onClick, isOpen }: TriggerParams) => { + return ( + <button + type="button" + className="group flex h-8 w-[160px] cursor-pointer items-center justify-between rounded-lg border-none bg-components-input-bg-normal px-2 text-left hover:bg-state-base-hover-alt focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={onClick} + > + <div className="flex w-0 grow items-center gap-x-1"> + <RiTimeLine + className={cn( + 'size-4 shrink-0 text-text-tertiary', + isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', + )} + /> + {inputElem} + </div> + <div className="system-sm-regular text-text-tertiary"> + {convertTimezoneToOffsetStr(timezone)} + </div> + </button> + ) + }, + [timezone], + ) return ( <div className="self-stretch px-6"> <div className="my-3 flex items-center"> - <div className="system-xs-medium-uppercase text-text-tertiary">{t($ => $[`${i18nPrefix}.updateSettings`], { ns: 'plugin' })}</div> + <div className="system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.updateSettings`], { ns: 'plugin' })} + </div> <div className="ml-2 h-px grow bg-divider-subtle"></div> </div> <div className="space-y-4"> <div className="flex items-center justify-between"> - <Label label={t($ => $[`${i18nPrefix}.automaticUpdates`], { ns: 'plugin' })} description={strategyDescription} /> + <Label + label={t(($) => $[`${i18nPrefix}.automaticUpdates`], { ns: 'plugin' })} + description={strategyDescription} + /> <StrategyPicker value={strategy_setting} onChange={handleChange('strategy_setting')} /> </div> {strategy_setting !== AUTO_UPDATE_STRATEGY.disabled && ( <> <div className="flex items-center justify-between"> - <Label label={t($ => $[`${i18nPrefix}.updateTime`], { ns: 'plugin' })} /> + <Label label={t(($) => $[`${i18nPrefix}.updateTime`], { ns: 'plugin' })} /> <div className="flex flex-col items-end"> <TimePicker - value={timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(upgrade_time_of_day, timezone!))} + value={timeOfDayToDayjs( + convertUTCDaySecondsToLocalSeconds(upgrade_time_of_day, timezone!), + )} timezone={timezone} - onChange={v => handleChange('upgrade_time_of_day')(convertLocalSecondsToUTCDaySeconds(dayjsToTimeOfDay(v), timezone!))} - onClear={() => handleChange('upgrade_time_of_day')(convertLocalSecondsToUTCDaySeconds(0, timezone!))} - title={t($ => $[`${i18nPrefix}.updateTime`], { ns: 'plugin' })} + onChange={(v) => + handleChange('upgrade_time_of_day')( + convertLocalSecondsToUTCDaySeconds(dayjsToTimeOfDay(v), timezone!), + ) + } + onClear={() => + handleChange('upgrade_time_of_day')( + convertLocalSecondsToUTCDaySeconds(0, timezone!), + ) + } + title={t(($) => $[`${i18nPrefix}.updateTime`], { ns: 'plugin' })} minuteFilter={minuteFilter} renderTrigger={renderTimePickerTrigger} placement="bottom-end" /> <div className="mt-1 text-right body-xs-regular text-text-tertiary"> <Trans - i18nKey={$ => $[`${i18nPrefix}.changeTimezone`]} + i18nKey={($) => $[`${i18nPrefix}.changeTimezone`]} ns="plugin" components={{ setTimezone: <SettingTimeZone />, @@ -185,18 +215,19 @@ const AutoUpdateSetting: FC<Props> = ({ </div> </div> <div> - <Label label={t($ => $[`${i18nPrefix}.specifyPluginsToUpdate`], { ns: 'plugin' })} /> + <Label + label={t(($) => $[`${i18nPrefix}.specifyPluginsToUpdate`], { ns: 'plugin' })} + /> <SegmentedControl<AUTO_UPDATE_MODE> - aria-label={t($ => $[`${i18nPrefix}.specifyPluginsToUpdate`], { ns: 'plugin' })} + aria-label={t(($) => $[`${i18nPrefix}.specifyPluginsToUpdate`], { ns: 'plugin' })} className="mt-1 flex w-full" value={[upgrade_mode]} onValueChange={(nextValue) => { const selectedValue = nextValue[0] - if (selectedValue) - handleChange('upgrade_mode')(selectedValue) + if (selectedValue) handleChange('upgrade_mode')(selectedValue) }} > - {scopeOptions.map(option => ( + {scopeOptions.map((option) => ( <SegmentedControlItem<AUTO_UPDATE_MODE> key={option.value} value={option.value} diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/no-data-placeholder.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/no-data-placeholder.tsx index 2674eea4e77c21..a02f6b6c94e049 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/no-data-placeholder.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/no-data-placeholder.tsx @@ -11,13 +11,17 @@ type Props = Readonly<{ noPlugins?: boolean }> -const NoDataPlaceholder: FC<Props> = ({ - className, - noPlugins, -}) => { +const NoDataPlaceholder: FC<Props> = ({ className, noPlugins }) => { const { t } = useTranslation() - const icon = noPlugins ? (<Group className="size-6 text-text-quaternary" />) : (<SearchMenu className="size-8 text-text-tertiary" />) - const text = t($ => $[`autoUpdate.noPluginPlaceholder.${noPlugins ? 'noInstalled' : 'noFound'}`], { ns: 'plugin' }) + const icon = noPlugins ? ( + <Group className="size-6 text-text-quaternary" /> + ) : ( + <SearchMenu className="size-8 text-text-tertiary" /> + ) + const text = t( + ($) => $[`autoUpdate.noPluginPlaceholder.${noPlugins ? 'noInstalled' : 'noFound'}`], + { ns: 'plugin' }, + ) return ( <div className={cn('flex items-center justify-center', className)}> <div className="flex flex-col items-center"> diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/no-plugin-selected.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/no-plugin-selected.tsx index 24d89f536a12b6..60b7d7b35fda54 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/no-plugin-selected.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/no-plugin-selected.tsx @@ -8,15 +8,9 @@ type Props = Readonly<{ updateMode: AUTO_UPDATE_MODE }> -const NoPluginSelected: FC<Props> = ({ - updateMode, -}) => { +const NoPluginSelected: FC<Props> = ({ updateMode }) => { const { t } = useTranslation() - const text = `${t($ => $[`autoUpdate.upgradeModePlaceholder.${updateMode === AUTO_UPDATE_MODE.partial ? 'partial' : 'exclude'}`], { ns: 'plugin' })}` - return ( - <div className="text-center system-xs-regular text-text-tertiary"> - {text} - </div> - ) + const text = `${t(($) => $[`autoUpdate.upgradeModePlaceholder.${updateMode === AUTO_UPDATE_MODE.partial ? 'partial' : 'exclude'}`], { ns: 'plugin' })}` + return <div className="text-center system-xs-regular text-text-tertiary">{text}</div> } export default React.memo(NoPluginSelected) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx index 160f174a49f37a..c33286d09388f1 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx @@ -22,85 +22,80 @@ type Props = Readonly<{ integrationCategory?: PluginCategoryEnum }> -const PluginsPicker: FC<Props> = ({ - updateMode, - value, - onChange, - integrationCategory, -}) => { +const PluginsPicker: FC<Props> = ({ updateMode, value, onChange, integrationCategory }) => { const { t } = useTranslation() const { data } = useInstalledPluginList() const pluginCategoryById = useMemo(() => { - return new Map(data?.plugins.map(plugin => [plugin.plugin_id, plugin.declaration.category])) + return new Map(data?.plugins.map((plugin) => [plugin.plugin_id, plugin.declaration.category])) }, [data?.plugins]) - const isCurrentCategoryPlugin = useCallback((pluginId: string) => { - if (!integrationCategory) - return true + const isCurrentCategoryPlugin = useCallback( + (pluginId: string) => { + if (!integrationCategory) return true - return pluginCategoryById.get(pluginId) === integrationCategory - }, [integrationCategory, pluginCategoryById]) + return pluginCategoryById.get(pluginId) === integrationCategory + }, + [integrationCategory, pluginCategoryById], + ) const visiblePlugins = useMemo(() => { return value.filter(isCurrentCategoryPlugin) }, [isCurrentCategoryPlugin, value]) const hiddenPlugins = useMemo(() => { - if (!integrationCategory) - return [] + if (!integrationCategory) return [] - return value.filter(plugin => !isCurrentCategoryPlugin(plugin)) + return value.filter((plugin) => !isCurrentCategoryPlugin(plugin)) }, [integrationCategory, isCurrentCategoryPlugin, value]) const hasSelected = visiblePlugins.length > 0 const isExcludeMode = updateMode === AUTO_UPDATE_MODE.exclude const handleClear = () => { onChange(hiddenPlugins) } - const handleVisiblePluginsChange = useCallback((newVisiblePlugins: string[]) => { - onChange(integrationCategory - ? [...hiddenPlugins, ...newVisiblePlugins.filter(plugin => !hiddenPlugins.includes(plugin))] - : newVisiblePlugins) - }, [hiddenPlugins, integrationCategory, onChange]) + const handleVisiblePluginsChange = useCallback( + (newVisiblePlugins: string[]) => { + onChange( + integrationCategory + ? [ + ...hiddenPlugins, + ...newVisiblePlugins.filter((plugin) => !hiddenPlugins.includes(plugin)), + ] + : newVisiblePlugins, + ) + }, + [hiddenPlugins, integrationCategory, onChange], + ) - const [isShowToolPicker, { - set: setToolPicker, - }] = useBoolean(false) + const [isShowToolPicker, { set: setToolPicker }] = useBoolean(false) return ( <div className="mt-2 flex w-full flex-col gap-2 rounded-[10px] bg-background-section-burn p-2.5"> - {hasSelected - ? ( - <div className="flex items-center justify-between gap-3"> - <div className="min-w-0 flex-1 system-xs-medium text-text-tertiary"> - {t($ => $[`${i18nPrefix}.${isExcludeMode ? 'excludeUpdate' : 'partialUPdate'}`], { - ns: 'plugin', - count: visiblePlugins.length, - num: visiblePlugins.length, - })} - </div> - <button - type="button" - className="shrink-0 cursor-pointer border-none bg-transparent p-0 text-left system-xs-medium text-text-tertiary hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={handleClear} - > - {t($ => $[`${i18nPrefix}.operation.clearAll`], { ns: 'plugin' })} - </button> - </div> - ) - : ( - <NoPluginSelected updateMode={updateMode} /> - )} - - {hasSelected && ( - <PluginsSelected - className="h-6 w-full gap-1" - plugins={visiblePlugins} - /> + {hasSelected ? ( + <div className="flex items-center justify-between gap-3"> + <div className="min-w-0 flex-1 system-xs-medium text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.${isExcludeMode ? 'excludeUpdate' : 'partialUPdate'}`], { + ns: 'plugin', + count: visiblePlugins.length, + num: visiblePlugins.length, + })} + </div> + <button + type="button" + className="shrink-0 cursor-pointer border-none bg-transparent p-0 text-left system-xs-medium text-text-tertiary hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleClear} + > + {t(($) => $[`${i18nPrefix}.operation.clearAll`], { ns: 'plugin' })} + </button> + </div> + ) : ( + <NoPluginSelected updateMode={updateMode} /> )} + {hasSelected && <PluginsSelected className="h-6 w-full gap-1" plugins={visiblePlugins} />} + <ToolPicker - trigger={( + trigger={ <Button className="h-6 w-full gap-1" size="small" variant="secondary-accent"> <RiAddLine className="size-3.5" /> - {t($ => $[`${i18nPrefix}.operation.select`], { ns: 'plugin' })} + {t(($) => $[`${i18nPrefix}.operation.select`], { ns: 'plugin' })} </Button> - )} + } value={visiblePlugins} onChange={handleVisiblePluginsChange} isShow={isShowToolPicker} diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-selected.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-selected.tsx index 061a02d45254d1..a33206f2d5a8b5 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-selected.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-selected.tsx @@ -11,22 +11,16 @@ type Props = Readonly<{ plugins: string[] }> -const PluginsSelected: FC<Props> = ({ - className, - plugins, -}) => { +const PluginsSelected: FC<Props> = ({ className, plugins }) => { const displayPlugins = plugins.slice(0, MAX_DISPLAY_COUNT) const hiddenPluginsCount = plugins.length - displayPlugins.length return ( <div className={cn('flex min-w-0 items-center overflow-hidden', className)}> - {displayPlugins.map(plugin => ( + {displayPlugins.map((plugin) => ( <Icon key={plugin} size="tiny" src={`${MARKETPLACE_API_PREFIX}/plugins/${plugin}/icon`} /> ))} {hiddenPluginsCount > 0 && ( - <div className="shrink-0 system-xs-medium text-text-tertiary"> - + - {hiddenPluginsCount} - </div> + <div className="shrink-0 system-xs-medium text-text-tertiary">+{hiddenPluginsCount}</div> )} </div> ) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx index 49f7e39db4372e..05b8b0443f6389 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx @@ -8,38 +8,34 @@ type Props = Readonly<{ value: AUTO_UPDATE_STRATEGY onChange: (value: AUTO_UPDATE_STRATEGY) => void }> -const StrategyPicker = ({ - value, - onChange, -}: Props) => { +const StrategyPicker = ({ value, onChange }: Props) => { const { t } = useTranslation() const options = [ { value: AUTO_UPDATE_STRATEGY.disabled, - label: t($ => $[`${i18nPrefix}.disabled.name`], { ns: 'plugin' }), + label: t(($) => $[`${i18nPrefix}.disabled.name`], { ns: 'plugin' }), }, { value: AUTO_UPDATE_STRATEGY.fixOnly, - label: t($ => $[`${i18nPrefix}.fixOnly.name`], { ns: 'plugin' }), + label: t(($) => $[`${i18nPrefix}.fixOnly.name`], { ns: 'plugin' }), }, { value: AUTO_UPDATE_STRATEGY.latest, - label: t($ => $[`${i18nPrefix}.latest.name`], { ns: 'plugin' }), + label: t(($) => $[`${i18nPrefix}.latest.name`], { ns: 'plugin' }), }, ] return ( <SegmentedControl<AUTO_UPDATE_STRATEGY> - aria-label={t($ => $['autoUpdate.automaticUpdates'], { ns: 'plugin' })} + aria-label={t(($) => $['autoUpdate.automaticUpdates'], { ns: 'plugin' })} className="w-[326px]" value={[value]} onValueChange={(nextValue) => { const selectedValue = nextValue[0] - if (selectedValue) - onChange(selectedValue) + if (selectedValue) onChange(selectedValue) }} > - {options.map(option => ( + {options.map((option) => ( <SegmentedControlItem<AUTO_UPDATE_STRATEGY> key={option.value} value={option.value} diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-item.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-item.tsx index 0d888bf7cf90bf..dce950c04966b2 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-item.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-item.tsx @@ -14,22 +14,18 @@ type Props = Readonly<{ onCheckChange: () => void }> -const ToolItem: FC<Props> = ({ - payload, - isChecked, - onCheckChange, -}) => { +const ToolItem: FC<Props> = ({ payload, isChecked, onCheckChange }) => { const language = useGetLanguage() const { plugin_id, declaration } = payload const { label, author: org } = declaration return ( - <div - className="flex w-full items-center gap-1 rounded-lg py-1 pr-2 pl-3 select-none hover:bg-state-base-hover" - > + <div className="flex w-full items-center gap-1 rounded-lg py-1 pr-2 pl-3 select-none hover:bg-state-base-hover"> <div className="flex min-w-0 grow items-center gap-2 pr-2"> <Icon size="tiny" src={`${MARKETPLACE_API_PREFIX}/plugins/${plugin_id}/icon`} /> - <div className="min-w-0 truncate system-sm-medium text-text-secondary">{renderI18nObject(label, language)}</div> + <div className="min-w-0 truncate system-sm-medium text-text-secondary"> + {renderI18nObject(label, language)} + </div> <div className="min-w-0 truncate system-xs-regular text-text-quaternary">{org}</div> </div> <Checkbox diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx index ec08463d123f31..686a4740d12b50 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx @@ -3,11 +3,7 @@ import type { FC } from 'react' import type { ActivePluginType } from '../../marketplace/constants' import type { PluginCategoryEnum } from '../../types' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -39,17 +35,26 @@ const ToolPicker: FC<Props> = ({ const { t } = useTranslation() const allTabs = [ - { key: PLUGIN_TYPE_SEARCH_MAP.all, name: t($ => $['category.all'], { ns: 'plugin' }) }, - { key: PLUGIN_TYPE_SEARCH_MAP.model, name: t($ => $['category.models'], { ns: 'plugin' }) }, - { key: PLUGIN_TYPE_SEARCH_MAP.tool, name: t($ => $['category.tools'], { ns: 'plugin' }) }, - { key: PLUGIN_TYPE_SEARCH_MAP.agent, name: t($ => $['category.agents'], { ns: 'plugin' }) }, - { key: PLUGIN_TYPE_SEARCH_MAP.extension, name: t($ => $['category.extensions'], { ns: 'plugin' }) }, - { key: PLUGIN_TYPE_SEARCH_MAP.datasource, name: t($ => $['category.datasources'], { ns: 'plugin' }) }, - { key: PLUGIN_TYPE_SEARCH_MAP.trigger, name: t($ => $['category.triggers'], { ns: 'plugin' }) }, - { key: PLUGIN_TYPE_SEARCH_MAP.bundle, name: t($ => $['category.bundles'], { ns: 'plugin' }) }, + { key: PLUGIN_TYPE_SEARCH_MAP.all, name: t(($) => $['category.all'], { ns: 'plugin' }) }, + { key: PLUGIN_TYPE_SEARCH_MAP.model, name: t(($) => $['category.models'], { ns: 'plugin' }) }, + { key: PLUGIN_TYPE_SEARCH_MAP.tool, name: t(($) => $['category.tools'], { ns: 'plugin' }) }, + { key: PLUGIN_TYPE_SEARCH_MAP.agent, name: t(($) => $['category.agents'], { ns: 'plugin' }) }, + { + key: PLUGIN_TYPE_SEARCH_MAP.extension, + name: t(($) => $['category.extensions'], { ns: 'plugin' }), + }, + { + key: PLUGIN_TYPE_SEARCH_MAP.datasource, + name: t(($) => $['category.datasources'], { ns: 'plugin' }), + }, + { + key: PLUGIN_TYPE_SEARCH_MAP.trigger, + name: t(($) => $['category.triggers'], { ns: 'plugin' }), + }, + { key: PLUGIN_TYPE_SEARCH_MAP.bundle, name: t(($) => $['category.bundles'], { ns: 'plugin' }) }, ] const tabs = integrationCategory - ? allTabs.filter(tab => tab.key === integrationCategory) + ? allTabs.filter((tab) => tab.key === integrationCategory) : allTabs const [pluginType, setPluginType] = useState<ActivePluginType>(PLUGIN_TYPE_SEARCH_MAP.all) @@ -62,23 +67,25 @@ const ToolPicker: FC<Props> = ({ return list.filter((plugin) => { const isFromMarketPlace = plugin.source === PluginSource.marketplace return ( - isFromMarketPlace && (effectivePluginType === PLUGIN_TYPE_SEARCH_MAP.all || plugin.declaration.category === effectivePluginType) - && (tags.length === 0 || tags.some(tag => plugin.declaration.tags.includes(tag))) - && (query === '' || plugin.plugin_id.toLowerCase().includes(query.toLowerCase())) + isFromMarketPlace && + (effectivePluginType === PLUGIN_TYPE_SEARCH_MAP.all || + plugin.declaration.category === effectivePluginType) && + (tags.length === 0 || tags.some((tag) => plugin.declaration.tags.includes(tag))) && + (query === '' || plugin.plugin_id.toLowerCase().includes(query.toLowerCase())) ) }) }, [data, effectivePluginType, query, tags]) const handleCheckChange = (pluginId: string) => { const newValue = value.includes(pluginId) - ? value.filter(id => id !== pluginId) + ? value.filter((id) => id !== pluginId) : [...value, pluginId] onChange(newValue) } const listContent = ( <div className="max-h-[396px] overflow-y-auto p-1"> - {filteredList.map(item => ( + {filteredList.map((item) => ( <ToolItem key={item.plugin_id} payload={item} @@ -95,9 +102,7 @@ const ToolPicker: FC<Props> = ({ </div> ) - const noData = ( - <NoDataPlaceholder className="h-[396px]" noPlugins={!query} /> - ) + const noData = <NoDataPlaceholder className="h-[396px]" noPlugins={!query} /> const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div> @@ -117,17 +122,18 @@ const ToolPicker: FC<Props> = ({ onSearchChange={setQuery} tags={tags} onTagsChange={setTags} - placeholder={t($ => $.searchTools, { ns: 'plugin' })!} + placeholder={t(($) => $.searchTools, { ns: 'plugin' })!} inputClassName="w-full" /> </div> <div className="flex items-center justify-between bg-components-panel-bg px-3 pb-2"> <div className="flex min-w-0 items-center gap-0.5 overflow-x-auto"> - {tabs.map(tab => ( + {tabs.map((tab) => ( <div className={cn( 'flex h-6 shrink-0 cursor-pointer items-center rounded-md px-2 system-xs-medium text-text-tertiary hover:bg-state-base-hover', - effectivePluginType === tab.key && 'bg-state-base-hover-alt system-xs-semibold text-text-primary', + effectivePluginType === tab.key && + 'bg-state-base-hover-alt system-xs-semibold text-text-primary', )} key={tab.key} onClick={() => setPluginType(tab.key)} diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/utils.ts b/web/app/components/plugins/reference-setting-modal/auto-update-setting/utils.ts index e6a8ebee59e2da..93eb3affb86958 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/utils.ts +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/utils.ts @@ -13,7 +13,10 @@ export const timeOfDayToDayjs = (timeOfDay: number): Dayjs => { return res } -export const convertLocalSecondsToUTCDaySeconds = (secondsInDay: number, localTimezone: string): number => { +export const convertLocalSecondsToUTCDaySeconds = ( + secondsInDay: number, + localTimezone: string, +): number => { const localDayStart = dayjs().tz(localTimezone).startOf('day') const localTargetTime = localDayStart.add(secondsInDay, 'second') const utcTargetTime = localTargetTime.utc() @@ -23,12 +26,14 @@ export const convertLocalSecondsToUTCDaySeconds = (secondsInDay: number, localTi } export const dayjsToTimeOfDay = (date?: Dayjs): number => { - if (!date) - return 0 + if (!date) return 0 return date.hour() * 3600 + date.minute() * 60 } -export const convertUTCDaySecondsToLocalSeconds = (utcDaySeconds: number, localTimezone: string): number => { +export const convertUTCDaySecondsToLocalSeconds = ( + utcDaySeconds: number, + localTimezone: string, +): number => { const utcDayStart = dayjs().utc().startOf('day') const utcTargetTime = utcDayStart.add(utcDaySeconds, 'second') const localTargetTime = utcTargetTime.tz(localTimezone) diff --git a/web/app/components/plugins/reference-setting-modal/index.tsx b/web/app/components/plugins/reference-setting-modal/index.tsx index 615691a51e49aa..a49e01186240ac 100644 --- a/web/app/components/plugins/reference-setting-modal/index.tsx +++ b/web/app/components/plugins/reference-setting-modal/index.tsx @@ -42,35 +42,51 @@ const PluginSettingModal: FC<Props> = ({ const { t } = useTranslation() const { auto_upgrade: autoUpdateConfig, permission: privilege } = payload || {} const [tempPrivilege, setTempPrivilege] = useState<Permissions>(privilege) - const [tempAutoUpdateConfig, setTempAutoUpdateConfig] = useState<AutoUpdateConfig>(autoUpdateConfig || autoUpdateDefaultValue) + const [tempAutoUpdateConfig, setTempAutoUpdateConfig] = useState<AutoUpdateConfig>( + autoUpdateConfig || autoUpdateDefaultValue, + ) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isPermissionDisabledByRBAC = systemFeatures.rbac_enabled - const permissionDisabledTip = t($ => $[`${i18nPrefix}.configurePermissionsInSettings`], { ns: 'plugin' }) - const handlePrivilegeChange = useCallback((key: string) => { - return (value: PermissionType) => { - if (isPermissionDisabledByRBAC) - return - setTempPrivilege({ - ...tempPrivilege, - [key]: value, - }) - } - }, [isPermissionDisabledByRBAC, tempPrivilege]) + const permissionDisabledTip = t(($) => $[`${i18nPrefix}.configurePermissionsInSettings`], { + ns: 'plugin', + }) + const handlePrivilegeChange = useCallback( + (key: string) => { + return (value: PermissionType) => { + if (isPermissionDisabledByRBAC) return + setTempPrivilege({ + ...tempPrivilege, + [key]: value, + }) + } + }, + [isPermissionDisabledByRBAC, tempPrivilege], + ) const handleSave = useCallback(async () => { await onSave({ - permission: canSetPermissions ? tempPrivilege : (privilege || tempPrivilege), - auto_upgrade: canSetAutoUpdate ? tempAutoUpdateConfig : (autoUpdateConfig || tempAutoUpdateConfig), + permission: canSetPermissions ? tempPrivilege : privilege || tempPrivilege, + auto_upgrade: canSetAutoUpdate + ? tempAutoUpdateConfig + : autoUpdateConfig || tempAutoUpdateConfig, }) onHide() - }, [autoUpdateConfig, canSetAutoUpdate, canSetPermissions, onHide, onSave, privilege, tempAutoUpdateConfig, tempPrivilege]) + }, [ + autoUpdateConfig, + canSetAutoUpdate, + canSetPermissions, + onHide, + onSave, + privilege, + tempAutoUpdateConfig, + tempPrivilege, + ]) return ( <Dialog open onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DialogContent className="w-155 max-w-155 overflow-hidden! border-none p-0! text-left align-middle"> @@ -78,13 +94,23 @@ const PluginSettingModal: FC<Props> = ({ <div className="shadows-shadow-xl flex w-full flex-col items-start rounded-2xl border border-components-panel-border bg-components-panel-bg"> <div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6"> - <span className="self-stretch title-2xl-semi-bold text-text-primary">{t($ => $[`${i18nPrefix}.title`], { ns: 'plugin' })}</span> + <span className="self-stretch title-2xl-semi-bold text-text-primary"> + {t(($) => $[`${i18nPrefix}.title`], { ns: 'plugin' })} + </span> </div> {canSetPermissions && ( <div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3"> {[ - { title: t($ => $[`${i18nPrefix}.whoCanInstall`], { ns: 'plugin' }), key: 'install_permission', value: tempPrivilege?.install_permission || PermissionType.noOne }, - { title: t($ => $[`${i18nPrefix}.whoCanDebug`], { ns: 'plugin' }), key: 'debug_permission', value: tempPrivilege?.debug_permission || PermissionType.noOne }, + { + title: t(($) => $[`${i18nPrefix}.whoCanInstall`], { ns: 'plugin' }), + key: 'install_permission', + value: tempPrivilege?.install_permission || PermissionType.noOne, + }, + { + title: t(($) => $[`${i18nPrefix}.whoCanDebug`], { ns: 'plugin' }), + key: 'debug_permission', + value: tempPrivilege?.debug_permission || PermissionType.noOne, + }, ].map(({ title, key, value }) => ( <div key={key} className="flex flex-col items-start gap-1 self-stretch"> <Label @@ -92,39 +118,32 @@ const PluginSettingModal: FC<Props> = ({ tooltip={isPermissionDisabledByRBAC ? permissionDisabledTip : undefined} /> <div className="flex w-full items-start justify-between gap-2"> - {[PermissionType.everyone, PermissionType.admin, PermissionType.noOne].map(option => ( - <OptionCard - key={option} - title={t($ => $[`${i18nPrefix}.${option}`], { ns: 'plugin' })} - onSelect={() => handlePrivilegeChange(key)(option)} - selected={value === option} - disabled={isPermissionDisabledByRBAC} - className="flex-1" - /> - ))} + {[PermissionType.everyone, PermissionType.admin, PermissionType.noOne].map( + (option) => ( + <OptionCard + key={option} + title={t(($) => $[`${i18nPrefix}.${option}`], { ns: 'plugin' })} + onSelect={() => handlePrivilegeChange(key)(option)} + selected={value === option} + disabled={isPermissionDisabledByRBAC} + className="flex-1" + /> + ), + )} </div> </div> ))} </div> )} - { - systemFeatures.enable_marketplace && canSetAutoUpdate && ( - <AutoUpdateSetting payload={tempAutoUpdateConfig} onChange={setTempAutoUpdateConfig} /> - ) - } + {systemFeatures.enable_marketplace && canSetAutoUpdate && ( + <AutoUpdateSetting payload={tempAutoUpdateConfig} onChange={setTempAutoUpdateConfig} /> + )} <div className="flex h-19 items-center justify-end gap-2 self-stretch p-6 pt-5"> - <Button - className="min-w-18" - onClick={onHide} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button className="min-w-18" onClick={onHide}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button - className="min-w-18" - variant="primary" - onClick={handleSave} - > - {t($ => $['operation.save'], { ns: 'common' })} + <Button className="min-w-18" variant="primary" onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </div> diff --git a/web/app/components/plugins/reference-setting-modal/label.tsx b/web/app/components/plugins/reference-setting-modal/label.tsx index 4fc2415c34699e..efa80ca2aa4791 100644 --- a/web/app/components/plugins/reference-setting-modal/label.tsx +++ b/web/app/components/plugins/reference-setting-modal/label.tsx @@ -10,11 +10,7 @@ type Props = Readonly<{ tooltip?: string }> -const Label: FC<Props> = ({ - label, - description, - tooltip, -}) => { +const Label: FC<Props> = ({ label, description, tooltip }) => { const tooltipIcon = ( <span aria-label={tooltip} @@ -31,17 +27,11 @@ const Label: FC<Props> = ({ {tooltip && ( <Tooltip> <TooltipTrigger render={tooltipIcon} /> - <TooltipContent> - {tooltip} - </TooltipContent> + <TooltipContent>{tooltip}</TooltipContent> </Tooltip> )} </div> - {description && ( - <div className="mt-1 body-xs-regular text-text-tertiary"> - {description} - </div> - )} + {description && <div className="mt-1 body-xs-regular text-text-tertiary">{description}</div>} </div> ) } diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index 2e1ad4f89c5cc8..dea0689068d53a 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -225,7 +225,15 @@ export type PluginDetail = { } export type Plugin = { - type: 'plugin' | 'bundle' | 'model' | 'extension' | 'tool' | 'agent_strategy' | 'datasource' | 'trigger' + type: + | 'plugin' + | 'bundle' + | 'model' + | 'extension' + | 'tool' + | 'agent_strategy' + | 'datasource' + | 'trigger' org: string author?: string name: string @@ -536,7 +544,7 @@ const AgentFeature = { HISTORY_MESSAGES: 'history-messages', } as const -type AgentFeature = typeof AgentFeature[keyof typeof AgentFeature] +type AgentFeature = (typeof AgentFeature)[keyof typeof AgentFeature] type Identity = { author: string diff --git a/web/app/components/plugins/update-plugin/__tests__/downgrade-warning.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/downgrade-warning.spec.tsx index be446f98d167c6..2b72ed63d9673d 100644 --- a/web/app/components/plugins/update-plugin/__tests__/downgrade-warning.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/downgrade-warning.spec.tsx @@ -24,7 +24,9 @@ describe('DowngradeWarningModal', () => { />, ) expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.title')).toBeInTheDocument() - expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.description')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.description'), + ).toBeInTheDocument() }) it('renders three action buttons', () => { @@ -36,7 +38,9 @@ describe('DowngradeWarningModal', () => { />, ) expect(screen.getByText('app.newApp.Cancel')).toBeInTheDocument() - expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.downgrade')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.downgrade'), + ).toBeInTheDocument() expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.exclude')).toBeInTheDocument() }) diff --git a/web/app/components/plugins/update-plugin/__tests__/from-github.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/from-github.spec.tsx index 1ce1a1a0af0198..834e8cc5705779 100644 --- a/web/app/components/plugins/update-plugin/__tests__/from-github.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/from-github.spec.tsx @@ -3,15 +3,23 @@ import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({ - default: ({ updatePayload, onClose, onSuccess }: { + default: ({ + updatePayload, + onClose, + onSuccess, + }: { updatePayload?: Record<string, unknown> onClose: () => void onSuccess: () => void }) => ( <div data-testid="install-from-github"> <span data-testid="update-payload">{JSON.stringify(updatePayload)}</span> - <button data-testid="close-btn" onClick={onClose}>Close</button> - <button data-testid="success-btn" onClick={onSuccess}>Success</button> + <button data-testid="close-btn" onClick={onClose}> + Close + </button> + <button data-testid="success-btn" onClick={onSuccess}> + Success + </button> </div> ), })) diff --git a/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx index 4e9f1ce862b338..fb7946ad916574 100644 --- a/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx @@ -44,7 +44,11 @@ vi.mock('@langgenius/dify-ui/button', () => ({ children: React.ReactNode onClick?: () => void disabled?: boolean - }) => <button disabled={disabled} onClick={onClick}>{children}</button>, + }) => ( + <button disabled={disabled} onClick={onClick}> + {children} + </button> + ), })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -54,7 +58,13 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('@/app/components/plugins/card', () => ({ - default: ({ titleLeft, payload }: { titleLeft: React.ReactNode, payload: { label: Record<string, string> } }) => ( + default: ({ + titleLeft, + payload, + }: { + titleLeft: React.ReactNode + payload: { label: Record<string, string> } + }) => ( <div data-testid="plugin-card"> <div>{payload.label.en_US}</div> <div>{titleLeft}</div> @@ -110,7 +120,9 @@ vi.mock('../downgrade-warning', () => ({ ), })) -const createPayload = (overrides: Partial<UpdateFromMarketPlacePayload> = {}): UpdateFromMarketPlacePayload => ({ +const createPayload = ( + overrides: Partial<UpdateFromMarketPlacePayload> = {}, +): UpdateFromMarketPlacePayload => ({ category: PluginCategoryEnum.tool, originalPackageInfo: { id: 'plugin@1.0.0', @@ -138,13 +150,7 @@ describe('UpdateFromMarketplace', () => { }) it('renders the upgrade modal content and current version transition', async () => { - render( - <UpdateFromMarketplace - payload={createPayload()} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + render(<UpdateFromMarketplace payload={createPayload()} onSave={vi.fn()} onCancel={vi.fn()} />) expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument() expect(screen.getByText('plugin.upgrade.description')).toBeInTheDocument() @@ -156,13 +162,7 @@ describe('UpdateFromMarketplace', () => { it('submits the marketplace upgrade and calls onSave when installation is immediate', async () => { const onSave = vi.fn() - render( - <UpdateFromMarketplace - payload={createPayload()} - onSave={onSave} - onCancel={vi.fn()} - />, - ) + render(<UpdateFromMarketplace payload={createPayload()} onSave={onSave} onCancel={vi.fn()} />) fireEvent.click(screen.getByText('plugin.upgrade.upgrade')) @@ -179,20 +179,16 @@ describe('UpdateFromMarketplace', () => { mockUpdateFromMarketPlace.mockResolvedValue({ task: { status: TaskStatus.failed, - plugins: [{ - plugin_unique_identifier: 'plugin@2.0.0', - message: 'upgrade failed', - }], + plugins: [ + { + plugin_unique_identifier: 'plugin@2.0.0', + message: 'upgrade failed', + }, + ], }, }) - render( - <UpdateFromMarketplace - payload={createPayload()} - onSave={vi.fn()} - onCancel={vi.fn()} - />, - ) + render(<UpdateFromMarketplace payload={createPayload()} onSave={vi.fn()} onCancel={vi.fn()} />) fireEvent.click(screen.getByText('plugin.upgrade.upgrade')) @@ -215,7 +211,10 @@ describe('UpdateFromMarketplace', () => { fireEvent.click(screen.getByText('exclude and downgrade')) await waitFor(() => { - expect(mockRemoveAutoUpgrade).toHaveBeenCalledWith({ plugin_id: 'plugin-1', category: PluginCategoryEnum.tool }) + expect(mockRemoveAutoUpgrade).toHaveBeenCalledWith({ + plugin_id: 'plugin-1', + category: PluginCategoryEnum.tool, + }) expect(mockUpdateFromMarketPlace).toHaveBeenCalled() }) }) diff --git a/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx index 0813a3fa90a39a..6ead3397bd7976 100644 --- a/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx @@ -79,15 +79,23 @@ const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error') // Mock InstallFromGitHub component vi.mock('../../install-plugin/install-from-github', () => ({ - default: ({ updatePayload, onClose, onSuccess }: { + default: ({ + updatePayload, + onClose, + onSuccess, + }: { updatePayload: UpdateFromGitHubPayload onClose: () => void onSuccess: () => void }) => ( <div data-testid="install-from-github"> <span data-testid="github-payload">{JSON.stringify(updatePayload)}</span> - <button data-testid="github-close" onClick={onClose}>Close</button> - <button data-testid="github-success" onClick={onSuccess}>Success</button> + <button data-testid="github-close" onClick={onClose}> + Close + </button> + <button data-testid="github-success" onClick={onSuccess}> + Success + </button> </div> ), })) @@ -96,7 +104,9 @@ vi.mock('../../install-plugin/install-from-github', () => ({ // Test Data Factories // ================================ -const createMockPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ +const createMockPluginDeclaration = ( + overrides: Partial<PluginDeclaration> = {}, +): PluginDeclaration => ({ plugin_unique_identifier: 'test-plugin-id', version: '1.0.0', author: 'test-author', @@ -134,7 +144,9 @@ const createMockPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}) ...overrides, }) -const createMockMarketPlacePayload = (overrides: Partial<UpdateFromMarketPlacePayload> = {}): UpdateFromMarketPlacePayload => ({ +const createMockMarketPlacePayload = ( + overrides: Partial<UpdateFromMarketPlacePayload> = {}, +): UpdateFromMarketPlacePayload => ({ category: PluginCategoryEnum.tool, originalPackageInfo: { id: 'original-id', @@ -147,15 +159,27 @@ const createMockMarketPlacePayload = (overrides: Partial<UpdateFromMarketPlacePa ...overrides, }) -const createMockGitHubPayload = (overrides: Partial<UpdateFromGitHubPayload> = {}): UpdateFromGitHubPayload => ({ +const createMockGitHubPayload = ( + overrides: Partial<UpdateFromGitHubPayload> = {}, +): UpdateFromGitHubPayload => ({ originalPackageInfo: { id: 'github-original-id', repo: 'owner/repo', version: '1.0.0', package: 'test-package.difypkg', releases: [ - { tag_name: 'v1.0.0', assets: [{ id: 1, name: 'plugin.difypkg', browser_download_url: 'https://github.com/test' }] }, - { tag_name: 'v2.0.0', assets: [{ id: 2, name: 'plugin.difypkg', browser_download_url: 'https://github.com/test' }] }, + { + tag_name: 'v1.0.0', + assets: [ + { id: 1, name: 'plugin.difypkg', browser_download_url: 'https://github.com/test' }, + ], + }, + { + tag_name: 'v2.0.0', + assets: [ + { id: 2, name: 'plugin.difypkg', browser_download_url: 'https://github.com/test' }, + ], + }, ], }, ...overrides, @@ -167,21 +191,18 @@ const createMockGitHubPayload = (overrides: Partial<UpdateFromGitHubPayload> = { // Helper Functions // ================================ -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, }, - }, -}) + }) const renderWithQueryClient = (ui: React.ReactElement) => { const queryClient = createQueryClient() - return render( - <QueryClientProvider client={queryClient}> - {ui} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>) } // ================================ @@ -335,13 +356,7 @@ describe('update-plugin', () => { const onCancel = vi.fn() // Act - render( - <FromGitHub - payload={payload} - onSave={onSave} - onCancel={onCancel} - />, - ) + render(<FromGitHub payload={payload} onSave={onSave} onCancel={onCancel} />) // Assert expect(screen.getByTestId('install-from-github')).toBeInTheDocument() @@ -362,11 +377,7 @@ describe('update-plugin', () => { // Act render( - <FromGitHub - payload={createMockGitHubPayload()} - onSave={vi.fn()} - onCancel={onCancel} - />, + <FromGitHub payload={createMockGitHubPayload()} onSave={vi.fn()} onCancel={onCancel} />, ) fireEvent.click(screen.getByTestId('github-close')) @@ -380,11 +391,7 @@ describe('update-plugin', () => { // Act render( - <FromGitHub - payload={createMockGitHubPayload()} - onSave={onSave} - onCancel={vi.fn()} - />, + <FromGitHub payload={createMockGitHubPayload()} onSave={onSave} onCancel={vi.fn()} />, ) fireEvent.click(screen.getByTestId('github-success')) @@ -405,11 +412,7 @@ describe('update-plugin', () => { // Act renderWithQueryClient( - <UpdateFromMarketplace - payload={payload} - onSave={vi.fn()} - onCancel={vi.fn()} - />, + <UpdateFromMarketplace payload={payload} onSave={vi.fn()} onCancel={vi.fn()} />, ) // Assert @@ -432,11 +435,7 @@ describe('update-plugin', () => { // Act renderWithQueryClient( - <UpdateFromMarketplace - payload={payload} - onSave={vi.fn()} - onCancel={vi.fn()} - />, + <UpdateFromMarketplace payload={payload} onSave={vi.fn()} onCancel={vi.fn()} />, ) // Assert @@ -449,11 +448,7 @@ describe('update-plugin', () => { // Act renderWithQueryClient( - <UpdateFromMarketplace - payload={payload} - onSave={vi.fn()} - onCancel={vi.fn()} - />, + <UpdateFromMarketplace payload={payload} onSave={vi.fn()} onCancel={vi.fn()} />, ) // Assert @@ -478,8 +473,12 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.title')).toBeInTheDocument() - expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.description')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.title'), + ).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.description'), + ).toBeInTheDocument() }) it('should not show downgrade warning modal when isShowDowngradeWarningModal is false', () => { @@ -497,7 +496,9 @@ describe('update-plugin', () => { ) // Assert - expect(screen.queryByText('plugin.autoUpdate.pluginDowngradeWarning.title')).not.toBeInTheDocument() + expect( + screen.queryByText('plugin.autoUpdate.pluginDowngradeWarning.title'), + ).not.toBeInTheDocument() expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument() }) }) @@ -510,11 +511,7 @@ describe('update-plugin', () => { // Act renderWithQueryClient( - <UpdateFromMarketplace - payload={payload} - onSave={vi.fn()} - onCancel={onCancel} - />, + <UpdateFromMarketplace payload={payload} onSave={vi.fn()} onCancel={onCancel} />, ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) @@ -533,11 +530,7 @@ describe('update-plugin', () => { // Act renderWithQueryClient( - <UpdateFromMarketplace - payload={payload} - onSave={onSave} - onCancel={vi.fn()} - />, + <UpdateFromMarketplace payload={payload} onSave={onSave} onCancel={vi.fn()} />, ) fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) @@ -557,11 +550,7 @@ describe('update-plugin', () => { // Act renderWithQueryClient( - <UpdateFromMarketplace - payload={payload} - onSave={vi.fn()} - onCancel={vi.fn()} - />, + <UpdateFromMarketplace payload={payload} onSave={vi.fn()} onCancel={vi.fn()} />, ) // Assert - button should show Update before clicking @@ -572,7 +561,9 @@ describe('update-plugin', () => { // Assert - Cancel button should be hidden during upgrade await waitFor(() => { - expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.cancel' }), + ).not.toBeInTheDocument() }) }) @@ -587,11 +578,7 @@ describe('update-plugin', () => { // Act renderWithQueryClient( - <UpdateFromMarketplace - payload={payload} - onSave={onSave} - onCancel={vi.fn()} - />, + <UpdateFromMarketplace payload={payload} onSave={onSave} onCancel={vi.fn()} />, ) fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) @@ -613,11 +600,7 @@ describe('update-plugin', () => { // Act renderWithQueryClient( - <UpdateFromMarketplace - payload={payload} - onSave={onSave} - onCancel={vi.fn()} - />, + <UpdateFromMarketplace payload={payload} onSave={onSave} onCancel={vi.fn()} />, ) fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) @@ -643,11 +626,7 @@ describe('update-plugin', () => { // Act renderWithQueryClient( - <UpdateFromMarketplace - payload={payload} - onSave={vi.fn()} - onCancel={onCancel} - />, + <UpdateFromMarketplace payload={payload} onSave={vi.fn()} onCancel={onCancel} />, ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) @@ -665,11 +644,7 @@ describe('update-plugin', () => { // Act renderWithQueryClient( - <UpdateFromMarketplace - payload={payload} - onSave={vi.fn()} - onCancel={vi.fn()} - />, + <UpdateFromMarketplace payload={payload} onSave={vi.fn()} onCancel={vi.fn()} />, ) fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) @@ -694,11 +669,7 @@ describe('update-plugin', () => { // Act renderWithQueryClient( - <UpdateFromMarketplace - payload={payload} - onSave={onSave} - onCancel={vi.fn()} - />, + <UpdateFromMarketplace payload={payload} onSave={onSave} onCancel={vi.fn()} />, ) fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) @@ -707,7 +678,9 @@ describe('update-plugin', () => { expect(mockCheck).toHaveBeenCalled() }) await waitFor(() => { - expect(toastErrorSpy).toHaveBeenCalledWith('Installation failed due to dependency conflict') + expect(toastErrorSpy).toHaveBeenCalledWith( + 'Installation failed due to dependency conflict', + ) }) // onSave should NOT be called when task fails expect(onSave).not.toHaveBeenCalled() @@ -722,11 +695,13 @@ describe('update-plugin', () => { mockUpdateFromMarketPlace.mockResolvedValue({ task: { status: TaskStatus.failed, - plugins: [{ - plugin_unique_identifier: 'test-target-id', - status: TaskStatus.failed, - message: 'failed to init environment', - }], + plugins: [ + { + plugin_unique_identifier: 'test-target-id', + status: TaskStatus.failed, + message: 'failed to init environment', + }, + ], }, }) const onSave = vi.fn() @@ -734,11 +709,7 @@ describe('update-plugin', () => { // Act renderWithQueryClient( - <UpdateFromMarketplace - payload={payload} - onSave={onSave} - onCancel={vi.fn()} - />, + <UpdateFromMarketplace payload={payload} onSave={onSave} onCancel={vi.fn()} />, ) fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) @@ -758,7 +729,9 @@ describe('update-plugin', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(UpdateFromMarketplace).toBeDefined() - expect((UpdateFromMarketplace as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') + expect((UpdateFromMarketplace as { $$typeof?: symbol }).$$typeof?.toString()).toContain( + 'Symbol', + ) }) }) @@ -782,7 +755,9 @@ describe('update-plugin', () => { isShowDowngradeWarningModal={true} />, ) - fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' })) + fireEvent.click( + screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' }), + ) // Assert await waitFor(() => { @@ -815,7 +790,9 @@ describe('update-plugin', () => { isShowDowngradeWarningModal={true} />, ) - fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' })) + fireEvent.click( + screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' }), + ) // Assert - mutateAsync should NOT be called when pluginId is undefined await waitFor(() => { @@ -842,8 +819,12 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.title')).toBeInTheDocument() - expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.description')).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.title'), + ).toBeInTheDocument() + expect( + screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.description'), + ).toBeInTheDocument() }) it('should render all three action buttons', () => { @@ -858,8 +839,14 @@ describe('update-plugin', () => { // Assert expect(screen.getByRole('button', { name: 'app.newApp.Cancel' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.downgrade' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'plugin.autoUpdate.pluginDowngradeWarning.downgrade', + }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' }), + ).toBeInTheDocument() }) }) @@ -894,7 +881,11 @@ describe('update-plugin', () => { onExcludeAndDowngrade={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.downgrade' })) + fireEvent.click( + screen.getByRole('button', { + name: 'plugin.autoUpdate.pluginDowngradeWarning.downgrade', + }), + ) // Assert expect(onJustDowngrade).toHaveBeenCalledTimes(1) @@ -912,7 +903,9 @@ describe('update-plugin', () => { onExcludeAndDowngrade={onExcludeAndDowngrade} />, ) - fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' })) + fireEvent.click( + screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' }), + ) // Assert expect(onExcludeAndDowngrade).toHaveBeenCalledTimes(1) @@ -995,7 +988,9 @@ describe('update-plugin', () => { const onShowChange = vi.fn() // Act - render(<PluginVersionPicker {...defaultProps} disabled={true} onShowChange={onShowChange} />) + render( + <PluginVersionPicker {...defaultProps} disabled={true} onShowChange={onShowChange} />, + ) fireEvent.click(screen.getByText('Select Version')) // Assert @@ -1019,7 +1014,7 @@ describe('update-plugin', () => { ) // Click on version 2.0.0 const versionElements = screen.getAllByText(/^\d+\.\d+\.\d+$/) - const version2Element = versionElements.find(el => el.textContent === '2.0.0') + const version2Element = versionElements.find((el) => el.textContent === '2.0.0') if (version2Element) { fireEvent.click(version2Element.closest('div[class*="cursor-pointer"]')!) } @@ -1048,7 +1043,7 @@ describe('update-plugin', () => { ) // Click on current version 1.0.0 const versionElements = screen.getAllByText(/^\d+\.\d+\.\d+$/) - const version1Element = versionElements.find(el => el.textContent === '1.0.0') + const version1Element = versionElements.find((el) => el.textContent === '1.0.0') if (version1Element) { fireEvent.click(version1Element.closest('div[class*="cursor"]')!) } @@ -1072,7 +1067,7 @@ describe('update-plugin', () => { ) // Click on version 1.0.0 (downgrade) const versionElements = screen.getAllByText(/^\d+\.\d+\.\d+$/) - const version1Element = versionElements.find(el => el.textContent === '1.0.0') + const version1Element = versionElements.find((el) => el.textContent === '1.0.0') if (version1Element) { fireEvent.click(version1Element.closest('div[class*="cursor-pointer"]')!) } @@ -1089,13 +1084,7 @@ describe('update-plugin', () => { describe('Props', () => { it('should support custom placement', () => { // Act - render( - <PluginVersionPicker - {...defaultProps} - isShow={true} - placement="top-end" - />, - ) + render(<PluginVersionPicker {...defaultProps} isShow={true} placement="top-end" />) // Assert expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument() @@ -1104,12 +1093,7 @@ describe('update-plugin', () => { it('should support custom offset', () => { // Act render( - <PluginVersionPicker - {...defaultProps} - isShow={true} - sideOffset={10} - alignOffset={20} - />, + <PluginVersionPicker {...defaultProps} isShow={true} sideOffset={10} alignOffset={20} />, ) // Assert @@ -1120,7 +1104,9 @@ describe('update-plugin', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(PluginVersionPicker).toBeDefined() - expect((PluginVersionPicker as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') + expect((PluginVersionPicker as { $$typeof?: symbol }).$$typeof?.toString()).toContain( + 'Symbol', + ) }) }) }) @@ -1162,20 +1148,23 @@ describe('update-plugin', () => { it('should handle empty version list in PluginVersionPicker', () => { // Override the mock temporarily - vi.mocked(vi.importActual('@/service/use-plugins') as unknown as Record<string, unknown>).useVersionListOfPlugin = () => ({ + vi.mocked( + vi.importActual('@/service/use-plugins') as unknown as Record<string, unknown>, + ).useVersionListOfPlugin = () => ({ data: { data: { versions: [] } }, }) // Act render( - <PluginVersionPicker {...{ - isShow: true, - onShowChange: vi.fn(), - pluginID: 'test', - currentVersion: '1.0.0', - trigger: <span>Select</span>, - onSelect: vi.fn(), - }} + <PluginVersionPicker + {...{ + isShow: true, + onShowChange: vi.fn(), + pluginID: 'test', + currentVersion: '1.0.0', + trigger: <span>Select</span>, + onSelect: vi.fn(), + }} />, ) diff --git a/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx index 556b64a620ce7a..9259166b2a1e22 100644 --- a/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx @@ -77,7 +77,11 @@ describe('PluginVersionPicker', () => { const currentBadge = screen.getByText('CURRENT') const oldVersion = screen.getByText('1.0.0') - expect(screen.getByText('plugin.detailPanel.switchVersion')).toHaveClass('px-3', 'pb-0.5', 'pt-1') + expect(screen.getByText('plugin.detailPanel.switchVersion')).toHaveClass( + 'px-3', + 'pb-0.5', + 'pt-1', + ) expect(currentVersion.closest('.cursor-default')).toHaveClass('px-2', 'py-1', 'opacity-30') expect(oldVersion.closest('.cursor-pointer')).toHaveClass('px-2', 'py-1') expect(currentVersion.parentElement).toHaveClass('min-h-5', 'gap-1', 'px-1') diff --git a/web/app/components/plugins/update-plugin/downgrade-warning.tsx b/web/app/components/plugins/update-plugin/downgrade-warning.tsx index 2623d276e29588..255516c86feb4d 100644 --- a/web/app/components/plugins/update-plugin/downgrade-warning.tsx +++ b/web/app/components/plugins/update-plugin/downgrade-warning.tsx @@ -8,25 +8,29 @@ type Props = Readonly<{ onJustDowngrade: () => void onExcludeAndDowngrade: () => void }> -const DowngradeWarningModal = ({ - onCancel, - onJustDowngrade, - onExcludeAndDowngrade, -}: Props) => { +const DowngradeWarningModal = ({ onCancel, onJustDowngrade, onExcludeAndDowngrade }: Props) => { const { t } = useTranslation() return ( <> <div className="flex flex-col items-start gap-2 self-stretch"> - <div className="title-2xl-semi-bold text-text-primary">{t($ => $[`${i18nPrefix}.title`], { ns: 'plugin' })}</div> + <div className="title-2xl-semi-bold text-text-primary"> + {t(($) => $[`${i18nPrefix}.title`], { ns: 'plugin' })} + </div> <div className="system-md-regular text-text-secondary"> - {t($ => $[`${i18nPrefix}.description`], { ns: 'plugin' })} + {t(($) => $[`${i18nPrefix}.description`], { ns: 'plugin' })} </div> </div> <div className="mt-9 flex items-start justify-end space-x-2 self-stretch"> - <Button variant="secondary" onClick={() => onCancel()}>{t($ => $['newApp.Cancel'], { ns: 'app' })}</Button> - <Button variant="secondary" tone="destructive" onClick={onJustDowngrade}>{t($ => $[`${i18nPrefix}.downgrade`], { ns: 'plugin' })}</Button> - <Button variant="primary" onClick={onExcludeAndDowngrade}>{t($ => $[`${i18nPrefix}.exclude`], { ns: 'plugin' })}</Button> + <Button variant="secondary" onClick={() => onCancel()}> + {t(($) => $['newApp.Cancel'], { ns: 'app' })} + </Button> + <Button variant="secondary" tone="destructive" onClick={onJustDowngrade}> + {t(($) => $[`${i18nPrefix}.downgrade`], { ns: 'plugin' })} + </Button> + <Button variant="primary" onClick={onExcludeAndDowngrade}> + {t(($) => $[`${i18nPrefix}.exclude`], { ns: 'plugin' })} + </Button> </div> </> ) diff --git a/web/app/components/plugins/update-plugin/from-github.tsx b/web/app/components/plugins/update-plugin/from-github.tsx index 0245b32fa29ad6..9806a80959d67f 100644 --- a/web/app/components/plugins/update-plugin/from-github.tsx +++ b/web/app/components/plugins/update-plugin/from-github.tsx @@ -10,17 +10,7 @@ type Props = Readonly<{ onCancel: () => void }> -const FromGitHub: FC<Props> = ({ - payload, - onSave, - onCancel, -}) => { - return ( - <InstallFromGitHub - updatePayload={payload} - onClose={onCancel} - onSuccess={onSave} - /> - ) +const FromGitHub: FC<Props> = ({ payload, onSave, onCancel }) => { + return <InstallFromGitHub updatePayload={payload} onClose={onCancel} onSuccess={onSave} /> } export default React.memo(FromGitHub) diff --git a/web/app/components/plugins/update-plugin/from-market-place.tsx b/web/app/components/plugins/update-plugin/from-market-place.tsx index 3da2fc2799a5fe..cce8196f46a5f9 100644 --- a/web/app/components/plugins/update-plugin/from-market-place.tsx +++ b/web/app/components/plugins/update-plugin/from-market-place.tsx @@ -2,12 +2,7 @@ import type { UpdateFromMarketPlacePayload } from '../types' 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 { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -48,7 +43,7 @@ const UploadStep = { installed: 'installed', } as const -type UploadStep = typeof UploadStep[keyof typeof UploadStep] +type UploadStep = (typeof UploadStep)[keyof typeof UploadStep] const UpdatePluginModal = ({ payload, @@ -57,23 +52,17 @@ const UpdatePluginModal = ({ onCancel, isShowDowngradeWarningModal, }: Props) => { - const { - originalPackageInfo, - targetPackageInfo, - } = payload + const { originalPackageInfo, targetPackageInfo } = payload const { t } = useTranslation() const { getIconUrl } = useGetIcon() const [icon, setIcon] = useState<string>(originalPackageInfo.payload.icon) useEffect(() => { - (async () => { + ;(async () => { const icon = await getIconUrl(originalPackageInfo.payload.icon) setIcon(icon) })() }, [originalPackageInfo, getIconUrl]) - const { - check, - stop, - } = checkTaskStatus() + const { check, stop } = checkTaskStatus() const handleCancel = () => { stop() onCancel() @@ -83,34 +72,33 @@ const UpdatePluginModal = ({ const { handleInstallTaskStart } = usePluginTaskList(payload.category) const configBtnText = useMemo(() => { - return ({ - [UploadStep.notStarted]: t($ => $[`${i18nPrefix}.upgrade`], { ns: 'plugin' }), - [UploadStep.upgrading]: t($ => $[`${i18nPrefix}.upgrading`], { ns: 'plugin' }), - [UploadStep.installed]: t($ => $[`${i18nPrefix}.close`], { ns: 'plugin' }), - })[uploadStep] + return { + [UploadStep.notStarted]: t(($) => $[`${i18nPrefix}.upgrade`], { ns: 'plugin' }), + [UploadStep.upgrading]: t(($) => $[`${i18nPrefix}.upgrading`], { ns: 'plugin' }), + [UploadStep.installed]: t(($) => $[`${i18nPrefix}.close`], { ns: 'plugin' }), + }[uploadStep] }, [t, uploadStep]) const handleConfirm = useCallback(async () => { if (uploadStep === UploadStep.notStarted) { setUploadStep(UploadStep.upgrading) try { - const response = await updateFromMarketPlace({ + const response = (await updateFromMarketPlace({ original_plugin_unique_identifier: originalPackageInfo.id, new_plugin_unique_identifier: targetPackageInfo.id, - }) as Awaited<ReturnType<typeof updateFromMarketPlace>> & FailedUpgradeResponse + })) as Awaited<ReturnType<typeof updateFromMarketPlace>> & FailedUpgradeResponse if (response.task?.status === TaskStatus.failed) { - const failedPlugin = response.task.plugins?.find(plugin => plugin.plugin_unique_identifier === targetPackageInfo.id) - ?? response.task.plugins?.[0] - toast.error(failedPlugin?.message || t($ => $.error, { ns: 'common' })) + const failedPlugin = + response.task.plugins?.find( + (plugin) => plugin.plugin_unique_identifier === targetPackageInfo.id, + ) ?? response.task.plugins?.[0] + toast.error(failedPlugin?.message || t(($) => $.error, { ns: 'common' })) setUploadStep(UploadStep.notStarted) return } - const { - all_installed: isInstalled, - task_id: taskId, - } = response + const { all_installed: isInstalled, task_id: taskId } = response if (isInstalled) { onSave() @@ -127,16 +115,21 @@ const UpdatePluginModal = ({ return } onSave() - } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { + } catch { setUploadStep(UploadStep.notStarted) } return } - if (uploadStep === UploadStep.installed) - onSave() - }, [onSave, uploadStep, check, originalPackageInfo.id, handleInstallTaskStart, t, targetPackageInfo.id]) + if (uploadStep === UploadStep.installed) onSave() + }, [ + onSave, + uploadStep, + check, + originalPackageInfo.id, + handleInstallTaskStart, + t, + targetPackageInfo.id, + ]) const { mutateAsync } = useRemoveAutoUpgrade() const handleExcludeAndDownload = async () => { @@ -148,7 +141,8 @@ const UpdatePluginModal = ({ } handleConfirm() } - const doShowDowngradeWarningModal = isShowDowngradeWarningModal && uploadStep === UploadStep.notStarted + const doShowDowngradeWarningModal = + isShowDowngradeWarningModal && uploadStep === UploadStep.notStarted return ( <Dialog open onOpenChange={() => onCancel()}> @@ -167,10 +161,16 @@ const UpdatePluginModal = ({ {!doShowDowngradeWarningModal && ( <> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $[`${i18nPrefix}.${uploadStep === UploadStep.installed ? 'successfulTitle' : 'title'}`], { ns: 'plugin' })} + {t( + ($) => + $[ + `${i18nPrefix}.${uploadStep === UploadStep.installed ? 'successfulTitle' : 'title'}` + ], + { ns: 'plugin' }, + )} </DialogTitle> <div className="mt-3 mb-2 system-md-regular text-text-secondary"> - {t($ => $[`${i18nPrefix}.description`], { ns: 'plugin' })} + {t(($) => $[`${i18nPrefix}.description`], { ns: 'plugin' })} </div> <div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2"> <Card @@ -180,21 +180,19 @@ const UpdatePluginModal = ({ icon: icon!, })} className="w-full" - titleLeft={( + titleLeft={ <> <Badge className="mx-1" size="s" state={BadgeState.Warning}> {`${originalPackageInfo.payload.version} -> ${targetPackageInfo.version}`} </Badge> </> - )} + } /> </div> <div className="flex items-center justify-end gap-2 self-stretch pt-5"> {uploadStep === UploadStep.notStarted && ( - <Button - onClick={handleCancel} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button onClick={handleCancel}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> )} <Button diff --git a/web/app/components/plugins/update-plugin/index.tsx b/web/app/components/plugins/update-plugin/index.tsx index f8f77e845a3883..82c7d5ddcd5388 100644 --- a/web/app/components/plugins/update-plugin/index.tsx +++ b/web/app/components/plugins/update-plugin/index.tsx @@ -14,20 +14,8 @@ const UpdatePlugin: FC<UpdatePluginModalType> = ({ onSave, }) => { if (type === PluginSource.github) { - return ( - <UpdateFromGitHub - payload={github!} - onSave={onSave} - onCancel={onCancel} - /> - ) + return <UpdateFromGitHub payload={github!} onSave={onSave} onCancel={onCancel} /> } - return ( - <UpdateFromMarketplace - payload={marketPlace!} - onSave={onSave} - onCancel={onCancel} - /> - ) + return <UpdateFromMarketplace payload={marketPlace!} onSave={onSave} onCancel={onCancel} /> } export default React.memo(UpdatePlugin) diff --git a/web/app/components/plugins/update-plugin/plugin-version-picker.tsx b/web/app/components/plugins/update-plugin/plugin-version-picker.tsx index 865b8a0e6021bc..ff2a28a7bc8ded 100644 --- a/web/app/components/plugins/update-plugin/plugin-version-picker.tsx +++ b/web/app/components/plugins/update-plugin/plugin-version-picker.tsx @@ -2,11 +2,7 @@ import type { Placement } from '@langgenius/dify-ui/popover' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -49,28 +45,33 @@ const PluginVersionPicker: FC<Props> = ({ onSelect, }) => { const { t } = useTranslation() - const format = t($ => $.dateTimeFormat, { ns: 'appLog' }).split(' ')[0] + const format = t(($) => $.dateTimeFormat, { ns: 'appLog' }).split(' ')[0] const { formatDate } = useTimestamp() const { data: res } = useVersionListOfPlugin(pluginID) - const handleSelect = useCallback(({ version, unique_identifier, isDowngrade }: { - version: string - unique_identifier: string - isDowngrade: boolean - }) => { - if (currentVersion === version) - return - onSelect({ version, unique_identifier, isDowngrade }) - onShowChange(false) - }, [currentVersion, onSelect, onShowChange]) + const handleSelect = useCallback( + ({ + version, + unique_identifier, + isDowngrade, + }: { + version: string + unique_identifier: string + isDowngrade: boolean + }) => { + if (currentVersion === version) return + onSelect({ version, unique_identifier, isDowngrade }) + onShowChange(false) + }, + [currentVersion, onSelect, onShowChange], + ) return ( <Popover open={isShow} onOpenChange={(open) => { - if (!disabled) - onShowChange(open) + if (!disabled) onShowChange(open) }} > <PopoverTrigger @@ -87,26 +88,35 @@ const PluginVersionPicker: FC<Props> = ({ popupClassName="relative w-[209px] bg-components-panel-bg-blur p-1 backdrop-blur-[5px]" > <div className="px-3 pt-1 pb-0.5 system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['detailPanel.switchVersion'], { ns: 'plugin' })} + {t(($) => $['detailPanel.switchVersion'], { ns: 'plugin' })} </div> <div className="relative max-h-[224px] overflow-y-auto"> - {res?.data.versions.map(version => ( + {res?.data.versions.map((version) => ( <div key={version.unique_identifier} className={cn( 'flex cursor-pointer items-center rounded-lg px-2 py-1 hover:bg-state-base-hover', - currentVersion === version.version && 'cursor-default opacity-30 hover:bg-transparent', + currentVersion === version.version && + 'cursor-default opacity-30 hover:bg-transparent', )} - onClick={() => handleSelect({ - version: version.version, - unique_identifier: version.unique_identifier, - isDowngrade: isEarlierThanVersion(version.version, currentVersion), - })} + onClick={() => + handleSelect({ + version: version.version, + unique_identifier: version.unique_identifier, + isDowngrade: isEarlierThanVersion(version.version, currentVersion), + }) + } > <div className="flex min-h-5 min-w-0 grow items-center gap-1 px-1"> - <div className="min-w-0 grow truncate system-sm-medium text-text-secondary">{version.version}</div> - {currentVersion === version.version && <Badge className="shrink-0" variant="dimm" text="CURRENT" />} - <div className="shrink-0 system-xs-regular text-text-tertiary">{formatDate(version.created_at, format!)}</div> + <div className="min-w-0 grow truncate system-sm-medium text-text-secondary"> + {version.version} + </div> + {currentVersion === version.version && ( + <Badge className="shrink-0" variant="dimm" text="CURRENT" /> + )} + <div className="shrink-0 system-xs-regular text-text-tertiary"> + {formatDate(version.created_at, format!)} + </div> </div> </div> ))} diff --git a/web/app/components/plugins/utils.ts b/web/app/components/plugins/utils.ts index 7deb97b7dfc05d..8673d557a5a6c5 100644 --- a/web/app/components/plugins/utils.ts +++ b/web/app/components/plugins/utils.ts @@ -1,30 +1,25 @@ import type { Plugin } from './types' - import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config' const hasUrlProtocol = (value: string) => /^[a-z][a-z\d+.-]*:/i.test(value) export const getPluginCardIconUrl = ( plugin: Pick<Plugin, 'from' | 'name' | 'org' | 'type'>, - icon: string | { content: string, background: string } | undefined, + icon: string | { content: string; background: string } | undefined, tenantId: string, ) => { - if (!icon) - return '' + if (!icon) return '' - if (typeof icon === 'object') - return icon + if (typeof icon === 'object') return icon - if (hasUrlProtocol(icon) || icon.startsWith('/')) - return icon + if (hasUrlProtocol(icon) || icon.startsWith('/')) return icon if (plugin.from === 'marketplace') { const basePath = plugin.type === 'bundle' ? 'bundles' : 'plugins' return `${MARKETPLACE_API_PREFIX}/${basePath}/${plugin.org}/${plugin.name}/icon` } - if (!tenantId) - return icon + if (!tenantId) return icon return `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenantId}&filename=${icon}` } diff --git a/web/app/components/provider/i18n-server.tsx b/web/app/components/provider/i18n-server.tsx index 23391cf4287b71..4560a46d0ec636 100644 --- a/web/app/components/provider/i18n-server.tsx +++ b/web/app/components/provider/i18n-server.tsx @@ -1,20 +1,12 @@ import { getLocaleOnServer, getResources } from '@/i18n-config/server' - import { I18nClientProvider } from './i18n' -export async function I18nServerProvider({ - children, -}: { - children: React.ReactNode -}) { +export async function I18nServerProvider({ children }: { children: React.ReactNode }) { const locale = await getLocaleOnServer() const resource = await getResources(locale) return ( - <I18nClientProvider - locale={locale} - resource={resource} - > + <I18nClientProvider locale={locale} resource={resource}> {children} </I18nClientProvider> ) diff --git a/web/app/components/provider/i18n.tsx b/web/app/components/provider/i18n.tsx index 6441a09dd37745..9c9e60a7350e8f 100644 --- a/web/app/components/provider/i18n.tsx +++ b/web/app/components/provider/i18n.tsx @@ -16,9 +16,5 @@ export function I18nClientProvider({ }) { const i18n = createI18nextInstance(locale, resource) - return ( - <I18nextProvider i18n={i18n}> - {children} - </I18nextProvider> - ) + return <I18nextProvider i18n={i18n}>{children}</I18nextProvider> } diff --git a/web/app/components/rag-pipeline/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/__tests__/index.spec.tsx index 221713defe6b5f..d8e40f40421399 100644 --- a/web/app/components/rag-pipeline/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/__tests__/index.spec.tsx @@ -2,7 +2,6 @@ import type { FetchWorkflowDraftResponse } from '@/types/workflow' import { cleanup, render, screen } from '@testing-library/react' import * as React from 'react' import { BlockEnum } from '@/app/components/workflow/types' - import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { usePipelineInit } from '../hooks' import RagPipelineWrapper from '../index' @@ -32,7 +31,15 @@ vi.mock('../components/conversion', () => ({ })) vi.mock('../components/rag-pipeline-main', () => ({ - default: ({ nodes, edges, viewport }: { nodes?: unknown[], edges?: unknown[], viewport?: { zoom?: number } }) => ( + default: ({ + nodes, + edges, + viewport, + }: { + nodes?: unknown[] + edges?: unknown[] + viewport?: { zoom?: number } + }) => ( <div data-testid="rag-pipeline-main"> <span data-testid="nodes-count">{nodes?.length ?? 0}</span> <span data-testid="edges-count">{edges?.length ?? 0}</span> @@ -58,29 +65,42 @@ const mockUsePipelineInit = vi.mocked(usePipelineInit) const mockProcessNodesWithoutDataSource = vi.mocked(processNodesWithoutDataSource) const mockSelectorWithDataset = (pipelineId: string | null | undefined) => { - mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => { - const mockState = { dataset: pipelineId ? { pipeline_id: pipelineId } : null } - return selector(mockState) - }) + mockUseDatasetDetailContextWithSelector.mockImplementation( + (selector: (state: Record<string, unknown>) => unknown) => { + const mockState = { dataset: pipelineId ? { pipeline_id: pipelineId } : null } + return selector(mockState) + }, + ) } -const createMockWorkflowData = (overrides?: Partial<FetchWorkflowDraftResponse>): FetchWorkflowDraftResponse => ({ - graph: { - nodes: [ - { id: 'node-1', type: 'custom', data: { type: BlockEnum.Start, title: 'Start' }, position: { x: 100, y: 100 } }, - { id: 'node-2', type: 'custom', data: { type: BlockEnum.End, title: 'End' }, position: { x: 300, y: 100 } }, - ], - edges: [ - { id: 'edge-1', source: 'node-1', target: 'node-2', type: 'custom' }, - ], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - hash: 'test-hash-123', - updated_at: 1234567890, - tool_published: false, - environment_variables: [], - ...overrides, -} as FetchWorkflowDraftResponse) +const createMockWorkflowData = ( + overrides?: Partial<FetchWorkflowDraftResponse>, +): FetchWorkflowDraftResponse => + ({ + graph: { + nodes: [ + { + id: 'node-1', + type: 'custom', + data: { type: BlockEnum.Start, title: 'Start' }, + position: { x: 100, y: 100 }, + }, + { + id: 'node-2', + type: 'custom', + data: { type: BlockEnum.End, title: 'End' }, + position: { x: 300, y: 100 }, + }, + ], + edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', type: 'custom' }], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + hash: 'test-hash-123', + updated_at: 1234567890, + tool_published: false, + environment_variables: [], + ...overrides, + }) as FetchWorkflowDraftResponse afterEach(() => { cleanup() @@ -286,7 +306,14 @@ describe('RagPipeline', () => { it('should handle empty edges array', () => { const mockData = createMockWorkflowData({ graph: { - nodes: [{ id: 'node-1', type: 'custom', data: { type: BlockEnum.Start, title: 'Start', desc: '' }, position: { x: 0, y: 0 } }], + nodes: [ + { + id: 'node-1', + type: 'custom', + data: { type: BlockEnum.Start, title: 'Start', desc: '' }, + position: { x: 0, y: 0 }, + }, + ], edges: [], viewport: { x: 0, y: 0, zoom: 1 }, }, @@ -414,7 +441,14 @@ describe('processNodesWithoutDataSource utility integration', () => { const mockData = createMockWorkflowData() mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) mockProcessNodesWithoutDataSource.mockReturnValue({ - nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as unknown as ReturnType<typeof processNodesWithoutDataSource>['nodes'], + nodes: [ + { + id: 'processed-node', + type: 'custom', + data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, + position: { x: 0, y: 0 }, + }, + ] as unknown as ReturnType<typeof processNodesWithoutDataSource>['nodes'], viewport: { x: 0, y: 0, zoom: 2 }, }) diff --git a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx index 623b8d7c234173..d77a051fb69c11 100644 --- a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - import Conversion from '../conversion' const mockConvert = vi.fn() @@ -16,7 +15,9 @@ let mockDatasetDetailState = { }, } vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: typeof mockDatasetDetailState) => unknown) => selector(mockDatasetDetailState), + useDatasetDetailContextWithSelector: ( + selector: (state: typeof mockDatasetDetailState) => unknown, + ) => selector(mockDatasetDetailState), })) vi.mock('@/service/use-pipeline', () => ({ @@ -53,7 +54,9 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, onClick, ...props }: Record<string, unknown>) => ( - <button onClick={onClick as () => void} {...props}>{children as string}</button> + <button onClick={onClick as () => void} {...props}> + {children as string} + </button> ), })) @@ -149,16 +152,21 @@ describe('Conversion', () => { fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - expect(mockConvert).toHaveBeenCalledWith('ds-123', expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - })) + expect(mockConvert).toHaveBeenCalledWith( + 'ds-123', + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) }) it('should handle successful conversion', async () => { - mockConvert.mockImplementation((_id: string, opts: { onSuccess: (res: { status: string }) => void }) => { - opts.onSuccess({ status: 'success' }) - }) + mockConvert.mockImplementation( + (_id: string, opts: { onSuccess: (res: { status: string }) => void }) => { + opts.onSuccess({ status: 'success' }) + }, + ) render(<Conversion />) @@ -170,9 +178,11 @@ describe('Conversion', () => { }) it('should handle failed conversion', async () => { - mockConvert.mockImplementation((_id: string, opts: { onSuccess: (res: { status: string }) => void }) => { - opts.onSuccess({ status: 'failed' }) - }) + mockConvert.mockImplementation( + (_id: string, opts: { onSuccess: (res: { status: string }) => void }) => { + opts.onSuccess({ status: 'failed' }) + }, + ) render(<Conversion />) diff --git a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx index 132f1e0aec3224..2c3072407770f5 100644 --- a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx @@ -1,7 +1,6 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { createMockProviderContextValue } from '@/__mocks__/provider-context' - import Conversion from '../conversion' import RagPipelinePanel from '../panel' import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal' @@ -83,7 +82,11 @@ vi.mock('@/app/components/workflow/store', () => { }) vi.mock('@/app/components/workflow/hooks-store', () => ({ - useHooksStore: <T,>(selector: (state: { accessControl: { canImportExportDSL: boolean, canRun: boolean, canReleaseAndVersion: boolean } }) => T): T => + useHooksStore: <T,>( + selector: (state: { + accessControl: { canImportExportDSL: boolean; canRun: boolean; canReleaseAndVersion: boolean } + }) => T, + ): T => selector({ accessControl: { canImportExportDSL: true, @@ -93,15 +96,13 @@ vi.mock('@/app/components/workflow/hooks-store', () => ({ }), })) -const { - mockHandlePaneContextmenuCancel, - mockExportCheck, - mockHandleExportDSL, -} = vi.hoisted(() => ({ - mockHandlePaneContextmenuCancel: vi.fn(), - mockExportCheck: vi.fn(), - mockHandleExportDSL: vi.fn(), -})) +const { mockHandlePaneContextmenuCancel, mockExportCheck, mockHandleExportDSL } = vi.hoisted( + () => ({ + mockHandlePaneContextmenuCancel: vi.fn(), + mockExportCheck: vi.fn(), + mockHandleExportDSL: vi.fn(), + }), +) vi.mock('@/app/components/workflow/hooks', () => { return { useNodesSyncDraft: () => ({ @@ -226,7 +227,9 @@ let mockDatasetDetailState = { }, } vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: typeof mockDatasetDetailState) => unknown) => selector(mockDatasetDetailState), + useDatasetDetailContextWithSelector: ( + selector: (state: typeof mockDatasetDetailState) => unknown, + ) => selector(mockDatasetDetailState), })) vi.mock('@/service/workflow', () => ({ @@ -237,10 +240,14 @@ vi.mock('@/service/workflow', () => ({ }), })) -let mockEventSubscriptionCallback: ((v: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) | null = null -const mockUseSubscription = vi.fn((callback: (v: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) => { - mockEventSubscriptionCallback = callback -}) +let mockEventSubscriptionCallback: + | ((v: { type: string; payload?: { data?: EnvironmentVariable[] } }) => void) + | null = null +const mockUseSubscription = vi.fn( + (callback: (v: { type: string; payload?: { data?: EnvironmentVariable[] } }) => void) => { + mockEventSubscriptionCallback = callback + }, +) vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ eventEmitter: { @@ -262,8 +269,9 @@ vi.mock('@/utils/var', () => ({ vi.mock('@/context/provider-context', () => ({ useProviderContext: () => createMockProviderContextValue(), - useProviderContextSelector: <T,>(selector: (state: ReturnType<typeof createMockProviderContextValue>) => T): T => - selector(createMockProviderContextValue()), + useProviderContextSelector: <T,>( + selector: (state: ReturnType<typeof createMockProviderContextValue>) => T, + ): T => selector(createMockProviderContextValue()), })) vi.mock('@/app/components/workflow', () => ({ @@ -273,7 +281,11 @@ vi.mock('@/app/components/workflow', () => ({ })) vi.mock('@/app/components/workflow/panel', () => ({ - default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => ( + default: ({ + components, + }: { + components?: { left?: React.ReactNode; right?: React.ReactNode } + }) => ( <div data-testid="workflow-panel"> <div data-testid="panel-left">{components?.left}</div> <div data-testid="panel-right">{components?.right}</div> @@ -297,12 +309,18 @@ vi.mock('@/app/components/workflow/constants', () => ({ })) vi.mock('@/app/components/workflow/utils', () => ({ - initialNodes: vi.fn(nodes => nodes), - initialEdges: vi.fn(edges => edges), + initialNodes: vi.fn((nodes) => nodes), + initialEdges: vi.fn((edges) => edges), })) vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ - default: ({ file, updateFile, className, accept, displayName }: { + default: ({ + file, + updateFile, + className, + accept, + displayName, + }: { file?: File updateFile: (file?: File) => void className?: string @@ -321,7 +339,9 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ /> {file && <span data-testid="file-name">{file.name}</span>} <span data-testid="display-name">{displayName}</span> - <button data-testid="clear-file" onClick={() => updateFile(undefined)}>Clear</button> + <button data-testid="clear-file" onClick={() => updateFile(undefined)}> + Clear + </button> </div> ), })) @@ -335,35 +355,50 @@ vi.mock('../publish-toast', () => ({ })) vi.mock('../update-dsl-modal', () => ({ - default: ({ onCancel, onBackup, onImport }: { + default: ({ + onCancel, + onBackup, + onImport, + }: { onCancel: () => void onBackup: () => void onImport?: () => void }) => ( <div data-testid="update-dsl-modal"> - <button data-testid="dsl-cancel" onClick={onCancel}>Cancel</button> - <button data-testid="dsl-backup" onClick={onBackup}>Backup</button> - <button data-testid="dsl-import" onClick={onImport}>Import</button> + <button data-testid="dsl-cancel" onClick={onCancel}> + Cancel + </button> + <button data-testid="dsl-backup" onClick={onBackup}> + Backup + </button> + <button data-testid="dsl-import" onClick={onImport}> + Import + </button> </div> ), })) vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ - default: ({ envList, onConfirm, onClose }: { + default: ({ + envList, + onConfirm, + onClose, + }: { envList: EnvironmentVariable[] onConfirm: () => void onClose: () => void - }) => ( - envList.length > 0 - ? ( - <div data-testid="dsl-export-confirm-modal"> - <span data-testid="env-count">{envList.length}</span> - <button data-testid="dsl-export-confirm" onClick={onConfirm}>Confirm</button> - <button data-testid="dsl-export-close" onClick={onClose}>Close</button> - </div> - ) - : null - ), + }) => + envList.length > 0 ? ( + <div data-testid="dsl-export-confirm-modal"> + <span data-testid="env-count">{envList.length}</span> + <button data-testid="dsl-export-confirm" onClick={onConfirm}> + Confirm + </button> + <button data-testid="dsl-export-close" onClick={onClose}> + Close + </button> + </div> + ) : null, })) // Silence expected console.error from Dialog/Modal rendering @@ -413,7 +448,9 @@ describe('Conversion', () => { it('should render conversion button', () => { render(<Conversion />) - expect(screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }), + ).toBeInTheDocument() }) it('should render description text', () => { @@ -441,7 +478,9 @@ describe('Conversion', () => { it('should show confirm modal when convert button is clicked', () => { render(<Conversion />) - const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) + const convertButton = screen.getByRole('button', { + name: /datasetPipeline\.operations\.convert/i, + }) fireEvent.click(convertButton) // AlertDialog renders title and content via portal. @@ -452,14 +491,18 @@ describe('Conversion', () => { it('should hide confirm modal when cancel is clicked', async () => { render(<Conversion />) - const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) + const convertButton = screen.getByRole('button', { + name: /datasetPipeline\.operations\.convert/i, + }) fireEvent.click(convertButton) expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument() // AlertDialog close is async because it unmounts after state updates. fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => { - expect(screen.queryByText('datasetPipeline.conversion.confirm.title')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetPipeline.conversion.confirm.title'), + ).not.toBeInTheDocument() }) }) }) @@ -471,50 +514,67 @@ describe('Conversion', () => { }) it('should call convert with datasetId and show success toast on success', async () => { - mockConvertFn.mockImplementation((_datasetId: string, options: { onSuccess: (res: { status: string }) => void }) => { - options.onSuccess({ status: 'success' }) - }) + mockConvertFn.mockImplementation( + (_datasetId: string, options: { onSuccess: (res: { status: string }) => void }) => { + options.onSuccess({ status: 'success' }) + }, + ) render(<Conversion />) - const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) + const convertButton = screen.getByRole('button', { + name: /datasetPipeline\.operations\.convert/i, + }) fireEvent.click(convertButton) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { - expect(mockConvertFn).toHaveBeenCalledWith('test-dataset-id', expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - })) + expect(mockConvertFn).toHaveBeenCalledWith( + 'test-dataset-id', + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) }) }) it('should close modal on success', async () => { - mockConvertFn.mockImplementation((_datasetId: string, options: { onSuccess: (res: { status: string }) => void }) => { - options.onSuccess({ status: 'success' }) - }) + mockConvertFn.mockImplementation( + (_datasetId: string, options: { onSuccess: (res: { status: string }) => void }) => { + options.onSuccess({ status: 'success' }) + }, + ) render(<Conversion />) - const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) + const convertButton = screen.getByRole('button', { + name: /datasetPipeline\.operations\.convert/i, + }) fireEvent.click(convertButton) expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { - expect(screen.queryByText('datasetPipeline.conversion.confirm.title')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetPipeline.conversion.confirm.title'), + ).not.toBeInTheDocument() }) }) it('should show error toast when conversion fails with status failed', async () => { - mockConvertFn.mockImplementation((_datasetId: string, options: { onSuccess: (res: { status: string }) => void }) => { - options.onSuccess({ status: 'failed' }) - }) + mockConvertFn.mockImplementation( + (_datasetId: string, options: { onSuccess: (res: { status: string }) => void }) => { + options.onSuccess({ status: 'failed' }) + }, + ) render(<Conversion />) - const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) + const convertButton = screen.getByRole('button', { + name: /datasetPipeline\.operations\.convert/i, + }) fireEvent.click(convertButton) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) @@ -532,7 +592,9 @@ describe('Conversion', () => { render(<Conversion />) - const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) + const convertButton = screen.getByRole('button', { + name: /datasetPipeline\.operations\.convert/i, + }) fireEvent.click(convertButton) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) @@ -544,14 +606,18 @@ describe('Conversion', () => { describe('Memoization', () => { it('should be wrapped with React.memo', () => { - expect((Conversion as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + expect((Conversion as unknown as { $$typeof: symbol }).$$typeof).toBe( + Symbol.for('react.memo'), + ) }) it('should use useCallback for handleConvert', () => { const { rerender } = render(<Conversion />) rerender(<Conversion />) - expect(screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }), + ).toBeInTheDocument() }) }) @@ -596,7 +662,9 @@ describe('PipelineScreenShot', () => { describe('Memoization', () => { it('should be wrapped with React.memo', () => { - expect((PipelineScreenShot as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + expect((PipelineScreenShot as unknown as { $$typeof: symbol }).$$typeof).toBe( + Symbol.for('react.memo'), + ) }) }) }) @@ -845,7 +913,9 @@ describe('RagPipelinePanel', () => { describe('Memoization', () => { it('should be wrapped with memo', () => { - expect((RagPipelinePanel as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + expect((RagPipelinePanel as unknown as { $$typeof: symbol }).$$typeof).toBe( + Symbol.for('react.memo'), + ) }) }) }) @@ -892,7 +962,13 @@ describe('RagPipelineChildren', () => { render(<RagPipelineChildren />) const mockEnvVariables: EnvironmentVariable[] = [ - { id: '1', name: 'SECRET_KEY', value: 'test-secret', value_type: 'secret' as const, description: '' }, + { + id: '1', + name: 'SECRET_KEY', + value: 'test-secret', + value_type: 'secret' as const, + description: '', + }, ] if (mockEventSubscriptionCallback) { @@ -958,7 +1034,13 @@ describe('RagPipelineChildren', () => { render(<RagPipelineChildren />) const mockEnvVariables: EnvironmentVariable[] = [ - { id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' }, + { + id: '1', + name: 'API_KEY', + value: 'secret-value', + value_type: 'secret' as const, + description: '', + }, ] if (mockEventSubscriptionCallback) { @@ -979,7 +1061,13 @@ describe('RagPipelineChildren', () => { render(<RagPipelineChildren />) const mockEnvVariables: EnvironmentVariable[] = [ - { id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' }, + { + id: '1', + name: 'API_KEY', + value: 'secret-value', + value_type: 'secret' as const, + description: '', + }, ] if (mockEventSubscriptionCallback) { @@ -1006,7 +1094,13 @@ describe('RagPipelineChildren', () => { render(<RagPipelineChildren />) const mockEnvVariables: EnvironmentVariable[] = [ - { id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' }, + { + id: '1', + name: 'API_KEY', + value: 'secret-value', + value_type: 'secret' as const, + description: '', + }, ] if (mockEventSubscriptionCallback) { @@ -1030,7 +1124,9 @@ describe('RagPipelineChildren', () => { describe('Memoization', () => { it('should be wrapped with memo', () => { - expect((RagPipelineChildren as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + expect((RagPipelineChildren as unknown as { $$typeof: symbol }).$$typeof).toBe( + Symbol.for('react.memo'), + ) }) }) }) @@ -1045,12 +1141,7 @@ describe('Integration Tests', () => { const mockOnConfirm = vi.fn().mockResolvedValue(undefined) it('should complete full publish flow', async () => { - render( - <PublishAsKnowledgePipelineModal - onCancel={mockOnCancel} - onConfirm={mockOnConfirm} - />, - ) + render(<PublishAsKnowledgePipelineModal onCancel={mockOnCancel} onConfirm={mockOnConfirm} />) fireEvent.change(getNameInput(), { target: { value: 'My Pipeline' } }) @@ -1084,12 +1175,7 @@ describe('Edge Cases', () => { describe('Null/Undefined Values', () => { it('should handle empty knowledgeName', () => { - render( - <PublishAsKnowledgePipelineModal - onCancel={vi.fn()} - onConfirm={vi.fn()} - />, - ) + render(<PublishAsKnowledgePipelineModal onCancel={vi.fn()} onConfirm={vi.fn()} />) const input = getNameInput() fireEvent.change(input, { target: { value: '' } }) @@ -1099,12 +1185,7 @@ describe('Edge Cases', () => { describe('Boundary Conditions', () => { it('should handle very long pipeline name', () => { - render( - <PublishAsKnowledgePipelineModal - onCancel={vi.fn()} - onConfirm={vi.fn()} - />, - ) + render(<PublishAsKnowledgePipelineModal onCancel={vi.fn()} onConfirm={vi.fn()} />) const longName = 'A'.repeat(1000) const input = getNameInput() @@ -1113,12 +1194,7 @@ describe('Edge Cases', () => { }) it('should handle special characters in name', () => { - render( - <PublishAsKnowledgePipelineModal - onCancel={vi.fn()} - onConfirm={vi.fn()} - />, - ) + render(<PublishAsKnowledgePipelineModal onCancel={vi.fn()} onConfirm={vi.fn()} />) const specialName = '<script>alert("xss")</script>' const input = getNameInput() @@ -1140,24 +1216,14 @@ describe('Accessibility', () => { describe('PublishAsKnowledgePipelineModal', () => { it('should have accessible form inputs', () => { - render( - <PublishAsKnowledgePipelineModal - onCancel={vi.fn()} - onConfirm={vi.fn()} - />, - ) + render(<PublishAsKnowledgePipelineModal onCancel={vi.fn()} onConfirm={vi.fn()} />) expect(getNameInput()).toBeInTheDocument() expect(getDescriptionTextarea()).toBeInTheDocument() }) it('should have accessible buttons', () => { - render( - <PublishAsKnowledgePipelineModal - onCancel={vi.fn()} - onConfirm={vi.fn()} - />, - ) + render(<PublishAsKnowledgePipelineModal onCancel={vi.fn()} onConfirm={vi.fn()} />) expect(screen.getByRole('button', { name: /common\.operation\.cancel/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /workflow\.common\.publish/i })).toBeInTheDocument() diff --git a/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx index 9ad6787f91aae0..874ccc2bfd6be1 100644 --- a/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal' vi.mock('@/app/components/workflow/store', () => ({ @@ -18,12 +17,14 @@ vi.mock('@/app/components/workflow/store', () => ({ })) vi.mock('@langgenius/dify-ui/dialog', () => ({ - Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) => + Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => open === false ? null : <>{children}</>, - DialogContent: ({ children, className }: { children: React.ReactNode, className?: string }) => ( - <div data-testid="modal" className={className}>{children}</div> + DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => ( + <div data-testid="modal" className={className}> + {children} + </div> ), - DialogTitle: ({ children, className }: { children: React.ReactNode, className?: string }) => ( + DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => ( <h2 className={className}>{children}</h2> ), })) @@ -91,7 +92,9 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should initialize description as empty', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - const textarea = screen.getByRole('textbox', { name: 'pipeline.common.publishAsPipeline.description' }) as HTMLTextAreaElement + const textarea = screen.getByRole('textbox', { + name: 'pipeline.common.publishAsPipeline.description', + }) as HTMLTextAreaElement expect(textarea.value).toBe('') }) @@ -135,7 +138,9 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should update description when textarea changes', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - const textarea = screen.getByRole('textbox', { name: 'pipeline.common.publishAsPipeline.description' }) + const textarea = screen.getByRole('textbox', { + name: 'pipeline.common.publishAsPipeline.description', + }) fireEvent.change(textarea, { target: { value: 'My description' } }) expect((textarea as HTMLTextAreaElement).value).toBe('My description') @@ -214,15 +219,13 @@ describe('PublishAsKnowledgePipelineModal', () => { const nameInput = screen.getByTestId('name-input') fireEvent.change(nameInput, { target: { value: ' Trimmed Name ' } }) - const textarea = screen.getByRole('textbox', { name: 'pipeline.common.publishAsPipeline.description' }) + const textarea = screen.getByRole('textbox', { + name: 'pipeline.common.publishAsPipeline.description', + }) fireEvent.change(textarea, { target: { value: ' Some desc ' } }) fireEvent.click(screen.getByText('workflow.common.publish')) - expect(mockOnConfirm).toHaveBeenCalledWith( - 'Trimmed Name', - expect.any(Object), - 'Some desc', - ) + expect(mockOnConfirm).toHaveBeenCalledWith('Trimmed Name', expect.any(Object), 'Some desc') }) }) diff --git a/web/app/components/rag-pipeline/components/__tests__/publish-toast.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/publish-toast.spec.tsx index 1d05eecb258a4e..1cefddfba7690a 100644 --- a/web/app/components/rag-pipeline/components/__tests__/publish-toast.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/publish-toast.spec.tsx @@ -103,7 +103,9 @@ describe('PublishToast', () => { it('should have correct toast width', () => { render(<PublishToast />) - const toastContainer = screen.getByText('pipeline.publishToast.title').closest('.w-\\[420px\\]') + const toastContainer = screen + .getByText('pipeline.publishToast.title') + .closest('.w-\\[420px\\]') expect(toastContainer).toBeInTheDocument() }) diff --git a/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx index 56baf482d777b2..6614faa0d17045 100644 --- a/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx @@ -4,7 +4,9 @@ import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants' import RagPipelineChildren from '../rag-pipeline-children' let mockShowImportDSLModal = false -let mockSubscription: ((value: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) | null = null +let mockSubscription: + | ((value: { type: string; payload?: { data?: EnvironmentVariable[] } }) => void) + | null = null const { mockSetShowImportDSLModal, @@ -25,7 +27,9 @@ const { vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ eventEmitter: { - useSubscription: (callback: (value: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) => { + useSubscription: ( + callback: (value: { type: string; payload?: { data?: EnvironmentVariable[] } }) => void, + ) => { mockSubscription = callback }, }, @@ -33,17 +37,22 @@ vi.mock('@/context/event-emitter', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { - showImportDSLModal: boolean - setShowImportDSLModal: typeof mockSetShowImportDSLModal - }) => unknown) => selector({ - showImportDSLModal: mockShowImportDSLModal, - setShowImportDSLModal: mockSetShowImportDSLModal, - }), + useStore: ( + selector: (state: { + showImportDSLModal: boolean + setShowImportDSLModal: typeof mockSetShowImportDSLModal + }) => unknown, + ) => + selector({ + showImportDSLModal: mockShowImportDSLModal, + setShowImportDSLModal: mockSetShowImportDSLModal, + }), })) vi.mock('@/app/components/workflow/hooks-store', () => ({ - useHooksStore: <T,>(selector: (state: { accessControl: { canImportExportDSL: boolean } }) => T): T => + useHooksStore: <T,>( + selector: (state: { accessControl: { canImportExportDSL: boolean } }) => T, + ): T => selector({ accessControl: { canImportExportDSL: true, @@ -100,7 +109,7 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ onClose: () => void }) => ( <div data-testid="dsl-export-modal"> - <div>{envList.map(env => env.name).join(',')}</div> + <div>{envList.map((env) => env.name).join(',')}</div> <button onClick={onConfirm}>confirm export</button> <button onClick={onClose}>close export</button> </div> diff --git a/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-main.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-main.spec.tsx index d175780a0294b0..f41db99644d339 100644 --- a/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-main.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-main.spec.tsx @@ -102,30 +102,42 @@ vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => })) vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: { permission_keys: string[] } }) => unknown) => - selector({ dataset: { permission_keys: mockPermissionState.permissionKeys } }), + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: { permission_keys: string[] } }) => unknown, + ) => selector({ dataset: { permission_keys: mockPermissionState.permissionKeys } }), })) vi.mock('@/app/components/workflow', () => ({ - WorkflowWithInnerContext: ({ children, onWorkflowDataUpdate, hooksStore }: PropsWithChildren<{ onWorkflowDataUpdate?: (payload: unknown) => void, hooksStore?: { accessControl?: { canComment?: boolean, canEdit?: boolean } } }>) => ( + WorkflowWithInnerContext: ({ + children, + onWorkflowDataUpdate, + hooksStore, + }: PropsWithChildren<{ + onWorkflowDataUpdate?: (payload: unknown) => void + hooksStore?: { accessControl?: { canComment?: boolean; canEdit?: boolean } } + }>) => ( <div data-testid="workflow-inner-context"> <div data-testid="can-comment">{String(hooksStore?.accessControl?.canComment)}</div> <div data-testid="can-edit">{String(hooksStore?.accessControl?.canEdit)}</div> {children} <button data-testid="trigger-update" - onClick={() => onWorkflowDataUpdate?.({ - rag_pipeline_variables: [{ id: '1', name: 'var1' }], - environment_variables: [{ id: '2', name: 'env1' }], - })} + onClick={() => + onWorkflowDataUpdate?.({ + rag_pipeline_variables: [{ id: '1', name: 'var1' }], + environment_variables: [{ id: '2', name: 'env1' }], + }) + } > Trigger Update </button> <button data-testid="trigger-update-partial" - onClick={() => onWorkflowDataUpdate?.({ - rag_pipeline_variables: [{ id: '3', name: 'var2' }], - })} + onClick={() => + onWorkflowDataUpdate?.({ + rag_pipeline_variables: [{ id: '3', name: 'var2' }], + }) + } > Trigger Partial Update </button> diff --git a/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx index 1854b2a683e35e..b48aca198bdbc3 100644 --- a/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx @@ -24,6 +24,9 @@ describe('PipelineScreenShot', () => { expect(sources[0]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline.png') expect(sources[1]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline@2x.png') expect(sources[2]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline@3x.png') - expect(screen.getByAltText('Pipeline Screenshot')).toHaveAttribute('src', '/console/screenshots/dark/Pipeline.png') + expect(screen.getByAltText('Pipeline Screenshot')).toHaveAttribute( + 'src', + '/console/screenshots/dark/Pipeline.png', + ) }) }) diff --git a/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx index 815b21ba981b6e..ed49ea852cad96 100644 --- a/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx @@ -19,7 +19,9 @@ const toastMocks = vi.hoisted(() => { const call = vi.fn() return { call, - api: vi.fn((message: unknown, options?: Record<string, unknown>) => call({ message, ...options })), + api: vi.fn((message: unknown, options?: Record<string, unknown>) => + call({ message, ...options }), + ), dismiss: vi.fn(), update: vi.fn(), promise: vi.fn(), @@ -28,10 +30,18 @@ const toastMocks = vi.hoisted(() => { vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.api, { - success: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'success', message, ...options })), - error: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'error', message, ...options })), - warning: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'warning', message, ...options })), - info: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'info', message, ...options })), + success: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'success', message, ...options }), + ), + error: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'info', message, ...options }), + ), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -90,10 +100,7 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ updateFile(file) }} /> - <button - data-testid="clear-file" - onClick={() => updateFile(undefined)} - > + <button data-testid="clear-file" onClick={() => updateFile(undefined)}> Clear </button> </div> @@ -101,7 +108,14 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ })) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick, disabled, className, variant, loading }: { + Button: ({ + children, + onClick, + disabled, + className, + variant, + loading, + }: { children: React.ReactNode onClick?: () => void disabled?: boolean @@ -304,7 +318,9 @@ describe('UpdateDSLModal', () => { describe('memoization', () => { it('should be wrapped with React.memo', () => { - expect((UpdateDSLModal as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + expect((UpdateDSLModal as unknown as { $$typeof: symbol }).$$typeof).toBe( + Symbol.for('react.memo'), + ) }) }) @@ -379,9 +395,11 @@ describe('UpdateDSLModal', () => { fireEvent.click(importButton) await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + }), + ) }) }) @@ -424,17 +442,23 @@ describe('UpdateDSLModal', () => { const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' }) fireEvent.change(fileInput, { target: { files: [file] } }) - await waitFor(() => { - const importButton = screen.getByText('workflow.common.overwriteAndImport') - expect(importButton).not.toBeDisabled() - }, { timeout: 1000 }) + await waitFor( + () => { + const importButton = screen.getByText('workflow.common.overwriteAndImport') + expect(importButton).not.toBeDisabled() + }, + { timeout: 1000 }, + ) const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - await waitFor(() => { - expect(mockOnImport).toHaveBeenCalled() - }, { timeout: 1000 }) + await waitFor( + () => { + expect(mockOnImport).toHaveBeenCalled() + }, + { timeout: 1000 }, + ) }) it('should show warning notification on import with warnings', async () => { @@ -459,9 +483,11 @@ describe('UpdateDSLModal', () => { fireEvent.click(importButton) await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - type: 'warning', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'warning', + }), + ) }) }) @@ -487,9 +513,11 @@ describe('UpdateDSLModal', () => { fireEvent.click(importButton) await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) }) @@ -515,9 +543,11 @@ describe('UpdateDSLModal', () => { fireEvent.click(importButton) await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) }) @@ -539,9 +569,11 @@ describe('UpdateDSLModal', () => { fireEvent.click(importButton) await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) }) @@ -564,7 +596,7 @@ describe('UpdateDSLModal', () => { }) await act(async () => { - await new Promise<void>(resolve => queueMicrotask(resolve)) + await new Promise<void>((resolve) => queueMicrotask(resolve)) }) const importButton = screen.getByText('workflow.common.overwriteAndImport') @@ -619,7 +651,7 @@ describe('UpdateDSLModal', () => { await act(async () => { fireEvent.change(fileInput, { target: { files: [file] } }) - await new Promise<void>(resolve => queueMicrotask(resolve)) + await new Promise<void>((resolve) => queueMicrotask(resolve)) }) const importButton = screen.getByText('workflow.common.overwriteAndImport') @@ -661,10 +693,13 @@ describe('UpdateDSLModal', () => { const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - await waitFor(() => { - expect(screen.getByText('1.0.0')).toBeInTheDocument() - expect(screen.getByText('2.0.0')).toBeInTheDocument() - }, { timeout: 1000 }) + await waitFor( + () => { + expect(screen.getByText('1.0.0')).toBeInTheDocument() + expect(screen.getByText('2.0.0')).toBeInTheDocument() + }, + { timeout: 1000 }, + ) }) it('should close error modal when cancel button is clicked', async () => { @@ -690,13 +725,16 @@ describe('UpdateDSLModal', () => { const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - await waitFor(() => { - expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 1000 }) + await waitFor( + () => { + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }, + { timeout: 1000 }, + ) const cancelButtons = screen.getAllByText('app.newApp.Cancel') - const errorModalCancelButton = cancelButtons.find(btn => - btn.getAttribute('data-variant') === 'secondary', + const errorModalCancelButton = cancelButtons.find( + (btn) => btn.getAttribute('data-variant') === 'secondary', ) if (errorModalCancelButton) { fireEvent.click(errorModalCancelButton) @@ -730,7 +768,7 @@ describe('UpdateDSLModal', () => { await act(async () => { fireEvent.change(fileInput, { target: { files: [file] } }) - await new Promise<void>(resolve => queueMicrotask(resolve)) + await new Promise<void>((resolve) => queueMicrotask(resolve)) }) const importButton = screen.getByText('workflow.common.overwriteAndImport') @@ -742,9 +780,12 @@ describe('UpdateDSLModal', () => { await vi.advanceTimersByTimeAsync(350) }) - await waitFor(() => { - expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 1000 }) + await waitFor( + () => { + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }, + { timeout: 1000 }, + ) const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) @@ -784,17 +825,22 @@ describe('UpdateDSLModal', () => { const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - await waitFor(() => { - expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 1000 }) + await waitFor( + () => { + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }, + { timeout: 1000 }, + ) const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + }), + ) }) }) @@ -826,17 +872,22 @@ describe('UpdateDSLModal', () => { const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - await waitFor(() => { - expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 1000 }) + await waitFor( + () => { + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }, + { timeout: 1000 }, + ) const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) }) @@ -865,17 +916,22 @@ describe('UpdateDSLModal', () => { const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - await waitFor(() => { - expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 1000 }) + await waitFor( + () => { + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }, + { timeout: 1000 }, + ) const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) }) @@ -907,17 +963,22 @@ describe('UpdateDSLModal', () => { const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - await waitFor(() => { - expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 1000 }) + await waitFor( + () => { + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }, + { timeout: 1000 }, + ) const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) }) @@ -949,9 +1010,12 @@ describe('UpdateDSLModal', () => { const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - await waitFor(() => { - expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 1000 }) + await waitFor( + () => { + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }, + { timeout: 1000 }, + ) const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) @@ -984,7 +1048,7 @@ describe('UpdateDSLModal', () => { await act(async () => { fireEvent.change(fileInput, { target: { files: [file] } }) - await new Promise<void>(resolve => queueMicrotask(resolve)) + await new Promise<void>((resolve) => queueMicrotask(resolve)) }) const importButton = screen.getByText('workflow.common.overwriteAndImport') @@ -996,9 +1060,12 @@ describe('UpdateDSLModal', () => { await vi.advanceTimersByTimeAsync(350) }) - await waitFor(() => { - expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 1000 }) + await waitFor( + () => { + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }, + { timeout: 1000 }, + ) const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) @@ -1033,9 +1100,12 @@ describe('UpdateDSLModal', () => { const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - await waitFor(() => { - expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 1000 }) + await waitFor( + () => { + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }, + { timeout: 1000 }, + ) }) it('should not call importDSLConfirm when importId is not set', async () => { diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/chunk-card.spec.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/chunk-card.spec.tsx index 59cd9613f34705..94e179eeb55775 100644 --- a/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/chunk-card.spec.tsx +++ b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/chunk-card.spec.tsx @@ -2,7 +2,6 @@ import type { ParentChildChunk } from '../types' import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' - import ChunkCard from '../chunk-card' vi.mock('@/app/components/datasets/documents/detail/completed/common/dot', () => ({ @@ -10,11 +9,9 @@ vi.mock('@/app/components/datasets/documents/detail/completed/common/dot', () => })) vi.mock('@/app/components/datasets/documents/detail/completed/common/segment-index-tag', () => ({ - default: ({ positionId, labelPrefix }: { positionId?: string | number, labelPrefix: string }) => ( + default: ({ positionId, labelPrefix }: { positionId?: string | number; labelPrefix: string }) => ( <span data-testid="segment-tag"> - {labelPrefix} - - - {positionId} + {labelPrefix}-{positionId} </span> ), })) @@ -24,12 +21,9 @@ vi.mock('@/app/components/datasets/documents/detail/completed/common/summary-lab })) vi.mock('@/app/components/datasets/formatted-text/flavours/preview-slice', () => ({ - PreviewSlice: ({ label, text }: { label: string, text: string }) => ( + PreviewSlice: ({ label, text }: { label: string; text: string }) => ( <span data-testid="preview-slice"> - {label} - : - {' '} - {text} + {label}: {text} </span> ), })) @@ -47,7 +41,7 @@ vi.mock('@/utils/format', () => ({ })) vi.mock('../q-a-item', () => ({ - default: ({ type, text }: { type: string, text: string }) => ( + default: ({ type, text }: { type: string; text: string }) => ( <span data-testid={`qa-${type}`}>{text}</span> ), })) diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/index.spec.tsx index 4827481579b19f..13db38ddb7b7e4 100644 --- a/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/index.spec.tsx @@ -1,4 +1,10 @@ -import type { GeneralChunks, ParentChildChunk, ParentChildChunks, QAChunk, QAChunks } from '../types' +import type { + GeneralChunks, + ParentChildChunk, + ParentChildChunks, + QAChunk, + QAChunks, +} from '../types' import { render, screen } from '@testing-library/react' import { ChunkingMode } from '@/models/datasets' import ChunkCard from '../chunk-card' @@ -7,8 +13,7 @@ import QAItem from '../q-a-item' import { QAItemType } from '../types' const createGeneralChunks = (overrides: GeneralChunks = []): GeneralChunks => { - if (overrides.length > 0) - return overrides + if (overrides.length > 0) return overrides return [ { content: 'This is the first chunk of text content.' }, { content: 'This is the second chunk with different content.' }, @@ -23,7 +28,9 @@ const createParentChildChunk = (overrides: Partial<ParentChildChunk> = {}): Pare ...overrides, }) -const createParentChildChunks = (overrides: Partial<ParentChildChunks> = {}): ParentChildChunks => ({ +const createParentChildChunks = ( + overrides: Partial<ParentChildChunks> = {}, +): ParentChildChunks => ({ parent_child_chunks: [ createParentChildChunk(), createParentChildChunk({ @@ -137,12 +144,7 @@ describe('ChunkCard', () => { } render( - <ChunkCard - chunkType={ChunkingMode.qa} - content={qaContent} - wordCount={45} - positionId={2} - />, + <ChunkCard chunkType={ChunkingMode.qa} content={qaContent} wordCount={45} positionId={2} />, ) expect(screen.getByText('Q'))!.toBeInTheDocument() @@ -368,12 +370,7 @@ describe('ChunkCard', () => { const qaContent: QAChunk = { question: 'Q?', answer: 'A.' } rerender( - <ChunkCard - chunkType={ChunkingMode.qa} - content={qaContent} - wordCount={4} - positionId={1} - />, + <ChunkCard chunkType={ChunkingMode.qa} content={qaContent} wordCount={4} positionId={1} />, ) expect(screen.getByText('Q'))!.toBeInTheDocument() @@ -400,12 +397,7 @@ describe('ChunkCard', () => { const emptyQA: QAChunk = { question: '', answer: '' } render( - <ChunkCard - chunkType={ChunkingMode.qa} - content={emptyQA} - wordCount={0} - positionId={1} - />, + <ChunkCard chunkType={ChunkingMode.qa} content={emptyQA} wordCount={0} positionId={1} />, ) expect(screen.getByText('Q'))!.toBeInTheDocument() @@ -452,12 +444,7 @@ describe('ChunkCardList', () => { it('should render text chunks correctly', () => { const chunks = createGeneralChunks() - render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={chunks} - />, - ) + render(<ChunkCardList chunkType={ChunkingMode.text} chunkInfo={chunks} />) expect(screen.getByText(chunks[0]!.content))!.toBeInTheDocument() expect(screen.getByText(chunks[1]!.content))!.toBeInTheDocument() @@ -483,12 +470,7 @@ describe('ChunkCardList', () => { it('should render QA chunks correctly', () => { const chunks = createQAChunks() - render( - <ChunkCardList - chunkType={ChunkingMode.qa} - chunkInfo={chunks} - />, - ) + render(<ChunkCardList chunkType={ChunkingMode.qa} chunkInfo={chunks} />) expect(screen.getByText('What is the answer to life?'))!.toBeInTheDocument() expect(screen.getByText('The answer is 42.'))!.toBeInTheDocument() @@ -499,17 +481,9 @@ describe('ChunkCardList', () => { describe('Memoization - chunkList', () => { it('should extract chunks from GeneralChunks for text mode', () => { - const chunks: GeneralChunks = [ - { content: 'Chunk 1' }, - { content: 'Chunk 2' }, - ] + const chunks: GeneralChunks = [{ content: 'Chunk 1' }, { content: 'Chunk 2' }] - render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={chunks} - />, - ) + render(<ChunkCardList chunkType={ChunkingMode.text} chunkInfo={chunks} />) expect(screen.getByText('Chunk 1'))!.toBeInTheDocument() expect(screen.getByText('Chunk 2'))!.toBeInTheDocument() @@ -517,9 +491,7 @@ describe('ChunkCardList', () => { it('should extract parent_child_chunks from ParentChildChunks for parentChild mode', () => { const chunks = createParentChildChunks({ - parent_child_chunks: [ - createParentChildChunk({ child_contents: ['Specific child'] }), - ], + parent_child_chunks: [createParentChildChunk({ child_contents: ['Specific child'] })], }) render( @@ -535,17 +507,10 @@ describe('ChunkCardList', () => { it('should extract qa_chunks from QAChunks for qa mode', () => { const chunks: QAChunks = { - qa_chunks: [ - { question: 'Specific Q', answer: 'Specific A' }, - ], + qa_chunks: [{ question: 'Specific Q', answer: 'Specific A' }], } - render( - <ChunkCardList - chunkType={ChunkingMode.qa} - chunkInfo={chunks} - />, - ) + render(<ChunkCardList chunkType={ChunkingMode.qa} chunkInfo={chunks} />) expect(screen.getByText('Specific Q'))!.toBeInTheDocument() expect(screen.getByText('Specific A'))!.toBeInTheDocument() @@ -555,21 +520,13 @@ describe('ChunkCardList', () => { const initialChunks = createGeneralChunks([{ content: 'Initial chunk' }]) const { rerender } = render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={initialChunks} - />, + <ChunkCardList chunkType={ChunkingMode.text} chunkInfo={initialChunks} />, ) expect(screen.getByText('Initial chunk'))!.toBeInTheDocument() const updatedChunks = createGeneralChunks([{ content: 'Updated chunk' }]) - rerender( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={updatedChunks} - />, - ) + rerender(<ChunkCardList chunkType={ChunkingMode.text} chunkInfo={updatedChunks} />) expect(screen.getByText('Updated chunk'))!.toBeInTheDocument() expect(screen.queryByText('Initial chunk')).not.toBeInTheDocument() @@ -580,12 +537,7 @@ describe('ChunkCardList', () => { it('should calculate word count for text chunks using string length', () => { const chunks = createGeneralChunks([{ content: 'Hello' }]) - render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={chunks} - />, - ) + render(<ChunkCardList chunkType={ChunkingMode.text} chunkInfo={chunks} />) expect(screen.getByText(/5\s+(?:\S.*)?characters/))!.toBeInTheDocument() }) @@ -613,17 +565,10 @@ describe('ChunkCardList', () => { it('should calculate word count for QA chunks using question + answer length', () => { const chunks: QAChunks = { - qa_chunks: [ - { question: 'Hi', answer: 'Bye' }, - ], + qa_chunks: [{ question: 'Hi', answer: 'Bye' }], } - render( - <ChunkCardList - chunkType={ChunkingMode.qa} - chunkInfo={chunks} - />, - ) + render(<ChunkCardList chunkType={ChunkingMode.qa} chunkInfo={chunks} />) expect(screen.getByText(/5\s+(?:\S.*)?characters/))!.toBeInTheDocument() }) @@ -637,12 +582,7 @@ describe('ChunkCardList', () => { { content: 'Third' }, ]) - render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={chunks} - />, - ) + render(<ChunkCardList chunkType={ChunkingMode.text} chunkInfo={chunks} />) expect(screen.getByText(/Chunk-01/))!.toBeInTheDocument() expect(screen.getByText(/Chunk-02/))!.toBeInTheDocument() @@ -655,11 +595,7 @@ describe('ChunkCardList', () => { const chunks = createGeneralChunks([{ content: 'Test' }]) const { container } = render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={chunks} - className="custom-class" - />, + <ChunkCardList chunkType={ChunkingMode.text} chunkInfo={chunks} className="custom-class" />, ) expect(container.firstChild)!.toHaveClass('custom-class') @@ -686,10 +622,7 @@ describe('ChunkCardList', () => { const chunks = createGeneralChunks([{ content: 'Test' }]) const { container } = render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={chunks} - />, + <ChunkCardList chunkType={ChunkingMode.text} chunkInfo={chunks} />, ) expect(container.firstChild)!.toHaveClass('flex') @@ -747,10 +680,7 @@ describe('ChunkCardList', () => { const chunks: GeneralChunks = [] const { container } = render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={chunks} - />, + <ChunkCardList chunkType={ChunkingMode.text} chunkInfo={chunks} />, ) expect(container.firstChild)!.toBeInTheDocument() @@ -780,12 +710,7 @@ describe('ChunkCardList', () => { qa_chunks: [], } - const { container } = render( - <ChunkCardList - chunkType={ChunkingMode.qa} - chunkInfo={chunks} - />, - ) + const { container } = render(<ChunkCardList chunkType={ChunkingMode.qa} chunkInfo={chunks} />) expect(container.firstChild)!.toBeInTheDocument() expect(container.firstChild?.childNodes.length).toBe(0) @@ -794,12 +719,7 @@ describe('ChunkCardList', () => { it('should handle single item in chunks', () => { const chunks = createGeneralChunks([{ content: 'Single chunk' }]) - render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={chunks} - />, - ) + render(<ChunkCardList chunkType={ChunkingMode.text} chunkInfo={chunks} />) expect(screen.getByText('Single chunk'))!.toBeInTheDocument() expect(screen.getByText(/Chunk-01/))!.toBeInTheDocument() @@ -808,12 +728,7 @@ describe('ChunkCardList', () => { it('should handle large number of chunks', () => { const chunks = Array.from({ length: 100 }, (_, i) => ({ content: `Chunk number ${i + 1}` })) - render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={chunks} - />, - ) + render(<ChunkCardList chunkType={ChunkingMode.text} chunkInfo={chunks} />) expect(screen.getByText('Chunk number 1'))!.toBeInTheDocument() expect(screen.getByText('Chunk number 100'))!.toBeInTheDocument() @@ -829,10 +744,7 @@ describe('ChunkCardList', () => { { content: 'Same content' }, ]) const { container } = render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={chunks} - />, + <ChunkCardList chunkType={ChunkingMode.text} chunkInfo={chunks} />, ) const chunkCards = container.querySelectorAll('.bg-components-panel-bg') @@ -854,12 +766,7 @@ describe('ChunkCardList Integration', () => { { content: 'Final paragraph concluding the content.' }, ]) - render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={textChunks} - />, - ) + render(<ChunkCardList chunkType={ChunkingMode.text} chunkInfo={textChunks} />) expect(screen.getByText('First paragraph of the document.'))!.toBeInTheDocument() expect(screen.getByText(/Chunk-01/))!.toBeInTheDocument() @@ -915,12 +822,7 @@ describe('ChunkCardList Integration', () => { ], }) - render( - <ChunkCardList - chunkType={ChunkingMode.qa} - chunkInfo={qaChunks} - />, - ) + render(<ChunkCardList chunkType={ChunkingMode.qa} chunkInfo={qaChunks} />) const qLabels = screen.getAllByText('Q') const aLabels = screen.getAllByText('A') @@ -928,9 +830,13 @@ describe('ChunkCardList Integration', () => { expect(aLabels.length).toBe(2) expect(screen.getByText('What is Dify?'))!.toBeInTheDocument() - expect(screen.getByText('Dify is an open-source LLM application development platform.'))!.toBeInTheDocument() + expect( + screen.getByText('Dify is an open-source LLM application development platform.'), + )!.toBeInTheDocument() expect(screen.getByText('How do I get started?'))!.toBeInTheDocument() - expect(screen.getByText('You can start by installing the platform using Docker.'))!.toBeInTheDocument() + expect( + screen.getByText('You can start by installing the platform using Docker.'), + )!.toBeInTheDocument() }) }) @@ -940,20 +846,12 @@ describe('ChunkCardList Integration', () => { const qaChunks = createQAChunks() const { rerender } = render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={textChunks} - />, + <ChunkCardList chunkType={ChunkingMode.text} chunkInfo={textChunks} />, ) expect(screen.getByText('Text content'))!.toBeInTheDocument() - rerender( - <ChunkCardList - chunkType={ChunkingMode.qa} - chunkInfo={qaChunks} - />, - ) + rerender(<ChunkCardList chunkType={ChunkingMode.qa} chunkInfo={qaChunks} />) expect(screen.queryByText('Text content')).not.toBeInTheDocument() expect(screen.getByText('What is the answer to life?'))!.toBeInTheDocument() @@ -964,10 +862,7 @@ describe('ChunkCardList Integration', () => { const parentChildChunks = createParentChildChunks() const { rerender } = render( - <ChunkCardList - chunkType={ChunkingMode.text} - chunkInfo={textChunks} - />, + <ChunkCardList chunkType={ChunkingMode.text} chunkInfo={textChunks} />, ) expect(screen.getByText('Simple text'))!.toBeInTheDocument() diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/chunk-card.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/chunk-card.tsx index 676bfdd6819d4b..6e10ffe497861c 100644 --- a/web/app/components/rag-pipeline/components/chunk-card-list/chunk-card.tsx +++ b/web/app/components/rag-pipeline/components/chunk-card-list/chunk-card.tsx @@ -80,7 +80,7 @@ const ChunkCard = (props: ChunkCardProps) => { labelPrefix={isParagraph ? 'Parent-Chunk' : 'Chunk'} /> <Dot /> - <div className="system-xs-medium text-text-tertiary">{`${formatNumber(wordCount)} ${t($ => $['segment.characters'], { ns: 'datasetDocuments', count: wordCount })}`}</div> + <div className="system-xs-medium text-text-tertiary">{`${formatNumber(wordCount)} ${t(($) => $['segment.characters'], { ns: 'datasetDocuments', count: wordCount })}`}</div> </div> )} <div className="body-md-regular text-text-secondary">{contentElement}</div> diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/index.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/index.tsx index d22c2a727c5d5c..fe284090f1a913 100644 --- a/web/app/components/rag-pipeline/components/chunk-card-list/index.tsx +++ b/web/app/components/rag-pipeline/components/chunk-card-list/index.tsx @@ -1,4 +1,12 @@ -import type { ChunkInfo, GeneralChunk, GeneralChunks, ParentChildChunk, ParentChildChunks, QAChunk, QAChunks } from './types' +import type { + ChunkInfo, + GeneralChunk, + GeneralChunks, + ParentChildChunk, + ParentChildChunks, + QAChunk, + QAChunks, +} from './types' import type { ParentMode } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import { useMemo } from 'react' @@ -16,8 +24,7 @@ export const ChunkCardList = (props: ChunkCardListProps) => { const { chunkType, parentMode, chunkInfo, className } = props const chunkList = useMemo(() => { - if (chunkType === ChunkingMode.text) - return chunkInfo as GeneralChunks + if (chunkType === ChunkingMode.text) return chunkInfo as GeneralChunks if (chunkType === ChunkingMode.parentChild) return (chunkInfo as ParentChildChunks).parent_child_chunks return (chunkInfo as QAChunks).qa_chunks @@ -26,8 +33,7 @@ export const ChunkCardList = (props: ChunkCardListProps) => { const getWordCount = (seg: GeneralChunk | ParentChildChunk | QAChunk) => { if (chunkType === ChunkingMode.parentChild) return (seg as ParentChildChunk).parent_content?.length - if (chunkType === ChunkingMode.text) - return (seg as GeneralChunk).content.length + if (chunkType === ChunkingMode.text) return (seg as GeneralChunk).content.length return (seg as QAChunk).question.length + (seg as QAChunk).answer.length } diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/q-a-item.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/q-a-item.tsx index 99dde5bbe4d322..039405d3d5ab23 100644 --- a/web/app/components/rag-pipeline/components/chunk-card-list/q-a-item.tsx +++ b/web/app/components/rag-pipeline/components/chunk-card-list/q-a-item.tsx @@ -10,7 +10,9 @@ const QAItem = (props: QAItemProps) => { const { type, text } = props return ( <div className="inline-flex items-start justify-start gap-1 self-stretch"> - <div className="w-4 text-[13px] leading-5 font-medium text-text-tertiary">{type === QAItemType.Question ? 'Q' : 'A'}</div> + <div className="w-4 text-[13px] leading-5 font-medium text-text-tertiary"> + {type === QAItemType.Question ? 'Q' : 'A'} + </div> <div className="flex-1 body-md-regular text-text-secondary">{text}</div> </div> ) diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/types.ts b/web/app/components/rag-pipeline/components/chunk-card-list/types.ts index b1213917e4c2f4..afab6b70b3a77c 100644 --- a/web/app/components/rag-pipeline/components/chunk-card-list/types.ts +++ b/web/app/components/rag-pipeline/components/chunk-card-list/types.ts @@ -30,4 +30,4 @@ export const QAItemType = { Question: 'question', Answer: 'answer', } as const -export type QAItemType = typeof QAItemType[keyof typeof QAItemType] +export type QAItemType = (typeof QAItemType)[keyof typeof QAItemType] diff --git a/web/app/components/rag-pipeline/components/conversion.tsx b/web/app/components/rag-pipeline/components/conversion.tsx index 7c13c9b281ac44..40f9b6d48bf666 100644 --- a/web/app/components/rag-pipeline/components/conversion.tsx +++ b/web/app/components/rag-pipeline/components/conversion.tsx @@ -26,68 +26,78 @@ import PipelineScreenShot from './screenshot' const Conversion = () => { const { t } = useTranslation() const { datasetId } = useParams() - const dataset = useDatasetDetailContextWithSelector(state => state.dataset) + const dataset = useDatasetDetailContextWithSelector((state) => state.dataset) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const [showConfirmModal, setShowConfirmModal] = useState(false) const { mutateAsync: convert, isPending } = useConvertDatasetToPipeline() const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, datasetId]) - const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys, { - currentUserId, - resourceMaintainer: dataset?.maintainer, - workspacePermissionKeys, - }), [currentUserId, dataset?.maintainer, dataset?.permission_keys, workspacePermissionKeys]) + const datasetACLCapabilities = React.useMemo( + () => + getDatasetACLCapabilities(dataset?.permission_keys, { + currentUserId, + resourceMaintainer: dataset?.maintainer, + workspacePermissionKeys, + }), + [currentUserId, dataset?.maintainer, dataset?.permission_keys, workspacePermissionKeys], + ) const canConvertDataset = datasetACLCapabilities.canEdit const handleConvert = useCallback(() => { - if (!canConvertDataset || !datasetId) - return + if (!canConvertDataset || !datasetId) return convert(datasetId as string, { onSuccess: (res) => { if (res.status === 'success') { - toast.success(t($ => $['conversion.successMessage'], { ns: 'datasetPipeline' })) + toast.success(t(($) => $['conversion.successMessage'], { ns: 'datasetPipeline' })) setShowConfirmModal(false) invalidDatasetDetail() - } - else if (res.status === 'failed') { - toast.error(t($ => $['conversion.errorMessage'], { ns: 'datasetPipeline' })) + } else if (res.status === 'failed') { + toast.error(t(($) => $['conversion.errorMessage'], { ns: 'datasetPipeline' })) } }, onError: () => { - toast.error(t($ => $['conversion.errorMessage'], { ns: 'datasetPipeline' })) + toast.error(t(($) => $['conversion.errorMessage'], { ns: 'datasetPipeline' })) }, }) }, [canConvertDataset, convert, datasetId, invalidDatasetDetail, t]) const handleShowConfirmModal = useCallback(() => { - if (!canConvertDataset) - return + if (!canConvertDataset) return setShowConfirmModal(true) }, [canConvertDataset]) const handleCancelConversion = useCallback(() => { setShowConfirmModal(false) }, []) - const confirmTitle = t($ => $['conversion.confirm.title'], { ns: 'datasetPipeline' }) - const confirmContent = t($ => $['conversion.confirm.content'], { ns: 'datasetPipeline' }) + const confirmTitle = t(($) => $['conversion.confirm.title'], { ns: 'datasetPipeline' }) + const confirmContent = t(($) => $['conversion.confirm.content'], { ns: 'datasetPipeline' }) return ( <div className="flex size-full items-center justify-center bg-background-body p-6 pb-16"> <div className="flex rounded-2xl border-[0.5px] border-components-card-border bg-components-card-bg shadow-sm shadow-shadow-shadow-4"> <div className="flex max-w-[480px] flex-col justify-between p-10"> <div className="flex flex-col gap-y-2.5"> <div className="title-4xl-semi-bold text-text-primary"> - {t($ => $['conversion.title'], { ns: 'datasetPipeline' })} + {t(($) => $['conversion.title'], { ns: 'datasetPipeline' })} </div> <div className="body-md-medium"> - <span className="text-text-secondary">{t($ => $['conversion.descriptionChunk1'], { ns: 'datasetPipeline' })}</span> - <span className="text-text-tertiary">{t($ => $['conversion.descriptionChunk2'], { ns: 'datasetPipeline' })}</span> + <span className="text-text-secondary"> + {t(($) => $['conversion.descriptionChunk1'], { ns: 'datasetPipeline' })} + </span> + <span className="text-text-tertiary"> + {t(($) => $['conversion.descriptionChunk2'], { ns: 'datasetPipeline' })} + </span> </div> </div> <div className="flex items-center gap-x-4"> - <Button variant="primary" className="w-32" disabled={!canConvertDataset} onClick={handleShowConfirmModal}> - {t($ => $['operations.convert'], { ns: 'datasetPipeline' })} + <Button + variant="primary" + className="w-32" + disabled={!canConvertDataset} + onClick={handleShowConfirmModal} + > + {t(($) => $['operations.convert'], { ns: 'datasetPipeline' })} </Button> <span className="system-xs-regular text-text-warning"> - {t($ => $['conversion.warning'], { ns: 'datasetPipeline' })} + {t(($) => $['conversion.warning'], { ns: 'datasetPipeline' })} </span> </div> </div> @@ -99,7 +109,10 @@ const Conversion = () => { </div> </div> </div> - <AlertDialog open={showConfirmModal} onOpenChange={open => !open && handleCancelConversion()}> + <AlertDialog + open={showConfirmModal} + onOpenChange={(open) => !open && handleCancelConversion()} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> @@ -111,10 +124,14 @@ const Conversion = () => { </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> - <AlertDialogConfirmButton loading={isPending} disabled={isPending || !canConvertDataset} onClick={handleConvert}> - {t($ => $['operation.confirm'], { ns: 'common' })} + <AlertDialogConfirmButton + loading={isPending} + disabled={isPending || !canConvertDataset} + onClick={handleConvert} + > + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx index 8bf870a3448da1..483eede4641007 100644 --- a/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx @@ -57,7 +57,10 @@ const { dynamicMocks, mockInputFieldEditorProps } = vi.hoisted(() => { }) vi.mock('@/next/dynamic', () => ({ - default: (_loader: () => Promise<{ default: React.ComponentType }>, _options?: Record<string, unknown>) => { + default: ( + _loader: () => Promise<{ default: React.ComponentType }>, + _options?: Record<string, unknown>, + ) => { return dynamicMocks.createMockComponent() }, })) @@ -214,7 +217,8 @@ describe('RagPipelinePanel', () => { render(<RagPipelinePanel />) await waitFor(() => { - const deleteUrl = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1') + const deleteUrl = + capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1') expect(deleteUrl).toBe('/rag/pipelines/pipeline-xyz/workflows/version-1') }) }) @@ -225,7 +229,8 @@ describe('RagPipelinePanel', () => { render(<RagPipelinePanel />) await waitFor(() => { - const updateUrl = capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-2') + const updateUrl = + capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-2') expect(updateUrl).toBe('/rag/pipelines/pipeline-def/workflows/version-2') }) }) @@ -655,8 +660,10 @@ describe('URL Generator Functions', () => { render(<RagPipelinePanel />) await waitFor(() => { - const deleteUrl1 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-x') - const deleteUrl2 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-x') + const deleteUrl1 = + capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-x') + const deleteUrl2 = + capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-x') expect(deleteUrl1).toBe(deleteUrl2) }) }) @@ -667,8 +674,10 @@ describe('URL Generator Functions', () => { render(<RagPipelinePanel />) await waitFor(() => { - const deleteUrl1 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1') - const deleteUrl2 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-2') + const deleteUrl1 = + capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1') + const deleteUrl2 = + capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-2') expect(deleteUrl1).not.toBe(deleteUrl2) expect(deleteUrl1).toBe('/rag/pipelines/stable-pipeline/workflows/version-1') expect(deleteUrl2).toBe('/rag/pipelines/stable-pipeline/workflows/version-2') @@ -714,8 +723,7 @@ describe('Performance', () => { it('should handle multiple rerenders without issues', async () => { const { rerender } = render(<RagPipelinePanel />) - for (let i = 0; i < 10; i++) - rerender(<RagPipelinePanel />) + for (let i = 0; i < 10; i++) rerender(<RagPipelinePanel />) await waitFor(() => { expect(screen.getByTestId('workflow-panel')).toBeInTheDocument() diff --git a/web/app/components/rag-pipeline/components/panel/index.tsx b/web/app/components/rag-pipeline/components/panel/index.tsx index 8f913956d1b969..1a5555845ac952 100644 --- a/web/app/components/rag-pipeline/components/panel/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/index.tsx @@ -1,8 +1,5 @@ import type { PanelProps } from '@/app/components/workflow/panel' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import Panel from '@/app/components/workflow/panel' import { useStore } from '@/app/components/workflow/store' import dynamic from '@/next/dynamic' @@ -10,9 +7,12 @@ import dynamic from '@/next/dynamic' const Record = dynamic(() => import('@/app/components/workflow/panel/record'), { ssr: false, }) -const TestRunPanel = dynamic(() => import('@/app/components/rag-pipeline/components/panel/test-run'), { - ssr: false, -}) +const TestRunPanel = dynamic( + () => import('@/app/components/rag-pipeline/components/panel/test-run'), + { + ssr: false, + }, +) const InputFieldPanel = dynamic(() => import('./input-field'), { ssr: false, }) @@ -22,13 +22,16 @@ const InputFieldEditorPanel = dynamic(() => import('./input-field/editor'), { const PreviewPanel = dynamic(() => import('./input-field/preview'), { ssr: false, }) -const GlobalVariablePanel = dynamic(() => import('@/app/components/workflow/panel/global-variable-panel'), { - ssr: false, -}) +const GlobalVariablePanel = dynamic( + () => import('@/app/components/workflow/panel/global-variable-panel'), + { + ssr: false, + }, +) const RagPipelinePanelOnRight = () => { - const historyWorkflowData = useStore(s => s.historyWorkflowData) - const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel) - const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel) + const historyWorkflowData = useStore((s) => s.historyWorkflowData) + const showDebugAndPreviewPanel = useStore((s) => s.showDebugAndPreviewPanel) + const showGlobalVariablePanel = useStore((s) => s.showGlobalVariablePanel) return ( <> @@ -40,30 +43,29 @@ const RagPipelinePanelOnRight = () => { } const RagPipelinePanelOnLeft = () => { - const showInputFieldPanel = useStore(s => s.showInputFieldPanel) - const showInputFieldPreviewPanel = useStore(s => s.showInputFieldPreviewPanel) - const inputFieldEditPanelProps = useStore(s => s.inputFieldEditPanelProps) + const showInputFieldPanel = useStore((s) => s.showInputFieldPanel) + const showInputFieldPreviewPanel = useStore((s) => s.showInputFieldPreviewPanel) + const inputFieldEditPanelProps = useStore((s) => s.inputFieldEditPanelProps) return ( <> {showInputFieldPreviewPanel && <PreviewPanel />} - {inputFieldEditPanelProps && ( - <InputFieldEditorPanel - {...inputFieldEditPanelProps} - /> - )} + {inputFieldEditPanelProps && <InputFieldEditorPanel {...inputFieldEditPanelProps} />} {showInputFieldPanel && <InputFieldPanel />} </> ) } const RagPipelinePanel = () => { - const pipelineId = useStore(s => s.pipelineId) + const pipelineId = useStore((s) => s.pipelineId) const versionHistoryPanelProps = useMemo(() => { return { getVersionListUrl: `/rag/pipelines/${pipelineId}/workflows`, - deleteVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}`, - restoreVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}/restore`, - updateVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}`, + deleteVersionUrl: (versionId: string) => + `/rag/pipelines/${pipelineId}/workflows/${versionId}`, + restoreVersionUrl: (versionId: string) => + `/rag/pipelines/${pipelineId}/workflows/${versionId}/restore`, + updateVersionUrl: (versionId: string) => + `/rag/pipelines/${pipelineId}/workflows/${versionId}`, latestVersionId: '', } }, [pipelineId]) @@ -78,9 +80,7 @@ const RagPipelinePanel = () => { } }, [versionHistoryPanelProps]) - return ( - <Panel {...panelProps} /> - ) + return <Panel {...panelProps} /> } export default memo(RagPipelinePanel) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/__tests__/footer-tip.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/footer-tip.spec.tsx index f70b9a4a6f9238..c599a5a43b5ad3 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/__tests__/footer-tip.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/footer-tip.spec.tsx @@ -25,7 +25,14 @@ describe('FooterTip', () => { const { container } = render(<FooterTip />) const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('flex', 'shrink-0', 'items-center', 'justify-center', 'gap-x-2', 'py-4') + expect(wrapper).toHaveClass( + 'flex', + 'shrink-0', + 'items-center', + 'justify-center', + 'gap-x-2', + 'py-4', + ) }) it('should have correct text styling', () => { diff --git a/web/app/components/rag-pipeline/components/panel/input-field/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/hooks.spec.ts index 9f7fb7e9020ff7..2b5c1a24994ebc 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/__tests__/hooks.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/hooks.spec.ts @@ -4,7 +4,9 @@ import { useFloatingRight } from '../hooks' const mockGetNodes = vi.fn() vi.mock('reactflow', () => ({ - useStore: (selector: (s: { getNodes: () => { id: string, data: { selected: boolean } }[] }) => unknown) => { + useStore: ( + selector: (s: { getNodes: () => { id: string; data: { selected: boolean } }[] }) => unknown, + ) => { return selector({ getNodes: mockGetNodes }) }, })) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx index 8b9b93480c5a69..626ab7ede157bd 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx @@ -86,18 +86,10 @@ vi.mock('../field-list', () => ({ allVariableNames: string[] }) => ( <div data-testid={`field-list-${nodeId}`}> - <span data-testid={`field-list-readonly-${nodeId}`}> - {String(readonly)} - </span> - <span data-testid={`field-list-classname-${nodeId}`}> - {labelClassName} - </span> - <span data-testid={`field-list-fields-count-${nodeId}`}> - {inputFields.length} - </span> - <span data-testid={`field-list-all-vars-${nodeId}`}> - {allVariableNames.join(',')} - </span> + <span data-testid={`field-list-readonly-${nodeId}`}>{String(readonly)}</span> + <span data-testid={`field-list-classname-${nodeId}`}>{labelClassName}</span> + <span data-testid={`field-list-fields-count-${nodeId}`}>{inputFields.length}</span> + <span data-testid={`field-list-all-vars-${nodeId}`}>{allVariableNames.join(',')}</span> {LabelRightContent} <button data-testid={`trigger-change-${nodeId}`} @@ -111,7 +103,8 @@ vi.mock('../field-list', () => ({ max_length: 48, required: true, }, - ])} + ]) + } > Add Field </button> @@ -131,9 +124,7 @@ vi.mock('../footer-tip', () => ({ vi.mock('../label-right-content/datasource', () => ({ default: ({ nodeData }: { nodeData: DataSourceNodeType }) => ( - <div data-testid={`datasource-label-${nodeData.title}`}> - {nodeData.title} - </div> + <div data-testid={`datasource-label-${nodeData.title}`}>{nodeData.title}</div> ), })) @@ -158,10 +149,7 @@ const createInputVar = (overrides?: Partial<InputVar>): InputVar => ({ ...overrides, }) -const createRAGPipelineVariable = ( - nodeId: string, - overrides?: Partial<InputVar>, -) => ({ +const createRAGPipelineVariable = (nodeId: string, overrides?: Partial<InputVar>) => ({ belong_to_node_id: nodeId, ...createInputVar(overrides), }) @@ -244,7 +232,9 @@ describe('InputFieldPanel', () => { it('should render unique inputs section title', () => { render(<InputFieldPanel />) - expect(screen.getByText('datasetPipeline.inputFieldPanel.uniqueInputs.title'))!.toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.inputFieldPanel.uniqueInputs.title'), + )!.toBeInTheDocument() }) it('should render global inputs field list', () => { @@ -343,19 +333,15 @@ describe('InputFieldPanel', () => { render(<InputFieldPanel />) - expect(screen.getByTestId('field-list-all-vars-node-1'))!.toHaveTextContent( - 'var1,var2', - ) - expect(screen.getByTestId('field-list-all-vars-shared'))!.toHaveTextContent( - 'var1,var2', - ) + expect(screen.getByTestId('field-list-all-vars-node-1'))!.toHaveTextContent('var1,var2') + expect(screen.getByTestId('field-list-all-vars-shared'))!.toHaveTextContent('var1,var2') }) }) describe('User Interactions', () => { const isCloseButton = (btn: HTMLElement) => - btn.classList.contains('size-6') - || btn.className.includes('shrink-0 items-center justify-center p-0.5') + btn.classList.contains('size-6') || + btn.className.includes('shrink-0 items-center justify-center p-0.5') it('should call closeAllInputFieldPanels when close button is clicked', () => { render(<InputFieldPanel />) @@ -381,9 +367,7 @@ describe('InputFieldPanel', () => { render(<InputFieldPanel />) - const previewButton = screen - .getByText('datasetPipeline.operations.preview') - .closest('button') + const previewButton = screen.getByText('datasetPipeline.operations.preview').closest('button') expect(previewButton)!.toBeDisabled() }) @@ -392,9 +376,7 @@ describe('InputFieldPanel', () => { render(<InputFieldPanel />) - const previewButton = screen - .getByText('datasetPipeline.operations.preview') - .closest('button') + const previewButton = screen.getByText('datasetPipeline.operations.preview').closest('button') expect(previewButton).not.toBeDisabled() }) }) @@ -405,9 +387,7 @@ describe('InputFieldPanel', () => { render(<InputFieldPanel />) - const previewButton = screen - .getByText('datasetPipeline.operations.preview') - .closest('button') + const previewButton = screen.getByText('datasetPipeline.operations.preview').closest('button') expect(previewButton)!.toHaveClass('bg-state-accent-active') expect(previewButton)!.toHaveClass('text-text-accent') }) @@ -417,9 +397,7 @@ describe('InputFieldPanel', () => { render(<InputFieldPanel />) - expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent( - 'true', - ) + expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent('true') }) it('should set readonly to true when editing', () => { @@ -427,9 +405,7 @@ describe('InputFieldPanel', () => { render(<InputFieldPanel />) - expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent( - 'true', - ) + expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent('true') }) it('should set readonly to false when not previewing or editing', () => { @@ -437,9 +413,7 @@ describe('InputFieldPanel', () => { render(<InputFieldPanel />) - expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent( - 'false', - ) + expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent('false') }) it('should set readonly to true when access control cannot edit', () => { @@ -447,9 +421,7 @@ describe('InputFieldPanel', () => { render(<InputFieldPanel />) - expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent( - 'true', - ) + expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent('true') }) }) @@ -480,9 +452,7 @@ describe('InputFieldPanel', () => { it('should place datasource node fields before global fields', async () => { const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - const variables = [ - createRAGPipelineVariable('shared', { variable: 'shared_var' }), - ] + const variables = [createRAGPipelineVariable('shared', { variable: 'shared_var' })] setupMocks({ nodes, ragPipelineVariables: variables }) render(<InputFieldPanel />) @@ -586,9 +556,7 @@ describe('InputFieldPanel', () => { it('should pass correct className to global inputs field list', () => { render(<InputFieldPanel />) - expect(screen.getByTestId('field-list-classname-shared'))!.toHaveTextContent( - 'pt-2 pb-1', - ) + expect(screen.getByTestId('field-list-classname-shared'))!.toHaveTextContent('pt-2 pb-1') }) }) @@ -622,8 +590,8 @@ describe('InputFieldPanel', () => { describe('Callback Stability', () => { const findCloseButton = (buttons: HTMLElement[]) => { const isCloseButton = (btn: HTMLElement) => - btn.classList.contains('size-6') - || btn.className.includes('shrink-0 items-center justify-center p-0.5') + btn.classList.contains('size-6') || + btn.className.includes('shrink-0 items-center justify-center p-0.5') return buttons.find(isCloseButton) } @@ -650,9 +618,7 @@ describe('InputFieldPanel', () => { rerender(<InputFieldPanel />) fireEvent.click(screen.getByText('datasetPipeline.operations.preview')) - expect(mockToggleInputFieldPreviewPanel.mock.calls.length).toBe( - callCount1 + 1, - ) + expect(mockToggleInputFieldPreviewPanel.mock.calls.length).toBe(callCount1 + 1) }) }) @@ -662,9 +628,7 @@ describe('InputFieldPanel', () => { render(<InputFieldPanel />) - expect(screen.getByTestId('field-list-all-vars-shared'))!.toHaveTextContent( - '', - ) + expect(screen.getByTestId('field-list-all-vars-shared'))!.toHaveTextContent('') }) it('should handle undefined ragPipelineVariables', () => { @@ -690,7 +654,8 @@ describe('InputFieldPanel', () => { it('should handle large number of datasource nodes', () => { const nodes = Array.from({ length: 10 }, (_, i) => - createDataSourceNode(`node-${i}`, `DataSource ${i}`)) + createDataSourceNode(`node-${i}`, `DataSource ${i}`), + ) setupMocks({ nodes }) render(<InputFieldPanel />) @@ -702,14 +667,13 @@ describe('InputFieldPanel', () => { it('should handle large number of variables', () => { const variables = Array.from({ length: 100 }, (_, i) => - createRAGPipelineVariable('shared', { variable: `var_${i}` })) + createRAGPipelineVariable('shared', { variable: `var_${i}` }), + ) setupMocks({ ragPipelineVariables: variables }) render(<InputFieldPanel />) - expect(screen.getByTestId('field-list-fields-count-shared'))!.toHaveTextContent( - '100', - ) + expect(screen.getByTestId('field-list-fields-count-shared'))!.toHaveTextContent('100') }) it('should handle special characters in variable names', () => { @@ -789,9 +753,7 @@ describe('InputFieldPanel', () => { describe('Integration with FieldList Component', () => { it('should pass correct props to FieldList for datasource nodes', () => { const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - const variables = [ - createRAGPipelineVariable('node-1', { variable: 'test_var' }), - ] + const variables = [createRAGPipelineVariable('node-1', { variable: 'test_var' })] setupMocks({ nodes, ragPipelineVariables: variables, @@ -806,9 +768,7 @@ describe('InputFieldPanel', () => { }) it('should pass correct props to FieldList for shared node', () => { - const variables = [ - createRAGPipelineVariable('shared', { variable: 'shared_var' }), - ] + const variables = [createRAGPipelineVariable('shared', { variable: 'shared_var' })] setupMocks({ ragPipelineVariables: variables, isEditing: true }) render(<InputFieldPanel />) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx index d824ea52fb812d..3eaf7a576fd98d 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx @@ -8,10 +8,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { PipelineInputVarType } from '@/models/pipeline' import InputFieldEditorPanel from '../index' -import { - convertFormDataToINputField, - convertToInputFieldFormData, -} from '../utils' +import { convertFormDataToINputField, convertToInputFieldFormData } from '../utils' const mockUseFloatingRight = vi.fn(() => ({ floatingRight: false, @@ -40,11 +37,10 @@ vi.mock('../form', () => ({ <span data-testid="form-initial-data">{JSON.stringify(initialData)}</span> <span data-testid="form-support-file">{String(supportFile)}</span> <span data-testid="form-is-edit-mode">{String(isEditMode)}</span> - <button data-testid="form-cancel-btn" onClick={onCancel}>Cancel</button> - <button - data-testid="form-submit-btn" - onClick={() => onSubmit(initialData)} - > + <button data-testid="form-cancel-btn" onClick={onCancel}> + Cancel + </button> + <button data-testid="form-submit-btn" onClick={() => onSubmit(initialData)}> Submit </button> </div> @@ -121,9 +117,7 @@ const createTestQueryClient = () => const TestWrapper = ({ children }: { children: React.ReactNode }) => { const queryClient = createTestQueryClient() - return ( - <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> - ) + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> } const renderWithProviders = (ui: React.ReactElement) => { @@ -162,9 +156,7 @@ describe('InputFieldEditorPanel', () => { renderWithProviders(<InputFieldEditorPanel {...props} />) - expect( - screen.getByText('datasetPipeline.inputFieldPanel.addInputField'), - ).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.inputFieldPanel.addInputField')).toBeInTheDocument() }) it('should render "Edit Input Field" title when initialData is provided', () => { @@ -174,9 +166,7 @@ describe('InputFieldEditorPanel', () => { renderWithProviders(<InputFieldEditorPanel {...props} />) - expect( - screen.getByText('datasetPipeline.inputFieldPanel.editInputField'), - ).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.inputFieldPanel.editInputField')).toBeInTheDocument() }) it('should pass supportFile=true to form', () => { @@ -222,9 +212,7 @@ describe('InputFieldEditorPanel', () => { const initialData = createInputVar({ type }) const props = createInputFieldEditorProps({ initialData }) - const { unmount } = renderWithProviders( - <InputFieldEditorPanel {...props} />, - ) + const { unmount } = renderWithProviders(<InputFieldEditorPanel {...props} />) expect(screen.getByTestId('input-field-form')).toBeInTheDocument() unmount() @@ -319,9 +307,7 @@ describe('InputFieldEditorPanel', () => { }) const props = createInputFieldEditorProps() - const { container } = renderWithProviders( - <InputFieldEditorPanel {...props} />, - ) + const { container } = renderWithProviders(<InputFieldEditorPanel {...props} />) const panel = container.firstChild as HTMLElement expect(panel.className).toContain('absolute') @@ -336,9 +322,7 @@ describe('InputFieldEditorPanel', () => { }) const props = createInputFieldEditorProps() - const { container } = renderWithProviders( - <InputFieldEditorPanel {...props} />, - ) + const { container } = renderWithProviders(<InputFieldEditorPanel {...props} />) const panel = container.firstChild as HTMLElement expect(panel.className).not.toContain('absolute') @@ -351,9 +335,7 @@ describe('InputFieldEditorPanel', () => { const onClose = vi.fn() const props = createInputFieldEditorProps({ onClose }) - const { rerender } = renderWithProviders( - <InputFieldEditorPanel {...props} />, - ) + const { rerender } = renderWithProviders(<InputFieldEditorPanel {...props} />) fireEvent.click(screen.getByTestId('form-cancel-btn')) rerender( @@ -370,9 +352,7 @@ describe('InputFieldEditorPanel', () => { const onSubmit = vi.fn() const props = createInputFieldEditorProps({ onSubmit }) - const { rerender } = renderWithProviders( - <InputFieldEditorPanel {...props} />, - ) + const { rerender } = renderWithProviders(<InputFieldEditorPanel {...props} />) fireEvent.click(screen.getByTestId('form-submit-btn')) rerender( @@ -391,9 +371,7 @@ describe('InputFieldEditorPanel', () => { const initialData = createInputVar() const props = createInputFieldEditorProps({ initialData }) - const { rerender } = renderWithProviders( - <InputFieldEditorPanel {...props} />, - ) + const { rerender } = renderWithProviders(<InputFieldEditorPanel {...props} />) const firstFormData = screen.getByTestId('form-initial-data').textContent rerender( @@ -412,9 +390,7 @@ describe('InputFieldEditorPanel', () => { const props1 = createInputFieldEditorProps({ initialData: initialData1 }) const props2 = createInputFieldEditorProps({ initialData: initialData2 }) - const { rerender } = renderWithProviders( - <InputFieldEditorPanel {...props1} />, - ) + const { rerender } = renderWithProviders(<InputFieldEditorPanel {...props1} />) const firstFormData = screen.getByTestId('form-initial-data').textContent rerender( @@ -434,9 +410,7 @@ describe('InputFieldEditorPanel', () => { it('should handle undefined initialData gracefully', () => { const props = createInputFieldEditorProps({ initialData: undefined }) - expect(() => - renderWithProviders(<InputFieldEditorPanel {...props} />), - ).not.toThrow() + expect(() => renderWithProviders(<InputFieldEditorPanel {...props} />)).not.toThrow() }) it('should handle rapid close button clicks', () => { @@ -445,7 +419,7 @@ describe('InputFieldEditorPanel', () => { renderWithProviders(<InputFieldEditorPanel {...props} />) const closeButtons = screen.getAllByRole('button') - const closeButton = closeButtons.find(btn => btn.querySelector('svg')) + const closeButton = closeButtons.find((btn) => btn.querySelector('svg')) if (closeButton) { fireEvent.click(closeButton) @@ -526,10 +500,7 @@ describe('convertToInputFieldFormData', () => { const result = convertToInputFieldFormData(inputVar) - expect(result.allowedFileUploadMethods).toEqual([ - 'local_file', - 'remote_url', - ]) + expect(result.allowedFileUploadMethods).toEqual(['local_file', 'remote_url']) expect(result.allowedTypesAndExtensions).toEqual({ allowedFileTypes: ['image', 'document'], allowedFileExtensions: ['.jpg', '.pdf'], @@ -809,10 +780,7 @@ describe('convertFormDataToINputField', () => { const result = convertFormDataToINputField(formData) - expect(result.allowed_file_upload_methods).toEqual([ - 'local_file', - 'remote_url', - ]) + expect(result.allowed_file_upload_methods).toEqual(['local_file', 'remote_url']) }) it('should map allowedTypesAndExtensions to separate fields', () => { @@ -958,13 +926,9 @@ describe('Round-Trip Conversion', () => { expect(result.type).toBe(original.type) expect(result.max_length).toBe(original.max_length) - expect(result.allowed_file_upload_methods).toEqual( - original.allowed_file_upload_methods, - ) + expect(result.allowed_file_upload_methods).toEqual(original.allowed_file_upload_methods) expect(result.allowed_file_types).toEqual(original.allowed_file_types) - expect(result.allowed_file_extensions).toEqual( - original.allowed_file_extensions, - ) + expect(result.allowed_file_extensions).toEqual(original.allowed_file_extensions) }) it('should handle all input types through round-trip', () => { @@ -1009,17 +973,12 @@ describe('Edge Cases', () => { it('should handle options with special characters', () => { const inputVar = createInputVar({ - options: ['<script>', '"quoted"', '\'apostrophe\'', '&'], + options: ['<script>', '"quoted"', "'apostrophe'", '&'], }) const result = convertToInputFieldFormData(inputVar) - expect(result.options).toEqual([ - '<script>', - '"quoted"', - '\'apostrophe\'', - '&', - ]) + expect(result.options).toEqual(['<script>', '"quoted"', "'apostrophe'", '&']) }) it('should handle very long strings', () => { @@ -1122,10 +1081,20 @@ describe('Hook Memoization', () => { } const { rerender } = render( - <TestComponent capture={(ref) => { handleSubmitRef1 = ref }} submitFn={onSubmit} />, + <TestComponent + capture={(ref) => { + handleSubmitRef1 = ref + }} + submitFn={onSubmit} + />, ) rerender( - <TestComponent capture={(ref) => { handleSubmitRef2 = ref }} submitFn={onSubmit} />, + <TestComponent + capture={(ref) => { + handleSubmitRef2 = ref + }} + submitFn={onSubmit} + />, ) expect(handleSubmitRef1).toBe(handleSubmitRef2) @@ -1143,10 +1112,7 @@ describe('Hook Memoization', () => { data: InputVar capture: (fd: FormData) => void }) => { - const formData = React.useMemo( - () => convertToInputFieldFormData(data), - [data], - ) + const formData = React.useMemo(() => convertToInputFieldFormData(data), [data]) capture(formData) return null } @@ -1154,13 +1120,17 @@ describe('Hook Memoization', () => { const { rerender } = render( <TestComponent data={initialData} - capture={(fd) => { formData1 = fd }} + capture={(fd) => { + formData1 = fd + }} />, ) rerender( <TestComponent data={initialData} - capture={(fd) => { formData2 = fd }} + capture={(fd) => { + formData2 = fd + }} />, ) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hooks.spec.ts index 9aac094d4cb045..3b85ddbe957844 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hooks.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hooks.spec.ts @@ -138,7 +138,7 @@ describe('useConfigurations', () => { }), ) - const typeConfig = result.current.find(c => c.variable === 'type') + const typeConfig = result.current.find((c) => c.variable === 'type') expect(typeConfig).toBeDefined() expect(typeConfig?.required).toBe(true) }) @@ -152,7 +152,7 @@ describe('useConfigurations', () => { }), ) - const varConfig = result.current.find(c => c.variable === 'variable') + const varConfig = result.current.find((c) => c.variable === 'variable') expect(varConfig).toBeDefined() expect(varConfig?.required).toBe(true) }) @@ -166,7 +166,7 @@ describe('useConfigurations', () => { }), ) - const labelConfig = result.current.find(c => c.variable === 'label') + const labelConfig = result.current.find((c) => c.variable === 'label') expect(labelConfig).toBeDefined() expect(labelConfig?.required).toBe(false) }) @@ -180,7 +180,7 @@ describe('useConfigurations', () => { }), ) - const requiredConfig = result.current.find(c => c.variable === 'required') + const requiredConfig = result.current.find((c) => c.variable === 'required') expect(requiredConfig).toBeDefined() }) @@ -193,10 +193,16 @@ describe('useConfigurations', () => { }), ) - const typeConfig = result.current.find(c => c.variable === 'type') - typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.singleFile, fieldApi: {} as never }) + const typeConfig = result.current.find((c) => c.variable === 'type') + typeConfig?.listeners?.onChange?.({ + value: PipelineInputVarType.singleFile, + fieldApi: {} as never, + }) - expect(mockSetFieldValue).toHaveBeenCalledWith('allowedFileUploadMethods', ['local_file', 'remote_url']) + expect(mockSetFieldValue).toHaveBeenCalledWith('allowedFileUploadMethods', [ + 'local_file', + 'remote_url', + ]) expect(mockSetFieldValue).toHaveBeenCalledWith('allowedTypesAndExtensions', { allowedFileTypes: ['image', 'document'], allowedFileExtensions: ['.jpg', '.png', '.pdf'], @@ -212,8 +218,11 @@ describe('useConfigurations', () => { }), ) - const typeConfig = result.current.find(c => c.variable === 'type') - typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.multiFiles, fieldApi: {} as never }) + const typeConfig = result.current.find((c) => c.variable === 'type') + typeConfig?.listeners?.onChange?.({ + value: PipelineInputVarType.multiFiles, + fieldApi: {} as never, + }) expect(mockSetFieldValue).toHaveBeenCalledWith('maxLength', 5) }) @@ -227,8 +236,11 @@ describe('useConfigurations', () => { }), ) - const typeConfig = result.current.find(c => c.variable === 'type') - typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.textInput, fieldApi: {} as never }) + const typeConfig = result.current.find((c) => c.variable === 'type') + typeConfig?.listeners?.onChange?.({ + value: PipelineInputVarType.textInput, + fieldApi: {} as never, + }) expect(mockSetFieldValue).not.toHaveBeenCalled() }) @@ -244,7 +256,7 @@ describe('useConfigurations', () => { }), ) - const varConfig = result.current.find(c => c.variable === 'variable') + const varConfig = result.current.find((c) => c.variable === 'variable') varConfig?.listeners?.onBlur?.({ value: 'myVariable', fieldApi: {} as never }) expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'myVariable') @@ -261,7 +273,7 @@ describe('useConfigurations', () => { }), ) - const varConfig = result.current.find(c => c.variable === 'variable') + const varConfig = result.current.find((c) => c.variable === 'variable') varConfig?.listeners?.onBlur?.({ value: 'myVariable', fieldApi: {} as never }) expect(mockSetFieldValue).not.toHaveBeenCalled() @@ -278,7 +290,7 @@ describe('useConfigurations', () => { }), ) - const labelConfig = result.current.find(c => c.variable === 'label') + const labelConfig = result.current.find((c) => c.variable === 'label') labelConfig?.listeners?.onBlur?.({ value: '', fieldApi: {} as never }) expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'existingVar') @@ -291,40 +303,34 @@ describe('useHiddenConfigurations', () => { }) it('should return array of hidden configurations', () => { - const { result } = renderHook(() => - useHiddenConfigurations({ options: undefined }), - ) + const { result } = renderHook(() => useHiddenConfigurations({ options: undefined })) expect(Array.isArray(result.current)).toBe(true) expect(result.current.length).toBeGreaterThan(0) }) it('should include default value config for textInput', () => { - const { result } = renderHook(() => - useHiddenConfigurations({ options: undefined }), - ) + const { result } = renderHook(() => useHiddenConfigurations({ options: undefined })) - const defaultConfigs = result.current.filter(c => c.variable === 'default') + const defaultConfigs = result.current.filter((c) => c.variable === 'default') expect(defaultConfigs.length).toBeGreaterThan(0) }) it('should include tooltips configuration for all types', () => { - const { result } = renderHook(() => - useHiddenConfigurations({ options: undefined }), - ) + const { result } = renderHook(() => useHiddenConfigurations({ options: undefined })) - const tooltipsConfig = result.current.find(c => c.variable === 'tooltips') + const tooltipsConfig = result.current.find((c) => c.variable === 'tooltips') expect(tooltipsConfig).toBeDefined() expect(tooltipsConfig?.showConditions).toEqual([]) }) it('should build select options from provided options', () => { - const { result } = renderHook(() => - useHiddenConfigurations({ options: ['opt1', 'opt2'] }), - ) + const { result } = renderHook(() => useHiddenConfigurations({ options: ['opt1', 'opt2'] })) const selectDefault = result.current.find( - c => c.variable === 'default' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.select), + (c) => + c.variable === 'default' && + c.showConditions?.some((sc) => sc.value === PipelineInputVarType.select), ) expect(selectDefault?.options).toBeDefined() expect(selectDefault?.options?.[0]?.value).toBe('') @@ -333,32 +339,30 @@ describe('useHiddenConfigurations', () => { }) it('should return empty options when options prop is undefined', () => { - const { result } = renderHook(() => - useHiddenConfigurations({ options: undefined }), - ) + const { result } = renderHook(() => useHiddenConfigurations({ options: undefined })) const selectDefault = result.current.find( - c => c.variable === 'default' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.select), + (c) => + c.variable === 'default' && + c.showConditions?.some((sc) => sc.value === PipelineInputVarType.select), ) expect(selectDefault?.options).toEqual([]) }) it('should include upload method configs for file types', () => { - const { result } = renderHook(() => - useHiddenConfigurations({ options: undefined }), - ) + const { result } = renderHook(() => useHiddenConfigurations({ options: undefined })) - const uploadMethods = result.current.filter(c => c.variable === 'allowedFileUploadMethods') + const uploadMethods = result.current.filter((c) => c.variable === 'allowedFileUploadMethods') expect(uploadMethods.length).toBe(2) // singleFile + multiFiles }) it('should include maxLength slider for multiFiles', () => { - const { result } = renderHook(() => - useHiddenConfigurations({ options: undefined }), - ) + const { result } = renderHook(() => useHiddenConfigurations({ options: undefined })) const maxLength = result.current.find( - c => c.variable === 'maxLength' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.multiFiles), + (c) => + c.variable === 'maxLength' && + c.showConditions?.some((sc) => sc.value === PipelineInputVarType.multiFiles), ) expect(maxLength).toBeDefined() expect(maxLength?.description).toBeDefined() diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx index 3c029f43766389..afddad49290062 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx @@ -8,7 +8,10 @@ import { useConfigurations, useHiddenConfigurations, useHiddenFieldNames } from import InputFieldForm from '../index' import { createInputFieldSchema, TEXT_MAX_LENGTH } from '../schema' -const createMockEvent = <T,>(value: T) => ({ value }) as unknown as Parameters<NonNullable<NonNullable<ReturnType<typeof useConfigurations>[number]['listeners']>['onChange']>>[0] +const createMockEvent = <T,>(value: T) => + ({ value }) as unknown as Parameters< + NonNullable<NonNullable<ReturnType<typeof useConfigurations>[number]['listeners']>['onChange']> + >[0] const mockFileUploadConfig = { image_file_size_limit: 10, @@ -45,7 +48,9 @@ const createFormData = (overrides?: Partial<FormData>): FormData => ({ ...overrides, }) -const createInputFieldFormProps = (overrides?: Partial<InputFieldFormProps>): InputFieldFormProps => ({ +const createInputFieldFormProps = ( + overrides?: Partial<InputFieldFormProps>, +): InputFieldFormProps => ({ initialData: createFormData(), supportFile: false, onCancel: vi.fn(), @@ -54,22 +59,19 @@ const createInputFieldFormProps = (overrides?: Partial<InputFieldFormProps>): In ...overrides, }) -const createTestQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, }, - }, -}) + }) const TestWrapper = ({ children }: { children: React.ReactNode }) => { const queryClient = createTestQueryClient() - return ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> - ) + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> } const renderWithProviders = (ui: React.ReactElement) => { @@ -298,10 +300,7 @@ describe('InputFieldForm', () => { fireEvent.submit(form) await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith( - expect.any(Object), - undefined, - ) + expect(onSubmit).toHaveBeenCalledWith(expect.any(Object), undefined) }) }) }) @@ -326,7 +325,9 @@ describe('InputFieldForm', () => { } await waitFor(() => { - expect(screen.queryByText(/appDebug.variableConfig.showAllSettings/i)).not.toBeInTheDocument() + expect( + screen.queryByText(/appDebug.variableConfig.showAllSettings/i), + ).not.toBeInTheDocument() }) }) }) @@ -504,7 +505,7 @@ describe('useConfigurations', () => { }), ) - const typeConfig = result.current.find(config => config.variable === 'type') + const typeConfig = result.current.find((config) => config.variable === 'type') expect(typeConfig).toBeDefined() expect(typeConfig?.required).toBe(true) }) @@ -521,7 +522,7 @@ describe('useConfigurations', () => { }), ) - const variableConfig = result.current.find(config => config.variable === 'variable') + const variableConfig = result.current.find((config) => config.variable === 'variable') expect(variableConfig).toBeDefined() expect(variableConfig?.required).toBe(true) }) @@ -538,7 +539,7 @@ describe('useConfigurations', () => { }), ) - const labelConfig = result.current.find(config => config.variable === 'label') + const labelConfig = result.current.find((config) => config.variable === 'label') expect(labelConfig).toBeDefined() expect(labelConfig?.required).toBe(false) }) @@ -555,7 +556,7 @@ describe('useConfigurations', () => { }), ) - const requiredConfig = result.current.find(config => config.variable === 'required') + const requiredConfig = result.current.find((config) => config.variable === 'required') expect(requiredConfig).toBeDefined() }) @@ -571,7 +572,7 @@ describe('useConfigurations', () => { }), ) - const typeConfig = result.current.find(config => config.variable === 'type') + const typeConfig = result.current.find((config) => config.variable === 'type') expect(typeConfig?.supportFile).toBe(true) }) }) @@ -589,11 +590,14 @@ describe('useConfigurations', () => { }), ) - const typeConfig = result.current.find(config => config.variable === 'type') + const typeConfig = result.current.find((config) => config.variable === 'type') typeConfig?.listeners?.onChange?.(createMockEvent(PipelineInputVarType.singleFile)) expect(mockSetFieldValue).toHaveBeenCalledWith('allowedFileUploadMethods', expect.any(Array)) - expect(mockSetFieldValue).toHaveBeenCalledWith('allowedTypesAndExtensions', expect.any(Object)) + expect(mockSetFieldValue).toHaveBeenCalledWith( + 'allowedTypesAndExtensions', + expect.any(Object), + ) }) it('should call setFieldValue when type changes to multiFiles', () => { @@ -608,7 +612,7 @@ describe('useConfigurations', () => { }), ) - const typeConfig = result.current.find(config => config.variable === 'type') + const typeConfig = result.current.find((config) => config.variable === 'type') typeConfig?.listeners?.onChange?.(createMockEvent(PipelineInputVarType.multiFiles)) expect(mockSetFieldValue).toHaveBeenCalledWith('maxLength', expect.any(Number)) @@ -626,7 +630,7 @@ describe('useConfigurations', () => { }), ) - const variableConfig = result.current.find(config => config.variable === 'variable') + const variableConfig = result.current.find((config) => config.variable === 'variable') variableConfig?.listeners?.onBlur?.(createMockEvent('test_variable')) expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'test_variable') @@ -644,7 +648,7 @@ describe('useConfigurations', () => { }), ) - const variableConfig = result.current.find(config => config.variable === 'variable') + const variableConfig = result.current.find((config) => config.variable === 'variable') variableConfig?.listeners?.onBlur?.(createMockEvent('test_variable')) expect(mockSetFieldValue).not.toHaveBeenCalled() @@ -662,7 +666,7 @@ describe('useConfigurations', () => { }), ) - const labelConfig = result.current.find(config => config.variable === 'label') + const labelConfig = result.current.find((config) => config.variable === 'label') labelConfig?.listeners?.onBlur?.(createMockEvent('')) expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'original_var') @@ -707,7 +711,7 @@ describe('useHiddenConfigurations', () => { useHiddenConfigurations({ options: undefined }), ) - const defaultConfigs = result.current.filter(config => config.variable === 'default') + const defaultConfigs = result.current.filter((config) => config.variable === 'default') expect(defaultConfigs.length).toBeGreaterThan(0) }) @@ -716,7 +720,7 @@ describe('useHiddenConfigurations', () => { useHiddenConfigurations({ options: undefined }), ) - const tooltipsConfig = result.current.find(config => config.variable === 'tooltips') + const tooltipsConfig = result.current.find((config) => config.variable === 'tooltips') expect(tooltipsConfig).toBeDefined() expect(tooltipsConfig?.showConditions).toEqual([]) }) @@ -726,7 +730,9 @@ describe('useHiddenConfigurations', () => { useHiddenConfigurations({ options: undefined }), ) - const placeholderConfigs = result.current.filter(config => config.variable === 'placeholder') + const placeholderConfigs = result.current.filter( + (config) => config.variable === 'placeholder', + ) expect(placeholderConfigs.length).toBeGreaterThan(0) }) @@ -735,7 +741,7 @@ describe('useHiddenConfigurations', () => { useHiddenConfigurations({ options: undefined }), ) - const unitConfig = result.current.find(config => config.variable === 'unit') + const unitConfig = result.current.find((config) => config.variable === 'unit') expect(unitConfig).toBeDefined() expect(unitConfig?.showConditions).toContainEqual({ variable: 'type', @@ -749,7 +755,7 @@ describe('useHiddenConfigurations', () => { ) const uploadMethodConfigs = result.current.filter( - config => config.variable === 'allowedFileUploadMethods', + (config) => config.variable === 'allowedFileUploadMethods', ) expect(uploadMethodConfigs.length).toBe(2) // One for singleFile, one for multiFiles }) @@ -760,8 +766,9 @@ describe('useHiddenConfigurations', () => { ) const maxLengthConfig = result.current.find( - config => config.variable === 'maxLength' - && config.showConditions?.some(c => c.value === PipelineInputVarType.multiFiles), + (config) => + config.variable === 'maxLength' && + config.showConditions?.some((c) => c.value === PipelineInputVarType.multiFiles), ) expect(maxLengthConfig).toBeDefined() }) @@ -771,13 +778,12 @@ describe('useHiddenConfigurations', () => { it('should generate select options from provided options array', () => { const options = ['Option A', 'Option B', 'Option C'] - const { result } = renderHookWithProviders(() => - useHiddenConfigurations({ options }), - ) + const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options })) const selectConfig = result.current.find( - config => config.variable === 'default' - && config.showConditions?.some(c => c.value === PipelineInputVarType.select), + (config) => + config.variable === 'default' && + config.showConditions?.some((c) => c.value === PipelineInputVarType.select), ) expect(selectConfig?.options).toBeDefined() expect(selectConfig?.options?.length).toBe(4) // 3 options + 1 "no default" option @@ -786,15 +792,14 @@ describe('useHiddenConfigurations', () => { it('should include "no default selected" option', () => { const options = ['Option A'] - const { result } = renderHookWithProviders(() => - useHiddenConfigurations({ options }), - ) + const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options })) const selectConfig = result.current.find( - config => config.variable === 'default' - && config.showConditions?.some(c => c.value === PipelineInputVarType.select), + (config) => + config.variable === 'default' && + config.showConditions?.some((c) => c.value === PipelineInputVarType.select), ) - const noDefaultOption = selectConfig?.options?.find(opt => opt.value === '') + const noDefaultOption = selectConfig?.options?.find((opt) => opt.value === '') expect(noDefaultOption).toBeDefined() }) @@ -804,8 +809,9 @@ describe('useHiddenConfigurations', () => { ) const selectConfig = result.current.find( - config => config.variable === 'default' - && config.showConditions?.some(c => c.value === PipelineInputVarType.select), + (config) => + config.variable === 'default' && + config.showConditions?.some((c) => c.value === PipelineInputVarType.select), ) expect(selectConfig?.options).toEqual([]) }) @@ -818,8 +824,9 @@ describe('useHiddenConfigurations', () => { ) const maxLengthConfig = result.current.find( - config => config.variable === 'maxLength' - && config.showConditions?.some(c => c.value === PipelineInputVarType.multiFiles), + (config) => + config.variable === 'maxLength' && + config.showConditions?.some((c) => c.value === PipelineInputVarType.multiFiles), ) expect(maxLengthConfig?.description).toBeDefined() }) @@ -835,7 +842,9 @@ describe('createInputFieldSchema', () => { describe('Common Schema Validation', () => { it('should validate required variable field', () => { - const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { + maxFileUploadLimit: 10, + }) const invalidData = { variable: '', label: 'Test', required: true, type: 'text-input' } const result = schema.safeParse(invalidData) @@ -844,7 +853,9 @@ describe('createInputFieldSchema', () => { }) it('should validate variable max length', () => { - const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { + maxFileUploadLimit: 10, + }) const invalidData = { variable: 'a'.repeat(100), label: 'Test', @@ -859,7 +870,9 @@ describe('createInputFieldSchema', () => { }) it('should validate variable does not start with number', () => { - const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { + maxFileUploadLimit: 10, + }) const invalidData = { variable: '123var', label: 'Test', @@ -874,7 +887,9 @@ describe('createInputFieldSchema', () => { }) it('should validate variable format (alphanumeric and underscore)', () => { - const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { + maxFileUploadLimit: 10, + }) const invalidData = { variable: 'var-name', label: 'Test', @@ -889,7 +904,9 @@ describe('createInputFieldSchema', () => { }) it('should accept valid variable name', () => { - const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { + maxFileUploadLimit: 10, + }) const validData = { variable: 'valid_var_123', label: 'Test', @@ -904,7 +921,9 @@ describe('createInputFieldSchema', () => { }) it('should validate required label field', () => { - const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { + maxFileUploadLimit: 10, + }) const invalidData = { variable: 'test_var', label: '', @@ -921,7 +940,9 @@ describe('createInputFieldSchema', () => { describe('Text Input Schema', () => { it('should validate maxLength within bounds', () => { - const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { + maxFileUploadLimit: 10, + }) const validData = { variable: 'test_var', label: 'Test', @@ -936,7 +957,9 @@ describe('createInputFieldSchema', () => { }) it('should reject maxLength exceeding TEXT_MAX_LENGTH', () => { - const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { + maxFileUploadLimit: 10, + }) const invalidData = { variable: 'test_var', label: 'Test', @@ -951,7 +974,9 @@ describe('createInputFieldSchema', () => { }) it('should reject maxLength less than 1', () => { - const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { + maxFileUploadLimit: 10, + }) const invalidData = { variable: 'test_var', label: 'Test', @@ -966,7 +991,9 @@ describe('createInputFieldSchema', () => { }) it('should allow optional default value', () => { - const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { + maxFileUploadLimit: 10, + }) const validData = { variable: 'test_var', label: 'Test', @@ -984,7 +1011,9 @@ describe('createInputFieldSchema', () => { describe('Paragraph Schema', () => { it('should validate paragraph type similar to textInput', () => { - const schema = createInputFieldSchema(PipelineInputVarType.paragraph, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.paragraph, mockT, { + maxFileUploadLimit: 10, + }) const validData = { variable: 'test_var', label: 'Test', @@ -1001,7 +1030,9 @@ describe('createInputFieldSchema', () => { describe('Number Schema', () => { it('should allow optional default number', () => { - const schema = createInputFieldSchema(PipelineInputVarType.number, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.number, mockT, { + maxFileUploadLimit: 10, + }) const validData = { variable: 'test_var', label: 'Test', @@ -1016,7 +1047,9 @@ describe('createInputFieldSchema', () => { }) it('should allow optional unit', () => { - const schema = createInputFieldSchema(PipelineInputVarType.number, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.number, mockT, { + maxFileUploadLimit: 10, + }) const validData = { variable: 'test_var', label: 'Test', @@ -1033,7 +1066,9 @@ describe('createInputFieldSchema', () => { describe('Select Schema', () => { it('should require non-empty options array', () => { - const schema = createInputFieldSchema(PipelineInputVarType.select, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.select, mockT, { + maxFileUploadLimit: 10, + }) const invalidData = { variable: 'test_var', label: 'Test', @@ -1048,7 +1083,9 @@ describe('createInputFieldSchema', () => { }) it('should accept valid options array', () => { - const schema = createInputFieldSchema(PipelineInputVarType.select, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.select, mockT, { + maxFileUploadLimit: 10, + }) const validData = { variable: 'test_var', label: 'Test', @@ -1063,7 +1100,9 @@ describe('createInputFieldSchema', () => { }) it('should reject duplicate options', () => { - const schema = createInputFieldSchema(PipelineInputVarType.select, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.select, mockT, { + maxFileUploadLimit: 10, + }) const invalidData = { variable: 'test_var', label: 'Test', @@ -1080,7 +1119,9 @@ describe('createInputFieldSchema', () => { describe('Single File Schema', () => { it('should validate allowedFileUploadMethods', () => { - const schema = createInputFieldSchema(PipelineInputVarType.singleFile, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.singleFile, mockT, { + maxFileUploadLimit: 10, + }) const validData = { variable: 'test_var', label: 'Test', @@ -1098,7 +1139,9 @@ describe('createInputFieldSchema', () => { }) it('should validate allowedTypesAndExtensions', () => { - const schema = createInputFieldSchema(PipelineInputVarType.singleFile, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.singleFile, mockT, { + maxFileUploadLimit: 10, + }) const validData = { variable: 'test_var', label: 'Test', @@ -1120,7 +1163,9 @@ describe('createInputFieldSchema', () => { describe('Multi Files Schema', () => { it('should validate maxLength within file upload limit', () => { const maxFileUploadLimit = 10 - const schema = createInputFieldSchema(PipelineInputVarType.multiFiles, mockT, { maxFileUploadLimit }) + const schema = createInputFieldSchema(PipelineInputVarType.multiFiles, mockT, { + maxFileUploadLimit, + }) const validData = { variable: 'test_var', label: 'Test', @@ -1140,7 +1185,9 @@ describe('createInputFieldSchema', () => { it('should reject maxLength exceeding file upload limit', () => { const maxFileUploadLimit = 10 - const schema = createInputFieldSchema(PipelineInputVarType.multiFiles, mockT, { maxFileUploadLimit }) + const schema = createInputFieldSchema(PipelineInputVarType.multiFiles, mockT, { + maxFileUploadLimit, + }) const invalidData = { variable: 'test_var', label: 'Test', @@ -1161,7 +1208,9 @@ describe('createInputFieldSchema', () => { describe('Default Schema', () => { it('should validate checkbox type with common schema', () => { - const schema = createInputFieldSchema(PipelineInputVarType.checkbox, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.checkbox, mockT, { + maxFileUploadLimit: 10, + }) const validData = { variable: 'test_var', label: 'Test', @@ -1175,7 +1224,9 @@ describe('createInputFieldSchema', () => { }) it('should allow passthrough of additional fields', () => { - const schema = createInputFieldSchema(PipelineInputVarType.checkbox, mockT, { maxFileUploadLimit: 10 }) + const schema = createInputFieldSchema(PipelineInputVarType.checkbox, mockT, { + maxFileUploadLimit: 10, + }) const validData = { variable: 'test_var', label: 'Test', diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx index e6bf21ed745be6..0f51a6b1f7e289 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx @@ -9,10 +9,7 @@ type MockForm = { setFieldValue: (fieldName: string, value: unknown) => void } -const { - mockForm, - mockInputField, -} = vi.hoisted(() => ({ +const { mockForm, mockInputField } = vi.hoisted(() => ({ mockForm: { store: {}, getFieldValue: vi.fn(), @@ -26,9 +23,10 @@ const { })) vi.mock('@/app/components/base/form', () => ({ - withForm: ({ render }: { - render: (props: { form: MockForm }) => React.ReactNode - }) => ({ form }: { form?: MockForm }) => render({ form: form ?? mockForm }), + withForm: + ({ render }: { render: (props: { form: MockForm }) => React.ReactNode }) => + ({ form }: { form?: MockForm }) => + render({ form: form ?? mockForm }), })) vi.mock('@/app/components/base/form/form-scenarios/input-field/field', () => ({ @@ -56,11 +54,13 @@ describe('InitialFields', () => { }) as unknown as ComponentType render(<InitialFieldsComp />) - expect(useConfigurations).toHaveBeenCalledWith(expect.objectContaining({ - supportFile: true, - getFieldValue: expect.any(Function), - setFieldValue: expect.any(Function), - })) + expect(useConfigurations).toHaveBeenCalledWith( + expect.objectContaining({ + supportFile: true, + getFieldValue: expect.any(Function), + setFieldValue: expect.any(Function), + }), + ) expect(screen.getAllByTestId('input-field')).toHaveLength(2) expect(screen.getByText('type')).toBeInTheDocument() expect(screen.getByText('label')).toBeInTheDocument() diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/schema.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/schema.spec.ts index d554f9653e0a07..64c40c0843c5c5 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/schema.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/schema.spec.ts @@ -200,7 +200,9 @@ describe('createInputFieldSchema', () => { describe('multiFiles type', () => { it('should validate maxLength against maxFileUploadLimit', () => { - const schema = createInputFieldSchema(PipelineInputVarType.multiFiles, t, { maxFileUploadLimit: 5 }) + const schema = createInputFieldSchema(PipelineInputVarType.multiFiles, t, { + maxFileUploadLimit: 5, + }) const valid = schema.safeParse({ type: 'file-list', diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx index 438fef8d7bf836..fdcc06f35334ae 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx @@ -55,7 +55,9 @@ describe('ShowAllSettings', () => { } render(<ShowAllSettingsHarness />) - fireEvent.click(screen.getByRole('button', { name: /appDebug\.variableConfig\.showAllSettings/ })) + fireEvent.click( + screen.getByRole('button', { name: /appDebug\.variableConfig\.showAllSettings/ }), + ) expect(handleShowAllSettings).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hidden-fields.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hidden-fields.tsx index f7b35e79164c1a..58525e36b37408 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hidden-fields.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hidden-fields.tsx @@ -8,31 +8,28 @@ type HiddenFieldsProps = { initialData?: Record<string, any> } -const HiddenFields = ({ - initialData, -}: HiddenFieldsProps) => withForm({ - defaultValues: initialData, - render: function Render({ - form, - }) { - const options = useStore(form.store, state => state.values.options) +const HiddenFields = ({ initialData }: HiddenFieldsProps) => + withForm({ + defaultValues: initialData, + render: function Render({ form }) { + const options = useStore(form.store, (state) => state.values.options) - const hiddenConfigurations = useHiddenConfigurations({ - options, - }) + const hiddenConfigurations = useHiddenConfigurations({ + options, + }) - return ( - <> - {hiddenConfigurations.map((config, index) => { - const FieldComponent = InputField({ - initialData, - config, - }) - return <FieldComponent key={index} form={form} /> - })} - </> - ) - }, -}) + return ( + <> + {hiddenConfigurations.map((config, index) => { + const FieldComponent = InputField({ + initialData, + config, + }) + return <FieldComponent key={index} form={form} /> + })} + </> + ) + }, + }) export default HiddenFields diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks.ts index 5a700acab68a21..648c8f01c2f220 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks.ts @@ -19,50 +19,48 @@ export const useHiddenFieldNames = (type: PipelineInputVarType) => { case PipelineInputVarType.textInput: case PipelineInputVarType.paragraph: fieldNames = [ - t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' }), - t($ => $['variableConfig.placeholder'], { ns: 'appDebug' }), - t($ => $['variableConfig.tooltips'], { ns: 'appDebug' }), + t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' }), + t(($) => $['variableConfig.placeholder'], { ns: 'appDebug' }), + t(($) => $['variableConfig.tooltips'], { ns: 'appDebug' }), ] break case PipelineInputVarType.number: fieldNames = [ - t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' }), - t($ => $['variableConfig.unit'], { ns: 'appDebug' }), - t($ => $['variableConfig.placeholder'], { ns: 'appDebug' }), - t($ => $['variableConfig.tooltips'], { ns: 'appDebug' }), + t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' }), + t(($) => $['variableConfig.unit'], { ns: 'appDebug' }), + t(($) => $['variableConfig.placeholder'], { ns: 'appDebug' }), + t(($) => $['variableConfig.tooltips'], { ns: 'appDebug' }), ] break case PipelineInputVarType.select: fieldNames = [ - t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' }), - t($ => $['variableConfig.tooltips'], { ns: 'appDebug' }), + t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' }), + t(($) => $['variableConfig.tooltips'], { ns: 'appDebug' }), ] break case PipelineInputVarType.singleFile: fieldNames = [ - t($ => $['variableConfig.uploadMethod'], { ns: 'appDebug' }), - t($ => $['variableConfig.tooltips'], { ns: 'appDebug' }), + t(($) => $['variableConfig.uploadMethod'], { ns: 'appDebug' }), + t(($) => $['variableConfig.tooltips'], { ns: 'appDebug' }), ] break case PipelineInputVarType.multiFiles: fieldNames = [ - t($ => $['variableConfig.uploadMethod'], { ns: 'appDebug' }), - t($ => $['variableConfig.maxNumberOfUploads'], { ns: 'appDebug' }), - t($ => $['variableConfig.tooltips'], { ns: 'appDebug' }), + t(($) => $['variableConfig.uploadMethod'], { ns: 'appDebug' }), + t(($) => $['variableConfig.maxNumberOfUploads'], { ns: 'appDebug' }), + t(($) => $['variableConfig.tooltips'], { ns: 'appDebug' }), ] break case PipelineInputVarType.checkbox: fieldNames = [ - t($ => $['variableConfig.startChecked'], { ns: 'appDebug' }), - t($ => $['variableConfig.tooltips'], { ns: 'appDebug' }), + t(($) => $['variableConfig.startChecked'], { ns: 'appDebug' }), + t(($) => $['variableConfig.tooltips'], { ns: 'appDebug' }), ] break default: - fieldNames = [ - t($ => $['variableConfig.tooltips'], { ns: 'appDebug' }), - ] + fieldNames = [t(($) => $['variableConfig.tooltips'], { ns: 'appDebug' })] } - return fieldNames.map(name => name.toLowerCase()).join(', ') + return fieldNames.map((name) => name.toLowerCase()).join(', ') }, [type, t]) return hiddenFieldNames @@ -76,133 +74,154 @@ export const useConfigurations = (props: { const { t } = useTranslation() const { getFieldValue, setFieldValue, supportFile } = props - const handleTypeChange = useCallback((type: PipelineInputVarType) => { - if ([PipelineInputVarType.singleFile, PipelineInputVarType.multiFiles].includes(type)) { - setFieldValue('allowedFileUploadMethods', DEFAULT_FILE_UPLOAD_SETTING.allowed_file_upload_methods) - setFieldValue('allowedTypesAndExtensions', { - allowedFileTypes: DEFAULT_FILE_UPLOAD_SETTING.allowed_file_types, - allowedFileExtensions: DEFAULT_FILE_UPLOAD_SETTING.allowed_file_extensions, - }) - if (type === PipelineInputVarType.multiFiles) - setFieldValue('maxLength', DEFAULT_FILE_UPLOAD_SETTING.max_length) - } - }, [setFieldValue]) + const handleTypeChange = useCallback( + (type: PipelineInputVarType) => { + if ([PipelineInputVarType.singleFile, PipelineInputVarType.multiFiles].includes(type)) { + setFieldValue( + 'allowedFileUploadMethods', + DEFAULT_FILE_UPLOAD_SETTING.allowed_file_upload_methods, + ) + setFieldValue('allowedTypesAndExtensions', { + allowedFileTypes: DEFAULT_FILE_UPLOAD_SETTING.allowed_file_types, + allowedFileExtensions: DEFAULT_FILE_UPLOAD_SETTING.allowed_file_extensions, + }) + if (type === PipelineInputVarType.multiFiles) + setFieldValue('maxLength', DEFAULT_FILE_UPLOAD_SETTING.max_length) + } + }, + [setFieldValue], + ) - const handleVariableNameBlur = useCallback((value: string) => { - const label = getFieldValue('label') - if (!value || label) - return - setFieldValue('label', value) - }, [getFieldValue, setFieldValue]) + const handleVariableNameBlur = useCallback( + (value: string) => { + const label = getFieldValue('label') + if (!value || label) return + setFieldValue('label', value) + }, + [getFieldValue, setFieldValue], + ) - const handleDisplayNameBlur = useCallback((value: string) => { - if (!value) - setFieldValue('label', getFieldValue('variable')) - }, [getFieldValue, setFieldValue]) + const handleDisplayNameBlur = useCallback( + (value: string) => { + if (!value) setFieldValue('label', getFieldValue('variable')) + }, + [getFieldValue, setFieldValue], + ) const initialConfigurations = useMemo((): InputFieldConfiguration[] => { - return [{ - type: InputFieldType.inputTypeSelect, - label: t($ => $['variableConfig.fieldType'], { ns: 'appDebug' }), - variable: 'type', - required: true, - showConditions: [], - listeners: { - onChange: ({ value }) => handleTypeChange(value), + return [ + { + type: InputFieldType.inputTypeSelect, + label: t(($) => $['variableConfig.fieldType'], { ns: 'appDebug' }), + variable: 'type', + required: true, + showConditions: [], + listeners: { + onChange: ({ value }) => handleTypeChange(value), + }, + supportFile, }, - supportFile, - }, { - type: InputFieldType.textInput, - label: t($ => $['variableConfig.varName'], { ns: 'appDebug' }), - variable: 'variable', - placeholder: t($ => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' }), - required: true, - listeners: { - onBlur: ({ value }) => handleVariableNameBlur(value), + { + type: InputFieldType.textInput, + label: t(($) => $['variableConfig.varName'], { ns: 'appDebug' }), + variable: 'variable', + placeholder: t(($) => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' }), + required: true, + listeners: { + onBlur: ({ value }) => handleVariableNameBlur(value), + }, + showConditions: [], }, - showConditions: [], - }, { - type: InputFieldType.textInput, - label: t($ => $['variableConfig.displayName'], { ns: 'appDebug' }), - variable: 'label', - placeholder: t($ => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' }), - required: false, - listeners: { - onBlur: ({ value }) => handleDisplayNameBlur(value), + { + type: InputFieldType.textInput, + label: t(($) => $['variableConfig.displayName'], { ns: 'appDebug' }), + variable: 'label', + placeholder: t(($) => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' }), + required: false, + listeners: { + onBlur: ({ value }) => handleDisplayNameBlur(value), + }, + showConditions: [], }, - showConditions: [], - }, { - type: InputFieldType.numberInput, - label: t($ => $['variableConfig.maxLength'], { ns: 'appDebug' }), - variable: 'maxLength', - placeholder: t($ => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' }), - required: true, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.textInput, - }], - min: 1, - max: TEXT_MAX_LENGTH, - }, { - type: InputFieldType.options, - label: t($ => $['variableConfig.options'], { ns: 'appDebug' }), - variable: 'options', - required: true, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.select, - }], - }, { - type: InputFieldType.fileTypes, - label: t($ => $['variableConfig.file.supportFileTypes'], { ns: 'appDebug' }), - variable: 'allowedTypesAndExtensions', - required: true, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.singleFile, - }], - }, { - type: InputFieldType.fileTypes, - label: t($ => $['variableConfig.file.supportFileTypes'], { ns: 'appDebug' }), - variable: 'allowedTypesAndExtensions', - required: true, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.multiFiles, - }], - }, { - type: InputFieldType.checkbox, - label: t($ => $['variableConfig.required'], { ns: 'appDebug' }), - variable: 'required', - required: true, - showConditions: [], - }] + { + type: InputFieldType.numberInput, + label: t(($) => $['variableConfig.maxLength'], { ns: 'appDebug' }), + variable: 'maxLength', + placeholder: t(($) => $['variableConfig.inputPlaceholder'], { ns: 'appDebug' }), + required: true, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.textInput, + }, + ], + min: 1, + max: TEXT_MAX_LENGTH, + }, + { + type: InputFieldType.options, + label: t(($) => $['variableConfig.options'], { ns: 'appDebug' }), + variable: 'options', + required: true, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.select, + }, + ], + }, + { + type: InputFieldType.fileTypes, + label: t(($) => $['variableConfig.file.supportFileTypes'], { ns: 'appDebug' }), + variable: 'allowedTypesAndExtensions', + required: true, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.singleFile, + }, + ], + }, + { + type: InputFieldType.fileTypes, + label: t(($) => $['variableConfig.file.supportFileTypes'], { ns: 'appDebug' }), + variable: 'allowedTypesAndExtensions', + required: true, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.multiFiles, + }, + ], + }, + { + type: InputFieldType.checkbox, + label: t(($) => $['variableConfig.required'], { ns: 'appDebug' }), + variable: 'required', + required: true, + showConditions: [], + }, + ] }, [t, supportFile, handleTypeChange, handleVariableNameBlur, handleDisplayNameBlur]) return initialConfigurations } -export const useHiddenConfigurations = (props: { - options: string[] | undefined -}) => { +export const useHiddenConfigurations = (props: { options: string[] | undefined }) => { const { t } = useTranslation() const { options } = props const { data: fileUploadConfigResponse } = useFileUploadConfig() - const { - imgSizeLimit, - docSizeLimit, - audioSizeLimit, - videoSizeLimit, - } = useFileSizeLimit(fileUploadConfigResponse) + const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = + useFileSizeLimit(fileUploadConfigResponse) const defaultSelectOptions = useMemo(() => { if (options) { const defaultOptions = [ { value: '', - label: t($ => $['variableConfig.noDefaultSelected'], { ns: 'appDebug' }), + label: t(($) => $['variableConfig.noDefaultSelected'], { ns: 'appDebug' }), }, ] const otherOptions = options.map((option: string) => ({ @@ -215,148 +234,186 @@ export const useHiddenConfigurations = (props: { }, [options, t]) const hiddenConfigurations = useMemo((): InputFieldConfiguration[] => { - return [{ - type: InputFieldType.textInput, - label: t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' }), - variable: 'default', - placeholder: t($ => $['variableConfig.defaultValuePlaceholder'], { ns: 'appDebug' }), - required: false, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.textInput, - }], - showOptional: true, - }, { - type: InputFieldType.textInput, - label: t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' }), - variable: 'default', - placeholder: t($ => $['variableConfig.defaultValuePlaceholder'], { ns: 'appDebug' }), - required: false, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.paragraph, - }], - showOptional: true, - }, { - type: InputFieldType.numberInput, - label: t($ => $['variableConfig.defaultValue'], { ns: 'appDebug' }), - variable: 'default', - placeholder: t($ => $['variableConfig.defaultValuePlaceholder'], { ns: 'appDebug' }), - required: false, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.number, - }], - showOptional: true, - }, { - type: InputFieldType.select, - label: t($ => $['variableConfig.startSelectedOption'], { ns: 'appDebug' }), - variable: 'default', - required: false, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.select, - }], - showOptional: true, - options: defaultSelectOptions, - popupProps: { - wrapperClassName: 'z-40', + return [ + { + type: InputFieldType.textInput, + label: t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' }), + variable: 'default', + placeholder: t(($) => $['variableConfig.defaultValuePlaceholder'], { ns: 'appDebug' }), + required: false, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.textInput, + }, + ], + showOptional: true, }, - }, { - type: InputFieldType.checkbox, - label: t($ => $['variableConfig.startChecked'], { ns: 'appDebug' }), - variable: 'default', - required: false, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.checkbox, - }], - }, { - type: InputFieldType.textInput, - label: t($ => $['variableConfig.placeholder'], { ns: 'appDebug' }), - variable: 'placeholder', - placeholder: t($ => $['variableConfig.placeholderPlaceholder'], { ns: 'appDebug' }), - required: false, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.textInput, - }], - showOptional: true, - }, { - type: InputFieldType.textInput, - label: t($ => $['variableConfig.placeholder'], { ns: 'appDebug' }), - variable: 'placeholder', - placeholder: t($ => $['variableConfig.placeholderPlaceholder'], { ns: 'appDebug' }), - required: false, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.paragraph, - }], - showOptional: true, - }, { - type: InputFieldType.textInput, - label: t($ => $['variableConfig.unit'], { ns: 'appDebug' }), - variable: 'unit', - placeholder: t($ => $['variableConfig.unitPlaceholder'], { ns: 'appDebug' }), - required: false, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.number, - }], - showOptional: true, - }, { - type: InputFieldType.textInput, - label: t($ => $['variableConfig.placeholder'], { ns: 'appDebug' }), - variable: 'placeholder', - placeholder: t($ => $['variableConfig.placeholderPlaceholder'], { ns: 'appDebug' }), - required: false, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.number, - }], - showOptional: true, - }, { - type: InputFieldType.uploadMethod, - label: t($ => $['variableConfig.uploadFileTypes'], { ns: 'appDebug' }), - variable: 'allowedFileUploadMethods', - required: false, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.singleFile, - }], - }, { - type: InputFieldType.uploadMethod, - label: t($ => $['variableConfig.uploadFileTypes'], { ns: 'appDebug' }), - variable: 'allowedFileUploadMethods', - required: false, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.multiFiles, - }], - }, { - type: InputFieldType.numberSlider, - label: t($ => $['variableConfig.maxNumberOfUploads'], { ns: 'appDebug' }), - variable: 'maxLength', - required: false, - showConditions: [{ - variable: 'type', - value: PipelineInputVarType.multiFiles, - }], - description: t($ => $['variableConfig.maxNumberTip'], { - ns: 'appDebug', - imgLimit: formatFileSize(imgSizeLimit), - docLimit: formatFileSize(docSizeLimit), - audioLimit: formatFileSize(audioSizeLimit), - videoLimit: formatFileSize(videoSizeLimit), - }), - }, { - type: InputFieldType.textInput, - label: t($ => $['variableConfig.tooltips'], { ns: 'appDebug' }), - variable: 'tooltips', - required: false, - showConditions: [], - showOptional: true, - }] + { + type: InputFieldType.textInput, + label: t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' }), + variable: 'default', + placeholder: t(($) => $['variableConfig.defaultValuePlaceholder'], { ns: 'appDebug' }), + required: false, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.paragraph, + }, + ], + showOptional: true, + }, + { + type: InputFieldType.numberInput, + label: t(($) => $['variableConfig.defaultValue'], { ns: 'appDebug' }), + variable: 'default', + placeholder: t(($) => $['variableConfig.defaultValuePlaceholder'], { ns: 'appDebug' }), + required: false, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.number, + }, + ], + showOptional: true, + }, + { + type: InputFieldType.select, + label: t(($) => $['variableConfig.startSelectedOption'], { ns: 'appDebug' }), + variable: 'default', + required: false, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.select, + }, + ], + showOptional: true, + options: defaultSelectOptions, + popupProps: { + wrapperClassName: 'z-40', + }, + }, + { + type: InputFieldType.checkbox, + label: t(($) => $['variableConfig.startChecked'], { ns: 'appDebug' }), + variable: 'default', + required: false, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.checkbox, + }, + ], + }, + { + type: InputFieldType.textInput, + label: t(($) => $['variableConfig.placeholder'], { ns: 'appDebug' }), + variable: 'placeholder', + placeholder: t(($) => $['variableConfig.placeholderPlaceholder'], { ns: 'appDebug' }), + required: false, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.textInput, + }, + ], + showOptional: true, + }, + { + type: InputFieldType.textInput, + label: t(($) => $['variableConfig.placeholder'], { ns: 'appDebug' }), + variable: 'placeholder', + placeholder: t(($) => $['variableConfig.placeholderPlaceholder'], { ns: 'appDebug' }), + required: false, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.paragraph, + }, + ], + showOptional: true, + }, + { + type: InputFieldType.textInput, + label: t(($) => $['variableConfig.unit'], { ns: 'appDebug' }), + variable: 'unit', + placeholder: t(($) => $['variableConfig.unitPlaceholder'], { ns: 'appDebug' }), + required: false, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.number, + }, + ], + showOptional: true, + }, + { + type: InputFieldType.textInput, + label: t(($) => $['variableConfig.placeholder'], { ns: 'appDebug' }), + variable: 'placeholder', + placeholder: t(($) => $['variableConfig.placeholderPlaceholder'], { ns: 'appDebug' }), + required: false, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.number, + }, + ], + showOptional: true, + }, + { + type: InputFieldType.uploadMethod, + label: t(($) => $['variableConfig.uploadFileTypes'], { ns: 'appDebug' }), + variable: 'allowedFileUploadMethods', + required: false, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.singleFile, + }, + ], + }, + { + type: InputFieldType.uploadMethod, + label: t(($) => $['variableConfig.uploadFileTypes'], { ns: 'appDebug' }), + variable: 'allowedFileUploadMethods', + required: false, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.multiFiles, + }, + ], + }, + { + type: InputFieldType.numberSlider, + label: t(($) => $['variableConfig.maxNumberOfUploads'], { ns: 'appDebug' }), + variable: 'maxLength', + required: false, + showConditions: [ + { + variable: 'type', + value: PipelineInputVarType.multiFiles, + }, + ], + description: t(($) => $['variableConfig.maxNumberTip'], { + ns: 'appDebug', + imgLimit: formatFileSize(imgSizeLimit), + docLimit: formatFileSize(docSizeLimit), + audioLimit: formatFileSize(audioSizeLimit), + videoLimit: formatFileSize(videoSizeLimit), + }), + }, + { + type: InputFieldType.textInput, + label: t(($) => $['variableConfig.tooltips'], { ns: 'appDebug' }), + variable: 'tooltips', + required: false, + showConditions: [], + showOptional: true, + }, + ] }, [defaultSelectOptions, imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit, t]) return hiddenConfigurations diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.tsx index b6a6d7d1456e7c..eaa09f77b1830c 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.tsx @@ -14,7 +14,13 @@ import InitialFields from './initial-fields' import { createInputFieldSchema } from './schema' import ShowAllSettings from './show-all-settings' -const InputFieldForm = ({ initialData, supportFile = false, onCancel, onSubmit, isEditMode = true }: InputFieldFormProps) => { +const InputFieldForm = ({ + initialData, + supportFile = false, + onCancel, + onSubmit, + isEditMode = true, +}: InputFieldFormProps) => { const { t } = useTranslation() const { data: fileUploadConfigResponse } = useFileUploadConfig() const { maxFileUploadLimit } = useFileSizeLimit(fileUploadConfigResponse) @@ -73,12 +79,12 @@ const InputFieldForm = ({ initialData, supportFile = false, onCancel, onSubmit, <div className="flex flex-col gap-4 px-4 py-2"> <InitialFieldsComp form={inputFieldForm} /> <Divider type="horizontal" /> - {!showAllSettings && (<ShowAllSettingComp form={inputFieldForm} />)} - {showAllSettings && (<HiddenFieldsComp form={inputFieldForm} />)} + {!showAllSettings && <ShowAllSettingComp form={inputFieldForm} />} + {showAllSettings && <HiddenFieldsComp form={inputFieldForm} />} </div> <div className="flex items-center justify-end gap-x-2 p-4 pt-2"> <Button variant="secondary" onClick={onCancel}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <inputFieldForm.AppForm> <inputFieldForm.Actions /> diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/initial-fields.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/initial-fields.tsx index 1cd6e247894aba..45cd6b0001c7c4 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/initial-fields.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/initial-fields.tsx @@ -9,40 +9,42 @@ type InitialFieldsProps = { supportFile: boolean } -const InitialFields = ({ - initialData, - supportFile, -}: InitialFieldsProps) => withForm({ - defaultValues: initialData, - render: function Render({ - form, - }) { - const getFieldValue = useCallback((fieldName: string) => { - return form.getFieldValue(fieldName) - }, [form]) +const InitialFields = ({ initialData, supportFile }: InitialFieldsProps) => + withForm({ + defaultValues: initialData, + render: function Render({ form }) { + const getFieldValue = useCallback( + (fieldName: string) => { + return form.getFieldValue(fieldName) + }, + [form], + ) - const setFieldValue = useCallback((fieldName: string, value: any) => { - form.setFieldValue(fieldName, value) - }, [form]) + const setFieldValue = useCallback( + (fieldName: string, value: any) => { + form.setFieldValue(fieldName, value) + }, + [form], + ) - const initialConfigurations = useConfigurations({ - getFieldValue, - setFieldValue, - supportFile, - }) + const initialConfigurations = useConfigurations({ + getFieldValue, + setFieldValue, + supportFile, + }) - return ( - <> - {initialConfigurations.map((config, index) => { - const FieldComponent = InputField({ - initialData, - config, - }) - return <FieldComponent key={index} form={form} /> - })} - </> - ) - }, -}) + return ( + <> + {initialConfigurations.map((config, index) => { + const FieldComponent = InputField({ + initialData, + config, + }) + return <FieldComponent key={index} form={form} /> + })} + </> + ) + }, + }) export default InitialFields diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/schema.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/schema.ts index d19311ba24cf92..ce7da842faac08 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/schema.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/schema.ts @@ -7,83 +7,109 @@ import { PipelineInputVarType } from '@/models/pipeline' export const TEXT_MAX_LENGTH = 256 -const TransferMethod = z.enum([ - 'all', - 'local_file', - 'remote_url', -]) +const TransferMethod = z.enum(['all', 'local_file', 'remote_url']) -const SupportedFileTypes = z.enum([ - 'image', - 'document', - 'video', - 'audio', - 'custom', -]) +const SupportedFileTypes = z.enum(['image', 'document', 'video', 'audio', 'custom']) -export const createInputFieldSchema = (type: PipelineInputVarType, t: TFunction, options: SchemaOptions) => { +export const createInputFieldSchema = ( + type: PipelineInputVarType, + t: TFunction, + options: SchemaOptions, +) => { const { maxFileUploadLimit } = options const commonSchema = z.object({ type: InputTypeEnum, - variable: z.string().nonempty({ - message: t($ => $['varKeyError.canNoBeEmpty'], { ns: 'appDebug', key: t($ => $['variableConfig.varName'], { ns: 'appDebug' }) }), - }).max(MAX_VAR_KEY_LENGTH, { - message: t($ => $['varKeyError.tooLong'], { ns: 'appDebug', key: t($ => $['variableConfig.varName'], { ns: 'appDebug' }) }), - }).regex(/^(?!\d)\w+/, { - message: t($ => $['varKeyError.notStartWithNumber'], { ns: 'appDebug', key: t($ => $['variableConfig.varName'], { ns: 'appDebug' }) }), - }).regex(/^[a-z_]\w{0,29}$/i, { - message: t($ => $['varKeyError.notValid'], { ns: 'appDebug', key: t($ => $['variableConfig.varName'], { ns: 'appDebug' }) }), - }), + variable: z + .string() + .nonempty({ + message: t(($) => $['varKeyError.canNoBeEmpty'], { + ns: 'appDebug', + key: t(($) => $['variableConfig.varName'], { ns: 'appDebug' }), + }), + }) + .max(MAX_VAR_KEY_LENGTH, { + message: t(($) => $['varKeyError.tooLong'], { + ns: 'appDebug', + key: t(($) => $['variableConfig.varName'], { ns: 'appDebug' }), + }), + }) + .regex(/^(?!\d)\w+/, { + message: t(($) => $['varKeyError.notStartWithNumber'], { + ns: 'appDebug', + key: t(($) => $['variableConfig.varName'], { ns: 'appDebug' }), + }), + }) + .regex(/^[a-z_]\w{0,29}$/i, { + message: t(($) => $['varKeyError.notValid'], { + ns: 'appDebug', + key: t(($) => $['variableConfig.varName'], { ns: 'appDebug' }), + }), + }), label: z.string().nonempty({ - message: t($ => $['variableConfig.errorMsg.labelNameRequired'], { ns: 'appDebug' }), + message: t(($) => $['variableConfig.errorMsg.labelNameRequired'], { ns: 'appDebug' }), }), required: z.boolean(), tooltips: z.string().optional(), }) if (type === PipelineInputVarType.textInput || type === PipelineInputVarType.paragraph) { - return z.object({ - maxLength: z.number().min(1).max(TEXT_MAX_LENGTH), - default: z.string().optional(), - }).merge(commonSchema).passthrough() + return z + .object({ + maxLength: z.number().min(1).max(TEXT_MAX_LENGTH), + default: z.string().optional(), + }) + .merge(commonSchema) + .passthrough() } if (type === PipelineInputVarType.number) { - return z.object({ - default: z.number().optional(), - unit: z.string().optional(), - placeholder: z.string().optional(), - }).merge(commonSchema).passthrough() + return z + .object({ + default: z.number().optional(), + unit: z.string().optional(), + placeholder: z.string().optional(), + }) + .merge(commonSchema) + .passthrough() } if (type === PipelineInputVarType.select) { - return z.object({ - options: z.array(z.string()).nonempty({ - message: t($ => $['variableConfig.errorMsg.atLeastOneOption'], { ns: 'appDebug' }), - }).refine( - arr => new Set(arr).size === arr.length, - { - message: t($ => $['variableConfig.errorMsg.optionRepeat'], { ns: 'appDebug' }), - }, - ), - default: z.string().optional(), - }).merge(commonSchema).passthrough() + return z + .object({ + options: z + .array(z.string()) + .nonempty({ + message: t(($) => $['variableConfig.errorMsg.atLeastOneOption'], { ns: 'appDebug' }), + }) + .refine((arr) => new Set(arr).size === arr.length, { + message: t(($) => $['variableConfig.errorMsg.optionRepeat'], { ns: 'appDebug' }), + }), + default: z.string().optional(), + }) + .merge(commonSchema) + .passthrough() } if (type === PipelineInputVarType.singleFile) { - return z.object({ - allowedFileUploadMethods: z.array(TransferMethod), - allowedTypesAndExtensions: z.object({ - allowedFileExtensions: z.array(z.string()).optional(), - allowedFileTypes: z.array(SupportedFileTypes), - }), - }).merge(commonSchema).passthrough() + return z + .object({ + allowedFileUploadMethods: z.array(TransferMethod), + allowedTypesAndExtensions: z.object({ + allowedFileExtensions: z.array(z.string()).optional(), + allowedFileTypes: z.array(SupportedFileTypes), + }), + }) + .merge(commonSchema) + .passthrough() } if (type === PipelineInputVarType.multiFiles) { - return z.object({ - allowedFileUploadMethods: z.array(TransferMethod), - allowedTypesAndExtensions: z.object({ - allowedFileExtensions: z.array(z.string()).optional(), - allowedFileTypes: z.array(SupportedFileTypes), - }), - maxLength: z.number().min(1).max(maxFileUploadLimit), - }).merge(commonSchema).passthrough() + return z + .object({ + allowedFileUploadMethods: z.array(TransferMethod), + allowedTypesAndExtensions: z.object({ + allowedFileExtensions: z.array(z.string()).optional(), + allowedFileTypes: z.array(SupportedFileTypes), + }), + maxLength: z.number().min(1).max(maxFileUploadLimit), + }) + .merge(commonSchema) + .passthrough() } return commonSchema.passthrough() } diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/show-all-settings.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/show-all-settings.tsx index ffdeb00f6db7c5..7597ac33f4731d 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/show-all-settings.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/show-all-settings.tsx @@ -10,37 +10,33 @@ type ShowAllSettingsProps = { handleShowAllSettings: () => void } -const ShowAllSettings = ({ - initialData, - handleShowAllSettings, -}: ShowAllSettingsProps) => withForm({ - defaultValues: initialData, - render: function Render({ - form, - }) { - const { t } = useTranslation() - const type = useStore(form.store, state => state.values.type) +const ShowAllSettings = ({ initialData, handleShowAllSettings }: ShowAllSettingsProps) => + withForm({ + defaultValues: initialData, + render: function Render({ form }) { + const { t } = useTranslation() + const type = useStore(form.store, (state) => state.values.type) - const hiddenFieldNames = useHiddenFieldNames(type) + const hiddenFieldNames = useHiddenFieldNames(type) - return ( - <button - type="button" - className="flex w-full cursor-pointer items-center gap-x-4 border-none bg-transparent p-0 text-left" - onClick={handleShowAllSettings} - > - <div className="flex grow flex-col"> - <span className="flex min-h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $['variableConfig.showAllSettings'], { ns: 'appDebug' })} - </span> - <span className="pb-0.5 body-xs-regular text-text-tertiary first-letter:capitalize"> - {hiddenFieldNames} - </span> - </div> - <RiArrowRightSLine className="size-4 shrink-0 text-text-secondary" aria-hidden="true" /> - </button> - ) - }, -}) + return ( + <button + type="button" + className="flex w-full cursor-pointer items-center gap-x-4 border-none bg-transparent p-0 text-left" + onClick={handleShowAllSettings} + > + <div className="flex grow flex-col"> + <span className="flex min-h-6 items-center system-sm-medium text-text-secondary"> + {t(($) => $['variableConfig.showAllSettings'], { ns: 'appDebug' })} + </span> + <span className="pb-0.5 body-xs-regular text-text-tertiary first-letter:capitalize"> + {hiddenFieldNames} + </span> + </div> + <RiArrowRightSLine className="size-4 shrink-0 text-text-secondary" aria-hidden="true" /> + </button> + ) + }, + }) export default ShowAllSettings diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/index.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/index.tsx index bfda8c8270b746..e8c5d8891447f1 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/index.tsx @@ -15,11 +15,7 @@ export type InputFieldEditorProps = { initialData?: InputVar } -const InputFieldEditorPanel = ({ - onClose, - onSubmit, - initialData, -}: InputFieldEditorProps) => { +const InputFieldEditorPanel = ({ onClose, onSubmit, initialData }: InputFieldEditorProps) => { const { t } = useTranslation() const { floatingRight, floatingRightWidth } = useFloatingRight(400) @@ -28,10 +24,13 @@ const InputFieldEditorPanel = ({ return convertToInputFieldFormData(initialData) }, [initialData]) - const handleSubmit = useCallback((value: FormData, moreInfo?: MoreInfo) => { - const inputFieldData = convertFormDataToINputField(value) - onSubmit(inputFieldData, moreInfo) - }, [onSubmit]) + const handleSubmit = useCallback( + (value: FormData, moreInfo?: MoreInfo) => { + const inputFieldData = convertFormDataToINputField(value) + onSubmit(inputFieldData, moreInfo) + }, + [onSubmit], + ) return ( <div @@ -45,11 +44,13 @@ const InputFieldEditorPanel = ({ }} > <div className="flex items-center pt-3.5 pr-11 pb-1 pl-4 system-xl-semibold text-text-primary"> - {initialData ? t($ => $['inputFieldPanel.editInputField'], { ns: 'datasetPipeline' }) : t($ => $['inputFieldPanel.addInputField'], { ns: 'datasetPipeline' })} + {initialData + ? t(($) => $['inputFieldPanel.editInputField'], { ns: 'datasetPipeline' }) + : t(($) => $['inputFieldPanel.addInputField'], { ns: 'datasetPipeline' })} </div> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-2.5 right-2.5 flex size-8 items-center justify-center border-none bg-transparent p-0" onClick={onClose} > diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/utils.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/utils.ts index 366ad798937239..c3921e7911be94 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/utils.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/utils.ts @@ -35,16 +35,11 @@ export const convertToInputFieldFormData = (data?: InputVar): FormData => { allowedTypesAndExtensions: {}, } - if (default_value !== undefined && default_value !== null) - formData.default = default_value - if (tooltips !== undefined && tooltips !== null) - formData.tooltips = tooltips - if (placeholder !== undefined && placeholder !== null) - formData.placeholder = placeholder - if (unit !== undefined && unit !== null) - formData.unit = unit - if (allowed_file_upload_methods) - formData.allowedFileUploadMethods = allowed_file_upload_methods + if (default_value !== undefined && default_value !== null) formData.default = default_value + if (tooltips !== undefined && tooltips !== null) formData.tooltips = tooltips + if (placeholder !== undefined && placeholder !== null) formData.placeholder = placeholder + if (unit !== undefined && unit !== null) formData.unit = unit + if (allowed_file_upload_methods) formData.allowedFileUploadMethods = allowed_file_upload_methods if (allowed_file_types && allowed_file_extensions) { formData.allowedTypesAndExtensions = { allowedFileTypes: allowed_file_types, diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx index 8f5d9b53868277..1728bec9301a16 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx @@ -27,12 +27,7 @@ describe('FieldItem', () => { it('should render the variable, label, and required badge', () => { render( - <FieldItem - payload={createInputVar()} - index={0} - onClickEdit={vi.fn()} - onRemove={vi.fn()} - />, + <FieldItem payload={createInputVar()} index={0} onClickEdit={vi.fn()} onRemove={vi.fn()} />, ) expect(screen.getByText('field_name'))!.toBeInTheDocument() diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts index 6dfc3422f34f47..3876c2d535dea9 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts @@ -75,7 +75,9 @@ describe('useFieldList', () => { describe('initialization', () => { it('should return inputFields from initialInputFields', () => { const fields = [createInputVar({ variable: 'var1' })] - const { result } = renderHook(() => useFieldList(createDefaultProps({ initialInputFields: fields }))) + const { result } = renderHook(() => + useFieldList(createDefaultProps({ initialInputFields: fields })), + ) expect(result.current.inputFields).toEqual(fields) }) @@ -109,10 +111,12 @@ describe('useFieldList', () => { const var2 = createInputVar({ variable: 'var2', label: 'V2' }) const onInputFieldsChange = vi.fn() const { result } = renderHook(() => - useFieldList(createDefaultProps({ - initialInputFields: [var1, var2], - onInputFieldsChange, - })), + useFieldList( + createDefaultProps({ + initialInputFields: [var1, var2], + onInputFieldsChange, + }), + ), ) act(() => { @@ -129,16 +133,16 @@ describe('useFieldList', () => { const var1 = createInputVar({ variable: 'var1' }) const onInputFieldsChange = vi.fn() const { result } = renderHook(() => - useFieldList(createDefaultProps({ - initialInputFields: [var1], - onInputFieldsChange, - })), + useFieldList( + createDefaultProps({ + initialInputFields: [var1], + onInputFieldsChange, + }), + ), ) act(() => { - result.current.handleListSortChange([ - { ...var1, id: '0', chosen: true, selected: true }, - ]) + result.current.handleListSortChange([{ ...var1, id: '0', chosen: true, selected: true }]) }) const updatedFields = onInputFieldsChange.mock.calls[0]![0] @@ -156,10 +160,12 @@ describe('useFieldList', () => { mockIsVarUsedInNodes.mockReturnValue(false) const { result } = renderHook(() => - useFieldList(createDefaultProps({ - initialInputFields: [var1, var2], - onInputFieldsChange, - })), + useFieldList( + createDefaultProps({ + initialInputFields: [var1, var2], + onInputFieldsChange, + }), + ), ) act(() => { @@ -175,10 +181,12 @@ describe('useFieldList', () => { mockIsVarUsedInNodes.mockReturnValue(true) const { result } = renderHook(() => - useFieldList(createDefaultProps({ - initialInputFields: [var1], - onInputFieldsChange, - })), + useFieldList( + createDefaultProps({ + initialInputFields: [var1], + onInputFieldsChange, + }), + ), ) act(() => { @@ -197,11 +205,13 @@ describe('useFieldList', () => { mockIsVarUsedInNodes.mockReturnValue(true) const { result } = renderHook(() => - useFieldList(createDefaultProps({ - initialInputFields: [var1], - onInputFieldsChange, - nodeId: 'node-1', - })), + useFieldList( + createDefaultProps({ + initialInputFields: [var1], + onInputFieldsChange, + nodeId: 'node-1', + }), + ), ) act(() => { @@ -239,9 +249,7 @@ describe('useFieldList', () => { }) it('should open editor for new field when id does not match', () => { - const { result } = renderHook(() => - useFieldList(createDefaultProps()), - ) + const { result } = renderHook(() => useFieldList(createDefaultProps())) act(() => { result.current.handleOpenInputFieldEditor('non-existent') @@ -255,9 +263,7 @@ describe('useFieldList', () => { }) it('should open editor for new field when no id provided', () => { - const { result } = renderHook(() => - useFieldList(createDefaultProps()), - ) + const { result } = renderHook(() => useFieldList(createDefaultProps())) act(() => { result.current.handleOpenInputFieldEditor() @@ -274,9 +280,7 @@ describe('useFieldList', () => { describe('field submission (via editor)', () => { it('should add new field when editingFieldIndex is -1', () => { const onInputFieldsChange = vi.fn() - const { result } = renderHook(() => - useFieldList(createDefaultProps({ onInputFieldsChange })), - ) + const { result } = renderHook(() => useFieldList(createDefaultProps({ onInputFieldsChange }))) act(() => { result.current.handleOpenInputFieldEditor() @@ -296,11 +300,13 @@ describe('useFieldList', () => { const var1 = createInputVar({ variable: 'existing_var' }) const onInputFieldsChange = vi.fn() const { result } = renderHook(() => - useFieldList(createDefaultProps({ - initialInputFields: [var1], - onInputFieldsChange, - allVariableNames: ['existing_var'], - })), + useFieldList( + createDefaultProps({ + initialInputFields: [var1], + onInputFieldsChange, + allVariableNames: ['existing_var'], + }), + ), ) act(() => { @@ -314,9 +320,7 @@ describe('useFieldList', () => { editorProps.onSubmit(duplicateField) }) - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) expect(onInputFieldsChange).not.toHaveBeenCalled() }) @@ -324,12 +328,14 @@ describe('useFieldList', () => { const var1 = createInputVar({ variable: 'old_name' }) const onInputFieldsChange = vi.fn() const { result } = renderHook(() => - useFieldList(createDefaultProps({ - initialInputFields: [var1], - onInputFieldsChange, - nodeId: 'node-1', - allVariableNames: ['old_name'], - })), + useFieldList( + createDefaultProps({ + initialInputFields: [var1], + onInputFieldsChange, + nodeId: 'node-1', + allVariableNames: ['old_name'], + }), + ), ) act(() => { diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx index b87b097c600669..b03bd9fa42c591 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx @@ -38,14 +38,17 @@ vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-conf isShow: boolean onCancel: () => void onConfirm: () => void - }) => isShow - ? ( - <div data-testid="remove-var-confirm"> - <button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button> - <button data-testid="confirm-ok" onClick={onConfirm}>Confirm</button> - </div> - ) - : null, + }) => + isShow ? ( + <div data-testid="remove-var-confirm"> + <button data-testid="confirm-cancel" onClick={onCancel}> + Cancel + </button> + <button data-testid="confirm-ok" onClick={onConfirm}> + Confirm + </button> + </div> + ) : null, })) const createInputVar = (overrides?: Partial<InputVar>): InputVar => ({ @@ -70,7 +73,8 @@ const createInputVarList = (count: number): InputVar[] => { createInputVar({ variable: `var_${i}`, label: `Label ${i}`, - })) + }), + ) } const createSortableItem = ( @@ -101,14 +105,7 @@ describe('FieldItem', () => { it('should render field item with variable name', () => { const payload = createInputVar({ variable: 'my_field' }) - render( - <FieldItem - payload={payload} - index={0} - onClickEdit={vi.fn()} - onRemove={vi.fn()} - />, - ) + render(<FieldItem payload={payload} index={0} onClickEdit={vi.fn()} onRemove={vi.fn()} />) expect(screen.getByText('my_field'))!.toBeInTheDocument() }) @@ -116,14 +113,7 @@ describe('FieldItem', () => { it('should render field item with label when provided', () => { const payload = createInputVar({ variable: 'field', label: 'Field Label' }) - render( - <FieldItem - payload={payload} - index={0} - onClickEdit={vi.fn()} - onRemove={vi.fn()} - />, - ) + render(<FieldItem payload={payload} index={0} onClickEdit={vi.fn()} onRemove={vi.fn()} />) expect(screen.getByText('Field Label'))!.toBeInTheDocument() }) @@ -131,14 +121,7 @@ describe('FieldItem', () => { it('should not render label when empty', () => { const payload = createInputVar({ variable: 'field', label: '' }) - render( - <FieldItem - payload={payload} - index={0} - onClickEdit={vi.fn()} - onRemove={vi.fn()} - />, - ) + render(<FieldItem payload={payload} index={0} onClickEdit={vi.fn()} onRemove={vi.fn()} />) expect(screen.queryByText('·')).not.toBeInTheDocument() }) @@ -146,14 +129,7 @@ describe('FieldItem', () => { it('should render required badge when not hovering and required is true', () => { const payload = createInputVar({ required: true }) - render( - <FieldItem - payload={payload} - index={0} - onClickEdit={vi.fn()} - onRemove={vi.fn()} - />, - ) + render(<FieldItem payload={payload} index={0} onClickEdit={vi.fn()} onRemove={vi.fn()} />) expect(screen.getByText(/required/i))!.toBeInTheDocument() }) @@ -161,14 +137,7 @@ describe('FieldItem', () => { it('should not render required badge when required is false', () => { const payload = createInputVar({ required: false }) - render( - <FieldItem - payload={payload} - index={0} - onClickEdit={vi.fn()} - onRemove={vi.fn()} - />, - ) + render(<FieldItem payload={payload} index={0} onClickEdit={vi.fn()} onRemove={vi.fn()} />) expect(screen.queryByText(/required/i)).not.toBeInTheDocument() }) @@ -177,12 +146,7 @@ describe('FieldItem', () => { const payload = createInputVar() const { container } = render( - <FieldItem - payload={payload} - index={0} - onClickEdit={vi.fn()} - onRemove={vi.fn()} - />, + <FieldItem payload={payload} index={0} onClickEdit={vi.fn()} onRemove={vi.fn()} />, ) const icons = container.querySelectorAll('svg') @@ -250,12 +214,7 @@ describe('FieldItem', () => { const payload = createInputVar({ variable: 'test_var' }) const { container } = render( - <FieldItem - payload={payload} - index={0} - onClickEdit={onClickEdit} - onRemove={vi.fn()} - />, + <FieldItem payload={payload} index={0} onClickEdit={onClickEdit} onRemove={vi.fn()} />, ) fireEvent.mouseEnter(container.firstChild!) const buttons = screen.getAllByRole('button') @@ -269,12 +228,7 @@ describe('FieldItem', () => { const payload = createInputVar() const { container } = render( - <FieldItem - payload={payload} - index={5} - onClickEdit={vi.fn()} - onRemove={onRemove} - />, + <FieldItem payload={payload} index={5} onClickEdit={vi.fn()} onRemove={onRemove} />, ) fireEvent.mouseEnter(container.firstChild!) const buttons = screen.getAllByRole('button') @@ -318,12 +272,7 @@ describe('FieldItem', () => { const { container } = render( <div onClick={parentClick}> - <FieldItem - payload={payload} - index={0} - onClickEdit={onClickEdit} - onRemove={vi.fn()} - /> + <FieldItem payload={payload} index={0} onClickEdit={onClickEdit} onRemove={vi.fn()} /> </div>, ) fireEvent.mouseEnter(container.querySelector('.handle')!) @@ -341,12 +290,7 @@ describe('FieldItem', () => { const { container } = render( <div onClick={parentClick}> - <FieldItem - payload={payload} - index={0} - onClickEdit={vi.fn()} - onRemove={onRemove} - /> + <FieldItem payload={payload} index={0} onClickEdit={vi.fn()} onRemove={onRemove} /> </div>, ) fireEvent.mouseEnter(container.querySelector('.handle')!) @@ -364,24 +308,14 @@ describe('FieldItem', () => { const payload = createInputVar() const { container, rerender } = render( - <FieldItem - payload={payload} - index={0} - onClickEdit={onClickEdit} - onRemove={vi.fn()} - />, + <FieldItem payload={payload} index={0} onClickEdit={onClickEdit} onRemove={vi.fn()} />, ) fireEvent.mouseEnter(container.firstChild!) const buttons = screen.getAllByRole('button') fireEvent.click(buttons[0]!) rerender( - <FieldItem - payload={payload} - index={0} - onClickEdit={onClickEdit} - onRemove={vi.fn()} - />, + <FieldItem payload={payload} index={0} onClickEdit={onClickEdit} onRemove={vi.fn()} />, ) fireEvent.mouseEnter(container.firstChild!) const buttonsAfterRerender = screen.getAllByRole('button') @@ -396,14 +330,7 @@ describe('FieldItem', () => { const longVariable = 'a'.repeat(200) const payload = createInputVar({ variable: longVariable }) - render( - <FieldItem - payload={payload} - index={0} - onClickEdit={vi.fn()} - onRemove={vi.fn()} - />, - ) + render(<FieldItem payload={payload} index={0} onClickEdit={vi.fn()} onRemove={vi.fn()} />) const varElement = screen.getByTitle(longVariable) expect(varElement)!.toHaveClass('truncate') @@ -413,14 +340,7 @@ describe('FieldItem', () => { const longLabel = 'b'.repeat(200) const payload = createInputVar({ label: longLabel }) - render( - <FieldItem - payload={payload} - index={0} - onClickEdit={vi.fn()} - onRemove={vi.fn()} - />, - ) + render(<FieldItem payload={payload} index={0} onClickEdit={vi.fn()} onRemove={vi.fn()} />) const labelElement = screen.getByTitle(longLabel) expect(labelElement)!.toHaveClass('truncate') @@ -432,14 +352,7 @@ describe('FieldItem', () => { label: '<label>&"test\'', }) - render( - <FieldItem - payload={payload} - index={0} - onClickEdit={vi.fn()} - onRemove={vi.fn()} - />, - ) + render(<FieldItem payload={payload} index={0} onClickEdit={vi.fn()} onRemove={vi.fn()} />) expect(screen.getByText('<test>&"var\''))!.toBeInTheDocument() expect(screen.getByText('<label>&"test\''))!.toBeInTheDocument() @@ -451,14 +364,7 @@ describe('FieldItem', () => { label: '标签_😀', }) - render( - <FieldItem - payload={payload} - index={0} - onClickEdit={vi.fn()} - onRemove={vi.fn()} - />, - ) + render(<FieldItem payload={payload} index={0} onClickEdit={vi.fn()} onRemove={vi.fn()} />) expect(screen.getByText('变量_🎉'))!.toBeInTheDocument() expect(screen.getByText('标签_😀'))!.toBeInTheDocument() @@ -479,12 +385,7 @@ describe('FieldItem', () => { const payload = createInputVar({ type }) const { unmount } = render( - <FieldItem - payload={payload} - index={0} - onClickEdit={vi.fn()} - onRemove={vi.fn()} - />, + <FieldItem payload={payload} index={0} onClickEdit={vi.fn()} onRemove={vi.fn()} />, ) expect(screen.getByText('test_variable'))!.toBeInTheDocument() @@ -500,21 +401,11 @@ describe('FieldItem', () => { const onRemove = vi.fn() const { rerender } = render( - <FieldItem - payload={payload} - index={0} - onClickEdit={onClickEdit} - onRemove={onRemove} - />, + <FieldItem payload={payload} index={0} onClickEdit={onClickEdit} onRemove={onRemove} />, ) rerender( - <FieldItem - payload={payload} - index={0} - onClickEdit={onClickEdit} - onRemove={onRemove} - />, + <FieldItem payload={payload} index={0} onClickEdit={onClickEdit} onRemove={onRemove} />, ) expect(screen.getByText('test_variable'))!.toBeInTheDocument() @@ -728,7 +619,7 @@ describe('FieldListContainer', () => { it('should convert InputVar[] to SortableItem[] with correct structure', () => { // Verify the conversion contract: id from variable, default sortable flags const inputFields = createInputVarList(2) - const converted: SortableItem[] = inputFields.map(content => ({ + const converted: SortableItem[] = inputFields.map((content) => ({ id: content.variable, chosen: false, selected: false, @@ -865,9 +756,7 @@ describe('FieldList', () => { />, ) - const addButton = screen.getAllByRole('button').find(btn => - btn.querySelector('svg'), - ) + const addButton = screen.getAllByRole('button').find((btn) => btn.querySelector('svg')) expect(addButton)!.toBeInTheDocument() }) @@ -885,9 +774,7 @@ describe('FieldList', () => { />, ) - const addButton = screen.getAllByRole('button').find(btn => - btn.querySelector('svg'), - ) + const addButton = screen.getAllByRole('button').find((btn) => btn.querySelector('svg')) expect(addButton)!.toBeDisabled() }) @@ -923,11 +810,8 @@ describe('FieldList', () => { allVariableNames={[]} />, ) - const addButton = screen.getAllByRole('button').find(btn => - btn.querySelector('svg'), - ) - if (addButton) - fireEvent.click(addButton) + const addButton = screen.getAllByRole('button').find((btn) => btn.querySelector('svg')) + if (addButton) fireEvent.click(addButton) expect(mockToggleInputFieldEditPanel).toHaveBeenCalled() }) @@ -945,11 +829,8 @@ describe('FieldList', () => { readonly={true} />, ) - const addButton = screen.getAllByRole('button').find(btn => - btn.querySelector('svg'), - ) - if (addButton) - fireEvent.click(addButton) + const addButton = screen.getAllByRole('button').find((btn) => btn.querySelector('svg')) + if (addButton) fireEvent.click(addButton) expect(mockToggleInputFieldEditPanel).not.toHaveBeenCalled() }) @@ -974,8 +855,7 @@ describe('FieldList', () => { // Trigger field change via remove action fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) expect(handleInputFieldsChange).toHaveBeenCalledWith('node-1', expect.any(Array)) }) @@ -998,8 +878,7 @@ describe('FieldList', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) await waitFor(() => { expect(screen.getByTestId('remove-var-confirm'))!.toBeInTheDocument() @@ -1022,8 +901,7 @@ describe('FieldList', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) await waitFor(() => { expect(screen.getByTestId('remove-var-confirm'))!.toBeInTheDocument() @@ -1053,8 +931,7 @@ describe('FieldList', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) await waitFor(() => { expect(screen.getByTestId('remove-var-confirm'))!.toBeInTheDocument() @@ -1085,8 +962,7 @@ describe('FieldList', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) expect(screen.queryByTestId('remove-var-confirm')).not.toBeInTheDocument() expect(handleInputFieldsChange).toHaveBeenCalled() @@ -1204,8 +1080,7 @@ describe('FieldList', () => { // After rerender, the callback chain should still work correctly fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) expect(handleInputFieldsChange).toHaveBeenCalledWith('node-1', expect.any(Array)) }) @@ -1258,12 +1133,14 @@ describe('useFieldList Hook', () => { const onInputFieldsChange = vi.fn() const initialFields = createInputVarList(2) - const { result } = renderHook(() => useFieldList({ - initialInputFields: initialFields, - onInputFieldsChange, - nodeId: 'node-1', - allVariableNames: [], - })) + const { result } = renderHook(() => + useFieldList({ + initialInputFields: initialFields, + onInputFieldsChange, + nodeId: 'node-1', + allVariableNames: [], + }), + ) // Simulate sort change by calling handleListSortChange directly const reorderedList: SortableItem[] = [ @@ -1285,12 +1162,14 @@ describe('useFieldList Hook', () => { const onInputFieldsChange = vi.fn() const initialFields = createInputVarList(1) - const { result } = renderHook(() => useFieldList({ - initialInputFields: initialFields, - onInputFieldsChange, - nodeId: 'node-1', - allVariableNames: [], - })) + const { result } = renderHook(() => + useFieldList({ + initialInputFields: initialFields, + onInputFieldsChange, + nodeId: 'node-1', + allVariableNames: [], + }), + ) const sortableList: SortableItem[] = [ createSortableItem(initialFields[0]!, { chosen: true, selected: true }), @@ -1325,8 +1204,7 @@ describe('useFieldList Hook', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) await waitFor(() => { expect(screen.getByTestId('remove-var-confirm'))!.toBeInTheDocument() @@ -1350,8 +1228,7 @@ describe('useFieldList Hook', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) expect(screen.queryByTestId('remove-var-confirm')).not.toBeInTheDocument() expect(handleInputFieldsChange).toHaveBeenCalled() @@ -1374,8 +1251,7 @@ describe('useFieldList Hook', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) await waitFor(() => { expect(screen.getByTestId('remove-var-confirm'))!.toBeInTheDocument() @@ -1399,8 +1275,7 @@ describe('useFieldList Hook', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) expect(mockIsVarUsedInNodes).toHaveBeenCalledWith(['rag', 'test-node-123', 'my_test_var']) }) @@ -1422,8 +1297,7 @@ describe('useFieldList Hook', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) expect(mockIsVarUsedInNodes).toHaveBeenCalledWith(['rag', 'node-1', '']) }) @@ -1443,11 +1317,10 @@ describe('useFieldList Hook', () => { />, ) const fieldItemRoots = container.querySelectorAll('.handle') - fieldItemRoots.forEach(el => fireEvent.mouseEnter(el)) + fieldItemRoots.forEach((el) => fireEvent.mouseEnter(el)) const allFieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (allFieldItemButtons.length >= 4) - fireEvent.click(allFieldItemButtons[3]!) + if (allFieldItemButtons.length >= 4) fireEvent.click(allFieldItemButtons[3]!) await waitFor(() => { expect(screen.getByTestId('remove-var-confirm'))!.toBeInTheDocument() @@ -1477,11 +1350,8 @@ describe('useFieldList Hook', () => { allVariableNames={[]} />, ) - const addButton = screen.getAllByRole('button').find(btn => - btn.querySelector('svg'), - ) - if (addButton) - fireEvent.click(addButton) + const addButton = screen.getAllByRole('button').find((btn) => btn.querySelector('svg')) + if (addButton) fireEvent.click(addButton) expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith( expect.objectContaining({ @@ -1505,8 +1375,7 @@ describe('useFieldList Hook', () => { ) fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 1) - fireEvent.click(fieldItemButtons[0]!) + if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]!) expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith( expect.objectContaining({ @@ -1537,8 +1406,7 @@ describe('useFieldList Hook', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) await waitFor(() => { expect(screen.getByTestId('remove-var-confirm'))!.toBeInTheDocument() @@ -1608,8 +1476,7 @@ describe('handleSubmitField', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 1) - fireEvent.click(fieldItemButtons[0]!) + if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]!) const editorProps = mockToggleInputFieldEditPanel.mock.calls[0]![0] @@ -1642,8 +1509,7 @@ describe('handleSubmitField', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 1) - fireEvent.click(fieldItemButtons[0]!) + if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]!) const editorProps = mockToggleInputFieldEditPanel.mock.calls[0]![0] @@ -1676,8 +1542,7 @@ describe('handleSubmitField', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 1) - fireEvent.click(fieldItemButtons[0]!) + if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]!) const editorProps = mockToggleInputFieldEditPanel.mock.calls[0]![0] @@ -1704,8 +1569,7 @@ describe('handleSubmitField', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 1) - fireEvent.click(fieldItemButtons[0]!) + if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]!) const editorProps = mockToggleInputFieldEditPanel.mock.calls[0]![0] @@ -1732,8 +1596,7 @@ describe('handleSubmitField', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 1) - fireEvent.click(fieldItemButtons[0]!) + if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]!) const editorProps = mockToggleInputFieldEditPanel.mock.calls[0]![0] @@ -1766,8 +1629,7 @@ describe('handleSubmitField', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 1) - fireEvent.click(fieldItemButtons[0]!) + if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]!) const editorProps = mockToggleInputFieldEditPanel.mock.calls[0]![0] @@ -1861,7 +1723,9 @@ describe('Duplicate Variable Name Handling', () => { editorProps.onSubmit(duplicateFieldData) expect(handleInputFieldsChange).not.toHaveBeenCalled() - expect(toastErrorSpy).toHaveBeenCalledWith('datasetPipeline.inputFieldPanel.error.variableDuplicate') + expect(toastErrorSpy).toHaveBeenCalledWith( + 'datasetPipeline.inputFieldPanel.error.variableDuplicate', + ) }) it('should allow updating field to same variable name', () => { @@ -1880,8 +1744,7 @@ describe('Duplicate Variable Name Handling', () => { fireEvent.mouseEnter(container.querySelector('.handle')!) const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') - if (fieldItemButtons.length >= 1) - fireEvent.click(fieldItemButtons[0]!) + if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]!) const editorProps = mockToggleInputFieldEditPanel.mock.calls[0]![0] @@ -1948,8 +1811,7 @@ describe('Integration Tests', () => { expect(mockToggleInputFieldEditPanel).toHaveBeenCalledTimes(2) } - if (fieldItemButtons.length >= 2) - fireEvent.click(fieldItemButtons[1]!) + if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]!) expect(handleInputFieldsChange).toHaveBeenCalled() }) @@ -1970,9 +1832,7 @@ describe('Integration Tests', () => { />, ) - const addButton = screen.getAllByRole('button').find(btn => - btn.querySelector('svg'), - ) + const addButton = screen.getAllByRole('button').find((btn) => btn.querySelector('svg')) expect(addButton)!.toBeDisabled() }) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/field-item.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/field-item.tsx index 4925feb465e403..ab8c2b6b397992 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/field-item.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/field-item.tsx @@ -2,11 +2,7 @@ import type { InputVarType } from '@/app/components/workflow/types' import type { InputVar } from '@/models/pipeline' import { cn } from '@langgenius/dify-ui/cn' -import { - RiDeleteBinLine, - RiDraggable, - RiEditLine, -} from '@remixicon/react' +import { RiDeleteBinLine, RiDraggable, RiEditLine } from '@remixicon/react' import { useHover } from 'ahooks' import * as React from 'react' import { useCallback, useRef } from 'react' @@ -24,48 +20,46 @@ type FieldItemProps = { onRemove: (index: number) => void } -const FieldItem = ({ - readonly, - payload, - index, - onClickEdit, - onRemove, -}: FieldItemProps) => { +const FieldItem = ({ readonly, payload, index, onClickEdit, onRemove }: FieldItemProps) => { const { t } = useTranslation() const ref = useRef(null) const isHovering = useHover(ref) - const handleOnClickEdit = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - if (readonly) - return - onClickEdit(payload.variable) - }, [onClickEdit, payload.variable, readonly]) + const handleOnClickEdit = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (readonly) return + onClickEdit(payload.variable) + }, + [onClickEdit, payload.variable, readonly], + ) - const handleRemove = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - if (readonly) - return - onRemove(index) - }, [index, onRemove, readonly]) + const handleRemove = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (readonly) return + onRemove(index) + }, + [index, onRemove, readonly], + ) return ( <div ref={ref} className={cn( 'handle flex h-8 cursor-pointer items-center justify-between gap-x-1 rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg py-1 pl-2 shadow-xs hover:shadow-sm', - (isHovering && !readonly) ? 'cursor-all-scroll pr-1' : 'pr-2.5', + isHovering && !readonly ? 'cursor-all-scroll pr-1' : 'pr-2.5', readonly && 'cursor-default', )} - // onClick={handleOnClickEdit} + // onClick={handleOnClickEdit} > <div className="flex grow basis-0 items-center gap-x-1 overflow-hidden"> - { - (isHovering && !readonly) - ? <RiDraggable className="size-4 shrink-0 text-text-quaternary" /> - : <InputField className="size-4 shrink-0 text-text-accent" /> - } + {isHovering && !readonly ? ( + <RiDraggable className="size-4 shrink-0 text-text-quaternary" /> + ) : ( + <InputField className="size-4 shrink-0 text-text-accent" /> + )} <div title={payload.variable} className="max-w-[130px] shrink-0 truncate system-sm-medium text-text-secondary" @@ -84,30 +78,26 @@ const FieldItem = ({ </> )} </div> - {(isHovering && !readonly) - ? ( - <div className="flex shrink-0 items-center gap-x-1"> - <ActionButton - className="mr-1" - onClick={handleOnClickEdit} - > - <RiEditLine className="size-4 text-text-tertiary" /> - </ActionButton> - <ActionButton - onClick={handleRemove} - > - <RiDeleteBinLine className="size-4 text-text-tertiary group-hover:text-text-destructive" /> - </ActionButton> - </div> - ) - : ( - <div className="flex shrink-0 items-center gap-x-2"> - {payload.required && ( - <Badge>{t($ => $['nodes.start.required'], { ns: 'workflow' })}</Badge> - )} - <InputVarTypeIcon type={payload.type as unknown as InputVarType} className="size-3 text-text-tertiary" /> - </div> + {isHovering && !readonly ? ( + <div className="flex shrink-0 items-center gap-x-1"> + <ActionButton className="mr-1" onClick={handleOnClickEdit}> + <RiEditLine className="size-4 text-text-tertiary" /> + </ActionButton> + <ActionButton onClick={handleRemove}> + <RiDeleteBinLine className="size-4 text-text-tertiary group-hover:text-text-destructive" /> + </ActionButton> + </div> + ) : ( + <div className="flex shrink-0 items-center gap-x-2"> + {payload.required && ( + <Badge>{t(($) => $['nodes.start.required'], { ns: 'workflow' })}</Badge> )} + <InputVarTypeIcon + type={payload.type as unknown as InputVarType} + className="size-3 text-text-tertiary" + /> + </div> + )} </div> ) } diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container.tsx index e99a815f0a3f0c..c9cc6de8639f9f 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container.tsx @@ -2,11 +2,7 @@ import type { SortableItem } from './types' import type { InputVar } from '@/models/pipeline' import { cn } from '@langgenius/dify-ui/cn' import { isEqual } from 'es-toolkit/predicate' -import { - memo, - useCallback, - useMemo, -} from 'react' +import { memo, useCallback, useMemo } from 'react' import { ReactSortable } from 'react-sortablejs' import FieldItem from './field-item' @@ -28,20 +24,22 @@ const FieldListContainer = ({ }: FieldListContainerProps) => { const list = useMemo(() => { return inputFields.map((content) => { - return ({ + return { id: content.variable, chosen: false, selected: false, ...content, - }) + } }) }, [inputFields]) - const handleListSortChange = useCallback((newList: SortableItem[]) => { - if (isEqual(newList, list)) - return - onListSortChange(newList) - }, [list, onListSortChange]) + const handleListSortChange = useCallback( + (newList: SortableItem[]) => { + if (isEqual(newList, list)) return + onListSortChange(newList) + }, + [list, onListSortChange], + ) return ( <ReactSortable<SortableItem> diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/hooks.ts b/web/app/components/rag-pipeline/components/panel/input-field/field-list/hooks.ts index 7c251aacca4cd8..a85c90f7025cfe 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/hooks.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/hooks.ts @@ -17,7 +17,12 @@ type useFieldListProps = { nodeId: string allVariableNames: string[] } -export const useFieldList = ({ initialInputFields, onInputFieldsChange, nodeId, allVariableNames }: useFieldListProps) => { +export const useFieldList = ({ + initialInputFields, + onInputFieldsChange, + nodeId, + allVariableNames, +}: useFieldListProps) => { const { t } = useTranslation() const { toggleInputFieldEditPanel } = useInputFieldPanel() const [inputFields, setInputFields] = useState<InputVar[]>(initialInputFields) @@ -25,64 +30,102 @@ export const useFieldList = ({ initialInputFields, onInputFieldsChange, nodeId, const [removedVar, setRemovedVar] = useState<ValueSelector>([]) const [removedIndex, setRemoveIndex] = useState(0) const { handleInputVarRename, isVarUsedInNodes, removeUsedVarInNodes } = usePipeline() - const [isShowRemoveVarConfirm, { setTrue: showRemoveVarConfirm, setFalse: hideRemoveVarConfirm }] = useBoolean(false) - const handleInputFieldsChange = useCallback((newInputFields: InputVar[]) => { - setInputFields(newInputFields) - inputFieldsRef.current = newInputFields - onInputFieldsChange(newInputFields) - }, [onInputFieldsChange]) - const handleListSortChange = useCallback((list: SortableItem[]) => { - const newInputFields = list.map((item) => { - const { id: _id, chosen: _chosen, selected: _selected, ...filed } = item - return filed - }) - handleInputFieldsChange(newInputFields) - }, [handleInputFieldsChange]) + const [ + isShowRemoveVarConfirm, + { setTrue: showRemoveVarConfirm, setFalse: hideRemoveVarConfirm }, + ] = useBoolean(false) + const handleInputFieldsChange = useCallback( + (newInputFields: InputVar[]) => { + setInputFields(newInputFields) + inputFieldsRef.current = newInputFields + onInputFieldsChange(newInputFields) + }, + [onInputFieldsChange], + ) + const handleListSortChange = useCallback( + (list: SortableItem[]) => { + const newInputFields = list.map((item) => { + const { id: _id, chosen: _chosen, selected: _selected, ...filed } = item + return filed + }) + handleInputFieldsChange(newInputFields) + }, + [handleInputFieldsChange], + ) const editingFieldIndex = useRef<number>(-1) const handleCloseInputFieldEditor = useCallback(() => { toggleInputFieldEditPanel?.(null) editingFieldIndex.current = -1 }, [toggleInputFieldEditPanel]) - const handleRemoveField = useCallback((index: number) => { - const itemToRemove = inputFieldsRef.current[index] - // Check if the variable is used in other nodes - if (isVarUsedInNodes([VARIABLE_PREFIX, nodeId, itemToRemove!.variable || ''])) { - showRemoveVarConfirm() - setRemovedVar([VARIABLE_PREFIX, nodeId, itemToRemove!.variable || '']) - setRemoveIndex(index as number) - return - } - const newInputFields = inputFieldsRef.current.filter((_, i) => i !== index) - handleInputFieldsChange(newInputFields) - }, [handleInputFieldsChange, isVarUsedInNodes, nodeId, showRemoveVarConfirm]) + const handleRemoveField = useCallback( + (index: number) => { + const itemToRemove = inputFieldsRef.current[index] + // Check if the variable is used in other nodes + if (isVarUsedInNodes([VARIABLE_PREFIX, nodeId, itemToRemove!.variable || ''])) { + showRemoveVarConfirm() + setRemovedVar([VARIABLE_PREFIX, nodeId, itemToRemove!.variable || '']) + setRemoveIndex(index as number) + return + } + const newInputFields = inputFieldsRef.current.filter((_, i) => i !== index) + handleInputFieldsChange(newInputFields) + }, + [handleInputFieldsChange, isVarUsedInNodes, nodeId, showRemoveVarConfirm], + ) const onRemoveVarConfirm = useCallback(() => { const newInputFields = inputFieldsRef.current.filter((_, i) => i !== removedIndex) handleInputFieldsChange(newInputFields) removeUsedVarInNodes(removedVar) hideRemoveVarConfirm() - }, [removedIndex, handleInputFieldsChange, removeUsedVarInNodes, removedVar, hideRemoveVarConfirm]) - const handleSubmitField = useCallback((data: InputVar, moreInfo?: MoreInfo) => { - const isDuplicate = allVariableNames.some(name => name === data.variable && name !== inputFieldsRef.current[editingFieldIndex.current]?.variable) - if (isDuplicate) { - toast.error(t($ => $['inputFieldPanel.error.variableDuplicate'], { ns: 'datasetPipeline' })) - return - } - const newInputFields = produce(inputFieldsRef.current, (draft) => { - const currentIndex = editingFieldIndex.current - if (currentIndex === -1) { - draft.push(data) + }, [ + removedIndex, + handleInputFieldsChange, + removeUsedVarInNodes, + removedVar, + hideRemoveVarConfirm, + ]) + const handleSubmitField = useCallback( + (data: InputVar, moreInfo?: MoreInfo) => { + const isDuplicate = allVariableNames.some( + (name) => + name === data.variable && + name !== inputFieldsRef.current[editingFieldIndex.current]?.variable, + ) + if (isDuplicate) { + toast.error( + t(($) => $['inputFieldPanel.error.variableDuplicate'], { ns: 'datasetPipeline' }), + ) return } - draft[currentIndex] = data - }) - handleInputFieldsChange(newInputFields) - // Update variable name in nodes if it has changed - if (moreInfo?.type === ChangeType.changeVarName) - handleInputVarRename(nodeId, [VARIABLE_PREFIX, nodeId, moreInfo.payload?.beforeKey || ''], [VARIABLE_PREFIX, nodeId, moreInfo.payload?.afterKey || '']) - handleCloseInputFieldEditor() - }, [allVariableNames, handleCloseInputFieldEditor, handleInputFieldsChange, handleInputVarRename, nodeId, t]) + const newInputFields = produce(inputFieldsRef.current, (draft) => { + const currentIndex = editingFieldIndex.current + if (currentIndex === -1) { + draft.push(data) + return + } + draft[currentIndex] = data + }) + handleInputFieldsChange(newInputFields) + // Update variable name in nodes if it has changed + if (moreInfo?.type === ChangeType.changeVarName) + handleInputVarRename( + nodeId, + [VARIABLE_PREFIX, nodeId, moreInfo.payload?.beforeKey || ''], + [VARIABLE_PREFIX, nodeId, moreInfo.payload?.afterKey || ''], + ) + handleCloseInputFieldEditor() + }, + [ + allVariableNames, + handleCloseInputFieldEditor, + handleInputFieldsChange, + handleInputVarRename, + nodeId, + t, + ], + ) const handleOpenInputFieldEditor = useCallback((id?: string) => { - const index = inputFieldsRef.current.findIndex(field => field.variable === id) + const index = inputFieldsRef.current.findIndex((field) => field.variable === id) editingFieldIndex.current = index toggleInputFieldEditPanel?.({ onClose: handleCloseInputFieldEditor, diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.tsx index de53e0ec18a2bf..2f1a76eb224c09 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.tsx @@ -29,9 +29,12 @@ const FieldList = ({ allVariableNames, }: FieldListProps) => { const { t } = useTranslation() - const onInputFieldsChange = useCallback((value: InputVar[]) => { - handleInputFieldsChange(nodeId, value) - }, [handleInputFieldsChange, nodeId]) + const onInputFieldsChange = useCallback( + (value: InputVar[]) => { + handleInputFieldsChange(nodeId, value) + }, + [handleInputFieldsChange, nodeId], + ) const { inputFields, @@ -51,11 +54,9 @@ const FieldList = ({ return ( <div className="flex flex-col"> <div className={cn('flex items-center gap-x-2 px-4', labelClassName)}> - <div className="grow"> - {LabelRightContent} - </div> + <div className="grow">{LabelRightContent}</div> <ActionButton - aria-label={t($ => $['operation.add'], { ns: 'common' })} + aria-label={t(($) => $['operation.add'], { ns: 'common' })} onClick={() => handleOpenInputFieldEditor()} disabled={readonly} className={cn(readonly && 'cursor-not-allowed')} diff --git a/web/app/components/rag-pipeline/components/panel/input-field/hooks.ts b/web/app/components/rag-pipeline/components/panel/input-field/hooks.ts index 1f35e55b998c58..8fd394558a06b4 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/hooks.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/hooks.ts @@ -5,31 +5,39 @@ import { useStore } from '@/app/components/workflow/store' export const useFloatingRight = (targetElementWidth: number) => { const [floatingRight, setFloatingRight] = useState(false) - const nodePanelWidth = useStore(state => state.nodePanelWidth) - const workflowCanvasWidth = useStore(state => state.workflowCanvasWidth) - const otherPanelWidth = useStore(state => state.otherPanelWidth) + const nodePanelWidth = useStore((state) => state.nodePanelWidth) + const workflowCanvasWidth = useStore((state) => state.workflowCanvasWidth) + const otherPanelWidth = useStore((state) => state.otherPanelWidth) - const selectedNodeId = useReactflow(useShallow((s) => { - const nodes = s.getNodes() - const currentNode = nodes.find(node => node.data.selected) + const selectedNodeId = useReactflow( + useShallow((s) => { + const nodes = s.getNodes() + const currentNode = nodes.find((node) => node.data.selected) - if (currentNode) - return currentNode.id - })) + if (currentNode) return currentNode.id + }), + ) useEffect(() => { if (typeof workflowCanvasWidth === 'number') { const inputFieldPanelWidth = 400 const marginRight = 4 - const leftWidth = workflowCanvasWidth - (selectedNodeId ? nodePanelWidth : 0) - otherPanelWidth - inputFieldPanelWidth - marginRight + const leftWidth = + workflowCanvasWidth - + (selectedNodeId ? nodePanelWidth : 0) - + otherPanelWidth - + inputFieldPanelWidth - + marginRight setFloatingRight(leftWidth < targetElementWidth + marginRight) } }, [workflowCanvasWidth, nodePanelWidth, otherPanelWidth, selectedNodeId, targetElementWidth]) const floatingRightWidth = useMemo(() => { - if (!floatingRight) - return targetElementWidth - const width = Math.min(targetElementWidth, (selectedNodeId ? nodePanelWidth : 0) + otherPanelWidth) + if (!floatingRight) return targetElementWidth + const width = Math.min( + targetElementWidth, + (selectedNodeId ? nodePanelWidth : 0) + otherPanelWidth, + ) return width }, [floatingRight, selectedNodeId, nodePanelWidth, otherPanelWidth, targetElementWidth]) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/index.tsx b/web/app/components/rag-pipeline/components/panel/input-field/index.tsx index f406d954466f6c..a59bae24638516 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/index.tsx @@ -4,12 +4,7 @@ import type { InputVar, RAGPipelineVariables } from '@/models/pipeline' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiCloseLine, RiEyeLine } from '@remixicon/react' -import { - memo, - useCallback, - useMemo, - useRef, -} from 'react' +import { memo, useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import Divider from '@/app/components/base/divider' @@ -27,26 +22,20 @@ import GlobalInputs from './label-right-content/global-inputs' const InputFieldPanel = () => { const { t } = useTranslation() const nodes = useNodes<DataSourceNodeType>() - const { - closeAllInputFieldPanels, - toggleInputFieldPreviewPanel, - isPreviewing, - isEditing, - } = useInputFieldPanel() - const canEdit = useHooksStore(s => s.accessControl.canEdit) + const { closeAllInputFieldPanels, toggleInputFieldPreviewPanel, isPreviewing, isEditing } = + useInputFieldPanel() + const canEdit = useHooksStore((s) => s.accessControl.canEdit) const shouldIgnoreInputFieldChange = !canEdit || isPreviewing const isReadonly = shouldIgnoreInputFieldChange || isEditing - const ragPipelineVariables = useStore(state => state.ragPipelineVariables) - const setRagPipelineVariables = useStore(state => state.setRagPipelineVariables) + const ragPipelineVariables = useStore((state) => state.ragPipelineVariables) + const setRagPipelineVariables = useStore((state) => state.setRagPipelineVariables) const getInputFieldsMap = () => { const inputFieldsMap: Record<string, InputVar[]> = {} ragPipelineVariables?.forEach((variable) => { const { belong_to_node_id: nodeId, ...varConfig } = variable - if (inputFieldsMap[nodeId]) - inputFieldsMap[nodeId].push(varConfig) - else - inputFieldsMap[nodeId] = [varConfig] + if (inputFieldsMap[nodeId]) inputFieldsMap[nodeId].push(varConfig) + else inputFieldsMap[nodeId] = [varConfig] }) return inputFieldsMap } @@ -56,7 +45,9 @@ const InputFieldPanel = () => { const datasourceNodeDataMap = useMemo(() => { const datasourceNodeDataMap: Record<string, DataSourceNodeType> = {} - const datasourceNodes: Node<DataSourceNodeType>[] = nodes.filter(node => node.data.type === BlockEnum.DataSource) + const datasourceNodes: Node<DataSourceNodeType>[] = nodes.filter( + (node) => node.data.type === BlockEnum.DataSource, + ) datasourceNodes.forEach((node) => { const { id, data } = node datasourceNodeDataMap[id] = data @@ -64,35 +55,36 @@ const InputFieldPanel = () => { return datasourceNodeDataMap }, [nodes]) - const updateInputFields = useCallback(async (key: string, value: InputVar[]) => { - if (shouldIgnoreInputFieldChange) - return + const updateInputFields = useCallback( + async (key: string, value: InputVar[]) => { + if (shouldIgnoreInputFieldChange) return - inputFieldsMap.current[key] = value - const datasourceNodeInputFields: RAGPipelineVariables = [] - const globalInputFields: RAGPipelineVariables = [] - Object.keys(inputFieldsMap.current).forEach((key) => { - const inputFields = inputFieldsMap.current[key] - inputFields!.forEach((inputField) => { - if (key === 'shared') { - globalInputFields.push({ - ...inputField, - belong_to_node_id: key, - }) - } - else { - datasourceNodeInputFields.push({ - ...inputField, - belong_to_node_id: key, - }) - } + inputFieldsMap.current[key] = value + const datasourceNodeInputFields: RAGPipelineVariables = [] + const globalInputFields: RAGPipelineVariables = [] + Object.keys(inputFieldsMap.current).forEach((key) => { + const inputFields = inputFieldsMap.current[key] + inputFields!.forEach((inputField) => { + if (key === 'shared') { + globalInputFields.push({ + ...inputField, + belong_to_node_id: key, + }) + } else { + datasourceNodeInputFields.push({ + ...inputField, + belong_to_node_id: key, + }) + } + }) }) - }) - // Datasource node input fields come first, then global input fields - const newRagPipelineVariables = [...datasourceNodeInputFields, ...globalInputFields] - setRagPipelineVariables?.(newRagPipelineVariables) - handleSyncWorkflowDraft() - }, [shouldIgnoreInputFieldChange, setRagPipelineVariables, handleSyncWorkflowDraft]) + // Datasource node input fields come first, then global input fields + const newRagPipelineVariables = [...datasourceNodeInputFields, ...globalInputFields] + setRagPipelineVariables?.(newRagPipelineVariables) + handleSyncWorkflowDraft() + }, + [shouldIgnoreInputFieldChange, setRagPipelineVariables, handleSyncWorkflowDraft], + ) const closePanel = useCallback(() => { closeAllInputFieldPanels() @@ -103,14 +95,14 @@ const InputFieldPanel = () => { }, [toggleInputFieldPreviewPanel]) const allVariableNames = useMemo(() => { - return ragPipelineVariables?.map(variable => variable.variable) || [] + return ragPipelineVariables?.map((variable) => variable.variable) || [] }, [ragPipelineVariables]) return ( <div className="mr-1 flex h-full w-[400px] flex-col rounded-2xl border-y-[0.5px] border-l-[0.5px] border-components-panel-border bg-components-panel-bg-alt shadow-xl shadow-shadow-shadow-5"> <div className="flex shrink-0 items-center p-4 pb-0"> <div className="grow system-xl-semibold text-text-primary"> - {t($ => $['inputFieldPanel.title'], { ns: 'datasetPipeline' })} + {t(($) => $['inputFieldPanel.title'], { ns: 'datasetPipeline' })} </div> <Button variant="ghost" @@ -123,12 +115,14 @@ const InputFieldPanel = () => { disabled={isEditing} > <RiEyeLine className="size-3.5" /> - <span className="px-[3px]">{t($ => $['operations.preview'], { ns: 'datasetPipeline' })}</span> + <span className="px-[3px]"> + {t(($) => $['operations.preview'], { ns: 'datasetPipeline' })} + </span> </Button> <Divider type="vertical" className="mx-1 h-3" /> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="flex size-6 shrink-0 items-center justify-center p-0.5" onClick={closePanel} > @@ -136,39 +130,39 @@ const InputFieldPanel = () => { </button> </div> <div className="shrink-0 px-4 pt-1 pb-2 system-sm-regular text-text-tertiary"> - {t($ => $['inputFieldPanel.description'], { ns: 'datasetPipeline' })} + {t(($) => $['inputFieldPanel.description'], { ns: 'datasetPipeline' })} </div> <div className="flex grow flex-col overflow-y-auto"> {/* Unique Inputs for Each Entrance */} <div className="flex h-6 items-center gap-x-0.5 px-4 pt-2"> <span className="system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['inputFieldPanel.uniqueInputs.title'], { ns: 'datasetPipeline' })} + {t(($) => $['inputFieldPanel.uniqueInputs.title'], { ns: 'datasetPipeline' })} </span> <Infotip - aria-label={t($ => $['inputFieldPanel.uniqueInputs.tooltip'], { ns: 'datasetPipeline' })} + aria-label={t(($) => $['inputFieldPanel.uniqueInputs.tooltip'], { + ns: 'datasetPipeline', + })} popupClassName="max-w-[240px]" > - {t($ => $['inputFieldPanel.uniqueInputs.tooltip'], { ns: 'datasetPipeline' })} + {t(($) => $['inputFieldPanel.uniqueInputs.tooltip'], { ns: 'datasetPipeline' })} </Infotip> </div> <div className="flex flex-col gap-y-1 py-1"> - { - Object.keys(datasourceNodeDataMap).map((key) => { - const inputFields = inputFieldsMap.current[key] || [] - return ( - <FieldList - key={key} - nodeId={key} - LabelRightContent={<Datasource nodeData={datasourceNodeDataMap[key]!} />} - inputFields={inputFields} - readonly={isReadonly} - labelClassName="pt-1 pb-1" - handleInputFieldsChange={updateInputFields} - allVariableNames={allVariableNames} - /> - ) - }) - } + {Object.keys(datasourceNodeDataMap).map((key) => { + const inputFields = inputFieldsMap.current[key] || [] + return ( + <FieldList + key={key} + nodeId={key} + LabelRightContent={<Datasource nodeData={datasourceNodeDataMap[key]!} />} + inputFields={inputFields} + readonly={isReadonly} + labelClassName="pt-1 pb-1" + handleInputFieldsChange={updateInputFields} + allVariableNames={allVariableNames} + /> + ) + })} </div> {/* Global Inputs */} <FieldList diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx index a6e67e8e69eb1e..b9df9917027aad 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx @@ -9,7 +9,11 @@ describe('GlobalInputs', () => { it('should render the title and tooltip copy', () => { render(<GlobalInputs />) - expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument() - expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title'), + ).toBeInTheDocument() + expect( + screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip'), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx index 17c3cfa068836d..7aa4ed2e312c4f 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx @@ -6,7 +6,15 @@ import Datasource from '../datasource' import GlobalInputs from '../global-inputs' vi.mock('@/app/components/workflow/block-icon', () => ({ - default: ({ type, toolIcon, className }: { type: BlockEnum, toolIcon?: string, className?: string }) => ( + default: ({ + type, + toolIcon, + className, + }: { + type: BlockEnum + toolIcon?: string + className?: string + }) => ( <div data-testid="block-icon" data-type={type} @@ -26,19 +34,20 @@ afterEach(() => { }) describe('Datasource', () => { - const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ - title: 'Test Data Source', - desc: 'Test description', - type: BlockEnum.DataSource, - provider_name: 'test-provider', - provider_type: 'api', - datasource_name: 'test-datasource', - datasource_label: 'Test Datasource', - plugin_id: 'test-plugin', - datasource_parameters: {}, - datasource_configurations: {}, - ...overrides, - } as DataSourceNodeType) + const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => + ({ + title: 'Test Data Source', + desc: 'Test description', + type: BlockEnum.DataSource, + provider_name: 'test-provider', + provider_type: 'api', + datasource_name: 'test-datasource', + datasource_label: 'Test Datasource', + plugin_id: 'test-plugin', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, + }) as DataSourceNodeType describe('rendering', () => { it('should render without crashing', () => { @@ -106,7 +115,9 @@ describe('Datasource', () => { describe('memoization', () => { it('should be wrapped with React.memo', () => { - expect((Datasource as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + expect((Datasource as unknown as { $$typeof: symbol }).$$typeof).toBe( + Symbol.for('react.memo'), + ) }) }) @@ -143,31 +154,41 @@ describe('GlobalInputs', () => { it('should render without crashing', () => { render(<GlobalInputs />) - expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title'), + ).toBeInTheDocument() }) it('should render title with correct translation key', () => { render(<GlobalInputs />) - expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title'), + ).toBeInTheDocument() }) it('should render tooltip component', () => { render(<GlobalInputs />) - expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument() + expect( + screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip'), + ).toBeInTheDocument() }) it('should pass correct tooltip content', () => { render(<GlobalInputs />) - expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument() + expect( + screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip'), + ).toBeInTheDocument() }) it('should render the tooltip trigger as an icon-sized button', () => { render(<GlobalInputs />) - expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toHaveClass('size-4') + expect( + screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip'), + ).toHaveClass('size-4') }) it('should have correct container layout', () => { @@ -187,7 +208,9 @@ describe('GlobalInputs', () => { describe('memoization', () => { it('should be wrapped with React.memo', () => { - expect((GlobalInputs as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + expect((GlobalInputs as unknown as { $$typeof: symbol }).$$typeof).toBe( + Symbol.for('react.memo'), + ) }) }) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/datasource.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/datasource.tsx index aecb250809c983..4c16a1b1af8609 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/datasource.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/datasource.tsx @@ -8,19 +8,13 @@ type DatasourceProps = { nodeData: DataSourceNodeType } -const Datasource = ({ - nodeData, -}: DatasourceProps) => { +const Datasource = ({ nodeData }: DatasourceProps) => { const toolIcon = useToolIcon(nodeData) return ( <div className="flex items-center gap-x-1.5"> <div className="flex size-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default"> - <BlockIcon - className="size-3.5" - type={BlockEnum.DataSource} - toolIcon={toolIcon} - /> + <BlockIcon className="size-3.5" type={BlockEnum.DataSource} toolIcon={toolIcon} /> </div> <span className="system-sm-medium text-text-secondary">{nodeData.title}</span> </div> diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx index 79998d5acbf223..77e30b4fd3f501 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx @@ -8,10 +8,13 @@ const GlobalInputs = () => { return ( <div className="flex items-center gap-x-1"> <span className="system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['inputFieldPanel.globalInputs.title'], { ns: 'datasetPipeline' })} + {t(($) => $['inputFieldPanel.globalInputs.title'], { ns: 'datasetPipeline' })} </span> - <Infotip aria-label={t($ => $['inputFieldPanel.globalInputs.tooltip'], { ns: 'datasetPipeline' })} popupClassName="w-[240px]"> - {t($ => $['inputFieldPanel.globalInputs.tooltip'], { ns: 'datasetPipeline' })} + <Infotip + aria-label={t(($) => $['inputFieldPanel.globalInputs.tooltip'], { ns: 'datasetPipeline' })} + popupClassName="w-[240px]" + > + {t(($) => $['inputFieldPanel.globalInputs.tooltip'], { ns: 'datasetPipeline' })} </Infotip> </div> ) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx index 04701aeba4cf28..c838b2acd771a9 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx @@ -2,10 +2,7 @@ import type { Datasource } from '../../../test-run/types' import { fireEvent, render, screen } from '@testing-library/react' import DataSource from '../data-source' -const { - mockOnSelect, - mockUseDraftPipelinePreProcessingParams, -} = vi.hoisted(() => ({ +const { mockOnSelect, mockUseDraftPipelinePreProcessingParams } = vi.hoisted(() => ({ mockOnSelect: vi.fn(), mockUseDraftPipelinePreProcessingParams: vi.fn(() => ({ data: { @@ -15,7 +12,8 @@ const { })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }), + useStore: (selector: (state: { pipelineId: string }) => string) => + selector({ pipelineId: 'pipeline-1' }), })) vi.mock('@/service/use-pipeline', () => ({ @@ -31,9 +29,7 @@ vi.mock('../../../test-run/preparation/data-source-options', () => ({ dataSourceNodeId: string }) => ( <div data-testid="data-source-options" data-node-id={dataSourceNodeId}> - <button - onClick={() => onSelect({ nodeId: 'source-node' } as Datasource)} - > + <button onClick={() => onSelect({ nodeId: 'source-node' } as Datasource)}> select datasource </button> </div> @@ -42,7 +38,7 @@ vi.mock('../../../test-run/preparation/data-source-options', () => ({ vi.mock('../form', () => ({ default: ({ variables }: { variables: Array<{ variable: string }> }) => ( - <div data-testid="preview-form">{variables.map(item => item.variable).join(',')}</div> + <div data-testid="preview-form">{variables.map((item) => item.variable).join(',')}</div> ), })) @@ -52,22 +48,22 @@ describe('DataSource preview', () => { }) it('should render the datasource selection step and forward selected values', () => { - render( - <DataSource - onSelect={mockOnSelect} - dataSourceNodeId="node-1" - />, - ) + render(<DataSource onSelect={mockOnSelect} dataSourceNodeId="node-1" />) fireEvent.click(screen.getByText('select datasource')) - expect(screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle'), + ).toBeInTheDocument() expect(screen.getByTestId('data-source-options')).toHaveAttribute('data-node-id', 'node-1') expect(screen.getByTestId('preview-form')).toHaveTextContent('source') - expect(mockUseDraftPipelinePreProcessingParams).toHaveBeenCalledWith({ - pipeline_id: 'pipeline-1', - node_id: 'node-1', - }, true) + expect(mockUseDraftPipelinePreProcessingParams).toHaveBeenCalledWith( + { + pipeline_id: 'pipeline-1', + node_id: 'node-1', + }, + true, + ) expect(mockOnSelect).toHaveBeenCalledWith({ nodeId: 'source-node' }) }) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx index 66299e112f1000..e721b44984ba6f 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx @@ -6,12 +6,7 @@ type MockForm = { id: string } -const { - mockForm, - mockBaseField, - mockUseInitialData, - mockUseConfigurations, -} = vi.hoisted(() => ({ +const { mockForm, mockBaseField, mockUseInitialData, mockUseConfigurations } = vi.hoisted(() => ({ mockForm: { id: 'form-1', } as MockForm, diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx index 6284d3045b3d13..e5f4cc64c30361 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx @@ -85,7 +85,7 @@ vi.mock('../../../test-run/preparation/data-source-options', () => ({ }) => ( <div data-testid="data-source-options"> <span data-testid="current-node-id">{dataSourceNodeId}</span> - {mockDatasourceOptions.map(option => ( + {mockDatasourceOptions.map((option) => ( <button key={option.value} data-testid={`option-${option.value}`} @@ -93,7 +93,8 @@ vi.mock('../../../test-run/preparation/data-source-options', () => ({ onSelect({ nodeId: option.value, nodeData: option.data, - })} + }) + } > {option.label} </button> @@ -121,7 +122,7 @@ vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ }, useConfigurations: (variables: RAGPipelineVariables) => { return React.useMemo(() => { - return variables.map(item => ({ + return variables.map((item) => ({ type: item.type, variable: item.variable, label: item.label, @@ -150,7 +151,12 @@ vi.mock('@/app/components/base/form', () => ({ })) vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({ - default: ({ config }: { initialData: Record<string, unknown>, config: { variable: string, label: string } }) => { + default: ({ + config, + }: { + initialData: Record<string, unknown> + config: { variable: string; label: string } + }) => { const FieldComponent = ({ form }: { form: unknown }) => ( <div data-testid={`field-${config.variable}`}> <label>{config.label}</label> @@ -178,9 +184,7 @@ const createRAGPipelineVariable = ( ...overrides, }) -const createDatasourceOption = ( - overrides?: Partial<DataSourceOption>, -): DataSourceOption => ({ +const createDatasourceOption = (overrides?: Partial<DataSourceOption>): DataSourceOption => ({ label: 'Test Datasource', value: 'datasource-node-1', data: { @@ -202,9 +206,7 @@ const createTestQueryClient = () => const TestWrapper = ({ children }: { children: React.ReactNode }) => { const queryClient = createTestQueryClient() - return ( - <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> - ) + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> } const renderWithProviders = (ui: React.ReactElement) => { @@ -228,9 +230,7 @@ describe('PreviewPanel', () => { it('should render preview panel without crashing', () => { renderWithProviders(<PreviewPanel />) - expect( - screen.getByText('datasetPipeline.operations.preview'), - ).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.operations.preview')).toBeInTheDocument() }) it('should render preview badge', () => { @@ -277,9 +277,7 @@ describe('PreviewPanel', () => { }) it('should update datasource state when DataSource selects', () => { - mockDatasourceOptions = [ - createDatasourceOption({ value: 'node-1', label: 'Node 1' }), - ] + mockDatasourceOptions = [createDatasourceOption({ value: 'node-1', label: 'Node 1' })] renderWithProviders(<PreviewPanel />) fireEvent.click(screen.getByTestId('option-node-1')) @@ -288,9 +286,7 @@ describe('PreviewPanel', () => { }) it('should pass datasource nodeId to ProcessDocuments', () => { - mockDatasourceOptions = [ - createDatasourceOption({ value: 'test-node', label: 'Test Node' }), - ] + mockDatasourceOptions = [createDatasourceOption({ value: 'test-node', label: 'Test Node' })] renderWithProviders(<PreviewPanel />) fireEvent.click(screen.getByTestId('option-test-node')) @@ -431,9 +427,7 @@ describe('DataSource', () => { it('should render step one title', () => { const onSelect = vi.fn() - renderWithProviders( - <DataSource onSelect={onSelect} dataSourceNodeId="" />, - ) + renderWithProviders(<DataSource onSelect={onSelect} dataSourceNodeId="" />) expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle'), @@ -443,9 +437,7 @@ describe('DataSource', () => { it('should render DataSourceOptions component', () => { const onSelect = vi.fn() - renderWithProviders( - <DataSource onSelect={onSelect} dataSourceNodeId="" />, - ) + renderWithProviders(<DataSource onSelect={onSelect} dataSourceNodeId="" />) expect(screen.getByTestId('data-source-options')).toBeInTheDocument() }) @@ -453,13 +445,9 @@ describe('DataSource', () => { it('should pass dataSourceNodeId to DataSourceOptions', () => { const onSelect = vi.fn() - renderWithProviders( - <DataSource onSelect={onSelect} dataSourceNodeId="test-node-id" />, - ) + renderWithProviders(<DataSource onSelect={onSelect} dataSourceNodeId="test-node-id" />) - expect(screen.getByTestId('current-node-id').textContent).toBe( - 'test-node-id', - ) + expect(screen.getByTestId('current-node-id').textContent).toBe('test-node-id') }) }) @@ -498,9 +486,7 @@ describe('DataSource', () => { variables: [createRAGPipelineVariable()], } - renderWithProviders( - <DataSource onSelect={onSelect} dataSourceNodeId="test-node" />, - ) + renderWithProviders(<DataSource onSelect={onSelect} dataSourceNodeId="test-node" />) await waitFor(() => { expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() @@ -511,9 +497,7 @@ describe('DataSource', () => { const onSelect = vi.fn() mockPreProcessingParamsData = { variables: [] } - renderWithProviders( - <DataSource onSelect={onSelect} dataSourceNodeId="test-node" />, - ) + renderWithProviders(<DataSource onSelect={onSelect} dataSourceNodeId="test-node" />) expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() }) @@ -522,9 +506,7 @@ describe('DataSource', () => { const onSelect = vi.fn() mockPreProcessingParamsData = undefined - renderWithProviders( - <DataSource onSelect={onSelect} dataSourceNodeId="" />, - ) + renderWithProviders(<DataSource onSelect={onSelect} dataSourceNodeId="" />) expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle'), @@ -539,9 +521,7 @@ describe('DataSource', () => { createDatasourceOption({ value: 'selected-node', label: 'Selected' }), ] - renderWithProviders( - <DataSource onSelect={onSelect} dataSourceNodeId="" />, - ) + renderWithProviders(<DataSource onSelect={onSelect} dataSourceNodeId="" />) fireEvent.click(screen.getByTestId('option-selected-node')) expect(onSelect).toHaveBeenCalledTimes(1) @@ -578,9 +558,7 @@ describe('DataSource', () => { const onSelect = vi.fn() mockPipelineId = null - renderWithProviders( - <DataSource onSelect={onSelect} dataSourceNodeId="test-node" />, - ) + renderWithProviders(<DataSource onSelect={onSelect} dataSourceNodeId="test-node" />) expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle'), @@ -591,15 +569,10 @@ describe('DataSource', () => { const onSelect = vi.fn() renderWithProviders( - <DataSource - onSelect={onSelect} - dataSourceNodeId="node-with-special-chars_123" - />, + <DataSource onSelect={onSelect} dataSourceNodeId="node-with-special-chars_123" />, ) - expect(screen.getByTestId('current-node-id').textContent).toBe( - 'node-with-special-chars_123', - ) + expect(screen.getByTestId('current-node-id').textContent).toBe('node-with-special-chars_123') }) }) }) @@ -643,9 +616,7 @@ describe('ProcessDocuments', () => { }) it('should handle different dataSourceNodeId values', () => { - const { rerender } = renderWithProviders( - <ProcessDocuments dataSourceNodeId="node-1" />, - ) + const { rerender } = renderWithProviders(<ProcessDocuments dataSourceNodeId="node-1" />) expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle'), @@ -724,9 +695,7 @@ describe('ProcessDocuments', () => { describe('Memoization', () => { it('should be memoized (React.memo)', () => { - const { rerender } = renderWithProviders( - <ProcessDocuments dataSourceNodeId="node-1" />, - ) + const { rerender } = renderWithProviders(<ProcessDocuments dataSourceNodeId="node-1" />) rerender( <TestWrapper> @@ -929,7 +898,8 @@ describe('Form', () => { it('should handle many variables', () => { const variables = Array.from({ length: 20 }, (_, i) => - createRAGPipelineVariable({ variable: `var_${i}`, label: `Var ${i}` })) + createRAGPipelineVariable({ variable: `var_${i}`, label: `Var ${i}` }), + ) renderWithProviders(<Form variables={variables} />) @@ -1008,9 +978,7 @@ describe('Preview Panel Integration', () => { describe('End-to-End Flow', () => { it('should complete full preview flow: select datasource -> show forms', async () => { - mockDatasourceOptions = [ - createDatasourceOption({ value: 'node-1', label: 'Local File' }), - ] + mockDatasourceOptions = [createDatasourceOption({ value: 'node-1', label: 'Local File' })] mockPreProcessingParamsData = { variables: [ createRAGPipelineVariable({ @@ -1075,9 +1043,7 @@ describe('Preview Panel Integration', () => { renderWithProviders(<PreviewPanel />) fireEvent.click(screen.getByTestId('option-communicated-node')) - expect(screen.getByTestId('current-node-id').textContent).toBe( - 'communicated-node', - ) + expect(screen.getByTestId('current-node-id').textContent).toBe('communicated-node') }) }) @@ -1091,19 +1057,13 @@ describe('Preview Panel Integration', () => { renderWithProviders(<PreviewPanel />) fireEvent.click(screen.getByTestId('option-persistent-node')) - expect(screen.getByTestId('current-node-id').textContent).toBe( - 'persistent-node', - ) + expect(screen.getByTestId('current-node-id').textContent).toBe('persistent-node') fireEvent.click(screen.getByTestId('option-other-node')) - expect(screen.getByTestId('current-node-id').textContent).toBe( - 'other-node', - ) + expect(screen.getByTestId('current-node-id').textContent).toBe('other-node') fireEvent.click(screen.getByTestId('option-persistent-node')) - expect(screen.getByTestId('current-node-id').textContent).toBe( - 'persistent-node', - ) + expect(screen.getByTestId('current-node-id').textContent).toBe('persistent-node') }) }) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx index 3e4944d775bee1..f3fa9c24b78e5b 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx @@ -1,14 +1,17 @@ import { render, screen } from '@testing-library/react' import ProcessDocuments from '../process-documents' -const mockUseDraftPipelineProcessingParams = vi.hoisted(() => vi.fn(() => ({ - data: { - variables: [{ variable: 'chunkSize' }], - }, -}))) +const mockUseDraftPipelineProcessingParams = vi.hoisted(() => + vi.fn(() => ({ + data: { + variables: [{ variable: 'chunkSize' }], + }, + })), +) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }), + useStore: (selector: (state: { pipelineId: string }) => string) => + selector({ pipelineId: 'pipeline-1' }), })) vi.mock('@/service/use-pipeline', () => ({ @@ -17,7 +20,7 @@ vi.mock('@/service/use-pipeline', () => ({ vi.mock('../form', () => ({ default: ({ variables }: { variables: Array<{ variable: string }> }) => ( - <div data-testid="preview-form">{variables.map(item => item.variable).join(',')}</div> + <div data-testid="preview-form">{variables.map((item) => item.variable).join(',')}</div> ), })) @@ -29,11 +32,16 @@ describe('ProcessDocuments preview', () => { it('should render the processing step and its variables', () => { render(<ProcessDocuments dataSourceNodeId="node-2" />) - expect(screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle')).toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle'), + ).toBeInTheDocument() expect(screen.getByTestId('preview-form')).toHaveTextContent('chunkSize') - expect(mockUseDraftPipelineProcessingParams).toHaveBeenCalledWith({ - pipeline_id: 'pipeline-1', - node_id: 'node-2', - }, true) + expect(mockUseDraftPipelineProcessingParams).toHaveBeenCalledWith( + { + pipeline_id: 'pipeline-1', + node_id: 'node-2', + }, + true, + ) }) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/data-source.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/data-source.tsx index 673853531f1e11..6f259b59ffa2cf 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/data-source.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/data-source.tsx @@ -11,27 +11,24 @@ type DatasourceProps = { dataSourceNodeId: string } -const DataSource = ({ - onSelect: setDatasource, - dataSourceNodeId, -}: DatasourceProps) => { +const DataSource = ({ onSelect: setDatasource, dataSourceNodeId }: DatasourceProps) => { const { t } = useTranslation() - const pipelineId = useStore(state => state.pipelineId) - const { data: paramsConfig } = useDraftPipelinePreProcessingParams({ - pipeline_id: pipelineId!, - node_id: dataSourceNodeId, - }, !!pipelineId && !!dataSourceNodeId) + const pipelineId = useStore((state) => state.pipelineId) + const { data: paramsConfig } = useDraftPipelinePreProcessingParams( + { + pipeline_id: pipelineId!, + node_id: dataSourceNodeId, + }, + !!pipelineId && !!dataSourceNodeId, + ) return ( <div className="flex flex-col"> <div className="px-4 pt-2 system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['inputFieldPanel.preview.stepOneTitle'], { ns: 'datasetPipeline' })} + {t(($) => $['inputFieldPanel.preview.stepOneTitle'], { ns: 'datasetPipeline' })} </div> <div className="px-4 py-2"> - <DataSourceOptions - onSelect={setDatasource} - dataSourceNodeId={dataSourceNodeId} - /> + <DataSourceOptions onSelect={setDatasource} dataSourceNodeId={dataSourceNodeId} /> </div> <Form variables={paramsConfig?.variables || []} /> </div> diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/form.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/form.tsx index 4d7a9b92be384b..fda2715f2ebba2 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/form.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/form.tsx @@ -1,15 +1,16 @@ import type { RAGPipelineVariables } from '@/models/pipeline' import { useAppForm } from '@/app/components/base/form' import BaseField from '@/app/components/base/form/form-scenarios/base/field' -import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields' +import { + useConfigurations, + useInitialData, +} from '@/app/components/rag-pipeline/hooks/use-input-fields' type FormProps = { variables: RAGPipelineVariables } -const Form = ({ - variables, -}: FormProps) => { +const Form = ({ variables }: FormProps) => { const initialData = useInitialData(variables) const configurations = useConfigurations(variables) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/index.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/index.tsx index 63cfd748166226..1b64009f8fba70 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/index.tsx @@ -35,7 +35,7 @@ const PreviewPanel = () => { <div className="flex items-center gap-x-2 px-4 pt-1"> <div className="grow py-1"> <Badge className="border-text-accent-secondary bg-components-badge-bg-dimm text-text-accent-secondary"> - {t($ => $['operations.preview'], { ns: 'datasetPipeline' })} + {t(($) => $['operations.preview'], { ns: 'datasetPipeline' })} </Badge> </div> <button @@ -47,10 +47,7 @@ const PreviewPanel = () => { </button> </div> {/* Data source form Preview */} - <DataSource - onSelect={setDatasource} - dataSourceNodeId={datasource?.nodeId || ''} - /> + <DataSource onSelect={setDatasource} dataSourceNodeId={datasource?.nodeId || ''} /> <div className="px-4 py-2"> <Divider type="horizontal" className="bg-divider-subtle" /> </div> diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/process-documents.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/process-documents.tsx index 51a456086b3983..c22db79e1a734b 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/process-documents.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/process-documents.tsx @@ -8,20 +8,21 @@ type ProcessDocumentsProps = { dataSourceNodeId: string } -const ProcessDocuments = ({ - dataSourceNodeId, -}: ProcessDocumentsProps) => { +const ProcessDocuments = ({ dataSourceNodeId }: ProcessDocumentsProps) => { const { t } = useTranslation() - const pipelineId = useStore(state => state.pipelineId) - const { data: paramsConfig } = useDraftPipelineProcessingParams({ - pipeline_id: pipelineId!, - node_id: dataSourceNodeId, - }, !!pipelineId && !!dataSourceNodeId) + const pipelineId = useStore((state) => state.pipelineId) + const { data: paramsConfig } = useDraftPipelineProcessingParams( + { + pipeline_id: pipelineId!, + node_id: dataSourceNodeId, + }, + !!pipelineId && !!dataSourceNodeId, + ) return ( <div className="flex flex-col"> <div className="px-4 pt-2 system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['inputFieldPanel.preview.stepTwoTitle'], { ns: 'datasetPipeline' })} + {t(($) => $['inputFieldPanel.preview.stepTwoTitle'], { ns: 'datasetPipeline' })} </div> <Form variables={paramsConfig?.variables || []} /> </div> diff --git a/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx index 8149bac1448cdd..c420646e8d1411 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx @@ -1,20 +1,17 @@ import { fireEvent, render, screen } from '@testing-library/react' import Header from '../header' -const { - mockSetIsPreparingDataSource, - mockHandleCancelDebugAndPreviewPanel, - mockWorkflowStore, -} = vi.hoisted(() => ({ - mockSetIsPreparingDataSource: vi.fn(), - mockHandleCancelDebugAndPreviewPanel: vi.fn(), - mockWorkflowStore: { - getState: vi.fn(() => ({ - isPreparingDataSource: true, - setIsPreparingDataSource: vi.fn(), - })), - }, -})) +const { mockSetIsPreparingDataSource, mockHandleCancelDebugAndPreviewPanel, mockWorkflowStore } = + vi.hoisted(() => ({ + mockSetIsPreparingDataSource: vi.fn(), + mockHandleCancelDebugAndPreviewPanel: vi.fn(), + mockWorkflowStore: { + getState: vi.fn(() => ({ + isPreparingDataSource: true, + setIsPreparingDataSource: vi.fn(), + })), + }, + })) vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => mockWorkflowStore, diff --git a/web/app/components/rag-pipeline/components/panel/test-run/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/index.spec.tsx index 5acea3733c41fe..0b16cc450f37ec 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/index.spec.tsx @@ -39,9 +39,14 @@ vi.mock('@/app/components/workflow/hooks', () => ({ useToolIcon: () => 'mock-tool-icon', })) -vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store/provider', () => ({ - default: ({ children }: { children: React.ReactNode }) => <div data-testid="data-source-provider">{children}</div>, -})) +vi.mock( + '@/app/components/datasets/documents/create-from-pipeline/data-source/store/provider', + () => ({ + default: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="data-source-provider">{children}</div> + ), + }), +) vi.mock('../preparation', () => ({ default: () => <div data-testid="preparation-component">Preparation</div>, @@ -53,23 +58,13 @@ vi.mock('../result', () => ({ vi.mock('@/app/components/workflow/run/result-panel', () => ({ default: (props: Record<string, unknown>) => ( - <div data-testid="result-panel"> - ResultPanel - - {' '} - {props.status as string} - </div> + <div data-testid="result-panel">ResultPanel - {props.status as string}</div> ), })) vi.mock('@/app/components/workflow/run/tracing-panel', () => ({ default: (props: { list: unknown[] }) => ( - <div data-testid="tracing-panel"> - TracingPanel - - {' '} - {props.list?.length ?? 0} - {' '} - items - </div> + <div data-testid="tracing-panel">TracingPanel - {props.list?.length ?? 0} items</div> ), })) @@ -81,7 +76,9 @@ vi.mock('@/config', () => ({ RAG_PIPELINE_PREVIEW_CHUNK_NUM: 5, })) -const createMockWorkflowRunningData = (overrides: Partial<WorkflowRunningData> = {}): WorkflowRunningData => ({ +const createMockWorkflowRunningData = ( + overrides: Partial<WorkflowRunningData> = {}, +): WorkflowRunningData => ({ result: { status: WorkflowRunningStatus.Succeeded, outputs: '{"test": "output"}', @@ -103,7 +100,7 @@ const createMockWorkflowRunningData = (overrides: Partial<WorkflowRunningData> = const createMockGeneralOutputs = (chunkContents: string[] = ['chunk1', 'chunk2']) => ({ chunk_structure: ChunkingMode.text, - preview: chunkContents.map(content => ({ content })), + preview: chunkContents.map((content) => ({ content })), }) const createMockParentChildOutputs = (parentMode: 'paragraph' | 'full-doc' = 'paragraph') => ({ @@ -271,7 +268,11 @@ describe('Result', () => { }) it('should switch to TRACING tab when clicked', async () => { - mockWorkflowRunningData.mockReturnValue(createMockWorkflowRunningData({ tracing: [{ id: '1' }] as unknown as WorkflowRunningData['tracing'] })) + mockWorkflowRunningData.mockReturnValue( + createMockWorkflowRunningData({ + tracing: [{ id: '1' }] as unknown as WorkflowRunningData['tracing'], + }), + ) render(<Result />) const tracingTab = screen.getByRole('button', { name: /runLog\.tracing/i }) @@ -369,7 +370,9 @@ describe('ResultPreview', () => { ) expect(screen.getByText('pipeline.result.resultPreview.error')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'pipeline.result.resultPreview.viewDetails' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'pipeline.result.resultPreview.viewDetails' }), + ).toBeInTheDocument() }) it('should call onSwitchToDetail when View Details button is clicked', () => { @@ -382,7 +385,9 @@ describe('ResultPreview', () => { />, ) - const viewDetailsButton = screen.getByRole('button', { name: 'pipeline.result.resultPreview.viewDetails' }) + const viewDetailsButton = screen.getByRole('button', { + name: 'pipeline.result.resultPreview.viewDetails', + }) fireEvent.click(viewDetailsButton) expect(mockOnSwitchToDetail).toHaveBeenCalledTimes(1) @@ -579,13 +584,7 @@ describe('Tabs', () => { describe('Disabled State', () => { it('should disable tabs when workflowRunningData is undefined', () => { - render( - <Tabs - currentTab="RESULT" - workflowRunningData={undefined} - switchTab={mockSwitchTab} - />, - ) + render(<Tabs currentTab="RESULT" workflowRunningData={undefined} switchTab={mockSwitchTab} />) const resultTab = screen.getByRole('button', { name: /runLog\.result/i }) expect(resultTab).toBeDisabled() @@ -794,8 +793,16 @@ describe('formatPreviewChunks', () => { expect(result).toEqual({ parent_child_chunks: [ - { parent_content: 'parent1', child_contents: ['child1', 'child2'], parent_mode: 'paragraph' }, - { parent_content: 'parent2', child_contents: ['child3', 'child4'], parent_mode: 'paragraph' }, + { + parent_content: 'parent1', + child_contents: ['child1', 'child2'], + parent_mode: 'paragraph', + }, + { + parent_content: 'parent2', + child_contents: ['child3', 'child4'], + parent_mode: 'paragraph', + }, ], parent_mode: 'paragraph', }) @@ -848,7 +855,9 @@ describe('formatPreviewChunks', () => { answer: `A${i}`, })), } - const result = formatPreviewChunks(outputs) as { qa_chunks: Array<{ question: string, answer: string }> } + const result = formatPreviewChunks(outputs) as { + qa_chunks: Array<{ question: string; answer: string }> + } expect(result.qa_chunks).toHaveLength(5) }) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/header.tsx b/web/app/components/rag-pipeline/components/panel/test-run/header.tsx index 2ffc03a2d3b0ac..a9714da31b6529 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/header.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/header.tsx @@ -12,19 +12,15 @@ const Header = () => { const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions() const handleClose = useCallback(() => { - const { - isPreparingDataSource, - setIsPreparingDataSource, - } = workflowStore.getState() - if (isPreparingDataSource) - setIsPreparingDataSource?.(false) + const { isPreparingDataSource, setIsPreparingDataSource } = workflowStore.getState() + if (isPreparingDataSource) setIsPreparingDataSource?.(false) handleCancelDebugAndPreviewPanel() }, [workflowStore]) return ( <div className="flex items-center gap-x-2 pt-4 pr-3 pl-4"> <div className="grow pr-8 pl-1 system-xl-semibold text-text-primary"> - {t($ => $['testRun.title'], { ns: 'datasetPipeline' })} + {t(($) => $['testRun.title'], { ns: 'datasetPipeline' })} </div> <button type="button" diff --git a/web/app/components/rag-pipeline/components/panel/test-run/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/index.tsx index 8a8cb882223fa6..122199c95aa230 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/index.tsx @@ -5,22 +5,18 @@ import Preparation from './preparation' import Result from './result' const TestRunPanel = () => { - const isPreparingDataSource = useStore(state => state.isPreparingDataSource) + const isPreparingDataSource = useStore((state) => state.isPreparingDataSource) return ( - <div - className="relative flex h-full w-[480px] flex-col rounded-l-2xl border-y-[0.5px] border-l-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-1" - > + <div className="relative flex h-full w-[480px] flex-col rounded-l-2xl border-y-[0.5px] border-l-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-1"> <Header /> - {isPreparingDataSource - ? ( - <DataSourceProvider> - <Preparation /> - </DataSourceProvider> - ) - : ( - <Result /> - )} + {isPreparingDataSource ? ( + <DataSourceProvider> + <Preparation /> + </DataSourceProvider> + ) : ( + <Result /> + )} </div> ) } diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/hooks.spec.ts index 35d42fc269c23d..28b6806bf783c2 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/hooks.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/hooks.spec.ts @@ -3,9 +3,15 @@ import { renderHook } from '@testing-library/react' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BlockEnum } from '@/app/components/workflow/types' -import { useDatasourceOptions, useOnlineDocument, useOnlineDrive, useTestRunSteps, useWebsiteCrawl } from '../hooks' - -const mockNodes: Array<{ id: string, data: Partial<DataSourceNodeType> & { type: string } }> = [] +import { + useDatasourceOptions, + useOnlineDocument, + useOnlineDrive, + useTestRunSteps, + useWebsiteCrawl, +} from '../hooks' + +const mockNodes: Array<{ id: string; data: Partial<DataSourceNodeType> & { type: string } }> = [] vi.mock('reactflow', () => ({ useNodes: () => mockNodes, })) @@ -18,7 +24,9 @@ vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/st })) vi.mock('@/app/components/workflow/types', async () => { - const actual = await vi.importActual<typeof import('@/app/components/workflow/types')>('@/app/components/workflow/types') + const actual = await vi.importActual<typeof import('@/app/components/workflow/types')>( + '@/app/components/workflow/types', + ) return { ...actual, BlockEnum: { diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/index.spec.tsx index 0ec5a4d1667ff7..5b7ac30f19ac8e 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/index.spec.tsx @@ -14,21 +14,22 @@ import { import Preparation from '../index' import StepIndicator from '../step-indicator' -let mockNodes: Array<{ id: string, data: DataSourceNodeType }> = [] - -const createNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ - title: 'Test Node', - desc: 'Test description', - type: 'data-source', - provider_type: DatasourceType.localFile, - provider_name: 'Local File', - datasource_name: 'local_file', - datasource_label: 'Local File', - plugin_id: 'test-plugin', - datasource_parameters: {}, - datasource_configurations: {}, - ...overrides, -} as unknown as DataSourceNodeType) +let mockNodes: Array<{ id: string; data: DataSourceNodeType }> = [] + +const createNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => + ({ + title: 'Test Node', + desc: 'Test description', + type: 'data-source', + provider_type: DatasourceType.localFile, + provider_name: 'Local File', + datasource_name: 'local_file', + datasource_label: 'Local File', + plugin_id: 'test-plugin', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, + }) as unknown as DataSourceNodeType vi.mock('reactflow', () => ({ useNodes: () => mockNodes, @@ -43,14 +44,23 @@ vi.mock('@/app/components/base/amplitude', () => ({ })) let mockDataSourceStoreState = { - localFileList: [] as Array<{ file: { id: string, name: string, type: string, size: number, extension: string, mime_type: string } }>, - onlineDocuments: [] as Array<{ workspace_id: string, page_id?: string, title?: string }>, - websitePages: [] as Array<{ url?: string, title?: string }>, + localFileList: [] as Array<{ + file: { + id: string + name: string + type: string + size: number + extension: string + mime_type: string + } + }>, + onlineDocuments: [] as Array<{ workspace_id: string; page_id?: string; title?: string }>, + websitePages: [] as Array<{ url?: string; title?: string }>, selectedFileIds: [] as string[], currentCredentialId: '', currentNodeIdRef: { current: '' }, bucket: '', - onlineDriveFileList: [] as Array<{ id: string, name: string, type: string }>, + onlineDriveFileList: [] as Array<{ id: string; name: string; type: string }>, setCurrentCredentialId: vi.fn(), setDocumentsData: vi.fn(), setSearchValue: vi.fn(), @@ -73,7 +83,8 @@ vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/st useDataSourceStore: () => ({ getState: () => mockDataSourceStoreState, }), - useDataSourceStoreWithSelector: <T,>(selector: (state: typeof mockDataSourceStoreState) => T) => selector(mockDataSourceStoreState), + useDataSourceStoreWithSelector: <T,>(selector: (state: typeof mockDataSourceStoreState) => T) => + selector(mockDataSourceStoreState), })) let mockWorkflowStoreState = { @@ -85,7 +96,8 @@ vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => ({ getState: () => mockWorkflowStoreState, }), - useStore: <T,>(selector: (state: typeof mockWorkflowStoreState) => T) => selector(mockWorkflowStoreState), + useStore: <T,>(selector: (state: typeof mockWorkflowStoreState) => T) => + selector(mockWorkflowStoreState), })) const mockHandleRun = vi.fn() @@ -98,8 +110,18 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/local-file', () => ({ - default: ({ allowedExtensions, supportBatchUpload }: { allowedExtensions: string[], supportBatchUpload: boolean }) => ( - <div data-testid="local-file" data-extensions={JSON.stringify(allowedExtensions)} data-batch={supportBatchUpload}> + default: ({ + allowedExtensions, + supportBatchUpload, + }: { + allowedExtensions: string[] + supportBatchUpload: boolean + }) => ( + <div + data-testid="local-file" + data-extensions={JSON.stringify(allowedExtensions)} + data-batch={supportBatchUpload} + > LocalFile Component </div> ), @@ -113,78 +135,136 @@ type MockDataSourceComponentProps = { onCredentialChange?: (credentialId: string) => void } -vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/online-documents', () => ({ - default: ({ nodeId, isInPipeline, supportBatchUpload, onCredentialChange }: MockDataSourceComponentProps) => ( - <div data-testid="online-documents" data-node-id={nodeId} data-in-pipeline={isInPipeline} data-batch={supportBatchUpload}> - <button onClick={() => onCredentialChange?.('new-credential-id')}>Change Credential</button> - OnlineDocuments Component - </div> - ), -})) - -vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl', () => ({ - default: ({ nodeId, isInPipeline, supportBatchUpload, onCredentialChange }: MockDataSourceComponentProps) => ( - <div data-testid="website-crawl" data-node-id={nodeId} data-in-pipeline={isInPipeline} data-batch={supportBatchUpload}> - <button onClick={() => onCredentialChange?.('new-credential-id')}>Change Credential</button> - WebsiteCrawl Component - </div> - ), -})) - -vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/online-drive', () => ({ - default: ({ nodeId, isInPipeline, supportBatchUpload, onCredentialChange }: MockDataSourceComponentProps) => ( - <div data-testid="online-drive" data-node-id={nodeId} data-in-pipeline={isInPipeline} data-batch={supportBatchUpload}> - <button onClick={() => onCredentialChange?.('new-credential-id')}>Change Credential</button> - OnlineDrive Component - </div> - ), -})) +vi.mock( + '@/app/components/datasets/documents/create-from-pipeline/data-source/online-documents', + () => ({ + default: ({ + nodeId, + isInPipeline, + supportBatchUpload, + onCredentialChange, + }: MockDataSourceComponentProps) => ( + <div + data-testid="online-documents" + data-node-id={nodeId} + data-in-pipeline={isInPipeline} + data-batch={supportBatchUpload} + > + <button onClick={() => onCredentialChange?.('new-credential-id')}>Change Credential</button> + OnlineDocuments Component + </div> + ), + }), +) + +vi.mock( + '@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl', + () => ({ + default: ({ + nodeId, + isInPipeline, + supportBatchUpload, + onCredentialChange, + }: MockDataSourceComponentProps) => ( + <div + data-testid="website-crawl" + data-node-id={nodeId} + data-in-pipeline={isInPipeline} + data-batch={supportBatchUpload} + > + <button onClick={() => onCredentialChange?.('new-credential-id')}>Change Credential</button> + WebsiteCrawl Component + </div> + ), + }), +) + +vi.mock( + '@/app/components/datasets/documents/create-from-pipeline/data-source/online-drive', + () => ({ + default: ({ + nodeId, + isInPipeline, + supportBatchUpload, + onCredentialChange, + }: MockDataSourceComponentProps) => ( + <div + data-testid="online-drive" + data-node-id={nodeId} + data-in-pipeline={isInPipeline} + data-batch={supportBatchUpload} + > + <button onClick={() => onCredentialChange?.('new-credential-id')}>Change Credential</button> + OnlineDrive Component + </div> + ), + }), +) vi.mock('../data-source-options', () => ({ - default: ({ dataSourceNodeId, onSelect }: { dataSourceNodeId: string, onSelect: (ds: Datasource) => void }) => ( + default: ({ + dataSourceNodeId, + onSelect, + }: { + dataSourceNodeId: string + onSelect: (ds: Datasource) => void + }) => ( <div data-testid="data-source-options" data-selected={dataSourceNodeId}> <button data-testid="select-local-file" - onClick={() => onSelect({ - nodeId: 'local-file-node', - nodeData: createNodeData({ provider_type: DatasourceType.localFile, fileExtensions: ['txt', 'pdf'] }), - })} + onClick={() => + onSelect({ + nodeId: 'local-file-node', + nodeData: createNodeData({ + provider_type: DatasourceType.localFile, + fileExtensions: ['txt', 'pdf'], + }), + }) + } > Select Local File </button> <button data-testid="select-online-document" - onClick={() => onSelect({ - nodeId: 'online-doc-node', - nodeData: createNodeData({ provider_type: DatasourceType.onlineDocument }), - })} + onClick={() => + onSelect({ + nodeId: 'online-doc-node', + nodeData: createNodeData({ provider_type: DatasourceType.onlineDocument }), + }) + } > Select Online Document </button> <button data-testid="select-website-crawl" - onClick={() => onSelect({ - nodeId: 'website-crawl-node', - nodeData: createNodeData({ provider_type: DatasourceType.websiteCrawl }), - })} + onClick={() => + onSelect({ + nodeId: 'website-crawl-node', + nodeData: createNodeData({ provider_type: DatasourceType.websiteCrawl }), + }) + } > Select Website Crawl </button> <button data-testid="select-online-drive" - onClick={() => onSelect({ - nodeId: 'online-drive-node', - nodeData: createNodeData({ provider_type: DatasourceType.onlineDrive }), - })} + onClick={() => + onSelect({ + nodeId: 'online-drive-node', + nodeData: createNodeData({ provider_type: DatasourceType.onlineDrive }), + }) + } > Select Online Drive </button> <button data-testid="select-unknown-type" - onClick={() => onSelect({ - nodeId: 'unknown-type-node', - nodeData: createNodeData({ provider_type: 'unknown_type' as DatasourceType }), - })} + onClick={() => + onSelect({ + nodeId: 'unknown-type-node', + nodeData: createNodeData({ provider_type: 'unknown_type' as DatasourceType }), + }) + } > Select Unknown Type </button> @@ -194,10 +274,22 @@ vi.mock('../data-source-options', () => ({ })) vi.mock('../document-processing', () => ({ - default: ({ dataSourceNodeId, onProcess, onBack }: { dataSourceNodeId: string, onProcess: (data: Record<string, unknown>) => void, onBack: () => void }) => ( + default: ({ + dataSourceNodeId, + onProcess, + onBack, + }: { + dataSourceNodeId: string + onProcess: (data: Record<string, unknown>) => void + onBack: () => void + }) => ( <div data-testid="document-processing" data-node-id={dataSourceNodeId}> - <button data-testid="process-btn" onClick={() => onProcess({ field1: 'value1' })}>Process</button> - <button data-testid="back-btn" onClick={onBack}>Back</button> + <button data-testid="process-btn" onClick={() => onProcess({ field1: 'value1' })}> + Process + </button> + <button data-testid="back-btn" onClick={onBack}> + Back + </button> DocumentProcessing </div> ), @@ -478,8 +570,7 @@ describe('FooterTips', () => { it('should render consistently across multiple rerenders', () => { const { rerender } = render(<FooterTips />) - for (let i = 0; i < 5; i++) - rerender(<FooterTips />) + for (let i = 0; i < 5; i++) rerender(<FooterTips />) expect(screen.getByText('datasetPipeline.testRun.tooltip'))!.toBeInTheDocument() }) @@ -576,8 +667,7 @@ describe('useTestRunSteps', () => { const { result } = renderHook(() => useTestRunSteps()) act(() => { - for (let i = 0; i < 4; i++) - result.current.handleNextStep() + for (let i = 0; i < 4; i++) result.current.handleNextStep() }) expect(result.current.currentStep).toBe(5) @@ -878,9 +968,15 @@ describe('useOnlineDocument', () => { const callOrder: string[] = [] mockDataSourceStoreState.setDocumentsData = vi.fn(() => callOrder.push('setDocumentsData')) mockDataSourceStoreState.setSearchValue = vi.fn(() => callOrder.push('setSearchValue')) - mockDataSourceStoreState.setSelectedPagesId = vi.fn(() => callOrder.push('setSelectedPagesId')) - mockDataSourceStoreState.setOnlineDocuments = vi.fn(() => callOrder.push('setOnlineDocuments')) - mockDataSourceStoreState.setCurrentDocument = vi.fn(() => callOrder.push('setCurrentDocument')) + mockDataSourceStoreState.setSelectedPagesId = vi.fn(() => + callOrder.push('setSelectedPagesId'), + ) + mockDataSourceStoreState.setOnlineDocuments = vi.fn(() => + callOrder.push('setOnlineDocuments'), + ) + mockDataSourceStoreState.setCurrentDocument = vi.fn(() => + callOrder.push('setCurrentDocument'), + ) act(() => { result.current.clearOnlineDocumentData() @@ -1004,11 +1100,15 @@ describe('useOnlineDrive', () => { it('should call all clear functions in correct order', () => { const { result } = renderHook(() => useOnlineDrive()) const callOrder: string[] = [] - mockDataSourceStoreState.setOnlineDriveFileList = vi.fn(() => callOrder.push('setOnlineDriveFileList')) + mockDataSourceStoreState.setOnlineDriveFileList = vi.fn(() => + callOrder.push('setOnlineDriveFileList'), + ) mockDataSourceStoreState.setBucket = vi.fn(() => callOrder.push('setBucket')) mockDataSourceStoreState.setPrefix = vi.fn(() => callOrder.push('setPrefix')) mockDataSourceStoreState.setKeywords = vi.fn(() => callOrder.push('setKeywords')) - mockDataSourceStoreState.setSelectedFileIds = vi.fn(() => callOrder.push('setSelectedFileIds')) + mockDataSourceStoreState.setSelectedFileIds = vi.fn(() => + callOrder.push('setSelectedFileIds'), + ) act(() => { result.current.clearOnlineDriveData() @@ -1039,7 +1139,9 @@ describe('useOnlineDrive', () => { result.current.clearOnlineDriveData() }) - expect(mockDataSourceStoreState.setOnlineDriveFileList.mock.calls.length).toBe(firstCallCount + 1) + expect(mockDataSourceStoreState.setOnlineDriveFileList.mock.calls.length).toBe( + firstCallCount + 1, + ) }) }) }) @@ -1061,7 +1163,9 @@ describe('Preparation', () => { render(<Preparation />) expect(screen.getByText('datasetPipeline.testRun.steps.dataSource'))!.toBeInTheDocument() - expect(screen.getByText('datasetPipeline.testRun.steps.documentProcessing'))!.toBeInTheDocument() + expect( + screen.getByText('datasetPipeline.testRun.steps.documentProcessing'), + )!.toBeInTheDocument() }) it('should render DataSourceOptions on step 1', () => { @@ -1154,11 +1258,17 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('select-local-file')) - expect(screen.getByTestId('data-source-options'))!.toHaveAttribute('data-selected', 'local-file-node') + expect(screen.getByTestId('data-source-options'))!.toHaveAttribute( + 'data-selected', + 'local-file-node', + ) fireEvent.click(screen.getByTestId('select-online-document')) - expect(screen.getByTestId('data-source-options'))!.toHaveAttribute('data-selected', 'online-doc-node') + expect(screen.getByTestId('data-source-options'))!.toHaveAttribute( + 'data-selected', + 'online-doc-node', + ) }) }) @@ -1166,7 +1276,9 @@ describe('Preparation', () => { it('should disable next button when no datasource is selected', () => { render(<Preparation />) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))!.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + )!.toBeDisabled() }) it('should disable next button for local file when file list is empty', () => { @@ -1175,29 +1287,53 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('select-local-file')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))!.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + )!.toBeDisabled() }) it('should disable next button for local file when file has no id', () => { mockDataSourceStoreState.localFileList = [ - { file: { id: '', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: '', + name: 'test.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] render(<Preparation />) fireEvent.click(screen.getByTestId('select-local-file')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))!.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + )!.toBeDisabled() }) it('should enable next button for local file when file has valid id', () => { mockDataSourceStoreState.localFileList = [ - { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'file-123', + name: 'test.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] render(<Preparation />) fireEvent.click(screen.getByTestId('select-local-file')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).not.toBeDisabled() }) it('should disable next button for online document when documents list is empty', () => { @@ -1206,7 +1342,9 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('select-online-document')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))!.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + )!.toBeDisabled() }) it('should enable next button for online document when documents exist', () => { @@ -1215,7 +1353,9 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('select-online-document')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).not.toBeDisabled() }) it('should disable next button for website crawl when pages list is empty', () => { @@ -1224,7 +1364,9 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('select-website-crawl')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))!.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + )!.toBeDisabled() }) it('should enable next button for website crawl when pages exist', () => { @@ -1233,7 +1375,9 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('select-website-crawl')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).not.toBeDisabled() }) it('should disable next button for online drive when no files selected', () => { @@ -1242,7 +1386,9 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('select-online-drive')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))!.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + )!.toBeDisabled() }) it('should enable next button for online drive when files are selected', () => { @@ -1251,14 +1397,25 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('select-online-drive')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).not.toBeDisabled() }) }) describe('Step Navigation', () => { it('should navigate to step 2 when next button is clicked with valid data', () => { mockDataSourceStoreState.localFileList = [ - { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'file-123', + name: 'test.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] render(<Preparation />) @@ -1271,19 +1428,40 @@ describe('Preparation', () => { it('should pass correct dataSourceNodeId to DocumentProcessing', () => { mockDataSourceStoreState.localFileList = [ - { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'file-123', + name: 'test.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] render(<Preparation />) fireEvent.click(screen.getByTestId('select-local-file')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - expect(screen.getByTestId('document-processing'))!.toHaveAttribute('data-node-id', 'local-file-node') + expect(screen.getByTestId('document-processing'))!.toHaveAttribute( + 'data-node-id', + 'local-file-node', + ) }) it('should navigate back to step 1 when back button is clicked', () => { mockDataSourceStoreState.localFileList = [ - { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'file-123', + name: 'test.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] render(<Preparation />) @@ -1301,7 +1479,16 @@ describe('Preparation', () => { describe('handleProcess', () => { it('should call handleRun with correct params for local file', async () => { mockDataSourceStoreState.localFileList = [ - { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'file-123', + name: 'test.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] render(<Preparation />) @@ -1310,16 +1497,20 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('process-btn')) await waitFor(() => { - expect(mockHandleRun).toHaveBeenCalledWith(expect.objectContaining({ - inputs: { field1: 'value1' }, - start_node_id: 'local-file-node', - datasource_type: DatasourceType.localFile, - })) + expect(mockHandleRun).toHaveBeenCalledWith( + expect.objectContaining({ + inputs: { field1: 'value1' }, + start_node_id: 'local-file-node', + datasource_type: DatasourceType.localFile, + }), + ) }) }) it('should call handleRun with correct params for online document', async () => { - mockDataSourceStoreState.onlineDocuments = [{ workspace_id: 'ws-1', page_id: 'page-1', title: 'Test Doc' }] + mockDataSourceStoreState.onlineDocuments = [ + { workspace_id: 'ws-1', page_id: 'page-1', title: 'Test Doc' }, + ] mockDataSourceStoreState.currentCredentialId = 'cred-123' render(<Preparation />) @@ -1328,11 +1519,13 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('process-btn')) await waitFor(() => { - expect(mockHandleRun).toHaveBeenCalledWith(expect.objectContaining({ - inputs: { field1: 'value1' }, - start_node_id: 'online-doc-node', - datasource_type: DatasourceType.onlineDocument, - })) + expect(mockHandleRun).toHaveBeenCalledWith( + expect.objectContaining({ + inputs: { field1: 'value1' }, + start_node_id: 'online-doc-node', + datasource_type: DatasourceType.onlineDocument, + }), + ) }) }) @@ -1346,17 +1539,21 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('process-btn')) await waitFor(() => { - expect(mockHandleRun).toHaveBeenCalledWith(expect.objectContaining({ - inputs: { field1: 'value1' }, - start_node_id: 'website-crawl-node', - datasource_type: DatasourceType.websiteCrawl, - })) + expect(mockHandleRun).toHaveBeenCalledWith( + expect.objectContaining({ + inputs: { field1: 'value1' }, + start_node_id: 'website-crawl-node', + datasource_type: DatasourceType.websiteCrawl, + }), + ) }) }) it('should call handleRun with correct params for online drive', async () => { mockDataSourceStoreState.selectedFileIds = ['file-1'] - mockDataSourceStoreState.onlineDriveFileList = [{ id: 'file-1', name: 'data.csv', type: 'file' }] + mockDataSourceStoreState.onlineDriveFileList = [ + { id: 'file-1', name: 'data.csv', type: 'file' }, + ] mockDataSourceStoreState.bucket = 'my-bucket' mockDataSourceStoreState.currentCredentialId = 'cred-789' render(<Preparation />) @@ -1366,17 +1563,28 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('process-btn')) await waitFor(() => { - expect(mockHandleRun).toHaveBeenCalledWith(expect.objectContaining({ - inputs: { field1: 'value1' }, - start_node_id: 'online-drive-node', - datasource_type: DatasourceType.onlineDrive, - })) + expect(mockHandleRun).toHaveBeenCalledWith( + expect.objectContaining({ + inputs: { field1: 'value1' }, + start_node_id: 'online-drive-node', + datasource_type: DatasourceType.onlineDrive, + }), + ) }) }) it('should call setIsPreparingDataSource(false) after processing', async () => { mockDataSourceStoreState.localFileList = [ - { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'file-123', + name: 'test.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] render(<Preparation />) @@ -1430,7 +1638,9 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('select-online-document')) fireEvent.click(screen.getByText('Change Credential')) - expect(mockDataSourceStoreState.setCurrentCredentialId).toHaveBeenCalledWith('new-credential-id') + expect(mockDataSourceStoreState.setCurrentCredentialId).toHaveBeenCalledWith( + 'new-credential-id', + ) }) it('should clear data when credential changes for website crawl', () => { @@ -1440,7 +1650,9 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('select-website-crawl')) fireEvent.click(screen.getByText('Change Credential')) - expect(mockDataSourceStoreState.setCurrentCredentialId).toHaveBeenCalledWith('new-credential-id') + expect(mockDataSourceStoreState.setCurrentCredentialId).toHaveBeenCalledWith( + 'new-credential-id', + ) expect(mockDataSourceStoreState.setWebsitePages).toHaveBeenCalled() }) @@ -1451,7 +1663,9 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('select-online-drive')) fireEvent.click(screen.getByText('Change Credential')) - expect(mockDataSourceStoreState.setCurrentCredentialId).toHaveBeenCalledWith('new-credential-id') + expect(mockDataSourceStoreState.setCurrentCredentialId).toHaveBeenCalledWith( + 'new-credential-id', + ) expect(mockDataSourceStoreState.setOnlineDriveFileList).toHaveBeenCalled() }) }) @@ -1484,7 +1698,16 @@ describe('Preparation', () => { it('should maintain state across rerenders', () => { mockDataSourceStoreState.localFileList = [ - { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'file-123', + name: 'test.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] const { rerender } = render(<Preparation />) @@ -1509,7 +1732,9 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('select-unknown-type')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).not.toBeDisabled() }) it('should handle handleProcess with unknown datasource type', async () => { @@ -1521,11 +1746,13 @@ describe('Preparation', () => { fireEvent.click(screen.getByTestId('process-btn')) await waitFor(() => { - expect(mockHandleRun).toHaveBeenCalledWith(expect.objectContaining({ - start_node_id: 'unknown-type-node', - datasource_type: 'unknown_type', - datasource_info_list: [], // Empty because no type matched - })) + expect(mockHandleRun).toHaveBeenCalledWith( + expect.objectContaining({ + start_node_id: 'unknown-type-node', + datasource_type: 'unknown_type', + datasource_info_list: [], // Empty because no type matched + }), + ) }) }) @@ -1543,7 +1770,16 @@ describe('Preparation', () => { it('should handle rapid step navigation', () => { mockDataSourceStoreState.localFileList = [ - { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'file-123', + name: 'test.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] render(<Preparation />) @@ -1560,7 +1796,16 @@ describe('Preparation', () => { describe('Integration', () => { it('should complete full flow: select datasource -> next -> process', async () => { mockDataSourceStoreState.localFileList = [ - { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'file-123', + name: 'test.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] render(<Preparation />) @@ -1579,7 +1824,16 @@ describe('Preparation', () => { it('should complete full flow with back navigation', async () => { mockDataSourceStoreState.localFileList = [ - { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'file-123', + name: 'test.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] mockDataSourceStoreState.onlineDocuments = [{ workspace_id: 'ws-1' }] render(<Preparation />) @@ -1594,7 +1848,10 @@ describe('Preparation', () => { fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - expect(screen.getByTestId('document-processing'))!.toHaveAttribute('data-node-id', 'online-doc-node') + expect(screen.getByTestId('document-processing'))!.toHaveAttribute( + 'data-node-id', + 'online-doc-node', + ) }) }) }) @@ -1610,61 +1867,95 @@ describe('Callback Dependencies', () => { const { rerender } = render(<Preparation />) fireEvent.click(screen.getByTestId('select-local-file')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))!.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + )!.toBeDisabled() mockDataSourceStoreState.localFileList = [ - { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'file-123', + name: 'test.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] rerender(<Preparation />) fireEvent.click(screen.getByTestId('select-local-file')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).not.toBeDisabled() }) it('should update when onlineDocuments changes', () => { const { rerender } = render(<Preparation />) fireEvent.click(screen.getByTestId('select-online-document')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))!.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + )!.toBeDisabled() mockDataSourceStoreState.onlineDocuments = [{ workspace_id: 'ws-1' }] rerender(<Preparation />) fireEvent.click(screen.getByTestId('select-online-document')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).not.toBeDisabled() }) it('should update when websitePages changes', () => { const { rerender } = render(<Preparation />) fireEvent.click(screen.getByTestId('select-website-crawl')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))!.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + )!.toBeDisabled() mockDataSourceStoreState.websitePages = [{ url: 'https://example.com' }] rerender(<Preparation />) fireEvent.click(screen.getByTestId('select-website-crawl')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).not.toBeDisabled() }) it('should update when selectedFileIds changes', () => { const { rerender } = render(<Preparation />) fireEvent.click(screen.getByTestId('select-online-drive')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))!.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + )!.toBeDisabled() mockDataSourceStoreState.selectedFileIds = ['file-1'] rerender(<Preparation />) fireEvent.click(screen.getByTestId('select-online-drive')) - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + expect( + screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }), + ).not.toBeDisabled() }) }) describe('handleProcess Callback Dependencies', () => { it('should use latest store state when processing', async () => { mockDataSourceStoreState.localFileList = [ - { file: { id: 'initial-file', name: 'initial.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'initial-file', + name: 'initial.txt', + type: 'text/plain', + size: 100, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] render(<Preparation />) @@ -1672,17 +1963,28 @@ describe('Callback Dependencies', () => { fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) mockDataSourceStoreState.localFileList = [ - { file: { id: 'updated-file', name: 'updated.txt', type: 'text/plain', size: 200, extension: 'txt', mime_type: 'text/plain' } }, + { + file: { + id: 'updated-file', + name: 'updated.txt', + type: 'text/plain', + size: 200, + extension: 'txt', + mime_type: 'text/plain', + }, + }, ] fireEvent.click(screen.getByTestId('process-btn')) await waitFor(() => { - expect(mockHandleRun).toHaveBeenCalledWith(expect.objectContaining({ - datasource_info_list: expect.arrayContaining([ - expect.objectContaining({ related_id: 'updated-file' }), - ]), - })) + expect(mockHandleRun).toHaveBeenCalledWith( + expect.objectContaining({ + datasource_info_list: expect.arrayContaining([ + expect.objectContaining({ related_id: 'updated-file' }), + ]), + }), + ) }) }) }) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/__tests__/index.spec.tsx index 95f24d3b1055e1..2ca59a2db7eb70 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/__tests__/index.spec.tsx @@ -74,9 +74,7 @@ describe('Actions', () => { it('should handle disabled switching from true to false', () => { const handleNextStep = vi.fn() - const { rerender } = render( - <Actions disabled={true} handleNextStep={handleNextStep} />, - ) + const { rerender } = render(<Actions disabled={true} handleNextStep={handleNextStep} />) expect(screen.getByRole('button')).toBeDisabled() @@ -88,9 +86,7 @@ describe('Actions', () => { it('should handle disabled switching from false to true', () => { const handleNextStep = vi.fn() - const { rerender } = render( - <Actions disabled={false} handleNextStep={handleNextStep} />, - ) + const { rerender } = render(<Actions disabled={false} handleNextStep={handleNextStep} />) expect(screen.getByRole('button')).not.toBeDisabled() @@ -102,9 +98,7 @@ describe('Actions', () => { it('should handle undefined disabled becoming true', () => { const handleNextStep = vi.fn() - const { rerender } = render( - <Actions handleNextStep={handleNextStep} />, - ) + const { rerender } = render(<Actions handleNextStep={handleNextStep} />) expect(screen.getByRole('button')).not.toBeDisabled() @@ -161,8 +155,7 @@ describe('Actions', () => { render(<Actions handleNextStep={handleNextStep} />) const button = screen.getByRole('button') - for (let i = 0; i < 10; i++) - fireEvent.click(button) + for (let i = 0; i < 10; i++) fireEvent.click(button) expect(handleNextStep).toHaveBeenCalledTimes(10) }) @@ -173,9 +166,7 @@ describe('Actions', () => { const handleNextStep1 = vi.fn() const handleNextStep2 = vi.fn() - const { rerender } = render( - <Actions handleNextStep={handleNextStep1} />, - ) + const { rerender } = render(<Actions handleNextStep={handleNextStep1} />) fireEvent.click(screen.getByRole('button')) rerender(<Actions handleNextStep={handleNextStep2} />) @@ -188,9 +179,7 @@ describe('Actions', () => { it('should maintain functionality after rerender with same props', () => { const handleNextStep = vi.fn() - const { rerender } = render( - <Actions handleNextStep={handleNextStep} />, - ) + const { rerender } = render(<Actions handleNextStep={handleNextStep} />) fireEvent.click(screen.getByRole('button')) rerender(<Actions handleNextStep={handleNextStep} />) @@ -204,9 +193,7 @@ describe('Actions', () => { const handleNextStep2 = vi.fn() const handleNextStep3 = vi.fn() - const { rerender } = render( - <Actions handleNextStep={handleNextStep1} />, - ) + const { rerender } = render(<Actions handleNextStep={handleNextStep1} />) fireEvent.click(screen.getByRole('button')) rerender(<Actions handleNextStep={handleNextStep2} />) @@ -225,9 +212,7 @@ describe('Actions', () => { it('should be wrapped with React.memo', () => { const handleNextStep = vi.fn() - const { rerender } = render( - <Actions handleNextStep={handleNextStep} />, - ) + const { rerender } = render(<Actions handleNextStep={handleNextStep} />) rerender(<Actions handleNextStep={handleNextStep} />) @@ -237,9 +222,7 @@ describe('Actions', () => { it('should not break when props remain the same across rerenders', () => { const handleNextStep = vi.fn() - const { rerender } = render( - <Actions disabled={false} handleNextStep={handleNextStep} />, - ) + const { rerender } = render(<Actions disabled={false} handleNextStep={handleNextStep} />) for (let i = 0; i < 5; i++) { rerender(<Actions disabled={false} handleNextStep={handleNextStep} />) @@ -252,9 +235,7 @@ describe('Actions', () => { it('should update correctly when only disabled prop changes', () => { const handleNextStep = vi.fn() - const { rerender } = render( - <Actions disabled={false} handleNextStep={handleNextStep} />, - ) + const { rerender } = render(<Actions disabled={false} handleNextStep={handleNextStep} />) expect(screen.getByRole('button')).not.toBeDisabled() @@ -267,9 +248,7 @@ describe('Actions', () => { const handleNextStep1 = vi.fn() const handleNextStep2 = vi.fn() - const { rerender } = render( - <Actions disabled={false} handleNextStep={handleNextStep1} />, - ) + const { rerender } = render(<Actions disabled={false} handleNextStep={handleNextStep1} />) fireEvent.click(screen.getByRole('button')) expect(handleNextStep1).toHaveBeenCalledTimes(1) @@ -364,9 +343,7 @@ describe('Actions', () => { it('should work in a typical workflow: enable -> click -> disable', () => { const handleNextStep = vi.fn() - const { rerender } = render( - <Actions disabled={false} handleNextStep={handleNextStep} />, - ) + const { rerender } = render(<Actions disabled={false} handleNextStep={handleNextStep} />) expect(screen.getByRole('button')).not.toBeDisabled() fireEvent.click(screen.getByRole('button')) @@ -388,17 +365,13 @@ describe('Actions', () => { it('should maintain consistent rendering across multiple state changes', () => { const handleNextStep = vi.fn() - const { rerender } = render( - <Actions disabled={false} handleNextStep={handleNextStep} />, - ) + const { rerender } = render(<Actions disabled={false} handleNextStep={handleNextStep} />) const states = [true, false, true, false, true] states.forEach((disabled) => { rerender(<Actions disabled={disabled} handleNextStep={handleNextStep} />) - if (disabled) - expect(screen.getByRole('button')).toBeDisabled() - else - expect(screen.getByRole('button')).not.toBeDisabled() + if (disabled) expect(screen.getByRole('button')).toBeDisabled() + else expect(screen.getByRole('button')).not.toBeDisabled() }) expect(screen.getByRole('button')).toBeInTheDocument() diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/index.tsx index 15b7b6d3aeb2ba..862516ffc7feee 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/index.tsx @@ -7,16 +7,13 @@ type ActionsProps = { handleNextStep: () => void } -const Actions = ({ - disabled, - handleNextStep, -}: ActionsProps) => { +const Actions = ({ disabled, handleNextStep }: ActionsProps) => { const { t } = useTranslation() return ( <div className="flex justify-end p-4 pt-2"> <Button disabled={disabled} variant="primary" onClick={handleNextStep}> - <span className="px-0.5">{t($ => $['stepOne.button'], { ns: 'datasetCreation' })}</span> + <span className="px-0.5">{t(($) => $['stepOne.button'], { ns: 'datasetCreation' })}</span> </Button> </div> ) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/index.spec.tsx index 66e4d4702118fa..bce50ef049583f 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/index.spec.tsx @@ -17,25 +17,26 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/workflow/block-icon', () => ({ - default: ({ type, toolIcon }: { type: string, toolIcon: unknown }) => ( + default: ({ type, toolIcon }: { type: string; toolIcon: unknown }) => ( <div data-testid="block-icon" data-type={type} data-tool-icon={JSON.stringify(toolIcon)}> BlockIcon </div> ), })) -const createNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ - title: 'Test Node', - desc: 'Test description', - type: 'data-source', - provider_type: 'local_file', - provider_name: 'Local File', - datasource_name: 'local_file', - plugin_id: 'test-plugin', - datasource_parameters: {}, - datasource_configurations: {}, - ...overrides, -} as unknown as DataSourceNodeType) +const createNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => + ({ + title: 'Test Node', + desc: 'Test description', + type: 'data-source', + provider_type: 'local_file', + provider_name: 'Local File', + datasource_name: 'local_file', + plugin_id: 'test-plugin', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, + }) as unknown as DataSourceNodeType const createDataSourceOption = (overrides?: Partial<DataSourceOption>): DataSourceOption => ({ label: 'Test Option', @@ -54,12 +55,7 @@ describe('OptionCard', () => { const nodeData = createNodeData() render( - <OptionCard - label="Test Label" - value="test-value" - selected={false} - nodeData={nodeData} - />, + <OptionCard label="Test Label" value="test-value" selected={false} nodeData={nodeData} />, ) expect(screen.getByText('Test Label'))!.toBeInTheDocument() @@ -69,12 +65,7 @@ describe('OptionCard', () => { const nodeData = createNodeData() render( - <OptionCard - label="My Data Source" - value="my-ds" - selected={false} - nodeData={nodeData} - />, + <OptionCard label="My Data Source" value="my-ds" selected={false} nodeData={nodeData} />, ) expect(screen.getByText('My Data Source'))!.toBeInTheDocument() @@ -83,14 +74,7 @@ describe('OptionCard', () => { it('should render BlockIcon component', () => { const nodeData = createNodeData() - render( - <OptionCard - label="Test" - value="test" - selected={false} - nodeData={nodeData} - />, - ) + render(<OptionCard label="Test" value="test" selected={false} nodeData={nodeData} />) expect(screen.getByTestId('block-icon'))!.toBeInTheDocument() }) @@ -98,14 +82,7 @@ describe('OptionCard', () => { it('should pass correct type to BlockIcon', () => { const nodeData = createNodeData() - render( - <OptionCard - label="Test" - value="test" - selected={false} - nodeData={nodeData} - />, - ) + render(<OptionCard label="Test" value="test" selected={false} nodeData={nodeData} />) const blockIcon = screen.getByTestId('block-icon') expect(blockIcon)!.toHaveAttribute('data-type', 'datasource') @@ -115,12 +92,7 @@ describe('OptionCard', () => { const nodeData = createNodeData() render( - <OptionCard - label="Long Label Text" - value="test" - selected={false} - nodeData={nodeData} - />, + <OptionCard label="Long Label Text" value="test" selected={false} nodeData={nodeData} />, ) expect(screen.getByTitle('Long Label Text'))!.toBeInTheDocument() @@ -132,12 +104,7 @@ describe('OptionCard', () => { const nodeData = createNodeData() const { container } = render( - <OptionCard - label="Test" - value="test" - selected={true} - nodeData={nodeData} - />, + <OptionCard label="Test" value="test" selected={true} nodeData={nodeData} />, ) const card = container.firstChild as HTMLElement @@ -149,12 +116,7 @@ describe('OptionCard', () => { const nodeData = createNodeData() const { container } = render( - <OptionCard - label="Test" - value="test" - selected={false} - nodeData={nodeData} - />, + <OptionCard label="Test" value="test" selected={false} nodeData={nodeData} />, ) const card = container.firstChild as HTMLElement @@ -164,14 +126,7 @@ describe('OptionCard', () => { it('should apply text-text-primary to label when selected', () => { const nodeData = createNodeData() - render( - <OptionCard - label="Test Label" - value="test" - selected={true} - nodeData={nodeData} - />, - ) + render(<OptionCard label="Test Label" value="test" selected={true} nodeData={nodeData} />) const label = screen.getByText('Test Label') expect(label.className).toContain('text-text-primary') @@ -180,14 +135,7 @@ describe('OptionCard', () => { it('should apply text-text-secondary to label when not selected', () => { const nodeData = createNodeData() - render( - <OptionCard - label="Test Label" - value="test" - selected={false} - nodeData={nodeData} - />, - ) + render(<OptionCard label="Test Label" value="test" selected={false} nodeData={nodeData} />) const label = screen.getByText('Test Label') expect(label.className).toContain('text-text-secondary') @@ -217,12 +165,7 @@ describe('OptionCard', () => { }) render( - <OptionCard - label="Website Crawler" - value="website" - selected={false} - nodeData={nodeData} - />, + <OptionCard label="Website Crawler" value="website" selected={false} nodeData={nodeData} />, ) expect(screen.getByText('Website Crawler'))!.toBeInTheDocument() @@ -306,13 +249,7 @@ describe('OptionCard', () => { const nodeData = createNodeData() const { container } = render( - <OptionCard - label="Test" - value="" - selected={false} - nodeData={nodeData} - onClick={onClick} - />, + <OptionCard label="Test" value="" selected={false} nodeData={nodeData} onClick={onClick} />, ) fireEvent.click(container.firstChild as HTMLElement) @@ -446,25 +383,13 @@ describe('OptionCard', () => { const nodeData = createNodeData() const { rerender, container } = render( - <OptionCard - label="Test" - value="test" - selected={false} - nodeData={nodeData} - />, + <OptionCard label="Test" value="test" selected={false} nodeData={nodeData} />, ) let card = container.firstChild as HTMLElement expect(card.className).not.toContain('border-components-option-card-option-selected-border') - rerender( - <OptionCard - label="Test" - value="test" - selected={true} - nodeData={nodeData} - />, - ) + rerender(<OptionCard label="Test" value="test" selected={true} nodeData={nodeData} />) card = container.firstChild as HTMLElement expect(card.className).toContain('border-components-option-card-option-selected-border') @@ -475,14 +400,7 @@ describe('OptionCard', () => { it('should handle empty label', () => { const nodeData = createNodeData() - render( - <OptionCard - label="" - value="test" - selected={false} - nodeData={nodeData} - />, - ) + render(<OptionCard label="" value="test" selected={false} nodeData={nodeData} />) expect(screen.getByTestId('block-icon'))!.toBeInTheDocument() }) @@ -491,14 +409,7 @@ describe('OptionCard', () => { const nodeData = createNodeData() const longLabel = 'A'.repeat(200) - render( - <OptionCard - label={longLabel} - value="test" - selected={false} - nodeData={nodeData} - />, - ) + render(<OptionCard label={longLabel} value="test" selected={false} nodeData={nodeData} />) expect(screen.getByText(longLabel))!.toBeInTheDocument() expect(screen.getByTitle(longLabel))!.toBeInTheDocument() @@ -508,14 +419,7 @@ describe('OptionCard', () => { const nodeData = createNodeData() const specialLabel = '<Test> & \'Label\' "Special"' - render( - <OptionCard - label={specialLabel} - value="test" - selected={false} - nodeData={nodeData} - />, - ) + render(<OptionCard label={specialLabel} value="test" selected={false} nodeData={nodeData} />) expect(screen.getByText(specialLabel))!.toBeInTheDocument() }) @@ -540,13 +444,7 @@ describe('OptionCard', () => { const nodeData = createNodeData() const { container } = render( - <OptionCard - label="Test" - value="" - selected={false} - nodeData={nodeData} - onClick={onClick} - />, + <OptionCard label="Test" value="" selected={false} nodeData={nodeData} onClick={onClick} />, ) fireEvent.click(container.firstChild as HTMLElement) @@ -576,12 +474,7 @@ describe('OptionCard', () => { const minimalNodeData = { title: 'Minimal' } as unknown as DataSourceNodeType render( - <OptionCard - label="Minimal" - value="test" - selected={false} - nodeData={minimalNodeData} - />, + <OptionCard label="Minimal" value="test" selected={false} nodeData={minimalNodeData} />, ) expect(screen.getByText('Minimal'))!.toBeInTheDocument() @@ -593,12 +486,7 @@ describe('OptionCard', () => { const nodeData = createNodeData() const { container } = render( - <OptionCard - label="Test" - value="test" - selected={false} - nodeData={nodeData} - />, + <OptionCard label="Test" value="test" selected={false} nodeData={nodeData} />, ) const card = container.firstChild as HTMLElement @@ -609,14 +497,7 @@ describe('OptionCard', () => { const nodeData = createNodeData() const label = 'This is a very long label that might get truncated' - render( - <OptionCard - label={label} - value="test" - selected={false} - nodeData={nodeData} - />, - ) + render(<OptionCard label={label} value="test" selected={false} nodeData={nodeData} />) expect(screen.getByTitle(label))!.toBeInTheDocument() }) @@ -633,12 +514,7 @@ describe('DataSourceOptions', () => { it('should render container without crashing', () => { mockDatasourceOptions = [] - const { container } = render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={vi.fn()} - />, - ) + const { container } = render(<DataSourceOptions dataSourceNodeId="" onSelect={vi.fn()} />) expect(container.querySelector('.grid'))!.toBeInTheDocument() }) @@ -650,12 +526,7 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Option 3', value: 'opt-3' }), ] - render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={vi.fn()} - />, - ) + render(<DataSourceOptions dataSourceNodeId="" onSelect={vi.fn()} />) expect(screen.getByText('Option 1'))!.toBeInTheDocument() expect(screen.getByText('Option 2'))!.toBeInTheDocument() @@ -665,12 +536,7 @@ describe('DataSourceOptions', () => { it('should render empty grid when no options', () => { mockDatasourceOptions = [] - const { container } = render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={vi.fn()} - />, - ) + const { container } = render(<DataSourceOptions dataSourceNodeId="" onSelect={vi.fn()} />) const grid = container.querySelector('.grid') expect(grid)!.toBeInTheDocument() @@ -680,12 +546,7 @@ describe('DataSourceOptions', () => { it('should apply correct grid layout classes', () => { mockDatasourceOptions = [createDataSourceOption()] - const { container } = render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={vi.fn()} - />, - ) + const { container } = render(<DataSourceOptions dataSourceNodeId="" onSelect={vi.fn()} />) const grid = container.querySelector('.grid') expect(grid?.className).toContain('grid-cols-4') @@ -699,12 +560,7 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'B', value: 'b' }), ] - const { container } = render( - <DataSourceOptions - dataSourceNodeId="a" - onSelect={vi.fn()} - />, - ) + const { container } = render(<DataSourceOptions dataSourceNodeId="a" onSelect={vi.fn()} />) const cards = container.querySelectorAll('.flex.cursor-pointer') expect(cards.length).toBe(2) @@ -719,15 +575,14 @@ describe('DataSourceOptions', () => { ] const { container } = render( - <DataSourceOptions - dataSourceNodeId="opt-1" - onSelect={vi.fn()} - />, + <DataSourceOptions dataSourceNodeId="opt-1" onSelect={vi.fn()} />, ) const cards = container.querySelectorAll('.flex.cursor-pointer') expect(cards[0]!.className).toContain('border-components-option-card-option-selected-border') - expect(cards[1]!.className).not.toContain('border-components-option-card-option-selected-border') + expect(cards[1]!.className).not.toContain( + 'border-components-option-card-option-selected-border', + ) }) it('should mark second option as selected when matching', () => { @@ -737,14 +592,13 @@ describe('DataSourceOptions', () => { ] const { container } = render( - <DataSourceOptions - dataSourceNodeId="opt-2" - onSelect={vi.fn()} - />, + <DataSourceOptions dataSourceNodeId="opt-2" onSelect={vi.fn()} />, ) const cards = container.querySelectorAll('.flex.cursor-pointer') - expect(cards[0]!.className).not.toContain('border-components-option-card-option-selected-border') + expect(cards[0]!.className).not.toContain( + 'border-components-option-card-option-selected-border', + ) expect(cards[1]!.className).toContain('border-components-option-card-option-selected-border') }) @@ -755,10 +609,7 @@ describe('DataSourceOptions', () => { ] const { container } = render( - <DataSourceOptions - dataSourceNodeId="non-existent" - onSelect={vi.fn()} - />, + <DataSourceOptions dataSourceNodeId="non-existent" onSelect={vi.fn()} />, ) const cards = container.querySelectorAll('.flex.cursor-pointer') @@ -770,12 +621,7 @@ describe('DataSourceOptions', () => { it('should handle empty dataSourceNodeId', () => { mockDatasourceOptions = [createDataSourceOption()] - const { container } = render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={vi.fn()} - />, - ) + const { container } = render(<DataSourceOptions dataSourceNodeId="" onSelect={vi.fn()} />) expect(container.querySelector('.grid'))!.toBeInTheDocument() }) @@ -793,12 +639,7 @@ describe('DataSourceOptions', () => { }), ] - render( - <DataSourceOptions - dataSourceNodeId="test-id" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="test-id" onSelect={onSelect} />) fireEvent.click(screen.getByText('Test Option')) expect(onSelect).toHaveBeenCalledTimes(1) @@ -817,12 +658,7 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Option 2', value: 'id-2', data: data2 }), ] - render( - <DataSourceOptions - dataSourceNodeId="id-1" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="id-1" onSelect={onSelect} />) fireEvent.click(screen.getByText('Option 1')) fireEvent.click(screen.getByText('Option 2')) @@ -835,12 +671,7 @@ describe('DataSourceOptions', () => { const onSelect = vi.fn() mockDatasourceOptions = [] - render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) expect(onSelect).not.toHaveBeenCalled() }) @@ -852,12 +683,7 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Option', value: 'opt-id', data: optionData }), ] - render( - <DataSourceOptions - dataSourceNodeId="opt-id" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="opt-id" onSelect={onSelect} />) fireEvent.click(screen.getByText('Option')) fireEvent.click(screen.getByText('Option')) fireEvent.click(screen.getByText('Option')) @@ -875,12 +701,7 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Second', value: 'second-id' }), ] - render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) await waitFor(() => { expect(onSelect).toHaveBeenCalledWith({ @@ -897,12 +718,7 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Second', value: 'second-id' }), ] - render( - <DataSourceOptions - dataSourceNodeId="second-id" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="second-id" onSelect={onSelect} />) await waitFor(() => { expect(onSelect).not.toHaveBeenCalled() @@ -913,12 +729,7 @@ describe('DataSourceOptions', () => { const onSelect = vi.fn() mockDatasourceOptions = [] - render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) await waitFor(() => { expect(onSelect).not.toHaveBeenCalled() @@ -927,29 +738,12 @@ describe('DataSourceOptions', () => { it('should run effect only once on mount', async () => { const onSelect = vi.fn() - mockDatasourceOptions = [ - createDataSourceOption({ label: 'First', value: 'first-id' }), - ] + mockDatasourceOptions = [createDataSourceOption({ label: 'First', value: 'first-id' })] - const { rerender } = render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + const { rerender } = render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) - rerender( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) - rerender( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + rerender(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) + rerender(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) await waitFor(() => { expect(onSelect).toHaveBeenCalledTimes(1) @@ -959,42 +753,25 @@ describe('DataSourceOptions', () => { it('should not re-run effect on rerender with different props', async () => { const onSelect1 = vi.fn() const onSelect2 = vi.fn() - mockDatasourceOptions = [ - createDataSourceOption({ label: 'First', value: 'first-id' }), - ] + mockDatasourceOptions = [createDataSourceOption({ label: 'First', value: 'first-id' })] - const { rerender } = render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect1} - />, - ) + const { rerender } = render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect1} />) await waitFor(() => { expect(onSelect1).toHaveBeenCalledTimes(1) }) - rerender( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect2} - />, - ) + rerender(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect2} />) expect(onSelect2).not.toHaveBeenCalled() }) it('should handle unmount cleanly', () => { const onSelect = vi.fn() - mockDatasourceOptions = [ - createDataSourceOption({ label: 'Test', value: 'test-id' }), - ] + mockDatasourceOptions = [createDataSourceOption({ label: 'Test', value: 'test-id' })] const { unmount } = render( - <DataSourceOptions - dataSourceNodeId="test-id" - onSelect={onSelect} - />, + <DataSourceOptions dataSourceNodeId="test-id" onSelect={onSelect} />, ) expect(() => unmount()).not.toThrow() @@ -1009,20 +786,10 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Option', value: 'opt-id', data: optionData }), ] - const { rerender } = render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + const { rerender } = render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) fireEvent.click(screen.getByText('Option')) - rerender( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + rerender(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) fireEvent.click(screen.getByText('Option')) expect(onSelect).toHaveBeenCalledTimes(3) // 1 auto-select + 2 clicks @@ -1031,24 +798,14 @@ describe('DataSourceOptions', () => { it('should update handleSelect when onSelect prop changes', () => { const onSelect1 = vi.fn() const onSelect2 = vi.fn() - mockDatasourceOptions = [ - createDataSourceOption({ label: 'Option', value: 'opt-id' }), - ] + mockDatasourceOptions = [createDataSourceOption({ label: 'Option', value: 'opt-id' })] const { rerender } = render( - <DataSourceOptions - dataSourceNodeId="opt-id" - onSelect={onSelect1} - />, + <DataSourceOptions dataSourceNodeId="opt-id" onSelect={onSelect1} />, ) fireEvent.click(screen.getByText('Option')) - rerender( - <DataSourceOptions - dataSourceNodeId="opt-id" - onSelect={onSelect2} - />, - ) + rerender(<DataSourceOptions dataSourceNodeId="opt-id" onSelect={onSelect2} />) fireEvent.click(screen.getByText('Option')) expect(onSelect1).toHaveBeenCalledTimes(1) @@ -1064,22 +821,14 @@ describe('DataSourceOptions', () => { ] const { rerender } = render( - <DataSourceOptions - dataSourceNodeId="opt-id" - onSelect={onSelect} - />, + <DataSourceOptions dataSourceNodeId="opt-id" onSelect={onSelect} />, ) fireEvent.click(screen.getByText('Option')) mockDatasourceOptions = [ createDataSourceOption({ label: 'Option', value: 'opt-id', data: data2 }), ] - rerender( - <DataSourceOptions - dataSourceNodeId="opt-id" - onSelect={onSelect} - />, - ) + rerender(<DataSourceOptions dataSourceNodeId="opt-id" onSelect={onSelect} />) fireEvent.click(screen.getByText('Option')) expect(onSelect).toHaveBeenNthCalledWith(1, { nodeId: 'opt-id', nodeData: data1 }) @@ -1090,31 +839,20 @@ describe('DataSourceOptions', () => { describe('Edge Cases', () => { it('should handle single option', () => { const onSelect = vi.fn() - mockDatasourceOptions = [ - createDataSourceOption({ label: 'Only Option', value: 'only-id' }), - ] + mockDatasourceOptions = [createDataSourceOption({ label: 'Only Option', value: 'only-id' })] - render( - <DataSourceOptions - dataSourceNodeId="only-id" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="only-id" onSelect={onSelect} />) expect(screen.getByText('Only Option'))!.toBeInTheDocument() }) it('should handle many options', () => { mockDatasourceOptions = Array.from({ length: 20 }, (_, i) => - createDataSourceOption({ label: `Option ${i}`, value: `opt-${i}` })) - - render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={vi.fn()} - />, + createDataSourceOption({ label: `Option ${i}`, value: `opt-${i}` }), ) + render(<DataSourceOptions dataSourceNodeId="" onSelect={vi.fn()} />) + expect(screen.getByText('Option 0'))!.toBeInTheDocument() expect(screen.getByText('Option 19'))!.toBeInTheDocument() }) @@ -1128,12 +866,7 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Same Label', value: 'id-2', data: data2 }), ] - render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) const labels = screen.getAllByText('Same Label') fireEvent.click(labels[1]!) // Click second one @@ -1151,12 +884,7 @@ describe('DataSourceOptions', () => { }), ] - render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) fireEvent.click(screen.getByText('Special')) expect(onSelect).toHaveBeenCalledWith({ @@ -1169,12 +897,7 @@ describe('DataSourceOptions', () => { const onSelect = vi.fn() mockDatasourceOptions = [] - const { container } = render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + const { container } = render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) expect(container.querySelector('.grid'))!.toBeInTheDocument() }) @@ -1186,12 +909,7 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Empty Value', value: '', data: emptyValueData }), ] - render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) fireEvent.click(screen.getByText('Empty Value')) expect(onSelect).toHaveBeenCalledWith({ @@ -1201,15 +919,10 @@ describe('DataSourceOptions', () => { }) it('should handle options with whitespace-only labels', () => { - mockDatasourceOptions = [ - createDataSourceOption({ label: ' ', value: 'whitespace' }), - ] + mockDatasourceOptions = [createDataSourceOption({ label: ' ', value: 'whitespace' })] const { container } = render( - <DataSourceOptions - dataSourceNodeId="whitespace" - onSelect={vi.fn()} - />, + <DataSourceOptions dataSourceNodeId="whitespace" onSelect={vi.fn()} />, ) const cards = container.querySelectorAll('.flex.cursor-pointer') @@ -1225,12 +938,7 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Weird', value: 'weird-id', data: weirdNodeData }), ] - render( - <DataSourceOptions - dataSourceNodeId="weird-id" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="weird-id" onSelect={onSelect} />) fireEvent.click(screen.getByText('Weird')) expect(onSelect).toHaveBeenCalledWith({ @@ -1257,12 +965,7 @@ describe('DataSourceOptions Integration', () => { createDataSourceOption({ label: 'Option 2', value: 'id-2', data: data2 }), ] - render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) await waitFor(() => { expect(onSelect).toHaveBeenCalledWith({ nodeId: 'id-1', nodeData: data1 }) @@ -1282,25 +985,19 @@ describe('DataSourceOptions Integration', () => { ] const { rerender, container } = render( - <DataSourceOptions - dataSourceNodeId="b" - onSelect={onSelect} - />, + <DataSourceOptions dataSourceNodeId="b" onSelect={onSelect} />, ) let cards = container.querySelectorAll('.flex.cursor-pointer') expect(cards[1]!.className).toContain('border-components-option-card-option-selected-border') - rerender( - <DataSourceOptions - dataSourceNodeId="c" - onSelect={onSelect} - />, - ) + rerender(<DataSourceOptions dataSourceNodeId="c" onSelect={onSelect} />) cards = container.querySelectorAll('.flex.cursor-pointer') expect(cards[2]!.className).toContain('border-components-option-card-option-selected-border') - expect(cards[1]!.className).not.toContain('border-components-option-card-option-selected-border') + expect(cards[1]!.className).not.toContain( + 'border-components-option-card-option-selected-border', + ) }) it('should handle rapid option switching', async () => { @@ -1311,12 +1008,7 @@ describe('DataSourceOptions Integration', () => { createDataSourceOption({ label: 'C', value: 'c' }), ] - render( - <DataSourceOptions - dataSourceNodeId="a" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="a" onSelect={onSelect} />) fireEvent.click(screen.getByText('B')) fireEvent.click(screen.getByText('C')) @@ -1338,10 +1030,7 @@ describe('DataSourceOptions Integration', () => { ] const { container } = render( - <DataSourceOptions - dataSourceNodeId="test-value" - onSelect={vi.fn()} - />, + <DataSourceOptions dataSourceNodeId="test-value" onSelect={vi.fn()} />, ) expect(screen.getByText('Test Label'))!.toBeInTheDocument() @@ -1357,12 +1046,7 @@ describe('DataSourceOptions Integration', () => { createDataSourceOption({ label: 'Click Me', value: 'click-id', data: nodeData }), ] - render( - <DataSourceOptions - dataSourceNodeId="click-id" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="click-id" onSelect={onSelect} />) fireEvent.click(screen.getByText('Click Me')) expect(onSelect).toHaveBeenCalledWith({ @@ -1381,24 +1065,18 @@ describe('DataSourceOptions Integration', () => { ] const { rerender, container } = render( - <DataSourceOptions - dataSourceNodeId="a" - onSelect={onSelect} - />, + <DataSourceOptions dataSourceNodeId="a" onSelect={onSelect} />, ) for (let i = 0; i < 5; i++) { - rerender( - <DataSourceOptions - dataSourceNodeId="a" - onSelect={onSelect} - />, - ) + rerender(<DataSourceOptions dataSourceNodeId="a" onSelect={onSelect} />) } const cards = container.querySelectorAll('.flex.cursor-pointer') expect(cards[0]!.className).toContain('border-components-option-card-option-selected-border') - expect(cards[1]!.className).not.toContain('border-components-option-card-option-selected-border') + expect(cards[1]!.className).not.toContain( + 'border-components-option-card-option-selected-border', + ) }) it('should handle options array reference change with same content', () => { @@ -1409,23 +1087,13 @@ describe('DataSourceOptions Integration', () => { createDataSourceOption({ label: 'Option', value: 'opt', data: nodeData }), ] - const { rerender } = render( - <DataSourceOptions - dataSourceNodeId="opt" - onSelect={onSelect} - />, - ) + const { rerender } = render(<DataSourceOptions dataSourceNodeId="opt" onSelect={onSelect} />) mockDatasourceOptions = [ createDataSourceOption({ label: 'Option', value: 'opt', data: nodeData }), ] - rerender( - <DataSourceOptions - dataSourceNodeId="opt" - onSelect={onSelect} - />, - ) + rerender(<DataSourceOptions dataSourceNodeId="opt" onSelect={onSelect} />) fireEvent.click(screen.getByText('Option')) @@ -1451,12 +1119,7 @@ describe('handleSelect Early Return Coverage', () => { ] mockDatasourceOptions = originalOptions - const { rerender } = render( - <DataSourceOptions - dataSourceNodeId="a" - onSelect={onSelect} - />, - ) + const { rerender } = render(<DataSourceOptions dataSourceNodeId="a" onSelect={onSelect} />) const newOptions = [ createDataSourceOption({ label: 'Option A', value: 'x' }), // Changed from 'a' to 'x' @@ -1464,12 +1127,7 @@ describe('handleSelect Early Return Coverage', () => { ] mockDatasourceOptions = newOptions - rerender( - <DataSourceOptions - dataSourceNodeId="a" - onSelect={onSelect} - />, - ) + rerender(<DataSourceOptions dataSourceNodeId="a" onSelect={onSelect} />) fireEvent.click(screen.getByText('Option A')) @@ -1483,12 +1141,7 @@ describe('handleSelect Early Return Coverage', () => { const onSelect = vi.fn() mockDatasourceOptions = [] - const { container } = render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + const { container } = render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) expect(container.querySelector('.grid'))!.toBeInTheDocument() expect(onSelect).not.toHaveBeenCalled() @@ -1505,12 +1158,7 @@ describe('handleSelect Early Return Coverage', () => { }), ] - render( - <DataSourceOptions - dataSourceNodeId="" - onSelect={onSelect} - />, - ) + render(<DataSourceOptions dataSourceNodeId="" onSelect={onSelect} />) await waitFor(() => { expect(onSelect).toHaveBeenCalledWith({ diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/index.tsx index 82194e2e8df741..e2c32502d29c51 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/index.tsx @@ -8,31 +8,29 @@ type DataSourceOptionsProps = { onSelect: (option: Datasource) => void } -const DataSourceOptions = ({ - dataSourceNodeId, - onSelect, -}: DataSourceOptionsProps) => { +const DataSourceOptions = ({ dataSourceNodeId, onSelect }: DataSourceOptionsProps) => { const options = useDatasourceOptions() - const handelSelect = useCallback((value: string) => { - const selectedOption = options.find(option => option.value === value) - if (!selectedOption) - return - const datasource = { - nodeId: selectedOption.value, - nodeData: selectedOption.data, - } - onSelect(datasource) - }, [onSelect, options]) + const handelSelect = useCallback( + (value: string) => { + const selectedOption = options.find((option) => option.value === value) + if (!selectedOption) return + const datasource = { + nodeId: selectedOption.value, + nodeData: selectedOption.data, + } + onSelect(datasource) + }, + [onSelect, options], + ) useEffect(() => { - if (options.length > 0 && !dataSourceNodeId) - handelSelect(options[0]!.value) + if (options.length > 0 && !dataSourceNodeId) handelSelect(options[0]!.value) }, []) return ( <div className="grid w-full grid-cols-4 gap-1"> - {options.map(option => ( + {options.map((option) => ( <OptionCard key={option.value} label={option.label} diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/option-card.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/option-card.tsx index d553f782f06b74..a15f5d05b3b31b 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/option-card.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/option-card.tsx @@ -14,13 +14,7 @@ type OptionCardProps = { onClick?: (value: string) => void } -const OptionCard = ({ - label, - value, - selected, - nodeData, - onClick, -}: OptionCardProps) => { +const OptionCard = ({ label, value, selected, nodeData, onClick }: OptionCardProps) => { const toolIcon = useToolIcon(nodeData) const handleClickCard = useCallback(() => { @@ -38,13 +32,13 @@ const OptionCard = ({ onClick={handleClickCard} > <div className="flex size-7 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-panel-border bg-background-default-dodge p-1"> - <BlockIcon - type={BlockEnum.DataSource} - toolIcon={toolIcon} - /> + <BlockIcon type={BlockEnum.DataSource} toolIcon={toolIcon} /> </div> <div - className={cn('line-clamp-2 grow system-sm-medium text-text-secondary', selected && 'text-text-primary')} + className={cn( + 'line-clamp-2 grow system-sm-medium text-text-secondary', + selected && 'text-text-primary', + )} title={label} > {label} diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx index 4389aa1c8c8799..657fad0357ba70 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx @@ -7,9 +7,12 @@ import Actions from '../actions' let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus } } | undefined vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { workflowRunningData: typeof mockWorkflowRunningData }) => unknown) => selector({ - workflowRunningData: mockWorkflowRunningData, - }), + useStore: ( + selector: (state: { workflowRunningData: typeof mockWorkflowRunningData }) => unknown, + ) => + selector({ + workflowRunningData: mockWorkflowRunningData, + }), })) const createFormParams = (overrides: Partial<CustomActionsProps> = {}): CustomActionsProps => ({ @@ -33,7 +36,9 @@ describe('Document processing actions', () => { render(<Actions formParams={formParams} onBack={onBack} />) - fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.backToDataSource' })) + fireEvent.click( + screen.getByRole('button', { name: 'datasetPipeline.operations.backToDataSource' }), + ) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.process' })) expect(onBack).toHaveBeenCalledTimes(1) @@ -42,27 +47,22 @@ describe('Document processing actions', () => { it('should disable processing when runDisabled or the workflow is already running', () => { const { rerender } = render( - <Actions - formParams={createFormParams()} - onBack={vi.fn()} - runDisabled - />, + <Actions formParams={createFormParams()} onBack={vi.fn()} runDisabled />, ) - expect(screen.getByRole('button', { name: 'datasetPipeline.operations.process' })).toBeDisabled() + expect( + screen.getByRole('button', { name: 'datasetPipeline.operations.process' }), + ).toBeDisabled() mockWorkflowRunningData = { result: { status: WorkflowRunningStatus.Running, }, } - rerender( - <Actions - formParams={createFormParams()} - onBack={vi.fn()} - />, - ) + rerender(<Actions formParams={createFormParams()} onBack={vi.fn()} />) - expectLoadingButton(screen.getByRole('button', { name: /datasetPipeline\.operations\.process/i })) + expectLoadingButton( + screen.getByRole('button', { name: /datasetPipeline\.operations\.process/i }), + ) }) }) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts index 822d55373216be..61e68cd1f90e3e 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts @@ -1,13 +1,16 @@ import { renderHook } from '@testing-library/react' import { useInputVariables } from '../hooks' -const mockUseDraftPipelineProcessingParams = vi.hoisted(() => vi.fn(() => ({ - data: { variables: [{ variable: 'chunkSize' }] }, - isFetching: true, -}))) +const mockUseDraftPipelineProcessingParams = vi.hoisted(() => + vi.fn(() => ({ + data: { variables: [{ variable: 'chunkSize' }] }, + isFetching: true, + })), +) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }), + useStore: (selector: (state: { pipelineId: string }) => string) => + selector({ pipelineId: 'pipeline-1' }), })) vi.mock('@/service/use-pipeline', () => ({ diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx index e0ca1b2cc4c339..68dc21066be4bc 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx @@ -1,7 +1,11 @@ import type { ZodSchema } from 'zod' import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions' import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types' -import type { PipelineProcessingParamsResponse, RAGPipelineVariable, RAGPipelineVariables } from '@/models/pipeline' +import type { + PipelineProcessingParamsResponse, + RAGPipelineVariable, + RAGPipelineVariables, +} from '@/models/pipeline' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' @@ -70,7 +74,10 @@ const mockFormStore = { } vi.mock('@/app/components/base/form', () => ({ - useAppForm: ({ onSubmit, validators }: { + useAppForm: ({ + onSubmit, + validators, + }: { onSubmit: (params: { value: Record<string, unknown> }) => void validators?: { onSubmit?: (params: { value: Record<string, unknown> }) => string | undefined @@ -86,8 +93,14 @@ vi.mock('@/app/components/base/form', () => ({ mockHandleSubmit() }, store: mockFormStore, - AppForm: ({ children }: { children: React.ReactNode }) => <div data-testid="app-form">{children}</div>, - Actions: ({ CustomActions }: { CustomActions: (props: CustomActionsProps) => React.ReactNode }) => ( + AppForm: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="app-form">{children}</div> + ), + Actions: ({ + CustomActions, + }: { + CustomActions: (props: CustomActionsProps) => React.ReactNode + }) => ( <div data-testid="form-actions"> {CustomActions({ form: { @@ -104,7 +117,7 @@ vi.mock('@/app/components/base/form', () => ({ })) vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({ - default: ({ config }: { initialData: Record<string, unknown>, config: BaseConfiguration }) => { + default: ({ config }: { initialData: Record<string, unknown>; config: BaseConfiguration }) => { return () => ( <div data-testid={`field-${config.variable}`}> <span data-testid={`field-label-${config.variable}`}>{config.label}</span> @@ -115,7 +128,9 @@ vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({ }, })) -const createRAGPipelineVariable = (overrides?: Partial<RAGPipelineVariable>): RAGPipelineVariable => ({ +const createRAGPipelineVariable = ( + overrides?: Partial<RAGPipelineVariable>, +): RAGPipelineVariable => ({ belong_to_node_id: 'test-node', type: PipelineInputVarType.textInput, label: 'Test Label', @@ -145,9 +160,10 @@ const createBaseConfiguration = (overrides?: Partial<BaseConfiguration>): BaseCo ...overrides, }) -const createMockSchema = (): ZodSchema => ({ - safeParse: vi.fn().mockReturnValue({ success: true }), -}) as unknown as ZodSchema +const createMockSchema = (): ZodSchema => + ({ + safeParse: vi.fn().mockReturnValue({ success: true }), + }) as unknown as ZodSchema const setupMocks = (options?: { pipelineId?: string | null @@ -166,21 +182,18 @@ const setupMocks = (options?: { mockGenerateZodSchema.mockReturnValue(createMockSchema()) } -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, }, - }, -}) + }) const renderWithQueryClient = (component: React.ReactElement) => { const queryClient = createQueryClient() - return render( - <QueryClientProvider client={queryClient}> - {component} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>) } describe('DocumentProcessing', () => { @@ -265,10 +278,7 @@ describe('DocumentProcessing', () => { setupMocks() renderWithQueryClient( - <DocumentProcessing - {...defaultProps} - dataSourceNodeId={customNodeId} - />, + <DocumentProcessing {...defaultProps} dataSourceNodeId={customNodeId} />, ) expect(screen.getByTestId('form-actions')).toBeInTheDocument() @@ -279,10 +289,7 @@ describe('DocumentProcessing', () => { setupMocks({ configurations: [] }) const { container } = renderWithQueryClient( - <DocumentProcessing - {...defaultProps} - onProcess={mockOnProcess} - />, + <DocumentProcessing {...defaultProps} onProcess={mockOnProcess} />, ) expect(container.querySelector('form')).toBeInTheDocument() @@ -292,12 +299,7 @@ describe('DocumentProcessing', () => { const mockOnBack = vi.fn() setupMocks() - renderWithQueryClient( - <DocumentProcessing - {...defaultProps} - onBack={mockOnBack} - />, - ) + renderWithQueryClient(<DocumentProcessing {...defaultProps} onBack={mockOnBack} />) expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) @@ -354,12 +356,7 @@ describe('DocumentProcessing', () => { const mockOnBack = vi.fn() setupMocks() - renderWithQueryClient( - <DocumentProcessing - {...defaultProps} - onBack={mockOnBack} - />, - ) + renderWithQueryClient(<DocumentProcessing {...defaultProps} onBack={mockOnBack} />) const backButton = screen.getByText('datasetPipeline.operations.backToDataSource') fireEvent.click(backButton) @@ -370,12 +367,7 @@ describe('DocumentProcessing', () => { const mockOnProcess = vi.fn() setupMocks() - renderWithQueryClient( - <DocumentProcessing - {...defaultProps} - onProcess={mockOnProcess} - />, - ) + renderWithQueryClient(<DocumentProcessing {...defaultProps} onProcess={mockOnProcess} />) const processButton = screen.getByText('datasetPipeline.operations.process') fireEvent.click(processButton) @@ -448,9 +440,11 @@ describe('DocumentProcessing', () => { it('should handle large number of variables', () => { const variables = Array.from({ length: 50 }, (_, i) => - createRAGPipelineVariable({ variable: `var_${i}` })) + createRAGPipelineVariable({ variable: `var_${i}` }), + ) const configurations = Array.from({ length: 50 }, (_, i) => - createBaseConfiguration({ variable: `var_${i}`, label: `Field ${i}` })) + createBaseConfiguration({ variable: `var_${i}`, label: `Field ${i}` }), + ) setupMocks({ paramsConfig: { variables }, configurations, @@ -465,10 +459,7 @@ describe('DocumentProcessing', () => { setupMocks() renderWithQueryClient( - <DocumentProcessing - {...defaultProps} - dataSourceNodeId="node-with-special_chars.123" - />, + <DocumentProcessing {...defaultProps} dataSourceNodeId="node-with-special_chars.123" />, ) expect(screen.getByTestId('form-actions')).toBeInTheDocument() @@ -496,12 +487,16 @@ describe('DocumentProcessing', () => { }) }) -const createMockFormParams = (overrides?: Partial<{ - handleSubmit: ReturnType<typeof vi.fn> - isSubmitting: boolean - canSubmit: boolean -}>): CustomActionsProps => ({ - form: { handleSubmit: overrides?.handleSubmit ?? vi.fn() } as unknown as CustomActionsProps['form'], +const createMockFormParams = ( + overrides?: Partial<{ + handleSubmit: ReturnType<typeof vi.fn> + isSubmitting: boolean + canSubmit: boolean + }>, +): CustomActionsProps => ({ + form: { + handleSubmit: overrides?.handleSubmit ?? vi.fn(), + } as unknown as CustomActionsProps['form'], isSubmitting: overrides?.isSubmitting ?? false, canSubmit: overrides?.canSubmit ?? true, }) @@ -516,12 +511,7 @@ describe('Actions', () => { it('should render back button', () => { const mockFormParams = createMockFormParams() - render( - <Actions - formParams={mockFormParams} - onBack={vi.fn()} - />, - ) + render(<Actions formParams={mockFormParams} onBack={vi.fn()} />) expect(screen.getByText('datasetPipeline.operations.backToDataSource')).toBeInTheDocument() }) @@ -529,12 +519,7 @@ describe('Actions', () => { it('should render process button', () => { const mockFormParams = createMockFormParams() - render( - <Actions - formParams={mockFormParams} - onBack={vi.fn()} - />, - ) + render(<Actions formParams={mockFormParams} onBack={vi.fn()} />) expect(screen.getByText('datasetPipeline.operations.process')).toBeInTheDocument() }) @@ -544,13 +529,7 @@ describe('Actions', () => { it('should disable process button when runDisabled is true', () => { const mockFormParams = createMockFormParams() - render( - <Actions - formParams={mockFormParams} - runDisabled={true} - onBack={vi.fn()} - />, - ) + render(<Actions formParams={mockFormParams} runDisabled={true} onBack={vi.fn()} />) const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).toBeDisabled() @@ -559,12 +538,7 @@ describe('Actions', () => { it('should disable process button when isSubmitting is true', () => { const mockFormParams = createMockFormParams({ isSubmitting: true }) - render( - <Actions - formParams={mockFormParams} - onBack={vi.fn()} - />, - ) + render(<Actions formParams={mockFormParams} onBack={vi.fn()} />) const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expectLoadingButton(processButton) @@ -573,12 +547,7 @@ describe('Actions', () => { it('should disable process button when canSubmit is false', () => { const mockFormParams = createMockFormParams({ canSubmit: false }) - render( - <Actions - formParams={mockFormParams} - onBack={vi.fn()} - />, - ) + render(<Actions formParams={mockFormParams} onBack={vi.fn()} />) const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).toBeDisabled() @@ -590,12 +559,7 @@ describe('Actions', () => { } const mockFormParams = createMockFormParams() - render( - <Actions - formParams={mockFormParams} - onBack={vi.fn()} - />, - ) + render(<Actions formParams={mockFormParams} onBack={vi.fn()} />) const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expectLoadingButton(processButton) @@ -607,13 +571,7 @@ describe('Actions', () => { } const mockFormParams = createMockFormParams() - render( - <Actions - formParams={mockFormParams} - runDisabled={false} - onBack={vi.fn()} - />, - ) + render(<Actions formParams={mockFormParams} runDisabled={false} onBack={vi.fn()} />) const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).not.toBeDisabled() @@ -625,12 +583,7 @@ describe('Actions', () => { const mockOnBack = vi.fn() const mockFormParams = createMockFormParams() - render( - <Actions - formParams={mockFormParams} - onBack={mockOnBack} - />, - ) + render(<Actions formParams={mockFormParams} onBack={mockOnBack} />) fireEvent.click(screen.getByText('datasetPipeline.operations.backToDataSource')) @@ -641,12 +594,7 @@ describe('Actions', () => { const mockSubmit = vi.fn() const mockFormParams = createMockFormParams({ handleSubmit: mockSubmit }) - render( - <Actions - formParams={mockFormParams} - onBack={vi.fn()} - />, - ) + render(<Actions formParams={mockFormParams} onBack={vi.fn()} />) fireEvent.click(screen.getByText('datasetPipeline.operations.process')) @@ -658,12 +606,7 @@ describe('Actions', () => { it('should handle undefined runDisabled prop', () => { const mockFormParams = createMockFormParams() - render( - <Actions - formParams={mockFormParams} - onBack={vi.fn()} - />, - ) + render(<Actions formParams={mockFormParams} onBack={vi.fn()} />) const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).not.toBeDisabled() @@ -673,12 +616,7 @@ describe('Actions', () => { mockWorkflowRunningData = undefined const mockFormParams = createMockFormParams() - render( - <Actions - formParams={mockFormParams} - onBack={vi.fn()} - />, - ) + render(<Actions formParams={mockFormParams} onBack={vi.fn()} />) const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).not.toBeDisabled() @@ -689,19 +627,9 @@ describe('Actions', () => { it('should be wrapped with React.memo', () => { const mockFormParams = createMockFormParams() const mockOnBack = vi.fn() - const { rerender } = render( - <Actions - formParams={mockFormParams} - onBack={mockOnBack} - />, - ) + const { rerender } = render(<Actions formParams={mockFormParams} onBack={mockOnBack} />) - rerender( - <Actions - formParams={mockFormParams} - onBack={mockOnBack} - />, - ) + rerender(<Actions formParams={mockFormParams} onBack={mockOnBack} />) expect(screen.getByText('datasetPipeline.operations.backToDataSource')).toBeInTheDocument() }) @@ -753,9 +681,7 @@ describe('Options', () => { initialData: {}, configurations: [], schema: createMockSchema(), - CustomActions: () => ( - <button data-testid="custom-action">Custom Submit</button> - ), + CustomActions: () => <button data-testid="custom-action">Custom Submit</button>, onSubmit: vi.fn(), } @@ -844,9 +770,7 @@ describe('Options', () => { safeParse: vi.fn().mockReturnValue({ success: false, error: { - issues: [ - { path: ['name'], message: 'Name is required' }, - ], + issues: [{ path: ['name'], message: 'Name is required' }], }, }), } as unknown as ZodSchema @@ -870,9 +794,7 @@ describe('Options', () => { safeParse: vi.fn().mockReturnValue({ success: false, error: { - issues: [ - { path: ['name'], message: 'Name is required' }, - ], + issues: [{ path: ['name'], message: 'Name is required' }], }, }), } as unknown as ZodSchema @@ -899,9 +821,7 @@ describe('Options', () => { safeParse: vi.fn().mockReturnValue({ success: false, error: { - issues: [ - { path: ['user', 'profile', 'email'], message: 'Invalid email format' }, - ], + issues: [{ path: ['user', 'profile', 'email'], message: 'Invalid email format' }], }, }), } as unknown as ZodSchema @@ -960,9 +880,7 @@ describe('Options', () => { safeParse: vi.fn().mockReturnValue({ success: false, error: { - issues: [ - { path: [], message: 'Form validation failed' }, - ], + issues: [{ path: [], message: 'Form validation failed' }], }, }), } as unknown as ZodSchema @@ -1098,7 +1016,8 @@ describe('Options', () => { it('should handle large number of configurations', () => { const configurations = Array.from({ length: 20 }, (_, i) => - createBaseConfiguration({ variable: `field_${i}`, label: `Field ${i}` })) + createBaseConfiguration({ variable: `field_${i}`, label: `Field ${i}` }), + ) const props = { initialData: {}, configurations, @@ -1129,11 +1048,12 @@ describe('useInputVariables Hook', () => { setupMocks({ isFetchingParams: true }) renderWithQueryClient( - <DocumentProcessing {...{ - dataSourceNodeId: 'test-node', - onProcess: vi.fn(), - onBack: vi.fn(), - }} + <DocumentProcessing + {...{ + dataSourceNodeId: 'test-node', + onProcess: vi.fn(), + onBack: vi.fn(), + }} />, ) @@ -1146,11 +1066,12 @@ describe('useInputVariables Hook', () => { setupMocks({ paramsConfig: { variables } }) renderWithQueryClient( - <DocumentProcessing {...{ - dataSourceNodeId: 'test-node', - onProcess: vi.fn(), - onBack: vi.fn(), - }} + <DocumentProcessing + {...{ + dataSourceNodeId: 'test-node', + onProcess: vi.fn(), + onBack: vi.fn(), + }} />, ) @@ -1164,11 +1085,12 @@ describe('useInputVariables Hook', () => { setupMocks() renderWithQueryClient( - <DocumentProcessing {...{ - dataSourceNodeId: 'test-node', - onProcess: vi.fn(), - onBack: vi.fn(), - }} + <DocumentProcessing + {...{ + dataSourceNodeId: 'test-node', + onProcess: vi.fn(), + onBack: vi.fn(), + }} />, ) @@ -1180,11 +1102,12 @@ describe('useInputVariables Hook', () => { setupMocks({ pipelineId: null }) renderWithQueryClient( - <DocumentProcessing {...{ - dataSourceNodeId: 'test-node', - onProcess: vi.fn(), - onBack: vi.fn(), - }} + <DocumentProcessing + {...{ + dataSourceNodeId: 'test-node', + onProcess: vi.fn(), + onBack: vi.fn(), + }} />, ) @@ -1216,11 +1139,12 @@ describe('DocumentProcessing Integration', () => { }) renderWithQueryClient( - <DocumentProcessing {...{ - dataSourceNodeId: 'test-node', - onProcess: vi.fn(), - onBack: vi.fn(), - }} + <DocumentProcessing + {...{ + dataSourceNodeId: 'test-node', + onProcess: vi.fn(), + onBack: vi.fn(), + }} />, ) @@ -1256,11 +1180,12 @@ describe('DocumentProcessing Integration', () => { }) renderWithQueryClient( - <DocumentProcessing {...{ - dataSourceNodeId: 'test-node', - onProcess: vi.fn(), - onBack: vi.fn(), - }} + <DocumentProcessing + {...{ + dataSourceNodeId: 'test-node', + onProcess: vi.fn(), + onBack: vi.fn(), + }} />, ) @@ -1272,11 +1197,12 @@ describe('DocumentProcessing Integration', () => { setupMocks({ isFetchingParams: true }) renderWithQueryClient( - <DocumentProcessing {...{ - dataSourceNodeId: 'test-node', - onProcess: vi.fn(), - onBack: vi.fn(), - }} + <DocumentProcessing + {...{ + dataSourceNodeId: 'test-node', + onProcess: vi.fn(), + onBack: vi.fn(), + }} />, ) @@ -1301,11 +1227,7 @@ describe('Prop Variations', () => { ['very-long-node-id-that-could-potentially-cause-issues-if-not-handled-properly'], ])('should handle dataSourceNodeId: %s', (nodeId) => { renderWithQueryClient( - <DocumentProcessing - dataSourceNodeId={nodeId} - onProcess={vi.fn()} - onBack={vi.fn()} - />, + <DocumentProcessing dataSourceNodeId={nodeId} onProcess={vi.fn()} onBack={vi.fn()} />, ) expect(screen.getByTestId('form-actions')).toBeInTheDocument() @@ -1346,17 +1268,16 @@ describe('Prop Variations', () => { describe('Configuration Variations', () => { it('should handle required fields', () => { - const configurations = [ - createBaseConfiguration({ variable: 'required', required: true }), - ] + const configurations = [createBaseConfiguration({ variable: 'required', required: true })] setupMocks({ configurations }) renderWithQueryClient( - <DocumentProcessing {...{ - dataSourceNodeId: 'test-node', - onProcess: vi.fn(), - onBack: vi.fn(), - }} + <DocumentProcessing + {...{ + dataSourceNodeId: 'test-node', + onProcess: vi.fn(), + onBack: vi.fn(), + }} />, ) @@ -1364,17 +1285,16 @@ describe('Prop Variations', () => { }) it('should handle optional fields', () => { - const configurations = [ - createBaseConfiguration({ variable: 'optional', required: false }), - ] + const configurations = [createBaseConfiguration({ variable: 'optional', required: false })] setupMocks({ configurations }) renderWithQueryClient( - <DocumentProcessing {...{ - dataSourceNodeId: 'test-node', - onProcess: vi.fn(), - onBack: vi.fn(), - }} + <DocumentProcessing + {...{ + dataSourceNodeId: 'test-node', + onProcess: vi.fn(), + onBack: vi.fn(), + }} />, ) @@ -1390,11 +1310,12 @@ describe('Prop Variations', () => { setupMocks({ configurations }) renderWithQueryClient( - <DocumentProcessing {...{ - dataSourceNodeId: 'test-node', - onProcess: vi.fn(), - onBack: vi.fn(), - }} + <DocumentProcessing + {...{ + dataSourceNodeId: 'test-node', + onProcess: vi.fn(), + onBack: vi.fn(), + }} />, ) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx index 5ec38ba6e5db82..17a01ba1374231 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx @@ -4,12 +4,7 @@ import type { BaseConfiguration } from '@/app/components/base/form/form-scenario import { fireEvent, render, screen, waitFor } from '@testing-library/react' import Options from '../options' -const { - mockFormValue, - mockHandleSubmit, - mockToastError, - mockBaseField, -} = vi.hoisted(() => ({ +const { mockFormValue, mockHandleSubmit, mockToastError, mockBaseField } = vi.hoisted(() => ({ mockFormValue: { chunkSize: 256 } as Record<string, unknown>, mockHandleSubmit: vi.fn(), mockToastError: vi.fn(), @@ -42,12 +37,17 @@ vi.mock('@/app/components/base/form', () => ({ }) => ({ handleSubmit: () => { const validationResult = validators?.onSubmit?.({ value: mockFormValue }) - if (!validationResult) - onSubmit({ value: mockFormValue }) + if (!validationResult) onSubmit({ value: mockFormValue }) mockHandleSubmit() }, - AppForm: ({ children }: { children: React.ReactNode }) => <div data-testid="app-form">{children}</div>, - Actions: ({ CustomActions }: { CustomActions: (props: CustomActionsProps) => React.ReactNode }) => ( + AppForm: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="app-form">{children}</div> + ), + Actions: ({ + CustomActions, + }: { + CustomActions: (props: CustomActionsProps) => React.ReactNode + }) => ( <div data-testid="form-actions"> {CustomActions({ form: { @@ -61,22 +61,24 @@ vi.mock('@/app/components/base/form', () => ({ }), })) -const createSchema = (success: boolean): ZodSchema => ({ - safeParse: vi.fn(() => { - if (success) - return { success: true } +const createSchema = (success: boolean): ZodSchema => + ({ + safeParse: vi.fn(() => { + if (success) return { success: true } - return { - success: false, - error: { - issues: [{ - path: ['chunkSize'], - message: 'Invalid value', - }], - }, - } - }), -}) as unknown as ZodSchema + return { + success: false, + error: { + issues: [ + { + path: ['chunkSize'], + message: 'Invalid value', + }, + ], + }, + } + }), + }) as unknown as ZodSchema describe('Document processing options', () => { beforeEach(() => { diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/actions.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/actions.tsx index c00e1176342963..1924b115e63e5e 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/actions.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/actions.tsx @@ -11,23 +11,16 @@ type ActionsProps = { onBack: () => void } -const Actions = ({ - formParams, - runDisabled, - onBack, -}: ActionsProps) => { +const Actions = ({ formParams, runDisabled, onBack }: ActionsProps) => { const { t } = useTranslation() const { form, isSubmitting, canSubmit } = formParams - const workflowRunningData = useStore(s => s.workflowRunningData) + const workflowRunningData = useStore((s) => s.workflowRunningData) const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running return ( <div className="flex items-center justify-end gap-x-2 p-4 pt-2"> - <Button - variant="secondary" - onClick={onBack} - > - {t($ => $['operations.backToDataSource'], { ns: 'datasetPipeline' })} + <Button variant="secondary" onClick={onBack}> + {t(($) => $['operations.backToDataSource'], { ns: 'datasetPipeline' })} </Button> <Button variant="primary" @@ -37,7 +30,7 @@ const Actions = ({ disabled={runDisabled || isSubmitting || !canSubmit || isRunning} loading={isSubmitting || isRunning} > - {t($ => $['operations.process'], { ns: 'datasetPipeline' })} + {t(($) => $['operations.process'], { ns: 'datasetPipeline' })} </Button> </div> ) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/hooks.ts b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/hooks.ts index d2029ddd245915..529932792c0834 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/hooks.ts +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/hooks.ts @@ -2,7 +2,7 @@ import { useStore } from '@/app/components/workflow/store' import { useDraftPipelineProcessingParams } from '@/service/use-pipeline' export const useInputVariables = (datasourceNodeId: string) => { - const pipelineId = useStore(state => state.pipelineId) + const pipelineId = useStore((state) => state.pipelineId) const { data: paramsConfig, isFetching: isFetchingParams } = useDraftPipelineProcessingParams({ pipeline_id: pipelineId!, node_id: datasourceNodeId, diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx index 3186e5bb7d78f2..a22d1d7edf936b 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx @@ -2,7 +2,10 @@ import type { CustomActionsProps } from '@/app/components/base/form/components/f import * as React from 'react' import { useCallback } from 'react' import { generateZodSchema } from '@/app/components/base/form/form-scenarios/base/utils' -import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields' +import { + useConfigurations, + useInitialData, +} from '@/app/components/rag-pipeline/hooks/use-input-fields' import Actions from './actions' import { useInputVariables } from './hooks' import Options from './options' @@ -13,19 +16,18 @@ type DocumentProcessingProps = { onBack: () => void } -const DocumentProcessing = ({ - dataSourceNodeId, - onProcess, - onBack, -}: DocumentProcessingProps) => { +const DocumentProcessing = ({ dataSourceNodeId, onProcess, onBack }: DocumentProcessingProps) => { const { isFetchingParams, paramsConfig } = useInputVariables(dataSourceNodeId) const initialData = useInitialData(paramsConfig?.variables || []) const configurations = useConfigurations(paramsConfig?.variables || []) const schema = generateZodSchema(configurations) - const renderCustomActions = useCallback((props: CustomActionsProps) => ( - <Actions runDisabled={isFetchingParams} formParams={props} onBack={onBack} /> - ), [isFetchingParams, onBack]) + const renderCustomActions = useCallback( + (props: CustomActionsProps) => ( + <Actions runDisabled={isFetchingParams} formParams={props} onBack={onBack} /> + ), + [isFetchingParams, onBack], + ) return ( <Options diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/options.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/options.tsx index 6a77c9b355f753..16a1fce37db71d 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/options.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/options.tsx @@ -12,7 +12,13 @@ type OptionsProps = { CustomActions: (props: CustomActionsProps) => React.JSX.Element onSubmit: (data: Record<string, any>) => void } -const Options = ({ initialData, configurations, schema, CustomActions, onSubmit }: OptionsProps) => { +const Options = ({ + initialData, + configurations, + schema, + CustomActions, + onSubmit, +}: OptionsProps) => { const form = useAppForm({ defaultValues: initialData, validators: { diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/footer-tips.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/footer-tips.tsx index 222f225ba05ac5..ff622a31cd4666 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/footer-tips.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/footer-tips.tsx @@ -6,7 +6,7 @@ const FooterTips = () => { return ( <div className="flex grow flex-col justify-end p-4 pt-2 system-xs-regular text-text-tertiary"> - {t($ => $['testRun.tooltip'], { ns: 'datasetPipeline' })} + {t(($) => $['testRun.tooltip'], { ns: 'datasetPipeline' })} </div> ) } diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/hooks.ts b/web/app/components/rag-pipeline/components/panel/test-run/preparation/hooks.ts index c608606cc7bdf4..e7a11de79c56f9 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/hooks.ts +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/hooks.ts @@ -13,20 +13,20 @@ export const useTestRunSteps = () => { const [currentStep, setCurrentStep] = useState(1) const handleNextStep = useCallback(() => { - setCurrentStep(preStep => preStep + 1) + setCurrentStep((preStep) => preStep + 1) }, []) const handleBackStep = useCallback(() => { - setCurrentStep(preStep => preStep - 1) + setCurrentStep((preStep) => preStep - 1) }, []) const steps = [ { - label: t($ => $['testRun.steps.dataSource'], { ns: 'datasetPipeline' }), + label: t(($) => $['testRun.steps.dataSource'], { ns: 'datasetPipeline' }), value: TestRunStep.dataSource, }, { - label: t($ => $['testRun.steps.documentProcessing'], { ns: 'datasetPipeline' }), + label: t(($) => $['testRun.steps.documentProcessing'], { ns: 'datasetPipeline' }), value: TestRunStep.documentProcessing, }, ] @@ -41,7 +41,7 @@ export const useTestRunSteps = () => { export const useDatasourceOptions = () => { const nodes = useNodes<DataSourceNodeType>() - const datasourceNodes = nodes.filter(node => node.data.type === BlockEnum.DataSource) + const datasourceNodes = nodes.filter((node) => node.data.type === BlockEnum.DataSource) const options = useMemo(() => { const options: DataSourceOption[] = [] @@ -86,13 +86,8 @@ export const useWebsiteCrawl = () => { const dataSourceStore = useDataSourceStore() const clearWebsiteCrawlData = useCallback(() => { - const { - setStep, - setCrawlResult, - setWebsitePages, - setPreviewIndex, - setCurrentWebsite, - } = dataSourceStore.getState() + const { setStep, setCrawlResult, setWebsitePages, setPreviewIndex, setCurrentWebsite } = + dataSourceStore.getState() setStep(CrawlStep.init) setCrawlResult(undefined) setCurrentWebsite(undefined) @@ -109,13 +104,8 @@ export const useOnlineDrive = () => { const dataSourceStore = useDataSourceStore() const clearOnlineDriveData = useCallback(() => { - const { - setOnlineDriveFileList, - setBucket, - setPrefix, - setKeywords, - setSelectedFileIds, - } = dataSourceStore.getState() + const { setOnlineDriveFileList, setBucket, setPrefix, setKeywords, setSelectedFileIds } = + dataSourceStore.getState() setOnlineDriveFileList([]) setBucket('') setPrefix([]) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.tsx index a65770f01d5070..533af1fe847504 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.tsx @@ -6,7 +6,10 @@ import { trackEvent } from '@/app/components/base/amplitude' import LocalFile from '@/app/components/datasets/documents/create-from-pipeline/data-source/local-file' import OnlineDocuments from '@/app/components/datasets/documents/create-from-pipeline/data-source/online-documents' import OnlineDrive from '@/app/components/datasets/documents/create-from-pipeline/data-source/online-drive' -import { useDataSourceStore, useDataSourceStoreWithSelector } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' +import { + useDataSourceStore, + useDataSourceStoreWithSelector, +} from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' import WebsiteCrawl from '@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl' import { useWorkflowRun } from '@/app/components/workflow/hooks' import { useWorkflowStore } from '@/app/components/workflow/store' @@ -16,36 +19,24 @@ import Actions from './actions' import DataSourceOptions from './data-source-options' import DocumentProcessing from './document-processing' import FooterTips from './footer-tips' -import { - useOnlineDocument, - useOnlineDrive, - useTestRunSteps, - useWebsiteCrawl, -} from './hooks' +import { useOnlineDocument, useOnlineDrive, useTestRunSteps, useWebsiteCrawl } from './hooks' import StepIndicator from './step-indicator' const Preparation = () => { - const { - localFileList, - onlineDocuments, - websitePages, - selectedFileIds, - } = useDataSourceStoreWithSelector(useShallow(state => ({ - localFileList: state.localFileList, - onlineDocuments: state.onlineDocuments, - websitePages: state.websitePages, - selectedFileIds: state.selectedFileIds, - }))) + const { localFileList, onlineDocuments, websitePages, selectedFileIds } = + useDataSourceStoreWithSelector( + useShallow((state) => ({ + localFileList: state.localFileList, + onlineDocuments: state.onlineDocuments, + websitePages: state.websitePages, + selectedFileIds: state.selectedFileIds, + })), + ) const workflowStore = useWorkflowStore() const dataSourceStore = useDataSourceStore() const [datasource, setDatasource] = useState<Datasource>() - const { - steps, - currentStep, - handleNextStep, - handleBackStep, - } = useTestRunSteps() + const { steps, currentStep, handleNextStep, handleBackStep } = useTestRunSteps() const { clearOnlineDocumentData } = useOnlineDocument() const { clearWebsiteCrawlData } = useWebsiteCrawl() @@ -54,165 +45,172 @@ const Preparation = () => { const datasourceType = datasource?.nodeData.provider_type const nextBtnDisabled = useMemo(() => { - if (!datasource) - return true + if (!datasource) return true if (datasourceType === DatasourceType.localFile) - return !localFileList.length || localFileList.some(file => !file.file.id) - if (datasourceType === DatasourceType.onlineDocument) - return !onlineDocuments.length - if (datasourceType === DatasourceType.websiteCrawl) - return !websitePages.length - if (datasourceType === DatasourceType.onlineDrive) - return !selectedFileIds.length + return !localFileList.length || localFileList.some((file) => !file.file.id) + if (datasourceType === DatasourceType.onlineDocument) return !onlineDocuments.length + if (datasourceType === DatasourceType.websiteCrawl) return !websitePages.length + if (datasourceType === DatasourceType.onlineDrive) return !selectedFileIds.length return false - }, [datasource, datasourceType, localFileList, onlineDocuments.length, selectedFileIds.length, websitePages.length]) + }, [ + datasource, + datasourceType, + localFileList, + onlineDocuments.length, + selectedFileIds.length, + websitePages.length, + ]) const { handleRun } = useWorkflowRun() - const handleProcess = useCallback((data: Record<string, any>) => { - if (!datasource) - return - const datasourceInfoList: Record<string, any>[] = [] - const credentialId = dataSourceStore.getState().currentCredentialId - if (datasourceType === DatasourceType.localFile) { - const { localFileList } = dataSourceStore.getState() - const { id, name, type, size, extension, mime_type } = localFileList[0]!.file - const documentInfo = { - related_id: id, - name, - type, - size, - extension, - mime_type, - url: '', - transfer_method: TransferMethod.local_file, + const handleProcess = useCallback( + (data: Record<string, any>) => { + if (!datasource) return + const datasourceInfoList: Record<string, any>[] = [] + const credentialId = dataSourceStore.getState().currentCredentialId + if (datasourceType === DatasourceType.localFile) { + const { localFileList } = dataSourceStore.getState() + const { id, name, type, size, extension, mime_type } = localFileList[0]!.file + const documentInfo = { + related_id: id, + name, + type, + size, + extension, + mime_type, + url: '', + transfer_method: TransferMethod.local_file, + } + datasourceInfoList.push(documentInfo) } - datasourceInfoList.push(documentInfo) - } - if (datasourceType === DatasourceType.onlineDocument) { - const { onlineDocuments } = dataSourceStore.getState() - const { workspace_id, ...rest } = onlineDocuments[0]! - const documentInfo = { - workspace_id, - page: rest, - credential_id: credentialId, + if (datasourceType === DatasourceType.onlineDocument) { + const { onlineDocuments } = dataSourceStore.getState() + const { workspace_id, ...rest } = onlineDocuments[0]! + const documentInfo = { + workspace_id, + page: rest, + credential_id: credentialId, + } + datasourceInfoList.push(documentInfo) } - datasourceInfoList.push(documentInfo) - } - if (datasourceType === DatasourceType.websiteCrawl) { - const { websitePages } = dataSourceStore.getState() - datasourceInfoList.push({ - ...websitePages[0], - credential_id: credentialId, - }) - } - if (datasourceType === DatasourceType.onlineDrive) { - const { bucket, onlineDriveFileList, selectedFileIds } = dataSourceStore.getState() - const file = onlineDriveFileList.find(file => file.id === selectedFileIds[0]) - datasourceInfoList.push({ - bucket, - id: file?.id, - name: file?.name, - type: file?.type, - credential_id: credentialId, + if (datasourceType === DatasourceType.websiteCrawl) { + const { websitePages } = dataSourceStore.getState() + datasourceInfoList.push({ + ...websitePages[0], + credential_id: credentialId, + }) + } + if (datasourceType === DatasourceType.onlineDrive) { + const { bucket, onlineDriveFileList, selectedFileIds } = dataSourceStore.getState() + const file = onlineDriveFileList.find((file) => file.id === selectedFileIds[0]) + datasourceInfoList.push({ + bucket, + id: file?.id, + name: file?.name, + type: file?.type, + credential_id: credentialId, + }) + } + const { setIsPreparingDataSource } = workflowStore.getState() + handleRun({ + inputs: data, + start_node_id: datasource.nodeId, + datasource_type: datasourceType, + datasource_info_list: datasourceInfoList, }) - } - const { setIsPreparingDataSource } = workflowStore.getState() - handleRun({ - inputs: data, - start_node_id: datasource.nodeId, - datasource_type: datasourceType, - datasource_info_list: datasourceInfoList, - }) - trackEvent('pipeline_start_action_time', { action_type: 'document_processing' }) - setIsPreparingDataSource?.(false) - }, [dataSourceStore, datasource, datasourceType, handleRun, workflowStore]) + trackEvent('pipeline_start_action_time', { action_type: 'document_processing' }) + setIsPreparingDataSource?.(false) + }, + [dataSourceStore, datasource, datasourceType, handleRun, workflowStore], + ) - const clearDataSourceData = useCallback((dataSource: Datasource) => { - if (dataSource.nodeData.provider_type === DatasourceType.onlineDocument) - clearOnlineDocumentData() - else if (dataSource.nodeData.provider_type === DatasourceType.websiteCrawl) - clearWebsiteCrawlData() - else if (dataSource.nodeData.provider_type === DatasourceType.onlineDrive) - clearOnlineDriveData() - }, [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData]) + const clearDataSourceData = useCallback( + (dataSource: Datasource) => { + if (dataSource.nodeData.provider_type === DatasourceType.onlineDocument) + clearOnlineDocumentData() + else if (dataSource.nodeData.provider_type === DatasourceType.websiteCrawl) + clearWebsiteCrawlData() + else if (dataSource.nodeData.provider_type === DatasourceType.onlineDrive) + clearOnlineDriveData() + }, + [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData], + ) - const handleSwitchDataSource = useCallback((dataSource: Datasource) => { - const { - setCurrentCredentialId, - currentNodeIdRef, - } = dataSourceStore.getState() - clearDataSourceData(dataSource) - setCurrentCredentialId('') - currentNodeIdRef.current = dataSource.nodeId - setDatasource(dataSource) - }, [clearDataSourceData, dataSourceStore]) + const handleSwitchDataSource = useCallback( + (dataSource: Datasource) => { + const { setCurrentCredentialId, currentNodeIdRef } = dataSourceStore.getState() + clearDataSourceData(dataSource) + setCurrentCredentialId('') + currentNodeIdRef.current = dataSource.nodeId + setDatasource(dataSource) + }, + [clearDataSourceData, dataSourceStore], + ) - const handleCredentialChange = useCallback((credentialId: string) => { - const { setCurrentCredentialId } = dataSourceStore.getState() - clearDataSourceData(datasource!) - setCurrentCredentialId(credentialId) - }, [clearDataSourceData, dataSourceStore, datasource]) + const handleCredentialChange = useCallback( + (credentialId: string) => { + const { setCurrentCredentialId } = dataSourceStore.getState() + clearDataSourceData(datasource!) + setCurrentCredentialId(credentialId) + }, + [clearDataSourceData, dataSourceStore, datasource], + ) return ( <> <StepIndicator steps={steps} currentStep={currentStep} /> <div className="flex grow flex-col overflow-y-auto"> - { - currentStep === 1 && ( - <> - <div className="flex flex-col gap-y-4 px-4 py-2"> - <DataSourceOptions - dataSourceNodeId={datasource?.nodeId || ''} - onSelect={handleSwitchDataSource} + {currentStep === 1 && ( + <> + <div className="flex flex-col gap-y-4 px-4 py-2"> + <DataSourceOptions + dataSourceNodeId={datasource?.nodeId || ''} + onSelect={handleSwitchDataSource} + /> + {datasourceType === DatasourceType.localFile && ( + <LocalFile + allowedExtensions={datasource!.nodeData.fileExtensions || []} + supportBatchUpload={false} // only support single file upload in test run /> - {datasourceType === DatasourceType.localFile && ( - <LocalFile - allowedExtensions={datasource!.nodeData.fileExtensions || []} - supportBatchUpload={false} // only support single file upload in test run - /> - )} - {datasourceType === DatasourceType.onlineDocument && ( - <OnlineDocuments - nodeId={datasource!.nodeId} - nodeData={datasource!.nodeData} - isInPipeline - onCredentialChange={handleCredentialChange} - supportBatchUpload={false} - /> - )} - {datasourceType === DatasourceType.websiteCrawl && ( - <WebsiteCrawl - nodeId={datasource!.nodeId} - nodeData={datasource!.nodeData} - isInPipeline - onCredentialChange={handleCredentialChange} - supportBatchUpload={false} - /> - )} - {datasourceType === DatasourceType.onlineDrive && ( - <OnlineDrive - nodeId={datasource!.nodeId} - nodeData={datasource!.nodeData} - isInPipeline - onCredentialChange={handleCredentialChange} - supportBatchUpload={false} - /> - )} - </div> - <Actions disabled={nextBtnDisabled} handleNextStep={handleNextStep} /> - <FooterTips /> - </> - ) - } - { - currentStep === 2 && ( - <DocumentProcessing - dataSourceNodeId={datasource!.nodeId} - onProcess={handleProcess} - onBack={handleBackStep} - /> - ) - } + )} + {datasourceType === DatasourceType.onlineDocument && ( + <OnlineDocuments + nodeId={datasource!.nodeId} + nodeData={datasource!.nodeData} + isInPipeline + onCredentialChange={handleCredentialChange} + supportBatchUpload={false} + /> + )} + {datasourceType === DatasourceType.websiteCrawl && ( + <WebsiteCrawl + nodeId={datasource!.nodeId} + nodeData={datasource!.nodeData} + isInPipeline + onCredentialChange={handleCredentialChange} + supportBatchUpload={false} + /> + )} + {datasourceType === DatasourceType.onlineDrive && ( + <OnlineDrive + nodeId={datasource!.nodeId} + nodeData={datasource!.nodeData} + isInPipeline + onCredentialChange={handleCredentialChange} + supportBatchUpload={false} + /> + )} + </div> + <Actions disabled={nextBtnDisabled} handleNextStep={handleNextStep} /> + <FooterTips /> + </> + )} + {currentStep === 2 && ( + <DocumentProcessing + dataSourceNodeId={datasource!.nodeId} + onProcess={handleProcess} + onBack={handleBackStep} + /> + )} </div> </> ) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/step-indicator.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/step-indicator.tsx index 75c8a6e150e8de..4a40e555704b4a 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/step-indicator.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/step-indicator.tsx @@ -12,10 +12,7 @@ type StepIndicatorProps = { steps: Step[] } -const StepIndicator = ({ - currentStep, - steps, -}: StepIndicatorProps) => { +const StepIndicator = ({ currentStep, steps }: StepIndicatorProps) => { return ( <div className="flex items-center gap-x-2 px-4 pb-2"> {steps.map((step, index) => { @@ -24,7 +21,10 @@ const StepIndicator = ({ return ( <div key={index} className="flex items-center gap-x-2"> <div - className={cn('flex items-center gap-x-1', isCurrentStep ? 'text-state-accent-solid' : 'text-text-tertiary')} + className={cn( + 'flex items-center gap-x-1', + isCurrentStep ? 'text-state-accent-solid' : 'text-text-tertiary', + )} > {isCurrentStep && <div className="size-1 rounded-full bg-state-accent-solid" />} <span className="system-2xs-semibold-uppercase">{step.label}</span> diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/__tests__/index.spec.tsx index d08c87fdf04603..71f2e402183ce6 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/result/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/__tests__/index.spec.tsx @@ -1,4 +1,9 @@ -import type { ChunkInfo, GeneralChunks, ParentChildChunks, QAChunks } from '@/app/components/rag-pipeline/components/chunk-card-list/types' +import type { + ChunkInfo, + GeneralChunks, + ParentChildChunks, + QAChunks, +} from '@/app/components/rag-pipeline/components/chunk-card-list/types' import type { WorkflowRunningData } from '@/app/components/workflow/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' @@ -14,8 +19,9 @@ import Tab from '../tabs/tab' let mockWorkflowRunningData: WorkflowRunningData | undefined vi.mock('@/app/components/workflow/store', () => ({ - useStore: <T,>(selector: (state: { workflowRunningData: WorkflowRunningData | undefined }) => T) => - selector({ workflowRunningData: mockWorkflowRunningData }), + useStore: <T,>( + selector: (state: { workflowRunningData: WorkflowRunningData | undefined }) => T, + ) => selector({ workflowRunningData: mockWorkflowRunningData }), })) vi.mock('@/app/components/workflow/run/result-panel', () => ({ @@ -61,7 +67,7 @@ vi.mock('@/app/components/workflow/run/result-panel', () => ({ })) vi.mock('@/app/components/workflow/run/tracing-panel', () => ({ - default: ({ className, list }: { className?: string, list: unknown[] }) => ( + default: ({ className, list }: { className?: string; list: unknown[] }) => ( <div data-testid="tracing-panel" data-classname={className} data-list-length={list.length}> TracingPanel </div> @@ -69,7 +75,7 @@ vi.mock('@/app/components/workflow/run/tracing-panel', () => ({ })) vi.mock('@/app/components/rag-pipeline/components/chunk-card-list', () => ({ - ChunkCardList: ({ chunkType, chunkInfo }: { chunkType?: string, chunkInfo?: ChunkInfo }) => ( + ChunkCardList: ({ chunkType, chunkInfo }: { chunkType?: string; chunkInfo?: ChunkInfo }) => ( <div data-testid="chunk-card-list" data-chunk-type={chunkType} @@ -148,7 +154,10 @@ const createGeneralChunkOutputs = (chunkCount: number = 5) => ({ })), }) -const createParentChildChunkOutputs = (parentMode: 'paragraph' | 'full-doc', parentCount: number = 3) => ({ +const createParentChildChunkOutputs = ( + parentMode: 'paragraph' | 'full-doc', + parentCount: number = 3, +) => ({ chunk_structure: ChunkingMode.parentChild, parent_mode: parentMode, preview: Array.from({ length: parentCount }, (_, i) => ({ @@ -316,22 +325,46 @@ describe('Tab', () => { const workflowData = createMockWorkflowRunningData() const { rerender } = render( - <Tab isActive={true} label="Tab" value="tab" workflowRunningData={workflowData} onClick={mockOnClick} />, + <Tab + isActive={true} + label="Tab" + value="tab" + workflowRunningData={workflowData} + onClick={mockOnClick} + />, ) expect(screen.getByRole('button')).not.toBeDisabled() rerender( - <Tab isActive={false} label="Tab" value="tab" workflowRunningData={workflowData} onClick={mockOnClick} />, + <Tab + isActive={false} + label="Tab" + value="tab" + workflowRunningData={workflowData} + onClick={mockOnClick} + />, ) expect(screen.getByRole('button')).not.toBeDisabled() rerender( - <Tab isActive={true} label="Tab" value="tab" workflowRunningData={undefined} onClick={mockOnClick} />, + <Tab + isActive={true} + label="Tab" + value="tab" + workflowRunningData={undefined} + onClick={mockOnClick} + />, ) expect(screen.getByRole('button'))!.toBeDisabled() rerender( - <Tab isActive={false} label="Tab" value="tab" workflowRunningData={undefined} onClick={mockOnClick} />, + <Tab + isActive={false} + label="Tab" + value="tab" + workflowRunningData={undefined} + onClick={mockOnClick} + />, ) expect(screen.getByRole('button'))!.toBeDisabled() }) @@ -441,13 +474,7 @@ describe('Tabs', () => { it('should disable all tabs when workflowRunningData is undefined', () => { const mockSwitchTab = vi.fn() - render( - <Tabs - currentTab="RESULT" - workflowRunningData={undefined} - switchTab={mockSwitchTab} - />, - ) + render(<Tabs currentTab="RESULT" workflowRunningData={undefined} switchTab={mockSwitchTab} />) const buttons = screen.getAllByRole('button') buttons.forEach((button) => { @@ -688,7 +715,11 @@ describe('ResultPreview', () => { />, ) - expect(screen.getByText(`pipeline.result.resultPreview.footerTip:{"count":${RAG_PIPELINE_PREVIEW_CHUNK_NUM}}`))!.toBeInTheDocument() + expect( + screen.getByText( + `pipeline.result.resultPreview.footerTip:{"count":${RAG_PIPELINE_PREVIEW_CHUNK_NUM}}`, + ), + )!.toBeInTheDocument() }) it('should not show loading when isRunning but outputs exist', () => { diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/index.tsx index c8c2fbebceac8b..0bafc93fc45c9e 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/result/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/index.tsx @@ -1,19 +1,14 @@ -import { - memo, - useState, -} from 'react' +import { memo, useState } from 'react' import Loading from '@/app/components/base/loading' import ResultPanel from '@/app/components/workflow/run/result-panel' import TracingPanel from '@/app/components/workflow/run/tracing-panel' import { useStore } from '@/app/components/workflow/store' -import { - WorkflowRunningStatus, -} from '@/app/components/workflow/types' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' import ResultPreview from './result-preview' import Tabs from './tabs' const Result = () => { - const workflowRunningData = useStore(s => s.workflowRunningData) + const workflowRunningData = useStore((s) => s.workflowRunningData) const [currentTab, setCurrentTab] = useState<string>('RESULT') const switchTab = async (tab: string) => { @@ -22,11 +17,18 @@ const Result = () => { return ( <div className="flex grow flex-col"> - <Tabs currentTab={currentTab} workflowRunningData={workflowRunningData} switchTab={switchTab} /> + <Tabs + currentTab={currentTab} + workflowRunningData={workflowRunningData} + switchTab={switchTab} + /> <div className="flex h-0 grow flex-col overflow-y-auto"> {currentTab === 'RESULT' && ( <ResultPreview - isRunning={!workflowRunningData?.result || workflowRunningData?.result.status === WorkflowRunningStatus.Running} + isRunning={ + !workflowRunningData?.result || + workflowRunningData?.result.status === WorkflowRunningStatus.Running + } outputs={workflowRunningData?.result?.outputs} error={workflowRunningData?.result?.error} onSwitchToDetail={() => switchTab('DETAIL')} diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/index.spec.tsx index 3a3c18eb44cf11..012bfbe257a54e 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/index.spec.tsx @@ -1,4 +1,9 @@ -import type { ChunkInfo, GeneralChunks, ParentChildChunks, QAChunks } from '../../../../../chunk-card-list/types' +import type { + ChunkInfo, + GeneralChunks, + ParentChildChunks, + QAChunks, +} from '../../../../../chunk-card-list/types' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { ChunkingMode } from '@/models/datasets' @@ -10,8 +15,12 @@ vi.mock('@/config', () => ({ })) vi.mock('../../../../../chunk-card-list', () => ({ - ChunkCardList: ({ chunkType, chunkInfo }: { chunkType: string, chunkInfo: ChunkInfo }) => ( - <div data-testid="chunk-card-list" data-chunk-type={chunkType} data-chunk-info={JSON.stringify(chunkInfo)}> + ChunkCardList: ({ chunkType, chunkInfo }: { chunkType: string; chunkInfo: ChunkInfo }) => ( + <div + data-testid="chunk-card-list" + data-chunk-type={chunkType} + data-chunk-info={JSON.stringify(chunkInfo)} + > ChunkCardList </div> ), @@ -29,7 +38,7 @@ const createGeneralChunkOutputs = (chunks: Array<{ content: string }>) => ({ * Factory for creating parent-child chunk preview outputs */ const createParentChildChunkOutputs = ( - chunks: Array<{ content: string, child_chunks: string[] }>, + chunks: Array<{ content: string; child_chunks: string[] }>, parentMode: 'paragraph' | 'full-doc' = 'paragraph', ) => ({ chunk_structure: ChunkingMode.parentChild, @@ -40,7 +49,7 @@ const createParentChildChunkOutputs = ( /** * Factory for creating QA chunk preview outputs */ -const createQAChunkOutputs = (chunks: Array<{ question: string, answer: string }>) => ({ +const createQAChunkOutputs = (chunks: Array<{ question: string; answer: string }>) => ({ chunk_structure: ChunkingMode.qa, qa_preview: chunks, }) @@ -60,7 +69,7 @@ const createMockGeneralChunks = (count: number): Array<{ content: string }> => { const createMockParentChildChunks = ( count: number, childCount: number = 3, -): Array<{ content: string, child_chunks: string[] }> => { +): Array<{ content: string; child_chunks: string[] }> => { return Array.from({ length: count }, (_, i) => ({ content: `Parent content ${i + 1}`, child_chunks: Array.from({ length: childCount }, (_, j) => `Child ${i + 1}-${j + 1}`), @@ -70,7 +79,7 @@ const createMockParentChildChunks = ( /** * Factory for creating mock QA chunks */ -const createMockQAChunks = (count: number): Array<{ question: string, answer: string }> => { +const createMockQAChunks = (count: number): Array<{ question: string; answer: string }> => { return Array.from({ length: count }, (_, i) => ({ question: `Question ${i + 1}?`, answer: `Answer ${i + 1}`, @@ -132,10 +141,7 @@ describe('formatPreviewChunks', () => { }) it('should handle general chunks with empty content', () => { - const outputs = createGeneralChunkOutputs([ - { content: '' }, - { content: 'Valid content' }, - ]) + const outputs = createGeneralChunkOutputs([{ content: '' }, { content: 'Valid content' }]) const result = formatPreviewChunks(outputs) as GeneralChunks @@ -174,10 +180,13 @@ describe('formatPreviewChunks', () => { describe('Parent-Child Chunks (hierarchical_model)', () => { describe('Paragraph Mode', () => { it('should format parent-child chunks in paragraph mode correctly', () => { - const outputs = createParentChildChunkOutputs([ - { content: 'Parent 1', child_chunks: ['Child 1-1', 'Child 1-2'] }, - { content: 'Parent 2', child_chunks: ['Child 2-1'] }, - ], 'paragraph') + const outputs = createParentChildChunkOutputs( + [ + { content: 'Parent 1', child_chunks: ['Child 1-1', 'Child 1-2'] }, + { content: 'Parent 2', child_chunks: ['Child 2-1'] }, + ], + 'paragraph', + ) const result = formatPreviewChunks(outputs) as ParentChildChunks @@ -196,7 +205,10 @@ describe('formatPreviewChunks', () => { }) it('should limit parent chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM (20) in paragraph mode', () => { - const outputs = createParentChildChunkOutputs(createMockParentChildChunks(30, 2), 'paragraph') + const outputs = createParentChildChunkOutputs( + createMockParentChildChunks(30, 2), + 'paragraph', + ) const result = formatPreviewChunks(outputs) as ParentChildChunks @@ -204,9 +216,15 @@ describe('formatPreviewChunks', () => { }) it('should NOT limit child chunks in paragraph mode', () => { - const outputs = createParentChildChunkOutputs([ - { content: 'Parent 1', child_chunks: Array.from({ length: 50 }, (_, i) => `Child ${i + 1}`) }, - ], 'paragraph') + const outputs = createParentChildChunkOutputs( + [ + { + content: 'Parent 1', + child_chunks: Array.from({ length: 50 }, (_, i) => `Child ${i + 1}`), + }, + ], + 'paragraph', + ) const result = formatPreviewChunks(outputs) as ParentChildChunks @@ -214,9 +232,10 @@ describe('formatPreviewChunks', () => { }) it('should handle empty child_chunks in paragraph mode', () => { - const outputs = createParentChildChunkOutputs([ - { content: 'Parent with no children', child_chunks: [] }, - ], 'paragraph') + const outputs = createParentChildChunkOutputs( + [{ content: 'Parent with no children', child_chunks: [] }], + 'paragraph', + ) const result = formatPreviewChunks(outputs) as ParentChildChunks @@ -226,20 +245,28 @@ describe('formatPreviewChunks', () => { describe('Full-Doc Mode', () => { it('should format parent-child chunks in full-doc mode correctly', () => { - const outputs = createParentChildChunkOutputs([ - { content: 'Full Doc Parent', child_chunks: ['Child 1', 'Child 2', 'Child 3'] }, - ], 'full-doc') + const outputs = createParentChildChunkOutputs( + [{ content: 'Full Doc Parent', child_chunks: ['Child 1', 'Child 2', 'Child 3'] }], + 'full-doc', + ) const result = formatPreviewChunks(outputs) as ParentChildChunks expect(result.parent_mode).toBe('full-doc') expect(result.parent_child_chunks).toHaveLength(1) expect(result.parent_child_chunks[0]!.parent_content).toBe('Full Doc Parent') - expect(result.parent_child_chunks[0]!.child_contents).toEqual(['Child 1', 'Child 2', 'Child 3']) + expect(result.parent_child_chunks[0]!.child_contents).toEqual([ + 'Child 1', + 'Child 2', + 'Child 3', + ]) }) it('should NOT limit parent chunks in full-doc mode', () => { - const outputs = createParentChildChunkOutputs(createMockParentChildChunks(30, 2), 'full-doc') + const outputs = createParentChildChunkOutputs( + createMockParentChildChunks(30, 2), + 'full-doc', + ) const result = formatPreviewChunks(outputs) as ParentChildChunks @@ -247,9 +274,15 @@ describe('formatPreviewChunks', () => { }) it('should limit child chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM (20) in full-doc mode', () => { - const outputs = createParentChildChunkOutputs([ - { content: 'Parent', child_chunks: Array.from({ length: 50 }, (_, i) => `Child ${i + 1}`) }, - ], 'full-doc') + const outputs = createParentChildChunkOutputs( + [ + { + content: 'Parent', + child_chunks: Array.from({ length: 50 }, (_, i) => `Child ${i + 1}`), + }, + ], + 'full-doc', + ) const result = formatPreviewChunks(outputs) as ParentChildChunks @@ -259,10 +292,19 @@ describe('formatPreviewChunks', () => { }) it('should handle multiple parents with many children in full-doc mode', () => { - const outputs = createParentChildChunkOutputs([ - { content: 'Parent 1', child_chunks: Array.from({ length: 25 }, (_, i) => `P1-Child ${i + 1}`) }, - { content: 'Parent 2', child_chunks: Array.from({ length: 30 }, (_, i) => `P2-Child ${i + 1}`) }, - ], 'full-doc') + const outputs = createParentChildChunkOutputs( + [ + { + content: 'Parent 1', + child_chunks: Array.from({ length: 25 }, (_, i) => `P1-Child ${i + 1}`), + }, + { + content: 'Parent 2', + child_chunks: Array.from({ length: 30 }, (_, i) => `P2-Child ${i + 1}`), + }, + ], + 'full-doc', + ) const result = formatPreviewChunks(outputs) as ParentChildChunks @@ -335,12 +377,16 @@ describe('formatPreviewChunks', () => { chunk_structure: ChunkingMode.qa, qa_preview: [ { question: 'Q1', answer: 'A1', extra: 'should be preserved' }, - ] as unknown as Array<{ question: string, answer: string }>, + ] as unknown as Array<{ question: string; answer: string }>, } const result = formatPreviewChunks(outputs) as QAChunks - expect(result.qa_chunks[0]).toEqual({ question: 'Q1', answer: 'A1', extra: 'should be preserved' }) + expect(result.qa_chunks[0]).toEqual({ + question: 'Q1', + answer: 'A1', + extra: 'should be preserved', + }) }) }) @@ -416,7 +462,9 @@ describe('ResultPreview', () => { }) it('should render loading spinner icon when loading', () => { - const { container } = render(<ResultPreview {...defaultProps} isRunning={true} outputs={undefined} />) + const { container } = render( + <ResultPreview {...defaultProps} isRunning={true} outputs={undefined} />, + ) const spinner = container.querySelector('.animate-spin') expect(spinner)!.toBeInTheDocument() @@ -426,7 +474,9 @@ describe('ResultPreview', () => { render(<ResultPreview {...defaultProps} isRunning={false} error="Something went wrong" />) expect(screen.getByText('pipeline.result.resultPreview.error'))!.toBeInTheDocument() - expect(screen.getByRole('button', { name: /pipeline\.result\.resultPreview\.viewDetails/i }))!.toBeInTheDocument() + expect( + screen.getByRole('button', { name: /pipeline\.result\.resultPreview\.viewDetails/i }), + )!.toBeInTheDocument() }) it('should render outputs when available', () => { @@ -455,7 +505,14 @@ describe('ResultPreview', () => { }) it('should not render error when isRunning is true', () => { - render(<ResultPreview {...defaultProps} isRunning={true} error="Error message" outputs={undefined} />) + render( + <ResultPreview + {...defaultProps} + isRunning={true} + error="Error message" + outputs={undefined} + />, + ) expect(screen.getByText('pipeline.result.resultPreview.loading'))!.toBeInTheDocument() expect(screen.queryByText('pipeline.result.resultPreview.error')).not.toBeInTheDocument() @@ -497,19 +554,13 @@ describe('ResultPreview', () => { }) it('should format and pass previewChunks to ChunkCardList', () => { - const outputs = createGeneralChunkOutputs([ - { content: 'Chunk 1' }, - { content: 'Chunk 2' }, - ]) + const outputs = createGeneralChunkOutputs([{ content: 'Chunk 1' }, { content: 'Chunk 2' }]) render(<ResultPreview {...defaultProps} outputs={outputs} />) const chunkList = screen.getByTestId('chunk-card-list') const chunkInfo = JSON.parse(chunkList.getAttribute('data-chunk-info') || '[]') - expect(chunkInfo).toEqual([ - { content: 'Chunk 1' }, - { content: 'Chunk 2' }, - ]) + expect(chunkInfo).toEqual([{ content: 'Chunk 1' }, { content: 'Chunk 2' }]) }) it('should handle parent-child outputs', () => { @@ -524,9 +575,7 @@ describe('ResultPreview', () => { }) it('should handle QA outputs', () => { - const outputs = createQAChunkOutputs([ - { question: 'Q1', answer: 'A1' }, - ]) + const outputs = createQAChunkOutputs([{ question: 'Q1', answer: 'A1' }]) render(<ResultPreview {...defaultProps} outputs={outputs} />) @@ -561,7 +610,9 @@ describe('ResultPreview', () => { describe('onSwitchToDetail prop', () => { it('should be called when view details button is clicked', () => { const onSwitchToDetail = vi.fn() - render(<ResultPreview {...defaultProps} error="Error" onSwitchToDetail={onSwitchToDetail} />) + render( + <ResultPreview {...defaultProps} error="Error" onSwitchToDetail={onSwitchToDetail} />, + ) fireEvent.click(screen.getByRole('button', { name: /viewDetails/i })) @@ -571,7 +622,9 @@ describe('ResultPreview', () => { it('should not be called automatically on render', () => { const onSwitchToDetail = vi.fn() - render(<ResultPreview {...defaultProps} error="Error" onSwitchToDetail={onSwitchToDetail} />) + render( + <ResultPreview {...defaultProps} error="Error" onSwitchToDetail={onSwitchToDetail} />, + ) expect(onSwitchToDetail).not.toHaveBeenCalled() }) @@ -649,7 +702,9 @@ describe('ResultPreview', () => { describe('Event Handlers', () => { it('should call onSwitchToDetail when view details button is clicked', () => { const onSwitchToDetail = vi.fn() - render(<ResultPreview {...defaultProps} error="Test error" onSwitchToDetail={onSwitchToDetail} />) + render( + <ResultPreview {...defaultProps} error="Test error" onSwitchToDetail={onSwitchToDetail} />, + ) fireEvent.click(screen.getByRole('button', { name: /viewDetails/i })) @@ -658,7 +713,9 @@ describe('ResultPreview', () => { it('should handle multiple clicks on view details button', () => { const onSwitchToDetail = vi.fn() - render(<ResultPreview {...defaultProps} error="Test error" onSwitchToDetail={onSwitchToDetail} />) + render( + <ResultPreview {...defaultProps} error="Test error" onSwitchToDetail={onSwitchToDetail} />, + ) const button = screen.getByRole('button', { name: /viewDetails/i }) fireEvent.click(button) @@ -715,7 +772,12 @@ describe('ResultPreview', () => { rerender(<ResultPreview {...defaultProps} isRunning={true} />) rerender(<ResultPreview {...defaultProps} isRunning={false} error="Error" />) - rerender(<ResultPreview {...defaultProps} outputs={createGeneralChunkOutputs([{ content: 'Test' }])} />) + rerender( + <ResultPreview + {...defaultProps} + outputs={createGeneralChunkOutputs([{ content: 'Test' }])} + />, + ) rerender(<ResultPreview {...defaultProps} />) expect(screen.queryByTestId('chunk-card-list')).not.toBeInTheDocument() @@ -803,14 +865,18 @@ describe('ResultPreview', () => { it('should have correct container classes for loading state', () => { const { container } = render(<ResultPreview {...defaultProps} isRunning={true} />) - const loadingContainer = container.querySelector('.flex.grow.flex-col.items-center.justify-center') + const loadingContainer = container.querySelector( + '.flex.grow.flex-col.items-center.justify-center', + ) expect(loadingContainer)!.toBeInTheDocument() }) it('should have correct container classes for error state', () => { const { container } = render(<ResultPreview {...defaultProps} error="Error" />) - const errorContainer = container.querySelector('.flex.grow.flex-col.items-center.justify-center') + const errorContainer = container.querySelector( + '.flex.grow.flex-col.items-center.justify-center', + ) expect(errorContainer)!.toBeInTheDocument() }) @@ -861,9 +927,24 @@ describe('State Transition Matrix', () => { { isRunning: false, outputs: undefined, error: undefined, expected: 'empty' }, { isRunning: true, outputs: undefined, error: undefined, expected: 'loading' }, { isRunning: false, outputs: undefined, error: 'Error', expected: 'error' }, - { isRunning: false, outputs: createGeneralChunkOutputs([{ content: 'Test' }]), error: undefined, expected: 'outputs' }, - { isRunning: true, outputs: createGeneralChunkOutputs([{ content: 'Test' }]), error: undefined, expected: 'outputs' }, - { isRunning: false, outputs: createGeneralChunkOutputs([{ content: 'Test' }]), error: 'Error', expected: 'both' }, + { + isRunning: false, + outputs: createGeneralChunkOutputs([{ content: 'Test' }]), + error: undefined, + expected: 'outputs', + }, + { + isRunning: true, + outputs: createGeneralChunkOutputs([{ content: 'Test' }]), + error: undefined, + expected: 'outputs', + }, + { + isRunning: false, + outputs: createGeneralChunkOutputs([{ content: 'Test' }]), + error: 'Error', + expected: 'both', + }, { isRunning: true, outputs: undefined, error: 'Error', expected: 'loading' }, ] diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts index 376b529d403a87..adef49e7e13604 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts @@ -44,16 +44,24 @@ describe('result preview utils', () => { const fullDoc = formatPreviewChunks({ chunk_structure: ChunkingMode.parentChild, parent_mode: 'full-doc', - preview: [ - { content: 'Parent 1', child_chunks: ['c1', 'c2', 'c3'] }, - ], + preview: [{ content: 'Parent 1', child_chunks: ['c1', 'c2', 'c3'] }], }) expect(paragraph).toEqual({ parent_mode: 'paragraph', parent_child_chunks: [ - { parent_content: 'Parent 1', parent_summary: undefined, child_contents: ['c1', 'c2', 'c3'], parent_mode: 'paragraph' }, - { parent_content: 'Parent 2', parent_summary: undefined, child_contents: ['c4'], parent_mode: 'paragraph' }, + { + parent_content: 'Parent 1', + parent_summary: undefined, + child_contents: ['c1', 'c2', 'c3'], + parent_mode: 'paragraph', + }, + { + parent_content: 'Parent 2', + parent_summary: undefined, + child_contents: ['c4'], + parent_mode: 'paragraph', + }, ], }) expect(fullDoc).toEqual({ diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/index.tsx index ad5d91f65feedb..12cd59f647c647 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/index.tsx @@ -14,12 +14,7 @@ type ResultTextProps = { onSwitchToDetail: () => void } -const ResultPreview = ({ - isRunning, - outputs, - error, - onSwitchToDetail, -}: ResultTextProps) => { +const ResultPreview = ({ isRunning, outputs, error, onSwitchToDetail }: ResultTextProps) => { const { t } = useTranslation() const previewChunks = useMemo(() => { @@ -31,14 +26,18 @@ const ResultPreview = ({ {isRunning && !outputs && ( <div className="flex grow flex-col items-center justify-center gap-y-2 pb-20"> <RiLoader2Line className="size-4 animate-spin text-text-tertiary" /> - <div className="system-sm-regular text-text-tertiary">{t($ => $['result.resultPreview.loading'], { ns: 'pipeline' })}</div> + <div className="system-sm-regular text-text-tertiary"> + {t(($) => $['result.resultPreview.loading'], { ns: 'pipeline' })} + </div> </div> )} {!isRunning && error && ( <div className="flex grow flex-col items-center justify-center gap-y-2 pb-20"> - <div className="system-sm-regular text-text-tertiary">{t($ => $['result.resultPreview.error'], { ns: 'pipeline' })}</div> + <div className="system-sm-regular text-text-tertiary"> + {t(($) => $['result.resultPreview.error'], { ns: 'pipeline' })} + </div> <Button onClick={onSwitchToDetail}> - {t($ => $['result.resultPreview.viewDetails'], { ns: 'pipeline' })} + {t(($) => $['result.resultPreview.viewDetails'], { ns: 'pipeline' })} </Button> </div> )} @@ -47,8 +46,17 @@ const ResultPreview = ({ <ChunkCardList chunkType={outputs.chunk_structure} chunkInfo={previewChunks} /> <div className="mt-1 flex items-center gap-x-2 system-xs-regular text-text-tertiary"> <div className="h-px flex-1 bg-linear-to-r from-background-gradient-mask-transparent to-divider-regular" /> - <span className="shrink-0truncate" title={t($ => $['result.resultPreview.footerTip'], { ns: 'pipeline', count: RAG_PIPELINE_PREVIEW_CHUNK_NUM })}> - {t($ => $['result.resultPreview.footerTip'], { ns: 'pipeline', count: RAG_PIPELINE_PREVIEW_CHUNK_NUM })} + <span + className="shrink-0truncate" + title={t(($) => $['result.resultPreview.footerTip'], { + ns: 'pipeline', + count: RAG_PIPELINE_PREVIEW_CHUNK_NUM, + })} + > + {t(($) => $['result.resultPreview.footerTip'], { + ns: 'pipeline', + count: RAG_PIPELINE_PREVIEW_CHUNK_NUM, + })} </span> <div className="h-px flex-1 bg-linear-to-l from-background-gradient-mask-transparent to-divider-regular" /> </div> diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils.ts b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils.ts index de3666224f0794..208abeda4741b6 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils.ts +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils.ts @@ -1,4 +1,9 @@ -import type { ChunkInfo, GeneralChunks, ParentChildChunks, QAChunks } from '../../../../chunk-card-list/types' +import type { + ChunkInfo, + GeneralChunks, + ParentChildChunks, + QAChunks, +} from '../../../../chunk-card-list/types' import type { ParentMode } from '@/models/datasets' import { RAG_PIPELINE_PREVIEW_CHUNK_NUM } from '@/config' import { ChunkingMode } from '@/models/datasets' @@ -76,20 +81,16 @@ const formatQAChunks = (outputs: any) => { } export const formatPreviewChunks = (outputs: any): ChunkInfo | undefined => { - if (!outputs) - return undefined + if (!outputs) return undefined const chunkingMode = outputs.chunk_structure const parentMode = outputs.parent_mode - if (chunkingMode === ChunkingMode.text) - return formatGeneralChunks(outputs) + if (chunkingMode === ChunkingMode.text) return formatGeneralChunks(outputs) - if (chunkingMode === ChunkingMode.parentChild) - return formatParentChildChunks(outputs, parentMode) + if (chunkingMode === ChunkingMode.parentChild) return formatParentChildChunks(outputs, parentMode) - if (chunkingMode === ChunkingMode.qa) - return formatQAChunks(outputs) + if (chunkingMode === ChunkingMode.qa) return formatQAChunks(outputs) return undefined } diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/index.spec.tsx index ec59fbd547dc3e..30182f8947a8c7 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/index.spec.tsx @@ -540,11 +540,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) expect(screen.getByRole('button', { name: 'runLog.result' })).toBeInTheDocument() @@ -557,11 +553,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() const { container } = render( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) const tabsContainer = container.firstChild @@ -579,11 +571,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) const buttons = screen.getAllByRole('button') @@ -598,11 +586,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) const resultTab = screen.getByRole('button', { name: 'runLog.result' }) @@ -619,11 +603,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="DETAIL" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="DETAIL" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) const resultTab = screen.getByRole('button', { name: 'runLog.result' }) @@ -684,11 +664,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) const buttons = screen.getAllByRole('button') @@ -701,11 +677,7 @@ describe('Tabs', () => { const mockSwitchTab = vi.fn() render( - <Tabs - currentTab="RESULT" - workflowRunningData={undefined} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={undefined} switchTab={mockSwitchTab} />, ) const buttons = screen.getAllByRole('button') @@ -720,11 +692,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) const buttons = screen.getAllByRole('button') @@ -740,11 +708,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) fireEvent.click(screen.getByRole('button', { name: 'runLog.detail' })) @@ -759,11 +723,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="DETAIL" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="DETAIL" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) fireEvent.click(screen.getByRole('button', { name: 'runLog.result' })) @@ -775,11 +735,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) fireEvent.click(screen.getByRole('button', { name: 'runLog.detail' })) @@ -791,11 +747,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) fireEvent.click(screen.getByRole('button', { name: 'runLog.tracing' })) @@ -805,13 +757,7 @@ describe('Tabs', () => { it('should not call switchTab when tabs are disabled', () => { const mockSwitchTab = vi.fn() - render( - <Tabs - currentTab="RESULT" - workflowRunningData={undefined} - switchTab={mockSwitchTab} - />, - ) + render(<Tabs currentTab="RESULT" workflowRunningData={undefined} switchTab={mockSwitchTab} />) const buttons = screen.getAllByRole('button') buttons.forEach((button) => { @@ -826,11 +772,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) fireEvent.click(screen.getByRole('button', { name: 'runLog.result' })) @@ -874,24 +816,18 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() const { rerender } = render( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) expect(screen.getByRole('button', { name: 'runLog.result' })).toHaveClass('text-text-primary') rerender( - <Tabs - currentTab="DETAIL" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="DETAIL" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) - expect(screen.getByRole('button', { name: 'runLog.result' })).toHaveClass('text-text-tertiary') + expect(screen.getByRole('button', { name: 'runLog.result' })).toHaveClass( + 'text-text-tertiary', + ) expect(screen.getByRole('button', { name: 'runLog.detail' })).toHaveClass('text-text-primary') }) @@ -900,11 +836,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() const { rerender } = render( - <Tabs - currentTab="RESULT" - workflowRunningData={undefined} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={undefined} switchTab={mockSwitchTab} />, ) const buttons = screen.getAllByRole('button') @@ -913,11 +845,7 @@ describe('Tabs', () => { }) rerender( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) const updatedButtons = screen.getAllByRole('button') @@ -932,13 +860,7 @@ describe('Tabs', () => { const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - render( - <Tabs - currentTab="" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, - ) + render(<Tabs currentTab="" workflowRunningData={workflowData} switchTab={mockSwitchTab} />) const buttons = screen.getAllByRole('button') buttons.forEach((button) => { @@ -951,14 +873,12 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="result" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="result" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) - expect(screen.getByRole('button', { name: 'runLog.result' })).toHaveClass('text-text-tertiary') + expect(screen.getByRole('button', { name: 'runLog.result' })).toHaveClass( + 'text-text-tertiary', + ) }) it('should handle whitespace in currentTab', () => { @@ -966,14 +886,12 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab=" RESULT " - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab=" RESULT " workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) - expect(screen.getByRole('button', { name: 'runLog.result' })).toHaveClass('text-text-tertiary') + expect(screen.getByRole('button', { name: 'runLog.result' })).toHaveClass( + 'text-text-tertiary', + ) }) it('should render correctly with minimal workflowRunningData', () => { @@ -1006,11 +924,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) const buttons = screen.getAllByRole('button') @@ -1026,11 +940,7 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() render( - <Tabs - currentTab="DETAIL" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="DETAIL" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) const resultTab = screen.getByRole('button', { name: 'runLog.result' }) @@ -1091,7 +1001,9 @@ describe('Tabs', () => { />, ) - expect(screen.getByRole('button', { name: 'runLog.tracing' })).toHaveClass('text-text-primary') + expect(screen.getByRole('button', { name: 'runLog.tracing' })).toHaveClass( + 'text-text-primary', + ) }) it('should transition from disabled to enabled state', () => { @@ -1099,22 +1011,14 @@ describe('Tabs', () => { const workflowData = createWorkflowRunningData() const { rerender } = render( - <Tabs - currentTab="RESULT" - workflowRunningData={undefined} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={undefined} switchTab={mockSwitchTab} />, ) fireEvent.click(screen.getByRole('button', { name: 'runLog.detail' })) expect(mockSwitchTab).not.toHaveBeenCalled() rerender( - <Tabs - currentTab="RESULT" - workflowRunningData={workflowData} - switchTab={mockSwitchTab} - />, + <Tabs currentTab="RESULT" workflowRunningData={workflowData} switchTab={mockSwitchTab} />, ) fireEvent.click(screen.getByRole('button', { name: 'runLog.detail' })) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx index 0597bc3de86925..810664edc63936 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx @@ -49,14 +49,7 @@ describe('Tab', () => { }) it('should disable the tab when workflow run data is unavailable', () => { - render( - <Tab - isActive={false} - label="Trace" - value="trace" - onClick={vi.fn()} - />, - ) + render(<Tab isActive={false} label="Trace" value="trace" onClick={vi.fn()} />) expect(screen.getByRole('button', { name: 'Trace' })).toBeDisabled() expect(screen.getByRole('button', { name: 'Trace' })).toHaveClass('opacity-30') diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/index.tsx index dd17fe10c84ae9..2a19c79ea6d65a 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/index.tsx @@ -9,31 +9,27 @@ type TabsProps = { switchTab: (tab: string) => void } -const Tabs = ({ - currentTab, - workflowRunningData, - switchTab, -}: TabsProps) => { +const Tabs = ({ currentTab, workflowRunningData, switchTab }: TabsProps) => { const { t } = useTranslation() return ( <div className="flex shrink-0 items-center gap-x-6 border-b-[0.5px] border-divider-subtle px-4"> <Tab isActive={currentTab === 'RESULT'} - label={t($ => $.result, { ns: 'runLog' })} + label={t(($) => $.result, { ns: 'runLog' })} value="RESULT" workflowRunningData={workflowRunningData} onClick={switchTab} /> <Tab isActive={currentTab === 'DETAIL'} - label={t($ => $.detail, { ns: 'runLog' })} + label={t(($) => $.detail, { ns: 'runLog' })} value="DETAIL" workflowRunningData={workflowRunningData} onClick={switchTab} /> <Tab isActive={currentTab === 'TRACING'} - label={t($ => $.tracing, { ns: 'runLog' })} + label={t(($) => $.tracing, { ns: 'runLog' })} value="TRACING" workflowRunningData={workflowRunningData} onClick={switchTab} diff --git a/web/app/components/rag-pipeline/components/panel/test-run/types.ts b/web/app/components/rag-pipeline/components/panel/test-run/types.ts index d129487fe16d33..ae103012775198 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/types.ts +++ b/web/app/components/rag-pipeline/components/panel/test-run/types.ts @@ -4,7 +4,7 @@ export const TestRunStep = { dataSource: 'dataSource', documentProcessing: 'documentProcessing', } as const -export type TestRunStep = typeof TestRunStep[keyof typeof TestRunStep] +export type TestRunStep = (typeof TestRunStep)[keyof typeof TestRunStep] export type DataSourceOption = { label: string diff --git a/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx b/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx index 0f95c0977779e8..a3928739a4fcfe 100644 --- a/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx +++ b/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx @@ -15,11 +15,7 @@ import { useWorkflowStore } from '@/app/components/workflow/store' type PublishAsKnowledgePipelineModalProps = { confirmDisabled?: boolean onCancel: () => void - onConfirm: ( - name: string, - icon: IconInfo, - description?: string, - ) => Promise<void> + onConfirm: (name: string, icon: IconInfo, description?: string) => Promise<void> } const PublishAsKnowledgePipelineModal = ({ confirmDisabled, @@ -54,26 +50,20 @@ const PublishAsKnowledgePipelineModal = ({ }, []) const handleConfirm = () => { - if (confirmDisabled) - return + if (confirmDisabled) return - onConfirm( - pipelineName?.trim() || '', - pipelineIcon, - description?.trim(), - ) + onConfirm(pipelineName?.trim() || '', pipelineIcon, description?.trim()) } return ( <> <Dialog open> <DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none p-0! text-left align-middle"> - <div className="relative flex items-center p-6 pr-14 pb-3 title-2xl-semi-bold text-text-primary"> - {t($ => $['common.publishAs'], { ns: 'pipeline' })} + {t(($) => $['common.publishAs'], { ns: 'pipeline' })} <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-5 right-5 flex size-8 cursor-pointer items-center justify-center border-none bg-transparent p-0" onClick={onCancel} > @@ -84,17 +74,22 @@ const PublishAsKnowledgePipelineModal = ({ <div className="mb-5 flex"> <div className="mr-3 grow"> <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $['common.publishAsPipeline.name'], { ns: 'pipeline' })} + {t(($) => $['common.publishAsPipeline.name'], { ns: 'pipeline' })} </div> <Input value={pipelineName} - onChange={e => setPipelineName(e.target.value)} - placeholder={t($ => $['common.publishAsPipeline.namePlaceholder'], { ns: 'pipeline' }) || ''} + onChange={(e) => setPipelineName(e.target.value)} + placeholder={ + t(($) => $['common.publishAsPipeline.namePlaceholder'], { ns: 'pipeline' }) || + '' + } /> </div> <AppIcon size="xxl" - onClick={() => { setShowAppIconPicker(true) }} + onClick={() => { + setShowAppIconPicker(true) + }} className="mt-2 shrink-0 cursor-pointer" iconType={pipelineIcon?.icon_type} icon={pipelineIcon?.icon} @@ -104,38 +99,41 @@ const PublishAsKnowledgePipelineModal = ({ </div> <div> <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $['common.publishAsPipeline.description'], { ns: 'pipeline' })} + {t(($) => $['common.publishAsPipeline.description'], { ns: 'pipeline' })} </div> <Textarea className="resize-none" - aria-label={t($ => $['common.publishAsPipeline.description'], { ns: 'pipeline' })} - placeholder={t($ => $['common.publishAsPipeline.descriptionPlaceholder'], { ns: 'pipeline' }) || ''} + aria-label={t(($) => $['common.publishAsPipeline.description'], { ns: 'pipeline' })} + placeholder={ + t(($) => $['common.publishAsPipeline.descriptionPlaceholder'], { + ns: 'pipeline', + }) || '' + } value={description} - onValueChange={value => setDescription(value)} + onValueChange={(value) => setDescription(value)} /> </div> </div> <div className="flex items-center justify-end px-6 py-5"> - <Button - className="mr-2" - onClick={onCancel} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button className="mr-2" onClick={onCancel}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button disabled={!pipelineName?.trim() || confirmDisabled} variant="primary" onClick={() => handleConfirm()} > - {t($ => $['common.publish'], { ns: 'workflow' })} + {t(($) => $['common.publish'], { ns: 'workflow' })} </Button> </div> {showAppIconPicker && ( <AppIconPicker open={showAppIconPicker} - initialEmoji={pipelineIcon.icon_type === 'emoji' - ? { icon: pipelineIcon.icon, background: pipelineIcon.icon_background } - : undefined} + initialEmoji={ + pipelineIcon.icon_type === 'emoji' + ? { icon: pipelineIcon.icon, background: pipelineIcon.icon_background } + : undefined + } onOpenChange={setShowAppIconPicker} onSelect={handleSelectIcon} /> diff --git a/web/app/components/rag-pipeline/components/publish-toast.tsx b/web/app/components/rag-pipeline/components/publish-toast.tsx index 174c87736f56d7..e22818d28a3e0c 100644 --- a/web/app/components/rag-pipeline/components/publish-toast.tsx +++ b/web/app/components/rag-pipeline/components/publish-toast.tsx @@ -1,36 +1,28 @@ -import { - RiCloseLine, - RiInformation2Fill, -} from '@remixicon/react' -import { - memo, - useState, -} from 'react' +import { RiCloseLine, RiInformation2Fill } from '@remixicon/react' +import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore } from '@/app/components/workflow/store' const PublishToast = () => { const { t } = useTranslation() - const publishedAt = useStore(s => s.publishedAt) + const publishedAt = useStore((s) => s.publishedAt) const [hideToast, setHideToast] = useState(false) - if (publishedAt || hideToast) - return null + if (publishedAt || hideToast) return null return ( <div className="pointer-events-none absolute right-0 bottom-[45px] left-0 z-10 flex justify-center"> - <div - className="relative flex w-[420px] space-x-1 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg" - > - <div className="pointer-events-none absolute inset-0 bg-linear-to-r from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent opacity-[0.4]"> - </div> + <div className="relative flex w-[420px] space-x-1 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg"> + <div className="pointer-events-none absolute inset-0 bg-linear-to-r from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent opacity-[0.4]"></div> <div className="flex size-6 items-center justify-center"> <RiInformation2Fill className="text-text-accent" /> </div> <div className="p-1"> - <div className="mb-1 system-sm-semibold text-text-primary">{t($ => $['publishToast.title'], { ns: 'pipeline' })}</div> + <div className="mb-1 system-sm-semibold text-text-primary"> + {t(($) => $['publishToast.title'], { ns: 'pipeline' })} + </div> <div className="system-xs-regular text-text-secondary"> - {t($ => $['publishToast.desc'], { ns: 'pipeline' })} + {t(($) => $['publishToast.desc'], { ns: 'pipeline' })} </div> </div> <div diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-children.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-children.tsx index c5539114bed615..066f035ff88189 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-children.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-children.tsx @@ -1,14 +1,8 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' -import { - memo, - useState, -} from 'react' +import { memo, useState } from 'react' import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants' import DSLExportConfirmModal from '@/app/components/workflow/dsl-export-confirm-modal' -import { - useDSL, - usePanelInteractions, -} from '@/app/components/workflow/hooks' +import { useDSL, usePanelInteractions } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useEventEmitterContextContext } from '@/context/event-emitter' import PluginDependency from '../../workflow/plugin-dependency' @@ -22,46 +16,36 @@ import UpdateDSLModal from './update-dsl-modal' const RagPipelineChildren = () => { const { eventEmitter } = useEventEmitterContextContext() const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([]) - const showImportDSLModal = useStore(s => s.showImportDSLModal) - const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal) - const canImportExportDSL = useHooksStore(s => s.accessControl.canImportExportDSL) - const { - handlePaneContextmenuCancel, - } = usePanelInteractions() - const { - exportCheck, - handleExportDSL, - } = useDSL() + const showImportDSLModal = useStore((s) => s.showImportDSLModal) + const setShowImportDSLModal = useStore((s) => s.setShowImportDSLModal) + const canImportExportDSL = useHooksStore((s) => s.accessControl.canImportExportDSL) + const { handlePaneContextmenuCancel } = usePanelInteractions() + const { exportCheck, handleExportDSL } = useDSL() // Initialize RAG pipeline search functionality useRagPipelineSearch() eventEmitter?.useSubscription((v: any) => { - if (v.type === DSL_EXPORT_CHECK) - setSecretEnvList(v.payload.data as EnvironmentVariable[]) + if (v.type === DSL_EXPORT_CHECK) setSecretEnvList(v.payload.data as EnvironmentVariable[]) }) return ( <> <PluginDependency /> - { - canImportExportDSL && showImportDSLModal && ( - <UpdateDSLModal - onCancel={() => setShowImportDSLModal(false)} - onBackup={exportCheck!} - onImport={handlePaneContextmenuCancel} - /> - ) - } - { - canImportExportDSL && secretEnvList.length > 0 && ( - <DSLExportConfirmModal - envList={secretEnvList} - onConfirm={handleExportDSL!} - onClose={() => setSecretEnvList([])} - /> - ) - } + {canImportExportDSL && showImportDSLModal && ( + <UpdateDSLModal + onCancel={() => setShowImportDSLModal(false)} + onBackup={exportCheck!} + onImport={handlePaneContextmenuCancel} + /> + )} + {canImportExportDSL && secretEnvList.length > 0 && ( + <DSLExportConfirmModal + envList={secretEnvList} + onConfirm={handleExportDSL!} + onClose={() => setSecretEnvList([])} + /> + )} <RagPipelineHeader /> <RagPipelinePanel /> <PublishToast /> diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx index 0943c5bdb18351..33aead3e4938ce 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx @@ -2,7 +2,6 @@ import type { PropsWithChildren, ReactNode } from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { WorkflowRunningStatus } from '@/app/components/workflow/types' - import RagPipelineHeader from '../index' import InputFieldButton from '../input-field-button' import Publisher from '../publisher' @@ -51,7 +50,9 @@ vi.mock('@/app/components/workflow/store', () => ({ })) vi.mock('@/app/components/workflow/hooks-store', () => ({ - useHooksStore: <T,>(selector: (state: { accessControl: { canRun: boolean, canReleaseAndVersion: boolean } }) => T): T => + useHooksStore: <T,>( + selector: (state: { accessControl: { canRun: boolean; canReleaseAndVersion: boolean } }) => T, + ): T => selector({ accessControl: { canRun: true, @@ -81,8 +82,11 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/workflow/header', () => ({ - default: ({ normal, viewHistory }: { - normal?: { components?: { left?: ReactNode, middle?: ReactNode }, runAndHistoryProps?: unknown } + default: ({ + normal, + viewHistory, + }: { + normal?: { components?: { left?: ReactNode; middle?: ReactNode }; runAndHistoryProps?: unknown } viewHistory?: { viewHistoryProps?: unknown } }) => ( <div data-testid="workflow-header"> @@ -102,7 +106,9 @@ vi.mock('@/next/navigation', () => ({ vi.mock('@/next/link', () => ({ default: ({ children, href, ...props }: PropsWithChildren<{ href: string }>) => ( - <a href={href} {...props}>{children}</a> + <a href={href} {...props}> + {children} + </a> ), })) @@ -138,13 +144,14 @@ let mockCurrentUserId = 'user-1' let mockIsLoadingWorkspacePermissionKeys = false let mockWorkspacePermissionKeys: string[] = [] vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: Record<string, unknown>) => unknown) => selector({ - dataset: { - permission_keys: mockDatasetPermissionKeys, - maintainer: mockDatasetMaintainer, - }, - mutateDatasetRes: mockMutateDatasetRes, - }), + useDatasetDetailContextWithSelector: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + dataset: { + permission_keys: mockDatasetPermissionKeys, + maintainer: mockDatasetMaintainer, + }, + mutateDatasetRes: mockMutateDatasetRes, + }), })) vi.mock('@/context/account-state', async (importOriginal) => { @@ -204,7 +211,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -217,8 +225,9 @@ vi.mock('@/context/modal-context', () => ({ let mockProviderContextValue = createMockProviderContextValue() vi.mock('@/context/provider-context', () => ({ useProviderContext: () => mockProviderContextValue, - useProviderContextSelector: <T,>(selector: (s: ReturnType<typeof createMockProviderContextValue>) => T): T => - selector(mockProviderContextValue), + useProviderContextSelector: <T,>( + selector: (s: ReturnType<typeof createMockProviderContextValue>) => T, + ): T => selector(mockProviderContextValue), })) const mockEventEmitter = { @@ -254,10 +263,18 @@ const toastMocks = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { - success: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'success', message, ...options })), - error: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'error', message, ...options })), - warning: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'warning', message, ...options })), - info: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'info', message, ...options })), + success: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'success', message, ...options }), + ), + error: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'info', message, ...options }), + ), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -269,23 +286,39 @@ vi.mock('ahooks', () => ({ return [ value, { - setTrue: vi.fn(() => { value = true }), - setFalse: vi.fn(() => { value = false }), - toggle: vi.fn(() => { value = !value }), + setTrue: vi.fn(() => { + value = true + }), + setFalse: vi.fn(() => { + value = false + }), + toggle: vi.fn(() => { + value = !value + }), }, ] }, })) vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({ - default: ({ onConfirm, onCancel }: { + default: ({ + onConfirm, + onCancel, + }: { onConfirm: (name: string, icon: unknown, description?: string) => void onCancel: () => void confirmDisabled?: boolean }) => ( <div data-testid="publish-as-pipeline-modal"> - <button data-testid="modal-confirm" onClick={() => onConfirm('test-name', { type: 'emoji', emoji: '📦' }, 'test-description')}>Confirm</button> - <button data-testid="modal-cancel" onClick={onCancel}>Cancel</button> + <button + data-testid="modal-confirm" + onClick={() => onConfirm('test-name', { type: 'emoji', emoji: '📦' }, 'test-description')} + > + Confirm + </button> + <button data-testid="modal-cancel" onClick={onCancel}> + Cancel + </button> </div> ), })) @@ -407,7 +440,8 @@ describe('InputFieldButton', () => { describe('Edge Cases', () => { it('should handle undefined setShowInputFieldPanel gracefully', () => { - mockStoreState.setShowInputFieldPanel = undefined as unknown as typeof mockSetShowInputFieldPanel + mockStoreState.setShowInputFieldPanel = + undefined as unknown as typeof mockSetShowInputFieldPanel render(<InputFieldButton />) @@ -436,7 +470,9 @@ describe('Publisher', () => { it('should render publish trigger button', () => { render(<Publisher />) - expect(screen.getByRole('button', { name: /workflow\.common\.publish/i }))!.toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflow\.common\.publish/i }), + )!.toBeInTheDocument() }) }) @@ -596,7 +632,9 @@ describe('Popup', () => { const button = screen.getByText(/pipeline.common.goToAddDocuments/i).closest('button')! fireEvent.click(button) - expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-id/documents/create-from-pipeline') + expect(mockPush).toHaveBeenCalledWith( + '/datasets/test-dataset-id/documents/create-from-pipeline', + ) }) it('should show pricing modal when clicking publish as template without permission', () => { @@ -899,9 +937,11 @@ describe('RunMode', () => { } let subscriptionCallback: ((v: { type: string }) => void) | null = null - mockEventEmitter.useSubscription.mockImplementation((callback: (v: { type: string }) => void) => { - subscriptionCallback = callback - }) + mockEventEmitter.useSubscription.mockImplementation( + (callback: (v: { type: string }) => void) => { + subscriptionCallback = callback + }, + ) render(<RunMode />) @@ -918,9 +958,11 @@ describe('RunMode', () => { } let subscriptionCallback: ((v: { type: string }) => void) | null = null - mockEventEmitter.useSubscription.mockImplementation((callback: (v: { type: string }) => void) => { - subscriptionCallback = callback - }) + mockEventEmitter.useSubscription.mockImplementation( + (callback: (v: { type: string }) => void) => { + subscriptionCallback = callback + }, + ) render(<RunMode />) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx index 493f3c30148566..90e9f9e9345f83 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx @@ -1,22 +1,22 @@ import { fireEvent, render, screen } from '@testing-library/react' import InputFieldButton from '../input-field-button' -const { - mockSetShowInputFieldPanel, - mockSetShowEnvPanel, -} = vi.hoisted(() => ({ +const { mockSetShowInputFieldPanel, mockSetShowEnvPanel } = vi.hoisted(() => ({ mockSetShowInputFieldPanel: vi.fn(), mockSetShowEnvPanel: vi.fn(), })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { - setShowInputFieldPanel: typeof mockSetShowInputFieldPanel - setShowEnvPanel: typeof mockSetShowEnvPanel - }) => unknown) => selector({ - setShowInputFieldPanel: mockSetShowInputFieldPanel, - setShowEnvPanel: mockSetShowEnvPanel, - }), + useStore: ( + selector: (state: { + setShowInputFieldPanel: typeof mockSetShowInputFieldPanel + setShowEnvPanel: typeof mockSetShowEnvPanel + }) => unknown, + ) => + selector({ + setShowInputFieldPanel: mockSetShowInputFieldPanel, + setShowEnvPanel: mockSetShowEnvPanel, + }), })) describe('InputFieldButton', () => { diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx index 7c02bd65b2331d..d986b5ecfd948b 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import RunMode from '../run-mode' const mockHandleWorkflowStartRunInWorkflow = vi.fn() @@ -8,7 +7,7 @@ const mockHandleStopRun = vi.fn() const mockSetIsPreparingDataSource = vi.fn() const mockSetShowDebugAndPreviewPanel = vi.fn() -let mockWorkflowRunningData: { task_id: string, result: { status: string } } | undefined +let mockWorkflowRunningData: { task_id: string; result: { status: string } } | undefined let mockIsPreparingDataSource = false vi.mock('@/app/components/workflow/hooks', () => ({ useWorkflowRun: () => ({ @@ -36,9 +35,10 @@ vi.mock('@/app/components/workflow/store', () => ({ })) vi.mock('@/app/components/workflow/hooks-store', () => ({ - useHooksStore: (selector: (state: { accessControl: { canRun: boolean } }) => unknown) => selector({ - accessControl: { canRun: true }, - }), + useHooksStore: (selector: (state: { accessControl: { canRun: boolean } }) => unknown) => + selector({ + accessControl: { canRun: true }, + }), })) vi.mock('@/app/components/workflow/types', () => ({ @@ -56,7 +56,7 @@ vi.mock('@/context/event-emitter', () => ({ })) vi.mock('@langgenius/dify-ui/cn', () => ({ - cn: (...args: unknown[]) => args.filter(a => typeof a === 'string').join(' '), + cn: (...args: unknown[]) => args.filter((a) => typeof a === 'string').join(' '), })) vi.mock('@remixicon/react', () => ({ diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/index.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/index.tsx index a12edaa46d06b6..b2ba2669258138 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/index.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/index.tsx @@ -1,18 +1,13 @@ import type { HeaderProps } from '@/app/components/workflow/header' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import Header from '@/app/components/workflow/header' -import { - useStore, -} from '@/app/components/workflow/store' +import { useStore } from '@/app/components/workflow/store' import InputFieldButton from './input-field-button' import Publisher from './publisher' import RunMode from './run-mode' const RagPipelineHeader = () => { - const pipelineId = useStore(s => s.pipelineId) + const pipelineId = useStore((s) => s.pipelineId) const viewHistoryProps = useMemo(() => { return { @@ -41,9 +36,7 @@ const RagPipelineHeader = () => { } }, [viewHistoryProps]) - return ( - <Header {...headerProps} /> - ) + return <Header {...headerProps} /> } export default memo(RagPipelineHeader) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/input-field-button.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/input-field-button.tsx index 524f27ecdff5cc..0794153733b06d 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/input-field-button.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/input-field-button.tsx @@ -6,21 +6,17 @@ import { useStore } from '@/app/components/workflow/store' const InputFieldButton = () => { const { t } = useTranslation() - const setShowInputFieldPanel = useStore(state => state.setShowInputFieldPanel) - const setShowEnvPanel = useStore(state => state.setShowEnvPanel) + const setShowInputFieldPanel = useStore((state) => state.setShowInputFieldPanel) + const setShowEnvPanel = useStore((state) => state.setShowEnvPanel) const handleClick = useCallback(() => { setShowInputFieldPanel?.(true) setShowEnvPanel(false) }, [setShowInputFieldPanel, setShowEnvPanel]) return ( - <Button - variant="secondary" - className="flex gap-x-0.5" - onClick={handleClick} - > + <Button variant="secondary" className="flex gap-x-0.5" onClick={handleClick}> <InputField className="size-4" /> - <span className="px-0.5">{t($ => $.inputField, { ns: 'datasetPipeline' })}</span> + <span className="px-0.5">{t(($) => $.inputField, { ns: 'datasetPipeline' })}</span> </Button> ) } diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx index bc02a8f205a88b..f5f88980d61dd4 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx @@ -28,8 +28,7 @@ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { const triggerHotkey = (hotkey: string) => { const handler = hotkeyHandlers.get(hotkey) - if (!handler) - return + if (!handler) return act(() => { handler({ preventDefault: vi.fn() } as unknown as KeyboardEvent) @@ -50,21 +49,35 @@ vi.mock('@langgenius/dify-ui/button', () => ({ ), })) vi.mock('@langgenius/dify-ui/alert-dialog', () => ({ - AlertDialog: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => ( - open - ? ( - <div role="alertdialog"> - {children} - <button data-testid="alert-dialog-close" onClick={() => onOpenChange?.(false)}> - Close - </button> - </div> - ) - : null - ), + AlertDialog: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void + }) => + open ? ( + <div role="alertdialog"> + {children} + <button data-testid="alert-dialog-close" onClick={() => onOpenChange?.(false)}> + Close + </button> + </div> + ) : null, AlertDialogActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - AlertDialogCancelButton: ({ children }: { children: React.ReactNode }) => <button>{children}</button>, - AlertDialogConfirmButton: ({ children, onClick, disabled }: Record<string, unknown>) => <button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined}>{children as React.ReactNode}</button>, + AlertDialogCancelButton: ({ children }: { children: React.ReactNode }) => ( + <button>{children}</button> + ), + AlertDialogConfirmButton: ({ children, onClick, disabled }: Record<string, unknown>) => ( + <button + onClick={onClick as (() => void) | undefined} + disabled={disabled as boolean | undefined} + > + {children as React.ReactNode} + </button> + ), AlertDialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, AlertDialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, @@ -77,8 +90,10 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/next/link', () => ({ - default: ({ children, href, ...props }: { children: React.ReactNode, href: string }) => ( - <a href={href} {...props}>{children}</a> + default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => ( + <a href={href} {...props}> + {children} + </a> ), })) @@ -94,9 +109,12 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/workflow/hooks-store', () => ({ - useHooksStore: (selector: (state: { accessControl: { canReleaseAndVersion: boolean } }) => unknown) => selector({ - accessControl: { canReleaseAndVersion: true }, - }), + useHooksStore: ( + selector: (state: { accessControl: { canReleaseAndVersion: boolean } }) => unknown, + ) => + selector({ + accessControl: { canReleaseAndVersion: true }, + }), })) const mockPublishedAt = vi.fn(() => null as number | null) @@ -196,24 +214,32 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) const mockSetShowPricingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: <T,>(selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T): T => - selector({ setShowPricingModal: mockSetShowPricingModal }), + useModalContextSelector: <T,>( + selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T, + ): T => selector({ setShowPricingModal: mockSetShowPricingModal }), })) const mockIsAllowPublishAsCustomKnowledgePipelineTemplate = vi.fn(() => true) vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ - isAllowPublishAsCustomKnowledgePipelineTemplate: mockIsAllowPublishAsCustomKnowledgePipelineTemplate(), + isAllowPublishAsCustomKnowledgePipelineTemplate: + mockIsAllowPublishAsCustomKnowledgePipelineTemplate(), }), - useProviderContextSelector: <T,>(selector: (s: { isAllowPublishAsCustomKnowledgePipelineTemplate: boolean }) => T): T => - selector({ isAllowPublishAsCustomKnowledgePipelineTemplate: mockIsAllowPublishAsCustomKnowledgePipelineTemplate() }), + useProviderContextSelector: <T,>( + selector: (s: { isAllowPublishAsCustomKnowledgePipelineTemplate: boolean }) => T, + ): T => + selector({ + isAllowPublishAsCustomKnowledgePipelineTemplate: + mockIsAllowPublishAsCustomKnowledgePipelineTemplate(), + }), })) const toastMocks = vi.hoisted(() => ({ @@ -225,10 +251,18 @@ const toastMocks = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { - success: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'success', message, ...options })), - error: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'error', message, ...options })), - warning: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'warning', message, ...options })), - info: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'info', message, ...options })), + success: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'success', message, ...options }), + ), + error: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'info', message, ...options }), + ), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -242,10 +276,8 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ formatTimeFromNow: (timestamp: number) => { const diff = Date.now() / 1000 - timestamp - if (diff < 60) - return 'just now' - if (diff < 3600) - return `${Math.floor(diff / 60)} minutes ago` + if (diff < 60) return 'just now' + if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago` return new Date(timestamp * 1000).toLocaleDateString() }, }), @@ -280,7 +312,11 @@ vi.mock('@/service/use-workflow', () => ({ })) vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({ - default: ({ confirmDisabled, onConfirm, onCancel }: { + default: ({ + confirmDisabled, + onConfirm, + onCancel, + }: { confirmDisabled: boolean onConfirm: (name: string, icon: IconInfo, description?: string) => void onCancel: () => void @@ -289,31 +325,35 @@ vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({ <button data-testid="modal-confirm" disabled={confirmDisabled} - onClick={() => onConfirm('Test Pipeline', { type: 'emoji', emoji: '📚', background: '#fff' } as unknown as IconInfo, 'Test description')} + onClick={() => + onConfirm( + 'Test Pipeline', + { type: 'emoji', emoji: '📚', background: '#fff' } as unknown as IconInfo, + 'Test description', + ) + } > Confirm </button> - <button data-testid="modal-cancel" onClick={onCancel}>Cancel</button> + <button data-testid="modal-cancel" onClick={onCancel}> + Cancel + </button> </div> ), })) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, }, - }, -}) + }) const renderWithQueryClient = (ui: React.ReactElement) => { const queryClient = createQueryClient() - return render( - <QueryClientProvider client={queryClient}> - {ui} - - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>) } describe('publisher', () => { @@ -407,7 +447,9 @@ describe('publisher', () => { it('should be memoized with React.memo', () => { expect(Publisher).toBeDefined() - expect((Publisher as unknown as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') + expect((Publisher as unknown as { $$typeof?: symbol }).$$typeof?.toString()).toContain( + 'Symbol', + ) }) }) @@ -485,9 +527,9 @@ describe('publisher', () => { renderWithQueryClient(<Popup />) - const addDocumentsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.goToAddDocuments'), - ) + const addDocumentsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.goToAddDocuments')) expect(addDocumentsButton).toBeDisabled() }) @@ -496,9 +538,9 @@ describe('publisher', () => { renderWithQueryClient(<Popup />) - const addDocumentsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.goToAddDocuments'), - ) + const addDocumentsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.goToAddDocuments')) expect(addDocumentsButton).not.toBeDisabled() }) @@ -556,7 +598,9 @@ describe('publisher', () => { fireEvent.click(publishButton) await waitFor(() => { - expect(screen.getByRole('button', { name: /workflow.common.published/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflow.common.published/i }), + ).toBeInTheDocument() }) }) }) @@ -566,12 +610,14 @@ describe('publisher', () => { mockPublishedAt.mockReturnValue(1700000000) renderWithQueryClient(<Popup />) - const addDocumentsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.goToAddDocuments'), - ) + const addDocumentsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.goToAddDocuments')) fireEvent.click(addDocumentsButton!) - expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-id/documents/create-from-pipeline') + expect(mockPush).toHaveBeenCalledWith( + '/datasets/test-dataset-id/documents/create-from-pipeline', + ) }) it('should show pricing modal when publish as template is clicked without permission', async () => { @@ -579,9 +625,9 @@ describe('publisher', () => { mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(false) renderWithQueryClient(<Popup />) - const publishAsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.publishAs'), - ) + const publishAsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.publishAs')) fireEvent.click(publishAsButton!) expect(mockSetShowPricingModal).toHaveBeenCalled() @@ -594,9 +640,9 @@ describe('publisher', () => { fireEvent.click(screen.getByText('workflow.common.publish')) - const publishAsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.publishAs'), - ) + const publishAsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.publishAs')) fireEvent.click(publishAsButton!) await waitFor(() => { @@ -611,9 +657,9 @@ describe('publisher', () => { fireEvent.click(screen.getByText('workflow.common.publish')) - const publishAsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.publishAs'), - ) + const publishAsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.publishAs')) fireEvent.click(publishAsButton!) await waitFor(() => { @@ -623,7 +669,9 @@ describe('publisher', () => { fireEvent.click(screen.getByTestId('modal-cancel')) await waitFor(() => { - expect(screen.queryByTestId('publish-as-knowledge-pipeline-modal')).not.toBeInTheDocument() + expect( + screen.queryByTestId('publish-as-knowledge-pipeline-modal'), + ).not.toBeInTheDocument() }) }) @@ -634,9 +682,9 @@ describe('publisher', () => { fireEvent.click(screen.getByText('workflow.common.publish')) - const publishAsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.publishAs'), - ) + const publishAsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.publishAs')) fireEvent.click(publishAsButton!) await waitFor(() => { @@ -663,9 +711,9 @@ describe('publisher', () => { fireEvent.click(screen.getByText('workflow.common.publish')) - const publishAsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.publishAs'), - ) + const publishAsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.publishAs')) fireEvent.click(publishAsButton!) await waitFor(() => { @@ -756,9 +804,9 @@ describe('publisher', () => { fireEvent.click(screen.getByText('workflow.common.publish')) - const publishAsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.publishAs'), - ) + const publishAsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.publishAs')) fireEvent.click(publishAsButton!) await waitFor(() => { @@ -784,9 +832,9 @@ describe('publisher', () => { fireEvent.click(screen.getByText('workflow.common.publish')) - const publishAsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.publishAs'), - ) + const publishAsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.publishAs')) fireEvent.click(publishAsButton!) await waitFor(() => { @@ -839,9 +887,9 @@ describe('publisher', () => { fireEvent.click(screen.getByText('workflow.common.publish')) - const publishAsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.publishAs'), - ) + const publishAsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.publishAs')) fireEvent.click(publishAsButton!) await waitFor(() => { @@ -865,9 +913,9 @@ describe('publisher', () => { fireEvent.click(screen.getByText('workflow.common.publish')) - const publishAsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.publishAs'), - ) + const publishAsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.publishAs')) fireEvent.click(publishAsButton!) await waitFor(() => { @@ -877,7 +925,9 @@ describe('publisher', () => { fireEvent.click(screen.getByTestId('modal-confirm')) await waitFor(() => { - expect(screen.queryByTestId('publish-as-knowledge-pipeline-modal')).not.toBeInTheDocument() + expect( + screen.queryByTestId('publish-as-knowledge-pipeline-modal'), + ).not.toBeInTheDocument() }) }) }) @@ -998,7 +1048,9 @@ describe('publisher', () => { fireEvent.click(publishButton) await waitFor(() => { - expect(screen.getByRole('button', { name: /workflow.common.published/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflow.common.published/i }), + ).toBeInTheDocument() }) vi.clearAllMocks() @@ -1022,15 +1074,20 @@ describe('publisher', () => { it('should not trigger duplicate publish via shortcut when already publishing', async () => { let resolvePublish: () => void = () => {} mockPublishedAt.mockReturnValue(1700000000) - mockPublishWorkflow.mockImplementation(() => new Promise((resolve) => { - resolvePublish = () => resolve({ created_at: 1700100000 }) - })) + mockPublishWorkflow.mockImplementation( + () => + new Promise((resolve) => { + resolvePublish = () => resolve({ created_at: 1700100000 }) + }), + ) renderWithQueryClient(<Popup />) triggerHotkey('Mod+Shift+P') await waitFor(() => { - const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) + const publishButton = screen.getByRole('button', { + name: /workflow.common.publishUpdate/i, + }) expect(publishButton).toBeDisabled() }) @@ -1052,7 +1109,9 @@ describe('publisher', () => { fireEvent.click(publishButton) await waitFor(() => { - expect(screen.getByRole('button', { name: /workflow.common.published/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflow.common.published/i }), + ).toBeInTheDocument() }) }) @@ -1072,7 +1131,9 @@ describe('publisher', () => { }) await waitFor(() => { - expect(screen.getByRole('button', { name: /workflow.common.publishUpdate/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflow.common.publishUpdate/i }), + ).toBeInTheDocument() }) }) @@ -1091,7 +1152,9 @@ describe('publisher', () => { fireEvent.click(publishButton) await waitFor(() => { - expect(screen.getByRole('button', { name: /workflow.common.published/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflow.common.published/i }), + ).toBeInTheDocument() }) }) @@ -1208,9 +1271,9 @@ describe('publisher', () => { fireEvent.click(screen.getByText('workflow.common.publish')) - const publishAsButton = screen.getAllByRole('button').find(btn => - btn.textContent?.includes('pipeline.common.publishAs'), - ) + const publishAsButton = screen + .getAllByRole('button') + .find((btn) => btn.textContent?.includes('pipeline.common.publishAs')) fireEvent.click(publishAsButton!) await waitFor(() => { diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx index f3e69baec44a8c..7c111a2260db46 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx @@ -1,28 +1,42 @@ import { fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - import Popup from '../popup' vi.mock('@langgenius/dify-ui/alert-dialog', () => ({ - AlertDialog: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => ( - open - ? ( - <div role="alertdialog"> - {children} - <button data-testid="alert-dialog-close" onClick={() => onOpenChange?.(false)}> - Close - </button> - </div> - ) - : null - ), + AlertDialog: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void + }) => + open ? ( + <div role="alertdialog"> + {children} + <button data-testid="alert-dialog-close" onClick={() => onOpenChange?.(false)}> + Close + </button> + </div> + ) : null, AlertDialogActions: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>, - AlertDialogCancelButton: ({ children }: { children?: React.ReactNode }) => <button>{children}</button>, - AlertDialogConfirmButton: ({ children, onClick, disabled }: { + AlertDialogCancelButton: ({ children }: { children?: React.ReactNode }) => ( + <button>{children}</button> + ), + AlertDialogConfirmButton: ({ + children, + onClick, + disabled, + }: { children?: React.ReactNode onClick?: () => void disabled?: boolean - }) => <button onClick={onClick} disabled={disabled}>{children}</button>, + }) => ( + <button onClick={onClick} disabled={disabled}> + {children} + </button> + ), AlertDialogContent: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>, AlertDialogDescription: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>, AlertDialogTitle: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>, @@ -39,10 +53,18 @@ const toastMocks = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { - success: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'success', message, ...options })), - error: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'error', message, ...options })), - warning: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'warning', message, ...options })), - info: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'info', message, ...options })), + success: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'success', message, ...options }), + ), + error: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'info', message, ...options }), + ), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -73,7 +95,7 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/next/link', () => ({ - default: ({ children, href }: { children: React.ReactNode, href: string }) => ( + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( <a href={href}>{children}</a> ), })) @@ -132,7 +154,9 @@ vi.mock('@/app/components/base/icons/src/public/common', () => ({ })) vi.mock('@/app/components/base/premium-badge', () => ({ - default: ({ children }: { children: React.ReactNode }) => <span data-testid="premium-badge">{children}</span>, + default: ({ children }: { children: React.ReactNode }) => ( + <span data-testid="premium-badge">{children}</span> + ), })) vi.mock('@/config', () => ({ @@ -147,13 +171,14 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: Record<string, unknown>) => unknown) => selector({ - dataset: { - permission_keys: mockDatasetPermissionKeys, - maintainer: mockDatasetMaintainer, - }, - mutateDatasetRes: mockMutateDatasetRes, - }), + useDatasetDetailContextWithSelector: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + dataset: { + permission_keys: mockDatasetPermissionKeys, + maintainer: mockDatasetMaintainer, + }, + mutateDatasetRes: mockMutateDatasetRes, + }), })) vi.mock('@/context/account-state', async (importOriginal) => { @@ -213,7 +238,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -223,8 +249,9 @@ vi.mock('@/context/i18n', () => ({ })) vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: <T,>(selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T) => - selector({ setShowPricingModal: mockSetShowPricingModal }), + useModalContextSelector: <T,>( + selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T, + ) => selector({ setShowPricingModal: mockSetShowPricingModal }), })) vi.mock('@/context/provider-context', () => ({ @@ -268,12 +295,23 @@ vi.mock('@langgenius/dify-ui/cn', () => ({ })) vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({ - default: ({ onConfirm, onCancel }: { onConfirm: (name: string, icon: unknown, desc: string) => void, onCancel: () => void }) => ( + default: ({ + onConfirm, + onCancel, + }: { + onConfirm: (name: string, icon: unknown, desc: string) => void + onCancel: () => void + }) => ( <div data-testid="publish-as-modal"> - <button data-testid="publish-as-confirm" onClick={() => onConfirm('My Pipeline', { icon_type: 'emoji' }, 'desc')}> + <button + data-testid="publish-as-confirm" + onClick={() => onConfirm('My Pipeline', { icon_type: 'emoji' }, 'desc')} + > Confirm </button> - <button data-testid="publish-as-cancel" onClick={onCancel}>Cancel</button> + <button data-testid="publish-as-cancel" onClick={onCancel}> + Cancel + </button> </div> ), })) @@ -297,10 +335,13 @@ describe('Popup', () => { mockCurrentUserId = 'user-1' mockIsLoadingWorkspacePermissionKeys = false mockWorkspacePermissionKeys = [] - mockUseBoolean.mockImplementation((initial: boolean) => [initial, { - setFalse: vi.fn(), - setTrue: vi.fn(), - }]) + mockUseBoolean.mockImplementation((initial: boolean) => [ + initial, + { + setFalse: vi.fn(), + setTrue: vi.fn(), + }, + ]) }) afterEach(() => { @@ -436,9 +477,18 @@ describe('Popup', () => { const hideConfirm = vi.fn() mockUseBoolean .mockImplementationOnce(() => [true, { setFalse: hideConfirm, setTrue: vi.fn() }]) - .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }]) - .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }]) - .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }]) + .mockImplementationOnce((initial: boolean) => [ + initial, + { setFalse: vi.fn(), setTrue: vi.fn() }, + ]) + .mockImplementationOnce((initial: boolean) => [ + initial, + { setFalse: vi.fn(), setTrue: vi.fn() }, + ]) + .mockImplementationOnce((initial: boolean) => [ + initial, + { setFalse: vi.fn(), setTrue: vi.fn() }, + ]) render(<Popup />) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx index 3d4f88b750ef30..c59846c4efbcf5 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx @@ -5,18 +5,17 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop import { toast } from '@langgenius/dify-ui/toast' import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNodesSyncDraft } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useStore } from '@/app/components/workflow/store' import { useDocLink } from '@/context/i18n' import Link from '@/next/link' -import { useInvalidCustomizedTemplateList, usePublishAsCustomizedPipeline } from '@/service/use-pipeline' +import { + useInvalidCustomizedTemplateList, + usePublishAsCustomizedPipeline, +} from '@/service/use-pipeline' import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal' import Popup from './popup' @@ -25,21 +24,23 @@ const Publisher = () => { const [open, setOpen] = useState(false) const [confirmVisible, { setFalse: hideConfirm, setTrue: showConfirm }] = useBoolean(false) const { handleSyncWorkflowDraft } = useNodesSyncDraft() - const canPipelineRelease = useHooksStore(s => s.accessControl.canReleaseAndVersion) + const canPipelineRelease = useHooksStore((s) => s.accessControl.canReleaseAndVersion) const docLink = useDocLink() - const pipelineId = useStore(s => s.pipelineId) + const pipelineId = useStore((s) => s.pipelineId) const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline() const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList() - const [showPublishAsKnowledgePipelineModal, setShowPublishAsKnowledgePipelineModal] = useState(false) + const [showPublishAsKnowledgePipelineModal, setShowPublishAsKnowledgePipelineModal] = + useState(false) const [isPublishingAsCustomizedPipeline, setIsPublishingAsCustomizedPipeline] = useState(false) - const handleOpenChange = useCallback((newOpen: boolean) => { - if (!newOpen && confirmVisible) - return - if (newOpen && canPipelineRelease) - handleSyncWorkflowDraft(true) - setOpen(canPipelineRelease && newOpen) - }, [confirmVisible, handleSyncWorkflowDraft, canPipelineRelease]) + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (!newOpen && confirmVisible) return + if (newOpen && canPipelineRelease) handleSyncWorkflowDraft(true) + setOpen(canPipelineRelease && newOpen) + }, + [confirmVisible, handleSyncWorkflowDraft, canPipelineRelease], + ) const closePopover = useCallback(() => { setOpen(false) }, []) @@ -49,56 +50,64 @@ const Publisher = () => { const hidePublishAsKnowledgePipelineModal = useCallback(() => { setShowPublishAsKnowledgePipelineModal(false) }, []) - const handlePublishAsKnowledgePipeline = useCallback(async (name: string, icon: IconInfo, description?: string) => { - try { - setIsPublishingAsCustomizedPipeline(true) - await publishAsCustomizedPipeline({ - pipelineId: pipelineId || '', - name, - icon_info: icon, - description, - }) - toast.success(t($ => $['publishTemplate.success.message'], { ns: 'datasetPipeline' }), { - description: ( - <div className="flex flex-col gap-y-1"> - <span className="system-xs-regular text-text-secondary"> - {t($ => $['publishTemplate.success.tip'], { ns: 'datasetPipeline' })} - </span> - <Link href={docLink()} target="_blank" className="inline-block system-xs-medium-uppercase text-text-accent"> - {t($ => $['publishTemplate.success.learnMore'], { ns: 'datasetPipeline' })} - </Link> - </div> - ), - }) - invalidCustomizedTemplateList() - } - catch { - toast.error(t($ => $['publishTemplate.error.message'], { ns: 'datasetPipeline' })) - } - finally { - setIsPublishingAsCustomizedPipeline(false) - hidePublishAsKnowledgePipelineModal() - } - }, [docLink, hidePublishAsKnowledgePipelineModal, invalidCustomizedTemplateList, pipelineId, publishAsCustomizedPipeline, t]) + const handlePublishAsKnowledgePipeline = useCallback( + async (name: string, icon: IconInfo, description?: string) => { + try { + setIsPublishingAsCustomizedPipeline(true) + await publishAsCustomizedPipeline({ + pipelineId: pipelineId || '', + name, + icon_info: icon, + description, + }) + toast.success( + t(($) => $['publishTemplate.success.message'], { ns: 'datasetPipeline' }), + { + description: ( + <div className="flex flex-col gap-y-1"> + <span className="system-xs-regular text-text-secondary"> + {t(($) => $['publishTemplate.success.tip'], { ns: 'datasetPipeline' })} + </span> + <Link + href={docLink()} + target="_blank" + className="inline-block system-xs-medium-uppercase text-text-accent" + > + {t(($) => $['publishTemplate.success.learnMore'], { ns: 'datasetPipeline' })} + </Link> + </div> + ), + }, + ) + invalidCustomizedTemplateList() + } catch { + toast.error(t(($) => $['publishTemplate.error.message'], { ns: 'datasetPipeline' })) + } finally { + setIsPublishingAsCustomizedPipeline(false) + hidePublishAsKnowledgePipelineModal() + } + }, + [ + docLink, + hidePublishAsKnowledgePipelineModal, + invalidCustomizedTemplateList, + pipelineId, + publishAsCustomizedPipeline, + t, + ], + ) return ( <> - <Popover - open={open} - onOpenChange={handleOpenChange} - > + <Popover open={open} onOpenChange={handleOpenChange}> <PopoverTrigger nativeButton - render={( - <Button - className="px-2" - variant="primary" - disabled={!canPipelineRelease} - > - <span className="pl-1">{t($ => $['common.publish'], { ns: 'workflow' })}</span> + render={ + <Button className="px-2" variant="primary" disabled={!canPipelineRelease}> + <span className="pl-1">{t(($) => $['common.publish'], { ns: 'workflow' })}</span> <RiArrowDownSLine className="size-4" /> </Button> - )} + } /> <PopoverContent placement="bottom-end" diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index a0ba87da793b32..ebed6c6c7e7719 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -28,7 +28,10 @@ import { IS_CLOUD_EDITION } from '@/config' import { userProfileIdAtom } from '@/context/account-state' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useModalContextSelector } from '@/context/modal-context' -import { workspacePermissionKeysAtom, workspacePermissionKeysLoadingAtom } from '@/context/permission-state' +import { + workspacePermissionKeysAtom, + workspacePermissionKeysLoadingAtom, +} from '@/context/permission-state' import { useProviderContextSelector } from '@/context/provider-context' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' @@ -61,11 +64,11 @@ const Popup = ({ const { t } = useTranslation() const { datasetId } = useParams() const { push } = useRouter() - const publishedAt = useStore(s => s.publishedAt) - const draftUpdatedAt = useStore(s => s.draftUpdatedAt) - const pipelineId = useStore(s => s.pipelineId) - const dataset = useDatasetDetailContextWithSelector(s => s.dataset) - const mutateDatasetRes = useDatasetDetailContextWithSelector(s => s.mutateDatasetRes) + const publishedAt = useStore((s) => s.publishedAt) + const draftUpdatedAt = useStore((s) => s.draftUpdatedAt) + const pipelineId = useStore((s) => s.pipelineId) + const dataset = useDatasetDetailContextWithSelector((s) => s.dataset) + const mutateDatasetRes = useDatasetDetailContextWithSelector((s) => s.mutateDatasetRes) const currentUserId = useAtomValue(userProfileIdAtom) const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) @@ -74,195 +77,256 @@ const Popup = ({ const { handleCheckBeforePublish } = useChecklistBeforePublish() const { mutateAsync: publishWorkflow } = usePublishWorkflow() const workflowStore = useWorkflowStore() - const isAllowPublishAsCustomKnowledgePipelineTemplate = useProviderContextSelector(s => s.isAllowPublishAsCustomKnowledgePipelineTemplate) - const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal) + const isAllowPublishAsCustomKnowledgePipelineTemplate = useProviderContextSelector( + (s) => s.isAllowPublishAsCustomKnowledgePipelineTemplate, + ) + const setShowPricingModal = useModalContextSelector((s) => s.setShowPricingModal) const apiReferenceUrl = useDatasetApiAccessUrl() const canAddDocumentsToDataset = getDatasetACLCapabilities(dataset?.permission_keys, { currentUserId, resourceMaintainer: dataset?.maintainer, workspacePermissionKeys, }).canUse - const isAddDocumentsDisabled = !publishedAt || isLoadingWorkspacePermissionKeys || !canAddDocumentsToDataset - const [localConfirmVisible, { setFalse: hideLocalConfirm, setTrue: showLocalConfirm }] = useBoolean(false) + const isAddDocumentsDisabled = + !publishedAt || isLoadingWorkspacePermissionKeys || !canAddDocumentsToDataset + const [localConfirmVisible, { setFalse: hideLocalConfirm, setTrue: showLocalConfirm }] = + useBoolean(false) const confirmVisible = controlledConfirmVisible ?? localConfirmVisible const showConfirm = onShowConfirm ?? showLocalConfirm const hideConfirm = onHideConfirm ?? hideLocalConfirm const [publishing, { setFalse: hidePublishing, setTrue: showPublishing }] = useBoolean(false) - const invalidPublishedPipelineInfo = useInvalid([...publishedPipelineInfoQueryKeyPrefix, pipelineId]) + const invalidPublishedPipelineInfo = useInvalid([ + ...publishedPipelineInfoQueryKeyPrefix, + pipelineId, + ]) const invalidDatasetList = useInvalidDatasetList() const handleHideConfirm = useCallback(() => { hideConfirm() onRequestClose?.() }, [hideConfirm, onRequestClose]) - const handlePublish = useCallback(async (params?: PublishWorkflowParams) => { - if (publishing) - return - let startedPublishing = false - try { - const checked = await handleCheckBeforePublish() - if (checked) { - if (!publishedAt && !confirmVisible) { - showConfirm() - return - } - startedPublishing = true - showPublishing() - const res = await publishWorkflow({ - url: `/rag/pipelines/${pipelineId}/workflows/publish`, - title: params?.title || '', - releaseNotes: params?.releaseNotes || '', - }) - setPublished(true) - trackEvent('app_published_time', { action_mode: 'pipeline', app_id: datasetId, app_name: params?.title || '' }) - if (res) { - toast.success(t($ => $['publishPipeline.success.message'], { ns: 'datasetPipeline' }), { - description: ( - <div className="system-xs-regular text-text-secondary"> - <Trans - i18nKey={$ => $['publishPipeline.success.tip']} - ns="datasetPipeline" - components={{ - CustomLink: ( - <Link className="system-xs-medium text-text-accent" href={`/datasets/${datasetId}/documents`}> - </Link> - ), - }} - /> - </div> - ), + const handlePublish = useCallback( + async (params?: PublishWorkflowParams) => { + if (publishing) return + let startedPublishing = false + try { + const checked = await handleCheckBeforePublish() + if (checked) { + if (!publishedAt && !confirmVisible) { + showConfirm() + return + } + startedPublishing = true + showPublishing() + const res = await publishWorkflow({ + url: `/rag/pipelines/${pipelineId}/workflows/publish`, + title: params?.title || '', + releaseNotes: params?.releaseNotes || '', + }) + setPublished(true) + trackEvent('app_published_time', { + action_mode: 'pipeline', + app_id: datasetId, + app_name: params?.title || '', }) - workflowStore.getState().setPublishedAt(res.created_at) - mutateDatasetRes?.() - invalidPublishedPipelineInfo() - invalidDatasetList() + if (res) { + toast.success( + t(($) => $['publishPipeline.success.message'], { ns: 'datasetPipeline' }), + { + description: ( + <div className="system-xs-regular text-text-secondary"> + <Trans + i18nKey={($) => $['publishPipeline.success.tip']} + ns="datasetPipeline" + components={{ + CustomLink: ( + <Link + className="system-xs-medium text-text-accent" + href={`/datasets/${datasetId}/documents`} + ></Link> + ), + }} + /> + </div> + ), + }, + ) + workflowStore.getState().setPublishedAt(res.created_at) + mutateDatasetRes?.() + invalidPublishedPipelineInfo() + invalidDatasetList() + } } + } catch { + toast.error(t(($) => $['publishPipeline.error.message'], { ns: 'datasetPipeline' })) + } finally { + if (startedPublishing) hidePublishing() + if (confirmVisible) handleHideConfirm() } - } - catch { - toast.error(t($ => $['publishPipeline.error.message'], { ns: 'datasetPipeline' })) - } - finally { - if (startedPublishing) - hidePublishing() - if (confirmVisible) - handleHideConfirm() - } - }, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, handleHideConfirm]) + }, + [ + publishing, + handleCheckBeforePublish, + publishedAt, + confirmVisible, + showPublishing, + publishWorkflow, + pipelineId, + datasetId, + showConfirm, + t, + workflowStore, + mutateDatasetRes, + invalidPublishedPipelineInfo, + invalidDatasetList, + hidePublishing, + handleHideConfirm, + ], + ) useHotkey('Mod+Shift+P', (e) => { e.preventDefault() - if (published) - return + if (published) return handlePublish() }) const goToAddDocuments = useCallback(() => { - if (isAddDocumentsDisabled) - return + if (isAddDocumentsDisabled) return push(`/datasets/${datasetId}/documents/create-from-pipeline`) }, [datasetId, isAddDocumentsDisabled, push]) const handleClickPublishAsKnowledgePipeline = useCallback(() => { onRequestClose?.() if (!isAllowPublishAsCustomKnowledgePipelineTemplate) { - if (IS_CLOUD_EDITION) - setShowPricingModal() - } - else { + if (IS_CLOUD_EDITION) setShowPricingModal() + } else { onShowPublishAsKnowledgePipelineModal?.() } - }, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, onShowPublishAsKnowledgePipelineModal, setShowPricingModal]) + }, [ + isAllowPublishAsCustomKnowledgePipelineTemplate, + onRequestClose, + onShowPublishAsKnowledgePipelineModal, + setShowPricingModal, + ]) return ( - <div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}> + <div + className={cn( + 'rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', + isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]', + )} + > <div className="p-4 pt-3"> <div className="flex h-6 items-center system-xs-medium-uppercase text-text-tertiary"> - {publishedAt ? t($ => $['common.latestPublished'], { ns: 'workflow' }) : t($ => $['common.currentDraftUnpublished'], { ns: 'workflow' })} + {publishedAt + ? t(($) => $['common.latestPublished'], { ns: 'workflow' }) + : t(($) => $['common.currentDraftUnpublished'], { ns: 'workflow' })} </div> - {publishedAt - ? ( - <div className="flex items-center justify-between"> - <div className="flex items-center system-sm-medium text-text-secondary"> - {t($ => $['common.publishedAt'], { ns: 'workflow' })} - {' '} - {formatTimeFromNow(publishedAt)} - </div> - </div> - ) - : ( - <div className="flex items-center system-sm-medium text-text-secondary"> - {t($ => $['common.autoSaved'], { ns: 'workflow' })} - {' '} - · - {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} - </div> - )} - <Button variant="primary" className="mt-3 w-full" onClick={() => handlePublish()} disabled={published || publishing}> - {published - ? t($ => $['common.published'], { ns: 'workflow' }) - : ( - <div className="flex gap-1"> - <span>{t($ => $['common.publishUpdate'], { ns: 'workflow' })}</span> - <KbdGroup> - {PUBLISH_SHORTCUT.map(key => ( - <Kbd key={key} color="white">{formatForDisplay(key)}</Kbd> - ))} - </KbdGroup> - </div> - )} + {publishedAt ? ( + <div className="flex items-center justify-between"> + <div className="flex items-center system-sm-medium text-text-secondary"> + {t(($) => $['common.publishedAt'], { ns: 'workflow' })}{' '} + {formatTimeFromNow(publishedAt)} + </div> + </div> + ) : ( + <div className="flex items-center system-sm-medium text-text-secondary"> + {t(($) => $['common.autoSaved'], { ns: 'workflow' })} · + {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} + </div> + )} + <Button + variant="primary" + className="mt-3 w-full" + onClick={() => handlePublish()} + disabled={published || publishing} + > + {published ? ( + t(($) => $['common.published'], { ns: 'workflow' }) + ) : ( + <div className="flex gap-1"> + <span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span> + <KbdGroup> + {PUBLISH_SHORTCUT.map((key) => ( + <Kbd key={key} color="white"> + {formatForDisplay(key)} + </Kbd> + ))} + </KbdGroup> + </div> + )} </Button> </div> <div className="border-t-[0.5px] border-t-divider-regular p-4 pt-3"> - <Button className="mb-1 w-full hover:bg-state-accent-hover hover:text-text-accent" variant="tertiary" onClick={goToAddDocuments} disabled={isAddDocumentsDisabled}> + <Button + className="mb-1 w-full hover:bg-state-accent-hover hover:text-text-accent" + variant="tertiary" + onClick={goToAddDocuments} + disabled={isAddDocumentsDisabled} + > <div className="flex grow items-center"> <RiPlayCircleLine className="mr-2 size-4" /> - {t($ => $['common.goToAddDocuments'], { ns: 'pipeline' })} + {t(($) => $['common.goToAddDocuments'], { ns: 'pipeline' })} </div> <RiArrowRightUpLine className="ml-2 size-4 shrink-0" /> </Button> <Link href={apiReferenceUrl} target="_blank" rel="noopener noreferrer"> - <Button className="w-full hover:bg-state-accent-hover hover:text-text-accent" variant="tertiary" disabled={!publishedAt}> + <Button + className="w-full hover:bg-state-accent-hover hover:text-text-accent" + variant="tertiary" + disabled={!publishedAt} + > <div className="flex grow items-center"> <RiTerminalBoxLine className="mr-2 size-4" /> - {t($ => $['common.accessAPIReference'], { ns: 'workflow' })} + {t(($) => $['common.accessAPIReference'], { ns: 'workflow' })} </div> <RiArrowRightUpLine className="ml-2 size-4 shrink-0" /> </Button> </Link> <Divider className="my-2" /> - <Button className="w-full hover:bg-state-accent-hover hover:text-text-accent" variant="tertiary" onClick={handleClickPublishAsKnowledgePipeline} disabled={!publishedAt || isPublishingAsCustomizedPipeline}> + <Button + className="w-full hover:bg-state-accent-hover hover:text-text-accent" + variant="tertiary" + onClick={handleClickPublishAsKnowledgePipeline} + disabled={!publishedAt || isPublishingAsCustomizedPipeline} + > <div className="flex grow items-center gap-x-2 overflow-hidden"> <span aria-hidden className="i-custom-vender-pipeline-pipeline-line size-4 shrink-0" /> - <span className="grow truncate text-left" title={t($ => $['common.publishAs'], { ns: 'pipeline' })}> - {t($ => $['common.publishAs'], { ns: 'pipeline' })} + <span + className="grow truncate text-left" + title={t(($) => $['common.publishAs'], { ns: 'pipeline' })} + > + {t(($) => $['common.publishAs'], { ns: 'pipeline' })} </span> {IS_CLOUD_EDITION && !isAllowPublishAsCustomKnowledgePipelineTemplate && ( <PremiumBadge className="shrink-0 select-none" size="s" color="indigo"> - <SparklesSoft aria-hidden="true" className="flex size-3 items-center text-components-premium-badge-indigo-text-stop-0" /> + <SparklesSoft + aria-hidden="true" + className="flex size-3 items-center text-components-premium-badge-indigo-text-stop-0" + /> <span className="p-0.5 system-2xs-medium"> - {t($ => $['upgradeBtn.encourageShort'], { ns: 'billing' })} + {t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })} </span> </PremiumBadge> )} </div> </Button> </div> - <AlertDialog open={confirmVisible} onOpenChange={open => !open && handleHideConfirm()}> + <AlertDialog open={confirmVisible} onOpenChange={(open) => !open && handleHideConfirm()}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle - title={t($ => $['common.confirmPublish'], { ns: 'pipeline' })} + title={t(($) => $['common.confirmPublish'], { ns: 'pipeline' })} className="w-full truncate title-2xl-semi-bold text-text-primary" > - {t($ => $['common.confirmPublish'], { ns: 'pipeline' })} + {t(($) => $['common.confirmPublish'], { ns: 'pipeline' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['common.confirmPublishContent'], { ns: 'pipeline' })} + {t(($) => $['common.confirmPublishContent'], { ns: 'pipeline' })} </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton disabled={publishing} onClick={() => void handlePublish()}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx index b9abae088498da..cb7e5abe1c8f9c 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx @@ -17,16 +17,14 @@ type RunModeProps = { text?: string } -const RunMode = ({ - text, -}: RunModeProps) => { +const RunMode = ({ text }: RunModeProps) => { const { t } = useTranslation() const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun() const { handleStopRun } = useWorkflowRun() const workflowStore = useWorkflowStore() - const workflowRunningData = useStore(s => s.workflowRunningData) - const isPreparingDataSource = useStore(s => s.isPreparingDataSource) - const canRun = useHooksStore(s => s.accessControl.canRun) + const workflowRunningData = useStore((s) => s.workflowRunningData) + const isPreparingDataSource = useStore((s) => s.isPreparingDataSource) + const canRun = useHooksStore((s) => s.accessControl.canRun) const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running const isDisabled = isPreparingDataSource || isRunning || !canRun @@ -43,12 +41,10 @@ const RunMode = ({ const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((v: any) => { - if (v.type === EVENT_WORKFLOW_STOP) - handleStop() + if (v.type === EVENT_WORKFLOW_STOP) handleStop() }) - if (!canRun) - return null + if (!canRun) return null return ( <div className="flex items-center gap-x-px"> @@ -60,38 +56,37 @@ const RunMode = ({ isDisabled ? 'rounded-l-md' : 'rounded-md', )} onClick={() => { - if (canRun) - handleWorkflowStartRunInWorkflow() + if (canRun) handleWorkflowStartRunInWorkflow() }} disabled={isDisabled} > {!isDisabled && ( <> <RiPlayLargeLine className="mr-1 size-4" /> - {workflowRunningData ? t($ => $['common.reRun'], { ns: 'pipeline' }) : (text ?? t($ => $['common.testRun'], { ns: 'pipeline' }))} + {workflowRunningData + ? t(($) => $['common.reRun'], { ns: 'pipeline' }) + : (text ?? t(($) => $['common.testRun'], { ns: 'pipeline' }))} </> )} {isRunning && ( <> <RiLoader2Line className="mr-1 size-4 animate-spin" /> - {t($ => $['common.processing'], { ns: 'pipeline' })} + {t(($) => $['common.processing'], { ns: 'pipeline' })} </> )} {isPreparingDataSource && ( <> <RiDatabase2Line className="mr-1 size-4" /> - {t($ => $['common.preparingDataSource'], { ns: 'pipeline' })} + {t(($) => $['common.preparingDataSource'], { ns: 'pipeline' })} </> )} - { - !isDisabled && ( - <KbdGroup> - {['Alt', 'R'].map(key => ( - <Kbd key={key}>{formatForDisplay(key)}</Kbd> - ))} - </KbdGroup> - ) - } + {!isDisabled && ( + <KbdGroup> + {['Alt', 'R'].map((key) => ( + <Kbd key={key}>{formatForDisplay(key)}</Kbd> + ))} + </KbdGroup> + )} </button> {isRunning && ( <button diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-main.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-main.tsx index 7bb8899a5a2a26..9304a9dccb5cdd 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-main.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-main.tsx @@ -1,10 +1,7 @@ import type { WorkflowProps } from '@/app/components/workflow' import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store' import { useAtomValue } from 'jotai' -import { - useCallback, - useMemo, -} from 'react' +import { useCallback, useMemo } from 'react' import { WorkflowWithInnerContext } from '@/app/components/workflow' import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars' import { useWorkflowStore } from '@/app/components/workflow/store' @@ -26,48 +23,48 @@ import { useInspectVarsCrud } from '../hooks/use-inspect-vars-crud' import RagPipelineChildren from './rag-pipeline-children' type RagPipelineMainProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'> -const RagPipelineMain = ({ - nodes, - edges, - viewport, -}: RagPipelineMainProps) => { +const RagPipelineMain = ({ nodes, edges, viewport }: RagPipelineMainProps) => { const workflowStore = useWorkflowStore() - const dataset = useDatasetDetailContextWithSelector(s => s.dataset) + const dataset = useDatasetDetailContextWithSelector((s) => s.dataset) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const datasetACLCapabilities = useMemo( - () => getDatasetACLCapabilities(dataset?.permission_keys, { - currentUserId, - resourceMaintainer: dataset?.maintainer, - workspacePermissionKeys, - }), + () => + getDatasetACLCapabilities(dataset?.permission_keys, { + currentUserId, + resourceMaintainer: dataset?.maintainer, + workspacePermissionKeys, + }), [dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys], ) type WorkflowDataUpdatePayload = { - rag_pipeline_variables?: Parameters<NonNullable<ReturnType<typeof workflowStore.getState>['setRagPipelineVariables']>>[0] - environment_variables?: Parameters<ReturnType<typeof workflowStore.getState>['setEnvironmentVariables']>[0] + rag_pipeline_variables?: Parameters< + NonNullable<ReturnType<typeof workflowStore.getState>['setRagPipelineVariables']> + >[0] + environment_variables?: Parameters< + ReturnType<typeof workflowStore.getState>['setEnvironmentVariables'] + >[0] } - const handleWorkflowDataUpdate = useCallback((payload: WorkflowDataUpdatePayload) => { - const { - rag_pipeline_variables, - environment_variables, - } = payload - if (rag_pipeline_variables) { - const { setRagPipelineVariables } = workflowStore.getState() - setRagPipelineVariables?.(rag_pipeline_variables) - } - if (environment_variables) { - const { setEnvironmentVariables } = workflowStore.getState() - setEnvironmentVariables(environment_variables) - } - }, [workflowStore]) + const handleWorkflowDataUpdate = useCallback( + (payload: WorkflowDataUpdatePayload) => { + const { rag_pipeline_variables, environment_variables } = payload + if (rag_pipeline_variables) { + const { setRagPipelineVariables } = workflowStore.getState() + setRagPipelineVariables?.(rag_pipeline_variables) + } + if (environment_variables) { + const { setEnvironmentVariables } = workflowStore.getState() + setEnvironmentVariables(environment_variables) + } + }, + [workflowStore], + ) - const { - doSyncWorkflowDraft, - syncWorkflowDraftWhenPageClose, - } = useNodesSyncDraftByCanEdit(datasetACLCapabilities.canEdit) + const { doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose } = useNodesSyncDraftByCanEdit( + datasetACLCapabilities.canEdit, + ) const { handleRefreshWorkflowDraft } = usePipelineRefreshDraft() const { handleBackupDraft, @@ -76,16 +73,12 @@ const RagPipelineMain = ({ handleRun, handleStopRun, } = usePipelineRunByCanEdit(datasetACLCapabilities.canEdit) - const { - handleStartWorkflowRun, - handleWorkflowStartRunInWorkflow, - } = usePipelineStartRunByCanEdit(datasetACLCapabilities.canEdit) + const { handleStartWorkflowRun, handleWorkflowStartRunInWorkflow } = usePipelineStartRunByCanEdit( + datasetACLCapabilities.canEdit, + ) const availableNodesMetaData = useAvailableNodesMetaData() const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl() - const { - exportCheck, - handleExportDSL, - } = useDSLByCanEdit(datasetACLCapabilities.canEdit) + const { exportCheck, handleExportDSL } = useDSLByCanEdit(datasetACLCapabilities.canEdit) const configsMap = useConfigsMap() const { fetchInspectVars } = useSetWorkflowVarsWithValue({ diff --git a/web/app/components/rag-pipeline/components/screenshot.tsx b/web/app/components/rag-pipeline/components/screenshot.tsx index c1a5552d0d1493..ead98653669896 100644 --- a/web/app/components/rag-pipeline/components/screenshot.tsx +++ b/web/app/components/rag-pipeline/components/screenshot.tsx @@ -8,8 +8,14 @@ const PipelineScreenShot = () => { return ( <picture> <source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline.png`} /> - <source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline@2x.png`} /> - <source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline@3x.png`} /> + <source + media="(resolution: 2x)" + srcSet={`${basePath}/screenshots/${theme}/Pipeline@2x.png`} + /> + <source + media="(resolution: 3x)" + srcSet={`${basePath}/screenshots/${theme}/Pipeline@3x.png`} + /> <img src={`${basePath}/screenshots/${theme}/Pipeline.png`} alt="Pipeline Screenshot" diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx index 17e2b85800417c..a4380fd6152140 100644 --- a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx +++ b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx @@ -2,11 +2,7 @@ import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' -import { - RiAlertFill, - RiCloseLine, - RiFileDownloadLine, -} from '@remixicon/react' +import { RiAlertFill, RiCloseLine, RiFileDownloadLine } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' import Uploader from '@/app/components/app/create-from-dsl-modal/uploader' @@ -19,11 +15,7 @@ type UpdateDSLModalProps = { onImport?: () => void } -const UpdateDSLModal = ({ - onCancel, - onBackup, - onImport, -}: UpdateDSLModalProps) => { +const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) => { const { t } = useTranslation() const { currentFile, @@ -42,18 +34,18 @@ const UpdateDSLModal = ({ <Dialog open={show} onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-none p-6 text-left align-middle"> - <div className="mb-3 flex items-center justify-between"> - <div className="title-2xl-semi-bold text-text-primary">{t($ => $['common.importDSL'], { ns: 'workflow' })}</div> + <div className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['common.importDSL'], { ns: 'workflow' })} + </div> <button type="button" className="flex h-[22px] w-[22px] cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onCancel} > <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" aria-hidden="true" /> @@ -65,17 +57,14 @@ const UpdateDSLModal = ({ <RiAlertFill className="size-4 shrink-0 text-text-warning-secondary" /> </div> <div className="flex grow flex-col items-start gap-0.5 py-1"> - <div className="system-xs-medium whitespace-pre-line text-text-primary">{t($ => $['common.importDSLTip'], { ns: 'workflow' })}</div> + <div className="system-xs-medium whitespace-pre-line text-text-primary"> + {t(($) => $['common.importDSLTip'], { ns: 'workflow' })} + </div> <div className="flex items-start gap-1 self-stretch pt-1 pb-0.5"> - <Button - size="small" - variant="secondary" - className="relative" - onClick={onBackup} - > + <Button size="small" variant="secondary" className="relative" onClick={onBackup}> <RiFileDownloadLine className="size-3.5 text-components-button-secondary-text" /> <div className="flex items-center justify-center gap-1 px-[3px]"> - {t($ => $['common.backupCurrentDraft'], { ns: 'workflow' })} + {t(($) => $['common.backupCurrentDraft'], { ns: 'workflow' })} </div> </Button> </div> @@ -83,7 +72,7 @@ const UpdateDSLModal = ({ </div> <div> <div className="pt-2 system-md-semibold text-text-primary"> - {t($ => $['common.chooseDSL'], { ns: 'workflow' })} + {t(($) => $['common.chooseDSL'], { ns: 'workflow' })} </div> <div className="flex w-full flex-col items-start justify-center gap-4 self-stretch py-4"> <Uploader @@ -96,7 +85,7 @@ const UpdateDSLModal = ({ </div> </div> <div className="flex items-center justify-end gap-2 self-stretch pt-5"> - <Button onClick={onCancel}>{t($ => $['newApp.Cancel'], { ns: 'app' })}</Button> + <Button onClick={onCancel}>{t(($) => $['newApp.Cancel'], { ns: 'app' })}</Button> <Button disabled={!currentFile || loading} variant="primary" @@ -104,7 +93,7 @@ const UpdateDSLModal = ({ onClick={handleImport} loading={loading} > - {t($ => $['common.overwriteAndImport'], { ns: 'workflow' })} + {t(($) => $['common.overwriteAndImport'], { ns: 'workflow' })} </Button> </div> </DialogContent> diff --git a/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx b/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx index a71c513f4e7fe0..7ac001cdb2c7a9 100644 --- a/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx +++ b/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx @@ -32,30 +32,38 @@ const VersionMismatchModal = ({ <AlertDialog open={isShow} onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <AlertDialogContent className="w-[480px] max-w-none! overflow-hidden! border-none p-6 text-left align-middle shadow-xl"> <div className="flex flex-col items-start gap-2 self-stretch pb-4"> - <AlertDialogTitle className="title-2xl-semi-bold text-text-primary">{t($ => $['newApp.appCreateDSLErrorTitle'], { ns: 'app' })}</AlertDialogTitle> - <AlertDialogDescription render={<div />} className="flex grow flex-col system-md-regular text-text-secondary"> - <div>{t($ => $['newApp.appCreateDSLErrorPart1'], { ns: 'app' })}</div> - <div>{t($ => $['newApp.appCreateDSLErrorPart2'], { ns: 'app' })}</div> + <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['newApp.appCreateDSLErrorTitle'], { ns: 'app' })} + </AlertDialogTitle> + <AlertDialogDescription + render={<div />} + className="flex grow flex-col system-md-regular text-text-secondary" + > + <div>{t(($) => $['newApp.appCreateDSLErrorPart1'], { ns: 'app' })}</div> + <div>{t(($) => $['newApp.appCreateDSLErrorPart2'], { ns: 'app' })}</div> <br /> <div> - {t($ => $['newApp.appCreateDSLErrorPart3'], { ns: 'app' })} + {t(($) => $['newApp.appCreateDSLErrorPart3'], { ns: 'app' })} <span className="system-md-medium">{versions?.importedVersion}</span> </div> <div> - {t($ => $['newApp.appCreateDSLErrorPart4'], { ns: 'app' })} + {t(($) => $['newApp.appCreateDSLErrorPart4'], { ns: 'app' })} <span className="system-md-medium">{versions?.systemVersion}</span> </div> </AlertDialogDescription> </div> <AlertDialogActions className="items-start p-0 pt-6"> - <AlertDialogCancelButton variant="secondary">{t($ => $['newApp.Cancel'], { ns: 'app' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton onClick={onConfirm}>{t($ => $['newApp.Confirm'], { ns: 'app' })}</AlertDialogConfirmButton> + <AlertDialogCancelButton variant="secondary"> + {t(($) => $['newApp.Cancel'], { ns: 'app' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton onClick={onConfirm}> + {t(($) => $['newApp.Confirm'], { ns: 'app' })} + </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> </AlertDialog> diff --git a/web/app/components/rag-pipeline/hooks/__tests__/index.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/index.spec.ts index 589450f4dd5a8f..a6a144296a76e7 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/index.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/index.spec.ts @@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BlockEnum } from '@/app/components/workflow/types' import { Resolution, TransferMethod } from '@/types/app' import { FlowType } from '@/types/common' - import { useAvailableNodesMetaData, useGetRunAndTraceUrl, @@ -86,10 +85,18 @@ vi.mock('@/app/components/workflow/nodes/knowledge-base/default', () => ({ })) vi.mock('@/app/components/workflow/utils', async (importOriginal) => { - const actual = await importOriginal() as Record<string, unknown> + const actual = (await importOriginal()) as Record<string, unknown> return { ...actual, - generateNewNode: ({ id, data, position }: { id: string, data: object, position: { x: number, y: number } }) => ({ + generateNewNode: ({ + id, + data, + position, + }: { + id: string + data: object + position: { x: number; y: number } + }) => ({ newNode: { id, data, position, type: 'custom' }, }), } diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-DSL.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-DSL.spec.ts index 11b05ea58c1b4f..505a1c4545ccbb 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-DSL.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-DSL.spec.ts @@ -11,10 +11,18 @@ const toastMocks = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { - success: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'success', message, ...options })), - error: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'error', message, ...options })), - warning: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'warning', message, ...options })), - info: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'info', message, ...options })), + success: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'success', message, ...options }), + ), + error: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'info', message, ...options }), + ), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -54,7 +62,13 @@ vi.mock('@/app/components/workflow/constants', () => ({ })) describe('useDSLByCanEdit', () => { - let mockLink: { href: string, download: string, click: ReturnType<typeof vi.fn>, style: { display: string }, remove: ReturnType<typeof vi.fn> } + let mockLink: { + href: string + download: string + click: ReturnType<typeof vi.fn> + style: { display: string } + remove: ReturnType<typeof vi.fn> + } let originalCreateElement: typeof document.createElement let originalAppendChild: typeof document.body.appendChild let mockCreateObjectURL: ReturnType<typeof vi.spyOn> @@ -80,7 +94,9 @@ describe('useDSLByCanEdit', () => { }) as typeof document.createElement originalAppendChild = document.body.appendChild.bind(document.body) - document.body.appendChild = vi.fn(<T extends Node>(node: T): T => node) as typeof document.body.appendChild + document.body.appendChild = vi.fn( + <T extends Node>(node: T): T => node, + ) as typeof document.body.appendChild mockCreateObjectURL = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:test-url') mockRevokeObjectURL = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => {}) @@ -210,7 +226,9 @@ describe('useDSLByCanEdit', () => { }) await waitFor(() => { - expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft') + expect(mockFetchWorkflowDraft).toHaveBeenCalledWith( + '/rag/pipelines/test-pipeline-id/workflows/draft', + ) expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() }) }) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-available-nodes-meta-data.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-available-nodes-meta-data.spec.ts index 9ed0dbfd15d976..13bc63aa051b90 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-available-nodes-meta-data.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-available-nodes-meta-data.spec.ts @@ -74,14 +74,14 @@ describe('useAvailableNodesMetaData', () => { it('should filter out HumanInput node', () => { const { result } = renderHook(() => useAvailableNodesMetaData()) - const nodeTypes = result.current.nodes.map(n => n.metaData.type) + const nodeTypes = result.current.nodes.map((n) => n.metaData.type) expect(nodeTypes).not.toContain(BlockEnum.HumanInput) }) it('should include DataSource with _dataSourceStartToAdd flag', () => { const { result } = renderHook(() => useAvailableNodesMetaData()) - const dsNode = result.current.nodes.find(n => n.metaData.type === BlockEnum.DataSource) + const dsNode = result.current.nodes.find((n) => n.metaData.type === BlockEnum.DataSource) expect(dsNode).toBeDefined() expect(dsNode!.defaultValue._dataSourceStartToAdd).toBe(true) @@ -89,7 +89,7 @@ describe('useAvailableNodesMetaData', () => { it('should include KnowledgeBase and DataSourceEmpty nodes', () => { const { result } = renderHook(() => useAvailableNodesMetaData()) - const nodeTypes = result.current.nodes.map(n => n.metaData.type) + const nodeTypes = result.current.nodes.map((n) => n.metaData.type) expect(nodeTypes).toContain(BlockEnum.KnowledgeBase) expect(nodeTypes).toContain(BlockEnum.DataSourceEmpty) @@ -117,7 +117,9 @@ describe('useAvailableNodesMetaData', () => { const { result } = renderHook(() => useAvailableNodesMetaData()) result.current.nodes.forEach((node) => { - expect(node.defaultValue.type).toBe(node.metaData.type === BlockEnum.AgentV2 ? BlockEnum.Agent : node.metaData.type) + expect(node.defaultValue.type).toBe( + node.metaData.type === BlockEnum.AgentV2 ? BlockEnum.Agent : node.metaData.type, + ) expect(node.defaultValue.title).toBe(node.metaData.title) }) }) @@ -140,7 +142,7 @@ describe('useAvailableNodesMetaData', () => { it('should include common nodes except HumanInput', () => { const { result } = renderHook(() => useAvailableNodesMetaData()) - const nodeTypes = result.current.nodes.map(n => n.metaData.type) + const nodeTypes = result.current.nodes.map((n) => n.metaData.type) expect(nodeTypes).toContain(BlockEnum.LLM) expect(nodeTypes).toContain(BlockEnum.HttpRequest) @@ -149,7 +151,7 @@ describe('useAvailableNodesMetaData', () => { it('should expose Agent v2 instead of legacy Agent when Agent v2 is enabled', () => { const { result } = renderHook(() => useAvailableNodesMetaData()) - const nodeTypes = result.current.nodes.map(n => n.metaData.type) + const nodeTypes = result.current.nodes.map((n) => n.metaData.type) expect(nodeTypes).toContain(BlockEnum.AgentV2) expect(nodeTypes).not.toContain(BlockEnum.Agent) @@ -161,7 +163,7 @@ describe('useAvailableNodesMetaData', () => { mockIsAgentV2Enabled.mockReturnValue(false) const { result } = renderHook(() => useAvailableNodesMetaData()) - const nodeTypes = result.current.nodes.map(n => n.metaData.type) + const nodeTypes = result.current.nodes.map((n) => n.metaData.type) expect(nodeTypes).toContain(BlockEnum.Agent) expect(nodeTypes).not.toContain(BlockEnum.AgentV2) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-configs-map.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-configs-map.spec.ts index 6e5bedd1225062..7313d7970e0a27 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-configs-map.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-configs-map.spec.ts @@ -1,6 +1,5 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' - import { useConfigsMap } from '../use-configs-map' const mockPipelineId = 'pipeline-xyz' diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-get-run-and-trace-url.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-get-run-and-trace-url.spec.ts index 10f31f55a6ff06..8fd397d02909eb 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-get-run-and-trace-url.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-get-run-and-trace-url.spec.ts @@ -1,6 +1,5 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' - import { useGetRunAndTraceUrl } from '../use-get-run-and-trace-url' vi.mock('@/app/components/workflow/store', () => ({ diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-input-field-panel.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-input-field-panel.spec.ts index d8c335e489ae30..510db6aadc86f9 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-input-field-panel.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-input-field-panel.spec.ts @@ -1,6 +1,5 @@ import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import { useInputFieldPanel } from '../use-input-field-panel' const mockSetShowInputFieldPanel = vi.fn() diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-input-fields.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-input-fields.spec.ts index c81f230252293e..9b6cfd5fbd1409 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-input-fields.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-input-fields.spec.ts @@ -7,11 +7,11 @@ import { useConfigurations, useInitialData } from '../use-input-fields' vi.mock('@/models/pipeline', () => ({ VAR_TYPE_MAP: { 'text-input': BaseFieldType.textInput, - 'paragraph': BaseFieldType.paragraph, - 'select': BaseFieldType.select, - 'number': BaseFieldType.numberInput, - 'checkbox': BaseFieldType.checkbox, - 'file': BaseFieldType.file, + paragraph: BaseFieldType.paragraph, + select: BaseFieldType.select, + number: BaseFieldType.numberInput, + checkbox: BaseFieldType.checkbox, + file: BaseFieldType.file, 'file-list': BaseFieldType.fileList, }, })) @@ -42,42 +42,54 @@ describe('useInitialData', () => { }) it('should initialize paragraph with empty string by default', () => { - const variables = [makeVariable({ type: 'paragraph', variable: 'para' })] as unknown as RAGPipelineVariables + const variables = [ + makeVariable({ type: 'paragraph', variable: 'para' }), + ] as unknown as RAGPipelineVariables const { result } = renderHook(() => useInitialData(variables)) expect(result.current.para).toBe('') }) it('should initialize select with empty string by default', () => { - const variables = [makeVariable({ type: 'select', variable: 'sel' })] as unknown as RAGPipelineVariables + const variables = [ + makeVariable({ type: 'select', variable: 'sel' }), + ] as unknown as RAGPipelineVariables const { result } = renderHook(() => useInitialData(variables)) expect(result.current.sel).toBe('') }) it('should initialize number with 0 by default', () => { - const variables = [makeVariable({ type: 'number', variable: 'num' })] as unknown as RAGPipelineVariables + const variables = [ + makeVariable({ type: 'number', variable: 'num' }), + ] as unknown as RAGPipelineVariables const { result } = renderHook(() => useInitialData(variables)) expect(result.current.num).toBe(0) }) it('should initialize checkbox with false by default', () => { - const variables = [makeVariable({ type: 'checkbox', variable: 'cb' })] as unknown as RAGPipelineVariables + const variables = [ + makeVariable({ type: 'checkbox', variable: 'cb' }), + ] as unknown as RAGPipelineVariables const { result } = renderHook(() => useInitialData(variables)) expect(result.current.cb).toBe(false) }) it('should initialize file with empty array by default', () => { - const variables = [makeVariable({ type: 'file', variable: 'f' })] as unknown as RAGPipelineVariables + const variables = [ + makeVariable({ type: 'file', variable: 'f' }), + ] as unknown as RAGPipelineVariables const { result } = renderHook(() => useInitialData(variables)) expect(result.current.f).toEqual([]) }) it('should initialize file-list with empty array by default', () => { - const variables = [makeVariable({ type: 'file-list', variable: 'fl' })] as unknown as RAGPipelineVariables + const variables = [ + makeVariable({ type: 'file-list', variable: 'fl' }), + ] as unknown as RAGPipelineVariables const { result } = renderHook(() => useInitialData(variables)) expect(result.current.fl).toEqual([]) @@ -162,9 +174,7 @@ describe('useConfigurations', () => { }) it('should handle undefined options', () => { - const variables = [ - makeVariable({ type: 'text-input' }), - ] as unknown as RAGPipelineVariables + const variables = [makeVariable({ type: 'text-input' })] as unknown as RAGPipelineVariables const { result } = renderHook(() => useConfigurations(variables)) expect(result.current[0]!.options).toBeUndefined() @@ -188,9 +198,7 @@ describe('useConfigurations', () => { }) it('should include showConditions as empty array', () => { - const variables = [ - makeVariable(), - ] as unknown as RAGPipelineVariables + const variables = [makeVariable()] as unknown as RAGPipelineVariables const { result } = renderHook(() => useConfigurations(variables)) expect(result.current[0]!.showConditions).toEqual([]) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-inspect-vars-crud.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-inspect-vars-crud.spec.ts index bb259284dc9b01..691d168a751829 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-inspect-vars-crud.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-inspect-vars-crud.spec.ts @@ -1,6 +1,5 @@ import { renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import { useInspectVarsCrud } from '../use-inspect-vars-crud' // Mock return value for useInspectVarsCrudCommon @@ -23,7 +22,8 @@ const mockApis = { const mockUseInspectVarsCrudCommon = vi.fn(() => mockApis) vi.mock('../../../workflow/hooks/use-inspect-vars-crud-common', () => ({ - useInspectVarsCrudCommon: (...args: Parameters<typeof mockUseInspectVarsCrudCommon>) => mockUseInspectVarsCrudCommon(...args), + useInspectVarsCrudCommon: (...args: Parameters<typeof mockUseInspectVarsCrudCommon>) => + mockUseInspectVarsCrudCommon(...args), })) const mockConfigsMap = { @@ -92,8 +92,7 @@ describe('useInspectVarsCrud', () => { 'invalidateConversationVarValues', ] - for (const key of expectedKeys) - expect(result.current).toHaveProperty(key) + for (const key of expectedKeys) expect(result.current).toHaveProperty(key) }) }) }) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts index 0f68a8ea4f4253..f03700c20ee7d2 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -1,7 +1,6 @@ import { renderHook } from '@testing-library/react' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - import { useNodesSyncDraft } from '../use-nodes-sync-draft' const mockGetNodes = vi.fn() @@ -243,11 +242,13 @@ describe('useNodesSyncDraft', () => { await result.current.doSyncWorkflowDraft() }) - expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({ - params: expect.not.objectContaining({ - source_workflow_id: expect.anything(), + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.not.objectContaining({ + source_workflow_id: expect.anything(), + }), }), - })) + ) }) it('should call onSuccess callback when sync succeeds', async () => { @@ -344,7 +345,7 @@ describe('useNodesSyncDraft', () => { await result.current.doSyncWorkflowDraft(false) }) - await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalled() }) @@ -367,7 +368,7 @@ describe('useNodesSyncDraft', () => { await result.current.doSyncWorkflowDraft(true) }) - await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled() }) @@ -458,7 +459,14 @@ describe('useNodesSyncDraft', () => { it('should remove underscore-prefixed keys from edges', () => { mockStoreGetState.mockReturnValue({ getNodes: mockGetNodes, - edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { _hidden: true, visible: false } }], + edges: [ + { + id: 'edge-1', + source: 'node-1', + target: 'node-2', + data: { _hidden: true, visible: false }, + }, + ], transform: [0, 0, 1], }) mockGetNodes.mockReturnValue([ diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-config.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-config.spec.ts index a57f255c6fa76b..3df8c83c85a779 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-config.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-config.spec.ts @@ -1,6 +1,5 @@ import { renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - import { usePipelineConfig } from '../use-pipeline-config' const mockUseStore = vi.fn() @@ -15,12 +14,14 @@ vi.mock('@/app/components/workflow/store', () => ({ const mockUseWorkflowConfig = vi.fn() vi.mock('@/service/use-workflow', () => ({ - useWorkflowConfig: (url: string, callback: (data: unknown) => void) => mockUseWorkflowConfig(url, callback), + useWorkflowConfig: (url: string, callback: (data: unknown) => void) => + mockUseWorkflowConfig(url, callback), })) const mockUseDataSourceList = vi.fn() vi.mock('@/service/use-pipeline', () => ({ - useDataSourceList: (enabled: boolean, callback: (data: unknown) => void) => mockUseDataSourceList(enabled, callback), + useDataSourceList: (enabled: boolean, callback: (data: unknown) => void) => + mockUseDataSourceList(enabled, callback), })) vi.mock('@/utils/var', () => ({ @@ -79,10 +80,7 @@ describe('usePipelineConfig', () => { it('should call useWorkflowConfig with correct URL for file upload config', () => { renderHook(() => usePipelineConfig()) - expect(mockUseWorkflowConfig).toHaveBeenCalledWith( - '/files/upload', - expect.any(Function), - ) + expect(mockUseWorkflowConfig).toHaveBeenCalledWith('/files/upload', expect.any(Function)) }) it('should call useDataSourceList when pipelineId exists', () => { @@ -194,15 +192,15 @@ describe('usePipelineConfig', () => { describe('handleUpdateDataSourceList', () => { it('should set data source list', () => { let capturedCallback: ((data: unknown) => void) | undefined - mockUseDataSourceList.mockImplementation((_enabled: boolean, callback: (data: unknown) => void) => { - capturedCallback = callback - }) + mockUseDataSourceList.mockImplementation( + (_enabled: boolean, callback: (data: unknown) => void) => { + capturedCallback = callback + }, + ) renderHook(() => usePipelineConfig()) - const dataSourceList = [ - { declaration: { identity: { icon: '/icon.png' } } }, - ] + const dataSourceList = [{ declaration: { identity: { icon: '/icon.png' } } }] capturedCallback?.(dataSourceList) @@ -211,15 +209,15 @@ describe('usePipelineConfig', () => { it('should prepend basePath to icon if not included', () => { let capturedCallback: ((data: unknown) => void) | undefined - mockUseDataSourceList.mockImplementation((_enabled: boolean, callback: (data: unknown) => void) => { - capturedCallback = callback - }) + mockUseDataSourceList.mockImplementation( + (_enabled: boolean, callback: (data: unknown) => void) => { + capturedCallback = callback + }, + ) renderHook(() => usePipelineConfig()) - const dataSourceList = [ - { declaration: { identity: { icon: '/icon.png' } } }, - ] + const dataSourceList = [{ declaration: { identity: { icon: '/icon.png' } } }] capturedCallback?.(dataSourceList) @@ -228,15 +226,15 @@ describe('usePipelineConfig', () => { it('should not modify icon if it already includes basePath', () => { let capturedCallback: ((data: unknown) => void) | undefined - mockUseDataSourceList.mockImplementation((_enabled: boolean, callback: (data: unknown) => void) => { - capturedCallback = callback - }) + mockUseDataSourceList.mockImplementation( + (_enabled: boolean, callback: (data: unknown) => void) => { + capturedCallback = callback + }, + ) renderHook(() => usePipelineConfig()) - const dataSourceList = [ - { declaration: { identity: { icon: '/base/icon.png' } } }, - ] + const dataSourceList = [{ declaration: { identity: { icon: '/base/icon.png' } } }] capturedCallback?.(dataSourceList) @@ -245,15 +243,15 @@ describe('usePipelineConfig', () => { it('should handle non-string icon', () => { let capturedCallback: ((data: unknown) => void) | undefined - mockUseDataSourceList.mockImplementation((_enabled: boolean, callback: (data: unknown) => void) => { - capturedCallback = callback - }) + mockUseDataSourceList.mockImplementation( + (_enabled: boolean, callback: (data: unknown) => void) => { + capturedCallback = callback + }, + ) renderHook(() => usePipelineConfig()) - const dataSourceList = [ - { declaration: { identity: { icon: { url: '/icon.png' } } } }, - ] + const dataSourceList = [{ declaration: { identity: { icon: { url: '/icon.png' } } } }] capturedCallback?.(dataSourceList) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts index 23b1065a45f3f7..98cb0bbe03ceab 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts @@ -1,6 +1,5 @@ import { renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - import { usePipelineInit } from '../use-pipeline-init' const mockWorkflowStoreGetState = vi.fn() @@ -57,16 +56,18 @@ describe('usePipelineInit', () => { setRagPipelineVariables: mockSetRagPipelineVariables, }) - mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => { - const state = { - dataset: { - pipeline_id: 'test-pipeline-id', - name: 'Test Knowledge', - icon_info: { icon: 'test-icon' }, - }, - } - return selector(state) - }) + mockUseDatasetDetailContextWithSelector.mockImplementation( + (selector: (state: Record<string, unknown>) => unknown) => { + const state = { + dataset: { + pipeline_id: 'test-pipeline-id', + name: 'Test Knowledge', + icon_info: { icon: 'test-icon' }, + }, + } + return selector(state) + }, + ) mockFetchWorkflowDraft.mockResolvedValue({ graph: { @@ -110,7 +111,9 @@ describe('usePipelineInit', () => { renderHook(() => usePipelineInit()) await waitFor(() => { - expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft') + expect(mockFetchWorkflowDraft).toHaveBeenCalledWith( + '/rag/pipelines/test-pipeline-id/workflows/draft', + ) }) }) @@ -226,9 +229,7 @@ describe('usePipelineInit', () => { updated_at: '2024-01-01T00:00:00Z', tool_published: false, environment_variables: [], - rag_pipeline_variables: [ - { variable: 'query', type: 'text-input' }, - ], + rag_pipeline_variables: [{ variable: 'query', type: 'text-input' }], }) renderHook(() => usePipelineInit()) @@ -313,10 +314,12 @@ describe('usePipelineInit', () => { describe('missing datasetId', () => { it('should not fetch when datasetId is missing', async () => { - mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => { - const state = { dataset: undefined } - return selector(state) - }) + mockUseDatasetDetailContextWithSelector.mockImplementation( + (selector: (state: Record<string, unknown>) => unknown) => { + const state = { dataset: undefined } + return selector(state) + }, + ) renderHook(() => usePipelineInit()) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts index b9cff292e6742c..40978e324a29fd 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts @@ -1,7 +1,6 @@ import { renderHook, waitFor } from '@testing-library/react' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - import { usePipelineRefreshDraft } from '../use-pipeline-refresh-draft' const mockWorkflowStoreGetState = vi.fn() @@ -92,7 +91,9 @@ describe('usePipelineRefreshDraft', () => { result.current.handleRefreshWorkflowDraft() }) - expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft') + expect(mockFetchWorkflowDraft).toHaveBeenCalledWith( + '/rag/pipelines/test-pipeline-id/workflows/draft', + ) }) it('should update workflow canvas with response data', async () => { @@ -138,7 +139,9 @@ describe('usePipelineRefreshDraft', () => { }) await waitFor(() => { - expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ variable: 'query', type: 'text-input' }]) + expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([ + { variable: 'query', type: 'text-input' }, + ]) }) }) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-run.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-run.spec.ts index 17345e1f4ef52d..cef721bce0896e 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-run.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-run.spec.ts @@ -3,7 +3,6 @@ import { renderHook } from '@testing-library/react' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WorkflowRunningStatus } from '@/app/components/workflow/types' - import { usePipelineRunByCanEdit } from '../use-pipeline-run' const mockStoreGetState = vi.fn() @@ -115,7 +114,10 @@ describe('usePipelineRunByCanEdit', () => { }) mockGetNodes.mockReturnValue([ - { id: 'node-1', data: { type: 'start', selected: true, _runningStatus: WorkflowRunningStatus.Running } }, + { + id: 'node-1', + data: { type: 'start', selected: true, _runningStatus: WorkflowRunningStatus.Running }, + }, ]) mockGetViewport.mockReturnValue({ x: 0, y: 0, zoom: 1 }) @@ -299,7 +301,9 @@ describe('usePipelineRunByCanEdit', () => { const { result } = renderHook(() => usePipelineRunByCanEdit(true)) act(() => { - result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory) + result.current.handleRestoreFromPublishedWorkflow( + publishedWorkflow as unknown as VersionHistory, + ) }) expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({ @@ -323,7 +327,9 @@ describe('usePipelineRunByCanEdit', () => { const { result } = renderHook(() => usePipelineRunByCanEdit(true)) act(() => { - result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory) + result.current.handleRestoreFromPublishedWorkflow( + publishedWorkflow as unknown as VersionHistory, + ) }) expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ key: 'ENV', value: 'value' }]) @@ -343,10 +349,14 @@ describe('usePipelineRunByCanEdit', () => { const { result } = renderHook(() => usePipelineRunByCanEdit(true)) act(() => { - result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory) + result.current.handleRestoreFromPublishedWorkflow( + publishedWorkflow as unknown as VersionHistory, + ) }) - expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ variable: 'query', type: 'text-input' }]) + expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([ + { variable: 'query', type: 'text-input' }, + ]) }) it('should handle empty environment and rag pipeline variables', () => { @@ -363,7 +373,9 @@ describe('usePipelineRunByCanEdit', () => { const { result } = renderHook(() => usePipelineRunByCanEdit(true)) act(() => { - result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory) + result.current.handleRestoreFromPublishedWorkflow( + publishedWorkflow as unknown as VersionHistory, + ) }) expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([]) @@ -762,7 +774,9 @@ describe('usePipelineRunByCanEdit', () => { const { result } = renderHook(() => usePipelineRunByCanEdit(true)) await act(async () => { - await result.current.handleRun({ inputs: {} }, { onData: customCallback } as unknown as Parameters<typeof result.current.handleRun>[1]) + await result.current.handleRun({ inputs: {} }, { + onData: customCallback, + } as unknown as Parameters<typeof result.current.handleRun>[1]) }) expect(capturedCallbacks.onData).toBeDefined() diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-start-run.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-start-run.spec.ts index 318d2c8de4d16e..f84203a957afbc 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-start-run.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-start-run.spec.ts @@ -2,7 +2,6 @@ import { renderHook } from '@testing-library/react' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WorkflowRunningStatus } from '@/app/components/workflow/types' - import { usePipelineStartRunByCanEdit } from '../use-pipeline-start-run' const mockWorkflowStoreGetState = vi.fn() diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-template.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-template.spec.ts index 58ee64250079ce..8873ad6cb4fc84 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-template.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-template.spec.ts @@ -1,6 +1,5 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' - import { usePipelineTemplate } from '../use-pipeline-template' vi.mock('@/app/components/workflow/constants', () => ({ @@ -15,7 +14,15 @@ vi.mock('@/app/components/workflow/nodes/knowledge-base/default', () => ({ })) vi.mock('@/app/components/workflow/utils', () => ({ - generateNewNode: ({ id, data, position }: { id: string, data: Record<string, unknown>, position: { x: number, y: number } }) => ({ + generateNewNode: ({ + id, + data, + position, + }: { + id: string + data: Record<string, unknown> + position: { x: number; y: number } + }) => ({ newNode: { id, data, position, type: 'custom' }, }), })) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline.spec.ts index 80a9d5b7c63766..514576382c9e87 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline.spec.ts @@ -5,7 +5,7 @@ import { usePipeline } from '../use-pipeline' const mockGetNodes = vi.fn() const mockSetNodes = vi.fn() -const mockEdges: Array<{ id: string, source: string, target: string }> = [] +const mockEdges: Array<{ id: string; source: string; target: string }> = [] vi.mock('reactflow', () => ({ useStoreApi: () => ({ @@ -15,8 +15,12 @@ vi.mock('reactflow', () => ({ edges: mockEdges, }), }), - getOutgoers: (node: { id: string }, nodes: Array<{ id: string }>, edges: Array<{ source: string, target: string }>) => { - return nodes.filter(n => edges.some(e => e.source === node.id && e.target === n.id)) + getOutgoers: ( + node: { id: string }, + nodes: Array<{ id: string }>, + edges: Array<{ source: string; target: string }>, + ) => { + return nodes.filter((n) => edges.some((e) => e.source === node.id && e.target === n.id)) }, })) @@ -38,8 +42,7 @@ vi.mock('es-toolkit/compat', () => ({ const seen = new Set<string>() return arr.filter((item) => { const val = item[key as keyof typeof item] as string - if (seen.has(val)) - return false + if (seen.has(val)) return false seen.add(val) return true }) @@ -98,10 +101,7 @@ describe('usePipeline', () => { const isUsed = result.current.isVarUsedInNodes(['rag', 'ds-1', 'var1']) expect(isUsed).toBe(true) - expect(mockFindUsedVarNodes).toHaveBeenCalledWith( - ['rag', 'ds-1', 'var1'], - expect.any(Array), - ) + expect(mockFindUsedVarNodes).toHaveBeenCalledWith(['rag', 'ds-1', 'var1'], expect.any(Array)) }) it('should return false when variable is not used', () => { @@ -278,7 +278,7 @@ describe('usePipeline', () => { expect(isUsed).toBe(true) const nodesArg = mockFindUsedVarNodes.mock.calls[0]![1] as Array<{ id: string }> - const nodeIds = nodesArg.map(n => n.id) + const nodeIds = nodesArg.map((n) => n.id) expect(nodeIds).toContain('ds-1') expect(nodeIds).toContain('node-2') expect(nodeIds).toContain('node-3') @@ -313,7 +313,7 @@ describe('usePipeline', () => { result.current.isVarUsedInNodes(['rag', 'ds-1', 'var1']) const nodesArg = mockFindUsedVarNodes.mock.calls[0]![1] as Array<{ id: string }> - const nodeIds = nodesArg.map(n => n.id) + const nodeIds = nodesArg.map((n) => n.id) const uniqueIds = [...new Set(nodeIds)] expect(nodeIds.length).toBe(uniqueIds.length) }) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-rag-pipeline-search.spec.tsx b/web/app/components/rag-pipeline/hooks/__tests__/use-rag-pipeline-search.spec.tsx index b49960c5a4c7b7..9f0a6223603f81 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-rag-pipeline-search.spec.tsx +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-rag-pipeline-search.spec.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BlockEnum } from '@/app/components/workflow/types' import { useRagPipelineSearch } from '../use-rag-pipeline-search' -const mockNodes: Array<{ id: string, data: Record<string, unknown> }> = [] +const mockNodes: Array<{ id: string; data: Record<string, unknown> }> = [] vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ default: () => mockNodes, })) @@ -109,11 +109,22 @@ describe('useRagPipelineSearch', () => { }, { id: 'node-2', - data: { type: BlockEnum.KnowledgeRetrieval, title: 'Knowledge Base', desc: 'Search knowledge', dataset_ids: ['ds1', 'ds2'] }, + data: { + type: BlockEnum.KnowledgeRetrieval, + title: 'Knowledge Base', + desc: 'Search knowledge', + dataset_ids: ['ds1', 'ds2'], + }, }, { id: 'node-3', - data: { type: BlockEnum.Tool, title: 'Web Search', desc: '', tool_description: 'Search the web', tool_label: 'WebSearch' }, + data: { + type: BlockEnum.Tool, + title: 'Web Search', + desc: '', + tool_description: 'Search the web', + tool_label: 'WebSearch', + }, }, { id: 'node-4', @@ -138,7 +149,7 @@ describe('useRagPipelineSearch', () => { const searchFn = mockRagPipelineNodesAction.searchFn! const results = searchFn(BlockEnum.LLM) - expect(results.some(r => r.title === 'GPT Model')).toBe(true) + expect(results.some((r) => r.title === 'GPT Model')).toBe(true) }) it('should find nodes by description', () => { @@ -147,7 +158,7 @@ describe('useRagPipelineSearch', () => { const searchFn = mockRagPipelineNodesAction.searchFn! const results = searchFn('knowledge') - expect(results.some(r => r.title === 'Knowledge Base')).toBe(true) + expect(results.some((r) => r.title === 'Knowledge Base')).toBe(true) }) it('should return all nodes when search term is empty', () => { @@ -164,7 +175,7 @@ describe('useRagPipelineSearch', () => { const searchFn = mockRagPipelineNodesAction.searchFn! const results = searchFn('') - const titles = results.map(r => r.title) + const titles = results.map((r) => r.title) const sortedTitles = [...titles].sort((a, b) => a.localeCompare(b)) expect(titles).toEqual(sortedTitles) @@ -194,7 +205,7 @@ describe('useRagPipelineSearch', () => { const searchFn = mockRagPipelineNodesAction.searchFn! const results = searchFn('web') - const toolResult = results.find(r => r.title === 'Web Search') + const toolResult = results.find((r) => r.title === 'Web Search') expect(toolResult).toBeDefined() expect(toolResult?.description).toContain('Search the web') }) @@ -205,7 +216,7 @@ describe('useRagPipelineSearch', () => { const searchFn = mockRagPipelineNodesAction.searchFn! const results = searchFn('Start') - const startResult = results.find(r => r.title === 'Start Node') + const startResult = results.find((r) => r.title === 'Start Node') expect(startResult?.metadata?.nodeId).toBe('node-4') }) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts index 6876df90141e6c..329b1dbdec976e 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts @@ -17,7 +17,9 @@ const toastMocks = vi.hoisted(() => { const call = vi.fn() return { call, - api: vi.fn((message: unknown, options?: Record<string, unknown>) => call({ message, ...options })), + api: vi.fn((message: unknown, options?: Record<string, unknown>) => + call({ message, ...options }), + ), dismiss: vi.fn(), update: vi.fn(), promise: vi.fn(), @@ -26,10 +28,18 @@ const toastMocks = vi.hoisted(() => { vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.api, { - success: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'success', message, ...options })), - error: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'error', message, ...options })), - warning: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'warning', message, ...options })), - info: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'info', message, ...options })), + success: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'success', message, ...options }), + ), + error: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'info', message, ...options }), + ), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -374,12 +384,17 @@ describe('useUpdateDSLModal', () => { }) it('should notify response error when importDSL rejects with a response body', async () => { - mockImportDSL.mockRejectedValue(new Response(JSON.stringify({ - error: 'Missing rag_pipeline data in YAML content', - }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - })) + mockImportDSL.mockRejectedValue( + new Response( + JSON.stringify({ + error: 'Missing rag_pipeline data in YAML content', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) const { result } = renderUpdateDSLModal() act(() => { @@ -525,12 +540,17 @@ describe('useUpdateDSLModal', () => { }) it('should notify response error when confirm rejects with a response body', async () => { - mockImportDSLConfirm.mockRejectedValue(new Response(JSON.stringify({ - error: 'Import information expired or does not exist', - }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - })) + mockImportDSLConfirm.mockRejectedValue( + new Response( + JSON.stringify({ + error: 'Import information expired or does not exist', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) const { result } = renderUpdateDSLModal() await setupPendingState(result) @@ -574,9 +594,7 @@ describe('useUpdateDSLModal', () => { describe('optional onImport', () => { it('should work without onImport callback', async () => { - const { result } = renderHook(() => - useUpdateDSLModal({ onCancel: mockOnCancel }), - ) + const { result } = renderHook(() => useUpdateDSLModal({ onCancel: mockOnCancel })) act(() => { result.current.handleFile(createFile()) diff --git a/web/app/components/rag-pipeline/hooks/use-DSL.ts b/web/app/components/rag-pipeline/hooks/use-DSL.ts index 62bf4d50bb0087..9158d14b871437 100644 --- a/web/app/components/rag-pipeline/hooks/use-DSL.ts +++ b/web/app/components/rag-pipeline/hooks/use-DSL.ts @@ -1,6 +1,4 @@ -import type { - useNodesSyncDraft, -} from './use-nodes-sync-draft' +import type { useNodesSyncDraft } from './use-nodes-sync-draft' import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -10,9 +8,7 @@ import { useEventEmitterContextContext } from '@/context/event-emitter' import { useExportPipelineDSL } from '@/service/use-pipeline' import { fetchWorkflowDraft } from '@/service/workflow' import { downloadBlob } from '@/utils/download' -import { - useNodesSyncDraftByCanEdit, -} from './use-nodes-sync-draft' +import { useNodesSyncDraftByCanEdit } from './use-nodes-sync-draft' type DoSyncWorkflowDraft = ReturnType<typeof useNodesSyncDraft>['doSyncWorkflowDraft'] @@ -22,36 +18,36 @@ const useDSLBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { const [exporting, setExporting] = useState(false) const workflowStore = useWorkflowStore() const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL() - const handleExportDSL = useCallback(async (include = false) => { - const { pipelineId, knowledgeName } = workflowStore.getState() - if (!pipelineId) - return - if (exporting) - return - try { - setExporting(true) - await doSyncWorkflowDraft() - const { data } = await exportPipelineConfig({ - pipelineId, - include, - }) - const file = new Blob([data], { type: 'application/yaml' }) - downloadBlob({ data: file, fileName: `${knowledgeName}.pipeline` }) - } - catch { - toast.error(t($ => $.exportFailed, { ns: 'app' })) - } - finally { - setExporting(false) - } - }, [t, doSyncWorkflowDraft, exporting, exportPipelineConfig, workflowStore]) + const handleExportDSL = useCallback( + async (include = false) => { + const { pipelineId, knowledgeName } = workflowStore.getState() + if (!pipelineId) return + if (exporting) return + try { + setExporting(true) + await doSyncWorkflowDraft() + const { data } = await exportPipelineConfig({ + pipelineId, + include, + }) + const file = new Blob([data], { type: 'application/yaml' }) + downloadBlob({ data: file, fileName: `${knowledgeName}.pipeline` }) + } catch { + toast.error(t(($) => $.exportFailed, { ns: 'app' })) + } finally { + setExporting(false) + } + }, + [t, doSyncWorkflowDraft, exporting, exportPipelineConfig, workflowStore], + ) const exportCheck = useCallback(async () => { const { pipelineId } = workflowStore.getState() - if (!pipelineId) - return + if (!pipelineId) return try { const workflowDraft = await fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`) - const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret') + const list = (workflowDraft.environment_variables || []).filter( + (env) => env.value_type === 'secret', + ) if (list.length === 0) { handleExportDSL() return @@ -62,9 +58,8 @@ const useDSLBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { data: list, }, } as any) - } - catch { - toast.error(t($ => $.exportFailed, { ns: 'app' })) + } catch { + toast.error(t(($) => $.exportFailed, { ns: 'app' })) } }, [eventEmitter, handleExportDSL, t, workflowStore]) return { diff --git a/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts b/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts index fb5a6b03b6f161..6cd8e7de2906c0 100644 --- a/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts @@ -15,53 +15,72 @@ export const useAvailableNodesMetaData = () => { const docLink = useDocLink() const agentV2Enabled = isAgentV2Enabled() - const mergedNodesMetaData = useMemo(() => [ - ...WORKFLOW_COMMON_NODES.filter(node => - node.metaData.type !== BlockEnum.HumanInput - && ( - agentV2Enabled - ? node.metaData.type !== BlockEnum.Agent - : node.metaData.type !== BlockEnum.AgentV2 - )), - { - ...dataSourceDefault, - defaultValue: { - ...dataSourceDefault.defaultValue, - _dataSourceStartToAdd: true, + const mergedNodesMetaData = useMemo( + () => [ + ...WORKFLOW_COMMON_NODES.filter( + (node) => + node.metaData.type !== BlockEnum.HumanInput && + (agentV2Enabled + ? node.metaData.type !== BlockEnum.Agent + : node.metaData.type !== BlockEnum.AgentV2), + ), + { + ...dataSourceDefault, + defaultValue: { + ...dataSourceDefault.defaultValue, + _dataSourceStartToAdd: true, + }, }, - }, - knowledgeBaseDefault, - dataSourceEmptyDefault, - ], [agentV2Enabled]) + knowledgeBaseDefault, + dataSourceEmptyDefault, + ], + [agentV2Enabled], + ) - const helpLinkUri = useMemo(() => docLink( - '/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration', - ), [docLink]) + const helpLinkUri = useMemo( + () => docLink('/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration'), + [docLink], + ) - const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => { - const { metaData } = node - const title = t($ => $[`blocks.${metaData.type}`], { ns: 'workflow' }) - const description = t($ => $[`blocksAbout.${metaData.type}` as I18nKeysWithPrefix<'workflow', 'blocksAbout.'>], { ns: 'workflow' }) - return { - ...node, - metaData: { - ...metaData, - title, - description, - helpLinkUri, - }, - defaultValue: { - ...node.defaultValue, - type: metaData.type === BlockEnum.AgentV2 ? BlockEnum.Agent : metaData.type, - title, - }, - } - }), [helpLinkUri, mergedNodesMetaData, t]) + const availableNodesMetaData = useMemo( + () => + mergedNodesMetaData.map((node) => { + const { metaData } = node + const title = t(($) => $[`blocks.${metaData.type}`], { ns: 'workflow' }) + const description = t( + ($) => + $[`blocksAbout.${metaData.type}` as I18nKeysWithPrefix<'workflow', 'blocksAbout.'>], + { ns: 'workflow' }, + ) + return { + ...node, + metaData: { + ...metaData, + title, + description, + helpLinkUri, + }, + defaultValue: { + ...node.defaultValue, + type: metaData.type === BlockEnum.AgentV2 ? BlockEnum.Agent : metaData.type, + title, + }, + } + }), + [helpLinkUri, mergedNodesMetaData, t], + ) - const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => { - acc![node.metaData.type] = node - return acc - }, {} as AvailableNodesMetaData['nodesMap']), [availableNodesMetaData]) + const availableNodesMetaDataMap = useMemo( + () => + availableNodesMetaData.reduce( + (acc, node) => { + acc![node.metaData.type] = node + return acc + }, + {} as AvailableNodesMetaData['nodesMap'], + ), + [availableNodesMetaData], + ) return useMemo(() => { return { diff --git a/web/app/components/rag-pipeline/hooks/use-configs-map.ts b/web/app/components/rag-pipeline/hooks/use-configs-map.ts index 99c42923495b17..221a87fc79d413 100644 --- a/web/app/components/rag-pipeline/hooks/use-configs-map.ts +++ b/web/app/components/rag-pipeline/hooks/use-configs-map.ts @@ -4,8 +4,8 @@ import { Resolution, TransferMethod } from '@/types/app' import { FlowType } from '@/types/common' export const useConfigsMap = () => { - const pipelineId = useStore(s => s.pipelineId) - const fileUploadConfig = useStore(s => s.fileUploadConfig) + const pipelineId = useStore((s) => s.pipelineId) + const fileUploadConfig = useStore((s) => s.fileUploadConfig) return useMemo(() => { return { flowId: pipelineId!, diff --git a/web/app/components/rag-pipeline/hooks/use-get-run-and-trace-url.ts b/web/app/components/rag-pipeline/hooks/use-get-run-and-trace-url.ts index f9988b60f8f45f..6b19ba6f399906 100644 --- a/web/app/components/rag-pipeline/hooks/use-get-run-and-trace-url.ts +++ b/web/app/components/rag-pipeline/hooks/use-get-run-and-trace-url.ts @@ -3,14 +3,17 @@ import { useWorkflowStore } from '@/app/components/workflow/store' export const useGetRunAndTraceUrl = () => { const workflowStore = useWorkflowStore() - const getWorkflowRunAndTraceUrl = useCallback((runId: string) => { - const { pipelineId } = workflowStore.getState() + const getWorkflowRunAndTraceUrl = useCallback( + (runId: string) => { + const { pipelineId } = workflowStore.getState() - return { - runUrl: `/rag/pipelines/${pipelineId}/workflow-runs/${runId}`, - traceUrl: `/rag/pipelines/${pipelineId}/workflow-runs/${runId}/node-executions`, - } - }, [workflowStore]) + return { + runUrl: `/rag/pipelines/${pipelineId}/workflow-runs/${runId}`, + traceUrl: `/rag/pipelines/${pipelineId}/workflow-runs/${runId}/node-executions`, + } + }, + [workflowStore], + ) return { getWorkflowRunAndTraceUrl, diff --git a/web/app/components/rag-pipeline/hooks/use-input-field-panel.ts b/web/app/components/rag-pipeline/hooks/use-input-field-panel.ts index c6f43caffd8ed9..5f5f075d429b99 100644 --- a/web/app/components/rag-pipeline/hooks/use-input-field-panel.ts +++ b/web/app/components/rag-pipeline/hooks/use-input-field-panel.ts @@ -4,8 +4,8 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store' export const useInputFieldPanel = () => { const workflowStore = useWorkflowStore() - const showInputFieldPreviewPanel = useStore(state => state.showInputFieldPreviewPanel) - const inputFieldEditPanelProps = useStore(state => state.inputFieldEditPanelProps) + const showInputFieldPreviewPanel = useStore((state) => state.showInputFieldPreviewPanel) + const inputFieldEditPanelProps = useStore((state) => state.inputFieldEditPanelProps) const isPreviewing = useMemo(() => { return showInputFieldPreviewPanel @@ -16,11 +16,8 @@ export const useInputFieldPanel = () => { }, [inputFieldEditPanelProps]) const closeAllInputFieldPanels = useCallback(() => { - const { - setShowInputFieldPanel, - setShowInputFieldPreviewPanel, - setInputFieldEditPanelProps, - } = workflowStore.getState() + const { setShowInputFieldPanel, setShowInputFieldPreviewPanel, setInputFieldEditPanelProps } = + workflowStore.getState() setShowInputFieldPanel?.(false) setShowInputFieldPreviewPanel?.(false) @@ -28,21 +25,19 @@ export const useInputFieldPanel = () => { }, [workflowStore]) const toggleInputFieldPreviewPanel = useCallback(() => { - const { - showInputFieldPreviewPanel, - setShowInputFieldPreviewPanel, - } = workflowStore.getState() + const { showInputFieldPreviewPanel, setShowInputFieldPreviewPanel } = workflowStore.getState() setShowInputFieldPreviewPanel?.(!showInputFieldPreviewPanel) }, [workflowStore]) - const toggleInputFieldEditPanel = useCallback((editContent: InputFieldEditorProps | null) => { - const { - setInputFieldEditPanelProps, - } = workflowStore.getState() + const toggleInputFieldEditPanel = useCallback( + (editContent: InputFieldEditorProps | null) => { + const { setInputFieldEditPanelProps } = workflowStore.getState() - setInputFieldEditPanelProps?.(editContent) - }, [workflowStore]) + setInputFieldEditPanelProps?.(editContent) + }, + [workflowStore], + ) return { closeAllInputFieldPanels, diff --git a/web/app/components/rag-pipeline/hooks/use-input-fields.ts b/web/app/components/rag-pipeline/hooks/use-input-fields.ts index 09ca5e3887621c..dfa27771c2c24f 100644 --- a/web/app/components/rag-pipeline/hooks/use-input-fields.ts +++ b/web/app/components/rag-pipeline/hooks/use-input-fields.ts @@ -4,22 +4,26 @@ import { useMemo } from 'react' import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' import { VAR_TYPE_MAP } from '@/models/pipeline' -export const useInitialData = (variables: RAGPipelineVariables, lastRunInputData?: Record<string, any>) => { +export const useInitialData = ( + variables: RAGPipelineVariables, + lastRunInputData?: Record<string, any>, +) => { const initialData = useMemo(() => { - return variables.reduce((acc, item) => { - const type = VAR_TYPE_MAP[item.type] - const variableName = item.variable - const defaultValue = lastRunInputData?.[variableName] || item.default_value - if ([BaseFieldType.textInput, BaseFieldType.paragraph, BaseFieldType.select].includes(type)) - acc[variableName] = defaultValue ?? '' - if (type === BaseFieldType.numberInput) - acc[variableName] = defaultValue ?? 0 - if (type === BaseFieldType.checkbox) - acc[variableName] = defaultValue ?? false - if ([BaseFieldType.file, BaseFieldType.fileList].includes(type)) - acc[variableName] = defaultValue ?? [] - return acc - }, {} as Record<string, any>) + return variables.reduce( + (acc, item) => { + const type = VAR_TYPE_MAP[item.type] + const variableName = item.variable + const defaultValue = lastRunInputData?.[variableName] || item.default_value + if ([BaseFieldType.textInput, BaseFieldType.paragraph, BaseFieldType.select].includes(type)) + acc[variableName] = defaultValue ?? '' + if (type === BaseFieldType.numberInput) acc[variableName] = defaultValue ?? 0 + if (type === BaseFieldType.checkbox) acc[variableName] = defaultValue ?? false + if ([BaseFieldType.file, BaseFieldType.fileList].includes(type)) + acc[variableName] = defaultValue ?? [] + return acc + }, + {} as Record<string, any>, + ) }, [lastRunInputData, variables]) return initialData @@ -35,7 +39,7 @@ export const useConfigurations = (variables: RAGPipelineVariables) => { label: item.label, required: item.required, maxLength: item.max_length, - options: item.options?.map(option => ({ + options: item.options?.map((option) => ({ label: option, value: option, })), diff --git a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts index fe5d8df971ccb5..cf4ff6a8817458 100644 --- a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts @@ -7,9 +7,7 @@ import { useNodesReadOnly, useNodesReadOnlyByCanEdit, } from '@/app/components/workflow/hooks/use-workflow' -import { - useWorkflowStore, -} from '@/app/components/workflow/store' +import { useWorkflowStore } from '@/app/components/workflow/store' import { API_PREFIX } from '@/config' import { postWithKeepalive } from '@/service/fetch' import { syncWorkflowDraft } from '@/service/workflow' @@ -21,35 +19,25 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { const { handleRefreshWorkflowDraft } = usePipelineRefreshDraft() const getPostParams = useCallback(() => { - const { - getNodes, - edges, - transform, - } = store.getState() + const { getNodes, edges, transform } = store.getState() const nodesOriginal = getNodes() - const nodes = nodesOriginal.filter(node => !node.data._isTempNode) + const nodes = nodesOriginal.filter((node) => !node.data._isTempNode) const [x, y, zoom] = transform - const { - pipelineId, - environmentVariables, - syncWorkflowDraftHash, - ragPipelineVariables, - } = workflowStore.getState() + const { pipelineId, environmentVariables, syncWorkflowDraftHash, ragPipelineVariables } = + workflowStore.getState() if (pipelineId && !!nodes.length) { const producedNodes = produce(nodes, (draft) => { draft.forEach((node) => { Object.keys(node.data).forEach((key) => { - if (key.startsWith('_')) - delete node.data[key] + if (key.startsWith('_')) delete node.data[key] }) }) }) const producedEdges = produce(edges, (draft) => { draft.forEach((edge) => { Object.keys(edge.data).forEach((key) => { - if (key.startsWith('_')) - delete edge.data[key] + if (key.startsWith('_')) delete edge.data[key] }) }) }) @@ -74,47 +62,39 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { }, [store, workflowStore]) const syncWorkflowDraftWhenPageClose = useCallback(() => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const postParams = getPostParams() - if (postParams) - postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params) + if (postParams) postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params) }, [getPostParams, getNodesReadOnly]) - const performSync = useCallback(async ( - notRefreshWhenSyncError?: boolean, - callback?: SyncDraftCallback, - ) => { - if (getNodesReadOnly()) - return + const performSync = useCallback( + async (notRefreshWhenSyncError?: boolean, callback?: SyncDraftCallback) => { + if (getNodesReadOnly()) return - const postParams = getPostParams() - if (postParams) { - const { - setSyncWorkflowDraftHash, - setDraftUpdatedAt, - } = workflowStore.getState() - try { - const res = await syncWorkflowDraft(postParams) - setSyncWorkflowDraftHash(res.hash) - setDraftUpdatedAt(res.updated_at) - callback?.onSuccess?.() - } - catch (error: any) { - if (error && error.json && !error.bodyUsed) { - error.json().then((err: any) => { - if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) - handleRefreshWorkflowDraft() - }) + const postParams = getPostParams() + if (postParams) { + const { setSyncWorkflowDraftHash, setDraftUpdatedAt } = workflowStore.getState() + try { + const res = await syncWorkflowDraft(postParams) + setSyncWorkflowDraftHash(res.hash) + setDraftUpdatedAt(res.updated_at) + callback?.onSuccess?.() + } catch (error: any) { + if (error && error.json && !error.bodyUsed) { + error.json().then((err: any) => { + if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) + handleRefreshWorkflowDraft() + }) + } + callback?.onError?.() + } finally { + callback?.onSettled?.() } - callback?.onError?.() - } - finally { - callback?.onSettled?.() } - } - }, [getPostParams, getNodesReadOnly, workflowStore, handleRefreshWorkflowDraft]) + }, + [getPostParams, getNodesReadOnly, workflowStore, handleRefreshWorkflowDraft], + ) const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly) diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-config.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-config.ts index b1871248fc8243..c8fd1a1796924d 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-config.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-config.ts @@ -2,61 +2,69 @@ import type { DataSourceItem } from '@/app/components/workflow/block-selector/ty import type { FileUploadConfigResponse } from '@/models/common' import type { FetchWorkflowDraftResponse } from '@/types/workflow' import { useCallback } from 'react' -import { - useStore, - useWorkflowStore, -} from '@/app/components/workflow/store' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { useDataSourceList } from '@/service/use-pipeline' import { useWorkflowConfig } from '@/service/use-workflow' import { basePath } from '@/utils/var' export const usePipelineConfig = () => { - const pipelineId = useStore(s => s.pipelineId) + const pipelineId = useStore((s) => s.pipelineId) const workflowStore = useWorkflowStore() - const handleUpdateNodesDefaultConfigs = useCallback((nodesDefaultConfigs: Record<string, any> | Record<string, any>[]) => { - const { setNodesDefaultConfigs } = workflowStore.getState() - let res: Record<string, any> = {} - if (Array.isArray(nodesDefaultConfigs)) { - nodesDefaultConfigs.forEach((item) => { - res[item.type] = item.config - }) - } - else { - res = nodesDefaultConfigs as Record<string, any> - } + const handleUpdateNodesDefaultConfigs = useCallback( + (nodesDefaultConfigs: Record<string, any> | Record<string, any>[]) => { + const { setNodesDefaultConfigs } = workflowStore.getState() + let res: Record<string, any> = {} + if (Array.isArray(nodesDefaultConfigs)) { + nodesDefaultConfigs.forEach((item) => { + res[item.type] = item.config + }) + } else { + res = nodesDefaultConfigs as Record<string, any> + } - setNodesDefaultConfigs!(res) - }, [workflowStore]) + setNodesDefaultConfigs!(res) + }, + [workflowStore], + ) useWorkflowConfig( pipelineId ? `/rag/pipelines/${pipelineId}/workflows/default-workflow-block-configs` : '', handleUpdateNodesDefaultConfigs, ) - const handleUpdatePublishedAt = useCallback((publishedWorkflow: FetchWorkflowDraftResponse | null) => { - const { setPublishedAt } = workflowStore.getState() + const handleUpdatePublishedAt = useCallback( + (publishedWorkflow: FetchWorkflowDraftResponse | null) => { + const { setPublishedAt } = workflowStore.getState() - setPublishedAt(publishedWorkflow?.created_at ?? 0) - }, [workflowStore]) + setPublishedAt(publishedWorkflow?.created_at ?? 0) + }, + [workflowStore], + ) useWorkflowConfig( pipelineId ? `/rag/pipelines/${pipelineId}/workflows/publish` : '', handleUpdatePublishedAt, ) - const handleUpdateDataSourceList = useCallback((dataSourceList: DataSourceItem[]) => { - dataSourceList.forEach((item) => { - const icon = item.declaration.identity.icon - if (typeof icon == 'string' && !icon.includes(basePath)) - item.declaration.identity.icon = `${basePath}${icon}` - }) - const { setDataSourceList } = workflowStore.getState() - setDataSourceList!(dataSourceList) - }, [workflowStore]) - - const handleUpdateWorkflowFileUploadConfig = useCallback((config: FileUploadConfigResponse) => { - const { setFileUploadConfig } = workflowStore.getState() - setFileUploadConfig(config) - }, [workflowStore]) + const handleUpdateDataSourceList = useCallback( + (dataSourceList: DataSourceItem[]) => { + dataSourceList.forEach((item) => { + const icon = item.declaration.identity.icon + if (typeof icon == 'string' && !icon.includes(basePath)) + item.declaration.identity.icon = `${basePath}${icon}` + }) + const { setDataSourceList } = workflowStore.getState() + setDataSourceList!(dataSourceList) + }, + [workflowStore], + ) + + const handleUpdateWorkflowFileUploadConfig = useCallback( + (config: FileUploadConfigResponse) => { + const { setFileUploadConfig } = workflowStore.getState() + setFileUploadConfig(config) + }, + [workflowStore], + ) useWorkflowConfig('/files/upload', handleUpdateWorkflowFileUploadConfig) useDataSourceList(!!pipelineId, handleUpdateDataSourceList) diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts index db5eb95fcaad13..62c05f56666b8c 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts @@ -1,31 +1,19 @@ import type { FetchWorkflowDraftResponse } from '@/types/workflow' -import { - useCallback, - useEffect, - useState, -} from 'react' -import { - useWorkflowStore, -} from '@/app/components/workflow/store' +import { useCallback, useEffect, useState } from 'react' +import { useWorkflowStore } from '@/app/components/workflow/store' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { - fetchWorkflowDraft, - syncWorkflowDraft, -} from '@/service/workflow' +import { fetchWorkflowDraft, syncWorkflowDraft } from '@/service/workflow' import { usePipelineConfig } from './use-pipeline-config' import { usePipelineTemplate } from './use-pipeline-template' export const usePipelineInit = () => { const workflowStore = useWorkflowStore() - const { - nodes: nodesTemplate, - edges: edgesTemplate, - } = usePipelineTemplate() + const { nodes: nodesTemplate, edges: edgesTemplate } = usePipelineTemplate() const [data, setData] = useState<FetchWorkflowDraftResponse>() const [isLoading, setIsLoading] = useState(true) - const datasetId = useDatasetDetailContextWithSelector(s => s.dataset)?.pipeline_id - const knowledgeName = useDatasetDetailContextWithSelector(s => s.dataset)?.name - const knowledgeIcon = useDatasetDetailContextWithSelector(s => s.dataset)?.icon_info + const datasetId = useDatasetDetailContextWithSelector((s) => s.dataset)?.pipeline_id + const knowledgeName = useDatasetDetailContextWithSelector((s) => s.dataset)?.name + const knowledgeIcon = useDatasetDetailContextWithSelector((s) => s.dataset)?.icon_info useEffect(() => { workflowStore.setState({ pipelineId: datasetId, knowledgeName, knowledgeIcon }) @@ -47,16 +35,26 @@ export const usePipelineInit = () => { setData(res) setDraftUpdatedAt(res.updated_at) setToolPublished(res.tool_published) - setEnvSecrets((res.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { - acc[env.id] = env.value - return acc - }, {} as Record<string, string>)) - setEnvironmentVariables(res.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || []) + setEnvSecrets( + (res.environment_variables || []) + .filter((env) => env.value_type === 'secret') + .reduce( + (acc, env) => { + acc[env.id] = env.value + return acc + }, + {} as Record<string, string>, + ), + ) + setEnvironmentVariables( + res.environment_variables?.map((env) => + env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env, + ) || [], + ) setSyncWorkflowDraftHash(res.hash) setRagPipelineVariables?.(res.rag_pipeline_variables || []) setIsLoading(false) - } - catch (error: any) { + } catch (error: any) { if (error && error.json && !error.bodyUsed && datasetId) { error.json().then((err: any) => { if (err.code === 'draft_workflow_not_exist') { diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.ts index c9966a90c5a56c..e02a21d7a22c42 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.ts @@ -19,24 +19,37 @@ export const usePipelineRefreshDraft = () => { setRagPipelineVariables, } = workflowStore.getState() setIsSyncingWorkflowDraft(true) - fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`).then((response) => { - const { - nodes: processedNodes, - viewport, - } = processNodesWithoutDataSource(response.graph.nodes, response.graph.viewport) - handleUpdateWorkflowCanvas({ - ...response.graph, - nodes: processedNodes, - viewport, - } as WorkflowDataUpdater) - setSyncWorkflowDraftHash(response.hash) - setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { - acc[env.id] = env.value - return acc - }, {} as Record<string, string>)) - setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || []) - setRagPipelineVariables?.(response.rag_pipeline_variables || []) - }).finally(() => setIsSyncingWorkflowDraft(false)) + fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`) + .then((response) => { + const { nodes: processedNodes, viewport } = processNodesWithoutDataSource( + response.graph.nodes, + response.graph.viewport, + ) + handleUpdateWorkflowCanvas({ + ...response.graph, + nodes: processedNodes, + viewport, + } as WorkflowDataUpdater) + setSyncWorkflowDraftHash(response.hash) + setEnvSecrets( + (response.environment_variables || []) + .filter((env) => env.value_type === 'secret') + .reduce( + (acc, env) => { + acc[env.id] = env.value + return acc + }, + {} as Record<string, string>, + ), + ) + setEnvironmentVariables( + response.environment_variables?.map((env) => + env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env, + ) || [], + ) + setRagPipelineVariables?.(response.rag_pipeline_variables || []) + }) + .finally(() => setIsSyncingWorkflowDraft(false)) }, [handleUpdateWorkflowCanvas, workflowStore]) return { diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-run.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-run.ts index 1b9a89d7eb434b..7652d62209eff7 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-run.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-run.ts @@ -1,14 +1,9 @@ -import type { - useNodesSyncDraft, -} from './use-nodes-sync-draft' +import type { useNodesSyncDraft } from './use-nodes-sync-draft' import type { IOtherOptions } from '@/service/base' import type { VersionHistory } from '@/types/workflow' import { produce } from 'immer' import { useCallback, useRef } from 'react' -import { - useReactFlow, - useStoreApi, -} from 'reactflow' +import { useReactFlow, useStoreApi } from 'reactflow' import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars' import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions' import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event' @@ -18,9 +13,7 @@ import { ssePost } from '@/service/base' import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow' import { stopWorkflowRun } from '@/service/workflow' import { FlowType } from '@/types/common' -import { - useNodesSyncDraftByCanEdit, -} from './use-nodes-sync-draft' +import { useNodesSyncDraftByCanEdit } from './use-nodes-sync-draft' type DoSyncWorkflowDraft = ReturnType<typeof useNodesSyncDraft>['doSyncWorkflowDraft'] @@ -51,16 +44,9 @@ const usePipelineRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { const abortControllerRef = useRef<AbortController | null>(null) const handleBackupDraft = useCallback(() => { - const { - getNodes, - edges, - } = store.getState() + const { getNodes, edges } = store.getState() const { getViewport } = reactflow - const { - backupDraft, - setBackupDraft, - environmentVariables, - } = workflowStore.getState() + const { backupDraft, setBackupDraft, environmentVariables } = workflowStore.getState() if (!backupDraft) { setBackupDraft({ @@ -74,19 +60,10 @@ const usePipelineRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { }, [reactflow, workflowStore, store, doSyncWorkflowDraft]) const handleLoadBackupDraft = useCallback(() => { - const { - backupDraft, - setBackupDraft, - setEnvironmentVariables, - } = workflowStore.getState() + const { backupDraft, setBackupDraft, setEnvironmentVariables } = workflowStore.getState() if (backupDraft) { - const { - nodes, - edges, - viewport, - environmentVariables, - } = backupDraft + const { nodes, edges, viewport, environmentVariables } = backupDraft handleUpdateWorkflowCanvas({ nodes, edges, @@ -97,7 +74,7 @@ const usePipelineRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { } }, [handleUpdateWorkflowCanvas, workflowStore]) - const pipelineId = useStore(s => s.pipelineId) + const pipelineId = useStore((s) => s.pipelineId) const invalidAllLastRun = useInvalidAllLastRun(FlowType.ragPipeline, pipelineId) const invalidateRunHistory = useInvalidateWorkflowRunHistory() const { fetchInspectVars } = useSetWorkflowVarsWithValue({ @@ -105,213 +82,218 @@ const usePipelineRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { flowId: pipelineId!, }) - const handleRun = useCallback(async ( - params: any, - callback?: IOtherOptions, - ) => { - const { - getNodes, - setNodes, - } = store.getState() - const newNodes = produce(getNodes(), (draft) => { - draft.forEach((node) => { - node.data.selected = false - node.data._runningStatus = undefined + const handleRun = useCallback( + async (params: any, callback?: IOtherOptions) => { + const { getNodes, setNodes } = store.getState() + const newNodes = produce(getNodes(), (draft) => { + draft.forEach((node) => { + node.data.selected = false + node.data._runningStatus = undefined + }) }) - }) - setNodes(newNodes) - await doSyncWorkflowDraft() - - const { - onWorkflowStarted, - onWorkflowFinished, - onNodeStarted, - onNodeFinished, - onIterationStart, - onIterationNext, - onIterationFinish, - onLoopStart, - onLoopNext, - onLoopFinish, - onNodeRetry, - onAgentLog, - onError, - ...restCallback - } = callback || {} - const { pipelineId } = workflowStore.getState() - const runHistoryUrl = `/rag/pipelines/${pipelineId}/workflow-runs` - workflowStore.setState({ historyWorkflowData: undefined }) - const workflowContainer = document.getElementById('workflow-container') - - const { - clientWidth, - clientHeight, - } = workflowContainer! - - const url = `/rag/pipelines/${pipelineId}/workflows/draft/run` - - const { - setWorkflowRunningData, - } = workflowStore.getState() - setWorkflowRunningData({ - result: { - inputs_truncated: false, - process_data_truncated: false, - outputs_truncated: false, - status: WorkflowRunningStatus.Running, - }, - tracing: [], - resultText: '', - }) - - abortControllerRef.current?.abort() - abortControllerRef.current = null - - ssePost( - url, - { - body: params, - }, - { - getAbortController: (controller: AbortController) => { - abortControllerRef.current = controller - }, - onWorkflowStarted: (params) => { - handleWorkflowStarted(params) - invalidateRunHistory(runHistoryUrl) + setNodes(newNodes) + await doSyncWorkflowDraft() - if (onWorkflowStarted) - onWorkflowStarted(params) - }, - onWorkflowFinished: (params) => { - handleWorkflowFinished(params) - invalidateRunHistory(runHistoryUrl) - fetchInspectVars({}) - invalidAllLastRun() - - if (onWorkflowFinished) - onWorkflowFinished(params) + const { + onWorkflowStarted, + onWorkflowFinished, + onNodeStarted, + onNodeFinished, + onIterationStart, + onIterationNext, + onIterationFinish, + onLoopStart, + onLoopNext, + onLoopFinish, + onNodeRetry, + onAgentLog, + onError, + ...restCallback + } = callback || {} + const { pipelineId } = workflowStore.getState() + const runHistoryUrl = `/rag/pipelines/${pipelineId}/workflow-runs` + workflowStore.setState({ historyWorkflowData: undefined }) + const workflowContainer = document.getElementById('workflow-container') + + const { clientWidth, clientHeight } = workflowContainer! + + const url = `/rag/pipelines/${pipelineId}/workflows/draft/run` + + const { setWorkflowRunningData } = workflowStore.getState() + setWorkflowRunningData({ + result: { + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + status: WorkflowRunningStatus.Running, }, - onError: (params) => { - handleWorkflowFailed() - invalidateRunHistory(runHistoryUrl) + tracing: [], + resultText: '', + }) - if (onError) - onError(params) + abortControllerRef.current?.abort() + abortControllerRef.current = null + + ssePost( + url, + { + body: params, }, - onNodeStarted: (params) => { - handleWorkflowNodeStarted( - params, - { + { + getAbortController: (controller: AbortController) => { + abortControllerRef.current = controller + }, + onWorkflowStarted: (params) => { + handleWorkflowStarted(params) + invalidateRunHistory(runHistoryUrl) + + if (onWorkflowStarted) onWorkflowStarted(params) + }, + onWorkflowFinished: (params) => { + handleWorkflowFinished(params) + invalidateRunHistory(runHistoryUrl) + fetchInspectVars({}) + invalidAllLastRun() + + if (onWorkflowFinished) onWorkflowFinished(params) + }, + onError: (params) => { + handleWorkflowFailed() + invalidateRunHistory(runHistoryUrl) + + if (onError) onError(params) + }, + onNodeStarted: (params) => { + handleWorkflowNodeStarted(params, { clientWidth, clientHeight, - }, - ) + }) - if (onNodeStarted) - onNodeStarted(params) - }, - onNodeFinished: (params) => { - handleWorkflowNodeFinished(params) + if (onNodeStarted) onNodeStarted(params) + }, + onNodeFinished: (params) => { + handleWorkflowNodeFinished(params) - if (onNodeFinished) - onNodeFinished(params) - }, - onIterationStart: (params) => { - handleWorkflowNodeIterationStarted( - params, - { + if (onNodeFinished) onNodeFinished(params) + }, + onIterationStart: (params) => { + handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight, - }, - ) - - if (onIterationStart) - onIterationStart(params) - }, - onIterationNext: (params) => { - handleWorkflowNodeIterationNext(params) - - if (onIterationNext) - onIterationNext(params) - }, - onIterationFinish: (params) => { - handleWorkflowNodeIterationFinished(params) - - if (onIterationFinish) - onIterationFinish(params) - }, - onLoopStart: (params) => { - handleWorkflowNodeLoopStarted( - params, - { + }) + + if (onIterationStart) onIterationStart(params) + }, + onIterationNext: (params) => { + handleWorkflowNodeIterationNext(params) + + if (onIterationNext) onIterationNext(params) + }, + onIterationFinish: (params) => { + handleWorkflowNodeIterationFinished(params) + + if (onIterationFinish) onIterationFinish(params) + }, + onLoopStart: (params) => { + handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight, - }, - ) - - if (onLoopStart) - onLoopStart(params) + }) + + if (onLoopStart) onLoopStart(params) + }, + onLoopNext: (params) => { + handleWorkflowNodeLoopNext(params) + + if (onLoopNext) onLoopNext(params) + }, + onLoopFinish: (params) => { + handleWorkflowNodeLoopFinished(params) + + if (onLoopFinish) onLoopFinish(params) + }, + onNodeRetry: (params) => { + handleWorkflowNodeRetry(params) + + if (onNodeRetry) onNodeRetry(params) + }, + onAgentLog: (params) => { + handleWorkflowAgentLog(params) + + if (onAgentLog) onAgentLog(params) + }, + onTextChunk: (params) => { + handleWorkflowTextChunk(params) + }, + onTextReplace: (params) => { + handleWorkflowTextReplace(params) + }, + ...restCallback, }, - onLoopNext: (params) => { - handleWorkflowNodeLoopNext(params) - - if (onLoopNext) - onLoopNext(params) - }, - onLoopFinish: (params) => { - handleWorkflowNodeLoopFinished(params) - - if (onLoopFinish) - onLoopFinish(params) - }, - onNodeRetry: (params) => { - handleWorkflowNodeRetry(params) - - if (onNodeRetry) - onNodeRetry(params) - }, - onAgentLog: (params) => { - handleWorkflowAgentLog(params) + ) + }, + [ + store, + doSyncWorkflowDraft, + workflowStore, + handleWorkflowStarted, + handleWorkflowFinished, + fetchInspectVars, + invalidAllLastRun, + invalidateRunHistory, + handleWorkflowFailed, + handleWorkflowNodeStarted, + handleWorkflowNodeFinished, + handleWorkflowNodeIterationStarted, + handleWorkflowNodeIterationNext, + handleWorkflowNodeIterationFinished, + handleWorkflowNodeLoopStarted, + handleWorkflowNodeLoopNext, + handleWorkflowNodeLoopFinished, + handleWorkflowNodeRetry, + handleWorkflowAgentLog, + handleWorkflowTextChunk, + handleWorkflowTextReplace, + ], + ) + + const handleStopRun = useCallback( + (taskId: string) => { + const { pipelineId } = workflowStore.getState() + + stopWorkflowRun(`/rag/pipelines/${pipelineId}/workflow-runs/tasks/${taskId}/stop`) + + if (abortControllerRef.current) abortControllerRef.current.abort() + + abortControllerRef.current = null + }, + [workflowStore], + ) + + const handleRestoreFromPublishedWorkflow = useCallback( + (publishedWorkflow: VersionHistory) => { + const nodes = publishedWorkflow.graph.nodes.map((node) => ({ + ...node, + selected: false, + data: { ...node.data, selected: false }, + })) + const edges = publishedWorkflow.graph.edges + const viewport = publishedWorkflow.graph.viewport! + handleUpdateWorkflowCanvas({ + nodes, + edges, + viewport, + }) - if (onAgentLog) - onAgentLog(params) - }, - onTextChunk: (params) => { - handleWorkflowTextChunk(params) - }, - onTextReplace: (params) => { - handleWorkflowTextReplace(params) - }, - ...restCallback, - }, - ) - }, [store, doSyncWorkflowDraft, workflowStore, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, invalidateRunHistory, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace]) - - const handleStopRun = useCallback((taskId: string) => { - const { pipelineId } = workflowStore.getState() - - stopWorkflowRun(`/rag/pipelines/${pipelineId}/workflow-runs/tasks/${taskId}/stop`) - - if (abortControllerRef.current) - abortControllerRef.current.abort() - - abortControllerRef.current = null - }, [workflowStore]) - - const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => { - const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } })) - const edges = publishedWorkflow.graph.edges - const viewport = publishedWorkflow.graph.viewport! - handleUpdateWorkflowCanvas({ - nodes, - edges, - viewport, - }) - - workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || []) - workflowStore.getState().setRagPipelineVariables?.(publishedWorkflow.rag_pipeline_variables || []) - }, [handleUpdateWorkflowCanvas, workflowStore]) + workflowStore + .getState() + .setEnvironmentVariables(publishedWorkflow.environment_variables || []) + workflowStore + .getState() + .setRagPipelineVariables?.(publishedWorkflow.rag_pipeline_variables || []) + }, + [handleUpdateWorkflowCanvas, workflowStore], + ) return { handleBackupDraft, diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.tsx b/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.tsx index ac9f426e0b32a3..b7a5de93a6e193 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.tsx +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.tsx @@ -1,16 +1,9 @@ -import type { - useNodesSyncDraft, -} from '.' +import type { useNodesSyncDraft } from '.' import { useCallback } from 'react' import { useWorkflowInteractions } from '@/app/components/workflow/hooks' import { useWorkflowStore } from '@/app/components/workflow/store' -import { - WorkflowRunningStatus, -} from '@/app/components/workflow/types' -import { - useInputFieldPanel, - useNodesSyncDraftByCanEdit, -} from '.' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { useInputFieldPanel, useNodesSyncDraftByCanEdit } from '.' type DoSyncWorkflowDraft = ReturnType<typeof useNodesSyncDraft>['doSyncWorkflowDraft'] @@ -20,12 +13,9 @@ const usePipelineStartRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { const { closeAllInputFieldPanels } = useInputFieldPanel() const handleWorkflowStartRunInWorkflow = useCallback(async () => { - const { - workflowRunningData, - } = workflowStore.getState() + const { workflowRunningData } = workflowStore.getState() - if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) - return + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) return const { isPreparingDataSource, diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-template.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-template.ts index 2c9083c4185866..20cadef72e6f6b 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-template.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-template.ts @@ -1,8 +1,6 @@ import type { KnowledgeBaseNodeType } from '@/app/components/workflow/nodes/knowledge-base/types' import { useTranslation } from 'react-i18next' -import { - START_INITIAL_POSITION, -} from '@/app/components/workflow/constants' +import { START_INITIAL_POSITION } from '@/app/components/workflow/constants' import knowledgeBaseDefault from '@/app/components/workflow/nodes/knowledge-base/default' import { generateNewNode } from '@/app/components/workflow/utils' @@ -12,9 +10,9 @@ export const usePipelineTemplate = () => { const { newNode: knowledgeBaseNode } = generateNewNode({ id: 'knowledgeBase', data: { - ...knowledgeBaseDefault.defaultValue as KnowledgeBaseNodeType, + ...(knowledgeBaseDefault.defaultValue as KnowledgeBaseNodeType), type: knowledgeBaseDefault.metaData.type, - title: t($ => $[`blocks.${knowledgeBaseDefault.metaData.type}`], { ns: 'workflow' }), + title: t(($) => $[`blocks.${knowledgeBaseDefault.metaData.type}`], { ns: 'workflow' }), selected: true, }, position: { diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline.tsx b/web/app/components/rag-pipeline/hooks/use-pipeline.tsx index 8cca8f63eac1ba..42c610c42388cd 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline.tsx +++ b/web/app/components/rag-pipeline/hooks/use-pipeline.tsx @@ -3,110 +3,117 @@ import type { Node, ValueSelector } from '../../workflow/types' import { uniqBy } from 'es-toolkit/compat' import { useCallback } from 'react' import { getOutgoers, useStoreApi } from 'reactflow' -import { findUsedVarNodes, updateNodeVars } from '../../workflow/nodes/_base/components/variable/utils' +import { + findUsedVarNodes, + updateNodeVars, +} from '../../workflow/nodes/_base/components/variable/utils' import { BlockEnum } from '../../workflow/types' export const usePipeline = () => { const store = useStoreApi() const getAllDatasourceNodes = useCallback(() => { - const { - getNodes, - } = store.getState() + const { getNodes } = store.getState() const nodes = getNodes() as Node<DataSourceNodeType>[] - const datasourceNodes = nodes.filter(node => node.data.type === BlockEnum.DataSource) + const datasourceNodes = nodes.filter((node) => node.data.type === BlockEnum.DataSource) return datasourceNodes }, [store]) - const getAllNodesInSameBranch = useCallback((nodeId: string) => { - const { - getNodes, - edges, - } = store.getState() - const nodes = getNodes() - const list: Node[] = [] - - const traverse = (root: Node, callback: (node: Node) => void) => { - if (root) { - const outgoers = getOutgoers(root, nodes, edges) - - if (outgoers.length) { - outgoers.forEach((node) => { - callback(node) - traverse(node, callback) - }) + const getAllNodesInSameBranch = useCallback( + (nodeId: string) => { + const { getNodes, edges } = store.getState() + const nodes = getNodes() + const list: Node[] = [] + + const traverse = (root: Node, callback: (node: Node) => void) => { + if (root) { + const outgoers = getOutgoers(root, nodes, edges) + + if (outgoers.length) { + outgoers.forEach((node) => { + callback(node) + traverse(node, callback) + }) + } } } - } - if (nodeId === 'shared') { - const allDatasourceNodes = getAllDatasourceNodes() + if (nodeId === 'shared') { + const allDatasourceNodes = getAllDatasourceNodes() + + if (allDatasourceNodes.length === 0) return [] + + list.push(...allDatasourceNodes) + + allDatasourceNodes.forEach((node) => { + traverse(node, (childNode) => { + list.push(childNode) + }) + }) + } else { + const currentNode = nodes.find((node) => node.id === nodeId)! + + if (!currentNode) return [] - if (allDatasourceNodes.length === 0) - return [] + list.push(currentNode) - list.push(...allDatasourceNodes) + traverse(currentNode, (node) => { + list.push(node) + }) + } - allDatasourceNodes.forEach((node) => { - traverse(node, (childNode) => { - list.push(childNode) + return uniqBy(list, 'id') + }, + [getAllDatasourceNodes, store], + ) + + const isVarUsedInNodes = useCallback( + (varSelector: ValueSelector) => { + const nodeId = varSelector[1] // Assuming the first element is always 'VARIABLE_PREFIX'(rag) + const afterNodes = getAllNodesInSameBranch(nodeId!) + const effectNodes = findUsedVarNodes(varSelector, afterNodes) + return effectNodes.length > 0 + }, + [getAllNodesInSameBranch], + ) + + const handleInputVarRename = useCallback( + (nodeId: string, oldValeSelector: ValueSelector, newVarSelector: ValueSelector) => { + const { getNodes, setNodes } = store.getState() + const afterNodes = getAllNodesInSameBranch(nodeId) + const effectNodes = findUsedVarNodes(oldValeSelector, afterNodes) + if (effectNodes.length > 0) { + const newNodes = getNodes().map((node) => { + if (effectNodes.find((n) => n.id === node.id)) + return updateNodeVars(node, oldValeSelector, newVarSelector) + + return node }) - }) - } - else { - const currentNode = nodes.find(node => node.id === nodeId)! - - if (!currentNode) - return [] - - list.push(currentNode) - - traverse(currentNode, (node) => { - list.push(node) - }) - } - - return uniqBy(list, 'id') - }, [getAllDatasourceNodes, store]) - - const isVarUsedInNodes = useCallback((varSelector: ValueSelector) => { - const nodeId = varSelector[1] // Assuming the first element is always 'VARIABLE_PREFIX'(rag) - const afterNodes = getAllNodesInSameBranch(nodeId!) - const effectNodes = findUsedVarNodes(varSelector, afterNodes) - return effectNodes.length > 0 - }, [getAllNodesInSameBranch]) - - const handleInputVarRename = useCallback((nodeId: string, oldValeSelector: ValueSelector, newVarSelector: ValueSelector) => { - const { getNodes, setNodes } = store.getState() - const afterNodes = getAllNodesInSameBranch(nodeId) - const effectNodes = findUsedVarNodes(oldValeSelector, afterNodes) - if (effectNodes.length > 0) { - const newNodes = getNodes().map((node) => { - if (effectNodes.find(n => n.id === node.id)) - return updateNodeVars(node, oldValeSelector, newVarSelector) - - return node - }) - setNodes(newNodes) - } - }, [getAllNodesInSameBranch, store]) - - const removeUsedVarInNodes = useCallback((varSelector: ValueSelector) => { - const nodeId = varSelector[1] // Assuming the first element is always 'VARIABLE_PREFIX'(rag) - const { getNodes, setNodes } = store.getState() - const afterNodes = getAllNodesInSameBranch(nodeId!) - const effectNodes = findUsedVarNodes(varSelector, afterNodes) - if (effectNodes.length > 0) { - const newNodes = getNodes().map((node) => { - if (effectNodes.find(n => n.id === node.id)) - return updateNodeVars(node, varSelector, []) - - return node - }) - setNodes(newNodes) - } - }, [getAllNodesInSameBranch, store]) + setNodes(newNodes) + } + }, + [getAllNodesInSameBranch, store], + ) + + const removeUsedVarInNodes = useCallback( + (varSelector: ValueSelector) => { + const nodeId = varSelector[1] // Assuming the first element is always 'VARIABLE_PREFIX'(rag) + const { getNodes, setNodes } = store.getState() + const afterNodes = getAllNodesInSameBranch(nodeId!) + const effectNodes = findUsedVarNodes(varSelector, afterNodes) + if (effectNodes.length > 0) { + const newNodes = getNodes().map((node) => { + if (effectNodes.find((n) => n.id === node.id)) + return updateNodeVars(node, varSelector, []) + + return node + }) + setNodes(newNodes) + } + }, + [getAllNodesInSameBranch, store], + ) return { handleInputVarRename, diff --git a/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx b/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx index b999f5ccc8d310..44380028c1baee 100644 --- a/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx +++ b/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx @@ -55,103 +55,104 @@ export const useRagPipelineSearch = () => { blockType: nodeData.type, nodeData, toolIcon: getToolIcon(nodeData), - modelInfo: nodeData.type === BlockEnum.LLM - ? { - provider: (nodeData as LLMNodeType).model?.provider, - name: (nodeData as LLMNodeType).model?.name, - mode: (nodeData as LLMNodeType).model?.mode, - } - : { - provider: undefined, - name: undefined, - mode: undefined, - }, + modelInfo: + nodeData.type === BlockEnum.LLM + ? { + provider: (nodeData as LLMNodeType).model?.provider, + name: (nodeData as LLMNodeType).model?.name, + mode: (nodeData as LLMNodeType).model?.mode, + } + : { + provider: undefined, + name: undefined, + mode: undefined, + }, } }) }, [nodes, getToolIcon]) // Calculate relevance score for search results - const calculateScore = useCallback((node: { - title: string - type: string - desc: string - modelInfo: { provider?: string, name?: string, mode?: string } - }, searchTerm: string): number => { - if (!searchTerm) - return 1 - - let score = 0 - const term = searchTerm.toLowerCase() - - // Title match (highest priority) - if (node.title.toLowerCase().includes(term)) - score += 10 - - // Type match - if (node.type.toLowerCase().includes(term)) - score += 8 - - // Description match - if (node.desc.toLowerCase().includes(term)) - score += 5 - - // Model info matches (for LLM nodes) - if (node.modelInfo.provider?.toLowerCase().includes(term)) - score += 6 - if (node.modelInfo.name?.toLowerCase().includes(term)) - score += 6 - if (node.modelInfo.mode?.toLowerCase().includes(term)) - score += 4 - - return score - }, []) + const calculateScore = useCallback( + ( + node: { + title: string + type: string + desc: string + modelInfo: { provider?: string; name?: string; mode?: string } + }, + searchTerm: string, + ): number => { + if (!searchTerm) return 1 + + let score = 0 + const term = searchTerm.toLowerCase() + + // Title match (highest priority) + if (node.title.toLowerCase().includes(term)) score += 10 + + // Type match + if (node.type.toLowerCase().includes(term)) score += 8 + + // Description match + if (node.desc.toLowerCase().includes(term)) score += 5 + + // Model info matches (for LLM nodes) + if (node.modelInfo.provider?.toLowerCase().includes(term)) score += 6 + if (node.modelInfo.name?.toLowerCase().includes(term)) score += 6 + if (node.modelInfo.mode?.toLowerCase().includes(term)) score += 4 + + return score + }, + [], + ) // Create search function for RAG pipeline nodes - const searchRagPipelineNodes = useCallback((query: string) => { - if (!searchableNodes.length) - return [] - - const searchTerm = query.toLowerCase().trim() - - const results = searchableNodes - .map((node) => { - const score = calculateScore(node, searchTerm) - - return score > 0 - ? { - id: node.id, - title: node.title, - description: node.desc || node.type, - type: 'workflow-node' as const, - path: `#${node.id}`, - icon: ( - <BlockIcon - type={node.blockType} - className="shrink-0" - size="sm" - toolIcon={node.toolIcon} - /> - ), - metadata: { - nodeId: node.id, - nodeData: node.nodeData, - }, - data: node.nodeData, - score, - } - : null - }) - .filter((node): node is NonNullable<typeof node> => node !== null) - .sort((a, b) => { - // If no search term, sort alphabetically - if (!searchTerm) - return a.title.localeCompare(b.title) - // Sort by relevance score (higher score first) - return (b.score || 0) - (a.score || 0) - }) - - return results - }, [searchableNodes, calculateScore]) + const searchRagPipelineNodes = useCallback( + (query: string) => { + if (!searchableNodes.length) return [] + + const searchTerm = query.toLowerCase().trim() + + const results = searchableNodes + .map((node) => { + const score = calculateScore(node, searchTerm) + + return score > 0 + ? { + id: node.id, + title: node.title, + description: node.desc || node.type, + type: 'workflow-node' as const, + path: `#${node.id}`, + icon: ( + <BlockIcon + type={node.blockType} + className="shrink-0" + size="sm" + toolIcon={node.toolIcon} + /> + ), + metadata: { + nodeId: node.id, + nodeData: node.nodeData, + }, + data: node.nodeData, + score, + } + : null + }) + .filter((node): node is NonNullable<typeof node> => node !== null) + .sort((a, b) => { + // If no search term, sort alphabetically + if (!searchTerm) return a.title.localeCompare(b.title) + // Sort by relevance score (higher score first) + return (b.score || 0) - (a.score || 0) + }) + + return results + }, + [searchableNodes, calculateScore], + ) // Directly set the search function on the action object useEffect(() => { diff --git a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts index 036a0fc22ddd0a..5db04050573e82 100644 --- a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts +++ b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts @@ -23,10 +23,10 @@ type UseUpdateDSLModalParams = { onCancel: () => void onImport?: () => void } -const isCompletedStatus = (status: DSLImportStatus): boolean => status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS +const isCompletedStatus = (status: DSLImportStatus): boolean => + status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS const getNonEmptyString = (value: unknown): string | undefined => { - if (typeof value !== 'string') - return undefined + if (typeof value !== 'string') return undefined const trimmedValue = value.trim() return trimmedValue || undefined @@ -34,14 +34,12 @@ const getNonEmptyString = (value: unknown): string | undefined => { const getImportErrorMessage = async (error: unknown): Promise<string | undefined> => { if (error instanceof Response && !error.bodyUsed) { try { - const errorData = await error.clone().json() as ImportErrorResponse + const errorData = (await error.clone().json()) as ImportErrorResponse return getNonEmptyString(errorData.message) ?? getNonEmptyString(errorData.error) - } - catch {} + } catch {} } - if (error instanceof Error) - return getNonEmptyString(error.message) + if (error instanceof Error) return getNonEmptyString(error.message) return undefined } @@ -72,64 +70,78 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam } const handleFile = (file?: File) => { setDSLFile(file) - if (file) - readFile(file) - if (!file) - setFileContent('') + if (file) readFile(file) + if (!file) setFileContent('') } - const notifyError = useCallback((message?: string) => { - setLoading(false) - toast.error(message || t($ => $['common.importFailure'], { ns: 'workflow' })) - }, [t]) - const updateWorkflow = useCallback(async (pipelineId: string) => { - const { graph, hash, rag_pipeline_variables } = await fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`) - const { nodes, edges, viewport } = graph - eventEmitter?.emit({ - type: WORKFLOW_DATA_UPDATE, - payload: { - nodes: initialNodes(nodes, edges), - edges: initialEdges(edges, nodes), - viewport, - hash, - rag_pipeline_variables: rag_pipeline_variables || [], - }, - }) - }, [eventEmitter]) - const completeImport = useCallback(async (pipelineId: string | undefined, status: DSLImportStatus = DSLImportStatus.COMPLETED) => { - if (!pipelineId) { - notifyError() - return - } - updateWorkflow(pipelineId) - onImport?.() - const isWarning = status === DSLImportStatus.COMPLETED_WITH_WARNINGS - toast(t($ => $[isWarning ? 'common.importWarning' : 'common.importSuccess'], { ns: 'workflow' }), { - type: isWarning ? 'warning' : 'success', - description: isWarning && t($ => $['common.importWarningDetails'], { ns: 'workflow' }), - }) - await handleCheckPluginDependencies(pipelineId, true) - setLoading(false) - onCancel() - }, [updateWorkflow, onImport, t, handleCheckPluginDependencies, onCancel, notifyError]) - const showVersionMismatch = useCallback((id: string, importedVersion?: string, systemVersion?: string) => { - setShow(false) - setTimeout(() => setShowErrorModal(true), 300) - setVersions({ - importedVersion: importedVersion ?? '', - systemVersion: systemVersion ?? '', - }) - setImportId(id) - }, []) + const notifyError = useCallback( + (message?: string) => { + setLoading(false) + toast.error(message || t(($) => $['common.importFailure'], { ns: 'workflow' })) + }, + [t], + ) + const updateWorkflow = useCallback( + async (pipelineId: string) => { + const { graph, hash, rag_pipeline_variables } = await fetchWorkflowDraft( + `/rag/pipelines/${pipelineId}/workflows/draft`, + ) + const { nodes, edges, viewport } = graph + eventEmitter?.emit({ + type: WORKFLOW_DATA_UPDATE, + payload: { + nodes: initialNodes(nodes, edges), + edges: initialEdges(edges, nodes), + viewport, + hash, + rag_pipeline_variables: rag_pipeline_variables || [], + }, + }) + }, + [eventEmitter], + ) + const completeImport = useCallback( + async (pipelineId: string | undefined, status: DSLImportStatus = DSLImportStatus.COMPLETED) => { + if (!pipelineId) { + notifyError() + return + } + updateWorkflow(pipelineId) + onImport?.() + const isWarning = status === DSLImportStatus.COMPLETED_WITH_WARNINGS + toast( + t(($) => $[isWarning ? 'common.importWarning' : 'common.importSuccess'], { + ns: 'workflow', + }), + { + type: isWarning ? 'warning' : 'success', + description: isWarning && t(($) => $['common.importWarningDetails'], { ns: 'workflow' }), + }, + ) + await handleCheckPluginDependencies(pipelineId, true) + setLoading(false) + onCancel() + }, + [updateWorkflow, onImport, t, handleCheckPluginDependencies, onCancel, notifyError], + ) + const showVersionMismatch = useCallback( + (id: string, importedVersion?: string, systemVersion?: string) => { + setShow(false) + setTimeout(() => setShowErrorModal(true), 300) + setVersions({ + importedVersion: importedVersion ?? '', + systemVersion: systemVersion ?? '', + }) + setImportId(id) + }, + [], + ) const handleImport: MouseEventHandler = useCallback(async () => { const { pipelineId } = workflowStore.getState() - if (isCreatingRef.current) - return + if (isCreatingRef.current) return isCreatingRef.current = true - if (!currentFile) - return + if (!currentFile) return try { - if (!pipelineId || !fileContent) - return + if (!pipelineId || !fileContent) return setLoading(true) const response = await importDSL({ mode: DSLImportMode.YAML_CONTENT, @@ -137,31 +149,33 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam pipeline_id: pipelineId, }) const { id, status, pipeline_id, imported_dsl_version, current_dsl_version } = response - if (isCompletedStatus(status)) - await completeImport(pipeline_id, status) + if (isCompletedStatus(status)) await completeImport(pipeline_id, status) else if (status === DSLImportStatus.PENDING) showVersionMismatch(id, imported_dsl_version, current_dsl_version) - else - notifyError(response.error) - } - catch (error) { + else notifyError(response.error) + } catch (error) { notifyError(await getImportErrorMessage(error)) } isCreatingRef.current = false - }, [currentFile, fileContent, workflowStore, importDSL, completeImport, showVersionMismatch, notifyError]) + }, [ + currentFile, + fileContent, + workflowStore, + importDSL, + completeImport, + showVersionMismatch, + notifyError, + ]) const onUpdateDSLConfirm: MouseEventHandler = useCallback(async () => { - if (!importId) - return + if (!importId) return try { const { status, pipeline_id, error } = await importDSLConfirm(importId) if (status === DSLImportStatus.COMPLETED) { await completeImport(pipeline_id) return } - if (status === DSLImportStatus.FAILED) - notifyError(error) - } - catch (error) { + if (status === DSLImportStatus.FAILED) notifyError(error) + } catch (error) { notifyError(await getImportErrorMessage(error)) } }, [importId, importDSLConfirm, completeImport, notifyError]) diff --git a/web/app/components/rag-pipeline/index.tsx b/web/app/components/rag-pipeline/index.tsx index 5e3d63a11d2ea0..dd50fa25e7ccd0 100644 --- a/web/app/components/rag-pipeline/index.tsx +++ b/web/app/components/rag-pipeline/index.tsx @@ -2,13 +2,8 @@ import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store import { useMemo } from 'react' import Loading from '@/app/components/base/loading' import WorkflowWithDefaultContext from '@/app/components/workflow' -import { - WorkflowContextProvider, -} from '@/app/components/workflow/context' -import { - initialEdges, - initialNodes, -} from '@/app/components/workflow/utils' +import { WorkflowContextProvider } from '@/app/components/workflow/context' +import { initialEdges, initialNodes } from '@/app/components/workflow/utils' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import Conversion from './components/conversion' import RagPipelineMain from './components/rag-pipeline-main' @@ -17,19 +12,14 @@ import { createRagPipelineSliceSlice } from './store' import { processNodesWithoutDataSource } from './utils' const RagPipeline = () => { - const { - data, - isLoading, - } = usePipelineInit() + const { data, isLoading } = usePipelineInit() const nodesData = useMemo(() => { - if (data) - return initialNodes(data.graph.nodes, data.graph.edges) + if (data) return initialNodes(data.graph.nodes, data.graph.edges) return [] }, [data]) const edgesData = useMemo(() => { - if (data) - return initialEdges(data.graph.edges, data.graph.nodes) + if (data) return initialEdges(data.graph.edges, data.graph.nodes) return [] }, [data]) @@ -42,29 +32,21 @@ const RagPipeline = () => { ) } - const { - nodes: processedNodes, - viewport, - } = processNodesWithoutDataSource(nodesData, data.graph.viewport) + const { nodes: processedNodes, viewport } = processNodesWithoutDataSource( + nodesData, + data.graph.viewport, + ) return ( - <WorkflowWithDefaultContext - edges={edgesData} - nodes={processedNodes} - > - <RagPipelineMain - edges={edgesData} - nodes={processedNodes} - viewport={viewport} - /> + <WorkflowWithDefaultContext edges={edgesData} nodes={processedNodes}> + <RagPipelineMain edges={edgesData} nodes={processedNodes} viewport={viewport} /> </WorkflowWithDefaultContext> ) } const RagPipelineWrapper = () => { - const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id) + const pipelineId = useDatasetDetailContextWithSelector((s) => s.dataset?.pipeline_id) - if (!pipelineId) - return <Conversion /> + if (!pipelineId) return <Conversion /> return ( <WorkflowContextProvider diff --git a/web/app/components/rag-pipeline/store/__tests__/index.spec.ts b/web/app/components/rag-pipeline/store/__tests__/index.spec.ts index 18d1da45974df9..ddc4df562d0e36 100644 --- a/web/app/components/rag-pipeline/store/__tests__/index.spec.ts +++ b/web/app/components/rag-pipeline/store/__tests__/index.spec.ts @@ -4,7 +4,6 @@ import type { DataSourceItem } from '@/app/components/workflow/block-selector/ty import type { RAGPipelineVariables } from '@/models/pipeline' import { describe, expect, it, vi } from 'vitest' import { PipelineInputVarType } from '@/models/pipeline' - import { createRagPipelineSliceSlice } from '../index' vi.mock('@/app/components/workflow/block-selector/utils', () => ({ @@ -19,7 +18,11 @@ const unusedGet = vi.fn() as unknown as SliceCreatorParams[1] const unusedApi = vi.fn() as unknown as SliceCreatorParams[2] function createSlice(mockSet = vi.fn()) { - return createRagPipelineSliceSlice(mockSet as unknown as SliceCreatorParams[0], unusedGet, unusedApi) + return createRagPipelineSliceSlice( + mockSet as unknown as SliceCreatorParams[0], + unusedGet, + unusedApi, + ) } describe('createRagPipelineSliceSlice', () => { @@ -185,7 +188,13 @@ describe('createRagPipelineSliceSlice', () => { mockSet.mockClear() const slice = createSlice(mockSet) const variables: RAGPipelineVariables = [ - { type: PipelineInputVarType.textInput, variable: 'var1', label: 'Var 1', required: true, belong_to_node_id: 'node-1' }, + { + type: PipelineInputVarType.textInput, + variable: 'var1', + label: 'Var 1', + required: true, + belong_to_node_id: 'node-1', + }, ] slice.setRagPipelineVariables(variables) diff --git a/web/app/components/rag-pipeline/store/index.ts b/web/app/components/rag-pipeline/store/index.ts index 732076d5460e96..5206848cc3e0f8 100644 --- a/web/app/components/rag-pipeline/store/index.ts +++ b/web/app/components/rag-pipeline/store/index.ts @@ -1,9 +1,7 @@ import type { StateCreator } from 'zustand' import type { InputFieldEditorProps } from '../components/panel/input-field/editor' import type { DataSourceItem } from '@/app/components/workflow/block-selector/types' -import type { - ToolWithProvider, -} from '@/app/components/workflow/types' +import type { ToolWithProvider } from '@/app/components/workflow/types' import type { IconInfo } from '@/models/datasets' import type { RAGPipelineVariables } from '@/models/pipeline' import { transformDataSourceToTool } from '@/app/components/workflow/block-selector/utils' @@ -28,24 +26,27 @@ export type RagPipelineSliceShape = { setIsPreparingDataSource: (isPreparingDataSource: boolean) => void } -export const createRagPipelineSliceSlice: StateCreator<RagPipelineSliceShape> = set => ({ +export const createRagPipelineSliceSlice: StateCreator<RagPipelineSliceShape> = (set) => ({ pipelineId: '', knowledgeName: '', showInputFieldPanel: false, - setShowInputFieldPanel: showInputFieldPanel => set(() => ({ showInputFieldPanel })), + setShowInputFieldPanel: (showInputFieldPanel) => set(() => ({ showInputFieldPanel })), showInputFieldPreviewPanel: false, - setShowInputFieldPreviewPanel: showInputFieldPreviewPanel => set(() => ({ showInputFieldPreviewPanel })), + setShowInputFieldPreviewPanel: (showInputFieldPreviewPanel) => + set(() => ({ showInputFieldPreviewPanel })), inputFieldEditPanelProps: null, - setInputFieldEditPanelProps: inputFieldEditPanelProps => set(() => ({ inputFieldEditPanelProps })), + setInputFieldEditPanelProps: (inputFieldEditPanelProps) => + set(() => ({ inputFieldEditPanelProps })), nodesDefaultConfigs: {}, - setNodesDefaultConfigs: nodesDefaultConfigs => set(() => ({ nodesDefaultConfigs })), + setNodesDefaultConfigs: (nodesDefaultConfigs) => set(() => ({ nodesDefaultConfigs })), ragPipelineVariables: [], - setRagPipelineVariables: (ragPipelineVariables: RAGPipelineVariables) => set(() => ({ ragPipelineVariables })), + setRagPipelineVariables: (ragPipelineVariables: RAGPipelineVariables) => + set(() => ({ ragPipelineVariables })), dataSourceList: [], setDataSourceList: (dataSourceList: DataSourceItem[]) => { - const formattedDataSourceList = dataSourceList.map(item => transformDataSourceToTool(item)) + const formattedDataSourceList = dataSourceList.map((item) => transformDataSourceToTool(item)) set(() => ({ dataSourceList: formattedDataSourceList })) }, isPreparingDataSource: false, - setIsPreparingDataSource: isPreparingDataSource => set(() => ({ isPreparingDataSource })), + setIsPreparingDataSource: (isPreparingDataSource) => set(() => ({ isPreparingDataSource })), }) diff --git a/web/app/components/rag-pipeline/utils/__tests__/index.spec.ts b/web/app/components/rag-pipeline/utils/__tests__/index.spec.ts index 3136c61443413b..7a4c1779f5acb9 100644 --- a/web/app/components/rag-pipeline/utils/__tests__/index.spec.ts +++ b/web/app/components/rag-pipeline/utils/__tests__/index.spec.ts @@ -23,7 +23,17 @@ vi.mock('@/app/components/workflow/note-node/types', () => ({ })) vi.mock('@/app/components/workflow/utils', () => ({ - generateNewNode: ({ id, type, data, position }: { id: string, type?: string, data: object, position: { x: number, y: number } }) => ({ + generateNewNode: ({ + id, + type, + data, + position, + }: { + id: string + type?: string + data: object + position: { x: number; y: number } + }) => ({ newNode: { id, type: type || 'custom', data, position }, }), })) diff --git a/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts b/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts index c90e702d8e3538..daf618f4dd8ac4 100644 --- a/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts +++ b/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts @@ -22,7 +22,17 @@ vi.mock('@/app/components/workflow/note-node/types', () => ({ })) vi.mock('@/app/components/workflow/utils', () => ({ - generateNewNode: ({ id, type, data, position }: { id: string, type: string, data: object, position: { x: number, y: number } }) => ({ + generateNewNode: ({ + id, + type, + data, + position, + }: { + id: string + type: string + data: object + position: { x: number; y: number } + }) => ({ newNode: { id, type, data, position }, }), })) @@ -57,16 +67,20 @@ describe('processNodesWithoutDataSource', () => { const result = processNodesWithoutDataSource(nodes, { x: 0, y: 0, zoom: 2 }) - expect(result.nodes[0]).toEqual(expect.objectContaining({ - id: 'data-source-empty', - type: 'data-source-empty', - position: { x: -100, y: 200 }, - })) - expect(result.nodes[1]).toEqual(expect.objectContaining({ - id: 'note', - type: 'note', - position: { x: -100, y: 300 }, - })) + expect(result.nodes[0]).toEqual( + expect.objectContaining({ + id: 'data-source-empty', + type: 'data-source-empty', + position: { x: -100, y: 200 }, + }), + ) + expect(result.nodes[1]).toEqual( + expect.objectContaining({ + id: 'note', + type: 'note', + position: { x: -100, y: 300 }, + }), + ) expect(result.viewport).toEqual({ x: 400, y: -200, diff --git a/web/app/components/rag-pipeline/utils/nodes.ts b/web/app/components/rag-pipeline/utils/nodes.ts index 00c3d831b8bbd8..623e7289809102 100644 --- a/web/app/components/rag-pipeline/utils/nodes.ts +++ b/web/app/components/rag-pipeline/utils/nodes.ts @@ -24,8 +24,7 @@ export const processNodesWithoutDataSource = (nodes: Node[], viewport?: Viewport } } - if (node!.type === CUSTOM_NODE && !leftNode) - leftNode = node + if (node!.type === CUSTOM_NODE && !leftNode) leftNode = node if (node!.type === CUSTOM_NODE && leftNode && node!.position.x < leftNode.position.x) leftNode = node @@ -70,11 +69,7 @@ export const processNodesWithoutDataSource = (nodes: Node[], viewport?: Viewport }, }).newNode return { - nodes: [ - newNode, - newNoteNode, - ...nodes, - ], + nodes: [newNode, newNoteNode, ...nodes], viewport: { x: (START_INITIAL_POSITION.x - startX) * (viewport?.zoom || 1), y: (START_INITIAL_POSITION.y - startY) * (viewport?.zoom || 1), diff --git a/web/app/components/share/text-generation/__tests__/index.spec.tsx b/web/app/components/share/text-generation/__tests__/index.spec.tsx index e3746b1da13d7a..1e019868080b75 100644 --- a/web/app/components/share/text-generation/__tests__/index.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/index.spec.tsx @@ -36,7 +36,7 @@ vi.mock('@/hooks/use-breakpoints', () => ({ vi.mock('@/next/navigation', () => ({ useSearchParams: () => ({ - get: (key: string) => key === 'mode' ? mockMode.value : null, + get: (key: string) => (key === 'mode' ? mockMode.value : null), }), })) @@ -62,8 +62,12 @@ vi.mock('../text-generation-sidebar', () => ({ return ( <div data-testid="sidebar"> <span data-testid="sidebar-current-tab">{props.currentTab}</span> - <button type="button" onClick={props.onRunOnceSend}>run-once</button> - <button type="button" onClick={() => props.onBatchSend([['name'], ['Alice']])}>run-batch</button> + <button type="button" onClick={props.onRunOnceSend}> + run-once + </button> + <button type="button" onClick={() => props.onBatchSend([['name'], ['Alice']])}> + run-batch + </button> </div> ) }, @@ -91,7 +95,9 @@ vi.mock('../text-generation-result-panel', () => ({ > set-run-control </button> - <button type="button" onClick={props.onRunStart}>start-run</button> + <button type="button" onClick={props.onRunStart}> + start-run + </button> </div> ) }, @@ -173,11 +179,13 @@ describe('TextGeneration', () => { const { container } = render(<TextGeneration isInstalledApp />) expect(screen.getByTestId('sidebar-current-tab')).toHaveTextContent('create') - expect(sidebarPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ - currentTab: 'create', - isInstalledApp: true, - isPC: true, - })) + expect(sidebarPropsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + currentTab: 'create', + isInstalledApp: true, + isPC: true, + }), + ) const root = container.firstElementChild as HTMLElement expect(root).toHaveClass('flex', 'h-full', 'rounded-2xl', 'shadow-md') diff --git a/web/app/components/share/text-generation/__tests__/info-modal.spec.tsx b/web/app/components/share/text-generation/__tests__/info-modal.spec.tsx index 63a15604c257c3..83f334def2af5f 100644 --- a/web/app/components/share/text-generation/__tests__/info-modal.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/info-modal.spec.tsx @@ -37,37 +37,19 @@ describe('InfoModal', () => { describe('rendering', () => { it('should not render when isShow is false', async () => { - await renderModal( - <InfoModal - isShow={false} - onClose={mockOnClose} - data={baseSiteInfo} - />, - ) + await renderModal(<InfoModal isShow={false} onClose={mockOnClose} data={baseSiteInfo} />) expect(screen.queryByText('Test App')).not.toBeInTheDocument() }) it('should render when isShow is true', async () => { - await renderModal( - <InfoModal - isShow={true} - onClose={mockOnClose} - data={baseSiteInfo} - />, - ) + await renderModal(<InfoModal isShow={true} onClose={mockOnClose} data={baseSiteInfo} />) expect(screen.getByText('Test App')).toBeInTheDocument() }) it('should render app title', async () => { - await renderModal( - <InfoModal - isShow={true} - onClose={mockOnClose} - data={baseSiteInfo} - />, - ) + await renderModal(<InfoModal isShow={true} onClose={mockOnClose} data={baseSiteInfo} />) expect(screen.getByText('Test App')).toBeInTheDocument() }) @@ -79,15 +61,13 @@ describe('InfoModal', () => { } await renderModal( - <InfoModal - isShow={true} - onClose={mockOnClose} - data={siteInfoWithCopyright} - />, + <InfoModal isShow={true} onClose={mockOnClose} data={siteInfoWithCopyright} />, ) const currentYear = new Date().getFullYear().toString() - expect(screen.getByText(`Copyright © ${currentYear} Dify AI. All Rights Reserved.`)).toBeInTheDocument() + expect( + screen.getByText(`Copyright © ${currentYear} Dify AI. All Rights Reserved.`), + ).toBeInTheDocument() }) it('should render current year in copyright', async () => { @@ -97,11 +77,7 @@ describe('InfoModal', () => { } await renderModal( - <InfoModal - isShow={true} - onClose={mockOnClose} - data={siteInfoWithCopyright} - />, + <InfoModal isShow={true} onClose={mockOnClose} data={siteInfoWithCopyright} />, ) const currentYear = new Date().getFullYear().toString() @@ -115,37 +91,21 @@ describe('InfoModal', () => { } await renderModal( - <InfoModal - isShow={true} - onClose={mockOnClose} - data={siteInfoWithDisclaimer} - />, + <InfoModal isShow={true} onClose={mockOnClose} data={siteInfoWithDisclaimer} />, ) expect(screen.getByText('This is a custom disclaimer')).toBeInTheDocument() }) it('should not render copyright section when not provided', async () => { - await renderModal( - <InfoModal - isShow={true} - onClose={mockOnClose} - data={baseSiteInfo} - />, - ) + await renderModal(<InfoModal isShow={true} onClose={mockOnClose} data={baseSiteInfo} />) const year = new Date().getFullYear().toString() expect(screen.queryByText(new RegExp(`©.*${year}`))).not.toBeInTheDocument() }) it('should render with undefined data', async () => { - await renderModal( - <InfoModal - isShow={true} - onClose={mockOnClose} - data={undefined} - />, - ) + await renderModal(<InfoModal isShow={true} onClose={mockOnClose} data={undefined} />) expect(screen.queryByText('Test App')).not.toBeInTheDocument() }) @@ -157,13 +117,7 @@ describe('InfoModal', () => { icon_url: 'https://example.com/icon.png', } - await renderModal( - <InfoModal - isShow={true} - onClose={mockOnClose} - data={siteInfoWithImage} - />, - ) + await renderModal(<InfoModal isShow={true} onClose={mockOnClose} data={siteInfoWithImage} />) expect(screen.getByText(siteInfoWithImage.title!)).toBeInTheDocument() }) @@ -171,13 +125,7 @@ describe('InfoModal', () => { describe('close functionality', () => { it('should call onClose when close button is clicked', async () => { - await renderModal( - <InfoModal - isShow={true} - onClose={mockOnClose} - data={baseSiteInfo} - />, - ) + await renderModal(<InfoModal isShow={true} onClose={mockOnClose} data={baseSiteInfo} />) const closeIcon = document.querySelector('[class*="text-text-tertiary"]') expect(closeIcon).toBeInTheDocument() @@ -196,13 +144,7 @@ describe('InfoModal', () => { custom_disclaimer: 'Disclaimer text here', } - await renderModal( - <InfoModal - isShow={true} - onClose={mockOnClose} - data={siteInfoWithBoth} - />, - ) + await renderModal(<InfoModal isShow={true} onClose={mockOnClose} data={siteInfoWithBoth} />) expect(screen.getByText(/My Company/)).toBeInTheDocument() expect(screen.getByText('Disclaimer text here')).toBeInTheDocument() diff --git a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx index 7c8a2c8c950a87..b13232fe5ba299 100644 --- a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx @@ -13,12 +13,13 @@ vi.mock('../info-modal', () => ({ onClose: () => void data?: SiteInfo }) => { - if (!isShow) - return null + if (!isShow) return null return ( <div data-testid="info-modal"> <span>{data?.title}</span> - <button type="button" onClick={onClose}>Close Info</button> + <button type="button" onClick={onClose}> + Close Info + </button> </div> ) }, @@ -262,7 +263,9 @@ describe('MenuDropdown', () => { describe('memoization', () => { it('should be wrapped with React.memo', () => { - expect((MenuDropdown as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + expect((MenuDropdown as unknown as { $$typeof: symbol }).$$typeof).toBe( + Symbol.for('react.memo'), + ) }) }) }) diff --git a/web/app/components/share/text-generation/__tests__/text-generation-result-panel.spec.tsx b/web/app/components/share/text-generation/__tests__/text-generation-result-panel.spec.tsx index b54534eb3cb422..c31388f758b4a9 100644 --- a/web/app/components/share/text-generation/__tests__/text-generation-result-panel.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/text-generation-result-panel.spec.tsx @@ -26,9 +26,7 @@ vi.mock('@/app/components/share/text-generation/run-batch/res-download', () => ( const promptConfig: PromptConfig = { prompt_template: 'template', - prompt_variables: [ - { key: 'name', name: 'Name', type: 'string', required: true }, - ], + prompt_variables: [{ key: 'name', name: 'Name', type: 'string', required: true }], } const siteInfo: SiteInfo = { @@ -68,7 +66,7 @@ const baseProps = { controlRetry: 88, controlSend: 77, controlStopResponding: 66, - exportRes: [{ 'Name': 'Alpha', 'share.generation.completionResult': 'Done' }!], + exportRes: [{ Name: 'Alpha', 'share.generation.completionResult': 'Done' }!], handleCompleted: vi.fn(), handleRetryAllFailedTask: vi.fn(), handleSaveMessage: vi.fn(async () => {}), @@ -100,18 +98,20 @@ describe('TextGenerationResultPanel', () => { render(<TextGenerationResultPanel {...baseProps} />) expect(screen.getByTestId('res-single'))!.toBeInTheDocument() - expect(resPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ - appId: 'app-123', - appSourceType: AppSourceType.webApp, - completionFiles: [], - controlSend: 77, - controlStopResponding: 66, - hideInlineStopButton: true, - inputs: { name: 'Alice' }, - isCallBatchAPI: false, - moreLikeThisEnabled: true, - taskId: undefined, - })) + expect(resPropsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + appId: 'app-123', + appSourceType: AppSourceType.webApp, + completionFiles: [], + controlSend: 77, + controlStopResponding: 66, + hideInlineStopButton: true, + inputs: { name: 'Alice' }, + isCallBatchAPI: false, + moreLikeThisEnabled: true, + taskId: undefined, + }), + ) expect(screen.queryByTestId('res-download-mock')).not.toBeInTheDocument() }) @@ -131,27 +131,37 @@ describe('TextGenerationResultPanel', () => { expect(screen.getByTestId('res-1'))!.toBeInTheDocument() expect(screen.getByTestId('res-2'))!.toBeInTheDocument() - expect(resPropsSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({ - inputs: { name: 'Alpha' }, - isError: false, - controlRetry: 0, - taskId: 1, - onRunControlChange: undefined, - })) - expect(resPropsSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ - inputs: { name: 'Beta' }, - isError: true, - controlRetry: 88, - taskId: 2, - })) + expect(resPropsSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + inputs: { name: 'Alpha' }, + isError: false, + controlRetry: 0, + taskId: 1, + onRunControlChange: undefined, + }), + ) + expect(resPropsSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + inputs: { name: 'Beta' }, + isError: true, + controlRetry: 88, + taskId: 2, + }), + ) expect(screen.getByText('share.generation.executions:{"num":2}'))!.toBeInTheDocument() expect(screen.getByTestId('res-download-mock'))!.toBeInTheDocument() - expect(resDownloadPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ - isMobile: false, - values: baseProps.exportRes, - })) + expect(resDownloadPropsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + isMobile: false, + values: baseProps.exportRes, + }), + ) expect(screen.getByText('share.generation.batchFailed.info:{"num":1}'))!.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'share.generation.batchFailed.retry' }))!.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'share.generation.batchFailed.retry' }), + )!.toBeInTheDocument() expect(screen.getByRole('status', { name: 'appApi.loading' }))!.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'share.generation.batchFailed.retry' })) diff --git a/web/app/components/share/text-generation/__tests__/text-generation-sidebar.spec.tsx b/web/app/components/share/text-generation/__tests__/text-generation-sidebar.spec.tsx index 421bf7be3b2ee9..a40f6c431afa0b 100644 --- a/web/app/components/share/text-generation/__tests__/text-generation-sidebar.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/text-generation-sidebar.spec.tsx @@ -27,7 +27,7 @@ vi.mock('@/app/components/share/text-generation/run-batch', () => ({ })) vi.mock('@/app/components/app/text-generate/saved-items', () => ({ - default: (props: { onStartCreateContent: () => void, list: Array<{ id: string }> }) => { + default: (props: { onStartCreateContent: () => void; list: Array<{ id: string }> }) => { savedItemsPropsSpy(props) return ( <div data-testid="saved-items-mock"> @@ -44,9 +44,7 @@ vi.mock('@/app/components/share/text-generation/menu-dropdown', () => ({ const promptConfig: PromptConfig = { prompt_template: 'template', - prompt_variables: [ - { key: 'name', name: 'Name', type: 'string', required: true }, - ], + prompt_variables: [{ key: 'name', name: 'Name', type: 'string', required: true }], } const savedMessages: SavedMessage[] = [ @@ -115,12 +113,14 @@ describe('TextGenerationSidebar', () => { expect(screen.getByText('Share description')).toBeInTheDocument() expect(screen.getByRole('tablist')).toHaveClass('w-full') expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() - expect(runOncePropsSpy).toHaveBeenCalledWith(expect.objectContaining({ - inputs: { name: 'Alice' }, - promptConfig, - runControl: null, - visionConfig, - })) + expect(runOncePropsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + inputs: { name: 'Alice' }, + promptConfig, + runControl: null, + visionConfig, + }), + ) expect(screen.queryByTestId('saved-items-mock')).not.toBeInTheDocument() }) @@ -131,11 +131,15 @@ describe('TextGenerationSidebar', () => { }) expect(screen.getByTestId('run-batch-mock')).toBeInTheDocument() - expect(runBatchPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ - vars: promptConfig.prompt_variables, - isAllFinished: true, - })) - expect(screen.queryByRole('tab', { name: /^share\.generation\.tabs\.saved/ })).not.toBeInTheDocument() + expect(runBatchPropsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + vars: promptConfig.prompt_variables, + isAllFinished: true, + }), + ) + expect( + screen.queryByRole('tab', { name: /^share\.generation\.tabs\.saved/ }), + ).not.toBeInTheDocument() }) it('should render saved items and allow switching back to create tab', () => { @@ -147,10 +151,12 @@ describe('TextGenerationSidebar', () => { }) expect(screen.getByTestId('saved-items-mock')).toBeInTheDocument() - expect(savedItemsPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ - list: baseProps.savedMessages, - isShowTextToSpeech: true, - })) + expect(savedItemsPropsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + list: baseProps.savedMessages, + isShowTextToSpeech: true, + }), + ) fireEvent.click(screen.getByRole('button', { name: 'back-to-create' })) expect(onTabChange).toHaveBeenCalledWith('create') @@ -225,10 +231,12 @@ describe('TextGenerationSidebar', () => { expect(root).toHaveClass('h-[calc(100%-64px)]') expect(body).toHaveClass('px-4') expect(footer).toHaveClass('px-4', 'rounded-b-2xl') - expect(savedItemsPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ - className: expect.stringContaining('mt-4'), - isShowTextToSpeech: undefined, - })) + expect(savedItemsPropsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + className: expect.stringContaining('mt-4'), + isShowTextToSpeech: undefined, + }), + ) }) it('should round the mobile panel body and hide branding when the webapp brand is removed', () => { diff --git a/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-app-state.spec.ts b/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-app-state.spec.ts index 67fbe593398001..1657c6bd426abb 100644 --- a/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-app-state.spec.ts +++ b/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-app-state.spec.ts @@ -60,7 +60,8 @@ vi.mock('@/service/share', async () => { const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share') return { ...actual, - fetchSavedMessage: (...args: Parameters<typeof actual.fetchSavedMessage>) => fetchSavedMessageMock(...args), + fetchSavedMessage: (...args: Parameters<typeof actual.fetchSavedMessage>) => + fetchSavedMessageMock(...args), removeMessage: (...args: Parameters<typeof actual.removeMessage>) => removeMessageMock(...args), saveMessage: (...args: Parameters<typeof actual.saveMessage>) => saveMessageMock(...args), } @@ -169,7 +170,8 @@ const resetMockWebAppState = () => { } vi.mock('@/context/web-app-context', () => ({ - useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => selector(mockWebAppState), + useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => + selector(mockWebAppState), })) describe('useTextGenerationAppState', () => { @@ -185,14 +187,19 @@ describe('useTextGenerationAppState', () => { }) it('should initialize app state and fetch saved messages for non-workflow web apps', async () => { - const { result } = renderHook(() => useTextGenerationAppState({ - isInstalledApp: false, - isWorkflow: false, - })) + const { result } = renderHook(() => + useTextGenerationAppState({ + isInstalledApp: false, + isWorkflow: false, + }), + ) await waitFor(() => { expect(result.current.appId).toBe('app-123') - expect(result.current.promptConfig?.prompt_variables.map(item => item.name)).toEqual(['Name', 'Enabled']) + expect(result.current.promptConfig?.prompt_variables.map((item) => item.name)).toEqual([ + 'Name', + 'Enabled', + ]) expect(result.current.savedMessages).toEqual([{ id: 'saved-1' }]) }) @@ -203,20 +210,24 @@ describe('useTextGenerationAppState', () => { expect(changeLanguageMock).toHaveBeenCalledWith('en-US') expect(fetchSavedMessageMock).toHaveBeenCalledWith(AppSourceType.webApp, 'app-123') expect(useDocumentTitleMock).toHaveBeenCalledWith('Share title') - expect(useAppFaviconMock).toHaveBeenCalledWith(expect.objectContaining({ - enable: true, - icon: 'robot', - })) + expect(useAppFaviconMock).toHaveBeenCalledWith( + expect.objectContaining({ + enable: true, + icon: 'robot', + }), + ) }) it('should no-op save actions before the app id is initialized', async () => { mockWebAppState.appInfo = null mockWebAppState.appParams = null - const { result } = renderHook(() => useTextGenerationAppState({ - isInstalledApp: false, - isWorkflow: false, - })) + const { result } = renderHook(() => + useTextGenerationAppState({ + isInstalledApp: false, + isWorkflow: false, + }), + ) await act(async () => { await result.current.fetchSavedMessages('') @@ -237,10 +248,12 @@ describe('useTextGenerationAppState', () => { custom_config: null, } - const { result } = renderHook(() => useTextGenerationAppState({ - isInstalledApp: false, - isWorkflow: false, - })) + const { result } = renderHook(() => + useTextGenerationAppState({ + isInstalledApp: false, + isWorkflow: false, + }), + ) await waitFor(() => { expect(result.current.appId).toBe('app-123') @@ -249,10 +262,12 @@ describe('useTextGenerationAppState', () => { }) it('should save and remove messages then refresh saved messages', async () => { - const { result } = renderHook(() => useTextGenerationAppState({ - isInstalledApp: false, - isWorkflow: false, - })) + const { result } = renderHook(() => + useTextGenerationAppState({ + isInstalledApp: false, + isWorkflow: false, + }), + ) await waitFor(() => { expect(result.current.appId).toBe('app-123') @@ -287,10 +302,12 @@ describe('useTextGenerationAppState', () => { }) it('should skip saved message fetching for workflows and disable favicon for installed apps', async () => { - const { result } = renderHook(() => useTextGenerationAppState({ - isInstalledApp: true, - isWorkflow: true, - })) + const { result } = renderHook(() => + useTextGenerationAppState({ + isInstalledApp: true, + isWorkflow: true, + }), + ) await waitFor(() => { expect(result.current.appId).toBe('app-123') @@ -298,9 +315,11 @@ describe('useTextGenerationAppState', () => { expect(result.current.appSourceType).toBe(AppSourceType.installedApp) expect(fetchSavedMessageMock).not.toHaveBeenCalled() - expect(useAppFaviconMock).toHaveBeenCalledWith(expect.objectContaining({ - enable: false, - })) + expect(useAppFaviconMock).toHaveBeenCalledWith( + expect.objectContaining({ + enable: false, + }), + ) }) it('should apply workflow launch inputs from the url to hidden prompt variables', async () => { @@ -333,23 +352,27 @@ describe('useTextGenerationAppState', () => { secret: 'prefilled-secret', }) - const { result } = renderHook(() => useTextGenerationAppState({ - isInstalledApp: false, - isWorkflow: true, - })) + const { result } = renderHook(() => + useTextGenerationAppState({ + isInstalledApp: false, + isWorkflow: true, + }), + ) await waitFor(() => { - expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([ - expect.objectContaining({ - key: 'visible', - default: 'Shown', - }), - expect.objectContaining({ - key: 'secret', - hide: true, - default: 'prefilled-secret', - }), - ])) + expect(result.current.promptConfig?.prompt_variables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'visible', + default: 'Shown', + }), + expect.objectContaining({ + key: 'secret', + hide: true, + default: 'prefilled-secret', + }), + ]), + ) }) expect(getRawInputsFromUrlParamsMock).toHaveBeenCalled() @@ -405,18 +428,22 @@ describe('useTextGenerationAppState', () => { invalid_cb: 'invalid', }) - const { result } = renderHook(() => useTextGenerationAppState({ - isInstalledApp: false, - isWorkflow: true, - })) + const { result } = renderHook(() => + useTextGenerationAppState({ + isInstalledApp: false, + isWorkflow: true, + }), + ) await waitFor(() => { - expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: 'bool_true', default: true }), - expect.objectContaining({ key: 'str_true', default: true }), - expect.objectContaining({ key: 'str_false', default: false }), - expect.objectContaining({ key: 'invalid_cb', default: false }), - ])) + expect(result.current.promptConfig?.prompt_variables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'bool_true', default: true }), + expect.objectContaining({ key: 'str_true', default: true }), + expect.objectContaining({ key: 'str_false', default: false }), + expect.objectContaining({ key: 'invalid_cb', default: false }), + ]), + ) }) }) @@ -449,16 +476,20 @@ describe('useTextGenerationAppState', () => { num_nan: 'not-a-number', }) - const { result } = renderHook(() => useTextGenerationAppState({ - isInstalledApp: false, - isWorkflow: true, - })) + const { result } = renderHook(() => + useTextGenerationAppState({ + isInstalledApp: false, + isWorkflow: true, + }), + ) await waitFor(() => { - expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: 'num_valid', default: 42 }), - expect.objectContaining({ key: 'num_nan', default: 0 }), - ])) + expect(result.current.promptConfig?.prompt_variables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'num_valid', default: 42 }), + expect.objectContaining({ key: 'num_nan', default: 0 }), + ]), + ) }) }) @@ -493,16 +524,20 @@ describe('useTextGenerationAppState', () => { sel_invalid: 'gamma', }) - const { result } = renderHook(() => useTextGenerationAppState({ - isInstalledApp: false, - isWorkflow: true, - })) + const { result } = renderHook(() => + useTextGenerationAppState({ + isInstalledApp: false, + isWorkflow: true, + }), + ) await waitFor(() => { - expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: 'sel_valid', default: 'beta' }), - expect.objectContaining({ key: 'sel_invalid', default: 'alpha' }), - ])) + expect(result.current.promptConfig?.prompt_variables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'sel_valid', default: 'beta' }), + expect.objectContaining({ key: 'sel_invalid', default: 'alpha' }), + ]), + ) }) }) @@ -526,15 +561,19 @@ describe('useTextGenerationAppState', () => { text_field: 12345, }) - const { result } = renderHook(() => useTextGenerationAppState({ - isInstalledApp: false, - isWorkflow: true, - })) + const { result } = renderHook(() => + useTextGenerationAppState({ + isInstalledApp: false, + isWorkflow: true, + }), + ) await waitFor(() => { - expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: 'text_field', default: 'original' }), - ])) + expect(result.current.promptConfig?.prompt_variables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'text_field', default: 'original' }), + ]), + ) }) }) }) diff --git a/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-batch.spec.ts b/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-batch.spec.ts index a0e61a369315e1..0c6927b04a6043 100644 --- a/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-batch.spec.ts +++ b/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-batch.spec.ts @@ -28,11 +28,13 @@ const renderBatchHook = (promptConfig: PromptConfig = createPromptConfig()) => { const onStart = vi.fn() const t = createTranslator() - const hook = renderHook(() => useTextGenerationBatch({ - promptConfig, - notify, - t, - })) + const hook = renderHook(() => + useTextGenerationBatch({ + promptConfig, + notify, + t, + }), + ) return { ...hook, @@ -59,7 +61,11 @@ describe('useTextGenerationBatch', () => { expect(onStart).toHaveBeenCalledTimes(1) expect(result.current.isCallBatchAPI).toBe(true) expect(result.current.allTaskList).toHaveLength(BATCH_CONCURRENCY + 1) - expect(result.current.allTaskList.slice(0, BATCH_CONCURRENCY).every(task => task.status === TaskStatus.running)).toBe(true) + expect( + result.current.allTaskList + .slice(0, BATCH_CONCURRENCY) + .every((task) => task.status === TaskStatus.running), + ).toBe(true) expect(result.current.allTaskList.at(-1)?.status).toBe(TaskStatus.pending) expect(result.current.allTaskList[0]?.params.inputs).toEqual({ name: 'Item 1', @@ -72,10 +78,13 @@ describe('useTextGenerationBatch', () => { let isStarted = true act(() => { - isStarted = result.current.handleRunBatch([ - ['Prompt', 'Score'], - ['Hello', '1'], - ], { onStart }) + isStarted = result.current.handleRunBatch( + [ + ['Prompt', 'Score'], + ['Hello', '1'], + ], + { onStart }, + ) }) expect(isStarted).toBe(false) @@ -104,9 +113,7 @@ describe('useTextGenerationBatch', () => { notify.mockClear() act(() => { - isStarted = result.current.handleRunBatch([ - ['Name', 'Score'], - ], { onStart }) + isStarted = result.current.handleRunBatch([['Name', 'Score']], { onStart }) }) expect(isStarted).toBe(false) @@ -118,10 +125,13 @@ describe('useTextGenerationBatch', () => { notify.mockClear() act(() => { - isStarted = result.current.handleRunBatch([ - ['Name', 'Score'], - ['', ''], - ], { onStart }) + isStarted = result.current.handleRunBatch( + [ + ['Name', 'Score'], + ['', ''], + ], + { onStart }, + ) }) expect(isStarted).toBe(false) @@ -136,14 +146,17 @@ describe('useTextGenerationBatch', () => { let isStarted = true act(() => { - isStarted = result.current.handleRunBatch([ - ['Name', 'Score'], - ['Alice', '1'], - ['', ''], - ['Bob', '2'], - ['', ''], - ['', ''], - ], { onStart }) + isStarted = result.current.handleRunBatch( + [ + ['Name', 'Score'], + ['Alice', '1'], + ['', ''], + ['Bob', '2'], + ['', ''], + ['', ''], + ], + { onStart }, + ) }) expect(isStarted).toBe(false) @@ -158,10 +171,13 @@ describe('useTextGenerationBatch', () => { let isStarted = true act(() => { - isStarted = result.current.handleRunBatch([ - ['Name', 'Score'], - ['', '1'], - ], { onStart }) + isStarted = result.current.handleRunBatch( + [ + ['Name', 'Score'], + ['', '1'], + ], + { onStart }, + ) }) expect(isStarted).toBe(false) @@ -175,17 +191,26 @@ describe('useTextGenerationBatch', () => { const { result, notify, onStart } = renderBatchHook({ prompt_template: 'template', prompt_variables: [ - createVariable({ key: 'name', name: 'Name', type: 'string', required: true, max_length: 3 }), + createVariable({ + key: 'name', + name: 'Name', + type: 'string', + required: true, + max_length: 3, + }), createVariable({ key: 'score', name: 'Score', type: 'number', required: false }), ], }) let isStarted = true act(() => { - isStarted = result.current.handleRunBatch([ - ['Name', 'Score'], - ['Alice', '1'], - ], { onStart }) + isStarted = result.current.handleRunBatch( + [ + ['Name', 'Score'], + ['Alice', '1'], + ], + { onStart }, + ) }) expect(isStarted).toBe(false) @@ -199,7 +224,10 @@ describe('useTextGenerationBatch', () => { const { result } = renderBatchHook() const csvData = [ ['Name', 'Score'], - ...Array.from({ length: BATCH_CONCURRENCY + 1 }, (_, index) => [`Item ${index + 1}`, `${index + 1}`]), + ...Array.from({ length: BATCH_CONCURRENCY + 1 }, (_, index) => [ + `Item ${index + 1}`, + `${index + 1}`, + ]), ] act(() => { @@ -214,8 +242,8 @@ describe('useTextGenerationBatch', () => { expect(result.current.allTaskList.at(-1)?.status).toBe(TaskStatus.running) expect(result.current.exportRes.at(0)).toEqual({ - 'Name': 'Item 1', - 'Score': '1', + Name: 'Item 1', + Score: '1', 'generation.completionResult': 'Result 1', }) }) @@ -224,7 +252,10 @@ describe('useTextGenerationBatch', () => { const { result, notify, onStart } = renderBatchHook() const csvData = [ ['Name', 'Score'], - ...Array.from({ length: BATCH_CONCURRENCY + 1 }, (_, index) => [`Item ${index + 1}`, `${index + 1}`]), + ...Array.from({ length: BATCH_CONCURRENCY + 1 }, (_, index) => [ + `Item ${index + 1}`, + `${index + 1}`, + ]), ] act(() => { @@ -250,10 +281,13 @@ describe('useTextGenerationBatch', () => { const { result } = renderBatchHook() act(() => { - result.current.handleRunBatch([ - ['Name', 'Score'], - ['Alice', '1'], - ], { onStart: vi.fn() }) + result.current.handleRunBatch( + [ + ['Name', 'Score'], + ['Alice', '1'], + ], + { onStart: vi.fn() }, + ) }) const taskSnapshot = result.current.allTaskList @@ -269,10 +303,13 @@ describe('useTextGenerationBatch', () => { const { result } = renderBatchHook() act(() => { - result.current.handleRunBatch([ - ['Name', 'Score'], - ['Alice', ''], - ], { onStart: vi.fn() }) + result.current.handleRunBatch( + [ + ['Name', 'Score'], + ['Alice', ''], + ], + { onStart: vi.fn() }, + ) }) act(() => { @@ -290,8 +327,8 @@ describe('useTextGenerationBatch', () => { expect(result.current.noPendingTask).toBe(true) expect(result.current.exportRes).toEqual([ { - 'Name': 'Alice', - 'Score': '', + Name: 'Alice', + Score: '', 'generation.completionResult': '{"answer":"failed"}', }, ]) diff --git a/web/app/components/share/text-generation/hooks/use-text-generation-app-state.ts b/web/app/components/share/text-generation/hooks/use-text-generation-app-state.ts index 684ee301d062a3..75c853da440d74 100644 --- a/web/app/components/share/text-generation/hooks/use-text-generation-app-state.ts +++ b/web/app/components/share/text-generation/hooks/use-text-generation-app-state.ts @@ -1,5 +1,10 @@ import type { TextGenerationCustomConfig } from '../types' -import type { MoreLikeThisConfig, PromptConfig, SavedMessage, TextToSpeechConfig } from '@/models/debug' +import type { + MoreLikeThisConfig, + PromptConfig, + SavedMessage, + TextToSpeechConfig, +} from '@/models/debug' import type { SiteInfo } from '@/models/share' import type { VisionSettings } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' @@ -12,7 +17,12 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useAppFavicon } from '@/hooks/use-app-favicon' import useDocumentTitle from '@/hooks/use-document-title' import { changeLanguage } from '@/i18n-config/client' -import { AppSourceType, fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share' +import { + AppSourceType, + fetchSavedMessage as doFetchSavedMessage, + removeMessage, + saveMessage, +} from '@/service/share' import { Resolution, TransferMethod } from '@/types/app' import { userInputsFormToPromptVariables } from '@/utils/model-config' @@ -37,18 +47,14 @@ const coerceWorkflowUrlDefault = ( promptVariable: NonNullable<PromptConfig['prompt_variables']>[number], rawValue: unknown, ) => { - if (rawValue === undefined || rawValue === null) - return undefined + if (rawValue === undefined || rawValue === null) return undefined if (promptVariable.type === 'checkbox') { - if (typeof rawValue === 'boolean') - return rawValue + if (typeof rawValue === 'boolean') return rawValue const normalized = String(rawValue).toLowerCase() - if (normalized === 'true') - return true - if (normalized === 'false') - return false + if (normalized === 'true') return true + if (normalized === 'false') return false return undefined } @@ -58,25 +64,26 @@ const coerceWorkflowUrlDefault = ( return Number.isNaN(numericValue) ? undefined : numericValue } - if (typeof rawValue !== 'string') - return undefined + if (typeof rawValue !== 'string') return undefined if (promptVariable.type === 'select') return promptVariable.options?.includes(rawValue) ? rawValue : undefined - if (promptVariable.max_length) - return rawValue.slice(0, promptVariable.max_length) + if (promptVariable.max_length) return rawValue.slice(0, promptVariable.max_length) return rawValue } -export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTextGenerationAppStateOptions) => { +export const useTextGenerationAppState = ({ + isInstalledApp, + isWorkflow, +}: UseTextGenerationAppStateOptions) => { const { t } = useTranslation() const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const appData = useWebAppStore(s => s.appInfo) - const appParams = useWebAppStore(s => s.appParams) - const accessMode = useWebAppStore(s => s.webAppAccessMode) + const appData = useWebAppStore((s) => s.appInfo) + const appParams = useWebAppStore((s) => s.appParams) + const accessMode = useWebAppStore((s) => s.webAppAccessMode) const [appId, setAppId] = useState('') const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null) const [customConfig, setCustomConfig] = useState<TextGenerationCustomConfig | null>(null) @@ -90,53 +97,61 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex detail: Resolution.low, transfer_methods: [TransferMethod.local_file], }) - const fetchSavedMessages = useCallback(async (targetAppId = appId) => { - if (!targetAppId) - return - const res = await doFetchSavedMessage(appSourceType, targetAppId) as { - data: SavedMessage[] - } - setSavedMessages(res.data) - }, [appId, appSourceType]) - const handleSaveMessage = useCallback(async (messageId: string) => { - if (!appId) - return - await saveMessage(messageId, appSourceType, appId) - toast.success(t($ => $['api.saved'], { ns: 'common' })) - await fetchSavedMessages(appId) - }, [appId, appSourceType, fetchSavedMessages, t]) - const handleRemoveSavedMessage = useCallback(async (messageId: string) => { - if (!appId) - return - await removeMessage(messageId, appSourceType, appId) - toast.success(t($ => $['api.remove'], { ns: 'common' })) - await fetchSavedMessages(appId) - }, [appId, appSourceType, fetchSavedMessages, t]) + const fetchSavedMessages = useCallback( + async (targetAppId = appId) => { + if (!targetAppId) return + const res = (await doFetchSavedMessage(appSourceType, targetAppId)) as { + data: SavedMessage[] + } + setSavedMessages(res.data) + }, + [appId, appSourceType], + ) + const handleSaveMessage = useCallback( + async (messageId: string) => { + if (!appId) return + await saveMessage(messageId, appSourceType, appId) + toast.success(t(($) => $['api.saved'], { ns: 'common' })) + await fetchSavedMessages(appId) + }, + [appId, appSourceType, fetchSavedMessages, t], + ) + const handleRemoveSavedMessage = useCallback( + async (messageId: string) => { + if (!appId) return + await removeMessage(messageId, appSourceType, appId) + toast.success(t(($) => $['api.remove'], { ns: 'common' })) + await fetchSavedMessages(appId) + }, + [appId, appSourceType, fetchSavedMessages, t], + ) useEffect(() => { let cancelled = false const initialize = async () => { - if (!appData || !appParams) - return + if (!appData || !appParams) return const { app_id: nextAppId, site, custom_config } = appData setAppId(nextAppId) setSiteInfo(site as SiteInfo) setCustomConfig((custom_config || null) as TextGenerationCustomConfig | null) await changeLanguage(site.default_language) - const { user_input_form, more_like_this, file_upload, text_to_speech } = appParams as unknown as ShareAppParams + const { user_input_form, more_like_this, file_upload, text_to_speech } = + appParams as unknown as ShareAppParams const promptVariables = userInputsFormToPromptVariables(user_input_form) if (isWorkflow && !isInstalledApp) { const workflowUrlInputs = await getRawInputsFromUrlParams() promptVariables.forEach((promptVariable) => { - const workflowDefault = coerceWorkflowUrlDefault(promptVariable, workflowUrlInputs[promptVariable.key]) - if (workflowDefault !== undefined) - promptVariable.default = workflowDefault + const workflowDefault = coerceWorkflowUrlDefault( + promptVariable, + workflowUrlInputs[promptVariable.key], + ) + if (workflowDefault !== undefined) promptVariable.default = workflowDefault }) } - if (cancelled) - return + if (cancelled) return setVisionConfig({ ...file_upload, - transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods, + transfer_methods: + file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods, image_file_size_limit: appParams?.system_parameters.image_file_size_limit, fileUploadConfig: appParams?.system_parameters, } as VisionSettings) @@ -146,15 +161,14 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex } as PromptConfig) setMoreLikeThisConfig(more_like_this) setTextToSpeechConfig(text_to_speech) - if (!isWorkflow) - await fetchSavedMessages(nextAppId) + if (!isWorkflow) await fetchSavedMessages(nextAppId) } void initialize() return () => { cancelled = true } }, [appData, appParams, fetchSavedMessages, isInstalledApp, isWorkflow]) - useDocumentTitle(siteInfo?.title || t($ => $['generation.title'], { ns: 'share' })) + useDocumentTitle(siteInfo?.title || t(($) => $['generation.title'], { ns: 'share' })) useAppFavicon({ enable: !isInstalledApp, icon_type: siteInfo?.icon_type, diff --git a/web/app/components/share/text-generation/hooks/use-text-generation-batch.ts b/web/app/components/share/text-generation/hooks/use-text-generation-batch.ts index 17810a63e04ee2..bfd8ecf4805fb7 100644 --- a/web/app/components/share/text-generation/hooks/use-text-generation-batch.ts +++ b/web/app/components/share/text-generation/hooks/use-text-generation-batch.ts @@ -4,7 +4,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { BATCH_CONCURRENCY } from '@/config' import { TaskStatus } from '../types' -type BatchNotify = (payload: { type: 'error' | 'info', message: string }) => void +type BatchNotify = (payload: { type: 'error' | 'info'; message: string }) => void type UseTextGenerationBatchOptions = { promptConfig: PromptConfig | null notify: BatchNotify @@ -46,191 +46,234 @@ export const useTextGenerationBatch = ({ currGroupNumRef.current = 0 }, [updateAllTaskList, updateBatchCompletionRes]) - const checkBatchInputs = useCallback((data: string[][]) => { - if (!data || data.length === 0) { - notify({ type: 'error', message: t($ => $['generation.errorMsg.empty'], { ns: 'share' }) }) - return false - } - - const promptVariables = promptConfig?.prompt_variables ?? [] - const headerData = data[0] - let isMapVarName = true - promptVariables.forEach((item, index) => { - if (!isMapVarName) - return - - if (item.name !== headerData![index]) - isMapVarName = false - }) + const checkBatchInputs = useCallback( + (data: string[][]) => { + if (!data || data.length === 0) { + notify({ + type: 'error', + message: t(($) => $['generation.errorMsg.empty'], { ns: 'share' }), + }) + return false + } - if (!isMapVarName) { - notify({ type: 'error', message: t($ => $['generation.errorMsg.fileStructNotMatch'], { ns: 'share' }) }) - return false - } - - let payloadData = data.slice(1) - if (payloadData.length === 0) { - notify({ type: 'error', message: t($ => $['generation.errorMsg.atLeastOne'], { ns: 'share' }) }) - return false - } - - const emptyLineIndexes = payloadData - .filter(item => item.every(value => value === '')) - .map(item => payloadData.indexOf(item)) - if (emptyLineIndexes.length > 0) { - let hasMiddleEmptyLine = false - let startIndex = emptyLineIndexes[0]! - 1 - emptyLineIndexes.forEach((index) => { - if (hasMiddleEmptyLine) - return - if (startIndex + 1 !== index) { - hasMiddleEmptyLine = true - return - } - startIndex += 1 + const promptVariables = promptConfig?.prompt_variables ?? [] + const headerData = data[0] + let isMapVarName = true + promptVariables.forEach((item, index) => { + if (!isMapVarName) return + + if (item.name !== headerData![index]) isMapVarName = false }) - if (hasMiddleEmptyLine) { - notify({ type: 'error', message: t($ => $['generation.errorMsg.emptyLine'], { ns: 'share', rowIndex: startIndex + 2 }) }) + if (!isMapVarName) { + notify({ + type: 'error', + message: t(($) => $['generation.errorMsg.fileStructNotMatch'], { ns: 'share' }), + }) return false } - } - - payloadData = payloadData.filter(item => !item.every(value => value === '')) - if (payloadData.length === 0) { - notify({ type: 'error', message: t($ => $['generation.errorMsg.atLeastOne'], { ns: 'share' }) }) - return false - } - - let errorRowIndex = 0 - let requiredVarName = '' - let tooLongVarName = '' - let maxLength = 0 - - for (const [index, item] of payloadData.entries()) { - for (const [varIndex, varItem] of promptVariables.entries()) { - const value = item[varIndex] ?? '' - - if (varItem.type === 'string' && varItem.max_length && value.length > varItem.max_length) { - tooLongVarName = varItem.name - maxLength = varItem.max_length - errorRowIndex = index + 1 - break - } - if (varItem.required && value.trim() === '') { - requiredVarName = varItem.name - errorRowIndex = index + 1 - break - } + let payloadData = data.slice(1) + if (payloadData.length === 0) { + notify({ + type: 'error', + message: t(($) => $['generation.errorMsg.atLeastOne'], { ns: 'share' }), + }) + return false } - if (errorRowIndex !== 0) - break - } + const emptyLineIndexes = payloadData + .filter((item) => item.every((value) => value === '')) + .map((item) => payloadData.indexOf(item)) + if (emptyLineIndexes.length > 0) { + let hasMiddleEmptyLine = false + let startIndex = emptyLineIndexes[0]! - 1 + emptyLineIndexes.forEach((index) => { + if (hasMiddleEmptyLine) return + if (startIndex + 1 !== index) { + hasMiddleEmptyLine = true + return + } + startIndex += 1 + }) + + if (hasMiddleEmptyLine) { + notify({ + type: 'error', + message: t(($) => $['generation.errorMsg.emptyLine'], { + ns: 'share', + rowIndex: startIndex + 2, + }), + }) + return false + } + } - if (errorRowIndex !== 0) { - if (requiredVarName) { + payloadData = payloadData.filter((item) => !item.every((value) => value === '')) + if (payloadData.length === 0) { notify({ type: 'error', - message: t($ => $['generation.errorMsg.invalidLine'], { ns: 'share', rowIndex: errorRowIndex + 1, varName: requiredVarName }), + message: t(($) => $['generation.errorMsg.atLeastOne'], { ns: 'share' }), }) + return false + } + + let errorRowIndex = 0 + let requiredVarName = '' + let tooLongVarName = '' + let maxLength = 0 + + for (const [index, item] of payloadData.entries()) { + for (const [varIndex, varItem] of promptVariables.entries()) { + const value = item[varIndex] ?? '' + + if ( + varItem.type === 'string' && + varItem.max_length && + value.length > varItem.max_length + ) { + tooLongVarName = varItem.name + maxLength = varItem.max_length + errorRowIndex = index + 1 + break + } + + if (varItem.required && value.trim() === '') { + requiredVarName = varItem.name + errorRowIndex = index + 1 + break + } + } + + if (errorRowIndex !== 0) break + } + + if (errorRowIndex !== 0) { + if (requiredVarName) { + notify({ + type: 'error', + message: t(($) => $['generation.errorMsg.invalidLine'], { + ns: 'share', + rowIndex: errorRowIndex + 1, + varName: requiredVarName, + }), + }) + } + + if (tooLongVarName) { + notify({ + type: 'error', + message: t(($) => $['generation.errorMsg.moreThanMaxLengthLine'], { + ns: 'share', + rowIndex: errorRowIndex + 1, + varName: tooLongVarName, + maxLength, + }), + }) + } + + return false } - if (tooLongVarName) { + return true + }, + [notify, promptConfig, t], + ) + + const handleRunBatch = useCallback( + (data: string[][], { onStart }: RunBatchCallbacks) => { + if (!checkBatchInputs(data)) return false + + const latestTaskList = allTaskListRef.current + const allTasksFinished = latestTaskList.every((task) => task.status === TaskStatus.completed) + if (!allTasksFinished && latestTaskList.length > 0) { notify({ - type: 'error', - message: t($ => $['generation.errorMsg.moreThanMaxLengthLine'], { - ns: 'share', - rowIndex: errorRowIndex + 1, - varName: tooLongVarName, - maxLength, - }), + type: 'info', + message: t(($) => $['errorMessage.waitForBatchResponse'], { ns: 'appDebug' }), }) + return false } - return false - } - - return true - }, [notify, promptConfig, t]) - - const handleRunBatch = useCallback((data: string[][], { onStart }: RunBatchCallbacks) => { - if (!checkBatchInputs(data)) - return false - - const latestTaskList = allTaskListRef.current - const allTasksFinished = latestTaskList.every(task => task.status === TaskStatus.completed) - if (!allTasksFinished && latestTaskList.length > 0) { - notify({ type: 'info', message: t($ => $['errorMessage.waitForBatchResponse'], { ns: 'appDebug' }) }) - return false - } - - const payloadData = data.filter(item => !item.every(value => value === '')).slice(1) - const promptVariables = promptConfig?.prompt_variables ?? [] - const nextTaskList: Task[] = payloadData.map((item, index) => { - const inputs: Record<string, string | boolean | undefined> = {} - promptVariables.forEach((variable, varIndex) => { - const input = item[varIndex] - inputs[variable.key] = input - if (!input) - inputs[variable.key] = variable.type === 'string' || variable.type === 'paragraph' ? '' : undefined - }) + const payloadData = data.filter((item) => !item.every((value) => value === '')).slice(1) + const promptVariables = promptConfig?.prompt_variables ?? [] + const nextTaskList: Task[] = payloadData.map((item, index) => { + const inputs: Record<string, string | boolean | undefined> = {} + promptVariables.forEach((variable, varIndex) => { + const input = item[varIndex] + inputs[variable.key] = input + if (!input) + inputs[variable.key] = + variable.type === 'string' || variable.type === 'paragraph' ? '' : undefined + }) - return { - id: index + 1, - status: index < GROUP_SIZE ? TaskStatus.running : TaskStatus.pending, - params: { inputs }, - } - }) + return { + id: index + 1, + status: index < GROUP_SIZE ? TaskStatus.running : TaskStatus.pending, + params: { inputs }, + } + }) - setIsCallBatchAPI(true) - updateAllTaskList(nextTaskList) - updateBatchCompletionRes({}) - currGroupNumRef.current = 0 - onStart() - return true - }, [checkBatchInputs, notify, promptConfig, t, updateAllTaskList, updateBatchCompletionRes]) - - const handleCompleted = useCallback((completionRes: string, taskId?: number, isSuccess?: boolean) => { - if (!taskId) - return - - const latestTaskList = allTaskListRef.current - const latestBatchCompletionRes = batchCompletionResRef.current - const pendingTaskList = latestTaskList.filter(task => task.status === TaskStatus.pending) - const runTasksCount = 1 + latestTaskList.filter(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status)).length - const shouldStartNextGroup = currGroupNumRef.current !== runTasksCount - && pendingTaskList.length > 0 - && (runTasksCount % GROUP_SIZE === 0 || (latestTaskList.length - runTasksCount < GROUP_SIZE)) - - if (shouldStartNextGroup) - currGroupNumRef.current = runTasksCount - - const nextPendingTaskIds = shouldStartNextGroup ? pendingTaskList.slice(0, GROUP_SIZE).map(item => item.id) : [] - updateAllTaskList(latestTaskList.map((task) => { - if (task.id === taskId) - return { ...task, status: isSuccess ? TaskStatus.completed : TaskStatus.failed } - if (shouldStartNextGroup && nextPendingTaskIds.includes(task.id)) - return { ...task, status: TaskStatus.running } - return task - })) - updateBatchCompletionRes({ - ...latestBatchCompletionRes, - [taskId]: completionRes, - }) - }, [updateAllTaskList, updateBatchCompletionRes]) + setIsCallBatchAPI(true) + updateAllTaskList(nextTaskList) + updateBatchCompletionRes({}) + currGroupNumRef.current = 0 + onStart() + return true + }, + [checkBatchInputs, notify, promptConfig, t, updateAllTaskList, updateBatchCompletionRes], + ) + + const handleCompleted = useCallback( + (completionRes: string, taskId?: number, isSuccess?: boolean) => { + if (!taskId) return + + const latestTaskList = allTaskListRef.current + const latestBatchCompletionRes = batchCompletionResRef.current + const pendingTaskList = latestTaskList.filter((task) => task.status === TaskStatus.pending) + const runTasksCount = + 1 + + latestTaskList.filter((task) => + [TaskStatus.completed, TaskStatus.failed].includes(task.status), + ).length + const shouldStartNextGroup = + currGroupNumRef.current !== runTasksCount && + pendingTaskList.length > 0 && + (runTasksCount % GROUP_SIZE === 0 || latestTaskList.length - runTasksCount < GROUP_SIZE) + + if (shouldStartNextGroup) currGroupNumRef.current = runTasksCount + + const nextPendingTaskIds = shouldStartNextGroup + ? pendingTaskList.slice(0, GROUP_SIZE).map((item) => item.id) + : [] + updateAllTaskList( + latestTaskList.map((task) => { + if (task.id === taskId) + return { ...task, status: isSuccess ? TaskStatus.completed : TaskStatus.failed } + if (shouldStartNextGroup && nextPendingTaskIds.includes(task.id)) + return { ...task, status: TaskStatus.running } + return task + }), + ) + updateBatchCompletionRes({ + ...latestBatchCompletionRes, + [taskId]: completionRes, + }) + }, + [updateAllTaskList, updateBatchCompletionRes], + ) const handleRetryAllFailedTask = useCallback(() => { setControlRetry(Date.now()) }, []) - const pendingTaskList = allTaskList.filter(task => task.status === TaskStatus.pending) - const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending) - const allSuccessTaskList = allTaskList.filter(task => task.status === TaskStatus.completed) - const allFailedTaskList = allTaskList.filter(task => task.status === TaskStatus.failed) - const allTasksFinished = allTaskList.every(task => task.status === TaskStatus.completed) - const allTasksRun = allTaskList.every(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status)) + const pendingTaskList = allTaskList.filter((task) => task.status === TaskStatus.pending) + const showTaskList = allTaskList.filter((task) => task.status !== TaskStatus.pending) + const allSuccessTaskList = allTaskList.filter((task) => task.status === TaskStatus.completed) + const allFailedTaskList = allTaskList.filter((task) => task.status === TaskStatus.failed) + const allTasksFinished = allTaskList.every((task) => task.status === TaskStatus.completed) + const allTasksRun = allTaskList.every((task) => + [TaskStatus.completed, TaskStatus.failed].includes(task.status), + ) const exportRes = useMemo(() => { return allTaskList.map((task) => { @@ -240,7 +283,7 @@ export const useTextGenerationBatch = ({ }) const completionValue = batchCompletionMap[String(task.id)] ?? '' - result[t($ => $['generation.completionResult'], { ns: 'share' })] = completionValue + result[t(($) => $['generation.completionResult'], { ns: 'share' })] = completionValue return result }) }, [allTaskList, batchCompletionMap, promptConfig, t]) diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx index f7d61329df266b..7ffa36c5e2ae70 100644 --- a/web/app/components/share/text-generation/index.tsx +++ b/web/app/components/share/text-generation/index.tsx @@ -23,14 +23,19 @@ type IMainProps = { } const TextGeneration: FC<IMainProps> = ({ isInstalledApp = false, isWorkflow = false }) => { const { t } = useTranslation() - const translateBatchKey: TextGenerationTranslate = useCallback((selector, options) => { - return t(selector, options) - }, [t]) + const translateBatchKey: TextGenerationTranslate = useCallback( + (selector, options) => { + return t(selector, options) + }, + [t], + ) const media = useBreakpoints() const isPC = media === MediaType.pc const searchParams = useSearchParams() const mode = searchParams.get('mode') || 'create' - const [currentTab, setCurrentTab] = useState<string>(['create', 'batch'].includes(mode) ? mode : 'create') + const [currentTab, setCurrentTab] = useState<string>( + ['create', 'batch'].includes(mode) ? mode : 'create', + ) const [inputs, setInputs] = useState<Record<string, InputValueTypes>>({}) const inputsRef = useRef(inputs) const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([]) @@ -38,26 +43,58 @@ const TextGeneration: FC<IMainProps> = ({ isInstalledApp = false, isWorkflow = f const [controlSend, setControlSend] = useState(0) const [controlStopResponding, setControlStopResponding] = useState(0) const [resultExisted, setResultExisted] = useState(false) - const [isShowResultPanel, { setTrue: showResultPanelState, setFalse: hideResultPanel }] = useBoolean(false) - const notify = useCallback(({ type, message }: { type: 'error' | 'info' | 'success' | 'warning', message: string }) => { - toast(message, { type }) - }, []) + const [isShowResultPanel, { setTrue: showResultPanelState, setFalse: hideResultPanel }] = + useBoolean(false) + const notify = useCallback( + ({ type, message }: { type: 'error' | 'info' | 'success' | 'warning'; message: string }) => { + toast(message, { type }) + }, + [], + ) const updateInputs = useCallback((newInputs: Record<string, InputValueTypes>) => { setInputs(newInputs) inputsRef.current = newInputs }, []) - const { accessMode, appId, appSourceType, customConfig, handleRemoveSavedMessage, handleSaveMessage, moreLikeThisConfig, promptConfig, savedMessages, siteInfo, systemFeatures, textToSpeechConfig, visionConfig } = useTextGenerationAppState({ + const { + accessMode, + appId, + appSourceType, + customConfig, + handleRemoveSavedMessage, + handleSaveMessage, + moreLikeThisConfig, + promptConfig, + savedMessages, + siteInfo, + systemFeatures, + textToSpeechConfig, + visionConfig, + } = useTextGenerationAppState({ isInstalledApp, isWorkflow, }) - const { allFailedTaskList, allSuccessTaskList, allTaskList, allTasksRun, controlRetry, exportRes, handleCompleted, handleRetryAllFailedTask, handleRunBatch: runBatchExecution, isCallBatchAPI, noPendingTask, resetBatchExecution, setIsCallBatchAPI, showTaskList } = useTextGenerationBatch({ + const { + allFailedTaskList, + allSuccessTaskList, + allTaskList, + allTasksRun, + controlRetry, + exportRes, + handleCompleted, + handleRetryAllFailedTask, + handleRunBatch: runBatchExecution, + isCallBatchAPI, + noPendingTask, + resetBatchExecution, + setIsCallBatchAPI, + showTaskList, + } = useTextGenerationBatch({ promptConfig, notify, t: translateBatchKey, }) useEffect(() => { - if (isCallBatchAPI) - setRunControl(null) + if (isCallBatchAPI) setRunControl(null) }, [isCallBatchAPI]) const showResultPanel = useCallback(() => { setTimeout(() => { @@ -73,15 +110,18 @@ const TextGeneration: FC<IMainProps> = ({ isInstalledApp = false, isWorkflow = f resetBatchExecution() showResultPanel() }, [resetBatchExecution, setIsCallBatchAPI, showResultPanel]) - const handleRunBatch = useCallback((data: string[][]) => { - runBatchExecution(data, { - onStart: () => { - setControlSend(Date.now()) - setControlStopResponding(Date.now()) - showResultPanel() - }, - }) - }, [runBatchExecution, showResultPanel]) + const handleRunBatch = useCallback( + (data: string[][]) => { + runBatchExecution(data, { + onStart: () => { + setControlSend(Date.now()) + setControlStopResponding(Date.now()) + showResultPanel() + }, + }) + }, + [runBatchExecution, showResultPanel], + ) if (!appId || !siteInfo || !promptConfig) { return ( <div className="flex h-screen items-center"> @@ -90,9 +130,70 @@ const TextGeneration: FC<IMainProps> = ({ isInstalledApp = false, isWorkflow = f ) } return ( - <div className={cn('bg-background-default-burn', isPC ? 'flex' : 'flex-col', isInstalledApp ? 'h-full rounded-2xl shadow-md' : 'h-screen')}> - <TextGenerationSidebar accessMode={accessMode} allTasksRun={allTasksRun} currentTab={currentTab} customConfig={customConfig} inputs={inputs} inputsRef={inputsRef} isInstalledApp={isInstalledApp} isPC={isPC} isWorkflow={isWorkflow} onBatchSend={handleRunBatch} onInputsChange={updateInputs} onRemoveSavedMessage={handleRemoveSavedMessage} onRunOnceSend={handleRunOnce} onTabChange={setCurrentTab} onVisionFilesChange={setCompletionFiles} promptConfig={promptConfig} resultExisted={resultExisted} runControl={runControl} savedMessages={savedMessages} siteInfo={siteInfo} systemFeatures={systemFeatures} textToSpeechConfig={textToSpeechConfig} visionConfig={visionConfig} /> - <TextGenerationResultPanel allFailedTaskList={allFailedTaskList} allSuccessTaskList={allSuccessTaskList} allTaskList={allTaskList} appId={appId} appSourceType={appSourceType} completionFiles={completionFiles} controlRetry={controlRetry} controlSend={controlSend} controlStopResponding={controlStopResponding} exportRes={exportRes} handleCompleted={handleCompleted} handleRetryAllFailedTask={handleRetryAllFailedTask} handleSaveMessage={handleSaveMessage} inputs={inputs} isCallBatchAPI={isCallBatchAPI} isPC={isPC} isShowResultPanel={isShowResultPanel} isWorkflow={isWorkflow} moreLikeThisEnabled={!!moreLikeThisConfig?.enabled} noPendingTask={noPendingTask} onHideResultPanel={hideResultPanel} onRunControlChange={setRunControl} onRunStart={handleRunStart} onShowResultPanel={showResultPanel} promptConfig={promptConfig} resultExisted={resultExisted} showTaskList={showTaskList} siteInfo={siteInfo} textToSpeechEnabled={!!textToSpeechConfig?.enabled} visionConfig={visionConfig} /> + <div + className={cn( + 'bg-background-default-burn', + isPC ? 'flex' : 'flex-col', + isInstalledApp ? 'h-full rounded-2xl shadow-md' : 'h-screen', + )} + > + <TextGenerationSidebar + accessMode={accessMode} + allTasksRun={allTasksRun} + currentTab={currentTab} + customConfig={customConfig} + inputs={inputs} + inputsRef={inputsRef} + isInstalledApp={isInstalledApp} + isPC={isPC} + isWorkflow={isWorkflow} + onBatchSend={handleRunBatch} + onInputsChange={updateInputs} + onRemoveSavedMessage={handleRemoveSavedMessage} + onRunOnceSend={handleRunOnce} + onTabChange={setCurrentTab} + onVisionFilesChange={setCompletionFiles} + promptConfig={promptConfig} + resultExisted={resultExisted} + runControl={runControl} + savedMessages={savedMessages} + siteInfo={siteInfo} + systemFeatures={systemFeatures} + textToSpeechConfig={textToSpeechConfig} + visionConfig={visionConfig} + /> + <TextGenerationResultPanel + allFailedTaskList={allFailedTaskList} + allSuccessTaskList={allSuccessTaskList} + allTaskList={allTaskList} + appId={appId} + appSourceType={appSourceType} + completionFiles={completionFiles} + controlRetry={controlRetry} + controlSend={controlSend} + controlStopResponding={controlStopResponding} + exportRes={exportRes} + handleCompleted={handleCompleted} + handleRetryAllFailedTask={handleRetryAllFailedTask} + handleSaveMessage={handleSaveMessage} + inputs={inputs} + isCallBatchAPI={isCallBatchAPI} + isPC={isPC} + isShowResultPanel={isShowResultPanel} + isWorkflow={isWorkflow} + moreLikeThisEnabled={!!moreLikeThisConfig?.enabled} + noPendingTask={noPendingTask} + onHideResultPanel={hideResultPanel} + onRunControlChange={setRunControl} + onRunStart={handleRunStart} + onShowResultPanel={showResultPanel} + promptConfig={promptConfig} + resultExisted={resultExisted} + showTaskList={showTaskList} + siteInfo={siteInfo} + textToSpeechEnabled={!!textToSpeechConfig?.enabled} + visionConfig={visionConfig} + /> </div> ) } diff --git a/web/app/components/share/text-generation/info-modal.tsx b/web/app/components/share/text-generation/info-modal.tsx index 0e5007c9ea51cc..5f8cc9090d9ce3 100644 --- a/web/app/components/share/text-generation/info-modal.tsx +++ b/web/app/components/share/text-generation/info-modal.tsx @@ -11,19 +11,14 @@ type Props = Readonly<{ onClose: () => void }> -const InfoModal = ({ - isShow, - onClose, - data, -}: Props) => { +const InfoModal = ({ isShow, onClose, data }: Props) => { const [currentYear] = React.useState(() => new Date().getFullYear()) return ( <Dialog open={isShow} onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DialogContent className="w-full max-w-100 min-w-100 overflow-hidden! border-none p-0! text-left align-middle"> @@ -45,17 +40,10 @@ const InfoModal = ({ {/* copyright */} {data?.copyright && ( <div> - Copyright © - {' '} - {currentYear} - {' '} - {data?.copyright} - . All Rights Reserved. + Copyright © {currentYear} {data?.copyright}. All Rights Reserved. </div> )} - {data?.custom_disclaimer && ( - <div className="mt-2">{data.custom_disclaimer}</div> - )} + {data?.custom_disclaimer && <div className="mt-2">{data.custom_disclaimer}</div>} </div> </div> </DialogContent> diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx index 1a0d604d7121c9..48c865b2c00e99 100644 --- a/web/app/components/share/text-generation/menu-dropdown.tsx +++ b/web/app/components/share/text-generation/menu-dropdown.tsx @@ -27,17 +27,13 @@ type Props = Readonly<{ hideLogout?: boolean }> -const MenuDropdown: FC<Props> = ({ - data, - placement, - hideLogout, -}) => { - const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) +const MenuDropdown: FC<Props> = ({ data, placement, hideLogout }) => { + const webAppAccessMode = useWebAppStore((s) => s.webAppAccessMode) const router = useRouter() const pathname = usePathname() const { t } = useTranslation() - const shareCode = useWebAppStore(s => s.shareCode) + const shareCode = useWebAppStore((s) => s.shareCode) const handleLogout = async () => { await webAppLogout(shareCode!) router.replace(`/webapp-signin?redirect_url=${pathname}`) @@ -54,12 +50,12 @@ const MenuDropdown: FC<Props> = ({ <> <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <ActionButton size="l" className="data-popup-open:bg-state-base-hover"> <span aria-hidden className="i-ri-equalizer-2-line h-[18px] w-[18px]" /> </ActionButton> - )} - aria-label={t($ => $['operation.more'], { ns: 'common' })} + } + aria-label={t(($) => $['operation.more'], { ns: 'common' })} /> <DropdownMenuContent placement={placement || 'bottom-end'} @@ -68,7 +64,7 @@ const MenuDropdown: FC<Props> = ({ > <div className="px-3 py-1.5 system-md-regular text-text-secondary"> <div className="flex items-center gap-2"> - <div className="grow">{t($ => $['theme.theme'], { ns: 'common' })}</div> + <div className="grow">{t(($) => $['theme.theme'], { ns: 'common' })}</div> <ThemeSwitcher /> </div> </div> @@ -80,21 +76,21 @@ const MenuDropdown: FC<Props> = ({ target="_blank" rel="noreferrer" > - <span className="grow">{t($ => $['chat.privacyPolicyMiddle'], { ns: 'share' })}</span> + <span className="grow"> + {t(($) => $['chat.privacyPolicyMiddle'], { ns: 'share' })} + </span> </DropdownMenuLinkItem> )} - <DropdownMenuItem - className="px-3 system-md-regular" - onClick={handleOpenInfoModal} - > - {t($ => $['userProfile.about'], { ns: 'common' })} + <DropdownMenuItem className="px-3 system-md-regular" onClick={handleOpenInfoModal}> + {t(($) => $['userProfile.about'], { ns: 'common' })} </DropdownMenuItem> - {!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && ( - <DropdownMenuItem - className="px-3 system-md-regular" - onClick={handleLogout} - > - {t($ => $['userProfile.logout'], { ns: 'common' })} + {!( + hideLogout || + webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || + webAppAccessMode === AccessMode.PUBLIC + ) && ( + <DropdownMenuItem className="px-3 system-md-regular" onClick={handleLogout}> + {t(($) => $['userProfile.logout'], { ns: 'common' })} </DropdownMenuItem> )} </DropdownMenuContent> diff --git a/web/app/components/share/text-generation/no-data/index.tsx b/web/app/components/share/text-generation/no-data/index.tsx index 6d084f85b5765b..23505e7f12b446 100644 --- a/web/app/components/share/text-generation/no-data/index.tsx +++ b/web/app/components/share/text-generation/no-data/index.tsx @@ -1,7 +1,5 @@ import type { FC } from 'react' -import { - RiSparklingFill, -} from '@remixicon/react' +import { RiSparklingFill } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -11,10 +9,8 @@ const NoData: FC<INoDataProps> = () => { return ( <div className="flex size-full flex-col items-center justify-center"> <RiSparklingFill className="size-12 text-text-empty-state-icon" /> - <div - className="mt-2 system-sm-regular text-text-quaternary" - > - {t($ => $['generation.noData'], { ns: 'share' })} + <div className="mt-2 system-sm-regular text-text-quaternary"> + {t(($) => $['generation.noData'], { ns: 'share' })} </div> </div> ) diff --git a/web/app/components/share/text-generation/result/__tests__/index.spec.tsx b/web/app/components/share/text-generation/result/__tests__/index.spec.tsx index 03e5c07b618556..b52410176c0774 100644 --- a/web/app/components/share/text-generation/result/__tests__/index.spec.tsx +++ b/web/app/components/share/text-generation/result/__tests__/index.spec.tsx @@ -49,9 +49,12 @@ vi.mock('@/service/share', async () => { const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share') return { ...actual, - sendCompletionMessage: (...args: Parameters<typeof actual.sendCompletionMessage>) => sendCompletionMessageMock(...args), - sendWorkflowMessage: (...args: Parameters<typeof actual.sendWorkflowMessage>) => sendWorkflowMessageMock(...args), - stopChatMessageResponding: (...args: Parameters<typeof actual.stopChatMessageResponding>) => stopChatMessageRespondingMock(...args), + sendCompletionMessage: (...args: Parameters<typeof actual.sendCompletionMessage>) => + sendCompletionMessageMock(...args), + sendWorkflowMessage: (...args: Parameters<typeof actual.sendWorkflowMessage>) => + sendWorkflowMessageMock(...args), + stopChatMessageResponding: (...args: Parameters<typeof actual.stopChatMessageResponding>) => + stopChatMessageRespondingMock(...args), } }) @@ -72,9 +75,7 @@ vi.mock('@/app/components/share/text-generation/no-data', () => ({ const promptConfig: PromptConfig = { prompt_template: 'template', - prompt_variables: [ - { key: 'name', name: 'Name', type: 'string', required: true }, - ], + prompt_variables: [{ key: 'name', name: 'Name', type: 'string', required: true }], } const siteInfo: SiteInfo = { @@ -132,7 +133,11 @@ describe('Result', () => { it('should stream completion results and stop the current task', async () => { let completionHandlers: { onCompleted: () => void - onData: (chunk: string, isFirstMessage: boolean, info: { messageId: string, taskId?: string }) => void + onData: ( + chunk: string, + isFirstMessage: boolean, + info: { messageId: string; taskId?: string }, + ) => void onError: () => void onMessageReplace: (messageReplace: { answer: string }) => void } | null = null @@ -144,11 +149,7 @@ describe('Result', () => { const onCompleted = vi.fn() const onRunControlChange = vi.fn() const { rerender } = render( - <Result - {...baseProps} - onCompleted={onCompleted} - onRunControlChange={onRunControlChange} - />, + <Result {...baseProps} onCompleted={onCompleted} onRunControlChange={onRunControlChange} />, ) rerender( @@ -173,14 +174,21 @@ describe('Result', () => { expect(screen.getByTestId('text-generation-res').textContent).toContain('Hello') await waitFor(() => { - expect(onRunControlChange).toHaveBeenLastCalledWith(expect.objectContaining({ - isStopping: false, - })) + expect(onRunControlChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + isStopping: false, + }), + ) }) fireEvent.click(screen.getByRole('button', { name: 'operation.stopResponding' })) await waitFor(() => { - expect(stopChatMessageRespondingMock).toHaveBeenCalledWith('app-1', 'task-1', AppSourceType.webApp, 'app-1') + expect(stopChatMessageRespondingMock).toHaveBeenCalledWith( + 'app-1', + 'task-1', + AppSourceType.webApp, + 'app-1', + ) }) await act(async () => { @@ -188,9 +196,11 @@ describe('Result', () => { }) expect(onCompleted).toHaveBeenCalledWith('Hello', undefined, true) - expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith(expect.objectContaining({ - messageId: 'message-1', - })) + expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + messageId: 'message-1', + }), + ) }) it('should render workflow results after workflow completion', async () => { @@ -200,22 +210,9 @@ describe('Result', () => { }) const onCompleted = vi.fn() - const { rerender } = render( - <Result - {...baseProps} - isWorkflow - onCompleted={onCompleted} - />, - ) + const { rerender } = render(<Result {...baseProps} isWorkflow onCompleted={onCompleted} />) - rerender( - <Result - {...baseProps} - isWorkflow - controlSend={1} - onCompleted={onCompleted} - />, - ) + rerender(<Result {...baseProps} isWorkflow controlSend={1} onCompleted={onCompleted} />) await act(async () => { workflowHandlers?.onWorkflowStarted?.({ @@ -263,44 +260,42 @@ describe('Result', () => { }) expect(screen.getByTestId('text-generation-res').textContent).toContain('{"answer":"Hello"}') - expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith(expect.objectContaining({ - workflowProcessData: expect.objectContaining({ - resultText: 'Hello', - status: 'succeeded', + expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + workflowProcessData: expect.objectContaining({ + resultText: 'Hello', + status: 'succeeded', + }), }), - })) + ) expect(onCompleted).toHaveBeenCalledWith('{"answer":"Hello"}', undefined, true) }) it('should render batch task ids for both short and long indexes', () => { - const { rerender } = render( - <Result - {...baseProps} - isCallBatchAPI - taskId={3} - />, + const { rerender } = render(<Result {...baseProps} isCallBatchAPI taskId={3} />) + + expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + taskId: '03', + }), ) - expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith(expect.objectContaining({ - taskId: '03', - })) + rerender(<Result {...baseProps} isCallBatchAPI taskId={12} />) - rerender( - <Result - {...baseProps} - isCallBatchAPI - taskId={12} - />, + expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + taskId: '12', + }), ) - - expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith(expect.objectContaining({ - taskId: '12', - })) }) it('should render the mobile stop button layout while a batch run is responding', async () => { let completionHandlers: { - onData: (chunk: string, isFirstMessage: boolean, info: { messageId: string, taskId?: string }) => void + onData: ( + chunk: string, + isFirstMessage: boolean, + info: { messageId: string; taskId?: string }, + ) => void } | null = null sendCompletionMessageMock.mockImplementation(async (_data, handlers) => { @@ -308,24 +303,11 @@ describe('Result', () => { }) const { rerender } = render( - <Result - {...baseProps} - isCallBatchAPI - isMobile - isPC={false} - taskId={2} - />, + <Result {...baseProps} isCallBatchAPI isMobile isPC={false} taskId={2} />, ) rerender( - <Result - {...baseProps} - controlSend={1} - isCallBatchAPI - isMobile - isPC={false} - taskId={2} - />, + <Result {...baseProps} controlSend={1} isCallBatchAPI isMobile isPC={false} taskId={2} />, ) await act(async () => { @@ -335,6 +317,8 @@ describe('Result', () => { }) }) - expect(screen.getByRole('button', { name: 'operation.stopResponding' }).parentElement?.className).toContain('justify-center') + expect( + screen.getByRole('button', { name: 'operation.stopResponding' }).parentElement?.className, + ).toContain('justify-center') }) }) diff --git a/web/app/components/share/text-generation/result/__tests__/result-request.spec.ts b/web/app/components/share/text-generation/result/__tests__/result-request.spec.ts index 62ef4c197d58b4..1b86e350d37550 100644 --- a/web/app/components/share/text-generation/result/__tests__/result-request.spec.ts +++ b/web/app/components/share/text-generation/result/__tests__/result-request.spec.ts @@ -77,9 +77,7 @@ describe('result-request', () => { isCallBatchAPI: false, promptConfig: { prompt_template: 'template', - prompt_variables: [ - { key: 'count', name: 'Count', type: 'number', required: true }, - ], + prompt_variables: [{ key: 'count', name: 'Count', type: 'number', required: true }], }, t: createTranslator(), }) @@ -96,9 +94,7 @@ describe('result-request', () => { isCallBatchAPI: false, promptConfig: { prompt_template: 'template', - prompt_variables: [ - { key: 'name', name: 'Name', type: 'string', required: true }, - ], + prompt_variables: [{ key: 'name', name: 'Name', type: 'string', required: true }], }, t: createTranslator(), }) @@ -121,9 +117,7 @@ describe('result-request', () => { isCallBatchAPI: false, promptConfig: { prompt_template: 'template', - prompt_variables: [ - { key: 'files', name: 'Files', type: 'file-list', required: true }, - ], + prompt_variables: [{ key: 'files', name: 'Files', type: 'file-list', required: true }], }, t: createTranslator(), }) @@ -146,9 +140,7 @@ describe('result-request', () => { isCallBatchAPI: false, promptConfig: { prompt_template: 'template', - prompt_variables: [ - { key: 'file', name: 'File', type: 'file', required: true }, - ], + prompt_variables: [{ key: 'file', name: 'File', type: 'file', required: true }], }, t: createTranslator(), }) @@ -160,9 +152,7 @@ describe('result-request', () => { const t = createTranslator() const result = validateResultRequest({ - completionFiles: [ - createVisionFile({ upload_file_id: '' }), - ], + completionFiles: [createVisionFile({ upload_file_id: '' })], inputs: { name: 'Alice', }, @@ -184,9 +174,7 @@ describe('result-request', () => { const t = createTranslator() const blocked = validateResultRequest({ - completionFiles: [ - createVisionFile({ upload_file_id: '' }), - ], + completionFiles: [createVisionFile({ upload_file_id: '' })], inputs: {}, isCallBatchAPI: false, promptConfig: null, @@ -213,9 +201,7 @@ describe('result-request', () => { it('should skip validation in batch mode', () => { const result = validateResultRequest({ - completionFiles: [ - createVisionFile({ upload_file_id: '' }), - ], + completionFiles: [createVisionFile({ upload_file_id: '' })], inputs: {}, isCallBatchAPI: true, promptConfig, diff --git a/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts b/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts index 8a831458b20506..14001d7ca75936 100644 --- a/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts +++ b/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts @@ -2,7 +2,11 @@ import type { WorkflowProcess } from '@/app/components/base/chat/types' import type { IOtherOptions } from '@/service/base' import type { HumanInputFormData, HumanInputFormTimeoutData, NodeTracing } from '@/types/workflow' import { act } from '@testing-library/react' -import { BlockEnum, NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types' +import { + BlockEnum, + NodeRunningStatus, + WorkflowRunningStatus, +} from '@/app/components/workflow/types' import { withSelectorKey } from '@/test/i18n-mock' import { appendParallelNext, @@ -109,15 +113,21 @@ describe('workflow-stream-handlers helpers', () => { let workflowProcessData = appendParallelStart(undefined, parallelTrace) workflowProcessData = appendParallelNext(workflowProcessData, parallelTrace) - workflowProcessData = finishParallelTrace(workflowProcessData, createTrace({ - node_id: 'parallel-node', - execution_metadata: { parallel_id: 'parallel-1' }, - error: 'failed', - })) - workflowProcessData = upsertWorkflowNode(workflowProcessData, createTrace({ - node_id: 'node-1', - execution_metadata: { parallel_id: 'parallel-2' }, - }))! + workflowProcessData = finishParallelTrace( + workflowProcessData, + createTrace({ + node_id: 'parallel-node', + execution_metadata: { parallel_id: 'parallel-1' }, + error: 'failed', + }), + ) + workflowProcessData = upsertWorkflowNode( + workflowProcessData, + createTrace({ + node_id: 'node-1', + execution_metadata: { parallel_id: 'parallel-2' }, + }), + )! workflowProcessData = appendResultText(workflowProcessData, 'Hello ') workflowProcessData = replaceResultText(workflowProcessData, 'Hello world') workflowProcessData = updateHumanInputRequired(workflowProcessData, createHumanInput()) @@ -144,10 +154,12 @@ describe('workflow-stream-handlers helpers', () => { inputs: [], }), ]) - expect(workflowProcessData.tracing[0]).toEqual(expect.objectContaining({ - node_id: 'parallel-node', - expand: true, - })) + expect(workflowProcessData.tracing[0]).toEqual( + expect.objectContaining({ + node_id: 'parallel-node', + expand: true, + }), + ) }) it('should initialize missing parallel details on start and next events', () => { @@ -173,10 +185,13 @@ describe('workflow-stream-handlers helpers', () => { }), ] - const nextProcess = appendParallelNext(process, createTrace({ - node_id: 'missing-node', - execution_metadata: { parallel_id: 'parallel-2' }, - })) + const nextProcess = appendParallelNext( + process, + createTrace({ + node_id: 'missing-node', + execution_metadata: { parallel_id: 'parallel-2' }, + }), + ) expect(nextProcess.tracing).toEqual(process.tracing) expect(nextProcess.expand).toBe(true) @@ -191,7 +206,10 @@ describe('workflow-stream-handlers helpers', () => { }), ] - const stoppedWorkflow = applyWorkflowFinishedState(workflowProcessData, WorkflowRunningStatus.Stopped) + const stoppedWorkflow = applyWorkflowFinishedState( + workflowProcessData, + WorkflowRunningStatus.Stopped, + ) markNodesStopped(stoppedWorkflow.tracing) expect(stoppedWorkflow.status).toBe(WorkflowRunningStatus.Stopped) @@ -211,9 +229,7 @@ describe('workflow-stream-handlers helpers', () => { status: NodeRunningStatus.Succeeded, }), ] - process.humanInputFormDataList = [ - createHumanInput({ node_id: 'node-1' }), - ] + process.humanInputFormDataList = [createHumanInput({ node_id: 'node-1' })] process.humanInputFilledFormDataList = [ { action_id: 'action-0', @@ -224,49 +240,76 @@ describe('workflow-stream-handlers helpers', () => { }, ] - const parallelMatched = appendParallelNext(process, createTrace({ - node_id: 'node-1', - execution_metadata: { - parallel_id: 'parallel-1', - }, - })) - const notFinished = finishParallelTrace(process, createTrace({ - node_id: 'missing', - execution_metadata: { - parallel_id: 'parallel-missing', - }, - })) - const ignoredIteration = upsertWorkflowNode(process, createTrace({ - iteration_id: 'iteration-1', - })) - const replacedNode = upsertWorkflowNode(process, createTrace({ - node_id: 'node-1', - })) - const ignoredFinish = finishWorkflowNode(process, createTrace({ - loop_id: 'loop-1', - })) - const unmatchedFinish = finishWorkflowNode(process, createTrace({ - node_id: 'missing', - execution_metadata: { - parallel_id: 'missing', - }, - })) - const finishedWithExtras = finishWorkflowNode(process, createTrace({ - node_id: 'node-1', - execution_metadata: { - parallel_id: 'parallel-1', - }, - error: 'failed', - })) + const parallelMatched = appendParallelNext( + process, + createTrace({ + node_id: 'node-1', + execution_metadata: { + parallel_id: 'parallel-1', + }, + }), + ) + const notFinished = finishParallelTrace( + process, + createTrace({ + node_id: 'missing', + execution_metadata: { + parallel_id: 'parallel-missing', + }, + }), + ) + const ignoredIteration = upsertWorkflowNode( + process, + createTrace({ + iteration_id: 'iteration-1', + }), + ) + const replacedNode = upsertWorkflowNode( + process, + createTrace({ + node_id: 'node-1', + }), + ) + const ignoredFinish = finishWorkflowNode( + process, + createTrace({ + loop_id: 'loop-1', + }), + ) + const unmatchedFinish = finishWorkflowNode( + process, + createTrace({ + node_id: 'missing', + execution_metadata: { + parallel_id: 'missing', + }, + }), + ) + const finishedWithExtras = finishWorkflowNode( + process, + createTrace({ + node_id: 'node-1', + execution_metadata: { + parallel_id: 'parallel-1', + }, + error: 'failed', + }), + ) const succeededWorkflow = applyWorkflowFinishedState(process, WorkflowRunningStatus.Succeeded) const outputlessWorkflow = applyWorkflowOutputs(undefined, null) - const updatedHumanInput = updateHumanInputRequired(process, createHumanInput({ - node_id: 'node-1', - expiration_time: 300, - })) - const appendedHumanInput = updateHumanInputRequired(process, createHumanInput({ - node_id: 'node-2', - })) + const updatedHumanInput = updateHumanInputRequired( + process, + createHumanInput({ + node_id: 'node-1', + expiration_time: 300, + }), + ) + const appendedHumanInput = updateHumanInputRequired( + process, + createHumanInput({ + node_id: 'node-2', + }), + ) const noListFilled = updateHumanInputFilled(undefined, { action_id: 'action-1', action_text: 'Submit', @@ -295,33 +338,41 @@ describe('workflow-stream-handlers helpers', () => { markNodesStopped(undefined) expect(parallelMatched.tracing[0]!.details).toHaveLength(2) - expect(notFinished).toEqual(expect.objectContaining({ - expand: true, - tracing: process.tracing, - })) + expect(notFinished).toEqual( + expect.objectContaining({ + expand: true, + tracing: process.tracing, + }), + ) expect(ignoredIteration).toEqual(process) - expect(replacedNode?.tracing[0]).toEqual(expect.objectContaining({ - node_id: 'node-1', - status: NodeRunningStatus.Running, - })) + expect(replacedNode?.tracing[0]).toEqual( + expect.objectContaining({ + node_id: 'node-1', + status: NodeRunningStatus.Running, + }), + ) expect(ignoredFinish).toEqual(process) expect(unmatchedFinish).toEqual(process) - expect(finishedWithExtras?.tracing[0]).toEqual(expect.objectContaining({ - extras: { - source: 'extra', - }, - error: 'failed', - })) + expect(finishedWithExtras?.tracing[0]).toEqual( + expect.objectContaining({ + extras: { + source: 'extra', + }, + error: 'failed', + }), + ) expect(succeededWorkflow.status).toBe(WorkflowRunningStatus.Succeeded) expect(outputlessWorkflow.files).toEqual([]) expect(updatedHumanInput.humanInputFormDataList?.[0]!.expiration_time).toBe(300) expect(appendedHumanInput.humanInputFormDataList).toHaveLength(2) expect(noListFilled.humanInputFilledFormDataList).toHaveLength(1) expect(appendedFilled.humanInputFilledFormDataList).toHaveLength(2) - expect(timeoutWithoutList).toEqual(expect.objectContaining({ - status: WorkflowRunningStatus.Running, - tracing: [], - })) + expect(timeoutWithoutList).toEqual( + expect.objectContaining({ + status: WorkflowRunningStatus.Running, + tracing: [], + }), + ) expect(timeoutWithMatch.humanInputFormDataList?.[0]!.expiration_time).toBe(400) }) }) @@ -331,22 +382,26 @@ describe('createWorkflowStreamHandlers', () => { vi.clearAllMocks() }) - const setupHandlers = (overrides: { isPublicAPI?: boolean, isTimedOut?: () => boolean } = {}) => { + const setupHandlers = (overrides: { isPublicAPI?: boolean; isTimedOut?: () => boolean } = {}) => { let completionRes = '' let currentTaskId: string | null = null let isStopping = false let messageId: string | null = null let workflowProcessData: WorkflowProcess | undefined - const setCurrentTaskId = vi.fn((value: string | null | ((prev: string | null) => string | null)) => { - currentTaskId = typeof value === 'function' ? value(currentTaskId) : value - }) + const setCurrentTaskId = vi.fn( + (value: string | null | ((prev: string | null) => string | null)) => { + currentTaskId = typeof value === 'function' ? value(currentTaskId) : value + }, + ) const setIsStopping = vi.fn((value: boolean | ((prev: boolean) => boolean)) => { isStopping = typeof value === 'function' ? value(isStopping) : value }) - const setMessageId = vi.fn((value: string | null | ((prev: string | null) => string | null)) => { - messageId = typeof value === 'function' ? value(messageId) : value - }) + const setMessageId = vi.fn( + (value: string | null | ((prev: string | null) => string | null)) => { + messageId = typeof value === 'function' ? value(messageId) : value + }, + ) const setWorkflowProcessData = vi.fn((value: WorkflowProcess | undefined) => { workflowProcessData = value }) @@ -396,7 +451,26 @@ describe('createWorkflowStreamHandlers', () => { it('should process workflow success and paused events', () => { const setup = setupHandlers({ isPublicAPI: true }) - const handlers = setup.handlers as Required<Pick<IOtherOptions, 'onWorkflowStarted' | 'onTextChunk' | 'onHumanInputRequired' | 'onHumanInputFormFilled' | 'onHumanInputFormTimeout' | 'onWorkflowPaused' | 'onWorkflowFinished' | 'onNodeStarted' | 'onNodeFinished' | 'onIterationStart' | 'onIterationNext' | 'onIterationFinish' | 'onLoopStart' | 'onLoopNext' | 'onLoopFinish'>> + const handlers = setup.handlers as Required< + Pick< + IOtherOptions, + | 'onWorkflowStarted' + | 'onTextChunk' + | 'onHumanInputRequired' + | 'onHumanInputFormFilled' + | 'onHumanInputFormTimeout' + | 'onWorkflowPaused' + | 'onWorkflowFinished' + | 'onNodeStarted' + | 'onNodeFinished' + | 'onIterationStart' + | 'onIterationNext' + | 'onIterationFinish' + | 'onLoopStart' + | 'onLoopNext' + | 'onLoopFinish' + > + > act(() => { handlers.onWorkflowStarted({ @@ -546,10 +620,12 @@ describe('createWorkflowStreamHandlers', () => { expect(setup.currentTaskId()).toBe('task-1') expect(setup.isStopping()).toBe(false) - expect(setup.workflowProcessData()).toEqual(expect.objectContaining({ - resultText: 'Hello', - status: WorkflowRunningStatus.Succeeded, - })) + expect(setup.workflowProcessData()).toEqual( + expect.objectContaining({ + resultText: 'Hello', + status: WorkflowRunningStatus.Succeeded, + }), + ) expect(sseGetMock).toHaveBeenCalledWith( '/workflow/run-1/events', {}, @@ -565,7 +641,9 @@ describe('createWorkflowStreamHandlers', () => { const timeoutSetup = setupHandlers({ isTimedOut: () => true, }) - const timeoutHandlers = timeoutSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowFinished'>> + const timeoutHandlers = timeoutSetup.handlers as Required< + Pick<IOtherOptions, 'onWorkflowFinished'> + > act(() => { timeoutHandlers.onWorkflowFinished({ @@ -598,7 +676,9 @@ describe('createWorkflowStreamHandlers', () => { }) const failureSetup = setupHandlers() - const failureHandlers = failureSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished'>> + const failureHandlers = failureSetup.handlers as Required< + Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished'> + > act(() => { failureHandlers.onWorkflowStarted({ @@ -636,10 +716,12 @@ describe('createWorkflowStreamHandlers', () => { message: 'failed', }) expect(failureSetup.onCompleted).toHaveBeenCalledWith('', 3, false) - expect(failureSetup.workflowProcessData()).toEqual(expect.objectContaining({ - status: WorkflowRunningStatus.Failed, - error: 'failed', - })) + expect(failureSetup.workflowProcessData()).toEqual( + expect.objectContaining({ + status: WorkflowRunningStatus.Failed, + error: 'failed', + }), + ) }) it('should cover existing workflow starts, stopped runs, and non-string outputs', () => { @@ -675,7 +757,9 @@ describe('createWorkflowStreamHandlers', () => { }, t: withSelectorKey((key: string) => key), taskId: 5, - }) as Required<Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished' | 'onTextReplace'>> + }) as Required< + Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished' | 'onTextReplace'> + > act(() => { handlers.onWorkflowStarted({ @@ -692,11 +776,13 @@ describe('createWorkflowStreamHandlers', () => { }) }) - expect(existingProcess).toEqual(expect.objectContaining({ - expand: true, - status: WorkflowRunningStatus.Running, - resultText: 'Replaced text', - })) + expect(existingProcess).toEqual( + expect.objectContaining({ + expand: true, + status: WorkflowRunningStatus.Running, + resultText: 'Replaced text', + }), + ) act(() => { handlers.onWorkflowFinished({ @@ -728,7 +814,9 @@ describe('createWorkflowStreamHandlers', () => { expect(setup.onCompleted).toHaveBeenCalledWith('', 5, false) const noOutputSetup = setupHandlers() - const noOutputHandlers = noOutputSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished' | 'onTextReplace'>> + const noOutputHandlers = noOutputSetup.handlers as Required< + Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished' | 'onTextReplace'> + > act(() => { noOutputHandlers.onWorkflowStarted({ @@ -770,7 +858,9 @@ describe('createWorkflowStreamHandlers', () => { expect(noOutputSetup.setCompletionRes).toHaveBeenCalledWith('') const objectOutputSetup = setupHandlers() - const objectOutputHandlers = objectOutputSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished'>> + const objectOutputHandlers = objectOutputSetup.handlers as Required< + Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished'> + > act(() => { objectOutputHandlers.onWorkflowStarted({ @@ -809,16 +899,22 @@ describe('createWorkflowStreamHandlers', () => { }) expect(objectOutputSetup.currentTaskId()).toBeNull() - expect(objectOutputSetup.setCompletionRes).toHaveBeenCalledWith('{"answer":"Hello","meta":{"mode":"object"}}') - expect(objectOutputSetup.workflowProcessData()).toEqual(expect.objectContaining({ - status: WorkflowRunningStatus.Succeeded, - resultText: '', - })) + expect(objectOutputSetup.setCompletionRes).toHaveBeenCalledWith( + '{"answer":"Hello","meta":{"mode":"object"}}', + ) + expect(objectOutputSetup.workflowProcessData()).toEqual( + expect.objectContaining({ + status: WorkflowRunningStatus.Succeeded, + resultText: '', + }), + ) }) it('should serialize empty, string, and circular workflow outputs', () => { const noOutputSetup = setupHandlers() - const noOutputHandlers = noOutputSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowFinished'>> + const noOutputHandlers = noOutputSetup.handlers as Required< + Pick<IOtherOptions, 'onWorkflowFinished'> + > act(() => { noOutputHandlers.onWorkflowFinished({ @@ -848,7 +944,9 @@ describe('createWorkflowStreamHandlers', () => { expect(noOutputSetup.setCompletionRes).toHaveBeenCalledWith('') const stringOutputSetup = setupHandlers() - const stringOutputHandlers = stringOutputSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowFinished'>> + const stringOutputHandlers = stringOutputSetup.handlers as Required< + Pick<IOtherOptions, 'onWorkflowFinished'> + > act(() => { stringOutputHandlers.onWorkflowFinished({ @@ -878,7 +976,9 @@ describe('createWorkflowStreamHandlers', () => { expect(stringOutputSetup.setCompletionRes).toHaveBeenCalledWith('plain text output') const circularOutputSetup = setupHandlers() - const circularOutputHandlers = circularOutputSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowFinished'>> + const circularOutputHandlers = circularOutputSetup.handlers as Required< + Pick<IOtherOptions, 'onWorkflowFinished'> + > const circularOutputs: Record<string, unknown> = { answer: 'Hello', } diff --git a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-run-state.spec.ts b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-run-state.spec.ts index 66c99c0317bb88..c48fcaf09ef00b 100644 --- a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-run-state.spec.ts +++ b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-run-state.spec.ts @@ -3,23 +3,24 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { AppSourceType } from '@/service/share' import { useResultRunState } from '../use-result-run-state' -const { - stopChatMessageRespondingMock, - stopWorkflowMessageMock, - updateFeedbackMock, -} = vi.hoisted(() => ({ - stopChatMessageRespondingMock: vi.fn(), - stopWorkflowMessageMock: vi.fn(), - updateFeedbackMock: vi.fn(), -})) +const { stopChatMessageRespondingMock, stopWorkflowMessageMock, updateFeedbackMock } = vi.hoisted( + () => ({ + stopChatMessageRespondingMock: vi.fn(), + stopWorkflowMessageMock: vi.fn(), + updateFeedbackMock: vi.fn(), + }), +) vi.mock('@/service/share', async () => { const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share') return { ...actual, - stopChatMessageResponding: (...args: Parameters<typeof actual.stopChatMessageResponding>) => stopChatMessageRespondingMock(...args), - stopWorkflowMessage: (...args: Parameters<typeof actual.stopWorkflowMessage>) => stopWorkflowMessageMock(...args), - updateFeedback: (...args: Parameters<typeof actual.updateFeedback>) => updateFeedbackMock(...args), + stopChatMessageResponding: (...args: Parameters<typeof actual.stopChatMessageResponding>) => + stopChatMessageRespondingMock(...args), + stopWorkflowMessage: (...args: Parameters<typeof actual.stopWorkflowMessage>) => + stopWorkflowMessageMock(...args), + updateFeedback: (...args: Parameters<typeof actual.updateFeedback>) => + updateFeedbackMock(...args), } }) @@ -34,14 +35,16 @@ describe('useResultRunState', () => { it('should expose run control and stop completion requests', async () => { const notify = vi.fn() const onRunControlChange = vi.fn() - const { result } = renderHook(() => useResultRunState({ - appId: 'app-1', - appSourceType: AppSourceType.webApp, - controlStopResponding: 0, - isWorkflow: false, - notify, - onRunControlChange, - })) + const { result } = renderHook(() => + useResultRunState({ + appId: 'app-1', + appSourceType: AppSourceType.webApp, + controlStopResponding: 0, + isWorkflow: false, + notify, + onRunControlChange, + }), + ) const abort = vi.fn() @@ -52,32 +55,43 @@ describe('useResultRunState', () => { }) await waitFor(() => { - expect(onRunControlChange).toHaveBeenLastCalledWith(expect.objectContaining({ - isStopping: false, - })) + expect(onRunControlChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + isStopping: false, + }), + ) }) await act(async () => { await result.current.handleStop() }) - expect(stopChatMessageRespondingMock).toHaveBeenCalledWith('app-1', 'task-1', AppSourceType.webApp, 'app-1') + expect(stopChatMessageRespondingMock).toHaveBeenCalledWith( + 'app-1', + 'task-1', + AppSourceType.webApp, + 'app-1', + ) expect(abort).toHaveBeenCalledTimes(1) }) it('should update feedback and react to external stop control', async () => { const notify = vi.fn() const onRunControlChange = vi.fn() - const { result, rerender } = renderHook(({ controlStopResponding }) => useResultRunState({ - appId: 'app-2', - appSourceType: AppSourceType.installedApp, - controlStopResponding, - isWorkflow: true, - notify, - onRunControlChange, - }), { - initialProps: { controlStopResponding: 0 }, - }) + const { result, rerender } = renderHook( + ({ controlStopResponding }) => + useResultRunState({ + appId: 'app-2', + appSourceType: AppSourceType.installedApp, + controlStopResponding, + isWorkflow: true, + notify, + onRunControlChange, + }), + { + initialProps: { controlStopResponding: 0 }, + }, + ) const abort = vi.fn() act(() => { @@ -91,13 +105,17 @@ describe('useResultRunState', () => { } satisfies FeedbackType) }) - expect(updateFeedbackMock).toHaveBeenCalledWith({ - url: '/messages/message-1/feedbacks', - body: { - rating: 'like', - content: undefined, + expect(updateFeedbackMock).toHaveBeenCalledWith( + { + url: '/messages/message-1/feedbacks', + body: { + rating: 'like', + content: undefined, + }, }, - }, AppSourceType.installedApp, 'app-2') + AppSourceType.installedApp, + 'app-2', + ) expect(result.current.feedback).toEqual({ rating: 'like', }) @@ -118,13 +136,15 @@ describe('useResultRunState', () => { it('should stop workflow requests through the workflow stop API', async () => { const notify = vi.fn() - const { result } = renderHook(() => useResultRunState({ - appId: 'app-3', - appSourceType: AppSourceType.installedApp, - controlStopResponding: 0, - isWorkflow: true, - notify, - })) + const { result } = renderHook(() => + useResultRunState({ + appId: 'app-3', + appSourceType: AppSourceType.installedApp, + controlStopResponding: 0, + isWorkflow: true, + notify, + }), + ) act(() => { result.current.setCurrentTaskId('task-3') @@ -134,19 +154,26 @@ describe('useResultRunState', () => { await result.current.handleStop() }) - expect(stopWorkflowMessageMock).toHaveBeenCalledWith('app-3', 'task-3', AppSourceType.installedApp, 'app-3') + expect(stopWorkflowMessageMock).toHaveBeenCalledWith( + 'app-3', + 'task-3', + AppSourceType.installedApp, + 'app-3', + ) }) it('should ignore invalid stops and report non-Error failures', async () => { const notify = vi.fn() stopChatMessageRespondingMock.mockRejectedValueOnce('stop failed') - const { result } = renderHook(() => useResultRunState({ - appSourceType: AppSourceType.webApp, - controlStopResponding: 0, - isWorkflow: false, - notify, - })) + const { result } = renderHook(() => + useResultRunState({ + appSourceType: AppSourceType.webApp, + controlStopResponding: 0, + isWorkflow: false, + notify, + }), + ) await act(async () => { await result.current.handleStop() @@ -156,15 +183,20 @@ describe('useResultRunState', () => { act(() => { result.current.setCurrentTaskId('task-4') - result.current.setIsStopping(prev => !prev) - result.current.setIsStopping(prev => !prev) + result.current.setIsStopping((prev) => !prev) + result.current.setIsStopping((prev) => !prev) }) await act(async () => { await result.current.handleStop() }) - expect(stopChatMessageRespondingMock).toHaveBeenCalledWith(undefined, 'task-4', AppSourceType.webApp, '') + expect(stopChatMessageRespondingMock).toHaveBeenCalledWith( + undefined, + 'task-4', + AppSourceType.webApp, + '', + ) expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'stop failed', @@ -176,12 +208,14 @@ describe('useResultRunState', () => { const notify = vi.fn() stopWorkflowMessageMock.mockRejectedValueOnce(new Error('workflow stop failed')) - const { result } = renderHook(() => useResultRunState({ - appSourceType: AppSourceType.installedApp, - controlStopResponding: 0, - isWorkflow: true, - notify, - })) + const { result } = renderHook(() => + useResultRunState({ + appSourceType: AppSourceType.installedApp, + controlStopResponding: 0, + isWorkflow: true, + notify, + }), + ) act(() => { result.current.setCurrentTaskId('task-5') @@ -191,7 +225,12 @@ describe('useResultRunState', () => { await result.current.handleStop() }) - expect(stopWorkflowMessageMock).toHaveBeenCalledWith(undefined, 'task-5', AppSourceType.installedApp, '') + expect(stopWorkflowMessageMock).toHaveBeenCalledWith( + undefined, + 'task-5', + AppSourceType.installedApp, + '', + ) expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'workflow stop failed', diff --git a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts index b8d4711d73e3d9..9de624ca143da7 100644 --- a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts +++ b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts @@ -29,8 +29,10 @@ vi.mock('@/service/share', async () => { const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share') return { ...actual, - sendCompletionMessage: (...args: Parameters<typeof actual.sendCompletionMessage>) => sendCompletionMessageMock(...args), - sendWorkflowMessage: (...args: Parameters<typeof actual.sendWorkflowMessage>) => sendWorkflowMessageMock(...args), + sendCompletionMessage: (...args: Parameters<typeof actual.sendCompletionMessage>) => + sendCompletionMessageMock(...args), + sendWorkflowMessage: (...args: Parameters<typeof actual.sendWorkflowMessage>) => + sendWorkflowMessageMock(...args), } }) @@ -64,7 +66,11 @@ type RunStateHarness = { type CompletionHandlers = { getAbortController: (abortController: AbortController) => void onCompleted: () => void - onData: (chunk: string, isFirstMessage: boolean, info: { messageId: string, taskId?: string }) => void + onData: ( + chunk: string, + isFirstMessage: boolean, + info: { messageId: string; taskId?: string }, + ) => void onError: () => void onMessageReplace: (messageReplace: { answer: string }) => void } @@ -142,9 +148,7 @@ const createRunStateHarness = (): RunStateHarness => { const promptConfig: PromptConfig = { prompt_template: 'template', - prompt_variables: [ - { key: 'name', name: 'Name', type: 'string', required: true }, - ], + prompt_variables: [{ key: 'name', name: 'Name', type: 'string', required: true }], } const visionConfig: VisionSettings = { @@ -180,31 +184,35 @@ const renderSender = ({ const onRunStart = vi.fn() const onShowRes = vi.fn() - const hook = renderHook((props: { controlRetry: number, controlSend: number }) => useResultSender({ - appId: 'app-1', - appSourceType, - completionFiles: [], - controlRetry: props.controlRetry, - controlSend: props.controlSend, - inputs, - isCallBatchAPI: false, - isPC, - isWorkflow, - notify, - onCompleted, - onRunStart, - onShowRes, - promptConfig, - runState: runState || createRunStateHarness().runState, - t: withSelectorKey((key: string) => key), - taskId, - visionConfig, - }), { - initialProps: { - controlRetry, - controlSend, + const hook = renderHook( + (props: { controlRetry: number; controlSend: number }) => + useResultSender({ + appId: 'app-1', + appSourceType, + completionFiles: [], + controlRetry: props.controlRetry, + controlSend: props.controlSend, + inputs, + isCallBatchAPI: false, + isPC, + isWorkflow, + notify, + onCompleted, + onRunStart, + onShowRes, + promptConfig, + runState: runState || createRunStateHarness().runState, + t: withSelectorKey((key: string) => key), + taskId, + visionConfig, + }), + { + initialProps: { + controlRetry, + controlSend, + }, }, - }) + ) return { ...hook, @@ -287,10 +295,12 @@ describe('useResultSender', () => { controlSend: 1, }) - expect(validateResultRequestMock).toHaveBeenCalledWith(expect.objectContaining({ - inputs: { name: 'Alice' }, - isCallBatchAPI: false, - })) + expect(validateResultRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + inputs: { name: 'Alice' }, + isCallBatchAPI: false, + }), + ) expect(buildResultRequestDataMock).toHaveBeenCalled() expect(harness.runState.prepareForNewRun).toHaveBeenCalledTimes(1) expect(harness.runState.setRespondingTrue).toHaveBeenCalledTimes(1) @@ -350,12 +360,14 @@ describe('useResultSender', () => { }) await waitFor(() => { - expect(createWorkflowStreamHandlersMock).toHaveBeenCalledWith(expect.objectContaining({ - getCompletionRes: harness.runState.getCompletionRes, - isPublicAPI: true, - resetRunState: harness.runState.resetRunState, - setWorkflowProcessData: harness.runState.setWorkflowProcessData, - })) + expect(createWorkflowStreamHandlersMock).toHaveBeenCalledWith( + expect.objectContaining({ + getCompletionRes: harness.runState.getCompletionRes, + isPublicAPI: true, + resetRunState: harness.runState.resetRunState, + setWorkflowProcessData: harness.runState.setWorkflowProcessData, + }), + ) expect(sendWorkflowMessageMock).toHaveBeenCalledWith( { inputs: { name: 'Alice' } }, expect.any(Object), @@ -388,9 +400,11 @@ describe('useResultSender', () => { expect(await result.current.handleSend()).toBe(true) }) - expect(createWorkflowStreamHandlersMock).toHaveBeenCalledWith(expect.objectContaining({ - isPublicAPI: false, - })) + expect(createWorkflowStreamHandlersMock).toHaveBeenCalledWith( + expect.objectContaining({ + isPublicAPI: false, + }), + ) expect(sendWorkflowMessageMock).toHaveBeenCalledWith( { inputs: { name: 'Alice' } }, expect.any(Object), @@ -484,9 +498,12 @@ describe('useResultSender', () => { let resolveSleep!: () => void let completionHandlers: CompletionHandlers | undefined - sleepMock.mockImplementation(() => new Promise<void>((resolve) => { - resolveSleep = resolve - })) + sleepMock.mockImplementation( + () => + new Promise<void>((resolve) => { + resolveSleep = resolve + }), + ) sendCompletionMessageMock.mockImplementation(async (_data, handlers) => { completionHandlers = handlers as CompletionHandlers }) diff --git a/web/app/components/share/text-generation/result/hooks/use-result-run-state.ts b/web/app/components/share/text-generation/result/hooks/use-result-run-state.ts index d2f276e8487811..8c3fd273b51f58 100644 --- a/web/app/components/share/text-generation/result/hooks/use-result-run-state.ts +++ b/web/app/components/share/text-generation/result/hooks/use-result-run-state.ts @@ -4,23 +4,19 @@ import type { WorkflowProcess } from '@/app/components/base/chat/types' import type { AppSourceType } from '@/service/share' import { useBoolean } from 'ahooks' import { useCallback, useEffect, useReducer, useRef, useState } from 'react' -import { - stopChatMessageResponding, - stopWorkflowMessage, - updateFeedback, -} from '@/service/share' +import { stopChatMessageResponding, stopWorkflowMessage, updateFeedback } from '@/service/share' -type Notify = (payload: { type: 'error', message: string }) => void +type Notify = (payload: { type: 'error'; message: string }) => void type RunControlState = { currentTaskId: string | null isStopping: boolean } -type RunControlAction - = | { type: 'reset' } - | { type: 'setCurrentTaskId', value: SetStateAction<string | null> } - | { type: 'setIsStopping', value: SetStateAction<boolean> } +type RunControlAction = + | { type: 'reset' } + | { type: 'setCurrentTaskId'; value: SetStateAction<string | null> } + | { type: 'setIsStopping'; value: SetStateAction<boolean> } type UseResultRunStateOptions = { appId?: string @@ -28,7 +24,9 @@ type UseResultRunStateOptions = { controlStopResponding?: number isWorkflow: boolean notify: Notify - onRunControlChange?: (control: { onStop: () => Promise<void> | void, isStopping: boolean } | null) => void + onRunControlChange?: ( + control: { onStop: () => Promise<void> | void; isStopping: boolean } | null, + ) => void } export type ResultRunStateController = { @@ -67,12 +65,14 @@ const runControlReducer = (state: RunControlState, action: RunControlAction): Ru case 'setCurrentTaskId': return { ...state, - currentTaskId: typeof action.value === 'function' ? action.value(state.currentTaskId) : action.value, + currentTaskId: + typeof action.value === 'function' ? action.value(state.currentTaskId) : action.value, } case 'setIsStopping': return { ...state, - isStopping: typeof action.value === 'function' ? action.value(state.isStopping) : action.value, + isStopping: + typeof action.value === 'function' ? action.value(state.isStopping) : action.value, } } } @@ -85,7 +85,8 @@ export const useResultRunState = ({ notify, onRunControlChange, }: UseResultRunStateOptions): ResultRunStateController => { - const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false) + const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = + useBoolean(false) const [completionResState, setCompletionResState] = useState<string>('') const completionResRef = useRef<string>('') const [workflowProcessDataState, setWorkflowProcessDataState] = useState<WorkflowProcess>() @@ -143,35 +144,37 @@ export const useResultRunState = ({ resetRunState() }, [resetRunState, setCompletionRes, setWorkflowProcessData]) - const handleFeedback = useCallback(async (nextFeedback: FeedbackType) => { - await updateFeedback({ - url: `/messages/${messageId}/feedbacks`, - body: { - rating: nextFeedback.rating, - content: nextFeedback.content, - }, - }, appSourceType, appId) - setFeedback(nextFeedback) - }, [appId, appSourceType, messageId]) + const handleFeedback = useCallback( + async (nextFeedback: FeedbackType) => { + await updateFeedback( + { + url: `/messages/${messageId}/feedbacks`, + body: { + rating: nextFeedback.rating, + content: nextFeedback.content, + }, + }, + appSourceType, + appId, + ) + setFeedback(nextFeedback) + }, + [appId, appSourceType, messageId], + ) const handleStop = useCallback(async () => { - if (!currentTaskId || isStopping) - return + if (!currentTaskId || isStopping) return setIsStopping(true) try { - if (isWorkflow) - await stopWorkflowMessage(appId!, currentTaskId, appSourceType, appId || '') - else - await stopChatMessageResponding(appId!, currentTaskId, appSourceType, appId || '') + if (isWorkflow) await stopWorkflowMessage(appId!, currentTaskId, appSourceType, appId || '') + else await stopChatMessageResponding(appId!, currentTaskId, appSourceType, appId || '') abortControllerRef.current?.abort() - } - catch (error) { + } catch (error) { const message = error instanceof Error ? error.message : String(error) notify({ type: 'error', message }) - } - finally { + } finally { setIsStopping(false) } }, [appId, appSourceType, currentTaskId, isStopping, isWorkflow, notify, setIsStopping]) @@ -195,8 +198,7 @@ export const useResultRunState = ({ }, [controlStopResponding, resetRunState, setRespondingFalse]) useEffect(() => { - if (!onRunControlChange) - return + if (!onRunControlChange) return if (isResponding && currentTaskId) { onRunControlChange({ diff --git a/web/app/components/share/text-generation/result/hooks/use-result-sender.ts b/web/app/components/share/text-generation/result/hooks/use-result-sender.ts index 18f8f3a5de7fc6..ba3effe5da6842 100644 --- a/web/app/components/share/text-generation/result/hooks/use-result-sender.ts +++ b/web/app/components/share/text-generation/result/hooks/use-result-sender.ts @@ -5,16 +5,12 @@ import type { PromptConfig } from '@/models/debug' import type { VisionFile, VisionSettings } from '@/types/app' import { useCallback, useEffect, useRef } from 'react' import { TEXT_GENERATION_TIMEOUT_MS } from '@/config' -import { - AppSourceType, - sendCompletionMessage, - sendWorkflowMessage, -} from '@/service/share' +import { AppSourceType, sendCompletionMessage, sendWorkflowMessage } from '@/service/share' import { sleep } from '@/utils' import { buildResultRequestData, validateResultRequest } from '../result-request' import { createWorkflowStreamHandlers } from '../workflow-stream-handlers' -type Notify = (payload: { type: 'error' | 'info' | 'warning', message: string }) => void +type Notify = (payload: { type: 'error' | 'info' | 'warning'; message: string }) => void type UseResultSenderOptions = { appId?: string appSourceType: AppSourceType @@ -65,7 +61,10 @@ export const useResultSender = ({ const handleSend = useCallback(async () => { if (runState.isResponding) { - notify({ type: 'info', message: t($ => $['errorMessage.waitForResponse'], { ns: 'appDebug' }) }) + notify({ + type: 'info', + message: t(($) => $['errorMessage.waitForResponse'], { ns: 'appDebug' }), + }) return false } @@ -142,46 +141,57 @@ export const useResultSender = ({ return true } - void sendCompletionMessage(data, { - onData: (chunk, _isFirstMessage, { messageId, taskId: nextTaskId }) => { - tempMessageId = messageId - if (nextTaskId && nextTaskId.trim() !== '') - runState.setCurrentTaskId(prev => prev ?? nextTaskId) - - completionChunks.push(chunk) - runState.setCompletionRes(completionChunks.join('')) - }, - onCompleted: () => { - if (isTimeout) { - notify({ type: 'warning', message: t($ => $['warningMessage.timeoutExceeded'], { ns: 'appDebug' }) }) - return - } - - runState.setRespondingFalse() - runState.resetRunState() - runState.setMessageId(tempMessageId) - onCompleted(runState.getCompletionRes(), taskId, true) - isEnd = true - }, - onMessageReplace: (messageReplace) => { - completionChunks = [messageReplace.answer] - runState.setCompletionRes(completionChunks.join('')) - }, - onError: () => { - if (isTimeout) { - notify({ type: 'warning', message: t($ => $['warningMessage.timeoutExceeded'], { ns: 'appDebug' }) }) - return - } + void sendCompletionMessage( + data, + { + onData: (chunk, _isFirstMessage, { messageId, taskId: nextTaskId }) => { + tempMessageId = messageId + if (nextTaskId && nextTaskId.trim() !== '') + runState.setCurrentTaskId((prev) => prev ?? nextTaskId) - runState.setRespondingFalse() - runState.resetRunState() - onCompleted(runState.getCompletionRes(), taskId, false) - isEnd = true - }, - getAbortController: (abortController) => { - runState.abortControllerRef.current = abortController + completionChunks.push(chunk) + runState.setCompletionRes(completionChunks.join('')) + }, + onCompleted: () => { + if (isTimeout) { + notify({ + type: 'warning', + message: t(($) => $['warningMessage.timeoutExceeded'], { ns: 'appDebug' }), + }) + return + } + + runState.setRespondingFalse() + runState.resetRunState() + runState.setMessageId(tempMessageId) + onCompleted(runState.getCompletionRes(), taskId, true) + isEnd = true + }, + onMessageReplace: (messageReplace) => { + completionChunks = [messageReplace.answer] + runState.setCompletionRes(completionChunks.join('')) + }, + onError: () => { + if (isTimeout) { + notify({ + type: 'warning', + message: t(($) => $['warningMessage.timeoutExceeded'], { ns: 'appDebug' }), + }) + return + } + + runState.setRespondingFalse() + runState.resetRunState() + onCompleted(runState.getCompletionRes(), taskId, false) + isEnd = true + }, + getAbortController: (abortController) => { + runState.abortControllerRef.current = abortController + }, }, - }, appSourceType, appId) + appSourceType, + appId, + ) return true }, [ @@ -210,16 +220,14 @@ export const useResultSender = ({ }, [handleSend]) useEffect(() => { - if (!controlSend) - return + if (!controlSend) return void handleSendRef.current() clearMoreLikeThis() }, [clearMoreLikeThis, controlSend]) useEffect(() => { - if (!controlRetry) - return + if (!controlRetry) return void handleSendRef.current() }, [controlRetry]) diff --git a/web/app/components/share/text-generation/result/index.tsx b/web/app/components/share/text-generation/result/index.tsx index 5c25f87382dbbd..89ee19c6080379 100644 --- a/web/app/components/share/text-generation/result/index.tsx +++ b/web/app/components/share/text-generation/result/index.tsx @@ -43,16 +43,46 @@ type IResultProps = { completionFiles: VisionFile[] siteInfo: SiteInfo | null onRunStart: () => void - onRunControlChange?: (control: { - onStop: () => Promise<void> | void - isStopping: boolean - } | null) => void + onRunControlChange?: ( + control: { + onStop: () => Promise<void> | void + isStopping: boolean + } | null, + ) => void hideInlineStopButton?: boolean } -const Result: FC<IResultProps> = ({ isWorkflow, isCallBatchAPI, isPC, isMobile, appSourceType, appId, isError, isShowTextToSpeech, promptConfig, moreLikeThisEnabled, inputs, controlSend, controlRetry, controlStopResponding, onShowRes, handleSaveMessage, taskId, onCompleted, visionConfig, completionFiles, siteInfo, onRunStart, onRunControlChange, hideInlineStopButton = false }) => { - const notify = useCallback(({ type, message }: { type: 'error' | 'info' | 'success' | 'warning', message: string }) => { - toast(message, { type }) - }, []) +const Result: FC<IResultProps> = ({ + isWorkflow, + isCallBatchAPI, + isPC, + isMobile, + appSourceType, + appId, + isError, + isShowTextToSpeech, + promptConfig, + moreLikeThisEnabled, + inputs, + controlSend, + controlRetry, + controlStopResponding, + onShowRes, + handleSaveMessage, + taskId, + onCompleted, + visionConfig, + completionFiles, + siteInfo, + onRunStart, + onRunControlChange, + hideInlineStopButton = false, +}) => { + const notify = useCallback( + ({ type, message }: { type: 'error' | 'info' | 'success' | 'warning'; message: string }) => { + toast(message, { type }) + }, + [], + ) const runState = useResultRunState({ appId, appSourceType, @@ -87,10 +117,14 @@ const Result: FC<IResultProps> = ({ isWorkflow, isCallBatchAPI, isPC, isMobile, {!hideInlineStopButton && runState.isResponding && runState.currentTaskId && ( <div className={`mb-3 flex ${isPC ? 'justify-end' : 'justify-center'}`}> <Button variant="secondary" disabled={runState.isStopping} onClick={runState.handleStop}> - {runState.isStopping - ? <span aria-hidden className="mr-[5px] i-ri-loader-2-line h-3.5 w-3.5 animate-spin" /> - : <span aria-hidden className="mr-[5px] i-ri-stop-circle-fill h-3.5 w-3.5" />} - <span className="text-xs font-normal">{t($ => $['operation.stopResponding'], { ns: 'appDebug' })}</span> + {runState.isStopping ? ( + <span aria-hidden className="mr-[5px] i-ri-loader-2-line h-3.5 w-3.5 animate-spin" /> + ) : ( + <span aria-hidden className="mr-[5px] i-ri-stop-circle-fill h-3.5 w-3.5" /> + )} + <span className="text-xs font-normal"> + {t(($) => $['operation.stopResponding'], { ns: 'appDebug' })} + </span> </Button> </div> )} @@ -121,28 +155,26 @@ const Result: FC<IResultProps> = ({ isWorkflow, isCallBatchAPI, isPC, isMobile, ) return ( <> - {!isCallBatchAPI && !isWorkflow && ((runState.isResponding && !runState.completionRes) - ? ( - <div className="flex size-full items-center justify-center"> - <Loading type="area" /> - </div> - ) - : ( - <> - {(isNoData) - ? <NoData /> - : renderTextGenerationRes()} - </> - ))} - {!isCallBatchAPI && isWorkflow && ((runState.isResponding && !runState.workflowProcessData) - ? ( - <div className="flex size-full items-center justify-center"> - <Loading type="area" /> - </div> - ) - : !runState.workflowProcessData - ? <NoData /> - : renderTextGenerationRes())} + {!isCallBatchAPI && + !isWorkflow && + (runState.isResponding && !runState.completionRes ? ( + <div className="flex size-full items-center justify-center"> + <Loading type="area" /> + </div> + ) : ( + <>{isNoData ? <NoData /> : renderTextGenerationRes()}</> + ))} + {!isCallBatchAPI && + isWorkflow && + (runState.isResponding && !runState.workflowProcessData ? ( + <div className="flex size-full items-center justify-center"> + <Loading type="area" /> + </div> + ) : !runState.workflowProcessData ? ( + <NoData /> + ) : ( + renderTextGenerationRes() + ))} {isCallBatchAPI && renderTextGenerationRes()} </> ) diff --git a/web/app/components/share/text-generation/result/result-request.ts b/web/app/components/share/text-generation/result/result-request.ts index 64925a1267d6a6..a41301c6bb9dca 100644 --- a/web/app/components/share/text-generation/result/result-request.ts +++ b/web/app/components/share/text-generation/result/result-request.ts @@ -6,15 +6,15 @@ import { getProcessedFiles } from '@/app/components/base/file-uploader/utils' import { TransferMethod } from '@/types/app' import { formatBooleanInputs } from '@/utils/model-config' -export type ResultInputValue - = | string - | boolean - | number - | string[] - | Record<string, unknown> - | FileEntity - | FileEntity[] - | undefined +export type ResultInputValue = + | string + | boolean + | number + | string[] + | Record<string, unknown> + | FileEntity + | FileEntity[] + | undefined type ValidationResult = { canSend: boolean @@ -43,11 +43,9 @@ const isMissingRequiredInput = ( variable: PromptConfig['prompt_variables'][number], value: ResultInputValue, ) => { - if (value === undefined || value === null) - return true + if (value === undefined || value === null) return true - if (variable.type === 'file-list') - return !Array.isArray(value) || value.length === 0 + if (variable.type === 'file-list') return !Array.isArray(value) || value.length === 0 if (['string', 'paragraph', 'number', 'json_object', 'select'].includes(variable.type)) return typeof value !== 'string' ? false : value.trim() === '' @@ -56,7 +54,9 @@ const isMissingRequiredInput = ( } const hasPendingLocalFiles = (completionFiles: VisionFile[]) => { - return completionFiles.some(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id) + return completionFiles.some( + (item) => item.transfer_method === TransferMethod.local_file && !item.upload_file_id, + ) } export const validateResultRequest = ({ @@ -66,8 +66,7 @@ export const validateResultRequest = ({ promptConfig, t, }: ValidateResultRequestParams): ValidationResult => { - if (isCallBatchAPI) - return { canSend: true } + if (isCallBatchAPI) return { canSend: true } const promptVariables = promptConfig?.prompt_variables if (!promptVariables?.length) { @@ -76,7 +75,7 @@ export const validateResultRequest = ({ canSend: false, notification: { type: 'info', - message: t($ => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' }), + message: t(($) => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' }), }, } } @@ -85,19 +84,28 @@ export const validateResultRequest = ({ } const requiredVariables = promptVariables.filter(({ key, name, required, type }) => { - if (type === 'boolean' || type === 'checkbox') - return false - - return (!key || !key.trim()) || (!name || !name.trim()) || required === undefined || required === null || required + if (type === 'boolean' || type === 'checkbox') return false + + return ( + !key || + !key.trim() || + !name || + !name.trim() || + required === undefined || + required === null || + required + ) }) - const missingRequiredVariable = requiredVariables.find(variable => isMissingRequiredInput(variable, inputs[variable.key]))?.name + const missingRequiredVariable = requiredVariables.find((variable) => + isMissingRequiredInput(variable, inputs[variable.key]), + )?.name if (missingRequiredVariable) { return { canSend: false, notification: { type: 'error', - message: t($ => $['errorMessage.valueOfVarRequired'], { + message: t(($) => $['errorMessage.valueOfVarRequired'], { ns: 'appDebug', key: missingRequiredVariable, }), @@ -110,7 +118,7 @@ export const validateResultRequest = ({ canSend: false, notification: { type: 'info', - message: t($ => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' }), + message: t(($) => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' }), }, } } @@ -125,7 +133,10 @@ export const buildResultRequestData = ({ visionConfig, }: BuildResultRequestDataParams) => { const processedInputs = { - ...formatBooleanInputs(promptConfig?.prompt_variables, inputs as Record<string, string | number | boolean | object>), + ...formatBooleanInputs( + promptConfig?.prompt_variables, + inputs as Record<string, string | number | boolean | object>, + ), } promptConfig?.prompt_variables.forEach((variable) => { @@ -144,8 +155,7 @@ export const buildResultRequestData = ({ ...(visionConfig.enabled && completionFiles.length > 0 ? { files: completionFiles.map((item) => { - if (item.transfer_method === TransferMethod.local_file) - return { ...item, url: '' } + if (item.transfer_method === TransferMethod.local_file) return { ...item, url: '' } return item }), diff --git a/web/app/components/share/text-generation/result/workflow-stream-handlers.ts b/web/app/components/share/text-generation/result/workflow-stream-handlers.ts index 832b57ffc60a16..335acdf69c9b3c 100644 --- a/web/app/components/share/text-generation/result/workflow-stream-handlers.ts +++ b/web/app/components/share/text-generation/result/workflow-stream-handlers.ts @@ -2,14 +2,18 @@ import type { Dispatch, SetStateAction } from 'react' import type { TextGenerationTranslate } from '../types' import type { WorkflowProcess } from '@/app/components/base/chat/types' import type { IOtherOptions } from '@/service/base' -import type { HumanInputFormTimeoutData, NodeTracing, WorkflowFinishedResponse } from '@/types/workflow' +import type { + HumanInputFormTimeoutData, + NodeTracing, + WorkflowFinishedResponse, +} from '@/types/workflow' import { produce } from 'immer' import { enrichSubmittedHumanInputFormData } from '@/app/components/base/chat/chat/answer/human-input-content/submitted-utils' import { getFilesInLogs } from '@/app/components/base/file-uploader/utils' import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types' import { sseGet } from '@/service/base' -type Notify = (payload: { type: 'error' | 'warning', message: string }) => void +type Notify = (payload: { type: 'error' | 'warning'; message: string }) => void type CreateWorkflowStreamHandlersParams = { getCompletionRes: () => string getWorkflowProcessData: () => WorkflowProcess | undefined @@ -45,9 +49,11 @@ const updateWorkflowProcess = ( } const matchParallelTrace = (trace: WorkflowProcess['tracing'][number], data: NodeTracing) => { - return trace.node_id === data.node_id - && (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id - || trace.parallel_id === data.execution_metadata?.parallel_id) + return ( + trace.node_id === data.node_id && + (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || + trace.parallel_id === data.execution_metadata?.parallel_id) + ) } const ensureParallelTraceDetails = (details?: NodeTracing['details']) => { @@ -69,9 +75,8 @@ const appendParallelStart = (current: WorkflowProcess | undefined, data: NodeTra const appendParallelNext = (current: WorkflowProcess | undefined, data: NodeTracing) => { return updateWorkflowProcess(current, (draft) => { draft.expand = true - const trace = draft.tracing.find(item => matchParallelTrace(item, data)) - if (!trace) - return + const trace = draft.tracing.find((item) => matchParallelTrace(item, data)) + if (!trace) return trace.details = ensureParallelTraceDetails(trace.details) trace.details.push([]) @@ -81,7 +86,7 @@ const appendParallelNext = (current: WorkflowProcess | undefined, data: NodeTrac const finishParallelTrace = (current: WorkflowProcess | undefined, data: NodeTracing) => { return updateWorkflowProcess(current, (draft) => { draft.expand = true - const traceIndex = draft.tracing.findIndex(item => matchParallelTrace(item, data)) + const traceIndex = draft.tracing.findIndex((item) => matchParallelTrace(item, data)) if (traceIndex > -1) { draft.tracing[traceIndex] = { ...data, @@ -92,31 +97,27 @@ const finishParallelTrace = (current: WorkflowProcess | undefined, data: NodeTra } const upsertWorkflowNode = (current: WorkflowProcess | undefined, data: NodeTracing) => { - if (data.iteration_id || data.loop_id) - return current + if (data.iteration_id || data.loop_id) return current return updateWorkflowProcess(current, (draft) => { draft.expand = true - const currentIndex = draft.tracing.findIndex(item => item.node_id === data.node_id) + const currentIndex = draft.tracing.findIndex((item) => item.node_id === data.node_id) const nextTrace = { ...data, status: NodeRunningStatus.Running, expand: true, } - if (currentIndex > -1) - draft.tracing[currentIndex] = nextTrace - else - draft.tracing.push(nextTrace) + if (currentIndex > -1) draft.tracing[currentIndex] = nextTrace + else draft.tracing.push(nextTrace) }) } const finishWorkflowNode = (current: WorkflowProcess | undefined, data: NodeTracing) => { - if (data.iteration_id || data.loop_id) - return current + if (data.iteration_id || data.loop_id) return current return updateWorkflowProcess(current, (draft) => { - const currentIndex = draft.tracing.findIndex(trace => matchParallelTrace(trace, data)) + const currentIndex = draft.tracing.findIndex((trace) => matchParallelTrace(trace, data)) if (currentIndex > -1) { draft.tracing[currentIndex] = { ...(draft.tracing[currentIndex]!.extras @@ -130,14 +131,17 @@ const finishWorkflowNode = (current: WorkflowProcess | undefined, data: NodeTrac } const markNodesStopped = (traces?: WorkflowProcess['tracing']) => { - if (!traces) - return + if (!traces) return const markTrace = (trace: WorkflowProcess['tracing'][number]) => { - if ([NodeRunningStatus.Running, NodeRunningStatus.Waiting].includes(trace.status as NodeRunningStatus)) + if ( + [NodeRunningStatus.Running, NodeRunningStatus.Waiting].includes( + trace.status as NodeRunningStatus, + ) + ) trace.status = NodeRunningStatus.Stopped - trace.details?.forEach(detailGroup => detailGroup.forEach(markTrace)) + trace.details?.forEach((detailGroup) => detailGroup.forEach(markTrace)) trace.retryDetail?.forEach(markTrace) trace.parallelDetail?.children?.forEach(markTrace) } @@ -188,18 +192,16 @@ const updateHumanInputRequired = ( return updateWorkflowProcess(current, (draft) => { if (!draft.humanInputFormDataList) { draft.humanInputFormDataList = [data] - } - else { - const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id) - if (currentFormIndex > -1) - draft.humanInputFormDataList[currentFormIndex] = data - else - draft.humanInputFormDataList.push(data) + } else { + const currentFormIndex = draft.humanInputFormDataList.findIndex( + (item) => item.node_id === data.node_id, + ) + if (currentFormIndex > -1) draft.humanInputFormDataList[currentFormIndex] = data + else draft.humanInputFormDataList.push(data) } - const currentIndex = draft.tracing.findIndex(item => item.node_id === data.node_id) - if (currentIndex > -1) - draft.tracing[currentIndex]!.status = NodeRunningStatus.Paused + const currentIndex = draft.tracing.findIndex((item) => item.node_id === data.node_id) + if (currentIndex > -1) draft.tracing[currentIndex]!.status = NodeRunningStatus.Paused }) } @@ -210,7 +212,9 @@ const updateHumanInputFilled = ( return updateWorkflowProcess(current, (draft) => { let requiredFormData: NonNullable<WorkflowProcess['humanInputFormDataList']>[number] | undefined if (draft.humanInputFormDataList?.length) { - const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id) + const currentFormIndex = draft.humanInputFormDataList.findIndex( + (item) => item.node_id === data.node_id, + ) if (currentFormIndex > -1) { requiredFormData = draft.humanInputFormDataList[currentFormIndex] draft.humanInputFormDataList.splice(currentFormIndex, 1) @@ -218,10 +222,8 @@ const updateHumanInputFilled = ( } const enrichedData = enrichSubmittedHumanInputFormData(data, requiredFormData) - if (!draft.humanInputFilledFormDataList) - draft.humanInputFilledFormDataList = [enrichedData] - else - draft.humanInputFilledFormDataList.push(enrichedData) + if (!draft.humanInputFilledFormDataList) draft.humanInputFilledFormDataList = [enrichedData] + else draft.humanInputFilledFormDataList.push(enrichedData) }) } @@ -230,10 +232,11 @@ const updateHumanInputTimeout = ( data: HumanInputFormTimeoutData, ) => { return updateWorkflowProcess(current, (draft) => { - if (!draft.humanInputFormDataList?.length) - return + if (!draft.humanInputFormDataList?.length) return - const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id) + const currentFormIndex = draft.humanInputFormDataList.findIndex( + (item) => item.node_id === data.node_id, + ) if (currentFormIndex > -1) draft.humanInputFormDataList[currentFormIndex]!.expiration_time = data.expiration_time }) @@ -247,16 +250,13 @@ const applyWorkflowPaused = (current: WorkflowProcess | undefined) => { } const serializeWorkflowOutputs = (outputs: WorkflowFinishedResponse['data']['outputs']) => { - if (outputs === undefined || outputs === null) - return '' + if (outputs === undefined || outputs === null) return '' - if (typeof outputs === 'string') - return outputs + if (typeof outputs === 'string') return outputs try { return JSON.stringify(outputs) ?? '' - } - catch { + } catch { return String(outputs) } } @@ -301,11 +301,13 @@ export const createWorkflowStreamHandlers = ({ onWorkflowStarted: ({ workflow_run_id, task_id }) => { const workflowProcessData = getWorkflowProcessData() if (workflowProcessData?.tracing.length) { - setWorkflowProcessData(updateWorkflowProcess(workflowProcessData, (draft) => { - draft.expand = true - draft.status = WorkflowRunningStatus.Running - draft.error = undefined - })) + setWorkflowProcessData( + updateWorkflowProcess(workflowProcessData, (draft) => { + draft.expand = true + draft.status = WorkflowRunningStatus.Running + draft.error = undefined + }), + ) return } @@ -340,14 +342,21 @@ export const createWorkflowStreamHandlers = ({ }, onWorkflowFinished: ({ data }) => { if (isTimedOut()) { - notify({ type: 'warning', message: t($ => $['warningMessage.timeoutExceeded'], { ns: 'appDebug' }) }) + notify({ + type: 'warning', + message: t(($) => $['warningMessage.timeoutExceeded'], { ns: 'appDebug' }), + }) return } const workflowStatus = data.status as WorkflowRunningStatus | undefined if (workflowStatus === WorkflowRunningStatus.Stopped) { setWorkflowProcessData( - applyWorkflowFinishedState(getWorkflowProcessData(), WorkflowRunningStatus.Stopped, data.error), + applyWorkflowFinishedState( + getWorkflowProcessData(), + WorkflowRunningStatus.Stopped, + data.error, + ), ) finishWithFailure() return @@ -356,7 +365,11 @@ export const createWorkflowStreamHandlers = ({ if (data.error) { notify({ type: 'error', message: data.error }) setWorkflowProcessData( - applyWorkflowFinishedState(getWorkflowProcessData(), WorkflowRunningStatus.Failed, data.error), + applyWorkflowFinishedState( + getWorkflowProcessData(), + WorkflowRunningStatus.Failed, + data.error, + ), ) finishWithFailure() return @@ -367,11 +380,14 @@ export const createWorkflowStreamHandlers = ({ setCompletionRes(serializedOutputs) if (data.outputs) { const outputKeys = Object.keys(data.outputs) - const isStringOutput = outputKeys.length === 1 && typeof data.outputs[outputKeys[0]!] === 'string' + const isStringOutput = + outputKeys.length === 1 && typeof data.outputs[outputKeys[0]!] === 'string' if (isStringOutput) { - setWorkflowProcessData(updateWorkflowProcess(getWorkflowProcessData(), (draft) => { - draft.resultText = data.outputs[outputKeys[0]!] - })) + setWorkflowProcessData( + updateWorkflowProcess(getWorkflowProcessData(), (draft) => { + draft.resultText = data.outputs[outputKeys[0]!] + }), + ) } } diff --git a/web/app/components/share/text-generation/run-batch/__tests__/index.spec.tsx b/web/app/components/share/text-generation/run-batch/__tests__/index.spec.tsx index 63aa04e29ab108..dfd81bb96f7825 100644 --- a/web/app/components/share/text-generation/run-batch/__tests__/index.spec.tsx +++ b/web/app/components/share/text-generation/run-batch/__tests__/index.spec.tsx @@ -43,13 +43,7 @@ describe('RunBatch', () => { it('should enable run button after CSV parsed and send data', async () => { const onSend = vi.fn() - render( - <RunBatch - vars={vars} - onSend={onSend} - isAllFinished - />, - ) + render(<RunBatch vars={vars} onSend={onSend} isAllFinished />) expect(receivedCSVDownloadProps?.vars).toEqual(vars) await act(async () => { @@ -68,13 +62,7 @@ describe('RunBatch', () => { it('should keep button disabled and show spinner when results still running on mobile', async () => { mockUseBreakpoints.mockReturnValue(MediaType.mobile) const onSend = vi.fn() - const { container } = render( - <RunBatch - vars={vars} - onSend={onSend} - isAllFinished={false} - />, - ) + const { container } = render(<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />) await act(async () => { latestOnParsed?.([['row']]) diff --git a/web/app/components/share/text-generation/run-batch/csv-download/__tests__/index.spec.tsx b/web/app/components/share/text-generation/run-batch/csv-download/__tests__/index.spec.tsx index c622278bc22c92..34e600be5c0a35 100644 --- a/web/app/components/share/text-generation/run-batch/csv-download/__tests__/index.spec.tsx +++ b/web/app/components/share/text-generation/run-batch/csv-download/__tests__/index.spec.tsx @@ -7,9 +7,16 @@ let capturedProps: Record<string, unknown> | undefined vi.mock('react-papaparse', () => ({ useCSVDownloader: () => { - const CSVDownloader = ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => { + const CSVDownloader = ({ + children, + ...props + }: React.PropsWithChildren<Record<string, unknown>>) => { capturedProps = props - return <div data-testid="csv-downloader" className={props.className as string}>{children}</div> + return ( + <div data-testid="csv-downloader" className={props.className as string}> + {children} + </div> + ) } return { CSVDownloader, @@ -41,9 +48,7 @@ describe('CSVDownload', () => { expect(capturedProps?.filename).toBe('template') expect(capturedProps?.type).toBe(mockType.Link) expect(capturedProps?.bom).toBe(true) - expect(capturedProps?.data).toEqual([ - { prompt: '', context: '' }, - ]) + expect(capturedProps?.data).toEqual([{ prompt: '', context: '' }]) expect(screen.getByText('share.generation.downloadTemplate'))!.toBeInTheDocument() }) }) diff --git a/web/app/components/share/text-generation/run-batch/csv-download/index.tsx b/web/app/components/share/text-generation/run-batch/csv-download/index.tsx index 8bd203ec95d5be..6eed9d6dfc726c 100644 --- a/web/app/components/share/text-generation/run-batch/csv-download/index.tsx +++ b/web/app/components/share/text-generation/run-batch/csv-download/index.tsx @@ -2,18 +2,14 @@ import type { FC } from 'react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { - useCSVDownloader, -} from 'react-papaparse' +import { useCSVDownloader } from 'react-papaparse' import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general' type ICSVDownloadProps = { vars: { name: string }[] } -const CSVDownload: FC<ICSVDownloadProps> = ({ - vars, -}) => { +const CSVDownload: FC<ICSVDownloadProps> = ({ vars }) => { const { t } = useTranslation() const { CSVDownloader, Type } = useCSVDownloader() const addQueryContentVars = [...vars] @@ -27,13 +23,17 @@ const CSVDownload: FC<ICSVDownloadProps> = ({ return ( <div className="mt-6"> - <div className="system-sm-medium text-text-primary">{t($ => $['generation.csvStructureTitle'], { ns: 'share' })}</div> + <div className="system-sm-medium text-text-primary"> + {t(($) => $['generation.csvStructureTitle'], { ns: 'share' })} + </div> <div className="mt-2 max-h-[500px] overflow-auto"> <table className="w-full table-fixed border-separate border-spacing-0 rounded-lg border border-divider-regular text-xs"> <thead className="text-text-tertiary"> <tr> {addQueryContentVars.map((item, i) => ( - <td key={i} className="h-9 border-b border-divider-regular pr-2 pl-3">{item.name}</td> + <td key={i} className="h-9 border-b border-divider-regular pr-2 pl-3"> + {item.name} + </td> ))} </tr> </thead> @@ -41,9 +41,7 @@ const CSVDownload: FC<ICSVDownloadProps> = ({ <tr> {addQueryContentVars.map((item, i) => ( <td key={i} className="h-9 pl-4"> - {item.name} - {' '} - {t($ => $['generation.field'], { ns: 'share' })} + {item.name} {t(($) => $['generation.field'], { ns: 'share' })} </td> ))} </tr> @@ -58,17 +56,14 @@ const CSVDownload: FC<ICSVDownloadProps> = ({ config={{ // delimiter: ';', }} - data={[ - template, - ]} + data={[template]} > <div className="flex h-[18px] items-center space-x-1 system-xs-medium text-text-accent"> <DownloadIcon className="size-3" /> - <span>{t($ => $['generation.downloadTemplate'], { ns: 'share' })}</span> + <span>{t(($) => $['generation.downloadTemplate'], { ns: 'share' })}</span> </div> </CSVDownloader> </div> - ) } export default React.memo(CSVDownload) diff --git a/web/app/components/share/text-generation/run-batch/csv-reader/__tests__/index.spec.tsx b/web/app/components/share/text-generation/run-batch/csv-reader/__tests__/index.spec.tsx index f1361965a58fae..3422d1576462f0 100644 --- a/web/app/components/share/text-generation/run-batch/csv-reader/__tests__/index.spec.tsx +++ b/web/app/components/share/text-generation/run-batch/csv-reader/__tests__/index.spec.tsx @@ -14,7 +14,15 @@ let capturedHandlers: CSVReaderHandlers = {} vi.mock('react-papaparse', () => ({ useCSVReader: () => ({ - CSVReader: ({ children, ...handlers }: { children: (ctx: { getRootProps: () => Record<string, string>, acceptedFile: { name: string } | null }) => React.ReactNode } & CSVReaderHandlers) => { + CSVReader: ({ + children, + ...handlers + }: { + children: (ctx: { + getRootProps: () => Record<string, string> + acceptedFile: { name: string } | null + }) => React.ReactNode + } & CSVReaderHandlers) => { capturedHandlers = handlers return ( <div data-testid="csv-reader-wrapper"> @@ -64,14 +72,18 @@ describe('CSVReader', () => { capturedHandlers.onDragOver?.(dragEvent) }) await waitFor(() => { - expect(screen.getByTestId('drop-zone')).toHaveClass('border-components-dropzone-border-accent') + expect(screen.getByTestId('drop-zone')).toHaveClass( + 'border-components-dropzone-border-accent', + ) }) await act(async () => { capturedHandlers.onDragLeave?.(dragEvent) }) await waitFor(() => { - expect(screen.getByTestId('drop-zone')).not.toHaveClass('border-components-dropzone-border-accent') + expect(screen.getByTestId('drop-zone')).not.toHaveClass( + 'border-components-dropzone-border-accent', + ) }) }) }) diff --git a/web/app/components/share/text-generation/run-batch/csv-reader/index.tsx b/web/app/components/share/text-generation/run-batch/csv-reader/index.tsx index 2cae00b59d8bfd..de3589443a45ec 100644 --- a/web/app/components/share/text-generation/run-batch/csv-reader/index.tsx +++ b/web/app/components/share/text-generation/run-batch/csv-reader/index.tsx @@ -4,18 +4,14 @@ import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { - useCSVReader, -} from 'react-papaparse' +import { useCSVReader } from 'react-papaparse' import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files' type Props = Readonly<{ onParsed: (data: string[][]) => void }> -const CSVReader: FC<Props> = ({ - onParsed, -}) => { +const CSVReader: FC<Props> = ({ onParsed }) => { const { t } = useTranslation() const { CSVReader } = useCSVReader() const [zoneHover, setZoneHover] = useState(false) @@ -34,40 +30,39 @@ const CSVReader: FC<Props> = ({ setZoneHover(false) }} > - {({ - getRootProps, - acceptedFile, - }: any) => ( + {({ getRootProps, acceptedFile }: any) => ( <> <div {...getRootProps()} className={cn( 'flex h-20 items-center rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg system-sm-regular', - acceptedFile && 'border-solid border-components-panel-border bg-components-panel-on-panel-item-bg px-6 hover:border-components-panel-bg-blur hover:bg-components-panel-on-panel-item-bg-hover', - zoneHover && 'border border-components-dropzone-border-accent bg-components-dropzone-bg-accent', + acceptedFile && + 'border-solid border-components-panel-border bg-components-panel-on-panel-item-bg px-6 hover:border-components-panel-bg-blur hover:bg-components-panel-on-panel-item-bg-hover', + zoneHover && + 'border border-components-dropzone-border-accent bg-components-dropzone-bg-accent', )} > - { - acceptedFile - ? ( - <div className="flex w-full items-center space-x-2"> - <CSVIcon className="shrink-0" /> - <div className="flex w-0 grow"> - <span className="max-w-[calc(100%-30px)] truncate text-text-secondary">{acceptedFile.name.replace(/.csv$/, '')}</span> - <span className="shrink-0 text-text-tertiary">.csv</span> - </div> - </div> - ) - : ( - <div className="flex w-full items-center justify-center space-x-2"> - <CSVIcon className="shrink-0" /> - <div className="text-text-tertiary"> - {t($ => $['generation.csvUploadTitle'], { ns: 'share' })} - <span className="cursor-pointer text-text-accent">{t($ => $['generation.browse'], { ns: 'share' })}</span> - </div> - </div> - ) - } + {acceptedFile ? ( + <div className="flex w-full items-center space-x-2"> + <CSVIcon className="shrink-0" /> + <div className="flex w-0 grow"> + <span className="max-w-[calc(100%-30px)] truncate text-text-secondary"> + {acceptedFile.name.replace(/.csv$/, '')} + </span> + <span className="shrink-0 text-text-tertiary">.csv</span> + </div> + </div> + ) : ( + <div className="flex w-full items-center justify-center space-x-2"> + <CSVIcon className="shrink-0" /> + <div className="text-text-tertiary"> + {t(($) => $['generation.csvUploadTitle'], { ns: 'share' })} + <span className="cursor-pointer text-text-accent"> + {t(($) => $['generation.browse'], { ns: 'share' })} + </span> + </div> + </div> + )} </div> </> )} diff --git a/web/app/components/share/text-generation/run-batch/index.tsx b/web/app/components/share/text-generation/run-batch/index.tsx index f8c8a7e1af1bae..c831b87549e334 100644 --- a/web/app/components/share/text-generation/run-batch/index.tsx +++ b/web/app/components/share/text-generation/run-batch/index.tsx @@ -2,10 +2,7 @@ import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - RiLoader2Line, - RiPlayLargeLine, -} from '@remixicon/react' +import { RiLoader2Line, RiPlayLargeLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' @@ -18,11 +15,7 @@ type IRunBatchProps = { isAllFinished: boolean } -const RunBatch: FC<IRunBatchProps> = ({ - vars, - onSend, - isAllFinished, -}) => { +const RunBatch: FC<IRunBatchProps> = ({ vars, onSend, isAllFinished }) => { const { t } = useTranslation() const media = useBreakpoints() const isPC = media === MediaType.pc @@ -50,8 +43,13 @@ const RunBatch: FC<IRunBatchProps> = ({ onClick={handleSend} disabled={!isParsed || !isAllFinished} > - <Icon className={cn(!isAllFinished && 'animate-spin', 'mr-1 size-4 shrink-0')} aria-hidden="true" /> - <span className="text-[13px] uppercase">{t($ => $['generation.run'], { ns: 'share' })}</span> + <Icon + className={cn(!isAllFinished && 'animate-spin', 'mr-1 size-4 shrink-0')} + aria-hidden="true" + /> + <span className="text-[13px] uppercase"> + {t(($) => $['generation.run'], { ns: 'share' })} + </span> </Button> </div> </div> diff --git a/web/app/components/share/text-generation/run-batch/res-download/__tests__/index.spec.tsx b/web/app/components/share/text-generation/run-batch/res-download/__tests__/index.spec.tsx index 2419a570f17541..5a79999de05e62 100644 --- a/web/app/components/share/text-generation/run-batch/res-download/__tests__/index.spec.tsx +++ b/web/app/components/share/text-generation/run-batch/res-download/__tests__/index.spec.tsx @@ -7,9 +7,16 @@ let capturedProps: Record<string, unknown> | undefined vi.mock('react-papaparse', () => ({ useCSVDownloader: () => { - const CSVDownloader = ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => { + const CSVDownloader = ({ + children, + ...props + }: React.PropsWithChildren<Record<string, unknown>>) => { capturedProps = props - return <div data-testid="csv-downloader" className={props.className as string}>{children}</div> + return ( + <div data-testid="csv-downloader" className={props.className as string}> + {children} + </div> + ) } return { CSVDownloader, diff --git a/web/app/components/share/text-generation/run-batch/res-download/index.tsx b/web/app/components/share/text-generation/run-batch/res-download/index.tsx index 2ef7b68539be9a..2f5537df88baad 100644 --- a/web/app/components/share/text-generation/run-batch/res-download/index.tsx +++ b/web/app/components/share/text-generation/run-batch/res-download/index.tsx @@ -5,9 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { RiDownloadLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { - useCSVDownloader, -} from 'react-papaparse' +import { useCSVDownloader } from 'react-papaparse' import ActionButton from '@/app/components/base/action-button' type IResDownloadProps = { @@ -15,10 +13,7 @@ type IResDownloadProps = { values: Record<string, string>[] } -const ResDownload: FC<IResDownloadProps> = ({ - isMobile, - values, -}) => { +const ResDownload: FC<IResDownloadProps> = ({ isMobile, values }) => { const { t } = useTranslation() const { CSVDownloader, Type } = useCSVDownloader() @@ -41,7 +36,7 @@ const ResDownload: FC<IResDownloadProps> = ({ {!isMobile && ( <Button className={cn('space-x-1')}> <RiDownloadLine className="size-4" /> - <span>{t($ => $['operation.download'], { ns: 'common' })}</span> + <span>{t(($) => $['operation.download'], { ns: 'common' })}</span> </Button> )} </CSVDownloader> diff --git a/web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx b/web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx index 40a2bdbaafbb0b..383f8f63ea096c 100644 --- a/web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx +++ b/web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx @@ -22,13 +22,21 @@ vi.mock('@/hooks/use-breakpoints', () => { }) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ value, onChange }: { value?: string, onChange?: (val: string) => void }) => ( - <textarea data-testid="code-editor-mock" value={value} onChange={e => onChange?.(e.target.value)} /> + default: ({ value, onChange }: { value?: string; onChange?: (val: string) => void }) => ( + <textarea + data-testid="code-editor-mock" + value={value} + onChange={(e) => onChange?.(e.target.value)} + /> ), })) vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => { - function TextGenerationImageUploaderMock({ onFilesChange }: { onFilesChange: (files: VisionFile[]) => void }) { + function TextGenerationImageUploaderMock({ + onFilesChange, + }: { + onFilesChange: (files: VisionFile[]) => void + }) { useEffect(() => { onFilesChange([]) }, [onFilesChange]) @@ -40,14 +48,16 @@ vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', ( }) vi.mock('@/app/components/base/file-uploader', () => ({ - FileUploaderInAttachmentWrapper: ({ value, onChange }: { value: object[], onChange: (files: object[]) => void }) => ( + FileUploaderInAttachmentWrapper: ({ + value, + onChange, + }: { + value: object[] + onChange: (files: object[]) => void + }) => ( <div data-testid="file-uploader-mock"> <button onClick={() => onChange([{ id: 'test-file' }])}>Upload</button> - <span> - {value?.length || 0} - {' '} - files - </span> + <span>{value?.length || 0} files</span> </div> ), })) @@ -101,11 +111,13 @@ const siteInfo: SiteInfo = { title: 'Share', } -const setup = (overrides: { - promptConfig?: PromptConfig - visionConfig?: VisionSettings - runControl?: React.ComponentProps<typeof RunOnce>['runControl'] -} = {}) => { +const setup = ( + overrides: { + promptConfig?: PromptConfig + visionConfig?: VisionSettings + runControl?: React.ComponentProps<typeof RunOnce>['runControl'] + } = {}, +) => { const onInputsChange = vi.fn() const onSend = vi.fn() const onVisionFilesChange = vi.fn() @@ -233,7 +245,9 @@ describe('RunOnce', () => { await waitFor(() => { expect(onInputsChange).toHaveBeenCalled() }) - const stopButton = screen.getByRole('button', { name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}' }) + const stopButton = screen.getByRole('button', { + name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}', + }) fireEvent.click(stopButton) expect(onStop).toHaveBeenCalledTimes(1) }) @@ -247,7 +261,9 @@ describe('RunOnce', () => { await waitFor(() => { expect(onInputsChange).toHaveBeenCalled() }) - const stopButton = screen.getByRole('button', { name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}' }) + const stopButton = screen.getByRole('button', { + name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}', + }) expect(stopButton)!.toBeDisabled() }) @@ -265,7 +281,10 @@ describe('RunOnce', () => { }), ], } - const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } }) + const { onInputsChange } = setup({ + promptConfig, + visionConfig: { ...baseVisionConfig, enabled: false }, + }) await waitFor(() => { expect(onInputsChange).toHaveBeenCalledWith({ selectInput: 'Option A', @@ -287,7 +306,10 @@ describe('RunOnce', () => { }), ], } - const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } }) + const { onInputsChange } = setup({ + promptConfig, + visionConfig: { ...baseVisionConfig, enabled: false }, + }) await waitFor(() => { expect(onInputsChange).toHaveBeenCalledWith({ fileInput: undefined, @@ -307,7 +329,10 @@ describe('RunOnce', () => { }), ], } - const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } }) + const { onInputsChange } = setup({ + promptConfig, + visionConfig: { ...baseVisionConfig, enabled: false }, + }) await waitFor(() => { expect(onInputsChange).toHaveBeenCalledWith({ fileListInput: [], @@ -330,7 +355,10 @@ describe('RunOnce', () => { }), ], } - const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } }) + const { onInputsChange } = setup({ + promptConfig, + visionConfig: { ...baseVisionConfig, enabled: false }, + }) await waitFor(() => { expect(onInputsChange).toHaveBeenCalledWith({ jsonInput: undefined, @@ -351,7 +379,10 @@ describe('RunOnce', () => { }), ], } - const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } }) + const { onInputsChange } = setup({ + promptConfig, + visionConfig: { ...baseVisionConfig, enabled: false }, + }) await waitFor(() => { expect(onInputsChange).toHaveBeenCalled() }) @@ -386,7 +417,10 @@ describe('RunOnce', () => { }), ], } - const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } }) + const { onInputsChange } = setup({ + promptConfig, + visionConfig: { ...baseVisionConfig, enabled: false }, + }) await waitFor(() => { expect(onInputsChange).toHaveBeenCalled() }) @@ -406,7 +440,10 @@ describe('RunOnce', () => { }), ], } - const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } }) + const { onInputsChange } = setup({ + promptConfig, + visionConfig: { ...baseVisionConfig, enabled: false }, + }) await waitFor(() => { expect(onInputsChange).toHaveBeenCalled() }) @@ -438,7 +475,10 @@ describe('RunOnce', () => { }), ], } - const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } }) + const { onInputsChange } = setup({ + promptConfig, + visionConfig: { ...baseVisionConfig, enabled: false }, + }) await waitFor(() => { expect(onInputsChange).toHaveBeenCalled() }) @@ -464,7 +504,10 @@ describe('RunOnce', () => { }), ], } - const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } }) + const { onInputsChange } = setup({ + promptConfig, + visionConfig: { ...baseVisionConfig, enabled: false }, + }) await waitFor(() => { expect(onInputsChange).toHaveBeenCalled() }) @@ -484,7 +527,10 @@ describe('RunOnce', () => { }), ], } - const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } }) + const { onInputsChange } = setup({ + promptConfig, + visionConfig: { ...baseVisionConfig, enabled: false }, + }) await waitFor(() => { expect(onInputsChange).toHaveBeenCalled() }) diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx index 13c41118d8a3a9..5cb402f5c286a4 100644 --- a/web/app/components/share/text-generation/run-once/index.tsx +++ b/web/app/components/share/text-generation/run-once/index.tsx @@ -6,12 +6,16 @@ import type { SiteInfo } from '@/models/share' import type { VisionFile, VisionSettings } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' -import { Textarea } from '@langgenius/dify-ui/textarea' import { - RiLoader2Line, - RiPlayLargeLine, -} from '@remixicon/react' + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' +import { Textarea } from '@langgenius/dify-ui/textarea' +import { RiLoader2Line, RiPlayLargeLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -57,14 +61,10 @@ const RunOnce: FC<IRunOnceProps> = ({ const onClear = () => { const newInputs: Record<string, InputValueTypes> = {} promptConfig.prompt_variables.forEach((item) => { - if (item.type === 'string' || item.type === 'paragraph') - newInputs[item.key] = '' - else if (item.type === 'number') - newInputs[item.key] = '' - else if (item.type === 'checkbox') - newInputs[item.key] = false - else - newInputs[item.key] = undefined + if (item.type === 'string' || item.type === 'paragraph') newInputs[item.key] = '' + else if (item.type === 'number') newInputs[item.key] = '' + else if (item.type === 'checkbox') newInputs[item.key] = false + else newInputs[item.key] = undefined }) onInputsChange(newInputs) } @@ -74,38 +74,36 @@ const RunOnce: FC<IRunOnceProps> = ({ onSend() } const isRunning = !!runControl - const stopLabel = t($ => $['generation.stopRun'], { ns: 'share', defaultValue: 'Stop Run' }) - const handlePrimaryClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => { - if (!isRunning) - return - e.preventDefault() - runControl?.onStop?.() - }, [isRunning, runControl]) + const stopLabel = t(($) => $['generation.stopRun'], { ns: 'share', defaultValue: 'Stop Run' }) + const handlePrimaryClick = useCallback( + (e: React.MouseEvent<HTMLButtonElement>) => { + if (!isRunning) return + e.preventDefault() + runControl?.onStop?.() + }, + [isRunning, runControl], + ) - const handleInputsChange = useCallback((newInputs: Record<string, any>) => { - onInputsChange(newInputs) - inputsRef.current = newInputs - }, [onInputsChange, inputsRef]) + const handleInputsChange = useCallback( + (newInputs: Record<string, any>) => { + onInputsChange(newInputs) + inputsRef.current = newInputs + }, + [onInputsChange, inputsRef], + ) useEffect(() => { - if (isInitialized) - return + if (isInitialized) return const newInputs: Record<string, any> = {} promptConfig.prompt_variables.forEach((item) => { - if (item.type === 'select') - newInputs[item.key] = item.default + if (item.type === 'select') newInputs[item.key] = item.default else if (item.type === 'string' || item.type === 'paragraph') newInputs[item.key] = item.default || '' - else if (item.type === 'number') - newInputs[item.key] = item.default ?? '' - else if (item.type === 'checkbox') - newInputs[item.key] = item.default || false - else if (item.type === 'file') - newInputs[item.key] = undefined - else if (item.type === 'file-list') - newInputs[item.key] = [] - else - newInputs[item.key] = undefined + else if (item.type === 'number') newInputs[item.key] = item.default ?? '' + else if (item.type === 'checkbox') newInputs[item.key] = item.default || false + else if (item.type === 'file') newInputs[item.key] = undefined + else if (item.type === 'file-list') newInputs[item.key] = [] + else newInputs[item.key] = undefined }) onInputsChange(newInputs) setIsInitialized(true) @@ -116,142 +114,191 @@ const RunOnce: FC<IRunOnceProps> = ({ <section> {/* input form */} <form onSubmit={onSubmit}> - {(inputs === null || inputs === undefined || Object.keys(inputs).length === 0) || !isInitialized + {inputs === null || + inputs === undefined || + Object.keys(inputs).length === 0 || + !isInitialized ? null - : promptConfig.prompt_variables.filter(item => item.hide !== true).map((item) => { - const inputValue = inputs[item.key] - const selectValue = typeof inputValue === 'string' && inputValue !== '' ? inputValue : null - const defaultSelectValue = typeof item.default === 'string' && item.default !== '' ? item.default : null + : promptConfig.prompt_variables + .filter((item) => item.hide !== true) + .map((item) => { + const inputValue = inputs[item.key] + const selectValue = + typeof inputValue === 'string' && inputValue !== '' ? inputValue : null + const defaultSelectValue = + typeof item.default === 'string' && item.default !== '' ? item.default : null - return ( - <div className="mt-4 w-full" key={item.key}> - {item.type !== 'checkbox' && ( - <div className="flex h-6 items-center gap-1 system-md-semibold text-text-secondary"> - <div className="truncate">{item.name}</div> - {!item.required && <span className="system-xs-regular text-text-tertiary">{t($ => $['panel.optional'], { ns: 'workflow' })}</span>} - </div> - )} - <div className="mt-1"> - {item.type === 'select' && ( - <Select<string> - value={selectValue} - onValueChange={(nextValue) => { - if (nextValue == null || nextValue === '') - return - handleInputsChange({ ...inputsRef.current, [item.key]: nextValue }) - }} - > - <SelectTrigger className="w-full"> - {selectValue ?? defaultSelectValue ?? t($ => $['placeholder.select'], { ns: 'common' })} - </SelectTrigger> - <SelectContent> - {(item.options || []).map(option => ( - <SelectItem key={option} value={option}> - <SelectItemText>{option}</SelectItemText> - <SelectItemIndicator /> - </SelectItem> - ))} - </SelectContent> - </Select> - )} - {item.type === 'string' && ( - <Input - type="text" - placeholder={item.name} - value={inputs[item.key] as string} - onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} - maxLength={item.max_length} - /> - )} - {item.type === 'paragraph' && ( - <Textarea - aria-label={item.name} - className="h-[104px] sm:text-xs" - placeholder={item.name} - value={inputs[item.key] as string} - onValueChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }} - /> - )} - {item.type === 'number' && ( - <Input - type="number" - placeholder={item.name} - value={inputs[item.key] as number} - onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} - /> - )} - {item.type === 'checkbox' && ( - <BoolInput - name={item.name || item.key} - value={!!inputs[item.key] as boolean} - required={item.required} - onChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }} - /> - )} - {item.type === 'file' && ( - <FileUploaderInAttachmentWrapper - value={inputs[item.key] && typeof inputs[item.key] === 'object' && !Array.isArray(inputs[item.key]) - ? [inputs[item.key] as FileEntity] - : []} - onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: files[0] }) }} - fileConfig={{ - ...item.config, - fileUploadConfig: (visionConfig as any).fileUploadConfig, - }} - /> - )} - {item.type === 'file-list' && ( - <FileUploaderInAttachmentWrapper - value={Array.isArray(inputs[item.key]) ? inputs[item.key] as FileEntity[] : []} - onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: files }) }} - fileConfig={{ - ...item.config, - // eslint-disable-next-line ts/no-explicit-any - fileUploadConfig: (visionConfig as any).fileUploadConfig, - }} - /> - )} - {item.type === 'json_object' && ( - <CodeEditor - language={CodeLanguage.json} - value={inputs[item.key] as string} - onChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }} - noWrapper - className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" - placeholder={ - <div className="whitespace-pre">{typeof item.json_schema === 'string' ? item.json_schema : JSON.stringify(item.json_schema || '', null, 2)}</div> - } - /> + return ( + <div className="mt-4 w-full" key={item.key}> + {item.type !== 'checkbox' && ( + <div className="flex h-6 items-center gap-1 system-md-semibold text-text-secondary"> + <div className="truncate">{item.name}</div> + {!item.required && ( + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['panel.optional'], { ns: 'workflow' })} + </span> + )} + </div> )} + <div className="mt-1"> + {item.type === 'select' && ( + <Select<string> + value={selectValue} + onValueChange={(nextValue) => { + if (nextValue == null || nextValue === '') return + handleInputsChange({ ...inputsRef.current, [item.key]: nextValue }) + }} + > + <SelectTrigger className="w-full"> + {selectValue ?? + defaultSelectValue ?? + t(($) => $['placeholder.select'], { ns: 'common' })} + </SelectTrigger> + <SelectContent> + {(item.options || []).map((option) => ( + <SelectItem key={option} value={option}> + <SelectItemText>{option}</SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ))} + </SelectContent> + </Select> + )} + {item.type === 'string' && ( + <Input + type="text" + placeholder={item.name} + value={inputs[item.key] as string} + onChange={(e: ChangeEvent<HTMLInputElement>) => { + handleInputsChange({ + ...inputsRef.current, + [item.key]: e.target.value, + }) + }} + maxLength={item.max_length} + /> + )} + {item.type === 'paragraph' && ( + <Textarea + aria-label={item.name} + className="h-[104px] sm:text-xs" + placeholder={item.name} + value={inputs[item.key] as string} + onValueChange={(value) => { + handleInputsChange({ ...inputsRef.current, [item.key]: value }) + }} + /> + )} + {item.type === 'number' && ( + <Input + type="number" + placeholder={item.name} + value={inputs[item.key] as number} + onChange={(e: ChangeEvent<HTMLInputElement>) => { + handleInputsChange({ + ...inputsRef.current, + [item.key]: e.target.value, + }) + }} + /> + )} + {item.type === 'checkbox' && ( + <BoolInput + name={item.name || item.key} + value={!!inputs[item.key] as boolean} + required={item.required} + onChange={(value) => { + handleInputsChange({ ...inputsRef.current, [item.key]: value }) + }} + /> + )} + {item.type === 'file' && ( + <FileUploaderInAttachmentWrapper + value={ + inputs[item.key] && + typeof inputs[item.key] === 'object' && + !Array.isArray(inputs[item.key]) + ? [inputs[item.key] as FileEntity] + : [] + } + onChange={(files) => { + handleInputsChange({ ...inputsRef.current, [item.key]: files[0] }) + }} + fileConfig={{ + ...item.config, + fileUploadConfig: (visionConfig as any).fileUploadConfig, + }} + /> + )} + {item.type === 'file-list' && ( + <FileUploaderInAttachmentWrapper + value={ + Array.isArray(inputs[item.key]) + ? (inputs[item.key] as FileEntity[]) + : [] + } + onChange={(files) => { + handleInputsChange({ ...inputsRef.current, [item.key]: files }) + }} + fileConfig={{ + ...item.config, + // eslint-disable-next-line ts/no-explicit-any + fileUploadConfig: (visionConfig as any).fileUploadConfig, + }} + /> + )} + {item.type === 'json_object' && ( + <CodeEditor + language={CodeLanguage.json} + value={inputs[item.key] as string} + onChange={(value) => { + handleInputsChange({ ...inputsRef.current, [item.key]: value }) + }} + noWrapper + className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" + placeholder={ + <div className="whitespace-pre"> + {typeof item.json_schema === 'string' + ? item.json_schema + : JSON.stringify(item.json_schema || '', null, 2)} + </div> + } + /> + )} + </div> </div> - </div> - ) - })} - { - visionConfig?.enabled && ( - <div className="mt-4 w-full"> - <div className="flex h-6 items-center system-md-semibold text-text-secondary">{t($ => $['imageUploader.imageUpload'], { ns: 'common' })}</div> - <div className="mt-1"> - <TextGenerationImageUploader - settings={visionConfig} - onFilesChange={files => onVisionFilesChange(files.filter(file => file.progress !== -1).map(fileItem => ({ - type: 'image', - transfer_method: fileItem.type, - url: fileItem.url, - upload_file_id: fileItem.fileId, - })))} - /> - </div> + ) + })} + {visionConfig?.enabled && ( + <div className="mt-4 w-full"> + <div className="flex h-6 items-center system-md-semibold text-text-secondary"> + {t(($) => $['imageUploader.imageUpload'], { ns: 'common' })} </div> - ) - } + <div className="mt-1"> + <TextGenerationImageUploader + settings={visionConfig} + onFilesChange={(files) => + onVisionFilesChange( + files + .filter((file) => file.progress !== -1) + .map((fileItem) => ({ + type: 'image', + transfer_method: fileItem.type, + url: fileItem.url, + upload_file_id: fileItem.fileId, + })), + ) + } + /> + </div> + </div> + )} <div className="mt-6 mb-3 w-full"> <div className="flex items-center justify-between gap-2"> - <Button - onClick={onClear} - disabled={false} - > - <span className="text-[13px]">{t($ => $['operation.clear'], { ns: 'common' })}</span> + <Button onClick={onClear} disabled={false}> + <span className="text-[13px]"> + {t(($) => $['operation.clear'], { ns: 'common' })} + </span> </Button> <Button className={cn(!isPC && 'grow')} @@ -260,21 +307,26 @@ const RunOnce: FC<IRunOnceProps> = ({ disabled={isRunning && runControl?.isStopping} onClick={handlePrimaryClick} > - {isRunning - ? ( - <> - {runControl?.isStopping - ? <RiLoader2Line className="mr-1 size-4 shrink-0 animate-spin" aria-hidden="true" /> - : <StopCircle className="mr-1 size-4 shrink-0" aria-hidden="true" />} - <span className="text-[13px]">{stopLabel}</span> - </> - ) - : ( - <> - <RiPlayLargeLine className="mr-1 size-4 shrink-0" aria-hidden="true" /> - <span className="text-[13px]">{t($ => $['generation.run'], { ns: 'share' })}</span> - </> + {isRunning ? ( + <> + {runControl?.isStopping ? ( + <RiLoader2Line + className="mr-1 size-4 shrink-0 animate-spin" + aria-hidden="true" + /> + ) : ( + <StopCircle className="mr-1 size-4 shrink-0" aria-hidden="true" /> )} + <span className="text-[13px]">{stopLabel}</span> + </> + ) : ( + <> + <RiPlayLargeLine className="mr-1 size-4 shrink-0" aria-hidden="true" /> + <span className="text-[13px]"> + {t(($) => $['generation.run'], { ns: 'share' })} + </span> + </> + )} </Button> </div> </div> diff --git a/web/app/components/share/text-generation/text-generation-result-panel.tsx b/web/app/components/share/text-generation/text-generation-result-panel.tsx index f9fcac41adb774..a5ca4443f747ff 100644 --- a/web/app/components/share/text-generation/text-generation-result-panel.tsx +++ b/web/app/components/share/text-generation/text-generation-result-panel.tsx @@ -128,10 +128,8 @@ const TextGenerationResultPanel: FC<TextGenerationResultPanelProps> = ({ : 'absolute top-0 left-0 z-10 flex w-full items-center justify-center px-2 pt-[3px] pb-[57px]', )} onClick={() => { - if (isShowResultPanel) - onHideResultPanel() - else - onShowResultPanel() + if (isShowResultPanel) onHideResultPanel() + else onShowResultPanel() }} > <div className="h-1 w-8 cursor-grab rounded-sm bg-divider-solid" /> @@ -155,13 +153,10 @@ const TextGenerationResultPanel: FC<TextGenerationResultPanelProps> = ({ !isPC && 'px-4 pt-3 pb-1', )} > - <div className="system-md-semibold-uppercase text-text-primary">{t($ => $['generation.executions'], { ns: 'share', num: allTaskList.length })}</div> - {allSuccessTaskList.length > 0 && ( - <ResDownload - isMobile={!isPC} - values={exportRes} - /> - )} + <div className="system-md-semibold-uppercase text-text-primary"> + {t(($) => $['generation.executions'], { ns: 'share', num: allTaskList.length })} + </div> + {allSuccessTaskList.length > 0 && <ResDownload isMobile={!isPC} values={exportRes} />} </div> )} <div @@ -172,7 +167,7 @@ const TextGenerationResultPanel: FC<TextGenerationResultPanelProps> = ({ !isPC && 'p-0 pb-2', )} > - {isCallBatchAPI ? showTaskList.map(task => renderResult(task)) : renderResult()} + {isCallBatchAPI ? showTaskList.map((task) => renderResult(task)) : renderResult()} {!noPendingTask && ( <div className="mt-4"> <Loading type="area" /> @@ -182,14 +177,19 @@ const TextGenerationResultPanel: FC<TextGenerationResultPanelProps> = ({ {isCallBatchAPI && allFailedTaskList.length > 0 && ( <div className="absolute bottom-6 left-1/2 z-10 flex -translate-x-1/2 items-center gap-2 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-xs"> <span aria-hidden className="i-ri-error-warning-fill size-4 text-text-destructive" /> - <div className="system-sm-medium text-text-secondary">{t($ => $['generation.batchFailed.info'], { ns: 'share', num: allFailedTaskList.length })}</div> + <div className="system-sm-medium text-text-secondary"> + {t(($) => $['generation.batchFailed.info'], { + ns: 'share', + num: allFailedTaskList.length, + })} + </div> <div className="h-3.5 w-px bg-divider-regular"></div> <button type="button" className="inline cursor-pointer border-none bg-transparent p-0 text-left system-sm-semibold-uppercase text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={handleRetryAllFailedTask} > - {t($ => $['generation.batchFailed.retry'], { ns: 'share' })} + {t(($) => $['generation.batchFailed.retry'], { ns: 'share' })} </button> </div> )} diff --git a/web/app/components/share/text-generation/text-generation-sidebar.tsx b/web/app/components/share/text-generation/text-generation-sidebar.tsx index 6a7dedf6c68df4..36fb576906105a 100644 --- a/web/app/components/share/text-generation/text-generation-sidebar.tsx +++ b/web/app/components/share/text-generation/text-generation-sidebar.tsx @@ -87,7 +87,12 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({ isInstalledApp && 'rounded-l-2xl', )} > - <div className={cn('shrink-0 space-y-4 border-b border-divider-subtle', isPC ? 'bg-components-panel-bg p-8 pb-0' : 'p-4 pb-0')}> + <div + className={cn( + 'shrink-0 space-y-4 border-b border-divider-subtle', + isPC ? 'bg-components-panel-bg p-8 pb-0' : 'p-4 pb-0', + )} + > <div className="flex items-center gap-3"> <AppIcon size={isPC ? 'large' : 'small'} @@ -96,8 +101,13 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({ background={siteInfo.icon_background || appDefaultIconBackground} imageUrl={siteInfo.icon_url} /> - <div className="grow truncate system-md-semibold text-text-secondary">{siteInfo.title}</div> - <MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} data={siteInfo} /> + <div className="grow truncate system-md-semibold text-text-secondary"> + {siteInfo.title} + </div> + <MenuDropdown + hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} + data={siteInfo} + /> </div> {siteInfo.description && ( <div> @@ -118,41 +128,35 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({ <button type="button" className="mt-0.5 flex items-center gap-0.5 system-xs-regular text-text-accent hover:opacity-80" - onClick={() => setDescExpanded(v => !v)} + onClick={() => setDescExpanded((v) => !v)} > - {descExpanded - ? ( - <> - <RiArrowUpSLine className="size-3" /> - {t($ => $['chat.collapse'], { ns: 'share' })} - </> - ) - : ( - <> - <RiArrowDownSLine className="size-3" /> - {t($ => $['chat.expand'], { ns: 'share' })} - </> - )} + {descExpanded ? ( + <> + <RiArrowUpSLine className="size-3" /> + {t(($) => $['chat.collapse'], { ns: 'share' })} + </> + ) : ( + <> + <RiArrowDownSLine className="size-3" /> + {t(($) => $['chat.expand'], { ns: 'share' })} + </> + )} </button> )} </div> )} <TabsList className="w-full"> <TabsTab value="create"> - <span className="ml-2">{t($ => $['generation.tabs.create'], { ns: 'share' })}</span> + <span className="ml-2">{t(($) => $['generation.tabs.create'], { ns: 'share' })}</span> </TabsTab> <TabsTab value="batch"> - <span className="ml-2">{t($ => $['generation.tabs.batch'], { ns: 'share' })}</span> + <span className="ml-2">{t(($) => $['generation.tabs.batch'], { ns: 'share' })}</span> </TabsTab> {!isWorkflow && ( <TabsTab value="saved" className="ml-auto"> <span aria-hidden className="i-ri-bookmark-3-line size-4" /> - <span className="ml-2">{t($ => $['generation.tabs.saved'], { ns: 'share' })}</span> - {savedMessages.length > 0 && ( - <Badge className="ml-1"> - {savedMessages.length} - </Badge> - )} + <span className="ml-2">{t(($) => $['generation.tabs.saved'], { ns: 'share' })}</span> + {savedMessages.length > 0 && <Badge className="ml-1">{savedMessages.length}</Badge>} </TabsTab> )} </TabsList> @@ -161,7 +165,10 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({ className={cn( 'h-0 grow overflow-y-auto bg-components-panel-bg', isPC ? 'px-8' : 'px-4', - !isPC && resultExisted && customConfig?.remove_webapp_brand && 'rounded-b-2xl border-b-[0.5px] border-divider-regular', + !isPC && + resultExisted && + customConfig?.remove_webapp_brand && + 'rounded-b-2xl border-b-[0.5px] border-divider-regular', )} > <TabsPanel value="create" keepMounted> @@ -204,12 +211,20 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({ !isPC && resultExisted && 'rounded-b-2xl border-b-[0.5px] border-divider-regular', )} > - <div className="system-2xs-medium-uppercase text-text-tertiary">{t($ => $['chat.poweredBy'], { ns: 'share' })}</div> - {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo - ? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" /> - : customConfig?.replace_webapp_logo - ? <img src={customConfig.replace_webapp_logo} alt="logo" className="block h-5 w-auto" /> - : <DifyLogo size="small" />} + <div className="system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['chat.poweredBy'], { ns: 'share' })} + </div> + {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo ? ( + <img + src={systemFeatures.branding.workspace_logo} + alt="logo" + className="block h-5 w-auto" + /> + ) : customConfig?.replace_webapp_logo ? ( + <img src={customConfig.replace_webapp_logo} alt="logo" className="block h-5 w-auto" /> + ) : ( + <DifyLogo size="small" /> + )} </div> )} </Tabs> diff --git a/web/app/components/share/text-generation/types.ts b/web/app/components/share/text-generation/types.ts index 95415567dc426f..c25e1504bb1e8f 100644 --- a/web/app/components/share/text-generation/types.ts +++ b/web/app/components/share/text-generation/types.ts @@ -1,10 +1,7 @@ import type { Namespace, SelectorParam } from 'i18next' import type { FileEntity } from '@/app/components/base/file-uploader/types' -export type TextGenerationTranslate = < - Ns extends Namespace, - Selector extends SelectorParam<Ns>, ->( +export type TextGenerationTranslate = <Ns extends Namespace, Selector extends SelectorParam<Ns>>( selector: Selector, options: { ns: Ns } & Record<string, unknown>, ) => string @@ -26,15 +23,15 @@ export enum TaskStatus { failed = 'failed', } -export type InputValueTypes - = | string - | boolean - | number - | string[] - | Record<string, unknown> - | FileEntity - | FileEntity[] - | undefined +export type InputValueTypes = + | string + | boolean + | number + | string[] + | Record<string, unknown> + | FileEntity + | FileEntity[] + | undefined export type TextGenerationRunControl = { onStop: () => Promise<void> | void diff --git a/web/app/components/signin/__tests__/countdown.spec.tsx b/web/app/components/signin/__tests__/countdown.spec.tsx index 2679cdb553bcad..8178080b79a1d5 100644 --- a/web/app/components/signin/__tests__/countdown.spec.tsx +++ b/web/app/components/signin/__tests__/countdown.spec.tsx @@ -42,7 +42,9 @@ describe('Countdown', () => { localStorage.setItem(COUNT_DOWN_KEY, '1000') render(<Countdown />) - expect(screen.queryByRole('button', { name: 'login.checkCode.resend' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'login.checkCode.resend' }), + ).not.toBeInTheDocument() }) }) @@ -60,8 +62,7 @@ describe('Countdown', () => { await waitFor(() => { expect(container).toHaveTextContent('30') }) - } - finally { + } finally { act(() => { root.unmount() }) diff --git a/web/app/components/signin/countdown.tsx b/web/app/components/signin/countdown.tsx index 181739b8eea778..322751178aaa11 100644 --- a/web/app/components/signin/countdown.tsx +++ b/web/app/components/signin/countdown.tsx @@ -12,8 +12,7 @@ type CountdownProps = { export default function Countdown({ onResend }: CountdownProps) { const isClient = useIsClient() - if (!isClient) - return <CountdownFallback /> + if (!isClient) return <CountdownFallback /> return ( <Suspense fallback={<CountdownFallback />}> @@ -27,7 +26,7 @@ function CountdownFallback() { return ( <p className="system-xs-regular text-text-tertiary"> - <span>{t($ => $['checkCode.didNotReceiveCode'], { ns: 'login' })}</span> + <span>{t(($) => $['checkCode.didNotReceiveCode'], { ns: 'login' })}</span> </p> ) } @@ -57,24 +56,17 @@ function CountdownContent({ onResend }: CountdownProps) { return ( <p className="system-xs-regular text-text-tertiary"> - <span>{t($ => $['checkCode.didNotReceiveCode'], { ns: 'login' })}</span> - {time > 0 && ( - <span> - {Math.round(time / 1000)} - s - </span> + <span>{t(($) => $['checkCode.didNotReceiveCode'], { ns: 'login' })}</span> + {time > 0 && <span>{Math.round(time / 1000)}s</span>} + {time <= 0 && ( + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-medium text-text-accent-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={resend} + > + {t(($) => $['checkCode.resend'], { ns: 'login' })} + </button> )} - { - time <= 0 && ( - <button - type="button" - className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-medium text-text-accent-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={resend} - > - {t($ => $['checkCode.resend'], { ns: 'login' })} - </button> - ) - } </p> ) } diff --git a/web/app/components/signin/storage.ts b/web/app/components/signin/storage.ts index ae5f8306b42510..bbff5856765169 100644 --- a/web/app/components/signin/storage.ts +++ b/web/app/components/signin/storage.ts @@ -3,13 +3,7 @@ import { createLocalStorageState } from 'foxact/create-local-storage-state' export const COUNT_DOWN_TIME_MS = 59000 export const COUNT_DOWN_KEY = 'leftTime' -const [ - _useCountdownLeftTime, - useCountdownLeftTimeValue, - useSetCountdownLeftTime, -] = createLocalStorageState<string>(COUNT_DOWN_KEY, undefined, { raw: true }) +const [_useCountdownLeftTime, useCountdownLeftTimeValue, useSetCountdownLeftTime] = + createLocalStorageState<string>(COUNT_DOWN_KEY, undefined, { raw: true }) -export { - useCountdownLeftTimeValue, - useSetCountdownLeftTime, -} +export { useCountdownLeftTimeValue, useSetCountdownLeftTime } diff --git a/web/app/components/snippet-list/__tests__/index.spec.tsx b/web/app/components/snippet-list/__tests__/index.spec.tsx index 95318454b11e29..60b10847fcebe5 100644 --- a/web/app/components/snippet-list/__tests__/index.spec.tsx +++ b/web/app/components/snippet-list/__tests__/index.spec.tsx @@ -34,7 +34,8 @@ vi.mock('@/service/use-snippets', () => ({ mutateAsync: vi.fn(), isPending: false, }), - useInfiniteSnippetList: (params: unknown, options: unknown) => mockUseInfiniteSnippetList(params, options), + useInfiniteSnippetList: (params: unknown, options: unknown) => + mockUseInfiniteSnippetList(params, options), useUpdateSnippetMutation: () => ({ mutate: vi.fn(), isPending: false, @@ -124,7 +125,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -158,19 +160,23 @@ vi.mock('@/next/dynamic', () => ({ 'data-testid': 'tag-management-modal', 'data-show': String(props.show), }, - React.createElement('button', { type: 'button', onClick: props.onClose }, 'close tag modal'), - React.createElement('button', { type: 'button', onClick: props.onTagsChange }, 'refresh tags'), + React.createElement( + 'button', + { type: 'button', onClick: props.onClose }, + 'close tag modal', + ), + React.createElement( + 'button', + { type: 'button', onClick: props.onTagsChange }, + 'refresh tags', + ), ) } }, })) vi.mock('@/features/tag-management/components/tag-filter', () => ({ - TagFilter: ({ - onOpenTagManagement, - }: { - onOpenTagManagement: () => void - }) => ( + TagFilter: ({ onOpenTagManagement }: { onOpenTagManagement: () => void }) => ( <button type="button" onClick={onOpenTagManagement}> common.tag.placeholder </button> @@ -190,8 +196,12 @@ vi.mock('@/features/tag-management/components/tag-selector', () => ({ onTagsChange: () => void }) => ( <div data-testid="snippet-card-tags"> - <button type="button" onClick={onOpenTagManagement}>open card tags</button> - <button type="button" onClick={onTagsChange}>refresh card tags</button> + <button type="button" onClick={onOpenTagManagement}> + open card tags + </button> + <button type="button" onClick={onTagsChange}> + refresh card tags + </button> </div> ), })) @@ -225,27 +235,29 @@ const mockFetchNextPage = vi.fn() const mockSnippetListState = { data: { - pages: [{ - data: [ - { - id: 'snippet-1', - name: 'Sales Snippet', - description: 'Builds a sales follow-up.', - type: 'node', - is_published: true, - use_count: 12, - tags: [], - created_at: 1704067200, - created_by: 'creator-1', - updated_at: 1704153600, - updated_by: 'creator-2', - }, - ], - page: 1, - limit: 30, - total: 1, - has_more: false, - }], + pages: [ + { + data: [ + { + id: 'snippet-1', + name: 'Sales Snippet', + description: 'Builds a sales follow-up.', + type: 'node', + is_published: true, + use_count: 12, + tags: [], + created_at: 1704067200, + created_by: 'creator-1', + updated_at: 1704153600, + updated_by: 'creator-2', + }, + ], + page: 1, + limit: 30, + total: 1, + has_more: false, + }, + ], }, isLoading: false, isFetching: false, @@ -293,11 +305,16 @@ describe('SnippetList', () => { expect(screen.getByRole('link', { name: 'common.menus.apps' })).toHaveAttribute('href', '/apps') expect(screen.getByRole('heading', { name: 'workflow.tabs.snippets' })).toBeInTheDocument() expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }), + ).toBeInTheDocument() expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() expect(screen.getByPlaceholderText('workflow.tabs.searchSnippets')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'snippet.create' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: /Sales Snippet/ })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate') + expect(screen.getByRole('link', { name: /Sales Snippet/ })).toHaveAttribute( + 'href', + '/snippets/snippet-1/orchestrate', + ) expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument() }) @@ -307,10 +324,7 @@ describe('SnippetList', () => { const card = screen.getByRole('link', { name: /Sales Snippet/ }).closest('article') const grid = card?.parentElement - expect(grid).toHaveClass( - 'grid', - 'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', - ) + expect(grid).toHaveClass('grid', 'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]') }) it('passes creator, tag, and search filters to the snippets list query', () => { @@ -320,51 +334,67 @@ describe('SnippetList', () => { renderList() - expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith({ - page: 1, - limit: 30, - keyword: 'sales', - tag_ids: ['tag-1', 'tag-2'], - creator_ids: ['creator-1', 'creator-2'], - }, { - enabled: true, - }) + expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith( + { + page: 1, + limit: 30, + keyword: 'sales', + tag_ids: ['tag-1', 'tag-2'], + creator_ids: ['creator-1', 'creator-2'], + }, + { + enabled: true, + }, + ) }) it('does not pass published state to the snippets list query by default', () => { renderList() - expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith(expect.not.objectContaining({ - is_published: expect.any(Boolean), - }), { - enabled: true, - }) + expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith( + expect.not.objectContaining({ + is_published: expect.any(Boolean), + }), + { + enabled: true, + }, + ) }) it('passes published state when selecting the published filter', () => { renderList() - fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i })) + fireEvent.click( + screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }), + ) fireEvent.click(screen.getByRole('menuitemradio', { name: /workflow\.common\.published/i })) - expect(mockUseInfiniteSnippetList).toHaveBeenLastCalledWith(expect.objectContaining({ - is_published: true, - }), { - enabled: true, - }) + expect(mockUseInfiniteSnippetList).toHaveBeenLastCalledWith( + expect.objectContaining({ + is_published: true, + }), + { + enabled: true, + }, + ) }) it('passes draft state when selecting the draft filter', () => { renderList() - fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i })) + fireEvent.click( + screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }), + ) fireEvent.click(screen.getByRole('menuitemradio', { name: /snippet\.draft/i })) - expect(mockUseInfiniteSnippetList).toHaveBeenLastCalledWith(expect.objectContaining({ - is_published: false, - }), { - enabled: true, - }) + expect(mockUseInfiniteSnippetList).toHaveBeenLastCalledWith( + expect.objectContaining({ + is_published: false, + }), + { + enabled: true, + }, + ) }) it('updates the search query state from the search input', () => { @@ -439,13 +469,15 @@ describe('SnippetList', () => { mockUseInfiniteSnippetList.mockReturnValue({ ...mockSnippetListState, data: { - pages: [{ - data: [], - page: 1, - limit: 30, - total: 0, - has_more: false, - }], + pages: [ + { + data: [], + page: 1, + limit: 30, + total: 0, + has_more: false, + }, + ], }, refetch: mockRefetch, fetchNextPage: mockFetchNextPage, @@ -481,9 +513,10 @@ describe('SnippetList', () => { renderList() - intersectionCallback?.([ - { isIntersecting: true } as IntersectionObserverEntry, - ], {} as IntersectionObserver) + intersectionCallback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) expect(mockFetchNextPage).toHaveBeenCalledTimes(1) }) @@ -503,9 +536,10 @@ describe('SnippetList', () => { renderList() - intersectionCallback?.([ - { isIntersecting: true } as IntersectionObserverEntry, - ], {} as IntersectionObserver) + intersectionCallback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) expect(mockFetchNextPage).not.toHaveBeenCalled() }) diff --git a/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx b/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx index 94053d41d2d6ab..1e61ba2e68166c 100644 --- a/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx +++ b/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx @@ -68,7 +68,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -77,8 +78,28 @@ vi.mock('@/service/use-common', () => ({ useMembers: () => ({ data: { accounts: [ - { id: 'creator-id', name: 'Creator', email: 'creator@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' }, - { id: 'updater-id', name: 'Updater', email: 'updater@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' }, + { + id: 'creator-id', + name: 'Creator', + email: 'creator@example.com', + avatar: '', + avatar_url: null, + role: 'editor', + last_login_at: '', + created_at: '', + status: 'active', + }, + { + id: 'updater-id', + name: 'Updater', + email: 'updater@example.com', + avatar: '', + avatar_url: null, + role: 'editor', + last_login_at: '', + created_at: '', + status: 'active', + }, ], }, }), @@ -125,9 +146,13 @@ vi.mock('@/features/tag-management/components/tag-selector', () => ({ return ( <div data-testid="snippet-tags"> - <span>{value.map(tag => tag.name).join(', ')}</span> - <button type="button" onClick={onOpenTagManagement}>manage tags</button> - <button type="button" onClick={onTagsChange}>sync tags</button> + <span>{value.map((tag) => tag.name).join(', ')}</span> + <button type="button" onClick={onOpenTagManagement}> + manage tags + </button> + <button type="button" onClick={onTagsChange}> + sync tags + </button> </div> ) }, @@ -153,7 +178,10 @@ describe('SnippetCard', () => { beforeEach(() => { vi.clearAllMocks() mockIsCurrentWorkspaceEditor.mockReturnValue(true) - mockWorkspacePermissionKeys.mockReturnValue(['snippets.create_and_modify', 'snippets.management']) + mockWorkspacePermissionKeys.mockReturnValue([ + 'snippets.create_and_modify', + 'snippets.management', + ]) }) describe('Rendering', () => { @@ -176,7 +204,11 @@ describe('SnippetCard', () => { }) it('should fall back to unknown user when creator and updater are unavailable', () => { - render(<SnippetCard snippet={createSnippet({ created_by: 'missing-creator', updated_by: 'missing-updater' })} />) + render( + <SnippetCard + snippet={createSnippet({ created_by: 'missing-creator', updated_by: 'missing-updater' })} + />, + ) expect(screen.getByText('snippet.unknownUser')).toBeInTheDocument() }) @@ -192,9 +224,15 @@ describe('SnippetCard', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' })) - expect(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).toBeInTheDocument() + expect( + await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' }), + ).toBeInTheDocument() + expect( + screen.getByRole('menuitem', { name: 'snippet.menu.exportSnippet' }), + ).toBeInTheDocument() + expect( + screen.getByRole('menuitem', { name: 'snippet.menu.deleteSnippet' }), + ).toBeInTheDocument() expect(screen.queryByRole('menuitem', { name: /duplicate/i })).not.toBeInTheDocument() }) @@ -203,7 +241,9 @@ describe('SnippetCard', () => { render(<SnippetCard snippet={createSnippet()} />) - expect(screen.queryByRole('button', { name: 'common.operation.more' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.more' }), + ).not.toBeInTheDocument() }) it('should show edit info with create-and-modify permission without management actions', async () => { @@ -214,9 +254,15 @@ describe('SnippetCard', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' })) - expect(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).toBeInTheDocument() - expect(screen.queryByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).not.toBeInTheDocument() + expect( + await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' }), + ).toBeInTheDocument() + expect( + screen.getByRole('menuitem', { name: 'snippet.menu.exportSnippet' }), + ).toBeInTheDocument() + expect( + screen.queryByRole('menuitem', { name: 'snippet.menu.deleteSnippet' }), + ).not.toBeInTheDocument() }) it('should show delete with snippet management permission without create-and-modify actions', async () => { @@ -227,9 +273,15 @@ describe('SnippetCard', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' })) - expect(screen.queryByRole('menuitem', { name: 'snippet.menu.editInfo' })).not.toBeInTheDocument() - expect(screen.queryByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).not.toBeInTheDocument() - expect(await screen.findByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).toBeInTheDocument() + expect( + screen.queryByRole('menuitem', { name: 'snippet.menu.editInfo' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('menuitem', { name: 'snippet.menu.exportSnippet' }), + ).not.toBeInTheDocument() + expect( + await screen.findByRole('menuitem', { name: 'snippet.menu.deleteSnippet' }), + ).toBeInTheDocument() }) it('should pass snippet management permission to tag binding capability', () => { @@ -237,11 +289,13 @@ describe('SnippetCard', () => { render(<SnippetCard snippet={createSnippet()} />) - expect(mockRenderTagSelector).toHaveBeenCalledWith(expect.objectContaining({ - type: 'snippet', - targetId: 'snippet-1', - canBindOrUnbindTags: true, - })) + expect(mockRenderTagSelector).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'snippet', + targetId: 'snippet-1', + canBindOrUnbindTags: true, + }), + ) }) it('should disable tag binding capability without snippet management permission', () => { @@ -249,11 +303,13 @@ describe('SnippetCard', () => { render(<SnippetCard snippet={createSnippet()} />) - expect(mockRenderTagSelector).toHaveBeenCalledWith(expect.objectContaining({ - type: 'snippet', - targetId: 'snippet-1', - canBindOrUnbindTags: false, - })) + expect(mockRenderTagSelector).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'snippet', + targetId: 'snippet-1', + canBindOrUnbindTags: false, + }), + ) }) it('should forward tag selector actions without navigating the card link', () => { @@ -262,7 +318,9 @@ describe('SnippetCard', () => { render( <SnippetCard - snippet={createSnippet({ tags: [{ id: 'tag-1', name: 'Sales', type: 'snippet', binding_count: '' }] })} + snippet={createSnippet({ + tags: [{ id: 'tag-1', name: 'Sales', type: 'snippet', binding_count: '' }], + })} onOpenTagManagement={onOpenTagManagement} onTagsChange={onTagsChange} />, @@ -287,9 +345,11 @@ describe('SnippetCard', () => { await waitFor(() => { expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: 'snippet-1' }) - expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({ - fileName: 'Tone Rewriter.yml', - })) + expect(mockDownloadBlob).toHaveBeenCalledWith( + expect.objectContaining({ + fileName: 'Tone Rewriter.yml', + }), + ) }) }) @@ -323,24 +383,29 @@ describe('SnippetCard', () => { fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i })) await waitFor(() => { - expect(mockUpdateMutate).toHaveBeenCalledWith({ - params: { snippetId: 'snippet-1' }, - body: { - name: 'Updated Snippet', - description: 'Rewrites rough drafts.', + expect(mockUpdateMutate).toHaveBeenCalledWith( + { + params: { snippetId: 'snippet-1' }, + body: { + name: 'Updated Snippet', + description: 'Rewrites rough drafts.', + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) expect(mockOnRefresh).toHaveBeenCalled() }) }) it('should show update errors from the mutation callback', async () => { - mockUpdateMutate.mockImplementation((_payload, options?: { onError?: (error: Error) => void }) => { - options?.onError?.(new Error('Update failed')) - }) + mockUpdateMutate.mockImplementation( + (_payload, options?: { onError?: (error: Error) => void }) => { + options?.onError?.(new Error('Update failed')) + }, + ) render(<SnippetCard snippet={createSnippet({ description: '' })} onRefresh={mockOnRefresh} />) @@ -352,12 +417,15 @@ describe('SnippetCard', () => { fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i })) await waitFor(() => { - expect(mockUpdateMutate).toHaveBeenCalledWith(expect.objectContaining({ - body: { - name: 'Updated Snippet', - description: '', - }, - }), expect.any(Object)) + expect(mockUpdateMutate).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + name: 'Updated Snippet', + description: '', + }, + }), + expect.any(Object), + ) expect(mockToastError).toHaveBeenCalledWith('Update failed') }) expect(mockOnRefresh).not.toHaveBeenCalled() @@ -375,20 +443,25 @@ describe('SnippetCard', () => { fireEvent.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' })) await waitFor(() => { - expect(mockDeleteMutate).toHaveBeenCalledWith({ - params: { snippetId: 'snippet-1' }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - })) + expect(mockDeleteMutate).toHaveBeenCalledWith( + { + params: { snippetId: 'snippet-1' }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) expect(mockOnRefresh).toHaveBeenCalled() }) }) it('should show delete errors from the mutation callback', async () => { - mockDeleteMutate.mockImplementation((_payload, options?: { onError?: (error: Error) => void }) => { - options?.onError?.(new Error('Delete failed')) - }) + mockDeleteMutate.mockImplementation( + (_payload, options?: { onError?: (error: Error) => void }) => { + options?.onError?.(new Error('Delete failed')) + }, + ) render(<SnippetCard snippet={createSnippet()} onRefresh={mockOnRefresh} />) diff --git a/web/app/components/snippet-list/components/__tests__/snippet-create-button.spec.tsx b/web/app/components/snippet-list/components/__tests__/snippet-create-button.spec.tsx index 3b3a61142c69a5..5ad43164af0a6c 100644 --- a/web/app/components/snippet-list/components/__tests__/snippet-create-button.spec.tsx +++ b/web/app/components/snippet-list/components/__tests__/snippet-create-button.spec.tsx @@ -1,7 +1,16 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import SnippetCreateButton from '../snippet-create-button' -const { mockPush, mockCreateMutateAsync, mockSyncDraftWorkflow, mockImportMutateAsync, mockConfirmImportMutateAsync, mockToastSuccess, mockToastError, mockWorkspacePermissionKeys } = vi.hoisted(() => ({ +const { + mockPush, + mockCreateMutateAsync, + mockSyncDraftWorkflow, + mockImportMutateAsync, + mockConfirmImportMutateAsync, + mockToastSuccess, + mockToastError, + mockWorkspacePermissionKeys, +} = vi.hoisted(() => ({ mockPush: vi.fn(), mockCreateMutateAsync: vi.fn(), mockSyncDraftWorkflow: vi.fn(), @@ -62,7 +71,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -112,7 +122,11 @@ describe('SnippetCreateButton', () => { it('should open the create dialog and create a snippet from the modal', async () => { mockCreateMutateAsync.mockResolvedValue({ id: 'snippet-123' }) - mockSyncDraftWorkflow.mockResolvedValue({ result: 'success', hash: 'draft-hash', updated_at: 1704067200 }) + mockSyncDraftWorkflow.mockResolvedValue({ + result: 'success', + hash: 'draft-hash', + updated_at: 1704067200, + }) render(<SnippetCreateButton />) diff --git a/web/app/components/snippet-list/components/__tests__/snippet-publish-status-filter.spec.tsx b/web/app/components/snippet-list/components/__tests__/snippet-publish-status-filter.spec.tsx index c9ffbf9df0f76a..01e266205ed1f6 100644 --- a/web/app/components/snippet-list/components/__tests__/snippet-publish-status-filter.spec.tsx +++ b/web/app/components/snippet-list/components/__tests__/snippet-publish-status-filter.spec.tsx @@ -5,14 +5,18 @@ describe('SnippetPublishStatusFilter', () => { it('should render the default published and draft filter label', () => { render(<SnippetPublishStatusFilter value="all" onChange={vi.fn()} />) - expect(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }), + ).toBeInTheDocument() }) it('should emit the selected publish status from the dropdown', () => { const onChange = vi.fn() render(<SnippetPublishStatusFilter value="all" onChange={onChange} />) - fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i })) + fireEvent.click( + screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }), + ) fireEvent.click(screen.getByRole('menuitemradio', { name: /workflow\.common\.published/i })) expect(onChange).toHaveBeenCalledWith('published') diff --git a/web/app/components/snippet-list/components/snippet-card.tsx b/web/app/components/snippet-list/components/snippet-card.tsx index f4d19f218f2834..eea79ce775ca67 100644 --- a/web/app/components/snippet-list/components/snippet-card.tsx +++ b/web/app/components/snippet-list/components/snippet-card.tsx @@ -23,12 +23,19 @@ import { useAtomValue } from 'jotai' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog' -import { canCreateAndModifySnippets, canManageSnippets } from '@/app/components/snippets/utils/permission' +import { + canCreateAndModifySnippets, + canManageSnippets, +} from '@/app/components/snippets/utils/permission' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { TagSelector } from '@/features/tag-management/components/tag-selector' import Link from '@/next/link' import { useMembers } from '@/service/use-common' -import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets' +import { + useDeleteSnippetMutation, + useExportSnippetMutation, + useUpdateSnippetMutation, +} from '@/service/use-snippets' import { downloadBlob } from '@/utils/download' import { formatTime } from '@/utils/time' @@ -60,22 +67,26 @@ const SnippetCard = ({ const canShowOperations = canCreateAndModifySnippet || canManageSnippet const memberNameById = useMemo(() => { - return new Map((membersData?.accounts ?? []).map(member => [member.id, member.name])) + return new Map((membersData?.accounts ?? []).map((member) => [member.id, member.name])) }, [membersData?.accounts]) - const updatedByName = (snippet.updated_by ? memberNameById.get(snippet.updated_by) : undefined) - || (snippet.created_by ? memberNameById.get(snippet.created_by) : undefined) - || t($ => $.unknownUser) + const updatedByName = + (snippet.updated_by ? memberNameById.get(snippet.updated_by) : undefined) || + (snippet.created_by ? memberNameById.get(snippet.created_by) : undefined) || + t(($) => $.unknownUser) const updatedAt = snippet.updated_at || snippet.created_at const updatedAtText = formatTime({ - date: (updatedAt > 1_000_000_000_000 ? updatedAt : updatedAt * 1000), - dateFormat: `${t($ => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, + date: updatedAt > 1_000_000_000_000 ? updatedAt : updatedAt * 1000, + dateFormat: `${t(($) => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, }) - const initialValue = useMemo(() => ({ - name: snippet.name, - description: snippet.description ?? undefined, - }), [snippet.description, snippet.name]) + const initialValue = useMemo( + () => ({ + name: snippet.name, + description: snippet.description ?? undefined, + }), + [snippet.description, snippet.name], + ) const handleOpenEditDialog = () => { setIsOperationsMenuOpen(false) @@ -83,55 +94,56 @@ const SnippetCard = ({ } const handleExportSnippet = async () => { - if (!canCreateAndModifySnippet) - return + if (!canCreateAndModifySnippet) return setIsOperationsMenuOpen(false) try { const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id }) const file = new Blob([data], { type: 'application/yaml' }) downloadBlob({ data: file, fileName: `${snippet.name}.yml` }) - } - catch { - toast.error(t($ => $.exportFailed)) + } catch { + toast.error(t(($) => $.exportFailed)) } } const handleDeleteSnippet = () => { - deleteSnippetMutation.mutate({ - params: { snippetId: snippet.id }, - }, { - onSuccess: () => { - toast.success(t($ => $.deleted)) - setIsDeleteDialogOpen(false) - onRefresh?.() + deleteSnippetMutation.mutate( + { + params: { snippetId: snippet.id }, }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : t($ => $.deleteFailed)) + { + onSuccess: () => { + toast.success(t(($) => $.deleted)) + setIsDeleteDialogOpen(false) + onRefresh?.() + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : t(($) => $.deleteFailed)) + }, }, - }) + ) } - const handleUpdateSnippet = ({ name, description }: { - name: string - description: string - }) => { - updateSnippetMutation.mutate({ - params: { snippetId: snippet.id }, - body: { - name, - description, - }, - }, { - onSuccess: () => { - toast.success(t($ => $.editDone)) - setIsEditDialogOpen(false) - onRefresh?.() + const handleUpdateSnippet = ({ name, description }: { name: string; description: string }) => { + updateSnippetMutation.mutate( + { + params: { snippetId: snippet.id }, + body: { + name, + description, + }, }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : t($ => $.editFailed)) + { + onSuccess: () => { + toast.success(t(($) => $.editDone)) + setIsEditDialogOpen(false) + onRefresh?.() + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : t(($) => $.editFailed)) + }, }, - }) + ) } return ( @@ -140,12 +152,18 @@ const SnippetCard = ({ <Link href={`/snippets/${snippet.id}/orchestrate`} className="flex min-h-0 flex-1 flex-col"> <div className="flex h-16.5 shrink-0 grow-0 flex-col justify-center px-3.5 pt-3.5 pb-3"> <div className="flex items-center text-sm/5 font-semibold text-text-secondary"> - <div className="truncate" title={snippet.name}>{snippet.name}</div> + <div className="truncate" title={snippet.name}> + {snippet.name} + </div> </div> <div className="flex items-center gap-1 text-2xs leading-4.5 font-medium text-text-tertiary"> - <div className="truncate" title={updatedByName}>{updatedByName}</div> + <div className="truncate" title={updatedByName}> + {updatedByName} + </div> <div>·</div> - <div className="truncate" title={updatedAtText}>{updatedAtText}</div> + <div className="truncate" title={updatedAtText}> + {updatedAtText} + </div> </div> </div> <div className="h-22.5 px-3.5 text-xs leading-normal text-text-tertiary"> @@ -179,9 +197,13 @@ const SnippetCard = ({ )} > <div className="mx-1 h-3.5 w-px shrink-0 bg-divider-regular" /> - <DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}> + <DropdownMenu + modal={false} + open={isOperationsMenuOpen} + onOpenChange={setIsOperationsMenuOpen} + > <DropdownMenuTrigger - aria-label={tCommon($ => $['operation.more'], { ns: 'common' })} + aria-label={tCommon(($) => $['operation.more'], { ns: 'common' })} className="flex size-8 items-center justify-center rounded-md border-none bg-transparent p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-active data-popup-open:bg-state-base-hover data-popup-open:shadow-none" onClick={(e) => { e.stopPropagation() @@ -189,7 +211,9 @@ const SnippetCard = ({ }} > <div className="flex size-8 cursor-pointer items-center justify-center rounded-md"> - <span className="sr-only">{tCommon($ => $['operation.more'], { ns: 'common' })}</span> + <span className="sr-only"> + {tCommon(($) => $['operation.more'], { ns: 'common' })} + </span> <span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" /> </div> </DropdownMenuTrigger> @@ -201,10 +225,14 @@ const SnippetCard = ({ {canCreateAndModifySnippet && ( <> <DropdownMenuItem className="gap-2 px-3" onClick={handleOpenEditDialog}> - <span className="system-sm-regular text-text-secondary">{t($ => $['menu.editInfo'])}</span> + <span className="system-sm-regular text-text-secondary"> + {t(($) => $['menu.editInfo'])} + </span> </DropdownMenuItem> <DropdownMenuItem className="gap-2 px-3" onClick={handleExportSnippet}> - <span className="system-sm-regular text-text-secondary">{t($ => $['menu.exportSnippet'])}</span> + <span className="system-sm-regular text-text-secondary"> + {t(($) => $['menu.exportSnippet'])} + </span> </DropdownMenuItem> </> )} @@ -219,7 +247,9 @@ const SnippetCard = ({ setIsDeleteDialogOpen(true) }} > - <span className="system-sm-regular">{t($ => $['menu.deleteSnippet'])}</span> + <span className="system-sm-regular"> + {t(($) => $['menu.deleteSnippet'])} + </span> </DropdownMenuItem> </> )} @@ -233,8 +263,8 @@ const SnippetCard = ({ <CreateSnippetDialog isOpen={isEditDialogOpen} initialValue={initialValue} - title={t($ => $.editDialogTitle)} - confirmText={tCommon($ => $['operation.save'], { ns: 'common' })} + title={t(($) => $.editDialogTitle)} + confirmText={tCommon(($) => $['operation.save'], { ns: 'common' })} isSubmitting={updateSnippetMutation.isPending} onClose={() => setIsEditDialogOpen(false)} onConfirm={handleUpdateSnippet} @@ -244,21 +274,21 @@ const SnippetCard = ({ <AlertDialogContent className="w-100"> <div className="space-y-2 p-6"> <AlertDialogTitle className="title-md-semi-bold text-text-primary"> - {t($ => $.deleteConfirmTitle)} + {t(($) => $.deleteConfirmTitle)} </AlertDialogTitle> <AlertDialogDescription className="system-sm-regular text-text-tertiary"> - {t($ => $.deleteConfirmContent)} + {t(($) => $.deleteConfirmContent)} </AlertDialogDescription> </div> <AlertDialogActions className="pt-0"> <AlertDialogCancelButton disabled={deleteSnippetMutation.isPending}> - {tCommon($ => $['operation.cancel'], { ns: 'common' })} + {tCommon(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton loading={deleteSnippetMutation.isPending} onClick={handleDeleteSnippet} > - {t($ => $['menu.deleteSnippet'])} + {t(($) => $['menu.deleteSnippet'])} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/snippet-list/components/snippet-create-button.tsx b/web/app/components/snippet-list/components/snippet-create-button.tsx index 50f7b549e54251..7819cda3d50ab7 100644 --- a/web/app/components/snippet-list/components/snippet-create-button.tsx +++ b/web/app/components/snippet-list/components/snippet-create-button.tsx @@ -1,11 +1,7 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useState } from 'react' import { useTranslation } from 'react-i18next' import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog' @@ -25,20 +21,19 @@ const SnippetCreateButton = () => { const [isMenuOpen, setIsMenuOpen] = useState(false) const isSubmitting = isCreatingSnippet || createSnippetMutation.isPending - if (!canCreateAndModifySnippet) - return null + if (!canCreateAndModifySnippet) return null return ( <> <Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}> <PopoverTrigger - render={( + render={ <Button disabled={isSubmitting}> <span aria-hidden className="mr-0.5 i-ri-add-line size-4" /> - <span>{t($ => $.create)}</span> + <span>{t(($) => $.create)}</span> <span aria-hidden className="ml-0.5 i-ri-arrow-down-s-line size-4" /> </Button> - )} + } /> <PopoverContent placement="bottom-end" @@ -46,7 +41,7 @@ const SnippetCreateButton = () => { popupClassName="w-[228px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs" > <div className="px-2 pt-2 pb-1 text-xs leading-4.5 font-medium text-text-tertiary"> - {t($ => $.createFrom)} + {t(($) => $.createFrom)} </div> <button type="button" @@ -56,8 +51,11 @@ const SnippetCreateButton = () => { setIsCreateDialogOpen(true) }} > - <span aria-hidden className="mr-2 i-custom-vender-line-files-file-plus-01 size-4 shrink-0" /> - <span>{t($ => $.createFromBlank)}</span> + <span + aria-hidden + className="mr-2 i-custom-vender-line-files-file-plus-01 size-4 shrink-0" + /> + <span>{t(($) => $.createFromBlank)}</span> </button> <button type="button" @@ -67,8 +65,11 @@ const SnippetCreateButton = () => { setIsImportDialogOpen(true) }} > - <span aria-hidden className="mr-2 i-custom-vender-line-files-file-arrow-01 size-4 shrink-0" /> - <span>{t($ => $.importDSLFile)}</span> + <span + aria-hidden + className="mr-2 i-custom-vender-line-files-file-arrow-01 size-4 shrink-0" + /> + <span>{t(($) => $.importDSLFile)}</span> </button> </PopoverContent> </Popover> diff --git a/web/app/components/snippet-list/components/snippet-publish-status-filter.tsx b/web/app/components/snippet-list/components/snippet-publish-status-filter.tsx index 571679affae4ca..399630415189d2 100644 --- a/web/app/components/snippet-list/components/snippet-publish-status-filter.tsx +++ b/web/app/components/snippet-list/components/snippet-publish-status-filter.tsx @@ -19,34 +19,36 @@ type SnippetPublishStatusFilterProps = { onChange: (value: SnippetPublishStatus) => void } -const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const chipClassName = + 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid' const snippetPublishStatusValues: SnippetPublishStatus[] = ['all', 'published', 'draft'] const isSnippetPublishStatus = (value: string): value is SnippetPublishStatus => { return snippetPublishStatusValues.includes(value as SnippetPublishStatus) } -const SnippetPublishStatusFilter = ({ - value, - onChange, -}: SnippetPublishStatusFilterProps) => { +const SnippetPublishStatusFilter = ({ value, onChange }: SnippetPublishStatusFilterProps) => { const { t } = useTranslation() - const options = useMemo(() => ([ - { value: 'all', text: t($ => $['types.all'], { ns: 'app' }) }, - { value: 'published', text: t($ => $['common.published'], { ns: 'workflow' }) }, - { value: 'draft', text: t($ => $.draft, { ns: 'snippet' }) }, - ] satisfies Array<{ value: SnippetPublishStatus, text: string }>), [t]) + const options = useMemo( + () => + [ + { value: 'all', text: t(($) => $['types.all'], { ns: 'app' }) }, + { value: 'published', text: t(($) => $['common.published'], { ns: 'workflow' }) }, + { value: 'draft', text: t(($) => $.draft, { ns: 'snippet' }) }, + ] satisfies Array<{ value: SnippetPublishStatus; text: string }>, + [t], + ) - const activeOption = options.find(option => option.value === value) + const activeOption = options.find((option) => option.value === value) const isSelected = value !== 'all' - const defaultLabel = `${t($ => $['common.published'], { ns: 'workflow' })} / ${t($ => $.draft, { ns: 'snippet' })}` + const defaultLabel = `${t(($) => $['common.published'], { ns: 'workflow' })} / ${t(($) => $.draft, { ns: 'snippet' })}` const triggerLabel = isSelected ? activeOption?.text : defaultLabel return ( <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <button type="button" className={cn( @@ -56,14 +58,17 @@ const SnippetPublishStatusFilter = ({ : 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover', )} /> - )} + } > <span className="px-1 text-text-tertiary">{triggerLabel}</span> <span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" /> </DropdownMenuTrigger> <DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]"> - <DropdownMenuRadioGroup value={value} onValueChange={nextValue => isSnippetPublishStatus(nextValue) && onChange(nextValue)}> - {options.map(option => ( + <DropdownMenuRadioGroup + value={value} + onValueChange={(nextValue) => isSnippetPublishStatus(nextValue) && onChange(nextValue)} + > + {options.map((option) => ( <DropdownMenuRadioItem key={option.value} value={option.value} closeOnClick> <span>{option.text}</span> <DropdownMenuRadioItemIndicator /> diff --git a/web/app/components/snippet-list/hooks/__tests__/use-snippets-query-state.spec.tsx b/web/app/components/snippet-list/hooks/__tests__/use-snippets-query-state.spec.tsx index 57e9e0c583a7e7..289c917a84e499 100644 --- a/web/app/components/snippet-list/hooks/__tests__/use-snippets-query-state.spec.tsx +++ b/web/app/components/snippet-list/hooks/__tests__/use-snippets-query-state.spec.tsx @@ -68,8 +68,7 @@ describe('useSnippetsQueryState', () => { expect(onUrlUpdate).toHaveBeenCalled() const update = onUrlUpdate.mock.calls.at(-1)![0] expect(update.searchParams.get('keywords')).toBe('search') - } - finally { + } finally { vi.useRealTimers() } }) diff --git a/web/app/components/snippet-list/hooks/use-snippets-query-state.ts b/web/app/components/snippet-list/hooks/use-snippets-query-state.ts index 17146832333683..a39a8f39335efe 100644 --- a/web/app/components/snippet-list/hooks/use-snippets-query-state.ts +++ b/web/app/components/snippet-list/hooks/use-snippets-query-state.ts @@ -3,9 +3,7 @@ import { useCallback, useMemo, useState } from 'react' import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from '../constants' const snippetListQueryParsers = { - tagIDs: parseAsArrayOf(parseAsString, ';') - .withDefault([]) - .withOptions({ history: 'push' }), + tagIDs: parseAsArrayOf(parseAsString, ';').withDefault([]).withOptions({ history: 'push' }), keywords: parseAsString.withDefault('').withOptions({ limitUrlUpdates: debounce(SNIPPET_LIST_SEARCH_DEBOUNCE_MS), }), @@ -15,27 +13,39 @@ export function useSnippetsQueryState() { const [urlQuery, setUrlQuery] = useQueryStates(snippetListQueryParsers) const [creatorIDs, setCreatorIDs] = useState<string[]>([]) - const setKeywords = useCallback((keywords: string) => { - setUrlQuery({ keywords }) - }, [setUrlQuery]) + const setKeywords = useCallback( + (keywords: string) => { + setUrlQuery({ keywords }) + }, + [setUrlQuery], + ) - const setTagIDs = useCallback((tagIDs: string[]) => { - setUrlQuery({ tagIDs }) - }, [setUrlQuery]) + const setTagIDs = useCallback( + (tagIDs: string[]) => { + setUrlQuery({ tagIDs }) + }, + [setUrlQuery], + ) const handleSetCreatorIDs = useCallback((creatorIDs: string[]) => { setCreatorIDs(creatorIDs) }, []) - const query = useMemo(() => ({ - ...urlQuery, - creatorIDs, - }), [creatorIDs, urlQuery]) + const query = useMemo( + () => ({ + ...urlQuery, + creatorIDs, + }), + [creatorIDs, urlQuery], + ) - return useMemo(() => ({ - query, - setKeywords, - setTagIDs, - setCreatorIDs: handleSetCreatorIDs, - }), [handleSetCreatorIDs, query, setKeywords, setTagIDs]) + return useMemo( + () => ({ + query, + setKeywords, + setTagIDs, + setCreatorIDs: handleSetCreatorIDs, + }), + [handleSetCreatorIDs, query, setKeywords, setTagIDs], + ) } diff --git a/web/app/components/snippet-list/index.tsx b/web/app/components/snippet-list/index.tsx index 95d58ef2b0b626..4d4675907a738f 100644 --- a/web/app/components/snippet-list/index.tsx +++ b/web/app/components/snippet-list/index.tsx @@ -25,17 +25,21 @@ import SnippetPublishStatusFilter from './components/snippet-publish-status-filt import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from './constants' import { useSnippetsQueryState } from './hooks/use-snippets-query-state' -const TagManagementModal = dynamic(() => import('@/features/tag-management/components/tag-management-modal').then(mod => mod.TagManagementModal), { - ssr: false, -}) +const TagManagementModal = dynamic( + () => + import('@/features/tag-management/components/tag-management-modal').then( + (mod) => mod.TagManagementModal, + ), + { + ssr: false, + }, +) const SNIPPET_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth'] const toSnippetPublishedQuery = (publishStatus: SnippetPublishStatus) => { - if (publishStatus === 'published') - return true - if (publishStatus === 'draft') - return false + if (publishStatus === 'published') return true + if (publishStatus === 'draft') return false return undefined } @@ -46,7 +50,7 @@ type SnippetCardSkeletonProps = { const SnippetCardSkeleton = ({ count }: SnippetCardSkeletonProps) => { return ( <> - {SNIPPET_CARD_SKELETON_KEYS.slice(0, count).map(key => ( + {SNIPPET_CARD_SKELETON_KEYS.slice(0, count).map((key) => ( <div key={key} className="col-span-1 h-55 animate-pulse rounded-xl bg-background-default-lighter" @@ -73,7 +77,7 @@ const SnippetList = () => { const [showTagManagementModal, setShowTagManagementModal] = useState(false) const [publishStatus, setPublishStatus] = useState<SnippetPublishStatus>('all') - useDocumentTitle(t($ => $['tabs.snippets'], { ns: 'workflow' })) + useDocumentTitle(t(($) => $['tabs.snippets'], { ns: 'workflow' })) const snippetListQuery = useMemo(() => { const isPublished = toSnippetPublishedQuery(publishStatus) @@ -103,15 +107,13 @@ const SnippetList = () => { }) useEffect(() => { - if (!canQuerySnippetList) - return + if (!canQuerySnippetList) return const hasMore = hasNextPage ?? true let observer: IntersectionObserver | undefined if (error) { - if (observer) - observer.disconnect() + if (observer) observer.disconnect() return } @@ -119,66 +121,82 @@ const SnippetList = () => { const containerHeight = containerRef.current.clientHeight const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) - observer = new IntersectionObserver((entries) => { - if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore) - fetchNextPage() - }, { - root: containerRef.current, - rootMargin: `${dynamicMargin}px`, - threshold: 0.1, - }) + observer = new IntersectionObserver( + (entries) => { + if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore) + fetchNextPage() + }, + { + root: containerRef.current, + rootMargin: `${dynamicMargin}px`, + threshold: 0.1, + }, + ) observer.observe(anchorRef.current) } return () => observer?.disconnect() }, [canQuerySnippetList, error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading]) - const pages = useMemo(() => canQuerySnippetList ? data?.pages ?? [] : [], [canQuerySnippetList, data?.pages]) - const snippets = useMemo<SnippetListItem[]>(() => pages.flatMap(({ data: pageSnippets }) => pageSnippets), [pages]) + const pages = useMemo( + () => (canQuerySnippetList ? (data?.pages ?? []) : []), + [canQuerySnippetList, data?.pages], + ) + const snippets = useMemo<SnippetListItem[]>( + () => pages.flatMap(({ data: pageSnippets }) => pageSnippets), + [pages], + ) const hasAnySnippet = (pages[0]?.total ?? 0) > 0 - const showSkeleton = isLoadingCurrentWorkspace || (canQuerySnippetList && (isLoading || (isFetching && pages.length === 0))) + const showSkeleton = + isLoadingCurrentWorkspace || + (canQuerySnippetList && (isLoading || (isFetching && pages.length === 0))) return ( - <div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body"> + <div + ref={containerRef} + className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body" + > <StudioListHeader - title={( + title={ <> <Link href="/apps" className="min-w-0 truncate text-[18px]/[21.6px] font-semibold text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid" > - {t($ => $['menus.apps'], { ns: 'common' })} + {t(($) => $['menus.apps'], { ns: 'common' })} </Link> <span className="mx-1.5 shrink-0 font-light text-divider-deep">/</span> <h1 className="min-w-0 truncate text-[18px]/[21.6px] font-semibold text-text-primary"> - {t($ => $['tabs.snippets'], { ns: 'workflow' })} + {t(($) => $['tabs.snippets'], { ns: 'workflow' })} </h1> </> - )} + } > <div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center gap-2"> - <CreatorsFilter - value={creatorIDs} - onChange={setCreatorIDs} - /> - <SnippetPublishStatusFilter - value={publishStatus} - onChange={setPublishStatus} + <CreatorsFilter value={creatorIDs} onChange={setCreatorIDs} /> + <SnippetPublishStatusFilter value={publishStatus} onChange={setPublishStatus} /> + <TagFilter + type="snippet" + value={tagIDs} + onChange={setTagIDs} + onOpenTagManagement={() => setShowTagManagementModal(true)} /> - <TagFilter type="snippet" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} /> <div className="relative w-50"> - <span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" /> + <span + aria-hidden + className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" + /> <Input className={cn('pl-6.5', keywords && 'pr-6.5')} value={keywords} - onChange={e => setKeywords(e.target.value)} - placeholder={t($ => $['tabs.searchSnippets'], { ns: 'workflow' })} + onChange={(e) => setKeywords(e.target.value)} + placeholder={t(($) => $['tabs.searchSnippets'], { ns: 'workflow' })} /> {!!keywords && ( <button type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled" onClick={() => setKeywords('')} > @@ -190,29 +208,32 @@ const SnippetList = () => { <SnippetCreateButton /> </div> </StudioListHeader> - <div className={cn( - 'relative grid grow grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-4 px-8 pt-2', - !hasAnySnippet && 'overflow-hidden', - )} + <div + className={cn( + 'relative grid grow grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-4 px-8 pt-2', + !hasAnySnippet && 'overflow-hidden', + )} > - {showSkeleton - ? <SnippetCardSkeleton count={6} /> - : hasAnySnippet - ? snippets.map(snippet => ( - <SnippetCard - key={snippet.id} - snippet={snippet} - onOpenTagManagement={() => setShowTagManagementModal(true)} - onRefresh={refetch} - onTagsChange={refetch} - /> - )) - : <Empty message={t($ => $['tabs.noSnippetsFound'], { ns: 'workflow' })} />} - {isFetchingNextPage && ( - <SnippetCardSkeleton count={3} /> + {showSkeleton ? ( + <SnippetCardSkeleton count={6} /> + ) : hasAnySnippet ? ( + snippets.map((snippet) => ( + <SnippetCard + key={snippet.id} + snippet={snippet} + onOpenTagManagement={() => setShowTagManagementModal(true)} + onRefresh={refetch} + onTagsChange={refetch} + /> + )) + ) : ( + <Empty message={t(($) => $['tabs.noSnippetsFound'], { ns: 'workflow' })} /> )} + {isFetchingNextPage && <SnippetCardSkeleton count={3} />} + </div> + <div ref={anchorRef} className="h-0"> + {' '} </div> - <div ref={anchorRef} className="h-0"> </div> <TagManagementModal type="snippet" show={showTagManagementModal} diff --git a/web/app/components/snippets/__tests__/create-snippet-dialog.spec.tsx b/web/app/components/snippets/__tests__/create-snippet-dialog.spec.tsx index c3c65fb3c61cf5..51fcf1e8976acd 100644 --- a/web/app/components/snippets/__tests__/create-snippet-dialog.spec.tsx +++ b/web/app/components/snippets/__tests__/create-snippet-dialog.spec.tsx @@ -49,8 +49,14 @@ describe('CreateSnippetDialog', () => { />, ) - await user.type(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), ' Support snippet ') - await user.type(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), ' Helps agents ') + await user.type( + screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), + ' Support snippet ', + ) + await user.type( + screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), + ' Helps agents ', + ) await user.click(screen.getByRole('button', { name: 'workflow.snippet.confirm' })) expect(onConfirm).toHaveBeenCalledWith({ @@ -109,7 +115,10 @@ describe('CreateSnippetDialog', () => { expect(screen.getByText('Save as snippet')).toBeInTheDocument() - await user.type(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), 'Simple snippet') + await user.type( + screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), + 'Simple snippet', + ) await user.click(screen.getByRole('button', { name: 'Create now' })) expect(onConfirm).toHaveBeenCalledWith({ @@ -164,9 +173,11 @@ describe('CreateSnippetDialog', () => { capturedKeyPressHandler?.() - expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ - name: 'Keyboard snippet', - })) + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Keyboard snippet', + }), + ) }) it('should disable form controls while submitting', () => { diff --git a/web/app/components/snippets/__tests__/import-snippet-dsl-dialog.spec.tsx b/web/app/components/snippets/__tests__/import-snippet-dsl-dialog.spec.tsx index 455dff64ef2e29..23a83185faf03e 100644 --- a/web/app/components/snippets/__tests__/import-snippet-dsl-dialog.spec.tsx +++ b/web/app/components/snippets/__tests__/import-snippet-dsl-dialog.spec.tsx @@ -61,7 +61,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -82,13 +83,7 @@ vi.mock('@/service/use-snippets', () => ({ })) vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ - default: ({ - file, - updateFile, - }: { - file?: File - updateFile: (file?: File) => void - }) => ( + default: ({ file, updateFile }: { file?: File; updateFile: (file?: File) => void }) => ( <button type="button" onClick={() => updateFile(new File(['name: snippet'], 'snippet.yml'))}> {file?.name || 'select-dsl-file'} </button> @@ -114,7 +109,10 @@ describe('ImportSnippetDSLDialog', () => { render(<ImportSnippetDSLDialog isOpen onClose={onClose} />) await user.click(screen.getByRole('button', { name: 'snippet.importFromDSLUrl' })) - await user.type(screen.getByPlaceholderText('snippet.importFromDSLUrlPlaceholder'), 'https://example.com/snippet.yml') + await user.type( + screen.getByPlaceholderText('snippet.importFromDSLUrlPlaceholder'), + 'https://example.com/snippet.yml', + ) await user.click(screen.getByRole('button', { name: 'common.operation.create' })) await waitFor(() => { @@ -185,7 +183,10 @@ describe('ImportSnippetDSLDialog', () => { render(<ImportSnippetDSLDialog isOpen onClose={vi.fn()} />) await user.click(screen.getByRole('button', { name: 'snippet.importFromDSLUrl' })) - await user.type(screen.getByPlaceholderText('snippet.importFromDSLUrlPlaceholder'), 'https://example.com/snippet.yml') + await user.type( + screen.getByPlaceholderText('snippet.importFromDSLUrlPlaceholder'), + 'https://example.com/snippet.yml', + ) await user.click(screen.getByRole('button', { name: 'common.operation.create' })) expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled() diff --git a/web/app/components/snippets/__tests__/index.spec.tsx b/web/app/components/snippets/__tests__/index.spec.tsx index 51d2c9a6727585..85cb01ba3ca2ab 100644 --- a/web/app/components/snippets/__tests__/index.spec.tsx +++ b/web/app/components/snippets/__tests__/index.spec.tsx @@ -3,10 +3,12 @@ import { render, screen } from '@testing-library/react' import SnippetPage from '..' const mockUseSnippetInit = vi.fn() -let capturedWorkflowDefaultContextProps: { - nodes: unknown[] - edges: unknown[] -} | undefined +let capturedWorkflowDefaultContextProps: + | { + nodes: unknown[] + edges: unknown[] + } + | undefined vi.mock('../hooks/use-snippet-init', () => ({ useSnippetInit: (snippetId: string) => mockUseSnippetInit(snippetId), @@ -53,9 +55,7 @@ vi.mock('@/app/components/workflow', () => ({ edges, } - return ( - <div data-testid="workflow-default-context">{children}</div> - ) + return <div data-testid="workflow-default-context">{children}</div> }, })) @@ -91,8 +91,10 @@ vi.mock('@/app/components/app-sidebar', () => ({ })) vi.mock('@/app/components/app-sidebar/nav-link', () => ({ - default: ({ name, onClick }: { name: string, onClick?: () => void }) => ( - <button type="button" onClick={onClick}>{name}</button> + default: ({ name, onClick }: { name: string; onClick?: () => void }) => ( + <button type="button" onClick={onClick}> + {name} + </button> ), })) @@ -112,16 +114,20 @@ const createSnippetDetailPayload = (nodeId: string, edgeId: string): SnippetDeta }, graph: { viewport: { x: 0, y: 0, zoom: 1 }, - nodes: [{ - id: nodeId, - position: { x: 0, y: 0 }, - data: { title: nodeId }, - }] as SnippetDetailPayload['graph']['nodes'], - edges: [{ - id: edgeId, - source: nodeId, - target: `${nodeId}-target`, - }] as SnippetDetailPayload['graph']['edges'], + nodes: [ + { + id: nodeId, + position: { x: 0, y: 0 }, + data: { title: nodeId }, + }, + ] as SnippetDetailPayload['graph']['nodes'], + edges: [ + { + id: edgeId, + source: nodeId, + target: `${nodeId}-target`, + }, + ] as SnippetDetailPayload['graph']['edges'], }, inputFields: [], uiMeta: { @@ -167,8 +173,12 @@ describe('SnippetPage', () => { it('should initialize workflow context with published graph data when the published workflow exists', () => { render(<SnippetPage snippetId="snippet-1" />) - expect(capturedWorkflowDefaultContextProps?.nodes).toEqual(mockPublishedSnippetDetail.graph.nodes) - expect(capturedWorkflowDefaultContextProps?.edges).toEqual(mockPublishedSnippetDetail.graph.edges) + expect(capturedWorkflowDefaultContextProps?.nodes).toEqual( + mockPublishedSnippetDetail.graph.nodes, + ) + expect(capturedWorkflowDefaultContextProps?.edges).toEqual( + mockPublishedSnippetDetail.graph.edges, + ) expect(screen.getByTestId('snippet-main')).toHaveTextContent('snippet-1:true') }) diff --git a/web/app/components/snippets/components/__tests__/snippet-children.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-children.spec.tsx index acdf92eed2c0a0..5fdf82a20e394f 100644 --- a/web/app/components/snippets/components/__tests__/snippet-children.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-children.spec.tsx @@ -57,13 +57,15 @@ describe('SnippetChildren', () => { expect(screen.getByTestId('snippet-header')).toBeInTheDocument() expect(screen.getByTestId('snippet-workflow-panel')).toBeInTheDocument() - expect(capturedHeaderProps).toEqual(expect.objectContaining({ - snippetId: 'snippet-1', - canSave: true, - canEdit: true, - isPublishing: false, - ...callbacks, - })) + expect(capturedHeaderProps).toEqual( + expect.objectContaining({ + snippetId: 'snippet-1', + canSave: true, + canEdit: true, + isPublishing: false, + ...callbacks, + }), + ) expect(capturedWorkflowPanelProps).toEqual({ snippetId: 'snippet-1', fields, diff --git a/web/app/components/snippets/components/__tests__/snippet-collapsed-preview.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-collapsed-preview.spec.tsx index ab07e4f94440fb..cad5d2d6ceaa5d 100644 --- a/web/app/components/snippets/components/__tests__/snippet-collapsed-preview.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-collapsed-preview.spec.tsx @@ -5,7 +5,10 @@ describe('SnippetCollapsedPreview', () => { it('should render collapsed route navigation and input field count', () => { render(<SnippetCollapsedPreview inputFieldCount={2} snippetId="snippet-1" />) - expect(screen.getByRole('link', { name: 'snippet.sectionOrchestrate' })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate') + expect(screen.getByRole('link', { name: 'snippet.sectionOrchestrate' })).toHaveAttribute( + 'href', + '/snippets/snippet-1/orchestrate', + ) expect(screen.getByLabelText('2 snippet.inputVariables')).toHaveTextContent('2') }) }) diff --git a/web/app/components/snippets/components/__tests__/snippet-layout.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-layout.spec.tsx index f5d3404f51d9ff..724ec0b02c9f10 100644 --- a/web/app/components/snippets/components/__tests__/snippet-layout.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-layout.spec.tsx @@ -28,11 +28,7 @@ describe('SnippetLayout', () => { describe('Document title', () => { it('should set the document title to the snippet name when snippet detail is available', () => { render( - <SnippetLayout - snippetId="snippet-1" - snippet={createSnippet()} - section="orchestrate" - > + <SnippetLayout snippetId="snippet-1" snippet={createSnippet()} section="orchestrate"> <div>content</div> </SnippetLayout>, ) @@ -44,17 +40,15 @@ describe('SnippetLayout', () => { describe('Layout', () => { it('should render the detail content without owning sidebar navigation', () => { render( - <SnippetLayout - snippetId="snippet-1" - snippet={createSnippet()} - section="orchestrate" - > + <SnippetLayout snippetId="snippet-1" snippet={createSnippet()} section="orchestrate"> <div>content</div> </SnippetLayout>, ) expect(screen.getByText('content')).toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'snippet.sectionOrchestrate' })).not.toBeInTheDocument() + expect( + screen.queryByRole('link', { name: 'snippet.sectionOrchestrate' }), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx index 4818f0920d9477..93fef61b3a91f0 100644 --- a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx @@ -89,7 +89,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -106,7 +107,8 @@ let snippetDetailStoreState: { } vi.mock('@/app/components/snippets/store', () => ({ - useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState), + useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => + selector(snippetDetailStoreState), })) vi.mock('@/service/use-snippet-workflows', () => ({ @@ -188,9 +190,7 @@ vi.mock('@/app/components/workflow', () => ({ capturedHooksStore = hooksStore capturedWorkflowNodes = nodes - return ( - <div data-testid="workflow-inner-context">{children}</div> - ) + return <div data-testid="workflow-inner-context">{children}</div> }, })) @@ -206,7 +206,11 @@ vi.mock('@/app/components/snippets/components/snippet-children', () => ({ }) => ( <div> <a href="/snippets">snippets list</a> - {canEdit && <button type="button" disabled={!canSave} onClick={onPublish}>publish</button>} + {canEdit && ( + <button type="button" disabled={!canSave} onClick={onPublish}> + publish + </button> + )} </div> ), })) @@ -283,11 +287,12 @@ const createNodeMetadata = (type: BlockEnum) => ({ checkValid: vi.fn(), }) -const createDraftNode = (id = 'draft-node') => ({ - id, - position: { x: 10, y: 20 }, - data: { type: BlockEnum.Code, title: 'Draft node' }, -}) as WorkflowProps['nodes'][number] +const createDraftNode = (id = 'draft-node') => + ({ + id, + position: { x: 10, y: 20 }, + data: { type: BlockEnum.Code, title: 'Draft node' }, + }) as WorkflowProps['nodes'][number] describe('SnippetMain', () => { beforeEach(() => { @@ -341,7 +346,7 @@ describe('SnippetMain', () => { }) expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument() - expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node']) + expect(capturedWorkflowNodes?.map((node) => node.id)).toEqual(['draft-node']) }) it('should keep the snippet canvas editable and sync draft changes with create-and-modify permission', async () => { @@ -355,11 +360,13 @@ describe('SnippetMain', () => { expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'publish' })).toBeInTheDocument() expect(store.getState().canvasReadOnly).toBe(false) - expect(mockSetNavigationState).toHaveBeenCalledWith(expect.objectContaining({ - readonly: false, - })) + expect(mockSetNavigationState).toHaveBeenCalledWith( + expect.objectContaining({ + readonly: false, + }), + ) - const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>) + const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as () => Promise<void> await doSyncWorkflowDraft() expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1) @@ -375,13 +382,16 @@ describe('SnippetMain', () => { expect(screen.queryByRole('button', { name: 'publish' })).not.toBeInTheDocument() expect(store.getState().canvasReadOnly).toBe(true) - expect(mockSetNavigationState).toHaveBeenCalledWith(expect.objectContaining({ - readonly: true, - })) + expect(mockSetNavigationState).toHaveBeenCalledWith( + expect.objectContaining({ + readonly: true, + }), + ) - const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>) + const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as () => Promise<void> await doSyncWorkflowDraft() - const syncWorkflowDraftWhenPageClose = capturedHooksStore?.syncWorkflowDraftWhenPageClose as (() => void) + const syncWorkflowDraftWhenPageClose = + capturedHooksStore?.syncWorkflowDraftWhenPageClose as () => void syncWorkflowDraftWhenPageClose() snippetDetailStoreState.onFieldsChange?.([ { @@ -408,9 +418,9 @@ describe('SnippetMain', () => { }) expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument() - expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node']) + expect(capturedWorkflowNodes?.map((node) => node.id)).toEqual(['draft-node']) - const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>) + const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as () => Promise<void> await doSyncWorkflowDraft() expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1) @@ -454,17 +464,20 @@ describe('SnippetMain', () => { }) await waitFor(() => { - expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([ - payload.inputFields[0], + expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith( + [ + payload.inputFields[0], + { + type: PipelineInputVarType.textInput, + label: 'New Field', + variable: 'new_field', + required: true, + }, + ], { - type: PipelineInputVarType.textInput, - label: 'New Field', - variable: 'new_field', - required: true, + onRefresh: expect.any(Function), }, - ], { - onRefresh: expect.any(Function), - }) + ) }) }) }) @@ -473,7 +486,7 @@ describe('SnippetMain', () => { it('should sync workflow draft during normal editing changes', async () => { renderSnippetMain({ currentNodes: [createDraftNode()] }) - const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>) + const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as () => Promise<void> await doSyncWorkflowDraft() expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1) @@ -483,7 +496,8 @@ describe('SnippetMain', () => { it('should sync workflow draft when the page closes', () => { renderSnippetMain({ hasInitialDraftChanges: true }) - const syncWorkflowDraftWhenPageClose = capturedHooksStore?.syncWorkflowDraftWhenPageClose as (() => void) + const syncWorkflowDraftWhenPageClose = + capturedHooksStore?.syncWorkflowDraftWhenPageClose as () => void syncWorkflowDraftWhenPageClose() expect(mockSyncWorkflowDraftWhenPageClose).toHaveBeenCalledTimes(1) @@ -567,10 +581,12 @@ describe('SnippetMain', () => { expect(mockPublishSnippetMutateAsync).toHaveBeenCalled() }) expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith(true) - expect(mockDoSyncWorkflowDraft.mock.invocationCallOrder[0]!).toBeLessThan(mockPublishSnippetMutateAsync.mock.invocationCallOrder[0]!) + expect(mockDoSyncWorkflowDraft.mock.invocationCallOrder[0]!).toBeLessThan( + mockPublishSnippetMutateAsync.mock.invocationCallOrder[0]!, + ) await waitFor(() => { - expect(capturedWorkflowNodes?.map(node => node.id)).toContain('published-draft-node') + expect(capturedWorkflowNodes?.map((node) => node.id)).toContain('published-draft-node') }) }) }) @@ -582,18 +598,34 @@ describe('SnippetMain', () => { expect(capturedHooksStore?.fetchInspectVars).toBe(mockFetchInspectVars) expect(capturedHooksStore?.hasNodeInspectVars).toBe(mockInspectVarsCrud.hasNodeInspectVars) expect(capturedHooksStore?.hasSetInspectVar).toBe(mockInspectVarsCrud.hasSetInspectVar) - expect(capturedHooksStore?.fetchInspectVarValue).toBe(mockInspectVarsCrud.fetchInspectVarValue) + expect(capturedHooksStore?.fetchInspectVarValue).toBe( + mockInspectVarsCrud.fetchInspectVarValue, + ) expect(capturedHooksStore?.editInspectVarValue).toBe(mockInspectVarsCrud.editInspectVarValue) - expect(capturedHooksStore?.renameInspectVarName).toBe(mockInspectVarsCrud.renameInspectVarName) - expect(capturedHooksStore?.appendNodeInspectVars).toBe(mockInspectVarsCrud.appendNodeInspectVars) + expect(capturedHooksStore?.renameInspectVarName).toBe( + mockInspectVarsCrud.renameInspectVarName, + ) + expect(capturedHooksStore?.appendNodeInspectVars).toBe( + mockInspectVarsCrud.appendNodeInspectVars, + ) expect(capturedHooksStore?.deleteInspectVar).toBe(mockInspectVarsCrud.deleteInspectVar) - expect(capturedHooksStore?.deleteNodeInspectorVars).toBe(mockInspectVarsCrud.deleteNodeInspectorVars) - expect(capturedHooksStore?.deleteAllInspectorVars).toBe(mockInspectVarsCrud.deleteAllInspectorVars) + expect(capturedHooksStore?.deleteNodeInspectorVars).toBe( + mockInspectVarsCrud.deleteNodeInspectorVars, + ) + expect(capturedHooksStore?.deleteAllInspectorVars).toBe( + mockInspectVarsCrud.deleteAllInspectorVars, + ) expect(capturedHooksStore?.isInspectVarEdited).toBe(mockInspectVarsCrud.isInspectVarEdited) expect(capturedHooksStore?.resetToLastRunVar).toBe(mockInspectVarsCrud.resetToLastRunVar) - expect(capturedHooksStore?.invalidateSysVarValues).toBe(mockInspectVarsCrud.invalidateSysVarValues) - expect(capturedHooksStore?.resetConversationVar).toBe(mockInspectVarsCrud.resetConversationVar) - expect(capturedHooksStore?.invalidateConversationVarValues).toBe(mockInspectVarsCrud.invalidateConversationVarValues) + expect(capturedHooksStore?.invalidateSysVarValues).toBe( + mockInspectVarsCrud.invalidateSysVarValues, + ) + expect(capturedHooksStore?.resetConversationVar).toBe( + mockInspectVarsCrud.resetConversationVar, + ) + expect(capturedHooksStore?.invalidateConversationVarValues).toBe( + mockInspectVarsCrud.invalidateConversationVarValues, + ) }) }) @@ -606,7 +638,9 @@ describe('SnippetMain', () => { nodesMap: Partial<Record<BlockEnum, unknown>> } - expect(availableNodesMetaData.nodes.map(node => node.metaData.type)).toEqual([BlockEnum.LLM]) + expect(availableNodesMetaData.nodes.map((node) => node.metaData.type)).toEqual([ + BlockEnum.LLM, + ]) expect(availableNodesMetaData.nodesMap[BlockEnum.LLM]).toBeDefined() expect(availableNodesMetaData.nodesMap[BlockEnum.HumanInput]).toBeUndefined() expect(availableNodesMetaData.nodesMap[BlockEnum.End]).toBeUndefined() @@ -620,17 +654,23 @@ describe('SnippetMain', () => { expect(capturedHooksStore?.handleBackupDraft).toBe(mockHandleBackupDraft) expect(capturedHooksStore?.handleLoadBackupDraft).toBe(mockHandleLoadBackupDraft) - expect(capturedHooksStore?.handleRestoreFromPublishedWorkflow).toBe(mockHandleRestoreFromPublishedWorkflow) + expect(capturedHooksStore?.handleRestoreFromPublishedWorkflow).toBe( + mockHandleRestoreFromPublishedWorkflow, + ) expect(capturedHooksStore?.handleRun).toBe(mockHandleRun) expect(capturedHooksStore?.handleStopRun).toBe(mockHandleStopRun) expect(capturedHooksStore?.handleStartWorkflowRun).toBe(mockHandleStartWorkflowRun) - expect(capturedHooksStore?.handleWorkflowStartRunInWorkflow).toBe(mockHandleWorkflowStartRunInWorkflow) + expect(capturedHooksStore?.handleWorkflowStartRunInWorkflow).toBe( + mockHandleWorkflowStartRunInWorkflow, + ) }) it('should pass snippet workflow run detail urls to WorkflowWithInnerContext', () => { renderSnippetMain() - const getWorkflowRunAndTraceUrl = capturedHooksStore?.getWorkflowRunAndTraceUrl as ((runId?: string) => { runUrl: string, traceUrl: string }) | undefined + const getWorkflowRunAndTraceUrl = capturedHooksStore?.getWorkflowRunAndTraceUrl as + | ((runId?: string) => { runUrl: string; traceUrl: string }) + | undefined expect(getWorkflowRunAndTraceUrl?.('run-1')).toEqual({ runUrl: '/snippets/snippet-1/workflow-runs/run-1', diff --git a/web/app/components/snippets/components/__tests__/snippet-run-panel.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-run-panel.spec.tsx index 19dac14715d11d..36f214d5452f37 100644 --- a/web/app/components/snippets/components/__tests__/snippet-run-panel.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-run-panel.spec.tsx @@ -50,7 +50,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/form-i value, onChange, }: { - payload: { variable: string, label: string, type: InputVarType } + payload: { variable: string; label: string; type: InputVarType } value: unknown onChange: (value: unknown) => void }) => ( @@ -64,13 +64,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/form-i })) vi.mock('@/app/components/workflow/run/result-text', () => ({ - default: ({ - outputs, - onClick, - }: { - outputs?: string - onClick: () => void - }) => ( + default: ({ outputs, onClick }: { outputs?: string; onClick: () => void }) => ( <button type="button" onClick={onClick}> {outputs || 'empty-result'} </button> @@ -104,15 +98,12 @@ describe('SnippetRunPanel', () => { it('should render snippet input fields with defaults and run with edited inputs', async () => { const user = userEvent.setup() - renderWorkflowComponent( - <SnippetRunPanel fields={fields} />, - { - initialStoreState: { - showInputsPanel: true, - previewPanelWidth: 480, - }, + renderWorkflowComponent(<SnippetRunPanel fields={fields} />, { + initialStoreState: { + showInputsPanel: true, + previewPanelWidth: 480, }, - ) + }) expect(screen.getByText(`Topic:${InputVarType.textInput}:default topic`)).toBeInTheDocument() @@ -139,30 +130,27 @@ describe('SnippetRunPanel', () => { it('should copy successful text results and open details from the result panel', async () => { const user = userEvent.setup() - renderWorkflowComponent( - <SnippetRunPanel fields={[]} />, - { - initialStoreState: { - showInputsPanel: false, - previewPanelWidth: 480, - workflowRunningData: { - task_id: 'task-1', - resultText: 'final answer', - tracing: [], - result: { - status: WorkflowRunningStatus.Succeeded, - finished_at: 1710000000, - files: [], - inputs: '{}', - inputs_truncated: false, - process_data_truncated: false, - outputs: '{}', - outputs_truncated: false, - }, + renderWorkflowComponent(<SnippetRunPanel fields={[]} />, { + initialStoreState: { + showInputsPanel: false, + previewPanelWidth: 480, + workflowRunningData: { + task_id: 'task-1', + resultText: 'final answer', + tracing: [], + result: { + status: WorkflowRunningStatus.Succeeded, + finished_at: 1710000000, + files: [], + inputs: '{}', + inputs_truncated: false, + process_data_truncated: false, + outputs: '{}', + outputs_truncated: false, }, }, }, - ) + }) expect(screen.getByText('final answer')).toBeInTheDocument() diff --git a/web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx index 9f0328e30247f0..7e4e3ec0b46d09 100644 --- a/web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx @@ -5,16 +5,20 @@ import { fireEvent, render, screen } from '@testing-library/react' import { PipelineInputVarType } from '@/models/pipeline' import { SnippetSidebarContent } from '../snippet-sidebar' -let capturedVarListProps: { - list: InputVar[] - onChange: (list: InputVar[]) => void - readonly: boolean -} | undefined -let capturedConfigVarModalProps: { - onClose: () => void - onConfirm: (field: InputVar) => void - varKeys: string[] -} | undefined +let capturedVarListProps: + | { + list: InputVar[] + onChange: (list: InputVar[]) => void + readonly: boolean + } + | undefined +let capturedConfigVarModalProps: + | { + onClose: () => void + onConfirm: (field: InputVar) => void + varKeys: string[] + } + | undefined vi.mock('@/app/components/app-sidebar/snippet-info/dropdown', () => ({ default: () => <div data-testid="snippet-info-dropdown" />, @@ -38,16 +42,20 @@ vi.mock('@/app/components/app/configuration/config-var/config-modal', () => ({ <div role="dialog" aria-label="config-var-modal"> <button type="button" - onClick={() => props.onConfirm({ - type: PipelineInputVarType.textInput as unknown as InputVar['type'], - label: 'Question', - variable: 'question', - required: false, - })} + onClick={() => + props.onConfirm({ + type: PipelineInputVarType.textInput as unknown as InputVar['type'], + label: 'Question', + variable: 'question', + required: false, + }) + } > confirm field </button> - <button type="button" onClick={props.onClose}>close modal</button> + <button type="button" onClick={props.onClose}> + close modal + </button> </div> ) }, @@ -133,22 +141,18 @@ describe('SnippetSidebarContent', () => { it('should hide input field operations when readonly', () => { render( - <SnippetSidebarContent - snippet={snippet} - fields={fields} - readonly - onFieldsChange={vi.fn()} - />, + <SnippetSidebarContent snippet={snippet} fields={fields} readonly onFieldsChange={vi.fn()} />, ) expect(screen.queryByRole('link', { name: /snippet\.management/i })).not.toBeInTheDocument() expect(screen.getByText(snippet.name)).toHaveAttribute('title', snippet.name) expect(screen.getByText(snippet.name)).toHaveClass('truncate') - if (!snippet.description) - throw new Error('snippet.description is required for this test') + if (!snippet.description) throw new Error('snippet.description is required for this test') expect(screen.getByText(snippet.description)).toHaveAttribute('title', snippet.description) expect(screen.getByText(snippet.description)).toHaveClass('truncate') - expect(screen.queryByRole('button', { name: /common\.operation\.add/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /common\.operation\.add/i }), + ).not.toBeInTheDocument() expect(capturedVarListProps?.readonly).toBe(true) }) @@ -162,7 +166,10 @@ describe('SnippetSidebarContent', () => { />, ) - expect(screen.getByRole('link', { name: 'snippet.sectionOrchestrate' })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate') + expect(screen.getByRole('link', { name: 'snippet.sectionOrchestrate' })).toHaveAttribute( + 'href', + '/snippets/snippet-1/orchestrate', + ) }) it('should add a new input field from the config variable modal', () => { diff --git a/web/app/components/snippets/components/__tests__/workflow-panel.spec.tsx b/web/app/components/snippets/components/__tests__/workflow-panel.spec.tsx index 78b0e2c1fdc28d..4212746a46d669 100644 --- a/web/app/components/snippets/components/__tests__/workflow-panel.spec.tsx +++ b/web/app/components/snippets/components/__tests__/workflow-panel.spec.tsx @@ -23,18 +23,21 @@ describe('SnippetWorkflowPanel', () => { // Verifies snippet panel wires version history support into the shared workflow panel. describe('Rendering', () => { it('should pass snippet version history panel props to the shared workflow panel', async () => { - render( - <SnippetWorkflowPanel - snippetId="snippet-1" - fields={defaultFields} - />, - ) + render(<SnippetWorkflowPanel snippetId="snippet-1" fields={defaultFields} />) await waitFor(() => { - expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe('/snippets/snippet-1/workflows') - expect(capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1') - expect(capturedPanelProps?.versionHistoryPanelProps?.restoreVersionUrl('version-1')).toBe('/snippets/snippet-1/workflows/version-1/restore') - expect(capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1') + expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe( + '/snippets/snippet-1/workflows', + ) + expect(capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')).toBe( + '/snippets/snippet-1/workflows/version-1', + ) + expect(capturedPanelProps?.versionHistoryPanelProps?.restoreVersionUrl('version-1')).toBe( + '/snippets/snippet-1/workflows/version-1/restore', + ) + expect(capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-1')).toBe( + '/snippets/snippet-1/workflows/version-1', + ) expect(capturedPanelProps?.versionHistoryPanelProps?.latestVersionId).toBe('') expect(capturedPanelProps?.components?.right).toBeTruthy() }) diff --git a/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts b/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts index ae21558f2ab136..98a78b4f42e52b 100644 --- a/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts +++ b/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts @@ -30,9 +30,11 @@ describe('useSnippetInputFieldActions', () => { describe('Field sync', () => { it('should update fields and sync the draft', () => { useSnippetDraftStore.getState().setInputFields([createField()]) - const { result } = renderHook(() => useSnippetInputFieldActions({ - snippetId: 'snippet-1', - })) + const { result } = renderHook(() => + useSnippetInputFieldActions({ + snippetId: 'snippet-1', + }), + ) const nextFields = [ createField(), createField({ diff --git a/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts b/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts index 4dcd8a60c71936..7f2006fa2343d9 100644 --- a/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts +++ b/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts @@ -58,9 +58,11 @@ describe('useSnippetPublish', () => { describe('Publish action', () => { it('should publish the snippet and show success feedback', async () => { - const { result } = renderHook(() => useSnippetPublish({ - snippetId: 'snippet-1', - })) + const { result } = renderHook(() => + useSnippetPublish({ + snippetId: 'snippet-1', + }), + ) await act(async () => { await result.current.handlePublish() @@ -73,7 +75,9 @@ describe('useSnippetPublish', () => { expect(mockSetQueryData).toHaveBeenCalledTimes(1) const setQueryDataCall = mockSetQueryData.mock.calls[0] expect(setQueryDataCall).toBeDefined() - const updateSnippetDetail = setQueryDataCall![1] as (old: { is_published: boolean }) => { is_published: boolean } + const updateSnippetDetail = setQueryDataCall![1] as (old: { is_published: boolean }) => { + is_published: boolean + } expect(updateSnippetDetail({ is_published: false })).toEqual({ is_published: true }) expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678) expect(mockResetWorkflowVersionHistory).toHaveBeenCalledTimes(1) @@ -83,9 +87,11 @@ describe('useSnippetPublish', () => { it('should not publish the snippet when checklist validation fails', async () => { mockHandleCheckBeforePublish.mockResolvedValue(false) - const { result } = renderHook(() => useSnippetPublish({ - snippetId: 'snippet-1', - })) + const { result } = renderHook(() => + useSnippetPublish({ + snippetId: 'snippet-1', + }), + ) await act(async () => { await result.current.handlePublish() @@ -102,9 +108,11 @@ describe('useSnippetPublish', () => { it('should surface publish errors through toast feedback', async () => { mockMutateAsync.mockRejectedValue(new Error('publish failed')) - const { result } = renderHook(() => useSnippetPublish({ - snippetId: 'snippet-1', - })) + const { result } = renderHook(() => + useSnippetPublish({ + snippetId: 'snippet-1', + }), + ) await act(async () => { await result.current.handlePublish() @@ -117,9 +125,11 @@ describe('useSnippetPublish', () => { it('should expose publishing pending state', () => { isPending = true - const { result } = renderHook(() => useSnippetPublish({ - snippetId: 'snippet-1', - })) + const { result } = renderHook(() => + useSnippetPublish({ + snippetId: 'snippet-1', + }), + ) expect(result.current.isPublishing).toBe(true) }) diff --git a/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts b/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts index 853b96663c1379..70c8b8cff5bca9 100644 --- a/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts +++ b/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts @@ -14,23 +14,24 @@ export const useSnippetInputFieldActions = ({ snippetId, }: UseSnippetInputFieldActionsOptions) => { const { syncInputFieldsDraft } = useNodesSyncDraft(snippetId) - const { - inputFields, - setInputFields, - } = useSnippetDraftStore(useShallow(state => ({ - inputFields: state.inputFields, - setInputFields: state.setInputFields, - }))) + const { inputFields, setInputFields } = useSnippetDraftStore( + useShallow((state) => ({ + inputFields: state.inputFields, + setInputFields: state.setInputFields, + })), + ) - const handleFieldsChange = useCallback((newFields: SnippetInputField[]) => { - if (!canEdit) - return + const handleFieldsChange = useCallback( + (newFields: SnippetInputField[]) => { + if (!canEdit) return - setInputFields(newFields) - void syncInputFieldsDraft(newFields, { - onRefresh: setInputFields, - }) - }, [canEdit, setInputFields, syncInputFieldsDraft]) + setInputFields(newFields) + void syncInputFieldsDraft(newFields, { + onRefresh: setInputFields, + }) + }, + [canEdit, setInputFields, syncInputFieldsDraft], + ) return { fields: inputFields, diff --git a/web/app/components/snippets/components/hooks/use-snippet-publish.ts b/web/app/components/snippets/components/hooks/use-snippet-publish.ts index 918de7270a1ce6..c4d208d8ceca3a 100644 --- a/web/app/components/snippets/components/hooks/use-snippet-publish.ts +++ b/web/app/components/snippets/components/hooks/use-snippet-publish.ts @@ -13,9 +13,7 @@ type UseSnippetPublishOptions = { snippetId: string } -export const useSnippetPublish = ({ - snippetId, -}: UseSnippetPublishOptions) => { +export const useSnippetPublish = ({ snippetId }: UseSnippetPublishOptions) => { const { t } = useTranslation('snippet') const workflowStore = useWorkflowStore() const queryClient = useQueryClient() @@ -26,8 +24,7 @@ export const useSnippetPublish = ({ const handlePublish = useCallback(async () => { try { const canPublish = await handleCheckBeforePublish() - if (!canPublish) - return + if (!canPublish) return const publishedWorkflow = await publishSnippetMutation.mutateAsync({ params: { snippetId }, @@ -39,18 +36,25 @@ export const useSnippetPublish = ({ params: { snippet_id: snippetId }, }, }), - old => old ? { ...old, is_published: true } : old, + (old) => (old ? { ...old, is_published: true } : old), ) workflowStore.getState().setPublishedAt(publishedWorkflow.created_at) resetWorkflowVersionHistory() - toast.success(t($ => $.publishSuccess)) + toast.success(t(($) => $.publishSuccess)) return true - } - catch (error) { - toast.error(error instanceof Error ? error.message : t($ => $.publishFailed)) + } catch (error) { + toast.error(error instanceof Error ? error.message : t(($) => $.publishFailed)) return false } - }, [handleCheckBeforePublish, publishSnippetMutation, queryClient, resetWorkflowVersionHistory, snippetId, t, workflowStore]) + }, [ + handleCheckBeforePublish, + publishSnippetMutation, + queryClient, + resetWorkflowVersionHistory, + snippetId, + t, + workflowStore, + ]) return { handlePublish, diff --git a/web/app/components/snippets/components/snippet-children.tsx b/web/app/components/snippets/components/snippet-children.tsx index 4c2e2db3545c90..77665049502143 100644 --- a/web/app/components/snippets/components/snippet-children.tsx +++ b/web/app/components/snippets/components/snippet-children.tsx @@ -33,10 +33,7 @@ const SnippetChildren = ({ onPublish={onPublish} /> - <SnippetWorkflowPanel - snippetId={snippetId} - fields={fields} - /> + <SnippetWorkflowPanel snippetId={snippetId} fields={fields} /> </> ) } diff --git a/web/app/components/snippets/components/snippet-collapsed-preview.tsx b/web/app/components/snippets/components/snippet-collapsed-preview.tsx index 7b57ff96131782..04f5b02eb63221 100644 --- a/web/app/components/snippets/components/snippet-collapsed-preview.tsx +++ b/web/app/components/snippets/components/snippet-collapsed-preview.tsx @@ -17,7 +17,7 @@ export function SnippetCollapsedPreview({ snippetId?: string }) { const { t } = useTranslation() - const sectionLabel = t($ => $.sectionOrchestrate, { ns: 'snippet' }) + const sectionLabel = t(($) => $.sectionOrchestrate, { ns: 'snippet' }) return ( <div @@ -29,32 +29,30 @@ export function SnippetCollapsedPreview({ </div> <SnippetPlaceholderIcon className="size-9" /> <div className="my-4 h-px w-8 rounded-full bg-divider-subtle" aria-hidden="true" /> - {snippetId - ? ( - <NavLink - mode="collapse" - name={sectionLabel} - href={`/snippets/${snippetId}/orchestrate`} - active - iconMap={{ selected: NodeTreeIcon, normal: NodeTreeIcon }} - /> - ) - : ( - <div - aria-label={sectionLabel} - className="flex size-8 items-center justify-center rounded-lg border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active p-1.5 text-text-accent-light-mode-only" - > - <div className="flex size-5 items-center justify-center"> - <NodeTreeIcon className="size-4.5 shrink-0" /> - </div> - </div> - )} + {snippetId ? ( + <NavLink + mode="collapse" + name={sectionLabel} + href={`/snippets/${snippetId}/orchestrate`} + active + iconMap={{ selected: NodeTreeIcon, normal: NodeTreeIcon }} + /> + ) : ( + <div + aria-label={sectionLabel} + className="flex size-8 items-center justify-center rounded-lg border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active p-1.5 text-text-accent-light-mode-only" + > + <div className="flex size-5 items-center justify-center"> + <NodeTreeIcon className="size-4.5 shrink-0" /> + </div> + </div> + )} <div className={cn( 'mt-4 flex min-w-6 items-center justify-center rounded-full border border-divider-subtle bg-components-badge-bg-gray-soft px-2 text-2xs leading-4 font-normal text-text-secondary', inputFieldCount > 99 ? 'h-5' : 'size-5', )} - aria-label={`${inputFieldCount} ${t($ => $.inputVariables, { ns: 'snippet' })}`} + aria-label={`${inputFieldCount} ${t(($) => $.inputVariables, { ns: 'snippet' })}`} > {inputFieldCount} </div> diff --git a/web/app/components/snippets/components/snippet-detail-section.tsx b/web/app/components/snippets/components/snippet-detail-section.tsx index 0fa9d81442e430..2e8515923dcab8 100644 --- a/web/app/components/snippets/components/snippet-detail-section.tsx +++ b/web/app/components/snippets/components/snippet-detail-section.tsx @@ -11,18 +11,24 @@ type SnippetDetailSectionProps = { } export function SnippetDetailSection({ expand }: SnippetDetailSectionProps) { - const snippetNavigation = useSnippetDetailStore(useShallow(state => ({ - onFieldsChange: state.onFieldsChange, - readonly: state.readonly, - snippet: state.snippet, - }))) - const snippetInputFields = useSnippetDraftStore(state => state.inputFields) + const snippetNavigation = useSnippetDetailStore( + useShallow((state) => ({ + onFieldsChange: state.onFieldsChange, + readonly: state.readonly, + snippet: state.snippet, + })), + ) + const snippetInputFields = useSnippetDraftStore((state) => state.inputFields) if (!expand) - return <SnippetCollapsedPreview inputFieldCount={snippetInputFields.length} snippetId={snippetNavigation.snippet?.id} /> + return ( + <SnippetCollapsedPreview + inputFieldCount={snippetInputFields.length} + snippetId={snippetNavigation.snippet?.id} + /> + ) - if (!snippetNavigation.snippet || !snippetNavigation.onFieldsChange) - return null + if (!snippetNavigation.snippet || !snippetNavigation.onFieldsChange) return null return ( <SnippetSidebarContent diff --git a/web/app/components/snippets/components/snippet-detail-sidebar.tsx b/web/app/components/snippets/components/snippet-detail-sidebar.tsx index 22d71c3598579e..7b4157f534b4b3 100644 --- a/web/app/components/snippets/components/snippet-detail-sidebar.tsx +++ b/web/app/components/snippets/components/snippet-detail-sidebar.tsx @@ -7,12 +7,7 @@ import SnippetDetailTop from './snippet-detail-top' export function SnippetDetailSidebar() { return ( <DetailSidebarFrame - renderTop={({ expand, onToggle }) => ( - <SnippetDetailTop - expand={expand} - onToggle={onToggle} - /> - )} + renderTop={({ expand, onToggle }) => <SnippetDetailTop expand={expand} onToggle={onToggle} />} renderSection={({ expand }) => <SnippetDetailSection expand={expand} />} /> ) diff --git a/web/app/components/snippets/components/snippet-detail-top.tsx b/web/app/components/snippets/components/snippet-detail-top.tsx index 903e1401fe55e2..ca45372fefb508 100644 --- a/web/app/components/snippets/components/snippet-detail-top.tsx +++ b/web/app/components/snippets/components/snippet-detail-top.tsx @@ -17,10 +17,7 @@ type SnippetDetailTopProps = { const SEARCH_SHORTCUT = ['Mod', 'K'] -const SnippetDetailTop = ({ - expand = true, - onToggle, -}: SnippetDetailTopProps) => { +const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) => { const { t } = useTranslation() const router = useRouter() const setGotoAnythingOpen = useSetGotoAnythingOpen() @@ -46,7 +43,7 @@ const SnippetDetailTop = ({ <div className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 transition-colors hover:bg-background-default-hover"> <button type="button" - aria-label={t($ => $['operation.back'], { ns: 'common' })} + aria-label={t(($) => $['operation.back'], { ns: 'common' })} className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary" onClick={() => router.back()} > @@ -54,39 +51,40 @@ const SnippetDetailTop = ({ </button> <Link href="/" - aria-label={t($ => $['mainNav.home'], { ns: 'common' })} + aria-label={t(($) => $['mainNav.home'], { ns: 'common' })} className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary" > <span aria-hidden className="i-custom-vender-main-nav-app-home size-4" /> </Link> </div> - <span className="shrink-0 system-md-regular text-text-quaternary"> - / - </span> + <span className="shrink-0 system-md-regular text-text-quaternary">/</span> <Link href="/snippets" className="shrink-0 truncate rounded-lg px-1.5 py-2 system-sm-semibold-uppercase text-text-secondary transition-colors hover:bg-background-default-hover hover:text-text-primary" > - {t($ => $['tabs.snippets'], { ns: 'workflow' })} + {t(($) => $['tabs.snippets'], { ns: 'workflow' })} </Link> </div> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['gotoAnything.searchTitle'], { ns: 'app' })} + aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })} className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary" onClick={() => setGotoAnythingOpen(true)} > <span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" /> </button> - )} + } /> - <TooltipContent placement="bottom" className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]"> - <span className="px-0.5">{t($ => $['gotoAnything.quickAction'], { ns: 'app' })}</span> + <TooltipContent + placement="bottom" + className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]" + > + <span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span> <KbdGroup> - {SEARCH_SHORTCUT.map(key => ( + {SEARCH_SHORTCUT.map((key) => ( <Kbd key={key}>{formatForDisplay(key)}</Kbd> ))} </KbdGroup> diff --git a/web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx b/web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx index 7a1572665de470..eec55bd51a44a6 100644 --- a/web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx +++ b/web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx @@ -84,13 +84,7 @@ describe('SnippetHeader', () => { it('should show publish loading state while publishing', () => { render( - <SnippetHeader - snippetId="snippet-1" - canSave - canEdit - isPublishing - onPublish={mockPublish} - />, + <SnippetHeader snippetId="snippet-1" canSave canEdit isPublishing onPublish={mockPublish} />, ) expectLoadingButton(screen.getByRole('button', { name: /snippet\.publishButton/i })) diff --git a/web/app/components/snippets/components/snippet-header/__tests__/run-mode.spec.tsx b/web/app/components/snippets/components/snippet-header/__tests__/run-mode.spec.tsx index 03fabf841a91b3..b29f0f6d182c32 100644 --- a/web/app/components/snippets/components/snippet-header/__tests__/run-mode.spec.tsx +++ b/web/app/components/snippets/components/snippet-header/__tests__/run-mode.spec.tsx @@ -60,17 +60,14 @@ describe('RunMode', () => { it('should stop the running workflow from the stop button and workflow stop event', async () => { const user = userEvent.setup() - renderWorkflowComponent( - <RunMode />, - { - initialStoreState: { - workflowRunningData: { - task_id: 'task-1', - result: runningResult, - }, + renderWorkflowComponent(<RunMode />, { + initialStoreState: { + workflowRunningData: { + task_id: 'task-1', + result: runningResult, }, }, - ) + }) expect(screen.getByRole('button', { name: /workflow\.common\.running/i })).toBeDisabled() @@ -101,16 +98,13 @@ describe('RunMode', () => { it('should stop with an empty task id when running data has no task id', async () => { const user = userEvent.setup() - renderWorkflowComponent( - <RunMode />, - { - initialStoreState: { - workflowRunningData: { - result: runningResult, - }, + renderWorkflowComponent(<RunMode />, { + initialStoreState: { + workflowRunningData: { + result: runningResult, }, }, - ) + }) await user.click(screen.getAllByRole('button')[1]!) diff --git a/web/app/components/snippets/components/snippet-header/index.tsx b/web/app/components/snippets/components/snippet-header/index.tsx index 83d56aec035f0c..ccc72fd1dc2837 100644 --- a/web/app/components/snippets/components/snippet-header/index.tsx +++ b/web/app/components/snippets/components/snippet-header/index.tsx @@ -2,10 +2,7 @@ import type { HeaderProps } from '@/app/components/workflow/header' import { Button } from '@langgenius/dify-ui/button' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import Header from '@/app/components/workflow/header' import RunMode from './run-mode' @@ -32,7 +29,7 @@ const PublishAction = ({ disabled={isPublishing || !canSave} onClick={onPublish} > - {t($ => $.publishButton)} + {t(($) => $.publishButton)} </Button> ) } @@ -55,17 +52,9 @@ const SnippetHeader = ({ return { normal: { components: { - left: ( - canEdit - ? ( - <PublishAction - canSave={canSave} - isPublishing={isPublishing} - onPublish={onPublish} - /> - ) - : null - ), + left: canEdit ? ( + <PublishAction canSave={canSave} isPublishing={isPublishing} onPublish={onPublish} /> + ) : null, }, controls: { showEnvButton: false, @@ -73,7 +62,7 @@ const SnippetHeader = ({ }, runAndHistoryProps: { showRunButton: true, - runButtonText: t($ => $.testRunButton), + runButtonText: t(($) => $.testRunButton), viewHistoryProps, components: { RunMode, diff --git a/web/app/components/snippets/components/snippet-header/run-mode.tsx b/web/app/components/snippets/components/snippet-header/run-mode.tsx index 3dffaa6028b14f..99b01547c187f4 100644 --- a/web/app/components/snippets/components/snippet-header/run-mode.tsx +++ b/web/app/components/snippets/components/snippet-header/run-mode.tsx @@ -16,13 +16,11 @@ type RunModeProps = { text?: string } -const RunMode = ({ - text, -}: RunModeProps) => { +const RunMode = ({ text }: RunModeProps) => { const { t } = useTranslation('snippet') const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun() const { handleStopRun } = useWorkflowRun() - const workflowRunningData = useStore(s => s.workflowRunningData) + const workflowRunningData = useStore((s) => s.workflowRunningData) const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running @@ -32,8 +30,7 @@ const RunMode = ({ const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((v) => { - if (typeof v !== 'string' && v.type === EVENT_WORKFLOW_STOP) - handleStop() + if (typeof v !== 'string' && v.type === EVENT_WORKFLOW_STOP) handleStop() }) return ( @@ -47,20 +44,18 @@ const RunMode = ({ onClick={handleWorkflowStartRunInWorkflow} disabled={isRunning} > - {isRunning - ? ( - <> - <span aria-hidden className="mr-1 i-ri-loader-2-line size-4 animate-spin" /> - {t($ => $['common.running'], { ns: 'workflow' })} - </> - ) - : ( - <> - <span aria-hidden className="mr-1 i-ri-play-large-line size-4" /> - {text ?? t($ => $['common.run'], { ns: 'workflow' })} - <ShortcutKbd hotkey={TEST_RUN_MENU_HOTKEY} textColor="secondary" /> - </> - )} + {isRunning ? ( + <> + <span aria-hidden className="mr-1 i-ri-loader-2-line size-4 animate-spin" /> + {t(($) => $['common.running'], { ns: 'workflow' })} + </> + ) : ( + <> + <span aria-hidden className="mr-1 i-ri-play-large-line size-4" /> + {text ?? t(($) => $['common.run'], { ns: 'workflow' })} + <ShortcutKbd hotkey={TEST_RUN_MENU_HOTKEY} textColor="secondary" /> + </> + )} </button> {isRunning && ( diff --git a/web/app/components/snippets/components/snippet-layout.tsx b/web/app/components/snippets/components/snippet-layout.tsx index ae8f693d8a4de9..d6a6faf9249405 100644 --- a/web/app/components/snippets/components/snippet-layout.tsx +++ b/web/app/components/snippets/components/snippet-layout.tsx @@ -12,20 +12,15 @@ type SnippetLayoutProps = { snippetId: string } -const SnippetLayout = ({ - children, - snippet, -}: SnippetLayoutProps) => { +const SnippetLayout = ({ children, snippet }: SnippetLayoutProps) => { const { t } = useTranslation('snippet') - useDocumentTitle(snippet?.name || t($ => $.typeLabel)) + useDocumentTitle(snippet?.name || t(($) => $.typeLabel)) return ( <div className="relative flex h-full min-h-0 min-w-0 overflow-hidden bg-background-body"> <div className="relative min-h-0 min-w-0 grow overflow-hidden"> - <div className="absolute inset-0 min-h-0 min-w-0 overflow-hidden"> - {children} - </div> + <div className="absolute inset-0 min-h-0 min-w-0 overflow-hidden">{children}</div> </div> </div> ) diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index fd0f72b637b57e..5a5374527fb78f 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -6,13 +6,7 @@ import type { SnippetCanvasData, SnippetDetailPayload, SnippetInputField } from import type { SnippetDraftSyncPayload } from '@/types/snippet' import { toast } from '@langgenius/dify-ui/toast' import { useAtomValue } from 'jotai' -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useState, -} from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { WorkflowWithInnerContext } from '@/app/components/workflow' @@ -20,10 +14,7 @@ import { useAvailableNodesMetaData } from '@/app/components/workflow-app/hooks' import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' -import { - initialEdges, - initialNodes, -} from '@/app/components/workflow/utils' +import { initialEdges, initialNodes } from '@/app/components/workflow/utils' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useSnippetDraftStore } from '../draft-store' import { useConfigsMap } from '../hooks/use-configs-map' @@ -73,9 +64,10 @@ type LocalDraftState = { } const hasSnippetDraftNodes = (payload?: Omit<SnippetDraftSyncPayload, 'hash'> | void) => { - const nodes = payload?.graph && typeof payload.graph === 'object' - ? (payload.graph as { nodes?: unknown }).nodes - : undefined + const nodes = + payload?.graph && typeof payload.graph === 'object' + ? (payload.graph as { nodes?: unknown }).nodes + : undefined return Array.isArray(nodes) && nodes.length > 0 } @@ -89,26 +81,21 @@ const SnippetMainContent = ({ onSaved, }: SnippetMainContentProps) => { const { t } = useTranslation('snippet') - const { - handlePublish, - isPublishing, - } = useSnippetPublish({ + const { handlePublish, isPublishing } = useSnippetPublish({ snippetId, }) const handlePublishSnippet = useCallback(async () => { const syncedDraftPayload = await onBeforePublish() - if (!syncedDraftPayload) - return false + if (!syncedDraftPayload) return false if (!hasSnippetDraftNodes(syncedDraftPayload)) { - toast.error(t($ => $.emptyGraphSaveError)) + toast.error(t(($) => $.emptyGraphSaveError)) return false } const didSave = await handlePublish() - if (didSave) - onSaved(syncedDraftPayload) + if (didSave) onSaved(syncedDraftPayload) return didSave }, [handlePublish, onBeforePublish, onSaved, t]) @@ -138,7 +125,9 @@ const SnippetMain = ({ setLocalDraftState(undefined) setLocalDraftSnippetId(snippetId) } - const currentCanvasNodeCount = useStore(state => state.nodes.filter(node => !node.data?._isTempNode).length) + const currentCanvasNodeCount = useStore( + (state) => state.nodes.filter((node) => !node.data?._isTempNode).length, + ) const effectiveDraftPayload = localDraftState?.payload ?? draftPayload const effectiveDraftNodes = localDraftState?.nodes ?? draftNodes const effectiveDraftEdges = localDraftState?.edges ?? draftEdges @@ -147,10 +136,8 @@ const SnippetMain = ({ const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canEditSnippet = canCreateAndModifySnippets(workspacePermissionKeys) const canSave = canEditSnippet && currentCanvasNodeCount > 0 - const { - doSyncWorkflowDraft: syncWorkflowDraft, - syncWorkflowDraftWhenPageClose, - } = useNodesSyncDraft(snippetId) + const { doSyncWorkflowDraft: syncWorkflowDraft, syncWorkflowDraftWhenPageClose } = + useNodesSyncDraft(snippetId) const workflowStore = useWorkflowStore() const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId) const { @@ -182,11 +169,11 @@ const SnippetMain = ({ } = useInspectVarsCrud(snippetId) const workflowAvailableNodesMetaData = useAvailableNodesMetaData() const availableNodesMetaData = useMemo(() => { - const nodes = workflowAvailableNodesMetaData.nodes.filter(node => - !unsupportedSnippetBlockTypes.has(node.metaData.type)) + const nodes = workflowAvailableNodesMetaData.nodes.filter( + (node) => !unsupportedSnippetBlockTypes.has(node.metaData.type), + ) - if (!workflowAvailableNodesMetaData.nodesMap) - return { nodes } + if (!workflowAvailableNodesMetaData.nodesMap) return { nodes } const { [BlockEnum.HumanInput]: _humanInput, @@ -200,31 +187,23 @@ const SnippetMain = ({ nodesMap, } }, [workflowAvailableNodesMetaData]) - const { - reset, - setNavigationState, - } = useSnippetDetailStore(useShallow(state => ({ - reset: state.reset, - setNavigationState: state.setNavigationState, - }))) - const { - hydrateDraft, - setInputFields, - } = useSnippetDraftStore(useShallow(state => ({ - hydrateDraft: state.hydrateDraft, - setInputFields: state.setInputFields, - }))) - const { - fields, - handleFieldsChange: handleSnippetFieldsChange, - } = useSnippetInputFieldActions({ + const { reset, setNavigationState } = useSnippetDetailStore( + useShallow((state) => ({ + reset: state.reset, + setNavigationState: state.setNavigationState, + })), + ) + const { hydrateDraft, setInputFields } = useSnippetDraftStore( + useShallow((state) => ({ + hydrateDraft: state.hydrateDraft, + setInputFields: state.setInputFields, + })), + ) + const { fields, handleFieldsChange: handleSnippetFieldsChange } = useSnippetInputFieldActions({ canEdit: canEditSnippet, snippetId, }) - const { - handleStartWorkflowRun, - handleWorkflowStartRunInWorkflow, - } = useSnippetStartRun({ + const { handleStartWorkflowRun, handleWorkflowStartRunInWorkflow } = useSnippetStartRun({ handleRun, }) const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl(snippetId) @@ -261,25 +240,27 @@ const SnippetMain = ({ workflowStore.temporal.getState().resume() }, [effectiveDraftEdges, effectiveDraftNodes, workflowStore]) - const doSyncWorkflowDraft = useCallback(( - ...args: Parameters<typeof syncWorkflowDraft> - ) => { - if (!canEditSnippet) - return Promise.resolve() + const doSyncWorkflowDraft = useCallback( + (...args: Parameters<typeof syncWorkflowDraft>) => { + if (!canEditSnippet) return Promise.resolve() - return syncWorkflowDraft(...args) - }, [canEditSnippet, syncWorkflowDraft]) + return syncWorkflowDraft(...args) + }, + [canEditSnippet, syncWorkflowDraft], + ) const handleSyncWorkflowDraftWhenPageClose = useCallback(() => { - if (!canEditSnippet) - return + if (!canEditSnippet) return syncWorkflowDraftWhenPageClose() }, [canEditSnippet, syncWorkflowDraftWhenPageClose]) - const handleFieldsChange = useCallback((nextFields: SnippetInputField[]) => { - handleSnippetFieldsChange(nextFields) - }, [handleSnippetFieldsChange]) + const handleFieldsChange = useCallback( + (nextFields: SnippetInputField[]) => { + handleSnippetFieldsChange(nextFields) + }, + [handleSnippetFieldsChange], + ) useEffect(() => { setNavigationState({ @@ -290,37 +271,42 @@ const SnippetMain = ({ }) }, [canEditSnippet, handleFieldsChange, setNavigationState, snippet, snippetId]) - const updateLocalDraftFromSyncPayload = useCallback(( - syncedDraftPayload?: Omit<SnippetDraftSyncPayload, 'hash'> | void, - ) => { - const graph = syncedDraftPayload?.graph - if (!graph || typeof graph !== 'object') - return - - const graphRecord = graph as Record<string, unknown> - const draftGraph: SnippetCanvasData = { - nodes: Array.isArray(graphRecord.nodes) ? graphRecord.nodes as SnippetCanvasData['nodes'] : [], - edges: Array.isArray(graphRecord.edges) ? graphRecord.edges as SnippetCanvasData['edges'] : [], - viewport: graphRecord.viewport && typeof graphRecord.viewport === 'object' - ? graphRecord.viewport as SnippetCanvasData['viewport'] - : { x: 0, y: 0, zoom: 1 }, - } - const inputFields = Array.isArray(syncedDraftPayload.input_fields) - ? syncedDraftPayload.input_fields as SnippetInputField[] - : fields - - setLocalDraftState({ - payload: { - ...draftPayload, - graph: draftGraph, - inputFields, - }, - nodes: initialNodes(draftGraph.nodes, draftGraph.edges), - edges: initialEdges(draftGraph.edges, draftGraph.nodes), - viewport: draftGraph.viewport, - }) - setInputFields(inputFields) - }, [draftPayload, fields, setInputFields]) + const updateLocalDraftFromSyncPayload = useCallback( + (syncedDraftPayload?: Omit<SnippetDraftSyncPayload, 'hash'> | void) => { + const graph = syncedDraftPayload?.graph + if (!graph || typeof graph !== 'object') return + + const graphRecord = graph as Record<string, unknown> + const draftGraph: SnippetCanvasData = { + nodes: Array.isArray(graphRecord.nodes) + ? (graphRecord.nodes as SnippetCanvasData['nodes']) + : [], + edges: Array.isArray(graphRecord.edges) + ? (graphRecord.edges as SnippetCanvasData['edges']) + : [], + viewport: + graphRecord.viewport && typeof graphRecord.viewport === 'object' + ? (graphRecord.viewport as SnippetCanvasData['viewport']) + : { x: 0, y: 0, zoom: 1 }, + } + const inputFields = Array.isArray(syncedDraftPayload.input_fields) + ? (syncedDraftPayload.input_fields as SnippetInputField[]) + : fields + + setLocalDraftState({ + payload: { + ...draftPayload, + graph: draftGraph, + inputFields, + }, + nodes: initialNodes(draftGraph.nodes, draftGraph.edges), + edges: initialEdges(draftGraph.edges, draftGraph.nodes), + viewport: draftGraph.viewport, + }) + setInputFields(inputFields) + }, + [draftPayload, fields, setInputFields], + ) const hooksStore = useMemo(() => { return { diff --git a/web/app/components/snippets/components/snippet-run-panel.tsx b/web/app/components/snippets/components/snippet-run-panel.tsx index 44029de11af239..d04f4351a81e49 100644 --- a/web/app/components/snippets/components/snippet-run-panel.tsx +++ b/web/app/components/snippets/components/snippet-run-panel.tsx @@ -6,30 +6,18 @@ import type { SnippetInputField } from '@/models/snippet' import { Button } from '@langgenius/dify-ui/button' import { toast } from '@langgenius/dify-ui/toast' import copy from 'copy-to-clipboard' -import { - memo, - useCallback, - useEffect, - useMemo, - useState, -} from 'react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useCheckInputsForms } from '@/app/components/base/chat/chat/check-input-forms-hooks' import { getProcessedInputs } from '@/app/components/base/chat/chat/utils' import Loading from '@/app/components/base/loading' -import { - useWorkflowInteractions, - useWorkflowRun, -} from '@/app/components/workflow/hooks' +import { useWorkflowInteractions, useWorkflowRun } from '@/app/components/workflow/hooks' import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item' import ResultPanel from '@/app/components/workflow/run/result-panel' import ResultText from '@/app/components/workflow/run/result-text' import TracingPanel from '@/app/components/workflow/run/tracing-panel' import { useStore } from '@/app/components/workflow/store' -import { - InputVarType, - WorkflowRunningStatus, -} from '@/app/components/workflow/types' +import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types' import { formatWorkflowRunIdentifier } from '@/app/components/workflow/utils' import { PipelineInputVarType } from '@/models/pipeline' @@ -50,7 +38,7 @@ const PIPELINE_TO_WORKFLOW_INPUT_VAR_TYPE: Record<PipelineInputVarType, InputVar } const buildPreviewFields = (fields: SnippetInputField[]): SnippetRunField[] => { - return fields.map(field => ({ + return fields.map((field) => ({ type: PIPELINE_TO_WORKFLOW_INPUT_VAR_TYPE[field.type], label: field.label, variable: field.variable, @@ -69,25 +57,22 @@ const buildPreviewFields = (fields: SnippetInputField[]): SnippetRunField[] => { const buildInitialInputs = (fields: SnippetRunField[]) => { return fields.reduce<Record<string, unknown>>((acc, field) => { - if (field.default !== undefined) - acc[field.variable] = field.default + if (field.default !== undefined) acc[field.variable] = field.default return acc }, {}) } -const SnippetRunPanel = ({ - fields, -}: SnippetRunPanelProps) => { +const SnippetRunPanel = ({ fields }: SnippetRunPanelProps) => { const { t } = useTranslation() const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions() const { handleRun } = useWorkflowRun() const { checkInputsForm } = useCheckInputsForms() - const workflowRunningData = useStore(s => s.workflowRunningData) - const showInputsPanel = useStore(s => s.showInputsPanel) - const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) - const panelWidth = useStore(s => s.previewPanelWidth) - const setPreviewPanelWidth = useStore(s => s.setPreviewPanelWidth) + const workflowRunningData = useStore((s) => s.workflowRunningData) + const showInputsPanel = useStore((s) => s.showInputsPanel) + const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth) + const panelWidth = useStore((s) => s.previewPanelWidth) + const setPreviewPanelWidth = useStore((s) => s.setPreviewPanelWidth) const previewFields = useMemo(() => buildPreviewFields(fields), [fields]) const initialInputs = useMemo(() => buildInitialInputs(previewFields), [previewFields]) @@ -98,22 +83,26 @@ const SnippetRunPanel = ({ const inputs = inputOverrides ?? initialInputs const hasInputTab = showInputsPanel && previewFields.length > 0 const defaultTab = hasInputTab ? 'INPUT' : 'RESULT' - const shouldShowDetailByDefault = !!workflowRunningData - && (workflowRunningData.result.status === WorkflowRunningStatus.Succeeded || workflowRunningData.result.status === WorkflowRunningStatus.Failed) - && !workflowRunningData.resultText - && !workflowRunningData.result.files?.length + const shouldShowDetailByDefault = + !!workflowRunningData && + (workflowRunningData.result.status === WorkflowRunningStatus.Succeeded || + workflowRunningData.result.status === WorkflowRunningStatus.Failed) && + !workflowRunningData.resultText && + !workflowRunningData.result.files?.length const currentTab = selectedTab ?? (shouldShowDetailByDefault ? 'DETAIL' : defaultTab) - const handleValueChange = useCallback((variable: string, value: unknown) => { - setInputOverrides(prev => ({ - ...(prev ?? initialInputs), - [variable]: value, - })) - }, [initialInputs]) + const handleValueChange = useCallback( + (variable: string, value: unknown) => { + setInputOverrides((prev) => ({ + ...(prev ?? initialInputs), + [variable]: value, + })) + }, + [initialInputs], + ) const handleSubmit = useCallback(() => { - if (!checkInputsForm(inputs, previewFields)) - return + if (!checkInputsForm(inputs, previewFields)) return setSelectedTab('RESULT') handleRun({ @@ -130,17 +119,18 @@ const SnippetRunPanel = ({ setIsResizing(false) }, []) - const resize = useCallback((e: MouseEvent) => { - if (!isResizing) - return + const resize = useCallback( + (e: MouseEvent) => { + if (!isResizing) return - const newWidth = window.innerWidth - e.clientX - const reservedCanvasWidth = 400 - const maxAllowed = workflowCanvasWidth ? (workflowCanvasWidth - reservedCanvasWidth) : 1024 + const newWidth = window.innerWidth - e.clientX + const reservedCanvasWidth = 400 + const maxAllowed = workflowCanvasWidth ? workflowCanvasWidth - reservedCanvasWidth : 1024 - if (newWidth >= 400 && newWidth <= maxAllowed) - setPreviewPanelWidth(newWidth) - }, [isResizing, setPreviewPanelWidth, workflowCanvasWidth]) + if (newWidth >= 400 && newWidth <= maxAllowed) setPreviewPanelWidth(newWidth) + }, + [isResizing, setPreviewPanelWidth, workflowCanvasWidth], + ) useEffect(() => { window.addEventListener('mousemove', resize) @@ -173,43 +163,42 @@ const SnippetRunPanel = ({ className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] leading-[18px] font-semibold ${currentTab === 'INPUT' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'}`} onClick={() => setSelectedTab('INPUT')} > - {t($ => $.input, { ns: 'runLog' })} + {t(($) => $.input, { ns: 'runLog' })} </div> )} <div className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] leading-[18px] font-semibold ${currentTab === 'RESULT' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'} ${!workflowRunningData ? '!cursor-not-allowed opacity-30' : ''}`} onClick={() => workflowRunningData && setSelectedTab('RESULT')} > - {t($ => $.result, { ns: 'runLog' })} + {t(($) => $.result, { ns: 'runLog' })} </div> <div className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] leading-[18px] font-semibold ${currentTab === 'DETAIL' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'} ${!workflowRunningData ? '!cursor-not-allowed opacity-30' : ''}`} onClick={() => workflowRunningData && setSelectedTab('DETAIL')} > - {t($ => $.detail, { ns: 'runLog' })} + {t(($) => $.detail, { ns: 'runLog' })} </div> <div className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] leading-[18px] font-semibold ${currentTab === 'TRACING' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'} ${!workflowRunningData ? '!cursor-not-allowed opacity-30' : ''}`} onClick={() => workflowRunningData && setSelectedTab('TRACING')} > - {t($ => $.tracing, { ns: 'runLog' })} + {t(($) => $.tracing, { ns: 'runLog' })} </div> </div> - <div className={`h-0 grow overflow-y-auto rounded-b-2xl ${(currentTab === 'RESULT' || currentTab === 'TRACING') ? '!bg-background-section-burn' : 'bg-components-panel-bg'}`}> + <div + className={`h-0 grow overflow-y-auto rounded-b-2xl ${currentTab === 'RESULT' || currentTab === 'TRACING' ? '!bg-background-section-burn' : 'bg-components-panel-bg'}`} + > {currentTab === 'INPUT' && hasInputTab && ( <> <div className="px-4 pt-3 pb-2"> {previewFields.map((field, index) => ( - <div - key={field.variable} - className="mb-2 last-of-type:mb-0" - > + <div key={field.variable} className="mb-2 last-of-type:mb-0"> <FormItem autoFocus={index === 0} className="!block" payload={field} value={inputs[field.variable]} - onChange={value => handleValueChange(field.variable, value)} + onChange={(value) => handleValueChange(field.variable, value)} /> </div> ))} @@ -221,7 +210,7 @@ const SnippetRunPanel = ({ disabled={workflowRunningData?.result?.status === WorkflowRunningStatus.Running} onClick={handleSubmit} > - {t($ => $['singleRun.startRun'], { ns: 'workflow' })} + {t(($) => $['singleRun.startRun'], { ns: 'workflow' })} </Button> </div> </> @@ -229,24 +218,29 @@ const SnippetRunPanel = ({ {currentTab === 'RESULT' && ( <div className="p-2"> <ResultText - isRunning={workflowRunningData?.result?.status === WorkflowRunningStatus.Running || !workflowRunningData?.result} + isRunning={ + workflowRunningData?.result?.status === WorkflowRunningStatus.Running || + !workflowRunningData?.result + } outputs={workflowRunningData?.resultText} allFiles={workflowRunningData?.result?.files} error={workflowRunningData?.result?.error} onClick={() => setSelectedTab('DETAIL')} /> - {(workflowRunningData?.result.status === WorkflowRunningStatus.Succeeded && workflowRunningData?.resultText && typeof workflowRunningData.resultText === 'string') && ( - <Button - className="mb-4 ml-4 space-x-1" - onClick={() => { - copy(workflowRunningData?.resultText || '') - toast.success(t($ => $['actionMsg.copySuccessfully'], { ns: 'common' })) - }} - > - <span className="i-ri-clipboard-line h-3.5 w-3.5" /> - <div>{t($ => $['operation.copy'], { ns: 'common' })}</div> - </Button> - )} + {workflowRunningData?.result.status === WorkflowRunningStatus.Succeeded && + workflowRunningData?.resultText && + typeof workflowRunningData.resultText === 'string' && ( + <Button + className="mb-4 ml-4 space-x-1" + onClick={() => { + copy(workflowRunningData?.resultText || '') + toast.success(t(($) => $['actionMsg.copySuccessfully'], { ns: 'common' })) + }} + > + <span className="i-ri-clipboard-line h-3.5 w-3.5" /> + <div>{t(($) => $['operation.copy'], { ns: 'common' })}</div> + </Button> + )} </div> )} {currentTab === 'DETAIL' && workflowRunningData?.result && ( @@ -263,7 +257,9 @@ const SnippetRunPanel = ({ elapsed_time={workflowRunningData.result?.elapsed_time} total_tokens={workflowRunningData.result?.total_tokens} created_at={workflowRunningData.result?.created_at} - created_by={(workflowRunningData.result?.created_by as unknown as { name: string })?.name} + created_by={ + (workflowRunningData.result?.created_by as unknown as { name: string })?.name + } steps={workflowRunningData.result?.total_steps} exceptionCounts={workflowRunningData.result?.exceptions_count} /> diff --git a/web/app/components/snippets/components/snippet-sidebar.tsx b/web/app/components/snippets/components/snippet-sidebar.tsx index d30be9a5641f04..567dbabe41e204 100644 --- a/web/app/components/snippets/components/snippet-sidebar.tsx +++ b/web/app/components/snippets/components/snippet-sidebar.tsx @@ -60,55 +60,76 @@ export const SnippetSidebarContent = ({ setIsShowAddVarModal(false) }, []) - const validateFields = useCallback((nextFields: SnippetInputField[]) => { - let errorMsgKey: 'varKeyError.keyAlreadyExists' | '' = '' - let typeName: 'variableConfig.varName' | 'variableConfig.labelName' | '' = '' - if (hasDuplicateStr(nextFields.map(item => item.variable))) { - errorMsgKey = 'varKeyError.keyAlreadyExists' - typeName = 'variableConfig.varName' - } - else if (hasDuplicateStr(nextFields.map(item => item.label as string))) { - errorMsgKey = 'varKeyError.keyAlreadyExists' - typeName = 'variableConfig.labelName' - } - - if (errorMsgKey && typeName) { - toast.error(t($ => $[errorMsgKey], { ns: 'appDebug', key: t($ => $[typeName], { ns: 'appDebug' }) })) - return false - } - - return true - }, [t]) - - const handleAddVarConfirm = useCallback((payload: InputVar) => { - const nextFields = [...fields, toSnippetInputField(payload)] - if (!validateFields(nextFields)) - return - - onFieldsChange(nextFields) - hideAddVarModal() - }, [fields, hideAddVarModal, onFieldsChange, validateFields]) - - const handleVarListChange = useCallback((list: InputVar[]) => { - const nextFields = list.map(toSnippetInputField) - if (isEqual(nextFields, fields)) - return - - onFieldsChange(nextFields) - }, [fields, onFieldsChange]) + const validateFields = useCallback( + (nextFields: SnippetInputField[]) => { + let errorMsgKey: 'varKeyError.keyAlreadyExists' | '' = '' + let typeName: 'variableConfig.varName' | 'variableConfig.labelName' | '' = '' + if (hasDuplicateStr(nextFields.map((item) => item.variable))) { + errorMsgKey = 'varKeyError.keyAlreadyExists' + typeName = 'variableConfig.varName' + } else if (hasDuplicateStr(nextFields.map((item) => item.label as string))) { + errorMsgKey = 'varKeyError.keyAlreadyExists' + typeName = 'variableConfig.labelName' + } + + if (errorMsgKey && typeName) { + toast.error( + t(($) => $[errorMsgKey], { + ns: 'appDebug', + key: t(($) => $[typeName], { ns: 'appDebug' }), + }), + ) + return false + } + + return true + }, + [t], + ) + + const handleAddVarConfirm = useCallback( + (payload: InputVar) => { + const nextFields = [...fields, toSnippetInputField(payload)] + if (!validateFields(nextFields)) return + + onFieldsChange(nextFields) + hideAddVarModal() + }, + [fields, hideAddVarModal, onFieldsChange, validateFields], + ) + + const handleVarListChange = useCallback( + (list: InputVar[]) => { + const nextFields = list.map(toSnippetInputField) + if (isEqual(nextFields, fields)) return + + onFieldsChange(nextFields) + }, + [fields, onFieldsChange], + ) return ( - <div className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-background-default', className)}> + <div + className={cn( + 'flex h-full min-h-0 flex-col overflow-hidden bg-background-default', + className, + )} + > <div className="shrink-0 px-3 py-2"> <div className="flex items-center gap-3"> <SnippetPlaceholderIcon /> <div className="min-w-0 grow"> - <div className="truncate system-xl-semibold text-text-primary" title={snippet.name}>{snippet.name}</div> + <div className="truncate system-xl-semibold text-text-primary" title={snippet.name}> + {snippet.name} + </div> </div> <SnippetInfoDropdown snippet={snippet} /> </div> {!!snippet.description && ( - <div className="mt-2 truncate system-sm-regular text-text-tertiary" title={snippet.description}> + <div + className="mt-2 truncate system-sm-regular text-text-tertiary" + title={snippet.description} + > {snippet.description} </div> )} @@ -117,7 +138,7 @@ export const SnippetSidebarContent = ({ <nav className="shrink-0 px-3 pt-4"> <NavLink mode="expand" - name={t($ => $.sectionOrchestrate, { ns: 'snippet' })} + name={t(($) => $.sectionOrchestrate, { ns: 'snippet' })} href={`/snippets/${snippet.id}/orchestrate`} active iconMap={{ selected: NodeTreeIcon, normal: NodeTreeIcon }} @@ -126,28 +147,24 @@ export const SnippetSidebarContent = ({ <div className="flex min-h-0 grow flex-col px-3 pt-6"> <Field - title={t($ => $.inputVariables, { ns: 'snippet' })} - operations={!readonly - ? ( - <button - type="button" - aria-label={`${t($ => $['operation.add'], { ns: 'common' })} ${t($ => $.inputVariables, { ns: 'snippet' })}`} - className={cn( - 'rounded-md border-none bg-transparent p-1 select-none focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', - 'cursor-pointer hover:bg-state-base-hover', - )} - onClick={showAddVarModal} - > - <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> - </button> - ) - : undefined} + title={t(($) => $.inputVariables, { ns: 'snippet' })} + operations={ + !readonly ? ( + <button + type="button" + aria-label={`${t(($) => $['operation.add'], { ns: 'common' })} ${t(($) => $.inputVariables, { ns: 'snippet' })}`} + className={cn( + 'rounded-md border-none bg-transparent p-1 select-none focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + 'cursor-pointer hover:bg-state-base-hover', + )} + onClick={showAddVarModal} + > + <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> + </button> + ) : undefined + } > - <VarList - readonly={readonly} - list={workflowInputVars} - onChange={handleVarListChange} - /> + <VarList readonly={readonly} list={workflowInputVars} onChange={handleVarListChange} /> </Field> </div> @@ -159,7 +176,7 @@ export const SnippetSidebarContent = ({ onClose={hideAddVarModal} onConfirm={handleAddVarConfirm} showHiddenField={false} - varKeys={fields.map(v => v.variable)} + varKeys={fields.map((v) => v.variable)} /> )} </div> diff --git a/web/app/components/snippets/components/workflow-panel.tsx b/web/app/components/snippets/components/workflow-panel.tsx index a05102f836434a..e00164e68de599 100644 --- a/web/app/components/snippets/components/workflow-panel.tsx +++ b/web/app/components/snippets/components/workflow-panel.tsx @@ -19,11 +19,9 @@ type SnippetWorkflowPanelProps = { fields: SnippetInputField[] } -const SnippetPanelOnRight = ({ - fields, -}: Pick<SnippetWorkflowPanelProps, 'fields'>) => { - const historyWorkflowData = useStore(s => s.historyWorkflowData) - const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel) +const SnippetPanelOnRight = ({ fields }: Pick<SnippetWorkflowPanelProps, 'fields'>) => { + const historyWorkflowData = useStore((s) => s.historyWorkflowData) + const showDebugAndPreviewPanel = useStore((s) => s.showDebugAndPreviewPanel) return ( <> @@ -33,15 +31,13 @@ const SnippetPanelOnRight = ({ ) } -const SnippetWorkflowPanel = ({ - snippetId, - fields, -}: SnippetWorkflowPanelProps) => { +const SnippetWorkflowPanel = ({ snippetId, fields }: SnippetWorkflowPanelProps) => { const versionHistoryPanelProps = useMemo(() => { return { getVersionListUrl: `/snippets/${snippetId}/workflows`, deleteVersionUrl: (versionId: string) => `/snippets/${snippetId}/workflows/${versionId}`, - restoreVersionUrl: (versionId: string) => `/snippets/${snippetId}/workflows/${versionId}/restore`, + restoreVersionUrl: (versionId: string) => + `/snippets/${snippetId}/workflows/${versionId}/restore`, updateVersionUrl: (versionId: string) => `/snippets/${snippetId}/workflows/${versionId}`, latestVersionId: '', } diff --git a/web/app/components/snippets/create-snippet-dialog.tsx b/web/app/components/snippets/create-snippet-dialog.tsx index 77a94e0829f4fb..b6648a4b319c33 100644 --- a/web/app/components/snippets/create-snippet-dialog.tsx +++ b/web/app/components/snippets/create-snippet-dialog.tsx @@ -68,8 +68,7 @@ function CreateSnippetDialog({ const trimmedName = name.trim() const trimmedDescription = description.trim() - if (!trimmedName) - return + if (!trimmedName) return const payload = { name: trimmedName, @@ -82,36 +81,34 @@ function CreateSnippetDialog({ }, [description, inputFields, name, onConfirm, selectedGraph]) useKeyPress(['meta.enter', 'ctrl.enter'], () => { - if (!isOpen) - return + if (!isOpen) return - if (isSubmitting) - return + if (isSubmitting) return handleConfirm() }) return ( <> - <Dialog open={isOpen} onOpenChange={open => !open && handleClose()}> + <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}> <DialogContent className="w-120 max-w-120 p-0"> <DialogCloseButton /> <div className="px-6 pt-6 pb-3"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {title || t($ => $['snippet.createDialogTitle'], { ns: 'workflow' })} + {title || t(($) => $['snippet.createDialogTitle'], { ns: 'workflow' })} </DialogTitle> </div> <div className="space-y-4 px-6 py-2"> <div> <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $['snippet.nameLabel'], { ns: 'workflow' })} + {t(($) => $['snippet.nameLabel'], { ns: 'workflow' })} </div> <Input value={name} - onChange={e => setName(e.target.value)} - placeholder={t($ => $['snippet.namePlaceholder'], { ns: 'workflow' }) || ''} + onChange={(e) => setName(e.target.value)} + placeholder={t(($) => $['snippet.namePlaceholder'], { ns: 'workflow' }) || ''} disabled={isSubmitting} autoFocus /> @@ -119,13 +116,15 @@ function CreateSnippetDialog({ <div> <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $['snippet.descriptionLabel'], { ns: 'workflow' })} + {t(($) => $['snippet.descriptionLabel'], { ns: 'workflow' })} </div> <Textarea className="resize-none" value={description} - onValueChange={value => setDescription(value)} - placeholder={t($ => $['snippet.descriptionPlaceholder'], { ns: 'workflow' }) || ''} + onValueChange={(value) => setDescription(value)} + placeholder={ + t(($) => $['snippet.descriptionPlaceholder'], { ns: 'workflow' }) || '' + } disabled={isSubmitting} /> </div> @@ -133,7 +132,7 @@ function CreateSnippetDialog({ <div className="flex items-center justify-end gap-2 px-6 pb-6"> <Button disabled={isSubmitting} onClick={handleClose}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button variant="primary" @@ -141,7 +140,7 @@ function CreateSnippetDialog({ loading={isSubmitting} onClick={handleConfirm} > - {confirmText || t($ => $['snippet.confirm'], { ns: 'workflow' })} + {confirmText || t(($) => $['snippet.confirm'], { ns: 'workflow' })} </Button> </div> </DialogContent> diff --git a/web/app/components/snippets/draft-store/__tests__/index.spec.ts b/web/app/components/snippets/draft-store/__tests__/index.spec.ts index d50067f2033e33..3d60cbdd944c8c 100644 --- a/web/app/components/snippets/draft-store/__tests__/index.spec.ts +++ b/web/app/components/snippets/draft-store/__tests__/index.spec.ts @@ -15,10 +15,7 @@ describe('useSnippetDraftStore', () => { }) it('should store and reset snippet input fields', () => { - const inputFields = [ - createField('topic'), - createField('audience'), - ] + const inputFields = [createField('topic'), createField('audience')] useSnippetDraftStore.getState().hydrateDraft({ snippetId: 'snippet-1', diff --git a/web/app/components/snippets/draft-store/index.ts b/web/app/components/snippets/draft-store/index.ts index 2cc9081b016d97..3cfe0c028ad29b 100644 --- a/web/app/components/snippets/draft-store/index.ts +++ b/web/app/components/snippets/draft-store/index.ts @@ -6,7 +6,7 @@ import { create } from 'zustand' type SnippetDraftState = { snippetId?: string inputFields: SnippetInputField[] - hydrateDraft: (payload: { snippetId: string, inputFields: SnippetInputField[] }) => void + hydrateDraft: (payload: { snippetId: string; inputFields: SnippetInputField[] }) => void setInputFields: (inputFields: SnippetInputField[]) => void reset: () => void } @@ -16,9 +16,9 @@ const initialState = { inputFields: [] as SnippetInputField[], } -export const useSnippetDraftStore = create<SnippetDraftState>(set => ({ +export const useSnippetDraftStore = create<SnippetDraftState>((set) => ({ ...initialState, hydrateDraft: ({ snippetId, inputFields }) => set({ snippetId, inputFields }), - setInputFields: inputFields => set({ inputFields }), + setInputFields: (inputFields) => set({ inputFields }), reset: () => set(initialState), })) diff --git a/web/app/components/snippets/hooks/__tests__/use-create-snippet-from-selection.spec.tsx b/web/app/components/snippets/hooks/__tests__/use-create-snippet-from-selection.spec.tsx index ddf48659807fca..508f03ddd84a92 100644 --- a/web/app/components/snippets/hooks/__tests__/use-create-snippet-from-selection.spec.tsx +++ b/web/app/components/snippets/hooks/__tests__/use-create-snippet-from-selection.spec.tsx @@ -29,17 +29,15 @@ type DialogProps = { inputFields?: SnippetInputField[] } -const createNode = ( - id: string, - data: Record<string, unknown>, -): Node => ({ - id, - type: 'custom', - position: { x: 0, y: 0 }, - width: 200, - height: 100, - data, -} as unknown as Node) +const createNode = (id: string, data: Record<string, unknown>): Node => + ({ + id, + type: 'custom', + position: { x: 0, y: 0 }, + width: 200, + height: 100, + data, + }) as unknown as Node describe('useCreateSnippetFromSelection', () => { beforeEach(() => { @@ -63,11 +61,13 @@ describe('useCreateSnippetFromSelection', () => { const edges: Edge[] = [] const onClose = vi.fn() - const { result } = renderHook(() => useCreateSnippetFromSelection({ - edges, - selectedNodes, - onClose, - })) + const { result } = renderHook(() => + useCreateSnippetFromSelection({ + edges, + selectedNodes, + onClose, + }), + ) act(() => { result.current.handleOpenCreateSnippet() @@ -107,19 +107,20 @@ describe('useCreateSnippetFromSelection', () => { required: true, }, ]) - const nodeData = dialogProps.selectedGraph?.nodes[0]?.data as Record<string, unknown> | undefined + const nodeData = dialogProps.selectedGraph?.nodes[0]?.data as + | Record<string, unknown> + | undefined - expect(nodeData?.prompt).toBe([ - `{{#${SNIPPET_INPUT_FIELD_NODE_ID}.API_KEY#}}`, - `{{#${SNIPPET_INPUT_FIELD_NODE_ID}.user_name#}}`, - `{{#${SNIPPET_INPUT_FIELD_NODE_ID}.user_id#}}`, - '{{#rag.query#}}', - `{{#${SNIPPET_INPUT_FIELD_NODE_ID}.result#}}`, - ].join(' ')) - expect(nodeData?.model_selector).toEqual([ - SNIPPET_INPUT_FIELD_NODE_ID, - 'MODEL_NAME', - ]) + expect(nodeData?.prompt).toBe( + [ + `{{#${SNIPPET_INPUT_FIELD_NODE_ID}.API_KEY#}}`, + `{{#${SNIPPET_INPUT_FIELD_NODE_ID}.user_name#}}`, + `{{#${SNIPPET_INPUT_FIELD_NODE_ID}.user_id#}}`, + '{{#rag.query#}}', + `{{#${SNIPPET_INPUT_FIELD_NODE_ID}.result#}}`, + ].join(' '), + ) + expect(nodeData?.model_selector).toEqual([SNIPPET_INPUT_FIELD_NODE_ID, 'MODEL_NAME']) expect(onClose).toHaveBeenCalled() }) @@ -131,15 +132,19 @@ describe('useCreateSnippetFromSelection', () => { }), createNode('if-else', { type: BlockEnum.IfElse, - cases: [{ - case_id: 'case-1', - conditions: [{ - id: 'condition-1', - variable_selector: ['sys', 'query'], - comparison_operator: 'contains', - value: 'hello', - }], - }], + cases: [ + { + case_id: 'case-1', + conditions: [ + { + id: 'condition-1', + variable_selector: ['sys', 'query'], + comparison_operator: 'contains', + value: 'hello', + }, + ], + }, + ], }), createNode('variable-aggregator', { type: BlockEnum.VariableAggregator, @@ -149,23 +154,25 @@ describe('useCreateSnippetFromSelection', () => { ], advanced_settings: { group_enabled: true, - groups: [{ - groupId: 'group-1', - group_name: 'Group1', - variables: [ - ['sys', 'workflow_id'], - ], - }], + groups: [ + { + groupId: 'group-1', + group_name: 'Group1', + variables: [['sys', 'workflow_id']], + }, + ], }, }), ] const onClose = vi.fn() - const { result } = renderHook(() => useCreateSnippetFromSelection({ - edges: [], - selectedNodes, - onClose, - })) + const { result } = renderHook(() => + useCreateSnippetFromSelection({ + edges: [], + selectedNodes, + onClose, + }), + ) act(() => { result.current.handleOpenCreateSnippet() @@ -194,23 +201,29 @@ describe('useCreateSnippetFromSelection', () => { }, ]) - const ifElseNode = dialogProps.selectedGraph?.nodes.find(node => node.id === 'if-else') - const aggregatorNode = dialogProps.selectedGraph?.nodes.find(node => node.id === 'variable-aggregator') + const ifElseNode = dialogProps.selectedGraph?.nodes.find((node) => node.id === 'if-else') + const aggregatorNode = dialogProps.selectedGraph?.nodes.find( + (node) => node.id === 'variable-aggregator', + ) const ifElseData = ifElseNode?.data as Record<string, unknown> const aggregatorData = aggregatorNode?.data as { variables?: string[][] advanced_settings?: { groups?: Array<{ variables?: string[][] }> } } - expect(ifElseData.cases).toEqual([{ - case_id: 'case-1', - conditions: [{ - id: 'condition-1', - variable_selector: [SNIPPET_INPUT_FIELD_NODE_ID, 'query'], - comparison_operator: 'contains', - value: 'hello', - }], - }]) + expect(ifElseData.cases).toEqual([ + { + case_id: 'case-1', + conditions: [ + { + id: 'condition-1', + variable_selector: [SNIPPET_INPUT_FIELD_NODE_ID, 'query'], + comparison_operator: 'contains', + value: 'hello', + }, + ], + }, + ]) expect(aggregatorData.variables).toEqual([ [SNIPPET_INPUT_FIELD_NODE_ID, 'files'], ['llm', 'text'], @@ -233,11 +246,13 @@ describe('useCreateSnippetFromSelection', () => { ] const onClose = vi.fn() - const { result } = renderHook(() => useCreateSnippetFromSelection({ - edges: [], - selectedNodes, - onClose, - })) + const { result } = renderHook(() => + useCreateSnippetFromSelection({ + edges: [], + selectedNodes, + onClose, + }), + ) act(() => { result.current.handleOpenCreateSnippet() diff --git a/web/app/components/snippets/hooks/__tests__/use-create-snippet.spec.tsx b/web/app/components/snippets/hooks/__tests__/use-create-snippet.spec.tsx index 052f9c10acce79..0b1cbfa309573a 100644 --- a/web/app/components/snippets/hooks/__tests__/use-create-snippet.spec.tsx +++ b/web/app/components/snippets/hooks/__tests__/use-create-snippet.spec.tsx @@ -80,7 +80,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -117,7 +118,11 @@ describe('useCreateSnippet', () => { describe('Create Flow', () => { it('should create snippet with graph and navigate on success', async () => { mockMutateAsync.mockResolvedValue({ id: 'snippet-123' }) - mockSyncDraftWorkflow.mockResolvedValue({ result: 'success', hash: 'draft-hash', updated_at: 1704067200 }) + mockSyncDraftWorkflow.mockResolvedValue({ + result: 'success', + hash: 'draft-hash', + updated_at: 1704067200, + }) const graph = { nodes: [], edges: [], diff --git a/web/app/components/snippets/hooks/__tests__/use-inspect-vars-crud.spec.ts b/web/app/components/snippets/hooks/__tests__/use-inspect-vars-crud.spec.ts index 69e264d0ccfa29..0110c837ad45ec 100644 --- a/web/app/components/snippets/hooks/__tests__/use-inspect-vars-crud.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-inspect-vars-crud.spec.ts @@ -21,7 +21,8 @@ const mockApis = { const mockUseInspectVarsCrudCommon = vi.fn(() => mockApis) vi.mock('../../../workflow/hooks/use-inspect-vars-crud-common', () => ({ - useInspectVarsCrudCommon: (...args: Parameters<typeof mockUseInspectVarsCrudCommon>) => mockUseInspectVarsCrudCommon(...args), + useInspectVarsCrudCommon: (...args: Parameters<typeof mockUseInspectVarsCrudCommon>) => + mockUseInspectVarsCrudCommon(...args), })) const mockConfigsMap = { @@ -88,8 +89,7 @@ describe('useInspectVarsCrud', () => { 'invalidateConversationVarValues', ] - for (const key of expectedKeys) - expect(result.current).toHaveProperty(key) + for (const key of expectedKeys) expect(result.current).toHaveProperty(key) }) }) }) diff --git a/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts index 4fce06922b5501..3b1d4658c32ca2 100644 --- a/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -38,10 +38,10 @@ vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({ })) vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({ - useSerialAsyncCallback: (fn: (...args: unknown[]) => Promise<void>, checkFn?: () => boolean) => + useSerialAsyncCallback: + (fn: (...args: unknown[]) => Promise<void>, checkFn?: () => boolean) => (...args: unknown[]) => { - if (checkFn?.()) - return + if (checkFn?.()) return if (deferSerialCallbacks) { queuedSerialCallbacks.push(() => fn(...args)) @@ -150,9 +150,12 @@ describe('snippet/use-nodes-sync-draft', () => { result.current.syncWorkflowDraftWhenPageClose() }) - expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/snippets/snippet-1/workflows/draft', expect.objectContaining({ - input_fields: [createInputField('topic')], - })) + expect(mockPostWithKeepalive).toHaveBeenCalledWith( + '/api/snippets/snippet-1/workflows/draft', + expect.objectContaining({ + input_fields: [createInputField('topic')], + }), + ) }) it('should snapshot graph before queued draft sync executes', async () => { @@ -166,11 +169,13 @@ describe('snippet/use-nodes-sync-draft', () => { mockGetNodes.mockReturnValue([ { id: 'late-node', position: { x: 9, y: 9 }, data: { title: 'Late' } }, ]) - reactFlowState.edges = [{ id: 'late-edge', source: 'late-node', target: 'late-target', data: { stable: false } }] + reactFlowState.edges = [ + { id: 'late-edge', source: 'late-node', target: 'late-target', data: { stable: false } }, + ] reactFlowState.transform = [99, 88, 0.5] await act(async () => { - await Promise.all(queuedSerialCallbacks.map(run => run())) + await Promise.all(queuedSerialCallbacks.map((run) => run())) }) expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({ diff --git a/web/app/components/snippets/hooks/__tests__/use-snippet-init.spec.ts b/web/app/components/snippets/hooks/__tests__/use-snippet-init.spec.ts index 347cb09674f5bb..88ed85392a8566 100644 --- a/web/app/components/snippets/hooks/__tests__/use-snippet-init.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-snippet-init.spec.ts @@ -1,8 +1,5 @@ import type { SnippetWorkflow } from '@/types/snippet' -import { - renderHook, - waitFor, -} from '@testing-library/react' +import { renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useSnippetInit } from '../use-snippet-init' @@ -37,8 +34,12 @@ vi.mock('@/service/use-snippets', async (importOriginal) => { vi.mock('@/service/use-snippet-workflows', () => ({ fetchSnippetDraftWorkflow: (snippetId: string) => mockFetchSnippetDraftWorkflow(snippetId), - useSnippetDefaultBlockConfigs: (snippetId: string, onSuccess?: (data: unknown) => void) => mockUseSnippetDefaultBlockConfigs(snippetId, onSuccess), - useSnippetPublishedWorkflow: (snippetId: string, onSuccess?: (data: { created_at: number }) => void) => mockUseSnippetPublishedWorkflow(snippetId, onSuccess), + useSnippetDefaultBlockConfigs: (snippetId: string, onSuccess?: (data: unknown) => void) => + mockUseSnippetDefaultBlockConfigs(snippetId, onSuccess), + useSnippetPublishedWorkflow: ( + snippetId: string, + onSuccess?: (data: { created_at: number }) => void, + ) => mockUseSnippetPublishedWorkflow(snippetId, onSuccess), })) const createDraftWorkflow = (overrides: Partial<SnippetWorkflow> = {}): SnippetWorkflow => ({ @@ -124,16 +125,18 @@ describe('useSnippetInit', () => { error: null, isLoading: false, }) - mockFetchSnippetDraftWorkflow.mockResolvedValue(createDraftWorkflow({ - input_fields: [ - { - label: 'Draft field', - variable: 'draft_field', - type: 'text-input', - required: true, - }, - ], - })) + mockFetchSnippetDraftWorkflow.mockResolvedValue( + createDraftWorkflow({ + input_fields: [ + { + label: 'Draft field', + variable: 'draft_field', + type: 'text-input', + required: true, + }, + ], + }), + ) const { result } = renderHook(() => useSnippetInit('snippet-1')) @@ -152,15 +155,17 @@ describe('useSnippetInit', () => { }) it('should sync draft metadata before returning initialized data', async () => { - mockFetchSnippetDraftWorkflow.mockResolvedValue(createDraftWorkflow({ - hash: 'fetched-draft-hash', - updated_at: 1_712_345_678, - graph: { - nodes: [{ id: 'node-1' }], - edges: [], - viewport: { x: 10, y: 20, zoom: 1.2 }, - }, - })) + mockFetchSnippetDraftWorkflow.mockResolvedValue( + createDraftWorkflow({ + hash: 'fetched-draft-hash', + updated_at: 1_712_345_678, + graph: { + nodes: [{ id: 'node-1' }], + edges: [], + viewport: { x: 10, y: 20, zoom: 1.2 }, + }, + }), + ) const { result } = renderHook(() => useSnippetInit('snippet-1')) @@ -207,15 +212,17 @@ describe('useSnippetInit', () => { }) } - return Promise.resolve(createDraftWorkflow({ - id: 'draft-2', - hash: 'snippet-2-hash', - graph: { - nodes: [{ id: 'snippet-2-node' }], - edges: [], - viewport: { x: 2, y: 2, zoom: 1 }, - }, - })) + return Promise.resolve( + createDraftWorkflow({ + id: 'draft-2', + hash: 'snippet-2-hash', + graph: { + nodes: [{ id: 'snippet-2-node' }], + edges: [], + viewport: { x: 2, y: 2, zoom: 1 }, + }, + }), + ) }) const { result, rerender } = renderHook(({ snippetId }) => useSnippetInit(snippetId), { @@ -228,14 +235,16 @@ describe('useSnippetInit', () => { expect(result.current.isLoading).toBe(false) }) - resolveFirstDraft(createDraftWorkflow({ - hash: 'stale-snippet-1-hash', - graph: { - nodes: [{ id: 'stale-node' }], - edges: [], - viewport: { x: 1, y: 1, zoom: 1 }, - }, - })) + resolveFirstDraft( + createDraftWorkflow({ + hash: 'stale-snippet-1-hash', + graph: { + nodes: [{ id: 'stale-node' }], + edges: [], + viewport: { x: 1, y: 1, zoom: 1 }, + }, + }), + ) await Promise.resolve() expect(result.current.data?.draft.graph.nodes).toEqual([{ id: 'snippet-2-node' }]) @@ -244,13 +253,15 @@ describe('useSnippetInit', () => { }) it('should normalize array default block configs into workflow store state', () => { - mockUseSnippetDefaultBlockConfigs.mockImplementation((_snippetId: string, onSuccess?: (data: unknown) => void) => { - onSuccess?.([ - { type: 'llm', config: { model: 'gpt-4.1' } }, - { type: 'code', config: { language: 'python3' } }, - ]) - return { data: undefined, isLoading: false } - }) + mockUseSnippetDefaultBlockConfigs.mockImplementation( + (_snippetId: string, onSuccess?: (data: unknown) => void) => { + onSuccess?.([ + { type: 'llm', config: { model: 'gpt-4.1' } }, + { type: 'code', config: { language: 'python3' } }, + ]) + return { data: undefined, isLoading: false } + }, + ) renderHook(() => useSnippetInit('snippet-1')) @@ -263,12 +274,14 @@ describe('useSnippetInit', () => { }) it('should keep object default block configs as-is', () => { - mockUseSnippetDefaultBlockConfigs.mockImplementation((_snippetId: string, onSuccess?: (data: unknown) => void) => { - onSuccess?.({ - llm: { model: 'gpt-4.1' }, - }) - return { data: undefined, isLoading: false } - }) + mockUseSnippetDefaultBlockConfigs.mockImplementation( + (_snippetId: string, onSuccess?: (data: unknown) => void) => { + onSuccess?.({ + llm: { model: 'gpt-4.1' }, + }) + return { data: undefined, isLoading: false } + }, + ) renderHook(() => useSnippetInit('snippet-1')) @@ -280,12 +293,14 @@ describe('useSnippetInit', () => { }) it('should sync published created_at into workflow store', () => { - mockUseSnippetPublishedWorkflow.mockImplementation((_snippetId: string, onSuccess?: (data: { created_at: number }) => void) => { - onSuccess?.({ - created_at: 1_712_345_678, - }) - return { data: { created_at: 1_712_345_678 }, isLoading: false } - }) + mockUseSnippetPublishedWorkflow.mockImplementation( + (_snippetId: string, onSuccess?: (data: { created_at: number }) => void) => { + onSuccess?.({ + created_at: 1_712_345_678, + }) + return { data: { created_at: 1_712_345_678 }, isLoading: false } + }, + ) renderHook(() => useSnippetInit('snippet-1')) diff --git a/web/app/components/snippets/hooks/__tests__/use-snippet-refresh-draft.spec.ts b/web/app/components/snippets/hooks/__tests__/use-snippet-refresh-draft.spec.ts index eb500839efbef4..580783ed2f2af6 100644 --- a/web/app/components/snippets/hooks/__tests__/use-snippet-refresh-draft.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-snippet-refresh-draft.spec.ts @@ -37,20 +37,21 @@ vi.mock('../../draft-store', () => ({ }, })) -const createDraftWorkflow = (overrides: Partial<SnippetWorkflow> = {}): SnippetWorkflow => ({ - id: 'draft-1', - graph: { - nodes: [{ id: 'node-1' }], - edges: [], - viewport: { x: 10, y: 20, zoom: 1.2 }, - }, - features: {}, - input_fields: [], - hash: 'draft-hash', - created_at: 1_712_300_000, - updated_at: 1_712_345_678, - ...overrides, -} as SnippetWorkflow) +const createDraftWorkflow = (overrides: Partial<SnippetWorkflow> = {}): SnippetWorkflow => + ({ + id: 'draft-1', + graph: { + nodes: [{ id: 'node-1' }], + edges: [], + viewport: { x: 10, y: 20, zoom: 1.2 }, + }, + features: {}, + input_fields: [], + hash: 'draft-hash', + created_at: 1_712_300_000, + updated_at: 1_712_345_678, + ...overrides, + }) as SnippetWorkflow describe('useSnippetRefreshDraft', () => { beforeEach(() => { diff --git a/web/app/components/snippets/hooks/__tests__/use-snippet-run.spec.ts b/web/app/components/snippets/hooks/__tests__/use-snippet-run.spec.ts index 608dc8cee1fcf2..1511430ece31cc 100644 --- a/web/app/components/snippets/hooks/__tests__/use-snippet-run.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-snippet-run.spec.ts @@ -145,8 +145,12 @@ describe('useSnippetRun', () => { result.current.handleStopRun('task-1') }) - expect(mocks.mockStopWorkflowRun).toHaveBeenCalledWith('/snippets/snippet-1/workflow-runs/tasks/task-1/stop') - expect(mocks.workflowStoreState.workflowRunningData?.result.status).toBe(WorkflowRunningStatus.Stopped) + expect(mocks.mockStopWorkflowRun).toHaveBeenCalledWith( + '/snippets/snippet-1/workflow-runs/tasks/task-1/stop', + ) + expect(mocks.workflowStoreState.workflowRunningData?.result.status).toBe( + WorkflowRunningStatus.Stopped, + ) }) it('does not fall back to the workflow running task id when stop is called without one', () => { @@ -158,7 +162,9 @@ describe('useSnippetRun', () => { }) expect(mocks.mockStopWorkflowRun).not.toHaveBeenCalled() - expect(mocks.workflowStoreState.workflowRunningData?.result.status).toBe(WorkflowRunningStatus.Stopped) + expect(mocks.workflowStoreState.workflowRunningData?.result.status).toBe( + WorkflowRunningStatus.Stopped, + ) }) it('does not call the stop endpoint when task id is unavailable', () => { @@ -170,6 +176,8 @@ describe('useSnippetRun', () => { }) expect(mocks.mockStopWorkflowRun).not.toHaveBeenCalled() - expect(mocks.workflowStoreState.workflowRunningData?.result.status).toBe(WorkflowRunningStatus.Stopped) + expect(mocks.workflowStoreState.workflowRunningData?.result.status).toBe( + WorkflowRunningStatus.Stopped, + ) }) }) diff --git a/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts b/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts index cc8cbb9f536344..cc789a7d5c1a3b 100644 --- a/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts @@ -53,9 +53,11 @@ describe('useSnippetStartRun', () => { it('should open the debug panel and input form when snippet has input fields', () => { useSnippetDraftStore.getState().setInputFields(inputFields) - const { result } = renderHook(() => useSnippetStartRun({ - handleRun: mockHandleRun, - })) + const { result } = renderHook(() => + useSnippetStartRun({ + handleRun: mockHandleRun, + }), + ) act(() => { result.current.handleWorkflowStartRunInWorkflow() @@ -69,9 +71,11 @@ describe('useSnippetStartRun', () => { }) it('should run immediately when snippet has no input fields', () => { - const { result } = renderHook(() => useSnippetStartRun({ - handleRun: mockHandleRun, - })) + const { result } = renderHook(() => + useSnippetStartRun({ + handleRun: mockHandleRun, + }), + ) act(() => { result.current.handleWorkflowStartRunInWorkflow() @@ -85,9 +89,11 @@ describe('useSnippetStartRun', () => { it('should use current snippet input fields from the store before starting a run', () => { useSnippetDraftStore.getState().setInputFields(inputFields) - const { result } = renderHook(() => useSnippetStartRun({ - handleRun: mockHandleRun, - })) + const { result } = renderHook(() => + useSnippetStartRun({ + handleRun: mockHandleRun, + }), + ) act(() => { result.current.handleWorkflowStartRunInWorkflow() @@ -110,9 +116,11 @@ describe('useSnippetStartRun', () => { setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel, }) - const { result } = renderHook(() => useSnippetStartRun({ - handleRun: mockHandleRun, - })) + const { result } = renderHook(() => + useSnippetStartRun({ + handleRun: mockHandleRun, + }), + ) act(() => { result.current.handleWorkflowStartRunInWorkflow() @@ -137,9 +145,11 @@ describe('useSnippetStartRun', () => { setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel, }) - const { result } = renderHook(() => useSnippetStartRun({ - handleRun: mockHandleRun, - })) + const { result } = renderHook(() => + useSnippetStartRun({ + handleRun: mockHandleRun, + }), + ) act(() => { result.current.handleWorkflowStartRunInWorkflow() diff --git a/web/app/components/snippets/hooks/use-configs-map.ts b/web/app/components/snippets/hooks/use-configs-map.ts index 7d5e2863a143e9..05d7bc4b589990 100644 --- a/web/app/components/snippets/hooks/use-configs-map.ts +++ b/web/app/components/snippets/hooks/use-configs-map.ts @@ -4,7 +4,7 @@ import { Resolution, TransferMethod } from '@/types/app' import { FlowType } from '@/types/common' export const useConfigsMap = (snippetId: string) => { - const fileUploadConfig = useStore(s => s.fileUploadConfig) + const fileUploadConfig = useStore((s) => s.fileUploadConfig) return useMemo(() => { return { diff --git a/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx b/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx index 31ed8a5f379336..d398e08b504d1b 100644 --- a/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx +++ b/web/app/components/snippets/hooks/use-create-snippet-from-selection.tsx @@ -19,9 +19,7 @@ const isRecord = (value: unknown): value is Record<string, unknown> => { } const isValueSelector = (value: unknown): value is ValueSelector => { - return Array.isArray(value) - && value.length > 0 - && value.every(item => typeof item === 'string') + return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string') } const isSelectorKey = (key?: string) => { @@ -37,17 +35,14 @@ const isValueSelectorList = (value: unknown[]) => { } const isContextPlaceholderSelector = (selector: ValueSelector) => { - return (selector.length === 1 && selector[0] === 'context') - || selector.at(-1) === '#context#' + return (selector.length === 1 && selector[0] === 'context') || selector.at(-1) === '#context#' } const getCenteredViewport = (nodes: Node[]) => { - if (!nodes.length) - return DEFAULT_SNIPPET_VIEWPORT + if (!nodes.length) return DEFAULT_SNIPPET_VIEWPORT const bounds = getNodesBounds(nodes) - if (!bounds.width || !bounds.height) - return DEFAULT_SNIPPET_VIEWPORT + if (!bounds.width || !bounds.height) return DEFAULT_SNIPPET_VIEWPORT const zoom = Math.min( (SNIPPET_VIEWPORT_WIDTH - SNIPPET_VIEWPORT_PADDING * 2) / bounds.width, @@ -67,81 +62,61 @@ const getCenteredViewport = (nodes: Node[]) => { const collectSelectorsFromText = (value: string, selectors: ValueSelector[]) => { for (const match of value.matchAll(VARIABLE_REFERENCE_REGEX)) { const variablePath = match[1] - if (!variablePath) - continue + if (!variablePath) continue const selector = variablePath.split('.').filter(Boolean) - if (selector.length > 0 && !isContextPlaceholderSelector(selector)) - selectors.push(selector) + if (selector.length > 0 && !isContextPlaceholderSelector(selector)) selectors.push(selector) } } -const collectVariableSelectors = ( - value: unknown, - selectors: ValueSelector[], - key?: string, -) => { +const collectVariableSelectors = (value: unknown, selectors: ValueSelector[], key?: string) => { if (typeof value === 'string') { collectSelectorsFromText(value, selectors) return } if (Array.isArray(value)) { - if (isSelectorKey(key) && isValueSelector(value)) - selectors.push(value) + if (isSelectorKey(key) && isValueSelector(value)) selectors.push(value) if (isValueSelectorListKey(key) && isValueSelectorList(value)) { - value.forEach(selector => selectors.push(selector)) + value.forEach((selector) => selectors.push(selector)) return } - value.forEach(item => collectVariableSelectors(item, selectors)) + value.forEach((item) => collectVariableSelectors(item, selectors)) return } - if (!isRecord(value)) - return + if (!isRecord(value)) return Object.entries(value).forEach(([currentKey, currentValue]) => { collectVariableSelectors(currentValue, selectors, currentKey) }) } -const isExternalVariableSelector = ( - selector: ValueSelector, - selectedNodeIds: Set<string>, -) => { +const isExternalVariableSelector = (selector: ValueSelector, selectedNodeIds: Set<string>) => { const nodeId = selector[0] - if (!nodeId) - return false + if (!nodeId) return false - if (nodeId.startsWith('$')) - return false + if (nodeId.startsWith('$')) return false - if (isContextPlaceholderSelector(selector)) - return false + if (isContextPlaceholderSelector(selector)) return false - if (selectedNodeIds.has(nodeId)) - return false + if (selectedNodeIds.has(nodeId)) return false return !RESERVED_VARIABLE_PREFIXES.has(nodeId) } const sanitizeInputFieldVariable = (variable: string) => { const sanitized = variable.replace(/\W/g, '_') - if (!sanitized) - return 'input' + if (!sanitized) return 'input' - if (/^\d/.test(sanitized)) - return `input_${sanitized}` + if (/^\d/.test(sanitized)) return `input_${sanitized}` return sanitized } -const getUniqueInputFieldVariable = ( - selector: ValueSelector, - usedVariables: Set<string>, -) => { +const getUniqueInputFieldVariable = (selector: ValueSelector, usedVariables: Set<string>) => { const baseVariable = sanitizeInputFieldVariable(selector.at(-1) ?? 'input') let variable = baseVariable let index = 2 @@ -157,29 +132,23 @@ const getUniqueInputFieldVariable = ( const getInputFieldType = (selector: ValueSelector) => { const variable = selector.at(-1) - if (variable === 'files') - return PipelineInputVarType.multiFiles + if (variable === 'files') return PipelineInputVarType.multiFiles return PipelineInputVarType.textInput } -const getExternalVariableInputFields = ( - nodes: Node[], - selectedNodeIds: Set<string>, -) => { +const getExternalVariableInputFields = (nodes: Node[], selectedNodeIds: Set<string>) => { const selectors: ValueSelector[] = [] - nodes.forEach(node => collectVariableSelectors(node.data, selectors)) + nodes.forEach((node) => collectVariableSelectors(node.data, selectors)) const usedVariables = new Set<string>() const fieldBySelector = new Map<string, SnippetInputField>() selectors.forEach((selector) => { - if (!isExternalVariableSelector(selector, selectedNodeIds)) - return + if (!isExternalVariableSelector(selector, selectedNodeIds)) return const selectorKey = selector.join('.') - if (fieldBySelector.has(selectorKey)) - return + if (fieldBySelector.has(selectorKey)) return const variable = getUniqueInputFieldVariable(selector, usedVariables) fieldBySelector.set(selectorKey, { @@ -209,8 +178,7 @@ const rewriteVariableReferences = ( if (typeof value === 'string') { return value.replace(VARIABLE_REFERENCE_REGEX, (match, variablePath: string) => { const nextSelector = selectorMap.get(variablePath) - if (!nextSelector) - return match + if (!nextSelector) return match return `{{#${nextSelector.join('.')}#}}` }) @@ -219,8 +187,7 @@ const rewriteVariableReferences = ( if (Array.isArray(value)) { if (isSelectorKey(key) && isValueSelector(value)) { const nextSelector = selectorMap.get(value.join('.')) - if (nextSelector) - return nextSelector + if (nextSelector) return nextSelector } if (isValueSelectorListKey(key) && isValueSelectorList(value)) { @@ -230,11 +197,10 @@ const rewriteVariableReferences = ( }) } - return value.map(item => rewriteVariableReferences(item, selectorMap)) + return value.map((item) => rewriteVariableReferences(item, selectorMap)) } - if (!isRecord(value)) - return value + if (!isRecord(value)) return value return Object.fromEntries( Object.entries(value).map(([currentKey, currentValue]) => [ @@ -245,12 +211,12 @@ const rewriteVariableReferences = ( } const getSelectedSnippetGraph = (selectedNodes: Node[], edges: Edge[]) => { - const selectedNodeIds = new Set(selectedNodes.map(node => node.id)) - const { - inputFields, - selectorMap, - } = getExternalVariableInputFields(selectedNodes, selectedNodeIds) - const nodes = selectedNodes.map(node => ({ + const selectedNodeIds = new Set(selectedNodes.map((node) => node.id)) + const { inputFields, selectorMap } = getExternalVariableInputFields( + selectedNodes, + selectedNodeIds, + ) + const nodes = selectedNodes.map((node) => ({ ...node, data: rewriteVariableReferences(node.data, selectorMap) as Node['data'], selected: false, @@ -260,8 +226,8 @@ const getSelectedSnippetGraph = (selectedNodes: Node[], edges: Edge[]) => { graph: { nodes, edges: edges - .filter(edge => selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target)) - .map(edge => ({ + .filter((edge) => selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target)) + .map((edge) => ({ ...edge, selected: false, })), @@ -283,7 +249,9 @@ export const useCreateSnippetFromSelection = ({ onClose, }: UseCreateSnippetFromSelectionParams) => { const [selectedSnippetGraph, setSelectedSnippetGraph] = useState<SnippetCanvasData>() - const [selectedSnippetInputFields, setSelectedSnippetInputFields] = useState<SnippetInputField[]>([]) + const [selectedSnippetInputFields, setSelectedSnippetInputFields] = useState<SnippetInputField[]>( + [], + ) const { createSnippetMutation, handleCloseCreateSnippetDialog, @@ -294,10 +262,7 @@ export const useCreateSnippetFromSelection = ({ } = useCreateSnippet() const handleOpenCreateSnippet = useCallback(() => { - const { - graph, - inputFields, - } = getSelectedSnippetGraph(selectedNodes, edges) + const { graph, inputFields } = getSelectedSnippetGraph(selectedNodes, edges) setSelectedSnippetGraph(graph) setSelectedSnippetInputFields(inputFields) handleOpenCreateSnippetDialog() diff --git a/web/app/components/snippets/hooks/use-create-snippet.ts b/web/app/components/snippets/hooks/use-create-snippet.ts index f3e22028241b2d..1e85eafbbb8947 100644 --- a/web/app/components/snippets/hooks/use-create-snippet.ts +++ b/web/app/components/snippets/hooks/use-create-snippet.ts @@ -19,8 +19,7 @@ export const useCreateSnippet = () => { const canCreateAndModifySnippet = canCreateAndModifySnippets(workspacePermissionKeys) const handleOpenCreateSnippetDialog = () => { - if (!canCreateAndModifySnippet) - return + if (!canCreateAndModifySnippet) return setIsCreateSnippetDialogOpen(true) } @@ -35,8 +34,7 @@ export const useCreateSnippet = () => { graph, input_fields, }: CreateSnippetDialogPayload) => { - if (!canCreateAndModifySnippet) - return + if (!canCreateAndModifySnippet) return setIsCreatingSnippet(true) @@ -58,14 +56,12 @@ export const useCreateSnippet = () => { }, }) - toast.success(t($ => $['snippet.createSuccess'], { ns: 'workflow' })) + toast.success(t(($) => $['snippet.createSuccess'], { ns: 'workflow' })) handleCloseCreateSnippetDialog() push(`/snippets/${snippet.id}/orchestrate`) - } - catch { + } catch { // The API client surfaces the response message. Avoid showing a second generic create-failed toast here. - } - finally { + } finally { setIsCreatingSnippet(false) } } diff --git a/web/app/components/snippets/hooks/use-get-run-and-trace-url.ts b/web/app/components/snippets/hooks/use-get-run-and-trace-url.ts index 5d26360835b3c4..19bcf1bc761e18 100644 --- a/web/app/components/snippets/hooks/use-get-run-and-trace-url.ts +++ b/web/app/components/snippets/hooks/use-get-run-and-trace-url.ts @@ -1,19 +1,22 @@ import { useCallback } from 'react' export const useGetRunAndTraceUrl = (snippetId: string) => { - const getWorkflowRunAndTraceUrl = useCallback((runId?: string) => { - if (!runId) { - return { - runUrl: '', - traceUrl: '', + const getWorkflowRunAndTraceUrl = useCallback( + (runId?: string) => { + if (!runId) { + return { + runUrl: '', + traceUrl: '', + } } - } - return { - runUrl: `/snippets/${snippetId}/workflow-runs/${runId}`, - traceUrl: `/snippets/${snippetId}/workflow-runs/${runId}/node-executions`, - } - }, [snippetId]) + return { + runUrl: `/snippets/${snippetId}/workflow-runs/${runId}`, + traceUrl: `/snippets/${snippetId}/workflow-runs/${runId}/node-executions`, + } + }, + [snippetId], + ) return { getWorkflowRunAndTraceUrl, diff --git a/web/app/components/snippets/hooks/use-nodes-sync-draft.ts b/web/app/components/snippets/hooks/use-nodes-sync-draft.ts index 6638b985ef82a9..b2757967f29a05 100644 --- a/web/app/components/snippets/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/snippets/hooks/use-nodes-sync-draft.ts @@ -1,6 +1,10 @@ import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store' import type { SnippetInputField } from '@/models/snippet' -import type { SnippetDraftSyncPayload, SnippetDraftSyncResponse, SnippetWorkflow } from '@/types/snippet' +import type { + SnippetDraftSyncPayload, + SnippetDraftSyncResponse, + SnippetWorkflow, +} from '@/types/snippet' import { produce } from 'immer' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' @@ -13,12 +17,16 @@ import { postWithKeepalive } from '@/service/fetch' import { useSnippetDraftStore } from '../draft-store' import { useSnippetRefreshDraft } from './use-snippet-refresh-draft' -const isSyncConflictError = (error: unknown): error is { bodyUsed: boolean, json: () => Promise<{ code?: string }> } => { - return !!error - && typeof error === 'object' - && 'bodyUsed' in error - && 'json' in error - && typeof error.json === 'function' +const isSyncConflictError = ( + error: unknown, +): error is { bodyUsed: boolean; json: () => Promise<{ code?: string }> } => { + return ( + !!error && + typeof error === 'object' && + 'bodyUsed' in error && + 'json' in error && + typeof error.json === 'function' + ) } type SyncInputFieldsDraftCallback = SyncDraftCallback & { @@ -31,13 +39,13 @@ const enqueueSnippetDraftSync = <Result>( snippetId: string, task: () => Promise<Result> | Result, ) => { - const previousTask = snippetDraftSyncQueues.get(snippetId)?.catch(() => undefined) ?? Promise.resolve() + const previousTask = + snippetDraftSyncQueues.get(snippetId)?.catch(() => undefined) ?? Promise.resolve() const nextTask = previousTask.then(task) snippetDraftSyncQueues.set(snippetId, nextTask) const cleanup = () => { - if (snippetDraftSyncQueues.get(snippetId) === nextTask) - snippetDraftSyncQueues.delete(snippetId) + if (snippetDraftSyncQueues.get(snippetId) === nextTask) snippetDraftSyncQueues.delete(snippetId) } void nextTask.then(cleanup, cleanup) @@ -56,98 +64,91 @@ export const useNodesSyncDraft = (snippetId: string) => { } }, []) - const getDraftSyncPayload = useCallback((inputFields?: SnippetInputField[]) => { - const { - getNodes, - edges, - transform, - } = store.getState() - const nodes = getNodes().filter(node => !node.data?._isTempNode) - const [x, y, zoom] = transform - if (!snippetId) - return null - - const producedNodes = produce(nodes, (draft) => { - draft.forEach((node) => { - Object.keys(node.data).forEach((key) => { - if (key.startsWith('_')) - delete node.data[key] + const getDraftSyncPayload = useCallback( + (inputFields?: SnippetInputField[]) => { + const { getNodes, edges, transform } = store.getState() + const nodes = getNodes().filter((node) => !node.data?._isTempNode) + const [x, y, zoom] = transform + if (!snippetId) return null + + const producedNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + Object.keys(node.data).forEach((key) => { + if (key.startsWith('_')) delete node.data[key] + }) }) }) - }) - const producedEdges = produce(edges.filter(edge => !edge.data?._isTemp), (draft) => { - draft.forEach((edge) => { - Object.keys(edge.data).forEach((key) => { - if (key.startsWith('_')) - delete edge.data[key] - }) - }) - }) - - return { - ...getInputFieldsSyncPayload(inputFields), - graph: { - nodes: producedNodes, - edges: producedEdges, - viewport: { x, y, zoom }, - }, - } - }, [getInputFieldsSyncPayload, snippetId, store]) - - const syncDraft = useCallback(async ( - payload: Omit<SnippetDraftSyncPayload, 'hash'>, - notRefreshWhenSyncError?: boolean, - callback?: SyncDraftCallback, - onRefresh?: (draftWorkflow: SnippetWorkflow) => void, - ): Promise<SnippetDraftSyncResponse | undefined> => { - if (getNodesReadOnly()) - return - - if (!snippetId) - return - - const { - setDraftUpdatedAt, - setSyncWorkflowDraftHash, - syncWorkflowDraftHash, - } = workflowStore.getState() - - try { - const response = await consoleClient.snippets.bySnippetId.workflows.draft.post({ - params: { snippet_id: snippetId }, - body: { - ...payload, - hash: syncWorkflowDraftHash || undefined, + const producedEdges = produce( + edges.filter((edge) => !edge.data?._isTemp), + (draft) => { + draft.forEach((edge) => { + Object.keys(edge.data).forEach((key) => { + if (key.startsWith('_')) delete edge.data[key] + }) + }) }, - }) - - setSyncWorkflowDraftHash(response.hash) - setDraftUpdatedAt(response.updated_at) - callback?.onSuccess?.() - return response - } - catch (error: unknown) { - if (isSyncConflictError(error) && !error.bodyUsed) { - error.json().then((err) => { - if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) - handleRefreshWorkflowDraft(onRefresh) + ) + + return { + ...getInputFieldsSyncPayload(inputFields), + graph: { + nodes: producedNodes, + edges: producedEdges, + viewport: { x, y, zoom }, + }, + } + }, + [getInputFieldsSyncPayload, snippetId, store], + ) + + const syncDraft = useCallback( + async ( + payload: Omit<SnippetDraftSyncPayload, 'hash'>, + notRefreshWhenSyncError?: boolean, + callback?: SyncDraftCallback, + onRefresh?: (draftWorkflow: SnippetWorkflow) => void, + ): Promise<SnippetDraftSyncResponse | undefined> => { + if (getNodesReadOnly()) return + + if (!snippetId) return + + const { setDraftUpdatedAt, setSyncWorkflowDraftHash, syncWorkflowDraftHash } = + workflowStore.getState() + + try { + const response = await consoleClient.snippets.bySnippetId.workflows.draft.post({ + params: { snippet_id: snippetId }, + body: { + ...payload, + hash: syncWorkflowDraftHash || undefined, + }, }) + + setSyncWorkflowDraftHash(response.hash) + setDraftUpdatedAt(response.updated_at) + callback?.onSuccess?.() + return response + } catch (error: unknown) { + if (isSyncConflictError(error) && !error.bodyUsed) { + error.json().then((err) => { + if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) + handleRefreshWorkflowDraft(onRefresh) + }) + } + callback?.onError?.() + return undefined + } finally { + callback?.onSettled?.() } - callback?.onError?.() - return undefined - } - finally { - callback?.onSettled?.() - } - }, [getNodesReadOnly, handleRefreshWorkflowDraft, snippetId, workflowStore]) + }, + [getNodesReadOnly, handleRefreshWorkflowDraft, snippetId, workflowStore], + ) const syncWorkflowDraftWhenPageClose = useCallback(() => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const draftPayload = getDraftSyncPayload() - if (!draftPayload) - return + if (!draftPayload) return const { syncWorkflowDraftHash } = workflowStore.getState() postWithKeepalive(`${API_PREFIX}/snippets/${snippetId}/workflows/draft`, { @@ -156,57 +157,59 @@ export const useNodesSyncDraft = (snippetId: string) => { }) }, [getDraftSyncPayload, getNodesReadOnly, snippetId, workflowStore]) - const syncWorkflowDraftWithPayload = useCallback(async ( - draftPayload: Omit<SnippetDraftSyncPayload, 'hash'> | null, - notRefreshWhenSyncError?: boolean, - callback?: SyncDraftCallback, - ) => { - if (!draftPayload) - return - - const response = await enqueueSnippetDraftSync(snippetId, () => syncDraft(draftPayload, notRefreshWhenSyncError, callback)) - return response ? draftPayload : undefined - }, [snippetId, syncDraft]) - - const syncInputFieldsDraftWithPayload = useCallback(async ( - draftPayload: Omit<SnippetDraftSyncPayload, 'hash'> | null, - callback?: SyncInputFieldsDraftCallback, - ) => { - if (!draftPayload) - return - - const response = await enqueueSnippetDraftSync(snippetId, () => syncDraft( - draftPayload, - false, - callback, - (draftWorkflow) => { - const refreshedInputFields = Array.isArray(draftWorkflow.input_fields) - ? draftWorkflow.input_fields as SnippetInputField[] - : [] - callback?.onRefresh?.(refreshedInputFields) - }, - )) - return response ? draftPayload : undefined - }, [snippetId, syncDraft]) - - const doSyncWorkflowDraft = useCallback(( - notRefreshWhenSyncError?: boolean, - callback?: SyncDraftCallback, - ) => { - if (getNodesReadOnly()) - return Promise.resolve() - - const draftPayload = getDraftSyncPayload() - return syncWorkflowDraftWithPayload(draftPayload, notRefreshWhenSyncError, callback) - }, [getDraftSyncPayload, getNodesReadOnly, syncWorkflowDraftWithPayload]) - - const syncInputFieldsDraft = useCallback(( - inputFields: SnippetInputField[], - callback?: SyncInputFieldsDraftCallback, - ) => { - const draftPayload = getDraftSyncPayload(inputFields) - return syncInputFieldsDraftWithPayload(draftPayload, callback) - }, [getDraftSyncPayload, syncInputFieldsDraftWithPayload]) + const syncWorkflowDraftWithPayload = useCallback( + async ( + draftPayload: Omit<SnippetDraftSyncPayload, 'hash'> | null, + notRefreshWhenSyncError?: boolean, + callback?: SyncDraftCallback, + ) => { + if (!draftPayload) return + + const response = await enqueueSnippetDraftSync(snippetId, () => + syncDraft(draftPayload, notRefreshWhenSyncError, callback), + ) + return response ? draftPayload : undefined + }, + [snippetId, syncDraft], + ) + + const syncInputFieldsDraftWithPayload = useCallback( + async ( + draftPayload: Omit<SnippetDraftSyncPayload, 'hash'> | null, + callback?: SyncInputFieldsDraftCallback, + ) => { + if (!draftPayload) return + + const response = await enqueueSnippetDraftSync(snippetId, () => + syncDraft(draftPayload, false, callback, (draftWorkflow) => { + const refreshedInputFields = Array.isArray(draftWorkflow.input_fields) + ? (draftWorkflow.input_fields as SnippetInputField[]) + : [] + callback?.onRefresh?.(refreshedInputFields) + }), + ) + return response ? draftPayload : undefined + }, + [snippetId, syncDraft], + ) + + const doSyncWorkflowDraft = useCallback( + (notRefreshWhenSyncError?: boolean, callback?: SyncDraftCallback) => { + if (getNodesReadOnly()) return Promise.resolve() + + const draftPayload = getDraftSyncPayload() + return syncWorkflowDraftWithPayload(draftPayload, notRefreshWhenSyncError, callback) + }, + [getDraftSyncPayload, getNodesReadOnly, syncWorkflowDraftWithPayload], + ) + + const syncInputFieldsDraft = useCallback( + (inputFields: SnippetInputField[], callback?: SyncInputFieldsDraftCallback) => { + const draftPayload = getDraftSyncPayload(inputFields) + return syncInputFieldsDraftWithPayload(draftPayload, callback) + }, + [getDraftSyncPayload, syncInputFieldsDraftWithPayload], + ) return { doSyncWorkflowDraft, diff --git a/web/app/components/snippets/hooks/use-snippet-init.ts b/web/app/components/snippets/hooks/use-snippet-init.ts index acc42bc8f3262c..a29ddfd4d5e482 100644 --- a/web/app/components/snippets/hooks/use-snippet-init.ts +++ b/web/app/components/snippets/hooks/use-snippet-init.ts @@ -1,41 +1,35 @@ import type { SnippetWorkflow } from '@/types/snippet' import isEqual from 'fast-deep-equal' -import { - useEffect, - useMemo, - useState, -} from 'react' +import { useEffect, useMemo, useState } from 'react' import { useWorkflowStore } from '@/app/components/workflow/store' import { fetchSnippetDraftWorkflow, useSnippetDefaultBlockConfigs, useSnippetPublishedWorkflow, } from '@/service/use-snippet-workflows' -import { - buildSnippetDetailPayload, - useSnippetApiDetail, -} from '@/service/use-snippets' +import { buildSnippetDetailPayload, useSnippetApiDetail } from '@/service/use-snippets' const normalizeNodesDefaultConfigs = (nodesDefaultConfigs: unknown) => { - if (!nodesDefaultConfigs || typeof nodesDefaultConfigs !== 'object') - return {} - - if (!Array.isArray(nodesDefaultConfigs)) - return nodesDefaultConfigs as Record<string, unknown> - - return nodesDefaultConfigs.reduce((acc, item) => { - if ( - item - && typeof item === 'object' - && 'type' in item - && 'config' in item - && typeof item.type === 'string' - ) { - acc[item.type] = item.config - } + if (!nodesDefaultConfigs || typeof nodesDefaultConfigs !== 'object') return {} + + if (!Array.isArray(nodesDefaultConfigs)) return nodesDefaultConfigs as Record<string, unknown> + + return nodesDefaultConfigs.reduce( + (acc, item) => { + if ( + item && + typeof item === 'object' && + 'type' in item && + 'config' in item && + typeof item.type === 'string' + ) { + acc[item.type] = item.config + } - return acc - }, {} as Record<string, unknown>) + return acc + }, + {} as Record<string, unknown>, + ) } const isNotFoundError = (error: unknown) => { @@ -72,8 +66,7 @@ export const useSnippetInit = (snippetId: string) => { }) useEffect(() => { - if (publishedWorkflowQuery.isLoading) - return + if (publishedWorkflowQuery.isLoading) return workflowStore.getState().setPublishedAt(publishedWorkflowQuery.data?.created_at ?? 0) }, [publishedWorkflowQuery.data?.created_at, publishedWorkflowQuery.isLoading, workflowStore]) @@ -81,19 +74,14 @@ export const useSnippetInit = (snippetId: string) => { useEffect(() => { let ignore = false - if (!snippetId) - return + if (!snippetId) return fetchSnippetDraftWorkflow(snippetId) .then((response) => { - if (ignore) - return + if (ignore) return if (response) { - const { - setDraftUpdatedAt, - setSyncWorkflowDraftHash, - } = workflowStore.getState() + const { setDraftUpdatedAt, setSyncWorkflowDraftHash } = workflowStore.getState() setDraftUpdatedAt(response.updated_at) setSyncWorkflowDraftHash(response.hash) @@ -116,8 +104,10 @@ export const useSnippetInit = (snippetId: string) => { } }, [snippetId, workflowStore]) - const isDraftWorkflowLoading = !!snippetId && (!draftWorkflowState.isLoaded || draftWorkflowState.snippetId !== snippetId) - const draftWorkflow = draftWorkflowState.snippetId === snippetId ? draftWorkflowState.data : undefined + const isDraftWorkflowLoading = + !!snippetId && (!draftWorkflowState.isLoaded || draftWorkflowState.snippetId !== snippetId) + const draftWorkflow = + draftWorkflowState.snippetId === snippetId ? draftWorkflowState.data : undefined const data = useMemo(() => { if (snippetApiDetail.data && !publishedWorkflowQuery.isLoading && !isDraftWorkflowLoading) { @@ -130,22 +120,31 @@ export const useSnippetInit = (snippetId: string) => { draft: buildSnippetDetailPayload(snippetApiDetail.data, effectiveDraftWorkflow), draftWorkflow, publishedWorkflow, - hasDraftChanges: !!draftWorkflow && !isEqual( - getComparableWorkflowData(draftWorkflow), - getComparableWorkflowData(publishedWorkflow), - ), + hasDraftChanges: + !!draftWorkflow && + !isEqual( + getComparableWorkflowData(draftWorkflow), + getComparableWorkflowData(publishedWorkflow), + ), } } - if (snippetApiDetail.error && isNotFoundError(snippetApiDetail.error)) - return null + if (snippetApiDetail.error && isNotFoundError(snippetApiDetail.error)) return null return undefined - }, [draftWorkflow, isDraftWorkflowLoading, publishedWorkflowQuery.data, publishedWorkflowQuery.isLoading, snippetApiDetail.data, snippetApiDetail.error]) + }, [ + draftWorkflow, + isDraftWorkflowLoading, + publishedWorkflowQuery.data, + publishedWorkflowQuery.isLoading, + snippetApiDetail.data, + snippetApiDetail.error, + ]) return { ...snippetApiDetail, data, - isLoading: snippetApiDetail.isLoading || publishedWorkflowQuery.isLoading || isDraftWorkflowLoading, + isLoading: + snippetApiDetail.isLoading || publishedWorkflowQuery.isLoading || isDraftWorkflowLoading, } } diff --git a/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts b/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts index e310c919fba324..f2192178358b68 100644 --- a/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts +++ b/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts @@ -11,41 +11,41 @@ export const useSnippetRefreshDraft = (snippetId: string) => { const workflowStore = useWorkflowStore() const { handleUpdateWorkflowCanvas } = useWorkflowUpdate() - const handleRefreshWorkflowDraft = useCallback((onSuccess?: (draftWorkflow: SnippetWorkflow) => void) => { - const { - setDraftUpdatedAt, - setIsSyncingWorkflowDraft, - setSyncWorkflowDraftHash, - } = workflowStore.getState() + const handleRefreshWorkflowDraft = useCallback( + (onSuccess?: (draftWorkflow: SnippetWorkflow) => void) => { + const { setDraftUpdatedAt, setIsSyncingWorkflowDraft, setSyncWorkflowDraftHash } = + workflowStore.getState() - if (!snippetId) - return + if (!snippetId) return - setIsSyncingWorkflowDraft(true) - fetchSnippetDraftWorkflow(snippetId).then((response) => { - if (!response) - return + setIsSyncingWorkflowDraft(true) + fetchSnippetDraftWorkflow(snippetId) + .then((response) => { + if (!response) return - const inputFields = Array.isArray(response.input_fields) - ? response.input_fields as SnippetInputField[] - : [] + const inputFields = Array.isArray(response.input_fields) + ? (response.input_fields as SnippetInputField[]) + : [] - handleUpdateWorkflowCanvas({ - ...response.graph, - nodes: response.graph?.nodes || [], - edges: response.graph?.edges || [], - viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 }, - } as WorkflowDataUpdater) - useSnippetDraftStore.setState({ - inputFields, - }) - setSyncWorkflowDraftHash(response.hash) - setDraftUpdatedAt(response.updated_at) - onSuccess?.(response) - }).finally(() => { - setIsSyncingWorkflowDraft(false) - }) - }, [handleUpdateWorkflowCanvas, snippetId, workflowStore]) + handleUpdateWorkflowCanvas({ + ...response.graph, + nodes: response.graph?.nodes || [], + edges: response.graph?.edges || [], + viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 }, + } as WorkflowDataUpdater) + useSnippetDraftStore.setState({ + inputFields, + }) + setSyncWorkflowDraftHash(response.hash) + setDraftUpdatedAt(response.updated_at) + onSuccess?.(response) + }) + .finally(() => { + setIsSyncingWorkflowDraft(false) + }) + }, + [handleUpdateWorkflowCanvas, snippetId, workflowStore], + ) return { handleRefreshWorkflowDraft, diff --git a/web/app/components/snippets/hooks/use-snippet-run.ts b/web/app/components/snippets/hooks/use-snippet-run.ts index 694d9b35a15748..e999ed80e44343 100644 --- a/web/app/components/snippets/hooks/use-snippet-run.ts +++ b/web/app/components/snippets/hooks/use-snippet-run.ts @@ -3,10 +3,7 @@ import type { SnippetDraftRunPayload } from '@/types/snippet' import type { VersionHistory } from '@/types/workflow' import { produce } from 'immer' import { useCallback, useRef } from 'react' -import { - useReactFlow, - useStoreApi, -} from 'reactflow' +import { useReactFlow, useStoreApi } from 'reactflow' import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars' import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions' import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event' @@ -46,15 +43,9 @@ export const useSnippetRun = (snippetId: string) => { const abortControllerRef = useRef<AbortController | null>(null) const handleBackupDraft = useCallback(() => { - const { - getNodes, - edges, - } = store.getState() + const { getNodes, edges } = store.getState() const { getViewport } = reactflow - const { - backupDraft, - setBackupDraft, - } = workflowStore.getState() + const { backupDraft, setBackupDraft } = workflowStore.getState() if (!backupDraft) { setBackupDraft({ @@ -68,18 +59,10 @@ export const useSnippetRun = (snippetId: string) => { }, [doSyncWorkflowDraft, reactflow, store, workflowStore]) const handleLoadBackupDraft = useCallback(() => { - const { - backupDraft, - setBackupDraft, - setEnvironmentVariables, - } = workflowStore.getState() + const { backupDraft, setBackupDraft, setEnvironmentVariables } = workflowStore.getState() if (backupDraft) { - const { - nodes, - edges, - viewport, - } = backupDraft + const { nodes, edges, viewport } = backupDraft handleUpdateWorkflowCanvas({ nodes, edges, @@ -97,208 +80,221 @@ export const useSnippetRun = (snippetId: string) => { flowId: snippetId, }) - const handleRun = useCallback(async ( - params: SnippetDraftRunPayload, - callback?: IOtherOptions, - ) => { - const { - getNodes, - setNodes, - } = store.getState() - const newNodes = produce(getNodes(), (draft) => { - draft.forEach((node) => { - node.data.selected = false - node.data._runningStatus = undefined + const handleRun = useCallback( + async (params: SnippetDraftRunPayload, callback?: IOtherOptions) => { + const { getNodes, setNodes } = store.getState() + const newNodes = produce(getNodes(), (draft) => { + draft.forEach((node) => { + node.data.selected = false + node.data._runningStatus = undefined + }) }) - }) - setNodes(newNodes) - await doSyncWorkflowDraft() - - const { - onWorkflowStarted, - onWorkflowFinished, - onNodeStarted, - onNodeFinished, - onIterationStart, - onIterationNext, - onIterationFinish, - onLoopStart, - onLoopNext, - onLoopFinish, - onNodeRetry, - onAgentLog, - onError, - ...restCallback - } = callback || {} - const runHistoryUrl = `/snippets/${snippetId}/workflow-runs` - workflowStore.setState({ historyWorkflowData: undefined }) - const workflowContainer = document.getElementById('workflow-container') - - const { - clientWidth, - clientHeight, - } = workflowContainer! - - const url = `/snippets/${snippetId}/workflows/draft/run` - - const { - setWorkflowRunningData, - } = workflowStore.getState() - setWorkflowRunningData({ - result: { - inputs_truncated: false, - process_data_truncated: false, - outputs_truncated: false, - status: WorkflowRunningStatus.Running, - }, - tracing: [], - resultText: '', - }) - - abortControllerRef.current?.abort() - abortControllerRef.current = null - - ssePost( - url, - { - body: params, - }, - { - getAbortController: (controller: AbortController) => { - abortControllerRef.current = controller - }, - onWorkflowStarted: (params) => { - handleWorkflowStarted(params) - invalidateRunHistory(runHistoryUrl) + setNodes(newNodes) + await doSyncWorkflowDraft() - onWorkflowStarted?.(params) + const { + onWorkflowStarted, + onWorkflowFinished, + onNodeStarted, + onNodeFinished, + onIterationStart, + onIterationNext, + onIterationFinish, + onLoopStart, + onLoopNext, + onLoopFinish, + onNodeRetry, + onAgentLog, + onError, + ...restCallback + } = callback || {} + const runHistoryUrl = `/snippets/${snippetId}/workflow-runs` + workflowStore.setState({ historyWorkflowData: undefined }) + const workflowContainer = document.getElementById('workflow-container') + + const { clientWidth, clientHeight } = workflowContainer! + + const url = `/snippets/${snippetId}/workflows/draft/run` + + const { setWorkflowRunningData } = workflowStore.getState() + setWorkflowRunningData({ + result: { + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + status: WorkflowRunningStatus.Running, }, - onWorkflowFinished: (params) => { - handleWorkflowFinished(params) - invalidateRunHistory(runHistoryUrl) - fetchInspectVars({}) - invalidAllLastRun() + tracing: [], + resultText: '', + }) - onWorkflowFinished?.(params) - }, - onError: (params) => { - handleWorkflowFailed() - invalidateRunHistory(runHistoryUrl) + abortControllerRef.current?.abort() + abortControllerRef.current = null - onError?.(params) + ssePost( + url, + { + body: params, }, - onNodeStarted: (params) => { - handleWorkflowNodeStarted( - params, - { + { + getAbortController: (controller: AbortController) => { + abortControllerRef.current = controller + }, + onWorkflowStarted: (params) => { + handleWorkflowStarted(params) + invalidateRunHistory(runHistoryUrl) + + onWorkflowStarted?.(params) + }, + onWorkflowFinished: (params) => { + handleWorkflowFinished(params) + invalidateRunHistory(runHistoryUrl) + fetchInspectVars({}) + invalidAllLastRun() + + onWorkflowFinished?.(params) + }, + onError: (params) => { + handleWorkflowFailed() + invalidateRunHistory(runHistoryUrl) + + onError?.(params) + }, + onNodeStarted: (params) => { + handleWorkflowNodeStarted(params, { clientWidth, clientHeight, - }, - ) + }) - onNodeStarted?.(params) - }, - onNodeFinished: (params) => { - handleWorkflowNodeFinished(params) + onNodeStarted?.(params) + }, + onNodeFinished: (params) => { + handleWorkflowNodeFinished(params) - onNodeFinished?.(params) - }, - onIterationStart: (params) => { - handleWorkflowNodeIterationStarted( - params, - { + onNodeFinished?.(params) + }, + onIterationStart: (params) => { + handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight, - }, - ) - - onIterationStart?.(params) - }, - onIterationNext: (params) => { - handleWorkflowNodeIterationNext(params) - - onIterationNext?.(params) - }, - onIterationFinish: (params) => { - handleWorkflowNodeIterationFinished(params) - - onIterationFinish?.(params) - }, - onLoopStart: (params) => { - handleWorkflowNodeLoopStarted( - params, - { + }) + + onIterationStart?.(params) + }, + onIterationNext: (params) => { + handleWorkflowNodeIterationNext(params) + + onIterationNext?.(params) + }, + onIterationFinish: (params) => { + handleWorkflowNodeIterationFinished(params) + + onIterationFinish?.(params) + }, + onLoopStart: (params) => { + handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight, - }, - ) - - onLoopStart?.(params) - }, - onLoopNext: (params) => { - handleWorkflowNodeLoopNext(params) - - onLoopNext?.(params) - }, - onLoopFinish: (params) => { - handleWorkflowNodeLoopFinished(params) - - onLoopFinish?.(params) - }, - onNodeRetry: (params) => { - handleWorkflowNodeRetry(params) - - onNodeRetry?.(params) - }, - onAgentLog: (params) => { - handleWorkflowAgentLog(params) - - onAgentLog?.(params) + }) + + onLoopStart?.(params) + }, + onLoopNext: (params) => { + handleWorkflowNodeLoopNext(params) + + onLoopNext?.(params) + }, + onLoopFinish: (params) => { + handleWorkflowNodeLoopFinished(params) + + onLoopFinish?.(params) + }, + onNodeRetry: (params) => { + handleWorkflowNodeRetry(params) + + onNodeRetry?.(params) + }, + onAgentLog: (params) => { + handleWorkflowAgentLog(params) + + onAgentLog?.(params) + }, + onTextChunk: (params) => { + handleWorkflowTextChunk(params) + }, + onTextReplace: (params) => { + handleWorkflowTextReplace(params) + }, + ...restCallback, }, - onTextChunk: (params) => { - handleWorkflowTextChunk(params) - }, - onTextReplace: (params) => { - handleWorkflowTextReplace(params) - }, - ...restCallback, - }, - ) - }, [doSyncWorkflowDraft, fetchInspectVars, handleWorkflowAgentLog, handleWorkflowFailed, handleWorkflowFinished, handleWorkflowNodeFinished, handleWorkflowNodeIterationFinished, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationStarted, handleWorkflowNodeLoopFinished, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopStarted, handleWorkflowNodeRetry, handleWorkflowNodeStarted, handleWorkflowStarted, handleWorkflowTextChunk, handleWorkflowTextReplace, invalidAllLastRun, invalidateRunHistory, snippetId, store, workflowStore]) - - const handleStopRun = useCallback((taskId: string) => { - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() - - if (taskId) - stopWorkflowRun(`/snippets/${snippetId}/workflow-runs/tasks/${taskId}/stop`) - - if (abortControllerRef.current) - abortControllerRef.current.abort() - - abortControllerRef.current = null - - if (workflowRunningData) { - setWorkflowRunningData(produce(workflowRunningData, (draft) => { - draft.result.status = WorkflowRunningStatus.Stopped + ) + }, + [ + doSyncWorkflowDraft, + fetchInspectVars, + handleWorkflowAgentLog, + handleWorkflowFailed, + handleWorkflowFinished, + handleWorkflowNodeFinished, + handleWorkflowNodeIterationFinished, + handleWorkflowNodeIterationNext, + handleWorkflowNodeIterationStarted, + handleWorkflowNodeLoopFinished, + handleWorkflowNodeLoopNext, + handleWorkflowNodeLoopStarted, + handleWorkflowNodeRetry, + handleWorkflowNodeStarted, + handleWorkflowStarted, + handleWorkflowTextChunk, + handleWorkflowTextReplace, + invalidAllLastRun, + invalidateRunHistory, + snippetId, + store, + workflowStore, + ], + ) + + const handleStopRun = useCallback( + (taskId: string) => { + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() + + if (taskId) stopWorkflowRun(`/snippets/${snippetId}/workflow-runs/tasks/${taskId}/stop`) + + if (abortControllerRef.current) abortControllerRef.current.abort() + + abortControllerRef.current = null + + if (workflowRunningData) { + setWorkflowRunningData( + produce(workflowRunningData, (draft) => { + draft.result.status = WorkflowRunningStatus.Stopped + }), + ) + } + }, + [snippetId, workflowStore], + ) + + const handleRestoreFromPublishedWorkflow = useCallback( + (publishedWorkflow: VersionHistory) => { + const nodes = publishedWorkflow.graph.nodes.map((node) => ({ + ...node, + selected: false, + data: { ...node.data, selected: false }, })) - } - }, [snippetId, workflowStore]) - - const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => { - const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } })) - const edges = publishedWorkflow.graph.edges - const viewport = publishedWorkflow.graph.viewport! - handleUpdateWorkflowCanvas({ - nodes, - edges, - viewport, - }) - - workflowStore.getState().setEnvironmentVariables([]) - }, [handleUpdateWorkflowCanvas, workflowStore]) + const edges = publishedWorkflow.graph.edges + const viewport = publishedWorkflow.graph.viewport! + handleUpdateWorkflowCanvas({ + nodes, + edges, + viewport, + }) + + workflowStore.getState().setEnvironmentVariables([]) + }, + [handleUpdateWorkflowCanvas, workflowStore], + ) return { handleBackupDraft, diff --git a/web/app/components/snippets/hooks/use-snippet-start-run.ts b/web/app/components/snippets/hooks/use-snippet-start-run.ts index 3e7ec5f4ac233b..a9ddf381921099 100644 --- a/web/app/components/snippets/hooks/use-snippet-start-run.ts +++ b/web/app/components/snippets/hooks/use-snippet-start-run.ts @@ -9,9 +9,7 @@ type UseSnippetStartRunOptions = { handleRun: (params: SnippetDraftRunPayload) => void } -export const useSnippetStartRun = ({ - handleRun, -}: UseSnippetStartRunOptions) => { +export const useSnippetStartRun = ({ handleRun }: UseSnippetStartRunOptions) => { const workflowStore = useWorkflowStore() const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions() @@ -25,8 +23,7 @@ export const useSnippetStartRun = ({ setShowGlobalVariablePanel, } = workflowStore.getState() - if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) - return + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) return setShowEnvPanel(false) setShowGlobalVariablePanel(false) diff --git a/web/app/components/snippets/import-snippet-dsl-dialog.tsx b/web/app/components/snippets/import-snippet-dsl-dialog.tsx index 378cd55737521f..486e3867d40223 100644 --- a/web/app/components/snippets/import-snippet-dsl-dialog.tsx +++ b/web/app/components/snippets/import-snippet-dsl-dialog.tsx @@ -38,7 +38,8 @@ const ImportSnippetDSLDialogTab = { FromUrl: 'from-url', } as const -type ImportSnippetDSLDialogTab = typeof ImportSnippetDSLDialogTab[keyof typeof ImportSnippetDSLDialogTab] +type ImportSnippetDSLDialogTab = + (typeof ImportSnippetDSLDialogTab)[keyof typeof ImportSnippetDSLDialogTab] const SnippetImportStatus = { Completed: 'completed', @@ -55,7 +56,7 @@ function SnippetDSLConfirmDialog({ onCancel, onConfirm, }: { - versions?: { importedVersion: string, systemVersion: string } + versions?: { importedVersion: string; systemVersion: string } confirmDisabled?: boolean onCancel: () => void onConfirm: MouseEventHandler @@ -66,35 +67,37 @@ function SnippetDSLConfirmDialog({ <AlertDialog open onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <AlertDialogContent className="w-120 overflow-hidden! border-none text-left align-middle shadow-xl"> <div className="flex flex-col items-start gap-2 self-stretch p-6 pb-4"> <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $.dslVersionMismatchTitle, { ns: 'snippet' })} + {t(($) => $.dslVersionMismatchTitle, { ns: 'snippet' })} </AlertDialogTitle> - <AlertDialogDescription render={<div />} className="flex grow flex-col system-md-regular text-text-secondary"> - <div>{t($ => $.dslVersionMismatchDescription, { ns: 'snippet' })}</div> - <div>{t($ => $.dslVersionMismatchQuestion, { ns: 'snippet' })}</div> + <AlertDialogDescription + render={<div />} + className="flex grow flex-col system-md-regular text-text-secondary" + > + <div>{t(($) => $.dslVersionMismatchDescription, { ns: 'snippet' })}</div> + <div>{t(($) => $.dslVersionMismatchQuestion, { ns: 'snippet' })}</div> <br /> <div> - {t($ => $.importedDSLVersion, { ns: 'snippet' })} + {t(($) => $.importedDSLVersion, { ns: 'snippet' })} <span className="system-md-medium">{versions.importedVersion}</span> </div> <div> - {t($ => $.currentDSLVersion, { ns: 'snippet' })} + {t(($) => $.currentDSLVersion, { ns: 'snippet' })} <span className="system-md-medium">{versions.systemVersion}</span> </div> </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton variant="secondary"> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={onConfirm} disabled={confirmDisabled}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> @@ -102,22 +105,21 @@ function SnippetDSLConfirmDialog({ ) } -function ImportSnippetDSLDialog({ - isOpen, - onClose, -}: ImportSnippetDSLDialogProps) { +function ImportSnippetDSLDialog({ isOpen, onClose }: ImportSnippetDSLDialogProps) { const { t } = useTranslation() const { push } = useRouter() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canCreateAndModifySnippet = canCreateAndModifySnippets(workspacePermissionKeys) const importSnippetMutation = useImportSnippetDSLMutation() const confirmSnippetImportMutation = useConfirmSnippetImportMutation() - const [currentTab, setCurrentTab] = useState<ImportSnippetDSLDialogTab>(ImportSnippetDSLDialogTab.FromFile) + const [currentTab, setCurrentTab] = useState<ImportSnippetDSLDialogTab>( + ImportSnippetDSLDialogTab.FromFile, + ) const [currentFile, setCurrentFile] = useState<File>() const [fileContent, setFileContent] = useState('') const [dslUrl, setDslUrl] = useState('') const [showConfirmModal, setShowConfirmModal] = useState(false) - const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>() + const [versions, setVersions] = useState<{ importedVersion: string; systemVersion: string }>() const [importId, setImportId] = useState<string>() const readFile = useCallback((file: File) => { @@ -128,47 +130,54 @@ function ImportSnippetDSLDialog({ reader.readAsText(file) }, []) - const handleFileChange = useCallback((file?: File) => { - setCurrentFile(file) - if (file) - readFile(file) - else - setFileContent('') - }, [readFile]) + const handleFileChange = useCallback( + (file?: File) => { + setCurrentFile(file) + if (file) readFile(file) + else setFileContent('') + }, + [readFile], + ) - const handleImportSuccess = useCallback((response: SnippetDSLImportResponse) => { - const snippetId = getImportedSnippetId(response) + const handleImportSuccess = useCallback( + (response: SnippetDSLImportResponse) => { + const snippetId = getImportedSnippetId(response) - onClose() - toast.success(t($ => $.importSuccess, { ns: 'snippet' })) + onClose() + toast.success(t(($) => $.importSuccess, { ns: 'snippet' })) - if (snippetId) - push(`/snippets/${snippetId}/orchestrate`) - }, [onClose, push, t]) + if (snippetId) push(`/snippets/${snippetId}/orchestrate`) + }, + [onClose, push, t], + ) - const handleImportResponse = useCallback((response: SnippetDSLImportResponse) => { - if (response.status === SnippetImportStatus.Completed || response.status === SnippetImportStatus.CompletedWithWarnings) { - handleImportSuccess(response) - return - } + const handleImportResponse = useCallback( + (response: SnippetDSLImportResponse) => { + if ( + response.status === SnippetImportStatus.Completed || + response.status === SnippetImportStatus.CompletedWithWarnings + ) { + handleImportSuccess(response) + return + } - if (response.status === SnippetImportStatus.Pending) { - setVersions({ - importedVersion: response.imported_dsl_version ?? '', - systemVersion: response.current_dsl_version ?? '', - }) - setImportId(response.id) - setShowConfirmModal(true) - return - } + if (response.status === SnippetImportStatus.Pending) { + setVersions({ + importedVersion: response.imported_dsl_version ?? '', + systemVersion: response.current_dsl_version ?? '', + }) + setImportId(response.id) + setShowConfirmModal(true) + return + } - if (response.error) - toast.error(response.error) - }, [handleImportSuccess]) + if (response.error) toast.error(response.error) + }, + [handleImportSuccess], + ) const handleImport = useCallback(async () => { - if (!canCreateAndModifySnippet) - return + if (!canCreateAndModifySnippet) return try { const response = await importSnippetMutation.mutateAsync({ @@ -178,53 +187,59 @@ function ImportSnippetDSLDialog({ }) handleImportResponse(response) + } catch (error) { + if (error instanceof Error && error.message) toast.error(error.message) } - catch (error) { - if (error instanceof Error && error.message) - toast.error(error.message) - } - }, [canCreateAndModifySnippet, currentTab, dslUrl, fileContent, handleImportResponse, importSnippetMutation]) + }, [ + canCreateAndModifySnippet, + currentTab, + dslUrl, + fileContent, + handleImportResponse, + importSnippetMutation, + ]) const handleConfirmImport: MouseEventHandler = useCallback(async () => { - if (!canCreateAndModifySnippet || !importId) - return + if (!canCreateAndModifySnippet || !importId) return try { const response = await confirmSnippetImportMutation.mutateAsync({ importId }) handleImportResponse(response) - } - catch (error) { - if (error instanceof Error && error.message) - toast.error(error.message) + } catch (error) { + if (error instanceof Error && error.message) toast.error(error.message) } }, [canCreateAndModifySnippet, confirmSnippetImportMutation, handleImportResponse, importId]) - const tabs = useMemo(() => [ - { - key: ImportSnippetDSLDialogTab.FromFile, - label: t($ => $.importFromDSLFile, { ns: 'snippet' }), - }, - { - key: ImportSnippetDSLDialogTab.FromUrl, - label: t($ => $.importFromDSLUrl, { ns: 'snippet' }), - }, - ], [t]) + const tabs = useMemo( + () => [ + { + key: ImportSnippetDSLDialogTab.FromFile, + label: t(($) => $.importFromDSLFile, { ns: 'snippet' }), + }, + { + key: ImportSnippetDSLDialogTab.FromUrl, + label: t(($) => $.importFromDSLUrl, { ns: 'snippet' }), + }, + ], + [t], + ) const isSubmitting = importSnippetMutation.isPending || confirmSnippetImportMutation.isPending - const importDisabled = isSubmitting - || !canCreateAndModifySnippet - || (currentTab === ImportSnippetDSLDialogTab.FromFile && !currentFile) - || (currentTab === ImportSnippetDSLDialogTab.FromUrl && !dslUrl.trim()) + const importDisabled = + isSubmitting || + !canCreateAndModifySnippet || + (currentTab === ImportSnippetDSLDialogTab.FromFile && !currentFile) || + (currentTab === ImportSnippetDSLDialogTab.FromUrl && !dslUrl.trim()) return ( <> - <Dialog open={isOpen} onOpenChange={open => !open && !showConfirmModal && onClose()}> + <Dialog open={isOpen} onOpenChange={(open) => !open && !showConfirmModal && onClose()}> <DialogContent className="w-full max-w-120! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl"> <div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary"> - {t($ => $.importDialogTitle, { ns: 'snippet' })} + {t(($) => $.importDialogTitle, { ns: 'snippet' })} <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="flex size-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover" onClick={onClose} > @@ -232,7 +247,7 @@ function ImportSnippetDSLDialog({ </button> </div> <div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary"> - {tabs.map(tab => ( + {tabs.map((tab) => ( <button key={tab.key} type="button" @@ -251,26 +266,22 @@ function ImportSnippetDSLDialog({ </div> <div className="px-6 py-4"> {currentTab === ImportSnippetDSLDialogTab.FromFile && ( - <Uploader - className="mt-0" - file={currentFile} - updateFile={handleFileChange} - /> + <Uploader className="mt-0" file={currentFile} updateFile={handleFileChange} /> )} {currentTab === ImportSnippetDSLDialogTab.FromUrl && ( <div> <div className="mb-1 system-md-semibold text-text-secondary">DSL URL</div> <Input - placeholder={t($ => $.importFromDSLUrlPlaceholder, { ns: 'snippet' }) || ''} + placeholder={t(($) => $.importFromDSLUrlPlaceholder, { ns: 'snippet' }) || ''} value={dslUrl} - onChange={event => setDslUrl(event.target.value)} + onChange={(event) => setDslUrl(event.target.value)} /> </div> )} </div> <div className="flex justify-end px-6 py-5"> <Button className="mr-2" disabled={isSubmitting} onClick={onClose}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button disabled={importDisabled} @@ -278,7 +289,7 @@ function ImportSnippetDSLDialog({ variant="primary" onClick={handleImport} > - {t($ => $['operation.create'], { ns: 'common' })} + {t(($) => $['operation.create'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/app/components/snippets/index.tsx b/web/app/components/snippets/index.tsx index 6201d66312b617..1d453d3f826526 100644 --- a/web/app/components/snippets/index.tsx +++ b/web/app/components/snippets/index.tsx @@ -4,10 +4,7 @@ import { useMemo } from 'react' import Loading from '@/app/components/base/loading' import WorkflowWithDefaultContext from '@/app/components/workflow' import { WorkflowContextProvider } from '@/app/components/workflow/context' -import { - initialEdges, - initialNodes, -} from '@/app/components/workflow/utils' +import { initialEdges, initialNodes } from '@/app/components/workflow/utils' import SnippetLayout from './components/snippet-layout' import SnippetMain from './components/snippet-main' import { useSnippetInit } from './hooks/use-snippet-init' @@ -18,10 +15,7 @@ type SnippetPageProps = { const SnippetPageLoading = ({ snippetId }: SnippetPageProps) => { return ( - <SnippetLayout - snippetId={snippetId} - section="orchestrate" - > + <SnippetLayout snippetId={snippetId} section="orchestrate"> <div className="flex h-full items-center justify-center bg-background-body"> <Loading /> </div> @@ -32,26 +26,22 @@ const SnippetPageLoading = ({ snippetId }: SnippetPageProps) => { const SnippetPage = ({ snippetId }: SnippetPageProps) => { const { data, isLoading } = useSnippetInit(snippetId) const publishedNodesData = useMemo(() => { - if (!data) - return [] + if (!data) return [] return initialNodes(data.published.graph.nodes, data.published.graph.edges) }, [data]) const publishedEdgesData = useMemo(() => { - if (!data) - return [] + if (!data) return [] return initialEdges(data.published.graph.edges, data.published.graph.nodes) }, [data]) const draftNodesData = useMemo(() => { - if (!data) - return [] + if (!data) return [] return initialNodes(data.draft.graph.nodes, data.draft.graph.edges) }, [data]) const draftEdgesData = useMemo(() => { - if (!data) - return [] + if (!data) return [] return initialEdges(data.draft.graph.edges, data.draft.graph.nodes) }, [data]) @@ -65,15 +55,8 @@ const SnippetPage = ({ snippetId }: SnippetPageProps) => { const initialWorkflowEdgesData = hasPublishedWorkflow ? publishedEdgesData : draftEdgesData return ( - <SnippetLayout - snippetId={snippetId} - snippet={data.snippet} - section="orchestrate" - > - <WorkflowWithDefaultContext - edges={initialWorkflowEdgesData} - nodes={initialWorkflowNodesData} - > + <SnippetLayout snippetId={snippetId} snippet={data.snippet} section="orchestrate"> + <WorkflowWithDefaultContext edges={initialWorkflowEdgesData} nodes={initialWorkflowNodesData}> <SnippetMain key={snippetId} snippetId={snippetId} diff --git a/web/app/components/snippets/store/index.ts b/web/app/components/snippets/store/index.ts index 09ec732aae16d4..4fbd41871810f4 100644 --- a/web/app/components/snippets/store/index.ts +++ b/web/app/components/snippets/store/index.ts @@ -22,8 +22,8 @@ const initialState = { onFieldsChange: undefined, } -export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({ +export const useSnippetDetailStore = create<SnippetDetailUIState>((set) => ({ ...initialState, - setNavigationState: state => set(state), + setNavigationState: (state) => set(state), reset: () => set(initialState), })) diff --git a/web/app/components/snippets/utils/permission.ts b/web/app/components/snippets/utils/permission.ts index 0eb2018ea76a4b..76926c498c79e2 100644 --- a/web/app/components/snippets/utils/permission.ts +++ b/web/app/components/snippets/utils/permission.ts @@ -6,15 +6,21 @@ export const SnippetPermission = { Management: 'snippets.management', } as const satisfies Record<string, PermissionKey> -export const canCreateAndModifySnippets = (workspacePermissionKeys: readonly PermissionKey[] | null | undefined) => { +export const canCreateAndModifySnippets = ( + workspacePermissionKeys: readonly PermissionKey[] | null | undefined, +) => { return hasPermission(workspacePermissionKeys, SnippetPermission.CreateAndModify) } -export const canManageSnippets = (workspacePermissionKeys: readonly PermissionKey[] | null | undefined) => { +export const canManageSnippets = ( + workspacePermissionKeys: readonly PermissionKey[] | null | undefined, +) => { return hasPermission(workspacePermissionKeys, SnippetPermission.Management) } -export const canAccessSnippets = (workspacePermissionKeys: readonly PermissionKey[] | null | undefined) => { +export const canAccessSnippets = ( + workspacePermissionKeys: readonly PermissionKey[] | null | undefined, +) => { return hasPermission(workspacePermissionKeys, [ SnippetPermission.CreateAndModify, SnippetPermission.Management, diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx index 98a7b10c766685..56a36dcfdcecb7 100644 --- a/web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx @@ -43,8 +43,12 @@ describe('ConfigCredential', () => { }) expect(screen.getByText('tools.createTool.authMethod.types.none')).toBeInTheDocument() - expect(screen.getByText('tools.createTool.authMethod.types.api_key_header')).toBeInTheDocument() - expect(screen.getByText('tools.createTool.authMethod.types.api_key_query')).toBeInTheDocument() + expect( + screen.getByText('tools.createTool.authMethod.types.api_key_header'), + ).toBeInTheDocument() + expect( + screen.getByText('tools.createTool.authMethod.types.api_key_query'), + ).toBeInTheDocument() }) it('should render with positionCenter prop', async () => { @@ -182,8 +186,12 @@ describe('ConfigCredential', () => { }) fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_header')) - const headerInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiKeyPlaceholder') - const valueInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiValuePlaceholder') + const headerInput = screen.getByPlaceholderText( + 'tools.createTool.authMethod.types.apiKeyPlaceholder', + ) + const valueInput = screen.getByPlaceholderText( + 'tools.createTool.authMethod.types.apiValuePlaceholder', + ) fireEvent.change(headerInput, { target: { value: 'X-Auth' } }) fireEvent.change(valueInput, { target: { value: 'sEcReT' } }) fireEvent.click(screen.getByText('common.operation.save')) @@ -317,7 +325,9 @@ describe('ConfigCredential', () => { fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_query')) // Query param input should appear - expect(screen.getByPlaceholderText('tools.createTool.authMethod.types.queryParamPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('tools.createTool.authMethod.types.queryParamPlaceholder'), + ).toBeInTheDocument() }) it('should submit apiKeyQuery credential with default values', async () => { @@ -354,8 +364,12 @@ describe('ConfigCredential', () => { fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_query')) - const queryParamInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.queryParamPlaceholder') - const valueInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiValuePlaceholder') + const queryParamInput = screen.getByPlaceholderText( + 'tools.createTool.authMethod.types.queryParamPlaceholder', + ) + const valueInput = screen.getByPlaceholderText( + 'tools.createTool.authMethod.types.apiValuePlaceholder', + ) fireEvent.change(queryParamInput, { target: { value: 'api_key' } }) fireEvent.change(valueInput, { target: { value: 'my-secret-key' } }) @@ -422,10 +436,14 @@ describe('ConfigCredential', () => { fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_query')) // Header prefix options should disappear - expect(screen.queryByText('tools.createTool.authHeaderPrefix.types.basic')).not.toBeInTheDocument() + expect( + screen.queryByText('tools.createTool.authHeaderPrefix.types.basic'), + ).not.toBeInTheDocument() // Query param input should appear - expect(screen.getByPlaceholderText('tools.createTool.authMethod.types.queryParamPlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('tools.createTool.authMethod.types.queryParamPlaceholder'), + ).toBeInTheDocument() }) it('should switch from apiKeyQuery to none', async () => { @@ -476,7 +494,9 @@ describe('ConfigCredential', () => { }) // Header inputs should be visible with initial values - const headerInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiKeyPlaceholder') + const headerInput = screen.getByPlaceholderText( + 'tools.createTool.authMethod.types.apiKeyPlaceholder', + ) expect(headerInput).toHaveValue('X-Custom-Header') }) @@ -498,7 +518,9 @@ describe('ConfigCredential', () => { }) // Query param input should be visible with initial value - const queryParamInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.queryParamPlaceholder') + const queryParamInput = screen.getByPlaceholderText( + 'tools.createTool.authMethod.types.queryParamPlaceholder', + ) expect(queryParamInput).toHaveValue('apikey') }) }) diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts b/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts index 6fe3576c26b315..e57691190b58b7 100644 --- a/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts @@ -3,11 +3,7 @@ import examples from '../examples' describe('edit-custom-collection examples', () => { it('provides json, yaml, and blank templates in fixed order', () => { - expect(examples.map(example => example.key)).toEqual([ - 'json', - 'yaml', - 'blankTemplate', - ]) + expect(examples.map((example) => example.key)).toEqual(['json', 'yaml', 'blankTemplate']) }) it('contains representative OpenAPI content for each template', () => { diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx index 1822b8d9339961..7e534564e4f687 100644 --- a/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx @@ -79,28 +79,40 @@ describe('EditCustomCollectionModal', () => { const renderModal = (props?: { payload?: { provider: string - credentials: { auth_type: AuthType, api_key_header?: string, api_key_header_prefix?: AuthHeaderPrefix, api_key_value?: string } + credentials: { + auth_type: AuthType + api_key_header?: string + api_key_header_prefix?: AuthHeaderPrefix + api_key_value?: string + } schema_type: string schema: string - icon: { content: string, background: string } + icon: { content: string; background: string } privacy_policy?: string custom_disclaimer?: string labels?: string[] - tools?: Array<{ operation_id: string, summary: string, method: string, server_url: string, parameters: Array<{ name: string, label: { en_US: string, zh_Hans: string } }> }> + tools?: Array<{ + operation_id: string + summary: string + method: string + server_url: string + parameters: Array<{ name: string; label: { en_US: string; zh_Hans: string } }> + }> } positionLeft?: boolean dialogClassName?: string - }) => render( - <EditCustomCollectionModal - payload={props?.payload} - onHide={mockOnHide} - onAdd={mockOnAdd} - onEdit={mockOnEdit} - onRemove={mockOnRemove} - positionLeft={props?.positionLeft} - dialogClassName={props?.dialogClassName} - />, - ) + }) => + render( + <EditCustomCollectionModal + payload={props?.payload} + onHide={mockOnHide} + onAdd={mockOnAdd} + onEdit={mockOnEdit} + onRemove={mockOnRemove} + positionLeft={props?.positionLeft} + dialogClassName={props?.dialogClassName} + />, + ) // Tests for Add mode (no payload) describe('Add Mode', () => { @@ -122,7 +134,9 @@ describe('EditCustomCollectionModal', () => { fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => { - expect(toastNotifySpy).toHaveBeenCalledWith('common.errorMsg.fieldRequired:{"field":"tools.createTool.name"}') + expect(toastNotifySpy).toHaveBeenCalledWith( + 'common.errorMsg.fieldRequired:{"field":"tools.createTool.name"}', + ) }) expect(mockOnAdd).not.toHaveBeenCalled() }) @@ -136,7 +150,9 @@ describe('EditCustomCollectionModal', () => { fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => { - expect(toastNotifySpy).toHaveBeenCalledWith('common.errorMsg.fieldRequired:{"field":"tools.createTool.schema"}') + expect(toastNotifySpy).toHaveBeenCalledWith( + 'common.errorMsg.fieldRequired:{"field":"tools.createTool.schema"}', + ) }) expect(mockOnAdd).not.toHaveBeenCalled() }) @@ -162,19 +178,21 @@ describe('EditCustomCollectionModal', () => { }) await waitFor(() => { - expect(mockOnAdd).toHaveBeenCalledWith(expect.objectContaining({ - provider: 'provider', - schema: '{}', - schema_type: 'openapi', - icon: { - content: '🕵️', - background: '#FEF7C3', - }, - credentials: { - auth_type: 'none', - }, - labels: [], - })) + expect(mockOnAdd).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'provider', + schema: '{}', + schema_type: 'openapi', + icon: { + content: '🕵️', + background: '#FEF7C3', + }, + credentials: { + auth_type: 'none', + }, + labels: [], + }), + ) expect(toastNotifySpy).not.toHaveBeenCalled() }) }) @@ -204,16 +222,20 @@ describe('EditCustomCollectionModal', () => { privacy_policy: 'https://example.com/privacy', custom_disclaimer: 'Use at your own risk', labels: ['api', 'tools'], - tools: [{ - operation_id: 'getUsers', - summary: 'Get all users', - method: 'GET', - server_url: 'https://api.example.com/users', - parameters: [{ - name: 'limit', - label: { en_US: 'Limit', zh_Hans: '限制' }, - }], - }], + tools: [ + { + operation_id: 'getUsers', + summary: 'Get all users', + method: 'GET', + server_url: 'https://api.example.com/users', + parameters: [ + { + name: 'limit', + label: { en_US: 'Limit', zh_Hans: '限制' }, + }, + ], + }, + ], } it('should render edit mode title when payload is provided', () => { @@ -248,10 +270,12 @@ describe('EditCustomCollectionModal', () => { }) await waitFor(() => { - expect(mockOnEdit).toHaveBeenCalledWith(expect.objectContaining({ - provider: 'updated-provider', - original_provider: 'existing-provider', - })) + expect(mockOnEdit).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'updated-provider', + original_provider: 'existing-provider', + }), + ) }) }) @@ -295,11 +319,13 @@ describe('EditCustomCollectionModal', () => { }) await waitFor(() => { - expect(mockOnEdit).toHaveBeenCalledWith(expect.objectContaining({ - credentials: { - auth_type: AuthType.none, - }, - })) + expect(mockOnEdit).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: { + auth_type: AuthType.none, + }, + }), + ) // These fields should NOT be present const callArg = mockOnEdit.mock.calls[0]![0] expect(callArg.credentials.api_key_header).toBeUndefined() @@ -313,13 +339,15 @@ describe('EditCustomCollectionModal', () => { describe('Schema Parsing', () => { it('should parse schema and update params when schema changes', async () => { parseParamsSchemaMock.mockResolvedValueOnce({ - parameters_schema: [{ - operation_id: 'newOp', - summary: 'New operation', - method: 'POST', - server_url: 'https://api.example.com/new', - parameters: [], - }], + parameters_schema: [ + { + operation_id: 'newOp', + summary: 'New operation', + method: 'POST', + server_url: 'https://api.example.com/new', + parameters: [], + }, + ], schema_type: 'swagger', }) @@ -364,7 +392,7 @@ describe('EditCustomCollectionModal', () => { fireEvent.change(schemaInput, { target: { value: '' } }) // Wait a bit and check that parseParamsSchema was not called with empty string - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) expect(parseParamsSchemaMock).not.toHaveBeenCalledWith('') }) }) @@ -413,13 +441,15 @@ describe('EditCustomCollectionModal', () => { schema_type: 'openapi', schema: '{}', icon: { content: '🔧', background: '#FFCC00' }, - tools: [{ - operation_id: 'testOp', - summary: 'Test operation', - method: 'POST', - server_url: 'https://api.example.com/test', - parameters: [], - }], + tools: [ + { + operation_id: 'testOp', + summary: 'Test operation', + method: 'POST', + server_url: 'https://api.example.com/test', + parameters: [], + }, + ], } it('should render test button in available tools table', () => { @@ -453,7 +483,9 @@ describe('EditCustomCollectionModal', () => { it('should update custom disclaimer input', () => { renderModal() - const disclaimerInput = screen.getByPlaceholderText('tools.createTool.customDisclaimerPlaceholder') + const disclaimerInput = screen.getByPlaceholderText( + 'tools.createTool.customDisclaimerPlaceholder', + ) fireEvent.change(disclaimerInput, { target: { value: 'Custom disclaimer text' } }) expect(disclaimerInput)!.toHaveValue('Custom disclaimer text') @@ -471,7 +503,9 @@ describe('EditCustomCollectionModal', () => { const privacyInput = screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder') fireEvent.change(privacyInput, { target: { value: 'https://privacy.example.com' } }) - const disclaimerInput = screen.getByPlaceholderText('tools.createTool.customDisclaimerPlaceholder') + const disclaimerInput = screen.getByPlaceholderText( + 'tools.createTool.customDisclaimerPlaceholder', + ) fireEvent.change(disclaimerInput, { target: { value: 'My disclaimer' } }) await waitFor(() => { @@ -483,10 +517,12 @@ describe('EditCustomCollectionModal', () => { }) await waitFor(() => { - expect(mockOnAdd).toHaveBeenCalledWith(expect.objectContaining({ - privacy_policy: 'https://privacy.example.com', - custom_disclaimer: 'My disclaimer', - })) + expect(mockOnAdd).toHaveBeenCalledWith( + expect.objectContaining({ + privacy_policy: 'https://privacy.example.com', + custom_disclaimer: 'My disclaimer', + }), + ) }) }) }) @@ -514,13 +550,15 @@ describe('EditCustomCollectionModal', () => { schema_type: 'openapi', schema: '{}', icon: { content: '🔧', background: '#FFCC00' }, - tools: [{ - operation_id: 'testOp', - summary: 'Test', - method: 'GET', - server_url: serverUrl, - parameters: [], - }], + tools: [ + { + operation_id: 'testOp', + summary: 'Test', + method: 'GET', + server_url: serverUrl, + parameters: [], + }, + ], }) it('should extract path from full URL', () => { diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx index 6529b969d8a7e2..ccd309a31f23ae 100644 --- a/web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx @@ -31,13 +31,15 @@ describe('TestApi', () => { summary: 'summary', method: 'GET', server_url: 'https://api.example.com', - parameters: [{ - name: 'limit', - label: { - en_US: 'Limit', - zh_Hans: '限制', - }, - } as CustomParamSchema['parameters'][0]], + parameters: [ + { + name: 'limit', + label: { + en_US: 'Limit', + zh_Hans: '限制', + }, + } as CustomParamSchema['parameters'][0], + ], } const mockOnHide = vi.fn() @@ -221,7 +223,9 @@ describe('TestApi', () => { // Check that the auth method display shows the correct type // Check that the auth method display shows the correct type - expect(screen.getByText('tools.createTool.authMethod.types.api_key_header'))!.toBeInTheDocument() + expect( + screen.getByText('tools.createTool.authMethod.types.api_key_header'), + )!.toBeInTheDocument() }) }) diff --git a/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx b/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx index ef597ffcc7e73e..eebbcc3d78a645 100644 --- a/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx @@ -40,7 +40,9 @@ function SelectItem<Value = string>({ text, value, isChecked }: ItemProps<Value> <FieldItem> <FieldLabel className={cn( - isChecked ? 'border-2 border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' : 'border border-components-card-border', + isChecked + ? 'border-2 border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' + : 'border border-components-card-border', 'flex h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl bg-components-panel-on-panel-item-bg px-3 text-left outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover', )} > @@ -51,12 +53,7 @@ function SelectItem<Value = string>({ text, value, isChecked }: ItemProps<Value> ) } -export default function ConfigCredential({ - positionCenter, - credential, - onChange, - onHide, -}: Props) { +export default function ConfigCredential({ positionCenter, credential, onChange, onHide }: Props) { const { t } = useTranslation() const [tempCredential, setTempCredential] = useState<Credential>(credential) const handleAuthTypeChange = (value: AuthType) => { @@ -89,8 +86,7 @@ export default function ConfigCredential({ disablePointerDismissal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DrawerPortal> @@ -108,10 +104,10 @@ export default function ConfigCredential({ <div className="shrink-0 border-b border-divider-regular py-4"> <div className="flex h-6 items-center justify-between pr-5 pl-6"> <DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary"> - {t($ => $['createTool.authMethod.title'], { ns: 'tools' })} + {t(($) => $['createTool.authMethod.title'], { ns: 'tools' })} </DrawerTitle> <DrawerCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-6 rounded-md" /> </div> @@ -125,29 +121,33 @@ export default function ConfigCredential({ > <Field name="auth_type" className="contents"> <Fieldset - render={( + render={ <RadioGroup<AuthType> className="grid grid-cols-[repeat(auto-fit,minmax(8.5rem,1fr))] gap-2" value={tempCredential.auth_type} onValueChange={handleAuthTypeChange} /> - )} + } > <FieldsetLegend className="col-span-full py-2 system-sm-medium text-text-primary"> - {t($ => $['createTool.authMethod.type'], { ns: 'tools' })} + {t(($) => $['createTool.authMethod.type'], { ns: 'tools' })} </FieldsetLegend> <SelectItem<AuthType> - text={t($ => $['createTool.authMethod.types.none'], { ns: 'tools' })} + text={t(($) => $['createTool.authMethod.types.none'], { ns: 'tools' })} value={AuthType.none} isChecked={tempCredential.auth_type === AuthType.none} /> <SelectItem<AuthType> - text={t($ => $['createTool.authMethod.types.api_key_header'], { ns: 'tools' })} + text={t(($) => $['createTool.authMethod.types.api_key_header'], { + ns: 'tools', + })} value={AuthType.apiKeyHeader} isChecked={tempCredential.auth_type === AuthType.apiKeyHeader} /> <SelectItem<AuthType> - text={t($ => $['createTool.authMethod.types.api_key_query'], { ns: 'tools' })} + text={t(($) => $['createTool.authMethod.types.api_key_query'], { + ns: 'tools', + })} value={AuthType.apiKeyQuery} isChecked={tempCredential.auth_type === AuthType.apiKeyQuery} /> @@ -157,57 +157,87 @@ export default function ConfigCredential({ <> <Field name="api_key_header_prefix" className="contents"> <Fieldset - render={( + render={ <RadioGroup<AuthHeaderPrefix> className="grid grid-cols-[repeat(auto-fit,minmax(8.5rem,1fr))] gap-2" value={tempCredential.api_key_header_prefix} - onValueChange={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value })} + onValueChange={(value) => + setTempCredential({ ...tempCredential, api_key_header_prefix: value }) + } /> - )} + } > <FieldsetLegend className="col-span-full py-2 system-sm-medium text-text-primary"> - {t($ => $['createTool.authHeaderPrefix.title'], { ns: 'tools' })} + {t(($) => $['createTool.authHeaderPrefix.title'], { ns: 'tools' })} </FieldsetLegend> <SelectItem<AuthHeaderPrefix> - text={t($ => $['createTool.authHeaderPrefix.types.basic'], { ns: 'tools' })} + text={t(($) => $['createTool.authHeaderPrefix.types.basic'], { + ns: 'tools', + })} value={AuthHeaderPrefix.basic} - isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic} + isChecked={ + tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic + } /> <SelectItem<AuthHeaderPrefix> - text={t($ => $['createTool.authHeaderPrefix.types.bearer'], { ns: 'tools' })} + text={t(($) => $['createTool.authHeaderPrefix.types.bearer'], { + ns: 'tools', + })} value={AuthHeaderPrefix.bearer} - isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer} + isChecked={ + tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer + } /> <SelectItem<AuthHeaderPrefix> - text={t($ => $['createTool.authHeaderPrefix.types.custom'], { ns: 'tools' })} + text={t(($) => $['createTool.authHeaderPrefix.types.custom'], { + ns: 'tools', + })} value={AuthHeaderPrefix.custom} - isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom} + isChecked={ + tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom + } /> </Fieldset> </Field> <div> <div className="flex items-center py-2 system-sm-medium text-text-primary"> - {t($ => $['createTool.authMethod.key'], { ns: 'tools' })} + {t(($) => $['createTool.authMethod.key'], { ns: 'tools' })} <Infotip - aria-label={t($ => $['createTool.authMethod.keyTooltip'], { ns: 'tools' })} + aria-label={t(($) => $['createTool.authMethod.keyTooltip'], { + ns: 'tools', + })} className="ml-0.5 size-4" popupClassName="w-[261px] text-text-tertiary" > - {t($ => $['createTool.authMethod.keyTooltip'], { ns: 'tools' })} + {t(($) => $['createTool.authMethod.keyTooltip'], { ns: 'tools' })} </Infotip> </div> <Input value={tempCredential.api_key_header} - onChange={e => setTempCredential({ ...tempCredential, api_key_header: e.target.value })} - placeholder={t($ => $['createTool.authMethod.types.apiKeyPlaceholder'], { ns: 'tools' })!} + onChange={(e) => + setTempCredential({ ...tempCredential, api_key_header: e.target.value }) + } + placeholder={ + t(($) => $['createTool.authMethod.types.apiKeyPlaceholder'], { + ns: 'tools', + })! + } /> </div> <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.authMethod.value'], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.authMethod.value'], { ns: 'tools' })} + </div> <Input value={tempCredential.api_key_value} - onChange={e => setTempCredential({ ...tempCredential, api_key_value: e.target.value })} - placeholder={t($ => $['createTool.authMethod.types.apiValuePlaceholder'], { ns: 'tools' })!} + onChange={(e) => + setTempCredential({ ...tempCredential, api_key_value: e.target.value }) + } + placeholder={ + t(($) => $['createTool.authMethod.types.apiValuePlaceholder'], { + ns: 'tools', + })! + } /> </div> </> @@ -216,34 +246,55 @@ export default function ConfigCredential({ <> <div> <div className="flex items-center py-2 system-sm-medium text-text-primary"> - {t($ => $['createTool.authMethod.queryParam'], { ns: 'tools' })} + {t(($) => $['createTool.authMethod.queryParam'], { ns: 'tools' })} <Infotip - aria-label={t($ => $['createTool.authMethod.queryParamTooltip'], { ns: 'tools' })} + aria-label={t(($) => $['createTool.authMethod.queryParamTooltip'], { + ns: 'tools', + })} className="ml-0.5 size-4" popupClassName="w-[261px] text-text-tertiary" > - {t($ => $['createTool.authMethod.queryParamTooltip'], { ns: 'tools' })} + {t(($) => $['createTool.authMethod.queryParamTooltip'], { ns: 'tools' })} </Infotip> </div> <Input value={tempCredential.api_key_query_param} - onChange={e => setTempCredential({ ...tempCredential, api_key_query_param: e.target.value })} - placeholder={t($ => $['createTool.authMethod.types.queryParamPlaceholder'], { ns: 'tools' })!} + onChange={(e) => + setTempCredential({ + ...tempCredential, + api_key_query_param: e.target.value, + }) + } + placeholder={ + t(($) => $['createTool.authMethod.types.queryParamPlaceholder'], { + ns: 'tools', + })! + } /> </div> <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.authMethod.value'], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.authMethod.value'], { ns: 'tools' })} + </div> <Input value={tempCredential.api_key_value} - onChange={e => setTempCredential({ ...tempCredential, api_key_value: e.target.value })} - placeholder={t($ => $['createTool.authMethod.types.apiValuePlaceholder'], { ns: 'tools' })!} + onChange={(e) => + setTempCredential({ ...tempCredential, api_key_value: e.target.value }) + } + placeholder={ + t(($) => $['createTool.authMethod.types.apiValuePlaceholder'], { + ns: 'tools', + })! + } /> </div> </> )} </ScrollArea> <div className="mt-4 flex shrink-0 justify-end space-x-2 px-6 py-4"> - <Button onClick={onHide}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> + <Button onClick={onHide}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> <Button variant="primary" onClick={() => { @@ -251,7 +302,7 @@ export default function ConfigCredential({ onHide() }} > - {t($ => $['operation.save'], { ns: 'common' })} + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </DrawerContent> diff --git a/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx b/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx index cc4bd31acd87a9..d2e906f939b092 100644 --- a/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx @@ -19,16 +19,14 @@ type Props = Readonly<{ onChange: (value: string) => void }> -const GetSchema: FC<Props> = ({ - onChange, -}) => { +const GetSchema: FC<Props> = ({ onChange }) => { const { t } = useTranslation() const [showImportFromUrl, setShowImportFromUrl] = useState(false) const [importUrl, setImportUrl] = useState('') const [isParsing, setIsParsing] = useState(false) const handleImportFromUrl = async () => { if (!importUrl.startsWith('http://') && !importUrl.startsWith('https://')) { - toast.error(t($ => $['createTool.urlError'], { ns: 'tools' })) + toast.error(t(($) => $['createTool.urlError'], { ns: 'tools' })) return } setIsParsing(true) @@ -36,8 +34,7 @@ const GetSchema: FC<Props> = ({ const { schema } = await importSchemaFromURL(importUrl) setImportUrl('') onChange(schema) - } - finally { + } finally { setIsParsing(false) setShowImportFromUrl(false) } @@ -48,29 +45,20 @@ const GetSchema: FC<Props> = ({ return ( <div className="flex w-[224px] justify-end gap-1"> <DropdownMenu open={showImportFromUrl} onOpenChange={setShowImportFromUrl}> - <DropdownMenuTrigger - render={( - <Button - size="small" - className="gap-1" - /> - )} - > + <DropdownMenuTrigger render={<Button size="small" className="gap-1" />}> <span className="i-ri-add-line size-3" aria-hidden /> - <span className="system-xs-medium text-text-secondary">{t($ => $['createTool.importFromUrl'], { ns: 'tools' })}</span> + <span className="system-xs-medium text-text-secondary"> + {t(($) => $['createTool.importFromUrl'], { ns: 'tools' })} + </span> </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-start" - sideOffset={2} - popupClassName="w-[300px] p-2" - > + <DropdownMenuContent placement="bottom-start" sideOffset={2} popupClassName="w-[300px] p-2"> <div className="relative"> <Input type="text" className="w-full" - placeholder={t($ => $['createTool.importFromUrlPlaceHolder'], { ns: 'tools' })!} + placeholder={t(($) => $['createTool.importFromUrlPlaceHolder'], { ns: 'tools' })!} value={importUrl} - onChange={e => setImportUrl(e.target.value)} + onChange={(e) => setImportUrl(e.target.value)} /> <Button className="absolute top-1 right-1" @@ -80,29 +68,20 @@ const GetSchema: FC<Props> = ({ onClick={handleImportFromUrl} loading={isParsing} > - {isParsing ? '' : t($ => $['operation.ok'], { ns: 'common' })} + {isParsing ? '' : t(($) => $['operation.ok'], { ns: 'common' })} </Button> </div> </DropdownMenuContent> </DropdownMenu> <DropdownMenu open={showExamples} onOpenChange={setShowExamples}> - <DropdownMenuTrigger - render={( - <Button - size="small" - className="gap-1" - /> - )} - > - <span className="system-xs-medium text-text-secondary">{t($ => $['createTool.examples'], { ns: 'tools' })}</span> + <DropdownMenuTrigger render={<Button size="small" className="gap-1" />}> + <span className="system-xs-medium text-text-secondary"> + {t(($) => $['createTool.examples'], { ns: 'tools' })} + </span> <span className="i-ri-arrow-down-s-line size-3" aria-hidden /> </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-end" - sideOffset={2} - popupClassName="min-w-max" - > - {examples.map(item => ( + <DropdownMenuContent placement="bottom-end" sideOffset={2} popupClassName="min-w-max"> + {examples.map((item) => ( <DropdownMenuItem key={item.key} onClick={() => { @@ -111,7 +90,7 @@ const GetSchema: FC<Props> = ({ }} className="system-sm-regular whitespace-nowrap text-text-secondary" > - {t($ => $[`createTool.exampleOptions.${item.key}`], { ns: 'tools' })} + {t(($) => $[`createTool.exampleOptions.${item.key}`], { ns: 'tools' })} </DropdownMenuItem> ))} </DropdownMenuContent> diff --git a/web/app/components/tools/edit-custom-collection-modal/index.tsx b/web/app/components/tools/edit-custom-collection-modal/index.tsx index 6d9f35ce1a3f9c..17a32d42103602 100644 --- a/web/app/components/tools/edit-custom-collection-modal/index.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/index.tsx @@ -58,22 +58,25 @@ const EditCustomCollectionModal: FC<Props> = ({ const [editFirst, setEditFirst] = useState(!isAdd) const [paramsSchemas, setParamsSchemas] = useState<CustomParamSchema[]>(payload?.tools || []) const [labels, setLabels] = useState<string[]>(payload?.labels || []) - const [customCollection, setCustomCollection, getCustomCollection] = useGetState<CustomCollectionBackend>(isAdd - ? { - provider: '', - credentials: { - auth_type: AuthType.none, - api_key_header: 'Authorization', - api_key_header_prefix: AuthHeaderPrefix.basic, - }, - icon: { - content: '🕵️', - background: '#FEF7C3', - }, - schema_type: '', - schema: '', - } - : payload) + const [customCollection, setCustomCollection, getCustomCollection] = + useGetState<CustomCollectionBackend>( + isAdd + ? { + provider: '', + credentials: { + auth_type: AuthType.none, + api_key_header: 'Authorization', + api_key_header_prefix: AuthHeaderPrefix.basic, + }, + icon: { + content: '🕵️', + background: '#FEF7C3', + }, + schema_type: '', + schema: '', + } + : payload, + ) const originalProvider = isEdit ? payload.provider : '' @@ -104,13 +107,12 @@ const EditCustomCollectionModal: FC<Props> = ({ } useEffect(() => { - if (!debouncedSchema) - return + if (!debouncedSchema) return if (isEdit && editFirst) { setEditFirst(false) return } - (async () => { + ;(async () => { try { const { parameters_schema, schema_type } = await parseParamsSchema(debouncedSchema) const customCollection = getCustomCollection() @@ -119,8 +121,7 @@ const EditCustomCollectionModal: FC<Props> = ({ }) setCustomCollection(newCollection) setParamsSchemas(parameters_schema) - } - catch { + } catch { const customCollection = getCustomCollection() const newCollection = produce(customCollection, (draft) => { draft.schema_type = '' @@ -163,10 +164,16 @@ const EditCustomCollectionModal: FC<Props> = ({ let errorMessage = '' if (!postData.provider) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: t($ => $['createTool.name'], { ns: 'tools' }) }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: t(($) => $['createTool.name'], { ns: 'tools' }), + }) if (!postData.schema) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: t($ => $['createTool.schema'], { ns: 'tools' }) }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: t(($) => $['createTool.schema'], { ns: 'tools' }), + }) if (errorMessage) { toast.error(errorMessage) @@ -185,14 +192,12 @@ const EditCustomCollectionModal: FC<Props> = ({ } const getPath = (url: string) => { - if (!url) - return '' + if (!url) return '' try { const path = decodeURI(new URL(url).pathname) return path || '' - } - catch { + } catch { return url } } @@ -205,8 +210,7 @@ const EditCustomCollectionModal: FC<Props> = ({ disablePointerDismissal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DrawerPortal> @@ -224,10 +228,10 @@ const EditCustomCollectionModal: FC<Props> = ({ <div className="shrink-0 border-b border-divider-regular py-4"> <div className="flex h-6 items-center justify-between pr-5 pl-6"> <DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary"> - {t($ => $[`createTool.${isAdd ? 'title' : 'editTitle'}`], { ns: 'tools' })} + {t(($) => $[`createTool.${isAdd ? 'title' : 'editTitle'}`], { ns: 'tools' })} </DrawerTitle> <DrawerCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-6 rounded-md" /> </div> @@ -237,15 +241,24 @@ const EditCustomCollectionModal: FC<Props> = ({ <div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3"> <div> <div className="py-2 system-sm-medium text-text-primary"> - {t($ => $['createTool.name'], { ns: 'tools' })} - {' '} + {t(($) => $['createTool.name'], { ns: 'tools' })}{' '} <span className="ml-1 text-red-500">*</span> </div> <div className="flex items-center justify-between gap-3"> - <AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" icon={emoji.content} background={emoji.background} /> + <AppIcon + size="large" + onClick={() => { + setShowEmojiPicker(true) + }} + className="cursor-pointer" + icon={emoji.content} + background={emoji.background} + /> <Input className="h-10 grow" - placeholder={t($ => $['createTool.toolNamePlaceHolder'], { ns: 'tools' })!} + placeholder={ + t(($) => $['createTool.toolNamePlaceHolder'], { ns: 'tools' })! + } value={customCollection.provider} onChange={(e) => { const newCollection = produce(customCollection, (draft) => { @@ -262,7 +275,7 @@ const EditCustomCollectionModal: FC<Props> = ({ <div className="flex items-center justify-between"> <div className="flex items-center"> <div className="py-2 system-sm-medium text-text-primary"> - {t($ => $['createTool.schema'], { ns: 'tools' })} + {t(($) => $['createTool.schema'], { ns: 'tools' })} <span className="ml-1 text-red-500">*</span> </div> <div className="mx-2 h-3 w-px bg-divider-regular"></div> @@ -272,39 +285,64 @@ const EditCustomCollectionModal: FC<Props> = ({ rel="noopener noreferrer" className="flex h-[18px] items-center space-x-1 text-text-accent" > - <div className="text-xs font-normal">{t($ => $['createTool.viewSchemaSpec'], { ns: 'tools' })}</div> + <div className="text-xs font-normal"> + {t(($) => $['createTool.viewSchemaSpec'], { ns: 'tools' })} + </div> <LinkExternal02 className="size-3" /> </a> </div> <GetSchema onChange={setSchema} /> - </div> <Textarea - aria-label={t($ => $['createTool.schema'], { ns: 'tools' })} + aria-label={t(($) => $['createTool.schema'], { ns: 'tools' })} className="h-[240px] resize-none" value={schema} - onValueChange={value => setSchema(value)} - placeholder={t($ => $['createTool.schemaPlaceHolder'], { ns: 'tools' })!} + onValueChange={(value) => setSchema(value)} + placeholder={ + t(($) => $['createTool.schemaPlaceHolder'], { ns: 'tools' })! + } /> </div> {/* Available Tools */} <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.availableTools.title'], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.availableTools.title'], { ns: 'tools' })} + </div> <div className="w-full overflow-x-auto rounded-lg border border-divider-regular"> <table className="w-full system-xs-regular text-text-secondary"> <thead className="text-text-tertiary uppercase"> - <tr className={cn(paramsSchemas.length > 0 && 'border-b', 'border-divider-regular')}> - <th className="p-2 pl-3 font-medium">{t($ => $['createTool.availableTools.name'], { ns: 'tools' })}</th> - <th className="w-[236px] p-2 pl-3 font-medium">{t($ => $['createTool.availableTools.description'], { ns: 'tools' })}</th> - <th className="p-2 pl-3 font-medium">{t($ => $['createTool.availableTools.method'], { ns: 'tools' })}</th> - <th className="p-2 pl-3 font-medium">{t($ => $['createTool.availableTools.path'], { ns: 'tools' })}</th> - <th className="w-[54px] p-2 pl-3 font-medium">{t($ => $['createTool.availableTools.action'], { ns: 'tools' })}</th> + <tr + className={cn( + paramsSchemas.length > 0 && 'border-b', + 'border-divider-regular', + )} + > + <th className="p-2 pl-3 font-medium"> + {t(($) => $['createTool.availableTools.name'], { ns: 'tools' })} + </th> + <th className="w-[236px] p-2 pl-3 font-medium"> + {t(($) => $['createTool.availableTools.description'], { + ns: 'tools', + })} + </th> + <th className="p-2 pl-3 font-medium"> + {t(($) => $['createTool.availableTools.method'], { ns: 'tools' })} + </th> + <th className="p-2 pl-3 font-medium"> + {t(($) => $['createTool.availableTools.path'], { ns: 'tools' })} + </th> + <th className="w-[54px] p-2 pl-3 font-medium"> + {t(($) => $['createTool.availableTools.action'], { ns: 'tools' })} + </th> </tr> </thead> <tbody> {paramsSchemas.map((item, index) => ( - <tr key={index} className="border-b border-divider-regular last:border-0"> + <tr + key={index} + className="border-b border-divider-regular last:border-0" + > <td className="p-2 pl-3">{item.operation_id}</td> <td className="w-[236px] p-2 pl-3">{item.summary}</td> <td className="p-2 pl-3">{item.method}</td> @@ -317,7 +355,9 @@ const EditCustomCollectionModal: FC<Props> = ({ setIsShowTestApi(true) }} > - {t($ => $['createTool.availableTools.test'], { ns: 'tools' })} + {t(($) => $['createTool.availableTools.test'], { + ns: 'tools', + })} </Button> </td> </tr> @@ -329,22 +369,35 @@ const EditCustomCollectionModal: FC<Props> = ({ {/* Authorization method */} <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.authMethod.title'], { ns: 'tools' })}</div> - <div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5" onClick={() => setCredentialsModalShow(true)}> - <div className="system-xs-regular text-text-primary">{t($ => $[`createTool.authMethod.types.${credential.auth_type}`], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.authMethod.title'], { ns: 'tools' })} + </div> + <div + className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5" + onClick={() => setCredentialsModalShow(true)} + > + <div className="system-xs-regular text-text-primary"> + {t(($) => $[`createTool.authMethod.types.${credential.auth_type}`], { + ns: 'tools', + })} + </div> <RiSettings2Line className="size-4 text-text-secondary" /> </div> </div> {/* Labels */} <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.toolInput.label'], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.toolInput.label'], { ns: 'tools' })} + </div> <LabelSelector value={labels} onChange={handleLabelSelect} /> </div> {/* Privacy Policy */} <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.privacyPolicy'], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.privacyPolicy'], { ns: 'tools' })} + </div> <Input value={customCollection.privacy_policy} onChange={(e) => { @@ -354,12 +407,17 @@ const EditCustomCollectionModal: FC<Props> = ({ setCustomCollection(newCollection) }} className="h-10 grow" - placeholder={t($ => $['createTool.privacyPolicyPlaceholder'], { ns: 'tools' }) || ''} + placeholder={ + t(($) => $['createTool.privacyPolicyPlaceholder'], { ns: 'tools' }) || + '' + } /> </div> <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.customDisclaimer'], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.customDisclaimer'], { ns: 'tools' })} + </div> <Input value={customCollection.custom_disclaimer} onChange={(e) => { @@ -369,20 +427,32 @@ const EditCustomCollectionModal: FC<Props> = ({ setCustomCollection(newCollection) }} className="h-10 grow" - placeholder={t($ => $['createTool.customDisclaimerPlaceholder'], { ns: 'tools' }) || ''} + placeholder={ + t(($) => $['createTool.customDisclaimerPlaceholder'], { + ns: 'tools', + }) || '' + } /> </div> - </div> - <div className={cn(isEdit ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}> - { - isEdit && ( - <Button variant="primary" tone="destructive" onClick={onRemove}>{t($ => $['operation.delete'], { ns: 'common' })}</Button> - ) - } + <div + className={cn( + isEdit ? 'justify-between' : 'justify-end', + 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4', + )} + > + {isEdit && ( + <Button variant="primary" tone="destructive" onClick={onRemove}> + {t(($) => $['operation.delete'], { ns: 'common' })} + </Button> + )} <div className="flex space-x-2"> - <Button onClick={onHide}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" onClick={handleSave}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button onClick={onHide}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button variant="primary" onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> </div> {showEmojiPicker && ( @@ -418,7 +488,6 @@ const EditCustomCollectionModal: FC<Props> = ({ </DrawerPortal> </Drawer> </> - ) } export default React.memo(EditCustomCollectionModal) diff --git a/web/app/components/tools/edit-custom-collection-modal/test-api.tsx b/web/app/components/tools/edit-custom-collection-modal/test-api.tsx index f6a3ebaaf5c6be..169bc02a4031c7 100644 --- a/web/app/components/tools/edit-custom-collection-modal/test-api.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/test-api.tsx @@ -1,6 +1,10 @@ 'use client' import type { FC } from 'react' -import type { Credential, CustomCollectionBackend, CustomParamSchema } from '@/app/components/tools/types' +import type { + Credential, + CustomCollectionBackend, + CustomParamSchema, +} from '@/app/components/tools/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { @@ -31,24 +35,20 @@ type Props = Readonly<{ onHide: () => void }> -const TestApi: FC<Props> = ({ - positionCenter, - customCollection, - tool, - onHide, -}) => { +const TestApi: FC<Props> = ({ positionCenter, customCollection, tool, onHide }) => { const { t } = useTranslation() const locale = useLocale() const language = getLanguage(locale) const [credentialsModalShow, setCredentialsModalShow] = useState(false) - const [tempCredential, setTempCredential] = React.useState<Credential>(customCollection.credentials) + const [tempCredential, setTempCredential] = React.useState<Credential>( + customCollection.credentials, + ) const [testing, setTesting] = useState(false) const [result, setResult] = useState<string>('') const { operation_id: toolName, parameters } = tool const [parametersValue, setParametersValue] = useState<Record<string, string>>({}) const handleTest = async () => { - if (testing) - return + if (testing) return setTesting(true) // clone test schema const credentials = JSON.parse(JSON.stringify(tempCredential)) as Credential @@ -65,7 +65,7 @@ const TestApi: FC<Props> = ({ schema: customCollection.schema, parameters: parametersValue, } - const res = await testAPIAvailable(data) as any + const res = (await testAPIAvailable(data)) as any setResult(res.error || res.result) setTesting(false) } @@ -78,8 +78,7 @@ const TestApi: FC<Props> = ({ disablePointerDismissal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DrawerPortal> @@ -97,10 +96,10 @@ const TestApi: FC<Props> = ({ <div className="shrink-0 border-b border-divider-regular py-4"> <div className="flex h-6 items-center justify-between pr-5 pl-6"> <DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary"> - {`${t($ => $['test.title'], { ns: 'tools' })} ${toolName}`} + {`${t(($) => $['test.title'], { ns: 'tools' })} ${toolName}`} </DrawerTitle> <DrawerCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-6 rounded-md" /> </div> @@ -108,33 +107,54 @@ const TestApi: FC<Props> = ({ <div className="min-h-0 flex-1 overflow-y-auto px-6 pt-2"> <div className="space-y-4"> <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.authMethod.title'], { ns: 'tools' })}</div> - <div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5" onClick={() => setCredentialsModalShow(true)}> - <div className="system-xs-regular text-text-primary">{t($ => $[`createTool.authMethod.types.${tempCredential.auth_type}`], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.authMethod.title'], { ns: 'tools' })} + </div> + <div + className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5" + onClick={() => setCredentialsModalShow(true)} + > + <div className="system-xs-regular text-text-primary"> + {t(($) => $[`createTool.authMethod.types.${tempCredential.auth_type}`], { + ns: 'tools', + })} + </div> <RiSettings2Line className="size-4 text-text-secondary" /> </div> </div> <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['test.parametersValue'], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['test.parametersValue'], { ns: 'tools' })} + </div> <div className="rounded-lg border border-divider-regular"> <table className="w-full body-xs-regular text-text-secondary"> <thead className="text-text-tertiary uppercase"> <tr className="border-b border-divider-regular"> - <th className="p-2 pl-3 font-medium">{t($ => $['test.parameters'], { ns: 'tools' })}</th> - <th className="p-2 pl-3 font-medium">{t($ => $['test.value'], { ns: 'tools' })}</th> + <th className="p-2 pl-3 font-medium"> + {t(($) => $['test.parameters'], { ns: 'tools' })} + </th> + <th className="p-2 pl-3 font-medium"> + {t(($) => $['test.value'], { ns: 'tools' })} + </th> </tr> </thead> <tbody> {parameters.map((item, index) => ( - <tr key={index} className="border-b border-divider-regular last:border-0"> - <td className="py-2 pr-2.5 pl-3"> - {item.label[language]} - </td> + <tr + key={index} + className="border-b border-divider-regular last:border-0" + > + <td className="py-2 pr-2.5 pl-3">{item.label[language]}</td> <td className=""> <Input value={parametersValue[item.name] || ''} - onChange={e => setParametersValue({ ...parametersValue, [item.name]: e.target.value })} + onChange={(e) => + setParametersValue({ + ...parametersValue, + [item.name]: e.target.value, + }) + } type="text" className="!hover:border-transparent !hover:bg-transparent !focus:border-transparent !focus:bg-transparent border-transparent! bg-transparent!" /> @@ -145,16 +165,29 @@ const TestApi: FC<Props> = ({ </table> </div> </div> - </div> - <Button variant="primary" className="mt-4 h-10 w-full" loading={testing} disabled={testing} onClick={handleTest}>{t($ => $['test.title'], { ns: 'tools' })}</Button> + <Button + variant="primary" + className="mt-4 h-10 w-full" + loading={testing} + disabled={testing} + onClick={handleTest} + > + {t(($) => $['test.title'], { ns: 'tools' })} + </Button> <div className="mt-6"> <div className="flex items-center space-x-3"> - <div className="system-xs-semibold text-text-tertiary">{t($ => $['test.testResult'], { ns: 'tools' })}</div> + <div className="system-xs-semibold text-text-tertiary"> + {t(($) => $['test.testResult'], { ns: 'tools' })} + </div> <div className="bg-[rgb(243, 244, 246)] h-px w-0 grow"></div> </div> <div className="mt-2 h-[200px] overflow-x-hidden overflow-y-auto rounded-lg bg-components-input-bg-normal px-3 py-2 system-xs-regular text-text-secondary"> - {result || <span className="text-text-quaternary">{t($ => $['test.testResultPlaceholder'], { ns: 'tools' })}</span>} + {result || ( + <span className="text-text-quaternary"> + {t(($) => $['test.testResultPlaceholder'], { ns: 'tools' })} + </span> + )} </div> </div> </div> diff --git a/web/app/components/tools/integrations-setting-modal.tsx b/web/app/components/tools/integrations-setting-modal.tsx index bc416317738a0d..d71b187c3cb579 100644 --- a/web/app/components/tools/integrations-setting-modal.tsx +++ b/web/app/components/tools/integrations-setting-modal.tsx @@ -25,7 +25,11 @@ export default function IntegrationsSettingModal({ const { t } = useTranslation() const isAgentSource = source === 'agent' const handleSwitchToMarketplace = useCallback((path: string) => { - window.open(getMarketplaceUrl(path, undefined, { source: window.location.origin }), '_blank', 'noopener,noreferrer') + window.open( + getMarketplaceUrl(path, undefined, { source: window.location.origin }), + '_blank', + 'noopener,noreferrer', + ) }, []) return ( @@ -35,10 +39,11 @@ export default function IntegrationsSettingModal({ className={isAgentSource ? 'bg-transparent backdrop-blur-none' : undefined} onClose={onCancel} > - <div className={cn( - 'mx-auto flex h-dvh w-[min(1440px,calc(100vw-48px))] shrink-0 py-6', - isAgentSource && 'w-full p-6', - )} + <div + className={cn( + 'mx-auto flex h-dvh w-[min(1440px,calc(100vw-48px))] shrink-0 py-6', + isAgentSource && 'w-full p-6', + )} > <div className="relative flex min-h-0 w-full shrink-0 overflow-hidden rounded-2xl border border-divider-subtle bg-components-panel-bg shadow-2xl"> <IntegrationsPage @@ -51,7 +56,7 @@ export default function IntegrationsSettingModal({ variant="tertiary" size="large" className="px-2" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onCancel} > <span className="i-ri-close-line h-5 w-5" /> diff --git a/web/app/components/tools/labels/__tests__/filter.spec.tsx b/web/app/components/tools/labels/__tests__/filter.spec.tsx index 2435ab7787a92f..a011c6a4156adb 100644 --- a/web/app/components/tools/labels/__tests__/filter.spec.tsx +++ b/web/app/components/tools/labels/__tests__/filter.spec.tsx @@ -14,7 +14,7 @@ vi.mock('@/app/components/plugins/hooks', () => ({ useTags: () => ({ tags: mockTags, tagsMap: mockTags.reduce((acc, tag) => ({ ...acc, [tag.name]: tag }), {}), - getTagLabel: (name: string) => mockTags.find(t => t.name === name)?.label ?? name, + getTagLabel: (name: string) => mockTags.find((t) => t.name === name)?.label ?? name, }), })) @@ -101,11 +101,10 @@ describe('LabelFilter', () => { // Find the label item in the dropdown list const labelItems = screen.getAllByText('Agent') - const dropdownItem = labelItems.find(el => el.closest('.hover\\:bg-state-base-hover')) + const dropdownItem = labelItems.find((el) => el.closest('.hover\\:bg-state-base-hover')) await act(async () => { - if (dropdownItem) - fireEvent.click(dropdownItem) + if (dropdownItem) fireEvent.click(dropdownItem) }) expect(mockOnChange).toHaveBeenCalledWith([]) diff --git a/web/app/components/tools/labels/__tests__/selector.spec.tsx b/web/app/components/tools/labels/__tests__/selector.spec.tsx index 9e011a31ef263e..64b7b87d19842e 100644 --- a/web/app/components/tools/labels/__tests__/selector.spec.tsx +++ b/web/app/components/tools/labels/__tests__/selector.spec.tsx @@ -22,16 +22,11 @@ vi.mock('@langgenius/dify-ui/popover', async () => { const isControlled = controlledOpen !== undefined const open = isControlled ? !!controlledOpen : uncontrolledOpen const setOpen = (nextOpen: boolean) => { - if (!isControlled) - setUncontrolledOpen(nextOpen) + if (!isControlled) setUncontrolledOpen(nextOpen) onOpenChange?.(nextOpen) } - return ( - <PopoverContext.Provider value={{ open, setOpen }}> - {children} - </PopoverContext.Provider> - ) + return <PopoverContext.Provider value={{ open, setOpen }}>{children}</PopoverContext.Provider> } const PopoverTrigger = ({ @@ -45,11 +40,7 @@ vi.mock('@langgenius/dify-ui/popover', async () => { }) => { const { open, setOpen } = React.useContext(PopoverContext) if (render) { - return ( - <div onClick={() => setOpen(!open)}> - {render} - </div> - ) + return <div onClick={() => setOpen(!open)}>{render}</div> } return ( @@ -64,8 +55,7 @@ vi.mock('@langgenius/dify-ui/popover', async () => { ...props }: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => { const { open } = React.useContext(PopoverContext) - if (!open) - return null + if (!open) return null return <div {...props}>{children}</div> } @@ -89,7 +79,7 @@ vi.mock('@/app/components/plugins/hooks', () => ({ useTags: () => ({ tags: mockTags, tagsMap: mockTags.reduce((acc, tag) => ({ ...acc, [tag.name]: tag }), {}), - getTagLabel: (name: string) => mockTags.find(t => t.name === name)?.label ?? name, + getTagLabel: (name: string) => mockTags.find((t) => t.name === name)?.label ?? name, }), })) @@ -138,7 +128,9 @@ describe('LabelSelector', () => { it('should render the trigger as a native button', () => { render(<LabelSelector value={[]} onChange={mockOnChange} />) - expect(screen.getByRole('button', { name: 'tools.createTool.toolInput.labelPlaceholder' })).toHaveAttribute('type', 'button') + expect( + screen.getByRole('button', { name: 'tools.createTool.toolInput.labelPlaceholder' }), + ).toHaveAttribute('type', 'button') }) it('should display selected labels as comma-separated list', () => { diff --git a/web/app/components/tools/labels/filter.tsx b/web/app/components/tools/labels/filter.tsx index 048b2bcaa230af..17fcb9b99b030d 100644 --- a/web/app/components/tools/labels/filter.tsx +++ b/web/app/components/tools/labels/filter.tsx @@ -1,11 +1,7 @@ import type { FC } from 'react' import type { Label } from '@/app/components/tools/labels/constant' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowDownSLine } from '@remixicon/react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -19,10 +15,7 @@ type LabelFilterProps = { value: string[] onChange: (v: string[]) => void } -const LabelFilter: FC<LabelFilterProps> = ({ - value, - onChange, -}) => { +const LabelFilter: FC<LabelFilterProps> = ({ value, onChange }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -31,25 +24,20 @@ const LabelFilter: FC<LabelFilterProps> = ({ const [keywords, setKeywords] = useState('') const filteredLabelList = useMemo(() => { - return labelList.filter(label => label.name.includes(keywords)) + return labelList.filter((label) => label.name.includes(keywords)) }, [labelList, keywords]) const currentLabel = useMemo(() => { - return labelList.find(label => label.name === value[0]) + return labelList.find((label) => label.name === value[0]) }, [value, labelList]) const selectLabel = (label: Label) => { - if (value.includes(label.name)) - onChange(value.filter(v => v !== label.name)) - else - onChange([...value, label.name]) + if (value.includes(label.name)) onChange(value.filter((v) => v !== label.name)) + else onChange([...value, label.name]) } return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <div className="relative"> <PopoverTrigger className={cn( @@ -59,25 +47,26 @@ const LabelFilter: FC<LabelFilterProps> = ({ > <div className="flex min-w-0 items-center p-1"> <div className="min-w-0 truncate text-[13px] leading-4 text-text-tertiary"> - {!value.length && t($ => $['tag.tags'], { ns: 'common' })} + {!value.length && t(($) => $['tag.tags'], { ns: 'common' })} {!!value.length && currentLabel?.label} </div> </div> {value.length > 1 && ( <div className="shrink-0 text-[13px] leading-4 font-normal text-text-tertiary">{`+${value.length - 1}`}</div> )} - {!value.length && ( - <RiArrowDownSLine className="size-4 shrink-0 text-text-tertiary" /> - )} + {!value.length && <RiArrowDownSLine className="size-4 shrink-0 text-text-tertiary" />} </PopoverTrigger> {!!value.length && ( <button type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} className="group/clear absolute top-1/2 right-2 -translate-y-1/2 border-none bg-transparent p-px" onClick={() => onChange([])} > - <XCircle className="size-3.5 text-text-tertiary group-hover/clear:text-text-secondary" aria-hidden="true" /> + <XCircle + className="size-3.5 text-text-tertiary group-hover/clear:text-text-secondary" + aria-hidden="true" + /> </button> )} <PopoverContent @@ -91,12 +80,12 @@ const LabelFilter: FC<LabelFilterProps> = ({ showLeftIcon showClearIcon value={keywords} - onChange={e => setKeywords(e.target.value)} + onChange={(e) => setKeywords(e.target.value)} onClear={() => setKeywords('')} /> </div> <div className="p-1"> - {filteredLabelList.map(label => ( + {filteredLabelList.map((label) => ( <button key={label.name} type="button" @@ -104,13 +93,17 @@ const LabelFilter: FC<LabelFilterProps> = ({ onClick={() => selectLabel(label)} > <div className="grow truncate text-sm/5 text-text-secondary">{label.label}</div> - {value.includes(label.name) && <Check className="size-4 shrink-0 text-text-accent" aria-hidden="true" />} + {value.includes(label.name) && ( + <Check className="size-4 shrink-0 text-text-accent" aria-hidden="true" /> + )} </button> ))} {!filteredLabelList.length && ( <div className="flex flex-col items-center gap-1 p-3"> <Tag03 className="size-6 text-text-quaternary" /> - <div className="text-xs leading-[14px] text-text-tertiary">{t($ => $['tag.noTag'], { ns: 'common' })}</div> + <div className="text-xs leading-[14px] text-text-tertiary"> + {t(($) => $['tag.noTag'], { ns: 'common' })} + </div> </div> )} </div> @@ -118,7 +111,6 @@ const LabelFilter: FC<LabelFilterProps> = ({ </PopoverContent> </div> </Popover> - ) } diff --git a/web/app/components/tools/labels/selector.tsx b/web/app/components/tools/labels/selector.tsx index cec72088d303d7..258a7fd202a880 100644 --- a/web/app/components/tools/labels/selector.tsx +++ b/web/app/components/tools/labels/selector.tsx @@ -1,11 +1,7 @@ import { Checkbox } from '@langgenius/dify-ui/checkbox' import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useDebounceFn } from 'ahooks' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -18,10 +14,7 @@ type LabelSelectorProps = { onChange: (v: string[]) => void } -function LabelSelector({ - value, - onChange, -}: LabelSelectorProps) { +function LabelSelector({ value, onChange }: LabelSelectorProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -29,17 +22,20 @@ function LabelSelector({ const [keywords, setKeywords] = useState('') const [searchKeywords, setSearchKeywords] = useState('') - const { run: handleSearch } = useDebounceFn(() => { - setSearchKeywords(keywords) - }, { wait: 500 }) + const { run: handleSearch } = useDebounceFn( + () => { + setSearchKeywords(keywords) + }, + { wait: 500 }, + ) const handleKeywordsChange = (value: string) => { setKeywords(value) handleSearch() } - const filteredLabelList = labelList.filter(label => label.name.includes(searchKeywords)) - const selectedLabels = value.map(v => labelList.find(l => l.name === v)?.label).join(', ') + const filteredLabelList = labelList.filter((label) => label.name.includes(searchKeywords)) + const selectedLabels = value.map((v) => labelList.find((l) => l.name === v)?.label).join(', ') return ( <Popover open={open} onOpenChange={setOpen}> @@ -50,8 +46,13 @@ function LabelSelector({ 'data-popup-open:bg-components-input-bg-hover data-popup-open:hover:bg-components-input-bg-hover', )} > - <div className={cn('grow truncate text-[13px] leading-4.5 text-text-secondary', !value.length && 'text-text-quaternary!')}> - {!value.length && t($ => $['createTool.toolInput.labelPlaceholder'], { ns: 'tools' })} + <div + className={cn( + 'grow truncate text-[13px] leading-4.5 text-text-secondary', + !value.length && 'text-text-quaternary!', + )} + > + {!value.length && t(($) => $['createTool.toolInput.labelPlaceholder'], { ns: 'tools' })} {!!value.length && selectedLabels} </div> <div className="ml-1 shrink-0 text-text-secondary opacity-60"> @@ -69,32 +70,31 @@ function LabelSelector({ showLeftIcon showClearIcon value={keywords} - onChange={e => handleKeywordsChange(e.target.value)} + onChange={(e) => handleKeywordsChange(e.target.value)} onClear={() => handleKeywordsChange('')} /> </div> <CheckboxGroup - aria-label={t($ => $['createTool.toolInput.labelPlaceholder'], { ns: 'tools' })} + aria-label={t(($) => $['createTool.toolInput.labelPlaceholder'], { ns: 'tools' })} value={value} - onValueChange={nextValue => onChange(nextValue)} + onValueChange={(nextValue) => onChange(nextValue)} className="max-h-[264px] overflow-y-auto p-1" > - {filteredLabelList.map(label => ( + {filteredLabelList.map((label) => ( <label key={label.name} className="flex cursor-pointer items-center gap-2 rounded-lg py-[6px] pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" > - <Checkbox - className="shrink-0" - value={label.name} - /> + <Checkbox className="shrink-0" value={label.name} /> <div className="grow truncate text-sm/5 text-text-secondary">{label.label}</div> </label> ))} {!filteredLabelList.length && ( <div className="flex flex-col items-center gap-1 p-3"> <Tag03 className="size-6 text-text-quaternary" /> - <div className="text-xs leading-[14px] text-text-tertiary">{t($ => $['tag.noTag'], { ns: 'common' })}</div> + <div className="text-xs leading-[14px] text-text-tertiary"> + {t(($) => $['tag.noTag'], { ns: 'common' })} + </div> </div> )} </CheckboxGroup> diff --git a/web/app/components/tools/marketplace/__tests__/hooks.spec.ts b/web/app/components/tools/marketplace/__tests__/hooks.spec.ts index 14244f763cbad9..45fc64d87b9731 100644 --- a/web/app/components/tools/marketplace/__tests__/hooks.spec.ts +++ b/web/app/components/tools/marketplace/__tests__/hooks.spec.ts @@ -19,7 +19,8 @@ const mockFetchNextPage = vi.fn() const mockUseMarketplaceCollectionsAndPlugins = vi.fn() const mockUseMarketplacePlugins = vi.fn() vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ - useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => mockUseMarketplaceCollectionsAndPlugins(...args), + useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => + mockUseMarketplaceCollectionsAndPlugins(...args), useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args), })) diff --git a/web/app/components/tools/marketplace/__tests__/index.spec.tsx b/web/app/components/tools/marketplace/__tests__/index.spec.tsx index 2781267a06d66c..eed9f38862befb 100644 --- a/web/app/components/tools/marketplace/__tests__/index.spec.tsx +++ b/web/app/components/tools/marketplace/__tests__/index.spec.tsx @@ -8,7 +8,6 @@ import * as React from 'react' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { CollectionType } from '@/app/components/tools/types' import { getMarketplaceUrl } from '@/utils/var' - import Marketplace from '../index' const { mockRouterPush } = vi.hoisted(() => ({ @@ -43,7 +42,8 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ const mockUseMarketplaceCollectionsAndPlugins = vi.fn() const mockUseMarketplacePlugins = vi.fn() vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ - useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => mockUseMarketplaceCollectionsAndPlugins(...args), + useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => + mockUseMarketplaceCollectionsAndPlugins(...args), useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args), })) @@ -159,11 +159,13 @@ describe('Marketplace', () => { // Assert expect(screen.getByTestId('marketplace-list')).toBeInTheDocument() - expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({ - cardContainerClassName: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3', - onCollectionMoreClick: expect.any(Function), - showInstallButton: true, - })) + expect(listRenderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + cardContainerClassName: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3', + onCollectionMoreClick: expect.any(Function), + showInstallButton: true, + }), + ) }) it('should hide install actions when plugin install permission is missing', () => { @@ -183,9 +185,11 @@ describe('Marketplace', () => { />, ) - expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({ - showInstallButton: false, - })) + expect(listRenderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + showInstallButton: false, + }), + ) }) }) @@ -219,7 +223,9 @@ describe('Marketplace', () => { tags: 'tag-a,tag-b', theme: undefined, }) - const marketplaceLink = screen.getByRole('link', { name: /plugin.marketplace.difyMarketplace/i }) + const marketplaceLink = screen.getByRole('link', { + name: /plugin.marketplace.difyMarketplace/i, + }) expect(marketplaceLink).toHaveAttribute('href', 'https://marketplace.test/market') }) @@ -236,8 +242,9 @@ describe('Marketplace', () => { />, ) - const contentFrames = Array.from(container.querySelectorAll('div')) - .filter(el => el.classList.contains('max-w-[1600px]')) + const contentFrames = Array.from(container.querySelectorAll('div')).filter((el) => + el.classList.contains('max-w-[1600px]'), + ) expect(contentFrames).toHaveLength(2) expect(contentFrames[0]).toHaveClass('px-6') expect(contentFrames[1]).toHaveClass('px-6') @@ -264,7 +271,9 @@ describe('Marketplace', () => { sort_order: 'DESC', }) - expect(mockRouterPush).toHaveBeenCalledWith('/marketplace?category=tool&q=featured+tools&sort_by=install_count&sort_order=DESC') + expect(mockRouterPush).toHaveBeenCalledWith( + '/marketplace?category=tool&q=featured+tools&sort_by=install_count&sort_order=DESC', + ) }) }) }) diff --git a/web/app/components/tools/marketplace/hooks.ts b/web/app/components/tools/marketplace/hooks.ts index 6db444f012a1d8..b893a63fbdd79d 100644 --- a/web/app/components/tools/marketplace/hooks.ts +++ b/web/app/components/tools/marketplace/hooks.ts @@ -1,9 +1,4 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, -} from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants' import { useMarketplaceCollectionsAndPlugins, @@ -17,7 +12,9 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin const { data: toolProvidersData, isSuccess } = useAllToolProviders() const exclude = useMemo(() => { if (isSuccess) - return toolProvidersData?.filter(toolProvider => !!toolProvider.plugin_id).map(toolProvider => toolProvider.plugin_id!) + return toolProvidersData + ?.filter((toolProvider) => !!toolProvider.plugin_id) + .map((toolProvider) => toolProvider.plugin_id!) }, [isSuccess, toolProvidersData]) const { isLoading, @@ -61,8 +58,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin exclude, type: 'plugin', }) - } - else { + } else { if (isSuccess) { queryMarketplaceCollectionsAndPlugins({ category: PluginCategoryEnum.tool, @@ -73,22 +69,29 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin resetPlugins() } } - }, [searchPluginText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess]) + }, [ + searchPluginText, + filterPluginTags, + queryPlugins, + queryMarketplaceCollectionsAndPlugins, + queryPluginsWithDebounced, + resetPlugins, + exclude, + isSuccess, + ]) - const handleScroll = useCallback((e: Event) => { - const target = e.target as HTMLDivElement - const { - scrollTop, - scrollHeight, - clientHeight, - } = target - if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) { - const searchPluginText = searchPluginTextRef.current - const filterPluginTags = filterPluginTagsRef.current - if (hasNextPage && (!!searchPluginText || !!filterPluginTags.length)) - fetchNextPage() - } - }, [exclude, fetchNextPage, hasNextPage, plugins, queryPlugins]) + const handleScroll = useCallback( + (e: Event) => { + const target = e.target as HTMLDivElement + const { scrollTop, scrollHeight, clientHeight } = target + if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) { + const searchPluginText = searchPluginTextRef.current + const filterPluginTags = filterPluginTagsRef.current + if (hasNextPage && (!!searchPluginText || !!filterPluginTags.length)) fetchNextPage() + } + }, + [exclude, fetchNextPage, hasNextPage, plugins, queryPlugins], + ) return { isLoading: isLoading || isPluginsLoading, diff --git a/web/app/components/tools/marketplace/index.tsx b/web/app/components/tools/marketplace/index.tsx index 88aacd4bd1e21f..586ef208c63d1f 100644 --- a/web/app/components/tools/marketplace/index.tsx +++ b/web/app/components/tools/marketplace/index.tsx @@ -2,10 +2,7 @@ import type { SearchParamsFromCollection } from '@dify/contracts/marketplace' import type { ToolsContentInset } from '../content-inset' import type { useMarketplace } from './hooks' import { cn } from '@langgenius/dify-ui/cn' -import { - RiArrowRightUpLine, - RiArrowUpDoubleLine, -} from '@remixicon/react' +import { RiArrowRightUpLine, RiArrowUpDoubleLine } from '@remixicon/react' import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' @@ -37,25 +34,17 @@ const Marketplace = ({ const { theme } = useTheme() const router = useRouter() const { canInstallPlugin } = usePluginSettingsAccess() - const { - isLoading, - marketplaceCollections, - marketplaceCollectionPluginsMap, - plugins, - page, - } = marketplaceContext + const { isLoading, marketplaceCollections, marketplaceCollectionPluginsMap, plugins, page } = + marketplaceContext const contentPaddingClassName = toolsContentInsetClassNames[contentInset] const marketplaceFrameClassName = cn(contentPaddingClassName, toolsUnifiedContentFrameClassName) const cardContainerClassName = 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3' const handleCollectionMoreClick = (searchParams?: SearchParamsFromCollection) => { const params = new URLSearchParams({ category: 'tool' }) - if (searchParams?.query) - params.set('q', searchParams.query) - if (searchParams?.sort_by) - params.set('sort_by', searchParams.sort_by) - if (searchParams?.sort_order) - params.set('sort_order', searchParams.sort_order) + if (searchParams?.query) params.set('q', searchParams.query) + if (searchParams?.sort_by) params.set('sort_by', searchParams.sort_by) + if (searchParams?.sort_order) params.set('sort_order', searchParams.sort_order) router.push(`/marketplace?${params.toString()}`) } @@ -71,71 +60,72 @@ const Marketplace = ({ )} <div className={cn('pt-4 pb-3', marketplaceFrameClassName)}> <div className="bg-linear-to-r from-[rgba(11,165,236,0.95)] to-[rgba(21,90,239,0.95)] bg-clip-text title-2xl-semi-bold text-transparent"> - {t($ => $['marketplace.moreFrom'], { ns: 'plugin' })} + {t(($) => $['marketplace.moreFrom'], { ns: 'plugin' })} </div> <div className="flex items-center text-center body-md-regular text-text-tertiary"> - {t($ => $['marketplace.discover'], { ns: 'plugin' })} + {t(($) => $['marketplace.discover'], { ns: 'plugin' })} <span className="relative ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.models'], { ns: 'plugin' })} + {t(($) => $['category.models'], { ns: 'plugin' })} </span> , <span className="relative ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.tools'], { ns: 'plugin' })} + {t(($) => $['category.tools'], { ns: 'plugin' })} </span> , <span className="relative ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.datasources'], { ns: 'plugin' })} + {t(($) => $['category.datasources'], { ns: 'plugin' })} </span> , <span className="relative ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.triggers'], { ns: 'plugin' })} + {t(($) => $['category.triggers'], { ns: 'plugin' })} </span> , <span className="relative ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.agents'], { ns: 'plugin' })} + {t(($) => $['category.agents'], { ns: 'plugin' })} </span> , <span className="relative mr-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.extensions'], { ns: 'plugin' })} + {t(($) => $['category.extensions'], { ns: 'plugin' })} </span> - {t($ => $['marketplace.and'], { ns: 'plugin' })} + {t(($) => $['marketplace.and'], { ns: 'plugin' })} <span className="relative mr-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> - {t($ => $['category.bundles'], { ns: 'plugin' })} + {t(($) => $['category.bundles'], { ns: 'plugin' })} </span> - {t($ => $['operation.in'], { ns: 'common' })} + {t(($) => $['operation.in'], { ns: 'common' })} <a - href={getMarketplaceUrl('', { language: locale, q: searchPluginText, tags: filterPluginTags.join(','), theme })} + href={getMarketplaceUrl('', { + language: locale, + q: searchPluginText, + tags: filterPluginTags.join(','), + theme, + })} className="ml-1 flex items-center system-sm-medium text-text-accent" target="_blank" > - {t($ => $['marketplace.difyMarketplace'], { ns: 'plugin' })} + {t(($) => $['marketplace.difyMarketplace'], { ns: 'plugin' })} <RiArrowRightUpLine className="size-4" /> </a> </div> </div> </div> <div className="mt-[-14px] shrink-0 grow bg-background-default-subtle pb-2"> - { - isLoading && page === 1 && ( - <div className="absolute top-1/2 left-1/2 -translate-1/2"> - <Loading /> - </div> - ) - } - { - (!isLoading || page > 1) && ( - <div className={marketplaceFrameClassName}> - <List - marketplaceCollections={marketplaceCollections || []} - marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} - plugins={plugins} - showInstallButton={canInstallPlugin} - cardContainerClassName={cardContainerClassName} - onCollectionMoreClick={handleCollectionMoreClick} - /> - </div> - ) - } + {isLoading && page === 1 && ( + <div className="absolute top-1/2 left-1/2 -translate-1/2"> + <Loading /> + </div> + )} + {(!isLoading || page > 1) && ( + <div className={marketplaceFrameClassName}> + <List + marketplaceCollections={marketplaceCollections || []} + marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} + plugins={plugins} + showInstallButton={canInstallPlugin} + cardContainerClassName={cardContainerClassName} + onCollectionMoreClick={handleCollectionMoreClick} + /> + </div> + )} </div> </> ) diff --git a/web/app/components/tools/mcp/__tests__/create-card.spec.tsx b/web/app/components/tools/mcp/__tests__/create-card.spec.tsx index c24b5382a0154f..277b966b621495 100644 --- a/web/app/components/tools/mcp/__tests__/create-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/create-card.spec.tsx @@ -18,18 +18,20 @@ vi.mock('@/service/use-tools', () => ({ // Mock the MCP Modal type MockMCPModalProps = { show: boolean - onConfirm: (info: { name: string, server_url: string }) => void + onConfirm: (info: { name: string; server_url: string }) => void onHide: () => void } vi.mock('../modal', () => ({ default: ({ show, onConfirm, onHide }: MockMCPModalProps) => { - if (!show) - return null + if (!show) return null return ( <div data-testid="mcp-modal"> <span>tools.mcp.modal.title</span> - <button data-testid="confirm-btn" onClick={() => onConfirm({ name: 'Test MCP', server_url: 'https://test.com' })}> + <button + data-testid="confirm-btn" + onClick={() => onConfirm({ name: 'Test MCP', server_url: 'https://test.com' })} + > Confirm </button> <button data-testid="close-btn" onClick={onHide}> @@ -133,7 +135,9 @@ describe('NewMCPCard', () => { it('should render toolbar button', () => { render(<NewMCPButton {...defaultProps} />, { wrapper: createWrapper() }) - expect(screen.getByRole('button', { name: /tools\.mcp\.create\.cardTitle/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /tools\.mcp\.create\.cardTitle/i }), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/tools/mcp/__tests__/headers-input.spec.tsx b/web/app/components/tools/mcp/__tests__/headers-input.spec.tsx index 881beb00f146f3..e2f03515688d06 100644 --- a/web/app/components/tools/mcp/__tests__/headers-input.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/headers-input.spec.tsx @@ -99,9 +99,7 @@ describe('HeadersInput', () => { }) describe('Item Interactions', () => { - const headersItems = [ - { id: '1', key: 'Header1', value: 'Value1' }, - ] + const headersItems = [{ id: '1', key: 'Header1', value: 'Value1' }] it('should call onChange when key is changed', () => { const onChange = vi.fn() @@ -110,9 +108,7 @@ describe('HeadersInput', () => { const keyInput = screen.getByDisplayValue('Header1') fireEvent.change(keyInput, { target: { value: 'NewHeader' } }) - expect(onChange).toHaveBeenCalledWith([ - { id: '1', key: 'NewHeader', value: 'Value1' }, - ]) + expect(onChange).toHaveBeenCalledWith([{ id: '1', key: 'NewHeader', value: 'Value1' }]) }) it('should call onChange when value is changed', () => { @@ -122,16 +118,16 @@ describe('HeadersInput', () => { const valueInput = screen.getByDisplayValue('Value1') fireEvent.change(valueInput, { target: { value: 'NewValue' } }) - expect(onChange).toHaveBeenCalledWith([ - { id: '1', key: 'Header1', value: 'NewValue' }, - ]) + expect(onChange).toHaveBeenCalledWith([{ id: '1', key: 'Header1', value: 'NewValue' }]) }) it('should remove item when delete button is clicked', () => { const onChange = vi.fn() render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />) - const deleteButton = document.querySelector('[class*="text-text-destructive"]')?.closest('button') + const deleteButton = document + .querySelector('[class*="text-text-destructive"]') + ?.closest('button') if (deleteButton) { fireEvent.click(deleteButton) expect(onChange).toHaveBeenCalledWith([]) diff --git a/web/app/components/tools/mcp/__tests__/index.spec.tsx b/web/app/components/tools/mcp/__tests__/index.spec.tsx index a6b4cc61ced411..572575fcb6f56c 100644 --- a/web/app/components/tools/mcp/__tests__/index.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/index.spec.tsx @@ -60,7 +60,9 @@ vi.mock('@/app/components/tools/provider/tool-card-skeleton', () => ({ default: ({ variant }: { variant?: string }) => ( <> {Array.from({ length: 6 }, (_, index) => ( - <div key={index} data-testid="mcp-card-skeleton" data-variant={variant}>Loading MCP</div> + <div key={index} data-testid="mcp-card-skeleton" data-variant={variant}> + Loading MCP + </div> ))} </> ), @@ -68,38 +70,82 @@ vi.mock('@/app/components/tools/provider/tool-card-skeleton', () => ({ // Mock child components vi.mock('../create-card', () => ({ - default: ({ handleCreate }: { handleCreate: (provider: { id: string, name: string }) => void }) => ( - <button data-testid="create-card" type="button" onClick={() => handleCreate({ id: 'new-id', name: 'New Provider' })}> + default: ({ + handleCreate, + }: { + handleCreate: (provider: { id: string; name: string }) => void + }) => ( + <button + data-testid="create-card" + type="button" + onClick={() => handleCreate({ id: 'new-id', name: 'New Provider' })} + > Create Card </button> ), })) vi.mock('../provider-card', () => ({ - default: ({ data, handleSelect, onUpdate, onDeleted }: { data: MockProvider, handleSelect: (id: string) => void, onUpdate: (id: string) => void, onDeleted: () => void }) => { + default: ({ + data, + handleSelect, + onUpdate, + onDeleted, + }: { + data: MockProvider + handleSelect: (id: string) => void + onUpdate: (id: string) => void + onDeleted: () => void + }) => { const displayName = typeof data.name === 'string' ? data.name : Object.values(data.name)[0] return ( <div data-testid={`provider-card-${data.id}`}> - <button type="button" onClick={() => handleSelect(data.id)}>{displayName}</button> - <button data-testid={`update-btn-${data.id}`} onClick={() => onUpdate(data.id)}>Update</button> - <button data-testid={`delete-btn-${data.id}`} onClick={onDeleted}>Delete</button> + <button type="button" onClick={() => handleSelect(data.id)}> + {displayName} + </button> + <button data-testid={`update-btn-${data.id}`} onClick={() => onUpdate(data.id)}> + Update + </button> + <button data-testid={`delete-btn-${data.id}`} onClick={onDeleted}> + Delete + </button> </div> ) }, })) vi.mock('../detail/provider-detail', () => ({ - default: ({ detail, onHide, onUpdate, isTriggerAuthorize, onFirstCreate }: { detail: MockDetail, onHide: () => void, onUpdate: () => void, isTriggerAuthorize: boolean, onFirstCreate: () => void }) => { + default: ({ + detail, + onHide, + onUpdate, + isTriggerAuthorize, + onFirstCreate, + }: { + detail: MockDetail + onHide: () => void + onUpdate: () => void + isTriggerAuthorize: boolean + onFirstCreate: () => void + }) => { const displayName = detail?.name - ? (typeof detail.name === 'string' ? detail.name : Object.values(detail.name)[0]) + ? typeof detail.name === 'string' + ? detail.name + : Object.values(detail.name)[0] : '' return ( <div data-testid="detail-panel"> <div data-testid="detail-name">{displayName}</div> <div data-testid="trigger-authorize">{isTriggerAuthorize ? 'true' : 'false'}</div> - <button data-testid="close-detail" onClick={onHide}>Close</button> - <button data-testid="update-detail" onClick={onUpdate}>Update List</button> - <button data-testid="first-create-done" onClick={onFirstCreate}>First Create Done</button> + <button data-testid="close-detail" onClick={onHide}> + Close + </button> + <button data-testid="update-detail" onClick={onUpdate}> + Update List + </button> + <button data-testid="first-create-done" onClick={onFirstCreate}> + First Create Done + </button> </div> ) }, @@ -134,9 +180,7 @@ describe('MCPList', () => { it('should render providers read-only when user lacks mcp.manage', () => { mockAppContextState.workspacePermissionKeys = [] - mockProviders = [ - { id: '1', name: 'Provider 1', type: 'mcp' }, - ] + mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }] render(<MCPList searchText="" />) @@ -146,9 +190,7 @@ describe('MCPList', () => { }) it('should hide create card when parent moves creation into the toolbar', () => { - mockProviders = [ - { id: '1', name: 'Provider 1', type: 'mcp' }, - ] + mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }] render(<MCPList searchText="" showCreateCard={false} />) @@ -173,9 +215,7 @@ describe('MCPList', () => { }) it('should not render skeleton cards when providers exist', () => { - mockProviders = [ - { id: '1', name: 'Provider 1', type: 'mcp' }, - ] + mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }] render(<MCPList searchText="" />) expect(screen.queryByTestId('mcp-card-skeleton')).not.toBeInTheDocument() @@ -359,9 +399,7 @@ describe('MCPList', () => { describe('Update Provider', () => { beforeEach(() => { - mockProviders = [ - { id: '1', name: 'Provider 1', type: 'mcp' }, - ] + mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }] }) it('should call refetch and set provider after update', async () => { @@ -396,9 +434,7 @@ describe('MCPList', () => { describe('Delete Provider', () => { beforeEach(() => { - mockProviders = [ - { id: '1', name: 'Provider 1', type: 'mcp' }, - ] + mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }] }) it('should call refetch after delete', async () => { diff --git a/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx index a3bf645ba772f3..18ed3d1267ab2a 100644 --- a/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx @@ -124,18 +124,18 @@ describe('MCPServerModal', () => { }) it('should render parameters section when latestParams is provided', () => { - const latestParams = [ - { variable: 'param1', label: 'Parameter 1', type: 'string' }, - ] - render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() }) + const latestParams = [{ variable: 'param1', label: 'Parameter 1', type: 'string' }] + render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('tools.mcp.server.modal.parameters'))!.toBeInTheDocument() }) it('should render parameters tip', () => { - const latestParams = [ - { variable: 'param1', label: 'Parameter 1', type: 'string' }, - ] - render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() }) + const latestParams = [{ variable: 'param1', label: 'Parameter 1', type: 'string' }] + render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('tools.mcp.server.modal.parametersTip'))!.toBeInTheDocument() }) @@ -144,7 +144,9 @@ describe('MCPServerModal', () => { { variable: 'param1', label: 'Parameter 1', type: 'string' }, { variable: 'param2', label: 'Parameter 2', type: 'number' }, ] - render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() }) + render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('Parameter 1'))!.toBeInTheDocument() expect(screen.getByText('Parameter 2'))!.toBeInTheDocument() }) @@ -220,13 +222,10 @@ describe('MCPServerModal', () => { }) it('should populate parameters with existing values', () => { - const latestParams = [ - { variable: 'param1', label: 'Parameter 1', type: 'string' }, - ] - render( - <MCPServerModal {...defaultProps} data={mockData} latestParams={latestParams} />, - { wrapper: createWrapper() }, - ) + const latestParams = [{ variable: 'param1', label: 'Parameter 1', type: 'string' }] + render(<MCPServerModal {...defaultProps} data={mockData} latestParams={latestParams} />, { + wrapper: createWrapper(), + }) const paramInput = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') expect(paramInput)!.toHaveValue('existing value') @@ -267,10 +266,9 @@ describe('MCPServerModal', () => { parameters: {}, } as unknown as MCPServerDetail - render( - <MCPServerModal {...defaultProps} data={mockData} appInfo={appInfo} />, - { wrapper: createWrapper() }, - ) + render(<MCPServerModal {...defaultProps} data={mockData} appInfo={appInfo} />, { + wrapper: createWrapper(), + }) const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') expect(textarea)!.toHaveValue('Data description') @@ -293,10 +291,9 @@ describe('MCPServerModal', () => { parameters: { param1: 'value1' }, } as unknown as MCPServerDetail - render( - <MCPServerModal {...defaultProps} data={mockData} onHide={onHide} />, - { wrapper: createWrapper() }, - ) + render(<MCPServerModal {...defaultProps} data={mockData} onHide={onHide} />, { + wrapper: createWrapper(), + }) // Change description const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') @@ -319,17 +316,18 @@ describe('MCPServerModal', () => { { variable: 'param2', label: 'Parameter 2', type: 'string' }, ] - render( - <MCPServerModal {...defaultProps} latestParams={latestParams} />, - { wrapper: createWrapper() }, - ) + render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { + wrapper: createWrapper(), + }) // Fill description first const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') fireEvent.change(textarea, { target: { value: 'Test description' } }) // Get all parameter inputs - const paramInputs = screen.getAllByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + const paramInputs = screen.getAllByPlaceholderText( + 'tools.mcp.server.modal.parametersPlaceholder', + ) // Change the first parameter value fireEvent.change(paramInputs[0]!, { target: { value: 'new param value' } }) @@ -339,14 +337,11 @@ describe('MCPServerModal', () => { it('should submit with parameter values', async () => { const onHide = vi.fn() - const latestParams = [ - { variable: 'param1', label: 'Parameter 1', type: 'string' }, - ] + const latestParams = [{ variable: 'param1', label: 'Parameter 1', type: 'string' }] - render( - <MCPServerModal {...defaultProps} latestParams={latestParams} onHide={onHide} />, - { wrapper: createWrapper() }, - ) + render(<MCPServerModal {...defaultProps} latestParams={latestParams} onHide={onHide} />, { + wrapper: createWrapper(), + }) // Fill description const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') @@ -367,20 +362,20 @@ describe('MCPServerModal', () => { it('should ignore parameters without variables when rendering and submitting', async () => { const onHide = vi.fn() - const latestParams = [ - { label: 'Missing variable', type: 'string' }, - ] + const latestParams = [{ label: 'Missing variable', type: 'string' }] - render( - <MCPServerModal {...defaultProps} latestParams={latestParams} onHide={onHide} />, - { wrapper: createWrapper() }, - ) + render(<MCPServerModal {...defaultProps} latestParams={latestParams} onHide={onHide} />, { + wrapper: createWrapper(), + }) expect(screen.queryByText('Missing variable')).not.toBeInTheDocument() - fireEvent.change(screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder'), { - target: { value: 'Test description' }, - }) + fireEvent.change( + screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder'), + { + target: { value: 'Test description' }, + }, + ) fireEvent.click(screen.getByText('tools.mcp.server.modal.confirm')) await waitFor(() => { @@ -394,16 +389,22 @@ describe('MCPServerModal', () => { render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() }) - fireEvent.change(screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder'), { - target: { value: 'Test description' }, - }) + fireEvent.change( + screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder'), + { + target: { value: 'Test description' }, + }, + ) fireEvent.click(screen.getByText('tools.mcp.server.modal.confirm')) await waitFor(() => { - expect(mockSocketEmit).toHaveBeenCalledWith('collaboration_event', expect.objectContaining({ - type: 'mcp_server_update', - data: expect.objectContaining({ action: 'created' }), - })) + expect(mockSocketEmit).toHaveBeenCalledWith( + 'collaboration_event', + expect.objectContaining({ + type: 'mcp_server_update', + data: expect.objectContaining({ action: 'created' }), + }), + ) }) }) diff --git a/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx index c9814e8c8d66be..b22d2572a7e919 100644 --- a/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx @@ -9,12 +9,13 @@ import { AppModeEnum } from '@/types/app' import MCPServiceCard from '../mcp-service-card' vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({ - default: ({ show, onHide }: { show: boolean, onHide: () => void }) => { - if (!show) - return null + default: ({ show, onHide }: { show: boolean; onHide: () => void }) => { + if (!show) return null return ( <div data-testid="mcp-server-modal"> - <button data-testid="close-modal-btn" onClick={onHide}>Close</button> + <button data-testid="close-modal-btn" onClick={onHide}> + Close + </button> </div> ) }, @@ -50,13 +51,15 @@ type MockHookState = { serverPublished: boolean serverActivated: boolean serverURL: string - detail: { - id: string - status: string - server_code: string - description: string - parameters: Record<string, unknown> - } | undefined + detail: + | { + id: string + status: string + server_code: string + description: string + parameters: Record<string, unknown> + } + | undefined canManageMCP: boolean toggleDisabled: boolean isMinimalState: boolean @@ -114,12 +117,15 @@ describe('MCPServiceCard', () => { React.createElement(QueryClientProvider, { client: queryClient }, children) } - const createMockAppInfo = (mode: AppModeEnum = AppModeEnum.CHAT): AppDetailResponse & Partial<AppSSO> => ({ - id: 'app-123', - name: 'Test App', - mode, - api_base_url: 'https://api.example.com/v1', - } as AppDetailResponse & Partial<AppSSO>) + const createMockAppInfo = ( + mode: AppModeEnum = AppModeEnum.CHAT, + ): AppDetailResponse & Partial<AppSSO> => + ({ + id: 'app-123', + name: 'Test App', + mode, + api_base_url: 'https://api.example.com/v1', + }) as AppDetailResponse & Partial<AppSSO> beforeEach(() => { mockHookState = createDefaultHookState() @@ -161,7 +167,9 @@ describe('MCPServiceCard', () => { const editButton = screen.getByRole('button', { name: /tools\.mcp\.server\.edit/i }) expect(editButton).toBeDisabled() - const regenerateButton = screen.getByRole('button', { name: /appOverview\.overview\.appInfo\.regenerate/i }) + const regenerateButton = screen.getByRole('button', { + name: /appOverview\.overview\.appInfo\.regenerate/i, + }) expect(regenerateButton).toBeDisabled() }) @@ -175,7 +183,9 @@ describe('MCPServiceCard', () => { it('should return null when isLoading is true', () => { mockHookState = createDefaultHookState({ isLoading: true }) - const { container } = render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) + const { container } = render(<MCPServiceCard appInfo={createMockAppInfo()} />, { + wrapper: createWrapper(), + }) expect(container.firstChild).toBeNull() }) @@ -274,7 +284,9 @@ describe('MCPServiceCard', () => { render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /tools\.mcp\.server\.edit/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /tools\.mcp\.server\.edit/i }), + ).not.toBeInTheDocument() }) it('should open modal when enabling unpublished server', async () => { @@ -408,7 +420,9 @@ describe('MCPServiceCard', () => { toggleDisabled: true, }) - render(<MCPServiceCard appInfo={createMockAppInfo(AppModeEnum.WORKFLOW)} />, { wrapper: createWrapper() }) + render(<MCPServiceCard appInfo={createMockAppInfo(AppModeEnum.WORKFLOW)} />, { + wrapper: createWrapper(), + }) const switchElement = screen.getByRole('switch') expect(switchElement.className).toContain('cursor-not-allowed') diff --git a/web/app/components/tools/mcp/__tests__/modal.spec.tsx b/web/app/components/tools/mcp/__tests__/modal.spec.tsx index bc0a7c61955839..f11b83819e610e 100644 --- a/web/app/components/tools/mcp/__tests__/modal.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/modal.spec.tsx @@ -135,7 +135,9 @@ describe('MCPModal', () => { it('should update server identifier input value', () => { render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() }) - const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder') + const identifierInput = screen.getByPlaceholderText( + 'tools.mcp.modal.serverIdentifierPlaceholder', + ) fireEvent.change(identifierInput, { target: { value: 'my-server' } }) expect(identifierInput)!.toHaveValue('my-server') @@ -225,7 +227,9 @@ describe('MCPModal', () => { // Fill required fields const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder') const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder') - const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder') + const identifierInput = screen.getByPlaceholderText( + 'tools.mcp.modal.serverIdentifierPlaceholder', + ) fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } }) fireEvent.change(nameInput, { target: { value: 'Test Server' } }) @@ -244,7 +248,9 @@ describe('MCPModal', () => { // Fill required fields const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder') const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder') - const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder') + const identifierInput = screen.getByPlaceholderText( + 'tools.mcp.modal.serverIdentifierPlaceholder', + ) fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } }) fireEvent.change(nameInput, { target: { value: 'Test Server' } }) @@ -271,7 +277,9 @@ describe('MCPModal', () => { // Fill fields with invalid URL const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder') const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder') - const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder') + const identifierInput = screen.getByPlaceholderText( + 'tools.mcp.modal.serverIdentifierPlaceholder', + ) fireEvent.change(urlInput, { target: { value: 'not-a-valid-url' } }) fireEvent.change(nameInput, { target: { value: 'Test Server' } }) @@ -281,7 +289,7 @@ describe('MCPModal', () => { fireEvent.click(confirmButton) // Wait a bit and verify onConfirm was not called - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) expect(onConfirm).not.toHaveBeenCalled() expect(mockToastError).toHaveBeenCalledWith('tools.mcp.modal.invalidServerUrl') }) @@ -293,7 +301,9 @@ describe('MCPModal', () => { // Fill fields with invalid server identifier const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder') const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder') - const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder') + const identifierInput = screen.getByPlaceholderText( + 'tools.mcp.modal.serverIdentifierPlaceholder', + ) fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } }) fireEvent.change(nameInput, { target: { value: 'Test Server' } }) @@ -303,7 +313,7 @@ describe('MCPModal', () => { fireEvent.click(confirmButton) // Wait a bit and verify onConfirm was not called - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) expect(onConfirm).not.toHaveBeenCalled() expect(mockToastError).toHaveBeenCalledWith('tools.mcp.modal.invalidServerIdentifier') }) @@ -438,7 +448,9 @@ describe('MCPModal', () => { // Fill required fields const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder') const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder') - const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder') + const identifierInput = screen.getByPlaceholderText( + 'tools.mcp.modal.serverIdentifierPlaceholder', + ) fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } }) fireEvent.change(nameInput, { target: { value: 'Test Server' } }) @@ -473,7 +485,9 @@ describe('MCPModal', () => { // Fill required fields const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder') const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder') - const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder') + const identifierInput = screen.getByPlaceholderText( + 'tools.mcp.modal.serverIdentifierPlaceholder', + ) fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } }) fireEvent.change(nameInput, { target: { value: 'Test Server' } }) @@ -504,12 +518,14 @@ describe('MCPModal', () => { server_identifier: 'test-server', icon: { content: '🔗', background: '#6366F1' }, masked_headers: { - 'Authorization': 'Bearer token', + Authorization: 'Bearer token', 'X-Custom': 'value', }, } as unknown as ToolWithProvider - render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() }) + render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { + wrapper: createWrapper(), + }) // Switch to headers tab const headersTab = screen.getByText('tools.mcp.modal.headers') @@ -546,7 +562,9 @@ describe('MCPModal', () => { icon: { content: '🚀', background: '#FF0000' }, } as unknown as ToolWithProvider - render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() }) + render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { + wrapper: createWrapper(), + }) // Don't change the URL, just submit const saveButton = screen.getByText('tools.mcp.modal.save') @@ -571,7 +589,9 @@ describe('MCPModal', () => { icon: { content: '🚀', background: '#FF0000' }, } as unknown as ToolWithProvider - render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() }) + render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { + wrapper: createWrapper(), + }) // Change the URL const urlInput = screen.getByDisplayValue('https://existing.com/mcp') @@ -598,7 +618,9 @@ describe('MCPModal', () => { // Fill required fields const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder') const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder') - const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder') + const identifierInput = screen.getByPlaceholderText( + 'tools.mcp.modal.serverIdentifierPlaceholder', + ) fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } }) fireEvent.change(nameInput, { target: { value: 'Test Server' } }) @@ -626,7 +648,9 @@ describe('MCPModal', () => { // Fill required fields const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder') const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder') - const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder') + const identifierInput = screen.getByPlaceholderText( + 'tools.mcp.modal.serverIdentifierPlaceholder', + ) fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } }) fireEvent.change(nameInput, { target: { value: 'Test Server' } }) @@ -671,7 +695,9 @@ describe('MCPModal', () => { render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() }) // Find the app icon container with cursor-pointer and rounded-2xl classes - const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]') + const appIconContainer = document.querySelector( + '[class*="rounded-2xl"][class*="cursor-pointer"]', + ) if (appIconContainer) { fireEvent.click(appIconContainer) @@ -686,7 +712,9 @@ describe('MCPModal', () => { render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() }) // Open the icon picker - const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]') + const appIconContainer = document.querySelector( + '[class*="rounded-2xl"][class*="cursor-pointer"]', + ) if (appIconContainer) { fireEvent.click(appIconContainer) @@ -708,7 +736,9 @@ describe('MCPModal', () => { render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() }) // Open the icon picker - const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]') + const appIconContainer = document.querySelector( + '[class*="rounded-2xl"][class*="cursor-pointer"]', + ) if (appIconContainer) { fireEvent.click(appIconContainer) @@ -741,18 +771,15 @@ describe('MCPModal', () => { } const fillRequiredFields = () => { - fireEvent.change( - screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder'), - { target: { value: 'https://example.com/mcp' } }, - ) - fireEvent.change( - screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder'), - { target: { value: 'srv' } }, - ) - fireEvent.change( - screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder'), - { target: { value: 'srv-id' } }, - ) + fireEvent.change(screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder'), { + target: { value: 'https://example.com/mcp' }, + }) + fireEvent.change(screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder'), { + target: { value: 'srv' }, + }) + fireEvent.change(screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder'), { + target: { value: 'srv-id' }, + }) } it('does not render the toggle when SSO is not configured', () => { @@ -785,10 +812,7 @@ describe('MCPModal', () => { it('submits identity_mode="off" by default (toggle off)', async () => { enableRefreshCapableSSO() const onConfirm = vi.fn() - render( - <MCPModal {...defaultProps} onConfirm={onConfirm} />, - { wrapper: createWrapper() }, - ) + render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() }) fillRequiredFields() fireEvent.click(screen.getByText('tools.mcp.modal.confirm')) @@ -805,10 +829,7 @@ describe('MCPModal', () => { it('submits identity_mode="idp_token" when toggle is flipped on', async () => { enableRefreshCapableSSO() const onConfirm = vi.fn() - render( - <MCPModal {...defaultProps} onConfirm={onConfirm} />, - { wrapper: createWrapper() }, - ) + render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() }) fillRequiredFields() const fwdSwitch = screen.getByRole('switch', { @@ -838,10 +859,9 @@ describe('MCPModal', () => { identity_mode: 'idp_token', } as unknown as ToolWithProvider - render( - <MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, - { wrapper: createWrapper() }, - ) + render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { + wrapper: createWrapper(), + }) fireEvent.click(screen.getByText('tools.mcp.modal.save')) await waitFor(() => { @@ -866,10 +886,7 @@ describe('MCPModal', () => { identity_mode: 'idp_token', } as unknown as ToolWithProvider - render( - <MCPModal {...defaultProps} data={mockData} />, - { wrapper: createWrapper() }, - ) + render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() }) expect( screen.getByRole('switch', { name: 'tools.mcp.modal.forwardUserIdentity' }), @@ -887,10 +904,7 @@ describe('MCPModal', () => { identity_mode: 'off', } as unknown as ToolWithProvider - render( - <MCPModal {...defaultProps} data={mockData} />, - { wrapper: createWrapper() }, - ) + render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() }) expect( screen.getByRole('switch', { name: 'tools.mcp.modal.forwardUserIdentity' }), diff --git a/web/app/components/tools/mcp/__tests__/provider-card.spec.tsx b/web/app/components/tools/mcp/__tests__/provider-card.spec.tsx index 34ac824637ad03..3d03df971551de 100644 --- a/web/app/components/tools/mcp/__tests__/provider-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/provider-card.spec.tsx @@ -34,11 +34,13 @@ type MCPModalProps = { vi.mock('../modal', () => ({ default: ({ show, onConfirm, onHide }: MCPModalProps) => { - if (!show) - return null + if (!show) return null return ( <div data-testid="mcp-modal"> - <button data-testid="modal-confirm-btn" onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })}> + <button + data-testid="modal-confirm-btn" + onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })} + > Confirm </button> <button data-testid="modal-close-btn" onClick={onHide}> @@ -148,19 +150,20 @@ describe('MCPCard', () => { React.createElement(QueryClientProvider, { client: queryClient }, children) } - const createMockData = (overrides = {}): ToolWithProvider => ({ - id: 'mcp-1', - name: 'Test MCP Server', - server_identifier: 'test-server', - icon: { content: '🔧', background: '#FF0000' }, - tools: [ - { name: 'tool1', description: 'Tool 1' }, - { name: 'tool2', description: 'Tool 2' }, - ], - is_team_authorization: true, - updated_at: Date.now() / 1000, - ...overrides, - } as unknown as ToolWithProvider) + const createMockData = (overrides = {}): ToolWithProvider => + ({ + id: 'mcp-1', + name: 'Test MCP Server', + server_identifier: 'test-server', + icon: { content: '🔧', background: '#FF0000' }, + tools: [ + { name: 'tool1', description: 'Tool 1' }, + { name: 'tool2', description: 'Tool 2' }, + ], + is_team_authorization: true, + updated_at: Date.now() / 1000, + ...overrides, + }) as unknown as ToolWithProvider const defaultProps = { data: createMockData(), @@ -169,8 +172,10 @@ describe('MCPCard', () => { onDeleted: vi.fn(), } - const getDeleteConfirmButton = () => screen.getByRole('button', { name: 'common.operation.confirm' }) - const getDeleteCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' }) + const getDeleteConfirmButton = () => + screen.getByRole('button', { name: 'common.operation.confirm' }) + const getDeleteCancelButton = () => + screen.getByRole('button', { name: 'common.operation.cancel' }) beforeEach(() => { mockUpdateMCP.mockClear() @@ -234,49 +239,42 @@ describe('MCPCard', () => { describe('No Tools State', () => { it('should show no tools message when tools array is empty', () => { const dataWithNoTools = createMockData({ tools: [] }) - render( - <MCPCard {...defaultProps} data={dataWithNoTools} />, - { wrapper: createWrapper() }, - ) + render(<MCPCard {...defaultProps} data={dataWithNoTools} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.noTools')).toBeInTheDocument() }) it('should show not configured badge when not authorized', () => { const dataNotAuthorized = createMockData({ is_team_authorization: false }) - render( - <MCPCard {...defaultProps} data={dataNotAuthorized} />, - { wrapper: createWrapper() }, - ) + render(<MCPCard {...defaultProps} data={dataNotAuthorized} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument() }) it('should show not configured badge when no tools', () => { const dataWithNoTools = createMockData({ tools: [], is_team_authorization: true }) - render( - <MCPCard {...defaultProps} data={dataWithNoTools} />, - { wrapper: createWrapper() }, - ) + render(<MCPCard {...defaultProps} data={dataWithNoTools} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument() }) }) describe('Selected State', () => { it('should apply selected styles when current provider matches', () => { - render( - <MCPCard {...defaultProps} currentProvider={defaultProps.data} />, - { wrapper: createWrapper() }, + render(<MCPCard {...defaultProps} currentProvider={defaultProps.data} />, { + wrapper: createWrapper(), + }) + const card = document.querySelector( + '[class*="border-components-option-card-option-selected-border"]', ) - const card = document.querySelector('[class*="border-components-option-card-option-selected-border"]') expect(card).toBeInTheDocument() }) it('should not apply selected styles when different provider', () => { const differentProvider = createMockData({ id: 'different-id' }) - render( - <MCPCard {...defaultProps} currentProvider={differentProvider} />, - { wrapper: createWrapper() }, + render(<MCPCard {...defaultProps} currentProvider={differentProvider} />, { + wrapper: createWrapper(), + }) + const card = document.querySelector( + '[class*="border-components-option-card-option-selected-border"]', ) - const card = document.querySelector('[class*="border-components-option-card-option-selected-border"]') expect(card).not.toBeInTheDocument() }) }) @@ -284,10 +282,9 @@ describe('MCPCard', () => { describe('User Interactions', () => { it('should call handleSelect when card is clicked', () => { const handleSelect = vi.fn() - render( - <MCPCard {...defaultProps} handleSelect={handleSelect} />, - { wrapper: createWrapper() }, - ) + render(<MCPCard {...defaultProps} handleSelect={handleSelect} />, { + wrapper: createWrapper(), + }) const card = screen.getByText('Test MCP Server').closest('[class*="cursor-pointer"]') if (card) { @@ -309,20 +306,16 @@ describe('MCPCard', () => { describe('Status Indicator', () => { it('should show green indicator when authorized and has tools', () => { const data = createMockData({ is_team_authorization: true, tools: [{ name: 'tool1' }] }) - render( - <MCPCard {...defaultProps} data={data} />, - { wrapper: createWrapper() }, - ) + render(<MCPCard {...defaultProps} data={data} />, { wrapper: createWrapper() }) // Should have green indicator (not showing red badge) expect(screen.queryByText('tools.mcp.noConfigured')).not.toBeInTheDocument() }) it('should show red indicator when not configured', () => { const data = createMockData({ is_team_authorization: false }) - const { container } = render( - <MCPCard {...defaultProps} data={data} />, - { wrapper: createWrapper() }, - ) + const { container } = render(<MCPCard {...defaultProps} data={data} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument() expect(container.querySelector('.size-1\\.5')).toBeInTheDocument() }) @@ -332,27 +325,20 @@ describe('MCPCard', () => { it('should handle long MCP name', () => { const longName = 'A'.repeat(100) const data = createMockData({ name: longName }) - render( - <MCPCard {...defaultProps} data={data} />, - { wrapper: createWrapper() }, - ) + render(<MCPCard {...defaultProps} data={data} />, { wrapper: createWrapper() }) expect(screen.getByText(longName)).toBeInTheDocument() }) it('should handle special characters in name', () => { const data = createMockData({ name: 'Test <Script> & "Quotes"' }) - render( - <MCPCard {...defaultProps} data={data} />, - { wrapper: createWrapper() }, - ) + render(<MCPCard {...defaultProps} data={data} />, { wrapper: createWrapper() }) expect(screen.getByText('Test <Script> & "Quotes"')).toBeInTheDocument() }) it('should handle undefined currentProvider', () => { - render( - <MCPCard {...defaultProps} currentProvider={undefined} />, - { wrapper: createWrapper() }, - ) + render(<MCPCard {...defaultProps} currentProvider={undefined} />, { + wrapper: createWrapper(), + }) expect(screen.getByText('Test MCP Server')).toBeInTheDocument() }) }) @@ -374,7 +360,9 @@ describe('MCPCard', () => { it('should stop propagation when clicking on dropdown container', () => { const handleSelect = vi.fn() - render(<MCPCard {...defaultProps} handleSelect={handleSelect} />, { wrapper: createWrapper() }) + render(<MCPCard {...defaultProps} handleSelect={handleSelect} />, { + wrapper: createWrapper(), + }) // Click on the dropdown area (which should stop propagation) const dropdown = screen.getByTestId('operation-dropdown') diff --git a/web/app/components/tools/mcp/create-card.tsx b/web/app/components/tools/mcp/create-card.tsx index 4047bece26bb18..3c05de13066f12 100644 --- a/web/app/components/tools/mcp/create-card.tsx +++ b/web/app/components/tools/mcp/create-card.tsx @@ -19,8 +19,7 @@ function useMCPCreateAction({ handleCreate }: Props) { const [showModal, setShowModal] = useState(false) const create = async (info: Parameters<typeof createMCP>[0]) => { - if (!canManageMCP) - return + if (!canManageMCP) return const provider = await createMCP(info) await handleCreate(provider) @@ -36,16 +35,10 @@ function useMCPCreateAction({ handleCreate }: Props) { export function NewMCPButton({ handleCreate }: Props) { const { t } = useTranslation() - const addMCPServerLabel = t($ => $['mcp.create.cardTitle'], { ns: 'tools' }) - const { - canManageMCP, - create, - setShowModal, - showModal, - } = useMCPCreateAction({ handleCreate }) + const addMCPServerLabel = t(($) => $['mcp.create.cardTitle'], { ns: 'tools' }) + const { canManageMCP, create, setShowModal, showModal } = useMCPCreateAction({ handleCreate }) - if (!canManageMCP) - return null + if (!canManageMCP) return null return ( <> @@ -60,11 +53,7 @@ export function NewMCPButton({ handleCreate }: Props) { {addMCPServerLabel} </Button> {canManageMCP && showModal && ( - <MCPModal - show={showModal} - onConfirm={create} - onHide={() => setShowModal(false)} - /> + <MCPModal show={showModal} onConfirm={create} onHide={() => setShowModal(false)} /> )} </> ) @@ -73,12 +62,7 @@ export function NewMCPButton({ handleCreate }: Props) { const NewMCPCard = ({ handleCreate }: Props) => { const { t } = useTranslation() const docLink = useDocLink() - const { - canManageMCP, - create, - setShowModal, - showModal, - } = useMCPCreateAction({ handleCreate }) + const { canManageMCP, create, setShowModal, showModal } = useMCPCreateAction({ handleCreate }) const linkUrl = useMemo(() => docLink('/use-dify/workspace/tools#mcp'), [docLink]) @@ -86,18 +70,14 @@ const NewMCPCard = ({ handleCreate }: Props) => { <> {canManageMCP && ( <CreateEntryCard - title={t($ => $['mcp.create.cardTitle'], { ns: 'tools' })} - linkText={t($ => $['mcp.create.cardLink'], { ns: 'tools' })} + title={t(($) => $['mcp.create.cardTitle'], { ns: 'tools' })} + linkText={t(($) => $['mcp.create.cardLink'], { ns: 'tools' })} linkUrl={linkUrl} onCreate={() => setShowModal(true)} /> )} {canManageMCP && showModal && ( - <MCPModal - show={showModal} - onConfirm={create} - onHide={() => setShowModal(false)} - /> + <MCPModal show={showModal} onConfirm={create} onHide={() => setShowModal(false)} /> )} </> ) diff --git a/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx index 793d6b61717241..c74b4c0316dda1 100644 --- a/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx @@ -71,11 +71,13 @@ type MCPModalProps = { vi.mock('../../modal', () => ({ default: ({ show, onConfirm, onHide }: MCPModalProps) => { - if (!show) - return null + if (!show) return null return ( <div data-testid="mcp-update-modal"> - <button data-testid="modal-confirm-btn" onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })}> + <button + data-testid="modal-confirm-btn" + onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })} + > Confirm </button> <button data-testid="modal-close-btn" onClick={onHide}> @@ -88,10 +90,14 @@ vi.mock('../../modal', () => ({ // Mock OperationDropdown vi.mock('../operation-dropdown', () => ({ - default: ({ onEdit, onRemove }: { onEdit: () => void, onRemove: () => void }) => ( + default: ({ onEdit, onRemove }: { onEdit: () => void; onRemove: () => void }) => ( <div data-testid="operation-dropdown"> - <button data-testid="edit-btn" onClick={onEdit}>Edit</button> - <button data-testid="remove-btn" onClick={onRemove}>Remove</button> + <button data-testid="edit-btn" onClick={onEdit}> + Edit + </button> + <button data-testid="remove-btn" onClick={onRemove}> + Remove + </button> </div> ), })) @@ -102,9 +108,7 @@ type ToolItemData = { } vi.mock('../tool-item', () => ({ - default: ({ tool }: { tool: ToolItemData }) => ( - <div data-testid="tool-item">{tool.name}</div> - ), + default: ({ tool }: { tool: ToolItemData }) => <div data-testid="tool-item">{tool.name}</div>, })) const mockAppContextState = vi.hoisted(() => ({ @@ -177,16 +181,17 @@ describe('MCPDetailContent', () => { const getConfirmButton = () => screen.getByRole('button', { name: 'common.operation.confirm' }) const getCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' }) - const createMockDetail = (overrides = {}): ToolWithProvider => ({ - id: 'mcp-1', - name: 'Test MCP Server', - server_identifier: 'test-mcp', - server_url: 'https://example.com/mcp', - icon: { content: '🔧', background: '#FF0000' }, - tools: [], - is_team_authorization: false, - ...overrides, - } as unknown as ToolWithProvider) + const createMockDetail = (overrides = {}): ToolWithProvider => + ({ + id: 'mcp-1', + name: 'Test MCP Server', + server_identifier: 'test-mcp', + server_url: 'https://example.com/mcp', + icon: { content: '🔧', background: '#FF0000' }, + tools: [], + is_team_authorization: false, + ...overrides, + }) as unknown as ToolWithProvider const defaultProps = { detail: createMockDetail(), @@ -266,37 +271,25 @@ describe('MCPDetailContent', () => { describe('Authorization State', () => { it('should show authorize button when not authorized', () => { const detail = createMockDetail({ is_team_authorization: false }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.authorize'))!.toBeInTheDocument() }) it('should show authorized button when authorized', () => { const detail = createMockDetail({ is_team_authorization: true }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.auth.authorized'))!.toBeInTheDocument() }) it('should show authorization required message when not authorized', () => { const detail = createMockDetail({ is_team_authorization: false }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.authorizingRequired'))!.toBeInTheDocument() }) it('should show authorization tip', () => { const detail = createMockDetail({ is_team_authorization: false }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.authorizeTip'))!.toBeInTheDocument() }) }) @@ -304,19 +297,13 @@ describe('MCPDetailContent', () => { describe('Empty Tools State', () => { it('should show empty message when authorized but no tools', () => { const detail = createMockDetail({ is_team_authorization: true, tools: [] }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.toolsEmpty'))!.toBeInTheDocument() }) it('should show get tools button when empty', () => { const detail = createMockDetail({ is_team_authorization: true, tools: [] }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.getTools'))!.toBeInTheDocument() }) }) @@ -333,20 +320,14 @@ describe('MCPDetailContent', () => { describe('Edge Cases', () => { it('should handle empty server URL', () => { const detail = createMockDetail({ server_url: '' }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('Test MCP Server'))!.toBeInTheDocument() }) it('should handle long MCP name', () => { const longName = 'A'.repeat(100) const detail = createMockDetail({ name: longName }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText(longName))!.toBeInTheDocument() }) }) @@ -360,10 +341,7 @@ describe('MCPDetailContent', () => { ], } const detail = createMockDetail({ is_team_authorization: true }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tool1'))!.toBeInTheDocument() expect(screen.getByText('tool2'))!.toBeInTheDocument() }) @@ -373,10 +351,7 @@ describe('MCPDetailContent', () => { tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }], } const detail = createMockDetail({ is_team_authorization: true }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.onlyTool'))!.toBeInTheDocument() }) @@ -388,10 +363,7 @@ describe('MCPDetailContent', () => { ], } const detail = createMockDetail({ is_team_authorization: true }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText(/tools.mcp.toolsNum/))!.toBeInTheDocument() }) }) @@ -403,10 +375,7 @@ describe('MCPDetailContent', () => { tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }], } const detail = createMockDetail({ is_team_authorization: true }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.gettingTools'))!.toBeInTheDocument() }) @@ -416,20 +385,14 @@ describe('MCPDetailContent', () => { tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }], } const detail = createMockDetail({ is_team_authorization: true }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.updateTools'))!.toBeInTheDocument() }) it('should show authorizing button when authorizing', () => { mockIsAuthorizing = true const detail = createMockDetail({ is_team_authorization: false }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) // Multiple elements show authorizing text - use getAllByText const authorizingElements = screen.getAllByText('tools.mcp.authorizing') expect(authorizingElements.length).toBeGreaterThan(0) @@ -440,10 +403,9 @@ describe('MCPDetailContent', () => { it('should call authorizeMcp when authorize button is clicked', async () => { const onFirstCreate = vi.fn() const detail = createMockDetail({ is_team_authorization: false }) - render( - <MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />, { + wrapper: createWrapper(), + }) const authorizeBtn = screen.getByText('tools.mcp.authorize') fireEvent.click(authorizeBtn) @@ -457,10 +419,7 @@ describe('MCPDetailContent', () => { it('should open OAuth popup when authorization_url is returned', async () => { mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' }) const detail = createMockDetail({ is_team_authorization: false }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) const authorizeBtn = screen.getByText('tools.mcp.authorize') fireEvent.click(authorizeBtn) @@ -477,7 +436,12 @@ describe('MCPDetailContent', () => { const onFirstCreate = vi.fn() const detail = createMockDetail({ is_team_authorization: false }) render( - <MCPDetailContent {...defaultProps} detail={detail} isTriggerAuthorize={true} onFirstCreate={onFirstCreate} />, + <MCPDetailContent + {...defaultProps} + detail={detail} + isTriggerAuthorize={true} + onFirstCreate={onFirstCreate} + />, { wrapper: createWrapper() }, ) @@ -490,10 +454,7 @@ describe('MCPDetailContent', () => { it('should disable authorize action when user lacks mcp.manage', () => { mockAppContextState.workspacePermissionKeys = [] const detail = createMockDetail({ is_team_authorization: false }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.authorize').closest('button')).toBeDisabled() }) @@ -505,10 +466,7 @@ describe('MCPDetailContent', () => { tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }], } const detail = createMockDetail({ is_team_authorization: true }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) const updateBtn = screen.getByText('tools.mcp.update') fireEvent.click(updateBtn) @@ -524,10 +482,9 @@ describe('MCPDetailContent', () => { } const onUpdate = vi.fn() const detail = createMockDetail({ is_team_authorization: true }) - render( - <MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) // Open confirm dialog const updateBtn = screen.getByText('tools.mcp.update') @@ -551,10 +508,9 @@ describe('MCPDetailContent', () => { it('should call handleUpdateTools when get tools button is clicked', async () => { const onUpdate = vi.fn() const detail = createMockDetail({ is_team_authorization: true, tools: [] }) - render( - <MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) const getToolsBtn = screen.getByText('tools.mcp.getTools') fireEvent.click(getToolsBtn) @@ -600,7 +556,9 @@ describe('MCPDetailContent', () => { it('should call updateMCP when form is confirmed', async () => { const onUpdate = vi.fn() - render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() }) + render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) // Open modal const editBtn = screen.getByTestId('edit-btn') @@ -627,7 +585,9 @@ describe('MCPDetailContent', () => { it('should not call onUpdate when updateMCP fails', async () => { mockUpdateMCP.mockResolvedValue({ result: 'error' }) const onUpdate = vi.fn() - render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() }) + render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) // Open modal const editBtn = screen.getByTestId('edit-btn') @@ -682,7 +642,9 @@ describe('MCPDetailContent', () => { it('should call deleteMCP when delete is confirmed', async () => { const onUpdate = vi.fn() - render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() }) + render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) // Open confirm const removeBtn = screen.getByTestId('remove-btn') @@ -704,7 +666,9 @@ describe('MCPDetailContent', () => { it('should not call onUpdate when deleteMCP fails', async () => { mockDeleteMCP.mockResolvedValue({ result: 'error' }) const onUpdate = vi.fn() - render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() }) + render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) // Open confirm const removeBtn = screen.getByTestId('remove-btn') @@ -755,10 +719,9 @@ describe('MCPDetailContent', () => { mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' }) const onUpdate = vi.fn() const detail = createMockDetail({ is_team_authorization: false }) - render( - <MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) // Click authorize to trigger OAuth popup const authorizeBtn = screen.getByText('tools.mcp.authorize') @@ -782,10 +745,7 @@ describe('MCPDetailContent', () => { mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' }) const detail = createMockDetail({ is_team_authorization: false }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) const authorizeButton = screen.getByText('tools.mcp.authorize').closest('button')! expect(authorizeButton).toBeDisabled() @@ -797,20 +757,16 @@ describe('MCPDetailContent', () => { describe('Authorized Button', () => { it('should show authorized button when team is authorized', () => { const detail = createMockDetail({ is_team_authorization: true }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.auth.authorized'))!.toBeInTheDocument() }) it('should call handleAuthorize when authorized button is clicked', async () => { const onFirstCreate = vi.fn() const detail = createMockDetail({ is_team_authorization: true }) - render( - <MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />, { + wrapper: createWrapper(), + }) const authorizedBtn = screen.getByText('tools.auth.authorized') fireEvent.click(authorizedBtn) @@ -824,10 +780,7 @@ describe('MCPDetailContent', () => { it('should disable authorized button when user lacks mcp.manage', () => { mockAppContextState.workspacePermissionKeys = [] const detail = createMockDetail({ is_team_authorization: true }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.auth.authorized').closest('button')).toBeDisabled() }) @@ -839,10 +792,7 @@ describe('MCPDetailContent', () => { tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }], } const detail = createMockDetail({ is_team_authorization: true }) - render( - <MCPDetailContent {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailContent {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) // Open confirm dialog const updateBtn = screen.getByText('tools.mcp.update') diff --git a/web/app/components/tools/mcp/detail/__tests__/list-loading.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/list-loading.spec.tsx index 79fb8282b00573..1e0e4d8e9677ee 100644 --- a/web/app/components/tools/mcp/detail/__tests__/list-loading.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/list-loading.spec.tsx @@ -11,7 +11,9 @@ describe('ListLoading', () => { it('should render 5 skeleton items', () => { render(<ListLoading />) - const skeletonItems = document.querySelectorAll('[class*="bg-components-panel-on-panel-item-bg-hover"]') + const skeletonItems = document.querySelectorAll( + '[class*="bg-components-panel-on-panel-item-bg-hover"]', + ) expect(skeletonItems.length).toBe(5) }) diff --git a/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx index e558ffbdac5362..d970f9c8b2d179 100644 --- a/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx @@ -4,28 +4,39 @@ import OperationDropdown from '../operation-dropdown' vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') - const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) + const DropdownMenuContext = React.createContext<{ + isOpen: boolean + setOpen: (open: boolean) => void + } | null>(null) const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => { + DropdownMenu: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void + }) => { const [internalOpen, setInternalOpen] = React.useState(open ?? false) const isOpen = open ?? internalOpen const setOpen = (nextOpen: boolean) => { - if (open === undefined) - setInternalOpen(nextOpen) + if (open === undefined) setInternalOpen(nextOpen) onOpenChange?.(nextOpen) } return ( <DropdownMenuContext value={{ isOpen, setOpen }}> - <div data-testid="dropdown-menu" data-open={isOpen}>{children}</div> + <div data-testid="dropdown-menu" data-open={isOpen}> + {children} + </div> </DropdownMenuContext> ) }, @@ -45,9 +56,17 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { } if (render) - return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record<string, unknown>, children) + return React.cloneElement( + render, + { 'data-testid': 'dropdown-trigger', onClick: handleClick } as Record<string, unknown>, + children, + ) - return <button data-testid="dropdown-trigger" onClick={handleClick}>{children}</button> + return ( + <button data-testid="dropdown-trigger" onClick={handleClick}> + {children} + </button> + ) }, DropdownMenuContent: ({ children, @@ -59,7 +78,14 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { popupClassName?: string }) => { const { isOpen } = useDropdownMenuContext() - return isOpen ? <div data-testid="dropdown-content" className={[className, popupClassName].filter(Boolean).join(' ')}>{children}</div> : null + return isOpen ? ( + <div + data-testid="dropdown-content" + className={[className, popupClassName].filter(Boolean).join(' ')} + > + {children} + </div> + ) : null }, DropdownMenuItem: ({ children, diff --git a/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx index c32398070b7884..b04b6ac39c11f2 100644 --- a/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx @@ -8,11 +8,21 @@ import MCPDetailPanel from '../provider-detail' // Mock the content component to expose onUpdate callback vi.mock('../content', () => ({ - default: ({ detail, onUpdate }: { detail: ToolWithProvider, onUpdate: (isDelete?: boolean) => void }) => ( + default: ({ + detail, + onUpdate, + }: { + detail: ToolWithProvider + onUpdate: (isDelete?: boolean) => void + }) => ( <div data-testid="mcp-detail-content"> {detail.name} - <button data-testid="update-btn" onClick={() => onUpdate()}>Update</button> - <button data-testid="delete-btn" onClick={() => onUpdate(true)}>Delete</button> + <button data-testid="update-btn" onClick={() => onUpdate()}> + Update + </button> + <button data-testid="delete-btn" onClick={() => onUpdate(true)}> + Delete + </button> </div> ), })) @@ -30,15 +40,16 @@ describe('MCPDetailPanel', () => { React.createElement(QueryClientProvider, { client: queryClient }, children) } - const createMockDetail = (): ToolWithProvider => ({ - id: 'mcp-1', - name: 'Test MCP', - server_identifier: 'test-mcp', - server_url: 'https://example.com/mcp', - icon: { content: '🔧', background: '#FF0000' }, - tools: [], - is_team_authorization: true, - } as unknown as ToolWithProvider) + const createMockDetail = (): ToolWithProvider => + ({ + id: 'mcp-1', + name: 'Test MCP', + server_identifier: 'test-mcp', + server_url: 'https://example.com/mcp', + icon: { content: '🔧', background: '#FF0000' }, + tools: [], + is_team_authorization: true, + }) as unknown as ToolWithProvider const defaultProps = { onUpdate: vi.fn(), @@ -49,19 +60,15 @@ describe('MCPDetailPanel', () => { describe('Rendering', () => { it('should render nothing when detail is undefined', () => { - const { container } = render( - <MCPDetailPanel {...defaultProps} detail={undefined} />, - { wrapper: createWrapper() }, - ) + const { container } = render(<MCPDetailPanel {...defaultProps} detail={undefined} />, { + wrapper: createWrapper(), + }) expect(container.innerHTML).toBe('') }) it('should render drawer when detail is provided', () => { const detail = createMockDetail() - render( - <MCPDetailPanel {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailPanel {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) const dialog = screen.getByRole('dialog') expect(dialog).toBeInTheDocument() @@ -76,19 +83,13 @@ describe('MCPDetailPanel', () => { it('should render content when detail is provided', () => { const detail = createMockDetail() - render( - <MCPDetailPanel {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailPanel {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument() }) it('should pass detail to content component', () => { const detail = createMockDetail() - render( - <MCPDetailPanel {...defaultProps} detail={detail} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailPanel {...defaultProps} detail={detail} />, { wrapper: createWrapper() }) expect(screen.getByText('Test MCP')).toBeInTheDocument() }) }) @@ -97,20 +98,18 @@ describe('MCPDetailPanel', () => { it('should call onUpdate when update is triggered', () => { const onUpdate = vi.fn() const detail = createMockDetail() - render( - <MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} />, { + wrapper: createWrapper(), + }) // The update callback is passed to content component expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument() }) it('should accept isTriggerAuthorize prop', () => { const detail = createMockDetail() - render( - <MCPDetailPanel {...defaultProps} detail={detail} isTriggerAuthorize={true} />, - { wrapper: createWrapper() }, - ) + render(<MCPDetailPanel {...defaultProps} detail={detail} isTriggerAuthorize={true} />, { + wrapper: createWrapper(), + }) expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument() }) }) diff --git a/web/app/components/tools/mcp/detail/__tests__/tool-item.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/tool-item.spec.tsx index edbbf3e9a34caa..31fa77b853dc30 100644 --- a/web/app/components/tools/mcp/detail/__tests__/tool-item.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/tool-item.spec.tsx @@ -4,19 +4,20 @@ import { describe, expect, it } from 'vitest' import MCPToolItem from '../tool-item' describe('MCPToolItem', () => { - const createMockTool = (overrides = {}): Tool => ({ - name: 'test-tool', - label: { - en_US: 'Test Tool', - zh_Hans: '测试工具', - }, - description: { - en_US: 'A test tool description', - zh_Hans: '测试工具描述', - }, - parameters: [], - ...overrides, - } as unknown as Tool) + const createMockTool = (overrides = {}): Tool => + ({ + name: 'test-tool', + label: { + en_US: 'Test Tool', + zh_Hans: '测试工具', + }, + description: { + en_US: 'A test tool description', + zh_Hans: '测试工具描述', + }, + parameters: [], + ...overrides, + }) as unknown as Tool describe('Rendering', () => { it('should render without crashing', () => { @@ -81,7 +82,9 @@ describe('MCPToolItem', () => { it('should have hover styles', () => { const tool = createMockTool() render(<MCPToolItem tool={tool} />) - const toolElement = document.querySelector('[class*="hover:bg-components-panel-on-panel-item-bg-hover"]') + const toolElement = document.querySelector( + '[class*="hover:bg-components-panel-on-panel-item-bg-hover"]', + ) expect(toolElement).toBeInTheDocument() }) }) diff --git a/web/app/components/tools/mcp/detail/content.tsx b/web/app/components/tools/mcp/detail/content.tsx index 9bf3cabe835dac..c800a581372c5a 100644 --- a/web/app/components/tools/mcp/detail/content.tsx +++ b/web/app/components/tools/mcp/detail/content.tsx @@ -60,96 +60,97 @@ const MCPDetailContent: FC<Props> = ({ const { t } = useTranslation() const canManageMCP = useCanManageMCP() - const { data, isFetching: isGettingTools } = useMCPTools(detail.is_team_authorization ? detail.id : '') + const { data, isFetching: isGettingTools } = useMCPTools( + detail.is_team_authorization ? detail.id : '', + ) const invalidateMCPTools = useInvalidateMCPTools() const invalidateAllMCPTools = useInvalidateAllMCPTools() const { mutateAsync: updateTools, isPending: isUpdating } = useUpdateMCPTools() const { mutateAsync: authorizeMcp, isPending: isAuthorizing } = useAuthorizeMCP() const toolList = data?.tools || [] - const [isShowUpdateConfirm, { - setTrue: showUpdateConfirm, - setFalse: hideUpdateConfirm, - }] = useBoolean(false) + const [isShowUpdateConfirm, { setTrue: showUpdateConfirm, setFalse: hideUpdateConfirm }] = + useBoolean(false) const handleUpdateTools = useCallback(async () => { hideUpdateConfirm() - if (!canManageMCP || !detail) - return + if (!canManageMCP || !detail) return await updateTools(detail.id) invalidateMCPTools(detail.id) invalidateAllMCPTools() onUpdate() - }, [canManageMCP, detail, hideUpdateConfirm, invalidateAllMCPTools, invalidateMCPTools, onUpdate, updateTools]) + }, [ + canManageMCP, + detail, + hideUpdateConfirm, + invalidateAllMCPTools, + invalidateMCPTools, + onUpdate, + updateTools, + ]) const { mutateAsync: updateMCP } = useUpdateMCP({}) const { mutateAsync: deleteMCP } = useDeleteMCP({}) - const [isShowUpdateModal, { - setTrue: showUpdateModal, - setFalse: hideUpdateModal, - }] = useBoolean(false) + const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = + useBoolean(false) - const [isShowDeleteConfirm, { - setTrue: showDeleteConfirm, - setFalse: hideDeleteConfirm, - }] = useBoolean(false) + const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = + useBoolean(false) - const [deleting, { - setTrue: showDeleting, - setFalse: hideDeleting, - }] = useBoolean(false) + const [deleting, { setTrue: showDeleting, setFalse: hideDeleting }] = useBoolean(false) const handleOAuthCallback = useCallback(() => { - if (!canManageMCP) - return - if (!detail.id) - return + if (!canManageMCP) return + if (!detail.id) return handleUpdateTools() }, [canManageMCP, detail.id, handleUpdateTools]) const handleAuthorize = useCallback(async () => { - if (!canManageMCP) - return + if (!canManageMCP) return onFirstCreate() - if (!detail) - return + if (!detail) return try { const res = await authorizeMcp({ provider_id: detail.id, }) - if (res.result === 'success') - handleUpdateTools() - - else if (res.authorization_url) - openOAuthPopup(res.authorization_url, handleOAuthCallback) - } - catch { + if (res.result === 'success') handleUpdateTools() + else if (res.authorization_url) openOAuthPopup(res.authorization_url, handleOAuthCallback) + } catch { // On authorization error, refresh the parent component state // to update the connection status indicator onUpdate() } - }, [canManageMCP, onFirstCreate, detail, authorizeMcp, handleUpdateTools, handleOAuthCallback, onUpdate]) + }, [ + canManageMCP, + onFirstCreate, + detail, + authorizeMcp, + handleUpdateTools, + handleOAuthCallback, + onUpdate, + ]) - const handleUpdate = useCallback(async (data: MCPModalConfirmPayload) => { - if (!canManageMCP || !detail) - return - const res = await updateMCP({ - ...data, - provider_id: detail.id, - }) as MutationResult - if (res.result === 'success') { - hideUpdateModal() - onUpdate() - handleAuthorize() - } - }, [canManageMCP, detail, updateMCP, hideUpdateModal, onUpdate, handleAuthorize]) + const handleUpdate = useCallback( + async (data: MCPModalConfirmPayload) => { + if (!canManageMCP || !detail) return + const res = (await updateMCP({ + ...data, + provider_id: detail.id, + })) as MutationResult + if (res.result === 'success') { + hideUpdateModal() + onUpdate() + handleAuthorize() + } + }, + [canManageMCP, detail, updateMCP, hideUpdateModal, onUpdate, handleAuthorize], + ) const handleDelete = useCallback(async () => { - if (!canManageMCP || !detail) - return + if (!canManageMCP || !detail) return showDeleting() - const res = await deleteMCP(detail.id) as MutationResult + const res = (await deleteMCP(detail.id)) as MutationResult hideDeleting() if (res.result === 'success') { hideDeleteConfirm() @@ -158,18 +159,18 @@ const MCPDetailContent: FC<Props> = ({ }, [canManageMCP, detail, showDeleting, deleteMCP, hideDeleting, hideDeleteConfirm, onUpdate]) useEffect(() => { - if (isTriggerAuthorize) - handleAuthorize() + if (isTriggerAuthorize) handleAuthorize() }, []) - if (!detail) - return null - const identifierLabel = t($ => $['mcp.identifier'], { ns: 'tools' }) - const serverUrlLabel = t($ => $['mcp.modal.serverUrl'], { ns: 'tools' }) + if (!detail) return null + const identifierLabel = t(($) => $['mcp.identifier'], { ns: 'tools' }) + const serverUrlLabel = t(($) => $['mcp.modal.serverUrl'], { ns: 'tools' }) return ( <> - <div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3')}> + <div + className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3')} + > <div className="flex"> <div className="shrink-0 overflow-hidden rounded-xl border border-components-panel-border-subtle"> <Icon src={detail.icon} /> @@ -181,7 +182,7 @@ const MCPDetailContent: FC<Props> = ({ <div className="mt-0.5 flex items-center gap-1"> <Tooltip> <TooltipTrigger - render={( + render={ <Button type="button" variant="ghost" @@ -192,35 +193,34 @@ const MCPDetailContent: FC<Props> = ({ > {detail.server_identifier} </Button> - )} + } /> - <TooltipContent> - {identifierLabel} - </TooltipContent> + <TooltipContent>{identifierLabel}</TooltipContent> </Tooltip> <div className="shrink-0 system-xs-regular text-text-quaternary">·</div> <Tooltip> <TooltipTrigger - render={( - <div aria-label={serverUrlLabel} className="truncate system-xs-regular text-text-secondary"> + render={ + <div + aria-label={serverUrlLabel} + className="truncate system-xs-regular text-text-secondary" + > {detail.server_url} </div> - )} + } /> - <TooltipContent> - {serverUrlLabel} - </TooltipContent> + <TooltipContent>{serverUrlLabel}</TooltipContent> </Tooltip> </div> </div> <div className="flex gap-1"> {canManageMCP && ( - <OperationDropdown - onEdit={showUpdateModal} - onRemove={showDeleteConfirm} - /> + <OperationDropdown onEdit={showUpdateModal} onRemove={showDeleteConfirm} /> )} - <ActionButton aria-label={t($ => $['operation.close'], { ns: 'common' })} onClick={onHide}> + <ActionButton + aria-label={t(($) => $['operation.close'], { ns: 'common' })} + onClick={onHide} + > <span aria-hidden className="i-ri-close-line size-4" /> </ActionButton> </div> @@ -234,7 +234,7 @@ const MCPDetailContent: FC<Props> = ({ disabled={!canManageMCP} > <StatusDot className="mr-2" status="success" /> - {t($ => $['auth.authorized'], { ns: 'tools' })} + {t(($) => $['auth.authorized'], { ns: 'tools' })} </Button> )} {!detail.is_team_authorization && !isAuthorizing && ( @@ -244,17 +244,13 @@ const MCPDetailContent: FC<Props> = ({ onClick={handleAuthorize} disabled={!canManageMCP} > - {t($ => $['mcp.authorize'], { ns: 'tools' })} + {t(($) => $['mcp.authorize'], { ns: 'tools' })} </Button> )} {isAuthorizing && ( - <Button - variant="primary" - className="w-full" - disabled - > + <Button variant="primary" className="w-full" disabled> <span aria-hidden className="mr-1 i-ri-loader-2-line size-4 animate-spin" /> - {t($ => $['mcp.authorizing'], { ns: 'tools' })} + {t(($) => $['mcp.authorizing'], { ns: 'tools' })} </Button> )} </div> @@ -264,8 +260,16 @@ const MCPDetailContent: FC<Props> = ({ <> <div className="flex shrink-0 justify-between gap-2 px-4 pt-2 pb-1"> <div className="flex h-6 items-center"> - {!isUpdating && <div className="system-sm-semibold-uppercase text-text-secondary">{t($ => $['mcp.gettingTools'], { ns: 'tools' })}</div>} - {isUpdating && <div className="system-sm-semibold-uppercase text-text-secondary">{t($ => $['mcp.updateTools'], { ns: 'tools' })}</div>} + {!isUpdating && ( + <div className="system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['mcp.gettingTools'], { ns: 'tools' })} + </div> + )} + {isUpdating && ( + <div className="system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['mcp.updateTools'], { ns: 'tools' })} + </div> + )} </div> <div></div> </div> @@ -276,13 +280,11 @@ const MCPDetailContent: FC<Props> = ({ )} {!isUpdating && detail.is_team_authorization && !isGettingTools && !toolList.length && ( <div className="flex size-full flex-col items-center justify-center"> - <div className="mb-3 system-sm-regular text-text-tertiary">{t($ => $['mcp.toolsEmpty'], { ns: 'tools' })}</div> - <Button - variant="primary" - onClick={handleUpdateTools} - disabled={!canManageMCP} - > - {t($ => $['mcp.getTools'], { ns: 'tools' })} + <div className="mb-3 system-sm-regular text-text-tertiary"> + {t(($) => $['mcp.toolsEmpty'], { ns: 'tools' })} + </div> + <Button variant="primary" onClick={handleUpdateTools} disabled={!canManageMCP}> + {t(($) => $['mcp.getTools'], { ns: 'tools' })} </Button> </div> )} @@ -290,22 +292,27 @@ const MCPDetailContent: FC<Props> = ({ <> <div className="flex shrink-0 justify-between gap-2 px-4 pt-2 pb-1"> <div className="flex h-6 items-center"> - {toolList.length > 1 && <div className="system-sm-semibold-uppercase text-text-secondary">{t($ => $['mcp.toolsNum'], { ns: 'tools', count: toolList.length })}</div>} - {toolList.length === 1 && <div className="system-sm-semibold-uppercase text-text-secondary">{t($ => $['mcp.onlyTool'], { ns: 'tools' })}</div>} + {toolList.length > 1 && ( + <div className="system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['mcp.toolsNum'], { ns: 'tools', count: toolList.length })} + </div> + )} + {toolList.length === 1 && ( + <div className="system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['mcp.onlyTool'], { ns: 'tools' })} + </div> + )} </div> <div> <Button size="small" onClick={showUpdateConfirm} disabled={!canManageMCP}> <span aria-hidden className="mr-1 i-ri-loop-left-line size-3.5" /> - {t($ => $['mcp.update'], { ns: 'tools' })} + {t(($) => $['mcp.update'], { ns: 'tools' })} </Button> </div> </div> <div className="flex h-0 w-full grow flex-col gap-2 overflow-y-auto px-4 pb-4"> - {toolList.map(tool => ( - <ToolItem - key={`${detail.id}${tool.name}`} - tool={tool} - /> + {toolList.map((tool) => ( + <ToolItem key={`${detail.id}${tool.name}`} tool={tool} /> ))} </div> </> @@ -313,9 +320,19 @@ const MCPDetailContent: FC<Props> = ({ {!isUpdating && !detail.is_team_authorization && ( <div className="flex size-full flex-col items-center justify-center"> - {!isAuthorizing && <div className="mb-1 system-md-medium text-text-secondary">{t($ => $['mcp.authorizingRequired'], { ns: 'tools' })}</div>} - {isAuthorizing && <div className="mb-1 system-md-medium text-text-secondary">{t($ => $['mcp.authorizing'], { ns: 'tools' })}</div>} - <div className="system-sm-regular text-text-tertiary">{t($ => $['mcp.authorizeTip'], { ns: 'tools' })}</div> + {!isAuthorizing && ( + <div className="mb-1 system-md-medium text-text-secondary"> + {t(($) => $['mcp.authorizingRequired'], { ns: 'tools' })} + </div> + )} + {isAuthorizing && ( + <div className="mb-1 system-md-medium text-text-secondary"> + {t(($) => $['mcp.authorizing'], { ns: 'tools' })} + </div> + )} + <div className="system-sm-regular text-text-tertiary"> + {t(($) => $['mcp.authorizeTip'], { ns: 'tools' })} + </div> </div> )} </div> @@ -327,38 +344,48 @@ const MCPDetailContent: FC<Props> = ({ onHide={hideUpdateModal} /> )} - <AlertDialog open={canManageMCP && isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}> + <AlertDialog + open={canManageMCP && isShowDeleteConfirm} + onOpenChange={(open) => !open && hideDeleteConfirm()} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['mcp.delete'], { ns: 'tools' })} + {t(($) => $['mcp.delete'], { ns: 'tools' })} </AlertDialogTitle> <div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['mcp.deleteConfirmTitle'], { ns: 'tools', mcp: detail.name })} + {t(($) => $['mcp.deleteConfirmTitle'], { ns: 'tools', mcp: detail.name })} </div> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={handleDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> </AlertDialog> - <AlertDialog open={canManageMCP && isShowUpdateConfirm} onOpenChange={open => !open && hideUpdateConfirm()}> + <AlertDialog + open={canManageMCP && isShowUpdateConfirm} + onOpenChange={(open) => !open && hideUpdateConfirm()} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['mcp.toolUpdateConfirmTitle'], { ns: 'tools' })} + {t(($) => $['mcp.toolUpdateConfirmTitle'], { ns: 'tools' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['mcp.toolUpdateConfirmContent'], { ns: 'tools' })} + {t(($) => $['mcp.toolUpdateConfirmContent'], { ns: 'tools' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={handleUpdateTools}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/tools/mcp/detail/operation-dropdown.tsx b/web/app/components/tools/mcp/detail/operation-dropdown.tsx index 822ba923e23f5c..aa3975cfa7b539 100644 --- a/web/app/components/tools/mcp/detail/operation-dropdown.tsx +++ b/web/app/components/tools/mcp/detail/operation-dropdown.tsx @@ -7,11 +7,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { - RiDeleteBinLine, - RiEditLine, - RiMoreFill, -} from '@remixicon/react' +import { RiDeleteBinLine, RiEditLine, RiMoreFill } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' @@ -23,36 +19,33 @@ type Props = Readonly<{ onRemove: () => void }> -const OperationDropdown: FC<Props> = ({ - inCard, - onOpenChange, - onEdit, - onRemove, -}) => { +const OperationDropdown: FC<Props> = ({ inCard, onOpenChange, onEdit, onRemove }) => { const { t } = useTranslation() return ( <DropdownMenu onOpenChange={onOpenChange}> <DropdownMenuTrigger - render={<ActionButton size={inCard ? 'l' : 'm'} className="data-popup-open:bg-state-base-hover" />} + render={ + <ActionButton size={inCard ? 'l' : 'm'} className="data-popup-open:bg-state-base-hover" /> + } > <RiMoreFill className={cn('size-4', inCard && 'size-5')} /> </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="w-[160px]" - > + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[160px]"> <DropdownMenuItem onClick={onEdit}> <RiEditLine className="size-4 shrink-0 text-text-tertiary" /> - <div className="ml-2 system-md-regular text-text-secondary">{t($ => $['mcp.operation.edit'], { ns: 'tools' })}</div> + <div className="ml-2 system-md-regular text-text-secondary"> + {t(($) => $['mcp.operation.edit'], { ns: 'tools' })} + </div> </DropdownMenuItem> <DropdownMenuItem className="data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive" onClick={onRemove} > <RiDeleteBinLine className="size-4 shrink-0 text-inherit" /> - <div className="ml-2 system-md-regular text-inherit">{t($ => $['mcp.operation.remove'], { ns: 'tools' })}</div> + <div className="ml-2 system-md-regular text-inherit"> + {t(($) => $['mcp.operation.remove'], { ns: 'tools' })} + </div> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> diff --git a/web/app/components/tools/mcp/detail/provider-detail.tsx b/web/app/components/tools/mcp/detail/provider-detail.tsx index 11afd8f386dd44..d9c15e80b6ca23 100644 --- a/web/app/components/tools/mcp/detail/provider-detail.tsx +++ b/web/app/components/tools/mcp/detail/provider-detail.tsx @@ -29,13 +29,11 @@ const MCPDetailPanel: FC<Props> = ({ onFirstCreate, }) => { const handleUpdate = (isDelete = false) => { - if (isDelete) - onHide() + if (isDelete) onHide() onUpdate() } - if (!detail) - return null + if (!detail) return null return ( <Drawer @@ -43,14 +41,17 @@ const MCPDetailPanel: FC<Props> = ({ modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DrawerPortal> <DrawerBackdrop className="bg-transparent" /> <DrawerViewport> - <DrawerPopup className={cn('justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-[400px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerPopup + className={cn( + 'justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-[400px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border', + )} + > <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> {detail && ( <MCPDetailContent diff --git a/web/app/components/tools/mcp/detail/tool-item.tsx b/web/app/components/tools/mcp/detail/tool-item.tsx index 31ecd20b4af0f9..f4f49e7467f378 100644 --- a/web/app/components/tools/mcp/detail/tool-item.tsx +++ b/web/app/components/tools/mcp/detail/tool-item.tsx @@ -11,9 +11,7 @@ type Props = Readonly<{ tool: Tool }> -const MCPToolItem = ({ - tool, -}: Props) => { +const MCPToolItem = ({ tool }: Props) => { const locale = useLocale() const language = getLanguage(locale) const { t } = useTranslation() @@ -21,24 +19,25 @@ const MCPToolItem = ({ const renderParameters = () => { const parameters = tool.parameters - if (parameters.length === 0) - return null + if (parameters.length === 0) return null return ( <div className="mt-2"> <div className="mb-1 title-xs-semi-bold text-text-primary"> - {t($ => $['mcp.toolItem.parameters'], { ns: 'tools' })} - : + {t(($) => $['mcp.toolItem.parameters'], { ns: 'tools' })}: </div> <ul className="space-y-1"> {parameters.map((parameter) => { - const descriptionContent = parameter.human_description[language] || t($ => $['mcp.toolItem.noDescription'], { ns: 'tools' }) + const descriptionContent = + parameter.human_description[language] || + t(($) => $['mcp.toolItem.noDescription'], { ns: 'tools' }) return ( <li key={parameter.name} className="pl-2"> - <span className="system-xs-regular font-bold text-text-secondary">{parameter.name}</span> + <span className="system-xs-regular font-bold text-text-secondary"> + {parameter.name} + </span> <span className="mr-1 system-xs-regular text-text-tertiary"> - ( - {parameter.type} + ({parameter.type} ): </span> <span className="system-xs-regular text-text-tertiary">{descriptionContent}</span> @@ -55,15 +54,21 @@ const MCPToolItem = ({ <PopoverTrigger openOnHover aria-label={tool.label[language]} - render={( + render={ <button type="button" - className={cn('bg-components-panel-item-bg w-full cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 text-left shadow-xs outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover')} + className={cn( + 'bg-components-panel-item-bg w-full cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 text-left shadow-xs outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover', + )} > - <div className="pb-0.5 system-md-semibold text-text-secondary">{tool.label[language]}</div> - <div className="line-clamp-2 system-xs-regular text-text-tertiary">{tool.description[language]}</div> + <div className="pb-0.5 system-md-semibold text-text-secondary"> + {tool.label[language]} + </div> + <div className="line-clamp-2 system-xs-regular text-text-tertiary"> + {tool.description[language]} + </div> </button> - )} + } /> <PopoverContent placement="left" diff --git a/web/app/components/tools/mcp/headers-input.tsx b/web/app/components/tools/mcp/headers-input.tsx index 53d156d8d0056b..a83e09d6f2f830 100644 --- a/web/app/components/tools/mcp/headers-input.tsx +++ b/web/app/components/tools/mcp/headers-input.tsx @@ -21,12 +21,7 @@ type Props = Readonly<{ isMasked?: boolean }> -const HeadersInput = ({ - headersItems, - onChange, - readonly = false, - isMasked = false, -}: Props) => { +const HeadersInput = ({ headersItems, onChange, readonly = false, isMasked = false }: Props) => { const { t } = useTranslation() const handleItemChange = (index: number, field: 'key' | 'value', value: string) => { @@ -52,17 +47,12 @@ const HeadersInput = ({ return ( <div className="space-y-2"> <div className="body-xs-regular text-text-tertiary"> - {t($ => $['mcp.modal.noHeaders'], { ns: 'tools' })} + {t(($) => $['mcp.modal.noHeaders'], { ns: 'tools' })} </div> {!readonly && ( - <Button - variant="secondary" - size="small" - onClick={handleAddItem} - className="w-full" - > + <Button variant="secondary" size="small" onClick={handleAddItem} className="w-full"> <RiAddLine className="mr-1 size-4" /> - {t($ => $['mcp.modal.addHeader'], { ns: 'tools' })} + {t(($) => $['mcp.modal.addHeader'], { ns: 'tools' })} </Button> )} </div> @@ -73,13 +63,17 @@ const HeadersInput = ({ <div className="space-y-2"> {isMasked && ( <div className="body-xs-regular text-text-tertiary"> - {t($ => $['mcp.modal.maskedHeadersTip'], { ns: 'tools' })} + {t(($) => $['mcp.modal.maskedHeadersTip'], { ns: 'tools' })} </div> )} <div className="overflow-hidden rounded-lg border border-divider-regular"> <div className="bg-background-secondary flex h-7 items-center system-xs-medium-uppercase leading-7 text-text-tertiary"> - <div className="h-full w-1/2 border-r border-divider-regular pl-3">{t($ => $['mcp.modal.headerKey'], { ns: 'tools' })}</div> - <div className="h-full w-1/2 pr-1 pl-3">{t($ => $['mcp.modal.headerValue'], { ns: 'tools' })}</div> + <div className="h-full w-1/2 border-r border-divider-regular pl-3"> + {t(($) => $['mcp.modal.headerKey'], { ns: 'tools' })} + </div> + <div className="h-full w-1/2 pr-1 pl-3"> + {t(($) => $['mcp.modal.headerValue'], { ns: 'tools' })} + </div> </div> {headersItems.map((item, index) => ( <div @@ -92,8 +86,8 @@ const HeadersInput = ({ <div className="w-1/2 border-r border-divider-regular"> <Input value={item.key} - onChange={e => handleItemChange(index, 'key', e.target.value)} - placeholder={t($ => $['mcp.modal.headerKeyPlaceholder'], { ns: 'tools' })} + onChange={(e) => handleItemChange(index, 'key', e.target.value)} + placeholder={t(($) => $['mcp.modal.headerKeyPlaceholder'], { ns: 'tools' })} className="rounded-none border-0" readOnly={readonly} /> @@ -101,16 +95,13 @@ const HeadersInput = ({ <div className="flex w-1/2 items-center"> <Input value={item.value} - onChange={e => handleItemChange(index, 'value', e.target.value)} - placeholder={t($ => $['mcp.modal.headerValuePlaceholder'], { ns: 'tools' })} + onChange={(e) => handleItemChange(index, 'value', e.target.value)} + placeholder={t(($) => $['mcp.modal.headerValuePlaceholder'], { ns: 'tools' })} className="flex-1 rounded-none border-0" readOnly={readonly} /> {!readonly && !!headersItems.length && ( - <ActionButton - onClick={() => handleRemoveItem(index)} - className="mr-2" - > + <ActionButton onClick={() => handleRemoveItem(index)} className="mr-2"> <RiDeleteBinLine className="size-4 text-text-destructive" /> </ActionButton> )} @@ -119,14 +110,9 @@ const HeadersInput = ({ ))} </div> {!readonly && ( - <Button - variant="secondary" - size="small" - onClick={handleAddItem} - className="w-full" - > + <Button variant="secondary" size="small" onClick={handleAddItem} className="w-full"> <RiAddLine className="mr-1 size-4" /> - {t($ => $['mcp.modal.addHeader'], { ns: 'tools' })} + {t(($) => $['mcp.modal.addHeader'], { ns: 'tools' })} </Button> )} </div> diff --git a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts index ce5405946e40b1..51a49f148f0e93 100644 --- a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts +++ b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts @@ -1,4 +1,7 @@ -import type { AppIconEmojiSelection, AppIconImageSelection } from '@/app/components/base/app-icon-picker' +import type { + AppIconEmojiSelection, + AppIconImageSelection, +} from '@/app/components/base/app-icon-picker' import type { ToolWithProvider } from '@/app/components/workflow/types' import { act, renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' @@ -131,7 +134,7 @@ describe('useMCPModalForm', () => { sse_read_timeout: 600, }, masked_headers: { - 'Authorization': '***', + Authorization: '***', 'X-Custom': 'value', }, is_dynamic_registration: false, @@ -170,8 +173,8 @@ describe('useMCPModalForm', () => { const { result } = renderHook(() => useMCPModalForm(mockData)) expect(result.current.state.appIcon.type).toBe('emoji') - expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🚀') - expect(((result.current.state.appIcon) as AppIconEmojiSelection).background).toBe('#FF0000') + expect((result.current.state.appIcon as AppIconEmojiSelection).icon).toBe('🚀') + expect((result.current.state.appIcon as AppIconEmojiSelection).background).toBe('#FF0000') }) it('should store original server URL and ID', () => { @@ -193,8 +196,10 @@ describe('useMCPModalForm', () => { const { result } = renderHook(() => useMCPModalForm(mockDataWithImageIcon)) expect(result.current.state.appIcon.type).toBe('image') - expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/abc123/file-preview/icon.png') - expect(((result.current.state.appIcon) as AppIconImageSelection).fileId).toBe('abc123') + expect((result.current.state.appIcon as AppIconImageSelection).url).toBe( + 'https://example.com/files/abc123/file-preview/icon.png', + ) + expect((result.current.state.appIcon as AppIconImageSelection).fileId).toBe('abc123') }) }) }) @@ -332,7 +337,7 @@ describe('useMCPModalForm', () => { result.current.actions.setAppIcon({ type: 'emoji', icon: '🎉', background: '#00FF00' }) }) - expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🎉') + expect((result.current.state.appIcon as AppIconEmojiSelection).icon).toBe('🎉') // Reset icon act(() => { @@ -421,13 +426,15 @@ describe('useMCPModalForm', () => { }) it('should fetch icon successfully for valid URL in create mode', async () => { - vi.mocked(await import('@/service/common').then(m => m.uploadRemoteFileInfo)).mockResolvedValueOnce({ + vi.mocked( + await import('@/service/common').then((m) => m.uploadRemoteFileInfo), + ).mockResolvedValueOnce({ id: 'file123', name: 'icon.png', size: 1024, mime_type: 'image/png', url: 'https://example.com/files/file123/file-preview/icon.png', - } as unknown as { id: string, name: string, size: number, mime_type: string, url: string }) + } as unknown as { id: string; name: string; size: number; mime_type: string; url: string }) const { result } = renderHook(() => useMCPModalForm()) @@ -437,7 +444,9 @@ describe('useMCPModalForm', () => { // Icon should be set to image type expect(result.current.state.appIcon.type).toBe('image') - expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/file123/file-preview/icon.png') + expect((result.current.state.appIcon as AppIconImageSelection).url).toBe( + 'https://example.com/files/file123/file-preview/icon.png', + ) expect(result.current.state.isFetchingIcon).toBe(false) }) }) @@ -494,7 +503,9 @@ describe('useMCPModalForm', () => { const { result } = renderHook(() => useMCPModalForm(mockData)) expect(result.current.state.appIcon.type).toBe('image') - expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/icon.png') + expect((result.current.state.appIcon as AppIconImageSelection).url).toBe( + 'https://example.com/icon.png', + ) }) }) diff --git a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts index 30c34f5b300f48..620aec90daeb33 100644 --- a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts +++ b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts @@ -10,13 +10,15 @@ import { AppACLPermission } from '@/utils/permission' import { useMCPServiceCardState } from '../use-mcp-service-card' // Mutable mock data for MCP server detail -let mockMCPServerDetailData: { - id: string - status: string - server_code: string - description: string - parameters: Record<string, unknown> -} | undefined = { +let mockMCPServerDetailData: + | { + id: string + status: string + server_code: string + description: string + parameters: Record<string, unknown> + } + | undefined = { id: 'server-123', status: 'active', server_code: 'abc123', @@ -89,13 +91,14 @@ describe('useMCPServiceCardState', () => { const createMockAppInfo = ( mode: AppModeEnum = AppModeEnum.CHAT, permissionKeys: string[] = [AppACLPermission.Edit], - ): AppDetailResponse & Partial<AppSSO> => ({ - id: 'app-123', - name: 'Test App', - mode, - api_base_url: 'https://api.example.com/v1', - permission_keys: permissionKeys, - } as AppDetailResponse & Partial<AppSSO>) + ): AppDetailResponse & Partial<AppSSO> => + ({ + id: 'app-123', + name: 'Test App', + mode, + api_base_url: 'https://api.example.com/v1', + permission_keys: permissionKeys, + }) as AppDetailResponse & Partial<AppSSO> beforeEach(() => { vi.clearAllMocks() @@ -114,10 +117,9 @@ describe('useMCPServiceCardState', () => { describe('Initialization', () => { it('should initialize with correct default values for basic app', () => { const appInfo = createMockAppInfo(AppModeEnum.CHAT) - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(result.current.serverPublished).toBe(true) expect(result.current.serverActivated).toBe(true) @@ -127,20 +129,18 @@ describe('useMCPServiceCardState', () => { it('should initialize with correct values for workflow app', () => { const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW) - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(result.current.isLoading).toBe(false) }) it('should initialize with correct values for advanced chat app', () => { const appInfo = createMockAppInfo(AppModeEnum.ADVANCED_CHAT) - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(result.current.isLoading).toBe(false) }) @@ -149,10 +149,9 @@ describe('useMCPServiceCardState', () => { describe('Server URL Generation', () => { it('should generate correct server URL when published', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(result.current.serverURL).toBe('https://api.example.com/mcp/server/abc123/mcp') }) @@ -161,20 +160,18 @@ describe('useMCPServiceCardState', () => { describe('Permission Flags', () => { it('should expose MCP manage capability from app edit ACL', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(result.current.canManageMCP).toBe(true) }) it('should keep MCP server status readable and disable mutations without app edit ACL', async () => { const appInfo = createMockAppInfo(AppModeEnum.CHAT, []) - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(result.current.canManageMCP).toBe(false) expect(result.current.toggleDisabled).toBe(true) @@ -201,10 +198,9 @@ describe('useMCPServiceCardState', () => { it('should read workflow state without app edit ACL for workflow apps', () => { const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW, []) - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(result.current.canManageMCP).toBe(false) expect(result.current.isLoading).toBe(false) @@ -215,10 +211,9 @@ describe('useMCPServiceCardState', () => { it('should have toggleDisabled false when editor has permissions', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) // Toggle is not disabled when user has permissions and app is published expect(typeof result.current.toggleDisabled).toBe('boolean') @@ -226,10 +221,9 @@ describe('useMCPServiceCardState', () => { it('should have toggleDisabled true when triggerModeDisabled is true', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, true), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, true), { + wrapper: createWrapper(), + }) expect(result.current.toggleDisabled).toBe(true) }) @@ -238,10 +232,9 @@ describe('useMCPServiceCardState', () => { describe('UI State Actions', () => { it('should open confirm delete modal', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(result.current.showConfirmDelete).toBe(false) @@ -254,10 +247,9 @@ describe('useMCPServiceCardState', () => { it('should close confirm delete modal', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) act(() => { result.current.openConfirmDelete() @@ -272,10 +264,9 @@ describe('useMCPServiceCardState', () => { it('should open server modal', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(result.current.showMCPServerModal).toBe(false) @@ -288,10 +279,9 @@ describe('useMCPServiceCardState', () => { it('should handle server modal hide', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) act(() => { result.current.openServerModal() @@ -309,10 +299,9 @@ describe('useMCPServiceCardState', () => { it('should not deactivate when wasActivated is true', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) let hideResult: { shouldDeactivate: boolean } | undefined act(() => { @@ -326,20 +315,18 @@ describe('useMCPServiceCardState', () => { describe('Handler Functions', () => { it('should have handleGenCode function', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(typeof result.current.handleGenCode).toBe('function') }) it('should call handleGenCode and invalidate server detail', async () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) await act(async () => { await result.current.handleGenCode() @@ -351,30 +338,27 @@ describe('useMCPServiceCardState', () => { it('should have handleStatusChange function', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(typeof result.current.handleStatusChange).toBe('function') }) it('should have invalidateBasicAppConfig function', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(typeof result.current.invalidateBasicAppConfig).toBe('function') }) it('should call invalidateBasicAppConfig', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) // Call the function - should not throw act(() => { @@ -389,10 +373,9 @@ describe('useMCPServiceCardState', () => { describe('Status Change', () => { it('should return activated state when status change succeeds', async () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) let statusResult: { activated: boolean } | undefined await act(async () => { @@ -404,10 +387,9 @@ describe('useMCPServiceCardState', () => { it('should return deactivated state when disabling', async () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) let statusResult: { activated: boolean } | undefined await act(async () => { @@ -424,10 +406,9 @@ describe('useMCPServiceCardState', () => { mockMCPServerDetailData = undefined const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) // Verify server is not published expect(result.current.serverPublished).toBe(false) @@ -446,20 +427,18 @@ describe('useMCPServiceCardState', () => { describe('Loading States', () => { it('should have genLoading state', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(typeof result.current.genLoading).toBe('boolean') }) it('should have isLoading state for basic app', () => { const appInfo = createMockAppInfo(AppModeEnum.CHAT) - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) // Basic app doesn't need workflow, so isLoading should be false expect(result.current.isLoading).toBe(false) @@ -469,10 +448,9 @@ describe('useMCPServiceCardState', () => { describe('Detail Data', () => { it('should return detail data when available', () => { const appInfo = createMockAppInfo() - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(result.current.detail).toBeDefined() expect(result.current.detail?.id).toBe('server-123') @@ -483,20 +461,18 @@ describe('useMCPServiceCardState', () => { describe('Latest Params', () => { it('should return latestParams for workflow app', () => { const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW) - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(Array.isArray(result.current.latestParams)).toBe(true) }) it('should return latestParams for basic app', () => { const appInfo = createMockAppInfo(AppModeEnum.CHAT) - const { result } = renderHook( - () => useMCPServiceCardState(appInfo, false), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { + wrapper: createWrapper(), + }) expect(Array.isArray(result.current.latestParams)).toBe(true) }) diff --git a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts index ca275e8df5d039..3a91a5a81574e6 100644 --- a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts +++ b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts @@ -15,8 +15,7 @@ const extractFileId = (url: string) => { return match ? match[1] : null } const getIcon = (data?: ToolWithProvider): AppIconSelection => { - if (!data) - return DEFAULT_ICON as AppIconSelection + if (!data) return DEFAULT_ICON as AppIconSelection if (typeof data.icon === 'string') return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection return { @@ -26,14 +25,17 @@ const getIcon = (data?: ToolWithProvider): AppIconSelection => { } as unknown as AppIconSelection } const getInitialHeaders = (data?: ToolWithProvider): HeaderItem[] => { - return Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })) + return Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ + id: uuid(), + key, + value, + })) } export const isValidUrl = (string: string) => { try { const url = new URL(string) return url.protocol === 'http:' || url.protocol === 'https:' - } - catch { + } catch { return false } } @@ -93,13 +95,17 @@ export const useMCPModalForm = (data?: ToolWithProvider) => { const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [serverIdentifier, setServerIdentifier] = useState(() => data?.server_identifier || '') const [timeout, setMcpTimeout] = useState(() => data?.configuration?.timeout || 30) - const [sseReadTimeout, setSseReadTimeout] = useState(() => data?.configuration?.sse_read_timeout || 300) + const [sseReadTimeout, setSseReadTimeout] = useState( + () => data?.configuration?.sse_read_timeout || 300, + ) const [headers, setHeaders] = useState<HeaderItem[]>(() => getInitialHeaders(data)) const [isFetchingIcon, setIsFetchingIcon] = useState(false) const appIconRef = useRef<HTMLDivElement>(null) // Auth state const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication) - const [isDynamicRegistration, setIsDynamicRegistration] = useState(() => isCreate ? true : (data?.is_dynamic_registration ?? true)) + const [isDynamicRegistration, setIsDynamicRegistration] = useState(() => + isCreate ? true : (data?.is_dynamic_registration ?? true), + ) const [clientID, setClientID] = useState(() => data?.authentication?.client_id || '') const [credentials, setCredentials] = useState(() => data?.authentication?.client_secret || '') // M3 — user-identity forwarding. The UI toggle is true iff the persisted @@ -107,40 +113,36 @@ export const useMCPModalForm = (data?: ToolWithProvider) => { const [forwardUserIdentity, setForwardUserIdentity] = useState( () => (data?.identity_mode ?? 'off') !== 'off', ) - const handleUrlBlur = useCallback(async (urlValue: string) => { - if (data) - return - if (!isValidUrl(urlValue)) - return - const domain = getDomain(urlValue) - const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128` - setIsFetchingIcon(true) - try { - const res = await uploadRemoteFileInfo(remoteIcon, undefined, true) - setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' }) - } - catch (e) { - let errorMessage = 'Failed to fetch remote icon' - if (e instanceof Response) { - try { - const errorData = await e.json() - if (errorData?.code) - errorMessage = `Upload failed: ${errorData.code}` - } - catch { - // Ignore JSON parsing errors + const handleUrlBlur = useCallback( + async (urlValue: string) => { + if (data) return + if (!isValidUrl(urlValue)) return + const domain = getDomain(urlValue) + const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128` + setIsFetchingIcon(true) + try { + const res = await uploadRemoteFileInfo(remoteIcon, undefined, true) + setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' }) + } catch (e) { + let errorMessage = 'Failed to fetch remote icon' + if (e instanceof Response) { + try { + const errorData = await e.json() + if (errorData?.code) errorMessage = `Upload failed: ${errorData.code}` + } catch { + // Ignore JSON parsing errors + } + } else if (e instanceof Error) { + errorMessage = e.message } + console.error('Failed to fetch remote icon:', e) + toast.warning(errorMessage) + } finally { + setIsFetchingIcon(false) } - else if (e instanceof Error) { - errorMessage = e.message - } - console.error('Failed to fetch remote icon:', e) - toast.warning(errorMessage) - } - finally { - setIsFetchingIcon(false) - } - }, [data]) + }, + [data], + ) const resetIcon = useCallback(() => { setAppIcon(getIcon(data)) }, [data]) diff --git a/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts index 05e82e08a6dc7e..967fcbe63ec36c 100644 --- a/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts +++ b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts @@ -27,10 +27,7 @@ type BasicAppConfig = { user_input_form?: Array<Record<string, unknown>> } -export const useMCPServiceCardState = ( - appInfo: AppInfo, - triggerModeDisabled: boolean, -) => { +export const useMCPServiceCardState = (appInfo: AppInfo, triggerModeDisabled: boolean) => { const appId = appInfo.id const queryClient = useQueryClient() @@ -42,11 +39,12 @@ export const useMCPServiceCardState = ( const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageMCP = useMemo( - () => getAppACLCapabilities(appInfo.permission_keys, { - currentUserId, - resourceMaintainer: appInfo.maintainer, - workspacePermissionKeys, - }).canEdit, + () => + getAppACLCapabilities(appInfo.permission_keys, { + currentUserId, + resourceMaintainer: appInfo.maintainer, + workspacePermissionKeys, + }).canEdit, [appInfo.maintainer, appInfo.permission_keys, currentUserId, workspacePermissionKeys], ) @@ -55,7 +53,8 @@ export const useMCPServiceCardState = ( const [showMCPServerModal, setShowMCPServerModal] = useState(false) // Derived app type values - const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW + const isAdvancedApp = + appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW const isBasicApp = !isAdvancedApp const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW @@ -85,16 +84,18 @@ export const useMCPServiceCardState = ( // App state checks const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at - const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start) + const hasStartNode = currentWorkflow?.graph?.nodes?.some( + (node) => node.data.type === BlockEnum.Start, + ) const missingStartNode = isWorkflowApp && !hasStartNode const hasInsufficientPermissions = !canManageMCP - const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled + const toggleDisabled = + hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled const isMinimalState = appUnpublished || missingStartNode // Basic app input form const basicAppInputForm = useMemo(() => { - if (!isBasicApp || !basicAppConfig?.user_input_form) - return [] + if (!isBasicApp || !basicAppConfig?.user_input_form) return [] return (basicAppConfig.user_input_form as Array<Record<string, unknown>>).map((item) => { const type = Object.keys(item)[0] return { @@ -107,10 +108,11 @@ export const useMCPServiceCardState = ( // Latest params for modal const latestParams = useMemo(() => { if (isAdvancedApp) { - if (!currentWorkflow?.graph) - return [] - type StartNodeData = { type: string, variables?: Array<{ variable: string, label: string }> } - const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as { data: StartNodeData } | undefined + if (!currentWorkflow?.graph) return [] + type StartNodeData = { type: string; variables?: Array<{ variable: string; label: string }> } + const startNode = currentWorkflow?.graph.nodes.find( + (node) => node.data.type === BlockEnum.Start, + ) as { data: StartNodeData } | undefined return startNode?.data.variables || [] } return basicAppInputForm @@ -118,32 +120,33 @@ export const useMCPServiceCardState = ( // Handlers const handleGenCode = useCallback(async () => { - if (!canManageMCP) - return + if (!canManageMCP) return await refreshMCPServerCode(detail?.id || '') invalidateMCPServerDetail(appId) }, [canManageMCP, refreshMCPServerCode, detail?.id, invalidateMCPServerDetail, appId]) - const handleStatusChange = useCallback(async (state: boolean) => { - if (!canManageMCP) - return { activated: false } + const handleStatusChange = useCallback( + async (state: boolean) => { + if (!canManageMCP) return { activated: false } - if (state && !serverPublished) { - setShowMCPServerModal(true) - return { activated: false } - } + if (state && !serverPublished) { + setShowMCPServerModal(true) + return { activated: false } + } - await updateMCPServer({ - appID: appId, - id: id || '', - description: detail?.description || '', - parameters: detail?.parameters || {}, - status: state ? 'active' : 'inactive', - }) - invalidateMCPServerDetail(appId) - return { activated: state } - }, [canManageMCP, serverPublished, updateMCPServer, appId, id, detail, invalidateMCPServerDetail]) + await updateMCPServer({ + appID: appId, + id: id || '', + description: detail?.description || '', + parameters: detail?.parameters || {}, + status: state ? 'active' : 'inactive', + }) + invalidateMCPServerDetail(appId) + return { activated: state } + }, + [canManageMCP, serverPublished, updateMCPServer, appId, id, detail, invalidateMCPServerDetail], + ) const handleServerModalHide = useCallback((wasActivated: boolean) => { setShowMCPServerModal(false) @@ -152,15 +155,13 @@ export const useMCPServiceCardState = ( }, []) const openConfirmDelete = useCallback(() => { - if (!canManageMCP) - return + if (!canManageMCP) return setShowConfirmDelete(true) }, [canManageMCP]) const closeConfirmDelete = useCallback(() => setShowConfirmDelete(false), []) const openServerModal = useCallback(() => { - if (!canManageMCP) - return + if (!canManageMCP) return setShowMCPServerModal(true) }, [canManageMCP]) diff --git a/web/app/components/tools/mcp/index.tsx b/web/app/components/tools/mcp/index.tsx index ea90a1e7bfd382..238fb57158eed5 100644 --- a/web/app/components/tools/mcp/index.tsx +++ b/web/app/components/tools/mcp/index.tsx @@ -5,9 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { useEffect, useMemo, useState } from 'react' import { useCanManageMCP } from '@/app/components/tools/hooks/use-tool-permissions' import ToolCardSkeletonGrid from '@/app/components/tools/provider/tool-card-skeleton' -import { - useAllToolProviders, -} from '@/service/use-tools' +import { useAllToolProviders } from '@/service/use-tools' import { toolsContentInsetClassNames, toolsUnifiedContentFrameClassName } from '../content-inset' import NewMCPCard from './create-card' import MCPDetailPanel from './detail/provider-detail' @@ -34,10 +32,11 @@ const MCPList = ({ const filteredList = useMemo(() => { return list.filter((collection) => { - if (collection.type !== 'mcp') - return false + if (collection.type !== 'mcp') return false if (searchText) - return Object.values(collection.name).some(value => (value as string).toLowerCase().includes(searchText.toLowerCase())) + return Object.values(collection.name).some((value) => + (value as string).toLowerCase().includes(searchText.toLowerCase()), + ) return true }) as ToolWithProvider[] }, [list, searchText]) @@ -45,12 +44,11 @@ const MCPList = ({ const [currentProviderID, setCurrentProviderID] = useState<string>() const currentProvider = useMemo(() => { - return list.find(provider => provider.id === currentProviderID) + return list.find((provider) => provider.id === currentProviderID) }, [list, currentProviderID]) const handleCreate = async (provider: ToolWithProvider) => { - if (!canManageMCP) - return + if (!canManageMCP) return await refetch() // update list setCurrentProviderID(provider.id) @@ -58,23 +56,19 @@ const MCPList = ({ } useEffect(() => { - if (!canManageMCP || !createdProviderId) - return + if (!canManageMCP || !createdProviderId) return let isActive = true const openCreatedProvider = async () => { try { await refetch() - if (!isActive) - return + if (!isActive) return setCurrentProviderID(createdProviderId) setIsTriggerAuthorize(true) - } - finally { - if (isActive) - onCreatedProviderHandled?.() + } finally { + if (isActive) onCreatedProviderHandled?.() } } @@ -86,8 +80,7 @@ const MCPList = ({ }, [canManageMCP, createdProviderId, onCreatedProviderHandled, refetch]) const handleUpdate = async (providerID: string) => { - if (!canManageMCP) - return + if (!canManageMCP) return await refetch() // update list setCurrentProviderID(providerID) @@ -105,18 +98,20 @@ const MCPList = ({ )} > {!isLoading && canManageMCP && showCreateCard && <NewMCPCard handleCreate={handleCreate} />} - {isLoading - ? <ToolCardSkeletonGrid variant="mcp" /> - : filteredList.map(provider => ( - <MCPCard - key={provider.id} - data={provider} - currentProvider={currentProvider as ToolWithProvider} - handleSelect={setCurrentProviderID} - onUpdate={handleUpdate} - onDeleted={refetch} - /> - ))} + {isLoading ? ( + <ToolCardSkeletonGrid variant="mcp" /> + ) : ( + filteredList.map((provider) => ( + <MCPCard + key={provider.id} + data={provider} + currentProvider={currentProvider as ToolWithProvider} + handleSelect={setCurrentProviderID} + onUpdate={handleUpdate} + onDeleted={refetch} + /> + )) + )} </div> {currentProvider && ( <MCPDetailPanel diff --git a/web/app/components/tools/mcp/mcp-server-modal.tsx b/web/app/components/tools/mcp/mcp-server-modal.tsx index 96269764cdc8be..74fc666337adf4 100644 --- a/web/app/components/tools/mcp/mcp-server-modal.tsx +++ b/web/app/components/tools/mcp/mcp-server-modal.tsx @@ -1,7 +1,5 @@ 'use client' -import type { - MCPServerDetail, -} from '@/app/components/tools/types' +import type { MCPServerDetail } from '@/app/components/tools/types' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Textarea } from '@langgenius/dify-ui/textarea' @@ -34,14 +32,7 @@ type MCPServerParam = { type?: string } -const MCPServerModal = ({ - appID, - latestParams = [], - data, - show, - onHide, - appInfo, -}: ModalProps) => { +const MCPServerModal = ({ appID, latestParams = [], data, show, onHide, appInfo }: ModalProps) => { const { t } = useTranslation() const { mutateAsync: createMCPServer, isPending: creating } = useCreateMCPServer() const { mutateAsync: updateMCPServer, isPending: updating } = useUpdateMCPServer() @@ -52,7 +43,7 @@ const MCPServerModal = ({ const [params, setParams] = React.useState<Record<string, string>>(data?.parameters || {}) const handleParamChange = (variable: string, value: string) => { - setParams(prev => ({ + setParams((prev) => ({ ...prev, [variable]: value, })) @@ -61,20 +52,17 @@ const MCPServerModal = ({ const getParamValue = () => { const res: Record<string, string> = {} latestParams.forEach((param) => { - if (!param.variable) - return + if (!param.variable) return const value = params[param.variable] - if (value !== undefined) - res[param.variable] = value + if (value !== undefined) res[param.variable] = value }) return res } const emitMcpServerUpdate = (action: 'created' | 'updated') => { const socket = webSocketClient.getSocket(appID) - if (!socket) - return + if (!socket) return const timestamp = Date.now() socket.emit('collaboration_event', { @@ -98,15 +86,13 @@ const MCPServerModal = ({ parameters: getParamValue(), } - if (description.trim()) - payload.description = description + if (description.trim()) payload.description = description await createMCPServer(payload) invalidateMCPServerDetail(appID) emitMcpServerUpdate('created') onHide() - } - else { + } else { const payload: { appID: string id: string @@ -130,49 +116,57 @@ const MCPServerModal = ({ <Dialog open={show} onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-[520px]! flex-col overflow-hidden! border-none p-0! text-left align-middle transition-all duration-100 ease-in"> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-5 right-5 z-10 cursor-pointer border-none bg-transparent p-1.5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onHide} > <RiCloseLine className="size-5 text-text-tertiary" aria-hidden="true" /> </button> <div className="relative shrink-0 p-6 pr-12 pb-3 title-2xl-semi-bold text-xl wrap-break-word text-text-primary"> - {!data ? t($ => $['mcp.server.modal.addTitle'], { ns: 'tools' }) : t($ => $['mcp.server.modal.editTitle'], { ns: 'tools' })} + {!data + ? t(($) => $['mcp.server.modal.addTitle'], { ns: 'tools' }) + : t(($) => $['mcp.server.modal.editTitle'], { ns: 'tools' })} </div> <div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto"> <div className="min-w-0 space-y-5 px-6 py-3"> <div className="space-y-0.5"> <div className="flex h-6 items-center gap-1"> - <div className="system-sm-medium text-text-secondary">{t($ => $['mcp.server.modal.description'], { ns: 'tools' })}</div> + <div className="system-sm-medium text-text-secondary"> + {t(($) => $['mcp.server.modal.description'], { ns: 'tools' })} + </div> <div className="system-xs-regular text-text-destructive-secondary">*</div> </div> <Textarea - aria-label={t($ => $['mcp.server.modal.description'], { ns: 'tools' })} + aria-label={t(($) => $['mcp.server.modal.description'], { ns: 'tools' })} className="h-[96px] resize-none" value={description} - placeholder={t($ => $['mcp.server.modal.descriptionPlaceholder'], { ns: 'tools' })} - onValueChange={value => setDescription(value)} + placeholder={t(($) => $['mcp.server.modal.descriptionPlaceholder'], { + ns: 'tools', + })} + onValueChange={(value) => setDescription(value)} /> </div> {latestParams.length > 0 && ( <div className="min-w-0"> <div className="mb-1 flex items-center gap-2"> - <div className="shrink-0 system-xs-medium-uppercase text-text-primary">{t($ => $['mcp.server.modal.parameters'], { ns: 'tools' })}</div> + <div className="shrink-0 system-xs-medium-uppercase text-text-primary"> + {t(($) => $['mcp.server.modal.parameters'], { ns: 'tools' })} + </div> <Divider type="horizontal" className="m-0! h-px! grow bg-divider-subtle" /> </div> - <div className="mb-2 body-xs-regular text-text-tertiary">{t($ => $['mcp.server.modal.parametersTip'], { ns: 'tools' })}</div> + <div className="mb-2 body-xs-regular text-text-tertiary"> + {t(($) => $['mcp.server.modal.parametersTip'], { ns: 'tools' })} + </div> <div className="min-w-0 space-y-3"> {latestParams.map((paramItem) => { - if (!paramItem.variable) - return null + if (!paramItem.variable) return null const { variable } = paramItem @@ -181,7 +175,7 @@ const MCPServerModal = ({ key={variable} data={paramItem} value={params[variable] || ''} - onChange={value => handleParamChange(variable, value)} + onChange={(value) => handleParamChange(variable, value)} /> ) })} @@ -191,8 +185,16 @@ const MCPServerModal = ({ </div> </div> <div className="flex shrink-0 flex-row-reverse flex-wrap gap-2 p-6 pt-5"> - <Button disabled={!description || creating || updating} variant="primary" onClick={submit}>{data ? t($ => $['mcp.modal.save'], { ns: 'tools' }) : t($ => $['mcp.server.modal.confirm'], { ns: 'tools' })}</Button> - <Button onClick={onHide}>{t($ => $['mcp.modal.cancel'], { ns: 'tools' })}</Button> + <Button + disabled={!description || creating || updating} + variant="primary" + onClick={submit} + > + {data + ? t(($) => $['mcp.modal.save'], { ns: 'tools' }) + : t(($) => $['mcp.server.modal.confirm'], { ns: 'tools' })} + </Button> + <Button onClick={onHide}>{t(($) => $['mcp.modal.cancel'], { ns: 'tools' })}</Button> </div> </DialogContent> </Dialog> diff --git a/web/app/components/tools/mcp/mcp-server-param-item.tsx b/web/app/components/tools/mcp/mcp-server-param-item.tsx index 7267cb7e291ce0..50cb8a584995a4 100644 --- a/web/app/components/tools/mcp/mcp-server-param-item.tsx +++ b/web/app/components/tools/mcp/mcp-server-param-item.tsx @@ -9,27 +9,29 @@ type Props = Readonly<{ onChange: (value: string) => void }> -const MCPServerParamItem = ({ - data, - value, - onChange, -}: Props) => { +const MCPServerParamItem = ({ data, value, onChange }: Props) => { const { t } = useTranslation() return ( <div className="min-w-0 space-y-0.5"> <div className="flex min-h-6 min-w-0 flex-wrap items-center gap-2"> - <div className="max-w-full min-w-0 system-xs-medium wrap-break-word text-text-secondary">{data.label}</div> + <div className="max-w-full min-w-0 system-xs-medium wrap-break-word text-text-secondary"> + {data.label} + </div> <div className="system-xs-medium text-text-quaternary">·</div> - <div className="max-w-full min-w-0 system-xs-medium break-all text-text-secondary">{data.variable}</div> - <div className="max-w-full min-w-0 system-xs-medium wrap-break-word text-text-tertiary">{data.type}</div> + <div className="max-w-full min-w-0 system-xs-medium break-all text-text-secondary"> + {data.variable} + </div> + <div className="max-w-full min-w-0 system-xs-medium wrap-break-word text-text-tertiary"> + {data.type} + </div> </div> <Textarea aria-label={data.label} className="h-8 resize-none" value={value} - placeholder={t($ => $['mcp.server.modal.parametersPlaceholder'], { ns: 'tools' })} - onValueChange={value => onChange(value)} + placeholder={t(($) => $['mcp.server.modal.parametersPlaceholder'], { ns: 'tools' })} + onValueChange={(value) => onChange(value)} /> </div> ) diff --git a/web/app/components/tools/mcp/mcp-service-card.tsx b/web/app/components/tools/mcp/mcp-service-card.tsx index 6a57b7793723ec..8e33d0f423bace 100644 --- a/web/app/components/tools/mcp/mcp-service-card.tsx +++ b/web/app/components/tools/mcp/mcp-service-card.tsx @@ -40,10 +40,15 @@ const StatusIndicator: FC<StatusIndicatorProps> = ({ serverActivated }) => { return ( <div className="flex items-center gap-1"> <StatusDot status={serverActivated ? 'success' : 'warning'} /> - <div className={cn('system-xs-semibold-uppercase', serverActivated ? 'text-text-success' : 'text-text-warning')}> + <div + className={cn( + 'system-xs-semibold-uppercase', + serverActivated ? 'text-text-success' : 'text-text-warning', + )} + > {serverActivated - ? t($ => $['overview.status.running'], { ns: 'appOverview' }) - : t($ => $['overview.status.disable'], { ns: 'appOverview' })} + ? t(($) => $['overview.status.running'], { ns: 'appOverview' }) + : t(($) => $['overview.status.disable'], { ns: 'appOverview' })} </div> </div> ) @@ -68,13 +73,11 @@ const ServerURLSection: FC<ServerURLSectionProps> = ({ return ( <div className="flex flex-col items-start justify-center self-stretch"> <div className="pb-1 system-xs-medium text-text-tertiary"> - {t($ => $['mcp.server.url'], { ns: 'tools' })} + {t(($) => $['mcp.server.url'], { ns: 'tools' })} </div> <div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2"> <div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1"> - <div className="truncate text-xs font-medium text-text-secondary"> - {serverURL} - </div> + <div className="truncate text-xs font-medium text-text-secondary">{serverURL}</div> </div> {serverPublished && ( <> @@ -82,7 +85,7 @@ const ServerURLSection: FC<ServerURLSectionProps> = ({ <Divider type="vertical" className="mx-0.5! h-3.5! shrink-0" /> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" className={cn( @@ -91,16 +94,24 @@ const ServerURLSection: FC<ServerURLSectionProps> = ({ ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed', )} - aria-label={t($ => $['overview.appInfo.regenerate'], { ns: 'appOverview' }) || ''} + aria-label={ + t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' }) || '' + } disabled={!canManageMCP} onClick={onRegenerate} > - <span className={cn('i-ri-loop-left-line', 'size-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} /> + <span + className={cn( + 'i-ri-loop-left-line', + 'size-4 text-text-tertiary hover:text-text-secondary', + genLoading && 'animate-spin', + )} + /> </button> - )} + } /> <TooltipContent> - {t($ => $['overview.appInfo.regenerate'], { ns: 'appOverview' })} + {t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })} </TooltipContent> </Tooltip> </> @@ -121,7 +132,12 @@ const TriggerModeOverlay: FC<TriggerModeOverlayProps> = ({ triggerModeMessage }) <PopoverTrigger openOnHover aria-label={typeof triggerModeMessage === 'string' ? triggerModeMessage : 'Disabled'} - render={<button type="button" className="absolute inset-0 z-10 cursor-not-allowed rounded-xl outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-hover" />} + render={ + <button + type="button" + className="absolute inset-0 z-10 cursor-not-allowed rounded-xl outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-hover" + /> + } /> <PopoverContent placement="right" @@ -132,7 +148,9 @@ const TriggerModeOverlay: FC<TriggerModeOverlayProps> = ({ triggerModeMessage }) </Popover> ) } - return <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div> + return ( + <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div> + ) } // Helper function for tooltip content @@ -153,24 +171,22 @@ function getTooltipContent({ t, docLink, }: TooltipContentParams): ReactNode { - if (!toggleDisabled) - return '' + if (!toggleDisabled) return '' - if (appUnpublished) - return t($ => $['mcp.server.publishTip'], { ns: 'tools' }) + if (appUnpublished) return t(($) => $['mcp.server.publishTip'], { ns: 'tools' }) if (missingStartNode) { return ( <> <div className="mb-1 text-xs font-normal text-text-secondary"> - {t($ => $['overview.appInfo.enableTooltip.description'], { ns: 'appOverview' })} + {t(($) => $['overview.appInfo.enableTooltip.description'], { ns: 'appOverview' })} </div> <button type="button" className="cursor-pointer rounded-sm text-xs font-normal text-text-accent outline-hidden hover:underline focus-visible:ring-1 focus-visible:ring-components-input-border-hover" onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')} > - {t($ => $['overview.appInfo.enableTooltip.learnMore'], { ns: 'appOverview' })} + {t(($) => $['overview.appInfo.enableTooltip.learnMore'], { ns: 'appOverview' })} </button> </> ) @@ -230,10 +246,10 @@ const MCPServiceCard: FC<IAppCardProps> = ({ const emitMcpServerUpdate = async (data: Record<string, unknown>) => { try { - const { webSocketClient } = await import('@/app/components/workflow/collaboration/core/websocket-manager') + const { webSocketClient } = + await import('@/app/components/workflow/collaboration/core/websocket-manager') const socket = webSocketClient.getSocket(appId) - if (!socket) - return + if (!socket) return const timestamp = Date.now() socket.emit('collaboration_event', { @@ -244,8 +260,7 @@ const MCPServiceCard: FC<IAppCardProps> = ({ }, timestamp, }) - } - catch (error) { + } catch (error) { console.error('MCP collaboration event emit failed:', error) } } @@ -258,8 +273,7 @@ const MCPServiceCard: FC<IAppCardProps> = ({ setPendingStatus(null) } - if (result.activated !== state) - return + if (result.activated !== state) return // Emit collaboration event to notify other clients of MCP server status change void emitMcpServerUpdate({ @@ -289,14 +303,12 @@ const MCPServiceCard: FC<IAppCardProps> = ({ // Listen for collaborative MCP server updates from other clients useEffect(() => { - if (!appId) - return + if (!appId) return const unsubscribe = collaborationManager.onMcpServerUpdate((_update: CollaborationUpdate) => { try { invalidateMCPServerDetailRef.current(appId) - } - catch (error) { + } catch (error) { console.error('MCP server update failed:', error) } }) @@ -304,8 +316,7 @@ const MCPServiceCard: FC<IAppCardProps> = ({ return unsubscribe }, [appId]) - if (isLoading) - return null + if (isLoading) return null const tooltipContent = getTooltipContent({ toggleDisabled, @@ -318,12 +329,25 @@ const MCPServiceCard: FC<IAppCardProps> = ({ return ( <> - <div className={cn('w-full max-w-full rounded-xl border-t border-l-[0.5px] border-effects-highlight', isMinimalState && 'h-12')}> - <div className={cn('relative rounded-xl bg-background-default', triggerModeDisabled && 'opacity-60')}> - {triggerModeDisabled && ( - <TriggerModeOverlay triggerModeMessage={triggerModeMessage} /> + <div + className={cn( + 'w-full max-w-full rounded-xl border-t border-l-[0.5px] border-effects-highlight', + isMinimalState && 'h-12', + )} + > + <div + className={cn( + 'relative rounded-xl bg-background-default', + triggerModeDisabled && 'opacity-60', )} - <div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}> + > + {triggerModeDisabled && <TriggerModeOverlay triggerModeMessage={triggerModeMessage} />} + <div + className={cn( + 'flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', + isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle', + )} + > <div className="flex w-full items-center gap-3 self-stretch"> <div className="flex grow items-center"> <div className="mr-2 shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md"> @@ -331,36 +355,48 @@ const MCPServiceCard: FC<IAppCardProps> = ({ </div> <div className="group w-full"> <div className="min-w-0 overflow-hidden system-md-semibold break-normal text-ellipsis text-text-secondary group-hover:text-text-primary"> - {t($ => $['mcp.server.title'], { ns: 'tools' })} + {t(($) => $['mcp.server.title'], { ns: 'tools' })} </div> </div> </div> <StatusIndicator serverActivated={serverActivated} /> - {toggleDisabled && tooltipContent - ? ( - <Popover> - <PopoverTrigger - openOnHover - nativeButton={false} - aria-label={typeof tooltipContent === 'string' ? tooltipContent : t($ => $['overview.appInfo.enableTooltip.description'], { ns: 'appOverview' })} - render={( - <div> - <Switch checked={activated} onCheckedChange={onChangeStatus} disabled={toggleDisabled} /> - </div> - )} - /> - <PopoverContent - placement="right" - sideOffset={24} - popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg" - > - {tooltipContent} - </PopoverContent> - </Popover> - ) - : ( - <Switch checked={activated} onCheckedChange={onChangeStatus} disabled={toggleDisabled} /> - )} + {toggleDisabled && tooltipContent ? ( + <Popover> + <PopoverTrigger + openOnHover + nativeButton={false} + aria-label={ + typeof tooltipContent === 'string' + ? tooltipContent + : t(($) => $['overview.appInfo.enableTooltip.description'], { + ns: 'appOverview', + }) + } + render={ + <div> + <Switch + checked={activated} + onCheckedChange={onChangeStatus} + disabled={toggleDisabled} + /> + </div> + } + /> + <PopoverContent + placement="right" + sideOffset={24} + popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg" + > + {tooltipContent} + </PopoverContent> + </Popover> + ) : ( + <Switch + checked={activated} + onCheckedChange={onChangeStatus} + disabled={toggleDisabled} + /> + )} </div> {!isMinimalState && ( <ServerURLSection @@ -383,7 +419,9 @@ const MCPServiceCard: FC<IAppCardProps> = ({ <div className="flex items-center justify-center gap-px"> <span className="i-ri-edit-line size-3.5" /> <div className="px-[3px] system-xs-medium text-text-tertiary"> - {serverPublished ? t($ => $['mcp.server.edit'], { ns: 'tools' }) : t($ => $['mcp.server.addDescription'], { ns: 'tools' })} + {serverPublished + ? t(($) => $['mcp.server.edit'], { ns: 'tools' }) + : t(($) => $['mcp.server.addDescription'], { ns: 'tools' })} </div> </div> </Button> @@ -403,20 +441,22 @@ const MCPServiceCard: FC<IAppCardProps> = ({ /> )} - <AlertDialog open={showConfirmDelete} onOpenChange={open => !open && closeConfirmDelete()}> + <AlertDialog open={showConfirmDelete} onOpenChange={(open) => !open && closeConfirmDelete()}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['overview.appInfo.regenerate'], { ns: 'appOverview' })} + {t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['mcp.server.reGen'], { ns: 'tools' })} + {t(($) => $['mcp.server.reGen'], { ns: 'tools' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={onConfirmRegenerate}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx index d20c67ea768dfd..9ee1cffe80135e 100644 --- a/web/app/components/tools/mcp/modal.tsx +++ b/web/app/components/tools/mcp/modal.tsx @@ -29,7 +29,7 @@ import HeadersSection from './sections/headers-section' // no refresh model and no token endpoint, so the enterprise side returns the // disabled stub for it. const MCP_FORWARDING_CAPABLE_PROTOCOLS = ['oidc', 'oauth2'] as const -type MCPForwardingCapableProtocol = typeof MCP_FORWARDING_CAPABLE_PROTOCOLS[number] +type MCPForwardingCapableProtocol = (typeof MCP_FORWARDING_CAPABLE_PROTOCOLS)[number] type MCPModalConfirmPayload = { name: string @@ -65,51 +65,51 @@ type MCPModalContentProps = { onHide: () => void } -const MCPModalContent: FC<MCPModalContentProps> = ({ - data, - onConfirm, - onHide, -}) => { +const MCPModalContent: FC<MCPModalContentProps> = ({ data, onConfirm, onHide }) => { const { t } = useTranslation() - const { - isCreate, - originalServerUrl, - originalServerID, - appIconRef, - state, - actions, - } = useMCPModalForm(data) + const { isCreate, originalServerUrl, originalServerID, appIconRef, state, actions } = + useMCPModalForm(data) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) // SAML has no refresh_token model, so the enterprise side can't mint // per-call MCP tokens. Only OIDC and OAuth2 can — gate the toggle on // both "SSO enforced" AND "protocol is refresh-capable". - const ssoProtocol = systemFeatures.sso_enforced_for_signin_protocol as MCPForwardingCapableProtocol - const isForwardIdentitySupported = systemFeatures.sso_enforced_for_signin && MCP_FORWARDING_CAPABLE_PROTOCOLS.includes(ssoProtocol) + const ssoProtocol = + systemFeatures.sso_enforced_for_signin_protocol as MCPForwardingCapableProtocol + const isForwardIdentitySupported = + systemFeatures.sso_enforced_for_signin && MCP_FORWARDING_CAPABLE_PROTOCOLS.includes(ssoProtocol) const isHovering = useHover(appIconRef) const authMethods = [ - { text: t($ => $['mcp.modal.authentication'], { ns: 'tools' }), value: MCPAuthMethod.authentication }, - { text: t($ => $['mcp.modal.headers'], { ns: 'tools' }), value: MCPAuthMethod.headers }, - { text: t($ => $['mcp.modal.configurations'], { ns: 'tools' }), value: MCPAuthMethod.configurations }, + { + text: t(($) => $['mcp.modal.authentication'], { ns: 'tools' }), + value: MCPAuthMethod.authentication, + }, + { text: t(($) => $['mcp.modal.headers'], { ns: 'tools' }), value: MCPAuthMethod.headers }, + { + text: t(($) => $['mcp.modal.configurations'], { ns: 'tools' }), + value: MCPAuthMethod.configurations, + }, ] const submit = async () => { if (!isValidUrl(state.url)) { - toast.error(t($ => $['mcp.modal.invalidServerUrl'], { ns: 'tools' })) + toast.error(t(($) => $['mcp.modal.invalidServerUrl'], { ns: 'tools' })) return } if (!isValidServerID(state.serverIdentifier.trim())) { - toast.error(t($ => $['mcp.modal.invalidServerIdentifier'], { ns: 'tools' })) + toast.error(t(($) => $['mcp.modal.invalidServerIdentifier'], { ns: 'tools' })) return } - const formattedHeaders = state.headers.reduce((acc, item) => { - if (item.key.trim()) - acc[item.key.trim()] = item.value - return acc - }, {} as Record<string, string>) + const formattedHeaders = state.headers.reduce( + (acc, item) => { + if (item.key.trim()) acc[item.key.trim()] = item.value + return acc + }, + {} as Record<string, string>, + ) await onConfirm({ server_url: originalServerUrl === state.url ? '[__HIDDEN__]' : state.url.trim(), @@ -132,45 +132,51 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ // longer available so a stale row can't keep forwarding configured. identity_mode: state.forwardUserIdentity && isForwardIdentitySupported ? 'idp_token' : 'off', }) - if (isCreate) - onHide() + if (isCreate) onHide() } const handleIconSelect = (payload: AppIconSelection) => { actions.setAppIcon(payload) } - const isSubmitDisabled = !state.name || !state.url || !state.serverIdentifier || state.isFetchingIcon + const isSubmitDisabled = + !state.name || !state.url || !state.serverIdentifier || state.isFetchingIcon return ( <> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-5 right-5 z-10 cursor-pointer border-none bg-transparent p-1.5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onHide} > <RiCloseLine className="size-5 text-text-tertiary" aria-hidden="true" /> </button> <div className="relative pb-3 title-2xl-semi-bold text-xl text-text-primary"> - {!isCreate ? t($ => $['mcp.modal.editTitle'], { ns: 'tools' }) : t($ => $['mcp.modal.title'], { ns: 'tools' })} + {!isCreate + ? t(($) => $['mcp.modal.editTitle'], { ns: 'tools' }) + : t(($) => $['mcp.modal.title'], { ns: 'tools' })} </div> <div className="space-y-5 py-3"> {/* Server URL */} <div> <div className="mb-1 flex h-6 items-center"> - <span className="system-sm-medium text-text-secondary">{t($ => $['mcp.modal.serverUrl'], { ns: 'tools' })}</span> + <span className="system-sm-medium text-text-secondary"> + {t(($) => $['mcp.modal.serverUrl'], { ns: 'tools' })} + </span> </div> <Input value={state.url} - onChange={e => actions.setUrl(e.target.value)} - onBlur={e => actions.handleUrlBlur(e.target.value.trim())} - placeholder={t($ => $['mcp.modal.serverUrlPlaceholder'], { ns: 'tools' })} + onChange={(e) => actions.setUrl(e.target.value)} + onBlur={(e) => actions.handleUrlBlur(e.target.value.trim())} + placeholder={t(($) => $['mcp.modal.serverUrlPlaceholder'], { ns: 'tools' })} /> {originalServerUrl && originalServerUrl !== state.url && ( <div className="mt-1 flex h-5 items-center"> - <span className="body-xs-regular text-text-warning">{t($ => $['mcp.modal.serverUrlWarning'], { ns: 'tools' })}</span> + <span className="body-xs-regular text-text-warning"> + {t(($) => $['mcp.modal.serverUrlWarning'], { ns: 'tools' })} + </span> </div> )} </div> @@ -179,12 +185,14 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ <div className="flex space-x-3"> <div className="grow pb-1"> <div className="mb-1 flex h-6 items-center"> - <span className="system-sm-medium text-text-secondary">{t($ => $['mcp.modal.name'], { ns: 'tools' })}</span> + <span className="system-sm-medium text-text-secondary"> + {t(($) => $['mcp.modal.name'], { ns: 'tools' })} + </span> </div> <Input value={state.name} - onChange={e => actions.setName(e.target.value)} - placeholder={t($ => $['mcp.modal.namePlaceholder'], { ns: 'tools' })} + onChange={(e) => actions.setName(e.target.value)} + placeholder={t(($) => $['mcp.modal.namePlaceholder'], { ns: 'tools' })} /> </div> <div className="pt-2" ref={appIconRef}> @@ -193,17 +201,22 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ icon={state.appIcon.type === 'emoji' ? state.appIcon.icon : state.appIcon.fileId} background={state.appIcon.type === 'emoji' ? state.appIcon.background : undefined} imageUrl={state.appIcon.type === 'image' ? state.appIcon.url : undefined} - innerIcon={shouldUseMcpIconForAppIcon(state.appIcon.type, state.appIcon.type === 'emoji' ? state.appIcon.icon : '') ? <Mcp className="size-8 text-text-primary-on-surface" /> : undefined} + innerIcon={ + shouldUseMcpIconForAppIcon( + state.appIcon.type, + state.appIcon.type === 'emoji' ? state.appIcon.icon : '', + ) ? ( + <Mcp className="size-8 text-text-primary-on-surface" /> + ) : undefined + } size="xxl" className="relative cursor-pointer rounded-2xl" coverElement={ - isHovering - ? ( - <div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt"> - <RiEditLine className="size-6 text-text-primary-on-surface" /> - </div> - ) - : null + isHovering ? ( + <div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt"> + <RiEditLine className="size-6 text-text-primary-on-surface" /> + </div> + ) : null } onClick={() => actions.setShowAppIconPicker(true)} /> @@ -213,17 +226,23 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ {/* Server Identifier */} <div> <div className="flex h-6 items-center"> - <span className="system-sm-medium text-text-secondary">{t($ => $['mcp.modal.serverIdentifier'], { ns: 'tools' })}</span> + <span className="system-sm-medium text-text-secondary"> + {t(($) => $['mcp.modal.serverIdentifier'], { ns: 'tools' })} + </span> + </div> + <div className="mb-1 body-xs-regular text-text-tertiary"> + {t(($) => $['mcp.modal.serverIdentifierTip'], { ns: 'tools' })} </div> - <div className="mb-1 body-xs-regular text-text-tertiary">{t($ => $['mcp.modal.serverIdentifierTip'], { ns: 'tools' })}</div> <Input value={state.serverIdentifier} - onChange={e => actions.setServerIdentifier(e.target.value)} - placeholder={t($ => $['mcp.modal.serverIdentifierPlaceholder'], { ns: 'tools' })} + onChange={(e) => actions.setServerIdentifier(e.target.value)} + placeholder={t(($) => $['mcp.modal.serverIdentifierPlaceholder'], { ns: 'tools' })} /> {originalServerID && originalServerID !== state.serverIdentifier && ( <div className="mt-1 flex h-5 items-center"> - <span className="body-xs-regular text-text-warning">{t($ => $['mcp.modal.serverIdentifierWarning'], { ns: 'tools' })}</span> + <span className="body-xs-regular text-text-warning"> + {t(($) => $['mcp.modal.serverIdentifierWarning'], { ns: 'tools' })} + </span> </div> )} </div> @@ -241,11 +260,11 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ id="mcp-forward-user-identity-label" className="system-sm-medium text-text-secondary" > - {t($ => $['mcp.modal.forwardUserIdentity'], { ns: 'tools' })} + {t(($) => $['mcp.modal.forwardUserIdentity'], { ns: 'tools' })} </span> </div> <div className="body-xs-regular text-text-tertiary"> - {t($ => $['mcp.modal.forwardUserIdentityTip'], { ns: 'tools' })} + {t(($) => $['mcp.modal.forwardUserIdentityTip'], { ns: 'tools' })} </div> </div> )} @@ -255,13 +274,12 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ value={[state.authMethod]} onValueChange={(nextValue) => { const nextAuthMethod = nextValue[0] - if (nextAuthMethod) - actions.setAuthMethod(nextAuthMethod) + if (nextAuthMethod) actions.setAuthMethod(nextAuthMethod) }} - aria-label={t($ => $['mcp.modal.authentication'], { ns: 'tools' })} + aria-label={t(($) => $['mcp.modal.authentication'], { ns: 'tools' })} className="w-full" > - {authMethods.map(option => ( + {authMethods.map((option) => ( <SegmentedControlItem<MCPAuthMethod> key={option.value} value={option.value} @@ -303,17 +321,21 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ {/* Actions */} <div className="flex flex-row-reverse pt-5"> <Button disabled={isSubmitDisabled} className="ml-2" variant="primary" onClick={submit}> - {data ? t($ => $['mcp.modal.save'], { ns: 'tools' }) : t($ => $['mcp.modal.confirm'], { ns: 'tools' })} + {data + ? t(($) => $['mcp.modal.save'], { ns: 'tools' }) + : t(($) => $['mcp.modal.confirm'], { ns: 'tools' })} </Button> - <Button onClick={onHide}>{t($ => $['mcp.modal.cancel'], { ns: 'tools' })}</Button> + <Button onClick={onHide}>{t(($) => $['mcp.modal.cancel'], { ns: 'tools' })}</Button> </div> {state.showAppIconPicker && ( <AppIconPicker open={state.showAppIconPicker} - initialEmoji={state.appIcon.type === 'emoji' - ? { icon: state.appIcon.icon, background: state.appIcon.background } - : undefined} + initialEmoji={ + state.appIcon.type === 'emoji' + ? { icon: state.appIcon.icon, background: state.appIcon.background } + : undefined + } onOpenChange={actions.setShowAppIconPicker} onSelect={handleIconSelect} /> @@ -328,24 +350,14 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ * Uses a keyed inner component to ensure form state resets when switching * between create mode and edit mode with different data. */ -const MCPModal: FC<DuplicateAppModalProps> = ({ - data, - show, - onConfirm, - onHide, -}) => { +const MCPModal: FC<DuplicateAppModalProps> = ({ data, show, onConfirm, onHide }) => { // Use data ID as key to reset form state when switching between items const formKey = data?.id ?? 'create' return ( <Dialog open={show}> <DialogContent className="w-full max-w-[520px]! border-none p-6 text-left align-middle"> - <MCPModalContent - key={formKey} - data={data} - onConfirm={onConfirm} - onHide={onHide} - /> + <MCPModalContent key={formKey} data={data} onConfirm={onConfirm} onHide={onHide} /> </DialogContent> </Dialog> ) diff --git a/web/app/components/tools/mcp/provider-card.tsx b/web/app/components/tools/mcp/provider-card.tsx index daae08cabc38f4..e88280b3e35444 100644 --- a/web/app/components/tools/mcp/provider-card.tsx +++ b/web/app/components/tools/mcp/provider-card.tsx @@ -38,19 +38,13 @@ const isMCPCardActionTarget = (target: EventTarget | null) => { return target instanceof HTMLElement && Boolean(target.closest('[data-mcp-card-action]')) } -const MCPCard = ({ - currentProvider, - data, - onUpdate, - handleSelect, - onDeleted, -}: Props) => { +const MCPCard = ({ currentProvider, data, onUpdate, handleSelect, onDeleted }: Props) => { const { t } = useTranslation() const { formatTimeFromNow } = useFormatTimeFromNow() const canManageMCP = useCanManageMCP() const isConfigured = data.is_team_authorization && data.tools.length > 0 const updatedAtText = data.updated_at - ? `${t($ => $['mcp.updateTime'], { ns: 'tools' })} ${formatTimeFromNow(data.updated_at * 1000)}` + ? `${t(($) => $['mcp.updateTime'], { ns: 'tools' })} ${formatTimeFromNow(data.updated_at * 1000)}` : undefined const { mutateAsync: updateMCP } = useUpdateMCP({}) @@ -58,41 +52,35 @@ const MCPCard = ({ const [isOperationShow, setIsOperationShow] = useState(false) - const [isShowUpdateModal, { - setTrue: showUpdateModal, - setFalse: hideUpdateModal, - }] = useBoolean(false) - - const [isShowDeleteConfirm, { - setTrue: showDeleteConfirm, - setFalse: hideDeleteConfirm, - }] = useBoolean(false) - - const [deleting, { - setTrue: showDeleting, - setFalse: hideDeleting, - }] = useBoolean(false) - - const handleUpdate = useCallback(async (form: MCPModalConfirmPayload) => { - if (!canManageMCP) - return - - const res = await updateMCP({ - ...form, - provider_id: data.id, - }) as MutationResult - if (res.result === 'success') { - hideUpdateModal() - onUpdate(data.id) - } - }, [canManageMCP, data, updateMCP, hideUpdateModal, onUpdate]) + const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = + useBoolean(false) + + const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = + useBoolean(false) + + const [deleting, { setTrue: showDeleting, setFalse: hideDeleting }] = useBoolean(false) + + const handleUpdate = useCallback( + async (form: MCPModalConfirmPayload) => { + if (!canManageMCP) return + + const res = (await updateMCP({ + ...form, + provider_id: data.id, + })) as MutationResult + if (res.result === 'success') { + hideUpdateModal() + onUpdate(data.id) + } + }, + [canManageMCP, data, updateMCP, hideUpdateModal, onUpdate], + ) const handleDelete = useCallback(async () => { - if (!canManageMCP) - return + if (!canManageMCP) return showDeleting() - const res = await deleteMCP(data.id) as MutationResult + const res = (await deleteMCP(data.id)) as MutationResult hideDeleting() if (res.result === 'success') { hideDeleteConfirm() @@ -104,22 +92,25 @@ const MCPCard = ({ handleSelect(data.id) }, [data.id, handleSelect]) - const handleCardClick = useCallback((event: MouseEvent<HTMLDivElement>) => { - if (isMCPCardActionTarget(event.target)) - return + const handleCardClick = useCallback( + (event: MouseEvent<HTMLDivElement>) => { + if (isMCPCardActionTarget(event.target)) return - handleSelectProvider() - }, [handleSelectProvider]) + handleSelectProvider() + }, + [handleSelectProvider], + ) - const handleCardKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => { - if (isMCPCardActionTarget(event.target)) - return - if (event.key !== 'Enter' && event.key !== ' ') - return + const handleCardKeyDown = useCallback( + (event: KeyboardEvent<HTMLDivElement>) => { + if (isMCPCardActionTarget(event.target)) return + if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - handleSelectProvider() - }, [handleSelectProvider]) + event.preventDefault() + handleSelectProvider() + }, + [handleSelectProvider], + ) return ( <div @@ -129,7 +120,8 @@ const MCPCard = ({ onKeyDown={handleCardKeyDown} className={cn( 'group relative flex cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', - currentProvider?.id === data.id && 'border-components-option-card-option-selected-border bg-components-panel-on-panel-item-bg-hover', + currentProvider?.id === data.id && + 'border-components-option-card-option-selected-border bg-components-panel-on-panel-item-bg-hover', )} > <div className="flex shrink-0 items-center gap-3 rounded-t-xl p-4"> @@ -137,35 +129,54 @@ const MCPCard = ({ <Icon src={data.icon} /> </div> <div className="min-w-0 grow"> - <div className="mb-1 truncate system-md-semibold text-text-secondary" title={data.name}>{data.name}</div> - <div className="truncate system-xs-regular text-text-tertiary" title={data.server_identifier}>{data.server_identifier}</div> + <div className="mb-1 truncate system-md-semibold text-text-secondary" title={data.name}> + {data.name} + </div> + <div + className="truncate system-xs-regular text-text-tertiary" + title={data.server_identifier} + > + {data.server_identifier} + </div> </div> </div> <div className="flex items-center gap-1 rounded-b-xl pt-1.5 pr-2.5 pb-2.5 pl-4"> <div className="flex w-0 grow items-center gap-2"> {data.tools.length > 0 && ( - <div className="shrink-0 system-xs-regular text-text-tertiary">{t($ => $['mcp.toolsCount'], { ns: 'tools', count: data.tools.length })}</div> + <div className="shrink-0 system-xs-regular text-text-tertiary"> + {t(($) => $['mcp.toolsCount'], { ns: 'tools', count: data.tools.length })} + </div> )} {!data.tools.length && ( - <div className="shrink-0 system-xs-regular text-text-tertiary">{t($ => $['mcp.noTools'], { ns: 'tools' })}</div> + <div className="shrink-0 system-xs-regular text-text-tertiary"> + {t(($) => $['mcp.noTools'], { ns: 'tools' })} + </div> )} {updatedAtText && ( <> <div className="system-xs-regular text-divider-deep">·</div> - <div className="truncate system-xs-regular text-text-tertiary" title={updatedAtText}>{updatedAtText}</div> + <div className="truncate system-xs-regular text-text-tertiary" title={updatedAtText}> + {updatedAtText} + </div> </> )} </div> {isConfigured && <StatusDot status="success" size="small" className="shrink-0" />} {!isConfigured && ( <div className="flex shrink-0 items-center gap-1 rounded-md border border-util-colors-red-red-500 bg-components-badge-bg-red-soft px-1.5 py-0.5 system-xs-medium text-util-colors-red-red-500"> - {t($ => $['mcp.noConfigured'], { ns: 'tools' })} + {t(($) => $['mcp.noConfigured'], { ns: 'tools' })} <StatusDot status="error" size="small" /> </div> )} </div> {canManageMCP && ( - <div data-mcp-card-action className={cn('absolute top-2.5 right-2.5 hidden group-hover:block', isOperationShow && 'block')}> + <div + data-mcp-card-action + className={cn( + 'absolute top-2.5 right-2.5 hidden group-hover:block', + isOperationShow && 'block', + )} + > <OperationDropdown inCard onOpenChange={setIsOperationShow} @@ -182,20 +193,25 @@ const MCPCard = ({ onHide={hideUpdateModal} /> )} - <AlertDialog open={canManageMCP && isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}> + <AlertDialog + open={canManageMCP && isShowDeleteConfirm} + onOpenChange={(open) => !open && hideDeleteConfirm()} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['mcp.delete'], { ns: 'tools' })} + {t(($) => $['mcp.delete'], { ns: 'tools' })} </AlertDialogTitle> <div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['mcp.deleteConfirmTitle'], { ns: 'tools', mcp: data.name })} + {t(($) => $['mcp.deleteConfirmTitle'], { ns: 'tools', mcp: data.name })} </div> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={handleDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/tools/mcp/sections/__tests__/configurations-section.spec.tsx b/web/app/components/tools/mcp/sections/__tests__/configurations-section.spec.tsx index e48d4b87360133..165a701e9e7e35 100644 --- a/web/app/components/tools/mcp/sections/__tests__/configurations-section.spec.tsx +++ b/web/app/components/tools/mcp/sections/__tests__/configurations-section.spec.tsx @@ -12,8 +12,7 @@ describe('ConfigurationsSection', () => { const getVisibleNumberInputs = () => screen.getAllByRole('textbox') const getVisibleNumberInput = (index: number) => { const input = getVisibleNumberInputs()[index] - if (!input) - throw new Error(`Expected number input at index ${index}`) + if (!input) throw new Error(`Expected number input at index ${index}`) return input } const getTimeoutInput = () => getVisibleNumberInput(0) @@ -69,7 +68,9 @@ describe('ConfigurationsSection', () => { it('should call onSseReadTimeoutChange when SSE timeout input changes', () => { const onSseReadTimeoutChange = vi.fn() - render(<ConfigurationsSection {...defaultProps} onSseReadTimeoutChange={onSseReadTimeoutChange} />) + render( + <ConfigurationsSection {...defaultProps} onSseReadTimeoutChange={onSseReadTimeoutChange} />, + ) const sseInput = getSseReadTimeoutInput() fireEvent.change(sseInput, { target: { value: '500' } }) diff --git a/web/app/components/tools/mcp/sections/__tests__/headers-section.spec.tsx b/web/app/components/tools/mcp/sections/__tests__/headers-section.spec.tsx index aeec35385e65d9..9fa718cd832ba6 100644 --- a/web/app/components/tools/mcp/sections/__tests__/headers-section.spec.tsx +++ b/web/app/components/tools/mcp/sections/__tests__/headers-section.spec.tsx @@ -57,24 +57,12 @@ describe('HeadersSection', () => { }) it('should show masked tip when not isCreate and has headers with content', () => { - render( - <HeadersSection - {...defaultProps} - isCreate={false} - headers={headersWithItems} - />, - ) + render(<HeadersSection {...defaultProps} isCreate={false} headers={headersWithItems} />) expect(screen.getByText('tools.mcp.modal.maskedHeadersTip'))!.toBeInTheDocument() }) it('should not show masked tip when isCreate is true', () => { - render( - <HeadersSection - {...defaultProps} - isCreate={true} - headers={headersWithItems} - />, - ) + render(<HeadersSection {...defaultProps} isCreate={true} headers={headersWithItems} />) expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument() }) }) @@ -99,11 +87,7 @@ describe('HeadersSection', () => { const onHeadersChange = vi.fn() const headers = [{ id: '1', key: '', value: '' }] render( - <HeadersSection - {...defaultProps} - headers={headers} - onHeadersChange={onHeadersChange} - />, + <HeadersSection {...defaultProps} headers={headers} onHeadersChange={onHeadersChange} />, ) const inputs = screen.getAllByRole('textbox') @@ -117,11 +101,7 @@ describe('HeadersSection', () => { const onHeadersChange = vi.fn() const headers = [{ id: '1', key: 'X-Custom-Header', value: '' }] render( - <HeadersSection - {...defaultProps} - headers={headers} - onHeadersChange={onHeadersChange} - />, + <HeadersSection {...defaultProps} headers={headers} onHeadersChange={onHeadersChange} />, ) const inputs = screen.getAllByRole('textbox') @@ -135,11 +115,7 @@ describe('HeadersSection', () => { const onHeadersChange = vi.fn() const headers = [{ id: '1', key: 'X-Header', value: 'value' }] render( - <HeadersSection - {...defaultProps} - headers={headers} - onHeadersChange={onHeadersChange} - />, + <HeadersSection {...defaultProps} headers={headers} onHeadersChange={onHeadersChange} />, ) // Find and click the delete button diff --git a/web/app/components/tools/mcp/sections/authentication-section.tsx b/web/app/components/tools/mcp/sections/authentication-section.tsx index 4b11d23c02bf3f..c5357af87b1054 100644 --- a/web/app/components/tools/mcp/sections/authentication-section.tsx +++ b/web/app/components/tools/mcp/sections/authentication-section.tsx @@ -35,13 +35,17 @@ const AuthenticationSection: FC<AuthenticationSectionProps> = ({ checked={isDynamicRegistration} onCheckedChange={onDynamicRegistrationChange} /> - <span className="system-sm-medium text-text-secondary">{t($ => $['mcp.modal.useDynamicClientRegistration'], { ns: 'tools' })}</span> + <span className="system-sm-medium text-text-secondary"> + {t(($) => $['mcp.modal.useDynamicClientRegistration'], { ns: 'tools' })} + </span> </div> {!isDynamicRegistration && ( <div className="mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3"> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-text-warning" /> <div className="system-xs-regular text-text-secondary"> - <div className="mb-1">{t($ => $['mcp.modal.redirectUrlWarning'], { ns: 'tools' })}</div> + <div className="mb-1"> + {t(($) => $['mcp.modal.redirectUrlWarning'], { ns: 'tools' })} + </div> <code className="block rounded-sm bg-state-warning-active px-2 py-1 system-xs-medium break-all text-text-secondary"> {`${API_PREFIX}/mcp/oauth/callback`} </code> @@ -51,23 +55,27 @@ const AuthenticationSection: FC<AuthenticationSectionProps> = ({ </div> <div> <div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}> - <span className="system-sm-medium text-text-secondary">{t($ => $['mcp.modal.clientID'], { ns: 'tools' })}</span> + <span className="system-sm-medium text-text-secondary"> + {t(($) => $['mcp.modal.clientID'], { ns: 'tools' })} + </span> </div> <Input value={clientID} - onChange={e => onClientIDChange(e.target.value)} - placeholder={t($ => $['mcp.modal.clientID'], { ns: 'tools' })} + onChange={(e) => onClientIDChange(e.target.value)} + placeholder={t(($) => $['mcp.modal.clientID'], { ns: 'tools' })} disabled={isDynamicRegistration} /> </div> <div> <div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}> - <span className="system-sm-medium text-text-secondary">{t($ => $['mcp.modal.clientSecret'], { ns: 'tools' })}</span> + <span className="system-sm-medium text-text-secondary"> + {t(($) => $['mcp.modal.clientSecret'], { ns: 'tools' })} + </span> </div> <Input value={credentials} - onChange={e => onCredentialsChange(e.target.value)} - placeholder={t($ => $['mcp.modal.clientSecretPlaceholder'], { ns: 'tools' })} + onChange={(e) => onCredentialsChange(e.target.value)} + placeholder={t(($) => $['mcp.modal.clientSecretPlaceholder'], { ns: 'tools' })} disabled={isDynamicRegistration} /> </div> diff --git a/web/app/components/tools/mcp/sections/configurations-section.tsx b/web/app/components/tools/mcp/sections/configurations-section.tsx index 25cc4d2be0e3d8..1e1b62c5a8c0bf 100644 --- a/web/app/components/tools/mcp/sections/configurations-section.tsx +++ b/web/app/components/tools/mcp/sections/configurations-section.tsx @@ -22,29 +22,33 @@ const ConfigurationsSection: FC<ConfigurationsSectionProps> = ({ <> <div> <div className="mb-1 flex h-6 items-center"> - <span className="system-sm-medium text-text-secondary">{t($ => $['mcp.modal.timeout'], { ns: 'tools' })}</span> + <span className="system-sm-medium text-text-secondary"> + {t(($) => $['mcp.modal.timeout'], { ns: 'tools' })} + </span> </div> - <NumberField - value={timeout} - min={0} - onValueChange={value => onTimeoutChange(value ?? 0)} - > + <NumberField value={timeout} min={0} onValueChange={(value) => onTimeoutChange(value ?? 0)}> <NumberFieldGroup> - <NumberFieldInput placeholder={t($ => $['mcp.modal.timeoutPlaceholder'], { ns: 'tools' })} /> + <NumberFieldInput + placeholder={t(($) => $['mcp.modal.timeoutPlaceholder'], { ns: 'tools' })} + /> </NumberFieldGroup> </NumberField> </div> <div> <div className="mb-1 flex h-6 items-center"> - <span className="system-sm-medium text-text-secondary">{t($ => $['mcp.modal.sseReadTimeout'], { ns: 'tools' })}</span> + <span className="system-sm-medium text-text-secondary"> + {t(($) => $['mcp.modal.sseReadTimeout'], { ns: 'tools' })} + </span> </div> <NumberField value={sseReadTimeout} min={0} - onValueChange={value => onSseReadTimeoutChange(value ?? 0)} + onValueChange={(value) => onSseReadTimeoutChange(value ?? 0)} > <NumberFieldGroup> - <NumberFieldInput placeholder={t($ => $['mcp.modal.timeoutPlaceholder'], { ns: 'tools' })} /> + <NumberFieldInput + placeholder={t(($) => $['mcp.modal.timeoutPlaceholder'], { ns: 'tools' })} + /> </NumberFieldGroup> </NumberField> </div> diff --git a/web/app/components/tools/mcp/sections/headers-section.tsx b/web/app/components/tools/mcp/sections/headers-section.tsx index 2b75dd1a00ac14..a95e3c762b7a26 100644 --- a/web/app/components/tools/mcp/sections/headers-section.tsx +++ b/web/app/components/tools/mcp/sections/headers-section.tsx @@ -10,24 +10,24 @@ type HeadersSectionProps = { isCreate: boolean } -const HeadersSection: FC<HeadersSectionProps> = ({ - headers, - onHeadersChange, - isCreate, -}) => { +const HeadersSection: FC<HeadersSectionProps> = ({ headers, onHeadersChange, isCreate }) => { const { t } = useTranslation() return ( <div> <div className="mb-1 flex h-6 items-center"> - <span className="system-sm-medium text-text-secondary">{t($ => $['mcp.modal.headers'], { ns: 'tools' })}</span> + <span className="system-sm-medium text-text-secondary"> + {t(($) => $['mcp.modal.headers'], { ns: 'tools' })} + </span> + </div> + <div className="mb-2 body-xs-regular text-text-tertiary"> + {t(($) => $['mcp.modal.headersTip'], { ns: 'tools' })} </div> - <div className="mb-2 body-xs-regular text-text-tertiary">{t($ => $['mcp.modal.headersTip'], { ns: 'tools' })}</div> <HeadersInput headersItems={headers} onChange={onHeadersChange} readonly={false} - isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0} + isMasked={!isCreate && headers.filter((item) => item.key.trim()).length > 0} /> </div> ) diff --git a/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx b/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx index dccfdbcc26326d..de4460f842cd80 100644 --- a/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx +++ b/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx @@ -37,7 +37,8 @@ vi.mock('jotai', () => ({ // Mock useLocale and useDocLink vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', - useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`, + useDocLink: () => (path?: string) => + `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`, })) // Mock getLanguage @@ -56,7 +57,11 @@ let mockModalVisible = false // Mock EditCustomToolModal - complex component vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({ - default: ({ payload, onHide, onAdd }: { + default: ({ + payload, + onHide, + onAdd, + }: { payload: null onHide: () => void onAdd: (data: CustomCollectionBackend) => void @@ -66,7 +71,9 @@ vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({ return ( <div data-testid="edit-custom-collection-modal"> <span data-testid="modal-payload">{payload === null ? 'null' : 'not-null'}</span> - <button data-testid="close-modal" onClick={onHide}>Close</button> + <button data-testid="close-modal" onClick={onHide}> + Close + </button> <button data-testid="submit-modal" onClick={() => { @@ -145,7 +152,12 @@ describe('CustomCreateCard', () => { const card = screen.getByText('tools.createSwaggerAPIAsTool').closest('.col-span-1') expect(card).toBeInTheDocument() - expect(card).toHaveClass('h-[120px]', 'border-[0.5px]', 'border-components-panel-border', 'shadow-md') + expect(card).toHaveClass( + 'h-[120px]', + 'border-[0.5px]', + 'border-components-panel-border', + 'shadow-md', + ) expect(card).toHaveClass('min-w-0') expect(card).not.toHaveClass('flex-1') }) @@ -154,7 +166,10 @@ describe('CustomCreateCard', () => { render(<CustomCreateCard onRefreshData={mockOnRefreshData} />) const docLink = screen.getByText('tools.swaggerAPIAsToolTip').closest('a') - expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/use-dify/workspace/tools#swagger-api') + expect(docLink).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/cloud/use-dify/workspace/tools#swagger-api', + ) expect(docLink).toHaveAttribute('target', '_blank') expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') }) @@ -164,7 +179,9 @@ describe('CustomCreateCard', () => { it('should render toolbar add button when user has tool.manage', () => { render(<NewCustomToolButton onRefreshData={mockOnRefreshData} />) - expect(screen.getByRole('button', { name: /tools\.addSwaggerAPIAsTool/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /tools\.addSwaggerAPIAsTool/i }), + ).toBeInTheDocument() }) it('should not render toolbar add button when user does not have tool.manage', () => { diff --git a/web/app/components/tools/provider/__tests__/detail.spec.tsx b/web/app/components/tools/provider/__tests__/detail.spec.tsx index 849f0a74e9712d..e0f66a2e0dc0df 100644 --- a/web/app/components/tools/provider/__tests__/detail.spec.tsx +++ b/web/app/components/tools/provider/__tests__/detail.spec.tsx @@ -9,7 +9,12 @@ vi.mock('@/i18n-config/language', () => ({ })) const mockAppContextState = vi.hoisted(() => ({ - workspacePermissionKeys: ['tool.manage', 'credential.use', 'credential.create', 'credential.manage'] as string[], + workspacePermissionKeys: [ + 'tool.manage', + 'credential.use', + 'credential.create', + 'credential.manage', + ] as string[], workspacePermissionKeysAtom: Symbol('workspacePermissionKeysAtom'), })) @@ -47,9 +52,7 @@ vi.mock('@/context/modal-context', () => ({ vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ - modelProviders: [ - { provider: 'model-collection-id', name: 'TestModel' }, - ], + modelProviders: [{ provider: 'model-collection-id', name: 'TestModel' }], }), })) @@ -123,35 +126,85 @@ vi.mock('@/app/components/plugins/card/base/title', () => ({ })) vi.mock('../tool-item', () => ({ - default: ({ tool }: { tool: { name: string } }) => <div data-testid={`tool-${tool.name}`}>{tool.name}</div>, + default: ({ tool }: { tool: { name: string } }) => ( + <div data-testid={`tool-${tool.name}`}>{tool.name}</div> + ), })) vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({ - default: ({ onHide, onEdit, onRemove }: { onHide: () => void, onEdit: (data: unknown) => void, onRemove: () => void }) => ( + default: ({ + onHide, + onEdit, + onRemove, + }: { + onHide: () => void + onEdit: (data: unknown) => void + onRemove: () => void + }) => ( <div data-testid="edit-custom-modal"> - <button data-testid="edit-save" onClick={() => onEdit({ labels: ['test'] })}>Save</button> - <button data-testid="edit-remove" onClick={onRemove}>Remove</button> - <button data-testid="edit-close" onClick={onHide}>Close</button> + <button data-testid="edit-save" onClick={() => onEdit({ labels: ['test'] })}> + Save + </button> + <button data-testid="edit-remove" onClick={onRemove}> + Remove + </button> + <button data-testid="edit-close" onClick={onHide}> + Close + </button> </div> ), })) vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({ - default: ({ onCancel, onSaved, onRemove, readonly }: { onCancel: () => void, onSaved: (val: Record<string, string>) => Promise<void>, onRemove: () => Promise<void>, readonly?: boolean }) => ( + default: ({ + onCancel, + onSaved, + onRemove, + readonly, + }: { + onCancel: () => void + onSaved: (val: Record<string, string>) => Promise<void> + onRemove: () => Promise<void> + readonly?: boolean + }) => ( <div data-testid="config-credential" data-readonly={readonly ? 'true' : 'false'}> - <button data-testid="credential-save" disabled={readonly} onClick={() => onSaved({ key: 'val' })}>Save</button> - <button data-testid="credential-remove" disabled={readonly} onClick={onRemove}>Remove</button> - <button data-testid="credential-cancel" onClick={onCancel}>Cancel</button> + <button + data-testid="credential-save" + disabled={readonly} + onClick={() => onSaved({ key: 'val' })} + > + Save + </button> + <button data-testid="credential-remove" disabled={readonly} onClick={onRemove}> + Remove + </button> + <button data-testid="credential-cancel" onClick={onCancel}> + Cancel + </button> </div> ), })) vi.mock('@/app/components/tools/workflow-tool', () => ({ - WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => ( + WorkflowToolDrawer: ({ + onHide, + onSave, + onRemove, + }: { + onHide: () => void + onSave: (data: unknown) => void + onRemove: () => void + }) => ( <div data-testid="workflow-tool-drawer"> - <button data-testid="wf-save" onClick={() => onSave({ name: 'test' })}>Save</button> - <button data-testid="wf-remove" onClick={onRemove}>Remove</button> - <button data-testid="wf-close" onClick={onHide}>Close</button> + <button data-testid="wf-save" onClick={() => onSave({ name: 'test' })}> + Save + </button> + <button data-testid="wf-remove" onClick={onRemove}> + Remove + </button> + <button data-testid="wf-close" onClick={onHide}> + Close + </button> </div> ), })) @@ -171,7 +224,8 @@ const createMockCollection = (overrides?: Partial<Collection>): Collection => ({ ...overrides, }) -const getDeleteConfirmButton = () => screen.getByRole('button', { name: 'common.operation.confirm' }) +const getDeleteConfirmButton = () => + screen.getByRole('button', { name: 'common.operation.confirm' }) const getDeleteCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' }) describe('ProviderDetail', () => { @@ -181,12 +235,33 @@ describe('ProviderDetail', () => { beforeEach(() => { vi.clearAllMocks() mockFetchBuiltInToolList.mockResolvedValue([ - { name: 'tool-1', label: { en_US: 'Tool 1' }, description: { en_US: 'desc' }, parameters: [], labels: [], author: '', output_schema: {} }, - { name: 'tool-2', label: { en_US: 'Tool 2' }, description: { en_US: 'desc' }, parameters: [], labels: [], author: '', output_schema: {} }, + { + name: 'tool-1', + label: { en_US: 'Tool 1' }, + description: { en_US: 'desc' }, + parameters: [], + labels: [], + author: '', + output_schema: {}, + }, + { + name: 'tool-2', + label: { en_US: 'Tool 2' }, + description: { en_US: 'desc' }, + parameters: [], + labels: [], + author: '', + output_schema: {}, + }, ]) mockFetchCustomToolList.mockResolvedValue([]) mockFetchModelToolList.mockResolvedValue([]) - mockAppContextState.workspacePermissionKeys = ['tool.manage', 'credential.use', 'credential.create', 'credential.manage'] + mockAppContextState.workspacePermissionKeys = [ + 'tool.manage', + 'credential.use', + 'credential.create', + 'credential.manage', + ] }) afterEach(() => { @@ -205,7 +280,9 @@ describe('ProviderDetail', () => { const dialog = screen.getByRole('dialog') - expect(document.querySelector('.absolute.inset-0.z-50.bg-transparent')).not.toBeInTheDocument() + expect( + document.querySelector('.absolute.inset-0.z-50.bg-transparent'), + ).not.toBeInTheDocument() expect(dialog.closest('.pointer-events-none')).toBeInTheDocument() expect(dialog).toHaveClass( 'pointer-events-auto', @@ -350,7 +427,9 @@ describe('ProviderDetail', () => { />, ) - const configureButton = (await screen.findByText('tools.createTool.editAction')).closest('button')! + const configureButton = (await screen.findByText('tools.createTool.editAction')).closest( + 'button', + )! expect(configureButton.querySelector('.i-ri-equalizer-2-line')).toBeInTheDocument() }) @@ -358,7 +437,15 @@ describe('ProviderDetail', () => { it('renders custom tool details read-only without tool.manage', async () => { mockAppContextState.workspacePermissionKeys = [] mockFetchCustomToolList.mockResolvedValue([ - { name: 'custom-tool', label: { en_US: 'Custom Tool' }, description: { en_US: 'desc' }, parameters: [], labels: [], author: '', output_schema: {} }, + { + name: 'custom-tool', + label: { en_US: 'Custom Tool' }, + description: { en_US: 'desc' }, + parameters: [], + labels: [], + author: '', + output_schema: {}, + }, ]) render( @@ -369,7 +456,9 @@ describe('ProviderDetail', () => { />, ) - const configureButton = (await screen.findByText('tools.createTool.editAction')).closest('button')! + const configureButton = (await screen.findByText('tools.createTool.editAction')).closest( + 'button', + )! expect(mockFetchCustomCollection).not.toHaveBeenCalled() expect(mockFetchCustomToolList).toHaveBeenCalledWith('test-collection') @@ -419,7 +508,9 @@ describe('ProviderDetail', () => { ) const openInStudio = (await screen.findByText('tools.openInStudio')).closest('a')! - const configureButton = (await screen.findByText('tools.createTool.editAction')).closest('button')! + const configureButton = (await screen.findByText('tools.createTool.editAction')).closest( + 'button', + )! expect(openInStudio).toHaveAttribute('href', '/app/wf-123/workflow') expect(openInStudio).toHaveClass('h-8', 'min-w-0', 'flex-1', 'rounded-lg', 'px-3', 'py-2') @@ -437,7 +528,9 @@ describe('ProviderDetail', () => { />, ) - const actions = (await screen.findByText('tools.openInStudio')).closest('.border-b-\\[0\\.5px\\]')! + const actions = (await screen.findByText('tools.openInStudio')).closest( + '.border-b-\\[0\\.5px\\]', + )! expect(actions).toHaveClass('-mx-4', 'px-4', 'border-b-[0.5px]', 'border-divider-subtle') }) @@ -457,7 +550,9 @@ describe('ProviderDetail', () => { expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-id') }) - const configureButton = (await screen.findByText('tools.createTool.editAction')).closest('button')! + const configureButton = (await screen.findByText('tools.createTool.editAction')).closest( + 'button', + )! expect(screen.getByText('tools.openInStudio')).toBeInTheDocument() expect(configureButton).toBeDisabled() @@ -469,7 +564,15 @@ describe('ProviderDetail', () => { describe('Model Collection', () => { it('opens model modal when clicking auth button for model type', async () => { mockFetchModelToolList.mockResolvedValue([ - { name: 'model-tool-1', label: { en_US: 'MT1' }, description: { en_US: '' }, parameters: [], labels: [], author: '', output_schema: {} }, + { + name: 'model-tool-1', + label: { en_US: 'MT1' }, + description: { en_US: '' }, + parameters: [], + labels: [], + author: '', + output_schema: {}, + }, ]) render( <ProviderDetail @@ -564,7 +667,11 @@ describe('ProviderDetail', () => { }) it('does not open setup credential drawer without credential.create', async () => { - mockAppContextState.workspacePermissionKeys = ['tool.manage', 'credential.use', 'credential.manage'] + mockAppContextState.workspacePermissionKeys = [ + 'tool.manage', + 'credential.use', + 'credential.manage', + ] render( <ProviderDetail @@ -619,7 +726,9 @@ describe('ProviderDetail', () => { fireEvent.click(screen.getByTestId('credential-save')) }) await waitFor(() => { - expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test-collection', { key: 'val' }) + expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test-collection', { + key: 'val', + }) expect(mockOnRefreshData).toHaveBeenCalled() }) }) @@ -769,8 +878,20 @@ describe('ProviderDetail', () => { workflow_tool_id: 'wt-456', tool: { parameters: [ - { name: 'query', type: 'string', llm_description: 'Search query', form: 'llm', required: true }, - { name: 'limit', type: 'number', llm_description: 'Max results', form: 'form', required: false }, + { + name: 'query', + type: 'string', + llm_description: 'Search query', + form: 'llm', + required: true, + }, + { + name: 'limit', + type: 'number', + llm_description: 'Max results', + form: 'form', + required: false, + }, ], labels: ['search'], }, diff --git a/web/app/components/tools/provider/__tests__/empty.spec.tsx b/web/app/components/tools/provider/__tests__/empty.spec.tsx index 16510e848c7c31..ed04a2d5732992 100644 --- a/web/app/components/tools/provider/__tests__/empty.spec.tsx +++ b/web/app/components/tools/provider/__tests__/empty.spec.tsx @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // Import the mock to control it in tests import useTheme from '@/hooks/use-theme' import { ToolTypeEnum } from '../../../workflow/block-selector/types' - import Empty from '../empty' // Mock useTheme hook @@ -82,9 +81,18 @@ describe('Empty', () => { expect(screen.getByText('tools.workflowToolEmpty.step2')).toBeInTheDocument() expect(screen.getByText('tools.workflowToolEmpty.step3')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.goToStudio/i })).toHaveAttribute('href', '/apps') - expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('target', '_blank') - expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow') + expect( + screen.getByRole('link', { name: /tools\.workflowToolEmpty\.goToStudio/i }), + ).toHaveAttribute('href', '/apps') + expect( + screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i }), + ).toHaveAttribute('target', '_blank') + expect( + screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i }), + ).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow', + ) }) }) diff --git a/web/app/components/tools/provider/__tests__/tool-item.spec.tsx b/web/app/components/tools/provider/__tests__/tool-item.spec.tsx index 734b6f684418e3..e836de716713e8 100644 --- a/web/app/components/tools/provider/__tests__/tool-item.spec.tsx +++ b/web/app/components/tools/provider/__tests__/tool-item.spec.tsx @@ -12,28 +12,40 @@ vi.mock('@/i18n-config/language', () => ({ let mockModalVisible = false // Mock SettingBuiltInTool modal - complex component that needs mocking -vi.mock('@/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool', () => ({ - default: ({ onHide, collection, toolName, readonly, isBuiltIn, isModel }: { - onHide: () => void - collection: Collection - toolName: string - readonly: boolean - isBuiltIn: boolean - isModel: boolean - }) => { - mockModalVisible = true - return ( - <div data-testid="setting-built-in-tool-modal"> - <span data-testid="modal-tool-name">{toolName}</span> - <span data-testid="modal-collection-id">{collection.id}</span> - <span data-testid="modal-readonly">{readonly.toString()}</span> - <span data-testid="modal-is-builtin">{isBuiltIn.toString()}</span> - <span data-testid="modal-is-model">{isModel.toString()}</span> - <button data-testid="close-modal" onClick={onHide}>Close</button> - </div> - ) - }, -})) +vi.mock( + '@/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool', + () => ({ + default: ({ + onHide, + collection, + toolName, + readonly, + isBuiltIn, + isModel, + }: { + onHide: () => void + collection: Collection + toolName: string + readonly: boolean + isBuiltIn: boolean + isModel: boolean + }) => { + mockModalVisible = true + return ( + <div data-testid="setting-built-in-tool-modal"> + <span data-testid="modal-tool-name">{toolName}</span> + <span data-testid="modal-collection-id">{collection.id}</span> + <span data-testid="modal-readonly">{readonly.toString()}</span> + <span data-testid="modal-is-builtin">{isBuiltIn.toString()}</span> + <span data-testid="modal-is-model">{isModel.toString()}</span> + <button data-testid="close-modal" onClick={onHide}> + Close + </button> + </div> + ) + }, + }), +) describe('ToolItem', () => { // Factory function for creating mock collection @@ -247,7 +259,10 @@ describe('ToolItem', () => { render(<ToolItem {...defaultProps} />) const descriptionElement = screen.getByText('Test tool description for testing purposes') - expect(descriptionElement).toHaveAttribute('title', 'Test tool description for testing purposes') + expect(descriptionElement).toHaveAttribute( + 'title', + 'Test tool description for testing purposes', + ) }) it('should apply line-clamp-2 to description for text overflow', () => { diff --git a/web/app/components/tools/provider/create-entry-card.tsx b/web/app/components/tools/provider/create-entry-card.tsx index 59409076c237bc..1c45c2aee66009 100644 --- a/web/app/components/tools/provider/create-entry-card.tsx +++ b/web/app/components/tools/provider/create-entry-card.tsx @@ -1,9 +1,6 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' -import { - RiAddLine, - RiArrowRightUpLine, -} from '@remixicon/react' +import { RiAddLine, RiArrowRightUpLine } from '@remixicon/react' type CreateEntryCardProps = { className?: string @@ -21,7 +18,12 @@ const CreateEntryCard = ({ title, }: CreateEntryCardProps) => { return ( - <div className={cn('col-span-1 flex h-[120px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-md', className)}> + <div + className={cn( + 'col-span-1 flex h-[120px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-md', + className, + )} + > <button type="button" aria-label={title} @@ -35,7 +37,10 @@ const CreateEntryCard = ({ </div> </div> <div className="min-w-0 flex-1 py-px"> - <div className="truncate system-md-semibold text-text-primary group-hover:text-text-accent" title={title}> + <div + className="truncate system-md-semibold text-text-primary group-hover:text-text-accent" + title={title} + > {title} </div> </div> @@ -49,7 +54,9 @@ const CreateEntryCard = ({ className="flex h-8 items-center gap-0.5 border-t border-divider-subtle px-3 py-2 text-components-button-secondary-text outline-hidden hover:bg-components-panel-on-panel-item-bg-hover hover:text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-hover" > <div className="min-w-0 flex-1 px-0.5"> - <div className="truncate system-sm-medium" title={linkText}>{linkText}</div> + <div className="truncate system-sm-medium" title={linkText}> + {linkText} + </div> </div> <RiArrowRightUpLine className="size-4 shrink-0" /> </a> diff --git a/web/app/components/tools/provider/custom-create-card.tsx b/web/app/components/tools/provider/custom-create-card.tsx index 10d510cdba1e2a..36dd2f30261806 100644 --- a/web/app/components/tools/provider/custom-create-card.tsx +++ b/web/app/components/tools/provider/custom-create-card.tsx @@ -20,11 +20,10 @@ function useCustomToolCreateAction({ onRefreshData }: Props) { const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false) const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => { - if (!canManageTools) - return + if (!canManageTools) return await createCustomCollection(data) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) setIsShowEditCustomCollectionModal(false) onRefreshData() } @@ -39,7 +38,7 @@ function useCustomToolCreateAction({ onRefreshData }: Props) { export const NewCustomToolButton = ({ onRefreshData }: Props) => { const { t } = useTranslation() - const addSwaggerAPIAsToolLabel = t($ => $.addSwaggerAPIAsTool, { ns: 'tools' }) + const addSwaggerAPIAsToolLabel = t(($) => $.addSwaggerAPIAsTool, { ns: 'tools' }) const { canManageTools, doCreateCustomToolCollection, @@ -47,8 +46,7 @@ export const NewCustomToolButton = ({ onRefreshData }: Props) => { setIsShowEditCustomCollectionModal, } = useCustomToolCreateAction({ onRefreshData }) - if (!canManageTools) - return null + if (!canManageTools) return null return ( <> @@ -88,8 +86,8 @@ const Contribute = ({ onRefreshData }: Props) => { {canManageTools && ( <CreateEntryCard className="min-w-0" - title={t($ => $.createSwaggerAPIAsTool, { ns: 'tools' })} - linkText={t($ => $.swaggerAPIAsToolTip, { ns: 'tools' })} + title={t(($) => $.createSwaggerAPIAsTool, { ns: 'tools' })} + linkText={t(($) => $.swaggerAPIAsToolTip, { ns: 'tools' })} linkUrl={docLink('/use-dify/workspace/tools#swagger-api')} onCreate={() => setIsShowEditCustomCollectionModal(true)} /> diff --git a/web/app/components/tools/provider/detail.tsx b/web/app/components/tools/provider/detail.tsx index 8e2fbc1ab2d123..6d0e0914f887eb 100644 --- a/web/app/components/tools/provider/detail.tsx +++ b/web/app/components/tools/provider/detail.tsx @@ -1,5 +1,11 @@ 'use client' -import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types' +import type { + Collection, + CustomCollectionBackend, + Tool, + WorkflowToolProviderRequest, + WorkflowToolProviderResponse, +} from '../types' import type { WorkflowToolDrawerPayload } from '@/app/components/tools/workflow-tool' import { AlertDialog, @@ -21,9 +27,7 @@ import { } from '@langgenius/dify-ui/drawer' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { toast } from '@langgenius/dify-ui/toast' -import { - RiCloseLine, -} from '@remixicon/react' +import { RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -40,7 +44,6 @@ import ConfigCredential from '@/app/components/tools/setting/build-in/config-cre import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool' import { useLocale } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' - import { useProviderContext } from '@/context/provider-context' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import { getLanguage } from '@/i18n-config/language' @@ -68,11 +71,7 @@ type Props = Readonly<{ onRefreshData: () => void }> -const ProviderDetail = ({ - collection, - onHide, - onRefreshData, -}: Props) => { +const ProviderDetail = ({ collection, onHide, onRefreshData }: Props) => { const { t } = useTranslation() const locale = useLocale() const language = getLanguage(locale) @@ -93,11 +92,10 @@ const ProviderDetail = ({ const { setShowModelModal } = useModalContext() const { modelProviders: providers } = useProviderContext() const showSettingAuthModal = () => { - if (!canOpenCredentialSettings) - return + if (!canOpenCredentialSettings) return if (isModel) { - const provider = providers.find(item => item.provider === collection?.id) + const provider = providers.find((item) => item.provider === collection?.id) if (provider) { setShowModelModal({ payload: { @@ -110,20 +108,20 @@ const ProviderDetail = ({ }, }) } - } - else { + } else { setShowSettingAuth(true) } } // custom provider - const [customCollection, setCustomCollection] = useState<CustomCollectionBackend | WorkflowToolProviderResponse | null>(null) + const [customCollection, setCustomCollection] = useState< + CustomCollectionBackend | WorkflowToolProviderResponse | null + >(null) const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [deleteAction, setDeleteAction] = useState('') const getCustomProvider = useCallback(async () => { - if (!canManageTools) - return + if (!canManageTools) return setIsDetailLoading(true) const res = await fetchCustomCollection(collection.name) @@ -140,24 +138,22 @@ const ProviderDetail = ({ }, [canManageTools, collection.labels, collection.name]) const doUpdateCustomToolCollection = async (data: CustomCollectionBackend) => { - if (!canManageTools) - return + if (!canManageTools) return await updateCustomCollection(data) onRefreshData() await getCustomProvider() // Use fresh data from form submission to avoid race condition with collection.labels - setCustomCollection(prev => prev ? { ...prev, labels: data.labels } : null) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + setCustomCollection((prev) => (prev ? { ...prev, labels: data.labels } : null)) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) setIsShowEditCustomCollectionModal(false) } const doRemoveCustomToolCollection = async () => { - if (!canManageTools) - return + if (!canManageTools) return await removeCustomCollection(collection?.name as string) onRefreshData() - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) setIsShowEditCustomCollectionModal(false) } // workflow provider @@ -167,41 +163,43 @@ const ProviderDetail = ({ const res = await fetchWorkflowToolDetail(collection.id) const payload = { ...res, - parameters: res.tool?.parameters.map((item) => { - return { - name: item.name, - description: item.llm_description, - form: item.form, - required: item.required, - type: item.type, - } - }) || [], + parameters: + res.tool?.parameters.map((item) => { + return { + name: item.name, + description: item.llm_description, + form: item.form, + required: item.required, + type: item.type, + } + }) || [], labels: res.tool?.labels || [], } setCustomCollection(payload) setIsDetailLoading(false) }, [collection.id]) const removeWorkflowToolProvider = async () => { - if (!canManageTools) - return + if (!canManageTools) return await deleteWorkflowTool(collection.id) onRefreshData() - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) setWorkflowToolDrawerOpen(false) } - const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{ - workflow_app_id: string - workflow_tool_id: string - }>) => { - if (!canManageTools) - return + const updateWorkflowToolProvider = async ( + data: WorkflowToolProviderRequest & + Partial<{ + workflow_app_id: string + workflow_tool_id: string + }>, + ) => { + if (!canManageTools) return await saveWorkflowToolProvider(data) invalidateAllWorkflowTools() onRefreshData() getWorkflowToolProvider() - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) setWorkflowToolDrawerOpen(false) } const onClickCustomToolDelete = () => { @@ -213,11 +211,8 @@ const ProviderDetail = ({ setShowConfirmDelete(true) } const handleConfirmDelete = () => { - if (deleteAction === 'customTool') - doRemoveCustomToolCollection() - - else if (deleteAction === 'workflowTool') - removeWorkflowToolProvider() + if (deleteAction === 'customTool') doRemoveCustomToolCollection() + else if (deleteAction === 'workflowTool') removeWorkflowToolProvider() setShowConfirmDelete(false) } @@ -230,30 +225,31 @@ const ProviderDetail = ({ if (collection.type === CollectionType.builtIn) { const list = await fetchBuiltInToolList(collection.name) setToolList(list) - } - else if (collection.type === CollectionType.model) { + } else if (collection.type === CollectionType.model) { const list = await fetchModelToolList(collection.name) setToolList(list) - } - else if (collection.type === CollectionType.workflow) { + } else if (collection.type === CollectionType.workflow) { setToolList([]) - } - else { + } else { const list = await fetchCustomToolList(collection.name) setToolList(list) } - } - catch { } + } catch {} setIsDetailLoading(false) }, [collection.name, collection.type]) useEffect(() => { - if (collection.type === CollectionType.custom && canManageTools) - getCustomProvider() - if (collection.type === CollectionType.workflow) - getWorkflowToolProvider() + if (collection.type === CollectionType.custom && canManageTools) getCustomProvider() + if (collection.type === CollectionType.workflow) getWorkflowToolProvider() getProviderToolList() - }, [canManageTools, collection.name, collection.type, getCustomProvider, getProviderToolList, getWorkflowToolProvider]) + }, [ + canManageTools, + collection.name, + collection.type, + getCustomProvider, + getProviderToolList, + getWorkflowToolProvider, + ]) return ( <Drawer @@ -262,13 +258,16 @@ const ProviderDetail = ({ disablePointerDismissal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DrawerPortal> <DrawerViewport className="pointer-events-none"> - <DrawerPopup className={cn('pointer-events-auto touch-auto justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-[400px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border')}> + <DrawerPopup + className={cn( + 'pointer-events-auto touch-auto justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-[400px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border', + )} + > <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> <div className="flex h-full flex-col p-4"> <div className="shrink-0"> @@ -279,30 +278,36 @@ const ProviderDetail = ({ <Title title={collection.label[language]!} /> </div> <div className="mt-0.5 mb-1 flex h-4 items-center justify-between"> - {collection.type === CollectionType.workflow || collection.type === CollectionType.custom - ? ( - <div className="truncate system-xs-regular text-text-tertiary"> - {collection.author && `${t($ => $.author, { ns: 'tools' })} ${collection.author}`} - </div> - ) - : ( - <OrgInfo - packageNameClassName="w-auto" - orgName={collection.author} - packageName={collection.name} - /> - )} + {collection.type === CollectionType.workflow || + collection.type === CollectionType.custom ? ( + <div className="truncate system-xs-regular text-text-tertiary"> + {collection.author && + `${t(($) => $.author, { ns: 'tools' })} ${collection.author}`} + </div> + ) : ( + <OrgInfo + packageNameClassName="w-auto" + orgName={collection.author} + packageName={collection.name} + /> + )} </div> </div> <div className="flex gap-1"> - <ActionButton aria-label={t($ => $['operation.close'], { ns: 'common' })} onClick={onHide}> + <ActionButton + aria-label={t(($) => $['operation.close'], { ns: 'common' })} + onClick={onHide} + > <RiCloseLine className="size-4" /> </ActionButton> </div> </div> </div> {!!collection.description[language] && ( - <Description text={collection.description[language]} descriptionLineRows={2}></Description> + <Description + text={collection.description[language]} + descriptionLineRows={2} + ></Description> )} <div className="-mx-4 flex gap-1 border-b-[0.5px] border-divider-subtle px-4"> {collection.type === CollectionType.custom && !isDetailLoading && ( @@ -311,109 +316,183 @@ const ProviderDetail = ({ onClick={() => setIsShowEditCustomCollectionModal(true)} disabled={!canManageTools} > - <span aria-hidden className="mr-1 i-ri-equalizer-2-line size-4 text-components-button-secondary-text" /> - <div className="system-sm-medium text-text-secondary">{t($ => $['createTool.editAction'], { ns: 'tools' })}</div> + <span + aria-hidden + className="mr-1 i-ri-equalizer-2-line size-4 text-components-button-secondary-text" + /> + <div className="system-sm-medium text-text-secondary"> + {t(($) => $['createTool.editAction'], { ns: 'tools' })} + </div> </Button> )} - {collection.type === CollectionType.workflow && !isDetailLoading && customCollection && ( - <> - <Button - nativeButton={false} - variant="primary" - className={cn('my-3 h-8 min-w-0 flex-1 rounded-lg px-3 py-2')} - render={<a href={`${basePath}/app/${(customCollection as WorkflowToolProviderResponse).workflow_app_id}/workflow`} rel="noreferrer" target="_blank" aria-label={t($ => $.openInStudio, { ns: 'tools' })} />} - > - <span className="min-w-0 truncate px-0.5 system-sm-medium">{t($ => $.openInStudio, { ns: 'tools' })}</span> - <span aria-hidden className="i-ri-arrow-right-up-line size-4 shrink-0" /> - </Button> - <Button - variant="secondary" - className={cn('my-3 h-8 min-w-0 flex-1 rounded-lg px-3 py-2')} - onClick={() => setWorkflowToolDrawerOpen(true)} - disabled={!canManageTools} - > - <span aria-hidden className="i-ri-equalizer-2-line size-4 shrink-0 text-components-button-secondary-text" /> - <span className="min-w-0 truncate px-0.5 system-sm-medium text-components-button-secondary-text">{t($ => $['createTool.editAction'], { ns: 'tools' })}</span> - </Button> - </> - )} + {collection.type === CollectionType.workflow && + !isDetailLoading && + customCollection && ( + <> + <Button + nativeButton={false} + variant="primary" + className={cn('my-3 h-8 min-w-0 flex-1 rounded-lg px-3 py-2')} + render={ + <a + href={`${basePath}/app/${(customCollection as WorkflowToolProviderResponse).workflow_app_id}/workflow`} + rel="noreferrer" + target="_blank" + aria-label={t(($) => $.openInStudio, { ns: 'tools' })} + /> + } + > + <span className="min-w-0 truncate px-0.5 system-sm-medium"> + {t(($) => $.openInStudio, { ns: 'tools' })} + </span> + <span aria-hidden className="i-ri-arrow-right-up-line size-4 shrink-0" /> + </Button> + <Button + variant="secondary" + className={cn('my-3 h-8 min-w-0 flex-1 rounded-lg px-3 py-2')} + onClick={() => setWorkflowToolDrawerOpen(true)} + disabled={!canManageTools} + > + <span + aria-hidden + className="i-ri-equalizer-2-line size-4 shrink-0 text-components-button-secondary-text" + /> + <span className="min-w-0 truncate px-0.5 system-sm-medium text-components-button-secondary-text"> + {t(($) => $['createTool.editAction'], { ns: 'tools' })} + </span> + </Button> + </> + )} </div> <div className="flex min-h-0 flex-1 flex-col pt-3"> - {isDetailLoading && <div className="flex h-[200px]"><Loading type="app" /></div>} + {isDetailLoading && ( + <div className="flex h-[200px]"> + <Loading type="app" /> + </div> + )} {!isDetailLoading && ( <> <div className="shrink-0"> - {(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && isAuthed && ( - <div className="mb-1 flex h-6 items-center justify-between system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['detailPanel.actionNum'], { ns: 'plugin', num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' })} - {needAuth && ( + {(collection.type === CollectionType.builtIn || + collection.type === CollectionType.model) && + isAuthed && ( + <div className="mb-1 flex h-6 items-center justify-between system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['detailPanel.actionNum'], { + ns: 'plugin', + num: toolList.length, + action: toolList.length > 1 ? 'actions' : 'action', + })} + {needAuth && ( + <Button + variant="secondary" + size="small" + onClick={() => { + if ( + collection.type === CollectionType.builtIn || + collection.type === CollectionType.model + ) + showSettingAuthModal() + }} + disabled={!canOpenCredentialSettings} + > + <StatusDot className="mr-2" status="success" /> + {t(($) => $['auth.authorized'], { ns: 'tools' })} + </Button> + )} + </div> + )} + {(collection.type === CollectionType.builtIn || + collection.type === CollectionType.model) && + needAuth && + !isAuthed && ( + <> + <div className="system-sm-semibold-uppercase text-text-secondary"> + <span className=""> + {t(($) => $.includeToolNum, { + ns: 'tools', + num: toolList.length, + action: toolList.length > 1 ? 'actions' : 'action', + }).toLocaleUpperCase()} + </span> + <span className="px-1">·</span> + <span className="text-util-colors-orange-orange-600"> + {t(($) => $['auth.setup'], { ns: 'tools' }).toLocaleUpperCase()} + </span> + </div> <Button - variant="secondary" - size="small" + variant="primary" + className={cn('my-3 w-full shrink-0')} onClick={() => { - if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model) + if ( + collection.type === CollectionType.builtIn || + collection.type === CollectionType.model + ) showSettingAuthModal() }} disabled={!canOpenCredentialSettings} > - <StatusDot className="mr-2" status="success" /> - {t($ => $['auth.authorized'], { ns: 'tools' })} + {t(($) => $['auth.unauthorized'], { ns: 'tools' })} </Button> - )} - </div> - )} - {(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && needAuth && !isAuthed && ( - <> - <div className="system-sm-semibold-uppercase text-text-secondary"> - <span className="">{t($ => $.includeToolNum, { ns: 'tools', num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span> - <span className="px-1">·</span> - <span className="text-util-colors-orange-orange-600">{t($ => $['auth.setup'], { ns: 'tools' }).toLocaleUpperCase()}</span> - </div> - <Button - variant="primary" - className={cn('my-3 w-full shrink-0')} - onClick={() => { - if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model) - showSettingAuthModal() - }} - disabled={!canOpenCredentialSettings} - > - {t($ => $['auth.unauthorized'], { ns: 'tools' })} - </Button> - </> - )} - {(collection.type === CollectionType.custom) && ( + </> + )} + {collection.type === CollectionType.custom && ( <div className="system-sm-semibold-uppercase text-text-secondary"> - <span className="">{t($ => $.includeToolNum, { ns: 'tools', num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span> + <span className=""> + {t(($) => $.includeToolNum, { + ns: 'tools', + num: toolList.length, + action: toolList.length > 1 ? 'actions' : 'action', + }).toLocaleUpperCase()} + </span> </div> )} - {(collection.type === CollectionType.workflow) && ( + {collection.type === CollectionType.workflow && ( <div className="system-sm-semibold-uppercase text-text-secondary"> - <span className="">{t($ => $['createTool.toolInput.title'], { ns: 'tools' }).toLocaleUpperCase()}</span> + <span className=""> + {t(($) => $['createTool.toolInput.title'], { + ns: 'tools', + }).toLocaleUpperCase()} + </span> </div> )} </div> <div className="mt-1 flex-1 overflow-y-auto py-2"> - {collection.type !== CollectionType.workflow && toolList.map(tool => ( - <ToolItem - key={tool.name} - disabled={false} - collection={collection} - tool={tool} - isBuiltIn={isBuiltIn} - isModel={isModel} - /> - ))} - {collection.type === CollectionType.workflow && (customCollection as WorkflowToolProviderResponse)?.tool?.parameters.map(item => ( - <div key={item.name} className="mb-1 py-1"> - <div className="mb-1 flex items-center gap-2"> - <span className="code-sm-semibold text-text-secondary">{item.name}</span> - <span className="system-xs-regular text-text-tertiary">{item.type}</span> - <span className="system-xs-medium text-text-warning-secondary">{item.required ? t($ => $['createTool.toolInput.required'], { ns: 'tools' }) : ''}</span> - </div> - <div className="system-xs-regular text-text-tertiary">{item.llm_description}</div> - </div> - ))} + {collection.type !== CollectionType.workflow && + toolList.map((tool) => ( + <ToolItem + key={tool.name} + disabled={false} + collection={collection} + tool={tool} + isBuiltIn={isBuiltIn} + isModel={isModel} + /> + ))} + {collection.type === CollectionType.workflow && + (customCollection as WorkflowToolProviderResponse)?.tool?.parameters.map( + (item) => ( + <div key={item.name} className="mb-1 py-1"> + <div className="mb-1 flex items-center gap-2"> + <span className="code-sm-semibold text-text-secondary"> + {item.name} + </span> + <span className="system-xs-regular text-text-tertiary"> + {item.type} + </span> + <span className="system-xs-medium text-text-warning-secondary"> + {item.required + ? t(($) => $['createTool.toolInput.required'], { + ns: 'tools', + }) + : ''} + </span> + </div> + <div className="system-xs-regular text-text-tertiary"> + {item.llm_description} + </div> + </div> + ), + )} </div> </> )} @@ -423,20 +502,18 @@ const ProviderDetail = ({ collection={collection} onCancel={() => setShowSettingAuth(false)} onSaved={async (value) => { - if (!canSaveCredentialSettings) - return + if (!canSaveCredentialSettings) return await updateBuiltInToolCredential(collection.name, value) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) await onRefreshData() setShowSettingAuth(false) }} onRemove={async () => { - if (!canManageCredential) - return + if (!canManageCredential) return await removeBuiltInToolCredential(collection.name) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) await onRefreshData() setShowSettingAuth(false) }} @@ -459,20 +536,25 @@ const ProviderDetail = ({ onSave={updateWorkflowToolProvider} /> )} - <AlertDialog open={showConfirmDelete} onOpenChange={open => !open && setShowConfirmDelete(false)}> + <AlertDialog + open={showConfirmDelete} + onOpenChange={(open) => !open && setShowConfirmDelete(false)} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['createTool.deleteToolConfirmTitle'], { ns: 'tools' })} + {t(($) => $['createTool.deleteToolConfirmTitle'], { ns: 'tools' })} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['createTool.deleteToolConfirmContent'], { ns: 'tools' })} + {t(($) => $['createTool.deleteToolConfirmContent'], { ns: 'tools' })} </AlertDialogDescription> </div> <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={handleConfirmDelete}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/tools/provider/empty.tsx b/web/app/components/tools/provider/empty.tsx index 5dde61ecbb1d47..7d0da3054913c5 100644 --- a/web/app/components/tools/provider/empty.tsx +++ b/web/app/components/tools/provider/empty.tsx @@ -30,25 +30,24 @@ const getLink = (type?: ToolTypeEnum) => { return buildIntegrationPath('custom-tool') } } -const Empty = ({ - type, - isAgent, -}: Props) => { +const Empty = ({ type, isAgent }: Props) => { const { t } = useTranslation() const docLink = useDocLink() const { theme } = useTheme() const hasLink = type && [ToolTypeEnum.Custom, ToolTypeEnum.MCP].includes(type) - const renderType = isAgent ? 'agent' as const : type - const hasTitle = renderType && t($ => $[`addToolModal.${renderType}.title`], { ns: 'tools' }) !== `addToolModal.${renderType}.title` + const renderType = isAgent ? ('agent' as const) : type + const hasTitle = + renderType && + t(($) => $[`addToolModal.${renderType}.title`], { ns: 'tools' }) !== + `addToolModal.${renderType}.title` const tipClassName = cn( 'flex items-center text-[13px] leading-[18px] text-text-tertiary', hasLink && 'cursor-pointer hover:text-text-accent', ) const tipContent = renderType && ( <> - {t($ => $[`addToolModal.${renderType}.tip`], { ns: 'tools' })} - {' '} + {t(($) => $[`addToolModal.${renderType}.tip`], { ns: 'tools' })}{' '} {hasLink && <RiArrowRightUpLine className="ml-0.5 size-3" />} </> ) @@ -58,10 +57,10 @@ const Empty = ({ <div className="flex w-full max-w-[1060px] flex-col items-center gap-8 text-center"> <div className="flex w-full max-w-[739px] flex-col items-center gap-1"> <div className="text-[24px] font-semibold text-text-primary"> - {t($ => $['workflowToolEmpty.title'], { ns: 'tools' })} + {t(($) => $['workflowToolEmpty.title'], { ns: 'tools' })} </div> <div className="system-xs-regular text-text-tertiary"> - {t($ => $['workflowToolEmpty.description'], { ns: 'tools' })} + {t(($) => $['workflowToolEmpty.description'], { ns: 'tools' })} </div> </div> <div className="flex w-full flex-col items-center gap-4"> @@ -78,7 +77,7 @@ const Empty = ({ <div className="h-px flex-1 bg-divider-subtle" /> </div> <div className="text-left system-md-semibold text-text-secondary"> - {t($ => $[stepKey], { ns: 'tools' })} + {t(($) => $[stepKey], { ns: 'tools' })} </div> </div> ))} @@ -87,7 +86,7 @@ const Empty = ({ href="/apps" className="flex h-7 items-center gap-1.5 py-1 system-sm-semibold text-text-accent hover:text-text-accent-secondary" > - {t($ => $['workflowToolEmpty.goToStudio'], { ns: 'tools' })} + {t(($) => $['workflowToolEmpty.goToStudio'], { ns: 'tools' })} <span className="flex size-5 items-center justify-center rounded-full bg-text-accent text-text-primary-on-surface"> <RiArrowRightLine className="size-4" /> </span> @@ -99,7 +98,7 @@ const Empty = ({ rel="noreferrer" className="rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px] system-2xs-medium-uppercase text-text-tertiary hover:text-text-accent" > - {t($ => $['workflowToolEmpty.learnMore'], { ns: 'tools' })} + {t(($) => $['workflowToolEmpty.learnMore'], { ns: 'tools' })} </Link> </div> ) @@ -109,22 +108,17 @@ const Empty = ({ <div className="flex flex-col items-center justify-center"> <NoToolPlaceholder className={theme === 'dark' ? 'invert' : ''} /> <div className="mt-2 mb-1 text-[13px] leading-[18px] font-medium text-text-primary"> - {(hasTitle && renderType) ? t($ => $[`addToolModal.${renderType}.title`], { ns: 'tools' }) : 'No tools available'} + {hasTitle && renderType + ? t(($) => $[`addToolModal.${renderType}.title`], { ns: 'tools' }) + : 'No tools available'} </div> {!!(!isAgent && hasTitle && renderType && hasLink) && ( - <Link - href={getLink(type)} - target="_blank" - rel="noreferrer" - className={tipClassName} - > + <Link href={getLink(type)} target="_blank" rel="noreferrer" className={tipClassName}> {tipContent} </Link> )} {!!(!isAgent && hasTitle && renderType && !hasLink) && ( - <div className={tipClassName}> - {tipContent} - </div> + <div className={tipClassName}>{tipContent}</div> )} </div> ) diff --git a/web/app/components/tools/provider/tool-card-skeleton.tsx b/web/app/components/tools/provider/tool-card-skeleton.tsx index eac79843e41ed5..ce78dd42456cf2 100644 --- a/web/app/components/tools/provider/tool-card-skeleton.tsx +++ b/web/app/components/tools/provider/tool-card-skeleton.tsx @@ -1,5 +1,10 @@ import { cn } from '@langgenius/dify-ui/cn' -import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' +import { + SkeletonContainer, + SkeletonPoint, + SkeletonRectangle, + SkeletonRow, +} from '@/app/components/base/skeleton' type ToolCardSkeletonGridProps = { className?: string @@ -97,10 +102,10 @@ const MCPCardSkeleton = () => ( ) const skeletonByVariant = { - 'default': ToolCardSkeleton, + default: ToolCardSkeleton, 'integrations-default': IntegrationsDefaultToolCardSkeleton, 'integrations-labeled': IntegrationsLabeledToolCardSkeleton, - 'mcp': MCPCardSkeleton, + mcp: MCPCardSkeleton, } const ToolCardSkeletonGrid = ({ diff --git a/web/app/components/tools/provider/tool-item.tsx b/web/app/components/tools/provider/tool-item.tsx index 09e52e34365f55..f13c4e49379478 100644 --- a/web/app/components/tools/provider/tool-item.tsx +++ b/web/app/components/tools/provider/tool-item.tsx @@ -15,13 +15,7 @@ type Props = Readonly<{ isModel: boolean }> -const ToolItem = ({ - disabled, - collection, - tool, - isBuiltIn, - isModel, -}: Props) => { +const ToolItem = ({ disabled, collection, tool, isBuiltIn, isModel }: Props) => { const locale = useLocale() const language = getLanguage(locale) const [showDetail, setShowDetail] = useState(false) @@ -29,11 +23,19 @@ const ToolItem = ({ return ( <> <div - className={cn('bg-components-panel-item-bg cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', disabled && 'cursor-not-allowed! opacity-50')} + className={cn( + 'bg-components-panel-item-bg cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', + disabled && 'cursor-not-allowed! opacity-50', + )} onClick={() => !disabled && setShowDetail(true)} > <div className="pb-0.5 system-md-semibold text-text-secondary">{tool.label[language]}</div> - <div className="line-clamp-2 system-xs-regular text-text-tertiary" title={tool.description[language]}>{tool.description[language]}</div> + <div + className="line-clamp-2 system-xs-regular text-text-tertiary" + title={tool.description[language]} + > + {tool.description[language]} + </div> </div> {showDetail && ( <SettingBuiltInTool diff --git a/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx b/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx index 0cb3970eaabe6c..164e0954421cfc 100644 --- a/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx +++ b/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx @@ -15,11 +15,12 @@ vi.mock('@/service/tools', () => ({ })) vi.mock('../../../utils/to-form-schema', () => ({ - toolCredentialToFormSchemas: (schemas: unknown[]) => (schemas as Record<string, unknown>[]).map(s => ({ - ...s, - variable: s.name, - show_on: [], - })), + toolCredentialToFormSchemas: (schemas: unknown[]) => + (schemas as Record<string, unknown>[]).map((s) => ({ + ...s, + variable: s.name, + show_on: [], + })), addDefaultValue: (value: Record<string, unknown>, _schemas: unknown[]) => ({ ...value }), })) @@ -28,12 +29,18 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({ - default: ({ value, onChange }: { value: Record<string, string>, onChange: (v: Record<string, string>) => void }) => ( + default: ({ + value, + onChange, + }: { + value: Record<string, string> + onChange: (v: Record<string, string>) => void + }) => ( <div data-testid="form"> <input data-testid="form-input" value={value.api_key || ''} - onChange={e => onChange({ ...value, api_key: e.target.value })} + onChange={(e) => onChange({ ...value, api_key: e.target.value })} /> </div> ), diff --git a/web/app/components/tools/setting/build-in/config-credentials.tsx b/web/app/components/tools/setting/build-in/config-credentials.tsx index 4a8b5aebeaa9fe..2a92a99eec88ba 100644 --- a/web/app/components/tools/setting/build-in/config-credentials.tsx +++ b/web/app/components/tools/setting/build-in/config-credentials.tsx @@ -65,7 +65,12 @@ const ConfigCredential: FC<Props> = ({ const handleSave = async () => { for (const field of credentialSchema) { if (field.required && !tempCredential[field.name]) { - toast.error(t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: field.label[language] || field.label.en_US })) + toast.error( + t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: field.label[language] || field.label.en_US, + }), + ) return } } @@ -73,8 +78,7 @@ const ConfigCredential: FC<Props> = ({ try { await onSaved(tempCredential) setIsLoading(false) - } - finally { + } finally { setIsLoading(false) } } @@ -85,8 +89,7 @@ const ConfigCredential: FC<Props> = ({ modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DrawerPortal> @@ -97,62 +100,78 @@ const ConfigCredential: FC<Props> = ({ <div className="shrink-0 border-b border-divider-subtle py-4"> <div className="flex h-6 items-center justify-between pr-5 pl-6"> <DrawerTitle className="min-w-0 truncate system-xl-semibold text-text-primary"> - {t($ => $['auth.setupModalTitle'], { ns: 'tools' })} + {t(($) => $['auth.setupModalTitle'], { ns: 'tools' })} </DrawerTitle> <DrawerCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-6 rounded-md" /> </div> <DrawerDescription className="pr-10 pl-6 system-xs-regular text-text-tertiary"> - {t($ => $['auth.setupModalTitleDescription'], { ns: 'tools' })} + {t(($) => $['auth.setupModalTitleDescription'], { ns: 'tools' })} </DrawerDescription> </div> <div className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> - {!credentialSchema - ? <Loading type="app" /> - : ( - <> - <Form - value={tempCredential} - onChange={(v) => { - setTempCredential(v) - }} - formSchemas={credentialSchema} - isEditMode={true} - readonly={readonly} - showOnVariableMap={{}} - validating={false} - inputClassName="bg-components-input-bg-normal!" - fieldMoreInfo={item => item.url - ? ( - <a - href={item.url} - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center text-xs text-text-accent" - > - {t($ => $.howToGet, { ns: 'tools' })} - <LinkExternal02 className="ml-1 size-3" /> - </a> - ) - : null} - /> - <div className={cn((collection.is_team_authorization && !isHideRemoveBtn) ? 'justify-between' : 'justify-end', 'mt-2 flex')}> - { - (collection.is_team_authorization && !isHideRemoveBtn && !readonly) && ( - <Button onClick={onRemove}>{t($ => $['operation.remove'], { ns: 'common' })}</Button> - ) - } - <div className="flex space-x-2"> - <Button onClick={onCancel}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - {!readonly && ( - <Button loading={isLoading || isSaving} disabled={isLoading || isSaving} variant="primary" onClick={handleSave}>{t($ => $['operation.save'], { ns: 'common' })}</Button> - )} - </div> - </div> - </> - )} + {!credentialSchema ? ( + <Loading type="app" /> + ) : ( + <> + <Form + value={tempCredential} + onChange={(v) => { + setTempCredential(v) + }} + formSchemas={credentialSchema} + isEditMode={true} + readonly={readonly} + showOnVariableMap={{}} + validating={false} + inputClassName="bg-components-input-bg-normal!" + fieldMoreInfo={(item) => + item.url ? ( + <a + href={item.url} + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center text-xs text-text-accent" + > + {t(($) => $.howToGet, { ns: 'tools' })} + <LinkExternal02 className="ml-1 size-3" /> + </a> + ) : null + } + /> + <div + className={cn( + collection.is_team_authorization && !isHideRemoveBtn + ? 'justify-between' + : 'justify-end', + 'mt-2 flex', + )} + > + {collection.is_team_authorization && !isHideRemoveBtn && !readonly && ( + <Button onClick={onRemove}> + {t(($) => $['operation.remove'], { ns: 'common' })} + </Button> + )} + <div className="flex space-x-2"> + <Button onClick={onCancel}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + {!readonly && ( + <Button + loading={isLoading || isSaving} + disabled={isLoading || isSaving} + variant="primary" + onClick={handleSave} + > + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> + )} + </div> + </div> + </> + )} </div> </DrawerContent> </DrawerPopup> diff --git a/web/app/components/tools/tool-provider-grid.tsx b/web/app/components/tools/tool-provider-grid.tsx index dec33121ae1a6f..7f0ac1f953ee2d 100644 --- a/web/app/components/tools/tool-provider-grid.tsx +++ b/web/app/components/tools/tool-provider-grid.tsx @@ -50,7 +50,7 @@ const collectionToCardPayload = (collection: Collection): CardPayload => { endpoint: { settings: [], }, - tags: collection.labels?.map(name => ({ name })) ?? [], + tags: collection.labels?.map((name) => ({ name })) ?? [], badges: [], verification: { authorized_category: 'community', @@ -87,7 +87,8 @@ export function ToolProviderGrid({ onRefreshData: () => void onSelectProvider: (providerId: string) => void }) { - const showWorkflowEmptyState = activeTab === 'workflow' && !hasCategoryCollections && !isSearchResultEmpty + const showWorkflowEmptyState = + activeTab === 'workflow' && !hasCategoryCollections && !isSearchResultEmpty const useCustomToolGrid = activeTab === 'api' const useThreeColumnIntegrationsGrid = useIntegrationsCard && activeTab !== 'builtin' const skeletonVariant = useIntegrationsCard @@ -111,53 +112,54 @@ export function ToolProviderGrid({ showWorkflowEmptyState && 'grow', )} > - {isLoading - ? <ToolCardSkeletonGrid variant={skeletonVariant} /> - : ( - <> - {activeTab === 'api' && showCreateCard && <CustomCreateCard onRefreshData={onRefreshData} />} - {collections.map(collection => ( - <div - key={collection.id} + {isLoading ? ( + <ToolCardSkeletonGrid variant={skeletonVariant} /> + ) : ( + <> + {activeTab === 'api' && showCreateCard && ( + <CustomCreateCard onRefreshData={onRefreshData} /> + )} + {collections.map((collection) => ( + <div + key={collection.id} + className={cn( + useCustomToolGrid && 'min-w-0', + useIntegrationsCard && !useCustomToolGrid && 'min-w-0', + )} + onClick={() => onSelectProvider(collection.id)} + > + {useIntegrationsCard ? ( + <IntegrationsToolProviderCard + collection={collection} + current={currentProviderId === collection.id} + showBuiltInBadge={activeTab === 'builtin' && !collection.plugin_id} + variant={activeTab === 'workflow' || activeTab === 'api' ? 'labeled' : 'default'} + /> + ) : ( + <Card className={cn( - useCustomToolGrid && 'min-w-0', - useIntegrationsCard && !useCustomToolGrid && 'min-w-0', + 'cursor-pointer', + currentProviderId === collection.id && + 'border-[1.5px] border-components-option-card-option-selected-border', )} - onClick={() => onSelectProvider(collection.id)} - > - {useIntegrationsCard - ? ( - <IntegrationsToolProviderCard - collection={collection} - current={currentProviderId === collection.id} - showBuiltInBadge={activeTab === 'builtin' && !collection.plugin_id} - variant={activeTab === 'workflow' || activeTab === 'api' ? 'labeled' : 'default'} - /> - ) - : ( - <Card - className={cn( - 'cursor-pointer', - currentProviderId === collection.id && 'border-[1.5px] border-components-option-card-option-selected-border', - )} - hideCornerMark - payload={collectionToCardPayload(collection)} - footer={( - <CardMoreInfo - tags={collection.labels?.map(label => getTagLabel(label)) || []} - /> - )} - /> - )} - </div> - ))} - {showWorkflowEmptyState && ( - <div className="absolute top-1/2 left-1/2 w-full max-w-[1060px] -translate-x-1/2 -translate-y-1/2 px-6"> - <WorkflowToolEmpty type={getToolType(activeTab)} /> - </div> + hideCornerMark + payload={collectionToCardPayload(collection)} + footer={ + <CardMoreInfo + tags={collection.labels?.map((label) => getTagLabel(label)) || []} + /> + } + /> )} - </> + </div> + ))} + {showWorkflowEmptyState && ( + <div className="absolute top-1/2 left-1/2 w-full max-w-[1060px] -translate-x-1/2 -translate-y-1/2 px-6"> + <WorkflowToolEmpty type={getToolType(activeTab)} /> + </div> )} + </> + )} </div> ) } diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index 02d0dbd23daab1..d820719aeeb506 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -213,10 +213,13 @@ export type WorkflowToolProviderOutputParameter = { export type WorkflowToolProviderOutputSchema = { type: string - properties: Record<string, { - type: string - description: string - }> + properties: Record< + string, + { + type: string + description: string + } + > } export type WorkflowToolProviderRequest = { diff --git a/web/app/components/tools/utils/__tests__/index.spec.ts b/web/app/components/tools/utils/__tests__/index.spec.ts index 829846bc86696e..8149a2442ae734 100644 --- a/web/app/components/tools/utils/__tests__/index.spec.ts +++ b/web/app/components/tools/utils/__tests__/index.spec.ts @@ -11,10 +11,7 @@ describe('tools/utils', () => { }) it('returns unsorted when some items lack position', () => { - const items = [ - { id: '1', position: 2 }, - { id: '2' }, - ] as unknown as ThoughtItem[] + const items = [{ id: '1', position: 2 }, { id: '2' }] as unknown as ThoughtItem[] const result = sortAgentSorts(items) expect(result[0]).toEqual(expect.objectContaining({ id: '1' })) expect(result[1]).toEqual(expect.objectContaining({ id: '2' })) @@ -60,14 +57,14 @@ describe('tools/utils', () => { ] as unknown as ThoughtItem[] const result = addFileInfos(items, [file1, file2]) - expect((result[0] as ThoughtItem & { message_files: FileEntity[] }).message_files).toEqual([file1, file2]) + expect((result[0] as ThoughtItem & { message_files: FileEntity[] }).message_files).toEqual([ + file1, + file2, + ]) }) it('returns items without files unchanged', () => { - const items = [ - { id: '1' }, - { id: '2', files: null }, - ] as unknown as ThoughtItem[] + const items = [{ id: '1' }, { id: '2', files: null }] as unknown as ThoughtItem[] const result = addFileInfos(items, []) expect(result[0]).toEqual(expect.objectContaining({ id: '1' })) }) diff --git a/web/app/components/tools/utils/__tests__/to-form-schema.spec.ts b/web/app/components/tools/utils/__tests__/to-form-schema.spec.ts index 5a97f41bc74549..8238a24aeac254 100644 --- a/web/app/components/tools/utils/__tests__/to-form-schema.spec.ts +++ b/web/app/components/tools/utils/__tests__/to-form-schema.spec.ts @@ -37,7 +37,9 @@ describe('to-form-schema utilities', () => { describe('triggerEventParametersToFormSchemas', () => { it('returns empty array for null/undefined parameters', () => { - expect(triggerEventParametersToFormSchemas(null as unknown as TriggerEventParameter[])).toEqual([]) + expect( + triggerEventParametersToFormSchemas(null as unknown as TriggerEventParameter[]), + ).toEqual([]) expect(triggerEventParametersToFormSchemas([])).toEqual([]) }) @@ -169,7 +171,10 @@ describe('to-form-schema utilities', () => { expect(result).toHaveLength(1) expect(result[0]!.variable).toBe('api_key') expect(result[0]!.type).toBe('secret-input') - expect(result[0]!.tooltip).toEqual({ en_US: 'Enter your API key', zh_Hans: '输入你的 API 密钥' }) + expect(result[0]!.tooltip).toEqual({ + en_US: 'Enter your API key', + zh_Hans: '输入你的 API 密钥', + }) expect(result[0]!.show_on).toEqual([]) }) @@ -207,7 +212,7 @@ describe('to-form-schema utilities', () => { ] const result = toolCredentialToFormSchemas(creds) expect(result[0]!.options).toHaveLength(2) - result[0]!.options!.forEach(opt => expect(opt.show_on).toEqual([])) + result[0]!.options!.forEach((opt) => expect(opt.show_on).toEqual([])) }) }) @@ -261,7 +266,7 @@ describe('to-form-schema utilities', () => { const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }] const result = generateFormValue({}, schemas) expect(result.name).toBeDefined() - const wrapper = result.name as { value: { type: string, value: unknown } } + const wrapper = result.name as { value: { type: string; value: unknown } } // correctInitialData sets type to 'mixed' for text-input but preserves default value expect(wrapper.value.type).toBe('mixed') expect(wrapper.value.value).toBe('hello') @@ -282,14 +287,14 @@ describe('to-form-schema utilities', () => { it('handles boolean default conversion in non-reasoning mode', () => { const schemas = [{ variable: 'flag', type: 'boolean', default: 'true' }] const result = generateFormValue({}, schemas) - const wrapper = result.flag as { value: { type: string, value: unknown } } + const wrapper = result.flag as { value: { type: string; value: unknown } } expect(wrapper.value.value).toBe(true) }) it('handles number-input default conversion', () => { const schemas = [{ variable: 'count', type: 'number-input', default: '42' }] const result = generateFormValue({}, schemas) - const wrapper = result.count as { value: { type: string, value: unknown } } + const wrapper = result.count as { value: { type: string; value: unknown } } expect(wrapper.value.value).toBe(42) }) }) @@ -326,7 +331,7 @@ describe('to-form-schema utilities', () => { it('fills defaults with correctInitialData for missing values', () => { const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }] const result = getConfiguredValue({}, schemas) - const val = result.name as { type: string, value: unknown } + const val = result.name as { type: string; value: unknown } expect(val.type).toBe('mixed') }) @@ -339,7 +344,7 @@ describe('to-form-schema utilities', () => { it('escapes newlines in string defaults', () => { const schemas = [{ variable: 'prompt', type: 'text-input', default: 'line1\nline2' }] const result = getConfiguredValue({}, schemas) - const val = result.prompt as { type: string, value: unknown } + const val = result.prompt as { type: string; value: unknown } expect(val.type).toBe('mixed') expect(val.value).toBe('line1\\nline2') }) @@ -347,14 +352,14 @@ describe('to-form-schema utilities', () => { it('handles boolean default conversion', () => { const schemas = [{ variable: 'flag', type: 'boolean', default: 'true' }] const result = getConfiguredValue({}, schemas) - const val = result.flag as { type: string, value: unknown } + const val = result.flag as { type: string; value: unknown } expect(val.value).toBe(true) }) it('handles app-selector type', () => { const schemas = [{ variable: 'app', type: 'app-selector', default: 'app-id-123' }] const result = getConfiguredValue({}, schemas) - const val = result.app as { type: string, value: unknown } + const val = result.app as { type: string; value: unknown } expect(val.value).toBe('app-id-123') }) }) diff --git a/web/app/components/tools/utils/index.ts b/web/app/components/tools/utils/index.ts index 4db5ae90818caa..9a075569527e99 100644 --- a/web/app/components/tools/utils/index.ts +++ b/web/app/components/tools/utils/index.ts @@ -19,23 +19,22 @@ export const getToolType = (type: string) => { } export const sortAgentSorts = (list: ThoughtItem[]) => { - if (!list) - return list - if (list.some(item => item.position === undefined)) - return list + if (!list) return list + if (list.some((item) => item.position === undefined)) return list const temp = [...list] temp.sort((a, b) => a.position - b.position) return temp } export const addFileInfos = (list: ThoughtItem[], messageFiles: (FileEntity | VisionFile)[]) => { - if (!list || !messageFiles) - return list + if (!list || !messageFiles) return list return list.map((item) => { if (item.files && item.files?.length > 0) { return { ...item, - message_files: item.files.map(fileId => messageFiles.find(file => file.id === fileId)) as FileEntity[], + message_files: item.files.map((fileId) => + messageFiles.find((file) => file.id === fileId), + ) as FileEntity[], } } return item diff --git a/web/app/components/tools/utils/to-form-schema.ts b/web/app/components/tools/utils/to-form-schema.ts index 0d88b1424d8bd3..8674b3a5adf5db 100644 --- a/web/app/components/tools/utils/to-form-schema.ts +++ b/web/app/components/tools/utils/to-form-schema.ts @@ -24,11 +24,11 @@ export type ToolCredentialFormSchema = { default?: string tooltip?: TypeWithI18N placeholder?: TypeWithI18N - show_on: { variable: string, value: string }[] + show_on: { variable: string; value: string }[] options?: { label: TypeWithI18N value: string - show_on: { variable: string, value: string }[] + show_on: { variable: string; value: string }[] }[] help?: TypeWithI18N | null url?: string @@ -48,11 +48,11 @@ export type ToolFormSchema = { required: boolean default?: string tooltip?: TypeWithI18N - show_on: { variable: string, value: string }[] + show_on: { variable: string; value: string }[] options?: { label: TypeWithI18N value: string - show_on: { variable: string, value: string }[] + show_on: { variable: string; value: string }[] }[] placeholder?: TypeWithI18N min?: number @@ -79,8 +79,7 @@ export const toType = (type: string) => { } export const triggerEventParametersToFormSchemas = (parameters: TriggerEventParameter[]) => { - if (!parameters?.length) - return [] + if (!parameters?.length) return [] return parameters.map((parameter) => { return { @@ -93,8 +92,7 @@ export const triggerEventParametersToFormSchemas = (parameters: TriggerEventPara } export const toolParametersToFormSchemas = (parameters: ToolParameter[]): ToolFormSchema[] => { - if (!parameters) - return [] + if (!parameters) return [] const formSchemas = parameters.map((parameter): ToolFormSchema => { return { @@ -115,9 +113,10 @@ export const toolParametersToFormSchemas = (parameters: ToolParameter[]): ToolFo return formSchemas } -export const toolCredentialToFormSchemas = (parameters: ToolCredential[]): ToolCredentialFormSchema[] => { - if (!parameters) - return [] +export const toolCredentialToFormSchemas = ( + parameters: ToolCredential[], +): ToolCredentialFormSchema[] => { + if (!parameters) return [] const formSchemas = parameters.map((parameter): ToolCredentialFormSchema => { return { @@ -138,39 +137,50 @@ export const toolCredentialToFormSchemas = (parameters: ToolCredential[]): ToolC return formSchemas } -export const addDefaultValue = (value: Record<string, unknown>, formSchemas: { variable: string, type: string, default?: unknown }[]) => { +export const addDefaultValue = ( + value: Record<string, unknown>, + formSchemas: { variable: string; type: string; default?: unknown }[], +) => { const newValues = { ...value } formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] - if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) + if ( + formSchema.default !== undefined && + (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined) + ) newValues[formSchema.variable] = formSchema.default // Fix: Convert boolean field values to proper boolean type - if (formSchema.type === 'boolean' && itemValue !== undefined && itemValue !== null && itemValue !== '') { + if ( + formSchema.type === 'boolean' && + itemValue !== undefined && + itemValue !== null && + itemValue !== '' + ) { if (typeof itemValue === 'string') - newValues[formSchema.variable] = itemValue === 'true' || itemValue === '1' || itemValue === 'True' - else if (typeof itemValue === 'number') - newValues[formSchema.variable] = itemValue === 1 - else if (typeof itemValue === 'boolean') - newValues[formSchema.variable] = itemValue + newValues[formSchema.variable] = + itemValue === 'true' || itemValue === '1' || itemValue === 'True' + else if (typeof itemValue === 'number') newValues[formSchema.variable] = itemValue === 1 + else if (typeof itemValue === 'boolean') newValues[formSchema.variable] = itemValue } }) return newValues } -const correctInitialData = (type: string, target: FormValueInput, defaultValue: unknown): FormValueInput => { - if (type === 'text-input' || type === 'secret-input') - target.type = 'mixed' +const correctInitialData = ( + type: string, + target: FormValueInput, + defaultValue: unknown, +): FormValueInput => { + if (type === 'text-input' || type === 'secret-input') target.type = 'mixed' if (type === 'boolean') { if (typeof defaultValue === 'string') target.value = defaultValue === 'true' || defaultValue === '1' - if (typeof defaultValue === 'boolean') - target.value = defaultValue + if (typeof defaultValue === 'boolean') target.value = defaultValue - if (typeof defaultValue === 'number') - target.value = defaultValue === 1 + if (typeof defaultValue === 'number') target.value = defaultValue === 1 } if (type === 'number-input') { @@ -178,22 +188,27 @@ const correctInitialData = (type: string, target: FormValueInput, defaultValue: target.value = Number.parseFloat(defaultValue) } - if (type === 'app-selector' || type === 'model-selector') - target.value = defaultValue + if (type === 'app-selector' || type === 'model-selector') target.value = defaultValue return target } -export const generateFormValue = (value: Record<string, unknown>, formSchemas: { variable: string, default?: unknown, type: string }[], isReasoning = false) => { +export const generateFormValue = ( + value: Record<string, unknown>, + formSchemas: { variable: string; default?: unknown; type: string }[], + isReasoning = false, +) => { const newValues: Record<string, unknown> = {} formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] - if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) { + if ( + formSchema.default !== undefined && + (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined) + ) { const defaultVal = formSchema.default if (isReasoning) { newValues[formSchema.variable] = { auto: 1, value: null } - } - else { + } else { const initialValue: FormValueInput = { type: 'constant', value: formSchema.default } newValues[formSchema.variable] = { value: correctInitialData(formSchema.type, initialValue, defaultVal), @@ -214,7 +229,9 @@ export const getPlainValue = (value: Record<string, { value: unknown }>) => { return plainValue } -export const getStructureValue = (value: Record<string, unknown>): Record<string, { value: unknown }> => { +export const getStructureValue = ( + value: Record<string, unknown>, +): Record<string, { value: unknown }> => { const newValue: Record<string, { value: unknown }> = {} Object.keys(value).forEach((key) => { newValue[key] = { @@ -224,15 +241,24 @@ export const getStructureValue = (value: Record<string, unknown>): Record<string return newValue } -export const getConfiguredValue = (value: Record<string, unknown>, formSchemas: { variable: string, type: string, default?: unknown }[]) => { +export const getConfiguredValue = ( + value: Record<string, unknown>, + formSchemas: { variable: string; type: string; default?: unknown }[], +) => { const newValues: Record<string, unknown> = { ...value } formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] - if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) { + if ( + formSchema.default !== undefined && + (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined) + ) { const defaultVal = formSchema.default const initialValue: FormValueInput = { type: 'constant', - value: typeof formSchema.default === 'string' ? formSchema.default.replace(/\n/g, '\\n') : formSchema.default, + value: + typeof formSchema.default === 'string' + ? formSchema.default.replace(/\n/g, '\\n') + : formSchema.default, } newValues[formSchema.variable] = correctInitialData(formSchema.type, initialValue, defaultVal) } @@ -241,16 +267,22 @@ export const getConfiguredValue = (value: Record<string, unknown>, formSchemas: } const getVarKindType = (type: FormTypeEnum) => { - if (type === FormTypeEnum.file || type === FormTypeEnum.files) - return VarKindType.variable - if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber) + if (type === FormTypeEnum.file || type === FormTypeEnum.files) return VarKindType.variable + if ( + type === FormTypeEnum.select || + type === FormTypeEnum.checkbox || + type === FormTypeEnum.textNumber + ) return VarKindType.constant - if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) - return VarKindType.mixed + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) return VarKindType.mixed } -export const generateAgentToolValue = (value: Record<string, { value?: unknown, auto?: 0 | 1 }>, formSchemas: { variable: string, default?: unknown, type: string }[], isReasoning = false) => { - const newValues: Record<string, { value: FormValueInput | null, auto?: 0 | 1 }> = {} +export const generateAgentToolValue = ( + value: Record<string, { value?: unknown; auto?: 0 | 1 }>, + formSchemas: { variable: string; default?: unknown; type: string }[], + isReasoning = false, +) => { + const newValues: Record<string, { value: FormValueInput | null; auto?: 0 | 1 }> = {} if (!isReasoning) { formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] @@ -260,10 +292,13 @@ export const generateAgentToolValue = (value: Record<string, { value?: unknown, value: itemValue?.value, }, } - newValues[formSchema.variable]!.value = correctInitialData(formSchema.type, newValues[formSchema.variable]!.value!, itemValue?.value) + newValues[formSchema.variable]!.value = correctInitialData( + formSchema.type, + newValues[formSchema.variable]!.value!, + itemValue?.value, + ) }) - } - else { + } else { formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] if (itemValue?.auto === 1) { @@ -271,8 +306,7 @@ export const generateAgentToolValue = (value: Record<string, { value?: unknown, auto: 1, value: null, } - } - else { + } else { newValues[formSchema.variable] = { auto: 0, value: (itemValue?.value as FormValueInput) || { diff --git a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx index 079c838cc84f05..d559caf1786cf7 100644 --- a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx @@ -63,7 +63,15 @@ vi.mock('@/app/components/plugins/hooks', () => ({ // Mock AppIcon - simplified for testing vi.mock('@/app/components/base/app-icon', () => ({ - default: ({ onClick, icon, background }: { onClick?: () => void, icon: string, background: string }) => ( + default: ({ + onClick, + icon, + background, + }: { + onClick?: () => void + icon: string + background: string + }) => ( <div data-testid="app-icon" onClick={onClick} data-icon={icon} data-background={background}> {icon} </div> @@ -72,10 +80,12 @@ vi.mock('@/app/components/base/app-icon', () => ({ // Mock LabelSelector - simplified for testing vi.mock('@/app/components/tools/labels/selector', () => ({ - default: ({ value, onChange }: { value: string[], onChange: (labels: string[]) => void }) => ( + default: ({ value, onChange }: { value: string[]; onChange: (labels: string[]) => void }) => ( <div data-testid="label-selector"> <span data-testid="label-values">{value.join(',')}</span> - <button data-testid="add-label" onClick={() => onChange([...value, 'new-label'])}>Add Label</button> + <button data-testid="add-label" onClick={() => onChange([...value, 'new-label'])}> + Add Label + </button> </div> ), })) @@ -87,7 +97,9 @@ const createMockEmoji = (overrides = {}) => ({ ...overrides, }) -const createMockWorkflowToolDetail = (overrides: Partial<WorkflowToolProviderResponse> = {}): WorkflowToolProviderResponse => ({ +const createMockWorkflowToolDetail = ( + overrides: Partial<WorkflowToolProviderResponse> = {}, +): WorkflowToolProviderResponse => ({ workflow_app_id: 'workflow-app-123', workflow_tool_id: 'workflow-tool-456', label: 'Test Tool', @@ -136,7 +148,9 @@ const createDefaultConfigureButtonProps = (overrides = {}) => ({ ...overrides, }) -const createDefaultDrawerPayload = (overrides: Partial<WorkflowToolDrawerPayload> = {}): WorkflowToolDrawerPayload => ({ +const createDefaultDrawerPayload = ( + overrides: Partial<WorkflowToolDrawerPayload> = {}, +): WorkflowToolDrawerPayload => ({ icon: createMockEmoji(), label: 'Test Tool', name: 'test_tool', @@ -469,7 +483,9 @@ describe('WorkflowToolDrawer', () => { // Assert // Assert - expect(screen.getByTestId('drawer-title'))!.toHaveTextContent('workflow.common.workflowAsTool') + expect(screen.getByTestId('drawer-title'))!.toHaveTextContent( + 'workflow.common.workflowAsTool', + ) }) it('should render name input field', () => { @@ -485,7 +501,9 @@ describe('WorkflowToolDrawer', () => { // Assert // Assert - expect(screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder'), + )!.toBeInTheDocument() }) it('should render name for tool call input', () => { @@ -501,7 +519,9 @@ describe('WorkflowToolDrawer', () => { // Assert // Assert - expect(screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder'), + )!.toBeInTheDocument() }) it('should render description textarea', () => { @@ -517,7 +537,9 @@ describe('WorkflowToolDrawer', () => { // Assert // Assert - expect(screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder'), + )!.toBeInTheDocument() }) it('should render tool input table', () => { @@ -599,7 +621,9 @@ describe('WorkflowToolDrawer', () => { // Assert // Assert - expect(screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder'))!.toBeInTheDocument() + expect( + screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder'), + )!.toBeInTheDocument() }) it('should render delete button when editing and onRemove provided', () => { @@ -957,10 +981,12 @@ describe('WorkflowToolDrawer', () => { await user.click(screen.getByText('common.operation.save')) // Assert - expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ - name: 'test_tool', - workflow_app_id: 'workflow-app-123', - })) + expect(onCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test_tool', + workflow_app_id: 'workflow-app-123', + }), + ) }) it('should show confirm modal when save clicked in edit mode', async () => { @@ -999,9 +1025,11 @@ describe('WorkflowToolDrawer', () => { await user.click(screen.getByText('common.operation.confirm')) // Assert - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - workflow_tool_id: 'tool-123', - })) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + workflow_tool_id: 'tool-123', + }), + ) }) it('should update parameter description on input', async () => { @@ -1010,20 +1038,24 @@ describe('WorkflowToolDrawer', () => { const props = { isAdd: true, payload: createDefaultDrawerPayload({ - parameters: [{ - name: 'param1', - description: '', // Start with empty description - form: 'llm', - required: true, - type: 'string', - }], + parameters: [ + { + name: 'param1', + description: '', // Start with empty description + form: 'llm', + required: true, + type: 'string', + }, + ], }), onHide: vi.fn(), } // Act render(<WorkflowToolDrawer {...props} />) - const descInput = screen.getByPlaceholderText('tools.createTool.toolInput.descriptionPlaceholder') + const descInput = screen.getByPlaceholderText( + 'tools.createTool.toolInput.descriptionPlaceholder', + ) await user.type(descInput, 'New parameter description') // Assert @@ -1176,13 +1208,15 @@ describe('WorkflowToolDrawer', () => { const props = { isAdd: true, payload: createDefaultDrawerPayload({ - parameters: [{ - name: '__image', - description: 'Image parameter', - form: 'llm', - required: true, - type: 'file', - }], + parameters: [ + { + name: '__image', + description: 'Image parameter', + form: 'llm', + required: true, + type: 'file', + }, + ], }), onHide: vi.fn(), } @@ -1200,11 +1234,13 @@ describe('WorkflowToolDrawer', () => { const props = { isAdd: true, payload: createDefaultDrawerPayload({ - outputParameters: [{ - name: 'text', // Collides with reserved - description: 'Custom text output', - type: VarType.string, - }], + outputParameters: [ + { + name: 'text', // Collides with reserved + description: 'Custom text output', + type: VarType.string, + }, + ], }), onHide: vi.fn(), } @@ -1595,10 +1631,12 @@ describe('Integration Tests', () => { // Assert await waitFor(() => { - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - workflow_tool_id: 'workflow-tool-1', - description: 'Updated description', - })) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + workflow_tool_id: 'workflow-tool-1', + description: 'Updated description', + }), + ) }) }) }) diff --git a/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts b/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts index 1f480a3e071543..403a10dbbe8a84 100644 --- a/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts +++ b/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts @@ -20,7 +20,9 @@ describe('workflow-tool helpers', () => { }) it('builds translated reserved workflow outputs', () => { - const t = withSelectorKey((key: string, options?: { ns?: string }) => `${options?.ns}:${key}`) as TFunction + const t = withSelectorKey( + (key: string, options?: { ns?: string }) => `${options?.ns}:${key}`, + ) as TFunction expect(getReservedWorkflowOutputParameters(t)).toEqual([ { @@ -44,15 +46,17 @@ describe('workflow-tool helpers', () => { }) it('derives workflow output parameters from schema through helper wrapper', () => { - expect(getWorkflowOutputParameters([], { - type: 'object', - properties: { - text: { - type: VarType.string, - description: 'Result text', + expect( + getWorkflowOutputParameters([], { + type: 'object', + properties: { + text: { + type: VarType.string, + description: 'Result text', + }, }, - }, - })).toEqual([ + }), + ).toEqual([ { name: 'text', description: 'Result text', @@ -62,26 +66,28 @@ describe('workflow-tool helpers', () => { }) it('builds workflow tool request payload', () => { - expect(buildWorkflowToolRequestPayload({ - name: 'workflow_tool', - description: 'Workflow tool', - emoji: { - content: '🧠', - background: '#ffffff', - }, - label: 'Workflow Tool', - labels: ['agent', 'workflow'], - parameters: [ - { - name: 'question', - type: VarType.string, - required: true, - form: 'llm', - description: 'Question to ask', + expect( + buildWorkflowToolRequestPayload({ + name: 'workflow_tool', + description: 'Workflow tool', + emoji: { + content: '🧠', + background: '#ffffff', }, - ], - privacyPolicy: 'https://example.com/privacy', - })).toEqual({ + label: 'Workflow Tool', + labels: ['agent', 'workflow'], + parameters: [ + { + name: 'question', + type: VarType.string, + required: true, + form: 'llm', + description: 'Question to ask', + }, + ], + privacyPolicy: 'https://example.com/privacy', + }), + ).toEqual({ name: 'workflow_tool', description: 'Workflow tool', icon: { diff --git a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx index e3791bb174f8a3..dfda7a5c1420e6 100644 --- a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx @@ -5,31 +5,44 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { WorkflowToolDrawer } from '../index' vi.mock('@/app/components/base/app-icon', () => ({ - default: ({ onClick, icon }: { onClick?: () => void, icon: string }) => ( - <button data-testid="app-icon" onClick={onClick}>{icon}</button> + default: ({ onClick, icon }: { onClick?: () => void; icon: string }) => ( + <button data-testid="app-icon" onClick={onClick}> + {icon} + </button> ), })) vi.mock('@/app/components/tools/labels/selector', () => ({ - default: ({ value, onChange }: { value: string[], onChange: (labels: string[]) => void }) => ( + default: ({ value, onChange }: { value: string[]; onChange: (labels: string[]) => void }) => ( <div data-testid="label-selector"> <span>{value.join(',')}</span> - <button data-testid="append-label" onClick={() => onChange([...value, 'new-label'])}>Add</button> + <button data-testid="append-label" onClick={() => onChange([...value, 'new-label'])}> + Add + </button> </div> ), })) vi.mock('../confirm-modal', () => ({ - default: ({ show, onClose, onConfirm }: { show: boolean, onClose: () => void, onConfirm: () => void }) => ( - show - ? ( - <div data-testid="confirm-modal"> - <button data-testid="confirm-save" onClick={onConfirm}>Confirm</button> - <button data-testid="close-confirm" onClick={onClose}>Close</button> - </div> - ) - : null - ), + default: ({ + show, + onClose, + onConfirm, + }: { + show: boolean + onClose: () => void + onConfirm: () => void + }) => + show ? ( + <div data-testid="confirm-modal"> + <button data-testid="confirm-save" onClick={onConfirm}> + Confirm + </button> + <button data-testid="close-confirm" onClick={onClose}> + Close + </button> + </div> + ) : null, })) const mockToastNotify = vi.fn() @@ -49,7 +62,9 @@ vi.mock('@/app/components/plugins/hooks', () => ({ }), })) -const createPayload = (overrides: Partial<WorkflowToolDrawerPayload> = {}): WorkflowToolDrawerPayload => ({ +const createPayload = ( + overrides: Partial<WorkflowToolDrawerPayload> = {}, +): WorkflowToolDrawerPayload => ({ icon: { content: '🔧', background: '#ffffff' }, label: 'My Tool', name: 'my_tool', @@ -78,25 +93,25 @@ describe('WorkflowToolDrawer', () => { const onCreate = vi.fn() render( - <WorkflowToolDrawer - isAdd - payload={createPayload()} - onHide={vi.fn()} - onCreate={onCreate} - />, + <WorkflowToolDrawer isAdd payload={createPayload()} onHide={vi.fn()} onCreate={onCreate} />, ) await user.clear(screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')) - await user.type(screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder'), 'Created Tool') + await user.type( + screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder'), + 'Created Tool', + ) await user.click(screen.getByTestId('append-label')) await user.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ - workflow_app_id: 'workflow-app-1', - label: 'Created Tool', - icon: { content: '🔧', background: '#ffffff' }, - labels: ['label1', 'new-label'], - })) + expect(onCreate).toHaveBeenCalledWith( + expect.objectContaining({ + workflow_app_id: 'workflow-app-1', + label: 'Created Tool', + icon: { content: '🔧', background: '#ffffff' }, + labels: ['label1', 'new-label'], + }), + ) }) it('should block invalid tool-call names before saving', async () => { @@ -115,22 +130,18 @@ describe('WorkflowToolDrawer', () => { await user.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(onCreate).not.toHaveBeenCalled() - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) it('should require confirmation before saving existing workflow tools', async () => { const user = userEvent.setup() const onSave = vi.fn() - render( - <WorkflowToolDrawer - payload={createPayload()} - onHide={vi.fn()} - onSave={onSave} - />, - ) + render(<WorkflowToolDrawer payload={createPayload()} onHide={vi.fn()} onSave={onSave} />) await user.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() @@ -138,21 +149,18 @@ describe('WorkflowToolDrawer', () => { await user.click(screen.getByTestId('confirm-save')) await waitFor(() => { - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - workflow_tool_id: 'workflow-tool-1', - name: 'my_tool', - })) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + workflow_tool_id: 'workflow-tool-1', + name: 'my_tool', + }), + ) }) }) it('should show duplicate reserved output warnings', () => { render( - <WorkflowToolDrawer - isAdd - payload={createPayload()} - onHide={vi.fn()} - onCreate={vi.fn()} - />, + <WorkflowToolDrawer isAdd payload={createPayload()} onHide={vi.fn()} onCreate={vi.fn()} />, ) expect(screen.getAllByTestId('reserved-output-warning').length).toBeGreaterThan(0) diff --git a/web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx index 3068f78e60b62e..3a84d15c25b4ca 100644 --- a/web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx @@ -94,7 +94,9 @@ describe('MethodSelector', () => { // Dropdown should now show both options with tips await waitFor(() => { - expect(screen.getByText('tools.createTool.toolInput.methodParameterTip'))!.toBeInTheDocument() + expect( + screen.getByText('tools.createTool.toolInput.methodParameterTip'), + )!.toBeInTheDocument() expect(screen.getByText('tools.createTool.toolInput.methodSettingTip'))!.toBeInTheDocument() }) }) @@ -110,7 +112,9 @@ describe('MethodSelector', () => { // Wait for dropdown to open await waitFor(() => { - expect(screen.getByText('tools.createTool.toolInput.methodParameterTip'))!.toBeInTheDocument() + expect( + screen.getByText('tools.createTool.toolInput.methodParameterTip'), + )!.toBeInTheDocument() }) // Click the llm option (by finding the method parameter option in dropdown) @@ -155,7 +159,9 @@ describe('MethodSelector', () => { await user.click(screen.getByText('tools.createTool.toolInput.methodSettingTip')) await waitFor(() => { - expect(screen.queryByText('tools.createTool.toolInput.methodSettingTip')).not.toBeInTheDocument() + expect( + screen.queryByText('tools.createTool.toolInput.methodSettingTip'), + ).not.toBeInTheDocument() }) }) @@ -168,13 +174,17 @@ describe('MethodSelector', () => { // First click - open await user.click(trigger) await waitFor(() => { - expect(screen.getByText('tools.createTool.toolInput.methodParameterTip'))!.toBeInTheDocument() + expect( + screen.getByText('tools.createTool.toolInput.methodParameterTip'), + )!.toBeInTheDocument() }) // Second click - close await user.click(trigger) await waitFor(() => { - expect(screen.queryByText('tools.createTool.toolInput.methodParameterTip')).not.toBeInTheDocument() + expect( + screen.queryByText('tools.createTool.toolInput.methodParameterTip'), + ).not.toBeInTheDocument() }) }) }) @@ -243,7 +253,9 @@ describe('MethodSelector', () => { await waitFor(() => { // Should show both option titles and descriptions // Should show both option titles and descriptions - expect(screen.getByText('tools.createTool.toolInput.methodParameterTip'))!.toBeInTheDocument() + expect( + screen.getByText('tools.createTool.toolInput.methodParameterTip'), + )!.toBeInTheDocument() expect(screen.getByText('tools.createTool.toolInput.methodSettingTip'))!.toBeInTheDocument() }) }) @@ -270,7 +282,9 @@ describe('MethodSelector', () => { await user.click(trigger) await waitFor(() => { - const options = document.querySelectorAll('.hover\\:bg-components-panel-on-panel-item-bg-hover') + const options = document.querySelectorAll( + '.hover\\:bg-components-panel-on-panel-item-bg-hover', + ) expect(options.length).toBeGreaterThanOrEqual(2) }) }) @@ -303,11 +317,15 @@ describe('MethodSelector', () => { await user.click(trigger) await waitFor(() => { - expect(screen.getByText('tools.createTool.toolInput.methodParameterTip'))!.toBeInTheDocument() + expect( + screen.getByText('tools.createTool.toolInput.methodParameterTip'), + )!.toBeInTheDocument() }) // Click the llm option in the dropdown (the one with the tip text nearby) - const llmOptionContainer = screen.getByText('tools.createTool.toolInput.methodParameterTip').closest('.cursor-pointer') + const llmOptionContainer = screen + .getByText('tools.createTool.toolInput.methodParameterTip') + .closest('.cursor-pointer') expect(llmOptionContainer)!.toBeInTheDocument() await user.click(llmOptionContainer!) diff --git a/web/app/components/tools/workflow-tool/__tests__/utils.test.ts b/web/app/components/tools/workflow-tool/__tests__/utils.test.ts index 579adee10f09c2..4fe0f1917e4fbd 100644 --- a/web/app/components/tools/workflow-tool/__tests__/utils.test.ts +++ b/web/app/components/tools/workflow-tool/__tests__/utils.test.ts @@ -1,4 +1,7 @@ -import type { WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema } from '../../types' +import type { + WorkflowToolProviderOutputParameter, + WorkflowToolProviderOutputSchema, +} from '../../types' import { VarType } from '@/app/components/workflow/types' import { buildWorkflowOutputParameters } from '../utils' @@ -56,9 +59,7 @@ describe('buildWorkflowOutputParameters', () => { const result = buildWorkflowOutputParameters(params, schema) - expect(result).toEqual([ - { name: 'missing_desc', description: '', type: undefined }, - ]) + expect(result).toEqual([{ name: 'missing_desc', description: '', type: undefined }]) }) it('derives parameters from schema when explicit array missing', () => { @@ -140,8 +141,6 @@ describe('buildWorkflowOutputParameters', () => { const result = buildWorkflowOutputParameters(undefined, schema) - expect(result).toEqual([ - { name: 'answer', description: '', type: VarType.string }, - ]) + expect(result).toEqual([{ name: 'answer', description: '', type: VarType.string }]) }) }) diff --git a/web/app/components/tools/workflow-tool/configure-button.tsx b/web/app/components/tools/workflow-tool/configure-button.tsx index 5d451144c6a7d1..bb6cc4ae72dba7 100644 --- a/web/app/components/tools/workflow-tool/configure-button.tsx +++ b/web/app/components/tools/workflow-tool/configure-button.tsx @@ -32,29 +32,37 @@ const WorkflowToolConfigureButton = ({ <> <Divider type="horizontal" className="h-px bg-divider-subtle" /> {(!published || !isLoading) && ( - <div className={cn( - 'group rounded-lg bg-background-section-burn transition-colors', - disabled ? 'cursor-not-allowed opacity-60 shadow-xs' : 'cursor-pointer', - !disabled && !published && 'hover:bg-state-accent-hover', - )} + <div + className={cn( + 'group rounded-lg bg-background-section-burn transition-colors', + disabled ? 'cursor-not-allowed opacity-60 shadow-xs' : 'cursor-pointer', + !disabled && !published && 'hover:bg-state-accent-hover', + )} > <div className="flex items-center justify-start gap-2 p-2 pl-2.5" onClick={() => { - if (!disabled && !published) - onConfigure() + if (!disabled && !published) onConfigure() }} > - <span className={cn('relative i-ri-hammer-line size-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} /> + <span + className={cn( + 'relative i-ri-hammer-line size-4 text-text-secondary', + !disabled && !published && 'group-hover:text-text-accent', + )} + /> <div - title={t($ => $['common.workflowAsTool'], { ns: 'workflow' }) || ''} - className={cn('shrink grow basis-0 truncate system-sm-medium text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} + title={t(($) => $['common.workflowAsTool'], { ns: 'workflow' }) || ''} + className={cn( + 'shrink grow basis-0 truncate system-sm-medium text-text-secondary', + !disabled && !published && 'group-hover:text-text-accent', + )} > - {t($ => $['common.workflowAsTool'], { ns: 'workflow' })} + {t(($) => $['common.workflowAsTool'], { ns: 'workflow' })} </div> {!published && ( <span className="shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['common.configureRequired'], { ns: 'workflow' })} + {t(($) => $['common.configureRequired'], { ns: 'workflow' })} </span> )} </div> @@ -72,7 +80,7 @@ const WorkflowToolConfigureButton = ({ onClick={onConfigure} disabled={disabled} > - {t($ => $['common.configure'], { ns: 'workflow' })} + {t(($) => $['common.configure'], { ns: 'workflow' })} {outdated && <StatusDot className="ml-1" status="warning" />} </Button> <Button @@ -81,20 +89,24 @@ const WorkflowToolConfigureButton = ({ onClick={() => router.push(buildIntegrationPath('workflow-tool'))} disabled={disabled} > - {t($ => $['common.manageInTools'], { ns: 'workflow' })} + {t(($) => $['common.manageInTools'], { ns: 'workflow' })} <span className="ml-1 i-ri-arrow-right-up-line size-4" /> </Button> </div> {outdated && ( <div className="mt-1 text-xs leading-[18px] text-text-warning"> - {t($ => $['common.workflowAsToolTip'], { ns: 'workflow' })} + {t(($) => $['common.workflowAsToolTip'], { ns: 'workflow' })} </div> )} </div> )} </div> )} - {published && isLoading && <div className="pt-2"><Loading type="app" /></div>} + {published && isLoading && ( + <div className="pt-2"> + <Loading type="app" /> + </div> + )} </> ) } diff --git a/web/app/components/tools/workflow-tool/confirm-modal/index.tsx b/web/app/components/tools/workflow-tool/confirm-modal/index.tsx index fb69c1663bafc9..68b6303dcfa593 100644 --- a/web/app/components/tools/workflow-tool/confirm-modal/index.tsx +++ b/web/app/components/tools/workflow-tool/confirm-modal/index.tsx @@ -23,7 +23,7 @@ const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => { > <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-4 right-4 cursor-pointer border-none bg-transparent p-2" onClick={onClose} > @@ -32,14 +32,20 @@ const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => { <div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-section p-3 shadow-xl"> <AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" /> </div> - <DialogTitle className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary">{t($ => $['createTool.confirmTitle'], { ns: 'tools' })}</DialogTitle> + <DialogTitle className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary"> + {t(($) => $['createTool.confirmTitle'], { ns: 'tools' })} + </DialogTitle> <div className="my-1 text-sm/5 text-text-tertiary"> - {t($ => $['createTool.confirmTip'], { ns: 'tools' })} + {t(($) => $['createTool.confirmTip'], { ns: 'tools' })} </div> <div className="flex items-center justify-end pt-6"> <div className="flex items-center"> - <Button className="mr-2" onClick={onClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" tone="destructive" onClick={onConfirm}>{t($ => $['operation.confirm'], { ns: 'common' })}</Button> + <Button className="mr-2" onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button variant="primary" tone="destructive" onClick={onConfirm}> + {t(($) => $['operation.confirm'], { ns: 'common' })} + </Button> </div> </div> </DialogContent> diff --git a/web/app/components/tools/workflow-tool/helpers.ts b/web/app/components/tools/workflow-tool/helpers.ts index 565d9d799ffb82..7ecc8f689d0df5 100644 --- a/web/app/components/tools/workflow-tool/helpers.ts +++ b/web/app/components/tools/workflow-tool/helpers.ts @@ -31,20 +31,20 @@ export const RESERVED_WORKFLOW_OUTPUTS: WorkflowToolProviderOutputParameter[] = ] export const isWorkflowToolNameValid = (name: string) => { - if (name === '') - return true + if (name === '') return true return /^\w+$/.test(name) } export const getReservedWorkflowOutputParameters = (t: TFunction) => { - return RESERVED_WORKFLOW_OUTPUTS.map(output => ({ + return RESERVED_WORKFLOW_OUTPUTS.map((output) => ({ ...output, - description: output.name === 'text' - ? t($ => $['nodes.tool.outputVars.text'], { ns: 'workflow' }) - : output.name === 'files' - ? t($ => $['nodes.tool.outputVars.files.title'], { ns: 'workflow' }) - : t($ => $['nodes.tool.outputVars.json'], { ns: 'workflow' }), + description: + output.name === 'text' + ? t(($) => $['nodes.tool.outputVars.text'], { ns: 'workflow' }) + : output.name === 'files' + ? t(($) => $['nodes.tool.outputVars.files.title'], { ns: 'workflow' }) + : t(($) => $['nodes.tool.outputVars.json'], { ns: 'workflow' }), })) } @@ -52,7 +52,7 @@ export const hasReservedWorkflowOutputConflict = ( reservedOutputParameters: WorkflowToolProviderOutputParameter[], name: string, ) => { - return reservedOutputParameters.some(parameter => parameter.name === name) + return reservedOutputParameters.some((parameter) => parameter.name === name) } export const getWorkflowOutputParameters = ( @@ -84,7 +84,7 @@ export const buildWorkflowToolRequestPayload = ({ description, icon: emoji, label, - parameters: parameters.map(item => ({ + parameters: parameters.map((item) => ({ name: item.name, description: item.description, form: item.form, diff --git a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts index 741d7ebadbf25c..0f8720f10be9eb 100644 --- a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts +++ b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts @@ -1,4 +1,7 @@ -import type { WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types' +import type { + WorkflowToolProviderRequest, + WorkflowToolProviderResponse, +} from '@/app/components/tools/types' import type { InputVar, Variable } from '@/app/components/workflow/types' import { act, renderHook } from '@testing-library/react' import { InputVarType } from '@/app/components/workflow/types' @@ -32,23 +35,27 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ const createMockEmoji = () => ({ content: '🔧', background: '#ffffff' }) -const createMockInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({ - variable: 'test_var', - label: 'Test Variable', - type: InputVarType.textInput, - required: true, - max_length: 100, - options: [], - ...overrides, -} as InputVar) - -const createMockVariable = (overrides: Partial<Variable> = {}): Variable => ({ - variable: 'output_var', - value_type: 'string', - ...overrides, -} as Variable) - -const createMockDetail = (overrides: Partial<WorkflowToolProviderResponse> = {}): WorkflowToolProviderResponse => ({ +const createMockInputVar = (overrides: Partial<InputVar> = {}): InputVar => + ({ + variable: 'test_var', + label: 'Test Variable', + type: InputVarType.textInput, + required: true, + max_length: 100, + options: [], + ...overrides, + }) as InputVar + +const createMockVariable = (overrides: Partial<Variable> = {}): Variable => + ({ + variable: 'output_var', + value_type: 'string', + ...overrides, + }) as Variable + +const createMockDetail = ( + overrides: Partial<WorkflowToolProviderResponse> = {}, +): WorkflowToolProviderResponse => ({ workflow_app_id: 'app-123', workflow_tool_id: 'tool-456', label: 'Test Tool', @@ -100,7 +107,9 @@ const createDefaultOptions = (overrides = {}) => ({ ...overrides, }) -const createMockRequest = (extra: Record<string, string> = {}): WorkflowToolProviderRequest & Record<string, unknown> => ({ +const createMockRequest = ( + extra: Record<string, string> = {}, +): WorkflowToolProviderRequest & Record<string, unknown> => ({ name: 'test_tool', description: 'desc', icon: createMockEmoji(), @@ -208,7 +217,9 @@ describe('useConfigureButton', () => { it('should forward isLoading from query hook', () => { mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: true }) - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true }))) + const { result } = renderHook(() => + useConfigureButton(createDefaultOptions({ published: true })), + ) expect(result.current.isLoading).toBe(true) }) @@ -223,7 +234,9 @@ describe('useConfigureButton', () => { }) it('should call query hook with enabled=false when controller is disabled', () => { - renderHook(() => useConfigureButton(createDefaultOptions({ enabled: false, published: true }))) + renderHook(() => + useConfigureButton(createDefaultOptions({ enabled: false, published: true })), + ) expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false) }) }) @@ -236,21 +249,29 @@ describe('useConfigureButton', () => { }) it('should be true when parameters differ', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ - published: true, - inputs: [ - createMockInputVar({ variable: 'test_var' }), - createMockInputVar({ variable: 'extra_var' }), - ], - }))) + const { result } = renderHook(() => + useConfigureButton( + createDefaultOptions({ + published: true, + inputs: [ + createMockInputVar({ variable: 'test_var' }), + createMockInputVar({ variable: 'extra_var' }), + ], + }), + ), + ) expect(result.current.outdated).toBe(true) }) it('should be false when parameters match', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ - published: true, - inputs: [createMockInputVar({ variable: 'test_var', required: true })], - }))) + const { result } = renderHook(() => + useConfigureButton( + createDefaultOptions({ + published: true, + inputs: [createMockInputVar({ variable: 'test_var', required: true })], + }), + ), + ) expect(result.current.outdated).toBe(false) }) }) @@ -275,7 +296,9 @@ describe('useConfigureButton', () => { }) it('should use detail values when published with detail', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true }))) + const { result } = renderHook(() => + useConfigureButton(createDefaultOptions({ published: true })), + ) expect(result.current.payload).toMatchObject({ icon: createMockEmoji(), @@ -295,14 +318,18 @@ describe('useConfigureButton', () => { it('should return empty parameters when published without detail', () => { mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: false }) - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true }))) + const { result } = renderHook(() => + useConfigureButton(createDefaultOptions({ published: true })), + ) expect(result.current.payload.parameters).toHaveLength(0) expect(result.current.payload.outputParameters).toHaveLength(0) }) it('should build output parameters from detail output_schema', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true }))) + const { result } = renderHook(() => + useConfigureButton(createDefaultOptions({ published: true })), + ) expect(result.current.payload.outputParameters).toHaveLength(1) expect(result.current.payload.outputParameters[0]).toMatchObject({ @@ -317,7 +344,9 @@ describe('useConfigureButton', () => { detail.tool.output_schema = undefined mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: detail, isLoading: false }) - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true }))) + const { result } = renderHook(() => + useConfigureButton(createDefaultOptions({ published: true })), + ) expect(result.current.payload.outputParameters[0]).toMatchObject({ name: 'output_var', @@ -326,10 +355,14 @@ describe('useConfigureButton', () => { }) it('should convert paragraph type to string in existing parameters', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ - published: true, - inputs: [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })], - }))) + const { result } = renderHook(() => + useConfigureButton( + createDefaultOptions({ + published: true, + inputs: [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })], + }), + ), + ) expect(result.current.payload.parameters[0]!.type).toBe('string') }) @@ -341,10 +374,16 @@ describe('useConfigureButton', () => { mockCreateWorkflowToolProvider.mockResolvedValue({}) const onRefreshData = vi.fn() const onConfigured = vi.fn() - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData, onConfigured }))) + const { result } = renderHook(() => + useConfigureButton(createDefaultOptions({ onRefreshData, onConfigured })), + ) await act(async () => { - await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string }) + await result.current.handleCreate( + createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { + workflow_app_id: string + }, + ) }) expect(mockCreateWorkflowToolProvider).toHaveBeenCalled() @@ -360,7 +399,11 @@ describe('useConfigureButton', () => { const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) await act(async () => { - await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string }) + await result.current.handleCreate( + createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { + workflow_app_id: string + }, + ) }) expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Create failed' }) @@ -373,15 +416,22 @@ describe('useConfigureButton', () => { const handlePublish = vi.fn().mockResolvedValue(undefined) const onRefreshData = vi.fn() const onConfigured = vi.fn() - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ - published: true, - handlePublish, - onRefreshData, - onConfigured, - }))) + const { result } = renderHook(() => + useConfigureButton( + createDefaultOptions({ + published: true, + handlePublish, + onRefreshData, + onConfigured, + }), + ), + ) await act(async () => { - await result.current.handleUpdate(createMockRequest({ workflow_tool_id: 'tool-456' }) as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>) + await result.current.handleUpdate( + createMockRequest({ workflow_tool_id: 'tool-456' }) as WorkflowToolProviderRequest & + Partial<{ workflow_app_id: string; workflow_tool_id: string }>, + ) }) expect(handlePublish).toHaveBeenCalled() @@ -394,13 +444,20 @@ describe('useConfigureButton', () => { it('should show error toast when publish fails', async () => { const handlePublish = vi.fn().mockRejectedValue(new Error('Publish failed')) - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ - published: true, - handlePublish, - }))) + const { result } = renderHook(() => + useConfigureButton( + createDefaultOptions({ + published: true, + handlePublish, + }), + ), + ) await act(async () => { - await result.current.handleUpdate(createMockRequest() as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>) + await result.current.handleUpdate( + createMockRequest() as WorkflowToolProviderRequest & + Partial<{ workflow_app_id: string; workflow_tool_id: string }>, + ) }) expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Publish failed' }) @@ -408,10 +465,15 @@ describe('useConfigureButton', () => { it('should show error toast when save fails', async () => { mockSaveWorkflowToolProvider.mockRejectedValue(new Error('Save failed')) - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true }))) + const { result } = renderHook(() => + useConfigureButton(createDefaultOptions({ published: true })), + ) await act(async () => { - await result.current.handleUpdate(createMockRequest() as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>) + await result.current.handleUpdate( + createMockRequest() as WorkflowToolProviderRequest & + Partial<{ workflow_app_id: string; workflow_tool_id: string }>, + ) }) expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Save failed' }) @@ -445,11 +507,15 @@ describe('useConfigureButton', () => { }) it('should not invalidate detail while disabled', () => { - renderHook(() => useConfigureButton(createDefaultOptions({ - enabled: false, - published: true, - detailNeedUpdate: true, - }))) + renderHook(() => + useConfigureButton( + createDefaultOptions({ + enabled: false, + published: true, + detailNeedUpdate: true, + }), + ), + ) expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled() }) @@ -459,7 +525,9 @@ describe('useConfigureButton', () => { describe('Edge Cases', () => { it('should handle undefined detail from query gracefully', () => { mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: false }) - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true }))) + const { result } = renderHook(() => + useConfigureButton(createDefaultOptions({ published: true })), + ) expect(result.current.outdated).toBe(false) expect(result.current.payload.parameters).toHaveLength(0) @@ -470,19 +538,27 @@ describe('useConfigureButton', () => { detail.tool.parameters = [] mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: detail, isLoading: false }) - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ - published: true, - inputs: [], - }))) + const { result } = renderHook(() => + useConfigureButton( + createDefaultOptions({ + published: true, + inputs: [], + }), + ), + ) expect(result.current.outdated).toBe(false) }) it('should handle undefined inputs and outputs', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ - inputs: undefined, - outputs: undefined, - }))) + const { result } = renderHook(() => + useConfigureButton( + createDefaultOptions({ + inputs: undefined, + outputs: undefined, + }), + ), + ) expect(result.current.payload.parameters).toHaveLength(0) expect(result.current.payload.outputParameters).toHaveLength(0) @@ -490,13 +566,21 @@ describe('useConfigureButton', () => { it('should handle missing onRefreshData callback in create', async () => { mockCreateWorkflowToolProvider.mockResolvedValue({}) - const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ - onRefreshData: undefined, - }))) + const { result } = renderHook(() => + useConfigureButton( + createDefaultOptions({ + onRefreshData: undefined, + }), + ), + ) // Should not throw await act(async () => { - await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string }) + await result.current.handleCreate( + createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { + workflow_app_id: string + }, + ) }) expect(mockCreateWorkflowToolProvider).toHaveBeenCalled() diff --git a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts index df3dbae9d749e4..3fe86c66cc084f 100644 --- a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts +++ b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts @@ -1,11 +1,21 @@ -import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types' +import type { + Emoji, + WorkflowToolProviderOutputParameter, + WorkflowToolProviderParameter, + WorkflowToolProviderRequest, + WorkflowToolProviderResponse, +} from '@/app/components/tools/types' import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' import { useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { createWorkflowToolProvider, saveWorkflowToolProvider } from '@/service/tools' -import { useInvalidateAllWorkflowTools, useInvalidateWorkflowToolDetailByAppID, useWorkflowToolDetailByAppID } from '@/service/use-tools' +import { + useInvalidateAllWorkflowTools, + useInvalidateWorkflowToolDetailByAppID, + useWorkflowToolDetailByAppID, +} from '@/service/use-tools' // region Pure helpers @@ -17,27 +27,22 @@ export function isParametersOutdated( detail: WorkflowToolProviderResponse | undefined, inputs: InputVar[] | undefined, ): boolean { - if (!detail) - return false - if (detail.tool.parameters.length !== (inputs?.length ?? 0)) - return true + if (!detail) return false + if (detail.tool.parameters.length !== (inputs?.length ?? 0)) return true for (const item of inputs || []) { - const param = detail.tool.parameters.find(p => p.name === item.variable) - if (!param) - return true - if (param.required !== item.required) - return true + const param = detail.tool.parameters.find((p) => p.name === item.variable) + if (!param) return true + if (param.required !== item.required) return true const needsStringType = item.type === 'paragraph' || item.type === 'text-input' - if (needsStringType && param.type !== 'string') - return true + if (needsStringType && param.type !== 'string') return true } return false } function buildNewParameters(inputs?: InputVar[]): WorkflowToolProviderParameter[] { - return (inputs || []).map(item => ({ + return (inputs || []).map((item) => ({ name: item.variable, description: '', form: 'llm', @@ -51,7 +56,7 @@ function buildExistingParameters( detail: WorkflowToolProviderResponse, ): WorkflowToolProviderParameter[] { return (inputs || []).map((item) => { - const matched = detail.tool.parameters.find(p => p.name === item.variable) + const matched = detail.tool.parameters.find((p) => p.name === item.variable) return { name: item.variable, required: item.required, @@ -63,7 +68,7 @@ function buildExistingParameters( } function buildNewOutputParameters(outputs?: Variable[]): WorkflowToolProviderOutputParameter[] { - return (outputs || []).map(item => ({ + return (outputs || []).map((item) => ({ name: item.variable, description: '', type: item.value_type, @@ -120,7 +125,10 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { const { t } = useTranslation() // Data fetching via React Query - const { data: detail, isLoading } = useWorkflowToolDetailByAppID(workflowAppId, enabled && published) + const { data: detail, isLoading } = useWorkflowToolDetailByAppID( + workflowAppId, + enabled && published, + ) // Invalidation functions (store in ref for stable effect dependency) const invalidateDetail = useInvalidateWorkflowToolDetailByAppID() @@ -131,15 +139,11 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { // Refetch when detailNeedUpdate becomes true useEffect(() => { - if (enabled && detailNeedUpdate) - invalidateDetailRef.current(workflowAppId) + if (enabled && detailNeedUpdate) invalidateDetailRef.current(workflowAppId) }, [detailNeedUpdate, enabled, workflowAppId]) // Computed values - const outdated = useMemo( - () => isParametersOutdated(detail, inputs), - [detail, inputs], - ) + const outdated = useMemo(() => isParametersOutdated(detail, inputs), [detail, inputs]) const payload = useMemo(() => { const hasPublishedDetail = published && detail?.tool @@ -178,18 +182,20 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { invalidateAllWorkflowTools() onRefreshData?.() invalidateDetail(workflowAppId) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) onConfigured?.() - } - catch (e) { + } catch (e) { toast.error((e as Error).message) } } - const handleUpdate = async (data: WorkflowToolProviderRequest & Partial<{ - workflow_app_id: string - workflow_tool_id: string - }>) => { + const handleUpdate = async ( + data: WorkflowToolProviderRequest & + Partial<{ + workflow_app_id: string + workflow_tool_id: string + }>, + ) => { try { await handlePublish() await saveWorkflowToolProvider(data) @@ -197,8 +203,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { invalidateAllWorkflowTools() invalidateDetail(workflowAppId) onConfigured?.() - } - catch (e) { + } catch (e) { toast.error((e as Error).message) } } diff --git a/web/app/components/tools/workflow-tool/index.tsx b/web/app/components/tools/workflow-tool/index.tsx index 40f645d303fd12..bad9ad27d32574 100644 --- a/web/app/components/tools/workflow-tool/index.tsx +++ b/web/app/components/tools/workflow-tool/index.tsx @@ -1,6 +1,12 @@ 'use client' import type { DrawerProps } from '@langgenius/dify-ui/drawer' -import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types' +import type { + Emoji, + WorkflowToolProviderOutputParameter, + WorkflowToolProviderOutputSchema, + WorkflowToolProviderParameter, + WorkflowToolProviderRequest, +} from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { @@ -57,10 +63,13 @@ export type WorkflowToolDrawerProps = { onHide: () => void onRemove?: () => void onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void - onSave?: (payload: WorkflowToolProviderRequest & Partial<{ - workflow_app_id: string - workflow_tool_id: string - }>) => void + onSave?: ( + payload: WorkflowToolProviderRequest & + Partial<{ + workflow_app_id: string + workflow_tool_id: string + }>, + ) => void } type WorkflowToolDrawerFrameProps = { @@ -72,24 +81,33 @@ type WorkflowToolDrawerFrameProps = { const InfoTooltip = ({ children }: { children: string }) => { return ( - <Infotip - aria-label={children} - className="ml-1 size-3.5" - popupClassName="w-[180px]" - > + <Infotip aria-label={children} className="ml-1 size-3.5" popupClassName="w-[180px]"> {children} </Infotip> ) } -const WorkflowToolDrawerFrame = ({ title, closeLabel, onHide, children }: WorkflowToolDrawerFrameProps) => { - const handleOpenChange = React.useCallback<NonNullable<DrawerProps['onOpenChange']>>((open) => { - if (!open) - onHide() - }, [onHide]) +const WorkflowToolDrawerFrame = ({ + title, + closeLabel, + onHide, + children, +}: WorkflowToolDrawerFrameProps) => { + const handleOpenChange = React.useCallback<NonNullable<DrawerProps['onOpenChange']>>( + (open) => { + if (!open) onHide() + }, + [onHide], + ) return ( - <Drawer open modal disablePointerDismissal swipeDirection="right" onOpenChange={handleOpenChange}> + <Drawer + open + modal + disablePointerDismissal + swipeDirection="right" + onOpenChange={handleOpenChange} + > <DrawerPortal> <DrawerBackdrop /> <DrawerViewport> @@ -103,18 +121,16 @@ const WorkflowToolDrawerFrame = ({ title, closeLabel, onHide, children }: Workfl <DrawerContent className="flex min-h-0 flex-1 flex-col overflow-hidden p-0 pb-0"> <div className="shrink-0 border-b border-divider-subtle py-4"> <div className="flex h-6 items-center justify-between pr-5 pl-6"> - <DrawerTitle data-testid="drawer-title" className="min-w-0 truncate system-xl-semibold text-text-primary"> + <DrawerTitle + data-testid="drawer-title" + className="min-w-0 truncate system-xl-semibold text-text-primary" + > {title} </DrawerTitle> - <DrawerCloseButton - className="size-6 rounded-md" - aria-label={closeLabel} - /> + <DrawerCloseButton className="size-6 rounded-md" aria-label={closeLabel} /> </div> </div> - <div className="grow overflow-hidden"> - {children} - </div> + <div className="grow overflow-hidden">{children}</div> </DrawerContent> </DrawerPopup> </DrawerViewport> @@ -145,17 +161,12 @@ export function WorkflowToolDrawer({ () => getWorkflowOutputParameters(rawOutputParameters, outputSchema), [rawOutputParameters, outputSchema], ) - const reservedOutputParameters = useMemo( - () => getReservedWorkflowOutputParameters(t), - [t], - ) + const reservedOutputParameters = useMemo(() => getReservedWorkflowOutputParameters(t), [t]) const handleParameterChange = (key: string, value: string, index: number) => { const newData = produce(parameters, (draft: WorkflowToolProviderParameter[]) => { - if (key === 'description') - draft[index]!.description = value - else - draft[index]!.form = value + if (key === 'description') draft[index]!.description = value + else draft[index]!.form = value }) setParameters(newData) } @@ -169,13 +180,21 @@ export function WorkflowToolDrawer({ const onConfirm = () => { let errorMessage = '' if (!label) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: t($ => $['createTool.name'], { ns: 'tools' }) }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: t(($) => $['createTool.name'], { ns: 'tools' }), + }) if (!name) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: t($ => $['createTool.nameForToolCall'], { ns: 'tools' }) }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: t(($) => $['createTool.nameForToolCall'], { ns: 'tools' }), + }) if (!isWorkflowToolNameValid(name)) - errorMessage = t($ => $['createTool.nameForToolCall'], { ns: 'tools' }) + t($ => $['createTool.nameForToolCallTip'], { ns: 'tools' }) + errorMessage = + t(($) => $['createTool.nameForToolCall'], { ns: 'tools' }) + + t(($) => $['createTool.nameForToolCallTip'], { ns: 'tools' }) if (errorMessage) { toast.error(errorMessage) @@ -196,8 +215,7 @@ export function WorkflowToolDrawer({ ...requestParams, workflow_tool_id: payload.workflow_tool_id!, }) - } - else { + } else { onCreate?.({ ...requestParams, workflow_app_id: payload.workflow_app_id!, @@ -209,68 +227,89 @@ export function WorkflowToolDrawer({ <> <WorkflowToolDrawerFrame onHide={onHide} - title={t($ => $['common.workflowAsTool'], { ns: 'workflow' })!} - closeLabel={t($ => $['operation.close'], { ns: 'common' })!} + title={t(($) => $['common.workflowAsTool'], { ns: 'workflow' })!} + closeLabel={t(($) => $['operation.close'], { ns: 'common' })!} > <div className="flex h-full flex-col"> <div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3"> {/* name & icon */} <div> <div className="py-2 system-sm-medium text-text-primary"> - {t($ => $['createTool.name'], { ns: 'tools' })} - {' '} + {t(($) => $['createTool.name'], { ns: 'tools' })}{' '} <span className="ml-1 text-red-500">*</span> </div> <div className="flex items-center justify-between gap-3"> - <AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} /> + <AppIcon + size="large" + onClick={() => { + setShowEmojiPicker(true) + }} + className="cursor-pointer" + iconType="emoji" + icon={emoji.content} + background={emoji.background} + /> <Input className="h-10 grow" - placeholder={t($ => $['createTool.toolNamePlaceHolder'], { ns: 'tools' })!} + placeholder={t(($) => $['createTool.toolNamePlaceHolder'], { ns: 'tools' })!} value={label} - onChange={e => setLabel(e.target.value)} + onChange={(e) => setLabel(e.target.value)} /> </div> </div> {/* name for tool call */} <div> <div className="flex items-center py-2 system-sm-medium text-text-primary"> - {t($ => $['createTool.nameForToolCall'], { ns: 'tools' })} - {' '} + {t(($) => $['createTool.nameForToolCall'], { ns: 'tools' })}{' '} <span className="ml-1 text-red-500">*</span> <InfoTooltip> - {t($ => $['createTool.nameForToolCallPlaceHolder'], { ns: 'tools' })} + {t(($) => $['createTool.nameForToolCallPlaceHolder'], { ns: 'tools' })} </InfoTooltip> </div> <Input className="h-10" - placeholder={t($ => $['createTool.nameForToolCallPlaceHolder'], { ns: 'tools' })!} + placeholder={t(($) => $['createTool.nameForToolCallPlaceHolder'], { ns: 'tools' })!} value={name} - onChange={e => setName(e.target.value)} + onChange={(e) => setName(e.target.value)} /> {!isWorkflowToolNameValid(name) && ( - <div className="text-xs leading-[18px] text-red-500">{t($ => $['createTool.nameForToolCallTip'], { ns: 'tools' })}</div> + <div className="text-xs leading-[18px] text-red-500"> + {t(($) => $['createTool.nameForToolCallTip'], { ns: 'tools' })} + </div> )} </div> {/* description */} <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.description'], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.description'], { ns: 'tools' })} + </div> <Textarea - aria-label={t($ => $['createTool.description'], { ns: 'tools' })} - placeholder={t($ => $['createTool.descriptionPlaceholder'], { ns: 'tools' }) || ''} + aria-label={t(($) => $['createTool.description'], { ns: 'tools' })} + placeholder={ + t(($) => $['createTool.descriptionPlaceholder'], { ns: 'tools' }) || '' + } value={description} - onValueChange={value => setDescription(value)} + onValueChange={(value) => setDescription(value)} /> </div> {/* Tool Input */} <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.toolInput.title'], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.toolInput.title'], { ns: 'tools' })} + </div> <div className="w-full overflow-x-auto rounded-lg border border-divider-regular"> <table className="w-full text-xs leading-[18px] font-normal text-text-secondary"> <thead className="text-text-tertiary uppercase"> <tr className="border-b border-divider-regular"> - <th className="w-[156px] p-2 pl-3 font-medium">{t($ => $['createTool.toolInput.name'], { ns: 'tools' })}</th> - <th className="w-[102px] p-2 pl-3 font-medium">{t($ => $['createTool.toolInput.method'], { ns: 'tools' })}</th> - <th className="p-2 pl-3 font-medium">{t($ => $['createTool.toolInput.description'], { ns: 'tools' })}</th> + <th className="w-[156px] p-2 pl-3 font-medium"> + {t(($) => $['createTool.toolInput.name'], { ns: 'tools' })} + </th> + <th className="w-[102px] p-2 pl-3 font-medium"> + {t(($) => $['createTool.toolInput.method'], { ns: 'tools' })} + </th> + <th className="p-2 pl-3 font-medium"> + {t(($) => $['createTool.toolInput.description'], { ns: 'tools' })} + </th> </tr> </thead> <tbody> @@ -279,34 +318,56 @@ export function WorkflowToolDrawer({ <td className="max-w-[156px] p-2 pl-3"> <div className="text-[13px] leading-[18px]"> <div className="flex"> - <span className="truncate font-medium text-text-primary">{item.name}</span> - <span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.required ? t($ => $['createTool.toolInput.required'], { ns: 'tools' }) : ''}</span> + <span className="truncate font-medium text-text-primary"> + {item.name} + </span> + <span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]"> + {item.required + ? t(($) => $['createTool.toolInput.required'], { ns: 'tools' }) + : ''} + </span> </div> <div className="text-text-tertiary">{item.type}</div> </div> </td> <td> {item.name === '__image' && ( - <div className={cn( - 'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2', - )} + <div + className={cn( + 'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2', + )} > - <div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}> - {t($ => $['createTool.toolInput.methodParameter'], { ns: 'tools' })} + <div + className={cn( + 'grow truncate text-[13px] leading-[18px] text-text-secondary', + )} + > + {t(($) => $['createTool.toolInput.methodParameter'], { + ns: 'tools', + })} </div> </div> )} {item.name !== '__image' && ( - <MethodSelector value={item.form} onChange={value => handleParameterChange('form', value, index)} /> + <MethodSelector + value={item.form} + onChange={(value) => handleParameterChange('form', value, index)} + /> )} </td> <td className="w-[236px] p-2 pl-3 text-text-tertiary"> <input type="text" className="w-full appearance-none bg-transparent text-[13px] leading-[18px] font-normal text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary" - placeholder={t($ => $['createTool.toolInput.descriptionPlaceholder'], { ns: 'tools' })!} + placeholder={ + t(($) => $['createTool.toolInput.descriptionPlaceholder'], { + ns: 'tools', + })! + } value={item.description} - onChange={e => handleParameterChange('description', e.target.value, index)} + onChange={(e) => + handleParameterChange('description', e.target.value, index) + } /> </td> </tr> @@ -317,13 +378,19 @@ export function WorkflowToolDrawer({ </div> {/* Tool Output */} <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.toolOutput.title'], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.toolOutput.title'], { ns: 'tools' })} + </div> <div className="w-full overflow-x-auto rounded-lg border border-divider-regular"> <table className="w-full text-xs leading-[18px] font-normal text-text-secondary"> <thead className="text-text-tertiary uppercase"> <tr className="border-b border-divider-regular"> - <th className="w-[156px] p-2 pl-3 font-medium">{t($ => $['createTool.name'], { ns: 'tools' })}</th> - <th className="p-2 pl-3 font-medium">{t($ => $['createTool.toolOutput.description'], { ns: 'tools' })}</th> + <th className="w-[156px] p-2 pl-3 font-medium"> + {t(($) => $['createTool.name'], { ns: 'tools' })} + </th> + <th className="p-2 pl-3 font-medium"> + {t(($) => $['createTool.toolOutput.description'], { ns: 'tools' })} + </th> </tr> </thead> <tbody> @@ -332,32 +399,47 @@ export function WorkflowToolDrawer({ <td className="max-w-[156px] p-2 pl-3"> <div className="text-[13px] leading-[18px]"> <div className="flex items-center"> - <span className="truncate font-medium text-text-primary">{item.name}</span> - <span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.reserved ? t($ => $['createTool.toolOutput.reserved'], { ns: 'tools' }) : ''}</span> - { - !item.reserved && hasReservedWorkflowOutputConflict(reservedOutputParameters, item.name) - ? ( - <Tooltip> - <TooltipTrigger - render={( - <span data-testid="reserved-output-warning" className="i-ri-error-warning-line size-3 text-text-warning-secondary" /> - )} - /> - <TooltipContent> - <div className="w-[180px]"> - {t($ => $['createTool.toolOutput.reservedParameterDuplicateTip'], { ns: 'tools' })} - </div> - </TooltipContent> - </Tooltip> - ) - : null - } + <span className="truncate font-medium text-text-primary"> + {item.name} + </span> + <span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]"> + {item.reserved + ? t(($) => $['createTool.toolOutput.reserved'], { ns: 'tools' }) + : ''} + </span> + {!item.reserved && + hasReservedWorkflowOutputConflict( + reservedOutputParameters, + item.name, + ) ? ( + <Tooltip> + <TooltipTrigger + render={ + <span + data-testid="reserved-output-warning" + className="i-ri-error-warning-line size-3 text-text-warning-secondary" + /> + } + /> + <TooltipContent> + <div className="w-[180px]"> + {t( + ($) => + $['createTool.toolOutput.reservedParameterDuplicateTip'], + { ns: 'tools' }, + )} + </div> + </TooltipContent> + </Tooltip> + ) : null} </div> <div className="text-text-tertiary">{item.type}</div> </div> </td> <td className="w-[236px] p-2 pl-3 text-text-tertiary"> - <span className="text-[13px] leading-[18px] font-normal text-text-secondary">{item.description}</span> + <span className="text-[13px] leading-[18px] font-normal text-text-secondary"> + {item.description} + </span> </td> </tr> ))} @@ -367,36 +449,47 @@ export function WorkflowToolDrawer({ </div> {/* Tags */} <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.toolInput.label'], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.toolInput.label'], { ns: 'tools' })} + </div> <LabelSelector value={labels} onChange={handleLabelSelect} /> </div> {/* Privacy Policy */} <div> - <div className="py-2 system-sm-medium text-text-primary">{t($ => $['createTool.privacyPolicy'], { ns: 'tools' })}</div> + <div className="py-2 system-sm-medium text-text-primary"> + {t(($) => $['createTool.privacyPolicy'], { ns: 'tools' })} + </div> <Input className="h-10" value={privacyPolicy} - onChange={e => setPrivacyPolicy(e.target.value)} - placeholder={t($ => $['createTool.privacyPolicyPlaceholder'], { ns: 'tools' }) || ''} + onChange={(e) => setPrivacyPolicy(e.target.value)} + placeholder={ + t(($) => $['createTool.privacyPolicyPlaceholder'], { ns: 'tools' }) || '' + } /> </div> </div> - <div className={cn((!isAdd && onRemove) ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}> + <div + className={cn( + !isAdd && onRemove ? 'justify-between' : 'justify-end', + 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4', + )} + > {!isAdd && onRemove && ( - <Button variant="primary" tone="destructive" onClick={onRemove}>{t($ => $['operation.delete'], { ns: 'common' })}</Button> + <Button variant="primary" tone="destructive" onClick={onRemove}> + {t(($) => $['operation.delete'], { ns: 'common' })} + </Button> )} <div className="flex space-x-2"> - <Button onClick={onHide}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> + <Button onClick={onHide}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> <Button variant="primary" onClick={() => { - if (isAdd) - onConfirm() - else - setConfirmModalOpen(true) + if (isAdd) onConfirm() + else setConfirmModalOpen(true) }} > - {t($ => $['operation.save'], { ns: 'common' })} + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </div> @@ -423,6 +516,5 @@ export function WorkflowToolDrawer({ /> )} </> - ) } diff --git a/web/app/components/tools/workflow-tool/method-selector.tsx b/web/app/components/tools/workflow-tool/method-selector.tsx index 9924c55fd8b369..ca860f231e7706 100644 --- a/web/app/components/tools/workflow-tool/method-selector.tsx +++ b/web/app/components/tools/workflow-tool/method-selector.tsx @@ -1,10 +1,6 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowDownSLine } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -14,10 +10,7 @@ type MethodSelectorProps = { value?: string onChange: (v: string) => void } -const MethodSelector: FC<MethodSelectorProps> = ({ - value, - onChange, -}) => { +const MethodSelector: FC<MethodSelectorProps> = ({ value, onChange }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const handleSelect = (value: string) => { @@ -26,32 +19,29 @@ const MethodSelector: FC<MethodSelectorProps> = ({ } return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <div className="relative"> <PopoverTrigger nativeButton={false} - render={( - <div className={cn( - 'flex h-9 min-h-[56px] cursor-pointer items-center gap-1 bg-transparent px-3 py-2 hover:bg-background-section-burn', - 'data-popup-open:bg-background-section-burn! data-popup-open:hover:bg-background-section-burn', - )} + render={ + <div + className={cn( + 'flex h-9 min-h-[56px] cursor-pointer items-center gap-1 bg-transparent px-3 py-2 hover:bg-background-section-burn', + 'data-popup-open:bg-background-section-burn! data-popup-open:hover:bg-background-section-burn', + )} > <div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}> - {value === 'llm' ? t($ => $['createTool.toolInput.methodParameter'], { ns: 'tools' }) : t($ => $['createTool.toolInput.methodSetting'], { ns: 'tools' })} + {value === 'llm' + ? t(($) => $['createTool.toolInput.methodParameter'], { ns: 'tools' }) + : t(($) => $['createTool.toolInput.methodSetting'], { ns: 'tools' })} </div> <div className="ml-1 shrink-0 text-text-secondary opacity-60"> <RiArrowDownSLine className="size-4" /> </div> </div> - )} + } /> - <PopoverContent - placement="bottom-start" - sideOffset={4} - > + <PopoverContent placement="bottom-start" sideOffset={4}> <div className="relative w-[320px]"> <div className="p-1"> <button @@ -61,11 +51,17 @@ const MethodSelector: FC<MethodSelectorProps> = ({ > <div className="flex items-center gap-1"> <div className="size-4 shrink-0"> - {value === 'llm' && <Check className="size-4 shrink-0 text-text-accent" aria-hidden />} + {value === 'llm' && ( + <Check className="size-4 shrink-0 text-text-accent" aria-hidden /> + )} </div> - <div className="text-[13px] leading-[18px] font-medium text-text-secondary">{t($ => $['createTool.toolInput.methodParameter'], { ns: 'tools' })}</div> + <div className="text-[13px] leading-[18px] font-medium text-text-secondary"> + {t(($) => $['createTool.toolInput.methodParameter'], { ns: 'tools' })} + </div> + </div> + <div className="pl-5 text-[13px] leading-[18px] text-text-tertiary"> + {t(($) => $['createTool.toolInput.methodParameterTip'], { ns: 'tools' })} </div> - <div className="pl-5 text-[13px] leading-[18px] text-text-tertiary">{t($ => $['createTool.toolInput.methodParameterTip'], { ns: 'tools' })}</div> </button> <button type="button" @@ -74,11 +70,17 @@ const MethodSelector: FC<MethodSelectorProps> = ({ > <div className="flex items-center gap-1"> <div className="size-4 shrink-0"> - {value === 'form' && <Check className="size-4 shrink-0 text-text-accent" aria-hidden />} + {value === 'form' && ( + <Check className="size-4 shrink-0 text-text-accent" aria-hidden /> + )} </div> - <div className="text-[13px] leading-[18px] font-medium text-text-secondary">{t($ => $['createTool.toolInput.methodSetting'], { ns: 'tools' })}</div> + <div className="text-[13px] leading-[18px] font-medium text-text-secondary"> + {t(($) => $['createTool.toolInput.methodSetting'], { ns: 'tools' })} + </div> + </div> + <div className="pl-5 text-[13px] leading-[18px] text-text-tertiary"> + {t(($) => $['createTool.toolInput.methodSettingTip'], { ns: 'tools' })} </div> - <div className="pl-5 text-[13px] leading-[18px] text-text-tertiary">{t($ => $['createTool.toolInput.methodSettingTip'], { ns: 'tools' })}</div> </button> </div> </div> diff --git a/web/app/components/tools/workflow-tool/utils.ts b/web/app/components/tools/workflow-tool/utils.ts index c5a5ef17d9c2e8..5273535bab5536 100644 --- a/web/app/components/tools/workflow-tool/utils.ts +++ b/web/app/components/tools/workflow-tool/utils.ts @@ -1,13 +1,15 @@ -import type { WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema } from '../types' +import type { + WorkflowToolProviderOutputParameter, + WorkflowToolProviderOutputSchema, +} from '../types' import { VarType } from '@/app/components/workflow/types' const validVarTypes = new Set<string>(Object.values(VarType)) const normalizeVarType = (type?: string): VarType | undefined => { - if (!type) - return undefined + if (!type) return undefined - return validVarTypes.has(type) ? type as VarType : undefined + return validVarTypes.has(type) ? (type as VarType) : undefined } export const buildWorkflowOutputParameters = ( @@ -17,8 +19,7 @@ export const buildWorkflowOutputParameters = ( const schemaProperties = outputSchema?.properties if (Array.isArray(outputParameters) && outputParameters.length > 0) { - if (!schemaProperties) - return outputParameters + if (!schemaProperties) return outputParameters return outputParameters.map((item) => { const schema = schemaProperties[item.name] @@ -30,8 +31,7 @@ export const buildWorkflowOutputParameters = ( }) } - if (!schemaProperties) - return [] + if (!schemaProperties) return [] return Object.entries(schemaProperties).map(([name, schema]) => ({ name, diff --git a/web/app/components/workflow-app/__tests__/index.spec.tsx b/web/app/components/workflow-app/__tests__/index.spec.tsx index 7f4b9ee1f1f3ea..cf4ab76ec60835 100644 --- a/web/app/components/workflow-app/__tests__/index.spec.tsx +++ b/web/app/components/workflow-app/__tests__/index.spec.tsx @@ -28,7 +28,7 @@ let workflowInitState: { graph: { nodes: Array<Record<string, unknown>> edges: Array<Record<string, unknown>> - viewport: { x: number, y: number, zoom: number } + viewport: { x: number; y: number; zoom: number } } features: Record<string, unknown> } | null @@ -136,7 +136,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -202,7 +203,11 @@ vi.mock('@/app/components/workflow', () => ({ edges: Array<Record<string, unknown>> children: ReactNode }) => ( - <div data-testid="workflow-default-context" data-nodes={JSON.stringify(nodes)} data-edges={JSON.stringify(edges)}> + <div + data-testid="workflow-default-context" + data-nodes={JSON.stringify(nodes)} + data-edges={JSON.stringify(edges)} + > {children} </div> ), @@ -214,9 +219,7 @@ vi.mock('@/app/components/workflow/context', () => ({ }: { injectWorkflowStoreSliceFn: unknown children: ReactNode - }) => ( - <div data-testid="workflow-context-provider">{children}</div> - ), + }) => <div data-testid="workflow-context-provider">{children}</div>, })) vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({ @@ -304,9 +307,18 @@ describe('WorkflowApp', () => { render(<WorkflowApp />) expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument() - expect(screen.getByTestId('workflow-default-context')).toHaveAttribute('data-nodes', JSON.stringify([{ id: 'node-1' }])) - expect(screen.getByTestId('workflow-default-context')).toHaveAttribute('data-edges', JSON.stringify([{ id: 'edge-1' }])) - expect(screen.getByTestId('workflow-app-main')).toHaveAttribute('data-viewport', JSON.stringify({ x: 1, y: 2, zoom: 3 })) + expect(screen.getByTestId('workflow-default-context')).toHaveAttribute( + 'data-nodes', + JSON.stringify([{ id: 'node-1' }]), + ) + expect(screen.getByTestId('workflow-default-context')).toHaveAttribute( + 'data-edges', + JSON.stringify([{ id: 'edge-1' }]), + ) + expect(screen.getByTestId('workflow-app-main')).toHaveAttribute( + 'data-viewport', + JSON.stringify({ x: 1, y: 2, zoom: 3 }), + ) expect(screen.getByTestId('features-provider')).toBeInTheDocument() expect(mockSetTriggerStatuses).toHaveBeenCalledWith({ 'trigger-enabled': 'enabled', @@ -324,7 +336,8 @@ describe('WorkflowApp', () => { it('should replay workflow inputs from replayRunId and clean up workflow state on unmount', async () => { searchParamsValue = 'run-1' mockFetchRunDetail.mockResolvedValue({ - inputs: '{"sys.query":"hidden","foo":"bar","count":2,"flag":true,"obj":{"nested":true},"nil":null}', + inputs: + '{"sys.query":"hidden","foo":"bar","count":2,"flag":true,"obj":{"nested":true},"nil":null}', }) const { unmount } = render(<WorkflowApp />) diff --git a/web/app/components/workflow-app/__tests__/utils.spec.ts b/web/app/components/workflow-app/__tests__/utils.spec.ts index c8a9fffeeca973..bea1464eb0eb0b 100644 --- a/web/app/components/workflow-app/__tests__/utils.spec.ts +++ b/web/app/components/workflow-app/__tests__/utils.spec.ts @@ -1,18 +1,16 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' -import { - buildInitialFeatures, - buildTriggerStatusMap, - coerceReplayUserInputs, -} from '../utils' +import { buildInitialFeatures, buildTriggerStatusMap, coerceReplayUserInputs } from '../utils' describe('workflow-app utils', () => { it('should map trigger statuses to enabled and disabled states', () => { - expect(buildTriggerStatusMap([ - { node_id: 'node-1', status: 'enabled' }, - { node_id: 'node-2', status: 'disabled' }, - { node_id: 'node-3', status: 'paused' }, - ])).toEqual({ + expect( + buildTriggerStatusMap([ + { node_id: 'node-1', status: 'enabled' }, + { node_id: 'node-2', status: 'disabled' }, + { node_id: 'node-3', status: 'paused' }, + ]), + ).toEqual({ 'node-1': 'enabled', 'node-2': 'disabled', 'node-3': 'disabled', @@ -20,14 +18,16 @@ describe('workflow-app utils', () => { }) it('should coerce replay run inputs, omit sys keys, and stringify complex values', () => { - expect(coerceReplayUserInputs({ - 'sys.query': 'hidden', - 'query': 'hello', - 'count': 3, - 'enabled': true, - 'nullable': null, - 'metadata': { nested: true }, - })).toEqual({ + expect( + coerceReplayUserInputs({ + 'sys.query': 'hidden', + query: 'hello', + count: 3, + enabled: true, + nullable: null, + metadata: { nested: true }, + }), + ).toEqual({ query: 'hello', count: 3, enabled: true, @@ -39,27 +39,30 @@ describe('workflow-app utils', () => { }) it('should build initial features with file-upload and feature fallbacks', () => { - const result = buildInitialFeatures({ - file_upload: { - enabled: true, - allowed_file_types: [SupportUploadFileTypes.image], - allowed_file_extensions: ['.png'], - allowed_file_upload_methods: [TransferMethod.local_file], - number_limits: 2, - image: { + const result = buildInitialFeatures( + { + file_upload: { enabled: true, - number_limits: 5, - transfer_methods: [TransferMethod.remote_url], + allowed_file_types: [SupportUploadFileTypes.image], + allowed_file_extensions: ['.png'], + allowed_file_upload_methods: [TransferMethod.local_file], + number_limits: 2, + image: { + enabled: true, + number_limits: 5, + transfer_methods: [TransferMethod.remote_url], + }, }, + opening_statement: 'hello', + suggested_questions: ['Q1'], + suggested_questions_after_answer: { enabled: true }, + speech_to_text: { enabled: true }, + text_to_speech: { enabled: true }, + retriever_resource: { enabled: true }, + sensitive_word_avoidance: { enabled: true }, }, - opening_statement: 'hello', - suggested_questions: ['Q1'], - suggested_questions_after_answer: { enabled: true }, - speech_to_text: { enabled: true }, - text_to_speech: { enabled: true }, - retriever_resource: { enabled: true }, - sensitive_word_avoidance: { enabled: true }, - }, { enabled: true } as never) + { enabled: true } as never, + ) expect(result).toMatchObject({ file: { diff --git a/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx index aff1c32b19d220..7b0a350ca1af28 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx @@ -46,7 +46,9 @@ const mockAutoGenerateWebhookUrl = vi.fn() let workflowStoreState: WorkflowStoreState let mockCanEdit = true -let eventSubscription: ((value: { type: string, payload: { data: Array<Record<string, unknown>> } }) => void) | null = null +let eventSubscription: + | ((value: { type: string; payload: { data: Array<Record<string, unknown>> } }) => void) + | null = null let lastGenerateNodeInput: Record<string, unknown> | null = null vi.mock('reactflow', () => ({ @@ -63,7 +65,9 @@ vi.mock('@/app/components/workflow/store', () => ({ })) vi.mock('@/app/components/workflow/hooks-store', () => ({ - useHooksStore: <T,>(selector: (state: { accessControl: { canImportExportDSL: boolean, canEdit: boolean } }) => T): T => + useHooksStore: <T,>( + selector: (state: { accessControl: { canImportExportDSL: boolean; canEdit: boolean } }) => T, + ): T => selector({ accessControl: { canImportExportDSL: true, @@ -163,17 +167,16 @@ vi.mock('@/next/dynamic', async () => { const ReactModule = await import('react') return { - default: ( - loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>, - ) => { + default: (loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>) => { const DynamicComponent = (props: Record<string, unknown>) => { - const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null) + const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType< + Record<string, unknown> + > | null>(null) ReactModule.useEffect(() => { let mounted = true loader().then((mod) => { - if (mounted) - setLoaded(() => mod.default) + if (mounted) setLoaded(() => mod.default) }) return () => { mounted = false @@ -203,9 +206,15 @@ vi.mock('@/app/components/workflow/update-dsl-modal', () => ({ onImport: () => void }) => ( <div data-testid="update-dsl-modal"> - <button type="button" onClick={onCancel}>cancel-import-dsl</button> - <button type="button" onClick={onBackup}>backup-dsl</button> - <button type="button" onClick={onImport}>import-dsl</button> + <button type="button" onClick={onCancel}> + cancel-import-dsl + </button> + <button type="button" onClick={onBackup}> + backup-dsl + </button> + <button type="button" onClick={onImport}> + import-dsl + </button> </div> ), })) @@ -221,8 +230,12 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ onClose: () => void }) => ( <div data-testid="dsl-export-confirm-modal" data-env-count={String(envList.length)}> - <button type="button" onClick={onConfirm}>confirm-export-dsl</button> - <button type="button" onClick={onClose}>close-export-dsl</button> + <button type="button" onClick={onConfirm}> + confirm-export-dsl + </button> + <button type="button" onClick={onClose}> + close-export-dsl + </button> </div> ), })) @@ -237,55 +250,65 @@ vi.mock('@/app/components/workflow-app/components/workflow-onboarding-modal', () onSelectStartNode: (nodeType: BlockEnum, config?: TriggerPluginConfig) => void }) => ( <div data-testid="workflow-onboarding-modal"> - <button type="button" onClick={onClose}>close-onboarding</button> - <button type="button" onClick={() => onSelectStartNode(BlockEnum.Start)}>select-start-node</button> + <button type="button" onClick={onClose}> + close-onboarding + </button> + <button type="button" onClick={() => onSelectStartNode(BlockEnum.Start)}> + select-start-node + </button> <button type="button" - onClick={() => onSelectStartNode(BlockEnum.Start, { - title: 'Configured Start Title', - desc: 'Configured Start Description', - config: { image: true, custom: 'config' }, - extra: 'field', - } as never)} + onClick={() => + onSelectStartNode(BlockEnum.Start, { + title: 'Configured Start Title', + desc: 'Configured Start Description', + config: { image: true, custom: 'config' }, + extra: 'field', + } as never) + } > select-start-node-with-config </button> <button type="button" - onClick={() => onSelectStartNode(BlockEnum.TriggerPlugin, { - plugin_id: 'plugin-id', - provider_name: 'provider-name', - provider_type: 'tool', - event_name: 'event-name', - event_label: 'Event Label', - event_description: 'Event Description', - output_schema: { output: true }, - paramSchemas: [{ name: 'api_key' }], - params: { token: 'abc' }, - subscription_id: 'subscription-id', - plugin_unique_identifier: 'plugin-unique', - is_team_authorization: true, - meta: { source: 'plugin' }, - })} + onClick={() => + onSelectStartNode(BlockEnum.TriggerPlugin, { + plugin_id: 'plugin-id', + provider_name: 'provider-name', + provider_type: 'tool', + event_name: 'event-name', + event_label: 'Event Label', + event_description: 'Event Description', + output_schema: { output: true }, + paramSchemas: [{ name: 'api_key' }], + params: { token: 'abc' }, + subscription_id: 'subscription-id', + plugin_unique_identifier: 'plugin-unique', + is_team_authorization: true, + meta: { source: 'plugin' }, + }) + } > select-trigger-plugin </button> <button type="button" - onClick={() => onSelectStartNode(BlockEnum.TriggerPlugin, { - plugin_id: 'plugin-id-2', - provider_name: 'provider-name-2', - provider_type: 'tool', - event_name: 'event-name-2', - event_label: '', - event_description: '', - output_schema: {}, - paramSchemas: undefined, - params: {}, - subscription_id: 'subscription-id-2', - plugin_unique_identifier: 'plugin-unique-2', - is_team_authorization: false, - } as never)} + onClick={() => + onSelectStartNode(BlockEnum.TriggerPlugin, { + plugin_id: 'plugin-id-2', + provider_name: 'provider-name-2', + provider_type: 'tool', + event_name: 'event-name-2', + event_label: '', + event_description: '', + output_schema: {}, + paramSchemas: undefined, + params: {}, + subscription_id: 'subscription-id-2', + plugin_unique_identifier: 'plugin-unique-2', + is_team_authorization: false, + } as never) + } > select-trigger-plugin-fallback </button> @@ -308,9 +331,11 @@ describe('WorkflowChildren', () => { eventSubscription = null lastGenerateNodeInput = null mockCanEdit = true - mockHandleSyncWorkflowDraft.mockImplementation((_force?: boolean, _notRefresh?: boolean, callback?: { onSuccess?: () => void }) => { - callback?.onSuccess?.() - }) + mockHandleSyncWorkflowDraft.mockImplementation( + (_force?: boolean, _notRefresh?: boolean, callback?: { onSuccess?: () => void }) => { + callback?.onSuccess?.() + }, + ) }) it('should render feature panel, import modal actions, and default workflow chrome', async () => { @@ -352,7 +377,10 @@ describe('WorkflowChildren', () => { }) }) - expect(await screen.findByTestId('dsl-export-confirm-modal')).toHaveAttribute('data-env-count', '2') + expect(await screen.findByTestId('dsl-export-confirm-modal')).toHaveAttribute( + 'data-env-count', + '2', + ) await user.click(screen.getByRole('button', { name: /confirm-export-dsl/i })) await user.click(screen.getByRole('button', { name: /close-export-dsl/i })) diff --git a/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx index cc3f726f6b6009..91ad62f1800008 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx @@ -55,8 +55,8 @@ const hookFns = { const collaborationRuntime = vi.hoisted(() => ({ startCursorTracking: vi.fn(), stopCursorTracking: vi.fn(), - onlineUsers: [] as Array<{ user_id: string, username: string, avatar: string, sid: string }>, - cursors: {} as Record<string, { x: number, y: number, userId: string, timestamp: number }>, + onlineUsers: [] as Array<{ user_id: string; username: string; avatar: string; sid: string }>, + cursors: {} as Record<string, { x: number; y: number; userId: string; timestamp: number }>, isConnected: false, isEnabled: false, })) @@ -69,7 +69,10 @@ const collaborationListeners = vi.hoisted(() => ({ let capturedContextProps: Record<string, unknown> | null = null -type MockWorkflowWithInnerContextProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport' | 'onWorkflowDataUpdate' | 'cursors' | 'myUserId' | 'onlineUsers'> & { +type MockWorkflowWithInnerContextProps = Pick< + WorkflowProps, + 'nodes' | 'edges' | 'viewport' | 'onWorkflowDataUpdate' | 'cursors' | 'myUserId' | 'onlineUsers' +> & { hooksStore?: Record<string, unknown> children?: ReactNode } @@ -83,9 +86,10 @@ vi.mock('@/app/components/base/features/hooks', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: <T,>(selector: (state: { appId: string }) => T) => selector({ - appId: 'app-1', - }), + useStore: <T,>(selector: (state: { appId: string }) => T) => + selector({ + appId: 'app-1', + }), useWorkflowStore: () => ({ getState: () => ({ setConversationVariables: mockSetConversationVariables, @@ -122,14 +126,18 @@ vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ collaborationManager: { - onVarsAndFeaturesUpdate: mockOnVarsAndFeaturesUpdate.mockImplementation((handler: (update: unknown) => void | Promise<void>) => { - collaborationListeners.varsAndFeaturesUpdate = handler - return vi.fn() - }), - onWorkflowUpdate: mockOnWorkflowUpdate.mockImplementation((handler: () => void | Promise<void>) => { - collaborationListeners.workflowUpdate = handler - return vi.fn() - }), + onVarsAndFeaturesUpdate: mockOnVarsAndFeaturesUpdate.mockImplementation( + (handler: (update: unknown) => void | Promise<void>) => { + collaborationListeners.varsAndFeaturesUpdate = handler + return vi.fn() + }, + ), + onWorkflowUpdate: mockOnWorkflowUpdate.mockImplementation( + (handler: () => void | Promise<void>) => { + collaborationListeners.workflowUpdate = handler + return vi.fn() + }, + ), onSyncRequest: mockOnSyncRequest.mockImplementation((handler: () => void) => { collaborationListeners.syncRequest = handler return vi.fn() @@ -166,48 +174,55 @@ vi.mock('@/app/components/workflow', () => ({ <div data-testid="workflow-inner-context"> <button type="button" - onClick={() => onWorkflowDataUpdate?.({ - nodes: [], - edges: [], - features: { file_upload: { enabled: true } }, - conversation_variables: [{ - id: 'conversation-1', - name: 'conversation-1', - value_type: ChatVarType.String, - value: '', - description: '', - }], - environment_variables: [{ - id: 'env-1', - name: 'env-1', - value: '', - value_type: 'string', - description: '', - }], - })} + onClick={() => + onWorkflowDataUpdate?.({ + nodes: [], + edges: [], + features: { file_upload: { enabled: true } }, + conversation_variables: [ + { + id: 'conversation-1', + name: 'conversation-1', + value_type: ChatVarType.String, + value: '', + description: '', + }, + ], + environment_variables: [ + { + id: 'env-1', + name: 'env-1', + value: '', + value_type: 'string', + description: '', + }, + ], + }) + } > update-workflow-data </button> <button type="button" - onClick={() => onWorkflowDataUpdate?.({ - nodes: [], - edges: [], - conversation_variables: [{ - id: 'conversation-only', - name: 'conversation-only', - value_type: ChatVarType.String, - value: '', - description: '', - }], - })} + onClick={() => + onWorkflowDataUpdate?.({ + nodes: [], + edges: [], + conversation_variables: [ + { + id: 'conversation-only', + name: 'conversation-only', + value_type: ChatVarType.String, + value: '', + description: '', + }, + ], + }) + } > update-conversation-only </button> - <button - type="button" - onClick={() => onWorkflowDataUpdate?.({ nodes: [], edges: [] })} - > + <button type="button" onClick={() => onWorkflowDataUpdate?.({ nodes: [], edges: [] })}> update-empty-payload </button> {children} @@ -217,10 +232,16 @@ vi.mock('@/app/components/workflow', () => ({ })) vi.mock('@/app/components/workflow-app/hooks', () => ({ - useAvailableNodesMetaData: () => ({ nodes: [{ id: 'start' }], nodesMap: { start: { id: 'start' } } }), + useAvailableNodesMetaData: () => ({ + nodes: [{ id: 'start' }], + nodesMap: { start: { id: 'start' } }, + }), useConfigsMap: () => ({ flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } }), useDSL: () => ({ exportCheck: hookFns.exportCheck, handleExportDSL: hookFns.handleExportDSL }), - useDSLByCanEdit: () => ({ exportCheck: hookFns.exportCheck, handleExportDSL: hookFns.handleExportDSL }), + useDSLByCanEdit: () => ({ + exportCheck: hookFns.exportCheck, + handleExportDSL: hookFns.handleExportDSL, + }), useGetRunAndTraceUrl: () => ({ getWorkflowRunAndTraceUrl: hookFns.getWorkflowRunAndTraceUrl }), useInspectVarsCrud: () => ({ hasNodeInspectVars: hookFns.hasNodeInspectVars, @@ -249,7 +270,9 @@ vi.mock('@/app/components/workflow-app/hooks', () => ({ useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: hookFns.fetchInspectVars, }), - useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft }), + useWorkflowRefreshDraft: () => ({ + handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft, + }), useWorkflowRun: () => ({ handleBackupDraft: hookFns.handleBackupDraft, handleLoadBackupDraft: hookFns.handleLoadBackupDraft, @@ -286,7 +309,11 @@ vi.mock('@/app/components/workflow-app/hooks', () => ({ vi.mock('@/app/components/workflow-app/hooks/use-workflow-draft-graph-for-canvas', () => ({ useWorkflowDraftGraphForCanvas: () => ({ - getWorkflowDraftGraphForCanvas: (graph?: { nodes?: unknown[], edges?: unknown[], viewport?: unknown }) => ({ + getWorkflowDraftGraphForCanvas: (graph?: { + nodes?: unknown[] + edges?: unknown[] + viewport?: unknown + }) => ({ nodes: graph?.nodes?.length ? graph.nodes : [{ id: 'start-placeholder', data: { type: BlockEnum.StartPlaceholder } }], @@ -298,7 +325,11 @@ vi.mock('@/app/components/workflow-app/hooks/use-workflow-draft-graph-for-canvas vi.mock('@/app/components/workflow-app/hooks/use-workflow-draft-graph-for-canvas', () => ({ useWorkflowDraftGraphForCanvas: () => ({ - getWorkflowDraftGraphForCanvas: (graph?: { nodes?: unknown[], edges?: unknown[], viewport?: unknown }) => ({ + getWorkflowDraftGraphForCanvas: (graph?: { + nodes?: unknown[] + edges?: unknown[] + viewport?: unknown + }) => ({ nodes: graph?.nodes?.length ? graph.nodes : [{ id: 'start-placeholder', data: { type: BlockEnum.StartPlaceholder } }], @@ -334,13 +365,7 @@ describe('WorkflowMain', () => { const edges = [{ id: 'edge-1' }] const viewport = { x: 1, y: 2, zoom: 1.5 } - render( - <WorkflowMain - nodes={nodes as never} - edges={edges as never} - viewport={viewport} - />, - ) + render(<WorkflowMain nodes={nodes as never} edges={edges as never} viewport={viewport} />) expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument() expect(screen.getByTestId('workflow-children')).toBeInTheDocument() @@ -352,47 +377,37 @@ describe('WorkflowMain', () => { }) it('should update features and workflow variables when workflow data changes', () => { - render( - <WorkflowMain - nodes={[]} - edges={[]} - viewport={{ x: 0, y: 0, zoom: 1 }} - />, - ) + render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />) fireEvent.click(screen.getByRole('button', { name: /update-workflow-data/i })) - expect(mockSetFeatures).toHaveBeenCalledWith(expect.objectContaining({ - file: expect.objectContaining({ enabled: true }), - })) - expect(mockSetConversationVariables).toHaveBeenCalledWith([expect.objectContaining({ id: 'conversation-1' })]) - expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([expect.objectContaining({ id: 'env-1' })]) + expect(mockSetFeatures).toHaveBeenCalledWith( + expect.objectContaining({ + file: expect.objectContaining({ enabled: true }), + }), + ) + expect(mockSetConversationVariables).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'conversation-1' }), + ]) + expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'env-1' }), + ]) }) it('should only update the workflow store slices present in the payload', () => { - render( - <WorkflowMain - nodes={[]} - edges={[]} - viewport={{ x: 0, y: 0, zoom: 1 }} - />, - ) + render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />) fireEvent.click(screen.getByRole('button', { name: /update-conversation-only/i })) - expect(mockSetConversationVariables).toHaveBeenCalledWith([expect.objectContaining({ id: 'conversation-only' })]) + expect(mockSetConversationVariables).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'conversation-only' }), + ]) expect(mockSetFeatures).not.toHaveBeenCalled() expect(mockSetEnvironmentVariables).not.toHaveBeenCalled() }) it('should ignore empty workflow data updates', () => { - render( - <WorkflowMain - nodes={[]} - edges={[]} - viewport={{ x: 0, y: 0, zoom: 1 }} - />, - ) + render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />) fireEvent.click(screen.getByRole('button', { name: /update-empty-payload/i })) @@ -402,13 +417,7 @@ describe('WorkflowMain', () => { }) it('should expose the composed workflow action hooks through hooksStore', () => { - render( - <WorkflowMain - nodes={[]} - edges={[]} - viewport={{ x: 0, y: 0, zoom: 1 }} - />, - ) + render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />) expect(capturedContextProps?.hooksStore).toMatchObject({ syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose, @@ -422,7 +431,8 @@ describe('WorkflowMain', () => { handleStartWorkflowRun: hookFns.handleStartWorkflowRun, handleWorkflowStartRunInChatflow: hookFns.handleWorkflowStartRunInChatflow, handleWorkflowStartRunInWorkflow: hookFns.handleWorkflowStartRunInWorkflow, - handleWorkflowTriggerScheduleRunInWorkflow: hookFns.handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow: + hookFns.handleWorkflowTriggerScheduleRunInWorkflow, handleWorkflowTriggerWebhookRunInWorkflow: hookFns.handleWorkflowTriggerWebhookRunInWorkflow, handleWorkflowTriggerPluginRunInWorkflow: hookFns.handleWorkflowTriggerPluginRunInWorkflow, handleWorkflowRunAllTriggersInWorkflow: hookFns.handleWorkflowRunAllTriggersInWorkflow, @@ -442,13 +452,7 @@ describe('WorkflowMain', () => { } as never, }) - render( - <WorkflowMain - nodes={[]} - edges={[]} - viewport={{ x: 0, y: 0, zoom: 1 }} - />, - ) + render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />) expect(capturedContextProps?.hooksStore).toMatchObject({ accessControl: { @@ -462,18 +466,16 @@ describe('WorkflowMain', () => { it('passes collaboration props and tracks cursors when collaboration is enabled', () => { collaborationRuntime.isEnabled = true collaborationRuntime.isConnected = true - collaborationRuntime.onlineUsers = [{ user_id: 'u-1', username: 'Alice', avatar: '', sid: 'sid-1' }] + collaborationRuntime.onlineUsers = [ + { user_id: 'u-1', username: 'Alice', avatar: '', sid: 'sid-1' }, + ] collaborationRuntime.cursors = { 'current-user': { x: 1, y: 2, userId: 'current-user', timestamp: 1 }, 'user-other': { x: 20, y: 30, userId: 'user-other', timestamp: 2 }, } const { unmount } = render( - <WorkflowMain - nodes={[]} - edges={[]} - viewport={{ x: 0, y: 0, zoom: 1 }} - />, + <WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />, ) expect(collaborationRuntime.startCursorTracking).toHaveBeenCalled() @@ -505,13 +507,7 @@ describe('WorkflowMain', () => { }, }) - render( - <WorkflowMain - nodes={[]} - edges={[]} - viewport={{ x: 0, y: 0, zoom: 1 }} - />, - ) + render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />) expect(mockOnVarsAndFeaturesUpdate).toHaveBeenCalled() expect(mockOnWorkflowUpdate).toHaveBeenCalled() @@ -547,13 +543,7 @@ describe('WorkflowMain', () => { }, }) - render( - <WorkflowMain - nodes={[]} - edges={[]} - viewport={{ x: 0, y: 0, zoom: 1 }} - />, - ) + render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />) await collaborationListeners.workflowUpdate?.() diff --git a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx index e24b4fd8b2bf0c..cb569cc02dbe02 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx @@ -94,8 +94,14 @@ vi.mock('@/app/components/base/message-log-modal', () => ({ defaultTab?: string onCancel: () => void }) => ( - <div data-testid="message-log-modal" data-current-log-id={currentLogItem?.id ?? ''} data-default-tab={defaultTab ?? ''}> - <button type="button" onClick={onCancel}>close-message-log</button> + <div + data-testid="message-log-modal" + data-current-log-id={currentLogItem?.id ?? ''} + data-default-tab={defaultTab ?? ''} + > + <button type="button" onClick={onCancel}> + close-message-log + </button> </div> ), })) @@ -159,7 +165,10 @@ describe('WorkflowPanel', () => { const panel = await screen.findByTestId('panel') expect(panel).toHaveAttribute('data-version-list-url', '/apps/app-123/workflows') expect(panel).toHaveAttribute('data-delete-version-url', '/apps/app-123/workflows/version-1') - expect(panel).toHaveAttribute('data-restore-version-url', '/apps/app-123/workflows/version-1/restore') + expect(panel).toHaveAttribute( + 'data-restore-version-url', + '/apps/app-123/workflows/version-1/restore', + ) expect(panel).toHaveAttribute('data-update-version-url', '/apps/app-123/workflows/version-1') expect(panel).toHaveAttribute('data-latest-version-id', 'workflow-version-id') }) @@ -173,7 +182,10 @@ describe('WorkflowPanel', () => { render(<WorkflowPanel />) - expect(await screen.findByTestId('message-log-modal')).toHaveAttribute('data-current-log-id', 'log-1') + expect(await screen.findByTestId('message-log-modal')).toHaveAttribute( + 'data-current-log-id', + 'log-1', + ) expect(screen.getByTestId('message-log-modal')).toHaveAttribute('data-default-tab', 'detail') await user.click(screen.getByRole('button', { name: /close-message-log/i })) diff --git a/web/app/components/workflow-app/components/workflow-children.tsx b/web/app/components/workflow-app/components/workflow-children.tsx index 4ef93b079b2ade..89f09314786c0d 100644 --- a/web/app/components/workflow-app/components/workflow-children.tsx +++ b/web/app/components/workflow-app/components/workflow-children.tsx @@ -3,11 +3,7 @@ import type { TriggerDefaultValue, } from '@/app/components/workflow/block-selector/types' import type { EnvironmentVariable } from '@/app/components/workflow/types' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo, useCallback, useState } from 'react' import { useStoreApi } from 'reactflow' import { DSL_EXPORT_CHECK, START_INITIAL_POSITION } from '@/app/components/workflow/constants' import { @@ -34,9 +30,12 @@ const Features = dynamic(() => import('@/app/components/workflow/features'), { const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), { ssr: false, }) -const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), { - ssr: false, -}) +const DSLExportConfirmModal = dynamic( + () => import('@/app/components/workflow/dsl-export-confirm-modal'), + { + ssr: false, + }, +) const WorkflowOnboardingModal = dynamic(() => import('./workflow-onboarding-modal'), { ssr: false, }) @@ -69,30 +68,24 @@ const getTriggerPluginNodeData = ( const WorkflowChildren = () => { const { eventEmitter } = useEventEmitterContextContext() const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([]) - const showFeaturesPanel = useStore(s => s.showFeaturesPanel) - const showImportDSLModal = useStore(s => s.showImportDSLModal) - const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal) - const showOnboarding = useStore(s => s.showOnboarding) - const canImportExportDSL = useHooksStore(s => s.accessControl.canImportExportDSL) - const canEdit = useHooksStore(s => s.accessControl.canEdit) - const setShowOnboarding = useStore(s => s.setShowOnboarding) - const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode) - const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector) + const showFeaturesPanel = useStore((s) => s.showFeaturesPanel) + const showImportDSLModal = useStore((s) => s.showImportDSLModal) + const setShowImportDSLModal = useStore((s) => s.setShowImportDSLModal) + const showOnboarding = useStore((s) => s.showOnboarding) + const canImportExportDSL = useHooksStore((s) => s.accessControl.canImportExportDSL) + const canEdit = useHooksStore((s) => s.accessControl.canEdit) + const setShowOnboarding = useStore((s) => s.setShowOnboarding) + const setHasSelectedStartNode = useStore((s) => s.setHasSelectedStartNode) + const setShouldAutoOpenStartNodeSelector = useStore((s) => s.setShouldAutoOpenStartNodeSelector) const reactFlowStore = useStoreApi() const availableNodesMetaData = useAvailableNodesMetaData() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { handleOnboardingClose } = useAutoOnboarding() - const { - handlePaneContextmenuCancel, - } = usePanelInteractions() - const { - exportCheck, - handleExportDSL, - } = useDSL() + const { handlePaneContextmenuCancel } = usePanelInteractions() + const { exportCheck, handleExportDSL } = useDSL() eventEmitter?.useSubscription((v: any) => { - if (v.type === DSL_EXPORT_CHECK) - setSecretEnvList(v.payload.data as EnvironmentVariable[]) + if (v.type === DSL_EXPORT_CHECK) setSecretEnvList(v.payload.data as EnvironmentVariable[]) }) const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl() @@ -101,98 +94,100 @@ const WorkflowChildren = () => { handleOnboardingClose() }, [handleOnboardingClose]) - const handleSelectStartNode = useCallback((nodeType: BlockEnum, toolConfig?: BlockDefaultValue) => { - if (!canEdit) - return + const handleSelectStartNode = useCallback( + (nodeType: BlockEnum, toolConfig?: BlockDefaultValue) => { + if (!canEdit) return - const nodeDefault = availableNodesMetaData.nodesMap?.[nodeType] - if (!nodeDefault?.defaultValue) - return + const nodeDefault = availableNodesMetaData.nodesMap?.[nodeType] + if (!nodeDefault?.defaultValue) return + + const baseNodeData = { ...nodeDefault.defaultValue } + + const mergedNodeData = (() => { + if (nodeType !== BlockEnum.TriggerPlugin || !toolConfig) { + return { + ...baseNodeData, + ...toolConfig, + } + } - const baseNodeData = { ...nodeDefault.defaultValue } + const triggerNodeData = getTriggerPluginNodeData( + toolConfig as TriggerDefaultValue, + baseNodeData.title, + baseNodeData.desc, + ) - const mergedNodeData = (() => { - if (nodeType !== BlockEnum.TriggerPlugin || !toolConfig) { return { ...baseNodeData, - ...toolConfig, + ...triggerNodeData, + config: { + ...(baseNodeData as { config?: Record<string, any> }).config, + ...triggerNodeData.config, + }, } - } - - const triggerNodeData = getTriggerPluginNodeData( - toolConfig as TriggerDefaultValue, - baseNodeData.title, - baseNodeData.desc, - ) - - return { - ...baseNodeData, - ...triggerNodeData, - config: { - ...(baseNodeData as { config?: Record<string, any> }).config, - ...triggerNodeData.config, + })() + + const { newNode } = generateNewNode({ + data: { + ...mergedNodeData, + } as any, + position: START_INITIAL_POSITION, + }) + + const { setNodes, setEdges } = reactFlowStore.getState() + setNodes([newNode]) + setEdges([]) + + setShowOnboarding?.(false) + setHasSelectedStartNode?.(true) + setShouldAutoOpenStartNodeSelector?.(true) + + handleSyncWorkflowDraft(true, false, { + onSuccess: () => { + autoGenerateWebhookUrl(newNode.id) + }, + onError: () => { + console.error('Failed to save node to draft') }, - } - })() - - const { newNode } = generateNewNode({ - data: { - ...mergedNodeData, - } as any, - position: START_INITIAL_POSITION, - }) - - const { setNodes, setEdges } = reactFlowStore.getState() - setNodes([newNode]) - setEdges([]) - - setShowOnboarding?.(false) - setHasSelectedStartNode?.(true) - setShouldAutoOpenStartNodeSelector?.(true) - - handleSyncWorkflowDraft(true, false, { - onSuccess: () => { - autoGenerateWebhookUrl(newNode.id) - }, - onError: () => { - console.error('Failed to save node to draft') - }, - }) - }, [availableNodesMetaData, autoGenerateWebhookUrl, canEdit, handleSyncWorkflowDraft, reactFlowStore, setHasSelectedStartNode, setShouldAutoOpenStartNodeSelector, setShowOnboarding]) + }) + }, + [ + availableNodesMetaData, + autoGenerateWebhookUrl, + canEdit, + handleSyncWorkflowDraft, + reactFlowStore, + setHasSelectedStartNode, + setShouldAutoOpenStartNodeSelector, + setShowOnboarding, + ], + ) return ( <> <PluginDependency /> - { - showFeaturesPanel && <Features /> - } - { - canEdit && showOnboarding && ( - <WorkflowOnboardingModal - isShow={showOnboarding} - onClose={handleCloseOnboarding} - onSelectStartNode={handleSelectStartNode} - /> - ) - } - { - canImportExportDSL && showImportDSLModal && ( - <UpdateDSLModal - onCancel={() => setShowImportDSLModal(false)} - onBackup={exportCheck!} - onImport={handlePaneContextmenuCancel} - /> - ) - } - { - canImportExportDSL && secretEnvList.length > 0 && ( - <DSLExportConfirmModal - envList={secretEnvList} - onConfirm={handleExportDSL!} - onClose={() => setSecretEnvList([])} - /> - ) - } + {showFeaturesPanel && <Features />} + {canEdit && showOnboarding && ( + <WorkflowOnboardingModal + isShow={showOnboarding} + onClose={handleCloseOnboarding} + onSelectStartNode={handleSelectStartNode} + /> + )} + {canImportExportDSL && showImportDSLModal && ( + <UpdateDSLModal + onCancel={() => setShowImportDSLModal(false)} + onBackup={exportCheck!} + onImport={handlePaneContextmenuCancel} + /> + )} + {canImportExportDSL && secretEnvList.length > 0 && ( + <DSLExportConfirmModal + envList={secretEnvList} + onConfirm={handleExportDSL!} + onClose={() => setSecretEnvList([])} + /> + )} <WorkflowHeader /> <WorkflowPanel /> </> diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx index 5b8ecd67bcd2c7..9388c7eb81e154 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx @@ -30,10 +30,18 @@ const toastMocks = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { - success: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'success', message, ...options })), - error: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'error', message, ...options })), - warning: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'warning', message, ...options })), - info: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'info', message, ...options })), + success: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'success', message, ...options }), + ), + error: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'info', message, ...options }), + ), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -89,7 +97,9 @@ vi.mock('@/app/components/workflow/store', () => ({ })) vi.mock('@/app/components/workflow/hooks-store', () => ({ - useHooksStore: <T,>(selector: (state: { accessControl: { canReleaseAndVersion: boolean } }) => T): T => + useHooksStore: <T,>( + selector: (state: { accessControl: { canReleaseAndVersion: boolean } }) => T, + ): T => selector({ accessControl: { canReleaseAndVersion: true, @@ -136,22 +146,64 @@ vi.mock('@/app/components/app/app-publisher', () => ({ data-has-trigger-node={String(Boolean(props.hasTriggerNode))} data-inputs={JSON.stringify(inputs)} > - <button type="button" onClick={() => { props.onRefreshData?.() }}> + <button + type="button" + onClick={() => { + props.onRefreshData?.() + }} + > publisher-refresh </button> - <button type="button" onClick={() => { props.onToggle?.(true) }}> + <button + type="button" + onClick={() => { + props.onToggle?.(true) + }} + > publisher-toggle-on </button> - <button type="button" onClick={() => { props.onToggle?.(false) }}> + <button + type="button" + onClick={() => { + props.onToggle?.(false) + }} + > publisher-toggle-off </button> - <button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}> + <button + type="button" + onClick={() => { + Promise.resolve(props.onPublish?.()).catch(() => undefined) + }} + > publisher-publish </button> - <button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ url: '/apps/app-1/workflows/publish', title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}> + <button + type="button" + onClick={() => { + Promise.resolve( + props.onPublish?.({ + url: '/apps/app-1/workflows/publish', + title: 'Test title', + releaseNotes: 'Test notes', + }), + ).catch(() => undefined) + }} + > publisher-publish-with-params </button> - <button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ url: '/apps/app-id/workflows/publish/custom', title: 'Custom title', releaseNotes: 'Custom notes' })).catch(() => undefined) }}> + <button + type="button" + onClick={() => { + Promise.resolve( + props.onPublish?.({ + url: '/apps/app-id/workflows/publish/custom', + title: 'Custom title', + releaseNotes: 'Custom notes', + }), + ).catch(() => undefined) + }} + > publisher-publish-custom </button> </div> @@ -194,11 +246,7 @@ const renderWithToast = (ui: ReactElement) => { const queryClient = new QueryClient() return { queryClient, - ...render( - <QueryClientProvider client={queryClient}> - {ui} - </QueryClientProvider>, - ), + ...render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>), } } @@ -216,10 +264,14 @@ describe('FeaturesTrigger', () => { mockUseTheme.mockReturnValue({ theme: 'light' }) mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false }) mockUseChecklist.mockReturnValue([]) - mockUseChecklistBeforePublish.mockReturnValue({ handleCheckBeforePublish: mockHandleCheckBeforePublish }) + mockUseChecklistBeforePublish.mockReturnValue({ + handleCheckBeforePublish: mockHandleCheckBeforePublish, + }) mockHandleCheckBeforePublish.mockResolvedValue(true) mockUseNodesSyncDraft.mockReturnValue({ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft }) - mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => selector({ features: { file: {} } })) + mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => + selector({ features: { file: {} } }), + ) mockUseProviderContext.mockReturnValue(createProviderContext({})) mockUseNodes.mockReturnValue([]) mockUseEdges.mockReturnValue([]) @@ -240,7 +292,9 @@ describe('FeaturesTrigger', () => { renderWithToast(<FeaturesTrigger />) // Assert - expect(screen.queryByRole('button', { name: /workflow\.common\.features/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /workflow\.common\.features/i }), + ).not.toBeInTheDocument() }) it('should render the features button when in chat mode', () => { @@ -251,7 +305,9 @@ describe('FeaturesTrigger', () => { renderWithToast(<FeaturesTrigger />) // Assert - expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflow\.common\.features/i }), + ).toBeInTheDocument() }) it('should apply dark theme styling when theme is dark', () => { @@ -263,7 +319,9 @@ describe('FeaturesTrigger', () => { renderWithToast(<FeaturesTrigger />) // Assert - expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toHaveClass('rounded-lg') + expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toHaveClass( + 'rounded-lg', + ) }) }) @@ -326,18 +384,20 @@ describe('FeaturesTrigger', () => { describe('Computed Props', () => { it('should append image input when file image upload is enabled', () => { // Arrange - mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => selector({ - features: { file: { image: { enabled: true } } }, - })) - mockUseNodes.mockReturnValue([ - { id: 'start', data: { type: BlockEnum.Start } }, - ]) + mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => + selector({ + features: { file: { image: { enabled: true } } }, + }), + ) + mockUseNodes.mockReturnValue([{ id: 'start', data: { type: BlockEnum.Start } }]) // Act renderWithToast(<FeaturesTrigger />) // Assert - const inputs = JSON.parse(screen.getByTestId('app-publisher').getAttribute('data-inputs') ?? '[]') as Array<{ + const inputs = JSON.parse( + screen.getByTestId('app-publisher').getAttribute('data-inputs') ?? '[]', + ) as Array<{ type?: string variable?: string required?: boolean @@ -423,7 +483,10 @@ describe('FeaturesTrigger', () => { // Assert await waitFor(() => { - expect(toastMocks.call).toHaveBeenCalledWith({ type: 'error', message: 'workflow.panel.checklistTip' }) + expect(toastMocks.call).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.panel.checklistTip', + }) }) expect(mockPublishWorkflow).not.toHaveBeenCalled() }) @@ -446,12 +509,8 @@ describe('FeaturesTrigger', () => { it('should publish workflow and update related stores when validation passes', async () => { // Arrange const user = userEvent.setup() - mockUseNodes.mockReturnValue([ - { id: 'start', data: { type: BlockEnum.Start } }, - ]) - mockUseEdges.mockReturnValue([ - { source: 'start' }, - ]) + mockUseNodes.mockReturnValue([{ id: 'start', data: { type: BlockEnum.Start } }]) + mockUseEdges.mockReturnValue([{ source: 'start' }]) renderWithToast(<FeaturesTrigger />) // Act @@ -469,14 +528,22 @@ describe('FeaturesTrigger', () => { expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z') expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true) expect(mockResetWorkflowVersionHistory).toHaveBeenCalled() - expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' }) + expect(toastMocks.call).toHaveBeenCalledWith({ + type: 'success', + message: 'common.api.actionSuccess', + }) expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' }) - expect(mockSetQueryData).toHaveBeenCalledWith(['apps', 'detail', 'app-id'], expect.objectContaining({ - name: 'Updated App', - })) - expect(useAppStore.getState().appDetail).toEqual(expect.objectContaining({ - name: 'Updated App', - })) + expect(mockSetQueryData).toHaveBeenCalledWith( + ['apps', 'detail', 'app-id'], + expect.objectContaining({ + name: 'Updated App', + }), + ) + expect(useAppStore.getState().appDetail).toEqual( + expect.objectContaining({ + name: 'Updated App', + }), + ) }) }) @@ -611,7 +678,10 @@ describe('FeaturesTrigger', () => { await waitFor(() => { expect(mockPublishWorkflow).toHaveBeenCalled() }) - expect(toastMocks.call).not.toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' }) + expect(toastMocks.call).not.toHaveBeenCalledWith({ + type: 'success', + message: 'common.api.actionSuccess', + }) expect(mockUpdatePublishedWorkflow).not.toHaveBeenCalled() expect(mockInvalidateAppTriggers).not.toHaveBeenCalled() expect(mockSetPublishedAt).not.toHaveBeenCalled() diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/index.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/index.spec.tsx index 54b1ee410fde25..1e64a96715da19 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/index.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/index.spec.tsx @@ -58,14 +58,13 @@ vi.mock('@/app/components/workflow/header', () => ({ > <button type="button" - onClick={() => props.normal?.runAndHistoryProps?.viewHistoryProps?.onClearLogAndMessageModal?.()} + onClick={() => + props.normal?.runAndHistoryProps?.viewHistoryProps?.onClearLogAndMessageModal?.() + } > clear-history </button> - <button - type="button" - onClick={() => props.restoring?.onRestoreSettled?.()} - > + <button type="button" onClick={() => props.restoring?.onRestoreSettled?.()}> restore-settled </button> </div> diff --git a/web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.tsx index 0299d53ac9fc48..92840d74a2d223 100644 --- a/web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.tsx @@ -1,16 +1,13 @@ import { memo } from 'react' import ChatVariableButton from '@/app/components/workflow/header/chat-variable-button' -import { - useNodesReadOnly, -} from '@/app/components/workflow/hooks' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' import { useIsChatMode } from '../../hooks' const ChatVariableTrigger = () => { const { nodesReadOnly } = useNodesReadOnly() const isChatMode = useIsChatMode() - if (!isChatMode) - return null + if (!isChatMode) return null return <ChatVariableButton disabled={nodesReadOnly} /> } diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx index 3d2a3eaa493b8b..a5ba2723832521 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx @@ -1,19 +1,12 @@ import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher' import type { EndNodeType } from '@/app/components/workflow/nodes/end/types' import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' -import type { - CommonEdgeType, - Node, -} from '@/app/components/workflow/types' +import type { CommonEdgeType, Node } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import { useQueryClient } from '@tanstack/react-query' -import { - memo, - useCallback, - useMemo, -} from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useEdges } from 'reactflow' import { AppPublisher } from '@/app/components/app/app-publisher' @@ -30,23 +23,20 @@ import { } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { isAgentV2NodeData } from '@/app/components/workflow/nodes/agent-v2/types' -import { - useStore, - useWorkflowStore, -} from '@/app/components/workflow/store' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' -import { - BlockEnum, - InputVarType, - isTriggerNode, -} from '@/app/components/workflow/types' +import { BlockEnum, InputVarType, isTriggerNode } from '@/app/components/workflow/types' import { useProviderContext } from '@/context/provider-context' import useTheme from '@/hooks/use-theme' import { fetchAppDetail } from '@/service/apps' import { consoleQuery } from '@/service/client' import { appDetailQueryKeyPrefix } from '@/service/use-apps' import { useInvalidateAppTriggers } from '@/service/use-tools' -import { useInvalidateAppWorkflow, usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow' +import { + useInvalidateAppWorkflow, + usePublishWorkflow, + useResetWorkflowVersionHistory, +} from '@/service/use-workflow' const FeaturesTrigger = () => { const { t } = useTranslation() @@ -54,39 +44,43 @@ const FeaturesTrigger = () => { const isChatMode = useIsChatMode() const workflowStore = useWorkflowStore() const queryClient = useQueryClient() - const appDetail = useAppStore(s => s.appDetail) - const setAppDetail = useAppStore(s => s.setAppDetail) + const appDetail = useAppStore((s) => s.appDetail) + const setAppDetail = useAppStore((s) => s.setAppDetail) const appID = appDetail?.id const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly() - const canReleaseAndVersion = useHooksStore(s => s.accessControl.canReleaseAndVersion) + const canReleaseAndVersion = useHooksStore((s) => s.accessControl.canReleaseAndVersion) const { plan, isFetchedPlan } = useProviderContext() - const publishedAt = useStore(s => s.publishedAt) - const draftUpdatedAt = useStore(s => s.draftUpdatedAt) - const toolPublished = useStore(s => s.toolPublished) - const lastPublishedHasUserInput = useStore(s => s.lastPublishedHasUserInput) + const publishedAt = useStore((s) => s.publishedAt) + const draftUpdatedAt = useStore((s) => s.draftUpdatedAt) + const toolPublished = useStore((s) => s.toolPublished) + const lastPublishedHasUserInput = useStore((s) => s.lastPublishedHasUserInput) const nodes = useNodes() const rosterAgentIds = useMemo(() => { - return Array.from(new Set(nodes.flatMap((node) => { - const binding = isAgentV2NodeData(node.data) ? node.data.agent_binding : undefined - if ( - binding?.binding_type !== 'roster_agent' - || typeof binding.agent_id !== 'string' - || binding.agent_id.length === 0 - ) { - return [] - } + return Array.from( + new Set( + nodes.flatMap((node) => { + const binding = isAgentV2NodeData(node.data) ? node.data.agent_binding : undefined + if ( + binding?.binding_type !== 'roster_agent' || + typeof binding.agent_id !== 'string' || + binding.agent_id.length === 0 + ) { + return [] + } - return [binding.agent_id] - }))) + return [binding.agent_id] + }), + ), + ) }, [nodes]) const hasWorkflowNodes = nodes.length > 0 - const startNode = nodes.find(node => node.data.type === BlockEnum.Start) - const endNode = nodes.find(node => node.data.type === BlockEnum.End) + const startNode = nodes.find((node) => node.data.type === BlockEnum.Start) + const endNode = nodes.find((node) => node.data.type === BlockEnum.End) const startVariables = (startNode as Node<StartNodeType>)?.data?.variables const edges = useEdges<CommonEdgeType>() - const fileSettings = useFeatures(s => s.features.file) + const fileSettings = useFeatures((s) => s.features.file) const variables = useMemo(() => { const data = startVariables || [] if (fileSettings?.image?.enabled) { @@ -108,56 +102,48 @@ const FeaturesTrigger = () => { const { handleCheckBeforePublish } = useChecklistBeforePublish() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const startNodeIds = useMemo( - () => nodes.filter(node => node.data.type === BlockEnum.Start).map(node => node.id), + () => nodes.filter((node) => node.data.type === BlockEnum.Start).map((node) => node.id), [nodes], ) const hasUserInputNode = useMemo(() => { - if (!startNodeIds.length) - return false - return edges.some(edge => startNodeIds.includes(edge.source)) + if (!startNodeIds.length) return false + return edges.some((edge) => startNodeIds.includes(edge.source)) }, [edges, startNodeIds]) // Track trigger presence so the publisher can adjust UI (e.g. hide missing start section). - const hasTriggerNode = useMemo(() => ( - nodes.some(node => isTriggerNode(node.data.type as BlockEnum)) - ), [nodes]) + const hasTriggerNode = useMemo( + () => nodes.some((node) => isTriggerNode(node.data.type as BlockEnum)), + [nodes], + ) const startNodeLimitExceeded = useMemo(() => { const entryCount = nodes.reduce((count, node) => { const nodeType = node.data.type as BlockEnum - if (nodeType === BlockEnum.Start || isTriggerNode(nodeType)) - return count + 1 + if (nodeType === BlockEnum.Start || isTriggerNode(nodeType)) return count + 1 return count }, 0) return isFetchedPlan && plan.type === Plan.sandbox && entryCount > 2 }, [nodes, plan.type, isFetchedPlan]) const hasHumanInputNode = useMemo(() => { - return nodes.some(node => node.data.type === BlockEnum.HumanInput) + return nodes.some((node) => node.data.type === BlockEnum.HumanInput) }, [nodes]) const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() const invalidateAppTriggers = useInvalidateAppTriggers() const handleShowFeatures = useCallback(() => { - const { - showFeaturesPanel, - isRestoring, - setShowFeaturesPanel, - } = workflowStore.getState() - if (getNodesReadOnly() && !isRestoring) - return + const { showFeaturesPanel, isRestoring, setShowFeaturesPanel } = workflowStore.getState() + if (getNodesReadOnly() && !isRestoring) return setShowFeaturesPanel(!showFeaturesPanel) }, [workflowStore, getNodesReadOnly]) const updateAppDetail = useCallback(async () => { try { - if (!appID) - return + if (!appID) return const res = await fetchAppDetail({ url: '/apps', id: appID }) queryClient.setQueryData([...appDetailQueryKeyPrefix, appID], res) setAppDetail({ ...res }) - } - catch (error) { + } catch (error) { console.error(error) } }, [appID, queryClient, setAppDetail]) @@ -167,57 +153,79 @@ const FeaturesTrigger = () => { const needWarningNodes = useChecklist(nodes, edges) const updatePublishedWorkflow = useInvalidateAppWorkflow() - const onPublish = useCallback(async (params?: AppPublisherPublishParams) => { - const publishParams = params && 'title' in params ? params : undefined - // First check if there are any items in the checklist - // if (!validateBeforeRun()) - // throw new Error('Checklist has unresolved items') + const onPublish = useCallback( + async (params?: AppPublisherPublishParams) => { + const publishParams = params && 'title' in params ? params : undefined + // First check if there are any items in the checklist + // if (!validateBeforeRun()) + // throw new Error('Checklist has unresolved items') - if (needWarningNodes.length > 0) { - toast.error(t($ => $['panel.checklistTip'], { ns: 'workflow' })) - throw new Error('Checklist has unresolved items') - } + if (needWarningNodes.length > 0) { + toast.error(t(($) => $['panel.checklistTip'], { ns: 'workflow' })) + throw new Error('Checklist has unresolved items') + } - // Then perform the detailed validation - if (await handleCheckBeforePublish()) { - const res = await publishWorkflow({ - url: publishParams?.url || `/apps/${appID}/workflows/publish`, - title: publishParams?.title || '', - releaseNotes: publishParams?.releaseNotes || '', - }) - if (res) { - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) - updatePublishedWorkflow(appID!) - updateAppDetail() - invalidateAppTriggers(appID!) - if (rosterAgentIds.length > 0) { - void queryClient.invalidateQueries({ - queryKey: consoleQuery.agent.get.key(), - }) - void Promise.all(rosterAgentIds.map(agentId => queryClient.invalidateQueries({ - queryKey: consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({ - input: { - params: { - agent_id: agentId, - }, - }, - }).queryKey, - }))) + // Then perform the detailed validation + if (await handleCheckBeforePublish()) { + const res = await publishWorkflow({ + url: publishParams?.url || `/apps/${appID}/workflows/publish`, + title: publishParams?.title || '', + releaseNotes: publishParams?.releaseNotes || '', + }) + if (res) { + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) + updatePublishedWorkflow(appID!) + updateAppDetail() + invalidateAppTriggers(appID!) + if (rosterAgentIds.length > 0) { + void queryClient.invalidateQueries({ + queryKey: consoleQuery.agent.get.key(), + }) + void Promise.all( + rosterAgentIds.map((agentId) => + queryClient.invalidateQueries({ + queryKey: consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, + }, + }).queryKey, + }), + ), + ) + } + workflowStore.getState().setPublishedAt(res.created_at) + workflowStore.getState().setLastPublishedHasUserInput(hasUserInputNode) + resetWorkflowVersionHistory() } - workflowStore.getState().setPublishedAt(res.created_at) - workflowStore.getState().setLastPublishedHasUserInput(hasUserInputNode) - resetWorkflowVersionHistory() + } else { + throw new Error('Checklist failed') } - } - else { - throw new Error('Checklist failed') - } - }, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, appID, t, updatePublishedWorkflow, updateAppDetail, invalidateAppTriggers, rosterAgentIds, queryClient, workflowStore, hasUserInputNode, resetWorkflowVersionHistory]) + }, + [ + needWarningNodes, + handleCheckBeforePublish, + publishWorkflow, + appID, + t, + updatePublishedWorkflow, + updateAppDetail, + invalidateAppTriggers, + rosterAgentIds, + queryClient, + workflowStore, + hasUserInputNode, + resetWorkflowVersionHistory, + ], + ) - const onPublisherToggle = useCallback((state: boolean) => { - if (state) - handleSyncWorkflowDraft(true) - }, [handleSyncWorkflowDraft]) + const onPublisherToggle = useCallback( + (state: boolean) => { + if (state) handleSyncWorkflowDraft(true) + }, + [handleSyncWorkflowDraft], + ) const handleToolConfigureUpdate = useCallback(() => { workflowStore.setState({ toolPublished: true }) @@ -235,7 +243,7 @@ const FeaturesTrigger = () => { onClick={handleShowFeatures} > <span className="mr-1 i-ri-apps-2-add-line size-4 text-components-button-secondary-text" /> - {t($ => $['common.features'], { ns: 'workflow' })} + {t(($) => $['common.features'], { ns: 'workflow' })} </Button> )} <AppPublisher diff --git a/web/app/components/workflow-app/components/workflow-header/index.tsx b/web/app/components/workflow-app/components/workflow-header/index.tsx index 3fe679925aa927..da2434f1ac23e3 100644 --- a/web/app/components/workflow-app/components/workflow-header/index.tsx +++ b/web/app/components/workflow-app/components/workflow-header/index.tsx @@ -1,9 +1,5 @@ import type { HeaderProps } from '@/app/components/workflow/header' -import { - memo, - useCallback, - useMemo, -} from 'react' +import { memo, useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import Header from '@/app/components/workflow/header' @@ -13,11 +9,13 @@ import ChatVariableTrigger from './chat-variable-trigger' import FeaturesTrigger from './features-trigger' const WorkflowHeader = () => { - const { appDetail, setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({ - appDetail: state.appDetail, - setCurrentLogItem: state.setCurrentLogItem, - setShowMessageLogModal: state.setShowMessageLogModal, - }))) + const { appDetail, setCurrentLogItem, setShowMessageLogModal } = useAppStore( + useShallow((state) => ({ + appDetail: state.appDetail, + setCurrentLogItem: state.setCurrentLogItem, + setShowMessageLogModal: state.setShowMessageLogModal, + })), + ) const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() const isChatMode = useIsChatMode() @@ -29,7 +27,9 @@ const WorkflowHeader = () => { const viewHistoryProps = useMemo(() => { return { onClearLogAndMessageModal: handleClearLogAndMessageModal, - historyUrl: isChatMode ? `/apps/${appDetail!.id}/advanced-chat/workflow-runs` : `/apps/${appDetail!.id}/workflow-runs`, + historyUrl: isChatMode + ? `/apps/${appDetail!.id}/advanced-chat/workflow-runs` + : `/apps/${appDetail!.id}/workflow-runs`, } }, [appDetail, isChatMode, handleClearLogAndMessageModal]) @@ -54,9 +54,7 @@ const WorkflowHeader = () => { }, } }, [resetWorkflowVersionHistory, isChatMode, viewHistoryProps]) - return ( - <Header {...headerProps} /> - ) + return <Header {...headerProps} /> } export default memo(WorkflowHeader) diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index e563a46c625726..b95c0b1582ddbf 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -5,12 +5,7 @@ import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-s import type { Edge, Node } from '@/app/components/workflow/types' import type { FetchWorkflowDraftResponse } from '@/types/workflow' import { useAtomValue } from 'jotai' -import { - useCallback, - useEffect, - useMemo, - useRef, -} from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useReactFlow } from 'reactflow' import { useStore as useAppStore } from '@/app/components/app/store' import { useFeaturesStore } from '@/app/components/base/features/hooks' @@ -41,28 +36,30 @@ import { import WorkflowChildren from './workflow-children' type WorkflowMainProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'> -type WorkflowDataUpdatePayload = Pick<FetchWorkflowDraftResponse, 'features' | 'conversation_variables' | 'environment_variables'> -const WorkflowMain = ({ - nodes, - edges, - viewport, -}: WorkflowMainProps) => { +type WorkflowDataUpdatePayload = Pick< + FetchWorkflowDraftResponse, + 'features' | 'conversation_variables' | 'environment_variables' +> +const WorkflowMain = ({ nodes, edges, viewport }: WorkflowMainProps) => { const featuresStore = useFeaturesStore() const workflowStore = useWorkflowStore() - const appId = useStore(s => s.appId) - const appDetail = useAppStore(s => s.appDetail) + const appId = useStore((s) => s.appId) + const appDetail = useAppStore((s) => s.appDetail) const containerRef = useRef<HTMLDivElement>(null) const reactFlow = useReactFlow() const { getWorkflowDraftGraphForCanvas } = useWorkflowDraftGraphForCanvas(appDetail?.mode) - const reactFlowStore = useMemo(() => ({ - getState: () => ({ - getNodes: () => reactFlow.getNodes(), - setNodes: (nodesToSet: Node[]) => reactFlow.setNodes(nodesToSet), - getEdges: () => reactFlow.getEdges(), - setEdges: (edgesToSet: Edge[]) => reactFlow.setEdges(edgesToSet), + const reactFlowStore = useMemo( + () => ({ + getState: () => ({ + getNodes: () => reactFlow.getNodes(), + setNodes: (nodesToSet: Node[]) => reactFlow.setNodes(nodesToSet), + getEdges: () => reactFlow.getEdges(), + setEdges: (edgesToSet: Edge[]) => reactFlow.setEdges(edgesToSet), + }), }), - }), [reactFlow]) + [reactFlow], + ) const { startCursorTracking, stopCursorTracking, @@ -82,17 +79,17 @@ const WorkflowMain = ({ const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const appACLCapabilities = useMemo( - () => getAppACLCapabilities(appDetail?.permission_keys, { - currentUserId, - resourceMaintainer: appDetail?.maintainer, - workspacePermissionKeys, - }), + () => + getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], ) useEffect(() => { - if (!isCollaborationEnabled) - return + if (!isCollaborationEnabled) return if (containerRef.current) startCursorTracking(containerRef as React.RefObject<HTMLElement>, reactFlow) @@ -102,57 +99,66 @@ const WorkflowMain = ({ } }, [startCursorTracking, stopCursorTracking, reactFlow, isCollaborationEnabled]) - const handleWorkflowDataUpdate = useCallback((payload: WorkflowDataUpdatePayload) => { - const { - features, - conversation_variables, - environment_variables, - } = payload - if (features && featuresStore) { - const { setFeatures } = featuresStore.getState() + const handleWorkflowDataUpdate = useCallback( + (payload: WorkflowDataUpdatePayload) => { + const { features, conversation_variables, environment_variables } = payload + if (features && featuresStore) { + const { setFeatures } = featuresStore.getState() - const transformedFeatures: FeaturesData = { - file: { - image: { - enabled: !!features.file_upload?.image?.enabled, - number_limits: features.file_upload?.image?.number_limits || 3, - transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + const transformedFeatures: FeaturesData = { + file: { + image: { + enabled: !!features.file_upload?.image?.enabled, + number_limits: features.file_upload?.image?.number_limits || 3, + transfer_methods: features.file_upload?.image?.transfer_methods || [ + 'local_file', + 'remote_url', + ], + }, + enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled), + allowed_file_types: features.file_upload?.allowed_file_types || [ + SupportUploadFileTypes.image, + ], + allowed_file_extensions: + features.file_upload?.allowed_file_extensions || + FILE_EXTS[SupportUploadFileTypes.image]!.map((ext) => `.${ext}`), + allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || + features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + number_limits: + features.file_upload?.number_limits || + features.file_upload?.image?.number_limits || + 3, }, - enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled), - allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image], - allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image]!.map(ext => `.${ext}`), - allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], - number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3, - }, - opening: { - enabled: !!features.opening_statement, - opening_statement: features.opening_statement, - suggested_questions: features.suggested_questions, - }, - suggested: features.suggested_questions_after_answer || { enabled: false }, - speech2text: features.speech_to_text || { enabled: false }, - text2speech: features.text_to_speech || { enabled: false }, - citation: features.retriever_resource || { enabled: false }, - moderation: features.sensitive_word_avoidance || { enabled: false }, - annotationReply: features.annotation_reply || { enabled: false }, - } + opening: { + enabled: !!features.opening_statement, + opening_statement: features.opening_statement, + suggested_questions: features.suggested_questions, + }, + suggested: features.suggested_questions_after_answer || { enabled: false }, + speech2text: features.speech_to_text || { enabled: false }, + text2speech: features.text_to_speech || { enabled: false }, + citation: features.retriever_resource || { enabled: false }, + moderation: features.sensitive_word_avoidance || { enabled: false }, + annotationReply: features.annotation_reply || { enabled: false }, + } - setFeatures(transformedFeatures) - } - if (conversation_variables) { - const { setConversationVariables } = workflowStore.getState() - setConversationVariables(conversation_variables) - } - if (environment_variables) { - const { setEnvironmentVariables } = workflowStore.getState() - setEnvironmentVariables(environment_variables) - } - }, [featuresStore, workflowStore]) + setFeatures(transformedFeatures) + } + if (conversation_variables) { + const { setConversationVariables } = workflowStore.getState() + setConversationVariables(conversation_variables) + } + if (environment_variables) { + const { setEnvironmentVariables } = workflowStore.getState() + setEnvironmentVariables(environment_variables) + } + }, + [featuresStore, workflowStore], + ) - const { - doSyncWorkflowDraft, - syncWorkflowDraftWhenPageClose, - } = useNodesSyncDraftByCanEdit(appACLCapabilities.canEdit) + const { doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose } = useNodesSyncDraftByCanEdit( + appACLCapabilities.canEdit, + ) const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() const { handleUpdateWorkflowCanvas } = useWorkflowUpdate() const { @@ -164,26 +170,25 @@ const WorkflowMain = ({ } = useWorkflowRunByCanEdit(appACLCapabilities.canEdit) useEffect(() => { - if (!appId || !isCollaborationEnabled) - return + if (!appId || !isCollaborationEnabled) return - const unsubscribe = collaborationManager.onVarsAndFeaturesUpdate(async (_update: CollaborationUpdate) => { - try { - const response = await fetchWorkflowDraft(`/apps/${appId}/workflows/draft`) - handleWorkflowDataUpdate(response) - } - catch (error) { - console.error('workflow vars and features update failed:', error) - } - }) + const unsubscribe = collaborationManager.onVarsAndFeaturesUpdate( + async (_update: CollaborationUpdate) => { + try { + const response = await fetchWorkflowDraft(`/apps/${appId}/workflows/draft`) + handleWorkflowDataUpdate(response) + } catch (error) { + console.error('workflow vars and features update failed:', error) + } + }, + ) return unsubscribe }, [appId, handleWorkflowDataUpdate, isCollaborationEnabled]) // Listen for workflow updates from other users useEffect(() => { - if (!appId || !isCollaborationEnabled) - return + if (!appId || !isCollaborationEnabled) return const unsubscribe = collaborationManager.onWorkflowUpdate(async () => { try { @@ -195,19 +200,23 @@ const WorkflowMain = ({ // Update workflow canvas (nodes, edges, viewport) if (response.graph) handleUpdateWorkflowCanvas(getWorkflowDraftGraphForCanvas(response.graph)) - } - catch (error) { + } catch (error) { console.error('Failed to fetch updated workflow:', error) } }) return unsubscribe - }, [appId, getWorkflowDraftGraphForCanvas, handleWorkflowDataUpdate, handleUpdateWorkflowCanvas, isCollaborationEnabled]) + }, [ + appId, + getWorkflowDraftGraphForCanvas, + handleWorkflowDataUpdate, + handleUpdateWorkflowCanvas, + isCollaborationEnabled, + ]) // Listen for sync requests from other users (only processed by leader) useEffect(() => { - if (!appId || !isCollaborationEnabled) - return + if (!appId || !isCollaborationEnabled) return const unsubscribe = collaborationManager.onSyncRequest(() => { doSyncWorkflowDraft() @@ -226,10 +235,7 @@ const WorkflowMain = ({ } = useWorkflowStartRunByCanEdit(appACLCapabilities.canEdit) const availableNodesMetaData = useAvailableNodesMetaData() const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl() - const { - exportCheck, - handleExportDSL, - } = useDSLByCanEdit(appACLCapabilities.canEdit) + const { exportCheck, handleExportDSL } = useDSLByCanEdit(appACLCapabilities.canEdit) const configsMap = useConfigsMap() @@ -338,10 +344,7 @@ const WorkflowMain = ({ ]) return ( - <div - ref={containerRef} - className="relative size-full" - > + <div ref={containerRef} className="relative size-full"> <WorkflowWithInnerContext nodes={nodes} edges={edges} diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/index.spec.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/index.spec.tsx index c61882146b9c50..b7ad81b55de9ff 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/index.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/index.spec.tsx @@ -19,10 +19,16 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ {typeof trigger === 'function' ? trigger(Boolean(open)) : trigger} {open && ( <div> - <button data-testid="select-trigger-schedule" onClick={() => onSelect(BlockEnum.TriggerSchedule)}> + <button + data-testid="select-trigger-schedule" + onClick={() => onSelect(BlockEnum.TriggerSchedule)} + > Select Trigger Schedule </button> - <button data-testid="select-trigger-webhook" onClick={() => onSelect(BlockEnum.TriggerWebhook, { config: 'test' })}> + <button + data-testid="select-trigger-webhook" + onClick={() => onSelect(BlockEnum.TriggerWebhook, { config: 'test' })} + > Select Trigger Webhook </button> </div> @@ -50,8 +56,10 @@ describe('WorkflowOnboardingModal', () => { return render(<WorkflowOnboardingModal {...defaultProps} {...props} />) } const getBackdrop = () => document.body.querySelector('.bg-workflow-canvas-canvas-overlay') - const getUserInputHeading = () => screen.getByRole('heading', { name: 'workflow.onboarding.userInputFull' }) - const getTriggerHeading = () => screen.getByRole('heading', { name: 'workflow.onboarding.trigger' }) + const getUserInputHeading = () => + screen.getByRole('heading', { name: 'workflow.onboarding.userInputFull' }) + const getTriggerHeading = () => + screen.getByRole('heading', { name: 'workflow.onboarding.trigger' }) describe('Rendering', () => { it('should render without crashing', () => { @@ -199,7 +207,9 @@ describe('WorkflowOnboardingModal', () => { await user.click(screen.getByTestId('select-trigger-webhook')) expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1) - expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { config: 'test' }) + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { + config: 'test', + }) expect(mockOnClose).not.toHaveBeenCalled() }) }) @@ -240,8 +250,7 @@ describe('WorkflowOnboardingModal', () => { const backdrop = getBackdrop() expect(backdrop).toBeInTheDocument() - if (!backdrop) - throw new Error('backdrop should exist when dialog is open') + if (!backdrop) throw new Error('backdrop should exist when dialog is open') await user.click(backdrop) @@ -412,7 +421,9 @@ describe('WorkflowOnboardingModal', () => { await user.click(getTriggerHeading()) await user.click(screen.getByTestId('select-trigger-webhook')) - expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { config: 'test' }) + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { + config: 'test', + }) expect(mockOnClose).not.toHaveBeenCalled() }) diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-option.spec.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-option.spec.tsx index 2739c51b6242aa..c07b6aa22fa975 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-option.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-option.spec.tsx @@ -126,7 +126,8 @@ describe('StartNodeOption', () => { it('should render long description correctly', () => { // Arrange - const longDescription = 'This is a very long description that explains the option in great detail and should still render correctly within the component layout' + const longDescription = + 'This is a very long description that explains the option in great detail and should still render correctly within the component layout' // Act renderComponent({ description: longDescription }) @@ -233,7 +234,8 @@ describe('StartNodeOption', () => { renderComponent({ title: '' }) // Assert - const titleContainer = screen.getByText('Test description for the option').parentElement?.parentElement + const titleContainer = screen.getByText('Test description for the option').parentElement + ?.parentElement expect(titleContainer).toBeInTheDocument() }) diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-selection-panel.spec.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-selection-panel.spec.tsx index b2496f87142dee..d63c27008f38e7 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-selection-panel.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-selection-panel.spec.tsx @@ -31,16 +31,10 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ > Select Schedule </button> - <button - data-testid="select-webhook" - onClick={() => onSelect(BlockEnum.TriggerWebhook)} - > + <button data-testid="select-webhook" onClick={() => onSelect(BlockEnum.TriggerWebhook)}> Select Webhook </button> - <button - data-testid="close-selector" - onClick={() => onOpenChange(false)} - > + <button data-testid="close-selector" onClick={() => onOpenChange(false)}> Close </button> </div> diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx index d3755446ae8321..81bd5724822807 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx @@ -1,7 +1,13 @@ 'use client' import type { FC } from 'react' import type { BlockDefaultValue } from '@/app/components/workflow/block-selector/types' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { useTranslation } from 'react-i18next' import { BlockEnum } from '@/app/components/workflow/types' import StartNodeSelectionPanel from './start-node-selection-panel' @@ -30,10 +36,10 @@ const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({ <div className="pb-4"> <div className="mb-6"> <DialogTitle className="mb-2 title-2xl-semi-bold text-text-primary"> - {t($ => $['onboarding.title'], { ns: 'workflow' })} + {t(($) => $['onboarding.title'], { ns: 'workflow' })} </DialogTitle> <DialogDescription className="body-xs-regular leading-4 text-text-tertiary"> - {t($ => $['onboarding.description'], { ns: 'workflow' })} + {t(($) => $['onboarding.description'], { ns: 'workflow' })} </DialogDescription> </div> diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx index 07eb8ffe3c133d..3b148fd42faffb 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx @@ -21,27 +21,20 @@ const StartNodeOption: FC<StartNodeOptionProps> = ({ onClick={onClick} className="flex h-40 w-[280px] cursor-pointer flex-col gap-2 rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-4 shadow-sm transition-all hover:shadow-md" > - <div className="shrink-0"> - {icon} - </div> + <div className="shrink-0">{icon}</div> <div className="flex h-[74px] flex-col gap-1 py-0.5"> <div className="h-5 leading-5"> <h3 className="text-text-primary"> {title} {subtitle && ( - <span className="system-md-regular text-text-quaternary"> - {' '} - {subtitle} - </span> + <span className="system-md-regular text-text-quaternary"> {subtitle}</span> )} </h3> </div> <div className="h-12 leading-4"> - <p className="system-xs-regular text-text-tertiary"> - {description} - </p> + <p className="system-xs-regular text-text-tertiary">{description}</p> </div> </div> </div> diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx index 40b8cb14400643..9b3fb27e0882be 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx @@ -20,21 +20,24 @@ const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({ const { t } = useTranslation() const [showTriggerSelector, setShowTriggerSelector] = useState(false) - const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: BlockDefaultValue) => { - setShowTriggerSelector(false) - onSelectTrigger(nodeType, toolConfig) - }, [onSelectTrigger]) + const handleTriggerSelect = useCallback( + (nodeType: BlockEnum, toolConfig?: BlockDefaultValue) => { + setShowTriggerSelector(false) + onSelectTrigger(nodeType, toolConfig) + }, + [onSelectTrigger], + ) return ( <div className="grid grid-cols-2 gap-4"> <StartNodeOption - icon={( + icon={ <div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2"> <span className="i-custom-vender-workflow-home size-5 text-white" /> </div> - )} - title={t($ => $['onboarding.userInputFull'], { ns: 'workflow' })} - description={t($ => $['onboarding.userInputDescription'], { ns: 'workflow' })} + } + title={t(($) => $['onboarding.userInputFull'], { ns: 'workflow' })} + description={t(($) => $['onboarding.userInputDescription'], { ns: 'workflow' })} onClick={onSelectUserInput} /> @@ -55,13 +58,13 @@ const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({ ]} trigger={() => ( <StartNodeOption - icon={( + icon={ <div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2"> <span className="i-custom-vender-workflow-trigger-all size-5 text-white" /> </div> - )} - title={t($ => $['onboarding.trigger'], { ns: 'workflow' })} - description={t($ => $['onboarding.triggerDescription'], { ns: 'workflow' })} + } + title={t(($) => $['onboarding.trigger'], { ns: 'workflow' })} + description={t(($) => $['onboarding.triggerDescription'], { ns: 'workflow' })} onClick={() => setShowTriggerSelector(true)} /> )} diff --git a/web/app/components/workflow-app/components/workflow-panel.tsx b/web/app/components/workflow-app/components/workflow-panel.tsx index 1a9a76d9468353..4e7abbfef8471d 100644 --- a/web/app/components/workflow-app/components/workflow-panel.tsx +++ b/web/app/components/workflow-app/components/workflow-panel.tsx @@ -1,17 +1,12 @@ import type { PanelProps } from '@/app/components/workflow/panel' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import Panel from '@/app/components/workflow/panel' import CommentsPanel from '@/app/components/workflow/panel/comments-panel' import { useStore } from '@/app/components/workflow/store' import dynamic from '@/next/dynamic' -import { - useIsChatMode, -} from '../hooks' +import { useIsChatMode } from '../hooks' const MessageLogModal = dynamic(() => import('@/app/components/base/message-log-modal'), { ssr: false, @@ -28,86 +23,74 @@ const DebugAndPreview = dynamic(() => import('@/app/components/workflow/panel/de const WorkflowPreview = dynamic(() => import('@/app/components/workflow/panel/workflow-preview'), { ssr: false, }) -const ChatVariablePanel = dynamic(() => import('@/app/components/workflow/panel/chat-variable-panel'), { - ssr: false, -}) -const GlobalVariablePanel = dynamic(() => import('@/app/components/workflow/panel/global-variable-panel'), { - ssr: false, -}) +const ChatVariablePanel = dynamic( + () => import('@/app/components/workflow/panel/chat-variable-panel'), + { + ssr: false, + }, +) +const GlobalVariablePanel = dynamic( + () => import('@/app/components/workflow/panel/global-variable-panel'), + { + ssr: false, + }, +) const WorkflowPanelOnLeft = () => { - const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, currentLogModalActiveTab } = useAppStore(useShallow(state => ({ - currentLogItem: state.currentLogItem, - setCurrentLogItem: state.setCurrentLogItem, - showMessageLogModal: state.showMessageLogModal, - setShowMessageLogModal: state.setShowMessageLogModal, - currentLogModalActiveTab: state.currentLogModalActiveTab, - }))) + const { + currentLogItem, + setCurrentLogItem, + showMessageLogModal, + setShowMessageLogModal, + currentLogModalActiveTab, + } = useAppStore( + useShallow((state) => ({ + currentLogItem: state.currentLogItem, + setCurrentLogItem: state.setCurrentLogItem, + showMessageLogModal: state.showMessageLogModal, + setShowMessageLogModal: state.setShowMessageLogModal, + currentLogModalActiveTab: state.currentLogModalActiveTab, + })), + ) return ( <> - { - showMessageLogModal && ( - <MessageLogModal - fixedWidth - width={400} - currentLogItem={currentLogItem} - onCancel={() => { - setCurrentLogItem() - setShowMessageLogModal(false) - }} - defaultTab={currentLogModalActiveTab} - /> - ) - } + {showMessageLogModal && ( + <MessageLogModal + fixedWidth + width={400} + currentLogItem={currentLogItem} + onCancel={() => { + setCurrentLogItem() + setShowMessageLogModal(false) + }} + defaultTab={currentLogModalActiveTab} + /> + )} </> ) } const WorkflowPanelOnRight = () => { const isChatMode = useIsChatMode() - const historyWorkflowData = useStore(s => s.historyWorkflowData) - const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel) - const showChatVariablePanel = useStore(s => s.showChatVariablePanel) - const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel) - const controlMode = useStore(s => s.controlMode) + const historyWorkflowData = useStore((s) => s.historyWorkflowData) + const showDebugAndPreviewPanel = useStore((s) => s.showDebugAndPreviewPanel) + const showChatVariablePanel = useStore((s) => s.showChatVariablePanel) + const showGlobalVariablePanel = useStore((s) => s.showGlobalVariablePanel) + const controlMode = useStore((s) => s.controlMode) return ( <> - { - historyWorkflowData && !isChatMode && ( - <Record /> - ) - } - { - historyWorkflowData && isChatMode && ( - <ChatRecord /> - ) - } - { - showDebugAndPreviewPanel && isChatMode && ( - <DebugAndPreview /> - ) - } - { - showDebugAndPreviewPanel && !isChatMode && ( - <WorkflowPreview /> - ) - } - { - showChatVariablePanel && isChatMode && ( - <ChatVariablePanel /> - ) - } - { - showGlobalVariablePanel && ( - <GlobalVariablePanel /> - ) - } + {historyWorkflowData && !isChatMode && <Record />} + {historyWorkflowData && isChatMode && <ChatRecord />} + {showDebugAndPreviewPanel && isChatMode && <DebugAndPreview />} + {showDebugAndPreviewPanel && !isChatMode && <WorkflowPreview />} + {showChatVariablePanel && isChatMode && <ChatVariablePanel />} + {showGlobalVariablePanel && <GlobalVariablePanel />} {controlMode === 'comment' && <CommentsPanel />} </> ) } const WorkflowPanel = () => { - const appDetail = useAppStore(s => s.appDetail) + const appDetail = useAppStore((s) => s.appDetail) const versionHistoryPanelProps = useMemo(() => { const appId = appDetail?.id return { @@ -129,9 +112,7 @@ const WorkflowPanel = () => { } }, [versionHistoryPanelProps]) - return ( - <Panel {...panelProps} /> - ) + return <Panel {...panelProps} /> } export default memo(WorkflowPanel) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts index 33667eb3dc605d..a9bc05b31da4f7 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts @@ -11,10 +11,18 @@ const toastMocks = vi.hoisted(() => ({ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign(toastMocks.call, { - success: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'success', message, ...options })), - error: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'error', message, ...options })), - warning: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'warning', message, ...options })), - info: vi.fn((message: string, options?: Record<string, unknown>) => toastMocks.call({ type: 'info', message, ...options })), + success: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'success', message, ...options }), + ), + error: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: string, options?: Record<string, unknown>) => + toastMocks.call({ type: 'info', message, ...options }), + ), dismiss: toastMocks.dismiss, update: toastMocks.update, promise: toastMocks.promise, @@ -99,10 +107,12 @@ describe('useDSLByCanEdit', () => { include: false, workflowID: undefined, }) - expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.any(Blob), - fileName: 'Workflow App.yml', - })) + expect(mockDownloadBlob).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.any(Blob), + fileName: 'Workflow App.yml', + }), + ) }) it('should forward include and workflow id arguments when exporting dsl directly', async () => { diff --git a/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts index 24101104c9e4cf..d590ca4af567b7 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts @@ -32,8 +32,12 @@ describe('useAvailableNodesMetaData', () => { expect(result.current.nodesMap?.[BlockEnum.Answer]).toBeDefined() expect(result.current.nodesMap?.[BlockEnum.End]).toBeUndefined() expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeUndefined() - expect(result.current.nodesMap?.[BlockEnum.VariableAssigner]).toBe(result.current.nodesMap?.[BlockEnum.VariableAggregator]) - expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toContain('/docs/use-dify/nodes/') + expect(result.current.nodesMap?.[BlockEnum.VariableAssigner]).toBe( + result.current.nodesMap?.[BlockEnum.VariableAggregator], + ) + expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toContain( + '/docs/use-dify/nodes/', + ) }) it('should include workflow-specific trigger and end nodes outside chat mode', () => { @@ -59,18 +63,24 @@ describe('useAvailableNodesMetaData', () => { const { result } = renderHook(() => useAvailableNodesMetaData()) - expect(result.current.nodesMap?.[BlockEnum.End]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/output') - expect(result.current.nodesMap?.[BlockEnum.IterationStart]?.metaData.helpLinkUri).toBeUndefined() + expect(result.current.nodesMap?.[BlockEnum.End]?.metaData.helpLinkUri).toBe( + '/docs/use-dify/nodes/output', + ) + expect( + result.current.nodesMap?.[BlockEnum.IterationStart]?.metaData.helpLinkUri, + ).toBeUndefined() expect(result.current.nodesMap?.[BlockEnum.LoopStart]?.metaData.helpLinkUri).toBeUndefined() expect(result.current.nodesMap?.[BlockEnum.LoopEnd]?.metaData.helpLinkUri).toBeUndefined() - expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/start') + expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toBe( + '/docs/use-dify/nodes/start', + ) }) it('should expose Agent v2 instead of legacy Agent when Agent v2 is enabled', () => { mockUseIsChatMode.mockReturnValue(false) const { result } = renderHook(() => useAvailableNodesMetaData()) - const nodeTypes = result.current.nodes.map(node => node.metaData.type) + const nodeTypes = result.current.nodes.map((node) => node.metaData.type) expect(nodeTypes).toContain(BlockEnum.AgentV2) expect(nodeTypes).not.toContain(BlockEnum.Agent) @@ -83,7 +93,7 @@ describe('useAvailableNodesMetaData', () => { mockIsAgentV2Enabled.mockReturnValue(false) const { result } = renderHook(() => useAvailableNodesMetaData()) - const nodeTypes = result.current.nodes.map(node => node.metaData.type) + const nodeTypes = result.current.nodes.map((node) => node.metaData.type) expect(nodeTypes).toContain(BlockEnum.Agent) expect(nodeTypes).not.toContain(BlockEnum.AgentV2) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-configs-map.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-configs-map.spec.ts index 34e45044492930..3613475b7b0d19 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-configs-map.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-configs-map.spec.ts @@ -5,7 +5,8 @@ import { useConfigsMap } from '../use-configs-map' const mockUseFeatures = vi.fn() vi.mock('@/app/components/base/features/hooks', () => ({ - useFeatures: (selector: (state: { features: { file: Record<string, unknown> } }) => unknown) => mockUseFeatures(selector), + useFeatures: (selector: (state: { features: { file: Record<string, unknown> } }) => unknown) => + mockUseFeatures(selector), })) vi.mock('@/app/components/workflow/store', () => ({ @@ -15,14 +16,17 @@ vi.mock('@/app/components/workflow/store', () => ({ describe('useConfigsMap', () => { beforeEach(() => { vi.clearAllMocks() - mockUseFeatures.mockImplementation((selector: (state: { features: { file: Record<string, unknown> } }) => unknown) => selector({ - features: { - file: { - enabled: true, - number_limits: 3, - }, - }, - })) + mockUseFeatures.mockImplementation( + (selector: (state: { features: { file: Record<string, unknown> } }) => unknown) => + selector({ + features: { + file: { + enabled: true, + number_limits: 3, + }, + }, + }), + ) }) it('should map workflow app id and feature file settings into inspect-var configs', () => { diff --git a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts index 1a2ad212d6cb4f..1aadaa5f44ee83 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -1,6 +1,5 @@ import { act } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import { renderHookWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { BlockEnum } from '@/app/components/workflow/types' import { useNodesSyncDraft } from '../use-nodes-sync-draft' @@ -33,7 +32,7 @@ let workflowStoreState: { let featuresState: { features: { - opening: { enabled: boolean, opening_statement: string, suggested_questions: string[] } + opening: { enabled: boolean; opening_statement: string; suggested_questions: string[] } suggested: Record<string, unknown> text2speech: Record<string, unknown> speech2text: Record<string, unknown> @@ -72,10 +71,10 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () })) vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({ - useSerialAsyncCallback: (fn: (...args: unknown[]) => Promise<void>, checkFn: () => boolean) => + useSerialAsyncCallback: + (fn: (...args: unknown[]) => Promise<void>, checkFn: () => boolean) => (...args: unknown[]) => { - if (!checkFn()) - return fn(...args) + if (!checkFn()) return fn(...args) }, })) @@ -135,7 +134,9 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => }, } mockGetNodesReadOnly.mockReturnValue(false) - mockGetNodes.mockReturnValue([{ id: 'n1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } }]) + mockGetNodes.mockReturnValue([ + { id: 'n1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } }, + ]) mockSyncWorkflowDraft.mockResolvedValue({ hash: 'new', updated_at: 1 }) mockCollaborationIsConnected.mockReturnValue(false) mockCollaborationGetIsLeader.mockReturnValue(true) @@ -143,27 +144,33 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => }) it('should call handleRefreshWorkflowDraft(true) — not updating canvas — on draft_workflow_not_sync', async () => { - const error = { json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_sync' }), bodyUsed: false } + const error = { + json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_sync' }), + bodyUsed: false, + } mockSyncWorkflowDraft.mockRejectedValue(error) const { result } = renderUseNodesSyncDraft() await act(async () => { await result.current.doSyncWorkflowDraft(false) }) - await new Promise(r => setTimeout(r, 0)) + await new Promise((r) => setTimeout(r, 0)) expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledWith(true) }) it('should NOT refresh when notRefreshWhenSyncError=true', async () => { - const error = { json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_sync' }), bodyUsed: false } + const error = { + json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_sync' }), + bodyUsed: false, + } mockSyncWorkflowDraft.mockRejectedValue(error) const { result } = renderUseNodesSyncDraft() await act(async () => { await result.current.doSyncWorkflowDraft(true) }) - await new Promise(r => setTimeout(r, 0)) + await new Promise((r) => setTimeout(r, 0)) expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled() }) @@ -176,7 +183,7 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => await act(async () => { await result.current.doSyncWorkflowDraft(false) }) - await new Promise(r => setTimeout(r, 0)) + await new Promise((r) => setTimeout(r, 0)) expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled() }) @@ -210,27 +217,51 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => await result.current.doSyncWorkflowDraft(false) }) - expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({ - params: expect.not.objectContaining({ - source_workflow_id: expect.anything(), + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.not.objectContaining({ + source_workflow_id: expect.anything(), + }), }), - })) + ) }) it('should strip temp entities and private data, use the latest hash, and invoke success callbacks', async () => { reactFlowState = { ...reactFlowState, edges: [ - { id: 'edge-1', source: 'n1', target: 'n2', data: { _isTemp: false, _private: 'drop', stable: 'keep' } }, - { id: 'placeholder-edge', source: 'start-placeholder', target: 'n1', data: { stable: 'drop' } }, + { + id: 'edge-1', + source: 'n1', + target: 'n2', + data: { _isTemp: false, _private: 'drop', stable: 'keep' }, + }, + { + id: 'placeholder-edge', + source: 'start-placeholder', + target: 'n1', + data: { stable: 'drop' }, + }, { id: 'temp-edge', source: 'n2', target: 'n3', data: { _isTemp: true } }, ], transform: [10, 20, 1.5], } mockGetNodes.mockReturnValue([ - { id: 'n1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start, _tempField: 'drop', label: 'Start' } }, - { id: 'start-placeholder', position: { x: 1, y: 1 }, data: { type: BlockEnum.StartPlaceholder } }, - { id: 'temp-node', position: { x: 2, y: 2 }, data: { type: BlockEnum.Answer, _isTempNode: true } }, + { + id: 'n1', + position: { x: 0, y: 0 }, + data: { type: BlockEnum.Start, _tempField: 'drop', label: 'Start' }, + }, + { + id: 'start-placeholder', + position: { x: 1, y: 1 }, + data: { type: BlockEnum.StartPlaceholder }, + }, + { + id: 'temp-node', + position: { x: 2, y: 2 }, + data: { type: BlockEnum.Answer, _isTempNode: true }, + }, ]) workflowStoreState = { ...workflowStoreState, @@ -266,7 +297,9 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => url: '/apps/app-1/workflows/draft', params: { graph: { - nodes: [{ id: 'n1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start, label: 'Start' } }], + nodes: [ + { id: 'n1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start, label: 'Start' } }, + ], edges: [{ id: 'edge-1', source: 'n1', target: 'n2', data: { stable: 'keep' } }], viewport: { x: 10, y: 20, zoom: 1.5 }, }, @@ -296,7 +329,12 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => reactFlowState = { ...reactFlowState, edges: [ - { id: 'edge-1', source: 'n1', target: 'pending-agent', data: { sourceType: BlockEnum.Start, targetType: BlockEnum.Agent } }, + { + id: 'edge-1', + source: 'n1', + target: 'pending-agent', + data: { sourceType: BlockEnum.Start, targetType: BlockEnum.Agent }, + }, { id: 'temp-edge', source: 'temp-node', target: 'pending-agent', data: {} }, ], } @@ -319,7 +357,11 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => selected: true, }, }, - { id: 'temp-node', position: { x: 2, y: 2 }, data: { type: BlockEnum.Answer, _isTempNode: true } }, + { + id: 'temp-node', + position: { x: 2, y: 2 }, + data: { type: BlockEnum.Answer, _isTempNode: true }, + }, ]) const { result } = renderUseNodesSyncDraft() @@ -328,33 +370,40 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => await result.current.doSyncWorkflowDraft(false) }) - expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({ - params: expect.objectContaining({ - graph: expect.objectContaining({ - nodes: [ - { id: 'n1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } }, - { - id: 'pending-agent', - position: { x: 1, y: 1 }, - data: { - type: BlockEnum.Agent, - title: 'Agent', - desc: '', - agent_node_kind: 'dify_agent', - version: '2', - agent_binding: { - binding_type: 'inline_agent', + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + graph: expect.objectContaining({ + nodes: [ + { id: 'n1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } }, + { + id: 'pending-agent', + position: { x: 1, y: 1 }, + data: { + type: BlockEnum.Agent, + title: 'Agent', + desc: '', + agent_node_kind: 'dify_agent', + version: '2', + agent_binding: { + binding_type: 'inline_agent', + }, + selected: true, }, - selected: true, }, - }, - ], - edges: [ - { id: 'edge-1', source: 'n1', target: 'pending-agent', data: { sourceType: BlockEnum.Start, targetType: BlockEnum.Agent } }, - ], + ], + edges: [ + { + id: 'edge-1', + source: 'n1', + target: 'pending-agent', + data: { sourceType: BlockEnum.Start, targetType: BlockEnum.Agent }, + }, + ], + }), }), }), - })) + ) }) it('should post workflow draft with keepalive when the page closes', () => { @@ -374,23 +423,28 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => result.current.syncWorkflowDraftWhenPageClose() }) - expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/apps/app-1/workflows/draft', expect.objectContaining({ - graph: expect.objectContaining({ - viewport: { x: 1, y: 2, zoom: 3 }, + expect(mockPostWithKeepalive).toHaveBeenCalledWith( + '/api/apps/app-1/workflows/draft', + expect.objectContaining({ + graph: expect.objectContaining({ + viewport: { x: 1, y: 2, zoom: 3 }, + }), + hash: 'hash-123', }), - hash: 'hash-123', - })) + ) }) it('should not post the local start placeholder when the page closes', () => { reactFlowState = { ...reactFlowState, - edges: [ - { id: 'placeholder-edge', source: 'start-placeholder', target: 'n1', data: {} }, - ], + edges: [{ id: 'placeholder-edge', source: 'start-placeholder', target: 'n1', data: {} }], } mockGetNodes.mockReturnValue([ - { id: 'start-placeholder', position: { x: 0, y: 0 }, data: { type: BlockEnum.StartPlaceholder } }, + { + id: 'start-placeholder', + position: { x: 0, y: 0 }, + data: { type: BlockEnum.StartPlaceholder }, + }, { id: 'n1', position: { x: 1, y: 1 }, data: { type: BlockEnum.Start } }, ]) @@ -400,12 +454,15 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => result.current.syncWorkflowDraftWhenPageClose() }) - expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/apps/app-1/workflows/draft', expect.objectContaining({ - graph: expect.objectContaining({ - nodes: [{ id: 'n1', position: { x: 1, y: 1 }, data: { type: BlockEnum.Start } }], - edges: [], + expect(mockPostWithKeepalive).toHaveBeenCalledWith( + '/api/apps/app-1/workflows/draft', + expect.objectContaining({ + graph: expect.objectContaining({ + nodes: [{ id: 'n1', position: { x: 1, y: 1 }, data: { type: BlockEnum.Start } }], + edges: [], + }), }), - })) + ) }) it('should emit sync request instead of syncing when current user is collaboration follower', async () => { diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-draft-graph-for-canvas.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-draft-graph-for-canvas.spec.ts index 71ac93add53c61..ac2110429f55db 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-draft-graph-for-canvas.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-draft-graph-for-canvas.spec.ts @@ -9,7 +9,10 @@ vi.mock('@/app/components/workflow/utils', async (importOriginal) => { const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>() return { ...actual, - generateNewNode: (args: { data: Record<string, unknown>, position: Record<string, unknown> }) => { + generateNewNode: (args: { + data: Record<string, unknown> + position: Record<string, unknown> + }) => { generateNewNodeCalls.push(args) return { newNode: { @@ -84,17 +87,23 @@ describe('useWorkflowDraftGraphForCanvas', () => { }) it('should reuse the provided local start placeholder template when available', () => { - const localStartPlaceholder = { id: 'start-placeholder', data: { type: BlockEnum.StartPlaceholder } } + const localStartPlaceholder = { + id: 'start-placeholder', + data: { type: BlockEnum.StartPlaceholder }, + } const draftNode = { id: 'llm', data: { type: BlockEnum.LLM } } const { result } = renderHook(() => useWorkflowDraftGraphForCanvas(AppModeEnum.WORKFLOW)) - const graph = result.current.getWorkflowDraftGraphForCanvas({ - nodes: [draftNode] as never, - edges: [], - viewport: { x: 1, y: 2, zoom: 0.5 }, - }, { - localStartPlaceholderNodes: [localStartPlaceholder] as never, - }) + const graph = result.current.getWorkflowDraftGraphForCanvas( + { + nodes: [draftNode] as never, + edges: [], + viewport: { x: 1, y: 2, zoom: 0.5 }, + }, + { + localStartPlaceholderNodes: [localStartPlaceholder] as never, + }, + ) expect(graph.nodes).toEqual([localStartPlaceholder, draftNode]) expect(graph.viewport).toEqual({ x: 1, y: 2, zoom: 0.5 }) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts index be1e6f17df51c7..f572c8b6f62e63 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts @@ -2,7 +2,6 @@ import { renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { BlockEnum } from '@/app/components/workflow/types' import { AppACLPermission } from '@/utils/permission' - import { useWorkflowInit } from '../use-workflow-init' const mockSetSyncWorkflowDraftHash = vi.fn() @@ -33,8 +32,9 @@ let workflowConfigState: { } vi.mock('@/app/components/workflow/store', () => ({ - useStore: <T>(selector: (state: { setSyncWorkflowDraftHash: ReturnType<typeof vi.fn> }) => T): T => - selector({ setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash }), + useStore: <T>( + selector: (state: { setSyncWorkflowDraftHash: ReturnType<typeof vi.fn> }) => T, + ): T => selector({ setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash }), useWorkflowStore: () => ({ setState: mockWorkflowStoreSetState, getState: mockWorkflowStoreGetState, @@ -42,8 +42,7 @@ vi.mock('@/app/components/workflow/store', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: <T>(selector: (state: typeof appStoreState) => T): T => - selector(appStoreState), + useStore: <T>(selector: (state: typeof appStoreState) => T): T => selector(appStoreState), })) vi.mock('@/context/account-state', async (importOriginal) => { @@ -88,24 +87,25 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('../use-workflow-template', () => ({ useWorkflowTemplate: () => ({ - nodes: appStoreState.appDetail.mode === 'workflow' - ? [{ id: 'start-placeholder', data: { type: BlockEnum.StartPlaceholder } }] - : [{ id: 'start', data: { type: BlockEnum.Start } }], + nodes: + appStoreState.appDetail.mode === 'workflow' + ? [{ id: 'start-placeholder', data: { type: BlockEnum.StartPlaceholder } }] + : [{ id: 'start', data: { type: BlockEnum.Start } }], edges: [], }), })) vi.mock('@/service/use-workflow', () => ({ useWorkflowConfig: (_url: string, onSuccess: (config: Record<string, unknown>) => void) => { - if (workflowConfigState.data) - onSuccess(workflowConfigState.data) + if (workflowConfigState.data) onSuccess(workflowConfigState.data) return workflowConfigState }, })) @@ -148,7 +148,12 @@ describe('useWorkflowInit', () => { beforeEach(() => { vi.clearAllMocks() appStoreState = { - appDetail: { id: 'app-1', name: 'Test', mode: 'workflow', permission_keys: [AppACLPermission.Edit] }, + appDetail: { + id: 'app-1', + name: 'Test', + mode: 'workflow', + permission_keys: [AppACLPermission.Edit], + }, } workflowConfigState = { data: null, isLoading: false } mockWorkflowStoreGetState.mockReturnValue({ @@ -160,8 +165,7 @@ describe('useWorkflowInit', () => { }) mockFetchNodesDefaultConfigs.mockResolvedValue([]) mockFetchPublishedWorkflow.mockResolvedValue({ created_at: 0, graph: { nodes: [], edges: [] } }) - mockFetchWorkflowDraft - .mockRejectedValueOnce(notExistError()) + mockFetchWorkflowDraft.mockRejectedValueOnce(notExistError()) mockSyncWorkflowDraft.mockReset() }) @@ -184,27 +188,36 @@ describe('useWorkflowInit', () => { ]) }) - expect(mockWorkflowStoreSetState).toHaveBeenCalledWith(expect.objectContaining({ - showOnboarding: false, - shouldAutoOpenStartNodeSelector: false, - hasSelectedStartNode: false, - hasShownOnboarding: true, - })) - expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({ - params: expect.objectContaining({ - graph: { - nodes: [], - edges: [], - }, + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith( + expect.objectContaining({ + showOnboarding: false, + shouldAutoOpenStartNodeSelector: false, + hasSelectedStartNode: false, + hasShownOnboarding: true, }), - })) + ) + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + graph: { + nodes: [], + edges: [], + }, + }), + }), + ) expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash') expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-workflow-hash') }) it('should keep creating the first backend draft for advanced chat apps', async () => { appStoreState = { - appDetail: { id: 'app-1', name: 'Test', mode: 'advanced-chat', permission_keys: [AppACLPermission.Edit] }, + appDetail: { + id: 'app-1', + name: 'Test', + mode: 'advanced-chat', + permission_keys: [AppACLPermission.Edit], + }, } mockFetchWorkflowDraft .mockReset() @@ -214,29 +227,38 @@ describe('useWorkflowInit', () => { renderHook(() => useWorkflowInit()) - await waitFor(() => expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({ - params: expect.objectContaining({ - graph: { - nodes: [{ id: 'start', data: { type: BlockEnum.Start } }], - edges: [], - }, + await waitFor(() => + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + graph: { + nodes: [{ id: 'start', data: { type: BlockEnum.Start } }], + edges: [], + }, + }), + }), + ), + ) + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith( + expect.objectContaining({ + showOnboarding: false, + shouldAutoOpenStartNodeSelector: false, + hasShownOnboarding: false, }), - }))) - expect(mockWorkflowStoreSetState).toHaveBeenCalledWith(expect.objectContaining({ - showOnboarding: false, - shouldAutoOpenStartNodeSelector: false, - hasShownOnboarding: false, - })) + ) expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash') }) it('should keep readonly users local when the first workflow draft does not exist', async () => { appStoreState = { - appDetail: { id: 'app-1', name: 'Test', mode: 'workflow', permission_keys: [AppACLPermission.ViewLayout] }, + appDetail: { + id: 'app-1', + name: 'Test', + mode: 'workflow', + permission_keys: [AppACLPermission.ViewLayout], + }, } - mockFetchWorkflowDraft - .mockReset() - .mockRejectedValueOnce(notExistError()) + mockFetchWorkflowDraft.mockReset().mockRejectedValueOnce(notExistError()) const { result } = renderHook(() => useWorkflowInit()) @@ -248,12 +270,14 @@ describe('useWorkflowInit', () => { { id: 'start-placeholder', data: { type: BlockEnum.StartPlaceholder } }, ]) expect(mockSyncWorkflowDraft).not.toHaveBeenCalled() - expect(mockWorkflowStoreSetState).toHaveBeenCalledWith(expect.objectContaining({ - envSecrets: {}, - environmentVariables: [], - conversationVariables: [], - isWorkflowDataLoaded: true, - })) + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith( + expect.objectContaining({ + envSecrets: {}, + environmentVariables: [], + conversationVariables: [], + isWorkflowDataLoaded: true, + }), + ) expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('') }) @@ -334,15 +358,17 @@ describe('useWorkflowInit', () => { }) expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ appId: 'app-1', appName: 'Test' }) - expect(mockWorkflowStoreSetState).toHaveBeenCalledWith(expect.objectContaining({ - envSecrets: { 'env-secret': 'top-secret' }, - environmentVariables: [ - { id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' }, - { id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' }, - ], - conversationVariables: [{ id: 'conversation-1' }], - isWorkflowDataLoaded: true, - })) + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith( + expect.objectContaining({ + envSecrets: { 'env-secret': 'top-secret' }, + environmentVariables: [ + { id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' }, + { id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' }, + ], + conversationVariables: [{ id: 'conversation-1' }], + isWorkflowDataLoaded: true, + }), + ) expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ nodesDefaultConfigs: { start: { title: 'Start Config' }, diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts index c77e52bf4d583b..9cb260882f0a96 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts @@ -2,7 +2,6 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { BlockEnum } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' - import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft' const mockHandleUpdateWorkflowCanvas = vi.fn() @@ -38,8 +37,7 @@ vi.mock('@/app/components/workflow/store', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: <T>(selector: (state: typeof appStoreState) => T): T => - selector(appStoreState), + useStore: <T>(selector: (state: typeof appStoreState) => T): T => selector(appStoreState), })) vi.mock('@/app/components/workflow/hooks', () => ({ diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts index 000c8fa532f398..d75a54101f7357 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts @@ -1,10 +1,10 @@ import type AudioPlayer from '@/app/components/base/audio-btn/audio' -import { createBaseWorkflowRunCallbacks, createFinalWorkflowRunCallbacks } from '../use-workflow-run-callbacks' +import { + createBaseWorkflowRunCallbacks, + createFinalWorkflowRunCallbacks, +} from '../use-workflow-run-callbacks' -const { - mockSseGet, - mockResetMsgId, -} = vi.hoisted(() => ({ +const { mockSseGet, mockResetMsgId } = vi.hoisted(() => ({ mockSseGet: vi.fn(), mockResetMsgId: vi.fn(), })) @@ -129,7 +129,10 @@ describe('useWorkflowRun callbacks helpers', () => { expect(handlers.handleWorkflowFailed).toHaveBeenCalled() expect(userOnError).toHaveBeenCalled() expect(getWorkflowRunningData).toHaveBeenCalled() - expect(trackWorkflowRunFailed).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' }, workflowData) + expect(trackWorkflowRunFailed).toHaveBeenCalledWith( + { error: 'failed', node_type: 'llm' }, + workflowData, + ) callbacks.onTTSChunk?.('message-1', 'audio-chunk') expect(getOrCreatePlayer).toHaveBeenCalled() @@ -320,7 +323,10 @@ describe('useWorkflowRun callbacks helpers', () => { expect(handlers.handleWorkflowFailed).toHaveBeenCalled() expect(userCallbacks.onError).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' }, '500') expect(getWorkflowRunningData).toHaveBeenCalled() - expect(trackWorkflowRunFailed).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' }, workflowData) + expect(trackWorkflowRunFailed).toHaveBeenCalledWith( + { error: 'failed', node_type: 'llm' }, + workflowData, + ) expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs') }) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-utils.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-utils.spec.ts index 2f1fe39ffcb7e2..cb1519ec69443a 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-utils.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-utils.spec.ts @@ -20,11 +20,7 @@ import { validateWorkflowRunRequest, } from '../use-workflow-run-utils' -const { - mockPost, - mockHandleStream, - mockToastError, -} = vi.hoisted(() => ({ +const { mockPost, mockHandleStream, mockToastError } = vi.hoisted(() => ({ mockPost: vi.fn(), mockHandleStream: vi.fn(), mockToastError: vi.fn(), @@ -57,25 +53,65 @@ describe('useWorkflowRun utils', () => { }) it('should resolve run history urls and run endpoints for workflow modes', () => { - expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW })).toBe('/apps/app-1/workflow-runs') - expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT })).toBe('/apps/app-1/advanced-chat/workflow-runs') + expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW })).toBe( + '/apps/app-1/workflow-runs', + ) + expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT })).toBe( + '/apps/app-1/advanced-chat/workflow-runs', + ) - expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.UserInput, true)).toBe('/apps/app-1/workflows/draft/run') - expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }, TriggerType.UserInput, false)).toBe('/apps/app-1/advanced-chat/workflows/draft/run') - expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.Schedule, true)).toBe('/apps/app-1/workflows/draft/trigger/run') - expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.All, true)).toBe('/apps/app-1/workflows/draft/trigger/run-all') + expect( + resolveWorkflowRunUrl( + { id: 'app-1', mode: AppModeEnum.WORKFLOW }, + TriggerType.UserInput, + true, + ), + ).toBe('/apps/app-1/workflows/draft/run') + expect( + resolveWorkflowRunUrl( + { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }, + TriggerType.UserInput, + false, + ), + ).toBe('/apps/app-1/advanced-chat/workflows/draft/run') + expect( + resolveWorkflowRunUrl( + { id: 'app-1', mode: AppModeEnum.WORKFLOW }, + TriggerType.Schedule, + true, + ), + ).toBe('/apps/app-1/workflows/draft/trigger/run') + expect( + resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.All, true), + ).toBe('/apps/app-1/workflows/draft/trigger/run-all') }) it('should build request bodies and validation errors for trigger runs', () => { - expect(buildWorkflowRunRequestBody(TriggerType.Schedule, {}, { scheduleNodeId: 'schedule-1' })).toEqual({ node_id: 'schedule-1' }) - expect(buildWorkflowRunRequestBody(TriggerType.Webhook, {}, { webhookNodeId: 'webhook-1' })).toEqual({ node_id: 'webhook-1' }) - expect(buildWorkflowRunRequestBody(TriggerType.Plugin, {}, { pluginNodeId: 'plugin-1' })).toEqual({ node_id: 'plugin-1' }) - expect(buildWorkflowRunRequestBody(TriggerType.All, {}, { allNodeIds: ['trigger-1', 'trigger-2'] })).toEqual({ node_ids: ['trigger-1', 'trigger-2'] }) - expect(buildWorkflowRunRequestBody(TriggerType.UserInput, { inputs: { query: 'hello' } })).toEqual({ inputs: { query: 'hello' } }) - - expect(validateWorkflowRunRequest(TriggerType.Schedule)).toBe('handleRun: schedule trigger run requires node id') - expect(validateWorkflowRunRequest(TriggerType.Webhook)).toBe('handleRun: webhook trigger run requires node id') - expect(validateWorkflowRunRequest(TriggerType.Plugin)).toBe('handleRun: plugin trigger run requires node id') + expect( + buildWorkflowRunRequestBody(TriggerType.Schedule, {}, { scheduleNodeId: 'schedule-1' }), + ).toEqual({ node_id: 'schedule-1' }) + expect( + buildWorkflowRunRequestBody(TriggerType.Webhook, {}, { webhookNodeId: 'webhook-1' }), + ).toEqual({ node_id: 'webhook-1' }) + expect( + buildWorkflowRunRequestBody(TriggerType.Plugin, {}, { pluginNodeId: 'plugin-1' }), + ).toEqual({ node_id: 'plugin-1' }) + expect( + buildWorkflowRunRequestBody(TriggerType.All, {}, { allNodeIds: ['trigger-1', 'trigger-2'] }), + ).toEqual({ node_ids: ['trigger-1', 'trigger-2'] }) + expect( + buildWorkflowRunRequestBody(TriggerType.UserInput, { inputs: { query: 'hello' } }), + ).toEqual({ inputs: { query: 'hello' } }) + + expect(validateWorkflowRunRequest(TriggerType.Schedule)).toBe( + 'handleRun: schedule trigger run requires node id', + ) + expect(validateWorkflowRunRequest(TriggerType.Webhook)).toBe( + 'handleRun: webhook trigger run requires node id', + ) + expect(validateWorkflowRunRequest(TriggerType.Plugin)).toBe( + 'handleRun: plugin trigger run requires node id', + ) expect(validateWorkflowRunRequest(TriggerType.All)).toBe('') expect(validateWorkflowRunRequest(TriggerType.All, { allNodeIds: [] })).toBe('') }) @@ -85,7 +121,13 @@ describe('useWorkflowRun utils', () => { expect(resolveWorkflowRunUrl(undefined, TriggerType.Plugin, true)).toBe('') expect(resolveWorkflowRunUrl(undefined, TriggerType.All, true)).toBe('') - expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.UserInput, false)).toBe('') + expect( + resolveWorkflowRunUrl( + { id: 'app-1', mode: AppModeEnum.WORKFLOW }, + TriggerType.UserInput, + false, + ), + ).toBe('') expect(consoleErrorSpy).toHaveBeenCalledWith('handleRun: missing app id for trigger plugin run') expect(consoleErrorSpy).toHaveBeenCalledWith('handleRun: missing app id for trigger run all') @@ -96,12 +138,17 @@ describe('useWorkflowRun utils', () => { it('should configure listening state for trigger and non-trigger modes', () => { const triggerActions = createListeningActions() - applyRunningStateForMode(triggerActions, TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] }) + applyRunningStateForMode(triggerActions, TriggerType.All, { + allNodeIds: ['trigger-1', 'trigger-2'], + }) expect(triggerActions.setIsListening).toHaveBeenCalledWith(true) expect(triggerActions.setShowVariableInspectPanel).toHaveBeenCalledWith(true) expect(triggerActions.setListeningTriggerIsAll).toHaveBeenCalledWith(true) - expect(triggerActions.setListeningTriggerNodeIds).toHaveBeenCalledWith(['trigger-1', 'trigger-2']) + expect(triggerActions.setListeningTriggerNodeIds).toHaveBeenCalledWith([ + 'trigger-1', + 'trigger-2', + ]) expect(triggerActions.setWorkflowRunningData).toHaveBeenCalledWith(createRunningWorkflowState()) const normalActions = createListeningActions() @@ -142,10 +189,18 @@ describe('useWorkflowRun utils', () => { }) it('should derive listening node ids, tts config, and published workflow mappings', () => { - expect(buildListeningTriggerNodeIds(TriggerType.Webhook, { webhookNodeId: 'webhook-1' })).toEqual(['webhook-1']) - expect(buildListeningTriggerNodeIds(TriggerType.Schedule, { scheduleNodeId: 'schedule-1' })).toEqual(['schedule-1']) - expect(buildListeningTriggerNodeIds(TriggerType.Plugin, { pluginNodeId: 'plugin-1' })).toEqual(['plugin-1']) - expect(buildListeningTriggerNodeIds(TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] })).toEqual(['trigger-1', 'trigger-2']) + expect( + buildListeningTriggerNodeIds(TriggerType.Webhook, { webhookNodeId: 'webhook-1' }), + ).toEqual(['webhook-1']) + expect( + buildListeningTriggerNodeIds(TriggerType.Schedule, { scheduleNodeId: 'schedule-1' }), + ).toEqual(['schedule-1']) + expect(buildListeningTriggerNodeIds(TriggerType.Plugin, { pluginNodeId: 'plugin-1' })).toEqual([ + 'plugin-1', + ]) + expect( + buildListeningTriggerNodeIds(TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] }), + ).toEqual(['trigger-1', 'trigger-2']) expect(buildTTSConfig({ token: 'public-token' }, '/apps/app-1')).toEqual({ ttsUrl: '/text-to-audio', @@ -226,9 +281,11 @@ describe('useWorkflowRun utils', () => { expect(clearAbortController).toHaveBeenCalledTimes(1) expect(clearListeningStateSpy).not.toHaveBeenCalled() - mockPost.mockResolvedValueOnce(new Response('{invalid-json}', { - headers: { 'content-type': 'application/json' }, - })) + mockPost.mockResolvedValueOnce( + new Response('{invalid-json}', { + headers: { 'content-type': 'application/json' }, + }), + ) await runTriggerDebug({ debugType: TriggerType.Schedule, @@ -265,9 +322,11 @@ describe('useWorkflowRun utils', () => { onCompleted: vi.fn(), } - mockPost.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Webhook failed' }), { - headers: { 'content-type': 'application/json' }, - })) + mockPost.mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'Webhook failed' }), { + headers: { 'content-type': 'application/json' }, + }), + ) await runTriggerDebug({ debugType: TriggerType.Webhook, @@ -287,9 +346,11 @@ describe('useWorkflowRun utils', () => { expect(clearListeningStateSpy).toHaveBeenCalled() expect(setWorkflowRunningData).toHaveBeenCalledWith(createFailedWorkflowState('Webhook failed')) - mockPost.mockResolvedValueOnce(new Response('data: ok', { - headers: { 'content-type': 'text/event-stream' }, - })) + mockPost.mockResolvedValueOnce( + new Response('data: ok', { + headers: { 'content-type': 'text/event-stream' }, + }), + ) await runTriggerDebug({ debugType: TriggerType.Plugin, @@ -320,12 +381,16 @@ describe('useWorkflowRun utils', () => { } mockPost - .mockResolvedValueOnce(new Response(JSON.stringify({ status: 'waiting', retry_in: 1 }), { - headers: { 'content-type': 'application/json' }, - })) - .mockResolvedValueOnce(new Response('data: ok', { - headers: { 'content-type': 'text/event-stream' }, - })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ status: 'waiting', retry_in: 1 }), { + headers: { 'content-type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response('data: ok', { + headers: { 'content-type': 'text/event-stream' }, + }), + ) const runPromise = runTriggerDebug({ debugType: TriggerType.All, @@ -355,9 +420,11 @@ describe('useWorkflowRun utils', () => { const setWorkflowRunningData = vi.fn() const controllerTarget: Record<string, unknown> = {} - mockPost.mockResolvedValueOnce(new Response('data: ok', { - headers: { 'content-type': 'text/event-stream' }, - })) + mockPost.mockResolvedValueOnce( + new Response('data: ok', { + headers: { 'content-type': 'text/event-stream' }, + }), + ) await runTriggerDebug({ debugType: TriggerType.Plugin, @@ -387,9 +454,11 @@ describe('useWorkflowRun utils', () => { const setWorkflowRunningData = vi.fn() const controllerTarget: Record<string, unknown> = {} - mockPost.mockRejectedValueOnce(new Response(JSON.stringify({ error: 'Plugin failed' }), { - headers: { 'content-type': 'application/json' }, - })) + mockPost.mockRejectedValueOnce( + new Response(JSON.stringify({ error: 'Plugin failed' }), { + headers: { 'content-type': 'application/json' }, + }), + ) await runTriggerDebug({ debugType: TriggerType.Plugin, diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts index d635e2bc3f8e4f..230ad3ea63bbc8 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts @@ -262,20 +262,20 @@ describe('useWorkflowRun', () => { ]) mocks.mockGetViewport.mockReturnValue({ x: 1, y: 2, zoom: 1.5 }) mocks.mockDoSyncWorkflowDraft.mockResolvedValue(undefined) - mocks.mockPost.mockResolvedValue(new Response('data: ok', { - headers: { 'content-type': 'text/event-stream' }, - })) + mocks.mockPost.mockResolvedValue( + new Response('data: ok', { + headers: { 'content-type': 'text/event-stream' }, + }), + ) mocks.mockGetAudioPlayer.mockReturnValue({ playAudioWithAudio: vi.fn(), }) mocks.runEventHandlers.handleWorkflowFailed.mockImplementation(() => { const workflowRunningData = mocks.workflowStoreState.workflowRunningData - if (typeof workflowRunningData !== 'object' || workflowRunningData === null) - return + if (typeof workflowRunningData !== 'object' || workflowRunningData === null) return const result = (workflowRunningData as { result?: { status?: string } }).result - if (result) - result.status = WorkflowRunningStatus.Failed + if (result) result.status = WorkflowRunningStatus.Failed }) mocks.workflowStoreState.backupDraft = undefined Object.assign(mocks.workflowStoreState, createWorkflowStoreState()) @@ -328,7 +328,9 @@ describe('useWorkflowRun', () => { edges: [{ id: 'backup-edge' }], viewport: { x: 0, y: 0, zoom: 2 }, }) - expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-backup', value: 'value' }]) + expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([ + { id: 'env-backup', value: 'value' }, + ]) expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({ features: { opening: { enabled: true } }, }) @@ -352,11 +354,13 @@ describe('useWorkflowRun', () => { expect(mocks.workflowStoreState.setListeningTriggerNodeId).toHaveBeenCalledWith(null) expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith([]) expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(false) - expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith(expect.objectContaining({ - result: expect.objectContaining({ - status: WorkflowRunningStatus.Running, + expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith( + expect.objectContaining({ + result: expect.objectContaining({ + status: WorkflowRunningStatus.Running, + }), }), - })) + ) expect(mocks.mockSsePost).toHaveBeenCalledWith( '/apps/app-1/workflows/draft/run', { body: { inputs: { query: 'hello' } } }, @@ -403,33 +407,31 @@ describe('useWorkflowRun', () => { expectedNodeIds: ['trigger-1', 'trigger-2'], expectedIsAll: true, }, - ])('should dispatch $title trigger runs through the debug runner integration', async ({ - params, - options, - expectedUrl, - expectedBody, - expectedNodeIds, - expectedIsAll, - }) => { - const { result } = renderHook(() => useWorkflowRun()) - - await act(async () => { - await result.current.handleRun(params, undefined, options) - }) - - expect(mocks.mockPost).toHaveBeenCalledWith( - expectedUrl, - expect.objectContaining({ - body: expectedBody, - signal: expect.any(AbortSignal), - }), - { needAllResponseContent: true }, - ) - expect(mocks.workflowStoreState.setIsListening).toHaveBeenCalledWith(true) - expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith(expectedNodeIds) - expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(expectedIsAll) - expect(mocks.mockSsePost).not.toHaveBeenCalled() - }) + ])( + 'should dispatch $title trigger runs through the debug runner integration', + async ({ params, options, expectedUrl, expectedBody, expectedNodeIds, expectedIsAll }) => { + const { result } = renderHook(() => useWorkflowRun()) + + await act(async () => { + await result.current.handleRun(params, undefined, options) + }) + + expect(mocks.mockPost).toHaveBeenCalledWith( + expectedUrl, + expect.objectContaining({ + body: expectedBody, + signal: expect.any(AbortSignal), + }), + { needAllResponseContent: true }, + ) + expect(mocks.workflowStoreState.setIsListening).toHaveBeenCalledWith(true) + expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith( + expectedNodeIds, + ) + expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(expectedIsAll) + expect(mocks.mockSsePost).not.toHaveBeenCalled() + }, + ) it('should expose the workflow-failed tracker through the callback factory context', async () => { const { result } = renderHook(() => useWorkflowRun()) @@ -438,7 +440,9 @@ describe('useWorkflowRun', () => { await result.current.handleRun({ inputs: { query: 'hello' } }) }) - const baseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as { + const baseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at( + -1, + )?.[0] as { getWorkflowRunningData: () => unknown trackWorkflowRunFailed: (params: unknown, workflowData: unknown) => void } @@ -447,7 +451,10 @@ describe('useWorkflowRun', () => { tracing: [{ node_id: 'node-1', status: 'running' }], } - baseCallbackFactoryContext.trackWorkflowRunFailed({ error: 'failed', node_type: 'llm' }, workflowData) + baseCallbackFactoryContext.trackWorkflowRunFailed( + { error: 'failed', node_type: 'llm' }, + workflowData, + ) expect(mocks.mockTrackEvent).toHaveBeenCalledWith('workflow_run_failed', { workflow_id: 'flow-1', @@ -482,7 +489,9 @@ describe('useWorkflowRun', () => { await result.current.handleRun({ inputs: { query: 'hello' } }) }) - const baseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as { + const baseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at( + -1, + )?.[0] as { trackWorkflowRunFailed: (params: unknown, workflowData: unknown) => void } const workflowData = { @@ -495,12 +504,16 @@ describe('useWorkflowRun', () => { tracing: [{ node_id: 'node-1', status: 'running' }], } - baseCallbackFactoryContext.trackWorkflowRunFailed({ error: 'failed', node_type: 'llm' }, workflowData) + baseCallbackFactoryContext.trackWorkflowRunFailed( + { error: 'failed', node_type: 'llm' }, + workflowData, + ) - const trackingEvent = mocks.mockTrackEvent.mock.calls.at(-1)?.[1] as WorkflowRunFailedTrackingEvent + const trackingEvent = mocks.mockTrackEvent.mock.calls.at( + -1, + )?.[1] as WorkflowRunFailedTrackingEvent const chunks = trackingEvent.data.workflow_data_chunks - if (!chunks) - throw new Error('Expected workflow data chunks to be tracked') + if (!chunks) throw new Error('Expected workflow data chunks to be tracked') expect(mocks.mockTrackEvent).toHaveBeenCalledWith('workflow_run_failed', { workflow_id: 'flow-1', @@ -513,7 +526,7 @@ describe('useWorkflowRun', () => { }, }) expect(chunks.length).toBeGreaterThan(1) - expect(chunks.every(chunk => chunk.length <= 900)).toBe(true) + expect(chunks.every((chunk) => chunk.length <= 900)).toBe(true) expect(chunks.join('')).toBe(JSON.stringify(workflowData)) expect(trackingEvent.data).not.toHaveProperty('workflow_data') }) @@ -525,7 +538,9 @@ describe('useWorkflowRun', () => { await result.current.handleRun({ inputs: { query: 'hello' } }) }) - const baseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as { + const baseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at( + -1, + )?.[0] as { trackWorkflowRunFailed: (params: unknown, workflowData: unknown) => void } @@ -548,7 +563,10 @@ describe('useWorkflowRun', () => { } circularWorkflowData.self = circularWorkflowData - baseCallbackFactoryContext.trackWorkflowRunFailed({ message: 'missing error' }, circularWorkflowData) + baseCallbackFactoryContext.trackWorkflowRunFailed( + { message: 'missing error' }, + circularWorkflowData, + ) expect(mocks.mockTrackEvent).toHaveBeenCalledWith('workflow_run_failed', { workflow_id: 'flow-1', @@ -568,7 +586,9 @@ describe('useWorkflowRun', () => { await result.current.handleRun({ token: 'public-token' }) }) - const publicBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as { + const publicBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at( + -1, + )?.[0] as { getOrCreatePlayer: () => unknown } @@ -590,9 +610,10 @@ describe('useWorkflowRun', () => { await result.current.handleRun({ appId: 'app-2' }) }) - const privateBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as { - getOrCreatePlayer: () => unknown - } + const privateBaseCallbackFactoryContext = + mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as { + getOrCreatePlayer: () => unknown + } privateBaseCallbackFactoryContext.getOrCreatePlayer() @@ -617,12 +638,16 @@ describe('useWorkflowRun', () => { result.current.handleStopRun('task-1') }) - expect(mocks.mockStopWorkflowRun).toHaveBeenCalledWith('/apps/app-1/workflow-runs/tasks/task-1/stop') - expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith(expect.objectContaining({ - result: expect.objectContaining({ - status: WorkflowRunningStatus.Stopped, + expect(mocks.mockStopWorkflowRun).toHaveBeenCalledWith( + '/apps/app-1/workflow-runs/tasks/task-1/stop', + ) + expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith( + expect.objectContaining({ + result: expect.objectContaining({ + status: WorkflowRunningStatus.Stopped, + }), }), - })) + ) const webhookAbort = vi.fn() const pluginAbort = vi.fn() @@ -658,7 +683,9 @@ describe('useWorkflowRun', () => { act(() => { result.current.handleRestoreFromPublishedWorkflow({ graph: { - nodes: [{ id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }], + nodes: [ + { id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }, + ], edges: [{ id: 'published-edge' }], viewport: { x: 10, y: 20, zoom: 0.8 }, }, @@ -677,7 +704,9 @@ describe('useWorkflowRun', () => { }) expect(mocks.mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({ - nodes: [{ id: 'published-node', selected: false, data: { selected: false, label: 'Published' } }], + nodes: [ + { id: 'published-node', selected: false, data: { selected: false, label: 'Published' } }, + ], edges: [{ id: 'published-edge' }], viewport: { x: 10, y: 20, zoom: 0.8 }, }) @@ -690,7 +719,9 @@ describe('useWorkflowRun', () => { file: { enabled: true }, }), }) - expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-published', value: 'value' }]) + expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([ + { id: 'env-published', value: 'value' }, + ]) }) it('should restore published workflows with empty environment variables as an empty list', () => { @@ -699,7 +730,9 @@ describe('useWorkflowRun', () => { act(() => { result.current.handleRestoreFromPublishedWorkflow({ graph: { - nodes: [{ id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }], + nodes: [ + { id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }, + ], edges: [], viewport: { x: 0, y: 0, zoom: 1 }, }, diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-start-run.spec.tsx b/web/app/components/workflow-app/hooks/__tests__/use-workflow-start-run.spec.tsx index c54dacc69cb303..6943f8b9aa074b 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-start-run.spec.tsx +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-start-run.spec.tsx @@ -1,9 +1,6 @@ import { act, renderHook } from '@testing-library/react' import { TriggerType } from '@/app/components/workflow/header/test-run-menu' -import { - BlockEnum, - WorkflowRunningStatus, -} from '@/app/components/workflow/types' +import { BlockEnum, WorkflowRunningStatus } from '@/app/components/workflow/types' import { useWorkflowStartRunByCanEdit } from '../use-workflow-start-run' const mockGetNodes = vi.fn() @@ -207,9 +204,7 @@ describe('useWorkflowStartRunByCanEdit', () => { }) it('should configure schedule trigger runs and execute the workflow with schedule options', async () => { - mockGetNodes.mockReturnValue([ - { id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } }, - ]) + mockGetNodes.mockReturnValue([{ id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } }]) const { result } = renderHook(() => useWorkflowStartRunByCanEdit(true)) @@ -224,14 +219,10 @@ describe('useWorkflowStartRunByCanEdit', () => { expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith(['schedule-1']) expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false) expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() - expect(mockHandleRun).toHaveBeenCalledWith( - {}, - undefined, - { - mode: TriggerType.Schedule, - scheduleNodeId: 'schedule-1', - }, - ) + expect(mockHandleRun).toHaveBeenCalledWith({}, undefined, { + mode: TriggerType.Schedule, + scheduleNodeId: 'schedule-1', + }) expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true) expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false) }) @@ -240,9 +231,7 @@ describe('useWorkflowStartRunByCanEdit', () => { workflowStoreState = createWorkflowStoreState({ showDebugAndPreviewPanel: true, }) - mockGetNodes.mockReturnValue([ - { id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } }, - ]) + mockGetNodes.mockReturnValue([{ id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } }]) const { result } = renderHook(() => useWorkflowStartRunByCanEdit(true)) @@ -258,15 +247,18 @@ describe('useWorkflowStartRunByCanEdit', () => { it.each([ { title: 'schedule', - invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => hook.handleWorkflowTriggerScheduleRunInWorkflow(undefined), + invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => + hook.handleWorkflowTriggerScheduleRunInWorkflow(undefined), }, { title: 'webhook', - invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: '' }), + invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => + hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: '' }), }, { title: 'plugin', - invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => hook.handleWorkflowTriggerPluginRunInWorkflow(''), + invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => + hook.handleWorkflowTriggerPluginRunInWorkflow(''), }, ])('should ignore $title trigger execution when the node id is empty', async ({ invoke }) => { const { result } = renderHook(() => useWorkflowStartRunByCanEdit(true)) @@ -283,41 +275,48 @@ describe('useWorkflowStartRunByCanEdit', () => { { title: 'schedule', warnMessage: 'handleWorkflowTriggerScheduleRunInWorkflow: schedule node not found', - invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => hook.handleWorkflowTriggerScheduleRunInWorkflow('schedule-missing'), + invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => + hook.handleWorkflowTriggerScheduleRunInWorkflow('schedule-missing'), }, { title: 'webhook', warnMessage: 'handleWorkflowTriggerWebhookRunInWorkflow: webhook node not found', - invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-missing' }), + invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => + hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-missing' }), }, { title: 'plugin', warnMessage: 'handleWorkflowTriggerPluginRunInWorkflow: plugin node not found', - invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-missing'), + invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => + hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-missing'), }, - ])('should warn when the $title trigger node cannot be found', async ({ warnMessage, invoke }) => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - mockGetNodes.mockReturnValue([{ id: 'other-node', data: { type: BlockEnum.Start } }]) + ])( + 'should warn when the $title trigger node cannot be found', + async ({ warnMessage, invoke }) => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockGetNodes.mockReturnValue([{ id: 'other-node', data: { type: BlockEnum.Start } }]) - const { result } = renderHook(() => useWorkflowStartRunByCanEdit(true)) + const { result } = renderHook(() => useWorkflowStartRunByCanEdit(true)) - await act(async () => { - await invoke(result.current) - }) + await act(async () => { + await invoke(result.current) + }) - expect(consoleWarnSpy).toHaveBeenCalledWith(warnMessage, expect.stringContaining('missing')) - expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled() - expect(mockHandleRun).not.toHaveBeenCalled() + expect(consoleWarnSpy).toHaveBeenCalledWith(warnMessage, expect.stringContaining('missing')) + expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockHandleRun).not.toHaveBeenCalled() - consoleWarnSpy.mockRestore() - }) + consoleWarnSpy.mockRestore() + }, + ) it.each([ { title: 'webhook', nodeId: 'webhook-1', nodeType: BlockEnum.TriggerWebhook, - invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-1' }), + invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => + hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-1' }), expectedParams: { node_id: 'webhook-1' }, expectedOptions: { mode: TriggerType.Webhook, webhookNodeId: 'webhook-1' }, }, @@ -325,32 +324,34 @@ describe('useWorkflowStartRunByCanEdit', () => { title: 'plugin', nodeId: 'plugin-1', nodeType: BlockEnum.TriggerPlugin, - invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-1'), + invoke: (hook: ReturnType<typeof useWorkflowStartRunByCanEdit>) => + hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-1'), expectedParams: { node_id: 'plugin-1' }, expectedOptions: { mode: TriggerType.Plugin, pluginNodeId: 'plugin-1' }, }, - ])('should configure $title trigger runs with node-specific options', async ({ nodeId, nodeType, invoke, expectedParams, expectedOptions }) => { - mockGetNodes.mockReturnValue([ - { id: nodeId, data: { type: nodeType } }, - ]) - - const { result } = renderHook(() => useWorkflowStartRunByCanEdit(true)) - - await act(async () => { - await invoke(result.current) - }) - - expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false) - expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false) - expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true) - expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false) - expect(mockSetListeningTriggerType).toHaveBeenCalledWith(nodeType) - expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith(nodeId) - expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith([nodeId]) - expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false) - expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() - expect(mockHandleRun).toHaveBeenCalledWith(expectedParams, undefined, expectedOptions) - }) + ])( + 'should configure $title trigger runs with node-specific options', + async ({ nodeId, nodeType, invoke, expectedParams, expectedOptions }) => { + mockGetNodes.mockReturnValue([{ id: nodeId, data: { type: nodeType } }]) + + const { result } = renderHook(() => useWorkflowStartRunByCanEdit(true)) + + await act(async () => { + await invoke(result.current) + }) + + expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false) + expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false) + expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true) + expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false) + expect(mockSetListeningTriggerType).toHaveBeenCalledWith(nodeType) + expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith(nodeId) + expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith([nodeId]) + expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false) + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + expect(mockHandleRun).toHaveBeenCalledWith(expectedParams, undefined, expectedOptions) + }, + ) it('should run all triggers and mark the listener state as global', async () => { const { result } = renderHook(() => useWorkflowStartRunByCanEdit(true)) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts index ecf285db60aff8..930c8e66971c55 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts @@ -15,15 +15,18 @@ vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: <T>(selector: (state: typeof appStoreState) => T): T => - selector(appStoreState), + useStore: <T>(selector: (state: typeof appStoreState) => T): T => selector(appStoreState), })) vi.mock('@/app/components/workflow/utils', async (importOriginal) => { const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>() return { ...actual, - generateNewNode: (args: { id?: string, data: Record<string, unknown>, position: Record<string, unknown> }) => { + generateNewNode: (args: { + id?: string + data: Record<string, unknown> + position: Record<string, unknown> + }) => { generateNewNodeCalls.push(args) return { newNode: { @@ -87,7 +90,7 @@ describe('useWorkflowTemplate', () => { const { result } = renderHook(() => useWorkflowTemplate()) expect(result.current.nodes).toHaveLength(3) - expect(result.current.nodes.map(node => node.id)).toEqual(['generated-1', 'llm', 'answer']) + expect(result.current.nodes.map((node) => node.id)).toEqual(['generated-1', 'llm', 'answer']) expect(result.current.edges).toEqual([ { id: 'generated-1-llm', diff --git a/web/app/components/workflow-app/hooks/use-DSL.ts b/web/app/components/workflow-app/hooks/use-DSL.ts index d2b1edfa7f3c89..1cd434cabdb72b 100644 --- a/web/app/components/workflow-app/hooks/use-DSL.ts +++ b/web/app/components/workflow-app/hooks/use-DSL.ts @@ -1,23 +1,14 @@ -import type { - useNodesSyncDraft, -} from './use-nodes-sync-draft' +import type { useNodesSyncDraft } from './use-nodes-sync-draft' import { toast } from '@langgenius/dify-ui/toast' -import { - useCallback, - useState, -} from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' -import { - DSL_EXPORT_CHECK, -} from '@/app/components/workflow/constants' +import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants' import { useEventEmitterContextContext } from '@/context/event-emitter' import { exportAppConfig } from '@/service/apps' import { fetchWorkflowDraft } from '@/service/workflow' import { downloadBlob } from '@/utils/download' -import { - useNodesSyncDraftByCanEdit, -} from './use-nodes-sync-draft' +import { useNodesSyncDraftByCanEdit } from './use-nodes-sync-draft' type DoSyncWorkflowDraft = ReturnType<typeof useNodesSyncDraft>['doSyncWorkflowDraft'] @@ -26,40 +17,40 @@ const useDSLBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { const { eventEmitter } = useEventEmitterContextContext() const [exporting, setExporting] = useState(false) - const appDetail = useAppStore(s => s.appDetail) + const appDetail = useAppStore((s) => s.appDetail) - const handleExportDSL = useCallback(async (include = false, workflowId?: string) => { - if (!appDetail) - return + const handleExportDSL = useCallback( + async (include = false, workflowId?: string) => { + if (!appDetail) return - if (exporting) - return + if (exporting) return - try { - setExporting(true) - await doSyncWorkflowDraft() - const { data } = await exportAppConfig({ - appID: appDetail.id, - include, - workflowID: workflowId, - }) - const file = new Blob([data], { type: 'application/yaml' }) - downloadBlob({ data: file, fileName: `${appDetail.name}.yml` }) - } - catch { - toast.error(t($ => $.exportFailed, { ns: 'app' })) - } - finally { - setExporting(false) - } - }, [appDetail, t, doSyncWorkflowDraft, exporting]) + try { + setExporting(true) + await doSyncWorkflowDraft() + const { data } = await exportAppConfig({ + appID: appDetail.id, + include, + workflowID: workflowId, + }) + const file = new Blob([data], { type: 'application/yaml' }) + downloadBlob({ data: file, fileName: `${appDetail.name}.yml` }) + } catch { + toast.error(t(($) => $.exportFailed, { ns: 'app' })) + } finally { + setExporting(false) + } + }, + [appDetail, t, doSyncWorkflowDraft, exporting], + ) const exportCheck = useCallback(async () => { - if (!appDetail) - return + if (!appDetail) return try { const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail?.id}/workflows/draft`) - const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret') + const list = (workflowDraft.environment_variables || []).filter( + (env) => env.value_type === 'secret', + ) if (list.length === 0) { handleExportDSL() return @@ -70,9 +61,8 @@ const useDSLBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { data: list, }, } as any) - } - catch { - toast.error(t($ => $.exportFailed, { ns: 'app' })) + } catch { + toast.error(t(($) => $.exportFailed, { ns: 'app' })) } }, [appDetail, eventEmitter, handleExportDSL, t]) diff --git a/web/app/components/workflow-app/hooks/use-auto-onboarding.ts b/web/app/components/workflow-app/hooks/use-auto-onboarding.ts index 4b43b72894c10d..d7189beab38d50 100644 --- a/web/app/components/workflow-app/hooks/use-auto-onboarding.ts +++ b/web/app/components/workflow-app/hooks/use-auto-onboarding.ts @@ -18,12 +18,10 @@ export const useAutoOnboarding = () => { setShouldAutoOpenStartNodeSelector, } = workflowStore.getState() - if (!isWorkflowDataLoaded) - return + if (!isWorkflowDataLoaded) return // Skip if already showing onboarding or it's the initial workflow creation - if (showOnboarding || notInitialWorkflow) - return + if (showOnboarding || notInitialWorkflow) return const nodes = getNodes() @@ -49,10 +47,8 @@ export const useAutoOnboarding = () => { } = workflowStore.getState() setShowOnboarding?.(false) setHasShownOnboarding?.(true) - if (hasSelectedStartNode) - setHasSelectedStartNode?.(false) - else - setShouldAutoOpenStartNodeSelector?.(false) + if (hasSelectedStartNode) setHasSelectedStartNode?.(false) + else setShouldAutoOpenStartNodeSelector?.(false) }, [workflowStore]) // Check on mount and when nodes change diff --git a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts index 2966d9d5d3cf13..68abe79c966861 100644 --- a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts @@ -18,12 +18,10 @@ import { docPathProductAvailability } from '@/types/doc-paths' import { useIsChatMode } from './use-is-chat-mode' const getNodeHelpLinkPath = (helpLinkUri?: string): DocPathWithoutLang | undefined => { - if (!helpLinkUri) - return undefined + if (!helpLinkUri) return undefined const helpLinkPath = `/use-dify/nodes/${helpLinkUri}` - if (!docPathProductAvailability[helpLinkPath]) - return undefined + if (!docPathProductAvailability[helpLinkPath]) return undefined return helpLinkPath as DocPathWithoutLang } @@ -34,62 +32,79 @@ export const useAvailableNodesMetaData = () => { const docLink = useDocLink() const agentV2Enabled = isAgentV2Enabled() - const startNodeMetaData = useMemo(() => ({ - ...StartDefault, - metaData: { - ...StartDefault.metaData, - isUndeletable: isChatMode, // start node is undeletable in chat mode, @use-nodes-interactions: handleNodeDelete function - }, - }), [isChatMode]) + const startNodeMetaData = useMemo( + () => ({ + ...StartDefault, + metaData: { + ...StartDefault.metaData, + isUndeletable: isChatMode, // start node is undeletable in chat mode, @use-nodes-interactions: handleNodeDelete function + }, + }), + [isChatMode], + ) const mergedNodesMetaData = useMemo(() => { - const commonNodes = WORKFLOW_COMMON_NODES.filter(node => + const commonNodes = WORKFLOW_COMMON_NODES.filter((node) => agentV2Enabled ? node.metaData.type !== BlockEnum.Agent - : node.metaData.type !== BlockEnum.AgentV2) + : node.metaData.type !== BlockEnum.AgentV2, + ) return [ ...commonNodes, startNodeMetaData, - ...( - isChatMode - ? [AnswerDefault] - : [ - StartPlaceholderDefault, - EndDefault, - TriggerWebhookDefault, - TriggerScheduleDefault, - TriggerPluginDefault, - ] - ), + ...(isChatMode + ? [AnswerDefault] + : [ + StartPlaceholderDefault, + EndDefault, + TriggerWebhookDefault, + TriggerScheduleDefault, + TriggerPluginDefault, + ]), ] }, [agentV2Enabled, isChatMode, startNodeMetaData]) - const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => { - const { metaData } = node - const title = t($ => $[`blocks.${metaData.type}`], { ns: 'workflow' }) - const description = t($ => $[`blocksAbout.${metaData.type}` as I18nKeysWithPrefix<'workflow', 'blocksAbout.'>], { ns: 'workflow' }) - const helpLinkPath = getNodeHelpLinkPath(metaData.helpLinkUri) - return { - ...node, - metaData: { - ...metaData, - title, - description, - helpLinkUri: helpLinkPath ? docLink(helpLinkPath) : undefined, - }, - defaultValue: { - ...node.defaultValue, - type: metaData.type === BlockEnum.AgentV2 ? BlockEnum.Agent : metaData.type, - title, - }, - } - }), [mergedNodesMetaData, t, docLink]) + const availableNodesMetaData = useMemo( + () => + mergedNodesMetaData.map((node) => { + const { metaData } = node + const title = t(($) => $[`blocks.${metaData.type}`], { ns: 'workflow' }) + const description = t( + ($) => + $[`blocksAbout.${metaData.type}` as I18nKeysWithPrefix<'workflow', 'blocksAbout.'>], + { ns: 'workflow' }, + ) + const helpLinkPath = getNodeHelpLinkPath(metaData.helpLinkUri) + return { + ...node, + metaData: { + ...metaData, + title, + description, + helpLinkUri: helpLinkPath ? docLink(helpLinkPath) : undefined, + }, + defaultValue: { + ...node.defaultValue, + type: metaData.type === BlockEnum.AgentV2 ? BlockEnum.Agent : metaData.type, + title, + }, + } + }), + [mergedNodesMetaData, t, docLink], + ) - const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => { - acc![node.metaData.type] = node - return acc - }, {} as AvailableNodesMetaData['nodesMap']), [availableNodesMetaData]) + const availableNodesMetaDataMap = useMemo( + () => + availableNodesMetaData.reduce( + (acc, node) => { + acc![node.metaData.type] = node + return acc + }, + {} as AvailableNodesMetaData['nodesMap'], + ), + [availableNodesMetaData], + ) return useMemo(() => { return { diff --git a/web/app/components/workflow-app/hooks/use-configs-map.ts b/web/app/components/workflow-app/hooks/use-configs-map.ts index b16a16fdc9e6ef..cd6c5adfe80c67 100644 --- a/web/app/components/workflow-app/hooks/use-configs-map.ts +++ b/web/app/components/workflow-app/hooks/use-configs-map.ts @@ -4,8 +4,8 @@ import { useStore } from '@/app/components/workflow/store' import { FlowType } from '@/types/common' export const useConfigsMap = () => { - const appId = useStore(s => s.appId) - const fileSettings = useFeatures(s => s.features.file) + const appId = useStore((s) => s.appId) + const fileSettings = useFeatures((s) => s.features.file) return useMemo(() => { return { flowId: appId!, diff --git a/web/app/components/workflow-app/hooks/use-get-run-and-trace-url.ts b/web/app/components/workflow-app/hooks/use-get-run-and-trace-url.ts index 28bcd017f86c77..17e19f6f4193f2 100644 --- a/web/app/components/workflow-app/hooks/use-get-run-and-trace-url.ts +++ b/web/app/components/workflow-app/hooks/use-get-run-and-trace-url.ts @@ -3,14 +3,17 @@ import { useWorkflowStore } from '@/app/components/workflow/store' export const useGetRunAndTraceUrl = () => { const workflowStore = useWorkflowStore() - const getWorkflowRunAndTraceUrl = useCallback((runId: string) => { - const { appId } = workflowStore.getState() + const getWorkflowRunAndTraceUrl = useCallback( + (runId: string) => { + const { appId } = workflowStore.getState() - return { - runUrl: `/apps/${appId}/workflow-runs/${runId}`, - traceUrl: `/apps/${appId}/workflow-runs/${runId}/node-executions`, - } - }, [workflowStore]) + return { + runUrl: `/apps/${appId}/workflow-runs/${runId}`, + traceUrl: `/apps/${appId}/workflow-runs/${runId}/node-executions`, + } + }, + [workflowStore], + ) return { getWorkflowRunAndTraceUrl, diff --git a/web/app/components/workflow-app/hooks/use-is-chat-mode.ts b/web/app/components/workflow-app/hooks/use-is-chat-mode.ts index d286c1a540a5fb..a76dbd69adfea6 100644 --- a/web/app/components/workflow-app/hooks/use-is-chat-mode.ts +++ b/web/app/components/workflow-app/hooks/use-is-chat-mode.ts @@ -2,7 +2,7 @@ import { useStore as useAppStore } from '@/app/components/app/store' import { AppModeEnum } from '@/types/app' export const useIsChatMode = () => { - const appDetail = useAppStore(s => s.appDetail) + const appDetail = useAppStore((s) => s.appDetail) return appDetail?.mode === AppModeEnum.ADVANCED_CHAT } diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 16358cb9eaf0bb..035557702dded5 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -7,8 +7,14 @@ import { useStoreApi } from 'reactflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' -import { useNodesReadOnly, useNodesReadOnlyByCanEdit } from '@/app/components/workflow/hooks/use-workflow' -import { isAgentV2NodeData, needsInlineAgentBindingCreation } from '@/app/components/workflow/nodes/agent-v2/types' +import { + useNodesReadOnly, + useNodesReadOnlyByCanEdit, +} from '@/app/components/workflow/hooks/use-workflow' +import { + isAgentV2NodeData, + needsInlineAgentBindingCreation, +} from '@/app/components/workflow/nodes/agent-v2/types' import { useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { API_PREFIX } from '@/config' @@ -24,37 +30,29 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() const { data: isCollaborationEnabled } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_collaboration_mode, + select: (s) => s.enable_collaboration_mode, }) const getPostParams = useCallback(() => { - const { - getNodes, - edges, - transform, - } = store.getState() + const { getNodes, edges, transform } = store.getState() const allNodes = getNodes() const nodes = allNodes.filter((node) => { - if (node.data?.type === BlockEnum.StartPlaceholder) - return false + if (node.data?.type === BlockEnum.StartPlaceholder) return false - if (!node.data?._isTempNode) - return true + if (!node.data?._isTempNode) return true return isAgentV2NodeData(node.data) && needsInlineAgentBindingCreation(node.data) }) const skippedNodeIds = new Set( allNodes .filter((node) => { - if (node.data?.type === BlockEnum.StartPlaceholder) - return true + if (node.data?.type === BlockEnum.StartPlaceholder) return true - if (!node.data?._isTempNode) - return false + if (!node.data?._isTempNode) return false return !(isAgentV2NodeData(node.data) && needsInlineAgentBindingCreation(node.data)) }) - .map(node => node.id), + .map((node) => node.id), ) const [x, y, zoom] = transform const { @@ -65,29 +63,36 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { isWorkflowDataLoaded, } = workflowStore.getState() - if (!appId || !isWorkflowDataLoaded) - return null + if (!appId || !isWorkflowDataLoaded) return null const features = featuresStore!.getState().features const producedNodes = produce(nodes, (draft) => { draft.forEach((node) => { Object.keys(node.data).forEach((key) => { - if (key.startsWith('_')) - delete node.data[key] + if (key.startsWith('_')) delete node.data[key] }) }) }) - const producedEdges = produce(edges.filter(edge => !edge.data?._isTemp && !skippedNodeIds.has(edge.source) && !skippedNodeIds.has(edge.target)), (draft) => { - draft.forEach((edge) => { - Object.keys(edge.data).forEach((key) => { - if (key.startsWith('_')) - delete edge.data[key] + const producedEdges = produce( + edges.filter( + (edge) => + !edge.data?._isTemp && + !skippedNodeIds.has(edge.source) && + !skippedNodeIds.has(edge.target), + ), + (draft) => { + draft.forEach((edge) => { + Object.keys(edge.data).forEach((key) => { + if (key.startsWith('_')) delete edge.data[key] + }) }) - }) - }) + }, + ) const featuresPayload: WorkflowDraftFeaturesPayload = { - opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '', - suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [], + opening_statement: features.opening?.enabled ? features.opening?.opening_statement || '' : '', + suggested_questions: features.opening?.enabled + ? features.opening?.suggested_questions || [] + : [], suggested_questions_after_answer: features.suggested, text_to_speech: features.text2speech, speech_to_text: features.speech2text, @@ -118,82 +123,82 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { }, [store, featuresStore, workflowStore, isCollaborationEnabled]) const syncWorkflowDraftWhenPageClose = useCallback(() => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return - const isFollower = isCollaborationEnabled - && collaborationManager.isConnected() - && !collaborationManager.getIsLeader() + const isFollower = + isCollaborationEnabled && + collaborationManager.isConnected() && + !collaborationManager.getIsLeader() - if (isFollower) - return + if (isFollower) return const postParams = getPostParams() - if (postParams) - postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params) + if (postParams) postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params) }, [getPostParams, getNodesReadOnly, isCollaborationEnabled]) - const performSync = useCallback(async ( - notRefreshWhenSyncError?: boolean, - callback?: SyncDraftCallback, - ) => { - if (getNodesReadOnly()) - return - - const isFollower = isCollaborationEnabled - && collaborationManager.isConnected() - && !collaborationManager.getIsLeader() - - if (isFollower) { - collaborationManager.emitSyncRequest() - callback?.onSettled?.() - return - } + const performSync = useCallback( + async (notRefreshWhenSyncError?: boolean, callback?: SyncDraftCallback) => { + if (getNodesReadOnly()) return - const baseParams = getPostParams() - if (!baseParams) - return + const isFollower = + isCollaborationEnabled && + collaborationManager.isConnected() && + !collaborationManager.getIsLeader() - const { - setSyncWorkflowDraftHash, - setDraftUpdatedAt, - } = workflowStore.getState() + if (isFollower) { + collaborationManager.emitSyncRequest() + callback?.onSettled?.() + return + } - try { - const latestHash = workflowStore.getState().syncWorkflowDraftHash + const baseParams = getPostParams() + if (!baseParams) return - const postParams = { - ...baseParams, - params: { - ...baseParams.params, - hash: latestHash || null, - }, - } + const { setSyncWorkflowDraftHash, setDraftUpdatedAt } = workflowStore.getState() - const res = await syncWorkflowDraft(postParams) - setSyncWorkflowDraftHash(res.hash) - setDraftUpdatedAt(res.updated_at) - callback?.onSuccess?.() - } - catch (error: unknown) { - const responseError = error as { bodyUsed?: boolean, json?: () => Promise<{ code?: string }> } - if (responseError.json && !responseError.bodyUsed) { - try { - const err = await responseError.json() - if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) - handleRefreshWorkflowDraft(true) + try { + const latestHash = workflowStore.getState().syncWorkflowDraftHash + + const postParams = { + ...baseParams, + params: { + ...baseParams.params, + hash: latestHash || null, + }, } - catch { - // Non-JSON upstream errors should not surface as unhandled promise rejections. + + const res = await syncWorkflowDraft(postParams) + setSyncWorkflowDraftHash(res.hash) + setDraftUpdatedAt(res.updated_at) + callback?.onSuccess?.() + } catch (error: unknown) { + const responseError = error as { + bodyUsed?: boolean + json?: () => Promise<{ code?: string }> + } + if (responseError.json && !responseError.bodyUsed) { + try { + const err = await responseError.json() + if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) + handleRefreshWorkflowDraft(true) + } catch { + // Non-JSON upstream errors should not surface as unhandled promise rejections. + } } + callback?.onError?.() + } finally { + callback?.onSettled?.() } - callback?.onError?.() - } - finally { - callback?.onSettled?.() - } - }, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft, isCollaborationEnabled]) + }, + [ + workflowStore, + getPostParams, + getNodesReadOnly, + handleRefreshWorkflowDraft, + isCollaborationEnabled, + ], + ) const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly) diff --git a/web/app/components/workflow-app/hooks/use-workflow-draft-graph-for-canvas.ts b/web/app/components/workflow-app/hooks/use-workflow-draft-graph-for-canvas.ts index 9c60933d1c07ff..5f75d3488753a8 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-draft-graph-for-canvas.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-draft-graph-for-canvas.ts @@ -1,7 +1,4 @@ -import type { - Node, - WorkflowDataUpdater, -} from '@/app/components/workflow/types' +import type { Node, WorkflowDataUpdater } from '@/app/components/workflow/types' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { START_INITIAL_POSITION } from '@/app/components/workflow/constants' @@ -15,60 +12,67 @@ type HydrateWorkflowDraftGraphOptions = { } const hasWorkflowEntryNode = (nodes: Node[] = []): boolean => { - return nodes.some(node => ( - node?.data?.type === BlockEnum.Start - || node?.data?.type === BlockEnum.TriggerSchedule - || node?.data?.type === BlockEnum.TriggerWebhook - || node?.data?.type === BlockEnum.TriggerPlugin - )) + return nodes.some( + (node) => + node?.data?.type === BlockEnum.Start || + node?.data?.type === BlockEnum.TriggerSchedule || + node?.data?.type === BlockEnum.TriggerWebhook || + node?.data?.type === BlockEnum.TriggerPlugin, + ) } const hasStartPlaceholderNode = (nodes: Node[] = []): boolean => { - return nodes.some(node => node?.data?.type === BlockEnum.StartPlaceholder) + return nodes.some((node) => node?.data?.type === BlockEnum.StartPlaceholder) } export const useWorkflowDraftGraphForCanvas = (appMode?: AppModeEnum | string) => { const { t } = useTranslation() - const getNodesWithLocalStartPlaceholder = useCallback(( - nodes: Node[] = [], - localStartPlaceholderNodes?: Node[], - ) => { - if (appMode !== AppModeEnum.WORKFLOW || hasWorkflowEntryNode(nodes) || hasStartPlaceholderNode(nodes)) - return nodes + const getNodesWithLocalStartPlaceholder = useCallback( + (nodes: Node[] = [], localStartPlaceholderNodes?: Node[]) => { + if ( + appMode !== AppModeEnum.WORKFLOW || + hasWorkflowEntryNode(nodes) || + hasStartPlaceholderNode(nodes) + ) + return nodes - if (localStartPlaceholderNodes?.length) - return [...localStartPlaceholderNodes, ...nodes] + if (localStartPlaceholderNodes?.length) return [...localStartPlaceholderNodes, ...nodes] - const { newNode: startPlaceholderNode } = generateNewNode({ - data: { - ...startPlaceholderDefault.defaultValue, - selected: true, - type: startPlaceholderDefault.metaData.type, - title: t($ => $[`blocks.${startPlaceholderDefault.metaData.type}`], { ns: 'workflow' }), - desc: '', - }, - position: START_INITIAL_POSITION, - }) + const { newNode: startPlaceholderNode } = generateNewNode({ + data: { + ...startPlaceholderDefault.defaultValue, + selected: true, + type: startPlaceholderDefault.metaData.type, + title: t(($) => $[`blocks.${startPlaceholderDefault.metaData.type}`], { ns: 'workflow' }), + desc: '', + }, + position: START_INITIAL_POSITION, + }) - return [startPlaceholderNode, ...nodes] - }, [appMode, t]) + return [startPlaceholderNode, ...nodes] + }, + [appMode, t], + ) - const getWorkflowDraftGraphForCanvas = useCallback(( - graph?: Partial<WorkflowDataUpdater>, - options?: HydrateWorkflowDraftGraphOptions, - ): WorkflowDataUpdater => { - const nodes = getNodesWithLocalStartPlaceholder( - graph?.nodes || [], - options?.localStartPlaceholderNodes, - ) + const getWorkflowDraftGraphForCanvas = useCallback( + ( + graph?: Partial<WorkflowDataUpdater>, + options?: HydrateWorkflowDraftGraphOptions, + ): WorkflowDataUpdater => { + const nodes = getNodesWithLocalStartPlaceholder( + graph?.nodes || [], + options?.localStartPlaceholderNodes, + ) - return { - nodes, - edges: graph?.edges || [], - viewport: graph?.viewport || { x: 0, y: 0, zoom: 1 }, - } - }, [getNodesWithLocalStartPlaceholder]) + return { + nodes, + edges: graph?.edges || [], + viewport: graph?.viewport || { x: 0, y: 0, zoom: 1 }, + } + }, + [getNodesWithLocalStartPlaceholder], + ) return { getWorkflowDraftGraphForCanvas, diff --git a/web/app/components/workflow-app/hooks/use-workflow-init.ts b/web/app/components/workflow-app/hooks/use-workflow-init.ts index 5c026d26a25395..54d95fdc40e353 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-init.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-init.ts @@ -2,17 +2,9 @@ import type { Edge, Node } from '@/app/components/workflow/types' import type { FileUploadConfigResponse } from '@/models/common' import type { FetchWorkflowDraftResponse } from '@/types/workflow' import { useAtomValue } from 'jotai' -import { - useCallback, - useEffect, - useMemo, - useState, -} from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useStore as useAppStore } from '@/app/components/app/store' -import { - useStore, - useWorkflowStore, -} from '@/app/components/workflow/store' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { userProfileIdAtom } from '@/context/account-state' import { workspacePermissionKeysAtom } from '@/context/permission-state' @@ -57,45 +49,46 @@ const createLocalWorkflowDraft = ( const hasConnectedUserInput = (nodes: Node[] = [], edges: Edge[] = []): boolean => { const startNodeIds = nodes - .filter(node => node?.data?.type === BlockEnum.Start) - .map(node => node.id) + .filter((node) => node?.data?.type === BlockEnum.Start) + .map((node) => node.id) - if (!startNodeIds.length) - return false + if (!startNodeIds.length) return false - return edges.some(edge => startNodeIds.includes(edge.source)) + return edges.some((edge) => startNodeIds.includes(edge.source)) } export const useWorkflowInit = () => { const workflowStore = useWorkflowStore() - const { - nodes: nodesTemplate, - edges: edgesTemplate, - } = useWorkflowTemplate() - const appDetail = useAppStore(state => state.appDetail)! + const { nodes: nodesTemplate, edges: edgesTemplate } = useWorkflowTemplate() + const appDetail = useAppStore((state) => state.appDetail)! const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const appACLCapabilities = useMemo(() => getAppACLCapabilities(appDetail.permission_keys, { - currentUserId, - resourceMaintainer: appDetail.maintainer, - workspacePermissionKeys, - }), [appDetail.maintainer, appDetail.permission_keys, currentUserId, workspacePermissionKeys]) + const appACLCapabilities = useMemo( + () => + getAppACLCapabilities(appDetail.permission_keys, { + currentUserId, + resourceMaintainer: appDetail.maintainer, + workspacePermissionKeys, + }), + [appDetail.maintainer, appDetail.permission_keys, currentUserId, workspacePermissionKeys], + ) const { getWorkflowDraftGraphForCanvas } = useWorkflowDraftGraphForCanvas(appDetail.mode) - const setSyncWorkflowDraftHash = useStore(s => s.setSyncWorkflowDraftHash) + const setSyncWorkflowDraftHash = useStore((s) => s.setSyncWorkflowDraftHash) const [data, setData] = useState<FetchWorkflowDraftResponse>() const [isLoading, setIsLoading] = useState(true) useEffect(() => { workflowStore.setState({ appId: appDetail.id, appName: appDetail.name }) }, [appDetail.id, workflowStore]) - const handleUpdateWorkflowFileUploadConfig = useCallback((config: FileUploadConfigResponse) => { - const { setFileUploadConfig } = workflowStore.getState() - setFileUploadConfig(config) - }, [workflowStore]) - const { - data: fileUploadConfigResponse, - isLoading: isFileUploadConfigLoading, - } = useWorkflowConfig('/files/upload', handleUpdateWorkflowFileUploadConfig) + const handleUpdateWorkflowFileUploadConfig = useCallback( + (config: FileUploadConfigResponse) => { + const { setFileUploadConfig } = workflowStore.getState() + setFileUploadConfig(config) + }, + [workflowStore], + ) + const { data: fileUploadConfigResponse, isLoading: isFileUploadConfigLoading } = + useWorkflowConfig('/files/upload', handleUpdateWorkflowFileUploadConfig) const handleGetInitialWorkflowData = useCallback(async () => { try { @@ -109,19 +102,26 @@ export const useWorkflowInit = () => { setData(initialData) workflowStore.setState({ - envSecrets: (initialData.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { - acc[env.id] = env.value - return acc - }, {} as Record<string, string>), - environmentVariables: initialData.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || [], + envSecrets: (initialData.environment_variables || []) + .filter((env) => env.value_type === 'secret') + .reduce( + (acc, env) => { + acc[env.id] = env.value + return acc + }, + {} as Record<string, string>, + ), + environmentVariables: + initialData.environment_variables?.map((env) => + env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env, + ) || [], conversationVariables: initialData.conversation_variables || [], isWorkflowDataLoaded: true, }) setSyncWorkflowDraftHash(initialData.hash) setIsLoading(false) - } - catch (error: unknown) { - const responseError = error as { bodyUsed?: boolean, json?: () => Promise<{ code?: string }> } + } catch (error: unknown) { + const responseError = error as { bodyUsed?: boolean; json?: () => Promise<{ code?: string }> } if (responseError.json && !responseError.bodyUsed && appDetail) { responseError.json().then((err) => { if (err.code === 'draft_workflow_not_exist') { @@ -175,7 +175,15 @@ export const useWorkflowInit = () => { }) } } - }, [appACLCapabilities.canEdit, appDetail, getWorkflowDraftGraphForCanvas, nodesTemplate, edgesTemplate, workflowStore, setSyncWorkflowDraftHash]) + }, [ + appACLCapabilities.canEdit, + appDetail, + getWorkflowDraftGraphForCanvas, + nodesTemplate, + edgesTemplate, + workflowStore, + setSyncWorkflowDraftHash, + ]) useEffect(() => { handleGetInitialWorkflowData() @@ -183,22 +191,27 @@ export const useWorkflowInit = () => { const handleFetchPreloadData = useCallback(async () => { try { - const nodesDefaultConfigsData = await fetchNodesDefaultConfigs(`/apps/${appDetail?.id}/workflows/default-workflow-block-configs`) - const publishedWorkflow = await fetchPublishedWorkflow(`/apps/${appDetail?.id}/workflows/publish`) + const nodesDefaultConfigsData = await fetchNodesDefaultConfigs( + `/apps/${appDetail?.id}/workflows/default-workflow-block-configs`, + ) + const publishedWorkflow = await fetchPublishedWorkflow( + `/apps/${appDetail?.id}/workflows/publish`, + ) workflowStore.setState({ - nodesDefaultConfigs: nodesDefaultConfigsData.reduce((acc, block) => { - if (!acc[block.type]) - acc[block.type] = { ...block.config } - return acc - }, {} as Record<string, unknown>), + nodesDefaultConfigs: nodesDefaultConfigsData.reduce( + (acc, block) => { + if (!acc[block.type]) acc[block.type] = { ...block.config } + return acc + }, + {} as Record<string, unknown>, + ), }) workflowStore.getState().setPublishedAt(publishedWorkflow?.created_at ?? 0) const graph = publishedWorkflow?.graph - workflowStore.getState().setLastPublishedHasUserInput( - hasConnectedUserInput(graph?.nodes, graph?.edges), - ) - } - catch (e) { + workflowStore + .getState() + .setLastPublishedHasUserInput(hasConnectedUserInput(graph?.nodes, graph?.edges)) + } catch (e) { console.error(e) workflowStore.getState().setLastPublishedHasUserInput(false) } diff --git a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts index dec94f33bb9741..36abd34eb136a3 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts @@ -6,53 +6,68 @@ import { fetchWorkflowDraft } from '@/service/workflow' import { useWorkflowDraftGraphForCanvas } from './use-workflow-draft-graph-for-canvas' export const useWorkflowRefreshDraft = () => { - const appDetail = useAppStore(s => s.appDetail) + const appDetail = useAppStore((s) => s.appDetail) const workflowStore = useWorkflowStore() const { handleUpdateWorkflowCanvas } = useWorkflowUpdate() const { getWorkflowDraftGraphForCanvas } = useWorkflowDraftGraphForCanvas(appDetail?.mode) - const handleRefreshWorkflowDraft = useCallback((notUpdateCanvas?: boolean) => { - const { - appId, - setSyncWorkflowDraftHash, - setIsSyncingWorkflowDraft, - setEnvironmentVariables, - setEnvSecrets, - setConversationVariables, - setIsWorkflowDataLoaded, - isWorkflowDataLoaded, - debouncedSyncWorkflowDraft, - } = workflowStore.getState() + const handleRefreshWorkflowDraft = useCallback( + (notUpdateCanvas?: boolean) => { + const { + appId, + setSyncWorkflowDraftHash, + setIsSyncingWorkflowDraft, + setEnvironmentVariables, + setEnvSecrets, + setConversationVariables, + setIsWorkflowDataLoaded, + isWorkflowDataLoaded, + debouncedSyncWorkflowDraft, + } = workflowStore.getState() - if (debouncedSyncWorkflowDraft && typeof (debouncedSyncWorkflowDraft as any).cancel === 'function') - (debouncedSyncWorkflowDraft as any).cancel() + if ( + debouncedSyncWorkflowDraft && + typeof (debouncedSyncWorkflowDraft as any).cancel === 'function' + ) + (debouncedSyncWorkflowDraft as any).cancel() - const wasLoaded = isWorkflowDataLoaded - if (wasLoaded) - setIsWorkflowDataLoaded(false) - setIsSyncingWorkflowDraft(true) - fetchWorkflowDraft(`/apps/${appId}/workflows/draft`) - .then((response) => { - // Ensure we have a valid workflow structure with viewport - if (!notUpdateCanvas) - handleUpdateWorkflowCanvas(getWorkflowDraftGraphForCanvas(response.graph)) - setSyncWorkflowDraftHash(response.hash) - setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { - acc[env.id] = env.value - return acc - }, {} as Record<string, string>)) - setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || []) - setConversationVariables(response.conversation_variables || []) - setIsWorkflowDataLoaded(true) - }) - .catch(() => { - if (wasLoaded) + const wasLoaded = isWorkflowDataLoaded + if (wasLoaded) setIsWorkflowDataLoaded(false) + setIsSyncingWorkflowDraft(true) + fetchWorkflowDraft(`/apps/${appId}/workflows/draft`) + .then((response) => { + // Ensure we have a valid workflow structure with viewport + if (!notUpdateCanvas) + handleUpdateWorkflowCanvas(getWorkflowDraftGraphForCanvas(response.graph)) + setSyncWorkflowDraftHash(response.hash) + setEnvSecrets( + (response.environment_variables || []) + .filter((env) => env.value_type === 'secret') + .reduce( + (acc, env) => { + acc[env.id] = env.value + return acc + }, + {} as Record<string, string>, + ), + ) + setEnvironmentVariables( + response.environment_variables?.map((env) => + env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env, + ) || [], + ) + setConversationVariables(response.conversation_variables || []) setIsWorkflowDataLoaded(true) - }) - .finally(() => { - setIsSyncingWorkflowDraft(false) - }) - }, [getWorkflowDraftGraphForCanvas, handleUpdateWorkflowCanvas, workflowStore]) + }) + .catch(() => { + if (wasLoaded) setIsWorkflowDataLoaded(true) + }) + .finally(() => { + setIsSyncingWorkflowDraft(false) + }) + }, + [getWorkflowDraftGraphForCanvas, handleUpdateWorkflowCanvas, workflowStore], + ) return { handleRefreshWorkflowDraft, diff --git a/web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts b/web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts index 3b1c30b385ce65..30411c04829cca 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts @@ -12,15 +12,24 @@ type WorkflowRunEventHandlers = { handleWorkflowStarted: NonNullable<IOtherOptions['onWorkflowStarted']> handleWorkflowFinished: NonNullable<IOtherOptions['onWorkflowFinished']> handleWorkflowFailed: () => void - handleWorkflowNodeStarted: (params: Parameters<NonNullable<IOtherOptions['onNodeStarted']>>[0], containerParams: ContainerSize) => void + handleWorkflowNodeStarted: ( + params: Parameters<NonNullable<IOtherOptions['onNodeStarted']>>[0], + containerParams: ContainerSize, + ) => void handleWorkflowNodeFinished: NonNullable<IOtherOptions['onNodeFinished']> handleWorkflowNodeHumanInputRequired: NonNullable<IOtherOptions['onHumanInputRequired']> handleWorkflowNodeHumanInputFormFilled: NonNullable<IOtherOptions['onHumanInputFormFilled']> handleWorkflowNodeHumanInputFormTimeout: NonNullable<IOtherOptions['onHumanInputFormTimeout']> - handleWorkflowNodeIterationStarted: (params: Parameters<NonNullable<IOtherOptions['onIterationStart']>>[0], containerParams: ContainerSize) => void + handleWorkflowNodeIterationStarted: ( + params: Parameters<NonNullable<IOtherOptions['onIterationStart']>>[0], + containerParams: ContainerSize, + ) => void handleWorkflowNodeIterationNext: NonNullable<IOtherOptions['onIterationNext']> handleWorkflowNodeIterationFinished: NonNullable<IOtherOptions['onIterationFinish']> - handleWorkflowNodeLoopStarted: (params: Parameters<NonNullable<IOtherOptions['onLoopStart']>>[0], containerParams: ContainerSize) => void + handleWorkflowNodeLoopStarted: ( + params: Parameters<NonNullable<IOtherOptions['onLoopStart']>>[0], + containerParams: ContainerSize, + ) => void handleWorkflowNodeLoopNext: NonNullable<IOtherOptions['onLoopNext']> handleWorkflowNodeLoopFinished: NonNullable<IOtherOptions['onLoopFinish']> handleWorkflowNodeRetry: NonNullable<IOtherOptions['onNodeRetry']> @@ -146,8 +155,7 @@ export const createBaseWorkflowRunCallbacks = ({ invalidateRunHistory(runHistoryUrl) clearListeningState() - if (onError) - onError(params, code) + if (onError) onError(params, code) trackWorkflowRunFailed(params, workflowData) } @@ -155,8 +163,7 @@ export const createBaseWorkflowRunCallbacks = ({ const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError, errorMessage) => { clearAbortController() clearListeningState() - if (onCompleted) - onCompleted(hasError, errorMessage) + if (onCompleted) onCompleted(hasError, errorMessage) } const baseSseOptions: IOtherOptions = { @@ -165,16 +172,14 @@ export const createBaseWorkflowRunCallbacks = ({ handleWorkflowStarted(params) invalidateRunHistory(runHistoryUrl) - if (onWorkflowStarted) - onWorkflowStarted(params) + if (onWorkflowStarted) onWorkflowStarted(params) }, onWorkflowFinished: (params) => { clearListeningState() handleWorkflowFinished(params) invalidateRunHistory(runHistoryUrl) - if (onWorkflowFinished) - onWorkflowFinished(params) + if (onWorkflowFinished) onWorkflowFinished(params) if (isInWorkflowDebug) { fetchInspectVars({}) invalidAllLastRun() @@ -183,62 +188,52 @@ export const createBaseWorkflowRunCallbacks = ({ onNodeStarted: (params) => { handleWorkflowNodeStarted(params, { clientWidth, clientHeight }) - if (onNodeStarted) - onNodeStarted(params) + if (onNodeStarted) onNodeStarted(params) }, onNodeFinished: (params) => { handleWorkflowNodeFinished(params) - if (onNodeFinished) - onNodeFinished(params) + if (onNodeFinished) onNodeFinished(params) }, onIterationStart: (params) => { handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight }) - if (onIterationStart) - onIterationStart(params) + if (onIterationStart) onIterationStart(params) }, onIterationNext: (params) => { handleWorkflowNodeIterationNext(params) - if (onIterationNext) - onIterationNext(params) + if (onIterationNext) onIterationNext(params) }, onIterationFinish: (params) => { handleWorkflowNodeIterationFinished(params) - if (onIterationFinish) - onIterationFinish(params) + if (onIterationFinish) onIterationFinish(params) }, onLoopStart: (params) => { handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight }) - if (onLoopStart) - onLoopStart(params) + if (onLoopStart) onLoopStart(params) }, onLoopNext: (params) => { handleWorkflowNodeLoopNext(params) - if (onLoopNext) - onLoopNext(params) + if (onLoopNext) onLoopNext(params) }, onLoopFinish: (params) => { handleWorkflowNodeLoopFinished(params) - if (onLoopFinish) - onLoopFinish(params) + if (onLoopFinish) onLoopFinish(params) }, onNodeRetry: (params) => { handleWorkflowNodeRetry(params) - if (onNodeRetry) - onNodeRetry(params) + if (onNodeRetry) onNodeRetry(params) }, onAgentLog: (params) => { handleWorkflowAgentLog(params) - if (onAgentLog) - onAgentLog(params) + if (onAgentLog) onAgentLog(params) }, onTextChunk: (params) => { handleWorkflowTextChunk(params) @@ -250,8 +245,7 @@ export const createBaseWorkflowRunCallbacks = ({ handleWorkflowReasoning(params) }, onTTSChunk: (messageId: string, audio: string) => { - if (!audio || audio === '') - return + if (!audio || audio === '') return const audioPlayer = getOrCreatePlayer() if (audioPlayer) { audioPlayer.playAudioWithAudio(audio, true) @@ -260,31 +254,26 @@ export const createBaseWorkflowRunCallbacks = ({ }, onTTSEnd: (_messageId: string, audio: string) => { const audioPlayer = getOrCreatePlayer() - if (audioPlayer) - audioPlayer.playAudioWithAudio(audio, false) + if (audioPlayer) audioPlayer.playAudioWithAudio(audio, false) }, onWorkflowPaused: (params) => { handleWorkflowPaused() invalidateRunHistory(runHistoryUrl) - if (onWorkflowPaused) - onWorkflowPaused(params) + if (onWorkflowPaused) onWorkflowPaused(params) const url = `/workflow/${params.workflow_run_id}/events` sseGet(url, {}, baseSseOptions) }, onHumanInputRequired: (params) => { handleWorkflowNodeHumanInputRequired(params) - if (onHumanInputRequired) - onHumanInputRequired(params) + if (onHumanInputRequired) onHumanInputRequired(params) }, onHumanInputFormFilled: (params) => { handleWorkflowNodeHumanInputFormFilled(params) - if (onHumanInputFormFilled) - onHumanInputFormFilled(params) + if (onHumanInputFormFilled) onHumanInputFormFilled(params) }, onHumanInputFormTimeout: (params) => { handleWorkflowNodeHumanInputFormTimeout(params) - if (onHumanInputFormTimeout) - onHumanInputFormTimeout(params) + if (onHumanInputFormTimeout) onHumanInputFormTimeout(params) }, onError: wrappedOnError, onCompleted: wrappedOnCompleted, @@ -361,8 +350,7 @@ export const createFinalWorkflowRunCallbacks = ({ handleWorkflowFinished(params) invalidateRunHistory(runHistoryUrl) - if (onWorkflowFinished) - onWorkflowFinished(params) + if (onWorkflowFinished) onWorkflowFinished(params) if (isInWorkflowDebug) { fetchInspectVars({}) invalidAllLastRun() @@ -375,69 +363,58 @@ export const createFinalWorkflowRunCallbacks = ({ invalidateRunHistory(runHistoryUrl) clearListeningState() - if (onError) - onError(params, code) + if (onError) onError(params, code) trackWorkflowRunFailed(params, workflowData) }, onNodeStarted: (params) => { handleWorkflowNodeStarted(params, { clientWidth, clientHeight }) - if (onNodeStarted) - onNodeStarted(params) + if (onNodeStarted) onNodeStarted(params) }, onNodeFinished: (params) => { handleWorkflowNodeFinished(params) - if (onNodeFinished) - onNodeFinished(params) + if (onNodeFinished) onNodeFinished(params) }, onIterationStart: (params) => { handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight }) - if (onIterationStart) - onIterationStart(params) + if (onIterationStart) onIterationStart(params) }, onIterationNext: (params) => { handleWorkflowNodeIterationNext(params) - if (onIterationNext) - onIterationNext(params) + if (onIterationNext) onIterationNext(params) }, onIterationFinish: (params) => { handleWorkflowNodeIterationFinished(params) - if (onIterationFinish) - onIterationFinish(params) + if (onIterationFinish) onIterationFinish(params) }, onLoopStart: (params) => { handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight }) - if (onLoopStart) - onLoopStart(params) + if (onLoopStart) onLoopStart(params) }, onLoopNext: (params) => { handleWorkflowNodeLoopNext(params) - if (onLoopNext) - onLoopNext(params) + if (onLoopNext) onLoopNext(params) }, onLoopFinish: (params) => { handleWorkflowNodeLoopFinished(params) - if (onLoopFinish) - onLoopFinish(params) + if (onLoopFinish) onLoopFinish(params) }, onNodeRetry: (params) => { handleWorkflowNodeRetry(params) - if (onNodeRetry) - onNodeRetry(params) + if (onNodeRetry) onNodeRetry(params) }, onAgentLog: (params) => { handleWorkflowAgentLog(params) - if (onAgentLog) - onAgentLog(params) + if (onAgentLog) onAgentLog(params) }, onTextChunk: (params) => { handleWorkflowTextChunk(params) @@ -449,8 +426,7 @@ export const createFinalWorkflowRunCallbacks = ({ handleWorkflowReasoning(params) }, onTTSChunk: (messageId: string, audio: string) => { - if (!audio || audio === '') - return + if (!audio || audio === '') return player?.playAudioWithAudio(audio, true) AudioPlayerManager.getInstance().resetMsgId(messageId) }, @@ -460,25 +436,21 @@ export const createFinalWorkflowRunCallbacks = ({ onWorkflowPaused: (params) => { handleWorkflowPaused() invalidateRunHistory(runHistoryUrl) - if (onWorkflowPaused) - onWorkflowPaused(params) + if (onWorkflowPaused) onWorkflowPaused(params) const url = `/workflow/${params.workflow_run_id}/events` sseGet(url, {}, finalCallbacks) }, onHumanInputRequired: (params) => { handleWorkflowNodeHumanInputRequired(params) - if (onHumanInputRequired) - onHumanInputRequired(params) + if (onHumanInputRequired) onHumanInputRequired(params) }, onHumanInputFormFilled: (params) => { handleWorkflowNodeHumanInputFormFilled(params) - if (onHumanInputFormFilled) - onHumanInputFormFilled(params) + if (onHumanInputFormFilled) onHumanInputFormFilled(params) }, onHumanInputFormTimeout: (params) => { handleWorkflowNodeHumanInputFormTimeout(params) - if (onHumanInputFormTimeout) - onHumanInputFormTimeout(params) + if (onHumanInputFormTimeout) onHumanInputFormTimeout(params) }, ...restCallback, } diff --git a/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts b/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts index 80a09f1c67c43f..07cceee9525472 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts @@ -33,7 +33,12 @@ type TTSParamsLike = { } type ListeningStateActions = { - setWorkflowRunningData: (data: ReturnType<typeof createRunningWorkflowState> | ReturnType<typeof createFailedWorkflowState> | ReturnType<typeof createStoppedWorkflowState>) => void + setWorkflowRunningData: ( + data: + | ReturnType<typeof createRunningWorkflowState> + | ReturnType<typeof createFailedWorkflowState> + | ReturnType<typeof createStoppedWorkflowState>, + ) => void setIsListening: (value: boolean) => void setShowVariableInspectPanel: (value: boolean) => void setListeningTriggerType: (value: TriggerNodeType | null) => void @@ -120,7 +125,11 @@ export const resolveWorkflowRunUrl = ( runMode: HandleRunMode, isInWorkflowDebug: boolean, ) => { - if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) { + if ( + runMode === TriggerType.Plugin || + runMode === TriggerType.Webhook || + runMode === TriggerType.Schedule + ) { if (!appDetail?.id) { console.error('handleRun: missing app id for trigger plugin run') return '' @@ -141,8 +150,7 @@ export const resolveWorkflowRunUrl = ( if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) return `/apps/${appDetail.id}/advanced-chat/workflows/draft/run` - if (isInWorkflowDebug && appDetail?.id) - return `/apps/${appDetail.id}/workflows/draft/run` + if (isInWorkflowDebug && appDetail?.id) return `/apps/${appDetail.id}/workflows/draft/run` return '' } @@ -152,25 +160,18 @@ export const buildWorkflowRunRequestBody = ( resolvedParams: Record<string, unknown>, options?: HandleRunOptions, ) => { - if (runMode === TriggerType.Schedule) - return { node_id: options?.scheduleNodeId } + if (runMode === TriggerType.Schedule) return { node_id: options?.scheduleNodeId } - if (runMode === TriggerType.Webhook) - return { node_id: options?.webhookNodeId } + if (runMode === TriggerType.Webhook) return { node_id: options?.webhookNodeId } - if (runMode === TriggerType.Plugin) - return { node_id: options?.pluginNodeId } + if (runMode === TriggerType.Plugin) return { node_id: options?.pluginNodeId } - if (runMode === TriggerType.All) - return { node_ids: options?.allNodeIds } + if (runMode === TriggerType.All) return { node_ids: options?.allNodeIds } return resolvedParams } -export const validateWorkflowRunRequest = ( - runMode: HandleRunMode, - options?: HandleRunOptions, -) => { +export const validateWorkflowRunRequest = (runMode: HandleRunMode, options?: HandleRunOptions) => { if (runMode === TriggerType.Schedule && !options?.scheduleNodeId) return 'handleRun: schedule trigger run requires node id' @@ -190,10 +191,10 @@ export const isDebuggableTriggerType = ( runMode: HandleRunMode, ): runMode is DebuggableTriggerType => { return ( - runMode === TriggerType.Schedule - || runMode === TriggerType.Webhook - || runMode === TriggerType.Plugin - || runMode === TriggerType.All + runMode === TriggerType.Schedule || + runMode === TriggerType.Webhook || + runMode === TriggerType.Plugin || + runMode === TriggerType.All ) } @@ -201,17 +202,13 @@ export const buildListeningTriggerNodeIds = ( runMode: DebuggableTriggerType, options?: HandleRunOptions, ) => { - if (runMode === TriggerType.All) - return options?.allNodeIds ?? [] + if (runMode === TriggerType.All) return options?.allNodeIds ?? [] - if (runMode === TriggerType.Webhook && options?.webhookNodeId) - return [options.webhookNodeId] + if (runMode === TriggerType.Webhook && options?.webhookNodeId) return [options.webhookNodeId] - if (runMode === TriggerType.Schedule && options?.scheduleNodeId) - return [options.scheduleNodeId] + if (runMode === TriggerType.Schedule && options?.scheduleNodeId) return [options.scheduleNodeId] - if (runMode === TriggerType.Plugin && options?.pluginNodeId) - return [options.pluginNodeId] + if (runMode === TriggerType.Plugin && options?.pluginNodeId) return [options.pluginNodeId] return [] } @@ -238,7 +235,16 @@ export const applyRunningStateForMode = ( actions.setWorkflowRunningData(createRunningWorkflowState()) } -export const clearListeningState = (actions: Pick<ListeningStateActions, 'setIsListening' | 'setListeningTriggerType' | 'setListeningTriggerNodeId' | 'setListeningTriggerNodeIds' | 'setListeningTriggerIsAll'>) => { +export const clearListeningState = ( + actions: Pick< + ListeningStateActions, + | 'setIsListening' + | 'setListeningTriggerType' + | 'setListeningTriggerNodeId' + | 'setListeningTriggerNodeIds' + | 'setListeningTriggerIsAll' + >, +) => { actions.setIsListening(false) actions.setListeningTriggerType(null) actions.setListeningTriggerNodeId(null) @@ -246,7 +252,16 @@ export const clearListeningState = (actions: Pick<ListeningStateActions, 'setIsL actions.setListeningTriggerIsAll(false) } -export const applyStoppedState = (actions: Pick<ListeningStateActions, 'setWorkflowRunningData' | 'setIsListening' | 'setShowVariableInspectPanel' | 'setListeningTriggerType' | 'setListeningTriggerNodeId'>) => { +export const applyStoppedState = ( + actions: Pick< + ListeningStateActions, + | 'setWorkflowRunningData' + | 'setIsListening' + | 'setShowVariableInspectPanel' + | 'setListeningTriggerType' + | 'setListeningTriggerNodeId' + >, +) => { actions.setWorkflowRunningData(createStoppedWorkflowState()) actions.setIsListening(false) actions.setListeningTriggerType(null) @@ -268,12 +283,10 @@ export const buildTTSConfig = (resolvedParams: TTSParamsLike, pathname: string) if (resolvedParams.token) { ttsUrl = '/text-to-audio' ttsIsPublic = true - } - else if (resolvedParams.appId) { + } else if (resolvedParams.appId) { if (isInstalledAppPath(pathname)) ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio` - else - ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio` + else ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio` } return { @@ -285,7 +298,9 @@ export const buildTTSConfig = (resolvedParams: TTSParamsLike, pathname: string) export const mapPublishedWorkflowFeatures = (publishedWorkflow: VersionHistory): FeaturesData => { return { opening: { - enabled: !!publishedWorkflow.features.opening_statement || !!publishedWorkflow.features.suggested_questions.length, + enabled: + !!publishedWorkflow.features.opening_statement || + !!publishedWorkflow.features.suggested_questions.length, opening_statement: publishedWorkflow.features.opening_statement, suggested_questions: publishedWorkflow.features.suggested_questions, }, @@ -299,7 +314,7 @@ export const mapPublishedWorkflowFeatures = (publishedWorkflow: VersionHistory): } export const normalizePublishedWorkflowNodes = (publishedWorkflow: VersionHistory) => { - return publishedWorkflow.graph.nodes.map(node => ({ + return publishedWorkflow.graph.nodes.map((node) => ({ ...node, selected: false, data: { @@ -309,13 +324,18 @@ export const normalizePublishedWorkflowNodes = (publishedWorkflow: VersionHistor })) } -const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise<void>((resolve) => { - const timer = window.setTimeout(resolve, delay) - signal.addEventListener('abort', () => { - clearTimeout(timer) - resolve() - }, { once: true }) -}) +const waitWithAbort = (signal: AbortSignal, delay: number) => + new Promise<void>((resolve) => { + const timer = window.setTimeout(resolve, delay) + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer) + resolve() + }, + { once: true }, + ) + }) export const runTriggerDebug = async ({ debugType, @@ -338,15 +358,18 @@ export const runTriggerDebug = async ({ const poll = async (): Promise<void> => { try { - const response = await post<Response>(url, { - body: requestBody, - signal: controller.signal, - }, { - needAllResponseContent: true, - }) - - if (controller.signal.aborted) - return + const response = await post<Response>( + url, + { + body: requestBody, + signal: controller.signal, + }, + { + needAllResponseContent: true, + }, + ) + + if (controller.signal.aborted) return if (!response) { const message = `${debugLabel} debug request failed` @@ -360,29 +383,30 @@ export const runTriggerDebug = async ({ if (contentType.includes(ContentType.json)) { let data: Record<string, unknown> | null = null try { - data = await response.json() as Record<string, unknown> - } - catch (jsonError) { - console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError) + data = (await response.json()) as Record<string, unknown> + } catch (jsonError) { + console.error( + `handleRun: ${debugLabel.toLowerCase()} debug response parse error`, + jsonError, + ) toast.error(`${debugLabel} debug request failed`) clearAbortController() clearListeningState() return } - if (controller.signal.aborted) - return + if (controller.signal.aborted) return if (data?.status === 'waiting') { const delay = Number(data.retry_in) || 2000 await waitWithAbort(controller.signal, delay) - if (controller.signal.aborted) - return + if (controller.signal.aborted) return await poll() return } - const errorMessage = typeof data?.message === 'string' ? data.message : `${debugLabel} debug failed` + const errorMessage = + typeof data?.message === 'string' ? data.message : `${debugLabel} debug failed` toast.error(errorMessage) clearAbortController() setWorkflowRunningData(createFailedWorkflowState(errorMessage)) @@ -425,13 +449,11 @@ export const runTriggerDebug = async ({ baseSseOptions.onDataSourceNodeCompleted, baseSseOptions.onDataSourceNodeError, ) - } - catch (error) { - if (controller.signal.aborted) - return + } catch (error) { + if (controller.signal.aborted) return if (error instanceof Response) { - const data = await error.clone().json() as Record<string, unknown> + const data = (await error.clone().json()) as Record<string, unknown> const errorMessage = typeof data?.error === 'string' ? data.error : '' toast.error(errorMessage) clearAbortController() diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts index fc8b3060f8881c..ae199e8cd9c569 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts @@ -6,10 +6,7 @@ import type { VersionHistory } from '@/types/workflow' import { noop } from 'es-toolkit/function' import { produce } from 'immer' import { useCallback, useRef } from 'react' -import { - useReactFlow, - useStoreApi, -} from 'reactflow' +import { useReactFlow, useStoreApi } from 'reactflow' import { v4 as uuidV4 } from 'uuid' import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' @@ -26,10 +23,7 @@ import { stopWorkflowRun } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { useSetWorkflowVarsWithValue } from '../../workflow/hooks/use-fetch-workflow-inspect-vars' import { useConfigsMap } from './use-configs-map' -import { - useNodesSyncDraft, - useNodesSyncDraftByCanEdit, -} from './use-nodes-sync-draft' +import { useNodesSyncDraft, useNodesSyncDraftByCanEdit } from './use-nodes-sync-draft' import { createBaseWorkflowRunCallbacks, createFinalWorkflowRunCallbacks, @@ -42,7 +36,6 @@ import { buildWorkflowRunRequestBody, clearListeningState, clearWindowDebugControllers, - isDebuggableTriggerType, mapPublishedWorkflowFeatures, normalizePublishedWorkflowNodes, @@ -70,13 +63,11 @@ type WorkflowDebugWindow = Window & { const WORKFLOW_DATA_CHUNK_SIZE = 900 const stringifyWorkflowData = (workflowData: unknown) => { - if (!workflowData) - return undefined + if (!workflowData) return undefined try { return JSON.stringify(workflowData) - } - catch { + } catch { return undefined } } @@ -93,8 +84,7 @@ const chunkWorkflowData = (serializedWorkflowData: string) => { const buildWorkflowDataTrackingProperties = (workflowData: unknown) => { const serializedWorkflowData = stringifyWorkflowData(workflowData) - if (!serializedWorkflowData) - return {} + if (!serializedWorkflowData) return {} return { workflow_data_chunks: chunkWorkflowData(serializedWorkflowData), @@ -102,20 +92,17 @@ const buildWorkflowDataTrackingProperties = (workflowData: unknown) => { } const getWorkflowStatus = (workflowData: unknown) => { - if (typeof workflowData !== 'object' || workflowData === null) - return undefined + if (typeof workflowData !== 'object' || workflowData === null) return undefined const result = (workflowData as Record<string, unknown>).result - if (typeof result !== 'object' || result === null) - return undefined + if (typeof result !== 'object' || result === null) return undefined const status = (result as Record<string, unknown>).status return typeof status === 'string' ? status : undefined } const getWorkflowTracingCount = (workflowData: unknown) => { - if (typeof workflowData !== 'object' || workflowData === null) - return undefined + if (typeof workflowData !== 'object' || workflowData === null) return undefined const tracing = (workflowData as Record<string, unknown>).tracing return Array.isArray(tracing) ? tracing.length : undefined @@ -165,16 +152,9 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { } = useWorkflowRunEvent() const handleBackupDraft = useCallback(() => { - const { - getNodes, - edges, - } = store.getState() + const { getNodes, edges } = store.getState() const { getViewport } = reactflow - const { - backupDraft, - setBackupDraft, - environmentVariables, - } = workflowStore.getState() + const { backupDraft, setBackupDraft, environmentVariables } = workflowStore.getState() const { features } = featuresStore!.getState() if (!backupDraft) { @@ -190,20 +170,10 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { }, [reactflow, workflowStore, store, featuresStore, doSyncWorkflowDraft]) const handleLoadBackupDraft = useCallback(() => { - const { - backupDraft, - setBackupDraft, - setEnvironmentVariables, - } = workflowStore.getState() + const { backupDraft, setBackupDraft, setEnvironmentVariables } = workflowStore.getState() if (backupDraft) { - const { - nodes, - edges, - viewport, - features, - environmentVariables, - } = backupDraft + const { nodes, edges, viewport, features, environmentVariables } = backupDraft handleUpdateWorkflowCanvas({ nodes, edges, @@ -215,130 +185,278 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { } }, [handleUpdateWorkflowCanvas, workflowStore, featuresStore]) - const handleRun = useCallback(async ( - params: WorkflowRunParams | null | undefined, - callback?: IOtherOptions, - options?: HandleRunOptions, - ) => { - const runMode = options?.mode ?? TriggerType.UserInput - const resolvedParams: WorkflowRunParams = params ?? {} - const { - getNodes, - setNodes, - } = store.getState() - const newNodes = produce(getNodes(), (draft: Node[]) => { - draft.forEach((node) => { - node.data.selected = false - node.data._runningStatus = undefined + const handleRun = useCallback( + async ( + params: WorkflowRunParams | null | undefined, + callback?: IOtherOptions, + options?: HandleRunOptions, + ) => { + const runMode = options?.mode ?? TriggerType.UserInput + const resolvedParams: WorkflowRunParams = params ?? {} + const { getNodes, setNodes } = store.getState() + const newNodes = produce(getNodes(), (draft: Node[]) => { + draft.forEach((node) => { + node.data.selected = false + node.data._runningStatus = undefined + }) }) - }) - setNodes(newNodes) - await doSyncWorkflowDraft() - - const { - onWorkflowStarted, - onWorkflowFinished, - onNodeStarted, - onNodeFinished, - onIterationStart, - onIterationNext, - onIterationFinish, - onLoopStart, - onLoopNext, - onLoopFinish, - onNodeRetry, - onAgentLog, - onError, - onWorkflowPaused, - onHumanInputRequired, - onHumanInputFormFilled, - onHumanInputFormTimeout, - onCompleted, - ...restCallback - } = callback || {} - workflowStore.setState({ historyWorkflowData: undefined }) - const appDetail = useAppStore.getState().appDetail - const runHistoryUrl = buildRunHistoryUrl(appDetail) - const workflowContainer = document.getElementById('workflow-container') - - const { - clientWidth, - clientHeight, - } = workflowContainer! - - const isInWorkflowDebug = appDetail?.mode === AppModeEnum.WORKFLOW - - const url = resolveWorkflowRunUrl(appDetail, runMode, isInWorkflowDebug) - const requestBody = buildWorkflowRunRequestBody(runMode, resolvedParams, options) - - if (!url) - return - - const validationMessage = validateWorkflowRunRequest(runMode, options) - if (validationMessage) { - console.error(validationMessage) - return - } - - abortControllerRef.current?.abort() - abortControllerRef.current = null - - const { - setWorkflowRunningData, - setIsListening, - setShowVariableInspectPanel, - setListeningTriggerType, - setListeningTriggerNodeIds, - setListeningTriggerIsAll, - setListeningTriggerNodeId, - } = workflowStore.getState() - - applyRunningStateForMode({ - setWorkflowRunningData, - setIsListening, - setShowVariableInspectPanel, - setListeningTriggerType, - setListeningTriggerNodeIds, - setListeningTriggerIsAll, - setListeningTriggerNodeId, - }, runMode, options) - - const { ttsUrl, ttsIsPublic } = buildTTSConfig(resolvedParams, pathname) - // Lazy initialization: Only create AudioPlayer when TTS is actually needed - // This prevents opening audio channel unnecessarily - let player: AudioPlayer | null = null - const getOrCreatePlayer = () => { - if (!player) - player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', noop) - - return player - } + setNodes(newNodes) + await doSyncWorkflowDraft() - const clearAbortController = () => { + const { + onWorkflowStarted, + onWorkflowFinished, + onNodeStarted, + onNodeFinished, + onIterationStart, + onIterationNext, + onIterationFinish, + onLoopStart, + onLoopNext, + onLoopFinish, + onNodeRetry, + onAgentLog, + onError, + onWorkflowPaused, + onHumanInputRequired, + onHumanInputFormFilled, + onHumanInputFormTimeout, + onCompleted, + ...restCallback + } = callback || {} + workflowStore.setState({ historyWorkflowData: undefined }) + const appDetail = useAppStore.getState().appDetail + const runHistoryUrl = buildRunHistoryUrl(appDetail) + const workflowContainer = document.getElementById('workflow-container') + + const { clientWidth, clientHeight } = workflowContainer! + + const isInWorkflowDebug = appDetail?.mode === AppModeEnum.WORKFLOW + + const url = resolveWorkflowRunUrl(appDetail, runMode, isInWorkflowDebug) + const requestBody = buildWorkflowRunRequestBody(runMode, resolvedParams, options) + + if (!url) return + + const validationMessage = validateWorkflowRunRequest(runMode, options) + if (validationMessage) { + console.error(validationMessage) + return + } + + abortControllerRef.current?.abort() abortControllerRef.current = null - clearWindowDebugControllers(window as unknown as Record<string, unknown>) - } - const clearListeningStateInStore = () => { - const state = workflowStore.getState() - clearListeningState({ - setIsListening: state.setIsListening, - setListeningTriggerType: state.setListeningTriggerType, - setListeningTriggerNodeId: state.setListeningTriggerNodeId, - setListeningTriggerNodeIds: state.setListeningTriggerNodeIds, - setListeningTriggerIsAll: state.setListeningTriggerIsAll, + const { + setWorkflowRunningData, + setIsListening, + setShowVariableInspectPanel, + setListeningTriggerType, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + setListeningTriggerNodeId, + } = workflowStore.getState() + + applyRunningStateForMode( + { + setWorkflowRunningData, + setIsListening, + setShowVariableInspectPanel, + setListeningTriggerType, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + setListeningTriggerNodeId, + }, + runMode, + options, + ) + + const { ttsUrl, ttsIsPublic } = buildTTSConfig(resolvedParams, pathname) + // Lazy initialization: Only create AudioPlayer when TTS is actually needed + // This prevents opening audio channel unnecessarily + let player: AudioPlayer | null = null + const getOrCreatePlayer = () => { + if (!player) + player = AudioPlayerManager.getInstance().getAudioPlayer( + ttsUrl, + ttsIsPublic, + uuidV4(), + 'none', + 'none', + noop, + ) + + return player + } + + const clearAbortController = () => { + abortControllerRef.current = null + clearWindowDebugControllers(window as unknown as Record<string, unknown>) + } + + const clearListeningStateInStore = () => { + const state = workflowStore.getState() + clearListeningState({ + setIsListening: state.setIsListening, + setListeningTriggerType: state.setListeningTriggerType, + setListeningTriggerNodeId: state.setListeningTriggerNodeId, + setListeningTriggerNodeIds: state.setListeningTriggerNodeIds, + setListeningTriggerIsAll: state.setListeningTriggerIsAll, + }) + } + + const workflowRunEventHandlers = { + handleWorkflowStarted, + handleWorkflowFinished, + handleWorkflowFailed, + handleWorkflowNodeStarted, + handleWorkflowNodeFinished, + handleWorkflowNodeHumanInputRequired, + handleWorkflowNodeHumanInputFormFilled, + handleWorkflowNodeHumanInputFormTimeout, + handleWorkflowNodeIterationStarted, + handleWorkflowNodeIterationNext, + handleWorkflowNodeIterationFinished, + handleWorkflowNodeLoopStarted, + handleWorkflowNodeLoopNext, + handleWorkflowNodeLoopFinished, + handleWorkflowNodeRetry, + handleWorkflowAgentLog, + handleWorkflowTextChunk, + handleWorkflowTextReplace, + handleWorkflowReasoning, + handleWorkflowPaused, + } + const userCallbacks = { + onWorkflowStarted, + onWorkflowFinished, + onNodeStarted, + onNodeFinished, + onIterationStart, + onIterationNext, + onIterationFinish, + onLoopStart, + onLoopNext, + onLoopFinish, + onNodeRetry, + onAgentLog, + onError, + onWorkflowPaused, + onHumanInputRequired, + onHumanInputFormFilled, + onHumanInputFormTimeout, + onCompleted, + } + + const getWorkflowRunningData = () => workflowStore.getState().workflowRunningData + + const trackWorkflowRunFailed = (eventParams: unknown, workflowData: unknown) => { + const payload = + typeof eventParams === 'object' && eventParams !== null + ? (eventParams as Record<string, unknown>) + : undefined + const reason = + typeof eventParams === 'string' + ? eventParams + : eventParams instanceof Error + ? eventParams.message + : typeof payload?.error === 'string' + ? payload.error + : undefined + const nodeType = typeof payload?.node_type === 'string' ? payload.node_type : undefined + const workflowDataTrackingProperties = buildWorkflowDataTrackingProperties(workflowData) + + trackEvent('workflow_run_failed', { + workflow_id: flowId, + reason, + node_type: nodeType, + data: { + workflow_status: getWorkflowStatus(workflowData), + workflow_tracing_count: getWorkflowTracingCount(workflowData), + ...workflowDataTrackingProperties, + }, + }) + } + + const baseSseOptions = createBaseWorkflowRunCallbacks({ + clientWidth, + clientHeight, + runHistoryUrl, + isInWorkflowDebug, + fetchInspectVars, + invalidAllLastRun, + invalidateRunHistory, + clearAbortController, + clearListeningState: clearListeningStateInStore, + getWorkflowRunningData, + trackWorkflowRunFailed, + handlers: workflowRunEventHandlers, + callbacks: userCallbacks, + restCallback, + getOrCreatePlayer, }) - } - const workflowRunEventHandlers = { + if (isDebuggableTriggerType(runMode)) { + await runTriggerDebug({ + debugType: runMode, + url, + requestBody, + baseSseOptions, + controllerTarget: window as unknown as Record<string, unknown>, + setAbortController: (controller) => { + abortControllerRef.current = controller + }, + clearAbortController, + clearListeningState: clearListeningStateInStore, + setWorkflowRunningData, + }) + return + } + + const finalCallbacks = createFinalWorkflowRunCallbacks({ + clientWidth, + clientHeight, + runHistoryUrl, + isInWorkflowDebug, + fetchInspectVars, + invalidAllLastRun, + invalidateRunHistory, + clearAbortController, + clearListeningState: clearListeningStateInStore, + getWorkflowRunningData, + trackWorkflowRunFailed, + handlers: workflowRunEventHandlers, + callbacks: userCallbacks, + restCallback, + baseSseOptions, + player, + setAbortController: (controller) => { + abortControllerRef.current = controller + }, + }) + + ssePost( + url, + { + body: requestBody, + }, + finalCallbacks, + ) + }, + [ + store, + doSyncWorkflowDraft, + workflowStore, + pathname, + handleWorkflowFailed, + flowId, handleWorkflowStarted, handleWorkflowFinished, - handleWorkflowFailed, + fetchInspectVars, + invalidAllLastRun, + invalidateRunHistory, handleWorkflowNodeStarted, handleWorkflowNodeFinished, - handleWorkflowNodeHumanInputRequired, - handleWorkflowNodeHumanInputFormFilled, - handleWorkflowNodeHumanInputFormTimeout, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, @@ -351,189 +469,80 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { handleWorkflowTextReplace, handleWorkflowReasoning, handleWorkflowPaused, - } - const userCallbacks = { - onWorkflowStarted, - onWorkflowFinished, - onNodeStarted, - onNodeFinished, - onIterationStart, - onIterationNext, - onIterationFinish, - onLoopStart, - onLoopNext, - onLoopFinish, - onNodeRetry, - onAgentLog, - onError, - onWorkflowPaused, - onHumanInputRequired, - onHumanInputFormFilled, - onHumanInputFormTimeout, - onCompleted, - } - - const getWorkflowRunningData = () => workflowStore.getState().workflowRunningData - - const trackWorkflowRunFailed = (eventParams: unknown, workflowData: unknown) => { - const payload = typeof eventParams === 'object' && eventParams !== null - ? eventParams as Record<string, unknown> - : undefined - const reason = typeof eventParams === 'string' - ? eventParams - : eventParams instanceof Error - ? eventParams.message - : typeof payload?.error === 'string' - ? payload.error - : undefined - const nodeType = typeof payload?.node_type === 'string' - ? payload.node_type - : undefined - const workflowDataTrackingProperties = buildWorkflowDataTrackingProperties(workflowData) - - trackEvent('workflow_run_failed', { - workflow_id: flowId, - reason, - node_type: nodeType, - data: { - workflow_status: getWorkflowStatus(workflowData), - workflow_tracing_count: getWorkflowTracingCount(workflowData), - ...workflowDataTrackingProperties, - }, - }) - } - - const baseSseOptions = createBaseWorkflowRunCallbacks({ - clientWidth, - clientHeight, - runHistoryUrl, - isInWorkflowDebug, - fetchInspectVars, - invalidAllLastRun, - invalidateRunHistory, - clearAbortController, - clearListeningState: clearListeningStateInStore, - getWorkflowRunningData, - trackWorkflowRunFailed, - handlers: workflowRunEventHandlers, - callbacks: userCallbacks, - restCallback, - getOrCreatePlayer, - }) - - if (isDebuggableTriggerType(runMode)) { - await runTriggerDebug({ - debugType: runMode, - url, - requestBody, - baseSseOptions, - controllerTarget: window as unknown as Record<string, unknown>, - setAbortController: (controller) => { - abortControllerRef.current = controller - }, - clearAbortController, - clearListeningState: clearListeningStateInStore, - setWorkflowRunningData, - }) - return - } - - const finalCallbacks = createFinalWorkflowRunCallbacks({ - clientWidth, - clientHeight, - runHistoryUrl, - isInWorkflowDebug, - fetchInspectVars, - invalidAllLastRun, - invalidateRunHistory, - clearAbortController, - clearListeningState: clearListeningStateInStore, - getWorkflowRunningData, - trackWorkflowRunFailed, - handlers: workflowRunEventHandlers, - callbacks: userCallbacks, - restCallback, - baseSseOptions, - player, - setAbortController: (controller) => { - abortControllerRef.current = controller - }, - }) - - ssePost( - url, - { - body: requestBody, - }, - finalCallbacks, - ) - }, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowFailed, flowId, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, invalidateRunHistory, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowReasoning, handleWorkflowPaused, handleWorkflowNodeHumanInputRequired, handleWorkflowNodeHumanInputFormFilled, handleWorkflowNodeHumanInputFormTimeout]) - - const handleStopRun = useCallback((taskId: string) => { - const setStoppedState = () => { - const { - setWorkflowRunningData, - setIsListening, - setShowVariableInspectPanel, - setListeningTriggerType, - setListeningTriggerNodeId, - } = workflowStore.getState() - - applyStoppedState({ - setWorkflowRunningData, - setIsListening, - setShowVariableInspectPanel, - setListeningTriggerType, - setListeningTriggerNodeId, - }) - } + handleWorkflowNodeHumanInputRequired, + handleWorkflowNodeHumanInputFormFilled, + handleWorkflowNodeHumanInputFormTimeout, + ], + ) + + const handleStopRun = useCallback( + (taskId: string) => { + const setStoppedState = () => { + const { + setWorkflowRunningData, + setIsListening, + setShowVariableInspectPanel, + setListeningTriggerType, + setListeningTriggerNodeId, + } = workflowStore.getState() + + applyStoppedState({ + setWorkflowRunningData, + setIsListening, + setShowVariableInspectPanel, + setListeningTriggerType, + setListeningTriggerNodeId, + }) + } + + if (taskId) { + const appId = useAppStore.getState().appDetail?.id + stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`) + setStoppedState() + return + } + + // Try webhook debug controller from global variable first + const debugWindow = window as WorkflowDebugWindow + + const webhookController = debugWindow.__webhookDebugAbortController + if (webhookController) webhookController.abort() + + const pluginController = debugWindow.__pluginDebugAbortController + if (pluginController) pluginController.abort() + + const scheduleController = debugWindow.__scheduleDebugAbortController + if (scheduleController) scheduleController.abort() + + const allTriggerController = debugWindow.__allTriggersDebugAbortController + if (allTriggerController) allTriggerController.abort() + + // Also try the ref + if (abortControllerRef.current) abortControllerRef.current.abort() - if (taskId) { - const appId = useAppStore.getState().appDetail?.id - stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`) + abortControllerRef.current = null setStoppedState() - return - } - - // Try webhook debug controller from global variable first - const debugWindow = window as WorkflowDebugWindow - - const webhookController = debugWindow.__webhookDebugAbortController - if (webhookController) - webhookController.abort() - - const pluginController = debugWindow.__pluginDebugAbortController - if (pluginController) - pluginController.abort() - - const scheduleController = debugWindow.__scheduleDebugAbortController - if (scheduleController) - scheduleController.abort() - - const allTriggerController = debugWindow.__allTriggersDebugAbortController - if (allTriggerController) - allTriggerController.abort() - - // Also try the ref - if (abortControllerRef.current) - abortControllerRef.current.abort() - - abortControllerRef.current = null - setStoppedState() - }, [workflowStore]) - - const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => { - const nodes = normalizePublishedWorkflowNodes(publishedWorkflow) - const edges = publishedWorkflow.graph.edges - const viewport = publishedWorkflow.graph.viewport! - handleUpdateWorkflowCanvas({ - nodes, - edges, - viewport, - }) - featuresStore?.setState({ features: mapPublishedWorkflowFeatures(publishedWorkflow) }) - workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || []) - }, [featuresStore, handleUpdateWorkflowCanvas, workflowStore]) + }, + [workflowStore], + ) + + const handleRestoreFromPublishedWorkflow = useCallback( + (publishedWorkflow: VersionHistory) => { + const nodes = normalizePublishedWorkflowNodes(publishedWorkflow) + const edges = publishedWorkflow.graph.edges + const viewport = publishedWorkflow.graph.viewport! + handleUpdateWorkflowCanvas({ + nodes, + edges, + viewport, + }) + featuresStore?.setState({ features: mapPublishedWorkflowFeatures(publishedWorkflow) }) + workflowStore + .getState() + .setEnvironmentVariables(publishedWorkflow.environment_variables || []) + }, + [featuresStore, handleUpdateWorkflowCanvas, workflowStore], + ) return { handleBackupDraft, diff --git a/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx b/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx index 633dd209143109..0e8440791aa0bf 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx +++ b/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx @@ -1,22 +1,12 @@ -import type { - useNodesSyncDraft, - useWorkflowRun, -} from '.' +import type { useNodesSyncDraft, useWorkflowRun } from '.' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { TriggerType } from '@/app/components/workflow/header/test-run-menu' import { useWorkflowInteractions } from '@/app/components/workflow/hooks' import { useWorkflowStore } from '@/app/components/workflow/store' -import { - BlockEnum, - WorkflowRunningStatus, -} from '@/app/components/workflow/types' -import { - useIsChatMode, - useNodesSyncDraftByCanEdit, - useWorkflowRunByCanEdit, -} from '.' +import { BlockEnum, WorkflowRunningStatus } from '@/app/components/workflow/types' +import { useIsChatMode, useNodesSyncDraftByCanEdit, useWorkflowRunByCanEdit } from '.' type HandleRun = ReturnType<typeof useWorkflowRun>['handleRun'] type DoSyncWorkflowDraft = ReturnType<typeof useNodesSyncDraft>['doSyncWorkflowDraft'] @@ -32,18 +22,14 @@ const useWorkflowStartRunBase = ( const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions() const handleWorkflowStartRunInWorkflow = useCallback(async () => { - const { - workflowRunningData, - } = workflowStore.getState() + const { workflowRunningData } = workflowStore.getState() - if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) - return + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) return const { getNodes } = store.getState() const nodes = getNodes() - const startNode = nodes.find(node => node.data.type === BlockEnum.Start) - if (!startNode) - return + const startNode = nodes.find((node) => node.data.type === BlockEnum.Start) + if (!startNode) return const startVariables = startNode?.data.variables || [] const fileSettings = featuresStore!.getState().features.file @@ -68,209 +54,206 @@ const useWorkflowStartRunBase = ( handleRun({ inputs: {}, files: [] }) setShowDebugAndPreviewPanel(true) setShowInputsPanel(false) - } - else { + } else { setShowDebugAndPreviewPanel(true) setShowInputsPanel(true) } - }, [store, workflowStore, featuresStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft]) - - const handleWorkflowTriggerScheduleRunInWorkflow = useCallback(async (nodeId?: string) => { - if (!nodeId) - return - - const { - workflowRunningData, - showDebugAndPreviewPanel, - setShowDebugAndPreviewPanel, - setShowInputsPanel, - setShowEnvPanel, - setShowGlobalVariablePanel, - setListeningTriggerType, - setListeningTriggerNodeId, - setListeningTriggerNodeIds, - setListeningTriggerIsAll, - } = workflowStore.getState() - - if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) - return - - const { getNodes } = store.getState() - const nodes = getNodes() - const scheduleNode = nodes.find(node => node.id === nodeId && node.data.type === BlockEnum.TriggerSchedule) - - if (!scheduleNode) { - console.warn('handleWorkflowTriggerScheduleRunInWorkflow: schedule node not found', nodeId) - return - } - - setShowEnvPanel(false) - setShowGlobalVariablePanel(false) - - if (showDebugAndPreviewPanel) { - handleCancelDebugAndPreviewPanel() - return - } - - setListeningTriggerType(BlockEnum.TriggerSchedule) - setListeningTriggerNodeId(nodeId) - setListeningTriggerNodeIds([nodeId]) - setListeningTriggerIsAll(false) + }, [ + store, + workflowStore, + featuresStore, + handleCancelDebugAndPreviewPanel, + handleRun, + doSyncWorkflowDraft, + ]) + + const handleWorkflowTriggerScheduleRunInWorkflow = useCallback( + async (nodeId?: string) => { + if (!nodeId) return + + const { + workflowRunningData, + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + setShowEnvPanel, + setShowGlobalVariablePanel, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) return + + const { getNodes } = store.getState() + const nodes = getNodes() + const scheduleNode = nodes.find( + (node) => node.id === nodeId && node.data.type === BlockEnum.TriggerSchedule, + ) + + if (!scheduleNode) { + console.warn('handleWorkflowTriggerScheduleRunInWorkflow: schedule node not found', nodeId) + return + } + + setShowEnvPanel(false) + setShowGlobalVariablePanel(false) + + if (showDebugAndPreviewPanel) { + handleCancelDebugAndPreviewPanel() + return + } + + setListeningTriggerType(BlockEnum.TriggerSchedule) + setListeningTriggerNodeId(nodeId) + setListeningTriggerNodeIds([nodeId]) + setListeningTriggerIsAll(false) - await doSyncWorkflowDraft() - handleRun( - {}, - undefined, - { + await doSyncWorkflowDraft() + handleRun({}, undefined, { mode: TriggerType.Schedule, scheduleNodeId: nodeId, - }, - ) - setShowDebugAndPreviewPanel(true) - setShowInputsPanel(false) - }, [store, workflowStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft]) - - const handleWorkflowTriggerWebhookRunInWorkflow = useCallback(async ({ nodeId }: { nodeId: string }) => { - if (!nodeId) - return - - const { - workflowRunningData, - showDebugAndPreviewPanel, - setShowDebugAndPreviewPanel, - setShowInputsPanel, - setShowEnvPanel, - setShowGlobalVariablePanel, - setListeningTriggerType, - setListeningTriggerNodeId, - setListeningTriggerNodeIds, - setListeningTriggerIsAll, - } = workflowStore.getState() - - if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) - return - - const { getNodes } = store.getState() - const nodes = getNodes() - const webhookNode = nodes.find(node => node.id === nodeId && node.data.type === BlockEnum.TriggerWebhook) - - if (!webhookNode) { - console.warn('handleWorkflowTriggerWebhookRunInWorkflow: webhook node not found', nodeId) - return - } - - setShowEnvPanel(false) - setShowGlobalVariablePanel(false) - - if (!showDebugAndPreviewPanel) + }) setShowDebugAndPreviewPanel(true) + setShowInputsPanel(false) + }, + [store, workflowStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft], + ) + + const handleWorkflowTriggerWebhookRunInWorkflow = useCallback( + async ({ nodeId }: { nodeId: string }) => { + if (!nodeId) return + + const { + workflowRunningData, + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + setShowEnvPanel, + setShowGlobalVariablePanel, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) return + + const { getNodes } = store.getState() + const nodes = getNodes() + const webhookNode = nodes.find( + (node) => node.id === nodeId && node.data.type === BlockEnum.TriggerWebhook, + ) + + if (!webhookNode) { + console.warn('handleWorkflowTriggerWebhookRunInWorkflow: webhook node not found', nodeId) + return + } + + setShowEnvPanel(false) + setShowGlobalVariablePanel(false) + + if (!showDebugAndPreviewPanel) setShowDebugAndPreviewPanel(true) + + setShowInputsPanel(false) + setListeningTriggerType(BlockEnum.TriggerWebhook) + setListeningTriggerNodeId(nodeId) + setListeningTriggerNodeIds([nodeId]) + setListeningTriggerIsAll(false) - setShowInputsPanel(false) - setListeningTriggerType(BlockEnum.TriggerWebhook) - setListeningTriggerNodeId(nodeId) - setListeningTriggerNodeIds([nodeId]) - setListeningTriggerIsAll(false) - - await doSyncWorkflowDraft() - handleRun( - { node_id: nodeId }, - undefined, - { + await doSyncWorkflowDraft() + handleRun({ node_id: nodeId }, undefined, { mode: TriggerType.Webhook, webhookNodeId: nodeId, - }, - ) - }, [store, workflowStore, handleRun, doSyncWorkflowDraft]) - - const handleWorkflowTriggerPluginRunInWorkflow = useCallback(async (nodeId?: string) => { - if (!nodeId) - return - const { - workflowRunningData, - showDebugAndPreviewPanel, - setShowDebugAndPreviewPanel, - setShowInputsPanel, - setShowEnvPanel, - setShowGlobalVariablePanel, - setListeningTriggerType, - setListeningTriggerNodeId, - setListeningTriggerNodeIds, - setListeningTriggerIsAll, - } = workflowStore.getState() - - if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) - return - - const { getNodes } = store.getState() - const nodes = getNodes() - const pluginNode = nodes.find(node => node.id === nodeId && node.data.type === BlockEnum.TriggerPlugin) - - if (!pluginNode) { - console.warn('handleWorkflowTriggerPluginRunInWorkflow: plugin node not found', nodeId) - return - } + }) + }, + [store, workflowStore, handleRun, doSyncWorkflowDraft], + ) + + const handleWorkflowTriggerPluginRunInWorkflow = useCallback( + async (nodeId?: string) => { + if (!nodeId) return + const { + workflowRunningData, + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + setShowEnvPanel, + setShowGlobalVariablePanel, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) return + + const { getNodes } = store.getState() + const nodes = getNodes() + const pluginNode = nodes.find( + (node) => node.id === nodeId && node.data.type === BlockEnum.TriggerPlugin, + ) + + if (!pluginNode) { + console.warn('handleWorkflowTriggerPluginRunInWorkflow: plugin node not found', nodeId) + return + } + + setShowEnvPanel(false) + setShowGlobalVariablePanel(false) + + if (!showDebugAndPreviewPanel) setShowDebugAndPreviewPanel(true) - setShowEnvPanel(false) - setShowGlobalVariablePanel(false) - - if (!showDebugAndPreviewPanel) - setShowDebugAndPreviewPanel(true) + setShowInputsPanel(false) + setListeningTriggerType(BlockEnum.TriggerPlugin) + setListeningTriggerNodeId(nodeId) + setListeningTriggerNodeIds([nodeId]) + setListeningTriggerIsAll(false) - setShowInputsPanel(false) - setListeningTriggerType(BlockEnum.TriggerPlugin) - setListeningTriggerNodeId(nodeId) - setListeningTriggerNodeIds([nodeId]) - setListeningTriggerIsAll(false) - - await doSyncWorkflowDraft() - handleRun( - { node_id: nodeId }, - undefined, - { + await doSyncWorkflowDraft() + handleRun({ node_id: nodeId }, undefined, { mode: TriggerType.Plugin, pluginNodeId: nodeId, - }, - ) - }, [store, workflowStore, handleRun, doSyncWorkflowDraft]) - - const handleWorkflowRunAllTriggersInWorkflow = useCallback(async (nodeIds: string[]) => { - if (!nodeIds.length) - return - const { - workflowRunningData, - showDebugAndPreviewPanel, - setShowDebugAndPreviewPanel, - setShowInputsPanel, - setShowEnvPanel, - setShowGlobalVariablePanel, - setListeningTriggerIsAll, - setListeningTriggerNodeIds, - setListeningTriggerNodeId, - } = workflowStore.getState() - - if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) - return - - setShowEnvPanel(false) - setShowGlobalVariablePanel(false) - setShowInputsPanel(false) - setListeningTriggerIsAll(true) - setListeningTriggerNodeIds(nodeIds) - setListeningTriggerNodeId(null) + }) + }, + [store, workflowStore, handleRun, doSyncWorkflowDraft], + ) + + const handleWorkflowRunAllTriggersInWorkflow = useCallback( + async (nodeIds: string[]) => { + if (!nodeIds.length) return + const { + workflowRunningData, + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + setShowEnvPanel, + setShowGlobalVariablePanel, + setListeningTriggerIsAll, + setListeningTriggerNodeIds, + setListeningTriggerNodeId, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) return + + setShowEnvPanel(false) + setShowGlobalVariablePanel(false) + setShowInputsPanel(false) + setListeningTriggerIsAll(true) + setListeningTriggerNodeIds(nodeIds) + setListeningTriggerNodeId(null) - if (!showDebugAndPreviewPanel) - setShowDebugAndPreviewPanel(true) + if (!showDebugAndPreviewPanel) setShowDebugAndPreviewPanel(true) - await doSyncWorkflowDraft() - handleRun( - { node_ids: nodeIds }, - undefined, - { + await doSyncWorkflowDraft() + handleRun({ node_ids: nodeIds }, undefined, { mode: TriggerType.All, allNodeIds: nodeIds, - }, - ) - }, [store, workflowStore, handleRun, doSyncWorkflowDraft]) + }) + }, + [store, workflowStore, handleRun, doSyncWorkflowDraft], + ) const handleWorkflowStartRunInChatflow = useCallback(async () => { const { @@ -286,19 +269,15 @@ const useWorkflowStartRunBase = ( setShowChatVariablePanel(false) setShowGlobalVariablePanel(false) - if (showDebugAndPreviewPanel) - handleCancelDebugAndPreviewPanel() - else - setShowDebugAndPreviewPanel(true) + if (showDebugAndPreviewPanel) handleCancelDebugAndPreviewPanel() + else setShowDebugAndPreviewPanel(true) setHistoryWorkflowData(undefined) }, [workflowStore, handleCancelDebugAndPreviewPanel]) const handleStartWorkflowRun = useCallback(() => { - if (!isChatMode) - handleWorkflowStartRunInWorkflow() - else - handleWorkflowStartRunInChatflow() + if (!isChatMode) handleWorkflowStartRunInWorkflow() + else handleWorkflowStartRunInChatflow() }, [isChatMode, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow]) return { diff --git a/web/app/components/workflow-app/hooks/use-workflow-template.ts b/web/app/components/workflow-app/hooks/use-workflow-template.ts index c473e047b53197..be7355031286b6 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-template.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-template.ts @@ -1,10 +1,7 @@ import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' -import { - NODE_WIDTH_X_OFFSET, - START_INITIAL_POSITION, -} from '@/app/components/workflow/constants' +import { NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION } from '@/app/components/workflow/constants' import answerDefault from '@/app/components/workflow/nodes/answer/default' import llmDefault from '@/app/components/workflow/nodes/llm/default' import startPlaceholderDefault from '@/app/components/workflow/nodes/start-placeholder/default' @@ -15,15 +12,15 @@ import { useIsChatMode } from './use-is-chat-mode' export const useWorkflowTemplate = () => { const isChatMode = useIsChatMode() - const appDetail = useAppStore(s => s.appDetail) + const appDetail = useAppStore((s) => s.appDetail) const { t } = useTranslation() const createStartNode = () => { const { newNode: startNode } = generateNewNode({ data: { - ...startDefault.defaultValue as StartNodeType, + ...(startDefault.defaultValue as StartNodeType), type: startDefault.metaData.type, - title: t($ => $[`blocks.${startDefault.metaData.type}`], { ns: 'workflow' }), + title: t(($) => $[`blocks.${startDefault.metaData.type}`], { ns: 'workflow' }), }, position: START_INITIAL_POSITION, }) @@ -44,7 +41,7 @@ export const useWorkflowTemplate = () => { }, selected: true, type: llmDefault.metaData.type, - title: t($ => $[`blocks.${llmDefault.metaData.type}`], { ns: 'workflow' }), + title: t(($) => $[`blocks.${llmDefault.metaData.type}`], { ns: 'workflow' }), }, position: { x: START_INITIAL_POSITION.x + NODE_WIDTH_X_OFFSET, @@ -58,7 +55,7 @@ export const useWorkflowTemplate = () => { ...answerDefault.defaultValue, answer: `{{#${llmNode.id}.text#}}`, type: answerDefault.metaData.type, - title: t($ => $[`blocks.${answerDefault.metaData.type}`], { ns: 'workflow' }), + title: t(($) => $[`blocks.${answerDefault.metaData.type}`], { ns: 'workflow' }), }, position: { x: START_INITIAL_POSITION.x + NODE_WIDTH_X_OFFSET * 2, @@ -93,7 +90,7 @@ export const useWorkflowTemplate = () => { ...startPlaceholderDefault.defaultValue, selected: true, type: startPlaceholderDefault.metaData.type, - title: t($ => $[`blocks.${startPlaceholderDefault.metaData.type}`], { ns: 'workflow' }), + title: t(($) => $[`blocks.${startPlaceholderDefault.metaData.type}`], { ns: 'workflow' }), desc: '', }, position: START_INITIAL_POSITION, diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index 35eaa35908d624..8724ab59c3c4de 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -3,23 +3,15 @@ import type { Features as FeaturesData } from '@/app/components/base/features/types' import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store' import { useAtomValue } from 'jotai' -import { - useEffect, - useMemo, -} from 'react' +import { useEffect, useMemo } from 'react' import { useStore as useAppStore } from '@/app/components/app/store' import { FeaturesProvider } from '@/app/components/base/features' import Loading from '@/app/components/base/loading' import WorkflowWithDefaultContext from '@/app/components/workflow' -import { - WorkflowContextProvider, -} from '@/app/components/workflow/context' +import { WorkflowContextProvider } from '@/app/components/workflow/context' import { useWorkflowStore } from '@/app/components/workflow/store' import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status' -import { - initialEdges, - initialNodes, -} from '@/app/components/workflow/utils' +import { initialEdges, initialNodes } from '@/app/components/workflow/utils' import { userProfileIdAtom } from '@/context/account-state' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' @@ -29,24 +21,13 @@ import { useAppTriggers } from '@/service/use-tools' import { AppModeEnum } from '@/types/app' import { getAppACLCapabilities } from '@/utils/permission' import WorkflowAppMain from './components/workflow-main' - import { useGetRunAndTraceUrl } from './hooks/use-get-run-and-trace-url' -import { - useWorkflowInit, -} from './hooks/use-workflow-init' +import { useWorkflowInit } from './hooks/use-workflow-init' import { createWorkflowSlice } from './store/workflow/workflow-slice' -import { - buildInitialFeatures, - buildTriggerStatusMap, - coerceReplayUserInputs, -} from './utils' +import { buildInitialFeatures, buildTriggerStatusMap, coerceReplayUserInputs } from './utils' const WorkflowAppWithAdditionalContext = () => { - const { - data, - isLoading, - fileUploadConfigResponse, - } = useWorkflowInit() + const { data, isLoading, fileUploadConfigResponse } = useWorkflowInit() const workflowStore = useWorkflowStore() const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom) const currentWorkspace = useAtomValue(currentWorkspaceAtom) @@ -55,12 +36,16 @@ const WorkflowAppWithAdditionalContext = () => { // Initialize trigger status at application level const { setTriggerStatuses } = useTriggerStatusStore() - const appDetail = useAppStore(s => s.appDetail) - const appACLCapabilities = useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { - currentUserId, - resourceMaintainer: appDetail?.maintainer, - workspacePermissionKeys, - }), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys]) + const appDetail = useAppStore((s) => s.appDetail) + const appACLCapabilities = useMemo( + () => + getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }), + [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], + ) const appId = appDetail?.id const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW const { data: triggersResponse } = useAppTriggers(isWorkflowMode ? appId : undefined, { @@ -84,7 +69,9 @@ const WorkflowAppWithAdditionalContext = () => { // Cancel any pending debounced sync operations const { debouncedSyncWorkflowDraft } = workflowStore.getState() // The debounced function from lodash has a cancel method - const cancellableSyncWorkflowDraft = debouncedSyncWorkflowDraft as { cancel?: () => void } | undefined + const cancellableSyncWorkflowDraft = debouncedSyncWorkflowDraft as + | { cancel?: () => void } + | undefined cancellableSyncWorkflowDraft?.cancel?.() } }, [workflowStore]) @@ -110,21 +97,19 @@ const WorkflowAppWithAdditionalContext = () => { const replayRunId = searchParams.get('replayRunId') useEffect(() => { - if (!replayRunId || !appACLCapabilities.canTestAndRun) - return + if (!replayRunId || !appACLCapabilities.canTestAndRun) return const { runUrl } = getWorkflowRunAndTraceUrl(replayRunId) - if (!runUrl) - return + if (!runUrl) return fetchRunDetail(runUrl).then((res) => { - const { setInputs, setShowInputsPanel, setShowDebugAndPreviewPanel } = workflowStore.getState() + const { setInputs, setShowInputsPanel, setShowDebugAndPreviewPanel } = + workflowStore.getState() const rawInputs = res.inputs let parsedInputs: unknown = rawInputs if (typeof rawInputs === 'string') { try { parsedInputs = JSON.parse(rawInputs) as unknown - } - catch (error) { + } catch (error) { console.error('Failed to parse workflow run inputs', error) return } @@ -132,8 +117,7 @@ const WorkflowAppWithAdditionalContext = () => { const userInputs = coerceReplayUserInputs(parsedInputs) - if (!userInputs || !Object.keys(userInputs).length) - return + if (!userInputs || !Object.keys(userInputs).length) return setInputs(userInputs) setShowInputsPanel(true) @@ -149,19 +133,15 @@ const WorkflowAppWithAdditionalContext = () => { ) } - const initialFeatures: FeaturesData = buildInitialFeatures(data.features, fileUploadConfigResponse) + const initialFeatures: FeaturesData = buildInitialFeatures( + data.features, + fileUploadConfigResponse, + ) return ( - <WorkflowWithDefaultContext - edges={edgesData} - nodes={nodesData} - > + <WorkflowWithDefaultContext edges={edgesData} nodes={nodesData}> <FeaturesProvider features={initialFeatures}> - <WorkflowAppMain - nodes={nodesData} - edges={edgesData} - viewport={data.graph.viewport} - /> + <WorkflowAppMain nodes={nodesData} edges={edgesData} viewport={data.graph.viewport} /> </FeaturesProvider> </WorkflowWithDefaultContext> ) diff --git a/web/app/components/workflow-app/store/workflow/workflow-slice.ts b/web/app/components/workflow-app/store/workflow/workflow-slice.ts index bc283208437126..42354247c3fb25 100644 --- a/web/app/components/workflow-app/store/workflow/workflow-slice.ts +++ b/web/app/components/workflow-app/store/workflow/workflow-slice.ts @@ -17,19 +17,20 @@ export type WorkflowSliceShape = { setHasShownOnboarding: (hasShownOnboarding: boolean) => void } -export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({ +export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = (set) => ({ appId: '', appName: '', notInitialWorkflow: false, - setNotInitialWorkflow: notInitialWorkflow => set(() => ({ notInitialWorkflow })), + setNotInitialWorkflow: (notInitialWorkflow) => set(() => ({ notInitialWorkflow })), shouldAutoOpenStartNodeSelector: false, - setShouldAutoOpenStartNodeSelector: shouldAutoOpenStartNodeSelector => set(() => ({ shouldAutoOpenStartNodeSelector })), + setShouldAutoOpenStartNodeSelector: (shouldAutoOpenStartNodeSelector) => + set(() => ({ shouldAutoOpenStartNodeSelector })), nodesDefaultConfigs: {}, - setNodesDefaultConfigs: nodesDefaultConfigs => set(() => ({ nodesDefaultConfigs })), + setNodesDefaultConfigs: (nodesDefaultConfigs) => set(() => ({ nodesDefaultConfigs })), showOnboarding: false, - setShowOnboarding: showOnboarding => set(() => ({ showOnboarding })), + setShowOnboarding: (showOnboarding) => set(() => ({ showOnboarding })), hasSelectedStartNode: false, - setHasSelectedStartNode: hasSelectedStartNode => set(() => ({ hasSelectedStartNode })), + setHasSelectedStartNode: (hasSelectedStartNode) => set(() => ({ hasSelectedStartNode })), hasShownOnboarding: false, - setHasShownOnboarding: hasShownOnboarding => set(() => ({ hasShownOnboarding })), + setHasShownOnboarding: (hasShownOnboarding) => set(() => ({ hasShownOnboarding })), }) diff --git a/web/app/components/workflow-app/utils.ts b/web/app/components/workflow-app/utils.ts index df344e333b43d2..fd54f0fa21884d 100644 --- a/web/app/components/workflow-app/utils.ts +++ b/web/app/components/workflow-app/utils.ts @@ -40,15 +40,15 @@ export const buildTriggerStatusMap = (triggers: TriggerStatusLike[]) => { }, {}) } -export const coerceReplayUserInputs = (rawInputs: unknown): Record<string, string | number | boolean> | null => { - if (!rawInputs || typeof rawInputs !== 'object' || Array.isArray(rawInputs)) - return null +export const coerceReplayUserInputs = ( + rawInputs: unknown, +): Record<string, string | number | boolean> | null => { + if (!rawInputs || typeof rawInputs !== 'object' || Array.isArray(rawInputs)) return null const userInputs: Record<string, string | number | boolean> = {} Object.entries(rawInputs as Record<string, unknown>).forEach(([key, value]) => { - if (key.startsWith('sys.')) - return + if (key.startsWith('sys.')) return if (value == null) { userInputs[key] = '' @@ -62,8 +62,7 @@ export const coerceReplayUserInputs = (rawInputs: unknown): Record<string, strin try { userInputs[key] = JSON.stringify(value) - } - catch { + } catch { userInputs[key] = String(value) } }) @@ -84,12 +83,18 @@ export const buildInitialFeatures = ( image: { enabled: !!imageUpload?.enabled, number_limits: imageUpload?.number_limits || 3, - transfer_methods: imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url], + transfer_methods: imageUpload?.transfer_methods || [ + TransferMethod.local_file, + TransferMethod.remote_url, + ], }, enabled: !!(fileUpload?.enabled || imageUpload?.enabled), allowed_file_types: fileUpload?.allowed_file_types || [SupportUploadFileTypes.image], - allowed_file_extensions: fileUpload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image]!.map(ext => `.${ext}`), - allowed_file_upload_methods: fileUpload?.allowed_file_upload_methods || imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url], + allowed_file_extensions: + fileUpload?.allowed_file_extensions || + FILE_EXTS[SupportUploadFileTypes.image]!.map((ext) => `.${ext}`), + allowed_file_upload_methods: fileUpload?.allowed_file_upload_methods || + imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url], number_limits: fileUpload?.number_limits || imageUpload?.number_limits || 3, fileUploadConfig: fileUploadConfigResponse, }, diff --git a/web/app/components/workflow/__tests__/block-icon.spec.tsx b/web/app/components/workflow/__tests__/block-icon.spec.tsx index 7bec2b41eff55f..0a3f93a7b9883b 100644 --- a/web/app/components/workflow/__tests__/block-icon.spec.tsx +++ b/web/app/components/workflow/__tests__/block-icon.spec.tsx @@ -5,10 +5,17 @@ import { BlockEnum } from '../types' describe('BlockIcon', () => { it('renders the default workflow icon container for regular nodes', () => { - const { container } = render(<BlockIcon type={BlockEnum.Start} size="xs" className="extra-class" />) + const { container } = render( + <BlockIcon type={BlockEnum.Start} size="xs" className="extra-class" />, + ) const iconContainer = container.firstElementChild - expect(iconContainer).toHaveClass('w-4', 'h-4', 'bg-util-colors-blue-brand-blue-brand-500', 'extra-class') + expect(iconContainer).toHaveClass( + 'w-4', + 'h-4', + 'bg-util-colors-blue-brand-blue-brand-500', + 'extra-class', + ) expect(iconContainer?.querySelector('.i-custom-vender-workflow-user-input')).toBeInTheDocument() }) @@ -33,10 +40,7 @@ describe('BlockIcon', () => { describe('VarBlockIcon', () => { it('renders the compact icon variant without the default container wrapper', () => { const { container } = render( - <VarBlockIcon - type={BlockEnum.Answer} - className="custom-var-icon" - />, + <VarBlockIcon type={BlockEnum.Answer} className="custom-var-icon" />, ) expect(container.querySelector('.custom-var-icon')).toBeInTheDocument() diff --git a/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx b/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx index 24041815a3d6c4..3e8aafa489d07f 100644 --- a/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx +++ b/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx @@ -34,12 +34,16 @@ vi.mock('reactflow', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { mousePosition: { - pageX: number - pageY: number - elementX: number - elementY: number - } }) => unknown) => mockUseStore(selector), + useStore: ( + selector: (state: { + mousePosition: { + pageX: number + pageY: number + elementX: number + elementY: number + } + }) => unknown, + ) => mockUseStore(selector), useWorkflowStore: () => mockUseWorkflowStore(), })) @@ -98,7 +102,9 @@ describe('CandidateNodeMain', () => { handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, }) const createAutoGenerateWebhookUrl = () => mockAutoGenerateWebhookUrl - const eventHandlers: Partial<Record<'click' | 'contextmenu', (event: { preventDefault: () => void }) => void>> = {} + const eventHandlers: Partial< + Record<'click' | 'contextmenu', (event: { preventDefault: () => void }) => void> + > = {} let nodes = [createNode({ id: 'existing-node' })] beforeEach(() => { @@ -107,9 +113,14 @@ describe('CandidateNodeMain', () => { eventHandlers.click = undefined eventHandlers.contextmenu = undefined - mockUseEventListener.mockImplementation((event: 'click' | 'contextmenu', handler: (event: { preventDefault: () => void }) => void) => { - eventHandlers[event] = handler - }) + mockUseEventListener.mockImplementation( + ( + event: 'click' | 'contextmenu', + handler: (event: { preventDefault: () => void }) => void, + ) => { + eventHandlers[event] = handler + }, + ) mockSetNodes.mockImplementation((nextNodes) => { nodes = nextNodes }) @@ -120,22 +131,29 @@ describe('CandidateNodeMain', () => { }), }) mockUseReactFlow.mockReturnValue({ - screenToFlowPosition: ({ x, y }: { x: number, y: number }) => ({ x: x + 10, y: y + 20 }), + screenToFlowPosition: ({ x, y }: { x: number; y: number }) => ({ x: x + 10, y: y + 20 }), }) mockUseViewport.mockReturnValue({ zoom: 1.5 }) - mockUseStore.mockImplementation((selector: (state: { mousePosition: { - pageX: number - pageY: number - elementX: number - elementY: number - } }) => unknown) => selector({ - mousePosition: { - pageX: 100, - pageY: 200, - elementX: 30, - elementY: 40, - }, - })) + mockUseStore.mockImplementation( + ( + selector: (state: { + mousePosition: { + pageX: number + pageY: number + elementX: number + elementY: number + } + }) => unknown, + ) => + selector({ + mousePosition: { + pageX: 100, + pageY: 200, + elementX: 30, + elementY: 40, + }, + }), + ) mockUseWorkflowStore.mockReturnValue({ getState: () => ({ setOpenInlineAgentPanelNodeId: mockSetOpenInlineAgentPanelNodeId, @@ -148,20 +166,29 @@ describe('CandidateNodeMain', () => { useNodesSyncDraft: createNodesSyncDraft, useAutoGenerateWebhookUrl: createAutoGenerateWebhookUrl, }) - mockHandleSyncWorkflowDraft.mockImplementation((_isSync: boolean, _force: boolean, options?: { onSuccess?: () => void }) => { - options?.onSuccess?.() - }) - mockCreateInlineAgentBinding.mockImplementation((_nodeId: string, options?: { onSuccess?: (binding: { - binding_type: 'inline_agent' - agent_id: string - current_snapshot_id: string - }) => void }) => { - options?.onSuccess?.({ - binding_type: 'inline_agent', - agent_id: 'inline-agent-1', - current_snapshot_id: 'inline-snapshot-1', - }) - }) + mockHandleSyncWorkflowDraft.mockImplementation( + (_isSync: boolean, _force: boolean, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }, + ) + mockCreateInlineAgentBinding.mockImplementation( + ( + _nodeId: string, + options?: { + onSuccess?: (binding: { + binding_type: 'inline_agent' + agent_id: string + current_snapshot_id: string + }) => void + }, + ) => { + options?.onSuccess?.({ + binding_type: 'inline_agent', + agent_id: 'inline-agent-1', + current_snapshot_id: 'inline-snapshot-1', + }) + }, + ) mockGetIterationStartNode.mockReturnValue(createNode({ id: 'iteration-start' })) mockGetLoopStartNode.mockReturnValue(createNode({ id: 'loop-start' })) }) @@ -188,19 +215,25 @@ describe('CandidateNodeMain', () => { eventHandlers.click?.({ preventDefault: vi.fn() }) - expect(mockSetNodes).toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ id: 'existing-node' }), - expect.objectContaining({ - id: 'candidate-webhook', - position: { x: 110, y: 220 }, - data: expect.objectContaining({ _isCandidate: false }), - }), - ])) + expect(mockSetNodes).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: 'existing-node' }), + expect.objectContaining({ + id: 'candidate-webhook', + position: { x: 110, y: 220 }, + data: expect.objectContaining({ _isCandidate: false }), + }), + ]), + ) expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodeAdd', { nodeId: 'candidate-webhook' }) expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined }) - expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith( + true, + true, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('candidate-webhook') expect(mockHandleNodeSelect).not.toHaveBeenCalled() }) @@ -247,20 +280,22 @@ describe('CandidateNodeMain', () => { eventHandlers.click?.({ preventDefault: vi.fn() }) - expect(mockSetNodes).toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ - id: 'candidate-agent-v2', - data: expect.objectContaining({ - agent_binding: { - binding_type: 'roster_agent', - agent_id: 'agent-1', - }, - agent_node_kind: 'dify_agent', - version: '2', - _isCandidate: false, + expect(mockSetNodes).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: 'candidate-agent-v2', + data: expect.objectContaining({ + agent_binding: { + binding_type: 'roster_agent', + agent_id: 'agent-1', + }, + agent_node_kind: 'dify_agent', + version: '2', + _isCandidate: false, + }), }), - }), - ])) + ]), + ) expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true) }) @@ -284,36 +319,45 @@ describe('CandidateNodeMain', () => { eventHandlers.click?.({ preventDefault: vi.fn() }) - expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith('candidate-inline-agent-v2', expect.objectContaining({ - onSuccess: expect.any(Function), - })) - expect(mockSetNodes.mock.calls[0]?.[0]).toEqual(expect.arrayContaining([ + expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith( + 'candidate-inline-agent-v2', expect.objectContaining({ - id: 'candidate-inline-agent-v2', - data: expect.objectContaining({ - agent_binding: { - binding_type: 'inline_agent', - }, - selected: true, - _isTempNode: true, - }), + onSuccess: expect.any(Function), }), - ])) - expect(mockSetNodes).toHaveBeenLastCalledWith(expect.arrayContaining([ - expect.objectContaining({ - id: 'candidate-inline-agent-v2', - data: expect.objectContaining({ - agent_binding: { - binding_type: 'inline_agent', - agent_id: 'inline-agent-1', - current_snapshot_id: 'inline-snapshot-1', - }, - selected: true, + ) + expect(mockSetNodes.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'candidate-inline-agent-v2', + data: expect.objectContaining({ + agent_binding: { + binding_type: 'inline_agent', + }, + selected: true, + _isTempNode: true, + }), }), - }), - ])) + ]), + ) + expect(mockSetNodes).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: 'candidate-inline-agent-v2', + data: expect.objectContaining({ + agent_binding: { + binding_type: 'inline_agent', + agent_id: 'inline-agent-1', + current_snapshot_id: 'inline-snapshot-1', + }, + selected: true, + }), + }), + ]), + ) const finalNodes = mockSetNodes.mock.calls.at(-1)?.[0] - const finalAgentNode = finalNodes.find((node: { id: string }) => node.id === 'candidate-inline-agent-v2') + const finalAgentNode = finalNodes.find( + (node: { id: string }) => node.id === 'candidate-inline-agent-v2', + ) expect(finalAgentNode.data._isTempNode).toBeUndefined() expect(mockSetOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('candidate-inline-agent-v2') expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true) @@ -343,19 +387,23 @@ describe('CandidateNodeMain', () => { eventHandlers.click?.({ preventDefault: vi.fn() }) expect(mockGetIterationStartNode).toHaveBeenCalledWith('candidate-iteration') - expect(mockSetNodes.mock.calls[0]![0]).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: 'candidate-iteration' }), - expect.objectContaining({ id: 'iteration-start' }), - ])) + expect(mockSetNodes.mock.calls[0]![0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'candidate-iteration' }), + expect.objectContaining({ id: 'iteration-start' }), + ]), + ) rerender(<CandidateNodeMain candidateNode={loopNode} />) eventHandlers.click?.({ preventDefault: vi.fn() }) expect(mockGetLoopStartNode).toHaveBeenCalledWith('candidate-loop') - expect(mockSetNodes.mock.calls[1]![0]).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: 'candidate-loop' }), - expect.objectContaining({ id: 'loop-start' }), - ])) + expect(mockSetNodes.mock.calls[1]![0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'candidate-loop' }), + expect.objectContaining({ id: 'loop-start' }), + ]), + ) }) it('should clear the candidate node on contextmenu', () => { diff --git a/web/app/components/workflow/__tests__/context.spec.tsx b/web/app/components/workflow/__tests__/context.spec.tsx index ccf1eaa9b157de..f5136da8d48993 100644 --- a/web/app/components/workflow/__tests__/context.spec.tsx +++ b/web/app/components/workflow/__tests__/context.spec.tsx @@ -4,7 +4,7 @@ import { WorkflowContextProvider } from '../context' import { useStore, useWorkflowStore } from '../store' const StoreConsumer = () => { - const showSingleRunPanel = useStore(s => s.showSingleRunPanel) + const showSingleRunPanel = useStore((s) => s.showSingleRunPanel) const store = useWorkflowStore() return ( diff --git a/web/app/components/workflow/__tests__/custom-connection-line.spec.tsx b/web/app/components/workflow/__tests__/custom-connection-line.spec.tsx index aaaf18153dc3c9..5c4898bc60f08f 100644 --- a/web/app/components/workflow/__tests__/custom-connection-line.spec.tsx +++ b/web/app/components/workflow/__tests__/custom-connection-line.spec.tsx @@ -5,17 +5,18 @@ import CustomConnectionLine from '../custom-connection-line' const createConnectionLineProps = ( overrides: Partial<ComponentProps<typeof CustomConnectionLine>> = {}, -): ComponentProps<typeof CustomConnectionLine> => ({ - fromX: 10, - fromY: 20, - toX: 70, - toY: 80, - fromPosition: Position.Right, - toPosition: Position.Left, - connectionLineType: undefined, - connectionStatus: null, - ...overrides, -} as ComponentProps<typeof CustomConnectionLine>) +): ComponentProps<typeof CustomConnectionLine> => + ({ + fromX: 10, + fromY: 20, + toX: 70, + toY: 80, + fromPosition: Position.Right, + toPosition: Position.Left, + connectionLineType: undefined, + connectionStatus: null, + ...overrides, + }) as ComponentProps<typeof CustomConnectionLine> describe('CustomConnectionLine', () => { it('should render the bezier path and target marker', () => { diff --git a/web/app/components/workflow/__tests__/custom-edge.spec.tsx b/web/app/components/workflow/__tests__/custom-edge.spec.tsx index 0263cdae272d0d..3503c3d079648e 100644 --- a/web/app/components/workflow/__tests__/custom-edge.spec.tsx +++ b/web/app/components/workflow/__tests__/custom-edge.spec.tsx @@ -31,7 +31,9 @@ vi.mock('reactflow', () => ({ data-dasharray={props.style.strokeDasharray} /> ), - EdgeLabelRenderer: ({ children }: { children?: ReactNode }) => <div data-testid="edge-label">{children}</div>, + EdgeLabelRenderer: ({ children }: { children?: ReactNode }) => ( + <div data-testid="edge-label">{children}</div> + ), getBezierPath: () => ['M 0 0', 24, 48], Position: { Right: 'right', @@ -72,11 +74,7 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ vi.mock('@/app/components/workflow/custom-edge-linear-gradient-render', () => ({ __esModule: true, - default: (props: { - id: string - startColor: string - stopColor: string - }) => { + default: (props: { id: string; startColor: string; stopColor: string }) => { mockGradientRender(props) return <div data-testid="edge-gradient">{props.id}</div> }, @@ -91,8 +89,7 @@ describe('CustomEdge', () => { handleNodeAdd: mockHandleNodeAdd, }) mockUseAvailableBlocks.mockImplementation((nodeType: BlockEnum) => { - if (nodeType === BlockEnum.Code) - return { availablePrevBlocks: ['code', 'llm'] } + if (nodeType === BlockEnum.Code) return { availablePrevBlocks: ['code', 'llm'] } return { availableNextBlocks: ['llm', 'tool'] } }) @@ -113,27 +110,31 @@ describe('CustomEdge', () => { targetY={220} targetPosition={Position.Left} selected={false} - data={{ - sourceType: BlockEnum.Start, - targetType: BlockEnum.Code, - _sourceRunningStatus: NodeRunningStatus.Succeeded, - _targetRunningStatus: NodeRunningStatus.Failed, - _hovering: true, - _waitingRun: true, - _dimmed: true, - _isTemp: true, - isInIteration: true, - isInLoop: true, - } as never} + data={ + { + sourceType: BlockEnum.Start, + targetType: BlockEnum.Code, + _sourceRunningStatus: NodeRunningStatus.Succeeded, + _targetRunningStatus: NodeRunningStatus.Failed, + _hovering: true, + _waitingRun: true, + _dimmed: true, + _isTemp: true, + isInIteration: true, + isInLoop: true, + } as never + } />, ) expect(screen.getByTestId('edge-gradient')).toHaveTextContent('edge-1') - expect(mockGradientRender).toHaveBeenCalledWith(expect.objectContaining({ - id: 'edge-1', - startColor: 'var(--color-workflow-link-line-success-handle)', - stopColor: 'var(--color-workflow-link-line-error-handle)', - })) + expect(mockGradientRender).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'edge-1', + startColor: 'var(--color-workflow-link-line-success-handle)', + stopColor: 'var(--color-workflow-link-line-error-handle)', + }), + ) expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'url(#edge-1)') expect(screen.getByTestId('base-edge')).toHaveAttribute('data-opacity', '0.3') expect(screen.getByTestId('base-edge')).toHaveAttribute('data-dasharray', '8 8') @@ -173,16 +174,21 @@ describe('CustomEdge', () => { targetY={100} targetPosition={Position.Left} selected - data={{ - sourceType: BlockEnum.Start, - targetType: BlockEnum.Code, - _sourceRunningStatus: NodeRunningStatus.Succeeded, - _targetRunningStatus: NodeRunningStatus.Running, - } as never} + data={ + { + sourceType: BlockEnum.Start, + targetType: BlockEnum.Code, + _sourceRunningStatus: NodeRunningStatus.Succeeded, + _targetRunningStatus: NodeRunningStatus.Running, + } as never + } />, ) - expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-handle)') + expect(screen.getByTestId('base-edge')).toHaveAttribute( + 'data-stroke', + 'var(--color-workflow-link-line-handle)', + ) }) it('should use the fail-branch running color while the connected node is hovering', () => { @@ -199,15 +205,20 @@ describe('CustomEdge', () => { targetY={100} targetPosition={Position.Left} selected={false} - data={{ - sourceType: BlockEnum.Start, - targetType: BlockEnum.Code, - _connectedNodeIsHovering: true, - } as never} + data={ + { + sourceType: BlockEnum.Start, + targetType: BlockEnum.Code, + _connectedNodeIsHovering: true, + } as never + } />, ) - expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-failure-handle)') + expect(screen.getByTestId('base-edge')).toHaveAttribute( + 'data-stroke', + 'var(--color-workflow-link-line-failure-handle)', + ) }) it('should fall back to the default edge color when no highlight state is active', () => { @@ -223,15 +234,23 @@ describe('CustomEdge', () => { targetY={100} targetPosition={Position.Left} selected={false} - data={{ - sourceType: BlockEnum.Start, - targetType: BlockEnum.Code, - } as never} + data={ + { + sourceType: BlockEnum.Start, + targetType: BlockEnum.Code, + } as never + } />, ) - expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-normal)') - expect(screen.getByTestId('block-selector')).toHaveAttribute('data-trigger-class', 'hover:scale-150 transition-all') + expect(screen.getByTestId('base-edge')).toHaveAttribute( + 'data-stroke', + 'var(--color-workflow-link-line-normal)', + ) + expect(screen.getByTestId('block-selector')).toHaveAttribute( + 'data-trigger-class', + 'hover:scale-150 transition-all', + ) expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({ opacity: '0', pointerEvents: 'none', diff --git a/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx b/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx index d0db0f026726d3..71251a80c74398 100644 --- a/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx +++ b/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx @@ -30,13 +30,7 @@ describe('DSLExportConfirmModal', () => { const onConfirm = vi.fn() const onClose = vi.fn() - render( - <DSLExportConfirmModal - envList={envList} - onConfirm={onConfirm} - onClose={onClose} - />, - ) + render(<DSLExportConfirmModal envList={envList} onConfirm={onConfirm} onClose={onClose} />) expect(screen.getByText('SECRET_TOKEN')).toBeInTheDocument() expect(screen.getByText('masked-value')).toBeInTheDocument() @@ -52,13 +46,7 @@ describe('DSLExportConfirmModal', () => { const onConfirm = vi.fn() const onClose = vi.fn() - render( - <DSLExportConfirmModal - envList={envList} - onConfirm={onConfirm} - onClose={onClose} - />, - ) + render(<DSLExportConfirmModal envList={envList} onConfirm={onConfirm} onClose={onClose} />) await user.click(screen.getByRole('button', { name: 'workflow.env.export.ignore' })) @@ -71,13 +59,7 @@ describe('DSLExportConfirmModal', () => { const onConfirm = vi.fn() const onClose = vi.fn() - render( - <DSLExportConfirmModal - envList={envList} - onConfirm={onConfirm} - onClose={onClose} - />, - ) + render(<DSLExportConfirmModal envList={envList} onConfirm={onConfirm} onClose={onClose} />) await user.click(screen.getByRole('checkbox')) await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' })) @@ -91,13 +73,7 @@ describe('DSLExportConfirmModal', () => { const onConfirm = vi.fn() const onClose = vi.fn() - render( - <DSLExportConfirmModal - envList={envList} - onConfirm={onConfirm} - onClose={onClose} - />, - ) + render(<DSLExportConfirmModal envList={envList} onConfirm={onConfirm} onClose={onClose} />) await user.click(screen.getByText('workflow.env.export.checkbox')) await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' })) @@ -108,19 +84,16 @@ describe('DSLExportConfirmModal', () => { it('should show exporting state and prevent duplicate submits while exporting', async () => { let resolveConfirm: () => void - const onConfirm = vi.fn(() => new Promise<void>((resolve) => { - resolveConfirm = resolve - })) + const onConfirm = vi.fn( + () => + new Promise<void>((resolve) => { + resolveConfirm = resolve + }), + ) const onClose = vi.fn() const user = userEvent.setup() - render( - <DSLExportConfirmModal - envList={envList} - onConfirm={onConfirm} - onClose={onClose} - />, - ) + render(<DSLExportConfirmModal envList={envList} onConfirm={onConfirm} onClose={onClose} />) const confirmButton = screen.getByRole('button', { name: 'workflow.env.export.ignore' }) @@ -143,13 +116,7 @@ describe('DSLExportConfirmModal', () => { }) it('should render border separators for all rows except the last one', () => { - render( - <DSLExportConfirmModal - envList={multiEnvList} - onConfirm={vi.fn()} - onClose={vi.fn()} - />, - ) + render(<DSLExportConfirmModal envList={multiEnvList} onConfirm={vi.fn()} onClose={vi.fn()} />) const firstNameCell = screen.getByText('SECRET_TOKEN').closest('td') const lastNameCell = screen.getByText('SERVICE_KEY').closest('td') diff --git a/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx index b216bf2e5e76a5..dbb8e33ca6b94d 100644 --- a/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx @@ -62,10 +62,7 @@ const getNodeRuntimeState = (node?: Node): NodeRuntimeState => (node?.data ?? {}) as NodeRuntimeState function createFlowNodes() { - return [ - createNode({ id: 'n1' }), - createNode({ id: 'n2', position: { x: 100, y: 0 } }), - ] + return [createNode({ id: 'n1' }), createNode({ id: 'n2', position: { x: 100, y: 0 } })] } function createFlowEdges() { @@ -109,8 +106,7 @@ const EdgeMenuHarness = () => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'Delete' && e.key !== 'Backspace') - return + if (e.key !== 'Delete' && e.key !== 'Backspace') return e.preventDefault() handleEdgeDelete() @@ -128,14 +124,18 @@ const EdgeMenuHarness = () => { <button type="button" aria-label="Right-click edge e1" - onContextMenu={e => handleEdgeContextMenu(e as never, edges.find(edge => edge.id === 'e1') as never)} + onContextMenu={(e) => + handleEdgeContextMenu(e as never, edges.find((edge) => edge.id === 'e1') as never) + } > edge-e1 </button> <button type="button" aria-label="Right-click edge e2" - onContextMenu={e => handleEdgeContextMenu(e as never, edges.find(edge => edge.id === 'e2') as never)} + onContextMenu={(e) => + handleEdgeContextMenu(e as never, edges.find((edge) => edge.id === 'e2') as never) + } > edge-e2 </button> @@ -144,7 +144,7 @@ const EdgeMenuHarness = () => { aria-label="Remove edge e1" onClick={() => { const { edges, setEdges } = reactFlowStore.getState() - setEdges(edges.filter(edge => edge.id !== 'e1')) + setEdges(edges.filter((edge) => edge.id !== 'e1')) }} > remove-e1 @@ -263,7 +263,9 @@ describe('EdgeContextmenu', () => { }) expect(await screen.findByRole('menu'))!.toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: /common\.operation\.delete/i }))!.toBeInTheDocument() + expect( + screen.getByRole('menuitem', { name: /common\.operation\.delete/i }), + )!.toBeInTheDocument() }) it('should delete the right-clicked edge and close the menu when delete is clicked', async () => { @@ -280,7 +282,7 @@ describe('EdgeContextmenu', () => { await waitFor(() => { expect(screen.queryByRole('menu')).not.toBeInTheDocument() - expect(latestEdges.map(edge => edge.id)).toEqual(['e1']) + expect(latestEdges.map((edge) => edge.id)).toEqual(['e1']) }) expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') }) @@ -288,37 +290,42 @@ describe('EdgeContextmenu', () => { it.each([ ['Delete', 'Delete'], ['Backspace', 'Backspace'], - ])('should delete the right-clicked edge with %s after switching from a selected node', async (_, key) => { - renderEdgeMenu({ - nodes: [ - createNode({ - id: 'n1', - selected: true, - data: { selected: true, _isBundled: true }, - }), - createNode({ - id: 'n2', - position: { x: 100, y: 0 }, - }), - ], - }) - - fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { - clientX: 240, - clientY: 120, - }) - - expect(await screen.findByRole('menu'))!.toBeInTheDocument() - - fireEvent.keyDown(document.body, { key }) - - await waitFor(() => { - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - expect(latestEdges.map(edge => edge.id)).toEqual(['e1']) - expect(latestNodes.map(node => node.id)).toEqual(['n1', 'n2']) - expect(latestNodes.every(node => !node.selected && !getNodeRuntimeState(node).selected)).toBe(true) - }) - }) + ])( + 'should delete the right-clicked edge with %s after switching from a selected node', + async (_, key) => { + renderEdgeMenu({ + nodes: [ + createNode({ + id: 'n1', + selected: true, + data: { selected: true, _isBundled: true }, + }), + createNode({ + id: 'n2', + position: { x: 100, y: 0 }, + }), + ], + }) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { + clientX: 240, + clientY: 120, + }) + + expect(await screen.findByRole('menu'))!.toBeInTheDocument() + + fireEvent.keyDown(document.body, { key }) + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + expect(latestEdges.map((edge) => edge.id)).toEqual(['e1']) + expect(latestNodes.map((node) => node.id)).toEqual(['n1', 'n2']) + expect( + latestNodes.every((node) => !node.selected && !getNodeRuntimeState(node).selected), + ).toBe(true) + }) + }, + ) it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => { renderEdgeMenu({ @@ -348,13 +355,16 @@ describe('EdgeContextmenu', () => { await waitFor(() => { expect(screen.queryByRole('menu')).not.toBeInTheDocument() - expect(latestEdges.map(edge => edge.id)).toEqual(['e2']) + expect(latestEdges.map((edge) => edge.id)).toEqual(['e2']) expect(latestNodes).toHaveLength(2) - expect(latestNodes.every(node => - !node.selected - && !getNodeRuntimeState(node).selected - && !getNodeRuntimeState(node)._isBundled, - )).toBe(true) + expect( + latestNodes.every( + (node) => + !node.selected && + !getNodeRuntimeState(node).selected && + !getNodeRuntimeState(node)._isBundled, + ), + ).toBe(true) }) }) @@ -376,9 +386,9 @@ describe('EdgeContextmenu', () => { await waitFor(() => { expect(screen.getAllByRole('menu')).toHaveLength(1) - expect(latestEdges.find(edge => edge.id === 'e1')?.selected).toBe(false) - expect(latestEdges.find(edge => edge.id === 'e2')?.selected).toBe(true) - expect(latestEdges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true) + expect(latestEdges.find((edge) => edge.id === 'e1')?.selected).toBe(false) + expect(latestEdges.find((edge) => edge.id === 'e2')?.selected).toBe(true) + expect(latestEdges.every((edge) => !getEdgeRuntimeState(edge)._isBundled)).toBe(true) }) }) @@ -391,7 +401,9 @@ describe('EdgeContextmenu', () => { }) expect(await screen.findByRole('menu'))!.toBeInTheDocument() - fireEvent.click(container.querySelector('button[aria-label="Remove edge e1"]') as HTMLButtonElement) + fireEvent.click( + container.querySelector('button[aria-label="Remove edge e1"]') as HTMLButtonElement, + ) await waitFor(() => { expect(screen.queryByRole('menu')).not.toBeInTheDocument() diff --git a/web/app/components/workflow/__tests__/features.spec.tsx b/web/app/components/workflow/__tests__/features.spec.tsx index 632a4c4e0bb711..5dee84b1e284b6 100644 --- a/web/app/components/workflow/__tests__/features.spec.tsx +++ b/web/app/components/workflow/__tests__/features.spec.tsx @@ -74,42 +74,51 @@ vi.mock('@/app/components/base/features/new-feature-panel', () => ({ onAutoAddPromptVariable: (variables: PromptVariable[]) => void workflowVariables: InputVar[] }) => { - if (!show) - return null + if (!show) return null return ( <section aria-label="new feature panel"> <div>{isChatMode ? 'chat mode' : 'completion mode'}</div> <div>{disabled ? 'panel disabled' : 'panel enabled'}</div> <ul aria-label="workflow variables"> - {workflowVariables.map(variable => ( - <li key={variable.variable}> - {`${variable.label}:${variable.variable}`} - </li> + {workflowVariables.map((variable) => ( + <li key={variable.variable}>{`${variable.label}:${variable.variable}`}</li> ))} </ul> - <button type="button" onClick={onChange}>open features</button> - <button type="button" onClick={onClose}>close features</button> + <button type="button" onClick={onChange}> + open features + </button> + <button type="button" onClick={onClose}> + close features + </button> <button type="button" - onClick={() => onAutoAddPromptVariable([{ - key: 'opening_statement', - name: 'Opening Statement', - type: 'string', - max_length: 200, - required: true, - }])} + onClick={() => + onAutoAddPromptVariable([ + { + key: 'opening_statement', + name: 'Opening Statement', + type: 'string', + max_length: 200, + required: true, + }, + ]) + } > add required variable </button> <button type="button" - onClick={() => onAutoAddPromptVariable([{ - key: 'optional_statement', - name: 'Optional Statement', - type: 'string', - max_length: 120, - }])} + onClick={() => + onAutoAddPromptVariable([ + { + key: 'optional_statement', + name: 'Optional Statement', + type: 'string', + max_length: 120, + }, + ]) + } > add optional variable </button> @@ -128,27 +137,25 @@ const startNode = createStartNode({ const DelayedFeatures = () => { const nodes = useNodes() - if (!nodes.length) - return null + if (!nodes.length) return null return <Features /> } -const renderFeatures = (options?: Omit<NonNullable<Parameters<typeof renderWorkflowFlowComponent>[1]>, 'nodes' | 'edges'>) => { +const renderFeatures = ( + options?: Omit<NonNullable<Parameters<typeof renderWorkflowFlowComponent>[1]>, 'nodes' | 'edges'>, +) => { const mergedInitialStoreState = { appId: 'app-1', ...(options?.initialStoreState || {}), } - return renderWorkflowFlowComponent( - <DelayedFeatures />, - { - nodes: [startNode], - edges: [], - ...options, - initialStoreState: mergedInitialStoreState, - }, - ) + return renderWorkflowFlowComponent(<DelayedFeatures />, { + nodes: [startNode], + edges: [], + ...options, + initialStoreState: mergedInitialStoreState, + }) } describe('Features', () => { @@ -165,7 +172,9 @@ describe('Features', () => { expect(screen.getByText('chat mode')).toBeInTheDocument() expect(screen.getByText('panel enabled')).toBeInTheDocument() - expect(screen.getByRole('list', { name: 'workflow variables' })).toHaveTextContent('Existing Variable:existing_variable') + expect(screen.getByRole('list', { name: 'workflow variables' })).toHaveTextContent( + 'Existing Variable:existing_variable', + ) }) }) diff --git a/web/app/components/workflow/__tests__/fixtures.ts b/web/app/components/workflow/__tests__/fixtures.ts index b7884f28359985..05958e015d6f4d 100644 --- a/web/app/components/workflow/__tests__/fixtures.ts +++ b/web/app/components/workflow/__tests__/fixtures.ts @@ -13,7 +13,9 @@ export function resetFixtureCounters() { } export function createNode( - overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}, + overrides: Omit<Partial<Node>, 'data'> & { + data?: Partial<CommonNodeType> & Record<string, unknown> + } = {}, ): Node { const id = overrides.id ?? `node-${++nodeIdCounter}` const { data: dataOverrides, ...rest } = overrides @@ -35,14 +37,20 @@ export function createNode( } as Node } -export function createStartNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node { +export function createStartNode( + overrides: Omit<Partial<Node>, 'data'> & { + data?: Partial<CommonNodeType> & Record<string, unknown> + } = {}, +): Node { return createNode({ ...overrides, data: { type: BlockEnum.Start, title: 'Start', desc: '', ...overrides.data }, }) } -export function createNodeDataFactory<T extends CommonNodeType & Record<string, unknown>>(defaults: T) { +export function createNodeDataFactory<T extends CommonNodeType & Record<string, unknown>>( + defaults: T, +) { return (overrides: Partial<T> = {}): T => ({ ...defaults, ...overrides, @@ -50,8 +58,13 @@ export function createNodeDataFactory<T extends CommonNodeType & Record<string, } export function createTriggerNode( - triggerType: BlockEnum.TriggerSchedule | BlockEnum.TriggerWebhook | BlockEnum.TriggerPlugin = BlockEnum.TriggerWebhook, - overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}, + triggerType: + | BlockEnum.TriggerSchedule + | BlockEnum.TriggerWebhook + | BlockEnum.TriggerPlugin = BlockEnum.TriggerWebhook, + overrides: Omit<Partial<Node>, 'data'> & { + data?: Partial<CommonNodeType> & Record<string, unknown> + } = {}, ): Node { return createNode({ ...overrides, @@ -59,24 +72,38 @@ export function createTriggerNode( }) } -export function createIterationNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node { +export function createIterationNode( + overrides: Omit<Partial<Node>, 'data'> & { + data?: Partial<CommonNodeType> & Record<string, unknown> + } = {}, +): Node { return createNode({ ...overrides, data: { type: BlockEnum.Iteration, title: 'Iteration', desc: '', ...overrides.data }, }) } -export function createLoopNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node { +export function createLoopNode( + overrides: Omit<Partial<Node>, 'data'> & { + data?: Partial<CommonNodeType> & Record<string, unknown> + } = {}, +): Node { return createNode({ ...overrides, data: { type: BlockEnum.Loop, title: 'Loop', desc: '', ...overrides.data }, }) } -export function createEdge(overrides: Omit<Partial<Edge>, 'data'> & { data?: Partial<CommonEdgeType> & Record<string, unknown> } = {}): Edge { +export function createEdge( + overrides: Omit<Partial<Edge>, 'data'> & { + data?: Partial<CommonEdgeType> & Record<string, unknown> + } = {}, +): Edge { const { data: dataOverrides, ...rest } = overrides return { - id: overrides.id ?? `edge-${overrides.source ?? 'src'}-${overrides.target ?? 'tgt'}-${++edgeIdCounter}`, + id: + overrides.id ?? + `edge-${overrides.source ?? 'src'}-${overrides.target ?? 'tgt'}-${++edgeIdCounter}`, source: 'source-node', target: 'target-node', data: { @@ -109,9 +136,7 @@ export function createWorkflowRunningData( } } -export function createNodeTracing( - overrides?: Partial<NodeTracing>, -): NodeTracing { +export function createNodeTracing(overrides?: Partial<NodeTracing>): NodeTracing { const nodeId = overrides?.node_id ?? 'node-1' return { id: `trace-${nodeId}`, diff --git a/web/app/components/workflow/__tests__/index.spec.tsx b/web/app/components/workflow/__tests__/index.spec.tsx index 5e99baaaad663c..f7f0108be7af97 100644 --- a/web/app/components/workflow/__tests__/index.spec.tsx +++ b/web/app/components/workflow/__tests__/index.spec.tsx @@ -37,7 +37,7 @@ const edges: Edge[] = [ const ContextConsumer = () => { const { store } = useWorkflowHistoryStore() - const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length) + const datasetCount = useDatasetsDetailStore((state) => Object.keys(state.datasetsDetail).length) const reactFlowStore = useStoreApi() return ( @@ -53,17 +53,12 @@ describe('WorkflowWithDefaultContext', () => { it('wires the ReactFlow, workflow history, and datasets detail providers around its children', () => { render( <WorkflowContextProvider> - <WorkflowWithDefaultContext - nodes={nodes} - edges={edges} - > + <WorkflowWithDefaultContext nodes={nodes} edges={edges}> <ContextConsumer /> </WorkflowWithDefaultContext> </WorkflowContextProvider>, ) - expect( - screen.getByText('history:1 datasets:0 reactflow:true'), - ).toBeInTheDocument() + expect(screen.getByText('history:1 datasets:0 reactflow:true')).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/__tests__/model-provider-fixtures.spec.ts b/web/app/components/workflow/__tests__/model-provider-fixtures.spec.ts index 4c728cccf3dc8c..dd49833e353a34 100644 --- a/web/app/components/workflow/__tests__/model-provider-fixtures.spec.ts +++ b/web/app/components/workflow/__tests__/model-provider-fixtures.spec.ts @@ -29,15 +29,19 @@ describe('model-provider-fixtures', () => { }) it('should allow overriding the default model item fields', () => { - expect(createModelItem({ - model: 'bge-large', - status: ModelStatusEnum.disabled, - load_balancing_enabled: true, - })).toEqual(expect.objectContaining({ - model: 'bge-large', - status: ModelStatusEnum.disabled, - load_balancing_enabled: true, - })) + expect( + createModelItem({ + model: 'bge-large', + status: ModelStatusEnum.disabled, + load_balancing_enabled: true, + }), + ).toEqual( + expect.objectContaining({ + model: 'bge-large', + status: ModelStatusEnum.disabled, + load_balancing_enabled: true, + }), + ) }) }) @@ -57,15 +61,19 @@ describe('model-provider-fixtures', () => { model_type: ModelTypeEnum.rerank, }) - expect(createModel({ - provider: 'cohere', - label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, - models: [customModelItem], - })).toEqual(expect.objectContaining({ - provider: 'cohere', - label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, - models: [customModelItem], - })) + expect( + createModel({ + provider: 'cohere', + label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, + models: [customModelItem], + }), + ).toEqual( + expect.objectContaining({ + provider: 'cohere', + label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, + models: [customModelItem], + }), + ) }) }) @@ -78,10 +86,12 @@ describe('model-provider-fixtures', () => { }) it('should allow overriding the default provider selection', () => { - expect(createDefaultModel({ - provider: 'azure_openai', - model: 'text-embedding-3-small', - })).toEqual({ + expect( + createDefaultModel({ + provider: 'azure_openai', + model: 'text-embedding-3-small', + }), + ).toEqual({ provider: 'azure_openai', model: 'text-embedding-3-small', }) @@ -124,25 +134,29 @@ describe('model-provider-fixtures', () => { }) it('should apply provider metadata overrides', () => { - expect(createProviderMeta({ - provider: 'bedrock', - supported_model_types: [ModelTypeEnum.textGeneration], - preferred_provider_type: PreferredProviderTypeEnum.system, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.paid, - quota_configurations: [], - }, - })).toEqual(expect.objectContaining({ - provider: 'bedrock', - supported_model_types: [ModelTypeEnum.textGeneration], - preferred_provider_type: PreferredProviderTypeEnum.system, - system_configuration: { - enabled: false, - current_quota_type: CurrentSystemQuotaTypeEnum.paid, - quota_configurations: [], - }, - })) + expect( + createProviderMeta({ + provider: 'bedrock', + supported_model_types: [ModelTypeEnum.textGeneration], + preferred_provider_type: PreferredProviderTypeEnum.system, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.paid, + quota_configurations: [], + }, + }), + ).toEqual( + expect.objectContaining({ + provider: 'bedrock', + supported_model_types: [ModelTypeEnum.textGeneration], + preferred_provider_type: PreferredProviderTypeEnum.system, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.paid, + quota_configurations: [], + }, + }), + ) }) }) @@ -161,19 +175,23 @@ describe('model-provider-fixtures', () => { }) it('should allow overriding the credential panel state', () => { - expect(createCredentialState({ - variant: 'credits-active', - supportsCredits: true, - showPrioritySwitcher: true, - credits: 12, - credentialName: 'Primary Key', - })).toEqual(expect.objectContaining({ - variant: 'credits-active', - supportsCredits: true, - showPrioritySwitcher: true, - credits: 12, - credentialName: 'Primary Key', - })) + expect( + createCredentialState({ + variant: 'credits-active', + supportsCredits: true, + showPrioritySwitcher: true, + credits: 12, + credentialName: 'Primary Key', + }), + ).toEqual( + expect.objectContaining({ + variant: 'credits-active', + supportsCredits: true, + showPrioritySwitcher: true, + credits: 12, + credentialName: 'Primary Key', + }), + ) }) }) }) diff --git a/web/app/components/workflow/__tests__/model-provider-fixtures.ts b/web/app/components/workflow/__tests__/model-provider-fixtures.ts index 988ed8df64be79..72095f025dfc5b 100644 --- a/web/app/components/workflow/__tests__/model-provider-fixtures.ts +++ b/web/app/components/workflow/__tests__/model-provider-fixtures.ts @@ -82,7 +82,9 @@ export function createProviderMeta(overrides: Partial<ModelProvider> = {}): Mode } } -export function createCredentialState(overrides: Partial<CredentialPanelState> = {}): CredentialPanelState { +export function createCredentialState( + overrides: Partial<CredentialPanelState> = {}, +): CredentialPanelState { return { variant: 'api-active', priority: 'apiKeyOnly', diff --git a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx index 10b9437f7db703..8c1753e6d45a8e 100644 --- a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx @@ -13,7 +13,9 @@ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { contextMenuTarget?: { type: 'node', nodeId: string } }) => unknown) => mockUseStore(selector), + useStore: ( + selector: (state: { contextMenuTarget?: { type: 'node'; nodeId: string } }) => unknown, + ) => mockUseStore(selector), })) vi.mock('@/app/components/workflow/node-actions-menu/use-node-actions-menu-model', () => ({ @@ -22,52 +24,60 @@ vi.mock('@/app/components/workflow/node-actions-menu/use-node-actions-menu-model describe('NodeContextmenu', () => { const mockClose = vi.fn() - let contextMenuTarget: { type: 'node', nodeId: string } | undefined + let contextMenuTarget: { type: 'node'; nodeId: string } | undefined let nodes: Node[] beforeEach(() => { vi.clearAllMocks() contextMenuTarget = undefined - nodes = [{ - id: 'node-1', - type: 'custom', - position: { x: 0, y: 0 }, - data: { - title: 'Node 1', - desc: '', - type: 'code' as never, - }, - } as Node] + nodes = [ + { + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + title: 'Node 1', + desc: '', + type: 'code' as never, + }, + } as Node, + ] mockUseNodes.mockImplementation(() => nodes) - mockUseStore.mockImplementation((selector: (state: { contextMenuTarget?: { type: 'node', nodeId: string } }) => unknown) => selector({ contextMenuTarget })) - mockUseNodeActionsMenuModel.mockImplementation((props: { id: string, data: Node['data'], onClose: () => void }) => ({ - about: { - author: 'Dify', - description: 'Node actions', - }, - canChangeBlock: false, - canRun: false, - data: props.data, - handleCopy: props.onClose, - handleDelete: props.onClose, - handleDuplicate: props.onClose, - handleRun: props.onClose, - helpLinkUri: undefined, - id: props.id, - isSingleton: false, - isUndeletable: false, - nodesReadOnly: false, - sourceHandle: 'source', - workflowAppHref: undefined, - })) + mockUseStore.mockImplementation( + (selector: (state: { contextMenuTarget?: { type: 'node'; nodeId: string } }) => unknown) => + selector({ contextMenuTarget }), + ) + mockUseNodeActionsMenuModel.mockImplementation( + (props: { id: string; data: Node['data']; onClose: () => void }) => ({ + about: { + author: 'Dify', + description: 'Node actions', + }, + canChangeBlock: false, + canRun: false, + data: props.data, + handleCopy: props.onClose, + handleDelete: props.onClose, + handleDuplicate: props.onClose, + handleRun: props.onClose, + helpLinkUri: undefined, + id: props.id, + isSingleton: false, + isUndeletable: false, + nodesReadOnly: false, + sourceHandle: 'source', + workflowAppHref: undefined, + }), + ) }) - const renderNodeContextmenu = () => render( - <ContextMenu open> - <NodeContextmenu onClose={mockClose} /> - </ContextMenu>, - ) + const renderNodeContextmenu = () => + render( + <ContextMenu open> + <NodeContextmenu onClose={mockClose} /> + </ContextMenu>, + ) it('should stay hidden when the node menu is absent', () => { renderNodeContextmenu() @@ -90,11 +100,13 @@ describe('NodeContextmenu', () => { renderNodeContextmenu() expect(screen.getByText('WORKFLOW.PANEL.ABOUT')).toBeInTheDocument() - expect(mockUseNodeActionsMenuModel).toHaveBeenCalledWith(expect.objectContaining({ - id: 'node-1', - data: expect.objectContaining({ title: 'Node 1' }), - showHelpLink: true, - })) + expect(mockUseNodeActionsMenuModel).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'node-1', + data: expect.objectContaining({ title: 'Node 1' }), + showHelpLink: true, + }), + ) fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.common\.copy/i })) diff --git a/web/app/components/workflow/__tests__/reactflow-mock-state.ts b/web/app/components/workflow/__tests__/reactflow-mock-state.ts index 7e31a8c3c102b0..aa56cc78000a17 100644 --- a/web/app/components/workflow/__tests__/reactflow-mock-state.ts +++ b/web/app/components/workflow/__tests__/reactflow-mock-state.ts @@ -15,7 +15,7 @@ import * as React from 'react' type MockNode = { id: string - position: { x: number, y: number } + position: { x: number; y: number } width?: number | null height?: number | null parentId?: string @@ -90,13 +90,15 @@ export function createReactFlowModuleMock() { setNodes: rfState.setNodes, setEdges: rfState.setEdges, getViewport: () => ({ x: 0, y: 0, zoom: 1 }), - screenToFlowPosition: (pos: { x: number, y: number }) => pos, - flowToScreenPosition: (pos: { x: number, y: number }) => pos, + screenToFlowPosition: (pos: { x: number; y: number }) => pos, + flowToScreenPosition: (pos: { x: number; y: number }) => pos, deleteElements: vi.fn(), addNodes: vi.fn(), addEdges: vi.fn(), getNode: vi.fn(), - toObject: vi.fn().mockReturnValue({ nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }), + toObject: vi + .fn() + .mockReturnValue({ nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }), viewportInitialized: true, })), diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index 050c7d845f4d4c..4335452c836cec 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -55,7 +55,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -81,7 +82,11 @@ vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => { vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ default: (props: { isOpen: boolean - selectedGraph?: { nodes: Node[], edges: Edge[], viewport: { x: number, y: number, zoom: number } } + selectedGraph?: { + nodes: Node[] + edges: Edge[] + viewport: { x: number; y: number; zoom: number } + } inputFields?: Array<{ variable: string }> }) => { mockCreateSnippetDialogRender(props) @@ -249,21 +254,27 @@ describe('SelectionContextmenu', () => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) - fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })) + fireEvent.click( + await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ }), + ) expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument() expect(store.getState().contextMenuTarget).toBeUndefined() const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0] expect(dialogProps.selectedGraph.nodes.map((node: Node) => node.id)).toEqual(['n1', 'n2']) - expect(dialogProps.selectedGraph.nodes.every((node: Node) => node.selected === false)).toBe(true) + expect(dialogProps.selectedGraph.nodes.every((node: Node) => node.selected === false)).toBe( + true, + ) expect(dialogProps.selectedGraph.edges).toHaveLength(1) expect(dialogProps.selectedGraph.viewport).toEqual({ x: 490, y: 380, zoom: 1 }) - expect(dialogProps.selectedGraph.edges[0]).toEqual(expect.objectContaining({ - source: 'n1', - target: 'n2', - selected: false, - })) + expect(dialogProps.selectedGraph.edges[0]).toEqual( + expect.objectContaining({ + source: 'n1', + target: 'n2', + selected: false, + }), + ) }) it('should hide create snippet action without snippets create-and-modify permission', async () => { @@ -281,7 +292,9 @@ describe('SelectionContextmenu', () => { await waitFor(() => { expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument() }) - expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ }), + ).not.toBeInTheDocument() }) it('should add input fields for variable references outside of the selected graph', async () => { @@ -311,7 +324,9 @@ describe('SelectionContextmenu', () => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) - fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })) + fireEvent.click( + await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ }), + ) const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0] expect(dialogProps.inputFields).toEqual([ @@ -328,36 +343,40 @@ describe('SelectionContextmenu', () => { required: true, }, ]) - expect(dialogProps.selectedGraph.nodes[0].data.prompt_template).toBe('Use {{#start.topic#}} and {{#n2.answer#}}') - expect(dialogProps.selectedGraph.nodes[0].data.query_variable_selector).toEqual(['start', 'topic']) + expect(dialogProps.selectedGraph.nodes[0].data.prompt_template).toBe( + 'Use {{#start.topic#}} and {{#n2.answer#}}', + ) + expect(dialogProps.selectedGraph.nodes[0].data.query_variable_selector).toEqual([ + 'start', + 'topic', + ]) expect(dialogProps.selectedGraph.nodes[0].data.env_reference).toBe('{{#start.API_KEY#}}') }) - it.each([ - BlockEnum.Answer, - BlockEnum.End, - BlockEnum.Start, - ])('should hide create snippet when selection contains %s node', async (nodeType) => { - const nodes = [ - createNode({ id: 'n1', selected: true, width: 80, height: 40, data: { type: nodeType } }), - createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), - ] - const { store } = renderSelectionMenu({ nodes }) - - act(() => { - store.setState({ contextMenuTarget: { type: 'selection' } }) - }) - - await waitFor(() => { - expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument() - }) - expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).not.toBeInTheDocument() - }) + it.each([BlockEnum.Answer, BlockEnum.End, BlockEnum.Start])( + 'should hide create snippet when selection contains %s node', + async (nodeType) => { + const nodes = [ + createNode({ id: 'n1', selected: true, width: 80, height: 40, data: { type: nodeType } }), + createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), + ] + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ contextMenuTarget: { type: 'selection' } }) + }) + + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument() + }) + expect( + screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ }), + ).not.toBeInTheDocument() + }, + ) it('should stay hidden when only one node is selected', async () => { - const nodes = [ - createNode({ id: 'n1', selected: true, width: 80, height: 40 }), - ] + const nodes = [createNode({ id: 'n1', selected: true, width: 80, height: 40 })] const { store } = renderSelectionMenu({ nodes }) @@ -392,8 +411,8 @@ describe('SelectionContextmenu', () => { fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) - expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(20) - expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(20) + expect(latestNodes.find((node) => node.id === 'n1')?.position.x).toBe(20) + expect(latestNodes.find((node) => node.id === 'n2')?.position.x).toBe(20) expect(store.getState().contextMenuTarget).toBeUndefined() expect(store.getState().helpLineHorizontal).toBeUndefined() expect(store.getState().helpLineVertical).toBeUndefined() @@ -425,7 +444,7 @@ describe('SelectionContextmenu', () => { fireEvent.click(screen.getByTestId('selection-contextmenu-item-distributeHorizontal')) - expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(150) + expect(latestNodes.find((node) => node.id === 'n2')?.position.x).toBe(150) }) it('should ignore child nodes when the selected container is aligned', async () => { @@ -464,9 +483,9 @@ describe('SelectionContextmenu', () => { fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) - expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(40) - expect(latestNodes.find(node => node.id === 'other')?.position.x).toBe(40) - expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(210) + expect(latestNodes.find((node) => node.id === 'container')?.position.x).toBe(40) + expect(latestNodes.find((node) => node.id === 'other')?.position.x).toBe(40) + expect(latestNodes.find((node) => node.id === 'child')?.position.x).toBe(210) }) it('should cancel when align bounds cannot be resolved', () => { @@ -502,8 +521,8 @@ describe('SelectionContextmenu', () => { fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) expect(store.getState().contextMenuTarget).toBeUndefined() - expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(0) - expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(80) + expect(latestNodes.find((node) => node.id === 'n1')?.position.x).toBe(0) + expect(latestNodes.find((node) => node.id === 'n2')?.position.x).toBe(80) }) it('should cancel when alignable nodes shrink to one item', () => { @@ -515,7 +534,13 @@ describe('SelectionContextmenu', () => { height: 20, data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] }, }), - createNode({ id: 'child', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }), + createNode({ + id: 'child', + selected: true, + position: { x: 80, y: 20 }, + width: 40, + height: 20, + }), ] const { store } = renderSelectionMenu({ nodes }) @@ -527,7 +552,7 @@ describe('SelectionContextmenu', () => { fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) expect(store.getState().contextMenuTarget).toBeUndefined() - expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(0) - expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(80) + expect(latestNodes.find((node) => node.id === 'container')?.position.x).toBe(0) + expect(latestNodes.find((node) => node.id === 'child')?.position.x).toBe(80) }) }) diff --git a/web/app/components/workflow/__tests__/trigger-status-sync.spec.tsx b/web/app/components/workflow/__tests__/trigger-status-sync.spec.tsx index 76be431aa7e935..2425a7b711a702 100644 --- a/web/app/components/workflow/__tests__/trigger-status-sync.spec.tsx +++ b/web/app/components/workflow/__tests__/trigger-status-sync.spec.tsx @@ -8,8 +8,8 @@ import { useTriggerStatusStore } from '../store/trigger-status' import { isTriggerNode } from '../types' // Mock the isTriggerNode function while preserving BlockEnum -vi.mock('../types', async importOriginal => ({ - ...await importOriginal<typeof import('../types')>(), +vi.mock('../types', async (importOriginal) => ({ + ...(await importOriginal<typeof import('../types')>()), isTriggerNode: vi.fn(), })) @@ -20,15 +20,15 @@ const TestTriggerNode: React.FC<{ nodeId: string nodeType: string }> = ({ nodeId, nodeType }) => { - const triggerStatus = useTriggerStatusStore(state => - mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', + const triggerStatus = useTriggerStatusStore((state) => + mockIsTriggerNode(nodeType as BlockEnum) + ? state.triggerStatuses[nodeId] || 'disabled' + : 'enabled', ) return ( <div data-testid={`node-${nodeId}`} data-status={triggerStatus}> - Status: - {' '} - {triggerStatus} + Status: {triggerStatus} </div> ) } @@ -48,25 +48,21 @@ const TestTriggerController: React.FC = () => { return ( <div> - <button - data-testid="toggle-node-1" - onClick={() => handleToggle('node-1', true)} - > + <button data-testid="toggle-node-1" onClick={() => handleToggle('node-1', true)}> Enable Node 1 </button> - <button - data-testid="toggle-node-2" - onClick={() => handleToggle('node-2', false)} - > + <button data-testid="toggle-node-2" onClick={() => handleToggle('node-2', false)}> Disable Node 2 </button> <button data-testid="batch-update" - onClick={() => handleBatchUpdate({ - 'node-1': 'disabled', - 'node-2': 'enabled', - 'node-3': 'enabled', - })} + onClick={() => + handleBatchUpdate({ + 'node-1': 'disabled', + 'node-2': 'enabled', + 'node-3': 'enabled', + }) + } > Batch Update </button> @@ -276,21 +272,24 @@ describe('Trigger Status Synchronization Integration', () => { nodeId: string nodeType: string }> = ({ nodeId, nodeType }) => { - const triggerStatusSelector = useCallback((state: { triggerStatuses: Record<string, string> }) => - mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', [nodeId, nodeType]) + const triggerStatusSelector = useCallback( + (state: { triggerStatuses: Record<string, string> }) => + mockIsTriggerNode(nodeType as BlockEnum) + ? state.triggerStatuses[nodeId] || 'disabled' + : 'enabled', + [nodeId, nodeType], + ) const triggerStatus = useTriggerStatusStore(triggerStatusSelector) return ( <div data-testid={`optimized-node-${nodeId}`} data-status={triggerStatus}> - Status: - {' '} - {triggerStatus} + Status: {triggerStatus} </div> ) } it('should work correctly with optimized selector using useCallback', () => { - mockIsTriggerNode.mockImplementation(nodeType => nodeType === 'trigger-webhook') + mockIsTriggerNode.mockImplementation((nodeType) => nodeType === 'trigger-webhook') const { getByTestId } = render( <> @@ -315,12 +314,14 @@ describe('Trigger Status Synchronization Integration', () => { }) it('should handle selector dependency changes correctly', () => { - mockIsTriggerNode.mockImplementation(nodeType => nodeType === 'trigger-webhook') + mockIsTriggerNode.mockImplementation((nodeType) => nodeType === 'trigger-webhook') const TestComponent: React.FC<{ nodeType: string }> = ({ nodeType }) => { const triggerStatusSelector = useCallback( (state: { triggerStatuses: Record<string, string> }) => - mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled', + mockIsTriggerNode(nodeType as BlockEnum) + ? state.triggerStatuses['test-node'] || 'disabled' + : 'enabled', [nodeType], ) const status = useTriggerStatusStore(triggerStatusSelector) diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts index 176431a74d288b..b6c3d61345503a 100644 --- a/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts +++ b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts @@ -38,23 +38,33 @@ workflow: it('should reject malformed yaml and answer nodes in non-advanced mode', () => { expect(validateDSLContent('[', AppModeEnum.CHAT)).toBe(false) - expect(validateDSLContent(` + expect( + validateDSLContent( + ` workflow: graph: nodes: - data: type: answer -`, AppModeEnum.CHAT)).toBe(false) +`, + AppModeEnum.CHAT, + ), + ).toBe(false) }) it('should accept valid node types for advanced chat mode', () => { - expect(validateDSLContent(` + expect( + validateDSLContent( + ` workflow: graph: nodes: - data: type: tool -`, AppModeEnum.ADVANCED_CHAT)).toBe(true) +`, + AppModeEnum.ADVANCED_CHAT, + ), + ).toBe(true) }) it('should accept empty yaml content', () => { diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx index f461c70927d40f..ec746bd1efbfc1 100644 --- a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx +++ b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx @@ -10,7 +10,9 @@ class MockFileReader { onload: ((this: FileReader, event: ProgressEvent<FileReader>) => void) | null = null readAsText(_file: Blob) { - const event = { target: { result: 'workflow:\n graph:\n nodes:\n - data:\n type: tool\n' } } as unknown as ProgressEvent<FileReader> + const event = { + target: { result: 'workflow:\n graph:\n nodes:\n - data:\n type: tool\n' }, + } as unknown as ProgressEvent<FileReader> this.onload?.call(this as unknown as FileReader, event) } } @@ -54,12 +56,13 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { appDetail: { id: string, mode: string } }) => unknown) => selector({ - appDetail: { - id: 'app-1', - mode: 'chat', - }, - }), + useStore: (selector: (state: { appDetail: { id: string; mode: string } }) => unknown) => + selector({ + appDetail: { + id: 'app-1', + mode: 'chat', + }, + }), })) vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ @@ -67,7 +70,7 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ <input data-testid="dsl-file-input" type="file" - onChange={event => updateFile(event.target.files?.[0])} + onChange={(event) => updateFile(event.target.files?.[0])} /> ), })) @@ -115,7 +118,9 @@ describe('UpdateDSLModal', () => { it('should keep import disabled until a file is selected', () => { renderModal() - expect(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })).toBeDisabled() + expect( + screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }), + ).toBeDisabled() }) it('should call backup handler from the warning area', () => { @@ -154,15 +159,19 @@ describe('UpdateDSLModal', () => { fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) await waitFor(() => { - expect(mockImportDSL).toHaveBeenCalledWith(expect.objectContaining({ - app_id: 'app-1', - yaml_content: expect.stringContaining('workflow:'), - })) - }) - - expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ - type: 'WORKFLOW_DATA_UPDATE', - })) + expect(mockImportDSL).toHaveBeenCalledWith( + expect.objectContaining({ + app_id: 'app-1', + yaml_content: expect.stringContaining('workflow:'), + }), + ) + }) + + expect(mockEmit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'WORKFLOW_DATA_UPDATE', + }), + ) expect(mockEmitWorkflowUpdate).toHaveBeenCalledWith('app-1') expect(defaultProps.onImport).toHaveBeenCalledTimes(1) expect(defaultProps.onCancel).toHaveBeenCalledTimes(1) @@ -235,9 +244,12 @@ describe('UpdateDSLModal', () => { expect(mockImportDSL).toHaveBeenCalled() }) - await waitFor(() => { - expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() - }, { timeout: 1000 }) + await waitFor( + () => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }, + { timeout: 1000 }, + ) fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Cancel' })) @@ -275,7 +287,11 @@ describe('UpdateDSLModal', () => { it('should show an error when the selected file content is invalid for the current app mode', async () => { class InvalidDSLFileReader extends MockFileReader { override readAsText(_file: Blob) { - const event = { target: { result: 'workflow:\n graph:\n nodes:\n - data:\n type: answer\n' } } as unknown as ProgressEvent<FileReader> + const event = { + target: { + result: 'workflow:\n graph:\n nodes:\n - data:\n type: answer\n', + }, + } as unknown as ProgressEvent<FileReader> this.onload?.call(this as unknown as FileReader, event) } } diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index b741b7619683ac..49ec9dead7d471 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -25,21 +25,23 @@ const reactFlowBridge = vi.hoisted(() => ({ })) const collaborationBridge = vi.hoisted(() => ({ - graphImportHandler: null as null | ((payload: { nodes: Node[], edges: Edge[] }) => void), + graphImportHandler: null as null | ((payload: { nodes: Node[]; edges: Edge[] }) => void), historyActionHandler: null as null | ((payload: unknown) => void), - restoreIntentHandler: null as null | ((payload: { - versionId: string - versionName?: string - initiatorUserId: string - initiatorName: string - }) => void), + restoreIntentHandler: null as + | null + | ((payload: { + versionId: string + versionName?: string + initiatorUserId: string + initiatorName: string + }) => void), })) const toastInfoMock = vi.hoisted(() => vi.fn()) const workflowCommentState = vi.hoisted(() => ({ comments: [] as Array<Record<string, unknown>>, - pendingComment: null as null | { elementX: number, elementY: number }, + pendingComment: null as null | { elementX: number; elementY: number }, activeComment: null as null | Record<string, unknown>, activeCommentLoading: false, replySubmitting: false, @@ -98,26 +100,30 @@ function createInitializedNode(id: string, x: number, label: string) { [internalsSymbol]: { positionAbsolute: { x, y: 0 }, handleBounds: { - source: [{ - id: null, - nodeId: id, - type: 'source', - position: Position.Right, - x: 160, - y: 0, - width: 0, - height: 40, - }], - target: [{ - id: null, - nodeId: id, - type: 'target', - position: Position.Left, - x: 0, - y: 0, - width: 0, - height: 40, - }], + source: [ + { + id: null, + nodeId: id, + type: 'source', + position: Position.Right, + x: 160, + y: 0, + width: 0, + height: 40, + }, + ], + target: [ + { + id: null, + nodeId: id, + type: 'target', + position: Position.Left, + x: 0, + y: 0, + width: 0, + height: 40, + }, + ], }, z: 0, }, @@ -181,7 +187,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ vi.mock('../collaboration/core/collaboration-manager', () => ({ collaborationManager: { - onGraphImport: (handler: (payload: { nodes: Node[], edges: Edge[] }) => void) => { + onGraphImport: (handler: (payload: { nodes: Node[]; edges: Edge[] }) => void) => { collaborationBridge.graphImportHandler = handler return vi.fn() }, @@ -205,7 +211,7 @@ vi.mock('../comment/cursor', () => ({ })) vi.mock('../comment/comment-input', () => ({ - CommentInput: ({ disabled, onCancel }: { disabled?: boolean, onCancel?: () => void }) => ( + CommentInput: ({ disabled, onCancel }: { disabled?: boolean; onCancel?: () => void }) => ( <button type="button" data-testid={disabled ? 'comment-input-preview' : 'comment-input-active'} @@ -224,7 +230,7 @@ vi.mock('../comment/comment-icon', () => ({ }: { comment: { id: string } onClick?: () => void - onPositionUpdate?: (position: { elementX: number, elementY: number }) => void + onPositionUpdate?: (position: { elementX: number; elementY: number }) => void }) => ( <button type="button" @@ -250,9 +256,15 @@ vi.mock('../comment/thread', () => ({ onNext?: () => void }) => ( <div data-testid="comment-thread"> - <button type="button" onClick={onDelete}>delete-thread</button> - <button type="button" onClick={() => onReplyDelete?.('reply-1')}>delete-reply</button> - <button type="button" onClick={onNext}>next-comment</button> + <button type="button" onClick={onDelete}> + delete-thread + </button> + <button type="button" onClick={() => onReplyDelete?.('reply-1')}> + delete-reply + </button> + <button type="button" onClick={onNext}> + next-comment + </button> </div> ), })) @@ -274,16 +286,19 @@ vi.mock('../base/confirm', () => ({ desc?: string onConfirm: () => void onCancel: () => void - }) => isShow - ? ( - <div role="alertdialog" data-testid="confirm-dialog"> - {title && <div>{title}</div>} - {desc && <div>{desc}</div>} - <button type="button" onClick={onConfirm}>common.operation.confirm</button> - <button type="button" onClick={onCancel}>common.operation.cancel</button> - </div> - ) - : null, + }) => + isShow ? ( + <div role="alertdialog" data-testid="confirm-dialog"> + {title && <div>{title}</div>} + {desc && <div>{desc}</div>} + <button type="button" onClick={onConfirm}> + common.operation.confirm + </button> + <button type="button" onClick={onCancel}> + common.operation.cancel + </button> + </div> + ) : null, })) vi.mock('../candidate-node', () => ({ @@ -295,10 +310,11 @@ vi.mock('../custom-connection-line', () => ({ })) vi.mock('../custom-edge', () => ({ - default: () => React.createElement(BaseEdge, { - id: 'edge-1', - path: 'M 0 0 L 100 0', - }), + default: () => + React.createElement(BaseEdge, { + id: 'edge-1', + path: 'M 0 0 L 100 0', + }), })) vi.mock('../help-line', () => ({ @@ -306,7 +322,8 @@ vi.mock('../help-line', () => ({ })) vi.mock('../nodes', () => ({ - default: ({ id }: { id: string }) => React.createElement('div', { 'data-testid': `workflow-node-${id}` }, `Workflow node ${id}`), + default: ({ id }: { id: string }) => + React.createElement('div', { 'data-testid': `workflow-node-${id}` }, `Workflow node ${id}`), })) vi.mock('../nodes/data-source-empty', () => ({ @@ -432,10 +449,7 @@ function renderSubject(options?: { return renderWorkflowComponent( <ReactFlowProvider> - <Workflow - nodes={nodes} - edges={edges} - > + <Workflow nodes={nodes} edges={edges}> <ReactFlowEdgeBootstrap nodes={nodes} edges={edges} /> </Workflow> </ReactFlowProvider>, @@ -452,7 +466,7 @@ function renderSubject(options?: { ) } -function ReactFlowEdgeBootstrap({ nodes, edges }: { nodes: Node[], edges: Edge[] }) { +function ReactFlowEdgeBootstrap({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) { const store = useStoreApi() React.useEffect(() => { @@ -460,7 +474,7 @@ function ReactFlowEdgeBootstrap({ nodes, edges }: { nodes: Node[], edges: Edge[] edges, width: 500, height: 500, - nodeInternals: new Map(nodes.map(node => [node.id, node])), + nodeInternals: new Map(nodes.map((node) => [node.id, node])), }) reactFlowBridge.store = store @@ -475,8 +489,7 @@ function ReactFlowEdgeBootstrap({ nodes, edges }: { nodes: Node[], edges: Edge[] function getPane(container: HTMLElement) { const pane = container.querySelector('.react-flow__pane') as HTMLElement | null - if (!pane) - throw new Error('Expected a rendered React Flow pane') + if (!pane) throw new Error('Expected a rendered React Flow pane') return pane } @@ -511,21 +524,28 @@ describe('Workflow edge event wiring', () => { }) act(() => { - reactFlowBridge.store?.getState().onEdgesChange?.([{ id: 'edge-1', type: 'select', selected: true }]) + reactFlowBridge.store + ?.getState() + .onEdgesChange?.([{ id: 'edge-1', type: 'select', selected: true }]) }) await waitFor(() => { - expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ id: 'edge-1', type: 'select' }), - ])) - expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith(expect.objectContaining({ - clientX: 24, - clientY: 48, - }), expect.objectContaining({ id: 'node-1' })) - expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith(expect.objectContaining({ - clientX: 24, - clientY: 48, - })) + expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'edge-1', type: 'select' })]), + ) + expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith( + expect.objectContaining({ + clientX: 24, + clientY: 48, + }), + expect.objectContaining({ id: 'node-1' }), + ) + expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith( + expect.objectContaining({ + clientX: 24, + clientY: 48, + }), + ) }) }) @@ -546,9 +566,9 @@ describe('Workflow edge event wiring', () => { await waitFor(() => { expect(screen.getByText('Workflow node node-1')).toBeInTheDocument() }) - expect(workflowHookMocks.handleEdgesChange).not.toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ id: 'edge-1', type: 'remove' }), - ])) + expect(workflowHookMocks.handleEdgesChange).not.toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'edge-1', type: 'remove' })]), + ) }) it('should clear context menu target when workflow data updates', () => { @@ -616,7 +636,9 @@ describe('Workflow edge event wiring', () => { it('should sync graph import events and show history action toast', async () => { renderSubject() - const importedNodes = [createInitializedNode('node-3', 480, 'Workflow node node-3')] as unknown as Node[] + const importedNodes = [ + createInitializedNode('node-3', 480, 'Workflow node node-3'), + ] as unknown as Node[] act(() => { collaborationBridge.graphImportHandler?.({ @@ -691,7 +713,10 @@ describe('Workflow edge event wiring', () => { act(() => { fireEvent.click(screen.getByRole('button', { name: 'next-comment' })) }) - expect(workflowCommentState.handleCommentIconClick).toHaveBeenCalledWith({ id: 'comment-2', resolved: false }) + expect(workflowCommentState.handleCommentIconClick).toHaveBeenCalledWith({ + id: 'comment-2', + resolved: false, + }) act(() => { fireEvent.click(screen.getByRole('button', { name: 'delete-thread' })) @@ -710,7 +735,10 @@ describe('Workflow edge event wiring', () => { await act(async () => { await store.getState().showConfirm?.onConfirm() }) - expect(workflowCommentState.handleCommentReplyDelete).toHaveBeenCalledWith('comment-1', 'reply-1') + expect(workflowCommentState.handleCommentReplyDelete).toHaveBeenCalledWith( + 'comment-1', + 'reply-1', + ) const wheelEvent = new WheelEvent('wheel', { cancelable: true, diff --git a/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx b/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx index 3172cc401e4282..7f92445e90bcc1 100644 --- a/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx @@ -50,9 +50,7 @@ const createWrapper = () => { workflowStore.temporal.getState().resume() return ({ children }: { children: React.ReactNode }) => ( - <WorkflowContext.Provider value={workflowStore}> - {children} - </WorkflowContext.Provider> + <WorkflowContext.Provider value={workflowStore}>{children}</WorkflowContext.Provider> ) } diff --git a/web/app/components/workflow/__tests__/workflow-test-env.spec.tsx b/web/app/components/workflow/__tests__/workflow-test-env.spec.tsx index 739fd01410ed1e..529a2aadc502d7 100644 --- a/web/app/components/workflow/__tests__/workflow-test-env.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-test-env.spec.tsx @@ -21,8 +21,12 @@ import { // --------------------------------------------------------------------------- function StoreReader() { - const showConfirm = useStore(s => s.showConfirm) - return React.createElement('div', { 'data-testid': 'store-reader' }, showConfirm ? 'has-confirm' : 'no-confirm') + const showConfirm = useStore((s) => s.showConfirm) + return React.createElement( + 'div', + { 'data-testid': 'store-reader' }, + showConfirm ? 'has-confirm' : 'no-confirm', + ) } function StoreWriter() { @@ -31,18 +35,19 @@ function StoreWriter() { 'button', { 'data-testid': 'store-writer', - 'onClick': () => store.setState({ showConfirm: { title: 'Test', onConfirm: () => {} } } as Partial<Shape>), + onClick: () => + store.setState({ showConfirm: { title: 'Test', onConfirm: () => {} } } as Partial<Shape>), }, 'Write', ) } function HooksStoreReader() { - const flowId = useHooksStore(s => s.configsMap?.flowId ?? 'none') + const flowId = useHooksStore((s) => s.configsMap?.flowId ?? 'none') return React.createElement('div', { 'data-testid': 'hooks-reader' }, flowId) } -function NodeRenderer(props: { id: string, data: { title: string }, selected?: boolean }) { +function NodeRenderer(props: { id: string; data: { title: string }; selected?: boolean }) { return React.createElement( 'div', { 'data-testid': 'node-render' }, @@ -52,8 +57,12 @@ function NodeRenderer(props: { id: string, data: { title: string }, selected?: b function FlowReader() { const nodes = useNodes() - const showConfirm = useStore(s => s.showConfirm) - return React.createElement('div', { 'data-testid': 'flow-reader' }, `${nodes.length}:${showConfirm ? 'confirm' : 'clear'}`) + const showConfirm = useStore((s) => s.showConfirm) + return React.createElement( + 'div', + { 'data-testid': 'flow-reader' }, + `${nodes.length}:${showConfirm ? 'confirm' : 'clear'}`, + ) } // --------------------------------------------------------------------------- @@ -75,7 +84,12 @@ describe('renderWorkflowComponent', () => { it('should return a live store that components can mutate', () => { const { store } = renderWorkflowComponent( - React.createElement(React.Fragment, null, React.createElement(StoreReader), React.createElement(StoreWriter)), + React.createElement( + React.Fragment, + null, + React.createElement(StoreReader), + React.createElement(StoreWriter), + ), ) expect(store.getState().showConfirm).toBeUndefined() @@ -90,7 +104,9 @@ describe('renderWorkflowComponent', () => { it('should provide HooksStoreContext when hooksStoreProps given', () => { renderWorkflowComponent(React.createElement(HooksStoreReader), { - hooksStoreProps: { configsMap: { flowId: 'test-123', flowType: FlowType.appFlow, fileSettings: {} } }, + hooksStoreProps: { + configsMap: { flowId: 'test-123', flowType: FlowType.appFlow, fileSettings: {} }, + }, }) expect(screen.getByTestId('hooks-reader'))!.toHaveTextContent('test-123') }) @@ -107,8 +123,7 @@ describe('renderWorkflowComponent', () => { try { renderWorkflowComponent(React.createElement(StoreReader), { container }) expect(container.querySelector('[data-testid="store-reader"]')).toBeTruthy() - } - finally { + } finally { document.body.removeChild(container) } }) @@ -121,22 +136,34 @@ describe('renderNodeComponent', () => { }) it('should accept custom nodeId and selected', () => { - renderNodeComponent(NodeRenderer, { title: 'World' }, { - nodeId: 'custom-42', - selected: true, - }) + renderNodeComponent( + NodeRenderer, + { title: 'World' }, + { + nodeId: 'custom-42', + selected: true, + }, + ) expect(screen.getByTestId('node-render'))!.toHaveTextContent('custom-42:World:sel') }) it('should provide WorkflowContext to node components', () => { - function NodeWithStore(props: { id: string, data: Record<string, unknown> }) { - const controlMode = useStore(s => s.controlMode) - return React.createElement('div', { 'data-testid': 'node-store' }, `${props.id}:${controlMode}`) + function NodeWithStore(props: { id: string; data: Record<string, unknown> }) { + const controlMode = useStore((s) => s.controlMode) + return React.createElement( + 'div', + { 'data-testid': 'node-store' }, + `${props.id}:${controlMode}`, + ) } - renderNodeComponent(NodeWithStore, {}, { - initialStoreState: { controlMode: 'hand' as Shape['controlMode'] }, - }) + renderNodeComponent( + NodeWithStore, + {}, + { + initialStoreState: { controlMode: 'hand' as Shape['controlMode'] }, + }, + ) expect(screen.getByTestId('node-store'))!.toHaveTextContent('test-node-1:hand') }) }) @@ -144,10 +171,7 @@ describe('renderNodeComponent', () => { describe('renderWorkflowFlowComponent', () => { it('should provide both ReactFlow and Workflow contexts', () => { renderWorkflowFlowComponent(React.createElement(FlowReader), { - nodes: [ - createNode({ id: 'n-1' }), - createNode({ id: 'n-2' }), - ], + nodes: [createNode({ id: 'n-1' }), createNode({ id: 'n-2' })], initialStoreState: { showConfirm: { title: 'Hey', onConfirm: () => {} } }, }) @@ -158,9 +182,7 @@ describe('renderWorkflowFlowComponent', () => { describe('renderWorkflowFlowHook', () => { it('should render hooks inside a real ReactFlow provider', () => { const { result } = renderWorkflowFlowHook(() => useNodes(), { - nodes: [ - createNode({ id: 'flow-1' }), - ], + nodes: [createNode({ id: 'flow-1' })], }) expect(result.current).toHaveLength(1) diff --git a/web/app/components/workflow/__tests__/workflow-test-env.tsx b/web/app/components/workflow/__tests__/workflow-test-env.tsx index 7c46887d3ef8d5..c0966dd2701ff6 100644 --- a/web/app/components/workflow/__tests__/workflow-test-env.tsx +++ b/web/app/components/workflow/__tests__/workflow-test-env.tsx @@ -60,7 +60,12 @@ * }) * ``` */ -import type { RenderHookOptions, RenderHookResult, RenderOptions, RenderResult } from '@testing-library/react' +import type { + RenderHookOptions, + RenderHookResult, + RenderOptions, + RenderResult, +} from '@testing-library/react' import type { Shape as HooksStoreShape } from '../hooks-store/store' import type { Shape } from '../store/workflow' import type { WorkflowHistoryState } from '../store/workflow/history-slice' @@ -106,15 +111,13 @@ type HooksStore = ReturnType<typeof createHooksStore> export function createTestWorkflowStore(initialState?: Partial<Shape>): WorkflowStore { const store = createWorkflowStore({}) - if (initialState) - store.setState(initialState) + if (initialState) store.setState(initialState) return store } function createTestHooksStore(props?: Partial<HooksStoreShape>): HooksStore { const store = createHooksStore(props ?? {}) - if (props) - store.setState(props) + if (props) store.setState(props) return store } @@ -157,37 +160,29 @@ function createWorkflowWrapper( stores.store.temporal.getState().resume() } - const queryClient = externalQueryClient ?? new QueryClient({ - defaultOptions: { - queries: { - retry: false, + const queryClient = + externalQueryClient ?? + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, }, - }, - }) - if (!externalQueryClient) - seedSystemFeatures(queryClient) - if (!externalQueryClient) - seedAppDslVersion(queryClient) + }) + if (!externalQueryClient) seedSystemFeatures(queryClient) + if (!externalQueryClient) seedAppDslVersion(queryClient) return ({ children }: { children: React.ReactNode }) => { let inner: React.ReactNode = children if (stores.hooksStore) { - inner = React.createElement( - HooksStoreContext.Provider, - { value: stores.hooksStore }, - inner, - ) + inner = React.createElement(HooksStoreContext.Provider, { value: stores.hooksStore }, inner) } return React.createElement( QueryClientProvider, { client: queryClient }, - React.createElement( - WorkflowContext.Provider, - { value: stores.store }, - inner, - ), + React.createElement(WorkflowContext.Provider, { value: stores.store }, inner), ) } } @@ -212,7 +207,13 @@ export function renderWorkflowHook<R, P = undefined>( hook: (props: P) => R, options?: WorkflowHookTestOptions<P>, ): WorkflowHookTestResult<R, P> { - const { initialStoreState, hooksStoreProps, historyStore: historyConfig, queryClient, ...rest } = options ?? {} + const { + initialStoreState, + hooksStoreProps, + historyStore: historyConfig, + queryClient, + ...rest + } = options ?? {} const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps }) const wrapper = createWorkflowWrapper(stores, historyConfig, queryClient) @@ -241,7 +242,13 @@ export function renderWorkflowComponent( ui: React.ReactElement, options?: WorkflowComponentTestOptions, ): WorkflowComponentTestResult { - const { initialStoreState, hooksStoreProps, historyStore: historyConfig, queryClient, ...renderOptions } = options ?? {} + const { + initialStoreState, + hooksStoreProps, + historyStore: historyConfig, + queryClient, + ...renderOptions + } = options ?? {} const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps }) const wrapper = createWorkflowWrapper(stores, historyConfig, queryClient) @@ -276,20 +283,21 @@ function createWorkflowFlowWrapper( ) { const workflowWrapper = createWorkflowWrapper(stores, historyConfig) - return ({ children }: { children: React.ReactNode }) => React.createElement( - workflowWrapper, - null, + return ({ children }: { children: React.ReactNode }) => React.createElement( - 'div', - { style: { width: 800, height: 600, ...canvasStyle } }, + workflowWrapper, + null, React.createElement( - ReactFlowProvider, - null, - React.createElement(ReactFlow, { fitView: true, ...reactFlowProps, nodes, edges }), - children, + 'div', + { style: { width: 800, height: 600, ...canvasStyle } }, + React.createElement( + ReactFlowProvider, + null, + React.createElement(ReactFlow, { fitView: true, ...reactFlowProps, nodes, edges }), + children, + ), ), - ), - ) + ) } export function renderWorkflowFlowComponent( diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index afb2acdaa114f1..2a890ff47fcb5f 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -35,7 +35,7 @@ type BlockIconProps = { type: BlockEnum size?: string className?: string - toolIcon?: string | { content: string, background: string } + toolIcon?: string | { content: string; background: string } } const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record<string, string> = { xs: 'w-4 h-4 rounded-[5px] shadow-xs', @@ -80,8 +80,7 @@ const DEFAULT_ICON_MAP: Record<BlockEnum, React.ComponentType<{ className: strin const getIcon = (type: BlockEnum, className: string) => { const DefaultIcon = DEFAULT_ICON_MAP[type] - if (!DefaultIcon) - return null + if (!DefaultIcon) return null return <DefaultIcon className={className} /> } @@ -90,8 +89,7 @@ const normalizeToolIconUrl = (toolIcon: string) => { const protectedPluginIconPath = '/workspaces/current/plugin/icon' const pathIndex = toolIcon.indexOf(protectedPluginIconPath) - if (pathIndex < 0) - return toolIcon + if (pathIndex < 0) return toolIcon return `${API_PREFIX}${toolIcon.slice(pathIndex)}` } @@ -127,27 +125,22 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = { [BlockEnum.TriggerWebhook]: 'bg-util-colors-blue-blue-500', [BlockEnum.TriggerPlugin]: 'bg-util-colors-blue-blue-500', } -const BlockIcon: FC<BlockIconProps> = ({ - type, - size = 'sm', - className, - toolIcon, -}) => { +const BlockIcon: FC<BlockIconProps> = ({ type, size = 'sm', className, toolIcon }) => { const isStart = type === BlockEnum.Start const isStartPlaceholder = type === BlockEnum.StartPlaceholder - const isToolOrDataSourceOrTriggerPlugin = type === BlockEnum.Tool || type === BlockEnum.DataSource || type === BlockEnum.TriggerPlugin + const isToolOrDataSourceOrTriggerPlugin = + type === BlockEnum.Tool || type === BlockEnum.DataSource || type === BlockEnum.TriggerPlugin const showDefaultIcon = !isToolOrDataSourceOrTriggerPlugin || !toolIcon - const resolvedToolIcon = typeof toolIcon === 'string' - ? normalizeToolIconUrl(toolIcon) - : toolIcon + const resolvedToolIcon = typeof toolIcon === 'string' ? normalizeToolIconUrl(toolIcon) : toolIcon if (isStart) { return ( - <div className={cn( - 'flex items-center justify-center border-[0.5px] border-white/2 bg-util-colors-blue-brand-blue-brand-500 text-white', - ICON_CONTAINER_CLASSNAME_SIZE_MAP[size], - className, - )} + <div + className={cn( + 'flex items-center justify-center border-[0.5px] border-white/2 bg-util-colors-blue-brand-blue-brand-500 text-white', + ICON_CONTAINER_CLASSNAME_SIZE_MAP[size], + className, + )} > <span aria-hidden @@ -159,77 +152,70 @@ const BlockIcon: FC<BlockIconProps> = ({ if (isStartPlaceholder) { return ( - <div className={cn( - 'flex items-center justify-center border border-dashed border-components-panel-border bg-state-base-hover text-text-tertiary shadow-none', - ICON_CONTAINER_CLASSNAME_SIZE_MAP[size], - className, - )} + <div + className={cn( + 'flex items-center justify-center border border-dashed border-components-panel-border bg-state-base-hover text-text-tertiary shadow-none', + ICON_CONTAINER_CLASSNAME_SIZE_MAP[size], + className, + )} > <span aria-hidden - className={cn('i-custom-vender-workflow-start-placeholder text-text-primary opacity-30', size === 'xs' ? 'size-3' : 'size-3.5')} + className={cn( + 'i-custom-vender-workflow-start-placeholder text-text-primary opacity-30', + size === 'xs' ? 'size-3' : 'size-3.5', + )} /> </div> ) } return ( - <div className={ - cn( + <div + className={cn( 'flex items-center justify-center border-[0.5px] border-white/2 text-white', ICON_CONTAINER_CLASSNAME_SIZE_MAP[size], showDefaultIcon && ICON_CONTAINER_BG_COLOR_MAP[type], toolIcon && 'shadow-none!', className, - ) - } + )} > - { - showDefaultIcon && ( - getIcon(type, (type === BlockEnum.TriggerSchedule || type === BlockEnum.TriggerWebhook) - ? (size === 'xs' ? 'w-4 h-4' : 'w-4.5 h-4.5') - : (size === 'xs' ? 'w-3 h-3' : 'w-3.5 h-3.5')) - ) - } - { - !showDefaultIcon && ( - <> - { - typeof resolvedToolIcon === 'string' - ? ( - <div - className="size-full shrink-0 rounded-md bg-cover bg-center" - style={{ - backgroundImage: `url(${resolvedToolIcon})`, - }} - > - </div> - ) - : ( - <AppIcon - className="size-full! shrink-0" - size="tiny" - icon={resolvedToolIcon?.content} - background={resolvedToolIcon?.background} - /> - ) - } - </> - ) - } + {showDefaultIcon && + getIcon( + type, + type === BlockEnum.TriggerSchedule || type === BlockEnum.TriggerWebhook + ? size === 'xs' + ? 'w-4 h-4' + : 'w-4.5 h-4.5' + : size === 'xs' + ? 'w-3 h-3' + : 'w-3.5 h-3.5', + )} + {!showDefaultIcon && ( + <> + {typeof resolvedToolIcon === 'string' ? ( + <div + className="size-full shrink-0 rounded-md bg-cover bg-center" + style={{ + backgroundImage: `url(${resolvedToolIcon})`, + }} + ></div> + ) : ( + <AppIcon + className="size-full! shrink-0" + size="tiny" + icon={resolvedToolIcon?.content} + background={resolvedToolIcon?.background} + /> + )} + </> + )} </div> ) } -export const VarBlockIcon: FC<BlockIconProps> = ({ - type, - className, -}) => { - return ( - <> - {getIcon(type, `w-3 h-3 ${className}`)} - </> - ) +export const VarBlockIcon: FC<BlockIconProps> = ({ type, className }) => { + return <>{getIcon(type, `w-3 h-3 ${className}`)}</> } export default memo(BlockIcon) diff --git a/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx index cedfd3cbc754db..d0070ab75197b6 100644 --- a/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx @@ -53,8 +53,7 @@ vi.mock('@/utils/var', async (importOriginal) => { getMarketplaceUrl: (path = '', params?: Record<string, string | undefined>) => { const searchParams = new URLSearchParams() Object.entries(params || {}).forEach(([key, value]) => { - if (value) - searchParams.set(key, value) + if (value) searchParams.set(key, value) }) const query = searchParams.toString() return `https://marketplace.test${path}${query ? `?${query}` : ''}` @@ -74,9 +73,13 @@ const mockUseAvailableNodesMetaData = vi.mocked(useAvailableNodesMetaData) type UseMarketplacePluginsReturn = ReturnType<typeof useMarketplacePlugins> type UseAllTriggerPluginsReturn = ReturnType<typeof useAllTriggerPlugins> -type UseFeaturedTriggersRecommendationsReturn = ReturnType<typeof useFeaturedTriggersRecommendations> +type UseFeaturedTriggersRecommendationsReturn = ReturnType< + typeof useFeaturedTriggersRecommendations +> -const createTriggerProvider = (overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider => ({ +const createTriggerProvider = ( + overrides: Partial<TriggerWithProvider> = {}, +): TriggerWithProvider => ({ id: 'provider-1', name: 'provider-one', author: 'Provider Author', @@ -112,7 +115,9 @@ const createTriggerProvider = (overrides: Partial<TriggerWithProvider> = {}): Tr let enableMarketplaceForRender = false const render = (ui: ReactElement) => - renderWithSystemFeatures(ui, { systemFeatures: { enable_marketplace: enableMarketplaceForRender } }) + renderWithSystemFeatures(ui, { + systemFeatures: { enable_marketplace: enableMarketplaceForRender }, + }) const createMarketplacePluginsMock = ( overrides: Partial<UseMarketplacePluginsReturn> = {}, @@ -131,36 +136,35 @@ const createMarketplacePluginsMock = ( ...overrides, }) -const createTriggerPluginsQueryResult = ( - data: TriggerWithProvider[], -): UseAllTriggerPluginsReturn => ({ - data, - error: null, - isError: false, - isPending: false, - isLoading: false, - isSuccess: true, - isFetching: false, - isRefetching: false, - isLoadingError: false, - isRefetchError: false, - isInitialLoading: false, - isPaused: false, - isEnabled: true, - status: 'success', - fetchStatus: 'idle', - dataUpdatedAt: Date.now(), - errorUpdatedAt: 0, - failureCount: 0, - failureReason: null, - errorUpdateCount: 0, - isFetched: true, - isFetchedAfterMount: true, - isPlaceholderData: false, - isStale: false, - refetch: vi.fn(), - promise: Promise.resolve(data), -} as UseAllTriggerPluginsReturn) +const createTriggerPluginsQueryResult = (data: TriggerWithProvider[]): UseAllTriggerPluginsReturn => + ({ + data, + error: null, + isError: false, + isPending: false, + isLoading: false, + isSuccess: true, + isFetching: false, + isRefetching: false, + isLoadingError: false, + isRefetchError: false, + isInitialLoading: false, + isPaused: false, + isEnabled: true, + status: 'success', + fetchStatus: 'idle', + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + promise: Promise.resolve(data), + }) as UseAllTriggerPluginsReturn const createFeaturedTriggersRecommendationsMock = ( overrides: Partial<UseFeaturedTriggersRecommendationsReturn> = {}, @@ -170,9 +174,10 @@ const createFeaturedTriggersRecommendationsMock = ( ...overrides, }) -const createAvailableNodesMetaData = (): ReturnType<typeof useAvailableNodesMetaData> => ({ - nodes: [], -} as unknown as ReturnType<typeof useAvailableNodesMetaData>) +const createAvailableNodesMetaData = (): ReturnType<typeof useAvailableNodesMetaData> => + ({ + nodes: [], + }) as unknown as ReturnType<typeof useAvailableNodesMetaData> describe('AllStartBlocks', () => { beforeEach(() => { @@ -182,9 +187,13 @@ describe('AllStartBlocks', () => { mockUseLocale.mockReturnValue('en_US') mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>) mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock()) - mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([createTriggerProvider()])) + mockUseAllTriggerPlugins.mockReturnValue( + createTriggerPluginsQueryResult([createTriggerProvider()]), + ) mockUseInvalidateAllTriggerPlugins.mockReturnValue(vi.fn()) - mockUseFeaturedTriggersRecommendations.mockReturnValue(createFeaturedTriggersRecommendationsMock()) + mockUseFeaturedTriggersRecommendations.mockReturnValue( + createFeaturedTriggersRecommendationsMock(), + ) mockUseNodes.mockReturnValue([]) mockUseAvailableNodesMetaData.mockReturnValue(createAvailableNodesMetaData()) }) @@ -220,10 +229,13 @@ describe('AllStartBlocks', () => { await user.click(screen.getByText('Provider One')) await user.click(screen.getByText('Created')) - expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({ - provider_id: 'provider-one', - event_name: 'created', - })) + expect(onSelect).toHaveBeenCalledWith( + BlockEnum.TriggerPlugin, + expect.objectContaining({ + provider_id: 'provider-one', + event_name: 'created', + }), + ) }) it('should show marketplace footer when marketplace is enabled without filters', async () => { @@ -239,7 +251,14 @@ describe('AllStartBlocks', () => { const footer = await screen.findByRole('link', { name: /plugin\.findMoreInMarketplace/ }) expect(footer).toHaveAttribute('href', 'https://marketplace.test/plugins/trigger') - expect(footer).toHaveClass('system-sm-medium', 'h-8', 'rounded-b-lg', 'bg-components-panel-bg-blur', 'text-text-accent-light-mode-only', 'shadow-lg') + expect(footer).toHaveClass( + 'system-sm-medium', + 'h-8', + 'rounded-b-lg', + 'bg-components-panel-bg-blur', + 'text-text-accent-light-mode-only', + 'shadow-lg', + ) expect(footer.querySelector('.i-custom-vender-main-nav-marketplace')).not.toBeInTheDocument() expect(footer.querySelector('svg')).toBeInTheDocument() }) @@ -256,7 +275,9 @@ describe('AllStartBlocks', () => { />, ) - const footer = await screen.findByRole('link', { name: /workflow\.nodes\.startPlaceholder\.browseMoreOnMarketplace/ }) + const footer = await screen.findByRole('link', { + name: /workflow\.nodes\.startPlaceholder\.browseMoreOnMarketplace/, + }) expect(footer).toHaveAttribute('href', 'https://marketplace.test/plugins/trigger') expect(footer).toHaveClass('flex-col') expect(footer.querySelector('.w-8 .bg-divider-subtle')).toBeInTheDocument() @@ -284,19 +305,23 @@ describe('AllStartBlocks', () => { it('should render searched marketplace results after built-in and installed trigger options', async () => { enableMarketplaceForRender = true - mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([ - createTriggerProvider({ - label: { en_US: 'Start Provider', zh_Hans: 'Start Provider' }, - }), - ])) - mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({ - plugins: [ - createPlugin({ - name: 'start-marketplace', - label: { en_US: 'Start Marketplace', zh_Hans: 'Start Marketplace' }, + mockUseAllTriggerPlugins.mockReturnValue( + createTriggerPluginsQueryResult([ + createTriggerProvider({ + label: { en_US: 'Start Provider', zh_Hans: 'Start Provider' }, }), - ], - })) + ]), + ) + mockUseMarketplacePlugins.mockReturnValue( + createMarketplacePluginsMock({ + plugins: [ + createPlugin({ + name: 'start-marketplace', + label: { en_US: 'Start Marketplace', zh_Hans: 'Start Marketplace' }, + }), + ], + }), + ) const { container } = render( <AllStartBlocks @@ -339,12 +364,21 @@ describe('AllStartBlocks', () => { />, ) - expect(screen.getByText('workflow.nodes.startPlaceholder.userInputConflictTip')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.startPlaceholder.userInputConflictTip'), + ).toBeInTheDocument() expect(screen.queryByText('workflow.tabs.allTriggers')).not.toBeInTheDocument() expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument() expect(screen.getByText('common.operation.added')).toBeInTheDocument() const footer = screen.getByRole('link', { name: /plugin\.findMoreInMarketplace/ }) - expect(footer).toHaveClass('system-sm-medium', 'h-8', 'rounded-b-lg', 'bg-components-panel-bg-blur', 'text-text-accent-light-mode-only', 'shadow-lg') + expect(footer).toHaveClass( + 'system-sm-medium', + 'h-8', + 'rounded-b-lg', + 'bg-components-panel-bg-blur', + 'text-text-accent-light-mode-only', + 'shadow-lg', + ) expect(footer.querySelector('.i-custom-vender-main-nav-marketplace')).not.toBeInTheDocument() expect(footer.querySelector('svg')).toBeInTheDocument() @@ -369,12 +403,18 @@ describe('AllStartBlocks', () => { expect(screen.queryByText('workflow.tabs.allTriggers')).not.toBeInTheDocument() expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument() - expect(screen.getByText('workflow.blocks.mostCommon').closest('.opacity-30')).toBeInTheDocument() - expect(screen.getByText('workflow.blocks.start').closest('.cursor-not-allowed')).toBeInTheDocument() + expect( + screen.getByText('workflow.blocks.mostCommon').closest('.opacity-30'), + ).toBeInTheDocument() + expect( + screen.getByText('workflow.blocks.start').closest('.cursor-not-allowed'), + ).toBeInTheDocument() await user.hover(screen.getByText('workflow.blocks.start')) - expect(await screen.findByText('workflow.nodes.startPlaceholder.userInputConflictTip')).toBeInTheDocument() + expect( + await screen.findByText('workflow.nodes.startPlaceholder.userInputConflictTip'), + ).toBeInTheDocument() fireEvent.click(screen.getByText('workflow.blocks.start')) expect(onSelect).not.toHaveBeenCalled() @@ -389,9 +429,11 @@ describe('AllStartBlocks', () => { it('should query marketplace and show the no-results state when filters have no matches', async () => { const queryPluginsWithDebounced = vi.fn() enableMarketplaceForRender = true - mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({ - queryPluginsWithDebounced, - })) + mockUseMarketplacePlugins.mockReturnValue( + createMarketplacePluginsMock({ + queryPluginsWithDebounced, + }), + ) mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([])) render( @@ -411,8 +453,12 @@ describe('AllStartBlocks', () => { }) }) - expect(screen.getByText('workflow.nodes.startPlaceholder.noTriggersFound')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'workflow.tabs.requestToCommunity' })).toHaveAttribute( + expect( + screen.getByText('workflow.nodes.startPlaceholder.noTriggersFound'), + ).toBeInTheDocument() + expect( + screen.getByRole('link', { name: 'workflow.tabs.requestToCommunity' }), + ).toHaveAttribute( 'href', 'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml', ) diff --git a/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx b/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx index 5bb062f72661ac..aab04fbaf8f88a 100644 --- a/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx @@ -28,7 +28,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability' }), })) -vi.mock('@/utils/var', async importOriginal => ({ +vi.mock('@/utils/var', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/utils/var')>()), getMarketplaceUrl: (path = '') => `https://marketplace.test${path}`, })) @@ -70,15 +70,19 @@ describe('AllTools', () => { searchText="" tags={[]} onSelect={vi.fn()} - buildInTools={[createToolProvider({ - id: 'provider-built-in', - label: { en_US: 'Built In Provider', zh_Hans: 'Built In Provider' }, - })]} - customTools={[createToolProvider({ - id: 'provider-custom', - type: 'custom', - label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' }, - })]} + buildInTools={[ + createToolProvider({ + id: 'provider-built-in', + label: { en_US: 'Built In Provider', zh_Hans: 'Built In Provider' }, + }), + ]} + customTools={[ + createToolProvider({ + id: 'provider-custom', + type: 'custom', + label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' }, + }), + ]} workflowTools={[]} mcpTools={[]} />, @@ -101,25 +105,33 @@ describe('AllTools', () => { searchText="" tags={[]} onSelect={vi.fn()} - buildInTools={[createToolProvider({ - id: 'provider-built-in', - label: { en_US: 'Built In Provider', zh_Hans: 'Built In Provider' }, - })]} - customTools={[createToolProvider({ - id: 'provider-custom', - type: CollectionType.custom, - label: { en_US: 'Swagger Provider', zh_Hans: 'Swagger Provider' }, - })]} - workflowTools={[createToolProvider({ - id: 'provider-workflow', - type: CollectionType.workflow, - label: { en_US: 'Workflow Provider', zh_Hans: 'Workflow Provider' }, - })]} - mcpTools={[createToolProvider({ - id: 'provider-mcp', - type: CollectionType.mcp, - label: { en_US: 'MCP Provider', zh_Hans: 'MCP Provider' }, - })]} + buildInTools={[ + createToolProvider({ + id: 'provider-built-in', + label: { en_US: 'Built In Provider', zh_Hans: 'Built In Provider' }, + }), + ]} + customTools={[ + createToolProvider({ + id: 'provider-custom', + type: CollectionType.custom, + label: { en_US: 'Swagger Provider', zh_Hans: 'Swagger Provider' }, + }), + ]} + workflowTools={[ + createToolProvider({ + id: 'provider-workflow', + type: CollectionType.workflow, + label: { en_US: 'Workflow Provider', zh_Hans: 'Workflow Provider' }, + }), + ]} + mcpTools={[ + createToolProvider({ + id: 'provider-mcp', + type: CollectionType.mcp, + label: { en_US: 'MCP Provider', zh_Hans: 'MCP Provider' }, + }), + ]} />, ) diff --git a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx index 3e33e62c80d4ab..a5d5d57bc07b0d 100644 --- a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx @@ -32,11 +32,12 @@ vi.mock('reactflow', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => selector({ - appDetail: { - type: runtimeState.appType, - }, - }), + useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => + selector({ + appDetail: { + type: runtimeState.appType, + }, + }), })) vi.mock('@/service/base', () => ({ @@ -108,7 +109,9 @@ const createJsonResponse = (body: unknown) => }) const mockInviteOptionsResponse = (agents: AgentInviteOptionResponse[]) => { - queryMocks.request.mockImplementation(() => Promise.resolve(createJsonResponse(createInviteOptionsResponse(agents)))) + queryMocks.request.mockImplementation(() => + Promise.resolve(createJsonResponse(createInviteOptionsResponse(agents))), + ) } const expectLastInviteOptionsRequest = () => { @@ -204,7 +207,9 @@ describe('Blocks', () => { await user.hover(agentBlock) - expect(await screen.findByRole('dialog', { name: 'agentV2.roster.nodeSelector.dialogLabel' })).toBeInTheDocument() + expect( + await screen.findByRole('dialog', { name: 'agentV2.roster.nodeSelector.dialogLabel' }), + ).toBeInTheDocument() }) it('opens the agent selector from the Agent block and selects an agent', async () => { @@ -249,12 +254,15 @@ describe('Blocks', () => { ) expect( - screen.getByText('Agent').compareDocumentPosition(screen.getByText('LLM')) & Node.DOCUMENT_POSITION_FOLLOWING, + screen.getByText('Agent').compareDocumentPosition(screen.getByText('LLM')) & + Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() await user.click(screen.getByRole('button', { name: /Agent/ })) - expect(await screen.findByRole('dialog', { name: 'agentV2.roster.nodeSelector.dialogLabel' })).toBeInTheDocument() + expect( + await screen.findByRole('dialog', { name: 'agentV2.roster.nodeSelector.dialogLabel' }), + ).toBeInTheDocument() expect(screen.getByRole('combobox', { name: 'agentV2.roster.searchLabel' })).toBeInTheDocument() expect(await screen.findByText('Nadia')).toBeInTheDocument() expect(screen.getByText('Researcher')).toBeInTheDocument() @@ -278,19 +286,31 @@ describe('Blocks', () => { it('should refresh Agent v2 roster options when the selector is reopened', async () => { const user = userEvent.setup() queryMocks.request - .mockImplementationOnce(() => Promise.resolve(createJsonResponse(createInviteOptionsResponse([ - createInviteOption({ - id: 'agent-1', - name: 'Nadia', - }), - ])))) - .mockImplementation(() => Promise.resolve(createJsonResponse(createInviteOptionsResponse([ - createInviteOption({ - id: 'agent-2', - name: 'Bruno', - role: 'Planner', - }), - ])))) + .mockImplementationOnce(() => + Promise.resolve( + createJsonResponse( + createInviteOptionsResponse([ + createInviteOption({ + id: 'agent-1', + name: 'Nadia', + }), + ]), + ), + ), + ) + .mockImplementation(() => + Promise.resolve( + createJsonResponse( + createInviteOptionsResponse([ + createInviteOption({ + id: 'agent-2', + name: 'Bruno', + role: 'Planner', + }), + ]), + ), + ), + ) const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -326,7 +346,9 @@ describe('Blocks', () => { await user.click(screen.getByRole('combobox', { name: 'agentV2.roster.searchLabel' })) await user.keyboard('{Escape}') await waitFor(() => { - expect(screen.queryByRole('dialog', { name: 'agentV2.roster.nodeSelector.dialogLabel' })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: 'agentV2.roster.nodeSelector.dialogLabel' }), + ).not.toBeInTheDocument() }) await user.click(screen.getByRole('button', { name: /Agent/ })) @@ -381,7 +403,9 @@ describe('Blocks', () => { await user.click(screen.getByRole('option', { name: 'Nadia Researcher' })) - await waitFor(() => expect(queryMocks.toastError).toHaveBeenCalledWith('workflow.nodes.agent.modelNotSelected')) + await waitFor(() => + expect(queryMocks.toastError).toHaveBeenCalledWith('workflow.nodes.agent.modelNotSelected'), + ) expect(onSelect).not.toHaveBeenCalled() }) @@ -418,11 +442,15 @@ describe('Blocks', () => { ) await user.click(screen.getByRole('button', { name: /Agent/ })) - const consoleLink = await screen.findByRole('option', { name: 'agentV2.roster.nodeSelector.manageInAgentConsole' }) + const consoleLink = await screen.findByRole('option', { + name: 'agentV2.roster.nodeSelector.manageInAgentConsole', + }) expect(consoleLink).toHaveAttribute('href', '/agents') expect(consoleLink).toHaveAttribute('target', '_blank') expect(consoleLink).toHaveAttribute('rel', 'noopener noreferrer') - await user.click(await screen.findByRole('option', { name: 'agentV2.roster.nodeSelector.startFromScratch' })) + await user.click( + await screen.findByRole('option', { name: 'agentV2.roster.nodeSelector.startFromScratch' }), + ) expect(onSelect).toHaveBeenCalledWith(BlockEnum.AgentV2, { agent_binding: { @@ -466,13 +494,17 @@ describe('Blocks', () => { await user.click(screen.getByRole('button', { name: /Agent/ })) - expect(await screen.findByRole('dialog', { name: 'agentV2.roster.nodeSelector.dialogLabel' })).toBeInTheDocument() + expect( + await screen.findByRole('dialog', { name: 'agentV2.roster.nodeSelector.dialogLabel' }), + ).toBeInTheDocument() await user.click(screen.getByRole('combobox', { name: 'agentV2.roster.searchLabel' })) await user.keyboard('{Escape}') await waitFor(() => { - expect(screen.queryByRole('dialog', { name: 'agentV2.roster.nodeSelector.dialogLabel' })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: 'agentV2.roster.nodeSelector.dialogLabel' }), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx b/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx index 0d242dbf78fde2..08c4f2f7a97db1 100644 --- a/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx @@ -30,7 +30,9 @@ const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins) let enableMarketplaceForRender = false const render = (ui: ReactElement) => - renderWithSystemFeatures(ui, { systemFeatures: { enable_marketplace: enableMarketplaceForRender } }) + renderWithSystemFeatures(ui, { + systemFeatures: { enable_marketplace: enableMarketplaceForRender }, + }) type UseMarketplacePluginsReturn = ReturnType<typeof useMarketplacePlugins> @@ -94,23 +96,20 @@ describe('DataSources', () => { const user = userEvent.setup() const onSelect = vi.fn() - render( - <DataSources - searchText="" - onSelect={onSelect} - dataSources={[createToolProvider()]} - />, - ) + render(<DataSources searchText="" onSelect={onSelect} dataSources={[createToolProvider()]} />) await user.click(screen.getByText('File Source')) await user.click(screen.getByText('Local File')) - expect(onSelect).toHaveBeenCalledWith(BlockEnum.DataSource, expect.objectContaining({ - provider_name: 'file', - datasource_name: 'local-file', - datasource_label: 'Local File', - fileExtensions: expect.arrayContaining(['txt', 'pdf', 'md']), - })) + expect(onSelect).toHaveBeenCalledWith( + BlockEnum.DataSource, + expect.objectContaining({ + provider_name: 'file', + datasource_name: 'local-file', + datasource_label: 'Local File', + fileExtensions: expect.arrayContaining(['txt', 'pdf', 'md']), + }), + ) }) it('should filter providers by search text', () => { @@ -123,15 +122,17 @@ describe('DataSources', () => { id: 'searchable-provider', name: 'searchable-provider', label: { en_US: 'Searchable Source', zh_Hans: '可搜索源' }, - tools: [{ - name: 'searchable-tool', - author: 'Dify', - label: { en_US: 'Searchable Tool', zh_Hans: '可搜索工具' }, - description: { en_US: 'desc', zh_Hans: '描述' }, - parameters: [], - labels: [], - output_schema: {}, - }], + tools: [ + { + name: 'searchable-tool', + author: 'Dify', + label: { en_US: 'Searchable Tool', zh_Hans: '可搜索工具' }, + description: { en_US: 'desc', zh_Hans: '描述' }, + parameters: [], + labels: [], + output_schema: {}, + }, + ], }), createToolProvider({ id: 'other-provider', @@ -152,18 +153,14 @@ describe('DataSources', () => { it('should query marketplace plugins for datasource search results', async () => { const queryPluginsWithDebounced = vi.fn() enableMarketplaceForRender = true - mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({ - queryPluginsWithDebounced, - })) - - render( - <DataSources - searchText="invoice" - onSelect={vi.fn()} - dataSources={[]} - />, + mockUseMarketplacePlugins.mockReturnValue( + createMarketplacePluginsMock({ + queryPluginsWithDebounced, + }), ) + render(<DataSources searchText="invoice" onSelect={vi.fn()} dataSources={[]} />) + await waitFor(() => { expect(queryPluginsWithDebounced).toHaveBeenCalledWith({ query: 'invoice', diff --git a/web/app/components/workflow/block-selector/__tests__/featured-tools.spec.tsx b/web/app/components/workflow/block-selector/__tests__/featured-tools.spec.tsx index b66c06281044cd..4179e304a9bba5 100644 --- a/web/app/components/workflow/block-selector/__tests__/featured-tools.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/featured-tools.spec.tsx @@ -20,7 +20,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability' }), })) -vi.mock('@/utils/var', async importOriginal => ({ +vi.mock('@/utils/var', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/utils/var')>()), getMarketplaceUrl: (path = '') => `https://marketplace.test${path}`, })) @@ -43,7 +43,8 @@ describe('FeaturedTools', () => { plugin_id: `plugin-${index + 1}`, latest_package_identifier: `plugin-${index + 1}@1.0.0`, label: { en_US: `Plugin ${index + 1}`, zh_Hans: `Plugin ${index + 1}` }, - })) + }), + ) const providers = plugins.map((plugin, index) => createToolProvider({ id: `provider-${index + 1}`, @@ -51,15 +52,9 @@ describe('FeaturedTools', () => { label: { en_US: `Provider ${index + 1}`, zh_Hans: `Provider ${index + 1}` }, }), ) - const providerMap = new Map(providers.map(provider => [provider.plugin_id!, provider])) + const providerMap = new Map(providers.map((provider) => [provider.plugin_id!, provider])) - render( - <FeaturedTools - plugins={plugins} - providerMap={providerMap} - onSelect={vi.fn()} - />, - ) + render(<FeaturedTools plugins={plugins} providerMap={providerMap} onSelect={vi.fn()} />) expect(screen.getByText('Provider 1')).toBeInTheDocument() expect(screen.queryByText('Provider 6')).not.toBeInTheDocument() @@ -75,10 +70,7 @@ describe('FeaturedTools', () => { render( <FeaturedTools plugins={[createPlugin()]} - providerMap={new Map([[ - 'plugin-1', - createToolProvider(), - ]])} + providerMap={new Map([['plugin-1', createToolProvider()]])} onSelect={vi.fn()} />, ) @@ -88,13 +80,7 @@ describe('FeaturedTools', () => { }) it('shows the marketplace empty state when no featured tools are available', () => { - render( - <FeaturedTools - plugins={[]} - providerMap={new Map()} - onSelect={vi.fn()} - />, - ) + render(<FeaturedTools plugins={[]} providerMap={new Map()} onSelect={vi.fn()} />) expect(screen.getByText('workflow.tabs.noFeaturedPlugins')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/block-selector/__tests__/featured-triggers.spec.tsx b/web/app/components/workflow/block-selector/__tests__/featured-triggers.spec.tsx index 30c6e98ae6c575..9ce73671659178 100644 --- a/web/app/components/workflow/block-selector/__tests__/featured-triggers.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/featured-triggers.spec.tsx @@ -57,7 +57,9 @@ const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({ ...overrides, }) -const createTriggerProvider = (overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider => ({ +const createTriggerProvider = ( + overrides: Partial<TriggerWithProvider> = {}, +): TriggerWithProvider => ({ id: 'provider-1', name: 'provider-one', author: 'Provider Author', @@ -102,42 +104,39 @@ describe('FeaturedTriggers', () => { it('should persist collapse state in localStorage', async () => { const user = userEvent.setup() - render( - <FeaturedTriggers - plugins={[]} - providerMap={new Map()} - onSelect={vi.fn()} - />, - ) + render(<FeaturedTriggers plugins={[]} providerMap={new Map()} onSelect={vi.fn()} />) await user.click(screen.getByRole('button', { name: /workflow\.tabs\.featuredTools/ })) - expect(screen.queryByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' })).not.toBeInTheDocument() - expect(globalThis.localStorage.setItem).toHaveBeenCalledWith('workflow_triggers_featured_collapsed', 'true') + expect( + screen.queryByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' }), + ).not.toBeInTheDocument() + expect(globalThis.localStorage.setItem).toHaveBeenCalledWith( + 'workflow_triggers_featured_collapsed', + 'true', + ) }) it('should show more and show less across installed providers', async () => { const user = userEvent.setup() - const providers = Array.from({ length: 6 }).map((_, index) => createTriggerProvider({ - id: `provider-${index}`, - name: `provider-${index}`, - label: { en_US: `Provider ${index}`, zh_Hans: `提供商${index}` }, - plugin_id: `plugin-${index}`, - plugin_unique_identifier: `plugin-${index}@1.0.0`, - })) - const providerMap = new Map(providers.map(provider => [provider.plugin_id!, provider])) - const plugins = providers.map(provider => createPlugin({ - plugin_id: provider.plugin_id!, - latest_package_identifier: provider.plugin_unique_identifier, - })) - - render( - <FeaturedTriggers - plugins={plugins} - providerMap={providerMap} - onSelect={vi.fn()} - />, + const providers = Array.from({ length: 6 }).map((_, index) => + createTriggerProvider({ + id: `provider-${index}`, + name: `provider-${index}`, + label: { en_US: `Provider ${index}`, zh_Hans: `提供商${index}` }, + plugin_id: `plugin-${index}`, + plugin_unique_identifier: `plugin-${index}@1.0.0`, + }), ) + const providerMap = new Map(providers.map((provider) => [provider.plugin_id!, provider])) + const plugins = providers.map((provider) => + createPlugin({ + plugin_id: provider.plugin_id!, + latest_package_identifier: provider.plugin_unique_identifier, + }), + ) + + render(<FeaturedTriggers plugins={plugins} providerMap={providerMap} onSelect={vi.fn()} />) expect(screen.getByText('Provider 4')).toBeInTheDocument() expect(screen.queryByText('Provider 5')).not.toBeInTheDocument() @@ -153,15 +152,11 @@ describe('FeaturedTriggers', () => { // Rendering should cover the empty state link and installed trigger selection. describe('Rendering and Selection', () => { it('should render the empty state link when there are no featured plugins', () => { - render( - <FeaturedTriggers - plugins={[]} - providerMap={new Map()} - onSelect={vi.fn()} - />, - ) + render(<FeaturedTriggers plugins={[]} providerMap={new Map()} onSelect={vi.fn()} />) - expect(screen.getByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' })).toHaveAttribute('href', 'https://marketplace.test/plugins/trigger') + expect( + screen.getByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' }), + ).toHaveAttribute('href', 'https://marketplace.test/plugins/trigger') }) it('should select an installed trigger event from the featured list', async () => { @@ -171,11 +166,15 @@ describe('FeaturedTriggers', () => { render( <FeaturedTriggers - plugins={[createPlugin({ plugin_id: 'plugin-1', latest_package_identifier: 'plugin-1@1.0.0' })]} - providerMap={new Map([ - ['plugin-1', provider], - ['plugin-1@1.0.0', provider], - ])} + plugins={[ + createPlugin({ plugin_id: 'plugin-1', latest_package_identifier: 'plugin-1@1.0.0' }), + ]} + providerMap={ + new Map([ + ['plugin-1', provider], + ['plugin-1@1.0.0', provider], + ]) + } onSelect={onSelect} />, ) @@ -183,11 +182,14 @@ describe('FeaturedTriggers', () => { await user.click(screen.getByText('Provider One')) await user.click(screen.getByText('Created')) - expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({ - provider_id: 'provider-one', - event_name: 'created', - event_label: 'Created', - })) + expect(onSelect).toHaveBeenCalledWith( + BlockEnum.TriggerPlugin, + expect.objectContaining({ + provider_id: 'provider-one', + event_name: 'created', + event_label: 'Created', + }), + ) }) it('should align featured item icons with the trigger list column', () => { @@ -204,10 +206,12 @@ describe('FeaturedTriggers', () => { label: { en_US: 'Plugin Two', zh_Hans: '插件二' }, }), ]} - providerMap={new Map([ - ['plugin-1', provider], - ['plugin-1@1.0.0', provider], - ])} + providerMap={ + new Map([ + ['plugin-1', provider], + ['plugin-1@1.0.0', provider], + ]) + } onSelect={vi.fn()} />, ) diff --git a/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx index bce33482f28f92..ca968775d0655d 100644 --- a/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx @@ -8,23 +8,27 @@ describe('block-selector hooks', () => { }) it('keeps the start tab enabled when a configured user input start node exists', () => { - const { result } = renderHook(() => useTabs({ - noStart: false, - defaultActiveTab: TabsEnum.Start, - })) - - expect(result.current.tabs.find(tab => tab.key === TabsEnum.Start)?.disabled).toBeFalsy() + const { result } = renderHook(() => + useTabs({ + noStart: false, + defaultActiveTab: TabsEnum.Start, + }), + ) + + expect(result.current.tabs.find((tab) => tab.key === TabsEnum.Start)?.disabled).toBeFalsy() expect(result.current.activeTab).toBe(TabsEnum.Start) }) it('disables the start tab when an unconfigured start placeholder exists', () => { - const { result } = renderHook(() => useTabs({ - noStart: false, - hasStartPlaceholderNode: true, - defaultActiveTab: TabsEnum.Start, - })) - - const startTab = result.current.tabs.find(tab => tab.key === TabsEnum.Start) + const { result } = renderHook(() => + useTabs({ + noStart: false, + hasStartPlaceholderNode: true, + defaultActiveTab: TabsEnum.Start, + }), + ) + + const startTab = result.current.tabs.find((tab) => tab.key === TabsEnum.Start) expect(startTab?.disabled).toBe(true) expect(startTab?.disabledTip).toBe('workflow.tabs.unconfiguredStartDisabledTip') expect(startTab?.disabledTipLinkKey).toBe('startNodesDocs') @@ -38,11 +42,11 @@ describe('block-selector hooks', () => { forceEnableStartTab: true, } - const { result, rerender } = renderHook(nextProps => useTabs(nextProps), { + const { result, rerender } = renderHook((nextProps) => useTabs(nextProps), { initialProps: props, }) - expect(result.current.tabs.find(tab => tab.key === TabsEnum.Start)?.disabled).toBeFalsy() + expect(result.current.tabs.find((tab) => tab.key === TabsEnum.Start)?.disabled).toBeFalsy() act(() => { result.current.setActiveTab(TabsEnum.Blocks) @@ -62,30 +66,34 @@ describe('block-selector hooks', () => { const { result: visible } = renderHook(() => useToolTabs()) const { result: hidden } = renderHook(() => useToolTabs(true)) - expect(visible.current.some(tab => tab.key === ToolTypeEnum.MCP)).toBe(true) - expect(hidden.current.some(tab => tab.key === ToolTypeEnum.MCP)).toBe(false) + expect(visible.current.some((tab) => tab.key === ToolTypeEnum.MCP)).toBe(true) + expect(hidden.current.some((tab) => tab.key === ToolTypeEnum.MCP)).toBe(false) }) it('includes the snippets tab by default', () => { const { result } = renderHook(() => useTabs({})) - expect(result.current.tabs.some(tab => tab.key === TabsEnum.Snippets)).toBe(true) + expect(result.current.tabs.some((tab) => tab.key === TabsEnum.Snippets)).toBe(true) }) it('hides the snippets tab and falls back when snippets are disabled', () => { - const { result } = renderHook(() => useTabs({ - defaultActiveTab: TabsEnum.Snippets, - noSnippets: true, - })) - - expect(result.current.tabs.some(tab => tab.key === TabsEnum.Snippets)).toBe(false) + const { result } = renderHook(() => + useTabs({ + defaultActiveTab: TabsEnum.Snippets, + noSnippets: true, + }), + ) + + expect(result.current.tabs.some((tab) => tab.key === TabsEnum.Snippets)).toBe(false) expect(result.current.activeTab).toBe(TabsEnum.Blocks) }) it('resets the active tab to the current default tab', () => { - const { result } = renderHook(() => useTabs({ - noStart: false, - })) + const { result } = renderHook(() => + useTabs({ + noStart: false, + }), + ) act(() => { result.current.setActiveTab(TabsEnum.Start) diff --git a/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx b/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx index f956b3d99fcc2c..7e91a3d0409590 100644 --- a/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx @@ -61,7 +61,7 @@ describe('IndexBar', () => { }), ] - const result = groupItems(items, item => item.label.zh_Hans[0] || item.label.en_US[0] || '') + const result = groupItems(items, (item) => item.label.zh_Hans[0] || item.label.en_US[0] || '') expect(result.letters).toEqual(['J', 'S', 'Z', '#']) expect(result.groups.J!.Builtin).toHaveLength(1) @@ -82,12 +82,7 @@ describe('IndexBar', () => { }, } - render( - <IndexBar - letters={['A']} - itemRefs={itemRefs} - />, - ) + render(<IndexBar letters={['A']} itemRefs={itemRefs} />) await user.click(screen.getByRole('button', { name: 'A' })) diff --git a/web/app/components/workflow/block-selector/__tests__/index.spec.tsx b/web/app/components/workflow/block-selector/__tests__/index.spec.tsx index 7aa8c9b9d2887f..9eab06588b00a3 100644 --- a/web/app/components/workflow/block-selector/__tests__/index.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/index.spec.tsx @@ -6,7 +6,8 @@ import NodeSelectorWrapper from '../index' import { BlockClassificationEnum } from '../types' vi.mock('reactflow', async () => - (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), +) vi.mock('@/service/use-plugins', () => ({ useFeaturedToolsRecommendations: () => ({ @@ -59,11 +60,7 @@ describe('NodeSelectorWrapper', () => { it('filters hidden block types from hooks store and forwards data sources', async () => { renderWorkflowComponent( - <NodeSelectorWrapper - open - onSelect={vi.fn()} - availableBlocksTypes={[BlockEnum.Code]} - />, + <NodeSelectorWrapper open onSelect={vi.fn()} availableBlocksTypes={[BlockEnum.Code]} />, { hooksStoreProps: { availableNodesMetaData: { diff --git a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx index b300c1b1cf0e1a..b0aab6f1c3bf3e 100644 --- a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx @@ -73,7 +73,6 @@ const renderNodeSelector = (ui: ReactElement, options?: RenderNodeSelectorOption flowType: FlowType.appFlow, fileSettings: {} as never, ...options?.hooksStoreProps?.configsMap, - }, }, }) @@ -87,15 +86,10 @@ describe('NodeSelector', () => { renderNodeSelector( <NodeSelector onSelect={onSelect} - blocks={[ - createBlock(BlockEnum.LLM, 'LLM'), - createBlock(BlockEnum.End, 'End'), - ]} + blocks={[createBlock(BlockEnum.LLM, 'LLM'), createBlock(BlockEnum.End, 'End')]} availableBlocksTypes={[BlockEnum.LLM, BlockEnum.End]} - trigger={open => ( - <button type="button"> - {open ? 'selector-open' : 'selector-closed'} - </button> + trigger={(open) => ( + <button type="button">{open ? 'selector-open' : 'selector-closed'}</button> )} />, ) @@ -122,7 +116,9 @@ describe('NodeSelector', () => { await user.click(screen.getByRole('button', { name: 'selector-closed' })) - const reopenedInput = screen.getByPlaceholderText('workflow.tabs.searchBlock') as HTMLInputElement + const reopenedInput = screen.getByPlaceholderText( + 'workflow.tabs.searchBlock', + ) as HTMLInputElement expect(reopenedInput.value).toBe('') expect(screen.getByText('End')).toBeInTheDocument() }) @@ -133,15 +129,11 @@ describe('NodeSelector', () => { renderNodeSelector( <NodeSelector onSelect={vi.fn()} - blocks={[ - createBlock(BlockEnum.LLM, 'LLM'), - ]} + blocks={[createBlock(BlockEnum.LLM, 'LLM')]} availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]} showStartTab - trigger={open => ( - <button type="button"> - {open ? 'selector-open' : 'selector-closed'} - </button> + trigger={(open) => ( + <button type="button">{open ? 'selector-open' : 'selector-closed'}</button> )} />, ) @@ -172,10 +164,8 @@ describe('NodeSelector', () => { onSelect={vi.fn()} blocks={[createBlock(BlockEnum.LLM, 'LLM')]} availableBlocksTypes={[BlockEnum.LLM]} - trigger={open => ( - <button type="button"> - {open ? 'selector-open' : 'selector-closed'} - </button> + trigger={(open) => ( + <button type="button">{open ? 'selector-open' : 'selector-closed'}</button> )} />, ) @@ -213,11 +203,7 @@ describe('NodeSelector', () => { const user = userEvent.setup() function TriggerShell() { - return ( - <span> - open-from-shell - </span> - ) + return <span>open-from-shell</span> } renderNodeSelector( @@ -271,11 +257,7 @@ describe('NodeSelector', () => { blocks={[createBlock(BlockEnum.LLM, 'LLM')]} availableBlocksTypes={[BlockEnum.LLM]} renderTriggerAsButtonRoot - trigger={() => ( - <Button variant="primary"> - open-shared-button-trigger - </Button> - )} + trigger={() => <Button variant="primary">open-shared-button-trigger</Button>} />, ) @@ -295,10 +277,7 @@ describe('NodeSelector', () => { open isolateKeyboardEvents onSelect={vi.fn()} - blocks={[ - createBlock(BlockEnum.LLM, 'LLM'), - createBlock(BlockEnum.End, 'End'), - ]} + blocks={[createBlock(BlockEnum.LLM, 'LLM'), createBlock(BlockEnum.End, 'End')]} availableBlocksTypes={[BlockEnum.LLM, BlockEnum.End]} />, ) @@ -308,8 +287,7 @@ describe('NodeSelector', () => { try { await user.type(searchInput, 'LLM') - } - finally { + } finally { document.body.removeEventListener('keydown', handleParentKeyDown) } @@ -347,11 +325,12 @@ describe('NodeSelector', () => { await user.hover(screen.getByText('workflow.tabs.start')) - expect(await screen.findByText('workflow.tabs.unconfiguredStartDisabledTip')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'workflow.tabs.startDisabledTipLearnMore' })).toHaveAttribute( - 'href', - 'https://docs.dify.ai/en/self-host/use-dify/nodes/trigger/overview', - ) + expect( + await screen.findByText('workflow.tabs.unconfiguredStartDisabledTip'), + ).toBeInTheDocument() + expect( + screen.getByRole('link', { name: 'workflow.tabs.startDisabledTipLearnMore' }), + ).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/nodes/trigger/overview') expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() }) @@ -380,6 +359,8 @@ describe('NodeSelector', () => { ) expect(screen.getByText('workflow.tabs.start')).toHaveAttribute('aria-disabled', 'false') - expect(screen.getByText('workflow.nodes.startPlaceholder.userInputConflictTip')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.startPlaceholder.userInputConflictTip'), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx index d7e513c3602f09..840e0edfc6f537 100644 --- a/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx @@ -17,13 +17,15 @@ vi.mock('../../../workflow-app/hooks', () => ({ const mockUseNodes = vi.mocked(useNodes) const mockUseAvailableNodesMetaData = vi.mocked(useAvailableNodesMetaData) -const createNode = (type: BlockEnum) => ({ - data: { type } as Pick<CommonNodeType, 'type'>, -}) as ReturnType<typeof useNodes>[number] +const createNode = (type: BlockEnum) => + ({ + data: { type } as Pick<CommonNodeType, 'type'>, + }) as ReturnType<typeof useNodes>[number] -const createAvailableNodesMetaData = (): ReturnType<typeof useAvailableNodesMetaData> => ({ - nodes: [], -} as unknown as ReturnType<typeof useAvailableNodesMetaData>) +const createAvailableNodesMetaData = (): ReturnType<typeof useAvailableNodesMetaData> => + ({ + nodes: [], + }) as unknown as ReturnType<typeof useAvailableNodesMetaData> describe('StartBlocks', () => { beforeEach(() => { @@ -134,7 +136,11 @@ describe('StartBlocks', () => { name: /workflow\.blocks\.start.*workflow\.nodes\.startPlaceholder\.userInputConflictTip/, }) expect(userInputButton).toHaveAttribute('aria-disabled', 'true') - expect(userInputButton.querySelector('.i-custom-vender-workflow-user-input')?.closest('.opacity-30')).toBeInTheDocument() + expect( + userInputButton + .querySelector('.i-custom-vender-workflow-user-input') + ?.closest('.opacity-30'), + ).toBeInTheDocument() await user.tab() expect(userInputButton).toHaveFocus() diff --git a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx index 208d87c23a1362..46a8156d3b959e 100644 --- a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx @@ -8,18 +8,16 @@ import { TabsEnum } from '../types' const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, { systemFeatures: { enable_marketplace: true } }) -const { - mockSetState, - mockInvalidateBuiltInTools, - mockToolsState, -} = vi.hoisted(() => ({ +const { mockSetState, mockInvalidateBuiltInTools, mockToolsState } = vi.hoisted(() => ({ mockSetState: vi.fn(), mockInvalidateBuiltInTools: vi.fn(), mockToolsState: { - buildInTools: [{ icon: '/tool.svg', name: 'tool' }] as Array<{ icon: string | Record<string, string>, name: string }> | undefined, - customTools: [] as Array<{ icon: string | Record<string, string>, name: string }> | undefined, - workflowTools: [] as Array<{ icon: string | Record<string, string>, name: string }> | undefined, - mcpTools: [] as Array<{ icon: string | Record<string, string>, name: string }> | undefined, + buildInTools: [{ icon: '/tool.svg', name: 'tool' }] as + | Array<{ icon: string | Record<string, string>; name: string }> + | undefined, + customTools: [] as Array<{ icon: string | Record<string, string>; name: string }> | undefined, + workflowTools: [] as Array<{ icon: string | Record<string, string>; name: string }> | undefined, + mcpTools: [] as Array<{ icon: string | Record<string, string>; name: string }> | undefined, }, })) @@ -70,9 +68,7 @@ vi.mock('../all-tools', () => ({ <div> tools-content {props.buildInTools.map((tool, index) => ( - <span key={index}> - {typeof tool.icon === 'string' ? tool.icon : 'object-icon'} - </span> + <span key={index}>{typeof tool.icon === 'string' ? tool.icon : 'object-icon'}</span> ))} <span>{props.showFeatured ? 'featured-on' : 'featured-off'}</span> <span>{props.featuredLoading ? 'featured-loading' : 'featured-idle'}</span> @@ -118,13 +114,7 @@ describe('Tabs', () => { it('should switch tabs through click handlers and render tools content with normalized icons', () => { const onActiveTabChange = vi.fn() - render( - <Tabs - {...baseProps} - activeTab={TabsEnum.Tools} - onActiveTabChange={onActiveTabChange} - />, - ) + render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} onActiveTabChange={onActiveTabChange} />) fireEvent.click(screen.getByText('Start')) @@ -145,13 +135,7 @@ describe('Tabs', () => { const user = userEvent.setup() const onActiveTabChange = vi.fn() - render( - <Tabs - {...baseProps} - activeTab={TabsEnum.Start} - onActiveTabChange={onActiveTabChange} - />, - ) + render(<Tabs {...baseProps} activeTab={TabsEnum.Start} onActiveTabChange={onActiveTabChange} />) await user.click(screen.getByText('Start')) await user.click(screen.getByText('Blocks')) @@ -182,7 +166,9 @@ describe('Tabs', () => { workflowTools: mockToolsState.workflowTools, mcpTools: mockToolsState.mcpTools, } - const updateState = mockSetState.mock.calls[0]![0] as (state: typeof previousState) => typeof previousState + const updateState = mockSetState.mock.calls[0]![0] as ( + state: typeof previousState, + ) => typeof previousState expect(updateState(previousState)).toBe(previousState) }) @@ -198,23 +184,25 @@ describe('Tabs', () => { expect(screen.getByText('object-icon'))!.toBeInTheDocument() const updateState = mockSetState.mock.calls[0]![0] as (state: { - buildInTools?: Array<{ icon: string | Record<string, string>, name: string }> - customTools?: Array<{ icon: string | Record<string, string>, name: string }> - workflowTools?: Array<{ icon: string | Record<string, string>, name: string }> - mcpTools?: Array<{ icon: string | Record<string, string>, name: string }> + buildInTools?: Array<{ icon: string | Record<string, string>; name: string }> + customTools?: Array<{ icon: string | Record<string, string>; name: string }> + workflowTools?: Array<{ icon: string | Record<string, string>; name: string }> + mcpTools?: Array<{ icon: string | Record<string, string>; name: string }> }) => { - buildInTools?: Array<{ icon: string | Record<string, string>, name: string }> - customTools?: Array<{ icon: string | Record<string, string>, name: string }> - workflowTools?: Array<{ icon: string | Record<string, string>, name: string }> - mcpTools?: Array<{ icon: string | Record<string, string>, name: string }> + buildInTools?: Array<{ icon: string | Record<string, string>; name: string }> + customTools?: Array<{ icon: string | Record<string, string>; name: string }> + workflowTools?: Array<{ icon: string | Record<string, string>; name: string }> + mcpTools?: Array<{ icon: string | Record<string, string>; name: string }> } - expect(updateState({ - buildInTools: [], - customTools: [], - workflowTools: [], - mcpTools: [], - })).toEqual({ + expect( + updateState({ + buildInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + }), + ).toEqual({ buildInTools: [{ icon: { light: '/tool.svg' }, name: 'tool' }], customTools: [{ icon: '/console/custom.svg', name: 'custom' }], workflowTools: [{ icon: '/console/workflow.svg', name: 'workflow' }], @@ -233,12 +221,7 @@ describe('Tabs', () => { it('should force start content to render and invalidate built-in tools after featured installs', async () => { const user = userEvent.setup() - render( - <Tabs - {...baseProps} - activeTab={TabsEnum.Tools} - />, - ) + render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />) await user.click(screen.getByRole('button', { name: 'Install featured tool' })) @@ -247,14 +230,7 @@ describe('Tabs', () => { }) it('should render start content when blocks are hidden but forceShowStartContent is enabled', () => { - render( - <Tabs - {...baseProps} - activeTab={TabsEnum.Start} - noBlocks - forceShowStartContent - />, - ) + render(<Tabs {...baseProps} activeTab={TabsEnum.Start} noBlocks forceShowStartContent />) expect(screen.getByText('start-content'))!.toBeInTheDocument() }) diff --git a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx index 8f316580ab608a..ecf26fed681505 100644 --- a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx @@ -95,7 +95,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -115,9 +116,12 @@ vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ useMarketplacePlugins: vi.fn(), })) -vi.mock('@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', () => ({ - default: () => ({ canInstallPlugin: true }), -})) +vi.mock( + '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', + () => ({ + default: () => ({ canInstallPlugin: true }), + }), +) vi.mock('@/service/tools', () => ({ createCustomCollection: vi.fn(), @@ -181,8 +185,12 @@ vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({ onHide: () => void }) => ( <div data-testid="edit-custom-tool-modal"> - <button type="button" onClick={() => onAdd({ name: 'collection-a' })}>submit-custom-tool</button> - <button type="button" onClick={onHide}>hide-custom-tool</button> + <button type="button" onClick={() => onAdd({ name: 'collection-a' })}> + submit-custom-tool + </button> + <button type="button" onClick={onHide}> + hide-custom-tool + </button> </div> ), })) @@ -219,8 +227,12 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () = onClose: () => void }) => ( <div data-testid="install-from-marketplace"> - <button type="button" onClick={() => onSuccess()}>complete-featured-install</button> - <button type="button" onClick={onClose}>cancel-featured-install</button> + <button type="button" onClick={() => onSuccess()}> + complete-featured-install + </button> + <button type="button" onClick={onClose}> + cancel-featured-install + </button> </div> ), })) @@ -233,11 +245,7 @@ vi.mock('@/utils/var', async (importOriginal) => { } }) -const createTool = ( - name: string, - label: string, - description = `${label} description`, -): Tool => ({ +const createTool = (name: string, label: string, description = `${label} description`): Tool => ({ name, author: 'author', label: { @@ -253,9 +261,7 @@ const createTool = ( output_schema: {}, }) -const createToolProvider = ( - overrides: Partial<ToolWithProvider> = {}, -): ToolWithProvider => ({ +const createToolProvider = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({ id: 'provider-1', name: 'provider-one', author: 'Provider Author', @@ -394,13 +400,21 @@ describe('ToolPicker', () => { fetchNextPage: vi.fn(), page: 0, } as ReturnType<typeof useMarketplacePlugins>) - mockUseAllBuiltInTools.mockReturnValue({ data: builtInTools } as ReturnType<typeof useAllBuiltInTools>) - mockUseAllCustomTools.mockImplementation((enabled = true) => ({ - data: enabled ? customTools : [], - } as ReturnType<typeof useAllCustomTools>)) - mockUseAllWorkflowTools.mockImplementation((enabled = true) => ({ - data: enabled ? workflowTools : [], - } as ReturnType<typeof useAllWorkflowTools>)) + mockUseAllBuiltInTools.mockReturnValue({ data: builtInTools } as ReturnType< + typeof useAllBuiltInTools + >) + mockUseAllCustomTools.mockImplementation( + (enabled = true) => + ({ + data: enabled ? customTools : [], + }) as ReturnType<typeof useAllCustomTools>, + ) + mockUseAllWorkflowTools.mockImplementation( + (enabled = true) => + ({ + data: enabled ? workflowTools : [], + }) as ReturnType<typeof useAllWorkflowTools>, + ) mockUseAllMCPTools.mockReturnValue({ data: mcpTools } as ReturnType<typeof useAllMCPTools>) mockUseInvalidateAllBuiltInTools.mockReturnValue(mockInvalidateBuiltInTools) mockUseInvalidateAllCustomTools.mockReturnValue(mockInvalidateCustomTools) @@ -501,11 +515,13 @@ describe('ToolPicker', () => { }) await user.click(screen.getByText('Weather Tool')) - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ - provider_name: 'custom-provider', - tool_name: 'weather-tool', - tool_label: 'Weather Tool', - })) + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ + provider_name: 'custom-provider', + tool_name: 'weather-tool', + tool_label: 'Weather Tool', + }), + ) await user.hover(screen.getByText('Custom Provider')) await user.click(screen.getByText('workflow.tabs.addAll')) @@ -561,16 +577,20 @@ describe('ToolPicker', () => { expect(screen.getByText('Custom Provider')).toBeInTheDocument() expect(screen.getByText('Built-in Provider')).toBeInTheDocument() expect(screen.getByText('MCP Provider')).toBeInTheDocument() - expect(Array.from(document.querySelectorAll('button')).some((button) => { - return button.className.includes('bg-components-button-primary-bg') - })).toBe(false) + expect( + Array.from(document.querySelectorAll('button')).some((button) => { + return button.className.includes('bg-components-button-primary-bg') + }), + ).toBe(false) }) it('should invalidate all tool collections after featured install succeeds', async () => { const user = userEvent.setup() mockUseFeaturedToolsRecommendations.mockReturnValue({ - plugins: [createPlugin({ plugin_id: 'featured-1', latest_package_identifier: 'featured-1@1.0.0' })], + plugins: [ + createPlugin({ plugin_id: 'featured-1', latest_package_identifier: 'featured-1@1.0.0' }), + ], isLoading: false, } as ReturnType<typeof useFeaturedToolsRecommendations>) @@ -585,11 +605,14 @@ describe('ToolPicker', () => { expect(await screen.findByTestId('install-from-marketplace')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'complete-featured-install' })) - await waitFor(() => { - expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1) - expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1) - expect(mockInvalidateWorkflowTools).toHaveBeenCalledTimes(1) - expect(mockInvalidateMcpTools).toHaveBeenCalledTimes(1) - }, { timeout: 3000 }) + await waitFor( + () => { + expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1) + expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1) + expect(mockInvalidateWorkflowTools).toHaveBeenCalledTimes(1) + expect(mockInvalidateMcpTools).toHaveBeenCalledTimes(1) + }, + { timeout: 3000 }, + ) }) }) diff --git a/web/app/components/workflow/block-selector/__tests__/tools.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tools.spec.tsx index b6d05ecb4541cb..25387c45651d5b 100644 --- a/web/app/components/workflow/block-selector/__tests__/tools.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tools.spec.tsx @@ -33,14 +33,7 @@ describe('Tools', () => { }) it('shows the empty state when there are no tools and no search text', () => { - render( - <Tools - tools={[]} - onSelect={vi.fn()} - viewType={ViewType.flat} - hasSearchText={false} - />, - ) + render(<Tools tools={[]} onSelect={vi.fn()} viewType={ViewType.flat} hasSearchText={false} />) expect(screen.getByText('No tools available')).toBeInTheDocument() }) @@ -82,7 +75,8 @@ describe('Tools', () => { en_US: `${String.fromCharCode(65 + index)} Provider`, zh_Hans: `${String.fromCharCode(65 + index)} Provider`, }, - }))} + }), + )} onSelect={vi.fn()} viewType={ViewType.flat} hasSearchText={false} diff --git a/web/app/components/workflow/block-selector/__tests__/utils.spec.ts b/web/app/components/workflow/block-selector/__tests__/utils.spec.ts index b003ef75616446..8f3249a82590ae 100644 --- a/web/app/components/workflow/block-selector/__tests__/utils.spec.ts +++ b/web/app/components/workflow/block-selector/__tests__/utils.spec.ts @@ -92,7 +92,8 @@ describe('transformDataSourceToTool', () => { const dataSourceItem = createDataSourceItem({ declaration: { ...baseDataSourceItem.declaration, - credentials_schema: undefined as unknown as DataSourceItem['declaration']['credentials_schema'], + credentials_schema: + undefined as unknown as DataSourceItem['declaration']['credentials_schema'], identity: { ...baseDataSourceItem.declaration.identity, tags: undefined as unknown as DataSourceItem['declaration']['identity']['tags'], diff --git a/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx b/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx index b6a9cb20d7ba83..7936e9048953e6 100644 --- a/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx @@ -3,20 +3,14 @@ import ViewTypeSelect, { ViewType } from '../view-type-select' const getViewOptions = (container: HTMLElement) => { const options = container.firstElementChild?.children - if (!options || options.length !== 2) - throw new Error('Expected two view options') + if (!options || options.length !== 2) throw new Error('Expected two view options') return [options[0] as HTMLDivElement, options[1] as HTMLDivElement] } describe('ViewTypeSelect', () => { it('should highlight the active view type', () => { const onChange = vi.fn() - const { container } = render( - <ViewTypeSelect - viewType={ViewType.flat} - onChange={onChange} - />, - ) + const { container } = render(<ViewTypeSelect viewType={ViewType.flat} onChange={onChange} />) const [flatOption, treeOption] = getViewOptions(container) @@ -26,12 +20,7 @@ describe('ViewTypeSelect', () => { it('should call onChange when switching to a different view type', () => { const onChange = vi.fn() - const { container } = render( - <ViewTypeSelect - viewType={ViewType.flat} - onChange={onChange} - />, - ) + const { container } = render(<ViewTypeSelect viewType={ViewType.flat} onChange={onChange} />) const [, treeOption] = getViewOptions(container) fireEvent.click(treeOption!) @@ -42,12 +31,7 @@ describe('ViewTypeSelect', () => { it('should ignore clicks on the current view type', () => { const onChange = vi.fn() - const { container } = render( - <ViewTypeSelect - viewType={ViewType.tree} - onChange={onChange} - />, - ) + const { container } = render(<ViewTypeSelect viewType={ViewType.tree} onChange={onChange} />) const [, treeOption] = getViewOptions(container) fireEvent.click(treeOption!) diff --git a/web/app/components/workflow/block-selector/agent-selector.tsx b/web/app/components/workflow/block-selector/agent-selector.tsx index 7ccd61516f9f6a..25bc5a64309889 100644 --- a/web/app/components/workflow/block-selector/agent-selector.tsx +++ b/web/app/components/workflow/block-selector/agent-selector.tsx @@ -12,12 +12,7 @@ import { ComboboxList, ComboboxStatus, } from '@langgenius/dify-ui/combobox' -import { - Popover, - PopoverContent, - PopoverTitle, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' import { useQuery } from '@tanstack/react-query' import { useDebounce } from 'ahooks' @@ -32,13 +27,9 @@ import BlockIcon from '../block-icon' const AGENT_SELECTOR_PAGE_SIZE = 8 -type AgentSelectorOption - = | AgentInviteOptionResponse - | AgentSelectorActionOption +type AgentSelectorOption = AgentInviteOptionResponse | AgentSelectorActionOption -type AgentSelectorActionOption - = | 'start-from-scratch' - | 'manage-in-agent-console' +type AgentSelectorActionOption = 'start-from-scratch' | 'manage-in-agent-console' export function AgentSelectorContent({ open, @@ -52,7 +43,7 @@ export function AgentSelectorContent({ onStartFromScratch?: () => void }) { const { t } = useTranslation(['agentV2', 'common', 'workflow']) - const appId = useHooksStore(s => s.configsMap?.flowId) + const appId = useHooksStore((s) => s.configsMap?.flowId) const [searchText, setSearchText] = useState('') const debouncedSearchText = useDebounce(searchText.trim(), { wait: 300 }) const agentsQuery = useQuery({ @@ -76,38 +67,34 @@ export function AgentSelectorContent({ const getOptionLabel = (option: AgentSelectorOption) => { if (isAgentSelectorActionOption(option)) { if (option === 'start-from-scratch') - return t($ => $['roster.nodeSelector.startFromScratch'], { ns: 'agentV2' }) + return t(($) => $['roster.nodeSelector.startFromScratch'], { ns: 'agentV2' }) - return t($ => $['roster.nodeSelector.manageInAgentConsole'], { ns: 'agentV2' }) + return t(($) => $['roster.nodeSelector.manageInAgentConsole'], { ns: 'agentV2' }) } return option.name } const handleInputValueChange = (nextSearchText: string, details: ComboboxChangeEventDetails) => { - if (details.reason !== 'item-press') - setSearchText(nextSearchText) + if (details.reason !== 'item-press') setSearchText(nextSearchText) } const handleValueChange = (option: AgentSelectorOption | null) => { - if (!option) - return + if (!option) return if (isAgentSelectorActionOption(option)) { - if (option === 'start-from-scratch') - onStartFromScratch?.() + if (option === 'start-from-scratch') onStartFromScratch?.() return } if (!option.active_config_snapshot_id) { - toast.error(t($ => $['nodes.agent.modelNotSelected'], { ns: 'workflow' })) + toast.error(t(($) => $['nodes.agent.modelNotSelected'], { ns: 'workflow' })) return } onSelect(toAgentRosterNodeData(option)) } const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen) - onOpenChange(false) + if (!nextOpen) onOpenChange(false) } const isLoading = agentsQuery.isPending @@ -127,10 +114,13 @@ export function AgentSelectorContent({ > <div className="bg-components-panel-bg-blur p-2 pb-1"> <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" /> + <span + aria-hidden + className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" + /> <ComboboxInput - aria-label={t($ => $['roster.searchLabel'], { ns: 'agentV2' })} - placeholder={t($ => $['roster.nodeSelector.searchPlaceholder'], { ns: 'agentV2' })} + aria-label={t(($) => $['roster.searchLabel'], { ns: 'agentV2' })} + placeholder={t(($) => $['roster.nodeSelector.searchPlaceholder'], { ns: 'agentV2' })} className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" /> </ComboboxInputGroup> @@ -138,11 +128,11 @@ export function AgentSelectorContent({ <ComboboxList className="max-h-none overflow-visible p-0"> <div role="presentation" className="max-h-54 overflow-y-auto p-1"> {isLoading && ( - <AgentSelectorLoadingSkeleton label={t($ => $.loading, { ns: 'common' })} /> + <AgentSelectorLoadingSkeleton label={t(($) => $.loading, { ns: 'common' })} /> )} {!isLoading && agentsQuery.isError && ( <ComboboxStatus className="px-3 py-2 system-xs-regular"> - {t($ => $['roster.loadingError'], { ns: 'agentV2' })} + {t(($) => $['roster.loadingError'], { ns: 'agentV2' })} </ComboboxStatus> )} {!isLoading && !agentsQuery.isError && ( @@ -150,18 +140,18 @@ export function AgentSelectorContent({ {agents.length === 0 && ( <ComboboxStatus className="px-3 py-2 system-xs-regular"> {debouncedSearchText - ? t($ => $['roster.emptySearch'], { ns: 'agentV2' }) - : t($ => $['roster.empty'], { ns: 'agentV2' })} + ? t(($) => $['roster.emptySearch'], { ns: 'agentV2' }) + : t(($) => $['roster.empty'], { ns: 'agentV2' })} </ComboboxStatus> )} - {agents.map(agent => ( + {agents.map((agent) => ( <AgentSelectorItem key={agent.id} agent={agent} /> ))} </> )} </div> <div role="presentation" className="border-t border-divider-subtle p-1"> - {actionOptions.map(option => ( + {actionOptions.map((option) => ( <AgentSelectorActionItem key={option} option={option} /> ))} </div> @@ -171,11 +161,7 @@ export function AgentSelectorContent({ ) } -function AgentSelectorLoadingSkeleton({ - label, -}: { - label: string -}) { +function AgentSelectorLoadingSkeleton({ label }: { label: string }) { return ( <ComboboxStatus className="p-0"> <span className="sr-only">{label}</span> @@ -204,13 +190,14 @@ function AgentSelectorLoadingSkeleton({ } function getAgentSelectorOptionValue(option: AgentSelectorOption) { - if (isAgentSelectorActionOption(option)) - return option + if (isAgentSelectorActionOption(option)) return option return option.id } -function isAgentSelectorActionOption(option: AgentSelectorOption): option is AgentSelectorActionOption { +function isAgentSelectorActionOption( + option: AgentSelectorOption, +): option is AgentSelectorActionOption { return typeof option === 'string' } @@ -226,11 +213,7 @@ function toAgentRosterNodeData(agent: AgentInviteOptionResponse): AgentRosterNod } } -function AgentSelectorAvatar({ - agent, -}: { - agent: AgentInviteOptionResponse -}) { +function AgentSelectorAvatar({ agent }: { agent: AgentInviteOptionResponse }) { return ( <AppIcon size="small" @@ -242,24 +225,15 @@ function AgentSelectorAvatar({ ) } -function AgentSelectorItem({ - agent, -}: { - agent: AgentInviteOptionResponse -}) { +function AgentSelectorItem({ agent }: { agent: AgentInviteOptionResponse }) { return ( - <ComboboxItem - value={agent} - className="grid-cols-[1fr] gap-0 py-1.5 pr-3 pl-2" - > + <ComboboxItem value={agent} className="grid-cols-[1fr] gap-0 py-1.5 pr-3 pl-2"> <ComboboxItemText className="flex items-center gap-2 px-0"> <span aria-hidden className="shrink-0"> <AgentSelectorAvatar agent={agent} /> </span> <span className="flex min-w-0 flex-1 flex-col gap-0.5"> - <span className="truncate system-sm-medium text-text-secondary"> - {agent.name} - </span> + <span className="truncate system-sm-medium text-text-secondary">{agent.name}</span> <span className="truncate system-xs-regular text-text-tertiary"> {agent.role || agent.description} </span> @@ -269,18 +243,18 @@ function AgentSelectorItem({ ) } -function AgentSelectorActionItem({ - option, -}: { - option: AgentSelectorActionOption -}) { +function AgentSelectorActionItem({ option }: { option: AgentSelectorActionOption }) { const { t } = useTranslation('agentV2') const isStartFromScratch = option === 'start-from-scratch' return ( <ComboboxItem value={option} - render={isStartFromScratch ? undefined : <Link href="/agents" target="_blank" rel="noopener noreferrer" />} + render={ + isStartFromScratch ? undefined : ( + <Link href="/agents" target="_blank" rel="noopener noreferrer" /> + ) + } className="flex min-h-7 w-full grid-cols-none items-center gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-highlighted:bg-state-base-hover data-highlighted:text-text-secondary" > <ComboboxItemText className="flex items-center gap-2 px-0 system-sm-regular text-text-secondary"> @@ -293,8 +267,8 @@ function AgentSelectorActionItem({ /> <span className="min-w-0 flex-1 truncate"> {isStartFromScratch - ? t($ => $['roster.nodeSelector.startFromScratch']) - : t($ => $['roster.nodeSelector.manageInAgentConsole'])} + ? t(($) => $['roster.nodeSelector.startFromScratch']) + : t(($) => $['roster.nodeSelector.manageInAgentConsole'])} </span> </ComboboxItemText> </ComboboxItem> @@ -321,27 +295,27 @@ export function AgentBlockItem({ <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger openOnHover - render={( + render={ <button type="button" className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" > - <BlockIcon - className="mr-2 shrink-0" - type={block.metaData.type} - /> + <BlockIcon className="mr-2 shrink-0" type={block.metaData.type} /> <span className="min-w-0 grow truncate system-sm-medium text-text-secondary"> {block.metaData.title} </span> <Badge size="xs" variant="dimm" - text={t($ => $['menus.status'], { ns: 'common' })} + text={t(($) => $['menus.status'], { ns: 'common' })} className="ml-2 shrink-0" /> - <span aria-hidden className="i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 -rotate-90 text-text-tertiary" /> + <span + aria-hidden + className="i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 -rotate-90 text-text-tertiary" + /> </button> - )} + } /> <PopoverContent placement="right-start" @@ -349,7 +323,7 @@ export function AgentBlockItem({ popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > <PopoverTitle className="sr-only"> - {t($ => $['roster.nodeSelector.dialogLabel'], { ns: 'agentV2' })} + {t(($) => $['roster.nodeSelector.dialogLabel'], { ns: 'agentV2' })} </PopoverTitle> <AgentSelectorContent open={open} diff --git a/web/app/components/workflow/block-selector/all-start-blocks.tsx b/web/app/components/workflow/block-selector/all-start-blocks.tsx index 3b9529d32c4b82..285e46b653f4a1 100644 --- a/web/app/components/workflow/block-selector/all-start-blocks.tsx +++ b/web/app/components/workflow/block-selector/all-start-blocks.tsx @@ -1,7 +1,5 @@ 'use client' -import type { - RefObject, -} from 'react' +import type { RefObject } from 'react' import type { BlockEnum, OnSelectBlock } from '../types' import type { ListRef } from './market-place-plugin/list' import type { TriggerDefaultValue, TriggerWithProvider } from './types' @@ -9,13 +7,7 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowRightUpLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general' @@ -33,8 +25,10 @@ import PluginList from './market-place-plugin/list' import StartBlocks from './start-blocks' import TriggerPluginList from './trigger-plugin/list' -const popoverMarketplaceFooterClassName = 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' -const panelMarketplaceFooterClassName = 'system-xs-regular z-10 flex flex-none cursor-pointer flex-col items-start gap-2 px-4 pt-2 pb-4 text-text-tertiary hover:text-text-secondary' +const popoverMarketplaceFooterClassName = + 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' +const panelMarketplaceFooterClassName = + 'system-xs-regular z-10 flex flex-none cursor-pointer flex-col items-start gap-2 px-4 pt-2 pb-4 text-text-tertiary hover:text-text-secondary' const SectionDivider = () => ( <div className="px-4 py-1" aria-hidden> @@ -76,29 +70,24 @@ const AllStartBlocks = ({ const [hasPluginContent, setHasPluginContent] = useState(false) const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const pluginRef = useRef<ListRef>(null) const wrapElemRef = useRef<HTMLDivElement>(null) const entryNodeTypes = useMemo(() => { - return availableBlocksTypes?.length - ? availableBlocksTypes - : [...ENTRY_NODE_TYPES] + return availableBlocksTypes?.length ? availableBlocksTypes : [...ENTRY_NODE_TYPES] }, [availableBlocksTypes]) const enableTriggerPlugin = entryNodeTypes.includes(BlockEnumValue.TriggerPlugin) const { data: triggerProviders = [] } = useAllTriggerPlugins(enableTriggerPlugin) const providerMap = useMemo(() => { const map = new Map<string, TriggerWithProvider>() triggerProviders.forEach((provider) => { - const keys = [ - provider.plugin_id, - provider.plugin_unique_identifier, - provider.id, - ].filter(Boolean) as string[] + const keys = [provider.plugin_id, provider.plugin_unique_identifier, provider.id].filter( + Boolean, + ) as string[] keys.forEach((key) => { - if (!map.has(key)) - map.set(key, provider) + if (!map.has(key)) map.set(key, provider) }) }) return map @@ -107,18 +96,12 @@ const AllStartBlocks = ({ const trimmedSearchText = searchText.trim() const hasSearchText = trimmedSearchText.length > 0 const hasFilter = hasSearchText || tags.length > 0 - const { - plugins: featuredPlugins = [], - isLoading: featuredLoading, - } = useFeaturedTriggersRecommendations(enableTriggerPlugin && enable_marketplace && !hasFilter) - const { - queryPluginsWithDebounced: fetchPlugins, - plugins: marketplacePlugins = [], - } = useMarketplacePlugins() + const { plugins: featuredPlugins = [], isLoading: featuredLoading } = + useFeaturedTriggersRecommendations(enableTriggerPlugin && enable_marketplace && !hasFilter) + const { queryPluginsWithDebounced: fetchPlugins, plugins: marketplacePlugins = [] } = + useMarketplacePlugins() - const shouldShowFeatured = enableTriggerPlugin - && enable_marketplace - && !hasFilter + const shouldShowFeatured = enableTriggerPlugin && enable_marketplace && !hasFilter const shouldShowMarketplaceFooter = enable_marketplace const isPanelVariant = variant === 'panel' @@ -130,23 +113,25 @@ const AllStartBlocks = ({ setHasPluginContent(hasContent) }, []) - const hasMarketplaceContent = enableTriggerPlugin && enable_marketplace && marketplacePlugins.length > 0 - const hasAnyContent = hasStartBlocksContent || hasPluginContent || shouldShowFeatured || hasMarketplaceContent + const hasMarketplaceContent = + enableTriggerPlugin && enable_marketplace && marketplacePlugins.length > 0 + const hasAnyContent = + hasStartBlocksContent || hasPluginContent || shouldShowFeatured || hasMarketplaceContent const shouldShowEmptyState = hasFilter && !hasAnyContent - const shouldShowInstalledTriggersDivider = isPanelVariant && hasStartBlocksContent && enableTriggerPlugin && hasPluginContent - const shouldShowMarketplaceSectionDivider = enableTriggerPlugin - && enable_marketplace - && (hasStartBlocksContent || hasPluginContent) - && (shouldShowFeatured || hasMarketplaceContent) + const shouldShowInstalledTriggersDivider = + isPanelVariant && hasStartBlocksContent && enableTriggerPlugin && hasPluginContent + const shouldShowMarketplaceSectionDivider = + enableTriggerPlugin && + enable_marketplace && + (hasStartBlocksContent || hasPluginContent) && + (shouldShowFeatured || hasMarketplaceContent) useEffect(() => { - if (!enableTriggerPlugin && hasPluginContent) - setHasPluginContent(false) + if (!enableTriggerPlugin && hasPluginContent) setHasPluginContent(false) }, [enableTriggerPlugin, hasPluginContent]) useEffect(() => { - if (!enableTriggerPlugin || !enable_marketplace) - return + if (!enableTriggerPlugin || !enable_marketplace) return if (hasFilter) { fetchPlugins({ query: searchText, @@ -157,8 +142,16 @@ const AllStartBlocks = ({ }, [enableTriggerPlugin, enable_marketplace, hasFilter, fetchPlugins, searchText, tags]) return ( - <div className={cn('max-w-[500px] min-w-[400px]', variant === 'panel' && 'h-full max-w-none min-w-0', className)}> - <div className={cn('flex max-h-[640px] flex-col', variant === 'panel' && 'h-full max-h-none')}> + <div + className={cn( + 'max-w-[500px] min-w-[400px]', + variant === 'panel' && 'h-full max-w-none min-w-0', + className, + )} + > + <div + className={cn('flex max-h-[640px] flex-col', variant === 'panel' && 'h-full max-h-none')} + > <div ref={wrapElemRef} className="flex-1 overflow-y-auto" @@ -167,12 +160,18 @@ const AllStartBlocks = ({ <div className={cn(shouldShowEmptyState && 'hidden')}> {hasUserInputNode && ( <div className="relative flex items-start gap-0.5 overflow-hidden border-b-[0.5px] border-divider-subtle bg-components-panel-bg-blur px-3 py-2"> - <div className="absolute inset-0 bg-linear-to-r from-util-colors-blue-light-blue-light-500/20 to-transparent opacity-40" aria-hidden /> - <span className="relative flex shrink-0 items-center justify-center p-1" aria-hidden> + <div + className="absolute inset-0 bg-linear-to-r from-util-colors-blue-light-blue-light-500/20 to-transparent opacity-40" + aria-hidden + /> + <span + className="relative flex shrink-0 items-center justify-center p-1" + aria-hidden + > <span className="i-ri-information-fill size-4 text-text-accent" /> </span> <div className="relative py-1 system-xs-regular text-text-secondary"> - {t($ => $['nodes.startPlaceholder.userInputConflictTip'], { ns: 'workflow' })} + {t(($) => $['nodes.startPlaceholder.userInputConflictTip'], { ns: 'workflow' })} </div> </div> )} @@ -190,9 +189,7 @@ const AllStartBlocks = ({ onContentStateChange={handleStartBlocksContentChange} /> - {shouldShowInstalledTriggersDivider && ( - <SectionDivider /> - )} + {shouldShowInstalledTriggersDivider && <SectionDivider />} {enableTriggerPlugin && ( <TriggerPluginList @@ -204,9 +201,7 @@ const AllStartBlocks = ({ /> )} - {shouldShowMarketplaceSectionDivider && ( - <SectionDivider /> - )} + {shouldShowMarketplaceSectionDivider && <SectionDivider />} {shouldShowFeatured && ( <FeaturedTriggers @@ -237,7 +232,7 @@ const AllStartBlocks = ({ <div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-center"> <SearchMenu className="size-8 text-text-quaternary" /> <div className="text-sm font-medium text-text-secondary"> - {t($ => $['nodes.startPlaceholder.noTriggersFound'], { ns: 'workflow' })} + {t(($) => $['nodes.startPlaceholder.noTriggersFound'], { ns: 'workflow' })} </div> <Link href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml" @@ -248,7 +243,7 @@ const AllStartBlocks = ({ variant="secondary-accent" className="h-6 cursor-pointer px-3 text-xs" > - {t($ => $['tabs.requestToCommunity'], { ns: 'workflow' })} + {t(($) => $['tabs.requestToCommunity'], { ns: 'workflow' })} </Button> </Link> </div> @@ -257,26 +252,33 @@ const AllStartBlocks = ({ {shouldShowMarketplaceFooter && ( <Link - className={isPanelVariant ? panelMarketplaceFooterClassName : popoverMarketplaceFooterClassName} + className={ + isPanelVariant ? panelMarketplaceFooterClassName : popoverMarketplaceFooterClassName + } href={getMarketplaceCategoryUrl(PluginCategoryEnum.trigger)} target="_blank" > - {isPanelVariant - ? ( - <> - <MarketplaceFooterDivider /> - <span className="flex items-center gap-1"> - <span className="i-custom-vender-workflow-marketplace size-3 shrink-0" aria-hidden /> - <span>{t($ => $['nodes.startPlaceholder.browseMoreOnMarketplace'], { ns: 'workflow' })}</span> - </span> - </> - ) - : ( - <> - <span>{t($ => $.findMoreInMarketplace, { ns: 'plugin' })}</span> - <RiArrowRightUpLine className="ml-0.5 size-3" /> - </> - )} + {isPanelVariant ? ( + <> + <MarketplaceFooterDivider /> + <span className="flex items-center gap-1"> + <span + className="i-custom-vender-workflow-marketplace size-3 shrink-0" + aria-hidden + /> + <span> + {t(($) => $['nodes.startPlaceholder.browseMoreOnMarketplace'], { + ns: 'workflow', + })} + </span> + </span> + </> + ) : ( + <> + <span>{t(($) => $.findMoreInMarketplace, { ns: 'plugin' })}</span> + <RiArrowRightUpLine className="ml-0.5 size-3" /> + </> + )} </Link> )} </div> diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index 109ca8b5be3301..376cf4dfe7c938 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -1,15 +1,11 @@ -import type { - Dispatch, - RefObject, - SetStateAction, -} from 'react' +import type { Dispatch, RefObject, SetStateAction } from 'react' import type { Plugin } from '../../plugins/types' -import type { - BlockEnum, - ToolWithProvider, -} from '../types' +import type { BlockEnum, ToolWithProvider } from '../types' import type { ToolDefaultValue, ToolValue } from './types' -import type { ListProps, ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list' +import type { + ListProps, + ListRef, +} from '@/app/components/workflow/block-selector/market-place-plugin/list' import type { OnSelectBlock } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' @@ -33,7 +29,8 @@ import Tools from './tools' import { ToolTypeEnum } from './types' import ViewTypeSelect, { ViewType } from './view-type-select' -const marketplaceFooterClassName = 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' +const marketplaceFooterClassName = + 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' type AllToolsProps = { className?: string @@ -90,13 +87,15 @@ const AllTools = ({ const isMatchingKeywords = (text: string, keywords: string) => { return text.toLowerCase().includes(keywords.toLowerCase()) } - const allProviders = useMemo(() => [...buildInTools, ...customTools, ...workflowTools, ...mcpTools], [buildInTools, customTools, workflowTools, mcpTools]) + const allProviders = useMemo( + () => [...buildInTools, ...customTools, ...workflowTools, ...mcpTools], + [buildInTools, customTools, workflowTools, mcpTools], + ) const providerMap = useMemo(() => { const map = new Map<string, ToolWithProvider>() allProviders.forEach((provider) => { const key = provider.plugin_id || provider.id - if (key) - map.set(key, provider) + if (key) map.set(key, provider) }) return map }, [allProviders]) @@ -104,52 +103,42 @@ const AllTools = ({ let mergedTools: ToolWithProvider[] = [] if (activeTab === ToolTypeEnum.All) mergedTools = [...buildInTools, ...customTools, ...workflowTools, ...mcpTools] - if (activeTab === ToolTypeEnum.BuiltIn) - mergedTools = buildInTools - if (activeTab === ToolTypeEnum.Custom) - mergedTools = customTools - if (activeTab === ToolTypeEnum.Workflow) - mergedTools = workflowTools - if (activeTab === ToolTypeEnum.MCP) - mergedTools = mcpTools + if (activeTab === ToolTypeEnum.BuiltIn) mergedTools = buildInTools + if (activeTab === ToolTypeEnum.Custom) mergedTools = customTools + if (activeTab === ToolTypeEnum.Workflow) mergedTools = workflowTools + if (activeTab === ToolTypeEnum.MCP) mergedTools = mcpTools const normalizedSearch = trimmedSearchText.toLowerCase() const getLocalizedText = (text?: Record<string, string> | null) => { - if (!text) - return '' + if (!text) return '' - if (text[language]) - return text[language] + if (text[language]) return text[language] - if (text['en-US']) - return text['en-US'] + if (text['en-US']) return text['en-US'] const firstValue = Object.values(text).find(Boolean) return firstValue || '' } if (!hasFilter || !normalizedSearch) - return mergedTools.filter(toolWithProvider => toolWithProvider.tools.length > 0) + return mergedTools.filter((toolWithProvider) => toolWithProvider.tools.length > 0) return mergedTools.reduce<ToolWithProvider[]>((acc, toolWithProvider) => { const providerLabel = getLocalizedText(toolWithProvider.label) - const providerMatches = [ - toolWithProvider.name, - providerLabel, - ].some(text => isMatchingKeywords(text || '', normalizedSearch)) + const providerMatches = [toolWithProvider.name, providerLabel].some((text) => + isMatchingKeywords(text || '', normalizedSearch), + ) if (providerMatches) { - if (toolWithProvider.tools.length > 0) - acc.push(toolWithProvider) + if (toolWithProvider.tools.length > 0) acc.push(toolWithProvider) return acc } const matchedTools = toolWithProvider.tools.filter((tool) => { const toolLabel = getLocalizedText(tool.label) - return [ - tool.name, - toolLabel, - ].some(text => isMatchingKeywords(text || '', normalizedSearch)) + return [tool.name, toolLabel].some((text) => + isMatchingKeywords(text || '', normalizedSearch), + ) }) if (matchedTools.length > 0) { @@ -161,21 +150,27 @@ const AllTools = ({ return acc }, []) - }, [activeTab, buildInTools, customTools, workflowTools, mcpTools, trimmedSearchText, hasFilter, language]) + }, [ + activeTab, + buildInTools, + customTools, + workflowTools, + mcpTools, + trimmedSearchText, + hasFilter, + language, + ]) - const { - queryPluginsWithDebounced: fetchPlugins, - plugins: notInstalledPlugins = [], - } = useMarketplacePlugins() + const { queryPluginsWithDebounced: fetchPlugins, plugins: notInstalledPlugins = [] } = + useMarketplacePlugins() const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) useEffect(() => { - if (!enable_marketplace) - return + if (!enable_marketplace) return if (hasFilter) { fetchPlugins({ query: searchText, @@ -193,53 +188,48 @@ const AllTools = ({ const hasToolsListContent = tools.length > 0 || isShowRAGRecommendations const hasPluginContent = enable_marketplace && notInstalledPlugins.length > 0 const shouldShowEmptyState = hasFilter && !hasToolsListContent && !hasPluginContent - const shouldShowFeatured = showFeatured - && enable_marketplace - && !isInRAGPipeline - && activeTab === ToolTypeEnum.All - && !hasFilter + const shouldShowFeatured = + showFeatured && + enable_marketplace && + !isInRAGPipeline && + activeTab === ToolTypeEnum.All && + !hasFilter const shouldShowMarketplaceFooter = enable_marketplace && !hasFilter - const handleRAGSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { - if (!pluginDefaultValue) - return - onSelect(type, pluginDefaultValue as ToolDefaultValue) - }, [onSelect]) + const handleRAGSelect = useCallback<OnSelectBlock>( + (type, pluginDefaultValue) => { + if (!pluginDefaultValue) return + onSelect(type, pluginDefaultValue as ToolDefaultValue) + }, + [onSelect], + ) const toolsListTitle = useMemo(() => { - if (activeTab === ToolTypeEnum.BuiltIn) - return t($ => $.allToolPlugins, { ns: 'tools' }) - if (activeTab === ToolTypeEnum.Custom) - return t($ => $.allSwaggerAPIAsTool, { ns: 'tools' }) - if (activeTab === ToolTypeEnum.Workflow) - return t($ => $.allWorkflowAsTool, { ns: 'tools' }) - if (activeTab === ToolTypeEnum.MCP) - return t($ => $.allMCP, { ns: 'tools' }) - return t($ => $.allTools, { ns: 'tools' }) + if (activeTab === ToolTypeEnum.BuiltIn) return t(($) => $.allToolPlugins, { ns: 'tools' }) + if (activeTab === ToolTypeEnum.Custom) return t(($) => $.allSwaggerAPIAsTool, { ns: 'tools' }) + if (activeTab === ToolTypeEnum.Workflow) return t(($) => $.allWorkflowAsTool, { ns: 'tools' }) + if (activeTab === ToolTypeEnum.MCP) return t(($) => $.allMCP, { ns: 'tools' }) + return t(($) => $.allTools, { ns: 'tools' }) }, [activeTab, t]) return ( <div className={cn('max-w-[500px]', className)}> <div className="flex items-center justify-between border-b border-divider-subtle px-3"> <div className="flex h-8 items-center space-x-1"> - { - tabs.map(tab => ( - <div - className={cn( - 'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover', - 'text-xs font-medium text-text-secondary', - activeTab === tab.key && 'bg-state-base-hover-alt', - )} - key={tab.key} - onClick={() => setActiveTab(tab.key)} - > - {tab.name} - </div> - )) - } + {tabs.map((tab) => ( + <div + className={cn( + 'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover', + 'text-xs font-medium text-text-secondary', + activeTab === tab.key && 'bg-state-base-hover-alt', + )} + key={tab.key} + onClick={() => setActiveTab(tab.key)} + > + {tab.name} + </div> + ))} </div> - {isSupportGroupView && ( - <ViewTypeSelect viewType={activeView} onChange={setActiveView} /> - )} + {isSupportGroupView && <ViewTypeSelect viewType={activeView} onChange={setActiveView} />} </div> <div className="flex max-h-[464px] flex-col"> <div @@ -308,7 +298,7 @@ const AllTools = ({ <div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-center"> <SearchMenu className="size-8 text-text-quaternary" /> <div className="text-sm font-medium text-text-secondary"> - {t($ => $['tabs.noPluginsFound'], { ns: 'workflow' })} + {t(($) => $['tabs.noPluginsFound'], { ns: 'workflow' })} </div> <Link href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml" @@ -319,7 +309,7 @@ const AllTools = ({ variant="secondary-accent" className="h-6 cursor-pointer px-3 text-xs" > - {t($ => $['tabs.requestToCommunity'], { ns: 'workflow' })} + {t(($) => $['tabs.requestToCommunity'], { ns: 'workflow' })} </Button> </Link> </div> @@ -331,7 +321,7 @@ const AllTools = ({ href={getMarketplaceCategoryUrl(PluginCategoryEnum.tool)} target="_blank" > - <span>{t($ => $.findMoreInMarketplace, { ns: 'plugin' })}</span> + <span>{t(($) => $.findMoreInMarketplace, { ns: 'plugin' })}</span> <RiArrowRightUpLine className="ml-0.5 size-3" /> </Link> )} diff --git a/web/app/components/workflow/block-selector/block-selector-row.tsx b/web/app/components/workflow/block-selector/block-selector-row.tsx index a9f1cf43b0dd88..723e7dbea49957 100644 --- a/web/app/components/workflow/block-selector/block-selector-row.tsx +++ b/web/app/components/workflow/block-selector/block-selector-row.tsx @@ -8,50 +8,40 @@ type SharedProps = { hoverable?: boolean } -type ButtonRowProps = SharedProps - & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'disabled'> - & { +type ButtonRowProps = SharedProps & + Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'disabled'> & { as?: 'button' nativeDisabled?: boolean } -type DivRowProps = SharedProps - & Omit<HTMLAttributes<HTMLDivElement>, 'className'> - & { +type DivRowProps = SharedProps & + Omit<HTMLAttributes<HTMLDivElement>, 'className'> & { as: 'div' } type BlockSelectorRowProps = ButtonRowProps | DivRowProps -const rowClassName = (className?: string, disabled = false, hoverable = true) => cn( - 'flex h-8 w-full items-center rounded-lg pr-2 pl-3', - !disabled && hoverable && 'hover:bg-state-base-hover', - disabled && 'cursor-not-allowed', - className, -) +const rowClassName = (className?: string, disabled = false, hoverable = true) => + cn( + 'flex h-8 w-full items-center rounded-lg pr-2 pl-3', + !disabled && hoverable && 'hover:bg-state-base-hover', + disabled && 'cursor-not-allowed', + className, + ) -const buttonClassName = (className?: string, disabled = false, hoverable = true) => cn( - rowClassName(className, disabled, hoverable), - !disabled && 'cursor-pointer', - 'border-0 bg-transparent text-left focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden', -) +const buttonClassName = (className?: string, disabled = false, hoverable = true) => + cn( + rowClassName(className, disabled, hoverable), + !disabled && 'cursor-pointer', + 'border-0 bg-transparent text-left focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden', + ) export function BlockSelectorRow(props: BlockSelectorRowProps) { if (props.as === 'div') { - const { - as: _as, - children, - className, - disabled = false, - hoverable, - ...rest - } = props + const { as: _as, children, className, disabled = false, hoverable, ...rest } = props return ( - <div - className={rowClassName(className, disabled, hoverable)} - {...rest} - > + <div className={rowClassName(className, disabled, hoverable)} {...rest}> {children} </div> ) diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx index c1a700b698b7cc..0de591c14a3a3b 100644 --- a/web/app/components/workflow/block-selector/blocks.tsx +++ b/web/app/components/workflow/block-selector/blocks.tsx @@ -7,11 +7,7 @@ import { PreviewCardTrigger, } from '@langgenius/dify-ui/preview-card' import { groupBy } from 'es-toolkit/compat' -import { - memo, - useCallback, - useMemo, -} from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import Badge from '@/app/components/base/badge' @@ -43,79 +39,84 @@ const Blocks = ({ const previewCardHandle = useMemo(() => createPreviewCardHandle<BlockPreviewPayload>(), []) // Use external blocks if provided, otherwise fallback to hook-based blocks - const blocks = blocksFromProps || blocksFromHooks.map(block => ({ - metaData: { - classification: block.classification, - sort: 0, // Default sort order - type: block.type, - title: block.title, - author: 'Dify', - // @ts-expect-error Fix this missing field later - description: block.description, - }, - defaultValue: {}, - checkValid: () => ({ isValid: true }), - }) as NodeDefault) + const blocks = + blocksFromProps || + blocksFromHooks.map( + (block) => + ({ + metaData: { + classification: block.classification, + sort: 0, // Default sort order + type: block.type, + title: block.title, + author: 'Dify', + // @ts-expect-error Fix this missing field later + description: block.description, + }, + defaultValue: {}, + checkValid: () => ({ isValid: true }), + }) as NodeDefault, + ) const groups = useMemo(() => { - return BLOCK_CLASSIFICATIONS.reduce((acc, classification) => { - const grouped = groupBy(blocks, 'metaData.classification') - const list = (grouped[classification] || []).filter((block) => { - // Filter out trigger types from Blocks tab - if (block.metaData.type === BlockEnum.TriggerWebhook - || block.metaData.type === BlockEnum.TriggerSchedule - || block.metaData.type === BlockEnum.TriggerPlugin) { - return false - } + return BLOCK_CLASSIFICATIONS.reduce( + (acc, classification) => { + const grouped = groupBy(blocks, 'metaData.classification') + const list = (grouped[classification] || []).filter((block) => { + // Filter out trigger types from Blocks tab + if ( + block.metaData.type === BlockEnum.TriggerWebhook || + block.metaData.type === BlockEnum.TriggerSchedule || + block.metaData.type === BlockEnum.TriggerPlugin + ) { + return false + } - return block.metaData.title.toLowerCase().includes(searchText.toLowerCase()) && availableBlocksTypes.includes(block.metaData.type) - }) + return ( + block.metaData.title.toLowerCase().includes(searchText.toLowerCase()) && + availableBlocksTypes.includes(block.metaData.type) + ) + }) - return { - ...acc, - [classification]: list, - } - }, {} as Record<string, typeof blocks>) + return { + ...acc, + [classification]: list, + } + }, + {} as Record<string, typeof blocks>, + ) }, [blocks, availableBlocksTypes, searchText]) - const isEmpty = Object.values(groups).every(list => !list.length) + const isEmpty = Object.values(groups).every((list) => !list.length) - const renderGroup = useCallback((classification: BlockClassificationEnum) => { - const list = [...groups[classification]!].sort((a, b) => { - if (a.metaData.type === BlockEnum.AgentV2) - return -1 - if (b.metaData.type === BlockEnum.AgentV2) - return 1 - return (a.metaData.sort || 0) - (b.metaData.sort || 0) - }) - const { getNodes } = store.getState() - const nodes = getNodes() - const hasKnowledgeBaseNode = nodes.some(node => node.data.type === BlockEnum.KnowledgeBase) - const filteredList = list.filter((block) => { - if (hasKnowledgeBaseNode) - return block.metaData.type !== BlockEnum.KnowledgeBase - return true - }) + const renderGroup = useCallback( + (classification: BlockClassificationEnum) => { + const list = [...groups[classification]!].sort((a, b) => { + if (a.metaData.type === BlockEnum.AgentV2) return -1 + if (b.metaData.type === BlockEnum.AgentV2) return 1 + return (a.metaData.sort || 0) - (b.metaData.sort || 0) + }) + const { getNodes } = store.getState() + const nodes = getNodes() + const hasKnowledgeBaseNode = nodes.some((node) => node.data.type === BlockEnum.KnowledgeBase) + const filteredList = list.filter((block) => { + if (hasKnowledgeBaseNode) return block.metaData.type !== BlockEnum.KnowledgeBase + return true + }) - return ( - <div - key={classification} - className="mb-1 last-of-type:mb-0" - > - { - classification !== '-' && !!filteredList.length && ( + return ( + <div key={classification} className="mb-1 last-of-type:mb-0"> + {classification !== '-' && !!filteredList.length && ( <div className="flex h-[22px] items-start px-3 text-xs font-medium text-text-tertiary"> - {t($ => $[`tabs.${classification}`], { ns: 'workflow' })} + {t(($) => $[`tabs.${classification}`], { ns: 'workflow' })} </div> - ) - } - { - filteredList.map((block) => { + )} + {filteredList.map((block) => { if (block.metaData.type === BlockEnum.AgentV2) { return ( <AgentBlockItem key={block.metaData.type} block={block} - onSelect={agent => + onSelect={(agent) => onSelect(BlockEnum.AgentV2, { agent_binding: { binding_type: 'roster_agent', @@ -123,7 +124,8 @@ const Blocks = ({ }, agent_node_kind: 'dify_agent', version: '2', - })} + }) + } onStartFromScratch={() => onSelect(BlockEnum.AgentV2, { agent_binding: { @@ -131,7 +133,8 @@ const Blocks = ({ }, agent_node_kind: 'dify_agent', version: '2', - })} + }) + } /> ) } @@ -143,49 +146,43 @@ const Blocks = ({ closeDelay={150} handle={previewCardHandle} payload={{ block }} - render={( + render={ <button type="button" className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={() => onSelect(block.metaData.type)} > - <BlockIcon - className="mr-2 shrink-0" - type={block.metaData.type} - /> - <span className="min-w-0 grow truncate text-sm text-text-secondary">{block.metaData.title}</span> - { - block.metaData.type === BlockEnum.LoopEnd && ( - <Badge - text={t($ => $['nodes.loop.loopNode'], { ns: 'workflow' })} - className="ml-2 shrink-0" - /> - ) - } + <BlockIcon className="mr-2 shrink-0" type={block.metaData.type} /> + <span className="min-w-0 grow truncate text-sm text-text-secondary"> + {block.metaData.title} + </span> + {block.metaData.type === BlockEnum.LoopEnd && ( + <Badge + text={t(($) => $['nodes.loop.loopNode'], { ns: 'workflow' })} + className="ml-2 shrink-0" + /> + )} </button> - )} + } /> ) - }) - } - </div> - ) - }, [groups, onSelect, previewCardHandle, t, store]) + })} + </div> + ) + }, + [groups, onSelect, previewCardHandle, t, store], + ) return ( <div className="max-h-[480px] max-w-[500px] overflow-y-auto p-1"> - { - isEmpty && ( - <div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary">{t($ => $['tabs.noResult'], { ns: 'workflow' })}</div> - ) - } - { - !isEmpty && BLOCK_CLASSIFICATIONS.map(renderGroup) - } + {isEmpty && ( + <div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary"> + {t(($) => $['tabs.noResult'], { ns: 'workflow' })} + </div> + )} + {!isEmpty && BLOCK_CLASSIFICATIONS.map(renderGroup)} <PreviewCard handle={previewCardHandle}> - {({ payload }) => ( - <BlockPreviewCard payload={payload as BlockPreviewPayload | undefined} /> - )} + {({ payload }) => <BlockPreviewCard payload={payload as BlockPreviewPayload | undefined} />} </PreviewCard> </div> ) @@ -195,27 +192,19 @@ type BlockPreviewCardProps = { payload?: BlockPreviewPayload } -function BlockPreviewCard({ - payload, -}: BlockPreviewCardProps) { - if (!payload) - return null +function BlockPreviewCard({ payload }: BlockPreviewCardProps) { + if (!payload) return null const { block } = payload return ( - <PreviewCardContent - placement="right" - popupClassName="w-[200px] border-none px-3 py-2" - > + <PreviewCardContent placement="right" popupClassName="w-[200px] border-none px-3 py-2"> <div> - <BlockIcon - size="md" - className="mb-2" - type={block.metaData.type} - /> + <BlockIcon size="md" className="mb-2" type={block.metaData.type} /> <div className="mb-1 system-md-medium text-text-primary">{block.metaData.title}</div> - <div className="system-xs-regular wrap-break-word text-text-tertiary">{block.metaData.description}</div> + <div className="system-xs-regular wrap-break-word text-text-tertiary"> + {block.metaData.description} + </div> </div> </PreviewCardContent> ) diff --git a/web/app/components/workflow/block-selector/data-sources.tsx b/web/app/components/workflow/block-selector/data-sources.tsx index 6a5116922f28f5..90e7db0e83e799 100644 --- a/web/app/components/workflow/block-selector/data-sources.tsx +++ b/web/app/components/workflow/block-selector/data-sources.tsx @@ -1,17 +1,9 @@ -import type { - OnSelectBlock, - ToolWithProvider, -} from '../types' +import type { OnSelectBlock, ToolWithProvider } from '../types' import type { DataSourceDefaultValue, ToolDefaultValue } from './types' import type { ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' -import { - useCallback, - useEffect, - useMemo, - useRef, -} from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import PluginList from '@/app/components/workflow/block-selector/market-place-plugin/list' import { useGetLanguage } from '@/context/i18n' import { systemFeaturesQueryOptions } from '@/features/system-features/client' @@ -48,48 +40,57 @@ const DataSources = ({ const filteredDatasources = useMemo(() => { const hasFilter = searchText if (!hasFilter) - return dataSources.filter(toolWithProvider => toolWithProvider.tools.length > 0) + return dataSources.filter((toolWithProvider) => toolWithProvider.tools.length > 0) return dataSources.filter((toolWithProvider) => { - return isMatchingKeywords(toolWithProvider.name, searchText) || toolWithProvider.tools.some((tool) => { - return tool.label[language]!.toLowerCase().includes(searchText.toLowerCase()) || tool.name.toLowerCase().includes(searchText.toLowerCase()) - }) + return ( + isMatchingKeywords(toolWithProvider.name, searchText) || + toolWithProvider.tools.some((tool) => { + return ( + tool.label[language]!.toLowerCase().includes(searchText.toLowerCase()) || + tool.name.toLowerCase().includes(searchText.toLowerCase()) + ) + }) + ) }) }, [searchText, dataSources, language]) - const handleSelect = useCallback((_: BlockEnum, toolDefaultValue: ToolDefaultValue) => { - let defaultValue: DataSourceDefaultValue = { - plugin_id: toolDefaultValue?.provider_id, - provider_type: toolDefaultValue?.provider_type, - provider_name: toolDefaultValue?.provider_name, - datasource_name: toolDefaultValue?.tool_name, - datasource_label: toolDefaultValue?.tool_label, - title: toolDefaultValue?.title, - plugin_unique_identifier: toolDefaultValue?.plugin_unique_identifier, - } - // Update defaultValue with fileExtensions if this is the local file data source - if (toolDefaultValue?.provider_id === 'langgenius/file' && toolDefaultValue?.provider_name === 'file') { - defaultValue = { - ...defaultValue, - fileExtensions: DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE, + const handleSelect = useCallback( + (_: BlockEnum, toolDefaultValue: ToolDefaultValue) => { + let defaultValue: DataSourceDefaultValue = { + plugin_id: toolDefaultValue?.provider_id, + provider_type: toolDefaultValue?.provider_type, + provider_name: toolDefaultValue?.provider_name, + datasource_name: toolDefaultValue?.tool_name, + datasource_label: toolDefaultValue?.tool_label, + title: toolDefaultValue?.title, + plugin_unique_identifier: toolDefaultValue?.plugin_unique_identifier, } - } - onSelect(BlockEnum.DataSource, toolDefaultValue && defaultValue) - }, [onSelect]) + // Update defaultValue with fileExtensions if this is the local file data source + if ( + toolDefaultValue?.provider_id === 'langgenius/file' && + toolDefaultValue?.provider_name === 'file' + ) { + defaultValue = { + ...defaultValue, + fileExtensions: DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE, + } + } + onSelect(BlockEnum.DataSource, toolDefaultValue && defaultValue) + }, + [onSelect], + ) const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) - const { - queryPluginsWithDebounced: fetchPlugins, - plugins: notInstalledPlugins = [], - } = useMarketplacePlugins() + const { queryPluginsWithDebounced: fetchPlugins, plugins: notInstalledPlugins = [] } = + useMarketplacePlugins() useEffect(() => { - if (!enable_marketplace) - return + if (!enable_marketplace) return if (searchText) { fetchPlugins({ query: searchText, diff --git a/web/app/components/workflow/block-selector/featured-tools.tsx b/web/app/components/workflow/block-selector/featured-tools.tsx index 838946fec50d67..e8c3c588f7dd22 100644 --- a/web/app/components/workflow/block-selector/featured-tools.tsx +++ b/web/app/components/workflow/block-selector/featured-tools.tsx @@ -5,7 +5,12 @@ import type { ToolDefaultValue, ToolValue } from './types' import type { Plugin } from '@/app/components/plugins/types' import type { Locale } from '@/i18n-config' import { cn } from '@langgenius/dify-ui/cn' -import { createPreviewCardHandle, PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card' +import { + createPreviewCardHandle, + PreviewCard, + PreviewCardContent, + PreviewCardTrigger, +} from '@langgenius/dify-ui/preview-card' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' @@ -62,15 +67,9 @@ const FeaturedTools = ({ setVisibleCount(INITIAL_VISIBLE_COUNT) } - const limitedPlugins = useMemo( - () => plugins.slice(0, MAX_RECOMMENDED_COUNT), - [plugins], - ) + const limitedPlugins = useMemo(() => plugins.slice(0, MAX_RECOMMENDED_COUNT), [plugins]) - const { - installedProviders, - uninstalledPlugins, - } = useMemo(() => { + const { installedProviders, uninstalledPlugins } = useMemo(() => { const installed: ToolWithProvider[] = [] const uninstalled: Plugin[] = [] const visitedProviderIds = new Set<string>() @@ -82,8 +81,7 @@ const FeaturedTools = ({ installed.push(provider) visitedProviderIds.add(provider.id) } - } - else { + } else { uninstalled.push(plugin) } }) @@ -109,7 +107,10 @@ const FeaturedTools = ({ ) const totalVisible = visibleInstalledProviders.length + visibleUninstalledPlugins.length - const maxAvailable = Math.min(MAX_RECOMMENDED_COUNT, installedProviders.length + uninstalledPlugins.length) + const maxAvailable = Math.min( + MAX_RECOMMENDED_COUNT, + installedProviders.length + uninstalledPlugins.length, + ) const hasMoreToShow = totalVisible < maxAvailable const canToggleVisibility = maxAvailable > INITIAL_VISIBLE_COUNT const isExpanded = canToggleVisibility && !hasMoreToShow @@ -120,14 +121,17 @@ const FeaturedTools = ({ <button type="button" className="flex w-full items-center rounded-md px-0 py-1 text-left text-text-primary" - onClick={() => setIsCollapsed(prev => !prev)} + onClick={() => setIsCollapsed((prev) => !prev)} > - <span className="system-xs-medium text-text-primary">{t($ => $['tabs.featuredTools'], { ns: 'workflow' })}</span> - <span className={cn( - 'i-custom-vender-solid-arrows-arrow-down-round-fill', - 'ml-0.5 size-4 text-text-tertiary transition-transform', - isCollapsed ? '-rotate-90' : 'rotate-0', - )} + <span className="system-xs-medium text-text-primary"> + {t(($) => $['tabs.featuredTools'], { ns: 'workflow' })} + </span> + <span + className={cn( + 'i-custom-vender-solid-arrows-arrow-down-round-fill', + 'ml-0.5 size-4 text-text-tertiary transition-transform', + isCollapsed ? '-rotate-90' : 'rotate-0', + )} /> </button> @@ -141,8 +145,13 @@ const FeaturedTools = ({ {showEmptyState && ( <p className="py-2 system-xs-regular text-text-tertiary"> - <Link className="text-text-accent" href={getMarketplaceCategoryUrl(PluginCategoryEnum.tool)} target="_blank" rel="noopener noreferrer"> - {t($ => $['tabs.noFeaturedPlugins'], { ns: 'workflow' })} + <Link + className="text-text-accent" + href={getMarketplaceCategoryUrl(PluginCategoryEnum.tool)} + target="_blank" + rel="noopener noreferrer" + > + {t(($) => $['tabs.noFeaturedPlugins'], { ns: 'workflow' })} </Link> </p> )} @@ -164,7 +173,7 @@ const FeaturedTools = ({ {visibleUninstalledPlugins.length > 0 && ( <div className="mt-1 flex flex-col gap-1"> - {visibleUninstalledPlugins.map(plugin => ( + {visibleUninstalledPlugins.map((plugin) => ( <FeaturedToolUninstalledItem key={plugin.plugin_id} plugin={plugin} @@ -186,8 +195,7 @@ const FeaturedTools = ({ className="group mt-1 flex cursor-pointer items-center gap-x-2 rounded-lg py-1 pr-2 pl-3 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary" onClick={() => { setVisibleCount((count) => { - if (count >= maxAvailable) - return INITIAL_VISIBLE_COUNT + if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable) }) @@ -195,16 +203,16 @@ const FeaturedTools = ({ > <div className="flex items-center px-1 text-text-tertiary transition-colors group-hover:text-text-secondary"> <span className="i-ri-more-line size-4 group-hover:hidden" /> - {isExpanded - ? ( - <span className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block" /> - ) - : ( - <span className="i-custom-vender-solid-arrows-arrow-down-double-line hidden size-4 group-hover:block" /> - )} + {isExpanded ? ( + <span className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block" /> + ) : ( + <span className="i-custom-vender-solid-arrows-arrow-down-double-line hidden size-4 group-hover:block" /> + )} </div> <div className="system-xs-regular"> - {t($ => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], { ns: 'workflow' })} + {t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], { + ns: 'workflow', + })} </div> </div> )} @@ -236,14 +244,16 @@ function FeaturedToolUninstalledItem({ }: FeaturedToolUninstalledItemProps) { const label = plugin.label?.[language] || plugin.name const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief - const installCountLabel = t($ => $.install, { ns: 'plugin', num: formatNumber(plugin.install_count || 0) }) + const installCountLabel = t(($) => $.install, { + ns: 'plugin', + num: formatNumber(plugin.install_count || 0), + }) const [actionOpen, setActionOpen] = useState(false) const [isInstallModalOpen, setIsInstallModalOpen] = useState(false) const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission() useEffect(() => { - if (!actionOpen) - return + if (!actionOpen) return const handleScroll = () => { setActionOpen(false) @@ -257,9 +267,7 @@ function FeaturedToolUninstalledItem({ }, [actionOpen]) const row = ( - <div - className="group flex h-8 w-full items-center rounded-lg pr-1 pl-3 hover:bg-state-base-hover" - > + <div className="group flex h-8 w-full items-center rounded-lg pr-1 pl-3 hover:bg-state-base-hover"> <div className="flex h-full min-w-0 items-center"> <BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} /> <div className="ml-2 min-w-0"> @@ -267,7 +275,11 @@ function FeaturedToolUninstalledItem({ </div> </div> <div className="ml-auto flex h-full items-center gap-1 pl-1"> - <span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span> + <span + className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`} + > + {installCountLabel} + </span> <div className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:size-6 [&_.action-btn]:min-h-0 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`} > @@ -280,7 +292,7 @@ function FeaturedToolUninstalledItem({ setIsInstallModalOpen(true) }} > - {t($ => $.installAction, { ns: 'plugin' })} + {t(($) => $.installAction, { ns: 'plugin' })} </button> )} <Action @@ -297,20 +309,20 @@ function FeaturedToolUninstalledItem({ return ( <> - {description - ? ( - // Preview is supplementary: icon / label / brief are all reachable from - // the InstallFromMarketplace modal that opens on click, so hover/focus-only - // activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection. - <PreviewCardTrigger - delay={150} - closeDelay={150} - handle={previewCardHandle} - payload={{ plugin, label, description }} - render={row} - /> - ) - : row} + {description ? ( + // Preview is supplementary: icon / label / brief are all reachable from + // the InstallFromMarketplace modal that opens on click, so hover/focus-only + // activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection. + <PreviewCardTrigger + delay={150} + closeDelay={150} + handle={previewCardHandle} + payload={{ plugin, label, description }} + render={row} + /> + ) : ( + row + )} {isInstallModalOpen && canInstallPlugin && ( <PluginInstallPermissionProvider canInstallPlugin={canInstallPlugin} @@ -337,18 +349,22 @@ type FeaturedToolPreviewCardProps = { payload?: FeaturedToolPreviewPayload } -function FeaturedToolPreviewCard({ - payload, -}: FeaturedToolPreviewCardProps) { - if (!payload) - return null +function FeaturedToolPreviewCard({ payload }: FeaturedToolPreviewCardProps) { + if (!payload) return null return ( <PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5"> <div> - <BlockIcon size="md" className="mb-2" type={BlockEnum.Tool} toolIcon={payload.plugin.icon} /> + <BlockIcon + size="md" + className="mb-2" + type={BlockEnum.Tool} + toolIcon={payload.plugin.icon} + /> <div className="mb-1 text-sm/5 text-text-primary">{payload.label}</div> - <div className="text-xs leading-[18px] wrap-break-word text-text-secondary">{payload.description}</div> + <div className="text-xs leading-[18px] wrap-break-word text-text-secondary"> + {payload.description} + </div> </div> </PreviewCardContent> ) diff --git a/web/app/components/workflow/block-selector/featured-triggers.tsx b/web/app/components/workflow/block-selector/featured-triggers.tsx index 7231eadf4cd131..15277ad856a611 100644 --- a/web/app/components/workflow/block-selector/featured-triggers.tsx +++ b/web/app/components/workflow/block-selector/featured-triggers.tsx @@ -5,7 +5,12 @@ import type { TriggerDefaultValue, TriggerWithProvider } from './types' import type { Plugin } from '@/app/components/plugins/types' import type { Locale } from '@/i18n-config' import { cn } from '@langgenius/dify-ui/cn' -import { createPreviewCardHandle, PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card' +import { + createPreviewCardHandle, + PreviewCard, + PreviewCardContent, + PreviewCardTrigger, +} from '@langgenius/dify-ui/preview-card' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' @@ -50,8 +55,14 @@ const FeaturedTriggers = ({ }: FeaturedTriggersProps) => { const { t } = useTranslation() const language = useGetLanguage() - const previewCardHandle = useMemo(() => createPreviewCardHandle<FeaturedTriggerPreviewPayload>(), []) - const triggerActionPreviewCardHandle = useMemo(() => createPreviewCardHandle<TriggerPluginActionPreviewPayload>(), []) + const previewCardHandle = useMemo( + () => createPreviewCardHandle<FeaturedTriggerPreviewPayload>(), + [], + ) + const triggerActionPreviewCardHandle = useMemo( + () => createPreviewCardHandle<TriggerPluginActionPreviewPayload>(), + [], + ) const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT) const [visibleCountPlugins, setVisibleCountPlugins] = useState(plugins) const [isCollapsed, setIsCollapsed] = useFeaturedTriggersCollapsed() @@ -61,28 +72,22 @@ const FeaturedTriggers = ({ setVisibleCount(INITIAL_VISIBLE_COUNT) } - const limitedPlugins = useMemo( - () => plugins.slice(0, MAX_RECOMMENDED_COUNT), - [plugins], - ) + const limitedPlugins = useMemo(() => plugins.slice(0, MAX_RECOMMENDED_COUNT), [plugins]) - const { - installedProviders, - uninstalledPlugins, - } = useMemo(() => { + const { installedProviders, uninstalledPlugins } = useMemo(() => { const installed: TriggerWithProvider[] = [] const uninstalled: Plugin[] = [] const visitedProviderIds = new Set<string>() limitedPlugins.forEach((plugin) => { - const provider = providerMap.get(plugin.plugin_id) || providerMap.get(plugin.latest_package_identifier) + const provider = + providerMap.get(plugin.plugin_id) || providerMap.get(plugin.latest_package_identifier) if (provider) { if (!visitedProviderIds.has(provider.id)) { installed.push(provider) visitedProviderIds.add(provider.id) } - } - else { + } else { uninstalled.push(plugin) } }) @@ -108,7 +113,10 @@ const FeaturedTriggers = ({ ) const totalVisible = visibleInstalledProviders.length + visibleUninstalledPlugins.length - const maxAvailable = Math.min(MAX_RECOMMENDED_COUNT, installedProviders.length + uninstalledPlugins.length) + const maxAvailable = Math.min( + MAX_RECOMMENDED_COUNT, + installedProviders.length + uninstalledPlugins.length, + ) const hasMoreToShow = totalVisible < maxAvailable const canToggleVisibility = maxAvailable > INITIAL_VISIBLE_COUNT const isExpanded = canToggleVisibility && !hasMoreToShow @@ -119,14 +127,17 @@ const FeaturedTriggers = ({ <button type="button" className="flex w-full items-center rounded-md px-4 py-1 text-left text-text-primary" - onClick={() => setIsCollapsed(prev => !prev)} + onClick={() => setIsCollapsed((prev) => !prev)} > - <span className="system-xs-medium text-text-primary">{t($ => $['tabs.featuredTools'], { ns: 'workflow' })}</span> - <span className={cn( - 'i-custom-vender-solid-arrows-arrow-down-round-fill', - 'ml-0.5 size-4 text-text-tertiary transition-transform', - isCollapsed ? '-rotate-90' : 'rotate-0', - )} + <span className="system-xs-medium text-text-primary"> + {t(($) => $['tabs.featuredTools'], { ns: 'workflow' })} + </span> + <span + className={cn( + 'i-custom-vender-solid-arrows-arrow-down-round-fill', + 'ml-0.5 size-4 text-text-tertiary transition-transform', + isCollapsed ? '-rotate-90' : 'rotate-0', + )} /> </button> @@ -140,15 +151,20 @@ const FeaturedTriggers = ({ {showEmptyState && ( <p className="px-4 py-2 system-xs-regular text-text-tertiary"> - <Link className="text-text-accent" href={getMarketplaceCategoryUrl(PluginCategoryEnum.trigger)} target="_blank" rel="noopener noreferrer"> - {t($ => $['tabs.noFeaturedTriggers'], { ns: 'workflow' })} + <Link + className="text-text-accent" + href={getMarketplaceCategoryUrl(PluginCategoryEnum.trigger)} + target="_blank" + rel="noopener noreferrer" + > + {t(($) => $['tabs.noFeaturedTriggers'], { ns: 'workflow' })} </Link> </p> )} {!showEmptyState && !isLoading && ( <div className="mt-1 p-1"> - {visibleInstalledProviders.map(provider => ( + {visibleInstalledProviders.map((provider) => ( <TriggerPluginItem key={provider.id} payload={provider} @@ -158,7 +174,7 @@ const FeaturedTriggers = ({ /> ))} - {visibleUninstalledPlugins.map(plugin => ( + {visibleUninstalledPlugins.map((plugin) => ( <div key={plugin.plugin_id} className="mb-1 last-of-type:mb-0"> <FeaturedTriggerUninstalledItem plugin={plugin} @@ -179,8 +195,7 @@ const FeaturedTriggers = ({ className="group mt-1 flex cursor-pointer items-center gap-x-2 rounded-lg py-1 pr-2 pl-3 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary" onClick={() => { setVisibleCount((count) => { - if (count >= maxAvailable) - return INITIAL_VISIBLE_COUNT + if (count >= maxAvailable) return INITIAL_VISIBLE_COUNT return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable) }) @@ -188,16 +203,16 @@ const FeaturedTriggers = ({ > <div className="flex items-center px-1 text-text-tertiary transition-colors group-hover:text-text-secondary"> <span className="i-ri-more-line size-4 group-hover:hidden" /> - {isExpanded - ? ( - <span className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block" /> - ) - : ( - <span className="i-custom-vender-solid-arrows-arrow-down-double-line hidden size-4 group-hover:block" /> - )} + {isExpanded ? ( + <span className="i-custom-vender-solid-arrows-arrow-up-double-line hidden size-4 group-hover:block" /> + ) : ( + <span className="i-custom-vender-solid-arrows-arrow-down-double-line hidden size-4 group-hover:block" /> + )} </div> <div className="system-xs-regular"> - {t($ => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], { ns: 'workflow' })} + {t(($) => $[isExpanded ? 'tabs.showLessFeatured' : 'tabs.showMoreFeatured'], { + ns: 'workflow', + })} </div> </div> )} @@ -205,12 +220,16 @@ const FeaturedTriggers = ({ )} <PreviewCard handle={previewCardHandle}> {({ payload }) => ( - <FeaturedTriggerPreviewCard payload={payload as FeaturedTriggerPreviewPayload | undefined} /> + <FeaturedTriggerPreviewCard + payload={payload as FeaturedTriggerPreviewPayload | undefined} + /> )} </PreviewCard> <PreviewCard handle={triggerActionPreviewCardHandle}> {({ payload }) => ( - <TriggerPluginActionPreviewCard payload={payload as TriggerPluginActionPreviewPayload | undefined} /> + <TriggerPluginActionPreviewCard + payload={payload as TriggerPluginActionPreviewPayload | undefined} + /> )} </PreviewCard> </div> @@ -234,14 +253,16 @@ function FeaturedTriggerUninstalledItem({ }: FeaturedTriggerUninstalledItemProps) { const label = plugin.label?.[language] || plugin.name const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief - const installCountLabel = t($ => $.install, { ns: 'plugin', num: formatNumber(plugin.install_count || 0) }) + const installCountLabel = t(($) => $.install, { + ns: 'plugin', + num: formatNumber(plugin.install_count || 0), + }) const [actionOpen, setActionOpen] = useState(false) const [isInstallModalOpen, setIsInstallModalOpen] = useState(false) const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission() useEffect(() => { - if (!actionOpen) - return + if (!actionOpen) return const handleScroll = () => { setActionOpen(false) @@ -257,13 +278,22 @@ function FeaturedTriggerUninstalledItem({ const row = ( <BlockSelectorRow as="div" className="group select-none"> <div className="flex min-w-0 items-center"> - <BlockIcon className="mr-2 shrink-0" type={BlockEnum.TriggerPlugin} size="sm" toolIcon={plugin.icon} /> + <BlockIcon + className="mr-2 shrink-0" + type={BlockEnum.TriggerPlugin} + size="sm" + toolIcon={plugin.icon} + /> <div className="min-w-0"> <div className="truncate system-sm-medium text-text-secondary">{label}</div> </div> </div> <div className="ml-auto flex h-6 items-center gap-1 pl-1"> - <span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span> + <span + className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`} + > + {installCountLabel} + </span> <div className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:size-6 [&_.action-btn]:min-h-0 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`} > @@ -276,7 +306,7 @@ function FeaturedTriggerUninstalledItem({ setIsInstallModalOpen(true) }} > - {t($ => $.installAction, { ns: 'plugin' })} + {t(($) => $.installAction, { ns: 'plugin' })} </button> )} <Action @@ -293,20 +323,20 @@ function FeaturedTriggerUninstalledItem({ return ( <> - {description - ? ( - // Preview is supplementary: icon / label / brief are all reachable from - // the InstallFromMarketplace modal that opens on click, so hover/focus-only - // activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection. - <PreviewCardTrigger - delay={150} - closeDelay={150} - handle={previewCardHandle} - payload={{ plugin, label, description }} - render={row} - /> - ) - : row} + {description ? ( + // Preview is supplementary: icon / label / brief are all reachable from + // the InstallFromMarketplace modal that opens on click, so hover/focus-only + // activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection. + <PreviewCardTrigger + delay={150} + closeDelay={150} + handle={previewCardHandle} + payload={{ plugin, label, description }} + render={row} + /> + ) : ( + row + )} {isInstallModalOpen && canInstallPlugin && ( <PluginInstallPermissionProvider canInstallPlugin={canInstallPlugin} @@ -333,18 +363,22 @@ type FeaturedTriggerPreviewCardProps = { payload?: FeaturedTriggerPreviewPayload } -function FeaturedTriggerPreviewCard({ - payload, -}: FeaturedTriggerPreviewCardProps) { - if (!payload) - return null +function FeaturedTriggerPreviewCard({ payload }: FeaturedTriggerPreviewCardProps) { + if (!payload) return null return ( <PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5"> <div> - <BlockIcon size="md" className="mb-2" type={BlockEnum.TriggerPlugin} toolIcon={payload.plugin.icon} /> + <BlockIcon + size="md" + className="mb-2" + type={BlockEnum.TriggerPlugin} + toolIcon={payload.plugin.icon} + /> <div className="mb-1 text-sm/5 text-text-primary">{payload.label}</div> - <div className="text-xs leading-[18px] wrap-break-word text-text-secondary">{payload.description}</div> + <div className="text-xs leading-[18px] wrap-break-word text-text-secondary"> + {payload.description} + </div> </div> </PreviewCardContent> ) diff --git a/web/app/components/workflow/block-selector/hooks.ts b/web/app/components/workflow/block-selector/hooks.ts index f1f6592be7317a..62ed987256f57c 100644 --- a/web/app/components/workflow/block-selector/hooks.ts +++ b/web/app/components/workflow/block-selector/hooks.ts @@ -1,16 +1,8 @@ import type { ReactNode } from 'react' -import { - useCallback, - useEffect, - useMemo, - useState, -} from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { BLOCKS } from './constants' -import { - TabsEnum, - ToolTypeEnum, -} from './types' +import { TabsEnum, ToolTypeEnum } from './types' const startNodesDocsTipLinkKey = 'startNodesDocs' as const @@ -20,7 +12,7 @@ export const useBlocks = () => { return BLOCKS.map((block) => { return { ...block, - title: t($ => $[`blocks.${block.type}`], { ns: 'workflow' }), + title: t(($) => $[`blocks.${block.type}`], { ns: 'workflow' }), } }) } @@ -50,70 +42,84 @@ export const useTabs = ({ const shouldShowStartTab = !noStart const shouldDisableStartTab = disableStartTab || (!forceEnableStartTab && hasStartPlaceholderNode) const startDisabledTip: ReactNode = disableStartTab - ? t($ => $['tabs.startNotSupportedTip'], { ns: 'workflow' }) + ? t(($) => $['tabs.startNotSupportedTip'], { ns: 'workflow' }) : hasStartPlaceholderNode - ? t($ => $['tabs.unconfiguredStartDisabledTip'], { ns: 'workflow' }) - : t($ => $['tabs.startDisabledTip'], { ns: 'workflow' }) + ? t(($) => $['tabs.unconfiguredStartDisabledTip'], { ns: 'workflow' }) + : t(($) => $['tabs.startDisabledTip'], { ns: 'workflow' }) const tabs = useMemo(() => { - const tabConfigs = [{ - key: TabsEnum.Blocks, - name: t($ => $['tabs.blocks'], { ns: 'workflow' }), - show: !noBlocks, - }, { - key: TabsEnum.Sources, - name: t($ => $['tabs.sources'], { ns: 'workflow' }), - show: !noSources, - }, { - key: TabsEnum.Tools, - name: t($ => $['tabs.tools'], { ns: 'workflow' }), - show: !noTools, - }, { - key: TabsEnum.Start, - name: t($ => $['tabs.start'], { ns: 'workflow' }), - show: shouldShowStartTab, - disabled: shouldDisableStartTab, - disabledTip: shouldDisableStartTab ? startDisabledTip : undefined, - disabledTipLinkKey: shouldDisableStartTab && !disableStartTab && hasStartPlaceholderNode ? startNodesDocsTipLinkKey : undefined, - }, { - key: TabsEnum.Snippets, - name: t($ => $['tabs.snippets'], { ns: 'workflow' }), - show: !noSnippets, - }] + const tabConfigs = [ + { + key: TabsEnum.Blocks, + name: t(($) => $['tabs.blocks'], { ns: 'workflow' }), + show: !noBlocks, + }, + { + key: TabsEnum.Sources, + name: t(($) => $['tabs.sources'], { ns: 'workflow' }), + show: !noSources, + }, + { + key: TabsEnum.Tools, + name: t(($) => $['tabs.tools'], { ns: 'workflow' }), + show: !noTools, + }, + { + key: TabsEnum.Start, + name: t(($) => $['tabs.start'], { ns: 'workflow' }), + show: shouldShowStartTab, + disabled: shouldDisableStartTab, + disabledTip: shouldDisableStartTab ? startDisabledTip : undefined, + disabledTipLinkKey: + shouldDisableStartTab && !disableStartTab && hasStartPlaceholderNode + ? startNodesDocsTipLinkKey + : undefined, + }, + { + key: TabsEnum.Snippets, + name: t(($) => $['tabs.snippets'], { ns: 'workflow' }), + show: !noSnippets, + }, + ] - return tabConfigs.filter(tab => tab.show) - }, [t, noBlocks, noSources, noTools, noSnippets, shouldShowStartTab, shouldDisableStartTab, startDisabledTip, disableStartTab, hasStartPlaceholderNode]) + return tabConfigs.filter((tab) => tab.show) + }, [ + t, + noBlocks, + noSources, + noTools, + noSnippets, + shouldShowStartTab, + shouldDisableStartTab, + startDisabledTip, + disableStartTab, + hasStartPlaceholderNode, + ]) - const getValidTabKey = useCallback((targetKey?: TabsEnum) => { - if (!targetKey) - return undefined - const tab = tabs.find(tabItem => tabItem.key === targetKey) - if (!tab || tab.disabled) - return undefined - return tab.key - }, [tabs]) + const getValidTabKey = useCallback( + (targetKey?: TabsEnum) => { + if (!targetKey) return undefined + const tab = tabs.find((tabItem) => tabItem.key === targetKey) + if (!tab || tab.disabled) return undefined + return tab.key + }, + [tabs], + ) const initialTab = useMemo(() => { - const fallbackTab = tabs.find(tab => !tab.disabled)?.key ?? TabsEnum.Blocks + const fallbackTab = tabs.find((tab) => !tab.disabled)?.key ?? TabsEnum.Blocks const preferredDefault = getValidTabKey(defaultActiveTab) - if (preferredDefault) - return preferredDefault + if (preferredDefault) return preferredDefault const preferredOrder: TabsEnum[] = [] - if (!noBlocks) - preferredOrder.push(TabsEnum.Blocks) - if (!noTools) - preferredOrder.push(TabsEnum.Tools) - if (!noSources) - preferredOrder.push(TabsEnum.Sources) - if (!noStart) - preferredOrder.push(TabsEnum.Start) - if (!noSnippets) - preferredOrder.push(TabsEnum.Snippets) + if (!noBlocks) preferredOrder.push(TabsEnum.Blocks) + if (!noTools) preferredOrder.push(TabsEnum.Tools) + if (!noSources) preferredOrder.push(TabsEnum.Sources) + if (!noStart) preferredOrder.push(TabsEnum.Start) + if (!noSnippets) preferredOrder.push(TabsEnum.Snippets) for (const tabKey of preferredOrder) { const validKey = getValidTabKey(tabKey) - if (validKey) - return validKey + if (validKey) return validKey } return fallbackTab @@ -124,9 +130,8 @@ export const useTabs = ({ }, [initialTab]) useEffect(() => { - const currentTab = tabs.find(tab => tab.key === activeTab) - if (!currentTab || currentTab.disabled) - resetActiveTab() + const currentTab = tabs.find((tab) => tab.key === activeTab) + if (!currentTab || currentTab.disabled) resetActiveTab() }, [tabs, activeTab, resetActiveTab]) return { @@ -142,19 +147,19 @@ export const useToolTabs = (isHideMCPTools?: boolean) => { const tabs = [ { key: ToolTypeEnum.All, - name: t($ => $['tabs.allTool'], { ns: 'workflow' }), + name: t(($) => $['tabs.allTool'], { ns: 'workflow' }), }, { key: ToolTypeEnum.BuiltIn, - name: t($ => $['tabs.plugin'], { ns: 'workflow' }), + name: t(($) => $['tabs.plugin'], { ns: 'workflow' }), }, { key: ToolTypeEnum.Custom, - name: t($ => $['tabs.customTool'], { ns: 'workflow' }), + name: t(($) => $['tabs.customTool'], { ns: 'workflow' }), }, { key: ToolTypeEnum.Workflow, - name: t($ => $['tabs.workflowTool'], { ns: 'workflow' }), + name: t(($) => $['tabs.workflowTool'], { ns: 'workflow' }), }, ] if (!isHideMCPTools) { diff --git a/web/app/components/workflow/block-selector/index-bar.tsx b/web/app/components/workflow/block-selector/index-bar.tsx index d983519539ba6e..9f5cd4f8921abd 100644 --- a/web/app/components/workflow/block-selector/index-bar.tsx +++ b/web/app/components/workflow/block-selector/index-bar.tsx @@ -23,40 +23,33 @@ export const AGENT_GROUP_NAME = '@@@agent@@@' } } */ -export const groupItems = (items: ToolWithProvider[], getFirstChar: (item: ToolWithProvider) => string) => { +export const groupItems = ( + items: ToolWithProvider[], + getFirstChar: (item: ToolWithProvider) => string, +) => { const groups = items.reduce((acc: Record<string, Record<string, ToolWithProvider[]>>, item) => { const firstChar = getFirstChar(item) - if (!firstChar || firstChar.length === 0) - return acc + if (!firstChar || firstChar.length === 0) return acc let letter // transform Chinese to pinyin if (/[\u4E00-\u9FA5]/.test(firstChar)) letter = pinyin(firstChar, { pattern: 'first', toneType: 'none' })[0]!.toUpperCase() - else - letter = firstChar.toUpperCase() + else letter = firstChar.toUpperCase() - if (!/[A-Z]/.test(letter)) - letter = '#' + if (!/[A-Z]/.test(letter)) letter = '#' - if (!acc[letter]) - acc[letter] = {} + if (!acc[letter]) acc[letter] = {} let groupName: string = '' - if (item.type === CollectionType.builtIn) - groupName = item.author - else if (item.type === CollectionType.custom) - groupName = CUSTOM_GROUP_NAME - else if (item.type === CollectionType.workflow) - groupName = WORKFLOW_GROUP_NAME - else if (item.type === CollectionType.datasource) - groupName = DATA_SOURCE_GROUP_NAME - else - groupName = AGENT_GROUP_NAME + if (item.type === CollectionType.builtIn) groupName = item.author + else if (item.type === CollectionType.custom) groupName = CUSTOM_GROUP_NAME + else if (item.type === CollectionType.workflow) groupName = WORKFLOW_GROUP_NAME + else if (item.type === CollectionType.datasource) groupName = DATA_SOURCE_GROUP_NAME + else groupName = AGENT_GROUP_NAME - if (!acc[letter]![groupName]) - acc[letter]![groupName] = [] + if (!acc[letter]![groupName]) acc[letter]![groupName] = [] acc[letter]![groupName]!.push(item) @@ -82,13 +75,21 @@ type IndexBarProps = { const IndexBar: FC<IndexBarProps> = ({ letters, itemRefs, className }) => { const handleIndexClick = (letter: string) => { const element = itemRefs.current?.[letter] - if (element) - element.scrollIntoView({ behavior: 'smooth' }) + if (element) element.scrollIntoView({ behavior: 'smooth' }) } return ( - <div className={cn('index-bar sticky top-[20px] flex h-full w-6 flex-col items-center justify-center text-xs font-medium text-text-quaternary', className)}> - <div className={cn('absolute top-0 left-0 h-full w-px bg-[linear-gradient(270deg,rgba(255,255,255,0)_0%,rgba(16,24,40,0.08)_30%,rgba(16,24,40,0.08)_50%,rgba(16,24,40,0.08)_70.5%,rgba(255,255,255,0)_100%)]')}></div> - {letters.map(letter => ( + <div + className={cn( + 'index-bar sticky top-[20px] flex h-full w-6 flex-col items-center justify-center text-xs font-medium text-text-quaternary', + className, + )} + > + <div + className={cn( + 'absolute top-0 left-0 h-full w-px bg-[linear-gradient(270deg,rgba(255,255,255,0)_0%,rgba(16,24,40,0.08)_30%,rgba(16,24,40,0.08)_50%,rgba(16,24,40,0.08)_70.5%,rgba(255,255,255,0)_100%)]', + )} + ></div> + {letters.map((letter) => ( <button type="button" className="cursor-pointer border-none bg-transparent p-0 text-left hover:text-text-secondary" diff --git a/web/app/components/workflow/block-selector/index.tsx b/web/app/components/workflow/block-selector/index.tsx index f4bb533e7bd446..4d54cc0e1648c4 100644 --- a/web/app/components/workflow/block-selector/index.tsx +++ b/web/app/components/workflow/block-selector/index.tsx @@ -1,40 +1,31 @@ import type { NodeSelectorProps } from './main' -import { - useMemo, -} from 'react' +import { useMemo } from 'react' import { useHooksStore } from '@/app/components/workflow/hooks-store/store' import { BlockEnum } from '@/app/components/workflow/types' import { useStore } from '../store' import NodeSelector from './main' const NodeSelectorWrapper = (props: NodeSelectorProps) => { - const availableNodesMetaData = useHooksStore(s => s.availableNodesMetaData) - const dataSourceList = useStore(s => s.dataSourceList) + const availableNodesMetaData = useHooksStore((s) => s.availableNodesMetaData) + const dataSourceList = useStore((s) => s.dataSourceList) const blocks = useMemo(() => { const result = availableNodesMetaData?.nodes || [] return result.filter((block) => { - if (block.metaData.type === BlockEnum.Start) - return false + if (block.metaData.type === BlockEnum.Start) return false - if (block.metaData.type === BlockEnum.StartPlaceholder) - return false + if (block.metaData.type === BlockEnum.StartPlaceholder) return false - if (block.metaData.type === BlockEnum.DataSource) - return false + if (block.metaData.type === BlockEnum.DataSource) return false - if (block.metaData.type === BlockEnum.Tool) - return false + if (block.metaData.type === BlockEnum.Tool) return false - if (block.metaData.type === BlockEnum.IterationStart) - return false + if (block.metaData.type === BlockEnum.IterationStart) return false - if (block.metaData.type === BlockEnum.LoopStart) - return false + if (block.metaData.type === BlockEnum.LoopStart) return false - if (block.metaData.type === BlockEnum.DataSourceEmpty) - return false + if (block.metaData.type === BlockEnum.DataSourceEmpty) return false return true }) diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 073a0d4ae6f261..58dc84e7962885 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -1,10 +1,5 @@ -import type { - OffsetOptions, - Placement, -} from '@floating-ui/react' -import type { - MouseEventHandler, -} from 'react' +import type { OffsetOptions, Placement } from '@floating-ui/react' +import type { MouseEventHandler } from 'react' import type { CommonNodeType, NodeDefault, @@ -13,20 +8,10 @@ import type { ToolWithProvider, } from '../types' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useDebounce } from 'ahooks' import * as React from 'react' -import { - memo, - useCallback, - useEffect, - useMemo, - useState, -} from 'react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import SearchBox from '@/app/components/plugins/marketplace/search-box' @@ -95,18 +80,19 @@ function NodeSelector({ }: NodeSelectorProps) { const { t } = useTranslation() const nodes = useNodes() - const flowType = useHooksStore(s => s.configsMap?.flowType) + const flowType = useHooksStore((s) => s.configsMap?.flowType) const [searchText, setSearchText] = useState('') - const [snippetsLoading, setSnippetsLoading] = useState(() => Boolean(openFromProps) && defaultActiveTab === TabsEnum.Snippets) + const [snippetsLoading, setSnippetsLoading] = useState( + () => Boolean(openFromProps) && defaultActiveTab === TabsEnum.Snippets, + ) const debouncedSearchText = useDebounce(searchText, { wait: 500 }) const [tags, setTags] = useState<string[]>([]) const [localOpen, setLocalOpen] = useState(false) // Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state. const filteredNodes = useMemo(() => { - if (!ignoreNodeIds.length) - return nodes + if (!ignoreNodeIds.length) return nodes const ignoreSet = new Set(ignoreNodeIds) - return nodes.filter(node => !ignoreSet.has(node.id)) + return nodes.filter((node) => !ignoreSet.has(node.id)) }, [nodes, ignoreNodeIds]) const { hasTriggerNode, hasUserInputNode, hasStartPlaceholderNode } = useMemo(() => { @@ -117,16 +103,11 @@ function NodeSelector({ } for (const node of filteredNodes) { const nodeType = (node.data as CommonNodeType | undefined)?.type - if (!nodeType) - continue - if (nodeType === BlockEnum.Start) - result.hasUserInputNode = true - if (nodeType === BlockEnum.StartPlaceholder) - result.hasStartPlaceholderNode = true - if (isTriggerNode(nodeType)) - result.hasTriggerNode = true - if (result.hasTriggerNode && result.hasUserInputNode && result.hasStartPlaceholderNode) - break + if (!nodeType) continue + if (nodeType === BlockEnum.Start) result.hasUserInputNode = true + if (nodeType === BlockEnum.StartPlaceholder) result.hasStartPlaceholderNode = true + if (isTriggerNode(nodeType)) result.hasTriggerNode = true + if (result.hasTriggerNode && result.hasUserInputNode && result.hasStartPlaceholderNode) break } return result }, [filteredNodes]) @@ -135,12 +116,7 @@ function NodeSelector({ const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection const disableStartTab = flowType === FlowType.snippet const disableSnippetsTab = flowType === FlowType.snippet - const { - activeTab, - resetActiveTab, - setActiveTab, - tabs, - } = useTabs({ + const { activeTab, resetActiveTab, setActiveTab, tabs } = useTabs({ noBlocks, noSources: !dataSources.length, noTools, @@ -152,46 +128,52 @@ function NodeSelector({ forceEnableStartTab, }) const open = openFromProps === undefined ? localOpen : openFromProps - const handleOpenChange = useCallback((newOpen: boolean) => { - if (disabled) - return + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (disabled) return - setLocalOpen(newOpen) + setLocalOpen(newOpen) - if (!newOpen) { - setSearchText('') - setSnippetsLoading(false) - resetActiveTab() - } - else if (activeTab === TabsEnum.Snippets) { - setSnippetsLoading(true) - } + if (!newOpen) { + setSearchText('') + setSnippetsLoading(false) + resetActiveTab() + } else if (activeTab === TabsEnum.Snippets) { + setSnippetsLoading(true) + } - if (onOpenChange) - onOpenChange(newOpen) - }, [activeTab, disabled, onOpenChange, resetActiveTab]) + if (onOpenChange) onOpenChange(newOpen) + }, + [activeTab, disabled, onOpenChange, resetActiveTab], + ) const handleTrigger = useCallback<MouseEventHandler<HTMLElement>>((e) => { e.stopPropagation() }, []) - const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { - handleOpenChange(false) - onSelect(type, pluginDefaultValue) - }, [handleOpenChange, onSelect]) + const handleSelect = useCallback<OnSelectBlock>( + (type, pluginDefaultValue) => { + handleOpenChange(false) + onSelect(type, pluginDefaultValue) + }, + [handleOpenChange, onSelect], + ) - const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => { - setActiveTab(newActiveTab) - if (open && newActiveTab === TabsEnum.Snippets) - setSnippetsLoading(true) - }, [open, setActiveTab]) - const handlePopupKeyDown = useCallback((event: React.KeyboardEvent) => { - if (isolateKeyboardEvents) - event.stopPropagation() - }, [isolateKeyboardEvents]) + const handleActiveTabChange = useCallback( + (newActiveTab: TabsEnum) => { + setActiveTab(newActiveTab) + if (open && newActiveTab === TabsEnum.Snippets) setSnippetsLoading(true) + }, + [open, setActiveTab], + ) + const handlePopupKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (isolateKeyboardEvents) event.stopPropagation() + }, + [isolateKeyboardEvents], + ) useEffect(() => { - if (!snippetsLoading) - return + if (!snippetsLoading) return const timer = window.setTimeout(() => { setSnippetsLoading(false) @@ -201,30 +183,26 @@ function NodeSelector({ window.clearTimeout(timer) } }, [snippetsLoading]) - const filterSearchText = activeTab === TabsEnum.Start || activeTab === TabsEnum.Tools - ? debouncedSearchText - : searchText + const filterSearchText = + activeTab === TabsEnum.Start || activeTab === TabsEnum.Tools ? debouncedSearchText : searchText const searchPlaceholder = useMemo(() => { - if (activeTab === TabsEnum.Start) - return t($ => $['tabs.searchTrigger'], { ns: 'workflow' }) + if (activeTab === TabsEnum.Start) return t(($) => $['tabs.searchTrigger'], { ns: 'workflow' }) - if (activeTab === TabsEnum.Blocks) - return t($ => $['tabs.searchBlock'], { ns: 'workflow' }) + if (activeTab === TabsEnum.Blocks) return t(($) => $['tabs.searchBlock'], { ns: 'workflow' }) - if (activeTab === TabsEnum.Tools) - return t($ => $['tabs.searchTool'], { ns: 'workflow' }) + if (activeTab === TabsEnum.Tools) return t(($) => $['tabs.searchTool'], { ns: 'workflow' }) if (activeTab === TabsEnum.Sources) - return t($ => $['tabs.searchDataSource'], { ns: 'workflow' }) + return t(($) => $['tabs.searchDataSource'], { ns: 'workflow' }) if (activeTab === TabsEnum.Snippets) - return t($ => $['tabs.searchSnippets'], { ns: 'workflow' }) + return t(($) => $['tabs.searchSnippets'], { ns: 'workflow' }) return '' }, [activeTab, t]) const defaultTriggerElement = ( <PopoverTrigger - aria-label={t($ => $['common.addBlock'], { ns: 'workflow' })} + aria-label={t(($) => $['common.addBlock'], { ns: 'workflow' })} className={cn( 'z-10 flex size-4 cursor-pointer items-center justify-center rounded-full border-0 bg-components-button-primary-bg p-0 text-text-primary-on-surface hover:bg-components-button-primary-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden', triggerClassName?.(open), @@ -238,32 +216,29 @@ function NodeSelector({ const triggerElement = trigger?.(open) const isValidTriggerElement = React.isValidElement(triggerElement) const isNativeButtonTrigger = isValidTriggerElement && triggerElement.type === 'button' - const shouldRenderTriggerAsButtonRoot = isValidTriggerElement && (renderTriggerAsButtonRoot || isNativeButtonTrigger) - const resolvedTriggerElement = shouldRenderTriggerAsButtonRoot - ? triggerElement - : ( - <div className={triggerInnerClassName}> - {triggerElement} - </div> - ) - const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset + const shouldRenderTriggerAsButtonRoot = + isValidTriggerElement && (renderTriggerAsButtonRoot || isNativeButtonTrigger) + const resolvedTriggerElement = shouldRenderTriggerAsButtonRoot ? ( + triggerElement + ) : ( + <div className={triggerInnerClassName}>{triggerElement}</div> + ) + const resolvedOffset = + typeof offset === 'number' || typeof offset === 'function' ? undefined : offset const sideOffset = typeof offset === 'number' ? offset : (resolvedOffset?.mainAxis ?? 0) const alignOffset = typeof offset === 'number' ? 0 : (resolvedOffset?.crossAxis ?? 0) return ( - <Popover - open={open} - onOpenChange={handleOpenChange} - > - {trigger - ? ( - <PopoverTrigger - nativeButton={shouldRenderTriggerAsButtonRoot} - onClick={handleTrigger} - render={resolvedTriggerElement as React.ReactElement} - /> - ) - : defaultTriggerElement} + <Popover open={open} onOpenChange={handleOpenChange}> + {trigger ? ( + <PopoverTrigger + nativeButton={shouldRenderTriggerAsButtonRoot} + onClick={handleTrigger} + render={resolvedTriggerElement as React.ReactElement} + /> + ) : ( + defaultTriggerElement + )} <PopoverContent placement={placement} sideOffset={sideOffset} @@ -271,7 +246,12 @@ function NodeSelector({ popupClassName="border-none bg-transparent shadow-none" popupProps={isolateKeyboardEvents ? { onKeyDown: handlePopupKeyDown } : undefined} > - <div className={cn('w-[400px] min-w-0 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg', popupClassName)}> + <div + className={cn( + 'w-[400px] min-w-0 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg', + popupClassName, + )} + > <Tabs tabs={tabs} activeTab={activeTab} @@ -280,56 +260,56 @@ function NodeSelector({ hasUserInputNode={hasUserInputNode} hasTriggerNode={hasTriggerNode} onActiveTabChange={handleActiveTabChange} - filterElem={activeTab === TabsEnum.Snippets - ? null - : ( - <div className="relative m-2" onClick={e => e.stopPropagation()}> - {activeTab === TabsEnum.Start && ( - <SearchBox - autoFocus - search={searchText} - onSearchChange={setSearchText} - tags={tags} - onTagsChange={setTags} - placeholder={searchPlaceholder} - inputClassName="grow" - /> - )} - {activeTab === TabsEnum.Blocks && ( - <Input - showLeftIcon - showClearIcon - autoFocus - value={searchText} - placeholder={searchPlaceholder} - onChange={e => setSearchText(e.target.value)} - onClear={() => setSearchText('')} - /> - )} - {activeTab === TabsEnum.Sources && ( - <Input - showLeftIcon - showClearIcon - autoFocus - value={searchText} - placeholder={searchPlaceholder} - onChange={e => setSearchText(e.target.value)} - onClear={() => setSearchText('')} - /> - )} - {activeTab === TabsEnum.Tools && ( - <SearchBox - autoFocus - search={searchText} - onSearchChange={setSearchText} - tags={tags} - onTagsChange={setTags} - placeholder={t($ => $.searchTools, { ns: 'plugin' })!} - inputClassName="grow" - /> - )} - </div> - )} + filterElem={ + activeTab === TabsEnum.Snippets ? null : ( + <div className="relative m-2" onClick={(e) => e.stopPropagation()}> + {activeTab === TabsEnum.Start && ( + <SearchBox + autoFocus + search={searchText} + onSearchChange={setSearchText} + tags={tags} + onTagsChange={setTags} + placeholder={searchPlaceholder} + inputClassName="grow" + /> + )} + {activeTab === TabsEnum.Blocks && ( + <Input + showLeftIcon + showClearIcon + autoFocus + value={searchText} + placeholder={searchPlaceholder} + onChange={(e) => setSearchText(e.target.value)} + onClear={() => setSearchText('')} + /> + )} + {activeTab === TabsEnum.Sources && ( + <Input + showLeftIcon + showClearIcon + autoFocus + value={searchText} + placeholder={searchPlaceholder} + onChange={(e) => setSearchText(e.target.value)} + onClear={() => setSearchText('')} + /> + )} + {activeTab === TabsEnum.Tools && ( + <SearchBox + autoFocus + search={searchText} + onSearchChange={setSearchText} + tags={tags} + onTagsChange={setTags} + placeholder={t(($) => $.searchTools, { ns: 'plugin' })!} + inputClassName="grow" + /> + )} + </div> + ) + } onSelect={handleSelect} searchText={filterSearchText} tags={tags} @@ -339,17 +319,17 @@ function NodeSelector({ noTools={noTools} onTagsChange={setTags} forceShowStartContent={forceShowStartContent} - snippetsElem={disableSnippetsTab - ? undefined - : ( - <Snippets - loading={snippetsLoading} - searchText={searchText} - onSearchTextChange={setSearchText} - insertPayload={snippetInsertPayload} - onInserted={() => handleOpenChange(false)} - /> - )} + snippetsElem={ + disableSnippetsTab ? undefined : ( + <Snippets + loading={snippetsLoading} + searchText={searchText} + onSearchTextChange={setSearchText} + insertPayload={snippetInsertPayload} + onInserted={() => handleOpenChange(false)} + /> + ) + } /> </div> </PopoverContent> diff --git a/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx b/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx index 1b0d890e537a29..8376e14ce4f5e6 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx @@ -33,13 +33,14 @@ vi.mock('@/utils/var', () => ({ getMarketplaceUrl: (path: string) => `https://marketplace.example${path}`, })) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, }, - }, -}) + }) const renderComponent = (props?: Partial<ComponentProps<typeof OperationDropdown>>) => { const queryClient = createQueryClient() diff --git a/web/app/components/workflow/block-selector/market-place-plugin/__tests__/item-list.spec.tsx b/web/app/components/workflow/block-selector/market-place-plugin/__tests__/item-list.spec.tsx index 85640e132f1300..e31ea7f1c5f3d0 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/__tests__/item-list.spec.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/__tests__/item-list.spec.tsx @@ -17,28 +17,35 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () = }) => ( <div data-testid="install-from-marketplace"> {uniqueIdentifier} - <button type="button" onClick={onSuccess}>install-success</button> - <button type="button" onClick={onClose}>install-close</button> + <button type="button" onClick={onSuccess}> + install-success + </button> + <button type="button" onClick={onClose}> + install-close + </button> </div> ), })) -vi.mock('@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', () => ({ - default: () => ({ - canInstallPlugin: true, - currentDifyVersion: '1.0.0', +vi.mock( + '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', + () => ({ + default: () => ({ + canInstallPlugin: true, + currentDifyVersion: '1.0.0', + }), }), -})) +) vi.mock('../action', () => ({ - default: ({ open, onOpenChange }: { open: boolean, onOpenChange: (open: boolean) => void }) => ( + default: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => ( <button type="button" onClick={() => onOpenChange(!open)}> marketplace-action </button> ), })) -vi.mock('@/utils/var', async importOriginal => ({ +vi.mock('@/utils/var', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/utils/var')>()), getMarketplaceUrl: (path = '', params?: Record<string, unknown>) => { const searchParams = new URLSearchParams(params as Record<string, string>) @@ -93,7 +100,10 @@ describe('marketplace plugin selector components', () => { />, ) - expect(screen.getByRole('link', { name: /plugin\.findMoreInMarketplace/i })).toHaveAttribute('href', 'https://marketplace.test/plugins/tool') + expect(screen.getByRole('link', { name: /plugin\.findMoreInMarketplace/i })).toHaveAttribute( + 'href', + 'https://marketplace.test/plugins/tool', + ) rerender( <List @@ -107,10 +117,15 @@ describe('marketplace plugin selector components', () => { expect(screen.getByText('plugin.fromMarketplace')).toBeInTheDocument() expect(screen.getByText('Filtered Plugin')).toBeInTheDocument() - const marketplaceSearchLinks = screen.getAllByRole('link', { name: /plugin\.searchInMarketplace/i }) + const marketplaceSearchLinks = screen.getAllByRole('link', { + name: /plugin\.searchInMarketplace/i, + }) expect(marketplaceSearchLinks).toHaveLength(1) expect(marketplaceSearchLinks[0]).toHaveAttribute('href', expect.stringContaining('q=filtered')) - expect(marketplaceSearchLinks[0]).toHaveAttribute('href', expect.stringContaining('/plugins/tool')) + expect(marketplaceSearchLinks[0]).toHaveAttribute( + 'href', + expect.stringContaining('/plugins/tool'), + ) }) it('should hide the marketplace footer when requested and no filters are active', () => { diff --git a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx index c2193afd41d53b..473fb2cf20930f 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx @@ -24,25 +24,20 @@ type Props = Readonly<{ version: string }> -const OperationDropdown: FC<Props> = ({ - open, - onOpenChange, - author, - name, - version, -}) => { +const OperationDropdown: FC<Props> = ({ open, onOpenChange, author, name, version }) => { const { t } = useTranslation() const { theme } = useTheme() - const downloadMutation = useMutation(marketplaceQuery.downloadPlugin.mutationOptions({ - onSuccess: (blob) => { - downloadBlob({ data: blob, fileName: `${author}-${name}_${version}.zip` }) - }, - })) + const downloadMutation = useMutation( + marketplaceQuery.downloadPlugin.mutationOptions({ + onSuccess: (blob) => { + downloadBlob({ data: blob, fileName: `${author}-${name}_${version}.zip` }) + }, + }), + ) const handleDownload = () => { - if (downloadMutation.isPending) - return + if (downloadMutation.isPending) return onOpenChange(false) downloadMutation.mutate({ @@ -55,27 +50,23 @@ const OperationDropdown: FC<Props> = ({ } return ( - <DropdownMenu - open={open} - onOpenChange={onOpenChange} - > + <DropdownMenu open={open} onOpenChange={onOpenChange}> <DropdownMenuTrigger - render={( + render={ <ActionButton className="focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover" - aria-label={t($ => $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.more'], { ns: 'common' })} > - <span aria-hidden className="i-ri-more-fill size-4 text-components-button-secondary-accent-text" /> + <span + aria-hidden + className="i-ri-more-fill size-4 text-components-button-secondary-accent-text" + /> </ActionButton> - )} + } /> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="min-w-[176px]" - > + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[176px]"> <DropdownMenuItem className="system-md-regular" onClick={handleDownload}> - {t($ => $['operation.download'], { ns: 'common' })} + {t(($) => $['operation.download'], { ns: 'common' })} </DropdownMenuItem> <DropdownMenuLinkItem className="system-md-regular" @@ -83,7 +74,7 @@ const OperationDropdown: FC<Props> = ({ target="_blank" rel="noopener noreferrer" > - {t($ => $['operation.viewDetails'], { ns: 'common' })} + {t(($) => $['operation.viewDetails'], { ns: 'common' })} </DropdownMenuLinkItem> </DropdownMenuContent> </DropdownMenu> diff --git a/web/app/components/workflow/block-selector/market-place-plugin/item.tsx b/web/app/components/workflow/block-selector/market-place-plugin/item.tsx index b37d3d40aab28e..3946ffcff74c11 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/item.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/item.tsx @@ -9,7 +9,6 @@ import { PluginInstallPermissionProvider } from '@/app/components/plugins/instal import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import { useLocale } from '@/context/i18n' - import { formatNumber } from '@/utils/format' import Action from './action' @@ -20,18 +19,14 @@ type Props = Readonly<{ onAction: (type: ActionType) => void }> -const Item: FC<Props> = ({ - payload, -}) => { +const Item: FC<Props> = ({ payload }) => { const { t } = useTranslation() const [open, setOpen] = React.useState(false) const locale = useLocale() const getLocalizedText = (obj: Record<string, string> | undefined) => obj?.[locale] || obj?.['en-US'] || obj?.en_US || '' - const [isShowInstallModal, { - setTrue: showInstallModal, - setFalse: hideInstallModal, - }] = useBoolean(false) + const [isShowInstallModal, { setTrue: showInstallModal, setFalse: hideInstallModal }] = + useBoolean(false) const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission() return ( @@ -42,23 +37,34 @@ const Item: FC<Props> = ({ /> <div className="ml-2 flex w-0 grow"> <div className="w-0 grow"> - <div className="h-4 truncate system-sm-medium leading-4 text-text-primary">{getLocalizedText(payload.label)}</div> - <div className="h-5 truncate system-xs-regular leading-5 text-text-tertiary">{getLocalizedText(payload.brief)}</div> + <div className="h-4 truncate system-sm-medium leading-4 text-text-primary"> + {getLocalizedText(payload.label)} + </div> + <div className="h-5 truncate system-xs-regular leading-5 text-text-tertiary"> + {getLocalizedText(payload.brief)} + </div> <div className="flex space-x-1 system-xs-regular text-text-tertiary"> <div>{payload.org}</div> <div>·</div> - <div>{t($ => $.install, { ns: 'plugin', num: formatNumber(payload.install_count || 0) })}</div> + <div> + {t(($) => $.install, { ns: 'plugin', num: formatNumber(payload.install_count || 0) })} + </div> </div> </div> {/* Action */} - <div className={cn(!open ? 'hidden' : 'flex', 'h-4 items-center space-x-1 system-xs-medium text-components-button-secondary-accent-text group-hover/plugin:flex')}> + <div + className={cn( + !open ? 'hidden' : 'flex', + 'h-4 items-center space-x-1 system-xs-medium text-components-button-secondary-accent-text group-hover/plugin:flex', + )} + > {canInstallPlugin && ( <button type="button" className="cursor-pointer rounded-md border-0 bg-transparent px-1.5 py-0.5 hover:bg-state-base-hover" onClick={showInstallModal} > - {t($ => $.installAction, { ns: 'plugin' })} + {t(($) => $.installAction, { ns: 'plugin' })} </button> )} <Action diff --git a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx index 50203e8bec29f5..7e81c5b9d993fd 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx @@ -39,7 +39,10 @@ const List = ({ const { t } = useTranslation() const noFilter = !searchText && tags.length === 0 const hasRes = list.length > 0 - const urlWithSearchText = getMarketplaceCategoryUrl(category, { q: searchText, tags: tags.join(',') }) + const urlWithSearchText = getMarketplaceCategoryUrl(category, { + q: searchText, + tags: tags.join(','), + }) const nextToStickyELemRef = useRef<HTMLDivElement>(null) const { handleScroll, scrollPosition } = useStickyScroll({ @@ -74,8 +77,7 @@ const List = ({ } if (noFilter) { - if (hideFindMoreFooter) - return null + if (hideFindMoreFooter) return null return ( <Link @@ -84,7 +86,7 @@ const List = ({ target="_blank" rel="noopener noreferrer" > - <span>{t($ => $.findMoreInMarketplace, { ns: 'plugin' })}</span> + <span>{t(($) => $.findMoreInMarketplace, { ns: 'plugin' })}</span> <RiArrowRightUpLine className="ml-0.5 size-3" /> </Link> ) @@ -96,29 +98,29 @@ const List = ({ <> {hasRes && ( <div - className={cn('sticky z-10 flex h-8 cursor-pointer justify-between px-4 py-1 system-sm-medium text-text-primary', stickyClassName, !disableMaxWidth && maxWidthClassName)} + className={cn( + 'sticky z-10 flex h-8 cursor-pointer justify-between px-4 py-1 system-sm-medium text-text-primary', + stickyClassName, + !disableMaxWidth && maxWidthClassName, + )} onClick={handleHeadClick} > - <span>{t($ => $.fromMarketplace, { ns: 'plugin' })}</span> + <span>{t(($) => $.fromMarketplace, { ns: 'plugin' })}</span> <Link href={urlWithSearchText} target="_blank" rel="noopener noreferrer" className="flex items-center text-text-accent-light-mode-only" - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > - <span>{t($ => $.searchInMarketplace, { ns: 'plugin' })}</span> + <span>{t(($) => $.searchInMarketplace, { ns: 'plugin' })}</span> <RiArrowRightUpLine className="ml-0.5 size-3" /> </Link> </div> )} <div className={cn('p-1', !disableMaxWidth && maxWidthClassName)} ref={nextToStickyELemRef}> {list.map((item, index) => ( - <Item - key={index} - payload={item} - onAction={noop} - /> + <Item key={index} payload={item} onAction={noop} /> ))} </div> </> diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/__tests__/list.spec.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/__tests__/list.spec.tsx index a9ffafca722cd3..5a900f1996222c 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/__tests__/list.spec.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/__tests__/list.spec.tsx @@ -25,24 +25,23 @@ vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability' }), })) -vi.mock('@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', () => ({ - default: () => ({ - canInstallPlugin: true, - currentDifyVersion: '1.0.0', +vi.mock( + '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', + () => ({ + default: () => ({ + canInstallPlugin: true, + currentDifyVersion: '1.0.0', + }), }), -})) +) vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ - default: ({ - uniqueIdentifier, - onClose, - }: { - uniqueIdentifier: string - onClose: () => void - }) => ( + default: ({ uniqueIdentifier, onClose }: { uniqueIdentifier: string; onClose: () => void }) => ( <div data-testid="install-from-marketplace"> {uniqueIdentifier} - <button type="button" onClick={onClose}>install-close</button> + <button type="button" onClick={onClose}> + install-close + </button> </div> ), })) @@ -78,10 +77,13 @@ describe('RAG tool recommendations list', () => { await user.click(screen.getByText('Provider Alpha')) await user.click(screen.getByText('Tool A')) - expect(onSelect).toHaveBeenCalledWith(BlockEnum.Tool, expect.objectContaining({ - provider_name: 'provider-one', - tool_name: 'tool-a', - })) + expect(onSelect).toHaveBeenCalledWith( + BlockEnum.Tool, + expect.objectContaining({ + provider_name: 'provider-one', + tool_name: 'tool-a', + }), + ) }) it('should render uninstalled plugins and open their install dialog', async () => { diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx index f96e9889cb77fd..5bc1725071a999 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx @@ -36,21 +36,19 @@ const RAGToolRecommendations = ({ } = useRAGRecommendedPlugins('tool') const recommendedPlugins = useMemo(() => { - if (ragRecommendedPlugins) - return ragRecommendedPlugins.installed_recommended_plugins + if (ragRecommendedPlugins) return ragRecommendedPlugins.installed_recommended_plugins return [] }, [ragRecommendedPlugins]) const unInstalledPlugins = useMemo(() => { if (ragRecommendedPlugins) - return (ragRecommendedPlugins.uninstalled_recommended_plugins).map(getFormattedPlugin) + return ragRecommendedPlugins.uninstalled_recommended_plugins.map(getFormattedPlugin) return [] }, [ragRecommendedPlugins]) const loadMore = useCallback(() => { onTagsChange((prev) => { - if (prev.includes('rag')) - return prev + if (prev.includes('rag')) return prev return [...prev, 'rag'] }) }, [onTagsChange]) @@ -60,10 +58,14 @@ const RAGToolRecommendations = ({ <button type="button" className="flex w-full items-center rounded-md px-3 pt-1 pb-0.5 text-left text-text-tertiary" - onClick={() => setIsCollapsed(prev => !prev)} + onClick={() => setIsCollapsed((prev) => !prev)} > - <span className="system-xs-medium text-text-tertiary">{t($ => $['ragToolSuggestions.title'], { ns: 'pipeline' })}</span> - <ArrowDownRoundFill className={`ml-1 size-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} /> + <span className="system-xs-medium text-text-tertiary"> + {t(($) => $['ragToolSuggestions.title'], { ns: 'pipeline' })} + </span> + <ArrowDownRoundFill + className={`ml-1 size-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} + /> </button> {!isCollapsed && ( <> @@ -73,24 +75,26 @@ const RAGToolRecommendations = ({ <Loading type="app" /> </div> )} - {!isFetchingRAGRecommendedPlugins && recommendedPlugins.length === 0 && unInstalledPlugins.length === 0 && ( - <p className="px-3 py-1 system-xs-regular text-text-tertiary"> - <Trans - i18nKey={$ => $['ragToolSuggestions.noRecommendationPlugins']} - ns="pipeline" - components={{ - CustomLink: ( - <Link - className="text-text-accent" - target="_blank" - rel="noopener noreferrer" - href={getMarketplaceUrl('', { tags: 'rag' })} - /> - ), - }} - /> - </p> - )} + {!isFetchingRAGRecommendedPlugins && + recommendedPlugins.length === 0 && + unInstalledPlugins.length === 0 && ( + <p className="px-3 py-1 system-xs-regular text-text-tertiary"> + <Trans + i18nKey={($) => $['ragToolSuggestions.noRecommendationPlugins']} + ns="pipeline" + components={{ + CustomLink: ( + <Link + className="text-text-accent" + target="_blank" + rel="noopener noreferrer" + href={getMarketplaceUrl('', { tags: 'rag' })} + /> + ), + }} + /> + </p> + )} {(recommendedPlugins.length > 0 || unInstalledPlugins.length > 0) && ( <> <List @@ -107,7 +111,7 @@ const RAGToolRecommendations = ({ <RiMoreLine className="size-4 text-text-tertiary" /> </div> <div className="system-xs-regular text-text-tertiary"> - {t($ => $['operation.more'], { ns: 'common' })} + {t(($) => $['operation.more'], { ns: 'common' })} </div> </div> </> diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx index 3e536b91b58cf0..2b12e6c3036cd9 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx @@ -22,24 +22,20 @@ type ListProps = { className?: string } -const List = ({ - onSelect, - tools, - viewType, - unInstalledPlugins, - className, -}: ListProps) => { +const List = ({ onSelect, tools, viewType, unInstalledPlugins, className }: ListProps) => { const language = useGetLanguage() const previewCardHandle = useMemo(() => createPreviewCardHandle<ToolActionPreviewPayload>(), []) const isFlatView = viewType === ViewType.flat - const { letters, groups: withLetterAndGroupViewToolsData } = groupItems(tools, tool => tool.label[language]![0]!) + const { letters, groups: withLetterAndGroupViewToolsData } = groupItems( + tools, + (tool) => tool.label[language]![0]!, + ) const treeViewToolsData = useMemo(() => { const result: Record<string, ToolWithProvider[]> = {} Object.keys(withLetterAndGroupViewToolsData).forEach((letter) => { Object.keys(withLetterAndGroupViewToolsData[letter]!).forEach((groupName) => { - if (!result[groupName]) - result[groupName] = [] + if (!result[groupName]) result[groupName] = [] result[groupName].push(...(withLetterAndGroupViewToolsData[letter]![groupName] ?? [])) }) }) @@ -50,12 +46,14 @@ const List = ({ const result: ToolWithProvider[] = [] letters.forEach((letter) => { Object.keys(withLetterAndGroupViewToolsData[letter]!).forEach((groupName) => { - result.push(...withLetterAndGroupViewToolsData[letter]![groupName]!.map((item) => { - return { - ...item, - letter, - } - })) + result.push( + ...withLetterAndGroupViewToolsData[letter]![groupName]!.map((item) => { + return { + ...item, + letter, + } + }), + ) }) }) @@ -64,52 +62,45 @@ const List = ({ const toolRefsRef = useRef<Record<string, HTMLDivElement | null>>({}) - const handleSelect = useCallback((type: BlockEnum, tool: ToolDefaultValue) => { - onSelect(type, tool) - }, [onSelect]) + const handleSelect = useCallback( + (type: BlockEnum, tool: ToolDefaultValue) => { + onSelect(type, tool) + }, + [onSelect], + ) return ( <div className={cn('max-w-full p-1', className)}> - {!!tools.length && ( - isFlatView - ? ( - <ToolListFlatView - toolRefs={toolRefsRef} - letters={letters} - payload={listViewToolData} - previewCardHandle={previewCardHandle} - isShowLetterIndex={false} - hasSearchText={false} - onSelect={handleSelect} - canNotSelectMultiple - indexBar={null} - /> - ) - : ( - <ToolListTreeView - payload={treeViewToolsData} - previewCardHandle={previewCardHandle} - hasSearchText={false} - onSelect={handleSelect} - canNotSelectMultiple - /> - ) - )} + {!!tools.length && + (isFlatView ? ( + <ToolListFlatView + toolRefs={toolRefsRef} + letters={letters} + payload={listViewToolData} + previewCardHandle={previewCardHandle} + isShowLetterIndex={false} + hasSearchText={false} + onSelect={handleSelect} + canNotSelectMultiple + indexBar={null} + /> + ) : ( + <ToolListTreeView + payload={treeViewToolsData} + previewCardHandle={previewCardHandle} + hasSearchText={false} + onSelect={handleSelect} + canNotSelectMultiple + /> + ))} <PreviewCard handle={previewCardHandle}> {({ payload }) => ( <ToolActionPreviewCard payload={payload as ToolActionPreviewPayload | undefined} /> )} </PreviewCard> - { - unInstalledPlugins.map((item) => { - return ( - <UninstalledItem - key={item.plugin_id} - payload={item} - /> - ) - }) - } + {unInstalledPlugins.map((item) => { + return <UninstalledItem key={item.plugin_id} payload={item} /> + })} </div> ) } diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx index 37ab1d87086610..a9c514ddea0a21 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx @@ -14,42 +14,32 @@ type UninstalledItemProps = { payload: Plugin } -const UninstalledItem = ({ - payload, -}: UninstalledItemProps) => { +const UninstalledItem = ({ payload }: UninstalledItemProps) => { const { t } = useTranslation() const locale = useLocale() const getLocalizedText = (obj: Record<string, string> | undefined) => obj?.[locale] || obj?.['en-US'] || obj?.en_US || '' - const [isShowInstallModal, { - setTrue: showInstallModal, - setFalse: hideInstallModal, - }] = useBoolean(false) + const [isShowInstallModal, { setTrue: showInstallModal, setFalse: hideInstallModal }] = + useBoolean(false) const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission() return ( <div className="flex h-8 items-center rounded-lg pr-2 pl-3 hover:bg-state-base-hover"> - <BlockIcon - className="shrink-0" - type={BlockEnum.Tool} - toolIcon={payload.icon} - /> + <BlockIcon className="shrink-0" type={BlockEnum.Tool} toolIcon={payload.icon} /> <div className="ml-2 flex w-0 grow items-center"> <div className="flex w-0 grow items-center gap-x-2"> <span className="truncate system-sm-regular text-text-primary"> {getLocalizedText(payload.label)} </span> - <span className="system-xs-regular text-text-quaternary"> - {payload.org} - </span> + <span className="system-xs-regular text-text-quaternary">{payload.org}</span> </div> {canInstallPlugin && ( <div className="cursor-pointer pl-1.5 system-xs-medium text-components-button-secondary-accent-text" onClick={showInstallModal} > - {t($ => $.installAction, { ns: 'plugin' })} + {t(($) => $.installAction, { ns: 'plugin' })} </div> )} {isShowInstallModal && canInstallPlugin && ( diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx index a0ee27a948bdcc..ffd1e83efbec77 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx @@ -23,13 +23,7 @@ vi.mock('../use-insert-snippet', () => ({ })) vi.mock('../snippet-tags-filter', () => ({ - default: ({ - value, - onChange, - }: { - value: string[] - onChange: (value: string[]) => void - }) => ( + default: ({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) => ( <button type="button" onClick={() => onChange(['tag-1'])}> {`tag-filter:${value.join(',')}`} </button> @@ -60,29 +54,35 @@ describe('Snippets', () => { render(<Snippets searchText="" />) expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'workflow.tabs.createSnippet' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.tabs.createSnippet' }), + ).not.toBeInTheDocument() }) it('should render snippet rows from infinite list data', () => { mockUseInfiniteSnippetList.mockReturnValue({ data: { - pages: [{ - data: [{ - id: 'snippet-1', - name: 'Customer Review', - description: 'Snippet description', - type: 'group', - is_published: true, - version: '1.0.0', - use_count: 3, - tags: [], - input_fields: [], - created_at: 1, - created_by: 'user-1', - updated_at: 2, - updated_by: 'user-1', - }], - }], + pages: [ + { + data: [ + { + id: 'snippet-1', + name: 'Customer Review', + description: 'Snippet description', + type: 'group', + is_published: true, + version: '1.0.0', + use_count: 3, + tags: [], + input_fields: [], + created_at: 1, + created_by: 'user-1', + updated_at: 2, + updated_by: 'user-1', + }, + ], + }, + ], }, isLoading: false, isFetching: false, @@ -134,23 +134,27 @@ describe('Snippets', () => { it('should delegate insert action when snippet item is clicked', () => { mockUseInfiniteSnippetList.mockReturnValue({ data: { - pages: [{ - data: [{ - id: 'snippet-1', - name: 'Customer Review', - description: 'Snippet description', - type: 'group', - is_published: true, - version: '1.0.0', - use_count: 3, - tags: [], - input_fields: [], - created_at: 1, - created_by: 'user-1', - updated_at: 2, - updated_by: 'user-1', - }], - }], + pages: [ + { + data: [ + { + id: 'snippet-1', + name: 'Customer Review', + description: 'Snippet description', + type: 'group', + is_published: true, + version: '1.0.0', + use_count: 3, + tags: [], + input_fields: [], + created_at: 1, + created_by: 'user-1', + updated_at: 2, + updated_by: 'user-1', + }, + ], + }, + ], }, isLoading: false, isFetching: false, diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-detail-card.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-detail-card.spec.tsx index dc5ab2563371dd..a88dd9270de139 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-detail-card.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-detail-card.spec.tsx @@ -12,13 +12,25 @@ vi.mock('@/service/use-common', () => ({ useMembers: () => ({ data: { accounts: [ - { id: 'user-1', name: 'Evan', email: 'evan@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' }, + { + id: 'user-1', + name: 'Evan', + email: 'evan@example.com', + avatar: '', + avatar_url: null, + role: 'editor', + last_login_at: '', + created_at: '', + status: 'active', + }, ], }, }), })) -const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): PublishedSnippetListItem => ({ +const createSnippet = ( + overrides: Partial<PublishedSnippetListItem> = {}, +): PublishedSnippetListItem => ({ id: 'snippet-1', name: 'Customer Review', description: 'Snippet description', diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-empty-state.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-empty-state.spec.tsx index 91567b79fe1448..5f9c9599f476cd 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-empty-state.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-empty-state.spec.tsx @@ -11,7 +11,9 @@ describe('SnippetEmptyState', () => { render(<SnippetEmptyState />) expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'workflow.tabs.createSnippet' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.tabs.createSnippet' }), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-list-item.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-list-item.spec.tsx index 306382cffb6e64..23480be4592c9d 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-list-item.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-list-item.spec.tsx @@ -2,7 +2,9 @@ import type { PublishedSnippetListItem } from '../snippet-detail-card' import { fireEvent, render, screen } from '@testing-library/react' import SnippetListItem from '../snippet-list-item' -const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): PublishedSnippetListItem => ({ +const createSnippet = ( + overrides: Partial<PublishedSnippetListItem> = {}, +): PublishedSnippetListItem => ({ id: 'snippet-1', name: 'Customer Review', description: 'Snippet description', diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-tags-filter.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-tags-filter.spec.tsx index 21fe0872b0a382..ff02e386a2864b 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-tags-filter.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-tags-filter.spec.tsx @@ -11,7 +11,7 @@ vi.mock('@/service/client', () => ({ consoleQuery: { tags: { get: { - queryOptions: vi.fn(input => ({ queryKey: ['tags', input] })), + queryOptions: vi.fn((input) => ({ queryKey: ['tags', input] })), }, }, }, diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx index fcdeff1d1e68ba..be5559e79f34f6 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx @@ -4,14 +4,14 @@ import { useInsertSnippet } from '../use-insert-snippet' type TestNode = { id: string - position: { x: number, y: number } + position: { x: number; y: number } selected?: boolean parentId?: string zIndex?: number data: { type?: string selected?: boolean - _children?: { nodeId: string, nodeType: string }[] + _children?: { nodeId: string; nodeType: string }[] _connectedSourceHandleIds?: string[] _connectedTargetHandleIds?: string[] } @@ -105,7 +105,11 @@ describe('useInsertSnippet', () => { id: 'snippet-node-1', zIndex: 1, position: { x: 10, y: 20 }, - data: { type: 'iteration', selected: false, _children: [{ nodeId: 'snippet-node-2', nodeType: 'code' }] }, + data: { + type: 'iteration', + selected: false, + _children: [{ nodeId: 'snippet-node-2', nodeType: 'code' }], + }, }, { id: 'snippet-node-2', @@ -164,138 +168,161 @@ describe('useInsertSnippet', () => { }) }) - it.each(['iteration', 'loop'] as const)('should connect inserted snippet nodes inside the %s container', async (containerType) => { - mockGetNodes.mockReturnValue([ - { - id: 'container-node', - position: { x: 0, y: 0 }, - data: { - type: containerType, - selected: false, - _children: [ - { nodeId: 'prev-node', nodeType: 'code' }, - { nodeId: 'next-node', nodeType: 'code' }, - ], + it.each(['iteration', 'loop'] as const)( + 'should connect inserted snippet nodes inside the %s container', + async (containerType) => { + mockGetNodes.mockReturnValue([ + { + id: 'container-node', + position: { x: 0, y: 0 }, + data: { + type: containerType, + selected: false, + _children: [ + { nodeId: 'prev-node', nodeType: 'code' }, + { nodeId: 'next-node', nodeType: 'code' }, + ], + }, }, - }, - { - id: 'prev-node', - parentId: 'container-node', - position: { x: 0, y: 0 }, - width: 240, - data: { type: 'code', selected: true, _connectedSourceHandleIds: ['source'] }, - }, - { - id: 'next-node', - parentId: 'container-node', - position: { x: 300, y: 0 }, - data: { type: 'code', selected: false, _connectedTargetHandleIds: ['target'] }, - }, - ]) - mockEdges = [ - { - id: 'prev-node-source-next-node-target', - source: 'prev-node', - sourceHandle: 'source', - target: 'next-node', - targetHandle: 'target', - data: { - sourceType: 'code', - targetType: 'code', + { + id: 'prev-node', + parentId: 'container-node', + position: { x: 0, y: 0 }, + width: 240, + data: { type: 'code', selected: true, _connectedSourceHandleIds: ['source'] }, }, - }, - ] - mockFetchQuery.mockResolvedValue({ - graph: { - nodes: [ - { - id: 'snippet-entry', - position: { x: 0, y: 0 }, - data: { type: 'llm', selected: false }, - }, - { - id: 'snippet-exit', - position: { x: 300, y: 0 }, - data: { type: 'code', selected: false }, + { + id: 'next-node', + parentId: 'container-node', + position: { x: 300, y: 0 }, + data: { type: 'code', selected: false, _connectedTargetHandleIds: ['target'] }, + }, + ]) + mockEdges = [ + { + id: 'prev-node-source-next-node-target', + source: 'prev-node', + sourceHandle: 'source', + target: 'next-node', + targetHandle: 'target', + data: { + sourceType: 'code', + targetType: 'code', }, - ], - edges: [ - { - id: 'snippet-entry-source-snippet-exit-target', - source: 'snippet-entry', - sourceHandle: 'source', - target: 'snippet-exit', - targetHandle: 'target', - data: { - sourceType: 'llm', - targetType: 'code', + }, + ] + mockFetchQuery.mockResolvedValue({ + graph: { + nodes: [ + { + id: 'snippet-entry', + position: { x: 0, y: 0 }, + data: { type: 'llm', selected: false }, }, - }, - ], - }, - }) + { + id: 'snippet-exit', + position: { x: 300, y: 0 }, + data: { type: 'code', selected: false }, + }, + ], + edges: [ + { + id: 'snippet-entry-source-snippet-exit-target', + source: 'snippet-entry', + sourceHandle: 'source', + target: 'snippet-exit', + targetHandle: 'target', + data: { + sourceType: 'llm', + targetType: 'code', + }, + }, + ], + }, + }) - const { result } = renderHook(() => useInsertSnippet()) + const { result } = renderHook(() => useInsertSnippet()) - await act(async () => { - await result.current.handleInsertSnippet('snippet-1', { - prevNodeId: 'prev-node', - prevNodeSourceHandle: 'source', - nextNodeId: 'next-node', - nextNodeTargetHandle: 'target', + await act(async () => { + await result.current.handleInsertSnippet('snippet-1', { + prevNodeId: 'prev-node', + prevNodeSourceHandle: 'source', + nextNodeId: 'next-node', + nextNodeTargetHandle: 'target', + }) }) - }) - const nextNodes = mockSetNodes.mock.calls[0]![0] as TestNode[] - const insertedEntry = nextNodes.find(node => node.id !== 'prev-node' && node.id !== 'next-node' && node.id.includes('snippet-entry'))! - const insertedExit = nextNodes.find(node => node.id !== 'prev-node' && node.id !== 'next-node' && node.id.includes('snippet-exit'))! - const shiftedNextNode = nextNodes.find(node => node.id === 'next-node')! - expect(insertedEntry.position).toEqual({ x: 300, y: 0 }) - expect(insertedEntry.parentId).toBe('container-node') - expect(insertedExit.parentId).toBe('container-node') - expect(shiftedNextNode.position.x).toBe(600) - expect(nextNodes.find(node => node.id === 'prev-node')!.data._connectedSourceHandleIds).toEqual(['source']) - expect(insertedEntry.data._connectedTargetHandleIds).toEqual(['target']) - expect(insertedExit.data._connectedSourceHandleIds).toEqual(['source']) - expect(shiftedNextNode.data._connectedTargetHandleIds).toEqual(['target']) + const nextNodes = mockSetNodes.mock.calls[0]![0] as TestNode[] + const insertedEntry = nextNodes.find( + (node) => + node.id !== 'prev-node' && node.id !== 'next-node' && node.id.includes('snippet-entry'), + )! + const insertedExit = nextNodes.find( + (node) => + node.id !== 'prev-node' && node.id !== 'next-node' && node.id.includes('snippet-exit'), + )! + const shiftedNextNode = nextNodes.find((node) => node.id === 'next-node')! + expect(insertedEntry.position).toEqual({ x: 300, y: 0 }) + expect(insertedEntry.parentId).toBe('container-node') + expect(insertedExit.parentId).toBe('container-node') + expect(shiftedNextNode.position.x).toBe(600) + expect( + nextNodes.find((node) => node.id === 'prev-node')!.data._connectedSourceHandleIds, + ).toEqual(['source']) + expect(insertedEntry.data._connectedTargetHandleIds).toEqual(['target']) + expect(insertedExit.data._connectedSourceHandleIds).toEqual(['source']) + expect(shiftedNextNode.data._connectedTargetHandleIds).toEqual(['target']) - const nextEdges = mockSetEdges.mock.calls[0]![0] as TestEdge[] - expect(nextEdges).toHaveLength(3) - expect(nextEdges.some(edge => edge.id === 'prev-node-source-next-node-target')).toBe(false) - expect(nextEdges).toEqual(expect.arrayContaining([ - expect.objectContaining({ - source: 'prev-node', - sourceHandle: 'source', - target: insertedEntry.id, - targetHandle: 'target', - }), - expect.objectContaining({ - source: insertedEntry.id, - target: insertedExit.id, - }), - expect.objectContaining({ - source: insertedExit.id, - sourceHandle: 'source', - target: 'next-node', - targetHandle: 'target', - }), - ])) - const incomingEdge = nextEdges.find(edge => edge.source === 'prev-node' && edge.target === insertedEntry.id) - const insertedInternalEdge = nextEdges.find(edge => edge.source === insertedEntry.id && edge.target === insertedExit.id) - const outgoingEdge = nextEdges.find(edge => edge.source === insertedExit.id && edge.target === 'next-node') - expect(incomingEdge?.zIndex).toBe(NESTED_ELEMENT_Z_INDEX) - expect(insertedInternalEdge?.zIndex).toBe(NESTED_ELEMENT_Z_INDEX) - expect(outgoingEdge?.zIndex).toBe(NESTED_ELEMENT_Z_INDEX) - expect(insertedInternalEdge?.data).toEqual(expect.objectContaining({ - isInIteration: containerType === 'iteration', - iteration_id: containerType === 'iteration' ? 'container-node' : undefined, - isInLoop: containerType === 'loop', - loop_id: containerType === 'loop' ? 'container-node' : undefined, - })) - expect(mockIncrementSnippetUseCount).toHaveBeenCalledWith({ - params: { snippetId: 'snippet-1' }, - }) - }) + const nextEdges = mockSetEdges.mock.calls[0]![0] as TestEdge[] + expect(nextEdges).toHaveLength(3) + expect(nextEdges.some((edge) => edge.id === 'prev-node-source-next-node-target')).toBe( + false, + ) + expect(nextEdges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: 'prev-node', + sourceHandle: 'source', + target: insertedEntry.id, + targetHandle: 'target', + }), + expect.objectContaining({ + source: insertedEntry.id, + target: insertedExit.id, + }), + expect.objectContaining({ + source: insertedExit.id, + sourceHandle: 'source', + target: 'next-node', + targetHandle: 'target', + }), + ]), + ) + const incomingEdge = nextEdges.find( + (edge) => edge.source === 'prev-node' && edge.target === insertedEntry.id, + ) + const insertedInternalEdge = nextEdges.find( + (edge) => edge.source === insertedEntry.id && edge.target === insertedExit.id, + ) + const outgoingEdge = nextEdges.find( + (edge) => edge.source === insertedExit.id && edge.target === 'next-node', + ) + expect(incomingEdge?.zIndex).toBe(NESTED_ELEMENT_Z_INDEX) + expect(insertedInternalEdge?.zIndex).toBe(NESTED_ELEMENT_Z_INDEX) + expect(outgoingEdge?.zIndex).toBe(NESTED_ELEMENT_Z_INDEX) + expect(insertedInternalEdge?.data).toEqual( + expect.objectContaining({ + isInIteration: containerType === 'iteration', + iteration_id: containerType === 'iteration' ? 'container-node' : undefined, + isInLoop: containerType === 'loop', + loop_id: containerType === 'loop' ? 'container-node' : undefined, + }), + ) + expect(mockIncrementSnippetUseCount).toHaveBeenCalledWith({ + params: { snippetId: 'snippet-1' }, + }) + }, + ) it('should show error toast when fetching snippet workflow fails', async () => { mockFetchQuery.mockRejectedValue(new Error('insert failed')) diff --git a/web/app/components/workflow/block-selector/snippets/index.tsx b/web/app/components/workflow/block-selector/snippets/index.tsx index be420b387782b5..3cf35398fe8ecd 100644 --- a/web/app/components/workflow/block-selector/snippets/index.tsx +++ b/web/app/components/workflow/block-selector/snippets/index.tsx @@ -7,20 +7,9 @@ import { ScrollAreaThumb, ScrollAreaViewport, } from '@langgenius/dify-ui/scroll-area' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useInfiniteScroll } from 'ahooks' -import { - memo, - useCallback, - useDeferredValue, - useMemo, - useRef, - useState, -} from 'react' +import { memo, useCallback, useDeferredValue, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { useInfiniteSnippetList } from '@/service/use-snippets' @@ -77,36 +66,31 @@ const Snippets = ({ const keyword = deferredSearchText.trim() || undefined - const { - data, - isLoading, - isFetching, - isFetchingNextPage, - fetchNextPage, - hasNextPage, - } = useInfiniteSnippetList({ - page: 1, - limit: 30, - keyword, - ...(tagIds.length ? { tag_ids: tagIds } : {}), - is_published: true, - }) + const { data, isLoading, isFetching, isFetchingNextPage, fetchNextPage, hasNextPage } = + useInfiniteSnippetList({ + page: 1, + limit: 30, + keyword, + ...(tagIds.length ? { tag_ids: tagIds } : {}), + is_published: true, + }) const snippets = useMemo(() => { return (data?.pages ?? []).flatMap(({ data }) => data) }, [data?.pages]) const isNoMore = hasNextPage === false - const handleSnippetClick = useCallback(async (snippetId: string) => { - const inserted = await handleInsertSnippet(snippetId, insertPayload) - if (inserted) - onInserted?.() - }, [handleInsertSnippet, insertPayload, onInserted]) + const handleSnippetClick = useCallback( + async (snippetId: string) => { + const inserted = await handleInsertSnippet(snippetId, insertPayload) + if (inserted) onInserted?.() + }, + [handleInsertSnippet, insertPayload, onInserted], + ) useInfiniteScroll( async () => { - if (!hasNextPage || isFetchingNextPage) - return { list: [] } + if (!hasNextPage || isFetchingNextPage) return { list: [] } await fetchNextPage() return { list: [] } @@ -122,21 +106,24 @@ const Snippets = ({ <div className="border-b border-divider-subtle p-2"> <div className="flex items-center rounded-lg border border-transparent bg-components-input-bg-normal focus-within:border-components-input-border-active hover:border-components-input-border-hover"> <div className="flex min-w-0 grow items-center py-1.75 pr-3 pl-2"> - <span className="i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" aria-hidden="true" /> + <span + className="i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" + aria-hidden="true" + /> <input autoFocus value={searchText} - placeholder={t($ => $['tabs.searchSnippets'], { ns: 'workflow' })} + placeholder={t(($) => $['tabs.searchSnippets'], { ns: 'workflow' })} className={cn( 'mr-1 ml-1.5 inline-block min-w-0 grow appearance-none bg-transparent system-sm-regular text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder', searchText && 'mr-2', )} - onChange={event => onSearchTextChange?.(event.target.value)} + onChange={(event) => onSearchTextChange?.(event.target.value)} /> {!!searchText && ( <button type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} className="group shrink-0 cursor-pointer rounded-md p-1 hover:bg-state-base-hover" onClick={() => onSearchTextChange?.('')} > @@ -162,55 +149,48 @@ const Snippets = ({ return ( <> {filter} - {!snippets.length - ? ( - <SnippetEmptyState /> - ) - : ( - <ScrollAreaRoot className="relative max-h-120 max-w-125 overflow-hidden"> - <ScrollAreaViewport ref={viewportRef}> - <ScrollAreaContent className="p-1"> - {snippets.map((item) => { - const row = ( - <SnippetListItem - snippet={item} - isHovered={hoveredSnippetId === item.id} - onClick={() => handleSnippetClick(item.id)} - onMouseEnter={() => setHoveredSnippetId(item.id)} - onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)} - /> - ) + {!snippets.length ? ( + <SnippetEmptyState /> + ) : ( + <ScrollAreaRoot className="relative max-h-120 max-w-125 overflow-hidden"> + <ScrollAreaViewport ref={viewportRef}> + <ScrollAreaContent className="p-1"> + {snippets.map((item) => { + const row = ( + <SnippetListItem + snippet={item} + isHovered={hoveredSnippetId === item.id} + onClick={() => handleSnippetClick(item.id)} + onMouseEnter={() => setHoveredSnippetId(item.id)} + onMouseLeave={() => + setHoveredSnippetId((current) => (current === item.id ? null : current)) + } + /> + ) - if (!item.description) - return <div key={item.id}>{row}</div> + if (!item.description) return <div key={item.id}>{row}</div> - return ( - <Tooltip key={item.id}> - <TooltipTrigger - delay={0} - render={row} - /> - <TooltipContent - placement="right-start" - className="bg-transparent! p-0!" - > - <SnippetDetailCard snippet={item} /> - </TooltipContent> - </Tooltip> - ) - })} - {isFetchingNextPage && ( - <div className="flex justify-center px-3 py-2"> - <Loading /> - </div> - )} - </ScrollAreaContent> - </ScrollAreaViewport> - <ScrollAreaScrollbar orientation="vertical"> - <ScrollAreaThumb /> - </ScrollAreaScrollbar> - </ScrollAreaRoot> - )} + return ( + <Tooltip key={item.id}> + <TooltipTrigger delay={0} render={row} /> + <TooltipContent placement="right-start" className="bg-transparent! p-0!"> + <SnippetDetailCard snippet={item} /> + </TooltipContent> + </Tooltip> + ) + })} + {isFetchingNextPage && ( + <div className="flex justify-center px-3 py-2"> + <Loading /> + </div> + )} + </ScrollAreaContent> + </ScrollAreaViewport> + <ScrollAreaScrollbar orientation="vertical"> + <ScrollAreaThumb /> + </ScrollAreaScrollbar> + </ScrollAreaRoot> + )} </> ) } diff --git a/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx b/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx index 887d0d9fd07e29..5806b8eb7491e4 100644 --- a/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx @@ -13,37 +13,31 @@ type SnippetDetailCardProps = { snippet: PublishedSnippetListItem } -const SnippetDetailCard: FC<SnippetDetailCardProps> = ({ - snippet, -}) => { +const SnippetDetailCard: FC<SnippetDetailCardProps> = ({ snippet }) => { const { description, name } = snippet const { t } = useTranslation('snippet') const { data: membersData } = useMembers() const { data: workflow } = useSnippetPublishedWorkflow(snippet.id) const creatorName = useMemo(() => { - const member = membersData?.accounts?.find(member => member.id === snippet.created_by) - return member?.name || t($ => $.unknownUser) + const member = membersData?.accounts?.find((member) => member.id === snippet.created_by) + return member?.name || t(($) => $.unknownUser) }, [membersData?.accounts, snippet.created_by, t]) const blockTypes = useMemo(() => { const graph = workflow?.graph - if (!graph || typeof graph !== 'object') - return [] + if (!graph || typeof graph !== 'object') return [] const graphRecord = graph as Record<string, unknown> - if (!Array.isArray(graphRecord.nodes)) - return [] + if (!Array.isArray(graphRecord.nodes)) return [] const availableBlockTypes = new Set(Object.values(BlockEnum)) return graphRecord.nodes.reduce<BlockEnum[]>((result, node) => { - if (!node || typeof node !== 'object') - return result + if (!node || typeof node !== 'object') return result const nodeRecord = node as Record<string, unknown> - if (!nodeRecord.data || typeof nodeRecord.data !== 'object') - return result + if (!nodeRecord.data || typeof nodeRecord.data !== 'object') return result const dataRecord = nodeRecord.data as Record<string, unknown> const blockType = dataRecord.type @@ -51,8 +45,7 @@ const SnippetDetailCard: FC<SnippetDetailCardProps> = ({ return result const normalizedBlockType = blockType as BlockEnum - if (!result.includes(normalizedBlockType)) - result.push(normalizedBlockType) + if (!result.includes(normalizedBlockType)) result.push(normalizedBlockType) return result }, []) @@ -65,25 +58,17 @@ const SnippetDetailCard: FC<SnippetDetailCardProps> = ({ <div className="system-md-medium text-text-primary">{name}</div> </div> {!!description && ( - <div className="w-50 system-xs-regular text-text-secondary"> - {description} - </div> + <div className="w-50 system-xs-regular text-text-secondary">{description}</div> )} {!!blockTypes.length && ( <div className="flex items-center gap-0.5 pt-1"> - {blockTypes.map(blockType => ( - <BlockIcon - key={blockType} - type={blockType} - size="sm" - /> + {blockTypes.map((blockType) => ( + <BlockIcon key={blockType} type={blockType} size="sm" /> ))} </div> )} </div> - <div className="pt-3 system-xs-regular text-text-tertiary"> - {creatorName} - </div> + <div className="pt-3 system-xs-regular text-text-tertiary">{creatorName}</div> </div> ) } diff --git a/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx b/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx index d58da2e9ac992b..9e8397ac36c98d 100644 --- a/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx @@ -7,7 +7,7 @@ const SnippetEmptyState = () => { <div className="flex min-h-120 flex-col items-center justify-center gap-2 px-4"> <span className="i-custom-vender-line-others-search-menu h-8 w-8 text-text-tertiary" /> <div className="system-sm-regular text-text-secondary"> - {t($ => $['tabs.noSnippetsFound'], { ns: 'workflow' })} + {t(($) => $['tabs.noSnippetsFound'], { ns: 'workflow' })} </div> </div> ) diff --git a/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx b/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx index c2042c25919f3b..90077240a2992d 100644 --- a/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx @@ -1,7 +1,4 @@ -import type { - ComponentPropsWithoutRef, - Ref, -} from 'react' +import type { ComponentPropsWithoutRef, Ref } from 'react' import type { PublishedSnippetListItem } from './snippet-detail-card' import { cn } from '@langgenius/dify-ui/cn' @@ -28,9 +25,7 @@ const SnippetListItem = ({ )} {...props} > - <div className="w-full truncate system-md-semibold text-text-secondary"> - {snippet.name} - </div> + <div className="w-full truncate system-md-semibold text-text-secondary">{snippet.name}</div> {!!snippet.description && ( <div className="line-clamp-1 w-full system-sm-regular text-text-tertiary"> {snippet.description} diff --git a/web/app/components/workflow/block-selector/snippets/snippet-tags-filter.tsx b/web/app/components/workflow/block-selector/snippets/snippet-tags-filter.tsx index ca697b87e4126a..2461f576d20dfd 100644 --- a/web/app/components/workflow/block-selector/snippets/snippet-tags-filter.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-tags-filter.tsx @@ -2,11 +2,7 @@ import { Checkbox } from '@langgenius/dify-ui/checkbox' import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group' import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -18,30 +14,27 @@ type SnippetTagsFilterProps = { onChange: (value: string[]) => void } -const SnippetTagsFilter = ({ - embedded = false, - value, - onChange, -}: SnippetTagsFilterProps) => { +const SnippetTagsFilter = ({ embedded = false, value, onChange }: SnippetTagsFilterProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const [searchText, setSearchText] = useState('') - const { data: tagList = [] } = useQuery(consoleQuery.tags.get.queryOptions({ - input: { - query: { - type: 'snippet', + const { data: tagList = [] } = useQuery( + consoleQuery.tags.get.queryOptions({ + input: { + query: { + type: 'snippet', + }, }, - }, - })) + }), + ) - const tagById = useMemo(() => new Map(tagList.map(tag => [tag.id, tag])), [tagList]) + const tagById = useMemo(() => new Map(tagList.map((tag) => [tag.id, tag])), [tagList]) const filteredTags = useMemo(() => { const normalizedSearch = searchText.trim().toLowerCase() - if (!normalizedSearch) - return tagList + if (!normalizedSearch) return tagList - return tagList.filter(tag => tag.name.toLowerCase().includes(normalizedSearch)) + return tagList.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch)) }, [searchText, tagList]) const selectedTags = value.flatMap((tagId) => { @@ -49,13 +42,13 @@ const SnippetTagsFilter = ({ return tag ? [tag] : [] }) const triggerLabel = selectedTags.length - ? selectedTags.map(tag => tag.name).join(', ') - : t($ => $['tag.placeholder'], { ns: 'common' }) + ? selectedTags.map((tag) => tag.name).join(', ') + : t(($) => $['tag.placeholder'], { ns: 'common' }) return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <button type="button" aria-label={triggerLabel} @@ -65,22 +58,26 @@ const SnippetTagsFilter = ({ ? 'h-7 rounded-md p-0.5' : 'h-8 min-w-8 rounded-lg border-[0.5px] border-components-panel-border bg-components-input-bg-normal px-2', embedded && !value.length && 'py-1 pr-2 pl-1.5', - embedded && value.length > 0 && 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pr-1.5 pl-1 shadow-xs shadow-shadow-shadow-3', + embedded && + value.length > 0 && + 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pr-1.5 pl-1 shadow-xs shadow-shadow-shadow-3', !embedded && 'hover:bg-components-input-bg-hover', - open && (embedded - ? !value.length && 'bg-state-base-hover' - : 'border-components-input-border-active bg-components-input-bg-active text-text-secondary'), + open && + (embedded + ? !value.length && 'bg-state-base-hover' + : 'border-components-input-border-active bg-components-input-bg-active text-text-secondary'), value.length > 0 && 'text-text-secondary', )} > - <span className="i-custom-vender-line-financeAndECommerce-tag-01 size-4 text-text-tertiary" aria-hidden="true" /> + <span + className="i-custom-vender-line-financeAndECommerce-tag-01 size-4 text-text-tertiary" + aria-hidden="true" + /> {value.length > 0 && ( - <span className="ml-1 system-xs-medium text-text-secondary"> - {value.length} - </span> + <span className="ml-1 system-xs-medium text-text-secondary">{value.length}</span> )} </button> - )} + } /> <PopoverContent placement="bottom-end" @@ -89,35 +86,36 @@ const SnippetTagsFilter = ({ > <div className="p-2 pb-1"> <div className="relative"> - <span className="absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" aria-hidden="true" /> + <span + className="absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" + aria-hidden="true" + /> <Input className="pl-6.5" value={searchText} - onChange={event => setSearchText(event.target.value)} - placeholder={t($ => $.searchTags, { ns: 'pluginTags' }) || ''} + onChange={(event) => setSearchText(event.target.value)} + placeholder={t(($) => $.searchTags, { ns: 'pluginTags' }) || ''} /> </div> </div> <CheckboxGroup - aria-label={t($ => $.allTags, { ns: 'pluginTags' })} + aria-label={t(($) => $.allTags, { ns: 'pluginTags' })} value={value} onValueChange={onChange} className="max-h-112 overflow-y-auto p-1" > - {filteredTags.map(tag => ( + {filteredTags.map((tag) => ( <label key={tag.id} className="flex h-7 cursor-pointer items-center rounded-lg px-2 py-1.5 select-none hover:bg-state-base-hover" > <Checkbox className="mr-1" value={tag.id} /> - <div className="px-1 system-sm-medium text-text-secondary"> - {tag.name} - </div> + <div className="px-1 system-sm-medium text-text-secondary">{tag.name}</div> </label> ))} {!filteredTags.length && ( <div className="px-3 py-2 system-xs-regular text-text-tertiary"> - {t($ => $['tag.noTag'], { ns: 'common' })} + {t(($) => $['tag.noTag'], { ns: 'common' })} </div> )} </CheckboxGroup> diff --git a/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts b/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts index 8c19a5198059f9..fc7ed250ab761b 100644 --- a/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts +++ b/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts @@ -14,37 +14,35 @@ import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../../utils' type SnippetInsertPayload = Parameters<OnNodeAdd>[1] const getSnippetGraph = (graph: Record<string, unknown> | undefined) => { - if (!graph) - return { nodes: [] as Node[], edges: [] as Edge[] } + if (!graph) return { nodes: [] as Node[], edges: [] as Edge[] } return { - nodes: Array.isArray(graph.nodes) ? graph.nodes as Node[] : [], - edges: Array.isArray(graph.edges) ? graph.edges as Edge[] : [], + nodes: Array.isArray(graph.nodes) ? (graph.nodes as Node[]) : [], + edges: Array.isArray(graph.edges) ? (graph.edges as Edge[]) : [], } } const getRootNodes = (nodes: Node[]) => { - const rootNodes = nodes.filter(node => !node.parentId) + const rootNodes = nodes.filter((node) => !node.parentId) return rootNodes.length ? rootNodes : nodes } const getSnippetBoundaryNodes = (nodes: Node[], edges: Edge[]) => { const rootNodes = getRootNodes(nodes) - const rootNodeIds = new Set(rootNodes.map(node => node.id)) + const rootNodeIds = new Set(rootNodes.map((node) => node.id)) const incomingNodeIds = new Set<string>() const outgoingNodeIds = new Set<string>() edges.forEach((edge) => { - if (!rootNodeIds.has(edge.source) || !rootNodeIds.has(edge.target)) - return + if (!rootNodeIds.has(edge.source) || !rootNodeIds.has(edge.target)) return outgoingNodeIds.add(edge.source) incomingNodeIds.add(edge.target) }) return { - entryNodes: rootNodes.filter(node => !incomingNodeIds.has(node.id)), - exitNodes: rootNodes.filter(node => !outgoingNodeIds.has(node.id)), + entryNodes: rootNodes.filter((node) => !incomingNodeIds.has(node.id)), + exitNodes: rootNodes.filter((node) => !outgoingNodeIds.has(node.id)), } } @@ -53,21 +51,20 @@ const canConnectToTarget = (node: Node) => { } const canConnectFromSource = (node: Node) => { - return node.data.type !== BlockEnum.IfElse - && node.data.type !== BlockEnum.QuestionClassifier - && node.data.type !== BlockEnum.HumanInput - && node.data.type !== BlockEnum.LoopEnd + return ( + node.data.type !== BlockEnum.IfElse && + node.data.type !== BlockEnum.QuestionClassifier && + node.data.type !== BlockEnum.HumanInput && + node.data.type !== BlockEnum.LoopEnd + ) } -const getInsertAnchor = ( - currentNodes: Node[], - insertPayload?: SnippetInsertPayload, -) => { +const getInsertAnchor = (currentNodes: Node[], insertPayload?: SnippetInsertPayload) => { const prevNode = insertPayload?.prevNodeId - ? currentNodes.find(node => node.id === insertPayload.prevNodeId) + ? currentNodes.find((node) => node.id === insertPayload.prevNodeId) : undefined const nextNode = insertPayload?.nextNodeId - ? currentNodes.find(node => node.id === insertPayload.nextNodeId) + ? currentNodes.find((node) => node.id === insertPayload.nextNodeId) : undefined if (nextNode) { @@ -91,19 +88,21 @@ const remapSnippetGraph = ( snippetEdges: Edge[], insertPayload?: SnippetInsertPayload, ) => { - const existingIds = new Set(currentNodes.map(node => node.id)) + const existingIds = new Set(currentNodes.map((node) => node.id)) const idMapping = new Map<string, string>() - const rootNodes = snippetNodes.filter(node => !node.parentId) - const minRootX = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.x)) : 0 - const minRootY = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.y)) : 0 + const rootNodes = snippetNodes.filter((node) => !node.parentId) + const minRootX = rootNodes.length ? Math.min(...rootNodes.map((node) => node.position.x)) : 0 + const minRootY = rootNodes.length ? Math.min(...rootNodes.map((node) => node.position.y)) : 0 const currentMaxX = currentNodes.length - ? Math.max(...currentNodes.map((node) => { - const nodeX = node.positionAbsolute?.x ?? node.position.x - return nodeX + (node.width ?? 0) - })) + ? Math.max( + ...currentNodes.map((node) => { + const nodeX = node.positionAbsolute?.x ?? node.position.x + return nodeX + (node.width ?? 0) + }), + ) : 0 const currentMinY = currentNodes.length - ? Math.min(...currentNodes.map(node => node.positionAbsolute?.y ?? node.position.y)) + ? Math.min(...currentNodes.map((node) => node.positionAbsolute?.y ?? node.position.y)) : 0 const insertAnchor = getInsertAnchor(currentNodes, insertPayload) const offsetX = (insertAnchor?.x ?? (currentNodes.length ? currentMaxX + 80 : 80)) - minRootX @@ -111,8 +110,7 @@ const remapSnippetGraph = ( snippetNodes.forEach((node, index) => { let nextId = `${node.id}-${Date.now()}-${index}` - while (existingIds.has(nextId)) - nextId = `${nextId}-1` + while (existingIds.has(nextId)) nextId = `${nextId}-1` existingIds.add(nextId) idMapping.set(node.id, nextId) }) @@ -132,18 +130,18 @@ const remapSnippetGraph = ( } : node.position, positionAbsolute: node.positionAbsolute - ? (isRootNode - ? { - x: node.positionAbsolute.x + offsetX, - y: node.positionAbsolute.y + offsetY, - } - : node.positionAbsolute) + ? isRootNode + ? { + x: node.positionAbsolute.x + offsetX, + y: node.positionAbsolute.y + offsetY, + } + : node.positionAbsolute : undefined, selected: true, data: { ...node.data, selected: true, - _children: node.data._children?.map(child => ({ + _children: node.data._children?.map((child) => ({ ...child, nodeId: idMapping.get(child.nodeId) ?? child.nodeId, })), @@ -151,7 +149,7 @@ const remapSnippetGraph = ( } }) - const edges = snippetEdges.map(edge => ({ + const edges = snippetEdges.map((edge) => ({ ...edge, id: `${idMapping.get(edge.source)}-${edge.sourceHandle}-${idMapping.get(edge.target)}-${edge.targetHandle}`, source: idMapping.get(edge.source)!, @@ -169,27 +167,27 @@ const remapSnippetGraph = ( } const getCurrentEdge = (edges: Edge[], insertPayload?: SnippetInsertPayload) => { - if (!insertPayload?.prevNodeId || !insertPayload.nextNodeId) - return undefined - - return edges.find(edge => - edge.source === insertPayload.prevNodeId - && edge.target === insertPayload.nextNodeId - && (edge.sourceHandle || 'source') === (insertPayload.prevNodeSourceHandle || 'source') - && (edge.targetHandle || 'target') === (insertPayload.nextNodeTargetHandle || 'target'), + if (!insertPayload?.prevNodeId || !insertPayload.nextNodeId) return undefined + + return edges.find( + (edge) => + edge.source === insertPayload.prevNodeId && + edge.target === insertPayload.nextNodeId && + (edge.sourceHandle || 'source') === (insertPayload.prevNodeSourceHandle || 'source') && + (edge.targetHandle || 'target') === (insertPayload.nextNodeTargetHandle || 'target'), ) } const getParentNode = (nodes: Node[], insertPayload?: SnippetInsertPayload) => { const prevNode = insertPayload?.prevNodeId - ? nodes.find(node => node.id === insertPayload.prevNodeId) + ? nodes.find((node) => node.id === insertPayload.prevNodeId) : undefined const nextNode = insertPayload?.nextNodeId - ? nodes.find(node => node.id === insertPayload.nextNodeId) + ? nodes.find((node) => node.id === insertPayload.nextNodeId) : undefined const parentId = prevNode?.parentId ?? nextNode?.parentId - return parentId ? nodes.find(node => node.id === parentId) : undefined + return parentId ? nodes.find((node) => node.id === parentId) : undefined } const createBoundaryEdges = ({ @@ -204,10 +202,10 @@ const createBoundaryEdges = ({ exitNodes: Node[] }) => { const prevNode = insertPayload?.prevNodeId - ? currentNodes.find(node => node.id === insertPayload.prevNodeId) + ? currentNodes.find((node) => node.id === insertPayload.prevNodeId) : undefined const nextNode = insertPayload?.nextNodeId - ? currentNodes.find(node => node.id === insertPayload.nextNodeId) + ? currentNodes.find((node) => node.id === insertPayload.nextNodeId) : undefined const parentNode = getParentNode(currentNodes, insertPayload) const isInIteration = parentNode?.data.type === BlockEnum.Iteration @@ -217,55 +215,59 @@ const createBoundaryEdges = ({ const outgoingEdges: Edge[] = [] if (prevNode) { - incomingEdges.push(...entryNodes.filter(canConnectToTarget).map((entryNode) => { - const sourceHandle = insertPayload?.prevNodeSourceHandle || 'source' - const targetHandle = 'target' - - return { - id: `${prevNode.id}-${sourceHandle}-${entryNode.id}-${targetHandle}`, - type: CUSTOM_EDGE, - source: prevNode.id, - sourceHandle, - target: entryNode.id, - targetHandle, - data: { - sourceType: prevNode.data.type, - targetType: entryNode.data.type, - isInIteration, - isInLoop, - iteration_id: isInIteration ? parentNode?.id : undefined, - loop_id: isInLoop ? parentNode?.id : undefined, - _connectedNodeIsSelected: true, - }, - zIndex, - } as Edge - })) + incomingEdges.push( + ...entryNodes.filter(canConnectToTarget).map((entryNode) => { + const sourceHandle = insertPayload?.prevNodeSourceHandle || 'source' + const targetHandle = 'target' + + return { + id: `${prevNode.id}-${sourceHandle}-${entryNode.id}-${targetHandle}`, + type: CUSTOM_EDGE, + source: prevNode.id, + sourceHandle, + target: entryNode.id, + targetHandle, + data: { + sourceType: prevNode.data.type, + targetType: entryNode.data.type, + isInIteration, + isInLoop, + iteration_id: isInIteration ? parentNode?.id : undefined, + loop_id: isInLoop ? parentNode?.id : undefined, + _connectedNodeIsSelected: true, + }, + zIndex, + } as Edge + }), + ) } if (nextNode) { - outgoingEdges.push(...exitNodes.filter(canConnectFromSource).map((exitNode) => { - const sourceHandle = 'source' - const targetHandle = insertPayload?.nextNodeTargetHandle || 'target' - - return { - id: `${exitNode.id}-${sourceHandle}-${nextNode.id}-${targetHandle}`, - type: CUSTOM_EDGE, - source: exitNode.id, - sourceHandle, - target: nextNode.id, - targetHandle, - data: { - sourceType: exitNode.data.type, - targetType: nextNode.data.type, - isInIteration, - isInLoop, - iteration_id: isInIteration ? parentNode?.id : undefined, - loop_id: isInLoop ? parentNode?.id : undefined, - _connectedNodeIsSelected: true, - }, - zIndex, - } as Edge - })) + outgoingEdges.push( + ...exitNodes.filter(canConnectFromSource).map((exitNode) => { + const sourceHandle = 'source' + const targetHandle = insertPayload?.nextNodeTargetHandle || 'target' + + return { + id: `${exitNode.id}-${sourceHandle}-${nextNode.id}-${targetHandle}`, + type: CUSTOM_EDGE, + source: exitNode.id, + sourceHandle, + target: nextNode.id, + targetHandle, + data: { + sourceType: exitNode.data.type, + targetType: nextNode.data.type, + isInIteration, + isInLoop, + iteration_id: isInIteration ? parentNode?.id : undefined, + loop_id: isInLoop ? parentNode?.id : undefined, + _connectedNodeIsSelected: true, + }, + zIndex, + } as Edge + }), + ) } return [...incomingEdges, ...outgoingEdges] @@ -279,151 +281,177 @@ export const useInsertSnippet = () => { const { saveStateToHistory } = useWorkflowHistory() const { mutate: incrementSnippetUseCount } = useIncrementSnippetUseCountMutation() - const handleInsertSnippet = useCallback(async (snippetId: string, insertPayload?: SnippetInsertPayload) => { - try { - const workflow = await queryClient.fetchQuery(consoleQuery.snippets.bySnippetId.workflows.publish.get.queryOptions({ - input: { - params: { snippet_id: snippetId }, - }, - })) - const { nodes: snippetNodes, edges: snippetEdges } = getSnippetGraph(workflow.graph) - - if (!snippetNodes.length) - return - - const { getNodes, setNodes, edges, setEdges } = store.getState() - const currentNodes = getNodes() - const remappedGraph = remapSnippetGraph(currentNodes, snippetNodes, snippetEdges, insertPayload) - const parentNode = getParentNode(currentNodes, insertPayload) - const rootNodeIds = new Set(getRootNodes(remappedGraph.nodes).map(node => node.id)) - const rootSnippetNodes = remappedGraph.nodes.filter(node => rootNodeIds.has(node.id)) - const currentEdge = getCurrentEdge(edges, insertPayload) - const { entryNodes, exitNodes } = getSnippetBoundaryNodes(remappedGraph.nodes, remappedGraph.edges) - const boundaryEdges = createBoundaryEdges({ - currentNodes, - insertPayload, - entryNodes, - exitNodes, - }) - const changes = [ - ...(currentEdge ? [{ type: 'remove', edge: currentEdge }] : []), - ...boundaryEdges.map(edge => ({ type: 'add', edge })), - ] - const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( - changes, - [...currentNodes, ...remappedGraph.nodes], - ) - const remappedNodesById = new Map(remappedGraph.nodes.map(node => [node.id, node])) - const firstEntryNode = entryNodes.find(canConnectToTarget) ?? entryNodes[0] - const clearedNodes = currentNodes.map(node => ({ - ...node, - selected: false, - position: insertPayload?.nextNodeId && node.id === insertPayload.nextNodeId - ? { - ...node.position, - x: node.position.x + NODE_WIDTH_X_OFFSET, - } - : node.position, - data: { - ...node.data, - selected: false, - ...(nodesConnectedSourceOrTargetHandleIdsMap[node.id] ?? {}), - _children: parentNode?.id === node.id - ? [ - ...(node.data._children ?? []), - ...rootSnippetNodes.map(rootNode => ({ - nodeId: rootNode.id, - nodeType: rootNode.data.type, - })), - ] - : node.data._children, - start_node_id: node.id === parentNode?.id - && node.data.start_node_id === insertPayload?.nextNodeId - && firstEntryNode - ? firstEntryNode.id - : node.data.start_node_id, - startNodeType: node.id === parentNode?.id - && node.data.start_node_id === insertPayload?.nextNodeId - && firstEntryNode - ? firstEntryNode.data.type - : node.data.startNodeType, - }, - })) - const insertedNodes = remappedGraph.nodes.map((node) => { - const shouldMoveIntoParent = !!parentNode && rootNodeIds.has(node.id) - const isInIteration = parentNode?.data.type === BlockEnum.Iteration - const isInLoop = parentNode?.data.type === BlockEnum.Loop - const snippetParentNode = node.parentId ? remappedNodesById.get(node.parentId) : undefined - const isNestedInSnippet = snippetParentNode?.data.type === BlockEnum.Iteration - || snippetParentNode?.data.type === BlockEnum.Loop - - return { + const handleInsertSnippet = useCallback( + async (snippetId: string, insertPayload?: SnippetInsertPayload) => { + try { + const workflow = await queryClient.fetchQuery( + consoleQuery.snippets.bySnippetId.workflows.publish.get.queryOptions({ + input: { + params: { snippet_id: snippetId }, + }, + }), + ) + const { nodes: snippetNodes, edges: snippetEdges } = getSnippetGraph(workflow.graph) + + if (!snippetNodes.length) return + + const { getNodes, setNodes, edges, setEdges } = store.getState() + const currentNodes = getNodes() + const remappedGraph = remapSnippetGraph( + currentNodes, + snippetNodes, + snippetEdges, + insertPayload, + ) + const parentNode = getParentNode(currentNodes, insertPayload) + const rootNodeIds = new Set(getRootNodes(remappedGraph.nodes).map((node) => node.id)) + const rootSnippetNodes = remappedGraph.nodes.filter((node) => rootNodeIds.has(node.id)) + const currentEdge = getCurrentEdge(edges, insertPayload) + const { entryNodes, exitNodes } = getSnippetBoundaryNodes( + remappedGraph.nodes, + remappedGraph.edges, + ) + const boundaryEdges = createBoundaryEdges({ + currentNodes, + insertPayload, + entryNodes, + exitNodes, + }) + const changes = [ + ...(currentEdge ? [{ type: 'remove', edge: currentEdge }] : []), + ...boundaryEdges.map((edge) => ({ type: 'add', edge })), + ] + const nodesConnectedSourceOrTargetHandleIdsMap = + getNodesConnectedSourceOrTargetHandleIdsMap(changes, [ + ...currentNodes, + ...remappedGraph.nodes, + ]) + const remappedNodesById = new Map(remappedGraph.nodes.map((node) => [node.id, node])) + const firstEntryNode = entryNodes.find(canConnectToTarget) ?? entryNodes[0] + const clearedNodes = currentNodes.map((node) => ({ ...node, - parentId: shouldMoveIntoParent ? parentNode.id : node.parentId, - extent: shouldMoveIntoParent ? parentNode.extent : node.extent, - zIndex: shouldMoveIntoParent || isNestedInSnippet ? NESTED_ELEMENT_Z_INDEX : 0, + selected: false, + position: + insertPayload?.nextNodeId && node.id === insertPayload.nextNodeId + ? { + ...node.position, + x: node.position.x + NODE_WIDTH_X_OFFSET, + } + : node.position, data: { ...node.data, + selected: false, ...(nodesConnectedSourceOrTargetHandleIdsMap[node.id] ?? {}), - isInIteration: shouldMoveIntoParent ? isInIteration : node.data.isInIteration, - isInLoop: shouldMoveIntoParent ? isInLoop : node.data.isInLoop, - iteration_id: shouldMoveIntoParent && isInIteration ? parentNode.id : node.data.iteration_id, - loop_id: shouldMoveIntoParent && isInLoop ? parentNode.id : node.data.loop_id, - }, - } - }) - const nextNodes = [...clearedNodes, ...insertedNodes] - const finalNodesById = new Map(nextNodes.map(node => [node.id, node])) - const insertedEdges = remappedGraph.edges.map((edge) => { - const sourceNode = finalNodesById.get(edge.source) - const targetNode = finalNodesById.get(edge.target) - const sourceParentNode = sourceNode?.parentId ? finalNodesById.get(sourceNode.parentId) : undefined - const targetParentNode = targetNode?.parentId ? finalNodesById.get(targetNode.parentId) : undefined - const nestedParentNode = [sourceParentNode, targetParentNode].find(node => node?.data.type === BlockEnum.Iteration || node?.data.type === BlockEnum.Loop) - const isInIteration = nestedParentNode?.data.type === BlockEnum.Iteration - const isInLoop = nestedParentNode?.data.type === BlockEnum.Loop - - return { - ...edge, - data: { - ...edge.data, - isInIteration, - iteration_id: isInIteration ? nestedParentNode.id : undefined, - isInLoop, - loop_id: isInLoop ? nestedParentNode.id : undefined, + _children: + parentNode?.id === node.id + ? [ + ...(node.data._children ?? []), + ...rootSnippetNodes.map((rootNode) => ({ + nodeId: rootNode.id, + nodeType: rootNode.data.type, + })), + ] + : node.data._children, + start_node_id: + node.id === parentNode?.id && + node.data.start_node_id === insertPayload?.nextNodeId && + firstEntryNode + ? firstEntryNode.id + : node.data.start_node_id, + startNodeType: + node.id === parentNode?.id && + node.data.start_node_id === insertPayload?.nextNodeId && + firstEntryNode + ? firstEntryNode.data.type + : node.data.startNodeType, }, - zIndex: nestedParentNode ? NESTED_ELEMENT_Z_INDEX : 0, - } - }) - - setNodes(nextNodes) - setEdges([ - ...edges - .filter(edge => edge.id !== currentEdge?.id) - .map(edge => ({ + })) + const insertedNodes = remappedGraph.nodes.map((node) => { + const shouldMoveIntoParent = !!parentNode && rootNodeIds.has(node.id) + const isInIteration = parentNode?.data.type === BlockEnum.Iteration + const isInLoop = parentNode?.data.type === BlockEnum.Loop + const snippetParentNode = node.parentId ? remappedNodesById.get(node.parentId) : undefined + const isNestedInSnippet = + snippetParentNode?.data.type === BlockEnum.Iteration || + snippetParentNode?.data.type === BlockEnum.Loop + + return { + ...node, + parentId: shouldMoveIntoParent ? parentNode.id : node.parentId, + extent: shouldMoveIntoParent ? parentNode.extent : node.extent, + zIndex: shouldMoveIntoParent || isNestedInSnippet ? NESTED_ELEMENT_Z_INDEX : 0, + data: { + ...node.data, + ...(nodesConnectedSourceOrTargetHandleIdsMap[node.id] ?? {}), + isInIteration: shouldMoveIntoParent ? isInIteration : node.data.isInIteration, + isInLoop: shouldMoveIntoParent ? isInLoop : node.data.isInLoop, + iteration_id: + shouldMoveIntoParent && isInIteration ? parentNode.id : node.data.iteration_id, + loop_id: shouldMoveIntoParent && isInLoop ? parentNode.id : node.data.loop_id, + }, + } + }) + const nextNodes = [...clearedNodes, ...insertedNodes] + const finalNodesById = new Map(nextNodes.map((node) => [node.id, node])) + const insertedEdges = remappedGraph.edges.map((edge) => { + const sourceNode = finalNodesById.get(edge.source) + const targetNode = finalNodesById.get(edge.target) + const sourceParentNode = sourceNode?.parentId + ? finalNodesById.get(sourceNode.parentId) + : undefined + const targetParentNode = targetNode?.parentId + ? finalNodesById.get(targetNode.parentId) + : undefined + const nestedParentNode = [sourceParentNode, targetParentNode].find( + (node) => node?.data.type === BlockEnum.Iteration || node?.data.type === BlockEnum.Loop, + ) + const isInIteration = nestedParentNode?.data.type === BlockEnum.Iteration + const isInLoop = nestedParentNode?.data.type === BlockEnum.Loop + + return { ...edge, data: { ...edge.data, - _connectedNodeIsSelected: false, + isInIteration, + iteration_id: isInIteration ? nestedParentNode.id : undefined, + isInLoop, + loop_id: isInLoop ? nestedParentNode.id : undefined, }, - })), - ...insertedEdges, - ...boundaryEdges, - ]) - saveStateToHistory(WorkflowHistoryEvent.NodePaste, { - nodeId: remappedGraph.nodes[0]?.id, - }) - handleSyncWorkflowDraft() - incrementSnippetUseCount({ - params: { snippetId }, - }) - return true - } - catch (error) { - toast.error(error instanceof Error ? error.message : t($ => $.createFailed, { ns: 'snippet' })) - return false - } - }, [handleSyncWorkflowDraft, incrementSnippetUseCount, queryClient, saveStateToHistory, store, t]) + zIndex: nestedParentNode ? NESTED_ELEMENT_Z_INDEX : 0, + } + }) + + setNodes(nextNodes) + setEdges([ + ...edges + .filter((edge) => edge.id !== currentEdge?.id) + .map((edge) => ({ + ...edge, + data: { + ...edge.data, + _connectedNodeIsSelected: false, + }, + })), + ...insertedEdges, + ...boundaryEdges, + ]) + saveStateToHistory(WorkflowHistoryEvent.NodePaste, { + nodeId: remappedGraph.nodes[0]?.id, + }) + handleSyncWorkflowDraft() + incrementSnippetUseCount({ + params: { snippetId }, + }) + return true + } catch (error) { + toast.error( + error instanceof Error ? error.message : t(($) => $.createFailed, { ns: 'snippet' }), + ) + return false + } + }, + [handleSyncWorkflowDraft, incrementSnippetUseCount, queryClient, saveStateToHistory, store, t], + ) return { handleInsertSnippet, diff --git a/web/app/components/workflow/block-selector/start-blocks.tsx b/web/app/components/workflow/block-selector/start-blocks.tsx index cb83db23702cc2..87489f96308a60 100644 --- a/web/app/components/workflow/block-selector/start-blocks.tsx +++ b/web/app/components/workflow/block-selector/start-blocks.tsx @@ -8,12 +8,7 @@ import { PreviewCardTrigger, } from '@langgenius/dify-ui/preview-card' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - memo, - useCallback, - useEffect, - useMemo, -} from 'react' +import { memo, useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import BlockIcon from '../block-icon' @@ -34,7 +29,7 @@ type StartBlocksProps = { disabled?: boolean } type StartBlockPreviewPayload = { - block: typeof START_BLOCKS[number] + block: (typeof START_BLOCKS)[number] } const StartBlocks = ({ @@ -55,30 +50,48 @@ const StartBlocks = ({ const filteredBlocks = useMemo(() => { // Check if Start node already exists in workflow - const hasStartNode = nodes.some(node => (node.data as CommonNodeType)?.type === BlockEnumValues.Start) + const hasStartNode = nodes.some( + (node) => (node.data as CommonNodeType)?.type === BlockEnumValues.Start, + ) const normalizedSearch = searchText.toLowerCase() const getDisplayName = (blockType: BlockEnum) => { if (blockType === BlockEnumValues.TriggerWebhook) - return t($ => $.customWebhook, { ns: 'workflow' }) + return t(($) => $.customWebhook, { ns: 'workflow' }) - return t($ => $[`blocks.${blockType}`], { ns: 'workflow' }) + return t(($) => $[`blocks.${blockType}`], { ns: 'workflow' }) } return START_BLOCKS.filter((block) => { // Hide User Input (Start) if it already exists in workflow or if hideUserInput is true. // In read-only conflict modes, keep it visible so the row can show Added or disabled tooltip state. - if (block.type === BlockEnumValues.Start && (hasStartNode || hideUserInput) && !showUserInputAdded && !showUserInputDisabled) + if ( + block.type === BlockEnumValues.Start && + (hasStartNode || hideUserInput) && + !showUserInputAdded && + !showUserInputDisabled + ) return false // Filter by search text const displayName = getDisplayName(block.type).toLowerCase() - if (!displayName.includes(normalizedSearch) && !block.title.toLowerCase().includes(normalizedSearch)) + if ( + !displayName.includes(normalizedSearch) && + !block.title.toLowerCase().includes(normalizedSearch) + ) return false // availableBlocksTypes now contains properly filtered entry node types from parent return availableBlocksTypes.includes(block.type) }) - }, [searchText, availableBlocksTypes, nodes, t, hideUserInput, showUserInputAdded, showUserInputDisabled]) + }, [ + searchText, + availableBlocksTypes, + nodes, + t, + hideUserInput, + showUserInputAdded, + showUserInputDisabled, + ]) const isEmpty = filteredBlocks.length === 0 @@ -90,76 +103,92 @@ const StartBlocks = ({ // reachable from the inspector + canvas once the row is clicked to insert // the start node, so hover/focus-only activation is a11y-safe. See // packages/dify-ui/AGENTS.md → Overlay Primitive Selection. - const renderBlock = useCallback((block: typeof START_BLOCKS[number]) => { - const isUserInput = block.type === BlockEnumValues.Start - const isUserInputDisabled = isUserInput && showUserInputDisabled - const isRowDisabled = disabled || (isUserInput && showUserInputAdded) || isUserInputDisabled - const label = t($ => $[`blocks.${block.type}`], { ns: 'workflow' }) - const disabledReason = t($ => $['nodes.startPlaceholder.userInputConflictTip'], { ns: 'workflow' }) - const row = ( - <BlockSelectorRow - aria-disabled={isRowDisabled} - aria-label={isUserInputDisabled ? `${label}. ${disabledReason}` : label} - disabled={isRowDisabled} - onClick={() => { - if (isRowDisabled) - return - onSelect(block.type) - }} - > - <div className={cn('flex min-w-0 flex-1 items-center', isUserInputDisabled && 'opacity-30')}> - <BlockIcon - className="mr-2 shrink-0" - type={block.type} - size="sm" - /> - <div className="flex w-0 grow items-center justify-between text-sm text-text-secondary"> - <span className="truncate system-sm-medium">{label}</span> - {isUserInput && showUserInputAdded && ( - <span className="ml-2 shrink-0 system-xs-regular text-text-tertiary"> - {t($ => $['operation.added'], { ns: 'common' })} - </span> - )} - {isUserInput && showMostCommonBadge && !showUserInputAdded && ( - <span className="ml-2 shrink-0 rounded-[5px] border border-divider-deep px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['blocks.mostCommon'], { ns: 'workflow' })} - </span> - )} - {isUserInput && !showMostCommonBadge && !showUserInputAdded && !showUserInputDisabled && ( - <span className="ml-2 shrink-0 system-xs-regular text-text-quaternary">{t($ => $['blocks.originalStartNode'], { ns: 'workflow' })}</span> - )} + const renderBlock = useCallback( + (block: (typeof START_BLOCKS)[number]) => { + const isUserInput = block.type === BlockEnumValues.Start + const isUserInputDisabled = isUserInput && showUserInputDisabled + const isRowDisabled = disabled || (isUserInput && showUserInputAdded) || isUserInputDisabled + const label = t(($) => $[`blocks.${block.type}`], { ns: 'workflow' }) + const disabledReason = t(($) => $['nodes.startPlaceholder.userInputConflictTip'], { + ns: 'workflow', + }) + const row = ( + <BlockSelectorRow + aria-disabled={isRowDisabled} + aria-label={isUserInputDisabled ? `${label}. ${disabledReason}` : label} + disabled={isRowDisabled} + onClick={() => { + if (isRowDisabled) return + onSelect(block.type) + }} + > + <div + className={cn('flex min-w-0 flex-1 items-center', isUserInputDisabled && 'opacity-30')} + > + <BlockIcon className="mr-2 shrink-0" type={block.type} size="sm" /> + <div className="flex w-0 grow items-center justify-between text-sm text-text-secondary"> + <span className="truncate system-sm-medium">{label}</span> + {isUserInput && showUserInputAdded && ( + <span className="ml-2 shrink-0 system-xs-regular text-text-tertiary"> + {t(($) => $['operation.added'], { ns: 'common' })} + </span> + )} + {isUserInput && showMostCommonBadge && !showUserInputAdded && ( + <span className="ml-2 shrink-0 rounded-[5px] border border-divider-deep px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['blocks.mostCommon'], { ns: 'workflow' })} + </span> + )} + {isUserInput && + !showMostCommonBadge && + !showUserInputAdded && + !showUserInputDisabled && ( + <span className="ml-2 shrink-0 system-xs-regular text-text-quaternary"> + {t(($) => $['blocks.originalStartNode'], { ns: 'workflow' })} + </span> + )} + </div> </div> - </div> - </BlockSelectorRow> - ) + </BlockSelectorRow> + ) + + if (isUserInputDisabled) { + return ( + <Tooltip key={block.type}> + <TooltipTrigger render={row} /> + <TooltipContent + placement="right" + sideOffset={8} + className="max-w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-4 py-3.5 shadow-lg" + > + <p className="system-xs-regular text-text-secondary">{disabledReason}</p> + </TooltipContent> + </Tooltip> + ) + } - if (isUserInputDisabled) { return ( - <Tooltip key={block.type}> - <TooltipTrigger render={row} /> - <TooltipContent placement="right" sideOffset={8} className="max-w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-4 py-3.5 shadow-lg"> - <p className="system-xs-regular text-text-secondary"> - {disabledReason} - </p> - </TooltipContent> - </Tooltip> + <PreviewCardTrigger + key={block.type} + delay={150} + closeDelay={150} + handle={previewCardHandle} + payload={{ block }} + render={row} + /> ) - } - - return ( - <PreviewCardTrigger - key={block.type} - delay={150} - closeDelay={150} - handle={previewCardHandle} - payload={{ block }} - render={row} - /> - ) - }, [disabled, onSelect, previewCardHandle, showMostCommonBadge, showUserInputAdded, showUserInputDisabled, t]) + }, + [ + disabled, + onSelect, + previewCardHandle, + showMostCommonBadge, + showUserInputAdded, + showUserInputDisabled, + t, + ], + ) - if (isEmpty) - return null + if (isEmpty) return null return ( <div className="p-1"> @@ -167,20 +196,19 @@ const StartBlocks = ({ {filteredBlocks.map((block, index) => ( <div key={block.type}> {renderBlock(block)} - {block.type === BlockEnumValues.Start && !showMostCommonBadge && index < filteredBlocks.length - 1 && ( - <div className="my-1 px-3"> - <div className="border-t border-divider-subtle" /> - </div> - )} + {block.type === BlockEnumValues.Start && + !showMostCommonBadge && + index < filteredBlocks.length - 1 && ( + <div className="my-1 px-3"> + <div className="border-t border-divider-subtle" /> + </div> + )} </div> ))} </div> <PreviewCard handle={previewCardHandle}> {({ payload }) => ( - <StartBlockPreviewCard - payload={payload as StartBlockPreviewPayload | undefined} - t={t} - /> + <StartBlockPreviewCard payload={payload as StartBlockPreviewPayload | undefined} t={t} /> )} </PreviewCard> </div> @@ -192,17 +220,14 @@ type StartBlockPreviewCardProps = { t: ReturnType<typeof useTranslation>['t'] } -function StartBlockPreviewCard({ - payload, - t, -}: StartBlockPreviewCardProps) { - if (!payload) - return null +function StartBlockPreviewCard({ payload, t }: StartBlockPreviewCardProps) { + if (!payload) return null const { block } = payload - const description = block.type === BlockEnumValues.Start - ? t($ => $['nodes.start.userInputTipDescription'], { ns: 'workflow' }) - : t($ => $[`blocksAbout.${block.type}`], { ns: 'workflow' }) + const description = + block.type === BlockEnumValues.Start + ? t(($) => $['nodes.start.userInputTipDescription'], { ns: 'workflow' }) + : t(($) => $[`blocksAbout.${block.type}`], { ns: 'workflow' }) const showDifyTeamAuthor = [ BlockEnumValues.Start, BlockEnumValues.TriggerWebhook, @@ -212,22 +237,14 @@ function StartBlockPreviewCard({ return ( <PreviewCardContent placement="right" popupClassName="w-[224px] px-3 pt-3 pb-2.5"> <div> - <BlockIcon - size="md" - className="mb-2" - type={block.type} - /> + <BlockIcon size="md" className="mb-2" type={block.type} /> <div className="mb-1 system-md-medium text-text-primary"> - {t($ => $[`blocks.${block.type}`], { ns: 'workflow' })} - </div> - <div className="system-xs-regular wrap-break-word text-text-secondary"> - {description} + {t(($) => $[`blocks.${block.type}`], { ns: 'workflow' })} </div> + <div className="system-xs-regular wrap-break-word text-text-secondary">{description}</div> {showDifyTeamAuthor && ( <div className="mt-1 system-xs-regular text-text-tertiary"> - {t($ => $.author, { ns: 'tools' })} - {' '} - {t($ => $.difyTeam, { ns: 'workflow' })} + {t(($) => $.author, { ns: 'tools' })} {t(($) => $.difyTeam, { ns: 'workflow' })} </div> )} </div> diff --git a/web/app/components/workflow/block-selector/storage.ts b/web/app/components/workflow/block-selector/storage.ts index bddac31846f982..1f2cc558b80fc9 100644 --- a/web/app/components/workflow/block-selector/storage.ts +++ b/web/app/components/workflow/block-selector/storage.ts @@ -1,10 +1,7 @@ import { createLocalStorageState } from 'foxact/create-local-storage-state' -const [ - useFeaturedToolsCollapsed, - _useFeaturedToolsCollapsedValue, - _useSetFeaturedToolsCollapsed, -] = createLocalStorageState<boolean>('workflow_tools_featured_collapsed', false) +const [useFeaturedToolsCollapsed, _useFeaturedToolsCollapsedValue, _useSetFeaturedToolsCollapsed] = + createLocalStorageState<boolean>('workflow_tools_featured_collapsed', false) const [ useFeaturedTriggersCollapsed, @@ -18,8 +15,4 @@ const [ _useSetRAGRecommendationsCollapsed, ] = createLocalStorageState<boolean>('workflow_rag_recommendations_collapsed', false) -export { - useFeaturedToolsCollapsed, - useFeaturedTriggersCollapsed, - useRAGRecommendationsCollapsed, -} +export { useFeaturedToolsCollapsed, useFeaturedTriggersCollapsed, useRAGRecommendationsCollapsed } diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index cde5b42733444e..5e290580745b39 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -1,10 +1,5 @@ import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' -import type { - BlockEnum, - NodeDefault, - OnSelectBlock, - ToolWithProvider, -} from '../types' +import type { BlockEnum, NodeDefault, OnSelectBlock, ToolWithProvider } from '../types' import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useSuspenseQuery } from '@tanstack/react-query' @@ -13,7 +8,13 @@ import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useFeaturedToolsRecommendations } from '@/service/use-plugins' -import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllBuiltInTools } from '@/service/use-tools' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, + useInvalidateAllBuiltInTools, +} from '@/service/use-tools' import { basePath } from '@/utils/var' import { useWorkflowStore } from '../store' import AllStartBlocks from './all-start-blocks' @@ -50,19 +51,16 @@ type TabsProps = { } const normalizeToolList = (list: ToolWithProvider[] | undefined, currentBasePath?: string) => { - if (!list || !currentBasePath) - return list + if (!list || !currentBasePath) return list let changed = false const normalized = list.map((provider) => { - if (typeof provider.icon !== 'string') - return provider + if (typeof provider.icon !== 'string') return provider - const shouldPrefix = provider.icon.startsWith('/') - && !provider.icon.startsWith(`${currentBasePath}/`) + const shouldPrefix = + provider.icon.startsWith('/') && !provider.icon.startsWith(`${currentBasePath}/`) - if (!shouldPrefix) - return provider + if (!shouldPrefix) return provider changed = true return { @@ -100,8 +98,7 @@ const getStoreToolUpdates = ({ updates.customTools = customTools if (workflowTools !== undefined && state.workflowTools !== workflowTools) updates.workflowTools = workflowTools - if (mcpTools !== undefined && state.mcpTools !== mcpTools) - updates.mcpTools = mcpTools + if (mcpTools !== undefined && state.mcpTools !== mcpTools) updates.mcpTools = mcpTools return updates } @@ -126,14 +123,13 @@ const TabHeaderItem = ({ tab.disabled ? 'cursor-not-allowed text-text-disabled opacity-60' : activeTab === tab.key - // eslint-disable-next-line tailwindcss/no-unknown-classes - ? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent' + ? // eslint-disable-next-line tailwindcss/no-unknown-classes + 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent' : 'cursor-pointer text-text-tertiary', ) const handleClick = () => { - if (tab.disabled || activeTab === tab.key) - return + if (tab.disabled || activeTab === tab.key) return onActiveTabChange(tab.key) } @@ -141,7 +137,7 @@ const TabHeaderItem = ({ return ( <Tooltip key={tab.key}> <TooltipTrigger - render={( + render={ <button type="button" className={className} @@ -150,7 +146,7 @@ const TabHeaderItem = ({ > {tab.name} </button> - )} + } /> <TooltipContent placement="top" className="max-w-[230px] rounded-xl px-4 py-3.5"> <div className="flex flex-col items-start gap-1 system-xs-regular text-text-secondary"> @@ -161,7 +157,7 @@ const TabHeaderItem = ({ href={disabledTipLinkHref} target="_blank" rel="noopener noreferrer" - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > {disabledTipLinkLabel} </a> @@ -173,12 +169,7 @@ const TabHeaderItem = ({ } return ( - <div - key={tab.key} - className={className} - aria-disabled={tab.disabled} - onClick={handleClick} - > + <div key={tab.key} className={className} aria-disabled={tab.disabled} onClick={handleClick}> {tab.name} </div> ) @@ -213,19 +204,26 @@ const Tabs: FC<TabsProps> = ({ const invalidateBuiltInTools = useInvalidateAllBuiltInTools() const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const workflowStore = useWorkflowStore() const inRAGPipeline = dataSources.length > 0 - const { - plugins: featuredPlugins = [], - isLoading: isFeaturedLoading, - } = useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline) - const normalizedBuiltInTools = useMemo(() => normalizeToolList(buildInTools, basePath), [buildInTools]) - const normalizedCustomTools = useMemo(() => normalizeToolList(customTools, basePath), [customTools]) - const normalizedWorkflowTools = useMemo(() => normalizeToolList(workflowTools, basePath), [workflowTools]) + const { plugins: featuredPlugins = [], isLoading: isFeaturedLoading } = + useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline) + const normalizedBuiltInTools = useMemo( + () => normalizeToolList(buildInTools, basePath), + [buildInTools], + ) + const normalizedCustomTools = useMemo( + () => normalizeToolList(customTools, basePath), + [customTools], + ) + const normalizedWorkflowTools = useMemo( + () => normalizeToolList(workflowTools, basePath), + [workflowTools], + ) const normalizedMcpTools = useMemo(() => normalizeToolList(mcpTools, basePath), [mcpTools]) - const disabledTip = t($ => $['tabs.startDisabledTip'], { ns: 'workflow' }) + const disabledTip = t(($) => $['tabs.startDisabledTip'], { ns: 'workflow' }) useEffect(() => { workflowStore.setState((state) => { @@ -236,106 +234,97 @@ const Tabs: FC<TabsProps> = ({ workflowTools: normalizedWorkflowTools, mcpTools: normalizedMcpTools, }) - if (!Object.keys(updates).length) - return state + if (!Object.keys(updates).length) return state return { ...state, ...updates, } }) - }, [normalizedBuiltInTools, normalizedCustomTools, normalizedMcpTools, normalizedWorkflowTools, workflowStore]) + }, [ + normalizedBuiltInTools, + normalizedCustomTools, + normalizedMcpTools, + normalizedWorkflowTools, + workflowStore, + ]) return ( - <div className="w-full min-w-0" onClick={e => e.stopPropagation()}> - { - !noBlocks && ( - <div className="relative flex w-full min-w-0 bg-background-section-burn pt-1 pl-1"> - { - tabs.map(tab => ( - <TabHeaderItem - key={tab.key} - tab={tab} - activeTab={activeTab} - onActiveTabChange={onActiveTabChange} - disabledTip={tab.disabledTip || disabledTip} - disabledTipLinkHref={tab.disabledTipLinkKey === 'startNodesDocs' ? docLink('/use-dify/nodes/trigger/overview') : undefined} - disabledTipLinkLabel={tab.disabledTipLinkKey === 'startNodesDocs' ? t($ => $['tabs.startDisabledTipLearnMore'], { ns: 'workflow' }) : undefined} - /> - )) - } - </div> - ) - } - {filterElem} - { - activeTab === TabsEnum.Start && (!noBlocks || forceShowStartContent) && ( - <div className="border-t border-divider-subtle"> - <AllStartBlocks - allowUserInputSelection={allowStartNodeSelection} - hasUserInputNode={hasUserInputNode} - hasTriggerNode={hasTriggerNode} - searchText={searchText} - onSelect={onSelect} - availableBlocksTypes={availableBlocksTypes} - tags={tags} + <div className="w-full min-w-0" onClick={(e) => e.stopPropagation()}> + {!noBlocks && ( + <div className="relative flex w-full min-w-0 bg-background-section-burn pt-1 pl-1"> + {tabs.map((tab) => ( + <TabHeaderItem + key={tab.key} + tab={tab} + activeTab={activeTab} + onActiveTabChange={onActiveTabChange} + disabledTip={tab.disabledTip || disabledTip} + disabledTipLinkHref={ + tab.disabledTipLinkKey === 'startNodesDocs' + ? docLink('/use-dify/nodes/trigger/overview') + : undefined + } + disabledTipLinkLabel={ + tab.disabledTipLinkKey === 'startNodesDocs' + ? t(($) => $['tabs.startDisabledTipLearnMore'], { ns: 'workflow' }) + : undefined + } /> - </div> - ) - } - { - activeTab === TabsEnum.Blocks && !noBlocks && ( - <div className="border-t border-divider-subtle"> - <Blocks - searchText={searchText} - onSelect={onSelect} - availableBlocksTypes={availableBlocksTypes} - blocks={blocks} - /> - </div> - ) - } - { - activeTab === TabsEnum.Sources && !!dataSources.length && ( - <div className="border-t border-divider-subtle"> - <DataSources - searchText={searchText} - onSelect={onSelect} - dataSources={dataSources} - /> - </div> - ) - } - { - activeTab === TabsEnum.Tools && !noTools && ( - <AllTools + ))} + </div> + )} + {filterElem} + {activeTab === TabsEnum.Start && (!noBlocks || forceShowStartContent) && ( + <div className="border-t border-divider-subtle"> + <AllStartBlocks + allowUserInputSelection={allowStartNodeSelection} + hasUserInputNode={hasUserInputNode} + hasTriggerNode={hasTriggerNode} searchText={searchText} onSelect={onSelect} + availableBlocksTypes={availableBlocksTypes} tags={tags} - canNotSelectMultiple - buildInTools={normalizedBuiltInTools || []} - customTools={normalizedCustomTools || []} - workflowTools={normalizedWorkflowTools || []} - mcpTools={normalizedMcpTools || []} - onTagsChange={onTagsChange} - isInRAGPipeline={inRAGPipeline} - featuredPlugins={featuredPlugins} - featuredLoading={isFeaturedLoading} - showFeatured={enable_marketplace && !inRAGPipeline} - onFeaturedInstallSuccess={async () => { - invalidateBuiltInTools() - }} /> - ) - } - { - activeTab === TabsEnum.Snippets && Boolean(snippetsElem) - ? ( - <div className="border-t border-divider-subtle"> - {snippetsElem} - </div> - ) - : null - } + </div> + )} + {activeTab === TabsEnum.Blocks && !noBlocks && ( + <div className="border-t border-divider-subtle"> + <Blocks + searchText={searchText} + onSelect={onSelect} + availableBlocksTypes={availableBlocksTypes} + blocks={blocks} + /> + </div> + )} + {activeTab === TabsEnum.Sources && !!dataSources.length && ( + <div className="border-t border-divider-subtle"> + <DataSources searchText={searchText} onSelect={onSelect} dataSources={dataSources} /> + </div> + )} + {activeTab === TabsEnum.Tools && !noTools && ( + <AllTools + searchText={searchText} + onSelect={onSelect} + tags={tags} + canNotSelectMultiple + buildInTools={normalizedBuiltInTools || []} + customTools={normalizedCustomTools || []} + workflowTools={normalizedWorkflowTools || []} + mcpTools={normalizedMcpTools || []} + onTagsChange={onTagsChange} + isInRAGPipeline={inRAGPipeline} + featuredPlugins={featuredPlugins} + featuredLoading={isFeaturedLoading} + showFeatured={enable_marketplace && !inRAGPipeline} + onFeaturedInstallSuccess={async () => { + invalidateBuiltInTools() + }} + /> + )} + {activeTab === TabsEnum.Snippets && Boolean(snippetsElem) ? ( + <div className="border-t border-divider-subtle">{snippetsElem}</div> + ) : null} </div> ) } diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx index 0b1b78fc00d6bc..0f320a0ee649ae 100644 --- a/web/app/components/workflow/block-selector/tool-picker.tsx +++ b/web/app/components/workflow/block-selector/tool-picker.tsx @@ -6,11 +6,7 @@ import type { ToolDefaultValue, ToolValue } from './types' import type { CustomCollectionBackend } from '@/app/components/tools/types' import type { BlockEnum, OnSelectBlock } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' import { useSuspenseQuery } from '@tanstack/react-query' import { useBoolean } from 'ahooks' @@ -21,9 +17,7 @@ import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-m import { useCanManageTools } from '@/app/components/tools/hooks/use-tool-permissions' import AllTools from '@/app/components/workflow/block-selector/all-tools' import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { - createCustomCollection, -} from '@/service/tools' +import { createCustomCollection } from '@/service/tools' import { useFeaturedToolsRecommendations } from '@/service/use-plugins' import { useAllBuiltInTools, @@ -43,7 +37,8 @@ type Props = Readonly<{ offset?: OffsetOptions isShow: boolean onShowChange: (isShow: boolean) => void -}> & ToolPickerContentProps +}> & + ToolPickerContentProps export type ToolPickerContentProps = Readonly<{ focusSearchOnMount?: boolean @@ -71,7 +66,7 @@ export function ToolPickerContent({ const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const { data: buildInTools } = useAllBuiltInTools() const shouldFetchCustomTools = scope !== 'plugins' && scope !== 'workflow' @@ -84,10 +79,8 @@ export function ToolPickerContent({ const invalidateWorkflowTools = useInvalidateAllWorkflowTools() const invalidateMcpTools = useInvalidateAllMCPTools() - const { - plugins: featuredPlugins = [], - isLoading: isFeaturedLoading, - } = useFeaturedToolsRecommendations(enable_marketplace) + const { plugins: featuredPlugins = [], isLoading: isFeaturedLoading } = + useFeaturedToolsRecommendations(enable_marketplace) const { builtinToolList, customToolList, workflowToolList } = useMemo(() => { if (scope === 'plugins') { @@ -128,17 +121,16 @@ export function ToolPickerContent({ onSelectMultiple(tools) } - const [isShowEditCollectionToolModal, { - setFalse: hideEditCustomCollectionModal, - setTrue: showEditCustomCollectionModal, - }] = useBoolean(false) + const [ + isShowEditCollectionToolModal, + { setFalse: hideEditCustomCollectionModal, setTrue: showEditCustomCollectionModal }, + ] = useBoolean(false) const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => { - if (!canManageTools) - return + if (!canManageTools) return await createCustomCollection(data) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) hideEditCustomCollectionModal() handleAddedCustomTool() } @@ -155,14 +147,19 @@ export function ToolPickerContent({ } return ( - <div className={cn('relative min-h-20 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs', panelClassName)}> + <div + className={cn( + 'relative min-h-20 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs', + panelClassName, + )} + > <div className="p-2 pb-1"> <SearchBox search={searchText} onSearchChange={setSearchText} tags={tags} onTagsChange={setTags} - placeholder={t($ => $.searchTools, { ns: 'plugin' })!} + placeholder={t(($) => $.searchTools, { ns: 'plugin' })!} supportAddCustomTool={supportAddCustomTool && canManageTools} onAddedCustomTool={handleAddedCustomTool} onShowAddCustomCollectionModal={showEditCustomCollectionModal} @@ -209,24 +206,19 @@ function ToolPicker({ onShowChange, ...contentProps }: Props) { - const sideOffset = typeof offset === 'number' ? offset : (typeof offset === 'function' ? 0 : (offset?.mainAxis ?? 0)) - const alignOffset = typeof offset === 'number' ? 0 : (typeof offset === 'function' ? 0 : (offset?.crossAxis ?? 0)) + const sideOffset = + typeof offset === 'number' ? offset : typeof offset === 'function' ? 0 : (offset?.mainAxis ?? 0) + const alignOffset = + typeof offset === 'number' ? 0 : typeof offset === 'function' ? 0 : (offset?.crossAxis ?? 0) const handleOpenChange = (nextOpen: boolean) => { - if (nextOpen && disabled) - return + if (nextOpen && disabled) return onShowChange(nextOpen) } return ( - <Popover - open={isShow} - onOpenChange={handleOpenChange} - > - <PopoverTrigger - nativeButton={false} - render={<div className="inline-block" />} - > + <Popover open={isShow} onOpenChange={handleOpenChange}> + <PopoverTrigger nativeButton={false} render={<div className="inline-block" />}> {trigger} </PopoverTrigger> diff --git a/web/app/components/workflow/block-selector/tool/__tests__/action-item.spec.tsx b/web/app/components/workflow/block-selector/tool/__tests__/action-item.spec.tsx index 341f0d2346fa01..559c88cf77a6bd 100644 --- a/web/app/components/workflow/block-selector/tool/__tests__/action-item.spec.tsx +++ b/web/app/components/workflow/block-selector/tool/__tests__/action-item.spec.tsx @@ -47,12 +47,15 @@ describe('ToolActionItem', () => { await user.click(screen.getByRole('button', { name: 'Search Tool' })) - expect(onSelect).toHaveBeenCalledWith(BlockEnum.Tool, expect.objectContaining({ - provider_id: 'provider-1', - tool_name: 'search', - tool_label: 'Search Tool', - params: {}, - })) + expect(onSelect).toHaveBeenCalledWith( + BlockEnum.Tool, + expect.objectContaining({ + provider_id: 'provider-1', + tool_name: 'search', + tool_label: 'Search Tool', + params: {}, + }), + ) expect(mockTrackEvent).toHaveBeenCalledWith('tool_selected', { tool_name: 'search', plugin_id: 'plugin-1', diff --git a/web/app/components/workflow/block-selector/tool/__tests__/tool.spec.tsx b/web/app/components/workflow/block-selector/tool/__tests__/tool.spec.tsx index b0cb22962635cd..442fdf6b604d58 100644 --- a/web/app/components/workflow/block-selector/tool/__tests__/tool.spec.tsx +++ b/web/app/components/workflow/block-selector/tool/__tests__/tool.spec.tsx @@ -47,10 +47,7 @@ describe('Tool', () => { render( <Tool payload={createToolProvider({ - tools: [ - createTool('tool-a', 'Tool A'), - createTool('tool-b', 'Tool B'), - ], + tools: [createTool('tool-a', 'Tool A'), createTool('tool-b', 'Tool B')], })} previewCardHandle={createPreviewCardHandle()} viewType={ViewType.flat} @@ -62,12 +59,15 @@ describe('Tool', () => { await user.click(screen.getByText('Provider One')) await user.click(screen.getByText('Tool B')) - expect(onSelect).toHaveBeenCalledWith(BlockEnum.Tool, expect.objectContaining({ - provider_id: 'provider-1', - provider_name: 'provider-one', - tool_name: 'tool-b', - title: 'Tool B', - })) + expect(onSelect).toHaveBeenCalledWith( + BlockEnum.Tool, + expect.objectContaining({ + provider_id: 'provider-1', + provider_name: 'provider-one', + tool_name: 'tool-b', + title: 'Tool B', + }), + ) expect(mockTrackEvent).toHaveBeenCalledWith('tool_selected', { tool_name: 'tool-b', plugin_id: 'plugin-1', @@ -93,10 +93,13 @@ describe('Tool', () => { await user.click(screen.getByText('Workflow Tool')) - expect(onSelect).toHaveBeenCalledWith(BlockEnum.Tool, expect.objectContaining({ - provider_type: CollectionType.workflow, - tool_name: 'workflow-tool', - tool_label: 'Workflow Tool', - })) + expect(onSelect).toHaveBeenCalledWith( + BlockEnum.Tool, + expect.objectContaining({ + provider_type: CollectionType.workflow, + tool_name: 'workflow-tool', + tool_label: 'Workflow Tool', + }), + ) }) }) diff --git a/web/app/components/workflow/block-selector/tool/action-item.tsx b/web/app/components/workflow/block-selector/tool/action-item.tsx index 23aea7125df149..dbd40cded345cd 100644 --- a/web/app/components/workflow/block-selector/tool/action-item.tsx +++ b/web/app/components/workflow/block-selector/tool/action-item.tsx @@ -17,9 +17,13 @@ import BlockIcon from '../../block-icon' import { BlockEnum } from '../../types' const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => { - if (!icon) - return icon - if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`)) + if (!icon) return icon + if ( + typeof icon === 'string' && + basePath && + icon.startsWith('/') && + !icon.startsWith(`${basePath}/`) + ) return `${basePath}${icon}` return icon } @@ -58,13 +62,11 @@ const ToolItem: FC<Props> = ({ return normalizeProviderIcon(provider.icon) ?? provider.icon }, [provider.icon]) const normalizedIconDark = useMemo(() => { - if (!provider.icon_dark) - return undefined + if (!provider.icon_dark) return undefined return normalizeProviderIcon(provider.icon_dark) ?? provider.icon_dark }, [provider.icon_dark]) const providerIcon = useMemo(() => { - if (theme === Theme.dark && normalizedIconDark) - return normalizedIconDark + if (theme === Theme.dark && normalizedIconDark) return normalizedIconDark return normalizedIcon }, [theme, normalizedIcon, normalizedIconDark]) @@ -75,8 +77,7 @@ const ToolItem: FC<Props> = ({ disabled={disabled} className="flex w-full cursor-pointer items-center justify-between rounded-lg border-none bg-transparent pr-1 pl-[21px] text-left hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-default" onClick={() => { - if (disabled) - return + if (disabled) return const params: Record<string, string> = {} if (payload.parameters) { payload.parameters.forEach((item) => { @@ -107,11 +108,17 @@ const ToolItem: FC<Props> = ({ }) }} > - <div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary')}> + <div + className={cn( + 'truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary', + )} + > <span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span> </div> {isAdded && ( - <div className="mr-4 system-xs-regular text-text-tertiary">{t($ => $['addToolModal.added'], { ns: 'tools' })}</div> + <div className="mr-4 system-xs-regular text-text-tertiary"> + {t(($) => $['addToolModal.added'], { ns: 'tools' })} + </div> )} </button> ) @@ -140,11 +147,8 @@ type ToolActionPreviewCardProps = { payload?: ToolActionPreviewPayload } -export function ToolActionPreviewCard({ - payload, -}: ToolActionPreviewCardProps) { - if (!payload) - return null +export function ToolActionPreviewCard({ payload }: ToolActionPreviewCardProps) { + if (!payload) return null return ( <PreviewCardContent placement="right" popupClassName="w-[200px] px-3 py-2.5"> @@ -155,8 +159,12 @@ export function ToolActionPreviewCard({ type={BlockEnum.Tool} toolIcon={payload.providerIcon} /> - <div className="mb-1 text-sm/5 text-text-primary">{payload.payload.label[payload.language]}</div> - <div className="text-xs leading-[18px] wrap-break-word text-text-secondary">{payload.payload.description[payload.language]}</div> + <div className="mb-1 text-sm/5 text-text-primary"> + {payload.payload.label[payload.language]} + </div> + <div className="text-xs leading-[18px] wrap-break-word text-text-secondary"> + {payload.payload.description[payload.language]} + </div> </div> </PreviewCardContent> ) diff --git a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx index bdd87717bdff82..3317c1b6ddabaa 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx @@ -38,22 +38,20 @@ const ToolViewFlatView: FC<Props> = ({ const firstLetterToolIds = useMemo(() => { const res: Record<string, string> = {} letters.forEach((letter) => { - const firstToolId = payload.find(tool => tool.letter === letter)?.id - if (firstToolId) - res[firstToolId] = letter + const firstToolId = payload.find((tool) => tool.letter === letter)?.id + if (firstToolId) res[firstToolId] = letter }) return res }, [payload, letters]) return ( <div className="flex w-full"> <div className="mr-1 grow"> - {payload.map(tool => ( + {payload.map((tool) => ( <div key={tool.id} ref={(el) => { const letter = firstLetterToolIds[tool.id] - if (letter) - toolRefs.current[letter] = el + if (letter) toolRefs.current[letter] = el }} > <Tool diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/item.spec.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/item.spec.tsx index f0eb788b70232d..f709cf8dab905e 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/item.spec.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/item.spec.tsx @@ -34,9 +34,11 @@ describe('ToolListTreeView Item', () => { render( <Item groupName="My Group" - toolList={[createToolProvider({ - label: { en_US: 'Provider Alpha', zh_Hans: 'Provider Alpha' }, - })]} + toolList={[ + createToolProvider({ + label: { en_US: 'Provider Alpha', zh_Hans: 'Provider Alpha' }, + }), + ]} previewCardHandle={createPreviewCardHandle()} hasSearchText={false} onSelect={vi.fn()} diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/list.spec.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/list.spec.tsx index 66b5a043abf9c9..242106a1905e36 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/list.spec.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/list.spec.tsx @@ -35,14 +35,18 @@ describe('ToolListTreeView', () => { render( <List payload={{ - BuiltIn: [createToolProvider({ - label: { en_US: 'Built In Provider', zh_Hans: 'Built In Provider' }, - })], - [CUSTOM_GROUP_NAME]: [createToolProvider({ - id: 'custom-provider', - type: 'custom', - label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' }, - })], + BuiltIn: [ + createToolProvider({ + label: { en_US: 'Built In Provider', zh_Hans: 'Built In Provider' }, + }), + ], + [CUSTOM_GROUP_NAME]: [ + createToolProvider({ + id: 'custom-provider', + type: 'custom', + label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' }, + }), + ], }} previewCardHandle={createPreviewCardHandle()} hasSearchText={false} diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx index 1be71bc1a1dc44..e8bdff7fa09d72 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx @@ -29,25 +29,24 @@ const ToolListTreeView: FC<Props> = ({ selectedTools, }) => { const { t } = useTranslation() - const getI18nGroupName = useCallback((name: string) => { - if (name === CUSTOM_GROUP_NAME) - return t($ => $['tabs.customTool'], { ns: 'workflow' }) + const getI18nGroupName = useCallback( + (name: string) => { + if (name === CUSTOM_GROUP_NAME) return t(($) => $['tabs.customTool'], { ns: 'workflow' }) - if (name === WORKFLOW_GROUP_NAME) - return t($ => $['tabs.workflowTool'], { ns: 'workflow' }) + if (name === WORKFLOW_GROUP_NAME) return t(($) => $['tabs.workflowTool'], { ns: 'workflow' }) - if (name === AGENT_GROUP_NAME) - return t($ => $['tabs.agent'], { ns: 'workflow' }) + if (name === AGENT_GROUP_NAME) return t(($) => $['tabs.agent'], { ns: 'workflow' }) - return name - }, [t]) + return name + }, + [t], + ) - if (!payload) - return null + if (!payload) return null return ( <div> - {Object.keys(payload).map(groupName => ( + {Object.keys(payload).map((groupName) => ( <Item key={groupName} groupName={getI18nGroupName(groupName)} diff --git a/web/app/components/workflow/block-selector/tool/tool.tsx b/web/app/components/workflow/block-selector/tool/tool.tsx index fd4f5905fd679b..63405331efd8e5 100644 --- a/web/app/components/workflow/block-selector/tool/tool.tsx +++ b/web/app/components/workflow/block-selector/tool/tool.tsx @@ -24,9 +24,13 @@ import { ViewType } from '../view-type-select' import ActionItem from './action-item' const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => { - if (!icon) - return icon! - if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`)) + if (!icon) return icon! + if ( + typeof icon === 'string' && + basePath && + icon.startsWith('/') && + !icon.startsWith(`${basePath}/`) + ) return `${basePath}${icon}` return icon } @@ -73,30 +77,35 @@ const Tool: FC<Props> = ({ return normalizeProviderIcon(payload.icon) ?? payload.icon }, [payload.icon]) const normalizedIconDark = useMemo(() => { - if (!payload.icon_dark) - return undefined! + if (!payload.icon_dark) return undefined! return normalizeProviderIcon(payload.icon_dark) ?? payload.icon_dark }, [payload.icon_dark]) const providerIcon = useMemo<ToolWithProvider['icon']>(() => { - if (theme === Theme.dark && normalizedIconDark) - return normalizedIconDark + if (theme === Theme.dark && normalizedIconDark) return normalizedIconDark return normalizedIcon }, [theme, normalizedIcon, normalizedIconDark]) - const getIsDisabled = useCallback((tool: ToolType) => { - if (!selectedTools || !selectedTools.length) - return false - return selectedTools.some(selectedTool => (selectedTool.provider_name === payload.name || selectedTool.provider_name === payload.id) && selectedTool.tool_name === tool.name) - }, [payload.id, payload.name, selectedTools]) + const getIsDisabled = useCallback( + (tool: ToolType) => { + if (!selectedTools || !selectedTools.length) return false + return selectedTools.some( + (selectedTool) => + (selectedTool.provider_name === payload.name || + selectedTool.provider_name === payload.id) && + selectedTool.tool_name === tool.name, + ) + }, + [payload.id, payload.name, selectedTools], + ) const totalToolsNum = actions.length - const selectedToolsNum = actions.filter(action => getIsDisabled(action)).length + const selectedToolsNum = actions.filter((action) => getIsDisabled(action)).length const isAllSelected = selectedToolsNum === totalToolsNum const notShowProviderSelectInfo = useMemo(() => { if (isAllSelected) { return ( <span className="system-xs-regular text-text-tertiary"> - {t($ => $['addToolModal.added'], { ns: 'tools' })} + {t(($) => $['addToolModal.added'], { ns: 'tools' })} </span> ) } @@ -107,49 +116,72 @@ const Tool: FC<Props> = ({ <span className="system-xs-regular text-components-button-secondary-accent-text" onClick={() => { - onSelectMultiple?.(BlockEnum.Tool, actions.filter(action => !getIsDisabled(action)).map((tool) => { - const params: Record<string, string> = {} - if (tool.parameters) { - tool.parameters.forEach((item) => { - params[item.name] = '' - }) - } - return { - provider_id: payload.id, - provider_type: payload.type, - provider_name: payload.name, - provider_show_name: payload.label[language], - plugin_id: payload.plugin_id!, - plugin_unique_identifier: payload.plugin_unique_identifier!, - provider_icon: normalizedIcon, - provider_icon_dark: normalizedIconDark, - tool_name: tool.name, - tool_label: tool.label[language]!, - tool_description: tool.description[language], - title: tool.label[language]!, - is_team_authorization: payload.is_team_authorization, - paramSchemas: tool.parameters, - params, - } - })) + onSelectMultiple?.( + BlockEnum.Tool, + actions + .filter((action) => !getIsDisabled(action)) + .map((tool) => { + const params: Record<string, string> = {} + if (tool.parameters) { + tool.parameters.forEach((item) => { + params[item.name] = '' + }) + } + return { + provider_id: payload.id, + provider_type: payload.type, + provider_name: payload.name, + provider_show_name: payload.label[language], + plugin_id: payload.plugin_id!, + plugin_unique_identifier: payload.plugin_unique_identifier!, + provider_icon: normalizedIcon, + provider_icon_dark: normalizedIconDark, + tool_name: tool.name, + tool_label: tool.label[language]!, + tool_description: tool.description[language], + title: tool.label[language]!, + is_team_authorization: payload.is_team_authorization, + paramSchemas: tool.parameters, + params, + } + }), + ) }} > - {t($ => $['tabs.addAll'], { ns: 'workflow' })} + {t(($) => $['tabs.addAll'], { ns: 'workflow' })} </span> ) } - if (selectedToolsNum === 0) - return <></> + if (selectedToolsNum === 0) return <></> return ( <span className="system-xs-regular text-text-tertiary"> {isAllSelected - ? t($ => $['tabs.allAdded'], { ns: 'workflow' }) + ? t(($) => $['tabs.allAdded'], { ns: 'workflow' }) : `${selectedToolsNum} / ${totalToolsNum}`} </span> ) - }, [actions, getIsDisabled, isAllSelected, isHovering, language, normalizedIcon, normalizedIconDark, onSelectMultiple, payload.id, payload.is_team_authorization, payload.label, payload.name, payload.plugin_id, payload.plugin_unique_identifier, payload.type, selectedToolsNum, t, totalToolsNum]) + }, [ + actions, + getIsDisabled, + isAllSelected, + isHovering, + language, + normalizedIcon, + normalizedIconDark, + onSelectMultiple, + payload.id, + payload.is_team_authorization, + payload.label, + payload.name, + payload.plugin_id, + payload.plugin_unique_identifier, + payload.type, + selectedToolsNum, + t, + totalToolsNum, + ]) if (isFoldHasSearchText !== hasSearchText) { setIsFoldHasSearchText(hasSearchText) @@ -159,24 +191,19 @@ const Tool: FC<Props> = ({ const FoldIcon = isFold ? RiArrowRightSLine : RiArrowDownSLine const groupName = useMemo(() => { - if (payload.type === CollectionType.builtIn) - return payload.author + if (payload.type === CollectionType.builtIn) return payload.author if (payload.type === CollectionType.custom) - return t($ => $['tabs.customTool'], { ns: 'workflow' }) + return t(($) => $['tabs.customTool'], { ns: 'workflow' }) if (payload.type === CollectionType.workflow) - return t($ => $['tabs.workflowTool'], { ns: 'workflow' }) + return t(($) => $['tabs.workflowTool'], { ns: 'workflow' }) return '' }, [payload.author, payload.type, t]) return ( - <div - key={payload.id} - className={cn('mb-1 last-of-type:mb-0')} - ref={ref} - > + <div key={payload.id} className={cn('mb-1 last-of-type:mb-0')} ref={ref}> <div className={cn(className)}> <div className="group/item flex w-full cursor-pointer items-center justify-between rounded-lg pr-1 pl-3 select-none hover:bg-state-base-hover" @@ -212,32 +239,43 @@ const Tool: FC<Props> = ({ }) }} > - <div className={cn('flex h-8 grow items-center', isShowCanNotChooseMCPTip && 'opacity-30')}> - <BlockIcon - className="shrink-0" - type={BlockEnum.Tool} - toolIcon={providerIcon} - /> + <div + className={cn('flex h-8 grow items-center', isShowCanNotChooseMCPTip && 'opacity-30')} + > + <BlockIcon className="shrink-0" type={BlockEnum.Tool} toolIcon={providerIcon} /> <div className="ml-2 flex w-0 grow items-center text-sm text-text-primary"> - <span className="max-w-[250px] truncate">{notShowProvider ? actions[0]?.label[language] : payload.label[language]}</span> + <span className="max-w-[250px] truncate"> + {notShowProvider ? actions[0]?.label[language] : payload.label[language]} + </span> {isFlatView && groupName && ( - <span className="ml-2 shrink-0 system-xs-regular text-text-quaternary">{groupName}</span> + <span className="ml-2 shrink-0 system-xs-regular text-text-quaternary"> + {groupName} + </span> )} {isMCPTool && <Mcp className="ml-2 size-3.5 shrink-0 text-text-quaternary" />} </div> </div> <div className="ml-2 flex items-center"> - {!isShowCanNotChooseMCPTip && !canNotSelectMultiple && (notShowProvider ? notShowProviderSelectInfo : selectedInfo)} + {!isShowCanNotChooseMCPTip && + !canNotSelectMultiple && + (notShowProvider ? notShowProviderSelectInfo : selectedInfo)} {isShowCanNotChooseMCPTip && <McpToolNotSupportTooltip />} {hasAction && ( - <FoldIcon className={cn('size-4 shrink-0 text-text-tertiary group-hover/item:text-text-tertiary', isFold && 'text-text-quaternary')} /> + <FoldIcon + className={cn( + 'size-4 shrink-0 text-text-tertiary group-hover/item:text-text-tertiary', + isFold && 'text-text-quaternary', + )} + /> )} </div> </div> - {!notShowProvider && hasAction && !isFold && ( - actions.map(action => ( + {!notShowProvider && + hasAction && + !isFold && + actions.map((action) => ( <ActionItem key={action.name} provider={payload} @@ -247,8 +285,7 @@ const Tool: FC<Props> = ({ disabled={getIsDisabled(action) || isShowCanNotChooseMCPTip} isAdded={getIsDisabled(action)} /> - )) - )} + ))} </div> </div> ) diff --git a/web/app/components/workflow/block-selector/tools.tsx b/web/app/components/workflow/block-selector/tools.tsx index e2c4a880481cd7..64676f1d05a7a2 100644 --- a/web/app/components/workflow/block-selector/tools.tsx +++ b/web/app/components/workflow/block-selector/tools.tsx @@ -59,13 +59,15 @@ const Tools = ({ } } */ - const { letters, groups: withLetterAndGroupViewToolsData } = groupItems(tools, tool => tool.label[language]![0]!) + const { letters, groups: withLetterAndGroupViewToolsData } = groupItems( + tools, + (tool) => tool.label[language]![0]!, + ) const treeViewToolsData = useMemo(() => { const result: Record<string, ToolWithProvider[]> = {} Object.keys(withLetterAndGroupViewToolsData).forEach((letter) => { Object.keys(withLetterAndGroupViewToolsData[letter]!).forEach((groupName) => { - if (!result[groupName]) - result[groupName] = [] + if (!result[groupName]) result[groupName] = [] result[groupName].push(...(withLetterAndGroupViewToolsData[letter]![groupName] ?? [])) }) }) @@ -76,12 +78,14 @@ const Tools = ({ const result: ToolWithProvider[] = [] letters.forEach((letter) => { Object.keys(withLetterAndGroupViewToolsData[letter]!).forEach((groupName) => { - result.push(...withLetterAndGroupViewToolsData[letter]![groupName]!.map((item) => { - return { - ...item, - letter, - } - })) + result.push( + ...withLetterAndGroupViewToolsData[letter]![groupName]!.map((item) => { + return { + ...item, + letter, + } + }), + ) }) }) @@ -97,35 +101,34 @@ const Tools = ({ <Empty type={toolType!} isAgent={isAgent} /> </div> )} - {!!tools.length && ( - isFlatView - ? ( - <ToolListFlatView - toolRefs={toolRefsRef} - letters={letters} - payload={listViewToolData} - previewCardHandle={previewCardHandle} - isShowLetterIndex={isShowLetterIndex} - hasSearchText={hasSearchText} - onSelect={onSelect} - canNotSelectMultiple={canNotSelectMultiple} - onSelectMultiple={onSelectMultiple} - selectedTools={selectedTools} - indexBar={<IndexBar letters={letters} itemRefs={toolRefsRef} className={indexBarClassName} />} - /> - ) - : ( - <ToolListTreeView - payload={treeViewToolsData} - previewCardHandle={previewCardHandle} - hasSearchText={hasSearchText} - onSelect={onSelect} - canNotSelectMultiple={canNotSelectMultiple} - onSelectMultiple={onSelectMultiple} - selectedTools={selectedTools} - /> - ) - )} + {!!tools.length && + (isFlatView ? ( + <ToolListFlatView + toolRefs={toolRefsRef} + letters={letters} + payload={listViewToolData} + previewCardHandle={previewCardHandle} + isShowLetterIndex={isShowLetterIndex} + hasSearchText={hasSearchText} + onSelect={onSelect} + canNotSelectMultiple={canNotSelectMultiple} + onSelectMultiple={onSelectMultiple} + selectedTools={selectedTools} + indexBar={ + <IndexBar letters={letters} itemRefs={toolRefsRef} className={indexBarClassName} /> + } + /> + ) : ( + <ToolListTreeView + payload={treeViewToolsData} + previewCardHandle={previewCardHandle} + hasSearchText={hasSearchText} + onSelect={onSelect} + canNotSelectMultiple={canNotSelectMultiple} + onSelectMultiple={onSelectMultiple} + selectedTools={selectedTools} + /> + ))} <PreviewCard handle={previewCardHandle}> {({ payload }) => ( <ToolActionPreviewCard payload={payload as ToolActionPreviewPayload | undefined} /> diff --git a/web/app/components/workflow/block-selector/trigger-plugin/__tests__/trigger-plugin.spec.tsx b/web/app/components/workflow/block-selector/trigger-plugin/__tests__/trigger-plugin.spec.tsx index 54311416591f25..dea4418af41bd5 100644 --- a/web/app/components/workflow/block-selector/trigger-plugin/__tests__/trigger-plugin.spec.tsx +++ b/web/app/components/workflow/block-selector/trigger-plugin/__tests__/trigger-plugin.spec.tsx @@ -40,22 +40,26 @@ const createEvent = (name: string, label: string): Event => ({ en_US: `${label} description`, zh_Hans: `${label} description`, }, - parameters: [{ - name: 'token', - label: { en_US: 'Token', zh_Hans: 'Token' }, - human_description: { en_US: 'Token', zh_Hans: 'Token' }, - type: 'string', - form: 'form', - llm_description: 'Token', - required: true, - multiple: false, - default: '', - }], + parameters: [ + { + name: 'token', + label: { en_US: 'Token', zh_Hans: 'Token' }, + human_description: { en_US: 'Token', zh_Hans: 'Token' }, + type: 'string', + form: 'form', + llm_description: 'Token', + required: true, + multiple: false, + default: '', + }, + ], labels: [], output_schema: { type: 'object' }, }) -const createTriggerProvider = (overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider => ({ +const createTriggerProvider = ( + overrides: Partial<TriggerWithProvider> = {}, +): TriggerWithProvider => ({ id: 'trigger-provider-1', name: 'trigger-provider', author: 'Trigger Author', @@ -87,7 +91,9 @@ describe('trigger plugin selector components', () => { vi.clearAllMocks() mockUseGetLanguage.mockReturnValue('en_US') mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>) - mockUseAllTriggerPlugins.mockReturnValue({ data: [] } as unknown as ReturnType<typeof useAllTriggerPlugins>) + mockUseAllTriggerPlugins.mockReturnValue({ data: [] } as unknown as ReturnType< + typeof useAllTriggerPlugins + >) }) it('should select trigger plugin action items with default params and preview details', async () => { @@ -107,13 +113,16 @@ describe('trigger plugin selector components', () => { await user.click(screen.getByText('On Created')) - expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({ - plugin_id: 'trigger-plugin-1', - provider_id: 'trigger-provider', - event_name: 'on_created', - event_label: 'On Created', - params: { token: '' }, - })) + expect(onSelect).toHaveBeenCalledWith( + BlockEnum.TriggerPlugin, + expect.objectContaining({ + plugin_id: 'trigger-plugin-1', + provider_id: 'trigger-provider', + event_name: 'on_created', + event_label: 'On Created', + params: { token: '' }, + }), + ) }) it('should select trigger plugin action items from the keyboard', async () => { @@ -137,10 +146,13 @@ describe('trigger plugin selector components', () => { await user.keyboard('{Enter}') - expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({ - event_name: 'on_created', - event_label: 'On Created', - })) + expect(onSelect).toHaveBeenCalledWith( + BlockEnum.TriggerPlugin, + expect.objectContaining({ + event_name: 'on_created', + event_label: 'On Created', + }), + ) }) it('should expand providers and select workflow trigger providers directly', async () => { @@ -150,10 +162,7 @@ describe('trigger plugin selector components', () => { const { rerender } = render( <TriggerPluginItem payload={createTriggerProvider({ - events: [ - createEvent('first', 'First Event'), - createEvent('second', 'Second Event'), - ], + events: [createEvent('first', 'First Event'), createEvent('second', 'Second Event')], })} hasSearchText={false} previewCardHandle={createPreviewCardHandle()} @@ -163,14 +172,20 @@ describe('trigger plugin selector components', () => { await user.click(screen.getByText('Trigger Provider')) - expect(screen.getByLabelText('workflow.tabs.allTriggers')).toHaveClass('max-h-[240px]', 'overscroll-contain') + expect(screen.getByLabelText('workflow.tabs.allTriggers')).toHaveClass( + 'max-h-[240px]', + 'overscroll-contain', + ) await user.click(screen.getByText('Second Event')) - expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({ - event_name: 'second', - title: 'Second Event', - })) + expect(onSelect).toHaveBeenCalledWith( + BlockEnum.TriggerPlugin, + expect.objectContaining({ + event_name: 'second', + title: 'Second Event', + }), + ) onSelect.mockClear() rerender( @@ -187,10 +202,13 @@ describe('trigger plugin selector components', () => { await user.click(screen.getByText('Workflow Event')) - expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({ - provider_type: CollectionType.workflow, - event_name: 'workflow_event', - })) + expect(onSelect).toHaveBeenCalledWith( + BlockEnum.TriggerPlugin, + expect.objectContaining({ + provider_type: CollectionType.workflow, + event_name: 'workflow_event', + }), + ) }) it('should expand trigger providers from the keyboard', async () => { @@ -200,10 +218,7 @@ describe('trigger plugin selector components', () => { render( <TriggerPluginItem payload={createTriggerProvider({ - events: [ - createEvent('first', 'First Event'), - createEvent('second', 'Second Event'), - ], + events: [createEvent('first', 'First Event'), createEvent('second', 'Second Event')], })} hasSearchText={false} previewCardHandle={createPreviewCardHandle()} diff --git a/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx b/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx index c08c0727f7fc79..3a06262aa82213 100644 --- a/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx +++ b/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx @@ -49,8 +49,7 @@ const TriggerPluginActionItem: FC<Props> = ({ disabled ? 'cursor-default' : 'cursor-pointer hover:bg-state-base-hover', )} onClick={() => { - if (disabled) - return + if (disabled) return const params: Record<string, string> = {} if (payload.parameters) { payload.parameters.forEach((item) => { @@ -75,11 +74,17 @@ const TriggerPluginActionItem: FC<Props> = ({ }) }} > - <div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary')}> + <div + className={cn( + 'truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary', + )} + > <span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span> </div> {isAdded && ( - <div className="mr-4 system-xs-regular text-text-tertiary">{t($ => $['addToolModal.added'], { ns: 'tools' })}</div> + <div className="mr-4 system-xs-regular text-text-tertiary"> + {t(($) => $['addToolModal.added'], { ns: 'tools' })} + </div> )} </button> ) @@ -104,11 +109,8 @@ type TriggerPluginActionPreviewCardProps = { payload?: TriggerPluginActionPreviewPayload } -export function TriggerPluginActionPreviewCard({ - payload, -}: TriggerPluginActionPreviewCardProps) { - if (!payload) - return null +export function TriggerPluginActionPreviewCard({ payload }: TriggerPluginActionPreviewCardProps) { + if (!payload) return null return ( <PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5"> @@ -119,8 +121,12 @@ export function TriggerPluginActionPreviewCard({ type={BlockEnum.TriggerPlugin} toolIcon={payload.provider.icon} /> - <div className="mb-1 text-sm/5 text-text-primary">{payload.payload.label[payload.language]}</div> - <div className="text-xs leading-[18px] wrap-break-word text-text-secondary">{payload.payload.description[payload.language]}</div> + <div className="mb-1 text-sm/5 text-text-primary"> + {payload.payload.label[payload.language]} + </div> + <div className="text-xs leading-[18px] wrap-break-word text-text-secondary"> + {payload.payload.description[payload.language]} + </div> </div> </PreviewCardContent> ) diff --git a/web/app/components/workflow/block-selector/trigger-plugin/item.tsx b/web/app/components/workflow/block-selector/trigger-plugin/item.tsx index 82e9b84675f128..9c157de7329615 100644 --- a/web/app/components/workflow/block-selector/trigger-plugin/item.tsx +++ b/web/app/components/workflow/block-selector/trigger-plugin/item.tsx @@ -1,7 +1,10 @@ 'use client' import type { FC } from 'react' import type { TriggerPluginActionPreviewCardHandle } from './action-item' -import type { TriggerDefaultValue, TriggerWithProvider } from '@/app/components/workflow/block-selector/types' +import type { + TriggerDefaultValue, + TriggerWithProvider, +} from '@/app/components/workflow/block-selector/types' import { cn } from '@langgenius/dify-ui/cn' import { ScrollAreaContent, @@ -25,9 +28,13 @@ import { BlockSelectorRow } from '../block-selector-row' import TriggerPluginActionItem from './action-item' const normalizeProviderIcon = (icon?: TriggerWithProvider['icon']) => { - if (!icon) - return icon - if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`)) + if (!icon) return icon + if ( + typeof icon === 'string' && + basePath && + icon.startsWith('/') && + !icon.startsWith(`${basePath}/`) + ) return `${basePath}${icon}` return icon } @@ -67,14 +74,13 @@ const TriggerPluginItem: FC<Props> = ({ const FoldIcon = isFold ? RiArrowRightSLine : RiArrowDownSLine const groupName = useMemo(() => { - if (payload.type === CollectionType.builtIn) - return payload.author + if (payload.type === CollectionType.builtIn) return payload.author if (payload.type === CollectionType.custom) - return t($ => $['tabs.customTool'], { ns: 'workflow' }) + return t(($) => $['tabs.customTool'], { ns: 'workflow' }) if (payload.type === CollectionType.workflow) - return t($ => $['tabs.workflowTool'], { ns: 'workflow' }) + return t(($) => $['tabs.workflowTool'], { ns: 'workflow' }) return payload.author || '' }, [payload.author, payload.type, t]) @@ -82,34 +88,30 @@ const TriggerPluginItem: FC<Props> = ({ return normalizeProviderIcon(payload.icon) ?? payload.icon }, [payload.icon]) const normalizedIconDark = useMemo(() => { - if (!payload.icon_dark) - return undefined + if (!payload.icon_dark) return undefined return normalizeProviderIcon(payload.icon_dark) ?? payload.icon_dark }, [payload.icon_dark]) const providerIcon = useMemo<TriggerWithProvider['icon']>(() => { - if (theme === Theme.dark && normalizedIconDark) - return normalizedIconDark + if (theme === Theme.dark && normalizedIconDark) return normalizedIconDark return normalizedIcon }, [normalizedIcon, normalizedIconDark, theme]) - const providerWithResolvedIcon = useMemo(() => ({ - ...payload, - icon: providerIcon, - }), [payload, providerIcon]) + const providerWithResolvedIcon = useMemo( + () => ({ + ...payload, + icon: providerIcon, + }), + [payload, providerIcon], + ) return ( - <div - key={payload.id} - className={cn('mb-1 last-of-type:mb-0')} - ref={ref} - > + <div key={payload.id} className={cn('mb-1 last-of-type:mb-0')} ref={ref}> <div className={cn(className)}> <BlockSelectorRow nativeDisabled={disabled} disabled={disabled} className="group/item justify-between select-none" onClick={() => { - if (disabled) - return + if (disabled) return if (hasAction) { setIsFold(!isFold) return @@ -147,14 +149,23 @@ const TriggerPluginItem: FC<Props> = ({ toolIcon={providerIcon} /> <div className="flex min-w-0 flex-1 items-center text-sm text-text-primary"> - <span className="max-w-[200px] truncate">{notShowProvider ? actions[0]?.label[language] : payload.label[language]}</span> - <span className="ml-2 truncate system-xs-regular text-text-quaternary">{groupName}</span> + <span className="max-w-[200px] truncate"> + {notShowProvider ? actions[0]?.label[language] : payload.label[language]} + </span> + <span className="ml-2 truncate system-xs-regular text-text-quaternary"> + {groupName} + </span> </div> </div> <div className="ml-2 flex items-center"> {hasAction && ( - <FoldIcon className={cn('size-4 shrink-0 text-text-tertiary group-hover/item:text-text-tertiary', isFold && 'text-text-quaternary')} /> + <FoldIcon + className={cn( + 'size-4 shrink-0 text-text-tertiary group-hover/item:text-text-tertiary', + isFold && 'text-text-quaternary', + )} + /> )} </div> </BlockSelectorRow> @@ -162,12 +173,12 @@ const TriggerPluginItem: FC<Props> = ({ {!notShowProvider && hasAction && !isFold && ( <ScrollAreaRoot className="relative max-h-[240px] overflow-hidden overscroll-contain"> <ScrollAreaViewport - aria-label={t($ => $['tabs.allTriggers'], { ns: 'workflow' })} + aria-label={t(($) => $['tabs.allTriggers'], { ns: 'workflow' })} className="max-h-[240px] overscroll-contain" role="region" > <ScrollAreaContent> - {actions.map(action => ( + {actions.map((action) => ( <TriggerPluginActionItem key={action.name} provider={providerWithResolvedIcon} diff --git a/web/app/components/workflow/block-selector/trigger-plugin/list.tsx b/web/app/components/workflow/block-selector/trigger-plugin/list.tsx index d73ac3985dd889..7a6402e384b985 100644 --- a/web/app/components/workflow/block-selector/trigger-plugin/list.tsx +++ b/web/app/components/workflow/block-selector/trigger-plugin/list.tsx @@ -25,23 +25,23 @@ const TriggerPluginList = ({ }: TriggerPluginListProps) => { const { data: triggerPluginsData } = useAllTriggerPlugins() const language = useGetLanguage() - const previewCardHandle = useMemo(() => createPreviewCardHandle<TriggerPluginActionPreviewPayload>(), []) + const previewCardHandle = useMemo( + () => createPreviewCardHandle<TriggerPluginActionPreviewPayload>(), + [], + ) const normalizedSearch = searchText.trim().toLowerCase() const triggerPlugins = useMemo(() => { const plugins = triggerPluginsData || [] const getLocalizedText = (text?: Record<string, string> | null) => { - if (!text) - return '' + if (!text) return '' - if (text[language]) - return text[language] + if (text[language]) return text[language] - if (text['en-US']) - return text['en-US'] + if (text['en-US']) return text['en-US'] const firstValue = Object.values(text).find(Boolean) - return (typeof firstValue === 'string') ? firstValue : '' + return typeof firstValue === 'string' ? firstValue : '' } const getSearchableTexts = (name: string, label?: Record<string, string> | null) => { const localized = getLocalizedText(label) @@ -51,16 +51,15 @@ const TriggerPluginList = ({ const isMatchingKeywords = (value: string) => value.toLowerCase().includes(normalizedSearch) if (!normalizedSearch) - return plugins.filter(triggerWithProvider => triggerWithProvider.events.length > 0) + return plugins.filter((triggerWithProvider) => triggerWithProvider.events.length > 0) return plugins.reduce<TriggerWithProvider[]>((acc, triggerWithProvider) => { - if (triggerWithProvider.events.length === 0) - return acc + if (triggerWithProvider.events.length === 0) return acc const providerMatches = getSearchableTexts( triggerWithProvider.name, triggerWithProvider.label, - ).some(text => isMatchingKeywords(text)) + ).some((text) => isMatchingKeywords(text)) if (providerMatches) { acc.push(triggerWithProvider) @@ -68,10 +67,7 @@ const TriggerPluginList = ({ } const matchedEvents = triggerWithProvider.events.filter((event) => { - return getSearchableTexts( - event.name, - event.label, - ).some(text => isMatchingKeywords(text)) + return getSearchableTexts(event.name, event.label).some((text) => isMatchingKeywords(text)) }) if (matchedEvents.length > 0) { @@ -91,12 +87,11 @@ const TriggerPluginList = ({ onContentStateChange?.(hasContent) }, [hasContent, onContentStateChange]) - if (!hasContent) - return null + if (!hasContent) return null return ( <div className="p-1"> - {triggerPlugins.map(plugin => ( + {triggerPlugins.map((plugin) => ( <TriggerPluginItem key={plugin.id} payload={plugin} @@ -108,7 +103,9 @@ const TriggerPluginList = ({ ))} <PreviewCard handle={previewCardHandle}> {({ payload }) => ( - <TriggerPluginActionPreviewCard payload={payload as TriggerPluginActionPreviewPayload | undefined} /> + <TriggerPluginActionPreviewCard + payload={payload as TriggerPluginActionPreviewPayload | undefined} + /> )} </PreviewCard> </div> diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index a6fea38acbdf10..e778f06522793d 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -1,5 +1,11 @@ import type { AgentInviteOptionResponse } from '@dify/contracts/api/console/agent/types.gen' -import type { ParametersSchema, PluginMeta, PluginTriggerSubscriptionConstructor, SupportedCreationMethods, TriggerEvent } from '../../plugins/types' +import type { + ParametersSchema, + PluginMeta, + PluginTriggerSubscriptionConstructor, + SupportedCreationMethods, + TriggerEvent, +} from '../../plugins/types' import type { Collection, Event } from '../../tools/types' import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -130,7 +136,7 @@ export type DataSourceItem = { identity: { author: string description: TypeWithI18N - icon: string | { background: string, content: string } + icon: string | { background: string; content: string } label: TypeWithI18N name: string tags: string[] @@ -139,7 +145,7 @@ export type DataSourceItem = { description: TypeWithI18N identity: { author: string - icon?: string | { background: string, content: string } + icon?: string | { background: string; content: string } label: TypeWithI18N name: string provider: string @@ -155,8 +161,14 @@ export type DataSourceItem = { } type TriggerCredentialField = { - type: 'secret-input' | 'text-input' | 'select' | 'boolean' - | 'app-selector' | 'model-selector' | 'tools-selector' + type: + | 'secret-input' + | 'text-input' + | 'select' + | 'boolean' + | 'app-selector' + | 'model-selector' + | 'tools-selector' name: string scope?: string | null required: boolean @@ -264,10 +276,10 @@ type LogRequest = { } type LogRequestHeaders = { - 'Host': string + Host: string 'User-Agent': string 'Content-Length': string - 'Accept': string + Accept: string 'Content-Type': string 'X-Forwarded-For': string 'X-Forwarded-Host': string diff --git a/web/app/components/workflow/block-selector/use-sticky-scroll.ts b/web/app/components/workflow/block-selector/use-sticky-scroll.ts index 4960eea74fc601..ad3f0be87623d6 100644 --- a/web/app/components/workflow/block-selector/use-sticky-scroll.ts +++ b/web/app/components/workflow/block-selector/use-sticky-scroll.ts @@ -11,30 +11,27 @@ type Params = { wrapElemRef: React.RefObject<HTMLElement | null> nextToStickyELemRef: React.RefObject<HTMLElement | null> } -const useStickyScroll = ({ - wrapElemRef, - nextToStickyELemRef, -}: Params) => { - const [scrollPosition, setScrollPosition] = React.useState<ScrollPosition>(ScrollPosition.belowTheWrap) - const { run: handleScroll } = useThrottleFn(() => { - const wrapDom = wrapElemRef.current - const stickyDOM = nextToStickyELemRef.current - if (!wrapDom || !stickyDOM) - return - const { height: wrapHeight, top: wrapTop } = wrapDom.getBoundingClientRect() - const { top: nextToStickyTop } = stickyDOM.getBoundingClientRect() - let scrollPositionNew: ScrollPosition +const useStickyScroll = ({ wrapElemRef, nextToStickyELemRef }: Params) => { + const [scrollPosition, setScrollPosition] = React.useState<ScrollPosition>( + ScrollPosition.belowTheWrap, + ) + const { run: handleScroll } = useThrottleFn( + () => { + const wrapDom = wrapElemRef.current + const stickyDOM = nextToStickyELemRef.current + if (!wrapDom || !stickyDOM) return + const { height: wrapHeight, top: wrapTop } = wrapDom.getBoundingClientRect() + const { top: nextToStickyTop } = stickyDOM.getBoundingClientRect() + let scrollPositionNew: ScrollPosition - if (nextToStickyTop - wrapTop >= wrapHeight) - scrollPositionNew = ScrollPosition.belowTheWrap - else if (nextToStickyTop <= wrapTop) - scrollPositionNew = ScrollPosition.aboveTheWrap - else - scrollPositionNew = ScrollPosition.showing + if (nextToStickyTop - wrapTop >= wrapHeight) scrollPositionNew = ScrollPosition.belowTheWrap + else if (nextToStickyTop <= wrapTop) scrollPositionNew = ScrollPosition.aboveTheWrap + else scrollPositionNew = ScrollPosition.showing - if (scrollPosition !== scrollPositionNew) - setScrollPosition(scrollPositionNew) - }, { wait: 100 }) + if (scrollPosition !== scrollPositionNew) setScrollPosition(scrollPositionNew) + }, + { wait: 100 }, + ) return { handleScroll, diff --git a/web/app/components/workflow/block-selector/view-type-select.tsx b/web/app/components/workflow/block-selector/view-type-select.tsx index bf9bcb76474b96..874c938ea6c843 100644 --- a/web/app/components/workflow/block-selector/view-type-select.tsx +++ b/web/app/components/workflow/block-selector/view-type-select.tsx @@ -15,36 +15,37 @@ type Props = Readonly<{ onChange: (viewType: ViewType) => void }> -const ViewTypeSelect: FC<Props> = ({ - viewType, - onChange, -}) => { - const handleChange = useCallback((nextViewType: ViewType) => { - return () => { - if (nextViewType === viewType) - return - onChange(nextViewType) - } - }, [viewType, onChange]) +const ViewTypeSelect: FC<Props> = ({ viewType, onChange }) => { + const handleChange = useCallback( + (nextViewType: ViewType) => { + return () => { + if (nextViewType === viewType) return + onChange(nextViewType) + } + }, + [viewType, onChange], + ) return ( <div className="flex items-center rounded-lg bg-components-segmented-control-bg-normal p-px"> <div - className={ - cn('rounded-lg p-[3px]', viewType === ViewType.flat + className={cn( + 'rounded-lg p-[3px]', + viewType === ViewType.flat ? 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-xs' - : 'cursor-pointer text-text-tertiary') - } + : 'cursor-pointer text-text-tertiary', + )} onClick={handleChange(ViewType.flat)} > <RiSortAlphabetAsc className="size-4" /> </div> <div - className={ - cn('rounded-lg p-[3px]', viewType === ViewType.tree + className={cn( + 'rounded-lg p-[3px]', + viewType === ViewType.tree ? 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-xs' - : 'cursor-pointer text-text-tertiary') - } + : 'cursor-pointer text-text-tertiary', + )} onClick={handleChange(ViewType.tree)} > <RiNodeTree className="size-4" /> diff --git a/web/app/components/workflow/candidate-node-main.tsx b/web/app/components/workflow/candidate-node-main.tsx index 968fefc9721d83..2d4af22960c522 100644 --- a/web/app/components/workflow/candidate-node-main.tsx +++ b/web/app/components/workflow/candidate-node-main.tsx @@ -1,43 +1,34 @@ -import type { - ComponentProps, - FC, -} from 'react' -import type { - Node, -} from '@/app/components/workflow/types' +import type { ComponentProps, FC } from 'react' +import type { Node } from '@/app/components/workflow/types' import { useEventListener } from 'ahooks' import { produce } from 'immer' -import { - memo, -} from 'react' -import { - useReactFlow, - useViewport, -} from 'reactflow' +import { memo } from 'react' +import { useReactFlow, useViewport } from 'reactflow' import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' import { CUSTOM_NODE } from './constants' -import { useAutoGenerateWebhookUrl, useNodesInteractions, useNodesSyncDraft, useWorkflowHistory, WorkflowHistoryEvent } from './hooks' +import { + useAutoGenerateWebhookUrl, + useNodesInteractions, + useNodesSyncDraft, + useWorkflowHistory, + WorkflowHistoryEvent, +} from './hooks' import CustomNode from './nodes' import { useCreateInlineAgentBinding } from './nodes/agent-v2/hooks' import { isAgentV2NodeData, needsInlineAgentBindingCreation } from './nodes/agent-v2/types' import CustomNoteNode from './note-node' import { CUSTOM_NOTE_NODE } from './note-node/constants' -import { - useStore, - useWorkflowStore, -} from './store' +import { useStore, useWorkflowStore } from './store' import { BlockEnum } from './types' import { getIterationStartNode, getLoopStartNode } from './utils' type Props = Readonly<{ candidateNode: Node }> -const CandidateNodeMain: FC<Props> = ({ - candidateNode, -}) => { +const CandidateNodeMain: FC<Props> = ({ candidateNode }) => { const reactflow = useReactFlow() const workflowStore = useWorkflowStore() - const mousePosition = useStore(s => s.mousePosition) + const mousePosition = useStore((s) => s.mousePosition) const { zoom } = useViewport() const { handleNodeSelect } = useNodesInteractions() const { saveStateToHistory } = useWorkflowHistory() @@ -51,7 +42,8 @@ const CandidateNodeMain: FC<Props> = ({ const { screenToFlowPosition } = reactflow const { nodes, setNodes } = collaborativeWorkflow.getState() const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY }) - const shouldCreateInlineAgentBinding = isAgentV2NodeData(candidateNode.data) && needsInlineAgentBindingCreation(candidateNode.data) + const shouldCreateInlineAgentBinding = + isAgentV2NodeData(candidateNode.data) && needsInlineAgentBindingCreation(candidateNode.data) const newNodes = produce(nodes, (draft) => { if (shouldCreateInlineAgentBinding) { draft.forEach((node) => { @@ -74,19 +66,16 @@ const CandidateNodeMain: FC<Props> = ({ if (candidateNode.data.type === BlockEnum.Iteration) draft.push(getIterationStartNode(candidateNode.id)) - if (candidateNode.data.type === BlockEnum.Loop) - draft.push(getLoopStartNode(candidateNode.id)) + if (candidateNode.data.type === BlockEnum.Loop) draft.push(getLoopStartNode(candidateNode.id)) }) setNodes(newNodes) if (candidateNode.type === CUSTOM_NOTE_NODE) saveStateToHistory(WorkflowHistoryEvent.NoteAdd, { nodeId: candidateNode.id }) - else - saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: candidateNode.id }) + else saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: candidateNode.id }) workflowStore.setState({ candidateNode: undefined }) - if (candidateNode.type === CUSTOM_NOTE_NODE) - handleNodeSelect(candidateNode.id) + if (candidateNode.type === CUSTOM_NOTE_NODE) handleNodeSelect(candidateNode.id) if (candidateNode.data.type === BlockEnum.TriggerWebhook) { handleSyncWorkflowDraft(true, true, { @@ -100,27 +89,28 @@ const CandidateNodeMain: FC<Props> = ({ createInlineAgentBinding(candidateNode.id, { onError: () => { const { nodes, setNodes } = collaborativeWorkflow.getState() - setNodes(nodes.filter(node => node.id !== candidateNode.id)) + setNodes(nodes.filter((node) => node.id !== candidateNode.id)) }, onSuccess: (binding) => { const { nodes, setNodes } = collaborativeWorkflow.getState() - setNodes(produce(nodes, (draft) => { - const node = draft.find(node => node.id === candidateNode.id) - if (node) { - if (isAgentV2NodeData(node.data) && needsInlineAgentBindingCreation(node.data)) - node.data.agent_binding = binding - node.data._openInlineAgentPanel = true - delete node.data._isTempNode - } - })) + setNodes( + produce(nodes, (draft) => { + const node = draft.find((node) => node.id === candidateNode.id) + if (node) { + if (isAgentV2NodeData(node.data) && needsInlineAgentBindingCreation(node.data)) + node.data.agent_binding = binding + node.data._openInlineAgentPanel = true + delete node.data._isTempNode + } + }), + ) handleSyncWorkflowDraft(true, true) }, }) return } - if (isAgentV2NodeData(candidateNode.data)) - handleSyncWorkflowDraft(true, true) + if (isAgentV2NodeData(candidateNode.data)) handleSyncWorkflowDraft(true, true) }) useEventListener('contextmenu', (e) => { @@ -138,16 +128,12 @@ const CandidateNodeMain: FC<Props> = ({ transformOrigin: '0 0', }} > - { - candidateNode.type === CUSTOM_NODE && ( - <CustomNode {...candidateNode as unknown as ComponentProps<typeof CustomNode>} /> - ) - } - { - candidateNode.type === CUSTOM_NOTE_NODE && ( - <CustomNoteNode {...candidateNode as unknown as ComponentProps<typeof CustomNoteNode>} /> - ) - } + {candidateNode.type === CUSTOM_NODE && ( + <CustomNode {...(candidateNode as unknown as ComponentProps<typeof CustomNode>)} /> + )} + {candidateNode.type === CUSTOM_NOTE_NODE && ( + <CustomNoteNode {...(candidateNode as unknown as ComponentProps<typeof CustomNoteNode>)} /> + )} </div> ) } diff --git a/web/app/components/workflow/candidate-node.tsx b/web/app/components/workflow/candidate-node.tsx index 2c61f0f8ade604..1338fa4aa31909 100644 --- a/web/app/components/workflow/candidate-node.tsx +++ b/web/app/components/workflow/candidate-node.tsx @@ -1,20 +1,12 @@ -import { - memo, -} from 'react' - +import { memo } from 'react' import CandidateNodeMain from './candidate-node-main' -import { - useStore, -} from './store' +import { useStore } from './store' const CandidateNode = () => { - const candidateNode = useStore(s => s.candidateNode) - if (!candidateNode) - return null + const candidateNode = useStore((s) => s.candidateNode) + if (!candidateNode) return null - return ( - <CandidateNodeMain candidateNode={candidateNode} /> - ) + return <CandidateNodeMain candidateNode={candidateNode} /> } export default memo(CandidateNode) diff --git a/web/app/components/workflow/collaboration/components/user-cursors.tsx b/web/app/components/workflow/collaboration/components/user-cursors.tsx index 8170ca1c1fad36..049f016b62bc79 100644 --- a/web/app/components/workflow/collaboration/components/user-cursors.tsx +++ b/web/app/components/workflow/collaboration/components/user-cursors.tsx @@ -1,5 +1,8 @@ import type { FC } from 'react' -import type { CursorPosition, OnlineUser } from '@/app/components/workflow/collaboration/types/collaboration' +import type { + CursorPosition, + OnlineUser, +} from '@/app/components/workflow/collaboration/types/collaboration' import { useViewport } from 'reactflow' import { getUserColor } from '../utils/user-color' @@ -9,11 +12,7 @@ type UserCursorsProps = { onlineUsers: OnlineUser[] } -const UserCursors: FC<UserCursorsProps> = ({ - cursors, - myUserId, - onlineUsers, -}) => { +const UserCursors: FC<UserCursorsProps> = ({ cursors, myUserId, onlineUsers }) => { const viewport = useViewport() const convertToScreenCoordinates = (cursor: CursorPosition) => { @@ -26,10 +25,9 @@ const UserCursors: FC<UserCursorsProps> = ({ return ( <> {Object.entries(cursors || {}).map(([userId, cursor]) => { - if (userId === myUserId) - return null + if (userId === myUserId) return null - const userInfo = onlineUsers.find(user => user.user_id === userId) + const userInfo = onlineUsers.find((user) => user.user_id === userId) const userName = userInfo?.username || `User ${userId.slice(-4)}` const userColor = getUserColor(userId) const screenPos = convertToScreenCoordinates(cursor) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts index e56aa1d82c85a4..be525ef0a69abf 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts @@ -92,20 +92,22 @@ describe('CollaborationManager logs and event helpers', () => { internals.reactFlowStore = { getState: () => ({ - getNodes: () => [{ - ...node, - data: { - ...node.data, - selected: true, + getNodes: () => [ + { + ...node, + data: { + ...node.data, + selected: true, + }, }, - }], + ], setNodes: vi.fn(), getEdges: () => [edge], setEdges: vi.fn(), }), } - const graphPayloads: Array<{ nodes: Node[], edges: Edge[] }> = [] + const graphPayloads: Array<{ nodes: Node[]; edges: Edge[] }> = [] manager.onGraphImport((graph) => { graphPayloads.push(graph) }) @@ -114,8 +116,7 @@ describe('CollaborationManager logs and event helpers', () => { expect(graphPayloads).toHaveLength(1) const payload = graphPayloads[0] - if (!payload) - throw new Error('graph import payload should exist') + if (!payload) throw new Error('graph import payload should exist') expect(payload.nodes).toHaveLength(1) expect(payload.edges).toHaveLength(1) expect(payload.nodes[0]?.data.selected).toBe(true) @@ -163,8 +164,13 @@ describe('CollaborationManager logs and event helpers', () => { manager.setNodes(oldNodes, nextNodes, 'test:partial-note-update') - expect(manager.getNodes().map(node => node.id).sort()).toEqual(['n-note', 'n-start']) - expect(manager.getEdges().map(currentEdge => currentEdge.id)).toEqual(['e-start-note']) + expect( + manager + .getNodes() + .map((node) => node.id) + .sort(), + ).toEqual(['n-note', 'n-start']) + expect(manager.getEdges().map((currentEdge) => currentEdge.id)).toEqual(['e-start-note']) }) it('clearGraphImportLog clears logs and pending import snapshot', () => { @@ -213,11 +219,12 @@ describe('CollaborationManager logs and event helpers', () => { const anchor = document.createElement('a') const clickSpy = vi.spyOn(anchor, 'click').mockImplementation(() => {}) const originalCreateElement = document.createElement.bind(document) - const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName: string): HTMLElement => { - if (tagName === 'a') - return anchor - return originalCreateElement(tagName) - }) + const createElementSpy = vi + .spyOn(document, 'createElement') + .mockImplementation((tagName: string): HTMLElement => { + if (tagName === 'a') return anchor + return originalCreateElement(tagName) + }) manager.downloadGraphImportLog() @@ -236,8 +243,8 @@ describe('CollaborationManager logs and event helpers', () => { setNodesAnomalyCount: number syncDiagnosticCount: number onlineUsersCount: number - crdtCounts: { nodes: number, edges: number } - reactFlowCounts: { nodes: number, edges: number } + crdtCounts: { nodes: number; edges: number } + reactFlowCounts: { nodes: number; edges: number } } } @@ -255,10 +262,12 @@ describe('CollaborationManager logs and event helpers', () => { it('emits collaboration events only when current app is connected', () => { const { manager, internals } = setupManagerWithDoc() - const sendSpy = vi.spyOn( - manager as unknown as { sendCollaborationEvent: (payload: unknown) => void }, - 'sendCollaborationEvent', - ).mockImplementation(() => {}) + const sendSpy = vi + .spyOn( + manager as unknown as { sendCollaborationEvent: (payload: unknown) => void }, + 'sendCollaborationEvent', + ) + .mockImplementation(() => {}) const isConnectedSpy = vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(false) manager.emitCommentsUpdate('app-1') @@ -288,9 +297,7 @@ describe('CollaborationManager logs and event helpers', () => { }) manager.emitRestoreComplete({ versionId: 'version-2', success: false, error: 'failed' }) - const eventTypes = sendSpy.mock.calls.map(call => ( - (call[0] as { type: string }).type - )) + const eventTypes = sendSpy.mock.calls.map((call) => (call[0] as { type: string }).type) expect(eventTypes).toEqual([ 'comments_update', 'workflow_history_action', @@ -335,12 +342,14 @@ describe('CollaborationManager logs and event helpers', () => { } internals.undoManager = undoManager - const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { - callback(0) - return 1 - }) + const rafSpy = vi + .spyOn(globalThis, 'requestAnimationFrame') + .mockImplementation((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) - const historyStates: Array<{ canUndo: boolean, canRedo: boolean }> = [] + const historyStates: Array<{ canUndo: boolean; canRedo: boolean }> = [] manager.onUndoRedoStateChange((state) => { historyStates.push(state) }) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts index 13688517839b24..adc55768173252 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts @@ -88,7 +88,7 @@ const createNode = (variables: string[]): Node<StartNodeData> => ({ type: BlockEnum.Start, title: 'Start', desc: '', - variables: variables.map(name => ({ + variables: variables.map((name) => ({ variable: name, label: name, type: 'text-input', @@ -130,7 +130,9 @@ const createLLMNode = (templates: PromptTemplateItem[]): Node<LLMNodeData> => ({ }, }) -const createParameterExtractorNode = (parameters: ParameterItem[]): Node<ParameterExtractorNodeData> => ({ +const createParameterExtractorNode = ( + parameters: ParameterItem[], +): Node<ParameterExtractorNodeData> => ({ id: PARAM_NODE_ID, type: 'custom', position: { x: 400, y: 120 }, @@ -228,7 +230,11 @@ describe('Loro merge behavior smoke test', () => { text: 'hello from docA', }, ] - syncNodes(managerA, [createLLMNode(deepClone(baseTemplate))], [createLLMNode(deepClone(additionTemplate))]) + syncNodes( + managerA, + [createLLMNode(deepClone(baseTemplate))], + [createLLMNode(deepClone(additionTemplate))], + ) const editedTemplate = [ { @@ -237,7 +243,11 @@ describe('Loro merge behavior smoke test', () => { text: 'updated by docB', }, ] - syncNodes(managerB, [createLLMNode(deepClone(baseTemplate))], [createLLMNode(deepClone(editedTemplate))]) + syncNodes( + managerB, + [createLLMNode(deepClone(baseTemplate))], + [createLLMNode(deepClone(editedTemplate))], + ) const updateForA = docB.export({ mode: 'update', from: docA.version() }) docA.import(updateForA) @@ -245,8 +255,12 @@ describe('Loro merge behavior smoke test', () => { const updateForB = docA.export({ mode: 'update', from: docB.version() }) docB.import(updateForB) - const finalA = exportNodes(managerA).find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined - const finalB = exportNodes(managerB).find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined + const finalA = exportNodes(managerA).find((node) => node.id === LLM_NODE_ID) as + | Node<LLMNodeData> + | undefined + const finalB = exportNodes(managerB).find((node) => node.id === LLM_NODE_ID) as + | Node<LLMNodeData> + | undefined expect(finalA).toBeDefined() expect(finalB).toBeDefined() @@ -309,10 +323,10 @@ describe('Loro merge behavior smoke test', () => { const updateForB = docA.export({ mode: 'update', from: docB.version() }) docB.import(updateForB) - const finalA = exportNodes(managerA).find(node => node.id === PARAM_NODE_ID) as + const finalA = exportNodes(managerA).find((node) => node.id === PARAM_NODE_ID) as | Node<ParameterExtractorNodeData> | undefined - const finalB = exportNodes(managerB).find(node => node.id === PARAM_NODE_ID) as + const finalB = exportNodes(managerB).find((node) => node.id === PARAM_NODE_ID) as | Node<ParameterExtractorNodeData> | undefined diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts index 63487432dcbeac..e7129765b9ebcf 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts @@ -61,7 +61,7 @@ type CollaborationManagerInternals = { rejoinInProgress: boolean onlineUsers: OnlineUser[] nodePanelPresence: NodePanelPresenceMap - cursors: Record<string, { x: number, y: number, userId: string, timestamp: number }> + cursors: Record<string, { x: number; y: number; userId: string; timestamp: number }> graphSyncDiagnostics: unknown[] setNodesAnomalyLogs: unknown[] handleSessionUnauthorized: () => void @@ -74,7 +74,15 @@ type CollaborationManagerInternals = { requestInitialSyncIfNeeded: () => void cleanupNodePanelPresence: (activeClientIds: Set<string>) => void recordGraphSyncDiagnostic: ( - stage: 'nodes_subscribe' | 'edges_subscribe' | 'nodes_import_apply' | 'edges_import_apply' | 'schedule_graph_import_emit' | 'graph_import_emit' | 'start_import_log' | 'finalize_import_log', + stage: + | 'nodes_subscribe' + | 'edges_subscribe' + | 'nodes_import_apply' + | 'edges_import_apply' + | 'schedule_graph_import_emit' + | 'graph_import_emit' + | 'start_import_log' + | 'finalize_import_log', status: 'triggered' | 'skipped' | 'applied' | 'queued' | 'emitted' | 'snapshot', reason?: string, details?: Record<string, unknown>, @@ -120,8 +128,7 @@ const createMockSocket = (id = 'socket-1'): MockSocket => { off: vi.fn(), trigger: (event: string, ...args: unknown[]) => { const handler = handlers.get(event) - if (handler) - handler(...args) + if (handler) handler(...args) }, } } @@ -154,8 +161,14 @@ describe('CollaborationManager socket and subscription behavior', () => { manager.emitWorkflowUpdate('wf-1') expect(socket.emit).toHaveBeenCalledTimes(3) - const payloads = socket.emit.mock.calls.map(call => call[1] as { type: string, data: Record<string, unknown> }) - expect(payloads.map(item => item.type)).toEqual(['mouse_move', 'sync_request', 'workflow_update']) + const payloads = socket.emit.mock.calls.map( + (call) => call[1] as { type: string; data: Record<string, unknown> }, + ) + expect(payloads.map((item) => item.type)).toEqual([ + 'mouse_move', + 'sync_request', + 'workflow_update', + ]) expect(payloads[0]?.data).toMatchObject({ x: 11, y: 22 }) expect(payloads[2]?.data).toMatchObject({ appId: 'wf-1' }) }) @@ -163,8 +176,12 @@ describe('CollaborationManager socket and subscription behavior', () => { it('tries to rejoin on unauthorized and forces disconnect on unauthorized ack', () => { const { internals } = setupManagerWithDoc() const socket = createMockSocket('socket-rejoin') - const getSocketSpy = vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(socket as unknown as Socket) - const forceDisconnectSpy = vi.spyOn(internals, 'forceDisconnect').mockImplementation(() => undefined) + const getSocketSpy = vi + .spyOn(webSocketClient, 'getSocket') + .mockReturnValue(socket as unknown as Socket) + const forceDisconnectSpy = vi + .spyOn(internals, 'forceDisconnect') + .mockImplementation(() => undefined) internals.currentAppId = 'app-rejoin' internals.rejoinInProgress = true @@ -192,7 +209,9 @@ describe('CollaborationManager socket and subscription behavior', () => { const { manager, internals } = setupManagerWithDoc() const socket = createMockSocket('socket-events') - const broadcastSpy = vi.spyOn(internals, 'broadcastCurrentGraph').mockImplementation(() => undefined) + const broadcastSpy = vi + .spyOn(internals, 'broadcastCurrentGraph') + .mockImplementation(() => undefined) internals.isLeader = true internals.setupSocketEventListeners(socket as unknown as Socket) @@ -297,7 +316,11 @@ describe('CollaborationManager socket and subscription behavior', () => { socket.trigger('collaboration_update', { ...baseUpdate, type: 'workflow_restore_intent', - data: { versionId: 'v1', initiatorUserId: 'u-1', initiatorName: 'Alice' } as unknown as Record<string, unknown>, + data: { + versionId: 'v1', + initiatorUserId: 'u-1', + initiatorName: 'Alice', + } as unknown as Record<string, unknown>, } satisfies CollaborationUpdate) socket.trigger('collaboration_update', { ...baseUpdate, @@ -328,8 +351,15 @@ describe('CollaborationManager socket and subscription behavior', () => { expect(latestPresence).toMatchObject({ 'n-1': { 'socket-events': { userId: 'u-1' } } }) expect(syncRequestHandler).toHaveBeenCalledTimes(1) expect(broadcastSpy).toHaveBeenCalledTimes(1) - expect(restoreIntentHandler).toHaveBeenCalledWith({ versionId: 'v1', initiatorUserId: 'u-1', initiatorName: 'Alice' } satisfies RestoreIntentData) - expect(restoreCompleteHandler).toHaveBeenCalledWith({ versionId: 'v1', success: true } satisfies RestoreCompleteData) + expect(restoreIntentHandler).toHaveBeenCalledWith({ + versionId: 'v1', + initiatorUserId: 'u-1', + initiatorName: 'Alice', + } satisfies RestoreIntentData) + expect(restoreCompleteHandler).toHaveBeenCalledWith({ + versionId: 'v1', + success: true, + } satisfies RestoreCompleteData) expect(historyHandler).toHaveBeenCalledWith({ action: 'undo', userId: 'u-1' }) }) @@ -338,7 +368,9 @@ describe('CollaborationManager socket and subscription behavior', () => { const socket = createMockSocket('socket-state') const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const emitGraphResyncRequestSpy = vi.spyOn(internals, 'emitGraphResyncRequest').mockImplementation(() => undefined) + const emitGraphResyncRequestSpy = vi + .spyOn(internals, 'emitGraphResyncRequest') + .mockImplementation(() => undefined) internals.cursors = { stale: { @@ -363,32 +395,39 @@ describe('CollaborationManager socket and subscription behavior', () => { const onlineUsersHandler = vi.fn() const leaderChangeHandler = vi.fn() - const stateChanges: Array<{ isConnected: boolean, disconnectReason?: string, error?: string }> = [] + const stateChanges: Array<{ isConnected: boolean; disconnectReason?: string; error?: string }> = + [] manager.onOnlineUsersUpdate(onlineUsersHandler) manager.onLeaderChange(leaderChangeHandler) manager.onStateChange((state) => { - stateChanges.push(state as { isConnected: boolean, disconnectReason?: string, error?: string }) + stateChanges.push( + state as { isConnected: boolean; disconnectReason?: string; error?: string }, + ) }) socket.trigger('online_users', { users: 'invalid-structure' }) expect(warnSpy).toHaveBeenCalled() socket.trigger('online_users', { - users: [{ + users: [ + { + user_id: 'online-user', + username: 'Alice', + avatar: '', + sid: 'socket-state', + }, + ], + leader: 'leader-1', + }) + + expect(onlineUsersHandler).toHaveBeenCalledWith([ + { user_id: 'online-user', username: 'Alice', avatar: '', sid: 'socket-state', - }], - leader: 'leader-1', - }) - - expect(onlineUsersHandler).toHaveBeenCalledWith([{ - user_id: 'online-user', - username: 'Alice', - avatar: '', - sid: 'socket-state', - } satisfies OnlineUser]) + } satisfies OnlineUser, + ]) expect(internals.cursors).toEqual({}) expect(internals.nodePanelPresence).toEqual({}) expect(internals.leaderId).toBe('leader-1') @@ -447,12 +486,14 @@ describe('CollaborationManager socket and subscription behavior', () => { internals.setupSocketEventListeners(socket as unknown as Socket) socket.trigger('online_users', { - users: [{ - user_id: 'u-1', - username: 'Alice', - avatar: '', - sid: 'socket-tab-b', - }], + users: [ + { + user_id: 'u-1', + username: 'Alice', + avatar: '', + sid: 'socket-tab-b', + }, + ], }) expect(internals.nodePanelPresence).toEqual({ @@ -479,10 +520,12 @@ describe('CollaborationManager socket and subscription behavior', () => { it('setupSubscriptions applies import updates and emits merged graph payload', () => { const { manager, internals } = setupManagerWithDoc() - const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { - callback(0) - return 1 - }) + const rafSpy = vi + .spyOn(globalThis, 'requestAnimationFrame') + .mockImplementation((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) const initialNode = { ...createNode('n-1', 'Initial'), data: { @@ -503,14 +546,16 @@ describe('CollaborationManager socket and subscription behavior', () => { manager.setEdges([], [edge]) manager.setNodes([initialNode], [remoteNode]) - let reactFlowNodes: Node[] = [{ - ...initialNode, - data: ({ - ...initialNode.data, - selected: true, - _localMeta: 'keep-me', - } as Node['data'] & Record<string, unknown>), - }] + let reactFlowNodes: Node[] = [ + { + ...initialNode, + data: { + ...initialNode.data, + selected: true, + _localMeta: 'keep-me', + } as Node['data'] & Record<string, unknown>, + }, + ] let reactFlowEdges: Edge[] = [edge] const setNodesSpy = vi.fn((nodes: Node[]) => { reactFlowNodes = nodes @@ -529,16 +574,24 @@ describe('CollaborationManager socket and subscription behavior', () => { let nodesSubscribeHandler: (event: LoroSubscribeEvent) => void = () => {} let edgesSubscribeHandler: (event: LoroSubscribeEvent) => void = () => {} - vi.spyOn(internals.nodesMap as object as { subscribe: (handler: (event: LoroSubscribeEvent) => void) => void }, 'subscribe') - .mockImplementation((handler: (event: LoroSubscribeEvent) => void) => { - nodesSubscribeHandler = handler - }) - vi.spyOn(internals.edgesMap as object as { subscribe: (handler: (event: LoroSubscribeEvent) => void) => void }, 'subscribe') - .mockImplementation((handler: (event: LoroSubscribeEvent) => void) => { - edgesSubscribeHandler = handler - }) + vi.spyOn( + internals.nodesMap as object as { + subscribe: (handler: (event: LoroSubscribeEvent) => void) => void + }, + 'subscribe', + ).mockImplementation((handler: (event: LoroSubscribeEvent) => void) => { + nodesSubscribeHandler = handler + }) + vi.spyOn( + internals.edgesMap as object as { + subscribe: (handler: (event: LoroSubscribeEvent) => void) => void + }, + 'subscribe', + ).mockImplementation((handler: (event: LoroSubscribeEvent) => void) => { + edgesSubscribeHandler = handler + }) - const importedGraphs: Array<{ nodes: Node[], edges: Edge[] }> = [] + const importedGraphs: Array<{ nodes: Node[]; edges: Edge[] }> = [] manager.onGraphImport((payload) => { importedGraphs.push(payload) }) @@ -552,8 +605,7 @@ describe('CollaborationManager socket and subscription behavior', () => { expect(setEdgesSpy).toHaveBeenCalled() expect(importedGraphs.length).toBeGreaterThan(0) const importedGraph = importedGraphs.at(-1) - if (!importedGraph) - throw new Error('imported graph should exist') + if (!importedGraph) throw new Error('imported graph should exist') expect(importedGraph.nodes[0]?.data).toMatchObject({ title: 'RemoteTitle', selected: true, @@ -593,10 +645,12 @@ describe('CollaborationManager socket and subscription behavior', () => { it('guards graph resync emission and graph snapshot broadcast', () => { const { manager, internals } = setupManagerWithDoc() const socket = createMockSocket('socket-resync') - const sendGraphEventSpy = vi.spyOn( - manager as unknown as { sendGraphEvent: (payload: Uint8Array) => void }, - 'sendGraphEvent', - ).mockImplementation(() => undefined) + const sendGraphEventSpy = vi + .spyOn( + manager as unknown as { sendGraphEvent: (payload: Uint8Array) => void }, + 'sendGraphEvent', + ) + .mockImplementation(() => undefined) internals.currentAppId = null vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(false) @@ -633,7 +687,9 @@ describe('CollaborationManager socket and subscription behavior', () => { const { manager, internals } = setupManagerWithDoc() const socket = createMockSocket('socket-connect') const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const disconnectSpy = vi.spyOn(webSocketClient, 'disconnect').mockImplementation(() => undefined) + const disconnectSpy = vi + .spyOn(webSocketClient, 'disconnect') + .mockImplementation(() => undefined) vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(socket as unknown as Socket) vi.spyOn(webSocketClient, 'connect').mockReturnValue(socket as unknown as Socket) @@ -677,24 +733,27 @@ describe('CollaborationManager socket and subscription behavior', () => { it('covers setNodes/setEdges guards and destroy delegation', () => { const { manager, internals } = setupManagerWithDoc() - const destroyDisconnectSpy = vi.spyOn( - manager as unknown as { disconnect: () => void }, - 'disconnect', - ).mockImplementation(() => undefined) + const destroyDisconnectSpy = vi + .spyOn(manager as unknown as { disconnect: () => void }, 'disconnect') + .mockImplementation(() => undefined) manager.setNodes([], [createNode('n-guard')]) manager.setEdges([], [createEdge('e-guard', 'n-a', 'n-b')]) const commitSpy = vi.fn() internals.doc = { commit: commitSpy } as unknown as LoroDoc - const syncNodesSpy = vi.spyOn( - internals as unknown as { syncNodes: (oldNodes: Node[], newNodes: Node[]) => void }, - 'syncNodes', - ).mockImplementation(() => undefined) - const syncEdgesSpy = vi.spyOn( - internals as unknown as { syncEdges: (oldEdges: Edge[], newEdges: Edge[]) => void }, - 'syncEdges', - ).mockImplementation(() => undefined) + const syncNodesSpy = vi + .spyOn( + internals as unknown as { syncNodes: (oldNodes: Node[], newNodes: Node[]) => void }, + 'syncNodes', + ) + .mockImplementation(() => undefined) + const syncEdgesSpy = vi + .spyOn( + internals as unknown as { syncEdges: (oldEdges: Edge[], newEdges: Edge[]) => void }, + 'syncEdges', + ) + .mockImplementation(() => undefined) internals.isUndoRedoInProgress = true manager.setNodes([], [createNode('n-skip')]) @@ -715,10 +774,12 @@ describe('CollaborationManager socket and subscription behavior', () => { it('covers emit guards and node panel presence local updates', () => { const { manager, internals } = setupManagerWithDoc() const socket = createMockSocket('socket-presence') - const sendSpy = vi.spyOn( - manager as unknown as { sendCollaborationEvent: (payload: unknown) => void }, - 'sendCollaborationEvent', - ).mockImplementation(() => undefined) + const sendSpy = vi + .spyOn( + manager as unknown as { sendCollaborationEvent: (payload: unknown) => void }, + 'sendCollaborationEvent', + ) + .mockImplementation(() => undefined) const isConnectedSpy = vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(false) const getSocketSpy = vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(null) @@ -768,7 +829,7 @@ describe('CollaborationManager socket and subscription behavior', () => { const helperInternals = internals as unknown as { mergeLocalNodeState: (nodes: Node[]) => Node[] - snapshotReactFlowGraph: () => { nodes: Node[], edges: Edge[] } + snapshotReactFlowGraph: () => { nodes: Node[]; edges: Edge[] } startImportLog: (source: 'nodes' | 'edges') => void finalizeImportLog: () => void } @@ -806,23 +867,29 @@ describe('CollaborationManager socket and subscription behavior', () => { internals.setupSocketEventListeners(socket as unknown as Socket) socket.trigger('online_users', { - users: [{ - user_id: 'u-1', - username: 'Alice', - avatar: '', - sid: 'socket-catch', - }], + users: [ + { + user_id: 'u-1', + username: 'Alice', + avatar: '', + sid: 'socket-catch', + }, + ], }) expect(cleanupSpy).toHaveBeenCalled() - const requestSyncSpy = vi.spyOn(internals, 'requestInitialSyncIfNeeded').mockImplementationOnce(() => { - throw new Error('status-failed') - }) + const requestSyncSpy = vi + .spyOn(internals, 'requestInitialSyncIfNeeded') + .mockImplementationOnce(() => { + throw new Error('status-failed') + }) socket.trigger('status', { isLeader: false }) expect(requestSyncSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled() - const resyncSpy = vi.spyOn(internals, 'emitGraphResyncRequest').mockImplementation(() => undefined) + const resyncSpy = vi + .spyOn(internals, 'emitGraphResyncRequest') + .mockImplementation(() => undefined) internals.pendingInitialSync = true internals.isLeader = true internals.requestInitialSyncIfNeeded() @@ -833,10 +900,12 @@ describe('CollaborationManager socket and subscription behavior', () => { it('covers graph broadcast guard and error path', () => { const { manager, internals } = setupManagerWithDoc() const socket = createMockSocket('socket-broadcast') - const sendGraphEventSpy = vi.spyOn( - manager as unknown as { sendGraphEvent: (payload: Uint8Array) => void }, - 'sendGraphEvent', - ).mockImplementation(() => undefined) + const sendGraphEventSpy = vi + .spyOn( + manager as unknown as { sendGraphEvent: (payload: Uint8Array) => void }, + 'sendGraphEvent', + ) + .mockImplementation(() => undefined) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) internals.currentAppId = 'app-broadcast' @@ -931,7 +1000,9 @@ describe('CollaborationManager socket and subscription behavior', () => { expect(noLocalState[0]?.id).toBe('no-local') internals.pendingInitialSync = false - const resyncSpy = vi.spyOn(internals, 'emitGraphResyncRequest').mockImplementation(() => undefined) + const resyncSpy = vi + .spyOn(internals, 'emitGraphResyncRequest') + .mockImplementation(() => undefined) privateInternals.requestInitialSyncIfNeeded() expect(resyncSpy).not.toHaveBeenCalled() @@ -1010,14 +1081,22 @@ describe('CollaborationManager socket and subscription behavior', () => { let nodesHandler: (event: LoroSubscribeEvent) => void = () => {} let edgesHandler: (event: LoroSubscribeEvent) => void = () => {} - vi.spyOn(internals.nodesMap as object as { subscribe: (handler: (event: LoroSubscribeEvent) => void) => void }, 'subscribe') - .mockImplementation((handler: (event: LoroSubscribeEvent) => void) => { - nodesHandler = handler - }) - vi.spyOn(internals.edgesMap as object as { subscribe: (handler: (event: LoroSubscribeEvent) => void) => void }, 'subscribe') - .mockImplementation((handler: (event: LoroSubscribeEvent) => void) => { - edgesHandler = handler - }) + vi.spyOn( + internals.nodesMap as object as { + subscribe: (handler: (event: LoroSubscribeEvent) => void) => void + }, + 'subscribe', + ).mockImplementation((handler: (event: LoroSubscribeEvent) => void) => { + nodesHandler = handler + }) + vi.spyOn( + internals.edgesMap as object as { + subscribe: (handler: (event: LoroSubscribeEvent) => void) => void + }, + 'subscribe', + ).mockImplementation((handler: (event: LoroSubscribeEvent) => void) => { + edgesHandler = handler + }) internals.setupSubscriptions() internals.isUndoRedoInProgress = true @@ -1049,10 +1128,12 @@ describe('CollaborationManager socket and subscription behavior', () => { const manager = new CollaborationManager() const internals = getManagerInternals(manager) const socket = createMockSocket('socket-undo-pop') - const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { - callback(0) - return 1 - }) + const rafSpy = vi + .spyOn(globalThis, 'requestAnimationFrame') + .mockImplementation((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) vi.useFakeTimers() vi.spyOn(webSocketClient, 'connect').mockReturnValue(socket as unknown as Socket) vi.spyOn(webSocketClient, 'disconnect').mockImplementation(() => undefined) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts index c04c9f127d7b4e..ed092774d64ea7 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts @@ -102,7 +102,10 @@ type CollaborationManagerInternals = { isUndoRedoInProgress: boolean } -const createVariable = (name: string, overrides: Partial<WorkflowVariable> = {}): WorkflowVariable => ({ +const createVariable = ( + name: string, + overrides: Partial<WorkflowVariable> = {}, +): WorkflowVariable => ({ default: '', hint: '', label: name, @@ -134,7 +137,7 @@ const createNodeSnapshot = (variableNames: string[]): Node<StartNodeData> => ({ title: '开始', desc: '', type: BlockEnum.Start, - variables: variableNames.map(name => createVariable(name)), + variables: variableNames.map((name) => createVariable(name)), }, }) @@ -177,7 +180,9 @@ const createLLMNodeSnapshot = (promptTemplates: PromptTemplateItem[]): Node<LLMN }, }) -const createParameterExtractorNodeSnapshot = (parameters: ParameterItem[]): Node<ParameterExtractorNodeData> => ({ +const createParameterExtractorNodeSnapshot = ( + parameters: ParameterItem[], +): Node<ParameterExtractorNodeData> => ({ id: PARAM_NODE_ID, type: 'custom', position: { x: 420, y: 220 }, @@ -214,13 +219,13 @@ const createParameterExtractorNodeSnapshot = (parameters: ParameterItem[]): Node const getVariables = (node: Node): string[] => { const data = node.data as CommonNodeType<{ variables?: WorkflowVariable[] }> const variables = data.variables ?? [] - return variables.map(item => item.variable) + return variables.map((item) => item.variable) } const getVariableObject = (node: Node, name: string): WorkflowVariable | undefined => { const data = node.data as CommonNodeType<{ variables?: WorkflowVariable[] }> const variables = data.variables ?? [] - return variables.find(item => item.variable === name) + return variables.find((item) => item.variable === name) } const getPromptTemplates = (node: Node): PromptTemplateItem[] => { @@ -236,7 +241,10 @@ const getParameters = (node: Node): ParameterItem[] => { const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals => manager as unknown as CollaborationManagerInternals -const setupManager = (): { manager: CollaborationManager, internals: CollaborationManagerInternals } => { +const setupManager = (): { + manager: CollaborationManager + internals: CollaborationManagerInternals +} => { const manager = new CollaborationManager() const doc = new LoroDoc() const internals = getManagerInternals(manager) @@ -265,7 +273,7 @@ describe('CollaborationManager syncNodes', () => { internals.syncNodes(base, next) - const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID) + const stored = (manager.getNodes() as Node[]).find((node) => node.id === NODE_ID) expect(stored).toBeDefined() expect(getVariables(stored!)).toEqual(['a', 'b']) }) @@ -277,12 +285,12 @@ describe('CollaborationManager syncNodes', () => { internals.syncNodes(base, userA) - const afterUserA = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID) + const afterUserA = (manager.getNodes() as Node[]).find((node) => node.id === NODE_ID) expect(getVariables(afterUserA!)).toEqual(['a', 'b']) internals.syncNodes(base, userB) - const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID) + const finalNode = (manager.getNodes() as Node[]).find((node) => node.id === NODE_ID) const finalVariables = getVariables(finalNode!) expect(finalVariables).toEqual(['a', 'c']) @@ -295,9 +303,7 @@ describe('CollaborationManager syncNodes', () => { ...createNodeSnapshot(['a']), data: { ...createNodeSnapshot(['a']).data, - variables: [ - createVariable('a', { label: 'A from userA', hint: 'hintA' }), - ], + variables: [createVariable('a', { label: 'A from userA', hint: 'hintA' })], }, }, ] @@ -306,9 +312,7 @@ describe('CollaborationManager syncNodes', () => { ...createNodeSnapshot(['a']), data: { ...createNodeSnapshot(['a']).data, - variables: [ - createVariable('a', { label: 'A from userB', hint: 'hintB' }), - ], + variables: [createVariable('a', { label: 'A from userB', hint: 'hintB' })], }, }, ] @@ -316,7 +320,7 @@ describe('CollaborationManager syncNodes', () => { internals.syncNodes(base, userA) internals.syncNodes(base, userB) - const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID) + const finalNode = (manager.getNodes() as Node[]).find((node) => node.id === NODE_ID) const finalVariable = getVariableObject(finalNode!, 'a') expect(finalVariable?.label).toBe('A from userB') @@ -331,9 +335,7 @@ describe('CollaborationManager syncNodes', () => { ...createNodeSnapshot(['a']), data: { ...createNodeSnapshot(['a']).data, - variables: [ - createVariable('a', { label: 'A after deletion' }), - ], + variables: [createVariable('a', { label: 'A after deletion' })], }, }, ] @@ -353,7 +355,7 @@ describe('CollaborationManager syncNodes', () => { internals.syncNodes(base, userA) internals.syncNodes(base, userB) - const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID) + const finalNode = (manager.getNodes() as Node[]).find((node) => node.id === NODE_ID) const finalVariables = getVariables(finalNode!) expect(finalVariables).toEqual(['a', 'b']) expect(getVariableObject(finalNode!, 'b')).toBeDefined() @@ -385,7 +387,7 @@ describe('CollaborationManager syncNodes', () => { const updatedNode = createLLMNodeSnapshot(updatedTemplates) promptInternals.syncNodes([deepClone(baseNode)], [deepClone(updatedNode)]) - const stored = (promptManager.getNodes() as Node[]).find(node => node.id === LLM_NODE_ID) + const stored = (promptManager.getNodes() as Node[]).find((node) => node.id === LLM_NODE_ID) expect(stored).toBeDefined() const storedTemplates = getPromptTemplates(stored!) @@ -404,7 +406,7 @@ describe('CollaborationManager syncNodes', () => { promptInternals.syncNodes([deepClone(updatedNode)], [deepClone(editedNode)]) - const final = (promptManager.getNodes() as Node[]).find(node => node.id === LLM_NODE_ID) + const final = (promptManager.getNodes() as Node[]).find((node) => node.id === LLM_NODE_ID) const finalTemplates = getPromptTemplates(final!) expect(finalTemplates).toHaveLength(1) expect(finalTemplates[0]!.text).toBe('updated system prompt') @@ -429,7 +431,7 @@ describe('CollaborationManager syncNodes', () => { const updatedNode = createParameterExtractorNodeSnapshot(updatedParameters) parameterInternals.syncNodes([deepClone(baseNode)], [deepClone(updatedNode)]) - const stored = (parameterManager.getNodes() as Node[]).find(node => node.id === PARAM_NODE_ID) + const stored = (parameterManager.getNodes() as Node[]).find((node) => node.id === PARAM_NODE_ID) expect(stored).toBeDefined() expect(getParameters(stored!)).toEqual(updatedParameters) @@ -440,7 +442,7 @@ describe('CollaborationManager syncNodes', () => { parameterInternals.syncNodes([deepClone(updatedNode)], [deepClone(editedNode)]) - const final = (parameterManager.getNodes() as Node[]).find(node => node.id === PARAM_NODE_ID) + const final = (parameterManager.getNodes() as Node[]).find((node) => node.id === PARAM_NODE_ID) expect(getParameters(final!)).toEqual(editedParameters) }) @@ -454,7 +456,7 @@ describe('CollaborationManager syncNodes', () => { internals.syncNodes([], [deepClone(emptyNode)]) - const stored = (manager.getNodes() as Node[]).find(node => node.id === 'empty-node') + const stored = (manager.getNodes() as Node[]).find((node) => node.id === 'empty-node') expect(stored).toBeDefined() expect(stored?.data).toEqual({}) }) @@ -462,12 +464,12 @@ describe('CollaborationManager syncNodes', () => { it('preserves CRDT list instances when synchronizing parsed state back into the manager', () => { const { manager: promptManager, internals: promptInternals } = setupManager() - const base = createLLMNodeSnapshot([ - { id: 'system', role: 'system', text: 'base' }, - ]) + const base = createLLMNodeSnapshot([{ id: 'system', role: 'system', text: 'base' }]) promptInternals.syncNodes([], [deepClone(base)]) - const storedBefore = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined + const storedBefore = promptManager.getNodes().find((node) => node.id === LLM_NODE_ID) as + | Node<LLMNodeData> + | undefined expect(storedBefore).toBeDefined() const firstTemplate = storedBefore?.data.prompt_template?.[0] expect(firstTemplate?.text).toBe('base') @@ -483,7 +485,9 @@ describe('CollaborationManager syncNodes', () => { promptInternals.syncNodes([baseNode], [mutatedNode]) - const storedAfter = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined + const storedAfter = promptManager.getNodes().find((node) => node.id === LLM_NODE_ID) as + | Node<LLMNodeData> + | undefined const templatesAfter = storedAfter?.data.prompt_template expect(Array.isArray(templatesAfter)).toBe(true) expect(templatesAfter).toHaveLength(2) @@ -498,13 +502,15 @@ describe('CollaborationManager syncNodes', () => { const node = createParameterExtractorNodeSnapshot(initialParameters) parameterInternals.syncNodes([], [deepClone(node)]) - const stored = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID) as Node<ParameterExtractorNodeData> + const stored = parameterManager + .getNodes() + .find((n) => n.id === PARAM_NODE_ID) as Node<ParameterExtractorNodeData> const mutatedNode = deepClone(stored) mutatedNode.data.parameters[0]!.description = 'updated' parameterInternals.syncNodes([stored], [mutatedNode]) - const storedAfter = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID) as + const storedAfter = parameterManager.getNodes().find((n) => n.id === PARAM_NODE_ID) as | Node<ParameterExtractorNodeData> | undefined const params = storedAfter?.data.parameters ?? [] @@ -513,7 +519,7 @@ describe('CollaborationManager syncNodes', () => { }) it('filters out transient/private data keys while keeping allowlisted ones', () => { - const nodeWithPrivate: Node<{ _foo: string, variables: WorkflowVariable[] }> = { + const nodeWithPrivate: Node<{ _foo: string; variables: WorkflowVariable[] }> = { id: 'private-node', type: 'custom', position: { x: 0, y: 0 }, @@ -530,7 +536,7 @@ describe('CollaborationManager syncNodes', () => { internals.syncNodes([], [deepClone(nodeWithPrivate)]) - const stored = (manager.getNodes() as Node[]).find(node => node.id === 'private-node')! + const stored = (manager.getNodes() as Node[]).find((node) => node.id === 'private-node')! const storedData = stored.data as CommonNodeType<{ _foo?: string }> expect(storedData._foo).toBeUndefined() expect(storedData._children).toEqual([{ nodeId: 'child-a', nodeType: BlockEnum.Start }]) @@ -551,7 +557,7 @@ describe('CollaborationManager syncNodes', () => { internals.syncNodes([deepClone(baseNode)], [withoutVariables]) - const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)! + const stored = (manager.getNodes() as Node[]).find((node) => node.id === NODE_ID)! const storedData = stored.data as CommonNodeType<{ variables?: WorkflowVariable[] }> expect(storedData.variables).toBeUndefined() }) @@ -567,7 +573,9 @@ describe('CollaborationManager syncNodes', () => { promptInternals.syncNodes([deepClone(nodeWithInvalidTemplate)], [mutated]) - const stored = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> + const stored = promptManager + .getNodes() + .find((node) => node.id === LLM_NODE_ID) as Node<LLMNodeData> expect(Array.isArray(stored.data.prompt_template)).toBe(true) expect(stored.data.prompt_template).toHaveLength(0) }) diff --git a/web/app/components/workflow/collaboration/core/__tests__/crdt-provider.test.ts b/web/app/components/workflow/collaboration/core/__tests__/crdt-provider.test.ts index d6a9e35228348d..824a4bc514ebda 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/crdt-provider.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/crdt-provider.test.ts @@ -52,8 +52,7 @@ const createMockSocket = (): MockSocket => { }), trigger: (event: string, ...args: unknown[]) => { const handler = handlers.get(event) - if (handler) - handler(...args) + if (handler) handler(...args) }, } diff --git a/web/app/components/workflow/collaboration/core/__tests__/event-emitter.test.ts b/web/app/components/workflow/collaboration/core/__tests__/event-emitter.test.ts index 19c4990856446d..17e715802177b4 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/event-emitter.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/event-emitter.test.ts @@ -67,9 +67,7 @@ describe('EventEmitter', () => { it('continues emitting when a handler throws', () => { const emitter = new EventEmitter() - const errorHandler = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined) + const errorHandler = vi.spyOn(console, 'error').mockImplementation(() => undefined) const failingHandler = vi.fn(() => { throw new Error('boom') diff --git a/web/app/components/workflow/collaboration/core/__tests__/websocket-manager.test.ts b/web/app/components/workflow/collaboration/core/__tests__/websocket-manager.test.ts index b9982164c56f23..f01650f5035db3 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/websocket-manager.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/websocket-manager.test.ts @@ -34,8 +34,7 @@ const createMockSocket = (id: string): MockSocket => { }), trigger: (event: string, ...args: unknown[]) => { const handler = handlers.get(event) - if (handler) - handler(...args) + if (handler) handler(...args) }, } @@ -95,7 +94,9 @@ describe('WebSocketClient', () => { const client = new WebSocketClient() client.connect('app-auth') - const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1] as () => void + const connectHandler = mockSocket.on.mock.calls.find( + (call) => call[0] === 'connect', + )?.[1] as () => void expect(connectHandler).toBeDefined() connectHandler() diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index a80e493da95d06..906818063c3c85 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -2,11 +2,7 @@ import type { Value } from 'loro-crdt' import type { Socket } from 'socket.io-client' -import type { - CommonNodeType, - Edge, - Node, -} from '../../types' +import type { CommonNodeType, Edge, Node } from '../../types' import type { CollaborationState, CollaborationUpdate, @@ -101,15 +97,15 @@ type SetNodesAnomalyLogEntry = { } } -type GraphSyncDiagnosticStage - = | 'nodes_subscribe' - | 'edges_subscribe' - | 'nodes_import_apply' - | 'edges_import_apply' - | 'schedule_graph_import_emit' - | 'graph_import_emit' - | 'start_import_log' - | 'finalize_import_log' +type GraphSyncDiagnosticStage = + | 'nodes_subscribe' + | 'edges_subscribe' + | 'nodes_import_apply' + | 'edges_import_apply' + | 'schedule_graph_import_emit' + | 'graph_import_emit' + | 'start_import_log' + | 'finalize_import_log' type GraphSyncDiagnosticEvent = { timestamp: number @@ -133,7 +129,8 @@ const SET_NODES_ANOMALY_LOG_LIMIT = 100 const GRAPH_SYNC_DIAGNOSTIC_LOG_LIMIT = 400 const toLoroValue = (value: unknown): Value => cloneDeep(value) as Value -const toLoroRecord = (value: unknown): Record<string, Value> => cloneDeep(value) as Record<string, Value> +const toLoroRecord = (value: unknown): Record<string, Value> => + cloneDeep(value) as Record<string, Value> export class CollaborationManager { private doc: LoroDoc | null = null private undoManager: UndoManager | null = null @@ -167,20 +164,16 @@ export class CollaborationManager { } | null = null private getActiveSocket(): Socket | null { - if (!this.currentAppId) - return null + if (!this.currentAppId) return null return webSocketClient.getSocket(this.currentAppId) } private handleSessionUnauthorized = (): void => { - if (this.rejoinInProgress) - return - if (!this.currentAppId) - return + if (this.rejoinInProgress) return + if (!this.currentAppId) return const socket = this.getActiveSocket() - if (!socket) - return + if (!socket) return this.rejoinInProgress = true console.warn('Collaboration session expired, attempting to rejoin workflow.') @@ -203,28 +196,35 @@ export class CollaborationManager { private sendCollaborationEvent(payload: CollaborationEventPayload): void { const socket = this.getActiveSocket() - if (!socket) - return + if (!socket) return - emitWithAuthGuard(socket, 'collaboration_event', payload, { onUnauthorized: this.handleSessionUnauthorized }) + emitWithAuthGuard(socket, 'collaboration_event', payload, { + onUnauthorized: this.handleSessionUnauthorized, + }) } private sendGraphEvent(payload: Uint8Array): void { const socket = this.getActiveSocket() - if (!socket) - return + if (!socket) return - emitWithAuthGuard(socket, 'graph_event', payload, { onUnauthorized: this.handleSessionUnauthorized }) + emitWithAuthGuard(socket, 'graph_event', payload, { + onUnauthorized: this.handleSessionUnauthorized, + }) } private getNodeContainer(nodeId: string): LoroMap<Record<string, Value>> { - if (!this.nodesMap) - throw new Error('Nodes map not initialized') + if (!this.nodesMap) throw new Error('Nodes map not initialized') let container = this.nodesMap.get(nodeId) as unknown - const isMapContainer = (value: unknown): value is LoroMap<Record<string, Value>> & LoroContainer => { - return !!value && typeof (value as LoroContainer).kind === 'function' && (value as LoroContainer).kind?.() === 'Map' + const isMapContainer = ( + value: unknown, + ): value is LoroMap<Record<string, Value>> & LoroContainer => { + return ( + !!value && + typeof (value as LoroContainer).kind === 'function' && + (value as LoroContainer).kind?.() === 'Map' + ) } if (!container || !isMapContainer(container)) { @@ -233,9 +233,11 @@ export class CollaborationManager { const attached = (newContainer as LoroContainer).getAttached?.() ?? newContainer container = attached if (previousValue && typeof previousValue === 'object') - this.populateNodeContainer(container as LoroMap<Record<string, Value>>, previousValue as Node) - } - else { + this.populateNodeContainer( + container as LoroMap<Record<string, Value>>, + previousValue as Node, + ) + } else { const attached = (container as LoroContainer).getAttached?.() ?? container container = attached } @@ -243,21 +245,34 @@ export class CollaborationManager { return container as LoroMap<Record<string, Value>> } - private ensureDataContainer(nodeContainer: LoroMap<Record<string, Value>>): LoroMap<Record<string, Value>> { + private ensureDataContainer( + nodeContainer: LoroMap<Record<string, Value>>, + ): LoroMap<Record<string, Value>> { let dataContainer = nodeContainer.get('data') as unknown - if (!dataContainer || typeof (dataContainer as LoroContainer).kind !== 'function' || (dataContainer as LoroContainer).kind?.() !== 'Map') + if ( + !dataContainer || + typeof (dataContainer as LoroContainer).kind !== 'function' || + (dataContainer as LoroContainer).kind?.() !== 'Map' + ) dataContainer = nodeContainer.setContainer('data', new LoroMap()) const attached = (dataContainer as LoroContainer).getAttached?.() ?? dataContainer return attached as LoroMap<Record<string, Value>> } - private ensureList(nodeContainer: LoroMap<Record<string, Value>>, key: string): LoroList<unknown> { + private ensureList( + nodeContainer: LoroMap<Record<string, Value>>, + key: string, + ): LoroList<unknown> { const dataContainer = this.ensureDataContainer(nodeContainer) let list = dataContainer.get(key) as unknown - if (!list || typeof (list as LoroContainer).kind !== 'function' || (list as LoroContainer).kind?.() !== 'List') + if ( + !list || + typeof (list as LoroContainer).kind !== 'function' || + (list as LoroContainer).kind?.() !== 'List' + ) list = dataContainer.setContainer(key, new LoroList()) const attached = (list as LoroContainer).getAttached?.() ?? list @@ -281,16 +296,13 @@ export class CollaborationManager { container.set('sourcePosition', node.sourcePosition) container.set('targetPosition', node.targetPosition) - if (node.width === undefined) - container.delete('width') + if (node.width === undefined) container.delete('width') else container.set('width', node.width) - if (node.height === undefined) - container.delete('height') + if (node.height === undefined) container.delete('height') else container.set('height', node.height) - if (node.selected === undefined) - container.delete('selected') + if (node.selected === undefined) container.delete('selected') else container.set('selected', node.selected) const optionalProps: Array<keyof Node> = [ @@ -315,43 +327,45 @@ export class CollaborationManager { optionalProps.forEach((prop) => { const value = node[prop] - if (value === undefined) - container.delete(prop as string) - else - container.set(prop as string, toLoroValue(value)) + if (value === undefined) container.delete(prop as string) + else container.set(prop as string, toLoroValue(value)) }) const dataContainer = this.ensureDataContainer(container) const handledKeys = new Set<string>() Object.entries(node.data || {}).forEach(([key, value]) => { - if (!this.shouldSyncDataKey(key)) - return + if (!this.shouldSyncDataKey(key)) return handledKeys.add(key) - if (listFields.has(key)) - this.syncList(container, key, Array.isArray(value) ? value : []) - else - dataContainer.set(key, toLoroValue(value)) + if (listFields.has(key)) this.syncList(container, key, Array.isArray(value) ? value : []) + else dataContainer.set(key, toLoroValue(value)) }) const existingData = dataContainer.toJSON() || {} Object.keys(existingData).forEach((key) => { - if (!this.shouldSyncDataKey(key)) - return - if (handledKeys.has(key)) - return + if (!this.shouldSyncDataKey(key)) return + if (handledKeys.has(key)) return dataContainer.delete(key) }) } private shouldSyncDataKey(key: string): boolean { - const syncDataAllowList = new Set(['_children', '_connectedSourceHandleIds', '_connectedTargetHandleIds', '_targetBranches']) + const syncDataAllowList = new Set([ + '_children', + '_connectedSourceHandleIds', + '_connectedTargetHandleIds', + '_targetBranches', + ]) return (syncDataAllowList.has(key) || !key.startsWith('_')) && key !== 'selected' } - private syncList(nodeContainer: LoroMap<Record<string, Value>>, key: string, desired: Array<unknown>): void { + private syncList( + nodeContainer: LoroMap<Record<string, Value>>, + key: string, + desired: Array<unknown>, + ): void { const list = this.ensureList(nodeContainer, key) const current = list.toJSON() as Array<unknown> const target = Array.isArray(desired) ? desired : [] @@ -366,10 +380,8 @@ export class CollaborationManager { if (current.length > target.length) { list.delete(target.length, current.length - target.length) - } - else if (target.length > current.length) { - for (let i = current.length; i < target.length; i += 1) - list.insert(i, cloneDeep(target[i])) + } else if (target.length > current.length) { + for (let i = current.length; i < target.length; i += 1) list.insert(i, cloneDeep(target[i])) } } @@ -389,26 +401,22 @@ export class CollaborationManager { Object.entries(this.nodePanelPresence).forEach(([id, viewers]) => { if (viewers[clientId]) { delete viewers[clientId] - if (Object.keys(viewers).length === 0) - delete this.nodePanelPresence[id] + if (Object.keys(viewers).length === 0) delete this.nodePanelPresence[id] } }) - if (!this.nodePanelPresence[nodeId]) - this.nodePanelPresence[nodeId] = {} + if (!this.nodePanelPresence[nodeId]) this.nodePanelPresence[nodeId] = {} this.nodePanelPresence[nodeId][clientId] = { ...user, clientId, timestamp: timestamp || Date.now(), } - } - else { + } else { const viewers = this.nodePanelPresence[nodeId] if (viewers) { delete viewers[clientId] - if (Object.keys(viewers).length === 0) - delete this.nodePanelPresence[nodeId] + if (Object.keys(viewers).length === 0) delete this.nodePanelPresence[nodeId] } } @@ -428,29 +436,31 @@ export class CollaborationManager { } }) - if (Object.keys(viewers).length === 0) - delete this.nodePanelPresence[nodeId] + if (Object.keys(viewers).length === 0) delete this.nodePanelPresence[nodeId] }) - if (hasChanges) - this.eventEmitter.emit('nodePanelPresence', this.getNodePanelPresenceSnapshot()) + if (hasChanges) this.eventEmitter.emit('nodePanelPresence', this.getNodePanelPresenceSnapshot()) } init = (appId: string, reactFlowStore: ReactFlowStore): void => { if (!reactFlowStore) { - console.warn('CollaborationManager.init called without reactFlowStore, deferring to connect()') + console.warn( + 'CollaborationManager.init called without reactFlowStore, deferring to connect()', + ) return } this.connect(appId, reactFlowStore) } - setNodes = (oldNodes: Node[], newNodes: Node[], source = 'collaboration-manager:setNodes'): void => { - if (!this.doc) - return + setNodes = ( + oldNodes: Node[], + newNodes: Node[], + source = 'collaboration-manager:setNodes', + ): void => { + if (!this.doc) return // Don't track operations during undo/redo to prevent loops - if (this.isUndoRedoInProgress) - return + if (this.isUndoRedoInProgress) return this.seedCrdtGraphFromReactFlowIfNeeded() this.captureSetNodesAnomaly(oldNodes, newNodes, source) @@ -459,12 +469,10 @@ export class CollaborationManager { } setEdges = (oldEdges: Edge[], newEdges: Edge[]): void => { - if (!this.doc) - return + if (!this.doc) return // Don't track operations during undo/redo to prevent loops - if (this.isUndoRedoInProgress) - return + if (this.isUndoRedoInProgress) return this.seedCrdtGraphFromReactFlowIfNeeded() this.syncEdges(oldEdges, newEdges) @@ -482,20 +490,17 @@ export class CollaborationManager { if (this.currentAppId === appId && this.doc) { // Already connected to the same app, only update store if provided and we don't have one - if (reactFlowStore && !this.reactFlowStore) - this.reactFlowStore = reactFlowStore + if (reactFlowStore && !this.reactFlowStore) this.reactFlowStore = reactFlowStore return connectionId } // Only disconnect if switching to a different app - if (this.currentAppId && this.currentAppId !== appId) - this.forceDisconnect() + if (this.currentAppId && this.currentAppId !== appId) this.forceDisconnect() this.currentAppId = appId // Only set store if provided - if (reactFlowStore) - this.reactFlowStore = reactFlowStore + if (reactFlowStore) this.reactFlowStore = reactFlowStore const socket = webSocketClient.connect(appId) @@ -513,7 +518,10 @@ export class CollaborationManager { excludeOriginPrefixes: [], // Don't exclude anything - let UndoManager track all local operations onPush: (_isUndo, _range, _event) => { // Store current selection state when an operation is pushed - const selectedNode = this.reactFlowStore?.getState().getNodes().find((n: Node) => n.data?.selected) + const selectedNode = this.reactFlowStore + ?.getState() + .getNodes() + .find((n: Node) => n.data?.selected) // Emit event to update UI button states when new operation is pushed setTimeout(() => { @@ -533,7 +541,12 @@ export class CollaborationManager { }, onPop: (_isUndo, value, _counterRange) => { // Restore selection state when undoing/redoing - if (value?.value && typeof value.value === 'object' && 'selectedNodeId' in value.value && this.reactFlowStore) { + if ( + value?.value && + typeof value.value === 'object' && + 'selectedNodeId' in value.value && + this.reactFlowStore + ) { const selectedNodeId = (value.value as { selectedNodeId?: string | null }).selectedNodeId if (selectedNodeId) { const state = this.reactFlowStore.getState() @@ -546,7 +559,11 @@ export class CollaborationManager { selected: n.id === selectedNodeId, }, })) - this.captureSetNodesAnomaly(nodes, newNodes, 'reactflow-native:undo-redo-selection-restore') + this.captureSetNodesAnomaly( + nodes, + newNodes, + 'reactflow-native:undo-redo-selection-restore', + ) setNodes(newNodes) } } @@ -559,23 +576,25 @@ export class CollaborationManager { // Force user_connect if already connected if (socket.connected) - emitWithAuthGuard(socket, 'user_connect', { workflow_id: appId }, { onUnauthorized: this.handleSessionUnauthorized }) + emitWithAuthGuard( + socket, + 'user_connect', + { workflow_id: appId }, + { onUnauthorized: this.handleSessionUnauthorized }, + ) return connectionId } disconnect = (connectionId?: string): void => { - if (connectionId) - this.activeConnections.delete(connectionId) + if (connectionId) this.activeConnections.delete(connectionId) // Only disconnect when no more connections - if (this.activeConnections.size === 0) - this.forceDisconnect() + if (this.activeConnections.size === 0) this.forceDisconnect() } private forceDisconnect = (): void => { - if (this.currentAppId) - webSocketClient.disconnect(this.currentAppId) + if (this.currentAppId) webSocketClient.disconnect(this.currentAppId) this.provider?.destroy() this.undoManager = null @@ -597,8 +616,7 @@ export class CollaborationManager { this.isLeader = false this.leaderId = null - if (wasLeader) - this.eventEmitter.emit('leaderChange', false) + if (wasLeader) this.eventEmitter.emit('leaderChange', false) this.activeConnections.clear() this.eventEmitter.removeAllListeners() @@ -609,22 +627,19 @@ export class CollaborationManager { } getNodes(): Node[] { - if (!this.nodesMap) - return [] - return Array.from(this.nodesMap.keys()).map(id => this.exportNode(id as string)) + if (!this.nodesMap) return [] + return Array.from(this.nodesMap.keys()).map((id) => this.exportNode(id as string)) } getEdges(): Edge[] { - return this.edgesMap ? Array.from(this.edgesMap.values()) as Edge[] : [] + return this.edgesMap ? (Array.from(this.edgesMap.values()) as Edge[]) : [] } emitCursorMove(position: CursorPosition): void { - if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) - return + if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return const socket = this.getActiveSocket() - if (!socket) - return + if (!socket) return this.sendCollaborationEvent({ type: 'mouse_move', @@ -635,8 +650,7 @@ export class CollaborationManager { } emitSyncRequest(): void { - if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) - return + if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return this.sendCollaborationEvent({ type: 'sync_request', @@ -646,8 +660,7 @@ export class CollaborationManager { } emitWorkflowUpdate(appId: string): void { - if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) - return + if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return this.sendCollaborationEvent({ type: 'workflow_update', @@ -657,12 +670,10 @@ export class CollaborationManager { } emitNodePanelPresence(nodeId: string, isOpen: boolean, user: NodePanelPresenceUser): void { - if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) - return + if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return const socket = this.getActiveSocket() - if (!socket || !nodeId || !user?.userId) - return + if (!socket || !nodeId || !user?.userId) return const payload: NodePanelPresenceEventData = { nodeId, @@ -685,7 +696,7 @@ export class CollaborationManager { return this.eventEmitter.on('syncRequest', callback) } - onGraphImport(callback: (payload: { nodes: Node[], edges: Edge[] }) => void): () => void { + onGraphImport(callback: (payload: { nodes: Node[]; edges: Edge[] }) => void): () => void { return this.eventEmitter.on('graphImport', callback) } @@ -701,7 +712,7 @@ export class CollaborationManager { return this.eventEmitter.on('onlineUsers', callback) } - onWorkflowUpdate(callback: (update: { appId: string, timestamp: number }) => void): () => void { + onWorkflowUpdate(callback: (update: { appId: string; timestamp: number }) => void): () => void { return this.eventEmitter.on('workflowUpdate', callback) } @@ -735,13 +746,12 @@ export class CollaborationManager { return this.eventEmitter.on('leaderChange', callback) } - onCommentsUpdate(callback: (update: { appId: string, timestamp: number }) => void): () => void { + onCommentsUpdate(callback: (update: { appId: string; timestamp: number }) => void): () => void { return this.eventEmitter.on('commentsUpdate', callback) } emitCommentsUpdate(appId: string): void { - if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) - return + if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return this.sendCollaborationEvent({ type: 'comments_update', @@ -751,8 +761,7 @@ export class CollaborationManager { } emitHistoryAction(action: 'undo' | 'redo' | 'jump'): void { - if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) - return + if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return this.sendCollaborationEvent({ type: 'workflow_history_action', @@ -761,17 +770,20 @@ export class CollaborationManager { }) } - onUndoRedoStateChange(callback: (state: { canUndo: boolean, canRedo: boolean }) => void): () => void { + onUndoRedoStateChange( + callback: (state: { canUndo: boolean; canRedo: boolean }) => void, + ): () => void { return this.eventEmitter.on('undoRedoStateChange', callback) } - onHistoryAction(callback: (payload: { action: 'undo' | 'redo' | 'jump', userId?: string }) => void): () => void { + onHistoryAction( + callback: (payload: { action: 'undo' | 'redo' | 'jump'; userId?: string }) => void, + ): () => void { return this.eventEmitter.on('historyAction', callback) } emitRestoreIntent(data: RestoreIntentData): void { - if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) - return + if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return this.sendCollaborationEvent({ type: 'workflow_restore_intent', @@ -781,8 +793,7 @@ export class CollaborationManager { } emitRestoreComplete(data: RestoreCompleteData): void { - if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) - return + if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return this.sendCollaborationEvent({ type: 'workflow_restore_complete', @@ -809,8 +820,7 @@ export class CollaborationManager { // Collaborative undo/redo methods undo(): boolean { - if (!this.undoManager) - return false + if (!this.undoManager) return false const canUndo = this.undoManager.canUndo() if (canUndo) { @@ -839,8 +849,7 @@ export class CollaborationManager { canRedo: this.undoManager?.canRedo() || false, }) }) - } - else { + } else { this.isUndoRedoInProgress = false } @@ -851,8 +860,7 @@ export class CollaborationManager { } redo(): boolean { - if (!this.undoManager) - return false + if (!this.undoManager) return false const canRedo = this.undoManager.canRedo() if (canRedo) { @@ -881,8 +889,7 @@ export class CollaborationManager { canRedo: this.undoManager?.canRedo() || false, }) }) - } - else { + } else { this.isUndoRedoInProgress = false } @@ -893,29 +900,25 @@ export class CollaborationManager { } canUndo(): boolean { - if (!this.undoManager) - return false + if (!this.undoManager) return false return this.undoManager.canUndo() } canRedo(): boolean { - if (!this.undoManager) - return false + if (!this.undoManager) return false return this.undoManager.canRedo() } clearUndoStack(): void { - if (!this.undoManager) - return + if (!this.undoManager) return this.undoManager.clear() } private syncNodes(oldNodes: Node[], newNodes: Node[]): void { - if (!this.nodesMap || !this.doc) - return + if (!this.nodesMap || !this.doc) return - const oldNodesMap = new Map(oldNodes.map(node => [node.id, node])) - const newNodesMap = new Map(newNodes.map(node => [node.id, node])) + const oldNodesMap = new Map(oldNodes.map((node) => [node.id, node])) + const newNodesMap = new Map(newNodes.map((node) => [node.id, node])) oldNodes.forEach((oldNode) => { if (!newNodesMap.has(oldNode.id)) { @@ -925,10 +928,8 @@ export class CollaborationManager { newNodes.forEach((newNode) => { const oldNode = oldNodesMap.get(newNode.id) - if (oldNode && oldNode === newNode) - return - if (oldNode && isEqual(oldNode, newNode)) - return + if (oldNode && oldNode === newNode) return + if (oldNode && isEqual(oldNode, newNode)) return const nodeContainer = this.getNodeContainer(newNode.id) this.populateNodeContainer(nodeContainer, newNode) @@ -936,11 +937,10 @@ export class CollaborationManager { } private syncEdges(oldEdges: Edge[], newEdges: Edge[]): void { - if (!this.edgesMap) - return + if (!this.edgesMap) return - const oldEdgesMap = new Map(oldEdges.map(edge => [edge.id, edge])) - const newEdgesMap = new Map(newEdges.map(edge => [edge.id, edge])) + const oldEdgesMap = new Map(oldEdges.map((edge) => [edge.id, edge])) + const newEdgesMap = new Map(newEdges.map((edge) => [edge.id, edge])) oldEdges.forEach((oldEdge) => { if (!newEdgesMap.has(oldEdge.id)) { @@ -961,18 +961,15 @@ export class CollaborationManager { this.nodesMap?.subscribe((event: LoroSubscribeEvent) => { const reactFlowStore = this.reactFlowStore const eventBy = event.by ?? 'unknown' - this.recordGraphSyncDiagnostic( - 'nodes_subscribe', - 'triggered', - undefined, - { - eventBy, - hasReactFlowStore: Boolean(reactFlowStore), - }, - ) + this.recordGraphSyncDiagnostic('nodes_subscribe', 'triggered', undefined, { + eventBy, + hasReactFlowStore: Boolean(reactFlowStore), + }) if (eventBy !== 'import') { - this.recordGraphSyncDiagnostic('nodes_subscribe', 'skipped', 'event_by_not_import', { eventBy }) + this.recordGraphSyncDiagnostic('nodes_subscribe', 'skipped', 'event_by_not_import', { + eventBy, + }) return } @@ -993,59 +990,52 @@ export class CollaborationManager { const previousNodes: Node[] = state.getNodes() const previousEdges: Edge[] = state.getEdges() this.startImportLog('nodes', { nodes: previousNodes, edges: previousEdges }) - const previousNodeMap = new Map(previousNodes.map(node => [node.id, node])) + const previousNodeMap = new Map(previousNodes.map((node) => [node.id, node])) const selectedIds = new Set( - previousNodes - .filter(node => node.data?.selected) - .map(node => node.id), + previousNodes.filter((node) => node.data?.selected).map((node) => node.id), ) this.pendingInitialSync = false - const updatedNodes = Array - .from(this.nodesMap?.keys() || []) - .map((nodeId) => { - const node = this.exportNode(nodeId as string) - const clonedNode: Node = { - ...node, - data: { - ...(node.data || {}), - }, - } - const clonedNodeData = clonedNode.data as (CommonNodeType & Record<string, unknown>) - // Keep the previous node's private data properties (starting with _) - const previousNode = previousNodeMap.get(clonedNode.id) - if (previousNode?.data) { - const previousData = previousNode.data as Record<string, unknown> - Object.entries(previousData) - .filter(([key]) => key.startsWith('_')) - .forEach(([key, value]) => { - if (!(key in clonedNodeData)) - clonedNodeData[key] = value - }) - } - - if (selectedIds.has(clonedNode.id)) - clonedNode.data.selected = true - - return clonedNode - }) + const updatedNodes = Array.from(this.nodesMap?.keys() || []).map((nodeId) => { + const node = this.exportNode(nodeId as string) + const clonedNode: Node = { + ...node, + data: { + ...(node.data || {}), + }, + } + const clonedNodeData = clonedNode.data as CommonNodeType & Record<string, unknown> + // Keep the previous node's private data properties (starting with _) + const previousNode = previousNodeMap.get(clonedNode.id) + if (previousNode?.data) { + const previousData = previousNode.data as Record<string, unknown> + Object.entries(previousData) + .filter(([key]) => key.startsWith('_')) + .forEach(([key, value]) => { + if (!(key in clonedNodeData)) clonedNodeData[key] = value + }) + } + + if (selectedIds.has(clonedNode.id)) clonedNode.data.selected = true + + return clonedNode + }) // Call ReactFlow's native setter directly to avoid triggering collaboration - this.captureSetNodesAnomaly(previousNodes, updatedNodes, 'reactflow-native:import-nodes-map-subscribe') - state.setNodes(updatedNodes) - this.recordGraphSyncDiagnostic( - 'nodes_import_apply', - 'applied', - undefined, - { - eventBy, - previousNodeCount: previousNodes.length, - updatedNodeCount: updatedNodes.length, - previousEdgeCount: previousEdges.length, - selectedCount: selectedIds.size, - }, + this.captureSetNodesAnomaly( + previousNodes, + updatedNodes, + 'reactflow-native:import-nodes-map-subscribe', ) + state.setNodes(updatedNodes) + this.recordGraphSyncDiagnostic('nodes_import_apply', 'applied', undefined, { + eventBy, + previousNodeCount: previousNodes.length, + updatedNodeCount: updatedNodes.length, + previousEdgeCount: previousEdges.length, + selectedCount: selectedIds.size, + }) this.scheduleGraphImportEmit() }) @@ -1054,18 +1044,15 @@ export class CollaborationManager { this.edgesMap?.subscribe((event: LoroSubscribeEvent) => { const reactFlowStore = this.reactFlowStore const eventBy = event.by ?? 'unknown' - this.recordGraphSyncDiagnostic( - 'edges_subscribe', - 'triggered', - undefined, - { - eventBy, - hasReactFlowStore: Boolean(reactFlowStore), - }, - ) + this.recordGraphSyncDiagnostic('edges_subscribe', 'triggered', undefined, { + eventBy, + hasReactFlowStore: Boolean(reactFlowStore), + }) if (eventBy !== 'import') { - this.recordGraphSyncDiagnostic('edges_subscribe', 'skipped', 'event_by_not_import', { eventBy }) + this.recordGraphSyncDiagnostic('edges_subscribe', 'skipped', 'event_by_not_import', { + eventBy, + }) return } @@ -1093,17 +1080,12 @@ export class CollaborationManager { // Call ReactFlow's native setter directly to avoid triggering collaboration state.setEdges(updatedEdges) - this.recordGraphSyncDiagnostic( - 'edges_import_apply', - 'applied', - undefined, - { - eventBy, - previousNodeCount: previousNodes.length, - previousEdgeCount: previousEdges.length, - updatedEdgeCount: updatedEdges.length, - }, - ) + this.recordGraphSyncDiagnostic('edges_import_apply', 'applied', undefined, { + eventBy, + previousNodeCount: previousNodes.length, + previousEdgeCount: previousEdges.length, + updatedEdgeCount: updatedEdges.length, + }) this.scheduleGraphImportEmit() }) @@ -1112,11 +1094,7 @@ export class CollaborationManager { private scheduleGraphImportEmit(): void { if (this.pendingGraphImportEmit) { - this.recordGraphSyncDiagnostic( - 'schedule_graph_import_emit', - 'skipped', - 'already_pending', - ) + this.recordGraphSyncDiagnostic('schedule_graph_import_emit', 'skipped', 'already_pending') return } @@ -1129,17 +1107,12 @@ export class CollaborationManager { this.finalizeImportLog() const mergedNodes = this.mergeLocalNodeState(this.getNodes()) const mergedEdges = this.getEdges() - this.recordGraphSyncDiagnostic( - 'graph_import_emit', - 'emitted', - undefined, - { - mergedNodeCount: mergedNodes.length, - mergedEdgeCount: mergedEdges.length, - crdtNodeCountBeforeFinalize: beforeFinalizeNodes, - crdtEdgeCountBeforeFinalize: beforeFinalizeEdges, - }, - ) + this.recordGraphSyncDiagnostic('graph_import_emit', 'emitted', undefined, { + mergedNodeCount: mergedNodes.length, + mergedEdgeCount: mergedEdges.length, + crdtNodeCountBeforeFinalize: beforeFinalizeNodes, + crdtEdgeCountBeforeFinalize: beforeFinalizeEdges, + }) this.eventEmitter.emit('graphImport', { nodes: mergedNodes, edges: mergedEdges, @@ -1160,14 +1133,12 @@ export class CollaborationManager { const state = reactFlowStore?.getState() const localNodes = state?.getNodes() || [] - if (localNodes.length === 0) - return nodes + if (localNodes.length === 0) return nodes - const localNodesMap = new Map(localNodes.map(node => [node.id, node])) + const localNodesMap = new Map(localNodes.map((node) => [node.id, node])) return nodes.map((node) => { const localNode = localNodesMap.get(node.id) - if (!localNode) - return node + if (!localNode) return node const nextNode = cloneDeep(node) const nextData = { ...(nextNode.data || {}) } as Node['data'] @@ -1176,12 +1147,14 @@ export class CollaborationManager { if (localData) { Object.entries(localData).forEach(([key, value]) => { - if (key === 'selected' || key.startsWith('_')) - nextDataRecord[key] = value + if (key === 'selected' || key.startsWith('_')) nextDataRecord[key] = value }) } - if (!Object.prototype.hasOwnProperty.call(nextDataRecord, 'selected') && localNode.selected !== undefined) + if ( + !Object.prototype.hasOwnProperty.call(nextDataRecord, 'selected') && + localNode.selected !== undefined + ) nextDataRecord.selected = localNode.selected nextNode.data = nextData @@ -1267,30 +1240,30 @@ export class CollaborationManager { this.graphSyncDiagnostics.push(entry) if (this.graphSyncDiagnostics.length > GRAPH_SYNC_DIAGNOSTIC_LOG_LIMIT) - this.graphSyncDiagnostics.splice(0, this.graphSyncDiagnostics.length - GRAPH_SYNC_DIAGNOSTIC_LOG_LIMIT) + this.graphSyncDiagnostics.splice( + 0, + this.graphSyncDiagnostics.length - GRAPH_SYNC_DIAGNOSTIC_LOG_LIMIT, + ) } private captureSetNodesAnomaly(oldNodes: Node[], newNodes: Node[], source: string): void { - const oldNodeIds = oldNodes.map(node => node.id) - const newNodeIds = newNodes.map(node => node.id) + const oldNodeIds = oldNodes.map((node) => node.id) + const newNodeIds = newNodes.map((node) => node.id) const newNodeIdSet = new Set(newNodeIds) - const removedNodeIds = oldNodeIds.filter(nodeId => !newNodeIdSet.has(nodeId)) + const removedNodeIds = oldNodeIds.filter((nodeId) => !newNodeIdSet.has(nodeId)) const oldStartNodeIds = oldNodes - .filter(node => (node.data as CommonNodeType | undefined)?.type === 'start') - .map(node => node.id) + .filter((node) => (node.data as CommonNodeType | undefined)?.type === 'start') + .map((node) => node.id) const newStartNodeIds = newNodes - .filter(node => (node.data as CommonNodeType | undefined)?.type === 'start') - .map(node => node.id) + .filter((node) => (node.data as CommonNodeType | undefined)?.type === 'start') + .map((node) => node.id) const reasons: SetNodesAnomalyReason[] = [] - if (newNodes.length < oldNodes.length) - reasons.push('node_count_decrease') - if (oldStartNodeIds.length > 0 && newStartNodeIds.length === 0) - reasons.push('start_removed') + if (newNodes.length < oldNodes.length) reasons.push('node_count_decrease') + if (oldStartNodeIds.length > 0 && newStartNodeIds.length === 0) reasons.push('start_removed') - if (!reasons.length) - return + if (!reasons.length) return const entry: SetNodesAnomalyLogEntry = { timestamp: Date.now(), @@ -1315,10 +1288,13 @@ export class CollaborationManager { } this.setNodesAnomalyLogs.push(entry) if (this.setNodesAnomalyLogs.length > SET_NODES_ANOMALY_LOG_LIMIT) - this.setNodesAnomalyLogs.splice(0, this.setNodesAnomalyLogs.length - SET_NODES_ANOMALY_LOG_LIMIT) + this.setNodesAnomalyLogs.splice( + 0, + this.setNodesAnomalyLogs.length - SET_NODES_ANOMALY_LOG_LIMIT, + ) } - private snapshotReactFlowGraph(): { nodes: Node[], edges: Edge[] } { + private snapshotReactFlowGraph(): { nodes: Node[]; edges: Edge[] } { if (!this.reactFlowStore) { return { nodes: this.getNodes(), @@ -1333,7 +1309,10 @@ export class CollaborationManager { } } - private startImportLog(source: 'nodes' | 'edges', before?: { nodes: Node[], edges: Edge[] }): void { + private startImportLog( + source: 'nodes' | 'edges', + before?: { nodes: Node[]; edges: Edge[] }, + ): void { if (!this.pendingImportLog) { const snapshot = before ?? this.snapshotReactFlowGraph() this.pendingImportLog = { @@ -1344,28 +1323,18 @@ export class CollaborationManager { edges: cloneDeep(snapshot.edges), }, } - this.recordGraphSyncDiagnostic( - 'start_import_log', - 'snapshot', - 'created', - { - source, - beforeNodes: snapshot.nodes.length, - beforeEdges: snapshot.edges.length, - }, - ) + this.recordGraphSyncDiagnostic('start_import_log', 'snapshot', 'created', { + source, + beforeNodes: snapshot.nodes.length, + beforeEdges: snapshot.edges.length, + }) return } this.pendingImportLog.sources.add(source) - this.recordGraphSyncDiagnostic( - 'start_import_log', - 'snapshot', - 'merged_source', - { - source, - sourceCount: this.pendingImportLog.sources.size, - }, - ) + this.recordGraphSyncDiagnostic('start_import_log', 'snapshot', 'merged_source', { + source, + sourceCount: this.pendingImportLog.sources.size, + }) } private finalizeImportLog(): void { @@ -1396,18 +1365,13 @@ export class CollaborationManager { } this.graphImportLogs.push(entry) - this.recordGraphSyncDiagnostic( - 'finalize_import_log', - 'snapshot', - undefined, - { - sources: entry.sources, - beforeNodes: entry.before.nodes.length, - beforeEdges: entry.before.edges.length, - afterNodes: entry.after.nodes.length, - afterEdges: entry.after.edges.length, - }, - ) + this.recordGraphSyncDiagnostic('finalize_import_log', 'snapshot', undefined, { + sources: entry.sources, + beforeNodes: entry.before.nodes.length, + beforeEdges: entry.before.edges.length, + afterNodes: entry.after.nodes.length, + afterEdges: entry.after.edges.length, + }) if (this.graphImportLogs.length > GRAPH_IMPORT_LOG_LIMIT) this.graphImportLogs.splice(0, this.graphImportLogs.length - GRAPH_IMPORT_LOG_LIMIT) this.pendingImportLog = null @@ -1417,7 +1381,7 @@ export class CollaborationManager { socket.on('collaboration_update', (update: CollaborationUpdate) => { if (update.type === 'mouse_move') { // Update cursor state for this user - const data = update.data as { x: number, y: number } + const data = update.data as { x: number; y: number } this.cursors[update.userId] = { x: data.x, y: data.y, @@ -1426,54 +1390,39 @@ export class CollaborationManager { } this.eventEmitter.emit('cursors', { ...this.cursors }) - } - else if (update.type === 'vars_and_features_update') { + } else if (update.type === 'vars_and_features_update') { this.eventEmitter.emit('varsAndFeaturesUpdate', update) - } - else if (update.type === 'app_state_update') { + } else if (update.type === 'app_state_update') { this.eventEmitter.emit('appStateUpdate', update) - } - else if (update.type === 'app_meta_update') { + } else if (update.type === 'app_meta_update') { this.eventEmitter.emit('appMetaUpdate', update) - } - else if (update.type === 'app_publish_update') { + } else if (update.type === 'app_publish_update') { this.eventEmitter.emit('appPublishUpdate', update) - } - else if (update.type === 'mcp_server_update') { + } else if (update.type === 'mcp_server_update') { this.eventEmitter.emit('mcpServerUpdate', update) - } - else if (update.type === 'workflow_update') { + } else if (update.type === 'workflow_update') { this.eventEmitter.emit('workflowUpdate', update.data) - } - else if (update.type === 'comments_update') { + } else if (update.type === 'comments_update') { this.eventEmitter.emit('commentsUpdate', update.data) - } - else if (update.type === 'node_panel_presence') { + } else if (update.type === 'node_panel_presence') { this.applyNodePanelPresenceUpdate(update.data as NodePanelPresenceEventData) - } - else if (update.type === 'sync_request') { + } else if (update.type === 'sync_request') { // Only process if we are the leader - if (this.isLeader) - this.eventEmitter.emit('syncRequest', {}) - } - else if (update.type === 'graph_resync_request') { - if (this.isLeader) - this.broadcastCurrentGraph() - } - else if (update.type === 'workflow_restore_intent') { + if (this.isLeader) this.eventEmitter.emit('syncRequest', {}) + } else if (update.type === 'graph_resync_request') { + if (this.isLeader) this.broadcastCurrentGraph() + } else if (update.type === 'workflow_restore_intent') { this.eventEmitter.emit('restoreIntent', update.data as RestoreIntentData) - } - else if (update.type === 'workflow_restore_complete') { + } else if (update.type === 'workflow_restore_complete') { this.eventEmitter.emit('restoreComplete', update.data as RestoreCompleteData) - } - else if (update.type === 'workflow_history_action') { + } else if (update.type === 'workflow_history_action') { const data = update.data as { action?: 'undo' | 'redo' | 'jump' } | undefined if (data?.action) this.eventEmitter.emit('historyAction', { action: data.action, userId: update.userId }) } }) - socket.on('online_users', (data: { users: OnlineUser[], leader?: string }) => { + socket.on('online_users', (data: { users: OnlineUser[]; leader?: string }) => { try { if (!data || !Array.isArray(data.users)) { console.warn('Invalid online_users data structure:', data) @@ -1489,21 +1438,18 @@ export class CollaborationManager { // Remove cursors for offline users Object.keys(this.cursors).forEach((userId) => { - if (!onlineUserIds.has(userId)) - delete this.cursors[userId] + if (!onlineUserIds.has(userId)) delete this.cursors[userId] }) this.cleanupNodePanelPresence(onlineClientIds) // Update leader information - if (data.leader && typeof data.leader === 'string') - this.leaderId = data.leader + if (data.leader && typeof data.leader === 'string') this.leaderId = data.leader this.onlineUsers = data.users this.eventEmitter.emit('onlineUsers', data.users) this.eventEmitter.emit('cursors', { ...this.cursors }) - } - catch (error) { + } catch (error) { console.error('Error processing online_users update:', error) } }) @@ -1521,15 +1467,12 @@ export class CollaborationManager { if (this.isLeader) { this.seedCrdtGraphFromReactFlowIfNeeded() this.pendingInitialSync = false - } - else { + } else { this.requestInitialSyncIfNeeded() } - if (wasLeader !== this.isLeader) - this.eventEmitter.emit('leaderChange', this.isLeader) - } - catch (error) { + if (wasLeader !== this.isLeader) this.eventEmitter.emit('leaderChange', this.isLeader) + } catch (error) { console.error('Error processing status update:', error) } }) @@ -1564,8 +1507,7 @@ export class CollaborationManager { // When a follower joins mid-session, it might miss earlier broadcasts and render stale data. // This lightweight checkpoint asks the leader to rebroadcast the latest graph snapshot once. private requestInitialSyncIfNeeded(): void { - if (!this.pendingInitialSync) - return + if (!this.pendingInitialSync) return if (this.isLeader) { this.pendingInitialSync = false return @@ -1576,8 +1518,7 @@ export class CollaborationManager { } private emitGraphResyncRequest(): void { - if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) - return + if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return this.sendCollaborationEvent({ type: 'graph_resync_request', @@ -1587,23 +1528,19 @@ export class CollaborationManager { } private seedCrdtGraphFromReactFlowIfNeeded(): void { - if (!this.doc) - return - if (!this.reactFlowStore) - return + if (!this.doc) return + if (!this.reactFlowStore) return // CRDT may still be empty when the canvas was initially loaded from HTTP draft data // before collaboration finished connecting, and no local mutation has been written yet. // Seed once from the current ReactFlow graph so leader resync can broadcast a full snapshot. - if (this.getNodes().length > 0 || this.getEdges().length > 0) - return + if (this.getNodes().length > 0 || this.getEdges().length > 0) return const state = this.reactFlowStore.getState() const nodes = state.getNodes() const edges = state.getEdges() - if (!nodes.length && !edges.length) - return + if (!nodes.length && !edges.length) return this.syncNodes([], nodes) this.syncEdges([], edges) @@ -1611,25 +1548,20 @@ export class CollaborationManager { } private broadcastCurrentGraph(): void { - if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) - return - if (!this.doc) - return + if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return + if (!this.doc) return const socket = webSocketClient.getSocket(this.currentAppId) - if (!socket) - return + if (!socket) return try { this.seedCrdtGraphFromReactFlowIfNeeded() - if (this.getNodes().length === 0 && this.getEdges().length === 0) - return + if (this.getNodes().length === 0 && this.getEdges().length === 0) return const snapshot = this.doc.export({ mode: 'snapshot' }) this.sendGraphEvent(snapshot) - } - catch (error) { + } catch (error) { console.error('Failed to broadcast graph snapshot:', error) } } diff --git a/web/app/components/workflow/collaboration/core/crdt-provider.ts b/web/app/components/workflow/collaboration/core/crdt-provider.ts index 53528c91706c37..8c226e36ead8ca 100644 --- a/web/app/components/workflow/collaboration/core/crdt-provider.ts +++ b/web/app/components/workflow/collaboration/core/crdt-provider.ts @@ -20,7 +20,9 @@ export class CRDTProvider { this.doc.subscribe((event: { by?: string }) => { if (event.by === 'local') { const update = this.doc.export({ mode: 'update' }) - emitWithAuthGuard(this.socket, 'graph_event', update, { onUnauthorized: this.onUnauthorized }) + emitWithAuthGuard(this.socket, 'graph_event', update, { + onUnauthorized: this.onUnauthorized, + }) } }) @@ -28,8 +30,7 @@ export class CRDTProvider { try { const data = new Uint8Array(updateData) this.doc.import(data) - } - catch (error) { + } catch (error) { console.error('Error importing graph update:', error) } }) diff --git a/web/app/components/workflow/collaboration/core/event-emitter.ts b/web/app/components/workflow/collaboration/core/event-emitter.ts index c562ae30837b84..6de3c602fe33e9 100644 --- a/web/app/components/workflow/collaboration/core/event-emitter.ts +++ b/web/app/components/workflow/collaboration/core/event-emitter.ts @@ -4,8 +4,7 @@ export class EventEmitter { private events: Map<string, Set<EventHandler<unknown>>> = new Map() on<T = unknown>(event: string, handler: EventHandler<T>): () => void { - if (!this.events.has(event)) - this.events.set(event, new Set()) + if (!this.events.has(event)) this.events.set(event, new Set()) this.events.get(event)!.add(handler as EventHandler<unknown>) @@ -13,29 +12,23 @@ export class EventEmitter { } off<T = unknown>(event: string, handler?: EventHandler<T>): void { - if (!this.events.has(event)) - return + if (!this.events.has(event)) return const handlers = this.events.get(event)! - if (handler) - handlers.delete(handler as EventHandler<unknown>) - else - handlers.clear() + if (handler) handlers.delete(handler as EventHandler<unknown>) + else handlers.clear() - if (handlers.size === 0) - this.events.delete(event) + if (handlers.size === 0) this.events.delete(event) } emit<T = unknown>(event: string, data: T): void { - if (!this.events.has(event)) - return + if (!this.events.has(event)) return const handlers = this.events.get(event)! handlers.forEach((handler) => { try { handler(data) - } - catch (error) { + } catch (error) { console.error(`Error in event handler for ${event}:`, error) } }) diff --git a/web/app/components/workflow/collaboration/core/websocket-manager.ts b/web/app/components/workflow/collaboration/core/websocket-manager.ts index 62ffe2cf0c75b4..b7c68a94361950 100644 --- a/web/app/components/workflow/collaboration/core/websocket-manager.ts +++ b/web/app/components/workflow/collaboration/core/websocket-manager.ts @@ -8,8 +8,7 @@ type AckArgs = unknown[] const isUnauthorizedAck = (...ackArgs: AckArgs): boolean => { const [first, second] = ackArgs - if (second === 401 || first === 401) - return true + if (second === 401 || first === 401) return true if (first && typeof first === 'object' && 'msg' in first) { const message = (first as { msg?: unknown }).msg @@ -30,18 +29,12 @@ export const emitWithAuthGuard = ( payload: unknown, options?: EmitAckOptions, ): void => { - if (!socket) - return - - socket.emit( - event, - payload, - (...ackArgs: AckArgs) => { - options?.onAck?.(...ackArgs) - if (isUnauthorizedAck(...ackArgs)) - options?.onUnauthorized?.(...ackArgs) - }, - ) + if (!socket) return + + socket.emit(event, payload, (...ackArgs: AckArgs) => { + options?.onAck?.(...ackArgs) + if (isUnauthorizedAck(...ackArgs)) options?.onUnauthorized?.(...ackArgs) + }) } export class WebSocketClient { @@ -59,13 +52,11 @@ export class WebSocketClient { connect(appId: string): Socket { const existingSocket = this.connections.get(appId) - if (existingSocket?.connected) - return existingSocket + if (existingSocket?.connected) return existingSocket if (this.connecting.has(appId)) { const pendingSocket = this.connections.get(appId) - if (pendingSocket) - return pendingSocket + if (pendingSocket) return pendingSocket } if (existingSocket && !existingSocket.connected) { @@ -101,9 +92,8 @@ export class WebSocketClient { this.connections.delete(appId) this.connecting.delete(appId) } - } - else { - this.connections.forEach(socket => socket.disconnect()) + } else { + this.connections.forEach((socket) => socket.disconnect()) this.connections.clear() this.connecting.clear() } @@ -120,8 +110,7 @@ export class WebSocketClient { getConnectedApps(): string[] { const connectedApps: string[] = [] this.connections.forEach((socket, appId) => { - if (socket.connected) - connectedApps.push(appId) + if (socket.connected) connectedApps.push(appId) }) return connectedApps } diff --git a/web/app/components/workflow/collaboration/hooks/__tests__/use-collaboration.spec.ts b/web/app/components/workflow/collaboration/hooks/__tests__/use-collaboration.spec.ts index 7c063ab2cf1e7a..169b45cc798b6d 100644 --- a/web/app/components/workflow/collaboration/hooks/__tests__/use-collaboration.spec.ts +++ b/web/app/components/workflow/collaboration/hooks/__tests__/use-collaboration.spec.ts @@ -4,7 +4,9 @@ import { renderHookWithSystemFeatures } from '@/__tests__/utils/mock-system-feat import { useCollaboration } from '../use-collaboration' type HookReactFlowStore = NonNullable<Parameters<typeof useCollaboration>[1]> -type HookReactFlowInstance = Parameters<ReturnType<typeof useCollaboration>['startCursorTracking']>[1] +type HookReactFlowInstance = Parameters< + ReturnType<typeof useCollaboration>['startCursorTracking'] +>[1] const mockConnect = vi.hoisted(() => vi.fn()) const mockDisconnect = vi.hoisted(() => vi.fn()) @@ -12,7 +14,9 @@ const mockIsConnected = vi.hoisted(() => vi.fn(() => true)) const mockEmitCursorMove = vi.hoisted(() => vi.fn()) const mockGetLeaderId = vi.hoisted(() => vi.fn(() => 'leader-1')) -let onStateChangeCallback: ((state: { isConnected?: boolean, disconnectReason?: string, error?: string }) => void) | null = null +let onStateChangeCallback: + | ((state: { isConnected?: boolean; disconnectReason?: string; error?: string }) => void) + | null = null let onCursorCallback: ((cursors: Record<string, CursorPosition>) => void) | null = null let onUsersCallback: ((users: OnlineUser[]) => void) | null = null let onPresenceCallback: ((presence: NodePanelPresenceMap) => void) | null = null @@ -28,7 +32,10 @@ let isCollaborationEnabled = true const mockStartTracking = vi.hoisted(() => vi.fn()) const mockStopTracking = vi.hoisted(() => vi.fn()) -const cursorServiceInstances: Array<{ startTracking: typeof mockStartTracking, stopTracking: typeof mockStopTracking }> = [] +const cursorServiceInstances: Array<{ + startTracking: typeof mockStartTracking + stopTracking: typeof mockStopTracking +}> = [] vi.mock('../../core/collaboration-manager', () => ({ collaborationManager: { @@ -37,7 +44,13 @@ vi.mock('../../core/collaboration-manager', () => ({ isConnected: () => mockIsConnected(), emitCursorMove: (...args: unknown[]) => mockEmitCursorMove(...args), getLeaderId: () => mockGetLeaderId(), - onStateChange: (callback: (state: { isConnected?: boolean, disconnectReason?: string, error?: string }) => void) => { + onStateChange: ( + callback: (state: { + isConnected?: boolean + disconnectReason?: string + error?: string + }) => void, + ) => { onStateChangeCallback = callback return unsubscribeState }, @@ -65,7 +78,10 @@ vi.mock('../../services/cursor-service', () => ({ startTracking = mockStartTracking stopTracking = mockStopTracking constructor() { - cursorServiceInstances.push({ startTracking: this.startTracking, stopTracking: this.stopTracking }) + cursorServiceInstances.push({ + startTracking: this.startTracking, + stopTracking: this.stopTracking, + }) } }, })) @@ -88,9 +104,12 @@ describe('useCollaboration', () => { const reactFlowStore: HookReactFlowStore = { getState: vi.fn(), } - const { result, unmount } = renderHookWithSystemFeatures(() => useCollaboration('app-1', reactFlowStore), { - systemFeatures: { enable_collaboration_mode: isCollaborationEnabled }, - }) + const { result, unmount } = renderHookWithSystemFeatures( + () => useCollaboration('app-1', reactFlowStore), + { + systemFeatures: { enable_collaboration_mode: isCollaborationEnabled }, + }, + ) await waitFor(() => { expect(mockConnect).toHaveBeenCalledWith('app-1', reactFlowStore) @@ -99,7 +118,9 @@ describe('useCollaboration', () => { onStateChangeCallback?.({ isConnected: true }) onUsersCallback?.([{ user_id: 'u1', username: 'U1', avatar: '', sid: 'sid-1' } as OnlineUser]) onCursorCallback?.({ u1: { x: 10, y: 20, userId: 'u1', timestamp: 1 } }) - onPresenceCallback?.({ nodeA: { sid1: { userId: 'u1', username: 'U1', clientId: 'sid1', timestamp: 1 } } }) + onPresenceCallback?.({ + nodeA: { sid1: { userId: 'u1', username: 'U1', clientId: 'sid1', timestamp: 1 } }, + }) onLeaderCallback?.(true) await waitFor(() => { @@ -118,7 +139,7 @@ describe('useCollaboration', () => { } as HookReactFlowInstance result.current.startCursorTracking(ref, reactFlowInstance) expect(mockStartTracking).toHaveBeenCalledTimes(1) - const emitPosition = mockStartTracking.mock.calls[0]?.[1] as ((position: CursorPosition) => void) + const emitPosition = mockStartTracking.mock.calls[0]?.[1] as (position: CursorPosition) => void emitPosition({ x: 1, y: 2, userId: 'u1', timestamp: 2 }) expect(mockEmitCursorMove).toHaveBeenCalledWith({ x: 1, y: 2, userId: 'u1', timestamp: 2 }) diff --git a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts index 4cc576fbf37c28..12f6436263b969 100644 --- a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts +++ b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts @@ -36,7 +36,7 @@ export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) const lastDisconnectReasonRef = useRef<string | null>(null) const { data: isCollaborationEnabled } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_collaboration_mode, + select: (s) => s.enable_collaboration_mode, }) useEffect(() => { @@ -50,8 +50,7 @@ export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) let connectionId: string | null = null let isUnmounted = false - if (!cursorServiceRef.current) - cursorServiceRef.current = new CursorService() + if (!cursorServiceRef.current) cursorServiceRef.current = new CursorService() const initCollaboration = async () => { try { @@ -61,41 +60,44 @@ export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) return } connectionId = id - setState(prev => ({ ...prev, isConnected: collaborationManager.isConnected() })) - } - catch (error) { + setState((prev) => ({ ...prev, isConnected: collaborationManager.isConnected() })) + } catch (error) { console.error('Failed to initialize collaboration:', error) } } initCollaboration() - const unsubscribeStateChange = collaborationManager.onStateChange((newState: Partial<CollaborationState>) => { - if (newState.isConnected === false) - lastDisconnectReasonRef.current = newState.disconnectReason || newState.error || null - if (newState.isConnected === true) - lastDisconnectReasonRef.current = null + const unsubscribeStateChange = collaborationManager.onStateChange( + (newState: Partial<CollaborationState>) => { + if (newState.isConnected === false) + lastDisconnectReasonRef.current = newState.disconnectReason || newState.error || null + if (newState.isConnected === true) lastDisconnectReasonRef.current = null - if (newState.isConnected === undefined) - return + if (newState.isConnected === undefined) return - setState(prev => ({ ...prev, isConnected: newState.isConnected ?? prev.isConnected })) - }) + setState((prev) => ({ ...prev, isConnected: newState.isConnected ?? prev.isConnected })) + }, + ) - const unsubscribeCursors = collaborationManager.onCursorUpdate((cursors: Record<string, CursorPosition>) => { - setState(prev => ({ ...prev, cursors })) - }) + const unsubscribeCursors = collaborationManager.onCursorUpdate( + (cursors: Record<string, CursorPosition>) => { + setState((prev) => ({ ...prev, cursors })) + }, + ) const unsubscribeUsers = collaborationManager.onOnlineUsersUpdate((users: OnlineUser[]) => { - setState(prev => ({ ...prev, onlineUsers: users })) + setState((prev) => ({ ...prev, onlineUsers: users })) }) - const unsubscribeNodePanelPresence = collaborationManager.onNodePanelPresenceUpdate((presence: NodePanelPresenceMap) => { - setState(prev => ({ ...prev, nodePanelPresence: presence })) - }) + const unsubscribeNodePanelPresence = collaborationManager.onNodePanelPresenceUpdate( + (presence: NodePanelPresenceMap) => { + setState((prev) => ({ ...prev, nodePanelPresence: presence })) + }, + ) const unsubscribeLeaderChange = collaborationManager.onLeaderChange((isLeader: boolean) => { - setState(prev => ({ ...prev, isLeader })) + setState((prev) => ({ ...prev, isLeader })) }) return () => { @@ -106,8 +108,7 @@ export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) unsubscribeNodePanelPresence() unsubscribeLeaderChange() cursorServiceRef.current?.stopTracking() - if (connectionId) - collaborationManager.disconnect(connectionId) + if (connectionId) collaborationManager.disconnect(connectionId) } }, [appId, reactFlowStore, isCollaborationEnabled]) @@ -115,22 +116,26 @@ export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) useEffect(() => { if (prevIsConnected.current && !state.isConnected) { const reason = lastDisconnectReasonRef.current - if (reason) - console.warn('WebSocket disconnected:', reason) - else - console.warn('WebSocket disconnected.') + if (reason) console.warn('WebSocket disconnected:', reason) + else console.warn('WebSocket disconnected.') } prevIsConnected.current = state.isConnected || false }, [state.isConnected]) - const startCursorTracking = (containerRef: React.RefObject<HTMLElement>, reactFlowInstance?: ReactFlowInstance) => { - if (!isCollaborationEnabled || !cursorServiceRef.current) - return + const startCursorTracking = ( + containerRef: React.RefObject<HTMLElement>, + reactFlowInstance?: ReactFlowInstance, + ) => { + if (!isCollaborationEnabled || !cursorServiceRef.current) return if (cursorServiceRef.current) { - cursorServiceRef.current.startTracking(containerRef, (position) => { - collaborationManager.emitCursorMove(position) - }, reactFlowInstance) + cursorServiceRef.current.startTracking( + containerRef, + (position) => { + collaborationManager.emitCursorMove(position) + }, + reactFlowInstance, + ) } } diff --git a/web/app/components/workflow/collaboration/services/__tests__/cursor-service.spec.ts b/web/app/components/workflow/collaboration/services/__tests__/cursor-service.spec.ts index 239435bec021bc..61da61102cac29 100644 --- a/web/app/components/workflow/collaboration/services/__tests__/cursor-service.spec.ts +++ b/web/app/components/workflow/collaboration/services/__tests__/cursor-service.spec.ts @@ -41,11 +41,13 @@ describe('CursorService', () => { container.dispatchEvent(new MouseEvent('mousemove', { clientX: 30, clientY: 50 })) expect(onEmit).toHaveBeenCalledTimes(1) - expect(onEmit).toHaveBeenLastCalledWith(expect.objectContaining({ - x: 7.5, - y: 10, - timestamp: 1000, - })) + expect(onEmit).toHaveBeenLastCalledWith( + expect.objectContaining({ + x: 7.5, + y: 10, + timestamp: 1000, + }), + ) now = 1100 container.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, clientY: 60 })) @@ -58,11 +60,13 @@ describe('CursorService', () => { now = 1800 container.dispatchEvent(new MouseEvent('mousemove', { clientX: 60, clientY: 90 })) expect(onEmit).toHaveBeenCalledTimes(2) - expect(onEmit).toHaveBeenLastCalledWith(expect.objectContaining({ - x: 22.5, - y: 30, - timestamp: 1800, - })) + expect(onEmit).toHaveBeenLastCalledWith( + expect.objectContaining({ + x: 22.5, + y: 30, + timestamp: 1800, + }), + ) }) it('stops tracking and forwards cursor updates to registered handler', () => { diff --git a/web/app/components/workflow/collaboration/services/cursor-service.ts b/web/app/components/workflow/collaboration/services/cursor-service.ts index 7af6f2f27fa72c..99144df6e7f0f1 100644 --- a/web/app/components/workflow/collaboration/services/cursor-service.ts +++ b/web/app/components/workflow/collaboration/services/cursor-service.ts @@ -12,15 +12,14 @@ export class CursorService { private onCursorUpdate: ((cursors: Record<string, CursorPosition>) => void) | null = null private onEmitPosition: ((position: CursorPosition) => void) | null = null private lastEmitTime = 0 - private lastPosition: { x: number, y: number } | null = null + private lastPosition: { x: number; y: number } | null = null startTracking( containerRef: RefObject<HTMLElement>, onEmitPosition: (position: CursorPosition) => void, reactFlowInstance?: ReactFlowInstance, ): void { - if (this.isTracking) - this.stopTracking() + if (this.isTracking) this.stopTracking() this.containerRef = containerRef this.onEmitPosition = onEmitPosition @@ -47,13 +46,11 @@ export class CursorService { } updateCursors(cursors: Record<string, CursorPosition>): void { - if (this.onCursorUpdate) - this.onCursorUpdate(cursors) + if (this.onCursorUpdate) this.onCursorUpdate(cursors) } private handleMouseMove = (event: MouseEvent): void => { - if (!this.containerRef?.current || !this.onEmitPosition) - return + if (!this.containerRef?.current || !this.onEmitPosition) return const rect = this.containerRef.current.getBoundingClientRect() let x = event.clientX - rect.left @@ -72,9 +69,10 @@ export class CursorService { const now = Date.now() const timeThrottled = now - this.lastEmitTime > CURSOR_THROTTLE_MS const minDistance = CURSOR_MIN_MOVE_DISTANCE / (this.reactFlowInstance?.getZoom() || 1) - const distanceThrottled = !this.lastPosition - || (Math.abs(x - this.lastPosition.x) > minDistance) - || (Math.abs(y - this.lastPosition.y) > minDistance) + const distanceThrottled = + !this.lastPosition || + Math.abs(x - this.lastPosition.x) > minDistance || + Math.abs(y - this.lastPosition.y) > minDistance if (timeThrottled && distanceThrottled) { this.lastPosition = { x, y } diff --git a/web/app/components/workflow/collaboration/types/collaboration.ts b/web/app/components/workflow/collaboration/types/collaboration.ts index 7c083bca188497..9ed5615cfc7fff 100644 --- a/web/app/components/workflow/collaboration/types/collaboration.ts +++ b/web/app/components/workflow/collaboration/types/collaboration.ts @@ -35,21 +35,21 @@ export type CollaborationState = { error?: string } -type CollaborationEventType - = | 'mouse_move' - | 'vars_and_features_update' - | 'sync_request' - | 'app_state_update' - | 'app_meta_update' - | 'mcp_server_update' - | 'workflow_update' - | 'comments_update' - | 'node_panel_presence' - | 'app_publish_update' - | 'graph_resync_request' - | 'workflow_restore_intent' - | 'workflow_restore_complete' - | 'workflow_history_action' +type CollaborationEventType = + | 'mouse_move' + | 'vars_and_features_update' + | 'sync_request' + | 'app_state_update' + | 'app_meta_update' + | 'mcp_server_update' + | 'workflow_update' + | 'comments_update' + | 'node_panel_presence' + | 'app_publish_update' + | 'graph_resync_request' + | 'workflow_restore_intent' + | 'workflow_restore_complete' + | 'workflow_history_action' export type CollaborationUpdate = { type: CollaborationEventType diff --git a/web/app/components/workflow/collaboration/utils/user-color.ts b/web/app/components/workflow/collaboration/utils/user-color.ts index a25e87dbe255b0..e7bfd67cabaffe 100644 --- a/web/app/components/workflow/collaboration/utils/user-color.ts +++ b/web/app/components/workflow/collaboration/utils/user-color.ts @@ -3,9 +3,21 @@ * Used for cursor colors and avatar backgrounds */ export const getUserColor = (id: string): string => { - const colors = ['#155AEF', '#0BA5EC', '#444CE7', '#7839EE', '#4CA30D', '#0E9384', '#DD2590', '#FF4405', '#D92D20', '#F79009', '#828DAD'] + const colors = [ + '#155AEF', + '#0BA5EC', + '#444CE7', + '#7839EE', + '#4CA30D', + '#0E9384', + '#DD2590', + '#FF4405', + '#D92D20', + '#F79009', + '#828DAD', + ] const hash = id.split('').reduce((a, b) => { - a = ((a << 5) - a) + b.charCodeAt(0) + a = (a << 5) - a + b.charCodeAt(0) return a & a }, 0) return colors[Math.abs(hash) % colors.length]! diff --git a/web/app/components/workflow/comment-manager.tsx b/web/app/components/workflow/comment-manager.tsx index 41175ae04dcecd..93d6f25658a128 100644 --- a/web/app/components/workflow/comment-manager.tsx +++ b/web/app/components/workflow/comment-manager.tsx @@ -7,7 +7,8 @@ const CommentManager = () => { const { handleCreateComment, handleCommentCancel } = useWorkflowComment() useEventListener('click', (e) => { - const { controlMode, mousePosition, pendingComment, isCommentPlacing } = workflowStore.getState() + const { controlMode, mousePosition, pendingComment, isCommentPlacing } = + workflowStore.getState() const target = e.target as HTMLElement const isInDropdown = target.closest('[data-mention-dropdown]') const isInCommentInput = target.closest('[data-comment-input]') @@ -31,18 +32,15 @@ const CommentManager = () => { if (!isInDropdown && !isInCommentInput && isOnCanvasPane) { e.preventDefault() e.stopPropagation() - if (pendingComment) - handleCommentCancel() - else - handleCreateComment(mousePosition) + if (pendingComment) handleCommentCancel() + else handleCreateComment(mousePosition) } } }) useEventListener('contextmenu', () => { const { isCommentPlacing } = workflowStore.getState() - if (!isCommentPlacing) - return + if (!isCommentPlacing) return workflowStore.setState({ isCommentPlacing: false, isCommentQuickAdd: false, diff --git a/web/app/components/workflow/comment/comment-icon.spec.tsx b/web/app/components/workflow/comment/comment-icon.spec.tsx index 27ba2fb80281f8..8c6144b616ffb6 100644 --- a/web/app/components/workflow/comment/comment-icon.spec.tsx +++ b/web/app/components/workflow/comment/comment-icon.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { CommentIcon } from './comment-icon' -type Position = { x: number, y: number } +type Position = { x: number; y: number } let mockUserId = 'user-1' const mockAppContextState = vi.hoisted(() => ({ @@ -81,13 +81,14 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + 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 }> }) => ( - <div data-testid="avatar-list">{users.map(user => user.id).join(',')}</div> + <div data-testid="avatar-list">{users.map((user) => user.id).join(',')}</div> ), })) @@ -146,11 +147,7 @@ describe('CommentIcon', () => { const onClick = vi.fn() const onPositionUpdate = vi.fn() const { container } = render( - <CommentIcon - comment={comment} - onClick={onClick} - onPositionUpdate={onPositionUpdate} - />, + <CommentIcon comment={comment} onClick={onClick} onPositionUpdate={onPositionUpdate} />, ) const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement diff --git a/web/app/components/workflow/comment/comment-icon.tsx b/web/app/components/workflow/comment/comment-icon.tsx index a9522f735bfcdd..187ca5159e5312 100644 --- a/web/app/components/workflow/comment/comment-icon.tsx +++ b/web/app/components/workflow/comment/comment-icon.tsx @@ -13,258 +13,291 @@ type CommentIconProps = { comment: WorkflowCommentList onClick: () => void isActive?: boolean - onPositionUpdate?: (position: { x: number, y: number }) => void + onPositionUpdate?: (position: { x: number; y: number }) => void } -export const CommentIcon: FC<CommentIconProps> = memo(({ comment, onClick, isActive = false, onPositionUpdate }) => { - const { flowToScreenPosition, screenToFlowPosition } = useReactFlow() - const viewport = useViewport() - 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) - const dragStateRef = useRef<{ - offsetX: number - offsetY: number - startX: number - startY: number - hasMoved: boolean - } | null>(null) - - const workflowContainerRect = typeof document !== 'undefined' - ? document.getElementById('workflow-container')?.getBoundingClientRect() - : null - const containerLeft = workflowContainerRect?.left ?? 0 - const containerTop = workflowContainerRect?.top ?? 0 - - const screenPosition = useMemo(() => { - return flowToScreenPosition({ - x: comment.position_x, - y: comment.position_y, - }) - }, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition]) - - const effectiveScreenPosition = dragPosition ?? screenPosition - const canvasPosition = useMemo(() => ({ - x: effectiveScreenPosition.x - containerLeft, - y: effectiveScreenPosition.y - containerTop, - }), [effectiveScreenPosition.x, effectiveScreenPosition.y, containerLeft, containerTop]) - const cursorClass = useMemo(() => { - if (!isAuthor) - return 'cursor-pointer' - if (isActive) - return isDragging ? 'cursor-grabbing' : '' - return isDragging ? 'cursor-grabbing' : 'cursor-pointer' - }, [isActive, isAuthor, isDragging]) - - const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { - if (event.button !== 0) - return - - event.stopPropagation() - event.preventDefault() - - if (!isAuthor) { - if (event.currentTarget.dataset.role !== 'comment-preview') - setShowPreview(false) - return - } - - dragStateRef.current = { - offsetX: event.clientX - screenPosition.x, - offsetY: event.clientY - screenPosition.y, - startX: event.clientX, - startY: event.clientY, - hasMoved: false, - } - - setDragPosition(screenPosition) - setIsDragging(false) - - if (event.currentTarget.dataset.role !== 'comment-preview') - setShowPreview(false) - - if (event.currentTarget.setPointerCapture) - event.currentTarget.setPointerCapture(event.pointerId) - }, [isAuthor, screenPosition]) - - const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { - const dragState = dragStateRef.current - if (!dragState) - return - - event.stopPropagation() - event.preventDefault() - - const nextX = event.clientX - dragState.offsetX - const nextY = event.clientY - dragState.offsetY - - if (!dragState.hasMoved) { - const distance = Math.hypot(event.clientX - dragState.startX, event.clientY - dragState.startY) - if (distance > 4) { - dragState.hasMoved = true - setIsDragging(true) +export const CommentIcon: FC<CommentIconProps> = memo( + ({ comment, onClick, isActive = false, onPositionUpdate }) => { + const { flowToScreenPosition, screenToFlowPosition } = useReactFlow() + const viewport = useViewport() + 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) + const dragStateRef = useRef<{ + offsetX: number + offsetY: number + startX: number + startY: number + hasMoved: boolean + } | null>(null) + + const workflowContainerRect = + typeof document !== 'undefined' + ? document.getElementById('workflow-container')?.getBoundingClientRect() + : null + const containerLeft = workflowContainerRect?.left ?? 0 + const containerTop = workflowContainerRect?.top ?? 0 + + const screenPosition = useMemo(() => { + return flowToScreenPosition({ + x: comment.position_x, + y: comment.position_y, + }) + }, [ + comment.position_x, + comment.position_y, + viewport.x, + viewport.y, + viewport.zoom, + flowToScreenPosition, + ]) + + const effectiveScreenPosition = dragPosition ?? screenPosition + const canvasPosition = useMemo( + () => ({ + x: effectiveScreenPosition.x - containerLeft, + y: effectiveScreenPosition.y - containerTop, + }), + [effectiveScreenPosition.x, effectiveScreenPosition.y, containerLeft, containerTop], + ) + const cursorClass = useMemo(() => { + if (!isAuthor) return 'cursor-pointer' + if (isActive) return isDragging ? 'cursor-grabbing' : '' + return isDragging ? 'cursor-grabbing' : 'cursor-pointer' + }, [isActive, isAuthor, isDragging]) + + const handlePointerDown = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + if (event.button !== 0) return + + event.stopPropagation() + event.preventDefault() + + if (!isAuthor) { + if (event.currentTarget.dataset.role !== 'comment-preview') setShowPreview(false) + return + } + + dragStateRef.current = { + offsetX: event.clientX - screenPosition.x, + offsetY: event.clientY - screenPosition.y, + startX: event.clientX, + startY: event.clientY, + hasMoved: false, + } + + setDragPosition(screenPosition) + setIsDragging(false) + + if (event.currentTarget.dataset.role !== 'comment-preview') setShowPreview(false) + + if (event.currentTarget.setPointerCapture) + event.currentTarget.setPointerCapture(event.pointerId) + }, + [isAuthor, screenPosition], + ) + + const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { + const dragState = dragStateRef.current + if (!dragState) return + + event.stopPropagation() + event.preventDefault() + + const nextX = event.clientX - dragState.offsetX + const nextY = event.clientY - dragState.offsetY + + if (!dragState.hasMoved) { + const distance = Math.hypot( + event.clientX - dragState.startX, + event.clientY - dragState.startY, + ) + if (distance > 4) { + dragState.hasMoved = true + setIsDragging(true) + } } - } - setDragPosition({ x: nextX, y: nextY }) - }, []) + setDragPosition({ x: nextX, y: nextY }) + }, []) - const finishDrag = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { - const dragState = dragStateRef.current - if (!dragState) - return false + const finishDrag = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { + const dragState = dragStateRef.current + if (!dragState) return false - if (event.currentTarget.hasPointerCapture?.(event.pointerId)) - event.currentTarget.releasePointerCapture(event.pointerId) + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) + event.currentTarget.releasePointerCapture(event.pointerId) - dragStateRef.current = null - setDragPosition(null) - setIsDragging(false) - return dragState.hasMoved - }, []) + dragStateRef.current = null + setDragPosition(null) + setIsDragging(false) + return dragState.hasMoved + }, []) - const handlePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { - event.stopPropagation() - event.preventDefault() + const handlePointerUp = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + event.stopPropagation() + event.preventDefault() - const finalScreenPosition = dragPosition ?? screenPosition - const didDrag = finishDrag(event) + const finalScreenPosition = dragPosition ?? screenPosition + const didDrag = finishDrag(event) - setShowPreview(false) + setShowPreview(false) - if (didDrag) { - if (onPositionUpdate) { - const flowPosition = screenToFlowPosition({ - x: finalScreenPosition.x, - y: finalScreenPosition.y, - }) - onPositionUpdate(flowPosition) - } - } - else if (!isActive) { - onClick() - } - }, [dragPosition, finishDrag, isActive, onClick, onPositionUpdate, screenPosition, screenToFlowPosition]) - - const handlePointerCancel = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { - event.stopPropagation() - event.preventDefault() - finishDrag(event) - }, [finishDrag]) - - const handleMouseEnter = useCallback(() => { - if (isActive || isDragging) - return - setShowPreview(true) - }, [isActive, isDragging]) - - const handleMouseLeave = useCallback(() => { - setShowPreview(false) - }, []) - - const participants = useMemo(() => { - const list = comment.participants ?? [] - const author = comment.created_by_account - if (!author) - return [...list] - const rest = list.filter(user => user.id !== author.id) - return [author, ...rest] - }, [comment.created_by_account, comment.participants]) - - // Calculate dynamic width based on number of participants - const participantCount = participants.length - const maxVisible = Math.min(3, participantCount) - const showCount = participantCount > 3 - const avatarSize = 24 - const avatarSpacing = 4 // -space-x-1 is about 4px overlap - - // Width calculation: first avatar + (additional avatars * (size - spacing)) + padding - const dynamicWidth = Math.max(40, // minimum width - 8 + avatarSize + Math.max(0, (showCount ? 2 : maxVisible - 1)) * (avatarSize - avatarSpacing) + 8) - - const pointerEventHandlers = useMemo(() => ({ - onPointerDown: handlePointerDown, - onPointerMove: handlePointerMove, - onPointerUp: handlePointerUp, - onPointerCancel: handlePointerCancel, - }), [handlePointerCancel, handlePointerDown, handlePointerMove, handlePointerUp]) - - return ( - <> - <div - className="absolute z-10" - style={{ - left: canvasPosition.x, - top: canvasPosition.y, - transform: 'translate(-50%, -50%)', - }} - data-role="comment-marker" - {...pointerEventHandlers} - > + if (didDrag) { + if (onPositionUpdate) { + const flowPosition = screenToFlowPosition({ + x: finalScreenPosition.x, + y: finalScreenPosition.y, + }) + onPositionUpdate(flowPosition) + } + } else if (!isActive) { + onClick() + } + }, + [ + dragPosition, + finishDrag, + isActive, + onClick, + onPositionUpdate, + screenPosition, + screenToFlowPosition, + ], + ) + + const handlePointerCancel = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + event.stopPropagation() + event.preventDefault() + finishDrag(event) + }, + [finishDrag], + ) + + const handleMouseEnter = useCallback(() => { + if (isActive || isDragging) return + setShowPreview(true) + }, [isActive, isDragging]) + + const handleMouseLeave = useCallback(() => { + setShowPreview(false) + }, []) + + const participants = useMemo(() => { + const list = comment.participants ?? [] + const author = comment.created_by_account + if (!author) return [...list] + const rest = list.filter((user) => user.id !== author.id) + return [author, ...rest] + }, [comment.created_by_account, comment.participants]) + + // Calculate dynamic width based on number of participants + const participantCount = participants.length + const maxVisible = Math.min(3, participantCount) + const showCount = participantCount > 3 + const avatarSize = 24 + const avatarSpacing = 4 // -space-x-1 is about 4px overlap + + // Width calculation: first avatar + (additional avatars * (size - spacing)) + padding + const dynamicWidth = Math.max( + 40, // minimum width + 8 + + avatarSize + + Math.max(0, showCount ? 2 : maxVisible - 1) * (avatarSize - avatarSpacing) + + 8, + ) + + const pointerEventHandlers = useMemo( + () => ({ + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + onPointerCancel: handlePointerCancel, + }), + [handlePointerCancel, handlePointerDown, handlePointerMove, handlePointerUp], + ) + + return ( + <> <div - className={cursorClass} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} + className="absolute z-10" + style={{ + left: canvasPosition.x, + top: canvasPosition.y, + transform: 'translate(-50%, -50%)', + }} + data-role="comment-marker" + {...pointerEventHandlers} > <div - className="relative h-10 rounded-t-full rounded-br-full" - style={{ width: dynamicWidth }} + className={cursorClass} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} > - <div className={`absolute inset-[6px] overflow-hidden rounded-tl-full rounded-tr-full rounded-br-full border bg-components-panel-bg transition-shadow ${ - isActive - ? 'border-primary-500 ring-1 ring-primary-500' - : 'border-components-panel-border' - }`} + <div + className="relative h-10 rounded-t-full rounded-br-full" + style={{ width: dynamicWidth }} > - <div className="flex size-full items-center justify-center px-1"> - <UserAvatarList - users={participants} - maxVisible={3} - size="sm" - className="translate-y-[-1.5px]" - /> + <div + className={`absolute inset-[6px] overflow-hidden rounded-tl-full rounded-tr-full rounded-br-full border bg-components-panel-bg transition-shadow ${ + isActive + ? 'border-primary-500 ring-1 ring-primary-500' + : 'border-components-panel-border' + }`} + > + <div className="flex size-full items-center justify-center px-1"> + <UserAvatarList + users={participants} + maxVisible={3} + size="sm" + className="translate-y-[-1.5px]" + /> + </div> </div> </div> </div> </div> - </div> - {/* Preview panel */} - {showPreview && !isActive && ( - <div - className="absolute z-20" - style={{ - left: (effectiveScreenPosition.x - containerLeft) - dynamicWidth / 2, - top: (effectiveScreenPosition.y - containerTop) + 20, - transform: 'translateY(-100%)', - }} - data-role="comment-preview" - {...pointerEventHandlers} - onMouseEnter={() => setShowPreview(true)} - onMouseLeave={() => setShowPreview(false)} - > - <CommentPreview - comment={comment} - onClick={() => { - setShowPreview(false) - onClick() + {/* Preview panel */} + {showPreview && !isActive && ( + <div + className="absolute z-20" + style={{ + left: effectiveScreenPosition.x - containerLeft - dynamicWidth / 2, + top: effectiveScreenPosition.y - containerTop + 20, + transform: 'translateY(-100%)', }} - /> - </div> - )} - </> - ) -}, (prevProps, nextProps) => { - return ( - prevProps.comment.id === nextProps.comment.id - && prevProps.comment.position_x === nextProps.comment.position_x - && prevProps.comment.position_y === nextProps.comment.position_y - && prevProps.onClick === nextProps.onClick - && prevProps.isActive === nextProps.isActive - && prevProps.onPositionUpdate === nextProps.onPositionUpdate - ) -}) + data-role="comment-preview" + {...pointerEventHandlers} + onMouseEnter={() => setShowPreview(true)} + onMouseLeave={() => setShowPreview(false)} + > + <CommentPreview + comment={comment} + onClick={() => { + setShowPreview(false) + onClick() + }} + /> + </div> + )} + </> + ) + }, + (prevProps, nextProps) => { + return ( + prevProps.comment.id === nextProps.comment.id && + prevProps.comment.position_x === nextProps.comment.position_x && + prevProps.comment.position_y === nextProps.comment.position_y && + prevProps.onClick === nextProps.onClick && + prevProps.isActive === nextProps.isActive && + prevProps.onPositionUpdate === nextProps.onPositionUpdate + ) + }, +) CommentIcon.displayName = 'CommentIcon' diff --git a/web/app/components/workflow/comment/comment-input.spec.tsx b/web/app/components/workflow/comment/comment-input.spec.tsx index 8fce32ea7dd31b..bcc19243b1e970 100644 --- a/web/app/components/workflow/comment/comment-input.spec.tsx +++ b/web/app/components/workflow/comment/comment-input.spec.tsx @@ -13,9 +13,8 @@ type MentionInputProps = { className?: string } -const stableT = (key: string, options?: { ns?: string }) => ( +const stableT = (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key -) let mentionInputProps: MentionInputProps | null = null const mockAppContextState = vi.hoisted(() => ({ @@ -28,11 +27,11 @@ const mockAppContextState = vi.hoisted(() => ({ vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey(stableT), }), - }) + } }) vi.mock('@/context/account-state', async (importOriginal) => { @@ -57,7 +56,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -88,13 +88,7 @@ describe('CommentInput', () => { }) it('passes translated placeholder to mention input', () => { - render( - <CommentInput - position={{ x: 0, y: 0 }} - onSubmit={vi.fn()} - onCancel={vi.fn()} - />, - ) + render(<CommentInput position={{ x: 0, y: 0 }} onSubmit={vi.fn()} onCancel={vi.fn()} />) expect(mentionInputProps?.placeholder).toBe('workflow.comments.placeholder.add') expect(mentionInputProps?.autoFocus).toBe(true) @@ -104,13 +98,7 @@ describe('CommentInput', () => { it('calls onCancel when Escape is pressed', () => { const onCancel = vi.fn() - render( - <CommentInput - position={{ x: 0, y: 0 }} - onSubmit={vi.fn()} - onCancel={onCancel} - />, - ) + render(<CommentInput position={{ x: 0, y: 0 }} onSubmit={vi.fn()} onCancel={onCancel} />) fireEvent.keyDown(document, { key: 'Escape' }) @@ -120,13 +108,7 @@ describe('CommentInput', () => { it('forwards mention submit to onSubmit', () => { const onSubmit = vi.fn() - render( - <CommentInput - position={{ x: 0, y: 0 }} - onSubmit={onSubmit} - onCancel={vi.fn()} - />, - ) + render(<CommentInput position={{ x: 0, y: 0 }} onSubmit={onSubmit} onCancel={vi.fn()} />) fireEvent.click(screen.getByTestId('mention-input')) diff --git a/web/app/components/workflow/comment/comment-input.tsx b/web/app/components/workflow/comment/comment-input.tsx index 7157f152ba6e8c..38545e99f23c4c 100644 --- a/web/app/components/workflow/comment/comment-input.tsx +++ b/web/app/components/workflow/comment/comment-input.tsx @@ -8,7 +8,7 @@ import { userProfileAtom } from '@/context/account-state' import { MentionInput } from './mention-input' type CommentInputProps = { - position: { x: number, y: number } + position: { x: number; y: number } onSubmit: (content: string, mentionedUserIds: string[]) => void onCancel: () => void autoFocus?: boolean @@ -21,165 +21,166 @@ type CommentInputProps = { }) => void } -export const CommentInput: FC<CommentInputProps> = memo(({ - position, - onSubmit, - onCancel, - autoFocus = true, - disabled = false, - onPositionChange, -}) => { - const [content, setContent] = useState('') - const { t } = useTranslation() - const userProfile = useAtomValue(userProfileAtom) - const dragStateRef = useRef<{ - pointerId: number | null - startPointerX: number - startPointerY: number - startX: number - startY: number - active: boolean - } & { - endHandler?: (event: PointerEvent) => void - }>({ - pointerId: null, - startPointerX: 0, - startPointerY: 0, - startX: 0, - startY: 0, - active: false, - endHandler: undefined, - }) +export const CommentInput: FC<CommentInputProps> = memo( + ({ position, onSubmit, onCancel, autoFocus = true, disabled = false, onPositionChange }) => { + const [content, setContent] = useState('') + const { t } = useTranslation() + const userProfile = useAtomValue(userProfileAtom) + const dragStateRef = useRef< + { + pointerId: number | null + startPointerX: number + startPointerY: number + startX: number + startY: number + active: boolean + } & { + endHandler?: (event: PointerEvent) => void + } + >({ + pointerId: null, + startPointerX: 0, + startPointerY: 0, + startX: 0, + startY: 0, + active: false, + endHandler: undefined, + }) - useEffect(() => { - const handleGlobalKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - e.stopPropagation() - onCancel() + useEffect(() => { + const handleGlobalKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + onCancel() + } } - } - document.addEventListener('keydown', handleGlobalKeyDown, true) - return () => { - document.removeEventListener('keydown', handleGlobalKeyDown, true) - } - }, [onCancel]) + document.addEventListener('keydown', handleGlobalKeyDown, true) + return () => { + document.removeEventListener('keydown', handleGlobalKeyDown, true) + } + }, [onCancel]) - const handleMentionSubmit = useCallback((content: string, mentionedUserIds: string[]) => { - onSubmit(content, mentionedUserIds) - setContent('') - }, [onSubmit]) + const handleMentionSubmit = useCallback( + (content: string, mentionedUserIds: string[]) => { + onSubmit(content, mentionedUserIds) + setContent('') + }, + [onSubmit], + ) - const handleDragPointerMove = useCallback((event: PointerEvent) => { - const state = dragStateRef.current - if (!state.active || (state.pointerId !== null && event.pointerId !== state.pointerId)) - return - if (!onPositionChange) - return - event.preventDefault() - const deltaX = event.clientX - state.startPointerX - const deltaY = event.clientY - state.startPointerY - onPositionChange({ - pageX: event.clientX, - pageY: event.clientY, - elementX: state.startX + deltaX, - elementY: state.startY + deltaY, - }) - }, [onPositionChange]) + const handleDragPointerMove = useCallback( + (event: PointerEvent) => { + const state = dragStateRef.current + if (!state.active || (state.pointerId !== null && event.pointerId !== state.pointerId)) + return + if (!onPositionChange) return + event.preventDefault() + const deltaX = event.clientX - state.startPointerX + const deltaY = event.clientY - state.startPointerY + onPositionChange({ + pageX: event.clientX, + pageY: event.clientY, + elementX: state.startX + deltaX, + elementY: state.startY + deltaY, + }) + }, + [onPositionChange], + ) - const stopDragging = useCallback((event?: PointerEvent) => { - const state = dragStateRef.current - if (!state.active) - return - if (event && state.pointerId !== null && event.pointerId !== state.pointerId) - return - state.active = false - state.pointerId = null - window.removeEventListener('pointermove', handleDragPointerMove) - if (state.endHandler) { - window.removeEventListener('pointerup', state.endHandler) - window.removeEventListener('pointercancel', state.endHandler) - state.endHandler = undefined - } - }, [handleDragPointerMove]) + const stopDragging = useCallback( + (event?: PointerEvent) => { + const state = dragStateRef.current + if (!state.active) return + if (event && state.pointerId !== null && event.pointerId !== state.pointerId) return + state.active = false + state.pointerId = null + window.removeEventListener('pointermove', handleDragPointerMove) + if (state.endHandler) { + window.removeEventListener('pointerup', state.endHandler) + window.removeEventListener('pointercancel', state.endHandler) + state.endHandler = undefined + } + }, + [handleDragPointerMove], + ) - const handleDragPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { - if (event.button !== 0) - return - event.stopPropagation() - event.preventDefault() - if (!onPositionChange) - return - const endHandler = (pointerEvent: PointerEvent) => { - stopDragging(pointerEvent) - } - dragStateRef.current = { - pointerId: event.pointerId, - startPointerX: event.clientX, - startPointerY: event.clientY, - startX: position.x, - startY: position.y, - active: true, - endHandler, - } - window.addEventListener('pointermove', handleDragPointerMove, { passive: false }) - window.addEventListener('pointerup', endHandler) - window.addEventListener('pointercancel', endHandler) - }, [handleDragPointerMove, onPositionChange, position.x, position.y, stopDragging]) + const handleDragPointerDown = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + if (event.button !== 0) return + event.stopPropagation() + event.preventDefault() + if (!onPositionChange) return + const endHandler = (pointerEvent: PointerEvent) => { + stopDragging(pointerEvent) + } + dragStateRef.current = { + pointerId: event.pointerId, + startPointerX: event.clientX, + startPointerY: event.clientY, + startX: position.x, + startY: position.y, + active: true, + endHandler, + } + window.addEventListener('pointermove', handleDragPointerMove, { passive: false }) + window.addEventListener('pointerup', endHandler) + window.addEventListener('pointercancel', endHandler) + }, + [handleDragPointerMove, onPositionChange, position.x, position.y, stopDragging], + ) - useEffect(() => () => { - stopDragging() - }, [stopDragging]) + useEffect( + () => () => { + stopDragging() + }, + [stopDragging], + ) - return ( - <div - className={cn( - 'absolute z-40 w-96', - disabled && 'pointer-events-none opacity-80', - )} - style={{ - left: position.x, - top: position.y, - }} - data-comment-input - > - <div className="flex items-center gap-3"> - <div - className="relative shrink-0 cursor-move" - onPointerDown={handleDragPointerDown} - > - <div className="relative aspect-square h-8 w-8 shrink-0 rounded-tl-full rounded-tr-full rounded-br-full bg-primary-500 p-[2px]"> - <div className="flex size-full items-center justify-center overflow-hidden rounded-tl-full rounded-tr-full rounded-br-full bg-components-panel-bg-blur p-[2px]"> - <Avatar - avatar={userProfile.avatar_url} - name={userProfile.name} - size="sm" - className="block size-full rounded-full" - /> + return ( + <div + className={cn('absolute z-40 w-96', disabled && 'pointer-events-none opacity-80')} + style={{ + left: position.x, + top: position.y, + }} + data-comment-input + > + <div className="flex items-center gap-3"> + <div className="relative shrink-0 cursor-move" onPointerDown={handleDragPointerDown}> + <div className="relative aspect-square h-8 w-8 shrink-0 rounded-tl-full rounded-tr-full rounded-br-full bg-primary-500 p-[2px]"> + <div className="flex size-full items-center justify-center overflow-hidden rounded-tl-full rounded-tr-full rounded-br-full bg-components-panel-bg-blur p-[2px]"> + <Avatar + avatar={userProfile.avatar_url} + name={userProfile.name} + size="sm" + className="block size-full rounded-full" + /> + </div> </div> </div> - </div> - <div - className={cn( - 'relative z-10 flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[4px] shadow-md', - )} - > - <div className="relative pt-[4px] pl-[9px]"> - <MentionInput - value={content} - onChange={setContent} - onSubmit={handleMentionSubmit} - placeholder={t($ => $['comments.placeholder.add'], { ns: 'workflow' })} - autoFocus={autoFocus} - disabled={disabled} - className="relative" - /> + <div + className={cn( + 'relative z-10 flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[4px] shadow-md', + )} + > + <div className="relative pt-[4px] pl-[9px]"> + <MentionInput + value={content} + onChange={setContent} + onSubmit={handleMentionSubmit} + placeholder={t(($) => $['comments.placeholder.add'], { ns: 'workflow' })} + autoFocus={autoFocus} + disabled={disabled} + className="relative" + /> + </div> </div> </div> </div> - </div> - ) -}) + ) + }, +) CommentInput.displayName = 'CommentInput' diff --git a/web/app/components/workflow/comment/comment-preview.spec.tsx b/web/app/components/workflow/comment/comment-preview.spec.tsx index 5ba5e5972f2edc..819cfb891118c5 100644 --- a/web/app/components/workflow/comment/comment-preview.spec.tsx +++ b/web/app/components/workflow/comment/comment-preview.spec.tsx @@ -11,7 +11,7 @@ let capturedUsers: UserProfile[] = [] vi.mock('@/app/components/base/user-avatar-list', () => ({ UserAvatarList: ({ users }: { users: UserProfile[] }) => { capturedUsers = users - return <div data-testid="avatar-list">{users.map(user => user.id).join(',')}</div> + return <div data-testid="avatar-list">{users.map((user) => user.id).join(',')}</div> }, })) @@ -22,8 +22,9 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ })) vi.mock('../store', () => ({ - useStore: (selector: (state: { setCommentPreviewHovering: (value: boolean) => void }) => unknown) => - selector({ setCommentPreviewHovering: mockSetHovering }), + useStore: ( + selector: (state: { setCommentPreviewHovering: (value: boolean) => void }) => unknown, + ) => selector({ setCommentPreviewHovering: mockSetHovering }), })) const createComment = (overrides: Partial<WorkflowCommentList> = {}): WorkflowCommentList => { @@ -58,7 +59,7 @@ describe('CommentPreview', () => { render(<CommentPreview comment={comment} />) - expect(capturedUsers.map(user => user.id)).toEqual(['user-1', 'user-2']) + expect(capturedUsers.map((user) => user.id)).toEqual(['user-1', 'user-2']) expect(screen.getByText('Hello')).toBeInTheDocument() expect(screen.getByText('time:10000')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/comment/comment-preview.tsx b/web/app/components/workflow/comment/comment-preview.tsx index 467c5adcb1b1fb..cf8e0f5915c01d 100644 --- a/web/app/components/workflow/comment/comment-preview.tsx +++ b/web/app/components/workflow/comment/comment-preview.tsx @@ -14,19 +14,21 @@ type CommentPreviewProps = { const CommentPreview: FC<CommentPreviewProps> = ({ comment, onClick }) => { const { formatTimeFromNow } = useFormatTimeFromNow() - const setCommentPreviewHovering = useStore(s => s.setCommentPreviewHovering) + const setCommentPreviewHovering = useStore((s) => s.setCommentPreviewHovering) const authorName = comment.created_by_account?.name ?? '' const participants = useMemo(() => { const list = comment.participants ?? [] const author = comment.created_by_account - if (!author) - return [...list] - const rest = list.filter(user => user.id !== author.id) + if (!author) return [...list] + const rest = list.filter((user) => user.id !== author.id) return [author, ...rest] }, [comment.created_by_account, comment.participants]) - useEffect(() => () => { - setCommentPreviewHovering(false) - }, [setCommentPreviewHovering]) + useEffect( + () => () => { + setCommentPreviewHovering(false) + }, + [setCommentPreviewHovering], + ) return ( <div @@ -36,11 +38,7 @@ const CommentPreview: FC<CommentPreviewProps> = ({ comment, onClick }) => { onMouseLeave={() => setCommentPreviewHovering(false)} > <div className="mb-3 flex items-center justify-between"> - <UserAvatarList - users={participants} - maxVisible={3} - size="sm" - /> + <UserAvatarList users={participants} maxVisible={3} size="sm" /> </div> <div className="mb-2 flex items-start"> diff --git a/web/app/components/workflow/comment/cursor.tsx b/web/app/components/workflow/comment/cursor.tsx index d4693be9dd08b5..dc97536fb0a8e1 100644 --- a/web/app/components/workflow/comment/cursor.tsx +++ b/web/app/components/workflow/comment/cursor.tsx @@ -5,12 +5,11 @@ import { useStore } from '../store' import { ControlMode } from '../types' export const CommentCursor: FC = memo(() => { - const controlMode = useStore(s => s.controlMode) - const mousePosition = useStore(s => s.mousePosition) - const isCommentPlacing = useStore(s => s.isCommentPlacing) + const controlMode = useStore((s) => s.controlMode) + const mousePosition = useStore((s) => s.mousePosition) + const isCommentPlacing = useStore((s) => s.isCommentPlacing) - if (controlMode !== ControlMode.Comment || isCommentPlacing) - return null + if (controlMode !== ControlMode.Comment || isCommentPlacing) return null return ( <div diff --git a/web/app/components/workflow/comment/mention-input.spec.tsx b/web/app/components/workflow/comment/mention-input.spec.tsx index 1fde65622113ea..70cda743c4d163 100644 --- a/web/app/components/workflow/comment/mention-input.spec.tsx +++ b/web/app/components/workflow/comment/mention-input.spec.tsx @@ -71,13 +71,7 @@ function ControlledMentionInput({ onSubmit: (content: string, mentionedUserIds: string[]) => void }) { const [value, setValue] = useState('') - return ( - <MentionInput - value={value} - onChange={setValue} - onSubmit={onSubmit} - /> - ) + return <MentionInput value={value} onChange={setValue} onSubmit={onSubmit} /> } describe('MentionInput', () => { @@ -89,13 +83,7 @@ describe('MentionInput', () => { }) it('loads mentionable users when cache is empty', async () => { - render( - <MentionInput - value="" - onChange={vi.fn()} - onSubmit={vi.fn()} - />, - ) + render(<MentionInput value="" onChange={vi.fn()} onSubmit={vi.fn()} />) await waitFor(() => { expect(mockFetchMentionableUsers).toHaveBeenCalledWith({ @@ -114,7 +102,9 @@ describe('MentionInput', () => { render(<ControlledMentionInput onSubmit={onSubmit} />) - const textarea = screen.getByPlaceholderText('workflow.comments.placeholder.add') as HTMLTextAreaElement + const textarea = screen.getByPlaceholderText( + 'workflow.comments.placeholder.add', + ) as HTMLTextAreaElement textarea.focus() textarea.setSelectionRange(4, 4) fireEvent.change(textarea, { target: { value: '@Ali' } }) @@ -162,15 +152,12 @@ describe('MentionInput', () => { mentionStoreState.mentionableUsersCache['app-1'] = mentionUsers const { unmount } = render( - <MentionInput - value="draft" - onChange={vi.fn()} - onSubmit={vi.fn()} - autoFocus - />, + <MentionInput value="draft" onChange={vi.fn()} onSubmit={vi.fn()} autoFocus />, ) - const textarea = screen.getByPlaceholderText('workflow.comments.placeholder.add') as HTMLTextAreaElement + const textarea = screen.getByPlaceholderText( + 'workflow.comments.placeholder.add', + ) as HTMLTextAreaElement act(() => { vi.runOnlyPendingTimers() @@ -181,8 +168,7 @@ describe('MentionInput', () => { expect(textarea.selectionEnd).toBe(5) unmount() - } - finally { + } finally { vi.useRealTimers() } }) diff --git a/web/app/components/workflow/comment/mention-input.tsx b/web/app/components/workflow/comment/mention-input.tsx index 6395af2a7d556d..2aeaa764737338 100644 --- a/web/app/components/workflow/comment/mention-input.tsx +++ b/web/app/components/workflow/comment/mention-input.tsx @@ -40,628 +40,641 @@ type MentionInputProps = { const EMPTY_USERS: UserProfile[] = [] -const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(({ - value, - onChange, - onSubmit, - onCancel, - placeholder, - disabled = false, - loading = false, - className, - isEditing = false, - autoFocus = false, -}, forwardedRef) => { - const params = useParams() - const { t } = useTranslation() - const appId = params.appId as string - const textareaRef = useRef<HTMLTextAreaElement>(null) - const highlightContentRef = useRef<HTMLDivElement>(null) - const actionContainerRef = useRef<HTMLDivElement | null>(null) - const actionRightRef = useRef<HTMLDivElement | null>(null) - const baseTextareaHeightRef = useRef<number | null>(null) - - // Expose textarea ref to parent component - useImperativeHandle(forwardedRef, () => textareaRef.current!, []) - - const workflowStore = useWorkflowStore() - const mentionUsersFromStore = useStore(state => ( - appId ? state.mentionableUsersCache[appId] : undefined - )) - const mentionUsers = useMemo(() => mentionUsersFromStore ?? EMPTY_USERS, [mentionUsersFromStore]) - - const [showMentionDropdown, setShowMentionDropdown] = useState(false) - const [mentionQuery, setMentionQuery] = useState('') - const [mentionPosition, setMentionPosition] = useState(0) - const [selectedMentionIndex, setSelectedMentionIndex] = useState(0) - const [mentionedUserIds, setMentionedUserIds] = useState<string[]>([]) - const resolvedPlaceholder = placeholder ?? t($ => $['comments.placeholder.add'], { ns: 'workflow' }) - const BASE_PADDING = 4 - const [shouldReserveButtonGap, setShouldReserveButtonGap] = useState(isEditing) - const [shouldReserveHorizontalSpace, setShouldReserveHorizontalSpace] = useState(() => !isEditing) - const [paddingRight, setPaddingRight] = useState(() => BASE_PADDING + (isEditing ? 0 : 48)) - const [paddingBottom, setPaddingBottom] = useState(() => BASE_PADDING + (isEditing ? 32 : 0)) - - const mentionNameList = useMemo(() => { - const names = mentionUsers - .map(user => user.name?.trim()) - .filter((name): name is string => Boolean(name)) - - const uniqueNames = Array.from(new Set(names)) - uniqueNames.sort((a, b) => b.length - a.length) - return uniqueNames - }, [mentionUsers]) - - const highlightedValue = useMemo<ReactNode>(() => { - if (!value) - return '' - - if (mentionNameList.length === 0) - return value - - const segments: ReactNode[] = [] - let cursor = 0 - let hasMention = false - - while (cursor < value.length) { - let nextMatchStart = -1 - let matchedName = '' - - for (const name of mentionNameList) { - const searchStart = value.indexOf(`@${name}`, cursor) - if (searchStart === -1) - continue - - const previousChar = searchStart > 0 ? value[searchStart - 1] : '' - if (searchStart > 0 && !/\s/.test(previousChar!)) - continue - - if ( - nextMatchStart === -1 - || searchStart < nextMatchStart - || (searchStart === nextMatchStart && name.length > matchedName.length) - ) { - nextMatchStart = searchStart - matchedName = name +const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>( + ( + { + value, + onChange, + onSubmit, + onCancel, + placeholder, + disabled = false, + loading = false, + className, + isEditing = false, + autoFocus = false, + }, + forwardedRef, + ) => { + const params = useParams() + const { t } = useTranslation() + const appId = params.appId as string + const textareaRef = useRef<HTMLTextAreaElement>(null) + const highlightContentRef = useRef<HTMLDivElement>(null) + const actionContainerRef = useRef<HTMLDivElement | null>(null) + const actionRightRef = useRef<HTMLDivElement | null>(null) + const baseTextareaHeightRef = useRef<number | null>(null) + + // Expose textarea ref to parent component + useImperativeHandle(forwardedRef, () => textareaRef.current!, []) + + const workflowStore = useWorkflowStore() + const mentionUsersFromStore = useStore((state) => + appId ? state.mentionableUsersCache[appId] : undefined, + ) + const mentionUsers = useMemo( + () => mentionUsersFromStore ?? EMPTY_USERS, + [mentionUsersFromStore], + ) + + const [showMentionDropdown, setShowMentionDropdown] = useState(false) + const [mentionQuery, setMentionQuery] = useState('') + const [mentionPosition, setMentionPosition] = useState(0) + const [selectedMentionIndex, setSelectedMentionIndex] = useState(0) + const [mentionedUserIds, setMentionedUserIds] = useState<string[]>([]) + const resolvedPlaceholder = + placeholder ?? t(($) => $['comments.placeholder.add'], { ns: 'workflow' }) + const BASE_PADDING = 4 + const [shouldReserveButtonGap, setShouldReserveButtonGap] = useState(isEditing) + const [shouldReserveHorizontalSpace, setShouldReserveHorizontalSpace] = useState( + () => !isEditing, + ) + const [paddingRight, setPaddingRight] = useState(() => BASE_PADDING + (isEditing ? 0 : 48)) + const [paddingBottom, setPaddingBottom] = useState(() => BASE_PADDING + (isEditing ? 32 : 0)) + + const mentionNameList = useMemo(() => { + const names = mentionUsers + .map((user) => user.name?.trim()) + .filter((name): name is string => Boolean(name)) + + const uniqueNames = Array.from(new Set(names)) + uniqueNames.sort((a, b) => b.length - a.length) + return uniqueNames + }, [mentionUsers]) + + const highlightedValue = useMemo<ReactNode>(() => { + if (!value) return '' + + if (mentionNameList.length === 0) return value + + const segments: ReactNode[] = [] + let cursor = 0 + let hasMention = false + + while (cursor < value.length) { + let nextMatchStart = -1 + let matchedName = '' + + for (const name of mentionNameList) { + const searchStart = value.indexOf(`@${name}`, cursor) + if (searchStart === -1) continue + + const previousChar = searchStart > 0 ? value[searchStart - 1] : '' + if (searchStart > 0 && !/\s/.test(previousChar!)) continue + + if ( + nextMatchStart === -1 || + searchStart < nextMatchStart || + (searchStart === nextMatchStart && name.length > matchedName.length) + ) { + nextMatchStart = searchStart + matchedName = name + } } + + if (nextMatchStart === -1) break + + if (nextMatchStart > cursor) + segments.push(<span key={`text-${cursor}`}>{value.slice(cursor, nextMatchStart)}</span>) + + const mentionEnd = nextMatchStart + matchedName.length + 1 + segments.push( + <span key={`mention-${nextMatchStart}`} className="text-primary-600"> + {value.slice(nextMatchStart, mentionEnd)} + </span>, + ) + + hasMention = true + cursor = mentionEnd } - if (nextMatchStart === -1) - break + if (!hasMention) return value - if (nextMatchStart > cursor) - segments.push(<span key={`text-${cursor}`}>{value.slice(cursor, nextMatchStart)}</span>) + if (cursor < value.length) + segments.push(<span key={`text-${cursor}`}>{value.slice(cursor)}</span>) - const mentionEnd = nextMatchStart + matchedName.length + 1 - segments.push( - <span key={`mention-${nextMatchStart}`} className="text-primary-600"> - {value.slice(nextMatchStart, mentionEnd)} - </span>, - ) + return segments + }, [value, mentionNameList]) + + const loadMentionableUsers = useCallback(async () => { + if (!appId) return - hasMention = true - cursor = mentionEnd - } + const state = workflowStore.getState() + if (state.mentionableUsersCache[appId] !== undefined) return - if (!hasMention) - return value + if (state.mentionableUsersLoading[appId]) return - if (cursor < value.length) - segments.push(<span key={`text-${cursor}`}>{value.slice(cursor)}</span>) + state.setMentionableUsersLoading(appId, true) + try { + const response = await consoleClient.apps.byAppId.workflow.comments.mentionUsers.get({ + params: { app_id: appId }, + }) + workflowStore.getState().setMentionableUsersCache(appId, response.users) + } catch (error) { + console.error('Failed to load mentionable users:', error) + } finally { + workflowStore.getState().setMentionableUsersLoading(appId, false) + } + }, [appId, workflowStore]) + + useEffect(() => { + loadMentionableUsers() + }, [loadMentionableUsers]) + const syncHighlightScroll = useCallback(() => { + const textarea = textareaRef.current + const highlightContent = highlightContentRef.current + if (!textarea || !highlightContent) return + + const { scrollTop, scrollLeft } = textarea + highlightContent.style.transform = `translate(${-scrollLeft}px, ${-scrollTop}px)` + }, []) + + const evaluateContentLayout = useCallback(() => { + const textarea = textareaRef.current + if (!textarea) return + + const extraBottom = Math.max(0, paddingBottom - BASE_PADDING) + const effectiveClientHeight = textarea.clientHeight - extraBottom + + if (baseTextareaHeightRef.current === null) + baseTextareaHeightRef.current = effectiveClientHeight + + const baseHeight = baseTextareaHeightRef.current ?? effectiveClientHeight + const hasMultiline = effectiveClientHeight > baseHeight + 1 + const shouldReserveVertical = isEditing ? true : hasMultiline + + setShouldReserveButtonGap(shouldReserveVertical) + setShouldReserveHorizontalSpace(!hasMultiline) + }, [isEditing, paddingBottom]) + + const updateLayoutPadding = useCallback(() => { + const actionEl = actionContainerRef.current + const rect = actionEl?.getBoundingClientRect() + const rightRect = actionRightRef.current?.getBoundingClientRect() + let actionWidth = 0 + if (rightRect) actionWidth = Math.ceil(rightRect.width) + else if (rect) actionWidth = Math.ceil(rect.width) + + const actionHeight = rect ? Math.ceil(rect.height) : 0 + const fallbackWidth = Math.max(0, paddingRight - BASE_PADDING) + const fallbackHeight = Math.max(0, paddingBottom - BASE_PADDING) + const effectiveWidth = actionWidth > 0 ? actionWidth : fallbackWidth + const effectiveHeight = actionHeight > 0 ? actionHeight : fallbackHeight + + const nextRight = BASE_PADDING + (shouldReserveHorizontalSpace ? effectiveWidth : 0) + const nextBottom = BASE_PADDING + (shouldReserveButtonGap ? effectiveHeight : 0) + + setPaddingRight((prev) => (prev === nextRight ? prev : nextRight)) + setPaddingBottom((prev) => (prev === nextBottom ? prev : nextBottom)) + }, [shouldReserveButtonGap, shouldReserveHorizontalSpace, paddingRight, paddingBottom]) + + const setActionContainerRef = useCallback( + (node: HTMLDivElement | null) => { + actionContainerRef.current = node + + if (!isEditing) actionRightRef.current = node + else if (!node) actionRightRef.current = null + + if (node && typeof window !== 'undefined') + window.requestAnimationFrame(() => updateLayoutPadding()) + }, + [isEditing, updateLayoutPadding], + ) - return segments - }, [value, mentionNameList]) + const setActionRightRef = useCallback( + (node: HTMLDivElement | null) => { + actionRightRef.current = node - const loadMentionableUsers = useCallback(async () => { - if (!appId) - return + if (node && typeof window !== 'undefined') + window.requestAnimationFrame(() => updateLayoutPadding()) + }, + [updateLayoutPadding], + ) - const state = workflowStore.getState() - if (state.mentionableUsersCache[appId] !== undefined) - return + useLayoutEffect(() => { + syncHighlightScroll() + }, [value, syncHighlightScroll]) - if (state.mentionableUsersLoading[appId]) - return + useLayoutEffect(() => { + Promise.resolve().then(() => { + evaluateContentLayout() + }) + }, [value, evaluateContentLayout]) - state.setMentionableUsersLoading(appId, true) - try { - const response = await consoleClient.apps.byAppId.workflow.comments.mentionUsers.get({ - params: { app_id: appId }, + useLayoutEffect(() => { + Promise.resolve().then(() => { + updateLayoutPadding() }) - workflowStore.getState().setMentionableUsersCache(appId, response.users) - } - catch (error) { - console.error('Failed to load mentionable users:', error) - } - finally { - workflowStore.getState().setMentionableUsersLoading(appId, false) - } - }, [appId, workflowStore]) - - useEffect(() => { - loadMentionableUsers() - }, [loadMentionableUsers]) - const syncHighlightScroll = useCallback(() => { - const textarea = textareaRef.current - const highlightContent = highlightContentRef.current - if (!textarea || !highlightContent) - return - - const { scrollTop, scrollLeft } = textarea - highlightContent.style.transform = `translate(${-scrollLeft}px, ${-scrollTop}px)` - }, []) - - const evaluateContentLayout = useCallback(() => { - const textarea = textareaRef.current - if (!textarea) - return - - const extraBottom = Math.max(0, paddingBottom - BASE_PADDING) - const effectiveClientHeight = textarea.clientHeight - extraBottom - - if (baseTextareaHeightRef.current === null) - baseTextareaHeightRef.current = effectiveClientHeight - - const baseHeight = baseTextareaHeightRef.current ?? effectiveClientHeight - const hasMultiline = effectiveClientHeight > baseHeight + 1 - const shouldReserveVertical = isEditing ? true : hasMultiline - - setShouldReserveButtonGap(shouldReserveVertical) - setShouldReserveHorizontalSpace(!hasMultiline) - }, [isEditing, paddingBottom]) - - const updateLayoutPadding = useCallback(() => { - const actionEl = actionContainerRef.current - const rect = actionEl?.getBoundingClientRect() - const rightRect = actionRightRef.current?.getBoundingClientRect() - let actionWidth = 0 - if (rightRect) - actionWidth = Math.ceil(rightRect.width) - else if (rect) - actionWidth = Math.ceil(rect.width) - - const actionHeight = rect ? Math.ceil(rect.height) : 0 - const fallbackWidth = Math.max(0, paddingRight - BASE_PADDING) - const fallbackHeight = Math.max(0, paddingBottom - BASE_PADDING) - const effectiveWidth = actionWidth > 0 ? actionWidth : fallbackWidth - const effectiveHeight = actionHeight > 0 ? actionHeight : fallbackHeight - - const nextRight = BASE_PADDING + (shouldReserveHorizontalSpace ? effectiveWidth : 0) - const nextBottom = BASE_PADDING + (shouldReserveButtonGap ? effectiveHeight : 0) - - setPaddingRight(prev => (prev === nextRight ? prev : nextRight)) - setPaddingBottom(prev => (prev === nextBottom ? prev : nextBottom)) - }, [shouldReserveButtonGap, shouldReserveHorizontalSpace, paddingRight, paddingBottom]) - - const setActionContainerRef = useCallback((node: HTMLDivElement | null) => { - actionContainerRef.current = node - - if (!isEditing) - actionRightRef.current = node - else if (!node) - actionRightRef.current = null - - if (node && typeof window !== 'undefined') - window.requestAnimationFrame(() => updateLayoutPadding()) - }, [isEditing, updateLayoutPadding]) - - const setActionRightRef = useCallback((node: HTMLDivElement | null) => { - actionRightRef.current = node - - if (node && typeof window !== 'undefined') - window.requestAnimationFrame(() => updateLayoutPadding()) - }, [updateLayoutPadding]) - - useLayoutEffect(() => { - syncHighlightScroll() - }, [value, syncHighlightScroll]) - - useLayoutEffect(() => { - Promise.resolve().then(() => { - evaluateContentLayout() - }) - }, [value, evaluateContentLayout]) - - useLayoutEffect(() => { - Promise.resolve().then(() => { - updateLayoutPadding() - }) - }, [updateLayoutPadding, isEditing, shouldReserveButtonGap]) - - useEffect(() => { - const handleResize = () => { - evaluateContentLayout() - updateLayoutPadding() - } - - window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleResize) - }, [evaluateContentLayout, updateLayoutPadding]) - - useEffect(() => { - Promise.resolve().then(() => { - baseTextareaHeightRef.current = null - evaluateContentLayout() - setShouldReserveHorizontalSpace(!isEditing) - }) - }, [isEditing, evaluateContentLayout]) - - const filteredMentionUsers = useMemo(() => { - if (!mentionQuery) - return mentionUsers - return mentionUsers.filter(user => - user.name.toLowerCase().includes(mentionQuery.toLowerCase()) - || user.email.toLowerCase().includes(mentionQuery.toLowerCase()), - ) - }, [mentionUsers, mentionQuery]) - - const shouldDisableMentionButton = useMemo(() => { - if (showMentionDropdown) - return true - - const textarea = textareaRef.current - if (!textarea) - return false - - const cursorPosition = textarea.selectionStart || 0 - const textBeforeCursor = value.slice(0, cursorPosition) - return /@\w*$/.test(textBeforeCursor) - }, [showMentionDropdown, value]) - - const dropdownPosition = useMemo(() => { - if (!showMentionDropdown || !textareaRef.current) - return { x: 0, y: 0, placement: 'bottom' as const } - - const textareaRect = textareaRef.current.getBoundingClientRect() - const dropdownHeight = 160 // max-h-40 = 10rem = 160px - const viewportHeight = window.innerHeight - const spaceBelow = viewportHeight - textareaRect.bottom - const spaceAbove = textareaRect.top - - const shouldPlaceAbove = spaceBelow < dropdownHeight && spaceAbove > spaceBelow - - return { - x: textareaRect.left, - y: shouldPlaceAbove ? textareaRect.top - 4 : textareaRect.bottom + 4, - placement: shouldPlaceAbove ? 'top' as const : 'bottom' as const, - } - }, [showMentionDropdown]) - - const handleContentChange = useCallback((newValue: string) => { - onChange(newValue) - - setTimeout(() => { - const cursorPosition = textareaRef.current?.selectionStart || 0 - const textBeforeCursor = newValue.slice(0, cursorPosition) - const mentionMatch = textBeforeCursor.match(/@(\w*)$/) - - if (mentionMatch) { - setMentionQuery(mentionMatch[1]!) - setMentionPosition(cursorPosition - mentionMatch[0].length) - setShowMentionDropdown(true) - setSelectedMentionIndex(0) - } - else { - setShowMentionDropdown(false) + }, [updateLayoutPadding, isEditing, shouldReserveButtonGap]) + + useEffect(() => { + const handleResize = () => { + evaluateContentLayout() + updateLayoutPadding() } - if (typeof window !== 'undefined') { - window.requestAnimationFrame(() => { - evaluateContentLayout() - syncHighlightScroll() - }) + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, [evaluateContentLayout, updateLayoutPadding]) + + useEffect(() => { + Promise.resolve().then(() => { + baseTextareaHeightRef.current = null + evaluateContentLayout() + setShouldReserveHorizontalSpace(!isEditing) + }) + }, [isEditing, evaluateContentLayout]) + + const filteredMentionUsers = useMemo(() => { + if (!mentionQuery) return mentionUsers + return mentionUsers.filter( + (user) => + user.name.toLowerCase().includes(mentionQuery.toLowerCase()) || + user.email.toLowerCase().includes(mentionQuery.toLowerCase()), + ) + }, [mentionUsers, mentionQuery]) + + const shouldDisableMentionButton = useMemo(() => { + if (showMentionDropdown) return true + + const textarea = textareaRef.current + if (!textarea) return false + + const cursorPosition = textarea.selectionStart || 0 + const textBeforeCursor = value.slice(0, cursorPosition) + return /@\w*$/.test(textBeforeCursor) + }, [showMentionDropdown, value]) + + const dropdownPosition = useMemo(() => { + if (!showMentionDropdown || !textareaRef.current) + return { x: 0, y: 0, placement: 'bottom' as const } + + const textareaRect = textareaRef.current.getBoundingClientRect() + const dropdownHeight = 160 // max-h-40 = 10rem = 160px + const viewportHeight = window.innerHeight + const spaceBelow = viewportHeight - textareaRect.bottom + const spaceAbove = textareaRect.top + + const shouldPlaceAbove = spaceBelow < dropdownHeight && spaceAbove > spaceBelow + + return { + x: textareaRect.left, + y: shouldPlaceAbove ? textareaRect.top - 4 : textareaRect.bottom + 4, + placement: shouldPlaceAbove ? ('top' as const) : ('bottom' as const), } - }, 0) - }, [onChange, evaluateContentLayout, syncHighlightScroll]) + }, [showMentionDropdown]) + + const handleContentChange = useCallback( + (newValue: string) => { + onChange(newValue) + + setTimeout(() => { + const cursorPosition = textareaRef.current?.selectionStart || 0 + const textBeforeCursor = newValue.slice(0, cursorPosition) + const mentionMatch = textBeforeCursor.match(/@(\w*)$/) + + if (mentionMatch) { + setMentionQuery(mentionMatch[1]!) + setMentionPosition(cursorPosition - mentionMatch[0].length) + setShowMentionDropdown(true) + setSelectedMentionIndex(0) + } else { + setShowMentionDropdown(false) + } + + if (typeof window !== 'undefined') { + window.requestAnimationFrame(() => { + evaluateContentLayout() + syncHighlightScroll() + }) + } + }, 0) + }, + [onChange, evaluateContentLayout, syncHighlightScroll], + ) - const handleMentionButtonClick = useCallback((e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() + const handleMentionButtonClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() - const textarea = textareaRef.current - if (!textarea) - return + const textarea = textareaRef.current + if (!textarea) return - const cursorPosition = textarea.selectionStart || 0 - const textBeforeCursor = value.slice(0, cursorPosition) + const cursorPosition = textarea.selectionStart || 0 + const textBeforeCursor = value.slice(0, cursorPosition) - if (showMentionDropdown) - return + if (showMentionDropdown) return - if (/@\w*$/.test(textBeforeCursor)) - return + if (/@\w*$/.test(textBeforeCursor)) return - const newContent = `${value.slice(0, cursorPosition)}@${value.slice(cursorPosition)}` + const newContent = `${value.slice(0, cursorPosition)}@${value.slice(cursorPosition)}` - onChange(newContent) + onChange(newContent) - setTimeout(() => { - const newCursorPos = cursorPosition + 1 - textarea.setSelectionRange(newCursorPos, newCursorPos) - textarea.focus() + setTimeout(() => { + const newCursorPos = cursorPosition + 1 + textarea.setSelectionRange(newCursorPos, newCursorPos) + textarea.focus() - setMentionQuery('') - setMentionPosition(cursorPosition) - setShowMentionDropdown(true) - setSelectedMentionIndex(0) + setMentionQuery('') + setMentionPosition(cursorPosition) + setShowMentionDropdown(true) + setSelectedMentionIndex(0) - if (typeof window !== 'undefined') { - window.requestAnimationFrame(() => { - evaluateContentLayout() - syncHighlightScroll() - }) - } - }, 0) - }, [value, onChange, evaluateContentLayout, syncHighlightScroll, showMentionDropdown]) - - const insertMention = useCallback((user: UserProfile) => { - const textarea = textareaRef.current - if (!textarea) - return - - const beforeMention = value.slice(0, mentionPosition) - const afterMention = value.slice(textarea.selectionStart || 0) - - const needsSpaceBefore = mentionPosition > 0 && !/\s/.test(value[mentionPosition - 1]!) - const prefix = needsSpaceBefore ? ' ' : '' - const newContent = `${beforeMention}${prefix}@${user.name} ${afterMention}` - - onChange(newContent) - setShowMentionDropdown(false) - - const newMentionedUserIds = [...mentionedUserIds, user.id] - setMentionedUserIds(newMentionedUserIds) - - setTimeout(() => { - const extraSpace = needsSpaceBefore ? 1 : 0 - const newCursorPos = mentionPosition + extraSpace + user.name.length + 2 // (space) + @ + name + space - textarea.setSelectionRange(newCursorPos, newCursorPos) - textarea.focus() - if (typeof window !== 'undefined') { - window.requestAnimationFrame(() => { - evaluateContentLayout() - syncHighlightScroll() - }) - } - }, 0) - }, [value, mentionPosition, onChange, mentionedUserIds, evaluateContentLayout, syncHighlightScroll]) + if (typeof window !== 'undefined') { + window.requestAnimationFrame(() => { + evaluateContentLayout() + syncHighlightScroll() + }) + } + }, 0) + }, + [value, onChange, evaluateContentLayout, syncHighlightScroll, showMentionDropdown], + ) - const handleSubmit = useCallback(async (e?: React.MouseEvent) => { - if (e) { - e.preventDefault() - e.stopPropagation() - } + const insertMention = useCallback( + (user: UserProfile) => { + const textarea = textareaRef.current + if (!textarea) return - if (value.trim()) { - try { - await onSubmit(value.trim(), mentionedUserIds) - setMentionedUserIds([]) + const beforeMention = value.slice(0, mentionPosition) + const afterMention = value.slice(textarea.selectionStart || 0) + + const needsSpaceBefore = mentionPosition > 0 && !/\s/.test(value[mentionPosition - 1]!) + const prefix = needsSpaceBefore ? ' ' : '' + const newContent = `${beforeMention}${prefix}@${user.name} ${afterMention}` + + onChange(newContent) setShowMentionDropdown(false) - } - catch (error) { - console.error('Failed to submit', error) - } - } - }, [value, mentionedUserIds, onSubmit]) - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - // Ignore key events during IME composition (e.g., Chinese, Japanese input) - if (e.nativeEvent.isComposing) - return + const newMentionedUserIds = [...mentionedUserIds, user.id] + setMentionedUserIds(newMentionedUserIds) + + setTimeout(() => { + const extraSpace = needsSpaceBefore ? 1 : 0 + const newCursorPos = mentionPosition + extraSpace + user.name.length + 2 // (space) + @ + name + space + textarea.setSelectionRange(newCursorPos, newCursorPos) + textarea.focus() + if (typeof window !== 'undefined') { + window.requestAnimationFrame(() => { + evaluateContentLayout() + syncHighlightScroll() + }) + } + }, 0) + }, + [ + value, + mentionPosition, + onChange, + mentionedUserIds, + evaluateContentLayout, + syncHighlightScroll, + ], + ) - if (showMentionDropdown) { - if (e.key === 'ArrowDown') { - e.preventDefault() - setSelectedMentionIndex(prev => - prev < filteredMentionUsers.length - 1 ? prev + 1 : 0, - ) - } - else if (e.key === 'ArrowUp') { - e.preventDefault() - setSelectedMentionIndex(prev => - prev > 0 ? prev - 1 : filteredMentionUsers.length - 1, - ) - } - else if (e.key === 'Enter') { - e.preventDefault() - if (filteredMentionUsers[selectedMentionIndex]) - insertMention(filteredMentionUsers[selectedMentionIndex]) + const handleSubmit = useCallback( + async (e?: React.MouseEvent) => { + if (e) { + e.preventDefault() + e.stopPropagation() + } - return - } - else if (e.key === 'Escape') { - e.preventDefault() - setShowMentionDropdown(false) - return + if (value.trim()) { + try { + await onSubmit(value.trim(), mentionedUserIds) + setMentionedUserIds([]) + setShowMentionDropdown(false) + } catch (error) { + console.error('Failed to submit', error) + } + } + }, + [value, mentionedUserIds, onSubmit], + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Ignore key events during IME composition (e.g., Chinese, Japanese input) + if (e.nativeEvent.isComposing) return + + if (showMentionDropdown) { + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedMentionIndex((prev) => + prev < filteredMentionUsers.length - 1 ? prev + 1 : 0, + ) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedMentionIndex((prev) => + prev > 0 ? prev - 1 : filteredMentionUsers.length - 1, + ) + } else if (e.key === 'Enter') { + e.preventDefault() + if (filteredMentionUsers[selectedMentionIndex]) + insertMention(filteredMentionUsers[selectedMentionIndex]) + + return + } else if (e.key === 'Escape') { + e.preventDefault() + setShowMentionDropdown(false) + return + } + } + + if (e.key === 'Enter' && !e.shiftKey && !showMentionDropdown) { + e.preventDefault() + handleSubmit() + } + }, + [ + showMentionDropdown, + filteredMentionUsers, + selectedMentionIndex, + insertMention, + handleSubmit, + ], + ) + + const resetMentionState = useCallback(() => { + setMentionedUserIds([]) + setShowMentionDropdown(false) + setMentionQuery('') + setMentionPosition(0) + setSelectedMentionIndex(0) + }, []) + + useEffect(() => { + if (!value) { + Promise.resolve().then(() => { + resetMentionState() + }) } - } - - if (e.key === 'Enter' && !e.shiftKey && !showMentionDropdown) { - e.preventDefault() - handleSubmit() - } - }, [showMentionDropdown, filteredMentionUsers, selectedMentionIndex, insertMention, handleSubmit]) - - const resetMentionState = useCallback(() => { - setMentionedUserIds([]) - setShowMentionDropdown(false) - setMentionQuery('') - setMentionPosition(0) - setSelectedMentionIndex(0) - }, []) - - useEffect(() => { - if (!value) { - Promise.resolve().then(() => { - resetMentionState() - }) - } - }, [value, resetMentionState]) - - useEffect(() => { - if (!autoFocus || !textareaRef.current) - return - - const textarea = textareaRef.current - const timeout = window.setTimeout(() => { - textarea.focus() - const length = textarea.value.length - textarea.setSelectionRange(length, length) - }, 0) - - return () => window.clearTimeout(timeout) - }, [autoFocus]) - - return ( - <> - <div className={cn('relative flex items-center', className)}> - <div - aria-hidden - className={cn( - 'pointer-events-none absolute inset-0 z-0 overflow-hidden p-1 leading-6 wrap-break-word whitespace-pre-wrap', - 'body-lg-regular text-text-primary', - )} - style={{ paddingRight, paddingBottom }} - > - <div - ref={highlightContentRef} - className="min-h-full" - style={{ willChange: 'transform' }} - > - {highlightedValue} + }, [value, resetMentionState]) - </div> - </div> - <Textarea - ref={textareaRef} - className={cn( - 'relative z-10 w-full resize-none bg-transparent p-1 body-lg-regular leading-6 text-transparent caret-primary-500 outline-hidden', - 'placeholder:text-text-tertiary', - )} - style={{ paddingRight, paddingBottom }} - placeholder={resolvedPlaceholder} - autoFocus={autoFocus} - minRows={isEditing ? 4 : 1} - maxRows={4} - value={value} - disabled={disabled || loading} - onChange={e => handleContentChange(e.target.value)} - onKeyDown={handleKeyDown} - onScroll={syncHighlightScroll} - /> - - {!isEditing && ( - <div - ref={setActionContainerRef} - className="absolute right-1 bottom-0 z-20 flex items-end gap-1" - > - <div - className={cn( - 'z-20 flex size-8 items-center justify-center rounded-lg transition-opacity', - shouldDisableMentionButton - ? 'cursor-not-allowed opacity-40' - : 'cursor-pointer hover:bg-state-base-hover', - )} - onClick={shouldDisableMentionButton ? undefined : handleMentionButtonClick} - > - <RiAtLine className="size-4 text-text-tertiary" /> - </div> - <Button - className="z-20 ml-2 w-8 px-0" - variant="primary" - disabled={!value.trim() || disabled || loading} - onClick={handleSubmit} - > - {loading - ? <RiLoader2Line className="size-4 animate-spin text-components-button-primary-text" /> - : <RiArrowUpLine className="size-4 text-components-button-primary-text" />} - </Button> - </div> - )} + useEffect(() => { + if (!autoFocus || !textareaRef.current) return - {isEditing && ( + const textarea = textareaRef.current + const timeout = window.setTimeout(() => { + textarea.focus() + const length = textarea.value.length + textarea.setSelectionRange(length, length) + }, 0) + + return () => window.clearTimeout(timeout) + }, [autoFocus]) + + return ( + <> + <div className={cn('relative flex items-center', className)}> <div - ref={setActionContainerRef} - className="absolute inset-x-1 bottom-0 z-20 flex items-end justify-between" + aria-hidden + className={cn( + 'pointer-events-none absolute inset-0 z-0 overflow-hidden p-1 leading-6 wrap-break-word whitespace-pre-wrap', + 'body-lg-regular text-text-primary', + )} + style={{ paddingRight, paddingBottom }} > <div - className={cn( - 'z-20 flex size-8 items-center justify-center rounded-lg transition-opacity', - shouldDisableMentionButton - ? 'cursor-not-allowed opacity-40' - : 'cursor-pointer hover:bg-state-base-hover', - )} - onClick={shouldDisableMentionButton ? undefined : handleMentionButtonClick} + ref={highlightContentRef} + className="min-h-full" + style={{ willChange: 'transform' }} > - <RiAtLine className="size-4 text-text-tertiary" /> + {highlightedValue} </div> + </div> + <Textarea + ref={textareaRef} + className={cn( + 'relative z-10 w-full resize-none bg-transparent p-1 body-lg-regular leading-6 text-transparent caret-primary-500 outline-hidden', + 'placeholder:text-text-tertiary', + )} + style={{ paddingRight, paddingBottom }} + placeholder={resolvedPlaceholder} + autoFocus={autoFocus} + minRows={isEditing ? 4 : 1} + maxRows={4} + value={value} + disabled={disabled || loading} + onChange={(e) => handleContentChange(e.target.value)} + onKeyDown={handleKeyDown} + onScroll={syncHighlightScroll} + /> + + {!isEditing && ( <div - ref={setActionRightRef} - className="flex items-center gap-2" + ref={setActionContainerRef} + className="absolute right-1 bottom-0 z-20 flex items-end gap-1" > - <Button variant="secondary" size="small" onClick={onCancel} disabled={loading}> - {t($ => $['operation.cancel'], { ns: 'common' })} - </Button> + <div + className={cn( + 'z-20 flex size-8 items-center justify-center rounded-lg transition-opacity', + shouldDisableMentionButton + ? 'cursor-not-allowed opacity-40' + : 'cursor-pointer hover:bg-state-base-hover', + )} + onClick={shouldDisableMentionButton ? undefined : handleMentionButtonClick} + > + <RiAtLine className="size-4 text-text-tertiary" /> + </div> <Button + className="z-20 ml-2 w-8 px-0" variant="primary" - size="small" - disabled={loading || !value.trim()} - onClick={() => handleSubmit()} - className="gap-1" + disabled={!value.trim() || disabled || loading} + onClick={handleSubmit} > - {loading && <RiLoader2Line className="mr-1 size-3.5 animate-spin" />} - <span>{t($ => $['operation.save'], { ns: 'common' })}</span> - {!loading && ( - <EnterKey className="size-4" /> + {loading ? ( + <RiLoader2Line className="size-4 animate-spin text-components-button-primary-text" /> + ) : ( + <RiArrowUpLine className="size-4 text-components-button-primary-text" /> )} </Button> </div> - </div> - )} - </div> - - {showMentionDropdown && filteredMentionUsers.length > 0 && typeof document !== 'undefined' && createPortal( - <div - className="fixed z-50 max-h-[248px] w-[280px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg/95 shadow-lg backdrop-blur-[10px]" - style={{ - left: dropdownPosition.x, - [dropdownPosition.placement === 'top' ? 'bottom' : 'top']: dropdownPosition.placement === 'top' - ? window.innerHeight - dropdownPosition.y - : dropdownPosition.y, - }} - data-mention-dropdown - > - {filteredMentionUsers.map((user, index) => ( + )} + + {isEditing && ( <div - key={user.id} - className={cn( - 'flex cursor-pointer items-center gap-2 rounded-md py-1 pr-3 pl-2 hover:bg-state-base-hover', - index === selectedMentionIndex && 'bg-state-base-hover', - )} - onClick={() => insertMention(user)} + ref={setActionContainerRef} + className="absolute inset-x-1 bottom-0 z-20 flex items-end justify-between" > - <Avatar - avatar={user.avatar_url || null} - name={user.name} - size="sm" - className="shrink-0" - /> - <div className="min-w-0 flex-1"> - <div className="truncate text-sm font-medium text-text-primary"> - {user.name} - </div> - <div className="truncate text-xs text-text-tertiary"> - {user.email} - </div> + <div + className={cn( + 'z-20 flex size-8 items-center justify-center rounded-lg transition-opacity', + shouldDisableMentionButton + ? 'cursor-not-allowed opacity-40' + : 'cursor-pointer hover:bg-state-base-hover', + )} + onClick={shouldDisableMentionButton ? undefined : handleMentionButtonClick} + > + <RiAtLine className="size-4 text-text-tertiary" /> + </div> + <div ref={setActionRightRef} className="flex items-center gap-2"> + <Button variant="secondary" size="small" onClick={onCancel} disabled={loading}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button + variant="primary" + size="small" + disabled={loading || !value.trim()} + onClick={() => handleSubmit()} + className="gap-1" + > + {loading && <RiLoader2Line className="mr-1 size-3.5 animate-spin" />} + <span>{t(($) => $['operation.save'], { ns: 'common' })}</span> + {!loading && <EnterKey className="size-4" />} + </Button> </div> </div> - ))} - </div>, - document.body, - )} - </> - ) -}) + )} + </div> + + {showMentionDropdown && + filteredMentionUsers.length > 0 && + typeof document !== 'undefined' && + createPortal( + <div + className="fixed z-50 max-h-[248px] w-[280px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg/95 shadow-lg backdrop-blur-[10px]" + style={{ + left: dropdownPosition.x, + [dropdownPosition.placement === 'top' ? 'bottom' : 'top']: + dropdownPosition.placement === 'top' + ? window.innerHeight - dropdownPosition.y + : dropdownPosition.y, + }} + data-mention-dropdown + > + {filteredMentionUsers.map((user, index) => ( + <div + key={user.id} + className={cn( + 'flex cursor-pointer items-center gap-2 rounded-md py-1 pr-3 pl-2 hover:bg-state-base-hover', + index === selectedMentionIndex && 'bg-state-base-hover', + )} + onClick={() => insertMention(user)} + > + <Avatar + avatar={user.avatar_url || null} + name={user.name} + size="sm" + className="shrink-0" + /> + <div className="min-w-0 flex-1"> + <div className="truncate text-sm font-medium text-text-primary"> + {user.name} + </div> + <div className="truncate text-xs text-text-tertiary">{user.email}</div> + </div> + </div> + ))} + </div>, + document.body, + )} + </> + ) + }, +) MentionInputInner.displayName = 'MentionInputInner' diff --git a/web/app/components/workflow/comment/thread.spec.tsx b/web/app/components/workflow/comment/thread.spec.tsx index 3cf742df579452..11b776b1a63354 100644 --- a/web/app/components/workflow/comment/thread.spec.tsx +++ b/web/app/components/workflow/comment/thread.spec.tsx @@ -3,7 +3,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' 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 mockFlowToScreenPosition = vi.hoisted(() => + vi.fn(({ x, y }: { x: number; y: number }) => ({ x, y })), +) const mockAppContextState = vi.hoisted(() => ({ userProfile: { id: 'user-1', @@ -18,7 +20,7 @@ const storeState = vi.hoisted(() => ({ { id: 'user-1', name: 'Alice', email: 'alice@example.com', avatar_url: 'alice.png' }, { id: 'user-2', name: 'Bob', email: 'bob@example.com', avatar_url: 'bob.png' }, ], - } as Record<string, Array<{ id: string, name: string, email: string, avatar_url: string }>>, + } as Record<string, Array<{ id: string; name: string; email: string; avatar_url: string }>>, setCommentPreviewHovering: (...args: unknown[]) => mockSetCommentPreviewHovering(...args), })) vi.mock('@/next/navigation', () => ({ @@ -53,7 +55,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -86,15 +89,21 @@ vi.mock('@/app/components/base/inline-delete-confirm', () => ({ vi.mock('@langgenius/dify-ui/avatar', () => ({ Avatar: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>, - AvatarRoot: ({ children }: { children: React.ReactNode }) => <div data-testid="avatar-root">{children}</div>, + AvatarRoot: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="avatar-root">{children}</div> + ), AvatarImage: ({ alt }: { alt: string }) => <div data-testid="avatar-image">{alt}</div>, - AvatarFallback: ({ children }: { children: React.ReactNode }) => <div data-testid="avatar-fallback">{children}</div>, + AvatarFallback: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="avatar-fallback">{children}</div> + ), })) vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({ DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, DropdownMenuTrigger: ({ children, ...props }: React.ComponentProps<'button'>) => ( - <button type="button" {...props}>{children}</button> + <button type="button" {...props}> + {children} + </button> ), DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, })) @@ -105,11 +114,14 @@ vi.mock('@langgenius/dify-ui/tooltip', () => ({ children, render, ...props - }: React.ComponentProps<'button'> & { children?: React.ReactNode, render?: React.ReactNode }) => { - if (render) - return <>{render}</> + }: React.ComponentProps<'button'> & { children?: React.ReactNode; render?: React.ReactNode }) => { + if (render) return <>{render}</> - return <button type="button" {...props}>{children}</button> + return ( + <button type="button" {...props}> + {children} + </button> + ) }, TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>, })) @@ -158,18 +170,20 @@ const createComment = (): WorkflowCommentDetail => ({ updated_at: 2, resolved: false, mentions: [], - replies: [{ - id: 'reply-1', - content: 'first reply', - created_by: 'user-1', - created_by_account: { - id: 'user-1', - name: 'Alice', - email: 'alice@example.com', - avatar_url: 'alice.png', + replies: [ + { + id: 'reply-1', + content: 'first reply', + created_by: 'user-1', + created_by_account: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + avatar_url: 'alice.png', + }, + created_at: 2, }, - created_at: 2, - }], + ], }) describe('CommentThread', () => { @@ -237,11 +251,7 @@ describe('CommentThread', () => { const onCommentEdit = vi.fn() render( - <CommentThread - comment={createComment()} - onClose={vi.fn()} - onCommentEdit={onCommentEdit} - />, + <CommentThread comment={createComment()} onClose={vi.fn()} onCommentEdit={onCommentEdit} />, ) fireEvent.click(screen.getByLabelText('workflow.comments.aria.commentActions')) @@ -256,11 +266,7 @@ describe('CommentThread', () => { it('submits reply and updates preview hovering state on mouse enter/leave', async () => { const onReply = vi.fn() const { container } = render( - <CommentThread - comment={createComment()} - onClose={vi.fn()} - onReply={onReply} - />, + <CommentThread comment={createComment()} onClose={vi.fn()} onReply={onReply} />, ) fireEvent.mouseEnter(container.firstElementChild as Element) @@ -272,7 +278,9 @@ describe('CommentThread', () => { fireEvent.click(screen.getByText('submit-workflow.comments.placeholder.reply')) await waitFor(() => { - expect(onReply).toHaveBeenCalledWith('content:workflow.comments.placeholder.reply', ['user-2']) + expect(onReply).toHaveBeenCalledWith('content:workflow.comments.placeholder.reply', [ + 'user-2', + ]) }) }) diff --git a/web/app/components/workflow/comment/thread.tsx b/web/app/components/workflow/comment/thread.tsx index e9a142645d1faa..68fc760a9d1c9f 100644 --- a/web/app/components/workflow/comment/thread.tsx +++ b/web/app/components/workflow/comment/thread.tsx @@ -1,7 +1,10 @@ 'use client' import type { FC, ReactNode } from 'react' -import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/app/components/workflow/comment/types' +import type { + WorkflowCommentDetail, + WorkflowCommentDetailReply, +} from '@/app/components/workflow/comment/types' import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import { @@ -10,7 +13,15 @@ import { DropdownMenuTrigger, } 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 { + 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' @@ -38,7 +49,11 @@ type CommentThreadProps = { canGoNext?: boolean onCommentEdit?: (content: string, mentionedUserIds?: string[]) => Promise<void> | void onReply?: (content: string, mentionedUserIds?: string[]) => Promise<void> | void - onReplyEdit?: (replyId: string, content: string, mentionedUserIds?: string[]) => Promise<void> | void + onReplyEdit?: ( + replyId: string, + content: string, + mentionedUserIds?: string[], + ) => Promise<void> | void onReplyDelete?: (replyId: string) => void onReplyDeleteDirect?: (replyId: string) => Promise<void> | void } @@ -58,17 +73,15 @@ const ThreadMessage: FC<{ const userColor = isCurrentUser ? undefined : getUserColor(authorId) const highlightedContent = useMemo<ReactNode>(() => { - if (!content) - return '' + if (!content) return '' // Extract valid user names from mentionableNames, sorted by length (longest first) - const normalizedNames = Array.from(new Set(mentionableNames - .map(name => name.trim()) - .filter(Boolean))) + const normalizedNames = Array.from( + new Set(mentionableNames.map((name) => name.trim()).filter(Boolean)), + ) normalizedNames.sort((a, b) => b.length - a.length) - if (normalizedNames.length === 0) - return content + if (normalizedNames.length === 0) return content const segments: ReactNode[] = [] let hasMention = false @@ -80,25 +93,22 @@ const ThreadMessage: FC<{ for (const name of normalizedNames) { const searchStart = content.indexOf(`@${name}`, cursor) - if (searchStart === -1) - continue + if (searchStart === -1) continue const previousChar = searchStart > 0 ? content[searchStart - 1] : '' - if (searchStart > 0 && !/\s/.test(previousChar!)) - continue + if (searchStart > 0 && !/\s/.test(previousChar!)) continue if ( - nextMatchStart === -1 - || searchStart < nextMatchStart - || (searchStart === nextMatchStart && name.length > matchedName.length) + nextMatchStart === -1 || + searchStart < nextMatchStart || + (searchStart === nextMatchStart && name.length > matchedName.length) ) { nextMatchStart = searchStart matchedName = name } } - if (nextMatchStart === -1) - break + if (nextMatchStart === -1) break if (nextMatchStart > cursor) segments.push(<span key={`text-${cursor}`}>{content.slice(cursor, nextMatchStart)}</span>) @@ -113,8 +123,7 @@ const ThreadMessage: FC<{ cursor = mentionEnd } - if (!hasMention) - return content + if (!hasMention) return content if (cursor < content.length) segments.push(<span key={`text-${cursor}`}>{content.slice(cursor)}</span>) @@ -126,16 +135,8 @@ const ThreadMessage: FC<{ <div className={cn('flex gap-3 pt-1', className)}> <div className="shrink-0"> <AvatarRoot size="sm" className={cn('size-8 rounded-full')}> - {avatarUrl && ( - <AvatarImage - src={avatarUrl} - alt={authorName} - /> - )} - <AvatarFallback - size="sm" - style={userColor ? { backgroundColor: userColor } : undefined} - > + {avatarUrl && <AvatarImage src={avatarUrl} alt={authorName} />} + <AvatarFallback size="sm" style={userColor ? { backgroundColor: userColor } : undefined}> {authorName?.[0]?.toLocaleUpperCase()} </AvatarFallback> </AvatarRoot> @@ -143,7 +144,9 @@ const ThreadMessage: FC<{ <div className="min-w-0 flex-1 pb-4 text-text-primary last:pb-0"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <span className="system-sm-medium text-text-primary">{authorName}</span> - <span className="system-2xs-regular text-text-tertiary">{formatTimeFromNow(createdAt * 1000)}</span> + <span className="system-2xs-regular text-text-tertiary"> + {formatTimeFromNow(createdAt * 1000)} + </span> </div> <div className="mt-1 system-sm-regular wrap-break-word whitespace-pre-wrap text-text-secondary"> {highlightedContent} @@ -153,614 +156,657 @@ const ThreadMessage: FC<{ ) } -export const CommentThread: FC<CommentThreadProps> = memo(({ - comment, - loading = false, - replySubmitting = false, - replyUpdating = false, - onClose, - onDelete, - onResolve, - onPrev, - onNext, - canGoPrev, - canGoNext, - onCommentEdit, - onReply, - onReplyEdit, - onReplyDelete, - onReplyDeleteDirect, -}) => { - const params = useParams() - const appId = params.appId as string - const { flowToScreenPosition } = useReactFlow() - const viewport = useViewport() - const userProfile = useAtomValue(userProfileAtom) - const currentUserId = userProfile.id - const { t } = useTranslation() - const [replyContent, setReplyContent] = useState('') - const [editingCommentContent, setEditingCommentContent] = useState('') - const [activeReplyMenuId, setActiveReplyMenuId] = useState<string | null>(null) - const [editingReply, setEditingReply] = useState<{ id: string, content: string }>({ id: '', content: '' }) - const [deletingReplyId, setDeletingReplyId] = useState<string | null>(null) - const [isCommentEditing, setIsCommentEditing] = useState(false) - const [isSubmittingEdit, setIsSubmittingEdit] = useState(false) - - // Focus management refs - const replyInputRef = useRef<HTMLTextAreaElement>(null) - const threadRef = useRef<HTMLDivElement>(null) - - // Get mentionable users from store - const mentionUsersFromStore = useStore(state => ( - appId ? state.mentionableUsersCache[appId] : undefined - )) - const mentionUsers = mentionUsersFromStore ?? [] - const setCommentPreviewHovering = useStore(state => state.setCommentPreviewHovering) - - // Extract all mentionable names for highlighting - const mentionableNames = useMemo(() => { - const names = mentionUsers - .map(user => user.name?.trim()) - .filter((name): name is string => Boolean(name)) - return Array.from(new Set(names)) - }, [mentionUsers]) - - useEffect(() => { - Promise.resolve().then(() => { - setReplyContent('') - setEditingCommentContent('') - setIsCommentEditing(false) - setEditingReply({ id: '', content: '' }) - setActiveReplyMenuId(null) - setDeletingReplyId(null) +export const CommentThread: FC<CommentThreadProps> = memo( + ({ + comment, + loading = false, + replySubmitting = false, + replyUpdating = false, + onClose, + onDelete, + onResolve, + onPrev, + onNext, + canGoPrev, + canGoNext, + onCommentEdit, + onReply, + onReplyEdit, + onReplyDelete, + onReplyDeleteDirect, + }) => { + const params = useParams() + const appId = params.appId as string + const { flowToScreenPosition } = useReactFlow() + const viewport = useViewport() + const userProfile = useAtomValue(userProfileAtom) + const currentUserId = userProfile.id + const { t } = useTranslation() + const [replyContent, setReplyContent] = useState('') + const [editingCommentContent, setEditingCommentContent] = useState('') + const [activeReplyMenuId, setActiveReplyMenuId] = useState<string | null>(null) + const [editingReply, setEditingReply] = useState<{ id: string; content: string }>({ + id: '', + content: '', }) - }, [comment.id]) - - useEffect(() => () => { - setCommentPreviewHovering(false) - }, [setCommentPreviewHovering]) - - // P0: Auto-focus reply input when thread opens or comment changes - useEffect(() => { - const timer = setTimeout(() => { - if (replyInputRef.current && !editingReply.id && !isCommentEditing && onReply) - replyInputRef.current.focus() - }, 100) - - return () => clearTimeout(timer) - }, [comment.id, editingReply.id, isCommentEditing, onReply]) - - // P2: Handle Esc key to close thread - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Don't intercept if actively editing a reply - if (editingReply.id || isCommentEditing) - return - - // Don't intercept if mention dropdown is open (let MentionInput handle it) - if (document.querySelector('[data-mention-dropdown]')) - return - - if (e.key === 'Escape') { - e.preventDefault() - e.stopPropagation() - onClose() + const [deletingReplyId, setDeletingReplyId] = useState<string | null>(null) + const [isCommentEditing, setIsCommentEditing] = useState(false) + const [isSubmittingEdit, setIsSubmittingEdit] = useState(false) + + // Focus management refs + const replyInputRef = useRef<HTMLTextAreaElement>(null) + const threadRef = useRef<HTMLDivElement>(null) + + // Get mentionable users from store + const mentionUsersFromStore = useStore((state) => + appId ? state.mentionableUsersCache[appId] : undefined, + ) + const mentionUsers = mentionUsersFromStore ?? [] + const setCommentPreviewHovering = useStore((state) => state.setCommentPreviewHovering) + + // Extract all mentionable names for highlighting + const mentionableNames = useMemo(() => { + const names = mentionUsers + .map((user) => user.name?.trim()) + .filter((name): name is string => Boolean(name)) + return Array.from(new Set(names)) + }, [mentionUsers]) + + useEffect(() => { + Promise.resolve().then(() => { + setReplyContent('') + setEditingCommentContent('') + setIsCommentEditing(false) + setEditingReply({ id: '', content: '' }) + setActiveReplyMenuId(null) + setDeletingReplyId(null) + }) + }, [comment.id]) + + useEffect( + () => () => { + setCommentPreviewHovering(false) + }, + [setCommentPreviewHovering], + ) + + // P0: Auto-focus reply input when thread opens or comment changes + useEffect(() => { + const timer = setTimeout(() => { + if (replyInputRef.current && !editingReply.id && !isCommentEditing && onReply) + replyInputRef.current.focus() + }, 100) + + return () => clearTimeout(timer) + }, [comment.id, editingReply.id, isCommentEditing, onReply]) + + // P2: Handle Esc key to close thread + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't intercept if actively editing a reply + if (editingReply.id || isCommentEditing) return + + // Don't intercept if mention dropdown is open (let MentionInput handle it) + if (document.querySelector('[data-mention-dropdown]')) return + + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + onClose() + } } - } - document.addEventListener('keydown', handleKeyDown, true) - return () => document.removeEventListener('keydown', handleKeyDown, true) - }, [onClose, editingReply.id, isCommentEditing]) + document.addEventListener('keydown', handleKeyDown, true) + return () => document.removeEventListener('keydown', handleKeyDown, true) + }, [onClose, editingReply.id, isCommentEditing]) + + const handleReplySubmit = useCallback( + async (content: string, mentionedUserIds: string[]) => { + if (!onReply || replySubmitting) return + + setReplyContent('') + + try { + await onReply(content, mentionedUserIds) - const handleReplySubmit = useCallback(async (content: string, mentionedUserIds: string[]) => { - if (!onReply || replySubmitting) - return + // P0: Restore focus to reply input after successful submission + setTimeout(() => { + replyInputRef.current?.focus() + }, 0) + } catch (error) { + console.error('Failed to send reply', error) + setReplyContent(content) + } + }, + [onReply, replySubmitting], + ) + + const screenPosition = useMemo(() => { + return flowToScreenPosition({ + x: comment.position_x, + y: comment.position_y, + }) + }, [ + comment.position_x, + comment.position_y, + viewport.x, + viewport.y, + viewport.zoom, + flowToScreenPosition, + ]) + const workflowContainerRect = + typeof document !== 'undefined' + ? document.getElementById('workflow-container')?.getBoundingClientRect() + : null + const containerLeft = workflowContainerRect?.left ?? 0 + const containerTop = workflowContainerRect?.top ?? 0 + const canvasPosition = useMemo( + () => ({ + x: screenPosition.x - containerLeft, + y: screenPosition.y - containerTop, + }), + [screenPosition.x, screenPosition.y, containerLeft, containerTop], + ) + + const handleStartEdit = useCallback((reply: WorkflowCommentDetailReply) => { + setEditingReply({ id: reply.id, content: reply.content }) + setIsCommentEditing(false) + setActiveReplyMenuId(null) + }, []) - setReplyContent('') + const handleStartCommentEdit = useCallback(() => { + setEditingCommentContent(comment.content) + setEditingReply({ id: '', content: '' }) + setIsCommentEditing(true) + setActiveReplyMenuId(null) + }, [comment.content]) - try { - await onReply(content, mentionedUserIds) + const handleCancelEdit = useCallback(() => { + setEditingReply({ id: '', content: '' }) - // P0: Restore focus to reply input after successful submission + // P1: Restore focus to reply input after canceling edit setTimeout(() => { replyInputRef.current?.focus() }, 0) - } - catch (error) { - console.error('Failed to send reply', error) - setReplyContent(content) - } - }, [onReply, replySubmitting]) + }, []) - const screenPosition = useMemo(() => { - return flowToScreenPosition({ - x: comment.position_x, - y: comment.position_y, - }) - }, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition]) - const workflowContainerRect = typeof document !== 'undefined' - ? document.getElementById('workflow-container')?.getBoundingClientRect() - : null - const containerLeft = workflowContainerRect?.left ?? 0 - const containerTop = workflowContainerRect?.top ?? 0 - const canvasPosition = useMemo(() => ({ - x: screenPosition.x - containerLeft, - y: screenPosition.y - containerTop, - }), [screenPosition.x, screenPosition.y, containerLeft, containerTop]) - - const handleStartEdit = useCallback((reply: WorkflowCommentDetailReply) => { - setEditingReply({ id: reply.id, content: reply.content }) - setIsCommentEditing(false) - setActiveReplyMenuId(null) - }, []) - - const handleStartCommentEdit = useCallback(() => { - setEditingCommentContent(comment.content) - setEditingReply({ id: '', content: '' }) - setIsCommentEditing(true) - setActiveReplyMenuId(null) - }, [comment.content]) - - const handleCancelEdit = useCallback(() => { - setEditingReply({ id: '', content: '' }) - - // P1: Restore focus to reply input after canceling edit - setTimeout(() => { - replyInputRef.current?.focus() - }, 0) - }, []) - - const handleCancelCommentEdit = useCallback(() => { - setEditingCommentContent('') - setIsCommentEditing(false) - - setTimeout(() => { - replyInputRef.current?.focus() - }, 0) - }, []) - - const handleCommentEditSubmit = useCallback(async (content: string, mentionedUserIds: string[]) => { - if (!onCommentEdit) - return - const trimmed = content.trim() - if (!trimmed) - return - - setIsSubmittingEdit(true) - try { - await onCommentEdit(trimmed, mentionedUserIds) + const handleCancelCommentEdit = useCallback(() => { setEditingCommentContent('') setIsCommentEditing(false) setTimeout(() => { replyInputRef.current?.focus() }, 0) - } - catch (error) { - console.error('Failed to edit comment', error) - } - finally { - setIsSubmittingEdit(false) - } - }, [onCommentEdit]) - - const handleEditSubmit = useCallback(async (content: string, mentionedUserIds: string[]) => { - if (!onReplyEdit || !editingReply) - return - const trimmed = content.trim() - if (!trimmed) - return - - setIsSubmittingEdit(true) - try { - await onReplyEdit(editingReply.id, trimmed, mentionedUserIds) - setEditingReply({ id: '', content: '' }) - - // P1: Restore focus to reply input after saving edit - setTimeout(() => { - replyInputRef.current?.focus() - }, 0) - } - catch (error) { - console.error('Failed to edit reply', error) - } - finally { - setIsSubmittingEdit(false) - } - }, [editingReply, onReplyEdit]) - - const replies = comment.replies || [] - const isOwnComment = comment.created_by_account?.id === currentUserId - const messageListRef = useRef<HTMLDivElement>(null) - const previousReplyCountRef = useRef<number | undefined>(undefined) - const previousCommentIdRef = useRef<string | undefined>(undefined) - - // Close dropdown when scrolling - useEffect(() => { - const container = messageListRef.current - if (!container || !activeReplyMenuId) - return - - const handleScroll = () => { - setActiveReplyMenuId(null) - } + }, []) + + const handleCommentEditSubmit = useCallback( + async (content: string, mentionedUserIds: string[]) => { + if (!onCommentEdit) return + const trimmed = content.trim() + if (!trimmed) return + + setIsSubmittingEdit(true) + try { + await onCommentEdit(trimmed, mentionedUserIds) + setEditingCommentContent('') + setIsCommentEditing(false) + + setTimeout(() => { + replyInputRef.current?.focus() + }, 0) + } catch (error) { + console.error('Failed to edit comment', error) + } finally { + setIsSubmittingEdit(false) + } + }, + [onCommentEdit], + ) + + const handleEditSubmit = useCallback( + async (content: string, mentionedUserIds: string[]) => { + if (!onReplyEdit || !editingReply) return + const trimmed = content.trim() + if (!trimmed) return + + setIsSubmittingEdit(true) + try { + await onReplyEdit(editingReply.id, trimmed, mentionedUserIds) + setEditingReply({ id: '', content: '' }) + + // P1: Restore focus to reply input after saving edit + setTimeout(() => { + replyInputRef.current?.focus() + }, 0) + } catch (error) { + console.error('Failed to edit reply', error) + } finally { + setIsSubmittingEdit(false) + } + }, + [editingReply, onReplyEdit], + ) + + const replies = comment.replies || [] + const isOwnComment = comment.created_by_account?.id === currentUserId + const messageListRef = useRef<HTMLDivElement>(null) + const previousReplyCountRef = useRef<number | undefined>(undefined) + const previousCommentIdRef = useRef<string | undefined>(undefined) + + // Close dropdown when scrolling + useEffect(() => { + const container = messageListRef.current + if (!container || !activeReplyMenuId) return + + const handleScroll = () => { + setActiveReplyMenuId(null) + } - container.addEventListener('scroll', handleScroll) - return () => container.removeEventListener('scroll', handleScroll) - }, [activeReplyMenuId]) - - // Auto-scroll to bottom on new messages - useEffect(() => { - const container = messageListRef.current - if (!container) - return - - const isFirstRender = previousCommentIdRef.current === undefined - const isNewComment = comment.id !== previousCommentIdRef.current - const hasNewReply = previousReplyCountRef.current !== undefined - && replies.length > previousReplyCountRef.current - - // Scroll on first render, new comment, or new reply - if (isFirstRender || isNewComment || hasNewReply) { - container.scrollTo({ - top: container.scrollHeight, - behavior: 'smooth', - }) - } + container.addEventListener('scroll', handleScroll) + return () => container.removeEventListener('scroll', handleScroll) + }, [activeReplyMenuId]) + + // Auto-scroll to bottom on new messages + useEffect(() => { + const container = messageListRef.current + if (!container) return + + const isFirstRender = previousCommentIdRef.current === undefined + const isNewComment = comment.id !== previousCommentIdRef.current + const hasNewReply = + previousReplyCountRef.current !== undefined && + replies.length > previousReplyCountRef.current + + // Scroll on first render, new comment, or new reply + if (isFirstRender || isNewComment || hasNewReply) { + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth', + }) + } - previousCommentIdRef.current = comment.id - previousReplyCountRef.current = replies.length - }, [comment.id, replies.length]) + previousCommentIdRef.current = comment.id + previousReplyCountRef.current = replies.length + }, [comment.id, replies.length]) - return ( - <div - className="absolute z-30 w-[360px] max-w-[360px]" - style={{ - left: canvasPosition.x + 40, - top: canvasPosition.y, - transform: 'translateY(-20%)', - }} - onMouseEnter={() => setCommentPreviewHovering(true)} - onMouseLeave={() => setCommentPreviewHovering(false)} - > + return ( <div - ref={threadRef} - className="relative flex h-[360px] flex-col overflow-hidden rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl" - role="dialog" - aria-modal="true" - aria-labelledby="comment-thread-title" + className="absolute z-30 w-[360px] max-w-[360px]" + style={{ + left: canvasPosition.x + 40, + top: canvasPosition.y, + transform: 'translateY(-20%)', + }} + onMouseEnter={() => setCommentPreviewHovering(true)} + onMouseLeave={() => setCommentPreviewHovering(false)} > - <div className="flex items-center justify-between rounded-t-2xl border-b border-components-panel-border bg-components-panel-bg-blur px-4 py-3"> - <div - id="comment-thread-title" - className="font-semibold text-text-primary uppercase" - > - {t($ => $['comments.panelTitle'], { ns: 'workflow' })} - </div> - <div className="flex items-center gap-1"> - <Tooltip> - <TooltipTrigger - render={( - <button - type="button" - disabled={loading} - className={cn('flex size-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')} - onClick={onDelete} - aria-label={t($ => $['comments.aria.deleteComment'], { ns: 'workflow' })} - > - <RiDeleteBinLine className="size-4" /> - </button> - )} - /> - <TooltipContent placement="top" className="px-2! py-1.5!"> - {t($ => $['comments.aria.deleteComment'], { ns: 'workflow' })} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger - render={( - <button - type="button" - disabled={comment.resolved || loading} - className={cn('flex size-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')} - onClick={onResolve} - aria-label={t($ => $['comments.aria.resolveComment'], { ns: 'workflow' })} - > - {comment.resolved ? <RiCheckboxCircleFill className="size-4" /> : <RiCheckboxCircleLine className="size-4" />} - </button> - )} - /> - <TooltipContent placement="top" className="px-2! py-1.5!"> - {t($ => $['comments.aria.resolveComment'], { ns: 'workflow' })} - </TooltipContent> - </Tooltip> - <Divider type="vertical" className="h-3.5" /> - <Tooltip> - <TooltipTrigger - render={( - <button - type="button" - disabled={!canGoPrev || loading} - className={cn('flex size-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')} - onClick={onPrev} - aria-label={t($ => $['comments.aria.previousComment'], { ns: 'workflow' })} - > - <RiArrowUpSLine className="size-4" /> - </button> - )} - /> - <TooltipContent placement="top" className="px-2! py-1.5!"> - {t($ => $['comments.aria.previousComment'], { ns: 'workflow' })} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger - render={( - <button - type="button" - disabled={!canGoNext || loading} - className={cn('flex size-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')} - onClick={onNext} - aria-label={t($ => $['comments.aria.nextComment'], { ns: 'workflow' })} - > - <RiArrowDownSLine className="size-4" /> - </button> - )} - /> - <TooltipContent placement="top" className="px-2! py-1.5!"> - {t($ => $['comments.aria.nextComment'], { ns: 'workflow' })} - </TooltipContent> - </Tooltip> - <button - type="button" - className="flex size-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" - onClick={onClose} - aria-label={t($ => $['comments.aria.closeComment'], { ns: 'workflow' })} - > - <RiCloseLine className="size-4" /> - </button> - </div> - </div> <div - ref={messageListRef} - className="relative mt-2 flex-1 overflow-y-auto px-4 pb-4" + ref={threadRef} + className="relative flex h-[360px] flex-col overflow-hidden rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl" + role="dialog" + aria-modal="true" + aria-labelledby="comment-thread-title" > - <div className="group relative -mx-4 rounded-lg px-4 py-2 transition-colors hover:bg-components-panel-on-panel-item-bg-hover"> - {isOwnComment && !isCommentEditing && ( - <div - className={cn( - 'absolute top-1 right-1 gap-1', - activeReplyMenuId === comment.id ? 'flex' : 'hidden group-hover:flex', - )} + <div className="flex items-center justify-between rounded-t-2xl border-b border-components-panel-border bg-components-panel-bg-blur px-4 py-3"> + <div id="comment-thread-title" className="font-semibold text-text-primary uppercase"> + {t(($) => $['comments.panelTitle'], { ns: 'workflow' })} + </div> + <div className="flex items-center gap-1"> + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + disabled={loading} + className={cn( + 'flex size-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled', + )} + onClick={onDelete} + aria-label={t(($) => $['comments.aria.deleteComment'], { ns: 'workflow' })} + > + <RiDeleteBinLine className="size-4" /> + </button> + } + /> + <TooltipContent placement="top" className="px-2! py-1.5!"> + {t(($) => $['comments.aria.deleteComment'], { ns: 'workflow' })} + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + disabled={comment.resolved || loading} + className={cn( + 'flex size-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled', + )} + onClick={onResolve} + aria-label={t(($) => $['comments.aria.resolveComment'], { ns: 'workflow' })} + > + {comment.resolved ? ( + <RiCheckboxCircleFill className="size-4" /> + ) : ( + <RiCheckboxCircleLine className="size-4" /> + )} + </button> + } + /> + <TooltipContent placement="top" className="px-2! py-1.5!"> + {t(($) => $['comments.aria.resolveComment'], { ns: 'workflow' })} + </TooltipContent> + </Tooltip> + <Divider type="vertical" className="h-3.5" /> + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + disabled={!canGoPrev || loading} + className={cn( + 'flex size-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled', + )} + onClick={onPrev} + aria-label={t(($) => $['comments.aria.previousComment'], { ns: 'workflow' })} + > + <RiArrowUpSLine className="size-4" /> + </button> + } + /> + <TooltipContent placement="top" className="px-2! py-1.5!"> + {t(($) => $['comments.aria.previousComment'], { ns: 'workflow' })} + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + disabled={!canGoNext || loading} + className={cn( + 'flex size-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled', + )} + onClick={onNext} + aria-label={t(($) => $['comments.aria.nextComment'], { ns: 'workflow' })} + > + <RiArrowDownSLine className="size-4" /> + </button> + } + /> + <TooltipContent placement="top" className="px-2! py-1.5!"> + {t(($) => $['comments.aria.nextComment'], { ns: 'workflow' })} + </TooltipContent> + </Tooltip> + <button + type="button" + className="flex size-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" + onClick={onClose} + aria-label={t(($) => $['comments.aria.closeComment'], { ns: 'workflow' })} > - <DropdownMenu - open={activeReplyMenuId === comment.id} - onOpenChange={open => setActiveReplyMenuId(open ? comment.id : null)} + <RiCloseLine className="size-4" /> + </button> + </div> + </div> + <div ref={messageListRef} className="relative mt-2 flex-1 overflow-y-auto px-4 pb-4"> + <div className="group relative -mx-4 rounded-lg px-4 py-2 transition-colors hover:bg-components-panel-on-panel-item-bg-hover"> + {isOwnComment && !isCommentEditing && ( + <div + className={cn( + 'absolute top-1 right-1 gap-1', + activeReplyMenuId === comment.id ? 'flex' : 'hidden group-hover:flex', + )} > - <DropdownMenuTrigger - className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" - aria-label={t($ => $['comments.aria.commentActions'], { ns: 'workflow' })} - > - <RiMoreFill className="size-4" /> - </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="w-36 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[10px]" + <DropdownMenu + open={activeReplyMenuId === comment.id} + onOpenChange={(open) => setActiveReplyMenuId(open ? comment.id : null)} > - <button - className="flex w-full items-center justify-start rounded-xl px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover" - onClick={(e) => { - e.stopPropagation() - handleStartCommentEdit() - }} + <DropdownMenuTrigger + className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" + aria-label={t(($) => $['comments.aria.commentActions'], { ns: 'workflow' })} > - {t($ => $['comments.actions.editComment'], { ns: 'workflow' })} - </button> - </DropdownMenuContent> - </DropdownMenu> - </div> - )} - {isCommentEditing - ? ( - <div className="flex gap-3 pt-1"> - <div className="shrink-0"> - <Avatar - name={comment.created_by_account?.name || t($ => $['comments.fallback.user'], { ns: 'workflow' })} - avatar={comment.created_by_account?.avatar_url || null} - size="sm" - className="size-8 rounded-full" + <RiMoreFill className="size-4" /> + </DropdownMenuTrigger> + <DropdownMenuContent + placement="bottom-end" + sideOffset={4} + popupClassName="w-36 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[10px]" + > + <button + className="flex w-full items-center justify-start rounded-xl px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover" + onClick={(e) => { + e.stopPropagation() + handleStartCommentEdit() + }} + > + {t(($) => $['comments.actions.editComment'], { ns: 'workflow' })} + </button> + </DropdownMenuContent> + </DropdownMenu> + </div> + )} + {isCommentEditing ? ( + <div className="flex gap-3 pt-1"> + <div className="shrink-0"> + <Avatar + name={ + comment.created_by_account?.name || + t(($) => $['comments.fallback.user'], { ns: 'workflow' }) + } + avatar={comment.created_by_account?.avatar_url || null} + size="sm" + className="size-8 rounded-full" + /> + </div> + <div className="min-w-0 flex-1"> + <div className="rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-1 shadow-md backdrop-blur-[10px]"> + <MentionInput + value={editingCommentContent} + onChange={setEditingCommentContent} + onSubmit={handleCommentEditSubmit} + onCancel={handleCancelCommentEdit} + placeholder={t(($) => $['comments.placeholder.editComment'], { + ns: 'workflow', + })} + disabled={loading} + loading={isSubmittingEdit} + isEditing={true} + className="system-sm-regular" + autoFocus /> </div> - <div className="min-w-0 flex-1"> - <div className="rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-1 shadow-md backdrop-blur-[10px]"> - <MentionInput - value={editingCommentContent} - onChange={setEditingCommentContent} - onSubmit={handleCommentEditSubmit} - onCancel={handleCancelCommentEdit} - placeholder={t($ => $['comments.placeholder.editComment'], { ns: 'workflow' })} - disabled={loading} - loading={isSubmittingEdit} - isEditing={true} - className="system-sm-regular" - autoFocus - /> - </div> - </div> </div> - ) - : ( - <ThreadMessage - authorId={comment.created_by_account?.id || ''} - authorName={comment.created_by_account?.name || t($ => $['comments.fallback.user'], { ns: 'workflow' })} - avatarUrl={comment.created_by_account?.avatar_url || null} - createdAt={comment.created_at ?? comment.updated_at ?? 0} - content={comment.content} - mentionableNames={mentionableNames} - /> - )} - </div> - {replies.length > 0 && ( - <div className="mt-2 space-y-3 pt-3"> - {replies.map((reply) => { - const isReplyEditing = editingReply?.id === reply.id - const isOwnReply = reply.created_by_account?.id === currentUserId - return ( - <div - key={reply.id} - className="group relative -mx-4 rounded-lg px-4 py-2 transition-colors hover:bg-components-panel-on-panel-item-bg-hover" - > - {isOwnReply && !isReplyEditing && ( - <div - className={cn( - 'absolute top-1 right-1 gap-1', - activeReplyMenuId === reply.id ? 'flex' : 'hidden group-hover:flex', - )} - data-reply-menu - > - <DropdownMenu - open={activeReplyMenuId === reply.id} - onOpenChange={(open) => { - if (!open) - setDeletingReplyId(null) - setActiveReplyMenuId(open ? reply.id : null) - }} + </div> + ) : ( + <ThreadMessage + authorId={comment.created_by_account?.id || ''} + authorName={ + comment.created_by_account?.name || + t(($) => $['comments.fallback.user'], { ns: 'workflow' }) + } + avatarUrl={comment.created_by_account?.avatar_url || null} + createdAt={comment.created_at ?? comment.updated_at ?? 0} + content={comment.content} + mentionableNames={mentionableNames} + /> + )} + </div> + {replies.length > 0 && ( + <div className="mt-2 space-y-3 pt-3"> + {replies.map((reply) => { + const isReplyEditing = editingReply?.id === reply.id + const isOwnReply = reply.created_by_account?.id === currentUserId + return ( + <div + key={reply.id} + className="group relative -mx-4 rounded-lg px-4 py-2 transition-colors hover:bg-components-panel-on-panel-item-bg-hover" + > + {isOwnReply && !isReplyEditing && ( + <div + className={cn( + 'absolute top-1 right-1 gap-1', + activeReplyMenuId === reply.id ? 'flex' : 'hidden group-hover:flex', + )} + data-reply-menu > - <DropdownMenuTrigger - className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" - aria-label={t($ => $['comments.aria.replyActions'], { ns: 'workflow' })} - > - <RiMoreFill className="size-4" /> - </DropdownMenuTrigger> - <DropdownMenuContent - placement="bottom-end" - sideOffset={4} - popupClassName="w-36 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[10px]" - data-reply-menu + <DropdownMenu + open={activeReplyMenuId === reply.id} + onOpenChange={(open) => { + if (!open) setDeletingReplyId(null) + setActiveReplyMenuId(open ? reply.id : null) + }} > - <div className={cn(deletingReplyId === reply.id ? 'hidden' : 'block')}> - <button - className="flex w-full items-center justify-start rounded-t-xl px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover" - onClick={(e) => { - e.stopPropagation() - handleStartEdit(reply) - }} - > - {t($ => $['comments.actions.editReply'], { ns: 'workflow' })} - </button> - <button - className="text-negative flex w-full items-center justify-start rounded-b-xl px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover" - onClick={(e) => { - e.stopPropagation() - e.preventDefault() - if (onReplyDeleteDirect) { - setDeletingReplyId(reply.id) - } - else { - setActiveReplyMenuId(null) - onReplyDelete?.(reply.id) - } - }} + <DropdownMenuTrigger + className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" + aria-label={t(($) => $['comments.aria.replyActions'], { + ns: 'workflow', + })} + > + <RiMoreFill className="size-4" /> + </DropdownMenuTrigger> + <DropdownMenuContent + placement="bottom-end" + sideOffset={4} + popupClassName="w-36 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[10px]" + data-reply-menu + > + <div + className={cn(deletingReplyId === reply.id ? 'hidden' : 'block')} > - {t($ => $['comments.actions.deleteReply'], { ns: 'workflow' })} - </button> - </div> + <button + className="flex w-full items-center justify-start rounded-t-xl px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover" + onClick={(e) => { + e.stopPropagation() + handleStartEdit(reply) + }} + > + {t(($) => $['comments.actions.editReply'], { ns: 'workflow' })} + </button> + <button + className="text-negative flex w-full items-center justify-start rounded-b-xl px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover" + onClick={(e) => { + e.stopPropagation() + e.preventDefault() + if (onReplyDeleteDirect) { + setDeletingReplyId(reply.id) + } else { + setActiveReplyMenuId(null) + onReplyDelete?.(reply.id) + } + }} + > + {t(($) => $['comments.actions.deleteReply'], { ns: 'workflow' })} + </button> + </div> - <div className={cn(deletingReplyId === reply.id ? 'block' : 'hidden')}> - <InlineDeleteConfirm - title={t($ => $['comments.actions.deleteReply'], { ns: 'workflow' })} - onConfirm={() => { - setDeletingReplyId(null) - setActiveReplyMenuId(null) - onReplyDeleteDirect?.(reply.id) - }} - onCancel={() => { - setDeletingReplyId(null) - }} - className="m-0 w-full border-0 shadow-none" - /> - </div> - </DropdownMenuContent> - </DropdownMenu> - </div> - )} - {isReplyEditing - ? ( - <div className="flex gap-3 pt-1"> - <div className="shrink-0"> - <Avatar - name={reply.created_by_account?.name || t($ => $['comments.fallback.user'], { ns: 'workflow' })} - avatar={reply.created_by_account?.avatar_url || null} - size="sm" - className="size-8 rounded-full" - /> - </div> - <div className="min-w-0 flex-1"> - <div className="rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-1 shadow-md backdrop-blur-[10px]"> - <MentionInput - value={editingReply?.content ?? ''} - onChange={newContent => setEditingReply(prev => prev ? { ...prev, content: newContent } : prev)} - onSubmit={handleEditSubmit} - onCancel={handleCancelEdit} - placeholder={t($ => $['comments.placeholder.editReply'], { ns: 'workflow' })} - disabled={loading} - loading={replyUpdating || isSubmittingEdit} - isEditing={true} - className="system-sm-regular" - autoFocus + <div + className={cn(deletingReplyId === reply.id ? 'block' : 'hidden')} + > + <InlineDeleteConfirm + title={t(($) => $['comments.actions.deleteReply'], { + ns: 'workflow', + })} + onConfirm={() => { + setDeletingReplyId(null) + setActiveReplyMenuId(null) + onReplyDeleteDirect?.(reply.id) + }} + onCancel={() => { + setDeletingReplyId(null) + }} + className="m-0 w-full border-0 shadow-none" /> </div> + </DropdownMenuContent> + </DropdownMenu> + </div> + )} + {isReplyEditing ? ( + <div className="flex gap-3 pt-1"> + <div className="shrink-0"> + <Avatar + name={ + reply.created_by_account?.name || + t(($) => $['comments.fallback.user'], { ns: 'workflow' }) + } + avatar={reply.created_by_account?.avatar_url || null} + size="sm" + className="size-8 rounded-full" + /> + </div> + <div className="min-w-0 flex-1"> + <div className="rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-1 shadow-md backdrop-blur-[10px]"> + <MentionInput + value={editingReply?.content ?? ''} + onChange={(newContent) => + setEditingReply((prev) => + prev ? { ...prev, content: newContent } : prev, + ) + } + onSubmit={handleEditSubmit} + onCancel={handleCancelEdit} + placeholder={t(($) => $['comments.placeholder.editReply'], { + ns: 'workflow', + })} + disabled={loading} + loading={replyUpdating || isSubmittingEdit} + isEditing={true} + className="system-sm-regular" + autoFocus + /> </div> </div> - ) - : ( - <ThreadMessage - authorId={reply.created_by_account?.id || ''} - authorName={reply.created_by_account?.name || t($ => $['comments.fallback.user'], { ns: 'workflow' })} - avatarUrl={reply.created_by_account?.avatar_url || null} - createdAt={reply.created_at ?? 0} - content={reply.content} - mentionableNames={mentionableNames} - /> - )} - </div> - ) - })} + </div> + ) : ( + <ThreadMessage + authorId={reply.created_by_account?.id || ''} + authorName={ + reply.created_by_account?.name || + t(($) => $['comments.fallback.user'], { ns: 'workflow' }) + } + avatarUrl={reply.created_by_account?.avatar_url || null} + createdAt={reply.created_at ?? 0} + content={reply.content} + mentionableNames={mentionableNames} + /> + )} + </div> + ) + })} + </div> + )} + </div> + {loading && ( + <div className="absolute inset-0 z-30 flex items-center justify-center bg-components-panel-bg/70 text-sm text-text-tertiary"> + {t(($) => $['comments.loading'], { ns: 'workflow' })} </div> )} - </div> - {loading && ( - <div className="absolute inset-0 z-30 flex items-center justify-center bg-components-panel-bg/70 text-sm text-text-tertiary"> - {t($ => $['comments.loading'], { ns: 'workflow' })} - </div> - )} - {onReply && ( - <div className="border-t border-components-panel-border px-4 py-3"> - <div className="flex items-center gap-3"> - <Avatar - avatar={userProfile?.avatar_url || null} - name={userProfile?.name || t($ => $.you, { ns: 'common' })} - size="sm" - className="size-8" - /> - <div className="flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-[2px] shadow-sm"> - <MentionInput - ref={replyInputRef} - value={replyContent} - onChange={setReplyContent} - onSubmit={handleReplySubmit} - placeholder={t($ => $['comments.placeholder.reply'], { ns: 'workflow' })} - disabled={loading} - loading={replySubmitting} + {onReply && ( + <div className="border-t border-components-panel-border px-4 py-3"> + <div className="flex items-center gap-3"> + <Avatar + avatar={userProfile?.avatar_url || null} + name={userProfile?.name || t(($) => $.you, { ns: 'common' })} + size="sm" + className="size-8" /> + <div className="flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-[2px] shadow-sm"> + <MentionInput + ref={replyInputRef} + value={replyContent} + onChange={setReplyContent} + onSubmit={handleReplySubmit} + placeholder={t(($) => $['comments.placeholder.reply'], { ns: 'workflow' })} + disabled={loading} + loading={replySubmitting} + /> + </div> </div> </div> - </div> - )} + )} + </div> </div> - </div> - ) -}) + ) + }, +) CommentThread.displayName = 'CommentThread' diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index f5d0460ce721c8..a73227662f0d6a 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -63,7 +63,7 @@ export const getGlobalVars = (isChatMode: boolean): Var[] => { variable: 'sys.workflow_run_id', type: VarType.string, }, - ...((isInWorkflow && !isChatMode) + ...(isInWorkflow && !isChatMode ? [ { variable: 'sys.timestamp', diff --git a/web/app/components/workflow/constants/node.ts b/web/app/components/workflow/constants/node.ts index 41269044245ff1..1c58768a441747 100644 --- a/web/app/components/workflow/constants/node.ts +++ b/web/app/components/workflow/constants/node.ts @@ -2,16 +2,13 @@ import agentV2Default from '@/app/components/workflow/nodes/agent-v2/default' import agentDefault from '@/app/components/workflow/nodes/agent/default' import assignerDefault from '@/app/components/workflow/nodes/assigner/default' import codeDefault from '@/app/components/workflow/nodes/code/default' - import documentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default' - import httpRequestDefault from '@/app/components/workflow/nodes/http/default' import humanInputDefault from '@/app/components/workflow/nodes/human-input/default' import ifElseDefault from '@/app/components/workflow/nodes/if-else/default' import iterationStartDefault from '@/app/components/workflow/nodes/iteration-start/default' import iterationDefault from '@/app/components/workflow/nodes/iteration/default' import knowledgeRetrievalDefault from '@/app/components/workflow/nodes/knowledge-retrieval/default' - import listOperatorDefault from '@/app/components/workflow/nodes/list-operator/default' import llmDefault from '@/app/components/workflow/nodes/llm/default' import loopEndDefault from '@/app/components/workflow/nodes/loop-end/default' diff --git a/web/app/components/workflow/context.tsx b/web/app/components/workflow/context.tsx index 4e37837c8eed0e..69ea1ba8aa6e8c 100644 --- a/web/app/components/workflow/context.tsx +++ b/web/app/components/workflow/context.tsx @@ -1,12 +1,7 @@ import type { StateCreator } from 'zustand' import type { SliceFromInjection } from './store/workflow' -import { - createContext, - useRef, -} from 'react' -import { - createWorkflowStore, -} from './store/workflow' +import { createContext, useRef } from 'react' +import { createWorkflowStore } from './store/workflow' type WorkflowStore = ReturnType<typeof createWorkflowStore> export const WorkflowContext = createContext<WorkflowStore | null>(null) @@ -15,15 +10,13 @@ type WorkflowProviderProps = { children: React.ReactNode injectWorkflowStoreSliceFn?: StateCreator<SliceFromInjection> } -export const WorkflowContextProvider = ({ children, injectWorkflowStoreSliceFn }: WorkflowProviderProps) => { +export const WorkflowContextProvider = ({ + children, + injectWorkflowStoreSliceFn, +}: WorkflowProviderProps) => { const storeRef = useRef<WorkflowStore | undefined>(undefined) - if (!storeRef.current) - storeRef.current = createWorkflowStore({ injectWorkflowStoreSliceFn }) + if (!storeRef.current) storeRef.current = createWorkflowStore({ injectWorkflowStoreSliceFn }) - return ( - <WorkflowContext.Provider value={storeRef.current}> - {children} - </WorkflowContext.Provider> - ) + return <WorkflowContext.Provider value={storeRef.current}>{children}</WorkflowContext.Provider> } diff --git a/web/app/components/workflow/custom-connection-line.tsx b/web/app/components/workflow/custom-connection-line.tsx index fb218722c49bbe..871ce5b49494bd 100644 --- a/web/app/components/workflow/custom-connection-line.tsx +++ b/web/app/components/workflow/custom-connection-line.tsx @@ -1,14 +1,9 @@ import type { ConnectionLineComponentProps } from 'reactflow' import { memo } from 'react' -import { - getBezierPath, - Position, -} from 'reactflow' +import { getBezierPath, Position } from 'reactflow' const CustomConnectionLine = ({ fromX, fromY, toX, toY }: ConnectionLineComponentProps) => { - const [ - edgePath, - ] = getBezierPath({ + const [edgePath] = getBezierPath({ sourceX: fromX, sourceY: fromY, sourcePosition: Position.Right, @@ -20,19 +15,8 @@ const CustomConnectionLine = ({ fromX, fromY, toX, toY }: ConnectionLineComponen return ( <g> - <path - fill="none" - stroke="#D0D5DD" - strokeWidth={2} - d={edgePath} - /> - <rect - x={toX} - y={toY - 4} - width={2} - height={8} - fill="#2970FF" - /> + <path fill="none" stroke="#D0D5DD" strokeWidth={2} d={edgePath} /> + <rect x={toX} y={toY - 4} width={2} height={8} fill="#2970FF" /> </g> ) } diff --git a/web/app/components/workflow/custom-edge-linear-gradient-render.tsx b/web/app/components/workflow/custom-edge-linear-gradient-render.tsx index 313fa2cafd2053..2a6d685a2fa395 100644 --- a/web/app/components/workflow/custom-edge-linear-gradient-render.tsx +++ b/web/app/components/workflow/custom-edge-linear-gradient-render.tsx @@ -15,22 +15,10 @@ const CustomEdgeLinearGradientRender = ({ stopColor, position, }: CustomEdgeLinearGradientRenderProps) => { - const { - x1, - x2, - y1, - y2, - } = position + const { x1, x2, y1, y2 } = position return ( <defs> - <linearGradient - id={id} - gradientUnits="userSpaceOnUse" - x1={x1} - y1={y1} - x2={x2} - y2={y2} - > + <linearGradient id={id} gradientUnits="userSpaceOnUse" x1={x1} y1={y1} x2={x2} y2={y2}> <stop offset="0%" style={{ diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index 135a4c5b672c72..730376fbf1fdaf 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/web/app/components/workflow/custom-edge.tsx @@ -1,29 +1,13 @@ import type { EdgeProps } from 'reactflow' -import type { - Edge, - OnSelectBlock, -} from './types' +import type { Edge, OnSelectBlock } from './types' import { intersection } from 'es-toolkit/array' -import { - memo, - useCallback, - useMemo, - useState, -} from 'react' -import { - BaseEdge, - EdgeLabelRenderer, - getBezierPath, - Position, -} from 'reactflow' +import { memo, useCallback, useMemo, useState } from 'react' +import { BaseEdge, EdgeLabelRenderer, getBezierPath, Position } from 'reactflow' import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' import BlockSelector from './block-selector' import { NESTED_ELEMENT_Z_INDEX } from './constants' import CustomEdgeLinearGradientRender from './custom-edge-linear-gradient-render' -import { - useAvailableBlocks, - useNodesInteractions, -} from './hooks' +import { useAvailableBlocks, useNodesInteractions } from './hooks' import { NodeRunningStatus } from './types' import { getEdgeColor } from './utils' @@ -40,11 +24,7 @@ const CustomEdge = ({ targetY, selected, }: EdgeProps) => { - const [ - edgePath, - labelX, - labelY, - ] = getBezierPath({ + const [edgePath, labelX, labelY] = getBezierPath({ sourceX: sourceX - 8, sourceY, sourcePosition: Position.Right, @@ -56,26 +36,26 @@ const CustomEdge = ({ const [open, setOpen] = useState(false) const [isTriggerHovered, setIsTriggerHovered] = useState(false) const { handleNodeAdd } = useNodesInteractions() - const { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration || (data as Edge['data'])?.isInLoop) - const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration || (data as Edge['data'])?.isInLoop) - const { - _sourceRunningStatus, - _targetRunningStatus, - } = data + const { availablePrevBlocks } = useAvailableBlocks( + (data as Edge['data'])!.targetType, + (data as Edge['data'])?.isInIteration || (data as Edge['data'])?.isInLoop, + ) + const { availableNextBlocks } = useAvailableBlocks( + (data as Edge['data'])!.sourceType, + (data as Edge['data'])?.isInIteration || (data as Edge['data'])?.isInLoop, + ) + const { _sourceRunningStatus, _targetRunningStatus } = data const isTriggerVisible = !!(data?._hovering || isTriggerHovered || open) const linearGradientId = useMemo(() => { if ( - ( - _sourceRunningStatus === NodeRunningStatus.Succeeded - || _sourceRunningStatus === NodeRunningStatus.Failed - || _sourceRunningStatus === NodeRunningStatus.Exception - ) && ( - _targetRunningStatus === NodeRunningStatus.Succeeded - || _targetRunningStatus === NodeRunningStatus.Failed - || _targetRunningStatus === NodeRunningStatus.Exception - || _targetRunningStatus === NodeRunningStatus.Running - ) + (_sourceRunningStatus === NodeRunningStatus.Succeeded || + _sourceRunningStatus === NodeRunningStatus.Failed || + _sourceRunningStatus === NodeRunningStatus.Exception) && + (_targetRunningStatus === NodeRunningStatus.Succeeded || + _targetRunningStatus === NodeRunningStatus.Failed || + _targetRunningStatus === NodeRunningStatus.Exception || + _targetRunningStatus === NodeRunningStatus.Running) ) { return id } @@ -85,58 +65,60 @@ const CustomEdge = ({ setOpen(v) }, []) - const handleInsert = useCallback<OnSelectBlock>((nodeType, pluginDefaultValue) => { - handleNodeAdd( - { - nodeType, - pluginDefaultValue, - }, - { - prevNodeId: source, - prevNodeSourceHandle: sourceHandleId || 'source', - nextNodeId: target, - nextNodeTargetHandle: targetHandleId || 'target', - }, - ) - }, [handleNodeAdd, source, sourceHandleId, target, targetHandleId]) + const handleInsert = useCallback<OnSelectBlock>( + (nodeType, pluginDefaultValue) => { + handleNodeAdd( + { + nodeType, + pluginDefaultValue, + }, + { + prevNodeId: source, + prevNodeSourceHandle: sourceHandleId || 'source', + nextNodeId: target, + nextNodeTargetHandle: targetHandleId || 'target', + }, + ) + }, + [handleNodeAdd, source, sourceHandleId, target, targetHandleId], + ) const stroke = useMemo(() => { - if (selected) - return getEdgeColor(NodeRunningStatus.Running) + if (selected) return getEdgeColor(NodeRunningStatus.Running) - if (linearGradientId) - return `url(#${linearGradientId})` + if (linearGradientId) return `url(#${linearGradientId})` if (data?._connectedNodeIsHovering) - return getEdgeColor(NodeRunningStatus.Running, sourceHandleId === ErrorHandleTypeEnum.failBranch) + return getEdgeColor( + NodeRunningStatus.Running, + sourceHandleId === ErrorHandleTypeEnum.failBranch, + ) return getEdgeColor() }, [data._connectedNodeIsHovering, linearGradientId, selected, sourceHandleId]) return ( <> - { - linearGradientId && ( - <CustomEdgeLinearGradientRender - id={linearGradientId} - startColor={getEdgeColor(_sourceRunningStatus)} - stopColor={getEdgeColor(_targetRunningStatus)} - position={{ - x1: sourceX, - y1: sourceY, - x2: targetX, - y2: targetY, - }} - /> - ) - } + {linearGradientId && ( + <CustomEdgeLinearGradientRender + id={linearGradientId} + startColor={getEdgeColor(_sourceRunningStatus)} + stopColor={getEdgeColor(_targetRunningStatus)} + position={{ + x1: sourceX, + y1: sourceY, + x2: targetX, + y2: targetY, + }} + /> + )} <BaseEdge id={id} path={edgePath} style={{ stroke, strokeWidth: 2, - opacity: data._dimmed ? 0.3 : (data._waitingRun ? 0.7 : 1), + opacity: data._dimmed ? 0.3 : data._waitingRun ? 0.7 : 1, strokeDasharray: data._isTemp ? '8 8' : undefined, }} /> @@ -148,9 +130,7 @@ const CustomEdge = ({ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, pointerEvents: isTriggerVisible ? 'all' : 'none', opacity: isTriggerVisible ? (data._waitingRun ? 0.7 : 1) : 0, - zIndex: data.isInIteration || data.isInLoop - ? NESTED_ELEMENT_Z_INDEX - : undefined, + zIndex: data.isInIteration || data.isInLoop ? NESTED_ELEMENT_Z_INDEX : undefined, }} onMouseEnter={() => setIsTriggerHovered(true)} onMouseLeave={() => setIsTriggerHovered(false)} diff --git a/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx b/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx index c3c3eaf9111c55..09ae01f95b71e0 100644 --- a/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx +++ b/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx @@ -12,26 +12,28 @@ vi.mock('@/service/datasets', () => ({ })) const Consumer = () => { - const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length) + const datasetCount = useDatasetsDetailStore((state) => Object.keys(state.datasetsDetail).length) return <div>{`dataset-count:${datasetCount}`}</div> } -const createWorkflowNode = (datasetIds: string[] = []): Node => ({ - id: `node-${datasetIds.join('-') || 'empty'}`, - type: 'custom', - position: { x: 0, y: 0 }, - data: { - title: 'Knowledge', - desc: '', - type: BlockEnum.KnowledgeRetrieval, - dataset_ids: datasetIds, - }, -} as unknown as Node) +const createWorkflowNode = (datasetIds: string[] = []): Node => + ({ + id: `node-${datasetIds.join('-') || 'empty'}`, + type: 'custom', + position: { x: 0, y: 0 }, + data: { + title: 'Knowledge', + desc: '', + type: BlockEnum.KnowledgeRetrieval, + dataset_ids: datasetIds, + }, + }) as unknown as Node -const createDataset = (id: string): DataSet => ({ - id, - name: `Dataset ${id}`, -} as DataSet) +const createDataset = (id: string): DataSet => + ({ + id, + name: `Dataset ${id}`, + }) as DataSet describe('datasets-detail-store provider', () => { beforeEach(() => { @@ -41,18 +43,19 @@ describe('datasets-detail-store provider', () => { it('should provide the datasets detail store without fetching when no knowledge datasets are selected', () => { render( - <DatasetsDetailProvider nodes={[ - { - id: 'node-start', - type: 'custom', - position: { x: 0, y: 0 }, - data: { - title: 'Start', - desc: '', - type: BlockEnum.Start, - }, - } as unknown as Node, - ]} + <DatasetsDetailProvider + nodes={[ + { + id: 'node-start', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + title: 'Start', + desc: '', + type: BlockEnum.Start, + }, + } as unknown as Node, + ]} > <Consumer /> </DatasetsDetailProvider>, @@ -68,10 +71,8 @@ describe('datasets-detail-store provider', () => { }) render( - <DatasetsDetailProvider nodes={[ - createWorkflowNode(['dataset-1', 'dataset-2']), - createWorkflowNode(['dataset-2']), - ]} + <DatasetsDetailProvider + nodes={[createWorkflowNode(['dataset-1', 'dataset-2']), createWorkflowNode(['dataset-2'])]} > <Consumer /> </DatasetsDetailProvider>, diff --git a/web/app/components/workflow/datasets-detail-store/__tests__/store.spec.tsx b/web/app/components/workflow/datasets-detail-store/__tests__/store.spec.tsx index a031c6370eb53c..0b8c1e90692c9d 100644 --- a/web/app/components/workflow/datasets-detail-store/__tests__/store.spec.tsx +++ b/web/app/components/workflow/datasets-detail-store/__tests__/store.spec.tsx @@ -52,13 +52,13 @@ describe('datasets-detail-store store', () => { it('merges dataset details by id', () => { const store = createDatasetsDetailStore() - store.getState().updateDatasetsDetail([ - createDataset('dataset-1', 'Dataset One'), - createDataset('dataset-2', 'Dataset Two'), - ]) - store.getState().updateDatasetsDetail([ - createDataset('dataset-2', 'Dataset Two Updated'), - ]) + store + .getState() + .updateDatasetsDetail([ + createDataset('dataset-1', 'Dataset One'), + createDataset('dataset-2', 'Dataset Two'), + ]) + store.getState().updateDatasetsDetail([createDataset('dataset-2', 'Dataset Two Updated')]) expect(store.getState().datasetsDetail).toMatchObject({ 'dataset-1': { name: 'Dataset One' }, @@ -70,13 +70,11 @@ describe('datasets-detail-store store', () => { const store = createDatasetsDetailStore() store.getState().updateDatasetsDetail([createDataset('dataset-3')]) const wrapper = ({ children }: { children: React.ReactNode }) => ( - <DatasetsDetailContext.Provider value={store}> - {children} - </DatasetsDetailContext.Provider> + <DatasetsDetailContext.Provider value={store}>{children}</DatasetsDetailContext.Provider> ) const { result } = renderHook( - () => useDatasetsDetailStore(state => state.datasetsDetail['dataset-3']?.name), + () => useDatasetsDetailStore((state) => state.datasetsDetail['dataset-3']?.name), { wrapper }, ) @@ -84,7 +82,7 @@ describe('datasets-detail-store store', () => { }) it('throws when the datasets detail provider is missing', () => { - expect(() => renderHook(() => useDatasetsDetailStore(state => state.datasetsDetail))).toThrow( + expect(() => renderHook(() => useDatasetsDetailStore((state) => state.datasetsDetail))).toThrow( 'Missing DatasetsDetailContext.Provider in the tree', ) }) diff --git a/web/app/components/workflow/datasets-detail-store/provider.tsx b/web/app/components/workflow/datasets-detail-store/provider.tsx index 7cd3b88bfb3ddb..e2012597e0544a 100644 --- a/web/app/components/workflow/datasets-detail-store/provider.tsx +++ b/web/app/components/workflow/datasets-detail-store/provider.tsx @@ -17,30 +17,31 @@ type DatasetsDetailProviderProps = { children: React.ReactNode } -const DatasetsDetailProvider: FC<DatasetsDetailProviderProps> = ({ - nodes, - children, -}) => { +const DatasetsDetailProvider: FC<DatasetsDetailProviderProps> = ({ nodes, children }) => { const storeRef = useRef<DatasetsDetailStoreApi>(undefined) - if (!storeRef.current) - storeRef.current = createDatasetsDetailStore() + if (!storeRef.current) storeRef.current = createDatasetsDetailStore() const updateDatasetsDetail = useCallback(async (datasetIds: string[]) => { - const { data: datasetsDetail } = await fetchDatasets({ url: '/datasets', params: { page: 1, ids: datasetIds } }) + const { data: datasetsDetail } = await fetchDatasets({ + url: '/datasets', + params: { page: 1, ids: datasetIds }, + }) if (datasetsDetail && datasetsDetail.length > 0) storeRef.current!.getState().updateDatasetsDetail(datasetsDetail) }, []) useEffect(() => { - if (!storeRef.current) - return - const knowledgeRetrievalNodes = nodes.filter(node => node.data.type === BlockEnum.KnowledgeRetrieval) + if (!storeRef.current) return + const knowledgeRetrievalNodes = nodes.filter( + (node) => node.data.type === BlockEnum.KnowledgeRetrieval, + ) const allDatasetIds = knowledgeRetrievalNodes.reduce<string[]>((acc, node) => { - return Array.from(new Set([...acc, ...(node.data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids])) + return Array.from( + new Set([...acc, ...(node.data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids]), + ) }, []) - if (allDatasetIds.length === 0) - return + if (allDatasetIds.length === 0) return updateDatasetsDetail(allDatasetIds) }, []) diff --git a/web/app/components/workflow/datasets-detail-store/store.ts b/web/app/components/workflow/datasets-detail-store/store.ts index 1347ee375a61a9..f91d7ce8c782e0 100644 --- a/web/app/components/workflow/datasets-detail-store/store.ts +++ b/web/app/components/workflow/datasets-detail-store/store.ts @@ -31,8 +31,7 @@ export const createDatasetsDetailStore = () => { export const useDatasetsDetailStore = <T>(selector: (state: DatasetsDetailStore) => T): T => { const store = use(DatasetsDetailContext) - if (!store) - throw new Error('Missing DatasetsDetailContext.Provider in the tree') + if (!store) throw new Error('Missing DatasetsDetailContext.Provider in the tree') return useStore(store, selector) } diff --git a/web/app/components/workflow/dsl-export-confirm-modal.tsx b/web/app/components/workflow/dsl-export-confirm-modal.tsx index 19185f24bab8c1..2aecf8269f256c 100644 --- a/web/app/components/workflow/dsl-export-confirm-modal.tsx +++ b/web/app/components/workflow/dsl-export-confirm-modal.tsx @@ -36,16 +36,14 @@ export const DSLExportConfirmContent = ({ const [isExporting, setIsExporting] = useState(false) const submit = useCallback(async () => { - if (isExporting) - return + if (isExporting) return setIsExporting(true) onExportingChange?.(true) try { await onConfirm(exportSecrets) onClose() - } - finally { + } finally { setIsExporting(false) onExportingChange?.(false) } @@ -55,29 +53,53 @@ export const DSLExportConfirmContent = ({ <AlertDialogContent className="w-120 max-w-120"> <div className="px-6 pt-6"> <AlertDialogTitle className="pb-6 title-2xl-semi-bold text-text-primary"> - {t($ => $['env.export.title'], { ns: 'workflow' })} + {t(($) => $['env.export.title'], { ns: 'workflow' })} </AlertDialogTitle> <div className="relative"> <table className="w-full border-separate border-spacing-0 rounded-lg border border-divider-regular shadow-xs"> <thead className="system-xs-medium-uppercase text-text-tertiary"> <tr> - <td width={220} className="h-7 border-r border-b border-divider-regular pl-3">{t($ => $['env.export.name'], { ns: 'workflow' })}</td> - <td className="h-7 border-b border-divider-regular pl-3">{t($ => $['env.export.value'], { ns: 'workflow' })}</td> + <td width={220} className="h-7 border-r border-b border-divider-regular pl-3"> + {t(($) => $['env.export.name'], { ns: 'workflow' })} + </td> + <td className="h-7 border-b border-divider-regular pl-3"> + {t(($) => $['env.export.value'], { ns: 'workflow' })} + </td> </tr> </thead> <tbody> {envList.map((env, index) => ( <tr key={env.name}> - <td className={cn('h-7 border-r border-divider-regular pl-3 system-xs-medium', index + 1 !== envList.length && 'border-b border-divider-regular')}> + <td + className={cn( + 'h-7 border-r border-divider-regular pl-3 system-xs-medium', + index + 1 !== envList.length && 'border-b border-divider-regular', + )} + > <div className="flex w-50 items-center gap-1"> - <span aria-hidden="true" className="i-custom-vender-line-others-env size-4 shrink-0 text-util-colors-violet-violet-600" /> + <span + aria-hidden="true" + className="i-custom-vender-line-others-env size-4 shrink-0 text-util-colors-violet-violet-600" + /> <div className="truncate text-text-primary">{env.name}</div> - <div className="shrink-0 text-text-tertiary">{t($ => $['env.export.secret'], { ns: 'workflow' })}</div> - <span aria-hidden="true" className="i-ri-lock-2-line size-3 shrink-0 text-text-tertiary" /> + <div className="shrink-0 text-text-tertiary"> + {t(($) => $['env.export.secret'], { ns: 'workflow' })} + </div> + <span + aria-hidden="true" + className="i-ri-lock-2-line size-3 shrink-0 text-text-tertiary" + /> </div> </td> - <td className={cn('h-7 pl-3', index + 1 !== envList.length && 'border-b border-divider-regular')}> - <div className="truncate system-xs-regular text-text-secondary">{env.value}</div> + <td + className={cn( + 'h-7 pl-3', + index + 1 !== envList.length && 'border-b border-divider-regular', + )} + > + <div className="truncate system-xs-regular text-text-secondary"> + {env.value} + </div> </td> </tr> ))} @@ -97,13 +119,13 @@ export const DSLExportConfirmContent = ({ isExporting && 'cursor-not-allowed opacity-50', )} > - {t($ => $['env.export.checkbox'], { ns: 'workflow' })} + {t(($) => $['env.export.checkbox'], { ns: 'workflow' })} </span> </label> </div> <AlertDialogActions> <AlertDialogCancelButton disabled={isExporting}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton tone="default" @@ -112,10 +134,10 @@ export const DSLExportConfirmContent = ({ onClick={submit} > {isExporting - ? t($ => $['operation.exporting'], { ns: 'common' }) + ? t(($) => $['operation.exporting'], { ns: 'common' }) : exportSecrets - ? t($ => $['env.export.export'], { ns: 'workflow' }) - : t($ => $['env.export.ignore'], { ns: 'workflow' })} + ? t(($) => $['env.export.export'], { ns: 'workflow' }) + : t(($) => $['env.export.ignore'], { ns: 'workflow' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> @@ -127,12 +149,14 @@ const DSLExportConfirmModal = (props: DSLExportConfirmModalProps) => { const [isExporting, setIsExporting] = useState(false) const isDialogOpen = envList.length > 0 - const handleOpenChange = useCallback((open: boolean) => { - if (open || isExporting) - return + const handleOpenChange = useCallback( + (open: boolean) => { + if (open || isExporting) return - onClose() - }, [isExporting, onClose]) + onClose() + }, + [isExporting, onClose], + ) return ( <AlertDialog open={isDialogOpen} onOpenChange={handleOpenChange}> diff --git a/web/app/components/workflow/edge-contextmenu.tsx b/web/app/components/workflow/edge-contextmenu.tsx index 5421dba1fa8601..ff351020f7c0ce 100644 --- a/web/app/components/workflow/edge-contextmenu.tsx +++ b/web/app/components/workflow/edge-contextmenu.tsx @@ -1,33 +1,22 @@ -import { - ContextMenuContent, - ContextMenuItem, -} from '@langgenius/dify-ui/context-menu' +import { ContextMenuContent, ContextMenuItem } from '@langgenius/dify-ui/context-menu' import { useTranslation } from 'react-i18next' import { useEdges } from 'reactflow' import { useEdgesInteractions } from './hooks' import { ShortcutKbd } from './shortcuts/shortcut-kbd' import { useStore } from './store' -export function EdgeContextmenu({ - onClose, -}: { - onClose: () => void -}) { +export function EdgeContextmenu({ onClose }: { onClose: () => void }) { const { t } = useTranslation() - const contextMenuTarget = useStore(s => s.contextMenuTarget) + const contextMenuTarget = useStore((s) => s.contextMenuTarget) const edgeId = contextMenuTarget?.type === 'edge' ? contextMenuTarget.edgeId : undefined const { handleEdgeDeleteById } = useEdgesInteractions() const edges = useEdges() - const currentEdgeExists = !edgeId || edges.some(edge => edge.id === edgeId) + const currentEdgeExists = !edgeId || edges.some((edge) => edge.id === edgeId) - if (!edgeId || !currentEdgeExists) - return null + if (!edgeId || !currentEdgeExists) return null return ( - <ContextMenuContent - popupClassName="rounded-lg" - sideOffset={4} - > + <ContextMenuContent popupClassName="rounded-lg" sideOffset={4}> <ContextMenuItem variant="destructive" className="justify-between gap-4 px-3" @@ -36,7 +25,7 @@ export function EdgeContextmenu({ onClose() }} > - <span>{t($ => $['operation.delete'], { ns: 'common' })}</span> + <span>{t(($) => $['operation.delete'], { ns: 'common' })}</span> <ShortcutKbd shortcut="workflow.delete" /> </ContextMenuItem> </ContextMenuContent> diff --git a/web/app/components/workflow/features.tsx b/web/app/components/workflow/features.tsx index eb0ec026123324..f466ab1a580c25 100644 --- a/web/app/components/workflow/features.tsx +++ b/web/app/components/workflow/features.tsx @@ -2,31 +2,25 @@ import type { StartNodeType } from './nodes/start/types' import type { CommonNodeType, InputVar, Node } from './types' import type { PromptVariable } from '@/models/debug' import type { WorkflowDraftFeaturesPayload } from '@/service/workflow' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' import { useNodes } from 'reactflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' import NewFeaturePanel from '@/app/components/base/features/new-feature-panel' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { updateFeatures } from '@/service/workflow' -import { - useIsChatMode, - useNodesReadOnly, -} from './hooks' +import { useIsChatMode, useNodesReadOnly } from './hooks' import useConfig from './nodes/start/use-config' import { useStore } from './store' import { InputVarType } from './types' const Features = () => { - const setShowFeaturesPanel = useStore(s => s.setShowFeaturesPanel) - const appId = useStore(s => s.appId) + const setShowFeaturesPanel = useStore((s) => s.setShowFeaturesPanel) + const appId = useStore((s) => s.appId) const isChatMode = useIsChatMode() const { nodesReadOnly } = useNodesReadOnly() const featuresStore = useFeaturesStore() const nodes = useNodes<CommonNodeType>() - const startNode = nodes.find(node => node.data.type === 'start') + const startNode = nodes.find((node) => node.data.type === 'start') const { id, data } = startNode as Node<StartNodeType> const { handleAddVariable } = useConfig(id, data) @@ -44,16 +38,19 @@ const Features = () => { } const handleFeaturesChange = useCallback(async () => { - if (!appId || !featuresStore) - return + if (!appId || !featuresStore) return try { const currentFeatures = featuresStore.getState().features // Transform features to match the expected server format (same as doSyncWorkflowDraft) const transformedFeatures: WorkflowDraftFeaturesPayload = { - opening_statement: currentFeatures.opening?.enabled ? (currentFeatures.opening?.opening_statement || '') : '', - suggested_questions: currentFeatures.opening?.enabled ? (currentFeatures.opening?.suggested_questions || []) : [], + opening_statement: currentFeatures.opening?.enabled + ? currentFeatures.opening?.opening_statement || '' + : '', + suggested_questions: currentFeatures.opening?.enabled + ? currentFeatures.opening?.suggested_questions || [] + : [], suggested_questions_after_answer: currentFeatures.suggested, text_to_speech: currentFeatures.text2speech, speech_to_text: currentFeatures.speech2text, @@ -74,8 +71,7 @@ const Features = () => { type: 'vars_and_features_update', }) } - } - catch (error) { + } catch (error) { console.error('Failed to update features:', error) } diff --git a/web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx b/web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx index 3fdd586aac99f7..fb0c5c13ae7de9 100644 --- a/web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx +++ b/web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx @@ -41,7 +41,11 @@ describe('ChatVariableButton', () => { }, }) - expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-xs') + expect(screen.getByRole('button')).toHaveClass( + 'border-black/5', + 'bg-white/10', + 'backdrop-blur-xs', + ) }) it('stays disabled without mutating panel state', () => { diff --git a/web/app/components/workflow/header/__tests__/env-button.spec.tsx b/web/app/components/workflow/header/__tests__/env-button.spec.tsx index b98fb3f8e8d2a9..2f9566cc7b73c1 100644 --- a/web/app/components/workflow/header/__tests__/env-button.spec.tsx +++ b/web/app/components/workflow/header/__tests__/env-button.spec.tsx @@ -49,7 +49,11 @@ describe('EnvButton', () => { }, }) - expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-xs') + expect(screen.getByRole('button')).toHaveClass( + 'border-black/5', + 'bg-white/10', + 'backdrop-blur-xs', + ) }) it('should keep the button disabled when the disabled prop is true', () => { diff --git a/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx b/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx index bc056e3288b783..f81e34fff7f2c1 100644 --- a/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx +++ b/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx @@ -49,7 +49,11 @@ describe('GlobalVariableButton', () => { }, }) - expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-xs') + expect(screen.getByRole('button')).toHaveClass( + 'border-black/5', + 'bg-white/10', + 'backdrop-blur-xs', + ) }) it('should keep the button disabled when the disabled prop is true', () => { diff --git a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx index 8334066ebc241d..31eee0043ec4bc 100644 --- a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx @@ -42,7 +42,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) 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 64f09f3cf12a93..4591980deaccf1 100644 --- a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx @@ -54,7 +54,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -127,11 +128,15 @@ vi.mock('../online-users', () => ({ })) vi.mock('../env-button', () => ({ - default: ({ disabled }: { disabled: boolean }) => <div data-testid="env-button">{`${disabled}`}</div>, + default: ({ disabled }: { disabled: boolean }) => ( + <div data-testid="env-button">{`${disabled}`}</div> + ), })) vi.mock('../global-variable-button', () => ({ - default: ({ disabled }: { disabled: boolean }) => <div data-testid="global-variable-button">{`${disabled}`}</div>, + default: ({ disabled }: { disabled: boolean }) => ( + <div data-testid="global-variable-button">{`${disabled}`}</div> + ), })) vi.mock('../run-and-history', () => ({ @@ -278,22 +283,19 @@ describe('Header layout components', () => { describe('HeaderInRestoring', () => { it('should cancel restoring mode and reopen the editor state', () => { - const { store } = renderWorkflowComponent( - <HeaderInRestoring />, - { - initialStoreState: { - isRestoring: true, - showWorkflowVersionHistoryPanel: true, - }, - hooksStoreProps: { - configsMap: { - flowType: FlowType.appFlow, - flowId: 'flow-1', - fileSettings: {}, - }, + const { store } = renderWorkflowComponent(<HeaderInRestoring />, { + initialStoreState: { + isRestoring: true, + showWorkflowVersionHistoryPanel: true, + }, + hooksStoreProps: { + configsMap: { + flowType: FlowType.appFlow, + flowId: 'flow-1', + fileSettings: {}, }, }, - ) + }) fireEvent.click(screen.getByRole('button', { name: 'workflow.common.exitVersions' })) @@ -360,29 +362,28 @@ describe('Header layout components', () => { it('should restore rag pipeline versions without emitting collaboration events', async () => { const currentVersion = createCurrentVersion() - renderWorkflowComponent( - <HeaderInRestoring />, - { - initialStoreState: { - isRestoring: true, - showWorkflowVersionHistoryPanel: true, - backupDraft: createBackupDraft(), - currentVersion, - }, - hooksStoreProps: { - configsMap: { - flowType: FlowType.ragPipeline, - flowId: 'pipeline-1', - fileSettings: {}, - }, + renderWorkflowComponent(<HeaderInRestoring />, { + initialStoreState: { + isRestoring: true, + showWorkflowVersionHistoryPanel: true, + backupDraft: createBackupDraft(), + currentVersion, + }, + hooksStoreProps: { + configsMap: { + flowType: FlowType.ragPipeline, + flowId: 'pipeline-1', + fileSettings: {}, }, }, - ) + }) fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' })) await waitFor(() => { - expect(mockRestoreWorkflow).toHaveBeenCalledWith('/rag/pipelines/pipeline-1/workflows/version-1/restore') + expect(mockRestoreWorkflow).toHaveBeenCalledWith( + '/rag/pipelines/pipeline-1/workflows/version-1/restore', + ) expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1) }) expect(mockEmitRestoreIntent).not.toHaveBeenCalled() @@ -393,29 +394,28 @@ describe('Header layout components', () => { it('should restore snippet versions through snippet routes without emitting collaboration events', async () => { const currentVersion = createCurrentVersion() - renderWorkflowComponent( - <HeaderInRestoring />, - { - initialStoreState: { - isRestoring: true, - showWorkflowVersionHistoryPanel: true, - backupDraft: createBackupDraft(), - currentVersion, - }, - hooksStoreProps: { - configsMap: { - flowType: FlowType.snippet, - flowId: 'snippet-1', - fileSettings: {}, - }, + renderWorkflowComponent(<HeaderInRestoring />, { + initialStoreState: { + isRestoring: true, + showWorkflowVersionHistoryPanel: true, + backupDraft: createBackupDraft(), + currentVersion, + }, + hooksStoreProps: { + configsMap: { + flowType: FlowType.snippet, + flowId: 'snippet-1', + fileSettings: {}, }, }, - ) + }) fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' })) await waitFor(() => { - expect(mockRestoreWorkflow).toHaveBeenCalledWith('/snippets/snippet-1/workflows/version-1/restore') + expect(mockRestoreWorkflow).toHaveBeenCalledWith( + '/snippets/snippet-1/workflows/version-1/restore', + ) expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1) }) expect(mockEmitRestoreIntent).not.toHaveBeenCalled() @@ -444,9 +444,11 @@ describe('Header layout components', () => { expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1) expect(store.getState().historyWorkflowData).toBeUndefined() - expect(mockViewHistory).toHaveBeenCalledWith(expect.objectContaining({ - withText: true, - })) + expect(mockViewHistory).toHaveBeenCalledWith( + expect.objectContaining({ + withText: true, + }), + ) }) }) }) diff --git a/web/app/components/workflow/header/__tests__/index.spec.tsx b/web/app/components/workflow/header/__tests__/index.spec.tsx index 5bdd91ac6f4191..aeb5fd13478359 100644 --- a/web/app/components/workflow/header/__tests__/index.spec.tsx +++ b/web/app/components/workflow/header/__tests__/index.spec.tsx @@ -12,11 +12,19 @@ const dynamicMockState = vi.hoisted(() => ({ })) function DynamicHeaderHistory(props: Record<string, unknown>) { - return <div data-testid="header-history" data-props={Object.keys(props).join(',')}>history-layout</div> + return ( + <div data-testid="header-history" data-props={Object.keys(props).join(',')}> + history-layout + </div> + ) } function DynamicHeaderRestoring(props: Record<string, unknown>) { - return <div data-testid="header-restoring" data-props={Object.keys(props).join(',')}>restoring-layout</div> + return ( + <div data-testid="header-restoring" data-props={Object.keys(props).join(',')}> + restoring-layout + </div> + ) } vi.mock('../../hooks', () => ({ diff --git a/web/app/components/workflow/header/__tests__/run-and-history.spec.tsx b/web/app/components/workflow/header/__tests__/run-and-history.spec.tsx index 98a426ef1dca06..f75637df96b834 100644 --- a/web/app/components/workflow/header/__tests__/run-and-history.spec.tsx +++ b/web/app/components/workflow/header/__tests__/run-and-history.spec.tsx @@ -25,7 +25,7 @@ vi.mock('../../hooks-store', () => ({ })) vi.mock('../run-mode', () => ({ - default: ({ text, disabled }: { text?: string, disabled?: boolean }) => { + default: ({ text, disabled }: { text?: string; disabled?: boolean }) => { mockRunMode({ text, disabled }) return ( diff --git a/web/app/components/workflow/header/__tests__/run-mode.spec.tsx b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx index b92f8d12a457e7..6544d93f8744df 100644 --- a/web/app/components/workflow/header/__tests__/run-mode.spec.tsx +++ b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx @@ -14,18 +14,24 @@ const mockHandleWorkflowRunAllTriggersInWorkflow = vi.fn() const mockHandleStopRun = vi.fn() const mockNotify = vi.fn() const mockTrackEvent = vi.fn() -const hotkeyRegistrations = vi.hoisted(() => new Map<string, { - callback: () => void - options?: { ignoreInputs?: boolean } -}>()) +const hotkeyRegistrations = vi.hoisted( + () => + new Map< + string, + { + callback: () => void + options?: { ignoreInputs?: boolean } + } + >(), +) let mockWarningNodes: Array<{ id: string }> = [] -let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus }, task_id: string } | undefined +let mockWorkflowRunningData: + | { result: { status: WorkflowRunningStatus }; task_id: string } + | undefined let mockIsListening = false let mockCanRun = true -let mockDynamicOptions = [ - { type: TriggerType.UserInput, nodeId: 'start-node' }, -] +let mockDynamicOptions = [{ type: TriggerType.UserInput, nodeId: 'start-node' }] vi.mock('@/app/components/workflow/hooks', () => ({ useWorkflowStartRun: () => ({ @@ -44,8 +50,9 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/workflow/store/workflow', () => ({ - useStore: (selector: (state: { workflowRunningData?: unknown, isListening: boolean }) => unknown) => - selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }), + useStore: ( + selector: (state: { workflowRunningData?: unknown; isListening: boolean }) => unknown, + ) => selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }), })) vi.mock('@/app/components/workflow/hooks-store', () => ({ @@ -94,7 +101,17 @@ vi.mock('@/context/event-emitter', () => ({ vi.mock('../test-run-menu', async (importOriginal) => { const actual = await importOriginal<typeof import('../test-run-menu')>() - const TestRunMenuMock = ({ children, options, onSelect, ref }: { children: ReactNode, options: Array<{ type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }>, onSelect: (option: { type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }) => void, ref?: React.Ref<TestRunMenuRef> }) => { + const TestRunMenuMock = ({ + children, + options, + onSelect, + ref, + }: { + children: ReactNode + options: Array<{ type: TriggerType; nodeId?: string; relatedNodeIds?: string[] }> + onSelect: (option: { type: TriggerType; nodeId?: string; relatedNodeIds?: string[] }) => void + ref?: React.Ref<TestRunMenuRef> + }) => { React.useImperativeHandle(ref, () => ({ toggle: vi.fn(), })) @@ -122,9 +139,7 @@ describe('RunMode', () => { mockIsListening = false mockCanRun = true hotkeyRegistrations.clear() - mockDynamicOptions = [ - { type: TriggerType.UserInput, nodeId: 'start-node' }, - ] + mockDynamicOptions = [{ type: TriggerType.UserInput, nodeId: 'start-node' }] }) it('should render the run trigger and start the workflow when a valid trigger is selected', () => { @@ -134,7 +149,9 @@ describe('RunMode', () => { fireEvent.click(screen.getByTestId('trigger-option')) expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalledTimes(1) - expect(mockTrackEvent).toHaveBeenCalledWith('app_start_action_time', { action_type: 'user_input' }) + expect(mockTrackEvent).toHaveBeenCalledWith('app_start_action_time', { + action_type: 'user_input', + }) }) it('should show an error toast instead of running when the selected trigger has checklist warnings', () => { @@ -159,7 +176,9 @@ describe('RunMode', () => { render(<RunMode />) expect(screen.getByText(/running/i))!.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' })) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' }), + ) expect(mockHandleStopRun).toHaveBeenCalledWith('task-1') }) @@ -191,7 +210,9 @@ describe('RunMode', () => { expect(screen.getByRole('button', { name: /run/i })).toBeDisabled() expect(screen.queryByTestId('trigger-option')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' }), + ).not.toBeInTheDocument() expect(mockHandleWorkflowStartRunInWorkflow).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx b/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx index 7fbc70db23bc84..c80036969a9ec3 100644 --- a/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx +++ b/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx @@ -6,7 +6,8 @@ import ScrollToSelectedNodeButton from '../scroll-to-selected-node-button' const mockScrollToWorkflowNode = vi.fn() vi.mock('reactflow', async () => - (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), +) vi.mock('../../utils/node-navigation', () => ({ scrollToWorkflowNode: (nodeId: string) => mockScrollToWorkflowNode(nodeId), diff --git a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx index 66f7ff86afcc90..09bc35a15f95ef 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx @@ -12,17 +12,27 @@ import { vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') - const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + const DropdownMenuContext = React.createContext<{ + open: boolean + setOpen: (open: boolean) => void + } | null>(null) const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + DropdownMenu: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( <DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}> <div>{children}</div> </DropdownMenuContext> @@ -31,8 +41,18 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const { open } = useDropdownMenuContext() return open ? <div>{children}</div> : null }, - DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement>, className?: string }) => ( - <button type="button" className={className} onClick={onClick}>{children}</button> + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler<HTMLButtonElement> + className?: string + }) => ( + <button type="button" className={className} onClick={onClick}> + {children} + </button> ), } }) @@ -59,13 +79,7 @@ describe('test-run-menu helpers', () => { expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '`' }))).toBe('~') expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '1' }))).toBe('1') - render( - <OptionRow - option={option} - shortcutKey="1" - onSelect={onSelect} - />, - ) + render(<OptionRow option={option} shortcutKey="1" onSelect={onSelect} />) expect(screen.getByText('1')).toBeInTheDocument() @@ -78,13 +92,17 @@ describe('test-run-menu helpers', () => { const handleSelect = vi.fn() const option = createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' }) - const { rerender, unmount } = renderHook(({ open }) => useShortcutMenu({ - open, - shortcutMappings: [{ option, shortcutKey: '~' }], - handleSelect, - }), { - initialProps: { open: true }, - }) + const { rerender, unmount } = renderHook( + ({ open }) => + useShortcutMenu({ + open, + shortcutMappings: [{ option, shortcutKey: '~' }], + handleSelect, + }), + { + initialProps: { open: true }, + }, + ) fireEvent.keyDown(window, { key: '`' }) fireEvent.keyDown(window, { key: '`', altKey: true }) @@ -112,9 +130,7 @@ describe('test-run-menu helpers', () => { const originalOnClick = vi.fn() const { rerender } = render( - <SingleOptionTrigger runSoleOption={runSoleOption}> - Open directly - </SingleOptionTrigger>, + <SingleOptionTrigger runSoleOption={runSoleOption}>Open directly</SingleOptionTrigger>, ) await user.click(screen.getByText('Open directly')) diff --git a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx index 4c0122a7d04632..e5dd454346a0ac 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx @@ -7,17 +7,27 @@ import TestRunMenu, { TriggerType } from '../test-run-menu' vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') - const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + const DropdownMenuContext = React.createContext<{ + open: boolean + setOpen: (open: boolean) => void + } | null>(null) const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + DropdownMenu: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( <DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}> <div>{children}</div> </DropdownMenuContext> @@ -39,17 +49,43 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { ) } - return <button type="button" onClick={() => setOpen(!open)}>{children}</button> + return ( + <button type="button" onClick={() => setOpen(!open)}> + {children} + </button> + ) }, DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { const { open } = useDropdownMenuContext() return open ? <div>{children}</div> : null }, DropdownMenuGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - DropdownMenuLabel: ({ children, className }: { children: React.ReactNode, className?: string }) => <div className={className}>{children}</div>, - DropdownMenuGroupLabel: ({ children, className }: { children: React.ReactNode, className?: string }) => <div className={className}>{children}</div>, - DropdownMenuSeparator: ({ className }: { className?: string }) => <div className={className} data-testid="dropdown-separator" />, - DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement>, className?: string }) => { + DropdownMenuLabel: ({ + children, + className, + }: { + children: React.ReactNode + className?: string + }) => <div className={className}>{children}</div>, + DropdownMenuGroupLabel: ({ + children, + className, + }: { + children: React.ReactNode + className?: string + }) => <div className={className}>{children}</div>, + DropdownMenuSeparator: ({ className }: { className?: string }) => ( + <div className={className} data-testid="dropdown-separator" /> + ), + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler<HTMLButtonElement> + className?: string + }) => { const { setOpen } = useDropdownMenuContext() return ( <button @@ -118,7 +154,13 @@ describe('TestRunMenu', () => { options={{ userInput: createOption(), runAll: createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' }), - triggers: [createOption({ id: 'trigger-1', type: TriggerType.Webhook, name: 'Webhook Trigger' })], + triggers: [ + createOption({ + id: 'trigger-1', + type: TriggerType.Webhook, + name: 'Webhook Trigger', + }), + ], }} onSelect={onSelect} > @@ -148,7 +190,9 @@ describe('TestRunMenu', () => { options={{ userInput: createOption({ enabled: false }), runAll: createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' }), - triggers: [createOption({ id: 'trigger-1', type: TriggerType.Webhook, name: 'Webhook Trigger' })], + triggers: [ + createOption({ id: 'trigger-1', type: TriggerType.Webhook, name: 'Webhook Trigger' }), + ], }} onSelect={vi.fn()} > diff --git a/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx b/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx index 9afe3efd605f06..43d43c431915ef 100644 --- a/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx +++ b/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx @@ -5,7 +5,9 @@ const mockUnsubscribe = vi.fn() const mockHandleUndo = vi.fn() const mockHandleRedo = vi.fn() -let latestTemporalListener: ((state: { pastStates: unknown[], futureStates: unknown[] }) => void) | undefined +let latestTemporalListener: + | ((state: { pastStates: unknown[]; futureStates: unknown[] }) => void) + | undefined let mockNodesReadOnly = false vi.mock('@/app/components/workflow/header/view-workflow-history', () => ({ @@ -22,7 +24,9 @@ vi.mock('@/app/components/workflow/workflow-history-store', () => ({ useWorkflowHistoryStore: () => ({ store: { temporal: { - subscribe: (listener: (state: { pastStates: unknown[], futureStates: unknown[] }) => void) => { + subscribe: ( + listener: (state: { pastStates: unknown[]; futureStates: unknown[] }) => void, + ) => { latestTemporalListener = listener return mockUnsubscribe }, diff --git a/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx index 75606f9216abb9..c5699bc7607172 100644 --- a/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx +++ b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx @@ -2,10 +2,16 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import VersionHistoryButton from '../version-history-button' let mockTheme: 'light' | 'dark' = 'light' -const hotkeyRegistrations = vi.hoisted(() => new Map<string, { - callback: () => void - options?: { ignoreInputs?: boolean } -}>()) +const hotkeyRegistrations = vi.hoisted( + () => + new Map< + string, + { + callback: () => void + options?: { ignoreInputs?: boolean } + } + >(), +) vi.mock('@/hooks/use-theme', () => ({ default: () => ({ @@ -71,6 +77,10 @@ describe('VersionHistoryButton', () => { mockTheme = 'dark' render(<VersionHistoryButton onClick={vi.fn()} />) - expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-xs') + expect(screen.getByRole('button')).toHaveClass( + 'border-black/5', + 'bg-white/10', + 'backdrop-blur-xs', + ) }) }) diff --git a/web/app/components/workflow/header/__tests__/view-history.spec.tsx b/web/app/components/workflow/header/__tests__/view-history.spec.tsx index 93e0b56125db27..1d8dec6beeb942 100644 --- a/web/app/components/workflow/header/__tests__/view-history.spec.tsx +++ b/web/app/components/workflow/header/__tests__/view-history.spec.tsx @@ -11,7 +11,9 @@ const mockCloseAllInputFieldPanels = vi.fn() const mockHandleNodesCancelSelected = vi.fn() const mockHandleCancelDebugAndPreviewPanel = vi.fn() const mockHandleBackupDraft = vi.fn() -const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`) +const mockFormatWorkflowRunIdentifier = vi.fn( + (finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`, +) let mockIsChatMode = false @@ -31,7 +33,8 @@ vi.mock('../../hooks', () => { }) vi.mock('@/service/use-workflow', () => ({ - useWorkflowRunHistory: (url?: string, enabled?: boolean) => mockUseWorkflowRunHistory(url, enabled), + useWorkflowRunHistory: (url?: string, enabled?: boolean) => + mockUseWorkflowRunHistory(url, enabled), })) vi.mock('@/hooks/use-format-time-from-now', () => ({ @@ -60,13 +63,8 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ - children, - ...props - }: React.ButtonHTMLAttributes<HTMLButtonElement>) => ( - <button {...props}> - {children} - </button> + Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => ( + <button {...props}>{children}</button> ), })) @@ -95,7 +93,8 @@ vi.mock('../../utils', async () => { const actual = await vi.importActual<typeof import('../../utils')>('../../utils') return { ...actual, - formatWorkflowRunIdentifier: (finishedAt?: number, status?: string) => mockFormatWorkflowRunIdentifier(finishedAt, status), + formatWorkflowRunIdentifier: (finishedAt?: number, status?: string) => + mockFormatWorkflowRunIdentifier(finishedAt, status), } }) @@ -157,10 +156,7 @@ describe('ViewHistory', () => { }) renderWorkflowComponent( - <ViewHistory - historyUrl="/history" - onClearLogAndMessageModal={onClearLogAndMessageModal} - />, + <ViewHistory historyUrl="/history" onClearLogAndMessageModal={onClearLogAndMessageModal} />, { hooksStoreProps: { handleBackupDraft: vi.fn(), diff --git a/web/app/components/workflow/header/chat-variable-button.tsx b/web/app/components/workflow/header/chat-variable-button.tsx index d53c88eac5a17e..6e73dbbaa0c3eb 100644 --- a/web/app/components/workflow/header/chat-variable-button.tsx +++ b/web/app/components/workflow/header/chat-variable-button.tsx @@ -7,11 +7,11 @@ import useTheme from '@/hooks/use-theme' const ChatVariableButton = ({ disabled }: { disabled: boolean }) => { const { theme } = useTheme() - const showChatVariablePanel = useStore(s => s.showChatVariablePanel) - const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) - const setShowEnvPanel = useStore(s => s.setShowEnvPanel) - const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) - const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) + const showChatVariablePanel = useStore((s) => s.showChatVariablePanel) + const setShowChatVariablePanel = useStore((s) => s.setShowChatVariablePanel) + const setShowEnvPanel = useStore((s) => s.setShowEnvPanel) + const setShowGlobalVariablePanel = useStore((s) => s.setShowGlobalVariablePanel) + const setShowDebugAndPreviewPanel = useStore((s) => s.setShowDebugAndPreviewPanel) const handleClick = () => { setShowChatVariablePanel(true) diff --git a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx index 3227c7570c774d..fcafc724c11b67 100644 --- a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx +++ b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx @@ -48,11 +48,12 @@ vi.mock('../../../hooks', () => ({ })) vi.mock('../../../hooks-store/store', () => ({ - useHooksStore: (selector: (state: { configsMap: { flowType: string } }) => unknown) => selector({ - configsMap: { - flowType: 'workflow', - }, - }), + useHooksStore: (selector: (state: { configsMap: { flowType: string } }) => unknown) => + selector({ + configsMap: { + flowType: 'workflow', + }, + }), })) vi.mock('@langgenius/dify-ui/popover', () => ({ @@ -62,17 +63,31 @@ vi.mock('@langgenius/dify-ui/popover', () => ({ }, PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}</>, PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>, - PopoverTitle: ({ children, className }: { children: ReactNode, className?: string }) => <h2 className={className}>{children}</h2>, - PopoverDescription: ({ children, className }: { children: ReactNode, className?: string }) => <p className={className}>{children}</p>, - PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => <button className={className}>{children}</button>, + PopoverTitle: ({ children, className }: { children: ReactNode; className?: string }) => ( + <h2 className={className}>{children}</h2> + ), + PopoverDescription: ({ children, className }: { children: ReactNode; className?: string }) => ( + <p className={className}>{children}</p> + ), + PopoverClose: ({ children, className }: { children: ReactNode; className?: string }) => ( + <button className={className}>{children}</button> + ), })) vi.mock('../plugin-group', () => ({ - ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) => <div data-testid="plugin-group">{items.map(item => item.title).join(',')}</div>, + ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) => ( + <div data-testid="plugin-group">{items.map((item) => item.title).join(',')}</div> + ), })) vi.mock('../node-group', () => ({ - ChecklistNodeGroup: ({ item, onItemClick }: { item: { title: string }, onItemClick: (item: { title: string }) => void }) => ( + ChecklistNodeGroup: ({ + item, + onItemClick, + }: { + item: { title: string } + onItemClick: (item: { title: string }) => void + }) => ( <button data-testid={`node-group-${item.title}`} onClick={() => onItemClick(item)}> {item.title} </button> diff --git a/web/app/components/workflow/header/checklist/__tests__/node-group.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/node-group.spec.tsx index a84ddd06afb474..64f589771c767c 100644 --- a/web/app/components/workflow/header/checklist/__tests__/node-group.spec.tsx +++ b/web/app/components/workflow/header/checklist/__tests__/node-group.spec.tsx @@ -36,7 +36,10 @@ describe('ChecklistNodeGroup', () => { expect(screen.getByText('Needs configuration')).toBeInTheDocument() expect(screen.getByText(/needConnectTip/i)).toBeInTheDocument() expect(screen.getAllByText(/goToFix/i)).toHaveLength(2) - expect(screen.getByRole('button', { name: /Needs configuration/i })).toHaveAttribute('title', 'Needs configuration') + expect(screen.getByRole('button', { name: /Needs configuration/i })).toHaveAttribute( + 'title', + 'Needs configuration', + ) fireEvent.click(screen.getByText('Needs configuration')) @@ -59,6 +62,9 @@ describe('ChecklistNodeGroup', () => { expect(onItemClick).not.toHaveBeenCalled() expect(screen.queryByText(/goToFix/i)).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /Needs configuration/i })).not.toBeInTheDocument() - expect(screen.getByText('Needs configuration').parentElement).toHaveAttribute('title', 'Needs configuration') + expect(screen.getByText('Needs configuration').parentElement).toHaveAttribute( + 'title', + 'Needs configuration', + ) }) }) diff --git a/web/app/components/workflow/header/checklist/__tests__/plugin-group.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/plugin-group.spec.tsx index 78341c21746057..b0a6f3e8569ff5 100644 --- a/web/app/components/workflow/header/checklist/__tests__/plugin-group.spec.tsx +++ b/web/app/components/workflow/header/checklist/__tests__/plugin-group.spec.tsx @@ -19,7 +19,9 @@ const createChecklistItem = (overrides: Partial<ChecklistItem> = {}): ChecklistI describe('ChecklistPluginGroup', () => { const getInstallButton = () => { - return screen.getByText('workflow.nodes.agent.pluginInstaller.install').closest('button') as HTMLButtonElement + return screen + .getByText('workflow.nodes.agent.pluginInstaller.install') + .closest('button') as HTMLButtonElement } const renderInPopover = (items: ChecklistItem[]) => { @@ -38,9 +40,18 @@ describe('ChecklistPluginGroup', () => { it('should set marketplace dependencies when install button is clicked', () => { const items: ChecklistItem[] = [ - createChecklistItem({ id: 'node-1', pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256' }), - createChecklistItem({ id: 'node-2', pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256' }), - createChecklistItem({ id: 'node-3', pluginUniqueIdentifier: 'langgenius/another-plugin:2.0.0@sha256' }), + createChecklistItem({ + id: 'node-1', + pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256', + }), + createChecklistItem({ + id: 'node-2', + pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256', + }), + createChecklistItem({ + id: 'node-3', + pluginUniqueIdentifier: 'langgenius/another-plugin:2.0.0@sha256', + }), ] renderInPopover(items) @@ -78,7 +89,9 @@ describe('ChecklistPluginGroup', () => { }) it('should omit the version when the marketplace identifier does not include one', () => { - renderInPopover([createChecklistItem({ pluginUniqueIdentifier: 'langgenius/test-plugin@sha256' })]) + renderInPopover([ + createChecklistItem({ pluginUniqueIdentifier: 'langgenius/test-plugin@sha256' }), + ]) fireEvent.click(getInstallButton()) diff --git a/web/app/components/workflow/header/checklist/index.tsx b/web/app/components/workflow/header/checklist/index.tsx index 5a12ad2d422ee5..bad1fc3162e836 100644 --- a/web/app/components/workflow/header/checklist/index.tsx +++ b/web/app/components/workflow/header/checklist/index.tsx @@ -1,7 +1,5 @@ import type { ChecklistItem } from '../../hooks/use-checklist' -import type { - CommonEdgeType, -} from '../../types' +import type { CommonEdgeType } from '../../types' import { cn } from '@langgenius/dify-ui/cn' import { Popover, @@ -11,20 +9,11 @@ import { PopoverTitle, PopoverTrigger, } from '@langgenius/dify-ui/popover' -import { - memo, - useMemo, - useState, -} from 'react' +import { memo, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - useEdges, -} from 'reactflow' +import { useEdges } from 'reactflow' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' -import { - useChecklist, - useNodesInteractions, -} from '../../hooks' +import { useChecklist, useNodesInteractions } from '../../hooks' import { useHooksStore } from '../../hooks-store/store' import { ChecklistNodeGroup } from './node-group' import { ChecklistPluginGroup } from './plugin-group' @@ -35,44 +24,36 @@ type WorkflowChecklistProps = { onItemClick?: (item: ChecklistItem) => void } -const WorkflowChecklist = ({ - disabled, - showGoTo = true, - onItemClick, -}: WorkflowChecklistProps) => { +const WorkflowChecklist = ({ disabled, showGoTo = true, onItemClick }: WorkflowChecklistProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const edges = useEdges<CommonEdgeType>() const nodes = useNodes() - const flowType = useHooksStore(s => s.configsMap?.flowType) + const flowType = useHooksStore((s) => s.configsMap?.flowType) const needWarningNodes = useChecklist(nodes, edges, { flowType }) const { handleNodeSelect } = useNodesInteractions() - const checklistLabel = t($ => $['panel.checklist'], { ns: 'workflow' }) + const checklistLabel = t(($) => $['panel.checklist'], { ns: 'workflow' }) const { pluginItems, nodeItems } = useMemo(() => { const plugins: ChecklistItem[] = [] const regular: ChecklistItem[] = [] for (const item of needWarningNodes) { - if (item.isPluginMissing) - plugins.push(item) - else - regular.push(item) + if (item.isPluginMissing) plugins.push(item) + else regular.push(item) } return { pluginItems: plugins, nodeItems: regular } }, [needWarningNodes]) const handleItemClick = (item: ChecklistItem) => { - if (onItemClick) - onItemClick(item) - else - handleNodeSelect(item.id) + if (onItemClick) onItemClick(item) + else handleNodeSelect(item.id) setOpen(false) } return ( - <Popover open={open} onOpenChange={newOpen => !disabled && setOpen(newOpen)}> + <Popover open={open} onOpenChange={(newOpen) => !disabled && setOpen(newOpen)}> <PopoverTrigger - render={( + render={ <button type="button" className={cn( @@ -82,9 +63,7 @@ const WorkflowChecklist = ({ disabled={disabled || undefined} aria-label={checklistLabel} > - <span - className="flex size-full items-center justify-center rounded-md group-data-popup-open:bg-state-accent-hover hover:bg-state-accent-hover" - > + <span className="flex size-full items-center justify-center rounded-md group-data-popup-open:bg-state-accent-hover hover:bg-state-accent-hover"> <span className="i-ri-list-check-3 size-4 text-components-button-ghost-text group-hover:text-components-button-secondary-accent-text group-data-popup-open:text-components-button-secondary-accent-text" aria-hidden="true" @@ -96,7 +75,7 @@ const WorkflowChecklist = ({ </span> )} </button> - )} + } /> <PopoverContent placement="bottom-start" @@ -104,10 +83,7 @@ const WorkflowChecklist = ({ alignOffset={-30} popupClassName="w-[420px] rounded-2xl bg-background-default-subtle" > - <div - className="overflow-y-auto" - style={{ maxHeight: 'calc(2 / 3 * 100vh)' }} - > + <div className="overflow-y-auto" style={{ maxHeight: 'calc(2 / 3 * 100vh)' }}> <div className="flex flex-col gap-0.5 px-3 pt-3.5 pb-1"> <div className="flex items-start px-1"> <div className="min-w-0 grow pr-8"> @@ -118,40 +94,36 @@ const WorkflowChecklist = ({ </div> <PopoverClose className="-mt-0.5 -mr-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} > <span className="i-ri-close-line size-4 text-text-tertiary" aria-hidden="true" /> </PopoverClose> </div> {needWarningNodes.length > 0 && ( <PopoverDescription className="px-1 text-xs/4 text-text-tertiary"> - {t($ => $['panel.checklistDescription'], { ns: 'workflow' })} + {t(($) => $['panel.checklistDescription'], { ns: 'workflow' })} </PopoverDescription> )} </div> - {needWarningNodes.length > 0 - ? ( - <div className="flex flex-col gap-1 px-4 pt-1 pb-4"> - {pluginItems.length > 0 && ( - <ChecklistPluginGroup items={pluginItems} /> - )} - {nodeItems.map(item => ( - <ChecklistNodeGroup - key={item.id} - item={item} - showGoTo={showGoTo} - onItemClick={handleItemClick} - /> - ))} - </div> - ) - : ( - <div className="mx-4 mb-3 rounded-lg py-4 text-center text-xs text-text-tertiary"> - <span className="mx-auto mb-[5px] i-custom-vender-line-general-checklist-square block h-8 w-8 text-text-quaternary" /> - {t($ => $['panel.checklistResolved'], { ns: 'workflow' })} - </div> - )} + {needWarningNodes.length > 0 ? ( + <div className="flex flex-col gap-1 px-4 pt-1 pb-4"> + {pluginItems.length > 0 && <ChecklistPluginGroup items={pluginItems} />} + {nodeItems.map((item) => ( + <ChecklistNodeGroup + key={item.id} + item={item} + showGoTo={showGoTo} + onItemClick={handleItemClick} + /> + ))} + </div> + ) : ( + <div className="mx-4 mb-3 rounded-lg py-4 text-center text-xs text-text-tertiary"> + <span className="mx-auto mb-[5px] i-custom-vender-line-general-checklist-square block h-8 w-8 text-text-quaternary" /> + {t(($) => $['panel.checklistResolved'], { ns: 'workflow' })} + </div> + )} </div> </PopoverContent> </Popover> diff --git a/web/app/components/workflow/header/checklist/node-group.tsx b/web/app/components/workflow/header/checklist/node-group.tsx index 6d6e1ea1515e3b..1006ab6887e120 100644 --- a/web/app/components/workflow/header/checklist/node-group.tsx +++ b/web/app/components/workflow/header/checklist/node-group.tsx @@ -11,84 +11,88 @@ type ChecklistSubItem = { message: string } -export const ChecklistNodeGroup = memo(({ - item, - showGoTo, - onItemClick, -}: { - item: ChecklistItem - showGoTo: boolean - onItemClick: (item: ChecklistItem) => void -}) => { - const { t } = useTranslation() - const goToEnabled = showGoTo && item.canNavigate && !item.disableGoTo +export const ChecklistNodeGroup = memo( + ({ + item, + showGoTo, + onItemClick, + }: { + item: ChecklistItem + showGoTo: boolean + onItemClick: (item: ChecklistItem) => void + }) => { + const { t } = useTranslation() + const goToEnabled = showGoTo && item.canNavigate && !item.disableGoTo - const subItems = useMemo(() => { - const items: ChecklistSubItem[] = [] - for (let i = 0; i < item.errorMessages.length; i++) - items.push({ key: `error-${i}`, message: item.errorMessages[i]! }) - if (item.unConnected) - items.push({ key: 'unconnected', message: t($ => $['common.needConnectTip'], { ns: 'workflow' }) }) - return items - }, [item.errorMessages, item.unConnected, t]) + const subItems = useMemo(() => { + const items: ChecklistSubItem[] = [] + for (let i = 0; i < item.errorMessages.length; i++) + items.push({ key: `error-${i}`, message: item.errorMessages[i]! }) + if (item.unConnected) + items.push({ + key: 'unconnected', + message: t(($) => $['common.needConnectTip'], { ns: 'workflow' }), + }) + return items + }, [item.errorMessages, item.unConnected, t]) - return ( - <div className="overflow-clip rounded-[10px] bg-components-panel-on-panel-item-bg"> - <div className="flex items-center gap-2 px-2 pt-2"> - <BlockIcon - type={item.type as BlockEnum} - size="sm" - toolIcon={item.toolIcon} - /> - <span className="min-w-0 grow truncate text-sm/5 font-medium text-text-primary"> - {item.title} - </span> - </div> - <div className="p-1"> - {subItems.map((sub) => { - const content = ( - <> - <ItemIndicator /> - <span className="min-w-0 grow truncate text-xs/4 text-text-warning"> - {sub.message} - </span> - {goToEnabled && ( - <div className="flex shrink-0 items-center gap-0.5 pr-0.5 opacity-0 transition-opacity duration-150 group-hover/item:opacity-100"> - <span className="text-xs/4 font-medium whitespace-nowrap text-text-accent"> - {t($ => $['panel.goToFix'], { ns: 'workflow' })} - </span> - <span className="i-ri-arrow-right-line size-3.5 text-text-accent" aria-hidden="true" /> - </div> - )} - </> - ) - const className = cn( - 'group/item flex w-full items-center gap-2 rounded-lg px-1 text-left', - goToEnabled && 'cursor-pointer hover:bg-state-base-hover', - ) + return ( + <div className="overflow-clip rounded-[10px] bg-components-panel-on-panel-item-bg"> + <div className="flex items-center gap-2 px-2 pt-2"> + <BlockIcon type={item.type as BlockEnum} size="sm" toolIcon={item.toolIcon} /> + <span className="min-w-0 grow truncate text-sm/5 font-medium text-text-primary"> + {item.title} + </span> + </div> + <div className="p-1"> + {subItems.map((sub) => { + const content = ( + <> + <ItemIndicator /> + <span className="min-w-0 grow truncate text-xs/4 text-text-warning"> + {sub.message} + </span> + {goToEnabled && ( + <div className="flex shrink-0 items-center gap-0.5 pr-0.5 opacity-0 transition-opacity duration-150 group-hover/item:opacity-100"> + <span className="text-xs/4 font-medium whitespace-nowrap text-text-accent"> + {t(($) => $['panel.goToFix'], { ns: 'workflow' })} + </span> + <span + className="i-ri-arrow-right-line size-3.5 text-text-accent" + aria-hidden="true" + /> + </div> + )} + </> + ) + const className = cn( + 'group/item flex w-full items-center gap-2 rounded-lg px-1 text-left', + goToEnabled && 'cursor-pointer hover:bg-state-base-hover', + ) + + if (goToEnabled) { + return ( + <button + key={sub.key} + type="button" + className={cn(className, 'border-none bg-transparent')} + title={sub.message} + onClick={() => onItemClick(item)} + > + {content} + </button> + ) + } - if (goToEnabled) { return ( - <button - key={sub.key} - type="button" - className={cn(className, 'border-none bg-transparent')} - title={sub.message} - onClick={() => onItemClick(item)} - > + <div key={sub.key} className={className} title={sub.message}> {content} - </button> + </div> ) - } - - return ( - <div key={sub.key} className={className} title={sub.message}> - {content} - </div> - ) - })} + })} + </div> </div> - </div> - ) -}) + ) + }, +) ChecklistNodeGroup.displayName = 'ChecklistNodeGroup' diff --git a/web/app/components/workflow/header/checklist/plugin-group.tsx b/web/app/components/workflow/header/checklist/plugin-group.tsx index 1268c64e2e370d..64ea8a22d76980 100644 --- a/web/app/components/workflow/header/checklist/plugin-group.tsx +++ b/web/app/components/workflow/header/checklist/plugin-group.tsx @@ -15,21 +15,16 @@ function getVersionFromMarketplaceIdentifier(identifier: string): string | undef return version || undefined } -export const ChecklistPluginGroup = memo(({ - items, -}: { - items: ChecklistItem[] -}) => { +export const ChecklistPluginGroup = memo(({ items }: { items: ChecklistItem[] }) => { const { t } = useTranslation() const identifiers = useMemo( - () => Array.from( - new Set( - items - .map(i => i.pluginUniqueIdentifier) - .filter((id): id is string => Boolean(id)), + () => + Array.from( + new Set( + items.map((i) => i.pluginUniqueIdentifier).filter((id): id is string => Boolean(id)), + ), ), - ), [items], ) @@ -47,8 +42,7 @@ export const ChecklistPluginGroup = memo(({ }, [identifiers]) const handleInstallAll = () => { - if (dependencies.length === 0) - return + if (dependencies.length === 0) return const { setDependencies } = usePluginDependencyStore.getState() setDependencies(dependencies) } @@ -60,36 +54,27 @@ export const ChecklistPluginGroup = memo(({ <span className="i-ri-download-line size-3.5 text-white" /> </div> <span className="min-w-0 grow truncate text-sm/5 font-medium text-text-primary"> - {t($ => $['nodes.common.pluginsNotInstalled'], { ns: 'workflow', count: items.length })} + {t(($) => $['nodes.common.pluginsNotInstalled'], { ns: 'workflow', count: items.length })} </span> <PopoverClose - render={( + render={ <Button variant="secondary" size="small" onClick={handleInstallAll} disabled={dependencies.length === 0} /> - )} + } > - {t($ => $['nodes.agent.pluginInstaller.install'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.pluginInstaller.install'], { ns: 'workflow' })} </PopoverClose> </div> <div className="p-1"> - {items.map(item => ( - <div - key={item.id} - className="flex items-center gap-2 rounded-lg px-1" - > + {items.map((item) => ( + <div key={item.id} className="flex items-center gap-2 rounded-lg px-1"> <ItemIndicator /> - <BlockIcon - type={item.type as BlockEnum} - size="xs" - toolIcon={item.toolIcon} - /> - <span className="min-w-0 grow truncate text-xs/4 text-text-warning"> - {item.title} - </span> + <BlockIcon type={item.type as BlockEnum} size="xs" toolIcon={item.toolIcon} /> + <span className="min-w-0 grow truncate text-xs/4 text-text-warning">{item.title}</span> </div> ))} </div> diff --git a/web/app/components/workflow/header/editing-title.tsx b/web/app/components/workflow/header/editing-title.tsx index 664edff6653107..153e8900b0074d 100644 --- a/web/app/components/workflow/header/editing-title.tsx +++ b/web/app/components/workflow/header/editing-title.tsx @@ -8,35 +8,28 @@ const EditingTitle = () => { const { t } = useTranslation() const { formatTime } = useTimestamp() const { formatTimeFromNow } = useFormatTimeFromNow() - const draftUpdatedAt = useStore(state => state.draftUpdatedAt) - const publishedAt = useStore(state => state.publishedAt) - const isSyncingWorkflowDraft = useStore(s => s.isSyncingWorkflowDraft) + const draftUpdatedAt = useStore((state) => state.draftUpdatedAt) + const publishedAt = useStore((state) => state.publishedAt) + const isSyncingWorkflowDraft = useStore((s) => s.isSyncingWorkflowDraft) return ( <div className="flex h-[18px] min-w-[300px] items-center system-xs-regular whitespace-nowrap text-text-tertiary"> - { - !!draftUpdatedAt && ( - <> - {t($ => $['common.autoSaved'], { ns: 'workflow' })} - {' '} - {formatTime(draftUpdatedAt / 1000, 'HH:mm:ss')} - </> - ) - } + {!!draftUpdatedAt && ( + <> + {t(($) => $['common.autoSaved'], { ns: 'workflow' })}{' '} + {formatTime(draftUpdatedAt / 1000, 'HH:mm:ss')} + </> + )} <span className="mx-1 flex items-center">·</span> - { - publishedAt - ? `${t($ => $['common.published'], { ns: 'workflow' })} ${formatTimeFromNow(publishedAt)}` - : t($ => $['common.unpublished'], { ns: 'workflow' }) - } - { - isSyncingWorkflowDraft && ( - <> - <span className="mx-1 flex items-center">·</span> - {t($ => $['common.syncingData'], { ns: 'workflow' })} - </> - ) - } + {publishedAt + ? `${t(($) => $['common.published'], { ns: 'workflow' })} ${formatTimeFromNow(publishedAt)}` + : t(($) => $['common.unpublished'], { ns: 'workflow' })} + {isSyncingWorkflowDraft && ( + <> + <span className="mx-1 flex items-center">·</span> + {t(($) => $['common.syncingData'], { ns: 'workflow' })} + </> + )} </div> ) } diff --git a/web/app/components/workflow/header/env-button.tsx b/web/app/components/workflow/header/env-button.tsx index f7364a478882e2..9859abfebee254 100644 --- a/web/app/components/workflow/header/env-button.tsx +++ b/web/app/components/workflow/header/env-button.tsx @@ -8,11 +8,11 @@ import useTheme from '@/hooks/use-theme' const EnvButton = ({ disabled }: { disabled: boolean }) => { const { theme } = useTheme() - const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) - const showEnvPanel = useStore(s => s.showEnvPanel) - const setShowEnvPanel = useStore(s => s.setShowEnvPanel) - const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) - const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) + const setShowChatVariablePanel = useStore((s) => s.setShowChatVariablePanel) + const showEnvPanel = useStore((s) => s.showEnvPanel) + const setShowEnvPanel = useStore((s) => s.setShowEnvPanel) + const setShowGlobalVariablePanel = useStore((s) => s.setShowGlobalVariablePanel) + const setShowDebugAndPreviewPanel = useStore((s) => s.setShowDebugAndPreviewPanel) const { closeAllInputFieldPanels } = useInputFieldPanel() const handleClick = () => { diff --git a/web/app/components/workflow/header/global-variable-button.tsx b/web/app/components/workflow/header/global-variable-button.tsx index 14fa8271471c7c..091aca99b977ab 100644 --- a/web/app/components/workflow/header/global-variable-button.tsx +++ b/web/app/components/workflow/header/global-variable-button.tsx @@ -8,11 +8,11 @@ import useTheme from '@/hooks/use-theme' const GlobalVariableButton = ({ disabled }: { disabled: boolean }) => { const { theme } = useTheme() - const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel) - const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) - const setShowEnvPanel = useStore(s => s.setShowEnvPanel) - const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) - const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) + const showGlobalVariablePanel = useStore((s) => s.showGlobalVariablePanel) + const setShowGlobalVariablePanel = useStore((s) => s.setShowGlobalVariablePanel) + const setShowEnvPanel = useStore((s) => s.setShowEnvPanel) + const setShowChatVariablePanel = useStore((s) => s.setShowChatVariablePanel) + const setShowDebugAndPreviewPanel = useStore((s) => s.setShowDebugAndPreviewPanel) const { closeAllInputFieldPanels } = useInputFieldPanel() const handleClick = () => { @@ -27,7 +27,9 @@ const GlobalVariableButton = ({ disabled }: { disabled: boolean }) => { <Button className={cn( 'rounded-lg border border-transparent p-2', - theme === 'dark' && showGlobalVariablePanel && 'border-black/5 bg-white/10 backdrop-blur-xs', + theme === 'dark' && + showGlobalVariablePanel && + 'border-black/5 bg-white/10 backdrop-blur-xs', )} disabled={disabled} onClick={handleClick} diff --git a/web/app/components/workflow/header/header-in-normal.tsx b/web/app/components/workflow/header/header-in-normal.tsx index 30fc8d24422438..bd2f74b5210d83 100644 --- a/web/app/components/workflow/header/header-in-normal.tsx +++ b/web/app/components/workflow/header/header-in-normal.tsx @@ -1,21 +1,12 @@ import type { StartNodeType } from '../nodes/start/types' import type { RunAndHistoryProps } from './run-and-history' -import { - useCallback, -} from 'react' +import { useCallback } from 'react' import { useNodes } from 'reactflow' import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' import Divider from '../../base/divider' -import { - useNodesInteractions, - useNodesReadOnly, - useWorkflowRun, -} from '../hooks' +import { useNodesInteractions, useNodesReadOnly, useWorkflowRun } from '../hooks' import { useHooksStore } from '../hooks-store' -import { - useStore, - useWorkflowStore, -} from '../store' +import { useStore, useWorkflowStore } from '../store' import EditingTitle from './editing-title' import EnvButton from './env-button' import GlobalVariableButton from './global-variable-button' @@ -37,35 +28,31 @@ export type HeaderInNormalProps = { } runAndHistoryProps?: RunAndHistoryProps } -const HeaderInNormal = ({ - components, - controls, - runAndHistoryProps, -}: HeaderInNormalProps) => { +const HeaderInNormal = ({ components, controls, runAndHistoryProps }: HeaderInNormalProps) => { const workflowStore = useWorkflowStore() const { nodesReadOnly } = useNodesReadOnly() - const canReleaseAndVersion = useHooksStore(s => s.accessControl.canReleaseAndVersion) + const canReleaseAndVersion = useHooksStore((s) => s.accessControl.canReleaseAndVersion) const { handleNodeSelect } = useNodesInteractions() - const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel) - const setShowEnvPanel = useStore(s => s.setShowEnvPanel) - const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) - const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel) - const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) - const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) + const setShowWorkflowVersionHistoryPanel = useStore((s) => s.setShowWorkflowVersionHistoryPanel) + const setShowEnvPanel = useStore((s) => s.setShowEnvPanel) + const setShowDebugAndPreviewPanel = useStore((s) => s.setShowDebugAndPreviewPanel) + const setShowVariableInspectPanel = useStore((s) => s.setShowVariableInspectPanel) + const setShowChatVariablePanel = useStore((s) => s.setShowChatVariablePanel) + const setShowGlobalVariablePanel = useStore((s) => s.setShowGlobalVariablePanel) const nodes = useNodes<StartNodeType>() - const selectedNode = nodes.find(node => node.data.selected) + const selectedNode = nodes.find((node) => node.data.selected) const { handleBackupDraft } = useWorkflowRun() const { closeAllInputFieldPanels } = useInputFieldPanel() const showEnvButton = controls?.showEnvButton !== false const showGlobalVariableButton = controls?.showGlobalVariableButton !== false - const showContextButtons = !!components?.chatVariableTrigger || showEnvButton || showGlobalVariableButton + const showContextButtons = + !!components?.chatVariableTrigger || showEnvButton || showGlobalVariableButton const onStartRestoring = useCallback(() => { workflowStore.setState({ isRestoring: true }) handleBackupDraft() // clear right panel - if (selectedNode) - handleNodeSelect(selectedNode.id, true) + if (selectedNode) handleNodeSelect(selectedNode.id, true) setShowWorkflowVersionHistoryPanel(true) setShowEnvPanel(false) setShowDebugAndPreviewPanel(false) @@ -73,13 +60,23 @@ const HeaderInNormal = ({ setShowChatVariablePanel(false) setShowGlobalVariablePanel(false) closeAllInputFieldPanels() - }, [workflowStore, handleBackupDraft, selectedNode, handleNodeSelect, setShowWorkflowVersionHistoryPanel, setShowEnvPanel, setShowDebugAndPreviewPanel, setShowVariableInspectPanel, setShowChatVariablePanel, setShowGlobalVariablePanel, closeAllInputFieldPanels]) + }, [ + workflowStore, + handleBackupDraft, + selectedNode, + handleNodeSelect, + setShowWorkflowVersionHistoryPanel, + setShowEnvPanel, + setShowDebugAndPreviewPanel, + setShowVariableInspectPanel, + setShowChatVariablePanel, + setShowGlobalVariablePanel, + closeAllInputFieldPanels, + ]) return ( <div className="flex w-full items-center justify-between"> - <div> - {components?.title ?? <EditingTitle />} - </div> + <div>{components?.title ?? <EditingTitle />}</div> <div> <ScrollToSelectedNodeButton /> </div> diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx index e3b56313beed2e..2c6418fcd01a77 100644 --- a/web/app/components/workflow/header/header-in-restoring.tsx +++ b/web/app/components/workflow/header/header-in-restoring.tsx @@ -3,59 +3,47 @@ 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, -} from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { Plan } from '@/app/components/billing/type' import { userProfileAtom } from '@/context/account-state' import { useProviderContext } from '@/context/provider-context' import useTheme from '@/hooks/use-theme' -import { useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow } from '@/service/use-workflow' -import { FlowType } from '@/types/common' import { - useWorkflowRefreshDraft, - useWorkflowRun, -} from '../hooks' + useInvalidAllLastRun, + useResetWorkflowVersionHistory, + useRestoreWorkflow, +} from '@/service/use-workflow' +import { FlowType } from '@/types/common' +import { useWorkflowRefreshDraft, useWorkflowRun } from '../hooks' import { useHooksStore } from '../hooks-store' -import { - useStore, - useWorkflowStore, -} from '../store' -import { - WorkflowVersion, -} from '../types' +import { useStore, useWorkflowStore } from '../store' +import { WorkflowVersion } from '../types' import RestoringTitle from './restoring-title' export type HeaderInRestoringProps = { onRestoreSettled?: () => void } -const HeaderInRestoring = ({ - onRestoreSettled, -}: HeaderInRestoringProps) => { +const HeaderInRestoring = ({ onRestoreSettled }: HeaderInRestoringProps) => { const { t } = useTranslation() const { theme } = useTheme() const [isRestorePlanUpgradeModalOpen, setIsRestorePlanUpgradeModalOpen] = useState(false) const { plan, enableBilling } = useProviderContext() const workflowStore = useWorkflowStore() const userProfile = useAtomValue(userProfileAtom) - const configsMap = useHooksStore(s => s.configsMap) + const configsMap = useHooksStore((s) => s.configsMap) const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId) - const { - deleteAllInspectVars, - } = workflowStore.getState() - const currentVersion = useStore(s => s.currentVersion) - const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel) + const { deleteAllInspectVars } = workflowStore.getState() + const currentVersion = useStore((s) => s.currentVersion) + const setShowWorkflowVersionHistoryPanel = useStore((s) => s.setShowWorkflowVersionHistoryPanel) - const { - handleLoadBackupDraft, - } = useWorkflowRun() + const { handleLoadBackupDraft } = useWorkflowRun() const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() const { mutateAsync: restoreWorkflow } = useRestoreWorkflow() const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() - const canRestore = !!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft + const canRestore = + !!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft const canUseWorkflowVersionAction = !enableBilling || plan.type !== Plan.sandbox const canEmitCollaborationEvents = configsMap?.flowType === FlowType.appFlow @@ -65,19 +53,20 @@ const HeaderInRestoring = ({ setShowWorkflowVersionHistoryPanel(false) }, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel]) - const restoreVersionUrl = useCallback((versionId: string) => { - if (!configsMap?.flowId) - return '' - if (configsMap.flowType === FlowType.ragPipeline) - return `/rag/pipelines/${configsMap.flowId}/workflows/${versionId}/restore` - if (configsMap.flowType === FlowType.snippet) - return `/snippets/${configsMap.flowId}/workflows/${versionId}/restore` - return `/apps/${configsMap.flowId}/workflows/${versionId}/restore` - }, [configsMap?.flowId, configsMap?.flowType]) + const restoreVersionUrl = useCallback( + (versionId: string) => { + if (!configsMap?.flowId) return '' + if (configsMap.flowType === FlowType.ragPipeline) + return `/rag/pipelines/${configsMap.flowId}/workflows/${versionId}/restore` + if (configsMap.flowType === FlowType.snippet) + return `/snippets/${configsMap.flowId}/workflows/${versionId}/restore` + return `/apps/${configsMap.flowId}/workflows/${versionId}/restore` + }, + [configsMap?.flowId, configsMap?.flowType], + ) const emitRestoreIntent = useCallback(async () => { - if (!currentVersion || !canEmitCollaborationEvents) - return + if (!currentVersion || !canEmitCollaborationEvents) return try { const { collaborationManager } = await import('../collaboration/core/collaboration-manager') collaborationManager.emitRestoreIntent({ @@ -86,43 +75,40 @@ const HeaderInRestoring = ({ initiatorUserId: userProfile.id, initiatorName: userProfile.name, }) - } - catch (error) { + } catch (error) { console.error('Failed to emit restore intent:', error) } }, [canEmitCollaborationEvents, currentVersion, userProfile.id, userProfile.name]) - const emitRestoreComplete = useCallback(async (success: boolean, errorMessage?: string) => { - if (!currentVersion || !canEmitCollaborationEvents) - return - try { - const { collaborationManager } = await import('../collaboration/core/collaboration-manager') - collaborationManager.emitRestoreComplete({ - versionId: currentVersion.id, - success, - ...(errorMessage ? { error: errorMessage } : {}), - }) - } - catch (error) { - console.error('Failed to emit restore complete:', error) - } - }, [canEmitCollaborationEvents, currentVersion]) + const emitRestoreComplete = useCallback( + async (success: boolean, errorMessage?: string) => { + if (!currentVersion || !canEmitCollaborationEvents) return + try { + const { collaborationManager } = await import('../collaboration/core/collaboration-manager') + collaborationManager.emitRestoreComplete({ + versionId: currentVersion.id, + success, + ...(errorMessage ? { error: errorMessage } : {}), + }) + } catch (error) { + console.error('Failed to emit restore complete:', error) + } + }, + [canEmitCollaborationEvents, currentVersion], + ) const emitWorkflowUpdate = useCallback(async () => { - if (!configsMap?.flowId || !canEmitCollaborationEvents) - return + if (!configsMap?.flowId || !canEmitCollaborationEvents) return try { const { collaborationManager } = await import('../collaboration/core/collaboration-manager') collaborationManager.emitWorkflowUpdate(configsMap.flowId) - } - catch (error) { + } catch (error) { console.error('Failed to emit workflow update:', error) } }, [canEmitCollaborationEvents, configsMap?.flowId]) const handleRestore = useCallback(async () => { - if (!canRestore || !currentVersion) - return + if (!canRestore || !currentVersion) return if (!canUseWorkflowVersionAction) { setIsRestorePlanUpgradeModalOpen(true) @@ -137,21 +123,36 @@ const HeaderInRestoring = ({ workflowStore.setState({ isRestoring: false }) workflowStore.setState({ backupDraft: undefined }) handleRefreshWorkflowDraft() - toast.success(t($ => $['versionHistory.action.restoreSuccess'], { ns: 'workflow' })) + toast.success(t(($) => $['versionHistory.action.restoreSuccess'], { ns: 'workflow' })) deleteAllInspectVars() invalidAllLastRun() await emitRestoreComplete(true) await emitWorkflowUpdate() - } - catch { - toast.error(t($ => $['versionHistory.action.restoreFailure'], { ns: 'workflow' })) + } catch { + toast.error(t(($) => $['versionHistory.action.restoreFailure'], { ns: 'workflow' })) await emitRestoreComplete(false, 'restore failed') - } - finally { + } finally { resetWorkflowVersionHistory() onRestoreSettled?.() } - }, [canRestore, currentVersion, canUseWorkflowVersionAction, setShowWorkflowVersionHistoryPanel, emitRestoreIntent, restoreWorkflow, restoreVersionUrl, workflowStore, handleRefreshWorkflowDraft, t, deleteAllInspectVars, invalidAllLastRun, emitRestoreComplete, emitWorkflowUpdate, resetWorkflowVersionHistory, onRestoreSettled]) + }, [ + canRestore, + currentVersion, + canUseWorkflowVersionAction, + setShowWorkflowVersionHistoryPanel, + emitRestoreIntent, + restoreWorkflow, + restoreVersionUrl, + workflowStore, + handleRefreshWorkflowDraft, + t, + deleteAllInspectVars, + invalidAllLastRun, + emitRestoreComplete, + emitWorkflowUpdate, + resetWorkflowVersionHistory, + onRestoreSettled, + ]) return ( <> @@ -168,7 +169,7 @@ const HeaderInRestoring = ({ theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs', )} > - {t($ => $['common.restore'], { ns: 'workflow' })} + {t(($) => $['common.restore'], { ns: 'workflow' })} </Button> <Button onClick={handleCancelRestore} @@ -179,7 +180,7 @@ const HeaderInRestoring = ({ > <div className="flex items-center gap-x-0.5"> <RiHistoryLine className="size-4" /> - <span className="px-0.5">{t($ => $['common.exitVersions'], { ns: 'workflow' })}</span> + <span className="px-0.5">{t(($) => $['common.exitVersions'], { ns: 'workflow' })}</span> </div> </Button> </div> @@ -187,8 +188,8 @@ const HeaderInRestoring = ({ <PlanUpgradeModal show onClose={() => setIsRestorePlanUpgradeModalOpen(false)} - title={t($ => $['upgrade.workflowRestore.title'], { ns: 'billing' })!} - description={t($ => $['upgrade.workflowRestore.description'], { ns: 'billing' })!} + title={t(($) => $['upgrade.workflowRestore.title'], { ns: 'billing' })!} + description={t(($) => $['upgrade.workflowRestore.description'], { ns: 'billing' })!} /> )} </> diff --git a/web/app/components/workflow/header/header-in-view-history.tsx b/web/app/components/workflow/header/header-in-view-history.tsx index 03b07ea0aa9de9..f89ce3ccd2c72a 100644 --- a/web/app/components/workflow/header/header-in-view-history.tsx +++ b/web/app/components/workflow/header/header-in-view-history.tsx @@ -1,32 +1,22 @@ import type { ViewHistoryProps } from './view-history' import { Button } from '@langgenius/dify-ui/button' -import { - useCallback, -} from 'react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows' import Divider from '../../base/divider' -import { - useWorkflowRun, -} from '../hooks' -import { - useWorkflowStore, -} from '../store' +import { useWorkflowRun } from '../hooks' +import { useWorkflowStore } from '../store' import RunningTitle from './running-title' import ViewHistory from './view-history' export type HeaderInHistoryProps = { viewHistoryProps?: ViewHistoryProps } -const HeaderInHistory = ({ - viewHistoryProps, -}: HeaderInHistoryProps) => { +const HeaderInHistory = ({ viewHistoryProps }: HeaderInHistoryProps) => { const { t } = useTranslation() const workflowStore = useWorkflowStore() - const { - handleLoadBackupDraft, - } = useWorkflowRun() + const { handleLoadBackupDraft } = useWorkflowRun() const handleGoBackToEdit = useCallback(() => { handleLoadBackupDraft() @@ -41,12 +31,9 @@ const HeaderInHistory = ({ <div className="flex items-center space-x-2"> <ViewHistory {...viewHistoryProps} withText /> <Divider type="vertical" className="mx-auto h-3.5" /> - <Button - variant="primary" - onClick={handleGoBackToEdit} - > + <Button variant="primary" onClick={handleGoBackToEdit}> <ArrowNarrowLeft className="mr-1 size-4" /> - {t($ => $['common.goBackToEdit'], { ns: 'workflow' })} + {t(($) => $['common.goBackToEdit'], { ns: 'workflow' })} </Button> </div> </> diff --git a/web/app/components/workflow/header/index.tsx b/web/app/components/workflow/header/index.tsx index fe5d27d0b5bace..a460821ae7196b 100644 --- a/web/app/components/workflow/header/index.tsx +++ b/web/app/components/workflow/header/index.tsx @@ -2,9 +2,7 @@ import type { HeaderInNormalProps } from './header-in-normal' import type { HeaderInRestoringProps } from './header-in-restoring' import type { HeaderInHistoryProps } from './header-in-view-history' import dynamic from '@/next/dynamic' -import { - useWorkflowMode, -} from '../hooks' +import { useWorkflowMode } from '../hooks' import HeaderInNormal from './header-in-normal' const HeaderInHistory = dynamic(() => import('./header-in-view-history'), { @@ -24,37 +22,13 @@ const Header = ({ viewHistory: viewHistoryProps, restoring: restoringProps, }: HeaderProps) => { - const { - normal, - restoring, - viewHistory, - } = useWorkflowMode() + const { normal, restoring, viewHistory } = useWorkflowMode() return ( - <div - className="absolute top-7 left-0 z-10 flex h-0 w-full items-center justify-between bg-mask-top2bottom-gray-50-to-transparent px-3" - > - { - normal && ( - <HeaderInNormal - {...normalProps} - /> - ) - } - { - viewHistory && ( - <HeaderInHistory - {...viewHistoryProps} - /> - ) - } - { - restoring && ( - <HeaderInRestoring - {...restoringProps} - /> - ) - } + <div className="absolute top-7 left-0 z-10 flex h-0 w-full items-center justify-between bg-mask-top2bottom-gray-50-to-transparent px-3"> + {normal && <HeaderInNormal {...normalProps} />} + {viewHistory && <HeaderInHistory {...viewHistoryProps} />} + {restoring && <HeaderInRestoring {...restoringProps} />} </div> ) } diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index c7f080c8425e2b..a8d893ae55489e 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -3,11 +3,7 @@ import type { OnlineUser } from '../collaboration/types/collaboration' import { ChevronDownIcon } from '@heroicons/react/20/solid' import { AvatarFallback, AvatarImage, AvatarRoot } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useAtomValue } from 'jotai' import { useEffect, useState } from 'react' @@ -32,8 +28,7 @@ const useAvatarUrls = (users: OnlineUser[]) => { try { const response = await getAvatar({ avatar: user.avatar }) newAvatarUrls[user.sid] = response.avatar_url - } - catch (error) { + } catch (error) { console.error('Failed to fetch avatar:', error) newAvatarUrls[user.sid] = user.avatar } @@ -44,8 +39,7 @@ const useAvatarUrls = (users: OnlineUser[]) => { setAvatarUrls(newAvatarUrls) } - if (users.length > 0) - fetchAvatars() + if (users.length > 0) fetchAvatars() }, [users]) return avatarUrls @@ -53,21 +47,21 @@ const useAvatarUrls = (users: OnlineUser[]) => { const OnlineUsers = () => { const { t } = useTranslation() - const appId = useStore(s => s.appId) - const { onlineUsers, cursors, isEnabled: isCollaborationEnabled } = useCollaboration(appId as string) + const appId = useStore((s) => s.appId) + const { + onlineUsers, + cursors, + isEnabled: isCollaborationEnabled, + } = useCollaboration(appId as string) const currentUserId = useAtomValue(userProfileIdAtom) const reactFlow = useReactFlow() const [dropdownOpen, setDropdownOpen] = useState(false) const avatarUrls = useAvatarUrls(onlineUsers || []) - const fallbackUsername = t($ => $['comments.fallback.user'], { ns: 'workflow' }) - const currentUserSuffix = t($ => $['members.you'], { ns: 'common' }) + const fallbackUsername = t(($) => $['comments.fallback.user'], { ns: 'workflow' }) + const currentUserSuffix = t(($) => $['members.you'], { ns: 'common' }) - const renderDisplayName = ( - user: OnlineUser, - baseClassName: string, - suffixClassName: string, - ) => { + const renderDisplayName = (user: OnlineUser, baseClassName: string, suffixClassName: string) => { const baseName = user.username || fallbackUsername const isCurrentUser = user.user_id === currentUserId @@ -75,9 +69,7 @@ const OnlineUsers = () => { <span className={cn('inline-flex min-w-0 items-center gap-1', baseClassName)}> <span className="truncate">{baseName}</span> {isCurrentUser && ( - <span className={cn('shrink-0', suffixClassName)}> - {currentUserSuffix} - </span> + <span className={cn('shrink-0', suffixClassName)}>{currentUserSuffix}</span> )} </span> ) @@ -86,15 +78,13 @@ const OnlineUsers = () => { // Function to jump to user's cursor position const jumpToUserCursor = (userId: string) => { const cursor = cursors[userId] - if (!cursor) - return + if (!cursor) return // Convert world coordinates to center the view on the cursor reactFlow.setCenter(cursor.x, cursor.y, { zoom: 1, duration: 800 }) } - if (!isCollaborationEnabled || !onlineUsers || onlineUsers.length === 0) - return null + if (!isCollaborationEnabled || !onlineUsers || onlineUsers.length === 0) return null // Display logic: // 1-3 users: show all avatars @@ -138,12 +128,7 @@ const OnlineUsers = () => { onClick={() => !isCurrentUser && jumpToUserCursor(user.user_id)} > <AvatarRoot size="sm" className="ring-1 ring-components-panel-bg"> - {avatarUrl && ( - <AvatarImage - src={avatarUrl} - alt={displayName} - /> - )} + {avatarUrl && <AvatarImage src={avatarUrl} alt={displayName} />} <AvatarFallback size="sm" style={userColor ? { backgroundColor: userColor } : undefined} @@ -170,7 +155,7 @@ const OnlineUsers = () => { {remainingCount > 0 && ( <Popover open={dropdownOpen} onOpenChange={setDropdownOpen}> <PopoverTrigger - render={( + render={ <div className="flex items-center gap-1"> <div className={cn( @@ -178,12 +163,11 @@ const OnlineUsers = () => { visibleUsers.length > 0 && '-ml-1', )} > - + - {remainingCount} + +{remainingCount} </div> <ChevronDownIcon className="size-3 cursor-pointer text-gray-500" /> </div> - )} + } /> <PopoverContent placement="bottom-start" @@ -205,7 +189,8 @@ const OnlineUsers = () => { key={user.sid} className={cn( 'flex items-center gap-2 rounded-lg px-3 py-1.5', - !isCurrentUser && 'cursor-pointer hover:bg-components-panel-on-panel-item-bg-hover', + !isCurrentUser && + 'cursor-pointer hover:bg-components-panel-on-panel-item-bg-hover', )} onClick={() => { if (!isCurrentUser) { @@ -216,12 +201,7 @@ const OnlineUsers = () => { > <div className="relative"> <AvatarRoot size="sm"> - {avatarUrl && ( - <AvatarImage - src={avatarUrl} - alt={displayName} - /> - )} + {avatarUrl && <AvatarImage src={avatarUrl} alt={displayName} />} <AvatarFallback size="sm" style={userColor ? { backgroundColor: userColor } : undefined} diff --git a/web/app/components/workflow/header/restoring-title.tsx b/web/app/components/workflow/header/restoring-title.tsx index 80b19dbafa5cd7..c23edf989a3839 100644 --- a/web/app/components/workflow/header/restoring-title.tsx +++ b/web/app/components/workflow/header/restoring-title.tsx @@ -9,38 +9,37 @@ const RestoringTitle = () => { const { t } = useTranslation() const { formatTimeFromNow } = useFormatTimeFromNow() const { formatTime } = useTimestamp() - const currentVersion = useStore(state => state.currentVersion) + const currentVersion = useStore((state) => state.currentVersion) const isDraft = currentVersion?.version === WorkflowVersion.Draft - const publishStatus = isDraft ? t($ => $['common.unpublished'], { ns: 'workflow' }) : t($ => $['common.published'], { ns: 'workflow' }) + const publishStatus = isDraft + ? t(($) => $['common.unpublished'], { ns: 'workflow' }) + : t(($) => $['common.published'], { ns: 'workflow' }) const versionName = useMemo(() => { - if (isDraft) - return t($ => $['versionHistory.currentDraft'], { ns: 'workflow' }) - return currentVersion?.marked_name || t($ => $['versionHistory.defaultName'], { ns: 'workflow' }) + if (isDraft) return t(($) => $['versionHistory.currentDraft'], { ns: 'workflow' }) + return ( + currentVersion?.marked_name || t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }) + ) }, [currentVersion, t, isDraft]) return ( <div className="flex flex-col gap-y-0.5"> <div className="flex items-center gap-x-1"> - <span className="system-sm-semibold text-text-primary"> - {versionName} - </span> + <span className="system-sm-semibold text-text-primary">{versionName}</span> <span className="rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-accent-secondary"> - {t($ => $['common.viewOnly'], { ns: 'workflow' })} + {t(($) => $['common.viewOnly'], { ns: 'workflow' })} </span> </div> <div className="flex h-4 items-center gap-x-1 system-xs-regular text-text-tertiary"> - { - currentVersion && ( - <> - <span>{publishStatus}</span> - <span>·</span> - <span>{`${formatTimeFromNow((isDraft ? currentVersion.updated_at : currentVersion.created_at) * 1000)} ${formatTime(currentVersion.created_at, 'HH:mm:ss')}`}</span> - <span>·</span> - <span>{currentVersion?.created_by?.name || ''}</span> - </> - ) - } + {currentVersion && ( + <> + <span>{publishStatus}</span> + <span>·</span> + <span>{`${formatTimeFromNow((isDraft ? currentVersion.updated_at : currentVersion.created_at) * 1000)} ${formatTime(currentVersion.created_at, 'HH:mm:ss')}`}</span> + <span>·</span> + <span>{currentVersion?.created_by?.name || ''}</span> + </> + )} </div> </div> ) diff --git a/web/app/components/workflow/header/run-and-history.tsx b/web/app/components/workflow/header/run-and-history.tsx index 59202e11a19a32..2c98874e5cd5d3 100644 --- a/web/app/components/workflow/header/run-and-history.tsx +++ b/web/app/components/workflow/header/run-and-history.tsx @@ -2,23 +2,16 @@ import type { ViewHistoryProps } from './view-history' import { cn } from '@langgenius/dify-ui/cn' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { - useNodesReadOnly, - useWorkflowStartRun, -} from '../hooks' +import { useNodesReadOnly, useWorkflowStartRun } from '../hooks' import { useHooksStore } from '../hooks-store' import Checklist from './checklist' import RunMode from './run-mode' import ViewHistory from './view-history' -const PreviewMode = memo(({ - disabled = false, -}: { - disabled?: boolean -}) => { +const PreviewMode = memo(({ disabled = false }: { disabled?: boolean }) => { const { t } = useTranslation() const { handleWorkflowStartRunInChatflow } = useWorkflowStartRun() - const canRun = useHooksStore(s => s.accessControl.canRun) + const canRun = useHooksStore((s) => s.accessControl.canRun) const isDisabled = disabled || !canRun return ( @@ -27,17 +20,14 @@ const PreviewMode = memo(({ disabled={isDisabled} className={cn( 'flex h-7 items-center rounded-md px-2.5 text-[13px] font-medium text-components-button-secondary-accent-text', - isDisabled - ? 'cursor-not-allowed opacity-50' - : 'cursor-pointer hover:bg-state-accent-hover', + isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-state-accent-hover', )} onClick={() => { - if (!isDisabled) - handleWorkflowStartRunInChatflow() + if (!isDisabled) handleWorkflowStartRunInChatflow() }} > <span aria-hidden className="mr-1 i-ri-play-large-line size-4" /> - {t($ => $['common.debugAndPreview'], { ns: 'workflow' })} + {t(($) => $['common.debugAndPreview'], { ns: 'workflow' })} </button> ) }) @@ -49,12 +39,10 @@ export type RunAndHistoryProps = { showPreviewButton?: boolean viewHistoryProps?: ViewHistoryProps components?: { - RunMode?: React.ComponentType< - { - text?: string - disabled?: boolean - } - > + RunMode?: React.ComponentType<{ + text?: string + disabled?: boolean + }> } } const RunAndHistory = ({ @@ -65,19 +53,18 @@ const RunAndHistory = ({ components, }: RunAndHistoryProps) => { const { nodesReadOnly } = useNodesReadOnly() - const canRun = useHooksStore(s => s.accessControl.canRun) + const canRun = useHooksStore((s) => s.accessControl.canRun) const { RunMode: CustomRunMode } = components || {} return ( <div className="flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-0.5 shadow-xs"> - { - showRunButton && ( - CustomRunMode ? <CustomRunMode text={runButtonText} disabled={!canRun} /> : <RunMode text={runButtonText} disabled={!canRun} /> - ) - } - { - showPreviewButton && <PreviewMode disabled={!canRun} /> - } + {showRunButton && + (CustomRunMode ? ( + <CustomRunMode text={runButtonText} disabled={!canRun} /> + ) : ( + <RunMode text={runButtonText} disabled={!canRun} /> + ))} + {showPreviewButton && <PreviewMode disabled={!canRun} />} <div className="mx-0.5 h-3.5 w-px bg-divider-regular"></div> <ViewHistory {...viewHistoryProps} /> <Checklist disabled={nodesReadOnly} /> diff --git a/web/app/components/workflow/header/run-mode.tsx b/web/app/components/workflow/header/run-mode.tsx index f9c2d97052eaca..ce571313e88498 100644 --- a/web/app/components/workflow/header/run-mode.tsx +++ b/web/app/components/workflow/header/run-mode.tsx @@ -7,7 +7,11 @@ import * as React from 'react' import { useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' -import { useWorkflowRun, useWorkflowRunValidation, useWorkflowStartRun } from '@/app/components/workflow/hooks' +import { + useWorkflowRun, + useWorkflowRunValidation, + useWorkflowStartRun, +} from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd' import { useStore } from '@/app/components/workflow/store/workflow' @@ -26,10 +30,7 @@ type RunModeProps = { const isWorkflowStopEvent = (value: EventEmitterValue) => typeof value !== 'string' && value.type === EVENT_WORKFLOW_STOP -const RunMode = ({ - text, - disabled = false, -}: RunModeProps) => { +const RunMode = ({ text, disabled = false }: RunModeProps) => { const { t } = useTranslation() const { handleWorkflowStartRunInWorkflow, @@ -40,9 +41,9 @@ const RunMode = ({ } = useWorkflowStartRun() const { handleStopRun } = useWorkflowRun() const { warningNodes } = useWorkflowRunValidation() - const workflowRunningData = useStore(s => s.workflowRunningData) - const isListening = useStore(s => s.isListening) - const canRun = useHooksStore(s => s.accessControl.canRun) + const workflowRunningData = useStore((s) => s.workflowRunningData) + const isListening = useStore((s) => s.isListening) + const canRun = useHooksStore((s) => s.accessControl.canRun) const isRunDisabled = disabled || !canRun const status = workflowRunningData?.result.status @@ -52,8 +53,7 @@ const RunMode = ({ const testRunMenuRef = useRef<TestRunMenuRef>(null) const handleToggleTestRunMenu = useCallback(() => { - if (isRunDisabled) - return + if (isRunDisabled) return testRunMenuRef.current?.toggle() }, [isRunDisabled]) @@ -66,120 +66,111 @@ const RunMode = ({ handleStopRun(workflowRunningData?.task_id || '') }, [handleStopRun, workflowRunningData?.task_id]) - const handleTriggerSelect = useCallback((option: TriggerOption) => { - if (isRunDisabled) - return + const handleTriggerSelect = useCallback( + (option: TriggerOption) => { + if (isRunDisabled) return - // Validate checklist before running any workflow - let isValid: boolean = true - warningNodes.forEach((node) => { - if (node.id === option.nodeId) - isValid = false - }) - if (!isValid) { - toast.error(t($ => $['panel.checklistTip'], { ns: 'workflow' })) - return - } + // Validate checklist before running any workflow + let isValid: boolean = true + warningNodes.forEach((node) => { + if (node.id === option.nodeId) isValid = false + }) + if (!isValid) { + toast.error(t(($) => $['panel.checklistTip'], { ns: 'workflow' })) + return + } - if (option.type === TriggerType.UserInput) { - handleWorkflowStartRunInWorkflow() - trackEvent('app_start_action_time', { action_type: 'user_input' }) - } - else if (option.type === TriggerType.Schedule) { - handleWorkflowTriggerScheduleRunInWorkflow(option.nodeId) - trackEvent('app_start_action_time', { action_type: 'schedule' }) - } - else if (option.type === TriggerType.Webhook) { - if (option.nodeId) - handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: option.nodeId }) - trackEvent('app_start_action_time', { action_type: 'webhook' }) - } - else if (option.type === TriggerType.Plugin) { - if (option.nodeId) - handleWorkflowTriggerPluginRunInWorkflow(option.nodeId) - trackEvent('app_start_action_time', { action_type: 'plugin' }) - } - else if (option.type === TriggerType.All) { - const targetNodeIds = option.relatedNodeIds?.filter(Boolean) - if (targetNodeIds && targetNodeIds.length > 0) - handleWorkflowRunAllTriggersInWorkflow(targetNodeIds) - trackEvent('app_start_action_time', { action_type: 'all' }) - } - }, [isRunDisabled, warningNodes, t, handleWorkflowStartRunInWorkflow, handleWorkflowTriggerScheduleRunInWorkflow, handleWorkflowTriggerWebhookRunInWorkflow, handleWorkflowTriggerPluginRunInWorkflow, handleWorkflowRunAllTriggersInWorkflow]) + if (option.type === TriggerType.UserInput) { + handleWorkflowStartRunInWorkflow() + trackEvent('app_start_action_time', { action_type: 'user_input' }) + } else if (option.type === TriggerType.Schedule) { + handleWorkflowTriggerScheduleRunInWorkflow(option.nodeId) + trackEvent('app_start_action_time', { action_type: 'schedule' }) + } else if (option.type === TriggerType.Webhook) { + if (option.nodeId) handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: option.nodeId }) + trackEvent('app_start_action_time', { action_type: 'webhook' }) + } else if (option.type === TriggerType.Plugin) { + if (option.nodeId) handleWorkflowTriggerPluginRunInWorkflow(option.nodeId) + trackEvent('app_start_action_time', { action_type: 'plugin' }) + } else if (option.type === TriggerType.All) { + const targetNodeIds = option.relatedNodeIds?.filter(Boolean) + if (targetNodeIds && targetNodeIds.length > 0) + handleWorkflowRunAllTriggersInWorkflow(targetNodeIds) + trackEvent('app_start_action_time', { action_type: 'all' }) + } + }, + [ + isRunDisabled, + warningNodes, + t, + handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, + ], + ) const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((v: EventEmitterValue) => { - if (isWorkflowStopEvent(v)) - handleStop() + if (isWorkflowStopEvent(v)) handleStop() }) return ( <div className="flex items-center gap-x-px"> - { - isRunDisabled - ? ( - <button - type="button" - className={cn( - 'flex h-7 cursor-not-allowed items-center gap-x-1 rounded-md px-1.5 system-xs-medium text-text-accent opacity-50', - )} - disabled - style={{ userSelect: 'none' }} - > - <span aria-hidden className="mr-1 i-ri-play-large-line size-4" /> - {text ?? t($ => $['common.run'], { ns: 'workflow' })} - <ShortcutKbd hotkey={TEST_RUN_MENU_HOTKEY} textColor="secondary" /> - </button> - ) - : ( - isRunning - ? ( - <button - type="button" - className={cn( - 'flex h-7 cursor-not-allowed items-center gap-x-1 rounded-l-md bg-state-accent-hover px-1.5 system-xs-medium text-text-accent', - )} - disabled={true} - > - <span className="mr-1 i-ri-loader-2-line size-4 animate-spin" /> - {isListening ? t($ => $['common.listening'], { ns: 'workflow' }) : t($ => $['common.running'], { ns: 'workflow' })} - </button> - ) - : ( - <TestRunMenu - ref={testRunMenuRef} - options={dynamicOptions} - onSelect={handleTriggerSelect} - > - <button - type="button" - className={cn( - 'flex h-7 cursor-pointer items-center gap-x-1 rounded-md px-1.5 system-xs-medium text-text-accent hover:bg-state-accent-hover', - )} - style={{ userSelect: 'none' }} - > - <span aria-hidden className="mr-1 i-ri-play-large-line size-4" /> - {text ?? t($ => $['common.run'], { ns: 'workflow' })} - <ShortcutKbd hotkey={TEST_RUN_MENU_HOTKEY} textColor="secondary" /> - </button> - </TestRunMenu> - ) - ) - } - { - isRunning && !isRunDisabled && ( + {isRunDisabled ? ( + <button + type="button" + className={cn( + 'flex h-7 cursor-not-allowed items-center gap-x-1 rounded-md px-1.5 system-xs-medium text-text-accent opacity-50', + )} + disabled + style={{ userSelect: 'none' }} + > + <span aria-hidden className="mr-1 i-ri-play-large-line size-4" /> + {text ?? t(($) => $['common.run'], { ns: 'workflow' })} + <ShortcutKbd hotkey={TEST_RUN_MENU_HOTKEY} textColor="secondary" /> + </button> + ) : isRunning ? ( + <button + type="button" + className={cn( + 'flex h-7 cursor-not-allowed items-center gap-x-1 rounded-l-md bg-state-accent-hover px-1.5 system-xs-medium text-text-accent', + )} + disabled={true} + > + <span className="mr-1 i-ri-loader-2-line size-4 animate-spin" /> + {isListening + ? t(($) => $['common.listening'], { ns: 'workflow' }) + : t(($) => $['common.running'], { ns: 'workflow' })} + </button> + ) : ( + <TestRunMenu ref={testRunMenuRef} options={dynamicOptions} onSelect={handleTriggerSelect}> <button type="button" - aria-label={t($ => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' })} className={cn( - 'flex size-7 items-center justify-center rounded-r-md bg-state-accent-active', + 'flex h-7 cursor-pointer items-center gap-x-1 rounded-md px-1.5 system-xs-medium text-text-accent hover:bg-state-accent-hover', )} - onClick={handleStop} + style={{ userSelect: 'none' }} > - <span aria-hidden className="i-ri-stop-circle-line size-4 text-text-accent" /> + <span aria-hidden className="mr-1 i-ri-play-large-line size-4" /> + {text ?? t(($) => $['common.run'], { ns: 'workflow' })} + <ShortcutKbd hotkey={TEST_RUN_MENU_HOTKEY} textColor="secondary" /> </button> - ) - } + </TestRunMenu> + )} + {isRunning && !isRunDisabled && ( + <button + type="button" + aria-label={t(($) => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' })} + className={cn( + 'flex size-7 items-center justify-center rounded-r-md bg-state-accent-active', + )} + onClick={handleStop} + > + <span aria-hidden className="i-ri-stop-circle-line size-4 text-text-accent" /> + </button> + )} </div> ) } diff --git a/web/app/components/workflow/header/running-title.tsx b/web/app/components/workflow/header/running-title.tsx index 0071a88e87c0bf..63c3279396a2b6 100644 --- a/web/app/components/workflow/header/running-title.tsx +++ b/web/app/components/workflow/header/running-title.tsx @@ -8,15 +8,19 @@ import { formatWorkflowRunIdentifier } from '../utils' const RunningTitle = () => { const { t } = useTranslation() const isChatMode = useIsChatMode() - const historyWorkflowData = useStore(s => s.historyWorkflowData) + const historyWorkflowData = useStore((s) => s.historyWorkflowData) return ( <div className="flex h-[18px] items-center text-xs text-gray-500"> <ClockPlay className="mr-1 size-3 text-gray-500" /> - <span>{isChatMode ? `Test Chat${formatWorkflowRunIdentifier(historyWorkflowData?.finished_at)}` : `Test Run${formatWorkflowRunIdentifier(historyWorkflowData?.finished_at)}`}</span> + <span> + {isChatMode + ? `Test Chat${formatWorkflowRunIdentifier(historyWorkflowData?.finished_at)}` + : `Test Run${formatWorkflowRunIdentifier(historyWorkflowData?.finished_at)}`} + </span> <span className="mx-1">·</span> <span className="ml-1 flex h-[18px] items-center rounded-[5px] border border-indigo-300 bg-white/48 px-1 text-[10px] font-semibold text-indigo-600 uppercase"> - {t($ => $['common.viewOnly'], { ns: 'workflow' })} + {t(($) => $['common.viewOnly'], { ns: 'workflow' })} </span> </div> ) diff --git a/web/app/components/workflow/header/scroll-to-selected-node-button.tsx b/web/app/components/workflow/header/scroll-to-selected-node-button.tsx index f043c81ca0ef02..b6a94678c8830e 100644 --- a/web/app/components/workflow/header/scroll-to-selected-node-button.tsx +++ b/web/app/components/workflow/header/scroll-to-selected-node-button.tsx @@ -8,10 +8,9 @@ import { scrollToWorkflowNode } from '../utils/node-navigation' const ScrollToSelectedNodeButton: FC = () => { const { t } = useTranslation() const nodes = useNodes<CommonNodeType>() - const selectedNode = nodes.find(node => node.data.selected) + const selectedNode = nodes.find((node) => node.data.selected) - if (!selectedNode) - return null + if (!selectedNode) return null return ( <div @@ -20,7 +19,7 @@ const ScrollToSelectedNodeButton: FC = () => { )} onClick={() => scrollToWorkflowNode(selectedNode.id)} > - {t($ => $['panel.scrollToSelectedNode'], { ns: 'workflow' })} + {t(($) => $['panel.scrollToSelectedNode'], { ns: 'workflow' })} </div> ) } diff --git a/web/app/components/workflow/header/test-run-menu-helpers.tsx b/web/app/components/workflow/header/test-run-menu-helpers.tsx index cebe3cd39212e0..672fc660b70239 100644 --- a/web/app/components/workflow/header/test-run-menu-helpers.tsx +++ b/web/app/components/workflow/header/test-run-menu-helpers.tsx @@ -2,11 +2,7 @@ import type { MouseEvent, MouseEventHandler, ReactElement } from 'react' import type { TriggerOption } from './test-run-menu' import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu' -import { - cloneElement, - isValidElement, - useEffect, -} from 'react' +import { cloneElement, isValidElement, useEffect } from 'react' import { ShortcutKbd } from '../shortcuts/shortcut-kbd' export type ShortcutMapping = { @@ -33,14 +29,10 @@ export const OptionRow = ({ onClick={() => onSelect(option)} > <div className="flex min-w-0 flex-1 items-center"> - <div className="flex size-6 shrink-0 items-center justify-center"> - {option.icon} - </div> + <div className="flex size-6 shrink-0 items-center justify-center">{option.icon}</div> <span className="ml-2 truncate">{option.name}</span> </div> - {shortcutKey && ( - <ShortcutKbd hotkey={shortcutKey} className="ml-2" textColor="secondary" /> - )} + {shortcutKey && <ShortcutKbd hotkey={shortcutKey} className="ml-2" textColor="secondary" />} </DropdownMenuItem> ) } @@ -55,8 +47,7 @@ export const useShortcutMenu = ({ handleSelect: (option: TriggerOption) => void }) => { useEffect(() => { - if (!open) - return + if (!open) return const handleKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) @@ -86,8 +77,7 @@ export const SingleOptionTrigger = ({ runSoleOption: () => void }) => { const handleRunClick = (event?: MouseEvent<HTMLElement>) => { - if (event?.defaultPrevented) - return + if (event?.defaultPrevented) return runSoleOption() } @@ -99,11 +89,9 @@ export const SingleOptionTrigger = ({ // eslint-disable-next-line react/no-clone-element return cloneElement(childElement, { onClick: (event: MouseEvent<HTMLElement>) => { - if (typeof originalOnClick === 'function') - originalOnClick(event) + if (typeof originalOnClick === 'function') originalOnClick(event) - if (event?.defaultPrevented) - return + if (event?.defaultPrevented) return runSoleOption() }, diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx index 31b6eec0922c91..9ba837d9752049 100644 --- a/web/app/components/workflow/header/test-run-menu.tsx +++ b/web/app/components/workflow/header/test-run-menu.tsx @@ -1,6 +1,20 @@ import type { ShortcutMapping } from './test-run-menu-helpers' -import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu' -import { forwardRef, isValidElement, useCallback, useImperativeHandle, useMemo, useState } from 'react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { + forwardRef, + isValidElement, + useCallback, + useImperativeHandle, + useMemo, + useState, +} from 'react' import { useTranslation } from 'react-i18next' import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers' @@ -41,19 +55,17 @@ export type TestRunMenuRef = { const getEnabledOptions = (options: TestRunOptions) => { const flattened: TriggerOption[] = [] - if (options.userInput) - flattened.push(options.userInput) - if (options.runAll) - flattened.push(options.runAll) + if (options.userInput) flattened.push(options.userInput) + if (options.runAll) flattened.push(options.runAll) flattened.push(...options.triggers) - return flattened.filter(option => option.enabled !== false) + return flattened.filter((option) => option.enabled !== false) } const getMenuVisibility = (options: TestRunOptions) => { return { hasUserInput: Boolean(options.userInput?.enabled !== false && options.userInput), - hasTriggers: options.triggers.some(trigger => trigger.enabled !== false), + hasTriggers: options.triggers.some((trigger) => trigger.enabled !== false), hasRunAll: Boolean(options.runAll?.enabled !== false && options.runAll), } } @@ -78,113 +90,114 @@ const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => { } // eslint-disable-next-line react/no-forward-ref -const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({ - options, - onSelect, - children, -}, ref) => { - const { t } = useTranslation() - const [open, setOpen] = useState(false) - const shortcutMappings = useMemo(() => buildShortcutMappings(options), [options]) - const shortcutKeyById = useMemo(() => { - const map = new Map<string, string>() - shortcutMappings.forEach(({ option, shortcutKey }) => { - map.set(option.id, shortcutKey) - }) - return map - }, [shortcutMappings]) - - const handleSelect = useCallback((option: TriggerOption) => { - onSelect(option) - setOpen(false) - }, [onSelect]) - - const enabledOptions = useMemo(() => getEnabledOptions(options), [options]) +const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>( + ({ options, onSelect, children }, ref) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const shortcutMappings = useMemo(() => buildShortcutMappings(options), [options]) + const shortcutKeyById = useMemo(() => { + const map = new Map<string, string>() + shortcutMappings.forEach(({ option, shortcutKey }) => { + map.set(option.id, shortcutKey) + }) + return map + }, [shortcutMappings]) + + const handleSelect = useCallback( + (option: TriggerOption) => { + onSelect(option) + setOpen(false) + }, + [onSelect], + ) - const hasSingleEnabledOption = enabledOptions.length === 1 - const soleEnabledOption = hasSingleEnabledOption ? enabledOptions[0] : undefined + const enabledOptions = useMemo(() => getEnabledOptions(options), [options]) - const runSoleOption = useCallback(() => { - if (soleEnabledOption) - handleSelect(soleEnabledOption) - }, [handleSelect, soleEnabledOption]) + const hasSingleEnabledOption = enabledOptions.length === 1 + const soleEnabledOption = hasSingleEnabledOption ? enabledOptions[0] : undefined - useShortcutMenu({ - open, - shortcutMappings, - handleSelect, - }) + const runSoleOption = useCallback(() => { + if (soleEnabledOption) handleSelect(soleEnabledOption) + }, [handleSelect, soleEnabledOption]) - useImperativeHandle(ref, () => ({ - toggle: () => { - if (hasSingleEnabledOption) { - runSoleOption() - return - } + useShortcutMenu({ + open, + shortcutMappings, + handleSelect, + }) - setOpen(prev => !prev) - }, - }), [hasSingleEnabledOption, runSoleOption]) + useImperativeHandle( + ref, + () => ({ + toggle: () => { + if (hasSingleEnabledOption) { + runSoleOption() + return + } + + setOpen((prev) => !prev) + }, + }), + [hasSingleEnabledOption, runSoleOption], + ) - const renderOption = (option: TriggerOption) => { - return <OptionRow key={option.id} option={option} shortcutKey={shortcutKeyById.get(option.id)} onSelect={handleSelect} /> - } + const renderOption = (option: TriggerOption) => { + return ( + <OptionRow + key={option.id} + option={option} + shortcutKey={shortcutKeyById.get(option.id)} + onSelect={handleSelect} + /> + ) + } + + const { hasUserInput, hasTriggers, hasRunAll } = useMemo( + () => getMenuVisibility(options), + [options], + ) - const { hasUserInput, hasTriggers, hasRunAll } = useMemo(() => getMenuVisibility(options), [options]) + if (hasSingleEnabledOption && soleEnabledOption) { + return <SingleOptionTrigger runSoleOption={runSoleOption}>{children}</SingleOptionTrigger> + } - if (hasSingleEnabledOption && soleEnabledOption) { return ( - <SingleOptionTrigger runSoleOption={runSoleOption}> - {children} - </SingleOptionTrigger> + <DropdownMenu open={open} onOpenChange={setOpen}> + {isValidElement(children) ? ( + <DropdownMenuTrigger render={children} style={{ userSelect: 'none' }} /> + ) : ( + <DropdownMenuTrigger style={{ userSelect: 'none' }}>{children}</DropdownMenuTrigger> + )} + <DropdownMenuContent + placement="bottom-start" + sideOffset={8} + alignOffset={-4} + popupClassName="w-[284px] p-1" + > + <DropdownMenuGroup> + <DropdownMenuLabel className="mb-1 px-3 pt-2 text-sm font-medium text-text-primary"> + {t(($) => $['common.chooseStartNodeToRun'], { ns: 'workflow' })} + </DropdownMenuLabel> + <div> + {hasUserInput && renderOption(options.userInput!)} + + {(hasTriggers || hasRunAll) && hasUserInput && ( + <DropdownMenuSeparator className="mx-3" /> + )} + + {hasRunAll && renderOption(options.runAll!)} + + {hasTriggers && + options.triggers + .filter((trigger) => trigger.enabled !== false) + .map((trigger) => renderOption(trigger))} + </div> + </DropdownMenuGroup> + </DropdownMenuContent> + </DropdownMenu> ) - } - - return ( - <DropdownMenu - open={open} - onOpenChange={setOpen} - > - {isValidElement(children) - ? ( - <DropdownMenuTrigger - render={children} - style={{ userSelect: 'none' }} - /> - ) - : ( - <DropdownMenuTrigger style={{ userSelect: 'none' }}> - {children} - </DropdownMenuTrigger> - )} - <DropdownMenuContent - placement="bottom-start" - sideOffset={8} - alignOffset={-4} - popupClassName="w-[284px] p-1" - > - <DropdownMenuGroup> - <DropdownMenuLabel className="mb-1 px-3 pt-2 text-sm font-medium text-text-primary"> - {t($ => $['common.chooseStartNodeToRun'], { ns: 'workflow' })} - </DropdownMenuLabel> - <div> - {hasUserInput && renderOption(options.userInput!)} - - {(hasTriggers || hasRunAll) && hasUserInput && ( - <DropdownMenuSeparator className="mx-3" /> - )} - - {hasRunAll && renderOption(options.runAll!)} - - {hasTriggers && options.triggers - .filter(trigger => trigger.enabled !== false) - .map(trigger => renderOption(trigger))} - </div> - </DropdownMenuGroup> - </DropdownMenuContent> - </DropdownMenu> - ) -}) + }, +) TestRunMenu.displayName = 'TestRunMenu' diff --git a/web/app/components/workflow/header/undo-redo.tsx b/web/app/components/workflow/header/undo-redo.tsx index b9b9c856a208fa..f56c82a72340ad 100644 --- a/web/app/components/workflow/header/undo-redo.tsx +++ b/web/app/components/workflow/header/undo-redo.tsx @@ -8,7 +8,7 @@ import { useWorkflowHistoryStore } from '@/app/components/workflow/workflow-hist import Divider from '../../base/divider' import TipPopup from '../operator/tip-popup' -type UndoRedoProps = { handleUndo: () => void, handleRedo: () => void } +type UndoRedoProps = { handleUndo: () => void; handleRedo: () => void } const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => { const { t } = useTranslation() const { store } = useWorkflowHistoryStore() @@ -28,31 +28,33 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => { return ( <div className="flex items-center space-x-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-[5px]"> - <TipPopup title={t($ => $['common.undo'], { ns: 'workflow' })!} shortcut="workflow.undo"> + <TipPopup title={t(($) => $['common.undo'], { ns: 'workflow' })!} shortcut="workflow.undo"> <button type="button" - aria-label={t($ => $['common.undo'], { ns: 'workflow' })!} + aria-label={t(($) => $['common.undo'], { ns: 'workflow' })!} data-tooltip-id="workflow.undo" disabled={nodesReadOnly || buttonsDisabled.undo} - className={ - cn('flex size-8 cursor-pointer items-center rounded-md px-1.5 system-sm-medium text-text-tertiary select-none hover:bg-state-base-hover hover:text-text-secondary', (nodesReadOnly || buttonsDisabled.undo) - && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled') - } + className={cn( + 'flex size-8 cursor-pointer items-center rounded-md px-1.5 system-sm-medium text-text-tertiary select-none hover:bg-state-base-hover hover:text-text-secondary', + (nodesReadOnly || buttonsDisabled.undo) && + 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled', + )} onClick={handleUndo} > <span className="i-ri-arrow-go-back-line size-4" /> </button> </TipPopup> - <TipPopup title={t($ => $['common.redo'], { ns: 'workflow' })!} shortcut="workflow.redo"> + <TipPopup title={t(($) => $['common.redo'], { ns: 'workflow' })!} shortcut="workflow.redo"> <button type="button" - aria-label={t($ => $['common.redo'], { ns: 'workflow' })!} + aria-label={t(($) => $['common.redo'], { ns: 'workflow' })!} data-tooltip-id="workflow.redo" disabled={nodesReadOnly || buttonsDisabled.redo} - className={ - cn('flex size-8 cursor-pointer items-center rounded-md px-1.5 system-sm-medium text-text-tertiary select-none hover:bg-state-base-hover hover:text-text-secondary', (nodesReadOnly || buttonsDisabled.redo) - && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled') - } + className={cn( + 'flex size-8 cursor-pointer items-center rounded-md px-1.5 system-sm-medium text-text-tertiary select-none hover:bg-state-base-hover hover:text-text-secondary', + (nodesReadOnly || buttonsDisabled.redo) && + 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled', + )} onClick={handleRedo} > <span className="i-ri-arrow-go-forward-fill size-4" /> diff --git a/web/app/components/workflow/header/version-history-button.tsx b/web/app/components/workflow/header/version-history-button.tsx index cc52e0e477dc4a..bec254e0ea1664 100644 --- a/web/app/components/workflow/header/version-history-button.tsx +++ b/web/app/components/workflow/header/version-history-button.tsx @@ -1,11 +1,7 @@ import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useHotkey } from '@tanstack/react-hotkeys' import * as React from 'react' import { useCallback } from 'react' @@ -24,7 +20,7 @@ const PopupContent = React.memo(() => { return ( <div className="flex items-center gap-x-1"> <div className="px-0.5 system-xs-medium text-text-secondary"> - {t($ => $['common.versionHistory'], { ns: 'workflow' })} + {t(($) => $['common.versionHistory'], { ns: 'workflow' })} </div> <ShortcutKbd hotkey={VERSION_HISTORY_HOTKEY} bgColor="gray" textColor="secondary" /> </div> @@ -33,24 +29,26 @@ const PopupContent = React.memo(() => { PopupContent.displayName = 'PopupContent' -const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ - onClick, -}) => { +const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => { const { theme } = useTheme() const handleViewVersionHistory = useCallback(async () => { await onClick?.() }, [onClick]) - useHotkey(VERSION_HISTORY_HOTKEY, () => { - void handleViewVersionHistory() - }, { - ignoreInputs: true, - }) + useHotkey( + VERSION_HISTORY_HOTKEY, + () => { + void handleViewVersionHistory() + }, + { + ignoreInputs: true, + }, + ) return ( <Tooltip> <TooltipTrigger - render={( + render={ <Button className={cn( 'rounded-lg border border-transparent p-2', @@ -60,11 +58,9 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ > <span className="i-ri-history-line size-4 text-components-button-secondary-text" /> </Button> - )} + } /> - <TooltipContent - className="rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]" - > + <TooltipContent className="rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"> <PopupContent /> </TooltipContent> </Tooltip> diff --git a/web/app/components/workflow/header/view-history.tsx b/web/app/components/workflow/header/view-history.tsx index adc70a9da5a5d3..3b52c3a5e45046 100644 --- a/web/app/components/workflow/header/view-history.tsx +++ b/web/app/components/workflow/header/view-history.tsx @@ -1,25 +1,11 @@ import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' -import { - memo, - useState, -} from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' -import { - useStore, - useWorkflowStore, -} from '@/app/components/workflow/store' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { useWorkflowRunHistory } from '@/service/use-workflow' import { @@ -36,192 +22,160 @@ export type ViewHistoryProps = { onClearLogAndMessageModal?: () => void historyUrl?: string } -const ViewHistory = ({ - withText, - onClearLogAndMessageModal, - historyUrl, -}: ViewHistoryProps) => { +const ViewHistory = ({ withText, onClearLogAndMessageModal, historyUrl }: ViewHistoryProps) => { const { t } = useTranslation() const isChatMode = useIsChatMode() const [open, setOpen] = useState(false) const { formatTimeFromNow } = useFormatTimeFromNow() - const { - handleNodesCancelSelected, - } = useNodesInteractions() - const { - handleCancelDebugAndPreviewPanel, - } = useWorkflowInteractions() + const { handleNodesCancelSelected } = useNodesInteractions() + const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions() const workflowStore = useWorkflowStore() - const setControlMode = useStore(s => s.setControlMode) - const historyWorkflowData = useStore(s => s.historyWorkflowData) + const setControlMode = useStore((s) => s.setControlMode) + const historyWorkflowData = useStore((s) => s.historyWorkflowData) const { handleBackupDraft } = useWorkflowRun() const { closeAllInputFieldPanels } = useInputFieldPanel() const shouldFetchHistory = open && !!historyUrl - const { - data, - isLoading, - } = useWorkflowRunHistory(historyUrl, shouldFetchHistory) + const { data, isLoading } = useWorkflowRunHistory(historyUrl, shouldFetchHistory) return ( - ( - <Popover - open={open} - onOpenChange={setOpen} - > - {withText - ? ( - <PopoverTrigger - render={( - <button - type="button" - aria-label={t($ => $['common.showRunHistory'], { ns: 'workflow' })} - className={cn( - 'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs', - 'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover', - 'data-popup-open:bg-components-button-secondary-bg-hover', - )} - > - <span className="mr-1 i-custom-vender-line-time-clock-play size-4" /> - {t($ => $['common.showRunHistory'], { ns: 'workflow' })} - </button> - )} - /> - ) - : ( - <Tooltip> - <TooltipTrigger - render={<div className="flex" />} + <Popover open={open} onOpenChange={setOpen}> + {withText ? ( + <PopoverTrigger + render={ + <button + type="button" + aria-label={t(($) => $['common.showRunHistory'], { ns: 'workflow' })} + className={cn( + 'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs', + 'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover', + 'data-popup-open:bg-components-button-secondary-bg-hover', + )} + > + <span className="mr-1 i-custom-vender-line-time-clock-play size-4" /> + {t(($) => $['common.showRunHistory'], { ns: 'workflow' })} + </button> + } + /> + ) : ( + <Tooltip> + <TooltipTrigger render={<div className="flex" />}> + <PopoverTrigger + render={ + <button + type="button" + aria-label={t(($) => $['common.viewRunHistory'], { ns: 'workflow' })} + className="group flex size-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover data-popup-open:bg-state-accent-hover" + onClick={() => { + onClearLogAndMessageModal?.() + }} > - <PopoverTrigger - render={( - <button - type="button" - aria-label={t($ => $['common.viewRunHistory'], { ns: 'workflow' })} - className="group flex size-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover data-popup-open:bg-state-accent-hover" - onClick={() => { - onClearLogAndMessageModal?.() - }} - > - <span className="i-custom-vender-line-time-clock-play size-4 text-components-button-ghost-text group-hover:text-components-button-secondary-accent-text group-data-popup-open:text-components-button-secondary-accent-text" /> - </button> - )} - /> - </TooltipTrigger> - <TooltipContent> - {t($ => $['common.viewRunHistory'], { ns: 'workflow' })} - </TooltipContent> - </Tooltip> - )} - <PopoverContent - placement={withText ? 'bottom-start' : 'bottom-end'} - sideOffset={4} - alignOffset={withText ? -8 : 10} - popupClassName="border-none bg-transparent shadow-none" + <span className="i-custom-vender-line-time-clock-play size-4 text-components-button-ghost-text group-hover:text-components-button-secondary-accent-text group-data-popup-open:text-components-button-secondary-accent-text" /> + </button> + } + /> + </TooltipTrigger> + <TooltipContent> + {t(($) => $['common.viewRunHistory'], { ns: 'workflow' })} + </TooltipContent> + </Tooltip> + )} + <PopoverContent + placement={withText ? 'bottom-start' : 'bottom-end'} + sideOffset={4} + alignOffset={withText ? -8 : 10} + popupClassName="border-none bg-transparent shadow-none" + > + <div + className="ml-2 flex w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl" + style={{ + maxHeight: 'calc(2 / 3 * 100vh)', + }} > - <div - className="ml-2 flex w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl" - style={{ - maxHeight: 'calc(2 / 3 * 100vh)', - }} - > - <div className="sticky top-0 flex items-center justify-between bg-components-panel-bg px-4 pt-3 text-base font-semibold text-text-primary"> - <div className="grow">{t($ => $['common.runHistory'], { ns: 'workflow' })}</div> - <button - type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} - className="flex size-6 shrink-0 cursor-pointer items-center justify-center" - onClick={() => { - onClearLogAndMessageModal?.() - setOpen(false) - }} - > - <span className="i-ri-close-line size-4 text-text-tertiary" /> - </button> + <div className="sticky top-0 flex items-center justify-between bg-components-panel-bg px-4 pt-3 text-base font-semibold text-text-primary"> + <div className="grow">{t(($) => $['common.runHistory'], { ns: 'workflow' })}</div> + <button + type="button" + aria-label={t(($) => $['operation.close'], { ns: 'common' })} + className="flex size-6 shrink-0 cursor-pointer items-center justify-center" + onClick={() => { + onClearLogAndMessageModal?.() + setOpen(false) + }} + > + <span className="i-ri-close-line size-4 text-text-tertiary" /> + </button> + </div> + {isLoading && ( + <div className="flex h-10 items-center justify-center"> + <Loading /> </div> - { - isLoading && ( - <div className="flex h-10 items-center justify-center"> - <Loading /> + )} + {!isLoading && ( + <div className="p-2"> + {!data?.data.length && ( + <div className="py-12"> + <span className="mx-auto mb-2 i-custom-vender-line-time-clock-play-slim size-8 text-text-quaternary" /> + <div className="text-center text-[13px] text-text-quaternary"> + {t(($) => $['common.notRunning'], { ns: 'workflow' })} + </div> </div> - ) - } - { - !isLoading && ( - <div className="p-2"> - { - !data?.data.length && ( - <div className="py-12"> - <span className="mx-auto mb-2 i-custom-vender-line-time-clock-play-slim size-8 text-text-quaternary" /> - <div className="text-center text-[13px] text-text-quaternary"> - {t($ => $['common.notRunning'], { ns: 'workflow' })} - </div> - </div> - ) - } - { - data?.data.map(item => ( - <div - key={item.id} - className={cn( - 'mb-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] hover:bg-state-base-hover', - item.id === historyWorkflowData?.id && 'bg-state-accent-hover hover:bg-state-accent-hover', - )} - onClick={() => { - workflowStore.setState({ - historyWorkflowData: item, - showInputsPanel: false, - showEnvPanel: false, - }) - closeAllInputFieldPanels() - handleBackupDraft() - setOpen(false) - handleNodesCancelSelected() - handleCancelDebugAndPreviewPanel() - setControlMode(ControlMode.Hand) - }} - > - { - !isChatMode && [WorkflowRunningStatus.Stopped, WorkflowRunningStatus.Paused].includes(item.status) && ( - <span className="mt-0.5 mr-1.5 i-custom-vender-line-alertsAndFeedback-alert-triangle h-3.5 w-3.5 text-[#F79009]" /> - ) - } - { - !isChatMode && item.status === WorkflowRunningStatus.Failed && ( - <span className="mt-0.5 mr-1.5 i-ri-error-warning-line h-3.5 w-3.5 text-[#F04438]" /> - ) - } - { - !isChatMode && item.status === WorkflowRunningStatus.Succeeded && ( - <span className="mt-0.5 mr-1.5 i-ri-checkbox-circle-line h-3.5 w-3.5 text-[#12B76A]" /> - ) - } - <div> - <div - className={cn( - 'flex items-center text-[13px] leading-[18px] font-medium text-text-primary', - item.id === historyWorkflowData?.id && 'text-text-accent', - )} - > - {`Test ${isChatMode ? 'Chat' : 'Run'}${formatWorkflowRunIdentifier(item.finished_at, item.status)}`} - </div> - <div className="flex items-center text-xs leading-[18px] text-text-tertiary"> - {item.created_by_account?.name} - {' '} - · - {formatTimeFromNow((item.finished_at || item.created_at) * 1000)} - </div> - </div> - </div> - )) - } + )} + {data?.data.map((item) => ( + <div + key={item.id} + className={cn( + 'mb-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] hover:bg-state-base-hover', + item.id === historyWorkflowData?.id && + 'bg-state-accent-hover hover:bg-state-accent-hover', + )} + onClick={() => { + workflowStore.setState({ + historyWorkflowData: item, + showInputsPanel: false, + showEnvPanel: false, + }) + closeAllInputFieldPanels() + handleBackupDraft() + setOpen(false) + handleNodesCancelSelected() + handleCancelDebugAndPreviewPanel() + setControlMode(ControlMode.Hand) + }} + > + {!isChatMode && + [WorkflowRunningStatus.Stopped, WorkflowRunningStatus.Paused].includes( + item.status, + ) && ( + <span className="mt-0.5 mr-1.5 i-custom-vender-line-alertsAndFeedback-alert-triangle h-3.5 w-3.5 text-[#F79009]" /> + )} + {!isChatMode && item.status === WorkflowRunningStatus.Failed && ( + <span className="mt-0.5 mr-1.5 i-ri-error-warning-line h-3.5 w-3.5 text-[#F04438]" /> + )} + {!isChatMode && item.status === WorkflowRunningStatus.Succeeded && ( + <span className="mt-0.5 mr-1.5 i-ri-checkbox-circle-line h-3.5 w-3.5 text-[#12B76A]" /> + )} + <div> + <div + className={cn( + 'flex items-center text-[13px] leading-[18px] font-medium text-text-primary', + item.id === historyWorkflowData?.id && 'text-text-accent', + )} + > + {`Test ${isChatMode ? 'Chat' : 'Run'}${formatWorkflowRunIdentifier(item.finished_at, item.status)}`} + </div> + <div className="flex items-center text-xs leading-[18px] text-text-tertiary"> + {item.created_by_account?.name} · + {formatTimeFromNow((item.finished_at || item.created_at) * 1000)} + </div> + </div> </div> - ) - } - </div> - </PopoverContent> - </Popover> - ) + ))} + </div> + )} + </div> + </PopoverContent> + </Popover> ) } diff --git a/web/app/components/workflow/header/view-workflow-history.tsx b/web/app/components/workflow/header/view-workflow-history.tsx index f3073c967fa612..d6848fa28f2a96 100644 --- a/web/app/components/workflow/header/view-workflow-history.tsx +++ b/web/app/components/workflow/header/view-workflow-history.tsx @@ -1,30 +1,14 @@ import type { WorkflowHistoryState } from '../store/workflow/history-slice' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverClose, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - RiCloseLine, - RiHistoryLine, -} from '@remixicon/react' -import { - memo, - useCallback, - useMemo, - useState, -} from 'react' +import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { RiCloseLine, RiHistoryLine } from '@remixicon/react' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import Divider from '../../base/divider' import { collaborationManager } from '../collaboration/core/collaboration-manager' -import { - useNodesReadOnly, - useWorkflowHistory, -} from '../hooks' +import { useNodesReadOnly, useWorkflowHistory } from '../hooks' import { useCollaborativeWorkflow } from '../hooks/use-collaborative-workflow' import TipPopup from '../operator/tip-popup' @@ -45,11 +29,13 @@ const ViewWorkflowHistory = () => { const [open, setOpen] = useState(false) const { nodesReadOnly } = useNodesReadOnly() - const { setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({ - appDetail: state.appDetail, - setCurrentLogItem: state.setCurrentLogItem, - setShowMessageLogModal: state.setShowMessageLogModal, - }))) + const { setCurrentLogItem, setShowMessageLogModal } = useAppStore( + useShallow((state) => ({ + appDetail: state.appDetail, + setCurrentLogItem: state.setCurrentLogItem, + setShowMessageLogModal: state.setShowMessageLogModal, + })), + ) const collaborativeWorkflow = useCollaborativeWorkflow() const { store, getHistoryLabel } = useWorkflowHistory() @@ -61,67 +47,76 @@ const ViewWorkflowHistory = () => { setCurrentHistoryStateIndex(0) }, [clear]) - const handleSetState = useCallback(({ index }: ChangeHistoryEntry) => { - const diff = currentHistoryStateIndex + index - if (diff === 0) - return + const handleSetState = useCallback( + ({ index }: ChangeHistoryEntry) => { + const diff = currentHistoryStateIndex + index + if (diff === 0) return - if (diff < 0) - undo(diff * -1) - else - redo(diff) + if (diff < 0) undo(diff * -1) + else redo(diff) - const { edges, nodes } = store.getState() - if (edges.length === 0 && nodes.length === 0) - return + const { edges, nodes } = store.getState() + if (edges.length === 0 && nodes.length === 0) return - const shouldBroadcast = collaborationManager.isConnected() - const { setEdges, setNodes } = collaborativeWorkflow.getState() - setEdges(edges, shouldBroadcast) - setNodes(nodes, shouldBroadcast, 'history:jump') - if (collaborationManager.isConnected()) - collaborationManager.emitHistoryAction('jump') - }, [collaborativeWorkflow, currentHistoryStateIndex, redo, store, undo]) + const shouldBroadcast = collaborationManager.isConnected() + const { setEdges, setNodes } = collaborativeWorkflow.getState() + setEdges(edges, shouldBroadcast) + setNodes(nodes, shouldBroadcast, 'history:jump') + if (collaborationManager.isConnected()) collaborationManager.emitHistoryAction('jump') + }, + [collaborativeWorkflow, currentHistoryStateIndex, redo, store, undo], + ) - const calculateStepLabel = useCallback((index: number) => { - if (!index) - return + const calculateStepLabel = useCallback( + (index: number) => { + if (!index) return - const count = index < 0 ? index * -1 : index - return `${index > 0 ? t($ => $['changeHistory.stepForward'], { ns: 'workflow', count }) : t($ => $['changeHistory.stepBackward'], { ns: 'workflow', count })}` - }, [t]) + const count = index < 0 ? index * -1 : index + return `${index > 0 ? t(($) => $['changeHistory.stepForward'], { ns: 'workflow', count }) : t(($) => $['changeHistory.stepBackward'], { ns: 'workflow', count })}` + }, + [t], + ) const calculateChangeList: ChangeHistoryList = useMemo(() => { const filterList = ( list: Array<Partial<WorkflowHistoryState> | undefined>, startIndex = 0, reverse = false, - ) => list.flatMap((state, index) => { - if (!state) - return [] + ) => + list.flatMap((state, index) => { + if (!state) return [] - const nodes = state.nodes || store.getState().nodes || [] - const nodeId = state.workflowHistoryEventMeta?.nodeId - const targetTitle = nodes.find(n => n.id === nodeId)?.data?.title ?? '' + const nodes = state.nodes || store.getState().nodes || [] + const nodeId = state.workflowHistoryEventMeta?.nodeId + const targetTitle = nodes.find((n) => n.id === nodeId)?.data?.title ?? '' - return [{ - label: state.workflowHistoryEvent ? getHistoryLabel(state.workflowHistoryEvent) : '', - index: reverse ? list.length - 1 - index - startIndex : index - startIndex, - state: { - ...state, - workflowHistoryEventMeta: state.workflowHistoryEventMeta - ? { - ...state.workflowHistoryEventMeta, - nodeTitle: state.workflowHistoryEventMeta.nodeTitle || targetTitle, - } - : undefined, - }, - }] - }) + return [ + { + label: state.workflowHistoryEvent ? getHistoryLabel(state.workflowHistoryEvent) : '', + index: reverse ? list.length - 1 - index - startIndex : index - startIndex, + state: { + ...state, + workflowHistoryEventMeta: state.workflowHistoryEventMeta + ? { + ...state.workflowHistoryEventMeta, + nodeTitle: state.workflowHistoryEventMeta.nodeTitle || targetTitle, + } + : undefined, + }, + }, + ] + }) const historyData = { pastStates: filterList(pastStates, pastStates.length).reverse(), - futureStates: filterList([...futureStates, (!pastStates.length && !futureStates.length) ? undefined : store.getState()].filter(Boolean), 0, true), + futureStates: filterList( + [ + ...futureStates, + !pastStates.length && !futureStates.length ? undefined : store.getState(), + ].filter(Boolean), + 0, + true, + ), statesCount: 0, } @@ -133,193 +128,181 @@ const ViewWorkflowHistory = () => { } }, [futureStates, getHistoryLabel, pastStates, store]) - const composeHistoryItemLabel = useCallback((nodeTitle: string | undefined, baseLabel: string) => { - if (!nodeTitle) - return baseLabel - return `${nodeTitle} ${baseLabel}` - }, []) + const composeHistoryItemLabel = useCallback( + (nodeTitle: string | undefined, baseLabel: string) => { + if (!nodeTitle) return baseLabel + return `${nodeTitle} ${baseLabel}` + }, + [], + ) return ( - ( - <Popover - modal="trap-focus" - open={open} - onOpenChange={(nextOpen) => { - if (nodesReadOnly) - return - setOpen(nextOpen) - }} + <Popover + modal="trap-focus" + open={open} + onOpenChange={(nextOpen) => { + if (nodesReadOnly) return + setOpen(nextOpen) + }} + > + <PopoverTrigger + render={ + <button + type="button" + aria-label={t(($) => $['changeHistory.title'], { ns: 'workflow' })} + disabled={nodesReadOnly} + className={cn( + 'box-border inline-flex size-8 max-h-8 min-h-8 max-w-8 min-w-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary data-popup-open:bg-state-accent-active data-popup-open:text-text-accent', + nodesReadOnly && + 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled', + )} + onClick={() => { + if (nodesReadOnly) return + setCurrentLogItem() + setShowMessageLogModal(false) + }} + > + <TipPopup title={t(($) => $['changeHistory.title'], { ns: 'workflow' })}> + <span className="flex size-full shrink-0 items-center justify-center"> + <span className="i-ri-history-line size-4 shrink-0" /> + </span> + </TipPopup> + </button> + } + /> + <PopoverContent + placement="bottom-end" + popupClassName="border-none bg-transparent shadow-none" > - <PopoverTrigger - render={( - <button - type="button" - aria-label={t($ => $['changeHistory.title'], { ns: 'workflow' })} - disabled={nodesReadOnly} - className={ - cn('box-border inline-flex size-8 max-h-8 min-h-8 max-w-8 min-w-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary data-popup-open:bg-state-accent-active data-popup-open:text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled') + <div className="flex max-w-[360px] min-w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]"> + <div className="sticky top-0 flex items-center justify-between px-4 pt-3"> + <div className="system-mg-regular grow text-text-secondary"> + {t(($) => $['changeHistory.title'], { ns: 'workflow' })} + </div> + <PopoverClose + render={ + <button + type="button" + aria-label={t(($) => $['operation.close'], { ns: 'common' })} + className="flex size-6 shrink-0 cursor-pointer items-center justify-center" + > + <RiCloseLine className="size-4 text-text-secondary" /> + </button> } onClick={() => { - if (nodesReadOnly) - return setCurrentLogItem() setShowMessageLogModal(false) }} - > - <TipPopup - title={t($ => $['changeHistory.title'], { ns: 'workflow' })} - > - <span className="flex size-full shrink-0 items-center justify-center"> - <span className="i-ri-history-line size-4 shrink-0" /> - </span> - </TipPopup> - </button> - )} - /> - <PopoverContent - placement="bottom-end" - popupClassName="border-none bg-transparent shadow-none" - > + /> + </div> <div - className="flex max-w-[360px] min-w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]" + className="overflow-y-auto p-2" + style={{ + maxHeight: 'calc(1 / 2 * 100vh)', + }} > - <div className="sticky top-0 flex items-center justify-between px-4 pt-3"> - <div className="system-mg-regular grow text-text-secondary">{t($ => $['changeHistory.title'], { ns: 'workflow' })}</div> - <PopoverClose - render={( - <button - type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} - className="flex size-6 shrink-0 cursor-pointer items-center justify-center" - > - <RiCloseLine className="size-4 text-text-secondary" /> - </button> - )} - onClick={() => { - setCurrentLogItem() - setShowMessageLogModal(false) - }} - /> - </div> - <div - className="overflow-y-auto p-2" - style={{ - maxHeight: 'calc(1 / 2 * 100vh)', - }} - > - { - !calculateChangeList.statesCount && ( - <div className="py-12"> - <RiHistoryLine className="mx-auto mb-2 size-8 text-text-tertiary" /> - <div className="text-center text-[13px] text-text-tertiary"> - {t($ => $['changeHistory.placeholder'], { ns: 'workflow' })} - </div> - </div> - ) - } - <div className="flex flex-col"> - { - calculateChangeList.futureStates.map((item: ChangeHistoryEntry) => ( + {!calculateChangeList.statesCount && ( + <div className="py-12"> + <RiHistoryLine className="mx-auto mb-2 size-8 text-text-tertiary" /> + <div className="text-center text-[13px] text-text-tertiary"> + {t(($) => $['changeHistory.placeholder'], { ns: 'workflow' })} + </div> + </div> + )} + <div className="flex flex-col"> + {calculateChangeList.futureStates.map((item: ChangeHistoryEntry) => ( + <div + key={item?.index} + className={cn( + 'mb-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] text-text-secondary hover:bg-state-base-hover', + item?.index === currentHistoryStateIndex && 'bg-state-base-hover', + )} + onClick={() => { + handleSetState(item) + setOpen(false) + }} + > + <div> <div - key={item?.index} className={cn( - 'mb-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] text-text-secondary hover:bg-state-base-hover', - item?.index === currentHistoryStateIndex && 'bg-state-base-hover', + 'flex items-center text-[13px] leading-[18px] font-medium text-text-secondary', )} - onClick={() => { - handleSetState(item) - setOpen(false) - }} > - <div> - <div - className={cn( - 'flex items-center text-[13px] leading-[18px] font-medium text-text-secondary', - )} - > - {composeHistoryItemLabel( - item?.state?.workflowHistoryEventMeta?.nodeTitle, - item?.label || t($ => $['changeHistory.sessionStart'], { ns: 'workflow' }), - )} - {' '} - ( - {calculateStepLabel(item?.index)} - {item?.index === currentHistoryStateIndex && t($ => $['changeHistory.currentState'], { ns: 'workflow' })} - ) - </div> - </div> + {composeHistoryItemLabel( + item?.state?.workflowHistoryEventMeta?.nodeTitle, + item?.label || + t(($) => $['changeHistory.sessionStart'], { ns: 'workflow' }), + )}{' '} + ({calculateStepLabel(item?.index)} + {item?.index === currentHistoryStateIndex && + t(($) => $['changeHistory.currentState'], { ns: 'workflow' })} + ) </div> - )) - } - { - calculateChangeList.pastStates.map((item: ChangeHistoryEntry) => ( + </div> + </div> + ))} + {calculateChangeList.pastStates.map((item: ChangeHistoryEntry) => ( + <div + key={item?.index} + className={cn( + 'mb-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] hover:bg-state-base-hover', + item?.index === calculateChangeList.statesCount - 1 && 'bg-state-base-hover', + )} + onClick={() => { + handleSetState(item) + setOpen(false) + }} + > + <div> <div - key={item?.index} className={cn( - 'mb-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] hover:bg-state-base-hover', - item?.index === calculateChangeList.statesCount - 1 && 'bg-state-base-hover', + 'flex items-center text-[13px] leading-[18px] font-medium text-text-secondary', )} - onClick={() => { - handleSetState(item) - setOpen(false) - }} > - <div> - <div - className={cn( - 'flex items-center text-[13px] leading-[18px] font-medium text-text-secondary', - )} - > - {composeHistoryItemLabel( - item?.state?.workflowHistoryEventMeta?.nodeTitle, - item?.label || t($ => $['changeHistory.sessionStart'], { ns: 'workflow' }), - )} - {' '} - ( - {calculateStepLabel(item?.index)} - ) - </div> - </div> + {composeHistoryItemLabel( + item?.state?.workflowHistoryEventMeta?.nodeTitle, + item?.label || + t(($) => $['changeHistory.sessionStart'], { ns: 'workflow' }), + )}{' '} + ({calculateStepLabel(item?.index)}) </div> - )) - } - </div> + </div> + </div> + ))} </div> - { - !!calculateChangeList.statesCount && ( - <div className="px-0.5"> - <Divider className="m-0" /> - <div - className={cn( - 'my-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] text-text-secondary', - 'hover:bg-state-base-hover', - )} - onClick={() => { - handleClearHistory() - setOpen(false) - }} - > - <div> - <div - className={cn( - 'flex items-center text-[13px] leading-[18px] font-medium', - )} - > - {t($ => $['changeHistory.clearHistory'], { ns: 'workflow' })} - </div> - </div> + </div> + {!!calculateChangeList.statesCount && ( + <div className="px-0.5"> + <Divider className="m-0" /> + <div + className={cn( + 'my-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] text-text-secondary', + 'hover:bg-state-base-hover', + )} + onClick={() => { + handleClearHistory() + setOpen(false) + }} + > + <div> + <div className={cn('flex items-center text-[13px] leading-[18px] font-medium')}> + {t(($) => $['changeHistory.clearHistory'], { ns: 'workflow' })} </div> </div> - ) - } - <div className="w-[240px] px-3 py-2 text-xs text-text-tertiary"> - <div className="mb-1 flex h-[22px] items-center font-medium uppercase">{t($ => $['changeHistory.hint'], { ns: 'workflow' })}</div> - <div className="mb-1 leading-[18px] text-text-tertiary">{t($ => $['changeHistory.hintText'], { ns: 'workflow' })}</div> + </div> + </div> + )} + <div className="w-[240px] px-3 py-2 text-xs text-text-tertiary"> + <div className="mb-1 flex h-[22px] items-center font-medium uppercase"> + {t(($) => $['changeHistory.hint'], { ns: 'workflow' })} + </div> + <div className="mb-1 leading-[18px] text-text-tertiary"> + {t(($) => $['changeHistory.hintText'], { ns: 'workflow' })} </div> </div> - </PopoverContent> - </Popover> - ) + </div> + </PopoverContent> + </Popover> ) } diff --git a/web/app/components/workflow/help-line/__tests__/index.spec.tsx b/web/app/components/workflow/help-line/__tests__/index.spec.tsx index f58c9c5d021b05..4a2394da584f27 100644 --- a/web/app/components/workflow/help-line/__tests__/index.spec.tsx +++ b/web/app/components/workflow/help-line/__tests__/index.spec.tsx @@ -9,15 +9,17 @@ vi.mock('reactflow', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { - helpLineHorizontal?: { top: number, left: number, width: number } - helpLineVertical?: { top: number, left: number, height: number } - }) => unknown) => mockUseStore(selector), + useStore: ( + selector: (state: { + helpLineHorizontal?: { top: number; left: number; width: number } + helpLineVertical?: { top: number; left: number; height: number } + }) => unknown, + ) => mockUseStore(selector), })) describe('HelpLine', () => { - let helpLineHorizontal: { top: number, left: number, width: number } | undefined - let helpLineVertical: { top: number, left: number, height: number } | undefined + let helpLineHorizontal: { top: number; left: number; width: number } | undefined + let helpLineVertical: { top: number; left: number; height: number } | undefined beforeEach(() => { vi.clearAllMocks() @@ -25,13 +27,18 @@ describe('HelpLine', () => { helpLineVertical = undefined mockUseViewport.mockReturnValue({ x: 10, y: 20, zoom: 2 }) - mockUseStore.mockImplementation((selector: (state: { - helpLineHorizontal?: { top: number, left: number, width: number } - helpLineVertical?: { top: number, left: number, height: number } - }) => unknown) => selector({ - helpLineHorizontal, - helpLineVertical, - })) + mockUseStore.mockImplementation( + ( + selector: (state: { + helpLineHorizontal?: { top: number; left: number; width: number } + helpLineVertical?: { top: number; left: number; height: number } + }) => unknown, + ) => + selector({ + helpLineHorizontal, + helpLineVertical, + }), + ) }) it('should render nothing when both help lines are absent', () => { diff --git a/web/app/components/workflow/help-line/index.tsx b/web/app/components/workflow/help-line/index.tsx index 68467d74c70d96..4b8dec260d1044 100644 --- a/web/app/components/workflow/help-line/index.tsx +++ b/web/app/components/workflow/help-line/index.tsx @@ -1,16 +1,9 @@ -import type { - HelpLineHorizontalPosition, - HelpLineVerticalPosition, -} from './types' +import type { HelpLineHorizontalPosition, HelpLineVerticalPosition } from './types' import { memo } from 'react' import { useViewport } from 'reactflow' import { useStore } from '../store' -const HelpLineHorizontal = memo(({ - top, - left, - width, -}: HelpLineHorizontalPosition) => { +const HelpLineHorizontal = memo(({ top, left, width }: HelpLineHorizontalPosition) => { const { x, y, zoom } = useViewport() return ( @@ -26,11 +19,7 @@ const HelpLineHorizontal = memo(({ }) HelpLineHorizontal.displayName = 'HelpLineBase' -const HelpLineVertical = memo(({ - top, - left, - height, -}: HelpLineVerticalPosition) => { +const HelpLineVertical = memo(({ top, left, height }: HelpLineVerticalPosition) => { const { x, y, zoom } = useViewport() return ( @@ -47,24 +36,15 @@ const HelpLineVertical = memo(({ HelpLineVertical.displayName = 'HelpLineVertical' const HelpLine = () => { - const helpLineHorizontal = useStore(s => s.helpLineHorizontal) - const helpLineVertical = useStore(s => s.helpLineVertical) + const helpLineHorizontal = useStore((s) => s.helpLineHorizontal) + const helpLineVertical = useStore((s) => s.helpLineVertical) - if (!helpLineHorizontal && !helpLineVertical) - return null + if (!helpLineHorizontal && !helpLineVertical) return null return ( <> - { - helpLineHorizontal && ( - <HelpLineHorizontal {...helpLineHorizontal} /> - ) - } - { - helpLineVertical && ( - <HelpLineVertical {...helpLineVertical} /> - ) - } + {helpLineHorizontal && <HelpLineHorizontal {...helpLineHorizontal} />} + {helpLineVertical && <HelpLineVertical {...helpLineVertical} />} </> ) } diff --git a/web/app/components/workflow/hooks-store/__tests__/provider.spec.tsx b/web/app/components/workflow/hooks-store/__tests__/provider.spec.tsx index eb43c9ca416cb2..b49cceeccb0be9 100644 --- a/web/app/components/workflow/hooks-store/__tests__/provider.spec.tsx +++ b/web/app/components/workflow/hooks-store/__tests__/provider.spec.tsx @@ -15,7 +15,8 @@ let mockReactflowState = { } vi.mock('reactflow', () => ({ - useStore: (selector: (state: typeof mockReactflowState) => unknown) => selector(mockReactflowState), + useStore: (selector: (state: typeof mockReactflowState) => unknown) => + selector(mockReactflowState), })) vi.mock('../store', async () => { diff --git a/web/app/components/workflow/hooks-store/__tests__/store.spec.tsx b/web/app/components/workflow/hooks-store/__tests__/store.spec.tsx index 131290b8343de9..763051c3e05315 100644 --- a/web/app/components/workflow/hooks-store/__tests__/store.spec.tsx +++ b/web/app/components/workflow/hooks-store/__tests__/store.spec.tsx @@ -23,18 +23,16 @@ describe('hooks-store store', () => { const handleRun = vi.fn() const store = createHooksStore({ handleRun }) const wrapper = ({ children }: { children: React.ReactNode }) => ( - <HooksStoreContext.Provider value={store}> - {children} - </HooksStoreContext.Provider> + <HooksStoreContext.Provider value={store}>{children}</HooksStoreContext.Provider> ) - const { result } = renderHook(() => useHooksStore(state => state.handleRun), { wrapper }) + const { result } = renderHook(() => useHooksStore((state) => state.handleRun), { wrapper }) expect(result.current).toBe(handleRun) }) it('throws when the hooks store provider is missing', () => { - expect(() => renderHook(() => useHooksStore(state => state.handleRun))).toThrow( + expect(() => renderHook(() => useHooksStore((state) => state.handleRun))).toThrow( 'Missing HooksStoreContext.Provider in the tree', ) }) diff --git a/web/app/components/workflow/hooks-store/provider.tsx b/web/app/components/workflow/hooks-store/provider.tsx index 48288ebd56acf0..1342ce8ea24546 100644 --- a/web/app/components/workflow/hooks-store/provider.tsx +++ b/web/app/components/workflow/hooks-store/provider.tsx @@ -1,41 +1,33 @@ import type { Shape } from './store' -import { - createContext, - useEffect, - useRef, -} from 'react' +import { createContext, useEffect, useRef } from 'react' import { useStore } from 'reactflow' -import { - createHooksStore, -} from './store' +import { createHooksStore } from './store' type HooksStore = ReturnType<typeof createHooksStore> export const HooksStoreContext = createContext<HooksStore | null | undefined>(null) type HooksStoreContextProviderProps = Partial<Shape> & { children: React.ReactNode } -export const HooksStoreContextProvider = ({ children, ...restProps }: HooksStoreContextProviderProps) => { +export const HooksStoreContextProvider = ({ + children, + ...restProps +}: HooksStoreContextProviderProps) => { const storeRef = useRef<HooksStore | undefined>(undefined) - const d3Selection = useStore(s => s.d3Selection) - const d3Zoom = useStore(s => s.d3Zoom) + const d3Selection = useStore((s) => s.d3Selection) + const d3Zoom = useStore((s) => s.d3Zoom) const { accessControl } = restProps useEffect(() => { - if (storeRef.current && d3Selection && d3Zoom) - storeRef.current.getState().refreshAll(restProps) + if (storeRef.current && d3Selection && d3Zoom) storeRef.current.getState().refreshAll(restProps) }, [d3Selection, d3Zoom]) useEffect(() => { - if (storeRef.current && accessControl) - storeRef.current.getState().refreshAll({ accessControl }) + if (storeRef.current && accessControl) storeRef.current.getState().refreshAll({ accessControl }) }, [accessControl]) - if (!storeRef.current) - storeRef.current = createHooksStore(restProps) + if (!storeRef.current) storeRef.current = createHooksStore(restProps) return ( - <HooksStoreContext.Provider value={storeRef.current}> - {children} - </HooksStoreContext.Provider> + <HooksStoreContext.Provider value={storeRef.current}>{children}</HooksStoreContext.Provider> ) } diff --git a/web/app/components/workflow/hooks-store/store.ts b/web/app/components/workflow/hooks-store/store.ts index d487ed27f0f0dc..705a1c2054dd2c 100644 --- a/web/app/components/workflow/hooks-store/store.ts +++ b/web/app/components/workflow/hooks-store/store.ts @@ -12,9 +12,7 @@ import type { FlowType } from '@/types/common' import type { VarInInspect } from '@/types/workflow' import { noop } from 'es-toolkit/function' import { use } from 'react' -import { - useStore as useZustandStore, -} from 'zustand' +import { useStore as useZustandStore } from 'zustand' import { createStore } from 'zustand/vanilla' import { HooksStoreContext } from './provider' @@ -64,13 +62,26 @@ type CommonHooksFnMap = { handleWorkflowTriggerPluginRunInWorkflow: (nodeId?: string) => void handleWorkflowRunAllTriggersInWorkflow: (nodeIds: string[]) => void availableNodesMetaData?: AvailableNodesMetaData - getWorkflowRunAndTraceUrl: (runId?: string) => { runUrl: string, traceUrl: string } + getWorkflowRunAndTraceUrl: (runId?: string) => { runUrl: string; traceUrl: string } exportCheck?: () => Promise<void> handleExportDSL?: (include?: boolean, flowId?: string) => Promise<void> - fetchInspectVars: (params: { passInVars?: boolean, vars?: VarInInspect[], passedInAllPluginInfoList?: Record<string, ToolWithProvider[]>, passedInSchemaTypeDefinitions?: SchemaTypeDefinition[] }) => Promise<void> + fetchInspectVars: (params: { + passInVars?: boolean + vars?: VarInInspect[] + passedInAllPluginInfoList?: Record<string, ToolWithProvider[]> + passedInSchemaTypeDefinitions?: SchemaTypeDefinition[] + }) => Promise<void> hasNodeInspectVars: (nodeId: string) => boolean - hasSetInspectVar: (nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => boolean - fetchInspectVarValue: (selector: ValueSelector, schemaTypeDefinitions: SchemaTypeDefinition[]) => Promise<void> + hasSetInspectVar: ( + nodeId: string, + name: string, + sysVars: VarInInspect[], + conversationVars: VarInInspect[], + ) => boolean + fetchInspectVarValue: ( + selector: ValueSelector, + schemaTypeDefinitions: SchemaTypeDefinition[], + ) => Promise<void> editInspectVarValue: (nodeId: string, varId: string, value: any) => Promise<void> renameInspectVarName: (nodeId: string, oldName: string, newName: string) => Promise<void> appendNodeInspectVars: (nodeId: string, payload: VarInInspect[], allNodes: Node[]) => void @@ -137,8 +148,8 @@ export const createHooksStore = ({ configsMap, accessControl = fullWorkflowAccessControl, }: Partial<Shape>) => { - return createStore<Shape>(set => ({ - refreshAll: props => set(state => ({ ...state, ...props })), + return createStore<Shape>((set) => ({ + refreshAll: (props) => set((state) => ({ ...state, ...props })), doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose, handleRefreshWorkflowDraft, @@ -180,8 +191,7 @@ export const createHooksStore = ({ export function useHooksStore<T>(selector: (state: Shape) => T): T { const store = use(HooksStoreContext) - if (!store) - throw new Error('Missing HooksStoreContext.Provider in the tree') + if (!store) throw new Error('Missing HooksStoreContext.Provider in the tree') return useZustandStore(store, selector) } diff --git a/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts b/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts index 9f5f2da6a7490e..1e536ae0e375c3 100644 --- a/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts @@ -14,7 +14,8 @@ type WebhookFlowNode = Node & { } vi.mock('@/app/components/app/store', async () => - (await import('../../__tests__/service-mock-factory')).createAppStoreMock({ appId: 'app-123' })) + (await import('../../__tests__/service-mock-factory')).createAppStoreMock({ appId: 'app-123' }), +) const mockFetchWebhookUrl = vi.fn() vi.mock('@/service/apps', () => ({ @@ -35,13 +36,16 @@ describe('useAutoGenerateWebhookUrl', () => { ] const renderAutoGenerateWebhookUrlHook = () => - renderWorkflowFlowHook(() => ({ - autoGenerateWebhookUrl: useAutoGenerateWebhookUrl(), - nodes: useNodes<WebhookFlowNode>(), - }), { - nodes: createFlowNodes(), - edges: [], - }) + renderWorkflowFlowHook( + () => ({ + autoGenerateWebhookUrl: useAutoGenerateWebhookUrl(), + nodes: useNodes<WebhookFlowNode>(), + }), + { + nodes: createFlowNodes(), + edges: [], + }, + ) beforeEach(() => { vi.clearAllMocks() @@ -62,7 +66,9 @@ describe('useAutoGenerateWebhookUrl', () => { expect(mockFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-123', nodeId: 'webhook-1' }) await waitFor(() => { - const webhookNode = result.current.nodes.find(node => node.id === 'webhook-1') as WebhookFlowNode | undefined + const webhookNode = result.current.nodes.find((node) => node.id === 'webhook-1') as + | WebhookFlowNode + | undefined expect(webhookNode?.data.webhook_url).toBe('https://example.com/webhook') expect(webhookNode?.data.webhook_debug_url).toBe('https://example.com/webhook-debug') }) @@ -77,7 +83,9 @@ describe('useAutoGenerateWebhookUrl', () => { expect(mockFetchWebhookUrl).not.toHaveBeenCalled() - const codeNode = result.current.nodes.find(node => node.id === 'code-1') as WebhookFlowNode | undefined + const codeNode = result.current.nodes.find((node) => node.id === 'code-1') as + | WebhookFlowNode + | undefined expect(codeNode?.data.webhook_url).toBeUndefined() }) @@ -92,20 +100,23 @@ describe('useAutoGenerateWebhookUrl', () => { }) it('should not fetch when webhook_url already exists', async () => { - const { result } = renderWorkflowFlowHook(() => ({ - autoGenerateWebhookUrl: useAutoGenerateWebhookUrl(), - }), { - nodes: [ - createNode({ - id: 'webhook-1', - data: { - type: BlockEnum.TriggerWebhook, - webhook_url: 'https://existing.com/webhook', - }, - }) as WebhookFlowNode, - ], - edges: [], - }) + const { result } = renderWorkflowFlowHook( + () => ({ + autoGenerateWebhookUrl: useAutoGenerateWebhookUrl(), + }), + { + nodes: [ + createNode({ + id: 'webhook-1', + data: { + type: BlockEnum.TriggerWebhook, + webhook_url: 'https://existing.com/webhook', + }, + }) as WebhookFlowNode, + ], + edges: [], + }, + ) await act(async () => { await result.current.autoGenerateWebhookUrl('webhook-1') @@ -128,7 +139,9 @@ describe('useAutoGenerateWebhookUrl', () => { 'Failed to auto-generate webhook URL:', expect.any(Error), ) - const webhookNode = result.current.nodes.find(node => node.id === 'webhook-1') as WebhookFlowNode | undefined + const webhookNode = result.current.nodes.find((node) => node.id === 'webhook-1') as + | WebhookFlowNode + | undefined expect(webhookNode?.data.webhook_url).toBe('') consoleSpy.mockRestore() }) diff --git a/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts b/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts index 955c514c81bb80..081e10d9bf4856 100644 --- a/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts @@ -6,7 +6,8 @@ import { useAvailableBlocks } from '../use-available-blocks' // Transitive imports of use-nodes-meta-data.ts — only useNodeMetaData uses these vi.mock('@/service/use-tools', async () => - (await import('../../__tests__/service-mock-factory')).createToolServiceMock()) + (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), +) vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en' })) const mockNodeTypes = [ @@ -48,34 +49,50 @@ const hooksStoreProps = { describe('useAvailableBlocks', () => { describe('availablePrevBlocks', () => { it('should return empty array when nodeType is undefined', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(undefined), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(undefined), { + hooksStoreProps, + }) expect(result.current.availablePrevBlocks).toEqual([]) }) it('should return empty array for Start node', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.Start), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.Start), { + hooksStoreProps, + }) expect(result.current.availablePrevBlocks).toEqual([]) }) it('should return empty array for StartPlaceholder node', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.StartPlaceholder), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.StartPlaceholder), { + hooksStoreProps, + }) expect(result.current.availablePrevBlocks).toEqual([]) }) it('should return empty array for trigger nodes', () => { - for (const trigger of [BlockEnum.TriggerPlugin, BlockEnum.TriggerWebhook, BlockEnum.TriggerSchedule]) { - const { result } = renderWorkflowHook(() => useAvailableBlocks(trigger), { hooksStoreProps }) + for (const trigger of [ + BlockEnum.TriggerPlugin, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerSchedule, + ]) { + const { result } = renderWorkflowHook(() => useAvailableBlocks(trigger), { + hooksStoreProps, + }) expect(result.current.availablePrevBlocks).toEqual([]) } }) it('should return empty array for DataSource node', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.DataSource), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.DataSource), { + hooksStoreProps, + }) expect(result.current.availablePrevBlocks).toEqual([]) }) it('should return all available nodes for regular block types', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { + hooksStoreProps, + }) expect(result.current.availablePrevBlocks.length).toBeGreaterThan(0) expect(result.current.availablePrevBlocks).toContain(BlockEnum.Code) }) @@ -83,33 +100,45 @@ describe('useAvailableBlocks', () => { describe('availableNextBlocks', () => { it('should return empty array when nodeType is undefined', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(undefined), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(undefined), { + hooksStoreProps, + }) expect(result.current.availableNextBlocks).toEqual([]) }) it('should return available nodes for End node', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.End), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.End), { + hooksStoreProps, + }) expect(result.current.availableNextBlocks.length).toBeGreaterThan(0) expect(result.current.availableNextBlocks).toContain(BlockEnum.Code) }) it('should return empty array for LoopEnd node', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LoopEnd), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LoopEnd), { + hooksStoreProps, + }) expect(result.current.availableNextBlocks).toEqual([]) }) it('should return empty array for KnowledgeBase node', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.KnowledgeBase), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.KnowledgeBase), { + hooksStoreProps, + }) expect(result.current.availableNextBlocks).toEqual([]) }) it('should return empty array for StartPlaceholder node', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.StartPlaceholder), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.StartPlaceholder), { + hooksStoreProps, + }) expect(result.current.availableNextBlocks).toEqual([]) }) it('should return all available nodes for regular block types', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { + hooksStoreProps, + }) expect(result.current.availableNextBlocks.length).toBeGreaterThan(0) expect(result.current.availableNextBlocks).not.toContain(BlockEnum.StartPlaceholder) }) @@ -117,7 +146,9 @@ describe('useAvailableBlocks', () => { describe('inContainer filtering', () => { it('should exclude Iteration, Loop, End, DataSource, KnowledgeBase, HumanInput when inContainer=true', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, true), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, true), { + hooksStoreProps, + }) expect(result.current.availableNextBlocks).not.toContain(BlockEnum.Iteration) expect(result.current.availableNextBlocks).not.toContain(BlockEnum.Loop) @@ -128,14 +159,18 @@ describe('useAvailableBlocks', () => { }) it('should exclude LoopEnd when not in container', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, false), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, false), { + hooksStoreProps, + }) expect(result.current.availableNextBlocks).not.toContain(BlockEnum.LoopEnd) }) }) describe('getAvailableBlocks callback', () => { it('should return prev and next blocks for a given node type', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { + hooksStoreProps, + }) const blocks = result.current.getAvailableBlocks(BlockEnum.Code) expect(blocks.availablePrevBlocks.length).toBeGreaterThan(0) @@ -143,21 +178,27 @@ describe('useAvailableBlocks', () => { }) it('should return empty prevBlocks for Start node', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { + hooksStoreProps, + }) const blocks = result.current.getAvailableBlocks(BlockEnum.Start) expect(blocks.availablePrevBlocks).toEqual([]) }) it('should return empty prevBlocks for DataSource node', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { + hooksStoreProps, + }) const blocks = result.current.getAvailableBlocks(BlockEnum.DataSource) expect(blocks.availablePrevBlocks).toEqual([]) }) it('should return no blocks for StartPlaceholder node', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { + hooksStoreProps, + }) const blocks = result.current.getAvailableBlocks(BlockEnum.StartPlaceholder) expect(blocks.availablePrevBlocks).toEqual([]) @@ -165,16 +206,26 @@ describe('useAvailableBlocks', () => { }) it('should return empty nextBlocks for LoopEnd/KnowledgeBase and available nodes for End', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) - - expect(result.current.getAvailableBlocks(BlockEnum.End).availableNextBlocks.length).toBeGreaterThan(0) - expect(result.current.getAvailableBlocks(BlockEnum.End).availableNextBlocks).toContain(BlockEnum.Code) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { + hooksStoreProps, + }) + + expect( + result.current.getAvailableBlocks(BlockEnum.End).availableNextBlocks.length, + ).toBeGreaterThan(0) + expect(result.current.getAvailableBlocks(BlockEnum.End).availableNextBlocks).toContain( + BlockEnum.Code, + ) expect(result.current.getAvailableBlocks(BlockEnum.LoopEnd).availableNextBlocks).toEqual([]) - expect(result.current.getAvailableBlocks(BlockEnum.KnowledgeBase).availableNextBlocks).toEqual([]) + expect( + result.current.getAvailableBlocks(BlockEnum.KnowledgeBase).availableNextBlocks, + ).toEqual([]) }) it('should filter by inContainer when provided', () => { - const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { + hooksStoreProps, + }) const blocks = result.current.getAvailableBlocks(BlockEnum.Code, true) expect(blocks.availableNextBlocks).not.toContain(BlockEnum.Iteration) diff --git a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts index ac7e11e113d19b..366400d4c4997d 100644 --- a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts @@ -19,20 +19,22 @@ vi.mock('reactflow', async () => { const base = (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock() return { ...base, - getOutgoers: vi.fn((node: Node, nodes: Node[], edges: { source: string, target: string }[]) => { + getOutgoers: vi.fn((node: Node, nodes: Node[], edges: { source: string; target: string }[]) => { return edges - .filter(e => e.source === node.id) - .map(e => nodes.find(n => n.id === e.target)) + .filter((e) => e.source === node.id) + .map((e) => nodes.find((n) => n.id === e.target)) .filter(Boolean) }), } }) vi.mock('@/service/use-tools', async () => - (await import('../../__tests__/service-mock-factory')).createToolServiceMock()) + (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), +) vi.mock('@/service/use-triggers', async () => - (await import('../../__tests__/service-mock-factory')).createTriggerServiceMock()) + (await import('../../__tests__/service-mock-factory')).createTriggerServiceMock(), +) vi.mock('@/service/use-strategy', () => ({ useStrategyProviders: () => ({ data: [] }), @@ -43,10 +45,16 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) type CheckValidFn = (data: CommonNodeType, t: unknown, extra?: unknown) => { errorMessage: string } -const mockNodesMap: Record<string, { checkValid: CheckValidFn, metaData: { isStart: boolean, isRequired: boolean } }> = {} +const mockNodesMap: Record< + string, + { checkValid: CheckValidFn; metaData: { isStart: boolean; isRequired: boolean } } +> = {} let mockModelProviders: Array<{ provider: string }> = [] let mockUsedVars: string[][] = [] -const mockAvailableVarMap: Record<string, { availableVars: Array<{ nodeId: string, vars: Array<{ variable: string }> }> }> = {} +const mockAvailableVarMap: Record< + string, + { availableVars: Array<{ nodeId: string; vars: Array<{ variable: string }> }> } +> = {} vi.mock('../use-nodes-meta-data', () => ({ useNodesMetaData: () => ({ @@ -57,10 +65,12 @@ vi.mock('../use-nodes-meta-data', () => ({ vi.mock('../use-nodes-available-var-list', () => ({ default: (nodes: Node[]) => { - const map: Record<string, { availableVars: Array<{ nodeId: string, vars: Array<{ variable: string }> }> }> = {} + const map: Record< + string, + { availableVars: Array<{ nodeId: string; vars: Array<{ variable: string }> }> } + > = {} if (nodes) { - for (const n of nodes) - map[n.id] = mockAvailableVarMap[n.id] ?? { availableVars: [] } + for (const n of nodes) map[n.id] = mockAvailableVarMap[n.id] ?? { availableVars: [] } } return map }, @@ -104,8 +114,9 @@ vi.mock('@/context/i18n', () => ({ })) vi.mock('@/context/provider-context', () => ({ - useProviderContextSelector: (selector: (state: { modelProviders: Array<{ provider: string }> }) => unknown) => - selector({ modelProviders: mockModelProviders }), + useProviderContextSelector: ( + selector: (state: { modelProviders: Array<{ provider: string }> }) => unknown, + ) => selector({ modelProviders: mockModelProviders }), })) // useWorkflowNodes reads from WorkflowContext (real store via renderWorkflowHook) @@ -141,8 +152,8 @@ beforeEach(() => { vi.clearAllMocks() resetReactFlowMockState() resetFixtureCounters() - Object.keys(mockNodesMap).forEach(k => delete mockNodesMap[k]) - Object.keys(mockAvailableVarMap).forEach(k => delete mockAvailableVarMap[k]) + Object.keys(mockNodesMap).forEach((k) => delete mockNodesMap[k]) + Object.keys(mockAvailableVarMap).forEach((k) => delete mockAvailableVarMap[k]) mockModelProviders = [] mockUsedVars = [] setupNodesMap() @@ -172,9 +183,7 @@ describe('useChecklist', () => { it('should return empty list when all nodes are valid and connected', () => { const { nodes, edges } = buildConnectedGraph() - const { result } = renderWorkflowHook( - () => useChecklist(nodes, edges), - ) + const { result } = renderWorkflowHook(() => useChecklist(nodes, edges)) expect(result.current).toEqual([]) }) @@ -184,12 +193,10 @@ describe('useChecklist', () => { const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } }) const isolatedLlm = createNode({ id: 'llm', data: { type: BlockEnum.LLM, title: 'LLM' } }) - const edges = [ - createEdge({ source: 'start', target: 'code' }), - ] + const edges = [createEdge({ source: 'start', target: 'code' })] - const { result } = renderWorkflowHook( - () => useChecklist([startNode, codeNode, isolatedLlm], edges), + const { result } = renderWorkflowHook(() => + useChecklist([startNode, codeNode, isolatedLlm], edges), ) const warning = result.current.find((item: ChecklistItem) => item.id === 'llm') @@ -206,13 +213,9 @@ describe('useChecklist', () => { const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) const llmNode = createNode({ id: 'llm', data: { type: BlockEnum.LLM, title: 'LLM' } }) - const edges = [ - createEdge({ source: 'start', target: 'llm' }), - ] + const edges = [createEdge({ source: 'start', target: 'llm' })] - const { result } = renderWorkflowHook( - () => useChecklist([startNode, llmNode], edges), - ) + const { result } = renderWorkflowHook(() => useChecklist([startNode, llmNode], edges)) const warning = result.current.find((item: ChecklistItem) => item.id === 'llm') expect(warning).toBeDefined() @@ -229,12 +232,10 @@ describe('useChecklist', () => { const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) const llmNode = createNode({ id: 'llm', data: { type: BlockEnum.LLM, title: 'LLM' } }) - const edges = [ - createEdge({ source: 'start', target: 'llm' }), - ] + const edges = [createEdge({ source: 'start', target: 'llm' })] - renderWorkflowHook( - () => useChecklist([startNode, llmNode], edges, { flowType: FlowType.snippet }), + renderWorkflowHook(() => + useChecklist([startNode, llmNode], edges, { flowType: FlowType.snippet }), ) expect(checkValid).toHaveBeenCalledWith( @@ -247,11 +248,11 @@ describe('useChecklist', () => { it('should report missing start node in workflow mode', () => { const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } }) - const { result } = renderWorkflowHook( - () => useChecklist([codeNode], []), - ) + const { result } = renderWorkflowHook(() => useChecklist([codeNode], [])) - const startRequired = result.current.find((item: ChecklistItem) => item.id === 'start-node-required') + const startRequired = result.current.find( + (item: ChecklistItem) => item.id === 'start-node-required', + ) expect(startRequired).toBeDefined() expect(startRequired!.canNavigate).toBe(false) }) @@ -266,11 +267,11 @@ describe('useChecklist', () => { data: { type: BlockEnum.StartPlaceholder, title: 'Workflow start' }, }) - const { result } = renderWorkflowHook( - () => useChecklist([placeholderNode], []), - ) + const { result } = renderWorkflowHook(() => useChecklist([placeholderNode], [])) - expect(result.current.find((item: ChecklistItem) => item.id === 'start-node-required')).toBeUndefined() + expect( + result.current.find((item: ChecklistItem) => item.id === 'start-node-required'), + ).toBeUndefined() expect(result.current).toEqual([ expect.objectContaining({ id: 'start-placeholder', @@ -294,13 +295,9 @@ describe('useChecklist', () => { }, }) - const edges = [ - createEdge({ source: 'start', target: 'tool' }), - ] + const edges = [createEdge({ source: 'start', target: 'tool' })] - const { result } = renderWorkflowHook( - () => useChecklist([startNode, toolNode], edges), - ) + const { result } = renderWorkflowHook(() => useChecklist([startNode, toolNode], edges)) const warning = result.current.find((item: ChecklistItem) => item.id === 'tool') expect(warning).toBeDefined() @@ -319,11 +316,11 @@ describe('useChecklist', () => { const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) - const { result } = renderWorkflowHook( - () => useChecklist([startNode], []), - ) + const { result } = renderWorkflowHook(() => useChecklist([startNode], [])) - const requiredItem = result.current.find((item: ChecklistItem) => item.id === `${BlockEnum.End}-need-added`) + const requiredItem = result.current.find( + (item: ChecklistItem) => item.id === `${BlockEnum.End}-need-added`, + ) expect(requiredItem).toBeDefined() expect(requiredItem!.canNavigate).toBe(false) }) @@ -332,9 +329,7 @@ describe('useChecklist', () => { const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } }) - const { result } = renderWorkflowHook( - () => useChecklist([startNode, codeNode], []), - ) + const { result } = renderWorkflowHook(() => useChecklist([startNode, codeNode], [])) const startWarning = result.current.find((item: ChecklistItem) => item.id === 'start') expect(startWarning).toBeUndefined() @@ -348,9 +343,7 @@ describe('useChecklist', () => { }) const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) - const { result } = renderWorkflowHook( - () => useChecklist([startNode, nonCustomNode], []), - ) + const { result } = renderWorkflowHook(() => useChecklist([startNode, nonCustomNode], [])) const alienWarning = result.current.find((item: ChecklistItem) => item.id === 'alien') expect(alienWarning).toBeUndefined() @@ -369,13 +362,9 @@ describe('useChecklist', () => { }, }) - const edges = [ - createEdge({ source: 'start', target: 'llm' }), - ] + const edges = [createEdge({ source: 'start', target: 'llm' })] - const { result } = renderWorkflowHook( - () => useChecklist([startNode, llmNode], edges), - ) + const { result } = renderWorkflowHook(() => useChecklist([startNode, llmNode], edges)) const warning = result.current.find((item: ChecklistItem) => item.id === 'llm') expect(warning).toBeDefined() @@ -407,13 +396,9 @@ describe('useChecklist', () => { }, }) - const edges = [ - createEdge({ source: 'start', target: 'llm' }), - ] + const edges = [createEdge({ source: 'start', target: 'llm' })] - const { result } = renderWorkflowHook( - () => useChecklist([startNode, llmNode], edges), - ) + const { result } = renderWorkflowHook(() => useChecklist([startNode, llmNode], edges)) const warning = result.current.find((item: ChecklistItem) => item.id === 'llm') expect(warning).toBeDefined() @@ -447,15 +432,19 @@ describe('useChecklist', () => { createEdge({ source: 'start', target: 'end-2' }), ] - const { result } = renderWorkflowHook( - () => useChecklist([startNode, firstEndNode, secondEndNode], edges), + const { result } = renderWorkflowHook(() => + useChecklist([startNode, firstEndNode, secondEndNode], edges), ) const firstWarning = result.current.find((item: ChecklistItem) => item.id === 'end-1') const secondWarning = result.current.find((item: ChecklistItem) => item.id === 'end-2') - expect(firstWarning?.errorMessages.some(message => message.includes('duplicateOutputVariable'))).toBe(true) - expect(secondWarning?.errorMessages.some(message => message.includes('duplicateOutputVariable'))).toBe(true) + expect( + firstWarning?.errorMessages.some((message) => message.includes('duplicateOutputVariable')), + ).toBe(true) + expect( + secondWarning?.errorMessages.some((message) => message.includes('duplicateOutputVariable')), + ).toBe(true) }) it('should sync checklist items to the workflow store without render phase update warnings', async () => { @@ -465,7 +454,7 @@ describe('useChecklist', () => { const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } }) function Operator() { - const checklistItems = useStore(state => state.checklistItems) + const checklistItems = useStore((state) => state.checklistItems) return createElement('div', { 'data-testid': 'checklist-count' }, checklistItems.length) } @@ -475,12 +464,7 @@ describe('useChecklist', () => { } const { store } = renderWorkflowComponent( - createElement( - Fragment, - null, - createElement(Operator), - createElement(WorkflowChecklist), - ), + createElement(Fragment, null, createElement(Operator), createElement(WorkflowChecklist)), ) await waitFor(() => { @@ -488,11 +472,12 @@ describe('useChecklist', () => { }) expect(screen.getByTestId('checklist-count')).toHaveTextContent('1') - expect(errorSpy.mock.calls.some(call => - call.some(arg => typeof arg === 'string' && arg.includes('Cannot update a component')), - )).toBe(false) - } - finally { + expect( + errorSpy.mock.calls.some((call) => + call.some((arg) => typeof arg === 'string' && arg.includes('Cannot update a component')), + ), + ).toBe(false) + } finally { errorSpy.mockRestore() } }) diff --git a/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts b/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts index 5811f14a60dc7f..acfb33b00660aa 100644 --- a/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts @@ -24,7 +24,9 @@ const createModel = (overrides: Partial<ModelConfig> = {}): ModelConfig => ({ ...overrides, }) -const createVisionPayload = (overrides: Partial<{ enabled: boolean, configs?: VisionSetting }> = {}) => ({ +const createVisionPayload = ( + overrides: Partial<{ enabled: boolean; configs?: VisionSetting }> = {}, +) => ({ enabled: false, ...overrides, }) @@ -49,10 +51,12 @@ describe('useConfigVision', () => { }, }) - const { result } = renderHook(() => useConfigVision(createModel(), { - payload: createVisionPayload(), - onChange, - })) + const { result } = renderHook(() => + useConfigVision(createModel(), { + payload: createVisionPayload(), + onChange, + }), + ) expect(result.current.isVisionModel).toBe(true) @@ -72,16 +76,18 @@ describe('useConfigVision', () => { it('should clear configs when disabling vision resolution', () => { const onChange = vi.fn() - const { result } = renderHook(() => useConfigVision(createModel(), { - payload: createVisionPayload({ - enabled: true, - configs: { - detail: Resolution.low, - variable_selector: ['node', 'files'], - }, + const { result } = renderHook(() => + useConfigVision(createModel(), { + payload: createVisionPayload({ + enabled: true, + configs: { + detail: Resolution.low, + variable_selector: ['node', 'files'], + }, + }), + onChange, }), - onChange, - })) + ) act(() => { result.current.handleVisionResolutionEnabledChange(false) @@ -99,10 +105,12 @@ describe('useConfigVision', () => { variable_selector: ['upstream', 'images'], } - const { result } = renderHook(() => useConfigVision(createModel(), { - payload: createVisionPayload({ enabled: true }), - onChange, - })) + const { result } = renderHook(() => + useConfigVision(createModel(), { + payload: createVisionPayload({ enabled: true }), + onChange, + }), + ) act(() => { result.current.handleVisionResolutionChange(config) @@ -117,16 +125,18 @@ describe('useConfigVision', () => { it('should disable vision settings when the selected model is no longer a vision model', () => { const onChange = vi.fn() - const { result } = renderHook(() => useConfigVision(createModel(), { - payload: createVisionPayload({ - enabled: true, - configs: { - detail: Resolution.high, - variable_selector: ['sys', 'files'], - }, + const { result } = renderHook(() => + useConfigVision(createModel(), { + payload: createVisionPayload({ + enabled: true, + configs: { + detail: Resolution.high, + variable_selector: ['sys', 'files'], + }, + }), + onChange, }), - onChange, - })) + ) act(() => { result.current.handleModelChanged() @@ -145,16 +155,18 @@ describe('useConfigVision', () => { }, }) - const { result } = renderHook(() => useConfigVision(createModel(), { - payload: createVisionPayload({ - enabled: true, - configs: { - detail: Resolution.low, - variable_selector: ['old', 'files'], - }, + const { result } = renderHook(() => + useConfigVision(createModel(), { + payload: createVisionPayload({ + enabled: true, + configs: { + detail: Resolution.low, + variable_selector: ['old', 'files'], + }, + }), + onChange, }), - onChange, - })) + ) act(() => { result.current.handleModelChanged() diff --git a/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx index 0626114da78fb7..5993f77a1a70b8 100644 --- a/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx @@ -19,12 +19,14 @@ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { - buildInTools: unknown[] - customTools: unknown[] - workflowTools: unknown[] - mcpTools: unknown[] - }) => unknown) => mockUseStore(selector), + useStore: ( + selector: (state: { + buildInTools: unknown[] + customTools: unknown[] + workflowTools: unknown[] + mcpTools: unknown[] + }) => unknown, + ) => mockUseStore(selector), })) vi.mock('@/service/use-triggers', () => ({ @@ -41,22 +43,29 @@ describe('useDynamicTestRunOptions', () => { mockUseTranslation.mockReturnValue({ t: withSelectorKey((key: string) => key), }) - mockUseStore.mockImplementation((selector: (state: { - buildInTools: unknown[] - customTools: unknown[] - workflowTools: unknown[] - mcpTools: unknown[] - }) => unknown) => selector({ - buildInTools: [], - customTools: [], - workflowTools: [], - mcpTools: [], - })) + mockUseStore.mockImplementation( + ( + selector: (state: { + buildInTools: unknown[] + customTools: unknown[] + workflowTools: unknown[] + mcpTools: unknown[] + }) => unknown, + ) => + selector({ + buildInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + }), + ) mockUseAllTriggerPlugins.mockReturnValue({ - data: [{ - name: 'plugin-provider', - icon: '/plugin-icon.png', - }], + data: [ + { + name: 'plugin-provider', + icon: '/plugin-icon.png', + }, + ], }) }) @@ -87,13 +96,15 @@ describe('useDynamicTestRunOptions', () => { const { result } = renderHook(() => useDynamicTestRunOptions()) - expect(result.current.userInput).toEqual(expect.objectContaining({ - id: 'inset-s-1', - type: 'user_input', - name: 'User Input', - nodeId: 'inset-s-1', - enabled: true, - })) + expect(result.current.userInput).toEqual( + expect.objectContaining({ + id: 'inset-s-1', + type: 'user_input', + name: 'User Input', + nodeId: 'inset-s-1', + enabled: true, + }), + ) expect(result.current.triggers).toEqual([ expect.objectContaining({ id: 'schedule-1', @@ -114,11 +125,13 @@ describe('useDynamicTestRunOptions', () => { nodeId: 'plugin-1', }), ]) - expect(result.current.runAll).toEqual(expect.objectContaining({ - id: 'run-all', - type: 'all', - relatedNodeIds: ['schedule-1', 'webhook-1', 'plugin-1'], - })) + expect(result.current.runAll).toEqual( + expect.objectContaining({ + id: 'run-all', + type: 'all', + relatedNodeIds: ['schedule-1', 'webhook-1', 'plugin-1'], + }), + ) }) it('should fall back to the workflow entry node and omit run-all when only one trigger exists', () => { @@ -135,12 +148,14 @@ describe('useDynamicTestRunOptions', () => { const { result } = renderHook(() => useDynamicTestRunOptions()) - expect(result.current.userInput).toEqual(expect.objectContaining({ - id: 'fallback-start', - type: 'user_input', - name: 'blocks.start', - nodeId: 'fallback-start', - })) + expect(result.current.userInput).toEqual( + expect.objectContaining({ + id: 'fallback-start', + type: 'user_input', + name: 'blocks.start', + nodeId: 'fallback-start', + }), + ) expect(result.current.triggers).toHaveLength(1) expect(result.current.runAll).toBeUndefined() }) diff --git a/web/app/components/workflow/hooks/__tests__/use-edges-interactions-without-sync.spec.ts b/web/app/components/workflow/hooks/__tests__/use-edges-interactions-without-sync.spec.ts index b38aca63981017..fc9ec417ad2c96 100644 --- a/web/app/components/workflow/hooks/__tests__/use-edges-interactions-without-sync.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-edges-interactions-without-sync.spec.ts @@ -44,13 +44,16 @@ const createFlowEdges = () => [ ] const renderEdgesInteractionsHook = () => - renderWorkflowFlowHook(() => ({ - ...useEdgesInteractionsWithoutSync(), - edges: useEdges(), - }), { - nodes: createFlowNodes(), - edges: createFlowEdges(), - }) + renderWorkflowFlowHook( + () => ({ + ...useEdgesInteractionsWithoutSync(), + edges: useEdges(), + }), + { + nodes: createFlowNodes(), + edges: createFlowEdges(), + }, + ) describe('useEdgesInteractionsWithoutSync', () => { it('clears running status and waitingRun on all edges', () => { @@ -73,18 +76,23 @@ describe('useEdgesInteractionsWithoutSync', () => { it('does not mutate the original edges array', () => { const edges = createFlowEdges() const originalData = { ...getEdgeRuntimeState(edges[0]) } - const { result } = renderWorkflowFlowHook(() => ({ - ...useEdgesInteractionsWithoutSync(), - edges: useEdges(), - }), { - nodes: createFlowNodes(), - edges, - }) + const { result } = renderWorkflowFlowHook( + () => ({ + ...useEdgesInteractionsWithoutSync(), + edges: useEdges(), + }), + { + nodes: createFlowNodes(), + edges, + }, + ) act(() => { result.current.handleEdgeCancelRunningStatus() }) - expect(getEdgeRuntimeState(edges[0])._sourceRunningStatus).toBe(originalData._sourceRunningStatus) + expect(getEdgeRuntimeState(edges[0])._sourceRunningStatus).toBe( + originalData._sourceRunningStatus, + ) }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.helpers.spec.ts b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.helpers.spec.ts index 136c46702398ea..81e7163edc2cff 100644 --- a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.helpers.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.helpers.spec.ts @@ -13,7 +13,9 @@ vi.mock('../../utils', () => ({ getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(), })) -const mockGetNodesConnectedSourceOrTargetHandleIdsMap = vi.mocked(getNodesConnectedSourceOrTargetHandleIdsMap) +const mockGetNodesConnectedSourceOrTargetHandleIdsMap = vi.mocked( + getNodesConnectedSourceOrTargetHandleIdsMap, +) describe('use-edges-interactions.helpers', () => { beforeEach(() => { @@ -31,10 +33,12 @@ describe('use-edges-interactions.helpers', () => { createNode({ id: 'node-1', data: { title: 'Source' } }), createNode({ id: 'node-2', data: { title: 'Target' } }), ] - const edgeChanges = [{ - type: 'add', - edge: createEdge({ id: 'edge-1', source: 'node-1', target: 'node-2' }), - }] + const edgeChanges = [ + { + type: 'add', + edge: createEdge({ id: 'edge-1', source: 'node-1', target: 'node-2' }), + }, + ] const result = applyConnectedHandleNodeData(nodes, edgeChanges) @@ -44,19 +48,25 @@ describe('use-edges-interactions.helpers', () => { }) it('clearEdgeMenuIfNeeded should return true only when the open menu belongs to a removed edge', () => { - expect(clearEdgeMenuIfNeeded({ - contextMenuTarget: { type: 'edge', edgeId: 'edge-1' }, - edgeIds: ['edge-1', 'edge-2'], - })).toBe(true) - - expect(clearEdgeMenuIfNeeded({ - contextMenuTarget: { type: 'edge', edgeId: 'edge-3' }, - edgeIds: ['edge-1', 'edge-2'], - })).toBe(false) - - expect(clearEdgeMenuIfNeeded({ - edgeIds: ['edge-1'], - })).toBe(false) + expect( + clearEdgeMenuIfNeeded({ + contextMenuTarget: { type: 'edge', edgeId: 'edge-1' }, + edgeIds: ['edge-1', 'edge-2'], + }), + ).toBe(true) + + expect( + clearEdgeMenuIfNeeded({ + contextMenuTarget: { type: 'edge', edgeId: 'edge-3' }, + edgeIds: ['edge-1', 'edge-2'], + }), + ).toBe(false) + + expect( + clearEdgeMenuIfNeeded({ + edgeIds: ['edge-1'], + }), + ).toBe(false) }) it('updateEdgeHoverState should toggle only the hovered edge flag', () => { @@ -67,8 +77,8 @@ describe('use-edges-interactions.helpers', () => { const result = updateEdgeHoverState(edges, 'edge-2', true) - expect(result.find(edge => edge.id === 'edge-1')?.data._hovering).toBe(false) - expect(result.find(edge => edge.id === 'edge-2')?.data._hovering).toBe(true) + expect(result.find((edge) => edge.id === 'edge-1')?.data._hovering).toBe(false) + expect(result.find((edge) => edge.id === 'edge-2')?.data._hovering).toBe(true) }) it('updateEdgeSelectionState should update selected flags for select changes only', () => { @@ -82,8 +92,8 @@ describe('use-edges-interactions.helpers', () => { { type: 'remove', id: 'edge-2' }, ]) - expect(result.find(edge => edge.id === 'edge-1')?.selected).toBe(true) - expect(result.find(edge => edge.id === 'edge-2')?.selected).toBe(true) + expect(result.find((edge) => edge.id === 'edge-1')?.selected).toBe(true) + expect(result.find((edge) => edge.id === 'edge-2')?.selected).toBe(true) }) it('buildContextMenuEdges should select the target edge and clear bundled markers', () => { @@ -94,9 +104,9 @@ describe('use-edges-interactions.helpers', () => { const result = buildContextMenuEdges(edges, 'edge-2') - expect(result.find(edge => edge.id === 'edge-1')?.selected).toBe(false) - expect(result.find(edge => edge.id === 'edge-2')?.selected).toBe(true) - expect(result.every(edge => edge.data._isBundled === false)).toBe(true) + expect(result.find((edge) => edge.id === 'edge-1')?.selected).toBe(false) + expect(result.find((edge) => edge.id === 'edge-2')?.selected).toBe(true) + expect(result.every((edge) => edge.data._isBundled === false)).toBe(true) }) it('clearNodeSelectionState should clear selected state and bundled markers on every node', () => { @@ -107,8 +117,8 @@ describe('use-edges-interactions.helpers', () => { const result = clearNodeSelectionState(nodes) - expect(result.every(node => node.selected === false)).toBe(true) - expect(result.every(node => node.data.selected === false)).toBe(true) - expect(result.every(node => node.data._isBundled === false)).toBe(true) + expect(result.every((node) => node.selected === false)).toBe(true) + expect(result.every((node) => node.data.selected === false)).toBe(true) + expect(result.every((node) => node.data._isBundled === false)).toBe(true) }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts index 3d463706944003..08bf4b808a7ac2 100644 --- a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts @@ -44,10 +44,7 @@ const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState => (node?.data ?? {}) as NodeRuntimeState function createFlowNodes() { - return [ - createNode({ id: 'n1' }), - createNode({ id: 'n2', position: { x: 100, y: 0 } }), - ] + return [createNode({ id: 'n1' }), createNode({ id: 'n2', position: { x: 100, y: 0 } })] } function createFlowEdges() { @@ -78,17 +75,20 @@ function renderEdgesInteractions(options?: { const { nodes = createFlowNodes(), edges = createFlowEdges(), initialStoreState } = options ?? {} return { - ...renderWorkflowFlowHook(() => ({ - ...useEdgesInteractions(), - nodes: useNodes(), - edges: useEdges(), - }), { - nodes, - edges, - initialStoreState, - hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, - reactFlowProps: { fitView: false }, - }), + ...renderWorkflowFlowHook( + () => ({ + ...useEdgesInteractions(), + nodes: useNodes(), + edges: useEdges(), + }), + { + nodes, + edges, + initialStoreState, + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + reactFlowProps: { fitView: false }, + }, + ), mockDoSync, } } @@ -107,17 +107,19 @@ describe('useEdgesInteractions', () => { }) await waitFor(() => { - expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e1'))._hovering).toBe(true) - expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e2'))._hovering).toBe(false) + expect( + getEdgeRuntimeState(result.current.edges.find((edge) => edge.id === 'e1'))._hovering, + ).toBe(true) + expect( + getEdgeRuntimeState(result.current.edges.find((edge) => edge.id === 'e2'))._hovering, + ).toBe(false) }) }) it('handleEdgeLeave should set _hovering to false', async () => { const { result } = renderEdgesInteractions({ - edges: createFlowEdges().map(edge => - edge.id === 'e1' - ? createEdge({ ...edge, data: { ...edge.data, _hovering: true } }) - : edge, + edges: createFlowEdges().map((edge) => + edge.id === 'e1' ? createEdge({ ...edge, data: { ...edge.data, _hovering: true } }) : edge, ), }) @@ -126,7 +128,9 @@ describe('useEdgesInteractions', () => { }) await waitFor(() => { - expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e1'))._hovering).toBe(false) + expect( + getEdgeRuntimeState(result.current.edges.find((edge) => edge.id === 'e1'))._hovering, + ).toBe(false) }) }) @@ -141,8 +145,8 @@ describe('useEdgesInteractions', () => { }) await waitFor(() => { - expect(result.current.edges.find(edge => edge.id === 'e1')?.selected).toBe(true) - expect(result.current.edges.find(edge => edge.id === 'e2')?.selected).toBe(false) + expect(result.current.edges.find((edge) => edge.id === 'e1')?.selected).toBe(true) + expect(result.current.edges.find((edge) => edge.id === 'e2')?.selected).toBe(false) }) }) @@ -180,20 +184,30 @@ describe('useEdgesInteractions', () => { }) act(() => { - result.current.handleEdgeContextMenu({ - preventDefault, - clientX: 320, - clientY: 180, - } as never, result.current.edges[1] as never) + result.current.handleEdgeContextMenu( + { + preventDefault, + clientX: 320, + clientY: 180, + } as never, + result.current.edges[1] as never, + ) }) expect(preventDefault).toHaveBeenCalled() await waitFor(() => { - expect(result.current.edges.find(edge => edge.id === 'e1')?.selected).toBe(false) - expect(result.current.edges.find(edge => edge.id === 'e2')?.selected).toBe(true) - expect(result.current.edges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true) - expect(result.current.nodes.every(node => !getNodeRuntimeState(node).selected && !node.selected && !getNodeRuntimeState(node)._isBundled)).toBe(true) + expect(result.current.edges.find((edge) => edge.id === 'e1')?.selected).toBe(false) + expect(result.current.edges.find((edge) => edge.id === 'e2')?.selected).toBe(true) + expect(result.current.edges.every((edge) => !getEdgeRuntimeState(edge)._isBundled)).toBe(true) + expect( + result.current.nodes.every( + (node) => + !getNodeRuntimeState(node).selected && + !node.selected && + !getNodeRuntimeState(node)._isBundled, + ), + ).toBe(true) }) expect(store.getState().contextMenuTarget).toEqual({ type: 'edge', edgeId: 'e2' }) @@ -375,7 +389,7 @@ describe('useEdgesInteractions', () => { result.current.handleEdgeSourceHandleChange('n1', 'missing-handle', 'new-handle') }) - expect(result.current.edges.map(edge => edge.id)).toEqual(['e1', 'e2']) + expect(result.current.edges.map((edge) => edge.id)).toEqual(['e1', 'e2']) expect(mockSaveStateToHistory).not.toHaveBeenCalled() }) @@ -436,15 +450,18 @@ describe('useEdgesInteractions', () => { const { result, store } = renderEdgesInteractions() act(() => { - result.current.handleEdgeContextMenu({ - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - clientX: 200, - clientY: 120, - } as never, result.current.edges[0] as never) + result.current.handleEdgeContextMenu( + { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clientX: 200, + clientY: 120, + } as never, + result.current.edges[0] as never, + ) }) - expect(result.current.edges.every(edge => !edge.selected)).toBe(true) + expect(result.current.edges.every((edge) => !edge.selected)).toBe(true) expect(store.getState().contextMenuTarget).toBeUndefined() }) diff --git a/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts b/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts index e1e26732aedce7..553dc9f224f0ec 100644 --- a/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts @@ -14,18 +14,22 @@ const mockInvalidateSysVarValues = vi.hoisted(() => vi.fn()) const mockHandleCancelAllNodeSuccessStatus = vi.hoisted(() => vi.fn()) const mockToNodeOutputVars = vi.hoisted(() => vi.fn()) -const schemaTypeDefinitions: SchemaTypeDefinition[] = [{ - name: 'simple', - schema: { - properties: {}, +const schemaTypeDefinitions: SchemaTypeDefinition[] = [ + { + name: 'simple', + schema: { + properties: {}, + }, }, -}] +] vi.mock('reactflow', async () => - (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), +) vi.mock('@/service/use-tools', async () => - (await import('../../__tests__/service-mock-factory')).createToolServiceMock()) + (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), +) vi.mock('@/service/use-workflow', () => ({ useInvalidateConversationVarValues: () => mockInvalidateConversationVarValues, @@ -84,13 +88,17 @@ describe('use-fetch-workflow-inspect-vars', () => { }, }), ] - mockToNodeOutputVars.mockReturnValue([{ - nodeId: 'node-1', - vars: [{ - variable: 'answer', - schemaType: 'simple', - }], - }]) + mockToNodeOutputVars.mockReturnValue([ + { + nodeId: 'node-1', + vars: [ + { + variable: 'answer', + schemaType: 'simple', + }, + ], + }, + ]) }) it('fetches inspect vars, invalidates cached values, and stores schema-enriched node vars', async () => { @@ -103,10 +111,11 @@ describe('use-fetch-workflow-inspect-vars', () => { ]) const { result, store } = renderWorkflowHook( - () => useSetWorkflowVarsWithValue({ - flowType: FlowType.appFlow, - flowId: 'flow-1', - }), + () => + useSetWorkflowVarsWithValue({ + flowType: FlowType.appFlow, + flowId: 'flow-1', + }), { initialStoreState: { dataSourceList: [], @@ -155,10 +164,11 @@ describe('use-fetch-workflow-inspect-vars', () => { } const { result, store } = renderWorkflowHook( - () => useSetWorkflowVarsWithValue({ - flowType: FlowType.appFlow, - flowId: 'flow-2', - }), + () => + useSetWorkflowVarsWithValue({ + flowType: FlowType.appFlow, + flowId: 'flow-2', + }), { initialStoreState: { dataSourceList: [], diff --git a/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts b/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts index 046745112d9769..0737db1878320d 100644 --- a/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts @@ -5,7 +5,8 @@ import { BlockEnum } from '../../types' import { useHelpline } from '../use-helpline' vi.mock('reactflow', async () => - (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), +) function makeNode(overrides: Record<string, unknown> & { id: string }): Node { return { @@ -24,8 +25,20 @@ describe('useHelpline', () => { it('should return empty arrays for nodes in iteration', () => { rfState.nodes = [ - { id: 'n1', position: { x: 0, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'n2', position: { x: 0, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { + id: 'n1', + position: { x: 0, y: 0 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'n2', + position: { x: 0, y: 0 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, ] const { result } = renderWorkflowHook(() => useHelpline()) @@ -39,7 +52,13 @@ describe('useHelpline', () => { it('should return empty arrays for nodes in loop', () => { rfState.nodes = [ - { id: 'n1', position: { x: 0, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { + id: 'n1', + position: { x: 0, y: 0 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, ] const { result } = renderWorkflowHook(() => useHelpline()) @@ -53,9 +72,27 @@ describe('useHelpline', () => { it('should detect horizontally aligned nodes (same y ±5px)', () => { rfState.nodes = [ - { id: 'n1', position: { x: 0, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'n2', position: { x: 300, y: 103 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'n3', position: { x: 600, y: 500 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { + id: 'n1', + position: { x: 0, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'n2', + position: { x: 300, y: 103 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'n3', + position: { x: 600, y: 500 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, ] const { result } = renderWorkflowHook(() => useHelpline()) @@ -70,9 +107,27 @@ describe('useHelpline', () => { it('should detect vertically aligned nodes (same x ±5px)', () => { rfState.nodes = [ - { id: 'n1', position: { x: 100, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'n2', position: { x: 102, y: 200 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'n3', position: { x: 500, y: 400 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { + id: 'n1', + position: { x: 100, y: 0 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'n2', + position: { x: 102, y: 200 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'n3', + position: { x: 500, y: 400 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, ] const { result } = renderWorkflowHook(() => useHelpline()) @@ -89,9 +144,27 @@ describe('useHelpline', () => { const ENTRY_OFFSET_Y = 21 rfState.nodes = [ - { id: 'start', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.Start } }, - { id: 'n2', position: { x: 300, y: 100 + ENTRY_OFFSET_Y }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'far', position: { x: 300, y: 500 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { + id: 'start', + position: { x: 100, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.Start }, + }, + { + id: 'n2', + position: { x: 300, y: 100 + ENTRY_OFFSET_Y }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'far', + position: { x: 300, y: 500 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, ] const { result } = renderWorkflowHook(() => useHelpline()) @@ -114,8 +187,20 @@ describe('useHelpline', () => { const ENTRY_OFFSET_Y = 21 rfState.nodes = [ - { id: 'trigger', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.TriggerWebhook } }, - { id: 'n2', position: { x: 300, y: 100 + ENTRY_OFFSET_Y }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { + id: 'trigger', + position: { x: 100, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.TriggerWebhook }, + }, + { + id: 'n2', + position: { x: 300, y: 100 + ENTRY_OFFSET_Y }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, ] const { result } = renderWorkflowHook(() => useHelpline()) @@ -135,9 +220,27 @@ describe('useHelpline', () => { it('should not detect alignment when positions differ by more than 5px', () => { rfState.nodes = [ - { id: 'n1', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'n2', position: { x: 300, y: 106 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'n3', position: { x: 106, y: 300 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { + id: 'n1', + position: { x: 100, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'n2', + position: { x: 300, y: 106 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'n3', + position: { x: 106, y: 300 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, ] const { result } = renderWorkflowHook(() => useHelpline()) @@ -151,8 +254,20 @@ describe('useHelpline', () => { it('should exclude child nodes in iteration', () => { rfState.nodes = [ - { id: 'n1', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'child', position: { x: 300, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM, isInIteration: true } }, + { + id: 'n1', + position: { x: 100, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'child', + position: { x: 300, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM, isInIteration: true }, + }, ] const { result } = renderWorkflowHook(() => useHelpline()) @@ -166,8 +281,20 @@ describe('useHelpline', () => { it('should set helpLineHorizontal in store when aligned nodes found', () => { rfState.nodes = [ - { id: 'n1', position: { x: 0, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'n2', position: { x: 300, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { + id: 'n1', + position: { x: 0, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'n2', + position: { x: 300, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, ] const { result, store } = renderWorkflowHook(() => useHelpline()) @@ -180,8 +307,20 @@ describe('useHelpline', () => { it('should clear helpLineHorizontal when no aligned nodes', () => { rfState.nodes = [ - { id: 'n1', position: { x: 0, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'n2', position: { x: 300, y: 500 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { + id: 'n1', + position: { x: 0, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'n2', + position: { x: 300, y: 500 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, ] const { result, store } = renderWorkflowHook(() => useHelpline()) @@ -194,8 +333,20 @@ describe('useHelpline', () => { it('should extend horizontal helpline when dragging node is before the first aligned node', () => { rfState.nodes = [ - { id: 'a', position: { x: 300, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'b', position: { x: 600, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { + id: 'a', + position: { x: 300, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'b', + position: { x: 600, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, ] const { result, store } = renderWorkflowHook(() => useHelpline()) @@ -211,8 +362,20 @@ describe('useHelpline', () => { it('should extend vertical helpline when dragging node is below the aligned nodes', () => { rfState.nodes = [ - { id: 'a', position: { x: 120, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, - { id: 'b', position: { x: 120, y: 260 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { + id: 'a', + position: { x: 120, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, + { + id: 'b', + position: { x: 120, y: 260 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, ] const { result, store } = renderWorkflowHook(() => useHelpline()) @@ -228,18 +391,26 @@ describe('useHelpline', () => { it('should extend horizontal helpline using entry node width when a start node is after the aligned nodes', () => { rfState.nodes = [ - { id: 'aligned', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { + id: 'aligned', + position: { x: 100, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM }, + }, ] const { result, store } = renderWorkflowHook(() => useHelpline()) - result.current.handleSetHelpline(makeNode({ - id: 'start-node', - position: { x: 500, y: 79 }, - width: 240, - height: 100, - data: { type: BlockEnum.Start }, - })) + result.current.handleSetHelpline( + makeNode({ + id: 'start-node', + position: { x: 500, y: 79 }, + width: 240, + height: 100, + data: { type: BlockEnum.Start }, + }), + ) expect(store.getState().helpLineHorizontal).toEqual({ top: 100, diff --git a/web/app/components/workflow/hooks/__tests__/use-hooksstore-wrappers.spec.ts b/web/app/components/workflow/hooks/__tests__/use-hooksstore-wrappers.spec.ts index 38bfa4839e6a70..b96d2464b94cdf 100644 --- a/web/app/components/workflow/hooks/__tests__/use-hooksstore-wrappers.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-hooksstore-wrappers.spec.ts @@ -34,7 +34,9 @@ describe('useWorkflowRun', () => { expect(result.current.handleBackupDraft).toBe(mocks.handleBackupDraft) expect(result.current.handleLoadBackupDraft).toBe(mocks.handleLoadBackupDraft) - expect(result.current.handleRestoreFromPublishedWorkflow).toBe(mocks.handleRestoreFromPublishedWorkflow) + expect(result.current.handleRestoreFromPublishedWorkflow).toBe( + mocks.handleRestoreFromPublishedWorkflow, + ) expect(result.current.handleRun).toBe(mocks.handleRun) expect(result.current.handleStopRun).toBe(mocks.handleStopRun) }) @@ -57,12 +59,24 @@ describe('useWorkflowStartRun', () => { }) expect(result.current.handleStartWorkflowRun).toBe(mocks.handleStartWorkflowRun) - expect(result.current.handleWorkflowStartRunInWorkflow).toBe(mocks.handleWorkflowStartRunInWorkflow) - expect(result.current.handleWorkflowStartRunInChatflow).toBe(mocks.handleWorkflowStartRunInChatflow) - expect(result.current.handleWorkflowTriggerScheduleRunInWorkflow).toBe(mocks.handleWorkflowTriggerScheduleRunInWorkflow) - expect(result.current.handleWorkflowTriggerWebhookRunInWorkflow).toBe(mocks.handleWorkflowTriggerWebhookRunInWorkflow) - expect(result.current.handleWorkflowTriggerPluginRunInWorkflow).toBe(mocks.handleWorkflowTriggerPluginRunInWorkflow) - expect(result.current.handleWorkflowRunAllTriggersInWorkflow).toBe(mocks.handleWorkflowRunAllTriggersInWorkflow) + expect(result.current.handleWorkflowStartRunInWorkflow).toBe( + mocks.handleWorkflowStartRunInWorkflow, + ) + expect(result.current.handleWorkflowStartRunInChatflow).toBe( + mocks.handleWorkflowStartRunInChatflow, + ) + expect(result.current.handleWorkflowTriggerScheduleRunInWorkflow).toBe( + mocks.handleWorkflowTriggerScheduleRunInWorkflow, + ) + expect(result.current.handleWorkflowTriggerWebhookRunInWorkflow).toBe( + mocks.handleWorkflowTriggerWebhookRunInWorkflow, + ) + expect(result.current.handleWorkflowTriggerPluginRunInWorkflow).toBe( + mocks.handleWorkflowTriggerPluginRunInWorkflow, + ) + expect(result.current.handleWorkflowRunAllTriggersInWorkflow).toBe( + mocks.handleWorkflowRunAllTriggersInWorkflow, + ) }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts b/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts index 7b2006aa77a21d..c91c3b8f686bdf 100644 --- a/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts @@ -16,15 +16,18 @@ const mockHandleCancelNodeSuccessStatus = vi.hoisted(() => vi.fn()) const mockHandleEdgeCancelRunningStatus = vi.hoisted(() => vi.fn()) const mockToNodeOutputVars = vi.hoisted(() => vi.fn()) -const schemaTypeDefinitions: SchemaTypeDefinition[] = [{ - name: 'simple', - schema: { - properties: {}, +const schemaTypeDefinitions: SchemaTypeDefinition[] = [ + { + name: 'simple', + schema: { + properties: {}, + }, }, -}] +] vi.mock('reactflow', async () => - (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), +) vi.mock('@/service/use-flow', () => ({ default: () => ({ @@ -40,7 +43,8 @@ vi.mock('@/service/use-flow', () => ({ })) vi.mock('@/service/use-tools', async () => - (await import('../../__tests__/service-mock-factory')).createToolServiceMock()) + (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), +) vi.mock('@/service/workflow', () => ({ fetchNodeInspectVars: (...args: unknown[]) => mockFetchNodeInspectVars(...args), @@ -58,10 +62,15 @@ vi.mock('../use-edges-interactions-without-sync', () => ({ }), })) -vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', async importOriginal => ({ - ...(await importOriginal<typeof import('@/app/components/workflow/nodes/_base/components/variable/utils')>()), - toNodeOutputVars: (...args: unknown[]) => mockToNodeOutputVars(...args), -})) +vi.mock( + '@/app/components/workflow/nodes/_base/components/variable/utils', + async (importOriginal) => ({ + ...(await importOriginal< + typeof import('@/app/components/workflow/nodes/_base/components/variable/utils') + >()), + toNodeOutputVars: (...args: unknown[]) => mockToNodeOutputVars(...args), + }), +) const createInspectVar = (overrides: Partial<VarInInspect> = {}): VarInInspect => ({ id: 'var-1', @@ -95,21 +104,26 @@ describe('useInspectVarsCrudCommon', () => { }, }), ] - mockToNodeOutputVars.mockReturnValue([{ - nodeId: 'node-1', - vars: [{ - variable: 'answer', - schemaType: 'simple', - }], - }]) + mockToNodeOutputVars.mockReturnValue([ + { + nodeId: 'node-1', + vars: [ + { + variable: 'answer', + schemaType: 'simple', + }, + ], + }, + ]) }) it('invalidates cached system vars without refetching node values for system selectors', async () => { const { result } = renderWorkflowHook( - () => useInspectVarsCrudCommon({ - flowId: 'flow-1', - flowType: FlowType.appFlow, - }), + () => + useInspectVarsCrudCommon({ + flowId: 'flow-1', + flowType: FlowType.appFlow, + }), { initialStoreState: { dataSourceList: [], @@ -126,29 +140,30 @@ describe('useInspectVarsCrudCommon', () => { }) it('fetches node inspect vars, adds schema types, and marks the node as fetched', async () => { - mockFetchNodeInspectVars.mockResolvedValue([ - createInspectVar(), - ]) + mockFetchNodeInspectVars.mockResolvedValue([createInspectVar()]) const { result, store } = renderWorkflowHook( - () => useInspectVarsCrudCommon({ - flowId: 'flow-1', - flowType: FlowType.appFlow, - }), + () => + useInspectVarsCrudCommon({ + flowId: 'flow-1', + flowType: FlowType.appFlow, + }), { initialStoreState: { dataSourceList: [], - nodesWithInspectVars: [{ - nodeId: 'node-1', - nodePayload: { - type: BlockEnum.Code, + nodesWithInspectVars: [ + { + nodeId: 'node-1', + nodePayload: { + type: BlockEnum.Code, + title: 'Code', + desc: '', + } as never, + nodeType: BlockEnum.Code, title: 'Code', - desc: '', - } as never, - nodeType: BlockEnum.Code, - title: 'Code', - vars: [], - }], + vars: [], + }, + ], }, }, ) @@ -176,23 +191,26 @@ describe('useInspectVarsCrudCommon', () => { mockDoDeleteAllInspectorVars.mockResolvedValue(undefined) const { result, store } = renderWorkflowHook( - () => useInspectVarsCrudCommon({ - flowId: 'flow-1', - flowType: FlowType.appFlow, - }), + () => + useInspectVarsCrudCommon({ + flowId: 'flow-1', + flowType: FlowType.appFlow, + }), { initialStoreState: { - nodesWithInspectVars: [{ - nodeId: 'node-1', - nodePayload: { - type: BlockEnum.Code, + nodesWithInspectVars: [ + { + nodeId: 'node-1', + nodePayload: { + type: BlockEnum.Code, + title: 'Code', + desc: '', + } as never, + nodeType: BlockEnum.Code, title: 'Code', - desc: '', - } as never, - nodeType: BlockEnum.Code, - title: 'Code', - vars: [createInspectVar()], - }], + vars: [createInspectVar()], + }, + ], }, }, ) diff --git a/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud.spec.ts b/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud.spec.ts index 90aa6290fa489e..e9befc499ad152 100644 --- a/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud.spec.ts @@ -34,11 +34,13 @@ describe('useInspectVarsCrud', () => { beforeEach(() => { vi.clearAllMocks() mockUseConversationVarValues.mockReturnValue({ - data: [createInspectVar({ - id: 'conversation-var', - name: 'history', - selector: ['conversation', 'history'], - })], + data: [ + createInspectVar({ + id: 'conversation-var', + name: 'history', + selector: ['conversation', 'history'], + }), + ], }) mockUseSysVarValues.mockReturnValue({ data: [ @@ -83,20 +85,24 @@ describe('useInspectVarsCrud', () => { const { result } = renderWorkflowHook(() => useInspectVarsCrud(), { initialStoreState: { - nodesWithInspectVars: [{ - nodeId: 'start-node', - nodePayload: { - type: BlockEnum.Start, + nodesWithInspectVars: [ + { + nodeId: 'start-node', + nodePayload: { + type: BlockEnum.Start, + title: 'Start', + desc: '', + } as never, + nodeType: BlockEnum.Start, title: 'Start', - desc: '', - } as never, - nodeType: BlockEnum.Start, - title: 'Start', - vars: [createInspectVar({ - id: 'start-answer', - selector: ['start-node', 'answer'], - })], - }], + vars: [ + createInspectVar({ + id: 'start-answer', + selector: ['start-node', 'answer'], + }), + ], + }, + ], }, hooksStoreProps: { configsMap: { @@ -122,8 +128,8 @@ describe('useInspectVarsCrud', () => { }) expect(result.current.conversationVars).toHaveLength(1) - expect(result.current.systemVars.map(item => item.name)).toEqual(['time']) - expect(result.current.nodesWithInspectVars[0]?.vars.map(item => item.name)).toEqual([ + expect(result.current.systemVars.map((item) => item.name)).toEqual(['time']) + expect(result.current.nodesWithInspectVars[0]?.vars.map((item) => item.name)).toEqual([ 'answer', 'query', 'files', diff --git a/web/app/components/workflow/hooks/__tests__/use-node-data-update.spec.ts b/web/app/components/workflow/hooks/__tests__/use-node-data-update.spec.ts index 231c5afc0e9177..ebdbcdab3935d3 100644 --- a/web/app/components/workflow/hooks/__tests__/use-node-data-update.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-node-data-update.spec.ts @@ -5,7 +5,8 @@ import { WorkflowRunningStatus } from '../../types' import { useNodeDataUpdate } from '../use-node-data-update' vi.mock('reactflow', async () => - (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), +) describe('useNodeDataUpdate', () => { beforeEach(() => { diff --git a/web/app/components/workflow/hooks/__tests__/use-node-plugin-installation.spec.ts b/web/app/components/workflow/hooks/__tests__/use-node-plugin-installation.spec.ts index deeb72fe544e43..384108cbe2b961 100644 --- a/web/app/components/workflow/hooks/__tests__/use-node-plugin-installation.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-node-plugin-installation.spec.ts @@ -47,7 +47,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -68,38 +69,41 @@ vi.mock('@/service/use-pipeline', () => ({ useInvalidDataSourceList: () => mockInvalidDataSourceList, })) -const makeToolNode = (overrides: Partial<CommonNodeType> = {}) => ({ - type: BlockEnum.Tool, - title: 'Tool node', - desc: '', - provider_type: CollectionType.builtIn, - provider_id: 'search', - provider_name: 'search', - plugin_id: 'plugin-search', - plugin_unique_identifier: 'plugin-search@1.0.0', - ...overrides, -}) as CommonNodeType - -const makeTriggerNode = (overrides: Partial<CommonNodeType> = {}) => ({ - type: BlockEnum.TriggerPlugin, - title: 'Trigger node', - desc: '', - provider_id: 'trigger-provider', - provider_name: 'trigger-provider', - plugin_id: 'trigger-plugin', - plugin_unique_identifier: 'trigger-plugin@1.0.0', - ...overrides, -}) as CommonNodeType - -const makeDataSourceNode = (overrides: Partial<CommonNodeType> = {}) => ({ - type: BlockEnum.DataSource, - title: 'Data source node', - desc: '', - provider_name: 'knowledge-provider', - plugin_id: 'knowledge-plugin', - plugin_unique_identifier: 'knowledge-plugin@1.0.0', - ...overrides, -}) as CommonNodeType +const makeToolNode = (overrides: Partial<CommonNodeType> = {}) => + ({ + type: BlockEnum.Tool, + title: 'Tool node', + desc: '', + provider_type: CollectionType.builtIn, + provider_id: 'search', + provider_name: 'search', + plugin_id: 'plugin-search', + plugin_unique_identifier: 'plugin-search@1.0.0', + ...overrides, + }) as CommonNodeType + +const makeTriggerNode = (overrides: Partial<CommonNodeType> = {}) => + ({ + type: BlockEnum.TriggerPlugin, + title: 'Trigger node', + desc: '', + provider_id: 'trigger-provider', + provider_name: 'trigger-provider', + plugin_id: 'trigger-plugin', + plugin_unique_identifier: 'trigger-plugin@1.0.0', + ...overrides, + }) as CommonNodeType + +const makeDataSourceNode = (overrides: Partial<CommonNodeType> = {}) => + ({ + type: BlockEnum.DataSource, + title: 'Data source node', + desc: '', + provider_name: 'knowledge-provider', + plugin_id: 'knowledge-plugin', + plugin_unique_identifier: 'knowledge-plugin@1.0.0', + ...overrides, + }) as CommonNodeType const matchedTool = { plugin_id: 'plugin-search', @@ -192,12 +196,14 @@ describe('useNodePluginInstallation', () => { it('should keep unknown tool collection types installable without collection state', () => { const { result } = renderWorkflowHook(() => - useNodePluginInstallation(makeToolNode({ - provider_type: 'unknown' as CollectionType, - plugin_unique_identifier: undefined, - plugin_id: undefined, - provider_id: 'legacy-provider', - })), + useNodePluginInstallation( + makeToolNode({ + provider_type: 'unknown' as CollectionType, + plugin_unique_identifier: undefined, + plugin_id: undefined, + provider_id: 'legacy-provider', + }), + ), ) expect(result.current.isChecking).toBe(false) @@ -222,11 +228,13 @@ describe('useNodePluginInstallation', () => { mockTriggerPlugins.mockReturnValue({ data: [matchedTriggerProvider], isLoading: false }) const { result } = renderWorkflowHook(() => - useNodePluginInstallation(makeTriggerNode({ - provider_id: 'missing-trigger', - provider_name: 'missing-trigger', - plugin_id: 'missing-trigger', - })), + useNodePluginInstallation( + makeTriggerNode({ + provider_id: 'missing-trigger', + provider_name: 'missing-trigger', + plugin_id: 'missing-trigger', + }), + ), ) expect(mockTriggerPlugins).toHaveBeenCalledWith(true) @@ -245,7 +253,9 @@ describe('useNodePluginInstallation', () => { mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: true }) const { result } = renderWorkflowHook(() => - useNodePluginInstallation(makeTriggerNode({ plugin_unique_identifier: undefined, plugin_id: 'trigger-plugin' })), + useNodePluginInstallation( + makeTriggerNode({ plugin_unique_identifier: undefined, plugin_id: 'trigger-plugin' }), + ), ) expect(result.current.isChecking).toBe(true) @@ -257,11 +267,14 @@ describe('useNodePluginInstallation', () => { it('should track missing and matched data source providers based on workflow store state', () => { const missingRender = renderWorkflowHook( - () => useNodePluginInstallation(makeDataSourceNode({ - provider_name: 'missing-provider', - plugin_id: 'missing-plugin', - plugin_unique_identifier: 'missing-plugin@1.0.0', - })), + () => + useNodePluginInstallation( + makeDataSourceNode({ + provider_name: 'missing-provider', + plugin_id: 'missing-plugin', + plugin_unique_identifier: 'missing-plugin@1.0.0', + }), + ), { initialStoreState: { dataSourceList: [matchedDataSource] as never, diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts index 53c218d9b1ee4f..9201ec39f5ef52 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts @@ -4,7 +4,9 @@ import { useSnippetDraftStore } from '@/app/components/snippets/draft-store' import { PipelineInputVarType } from '@/models/pipeline' import { FlowType } from '@/types/common' import { BlockEnum, VarType } from '../../types' -import useNodesAvailableVarList, { useGetNodesAvailableVarList } from '../use-nodes-available-var-list' +import useNodesAvailableVarList, { + useGetNodesAvailableVarList, +} from '../use-nodes-available-var-list' const mockGetTreeLeafNodes = vi.hoisted(() => vi.fn()) const mockGetBeforeNodesInSameBranchIncludeParent = vi.hoisted(() => vi.fn()) @@ -25,33 +27,39 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/workflow/hooks-store/store', () => ({ - useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => selector({ - configsMap: { - flowType: mockFlowType.value, - }, - }), + useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => + selector({ + configsMap: { + flowType: mockFlowType.value, + }, + }), })) -const createNode = (overrides: Partial<Node> = {}): Node => ({ - id: 'node-1', - type: 'custom', - position: { x: 0, y: 0 }, - data: { - type: BlockEnum.LLM, - title: 'Node', - desc: '', +const createNode = (overrides: Partial<Node> = {}): Node => + ({ + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + type: BlockEnum.LLM, + title: 'Node', + desc: '', + }, + ...overrides, + }) as Node + +const outputVars: NodeOutPutVar[] = [ + { + nodeId: 'vars-node', + title: 'Vars', + vars: [ + { + variable: 'name', + type: VarType.string, + }, + ] satisfies Var[], }, - ...overrides, -} as Node) - -const outputVars: NodeOutPutVar[] = [{ - nodeId: 'vars-node', - title: 'Vars', - vars: [{ - variable: 'name', - type: VarType.string, - }] satisfies Var[], -}] +] const outputVarsWithSystemVars: NodeOutPutVar[] = [ { @@ -71,10 +79,12 @@ const outputVarsWithSystemVars: NodeOutPutVar[] = [ { nodeId: 'global', title: 'SYSTEM', - vars: [{ - variable: 'sys.user_id', - type: VarType.string, - }] satisfies Var[], + vars: [ + { + variable: 'sys.user_id', + type: VarType.string, + }, + ] satisfies Var[], }, ] @@ -84,8 +94,12 @@ describe('useNodesAvailableVarList', () => { mockFlowType.value = undefined globalThis.history.pushState({}, '', '/') useSnippetDraftStore.getState().reset() - mockGetBeforeNodesInSameBranchIncludeParent.mockImplementation((nodeId: string) => [createNode({ id: `before-${nodeId}` })]) - mockGetTreeLeafNodes.mockImplementation((nodeId: string) => [createNode({ id: `leaf-${nodeId}` })]) + mockGetBeforeNodesInSameBranchIncludeParent.mockImplementation((nodeId: string) => [ + createNode({ id: `before-${nodeId}` }), + ]) + mockGetTreeLeafNodes.mockImplementation((nodeId: string) => [ + createNode({ id: `leaf-${nodeId}` }), + ]) mockGetNodeAvailableVars.mockReturnValue(outputVars) }) @@ -109,54 +123,72 @@ describe('useNodesAvailableVarList', () => { }) const filterVar = vi.fn(() => true) - const { result } = renderHook(() => useNodesAvailableVarList([loopNode, childNode], { - filterVar, - hideEnv: true, - hideChatVar: true, - })) + const { result } = renderHook(() => + useNodesAvailableVarList([loopNode, childNode], { + filterVar, + hideEnv: true, + hideChatVar: true, + }), + ) expect(mockGetBeforeNodesInSameBranchIncludeParent).toHaveBeenCalledWith('loop-1') expect(mockGetBeforeNodesInSameBranchIncludeParent).toHaveBeenCalledWith('child-1') - expect(result.current['loop-1']?.availableNodes.map(node => node.id)).toEqual(['before-loop-1', 'loop-1']) + expect(result.current['loop-1']?.availableNodes.map((node) => node.id)).toEqual([ + 'before-loop-1', + 'loop-1', + ]) expect(result.current['child-1']?.availableVars).toEqual(outputVars) - expect(mockGetNodeAvailableVars).toHaveBeenNthCalledWith(2, expect.objectContaining({ - parentNode: loopNode, - isChatMode: true, - filterVar, - hideEnv: true, - hideChatVar: true, - })) + expect(mockGetNodeAvailableVars).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + parentNode: loopNode, + isChatMode: true, + filterVar, + hideEnv: true, + hideChatVar: true, + }), + ) }) it('adds snippet input fields as virtual start variables on snippet canvases', () => { globalThis.history.pushState({}, '', '/snippets/snippet-1/orchestrate') - useSnippetDraftStore.getState().setInputFields([{ - type: PipelineInputVarType.textInput, - label: 'Topic', - variable: 'topic', - required: true, - }]) + useSnippetDraftStore.getState().setInputFields([ + { + type: PipelineInputVarType.textInput, + label: 'Topic', + variable: 'topic', + required: true, + }, + ]) const currentNode = createNode({ id: 'node-a' }) - const { result } = renderHook(() => useNodesAvailableVarList([currentNode], { - filterVar: () => true, - })) + const { result } = renderHook(() => + useNodesAvailableVarList([currentNode], { + filterVar: () => true, + }), + ) - expect(result.current['node-a']?.availableNodes[0]).toEqual(expect.objectContaining({ - id: 'start', - data: expect.objectContaining({ - type: BlockEnum.Start, + expect(result.current['node-a']?.availableNodes[0]).toEqual( + expect.objectContaining({ + id: 'start', + data: expect.objectContaining({ + type: BlockEnum.Start, + }), }), - })) - expect(result.current['node-a']?.availableVars[0]).toEqual(expect.objectContaining({ - nodeId: 'start', - isStartNode: true, - vars: [expect.objectContaining({ - variable: 'topic', - type: VarType.string, - })], - })) + ) + expect(result.current['node-a']?.availableVars[0]).toEqual( + expect.objectContaining({ + nodeId: 'start', + isStartNode: true, + vars: [ + expect.objectContaining({ + variable: 'topic', + type: VarType.string, + }), + ], + }), + ) }) it('filters system variables on snippet canvases', () => { @@ -165,18 +197,24 @@ describe('useNodesAvailableVarList', () => { const currentNode = createNode({ id: 'node-a' }) - const { result } = renderHook(() => useNodesAvailableVarList([currentNode], { - filterVar: () => true, - })) + const { result } = renderHook(() => + useNodesAvailableVarList([currentNode], { + filterVar: () => true, + }), + ) - expect(result.current['node-a']?.availableVars).toEqual([{ - nodeId: 'vars-node', - title: 'Vars', - vars: [{ - variable: 'answer', - type: VarType.string, - }], - }]) + expect(result.current['node-a']?.availableVars).toEqual([ + { + nodeId: 'vars-node', + title: 'Vars', + vars: [ + { + variable: 'answer', + type: VarType.string, + }, + ], + }, + ]) }) it('keeps system variables outside snippet canvases', () => { @@ -184,9 +222,11 @@ describe('useNodesAvailableVarList', () => { const currentNode = createNode({ id: 'node-a' }) - const { result } = renderHook(() => useNodesAvailableVarList([currentNode], { - filterVar: () => true, - })) + const { result } = renderHook(() => + useNodesAvailableVarList([currentNode], { + filterVar: () => true, + }), + ) expect(result.current['node-a']?.availableVars).toEqual(outputVarsWithSystemVars) }) @@ -197,18 +237,24 @@ describe('useNodesAvailableVarList', () => { const currentNode = createNode({ id: 'node-a' }) - const { result } = renderHook(() => useNodesAvailableVarList([currentNode], { - filterVar: () => true, - })) + const { result } = renderHook(() => + useNodesAvailableVarList([currentNode], { + filterVar: () => true, + }), + ) - expect(result.current['node-a']?.availableVars).toEqual([{ - nodeId: 'vars-node', - title: 'Vars', - vars: [{ - variable: 'answer', - type: VarType.string, - }], - }]) + expect(result.current['node-a']?.availableVars).toEqual([ + { + nodeId: 'vars-node', + title: 'Vars', + vars: [ + { + variable: 'answer', + type: VarType.string, + }, + ], + }, + ]) }) it('returns a callback version that can use leaf nodes or caller-provided nodes', () => { @@ -229,7 +275,7 @@ describe('useNodesAvailableVarList', () => { }) expect(mockGetTreeLeafNodes).toHaveBeenCalledWith('node-a') - expect(leafMap['node-a']?.availableNodes.map(node => node.id)).toEqual(['leaf-node-a']) + expect(leafMap['node-a']?.availableNodes.map((node) => node.id)).toEqual(['leaf-node-a']) expect(manualMap['node-b']?.availableNodes).toBe(passedInAvailableNodes) }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions-without-sync.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions-without-sync.spec.ts index 1a2ebe9385114e..a252e12c89678c 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions-without-sync.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions-without-sync.spec.ts @@ -15,18 +15,29 @@ const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState => const createFlowNodes = () => [ createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } }), - createNode({ id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } }), - createNode({ id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } }), + createNode({ + id: 'n2', + position: { x: 100, y: 0 }, + data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false }, + }), + createNode({ + id: 'n3', + position: { x: 200, y: 0 }, + data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true }, + }), ] const renderNodesInteractionsHook = () => - renderWorkflowFlowHook(() => ({ - ...useNodesInteractionsWithoutSync(), - nodes: useNodes(), - }), { - nodes: createFlowNodes(), - edges: [], - }) + renderWorkflowFlowHook( + () => ({ + ...useNodesInteractionsWithoutSync(), + nodes: useNodes(), + }), + { + nodes: createFlowNodes(), + edges: [], + }, + ) describe('useNodesInteractionsWithoutSync', () => { it('clears _runningStatus and _waitingRun on all nodes', async () => { @@ -53,9 +64,9 @@ describe('useNodesInteractionsWithoutSync', () => { }) await waitFor(() => { - const n1 = result.current.nodes.find(node => node.id === 'n1') - const n2 = result.current.nodes.find(node => node.id === 'n2') - const n3 = result.current.nodes.find(node => node.id === 'n3') + const n1 = result.current.nodes.find((node) => node.id === 'n1') + const n2 = result.current.nodes.find((node) => node.id === 'n2') + const n3 = result.current.nodes.find((node) => node.id === 'n3') expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running) expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined() @@ -71,8 +82,12 @@ describe('useNodesInteractionsWithoutSync', () => { }) await waitFor(() => { - expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._waitingRun).toBe(true) - expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n3'))._waitingRun).toBe(true) + expect( + getNodeRuntimeState(result.current.nodes.find((node) => node.id === 'n1'))._waitingRun, + ).toBe(true) + expect( + getNodeRuntimeState(result.current.nodes.find((node) => node.id === 'n3'))._waitingRun, + ).toBe(true) }) }) @@ -84,7 +99,7 @@ describe('useNodesInteractionsWithoutSync', () => { }) await waitFor(() => { - const n2 = result.current.nodes.find(node => node.id === 'n2') + const n2 = result.current.nodes.find((node) => node.id === 'n2') expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined() expect(getNodeRuntimeState(n2)._waitingRun).toBe(false) }) @@ -98,7 +113,7 @@ describe('useNodesInteractionsWithoutSync', () => { }) await waitFor(() => { - const n1 = result.current.nodes.find(node => node.id === 'n1') + const n1 = result.current.nodes.find((node) => node.id === 'n1') expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running) expect(getNodeRuntimeState(n1)._waitingRun).toBe(true) }) @@ -112,7 +127,7 @@ describe('useNodesInteractionsWithoutSync', () => { }) await waitFor(() => { - const n1 = result.current.nodes.find(node => node.id === 'n1') + const n1 = result.current.nodes.find((node) => node.id === 'n1') expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running) }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts index dba8107f7d4218..0eab5ad8af25ad 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts @@ -12,14 +12,18 @@ const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn()) const mockSaveStateToHistory = vi.hoisted(() => vi.fn()) const mockUndo = vi.hoisted(() => vi.fn()) const mockRedo = vi.hoisted(() => vi.fn()) -const mockHandleNodeIterationChildrenCopy = vi.hoisted(() => vi.fn(() => ({ - copyChildren: [], - newIdMapping: {}, -}))) -const mockHandleNodeLoopChildrenCopy = vi.hoisted(() => vi.fn(() => ({ - copyChildren: [], - newIdMapping: {}, -}))) +const mockHandleNodeIterationChildrenCopy = vi.hoisted(() => + vi.fn(() => ({ + copyChildren: [], + newIdMapping: {}, + })), +) +const mockHandleNodeLoopChildrenCopy = vi.hoisted(() => + vi.fn(() => ({ + copyChildren: [], + newIdMapping: {}, + })), +) const mockCreateInlineAgentBinding = vi.hoisted(() => vi.fn()) const runtimeNodesMetaDataMap = vi.hoisted(() => ({ value: {} as Record<string, unknown>, @@ -34,7 +38,8 @@ let currentNodes: Node[] = [] let currentEdges: Edge[] = [] vi.mock('reactflow', async () => - (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), +) vi.mock('../use-workflow', () => ({ useWorkflow: () => ({ @@ -99,7 +104,7 @@ vi.mock('../../nodes/loop/use-interactions', () => ({ }), })) -vi.mock('../use-workflow-history', async importOriginal => ({ +vi.mock('../use-workflow-history', async (importOriginal) => ({ ...(await importOriginal<typeof import('../use-workflow-history')>()), useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory, @@ -114,17 +119,24 @@ describe('useNodesInteractions', () => { resetReactFlowMockState() runtimeState.nodesReadOnly = false runtimeState.workflowReadOnly = false - mockCreateInlineAgentBinding.mockImplementation((_nodeId: string, options?: { onSuccess?: (binding: { - binding_type: 'inline_agent' - agent_id: string - current_snapshot_id: string - }) => void }) => { - options?.onSuccess?.({ - binding_type: 'inline_agent', - agent_id: 'inline-agent-1', - current_snapshot_id: 'inline-snapshot-1', - }) - }) + mockCreateInlineAgentBinding.mockImplementation( + ( + _nodeId: string, + options?: { + onSuccess?: (binding: { + binding_type: 'inline_agent' + agent_id: string + current_snapshot_id: string + }) => void + }, + ) => { + options?.onSuccess?.({ + binding_type: 'inline_agent', + agent_id: 'inline-agent-1', + current_snapshot_id: 'inline-snapshot-1', + }) + }, + ) currentNodes = [ createNode({ id: 'node-1', @@ -252,9 +264,15 @@ describe('useNodesInteractions', () => { }), ] const isConnectedSpy = vi.spyOn(collaborationManager, 'isConnected').mockReturnValue(true) - const emitHistoryActionSpy = vi.spyOn(collaborationManager, 'emitHistoryAction').mockImplementation(() => undefined) - const collabSetNodesSpy = vi.spyOn(collaborationManager, 'setNodes').mockImplementation(() => undefined) - const collabSetEdgesSpy = vi.spyOn(collaborationManager, 'setEdges').mockImplementation(() => undefined) + const emitHistoryActionSpy = vi + .spyOn(collaborationManager, 'emitHistoryAction') + .mockImplementation(() => undefined) + const collabSetNodesSpy = vi + .spyOn(collaborationManager, 'setNodes') + .mockImplementation(() => undefined) + const collabSetEdgesSpy = vi + .spyOn(collaborationManager, 'setEdges') + .mockImplementation(() => undefined) const { result } = renderWorkflowHook(() => useNodesInteractions(), { historyStore: { @@ -294,9 +312,15 @@ describe('useNodesInteractions', () => { }), ] vi.spyOn(collaborationManager, 'isConnected').mockReturnValue(false) - const emitHistoryActionSpy = vi.spyOn(collaborationManager, 'emitHistoryAction').mockImplementation(() => undefined) - const collabSetNodesSpy = vi.spyOn(collaborationManager, 'setNodes').mockImplementation(() => undefined) - const collabSetEdgesSpy = vi.spyOn(collaborationManager, 'setEdges').mockImplementation(() => undefined) + const emitHistoryActionSpy = vi + .spyOn(collaborationManager, 'emitHistoryAction') + .mockImplementation(() => undefined) + const collabSetNodesSpy = vi + .spyOn(collaborationManager, 'setNodes') + .mockImplementation(() => undefined) + const collabSetEdgesSpy = vi + .spyOn(collaborationManager, 'setEdges') + .mockImplementation(() => undefined) const { result } = renderWorkflowHook(() => useNodesInteractions(), { historyStore: { @@ -528,11 +552,15 @@ describe('useNodesInteractions', () => { ) }) - const agentNode = rfState.nodes.find(node => node.data.type === BlockEnum.AgentV2) + const agentNode = rfState.nodes.find((node) => node.data.type === BlockEnum.AgentV2) const firstSetNodesPayload = rfState.setNodes.mock.calls[0]?.[0] - const pendingAgentNode = firstSetNodesPayload.find((node: Node) => node.data.type === BlockEnum.AgentV2) + const pendingAgentNode = firstSetNodesPayload.find( + (node: Node) => node.data.type === BlockEnum.AgentV2, + ) const finalSetNodesPayload = rfState.setNodes.mock.calls.at(-1)?.[0] - const finalAgentNode = finalSetNodesPayload.find((node: Node) => node.data.type === BlockEnum.AgentV2) + const finalAgentNode = finalSetNodesPayload.find( + (node: Node) => node.data.type === BlockEnum.AgentV2, + ) expect(pendingAgentNode?.data._isTempNode).toBe(true) expect(agentNode?.data.agent_binding).toEqual({ @@ -541,9 +569,12 @@ describe('useNodesInteractions', () => { current_snapshot_id: 'inline-snapshot-1', }) expect(finalAgentNode?.data._isTempNode).toBeUndefined() - expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith(agentNode?.id, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith( + agentNode?.id, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) expect(store.getState().openInlineAgentPanelNodeId).toBe(agentNode?.id) expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true) }) @@ -599,9 +630,11 @@ describe('useNodesInteractions', () => { ) }) - const agentNode = rfState.nodes.find(node => node.data.type === BlockEnum.AgentV2) + const agentNode = rfState.nodes.find((node) => node.data.type === BlockEnum.AgentV2) const firstSetNodesPayload = rfState.setNodes.mock.calls[0]?.[0] - const pendingAgentNode = firstSetNodesPayload.find((node: Node) => node.data.type === BlockEnum.AgentV2) + const pendingAgentNode = firstSetNodesPayload.find( + (node: Node) => node.data.type === BlockEnum.AgentV2, + ) expect(pendingAgentNode?.data._isTempNode).toBe(true) expect(agentNode?.data.agent_binding).toEqual({ @@ -609,9 +642,12 @@ describe('useNodesInteractions', () => { agent_id: 'inline-agent-1', current_snapshot_id: 'inline-snapshot-1', }) - expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith(agentNode?.id, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith( + agentNode?.id, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true) }) @@ -681,8 +717,8 @@ describe('useNodesInteractions', () => { }) const nodesArg = rfState.setNodes.mock.calls[0]?.[0] as Node[] - const knowledgeNode = nodesArg.find(node => node.id === 'knowledge-retrieval-node') - const answerNode = nodesArg.find(node => node.id === 'answer-node') + const knowledgeNode = nodesArg.find((node) => node.id === 'knowledge-retrieval-node') + const answerNode = nodesArg.find((node) => node.id === 'answer-node') expect(knowledgeNode?.selected).toBe(false) expect(knowledgeNode?.data.selected).toBe(false) @@ -907,10 +943,7 @@ describe('useNodesInteractions', () => { }) act(() => { - result.current.handleNodeAdd( - { nodeType: BlockEnum.Code }, - { prevNodeId: 'meta-node-1' }, - ) + result.current.handleNodeAdd({ nodeType: BlockEnum.Code }, { prevNodeId: 'meta-node-1' }) result.current.handleNodeChange('meta-node-1', BlockEnum.Answer, 'source') result.current.handleNodesPaste() result.current.handleNodeResize('meta-node-1', { @@ -986,7 +1019,7 @@ describe('useNodesInteractions', () => { }) const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[] - const newNode = pastedNodes.find(node => node.id !== 'existing-node') + const newNode = pastedNodes.find((node) => node.id !== 'existing-node') expect(newNode?.data.title).toBe('Clipboard') }) @@ -1044,7 +1077,9 @@ describe('useNodesInteractions', () => { }) const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[] - const newNode = pastedNodes.find(node => !currentNodes.some(existingNode => existingNode.id === node.id)) + const newNode = pastedNodes.find( + (node) => !currentNodes.some((existingNode) => existingNode.id === node.id), + ) expect(newNode?.data.title).toBe('Clipboard (2)') }) @@ -1082,37 +1117,14 @@ describe('useNodesInteractions', () => { it.each([ [BlockEnum.Iteration, 'iteration-source'], [BlockEnum.Loop, 'loop-source'], - ])('pastes a copied %s as a top-level node when the source container remains selected', async (containerType, nodeId) => { - currentNodes = [ - createNode({ - id: nodeId, - position: { x: 20, y: 20 }, - selected: true, - data: { - type: containerType, - title: containerType === BlockEnum.Iteration ? 'Iteration' : 'Loop', - desc: '', - _children: [], - }, - }), - ] - currentEdges = [] - rfState.nodes = currentNodes as unknown as typeof rfState.nodes - rfState.edges = currentEdges as unknown as typeof rfState.edges - - const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { - historyStore: { - nodes: currentNodes, - edges: currentEdges, - }, - }) - - store.setState({ - clipboardElements: [ + ])( + 'pastes a copied %s as a top-level node when the source container remains selected', + async (containerType, nodeId) => { + currentNodes = [ createNode({ id: nodeId, - position: { x: 120, y: 120 }, - zIndex: 1002, + position: { x: 20, y: 20 }, + selected: true, data: { type: containerType, title: containerType === BlockEnum.Iteration ? 'Iteration' : 'Loop', @@ -1120,27 +1132,55 @@ describe('useNodesInteractions', () => { _children: [], }, }), - ] as never, - clipboardEdges: [] as never, - mousePosition: { - pageX: 60, - pageY: 80, - } as never, - }) - - await act(async () => { - await result.current.handleNodesPaste() - }) - - const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[] - const newContainer = pastedNodes.find(node => node.id !== nodeId && node.data.type === containerType) - - expect(newContainer).toBeDefined() - expect(newContainer?.parentId).toBeUndefined() - expect(newContainer?.zIndex).toBe(0) - expect(newContainer?.data.isInIteration).toBeFalsy() - expect(newContainer?.data.isInLoop).toBeFalsy() - }) + ] + currentEdges = [] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = currentEdges as unknown as typeof rfState.edges + + const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + + store.setState({ + clipboardElements: [ + createNode({ + id: nodeId, + position: { x: 120, y: 120 }, + zIndex: 1002, + data: { + type: containerType, + title: containerType === BlockEnum.Iteration ? 'Iteration' : 'Loop', + desc: '', + _children: [], + }, + }), + ] as never, + clipboardEdges: [] as never, + mousePosition: { + pageX: 60, + pageY: 80, + } as never, + }) + + await act(async () => { + await result.current.handleNodesPaste() + }) + + const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[] + const newContainer = pastedNodes.find( + (node) => node.id !== nodeId && node.data.type === containerType, + ) + + expect(newContainer).toBeDefined() + expect(newContainer?.parentId).toBeUndefined() + expect(newContainer?.zIndex).toBe(0) + expect(newContainer?.data.isInIteration).toBeFalsy() + expect(newContainer?.data.isInLoop).toBeFalsy() + }, + ) }) // Nested container paste restrictions should stay aligned with available block filtering. @@ -1165,7 +1205,10 @@ describe('useNodesInteractions', () => { }, }) - const runDisallowedPasteScenario = async (containerType: BlockEnum.Iteration | BlockEnum.Loop, nodeType: BlockEnum) => { + const runDisallowedPasteScenario = async ( + containerType: BlockEnum.Iteration | BlockEnum.Loop, + nodeType: BlockEnum, + ) => { runtimeNodesMetaDataMap.value = { [nodeType]: createNodeMeta(nodeType), } @@ -1223,7 +1266,9 @@ describe('useNodesInteractions', () => { expect(pastedNodes).toHaveLength(1) expect(pastedNodes[0]?.id).toBe(containerId) expect(pastedNodes[0]?.data._children).toEqual([]) - expect(pastedNodes.some(node => node.data.type === nodeType && node.parentId === containerId)).toBe(false) + expect( + pastedNodes.some((node) => node.data.type === nodeType && node.parentId === containerId), + ).toBe(false) } it.each(disallowedNestedPasteNodeTypes)( diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-meta-data.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-nodes-meta-data.spec.tsx index edb8affac404e6..7a32ccdd4c416e 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-meta-data.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-meta-data.spec.tsx @@ -8,9 +8,15 @@ vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en-US', })) -const buildInToolsState = vi.hoisted(() => [] as Array<{ id: string, author: string, description: Record<string, string> }>) -const customToolsState = vi.hoisted(() => [] as Array<{ id: string, author: string, description: Record<string, string> }>) -const workflowToolsState = vi.hoisted(() => [] as Array<{ id: string, author: string, description: Record<string, string> }>) +const buildInToolsState = vi.hoisted( + () => [] as Array<{ id: string; author: string; description: Record<string, string> }>, +) +const customToolsState = vi.hoisted( + () => [] as Array<{ id: string; author: string; description: Record<string, string> }>, +) +const workflowToolsState = vi.hoisted( + () => [] as Array<{ id: string; author: string; description: Record<string, string> }>, +) vi.mock('@/service/use-tools', () => ({ useAllBuiltInTools: () => ({ data: buildInToolsState }), @@ -18,17 +24,18 @@ vi.mock('@/service/use-tools', () => ({ useAllWorkflowTools: () => ({ data: workflowToolsState }), })) -const createNode = (overrides: Partial<Node> = {}): Node => ({ - id: 'node-1', - type: 'custom', - position: { x: 0, y: 0 }, - data: { - type: BlockEnum.LLM, - title: 'Node', - desc: '', - }, - ...overrides, -} as Node) +const createNode = (overrides: Partial<Node> = {}): Node => + ({ + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + type: BlockEnum.LLM, + title: 'Node', + desc: '', + }, + ...overrides, + }) as Node describe('useNodesMetaData', () => { beforeEach(() => { @@ -76,10 +83,12 @@ describe('useNodesMetaData', () => { }, }) - expect(result.current).toEqual(expect.objectContaining({ - author: 'Provider Author', - description: 'Built-in provider description', - })) + expect(result.current).toEqual( + expect.objectContaining({ + author: 'Provider Author', + description: 'Built-in provider description', + }), + ) }) it('prefers workflow store data for datasource nodes and keeps generic metadata for normal blocks', () => { @@ -140,14 +149,18 @@ describe('useNodesMetaData', () => { }, }) - expect(datasourceResult.result.current).toEqual(expect.objectContaining({ - author: 'Datasource Author', - description: 'Datasource description', - })) - expect(normalResult.result.current).toEqual(expect.objectContaining({ - author: 'Dify', - description: 'Node description', - title: 'LLM', - })) + expect(datasourceResult.result.current).toEqual( + expect.objectContaining({ + author: 'Datasource Author', + description: 'Datasource description', + }), + ) + expect(normalResult.result.current).toEqual( + expect.objectContaining({ + author: 'Dify', + description: 'Node description', + title: 'LLM', + }), + ) }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts index 45f0bbb92f038d..7c2ec0277be505 100644 --- a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts @@ -61,12 +61,14 @@ describe('usePanelInteractions', () => { source: clipboardNode.id, target: 'target-node', }) - readTextMock.mockResolvedValue(JSON.stringify({ - kind: 'dify-workflow-clipboard', - version: '0.6.0', - nodes: [clipboardNode], - edges: [clipboardEdge], - })) + readTextMock.mockResolvedValue( + JSON.stringify({ + kind: 'dify-workflow-clipboard', + version: '0.6.0', + nodes: [clipboardNode], + edges: [clipboardEdge], + }), + ) const { result, store } = renderWorkflowHook(() => usePanelInteractions()) diff --git a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts index c899a0634a6226..a32aa0989ab4b6 100644 --- a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts @@ -30,17 +30,20 @@ function createFlowEdges() { } function renderSelectionInteractions(initialStoreState?: Record<string, unknown>) { - return renderWorkflowFlowHook(() => ({ - ...useSelectionInteractions(), - nodes: useNodes(), - edges: useEdges(), - reactFlowStore: useStoreApi(), - }), { - nodes: createFlowNodes(), - edges: createFlowEdges(), - reactFlowProps: { fitView: false }, - initialStoreState, - }) + return renderWorkflowFlowHook( + () => ({ + ...useSelectionInteractions(), + nodes: useNodes(), + edges: useEdges(), + reactFlowStore: useStoreApi(), + }), + { + nodes: createFlowNodes(), + edges: createFlowEdges(), + reactFlowProps: { fitView: false }, + initialStoreState, + }, + ) } describe('useSelectionInteractions', () => { @@ -76,8 +79,8 @@ describe('useSelectionInteractions', () => { }) await waitFor(() => { - expect(result.current.nodes.every(node => !getBundledState(node)._isBundled)).toBe(true) - expect(result.current.edges.every(edge => !getBundledState(edge)._isBundled)).toBe(true) + expect(result.current.nodes.every((node) => !getBundledState(node)._isBundled)).toBe(true) + expect(result.current.edges.every((edge) => !getBundledState(edge)._isBundled)).toBe(true) }) }) @@ -98,9 +101,15 @@ describe('useSelectionInteractions', () => { }) await waitFor(() => { - expect(getBundledState(result.current.nodes.find(node => node.id === 'n1'))._isBundled).toBe(true) - expect(getBundledState(result.current.nodes.find(node => node.id === 'n2'))._isBundled).toBe(false) - expect(getBundledState(result.current.nodes.find(node => node.id === 'n3'))._isBundled).toBe(true) + expect( + getBundledState(result.current.nodes.find((node) => node.id === 'n1'))._isBundled, + ).toBe(true) + expect( + getBundledState(result.current.nodes.find((node) => node.id === 'n2'))._isBundled, + ).toBe(false) + expect( + getBundledState(result.current.nodes.find((node) => node.id === 'n3'))._isBundled, + ).toBe(true) }) }) @@ -121,17 +130,19 @@ describe('useSelectionInteractions', () => { }) await waitFor(() => { - expect(getBundledState(result.current.edges.find(edge => edge.id === 'e1'))._isBundled).toBe(true) - expect(getBundledState(result.current.edges.find(edge => edge.id === 'e2'))._isBundled).toBe(false) + expect( + getBundledState(result.current.edges.find((edge) => edge.id === 'e1'))._isBundled, + ).toBe(true) + expect( + getBundledState(result.current.edges.find((edge) => edge.id === 'e2'))._isBundled, + ).toBe(false) }) }) it('handleSelectionDrag should sync node positions', async () => { const setNodesSpy = vi.spyOn(collaborationManager, 'setNodes') const { result, store } = renderSelectionInteractions() - const draggedNodes = [ - { id: 'n1', position: { x: 50, y: 60 }, data: {} }, - ] as never + const draggedNodes = [{ id: 'n1', position: { x: 50, y: 60 }, data: {} }] as never act(() => { result.current.handleSelectionDrag({} as unknown as React.MouseEvent, draggedNodes) @@ -140,11 +151,20 @@ describe('useSelectionInteractions', () => { expect(store.getState().nodeAnimation).toBe(false) expect(setNodesSpy).toHaveBeenCalledOnce() expect(setNodesSpy.mock.calls[0]?.[2]).toBe('use-selection-interactions:handleSelectionDrag') - expect(setNodesSpy.mock.calls[0]?.[1].find(node => node.id === 'n1')?.position).toEqual({ x: 50, y: 60 }) + expect(setNodesSpy.mock.calls[0]?.[1].find((node) => node.id === 'n1')?.position).toEqual({ + x: 50, + y: 60, + }) await waitFor(() => { - expect(result.current.nodes.find(node => node.id === 'n1')?.position).toEqual({ x: 50, y: 60 }) - expect(result.current.nodes.find(node => node.id === 'n2')?.position).toEqual({ x: 100, y: 100 }) + expect(result.current.nodes.find((node) => node.id === 'n1')?.position).toEqual({ + x: 50, + y: 60, + }) + expect(result.current.nodes.find((node) => node.id === 'n2')?.position).toEqual({ + x: 100, + y: 100, + }) }) }) @@ -166,8 +186,8 @@ describe('useSelectionInteractions', () => { expect(result.current.reactFlowStore.getState().userSelectionActive).toBe(true) await waitFor(() => { - expect(result.current.nodes.every(node => !getBundledState(node)._isBundled)).toBe(true) - expect(result.current.edges.every(edge => !getBundledState(edge)._isBundled)).toBe(true) + expect(result.current.nodes.every((node) => !getBundledState(node)._isBundled)).toBe(true) + expect(result.current.edges.every((edge) => !getBundledState(edge)._isBundled)).toBe(true) }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-serial-async-callback.spec.ts b/web/app/components/workflow/hooks/__tests__/use-serial-async-callback.spec.ts index bdb2554cd87132..da163b4e8b2007 100644 --- a/web/app/components/workflow/hooks/__tests__/use-serial-async-callback.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-serial-async-callback.spec.ts @@ -24,7 +24,7 @@ describe('useSerialAsyncCallback', () => { it('should serialize concurrent calls sequentially', async () => { const order: number[] = [] const fn = vi.fn(async (id: number, delay: number) => { - await new Promise(resolve => setTimeout(resolve, delay)) + await new Promise((resolve) => setTimeout(resolve, delay)) order.push(id) return id }) @@ -76,8 +76,7 @@ describe('useSerialAsyncCallback', () => { let callCount = 0 const fn = vi.fn(async () => { callCount++ - if (callCount === 1) - throw new Error('fail') + if (callCount === 1) throw new Error('fail') return 'ok' }) diff --git a/web/app/components/workflow/hooks/__tests__/use-shortcuts.spec.ts b/web/app/components/workflow/hooks/__tests__/use-shortcuts.spec.ts index 349e4ba49845b7..f58b8e25f73ad7 100644 --- a/web/app/components/workflow/hooks/__tests__/use-shortcuts.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-shortcuts.spec.ts @@ -123,19 +123,22 @@ vi.mock('../use-workflow-organize', () => ({ }), })) -const createKeyboardEvent = (target: HTMLElement = document.body) => ({ - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - target, -}) as unknown as KeyboardEvent - -const createSelectionMock = (commonAncestorContainer: Node): Selection => ({ - isCollapsed: false, - rangeCount: 1, - getRangeAt: () => ({ - commonAncestorContainer, - } as unknown as Range), -} as unknown as Selection) +const createKeyboardEvent = (target: HTMLElement = document.body) => + ({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + target, + }) as unknown as KeyboardEvent + +const createSelectionMock = (commonAncestorContainer: Node): Selection => + ({ + isCollapsed: false, + rangeCount: 1, + getRangeAt: () => + ({ + commonAncestorContainer, + }) as unknown as Range, + }) as unknown as Selection const findRegistration = (matcher: (registration: KeyPressRegistration) => boolean) => { const registration = keyPressRegistrations.find(matcher) @@ -144,27 +147,25 @@ const findRegistration = (matcher: (registration: KeyPressRegistration) => boole } const isEditableTarget = (target: EventTarget | null) => { - return target instanceof HTMLInputElement - || target instanceof HTMLTextAreaElement - || target instanceof HTMLSelectElement - || (target instanceof HTMLElement && target.isContentEditable) + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + (target instanceof HTMLElement && target.isContentEditable) + ) } const triggerShortcut = ( registration: KeyPressRegistration, event: KeyboardEvent = createKeyboardEvent(), ) => { - if (registration.options?.enabled === false) - return + if (registration.options?.enabled === false) return - if (registration.options?.ignoreInputs !== false && isEditableTarget(event.target)) - return + if (registration.options?.ignoreInputs !== false && isEditableTarget(event.target)) return - if (registration.options?.preventDefault !== false) - event.preventDefault() + if (registration.options?.preventDefault !== false) event.preventDefault() - if (registration.options?.stopPropagation !== false) - event.stopPropagation() + if (registration.options?.stopPropagation !== false) event.stopPropagation() registration.handler(event) } @@ -180,7 +181,7 @@ describe('useShortcuts', () => { it('deletes selected nodes and edges only outside editable inputs', () => { renderWorkflowHook(() => useWorkflowHotkeys()) - const deleteShortcut = findRegistration(registration => registration.keyFilter === 'Delete') + const deleteShortcut = findRegistration((registration) => registration.keyFilter === 'Delete') expect(deleteShortcut.options?.meta).toEqual( expect.objectContaining({ scope: 'workflow-canvas' }), ) @@ -202,11 +203,13 @@ describe('useShortcuts', () => { it('runs layout and zoom shortcuts through the workflow actions', () => { renderWorkflowHook(() => useWorkflowHotkeys()) - const layoutShortcut = findRegistration(registration => registration.keyFilter === 'Mod+O') - const fitViewShortcut = findRegistration(registration => registration.keyFilter === 'Mod+1') - const halfZoomShortcut = findRegistration(registration => registration.keyFilter === 'Shift+5') - const zoomOutShortcut = findRegistration(registration => registration.keyFilter === 'Mod+-') - const zoomInShortcut = findRegistration(registration => registration.keyFilter === 'Mod+=') + const layoutShortcut = findRegistration((registration) => registration.keyFilter === 'Mod+O') + const fitViewShortcut = findRegistration((registration) => registration.keyFilter === 'Mod+1') + const halfZoomShortcut = findRegistration( + (registration) => registration.keyFilter === 'Shift+5', + ) + const zoomOutShortcut = findRegistration((registration) => registration.keyFilter === 'Mod+-') + const zoomInShortcut = findRegistration((registration) => registration.keyFilter === 'Mod+=') triggerShortcut(layoutShortcut) triggerShortcut(fitViewShortcut) @@ -241,7 +244,7 @@ describe('useShortcuts', () => { renderWorkflowHook(() => useWorkflowHotkeys()) - const copyShortcut = findRegistration(registration => registration.keyFilter === 'Mod+C') + const copyShortcut = findRegistration((registration) => registration.keyFilter === 'Mod+C') const event = createKeyboardEvent() triggerShortcut(copyShortcut, event) diff --git a/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts b/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts index a973dc36f7ecc8..5be7b51c7999dd 100644 --- a/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts @@ -5,20 +5,33 @@ import { BlockEnum } from '../../types' import { useGetToolIcon, useToolIcon } from '../use-tool-icon' vi.mock('reactflow', async () => - (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), +) vi.mock('@/service/use-tools', async () => (await import('../../__tests__/service-mock-factory')).createToolServiceMock({ - buildInTools: [{ id: 'builtin-1', name: 'builtin', icon: '/builtin.svg', icon_dark: '/builtin-dark.svg', plugin_id: 'p1' }], + buildInTools: [ + { + id: 'builtin-1', + name: 'builtin', + icon: '/builtin.svg', + icon_dark: '/builtin-dark.svg', + plugin_id: 'p1', + }, + ], customTools: [{ id: 'custom-1', name: 'custom', icon: '/custom.svg', plugin_id: 'p2' }], - workflowTools: [{ id: 'workflow-1', name: 'workflow-tool', icon: '/workflow.svg', plugin_id: 'p3' }], + workflowTools: [ + { id: 'workflow-1', name: 'workflow-tool', icon: '/workflow.svg', plugin_id: 'p3' }, + ], mcpTools: [{ id: 'mcp-1', name: 'mcp-tool', icon: '/mcp.svg', plugin_id: 'p4' }], - })) + }), +) vi.mock('@/service/use-triggers', async () => (await import('../../__tests__/service-mock-factory')).createTriggerServiceMock({ triggerPlugins: [{ id: 'trigger-1', icon: '/trigger.svg', icon_dark: '/trigger-dark.svg' }], - })) + }), +) let mockTheme = 'light' vi.mock('@/hooks/use-theme', () => ({ @@ -103,8 +116,12 @@ describe('useToolIcon', () => { provider_icon_dark: '/fallback-dark.svg', } - expect(renderWorkflowHook(() => useToolIcon(triggerData)).result.current).toBe('/trigger-dark.svg') - expect(renderWorkflowHook(() => useToolIcon(providerFallbackData)).result.current).toBe('/fallback-dark.svg') + expect(renderWorkflowHook(() => useToolIcon(triggerData)).result.current).toBe( + '/trigger-dark.svg', + ) + expect(renderWorkflowHook(() => useToolIcon(providerFallbackData)).result.current).toBe( + '/fallback-dark.svg', + ) }) it('should resolve workflow, mcp and datasource icons', () => { @@ -130,11 +147,15 @@ describe('useToolIcon', () => { expect(renderWorkflowHook(() => useToolIcon(workflowData)).result.current).toBe('/workflow.svg') expect(renderWorkflowHook(() => useToolIcon(mcpData)).result.current).toBe('/mcp.svg') - expect(renderWorkflowHook(() => useToolIcon(dataSourceData), { - initialStoreState: { - dataSourceList: [{ id: 'ds-1', plugin_id: 'datasource-1', icon: '/datasource.svg' }] as never, - }, - }).result.current).toBe('/datasource.svg') + expect( + renderWorkflowHook(() => useToolIcon(dataSourceData), { + initialStoreState: { + dataSourceList: [ + { id: 'ds-1', plugin_id: 'datasource-1', icon: '/datasource.svg' }, + ] as never, + }, + }).result.current, + ).toBe('/datasource.svg') }) it('should fallback to provider_icon when no collection match', () => { @@ -218,23 +239,31 @@ describe('useGetToolIcon', () => { it('should prefer workflow store collections over query collections', () => { const { result, store } = renderWorkflowHook(() => useGetToolIcon(), { initialStoreState: { - buildInTools: [{ id: 'override-1', name: 'override', icon: '/override.svg', plugin_id: 'p1' }] as never, - dataSourceList: [{ id: 'ds-1', plugin_id: 'datasource-1', icon: '/datasource-store.svg' }] as never, + buildInTools: [ + { id: 'override-1', name: 'override', icon: '/override.svg', plugin_id: 'p1' }, + ] as never, + dataSourceList: [ + { id: 'ds-1', plugin_id: 'datasource-1', icon: '/datasource-store.svg' }, + ] as never, }, }) - expect(result.current({ - ...baseNodeData, - type: BlockEnum.Tool, - provider_type: CollectionType.builtIn, - provider_id: 'override-1', - provider_name: 'override', - })).toBe('/override.svg') - expect(result.current({ - ...baseNodeData, - type: BlockEnum.DataSource, - plugin_id: 'datasource-1', - })).toBe('/datasource-store.svg') + expect( + result.current({ + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.builtIn, + provider_id: 'override-1', + provider_name: 'override', + }), + ).toBe('/override.svg') + expect( + result.current({ + ...baseNodeData, + type: BlockEnum.DataSource, + plugin_id: 'datasource-1', + }), + ).toBe('/datasource-store.svg') expect(store.getState().buildInTools).toHaveLength(1) }) 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 8864193e462f57..a02b4397fe3d0a 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 @@ -1,11 +1,16 @@ -import type { WorkflowCommentDetail, WorkflowCommentList } from '@/app/components/workflow/comment/types' +import type { + WorkflowCommentDetail, + WorkflowCommentList, +} from '@/app/components/workflow/comment/types' import { act, waitFor } from '@testing-library/react' import { createTestQueryClient, seedSystemFeatures } from '@/__tests__/utils/mock-system-features' import { renderWorkflowHook } from '../../__tests__/workflow-test-env' import { ControlMode } from '../../types' import { useWorkflowComment } from '../use-workflow-comment' -const mockScreenToFlowPosition = vi.hoisted(() => vi.fn(({ x, y }: { x: number, y: number }) => ({ x: x - 90, y: y - 180 }))) +const mockScreenToFlowPosition = vi.hoisted(() => + vi.fn(({ x, y }: { x: number; y: number }) => ({ x: x - 90, y: y - 180 })), +) const mockSetCenter = vi.hoisted(() => vi.fn()) const mockGetNodes = vi.hoisted(() => vi.fn(() => [])) @@ -71,7 +76,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -227,12 +233,14 @@ describe('useWorkflowComment', () => { pendingComment: { pageX: 100, pageY: 200, elementX: 10, elementY: 20 }, isCommentQuickAdd: true, mentionableUsersCache: { - 'app-1': [{ - id: 'user-2', - name: 'Bob', - email: 'bob@example.com', - avatar_url: 'bob.png', - }], + 'app-1': [ + { + id: 'user-2', + name: 'Bob', + email: 'bob@example.com', + avatar_url: 'bob.png', + }, + ], }, }, }) @@ -262,7 +270,7 @@ describe('useWorkflowComment', () => { mention_count: 1, reply_count: 0, }) - expect(comments[0]?.participants.map(p => p.id)).toEqual(['user-1', 'user-2']) + expect(comments[0]?.participants.map((p) => p.id)).toEqual(['user-1', 'user-2']) expect(store.getState().commentDetailCache['comment-2']).toMatchObject({ content: 'new message', position_x: 10, @@ -312,12 +320,14 @@ describe('useWorkflowComment', () => { pendingComment: { pageX: 100, pageY: 200, elementX: 10, elementY: 20 }, isCommentQuickAdd: true, mentionableUsersCache: { - 'app-1': [{ - id: 'user-2', - name: 'Bob', - email: 'bob@example.com', - avatar_url: 'bob.png', - }], + 'app-1': [ + { + id: 'user-2', + name: 'Bob', + email: 'bob@example.com', + avatar_url: 'bob.png', + }, + ], }, }, }) @@ -331,18 +341,22 @@ describe('useWorkflowComment', () => { id: 'comment-date-time', created_at: expectedCreatedAt, updated_at: expectedCreatedAt, - participants: [{ - id: 'user-1', - name: 'Alice', - email: 'alice@example.com', - avatar_url: 'alice.png', - }], - }) - expect(store.getState().commentDetailCache['comment-date-time']?.mentions).toEqual([{ - mentioned_user_id: 'missing-user', - mentioned_user_account: null, - reply_id: null, - }]) + participants: [ + { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + avatar_url: 'alice.png', + }, + ], + }) + expect(store.getState().commentDetailCache['comment-date-time']?.mentions).toEqual([ + { + mentioned_user_id: 'missing-user', + mentioned_user_account: null, + reply_id: null, + }, + ]) }) it('rolls back optimistic position update when API update fails', async () => { @@ -460,11 +474,7 @@ describe('useWorkflowComment', () => { await waitFor(() => { expect(store.getState().activeCommentId).toBe(commentB.id) }) - expect(mockSetCenter).toHaveBeenCalledWith( - 502, - 80, - { zoom: 1, duration: 600 }, - ) + expect(mockSetCenter).toHaveBeenCalledWith(502, 80, { zoom: 1, duration: 600 }) act(() => { result.current.handleCreateComment({ @@ -525,7 +535,9 @@ describe('useWorkflowComment', () => { await act(async () => { await result.current.handleCommentReply(commentA.id, ' new reply ', ['user-2']) - await result.current.handleCommentReplyUpdate(commentA.id, 'reply-1', ' edited reply ', ['user-2']) + await result.current.handleCommentReplyUpdate(commentA.id, 'reply-1', ' edited reply ', [ + 'user-2', + ]) await result.current.handleCommentReplyDelete(commentA.id, 'reply-1') }) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx index 37c1b74d207548..3980062cd5633f 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx @@ -25,27 +25,31 @@ vi.mock('reactflow', async () => { }), } }) -const nodes: Node[] = [{ - id: 'node-1', - type: 'custom', - position: { x: 0, y: 0 }, - data: { - type: BlockEnum.Start, - title: 'Start', - desc: '', +const nodes: Node[] = [ + { + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + type: BlockEnum.Start, + title: 'Start', + desc: '', + }, }, -}] - -const edges: Edge[] = [{ - id: 'edge-1', - source: 'node-1', - target: 'node-2', - type: 'custom', - data: { - sourceType: BlockEnum.Start, - targetType: BlockEnum.End, +] + +const edges: Edge[] = [ + { + id: 'edge-1', + source: 'node-1', + target: 'node-2', + type: 'custom', + data: { + sourceType: BlockEnum.Start, + targetType: BlockEnum.End, + }, }, -}] +] describe('useWorkflowHistory', () => { beforeEach(() => { @@ -94,8 +98,12 @@ describe('useWorkflowHistory', () => { }, }) - expect(result.current.getHistoryLabel(WorkflowHistoryEvent.NodeDelete)).toEqual(expect.stringMatching(/(?:^|\.)changeHistory\.nodeDelete(?=$|:)/)) - expect(result.current.getHistoryLabel('Unknown' as keyof typeof WorkflowHistoryEvent)).toBe('Unknown Event') + expect(result.current.getHistoryLabel(WorkflowHistoryEvent.NodeDelete)).toEqual( + expect.stringMatching(/(?:^|\.)changeHistory\.nodeDelete(?=$|:)/), + ) + expect(result.current.getHistoryLabel('Unknown' as keyof typeof WorkflowHistoryEvent)).toBe( + 'Unknown Event', + ) }) it('runs registered undo and redo callbacks', () => { diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-organize.helpers.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-organize.helpers.spec.ts index 688c2724c9e5cc..3e4432b3961ac0 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-organize.helpers.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-organize.helpers.spec.ts @@ -11,7 +11,7 @@ type TestNode = { id: string type: string parentId?: string - position: { x: number, y: number } + position: { x: number; y: number } width: number height: number data: { @@ -23,15 +23,16 @@ type TestNode = { } } -const createNode = (overrides: Record<string, unknown> = {}) => ({ - id: 'node', - type: 'custom', - position: { x: 0, y: 0 }, - width: 100, - height: 80, - data: { type: BlockEnum.Code, title: 'Code', desc: '' }, - ...overrides, -}) as TestNode +const createNode = (overrides: Record<string, unknown> = {}) => + ({ + id: 'node', + type: 'custom', + position: { x: 0, y: 0 }, + width: 100, + height: 80, + data: { type: BlockEnum.Code, title: 'Code', desc: '' }, + ...overrides, + }) as TestNode describe('use-workflow-organize helpers', () => { it('filters top-level container nodes and computes size changes', () => { @@ -41,7 +42,7 @@ describe('use-workflow-organize helpers', () => { createNode({ id: 'nested-loop', parentId: 'loop', data: { type: BlockEnum.Loop } }), createNode({ id: 'code', data: { type: BlockEnum.Code } }), ]) - expect(containers.map(node => node.id)).toEqual(['loop', 'iteration']) + expect(containers.map((node) => node.id)).toEqual(['loop', 'iteration']) const sizeChanges = getContainerSizeChanges(containers, { loop: { @@ -79,11 +80,13 @@ describe('use-workflow-organize helpers', () => { expect(layerMap.get(0)).toEqual({ minY: 100, maxHeight: 80 }) const resized = applyContainerSizeChanges(rootNodes, { loop: { width: 260, height: 220 } }) - expect(resized.find(node => node.id === 'loop')).toEqual(expect.objectContaining({ - width: 260, - height: 220, - data: expect.objectContaining({ width: 260, height: 220 }), - })) + expect(resized.find((node) => node.id === 'loop')).toEqual( + expect.objectContaining({ + width: 260, + height: 220, + data: expect.objectContaining({ width: 260, height: 220 }), + }), + ) const laidOut = applyLayoutToNodes({ nodes: rootNodes, @@ -91,8 +94,8 @@ describe('use-workflow-organize helpers', () => { parentNodes: [rootNodes[2]!], childLayoutsMap, }) - expect(laidOut.find(node => node.id === 'root-b')?.position).toEqual({ x: 210, y: 100 }) - expect(laidOut.find(node => node.id === 'loop-child')?.position).toEqual({ x: 110, y: 80 }) + expect(laidOut.find((node) => node.id === 'root-b')?.position).toEqual({ x: 210, y: 100 }) + expect(laidOut.find((node) => node.id === 'loop-child')?.position).toEqual({ x: 110, y: 80 }) }) it('keeps original positions when layer or child layout data is missing', () => { @@ -104,9 +107,7 @@ describe('use-workflow-organize helpers', () => { ] const layout = { bounds: { minX: 0, minY: 0, maxX: 100, maxY: 100 }, - nodes: new Map([ - ['root-a', { x: 20, y: 30, width: 50, height: 20 }], - ]), + nodes: new Map([['root-a', { x: 20, y: 30, width: 50, height: 20 }]]), } as unknown as Parameters<typeof applyLayoutToNodes>[0]['layout'] const laidOut = applyLayoutToNodes({ @@ -116,8 +117,8 @@ describe('use-workflow-organize helpers', () => { childLayoutsMap: {}, }) - expect(laidOut.find(node => node.id === 'root-a')?.position).toEqual({ x: 20, y: 30 }) - expect(laidOut.find(node => node.id === 'root-b')?.position).toEqual({ x: 3, y: 4 }) - expect(laidOut.find(node => node.id === 'loop-child')?.position).toEqual({ x: 7, y: 8 }) + expect(laidOut.find((node) => node.id === 'root-a')?.position).toEqual({ x: 20, y: 30 }) + expect(laidOut.find((node) => node.id === 'root-b')?.position).toEqual({ x: 3, y: 4 }) + expect(laidOut.find((node) => node.id === 'loop-child')?.position).toEqual({ x: 7, y: 8 }) }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-organize.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-organize.spec.tsx index b3b4baa21c597b..918f4084e9f927 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-organize.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-organize.spec.tsx @@ -12,7 +12,7 @@ const mockGetLayoutByELK = vi.hoisted(() => vi.fn()) const runtimeState = vi.hoisted(() => ({ nodes: [] as ReturnType<typeof createNode>[], - edges: [] as { id: string, source: string, target: string }[], + edges: [] as { id: string; source: string; target: string }[], nodesReadOnly: false, })) @@ -58,7 +58,7 @@ vi.mock('../use-workflow-history', () => ({ }, })) -vi.mock('../../utils/elk-layout', async importOriginal => ({ +vi.mock('../../utils/elk-layout', async (importOriginal) => ({ ...(await importOriginal<typeof import('../../utils/elk-layout')>()), getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args), getLayoutByELK: (...args: unknown[]) => mockGetLayoutByELK(...args), @@ -99,9 +99,7 @@ describe('useWorkflowOrganize', () => { runtimeState.edges = [] mockGetLayoutForChildNodes.mockResolvedValue({ bounds: { minX: 0, minY: 0, maxX: 320, maxY: 220 }, - nodes: new Map([ - ['loop-child', { x: 40, y: 60, width: 100, height: 60 }], - ]), + nodes: new Map([['loop-child', { x: 40, y: 60, width: 100, height: 60 }]]), }) mockGetLayoutByELK.mockResolvedValue({ nodes: new Map([ @@ -121,14 +119,18 @@ describe('useWorkflowOrganize', () => { expect(mockSetNodes).toHaveBeenCalledTimes(1) const nextNodes = mockSetNodes.mock.calls[0]![0] - expect(nextNodes.find((node: { id: string }) => node.id === 'loop-node')).toEqual(expect.objectContaining({ - width: expect.any(Number), - height: expect.any(Number), - position: { x: 10, y: 20 }, - })) - expect(nextNodes.find((node: { id: string }) => node.id === 'loop-child')).toEqual(expect.objectContaining({ - position: { x: 100, y: 120 }, - })) + expect(nextNodes.find((node: { id: string }) => node.id === 'loop-node')).toEqual( + expect.objectContaining({ + width: expect.any(Number), + height: expect.any(Number), + position: { x: 10, y: 20 }, + }), + ) + expect(nextNodes.find((node: { id: string }) => node.id === 'loop-child')).toEqual( + expect.objectContaining({ + position: { x: 100, y: 120 }, + }), + ) expect(mockSetViewport).toHaveBeenCalledWith({ x: 0, y: 0, zoom: 0.7 }) expect(mockSaveStateToHistory).toHaveBeenCalledWith('LayoutOrganize') expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled() diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-panel-interactions.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-panel-interactions.spec.tsx index 30ad040f05013f..ccdc3b728b8079 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-panel-interactions.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-panel-interactions.spec.tsx @@ -1,10 +1,7 @@ import { act } from '@testing-library/react' import { renderWorkflowHook } from '../../__tests__/workflow-test-env' import { ControlMode, WorkflowRunningStatus } from '../../types' -import { - useWorkflowInteractions, - useWorkflowMoveMode, -} from '../use-workflow-panel-interactions' +import { useWorkflowInteractions, useWorkflowMoveMode } from '../use-workflow-panel-interactions' const mockHandleSelectionCancel = vi.hoisted(() => vi.fn()) const mockHandleNodeCancelRunningStatus = vi.hoisted(() => vi.fn()) @@ -29,13 +26,15 @@ vi.mock('../use-selection-interactions', () => ({ vi.mock('../use-nodes-interactions-without-sync', () => ({ useNodesInteractionsWithoutSync: () => ({ - handleNodeCancelRunningStatus: (...args: unknown[]) => mockHandleNodeCancelRunningStatus(...args), + handleNodeCancelRunningStatus: (...args: unknown[]) => + mockHandleNodeCancelRunningStatus(...args), }), })) vi.mock('../use-edges-interactions-without-sync', () => ({ useEdgesInteractionsWithoutSync: () => ({ - handleEdgeCancelRunningStatus: (...args: unknown[]) => mockHandleEdgeCancelRunningStatus(...args), + handleEdgeCancelRunningStatus: (...args: unknown[]) => + mockHandleEdgeCancelRunningStatus(...args), }), })) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run.spec.ts index ff8c64656e4be5..fcf8393c14fea5 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-run.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-run.spec.ts @@ -17,7 +17,9 @@ describe('useWorkflowRun', () => { expect(result.current.handleBackupDraft).toBe(handlers.handleBackupDraft) expect(result.current.handleLoadBackupDraft).toBe(handlers.handleLoadBackupDraft) - expect(result.current.handleRestoreFromPublishedWorkflow).toBe(handlers.handleRestoreFromPublishedWorkflow) + expect(result.current.handleRestoreFromPublishedWorkflow).toBe( + handlers.handleRestoreFromPublishedWorkflow, + ) expect(result.current.handleRun).toBe(handlers.handleRun) expect(result.current.handleStopRun).toBe(handlers.handleStopRun) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx index 4e9f4c9b45ddd2..c3d43a921fcdb2 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx @@ -20,11 +20,13 @@ vi.mock('../use-nodes-interactions', () => ({ vi.mock('@/service/use-tools', () => ({ useAllBuiltInTools: () => ({ - data: [{ - id: 'provider-1', - icon: 'tool-icon', - tools: [], - }] satisfies Partial<ToolWithProvider>[], + data: [ + { + id: 'provider-1', + icon: 'tool-icon', + tools: [], + }, + ] satisfies Partial<ToolWithProvider>[], }), useAllCustomTools: () => ({ data: [] }), useAllWorkflowTools: () => ({ data: [] }), @@ -88,11 +90,11 @@ describe('useWorkflowSearch', () => { const { unmount } = renderHook(() => useWorkflowSearch()) const llmResults = await workflowNodesAction.search('', 'gpt') - expect(llmResults.map(item => item.id)).toEqual(['llm-1']) + expect(llmResults.map((item) => item.id)).toEqual(['llm-1']) expect(llmResults[0]?.title).toBe('Writer') const toolResults = await workflowNodesAction.search('', 'search') - expect(toolResults.map(item => item.id)).toEqual(['tool-1']) + expect(toolResults.map((item) => item.id)).toEqual(['tool-1']) expect(toolResults[0]?.description).toBe('Search the web') unmount() @@ -104,12 +106,14 @@ describe('useWorkflowSearch', () => { const { unmount } = renderHook(() => useWorkflowSearch()) act(() => { - document.dispatchEvent(new CustomEvent('workflow:select-node', { - detail: { - nodeId: 'node-42', - focus: false, - }, - })) + document.dispatchEvent( + new CustomEvent('workflow:select-node', { + detail: { + nodeId: 'node-42', + focus: false, + }, + }), + ) }) expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-42') diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-start-run.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-start-run.spec.tsx index fdde912285ec0d..00c7d6e7d6b425 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-start-run.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-start-run.spec.tsx @@ -18,11 +18,23 @@ describe('useWorkflowStartRun', () => { }) expect(result.current.handleStartWorkflowRun).toBe(handlers.handleStartWorkflowRun) - expect(result.current.handleWorkflowStartRunInWorkflow).toBe(handlers.handleWorkflowStartRunInWorkflow) - expect(result.current.handleWorkflowStartRunInChatflow).toBe(handlers.handleWorkflowStartRunInChatflow) - expect(result.current.handleWorkflowTriggerScheduleRunInWorkflow).toBe(handlers.handleWorkflowTriggerScheduleRunInWorkflow) - expect(result.current.handleWorkflowTriggerWebhookRunInWorkflow).toBe(handlers.handleWorkflowTriggerWebhookRunInWorkflow) - expect(result.current.handleWorkflowTriggerPluginRunInWorkflow).toBe(handlers.handleWorkflowTriggerPluginRunInWorkflow) - expect(result.current.handleWorkflowRunAllTriggersInWorkflow).toBe(handlers.handleWorkflowRunAllTriggersInWorkflow) + expect(result.current.handleWorkflowStartRunInWorkflow).toBe( + handlers.handleWorkflowStartRunInWorkflow, + ) + expect(result.current.handleWorkflowStartRunInChatflow).toBe( + handlers.handleWorkflowStartRunInChatflow, + ) + expect(result.current.handleWorkflowTriggerScheduleRunInWorkflow).toBe( + handlers.handleWorkflowTriggerScheduleRunInWorkflow, + ) + expect(result.current.handleWorkflowTriggerWebhookRunInWorkflow).toBe( + handlers.handleWorkflowTriggerWebhookRunInWorkflow, + ) + expect(result.current.handleWorkflowTriggerPluginRunInWorkflow).toBe( + handlers.handleWorkflowTriggerPluginRunInWorkflow, + ) + expect(result.current.handleWorkflowRunAllTriggersInWorkflow).toBe( + handlers.handleWorkflowRunAllTriggersInWorkflow, + ) }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-update.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-update.spec.tsx index 8bd2a1c4f36c20..9ffda61fb191be 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-update.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-update.spec.tsx @@ -28,7 +28,7 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -vi.mock('../../utils', async importOriginal => ({ +vi.mock('../../utils', async (importOriginal) => ({ ...(await importOriginal<typeof import('../../utils')>()), initialNodes: (nodes: unknown[], edges: unknown[]) => mockInitialNodes(nodes, edges), initialEdges: (edges: unknown[], nodes: unknown[]) => mockInitialEdges(edges, nodes), @@ -57,9 +57,11 @@ describe('useWorkflowUpdate', () => { expect(mockInitialNodes).toHaveBeenCalled() expect(mockInitialEdges).toHaveBeenCalled() - expect(mockEventEmit).toHaveBeenCalledWith(expect.objectContaining({ - type: 'WORKFLOW_DATA_UPDATE', - })) + expect(mockEventEmit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'WORKFLOW_DATA_UPDATE', + }), + ) expect(mockSetViewport).toHaveBeenCalledTimes(1) expect(mockSetViewport).toHaveBeenCalledWith({ x: 10, y: 20, zoom: 0.5 }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts index 70079d724e7706..d77b1befff7aa8 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts @@ -3,10 +3,12 @@ import { renderWorkflowHook } from '../../__tests__/workflow-test-env' import { useWorkflowVariables, useWorkflowVariableType } from '../use-workflow-variables' vi.mock('reactflow', async () => - (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), +) vi.mock('@/service/use-tools', async () => - (await import('../../__tests__/service-mock-factory')).createToolServiceMock()) + (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), +) const { mockToNodeAvailableVars, mockGetVarType } = vi.hoisted(() => ({ mockToNodeAvailableVars: vi.fn((_args: Record<string, unknown>) => [] as unknown[]), diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts index 99c1a0d57b3ac0..6164a7bf2ba934 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts @@ -1,7 +1,11 @@ import type { NodeDefault } from '../../types' import { act, renderHook } from '@testing-library/react' import { createNode } from '../../__tests__/fixtures' -import { baseRunningData, renderWorkflowFlowHook, renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { + baseRunningData, + renderWorkflowFlowHook, + renderWorkflowHook, +} from '../../__tests__/workflow-test-env' import { BlockClassificationEnum } from '../../block-selector/types' import { BlockEnum, WorkflowRunningStatus } from '../../types' import { @@ -16,7 +20,8 @@ import { let mockAppMode = 'workflow' vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { appDetail: { mode: string } }) => unknown) => selector({ appDetail: { mode: mockAppMode } }), + useStore: (selector: (state: { appDetail: { mode: string } }) => unknown) => + selector({ appDetail: { mode: mockAppMode } }), })) beforeEach(() => { @@ -85,7 +90,9 @@ describe('useWorkflowReadOnly', () => { it('should return workflowReadOnly false when status is Succeeded', () => { const { result } = renderWorkflowHook(() => useWorkflowReadOnly(), { initialStoreState: { - workflowRunningData: baseRunningData({ result: { status: WorkflowRunningStatus.Succeeded } }), + workflowRunningData: baseRunningData({ + result: { status: WorkflowRunningStatus.Succeeded }, + }), }, }) expect(result.current.workflowReadOnly).toBe(false) @@ -265,12 +272,14 @@ describe('useWorkflow connection validation', () => { }, }) - expect(result.current.isValidConnection({ - source: 'agent-v2', - sourceHandle: 'source', - target: 'code', - targetHandle: 'target', - })).toBe(true) + expect( + result.current.isValidConnection({ + source: 'agent-v2', + sourceHandle: 'source', + target: 'code', + targetHandle: 'target', + }), + ).toBe(true) }) }) diff --git a/web/app/components/workflow/hooks/use-DSL.ts b/web/app/components/workflow/hooks/use-DSL.ts index 2470ca7b4662be..6a408962009b57 100644 --- a/web/app/components/workflow/hooks/use-DSL.ts +++ b/web/app/components/workflow/hooks/use-DSL.ts @@ -1,8 +1,8 @@ import { useHooksStore } from '@/app/components/workflow/hooks-store' export const useDSL = () => { - const exportCheck = useHooksStore(s => s.exportCheck) - const handleExportDSL = useHooksStore(s => s.handleExportDSL) + const exportCheck = useHooksStore((s) => s.exportCheck) + const handleExportDSL = useHooksStore((s) => s.handleExportDSL) return { exportCheck, diff --git a/web/app/components/workflow/hooks/use-auto-generate-webhook-url.ts b/web/app/components/workflow/hooks/use-auto-generate-webhook-url.ts index 84dda1087ddcb1..7794ab3260b225 100644 --- a/web/app/components/workflow/hooks/use-auto-generate-webhook-url.ts +++ b/web/app/components/workflow/hooks/use-auto-generate-webhook-url.ts @@ -8,41 +8,38 @@ import { useCollaborativeWorkflow } from './use-collaborative-workflow' export const useAutoGenerateWebhookUrl = () => { const collaborativeWorkflow = useCollaborativeWorkflow() - return useCallback(async (nodeId: string) => { - const appId = useAppStore.getState().appDetail?.id - if (!appId) - return + return useCallback( + async (nodeId: string) => { + const appId = useAppStore.getState().appDetail?.id + if (!appId) return - const { nodes } = collaborativeWorkflow.getState() - const node = nodes.find(n => n.id === nodeId) - if (!node || node.data.type !== BlockEnum.TriggerWebhook) - return + const { nodes } = collaborativeWorkflow.getState() + const node = nodes.find((n) => n.id === nodeId) + if (!node || node.data.type !== BlockEnum.TriggerWebhook) return - if (node.data.webhook_url && node.data.webhook_url.length > 0) - return + if (node.data.webhook_url && node.data.webhook_url.length > 0) return - try { - const response = await fetchWebhookUrl({ appId, nodeId }) - const { nodes: latestNodes, setNodes } = collaborativeWorkflow.getState() - let hasUpdated = false - const updatedNodes = produce(latestNodes, (draft) => { - const targetNode = draft.find(n => n.id === nodeId) - if (!targetNode || targetNode.data.type !== BlockEnum.TriggerWebhook) - return + try { + const response = await fetchWebhookUrl({ appId, nodeId }) + const { nodes: latestNodes, setNodes } = collaborativeWorkflow.getState() + let hasUpdated = false + const updatedNodes = produce(latestNodes, (draft) => { + const targetNode = draft.find((n) => n.id === nodeId) + if (!targetNode || targetNode.data.type !== BlockEnum.TriggerWebhook) return - targetNode.data = { - ...targetNode.data, - webhook_url: response.webhook_url, - webhook_debug_url: response.webhook_debug_url, - } - hasUpdated = true - }) + targetNode.data = { + ...targetNode.data, + webhook_url: response.webhook_url, + webhook_debug_url: response.webhook_debug_url, + } + hasUpdated = true + }) - if (hasUpdated) - setNodes(updatedNodes) - } - catch (error: unknown) { - console.error('Failed to auto-generate webhook URL:', error) - } - }, [collaborativeWorkflow]) + if (hasUpdated) setNodes(updatedNodes) + } catch (error: unknown) { + console.error('Failed to auto-generate webhook URL:', error) + } + }, + [collaborativeWorkflow], + ) } diff --git a/web/app/components/workflow/hooks/use-available-blocks.ts b/web/app/components/workflow/hooks/use-available-blocks.ts index c42eee5345ff0c..675a36be49adc7 100644 --- a/web/app/components/workflow/hooks/use-available-blocks.ts +++ b/web/app/components/workflow/hooks/use-available-blocks.ts @@ -1,64 +1,100 @@ -import { - useCallback, - useMemo, -} from 'react' +import { useCallback, useMemo } from 'react' import { BlockEnum } from '../types' import { useNodesMetaData } from './use-nodes-meta-data' const availableBlocksFilter = (nodeType: BlockEnum, inContainer?: boolean) => { - if (nodeType === BlockEnum.StartPlaceholder) - return false + if (nodeType === BlockEnum.StartPlaceholder) return false - if (inContainer && (nodeType === BlockEnum.Iteration || nodeType === BlockEnum.Loop || nodeType === BlockEnum.End || nodeType === BlockEnum.DataSource || nodeType === BlockEnum.KnowledgeBase || nodeType === BlockEnum.HumanInput)) + if ( + inContainer && + (nodeType === BlockEnum.Iteration || + nodeType === BlockEnum.Loop || + nodeType === BlockEnum.End || + nodeType === BlockEnum.DataSource || + nodeType === BlockEnum.KnowledgeBase || + nodeType === BlockEnum.HumanInput) + ) return false - if (!inContainer && nodeType === BlockEnum.LoopEnd) - return false + if (!inContainer && nodeType === BlockEnum.LoopEnd) return false return true } export const useAvailableBlocks = (nodeType?: BlockEnum, inContainer?: boolean) => { - const { - nodes: availableNodes, - } = useNodesMetaData() - const availableNodesType = useMemo(() => availableNodes.map(node => node.metaData.type), [availableNodes]) + const { nodes: availableNodes } = useNodesMetaData() + const availableNodesType = useMemo( + () => availableNodes.map((node) => node.metaData.type), + [availableNodes], + ) const availablePrevBlocks = useMemo(() => { - if (!nodeType || nodeType === BlockEnum.Start || nodeType === BlockEnum.StartPlaceholder || nodeType === BlockEnum.DataSource - || nodeType === BlockEnum.TriggerPlugin || nodeType === BlockEnum.TriggerWebhook - || nodeType === BlockEnum.TriggerSchedule) { + if ( + !nodeType || + nodeType === BlockEnum.Start || + nodeType === BlockEnum.StartPlaceholder || + nodeType === BlockEnum.DataSource || + nodeType === BlockEnum.TriggerPlugin || + nodeType === BlockEnum.TriggerWebhook || + nodeType === BlockEnum.TriggerSchedule + ) { return [] } return availableNodesType }, [availableNodesType, nodeType]) const availableNextBlocks = useMemo(() => { - if (!nodeType || nodeType === BlockEnum.StartPlaceholder || nodeType === BlockEnum.LoopEnd || nodeType === BlockEnum.KnowledgeBase) + if ( + !nodeType || + nodeType === BlockEnum.StartPlaceholder || + nodeType === BlockEnum.LoopEnd || + nodeType === BlockEnum.KnowledgeBase + ) return [] return availableNodesType }, [availableNodesType, nodeType]) - const getAvailableBlocks = useCallback((nodeType?: BlockEnum, inContainer?: boolean) => { - let availablePrevBlocks = availableNodesType - if (!nodeType || nodeType === BlockEnum.Start || nodeType === BlockEnum.StartPlaceholder || nodeType === BlockEnum.DataSource) - availablePrevBlocks = [] + const getAvailableBlocks = useCallback( + (nodeType?: BlockEnum, inContainer?: boolean) => { + let availablePrevBlocks = availableNodesType + if ( + !nodeType || + nodeType === BlockEnum.Start || + nodeType === BlockEnum.StartPlaceholder || + nodeType === BlockEnum.DataSource + ) + availablePrevBlocks = [] - let availableNextBlocks = availableNodesType - if (!nodeType || nodeType === BlockEnum.StartPlaceholder || nodeType === BlockEnum.LoopEnd || nodeType === BlockEnum.KnowledgeBase) - availableNextBlocks = [] + let availableNextBlocks = availableNodesType + if ( + !nodeType || + nodeType === BlockEnum.StartPlaceholder || + nodeType === BlockEnum.LoopEnd || + nodeType === BlockEnum.KnowledgeBase + ) + availableNextBlocks = [] - return { - availablePrevBlocks: availablePrevBlocks.filter(nType => availableBlocksFilter(nType, inContainer)), - availableNextBlocks: availableNextBlocks.filter(nType => availableBlocksFilter(nType, inContainer)), - } - }, [availableNodesType]) + return { + availablePrevBlocks: availablePrevBlocks.filter((nType) => + availableBlocksFilter(nType, inContainer), + ), + availableNextBlocks: availableNextBlocks.filter((nType) => + availableBlocksFilter(nType, inContainer), + ), + } + }, + [availableNodesType], + ) return useMemo(() => { return { getAvailableBlocks, - availablePrevBlocks: availablePrevBlocks.filter(nType => availableBlocksFilter(nType, inContainer)), - availableNextBlocks: availableNextBlocks.filter(nType => availableBlocksFilter(nType, inContainer)), + availablePrevBlocks: availablePrevBlocks.filter((nType) => + availableBlocksFilter(nType, inContainer), + ), + availableNextBlocks: availableNextBlocks.filter((nType) => + availableBlocksFilter(nType, inContainer), + ), } }, [getAvailableBlocks, availablePrevBlocks, availableNextBlocks, inContainer]) } diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index 346a5195446d2c..1176b34429766e 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -20,12 +20,7 @@ import type { I18nKeysWithPrefix } from '@/types/i18n' import { toast } from '@langgenius/dify-ui/toast' import { useQueries, useQueryClient } from '@tanstack/react-query' import isDeepEqual from 'fast-deep-equal' -import { - useCallback, - useEffect, - useMemo, - useRef, -} from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useEdges, useStoreApi } from 'reactflow' import { useStore as useAppStore } from '@/app/components/app/store' @@ -47,23 +42,19 @@ import { } from '@/service/use-tools' import { useAllTriggerPlugins } from '@/service/use-triggers' import { AppModeEnum } from '@/types/app' -import { - CUSTOM_NODE, -} from '../constants' +import { CUSTOM_NODE } from '../constants' import { useDatasetsDetailStore } from '../datasets-detail-store/store' -import { - useGetToolIcon, - useNodesMetaData, -} from '../hooks' +import { useGetToolIcon, useNodesMetaData } from '../hooks' import { useHooksStore } from '../hooks-store/store' import { getNodeUsedVars, isSpecialVar } from '../nodes/_base/components/variable/utils' import { isAgentV2NodeData } from '../nodes/agent-v2/types' import { IndexMethodEnum } from '../nodes/knowledge-base/types' -import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from '../nodes/llm/utils' import { - useStore, - useWorkflowStore, -} from '../store' + getLLMModelIssue, + isLLMModelProviderInstalled, + LLMModelIssueCode, +} from '../nodes/llm/utils' +import { useStore, useWorkflowStore } from '../store' import { BlockEnum } from '../types' import { getDataSourceCheckParams, @@ -74,7 +65,9 @@ import { import { extractPluginId } from '../utils/plugin' import { isNodePluginMissing } from '../utils/plugin-install-check' import { getTriggerCheckParams } from '../utils/trigger' -import useNodesAvailableVarList, { useGetNodesAvailableVarList } from './use-nodes-available-var-list' +import useNodesAvailableVarList, { + useGetNodesAvailableVarList, +} from './use-nodes-available-var-list' export type ChecklistItem = { id: string @@ -92,8 +85,7 @@ export type ChecklistItem = { type CheckValidExtraData = Record<string, unknown> | undefined const withFlowType = (moreDataForCheckValid: CheckValidExtraData, flowType?: FlowType) => { - if (!flowType) - return moreDataForCheckValid + if (!flowType) return moreDataForCheckValid return { ...(moreDataForCheckValid ?? {}), @@ -115,14 +107,12 @@ const getDuplicateEndOutputMessages = ( const variableOccurrences = new Map<string, string[]>() nodes.forEach((node) => { - if (node.type !== CUSTOM_NODE || node.data.type !== BlockEnum.End) - return + if (node.type !== CUSTOM_NODE || node.data.type !== BlockEnum.End) return - const outputs = ((node.data as { outputs?: Array<{ variable?: string }> }).outputs) || [] + const outputs = (node.data as { outputs?: Array<{ variable?: string }> }).outputs || [] outputs.forEach((output) => { const variable = output.variable?.trim() - if (!variable) - return + if (!variable) return const occurrences = variableOccurrences.get(variable) || [] occurrences.push(node.id) @@ -132,12 +122,11 @@ const getDuplicateEndOutputMessages = ( const nodeMessages = new Map<string, string[]>() variableOccurrences.forEach((nodeIds, variable) => { - if (nodeIds.length <= 1) - return + if (nodeIds.length <= 1) return Array.from(new Set(nodeIds)).forEach((nodeId) => { const messages = nodeMessages.get(nodeId) || [] - messages.push(t($ => $['errorMsg.duplicateOutputVariable'], { ns: 'workflow', variable })) + messages.push(t(($) => $['errorMsg.duplicateOutputVariable'], { ns: 'workflow', variable })) nodeMessages.set(nodeId, messages) }) }) @@ -153,14 +142,15 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() - const dataSourceList = useStore(s => s.dataSourceList) + const dataSourceList = useStore((s) => s.dataSourceList) const { data: strategyProviders } = useStrategyProviders() const { data: triggerPlugins } = useAllTriggerPlugins() - const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail) + const datasetsDetail = useDatasetsDetailStore((s) => s.datasetsDetail) const getToolIcon = useGetToolIcon() const appMode = useAppStore.getState().appDetail?.mode - const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT - const modelProviders = useProviderContextSelector(s => s.modelProviders) + const shouldCheckStartNode = + appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT + const modelProviders = useProviderContextSelector((s) => s.modelProviders) const workflowStore = useWorkflowStore() const map = useNodesAvailableVarList(nodes) @@ -170,22 +160,19 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? const providers = new Set<string>() nodes.forEach((node) => { - if (node.type !== CUSTOM_NODE || node.data.type !== BlockEnum.KnowledgeBase) - return + if (node.type !== CUSTOM_NODE || node.data.type !== BlockEnum.KnowledgeBase) return const knowledgeBaseData = node.data as CommonNodeType<KnowledgeBaseNodeType> - if (knowledgeBaseData.indexing_technique !== IndexMethodEnum.QUALIFIED) - return + if (knowledgeBaseData.indexing_technique !== IndexMethodEnum.QUALIFIED) return const provider = knowledgeBaseData.embedding_model_provider - if (provider) - providers.add(provider) + if (provider) providers.add(provider) }) return [...providers] }, [nodes]) const knowledgeBaseProviderModelMap = useQueries({ - queries: knowledgeBaseEmbeddingProviders.map(provider => + queries: knowledgeBaseEmbeddingProviders.map((provider) => consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({ input: { params: { provider } }, enabled: !!provider, @@ -197,45 +184,48 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? const modelMap: Partial<Record<string, ModelItem[]>> = {} knowledgeBaseEmbeddingProviders.forEach((provider, index) => { const models = results[index]?.data - if (models) - modelMap[provider] = models + if (models) modelMap[provider] = models }) return modelMap }, }) - const getCheckData = useCallback((data: CommonNodeType<{}>) => { - let checkData = data - if (data.type === BlockEnum.KnowledgeRetrieval) { - const datasetIds = (data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids - const _datasets = datasetIds.reduce<DataSet[]>((acc, id) => { - if (datasetsDetail[id]) - acc.push(datasetsDetail[id]) - return acc - }, []) - checkData = { - ...data, - _datasets, - } as CommonNodeType<KnowledgeRetrievalNodeType> - } - else if (data.type === BlockEnum.KnowledgeBase) { - const modelProviderName = (data as CommonNodeType<KnowledgeBaseNodeType>).embedding_model_provider - checkData = { - ...data, - _embeddingModelList: embeddingModelList, - _embeddingProviderModelList: modelProviderName ? knowledgeBaseProviderModelMap[modelProviderName] : undefined, - _rerankModelList: rerankModelList, - } as CommonNodeType<KnowledgeBaseNodeType> - } - return checkData - }, [datasetsDetail, embeddingModelList, knowledgeBaseProviderModelMap, rerankModelList]) + const getCheckData = useCallback( + (data: CommonNodeType<{}>) => { + let checkData = data + if (data.type === BlockEnum.KnowledgeRetrieval) { + const datasetIds = (data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids + const _datasets = datasetIds.reduce<DataSet[]>((acc, id) => { + if (datasetsDetail[id]) acc.push(datasetsDetail[id]) + return acc + }, []) + checkData = { + ...data, + _datasets, + } as CommonNodeType<KnowledgeRetrievalNodeType> + } else if (data.type === BlockEnum.KnowledgeBase) { + const modelProviderName = (data as CommonNodeType<KnowledgeBaseNodeType>) + .embedding_model_provider + checkData = { + ...data, + _embeddingModelList: embeddingModelList, + _embeddingProviderModelList: modelProviderName + ? knowledgeBaseProviderModelMap[modelProviderName] + : undefined, + _rerankModelList: rerankModelList, + } as CommonNodeType<KnowledgeBaseNodeType> + } + return checkData + }, + [datasetsDetail, embeddingModelList, knowledgeBaseProviderModelMap, rerankModelList], + ) const needWarningNodes = useMemo<ChecklistItem[]>(() => { const list: ChecklistItem[] = [] - const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE) + const filteredNodes = nodes.filter((node) => node.type === CUSTOM_NODE) const duplicateEndOutputMessages = getDuplicateEndOutputMessages(filteredNodes, t) const { validNodes } = getValidTreeNodes(filteredNodes, edges) - const installedPluginIds = new Set(modelProviders.map(p => extractPluginId(p.provider))) + const installedPluginIds = new Set(modelProviders.map((p) => extractPluginId(p.provider))) for (let i = 0; i < filteredNodes.length; i++) { const node = filteredNodes[i] @@ -243,81 +233,113 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? let usedVars: ValueSelector[] = [] if (node!.data.type === BlockEnum.Tool) - moreDataForCheckValid = getToolCheckParams(node!.data as ToolNodeType, buildInTools || [], customTools || [], workflowTools || [], language) + moreDataForCheckValid = getToolCheckParams( + node!.data as ToolNodeType, + buildInTools || [], + customTools || [], + workflowTools || [], + language, + ) if (node!.data.type === BlockEnum.DataSource) - moreDataForCheckValid = getDataSourceCheckParams(node!.data as DataSourceNodeType, dataSourceList || [], language) + moreDataForCheckValid = getDataSourceCheckParams( + node!.data as DataSourceNodeType, + dataSourceList || [], + language, + ) if (node!.data.type === BlockEnum.TriggerPlugin) - moreDataForCheckValid = getTriggerCheckParams(node!.data as PluginTriggerNodeType, triggerPlugins, language) + moreDataForCheckValid = getTriggerCheckParams( + node!.data as PluginTriggerNodeType, + triggerPlugins, + language, + ) const toolIcon = getToolIcon(node!.data) if (node!.data.type === BlockEnum.Agent && !isAgentV2NodeData(node!.data)) { const data = node!.data as AgentNodeType const isReadyForCheckValid = !!strategyProviders - const provider = strategyProviders?.find(provider => provider.declaration.identity.name === data.agent_strategy_provider_name) - const strategy = provider?.declaration.strategies?.find(s => s.identity.name === data.agent_strategy_name) + const provider = strategyProviders?.find( + (provider) => provider.declaration.identity.name === data.agent_strategy_provider_name, + ) + const strategy = provider?.declaration.strategies?.find( + (s) => s.identity.name === data.agent_strategy_name, + ) moreDataForCheckValid = { provider, strategy, language, isReadyForCheckValid, } - } - else { - usedVars = getNodeUsedVars(node!).filter(v => v.length > 0) + } else { + usedVars = getNodeUsedVars(node!).filter((v) => v.length > 0) } if (node!.type === CUSTOM_NODE) { const checkData = getCheckData(node!.data) const validator = nodesExtraData?.[getNodeCatalogType(node!.data)]?.checkValid - const isPluginMissing = isNodePluginMissing(node!.data, { builtInTools: buildInTools, customTools, workflowTools, mcpTools, triggerPlugins, dataSourceList }) + const isPluginMissing = isNodePluginMissing(node!.data, { + builtInTools: buildInTools, + customTools, + workflowTools, + mcpTools, + triggerPlugins, + dataSourceList, + }) const errorMessages: string[] = [] if (isPluginMissing) { - errorMessages.push(t($ => $['nodes.common.pluginNotInstalled'], { ns: 'workflow' })) - } - else { + errorMessages.push(t(($) => $['nodes.common.pluginNotInstalled'], { ns: 'workflow' })) + } else { if (node!.data.type === BlockEnum.LLM) { - const modelProvider = (node!.data as CommonNodeType<{ model?: ModelConfig }>).model?.provider + const modelProvider = (node!.data as CommonNodeType<{ model?: ModelConfig }>).model + ?.provider const modelIssue = getLLMModelIssue({ modelProvider, - isModelProviderInstalled: isLLMModelProviderInstalled(modelProvider, installedPluginIds), + isModelProviderInstalled: isLLMModelProviderInstalled( + modelProvider, + installedPluginIds, + ), }) if (modelIssue === LLMModelIssueCode.providerPluginUnavailable) - errorMessages.push(t($ => $['errorMsg.configureModel'], { ns: 'workflow' })) + errorMessages.push(t(($) => $['errorMsg.configureModel'], { ns: 'workflow' })) } if (validator) { - const validationError = validator(checkData, t, withFlowType(moreDataForCheckValid, options?.flowType)).errorMessage - if (validationError) - errorMessages.push(validationError) + const validationError = validator( + checkData, + t, + withFlowType(moreDataForCheckValid, options?.flowType), + ).errorMessage + if (validationError) errorMessages.push(validationError) } const availableVars = map[node!.id]!.availableVars let hasInvalidVar = false for (const variable of usedVars) { - if (hasInvalidVar) - break - if (isSpecialVar(variable[0]!)) - continue - const usedNode = availableVars.find(v => v.nodeId === variable?.[0]) - if (!usedNode || !usedNode.vars.some(v => v.variable === variable?.[1])) + if (hasInvalidVar) break + if (isSpecialVar(variable[0]!)) continue + const usedNode = availableVars.find((v) => v.nodeId === variable?.[0]) + if (!usedNode || !usedNode.vars.some((v) => v.variable === variable?.[1])) hasInvalidVar = true } if (hasInvalidVar) - errorMessages.push(t($ => $['errorMsg.invalidVariable'], { ns: 'workflow' })) + errorMessages.push(t(($) => $['errorMsg.invalidVariable'], { ns: 'workflow' })) errorMessages.push(...(duplicateEndOutputMessages.get(node!.id) || [])) } - const isStartNodeMeta = nodesExtraData?.[node!.data.type as BlockEnum]?.metaData.isStart ?? false + const isStartNodeMeta = + nodesExtraData?.[node!.data.type as BlockEnum]?.metaData.isStart ?? false const isStartPlaceholderNode = node!.data.type === BlockEnum.StartPlaceholder - const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta || isStartPlaceholderNode : true + const canSkipConnectionCheck = shouldCheckStartNode + ? isStartNodeMeta || isStartPlaceholderNode + : true - const isUnconnected = !validNodes.some(n => n.id === node!.id) - const shouldShowError = errorMessages.length > 0 || (isUnconnected && !canSkipConnectionCheck) + const isUnconnected = !validNodes.some((n) => n.id === node!.id) + const shouldShowError = + errorMessages.length > 0 || (isUnconnected && !canSkipConnectionCheck) if (shouldShowError) { list.push({ @@ -340,40 +362,73 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? // Check for start nodes (including triggers) if (shouldCheckStartNode) { - const startNodesFiltered = nodes.filter(node => START_NODE_TYPES.includes(node.data.type as BlockEnum)) - const hasStartPlaceholderNode = nodes.some(node => node.data.type === BlockEnum.StartPlaceholder) + const startNodesFiltered = nodes.filter((node) => + START_NODE_TYPES.includes(node.data.type as BlockEnum), + ) + const hasStartPlaceholderNode = nodes.some( + (node) => node.data.type === BlockEnum.StartPlaceholder, + ) if (startNodesFiltered.length === 0 && !hasStartPlaceholderNode) { list.push({ id: 'start-node-required', type: BlockEnum.Start, - title: t($ => $['panel.startNode'], { ns: 'workflow' }), - errorMessages: [t($ => $['common.needStartNode'], { ns: 'workflow' })], + title: t(($) => $['panel.startNode'], { ns: 'workflow' }), + errorMessages: [t(($) => $['common.needStartNode'], { ns: 'workflow' })], canNavigate: false, }) } } - const isRequiredNodesType = Object.keys(nodesExtraData!).filter((key: any) => (nodesExtraData as any)[key].metaData.isRequired) + const isRequiredNodesType = Object.keys(nodesExtraData!).filter( + (key: any) => (nodesExtraData as any)[key].metaData.isRequired, + ) isRequiredNodesType.forEach((type: string) => { - if (!filteredNodes.some(node => node.data.type === type)) { + if (!filteredNodes.some((node) => node.data.type === type)) { list.push({ id: `${type}-need-added`, type, - title: t($ => $[`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>], { ns: 'workflow' }), - errorMessages: [t($ => $['common.needAdd'], { ns: 'workflow', node: t($ => $[`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>], { ns: 'workflow' }) })], + title: t(($) => $[`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>], { + ns: 'workflow', + }), + errorMessages: [ + t(($) => $['common.needAdd'], { + ns: 'workflow', + node: t(($) => $[`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>], { + ns: 'workflow', + }), + }), + ], canNavigate: false, }) } }) return list - }, [nodes, edges, shouldCheckStartNode, nodesExtraData, buildInTools, customTools, workflowTools, mcpTools, language, dataSourceList, triggerPlugins, getToolIcon, strategyProviders, getCheckData, t, map, modelProviders, options?.flowType]) + }, [ + nodes, + edges, + shouldCheckStartNode, + nodesExtraData, + buildInTools, + customTools, + workflowTools, + mcpTools, + language, + dataSourceList, + triggerPlugins, + getToolIcon, + strategyProviders, + getCheckData, + t, + map, + modelProviders, + options?.flowType, + ]) useEffect(() => { const currentChecklistItems = workflowStore.getState().checklistItems - if (isDeepEqual(currentChecklistItems, needWarningNodes)) - return + if (isDeepEqual(currentChecklistItems, needWarningNodes)) return workflowStore.setState({ checklistItems: needWarningNodes }) }, [needWarningNodes, workflowStore]) @@ -388,8 +443,8 @@ export const useChecklistBeforePublish = () => { const store = useStoreApi() const { nodesMap: nodesExtraData } = useNodesMetaData() const { data: strategyProviders } = useStrategyProviders() - const modelProviders = useProviderContextSelector(s => s.modelProviders) - const updateDatasetsDetail = useDatasetsDetailStore(s => s.updateDatasetsDetail) + const modelProviders = useProviderContextSelector((s) => s.modelProviders) + const updateDatasetsDetail = useDatasetsDetailStore((s) => s.updateDatasetsDetail) const updateTimeRef = useRef(0) const workflowStore = useWorkflowStore() const { getNodesAvailableVarList } = useGetNodesAvailableVarList() @@ -398,110 +453,110 @@ export const useChecklistBeforePublish = () => { const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() - const flowType = useHooksStore(s => s.configsMap?.flowType) + const flowType = useHooksStore((s) => s.configsMap?.flowType) const appMode = useAppStore.getState().appDetail?.mode - const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT - - const getCheckData = useCallback(( - data: CommonNodeType<object>, - datasets: DataSet[], - embeddingProviderModelMap?: Partial<Record<string, ModelItem[]>>, - ) => { - let checkData = data - if (data.type === BlockEnum.KnowledgeRetrieval) { - const datasetIds = (data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids - const datasetsDetail = datasets.reduce<Record<string, DataSet>>((acc, dataset) => { - acc[dataset.id] = dataset - return acc - }, {}) - const _datasets = datasetIds.reduce<DataSet[]>((acc, id) => { - if (datasetsDetail[id]) - acc.push(datasetsDetail[id]) - return acc - }, []) - checkData = { - ...data, - _datasets, - } as CommonNodeType<KnowledgeRetrievalNodeType> - } - else if (data.type === BlockEnum.KnowledgeBase) { - const modelProviderName = (data as CommonNodeType<KnowledgeBaseNodeType>).embedding_model_provider - checkData = { - ...data, - _embeddingModelList: embeddingModelList, - _embeddingProviderModelList: modelProviderName ? embeddingProviderModelMap?.[modelProviderName] : undefined, - _rerankModelList: rerankModelList, - } as CommonNodeType<KnowledgeBaseNodeType> - } - return checkData - }, [embeddingModelList, rerankModelList]) + const shouldCheckStartNode = + appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT + + const getCheckData = useCallback( + ( + data: CommonNodeType<object>, + datasets: DataSet[], + embeddingProviderModelMap?: Partial<Record<string, ModelItem[]>>, + ) => { + let checkData = data + if (data.type === BlockEnum.KnowledgeRetrieval) { + const datasetIds = (data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids + const datasetsDetail = datasets.reduce<Record<string, DataSet>>((acc, dataset) => { + acc[dataset.id] = dataset + return acc + }, {}) + const _datasets = datasetIds.reduce<DataSet[]>((acc, id) => { + if (datasetsDetail[id]) acc.push(datasetsDetail[id]) + return acc + }, []) + checkData = { + ...data, + _datasets, + } as CommonNodeType<KnowledgeRetrievalNodeType> + } else if (data.type === BlockEnum.KnowledgeBase) { + const modelProviderName = (data as CommonNodeType<KnowledgeBaseNodeType>) + .embedding_model_provider + checkData = { + ...data, + _embeddingModelList: embeddingModelList, + _embeddingProviderModelList: modelProviderName + ? embeddingProviderModelMap?.[modelProviderName] + : undefined, + _rerankModelList: rerankModelList, + } as CommonNodeType<KnowledgeBaseNodeType> + } + return checkData + }, + [embeddingModelList, rerankModelList], + ) const handleCheckBeforePublish = useCallback(async () => { - const { - getNodes, - edges, - } = store.getState() - const { - dataSourceList, - } = workflowStore.getState() + const { getNodes, edges } = store.getState() + const { dataSourceList } = workflowStore.getState() const nodes = getNodes() - const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE) + const filteredNodes = nodes.filter((node) => node.type === CUSTOM_NODE) const duplicateEndOutputMessages = getDuplicateEndOutputMessages(filteredNodes, t) const { validNodes, maxDepth } = getValidTreeNodes(filteredNodes, edges) if (maxDepth > MAX_TREE_DEPTH) { - toast.error(t($ => $['common.maxTreeDepth'], { ns: 'workflow', depth: MAX_TREE_DEPTH })) + toast.error(t(($) => $['common.maxTreeDepth'], { ns: 'workflow', depth: MAX_TREE_DEPTH })) return false } - const knowledgeBaseEmbeddingProviders = [...new Set( - filteredNodes - .filter(node => node.data.type === BlockEnum.KnowledgeBase) - .map(node => node.data as CommonNodeType<KnowledgeBaseNodeType>) - .filter(node => node.indexing_technique === IndexMethodEnum.QUALIFIED) - .map(node => node.embedding_model_provider) - .filter((provider): provider is string => !!provider), - )] + const knowledgeBaseEmbeddingProviders = [ + ...new Set( + filteredNodes + .filter((node) => node.data.type === BlockEnum.KnowledgeBase) + .map((node) => node.data as CommonNodeType<KnowledgeBaseNodeType>) + .filter((node) => node.indexing_technique === IndexMethodEnum.QUALIFIED) + .map((node) => node.embedding_model_provider) + .filter((provider): provider is string => !!provider), + ), + ] const fetchKnowledgeBaseProviderModelMap = async () => { const modelMap: Partial<Record<string, ModelItem[]>> = {} - await Promise.all(knowledgeBaseEmbeddingProviders.map(async (provider) => { - try { - const modelList = await queryClient.fetchQuery( - consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({ - input: { params: { provider } }, - }), - ) - - if (modelList.data) - modelMap[provider] = normalizeModelProviderModelsResponse(modelList) - } - catch { - } - })) + await Promise.all( + knowledgeBaseEmbeddingProviders.map(async (provider) => { + try { + const modelList = await queryClient.fetchQuery( + consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({ + input: { params: { provider } }, + }), + ) + + if (modelList.data) modelMap[provider] = normalizeModelProviderModelsResponse(modelList) + } catch {} + }), + ) return modelMap } const fetchLatestDatasets = async (): Promise<DataSet[] | null> => { const allDatasetIds = new Set<string>() filteredNodes.forEach((node) => { - if (node.data.type !== BlockEnum.KnowledgeRetrieval) - return + if (node.data.type !== BlockEnum.KnowledgeRetrieval) return const datasetIds = (node.data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids - datasetIds.forEach(id => allDatasetIds.add(id)) + datasetIds.forEach((id) => allDatasetIds.add(id)) }) - if (allDatasetIds.size === 0) - return [] + if (allDatasetIds.size === 0) return [] updateTimeRef.current = updateTimeRef.current + 1 const currUpdateTime = updateTimeRef.current - const { data: datasetsDetail } = await fetchDatasets({ url: '/datasets', params: { page: 1, ids: [...allDatasetIds] } }) - if (currUpdateTime < updateTimeRef.current) - return null - if (datasetsDetail?.length) - updateDatasetsDetail(datasetsDetail) + const { data: datasetsDetail } = await fetchDatasets({ + url: '/datasets', + params: { page: 1, ids: [...allDatasetIds] }, + }) + if (currUpdateTime < updateTimeRef.current) return null + if (datasetsDetail?.length) updateDatasetsDetail(datasetsDetail) return datasetsDetail || [] } @@ -510,51 +565,70 @@ export const useChecklistBeforePublish = () => { fetchLatestDatasets(), ]) - if (datasets === null) - return false + if (datasets === null) return false - const installedPluginIds = new Set(modelProviders.map(p => extractPluginId(p.provider))) + const installedPluginIds = new Set(modelProviders.map((p) => extractPluginId(p.provider))) const map = getNodesAvailableVarList(nodes) for (let i = 0; i < filteredNodes.length; i++) { const node = filteredNodes[i] let moreDataForCheckValid: CheckValidExtraData let usedVars: ValueSelector[] = [] if (node!.data.type === BlockEnum.Tool) - moreDataForCheckValid = getToolCheckParams(node!.data as ToolNodeType, buildInTools || [], customTools || [], workflowTools || [], language) + moreDataForCheckValid = getToolCheckParams( + node!.data as ToolNodeType, + buildInTools || [], + customTools || [], + workflowTools || [], + language, + ) if (node!.data.type === BlockEnum.DataSource) - moreDataForCheckValid = getDataSourceCheckParams(node!.data as DataSourceNodeType, dataSourceList || [], language) + moreDataForCheckValid = getDataSourceCheckParams( + node!.data as DataSourceNodeType, + dataSourceList || [], + language, + ) if (node!.data.type === BlockEnum.Agent && !isAgentV2NodeData(node!.data)) { const data = node!.data as AgentNodeType const isReadyForCheckValid = !!strategyProviders - const provider = strategyProviders?.find(provider => provider.declaration.identity.name === data.agent_strategy_provider_name) - const strategy = provider?.declaration.strategies?.find(s => s.identity.name === data.agent_strategy_name) + const provider = strategyProviders?.find( + (provider) => provider.declaration.identity.name === data.agent_strategy_provider_name, + ) + const strategy = provider?.declaration.strategies?.find( + (s) => s.identity.name === data.agent_strategy_name, + ) moreDataForCheckValid = { provider, strategy, language, isReadyForCheckValid, } - } - else { - usedVars = getNodeUsedVars(node!).filter(v => v.length > 0) + } else { + usedVars = getNodeUsedVars(node!).filter((v) => v.length > 0) } if (node!.data.type === BlockEnum.LLM) { - const modelProvider = (node!.data as CommonNodeType<{ model?: ModelConfig }>).model?.provider + const modelProvider = (node!.data as CommonNodeType<{ model?: ModelConfig }>).model + ?.provider const modelIssue = getLLMModelIssue({ modelProvider, isModelProviderInstalled: isLLMModelProviderInstalled(modelProvider, installedPluginIds), }) if (modelIssue === LLMModelIssueCode.providerPluginUnavailable) { - toast.error(`[${node!.data.title}] ${t($ => $['errorMsg.configureModel'], { ns: 'workflow' })}`) + toast.error( + `[${node!.data.title}] ${t(($) => $['errorMsg.configureModel'], { ns: 'workflow' })}`, + ) return false } } const checkData = getCheckData(node!.data, datasets, embeddingProviderModelMap) - const { errorMessage } = nodesExtraData![getNodeCatalogType(node!.data)].checkValid(checkData, t, withFlowType(moreDataForCheckValid, flowType)) + const { errorMessage } = nodesExtraData![getNodeCatalogType(node!.data)].checkValid( + checkData, + t, + withFlowType(moreDataForCheckValid, flowType), + ) if (errorMessage) { toast.error(`[${node!.data.title}] ${errorMessage}`) @@ -572,52 +646,86 @@ export const useChecklistBeforePublish = () => { for (const variable of usedVars) { const isSpecialVars = isSpecialVar(variable[0]!) if (!isSpecialVars) { - const usedNode = availableVars.find(v => v.nodeId === variable?.[0]) + const usedNode = availableVars.find((v) => v.nodeId === variable?.[0]) if (usedNode) { - const usedVar = usedNode.vars.find(v => v.variable === variable?.[1]) + const usedVar = usedNode.vars.find((v) => v.variable === variable?.[1]) if (!usedVar) { - toast.error(`[${node!.data.title}] ${t($ => $['errorMsg.invalidVariable'], { ns: 'workflow' })}`) + toast.error( + `[${node!.data.title}] ${t(($) => $['errorMsg.invalidVariable'], { ns: 'workflow' })}`, + ) return false } - } - else { - toast.error(`[${node!.data.title}] ${t($ => $['errorMsg.invalidVariable'], { ns: 'workflow' })}`) + } else { + toast.error( + `[${node!.data.title}] ${t(($) => $['errorMsg.invalidVariable'], { ns: 'workflow' })}`, + ) return false } } } - const isStartNodeMeta = nodesExtraData?.[node!.data.type as BlockEnum]?.metaData.isStart ?? false + const isStartNodeMeta = + nodesExtraData?.[node!.data.type as BlockEnum]?.metaData.isStart ?? false const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta : true - const isUnconnected = !validNodes.some(n => n.id === node!.id) + const isUnconnected = !validNodes.some((n) => n.id === node!.id) if (isUnconnected && !canSkipConnectionCheck) { - toast.error(`[${node!.data.title}] ${t($ => $['common.needConnectTip'], { ns: 'workflow' })}`) + toast.error( + `[${node!.data.title}] ${t(($) => $['common.needConnectTip'], { ns: 'workflow' })}`, + ) return false } } if (shouldCheckStartNode) { - const startNodesFiltered = nodes.filter(node => START_NODE_TYPES.includes(node.data.type as BlockEnum)) + const startNodesFiltered = nodes.filter((node) => + START_NODE_TYPES.includes(node.data.type as BlockEnum), + ) if (startNodesFiltered.length === 0) { - toast.error(t($ => $['common.needStartNode'], { ns: 'workflow' })) + toast.error(t(($) => $['common.needStartNode'], { ns: 'workflow' })) return false } } - const isRequiredNodesType = Object.keys(nodesExtraData!).filter((key: any) => (nodesExtraData as any)[key].metaData.isRequired) + const isRequiredNodesType = Object.keys(nodesExtraData!).filter( + (key: any) => (nodesExtraData as any)[key].metaData.isRequired, + ) for (let i = 0; i < isRequiredNodesType.length; i++) { const type = isRequiredNodesType[i] - if (!filteredNodes.some(node => node.data.type === type)) { - toast.error(t($ => $['common.needAdd'], { ns: 'workflow', node: t($ => $[`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>], { ns: 'workflow' }) })) + if (!filteredNodes.some((node) => node.data.type === type)) { + toast.error( + t(($) => $['common.needAdd'], { + ns: 'workflow', + node: t(($) => $[`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>], { + ns: 'workflow', + }), + }), + ) return false } } return true - }, [store, workflowStore, getNodesAvailableVarList, shouldCheckStartNode, nodesExtraData, t, updateDatasetsDetail, buildInTools, customTools, workflowTools, language, getCheckData, queryClient, strategyProviders, modelProviders, flowType]) + }, [ + store, + workflowStore, + getNodesAvailableVarList, + shouldCheckStartNode, + nodesExtraData, + t, + updateDatasetsDetail, + buildInTools, + customTools, + workflowTools, + language, + getCheckData, + queryClient, + strategyProviders, + modelProviders, + flowType, + ]) return { handleCheckBeforePublish, @@ -628,12 +736,12 @@ export const useWorkflowRunValidation = () => { const { t } = useTranslation() const nodes = useNodes() const edges = useEdges<CommonEdgeType>() - const flowType = useHooksStore(s => s.configsMap?.flowType) + const flowType = useHooksStore((s) => s.configsMap?.flowType) const needWarningNodes = useChecklist(nodes, edges, { flowType }) const validateBeforeRun = useCallback(() => { if (needWarningNodes.length > 0) { - toast.error(t($ => $['panel.checklistTip'], { ns: 'workflow' })) + toast.error(t(($) => $['panel.checklistTip'], { ns: 'workflow' })) return false } return true diff --git a/web/app/components/workflow/hooks/use-collaborative-workflow.ts b/web/app/components/workflow/hooks/use-collaborative-workflow.ts index c3d4ddc2946828..28b655ec5f579c 100644 --- a/web/app/components/workflow/hooks/use-collaborative-workflow.ts +++ b/web/app/components/workflow/hooks/use-collaborative-workflow.ts @@ -4,11 +4,9 @@ import { useStoreApi } from 'reactflow' import { collaborationManager } from '../collaboration/core/collaboration-manager' const sanitizeNodeForBroadcast = (node: Node): Node => { - if (!node.data) - return node + if (!node.data) return node - if (!Object.prototype.hasOwnProperty.call(node.data, 'selected')) - return node + if (!Object.prototype.hasOwnProperty.call(node.data, 'selected')) return node const sanitizedData = { ...node.data } delete (sanitizedData as Record<string, unknown>).selected @@ -20,11 +18,9 @@ const sanitizeNodeForBroadcast = (node: Node): Node => { } const sanitizeEdgeForBroadcast = (edge: Edge): Edge => { - if (!edge.data) - return edge + if (!edge.data) return edge - if (!Object.prototype.hasOwnProperty.call(edge.data, '_connectedNodeIsSelected')) - return edge + if (!Object.prototype.hasOwnProperty.call(edge.data, '_connectedNodeIsSelected')) return edge const sanitizedData = { ...edge.data } delete (sanitizedData as Record<string, unknown>)._connectedNodeIsSelected @@ -39,47 +35,55 @@ export const useCollaborativeWorkflow = () => { const store = useStoreApi() const { setNodes: collabSetNodes, setEdges: collabSetEdges } = collaborationManager - const setNodes = useCallback((newNodes: Node[], shouldBroadcast: boolean = true, source = 'use-collaborative-workflow:setNodes') => { - const { getNodes, setNodes: reactFlowSetNodes } = store.getState() - if (shouldBroadcast) { - const oldNodes = getNodes() - collabSetNodes( - oldNodes.map(sanitizeNodeForBroadcast), - newNodes.map(sanitizeNodeForBroadcast), - source, - ) - } - reactFlowSetNodes(newNodes) - }, [store, collabSetNodes]) - - const setEdges = useCallback((newEdges: Edge[], shouldBroadcast: boolean = true) => { - const { edges, setEdges: reactFlowSetEdges } = store.getState() - if (shouldBroadcast) { - collabSetEdges( - edges.map(sanitizeEdgeForBroadcast), - newEdges.map(sanitizeEdgeForBroadcast), - ) - } - - reactFlowSetEdges(newEdges) - }, [store, collabSetEdges]) + const setNodes = useCallback( + ( + newNodes: Node[], + shouldBroadcast: boolean = true, + source = 'use-collaborative-workflow:setNodes', + ) => { + const { getNodes, setNodes: reactFlowSetNodes } = store.getState() + if (shouldBroadcast) { + const oldNodes = getNodes() + collabSetNodes( + oldNodes.map(sanitizeNodeForBroadcast), + newNodes.map(sanitizeNodeForBroadcast), + source, + ) + } + reactFlowSetNodes(newNodes) + }, + [store, collabSetNodes], + ) + + const setEdges = useCallback( + (newEdges: Edge[], shouldBroadcast: boolean = true) => { + const { edges, setEdges: reactFlowSetEdges } = store.getState() + if (shouldBroadcast) { + collabSetEdges(edges.map(sanitizeEdgeForBroadcast), newEdges.map(sanitizeEdgeForBroadcast)) + } + + reactFlowSetEdges(newEdges) + }, + [store, collabSetEdges], + ) const collaborativeStore = useCallback(() => { const state = store.getState() return { - nodes: state.getNodes(), edges: state.edges, setNodes, setEdges, - } }, [store, setNodes, setEdges]) - return useMemo(() => ({ - getState: collaborativeStore, - setNodes, - setEdges, - }), [collaborativeStore, setEdges, setNodes]) + return useMemo( + () => ({ + getState: collaborativeStore, + setNodes, + setEdges, + }), + [collaborativeStore, setEdges, setNodes], + ) } diff --git a/web/app/components/workflow/hooks/use-config-vision.ts b/web/app/components/workflow/hooks/use-config-vision.ts index f9c68243c0e482..1a88e881909482 100644 --- a/web/app/components/workflow/hooks/use-config-vision.ts +++ b/web/app/components/workflow/hooks/use-config-vision.ts @@ -1,9 +1,7 @@ import type { ModelConfig, VisionSetting } from '@/app/components/workflow/types' import { produce } from 'immer' import { useCallback } from 'react' -import { - ModelFeatureEnum, -} from '@/app/components/header/account-setting/model-provider-page/declarations' +import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import { Resolution } from '@/types/app' import { useIsChatMode } from './use-workflow' @@ -17,20 +15,19 @@ type Params = { payload: Payload onChange: (payload: Payload) => void } -const useConfigVision = (model: ModelConfig, { - payload = { - enabled: false, - }, - onChange, -}: Params) => { - const { - currentModel: currModel, - } = useTextGenerationCurrentProviderAndModelAndModelList( - { - provider: model.provider, - model: model.name, +const useConfigVision = ( + model: ModelConfig, + { + payload = { + enabled: false, }, - ) + onChange, + }: Params, +) => { + const { currentModel: currModel } = useTextGenerationCurrentProviderAndModelAndModelList({ + provider: model.provider, + model: model.name, + }) const isChatMode = useIsChatMode() @@ -40,28 +37,33 @@ const useConfigVision = (model: ModelConfig, { const isVisionModel = getIsVisionModel() - const handleVisionResolutionEnabledChange = useCallback((enabled: boolean) => { - const newPayload = produce(payload, (draft) => { - draft.enabled = enabled - if (enabled && isChatMode) { - draft.configs = { - detail: Resolution.high, - variable_selector: ['sys', 'files'], + const handleVisionResolutionEnabledChange = useCallback( + (enabled: boolean) => { + const newPayload = produce(payload, (draft) => { + draft.enabled = enabled + if (enabled && isChatMode) { + draft.configs = { + detail: Resolution.high, + variable_selector: ['sys', 'files'], + } + } else if (!enabled) { + delete draft.configs } - } - else if (!enabled) { - delete draft.configs - } - }) - onChange(newPayload) - }, [isChatMode, onChange, payload]) + }) + onChange(newPayload) + }, + [isChatMode, onChange, payload], + ) - const handleVisionResolutionChange = useCallback((config: VisionSetting) => { - const newPayload = produce(payload, (draft) => { - draft.configs = config - }) - onChange(newPayload) - }, [onChange, payload]) + const handleVisionResolutionChange = useCallback( + (config: VisionSetting) => { + const newPayload = produce(payload, (draft) => { + draft.configs = config + }) + onChange(newPayload) + }, + [onChange, payload], + ) const handleModelChanged = useCallback(() => { const isVisionModel = getIsVisionModel() diff --git a/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx b/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx index ffc889eacd24a9..040c6bc1547f38 100644 --- a/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx +++ b/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx @@ -14,10 +14,10 @@ import { getWorkflowEntryNode } from '../utils/workflow-entry' export const useDynamicTestRunOptions = (): TestRunOptions => { const { t } = useTranslation() const nodes = useNodes() - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) - const workflowTools = useStore(s => s.workflowTools) - const mcpTools = useStore(s => s.mcpTools) + const buildInTools = useStore((s) => s.buildInTools) + const customTools = useStore((s) => s.customTools) + const workflowTools = useStore((s) => s.workflowTools) + const mcpTools = useStore((s) => s.mcpTools) const { data: triggerPlugins } = useAllTriggerPlugins() return useMemo(() => { @@ -27,74 +27,54 @@ export const useDynamicTestRunOptions = (): TestRunOptions => { for (const node of nodes) { const nodeData = node.data as CommonNodeType - if (!nodeData?.type) - continue + if (!nodeData?.type) continue if (nodeData.type === BlockEnum.Start) { userInput = { id: node.id, type: TriggerType.UserInput, - name: nodeData.title || t($ => $['blocks.start'], { ns: 'workflow' }), - icon: ( - <BlockIcon - type={BlockEnum.Start} - size="md" - /> - ), + name: nodeData.title || t(($) => $['blocks.start'], { ns: 'workflow' }), + icon: <BlockIcon type={BlockEnum.Start} size="md" />, nodeId: node.id, enabled: true, } - } - else if (nodeData.type === BlockEnum.TriggerSchedule) { + } else if (nodeData.type === BlockEnum.TriggerSchedule) { allTriggers.push({ id: node.id, type: TriggerType.Schedule, - name: nodeData.title || t($ => $['blocks.trigger-schedule'], { ns: 'workflow' }), - icon: ( - <BlockIcon - type={BlockEnum.TriggerSchedule} - size="md" - /> - ), + name: nodeData.title || t(($) => $['blocks.trigger-schedule'], { ns: 'workflow' }), + icon: <BlockIcon type={BlockEnum.TriggerSchedule} size="md" />, nodeId: node.id, enabled: true, }) - } - else if (nodeData.type === BlockEnum.TriggerWebhook) { + } else if (nodeData.type === BlockEnum.TriggerWebhook) { allTriggers.push({ id: node.id, type: TriggerType.Webhook, - name: nodeData.title || t($ => $['blocks.trigger-webhook'], { ns: 'workflow' }), - icon: ( - <BlockIcon - type={BlockEnum.TriggerWebhook} - size="md" - /> - ), + name: nodeData.title || t(($) => $['blocks.trigger-webhook'], { ns: 'workflow' }), + icon: <BlockIcon type={BlockEnum.TriggerWebhook} size="md" />, nodeId: node.id, enabled: true, }) - } - else if (nodeData.type === BlockEnum.TriggerPlugin) { + } else if (nodeData.type === BlockEnum.TriggerPlugin) { let triggerIcon: string | any if (nodeData.provider_id) { const targetTriggers = triggerPlugins || [] - triggerIcon = targetTriggers.find(toolWithProvider => toolWithProvider.name === nodeData.provider_id)?.icon + triggerIcon = targetTriggers.find( + (toolWithProvider) => toolWithProvider.name === nodeData.provider_id, + )?.icon } - const icon = ( - <BlockIcon - type={BlockEnum.TriggerPlugin} - size="md" - toolIcon={triggerIcon} - /> - ) + const icon = <BlockIcon type={BlockEnum.TriggerPlugin} size="md" toolIcon={triggerIcon} /> allTriggers.push({ id: node.id, type: TriggerType.Plugin, - name: nodeData.title || (nodeData as any).plugin_name || t($ => $['blocks.trigger-plugin'], { ns: 'workflow' }), + name: + nodeData.title || + (nodeData as any).plugin_name || + t(($) => $['blocks.trigger-plugin'], { ns: 'workflow' }), icon, nodeId: node.id, enabled: true, @@ -108,13 +88,10 @@ export const useDynamicTestRunOptions = (): TestRunOptions => { userInput = { id: startNode.id, type: TriggerType.UserInput, - name: (startNode.data as CommonNodeType)?.title || t($ => $['blocks.start'], { ns: 'workflow' }), - icon: ( - <BlockIcon - type={BlockEnum.Start} - size="md" - /> - ), + name: + (startNode.data as CommonNodeType)?.title || + t(($) => $['blocks.start'], { ns: 'workflow' }), + icon: <BlockIcon type={BlockEnum.Start} size="md" />, nodeId: startNode.id, enabled: true, } @@ -122,23 +99,24 @@ export const useDynamicTestRunOptions = (): TestRunOptions => { } const triggerNodeIds = allTriggers - .map(trigger => trigger.nodeId) + .map((trigger) => trigger.nodeId) .filter((nodeId): nodeId is string => Boolean(nodeId)) - const runAll: TriggerOption | undefined = triggerNodeIds.length > 1 - ? { - id: 'run-all', - type: TriggerType.All, - name: t($ => $['common.runAllTriggers'], { ns: 'workflow' }), - icon: ( - <div className="flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 bg-util-colors-purple-purple-500 text-white shadow-md"> - <TriggerAll className="size-4.5" /> - </div> - ), - relatedNodeIds: triggerNodeIds, - enabled: true, - } - : undefined + const runAll: TriggerOption | undefined = + triggerNodeIds.length > 1 + ? { + id: 'run-all', + type: TriggerType.All, + name: t(($) => $['common.runAllTriggers'], { ns: 'workflow' }), + icon: ( + <div className="flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 bg-util-colors-purple-purple-500 text-white shadow-md"> + <TriggerAll className="size-4.5" /> + </div> + ), + relatedNodeIds: triggerNodeIds, + enabled: true, + } + : undefined return { userInput, diff --git a/web/app/components/workflow/hooks/use-edges-interactions-without-sync.ts b/web/app/components/workflow/hooks/use-edges-interactions-without-sync.ts index 99673b70f84f84..113a54eb799d4a 100644 --- a/web/app/components/workflow/hooks/use-edges-interactions-without-sync.ts +++ b/web/app/components/workflow/hooks/use-edges-interactions-without-sync.ts @@ -6,10 +6,7 @@ export const useEdgesInteractionsWithoutSync = () => { const store = useStoreApi() const handleEdgeCancelRunningStatus = useCallback(() => { - const { - edges, - setEdges, - } = store.getState() + const { edges, setEdges } = store.getState() const newEdges = produce(edges, (draft) => { draft.forEach((edge) => { diff --git a/web/app/components/workflow/hooks/use-edges-interactions.helpers.ts b/web/app/components/workflow/hooks/use-edges-interactions.helpers.ts index fad92a59c01202..fd96d1b97cc645 100644 --- a/web/app/components/workflow/hooks/use-edges-interactions.helpers.ts +++ b/web/app/components/workflow/hooks/use-edges-interactions.helpers.ts @@ -8,7 +8,10 @@ export const applyConnectedHandleNodeData = ( nodes: Node[], edgeChanges: Parameters<typeof getNodesConnectedSourceOrTargetHandleIdsMap>[0], ) => { - const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(edgeChanges, nodes) + const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( + edgeChanges, + nodes, + ) return produce(nodes, (draft: Node[]) => { draft.forEach((node) => { @@ -32,45 +35,35 @@ export const clearEdgeMenuIfNeeded = ({ return !!(contextMenuTarget?.type === 'edge' && edgeIds.includes(contextMenuTarget.edgeId)) } -export const updateEdgeHoverState = ( - edges: Edge[], - edgeId: string, - hovering: boolean, -) => produce(edges, (draft) => { - const currentEdge = draft.find(edge => edge.id === edgeId) - if (currentEdge) - currentEdge.data._hovering = hovering -}) +export const updateEdgeHoverState = (edges: Edge[], edgeId: string, hovering: boolean) => + produce(edges, (draft) => { + const currentEdge = draft.find((edge) => edge.id === edgeId) + if (currentEdge) currentEdge.data._hovering = hovering + }) -export const updateEdgeSelectionState = ( - edges: Edge[], - changes: EdgeChange[], -) => produce(edges, (draft) => { - changes.forEach((change) => { - if (change.type === 'select') { - const currentEdge = draft.find(edge => edge.id === change.id) - if (currentEdge) - currentEdge.selected = change.selected - } +export const updateEdgeSelectionState = (edges: Edge[], changes: EdgeChange[]) => + produce(edges, (draft) => { + changes.forEach((change) => { + if (change.type === 'select') { + const currentEdge = draft.find((edge) => edge.id === change.id) + if (currentEdge) currentEdge.selected = change.selected + } + }) }) -}) -export const buildContextMenuEdges = ( - edges: Edge[], - edgeId: string, -) => produce(edges, (draft) => { - draft.forEach((item) => { - item.selected = item.id === edgeId - if (item.data._isBundled) - item.data._isBundled = false +export const buildContextMenuEdges = (edges: Edge[], edgeId: string) => + produce(edges, (draft) => { + draft.forEach((item) => { + item.selected = item.id === edgeId + if (item.data._isBundled) item.data._isBundled = false + }) }) -}) -export const clearNodeSelectionState = (nodes: Node[]) => produce(nodes, (draft: Node[]) => { - draft.forEach((node) => { - node.data.selected = false - if (node.data._isBundled) - node.data._isBundled = false - node.selected = false +export const clearNodeSelectionState = (nodes: Node[]) => + produce(nodes, (draft: Node[]) => { + draft.forEach((node) => { + node.data.selected = false + if (node.data._isBundled) node.data._isBundled = false + node.selected = false + }) }) -}) diff --git a/web/app/components/workflow/hooks/use-edges-interactions.ts b/web/app/components/workflow/hooks/use-edges-interactions.ts index 70a1189caf7b41..29a2b24583abd5 100644 --- a/web/app/components/workflow/hooks/use-edges-interactions.ts +++ b/web/app/components/workflow/hooks/use-edges-interactions.ts @@ -1,7 +1,4 @@ -import type { - EdgeMouseHandler, - OnEdgesChange, -} from 'reactflow' +import type { EdgeMouseHandler, OnEdgesChange } from 'reactflow' import { produce } from 'immer' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' @@ -27,175 +24,198 @@ export const useEdgesInteractions = () => { const workflowStore = useWorkflowStore() const collaborativeWorkflow = useCollaborativeWorkflow() - const deleteEdgeById = useCallback((edgeId: string) => { - const { - nodes, - setNodes, - edges, - setEdges, - } = collaborativeWorkflow.getState() - const currentEdgeIndex = edges.findIndex(edge => edge.id === edgeId) - - if (currentEdgeIndex < 0) - return - const currentEdge = edges[currentEdgeIndex]! - const newNodes = applyConnectedHandleNodeData(nodes, [{ type: 'remove', edge: currentEdge }]) - setNodes(newNodes) - const newEdges = produce(edges, (draft) => { - draft.splice(currentEdgeIndex, 1) - }) - setEdges(newEdges) - if (clearEdgeMenuIfNeeded({ contextMenuTarget: workflowStore.getState().contextMenuTarget, edgeIds: [currentEdge!.id] })) - workflowStore.setState({ contextMenuTarget: undefined }) - handleSyncWorkflowDraft() - saveStateToHistory(WorkflowHistoryEvent.EdgeDelete) - }, [collaborativeWorkflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory]) - - const handleEdgeEnter = useCallback<EdgeMouseHandler>((_, edge) => { - if (getNodesReadOnly()) - return - - const { edges, setEdges } = store.getState() - setEdges(updateEdgeHoverState(edges, edge.id, true)) - }, [getNodesReadOnly, store]) - - const handleEdgeLeave = useCallback<EdgeMouseHandler>((_, edge) => { - if (getNodesReadOnly()) - return - - const { edges, setEdges } = store.getState() - setEdges(updateEdgeHoverState(edges, edge.id, false)) - }, [getNodesReadOnly, store]) - - const handleEdgeDeleteByDeleteBranch = useCallback((nodeId: string, branchId: string) => { - if (getNodesReadOnly()) - return - - const { - nodes, - setNodes, - edges, - setEdges, - } = collaborativeWorkflow.getState() - const edgeWillBeDeleted = edges.filter(edge => edge.source === nodeId && edge.sourceHandle === branchId) - - if (!edgeWillBeDeleted.length) - return - - const newNodes = applyConnectedHandleNodeData( - nodes, - edgeWillBeDeleted.map(edge => ({ type: 'remove' as const, edge })), - ) - setNodes(newNodes) - const newEdges = produce(edges, (draft) => { - return draft.filter(edge => !edgeWillBeDeleted.find(e => e.id === edge.id)) - }) - setEdges(newEdges) - if (clearEdgeMenuIfNeeded({ - contextMenuTarget: workflowStore.getState().contextMenuTarget, - edgeIds: edgeWillBeDeleted.map(edge => edge.id), - })) { - workflowStore.setState({ contextMenuTarget: undefined }) - } - handleSyncWorkflowDraft() - saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch) - }, [getNodesReadOnly, collaborativeWorkflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory]) + const deleteEdgeById = useCallback( + (edgeId: string) => { + const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() + const currentEdgeIndex = edges.findIndex((edge) => edge.id === edgeId) + + if (currentEdgeIndex < 0) return + const currentEdge = edges[currentEdgeIndex]! + const newNodes = applyConnectedHandleNodeData(nodes, [{ type: 'remove', edge: currentEdge }]) + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + draft.splice(currentEdgeIndex, 1) + }) + setEdges(newEdges) + if ( + clearEdgeMenuIfNeeded({ + contextMenuTarget: workflowStore.getState().contextMenuTarget, + edgeIds: [currentEdge!.id], + }) + ) + workflowStore.setState({ contextMenuTarget: undefined }) + handleSyncWorkflowDraft() + saveStateToHistory(WorkflowHistoryEvent.EdgeDelete) + }, + [collaborativeWorkflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory], + ) + + const handleEdgeEnter = useCallback<EdgeMouseHandler>( + (_, edge) => { + if (getNodesReadOnly()) return + + const { edges, setEdges } = store.getState() + setEdges(updateEdgeHoverState(edges, edge.id, true)) + }, + [getNodesReadOnly, store], + ) + + const handleEdgeLeave = useCallback<EdgeMouseHandler>( + (_, edge) => { + if (getNodesReadOnly()) return + + const { edges, setEdges } = store.getState() + setEdges(updateEdgeHoverState(edges, edge.id, false)) + }, + [getNodesReadOnly, store], + ) + + const handleEdgeDeleteByDeleteBranch = useCallback( + (nodeId: string, branchId: string) => { + if (getNodesReadOnly()) return + + const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() + const edgeWillBeDeleted = edges.filter( + (edge) => edge.source === nodeId && edge.sourceHandle === branchId, + ) + + if (!edgeWillBeDeleted.length) return + + const newNodes = applyConnectedHandleNodeData( + nodes, + edgeWillBeDeleted.map((edge) => ({ type: 'remove' as const, edge })), + ) + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + return draft.filter((edge) => !edgeWillBeDeleted.find((e) => e.id === edge.id)) + }) + setEdges(newEdges) + if ( + clearEdgeMenuIfNeeded({ + contextMenuTarget: workflowStore.getState().contextMenuTarget, + edgeIds: edgeWillBeDeleted.map((edge) => edge.id), + }) + ) { + workflowStore.setState({ contextMenuTarget: undefined }) + } + handleSyncWorkflowDraft() + saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch) + }, + [ + getNodesReadOnly, + collaborativeWorkflow, + workflowStore, + handleSyncWorkflowDraft, + saveStateToHistory, + ], + ) const handleEdgeDelete = useCallback(() => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const { edges } = collaborativeWorkflow.getState() - const currentEdge = edges.find(edge => edge.selected) + const currentEdge = edges.find((edge) => edge.selected) - if (!currentEdge) - return + if (!currentEdge) return deleteEdgeById(currentEdge.id) }, [deleteEdgeById, getNodesReadOnly, collaborativeWorkflow]) - const handleEdgeDeleteById = useCallback((edgeId: string) => { - if (getNodesReadOnly()) - return - - deleteEdgeById(edgeId) - }, [deleteEdgeById, getNodesReadOnly]) - - const handleEdgesChange = useCallback<OnEdgesChange>((changes) => { - if (getNodesReadOnly()) - return - - const { - edges, - setEdges, - } = collaborativeWorkflow.getState() - setEdges(updateEdgeSelectionState(edges, changes)) - }, [collaborativeWorkflow, getNodesReadOnly]) - - const handleEdgeSourceHandleChange = useCallback((nodeId: string, oldHandleId: string, newHandleId: string) => { - if (getNodesReadOnly()) - return - - const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() - - // Find edges connected to the old handle - const affectedEdges = edges.filter( - edge => edge.source === nodeId && edge.sourceHandle === oldHandleId, - ) - - if (affectedEdges.length === 0) - return - - // Update node metadata: remove old handle, add new handle - const newNodes = applyConnectedHandleNodeData(nodes, [ - ...affectedEdges.map(edge => ({ type: 'remove' as const, edge })), - ...affectedEdges.map(edge => ({ - type: 'add' as const, - edge: { ...edge, sourceHandle: newHandleId }, - })), - ]) - setNodes(newNodes) - - // Update edges to use new sourceHandle and regenerate edge IDs - const newEdges = produce(edges, (draft) => { - draft.forEach((edge) => { - if (edge.source === nodeId && edge.sourceHandle === oldHandleId) { - edge.sourceHandle = newHandleId - edge.id = `${edge.source}-${newHandleId}-${edge.target}-${edge.targetHandle}` - } + const handleEdgeDeleteById = useCallback( + (edgeId: string) => { + if (getNodesReadOnly()) return + + deleteEdgeById(edgeId) + }, + [deleteEdgeById, getNodesReadOnly], + ) + + const handleEdgesChange = useCallback<OnEdgesChange>( + (changes) => { + if (getNodesReadOnly()) return + + const { edges, setEdges } = collaborativeWorkflow.getState() + setEdges(updateEdgeSelectionState(edges, changes)) + }, + [collaborativeWorkflow, getNodesReadOnly], + ) + + const handleEdgeSourceHandleChange = useCallback( + (nodeId: string, oldHandleId: string, newHandleId: string) => { + if (getNodesReadOnly()) return + + const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() + + // Find edges connected to the old handle + const affectedEdges = edges.filter( + (edge) => edge.source === nodeId && edge.sourceHandle === oldHandleId, + ) + + if (affectedEdges.length === 0) return + + // Update node metadata: remove old handle, add new handle + const newNodes = applyConnectedHandleNodeData(nodes, [ + ...affectedEdges.map((edge) => ({ type: 'remove' as const, edge })), + ...affectedEdges.map((edge) => ({ + type: 'add' as const, + edge: { ...edge, sourceHandle: newHandleId }, + })), + ]) + setNodes(newNodes) + + // Update edges to use new sourceHandle and regenerate edge IDs + const newEdges = produce(edges, (draft) => { + draft.forEach((edge) => { + if (edge.source === nodeId && edge.sourceHandle === oldHandleId) { + edge.sourceHandle = newHandleId + edge.id = `${edge.source}-${newHandleId}-${edge.target}-${edge.targetHandle}` + } + }) + }) + setEdges(newEdges) + if ( + clearEdgeMenuIfNeeded({ + contextMenuTarget: workflowStore.getState().contextMenuTarget, + edgeIds: affectedEdges.map((edge) => edge.id), + }) + ) { + workflowStore.setState({ contextMenuTarget: undefined }) + } + handleSyncWorkflowDraft() + saveStateToHistory(WorkflowHistoryEvent.EdgeSourceHandleChange) + }, + [ + getNodesReadOnly, + collaborativeWorkflow, + workflowStore, + handleSyncWorkflowDraft, + saveStateToHistory, + ], + ) + + const handleEdgeContextMenu = useCallback<EdgeMouseHandler>( + (e, edge) => { + if (getNodesReadOnly()) { + e.stopPropagation() + return + } + + e.preventDefault() + + const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() + setEdges(buildContextMenuEdges(edges, edge.id)) + if (nodes.some((node) => node.data.selected || node.selected || node.data._isBundled)) { + setNodes(clearNodeSelectionState(nodes)) + } + + workflowStore.setState({ + contextMenuTarget: { + type: 'edge', + edgeId: edge.id, + }, }) - }) - setEdges(newEdges) - if (clearEdgeMenuIfNeeded({ - contextMenuTarget: workflowStore.getState().contextMenuTarget, - edgeIds: affectedEdges.map(edge => edge.id), - })) { - workflowStore.setState({ contextMenuTarget: undefined }) - } - handleSyncWorkflowDraft() - saveStateToHistory(WorkflowHistoryEvent.EdgeSourceHandleChange) - }, [getNodesReadOnly, collaborativeWorkflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory]) - - const handleEdgeContextMenu = useCallback<EdgeMouseHandler>((e, edge) => { - if (getNodesReadOnly()) { - e.stopPropagation() - return - } - - e.preventDefault() - - const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() - setEdges(buildContextMenuEdges(edges, edge.id)) - if (nodes.some(node => node.data.selected || node.selected || node.data._isBundled)) { - setNodes(clearNodeSelectionState(nodes)) - } - - workflowStore.setState({ - contextMenuTarget: { - type: 'edge', - edgeId: edge.id, - }, - }) - }, [collaborativeWorkflow, workflowStore, getNodesReadOnly]) + }, + [collaborativeWorkflow, workflowStore, getNodesReadOnly], + ) return { handleEdgeEnter, diff --git a/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts b/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts index eef710ef70a8bb..6a84508e4808d0 100644 --- a/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts +++ b/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts @@ -12,7 +12,10 @@ import { useAllMCPTools, useAllWorkflowTools, } from '@/service/use-tools' -import { useInvalidateConversationVarValues, useInvalidateSysVarValues } from '@/service/use-workflow' +import { + useInvalidateConversationVarValues, + useInvalidateSysVarValues, +} from '@/service/use-workflow' import { fetchAllInspectVars } from '@/service/workflow' import useMatchSchemaType from '../nodes/_base/components/variable/use-match-schema-type' import { toNodeOutputVars } from '../nodes/_base/components/variable/utils' @@ -22,10 +25,7 @@ type Params = { flowId: string } -export const useSetWorkflowVarsWithValue = ({ - flowType, - flowId, -}: Params) => { +export const useSetWorkflowVarsWithValue = ({ flowType, flowId }: Params) => { const workflowStore = useWorkflowStore() const store = useStoreApi() const invalidateConversationVarValues = useInvalidateConversationVarValues(flowType, flowId) @@ -36,7 +36,7 @@ export const useSetWorkflowVarsWithValue = ({ const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() - const dataSourceList = useStore(s => s.dataSourceList) + const dataSourceList = useStore((s) => s.dataSourceList) const allPluginInfoList = useMemo(() => { return { @@ -48,71 +48,98 @@ export const useSetWorkflowVarsWithValue = ({ } }, [buildInTools, customTools, workflowTools, mcpTools, dataSourceList]) - const setInspectVarsToStore = useCallback((inspectVars: VarInInspect[], passedInAllPluginInfoList?: Record<string, ToolWithProvider[]>, passedInSchemaTypeDefinitions?: SchemaTypeDefinition[]) => { - const { setNodesWithInspectVars } = workflowStore.getState() - const { getNodes } = store.getState() - - const nodeArr = getNodes() - const allNodesOutputVars = toNodeOutputVars(nodeArr, false, () => true, [], [], [], passedInAllPluginInfoList || allPluginInfoList, passedInSchemaTypeDefinitions || schemaTypeDefinitions) + const setInspectVarsToStore = useCallback( + ( + inspectVars: VarInInspect[], + passedInAllPluginInfoList?: Record<string, ToolWithProvider[]>, + passedInSchemaTypeDefinitions?: SchemaTypeDefinition[], + ) => { + const { setNodesWithInspectVars } = workflowStore.getState() + const { getNodes } = store.getState() - const nodesKeyValue: Record<string, Node> = {} - nodeArr.forEach((node) => { - nodesKeyValue[node.id] = node - }) + const nodeArr = getNodes() + const allNodesOutputVars = toNodeOutputVars( + nodeArr, + false, + () => true, + [], + [], + [], + passedInAllPluginInfoList || allPluginInfoList, + passedInSchemaTypeDefinitions || schemaTypeDefinitions, + ) - const withValueNodeIds: Record<string, boolean> = {} - inspectVars.forEach((varItem) => { - const nodeId = varItem.selector[0] + const nodesKeyValue: Record<string, Node> = {} + nodeArr.forEach((node) => { + nodesKeyValue[node.id] = node + }) - const node = nodesKeyValue[nodeId!] - if (!node) - return - withValueNodeIds[nodeId!] = true - }) - const withValueNodes = Object.keys(withValueNodeIds).map((nodeId) => { - return nodesKeyValue[nodeId] - }) + const withValueNodeIds: Record<string, boolean> = {} + inspectVars.forEach((varItem) => { + const nodeId = varItem.selector[0] - const res: NodeWithVar[] = withValueNodes.map((node) => { - const nodeId = node!.id - const varsUnderTheNode = inspectVars.filter((varItem) => { - return varItem.selector[0] === nodeId + const node = nodesKeyValue[nodeId!] + if (!node) return + withValueNodeIds[nodeId!] = true }) - const nodeVar = allNodesOutputVars.find(item => item.nodeId === nodeId) + const withValueNodes = Object.keys(withValueNodeIds).map((nodeId) => { + return nodesKeyValue[nodeId] + }) + + const res: NodeWithVar[] = withValueNodes.map((node) => { + const nodeId = node!.id + const varsUnderTheNode = inspectVars.filter((varItem) => { + return varItem.selector[0] === nodeId + }) + const nodeVar = allNodesOutputVars.find((item) => item.nodeId === nodeId) - const nodeWithVar = { - nodeId, - nodePayload: node!.data, - nodeType: node!.data.type, - title: node!.data.title, - vars: varsUnderTheNode.map((item) => { - const schemaType = nodeVar ? nodeVar.vars.find(v => v.variable === item.name)?.schemaType : '' - return { - ...item, - schemaType, - } - }), - isSingRunRunning: false, - isValueFetched: false, - } - return nodeWithVar - }) - setNodesWithInspectVars(res) - }, [workflowStore, store, allPluginInfoList, schemaTypeDefinitions]) + const nodeWithVar = { + nodeId, + nodePayload: node!.data, + nodeType: node!.data.type, + title: node!.data.title, + vars: varsUnderTheNode.map((item) => { + const schemaType = nodeVar + ? nodeVar.vars.find((v) => v.variable === item.name)?.schemaType + : '' + return { + ...item, + schemaType, + } + }), + isSingRunRunning: false, + isValueFetched: false, + } + return nodeWithVar + }) + setNodesWithInspectVars(res) + }, + [workflowStore, store, allPluginInfoList, schemaTypeDefinitions], + ) - const fetchInspectVars = useCallback(async (params: { - passInVars?: boolean - vars?: VarInInspect[] - passedInAllPluginInfoList?: Record<string, ToolWithProvider[]> - passedInSchemaTypeDefinitions?: SchemaTypeDefinition[] - }) => { - const { passInVars, vars, passedInAllPluginInfoList, passedInSchemaTypeDefinitions } = params - invalidateConversationVarValues() - invalidateSysVarValues() - const data = passInVars ? vars! : await fetchAllInspectVars(flowType, flowId) - setInspectVarsToStore(data, passedInAllPluginInfoList, passedInSchemaTypeDefinitions) - handleCancelAllNodeSuccessStatus() // to make sure clear node output show the unset status - }, [invalidateConversationVarValues, invalidateSysVarValues, flowType, flowId, setInspectVarsToStore, handleCancelAllNodeSuccessStatus]) + const fetchInspectVars = useCallback( + async (params: { + passInVars?: boolean + vars?: VarInInspect[] + passedInAllPluginInfoList?: Record<string, ToolWithProvider[]> + passedInSchemaTypeDefinitions?: SchemaTypeDefinition[] + }) => { + const { passInVars, vars, passedInAllPluginInfoList, passedInSchemaTypeDefinitions } = params + invalidateConversationVarValues() + invalidateSysVarValues() + const data = passInVars ? vars! : await fetchAllInspectVars(flowType, flowId) + setInspectVarsToStore(data, passedInAllPluginInfoList, passedInSchemaTypeDefinitions) + handleCancelAllNodeSuccessStatus() // to make sure clear node output show the unset status + }, + [ + invalidateConversationVarValues, + invalidateSysVarValues, + flowType, + flowId, + setInspectVarsToStore, + handleCancelAllNodeSuccessStatus, + ], + ) return { fetchInspectVars, diff --git a/web/app/components/workflow/hooks/use-helpline.ts b/web/app/components/workflow/hooks/use-helpline.ts index 3fda2528c1c21b..b8cb4d559659b1 100644 --- a/web/app/components/workflow/hooks/use-helpline.ts +++ b/web/app/components/workflow/hooks/use-helpline.ts @@ -24,13 +24,8 @@ type NodeAlignPosition = { const ALIGN_THRESHOLD = 5 -const getEntryNodeDimension = ( - node: Node, - dimension: 'width' | 'height', -) => { - const offset = dimension === 'width' - ? ENTRY_NODE_WRAPPER_OFFSET.x - : ENTRY_NODE_WRAPPER_OFFSET.y +const getEntryNodeDimension = (node: Node, dimension: 'width' | 'height') => { + const offset = dimension === 'width' ? ENTRY_NODE_WRAPPER_OFFSET.x : ENTRY_NODE_WRAPPER_OFFSET.y return (node[dimension] ?? 0) - offset } @@ -48,20 +43,20 @@ const getAlignedNodes = ({ axis: 'x' | 'y' getNodeAlignPosition: (node: Node) => NodeAlignPosition }) => { - return nodes.filter((candidate) => { - if (candidate.id === node.id) - return false - if (candidate.data.isInIteration || candidate.data.isInLoop) - return false - - const candidateAlignPos = getNodeAlignPosition(candidate) - const diff = Math.ceil(candidateAlignPos[axis]) - Math.ceil(nodeAlignPos[axis]) - return diff < ALIGN_THRESHOLD && diff > -ALIGN_THRESHOLD - }).sort((a, b) => { - const aPos = getNodeAlignPosition(a) - const bPos = getNodeAlignPosition(b) - return aPos.x - bPos.x - }) + return nodes + .filter((candidate) => { + if (candidate.id === node.id) return false + if (candidate.data.isInIteration || candidate.data.isInLoop) return false + + const candidateAlignPos = getNodeAlignPosition(candidate) + const diff = Math.ceil(candidateAlignPos[axis]) - Math.ceil(nodeAlignPos[axis]) + return diff < ALIGN_THRESHOLD && diff > -ALIGN_THRESHOLD + }) + .sort((a, b) => { + const aPos = getNodeAlignPosition(a) + const bPos = getNodeAlignPosition(b) + return aPos.x - bPos.x + }) } const buildHorizontalHelpLine = ({ @@ -77,8 +72,7 @@ const buildHorizontalHelpLine = ({ getNodeAlignPosition: (node: Node) => NodeAlignPosition isEntryNode: (node: Node) => boolean }) => { - if (!alignedNodes.length) - return undefined + if (!alignedNodes.length) return undefined const first = alignedNodes[0] const last = alignedNodes[alignedNodes.length - 1] @@ -87,16 +81,25 @@ const buildHorizontalHelpLine = ({ const helpLine = { top: firstPos.y, left: firstPos.x, - width: lastPos.x + (isEntryNode(last!) ? getEntryNodeDimension(last!, 'width') : last!.width ?? 0) - firstPos.x, + width: + lastPos.x + + (isEntryNode(last!) ? getEntryNodeDimension(last!, 'width') : (last!.width ?? 0)) - + firstPos.x, } if (nodeAlignPos.x < firstPos.x) { helpLine.left = nodeAlignPos.x - helpLine.width = firstPos.x + (isEntryNode(first!) ? getEntryNodeDimension(first!, 'width') : first!.width ?? 0) - nodeAlignPos.x + helpLine.width = + firstPos.x + + (isEntryNode(first!) ? getEntryNodeDimension(first!, 'width') : (first!.width ?? 0)) - + nodeAlignPos.x } if (nodeAlignPos.x > lastPos.x) - helpLine.width = nodeAlignPos.x + (isEntryNode(node) ? getEntryNodeDimension(node, 'width') : node.width ?? 0) - firstPos.x + helpLine.width = + nodeAlignPos.x + + (isEntryNode(node) ? getEntryNodeDimension(node, 'width') : (node.width ?? 0)) - + firstPos.x return helpLine } @@ -114,8 +117,7 @@ const buildVerticalHelpLine = ({ getNodeAlignPosition: (node: Node) => NodeAlignPosition isEntryNode: (node: Node) => boolean }) => { - if (!alignedNodes.length) - return undefined + if (!alignedNodes.length) return undefined const first = alignedNodes[0] const last = alignedNodes[alignedNodes.length - 1] @@ -124,16 +126,25 @@ const buildVerticalHelpLine = ({ const helpLine = { top: firstPos.y, left: firstPos.x, - height: lastPos.y + (isEntryNode(last!) ? getEntryNodeDimension(last!, 'height') : last!.height ?? 0) - firstPos.y, + height: + lastPos.y + + (isEntryNode(last!) ? getEntryNodeDimension(last!, 'height') : (last!.height ?? 0)) - + firstPos.y, } if (nodeAlignPos.y < firstPos.y) { helpLine.top = nodeAlignPos.y - helpLine.height = firstPos.y + (isEntryNode(first!) ? getEntryNodeDimension(first!, 'height') : first!.height ?? 0) - nodeAlignPos.y + helpLine.height = + firstPos.y + + (isEntryNode(first!) ? getEntryNodeDimension(first!, 'height') : (first!.height ?? 0)) - + nodeAlignPos.y } if (nodeAlignPos.y > lastPos.y) - helpLine.height = nodeAlignPos.y + (isEntryNode(node) ? getEntryNodeDimension(node, 'height') : node.height ?? 0) - firstPos.y + helpLine.height = + nodeAlignPos.y + + (isEntryNode(node) ? getEntryNodeDimension(node, 'height') : (node.height ?? 0)) - + firstPos.y return helpLine } @@ -148,79 +159,86 @@ export const useHelpline = () => { }, []) // Get the actual alignment position of a node (accounting for wrapper offset) - const getNodeAlignPosition = useCallback((node: Node) => { - if (isEntryNode(node)) { - return { - x: node.position.x + ENTRY_NODE_WRAPPER_OFFSET.x, - y: node.position.y + ENTRY_NODE_WRAPPER_OFFSET.y, + const getNodeAlignPosition = useCallback( + (node: Node) => { + if (isEntryNode(node)) { + return { + x: node.position.x + ENTRY_NODE_WRAPPER_OFFSET.x, + y: node.position.y + ENTRY_NODE_WRAPPER_OFFSET.y, + } } - } - return { - x: node.position.x, - y: node.position.y, - } - }, [isEntryNode]) - - const handleSetHelpline = useCallback((node: Node) => { - const { getNodes } = store.getState() - const nodes = getNodes() - const { - setHelpLineHorizontal, - setHelpLineVertical, - } = workflowStore.getState() - - if (node.data.isInIteration) { return { - showHorizontalHelpLineNodes: [], - showVerticalHelpLineNodes: [], + x: node.position.x, + y: node.position.y, } - } - - if (node.data.isInLoop) { - return { - showHorizontalHelpLineNodes: [], - showVerticalHelpLineNodes: [], + }, + [isEntryNode], + ) + + const handleSetHelpline = useCallback( + (node: Node) => { + const { getNodes } = store.getState() + const nodes = getNodes() + const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState() + + if (node.data.isInIteration) { + return { + showHorizontalHelpLineNodes: [], + showVerticalHelpLineNodes: [], + } } - } - // Get the actual alignment position for the dragging node - const nodeAlignPos = getNodeAlignPosition(node) + if (node.data.isInLoop) { + return { + showHorizontalHelpLineNodes: [], + showVerticalHelpLineNodes: [], + } + } - const showHorizontalHelpLineNodes = getAlignedNodes({ - nodes, - node, - nodeAlignPos, - axis: 'y', - getNodeAlignPosition, - }) - const showVerticalHelpLineNodes = getAlignedNodes({ - nodes, - node, - nodeAlignPos, - axis: 'x', - getNodeAlignPosition, - }) + // Get the actual alignment position for the dragging node + const nodeAlignPos = getNodeAlignPosition(node) + + const showHorizontalHelpLineNodes = getAlignedNodes({ + nodes, + node, + nodeAlignPos, + axis: 'y', + getNodeAlignPosition, + }) + const showVerticalHelpLineNodes = getAlignedNodes({ + nodes, + node, + nodeAlignPos, + axis: 'x', + getNodeAlignPosition, + }) + + setHelpLineHorizontal( + buildHorizontalHelpLine({ + alignedNodes: showHorizontalHelpLineNodes, + node, + nodeAlignPos, + getNodeAlignPosition, + isEntryNode, + }), + ) + setHelpLineVertical( + buildVerticalHelpLine({ + alignedNodes: showVerticalHelpLineNodes, + node, + nodeAlignPos, + getNodeAlignPosition, + isEntryNode, + }), + ) - setHelpLineHorizontal(buildHorizontalHelpLine({ - alignedNodes: showHorizontalHelpLineNodes, - node, - nodeAlignPos, - getNodeAlignPosition, - isEntryNode, - })) - setHelpLineVertical(buildVerticalHelpLine({ - alignedNodes: showVerticalHelpLineNodes, - node, - nodeAlignPos, - getNodeAlignPosition, - isEntryNode, - })) - - return { - showHorizontalHelpLineNodes, - showVerticalHelpLineNodes, - } satisfies HelpLineNodeCollections - }, [store, workflowStore, getNodeAlignPosition, isEntryNode]) + return { + showHorizontalHelpLineNodes, + showVerticalHelpLineNodes, + } satisfies HelpLineNodeCollections + }, + [store, workflowStore, getNodeAlignPosition, isEntryNode], + ) return { handleSetHelpline, diff --git a/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts b/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts index 2bacef7028f20f..b3c8a4ea4a0e18 100644 --- a/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts +++ b/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts @@ -28,10 +28,7 @@ type Params = { flowId: string flowType: FlowType } -export const useInspectVarsCrudCommon = ({ - flowId, - flowType, -}: Params) => { +export const useInspectVarsCrudCommon = ({ flowId, flowType }: Params) => { const workflowStore = useWorkflowStore() const store = useStoreApi() const { @@ -61,146 +58,189 @@ export const useInspectVarsCrudCommon = ({ const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() - const getNodeInspectVars = useCallback((nodeId: string) => { - const { nodesWithInspectVars } = workflowStore.getState() - const node = nodesWithInspectVars.find(node => node.nodeId === nodeId) - return node - }, [workflowStore]) + const getNodeInspectVars = useCallback( + (nodeId: string) => { + const { nodesWithInspectVars } = workflowStore.getState() + const node = nodesWithInspectVars.find((node) => node.nodeId === nodeId) + return node + }, + [workflowStore], + ) - const getVarId = useCallback((nodeId: string, varName: string) => { - const node = getNodeInspectVars(nodeId) - if (!node) - return undefined - const varId = node.vars.find((varItem) => { - return varItem.selector[1] === varName - })?.id - return varId - }, [getNodeInspectVars]) + const getVarId = useCallback( + (nodeId: string, varName: string) => { + const node = getNodeInspectVars(nodeId) + if (!node) return undefined + const varId = node.vars.find((varItem) => { + return varItem.selector[1] === varName + })?.id + return varId + }, + [getNodeInspectVars], + ) - const getInspectVar = useCallback((nodeId: string, name: string): VarInInspect | undefined => { - const node = getNodeInspectVars(nodeId) - if (!node) - return undefined + const getInspectVar = useCallback( + (nodeId: string, name: string): VarInInspect | undefined => { + const node = getNodeInspectVars(nodeId) + if (!node) return undefined - const variable = node.vars.find((varItem) => { - return varItem.name === name - }) - return variable - }, [getNodeInspectVars]) + const variable = node.vars.find((varItem) => { + return varItem.name === name + }) + return variable + }, + [getNodeInspectVars], + ) - const hasSetInspectVar = useCallback((nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => { - const isEnv = isENV([nodeId]) - if (isEnv) // always have value - return true - const isSys = isSystemVar([nodeId]) - if (isSys) - return sysVars.some(varItem => varItem.selector?.[1]?.replace('sys.', '') === name) - const isChatVar = isConversationVar([nodeId]) - if (isChatVar) - return conversationVars.some(varItem => varItem.selector?.[1] === name) - return getInspectVar(nodeId, name) !== undefined - }, [getInspectVar]) + const hasSetInspectVar = useCallback( + (nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => { + const isEnv = isENV([nodeId]) + if (isEnv) + // always have value + return true + const isSys = isSystemVar([nodeId]) + if (isSys) + return sysVars.some((varItem) => varItem.selector?.[1]?.replace('sys.', '') === name) + const isChatVar = isConversationVar([nodeId]) + if (isChatVar) return conversationVars.some((varItem) => varItem.selector?.[1] === name) + return getInspectVar(nodeId, name) !== undefined + }, + [getInspectVar], + ) - const hasNodeInspectVars = useCallback((nodeId: string) => { - return !!getNodeInspectVars(nodeId) - }, [getNodeInspectVars]) + const hasNodeInspectVars = useCallback( + (nodeId: string) => { + return !!getNodeInspectVars(nodeId) + }, + [getNodeInspectVars], + ) - const fetchInspectVarValue = useCallback(async (selector: ValueSelector, schemaTypeDefinitions: SchemaTypeDefinition[]) => { - const { - setNodeInspectVars, - dataSourceList, - } = workflowStore.getState() - const nodeId = selector[0] - const isSystemVar = nodeId === 'sys' - const isConversationVar = nodeId === 'conversation' - if (isSystemVar) { - invalidateSysVarValues() - return - } - if (isConversationVar) { - invalidateConversationVarValues() - return - } - const { getNodes } = store.getState() - const nodeArr = getNodes() - const currentNode = nodeArr.find(node => node.id === nodeId) - const allPluginInfoList = { - buildInTools: buildInTools || [], - customTools: customTools || [], - workflowTools: workflowTools || [], - mcpTools: mcpTools || [], - dataSourceList: dataSourceList || [], - } - const currentNodeOutputVars = toNodeOutputVars([currentNode], false, () => true, [], [], [], allPluginInfoList, schemaTypeDefinitions) - const vars = await fetchNodeInspectVars(flowType, flowId, nodeId!) - const varsWithSchemaType = vars.map((varItem) => { - const schemaType = currentNodeOutputVars[0]?.vars.find(v => v.variable === varItem.name)?.schemaType || '' - return { - ...varItem, - schemaType, + const fetchInspectVarValue = useCallback( + async (selector: ValueSelector, schemaTypeDefinitions: SchemaTypeDefinition[]) => { + const { setNodeInspectVars, dataSourceList } = workflowStore.getState() + const nodeId = selector[0] + const isSystemVar = nodeId === 'sys' + const isConversationVar = nodeId === 'conversation' + if (isSystemVar) { + invalidateSysVarValues() + return + } + if (isConversationVar) { + invalidateConversationVarValues() + return + } + const { getNodes } = store.getState() + const nodeArr = getNodes() + const currentNode = nodeArr.find((node) => node.id === nodeId) + const allPluginInfoList = { + buildInTools: buildInTools || [], + customTools: customTools || [], + workflowTools: workflowTools || [], + mcpTools: mcpTools || [], + dataSourceList: dataSourceList || [], } - }) - setNodeInspectVars(nodeId!, varsWithSchemaType) - }, [workflowStore, flowType, flowId, invalidateSysVarValues, invalidateConversationVarValues, buildInTools, customTools, workflowTools, mcpTools]) + const currentNodeOutputVars = toNodeOutputVars( + [currentNode], + false, + () => true, + [], + [], + [], + allPluginInfoList, + schemaTypeDefinitions, + ) + const vars = await fetchNodeInspectVars(flowType, flowId, nodeId!) + const varsWithSchemaType = vars.map((varItem) => { + const schemaType = + currentNodeOutputVars[0]?.vars.find((v) => v.variable === varItem.name)?.schemaType || '' + return { + ...varItem, + schemaType, + } + }) + setNodeInspectVars(nodeId!, varsWithSchemaType) + }, + [ + workflowStore, + flowType, + flowId, + invalidateSysVarValues, + invalidateConversationVarValues, + buildInTools, + customTools, + workflowTools, + mcpTools, + ], + ) // after last run would call this - const appendNodeInspectVars = useCallback((nodeId: string, payload: VarInInspect[], allNodes: Node[]) => { - const { - nodesWithInspectVars, - setNodesWithInspectVars, - } = workflowStore.getState() - const nodes = produce(nodesWithInspectVars, (draft) => { - const nodeInfo = allNodes.find(node => node.id === nodeId) - if (nodeInfo) { - const index = draft.findIndex(node => node.nodeId === nodeId) - if (index === -1) { - draft.unshift({ - nodeId, - nodeType: nodeInfo.data.type, - title: nodeInfo.data.title, - vars: payload, - nodePayload: nodeInfo.data, - }) - } - else { - draft[index]!.vars = payload - // put the node to the topAdd commentMore actions - draft.unshift(draft.splice(index, 1)[0]!) + const appendNodeInspectVars = useCallback( + (nodeId: string, payload: VarInInspect[], allNodes: Node[]) => { + const { nodesWithInspectVars, setNodesWithInspectVars } = workflowStore.getState() + const nodes = produce(nodesWithInspectVars, (draft) => { + const nodeInfo = allNodes.find((node) => node.id === nodeId) + if (nodeInfo) { + const index = draft.findIndex((node) => node.nodeId === nodeId) + if (index === -1) { + draft.unshift({ + nodeId, + nodeType: nodeInfo.data.type, + title: nodeInfo.data.title, + vars: payload, + nodePayload: nodeInfo.data, + }) + } else { + draft[index]!.vars = payload + // put the node to the topAdd commentMore actions + draft.unshift(draft.splice(index, 1)[0]!) + } } - } - }) - setNodesWithInspectVars(nodes) - handleCancelNodeSuccessStatus(nodeId) - }, [workflowStore, handleCancelNodeSuccessStatus]) + }) + setNodesWithInspectVars(nodes) + handleCancelNodeSuccessStatus(nodeId) + }, + [workflowStore, handleCancelNodeSuccessStatus], + ) - const hasNodeInspectVar = useCallback((nodeId: string, varId: string) => { - const { nodesWithInspectVars } = workflowStore.getState() - const targetNode = nodesWithInspectVars.find(item => item.nodeId === nodeId) - if (!targetNode || !targetNode.vars) - return false - return targetNode.vars.some(item => item.id === varId) - }, [workflowStore]) + const hasNodeInspectVar = useCallback( + (nodeId: string, varId: string) => { + const { nodesWithInspectVars } = workflowStore.getState() + const targetNode = nodesWithInspectVars.find((item) => item.nodeId === nodeId) + if (!targetNode || !targetNode.vars) return false + return targetNode.vars.some((item) => item.id === varId) + }, + [workflowStore], + ) - const deleteInspectVar = useCallback(async (nodeId: string, varId: string) => { - const { deleteInspectVar } = workflowStore.getState() - if (hasNodeInspectVar(nodeId, varId)) { - await doDeleteInspectVar(varId) - deleteInspectVar(nodeId, varId) - } - }, [doDeleteInspectVar, workflowStore, hasNodeInspectVar]) + const deleteInspectVar = useCallback( + async (nodeId: string, varId: string) => { + const { deleteInspectVar } = workflowStore.getState() + if (hasNodeInspectVar(nodeId, varId)) { + await doDeleteInspectVar(varId) + deleteInspectVar(nodeId, varId) + } + }, + [doDeleteInspectVar, workflowStore, hasNodeInspectVar], + ) - const resetConversationVar = useCallback(async (varId: string) => { - await doResetConversationVar(varId) - invalidateConversationVarValues() - }, [doResetConversationVar, invalidateConversationVarValues]) + const resetConversationVar = useCallback( + async (varId: string) => { + await doResetConversationVar(varId) + invalidateConversationVarValues() + }, + [doResetConversationVar, invalidateConversationVarValues], + ) - const deleteNodeInspectorVars = useCallback(async (nodeId: string) => { - const { deleteNodeInspectVars } = workflowStore.getState() - if (hasNodeInspectVars(nodeId)) { - await doDeleteNodeInspectorVars(nodeId) - deleteNodeInspectVars(nodeId) - } - }, [doDeleteNodeInspectorVars, workflowStore, hasNodeInspectVars]) + const deleteNodeInspectorVars = useCallback( + async (nodeId: string) => { + const { deleteNodeInspectVars } = workflowStore.getState() + if (hasNodeInspectVars(nodeId)) { + await doDeleteNodeInspectorVars(nodeId) + deleteNodeInspectVars(nodeId) + } + }, + [doDeleteNodeInspectorVars, workflowStore, hasNodeInspectVars], + ) const deleteAllInspectorVars = useCallback(async () => { const { deleteAllInspectVars } = workflowStore.getState() @@ -209,53 +249,65 @@ export const useInspectVarsCrudCommon = ({ await invalidateSysVarValues() deleteAllInspectVars() handleEdgeCancelRunningStatus() - }, [doDeleteAllInspectorVars, invalidateConversationVarValues, invalidateSysVarValues, workflowStore, handleEdgeCancelRunningStatus]) + }, [ + doDeleteAllInspectorVars, + invalidateConversationVarValues, + invalidateSysVarValues, + workflowStore, + handleEdgeCancelRunningStatus, + ]) - const editInspectVarValue = useCallback(async (nodeId: string, varId: string, value: any) => { - const { setInspectVarValue } = workflowStore.getState() - await doEditInspectorVar({ - varId, - value, - }) - setInspectVarValue(nodeId, varId, value) - if (nodeId === VarInInspectType.conversation) - invalidateConversationVarValues() - if (nodeId === VarInInspectType.system) - invalidateSysVarValues() - }, [doEditInspectorVar, invalidateConversationVarValues, invalidateSysVarValues, workflowStore]) + const editInspectVarValue = useCallback( + async (nodeId: string, varId: string, value: any) => { + const { setInspectVarValue } = workflowStore.getState() + await doEditInspectorVar({ + varId, + value, + }) + setInspectVarValue(nodeId, varId, value) + if (nodeId === VarInInspectType.conversation) invalidateConversationVarValues() + if (nodeId === VarInInspectType.system) invalidateSysVarValues() + }, + [doEditInspectorVar, invalidateConversationVarValues, invalidateSysVarValues, workflowStore], + ) - const renameInspectVarName = useCallback(async (nodeId: string, oldName: string, newName: string) => { - const { renameInspectVarName } = workflowStore.getState() - const varId = getVarId(nodeId, oldName) - if (!varId) - return + const renameInspectVarName = useCallback( + async (nodeId: string, oldName: string, newName: string) => { + const { renameInspectVarName } = workflowStore.getState() + const varId = getVarId(nodeId, oldName) + if (!varId) return - const newSelector = [nodeId, newName] - await doEditInspectorVar({ - varId, - name: newName, - }) - renameInspectVarName(nodeId, varId, newSelector) - }, [doEditInspectorVar, getVarId, workflowStore]) + const newSelector = [nodeId, newName] + await doEditInspectorVar({ + varId, + name: newName, + }) + renameInspectVarName(nodeId, varId, newSelector) + }, + [doEditInspectorVar, getVarId, workflowStore], + ) - const isInspectVarEdited = useCallback((nodeId: string, name: string) => { - const inspectVar = getInspectVar(nodeId, name) - if (!inspectVar) - return false + const isInspectVarEdited = useCallback( + (nodeId: string, name: string) => { + const inspectVar = getInspectVar(nodeId, name) + if (!inspectVar) return false - return inspectVar.edited - }, [getInspectVar]) + return inspectVar.edited + }, + [getInspectVar], + ) - const resetToLastRunVar = useCallback(async (nodeId: string, varId: string) => { - const { resetToLastRunVar } = workflowStore.getState() - const isSysVar = nodeId === 'sys' - const data = await doResetToLastRunValue(varId) + const resetToLastRunVar = useCallback( + async (nodeId: string, varId: string) => { + const { resetToLastRunVar } = workflowStore.getState() + const isSysVar = nodeId === 'sys' + const data = await doResetToLastRunValue(varId) - if (isSysVar) - invalidateSysVarValues() - else - resetToLastRunVar(nodeId, varId, data.value) - }, [doResetToLastRunValue, invalidateSysVarValues, workflowStore]) + if (isSysVar) invalidateSysVarValues() + else resetToLastRunVar(nodeId, varId, data.value) + }, + [doResetToLastRunValue, invalidateSysVarValues, workflowStore], + ) return { hasNodeInspectVars, diff --git a/web/app/components/workflow/hooks/use-inspect-vars-crud.ts b/web/app/components/workflow/hooks/use-inspect-vars-crud.ts index b978455a075b55..0a0d76f6587920 100644 --- a/web/app/components/workflow/hooks/use-inspect-vars-crud.ts +++ b/web/app/components/workflow/hooks/use-inspect-vars-crud.ts @@ -1,32 +1,29 @@ import { produce } from 'immer' import { useHooksStore } from '@/app/components/workflow/hooks-store' -import { - useConversationVarValues, - useSysVarValues, -} from '@/service/use-workflow' +import { useConversationVarValues, useSysVarValues } from '@/service/use-workflow' import { FlowType } from '@/types/common' import { useStore } from '../store' import { BlockEnum } from '../types' const varsAppendStartNodeKeys = ['query', 'files'] const useInspectVarsCrud = () => { - const partOfNodesWithInspectVars = useStore(s => s.nodesWithInspectVars) - const configsMap = useHooksStore(s => s.configsMap) - const shouldSkipSharedVariableQueries = configsMap?.flowType === FlowType.ragPipeline - || configsMap?.flowType === FlowType.snippet + const partOfNodesWithInspectVars = useStore((s) => s.nodesWithInspectVars) + const configsMap = useHooksStore((s) => s.configsMap) + const shouldSkipSharedVariableQueries = + configsMap?.flowType === FlowType.ragPipeline || configsMap?.flowType === FlowType.snippet const variableFlowId = shouldSkipSharedVariableQueries ? '' : configsMap?.flowId const { data: conversationVars } = useConversationVarValues(configsMap?.flowType, variableFlowId) const { data: allSystemVars } = useSysVarValues(configsMap?.flowType, variableFlowId) const { varsAppendStartNode, systemVars } = (() => { - if (allSystemVars?.length === 0) - return { varsAppendStartNode: [], systemVars: [] } - const varsAppendStartNode = allSystemVars?.filter(({ name }) => varsAppendStartNodeKeys.includes(name)) || [] - const systemVars = allSystemVars?.filter(({ name }) => !varsAppendStartNodeKeys.includes(name)) || [] + if (allSystemVars?.length === 0) return { varsAppendStartNode: [], systemVars: [] } + const varsAppendStartNode = + allSystemVars?.filter(({ name }) => varsAppendStartNodeKeys.includes(name)) || [] + const systemVars = + allSystemVars?.filter(({ name }) => !varsAppendStartNodeKeys.includes(name)) || [] return { varsAppendStartNode, systemVars } })() const nodesWithInspectVars = (() => { - if (!partOfNodesWithInspectVars || partOfNodesWithInspectVars.length === 0) - return [] + if (!partOfNodesWithInspectVars || partOfNodesWithInspectVars.length === 0) return [] const nodesWithInspectVars = produce(partOfNodesWithInspectVars, (draft) => { draft.forEach((nodeWithVars) => { @@ -36,20 +33,20 @@ const useInspectVarsCrud = () => { }) return nodesWithInspectVars })() - const hasNodeInspectVars = useHooksStore(s => s.hasNodeInspectVars) - const hasSetInspectVar = useHooksStore(s => s.hasSetInspectVar) - const fetchInspectVarValue = useHooksStore(s => s.fetchInspectVarValue) - const editInspectVarValue = useHooksStore(s => s.editInspectVarValue) - const renameInspectVarName = useHooksStore(s => s.renameInspectVarName) - const appendNodeInspectVars = useHooksStore(s => s.appendNodeInspectVars) - const deleteInspectVar = useHooksStore(s => s.deleteInspectVar) - const deleteNodeInspectorVars = useHooksStore(s => s.deleteNodeInspectorVars) - const deleteAllInspectorVars = useHooksStore(s => s.deleteAllInspectorVars) - const isInspectVarEdited = useHooksStore(s => s.isInspectVarEdited) - const resetToLastRunVar = useHooksStore(s => s.resetToLastRunVar) - const invalidateSysVarValues = useHooksStore(s => s.invalidateSysVarValues) - const resetConversationVar = useHooksStore(s => s.resetConversationVar) - const invalidateConversationVarValues = useHooksStore(s => s.invalidateConversationVarValues) + const hasNodeInspectVars = useHooksStore((s) => s.hasNodeInspectVars) + const hasSetInspectVar = useHooksStore((s) => s.hasSetInspectVar) + const fetchInspectVarValue = useHooksStore((s) => s.fetchInspectVarValue) + const editInspectVarValue = useHooksStore((s) => s.editInspectVarValue) + const renameInspectVarName = useHooksStore((s) => s.renameInspectVarName) + const appendNodeInspectVars = useHooksStore((s) => s.appendNodeInspectVars) + const deleteInspectVar = useHooksStore((s) => s.deleteInspectVar) + const deleteNodeInspectorVars = useHooksStore((s) => s.deleteNodeInspectorVars) + const deleteAllInspectorVars = useHooksStore((s) => s.deleteAllInspectorVars) + const isInspectVarEdited = useHooksStore((s) => s.isInspectVarEdited) + const resetToLastRunVar = useHooksStore((s) => s.resetToLastRunVar) + const invalidateSysVarValues = useHooksStore((s) => s.invalidateSysVarValues) + const resetConversationVar = useHooksStore((s) => s.resetConversationVar) + const invalidateConversationVarValues = useHooksStore((s) => s.invalidateConversationVarValues) return { conversationVars: conversationVars || [], diff --git a/web/app/components/workflow/hooks/use-node-data-update.ts b/web/app/components/workflow/hooks/use-node-data-update.ts index e3ee6455434968..bca8dea84934b5 100644 --- a/web/app/components/workflow/hooks/use-node-data-update.ts +++ b/web/app/components/workflow/hooks/use-node-data-update.ts @@ -15,31 +15,35 @@ export const useNodeDataUpdate = () => { const { getNodesReadOnly } = useNodesReadOnly() const collaborativeWorkflow = useCollaborativeWorkflow() - const handleNodeDataUpdate = useCallback(({ id, data }: NodeDataUpdatePayload) => { - const { nodes, setNodes } = collaborativeWorkflow.getState() - const newNodes = produce(nodes, (draft) => { - const currentNode = draft.find(node => node.id === id)! + const handleNodeDataUpdate = useCallback( + ({ id, data }: NodeDataUpdatePayload) => { + const { nodes, setNodes } = collaborativeWorkflow.getState() + const newNodes = produce(nodes, (draft) => { + const currentNode = draft.find((node) => node.id === id)! - if (currentNode) - currentNode.data = { ...currentNode.data, ...data } - }) - setNodes(newNodes) - }, [collaborativeWorkflow]) - - const handleNodeDataUpdateWithSyncDraft = useCallback(( - payload: NodeDataUpdatePayload, - options?: { - sync?: boolean - notRefreshWhenSyncError?: boolean - callback?: SyncCallback + if (currentNode) currentNode.data = { ...currentNode.data, ...data } + }) + setNodes(newNodes) }, - ) => { - if (getNodesReadOnly()) - return + [collaborativeWorkflow], + ) + + const handleNodeDataUpdateWithSyncDraft = useCallback( + ( + payload: NodeDataUpdatePayload, + options?: { + sync?: boolean + notRefreshWhenSyncError?: boolean + callback?: SyncCallback + }, + ) => { + if (getNodesReadOnly()) return - handleNodeDataUpdate(payload) - handleSyncWorkflowDraft(options?.sync, options?.notRefreshWhenSyncError, options?.callback) - }, [handleSyncWorkflowDraft, handleNodeDataUpdate, getNodesReadOnly]) + handleNodeDataUpdate(payload) + handleSyncWorkflowDraft(options?.sync, options?.notRefreshWhenSyncError, options?.callback) + }, + [handleSyncWorkflowDraft, handleNodeDataUpdate, getNodesReadOnly], + ) return { handleNodeDataUpdate, diff --git a/web/app/components/workflow/hooks/use-node-plugin-installation.ts b/web/app/components/workflow/hooks/use-node-plugin-installation.ts index 1ac2e496b30a19..1f276fd90b6403 100644 --- a/web/app/components/workflow/hooks/use-node-plugin-installation.ts +++ b/web/app/components/workflow/hooks/use-node-plugin-installation.ts @@ -13,10 +13,7 @@ import { useAllWorkflowTools, useInvalidToolsByType, } from '@/service/use-tools' -import { - useAllTriggerPlugins, - useInvalidateAllTriggerPlugins, -} from '@/service/use-triggers' +import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers' import { useStore } from '../store' import { BlockEnum } from '../types' import { @@ -43,7 +40,11 @@ const NOOP_INSTALLATION: InstallationState = { shouldDim: false, } -const useToolInstallation = (data: ToolNodeType, enabled: boolean, canInstallPlugin: boolean): InstallationState => { +const useToolInstallation = ( + data: ToolNodeType, + enabled: boolean, + canInstallPlugin: boolean, +): InstallationState => { const isBuiltIn = enabled && data.provider_type === CollectionType.builtIn const isCustom = enabled && data.provider_type === CollectionType.custom const isWorkflow = enabled && data.provider_type === CollectionType.workflow @@ -56,8 +57,7 @@ const useToolInstallation = (data: ToolNodeType, enabled: boolean, canInstallPlu const invalidateTools = useInvalidToolsByType(enabled ? data.provider_type : undefined) const collectionInfo = useMemo(() => { - if (!enabled) - return undefined + if (!enabled) return undefined switch (data.provider_type) { case CollectionType.builtIn: @@ -102,8 +102,7 @@ const useToolInstallation = (data: ToolNodeType, enabled: boolean, canInstallPlu const { plugin_id, provider_id, provider_name } = data const matchedCollection = useMemo(() => { - if (!collection || !collection.length) - return undefined + if (!collection || !collection.length) return undefined return matchToolInCollection(collection, { plugin_id, provider_id, provider_name }) }, [collection, plugin_id, provider_id, provider_name]) @@ -112,8 +111,7 @@ const useToolInstallation = (data: ToolNodeType, enabled: boolean, canInstallPlu const canInstall = Boolean(data.plugin_unique_identifier) && canInstallPlugin const onInstallSuccess = useCallback(() => { - if (invalidateTools) - invalidateTools() + if (invalidateTools) invalidateTools() }, [invalidateTools]) const shouldDim = (!!collectionInfo && !isResolved) || (isResolved && !matchedCollection) @@ -128,7 +126,11 @@ const useToolInstallation = (data: ToolNodeType, enabled: boolean, canInstallPlu } } -const useTriggerInstallation = (data: PluginTriggerNodeType, enabled: boolean, canInstallPlugin: boolean): InstallationState => { +const useTriggerInstallation = ( + data: PluginTriggerNodeType, + enabled: boolean, + canInstallPlugin: boolean, +): InstallationState => { const triggerPluginsQuery = useAllTriggerPlugins(enabled) const invalidateTriggers = useInvalidateAllTriggerPlugins() @@ -137,8 +139,7 @@ const useTriggerInstallation = (data: PluginTriggerNodeType, enabled: boolean, c const { plugin_id, provider_id, provider_name } = data const matchedProvider = useMemo(() => { - if (!triggerProviders || !triggerProviders.length) - return undefined + if (!triggerProviders || !triggerProviders.length) return undefined return matchTriggerProvider(triggerProviders, { plugin_id, provider_id, provider_name }) }, [plugin_id, provider_id, provider_name, triggerProviders]) @@ -162,14 +163,17 @@ const useTriggerInstallation = (data: PluginTriggerNodeType, enabled: boolean, c } } -const useDataSourceInstallation = (data: DataSourceNodeType, _enabled: boolean, canInstallPlugin: boolean): InstallationState => { - const dataSourceList = useStore(s => s.dataSourceList) +const useDataSourceInstallation = ( + data: DataSourceNodeType, + _enabled: boolean, + canInstallPlugin: boolean, +): InstallationState => { + const dataSourceList = useStore((s) => s.dataSourceList) const invalidateDataSourceList = useInvalidDataSourceList() const { plugin_unique_identifier, plugin_id, provider_name } = data const matchedPlugin = useMemo(() => { - if (!dataSourceList || !dataSourceList.length) - return undefined + if (!dataSourceList || !dataSourceList.length) return undefined return matchDataSource(dataSourceList, { plugin_unique_identifier, plugin_id, provider_name }) }, [dataSourceList, plugin_id, plugin_unique_identifier, provider_name]) @@ -202,15 +206,20 @@ export const useNodePluginInstallation = (data: CommonNodeType): InstallationSta const { canInstallPlugin } = useWorkspacePluginInstallPermission() const toolInstallation = useToolInstallation(data as ToolNodeType, isTool, canInstallPlugin) - const triggerInstallation = useTriggerInstallation(data as PluginTriggerNodeType, isTrigger, canInstallPlugin) - const dataSourceInstallation = useDataSourceInstallation(data as DataSourceNodeType, isDataSource, canInstallPlugin) - - if (isTool) - return toolInstallation - if (isTrigger) - return triggerInstallation - if (isDataSource) - return dataSourceInstallation + const triggerInstallation = useTriggerInstallation( + data as PluginTriggerNodeType, + isTrigger, + canInstallPlugin, + ) + const dataSourceInstallation = useDataSourceInstallation( + data as DataSourceNodeType, + isDataSource, + canInstallPlugin, + ) + + if (isTool) return toolInstallation + if (isTrigger) return triggerInstallation + if (isDataSource) return dataSourceInstallation return NOOP_INSTALLATION } diff --git a/web/app/components/workflow/hooks/use-nodes-available-var-list.ts b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts index ee93c6930e7c01..38030ec1109a86 100644 --- a/web/app/components/workflow/hooks/use-nodes-available-var-list.ts +++ b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts @@ -2,11 +2,7 @@ import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/w import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useSnippetDraftStore } from '@/app/components/snippets/draft-store' -import { - useIsChatMode, - useWorkflow, - useWorkflowVariables, -} from '@/app/components/workflow/hooks' +import { useIsChatMode, useWorkflow, useWorkflowVariables } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store/store' import { appendSnippetInputFieldVars, @@ -26,11 +22,11 @@ type Params = { const getNodeInfo = (nodeId: string, nodes: Node[]) => { const allNodes = nodes - const node = allNodes.find(n => n.id === nodeId) + const node = allNodes.find((n) => n.id === nodeId) const isInIteration = !!node?.data.isInIteration const isInLoop = !!node?.data.isInLoop const parentNodeId = node?.parentId - const parentNode = allNodes.find(n => n.id === parentNodeId) + const parentNode = allNodes.find((n) => n.id === parentNodeId) return { node, isInIteration, @@ -40,51 +36,59 @@ const getNodeInfo = (nodeId: string, nodes: Node[]) => { } // TODO: loop type? -const useNodesAvailableVarList = (nodes: Node[], { - onlyLeafNodeVar, - filterVar, - hideEnv = false, - hideChatVar = false, - passedInAvailableNodes, -}: Params = { - onlyLeafNodeVar: false, - filterVar: () => true, -}) => { +const useNodesAvailableVarList = ( + nodes: Node[], + { + onlyLeafNodeVar, + filterVar, + hideEnv = false, + hideChatVar = false, + passedInAvailableNodes, + }: Params = { + onlyLeafNodeVar: false, + filterVar: () => true, + }, +) => { const { t } = useTranslation() - const snippetInputFields = useSnippetDraftStore(s => s.inputFields) + const snippetInputFields = useSnippetDraftStore((s) => s.inputFields) const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() - const isSnippetFlow = useHooksStore(s => s.configsMap?.flowType) === FlowType.snippet || isSnippetCanvas() + const isSnippetFlow = + useHooksStore((s) => s.configsMap?.flowType) === FlowType.snippet || isSnippetCanvas() - const nodeAvailabilityMap: { [key: string ]: { availableVars: NodeOutPutVar[], availableNodes: Node[] } } = {} + const nodeAvailabilityMap: { + [key: string]: { availableVars: NodeOutPutVar[]; availableNodes: Node[] } + } = {} nodes.forEach((node) => { const nodeId = node.id - const availableNodes = passedInAvailableNodes || (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranchIncludeParent(nodeId)) - if (node.data.type === BlockEnum.Loop) - availableNodes.push(node) + const availableNodes = + passedInAvailableNodes || + (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranchIncludeParent(nodeId)) + if (node.data.type === BlockEnum.Loop) availableNodes.push(node) const snippetInputFieldAvailability = appendSnippetInputFieldVars({ availableNodes, fields: snippetInputFields, - title: t($ => $.panelTitle, { ns: 'snippet' }), + title: t(($) => $.panelTitle, { ns: 'snippet' }), }) - const { - parentNode: iterationNode, - } = getNodeInfo(nodeId, nodes) + const { parentNode: iterationNode } = getNodeInfo(nodeId, nodes) - const availableVars = filterSnippetSystemVars([ - ...snippetInputFieldAvailability.availableVars, - ...getNodeAvailableVars({ - parentNode: iterationNode, - beforeNodes: availableNodes, - isChatMode, - filterVar, - hideEnv, - hideChatVar, - }), - ], isSnippetFlow) + const availableVars = filterSnippetSystemVars( + [ + ...snippetInputFieldAvailability.availableVars, + ...getNodeAvailableVars({ + parentNode: iterationNode, + beforeNodes: availableNodes, + isChatMode, + filterVar, + hideEnv, + hideChatVar, + }), + ], + isSnippetFlow, + ) const result = { node, availableVars, @@ -97,58 +101,73 @@ const useNodesAvailableVarList = (nodes: Node[], { export const useGetNodesAvailableVarList = () => { const { t } = useTranslation() - const snippetInputFields = useSnippetDraftStore(s => s.inputFields) + const snippetInputFields = useSnippetDraftStore((s) => s.inputFields) const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() - const isSnippetFlow = useHooksStore(s => s.configsMap?.flowType) === FlowType.snippet || isSnippetCanvas() - const getNodesAvailableVarList = useCallback((nodes: Node[], { - onlyLeafNodeVar, - filterVar, - hideEnv, - hideChatVar, - passedInAvailableNodes, - }: Params = { - onlyLeafNodeVar: false, - filterVar: () => true, - }) => { - const nodeAvailabilityMap: { [key: string ]: { availableVars: NodeOutPutVar[], availableNodes: Node[] } } = {} + const isSnippetFlow = + useHooksStore((s) => s.configsMap?.flowType) === FlowType.snippet || isSnippetCanvas() + const getNodesAvailableVarList = useCallback( + ( + nodes: Node[], + { onlyLeafNodeVar, filterVar, hideEnv, hideChatVar, passedInAvailableNodes }: Params = { + onlyLeafNodeVar: false, + filterVar: () => true, + }, + ) => { + const nodeAvailabilityMap: { + [key: string]: { availableVars: NodeOutPutVar[]; availableNodes: Node[] } + } = {} - nodes.forEach((node) => { - const nodeId = node.id - const availableNodes = passedInAvailableNodes || (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranchIncludeParent(nodeId)) - if (node.data.type === BlockEnum.Loop) - availableNodes.push(node) - const snippetInputFieldAvailability = appendSnippetInputFieldVars({ - availableNodes, - fields: snippetInputFields, - title: t($ => $.panelTitle, { ns: 'snippet' }), - }) + nodes.forEach((node) => { + const nodeId = node.id + const availableNodes = + passedInAvailableNodes || + (onlyLeafNodeVar + ? getTreeLeafNodes(nodeId) + : getBeforeNodesInSameBranchIncludeParent(nodeId)) + if (node.data.type === BlockEnum.Loop) availableNodes.push(node) + const snippetInputFieldAvailability = appendSnippetInputFieldVars({ + availableNodes, + fields: snippetInputFields, + title: t(($) => $.panelTitle, { ns: 'snippet' }), + }) - const { - parentNode: iterationNode, - } = getNodeInfo(nodeId, nodes) + const { parentNode: iterationNode } = getNodeInfo(nodeId, nodes) - const availableVars = filterSnippetSystemVars([ - ...snippetInputFieldAvailability.availableVars, - ...getNodeAvailableVars({ - parentNode: iterationNode, - beforeNodes: availableNodes, - isChatMode, - filterVar, - hideEnv, - hideChatVar, - }), - ], isSnippetFlow) - const result = { - node, - availableVars, - availableNodes: snippetInputFieldAvailability.availableNodes, - } - nodeAvailabilityMap[nodeId] = result - }) - return nodeAvailabilityMap - }, [getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent, getNodeAvailableVars, isChatMode, isSnippetFlow, snippetInputFields, t]) + const availableVars = filterSnippetSystemVars( + [ + ...snippetInputFieldAvailability.availableVars, + ...getNodeAvailableVars({ + parentNode: iterationNode, + beforeNodes: availableNodes, + isChatMode, + filterVar, + hideEnv, + hideChatVar, + }), + ], + isSnippetFlow, + ) + const result = { + node, + availableVars, + availableNodes: snippetInputFieldAvailability.availableNodes, + } + nodeAvailabilityMap[nodeId] = result + }) + return nodeAvailabilityMap + }, + [ + getTreeLeafNodes, + getBeforeNodesInSameBranchIncludeParent, + getNodeAvailableVars, + isChatMode, + isSnippetFlow, + snippetInputFields, + t, + ], + ) return { getNodesAvailableVarList, } diff --git a/web/app/components/workflow/hooks/use-nodes-interactions-without-sync.ts b/web/app/components/workflow/hooks/use-nodes-interactions-without-sync.ts index 0c343f4eb8fe49..96e16873aec763 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions-without-sync.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions-without-sync.ts @@ -7,10 +7,7 @@ export const useNodesInteractionsWithoutSync = () => { const store = useStoreApi() const handleNodeCancelRunningStatus = useCallback(() => { - const { - getNodes, - setNodes, - } = store.getState() + const { getNodes, setNodes } = store.getState() const nodes = getNodes() const newNodes = produce(nodes, (draft) => { @@ -23,10 +20,7 @@ export const useNodesInteractionsWithoutSync = () => { }, [store]) const handleCancelAllNodeSuccessStatus = useCallback(() => { - const { - getNodes, - setNodes, - } = store.getState() + const { getNodes, setNodes } = store.getState() const nodes = getNodes() const newNodes = produce(nodes, (draft) => { @@ -38,21 +32,21 @@ export const useNodesInteractionsWithoutSync = () => { setNodes(newNodes) }, [store]) - const handleCancelNodeSuccessStatus = useCallback((nodeId: string) => { - const { - getNodes, - setNodes, - } = store.getState() + const handleCancelNodeSuccessStatus = useCallback( + (nodeId: string) => { + const { getNodes, setNodes } = store.getState() - const newNodes = produce(getNodes(), (draft) => { - const node = draft.find(n => n.id === nodeId) - if (node && node.data._runningStatus === NodeRunningStatus.Succeeded) { - node.data._runningStatus = undefined - node.data._waitingRun = false - } - }) - setNodes(newNodes) - }, [store]) + const newNodes = produce(getNodes(), (draft) => { + const node = draft.find((n) => n.id === nodeId) + if (node && node.data._runningStatus === NodeRunningStatus.Succeeded) { + node.data._runningStatus = undefined + node.data._waitingRun = false + } + }) + setNodes(newNodes) + }, + [store], + ) return { handleNodeCancelRunningStatus, diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index ec874092a75dbf..4b2ea7bf6a8d06 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -18,11 +18,7 @@ import { useQuery } from '@tanstack/react-query' import { produce } from 'immer' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - getConnectedEdges, - getOutgoers, - useReactFlow, -} from 'reactflow' +import { getConnectedEdges, getOutgoers, useReactFlow } from 'reactflow' import { consoleQuery } from '@/service/client' import { collaborationManager } from '../collaboration/core/collaboration-manager' import { @@ -44,7 +40,6 @@ import { useNodeLoopInteractions } from '../nodes/loop/use-interactions' import { CUSTOM_NOTE_NODE } from '../note-node/constants' import { useWorkflowStore } from '../store' import { BlockEnum, ControlMode, isTriggerNode } from '../types' - import { generateNewNode, genNewNodeTitleFromOld, @@ -68,15 +63,8 @@ import { useHelpline } from './use-helpline' import useInspectVarsCrud from './use-inspect-vars-crud' import { useNodesMetaData } from './use-nodes-meta-data' import { useNodesSyncDraft } from './use-nodes-sync-draft' -import { - useNodesReadOnly, - useWorkflow, - useWorkflowReadOnly, -} from './use-workflow' -import { - useWorkflowHistory, - WorkflowHistoryEvent, -} from './use-workflow-history' +import { useNodesReadOnly, useWorkflow, useWorkflowReadOnly } from './use-workflow' +import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history' // Entry node deletion restriction has been removed to allow empty workflows @@ -91,19 +79,25 @@ function needsPendingInlineAgentBinding(defaultValue?: unknown) { if (!defaultValue || typeof defaultValue !== 'object' || !('agent_binding' in defaultValue)) return false - const binding = (defaultValue as { - agent_binding?: { - agent_id?: string - binding_type?: string - current_snapshot_id?: string + const binding = ( + defaultValue as { + agent_binding?: { + agent_id?: string + binding_type?: string + current_snapshot_id?: string + } } - }).agent_binding + ).agent_binding - return binding?.binding_type === 'inline_agent' - && (!binding.agent_id || !binding.current_snapshot_id) + return ( + binding?.binding_type === 'inline_agent' && (!binding.agent_id || !binding.current_snapshot_id) + ) } -function agentV2NodeDefaultsNeedInlineBinding(defaultValue?: unknown, pluginDefaultValue?: unknown) { +function agentV2NodeDefaultsNeedInlineBinding( + defaultValue?: unknown, + pluginDefaultValue?: unknown, +) { return needsPendingInlineAgentBinding({ ...(defaultValue && typeof defaultValue === 'object' ? defaultValue : {}), ...(pluginDefaultValue && typeof pluginDefaultValue === 'object' ? pluginDefaultValue : {}), @@ -114,18 +108,16 @@ const pruneClipboardNodesWithFilteredAncestors = ( sourceNodes: Node[], candidateNodes: Node[], ): Node[] => { - const candidateNodeIds = new Set(candidateNodes.map(node => node.id)) + const candidateNodeIds = new Set(candidateNodes.map((node) => node.id)) const filteredRootIds = sourceNodes - .filter(node => !candidateNodeIds.has(node.id)) - .map(node => node.id) + .filter((node) => !candidateNodeIds.has(node.id)) + .map((node) => node.id) - if (!filteredRootIds.length) - return candidateNodes + if (!filteredRootIds.length) return candidateNodes const childrenByParent = new Map<string, string[]>() sourceNodes.forEach((node) => { - if (!node.parentId) - return + if (!node.parentId) return const children = childrenByParent.get(node.parentId) ?? [] children.push(node.id) @@ -139,42 +131,41 @@ const pruneClipboardNodesWithFilteredAncestors = ( const currentNodeId = queue.shift()! const children = childrenByParent.get(currentNodeId) ?? [] children.forEach((childId) => { - if (filteredNodeIds.has(childId)) - return + if (filteredNodeIds.has(childId)) return filteredNodeIds.add(childId) queue.push(childId) }) } - return candidateNodes.filter(node => !filteredNodeIds.has(node.id)) + return candidateNodes.filter((node) => !filteredNodeIds.has(node.id)) } -const getUniquePastedNodeTitle = ( - sourceTitle: string, - reservedTitles: Set<string>, -) => { +const getUniquePastedNodeTitle = (sourceTitle: string, reservedTitles: Set<string>) => { let titleCandidate = sourceTitle - while (reservedTitles.has(titleCandidate)) - titleCandidate = genNewNodeTitleFromOld(titleCandidate) + while (reservedTitles.has(titleCandidate)) titleCandidate = genNewNodeTitleFromOld(titleCandidate) reservedTitles.add(titleCandidate) return titleCandidate } const isNoteLinkClickTarget = (target: EventTarget | null, node: Node) => { - return node.type === CUSTOM_NOTE_NODE - && target instanceof HTMLElement - && !!target.closest('.note-editor-theme_link') + return ( + node.type === CUSTOM_NOTE_NODE && + target instanceof HTMLElement && + !!target.closest('.note-editor-theme_link') + ) } export const useNodesInteractions = () => { const { t } = useTranslation() - const { data: appDslVersion = '' } = useQuery(consoleQuery.appDslVersion.get.queryOptions({ - staleTime: Infinity, - select: data => data.app_dsl_version, - })) + const { data: appDslVersion = '' } = useQuery( + consoleQuery.appDslVersion.get.queryOptions({ + staleTime: Infinity, + select: (data) => data.app_dsl_version, + }), + ) const collaborativeWorkflow = useCollaborativeWorkflow() const workflowStore = useWorkflowStore() const reactflow = useReactFlow() @@ -184,67 +175,63 @@ export const useNodesInteractions = () => { const { getNodesReadOnly } = useNodesReadOnly() const { getWorkflowReadOnly } = useWorkflowReadOnly() const { handleSetHelpline } = useHelpline() - const { handleNodeIterationChildDrag, handleNodeIterationChildrenCopy } - = useNodeIterationInteractions() - const { handleNodeLoopChildDrag, handleNodeLoopChildrenCopy } - = useNodeLoopInteractions() + const { handleNodeIterationChildDrag, handleNodeIterationChildrenCopy } = + useNodeIterationInteractions() + const { handleNodeLoopChildDrag, handleNodeLoopChildrenCopy } = useNodeLoopInteractions() const dragNodeStartPosition = useRef({ x: 0, y: 0 } as { x: number y: number }) const { nodesMap: nodesMetaDataMap } = useNodesMetaData() - const { - saveStateToHistory, - undo, - redo, - } = useWorkflowHistory() + const { saveStateToHistory, undo, redo } = useWorkflowHistory() const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl() const { createInlineAgentBinding } = useCreateInlineAgentBinding() - const createInlineAgentBindingForNode = useCallback((nodeId: string, options?: { - onError?: () => void - }) => { - workflowStore.getState().setOpenInlineAgentPanelNodeId(nodeId) - handleSyncWorkflowDraft(true, true) - createInlineAgentBinding(nodeId, { - onError: () => { - options?.onError?.() - }, - onSuccess: (binding) => { - const { nodes, setNodes } = collaborativeWorkflow.getState() - setNodes(produce(nodes, (draft) => { - const node = draft.find(node => node.id === nodeId) - if (node) { - if (isAgentV2NodeData(node.data) && needsInlineAgentBindingCreation(node.data)) - node.data.agent_binding = binding - node.data._openInlineAgentPanel = true - delete node.data._isTempNode - } - })) - handleSyncWorkflowDraft(true, true) + const createInlineAgentBindingForNode = useCallback( + ( + nodeId: string, + options?: { + onError?: () => void }, - }) - }, [collaborativeWorkflow, createInlineAgentBinding, handleSyncWorkflowDraft, workflowStore]) + ) => { + workflowStore.getState().setOpenInlineAgentPanelNodeId(nodeId) + handleSyncWorkflowDraft(true, true) + createInlineAgentBinding(nodeId, { + onError: () => { + options?.onError?.() + }, + onSuccess: (binding) => { + const { nodes, setNodes } = collaborativeWorkflow.getState() + setNodes( + produce(nodes, (draft) => { + const node = draft.find((node) => node.id === nodeId) + if (node) { + if (isAgentV2NodeData(node.data) && needsInlineAgentBindingCreation(node.data)) + node.data.agent_binding = binding + node.data._openInlineAgentPanel = true + delete node.data._isTempNode + } + }), + ) + handleSyncWorkflowDraft(true, true) + }, + }) + }, + [collaborativeWorkflow, createInlineAgentBinding, handleSyncWorkflowDraft, workflowStore], + ) const handleNodeDragStart = useCallback<NodeDragHandler>( (_, node) => { workflowStore.setState({ nodeAnimation: false }) - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return - if ( - node.type === CUSTOM_ITERATION_START_NODE - || node.type === CUSTOM_NOTE_NODE - ) { + if (node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_NOTE_NODE) { return } - if ( - node.type === CUSTOM_LOOP_START_NODE - || node.type === CUSTOM_NOTE_NODE - ) { + if (node.type === CUSTOM_LOOP_START_NODE || node.type === CUSTOM_NOTE_NODE) { return } @@ -258,39 +245,36 @@ export const useNodesInteractions = () => { const handleNodeDrag = useCallback<NodeDragHandler>( (e, node: Node) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return - if (node.type === CUSTOM_ITERATION_START_NODE) - return + if (node.type === CUSTOM_ITERATION_START_NODE) return - if (node.type === CUSTOM_LOOP_START_NODE) - return + if (node.type === CUSTOM_LOOP_START_NODE) return e.stopPropagation() const { nodes, setNodes } = collaborativeWorkflow.getState() const { restrictPosition } = handleNodeIterationChildDrag(node) - const { restrictPosition: restrictLoopPosition } - = handleNodeLoopChildDrag(node) + const { restrictPosition: restrictLoopPosition } = handleNodeLoopChildDrag(node) - const { showHorizontalHelpLineNodes, showVerticalHelpLineNodes } - = handleSetHelpline(node) - const showHorizontalHelpLineNodesLength - = showHorizontalHelpLineNodes.length + const { showHorizontalHelpLineNodes, showVerticalHelpLineNodes } = handleSetHelpline(node) + const showHorizontalHelpLineNodesLength = showHorizontalHelpLineNodes.length const showVerticalHelpLineNodesLength = showVerticalHelpLineNodes.length const newNodes = produce(nodes, (draft) => { - const currentNode = draft.find(n => n.id === node.id)! + const currentNode = draft.find((n) => n.id === node.id)! // Check if current dragging node is an entry node - const isCurrentEntryNode = isTriggerNode(node.data.type as BlockEnum) || node.data.type === BlockEnum.Start + const isCurrentEntryNode = + isTriggerNode(node.data.type as BlockEnum) || node.data.type === BlockEnum.Start // X-axis alignment with offset consideration if (showVerticalHelpLineNodesLength > 0) { const targetNode = showVerticalHelpLineNodes[0] - const isTargetEntryNode = isTriggerNode(targetNode!.data.type as BlockEnum) || targetNode!.data.type === BlockEnum.Start + const isTargetEntryNode = + isTriggerNode(targetNode!.data.type as BlockEnum) || + targetNode!.data.type === BlockEnum.Start // Calculate the wrapper position needed to align the inner nodes // Target inner position = target.position + target.offset @@ -300,48 +284,48 @@ export const useNodesInteractions = () => { const targetOffset = isTargetEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.x : 0 const currentOffset = isCurrentEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.x : 0 currentNode.position.x = targetNode!.position.x + targetOffset - currentOffset - } - else if (restrictPosition.x !== undefined) { + } else if (restrictPosition.x !== undefined) { currentNode.position.x = restrictPosition.x - } - else if (restrictLoopPosition.x !== undefined) { + } else if (restrictLoopPosition.x !== undefined) { currentNode.position.x = restrictLoopPosition.x - } - else { + } else { currentNode.position.x = node.position.x } // Y-axis alignment with offset consideration if (showHorizontalHelpLineNodesLength > 0) { const targetNode = showHorizontalHelpLineNodes[0] - const isTargetEntryNode = isTriggerNode(targetNode!.data.type as BlockEnum) || targetNode!.data.type === BlockEnum.Start + const isTargetEntryNode = + isTriggerNode(targetNode!.data.type as BlockEnum) || + targetNode!.data.type === BlockEnum.Start const targetOffset = isTargetEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.y : 0 const currentOffset = isCurrentEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.y : 0 currentNode.position.y = targetNode!.position.y + targetOffset - currentOffset - } - else if (restrictPosition.y !== undefined) { + } else if (restrictPosition.y !== undefined) { currentNode.position.y = restrictPosition.y - } - else if (restrictLoopPosition.y !== undefined) { + } else if (restrictLoopPosition.y !== undefined) { currentNode.position.y = restrictLoopPosition.y - } - else { + } else { currentNode.position.y = node.position.y } }) setNodes(newNodes) }, - [getNodesReadOnly, collaborativeWorkflow, handleNodeIterationChildDrag, handleNodeLoopChildDrag, handleSetHelpline], + [ + getNodesReadOnly, + collaborativeWorkflow, + handleNodeIterationChildDrag, + handleNodeLoopChildDrag, + handleSetHelpline, + ], ) const handleNodeDragStop = useCallback<NodeDragHandler>( (_, node) => { - const { setHelpLineHorizontal, setHelpLineVertical } - = workflowStore.getState() + const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState() - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const { x, y } = dragNodeStartPosition.current if (!(x === node.position.x && y === node.position.y)) { @@ -357,44 +341,26 @@ export const useNodesInteractions = () => { } } }, - [ - workflowStore, - getNodesReadOnly, - saveStateToHistory, - handleSyncWorkflowDraft, - ], + [workflowStore, getNodesReadOnly, saveStateToHistory, handleSyncWorkflowDraft], ) const handleNodeEnter = useCallback<NodeMouseHandler>( (_, node) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return - if ( - node.type === CUSTOM_NOTE_NODE - || node.type === CUSTOM_ITERATION_START_NODE - ) { + if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE) { return } - if ( - node.type === CUSTOM_LOOP_START_NODE - || node.type === CUSTOM_NOTE_NODE - ) { + if (node.type === CUSTOM_LOOP_START_NODE || node.type === CUSTOM_NOTE_NODE) { return } const { nodes, edges, setNodes, setEdges } = collaborativeWorkflow.getState() - const { - connectingNodePayload, - setEnteringNodePayload, - } = workflowStore.getState() + const { connectingNodePayload, setEnteringNodePayload } = workflowStore.getState() if (connectingNodePayload) { - if (connectingNodePayload.nodeId === node.id) - return - const connectingNode: Node = nodes.find( - n => n.id === connectingNodePayload.nodeId, - )! + if (connectingNodePayload.nodeId === node.id) return + const connectingNode: Node = nodes.find((n) => n.id === connectingNodePayload.nodeId)! const sameLevel = connectingNode.parentId === node.parentId if (sameLevel) { @@ -407,22 +373,21 @@ export const useNodesInteractions = () => { const newNodes = produce(nodes, (draft) => { draft.forEach((n) => { if ( - n.id === node.id - && fromType === 'source' - && (node.data.type === BlockEnum.VariableAssigner - || node.data.type === BlockEnum.VariableAggregator) + n.id === node.id && + fromType === 'source' && + (node.data.type === BlockEnum.VariableAssigner || + node.data.type === BlockEnum.VariableAggregator) ) { - if (!node.data.advanced_settings?.group_enabled) - n.data._isEntering = true + if (!node.data.advanced_settings?.group_enabled) n.data._isEntering = true } if ( - n.id === node.id - && fromType === 'target' - && (connectingNode.data.type === BlockEnum.VariableAssigner - || connectingNode.data.type === BlockEnum.VariableAggregator) - && node.data.type !== BlockEnum.IfElse - && node.data.type !== BlockEnum.QuestionClassifier - && node.data.type !== BlockEnum.HumanInput + n.id === node.id && + fromType === 'target' && + (connectingNode.data.type === BlockEnum.VariableAssigner || + connectingNode.data.type === BlockEnum.VariableAggregator) && + node.data.type !== BlockEnum.IfElse && + node.data.type !== BlockEnum.QuestionClassifier && + node.data.type !== BlockEnum.HumanInput ) { n.data._isEntering = true } @@ -435,9 +400,8 @@ export const useNodesInteractions = () => { const connectedEdges = getConnectedEdges([node], edges) connectedEdges.forEach((edge) => { - const currentEdge = draft.find(e => e.id === edge.id) - if (currentEdge) - currentEdge.data._connectedNodeIsHovering = true + const currentEdge = draft.find((e) => e.id === edge.id) + if (currentEdge) currentEdge.data._connectedNodeIsHovering = true }) }) setEdges(newEdges, false) @@ -447,20 +411,13 @@ export const useNodesInteractions = () => { const handleNodeLeave = useCallback<NodeMouseHandler>( (_, node) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return - if ( - node.type === CUSTOM_NOTE_NODE - || node.type === CUSTOM_ITERATION_START_NODE - ) { + if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE) { return } - if ( - node.type === CUSTOM_NOTE_NODE - || node.type === CUSTOM_LOOP_START_NODE - ) { + if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_LOOP_START_NODE) { return } @@ -484,13 +441,8 @@ export const useNodesInteractions = () => { ) const handleNodeSelect = useCallback( - ( - nodeId: string, - cancelSelection?: boolean, - initShowLastRunTab?: boolean, - ) => { - if (initShowLastRunTab) - workflowStore.setState({ initShowLastRunTab: true }) + (nodeId: string, cancelSelection?: boolean, initShowLastRunTab?: boolean) => { + if (initShowLastRunTab) workflowStore.setState({ initShowLastRunTab: true }) const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() const newNodes = produce(nodes, (draft) => { @@ -502,10 +454,9 @@ export const useNodesInteractions = () => { }) setNodes(newNodes, false) - const connectedEdges = getConnectedEdges( - [{ id: nodeId } as Node], - edges, - ).map(edge => edge.id) + const connectedEdges = getConnectedEdges([{ id: nodeId } as Node], edges).map( + (edge) => edge.id, + ) const newEdges = produce(edges, (draft) => { draft.forEach((edge) => { if (connectedEdges.includes(edge.id)) { @@ -513,8 +464,7 @@ export const useNodesInteractions = () => { ...edge.data, _connectedNodeIsSelected: !cancelSelection, } - } - else { + } else { edge.data = { ...edge.data, _connectedNodeIsSelected: false, @@ -530,16 +480,11 @@ export const useNodesInteractions = () => { const handleNodeClick = useCallback<NodeMouseHandler>( (event, node) => { const { controlMode } = workflowStore.getState() - if (controlMode === ControlMode.Comment) - return - if (isNoteLinkClickTarget(event.target, node)) - return - if (node.type === CUSTOM_ITERATION_START_NODE) - return - if (node.type === CUSTOM_LOOP_START_NODE) - return - if (node.data.type === BlockEnum.DataSourceEmpty) - return + if (controlMode === ControlMode.Comment) return + if (isNoteLinkClickTarget(event.target, node)) return + if (node.type === CUSTOM_ITERATION_START_NODE) return + if (node.type === CUSTOM_LOOP_START_NODE) return + if (node.data.type === BlockEnum.DataSourceEmpty) return handleNodeSelect(node.id) }, [handleNodeSelect, workflowStore], @@ -547,38 +492,33 @@ export const useNodesInteractions = () => { const handleNodeConnect = useCallback<OnConnect>( ({ source, sourceHandle, target, targetHandle }) => { - if (source === target) - return - if (getNodesReadOnly()) - return + if (source === target) return + if (getNodesReadOnly()) return const { nodes, edges, setNodes, setEdges } = collaborativeWorkflow.getState() - const targetNode = nodes.find(node => node.id === target!) - const sourceNode = nodes.find(node => node.id === source!) + const targetNode = nodes.find((node) => node.id === target!) + const sourceNode = nodes.find((node) => node.id === source!) - if (targetNode?.parentId !== sourceNode?.parentId) - return + if (targetNode?.parentId !== sourceNode?.parentId) return - if ( - sourceNode?.type === CUSTOM_NOTE_NODE - || targetNode?.type === CUSTOM_NOTE_NODE - ) { + if (sourceNode?.type === CUSTOM_NOTE_NODE || targetNode?.type === CUSTOM_NOTE_NODE) { return } if ( - edges.some(edge => - edge.source === source - && edge.sourceHandle === sourceHandle - && edge.target === target - && edge.targetHandle === targetHandle) + edges.some( + (edge) => + edge.source === source && + edge.sourceHandle === sourceHandle && + edge.target === target && + edge.targetHandle === targetHandle, + ) ) { return } - const parendNode = nodes.find(node => node.id === targetNode?.parentId) - const isInIteration - = parendNode && parendNode.data.type === BlockEnum.Iteration + const parendNode = nodes.find((node) => node.id === targetNode?.parentId) + const isInIteration = parendNode && parendNode.data.type === BlockEnum.Iteration const isInLoop = !!parendNode && parendNode.data.type === BlockEnum.Loop const newEdge = { @@ -589,8 +529,8 @@ export const useNodesInteractions = () => { sourceHandle, targetHandle, data: { - sourceType: nodes.find(node => node.id === source)!.data.type, - targetType: nodes.find(node => node.id === target)!.data.type, + sourceType: nodes.find((node) => node.id === source)!.data.type, + targetType: nodes.find((node) => node.id === target)!.data.type, isInIteration, iteration_id: isInIteration ? targetNode?.parentId : undefined, isInLoop, @@ -598,11 +538,10 @@ export const useNodesInteractions = () => { }, zIndex: targetNode?.parentId ? NESTED_ELEMENT_Z_INDEX : 0, } - const nodesConnectedSourceOrTargetHandleIdsMap - = getNodesConnectedSourceOrTargetHandleIdsMap( - [{ type: 'add', edge: newEdge }], - nodes, - ) + const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( + [{ type: 'add', edge: newEdge }], + nodes, + ) const newNodes = produce(nodes, (draft: Node[]) => { draft.forEach((node) => { if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { @@ -636,23 +575,20 @@ export const useNodesInteractions = () => { const handleNodeConnectStart = useCallback<OnConnectStart>( (_, { nodeId, handleType, handleId }) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return if (nodeId && handleType) { const { setConnectingNodePayload } = workflowStore.getState() const { nodes } = collaborativeWorkflow.getState() - const node = nodes.find(n => n.id === nodeId)! + const node = nodes.find((n) => n.id === nodeId)! - if (node.type === CUSTOM_NOTE_NODE) - return + if (node.type === CUSTOM_NOTE_NODE) return if ( - node.data.type === BlockEnum.VariableAggregator - || node.data.type === BlockEnum.VariableAssigner + node.data.type === BlockEnum.VariableAggregator || + node.data.type === BlockEnum.VariableAssigner ) { - if (handleType === 'target') - return + if (handleType === 'target') return } setConnectingNodePayload({ @@ -668,8 +604,7 @@ export const useNodesInteractions = () => { const handleNodeConnectEnd = useCallback<OnConnectEnd>( (e) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const { connectingNodePayload, @@ -678,36 +613,32 @@ export const useNodesInteractions = () => { setEnteringNodePayload, } = workflowStore.getState() if (connectingNodePayload && enteringNodePayload) { - const { setShowAssignVariablePopup, hoveringAssignVariableGroupId } - = workflowStore.getState() + const { setShowAssignVariablePopup, hoveringAssignVariableGroupId } = + workflowStore.getState() const { screenToFlowPosition } = reactflow const { nodes, setNodes } = collaborativeWorkflow.getState() const fromHandleType = connectingNodePayload.handleType const fromHandleId = connectingNodePayload.handleId - const fromNode = nodes.find( - n => n.id === connectingNodePayload.nodeId, - )! - const toNode = nodes.find(n => n.id === enteringNodePayload.nodeId)! - const toParentNode = nodes.find(n => n.id === toNode.parentId) + const fromNode = nodes.find((n) => n.id === connectingNodePayload.nodeId)! + const toNode = nodes.find((n) => n.id === enteringNodePayload.nodeId)! + const toParentNode = nodes.find((n) => n.id === toNode.parentId) - if (fromNode.parentId !== toNode.parentId) - return + if (fromNode.parentId !== toNode.parentId) return - const pointer = e as { x: number, y: number } + const pointer = e as { x: number; y: number } const { x, y } = screenToFlowPosition({ x: pointer.x, y: pointer.y }) if ( - fromHandleType === 'source' - && (toNode.data.type === BlockEnum.VariableAssigner - || toNode.data.type === BlockEnum.VariableAggregator) + fromHandleType === 'source' && + (toNode.data.type === BlockEnum.VariableAssigner || + toNode.data.type === BlockEnum.VariableAggregator) ) { const groupEnabled = toNode.data.advanced_settings?.group_enabled const firstGroupId = toNode.data.advanced_settings?.groups[0].groupId let handleId = 'target' if (groupEnabled) { - if (hoveringAssignVariableGroupId) - handleId = hoveringAssignVariableGroupId + if (hoveringAssignVariableGroupId) handleId = hoveringAssignVariableGroupId else handleId = firstGroupId } const newNodes = produce(nodes, (draft) => { @@ -746,28 +677,21 @@ export const useNodesInteractions = () => { const { deleteNodeInspectorVars } = useInspectVarsCrud() const handleNodeDelete = useCallback( (nodeId: string) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() - const currentNodeIndex = nodes.findIndex(node => node.id === nodeId) + const currentNodeIndex = nodes.findIndex((node) => node.id === nodeId) const currentNode = nodes[currentNodeIndex] - if (!currentNode) - return + if (!currentNode) return - if ( - nodesMetaDataMap?.[getNodeCatalogType(currentNode.data)]?.metaData - .isUndeletable - ) { + if (nodesMetaDataMap?.[getNodeCatalogType(currentNode.data)]?.metaData.isUndeletable) { return } deleteNodeInspectorVars(nodeId) if (currentNode.data.type === BlockEnum.Iteration) { - const iterationChildren = nodes.filter( - node => node.parentId === currentNode.id, - ) + const iterationChildren = nodes.filter((node) => node.parentId === currentNode.id) if (iterationChildren.length) { if (currentNode.data._isBundled) { @@ -775,8 +699,7 @@ export const useNodesInteractions = () => { handleNodeDelete(child.id) }) return handleNodeDelete(nodeId) - } - else { + } else { if (iterationChildren.length === 1) { handleNodeDelete(iterationChildren[0]!.id) handleNodeDelete(nodeId) @@ -787,8 +710,8 @@ export const useNodesInteractions = () => { if (!showConfirm) { setShowConfirm({ - title: t($ => $['nodes.iteration.deleteTitle'], { ns: 'workflow' }), - desc: t($ => $['nodes.iteration.deleteDesc'], { ns: 'workflow' }) || '', + title: t(($) => $['nodes.iteration.deleteTitle'], { ns: 'workflow' }), + desc: t(($) => $['nodes.iteration.deleteDesc'], { ns: 'workflow' }) || '', onConfirm: () => { iterationChildren.forEach((child) => { handleNodeDelete(child.id) @@ -805,9 +728,7 @@ export const useNodesInteractions = () => { } if (currentNode.data.type === BlockEnum.Loop) { - const loopChildren = nodes.filter( - node => node.parentId === currentNode.id, - ) + const loopChildren = nodes.filter((node) => node.parentId === currentNode.id) if (loopChildren.length) { if (currentNode.data._isBundled) { @@ -815,8 +736,7 @@ export const useNodesInteractions = () => { handleNodeDelete(child.id) }) return handleNodeDelete(nodeId) - } - else { + } else { if (loopChildren.length === 1) { handleNodeDelete(loopChildren[0]!.id) handleNodeDelete(nodeId) @@ -827,8 +747,8 @@ export const useNodesInteractions = () => { if (!showConfirm) { setShowConfirm({ - title: t($ => $['nodes.loop.deleteTitle'], { ns: 'workflow' }), - desc: t($ => $['nodes.loop.deleteDesc'], { ns: 'workflow' }) || '', + title: t(($) => $['nodes.loop.deleteTitle'], { ns: 'workflow' }), + desc: t(($) => $['nodes.loop.deleteDesc'], { ns: 'workflow' }) || '', onConfirm: () => { loopChildren.forEach((child) => { handleNodeDelete(child.id) @@ -846,13 +766,11 @@ export const useNodesInteractions = () => { if (currentNode.data.type === BlockEnum.DataSource) { const { id } = currentNode - const { ragPipelineVariables, setRagPipelineVariables } - = workflowStore.getState() + const { ragPipelineVariables, setRagPipelineVariables } = workflowStore.getState() if (ragPipelineVariables && setRagPipelineVariables) { const newRagPipelineVariables: RAGPipelineVariables = [] ragPipelineVariables.forEach((variable) => { - if (variable.belong_to_node_id === id) - return + if (variable.belong_to_node_id === id) return newRagPipelineVariables.push(variable) }) setRagPipelineVariables(newRagPipelineVariables) @@ -860,11 +778,10 @@ export const useNodesInteractions = () => { } const connectedEdges = getConnectedEdges([{ id: nodeId } as Node], edges) - const nodesConnectedSourceOrTargetHandleIdsMap - = getNodesConnectedSourceOrTargetHandleIdsMap( - connectedEdges.map(edge => ({ type: 'remove', edge })), - nodes, - ) + const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( + connectedEdges.map((edge) => ({ type: 'remove', edge })), + nodes, + ) const newNodes = produce(nodes, (draft: Node[]) => { draft.forEach((node) => { if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { @@ -875,9 +792,7 @@ export const useNodesInteractions = () => { } if (node.id === currentNode.parentId) { - node.data._children = node.data._children?.filter( - child => child.nodeId !== nodeId, - ) + node.data._children = node.data._children?.filter((child) => child.nodeId !== nodeId) } }) draft.splice(currentNodeIndex, 1) @@ -885,8 +800,7 @@ export const useNodesInteractions = () => { setNodes(newNodes, true, 'nodes:perform-batch-cascade-delete') const newEdges = produce(edges, (draft) => { return draft.filter( - edge => - !connectedEdges.some(connectedEdge => connectedEdge.id === edge.id), + (edge) => !connectedEdges.some((connectedEdge) => connectedEdge.id === edge.id), ) }) setEdges(newEdges) @@ -896,8 +810,7 @@ export const useNodesInteractions = () => { saveStateToHistory(WorkflowHistoryEvent.NoteDelete, { nodeId: currentNode.id, }) - } - else { + } else { saveStateToHistory(WorkflowHistoryEvent.NodeDelete, { nodeId: currentNode.id, }) @@ -917,58 +830,51 @@ export const useNodesInteractions = () => { const handleNodeAdd = useCallback<OnNodeAdd>( ( - { - nodeType, - sourceHandle = 'source', - targetHandle = 'target', - pluginDefaultValue, - }, + { nodeType, sourceHandle = 'source', targetHandle = 'target', pluginDefaultValue }, { prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle }, ) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() const nodeMetaData = nodesMetaDataMap?.[nodeType] - if (!nodeMetaData) - return + if (!nodeMetaData) return const { defaultValue } = nodeMetaData const nodesWithSameType = getNodesWithSameDefaultDataType(nodes, nodeType, defaultValue) - const shouldCreateInlineAgentBinding = nodeType === BlockEnum.AgentV2 - && agentV2NodeDefaultsNeedInlineBinding(defaultValue, pluginDefaultValue) - const { newNode, newIterationStartNode, newLoopStartNode } - = generateNewNode({ - type: getNodeCustomTypeByNodeDataType(nodeType), - data: { - ...(defaultValue as Node['data']), - title: - nodesWithSameType.length > 0 - ? `${defaultValue.title} ${nodesWithSameType.length + 1}` - : defaultValue.title, - ...pluginDefaultValue, - selected: true, - ...(shouldCreateInlineAgentBinding ? { _isTempNode: true } : {}), - _showAddVariablePopup: - (nodeType === BlockEnum.VariableAssigner - || nodeType === BlockEnum.VariableAggregator) - && !!prevNodeId, - _holdAddVariablePopup: false, - }, - position: { - x: 0, - y: 0, - }, - }) + const shouldCreateInlineAgentBinding = + nodeType === BlockEnum.AgentV2 && + agentV2NodeDefaultsNeedInlineBinding(defaultValue, pluginDefaultValue) + const { newNode, newIterationStartNode, newLoopStartNode } = generateNewNode({ + type: getNodeCustomTypeByNodeDataType(nodeType), + data: { + ...(defaultValue as Node['data']), + title: + nodesWithSameType.length > 0 + ? `${defaultValue.title} ${nodesWithSameType.length + 1}` + : defaultValue.title, + ...pluginDefaultValue, + selected: true, + ...(shouldCreateInlineAgentBinding ? { _isTempNode: true } : {}), + _showAddVariablePopup: + (nodeType === BlockEnum.VariableAssigner || + nodeType === BlockEnum.VariableAggregator) && + !!prevNodeId, + _holdAddVariablePopup: false, + }, + position: { + x: 0, + y: 0, + }, + }) if (prevNodeId && !nextNodeId) { - const prevNodeIndex = nodes.findIndex(node => node.id === prevNodeId) + const prevNodeIndex = nodes.findIndex((node) => node.id === prevNodeId) const prevNode = nodes[prevNodeIndex] const outgoers = getOutgoers(prevNode!, nodes, edges).sort( (a, b) => a.position.y - b.position.y, ) const lastOutgoer = outgoers.at(-1) - newNode.data._connectedTargetHandleIds - = nodeType === BlockEnum.DataSource ? [] : [targetHandle] + newNode.data._connectedTargetHandleIds = + nodeType === BlockEnum.DataSource ? [] : [targetHandle] newNode.data._connectedSourceHandleIds = [] newNode.position = { x: lastOutgoer @@ -981,12 +887,9 @@ export const useNodesInteractions = () => { newNode.parentId = prevNode!.parentId newNode.extent = prevNode!.extent - const parentNode - = nodes.find(node => node.id === prevNode!.parentId) || null - const isInIteration - = !!parentNode && parentNode.data.type === BlockEnum.Iteration - const isInLoop - = !!parentNode && parentNode.data.type === BlockEnum.Loop + const parentNode = nodes.find((node) => node.id === prevNode!.parentId) || null + const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration + const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop if (prevNode!.parentId) { newNode.data.isInIteration = isInIteration @@ -1000,19 +903,19 @@ export const useNodesInteractions = () => { newNode.zIndex = NESTED_ELEMENT_Z_INDEX } if ( - isInIteration - && (newNode.data.type === BlockEnum.Answer - || newNode.data.type === BlockEnum.Tool - || newNode.data.type === BlockEnum.Assigner) + isInIteration && + (newNode.data.type === BlockEnum.Answer || + newNode.data.type === BlockEnum.Tool || + newNode.data.type === BlockEnum.Assigner) ) { const iterNodeData: IterationNodeType = parentNode.data iterNodeData._isShowTips = true } if ( - isInLoop - && (newNode.data.type === BlockEnum.Answer - || newNode.data.type === BlockEnum.Tool - || newNode.data.type === BlockEnum.Assigner) + isInLoop && + (newNode.data.type === BlockEnum.Answer || + newNode.data.type === BlockEnum.Tool || + newNode.data.type === BlockEnum.Assigner) ) { const iterNodeData: IterationNodeType = parentNode.data iterNodeData._isShowTips = true @@ -1041,9 +944,9 @@ export const useNodesInteractions = () => { } } - const nodesConnectedSourceOrTargetHandleIdsMap - = getNodesConnectedSourceOrTargetHandleIdsMap( - (newEdge ? [{ type: 'add', edge: newEdge }] : []), + const nodesConnectedSourceOrTargetHandleIdsMap = + getNodesConnectedSourceOrTargetHandleIdsMap( + newEdge ? [{ type: 'add', edge: newEdge }] : [], nodes, ) const newNodes = produce(nodes, (draft: Node[]) => { @@ -1057,20 +960,14 @@ export const useNodesInteractions = () => { } } - if ( - node.data.type === BlockEnum.Iteration - && prevNode!.parentId === node.id - ) { + if (node.data.type === BlockEnum.Iteration && prevNode!.parentId === node.id) { node.data._children?.push({ nodeId: newNode.id, nodeType: newNode.data.type, }) } - if ( - node.data.type === BlockEnum.Loop - && prevNode!.parentId === node.id - ) { + if (node.data.type === BlockEnum.Loop && prevNode!.parentId === node.id) { node.data._children?.push({ nodeId: newNode.id, nodeType: newNode.data.type, @@ -1079,16 +976,14 @@ export const useNodesInteractions = () => { }) draft.push(newNode) - if (newIterationStartNode) - draft.push(newIterationStartNode) + if (newIterationStartNode) draft.push(newIterationStartNode) - if (newLoopStartNode) - draft.push(newLoopStartNode) + if (newLoopStartNode) draft.push(newLoopStartNode) }) if ( - newNode.data.type === BlockEnum.VariableAssigner - || newNode.data.type === BlockEnum.VariableAggregator + newNode.data.type === BlockEnum.VariableAssigner || + newNode.data.type === BlockEnum.VariableAggregator ) { const { setShowAssignVariablePopup } = workflowStore.getState() @@ -1098,7 +993,7 @@ export const useNodesInteractions = () => { variableAssignerNodeId: newNode.id, variableAssignerNodeData: newNode.data as VariableAssignerNodeType, variableAssignerNodeHandleId: targetHandle, - parentNode: nodes.find(node => node.id === newNode.parentId), + parentNode: nodes.find((node) => node.id === newNode.parentId), x: -25, y: 44, }) @@ -1110,20 +1005,19 @@ export const useNodesInteractions = () => { _connectedNodeIsSelected: false, } }) - if (newEdge) - draft.push(newEdge) + if (newEdge) draft.push(newEdge) }) setNodes(newNodes) setEdges(newEdges) } if (!prevNodeId && nextNodeId) { - const nextNodeIndex = nodes.findIndex(node => node.id === nextNodeId) + const nextNodeIndex = nodes.findIndex((node) => node.id === nextNodeId) const nextNode = nodes[nextNodeIndex]! if ( - nodeType !== BlockEnum.IfElse - && nodeType !== BlockEnum.QuestionClassifier - && nodeType !== BlockEnum.HumanInput + nodeType !== BlockEnum.IfElse && + nodeType !== BlockEnum.QuestionClassifier && + nodeType !== BlockEnum.HumanInput ) { newNode.data._connectedSourceHandleIds = [sourceHandle] } @@ -1135,12 +1029,9 @@ export const useNodesInteractions = () => { newNode.parentId = nextNode.parentId newNode.extent = nextNode.extent - const parentNode - = nodes.find(node => node.id === nextNode.parentId) || null - const isInIteration - = !!parentNode && parentNode.data.type === BlockEnum.Iteration - const isInLoop - = !!parentNode && parentNode.data.type === BlockEnum.Loop + const parentNode = nodes.find((node) => node.id === nextNode.parentId) || null + const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration + const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop if (parentNode && nextNode.parentId) { newNode.data.isInIteration = isInIteration @@ -1158,10 +1049,10 @@ export const useNodesInteractions = () => { let newEdge if ( - nodeType !== BlockEnum.IfElse - && nodeType !== BlockEnum.QuestionClassifier - && nodeType !== BlockEnum.HumanInput - && nodeType !== BlockEnum.LoopEnd + nodeType !== BlockEnum.IfElse && + nodeType !== BlockEnum.QuestionClassifier && + nodeType !== BlockEnum.HumanInput && + nodeType !== BlockEnum.LoopEnd ) { newEdge = { id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, @@ -1183,25 +1074,23 @@ export const useNodesInteractions = () => { } } - let nodesConnectedSourceOrTargetHandleIdsMap: ReturnType<typeof getNodesConnectedSourceOrTargetHandleIdsMap> + let nodesConnectedSourceOrTargetHandleIdsMap: ReturnType< + typeof getNodesConnectedSourceOrTargetHandleIdsMap + > if (newEdge) { - nodesConnectedSourceOrTargetHandleIdsMap - = getNodesConnectedSourceOrTargetHandleIdsMap( - [{ type: 'add', edge: newEdge }], - nodes, - ) + nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( + [{ type: 'add', edge: newEdge }], + nodes, + ) } const afterNodesInSameBranch = getAfterNodesInSameBranch(nextNodeId!) - const afterNodesInSameBranchIds = afterNodesInSameBranch.map( - node => node.id, - ) + const afterNodesInSameBranchIds = afterNodesInSameBranch.map((node) => node.id) const newNodes = produce(nodes, (draft) => { draft.forEach((node) => { node.data.selected = false - if (afterNodesInSameBranchIds.includes(node.id)) - node.position.x += NODE_WIDTH_X_OFFSET + if (afterNodesInSameBranchIds.includes(node.id)) node.position.x += NODE_WIDTH_X_OFFSET if (nodesConnectedSourceOrTargetHandleIdsMap?.[node.id]) { node.data = { @@ -1210,47 +1099,33 @@ export const useNodesInteractions = () => { } } - if ( - node.data.type === BlockEnum.Iteration - && nextNode.parentId === node.id - ) { + if (node.data.type === BlockEnum.Iteration && nextNode.parentId === node.id) { node.data._children?.push({ nodeId: newNode.id, nodeType: newNode.data.type, }) } - if ( - node.data.type === BlockEnum.Iteration - && node.data.start_node_id === nextNodeId - ) { + if (node.data.type === BlockEnum.Iteration && node.data.start_node_id === nextNodeId) { node.data.start_node_id = newNode.id node.data.startNodeType = newNode.data.type } - if ( - node.data.type === BlockEnum.Loop - && nextNode.parentId === node.id - ) { + if (node.data.type === BlockEnum.Loop && nextNode.parentId === node.id) { node.data._children?.push({ nodeId: newNode.id, nodeType: newNode.data.type, }) } - if ( - node.data.type === BlockEnum.Loop - && node.data.start_node_id === nextNodeId - ) { + if (node.data.type === BlockEnum.Loop && node.data.start_node_id === nextNodeId) { node.data.start_node_id = newNode.id node.data.startNodeType = newNode.data.type } }) draft.push(newNode) - if (newIterationStartNode) - draft.push(newIterationStartNode) - if (newLoopStartNode) - draft.push(newLoopStartNode) + if (newIterationStartNode) draft.push(newIterationStartNode) + if (newLoopStartNode) draft.push(newLoopStartNode) }) if (newEdge) { const newEdges = produce(edges, (draft) => { @@ -1265,17 +1140,16 @@ export const useNodesInteractions = () => { setNodes(newNodes) setEdges(newEdges) - } - else { + } else { setNodes(newNodes) } } if (prevNodeId && nextNodeId) { - const prevNode = nodes.find(node => node.id === prevNodeId)! - const nextNode = nodes.find(node => node.id === nextNodeId)! + const prevNode = nodes.find((node) => node.id === prevNodeId)! + const nextNode = nodes.find((node) => node.id === nextNodeId)! - newNode.data._connectedTargetHandleIds - = nodeType === BlockEnum.DataSource ? [] : [targetHandle] + newNode.data._connectedTargetHandleIds = + nodeType === BlockEnum.DataSource ? [] : [targetHandle] newNode.data._connectedSourceHandleIds = [sourceHandle] newNode.position = { x: nextNode.position.x, @@ -1284,12 +1158,9 @@ export const useNodesInteractions = () => { newNode.parentId = prevNode.parentId newNode.extent = prevNode.extent - const parentNode - = nodes.find(node => node.id === prevNode.parentId) || null - const isInIteration - = !!parentNode && parentNode.data.type === BlockEnum.Iteration - const isInLoop - = !!parentNode && parentNode.data.type === BlockEnum.Loop + const parentNode = nodes.find((node) => node.id === prevNode.parentId) || null + const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration + const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop if (parentNode && prevNode.parentId) { newNode.data.isInIteration = isInIteration @@ -1305,7 +1176,7 @@ export const useNodesInteractions = () => { } const currentEdgeIndex = edges.findIndex( - edge => edge.source === prevNodeId && edge.target === nextNodeId, + (edge) => edge.source === prevNodeId && edge.target === nextNodeId, ) let newPrevEdge = null @@ -1332,20 +1203,17 @@ export const useNodesInteractions = () => { let newNextEdge: Edge | null = null - const nextNodeParentNode - = nodes.find(node => node.id === nextNode.parentId) || null - const isNextNodeInIteration - = !!nextNodeParentNode - && nextNodeParentNode.data.type === BlockEnum.Iteration - const isNextNodeInLoop - = !!nextNodeParentNode - && nextNodeParentNode.data.type === BlockEnum.Loop + const nextNodeParentNode = nodes.find((node) => node.id === nextNode.parentId) || null + const isNextNodeInIteration = + !!nextNodeParentNode && nextNodeParentNode.data.type === BlockEnum.Iteration + const isNextNodeInLoop = + !!nextNodeParentNode && nextNodeParentNode.data.type === BlockEnum.Loop if ( - nodeType !== BlockEnum.IfElse - && nodeType !== BlockEnum.QuestionClassifier - && nodeType !== BlockEnum.HumanInput - && nodeType !== BlockEnum.LoopEnd + nodeType !== BlockEnum.IfElse && + nodeType !== BlockEnum.QuestionClassifier && + nodeType !== BlockEnum.HumanInput && + nodeType !== BlockEnum.LoopEnd ) { newNextEdge = { id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, @@ -1359,17 +1227,15 @@ export const useNodesInteractions = () => { targetType: nextNode.data.type, isInIteration: isNextNodeInIteration, isInLoop: isNextNodeInLoop, - iteration_id: isNextNodeInIteration - ? nextNode.parentId - : undefined, + iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined, loop_id: isNextNodeInLoop ? nextNode.parentId : undefined, _connectedNodeIsSelected: true, }, zIndex: nextNode.parentId ? NESTED_ELEMENT_Z_INDEX : 0, } } - const nodesConnectedSourceOrTargetHandleIdsMap - = getNodesConnectedSourceOrTargetHandleIdsMap( + const nodesConnectedSourceOrTargetHandleIdsMap = + getNodesConnectedSourceOrTargetHandleIdsMap( [ { type: 'remove', edge: edges[currentEdgeIndex]! }, ...(newPrevEdge ? [{ type: 'add', edge: newPrevEdge }] : []), @@ -1379,9 +1245,7 @@ export const useNodesInteractions = () => { ) const afterNodesInSameBranch = getAfterNodesInSameBranch(nextNodeId!) - const afterNodesInSameBranchIds = afterNodesInSameBranch.map( - node => node.id, - ) + const afterNodesInSameBranchIds = afterNodesInSameBranch.map((node) => node.id) const newNodes = produce(nodes, (draft) => { draft.forEach((node) => { node.data.selected = false @@ -1392,22 +1256,15 @@ export const useNodesInteractions = () => { ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], } } - if (afterNodesInSameBranchIds.includes(node.id)) - node.position.x += NODE_WIDTH_X_OFFSET + if (afterNodesInSameBranchIds.includes(node.id)) node.position.x += NODE_WIDTH_X_OFFSET - if ( - node.data.type === BlockEnum.Iteration - && prevNode.parentId === node.id - ) { + if (node.data.type === BlockEnum.Iteration && prevNode.parentId === node.id) { node.data._children?.push({ nodeId: newNode.id, nodeType: newNode.data.type, }) } - if ( - node.data.type === BlockEnum.Loop - && prevNode.parentId === node.id - ) { + if (node.data.type === BlockEnum.Loop && prevNode.parentId === node.id) { node.data._children?.push({ nodeId: newNode.id, nodeType: newNode.data.type, @@ -1415,15 +1272,13 @@ export const useNodesInteractions = () => { } }) draft.push(newNode) - if (newIterationStartNode) - draft.push(newIterationStartNode) - if (newLoopStartNode) - draft.push(newLoopStartNode) + if (newIterationStartNode) draft.push(newIterationStartNode) + if (newLoopStartNode) draft.push(newLoopStartNode) }) setNodes(newNodes) if ( - newNode.data.type === BlockEnum.VariableAssigner - || newNode.data.type === BlockEnum.VariableAggregator + newNode.data.type === BlockEnum.VariableAssigner || + newNode.data.type === BlockEnum.VariableAggregator ) { const { setShowAssignVariablePopup } = workflowStore.getState() @@ -1433,7 +1288,7 @@ export const useNodesInteractions = () => { variableAssignerNodeId: newNode.id, variableAssignerNodeData: newNode.data as VariableAssignerNodeType, variableAssignerNodeHandleId: targetHandle, - parentNode: nodes.find(node => node.id === newNode.parentId), + parentNode: nodes.find((node) => node.id === newNode.parentId), x: -25, y: 44, }) @@ -1446,11 +1301,9 @@ export const useNodesInteractions = () => { _connectedNodeIsSelected: false, } }) - if (newPrevEdge) - draft.push(newPrevEdge) + if (newPrevEdge) draft.push(newPrevEdge) - if (newNextEdge) - draft.push(newNextEdge) + if (newNextEdge) draft.push(newNextEdge) }) setEdges(newEdges) } @@ -1459,8 +1312,10 @@ export const useNodesInteractions = () => { createInlineAgentBindingForNode(newNode.id, { onError: () => { const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() - setNodes(nodes.filter(node => node.id !== newNode.id)) - setEdges(edges.filter(edge => edge.source !== newNode.id && edge.target !== newNode.id)) + setNodes(nodes.filter((node) => node.id !== newNode.id)) + setEdges( + edges.filter((edge) => edge.source !== newNode.id && edge.target !== newNode.id), + ) }, }) return @@ -1488,19 +1343,18 @@ export const useNodesInteractions = () => { sourceHandle: string, pluginDefaultValue?: BlockDefaultValue, ) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() - const currentNode = nodes.find(node => node.id === currentNodeId)! + const currentNode = nodes.find((node) => node.id === currentNodeId)! const connectedEdges = getConnectedEdges([currentNode], edges) const nodeMetaData = nodesMetaDataMap?.[nodeType] - if (!nodeMetaData) - return + if (!nodeMetaData) return const { defaultValue } = nodeMetaData const nodesWithSameType = getNodesWithSameDefaultDataType(nodes, nodeType, defaultValue) - const shouldCreateInlineAgentBinding = nodeType === BlockEnum.AgentV2 - && agentV2NodeDefaultsNeedInlineBinding(defaultValue, pluginDefaultValue) + const shouldCreateInlineAgentBinding = + nodeType === BlockEnum.AgentV2 && + agentV2NodeDefaultsNeedInlineBinding(defaultValue, pluginDefaultValue) const { newNode: newCurrentNode, newIterationStartNode, @@ -1531,145 +1385,117 @@ export const useNodesInteractions = () => { extent: currentNode.extent, zIndex: currentNode.zIndex, }) - const parentNode = nodes.find(node => node.id === currentNode.parentId) - const newNodeIsInIteration - = !!parentNode && parentNode.data.type === BlockEnum.Iteration - const newNodeIsInLoop - = !!parentNode && parentNode.data.type === BlockEnum.Loop - const outgoingEdges = connectedEdges.filter( - edge => edge.source === currentNodeId, - ) + const parentNode = nodes.find((node) => node.id === currentNode.parentId) + const newNodeIsInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration + const newNodeIsInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop + const outgoingEdges = connectedEdges.filter((edge) => edge.source === currentNodeId) const normalizedSourceHandle = sourceHandle || 'source' - const outgoingHandles = new Set( - outgoingEdges.map(edge => edge.sourceHandle || 'source'), - ) + const outgoingHandles = new Set(outgoingEdges.map((edge) => edge.sourceHandle || 'source')) const branchSourceHandle = currentNode.data._targetBranches?.[0]?.id let outgoingHandleToPreserve = normalizedSourceHandle if (!outgoingHandles.has(outgoingHandleToPreserve)) { if (branchSourceHandle && outgoingHandles.has(branchSourceHandle)) outgoingHandleToPreserve = branchSourceHandle - else if (outgoingHandles.has('source')) - outgoingHandleToPreserve = 'source' - else - outgoingHandleToPreserve = outgoingEdges[0]?.sourceHandle || 'source' + else if (outgoingHandles.has('source')) outgoingHandleToPreserve = 'source' + else outgoingHandleToPreserve = outgoingEdges[0]?.sourceHandle || 'source' } const outgoingEdgesToPreserve = outgoingEdges.filter( - edge => (edge.sourceHandle || 'source') === outgoingHandleToPreserve, - ) - const outgoingEdgeIds = new Set( - outgoingEdgesToPreserve.map(edge => edge.id), + (edge) => (edge.sourceHandle || 'source') === outgoingHandleToPreserve, ) + const outgoingEdgeIds = new Set(outgoingEdgesToPreserve.map((edge) => edge.id)) const newNodeSourceHandle = newCurrentNode.data._targetBranches?.[0]?.id || 'source' - const reconnectedEdges = connectedEdges.reduce<Edge[]>( - (acc, edge) => { - if (outgoingEdgeIds.has(edge.id)) { - const originalTargetNode = nodes.find( - node => node.id === edge.target, - ) - const targetNodeForEdge - = originalTargetNode && originalTargetNode.id !== currentNodeId - ? originalTargetNode - : newCurrentNode - if (!targetNodeForEdge) - return acc - - const targetHandle = edge.targetHandle || 'target' - const targetParentNode - = targetNodeForEdge.id === newCurrentNode.id - ? parentNode || null - : nodes.find(node => node.id === targetNodeForEdge.parentId) - || null - const isInIteration - = !!targetParentNode - && targetParentNode.data.type === BlockEnum.Iteration - const isInLoop - = !!targetParentNode - && targetParentNode.data.type === BlockEnum.Loop - - acc.push({ - ...edge, - id: `${newCurrentNode.id}-${newNodeSourceHandle}-${targetNodeForEdge.id}-${targetHandle}`, - source: newCurrentNode.id, - sourceHandle: newNodeSourceHandle, - target: targetNodeForEdge.id, - targetHandle, - type: CUSTOM_EDGE, - data: { - ...edge.data, - sourceType: newCurrentNode.data.type, - targetType: targetNodeForEdge.data.type, - isInIteration, - iteration_id: isInIteration - ? targetNodeForEdge.parentId - : undefined, - isInLoop, - loop_id: isInLoop ? targetNodeForEdge.parentId : undefined, - _connectedNodeIsSelected: false, - }, - zIndex: targetNodeForEdge.parentId ? NESTED_ELEMENT_Z_INDEX : 0, - }) - } + const reconnectedEdges = connectedEdges.reduce<Edge[]>((acc, edge) => { + if (outgoingEdgeIds.has(edge.id)) { + const originalTargetNode = nodes.find((node) => node.id === edge.target) + const targetNodeForEdge = + originalTargetNode && originalTargetNode.id !== currentNodeId + ? originalTargetNode + : newCurrentNode + if (!targetNodeForEdge) return acc + + const targetHandle = edge.targetHandle || 'target' + const targetParentNode = + targetNodeForEdge.id === newCurrentNode.id + ? parentNode || null + : nodes.find((node) => node.id === targetNodeForEdge.parentId) || null + const isInIteration = + !!targetParentNode && targetParentNode.data.type === BlockEnum.Iteration + const isInLoop = !!targetParentNode && targetParentNode.data.type === BlockEnum.Loop + + acc.push({ + ...edge, + id: `${newCurrentNode.id}-${newNodeSourceHandle}-${targetNodeForEdge.id}-${targetHandle}`, + source: newCurrentNode.id, + sourceHandle: newNodeSourceHandle, + target: targetNodeForEdge.id, + targetHandle, + type: CUSTOM_EDGE, + data: { + ...edge.data, + sourceType: newCurrentNode.data.type, + targetType: targetNodeForEdge.data.type, + isInIteration, + iteration_id: isInIteration ? targetNodeForEdge.parentId : undefined, + isInLoop, + loop_id: isInLoop ? targetNodeForEdge.parentId : undefined, + _connectedNodeIsSelected: false, + }, + zIndex: targetNodeForEdge.parentId ? NESTED_ELEMENT_Z_INDEX : 0, + }) + } - if ( - edge.target === currentNodeId - && edge.source !== currentNodeId - && !outgoingEdgeIds.has(edge.id) - ) { - const sourceNode = nodes.find(node => node.id === edge.source) - if (!sourceNode) - return acc - - const targetHandle = edge.targetHandle || 'target' - const sourceHandle = edge.sourceHandle || 'source' - - acc.push({ - ...edge, - id: `${sourceNode.id}-${sourceHandle}-${newCurrentNode.id}-${targetHandle}`, - source: sourceNode.id, - sourceHandle, - target: newCurrentNode.id, - targetHandle, - type: CUSTOM_EDGE, - data: { - ...edge.data, - sourceType: sourceNode.data.type, - targetType: newCurrentNode.data.type, - isInIteration: newNodeIsInIteration, - iteration_id: newNodeIsInIteration - ? newCurrentNode.parentId - : undefined, - isInLoop: newNodeIsInLoop, - loop_id: newNodeIsInLoop ? newCurrentNode.parentId : undefined, - _connectedNodeIsSelected: false, - }, - zIndex: newCurrentNode.parentId ? NESTED_ELEMENT_Z_INDEX : 0, - }) - } + if ( + edge.target === currentNodeId && + edge.source !== currentNodeId && + !outgoingEdgeIds.has(edge.id) + ) { + const sourceNode = nodes.find((node) => node.id === edge.source) + if (!sourceNode) return acc - return acc - }, - [], - ) + const targetHandle = edge.targetHandle || 'target' + const sourceHandle = edge.sourceHandle || 'source' + + acc.push({ + ...edge, + id: `${sourceNode.id}-${sourceHandle}-${newCurrentNode.id}-${targetHandle}`, + source: sourceNode.id, + sourceHandle, + target: newCurrentNode.id, + targetHandle, + type: CUSTOM_EDGE, + data: { + ...edge.data, + sourceType: sourceNode.data.type, + targetType: newCurrentNode.data.type, + isInIteration: newNodeIsInIteration, + iteration_id: newNodeIsInIteration ? newCurrentNode.parentId : undefined, + isInLoop: newNodeIsInLoop, + loop_id: newNodeIsInLoop ? newCurrentNode.parentId : undefined, + _connectedNodeIsSelected: false, + }, + zIndex: newCurrentNode.parentId ? NESTED_ELEMENT_Z_INDEX : 0, + }) + } + + return acc + }, []) const nodesWithNewNode = produce(nodes, (draft) => { draft.forEach((node) => { node.data.selected = false }) - const index = draft.findIndex(node => node.id === currentNodeId) + const index = draft.findIndex((node) => node.id === currentNodeId) draft.splice(index, 1, newCurrentNode) - if (newIterationStartNode) - draft.push(newIterationStartNode) - if (newLoopStartNode) - draft.push(newLoopStartNode) + if (newIterationStartNode) draft.push(newIterationStartNode) + if (newLoopStartNode) draft.push(newLoopStartNode) }) - const nodesConnectedSourceOrTargetHandleIdsMap - = getNodesConnectedSourceOrTargetHandleIdsMap( - [ - ...connectedEdges.map(edge => ({ type: 'remove', edge })), - ...reconnectedEdges.map(edge => ({ type: 'add', edge })), - ], - nodesWithNewNode, - ) + const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( + [ + ...connectedEdges.map((edge) => ({ type: 'remove', edge })), + ...reconnectedEdges.map((edge) => ({ type: 'add', edge })), + ], + nodesWithNewNode, + ) const newNodes = produce(nodesWithNewNode, (draft) => { draft.forEach((node) => { if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { @@ -1682,16 +1508,17 @@ export const useNodesInteractions = () => { }) setNodes(newNodes) const remainingEdges = edges.filter( - edge => - !connectedEdges.some(connectedEdge => connectedEdge.id === edge.id), + (edge) => !connectedEdges.some((connectedEdge) => connectedEdge.id === edge.id), ) setEdges([...remainingEdges, ...reconnectedEdges]) if (nodeType === BlockEnum.TriggerWebhook) { handleSyncWorkflowDraft(true, true, { onSuccess: () => autoGenerateWebhookUrl(newCurrentNode.id), }) - } - else if (isAgentV2NodeData(newCurrentNode.data) && needsInlineAgentBindingCreation(newCurrentNode.data)) { + } else if ( + isAgentV2NodeData(newCurrentNode.data) && + needsInlineAgentBindingCreation(newCurrentNode.data) + ) { createInlineAgentBindingForNode(newCurrentNode.id, { onError: () => { const { setNodes, setEdges } = collaborativeWorkflow.getState() @@ -1699,8 +1526,7 @@ export const useNodesInteractions = () => { setEdges(edges) }, }) - } - else { + } else { handleSyncWorkflowDraft() } @@ -1731,18 +1557,12 @@ export const useNodesInteractions = () => { const handleNodeContextMenu = useCallback( (e: MouseEvent, node: Node) => { - if ( - node.type === CUSTOM_NOTE_NODE - || node.type === CUSTOM_ITERATION_START_NODE - ) { + if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE) { e.stopPropagation() return } - if ( - node.type === CUSTOM_NOTE_NODE - || node.type === CUSTOM_LOOP_START_NODE - ) { + if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_LOOP_START_NODE) { e.stopPropagation() return } @@ -1759,95 +1579,85 @@ export const useNodesInteractions = () => { [workflowStore, handleNodeSelect], ) - const isNodeCopyable = useCallback((node: Node) => { - if ( - node.type === CUSTOM_ITERATION_START_NODE - || node.type === CUSTOM_LOOP_START_NODE - ) { - return false - } + const isNodeCopyable = useCallback( + (node: Node) => { + if (node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_LOOP_START_NODE) { + return false + } - if ( - node.data.type === BlockEnum.Start - || node.data.type === BlockEnum.LoopEnd - || node.data.type === BlockEnum.KnowledgeBase - || node.data.type === BlockEnum.DataSourceEmpty - ) { - return false - } + if ( + node.data.type === BlockEnum.Start || + node.data.type === BlockEnum.LoopEnd || + node.data.type === BlockEnum.KnowledgeBase || + node.data.type === BlockEnum.DataSourceEmpty + ) { + return false + } - if (node.type === CUSTOM_NOTE_NODE) - return true + if (node.type === CUSTOM_NOTE_NODE) return true - const nodeMeta = nodesMetaDataMap?.[getNodeCatalogType(node.data)] - if (!nodeMeta) - return false + const nodeMeta = nodesMetaDataMap?.[getNodeCatalogType(node.data)] + if (!nodeMeta) return false - const { metaData } = nodeMeta - return !metaData.isSingleton - }, [nodesMetaDataMap]) + const { metaData } = nodeMeta + return !metaData.isSingleton + }, + [nodesMetaDataMap], + ) - const getNodeDefaultValueForPaste = useCallback((node: Node) => { - if (node.type === CUSTOM_NOTE_NODE) - return {} + const getNodeDefaultValueForPaste = useCallback( + (node: Node) => { + if (node.type === CUSTOM_NOTE_NODE) return {} - const nodeMeta = nodesMetaDataMap?.[getNodeCatalogType(node.data)] - return nodeMeta?.defaultValue - }, [nodesMetaDataMap]) + const nodeMeta = nodesMetaDataMap?.[getNodeCatalogType(node.data)] + return nodeMeta?.defaultValue + }, + [nodesMetaDataMap], + ) const handleNodesCopy = useCallback( (nodeId?: string) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const { setClipboardData } = workflowStore.getState() const { nodes, edges } = collaborativeWorkflow.getState() let nodesToCopy: Node[] = [] if (nodeId) { - const nodeToCopy = nodes.find(node => node.id === nodeId && isNodeCopyable(node)) - if (nodeToCopy) - nodesToCopy = [nodeToCopy] - } - else { + const nodeToCopy = nodes.find((node) => node.id === nodeId && isNodeCopyable(node)) + if (nodeToCopy) nodesToCopy = [nodeToCopy] + } else { const bundledNodes = nodes.filter((node) => { - if (!node.data._isBundled) - return false + if (!node.data._isBundled) return false - if (!isNodeCopyable(node)) - return false + if (!isNodeCopyable(node)) return false - if (node.type === CUSTOM_NOTE_NODE) - return true + if (node.type === CUSTOM_NOTE_NODE) return true return !node.data.isInIteration && !node.data.isInLoop }) if (bundledNodes.length) { nodesToCopy = bundledNodes - } - else { - const selectedNodes = nodes.filter( - node => node.data.selected && isNodeCopyable(node), - ) + } else { + const selectedNodes = nodes.filter((node) => node.data.selected && isNodeCopyable(node)) - if (selectedNodes.length) - nodesToCopy = selectedNodes + if (selectedNodes.length) nodesToCopy = selectedNodes } } - if (!nodesToCopy.length) - return + if (!nodesToCopy.length) return - const copiedNodesMap = new Map(nodesToCopy.map(node => [node.id, node])) + const copiedNodesMap = new Map(nodesToCopy.map((node) => [node.id, node])) const queue = nodesToCopy - .filter(node => node.data.type === BlockEnum.Iteration || node.data.type === BlockEnum.Loop) - .map(node => node.id) + .filter( + (node) => node.data.type === BlockEnum.Iteration || node.data.type === BlockEnum.Loop, + ) + .map((node) => node.id) while (queue.length) { const parentId = queue.shift()! nodes.forEach((node) => { - if (node.parentId !== parentId || copiedNodesMap.has(node.id)) - return + if (node.parentId !== parentId || copiedNodesMap.has(node.id)) return copiedNodesMap.set(node.id, node) if (node.data.type === BlockEnum.Iteration || node.data.type === BlockEnum.Loop) @@ -1856,9 +1666,9 @@ export const useNodesInteractions = () => { } const copiedNodes = [...copiedNodesMap.values()] - const copiedNodeIds = new Set(copiedNodes.map(node => node.id)) + const copiedNodeIds = new Set(copiedNodes.map((node) => node.id)) const copiedEdges = edges.filter( - edge => copiedNodeIds.has(edge.source) && copiedNodeIds.has(edge.target), + (edge) => copiedNodeIds.has(edge.source) && copiedNodeIds.has(edge.target), ) const clipboardData = { @@ -1873,8 +1683,7 @@ export const useNodesInteractions = () => { ) const handleNodesPaste = useCallback(async () => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const { clipboardElements: storeClipboardElements, @@ -1886,26 +1695,20 @@ export const useNodesInteractions = () => { const hasSystemClipboard = clipboardData.nodes.length > 0 const shouldRunCompatibilityCheck = hasSystemClipboard && clipboardData.isVersionMismatch - const clipboardElements = hasSystemClipboard - ? clipboardData.nodes - : storeClipboardElements - const clipboardEdges = hasSystemClipboard - ? clipboardData.edges - : storeClipboardEdges + const clipboardElements = hasSystemClipboard ? clipboardData.nodes : storeClipboardElements + const clipboardEdges = hasSystemClipboard ? clipboardData.edges : storeClipboardEdges - if (hasSystemClipboard) - setClipboardData(clipboardData) + if (hasSystemClipboard) setClipboardData(clipboardData) const validatedClipboardElements = clipboardElements.filter(isClipboardNodeStructurallyValid) const validatedClipboardEdges = clipboardEdges.filter(isClipboardEdgeStructurallyValid) - if (!validatedClipboardElements.length) - return + if (!validatedClipboardElements.length) return const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() const reservedNodeTitles = new Set( nodes - .map(node => node.data.title) + .map((node) => node.data.title) .filter((title): title is string => typeof title === 'string'), ) @@ -1913,16 +1716,14 @@ export const useNodesInteractions = () => { const edgesToPaste: Edge[] = [] let compatibleClipboardElements = validatedClipboardElements.filter((node) => { - if (node.type === CUSTOM_NOTE_NODE) - return true + if (node.type === CUSTOM_NOTE_NODE) return true const nodeDefaultValue = getNodeDefaultValueForPaste(node) - if (!nodeDefaultValue) - return false + if (!nodeDefaultValue) return false if ( - shouldRunCompatibilityCheck - && !isClipboardValueCompatibleWithDefault(nodeDefaultValue, node.data) + shouldRunCompatibilityCheck && + !isClipboardValueCompatibleWithDefault(nodeDefaultValue, node.data) ) { return false } @@ -1937,33 +1738,30 @@ export const useNodesInteractions = () => { ) } - const compatibleClipboardNodeIds = new Set( - compatibleClipboardElements.map(node => node.id), - ) + const compatibleClipboardNodeIds = new Set(compatibleClipboardElements.map((node) => node.id)) const filteredNodeCount = shouldRunCompatibilityCheck ? validatedClipboardElements.length - compatibleClipboardElements.length : 0 const filteredEdgeCount = shouldRunCompatibilityCheck - ? validatedClipboardEdges.filter(edge => - !compatibleClipboardNodeIds.has(edge.source) - || !compatibleClipboardNodeIds.has(edge.target), - ).length + ? validatedClipboardEdges.filter( + (edge) => + !compatibleClipboardNodeIds.has(edge.source) || + !compatibleClipboardNodeIds.has(edge.target), + ).length : 0 - if ( - shouldRunCompatibilityCheck - && (filteredNodeCount > 0 || filteredEdgeCount > 0) - ) { - toast.warning(t($ => $['common.clipboardVersionCompatibilityWarning'], { - ns: 'workflow', - })) + if (shouldRunCompatibilityCheck && (filteredNodeCount > 0 || filteredEdgeCount > 0)) { + toast.warning( + t(($) => $['common.clipboardVersionCompatibilityWarning'], { + ns: 'workflow', + }), + ) } - if (!compatibleClipboardElements.length) - return + if (!compatibleClipboardElements.length) return const rootClipboardNodes = compatibleClipboardElements.filter( - node => !node.parentId || !compatibleClipboardNodeIds.has(node.parentId), + (node) => !node.parentId || !compatibleClipboardNodeIds.has(node.parentId), ) const positionReferenceNodes = rootClipboardNodes.length ? rootClipboardNodes @@ -1978,8 +1776,8 @@ export const useNodesInteractions = () => { const offsetY = currentPosition.y - y let idMapping: Record<string, string> = {} const pastedNodesMap: Record<string, Node> = {} - const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = [] - const selectedNodes = nodes.filter(node => node.selected) + const parentChildrenToAppend: { parentId: string; childId: string; childType: BlockEnum }[] = [] + const selectedNodes = nodes.filter((node) => node.selected) // Keep this list aligned with availableBlocksFilter(inContainer) // in use-available-blocks.ts. const commonNestedDisallowPasteNodes = [ @@ -1992,62 +1790,67 @@ export const useNodesInteractions = () => { ] // Same-canvas copy keeps the source container selected, so only treat a // selected container as the paste target when it is not part of the clipboard. - const selectedContainerNode = selectedNodes.length === 1 - && (selectedNodes[0]?.data.type === BlockEnum.Iteration || selectedNodes[0]?.data.type === BlockEnum.Loop) - && !compatibleClipboardNodeIds.has(selectedNodes[0].id) - ? selectedNodes[0] - : undefined + const selectedContainerNode = + selectedNodes.length === 1 && + (selectedNodes[0]?.data.type === BlockEnum.Iteration || + selectedNodes[0]?.data.type === BlockEnum.Loop) && + !compatibleClipboardNodeIds.has(selectedNodes[0].id) + ? selectedNodes[0] + : undefined rootClipboardNodes.forEach((nodeToPaste, index) => { const nodeDefaultValue = getNodeDefaultValueForPaste(nodeToPaste) - if (nodeToPaste.type !== CUSTOM_NOTE_NODE && !nodeDefaultValue) - return + if (nodeToPaste.type !== CUSTOM_NOTE_NODE && !nodeDefaultValue) return if (selectedContainerNode && commonNestedDisallowPasteNodes.includes(nodeToPaste.data.type)) return const mergedData = shouldRunCompatibilityCheck - ? sanitizeClipboardValueByDefault(nodeDefaultValue ?? {}, nodeToPaste.data) as Record<string, unknown> + ? (sanitizeClipboardValueByDefault(nodeDefaultValue ?? {}, nodeToPaste.data) as Record< + string, + unknown + >) : { ...(nodeToPaste.type !== CUSTOM_NOTE_NODE ? nodeDefaultValue : {}), ...nodeToPaste.data, } - const sourceTitle = typeof mergedData.title === 'string' - ? mergedData.title - : typeof nodeToPaste.data.title === 'string' - ? nodeToPaste.data.title - : 'Node' - const sourceDesc = typeof mergedData.desc === 'string' - ? mergedData.desc - : typeof nodeToPaste.data.desc === 'string' - ? nodeToPaste.data.desc - : '' - - const { newNode, newIterationStartNode, newLoopStartNode } - = generateNewNode({ - type: nodeToPaste.type, - data: { - ...mergedData, - type: nodeToPaste.data.type, - desc: sourceDesc, - selected: false, - _isBundled: false, - _connectedSourceHandleIds: [], - _connectedTargetHandleIds: [], - _dimmed: false, - isInIteration: false, - iteration_id: undefined, - isInLoop: false, - loop_id: undefined, - title: getUniquePastedNodeTitle(sourceTitle, reservedNodeTitles), - }, - position: { - x: nodeToPaste.position.x + offsetX, - y: nodeToPaste.position.y + offsetY, - }, - extent: nodeToPaste.extent, - zIndex: 0, - }) + const sourceTitle = + typeof mergedData.title === 'string' + ? mergedData.title + : typeof nodeToPaste.data.title === 'string' + ? nodeToPaste.data.title + : 'Node' + const sourceDesc = + typeof mergedData.desc === 'string' + ? mergedData.desc + : typeof nodeToPaste.data.desc === 'string' + ? nodeToPaste.data.desc + : '' + + const { newNode, newIterationStartNode, newLoopStartNode } = generateNewNode({ + type: nodeToPaste.type, + data: { + ...mergedData, + type: nodeToPaste.data.type, + desc: sourceDesc, + selected: false, + _isBundled: false, + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: [], + _dimmed: false, + isInIteration: false, + iteration_id: undefined, + isInLoop: false, + loop_id: undefined, + title: getUniquePastedNodeTitle(sourceTitle, reservedNodeTitles), + }, + position: { + x: nodeToPaste.position.x + offsetX, + y: nodeToPaste.position.y + offsetY, + }, + extent: nodeToPaste.extent, + zIndex: 0, + }) newNode.id = newNode.id + index let newChildren: Node[] = [] @@ -2059,41 +1862,41 @@ export const useNodesInteractions = () => { } const oldIterationStartNodeInClipboard = compatibleClipboardElements.find( - n => - n.parentId === nodeToPaste.id - && n.type === CUSTOM_ITERATION_START_NODE, + (n) => n.parentId === nodeToPaste.id && n.type === CUSTOM_ITERATION_START_NODE, ) if (oldIterationStartNodeInClipboard && newIterationStartNode) idMapping[oldIterationStartNodeInClipboard.id] = newIterationStartNode.id const copiedIterationChildren = compatibleClipboardElements.filter( - n => - n.parentId === nodeToPaste.id - && n.type !== CUSTOM_ITERATION_START_NODE, + (n) => n.parentId === nodeToPaste.id && n.type !== CUSTOM_ITERATION_START_NODE, ) if (copiedIterationChildren.length) { copiedIterationChildren.forEach((child, childIndex) => { const childType = child.data.type const childDefaultValue = getNodeDefaultValueForPaste(child) - if (child.type !== CUSTOM_NOTE_NODE && !childDefaultValue) - return + if (child.type !== CUSTOM_NOTE_NODE && !childDefaultValue) return const mergedChildData = shouldRunCompatibilityCheck - ? sanitizeClipboardValueByDefault(childDefaultValue ?? {}, child.data) as Record<string, unknown> + ? (sanitizeClipboardValueByDefault(childDefaultValue ?? {}, child.data) as Record< + string, + unknown + >) : { ...(child.type !== CUSTOM_NOTE_NODE ? childDefaultValue : {}), ...child.data, } - const childSourceTitle = typeof mergedChildData.title === 'string' - ? mergedChildData.title - : typeof child.data.title === 'string' - ? child.data.title - : 'Node' - const childSourceDesc = typeof mergedChildData.desc === 'string' - ? mergedChildData.desc - : typeof child.data.desc === 'string' - ? child.data.desc - : '' + const childSourceTitle = + typeof mergedChildData.title === 'string' + ? mergedChildData.title + : typeof child.data.title === 'string' + ? child.data.title + : 'Node' + const childSourceDesc = + typeof mergedChildData.desc === 'string' + ? mergedChildData.desc + : typeof child.data.desc === 'string' + ? child.data.desc + : '' const { newNode: newChild } = generateNewNode({ type: child.type, @@ -2122,22 +1925,18 @@ export const useNodesInteractions = () => { idMapping[child.id] = newChild.id newChildren.push(newChild) }) - } - else { + } else { const oldIterationStartNode = nodes.find( - n => - n.parentId === nodeToPaste.id - && n.type === CUSTOM_ITERATION_START_NODE, + (n) => n.parentId === nodeToPaste.id && n.type === CUSTOM_ITERATION_START_NODE, ) if (oldIterationStartNode && newIterationStartNode) idMapping[oldIterationStartNode.id] = newIterationStartNode.id - const { copyChildren, newIdMapping } - = handleNodeIterationChildrenCopy( - nodeToPaste.id, - newNode.id, - idMapping, - ) + const { copyChildren, newIdMapping } = handleNodeIterationChildrenCopy( + nodeToPaste.id, + newNode.id, + idMapping, + ) newChildren = copyChildren idMapping = newIdMapping } @@ -2148,10 +1947,8 @@ export const useNodesInteractions = () => { nodeType: child.data.type, }) }) - if (newIterationStartNode) - newChildren.push(newIterationStartNode) - } - else if (nodeToPaste.data.type === BlockEnum.Loop) { + if (newIterationStartNode) newChildren.push(newIterationStartNode) + } else if (nodeToPaste.data.type === BlockEnum.Loop) { if (newLoopStartNode) { newLoopStartNode.parentId = newNode.id const loopNodeData = newNode.data as LoopNodeType @@ -2159,41 +1956,41 @@ export const useNodesInteractions = () => { } const oldLoopStartNodeInClipboard = compatibleClipboardElements.find( - n => - n.parentId === nodeToPaste.id - && n.type === CUSTOM_LOOP_START_NODE, + (n) => n.parentId === nodeToPaste.id && n.type === CUSTOM_LOOP_START_NODE, ) if (oldLoopStartNodeInClipboard && newLoopStartNode) idMapping[oldLoopStartNodeInClipboard.id] = newLoopStartNode.id const copiedLoopChildren = compatibleClipboardElements.filter( - n => - n.parentId === nodeToPaste.id - && n.type !== CUSTOM_LOOP_START_NODE, + (n) => n.parentId === nodeToPaste.id && n.type !== CUSTOM_LOOP_START_NODE, ) if (copiedLoopChildren.length) { copiedLoopChildren.forEach((child, childIndex) => { const childType = child.data.type const childDefaultValue = getNodeDefaultValueForPaste(child) - if (child.type !== CUSTOM_NOTE_NODE && !childDefaultValue) - return + if (child.type !== CUSTOM_NOTE_NODE && !childDefaultValue) return const mergedChildData = shouldRunCompatibilityCheck - ? sanitizeClipboardValueByDefault(childDefaultValue ?? {}, child.data) as Record<string, unknown> + ? (sanitizeClipboardValueByDefault(childDefaultValue ?? {}, child.data) as Record< + string, + unknown + >) : { ...(child.type !== CUSTOM_NOTE_NODE ? childDefaultValue : {}), ...child.data, } - const childSourceTitle = typeof mergedChildData.title === 'string' - ? mergedChildData.title - : typeof child.data.title === 'string' - ? child.data.title - : 'Node' - const childSourceDesc = typeof mergedChildData.desc === 'string' - ? mergedChildData.desc - : typeof child.data.desc === 'string' - ? child.data.desc - : '' + const childSourceTitle = + typeof mergedChildData.title === 'string' + ? mergedChildData.title + : typeof child.data.title === 'string' + ? child.data.title + : 'Node' + const childSourceDesc = + typeof mergedChildData.desc === 'string' + ? mergedChildData.desc + : typeof child.data.desc === 'string' + ? child.data.desc + : '' const { newNode: newChild } = generateNewNode({ type: child.type, @@ -2222,22 +2019,18 @@ export const useNodesInteractions = () => { idMapping[child.id] = newChild.id newChildren.push(newChild) }) - } - else { + } else { const oldLoopStartNode = nodes.find( - n => - n.parentId === nodeToPaste.id - && n.type === CUSTOM_LOOP_START_NODE, + (n) => n.parentId === nodeToPaste.id && n.type === CUSTOM_LOOP_START_NODE, ) if (oldLoopStartNode && newLoopStartNode) idMapping[oldLoopStartNode.id] = newLoopStartNode.id - const { copyChildren, newIdMapping } - = handleNodeLoopChildrenCopy( - nodeToPaste.id, - newNode.id, - idMapping, - ) + const { copyChildren, newIdMapping } = handleNodeLoopChildrenCopy( + nodeToPaste.id, + newNode.id, + idMapping, + ) newChildren = copyChildren idMapping = newIdMapping } @@ -2248,11 +2041,12 @@ export const useNodesInteractions = () => { nodeType: child.data.type, }) }) - if (newLoopStartNode) - newChildren.push(newLoopStartNode) - } - else if (selectedContainerNode) { - if (selectedContainerNode.data.type === BlockEnum.Iteration || selectedContainerNode.data.type === BlockEnum.Loop) { + if (newLoopStartNode) newChildren.push(newLoopStartNode) + } else if (selectedContainerNode) { + if ( + selectedContainerNode.data.type === BlockEnum.Iteration || + selectedContainerNode.data.type === BlockEnum.Loop + ) { const isIteration = selectedContainerNode.data.type === BlockEnum.Iteration newNode.data.isInIteration = isIteration @@ -2296,12 +2090,13 @@ export const useNodesInteractions = () => { if (sourceId && targetId) { const sourceNode = pastedNodesMap[sourceId] const targetNode = pastedNodesMap[targetId] - if (!sourceNode || !targetNode) - return + if (!sourceNode || !targetNode) return - const parentNode = sourceNode.parentId && sourceNode.parentId === targetNode.parentId - ? pastedNodesMap[sourceNode.parentId] ?? nodes.find(n => n.id === sourceNode.parentId) - : null + const parentNode = + sourceNode.parentId && sourceNode.parentId === targetNode.parentId + ? (pastedNodesMap[sourceNode.parentId] ?? + nodes.find((n) => n.id === sourceNode.parentId)) + : null const isInIteration = parentNode?.data.type === BlockEnum.Iteration const isInLoop = parentNode?.data.type === BlockEnum.Loop const newEdge: Edge = { @@ -2327,9 +2122,8 @@ export const useNodesInteractions = () => { const newNodes = produce(nodes, (draft: Node[]) => { parentChildrenToAppend.forEach(({ parentId, childId, childType }) => { - const p = draft.find(n => n.id === parentId) - if (p) - p.data._children?.push({ nodeId: childId, nodeType: childType }) + const p = draft.find((n) => n.id === parentId) + if (p) p.data._children?.push({ nodeId: childId, nodeType: childType }) }) draft.push(...nodesToPaste) }) @@ -2356,8 +2150,7 @@ export const useNodesInteractions = () => { const handleNodesDuplicate = useCallback( (nodeId?: string) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return handleNodesCopy(nodeId) handleNodesPaste() @@ -2366,82 +2159,64 @@ export const useNodesInteractions = () => { ) const handleNodesDelete = useCallback(() => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const { nodes, edges } = collaborativeWorkflow.getState() - const bundledNodes = nodes.filter( - node => node.data._isBundled, - ) + const bundledNodes = nodes.filter((node) => node.data._isBundled) if (bundledNodes.length) { - bundledNodes.forEach(node => handleNodeDelete(node.id)) + bundledNodes.forEach((node) => handleNodeDelete(node.id)) return } - const edgeSelected = edges.some(edge => edge.selected) - if (edgeSelected) - return + const edgeSelected = edges.some((edge) => edge.selected) + if (edgeSelected) return - const selectedNode = nodes.find( - node => node.data.selected, - ) + const selectedNode = nodes.find((node) => node.data.selected) - if (selectedNode) - handleNodeDelete(selectedNode.id) + if (selectedNode) handleNodeDelete(selectedNode.id) }, [collaborativeWorkflow, getNodesReadOnly, handleNodeDelete]) const handleNodeResize = useCallback( (nodeId: string, params: ResizeParamsWithDirection) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const { nodes, setNodes } = collaborativeWorkflow.getState() const { x, y, width, height } = params - const currentNode = nodes.find(n => n.id === nodeId)! - const childrenNodes = nodes.filter(n => - currentNode.data._children?.find((child: NonNullable<Node['data']['_children']>[number]) => child.nodeId === n.id), + const currentNode = nodes.find((n) => n.id === nodeId)! + const childrenNodes = nodes.filter((n) => + currentNode.data._children?.find( + (child: NonNullable<Node['data']['_children']>[number]) => child.nodeId === n.id, + ), ) let rightNode: Node let bottomNode: Node childrenNodes.forEach((n) => { if (rightNode) { - if (n.position.x + n.width! > rightNode.position.x + rightNode.width!) - rightNode = n - } - else { + if (n.position.x + n.width! > rightNode.position.x + rightNode.width!) rightNode = n + } else { rightNode = n } if (bottomNode) { - if ( - n.position.y + n.height! - > bottomNode.position.y + bottomNode.height! - ) { + if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!) { bottomNode = n } - } - else { + } else { bottomNode = n } }) if (rightNode! && bottomNode!) { - const parentNode = nodes.find(n => n.id === rightNode.parentId) - const paddingMap - = parentNode?.data.type === BlockEnum.Iteration - ? ITERATION_PADDING - : LOOP_PADDING + const parentNode = nodes.find((n) => n.id === rightNode.parentId) + const paddingMap = + parentNode?.data.type === BlockEnum.Iteration ? ITERATION_PADDING : LOOP_PADDING - if (width < rightNode!.position.x + rightNode.width! + paddingMap.right) - return - if ( - height - < bottomNode.position.y + bottomNode.height! + paddingMap.bottom - ) { + if (width < rightNode!.position.x + rightNode.width! + paddingMap.right) return + if (height < bottomNode.position.y + bottomNode.height! + paddingMap.bottom) { return } } @@ -2466,17 +2241,15 @@ export const useNodesInteractions = () => { const handleNodeDisconnect = useCallback( (nodeId: string) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() - const currentNode = nodes.find(node => node.id === nodeId)! + const currentNode = nodes.find((node) => node.id === nodeId)! const connectedEdges = getConnectedEdges([currentNode], edges) - const nodesConnectedSourceOrTargetHandleIdsMap - = getNodesConnectedSourceOrTargetHandleIdsMap( - connectedEdges.map(edge => ({ type: 'remove', edge })), - nodes, - ) + const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( + connectedEdges.map((edge) => ({ type: 'remove', edge })), + nodes, + ) const newNodes = produce(nodes, (draft: Node[]) => { draft.forEach((node) => { if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { @@ -2490,8 +2263,7 @@ export const useNodesInteractions = () => { setNodes(newNodes) const newEdges = produce(edges, (draft) => { return draft.filter( - edge => - !connectedEdges.some(connectedEdge => connectedEdge.id === edge.id), + (edge) => !connectedEdges.some((connectedEdge) => connectedEdge.id === edge.id), ) }) setEdges(newEdges) @@ -2502,20 +2274,17 @@ export const useNodesInteractions = () => { ) const handleHistoryBack = useCallback(() => { - if (getNodesReadOnly() || getWorkflowReadOnly()) - return + if (getNodesReadOnly() || getWorkflowReadOnly()) return undo() const { edges, nodes } = workflowHistoryStore.getState() - if (edges.length === 0 && nodes.length === 0) - return + if (edges.length === 0 && nodes.length === 0) return const { setNodes, setEdges } = collaborativeWorkflow.getState() const shouldBroadcast = collaborationManager.isConnected() setEdges(edges, shouldBroadcast) setNodes(nodes, shouldBroadcast, 'nodes:history-back') - if (shouldBroadcast) - collaborationManager.emitHistoryAction('undo') + if (shouldBroadcast) collaborationManager.emitHistoryAction('undo') workflowStore.setState({ contextMenuTarget: undefined }) }, [ collaborativeWorkflow, @@ -2527,20 +2296,17 @@ export const useNodesInteractions = () => { ]) const handleHistoryForward = useCallback(() => { - if (getNodesReadOnly() || getWorkflowReadOnly()) - return + if (getNodesReadOnly() || getWorkflowReadOnly()) return redo() const { edges, nodes } = workflowHistoryStore.getState() - if (edges.length === 0 && nodes.length === 0) - return + if (edges.length === 0 && nodes.length === 0) return const { setNodes, setEdges } = collaborativeWorkflow.getState() const shouldBroadcast = collaborationManager.isConnected() setEdges(edges, shouldBroadcast) setNodes(nodes, shouldBroadcast, 'nodes:history-forward') - if (shouldBroadcast) - collaborationManager.emitHistoryAction('redo') + if (shouldBroadcast) collaborationManager.emitHistoryAction('redo') workflowStore.setState({ contextMenuTarget: undefined }) }, [ collaborativeWorkflow, @@ -2554,13 +2320,11 @@ export const useNodesInteractions = () => { const [isDimming, setIsDimming] = useState(false) /** Add opacity-30 to all nodes except the nodeId */ const dimOtherNodes = useCallback(() => { - if (isDimming) - return + if (isDimming) return const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() - const selectedNode = nodes.find(n => n.data.selected) - if (!selectedNode) - return + const selectedNode = nodes.find((n) => n.data.selected) + if (!selectedNode) return setIsDimming(true) @@ -2570,10 +2334,9 @@ export const useNodesInteractions = () => { const usedVars = getNodeUsedVars(selectedNode) const dependencyNodes: Node[] = [] usedVars.forEach((valueSelector) => { - const node = workflowNodes.find(node => node.id === valueSelector?.[0]) + const node = workflowNodes.find((node) => node.id === valueSelector?.[0]) if (node) { - if (!dependencyNodes.includes(node)) - dependencyNodes.push(node) + if (!dependencyNodes.includes(node)) dependencyNodes.push(node) } }) @@ -2582,20 +2345,18 @@ export const useNodesInteractions = () => { const node = outgoers[currIdx] const outgoersForNode = getOutgoers(node!, nodes as Node[], edges) outgoersForNode.forEach((item) => { - const existed = outgoers.some(v => v.id === item.id) - if (!existed) - outgoers.push(item) + const existed = outgoers.some((v) => v.id === item.id) + if (!existed) outgoers.push(item) }) } const dependentNodes: Node[] = [] outgoers.forEach((node) => { const usedVars = getNodeUsedVars(node) - const used = usedVars.some(v => v?.[0] === selectedNode.id) + const used = usedVars.some((v) => v?.[0] === selectedNode.id) if (used) { - const existed = dependentNodes.some(v => v.id === node.id) - if (!existed) - dependentNodes.push(node) + const existed = dependentNodes.some((v) => v.id === node.id) + if (!existed) dependentNodes.push(node) } }) @@ -2603,9 +2364,8 @@ export const useNodesInteractions = () => { const newNodes = produce(nodes, (draft) => { draft.forEach((n) => { - const dimNode = dimNodes.find(v => v.id === n.id) - if (!dimNode) - n.data._dimmed = true + const dimNode = dimNodes.find((v) => v.id === n.id) + if (!dimNode) n.data._dimmed = true }) }) @@ -2671,7 +2431,7 @@ export const useNodesInteractions = () => { setNodes(newNodes) const newEdges = produce( - edges.filter(e => !e.data._isTemp), + edges.filter((e) => !e.data._isTemp), (draft) => { draft.forEach((e) => { e.data._dimmed = false diff --git a/web/app/components/workflow/hooks/use-nodes-meta-data.ts b/web/app/components/workflow/hooks/use-nodes-meta-data.ts index 37ad7986571838..b48cfd41fedf51 100644 --- a/web/app/components/workflow/hooks/use-nodes-meta-data.ts +++ b/web/app/components/workflow/hooks/use-nodes-meta-data.ts @@ -7,15 +7,11 @@ import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { getNodeCatalogType } from '@/app/components/workflow/utils/node' import { useGetLanguage } from '@/context/i18n' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools } from '@/service/use-tools' import { canFindTool } from '@/utils' export const useNodesMetaData = () => { - const availableNodesMetaData = useHooksStore(s => s.availableNodesMetaData) + const availableNodesMetaData = useHooksStore((s) => s.availableNodesMetaData) return useMemo(() => { return { @@ -30,33 +26,42 @@ export const useNodeMetaData = (node: Node) => { const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() - const dataSourceList = useStore(s => s.dataSourceList) + const dataSourceList = useStore((s) => s.dataSourceList) const availableNodesMetaData = useNodesMetaData() const { data } = node const nodeMetaData = availableNodesMetaData.nodesMap?.[getNodeCatalogType(data)] const author = useMemo(() => { if (data.type === BlockEnum.DataSource) - return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.author + return dataSourceList?.find((dataSource) => dataSource.plugin_id === data.plugin_id)?.author if (data.type === BlockEnum.Tool) { if (data.provider_type === CollectionType.builtIn) - return buildInTools?.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.author + return buildInTools?.find((toolWithProvider) => + canFindTool(toolWithProvider.id, data.provider_id), + )?.author if (data.provider_type === CollectionType.workflow) - return workflowTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author - return customTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author + return workflowTools?.find((toolWithProvider) => toolWithProvider.id === data.provider_id) + ?.author + return customTools?.find((toolWithProvider) => toolWithProvider.id === data.provider_id) + ?.author } return nodeMetaData?.metaData.author }, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList]) const description = useMemo(() => { if (data.type === BlockEnum.DataSource) - return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.description[language] + return dataSourceList?.find((dataSource) => dataSource.plugin_id === data.plugin_id) + ?.description[language] if (data.type === BlockEnum.Tool) { if (data.provider_type === CollectionType.builtIn) - return buildInTools?.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.description[language] + return buildInTools?.find((toolWithProvider) => + canFindTool(toolWithProvider.id, data.provider_id), + )?.description[language] if (data.provider_type === CollectionType.workflow) - return workflowTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language] - return customTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language] + return workflowTools?.find((toolWithProvider) => toolWithProvider.id === data.provider_id) + ?.description[language] + return customTools?.find((toolWithProvider) => toolWithProvider.id === data.provider_id) + ?.description[language] } return nodeMetaData?.metaData.description }, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList, language]) diff --git a/web/app/components/workflow/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow/hooks/use-nodes-sync-draft.ts index 0ba2d3a6cf365d..c4efd4edc63289 100644 --- a/web/app/components/workflow/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow/hooks/use-nodes-sync-draft.ts @@ -8,23 +8,19 @@ export type SyncCallback = SyncDraftCallback export const useNodesSyncDraft = () => { const { getNodesReadOnly } = useNodesReadOnly() - const debouncedSyncWorkflowDraft = useStore(s => s.debouncedSyncWorkflowDraft) - const doSyncWorkflowDraft = useHooksStore(s => s.doSyncWorkflowDraft) - const syncWorkflowDraftWhenPageClose = useHooksStore(s => s.syncWorkflowDraftWhenPageClose) + const debouncedSyncWorkflowDraft = useStore((s) => s.debouncedSyncWorkflowDraft) + const doSyncWorkflowDraft = useHooksStore((s) => s.doSyncWorkflowDraft) + const syncWorkflowDraftWhenPageClose = useHooksStore((s) => s.syncWorkflowDraftWhenPageClose) - const handleSyncWorkflowDraft = useCallback(( - sync?: boolean, - notRefreshWhenSyncError?: boolean, - callback?: SyncDraftCallback, - ) => { - if (getNodesReadOnly()) - return + const handleSyncWorkflowDraft = useCallback( + (sync?: boolean, notRefreshWhenSyncError?: boolean, callback?: SyncDraftCallback) => { + if (getNodesReadOnly()) return - if (sync) - doSyncWorkflowDraft(notRefreshWhenSyncError, callback) - else - debouncedSyncWorkflowDraft(doSyncWorkflowDraft) - }, [debouncedSyncWorkflowDraft, doSyncWorkflowDraft, getNodesReadOnly]) + if (sync) doSyncWorkflowDraft(notRefreshWhenSyncError, callback) + else debouncedSyncWorkflowDraft(doSyncWorkflowDraft) + }, + [debouncedSyncWorkflowDraft, doSyncWorkflowDraft, getNodesReadOnly], + ) return { doSyncWorkflowDraft, diff --git a/web/app/components/workflow/hooks/use-panel-interactions.ts b/web/app/components/workflow/hooks/use-panel-interactions.ts index 05f47518ecde90..e7af3d7be3bfd5 100644 --- a/web/app/components/workflow/hooks/use-panel-interactions.ts +++ b/web/app/components/workflow/hooks/use-panel-interactions.ts @@ -7,25 +7,29 @@ import { readWorkflowClipboard } from '../utils' export const usePanelInteractions = () => { const workflowStore = useWorkflowStore() - const { data: appDslVersion = '' } = useQuery(consoleQuery.appDslVersion.get.queryOptions({ - staleTime: Infinity, - select: data => data.app_dsl_version, - })) + const { data: appDslVersion = '' } = useQuery( + consoleQuery.appDslVersion.get.queryOptions({ + staleTime: Infinity, + select: (data) => data.app_dsl_version, + }), + ) - const handlePaneContextMenu = useCallback((e: MouseEvent) => { - e.preventDefault() - // Sync the latest system clipboard into the workflow store before opening - // the pane menu because "Paste here" is disabled when no compatible node - // copy exists, including cross-app copies written outside this tab. - void readWorkflowClipboard(appDslVersion).then(({ nodes, edges }) => { - if (nodes.length) - workflowStore.getState().setClipboardData({ nodes, edges }) - }) + const handlePaneContextMenu = useCallback( + (e: MouseEvent) => { + e.preventDefault() + // Sync the latest system clipboard into the workflow store before opening + // the pane menu because "Paste here" is disabled when no compatible node + // copy exists, including cross-app copies written outside this tab. + void readWorkflowClipboard(appDslVersion).then(({ nodes, edges }) => { + if (nodes.length) workflowStore.getState().setClipboardData({ nodes, edges }) + }) - workflowStore.setState({ - contextMenuTarget: { type: 'panel' }, - }) - }, [workflowStore, appDslVersion]) + workflowStore.setState({ + contextMenuTarget: { type: 'panel' }, + }) + }, + [workflowStore, appDslVersion], + ) const handlePaneContextmenuCancel = useCallback(() => { workflowStore.setState({ contextMenuTarget: undefined }) diff --git a/web/app/components/workflow/hooks/use-selection-interactions.ts b/web/app/components/workflow/hooks/use-selection-interactions.ts index cfcbb8c217396b..05513b7d86d833 100644 --- a/web/app/components/workflow/hooks/use-selection-interactions.ts +++ b/web/app/components/workflow/hooks/use-selection-interactions.ts @@ -1,12 +1,8 @@ import type { MouseEvent } from 'react' -import type { - OnSelectionChangeFunc, -} from 'reactflow' +import type { OnSelectionChangeFunc } from 'reactflow' import type { Node } from '../types' import { produce } from 'immer' -import { - useCallback, -} from 'react' +import { useCallback } from 'react' import { useStoreApi } from 'reactflow' import { useWorkflowStore } from '../store' import { useCollaborativeWorkflow } from './use-collaborative-workflow' @@ -19,98 +15,78 @@ export const useSelectionInteractions = () => { const { getNodesReadOnly } = useNodesReadOnly() const handleSelectionStart = useCallback(() => { - const { - getNodes, - setNodes, - edges, - setEdges, - userSelectionRect, - } = store.getState() + const { getNodes, setNodes, edges, setEdges, userSelectionRect } = store.getState() if (!userSelectionRect?.width || !userSelectionRect?.height) { const nodes = getNodes() const newNodes = produce(nodes, (draft) => { draft.forEach((node) => { - if (node.data._isBundled) - node.data._isBundled = false + if (node.data._isBundled) node.data._isBundled = false }) }) setNodes(newNodes) const newEdges = produce(edges, (draft) => { draft.forEach((edge) => { - if (edge.data._isBundled) - edge.data._isBundled = false + if (edge.data._isBundled) edge.data._isBundled = false }) }) setEdges(newEdges) } }, [store]) - const handleSelectionChange = useCallback<OnSelectionChangeFunc>(({ nodes: nodesInSelection, edges: edgesInSelection }) => { - const { - getNodes, - setNodes, - edges, - setEdges, - userSelectionRect, - } = store.getState() + const handleSelectionChange = useCallback<OnSelectionChangeFunc>( + ({ nodes: nodesInSelection, edges: edgesInSelection }) => { + const { getNodes, setNodes, edges, setEdges, userSelectionRect } = store.getState() - const nodes = getNodes() + const nodes = getNodes() - if (!userSelectionRect?.width || !userSelectionRect?.height) - return + if (!userSelectionRect?.width || !userSelectionRect?.height) return - const newNodes = produce(nodes, (draft) => { - draft.forEach((node) => { - const nodeInSelection = nodesInSelection.find(n => n.id === node.id) + const newNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + const nodeInSelection = nodesInSelection.find((n) => n.id === node.id) - if (nodeInSelection) - node.data._isBundled = true - else - node.data._isBundled = false + if (nodeInSelection) node.data._isBundled = true + else node.data._isBundled = false + }) }) - }) - setNodes(newNodes) - const newEdges = produce(edges, (draft) => { - draft.forEach((edge) => { - const edgeInSelection = edgesInSelection.find(e => e.id === edge.id) + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + draft.forEach((edge) => { + const edgeInSelection = edgesInSelection.find((e) => e.id === edge.id) - if (edgeInSelection) - edge.data._isBundled = true - else - edge.data._isBundled = false + if (edgeInSelection) edge.data._isBundled = true + else edge.data._isBundled = false + }) + }) + setEdges(newEdges) + }, + [store], + ) + + const handleSelectionDrag = useCallback( + (_: MouseEvent, nodesWithDrag: Node[]) => { + workflowStore.setState({ + nodeAnimation: false, }) - }) - setEdges(newEdges) - }, [store]) - - const handleSelectionDrag = useCallback((_: MouseEvent, nodesWithDrag: Node[]) => { - workflowStore.setState({ - nodeAnimation: false, - }) - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return - const { nodes, setNodes } = collaborativeWorkflow.getState() - const newNodes = produce(nodes, (draft) => { - draft.forEach((node) => { - const dragNode = nodesWithDrag.find(n => n.id === node.id) + const { nodes, setNodes } = collaborativeWorkflow.getState() + const newNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + const dragNode = nodesWithDrag.find((n) => n.id === node.id) - if (dragNode) - node.position = dragNode.position + if (dragNode) node.position = dragNode.position + }) }) - }) - setNodes(newNodes, true, 'use-selection-interactions:handleSelectionDrag') - }, [collaborativeWorkflow, getNodesReadOnly, workflowStore]) + setNodes(newNodes, true, 'use-selection-interactions:handleSelectionDrag') + }, + [collaborativeWorkflow, getNodesReadOnly, workflowStore], + ) const handleSelectionCancel = useCallback(() => { - const { - getNodes, - setNodes, - edges, - setEdges, - } = store.getState() + const { getNodes, setNodes, edges, setEdges } = store.getState() store.setState({ userSelectionRect: null, @@ -120,30 +96,30 @@ export const useSelectionInteractions = () => { const nodes = getNodes() const newNodes = produce(nodes, (draft) => { draft.forEach((node) => { - if (node.data._isBundled) - node.data._isBundled = false + if (node.data._isBundled) node.data._isBundled = false }) }) setNodes(newNodes) const newEdges = produce(edges, (draft) => { draft.forEach((edge) => { - if (edge.data._isBundled) - edge.data._isBundled = false + if (edge.data._isBundled) edge.data._isBundled = false }) }) setEdges(newEdges) }, [store]) - const handleSelectionContextMenu = useCallback((e: MouseEvent) => { - const target = e.target as HTMLElement - if (!target.classList.contains('react-flow__nodesselection-rect')) - return + const handleSelectionContextMenu = useCallback( + (e: MouseEvent) => { + const target = e.target as HTMLElement + if (!target.classList.contains('react-flow__nodesselection-rect')) return - e.preventDefault() - workflowStore.setState({ - contextMenuTarget: { type: 'selection' }, - }) - }, [workflowStore]) + e.preventDefault() + workflowStore.setState({ + contextMenuTarget: { type: 'selection' }, + }) + }, + [workflowStore], + ) return { handleSelectionStart, diff --git a/web/app/components/workflow/hooks/use-serial-async-callback.ts b/web/app/components/workflow/hooks/use-serial-async-callback.ts index c36409a776b99e..68ae2fd93edb7a 100644 --- a/web/app/components/workflow/hooks/use-serial-async-callback.ts +++ b/web/app/components/workflow/hooks/use-serial-async-callback.ts @@ -1,7 +1,4 @@ -import { - useCallback, - useRef, -} from 'react' +import { useCallback, useRef } from 'react' export const useSerialAsyncCallback = <Args extends any[], Result = void>( fn: (...args: Args) => Promise<Result> | Result, @@ -9,14 +6,16 @@ export const useSerialAsyncCallback = <Args extends any[], Result = void>( ) => { const queueRef = useRef<Promise<unknown>>(Promise.resolve()) - return useCallback((...args: Args) => { - if (shouldSkip?.()) - return Promise.resolve(undefined as Result) + return useCallback( + (...args: Args) => { + if (shouldSkip?.()) return Promise.resolve(undefined as Result) - const lastPromise = queueRef.current.catch(() => undefined) - const nextPromise = lastPromise.then(() => fn(...args)) - queueRef.current = nextPromise + const lastPromise = queueRef.current.catch(() => undefined) + const nextPromise = lastPromise.then(() => fn(...args)) + queueRef.current = nextPromise - return nextPromise - }, [fn, shouldSkip]) + return nextPromise + }, + [fn, shouldSkip], + ) } diff --git a/web/app/components/workflow/hooks/use-set-workflow-vars-with-value.ts b/web/app/components/workflow/hooks/use-set-workflow-vars-with-value.ts index a04c2de3058017..fe5109e37594c6 100644 --- a/web/app/components/workflow/hooks/use-set-workflow-vars-with-value.ts +++ b/web/app/components/workflow/hooks/use-set-workflow-vars-with-value.ts @@ -1,7 +1,7 @@ import { useHooksStore } from '@/app/components/workflow/hooks-store' export const useSetWorkflowVarsWithValue = () => { - const fetchInspectVars = useHooksStore(s => s.fetchInspectVars) + const fetchInspectVars = useHooksStore((s) => s.fetchInspectVars) return { fetchInspectVars, diff --git a/web/app/components/workflow/hooks/use-tool-icon.ts b/web/app/components/workflow/hooks/use-tool-icon.ts index 6dec65974c9317..39e0e317719b8e 100644 --- a/web/app/components/workflow/hooks/use-tool-icon.ts +++ b/web/app/components/workflow/hooks/use-tool-icon.ts @@ -17,11 +17,13 @@ import { canFindTool } from '@/utils' import { useStore, useWorkflowStore } from '../store' import { BlockEnum } from '../types' -const isTriggerPluginNode = (data: Node['data']): data is PluginTriggerNodeType => data.type === BlockEnum.TriggerPlugin +const isTriggerPluginNode = (data: Node['data']): data is PluginTriggerNodeType => + data.type === BlockEnum.TriggerPlugin const isToolNode = (data: Node['data']): data is ToolNodeType => data.type === BlockEnum.Tool -const isDataSourceNode = (data: Node['data']): data is DataSourceNodeType => data.type === BlockEnum.DataSource +const isDataSourceNode = (data: Node['data']): data is DataSourceNodeType => + data.type === BlockEnum.DataSource type IconValue = ToolWithProvider['icon'] type ToolCollections = { @@ -36,8 +38,7 @@ const resolveIconByTheme = ( icon?: IconValue, iconDark?: IconValue, ) => { - if (currentTheme === 'dark' && iconDark) - return iconDark + if (currentTheme === 'dark' && iconDark) return iconDark return icon } @@ -48,11 +49,11 @@ const findTriggerPluginIcon = ( ) => { const targetTriggers = triggers || [] for (const identifier of identifiers) { - if (!identifier) - continue - const matched = targetTriggers.find(trigger => trigger.id === identifier || canFindTool(trigger.id, identifier)) - if (matched) - return resolveIconByTheme(currentTheme, matched.icon, matched.icon_dark) + if (!identifier) continue + const matched = targetTriggers.find( + (trigger) => trigger.id === identifier || canFindTool(trigger.id, identifier), + ) + if (matched) return resolveIconByTheme(currentTheme, matched.icon, matched.icon_dark) } return undefined } @@ -94,20 +95,16 @@ const findToolInCollections = ( const seen = new Set<ToolWithProvider[]>() for (const collection of collections) { - if (!collection || seen.has(collection)) - continue + if (!collection || seen.has(collection)) continue seen.add(collection) const matched = collection.find((toolWithProvider) => { - if (canFindTool(toolWithProvider.id, data.provider_id)) - return true - if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id) - return true + if (canFindTool(toolWithProvider.id, data.provider_id)) return true + if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id) return true return data.provider_name === toolWithProvider.name }) - if (matched) - return matched + if (matched) return matched } return undefined @@ -122,21 +119,21 @@ const findToolNodeIcon = ({ collections: ToolCollections theme?: string }) => { - const matched = findToolInCollections(getCollectionsToSearch(data.provider_type, collections), data) + const matched = findToolInCollections( + getCollectionsToSearch(data.provider_type, collections), + data, + ) if (matched) { const matchedIcon = resolveIconByTheme(theme, matched.icon, matched.icon_dark) - if (matchedIcon) - return matchedIcon + if (matchedIcon) return matchedIcon } return resolveIconByTheme(theme, data.provider_icon, data.provider_icon_dark) } -const findDataSourceIcon = ( - data: DataSourceNodeType, - dataSourceList?: ToolWithProvider[], -) => { - return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon +const findDataSourceIcon = (data: DataSourceNodeType, dataSourceList?: ToolWithProvider[]) => { + return dataSourceList?.find((toolWithProvider) => toolWithProvider.plugin_id === data.plugin_id) + ?.icon } const findNodeIcon = ({ @@ -152,8 +149,7 @@ const findNodeIcon = ({ triggerPlugins?: TriggerWithProvider[] theme?: string }) => { - if (!data) - return undefined + if (!data) return undefined if (isTriggerPluginNode(data)) { return findTriggerPluginIcon( @@ -163,11 +159,9 @@ const findNodeIcon = ({ ) } - if (isToolNode(data)) - return findToolNodeIcon({ data, collections, theme }) + if (isToolNode(data)) return findToolNodeIcon({ data, collections, theme }) - if (isDataSourceNode(data)) - return findDataSourceIcon(data, dataSourceList) + if (isDataSourceNode(data)) return findDataSourceIcon(data, dataSourceList) return undefined } @@ -177,24 +171,35 @@ export const useToolIcon = (data?: Node['data']) => { const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() - const dataSourceList = useStore(s => s.dataSourceList) + const dataSourceList = useStore((s) => s.dataSourceList) const { data: triggerPlugins } = useAllTriggerPlugins() const { theme } = useTheme() const toolIcon = useMemo(() => { - return findNodeIcon({ - data, - collections: { - buildInTools, - customTools, - workflowTools, - mcpTools, - }, - dataSourceList, - triggerPlugins, - theme, - }) || '' - }, [data, dataSourceList, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins, theme]) + return ( + findNodeIcon({ + data, + collections: { + buildInTools, + customTools, + workflowTools, + mcpTools, + }, + dataSourceList, + triggerPlugins, + theme, + }) || '' + ) + }, [ + data, + dataSourceList, + buildInTools, + customTools, + workflowTools, + mcpTools, + triggerPlugins, + theme, + ]) return toolIcon } @@ -208,28 +213,31 @@ export const useGetToolIcon = () => { const workflowStore = useWorkflowStore() const { theme } = useTheme() - const getToolIcon = useCallback((data: Node['data']) => { - const { - buildInTools: storeBuiltInTools, - customTools: storeCustomTools, - workflowTools: storeWorkflowTools, - mcpTools: storeMcpTools, - dataSourceList, - } = workflowStore.getState() - - return findNodeIcon({ - data, - collections: { - buildInTools: storeBuiltInTools ?? buildInTools, - customTools: storeCustomTools ?? customTools, - workflowTools: storeWorkflowTools ?? workflowTools, - mcpTools: storeMcpTools ?? mcpTools, - }, - dataSourceList, - triggerPlugins, - theme, - }) - }, [workflowStore, triggerPlugins, buildInTools, customTools, workflowTools, mcpTools, theme]) + const getToolIcon = useCallback( + (data: Node['data']) => { + const { + buildInTools: storeBuiltInTools, + customTools: storeCustomTools, + workflowTools: storeWorkflowTools, + mcpTools: storeMcpTools, + dataSourceList, + } = workflowStore.getState() + + return findNodeIcon({ + data, + collections: { + buildInTools: storeBuiltInTools ?? buildInTools, + customTools: storeCustomTools ?? customTools, + workflowTools: storeWorkflowTools ?? workflowTools, + mcpTools: storeMcpTools ?? mcpTools, + }, + dataSourceList, + triggerPlugins, + theme, + }) + }, + [workflowStore, triggerPlugins, buildInTools, customTools, workflowTools, mcpTools, theme], + ) return getToolIcon } diff --git a/web/app/components/workflow/hooks/use-workflow-comment.ts b/web/app/components/workflow/hooks/use-workflow-comment.ts index 9ec042cebe35e5..863e1df7028d14 100644 --- a/web/app/components/workflow/hooks/use-workflow-comment.ts +++ b/web/app/components/workflow/hooks/use-workflow-comment.ts @@ -1,4 +1,8 @@ -import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/app/components/workflow/comment/types' +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' @@ -14,15 +18,12 @@ import { ControlMode } from '../types' const EMPTY_USERS: UserProfile[] = [] const normalizeTimestamp = (value: number | string | null | undefined): number => { - if (value == null) - return Math.floor(Date.now() / 1000) + if (value == null) return Math.floor(Date.now() / 1000) - if (typeof value === 'number') - return value + if (typeof value === 'number') return value const parsed = Number(value) - if (!Number.isNaN(parsed)) - return parsed + if (!Number.isNaN(parsed)) return parsed return Math.floor(Date.parse(value) / 1000) } @@ -37,41 +38,41 @@ export const useWorkflowComment = () => { const params = useParams() const appId = params.appId as string const reactflow = useReactFlow() - const controlMode = useStore(s => s.controlMode) - const pendingComment = useStore(s => s.pendingComment) - const setPendingComment = useStore(s => s.setPendingComment) - const isCommentQuickAdd = useStore(s => s.isCommentQuickAdd) - const setCommentQuickAdd = useStore(s => s.setCommentQuickAdd) - const isCommentPlacing = useStore(s => s.isCommentPlacing) - const setActiveCommentId = useStore(s => s.setActiveCommentId) - const activeCommentId = useStore(s => s.activeCommentId) - const comments = useStore(s => s.comments) - const setComments = useStore(s => s.setComments) - const loading = useStore(s => s.commentsLoading) - const setCommentsLoading = useStore(s => s.setCommentsLoading) - const activeComment = useStore(s => s.activeCommentDetail) - const setActiveComment = useStore(s => s.setActiveCommentDetail) - const activeCommentLoading = useStore(s => s.activeCommentDetailLoading) - const setActiveCommentLoading = useStore(s => s.setActiveCommentDetailLoading) - const replySubmitting = useStore(s => s.replySubmitting) - const setReplySubmitting = useStore(s => s.setReplySubmitting) - const replyUpdating = useStore(s => s.replyUpdating) - const setReplyUpdating = useStore(s => s.setReplyUpdating) - const commentDetailCache = useStore(s => s.commentDetailCache) - const setCommentDetailCache = useStore(s => s.setCommentDetailCache) - const rightPanelWidth = useStore(s => s.rightPanelWidth) - const nodePanelWidth = useStore(s => s.nodePanelWidth) - const mentionableUsers = useStore(state => ( - appId ? state.mentionableUsersCache[appId] ?? EMPTY_USERS : EMPTY_USERS - )) + const controlMode = useStore((s) => s.controlMode) + const pendingComment = useStore((s) => s.pendingComment) + const setPendingComment = useStore((s) => s.setPendingComment) + const isCommentQuickAdd = useStore((s) => s.isCommentQuickAdd) + const setCommentQuickAdd = useStore((s) => s.setCommentQuickAdd) + const isCommentPlacing = useStore((s) => s.isCommentPlacing) + const setActiveCommentId = useStore((s) => s.setActiveCommentId) + const activeCommentId = useStore((s) => s.activeCommentId) + const comments = useStore((s) => s.comments) + const setComments = useStore((s) => s.setComments) + const loading = useStore((s) => s.commentsLoading) + const setCommentsLoading = useStore((s) => s.setCommentsLoading) + const activeComment = useStore((s) => s.activeCommentDetail) + const setActiveComment = useStore((s) => s.setActiveCommentDetail) + const activeCommentLoading = useStore((s) => s.activeCommentDetailLoading) + const setActiveCommentLoading = useStore((s) => s.setActiveCommentDetailLoading) + const replySubmitting = useStore((s) => s.replySubmitting) + const setReplySubmitting = useStore((s) => s.setReplySubmitting) + const replyUpdating = useStore((s) => s.replyUpdating) + const setReplyUpdating = useStore((s) => s.setReplyUpdating) + const commentDetailCache = useStore((s) => s.commentDetailCache) + const setCommentDetailCache = useStore((s) => s.setCommentDetailCache) + const rightPanelWidth = useStore((s) => s.rightPanelWidth) + const nodePanelWidth = useStore((s) => s.nodePanelWidth) + const mentionableUsers = useStore((state) => + appId ? (state.mentionableUsersCache[appId] ?? EMPTY_USERS) : EMPTY_USERS, + ) const mentionableUserById = useMemo( - () => new Map(mentionableUsers.map(user => [user.id, user])), + () => new Map(mentionableUsers.map((user) => [user.id, user])), [mentionableUsers], ) const userProfile = useAtomValue(userProfileAtom) const { data: isCollaborationEnabled } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_collaboration_mode, + select: (s) => s.enable_collaboration_mode, }) const commentDetailCacheRef = useRef<Record<string, WorkflowCommentDetail>>(commentDetailCache) const activeCommentIdRef = useRef<string | null>(null) @@ -84,25 +85,26 @@ export const useWorkflowComment = () => { commentDetailCacheRef.current = commentDetailCache }, [commentDetailCache]) - const refreshActiveComment = useCallback(async (commentId: string) => { - if (!appId) - return + const refreshActiveComment = useCallback( + async (commentId: string) => { + if (!appId) return - const detail = await consoleClient.apps.byAppId.workflow.comments.byCommentId.get({ - params: { app_id: appId, comment_id: commentId }, - }) + const detail = await consoleClient.apps.byAppId.workflow.comments.byCommentId.get({ + params: { app_id: appId, comment_id: commentId }, + }) - commentDetailCacheRef.current = { - ...commentDetailCacheRef.current, - [commentId]: detail, - } - setCommentDetailCache(commentDetailCacheRef.current) - setActiveComment(detail) - }, [appId, setActiveComment, setCommentDetailCache]) + commentDetailCacheRef.current = { + ...commentDetailCacheRef.current, + [commentId]: detail, + } + setCommentDetailCache(commentDetailCacheRef.current) + setActiveComment(detail) + }, + [appId, setActiveComment, setCommentDetailCache], + ) const loadComments = useCallback(async () => { - if (!appId || !isCollaborationEnabled) - return + if (!appId || !isCollaborationEnabled) return setCommentsLoading(true) try { @@ -110,24 +112,20 @@ export const useWorkflowComment = () => { params: { app_id: appId }, }) setComments(response.data) - } - catch (error) { + } catch (error) { console.error('Failed to fetch comments:', error) - } - finally { + } finally { setCommentsLoading(false) } }, [appId, isCollaborationEnabled, setComments, setCommentsLoading]) // Setup collaboration useEffect(() => { - if (!appId || !isCollaborationEnabled) - return + if (!appId || !isCollaborationEnabled) return const unsubscribe = collaborationManager.onCommentsUpdate(() => { loadComments() - if (activeCommentIdRef.current) - refreshActiveComment(activeCommentIdRef.current) + if (activeCommentIdRef.current) refreshActiveComment(activeCommentIdRef.current) }) return unsubscribe @@ -137,109 +135,120 @@ export const useWorkflowComment = () => { loadComments() }, [loadComments]) - const handleCommentSubmit = useCallback(async (content: string, mentionedUserIds: string[] = []) => { - if (!pendingComment) - return + const handleCommentSubmit = useCallback( + async (content: string, mentionedUserIds: string[] = []) => { + if (!pendingComment) return - if (!appId) { - console.error('AppId is missing') - return - } + if (!appId) { + console.error('AppId is missing') + return + } - try { - // Convert screen position to flow position when submitting - const { screenToFlowPosition } = reactflow - const flowPosition = screenToFlowPosition({ - x: pendingComment.pageX, - y: pendingComment.pageY, - }) + try { + // Convert screen position to flow position when submitting + const { screenToFlowPosition } = reactflow + const flowPosition = screenToFlowPosition({ + x: pendingComment.pageX, + y: pendingComment.pageY, + }) + + const newComment = await consoleClient.apps.byAppId.workflow.comments.post({ + params: { app_id: appId }, + body: { + position_x: flowPosition.x, + position_y: flowPosition.y, + content, + mentioned_user_ids: mentionedUserIds, + }, + }) + + const createdAtSeconds = normalizeTimestamp(newComment.created_at) + const createdByAccount = { + id: userProfile?.id ?? '', + name: userProfile?.name ?? '', + email: userProfile?.email ?? '', + avatar_url: userProfile?.avatar_url || userProfile?.avatar || null, + } + const mentionedUsers = mentionedUserIds + .map((mentionedId) => mentionableUserById.get(mentionedId)) + .filter((user): user is NonNullable<typeof user> => Boolean(user)) + const uniqueParticipantsMap = new Map<string, typeof createdByAccount>() + if (createdByAccount.id) uniqueParticipantsMap.set(createdByAccount.id, createdByAccount) + for (const user of mentionedUsers) { + if (!uniqueParticipantsMap.has(user.id)) { + uniqueParticipantsMap.set(user.id, { + id: user.id, + name: user.name, + email: user.email, + avatar_url: user.avatar_url ?? null, + }) + } + } + const participants = Array.from(uniqueParticipantsMap.values()) - const newComment = await consoleClient.apps.byAppId.workflow.comments.post({ - params: { app_id: appId }, - body: { + const composedComment: WorkflowCommentList = { + id: newComment.id, position_x: flowPosition.x, position_y: flowPosition.y, content, - mentioned_user_ids: mentionedUserIds, - }, - }) - - const createdAtSeconds = normalizeTimestamp(newComment.created_at) - const createdByAccount = { - id: userProfile?.id ?? '', - name: userProfile?.name ?? '', - email: userProfile?.email ?? '', - avatar_url: userProfile?.avatar_url || userProfile?.avatar || null, - } - const mentionedUsers = mentionedUserIds - .map(mentionedId => mentionableUserById.get(mentionedId)) - .filter((user): user is NonNullable<typeof user> => Boolean(user)) - const uniqueParticipantsMap = new Map<string, typeof createdByAccount>() - if (createdByAccount.id) - uniqueParticipantsMap.set(createdByAccount.id, createdByAccount) - for (const user of mentionedUsers) { - if (!uniqueParticipantsMap.has(user.id)) { - uniqueParticipantsMap.set(user.id, { - id: user.id, - name: user.name, - email: user.email, - avatar_url: user.avatar_url ?? null, - }) + created_by: createdByAccount.id, + created_by_account: createdByAccount, + created_at: createdAtSeconds, + updated_at: createdAtSeconds, + resolved: false, + mention_count: mentionedUserIds.length, + reply_count: 0, + participants, } - } - const participants = Array.from(uniqueParticipantsMap.values()) - - const composedComment: WorkflowCommentList = { - id: newComment.id, - position_x: flowPosition.x, - position_y: flowPosition.y, - content, - created_by: createdByAccount.id, - created_by_account: createdByAccount, - created_at: createdAtSeconds, - updated_at: createdAtSeconds, - resolved: false, - mention_count: mentionedUserIds.length, - reply_count: 0, - participants, - } - const composedDetail: WorkflowCommentDetail = { - id: newComment.id, - position_x: flowPosition.x, - position_y: flowPosition.y, - content, - created_by: createdByAccount.id, - created_by_account: createdByAccount, - created_at: createdAtSeconds, - updated_at: createdAtSeconds, - resolved: false, - replies: [], - mentions: mentionedUserIds.map(mentionedId => ({ - mentioned_user_id: mentionedId, - mentioned_user_account: mentionableUserById.get(mentionedId) ?? null, - reply_id: null, - })), - } + const composedDetail: WorkflowCommentDetail = { + id: newComment.id, + position_x: flowPosition.x, + position_y: flowPosition.y, + content, + created_by: createdByAccount.id, + created_by_account: createdByAccount, + created_at: createdAtSeconds, + updated_at: createdAtSeconds, + resolved: false, + replies: [], + mentions: mentionedUserIds.map((mentionedId) => ({ + mentioned_user_id: mentionedId, + mentioned_user_account: mentionableUserById.get(mentionedId) ?? null, + reply_id: null, + })), + } - setComments([...comments, composedComment]) - commentDetailCacheRef.current = { - ...commentDetailCacheRef.current, - [newComment.id]: composedDetail, - } - setCommentDetailCache(commentDetailCacheRef.current) + setComments([...comments, composedComment]) + commentDetailCacheRef.current = { + ...commentDetailCacheRef.current, + [newComment.id]: composedDetail, + } + setCommentDetailCache(commentDetailCacheRef.current) - collaborationManager.emitCommentsUpdate(appId) + collaborationManager.emitCommentsUpdate(appId) - setPendingComment(null) - setCommentQuickAdd(false) - } - catch (error) { - console.error('Failed to create comment:', error) - setPendingComment(null) - setCommentQuickAdd(false) - } - }, [appId, pendingComment, setPendingComment, setCommentQuickAdd, reactflow, comments, setComments, userProfile, setCommentDetailCache, mentionableUserById]) + setPendingComment(null) + setCommentQuickAdd(false) + } catch (error) { + console.error('Failed to create comment:', error) + setPendingComment(null) + setCommentQuickAdd(false) + } + }, + [ + appId, + pendingComment, + setPendingComment, + setCommentQuickAdd, + reactflow, + comments, + setComments, + userProfile, + setCommentDetailCache, + mentionableUserById, + ], + ) const handleCommentCancel = useCallback(() => { setPendingComment(null) @@ -247,339 +256,340 @@ export const useWorkflowComment = () => { }, [setPendingComment, setCommentQuickAdd]) useEffect(() => { - if (controlMode !== ControlMode.Comment && !isCommentQuickAdd) - setPendingComment(null) + if (controlMode !== ControlMode.Comment && !isCommentQuickAdd) setPendingComment(null) }, [controlMode, isCommentQuickAdd, setPendingComment]) useEffect(() => { - if (!pendingComment && !isCommentPlacing && isCommentQuickAdd) - setCommentQuickAdd(false) + if (!pendingComment && !isCommentPlacing && isCommentQuickAdd) setCommentQuickAdd(false) }, [isCommentPlacing, isCommentQuickAdd, pendingComment, setCommentQuickAdd]) - const handleCommentIconClick = useCallback(async (comment: WorkflowCommentList) => { - setPendingComment(null) - - activeCommentIdRef.current = comment.id - setActiveCommentId(comment.id) - - const cachedDetail = commentDetailCacheRef.current[comment.id] - setActiveComment(cachedDetail ?? toCommentDetailPreview(comment)) - - const hasSelectedNode = reactflow.getNodes().some(node => node.data?.selected) - const commentPanelWidth = controlMode === ControlMode.Comment ? 420 : 0 - const fallbackPanelWidth = (hasSelectedNode ? nodePanelWidth : 0) + commentPanelWidth - const effectivePanelWidth = Math.max(rightPanelWidth ?? 0, fallbackPanelWidth) + const handleCommentIconClick = useCallback( + async (comment: WorkflowCommentList) => { + setPendingComment(null) - const baseHorizontalOffsetPx = 220 - const panelCompensationPx = effectivePanelWidth / 2 - const desiredHorizontalOffsetPx = baseHorizontalOffsetPx + panelCompensationPx - const maxOffset = Math.max(0, (window.innerWidth / 2) - 60) - const horizontalOffsetPx = Math.min(desiredHorizontalOffsetPx, maxOffset) + activeCommentIdRef.current = comment.id + setActiveCommentId(comment.id) - reactflow.setCenter( - comment.position_x + horizontalOffsetPx, - comment.position_y, - { zoom: 1, duration: 600 }, - ) + const cachedDetail = commentDetailCacheRef.current[comment.id] + setActiveComment(cachedDetail ?? toCommentDetailPreview(comment)) - if (!appId) - return + const hasSelectedNode = reactflow.getNodes().some((node) => node.data?.selected) + const commentPanelWidth = controlMode === ControlMode.Comment ? 420 : 0 + const fallbackPanelWidth = (hasSelectedNode ? nodePanelWidth : 0) + commentPanelWidth + const effectivePanelWidth = Math.max(rightPanelWidth ?? 0, fallbackPanelWidth) - setActiveCommentLoading(!cachedDetail) + const baseHorizontalOffsetPx = 220 + const panelCompensationPx = effectivePanelWidth / 2 + const desiredHorizontalOffsetPx = baseHorizontalOffsetPx + panelCompensationPx + const maxOffset = Math.max(0, window.innerWidth / 2 - 60) + const horizontalOffsetPx = Math.min(desiredHorizontalOffsetPx, maxOffset) - try { - const detail = await consoleClient.apps.byAppId.workflow.comments.byCommentId.get({ - params: { app_id: appId, comment_id: comment.id }, + reactflow.setCenter(comment.position_x + horizontalOffsetPx, comment.position_y, { + zoom: 1, + duration: 600, }) - commentDetailCacheRef.current = { - ...commentDetailCacheRef.current, - [comment.id]: detail, - } - setCommentDetailCache(commentDetailCacheRef.current) + if (!appId) return - if (activeCommentIdRef.current === comment.id) - setActiveComment(detail) - } - catch (e) { - console.warn('Failed to load workflow comment detail', e) - } - finally { - setActiveCommentLoading(false) - } - }, [ - appId, - controlMode, - nodePanelWidth, - reactflow, - rightPanelWidth, - setActiveComment, - setActiveCommentId, - setActiveCommentLoading, - setCommentDetailCache, - setPendingComment, - ]) - - const handleCommentResolve = useCallback(async (commentId: string) => { - if (!appId) - return - - setActiveCommentLoading(true) - try { - await consoleClient.apps.byAppId.workflow.comments.byCommentId.resolve.post({ - params: { app_id: appId, comment_id: commentId }, - }) + setActiveCommentLoading(!cachedDetail) - collaborationManager.emitCommentsUpdate(appId) + try { + const detail = await consoleClient.apps.byAppId.workflow.comments.byCommentId.get({ + params: { app_id: appId, comment_id: comment.id }, + }) - await refreshActiveComment(commentId) - await loadComments() - } - catch (error) { - console.error('Failed to resolve comment:', error) - } - finally { - setActiveCommentLoading(false) - } - }, [appId, loadComments, refreshActiveComment, setActiveCommentLoading]) - - const handleCommentDelete = useCallback(async (commentId: string) => { - if (!appId) - return - - setActiveCommentLoading(true) - try { - await consoleClient.apps.byAppId.workflow.comments.byCommentId.delete({ - params: { app_id: appId, comment_id: commentId }, - }) + commentDetailCacheRef.current = { + ...commentDetailCacheRef.current, + [comment.id]: detail, + } + setCommentDetailCache(commentDetailCacheRef.current) - collaborationManager.emitCommentsUpdate(appId) + if (activeCommentIdRef.current === comment.id) setActiveComment(detail) + } catch (e) { + console.warn('Failed to load workflow comment detail', e) + } finally { + setActiveCommentLoading(false) + } + }, + [ + appId, + controlMode, + nodePanelWidth, + reactflow, + rightPanelWidth, + setActiveComment, + setActiveCommentId, + setActiveCommentLoading, + setCommentDetailCache, + setPendingComment, + ], + ) - const updatedCache = { ...commentDetailCacheRef.current } - delete updatedCache[commentId] - commentDetailCacheRef.current = updatedCache - setCommentDetailCache(updatedCache) + const handleCommentResolve = useCallback( + async (commentId: string) => { + if (!appId) return - const currentComments = comments.filter(c => c.id !== commentId) - const commentIndex = comments.findIndex(c => c.id === commentId) - const fallbackTarget = commentIndex >= 0 ? comments[commentIndex + 1] ?? comments[commentIndex - 1] : undefined + setActiveCommentLoading(true) + try { + await consoleClient.apps.byAppId.workflow.comments.byCommentId.resolve.post({ + params: { app_id: appId, comment_id: commentId }, + }) - await loadComments() + collaborationManager.emitCommentsUpdate(appId) - if (fallbackTarget) { - handleCommentIconClick(fallbackTarget) + await refreshActiveComment(commentId) + await loadComments() + } catch (error) { + console.error('Failed to resolve comment:', error) + } finally { + setActiveCommentLoading(false) } - else if (currentComments.length > 0) { - const nextComment = currentComments[0] - handleCommentIconClick(nextComment!) - } - else { - setActiveComment(null) - setActiveCommentId(null) - activeCommentIdRef.current = null - } - } - catch (error) { - console.error('Failed to delete comment:', error) - } - finally { - setActiveCommentLoading(false) - } - }, [appId, comments, handleCommentIconClick, loadComments, setActiveComment, setActiveCommentId, setActiveCommentLoading, setCommentDetailCache]) + }, + [appId, loadComments, refreshActiveComment, setActiveCommentLoading], + ) - const handleCommentPositionUpdate = useCallback(async (commentId: string, position: { x: number, y: number }) => { - if (!appId) - return + const handleCommentDelete = useCallback( + async (commentId: string) => { + if (!appId) return + + setActiveCommentLoading(true) + try { + await consoleClient.apps.byAppId.workflow.comments.byCommentId.delete({ + params: { app_id: appId, comment_id: commentId }, + }) + + collaborationManager.emitCommentsUpdate(appId) + + const updatedCache = { ...commentDetailCacheRef.current } + delete updatedCache[commentId] + commentDetailCacheRef.current = updatedCache + setCommentDetailCache(updatedCache) + + const currentComments = comments.filter((c) => c.id !== commentId) + const commentIndex = comments.findIndex((c) => c.id === commentId) + const fallbackTarget = + commentIndex >= 0 ? (comments[commentIndex + 1] ?? comments[commentIndex - 1]) : undefined + + await loadComments() + + if (fallbackTarget) { + handleCommentIconClick(fallbackTarget) + } else if (currentComments.length > 0) { + const nextComment = currentComments[0] + handleCommentIconClick(nextComment!) + } else { + setActiveComment(null) + setActiveCommentId(null) + activeCommentIdRef.current = null + } + } catch (error) { + console.error('Failed to delete comment:', error) + } finally { + setActiveCommentLoading(false) + } + }, + [ + appId, + comments, + handleCommentIconClick, + loadComments, + setActiveComment, + setActiveCommentId, + setActiveCommentLoading, + setCommentDetailCache, + ], + ) - const targetComment = comments.find(c => c.id === commentId) - if (!targetComment) - return + const handleCommentPositionUpdate = useCallback( + async (commentId: string, position: { x: number; y: number }) => { + if (!appId) return - const nextPosition = { - position_x: position.x, - position_y: position.y, - } + const targetComment = comments.find((c) => c.id === commentId) + if (!targetComment) return - const previousComments = comments - const updatedComments = comments.map(c => - c.id === commentId - ? { ...c, ...nextPosition } - : c, - ) - setComments(updatedComments) - - const cachedDetail = commentDetailCacheRef.current[commentId] - const updatedDetail = cachedDetail ? { ...cachedDetail, ...nextPosition } : null - if (updatedDetail) { - commentDetailCacheRef.current = { - ...commentDetailCacheRef.current, - [commentId]: updatedDetail, + const nextPosition = { + position_x: position.x, + position_y: position.y, } - setCommentDetailCache(commentDetailCacheRef.current) - if (activeCommentIdRef.current === commentId) - setActiveComment(updatedDetail) - } - else if (activeComment?.id === commentId) { - setActiveComment({ ...activeComment, ...nextPosition }) - } + const previousComments = comments + const updatedComments = comments.map((c) => + c.id === commentId ? { ...c, ...nextPosition } : c, + ) + setComments(updatedComments) - try { - await consoleClient.apps.byAppId.workflow.comments.byCommentId.put({ - params: { app_id: appId, comment_id: commentId }, - body: { - content: targetComment.content, - position_x: nextPosition.position_x, - position_y: nextPosition.position_y, - }, - }) - collaborationManager.emitCommentsUpdate(appId) - } - catch (error) { - console.error('Failed to update comment position:', error) - setComments(previousComments) - - if (cachedDetail) { + const cachedDetail = commentDetailCacheRef.current[commentId] + const updatedDetail = cachedDetail ? { ...cachedDetail, ...nextPosition } : null + if (updatedDetail) { commentDetailCacheRef.current = { ...commentDetailCacheRef.current, - [commentId]: cachedDetail, + [commentId]: updatedDetail, } setCommentDetailCache(commentDetailCacheRef.current) - if (activeCommentIdRef.current === commentId) - setActiveComment(cachedDetail) + if (activeCommentIdRef.current === commentId) setActiveComment(updatedDetail) + } else if (activeComment?.id === commentId) { + setActiveComment({ ...activeComment, ...nextPosition }) } - else if (activeComment?.id === commentId) { - setActiveComment(activeComment) - } - } - }, [activeComment, appId, comments, setComments, setCommentDetailCache, setActiveComment]) - - const handleCommentUpdate = useCallback(async (commentId: string, content: string, mentionedUserIds: string[] = []) => { - if (!appId) - return - const trimmed = content.trim() - if (!trimmed) - return - - const targetComment = comments.find(c => c.id === commentId) - const targetDetail = commentDetailCacheRef.current[commentId] - ?? (activeCommentIdRef.current === commentId ? activeComment : null) - const positionX = targetDetail?.position_x ?? targetComment?.position_x - const positionY = targetDetail?.position_y ?? targetComment?.position_y - - if (positionX === undefined || positionY === undefined) - return - - try { - await consoleClient.apps.byAppId.workflow.comments.byCommentId.put({ - params: { app_id: appId, comment_id: commentId }, - body: { - content: trimmed, - position_x: positionX, - position_y: positionY, - mentioned_user_ids: mentionedUserIds, - }, - }) - - collaborationManager.emitCommentsUpdate(appId) - await refreshActiveComment(commentId) - await loadComments() - } - catch (error) { - console.error('Failed to update comment:', error) - } - }, [activeComment, appId, comments, loadComments, refreshActiveComment]) - - const handleCommentReply = useCallback(async (commentId: string, content: string, mentionedUserIds: string[] = []) => { - if (!appId) - return - const trimmed = content.trim() - if (!trimmed) - return - - setReplySubmitting(true) - try { - await consoleClient.apps.byAppId.workflow.comments.byCommentId.replies.post({ - params: { app_id: appId, comment_id: commentId }, - body: { content: trimmed, mentioned_user_ids: mentionedUserIds }, - }) - - collaborationManager.emitCommentsUpdate(appId) - - await refreshActiveComment(commentId) - await loadComments() - } - catch (error) { - console.error('Failed to create reply:', error) - } - finally { - setReplySubmitting(false) - } - }, [appId, loadComments, refreshActiveComment, setReplySubmitting]) + try { + await consoleClient.apps.byAppId.workflow.comments.byCommentId.put({ + params: { app_id: appId, comment_id: commentId }, + body: { + content: targetComment.content, + position_x: nextPosition.position_x, + position_y: nextPosition.position_y, + }, + }) + collaborationManager.emitCommentsUpdate(appId) + } catch (error) { + console.error('Failed to update comment position:', error) + setComments(previousComments) + + if (cachedDetail) { + commentDetailCacheRef.current = { + ...commentDetailCacheRef.current, + [commentId]: cachedDetail, + } + setCommentDetailCache(commentDetailCacheRef.current) + + if (activeCommentIdRef.current === commentId) setActiveComment(cachedDetail) + } else if (activeComment?.id === commentId) { + setActiveComment(activeComment) + } + } + }, + [activeComment, appId, comments, setComments, setCommentDetailCache, setActiveComment], + ) - const handleCommentReplyUpdate = useCallback(async (commentId: string, replyId: string, content: string, mentionedUserIds: string[] = []) => { - if (!appId) - return - const trimmed = content.trim() - if (!trimmed) - return + const handleCommentUpdate = useCallback( + async (commentId: string, content: string, mentionedUserIds: string[] = []) => { + if (!appId) return + const trimmed = content.trim() + if (!trimmed) return + + const targetComment = comments.find((c) => c.id === commentId) + const targetDetail = + commentDetailCacheRef.current[commentId] ?? + (activeCommentIdRef.current === commentId ? activeComment : null) + const positionX = targetDetail?.position_x ?? targetComment?.position_x + const positionY = targetDetail?.position_y ?? targetComment?.position_y + + if (positionX === undefined || positionY === undefined) return + + try { + await consoleClient.apps.byAppId.workflow.comments.byCommentId.put({ + params: { app_id: appId, comment_id: commentId }, + body: { + content: trimmed, + position_x: positionX, + position_y: positionY, + mentioned_user_ids: mentionedUserIds, + }, + }) + + collaborationManager.emitCommentsUpdate(appId) + + await refreshActiveComment(commentId) + await loadComments() + } catch (error) { + console.error('Failed to update comment:', error) + } + }, + [activeComment, appId, comments, loadComments, refreshActiveComment], + ) - setReplyUpdating(true) - try { - await consoleClient.apps.byAppId.workflow.comments.byCommentId.replies.byReplyId.put({ - params: { app_id: appId, comment_id: commentId, reply_id: replyId }, - body: { content: trimmed, mentioned_user_ids: mentionedUserIds }, - }) + const handleCommentReply = useCallback( + async (commentId: string, content: string, mentionedUserIds: string[] = []) => { + if (!appId) return + const trimmed = content.trim() + if (!trimmed) return + + setReplySubmitting(true) + try { + await consoleClient.apps.byAppId.workflow.comments.byCommentId.replies.post({ + params: { app_id: appId, comment_id: commentId }, + body: { content: trimmed, mentioned_user_ids: mentionedUserIds }, + }) + + collaborationManager.emitCommentsUpdate(appId) + + await refreshActiveComment(commentId) + await loadComments() + } catch (error) { + console.error('Failed to create reply:', error) + } finally { + setReplySubmitting(false) + } + }, + [appId, loadComments, refreshActiveComment, setReplySubmitting], + ) - collaborationManager.emitCommentsUpdate(appId) + const handleCommentReplyUpdate = useCallback( + async ( + commentId: string, + replyId: string, + content: string, + mentionedUserIds: string[] = [], + ) => { + if (!appId) return + const trimmed = content.trim() + if (!trimmed) return + + setReplyUpdating(true) + try { + await consoleClient.apps.byAppId.workflow.comments.byCommentId.replies.byReplyId.put({ + params: { app_id: appId, comment_id: commentId, reply_id: replyId }, + body: { content: trimmed, mentioned_user_ids: mentionedUserIds }, + }) + + collaborationManager.emitCommentsUpdate(appId) + + await refreshActiveComment(commentId) + await loadComments() + } catch (error) { + console.error('Failed to update reply:', error) + } finally { + setReplyUpdating(false) + } + }, + [appId, loadComments, refreshActiveComment, setReplyUpdating], + ) - await refreshActiveComment(commentId) - await loadComments() - } - catch (error) { - console.error('Failed to update reply:', error) - } - finally { - setReplyUpdating(false) - } - }, [appId, loadComments, refreshActiveComment, setReplyUpdating]) + const handleCommentReplyDelete = useCallback( + async (commentId: string, replyId: string) => { + if (!appId) return - const handleCommentReplyDelete = useCallback(async (commentId: string, replyId: string) => { - if (!appId) - return + setActiveCommentLoading(true) + try { + await consoleClient.apps.byAppId.workflow.comments.byCommentId.replies.byReplyId.delete({ + params: { app_id: appId, comment_id: commentId, reply_id: replyId }, + }) - setActiveCommentLoading(true) - try { - await consoleClient.apps.byAppId.workflow.comments.byCommentId.replies.byReplyId.delete({ - params: { app_id: appId, comment_id: commentId, reply_id: replyId }, - }) + collaborationManager.emitCommentsUpdate(appId) - collaborationManager.emitCommentsUpdate(appId) + await refreshActiveComment(commentId) + await loadComments() + } catch (error) { + console.error('Failed to delete reply:', error) + } finally { + setActiveCommentLoading(false) + } + }, + [appId, loadComments, refreshActiveComment, setActiveCommentLoading], + ) - await refreshActiveComment(commentId) - await loadComments() - } - catch (error) { - console.error('Failed to delete reply:', error) - } - finally { - setActiveCommentLoading(false) - } - }, [appId, loadComments, refreshActiveComment, setActiveCommentLoading]) - - const handleCommentNavigate = useCallback((direction: 'prev' | 'next') => { - const currentId = activeCommentIdRef.current - if (!currentId) - return - const idx = comments.findIndex(c => c.id === currentId) - if (idx === -1) - return - const target = direction === 'prev' ? comments[idx - 1] : comments[idx + 1] - if (target) - handleCommentIconClick(target) - }, [comments, handleCommentIconClick]) + const handleCommentNavigate = useCallback( + (direction: 'prev' | 'next') => { + const currentId = activeCommentIdRef.current + if (!currentId) return + const idx = comments.findIndex((c) => c.id === currentId) + if (idx === -1) return + const target = direction === 'prev' ? comments[idx - 1] : comments[idx + 1] + if (target) handleCommentIconClick(target) + }, + [comments, handleCommentIconClick], + ) const handleActiveCommentClose = useCallback(() => { setActiveComment(null) @@ -588,15 +598,12 @@ export const useWorkflowComment = () => { activeCommentIdRef.current = null }, [setActiveComment, setActiveCommentId, setActiveCommentLoading]) - const handleCreateComment = useCallback((mousePosition: { - pageX: number - pageY: number - elementX: number - elementY: number - }) => { - if (controlMode === ControlMode.Comment) - setPendingComment(mousePosition) - }, [controlMode, setPendingComment]) + const handleCreateComment = useCallback( + (mousePosition: { pageX: number; pageY: number; elementX: number; elementY: number }) => { + if (controlMode === ControlMode.Comment) setPendingComment(mousePosition) + }, + [controlMode, setPendingComment], + ) return { comments, diff --git a/web/app/components/workflow/hooks/use-workflow-history.ts b/web/app/components/workflow/hooks/use-workflow-history.ts index 5e783a2a6cd2e4..94d4b3c1ec7cd9 100644 --- a/web/app/components/workflow/hooks/use-workflow-history.ts +++ b/web/app/components/workflow/hooks/use-workflow-history.ts @@ -1,14 +1,8 @@ import type { WorkflowHistoryEventMeta } from '../store/workflow/history-slice' import { debounce } from 'es-toolkit/compat' -import { - useCallback, - useRef, - useState, -} from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - useStoreApi, -} from 'reactflow' +import { useStoreApi } from 'reactflow' import { useWorkflowHistoryStore } from '../workflow-history-store' /** @@ -47,102 +41,110 @@ export const useWorkflowHistory = () => { const [redoCallbacks, setRedoCallbacks] = useState<(() => void)[]>([]) const onUndo = useCallback((callback: () => void) => { - setUndoCallbacks(prev => [...prev, callback]) - return () => setUndoCallbacks(prev => prev.filter(cb => cb !== callback)) + setUndoCallbacks((prev) => [...prev, callback]) + return () => setUndoCallbacks((prev) => prev.filter((cb) => cb !== callback)) }, []) const onRedo = useCallback((callback: () => void) => { - setRedoCallbacks(prev => [...prev, callback]) - return () => setRedoCallbacks(prev => prev.filter(cb => cb !== callback)) + setRedoCallbacks((prev) => [...prev, callback]) + return () => setRedoCallbacks((prev) => prev.filter((cb) => cb !== callback)) }, []) const undo = useCallback(() => { workflowHistoryStore.temporal.getState().undo() - undoCallbacks.forEach(callback => callback()) + undoCallbacks.forEach((callback) => callback()) }, [undoCallbacks, workflowHistoryStore.temporal]) const redo = useCallback(() => { workflowHistoryStore.temporal.getState().redo() - redoCallbacks.forEach(callback => callback()) + redoCallbacks.forEach((callback) => callback()) }, [redoCallbacks, workflowHistoryStore.temporal]) // Some events may be triggered multiple times in a short period of time. // We debounce the history state update to avoid creating multiple history states // with minimal changes. - const saveStateToHistoryRef = useRef(debounce((event: WorkflowHistoryEventT, meta?: WorkflowHistoryEventMeta) => { - workflowHistoryStore.setState({ - workflowHistoryEvent: event, - workflowHistoryEventMeta: meta, - nodes: store.getState().getNodes(), - edges: store.getState().edges, - }) - }, 500)) + const saveStateToHistoryRef = useRef( + debounce((event: WorkflowHistoryEventT, meta?: WorkflowHistoryEventMeta) => { + workflowHistoryStore.setState({ + workflowHistoryEvent: event, + workflowHistoryEventMeta: meta, + nodes: store.getState().getNodes(), + edges: store.getState().edges, + }) + }, 500), + ) - const saveStateToHistory = useCallback((event: WorkflowHistoryEventT, meta?: WorkflowHistoryEventMeta) => { - switch (event) { - case WorkflowHistoryEvent.NoteChange: - // Hint: Note change does not trigger when note text changes, - // because the note editors have their own history states. - saveStateToHistoryRef.current(event, meta) - break - case WorkflowHistoryEvent.NodeTitleChange: - case WorkflowHistoryEvent.NodeDescriptionChange: - case WorkflowHistoryEvent.NodeDragStop: - case WorkflowHistoryEvent.NodeChange: - case WorkflowHistoryEvent.NodeConnect: - case WorkflowHistoryEvent.NodePaste: - case WorkflowHistoryEvent.NodeDelete: - case WorkflowHistoryEvent.EdgeDelete: - case WorkflowHistoryEvent.EdgeDeleteByDeleteBranch: - case WorkflowHistoryEvent.NodeAdd: - case WorkflowHistoryEvent.NodeResize: - case WorkflowHistoryEvent.NoteAdd: - case WorkflowHistoryEvent.LayoutOrganize: - case WorkflowHistoryEvent.NoteDelete: - saveStateToHistoryRef.current(event, meta) - break - default: - // We do not create a history state for every event. - // Some events of reactflow may change things the user would not want to undo/redo. - // For example: UI state changes like selecting a node. - break - } - }, []) + const saveStateToHistory = useCallback( + (event: WorkflowHistoryEventT, meta?: WorkflowHistoryEventMeta) => { + switch (event) { + case WorkflowHistoryEvent.NoteChange: + // Hint: Note change does not trigger when note text changes, + // because the note editors have their own history states. + saveStateToHistoryRef.current(event, meta) + break + case WorkflowHistoryEvent.NodeTitleChange: + case WorkflowHistoryEvent.NodeDescriptionChange: + case WorkflowHistoryEvent.NodeDragStop: + case WorkflowHistoryEvent.NodeChange: + case WorkflowHistoryEvent.NodeConnect: + case WorkflowHistoryEvent.NodePaste: + case WorkflowHistoryEvent.NodeDelete: + case WorkflowHistoryEvent.EdgeDelete: + case WorkflowHistoryEvent.EdgeDeleteByDeleteBranch: + case WorkflowHistoryEvent.NodeAdd: + case WorkflowHistoryEvent.NodeResize: + case WorkflowHistoryEvent.NoteAdd: + case WorkflowHistoryEvent.LayoutOrganize: + case WorkflowHistoryEvent.NoteDelete: + saveStateToHistoryRef.current(event, meta) + break + default: + // We do not create a history state for every event. + // Some events of reactflow may change things the user would not want to undo/redo. + // For example: UI state changes like selecting a node. + break + } + }, + [], + ) - const getHistoryLabel = useCallback((event: WorkflowHistoryEventT) => { - switch (event) { - case WorkflowHistoryEvent.NodeTitleChange: - return t($ => $['changeHistory.nodeTitleChange'], { ns: 'workflow' }) - case WorkflowHistoryEvent.NodeDescriptionChange: - return t($ => $['changeHistory.nodeDescriptionChange'], { ns: 'workflow' }) - case WorkflowHistoryEvent.LayoutOrganize: - case WorkflowHistoryEvent.NodeDragStop: - return t($ => $['changeHistory.nodeDragStop'], { ns: 'workflow' }) - case WorkflowHistoryEvent.NodeChange: - return t($ => $['changeHistory.nodeChange'], { ns: 'workflow' }) - case WorkflowHistoryEvent.NodeConnect: - return t($ => $['changeHistory.nodeConnect'], { ns: 'workflow' }) - case WorkflowHistoryEvent.NodePaste: - return t($ => $['changeHistory.nodePaste'], { ns: 'workflow' }) - case WorkflowHistoryEvent.NodeDelete: - return t($ => $['changeHistory.nodeDelete'], { ns: 'workflow' }) - case WorkflowHistoryEvent.NodeAdd: - return t($ => $['changeHistory.nodeAdd'], { ns: 'workflow' }) - case WorkflowHistoryEvent.EdgeDelete: - case WorkflowHistoryEvent.EdgeDeleteByDeleteBranch: - return t($ => $['changeHistory.edgeDelete'], { ns: 'workflow' }) - case WorkflowHistoryEvent.NodeResize: - return t($ => $['changeHistory.nodeResize'], { ns: 'workflow' }) - case WorkflowHistoryEvent.NoteAdd: - return t($ => $['changeHistory.noteAdd'], { ns: 'workflow' }) - case WorkflowHistoryEvent.NoteChange: - return t($ => $['changeHistory.noteChange'], { ns: 'workflow' }) - case WorkflowHistoryEvent.NoteDelete: - return t($ => $['changeHistory.noteDelete'], { ns: 'workflow' }) - default: - return 'Unknown Event' - } - }, [t]) + const getHistoryLabel = useCallback( + (event: WorkflowHistoryEventT) => { + switch (event) { + case WorkflowHistoryEvent.NodeTitleChange: + return t(($) => $['changeHistory.nodeTitleChange'], { ns: 'workflow' }) + case WorkflowHistoryEvent.NodeDescriptionChange: + return t(($) => $['changeHistory.nodeDescriptionChange'], { ns: 'workflow' }) + case WorkflowHistoryEvent.LayoutOrganize: + case WorkflowHistoryEvent.NodeDragStop: + return t(($) => $['changeHistory.nodeDragStop'], { ns: 'workflow' }) + case WorkflowHistoryEvent.NodeChange: + return t(($) => $['changeHistory.nodeChange'], { ns: 'workflow' }) + case WorkflowHistoryEvent.NodeConnect: + return t(($) => $['changeHistory.nodeConnect'], { ns: 'workflow' }) + case WorkflowHistoryEvent.NodePaste: + return t(($) => $['changeHistory.nodePaste'], { ns: 'workflow' }) + case WorkflowHistoryEvent.NodeDelete: + return t(($) => $['changeHistory.nodeDelete'], { ns: 'workflow' }) + case WorkflowHistoryEvent.NodeAdd: + return t(($) => $['changeHistory.nodeAdd'], { ns: 'workflow' }) + case WorkflowHistoryEvent.EdgeDelete: + case WorkflowHistoryEvent.EdgeDeleteByDeleteBranch: + return t(($) => $['changeHistory.edgeDelete'], { ns: 'workflow' }) + case WorkflowHistoryEvent.NodeResize: + return t(($) => $['changeHistory.nodeResize'], { ns: 'workflow' }) + case WorkflowHistoryEvent.NoteAdd: + return t(($) => $['changeHistory.noteAdd'], { ns: 'workflow' }) + case WorkflowHistoryEvent.NoteChange: + return t(($) => $['changeHistory.noteChange'], { ns: 'workflow' }) + case WorkflowHistoryEvent.NoteDelete: + return t(($) => $['changeHistory.noteDelete'], { ns: 'workflow' }) + default: + return 'Unknown Event' + } + }, + [t], + ) return { store: workflowHistoryStore, diff --git a/web/app/components/workflow/hooks/use-workflow-mode.ts b/web/app/components/workflow/hooks/use-workflow-mode.ts index 5ac0f639cb5b2b..3b405a50429f9a 100644 --- a/web/app/components/workflow/hooks/use-workflow-mode.ts +++ b/web/app/components/workflow/hooks/use-workflow-mode.ts @@ -2,8 +2,8 @@ import { useMemo } from 'react' import { useStore } from '../store' export const useWorkflowMode = () => { - const historyWorkflowData = useStore(s => s.historyWorkflowData) - const isRestoring = useStore(s => s.isRestoring) + const historyWorkflowData = useStore((s) => s.historyWorkflowData) + const isRestoring = useStore((s) => s.isRestoring) return useMemo(() => { return { normal: !historyWorkflowData && !isRestoring, diff --git a/web/app/components/workflow/hooks/use-workflow-organize.helpers.ts b/web/app/components/workflow/hooks/use-workflow-organize.helpers.ts index 850aa403ef19d7..8fde78d4e6c3d3 100644 --- a/web/app/components/workflow/hooks/use-workflow-organize.helpers.ts +++ b/web/app/components/workflow/hooks/use-workflow-organize.helpers.ts @@ -20,9 +20,10 @@ type LayerInfo = { export const getLayoutContainerNodes = (nodes: Node[]) => { return nodes.filter( - node => (node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration) - && !node.parentId - && node.type === CUSTOM_NODE, + (node) => + (node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration) && + !node.parentId && + node.type === CUSTOM_NODE, ) } @@ -32,11 +33,12 @@ export const getContainerSizeChanges = ( ) => { return parentNodes.reduce<Record<string, ContainerSizeChange>>((acc, parentNode) => { const childLayout = childLayoutsMap[parentNode.id] - if (!childLayout || !childLayout.nodes.size) - return acc + if (!childLayout || !childLayout.nodes.size) return acc - const requiredWidth = (childLayout.bounds.maxX - childLayout.bounds.minX) + NODE_LAYOUT_HORIZONTAL_PADDING * 2 - const requiredHeight = (childLayout.bounds.maxY - childLayout.bounds.minY) + NODE_LAYOUT_VERTICAL_PADDING * 2 + const requiredWidth = + childLayout.bounds.maxX - childLayout.bounds.minX + NODE_LAYOUT_HORIZONTAL_PADDING * 2 + const requiredHeight = + childLayout.bounds.maxY - childLayout.bounds.minY + NODE_LAYOUT_VERTICAL_PADDING * 2 acc[parentNode.id] = { width: Math.max(parentNode.width || 0, requiredWidth), @@ -49,22 +51,25 @@ export const getContainerSizeChanges = ( export const applyContainerSizeChanges = ( nodes: Node[], containerSizeChanges: Record<string, ContainerSizeChange>, -) => produce(nodes, (draft) => { - draft.forEach((node) => { - const nextSize = containerSizeChanges[node.id] - if ((node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration) && nextSize) { - node.width = nextSize.width - node.height = nextSize.height - node.data.width = nextSize.width - node.data.height = nextSize.height - } +) => + produce(nodes, (draft) => { + draft.forEach((node) => { + const nextSize = containerSizeChanges[node.id] + if ( + (node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration) && + nextSize + ) { + node.width = nextSize.width + node.height = nextSize.height + node.data.width = nextSize.width + node.data.height = nextSize.height + } + }) }) -}) export const createLayerMap = (layout: LayoutResult) => { return Array.from(layout.nodes.values()).reduce<Map<number, LayerInfo>>((acc, layoutInfo) => { - if (layoutInfo.layer === undefined) - return acc + if (layoutInfo.layer === undefined) return acc const existing = acc.get(layoutInfo.layer) acc.set(layoutInfo.layer, { @@ -79,14 +84,12 @@ const getAlignedYPosition = ( layoutInfo: LayoutResult['nodes'] extends Map<string, infer T> ? T : never, layerMap: Map<number, LayerInfo>, ) => { - if (layoutInfo.layer === undefined) - return layoutInfo.y + if (layoutInfo.layer === undefined) return layoutInfo.y const layerInfo = layerMap.get(layoutInfo.layer) - if (!layerInfo) - return layoutInfo.y + if (!layerInfo) return layoutInfo.y - return (layerInfo.minY + layerInfo.maxHeight / 2) - layoutInfo.height / 2 + return layerInfo.minY + layerInfo.maxHeight / 2 - layoutInfo.height / 2 } export const applyLayoutToNodes = ({ @@ -106,8 +109,7 @@ export const applyLayoutToNodes = ({ draft.forEach((node) => { if (!node.parentId && node.type === CUSTOM_NODE) { const layoutInfo = layout.nodes.get(node.id) - if (!layoutInfo) - return + if (!layoutInfo) return node.position = { x: layoutInfo.x, @@ -118,15 +120,13 @@ export const applyLayoutToNodes = ({ parentNodes.forEach((parentNode) => { const childLayout = childLayoutsMap[parentNode.id] - if (!childLayout) - return + if (!childLayout) return draft - .filter(node => node.parentId === parentNode.id) + .filter((node) => node.parentId === parentNode.id) .forEach((childNode) => { const layoutInfo = childLayout.nodes.get(childNode.id) - if (!layoutInfo) - return + if (!layoutInfo) return childNode.position = { x: NODE_LAYOUT_HORIZONTAL_PADDING + (layoutInfo.x - childLayout.bounds.minX), diff --git a/web/app/components/workflow/hooks/use-workflow-organize.ts b/web/app/components/workflow/hooks/use-workflow-organize.ts index 897963eca8daf3..429284a0b6d213 100644 --- a/web/app/components/workflow/hooks/use-workflow-organize.ts +++ b/web/app/components/workflow/hooks/use-workflow-organize.ts @@ -1,10 +1,7 @@ import { useCallback } from 'react' import { useReactFlow } from 'reactflow' import { useWorkflowStore } from '../store' -import { - getLayoutByELK, - getLayoutForChildNodes, -} from '../utils/elk-layout' +import { getLayoutByELK, getLayoutForChildNodes } from '../utils/elk-layout' import { useCollaborativeWorkflow } from './use-collaborative-workflow' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useNodesReadOnly } from './use-workflow' @@ -25,25 +22,24 @@ export const useWorkflowOrganize = () => { const { handleSyncWorkflowDraft } = useNodesSyncDraft() const handleLayout = useCallback(async () => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return workflowStore.setState({ nodeAnimation: true }) - const { - nodes, - edges, - setNodes, - } = collaborativeWorkflow.getState() + const { nodes, edges, setNodes } = collaborativeWorkflow.getState() const parentNodes = getLayoutContainerNodes(nodes) const childLayoutEntries = await Promise.all( - parentNodes.map(async node => [node.id, await getLayoutForChildNodes(node.id, nodes, edges)] as const), + parentNodes.map( + async (node) => [node.id, await getLayoutForChildNodes(node.id, nodes, edges)] as const, + ), + ) + const childLayoutsMap = childLayoutEntries.reduce( + (acc, [nodeId, layout]) => { + if (layout) acc[nodeId] = layout + return acc + }, + {} as Record<string, NonNullable<Awaited<ReturnType<typeof getLayoutForChildNodes>>>>, ) - const childLayoutsMap = childLayoutEntries.reduce((acc, [nodeId, layout]) => { - if (layout) - acc[nodeId] = layout - return acc - }, {} as Record<string, NonNullable<Awaited<ReturnType<typeof getLayoutForChildNodes>>>>) const nodesWithUpdatedSizes = applyContainerSizeChanges( nodes, @@ -63,7 +59,14 @@ export const useWorkflowOrganize = () => { setTimeout(() => { handleSyncWorkflowDraft() }) - }, [getNodesReadOnly, handleSyncWorkflowDraft, reactflow, saveStateToHistory, collaborativeWorkflow, workflowStore]) + }, [ + getNodesReadOnly, + handleSyncWorkflowDraft, + reactflow, + saveStateToHistory, + collaborativeWorkflow, + workflowStore, + ]) return { handleLayout, diff --git a/web/app/components/workflow/hooks/use-workflow-panel-interactions.ts b/web/app/components/workflow/hooks/use-workflow-panel-interactions.ts index c3ebeba2300661..bd54f1a713e714 100644 --- a/web/app/components/workflow/hooks/use-workflow-panel-interactions.ts +++ b/web/app/components/workflow/hooks/use-workflow-panel-interactions.ts @@ -29,43 +29,44 @@ export const useWorkflowInteractions = () => { } export const useWorkflowMoveMode = () => { - const setControlMode = useStore(s => s.setControlMode) - const workflowRunningData = useStore(s => s.workflowRunningData) - const historyWorkflowData = useStore(s => s.historyWorkflowData) - const isRestoring = useStore(s => s.isRestoring) - const canComment = useHooksStore(s => s.accessControl.canComment) + const setControlMode = useStore((s) => s.setControlMode) + const workflowRunningData = useStore((s) => s.workflowRunningData) + const historyWorkflowData = useStore((s) => s.historyWorkflowData) + const isRestoring = useStore((s) => s.isRestoring) + const canComment = useHooksStore((s) => s.accessControl.canComment) const { getNodesReadOnly } = useNodesReadOnly() const { handleSelectionCancel } = useSelectionInteractions() const { data: isCommentModeAvailable } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_collaboration_mode, + select: (s) => s.enable_collaboration_mode, }) const isCommentModeOperationBlocked = !!( - workflowRunningData?.result.status === WorkflowRunningStatus.Running - || workflowRunningData?.result.status === WorkflowRunningStatus.Paused - || historyWorkflowData - || isRestoring + workflowRunningData?.result.status === WorkflowRunningStatus.Running || + workflowRunningData?.result.status === WorkflowRunningStatus.Paused || + historyWorkflowData || + isRestoring + ) + const canUseCommentMode = !!( + canComment && + !isCommentModeOperationBlocked && + isCommentModeAvailable ) - const canUseCommentMode = !!(canComment && !isCommentModeOperationBlocked && isCommentModeAvailable) const handleModePointer = useCallback(() => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return setControlMode(ControlMode.Pointer) }, [getNodesReadOnly, setControlMode]) const handleModeHand = useCallback(() => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return setControlMode(ControlMode.Hand) handleSelectionCancel() }, [getNodesReadOnly, handleSelectionCancel, setControlMode]) const handleModeComment = useCallback(() => { - if (!canUseCommentMode) - return + if (!canUseCommentMode) return setControlMode(ControlMode.Comment) handleSelectionCancel() diff --git a/web/app/components/workflow/hooks/use-workflow-refresh-draft.ts b/web/app/components/workflow/hooks/use-workflow-refresh-draft.ts index 1948bd471dffb0..5d950664595f62 100644 --- a/web/app/components/workflow/hooks/use-workflow-refresh-draft.ts +++ b/web/app/components/workflow/hooks/use-workflow-refresh-draft.ts @@ -1,7 +1,7 @@ import { useHooksStore } from '@/app/components/workflow/hooks-store' export const useWorkflowRefreshDraft = () => { - const handleRefreshWorkflowDraft = useHooksStore(s => s.handleRefreshWorkflowDraft) + const handleRefreshWorkflowDraft = useHooksStore((s) => s.handleRefreshWorkflowDraft) return { handleRefreshWorkflowDraft, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/test-helpers.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/test-helpers.ts index 8c2ed18f19b02e..9afec4a6b14649 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/test-helpers.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/test-helpers.ts @@ -108,16 +108,19 @@ export function renderRunEventHook<T extends Record<string, unknown>>( ) { const { nodes = createRunNodes(), edges = createRunEdges(), initialStoreState } = options ?? {} - return renderWorkflowFlowHook(() => ({ - ...useHook(), - nodes: useNodes(), - edges: useEdges(), - }), { - nodes, - edges, - reactFlowProps: { fitView: false }, - initialStoreState, - }) + return renderWorkflowFlowHook( + () => ({ + ...useHook(), + nodes: useNodes(), + edges: useEdges(), + }), + { + nodes, + edges, + reactFlowProps: { fitView: false }, + initialStoreState, + }, + ) } export function renderViewportHook<T extends Record<string, unknown>>( @@ -134,53 +137,78 @@ export function renderViewportHook<T extends Record<string, unknown>>( initialStoreState, } = options ?? {} - return renderWorkflowFlowHook(() => ({ - ...useHook(), - nodes: useNodes(), - edges: useEdges(), - reactFlowStore: useStoreApi(), - }), { - nodes, - edges, - reactFlowProps: { fitView: false }, - initialStoreState, - }) + return renderWorkflowFlowHook( + () => ({ + ...useHook(), + nodes: useNodes(), + edges: useEdges(), + reactFlowStore: useStoreApi(), + }), + { + nodes, + edges, + reactFlowProps: { fitView: false }, + initialStoreState, + }, + ) } -export const createStartedResponse = (overrides: Partial<WorkflowStartedResponse> = {}): WorkflowStartedResponse => ({ - task_id: 'task-2', - data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 }, - ...overrides, -} as WorkflowStartedResponse) - -export const createNodeFinishedResponse = (overrides: Partial<NodeFinishedResponse> = {}): NodeFinishedResponse => ({ - data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded }, - ...overrides, -} as NodeFinishedResponse) - -export const createIterationNextResponse = (overrides: Partial<IterationNextResponse> = {}): IterationNextResponse => ({ - data: { node_id: 'n1' }, - ...overrides, -} as IterationNextResponse) - -export const createIterationFinishedResponse = (overrides: Partial<IterationFinishedResponse> = {}): IterationFinishedResponse => ({ - data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded }, - ...overrides, -} as IterationFinishedResponse) - -export const createLoopNextResponse = (overrides: Partial<LoopNextResponse> = {}): LoopNextResponse => ({ - data: { node_id: 'n1', index: 5 }, - ...overrides, -} as LoopNextResponse) - -export const createLoopFinishedResponse = (overrides: Partial<LoopFinishedResponse> = {}): LoopFinishedResponse => ({ - data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded }, - ...overrides, -} as LoopFinishedResponse) - -export const createNodeStartedResponse = (overrides: Partial<NodeStartedResponse> = {}): NodeStartedResponse => ({ - data: { node_id: 'n1' }, - ...overrides, -} as NodeStartedResponse) - -export const pausedRunningData = (): WorkflowRunningData['result'] => ({ status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result']) +export const createStartedResponse = ( + overrides: Partial<WorkflowStartedResponse> = {}, +): WorkflowStartedResponse => + ({ + task_id: 'task-2', + data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 }, + ...overrides, + }) as WorkflowStartedResponse + +export const createNodeFinishedResponse = ( + overrides: Partial<NodeFinishedResponse> = {}, +): NodeFinishedResponse => + ({ + data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded }, + ...overrides, + }) as NodeFinishedResponse + +export const createIterationNextResponse = ( + overrides: Partial<IterationNextResponse> = {}, +): IterationNextResponse => + ({ + data: { node_id: 'n1' }, + ...overrides, + }) as IterationNextResponse + +export const createIterationFinishedResponse = ( + overrides: Partial<IterationFinishedResponse> = {}, +): IterationFinishedResponse => + ({ + data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded }, + ...overrides, + }) as IterationFinishedResponse + +export const createLoopNextResponse = ( + overrides: Partial<LoopNextResponse> = {}, +): LoopNextResponse => + ({ + data: { node_id: 'n1', index: 5 }, + ...overrides, + }) as LoopNextResponse + +export const createLoopFinishedResponse = ( + overrides: Partial<LoopFinishedResponse> = {}, +): LoopFinishedResponse => + ({ + data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded }, + ...overrides, + }) as LoopFinishedResponse + +export const createNodeStartedResponse = ( + overrides: Partial<NodeStartedResponse> = {}, +): NodeStartedResponse => + ({ + data: { node_id: 'n1' }, + ...overrides, + }) as NodeStartedResponse + +export const pausedRunningData = (): WorkflowRunningData['result'] => + ({ status: WorkflowRunningStatus.Paused }) as WorkflowRunningData['result'] diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-agent-log.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-agent-log.spec.ts index cae785546adf5e..4df411af5f0b9c 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-agent-log.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-agent-log.spec.ts @@ -29,10 +29,12 @@ describe('useWorkflowAgentLog', () => { const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), { initialStoreState: { workflowRunningData: baseRunningData({ - tracing: [{ - node_id: 'n1', - execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] }, - }], + tracing: [ + { + node_id: 'n1', + execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] }, + }, + ], }), }, }) @@ -41,17 +43,21 @@ describe('useWorkflowAgentLog', () => { data: { node_id: 'n1', message_id: 'm2' }, } as AgentLogResponse) - expect(store.getState().workflowRunningData!.tracing![0]!.execution_metadata!.agent_log).toHaveLength(2) + expect( + store.getState().workflowRunningData!.tracing![0]!.execution_metadata!.agent_log, + ).toHaveLength(2) }) it('updates an existing log entry by message_id', () => { const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), { initialStoreState: { workflowRunningData: baseRunningData({ - tracing: [{ - node_id: 'n1', - execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] }, - }], + tracing: [ + { + node_id: 'n1', + execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] }, + }, + ], }), }, }) @@ -78,6 +84,8 @@ describe('useWorkflowAgentLog', () => { data: { node_id: 'n1', message_id: 'm1' }, } as AgentLogResponse) - expect(store.getState().workflowRunningData!.tracing![0]!.execution_metadata!.agent_log).toHaveLength(1) + expect( + store.getState().workflowRunningData!.tracing![0]!.execution_metadata!.agent_log, + ).toHaveLength(1) }) }) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-finished.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-finished.spec.ts index 1a1aa03eff1b98..e7c3f486f2f91f 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-finished.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-finished.spec.ts @@ -34,8 +34,12 @@ describe('useWorkflowNodeFinished', () => { expect(trace!.status).toBe(NodeRunningStatus.Succeeded) await waitFor(() => { - expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded) - expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded) + expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe( + NodeRunningStatus.Succeeded, + ) + expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe( + NodeRunningStatus.Succeeded, + ) }) }) @@ -55,15 +59,17 @@ describe('useWorkflowNodeFinished', () => { }) act(() => { - result.current.handleWorkflowNodeFinished(createNodeFinishedResponse({ - data: { - id: 'trace-1', - node_id: 'n1', - node_type: BlockEnum.IfElse, - status: NodeRunningStatus.Succeeded, - outputs: { selected_case_id: 'branch-a' }, - } as never, - })) + result.current.handleWorkflowNodeFinished( + createNodeFinishedResponse({ + data: { + id: 'trace-1', + node_id: 'n1', + node_type: BlockEnum.IfElse, + status: NodeRunningStatus.Succeeded, + outputs: { selected_case_id: 'branch-a' }, + } as never, + }), + ) }) await waitFor(() => { diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-form-timeout.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-form-timeout.spec.ts index 1b09cabf59f55e..c5d9125f36a241 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-form-timeout.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-form-timeout.spec.ts @@ -8,7 +8,13 @@ describe('useWorkflowNodeHumanInputFormTimeout', () => { initialStoreState: { workflowRunningData: baseRunningData({ humanInputFormDataList: [ - { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '', expiration_time: 0 }, + { + node_id: 'n1', + form_id: 'f1', + node_title: 'Node 1', + form_content: '', + expiration_time: 0, + }, ], }), }, @@ -18,6 +24,8 @@ describe('useWorkflowNodeHumanInputFormTimeout', () => { data: { node_id: 'n1', node_title: 'Node 1', expiration_time: 1000 }, } as HumanInputFormTimeoutResponse) - expect(store.getState().workflowRunningData!.humanInputFormDataList![0]!.expiration_time).toBe(1000) + expect(store.getState().workflowRunningData!.humanInputFormDataList![0]!.expiration_time).toBe( + 1000, + ) }) }) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-required.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-required.spec.ts index 45fe510246b0f9..58136c1d6eb1f8 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-required.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-required.spec.ts @@ -4,17 +4,18 @@ import { createNode } from '../../../__tests__/fixtures' import { baseRunningData } from '../../../__tests__/workflow-test-env' import { NodeRunningStatus } from '../../../types' import { useWorkflowNodeHumanInputRequired } from '../use-workflow-node-human-input-required' -import { - getNodeRuntimeState, - renderViewportHook, -} from './test-helpers' +import { getNodeRuntimeState, renderViewportHook } from './test-helpers' describe('useWorkflowNodeHumanInputRequired', () => { it('creates humanInputFormDataList and sets tracing and node to Paused', async () => { const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), { nodes: [ createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }), - createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }), + createNode({ + id: 'n2', + position: { x: 300, y: 0 }, + data: { _runningStatus: NodeRunningStatus.Running }, + }), ], edges: [], initialStoreState: { @@ -36,7 +37,9 @@ describe('useWorkflowNodeHumanInputRequired', () => { expect(state.tracing![0]!.status).toBe(NodeRunningStatus.Paused) await waitFor(() => { - expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n1'))._runningStatus).toBe(NodeRunningStatus.Paused) + expect( + getNodeRuntimeState(result.current.nodes.find((item) => item.id === 'n1'))._runningStatus, + ).toBe(NodeRunningStatus.Paused) }) }) @@ -44,7 +47,11 @@ describe('useWorkflowNodeHumanInputRequired', () => { const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), { nodes: [ createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }), - createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }), + createNode({ + id: 'n2', + position: { x: 300, y: 0 }, + data: { _runningStatus: NodeRunningStatus.Running }, + }), ], edges: [], initialStoreState: { @@ -72,7 +79,11 @@ describe('useWorkflowNodeHumanInputRequired', () => { const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), { nodes: [ createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }), - createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }), + createNode({ + id: 'n2', + position: { x: 300, y: 0 }, + data: { _runningStatus: NodeRunningStatus.Running }, + }), ], edges: [], initialStoreState: { diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-finished.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-finished.spec.ts index 87617f08354028..947050a42923d1 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-finished.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-finished.spec.ts @@ -35,8 +35,12 @@ describe('useWorkflowNodeIterationFinished', () => { expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES) await waitFor(() => { - expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded) - expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded) + expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe( + NodeRunningStatus.Succeeded, + ) + expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe( + NodeRunningStatus.Succeeded, + ) }) }) }) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-started.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-started.spec.ts index 9472c0cafadac8..5b134871635f32 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-started.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-started.spec.ts @@ -39,11 +39,13 @@ describe('useWorkflowNodeIterationStarted', () => { expect(transform[1]).toBe(310) expect(transform[2]).toBe(1) - const node = result.current.nodes.find(item => item.id === 'n1') + const node = result.current.nodes.find((item) => item.id === 'n1') expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running) expect(getNodeRuntimeState(node)._iterationLength).toBe(10) expect(getNodeRuntimeState(node)._waitingRun).toBe(false) - expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running) + expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe( + NodeRunningStatus.Running, + ) }) }) }) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-finished.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-finished.spec.ts index 86e3d9cdc38994..1dd9c66a805161 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-finished.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-finished.spec.ts @@ -30,11 +30,17 @@ describe('useWorkflowNodeLoopFinished', () => { result.current.handleWorkflowNodeLoopFinished(createLoopFinishedResponse()) }) - expect(store.getState().workflowRunningData!.tracing![0]!.status).toBe(NodeRunningStatus.Succeeded) + expect(store.getState().workflowRunningData!.tracing![0]!.status).toBe( + NodeRunningStatus.Succeeded, + ) await waitFor(() => { - expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded) - expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded) + expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe( + NodeRunningStatus.Succeeded, + ) + expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe( + NodeRunningStatus.Succeeded, + ) }) }) }) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-next.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-next.spec.ts index 5baa44c983902d..6d9c10fd0784ac 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-next.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-next.spec.ts @@ -3,11 +3,7 @@ import { createNode } from '../../../__tests__/fixtures' import { baseRunningData } from '../../../__tests__/workflow-test-env' import { NodeRunningStatus } from '../../../types' import { useWorkflowNodeLoopNext } from '../use-workflow-node-loop-next' -import { - createLoopNextResponse, - getNodeRuntimeState, - renderRunEventHook, -} from './test-helpers' +import { createLoopNextResponse, getNodeRuntimeState, renderRunEventHook } from './test-helpers' describe('useWorkflowNodeLoopNext', () => { it('sets _loopIndex and resets child nodes to waiting', async () => { @@ -30,9 +26,15 @@ describe('useWorkflowNodeLoopNext', () => { }) await waitFor(() => { - expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._loopIndex).toBe(5) - expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._waitingRun).toBe(true) - expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Waiting) + expect( + getNodeRuntimeState(result.current.nodes.find((node) => node.id === 'n1'))._loopIndex, + ).toBe(5) + expect( + getNodeRuntimeState(result.current.nodes.find((node) => node.id === 'n2'))._waitingRun, + ).toBe(true) + expect( + getNodeRuntimeState(result.current.nodes.find((node) => node.id === 'n2'))._runningStatus, + ).toBe(NodeRunningStatus.Waiting) }) }) }) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-started.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-started.spec.ts index d1648968620869..f7aeb004711586 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-started.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-started.spec.ts @@ -25,7 +25,9 @@ describe('useWorkflowNodeLoopStarted', () => { ) }) - expect(store.getState().workflowRunningData!.tracing![0]!.status).toBe(NodeRunningStatus.Running) + expect(store.getState().workflowRunningData!.tracing![0]!.status).toBe( + NodeRunningStatus.Running, + ) await waitFor(() => { const transform = result.current.reactFlowStore.getState().transform @@ -33,11 +35,13 @@ describe('useWorkflowNodeLoopStarted', () => { expect(transform[1]).toBe(310) expect(transform[2]).toBe(1) - const node = result.current.nodes.find(item => item.id === 'n1') + const node = result.current.nodes.find((item) => item.id === 'n1') expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running) expect(getNodeRuntimeState(node)._loopLength).toBe(5) expect(getNodeRuntimeState(node)._waitingRun).toBe(false) - expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running) + expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe( + NodeRunningStatus.Running, + ) }) }) }) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-retry.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-retry.spec.ts index b3c6b814b1b631..ec44d6020dbf78 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-retry.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-retry.spec.ts @@ -1,10 +1,7 @@ import { act, waitFor } from '@testing-library/react' import { baseRunningData } from '../../../__tests__/workflow-test-env' import { useWorkflowNodeRetry } from '../use-workflow-node-retry' -import { - getNodeRuntimeState, - renderRunEventHook, -} from './test-helpers' +import { getNodeRuntimeState, renderRunEventHook } from './test-helpers' describe('useWorkflowNodeRetry', () => { it('pushes retry data to tracing and updates _retryIndex', async () => { diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-started.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-started.spec.ts index 60a17207bc6bd3..39ffed84e2f6b5 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-started.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-started.spec.ts @@ -30,10 +30,12 @@ describe('useWorkflowNodeStarted', () => { expect(transform[1]).toBe(310) expect(transform[2]).toBe(1) - const node = result.current.nodes.find(item => item.id === 'n1') + const node = result.current.nodes.find((item) => item.id === 'n1') expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running) expect(getNodeRuntimeState(node)._waitingRun).toBe(false) - expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running) + expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe( + NodeRunningStatus.Running, + ) }) }) @@ -43,9 +45,12 @@ describe('useWorkflowNodeStarted', () => { }) act(() => { - result.current.handleWorkflowNodeStarted(createNodeStartedResponse({ - data: { node_id: 'n2' } as never, - }), containerParams) + result.current.handleWorkflowNodeStarted( + createNodeStartedResponse({ + data: { node_id: 'n2' } as never, + }), + containerParams, + ) }) await waitFor(() => { @@ -53,7 +58,9 @@ describe('useWorkflowNodeStarted', () => { expect(transform[0]).toBe(0) expect(transform[1]).toBe(0) expect(transform[2]).toBe(1) - expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Running) + expect( + getNodeRuntimeState(result.current.nodes.find((item) => item.id === 'n2'))._runningStatus, + ).toBe(NodeRunningStatus.Running) }) }) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-reasoning.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-reasoning.spec.ts index ebbb547f706ef4..d5a86a56e41b1f 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-reasoning.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-reasoning.spec.ts @@ -32,7 +32,10 @@ describe('useWorkflowReasoning', () => { result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: 'a', node_id: 'llm-1' })) result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: 'b', node_id: 'llm-2' })) - expect(store.getState().workflowRunningData!.reasoningContent).toEqual({ 'llm-1': 'a', 'llm-2': 'b' }) + expect(store.getState().workflowRunningData!.reasoningContent).toEqual({ + 'llm-1': 'a', + 'llm-2': 'b', + }) }) it('falls back to "_" when the chunk carries no node id', () => { @@ -52,7 +55,9 @@ describe('useWorkflowReasoning', () => { }, }) - result.current.handleWorkflowReasoning(reasoningChunk({ reasoning: '', node_id: 'llm', is_final: true })) + result.current.handleWorkflowReasoning( + reasoningChunk({ reasoning: '', node_id: 'llm', is_final: true }), + ) const state = store.getState().workflowRunningData! expect(state.reasoningContent).toEqual({ llm: 'done' }) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts index 6010c791702436..62c45551d8127a 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts @@ -29,21 +29,41 @@ vi.mock('..', () => ({ useWorkflowFinished: () => ({ handleWorkflowFinished: handlers.handleWorkflowFinished }), useWorkflowFailed: () => ({ handleWorkflowFailed: handlers.handleWorkflowFailed }), useWorkflowNodeStarted: () => ({ handleWorkflowNodeStarted: handlers.handleWorkflowNodeStarted }), - useWorkflowNodeFinished: () => ({ handleWorkflowNodeFinished: handlers.handleWorkflowNodeFinished }), - useWorkflowNodeIterationStarted: () => ({ handleWorkflowNodeIterationStarted: handlers.handleWorkflowNodeIterationStarted }), - useWorkflowNodeIterationNext: () => ({ handleWorkflowNodeIterationNext: handlers.handleWorkflowNodeIterationNext }), - useWorkflowNodeIterationFinished: () => ({ handleWorkflowNodeIterationFinished: handlers.handleWorkflowNodeIterationFinished }), - useWorkflowNodeLoopStarted: () => ({ handleWorkflowNodeLoopStarted: handlers.handleWorkflowNodeLoopStarted }), - useWorkflowNodeLoopNext: () => ({ handleWorkflowNodeLoopNext: handlers.handleWorkflowNodeLoopNext }), - useWorkflowNodeLoopFinished: () => ({ handleWorkflowNodeLoopFinished: handlers.handleWorkflowNodeLoopFinished }), + useWorkflowNodeFinished: () => ({ + handleWorkflowNodeFinished: handlers.handleWorkflowNodeFinished, + }), + useWorkflowNodeIterationStarted: () => ({ + handleWorkflowNodeIterationStarted: handlers.handleWorkflowNodeIterationStarted, + }), + useWorkflowNodeIterationNext: () => ({ + handleWorkflowNodeIterationNext: handlers.handleWorkflowNodeIterationNext, + }), + useWorkflowNodeIterationFinished: () => ({ + handleWorkflowNodeIterationFinished: handlers.handleWorkflowNodeIterationFinished, + }), + useWorkflowNodeLoopStarted: () => ({ + handleWorkflowNodeLoopStarted: handlers.handleWorkflowNodeLoopStarted, + }), + useWorkflowNodeLoopNext: () => ({ + handleWorkflowNodeLoopNext: handlers.handleWorkflowNodeLoopNext, + }), + useWorkflowNodeLoopFinished: () => ({ + handleWorkflowNodeLoopFinished: handlers.handleWorkflowNodeLoopFinished, + }), useWorkflowNodeRetry: () => ({ handleWorkflowNodeRetry: handlers.handleWorkflowNodeRetry }), useWorkflowTextChunk: () => ({ handleWorkflowTextChunk: handlers.handleWorkflowTextChunk }), useWorkflowTextReplace: () => ({ handleWorkflowTextReplace: handlers.handleWorkflowTextReplace }), useWorkflowAgentLog: () => ({ handleWorkflowAgentLog: handlers.handleWorkflowAgentLog }), useWorkflowPaused: () => ({ handleWorkflowPaused: handlers.handleWorkflowPaused }), - useWorkflowNodeHumanInputRequired: () => ({ handleWorkflowNodeHumanInputRequired: handlers.handleWorkflowNodeHumanInputRequired }), - useWorkflowNodeHumanInputFormFilled: () => ({ handleWorkflowNodeHumanInputFormFilled: handlers.handleWorkflowNodeHumanInputFormFilled }), - useWorkflowNodeHumanInputFormTimeout: () => ({ handleWorkflowNodeHumanInputFormTimeout: handlers.handleWorkflowNodeHumanInputFormTimeout }), + useWorkflowNodeHumanInputRequired: () => ({ + handleWorkflowNodeHumanInputRequired: handlers.handleWorkflowNodeHumanInputRequired, + }), + useWorkflowNodeHumanInputFormFilled: () => ({ + handleWorkflowNodeHumanInputFormFilled: handlers.handleWorkflowNodeHumanInputFormFilled, + }), + useWorkflowNodeHumanInputFormTimeout: () => ({ + handleWorkflowNodeHumanInputFormTimeout: handlers.handleWorkflowNodeHumanInputFormTimeout, + }), })) vi.mock('../use-workflow-reasoning', () => ({ diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-started.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-started.spec.ts index 4fd49c9c6aa2d3..95d0c0ad247753 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-started.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-started.spec.ts @@ -44,9 +44,11 @@ describe('useWorkflowStarted', () => { }) act(() => { - result.current.handleWorkflowStarted(createStartedResponse({ - data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 }, - })) + result.current.handleWorkflowStarted( + createStartedResponse({ + data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 }, + }), + ) }) expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Running) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-text-chunk.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-text-chunk.spec.ts index 6af90592ef41cb..a19c071c4a48b4 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-text-chunk.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-text-chunk.spec.ts @@ -20,7 +20,10 @@ describe('useWorkflowTextChunk', () => { it('inserts a line break when text chunks switch to a different output selector', () => { const { result, store } = renderWorkflowHook(() => useWorkflowTextChunk(), { initialStoreState: { - workflowRunningData: baseRunningData({ resultText: 'Hello', resultTextSelectorKey: 'end.answer' }), + workflowRunningData: baseRunningData({ + resultText: 'Hello', + resultTextSelectorKey: 'end.answer', + }), }, }) @@ -36,7 +39,10 @@ describe('useWorkflowTextChunk', () => { it('does not add an extra line break when the incoming chunk already starts with one', () => { const { result, store } = renderWorkflowHook(() => useWorkflowTextChunk(), { initialStoreState: { - workflowRunningData: baseRunningData({ resultText: 'Hello', resultTextSelectorKey: 'end.answer' }), + workflowRunningData: baseRunningData({ + resultText: 'Hello', + resultTextSelectorKey: 'end.answer', + }), }, }) @@ -52,7 +58,10 @@ describe('useWorkflowTextChunk', () => { it('does not insert a line break when text chunks stay on the same output selector', () => { const { result, store } = renderWorkflowHook(() => useWorkflowTextChunk(), { initialStoreState: { - workflowRunningData: baseRunningData({ resultText: 'Hello', resultTextSelectorKey: 'end.answer' }), + workflowRunningData: baseRunningData({ + resultText: 'Hello', + resultTextSelectorKey: 'end.answer', + }), }, }) diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-agent-log.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-agent-log.ts index 3d6e655c7306ed..336af598fda585 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-agent-log.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-agent-log.ts @@ -6,43 +6,44 @@ import { useWorkflowStore } from '@/app/components/workflow/store' export const useWorkflowAgentLog = () => { const workflowStore = useWorkflowStore() - const handleWorkflowAgentLog = useCallback((params: AgentLogResponse) => { - const { data } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() + const handleWorkflowAgentLog = useCallback( + (params: AgentLogResponse) => { + const { data } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - const currentIndex = draft.tracing!.findIndex(item => item.node_id === data.node_id) - if (currentIndex > -1) { - const current = draft.tracing![currentIndex] + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + const currentIndex = draft.tracing!.findIndex((item) => item.node_id === data.node_id) + if (currentIndex > -1) { + const current = draft.tracing![currentIndex] - if (current!.execution_metadata) { - if (current!.execution_metadata.agent_log) { - const currentLogIndex = current!.execution_metadata.agent_log.findIndex(log => log.message_id === data.message_id) - if (currentLogIndex > -1) { - current!.execution_metadata.agent_log[currentLogIndex] = { - ...current!.execution_metadata.agent_log[currentLogIndex], - ...data, + if (current!.execution_metadata) { + if (current!.execution_metadata.agent_log) { + const currentLogIndex = current!.execution_metadata.agent_log.findIndex( + (log) => log.message_id === data.message_id, + ) + if (currentLogIndex > -1) { + current!.execution_metadata.agent_log[currentLogIndex] = { + ...current!.execution_metadata.agent_log[currentLogIndex], + ...data, + } + } else { + current!.execution_metadata.agent_log.push(data) + } + } else { + current!.execution_metadata.agent_log = [data] } + } else { + current!.execution_metadata = { + agent_log: [data], + } as any } - else { - current!.execution_metadata.agent_log.push(data) - } - } - else { - current!.execution_metadata.agent_log = [data] } - } - else { - current!.execution_metadata = { - agent_log: [data], - } as any - } - } - })) - }, [workflowStore]) + }), + ) + }, + [workflowStore], + ) return { handleWorkflowAgentLog, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-failed.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-failed.ts index b2d9755ab59b50..2e890fb9de8bdb 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-failed.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-failed.ts @@ -7,17 +7,16 @@ export const useWorkflowFailed = () => { const workflowStore = useWorkflowStore() const handleWorkflowFailed = useCallback(() => { - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - draft.result = { - ...draft.result, - status: WorkflowRunningStatus.Failed, - } - })) + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + draft.result = { + ...draft.result, + status: WorkflowRunningStatus.Failed, + } + }), + ) }, [workflowStore]) return { diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-finished.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-finished.ts index 3858b1eed8d5be..3a081a41a60ca4 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-finished.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-finished.ts @@ -7,27 +7,32 @@ import { useWorkflowStore } from '@/app/components/workflow/store' export const useWorkflowFinished = () => { const workflowStore = useWorkflowStore() - const handleWorkflowFinished = useCallback((params: WorkflowFinishedResponse) => { - const { data } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() + const handleWorkflowFinished = useCallback( + (params: WorkflowFinishedResponse) => { + const { data } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() - const isStringOutput = data.outputs && Object.keys(data.outputs).length === 1 && typeof data.outputs[Object.keys(data.outputs)[0]!] === 'string' + const isStringOutput = + data.outputs && + Object.keys(data.outputs).length === 1 && + typeof data.outputs[Object.keys(data.outputs)[0]!] === 'string' - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - draft.result = { - ...draft.result, - ...data, - files: getFilesInLogs(data.outputs), - } as any - if (isStringOutput) { - draft.resultTabActive = true - draft.resultText = data.outputs[Object.keys(data.outputs)[0]!] - } - })) - }, [workflowStore]) + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + draft.result = { + ...draft.result, + ...data, + files: getFilesInLogs(data.outputs), + } as any + if (isStringOutput) { + draft.resultTabActive = true + draft.resultText = data.outputs[Object.keys(data.outputs)[0]!] + } + }), + ) + }, + [workflowStore], + ) return { handleWorkflowFinished, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-finished.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-finished.ts index 6768273f204aca..10547e7eb2011e 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-finished.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-finished.ts @@ -4,69 +4,62 @@ import { useCallback } from 'react' import { useStoreApi } from 'reactflow' import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' import { useWorkflowStore } from '@/app/components/workflow/store' -import { - BlockEnum, - NodeRunningStatus, -} from '@/app/components/workflow/types' +import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' export const useWorkflowNodeFinished = () => { const store = useStoreApi() const workflowStore = useWorkflowStore() - const handleWorkflowNodeFinished = useCallback((params: NodeFinishedResponse) => { - const { data } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() - const { - getNodes, - setNodes, - edges, - setEdges, - } = store.getState() - const nodes = getNodes() - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - const currentIndex = draft.tracing!.findIndex(item => item.id === data.id) - if (currentIndex > -1) { - draft.tracing![currentIndex] = { - ...draft.tracing![currentIndex], - ...data, - } - } - })) + const handleWorkflowNodeFinished = useCallback( + (params: NodeFinishedResponse) => { + const { data } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() + const { getNodes, setNodes, edges, setEdges } = store.getState() + const nodes = getNodes() + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + const currentIndex = draft.tracing!.findIndex((item) => item.id === data.id) + if (currentIndex > -1) { + draft.tracing![currentIndex] = { + ...draft.tracing![currentIndex], + ...data, + } + } + }), + ) - const newNodes = produce(nodes, (draft) => { - const currentNode = draft.find(node => node.id === data.node_id)! - currentNode.data._runningStatus = data.status - if (data.status === NodeRunningStatus.Exception) { - if (data.execution_metadata?.error_strategy === ErrorHandleTypeEnum.failBranch) - currentNode.data._runningBranchId = ErrorHandleTypeEnum.failBranch - } - else { - if (data.node_type === BlockEnum.IfElse) - currentNode.data._runningBranchId = data?.outputs?.selected_case_id + const newNodes = produce(nodes, (draft) => { + const currentNode = draft.find((node) => node.id === data.node_id)! + currentNode.data._runningStatus = data.status + if (data.status === NodeRunningStatus.Exception) { + if (data.execution_metadata?.error_strategy === ErrorHandleTypeEnum.failBranch) + currentNode.data._runningBranchId = ErrorHandleTypeEnum.failBranch + } else { + if (data.node_type === BlockEnum.IfElse) + currentNode.data._runningBranchId = data?.outputs?.selected_case_id - if (data.node_type === BlockEnum.QuestionClassifier) - currentNode.data._runningBranchId = data?.outputs?.class_id - if (data.node_type === BlockEnum.HumanInput) - currentNode.data._runningBranchId = data?.outputs?.__action_id - } - }) - setNodes(newNodes) - const newEdges = produce(edges, (draft) => { - const incomeEdges = draft.filter((edge) => { - return edge.target === data.node_id - }) - incomeEdges.forEach((edge) => { - edge.data = { - ...edge.data, - _targetRunningStatus: data.status, + if (data.node_type === BlockEnum.QuestionClassifier) + currentNode.data._runningBranchId = data?.outputs?.class_id + if (data.node_type === BlockEnum.HumanInput) + currentNode.data._runningBranchId = data?.outputs?.__action_id } }) - }) - setEdges(newEdges) - }, [store, workflowStore]) + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + const incomeEdges = draft.filter((edge) => { + return edge.target === data.node_id + }) + incomeEdges.forEach((edge) => { + edge.data = { + ...edge.data, + _targetRunningStatus: data.status, + } + }) + }) + setEdges(newEdges) + }, + [store, workflowStore], + ) return { handleWorkflowNodeFinished, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-filled.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-filled.ts index f3750b69961448..312fc3464f7a7c 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-filled.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-filled.ts @@ -6,27 +6,28 @@ import { useWorkflowStore } from '@/app/components/workflow/store' export const useWorkflowNodeHumanInputFormFilled = () => { const workflowStore = useWorkflowStore() - const handleWorkflowNodeHumanInputFormFilled = useCallback((params: HumanInputFormFilledResponse) => { - const { data } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() + const handleWorkflowNodeHumanInputFormFilled = useCallback( + (params: HumanInputFormFilledResponse) => { + const { data } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() - const newWorkflowRunningData = produce(workflowRunningData!, (draft) => { - if (draft.humanInputFormDataList?.length) { - const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id) - draft.humanInputFormDataList.splice(currentFormIndex, 1) - } - if (!draft.humanInputFilledFormDataList) { - draft.humanInputFilledFormDataList = [data] - } - else { - draft.humanInputFilledFormDataList.push(data) - } - }) - setWorkflowRunningData(newWorkflowRunningData) - }, [workflowStore]) + const newWorkflowRunningData = produce(workflowRunningData!, (draft) => { + if (draft.humanInputFormDataList?.length) { + const currentFormIndex = draft.humanInputFormDataList.findIndex( + (item) => item.node_id === data.node_id, + ) + draft.humanInputFormDataList.splice(currentFormIndex, 1) + } + if (!draft.humanInputFilledFormDataList) { + draft.humanInputFilledFormDataList = [data] + } else { + draft.humanInputFilledFormDataList.push(data) + } + }) + setWorkflowRunningData(newWorkflowRunningData) + }, + [workflowStore], + ) return { handleWorkflowNodeHumanInputFormFilled, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-timeout.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-timeout.ts index 7e636e0eed1db1..dc054f5d0f29f5 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-timeout.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-timeout.ts @@ -6,21 +6,23 @@ import { useWorkflowStore } from '@/app/components/workflow/store' export const useWorkflowNodeHumanInputFormTimeout = () => { const workflowStore = useWorkflowStore() - const handleWorkflowNodeHumanInputFormTimeout = useCallback((params: HumanInputFormTimeoutResponse) => { - const { data } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() + const handleWorkflowNodeHumanInputFormTimeout = useCallback( + (params: HumanInputFormTimeoutResponse) => { + const { data } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() - const newWorkflowRunningData = produce(workflowRunningData!, (draft) => { - if (draft.humanInputFormDataList?.length) { - const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id) - draft.humanInputFormDataList[currentFormIndex]!.expiration_time = data.expiration_time - } - }) - setWorkflowRunningData(newWorkflowRunningData) - }, [workflowStore]) + const newWorkflowRunningData = produce(workflowRunningData!, (draft) => { + if (draft.humanInputFormDataList?.length) { + const currentFormIndex = draft.humanInputFormDataList.findIndex( + (item) => item.node_id === data.node_id, + ) + draft.humanInputFormDataList[currentFormIndex]!.expiration_time = data.expiration_time + } + }) + setWorkflowRunningData(newWorkflowRunningData) + }, + [workflowStore], + ) return { handleWorkflowNodeHumanInputFormTimeout, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-required.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-required.ts index b14e7bdb21b80c..caf3688924278e 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-required.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-required.ts @@ -1,9 +1,7 @@ import type { HumanInputRequiredResponse } from '@/types/workflow' import { produce } from 'immer' import { useCallback } from 'react' -import { - useStoreApi, -} from 'reactflow' +import { useStoreApi } from 'reactflow' import { useWorkflowStore } from '@/app/components/workflow/store' import { NodeRunningStatus } from '@/app/components/workflow/types' @@ -12,47 +10,44 @@ export const useWorkflowNodeHumanInputRequired = () => { const workflowStore = useWorkflowStore() // Notice: Human input required !== Workflow Paused - const handleWorkflowNodeHumanInputRequired = useCallback((params: HumanInputRequiredResponse) => { - const { data } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() + const handleWorkflowNodeHumanInputRequired = useCallback( + (params: HumanInputRequiredResponse) => { + const { data } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() - const newWorkflowRunningData = produce(workflowRunningData!, (draft) => { - if (!draft.humanInputFormDataList) { - draft.humanInputFormDataList = [data] - } - else { - const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id) - if (currentFormIndex > -1) { - draft.humanInputFormDataList[currentFormIndex] = data + const newWorkflowRunningData = produce(workflowRunningData!, (draft) => { + if (!draft.humanInputFormDataList) { + draft.humanInputFormDataList = [data] + } else { + const currentFormIndex = draft.humanInputFormDataList.findIndex( + (item) => item.node_id === data.node_id, + ) + if (currentFormIndex > -1) { + draft.humanInputFormDataList[currentFormIndex] = data + } else { + draft.humanInputFormDataList.push(data) + } } - else { - draft.humanInputFormDataList.push(data) + const currentIndex = draft.tracing!.findIndex((item) => item.node_id === data.node_id) + if (currentIndex > -1) { + draft.tracing![currentIndex] = { + ...draft.tracing![currentIndex]!, + status: NodeRunningStatus.Paused, + } } - } - const currentIndex = draft.tracing!.findIndex(item => item.node_id === data.node_id) - if (currentIndex > -1) { - draft.tracing![currentIndex] = { - ...draft.tracing![currentIndex]!, - status: NodeRunningStatus.Paused, - } - } - }) - setWorkflowRunningData(newWorkflowRunningData) + }) + setWorkflowRunningData(newWorkflowRunningData) - const { - getNodes, - setNodes, - } = store.getState() - const nodes = getNodes() - const currentNodeIndex = nodes.findIndex(node => node.id === data.node_id) - const newNodes = produce(nodes, (draft) => { - draft[currentNodeIndex]!.data._runningStatus = NodeRunningStatus.Paused - }) - setNodes(newNodes) - }, [store, workflowStore]) + const { getNodes, setNodes } = store.getState() + const nodes = getNodes() + const currentNodeIndex = nodes.findIndex((node) => node.id === data.node_id) + const newNodes = produce(nodes, (draft) => { + draft[currentNodeIndex]!.data._runningStatus = NodeRunningStatus.Paused + }) + setNodes(newNodes) + }, + [store, workflowStore], + ) return { handleWorkflowNodeHumanInputRequired, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-iteration-finished.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-iteration-finished.ts index 4491104c08be5a..a668f6e8b3889c 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-iteration-finished.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-iteration-finished.ts @@ -9,50 +9,46 @@ export const useWorkflowNodeIterationFinished = () => { const store = useStoreApi() const workflowStore = useWorkflowStore() - const handleWorkflowNodeIterationFinished = useCallback((params: IterationFinishedResponse) => { - const { data } = params - const { - workflowRunningData, - setWorkflowRunningData, - setIterTimes, - } = workflowStore.getState() - const { - getNodes, - setNodes, - edges, - setEdges, - } = store.getState() - const nodes = getNodes() - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - const currentIndex = draft.tracing!.findIndex(item => item.id === data.id) + const handleWorkflowNodeIterationFinished = useCallback( + (params: IterationFinishedResponse) => { + const { data } = params + const { workflowRunningData, setWorkflowRunningData, setIterTimes } = workflowStore.getState() + const { getNodes, setNodes, edges, setEdges } = store.getState() + const nodes = getNodes() + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + const currentIndex = draft.tracing!.findIndex((item) => item.id === data.id) - if (currentIndex > -1) { - draft.tracing![currentIndex] = { - ...draft.tracing![currentIndex], - ...data, - } - } - })) - setIterTimes(DEFAULT_ITER_TIMES) - const newNodes = produce(nodes, (draft) => { - const currentNode = draft.find(node => node.id === data.node_id)! + if (currentIndex > -1) { + draft.tracing![currentIndex] = { + ...draft.tracing![currentIndex], + ...data, + } + } + }), + ) + setIterTimes(DEFAULT_ITER_TIMES) + const newNodes = produce(nodes, (draft) => { + const currentNode = draft.find((node) => node.id === data.node_id)! - currentNode.data._runningStatus = data.status - }) - setNodes(newNodes) - const newEdges = produce(edges, (draft) => { - const incomeEdges = draft.filter((edge) => { - return edge.target === data.node_id + currentNode.data._runningStatus = data.status }) - incomeEdges.forEach((edge) => { - edge.data = { - ...edge.data, - _targetRunningStatus: data.status, - } + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + const incomeEdges = draft.filter((edge) => { + return edge.target === data.node_id + }) + incomeEdges.forEach((edge) => { + edge.data = { + ...edge.data, + _targetRunningStatus: data.status, + } + }) }) - }) - setEdges(newEdges) - }, [workflowStore, store]) + setEdges(newEdges) + }, + [workflowStore, store], + ) return { handleWorkflowNodeIterationFinished, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-iteration-next.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-iteration-next.ts index 70fe6fbf227255..237a4d227c51f4 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-iteration-next.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-iteration-next.ts @@ -8,26 +8,23 @@ export const useWorkflowNodeIterationNext = () => { const store = useStoreApi() const workflowStore = useWorkflowStore() - const handleWorkflowNodeIterationNext = useCallback((params: IterationNextResponse) => { - const { - iterTimes, - setIterTimes, - } = workflowStore.getState() + const handleWorkflowNodeIterationNext = useCallback( + (params: IterationNextResponse) => { + const { iterTimes, setIterTimes } = workflowStore.getState() - const { data } = params - const { - getNodes, - setNodes, - } = store.getState() + const { data } = params + const { getNodes, setNodes } = store.getState() - const nodes = getNodes() - const newNodes = produce(nodes, (draft) => { - const currentNode = draft.find(node => node.id === data.node_id)! - currentNode.data._iterationIndex = iterTimes - setIterTimes(iterTimes + 1) - }) - setNodes(newNodes) - }, [workflowStore, store]) + const nodes = getNodes() + const newNodes = produce(nodes, (draft) => { + const currentNode = draft.find((node) => node.id === data.node_id)! + currentNode.data._iterationIndex = iterTimes + setIterTimes(iterTimes + 1) + }) + setNodes(newNodes) + }, + [workflowStore, store], + ) return { handleWorkflowNodeIterationNext, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-iteration-started.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-iteration-started.ts index b6f254db145fbc..6ac14c9bfa66cc 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-iteration-started.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-iteration-started.ts @@ -1,10 +1,7 @@ import type { IterationStartedResponse } from '@/types/workflow' import { produce } from 'immer' import { useCallback } from 'react' -import { - useReactFlow, - useStoreApi, -} from 'reactflow' +import { useReactFlow, useStoreApi } from 'reactflow' import { DEFAULT_ITER_TIMES } from '@/app/components/workflow/constants' import { useWorkflowStore } from '@/app/components/workflow/store' import { NodeRunningStatus } from '@/app/components/workflow/types' @@ -14,70 +11,66 @@ export const useWorkflowNodeIterationStarted = () => { const reactflow = useReactFlow() const workflowStore = useWorkflowStore() - const handleWorkflowNodeIterationStarted = useCallback(( - params: IterationStartedResponse, - containerParams: { - clientWidth: number - clientHeight: number - }, - ) => { - const { data } = params - const { - workflowRunningData, - setWorkflowRunningData, - setIterTimes, - } = workflowStore.getState() - const { - getNodes, - setNodes, - edges, - setEdges, - transform, - } = store.getState() - const nodes = getNodes() - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - draft.tracing!.push({ - ...data, - status: NodeRunningStatus.Running, - }) - })) - setIterTimes(DEFAULT_ITER_TIMES) + const handleWorkflowNodeIterationStarted = useCallback( + ( + params: IterationStartedResponse, + containerParams: { + clientWidth: number + clientHeight: number + }, + ) => { + const { data } = params + const { workflowRunningData, setWorkflowRunningData, setIterTimes } = workflowStore.getState() + const { getNodes, setNodes, edges, setEdges, transform } = store.getState() + const nodes = getNodes() + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + draft.tracing!.push({ + ...data, + status: NodeRunningStatus.Running, + }) + }), + ) + setIterTimes(DEFAULT_ITER_TIMES) - const { - setViewport, - } = reactflow - const currentNodeIndex = nodes.findIndex(node => node.id === data.node_id) - const currentNode = nodes[currentNodeIndex] - const position = currentNode!.position - const zoom = transform[2] + const { setViewport } = reactflow + const currentNodeIndex = nodes.findIndex((node) => node.id === data.node_id) + const currentNode = nodes[currentNodeIndex] + const position = currentNode!.position + const zoom = transform[2] - if (!currentNode!.parentId) { - setViewport({ - x: (containerParams.clientWidth - 400 - currentNode!.width! * zoom) / 2 - position.x * zoom, - y: (containerParams.clientHeight - currentNode!.height! * zoom) / 2 - position.y * zoom, - zoom: transform[2], + if (!currentNode!.parentId) { + setViewport({ + x: + (containerParams.clientWidth - 400 - currentNode!.width! * zoom) / 2 - + position.x * zoom, + y: (containerParams.clientHeight - currentNode!.height! * zoom) / 2 - position.y * zoom, + zoom: transform[2], + }) + } + const newNodes = produce(nodes, (draft) => { + draft[currentNodeIndex]!.data._runningStatus = NodeRunningStatus.Running + draft[currentNodeIndex]!.data._iterationLength = data.metadata.iterator_length + draft[currentNodeIndex]!.data._waitingRun = false }) - } - const newNodes = produce(nodes, (draft) => { - draft[currentNodeIndex]!.data._runningStatus = NodeRunningStatus.Running - draft[currentNodeIndex]!.data._iterationLength = data.metadata.iterator_length - draft[currentNodeIndex]!.data._waitingRun = false - }) - setNodes(newNodes) - const newEdges = produce(edges, (draft) => { - const incomeEdges = draft.filter(edge => edge.target === data.node_id) + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + const incomeEdges = draft.filter((edge) => edge.target === data.node_id) - incomeEdges.forEach((edge) => { - edge.data = { - ...edge.data, - _sourceRunningStatus: nodes.find(node => node.id === edge.source)!.data._runningStatus, - _targetRunningStatus: NodeRunningStatus.Running, - _waitingRun: false, - } + incomeEdges.forEach((edge) => { + edge.data = { + ...edge.data, + _sourceRunningStatus: nodes.find((node) => node.id === edge.source)!.data + ._runningStatus, + _targetRunningStatus: NodeRunningStatus.Running, + _waitingRun: false, + } + }) }) - }) - setEdges(newEdges) - }, [workflowStore, store, reactflow]) + setEdges(newEdges) + }, + [workflowStore, store, reactflow], + ) return { handleWorkflowNodeIterationStarted, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts index 24df8ac6aad580..e9b79a4e53c10e 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-finished.ts @@ -8,48 +8,45 @@ export const useWorkflowNodeLoopFinished = () => { const store = useStoreApi() const workflowStore = useWorkflowStore() - const handleWorkflowNodeLoopFinished = useCallback((params: LoopFinishedResponse) => { - const { data } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() - const { - getNodes, - setNodes, - edges, - setEdges, - } = store.getState() - const nodes = getNodes() - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - const currentIndex = draft.tracing!.findIndex(item => item.id === data.id) + const handleWorkflowNodeLoopFinished = useCallback( + (params: LoopFinishedResponse) => { + const { data } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() + const { getNodes, setNodes, edges, setEdges } = store.getState() + const nodes = getNodes() + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + const currentIndex = draft.tracing!.findIndex((item) => item.id === data.id) - if (currentIndex > -1) { - draft.tracing![currentIndex] = { - ...draft.tracing![currentIndex], - ...data, - } - } - })) - const newNodes = produce(nodes, (draft) => { - const currentNode = draft.find(node => node.id === data.node_id)! + if (currentIndex > -1) { + draft.tracing![currentIndex] = { + ...draft.tracing![currentIndex], + ...data, + } + } + }), + ) + const newNodes = produce(nodes, (draft) => { + const currentNode = draft.find((node) => node.id === data.node_id)! - currentNode.data._runningStatus = data.status - }) - setNodes(newNodes) - const newEdges = produce(edges, (draft) => { - const incomeEdges = draft.filter((edge) => { - return edge.target === data.node_id + currentNode.data._runningStatus = data.status }) - incomeEdges.forEach((edge) => { - edge.data = { - ...edge.data, - _targetRunningStatus: data.status, - } + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + const incomeEdges = draft.filter((edge) => { + return edge.target === data.node_id + }) + incomeEdges.forEach((edge) => { + edge.data = { + ...edge.data, + _targetRunningStatus: data.status, + } + }) }) - }) - setEdges(newEdges) - }, [workflowStore, store]) + setEdges(newEdges) + }, + [workflowStore, store], + ) return { handleWorkflowNodeLoopFinished, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts index 2f92e2bae138f5..ead5d9d6b43d71 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-next.ts @@ -7,27 +7,27 @@ import { NodeRunningStatus } from '@/app/components/workflow/types' export const useWorkflowNodeLoopNext = () => { const store = useStoreApi() - const handleWorkflowNodeLoopNext = useCallback((params: LoopNextResponse) => { - const { data } = params - const { - getNodes, - setNodes, - } = store.getState() + const handleWorkflowNodeLoopNext = useCallback( + (params: LoopNextResponse) => { + const { data } = params + const { getNodes, setNodes } = store.getState() - const nodes = getNodes() - const newNodes = produce(nodes, (draft) => { - const currentNode = draft.find(node => node.id === data.node_id)! - currentNode.data._loopIndex = data.index + const nodes = getNodes() + const newNodes = produce(nodes, (draft) => { + const currentNode = draft.find((node) => node.id === data.node_id)! + currentNode.data._loopIndex = data.index - draft.forEach((node) => { - if (node.parentId === data.node_id) { - node.data._waitingRun = true - node.data._runningStatus = NodeRunningStatus.Waiting - } + draft.forEach((node) => { + if (node.parentId === data.node_id) { + node.data._waitingRun = true + node.data._runningStatus = NodeRunningStatus.Waiting + } + }) }) - }) - setNodes(newNodes) - }, [store]) + setNodes(newNodes) + }, + [store], + ) return { handleWorkflowNodeLoopNext, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts index dcdf651967888b..57a202d5cafe35 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-loop-started.ts @@ -1,10 +1,7 @@ import type { LoopStartedResponse } from '@/types/workflow' import { produce } from 'immer' import { useCallback } from 'react' -import { - useReactFlow, - useStoreApi, -} from 'reactflow' +import { useReactFlow, useStoreApi } from 'reactflow' import { useWorkflowStore } from '@/app/components/workflow/store' import { NodeRunningStatus } from '@/app/components/workflow/types' @@ -13,68 +10,65 @@ export const useWorkflowNodeLoopStarted = () => { const reactflow = useReactFlow() const workflowStore = useWorkflowStore() - const handleWorkflowNodeLoopStarted = useCallback(( - params: LoopStartedResponse, - containerParams: { - clientWidth: number - clientHeight: number - }, - ) => { - const { data } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() - const { - getNodes, - setNodes, - edges, - setEdges, - transform, - } = store.getState() - const nodes = getNodes() - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - draft.tracing!.push({ - ...data, - status: NodeRunningStatus.Running, - }) - })) + const handleWorkflowNodeLoopStarted = useCallback( + ( + params: LoopStartedResponse, + containerParams: { + clientWidth: number + clientHeight: number + }, + ) => { + const { data } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() + const { getNodes, setNodes, edges, setEdges, transform } = store.getState() + const nodes = getNodes() + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + draft.tracing!.push({ + ...data, + status: NodeRunningStatus.Running, + }) + }), + ) - const { - setViewport, - } = reactflow - const currentNodeIndex = nodes.findIndex(node => node.id === data.node_id) - const currentNode = nodes[currentNodeIndex] - const position = currentNode!.position - const zoom = transform[2] + const { setViewport } = reactflow + const currentNodeIndex = nodes.findIndex((node) => node.id === data.node_id) + const currentNode = nodes[currentNodeIndex] + const position = currentNode!.position + const zoom = transform[2] - if (!currentNode!.parentId) { - setViewport({ - x: (containerParams.clientWidth - 400 - currentNode!.width! * zoom) / 2 - position.x * zoom, - y: (containerParams.clientHeight - currentNode!.height! * zoom) / 2 - position.y * zoom, - zoom: transform[2], + if (!currentNode!.parentId) { + setViewport({ + x: + (containerParams.clientWidth - 400 - currentNode!.width! * zoom) / 2 - + position.x * zoom, + y: (containerParams.clientHeight - currentNode!.height! * zoom) / 2 - position.y * zoom, + zoom: transform[2], + }) + } + const newNodes = produce(nodes, (draft) => { + draft[currentNodeIndex]!.data._runningStatus = NodeRunningStatus.Running + draft[currentNodeIndex]!.data._loopLength = data.metadata.loop_length + draft[currentNodeIndex]!.data._waitingRun = false }) - } - const newNodes = produce(nodes, (draft) => { - draft[currentNodeIndex]!.data._runningStatus = NodeRunningStatus.Running - draft[currentNodeIndex]!.data._loopLength = data.metadata.loop_length - draft[currentNodeIndex]!.data._waitingRun = false - }) - setNodes(newNodes) - const newEdges = produce(edges, (draft) => { - const incomeEdges = draft.filter(edge => edge.target === data.node_id) + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + const incomeEdges = draft.filter((edge) => edge.target === data.node_id) - incomeEdges.forEach((edge) => { - edge.data = { - ...edge.data, - _sourceRunningStatus: nodes.find(node => node.id === edge.source)!.data._runningStatus, - _targetRunningStatus: NodeRunningStatus.Running, - _waitingRun: false, - } + incomeEdges.forEach((edge) => { + edge.data = { + ...edge.data, + _sourceRunningStatus: nodes.find((node) => node.id === edge.source)!.data + ._runningStatus, + _targetRunningStatus: NodeRunningStatus.Running, + _waitingRun: false, + } + }) }) - }) - setEdges(newEdges) - }, [workflowStore, store, reactflow]) + setEdges(newEdges) + }, + [workflowStore, store, reactflow], + ) return { handleWorkflowNodeLoopStarted, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-retry.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-retry.ts index 5b09d27ca4b5da..515d4895b4ff62 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-retry.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-retry.ts @@ -1,6 +1,4 @@ -import type { - NodeFinishedResponse, -} from '@/types/workflow' +import type { NodeFinishedResponse } from '@/types/workflow' import { produce } from 'immer' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' @@ -10,28 +8,27 @@ export const useWorkflowNodeRetry = () => { const store = useStoreApi() const workflowStore = useWorkflowStore() - const handleWorkflowNodeRetry = useCallback((params: NodeFinishedResponse) => { - const { data } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() - const { - getNodes, - setNodes, - } = store.getState() + const handleWorkflowNodeRetry = useCallback( + (params: NodeFinishedResponse) => { + const { data } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() + const { getNodes, setNodes } = store.getState() - const nodes = getNodes() - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - draft.tracing!.push(data) - })) - const newNodes = produce(nodes, (draft) => { - const currentNode = draft.find(node => node.id === data.node_id)! + const nodes = getNodes() + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + draft.tracing!.push(data) + }), + ) + const newNodes = produce(nodes, (draft) => { + const currentNode = draft.find((node) => node.id === data.node_id)! - currentNode.data._retryIndex = data.retry_index - }) - setNodes(newNodes) - }, [workflowStore, store]) + currentNode.data._retryIndex = data.retry_index + }) + setNodes(newNodes) + }, + [workflowStore, store], + ) return { handleWorkflowNodeRetry, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-started.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-started.ts index a435bbdda6e68b..2ee7ea46b97c27 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-started.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-started.ts @@ -1,10 +1,7 @@ import type { NodeStartedResponse } from '@/types/workflow' import { produce } from 'immer' import { useCallback } from 'react' -import { - useReactFlow, - useStoreApi, -} from 'reactflow' +import { useReactFlow, useStoreApi } from 'reactflow' import { useWorkflowStore } from '@/app/components/workflow/store' import { NodeRunningStatus } from '@/app/components/workflow/types' @@ -13,89 +10,88 @@ export const useWorkflowNodeStarted = () => { const workflowStore = useWorkflowStore() const reactflow = useReactFlow() - const handleWorkflowNodeStarted = useCallback(( - params: NodeStartedResponse, - containerParams: { - clientWidth: number - clientHeight: number - }, - ) => { - const { data } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() - const { - getNodes, - setNodes, - edges, - setEdges, - transform, - } = store.getState() - const nodes = getNodes() - const currentIndex = workflowRunningData?.tracing?.findIndex(item => item.node_id === data.node_id) - if (currentIndex && currentIndex > -1) { - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - draft.tracing![currentIndex] = { - ...data, - status: NodeRunningStatus.Running, - } - })) - } - else { - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - draft.tracing!.push({ - ...data, - status: NodeRunningStatus.Running, - }) - })) - } + const handleWorkflowNodeStarted = useCallback( + ( + params: NodeStartedResponse, + containerParams: { + clientWidth: number + clientHeight: number + }, + ) => { + const { data } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() + const { getNodes, setNodes, edges, setEdges, transform } = store.getState() + const nodes = getNodes() + const currentIndex = workflowRunningData?.tracing?.findIndex( + (item) => item.node_id === data.node_id, + ) + if (currentIndex && currentIndex > -1) { + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + draft.tracing![currentIndex] = { + ...data, + status: NodeRunningStatus.Running, + } + }), + ) + } else { + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + draft.tracing!.push({ + ...data, + status: NodeRunningStatus.Running, + }) + }), + ) + } - const { - setViewport, - } = reactflow - const currentNodeIndex = nodes.findIndex(node => node.id === data.node_id) - const currentNode = nodes[currentNodeIndex] - const position = currentNode!.position - const zoom = transform[2] + const { setViewport } = reactflow + const currentNodeIndex = nodes.findIndex((node) => node.id === data.node_id) + const currentNode = nodes[currentNodeIndex] + const position = currentNode!.position + const zoom = transform[2] - if (!currentNode!.parentId) { - setViewport({ - x: (containerParams.clientWidth - 400 - currentNode!.width! * zoom) / 2 - position.x * zoom, - y: (containerParams.clientHeight - currentNode!.height! * zoom) / 2 - position.y * zoom, - zoom: transform[2], - }) - } - const newNodes = produce(nodes, (draft) => { - draft[currentNodeIndex]!.data._runningStatus = NodeRunningStatus.Running - draft[currentNodeIndex]!.data._waitingRun = false - }) - setNodes(newNodes) - const newEdges = produce(edges, (draft) => { - const incomeEdges = draft.filter((edge) => { - return edge.target === data.node_id + if (!currentNode!.parentId) { + setViewport({ + x: + (containerParams.clientWidth - 400 - currentNode!.width! * zoom) / 2 - + position.x * zoom, + y: (containerParams.clientHeight - currentNode!.height! * zoom) / 2 - position.y * zoom, + zoom: transform[2], + }) + } + const newNodes = produce(nodes, (draft) => { + draft[currentNodeIndex]!.data._runningStatus = NodeRunningStatus.Running + draft[currentNodeIndex]!.data._waitingRun = false }) + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + const incomeEdges = draft.filter((edge) => { + return edge.target === data.node_id + }) - incomeEdges.forEach((edge) => { - const incomeNode = nodes.find(node => node.id === edge.source)! - if (!incomeNode || !('data' in incomeNode)) - return + incomeEdges.forEach((edge) => { + const incomeNode = nodes.find((node) => node.id === edge.source)! + if (!incomeNode || !('data' in incomeNode)) return - if ( - (!incomeNode.data._runningBranchId && edge.sourceHandle === 'source') - || (incomeNode.data._runningBranchId && edge.sourceHandle === incomeNode.data._runningBranchId) - ) { - edge.data = { - ...edge.data, - _sourceRunningStatus: incomeNode.data._runningStatus, - _targetRunningStatus: NodeRunningStatus.Running, - _waitingRun: false, + if ( + (!incomeNode.data._runningBranchId && edge.sourceHandle === 'source') || + (incomeNode.data._runningBranchId && + edge.sourceHandle === incomeNode.data._runningBranchId) + ) { + edge.data = { + ...edge.data, + _sourceRunningStatus: incomeNode.data._runningStatus, + _targetRunningStatus: NodeRunningStatus.Running, + _waitingRun: false, + } } - } + }) }) - }) - setEdges(newEdges) - }, [workflowStore, store, reactflow]) + setEdges(newEdges) + }, + [workflowStore, store, reactflow], + ) return { handleWorkflowNodeStarted, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-paused.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-paused.ts index fc85d3d459bc83..85e1681f23ebbd 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-paused.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-paused.ts @@ -7,17 +7,16 @@ export const useWorkflowPaused = () => { const workflowStore = useWorkflowStore() const handleWorkflowPaused = useCallback(() => { - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - draft.result = { - ...draft.result, - status: WorkflowRunningStatus.Paused, - } - })) + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + draft.result = { + ...draft.result, + status: WorkflowRunningStatus.Paused, + } + }), + ) }, [workflowStore]) return { diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-reasoning.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-reasoning.ts index 9abfeca0383f58..25c912196c747b 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-reasoning.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-reasoning.ts @@ -6,23 +6,25 @@ import { useWorkflowStore } from '@/app/components/workflow/store' export const useWorkflowReasoning = () => { const workflowStore = useWorkflowStore() - const handleWorkflowReasoning = useCallback((params: ReasoningChunkResponse) => { - const { data: { reasoning, node_id, is_final } } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() + const handleWorkflowReasoning = useCallback( + (params: ReasoningChunkResponse) => { + const { + data: { reasoning, node_id, is_final }, + } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - const reasoningContent = (draft.reasoningContent ||= {}) - // key by LLM node so multiple nodes' reasoning stays separated - const key = node_id || '_' - if (reasoning) - reasoningContent[key] = (reasoningContent[key] || '') + reasoning - if (is_final) - draft.reasoningFinished = true - })) - }, [workflowStore]) + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + const reasoningContent = (draft.reasoningContent ||= {}) + // key by LLM node so multiple nodes' reasoning stays separated + const key = node_id || '_' + if (reasoning) reasoningContent[key] = (reasoningContent[key] || '') + reasoning + if (is_final) draft.reasoningFinished = true + }), + ) + }, + [workflowStore], + ) return { handleWorkflowReasoning, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-started.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-started.ts index a100768444587e..767979e7a397a3 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-started.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-started.ts @@ -9,59 +9,58 @@ export const useWorkflowStarted = () => { const store = useStoreApi() const workflowStore = useWorkflowStore() - const handleWorkflowStarted = useCallback((params: WorkflowStartedResponse) => { - const { task_id, data } = params - const { - workflowRunningData, - setWorkflowRunningData, - setIterParallelLogMap, - } = workflowStore.getState() - const { - getNodes, - setNodes, - edges, - setEdges, - } = store.getState() - if (workflowRunningData?.result?.status === WorkflowRunningStatus.Paused) { - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - draft.result = { - ...draft.result, - status: WorkflowRunningStatus.Running, - } - })) - return - } - setIterParallelLogMap(new Map()) - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - draft.task_id = task_id - draft.result = { - ...draft?.result, - ...data, - status: WorkflowRunningStatus.Running, + const handleWorkflowStarted = useCallback( + (params: WorkflowStartedResponse) => { + const { task_id, data } = params + const { workflowRunningData, setWorkflowRunningData, setIterParallelLogMap } = + workflowStore.getState() + const { getNodes, setNodes, edges, setEdges } = store.getState() + if (workflowRunningData?.result?.status === WorkflowRunningStatus.Paused) { + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + draft.result = { + ...draft.result, + status: WorkflowRunningStatus.Running, + } + }), + ) + return } - draft.resultText = '' - draft.resultTextSelectorKey = undefined - })) - const nodes = getNodes() - const newNodes = produce(nodes, (draft) => { - draft.forEach((node) => { - node.data._waitingRun = true - node.data._runningBranchId = undefined + setIterParallelLogMap(new Map()) + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + draft.task_id = task_id + draft.result = { + ...draft?.result, + ...data, + status: WorkflowRunningStatus.Running, + } + draft.resultText = '' + draft.resultTextSelectorKey = undefined + }), + ) + const nodes = getNodes() + const newNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + node.data._waitingRun = true + node.data._runningBranchId = undefined + }) }) - }) - setNodes(newNodes) - const newEdges = produce(edges, (draft) => { - draft.forEach((edge) => { - edge.data = { - ...edge.data, - _sourceRunningStatus: undefined, - _targetRunningStatus: undefined, - _waitingRun: true, - } + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + draft.forEach((edge) => { + edge.data = { + ...edge.data, + _sourceRunningStatus: undefined, + _targetRunningStatus: undefined, + _waitingRun: true, + } + }) }) - }) - setEdges(newEdges) - }, [workflowStore, store]) + setEdges(newEdges) + }, + [workflowStore, store], + ) return { handleWorkflowStarted, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-chunk.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-chunk.ts index 21e46d6d1e2f80..c8903078619a80 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-chunk.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-chunk.ts @@ -6,29 +6,32 @@ import { useWorkflowStore } from '@/app/components/workflow/store' export const useWorkflowTextChunk = () => { const workflowStore = useWorkflowStore() - const handleWorkflowTextChunk = useCallback((params: TextChunkResponse) => { - const { data: { text, from_variable_selector } } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() - const nextSelectorKey = from_variable_selector?.join('.') + const handleWorkflowTextChunk = useCallback( + (params: TextChunkResponse) => { + const { + data: { text, from_variable_selector }, + } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() + const nextSelectorKey = from_variable_selector?.join('.') - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - draft.resultTabActive = true - const shouldInsertLineBreak = nextSelectorKey - && draft.resultText - && draft.resultTextSelectorKey - && draft.resultTextSelectorKey !== nextSelectorKey - && !draft.resultText.endsWith('\n') - && !text.startsWith('\n') - if (shouldInsertLineBreak) - draft.resultText += '\n' - draft.resultText += text - if (nextSelectorKey) - draft.resultTextSelectorKey = nextSelectorKey - })) - }, [workflowStore]) + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + draft.resultTabActive = true + const shouldInsertLineBreak = + nextSelectorKey && + draft.resultText && + draft.resultTextSelectorKey && + draft.resultTextSelectorKey !== nextSelectorKey && + !draft.resultText.endsWith('\n') && + !text.startsWith('\n') + if (shouldInsertLineBreak) draft.resultText += '\n' + draft.resultText += text + if (nextSelectorKey) draft.resultTextSelectorKey = nextSelectorKey + }), + ) + }, + [workflowStore], + ) return { handleWorkflowTextChunk, diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-replace.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-replace.ts index aa5696a6f123a2..a6b221de7beb57 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-replace.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-replace.ts @@ -6,17 +6,21 @@ import { useWorkflowStore } from '@/app/components/workflow/store' export const useWorkflowTextReplace = () => { const workflowStore = useWorkflowStore() - const handleWorkflowTextReplace = useCallback((params: TextReplaceResponse) => { - const { data: { text } } = params - const { - workflowRunningData, - setWorkflowRunningData, - } = workflowStore.getState() - setWorkflowRunningData(produce(workflowRunningData!, (draft) => { - draft.resultText = text - draft.resultTextSelectorKey = undefined - })) - }, [workflowStore]) + const handleWorkflowTextReplace = useCallback( + (params: TextReplaceResponse) => { + const { + data: { text }, + } = params + const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() + setWorkflowRunningData( + produce(workflowRunningData!, (draft) => { + draft.resultText = text + draft.resultTextSelectorKey = undefined + }), + ) + }, + [workflowStore], + ) return { handleWorkflowTextReplace, diff --git a/web/app/components/workflow/hooks/use-workflow-run.ts b/web/app/components/workflow/hooks/use-workflow-run.ts index 05a60ebb4b7e1c..43769bd5f47503 100644 --- a/web/app/components/workflow/hooks/use-workflow-run.ts +++ b/web/app/components/workflow/hooks/use-workflow-run.ts @@ -1,11 +1,13 @@ import { useHooksStore } from '@/app/components/workflow/hooks-store' export const useWorkflowRun = () => { - const handleBackupDraft = useHooksStore(s => s.handleBackupDraft) - const handleLoadBackupDraft = useHooksStore(s => s.handleLoadBackupDraft) - const handleRestoreFromPublishedWorkflow = useHooksStore(s => s.handleRestoreFromPublishedWorkflow) - const handleRun = useHooksStore(s => s.handleRun) - const handleStopRun = useHooksStore(s => s.handleStopRun) + const handleBackupDraft = useHooksStore((s) => s.handleBackupDraft) + const handleLoadBackupDraft = useHooksStore((s) => s.handleLoadBackupDraft) + const handleRestoreFromPublishedWorkflow = useHooksStore( + (s) => s.handleRestoreFromPublishedWorkflow, + ) + const handleRun = useHooksStore((s) => s.handleRun) + const handleStopRun = useHooksStore((s) => s.handleStopRun) return { handleBackupDraft, diff --git a/web/app/components/workflow/hooks/use-workflow-search.tsx b/web/app/components/workflow/hooks/use-workflow-search.tsx index 8ca597f94eb117..f649ea8fd9b301 100644 --- a/web/app/components/workflow/hooks/use-workflow-search.tsx +++ b/web/app/components/workflow/hooks/use-workflow-search.tsx @@ -33,24 +33,26 @@ export const useWorkflowSearch = () => { const { data: mcpTools } = useAllMCPTools() // Extract tool icon logic - clean separation of concerns - const getToolIcon = useCallback((nodeData: CommonNodeType): string | Emoji | undefined => { - if (nodeData?.type !== BlockEnum.Tool) - return undefined - - const toolCollections: Record<string, any[]> = { - [CollectionType.builtIn]: buildInTools || [], - [CollectionType.custom]: customTools || [], - [CollectionType.mcp]: mcpTools || [], - } + const getToolIcon = useCallback( + (nodeData: CommonNodeType): string | Emoji | undefined => { + if (nodeData?.type !== BlockEnum.Tool) return undefined + + const toolCollections: Record<string, any[]> = { + [CollectionType.builtIn]: buildInTools || [], + [CollectionType.custom]: customTools || [], + [CollectionType.mcp]: mcpTools || [], + } - const targetTools = (nodeData.provider_type && toolCollections[nodeData.provider_type]) || workflowTools - return targetTools?.find((tool: any) => canFindTool(tool.id, nodeData.provider_id))?.icon - }, [buildInTools, customTools, workflowTools, mcpTools]) + const targetTools = + (nodeData.provider_type && toolCollections[nodeData.provider_type]) || workflowTools + return targetTools?.find((tool: any) => canFindTool(tool.id, nodeData.provider_id))?.icon + }, + [buildInTools, customTools, workflowTools, mcpTools], + ) // Extract model info logic - clean extraction const getModelInfo = useCallback((nodeData: CommonNodeType) => { - if (nodeData?.type !== BlockEnum.LLM) - return {} + if (nodeData?.type !== BlockEnum.LLM) return {} const llmNodeData = nodeData as LLMNodeType return llmNodeData.model @@ -64,8 +66,7 @@ export const useWorkflowSearch = () => { const searchableNodes = useMemo(() => { const filteredNodes = nodes.filter((node) => { - if (!node.id || !node.data || node.type === 'sticky') - return false + if (!node.id || !node.data || node.type === 'sticky') return false const nodeData = node.data as CommonNodeType const nodeType = nodeData?.type @@ -91,97 +92,95 @@ export const useWorkflowSearch = () => { }, [nodes, getToolIcon, getModelInfo]) // Calculate search score - clean scoring logic - const calculateScore = useCallback((node: { - title: string - type: string - desc: string - modelInfo: { provider?: string, name?: string, mode?: string } - }, searchTerm: string): number => { - if (!searchTerm) - return 1 - - const titleMatch = node.title.toLowerCase() - const typeMatch = node.type.toLowerCase() - const descMatch = node.desc?.toLowerCase() || '' - const modelProviderMatch = node.modelInfo?.provider?.toLowerCase() || '' - const modelNameMatch = node.modelInfo?.name?.toLowerCase() || '' - const modelModeMatch = node.modelInfo?.mode?.toLowerCase() || '' - - let score = 0 - - // Title matching (exact prefix > partial match) - if (titleMatch.startsWith(searchTerm)) - score += 100 - else if (titleMatch.includes(searchTerm)) - score += 50 - - // Type matching (exact > partial) - if (typeMatch === searchTerm) - score += 80 - else if (typeMatch.includes(searchTerm)) - score += 30 - - // Description matching (additive) - if (descMatch.includes(searchTerm)) - score += 20 - - // LLM model matching (additive - can combine multiple matches) - if (modelNameMatch && modelNameMatch.includes(searchTerm)) - score += 60 - if (modelProviderMatch && modelProviderMatch.includes(searchTerm)) - score += 40 - if (modelModeMatch && modelModeMatch.includes(searchTerm)) - score += 30 - - return score - }, []) + const calculateScore = useCallback( + ( + node: { + title: string + type: string + desc: string + modelInfo: { provider?: string; name?: string; mode?: string } + }, + searchTerm: string, + ): number => { + if (!searchTerm) return 1 + + const titleMatch = node.title.toLowerCase() + const typeMatch = node.type.toLowerCase() + const descMatch = node.desc?.toLowerCase() || '' + const modelProviderMatch = node.modelInfo?.provider?.toLowerCase() || '' + const modelNameMatch = node.modelInfo?.name?.toLowerCase() || '' + const modelModeMatch = node.modelInfo?.mode?.toLowerCase() || '' + + let score = 0 + + // Title matching (exact prefix > partial match) + if (titleMatch.startsWith(searchTerm)) score += 100 + else if (titleMatch.includes(searchTerm)) score += 50 + + // Type matching (exact > partial) + if (typeMatch === searchTerm) score += 80 + else if (typeMatch.includes(searchTerm)) score += 30 + + // Description matching (additive) + if (descMatch.includes(searchTerm)) score += 20 + + // LLM model matching (additive - can combine multiple matches) + if (modelNameMatch && modelNameMatch.includes(searchTerm)) score += 60 + if (modelProviderMatch && modelProviderMatch.includes(searchTerm)) score += 40 + if (modelModeMatch && modelModeMatch.includes(searchTerm)) score += 30 + + return score + }, + [], + ) // Create search function for workflow nodes - const searchWorkflowNodes = useCallback((query: string) => { - if (!searchableNodes.length) - return [] - - const searchTerm = query.toLowerCase().trim() - - const results = searchableNodes - .map((node) => { - const score = calculateScore(node, searchTerm) - - return score > 0 - ? { - id: node.id, - title: node.title, - description: node.desc || node.type, - type: 'workflow-node' as const, - path: `#${node.id}`, - icon: ( - <BlockIcon - type={node.blockType} - className="shrink-0" - size="sm" - toolIcon={node.toolIcon} - /> - ), - metadata: { - nodeId: node.id, - nodeData: node.nodeData, - }, - data: node.nodeData, - score, - } - : null - }) - .filter((node): node is NonNullable<typeof node> => node !== null) - .sort((a, b) => { - // If no search term, sort alphabetically - if (!searchTerm) - return a.title.localeCompare(b.title) - // Sort by relevance score (higher score first) - return (b.score || 0) - (a.score || 0) - }) - - return results - }, [searchableNodes, calculateScore]) + const searchWorkflowNodes = useCallback( + (query: string) => { + if (!searchableNodes.length) return [] + + const searchTerm = query.toLowerCase().trim() + + const results = searchableNodes + .map((node) => { + const score = calculateScore(node, searchTerm) + + return score > 0 + ? { + id: node.id, + title: node.title, + description: node.desc || node.type, + type: 'workflow-node' as const, + path: `#${node.id}`, + icon: ( + <BlockIcon + type={node.blockType} + className="shrink-0" + size="sm" + toolIcon={node.toolIcon} + /> + ), + metadata: { + nodeId: node.id, + nodeData: node.nodeData, + }, + data: node.nodeData, + score, + } + : null + }) + .filter((node): node is NonNullable<typeof node> => node !== null) + .sort((a, b) => { + // If no search term, sort alphabetically + if (!searchTerm) return a.title.localeCompare(b.title) + // Sort by relevance score (higher score first) + return (b.score || 0) - (a.score || 0) + }) + + return results + }, + [searchableNodes, calculateScore], + ) // Directly set the search function on the action object useEffect(() => { diff --git a/web/app/components/workflow/hooks/use-workflow-start-run.tsx b/web/app/components/workflow/hooks/use-workflow-start-run.tsx index 46fe5649c8cec6..8673889e72738d 100644 --- a/web/app/components/workflow/hooks/use-workflow-start-run.tsx +++ b/web/app/components/workflow/hooks/use-workflow-start-run.tsx @@ -1,13 +1,21 @@ import { useHooksStore } from '@/app/components/workflow/hooks-store' export const useWorkflowStartRun = () => { - const handleStartWorkflowRun = useHooksStore(s => s.handleStartWorkflowRun) - const handleWorkflowStartRunInWorkflow = useHooksStore(s => s.handleWorkflowStartRunInWorkflow) - const handleWorkflowStartRunInChatflow = useHooksStore(s => s.handleWorkflowStartRunInChatflow) - const handleWorkflowTriggerScheduleRunInWorkflow = useHooksStore(s => s.handleWorkflowTriggerScheduleRunInWorkflow) - const handleWorkflowTriggerWebhookRunInWorkflow = useHooksStore(s => s.handleWorkflowTriggerWebhookRunInWorkflow) - const handleWorkflowTriggerPluginRunInWorkflow = useHooksStore(s => s.handleWorkflowTriggerPluginRunInWorkflow) - const handleWorkflowRunAllTriggersInWorkflow = useHooksStore(s => s.handleWorkflowRunAllTriggersInWorkflow) + const handleStartWorkflowRun = useHooksStore((s) => s.handleStartWorkflowRun) + const handleWorkflowStartRunInWorkflow = useHooksStore((s) => s.handleWorkflowStartRunInWorkflow) + const handleWorkflowStartRunInChatflow = useHooksStore((s) => s.handleWorkflowStartRunInChatflow) + const handleWorkflowTriggerScheduleRunInWorkflow = useHooksStore( + (s) => s.handleWorkflowTriggerScheduleRunInWorkflow, + ) + const handleWorkflowTriggerWebhookRunInWorkflow = useHooksStore( + (s) => s.handleWorkflowTriggerWebhookRunInWorkflow, + ) + const handleWorkflowTriggerPluginRunInWorkflow = useHooksStore( + (s) => s.handleWorkflowTriggerPluginRunInWorkflow, + ) + const handleWorkflowRunAllTriggersInWorkflow = useHooksStore( + (s) => s.handleWorkflowRunAllTriggersInWorkflow, + ) return { handleStartWorkflowRun, handleWorkflowStartRunInWorkflow, diff --git a/web/app/components/workflow/hooks/use-workflow-update.ts b/web/app/components/workflow/hooks/use-workflow-update.ts index 26244d20933c57..b558dad68bfe1c 100644 --- a/web/app/components/workflow/hooks/use-workflow-update.ts +++ b/web/app/components/workflow/hooks/use-workflow-update.ts @@ -3,33 +3,34 @@ import { useCallback } from 'react' import { useReactFlow } from 'reactflow' import { useEventEmitterContextContext } from '@/context/event-emitter' import { WORKFLOW_DATA_UPDATE } from '../constants' -import { - initialEdges, - initialNodes, -} from '../utils' +import { initialEdges, initialNodes } from '../utils' export const useWorkflowUpdate = () => { const reactflow = useReactFlow() const { eventEmitter } = useEventEmitterContextContext() - const handleUpdateWorkflowCanvas = useCallback((payload: WorkflowDataUpdater) => { - const { - nodes, - edges, - viewport, - } = payload + const handleUpdateWorkflowCanvas = useCallback( + (payload: WorkflowDataUpdater) => { + const { nodes, edges, viewport } = payload - eventEmitter?.emit({ - type: WORKFLOW_DATA_UPDATE, - payload: { - nodes: initialNodes(nodes, edges), - edges: initialEdges(edges, nodes), - }, - } as never) + eventEmitter?.emit({ + type: WORKFLOW_DATA_UPDATE, + payload: { + nodes: initialNodes(nodes, edges), + edges: initialEdges(edges, nodes), + }, + } as never) - if (viewport && typeof viewport.x === 'number' && typeof viewport.y === 'number' && typeof viewport.zoom === 'number') - reactflow.setViewport(viewport) - }, [eventEmitter, reactflow]) + if ( + viewport && + typeof viewport.x === 'number' && + typeof viewport.y === 'number' && + typeof viewport.zoom === 'number' + ) + reactflow.setViewport(viewport) + }, + [eventEmitter, reactflow], + ) return { handleUpdateWorkflowCanvas, diff --git a/web/app/components/workflow/hooks/use-workflow-variables.ts b/web/app/components/workflow/hooks/use-workflow-variables.ts index 8a975d80ec02ff..3b68e44f7b850d 100644 --- a/web/app/components/workflow/hooks/use-workflow-variables.ts +++ b/web/app/components/workflow/hooks/use-workflow-variables.ts @@ -1,14 +1,12 @@ import type { Type } from '../nodes/llm/types' -import type { - Node, - NodeOutPutVar, - ValueSelector, - Var, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' -import { getVarType, toNodeAvailableVars } from '@/app/components/workflow/nodes/_base/components/variable/utils' +import { + getVarType, + toNodeAvailableVars, +} from '@/app/components/workflow/nodes/_base/components/variable/utils' import { useAllBuiltInTools, useAllCustomTools, @@ -29,73 +27,48 @@ export const useWorkflowVariables = () => { const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() - const getNodeAvailableVars = useCallback(({ - parentNode, - beforeNodes, - isChatMode, - filterVar, - hideEnv, - hideChatVar, - }: { - parentNode?: Node | null - beforeNodes: Node[] - isChatMode: boolean - filterVar: (payload: Var, selector: ValueSelector) => boolean - hideEnv?: boolean - hideChatVar?: boolean - }): NodeOutPutVar[] => { - const { - conversationVariables, - environmentVariables, - ragPipelineVariables, - dataSourceList, - } = workflowStore.getState() - return toNodeAvailableVars({ + const getNodeAvailableVars = useCallback( + ({ parentNode, - t, beforeNodes, isChatMode, - environmentVariables: hideEnv ? [] : environmentVariables, - conversationVariables: (isChatMode && !hideChatVar) ? conversationVariables : [], - ragVariables: ragPipelineVariables, filterVar, - allPluginInfoList: { - buildInTools: buildInTools || [], - customTools: customTools || [], - workflowTools: workflowTools || [], - mcpTools: mcpTools || [], - dataSourceList: dataSourceList || [], - }, - schemaTypeDefinitions, - }) - }, [t, workflowStore, schemaTypeDefinitions, buildInTools, customTools, workflowTools, mcpTools]) + hideEnv, + hideChatVar, + }: { + parentNode?: Node | null + beforeNodes: Node[] + isChatMode: boolean + filterVar: (payload: Var, selector: ValueSelector) => boolean + hideEnv?: boolean + hideChatVar?: boolean + }): NodeOutPutVar[] => { + const { conversationVariables, environmentVariables, ragPipelineVariables, dataSourceList } = + workflowStore.getState() + return toNodeAvailableVars({ + parentNode, + t, + beforeNodes, + isChatMode, + environmentVariables: hideEnv ? [] : environmentVariables, + conversationVariables: isChatMode && !hideChatVar ? conversationVariables : [], + ragVariables: ragPipelineVariables, + filterVar, + allPluginInfoList: { + buildInTools: buildInTools || [], + customTools: customTools || [], + workflowTools: workflowTools || [], + mcpTools: mcpTools || [], + dataSourceList: dataSourceList || [], + }, + schemaTypeDefinitions, + }) + }, + [t, workflowStore, schemaTypeDefinitions, buildInTools, customTools, workflowTools, mcpTools], + ) - const getCurrentVariableType = useCallback(({ - parentNode, - valueSelector, - isIterationItem, - isLoopItem, - availableNodes, - isChatMode, - isConstant, - preferSchemaType, - }: { - valueSelector: ValueSelector - parentNode?: Node | null - isIterationItem?: boolean - isLoopItem?: boolean - availableNodes: any[] - isChatMode: boolean - isConstant?: boolean - preferSchemaType?: boolean - }) => { - const { - conversationVariables, - environmentVariables, - ragPipelineVariables, - dataSourceList, - } = workflowStore.getState() - return getVarType({ + const getCurrentVariableType = useCallback( + ({ parentNode, valueSelector, isIterationItem, @@ -103,20 +76,43 @@ export const useWorkflowVariables = () => { availableNodes, isChatMode, isConstant, - environmentVariables, - conversationVariables, - ragVariables: ragPipelineVariables, - allPluginInfoList: { - buildInTools: buildInTools || [], - customTools: customTools || [], - workflowTools: workflowTools || [], - mcpTools: mcpTools || [], - dataSourceList: dataSourceList ?? [], - }, - schemaTypeDefinitions, preferSchemaType, - }) - }, [workflowStore, schemaTypeDefinitions, buildInTools, customTools, workflowTools, mcpTools]) + }: { + valueSelector: ValueSelector + parentNode?: Node | null + isIterationItem?: boolean + isLoopItem?: boolean + availableNodes: any[] + isChatMode: boolean + isConstant?: boolean + preferSchemaType?: boolean + }) => { + const { conversationVariables, environmentVariables, ragPipelineVariables, dataSourceList } = + workflowStore.getState() + return getVarType({ + parentNode, + valueSelector, + isIterationItem, + isLoopItem, + availableNodes, + isChatMode, + isConstant, + environmentVariables, + conversationVariables, + ragVariables: ragPipelineVariables, + allPluginInfoList: { + buildInTools: buildInTools || [], + customTools: customTools || [], + workflowTools: workflowTools || [], + mcpTools: mcpTools || [], + dataSourceList: dataSourceList ?? [], + }, + schemaTypeDefinitions, + preferSchemaType, + }) + }, + [workflowStore, schemaTypeDefinitions, buildInTools, customTools, workflowTools, mcpTools], + ) return { getNodeAvailableVars, @@ -126,9 +122,7 @@ export const useWorkflowVariables = () => { export const useWorkflowVariableType = () => { const store = useStoreApi() - const { - getNodes, - } = store.getState() + const { getNodes } = store.getState() const { getCurrentVariableType } = useWorkflowVariables() const isChatMode = useIsChatMode() @@ -140,9 +134,9 @@ export const useWorkflowVariableType = () => { nodeId: string valueSelector: ValueSelector }) => { - const node = getNodes().find(n => n.id === nodeId) + const node = getNodes().find((n) => n.id === nodeId) const isInIteration = !!node?.data.isInIteration - const iterationNode = isInIteration ? getNodes().find(n => n.id === node.parentId) : null + const iterationNode = isInIteration ? getNodes().find((n) => n.id === node.parentId) : null const availableNodes = [node] const type = getCurrentVariableType({ diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 8bfa148f9fc56b..e957ed467f53a4 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -1,52 +1,32 @@ -import type { - Connection, -} from 'reactflow' +import type { Connection } from 'reactflow' import type { IterationNodeType } from '../nodes/iteration/types' import type { LoopNodeType } from '../nodes/loop/types' -import type { - BlockEnum, - Edge, - Node, - ValueSelector, -} from '../types' +import type { BlockEnum, Edge, Node, ValueSelector } from '../types' import { uniqBy } from 'es-toolkit/compat' -import { - useCallback, -} from 'react' -import { - getIncomers, - getOutgoers, -} from 'reactflow' +import { useCallback } from 'react' +import { getIncomers, getOutgoers } from 'reactflow' import { useStore as useAppStore } from '@/app/components/app/store' import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants' import { AppModeEnum } from '@/types/app' import { useNodesMetaData } from '.' -import { - SUPPORT_OUTPUT_VARS_NODE, -} from '../constants' +import { SUPPORT_OUTPUT_VARS_NODE } from '../constants' import { useHooksStore } from '../hooks-store' -import { findUsedVarNodes, getNodeOutputVars, updateNodeVars } from '../nodes/_base/components/variable/utils' - -import { CUSTOM_NOTE_NODE } from '../note-node/constants' import { - useStore, - useWorkflowStore, -} from '../store' -import { - WorkflowRunningStatus, -} from '../types' + findUsedVarNodes, + getNodeOutputVars, + updateNodeVars, +} from '../nodes/_base/components/variable/utils' +import { CUSTOM_NOTE_NODE } from '../note-node/constants' +import { useStore, useWorkflowStore } from '../store' +import { WorkflowRunningStatus } from '../types' import { getNodeCatalogType } from '../utils' -import { - getWorkflowEntryNode, - isWorkflowEntryNode, -} from '../utils/workflow-entry' - +import { getWorkflowEntryNode, isWorkflowEntryNode } from '../utils/workflow-entry' import { useAvailableBlocks } from './use-available-blocks' export const useIsChatMode = () => { - const appDetail = useAppStore(s => s.appDetail) + const appDetail = useAppStore((s) => s.appDetail) return appDetail?.mode === AppModeEnum.ADVANCED_CHAT } @@ -56,346 +36,387 @@ export const useWorkflow = () => { const { getAvailableBlocks } = useAvailableBlocks() const { nodesMap } = useNodesMetaData() - const getNodeById = useCallback((nodeId: string) => { - const { nodes } = collaborativeWorkflow.getState() - const currentNode = nodes.find(node => node.id === nodeId) - return currentNode - }, [collaborativeWorkflow]) - - const getTreeLeafNodes = useCallback((nodeId: string) => { - const { nodes, edges } = collaborativeWorkflow.getState() - const currentNode = nodes.find(node => node.id === nodeId) - - let startNodes = nodes.filter(node => nodesMap?.[node.data.type as BlockEnum]?.metaData.isStart) || [] + const getNodeById = useCallback( + (nodeId: string) => { + const { nodes } = collaborativeWorkflow.getState() + const currentNode = nodes.find((node) => node.id === nodeId) + return currentNode + }, + [collaborativeWorkflow], + ) + + const getTreeLeafNodes = useCallback( + (nodeId: string) => { + const { nodes, edges } = collaborativeWorkflow.getState() + const currentNode = nodes.find((node) => node.id === nodeId) + + let startNodes = + nodes.filter((node) => nodesMap?.[node.data.type as BlockEnum]?.metaData.isStart) || [] + + if (currentNode?.parentId) { + const startNode = nodes.find( + (node) => + node.parentId === currentNode.parentId && + (node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_LOOP_START_NODE), + ) + if (startNode) startNodes = [startNode] + } - if (currentNode?.parentId) { - const startNode = nodes.find(node => node.parentId === currentNode.parentId && (node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_LOOP_START_NODE)) - if (startNode) - startNodes = [startNode] - } + if (!startNodes.length) return [] - if (!startNodes.length) - return [] - - const list: Node[] = [] - const preOrder = (root: Node, callback: (node: Node) => void) => { - if (root.id === nodeId) - return - const outgoers = getOutgoers(root, nodes, edges) + const list: Node[] = [] + const preOrder = (root: Node, callback: (node: Node) => void) => { + if (root.id === nodeId) return + const outgoers = getOutgoers(root, nodes, edges) - if (outgoers.length) { - outgoers.forEach((outgoer) => { - preOrder(outgoer, callback) - }) - } - else { - if (root.id !== nodeId) - callback(root) + if (outgoers.length) { + outgoers.forEach((outgoer) => { + preOrder(outgoer, callback) + }) + } else { + if (root.id !== nodeId) callback(root) + } } - } - startNodes.forEach((startNode) => { - preOrder(startNode, (node) => { - list.push(node) + startNodes.forEach((startNode) => { + preOrder(startNode, (node) => { + list.push(node) + }) }) - }) - const incomers = getIncomers({ id: nodeId } as Node, nodes, edges) + const incomers = getIncomers({ id: nodeId } as Node, nodes, edges) + + list.push(...incomers) - list.push(...incomers) + return uniqBy(list, 'id').filter((item: Node) => { + return SUPPORT_OUTPUT_VARS_NODE.includes(item.data.type) + }) + }, + [collaborativeWorkflow, nodesMap], + ) - return uniqBy(list, 'id').filter((item: Node) => { - return SUPPORT_OUTPUT_VARS_NODE.includes(item.data.type) - }) - }, [collaborativeWorkflow, nodesMap]) + const getBeforeNodesInSameBranch = useCallback( + (nodeId: string, newNodes?: Node[], newEdges?: Edge[]) => { + const { nodes: oldNodes, edges } = collaborativeWorkflow.getState() + const nodes = newNodes || oldNodes + const currentNode = nodes.find((node) => node.id === nodeId) - const getBeforeNodesInSameBranch = useCallback((nodeId: string, newNodes?: Node[], newEdges?: Edge[]) => { - const { nodes: oldNodes, edges } = collaborativeWorkflow.getState() - const nodes = newNodes || oldNodes - const currentNode = nodes.find(node => node.id === nodeId) + const list: Node[] = [] - const list: Node[] = [] + if (!currentNode) return list - if (!currentNode) - return list + if (currentNode.parentId) { + const parentNode = nodes.find((node) => node.id === currentNode.parentId) + if (parentNode) { + const parentList = getBeforeNodesInSameBranch(parentNode.id) - if (currentNode.parentId) { - const parentNode = nodes.find(node => node.id === currentNode.parentId) - if (parentNode) { - const parentList = getBeforeNodesInSameBranch(parentNode.id) + list.push(...parentList) + } + } - list.push(...parentList) + const traverse = (root: Node, callback: (node: Node) => void) => { + if (root) { + const incomers = getIncomers(root, nodes, newEdges || edges) + + if (incomers.length) { + incomers.forEach((node) => { + if (!list.find((n) => node.id === n.id)) { + callback(node) + traverse(node, callback) + } + }) + } + } } - } + traverse(currentNode, (node) => { + list.push(node) + }) - const traverse = (root: Node, callback: (node: Node) => void) => { - if (root) { - const incomers = getIncomers(root, nodes, newEdges || edges) + const length = list.length + if (length) { + return uniqBy(list, 'id') + .reverse() + .filter((item: Node) => { + return SUPPORT_OUTPUT_VARS_NODE.includes(item.data.type) + }) + } - if (incomers.length) { - incomers.forEach((node) => { - if (!list.find(n => node.id === n.id)) { + return [] + }, + [collaborativeWorkflow], + ) + + const getBeforeNodesInSameBranchIncludeParent = useCallback( + (nodeId: string, newNodes?: Node[], newEdges?: Edge[]) => { + const nodes = getBeforeNodesInSameBranch(nodeId, newNodes, newEdges) + const { nodes: allNodes } = collaborativeWorkflow.getState() + const node = allNodes.find((n) => n.id === nodeId) + const parentNodeId = node?.parentId + const parentNode = allNodes.find((n) => n.id === parentNodeId) + if (parentNode) nodes.push(parentNode) + + return nodes + }, + [getBeforeNodesInSameBranch, collaborativeWorkflow], + ) + + const getAfterNodesInSameBranch = useCallback( + (nodeId: string) => { + const { nodes, edges } = collaborativeWorkflow.getState() + const currentNode = nodes.find((node) => node.id === nodeId)! + + if (!currentNode) return [] + const list: Node[] = [currentNode] + + const traverse = (root: Node, callback: (node: Node) => void) => { + if (root) { + const outgoers = getOutgoers(root, nodes, edges) + + if (outgoers.length) { + outgoers.forEach((node) => { callback(node) traverse(node, callback) - } - }) + }) + } } } - } - traverse(currentNode, (node) => { - list.push(node) - }) - - const length = list.length - if (length) { - return uniqBy(list, 'id').reverse().filter((item: Node) => { - return SUPPORT_OUTPUT_VARS_NODE.includes(item.data.type) + traverse(currentNode, (node) => { + list.push(node) }) - } - return [] - }, [collaborativeWorkflow]) + return uniqBy(list, 'id') + }, + [collaborativeWorkflow], + ) - const getBeforeNodesInSameBranchIncludeParent = useCallback((nodeId: string, newNodes?: Node[], newEdges?: Edge[]) => { - const nodes = getBeforeNodesInSameBranch(nodeId, newNodes, newEdges) - const { nodes: allNodes } = collaborativeWorkflow.getState() - const node = allNodes.find(n => n.id === nodeId) - const parentNodeId = node?.parentId - const parentNode = allNodes.find(n => n.id === parentNodeId) - if (parentNode) - nodes.push(parentNode) + const getBeforeNodeById = useCallback( + (nodeId: string) => { + const { nodes, edges } = collaborativeWorkflow.getState() + const node = nodes.find((node) => node.id === nodeId)! - return nodes - }, [getBeforeNodesInSameBranch, collaborativeWorkflow]) + return getIncomers(node, nodes, edges) + }, + [collaborativeWorkflow], + ) - const getAfterNodesInSameBranch = useCallback((nodeId: string) => { - const { nodes, edges } = collaborativeWorkflow.getState() - const currentNode = nodes.find(node => node.id === nodeId)! + const getIterationNodeChildren = useCallback( + (nodeId: string) => { + const { nodes } = collaborativeWorkflow.getState() - if (!currentNode) - return [] - const list: Node[] = [currentNode] + return nodes.filter((node) => node.parentId === nodeId) + }, + [collaborativeWorkflow], + ) - const traverse = (root: Node, callback: (node: Node) => void) => { - if (root) { - const outgoers = getOutgoers(root, nodes, edges) + const getLoopNodeChildren = useCallback( + (nodeId: string) => { + const { nodes } = collaborativeWorkflow.getState() - if (outgoers.length) { - outgoers.forEach((node) => { - callback(node) - traverse(node, callback) - }) - } - } - } - traverse(currentNode, (node) => { - list.push(node) - }) + return nodes.filter((node) => node.parentId === nodeId) + }, + [collaborativeWorkflow], + ) - return uniqBy(list, 'id') - }, [collaborativeWorkflow]) + const isFromStartNode = useCallback( + (nodeId: string) => { + const { nodes } = collaborativeWorkflow.getState() + const currentNode = nodes.find((node) => node.id === nodeId) - const getBeforeNodeById = useCallback((nodeId: string) => { - const { nodes, edges } = collaborativeWorkflow.getState() - const node = nodes.find(node => node.id === nodeId)! + if (!currentNode) return false - return getIncomers(node, nodes, edges) - }, [collaborativeWorkflow]) + if (isWorkflowEntryNode(currentNode.data.type)) return true - const getIterationNodeChildren = useCallback((nodeId: string) => { - const { nodes } = collaborativeWorkflow.getState() + const checkPreviousNodes = (node: Node) => { + const previousNodes = getBeforeNodeById(node.id) - return nodes.filter(node => node.parentId === nodeId) - }, [collaborativeWorkflow]) + for (const prevNode of previousNodes) { + if (isWorkflowEntryNode(prevNode.data.type)) return true + if (checkPreviousNodes(prevNode)) return true + } - const getLoopNodeChildren = useCallback((nodeId: string) => { - const { nodes } = collaborativeWorkflow.getState() + return false + } - return nodes.filter(node => node.parentId === nodeId) - }, [collaborativeWorkflow]) + return checkPreviousNodes(currentNode) + }, + [collaborativeWorkflow, getBeforeNodeById], + ) + + const handleOutVarRenameChange = useCallback( + (nodeId: string, oldValeSelector: ValueSelector, newVarSelector: ValueSelector) => { + const { nodes: allNodes, setNodes } = collaborativeWorkflow.getState() + const affectedNodes = findUsedVarNodes(oldValeSelector, allNodes) + if (affectedNodes.length > 0) { + const newNodes = allNodes.map((node) => { + if (affectedNodes.find((n) => n.id === node.id)) + return updateNodeVars(node, oldValeSelector, newVarSelector) + + return node + }) + setNodes(newNodes) + } + }, + [collaborativeWorkflow], + ) + + const isVarUsedInNodes = useCallback( + (varSelector: ValueSelector) => { + const nodeId = varSelector[0] + const afterNodes = getAfterNodesInSameBranch(nodeId!) + const effectNodes = findUsedVarNodes(varSelector, afterNodes) + return effectNodes.length > 0 + }, + [getAfterNodesInSameBranch], + ) + + const removeUsedVarInNodes = useCallback( + (varSelector: ValueSelector) => { + const nodeId = varSelector[0] + const { nodes, setNodes } = collaborativeWorkflow.getState() + const afterNodes = getAfterNodesInSameBranch(nodeId!) + const effectNodes = findUsedVarNodes(varSelector, afterNodes) + if (effectNodes.length > 0) { + const newNodes = nodes.map((node) => { + if (effectNodes.find((n) => n.id === node.id)) + return updateNodeVars(node, varSelector, []) + + return node + }) + setNodes(newNodes) + } + }, + [getAfterNodesInSameBranch, collaborativeWorkflow], + ) + + const isNodeVarsUsedInNodes = useCallback( + (node: Node, isChatMode: boolean) => { + const outputVars = getNodeOutputVars(node, isChatMode) + const isUsed = outputVars.some((varSelector) => { + return isVarUsedInNodes(varSelector) + }) + return isUsed + }, + [isVarUsedInNodes], + ) - const isFromStartNode = useCallback((nodeId: string) => { - const { nodes } = collaborativeWorkflow.getState() - const currentNode = nodes.find(node => node.id === nodeId) + const getRootNodesById = useCallback( + (nodeId: string) => { + const { nodes, edges } = collaborativeWorkflow.getState() + const currentNode = nodes.find((node) => node.id === nodeId) - if (!currentNode) - return false + const rootNodes: Node[] = [] - if (isWorkflowEntryNode(currentNode.data.type)) - return true + if (!currentNode) return rootNodes - const checkPreviousNodes = (node: Node) => { - const previousNodes = getBeforeNodeById(node.id) + if (currentNode.parentId) { + const parentNode = nodes.find((node) => node.id === currentNode.parentId) + if (parentNode) { + const parentList = getRootNodesById(parentNode.id) - for (const prevNode of previousNodes) { - if (isWorkflowEntryNode(prevNode.data.type)) - return true - if (checkPreviousNodes(prevNode)) - return true + rootNodes.push(...parentList) + } } - return false - } - - return checkPreviousNodes(currentNode) - }, [collaborativeWorkflow, getBeforeNodeById]) - - const handleOutVarRenameChange = useCallback((nodeId: string, oldValeSelector: ValueSelector, newVarSelector: ValueSelector) => { - const { nodes: allNodes, setNodes } = collaborativeWorkflow.getState() - const affectedNodes = findUsedVarNodes(oldValeSelector, allNodes) - if (affectedNodes.length > 0) { - const newNodes = allNodes.map((node) => { - if (affectedNodes.find(n => n.id === node.id)) - return updateNodeVars(node, oldValeSelector, newVarSelector) + const traverse = (root: Node, callback: (node: Node) => void) => { + if (root) { + const incomers = getIncomers(root, nodes, edges) - return node - }) - setNodes(newNodes) - } - }, [collaborativeWorkflow]) - - const isVarUsedInNodes = useCallback((varSelector: ValueSelector) => { - const nodeId = varSelector[0] - const afterNodes = getAfterNodesInSameBranch(nodeId!) - const effectNodes = findUsedVarNodes(varSelector, afterNodes) - return effectNodes.length > 0 - }, [getAfterNodesInSameBranch]) - - const removeUsedVarInNodes = useCallback((varSelector: ValueSelector) => { - const nodeId = varSelector[0] - const { nodes, setNodes } = collaborativeWorkflow.getState() - const afterNodes = getAfterNodesInSameBranch(nodeId!) - const effectNodes = findUsedVarNodes(varSelector, afterNodes) - if (effectNodes.length > 0) { - const newNodes = nodes.map((node) => { - if (effectNodes.find(n => n.id === node.id)) - return updateNodeVars(node, varSelector, []) - - return node + if (incomers.length) { + incomers.forEach((node) => { + traverse(node, callback) + }) + } else { + callback(root) + } + } + } + traverse(currentNode, (node) => { + rootNodes.push(node) }) - setNodes(newNodes) - } - }, [getAfterNodesInSameBranch, collaborativeWorkflow]) - - const isNodeVarsUsedInNodes = useCallback((node: Node, isChatMode: boolean) => { - const outputVars = getNodeOutputVars(node, isChatMode) - const isUsed = outputVars.some((varSelector) => { - return isVarUsedInNodes(varSelector) - }) - return isUsed - }, [isVarUsedInNodes]) - const getRootNodesById = useCallback((nodeId: string) => { - const { nodes, edges } = collaborativeWorkflow.getState() - const currentNode = nodes.find(node => node.id === nodeId) + const length = rootNodes.length + if (length) return uniqBy(rootNodes, 'id') - const rootNodes: Node[] = [] + return [] + }, + [collaborativeWorkflow], + ) + + const getStartNodes = useCallback( + (nodes: Node[], currentNode?: Node) => { + const { id, parentId } = currentNode || {} + let startNodes: Node[] = [] + + if (parentId) { + const parentNode = nodes.find((node) => node.id === parentId) + if (!parentNode) throw new Error('Parent node not found') + + const startNode = nodes.find( + (node) => node.id === (parentNode.data as IterationNodeType | LoopNodeType).start_node_id, + ) + if (startNode) startNodes = [startNode] + } else { + startNodes = + nodes.filter((node) => nodesMap?.[node.data.type as BlockEnum]?.metaData.isStart) || [] + } - if (!currentNode) - return rootNodes + if (!startNodes.length) startNodes = getRootNodesById(id || '') - if (currentNode.parentId) { - const parentNode = nodes.find(node => node.id === currentNode.parentId) - if (parentNode) { - const parentList = getRootNodesById(parentNode.id) + return startNodes + }, + [nodesMap, getRootNodesById], + ) - rootNodes.push(...parentList) - } - } + const isValidConnection = useCallback( + ({ source, sourceHandle: _sourceHandle, target }: Connection) => { + const { nodes, edges } = collaborativeWorkflow.getState() + const sourceNode: Node = nodes.find((node) => node.id === source)! + const targetNode: Node = nodes.find((node) => node.id === target)! - const traverse = (root: Node, callback: (node: Node) => void) => { - if (root) { - const incomers = getIncomers(root, nodes, edges) + if (sourceNode.type === CUSTOM_NOTE_NODE || targetNode.type === CUSTOM_NOTE_NODE) return false - if (incomers.length) { - incomers.forEach((node) => { - traverse(node, callback) - }) - } - else { - callback(root) - } - } - } - traverse(currentNode, (node) => { - rootNodes.push(node) - }) - - const length = rootNodes.length - if (length) - return uniqBy(rootNodes, 'id') - - return [] - }, [collaborativeWorkflow]) - - const getStartNodes = useCallback((nodes: Node[], currentNode?: Node) => { - const { id, parentId } = currentNode || {} - let startNodes: Node[] = [] - - if (parentId) { - const parentNode = nodes.find(node => node.id === parentId) - if (!parentNode) - throw new Error('Parent node not found') - - const startNode = nodes.find(node => node.id === (parentNode.data as (IterationNodeType | LoopNodeType)).start_node_id) - if (startNode) - startNodes = [startNode] - } - else { - startNodes = nodes.filter(node => nodesMap?.[node.data.type as BlockEnum]?.metaData.isStart) || [] - } - - if (!startNodes.length) - startNodes = getRootNodesById(id || '') - - return startNodes - }, [nodesMap, getRootNodesById]) - - const isValidConnection = useCallback(({ source, sourceHandle: _sourceHandle, target }: Connection) => { - const { nodes, edges } = collaborativeWorkflow.getState() - const sourceNode: Node = nodes.find(node => node.id === source)! - const targetNode: Node = nodes.find(node => node.id === target)! - - if (sourceNode.type === CUSTOM_NOTE_NODE || targetNode.type === CUSTOM_NOTE_NODE) - return false + if (sourceNode.parentId !== targetNode.parentId) return false - if (sourceNode.parentId !== targetNode.parentId) - return false + if (sourceNode && targetNode) { + const sourceNodeCatalogType = getNodeCatalogType(sourceNode.data) + const targetNodeCatalogType = getNodeCatalogType(targetNode.data) + const sourceNodeAvailableNextNodes = getAvailableBlocks( + sourceNodeCatalogType, + !!sourceNode.parentId, + ).availableNextBlocks + const targetNodeAvailablePrevNodes = getAvailableBlocks( + targetNodeCatalogType, + !!targetNode.parentId, + ).availablePrevBlocks - if (sourceNode && targetNode) { - const sourceNodeCatalogType = getNodeCatalogType(sourceNode.data) - const targetNodeCatalogType = getNodeCatalogType(targetNode.data) - const sourceNodeAvailableNextNodes = getAvailableBlocks(sourceNodeCatalogType, !!sourceNode.parentId).availableNextBlocks - const targetNodeAvailablePrevNodes = getAvailableBlocks(targetNodeCatalogType, !!targetNode.parentId).availablePrevBlocks + if (!sourceNodeAvailableNextNodes.includes(targetNodeCatalogType)) return false - if (!sourceNodeAvailableNextNodes.includes(targetNodeCatalogType)) - return false + if (!targetNodeAvailablePrevNodes.includes(sourceNodeCatalogType)) return false + } - if (!targetNodeAvailablePrevNodes.includes(sourceNodeCatalogType)) - return false - } + const hasCycle = (node: Node, visited = new Set()) => { + if (visited.has(node.id)) return false - const hasCycle = (node: Node, visited = new Set()) => { - if (visited.has(node.id)) - return false + visited.add(node.id) - visited.add(node.id) - - for (const outgoer of getOutgoers(node, nodes, edges)) { - if (outgoer.id === source) - return true - if (hasCycle(outgoer, visited)) - return true + for (const outgoer of getOutgoers(node, nodes, edges)) { + if (outgoer.id === source) return true + if (hasCycle(outgoer, visited)) return true + } } - } - return !hasCycle(targetNode) - }, [collaborativeWorkflow, getAvailableBlocks]) + return !hasCycle(targetNode) + }, + [collaborativeWorkflow, getAvailableBlocks], + ) - const getNode = useCallback((nodeId?: string) => { - const { nodes } = collaborativeWorkflow.getState() + const getNode = useCallback( + (nodeId?: string) => { + const { nodes } = collaborativeWorkflow.getState() - return nodes.find(node => node.id === nodeId) || getWorkflowEntryNode(nodes) - }, [collaborativeWorkflow]) + return nodes.find((node) => node.id === nodeId) || getWorkflowEntryNode(nodes) + }, + [collaborativeWorkflow], + ) return { getNodeById, @@ -420,57 +441,51 @@ export const useWorkflow = () => { export const useWorkflowReadOnly = () => { const workflowStore = useWorkflowStore() - const workflowRunningData = useStore(s => s.workflowRunningData) - const canvasReadOnly = useStore(s => s.canvasReadOnly) + const workflowRunningData = useStore((s) => s.workflowRunningData) + const canvasReadOnly = useStore((s) => s.canvasReadOnly) const getWorkflowReadOnly = useCallback(() => { - const { - canvasReadOnly, - workflowRunningData, - } = workflowStore.getState() + const { canvasReadOnly, workflowRunningData } = workflowStore.getState() return canvasReadOnly || workflowRunningData?.result.status === WorkflowRunningStatus.Running }, [workflowStore]) return { - workflowReadOnly: canvasReadOnly || workflowRunningData?.result.status === WorkflowRunningStatus.Running, + workflowReadOnly: + canvasReadOnly || workflowRunningData?.result.status === WorkflowRunningStatus.Running, getWorkflowReadOnly, } } const useNodesReadOnlyBase = (canEdit: boolean) => { const workflowStore = useWorkflowStore() - const canvasReadOnly = useStore(s => s.canvasReadOnly) - const workflowRunningData = useStore(s => s.workflowRunningData) - const historyWorkflowData = useStore(s => s.historyWorkflowData) - const isRestoring = useStore(s => s.isRestoring) + const canvasReadOnly = useStore((s) => s.canvasReadOnly) + const workflowRunningData = useStore((s) => s.workflowRunningData) + const historyWorkflowData = useStore((s) => s.historyWorkflowData) + const isRestoring = useStore((s) => s.isRestoring) const getNodesReadOnly = useCallback((): boolean => { - const { - workflowRunningData, - historyWorkflowData, - isRestoring, - canvasReadOnly, - } = workflowStore.getState() + const { workflowRunningData, historyWorkflowData, isRestoring, canvasReadOnly } = + workflowStore.getState() return !!( - canvasReadOnly - || !canEdit - || workflowRunningData?.result.status === WorkflowRunningStatus.Running - || workflowRunningData?.result.status === WorkflowRunningStatus.Paused - || historyWorkflowData - || isRestoring + canvasReadOnly || + !canEdit || + workflowRunningData?.result.status === WorkflowRunningStatus.Running || + workflowRunningData?.result.status === WorkflowRunningStatus.Paused || + historyWorkflowData || + isRestoring ) }, [workflowStore, canEdit]) return { nodesReadOnly: !!( - canvasReadOnly - || !canEdit - || workflowRunningData?.result.status === WorkflowRunningStatus.Running - || workflowRunningData?.result.status === WorkflowRunningStatus.Paused - || historyWorkflowData - || isRestoring + canvasReadOnly || + !canEdit || + workflowRunningData?.result.status === WorkflowRunningStatus.Running || + workflowRunningData?.result.status === WorkflowRunningStatus.Paused || + historyWorkflowData || + isRestoring ), getNodesReadOnly, } @@ -481,7 +496,7 @@ export const useNodesReadOnlyByCanEdit = (canEdit: boolean) => { } export const useNodesReadOnly = () => { - const canEdit = useHooksStore(s => s.accessControl.canEdit) + const canEdit = useHooksStore((s) => s.accessControl.canEdit) return useNodesReadOnlyBase(canEdit) } @@ -489,18 +504,19 @@ export const useNodesReadOnly = () => { export const useIsNodeInIteration = (iterationId: string) => { const collaborativeWorkflow = useCollaborativeWorkflow() - const isNodeInIteration = useCallback((nodeId: string) => { - const { nodes } = collaborativeWorkflow.getState() - const node = nodes.find(node => node.id === nodeId) + const isNodeInIteration = useCallback( + (nodeId: string) => { + const { nodes } = collaborativeWorkflow.getState() + const node = nodes.find((node) => node.id === nodeId) - if (!node) - return false + if (!node) return false - if (node.parentId === iterationId) - return true + if (node.parentId === iterationId) return true - return false - }, [iterationId, collaborativeWorkflow]) + return false + }, + [iterationId, collaborativeWorkflow], + ) return { isNodeInIteration, } @@ -509,18 +525,19 @@ export const useIsNodeInIteration = (iterationId: string) => { export const useIsNodeInLoop = (loopId: string) => { const collaborativeWorkflow = useCollaborativeWorkflow() - const isNodeInLoop = useCallback((nodeId: string) => { - const { nodes } = collaborativeWorkflow.getState() - const node = nodes.find(node => node.id === nodeId) + const isNodeInLoop = useCallback( + (nodeId: string) => { + const { nodes } = collaborativeWorkflow.getState() + const node = nodes.find((node) => node.id === nodeId) - if (!node) - return false + if (!node) return false - if (node.parentId === loopId) - return true + if (node.parentId === loopId) return true - return false - }, [loopId, collaborativeWorkflow]) + return false + }, + [loopId, collaborativeWorkflow], + ) return { isNodeInLoop, } diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 629c2a2c1b7328..1883ef8c716a4e 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -1,18 +1,11 @@ 'use client' import type { FC } from 'react' -import type { - Viewport, -} from 'reactflow' +import type { Viewport } from 'reactflow' import type { CursorPosition, OnlineUser } from './collaboration/types/collaboration' import type { Shape as HooksStoreShape } from './hooks-store' import type { WorkflowSliceShape } from './store/workflow/workflow-slice' -import type { - ConversationVariable, - Edge, - EnvironmentVariable, - Node, -} from './types' +import type { ConversationVariable, Edge, EnvironmentVariable, Node } from './types' import type { EventEmitterValue } from '@/context/event-emitter' import type { VarInInspect } from '@/types/workflow' import { @@ -26,21 +19,10 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { - useEventListener, -} from 'ahooks' +import { useEventListener } from 'ahooks' import { isEqual } from 'es-toolkit/predicate' import { setAutoFreeze } from 'immer' -import { - Fragment, - memo, - Suspense, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { Fragment, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ReactFlow, { Background, @@ -70,11 +52,7 @@ import { CommentIcon } from './comment/comment-icon' import { CommentInput } from './comment/comment-input' import { CommentCursor } from './comment/cursor' import { CommentThread } from './comment/thread' -import { - CUSTOM_EDGE, - CUSTOM_NODE, - WORKFLOW_DATA_UPDATE, -} from './constants' +import { CUSTOM_EDGE, CUSTOM_NODE, WORKFLOW_DATA_UPDATE } from './constants' import CustomConnectionLine from './custom-connection-line' import CustomEdge from './custom-edge' import DatasetsDetailProvider from './datasets-detail-store/provider' @@ -110,15 +88,9 @@ import { WorkflowLocalStorageBridge } from './persistence/local-storage-bridge' import { useWorkflowHotkeys } from './shortcuts/use-workflow-hotkeys' import CustomSimpleNode from './simple-node' import { CUSTOM_SIMPLE_NODE } from './simple-node/constants' -import { - useStore, - useWorkflowStore, -} from './store/workflow' +import { useStore, useWorkflowStore } from './store/workflow' import SyncingDataModal from './syncing-data-modal' -import { - ControlMode, - WorkflowRunningStatus, -} from './types' +import { ControlMode, WorkflowRunningStatus } from './types' import { setupScrollToNodeListener } from './utils/node-navigation' import { WorkflowContextmenu } from './workflow-contextmenu' import 'reactflow/dist/style.css' @@ -157,634 +129,649 @@ export type WorkflowProps = { onlineUsers?: OnlineUser[] } -const CommentPlacementPreview = memo(({ - onSubmit, - onCancel, -}: { - onSubmit: (content: string, mentionedUserIds: string[]) => void - onCancel: () => void -}) => { - const isCommentPlacing = useStore(s => s.isCommentPlacing) - const pendingComment = useStore(s => s.pendingComment) - const mousePosition = useStore(s => s.mousePosition) +const CommentPlacementPreview = memo( + ({ + onSubmit, + onCancel, + }: { + onSubmit: (content: string, mentionedUserIds: string[]) => void + onCancel: () => void + }) => { + const isCommentPlacing = useStore((s) => s.isCommentPlacing) + const pendingComment = useStore((s) => s.pendingComment) + const mousePosition = useStore((s) => s.mousePosition) + + if (!isCommentPlacing || pendingComment) return null + + return ( + <CommentInput + position={{ + x: mousePosition.elementX, + y: mousePosition.elementY, + }} + onSubmit={onSubmit} + onCancel={onCancel} + autoFocus={false} + disabled + /> + ) + }, +) - if (!isCommentPlacing || pendingComment) - return null +CommentPlacementPreview.displayName = 'CommentPlacementPreview' - return ( - <CommentInput - position={{ - x: mousePosition.elementX, - y: mousePosition.elementY, - }} - onSubmit={onSubmit} - onCancel={onCancel} - autoFocus={false} - disabled - /> - ) -}) +export const Workflow: FC<WorkflowProps> = memo( + ({ + nodes: originalNodes, + edges: originalEdges, + viewport, + children, + onWorkflowDataUpdate, + cursors, + myUserId, + onlineUsers, + }) => { + const { t } = useTranslation() + const workflowContainerRef = useRef<HTMLDivElement>(null) + const workflowStore = useWorkflowStore() + const reactflow = useReactFlow() + const store = useStoreApi() + const [isMouseOverCanvas, setIsMouseOverCanvas] = useState(false) + const [nodes, setNodes] = useNodesState(originalNodes) + const [edges, setEdges] = useEdgesState(originalEdges) + const controlMode = useStore((s) => s.controlMode) + const nodeAnimation = useStore((s) => s.nodeAnimation) + const showConfirm = useStore((s) => s.showConfirm) + const workflowCanvasHeight = useStore((s) => s.workflowCanvasHeight) + const bottomPanelHeight = useStore((s) => s.bottomPanelHeight) + const setWorkflowCanvasWidth = useStore((s) => s.setWorkflowCanvasWidth) + const setWorkflowCanvasHeight = useStore((s) => s.setWorkflowCanvasHeight) + const workflowCanvasSizeRef = useRef<{ width?: number; height?: number }>({}) + const workflowCanvasResizeFrameRef = useRef<number | undefined>(undefined) + const controlHeight = useMemo(() => { + if (!workflowCanvasHeight) return '100%' + return workflowCanvasHeight - bottomPanelHeight + }, [workflowCanvasHeight, bottomPanelHeight]) + + // update workflow Canvas width and height + useEffect(() => { + if (workflowContainerRef.current) { + const updateWorkflowCanvasSize = (width: number, height: number) => { + if ( + workflowCanvasSizeRef.current.width === width && + workflowCanvasSizeRef.current.height === height + ) + return -CommentPlacementPreview.displayName = 'CommentPlacementPreview' + workflowCanvasSizeRef.current = { width, height } + if (workflowCanvasResizeFrameRef.current) + cancelAnimationFrame(workflowCanvasResizeFrameRef.current) -export const Workflow: FC<WorkflowProps> = memo(({ - nodes: originalNodes, - edges: originalEdges, - viewport, - children, - onWorkflowDataUpdate, - cursors, - myUserId, - onlineUsers, -}) => { - const { t } = useTranslation() - const workflowContainerRef = useRef<HTMLDivElement>(null) - const workflowStore = useWorkflowStore() - const reactflow = useReactFlow() - const store = useStoreApi() - const [isMouseOverCanvas, setIsMouseOverCanvas] = useState(false) - const [nodes, setNodes] = useNodesState(originalNodes) - const [edges, setEdges] = useEdgesState(originalEdges) - const controlMode = useStore(s => s.controlMode) - const nodeAnimation = useStore(s => s.nodeAnimation) - const showConfirm = useStore(s => s.showConfirm) - const workflowCanvasHeight = useStore(s => s.workflowCanvasHeight) - const bottomPanelHeight = useStore(s => s.bottomPanelHeight) - const setWorkflowCanvasWidth = useStore(s => s.setWorkflowCanvasWidth) - const setWorkflowCanvasHeight = useStore(s => s.setWorkflowCanvasHeight) - const workflowCanvasSizeRef = useRef<{ width?: number, height?: number }>({}) - const workflowCanvasResizeFrameRef = useRef<number | undefined>(undefined) - const controlHeight = useMemo(() => { - if (!workflowCanvasHeight) - return '100%' - return workflowCanvasHeight - bottomPanelHeight - }, [workflowCanvasHeight, bottomPanelHeight]) - - // update workflow Canvas width and height - useEffect(() => { - if (workflowContainerRef.current) { - const updateWorkflowCanvasSize = (width: number, height: number) => { - if (workflowCanvasSizeRef.current.width === width && workflowCanvasSizeRef.current.height === height) - return - - workflowCanvasSizeRef.current = { width, height } - if (workflowCanvasResizeFrameRef.current) - cancelAnimationFrame(workflowCanvasResizeFrameRef.current) - - workflowCanvasResizeFrameRef.current = requestAnimationFrame(() => { - workflowCanvasResizeFrameRef.current = undefined - setWorkflowCanvasWidth(width) - setWorkflowCanvasHeight(height) + workflowCanvasResizeFrameRef.current = requestAnimationFrame(() => { + workflowCanvasResizeFrameRef.current = undefined + setWorkflowCanvasWidth(width) + setWorkflowCanvasHeight(height) + }) + } + + const resizeContainerObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { inlineSize, blockSize } = entry.borderBoxSize[0]! + updateWorkflowCanvasSize(inlineSize, blockSize) + } }) + resizeContainerObserver.observe(workflowContainerRef.current) + return () => { + if (workflowCanvasResizeFrameRef.current) { + cancelAnimationFrame(workflowCanvasResizeFrameRef.current) + workflowCanvasResizeFrameRef.current = undefined + } + resizeContainerObserver.disconnect() + } } + }, [setWorkflowCanvasHeight, setWorkflowCanvasWidth]) + + const { + setShowConfirm, + setControlPromptEditorRerenderKey, + setSyncWorkflowDraftHash, + setNodes: setNodesInStore, + } = workflowStore.getState() + const currentNodes = useNodes() + const setNodesOnlyChangeWithData = useCallback( + (nodes: Node[]) => { + const nodesData = nodes.map((node) => ({ + id: node.id, + data: node.data, + })) + const oldData = workflowStore.getState().nodes.map((node) => ({ + id: node.id, + data: node.data, + })) + if (!isEqual(oldData, nodesData)) setNodesInStore(nodes) + }, + [setNodesInStore, workflowStore], + ) + useEffect(() => { + setNodesOnlyChangeWithData(currentNodes as Node[]) + }, [currentNodes, setNodesOnlyChangeWithData]) + useEffect(() => { + return collaborationManager.onGraphImport( + ({ nodes: importedNodes, edges: importedEdges }) => { + if (!isEqual(nodes, importedNodes)) { + setNodes(importedNodes) + store.getState().setNodes(importedNodes) + } + if (!isEqual(edges, importedEdges)) { + setEdges(importedEdges) + store.getState().setEdges(importedEdges) + } + }, + ) + }, [edges, nodes, setEdges, setNodes, store]) - const resizeContainerObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const { inlineSize, blockSize } = entry.borderBoxSize[0]! - updateWorkflowCanvasSize(inlineSize, blockSize) - } + useEffect(() => { + return collaborationManager.onHistoryAction((_) => { + toast.info(t(($) => $['collaboration.historyAction.generic'], { ns: 'workflow' })) }) - resizeContainerObserver.observe(workflowContainerRef.current) - return () => { - if (workflowCanvasResizeFrameRef.current) { - cancelAnimationFrame(workflowCanvasResizeFrameRef.current) - workflowCanvasResizeFrameRef.current = undefined - } - resizeContainerObserver.disconnect() - } - } - }, [setWorkflowCanvasHeight, setWorkflowCanvasWidth]) - - const { - setShowConfirm, - setControlPromptEditorRerenderKey, - setSyncWorkflowDraftHash, - setNodes: setNodesInStore, - } = workflowStore.getState() - const currentNodes = useNodes() - const setNodesOnlyChangeWithData = useCallback((nodes: Node[]) => { - const nodesData = nodes.map(node => ({ - id: node.id, - data: node.data, - })) - const oldData = workflowStore.getState().nodes.map(node => ({ - id: node.id, - data: node.data, - })) - if (!isEqual(oldData, nodesData)) - setNodesInStore(nodes) - }, [setNodesInStore, workflowStore]) - useEffect(() => { - setNodesOnlyChangeWithData(currentNodes as Node[]) - }, [currentNodes, setNodesOnlyChangeWithData]) - useEffect(() => { - return collaborationManager.onGraphImport(({ nodes: importedNodes, edges: importedEdges }) => { - if (!isEqual(nodes, importedNodes)) { - setNodes(importedNodes) - store.getState().setNodes(importedNodes) - } - if (!isEqual(edges, importedEdges)) { - setEdges(importedEdges) - store.getState().setEdges(importedEdges) + }, [t]) + + useEffect(() => { + return collaborationManager.onRestoreIntent((data) => { + toast.info( + t(($) => $['versionHistory.action.restoreInProgress'], { + ns: 'workflow', + userName: data.initiatorName, + versionName: data.versionName || data.versionId, + }), + ) + }) + }, [t]) + + const { handleSyncWorkflowDraft, syncWorkflowDraftWhenPageClose } = useNodesSyncDraft() + const { workflowReadOnly } = useWorkflowReadOnly() + const { nodesReadOnly } = useNodesReadOnly() + const { eventEmitter } = useEventEmitterContextContext() + const { + comments, + pendingComment, + activeComment, + activeCommentLoading, + replySubmitting, + replyUpdating, + handleCommentSubmit, + handleCommentCancel, + handleCommentIconClick, + handleActiveCommentClose, + handleCommentResolve, + handleCommentDelete, + handleCommentUpdate, + handleCommentReply, + handleCommentReplyUpdate, + handleCommentReplyDelete, + handleCommentPositionUpdate, + } = useWorkflowComment() + const showUserComments = useStore((s) => s.showUserComments) + const showUserCursors = useStore((s) => s.showUserCursors) + const showResolvedComments = useStore((s) => s.showResolvedComments) + const isCommentPreviewHovering = useStore((s) => s.isCommentPreviewHovering) + const isCommentPlacing = useStore((s) => s.isCommentPlacing) + const setCommentPlacing = useStore((s) => s.setCommentPlacing) + const setCommentQuickAdd = useStore((s) => s.setCommentQuickAdd) + const setPendingCommentState = useStore((s) => s.setPendingComment) + const isCommentInputActive = Boolean(pendingComment) || isCommentPlacing + const visibleComments = useMemo(() => { + if (showResolvedComments) return comments + return comments.filter((comment) => !comment.resolved) + }, [comments, showResolvedComments]) + const handleVisibleCommentNavigate = useCallback( + (direction: 'prev' | 'next') => { + if (!activeComment) return + const idx = visibleComments.findIndex((comment) => comment.id === activeComment.id) + if (idx === -1) return + const target = direction === 'prev' ? visibleComments[idx - 1] : visibleComments[idx + 1] + if (target) handleCommentIconClick(target) + }, + [activeComment, handleCommentIconClick, visibleComments], + ) + + eventEmitter?.useSubscription((v: EventEmitterValue) => { + if (typeof v === 'object' && v.type === WORKFLOW_DATA_UPDATE) { + const payload = v.payload as WorkflowDataUpdatePayload + setNodes(payload.nodes) + store.getState().setNodes(payload.nodes) + setEdges(payload.edges) + workflowStore.setState({ contextMenuTarget: undefined }) + + if (payload.viewport) reactflow.setViewport(payload.viewport) + + if (payload.hash) setSyncWorkflowDraftHash(payload.hash) + + onWorkflowDataUpdate?.(payload) + + setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) } }) - }, [edges, nodes, setEdges, setNodes, store]) - - useEffect(() => { - return collaborationManager.onHistoryAction((_) => { - toast.info(t($ => $['collaboration.historyAction.generic'], { ns: 'workflow' })) - }) - }, [t]) - - useEffect(() => { - return collaborationManager.onRestoreIntent((data) => { - toast.info(t($ => $['versionHistory.action.restoreInProgress'], { - ns: 'workflow', - userName: data.initiatorName, - versionName: data.versionName || data.versionId, - })) - }) - }, [t]) - - const { - handleSyncWorkflowDraft, - syncWorkflowDraftWhenPageClose, - } = useNodesSyncDraft() - const { workflowReadOnly } = useWorkflowReadOnly() - const { nodesReadOnly } = useNodesReadOnly() - const { eventEmitter } = useEventEmitterContextContext() - const { - comments, - pendingComment, - activeComment, - activeCommentLoading, - replySubmitting, - replyUpdating, - handleCommentSubmit, - handleCommentCancel, - handleCommentIconClick, - handleActiveCommentClose, - handleCommentResolve, - handleCommentDelete, - handleCommentUpdate, - handleCommentReply, - handleCommentReplyUpdate, - handleCommentReplyDelete, - handleCommentPositionUpdate, - } = useWorkflowComment() - const showUserComments = useStore(s => s.showUserComments) - const showUserCursors = useStore(s => s.showUserCursors) - const showResolvedComments = useStore(s => s.showResolvedComments) - const isCommentPreviewHovering = useStore(s => s.isCommentPreviewHovering) - const isCommentPlacing = useStore(s => s.isCommentPlacing) - const setCommentPlacing = useStore(s => s.setCommentPlacing) - const setCommentQuickAdd = useStore(s => s.setCommentQuickAdd) - const setPendingCommentState = useStore(s => s.setPendingComment) - const isCommentInputActive = Boolean(pendingComment) || isCommentPlacing - const visibleComments = useMemo(() => { - if (showResolvedComments) - return comments - return comments.filter(comment => !comment.resolved) - }, [comments, showResolvedComments]) - const handleVisibleCommentNavigate = useCallback((direction: 'prev' | 'next') => { - if (!activeComment) - return - const idx = visibleComments.findIndex(comment => comment.id === activeComment.id) - if (idx === -1) - return - const target = direction === 'prev' ? visibleComments[idx - 1] : visibleComments[idx + 1] - if (target) - handleCommentIconClick(target) - }, [activeComment, handleCommentIconClick, visibleComments]) - - eventEmitter?.useSubscription((v: EventEmitterValue) => { - if (typeof v === 'object' && v.type === WORKFLOW_DATA_UPDATE) { - const payload = v.payload as WorkflowDataUpdatePayload - setNodes(payload.nodes) - store.getState().setNodes(payload.nodes) - setEdges(payload.edges) - workflowStore.setState({ contextMenuTarget: undefined }) - - if (payload.viewport) - reactflow.setViewport(payload.viewport) - - if (payload.hash) - setSyncWorkflowDraftHash(payload.hash) - - onWorkflowDataUpdate?.(payload) - - setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) - } - }) - useEffect(() => { - setAutoFreeze(false) + useEffect(() => { + setAutoFreeze(false) - return () => { - setAutoFreeze(true) - } - }, []) + return () => { + setAutoFreeze(true) + } + }, []) - useEffect(() => { - return () => { - handleSyncWorkflowDraft(true, true) - } - }, [handleSyncWorkflowDraft]) + useEffect(() => { + return () => { + handleSyncWorkflowDraft(true, true) + } + }, [handleSyncWorkflowDraft]) + + const handlePendingCommentPositionChange = useCallback( + (position: NonNullable<WorkflowSliceShape['pendingComment']>) => { + setPendingCommentState(position) + }, + [setPendingCommentState], + ) + + const handleCommentPlacementCancel = useCallback(() => { + setPendingCommentState(null) + setCommentPlacing(false) + setCommentQuickAdd(false) + }, [setCommentPlacing, setCommentQuickAdd, setPendingCommentState]) + + const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() + const handleSyncWorkflowDraftWhenPageClose = useCallback(() => { + if (document.visibilityState === 'hidden') { + syncWorkflowDraftWhenPageClose() + return + } - const handlePendingCommentPositionChange = useCallback((position: NonNullable<WorkflowSliceShape['pendingComment']>) => { - setPendingCommentState(position) - }, [setPendingCommentState]) + if (document.visibilityState === 'visible') { + const { isListening, workflowRunningData } = workflowStore.getState() + const status = workflowRunningData?.result?.status + // Avoid resetting UI state when user comes back while a run is active or listening for triggers + if (isListening || status === WorkflowRunningStatus.Running) return - const handleCommentPlacementCancel = useCallback(() => { - setPendingCommentState(null) - setCommentPlacing(false) - setCommentQuickAdd(false) - }, [setCommentPlacing, setCommentQuickAdd, setPendingCommentState]) + setTimeout(() => handleRefreshWorkflowDraft(), 500) + } + }, [syncWorkflowDraftWhenPageClose, handleRefreshWorkflowDraft, workflowStore]) - const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() - const handleSyncWorkflowDraftWhenPageClose = useCallback(() => { - if (document.visibilityState === 'hidden') { + // Also add beforeunload handler as additional safety net for tab close + const handleBeforeUnload = useCallback(() => { syncWorkflowDraftWhenPageClose() - return - } - - if (document.visibilityState === 'visible') { - const { isListening, workflowRunningData } = workflowStore.getState() - const status = workflowRunningData?.result?.status - // Avoid resetting UI state when user comes back while a run is active or listening for triggers - if (isListening || status === WorkflowRunningStatus.Running) - return - - setTimeout(() => handleRefreshWorkflowDraft(), 500) - } - }, [syncWorkflowDraftWhenPageClose, handleRefreshWorkflowDraft, workflowStore]) - - // Also add beforeunload handler as additional safety net for tab close - const handleBeforeUnload = useCallback(() => { - syncWorkflowDraftWhenPageClose() - }, [syncWorkflowDraftWhenPageClose]) - - // Optimized comment deletion using showConfirm - const handleCommentDeleteClick = useCallback((commentId: string) => { - if (!showConfirm) { - setShowConfirm({ - title: t($ => $['comments.confirm.deleteThreadTitle'], { ns: 'workflow' }), - desc: t($ => $['comments.confirm.deleteThreadDesc'], { ns: 'workflow' }), - onConfirm: async () => { - await handleCommentDelete(commentId) - setShowConfirm(undefined) - }, - }) - } - }, [showConfirm, setShowConfirm, handleCommentDelete, t]) - - const handleCommentReplyDeleteClick = useCallback((commentId: string, replyId: string) => { - if (!showConfirm) { - setShowConfirm({ - title: t($ => $['comments.confirm.deleteReplyTitle'], { ns: 'workflow' }), - desc: t($ => $['comments.confirm.deleteReplyDesc'], { ns: 'workflow' }), - onConfirm: async () => { - await handleCommentReplyDelete(commentId, replyId) - setShowConfirm(undefined) - }, - }) - } - }, [showConfirm, setShowConfirm, handleCommentReplyDelete, t]) + }, [syncWorkflowDraftWhenPageClose]) + + // Optimized comment deletion using showConfirm + const handleCommentDeleteClick = useCallback( + (commentId: string) => { + if (!showConfirm) { + setShowConfirm({ + title: t(($) => $['comments.confirm.deleteThreadTitle'], { ns: 'workflow' }), + desc: t(($) => $['comments.confirm.deleteThreadDesc'], { ns: 'workflow' }), + onConfirm: async () => { + await handleCommentDelete(commentId) + setShowConfirm(undefined) + }, + }) + } + }, + [showConfirm, setShowConfirm, handleCommentDelete, t], + ) + + const handleCommentReplyDeleteClick = useCallback( + (commentId: string, replyId: string) => { + if (!showConfirm) { + setShowConfirm({ + title: t(($) => $['comments.confirm.deleteReplyTitle'], { ns: 'workflow' }), + desc: t(($) => $['comments.confirm.deleteReplyDesc'], { ns: 'workflow' }), + onConfirm: async () => { + await handleCommentReplyDelete(commentId, replyId) + setShowConfirm(undefined) + }, + }) + } + }, + [showConfirm, setShowConfirm, handleCommentReplyDelete, t], + ) - useEffect(() => { - document.addEventListener('visibilitychange', handleSyncWorkflowDraftWhenPageClose) - window.addEventListener('beforeunload', handleBeforeUnload) + useEffect(() => { + document.addEventListener('visibilitychange', handleSyncWorkflowDraftWhenPageClose) + window.addEventListener('beforeunload', handleBeforeUnload) - return () => { - document.removeEventListener('visibilitychange', handleSyncWorkflowDraftWhenPageClose) - window.removeEventListener('beforeunload', handleBeforeUnload) - } - }, [handleSyncWorkflowDraftWhenPageClose, handleBeforeUnload]) - - useEventListener('keydown', (e) => { - if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) - e.preventDefault() - if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey)) - e.preventDefault() - if ((e.key === 'y' || e.key === 'Y') && (e.ctrlKey || e.metaKey)) - e.preventDefault() - if ((e.key === 's' || e.key === 'S') && (e.ctrlKey || e.metaKey)) - e.preventDefault() - }) - useEventListener('mousemove', (e) => { - const containerClientRect = workflowContainerRef.current?.getBoundingClientRect() - - if (containerClientRect) { - workflowStore.setState({ - mousePosition: { - pageX: e.clientX, - pageY: e.clientY, - elementX: e.clientX - containerClientRect.left, - elementY: e.clientY - containerClientRect.top, - }, - }) - const target = e.target as HTMLElement - const onPane = !!target?.closest('.react-flow__pane') - setIsMouseOverCanvas(onPane) - } - }) + return () => { + document.removeEventListener('visibilitychange', handleSyncWorkflowDraftWhenPageClose) + window.removeEventListener('beforeunload', handleBeforeUnload) + } + }, [handleSyncWorkflowDraftWhenPageClose, handleBeforeUnload]) - // Prevent browser zoom interactions from hijacking gestures meant for the workflow canvas - useEffect(() => { - const preventBrowserZoom = (event: WheelEvent) => { - if (!isCommentPreviewHovering && !isCommentInputActive) - return + useEventListener('keydown', (e) => { + if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) e.preventDefault() + if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey)) e.preventDefault() + if ((e.key === 'y' || e.key === 'Y') && (e.ctrlKey || e.metaKey)) e.preventDefault() + if ((e.key === 's' || e.key === 'S') && (e.ctrlKey || e.metaKey)) e.preventDefault() + }) + useEventListener('mousemove', (e) => { + const containerClientRect = workflowContainerRef.current?.getBoundingClientRect() + + if (containerClientRect) { + workflowStore.setState({ + mousePosition: { + pageX: e.clientX, + pageY: e.clientY, + elementX: e.clientX - containerClientRect.left, + elementY: e.clientY - containerClientRect.top, + }, + }) + const target = e.target as HTMLElement + const onPane = !!target?.closest('.react-flow__pane') + setIsMouseOverCanvas(onPane) + } + }) - if (event.ctrlKey || event.metaKey) - event.preventDefault() - } + // Prevent browser zoom interactions from hijacking gestures meant for the workflow canvas + useEffect(() => { + const preventBrowserZoom = (event: WheelEvent) => { + if (!isCommentPreviewHovering && !isCommentInputActive) return - const preventGestureZoom = (event: Event) => { - if (!isCommentPreviewHovering && !isCommentInputActive) - return + if (event.ctrlKey || event.metaKey) event.preventDefault() + } - event.preventDefault() - } + const preventGestureZoom = (event: Event) => { + if (!isCommentPreviewHovering && !isCommentInputActive) return - window.addEventListener('wheel', preventBrowserZoom, { passive: false }) - const gestureEvents: Array<'gesturestart' | 'gesturechange' | 'gestureend'> = ['gesturestart', 'gesturechange', 'gestureend'] - gestureEvents.forEach((eventName) => { - window.addEventListener(eventName, preventGestureZoom, { passive: false }) - }) + event.preventDefault() + } - return () => { - window.removeEventListener('wheel', preventBrowserZoom) + window.addEventListener('wheel', preventBrowserZoom, { passive: false }) + const gestureEvents: Array<'gesturestart' | 'gesturechange' | 'gestureend'> = [ + 'gesturestart', + 'gesturechange', + 'gestureend', + ] gestureEvents.forEach((eventName) => { - window.removeEventListener(eventName, preventGestureZoom) + window.addEventListener(eventName, preventGestureZoom, { passive: false }) }) - } - }, [isCommentPreviewHovering, isCommentInputActive]) - - const { - handleNodeDragStart, - handleNodeDrag, - handleNodeDragStop, - handleNodeEnter, - handleNodeLeave, - handleNodeClick, - handleNodeConnect, - handleNodeConnectStart, - handleNodeConnectEnd, - handleNodeContextMenu, - handleHistoryBack, - handleHistoryForward, - } = useNodesInteractions() - const { - handleEdgeEnter, - handleEdgeLeave, - handleEdgesChange, - handleEdgeContextMenu, - } = useEdgesInteractions() - const { - handleSelectionStart, - handleSelectionChange, - handleSelectionDrag, - handleSelectionContextMenu, - } = useSelectionInteractions() - const { - handlePaneContextMenu, - } = usePanelInteractions() - const { - isValidConnection, - } = useWorkflow() - - useOnViewportChange({ - onEnd: () => { - handleSyncWorkflowDraft() - }, - }) - - useWorkflowHotkeys() - // Initialize workflow node search functionality - useWorkflowSearch() - - // Set up scroll to node event listener using the utility function - useEffect(() => { - return setupScrollToNodeListener(nodes, reactflow) - }, [nodes, reactflow]) - - const { schemaTypeDefinitions } = useMatchSchemaType() - const { fetchInspectVars } = useSetWorkflowVarsWithValue() - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() - const dataSourceList = useStore(s => s.dataSourceList) - // buildInTools, customTools, workflowTools, mcpTools, dataSourceList - const configsMap = useHooksStore(s => s.configsMap) - const [isLoadedVars, setIsLoadedVars] = useState(false) - const [vars, setVars] = useState<VarInInspect[]>([]) - useEffect(() => { - (async () => { - if (!configsMap?.flowType || !configsMap?.flowId) - return - const data = await fetchAllInspectVars(configsMap.flowType, configsMap.flowId) - setVars(data) - setIsLoadedVars(true) - })() - }, [configsMap?.flowType, configsMap?.flowId]) - useEffect(() => { - if (schemaTypeDefinitions && isLoadedVars) { - fetchInspectVars({ - passInVars: true, - vars, - passedInAllPluginInfoList: { - buildInTools: buildInTools || [], - customTools: customTools || [], - workflowTools: workflowTools || [], - mcpTools: mcpTools || [], - dataSourceList: dataSourceList ?? [], - }, - passedInSchemaTypeDefinitions: schemaTypeDefinitions, - }) - } - }, [schemaTypeDefinitions, fetchInspectVars, isLoadedVars, vars, customTools, buildInTools, workflowTools, mcpTools, dataSourceList]) - if (IS_DEV) { - store.getState().onError = (code, message) => { - if (code === '002') - return - console.warn(message) + return () => { + window.removeEventListener('wheel', preventBrowserZoom) + gestureEvents.forEach((eventName) => { + window.removeEventListener(eventName, preventGestureZoom) + }) + } + }, [isCommentPreviewHovering, isCommentInputActive]) + + const { + handleNodeDragStart, + handleNodeDrag, + handleNodeDragStop, + handleNodeEnter, + handleNodeLeave, + handleNodeClick, + handleNodeConnect, + handleNodeConnectStart, + handleNodeConnectEnd, + handleNodeContextMenu, + handleHistoryBack, + handleHistoryForward, + } = useNodesInteractions() + const { handleEdgeEnter, handleEdgeLeave, handleEdgesChange, handleEdgeContextMenu } = + useEdgesInteractions() + const { + handleSelectionStart, + handleSelectionChange, + handleSelectionDrag, + handleSelectionContextMenu, + } = useSelectionInteractions() + const { handlePaneContextMenu } = usePanelInteractions() + const { isValidConnection } = useWorkflow() + + useOnViewportChange({ + onEnd: () => { + handleSyncWorkflowDraft() + }, + }) + + useWorkflowHotkeys() + // Initialize workflow node search functionality + useWorkflowSearch() + + // Set up scroll to node event listener using the utility function + useEffect(() => { + return setupScrollToNodeListener(nodes, reactflow) + }, [nodes, reactflow]) + + const { schemaTypeDefinitions } = useMatchSchemaType() + const { fetchInspectVars } = useSetWorkflowVarsWithValue() + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() + const dataSourceList = useStore((s) => s.dataSourceList) + // buildInTools, customTools, workflowTools, mcpTools, dataSourceList + const configsMap = useHooksStore((s) => s.configsMap) + const [isLoadedVars, setIsLoadedVars] = useState(false) + const [vars, setVars] = useState<VarInInspect[]>([]) + useEffect(() => { + ;(async () => { + if (!configsMap?.flowType || !configsMap?.flowId) return + const data = await fetchAllInspectVars(configsMap.flowType, configsMap.flowId) + setVars(data) + setIsLoadedVars(true) + })() + }, [configsMap?.flowType, configsMap?.flowId]) + useEffect(() => { + if (schemaTypeDefinitions && isLoadedVars) { + fetchInspectVars({ + passInVars: true, + vars, + passedInAllPluginInfoList: { + buildInTools: buildInTools || [], + customTools: customTools || [], + workflowTools: workflowTools || [], + mcpTools: mcpTools || [], + dataSourceList: dataSourceList ?? [], + }, + passedInSchemaTypeDefinitions: schemaTypeDefinitions, + }) + } + }, [ + schemaTypeDefinitions, + fetchInspectVars, + isLoadedVars, + vars, + customTools, + buildInTools, + workflowTools, + mcpTools, + dataSourceList, + ]) + + if (IS_DEV) { + store.getState().onError = (code, message) => { + if (code === '002') return + console.warn(message) + } } - } - return ( - <div - id="workflow-container" - className={cn( - 'relative isolate h-full w-full min-w-[960px] overflow-hidden', - workflowReadOnly && 'workflow-panel-animation', - nodeAnimation && 'workflow-node-animation', - )} - ref={workflowContainerRef} - > - <CandidateNode /> - <CommentManager /> + return ( <div - className="pointer-events-none absolute top-0 left-0 z-10 flex w-12 items-center justify-center p-1 pl-2" - style={{ height: controlHeight }} + id="workflow-container" + className={cn( + 'relative isolate h-full w-full min-w-[960px] overflow-hidden', + workflowReadOnly && 'workflow-panel-animation', + nodeAnimation && 'workflow-node-animation', + )} + ref={workflowContainerRef} > - <Control /> - </div> - <Operator handleRedo={handleHistoryForward} handleUndo={handleHistoryBack} /> - <HelpLine /> - <AlertDialog open={!!showConfirm} onOpenChange={open => !open && setShowConfirm(undefined)}> - <AlertDialogContent> - <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> - <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {showConfirm?.title} - </AlertDialogTitle> - {showConfirm?.desc && ( - <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {showConfirm.desc} - </AlertDialogDescription> - )} - </div> - <AlertDialogActions> - <AlertDialogCancelButton>{t($ => $['operation.cancel'], { ns: 'common' })}</AlertDialogCancelButton> - <AlertDialogConfirmButton onClick={showConfirm?.onConfirm}> - {t($ => $['operation.confirm'], { ns: 'common' })} - </AlertDialogConfirmButton> - </AlertDialogActions> - </AlertDialogContent> - </AlertDialog> - {controlMode === ControlMode.Comment && isMouseOverCanvas && ( - <CommentCursor /> - )} - <CommentPlacementPreview - onSubmit={handleCommentSubmit} - onCancel={handleCommentPlacementCancel} - /> - {pendingComment && ( - <CommentInput - position={{ - x: pendingComment.elementX, - y: pendingComment.elementY, - }} + <CandidateNode /> + <CommentManager /> + <div + className="pointer-events-none absolute top-0 left-0 z-10 flex w-12 items-center justify-center p-1 pl-2" + style={{ height: controlHeight }} + > + <Control /> + </div> + <Operator handleRedo={handleHistoryForward} handleUndo={handleHistoryBack} /> + <HelpLine /> + <AlertDialog + open={!!showConfirm} + onOpenChange={(open) => !open && setShowConfirm(undefined)} + > + <AlertDialogContent> + <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> + <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> + {showConfirm?.title} + </AlertDialogTitle> + {showConfirm?.desc && ( + <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> + {showConfirm.desc} + </AlertDialogDescription> + )} + </div> + <AlertDialogActions> + <AlertDialogCancelButton> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </AlertDialogCancelButton> + <AlertDialogConfirmButton onClick={showConfirm?.onConfirm}> + {t(($) => $['operation.confirm'], { ns: 'common' })} + </AlertDialogConfirmButton> + </AlertDialogActions> + </AlertDialogContent> + </AlertDialog> + {controlMode === ControlMode.Comment && isMouseOverCanvas && <CommentCursor />} + <CommentPlacementPreview onSubmit={handleCommentSubmit} - onCancel={handleCommentCancel} - onPositionChange={handlePendingCommentPositionChange} + onCancel={handleCommentPlacementCancel} /> - )} - {visibleComments.map((comment, index) => { - const isActive = activeComment?.id === comment.id - - if (isActive && activeComment) { - const canGoPrev = index > 0 - const canGoNext = index < visibleComments.length - 1 - return ( - <Fragment key={comment.id}> - <CommentIcon - key={`${comment.id}-icon`} - comment={comment} - onClick={() => handleCommentIconClick(comment)} - isActive={true} - onPositionUpdate={position => handleCommentPositionUpdate(comment.id, position)} - /> - <CommentThread - key={`${comment.id}-thread`} - comment={activeComment} - loading={activeCommentLoading} - replySubmitting={replySubmitting} - replyUpdating={replyUpdating} - onClose={handleActiveCommentClose} - onResolve={() => handleCommentResolve(comment.id)} - onDelete={() => handleCommentDeleteClick(comment.id)} - onCommentEdit={(content, ids) => handleCommentUpdate(comment.id, content, ids ?? [])} - onPrev={canGoPrev ? () => handleVisibleCommentNavigate('prev') : undefined} - onNext={canGoNext ? () => handleVisibleCommentNavigate('next') : undefined} - onReply={(content, ids) => handleCommentReply(comment.id, content, ids ?? [])} - onReplyEdit={(replyId, content, ids) => handleCommentReplyUpdate(comment.id, replyId, content, ids ?? [])} - onReplyDelete={replyId => handleCommentReplyDeleteClick(comment.id, replyId)} - onReplyDeleteDirect={replyId => handleCommentReplyDelete(comment.id, replyId)} - canGoPrev={canGoPrev} - canGoNext={canGoNext} - /> - </Fragment> - ) - } - - return (showUserComments || controlMode === ControlMode.Comment) - ? ( - <CommentIcon - key={comment.id} - comment={comment} - onClick={() => handleCommentIconClick(comment)} - onPositionUpdate={position => handleCommentPositionUpdate(comment.id, position)} - /> - ) - : null - })} - {children} - <WorkflowContextmenu> - <ReactFlow - nodeTypes={nodeTypes} - edgeTypes={edgeTypes} - nodes={nodes} - edges={edges} - className={controlMode === ControlMode.Comment ? 'comment-mode-flow' : ''} - onNodeDragStart={handleNodeDragStart} - onNodeDrag={handleNodeDrag} - onNodeDragStop={handleNodeDragStop} - onNodeMouseEnter={handleNodeEnter} - onNodeMouseLeave={handleNodeLeave} - onNodeClick={handleNodeClick} - onNodeContextMenu={handleNodeContextMenu} - onConnect={handleNodeConnect} - onConnectStart={handleNodeConnectStart} - onConnectEnd={handleNodeConnectEnd} - onEdgeMouseEnter={handleEdgeEnter} - onEdgeMouseLeave={handleEdgeLeave} - onEdgesChange={handleEdgesChange} - onEdgeContextMenu={handleEdgeContextMenu} - onSelectionStart={handleSelectionStart} - onSelectionChange={handleSelectionChange} - onSelectionDrag={handleSelectionDrag} - onPaneContextMenu={handlePaneContextMenu} - onSelectionContextMenu={handleSelectionContextMenu} - connectionLineComponent={CustomConnectionLine} - defaultViewport={viewport} - multiSelectionKeyCode={null} - deleteKeyCode={null} - nodesDraggable={!nodesReadOnly && controlMode !== ControlMode.Comment} - nodesConnectable={!nodesReadOnly} - nodesFocusable={!nodesReadOnly} - edgesFocusable={!nodesReadOnly} - panOnScroll={controlMode === ControlMode.Pointer && !workflowReadOnly} - panOnDrag={controlMode === ControlMode.Hand || [1]} - zoomOnPinch={true} - zoomOnScroll={true} - zoomOnDoubleClick={true} - isValidConnection={isValidConnection} - selectionKeyCode={null} - selectionMode={SelectionMode.Partial} - selectionOnDrag={controlMode === ControlMode.Pointer && !workflowReadOnly} - minZoom={0.25} - > - <Background - gap={[14, 14]} - size={2} - className="bg-workflow-canvas-workflow-bg" - color="var(--color-workflow-canvas-workflow-dot-color)" + {pendingComment && ( + <CommentInput + position={{ + x: pendingComment.elementX, + y: pendingComment.elementY, + }} + onSubmit={handleCommentSubmit} + onCancel={handleCommentCancel} + onPositionChange={handlePendingCommentPositionChange} /> - {showUserCursors && cursors && ( - <UserCursors - cursors={cursors} - myUserId={myUserId || null} - onlineUsers={onlineUsers || []} + )} + {visibleComments.map((comment, index) => { + const isActive = activeComment?.id === comment.id + + if (isActive && activeComment) { + const canGoPrev = index > 0 + const canGoNext = index < visibleComments.length - 1 + return ( + <Fragment key={comment.id}> + <CommentIcon + key={`${comment.id}-icon`} + comment={comment} + onClick={() => handleCommentIconClick(comment)} + isActive={true} + onPositionUpdate={(position) => handleCommentPositionUpdate(comment.id, position)} + /> + <CommentThread + key={`${comment.id}-thread`} + comment={activeComment} + loading={activeCommentLoading} + replySubmitting={replySubmitting} + replyUpdating={replyUpdating} + onClose={handleActiveCommentClose} + onResolve={() => handleCommentResolve(comment.id)} + onDelete={() => handleCommentDeleteClick(comment.id)} + onCommentEdit={(content, ids) => + handleCommentUpdate(comment.id, content, ids ?? []) + } + onPrev={canGoPrev ? () => handleVisibleCommentNavigate('prev') : undefined} + onNext={canGoNext ? () => handleVisibleCommentNavigate('next') : undefined} + onReply={(content, ids) => handleCommentReply(comment.id, content, ids ?? [])} + onReplyEdit={(replyId, content, ids) => + handleCommentReplyUpdate(comment.id, replyId, content, ids ?? []) + } + onReplyDelete={(replyId) => handleCommentReplyDeleteClick(comment.id, replyId)} + onReplyDeleteDirect={(replyId) => handleCommentReplyDelete(comment.id, replyId)} + canGoPrev={canGoPrev} + canGoNext={canGoNext} + /> + </Fragment> + ) + } + + return showUserComments || controlMode === ControlMode.Comment ? ( + <CommentIcon + key={comment.id} + comment={comment} + onClick={() => handleCommentIconClick(comment)} + onPositionUpdate={(position) => handleCommentPositionUpdate(comment.id, position)} /> - )} - </ReactFlow> - </WorkflowContextmenu> - <SyncingDataModal /> - </div> - ) -}) + ) : null + })} + {children} + <WorkflowContextmenu> + <ReactFlow + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + nodes={nodes} + edges={edges} + className={controlMode === ControlMode.Comment ? 'comment-mode-flow' : ''} + onNodeDragStart={handleNodeDragStart} + onNodeDrag={handleNodeDrag} + onNodeDragStop={handleNodeDragStop} + onNodeMouseEnter={handleNodeEnter} + onNodeMouseLeave={handleNodeLeave} + onNodeClick={handleNodeClick} + onNodeContextMenu={handleNodeContextMenu} + onConnect={handleNodeConnect} + onConnectStart={handleNodeConnectStart} + onConnectEnd={handleNodeConnectEnd} + onEdgeMouseEnter={handleEdgeEnter} + onEdgeMouseLeave={handleEdgeLeave} + onEdgesChange={handleEdgesChange} + onEdgeContextMenu={handleEdgeContextMenu} + onSelectionStart={handleSelectionStart} + onSelectionChange={handleSelectionChange} + onSelectionDrag={handleSelectionDrag} + onPaneContextMenu={handlePaneContextMenu} + onSelectionContextMenu={handleSelectionContextMenu} + connectionLineComponent={CustomConnectionLine} + defaultViewport={viewport} + multiSelectionKeyCode={null} + deleteKeyCode={null} + nodesDraggable={!nodesReadOnly && controlMode !== ControlMode.Comment} + nodesConnectable={!nodesReadOnly} + nodesFocusable={!nodesReadOnly} + edgesFocusable={!nodesReadOnly} + panOnScroll={controlMode === ControlMode.Pointer && !workflowReadOnly} + panOnDrag={controlMode === ControlMode.Hand || [1]} + zoomOnPinch={true} + zoomOnScroll={true} + zoomOnDoubleClick={true} + isValidConnection={isValidConnection} + selectionKeyCode={null} + selectionMode={SelectionMode.Partial} + selectionOnDrag={controlMode === ControlMode.Pointer && !workflowReadOnly} + minZoom={0.25} + > + <Background + gap={[14, 14]} + size={2} + className="bg-workflow-canvas-workflow-bg" + color="var(--color-workflow-canvas-workflow-dot-color)" + /> + {showUserCursors && cursors && ( + <UserCursors + cursors={cursors} + myUserId={myUserId || null} + onlineUsers={onlineUsers || []} + /> + )} + </ReactFlow> + </WorkflowContextmenu> + <SyncingDataModal /> + </div> + ) + }, +) type WorkflowWithInnerContextProps = WorkflowProps & { hooksStore?: Partial<HooksStoreShape> @@ -792,30 +779,19 @@ type WorkflowWithInnerContextProps = WorkflowProps & { myUserId?: string | null onlineUsers?: OnlineUser[] } -export const WorkflowWithInnerContext = memo(({ - hooksStore, - cursors, - myUserId, - onlineUsers, - ...restProps -}: WorkflowWithInnerContextProps) => { - return ( - <HooksStoreContextProvider {...hooksStore}> - <Workflow - {...restProps} - cursors={cursors} - myUserId={myUserId} - onlineUsers={onlineUsers} - /> - </HooksStoreContextProvider> - ) -}) - -type WorkflowWithDefaultContextProps - = Pick<WorkflowProps, 'edges' | 'nodes'> - & { - children: React.ReactNode - } +export const WorkflowWithInnerContext = memo( + ({ hooksStore, cursors, myUserId, onlineUsers, ...restProps }: WorkflowWithInnerContextProps) => { + return ( + <HooksStoreContextProvider {...hooksStore}> + <Workflow {...restProps} cursors={cursors} myUserId={myUserId} onlineUsers={onlineUsers} /> + </HooksStoreContextProvider> + ) + }, +) + +type WorkflowWithDefaultContextProps = Pick<WorkflowProps, 'edges' | 'nodes'> & { + children: React.ReactNode +} const WorkflowHistoryStoreInitializer = ({ nodes, @@ -848,16 +824,11 @@ const WorkflowWithDefaultContext = ({ }: WorkflowWithDefaultContextProps) => { return ( <ReactFlowProvider> - <WorkflowHistoryStoreInitializer - nodes={nodes} - edges={edges} - > + <WorkflowHistoryStoreInitializer nodes={nodes} edges={edges}> <Suspense fallback={null}> <WorkflowLocalStorageBridge /> </Suspense> - <DatasetsDetailProvider nodes={nodes}> - {children} - </DatasetsDetailProvider> + <DatasetsDetailProvider nodes={nodes}>{children}</DatasetsDetailProvider> </WorkflowHistoryStoreInitializer> </ReactFlowProvider> ) diff --git a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx index 19cb4ea0e4b62a..df8165d3fc2486 100644 --- a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx @@ -23,7 +23,15 @@ import { ChangeBlockMenuTrigger } from '../change-block-menu-trigger' import { NodeActionsDropdownContent } from '../dropdown-content' vi.mock('@/app/components/workflow/block-selector', () => ({ - default: ({ trigger, onSelect, availableBlocksTypes, showStartTab, ignoreNodeIds, forceEnableStartTab, allowUserInputSelection }: any) => ( + default: ({ + trigger, + onSelect, + availableBlocksTypes, + showStartTab, + ignoreNodeIds, + forceEnableStartTab, + allowUserInputSelection, + }: any) => ( <div> <div>{trigger()}</div> <div>{`available:${(availableBlocksTypes || []).join(',')}`}</div> @@ -31,7 +39,9 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ <div>{`ignore:${(ignoreNodeIds || []).join(',')}`}</div> <div>{`force-start:${String(forceEnableStartTab)}`}</div> <div>{`allow-start:${String(allowUserInputSelection)}`}</div> - <button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button> + <button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}> + select-http + </button> </div> ), })) @@ -130,12 +140,18 @@ describe('node actions menu details', () => { handleNodeSelect, handleNodesCopy, } as unknown as ReturnType<typeof useNodesInteractions>) - mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType<typeof useNodesReadOnly>) - mockUseHooksStore.mockImplementation((selector: any) => selector({ - configsMap: { flowType: FlowType.appFlow }, - accessControl: { canRun: true }, - })) - mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType< + typeof useNodesReadOnly + >) + mockUseHooksStore.mockImplementation((selector: any) => + selector({ + configsMap: { flowType: FlowType.appFlow }, + accessControl: { canRun: true }, + }), + ) + mockUseNodes.mockReturnValue([ + { id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }, + ] as any) mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any) }) @@ -156,7 +172,12 @@ describe('node actions menu details', () => { expect(screen.getByText('ignore:')).toBeInTheDocument() expect(screen.getByText('force-start:false')).toBeInTheDocument() expect(screen.getByText('allow-start:false')).toBeInTheDocument() - expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined) + expect(handleNodeChange).toHaveBeenCalledWith( + 'node-1', + BlockEnum.HttpRequest, + 'source', + undefined, + ) }) it('should expose trigger and start-node specific block selector options', () => { @@ -169,7 +190,9 @@ describe('node actions menu details', () => { availableNextBlocks: [BlockEnum.HttpRequest], } as ReturnType<typeof useAvailableBlocks>) mockUseIsChatMode.mockReturnValueOnce(true) - mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } })) + mockUseHooksStore.mockImplementationOnce((selector: any) => + selector({ configsMap: { flowType: FlowType.appFlow } }), + ) mockUseNodes.mockReturnValueOnce([] as any) const { rerender } = render( @@ -193,8 +216,12 @@ describe('node actions menu details', () => { availablePrevBlocks: [BlockEnum.Code], availableNextBlocks: [], } as ReturnType<typeof useAvailableBlocks>) - mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } })) - mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any) + mockUseHooksStore.mockImplementationOnce((selector: any) => + selector({ configsMap: { flowType: FlowType.ragPipeline } }), + ) + mockUseNodes.mockReturnValueOnce([ + { id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }, + ] as any) rerender( <ChangeBlockMenuTrigger @@ -230,7 +257,10 @@ describe('node actions menu details', () => { expect(handleNodesCopy).toHaveBeenCalledWith('node-1') expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1') expect(handleNodeDelete).toHaveBeenCalledWith('node-1') - expect(screen.getByRole('menuitem', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node') + expect(screen.getByRole('menuitem', { name: 'workflow.panel.helpLink' })).toHaveAttribute( + 'href', + 'https://docs.example.com/node', + ) }) it('should stop the current single run from the run action when the node is running', async () => { @@ -275,7 +305,15 @@ describe('node actions menu details', () => { <DropdownMenuContent> <NodeActionsDropdownContent id="node-2" - data={{ type: BlockEnum.Tool, title: 'Workflow Tool', desc: '', provider_type: 'workflow', provider_id: 'workflow-tool' } as any} + data={ + { + type: BlockEnum.Tool, + title: 'Workflow Tool', + desc: '', + provider_type: 'workflow', + provider_id: 'workflow-tool', + } as any + } onClose={vi.fn()} showHelpLink={false} /> @@ -287,9 +325,14 @@ describe('node actions menu details', () => { }, ) - expect(screen.getByRole('menuitem', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow') + expect(screen.getByRole('menuitem', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute( + 'href', + '/app/app-123/workflow', + ) - mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType<typeof useNodesReadOnly>) + mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType< + typeof useNodesReadOnly + >) mockUseNodeMetaData.mockReturnValueOnce({ isTypeFixed: true, isSingleton: true, diff --git a/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx index 44704d037bc890..99927fa9a73ad7 100644 --- a/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx @@ -35,39 +35,37 @@ const mockUseNodesInteractions = vi.mocked(useNodesInteractions) const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools) -const createQueryResult = <T,>(data: T): UseQueryResult<T, Error> => ({ - data, - error: null, - refetch: vi.fn(), - isError: false, - isPending: false, - isLoading: false, - isSuccess: true, - isFetching: false, - isRefetching: false, - isLoadingError: false, - isRefetchError: false, - isInitialLoading: false, - isPaused: false, - isEnabled: true, - status: 'success', - fetchStatus: 'idle', - dataUpdatedAt: Date.now(), - errorUpdatedAt: 0, - failureCount: 0, - failureReason: null, - errorUpdateCount: 0, - isFetched: true, - isFetchedAfterMount: true, - isPlaceholderData: false, - isStale: false, - promise: Promise.resolve(data), -} as UseQueryResult<T, Error>) - -const renderComponent = ( - showHelpLink: boolean = true, - onOpenChange?: (open: boolean) => void, -) => +const createQueryResult = <T,>(data: T): UseQueryResult<T, Error> => + ({ + data, + error: null, + refetch: vi.fn(), + isError: false, + isPending: false, + isLoading: false, + isSuccess: true, + isFetching: false, + isRefetching: false, + isLoadingError: false, + isRefetchError: false, + isInitialLoading: false, + isPaused: false, + isEnabled: true, + status: 'success', + fetchStatus: 'idle', + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isPlaceholderData: false, + isStale: false, + promise: Promise.resolve(data), + }) as UseQueryResult<T, Error> + +const renderComponent = (showHelpLink: boolean = true, onOpenChange?: (open: boolean) => void) => renderWorkflowFlowComponent( <NodeActionsDropdown id="node-1" diff --git a/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx b/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx index 5285ff5274faf8..8d6e9bacfc8d83 100644 --- a/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx +++ b/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx @@ -1,8 +1,4 @@ -import type { - CommonNodeType, - Node, - OnSelectBlock, -} from '@/app/components/workflow/types' +import type { CommonNodeType, Node, OnSelectBlock } from '@/app/components/workflow/types' import { intersection } from 'es-toolkit/array' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -32,17 +28,19 @@ export function ChangeBlockMenuTrigger({ const { t } = useTranslation() const { handleNodeChange } = useNodesInteractions() const nodeCatalogType = getNodeCatalogType(nodeData) - const { - availablePrevBlocks, - availableNextBlocks, - } = useAvailableBlocks(nodeCatalogType, nodeData.isInIteration || nodeData.isInLoop) + const { availablePrevBlocks, availableNextBlocks } = useAvailableBlocks( + nodeCatalogType, + nodeData.isInIteration || nodeData.isInLoop, + ) const isChatMode = useIsChatMode() - const flowType = useHooksStore(s => s.configsMap?.flowType) + const flowType = useHooksStore((s) => s.configsMap?.flowType) const nodes = useNodes() const hasStartNode = useMemo(() => { - return nodes.some(n => (n.data as CommonNodeType | undefined)?.type === BlockEnum.Start) + return nodes.some((n) => (n.data as CommonNodeType | undefined)?.type === BlockEnum.Start) }, [nodes]) - const showStartTab = flowType !== FlowType.ragPipeline && (!isChatMode || nodeData.type === BlockEnum.Start || !hasStartNode) + const showStartTab = + flowType !== FlowType.ragPipeline && + (!isChatMode || nodeData.type === BlockEnum.Start || !hasStartNode) const ignoreNodeIds = useMemo(() => { if (isTriggerNode(nodeData.type as BlockEnum) || nodeData.type === BlockEnum.Start) return [nodeId] @@ -53,14 +51,16 @@ export function ChangeBlockMenuTrigger({ const availableNodes = useMemo(() => { if (availablePrevBlocks.length && availableNextBlocks.length) return intersection(availablePrevBlocks, availableNextBlocks) - if (availablePrevBlocks.length) - return availablePrevBlocks + if (availablePrevBlocks.length) return availablePrevBlocks return availableNextBlocks }, [availablePrevBlocks, availableNextBlocks]) - const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { - handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue) - }, [handleNodeChange, nodeId, sourceHandle]) + const handleSelect = useCallback<OnSelectBlock>( + (type, pluginDefaultValue) => { + handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue) + }, + [handleNodeChange, nodeId, sourceHandle], + ) const renderTrigger = useCallback(() => { return ( @@ -68,7 +68,7 @@ export function ChangeBlockMenuTrigger({ type="button" className="mx-1 flex h-8 w-[calc(100%-8px)] cursor-pointer items-center rounded-lg border-0 bg-transparent px-2 text-left text-sm text-text-secondary outline-hidden select-none hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden" > - {t($ => $['panel.changeBlock'], { ns: 'workflow' })} + {t(($) => $['panel.changeBlock'], { ns: 'workflow' })} </button> ) }, [t]) diff --git a/web/app/components/workflow/node-actions-menu/context-menu-content.tsx b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx index 72e036174d1736..757c4b896929a4 100644 --- a/web/app/components/workflow/node-actions-menu/context-menu-content.tsx +++ b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx @@ -22,17 +22,15 @@ export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) { const hasEditGroup = !model.nodesReadOnly && !model.isSingleton const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable const singleRunActionLabel = model.isSingleRunning - ? t($ => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' }) - : t($ => $['panel.runThisStep'], { ns: 'workflow' }) + ? t(($) => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' }) + : t(($) => $['panel.runThisStep'], { ns: 'workflow' }) return ( <> {hasRunGroup && ( <ContextMenuGroup> {model.canRun && ( - <ContextMenuItem onClick={model.handleRun}> - {singleRunActionLabel} - </ContextMenuItem> + <ContextMenuItem onClick={model.handleRun}>{singleRunActionLabel}</ContextMenuItem> )} {model.canChangeBlock && ( <ChangeBlockMenuTrigger @@ -43,7 +41,10 @@ export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) { )} </ContextMenuGroup> )} - {hasRunGroup && (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && <ContextMenuSeparator />} + {hasRunGroup && + (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && ( + <ContextMenuSeparator /> + )} {hasEditGroup && ( <ContextMenuGroup> <ContextMenuItem @@ -51,7 +52,7 @@ export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) { onClick={model.handleCopy} > <NodeActionsMenuItemContent shortcut="workflow.copy"> - {t($ => $['common.copy'], { ns: 'workflow' })} + {t(($) => $['common.copy'], { ns: 'workflow' })} </NodeActionsMenuItemContent> </ContextMenuItem> <ContextMenuItem @@ -59,12 +60,14 @@ export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) { onClick={model.handleDuplicate} > <NodeActionsMenuItemContent shortcut="workflow.duplicate"> - {t($ => $['common.duplicate'], { ns: 'workflow' })} + {t(($) => $['common.duplicate'], { ns: 'workflow' })} </NodeActionsMenuItemContent> </ContextMenuItem> </ContextMenuGroup> )} - {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && <ContextMenuSeparator />} + {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && ( + <ContextMenuSeparator /> + )} {hasDeleteGroup && ( <ContextMenuGroup> <ContextMenuItem @@ -72,7 +75,7 @@ export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) { onClick={model.handleDelete} > <NodeActionsMenuItemContent shortcut="workflow.delete"> - {t($ => $['operation.delete'], { ns: 'common' })} + {t(($) => $['operation.delete'], { ns: 'common' })} </NodeActionsMenuItemContent> </ContextMenuItem> </ContextMenuGroup> @@ -80,8 +83,12 @@ export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) { {hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && <ContextMenuSeparator />} {model.workflowAppHref && ( <ContextMenuGroup> - <ContextMenuLinkItem href={model.workflowAppHref} target="_blank" rel="noopener noreferrer"> - {t($ => $['panel.openWorkflow'], { ns: 'workflow' })} + <ContextMenuLinkItem + href={model.workflowAppHref} + target="_blank" + rel="noopener noreferrer" + > + {t(($) => $['panel.openWorkflow'], { ns: 'workflow' })} </ContextMenuLinkItem> </ContextMenuGroup> )} @@ -89,15 +96,15 @@ export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) { {model.helpLinkUri && ( <ContextMenuGroup> <ContextMenuLinkItem href={model.helpLinkUri} target="_blank" rel="noopener noreferrer"> - {t($ => $['panel.helpLink'], { ns: 'workflow' })} + {t(($) => $['panel.helpLink'], { ns: 'workflow' })} </ContextMenuLinkItem> </ContextMenuGroup> )} <ContextMenuSeparator /> <NodeActionsMenuAbout - title={t($ => $['panel.about'], { ns: 'workflow' })} + title={t(($) => $['panel.about'], { ns: 'workflow' })} description={model.about.description} - author={`${t($ => $['panel.createdBy'], { ns: 'workflow' })} ${model.about.author}`} + author={`${t(($) => $['panel.createdBy'], { ns: 'workflow' })} ${model.about.author}`} /> </> ) diff --git a/web/app/components/workflow/node-actions-menu/dropdown-content.tsx b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx index 7efd3fe39b8182..2603c8053614d1 100644 --- a/web/app/components/workflow/node-actions-menu/dropdown-content.tsx +++ b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx @@ -22,17 +22,15 @@ export function NodeActionsDropdownContent(props: NodeActionsMenuProps) { const hasEditGroup = !model.nodesReadOnly && !model.isSingleton const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable const singleRunActionLabel = model.isSingleRunning - ? t($ => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' }) - : t($ => $['panel.runThisStep'], { ns: 'workflow' }) + ? t(($) => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' }) + : t(($) => $['panel.runThisStep'], { ns: 'workflow' }) return ( <> {hasRunGroup && ( <DropdownMenuGroup> {model.canRun && ( - <DropdownMenuItem onClick={model.handleRun}> - {singleRunActionLabel} - </DropdownMenuItem> + <DropdownMenuItem onClick={model.handleRun}>{singleRunActionLabel}</DropdownMenuItem> )} {model.canChangeBlock && ( <ChangeBlockMenuTrigger @@ -43,7 +41,10 @@ export function NodeActionsDropdownContent(props: NodeActionsMenuProps) { )} </DropdownMenuGroup> )} - {hasRunGroup && (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && <DropdownMenuSeparator />} + {hasRunGroup && + (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && ( + <DropdownMenuSeparator /> + )} {hasEditGroup && ( <DropdownMenuGroup> <DropdownMenuItem @@ -51,7 +52,7 @@ export function NodeActionsDropdownContent(props: NodeActionsMenuProps) { onClick={model.handleCopy} > <NodeActionsMenuItemContent shortcut="workflow.copy"> - {t($ => $['common.copy'], { ns: 'workflow' })} + {t(($) => $['common.copy'], { ns: 'workflow' })} </NodeActionsMenuItemContent> </DropdownMenuItem> <DropdownMenuItem @@ -59,12 +60,14 @@ export function NodeActionsDropdownContent(props: NodeActionsMenuProps) { onClick={model.handleDuplicate} > <NodeActionsMenuItemContent shortcut="workflow.duplicate"> - {t($ => $['common.duplicate'], { ns: 'workflow' })} + {t(($) => $['common.duplicate'], { ns: 'workflow' })} </NodeActionsMenuItemContent> </DropdownMenuItem> </DropdownMenuGroup> )} - {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && <DropdownMenuSeparator />} + {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && ( + <DropdownMenuSeparator /> + )} {hasDeleteGroup && ( <DropdownMenuGroup> <DropdownMenuItem @@ -72,7 +75,7 @@ export function NodeActionsDropdownContent(props: NodeActionsMenuProps) { onClick={model.handleDelete} > <NodeActionsMenuItemContent shortcut="workflow.delete"> - {t($ => $['operation.delete'], { ns: 'common' })} + {t(($) => $['operation.delete'], { ns: 'common' })} </NodeActionsMenuItemContent> </DropdownMenuItem> </DropdownMenuGroup> @@ -80,8 +83,12 @@ export function NodeActionsDropdownContent(props: NodeActionsMenuProps) { {hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && <DropdownMenuSeparator />} {model.workflowAppHref && ( <DropdownMenuGroup> - <DropdownMenuLinkItem href={model.workflowAppHref} target="_blank" rel="noopener noreferrer"> - {t($ => $['panel.openWorkflow'], { ns: 'workflow' })} + <DropdownMenuLinkItem + href={model.workflowAppHref} + target="_blank" + rel="noopener noreferrer" + > + {t(($) => $['panel.openWorkflow'], { ns: 'workflow' })} </DropdownMenuLinkItem> </DropdownMenuGroup> )} @@ -89,15 +96,15 @@ export function NodeActionsDropdownContent(props: NodeActionsMenuProps) { {model.helpLinkUri && ( <DropdownMenuGroup> <DropdownMenuLinkItem href={model.helpLinkUri} target="_blank" rel="noopener noreferrer"> - {t($ => $['panel.helpLink'], { ns: 'workflow' })} + {t(($) => $['panel.helpLink'], { ns: 'workflow' })} </DropdownMenuLinkItem> </DropdownMenuGroup> )} <DropdownMenuSeparator /> <NodeActionsMenuAbout - title={t($ => $['panel.about'], { ns: 'workflow' })} + title={t(($) => $['panel.about'], { ns: 'workflow' })} description={model.about.description} - author={`${t($ => $['panel.createdBy'], { ns: 'workflow' })} ${model.about.author}`} + author={`${t(($) => $['panel.createdBy'], { ns: 'workflow' })} ${model.about.author}`} /> </> ) diff --git a/web/app/components/workflow/node-actions-menu/index.tsx b/web/app/components/workflow/node-actions-menu/index.tsx index c8d3466141d830..43df514e1405a2 100644 --- a/web/app/components/workflow/node-actions-menu/index.tsx +++ b/web/app/components/workflow/node-actions-menu/index.tsx @@ -28,10 +28,13 @@ export function NodeActionsDropdown({ const { t } = useTranslation() const [open, setOpen] = useState(false) - const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen) - onOpenChange?.(nextOpen) - }, [onOpenChange]) + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen) + onOpenChange?.(nextOpen) + }, + [onOpenChange], + ) const closeMenu = useCallback(() => { setOpen(false) @@ -39,16 +42,12 @@ export function NodeActionsDropdown({ }, [onOpenChange]) return ( - <DropdownMenu - modal={false} - open={open} - onOpenChange={handleOpenChange} - > + <DropdownMenu modal={false} open={open} onOpenChange={handleOpenChange}> <DropdownMenuTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.more'], { ns: 'common' })} className={cn( 'flex size-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-text-tertiary hover:bg-state-base-hover', 'focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden data-popup-open:bg-state-base-hover', @@ -57,7 +56,7 @@ export function NodeActionsDropdown({ > <span aria-hidden className="i-ri-more-fill size-4" /> </button> - )} + } /> <DropdownMenuContent placement="bottom-end" diff --git a/web/app/components/workflow/node-actions-menu/shared.tsx b/web/app/components/workflow/node-actions-menu/shared.tsx index 7c9d4205dc83c5..9872ba1dac97f2 100644 --- a/web/app/components/workflow/node-actions-menu/shared.tsx +++ b/web/app/components/workflow/node-actions-menu/shared.tsx @@ -35,9 +35,7 @@ export function NodeActionsMenuAbout({ }) { return ( <div className="px-3 py-2 text-xs text-text-tertiary"> - <div className="mb-1 flex h-[22px] items-center font-medium"> - {title.toLocaleUpperCase()} - </div> + <div className="mb-1 flex h-[22px] items-center font-medium">{title.toLocaleUpperCase()}</div> <div className="mb-1 leading-[18px] text-text-secondary">{description}</div> <div className="leading-[18px]">{author}</div> </div> diff --git a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts index db184749303daa..affd9e634608d1 100644 --- a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts +++ b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts @@ -28,15 +28,11 @@ export function useNodeActionsMenuModel({ showHelpLink = true, }: UseNodeActionsMenuModelParams) { const edges = useEdges() - const { - handleNodeDelete, - handleNodesDuplicate, - handleNodeSelect, - handleNodesCopy, - } = useNodesInteractions() + const { handleNodeDelete, handleNodesDuplicate, handleNodeSelect, handleNodesCopy } = + useNodesInteractions() const workflowStore = useWorkflowStore() const { nodesReadOnly } = useNodesReadOnly() - const canRunWorkflow = useHooksStore(s => s.accessControl.canRun) + const canRunWorkflow = useHooksStore((s) => s.accessControl.canRun) const nodeMetaData = useNodeMetaData({ id, data } as Node) const { data: workflowTools } = useAllWorkflowTools() @@ -45,17 +41,16 @@ export function useNodeActionsMenuModel({ const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running const canChangeBlock = !nodeMetaData.isTypeFixed && !nodeMetaData.isUndeletable && !nodesReadOnly const sourceHandle = useMemo(() => { - return edges.find(edge => edge.target === id)?.sourceHandle || 'source' + return edges.find((edge) => edge.target === id)?.sourceHandle || 'source' }, [edges, id]) const workflowAppHref = useMemo(() => { - const isWorkflowTool = data.type === BlockEnum.Tool && data.provider_type === CollectionType.workflow - if (!isWorkflowTool || !workflowTools || !data.provider_id) - return undefined + const isWorkflowTool = + data.type === BlockEnum.Tool && data.provider_type === CollectionType.workflow + if (!isWorkflowTool || !workflowTools || !data.provider_id) return undefined - const workflowTool = workflowTools.find(item => canFindTool(item.id, data.provider_id)) - if (!workflowTool?.workflow_app_id) - return undefined + const workflowTool = workflowTools.find((item) => canFindTool(item.id, data.provider_id)) + if (!workflowTool?.workflow_app_id) return undefined return `/app/${workflowTool.workflow_app_id}/workflow` }, [data.provider_id, data.provider_type, data.type, workflowTools]) diff --git a/web/app/components/workflow/node-contextmenu.tsx b/web/app/components/workflow/node-contextmenu.tsx index 491e0dc5178d9a..a11ec1550dfbe6 100644 --- a/web/app/components/workflow/node-contextmenu.tsx +++ b/web/app/components/workflow/node-contextmenu.tsx @@ -1,30 +1,22 @@ import type { Node } from './types' -import { - ContextMenuContent, -} from '@langgenius/dify-ui/context-menu' +import { ContextMenuContent } from '@langgenius/dify-ui/context-menu' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { NodeActionsContextMenuContent } from './node-actions-menu/context-menu-content' import { NODE_ACTIONS_MENU_WIDTH_CLASS_NAME } from './node-actions-menu/shared' import { useStore } from './store' -export function NodeContextmenu({ - onClose, -}: { - onClose: () => void -}) { +export function NodeContextmenu({ onClose }: { onClose: () => void }) { const nodes = useNodes() - const contextMenuTarget = useStore(s => s.contextMenuTarget) + const contextMenuTarget = useStore((s) => s.contextMenuTarget) const nodeId = contextMenuTarget?.type === 'node' ? contextMenuTarget.nodeId : undefined - const currentNode = nodeId ? nodes.find(node => node.id === nodeId) as Node | undefined : undefined + const currentNode = nodeId + ? (nodes.find((node) => node.id === nodeId) as Node | undefined) + : undefined - if (!nodeId || !currentNode) - return null + if (!nodeId || !currentNode) return null return ( - <ContextMenuContent - popupClassName={NODE_ACTIONS_MENU_WIDTH_CLASS_NAME} - sideOffset={4} - > + <ContextMenuContent popupClassName={NODE_ACTIONS_MENU_WIDTH_CLASS_NAME} sideOffset={4}> <NodeActionsContextMenuContent id={currentNode.id} data={currentNode.data} diff --git a/web/app/components/workflow/nodes/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/__tests__/index.spec.tsx index a65b327838290c..df63ab28e708ff 100644 --- a/web/app/components/workflow/nodes/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/__tests__/index.spec.tsx @@ -87,26 +87,14 @@ const baseNodeProps = { describe('workflow nodes index', () => { it('should render the mapped node inside the base node shell', () => { - render( - <CustomNode - id="node-1" - data={createNodeData()} - {...baseNodeProps} - />, - ) + render(<CustomNode id="node-1" data={createNodeData()} {...baseNodeProps} />) expect(screen.getByText('base-node:node-1:start')).toBeInTheDocument() expect(screen.getByText('start-node-component')).toBeInTheDocument() }) it('should render the mapped panel inside the base panel shell for custom nodes', () => { - render( - <Panel - type={CUSTOM_NODE} - id="node-1" - data={createNodeData()} - />, - ) + render(<Panel type={CUSTOM_NODE} id="node-1" data={createNodeData()} />) expect(screen.getByText('base-panel:node-1:start')).toBeInTheDocument() expect(screen.getByText('start-panel-component')).toBeInTheDocument() @@ -114,35 +102,19 @@ describe('workflow nodes index', () => { it('should remount the base panel when a node keeps its id but changes type', () => { const { rerender } = render( - <Panel - type={CUSTOM_NODE} - id="node-1" - data={createStartPlaceholderData()} - />, + <Panel type={CUSTOM_NODE} id="node-1" data={createStartPlaceholderData()} />, ) expect(screen.getByText('base-panel-initial:start-placeholder')).toBeInTheDocument() - rerender( - <Panel - type={CUSTOM_NODE} - id="node-1" - data={createNodeData()} - />, - ) + rerender(<Panel type={CUSTOM_NODE} id="node-1" data={createNodeData()} />) expect(screen.getByText('base-panel:node-1:start')).toBeInTheDocument() expect(screen.getByText('base-panel-initial:start')).toBeInTheDocument() }) it('should return null for non-custom panel types', () => { - const { container } = render( - <Panel - type="default" - id="node-1" - data={createNodeData()} - />, - ) + const { container } = render(<Panel type="default" id="node-1" data={createNodeData()} />) expect(container).toBeEmptyDOMElement() }) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx index d912b219be1cce..95650fdea47852 100644 --- a/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx +++ b/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx @@ -10,11 +10,13 @@ describe('node sections', () => { it('should render loop and loading metadata in the header section', () => { render( <NodeHeaderMeta - data={{ - type: BlockEnum.Loop, - _loopIndex: 2, - _runningStatus: NodeRunningStatus.Running, - } as never} + data={ + { + type: BlockEnum.Loop, + _loopIndex: 2, + _runningStatus: NodeRunningStatus.Running, + } as never + } hasVarValue={false} isLoading loopIndex={<div>loop-index</div>} @@ -28,10 +30,7 @@ describe('node sections', () => { it('should render the container node body and description branches', () => { const { rerender } = render( - <NodeBody - data={{ type: BlockEnum.Loop } as never} - child={<div>body-content</div>} - />, + <NodeBody data={{ type: BlockEnum.Loop } as never} child={<div>body-content</div>} />, ) expect(screen.getByText('body-content').parentElement).toHaveClass('grow') @@ -45,13 +44,15 @@ describe('node sections', () => { render( <NodeHeaderMeta - data={{ - type: BlockEnum.Iteration, - is_parallel: true, - _iterationLength: 3, - _iterationIndex: 5, - _runningStatus: NodeRunningStatus.Running, - } as never} + data={ + { + type: BlockEnum.Iteration, + is_parallel: true, + _iterationLength: 3, + _iterationIndex: 5, + _runningStatus: NodeRunningStatus.Running, + } as never + } hasVarValue={false} isLoading={false} loopIndex={null} @@ -129,7 +130,11 @@ describe('node sections', () => { rerender(<NodeDescription data={{ type: BlockEnum.Loop, desc: 'hidden' } as never} />) expect(screen.queryByText('hidden')).not.toBeInTheDocument() - rerender(<NodeDescription data={{ type: BlockEnum.StartPlaceholder, desc: 'old placeholder description' } as never} />) + rerender( + <NodeDescription + data={{ type: BlockEnum.StartPlaceholder, desc: 'old placeholder description' } as never} + />, + ) expect(screen.queryByText('old placeholder description')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts b/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts index 01275cbcd33a44..d18e9ddfdabcf0 100644 --- a/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts +++ b/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts @@ -8,11 +8,19 @@ import { describe('node helpers', () => { it('should derive node border states from running status and selection state', () => { - expect(getNodeStatusBorders(NodeRunningStatus.Running, false, false).showRunningBorder).toBe(true) - expect(getNodeStatusBorders(NodeRunningStatus.Succeeded, false, false).showSuccessBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Running, false, false).showRunningBorder).toBe( + true, + ) + expect(getNodeStatusBorders(NodeRunningStatus.Succeeded, false, false).showSuccessBorder).toBe( + true, + ) expect(getNodeStatusBorders(NodeRunningStatus.Failed, false, false).showFailedBorder).toBe(true) - expect(getNodeStatusBorders(NodeRunningStatus.Exception, false, false).showExceptionBorder).toBe(true) - expect(getNodeStatusBorders(NodeRunningStatus.Succeeded, false, true).showSuccessBorder).toBe(false) + expect( + getNodeStatusBorders(NodeRunningStatus.Exception, false, false).showExceptionBorder, + ).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Succeeded, false, true).showSuccessBorder).toBe( + false, + ) }) it('should expose the correct loop translation key per running status', () => { 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 c7e7f941db7b9c..5612db1506a037 100644 --- a/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx @@ -43,7 +43,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -79,10 +80,9 @@ vi.mock('@/app/components/workflow/nodes/loop/use-interactions', () => ({ })) vi.mock('../use-node-resize-observer', () => ({ - default: (options: { enabled: boolean, onResize: () => void }) => { + default: (options: { enabled: boolean; onResize: () => void }) => { mockUseNodeResizeObserver(options) - if (options.enabled) - options.onResize() + if (options.enabled) options.onResize() }, })) @@ -92,7 +92,9 @@ vi.mock('../components/add-variable-popup-with-position', () => ({ vi.mock('../components/entry-node-container', () => ({ __esModule: true, StartNodeTypeEnum: { Start: 'start', Trigger: 'trigger' }, - default: ({ children }: PropsWithChildren) => <div data-testid="entry-node-container">{children}</div>, + default: ({ children }: PropsWithChildren) => ( + <div data-testid="entry-node-container">{children}</div> + ), })) vi.mock('../components/error-handle/error-handle-on-node', () => ({ default: () => <div data-testid="error-handle-node" />, @@ -185,17 +187,21 @@ describe('BaseNode', () => { renderWorkflowComponent( <BaseNode id="node-1" - data={toNodeData(createData({ - type: BlockEnum.Iteration, - is_parallel: true, - }))} + data={toNodeData( + createData({ + type: BlockEnum.Iteration, + is_parallel: true, + }), + )} > <div>Iteration body</div> </BaseNode>, ) const titleButton = screen.getByRole('button', { name: 'Node title' }) - const parallelButton = screen.getByRole('button', { name: /workflow\.nodes\.iteration\.parallelModeUpper/ }) + const parallelButton = screen.getByRole('button', { + name: /workflow\.nodes\.iteration\.parallelModeUpper/, + }) expect(titleButton).not.toContainElement(parallelButton) expect(titleButton.querySelector('button')).toBeNull() @@ -235,13 +241,15 @@ describe('BaseNode', () => { renderWorkflowComponent( <BaseNode id="node-1" - data={toNodeData(createData({ - type: BlockEnum.Loop, - _loopIndex: 3, - _runningStatus: NodeRunningStatus.Running, - width: 320, - height: 220, - }))} + data={toNodeData( + createData({ + type: BlockEnum.Loop, + _loopIndex: 3, + _runningStatus: NodeRunningStatus.Running, + width: 320, + height: 220, + }), + )} > <div>Loop body</div> </BaseNode>, @@ -263,11 +271,13 @@ describe('BaseNode', () => { renderWorkflowComponent( <BaseNode id="node-1" - data={toNodeData(createData({ - type: BlockEnum.Iteration, - selected: true, - isInIteration: true, - }))} + data={toNodeData( + createData({ + type: BlockEnum.Iteration, + selected: true, + isInIteration: true, + }), + )} > <div>Iteration body</div> </BaseNode>, @@ -282,11 +292,13 @@ describe('BaseNode', () => { renderWorkflowComponent( <BaseNode id="node-2" - data={toNodeData(createData({ - type: BlockEnum.Loop, - selected: true, - isInLoop: true, - }))} + data={toNodeData( + createData({ + type: BlockEnum.Loop, + selected: true, + isInLoop: true, + }), + )} > <div>Loop body</div> </BaseNode>, @@ -297,7 +309,8 @@ describe('BaseNode', () => { }) it('should keep viewer avatars outside the truncated title area', () => { - const longTitle = 'This is a very long node title that should truncate before it clips the viewer avatars' + const longTitle = + 'This is a very long node title that should truncate before it clips the viewer avatars' mockUseCollaboration.mockReturnValue({ nodePanelPresence: { 'node-1': { diff --git a/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx index 02603e68c8798d..985fdd0ad9df2b 100644 --- a/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx +++ b/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx @@ -11,24 +11,29 @@ describe('useNodeResizeObserver', () => { const onResize = vi.fn() let resizeCallback: (() => void) | undefined - vi.stubGlobal('ResizeObserver', class { - constructor(callback: () => void) { - resizeCallback = callback - } + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: () => void) { + resizeCallback = callback + } - observe = observe - disconnect = disconnect - unobserve = vi.fn() - }) + observe = observe + disconnect = disconnect + unobserve = vi.fn() + }, + ) const node = document.createElement('div') const nodeRef = { current: node } - const { unmount } = renderHook(() => useNodeResizeObserver({ - enabled: true, - nodeRef, - onResize, - })) + const { unmount } = renderHook(() => + useNodeResizeObserver({ + enabled: true, + nodeRef, + onResize, + }), + ) expect(observe).toHaveBeenCalledWith(node) resizeCallback?.() @@ -41,17 +46,22 @@ describe('useNodeResizeObserver', () => { it('should do nothing when disabled', () => { const observe = vi.fn() - vi.stubGlobal('ResizeObserver', class { - observe = observe - disconnect = vi.fn() - unobserve = vi.fn() - }) - - renderHook(() => useNodeResizeObserver({ - enabled: false, - nodeRef: { current: document.createElement('div') }, - onResize: vi.fn(), - })) + vi.stubGlobal( + 'ResizeObserver', + class { + observe = observe + disconnect = vi.fn() + unobserve = vi.fn() + }, + ) + + renderHook(() => + useNodeResizeObserver({ + enabled: false, + nodeRef: { current: document.createElement('div') }, + onResize: vi.fn(), + }), + ) expect(observe).not.toHaveBeenCalled() }) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx index bd0ac7b7b5b032..bff3e7d084783d 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx @@ -52,21 +52,12 @@ vi.mock('@/app/components/base/search-input', () => ({ placeholder?: string className?: string }) => ( - <input - aria-label={placeholder} - value={value} - onChange={e => onValueChange(e.target.value)} - /> + <input aria-label={placeholder} value={value} onChange={(e) => onValueChange(e.target.value)} /> ), })) vi.mock('@/app/components/workflow/block-selector/view-type-select', () => ({ - default: ({ - onChange, - }: { - viewType: string - onChange: (value: string) => void - }) => ( + default: ({ onChange }: { viewType: string; onChange: (value: string) => void }) => ( <button type="button" onClick={() => onChange('grid')}> view-type </button> @@ -92,31 +83,37 @@ vi.mock('@/app/components/workflow/block-selector/tools', () => ({ output_schema?: Record<string, unknown> }> }> - onSelect: (value: unknown, tool: { - tool_name: string - provider_name: string - tool_label: string - output_schema?: Record<string, unknown> - provider_id: string - meta?: unknown - }) => void + onSelect: ( + value: unknown, + tool: { + tool_name: string + provider_name: string + tool_label: string + output_schema?: Record<string, unknown> + provider_id: string + meta?: unknown + }, + ) => void }) => ( <div data-testid="tools-list"> - {tools.map(tool => ( + {tools.map((tool) => ( <div key={tool.id}> <span>{tool.name}</span> <button type="button" - onClick={() => onSelect(undefined, { - tool_name: tool.tools[0]!.name, - provider_name: tool.id, - tool_label: typeof tool.tools[0]!.label === 'string' - ? tool.tools[0]!.label - : tool.tools[0]!.label.en_US || '', - output_schema: tool.tools[0]!.output_schema, - provider_id: tool.id, - meta: tool.meta, - })} + onClick={() => + onSelect(undefined, { + tool_name: tool.tools[0]!.name, + provider_name: tool.id, + tool_label: + typeof tool.tools[0]!.label === 'string' + ? tool.tools[0]!.label + : tool.tools[0]!.label.en_US || '', + output_schema: tool.tools[0]!.output_schema, + provider_id: tool.id, + meta: tool.meta, + }) + } > {`select-${tool.name}`} </button> @@ -127,15 +124,9 @@ vi.mock('@/app/components/workflow/block-selector/tools', () => ({ })) vi.mock('@/app/components/workflow/block-selector/market-place-plugin/list', () => ({ - default: ({ - list, - searchText, - }: { - list: Array<{ plugin_id: string }> - searchText: string - }) => ( + default: ({ list, searchText }: { list: Array<{ plugin_id: string }>; searchText: string }) => ( <div data-testid="plugin-list"> - {`${searchText}:${list.map(item => item.plugin_id).join(',')}`} + {`${searchText}:${list.map((item) => item.plugin_id).join(',')}`} </div> ), })) @@ -166,11 +157,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version' uniqueIdentifier: string tooltip: ReactNode }) => ( - <button - type="button" - data-testid="switch-plugin-version" - onClick={onChange} - > + <button type="button" data-testid="switch-plugin-version" onClick={onChange}> switch-plugin-version </button> ), @@ -185,7 +172,11 @@ vi.mock('@/next/link', () => ({ href: string children: ReactNode className?: string - }) => <a href={href} className={className}>{children}</a>, + }) => ( + <a href={href} className={className}> + {children} + </a> + ), })) vi.mock('@langgenius/dify-ui/popover', async () => { const React = await import('react') @@ -207,16 +198,11 @@ vi.mock('@langgenius/dify-ui/popover', async () => { const isControlled = controlledOpen !== undefined const open = isControlled ? !!controlledOpen : uncontrolledOpen const setOpen = (nextOpen: boolean) => { - if (!isControlled) - setUncontrolledOpen(nextOpen) + if (!isControlled) setUncontrolledOpen(nextOpen) onOpenChange?.(nextOpen) } - return ( - <PopoverContext value={{ open, setOpen }}> - {children} - </PopoverContext> - ) + return <PopoverContext value={{ open, setOpen }}>{children}</PopoverContext> } const PopoverTrigger = ({ render }: { render: React.ReactNode }) => { @@ -250,31 +236,34 @@ const createStrategyDetail = ( name: string, strategyName: string, strategyLabel: string, -): StrategyPluginDetail => ({ - plugin_unique_identifier: `provider/${name}`, - plugin_id: `plugin-${name}`, - declaration: { - identity: { - author: 'Dify', - name, - description: { en_US: `${name} description` }, - icon: `${name}.png`, - label: { en_US: `${name} label` }, - tags: [], - }, - strategies: [{ +): StrategyPluginDetail => + ({ + plugin_unique_identifier: `provider/${name}`, + plugin_id: `plugin-${name}`, + declaration: { identity: { - name: strategyName, author: 'Dify', - label: { en_US: strategyLabel }, + name, + description: { en_US: `${name} description` }, + icon: `${name}.png`, + label: { en_US: `${name} label` }, + tags: [], }, - description: { en_US: `${strategyLabel} description` }, - parameters: [], - output_schema: { result: { type: 'string' } }, - }], - }, - meta: { version: '1.0.0' }, -} as unknown as StrategyPluginDetail) + strategies: [ + { + identity: { + name: strategyName, + author: 'Dify', + label: { en_US: strategyLabel }, + }, + description: { en_US: `${strategyLabel} description` }, + parameters: [], + output_schema: { result: { type: 'string' } }, + }, + ], + }, + meta: { version: '1.0.0' }, + }) as unknown as StrategyPluginDetail describe('AgentStrategySelector', () => { const alphaDetail = createStrategyDetail('alpha', 'alpha-strategy', 'Alpha Strategy') @@ -298,11 +287,7 @@ describe('AgentStrategySelector', () => { it('filters strategies and queries marketplace when searching', async () => { const user = userEvent.setup() - render( - <AgentStrategySelector - onChange={vi.fn()} - />, - ) + render(<AgentStrategySelector onChange={vi.fn()} />) await user.click(screen.getByTestId('agent-strategy-trigger')) @@ -311,7 +296,9 @@ describe('AgentStrategySelector', () => { expect(screen.getByTestId('plugin-list')).toHaveTextContent(':market-agent') await user.type( - screen.getByRole('textbox', { name: /(?:^|\.)nodes\.agent\.strategy\.searchPlaceholder(?=$|:)/ }), + screen.getByRole('textbox', { + name: /(?:^|\.)nodes\.agent\.strategy\.searchPlaceholder(?=$|:)/, + }), 'alp', ) @@ -330,11 +317,7 @@ describe('AgentStrategySelector', () => { const user = userEvent.setup() const onChange = vi.fn() - render( - <AgentStrategySelector - onChange={onChange} - />, - ) + render(<AgentStrategySelector onChange={onChange} />) await user.click(screen.getByTestId('agent-strategy-trigger')) await user.click(screen.getByRole('button', { name: 'select-alpha' })) @@ -376,7 +359,9 @@ describe('AgentStrategySelector', () => { ) expect(screen.getByText(/(?:^|\.)nodes\.agent\.pluginNotInstalled(?=$|:)/)).toBeInTheDocument() - expect(screen.getByText(/(?:^|\.)nodes\.agent\.pluginNotInstalledDesc(?=$|:)/)).toBeInTheDocument() + expect( + screen.getByText(/(?:^|\.)nodes\.agent\.pluginNotInstalledDesc(?=$|:)/), + ).toBeInTheDocument() }) it('renders install and switch-version actions for marketplace strategies', async () => { diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx index 905e2e07cb4146..4a63df68892440 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx @@ -22,10 +22,8 @@ vi.mock('@/context/i18n', () => ({ vi.mock('@/hooks/use-i18n', () => ({ useRenderI18nObject: () => (value: unknown) => { - if (typeof value === 'string') - return value - if (value && typeof value === 'object' && 'en_US' in value) - return value.en_US + if (typeof value === 'string') return value + if (value && typeof value === 'object' && 'en_US' in value) return value.en_US return 'label' }, })) @@ -69,12 +67,20 @@ type MockFormProps = { } vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({ - default: ({ formSchemas, value, onChange, override, nodeId, nodeOutputVars, availableNodes }: MockFormProps) => { + default: ({ + formSchemas, + value, + onChange, + override, + nodeId, + nodeOutputVars, + availableNodes, + }: MockFormProps) => { const renderOverride = override?.[1] return ( <div data-testid="mock-form"> - {formSchemas.map(schema => ( + {formSchemas.map((schema) => ( <div key={schema.variable}> {renderOverride?.(schema, { value, @@ -111,18 +117,23 @@ describe('AgentStrategy', () => { vi.clearAllMocks() }) - const createTextNumberSchema = (overrides: Partial<CredentialFormSchemaNumberInput> = {}): CredentialFormSchema => ({ - name: 'count', - variable: 'count', - label: createI18nLabel('Count'), - type: FormTypeEnum.textNumber, - required: false, - show_on: [], - default: '1', - ...overrides, - } as unknown as CredentialFormSchema) - - const createTextInputSchema = (overrides: Partial<CredentialFormSchemaTextInput> = {}): CredentialFormSchema => ({ + const createTextNumberSchema = ( + overrides: Partial<CredentialFormSchemaNumberInput> = {}, + ): CredentialFormSchema => + ({ + name: 'count', + variable: 'count', + label: createI18nLabel('Count'), + type: FormTypeEnum.textNumber, + required: false, + show_on: [], + default: '1', + ...overrides, + }) as unknown as CredentialFormSchema + + const createTextInputSchema = ( + overrides: Partial<CredentialFormSchemaTextInput> = {}, + ): CredentialFormSchema => ({ name: 'prompt', variable: 'prompt', label: createI18nLabel('Prompt'), @@ -137,11 +148,13 @@ describe('AgentStrategy', () => { render( <AgentStrategy {...defaultProps} - formSchema={[createTextNumberSchema({ - min: 0, - max: 0, - default: '0', - })]} + formSchema={[ + createTextNumberSchema({ + min: 0, + max: 0, + default: '0', + }), + ]} />, ) @@ -152,9 +165,11 @@ describe('AgentStrategy', () => { render( <AgentStrategy {...defaultProps} - formSchema={[createTextNumberSchema({ - max: 5, - })]} + formSchema={[ + createTextNumberSchema({ + max: 5, + }), + ]} />, ) @@ -165,9 +180,11 @@ describe('AgentStrategy', () => { render( <AgentStrategy {...defaultProps} - formSchema={[createTextNumberSchema({ - min: 0, - })]} + formSchema={[ + createTextNumberSchema({ + min: 0, + }), + ]} />, ) @@ -175,12 +192,7 @@ describe('AgentStrategy', () => { }) it('should render text-input schemas through the editor override', () => { - render( - <AgentStrategy - {...defaultProps} - formSchema={[createTextInputSchema()]} - />, - ) + render(<AgentStrategy {...defaultProps} formSchema={[createTextInputSchema()]} />) expect(screen.getByTestId('agent-strategy-editor')).toHaveTextContent('hello') }) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx index c330e0caed6a4f..37a8b128a4057d 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx @@ -55,11 +55,7 @@ describe('File upload support components', () => { const onToggle = vi.fn() render( - <FileTypeItem - type={SupportUploadFileTypes.image} - selected={false} - onToggle={onToggle} - />, + <FileTypeItem type={SupportUploadFileTypes.image} selected={false} onToggle={onToggle} />, ) expect(screen.getByText('appDebug.variableConfig.file.image.name')).toBeInTheDocument() @@ -83,7 +79,9 @@ describe('File upload support components', () => { />, ) - const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder') + const input = screen.getByPlaceholderText( + 'appDebug.variableConfig.file.custom.createPlaceholder', + ) await user.type(input, 'csv') fireEvent.blur(input) @@ -97,41 +95,35 @@ describe('File upload support components', () => { const user = userEvent.setup() const onChange = vi.fn() - render( - <FileUploadSetting - payload={createPayload()} - isMultiple - onChange={onChange} - />, - ) + render(<FileUploadSetting payload={createPayload()} isMultiple onChange={onChange} />) await user.click(screen.getByText('appDebug.variableConfig.file.image.name')) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - allowed_file_types: [SupportUploadFileTypes.document, SupportUploadFileTypes.image], - })) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + allowed_file_types: [SupportUploadFileTypes.document, SupportUploadFileTypes.image], + }), + ) await user.click(screen.getByText('URL')) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - allowed_file_upload_methods: [TransferMethod.remote_url], - })) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + allowed_file_upload_methods: [TransferMethod.remote_url], + }), + ) fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '5' } }) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - max_length: 5, - })) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + max_length: 5, + }), + ) }) it('should keep upload limits within the configured range', () => { const StatefulFileUploadSetting = () => { const [payload, setPayload] = useState(createPayload()) - return ( - <FileUploadSetting - payload={payload} - isMultiple - onChange={setPayload} - /> - ) + return <FileUploadSetting payload={payload} isMultiple onChange={setPayload} /> } render(<StatefulFileUploadSetting />) @@ -154,30 +146,26 @@ describe('File upload support components', () => { const user = userEvent.setup() const onChange = vi.fn() const { rerender } = render( - <FileUploadSetting - payload={createPayload()} - isMultiple={false} - onChange={onChange} - />, + <FileUploadSetting payload={createPayload()} isMultiple={false} onChange={onChange} />, ) await user.click(screen.getByText('appDebug.variableConfig.file.document.name')) - expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ - allowed_file_types: [], - })) + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + allowed_file_types: [], + }), + ) rerender( - <FileUploadSetting - payload={createPayload()} - isMultiple={false} - onChange={onChange} - />, + <FileUploadSetting payload={createPayload()} isMultiple={false} onChange={onChange} />, ) await user.click(screen.getByText('appDebug.variableConfig.file.custom.name')) - expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ - allowed_file_types: [SupportUploadFileTypes.custom], - })) + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + allowed_file_types: [SupportUploadFileTypes.custom], + }), + ) rerender( <FileUploadSetting @@ -190,26 +178,26 @@ describe('File upload support components', () => { ) await user.click(screen.getByText('appDebug.variableConfig.file.custom.name')) - expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ - allowed_file_types: [], - })) + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + allowed_file_types: [], + }), + ) }) it('should support both upload methods and update custom extensions', async () => { const user = userEvent.setup() const onChange = vi.fn() const { rerender } = render( - <FileUploadSetting - payload={createPayload()} - isMultiple={false} - onChange={onChange} - />, + <FileUploadSetting payload={createPayload()} isMultiple={false} onChange={onChange} />, ) await user.click(screen.getByText('appDebug.variableConfig.both')) - expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ - allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], - })) + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + }), + ) rerender( <FileUploadSetting @@ -221,13 +209,17 @@ describe('File upload support components', () => { />, ) - const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder') + const input = screen.getByPlaceholderText( + 'appDebug.variableConfig.file.custom.createPlaceholder', + ) await user.type(input, 'csv') fireEvent.blur(input) - expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ - allowed_file_extensions: ['pdf', 'csv'], - })) + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + allowed_file_extensions: ['pdf', 'csv'], + }), + ) }) it('should render support file types in the feature panel and hide them when requested', () => { @@ -252,7 +244,9 @@ describe('File upload support components', () => { />, ) - expect(screen.queryByText('appDebug.variableConfig.file.document.name')).not.toBeInTheDocument() + expect( + screen.queryByText('appDebug.variableConfig.file.document.name'), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx index 9ca932e54cc6b1..68484a67b1eac5 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx @@ -1,5 +1,8 @@ import type { ComponentProps } from 'react' -import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + CredentialFormSchema, + FormOption, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { AppSelectorValue } from '@/app/components/plugins/plugin-detail-panel/app-selector' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -9,10 +12,7 @@ import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__ import { VarKindType } from '../../types' import FormInputItem from '../form-input-item' -const { - mockFetchDynamicOptions, - mockTriggerDynamicOptionsState, -} = vi.hoisted(() => ({ +const { mockFetchDynamicOptions, mockTriggerDynamicOptionsState } = vi.hoisted(() => ({ mockFetchDynamicOptions: vi.fn(), mockTriggerDynamicOptionsState: { data: undefined as { options: FormOption[] } | undefined, @@ -48,7 +48,9 @@ vi.mock('@/app/components/workflow/hooks', () => ({ vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ AppSelector: ({ onSelect }: { onSelect: (value: AppSelectorValue) => void }) => ( - <button onClick={() => onSelect({ app_id: 'app-1', inputs: {}, files: [] })}>app-selector</button> + <button onClick={() => onSelect({ app_id: 'app-1', inputs: {}, files: [] })}> + app-selector + </button> ), })) @@ -59,14 +61,18 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ })) vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input', () => ({ - default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => ( - <input aria-label="mixed-variable-input" value={value} onChange={e => onChange(e.target.value)} /> + default: ({ onChange, value }: { onChange: (value: string) => void; value: string }) => ( + <input + aria-label="mixed-variable-input" + value={value} + onChange={(e) => onChange(e.target.value)} + /> ), })) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => ( - <textarea aria-label="json-editor" value={value} onChange={e => onChange(e.target.value)} /> + default: ({ onChange, value }: { onChange: (value: string) => void; value: string }) => ( + <textarea aria-label="json-editor" value={value} onChange={(e) => onChange(e.target.value)} /> ), })) @@ -77,29 +83,29 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference })) const createSchema = ( - overrides: Partial<CredentialFormSchema & { + overrides: Partial< + CredentialFormSchema & { + _type?: FormTypeEnum + multiple?: boolean + options?: FormOption[] + } + > = {}, +) => + ({ + label: { en_US: 'Field', zh_Hans: '字段' }, + name: 'field', + required: false, + show_on: [], + type: FormTypeEnum.textInput, + variable: 'field', + ...overrides, + }) as CredentialFormSchema & { _type?: FormTypeEnum multiple?: boolean options?: FormOption[] - }> = {}, -) => ({ - label: { en_US: 'Field', zh_Hans: '字段' }, - name: 'field', - required: false, - show_on: [], - type: FormTypeEnum.textInput, - variable: 'field', - ...overrides, -}) as CredentialFormSchema & { - _type?: FormTypeEnum - multiple?: boolean - options?: FormOption[] -} + } -const createOption = ( - value: string, - overrides: Partial<FormOption> = {}, -): FormOption => ({ +const createOption = (value: string, overrides: Partial<FormOption> = {}): FormOption => ({ label: { en_US: value, zh_Hans: value }, show_on: [], value, @@ -143,7 +149,9 @@ describe('FormInputItem branches', () => { it('should update mixed string inputs via the shared text input', () => { const { onChange } = renderFormInputItem() - fireEvent.change(screen.getByLabelText('mixed-variable-input'), { target: { value: 'hello world' } }) + fireEvent.change(screen.getByLabelText('mixed-variable-input'), { + target: { value: 'hello world' }, + }) expect(onChange).toHaveBeenCalledWith({ field: { @@ -183,10 +191,7 @@ describe('FormInputItem branches', () => { const { onChange } = renderFormInputItem({ schema: createSchema({ type: FormTypeEnum.select, - options: [ - createOption('basic', { icon: '/basic.svg' }), - createOption('pro'), - ], + options: [createOption('basic', { icon: '/basic.svg' }), createOption('pro')], }), value: { field: { @@ -214,10 +219,7 @@ describe('FormInputItem branches', () => { schema: createSchema({ multiple: true, type: FormTypeEnum.select, - options: [ - createOption('alpha'), - createOption('beta'), - ], + options: [createOption('alpha'), createOption('beta')], }), value: { field: { @@ -241,9 +243,7 @@ describe('FormInputItem branches', () => { it('should fetch tool dynamic options, render them, and update the value', async () => { mockFetchDynamicOptions.mockResolvedValueOnce({ - options: [ - createOption('remote', { icon: '/remote.svg' }), - ], + options: [createOption('remote', { icon: '/remote.svg' })], }) const { onChange } = renderFormInputItem({ schema: createSchema({ @@ -301,9 +301,7 @@ describe('FormInputItem branches', () => { it('should use trigger dynamic options for multi-select values', async () => { mockTriggerDynamicOptionsState.data = { - options: [ - createOption('trigger-option'), - ], + options: [createOption('trigger-option')], } const { onChange } = renderFormInputItem({ @@ -311,7 +309,11 @@ describe('FormInputItem branches', () => { multiple: true, type: FormTypeEnum.dynamicSelect, }), - currentProvider: { plugin_id: 'provider-2', name: 'provider-2', credential_id: 'credential-1' } as never, + currentProvider: { + plugin_id: 'provider-2', + name: 'provider-2', + credential_id: 'credential-1', + } as never, currentTool: { name: 'trigger-tool' } as never, providerType: PluginCategoryEnum.trigger, value: { @@ -377,7 +379,9 @@ describe('FormInputItem branches', () => { }, }) - fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '{"enabled":true}' } }) + fireEvent.change(screen.getByLabelText('json-editor'), { + target: { value: '{"enabled":true}' }, + }) expect(json.onChange).toHaveBeenCalledWith({ field: { type: VarKindType.constant, diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.helpers.spec.ts b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.helpers.spec.ts index 3da75078cbfee9..e9353369108038 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.helpers.spec.ts +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.helpers.spec.ts @@ -1,4 +1,7 @@ -import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + CredentialFormSchema, + FormOption, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Var } from '@/app/components/workflow/types' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { VarType } from '@/app/components/workflow/types' @@ -18,29 +21,29 @@ import { } from '../form-input-item.helpers' const createSchema = ( - overrides: Partial<CredentialFormSchema & { + overrides: Partial< + CredentialFormSchema & { + _type?: FormTypeEnum + multiple?: boolean + options?: FormOption[] + } + > = {}, +) => + ({ + label: { en_US: 'Field', zh_Hans: '字段' }, + name: 'field', + required: false, + show_on: [], + type: FormTypeEnum.textInput, + variable: 'field', + ...overrides, + }) as CredentialFormSchema & { _type?: FormTypeEnum multiple?: boolean options?: FormOption[] - }> = {}, -) => ({ - label: { en_US: 'Field', zh_Hans: '字段' }, - name: 'field', - required: false, - show_on: [], - type: FormTypeEnum.textInput, - variable: 'field', - ...overrides, -}) as CredentialFormSchema & { - _type?: FormTypeEnum - multiple?: boolean - options?: FormOption[] -} + } -const createOption = ( - value: string, - overrides: Partial<FormOption> = {}, -): FormOption => ({ +const createOption = (value: string, overrides: Partial<FormOption> = {}): FormOption => ({ label: { en_US: value, zh_Hans: value }, show_on: [], value, @@ -49,14 +52,14 @@ const createOption = ( describe('form-input-item helpers', () => { it('should derive field state and target var type', () => { - const numberState = getFormInputState( - createSchema({ type: FormTypeEnum.textNumber }), - { type: VarKindType.constant, value: 1 }, - ) - const filesState = getFormInputState( - createSchema({ type: FormTypeEnum.files }), - { type: VarKindType.variable, value: ['node', 'files'] }, - ) + const numberState = getFormInputState(createSchema({ type: FormTypeEnum.textNumber }), { + type: VarKindType.constant, + value: 1, + }) + const filesState = getFormInputState(createSchema({ type: FormTypeEnum.files }), { + type: VarKindType.variable, + value: ['node', 'files'], + }) expect(numberState.isNumber).toBe(true) expect(numberState.showTypeSwitch).toBe(true) @@ -67,7 +70,9 @@ describe('form-input-item helpers', () => { }) it('should return filter functions and var kind types by schema mode', () => { - const stringFilter = getFilterVar(getFormInputState(createSchema(), { type: VarKindType.mixed, value: '' })) + const stringFilter = getFilterVar( + getFormInputState(createSchema(), { type: VarKindType.mixed, value: '' }), + ) const booleanState = getFormInputState( createSchema({ _type: FormTypeEnum.boolean, type: FormTypeEnum.textInput }), { type: VarKindType.constant, value: true }, @@ -78,29 +83,33 @@ describe('form-input-item helpers', () => { expect(getVarKindType(booleanState)).toBe(VarKindType.constant) expect(getFilterVar(booleanState)?.({ type: VarType.boolean } as Var)).toBe(false) - const fileState = getFormInputState( - createSchema({ type: FormTypeEnum.file }), - { type: VarKindType.variable, value: ['node', 'file'] }, - ) - const objectState = getFormInputState( - createSchema({ type: FormTypeEnum.object }), - { type: VarKindType.constant, value: '{}' }, - ) - const arrayState = getFormInputState( - createSchema({ type: FormTypeEnum.array }), - { type: VarKindType.constant, value: '[]' }, - ) - const dynamicState = getFormInputState( - createSchema({ type: FormTypeEnum.dynamicSelect }), - { type: VarKindType.constant, value: 'selected' }, - ) + const fileState = getFormInputState(createSchema({ type: FormTypeEnum.file }), { + type: VarKindType.variable, + value: ['node', 'file'], + }) + const objectState = getFormInputState(createSchema({ type: FormTypeEnum.object }), { + type: VarKindType.constant, + value: '{}', + }) + const arrayState = getFormInputState(createSchema({ type: FormTypeEnum.array }), { + type: VarKindType.constant, + value: '[]', + }) + const dynamicState = getFormInputState(createSchema({ type: FormTypeEnum.dynamicSelect }), { + type: VarKindType.constant, + value: 'selected', + }) expect(getFilterVar(fileState)?.({ type: VarType.file } as Var)).toBe(true) expect(getFilterVar(objectState)?.({ type: VarType.object } as Var)).toBe(true) expect(getFilterVar(arrayState)?.({ type: VarType.arrayString } as Var)).toBe(true) expect(getVarKindType(fileState)).toBe(VarKindType.variable) expect(getVarKindType(dynamicState)).toBe(VarKindType.constant) - expect(getVarKindType(getFormInputState(createSchema({ type: FormTypeEnum.appSelector }), undefined))).toBeUndefined() + expect( + getVarKindType( + getFormInputState(createSchema({ type: FormTypeEnum.appSelector }), undefined), + ), + ).toBeUndefined() }) it('should filter and map visible options using show_on rules', () => { @@ -126,11 +135,7 @@ describe('form-input-item helpers', () => { }) it('should compute selected labels and checkbox state from visible options', () => { - const options = [ - createOption('alpha'), - createOption('beta'), - createOption('gamma'), - ] + const options = [createOption('alpha'), createOption('beta'), createOption('gamma')] expect(getSelectedLabels(['alpha', 'beta'], options, 'en_US')).toBe('alpha, beta') expect(getSelectedLabels(['alpha', 'beta', 'gamma'], options, 'en_US')).toBe('3 selected') diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.sections.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.sections.spec.tsx index 34382f2be06752..ad05b4ba37a3e5 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.sections.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.sections.spec.tsx @@ -1,10 +1,7 @@ import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' -import { - JsonEditorField, - MultiSelectField, -} from '../form-input-item.sections' +import { JsonEditorField, MultiSelectField } from '../form-input-item.sections' describe('form-input-item sections', () => { it('should render a loading multi-select label', () => { diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.spec.tsx index b8ba298caf294d..73b2b88867330d 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.spec.tsx @@ -1,5 +1,8 @@ import type { ComponentProps } from 'react' -import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + CredentialFormSchema, + FormOption, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { fireEvent, screen } from '@testing-library/react' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' @@ -19,29 +22,29 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) const createSchema = ( - overrides: Partial<CredentialFormSchema & { + overrides: Partial< + CredentialFormSchema & { + _type?: FormTypeEnum + multiple?: boolean + options?: FormOption[] + } + > = {}, +) => + ({ + label: { en_US: 'Field', zh_Hans: '字段' }, + name: 'field', + required: false, + show_on: [], + type: FormTypeEnum.textInput, + variable: 'field', + ...overrides, + }) as CredentialFormSchema & { _type?: FormTypeEnum multiple?: boolean options?: FormOption[] - }> = {}, -) => ({ - label: { en_US: 'Field', zh_Hans: '字段' }, - name: 'field', - required: false, - show_on: [], - type: FormTypeEnum.textInput, - variable: 'field', - ...overrides, -}) as CredentialFormSchema & { - _type?: FormTypeEnum - multiple?: boolean - options?: FormOption[] -} + } -const createOption = ( - value: string, - overrides: Partial<FormOption> = {}, -): FormOption => ({ +const createOption = (value: string, overrides: Partial<FormOption> = {}): FormOption => ({ label: { en_US: value, zh_Hans: value }, show_on: [], value, diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx index 95d1cd740691bc..8c366c258c0292 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx @@ -6,11 +6,7 @@ import { fullWorkflowAccessControl } from '../../../../hooks-store' import { BlockEnum, NodeRunningStatus } from '../../../../types' import NodeControl from '../node-control' -const { - mockHandleNodeSelect, - mockCanRunBySingle, - mockUseNodesReadOnly, -} = vi.hoisted(() => ({ +const { mockHandleNodeSelect, mockCanRunBySingle, mockUseNodesReadOnly } = vi.hoisted(() => ({ mockHandleNodeSelect: vi.fn(), mockCanRunBySingle: vi.fn(() => true), mockUseNodesReadOnly: vi.fn(() => false), @@ -40,20 +36,25 @@ vi.mock('../../../../utils', async () => { vi.mock('@/app/components/workflow/node-actions-menu', () => ({ NodeActionsDropdown: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => ( <> - <button type="button" onClick={() => onOpenChange(true)}>open panel</button> - <button type="button" onClick={() => onOpenChange(false)}>close panel</button> + <button type="button" onClick={() => onOpenChange(true)}> + open panel + </button> + <button type="button" onClick={() => onOpenChange(false)}> + close panel + </button> </> ), })) -function NodeControlHarness({ id, data }: { id: string, data: CommonNodeType, selected?: boolean }) { - return ( - <NodeControl - id={id} - data={data} - pluginInstallLocked={mockPluginInstallLocked} - /> - ) +function NodeControlHarness({ + id, + data, +}: { + id: string + data: CommonNodeType + selected?: boolean +}) { + return <NodeControl id={id} data={data} pluginInstallLocked={mockPluginInstallLocked} /> } function renderNodeControl(ui: React.ReactElement, accessControl = fullWorkflowAccessControl) { @@ -86,9 +87,7 @@ describe('NodeControl', () => { // Run/stop behavior should be driven by the workflow store, not CSS classes. describe('Single Run Actions', () => { it('should trigger a single run through the workflow store', () => { - const { store } = renderNodeControl( - <NodeControlHarness id="node-1" data={makeData()} />, - ) + const { store } = renderNodeControl(<NodeControlHarness id="node-1" data={makeData()} />) fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.runThisStep' })) @@ -108,7 +107,9 @@ describe('NodeControl', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' })) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' }), + ) expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-2', action: 'stop' }) expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-2') @@ -135,41 +136,37 @@ describe('NodeControl', () => { it('should hide the run control when single-node execution is not supported', () => { mockCanRunBySingle.mockReturnValue(false) - renderNodeControl( - <NodeControlHarness - id="node-4" - data={makeData()} - />, - ) + renderNodeControl(<NodeControlHarness id="node-4" data={makeData()} />) - expect(screen.queryByRole('button', { name: 'workflow.panel.runThisStep' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.panel.runThisStep' }), + ).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument() }) it('should hide the run control when workflow run permission is missing', () => { - renderNodeControl( - <NodeControlHarness id="node-5" data={makeData()} />, - { - canEdit: false, - canComment: true, - canRun: false, - canImportExportDSL: false, - canReleaseAndVersion: false, - }, - ) - - expect(screen.queryByRole('button', { name: 'workflow.panel.runThisStep' })).not.toBeInTheDocument() + renderNodeControl(<NodeControlHarness id="node-5" data={makeData()} />, { + canEdit: false, + canComment: true, + canRun: false, + canImportExportDSL: false, + canReleaseAndVersion: false, + }) + + expect( + screen.queryByRole('button', { name: 'workflow.panel.runThisStep' }), + ).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument() }) it('should hide the run control when nodes are read-only', () => { mockUseNodesReadOnly.mockReturnValue(true) - renderNodeControl( - <NodeControlHarness id="node-6" data={makeData()} />, - ) + renderNodeControl(<NodeControlHarness id="node-6" data={makeData()} />) - expect(screen.queryByRole('button', { name: 'workflow.panel.runThisStep' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.panel.runThisStep' }), + ).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx index 1f2c3b3aef3971..564327dbfaf8a2 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx @@ -301,22 +301,35 @@ describe('node-handle', () => { }) it.each([ - ['succeeded', NodeRunningStatus.Succeeded, undefined, 'after:bg-workflow-link-line-success-handle'], + [ + 'succeeded', + NodeRunningStatus.Succeeded, + undefined, + 'after:bg-workflow-link-line-success-handle', + ], ['failed', NodeRunningStatus.Failed, undefined, 'after:bg-workflow-link-line-error-handle'], - ['exception', NodeRunningStatus.Exception, true, 'after:bg-workflow-link-line-failure-handle'], - ])('should render the source %s status class', (_label, runningStatus, showExceptionStatus, expectedClass) => { - renderSourceHandle( - { - _runningStatus: runningStatus, - }, - { - showExceptionStatus, - }, - ) - - expect(screen.getByTestId('handle-source-handle')).toHaveClass(expectedClass) - expect(screen.getByTestId('handle-source-handle')).toHaveClass('custom-source-handle') - }) + [ + 'exception', + NodeRunningStatus.Exception, + true, + 'after:bg-workflow-link-line-failure-handle', + ], + ])( + 'should render the source %s status class', + (_label, runningStatus, showExceptionStatus, expectedClass) => { + renderSourceHandle( + { + _runningStatus: runningStatus, + }, + { + showExceptionStatus, + }, + ) + + expect(screen.getByTestId('handle-source-handle')).toHaveClass(expectedClass) + expect(screen.getByTestId('handle-source-handle')).toHaveClass('custom-source-handle') + }, + ) }) // Auto-open tests cover workflow start-trigger variants, chat-mode bypass, and store fallback paths. @@ -357,7 +370,9 @@ describe('node-handle', () => { renderSourceHandle({ type: BlockEnum.Start }) - expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ shouldAutoOpenStartNodeSelector: false }) + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ + shouldAutoOpenStartNodeSelector: false, + }) expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ hasSelectedStartNode: false }) }) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/remove-effect-var-confirm.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/remove-effect-var-confirm.spec.tsx index 652140c48f7012..75d05b89f9cd03 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/remove-effect-var-confirm.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/remove-effect-var-confirm.spec.tsx @@ -3,13 +3,7 @@ import RemoveEffectVarConfirm from '../remove-effect-var-confirm' describe('RemoveEffectVarConfirm', () => { it('should render title and content when open', () => { - render( - <RemoveEffectVarConfirm - isShow - onConfirm={vi.fn()} - onCancel={vi.fn()} - />, - ) + render(<RemoveEffectVarConfirm isShow onConfirm={vi.fn()} onCancel={vi.fn()} />) expect(screen.getByRole('alertdialog')).toBeInTheDocument() expect(screen.getByText('workflow.common.effectVarConfirm.title')).toBeInTheDocument() @@ -19,13 +13,7 @@ describe('RemoveEffectVarConfirm', () => { it('should call onConfirm when confirm is clicked', () => { const onConfirm = vi.fn() - render( - <RemoveEffectVarConfirm - isShow - onConfirm={onConfirm} - onCancel={vi.fn()} - />, - ) + render(<RemoveEffectVarConfirm isShow onConfirm={onConfirm} onCancel={vi.fn()} />) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) @@ -35,13 +23,7 @@ describe('RemoveEffectVarConfirm', () => { it('should call onCancel when cancel is clicked', async () => { const onCancel = vi.fn() - render( - <RemoveEffectVarConfirm - isShow - onConfirm={vi.fn()} - onCancel={onCancel} - />, - ) + render(<RemoveEffectVarConfirm isShow onConfirm={vi.fn()} onCancel={onCancel} />) fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) diff --git a/web/app/components/workflow/nodes/_base/components/add-button.tsx b/web/app/components/workflow/nodes/_base/components/add-button.tsx index 95d6b10d8b2dc8..a80291c89c8dfa 100644 --- a/web/app/components/workflow/nodes/_base/components/add-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/add-button.tsx @@ -2,9 +2,7 @@ import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - RiAddLine, -} from '@remixicon/react' +import { RiAddLine } from '@remixicon/react' import * as React from 'react' type Props = Readonly<{ @@ -13,18 +11,9 @@ type Props = Readonly<{ onClick: () => void }> -const AddButton: FC<Props> = ({ - className, - text, - onClick, -}) => { +const AddButton: FC<Props> = ({ className, text, onClick }) => { return ( - <Button - className={cn('w-full', className)} - variant="tertiary" - size="medium" - onClick={onClick} - > + <Button className={cn('w-full', className)} variant="tertiary" size="medium" onClick={onClick}> <RiAddLine className="mr-1 size-3.5" /> <div>{text}</div> </Button> diff --git a/web/app/components/workflow/nodes/_base/components/add-variable-popup-with-position.tsx b/web/app/components/workflow/nodes/_base/components/add-variable-popup-with-position.tsx index bdbe6c1415e4a6..14804583920860 100644 --- a/web/app/components/workflow/nodes/_base/components/add-variable-popup-with-position.tsx +++ b/web/app/components/workflow/nodes/_base/components/add-variable-popup-with-position.tsx @@ -1,21 +1,7 @@ -import type { - ValueSelector, - Var, - VarType, -} from '../../../types' +import type { ValueSelector, Var, VarType } from '../../../types' import { useClickAway } from 'ahooks' -import { - memo, - useCallback, - useMemo, - useRef, -} from 'react' -import { - useIsChatMode, - useNodeDataUpdate, - useWorkflow, - useWorkflowVariables, -} from '../../../hooks' +import { memo, useCallback, useMemo, useRef } from 'react' +import { useIsChatMode, useNodeDataUpdate, useWorkflow, useWorkflowVariables } from '../../../hooks' import { useStore } from '../../../store' import { useVariableAssigner } from '../../variable-assigner/hooks' import { filterVar } from '../../variable-assigner/utils' @@ -25,13 +11,10 @@ type AddVariablePopupWithPositionProps = { nodeId: string nodeData: any } -const AddVariablePopupWithPosition = ({ - nodeId, - nodeData, -}: AddVariablePopupWithPositionProps) => { +const AddVariablePopupWithPosition = ({ nodeId, nodeData }: AddVariablePopupWithPositionProps) => { const ref = useRef<HTMLDivElement>(null) - const showAssignVariablePopup = useStore(s => s.showAssignVariablePopup) - const setShowAssignVariablePopup = useStore(s => s.setShowAssignVariablePopup) + const showAssignVariablePopup = useStore((s) => s.showAssignVariablePopup) + const setShowAssignVariablePopup = useStore((s) => s.setShowAssignVariablePopup) const { handleNodeDataUpdate } = useNodeDataUpdate() const { handleAddVariableInAddVariablePopupWithPosition } = useVariableAssigner() const isChatMode = useIsChatMode() @@ -39,20 +22,20 @@ const AddVariablePopupWithPosition = ({ const { getNodeAvailableVars } = useWorkflowVariables() const outputType = useMemo(() => { - if (!showAssignVariablePopup) - return '' + if (!showAssignVariablePopup) return '' - const groupEnabled = showAssignVariablePopup.variableAssignerNodeData.advanced_settings?.group_enabled + const groupEnabled = + showAssignVariablePopup.variableAssignerNodeData.advanced_settings?.group_enabled - if (!groupEnabled) - return showAssignVariablePopup.variableAssignerNodeData.output_type + if (!groupEnabled) return showAssignVariablePopup.variableAssignerNodeData.output_type - const group = showAssignVariablePopup.variableAssignerNodeData.advanced_settings?.groups.find(group => group.groupId === showAssignVariablePopup.variableAssignerNodeHandleId) + const group = showAssignVariablePopup.variableAssignerNodeData.advanced_settings?.groups.find( + (group) => group.groupId === showAssignVariablePopup.variableAssignerNodeHandleId, + ) return group?.output_type || '' }, [showAssignVariablePopup]) const availableVars = useMemo(() => { - if (!showAssignVariablePopup) - return [] + if (!showAssignVariablePopup) return [] return getNodeAvailableVars({ parentNode: showAssignVariablePopup.parentNode, @@ -68,12 +51,20 @@ const AddVariablePopupWithPosition = ({ isChatMode, filterVar: filterVar(outputType as VarType), }) - .map(node => ({ + .map((node) => ({ ...node, - vars: node.isStartNode ? node.vars.filter(v => !v.variable.startsWith('sys.')) : node.vars, + vars: node.isStartNode + ? node.vars.filter((v) => !v.variable.startsWith('sys.')) + : node.vars, })) - .filter(item => item.vars.length > 0) - }, [showAssignVariablePopup, getNodeAvailableVars, getBeforeNodesInSameBranch, isChatMode, outputType]) + .filter((item) => item.vars.length > 0) + }, [ + showAssignVariablePopup, + getNodeAvailableVars, + getBeforeNodesInSameBranch, + isChatMode, + outputType, + ]) useClickAway(() => { if (nodeData._holdAddVariablePopup) { @@ -83,8 +74,7 @@ const AddVariablePopupWithPosition = ({ _holdAddVariablePopup: false, }, }) - } - else { + } else { handleNodeDataUpdate({ id: nodeId, data: { @@ -95,20 +85,22 @@ const AddVariablePopupWithPosition = ({ } }, ref) - const handleAddVariable = useCallback((value: ValueSelector, varDetail: Var) => { - if (showAssignVariablePopup) { - handleAddVariableInAddVariablePopupWithPosition( - showAssignVariablePopup.nodeId, - showAssignVariablePopup.variableAssignerNodeId, - showAssignVariablePopup.variableAssignerNodeHandleId, - value, - varDetail, - ) - } - }, [showAssignVariablePopup, handleAddVariableInAddVariablePopupWithPosition]) + const handleAddVariable = useCallback( + (value: ValueSelector, varDetail: Var) => { + if (showAssignVariablePopup) { + handleAddVariableInAddVariablePopupWithPosition( + showAssignVariablePopup.nodeId, + showAssignVariablePopup.variableAssignerNodeId, + showAssignVariablePopup.variableAssignerNodeHandleId, + value, + varDetail, + ) + } + }, + [showAssignVariablePopup, handleAddVariableInAddVariablePopupWithPosition], + ) - if (!showAssignVariablePopup) - return null + if (!showAssignVariablePopup) return null return ( <div @@ -119,10 +111,7 @@ const AddVariablePopupWithPosition = ({ }} ref={ref} > - <AddVariablePopup - availableVars={availableVars} - onSelect={handleAddVariable} - /> + <AddVariablePopup availableVars={availableVars} onSelect={handleAddVariable} /> </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/add-variable-popup.tsx b/web/app/components/workflow/nodes/_base/components/add-variable-popup.tsx index 67dbdc89798fa5..31fa858f097a26 100644 --- a/web/app/components/workflow/nodes/_base/components/add-variable-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/add-variable-popup.tsx @@ -1,8 +1,4 @@ -import type { - NodeOutPutVar, - ValueSelector, - Var, -} from '@/app/components/workflow/types' +import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { memo } from 'react' import { useTranslation } from 'react-i18next' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' @@ -11,24 +7,16 @@ type AddVariablePopupProps = { availableVars: NodeOutPutVar[] onSelect: (value: ValueSelector, item: Var) => void } -const AddVariablePopup = ({ - availableVars, - onSelect, -}: AddVariablePopupProps) => { +const AddVariablePopup = ({ availableVars, onSelect }: AddVariablePopupProps) => { const { t } = useTranslation() return ( <div className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg"> <div className="flex h-[34px] items-center border-b-[0.5px] border-b-divider-regular px-4 text-[13px] font-semibold text-text-secondary"> - {t($ => $['nodes.variableAssigner.setAssignVariable'], { ns: 'workflow' })} + {t(($) => $['nodes.variableAssigner.setAssignVariable'], { ns: 'workflow' })} </div> <div className="p-1"> - <VarReferenceVars - hideSearch - vars={availableVars} - onChange={onSelect} - isSupportFileVar - /> + <VarReferenceVars hideSearch vars={availableVars} onChange={onSelect} isSupportFileVar /> </div> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx index 0f80c151df86cd..7585aaa668f900 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx @@ -2,18 +2,13 @@ import type { ReactNode } from 'react' import type { ToolWithProvider } from '../../../types' import type { Strategy } from './agent-strategy' import type { StrategyPluginDetail } from '@/app/components/plugins/types' -import type { ListProps, ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list' +import type { + ListProps, + ListRef, +} from '@/app/components/workflow/block-selector/market-place-plugin/list' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useSuspenseQuery } from '@tanstack/react-query' import { memo, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -34,29 +29,29 @@ import { SwitchPluginVersion } from './switch-plugin-version' const DEFAULT_TAGS: ListProps['tags'] = [] -const NotFoundWarn = (props: { - title: ReactNode - description: ReactNode -}) => { +const NotFoundWarn = (props: { title: ReactNode; description: ReactNode }) => { const { title, description } = props const { t } = useTranslation() return ( <Tooltip> <TooltipTrigger - render={<div><span className="i-ri-error-warning-fill size-4 text-text-destructive" aria-hidden="true" /></div>} + render={ + <div> + <span + className="i-ri-error-warning-fill size-4 text-text-destructive" + aria-hidden="true" + /> + </div> + } /> <TooltipContent className="w-45"> <div className="space-y-1 text-xs"> - <h3 className="font-semibold text-text-primary"> - {title} - </h3> - <p className="tracking-tight text-text-secondary"> - {description} - </p> + <h3 className="font-semibold text-text-primary">{title}</h3> + <p className="tracking-tight text-text-secondary">{description}</p> <p> <Link href="/plugins" className="tracking-tight text-text-accent"> - {t($ => $['nodes.agent.linkToPlugin'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.linkToPlugin'], { ns: 'workflow' })} </Link> </p> </div> @@ -65,7 +60,10 @@ const NotFoundWarn = (props: { ) } -function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => string): ToolWithProvider[] { +function formatStrategy( + input: StrategyPluginDetail[], + getIcon: (i: string) => string, +): ToolWithProvider[] { return input.map((item) => { const res: ToolWithProvider = { id: item.plugin_unique_identifier, @@ -77,12 +75,13 @@ function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => s label: item.declaration.identity.label as ToolWithProvider['label'], type: CollectionType.all, meta: item.meta, - tools: item.declaration.strategies.map(strategy => ({ + tools: item.declaration.strategies.map((strategy) => ({ name: strategy.identity.name, author: strategy.identity.author, label: strategy.identity.label as ToolWithProvider['tools'][number]['label'], description: strategy.description, - parameters: strategy.parameters as unknown as ToolWithProvider['tools'][number]['parameters'], + parameters: + strategy.parameters as unknown as ToolWithProvider['tools'][number]['parameters'], output_schema: strategy.output_schema, labels: [], })), @@ -103,7 +102,7 @@ type AgentStrategySelectorProps = { export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => { const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + select: (s) => s.enable_marketplace, }) const { value, onChange } = props @@ -114,42 +113,44 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => const { getIconUrl } = useGetIcon() const list = stra.data ? formatStrategy(stra.data, getIconUrl) : undefined const filteredTools = useMemo(() => { - if (!list) - return [] - return list.filter(tool => tool.name.toLowerCase().includes(query.toLowerCase())) + if (!list) return [] + return list.filter((tool) => tool.name.toLowerCase().includes(query.toLowerCase())) }, [query, list]) const { strategyStatus, refetch: refetchStrategyInfo } = useStrategyInfo( value?.agent_strategy_provider_name, value?.agent_strategy_name, ) - const showPluginNotInstalledWarn = strategyStatus?.plugin?.source === 'external' - && !strategyStatus.plugin.installed && !!value + const showPluginNotInstalledWarn = + strategyStatus?.plugin?.source === 'external' && !strategyStatus.plugin.installed && !!value - const showUnsupportedStrategy = strategyStatus?.plugin.source === 'external' - && !strategyStatus?.isExistInPlugin && !!value + const showUnsupportedStrategy = + strategyStatus?.plugin.source === 'external' && !strategyStatus?.isExistInPlugin && !!value - const showSwitchVersion = !strategyStatus?.isExistInPlugin - && strategyStatus?.plugin.source === 'marketplace' && strategyStatus.plugin.installed && !!value + const showSwitchVersion = + !strategyStatus?.isExistInPlugin && + strategyStatus?.plugin.source === 'marketplace' && + strategyStatus.plugin.installed && + !!value - const showInstallButton = !strategyStatus?.isExistInPlugin - && strategyStatus?.plugin.source === 'marketplace' && !strategyStatus.plugin.installed && !!value + const showInstallButton = + !strategyStatus?.isExistInPlugin && + strategyStatus?.plugin.source === 'marketplace' && + !strategyStatus.plugin.installed && + !!value - const icon = list?.find( - coll => coll.tools?.find(tool => tool.name === value?.agent_strategy_name), + const icon = list?.find((coll) => + coll.tools?.find((tool) => tool.name === value?.agent_strategy_name), )?.icon as string | undefined const { t } = useTranslation() const wrapElemRef = useRef<HTMLDivElement>(null) - const { - queryPluginsWithDebounced: fetchPlugins, - plugins: notInstalledPlugins = [], - } = useMarketplacePlugins() + const { queryPluginsWithDebounced: fetchPlugins, plugins: notInstalledPlugins = [] } = + useMarketplacePlugins() useEffect(() => { - if (!enable_marketplace) - return + if (!enable_marketplace) return if (query) { fetchPlugins({ query, @@ -163,7 +164,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <div className="flex h-8 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 select-none hover:bg-state-base-hover-alt"> {icon && ( <div className="flex size-6 items-center justify-center"> @@ -177,46 +178,57 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => </div> )} <p - className={cn(value ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder', 'px-1 text-xs')} + className={cn( + value + ? 'text-components-input-text-filled' + : 'text-components-input-text-placeholder', + 'px-1 text-xs', + )} > - {value?.agent_strategy_label || t($ => $['nodes.agent.strategy.selectTip'], { ns: 'workflow' })} + {value?.agent_strategy_label || + t(($) => $['nodes.agent.strategy.selectTip'], { ns: 'workflow' })} </p> <div className="ml-auto flex items-center gap-1"> {showInstallButton && value && ( <InstallPluginButton - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} size="small" uniqueIdentifier={value.plugin_unique_identifier} /> )} - {showPluginNotInstalledWarn - ? ( - <NotFoundWarn - title={t($ => $['nodes.agent.pluginNotInstalled'], { ns: 'workflow' })} - description={t($ => $['nodes.agent.pluginNotInstalledDesc'], { ns: 'workflow' })} - /> - ) - : showUnsupportedStrategy - ? ( - <NotFoundWarn - title={t($ => $['nodes.agent.unsupportedStrategy'], { ns: 'workflow' })} - description={t($ => $['nodes.agent.strategyNotFoundDesc'], { ns: 'workflow' })} - /> - ) - : <span className="i-ri-arrow-down-s-line size-4 text-text-tertiary" aria-hidden="true" />} + {showPluginNotInstalledWarn ? ( + <NotFoundWarn + title={t(($) => $['nodes.agent.pluginNotInstalled'], { ns: 'workflow' })} + description={t(($) => $['nodes.agent.pluginNotInstalledDesc'], { + ns: 'workflow', + })} + /> + ) : showUnsupportedStrategy ? ( + <NotFoundWarn + title={t(($) => $['nodes.agent.unsupportedStrategy'], { ns: 'workflow' })} + description={t(($) => $['nodes.agent.strategyNotFoundDesc'], { ns: 'workflow' })} + /> + ) : ( + <span + className="i-ri-arrow-down-s-line size-4 text-text-tertiary" + aria-hidden="true" + /> + )} {showSwitchVersion && value && ( <SwitchPluginVersion uniqueIdentifier={value.plugin_unique_identifier} - tooltip={( + tooltip={ <div className="w-45 space-y-1 text-xs"> <h3 className="font-semibold text-text-primary"> - {t($ => $['nodes.agent.unsupportedStrategy'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.unsupportedStrategy'], { ns: 'workflow' })} </h3> <p className="text-text-tertiary"> - {t($ => $['nodes.agent.strategyNotFoundDescAndSwitchVersion'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.strategyNotFoundDescAndSwitchVersion'], { + ns: 'workflow', + })} </p> </div> - )} + } onChange={() => { refetchStrategyInfo() }} @@ -224,7 +236,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => )} </div> </div> - )} + } /> <PopoverContent placement="bottom" @@ -233,10 +245,20 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => > <div className="w-97 overflow-hidden rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow"> <header className="flex gap-1 p-2"> - <SearchInput placeholder={t($ => $['nodes.agent.strategy.searchPlaceholder'], { ns: 'workflow' })} value={query} onValueChange={setQuery} className="w-full" /> + <SearchInput + placeholder={t(($) => $['nodes.agent.strategy.searchPlaceholder'], { + ns: 'workflow', + })} + value={query} + onValueChange={setQuery} + className="w-full" + /> <ViewTypeSelect viewType={viewType} onChange={setViewType} /> </header> - <div className="relative flex w-full flex-col overflow-hidden md:max-h-75 xl:max-h-100 2xl:max-h-141" ref={wrapElemRef}> + <div + className="relative flex w-full flex-col overflow-hidden md:max-h-75 xl:max-h-100 2xl:max-h-141" + ref={wrapElemRef} + > <Tools tools={filteredTools} viewType={viewType} diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index b0f94eb0360b00..4454dace7559f1 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -2,7 +2,11 @@ import type { ComponentProps } from 'react' import type { Node } from 'reactflow' import type { NodeOutPutVar } from '../../../types' import type { ToolVarInputs } from '../../tool/types' -import type { CredentialFormSchema, CredentialFormSchemaNumberInput, CredentialFormSchemaTextInput } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + CredentialFormSchema, + CredentialFormSchemaNumberInput, + CredentialFormSchemaTextInput, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { PluginMeta } from '@/app/components/plugins/types' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' import { @@ -19,7 +23,10 @@ import { memo } from 'react' import { useTranslation } from 'react-i18next' import { Agent } from '@/app/components/base/icons/src/vender/workflow' import ListEmpty from '@/app/components/base/list-empty' -import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + FormTypeEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' import MultipleToolSelector from '@/app/components/plugins/plugin-detail-panel/multiple-tool-selector' @@ -61,15 +68,22 @@ type MultipleToolSelectorSchema = CustomSchema<'array[tools]'> type CustomField = ToolSelectorSchema | MultipleToolSelectorSchema export const AgentStrategy = memo((props: AgentStrategyProps) => { - const { strategy, onStrategyChange, formSchema, formValue, onFormValueChange, nodeOutputVars, availableNodes, nodeId } = props + const { + strategy, + onStrategyChange, + formSchema, + formValue, + onFormValueChange, + nodeOutputVars, + availableNodes, + nodeId, + } = props const { t } = useTranslation() const docLink = useDocLink() const defaultModel = useDefaultModel(ModelTypeEnum.textGeneration) const renderI18nObject = useRenderI18nObject() const workflowStore = useWorkflowStore() - const { - setControlPromptEditorRerenderKey, - } = workflowStore.getState() + const { setControlPromptEditorRerenderKey } = workflowStore.getState() const override: ComponentProps<typeof Form<CustomField>>['override'] = [ [FormTypeEnum.textNumber, FormTypeEnum.textInput], @@ -124,8 +138,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { } case FormTypeEnum.textNumber: { const def = schema as CredentialFormSchemaNumberInput - if (def.max == null || def.min == null) - return false + if (def.max == null || def.min == null) return false const defaultValue = schema.default ? Number.parseInt(schema.default) : 1 const value = props.value[schema.variable] ?? defaultValue @@ -135,13 +148,11 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { } return ( <Field - title={( + title={ <> - {label} - {' '} - {def.required && <span className="text-red-500">*</span>} + {label} {def.required && <span className="text-red-500">*</span>} </> - )} + } key={def.variable} tooltip={def.tooltip && renderI18nObject(def.tooltip)} inline @@ -160,7 +171,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { value={value} min={def.min} max={def.max} - onValueChange={nextValue => onChange(nextValue ?? defaultValue)} + onValueChange={(nextValue) => onChange(nextValue ?? defaultValue)} > <NumberFieldGroup> <NumberFieldInput aria-label={label} className="w-12" /> @@ -177,7 +188,10 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { } }, ] - const renderField: ComponentProps<typeof Form<CustomField>>['customRenderField'] = (schema, props) => { + const renderField: ComponentProps<typeof Form<CustomField>>['customRenderField'] = ( + schema, + props, + ) => { switch (schema.type) { case FormTypeEnum.toolSelector: { const value = props.value[schema.variable] @@ -186,13 +200,12 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { } return ( <Field - title={( + title={ <> - {renderI18nObject(schema.label)} - {' '} + {renderI18nObject(schema.label)}{' '} {schema.required && <span className="text-red-500">*</span>} </> - )} + } tooltip={schema.tooltip && renderI18nObject(schema.tooltip)} > <ToolSelector @@ -201,7 +214,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { availableNodes={props.availableNodes || []} scope={schema.scope} value={value} - onSelect={item => onChange(item)} + onSelect={(item) => onChange(item)} onDelete={() => onChange(null)} onSelectMultiple={noop} /> @@ -233,51 +246,43 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { return ( <div className="space-y-2"> <AgentStrategySelector value={strategy} onChange={onStrategyChange} /> - { - strategy - ? ( - <div> - <Form<CustomField> - formSchemas={[ - ...formSchema, - ]} - value={formValue} - onChange={onFormValueChange} - validating={false} - showOnVariableMap={{}} - isEditMode={true} - isAgentStrategy={true} - fieldLabelClassName="uppercase" - customRenderField={renderField} - override={override} - nodeId={nodeId} - nodeOutputVars={nodeOutputVars || []} - availableNodes={availableNodes || []} - /> - </div> - ) - : ( - <ListEmpty - icon={<Agent className="size-5 shrink-0 text-text-accent" />} - title={t($ => $['nodes.agent.strategy.configureTip'], { ns: 'workflow' })} - description={( - <div className="text-xs text-text-tertiary"> - {t($ => $['nodes.agent.strategy.configureTipDesc'], { ns: 'workflow' })} - {' '} - <br /> - <Link - href={docLink('/use-dify/nodes/agent')} - className="text-text-accent-secondary" - target="_blank" - rel="noopener noreferrer" - > - {t($ => $['nodes.agent.learnMore'], { ns: 'workflow' })} - </Link> - </div> - )} - /> - ) - } + {strategy ? ( + <div> + <Form<CustomField> + formSchemas={[...formSchema]} + value={formValue} + onChange={onFormValueChange} + validating={false} + showOnVariableMap={{}} + isEditMode={true} + isAgentStrategy={true} + fieldLabelClassName="uppercase" + customRenderField={renderField} + override={override} + nodeId={nodeId} + nodeOutputVars={nodeOutputVars || []} + availableNodes={availableNodes || []} + /> + </div> + ) : ( + <ListEmpty + icon={<Agent className="size-5 shrink-0 text-text-accent" />} + title={t(($) => $['nodes.agent.strategy.configureTip'], { ns: 'workflow' })} + description={ + <div className="text-xs text-text-tertiary"> + {t(($) => $['nodes.agent.strategy.configureTipDesc'], { ns: 'workflow' })} <br /> + <Link + href={docLink('/use-dify/nodes/agent')} + className="text-text-accent-secondary" + target="_blank" + rel="noopener noreferrer" + > + {t(($) => $['nodes.agent.learnMore'], { ns: 'workflow' })} + </Link> + </div> + } + /> + )} </div> ) }) diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts b/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts index 123968b23c6a75..3c0a39378ff3d6 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts @@ -14,7 +14,8 @@ import { type FormArg = Parameters<typeof buildSubmitData>[0][number] describe('before-run-form helpers', () => { - const createValues = (values: Record<string, unknown>) => values as unknown as Record<string, string> + const createValues = (values: Record<string, unknown>) => + values as unknown as Record<string, string> const createInput = (input: Partial<InputVar>): InputVar => ({ variable: 'field', label: 'Field', @@ -22,12 +23,13 @@ describe('before-run-form helpers', () => { required: false, ...input, }) - const createForm = (form: Partial<FormArg>): FormArg => ({ - inputs: [], - values: createValues({}), - onChange: vi.fn(), - ...form, - } as FormArg) + const createForm = (form: Partial<FormArg>): FormArg => + ({ + inputs: [], + values: createValues({}), + onChange: vi.fn(), + ...form, + }) as FormArg it('should format values by input type', () => { expect(formatValue('12.5', InputVarType.number)).toBe(12.5) @@ -35,88 +37,179 @@ describe('before-run-form helpers', () => { expect(formatValue('', InputVarType.checkbox)).toBe(false) expect(formatValue(['{"foo":1}'], InputVarType.contexts)).toEqual([{ foo: 1 }]) expect(formatValue(null, InputVarType.singleFile)).toBeNull() - expect(formatValue([{ transfer_method: TransferMethod.remote_url, related_id: '3' }], InputVarType.singleFile)).toEqual(expect.any(Array)) + expect( + formatValue( + [{ transfer_method: TransferMethod.remote_url, related_id: '3' }], + InputVarType.singleFile, + ), + ).toEqual(expect.any(Array)) expect(formatValue('', InputVarType.singleFile)).toBeUndefined() }) it('should detect when file uploads are still in progress', () => { expect(isFilesLoaded([])).toBe(true) expect(isFilesLoaded([createForm({ inputs: [], values: {} })])).toBe(true) - expect(isFilesLoaded([createForm({ - inputs: [], - values: createValues({ - '#files#': [{ transfer_method: TransferMethod.local_file }], - }), - })])).toBe(false) + expect( + isFilesLoaded([ + createForm({ + inputs: [], + values: createValues({ + '#files#': [{ transfer_method: TransferMethod.local_file }], + }), + }), + ]), + ).toBe(false) }) it('should report required and uploading file errors', () => { - const t = withSelectorKey((key: string, options?: Record<string, unknown>) => ( - `${key}:${options?.field ?? ''}` - )) + const t = withSelectorKey( + (key: string, options?: Record<string, unknown>) => `${key}:${options?.field ?? ''}`, + ) - expect(getFormErrorMessage([createForm({ - inputs: [createInput({ variable: 'query', label: 'Query', required: true })], - values: createValues({ query: '' }), - })], [{}], t)).toContain('errorMsg.fieldRequired') + expect( + getFormErrorMessage( + [ + createForm({ + inputs: [createInput({ variable: 'query', label: 'Query', required: true })], + values: createValues({ query: '' }), + }), + ], + [{}], + t, + ), + ).toContain('errorMsg.fieldRequired') - expect(getFormErrorMessage([createForm({ - inputs: [createInput({ variable: 'file', label: 'File', type: InputVarType.singleFile, required: true })], - values: createValues({ file: [] }), - })], [{}], t)).toContain('errorMsg.fieldRequired') + expect( + getFormErrorMessage( + [ + createForm({ + inputs: [ + createInput({ + variable: 'file', + label: 'File', + type: InputVarType.singleFile, + required: true, + }), + ], + values: createValues({ file: [] }), + }), + ], + [{}], + t, + ), + ).toContain('errorMsg.fieldRequired') - expect(getFormErrorMessage([createForm({ - inputs: [createInput({ variable: 'files', label: 'Files', type: InputVarType.multiFiles, required: true })], - values: createValues({ files: [] }), - })], [{}], t)).toContain('errorMsg.fieldRequired') + expect( + getFormErrorMessage( + [ + createForm({ + inputs: [ + createInput({ + variable: 'files', + label: 'Files', + type: InputVarType.multiFiles, + required: true, + }), + ], + values: createValues({ files: [] }), + }), + ], + [{}], + t, + ), + ).toContain('errorMsg.fieldRequired') - expect(getFormErrorMessage([createForm({ - inputs: [createInput({ variable: 'file', label: 'File', type: InputVarType.singleFile })], - values: createValues({ file: { transferMethod: TransferMethod.local_file } }), - })], [{}], t)).toContain('errorMessage.waitForFileUpload') + expect( + getFormErrorMessage( + [ + createForm({ + inputs: [ + createInput({ variable: 'file', label: 'File', type: InputVarType.singleFile }), + ], + values: createValues({ file: { transferMethod: TransferMethod.local_file } }), + }), + ], + [{}], + t, + ), + ).toContain('errorMessage.waitForFileUpload') - expect(getFormErrorMessage([createForm({ - inputs: [createInput({ variable: 'files', label: 'Files', type: InputVarType.multiFiles })], - values: createValues({ files: [{ transferMethod: TransferMethod.local_file }] }), - })], [{}], t)).toContain('errorMessage.waitForFileUpload') + expect( + getFormErrorMessage( + [ + createForm({ + inputs: [ + createInput({ variable: 'files', label: 'Files', type: InputVarType.multiFiles }), + ], + values: createValues({ files: [{ transferMethod: TransferMethod.local_file }] }), + }), + ], + [{}], + t, + ), + ).toContain('errorMessage.waitForFileUpload') - expect(getFormErrorMessage([createForm({ - inputs: [createInput({ - variable: 'config', - label: { nodeType: BlockEnum.Tool, nodeName: 'Tool', variable: 'Config' }, - required: true, - })], - values: createValues({ config: '' }), - })], [{}], t)).toContain('Config') + expect( + getFormErrorMessage( + [ + createForm({ + inputs: [ + createInput({ + variable: 'config', + label: { nodeType: BlockEnum.Tool, nodeName: 'Tool', variable: 'Config' }, + required: true, + }), + ], + values: createValues({ config: '' }), + }), + ], + [{}], + t, + ), + ).toContain('Config') }) it('should build submit data and keep parse errors', () => { - expect(buildSubmitData([createForm({ - inputs: [createInput({ variable: 'query' })], - values: createValues({ query: 'hello' }), - })])).toEqual({ + expect( + buildSubmitData([ + createForm({ + inputs: [createInput({ variable: 'query' })], + values: createValues({ query: 'hello' }), + }), + ]), + ).toEqual({ submitData: { query: 'hello' }, parseErrorJsonField: '', }) - expect(buildSubmitData([createForm({ - inputs: [createInput({ variable: 'payload', type: InputVarType.json })], - values: createValues({ payload: '{' }), - })]).parseErrorJsonField).toBe('payload') + expect( + buildSubmitData([ + createForm({ + inputs: [createInput({ variable: 'payload', type: InputVarType.json })], + values: createValues({ payload: '{' }), + }), + ]).parseErrorJsonField, + ).toBe('payload') - expect(buildSubmitData([createForm({ - inputs: [ - createInput({ variable: 'files', type: InputVarType.multiFiles }), - createInput({ variable: 'file', type: InputVarType.singleFile }), - ], - values: createValues({ - files: [{ transfer_method: TransferMethod.remote_url, related_id: '1' }], - file: { transfer_method: TransferMethod.remote_url, related_id: '2' }, + expect( + buildSubmitData([ + createForm({ + inputs: [ + createInput({ variable: 'files', type: InputVarType.multiFiles }), + createInput({ variable: 'file', type: InputVarType.singleFile }), + ], + values: createValues({ + files: [{ transfer_method: TransferMethod.remote_url, related_id: '1' }], + file: { transfer_method: TransferMethod.remote_url, related_id: '2' }, + }), + }), + ]).submitData, + ).toEqual( + expect.objectContaining({ + files: expect.any(Array), + file: expect.any(Object), }), - })]).submitData).toEqual(expect.objectContaining({ - files: expect.any(Array), - file: expect.any(Object), - })) + ) }) it('should derive the zero-form auto behaviors', () => { diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx index 39b9fd88882edf..e3896e8db71fc0 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx @@ -12,11 +12,13 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) vi.mock('../form', () => ({ - default: ({ values }: { values: Record<string, unknown> }) => <div>{Object.keys(values).join(',')}</div>, + default: ({ values }: { values: Record<string, unknown> }) => ( + <div>{Object.keys(values).join(',')}</div> + ), })) vi.mock('../panel-wrap', () => ({ - default: ({ children, nodeName }: { children: React.ReactNode, nodeName: string }) => ( + default: ({ children, nodeName }: { children: React.ReactNode; nodeName: string }) => ( <div> <div>{nodeName}</div> {children} @@ -25,7 +27,13 @@ vi.mock('../panel-wrap', () => ({ })) vi.mock('@/app/components/workflow/nodes/human-input/components/single-run-form', () => ({ - default: ({ onSubmit, handleBack }: { onSubmit: (data: Record<string, unknown>) => void, handleBack?: () => void }) => ( + default: ({ + onSubmit, + handleBack, + }: { + onSubmit: (data: Record<string, unknown>) => void + handleBack?: () => void + }) => ( <div> <div>single-run-form</div> <button onClick={() => onSubmit({ approved: true })}>submit-generated-form</button> @@ -77,14 +85,22 @@ describe('BeforeRunForm', () => { render( <BeforeRunForm {...createProps({ - forms: [createForm({ - inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }], - values: { query: '' }, - })], - filteredExistVarForms: [createForm({ - inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }], - values: { query: '' }, - })], + forms: [ + createForm({ + inputs: [ + { variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }, + ], + values: { query: '' }, + }), + ], + filteredExistVarForms: [ + createForm({ + inputs: [ + { variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }, + ], + values: { query: '' }, + }), + ], existVarValuesInForms: [{}], })} />, @@ -103,21 +119,31 @@ describe('BeforeRunForm', () => { {...createProps({ nodeName: 'Human input', nodeType: BlockEnum.HumanInput, - forms: [createForm({ - inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }], - values: { query: 'hello' }, - })], - filteredExistVarForms: [createForm({ - inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }], - values: { query: 'hello' }, - })], + forms: [ + createForm({ + inputs: [ + { variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }, + ], + values: { query: 'hello' }, + }), + ], + filteredExistVarForms: [ + createForm({ + inputs: [ + { variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }, + ], + values: { query: 'hello' }, + }), + ], existVarValuesInForms: [{}], handleShowGeneratedForm, })} />, ) - fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.singleRun.button' })) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.nodes.humanInput.singleRun.button' }), + ) expect(handleShowGeneratedForm).toHaveBeenCalledWith({ query: 'hello' }) }) @@ -132,14 +158,22 @@ describe('BeforeRunForm', () => { {...createProps({ nodeName: 'Human input', nodeType: BlockEnum.HumanInput, - forms: [createForm({ - inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }], - values: { query: 'hello' }, - })], - filteredExistVarForms: [createForm({ - inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }], - values: { query: 'hello' }, - })], + forms: [ + createForm({ + inputs: [ + { variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }, + ], + values: { query: 'hello' }, + }), + ], + filteredExistVarForms: [ + createForm({ + inputs: [ + { variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }, + ], + values: { query: 'hello' }, + }), + ], existVarValuesInForms: [{}], showGeneratedForm: true, formData: {} as BeforeRunFormProps['formData'], @@ -168,14 +202,22 @@ describe('BeforeRunForm', () => { <BeforeRunForm {...createProps({ onRun, - forms: [createForm({ - inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }], - values: { query: 'hello' }, - })], - filteredExistVarForms: [createForm({ - inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }], - values: { query: 'hello' }, - })], + forms: [ + createForm({ + inputs: [ + { variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }, + ], + values: { query: 'hello' }, + }), + ], + filteredExistVarForms: [ + createForm({ + inputs: [ + { variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }, + ], + values: { query: 'hello' }, + }), + ], existVarValuesInForms: [{}], })} />, @@ -199,21 +241,31 @@ describe('BeforeRunForm', () => { ) expect(handleShowGeneratedForm).toHaveBeenCalledWith({}) - expect(screen.getByRole('button', { name: 'workflow.nodes.humanInput.singleRun.button' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'workflow.nodes.humanInput.singleRun.button' }), + ).toBeInTheDocument() }) it('should show an error toast when json input is invalid', () => { render( <BeforeRunForm {...createProps({ - forms: [createForm({ - inputs: [{ variable: 'payload', label: 'Payload', type: InputVarType.json, required: true }], - values: { payload: '{' }, - })], - filteredExistVarForms: [createForm({ - inputs: [{ variable: 'payload', label: 'Payload', type: InputVarType.json, required: true }], - values: { payload: '{' }, - })], + forms: [ + createForm({ + inputs: [ + { variable: 'payload', label: 'Payload', type: InputVarType.json, required: true }, + ], + values: { payload: '{' }, + }), + ], + filteredExistVarForms: [ + createForm({ + inputs: [ + { variable: 'payload', label: 'Payload', type: InputVarType.json, required: true }, + ], + values: { payload: '{' }, + }), + ], existVarValuesInForms: [{}], })} />, diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/bool-input.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/bool-input.tsx index e9f3aa1e8541a2..9828027c175af2 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/bool-input.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/bool-input.tsx @@ -13,17 +13,14 @@ type Props = Readonly<{ readonly?: boolean }> -const BoolInput: FC<Props> = ({ - value, - onChange, - name, - required, - readonly, -}) => { +const BoolInput: FC<Props> = ({ value, onChange, name, required, readonly }) => { const { t } = useTranslation() - const handleChange = useCallback((checked: boolean) => { - onChange(checked) - }, [onChange]) + const handleChange = useCallback( + (checked: boolean) => { + onChange(checked) + }, + [onChange], + ) return ( <label className="flex h-6 items-center gap-2"> <Checkbox @@ -34,7 +31,11 @@ const BoolInput: FC<Props> = ({ /> <div className="flex items-center gap-1 system-sm-medium text-text-secondary"> {name} - {!required && <span className="system-xs-regular text-text-tertiary">{t($ => $['panel.optional'], { ns: 'workflow' })}</span>} + {!required && ( + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['panel.optional'], { ns: 'workflow' })} + </span> + )} </div> </label> ) diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx index badff3bb8ab5b2..10df6c6197f595 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx @@ -3,11 +3,16 @@ import type { FC } from 'react' import type { InputVar } from '../../../../types' import type { FileEntity } from '@/app/components/base/file-uploader/types' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' -import { Textarea } from '@langgenius/dify-ui/textarea' import { - RiDeleteBinLine, -} from '@remixicon/react' + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' +import { Textarea } from '@langgenius/dify-ui/textarea' +import { RiDeleteBinLine } from '@remixicon/react' import { produce } from 'immer' import * as React from 'react' import { useCallback, useMemo } from 'react' @@ -47,31 +52,36 @@ const FormItem: FC<Props> = ({ }) => { const { t } = useTranslation() const { type } = payload - const fileSettings = useHooksStore(s => s.configsMap?.fileSettings) + const fileSettings = useHooksStore((s) => s.configsMap?.fileSettings) const jsonSchemaPlaceholder = React.useMemo(() => { const schema = (payload as any)?.json_schema - if (!schema) - return '' + if (!schema) return '' return typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2) }, [payload]) - const handleArrayItemChange = useCallback((index: number) => { - return (newValue: any) => { - const newValues = produce(value, (draft: any) => { - draft[index] = newValue - }) - onChange(newValues) - } - }, [value, onChange]) + const handleArrayItemChange = useCallback( + (index: number) => { + return (newValue: any) => { + const newValues = produce(value, (draft: any) => { + draft[index] = newValue + }) + onChange(newValues) + } + }, + [value, onChange], + ) - const handleArrayItemRemove = useCallback((index: number) => { - return () => { - const newValues = produce(value, (draft: any) => { - draft.splice(index, 1) - }) - onChange(newValues) - } - }, [value, onChange]) + const handleArrayItemRemove = useCallback( + (index: number) => { + return () => { + const newValues = produce(value, (draft: any) => { + draft.splice(index, 1) + }) + onChange(newValues) + } + }, + [value, onChange], + ) const nodeKey = (() => { if (typeof payload.label === 'object') { const { nodeType, nodeName, variable, isChatVar } = payload.label @@ -82,7 +92,10 @@ const FormItem: FC<Props> = ({ <div className="p-px"> <VarBlockIcon type={nodeType || BlockEnum.Start} /> </div> - <div className="mx-0.5 max-w-[150px] truncate text-xs font-medium text-text-secondary" title={nodeName}> + <div + className="mx-0.5 max-w-[150px] truncate text-xs font-medium text-text-secondary" + title={nodeName} + > {nodeName} </div> <Line3 className="mr-0.5"></Line3> @@ -91,7 +104,13 @@ const FormItem: FC<Props> = ({ <div className="flex items-center text-primary-600"> {!isChatVar && <Variable02 className="size-3.5" />} {isChatVar && <BubbleX className="size-3.5 text-util-colors-teal-teal-700" />} - <div className={cn('ml-0.5 max-w-[150px] truncate text-xs font-medium', isChatVar && 'text-text-secondary')} title={variable}> + <div + className={cn( + 'ml-0.5 max-w-[150px] truncate text-xs font-medium', + isChatVar && 'text-text-secondary', + )} + title={variable} + > {variable} </div> </div> @@ -107,19 +126,18 @@ const FormItem: FC<Props> = ({ const isIterator = type === InputVarType.iterator const isIteratorItemFile = isIterator && payload.isFileItem const singleFileValue = useMemo(() => { - if (payload.variable === '#files#') - return value || [] + if (payload.variable === '#files#') return value || [] return value ? [value] : [] }, [payload.variable, value]) - const handleSingleFileChange = useCallback((files: FileEntity[]) => { - if (payload.variable === '#files#') - onChange(files) - else if (files.length) - onChange(files[0]) - else - onChange(null) - }, [onChange, payload.variable]) + const handleSingleFileChange = useCallback( + (files: FileEntity[]) => { + if (payload.variable === '#files#') onChange(files) + else if (files.length) onChange(files[0]) + else onChange(null) + }, + [onChange, payload.variable], + ) return ( <div className={cn(className)}> @@ -128,81 +146,72 @@ const FormItem: FC<Props> = ({ <div className="truncate"> {typeof payload.label === 'object' ? nodeKey : payload.label} </div> - {payload.hide === true - ? ( - <span className="system-xs-regular text-text-tertiary"> - {t($ => $['panel.optional_and_hidden'], { ns: 'workflow' })} - </span> - ) - : ( - !payload.required && ( - <span className="system-xs-regular text-text-tertiary"> - {t($ => $['panel.optional'], { ns: 'workflow' })} - </span> - ) - )} + {payload.hide === true ? ( + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['panel.optional_and_hidden'], { ns: 'workflow' })} + </span> + ) : ( + !payload.required && ( + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['panel.optional'], { ns: 'workflow' })} + </span> + ) + )} </div> )} <div className="grow"> - { - type === InputVarType.textInput && ( - <Input - value={value || ''} - onChange={e => onChange(e.target.value)} - placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label} - autoFocus={autoFocus} - /> - ) - } + {type === InputVarType.textInput && ( + <Input + value={value || ''} + onChange={(e) => onChange(e.target.value)} + placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label} + autoFocus={autoFocus} + /> + )} - { - type === InputVarType.number && ( - <Input - type="number" - value={value || ''} - onChange={e => onChange(e.target.value)} - placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label} - autoFocus={autoFocus} - /> - ) - } + {type === InputVarType.number && ( + <Input + type="number" + value={value || ''} + onChange={(e) => onChange(e.target.value)} + placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label} + autoFocus={autoFocus} + /> + )} - { - type === InputVarType.paragraph && ( - <Textarea - aria-label={typeof payload.label === 'object' ? payload.label.variable : payload.label} - value={value || ''} - onValueChange={value => onChange(value)} - placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label} - autoFocus={autoFocus} - /> - ) - } + {type === InputVarType.paragraph && ( + <Textarea + aria-label={typeof payload.label === 'object' ? payload.label.variable : payload.label} + value={value || ''} + onValueChange={(value) => onChange(value)} + placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label} + autoFocus={autoFocus} + /> + )} - { - type === InputVarType.select && ( - <Select - value={value || payload.default || null} - onValueChange={(nextValue) => { - if (!nextValue) - return - onChange(nextValue) - }} - > - <SelectTrigger className="w-full"> - {String(value || payload.default || t($ => $['placeholder.select'], { ns: 'common' }))} - </SelectTrigger> - <SelectContent> - {(payload.options || []).map(option => ( - <SelectItem key={option} value={option}> - <SelectItemText>{option}</SelectItemText> - <SelectItemIndicator /> - </SelectItem> - ))} - </SelectContent> - </Select> - ) - } + {type === InputVarType.select && ( + <Select + value={value || payload.default || null} + onValueChange={(nextValue) => { + if (!nextValue) return + onChange(nextValue) + }} + > + <SelectTrigger className="w-full"> + {String( + value || payload.default || t(($) => $['placeholder.select'], { ns: 'common' }), + )} + </SelectTrigger> + <SelectContent> + {(payload.options || []).map((option) => ( + <SelectItem key={option} value={option}> + <SelectItemText>{option}</SelectItemText> + <SelectItemIndicator /> + </SelectItem> + ))} + </SelectContent> + </Select> + )} {isBooleanType && ( <BoolInput @@ -213,16 +222,14 @@ const FormItem: FC<Props> = ({ /> )} - { - type === InputVarType.json && ( - <CodeEditor - value={value} - title={<span>JSON</span>} - language={CodeLanguage.json} - onChange={onChange} - /> - ) - } + {type === InputVarType.json && ( + <CodeEditor + value={value} + title={<span>JSON</span>} + language={CodeLanguage.json} + onChange={onChange} + /> + )} {type === InputVarType.jsonObject && ( <CodeEditor value={value} @@ -230,33 +237,37 @@ const FormItem: FC<Props> = ({ onChange={onChange} noWrapper className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" - placeholder={ - <div className="whitespace-pre">{jsonSchemaPlaceholder}</div> - } + placeholder={<div className="whitespace-pre">{jsonSchemaPlaceholder}</div>} /> )} - {(type === InputVarType.singleFile) && ( + {type === InputVarType.singleFile && ( <FileUploaderInAttachmentWrapper value={singleFileValue} onChange={handleSingleFileChange} fileConfig={{ - allowed_file_types: inStepRun && (!payload.allowed_file_types || payload.allowed_file_types.length === 0) - ? [ - SupportUploadFileTypes.image, - SupportUploadFileTypes.document, - SupportUploadFileTypes.audio, - SupportUploadFileTypes.video, - ] - : payload.allowed_file_types, - allowed_file_extensions: inStepRun && (!payload.allowed_file_extensions || payload.allowed_file_extensions.length === 0) - ? [ - ...(FILE_EXTS[SupportUploadFileTypes.image] ?? []), - ...(FILE_EXTS[SupportUploadFileTypes.document] ?? []), - ...(FILE_EXTS[SupportUploadFileTypes.audio] ?? []), - ...(FILE_EXTS[SupportUploadFileTypes.video] ?? []), - ] - : payload.allowed_file_extensions, - allowed_file_upload_methods: inStepRun ? [TransferMethod.local_file, TransferMethod.remote_url] : payload.allowed_file_upload_methods, + allowed_file_types: + inStepRun && + (!payload.allowed_file_types || payload.allowed_file_types.length === 0) + ? [ + SupportUploadFileTypes.image, + SupportUploadFileTypes.document, + SupportUploadFileTypes.audio, + SupportUploadFileTypes.video, + ] + : payload.allowed_file_types, + allowed_file_extensions: + inStepRun && + (!payload.allowed_file_extensions || payload.allowed_file_extensions.length === 0) + ? [ + ...(FILE_EXTS[SupportUploadFileTypes.image] ?? []), + ...(FILE_EXTS[SupportUploadFileTypes.document] ?? []), + ...(FILE_EXTS[SupportUploadFileTypes.audio] ?? []), + ...(FILE_EXTS[SupportUploadFileTypes.video] ?? []), + ] + : payload.allowed_file_extensions, + allowed_file_upload_methods: inStepRun + ? [TransferMethod.local_file, TransferMethod.remote_url] + : payload.allowed_file_upload_methods, number_limits: 1, fileUploadConfig: fileSettings?.fileUploadConfig, }} @@ -265,106 +276,108 @@ const FormItem: FC<Props> = ({ {(type === InputVarType.multiFiles || isIteratorItemFile) && ( <FileUploaderInAttachmentWrapper value={value} - onChange={files => onChange(files)} + onChange={(files) => onChange(files)} fileConfig={{ - allowed_file_types: (inStepRun || isIteratorItemFile) && (!payload.allowed_file_types || payload.allowed_file_types.length === 0) - ? [ - SupportUploadFileTypes.image, - SupportUploadFileTypes.document, - SupportUploadFileTypes.audio, - SupportUploadFileTypes.video, - ] - : payload.allowed_file_types, - allowed_file_extensions: (inStepRun || isIteratorItemFile) && (!payload.allowed_file_extensions || payload.allowed_file_extensions.length === 0) - ? [ - ...(FILE_EXTS[SupportUploadFileTypes.image] ?? []), - ...(FILE_EXTS[SupportUploadFileTypes.document] ?? []), - ...(FILE_EXTS[SupportUploadFileTypes.audio] ?? []), - ...(FILE_EXTS[SupportUploadFileTypes.video] ?? []), - ] - : payload.allowed_file_extensions, - allowed_file_upload_methods: (inStepRun || isIteratorItemFile) ? [TransferMethod.local_file, TransferMethod.remote_url] : payload.allowed_file_upload_methods, - number_limits: (inStepRun || isIteratorItemFile) ? 5 : payload.max_length, + allowed_file_types: + (inStepRun || isIteratorItemFile) && + (!payload.allowed_file_types || payload.allowed_file_types.length === 0) + ? [ + SupportUploadFileTypes.image, + SupportUploadFileTypes.document, + SupportUploadFileTypes.audio, + SupportUploadFileTypes.video, + ] + : payload.allowed_file_types, + allowed_file_extensions: + (inStepRun || isIteratorItemFile) && + (!payload.allowed_file_extensions || payload.allowed_file_extensions.length === 0) + ? [ + ...(FILE_EXTS[SupportUploadFileTypes.image] ?? []), + ...(FILE_EXTS[SupportUploadFileTypes.document] ?? []), + ...(FILE_EXTS[SupportUploadFileTypes.audio] ?? []), + ...(FILE_EXTS[SupportUploadFileTypes.video] ?? []), + ] + : payload.allowed_file_extensions, + allowed_file_upload_methods: + inStepRun || isIteratorItemFile + ? [TransferMethod.local_file, TransferMethod.remote_url] + : payload.allowed_file_upload_methods, + number_limits: inStepRun || isIteratorItemFile ? 5 : payload.max_length, fileUploadConfig: fileSettings?.fileUploadConfig, }} /> )} - { - type === InputVarType.files && ( - <TextGenerationImageUploader - settings={{ + {type === InputVarType.files && ( + <TextGenerationImageUploader + settings={ + { ...fileSettings, detail: fileSettings?.image?.detail || Resolution.high, transfer_methods: fileSettings?.allowed_file_upload_methods || [], - } as any} - onFilesChange={files => onChange(files.filter(file => file.progress !== -1).map(fileItem => ({ - type: 'image', - transfer_method: fileItem.type, - url: fileItem.url, - upload_file_id: fileItem.fileId, - })))} - /> - ) - } + } as any + } + onFilesChange={(files) => + onChange( + files + .filter((file) => file.progress !== -1) + .map((fileItem) => ({ + type: 'image', + transfer_method: fileItem.type, + url: fileItem.url, + upload_file_id: fileItem.fileId, + })), + ) + } + /> + )} - { - isContext && ( - <div className="space-y-2"> - {(value || []).map((item: any, index: number) => ( - <CodeEditor - key={index} - value={item} - title={<span>JSON</span>} - headerRight={ - (value as any).length > 1 - ? ( - <RiDeleteBinLine - onClick={handleArrayItemRemove(index)} - className="mr-1 size-3.5 cursor-pointer text-text-tertiary" - /> - ) - : undefined - } - language={CodeLanguage.json} - onChange={handleArrayItemChange(index)} - /> - ))} - </div> - ) - } + {isContext && ( + <div className="space-y-2"> + {(value || []).map((item: any, index: number) => ( + <CodeEditor + key={index} + value={item} + title={<span>JSON</span>} + headerRight={ + (value as any).length > 1 ? ( + <RiDeleteBinLine + onClick={handleArrayItemRemove(index)} + className="mr-1 size-3.5 cursor-pointer text-text-tertiary" + /> + ) : undefined + } + language={CodeLanguage.json} + onChange={handleArrayItemChange(index)} + /> + ))} + </div> + )} - { - (isIterator && !isIteratorItemFile) && ( - <div className="space-y-2"> - {(value || []).map((item: any, index: number) => ( - <TextEditor - key={index} - isInNode - value={item} - title={( - <span> - {t($ => $['variableConfig.content'], { ns: 'appDebug' })} - {' '} - {index + 1} - {' '} - </span> - )} - onChange={handleArrayItemChange(index)} - headerRight={ - (value as any).length > 1 - ? ( - <RiDeleteBinLine - onClick={handleArrayItemRemove(index)} - className="mr-1 size-3.5 cursor-pointer text-text-tertiary" - /> - ) - : undefined - } - /> - ))} - </div> - ) - } + {isIterator && !isIteratorItemFile && ( + <div className="space-y-2"> + {(value || []).map((item: any, index: number) => ( + <TextEditor + key={index} + isInNode + value={item} + title={ + <span> + {t(($) => $['variableConfig.content'], { ns: 'appDebug' })} {index + 1}{' '} + </span> + } + onChange={handleArrayItemChange(index)} + headerRight={ + (value as any).length > 1 ? ( + <RiDeleteBinLine + onClick={handleArrayItemRemove(index)} + className="mr-1 size-3.5 cursor-pointer text-text-tertiary" + /> + ) : undefined + } + /> + ))} + </div> + )} </div> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/form.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/form.tsx index 9cf6f2a891008a..2bdaf5528d1491 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/form.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/form.tsx @@ -18,21 +18,12 @@ export type Props = Readonly<{ onChange: (newValues: Record<string, any>) => void }> -const Form: FC<Props> = ({ - className, - label, - inputs, - values, - onChange, -}) => { +const Form: FC<Props> = ({ className, label, inputs, values, onChange }) => { const { t } = useTranslation() const mapKeysWithSameValueSelector = useMemo(() => { const keysWithSameValueSelector = (key: string) => { - const targetValueSelector = inputs.find( - item => item.variable === key, - )?.value_selector - if (!targetValueSelector) - return [key] + const targetValueSelector = inputs.find((item) => item.variable === key)?.value_selector + if (!targetValueSelector) return [key] const result: string[] = [] inputs.forEach((item) => { @@ -43,8 +34,7 @@ const Form: FC<Props> = ({ } const m = new Map() - for (const input of inputs) - m.set(input.variable, keysWithSameValueSelector(input.variable)) + for (const input of inputs) m.set(input.variable, keysWithSameValueSelector(input.variable)) return m }, [inputs]) @@ -52,16 +42,18 @@ const Form: FC<Props> = ({ useEffect(() => { valuesRef.current = values }, [values]) - const handleChange = useCallback((key: string) => { - const mKeys = mapKeysWithSameValueSelector.get(key) ?? [key] - return (value: any) => { - const newValues = produce(valuesRef.current, (draft) => { - for (const k of mKeys) - draft[k] = value - }) - onChange(newValues) - } - }, [valuesRef, onChange, mapKeysWithSameValueSelector]) + const handleChange = useCallback( + (key: string) => { + const mKeys = mapKeysWithSameValueSelector.get(key) ?? [key] + return (value: any) => { + const newValues = produce(valuesRef.current, (draft) => { + for (const k of mKeys) draft[k] = value + }) + onChange(newValues) + } + }, + [valuesRef, onChange, mapKeysWithSameValueSelector], + ) const isArrayLikeType = [InputVarType.contexts, InputVarType.iterator].includes(inputs[0]?.type!) const isIteratorItemFile = inputs[0]?.type === InputVarType.iterator && inputs[0]?.isFileItem @@ -69,8 +61,7 @@ const Form: FC<Props> = ({ const handleAddContext = useCallback(() => { const newValues = produce(values, (draft: any) => { const key = inputs[0]!.variable - if (!draft[key]) - draft[key] = [] + if (!draft[key]) draft[key] = [] draft[key].push(isContext ? RETRIEVAL_OUTPUT_STRUCT : '') }) onChange(newValues) @@ -80,11 +71,13 @@ const Form: FC<Props> = ({ <div className={cn(className, 'space-y-2')}> {label && ( <div className="mb-1 flex items-center justify-between"> - <div className="flex h-6 items-center system-xs-medium-uppercase text-text-tertiary">{label}</div> + <div className="flex h-6 items-center system-xs-medium-uppercase text-text-tertiary"> + {label} + </div> {isArrayLikeType && !isIteratorItemFile && ( <button type="button" - aria-label={`${t($ => $['operation.add'], { ns: 'common' })} ${label}`} + aria-label={`${t(($) => $['operation.add'], { ns: 'common' })} ${label}`} className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={handleAddContext} > diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts b/web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts index 0969d3ea16cada..e90ea12be682a6 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts @@ -11,24 +11,16 @@ export type BeforeRunFormTranslator = <Namespace extends 'workflow' | 'appDebug' ) => string export function formatValue(value: unknown, type: InputVarType) { - if (type === InputVarType.checkbox) - return !!value - if (value === undefined || value === null) - return value - if (type === InputVarType.number) - return Number.parseFloat(String(value)) - if (type === InputVarType.json) - return JSON.parse(String(value)) - if (type === InputVarType.contexts) - return (value as string[]).map(item => JSON.parse(item)) - if (type === InputVarType.multiFiles) - return getProcessedFiles(value as FileEntity[]) + if (type === InputVarType.checkbox) return !!value + if (value === undefined || value === null) return value + if (type === InputVarType.number) return Number.parseFloat(String(value)) + if (type === InputVarType.json) return JSON.parse(String(value)) + if (type === InputVarType.contexts) return (value as string[]).map((item) => JSON.parse(item)) + if (type === InputVarType.multiFiles) return getProcessedFiles(value as FileEntity[]) if (type === InputVarType.singleFile) { - if (Array.isArray(value)) - return getProcessedFiles(value as FileEntity[]) - if (!value) - return undefined + if (Array.isArray(value)) return getProcessedFiles(value as FileEntity[]) + if (!value) return undefined return getProcessedFiles([value as FileEntity])[0] } @@ -36,15 +28,17 @@ export function formatValue(value: unknown, type: InputVarType) { } export const isFilesLoaded = (forms: FormProps[]) => { - if (!forms.length) - return true + if (!forms.length) return true - const filesForm = forms.find(item => !!item.values['#files#']) - if (!filesForm) - return true + const filesForm = forms.find((item) => !!item.values['#files#']) + if (!filesForm) return true - const files = filesForm.values['#files#'] as unknown as Array<{ transfer_method?: TransferMethod, upload_file_id?: string }> | undefined - return !files?.some(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id) + const files = filesForm.values['#files#'] as unknown as + | Array<{ transfer_method?: TransferMethod; upload_file_id?: string }> + | undefined + return !files?.some( + (item) => item.transfer_method === TransferMethod.local_file && !item.upload_file_id, + ) } export const getFormErrorMessage = ( @@ -59,33 +53,43 @@ export const getFormErrorMessage = ( form.inputs.forEach((input) => { const value = form.values[input.variable] as unknown - const missingRequired = input.required - && input.type !== InputVarType.checkbox - && !(input.variable in existVarValuesInForm!) - && ( - value === '' || value === undefined || value === null - || ( - (input.type === InputVarType.files - || input.type === InputVarType.multiFiles - || input.type === InputVarType.singleFile) - && Array.isArray(value) - && value.length === 0 - ) - ) + const missingRequired = + input.required && + input.type !== InputVarType.checkbox && + !(input.variable in existVarValuesInForm!) && + (value === '' || + value === undefined || + value === null || + ((input.type === InputVarType.files || + input.type === InputVarType.multiFiles || + input.type === InputVarType.singleFile) && + Array.isArray(value) && + value.length === 0)) if (!errMsg && missingRequired) { - errMsg = t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: typeof input.label === 'object' ? input.label.variable : input.label }) + errMsg = t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: typeof input.label === 'object' ? input.label.variable : input.label, + }) return } - if (!errMsg && (input.type === InputVarType.singleFile || input.type === InputVarType.multiFiles) && value) { + if ( + !errMsg && + (input.type === InputVarType.singleFile || input.type === InputVarType.multiFiles) && + value + ) { const fileIsUploading = Array.isArray(value) - ? value.find((item: { transferMethod?: TransferMethod, uploadedId?: string }) => item.transferMethod === TransferMethod.local_file && !item.uploadedId) - : (value as { transferMethod?: TransferMethod, uploadedId?: string }).transferMethod === TransferMethod.local_file - && !(value as { transferMethod?: TransferMethod, uploadedId?: string }).uploadedId + ? value.find( + (item: { transferMethod?: TransferMethod; uploadedId?: string }) => + item.transferMethod === TransferMethod.local_file && !item.uploadedId, + ) + : (value as { transferMethod?: TransferMethod; uploadedId?: string }).transferMethod === + TransferMethod.local_file && + !(value as { transferMethod?: TransferMethod; uploadedId?: string }).uploadedId if (fileIsUploading) - errMsg = t($ => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' }) + errMsg = t(($) => $['errorMessage.waitForFileUpload'], { ns: 'appDebug' }) } }) }) @@ -101,8 +105,7 @@ export const buildSubmitData = (forms: FormProps[]) => { form.inputs.forEach((input) => { try { submitData[input.variable] = formatValue(form.values[input.variable], input.type) - } - catch { + } catch { parseErrorJsonField = input.variable } }) @@ -111,10 +114,16 @@ export const buildSubmitData = (forms: FormProps[]) => { return { submitData, parseErrorJsonField } } -export const shouldAutoRunBeforeRunForm = (filteredExistVarForms: FormProps[], isHumanInput: boolean) => { +export const shouldAutoRunBeforeRunForm = ( + filteredExistVarForms: FormProps[], + isHumanInput: boolean, +) => { return filteredExistVarForms.length === 0 && !isHumanInput } -export const shouldAutoShowGeneratedForm = (filteredExistVarForms: FormProps[], isHumanInput: boolean) => { +export const shouldAutoShowGeneratedForm = ( + filteredExistVarForms: FormProps[], + isHumanInput: boolean, +) => { return filteredExistVarForms.length === 0 && isHumanInput } diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx index c3006674b7941c..99ceff56c8f7d7 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx @@ -79,14 +79,14 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({ const { submitData, parseErrorJsonField } = buildSubmitData(forms) if (parseErrorJsonField) { - toast.error(t($ => $['errorMsg.invalidJson'], { ns: 'workflow', field: parseErrorJsonField })) + toast.error( + t(($) => $['errorMsg.invalidJson'], { ns: 'workflow', field: parseErrorJsonField }), + ) return } - if (isHumanInput) - handleShowGeneratedForm?.(submitData) - else - onRun(submitData) + if (isHumanInput) handleShowGeneratedForm?.(submitData) + else onRun(submitData) } const handleHumanInputFormSubmit = async (data: any) => { @@ -97,33 +97,23 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({ const hasRun = useRef(false) useEffect(() => { // React 18 run twice in dev mode - if (hasRun.current) - return + if (hasRun.current) return hasRun.current = true - if (shouldAutoRunBeforeRunForm(filteredExistVarForms, isHumanInput)) - onRun({}) + if (shouldAutoRunBeforeRunForm(filteredExistVarForms, isHumanInput)) onRun({}) if (shouldAutoShowGeneratedForm(filteredExistVarForms, isHumanInput)) handleShowGeneratedForm?.({}) }, [filteredExistVarForms, handleShowGeneratedForm, isHumanInput, onRun]) - if (shouldAutoRunBeforeRunForm(filteredExistVarForms, isHumanInput)) - return null + if (shouldAutoRunBeforeRunForm(filteredExistVarForms, isHumanInput)) return null return ( - <PanelWrap - nodeName={nodeName} - onHide={onHide} - > + <PanelWrap nodeName={nodeName} onHide={onHide}> <div className="h-0 grow overflow-y-auto pb-4"> {!showGeneratedForm && ( <div className="mt-3 space-y-4 px-4"> {filteredExistVarForms.map((form, index) => ( <div key={index}> - <Form - key={index} - className={cn(index < forms.length - 1 && 'mb-4')} - {...form} - /> + <Form key={index} className={cn(index < forms.length - 1 && 'mb-4')} {...form} /> {index < forms.length - 1 && <Split />} </div> ))} @@ -141,13 +131,23 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({ {!showGeneratedForm && ( <div className="mt-4 flex justify-between space-x-2 px-4"> {!isHumanInput && ( - <Button disabled={!isFileLoaded} variant="primary" className="w-0 grow space-x-2" onClick={handleRunOrGenerateForm}> - <div>{t($ => $[`${i18nPrefix}.startRun`], { ns: 'workflow' })}</div> + <Button + disabled={!isFileLoaded} + variant="primary" + className="w-0 grow space-x-2" + onClick={handleRunOrGenerateForm} + > + <div>{t(($) => $[`${i18nPrefix}.startRun`], { ns: 'workflow' })}</div> </Button> )} {isHumanInput && ( - <Button disabled={!isFileLoaded} variant="primary" className="w-0 grow space-x-2" onClick={handleRunOrGenerateForm}> - <div>{t($ => $['nodes.humanInput.singleRun.button'], { ns: 'workflow' })}</div> + <Button + disabled={!isFileLoaded} + variant="primary" + className="w-0 grow space-x-2" + onClick={handleRunOrGenerateForm} + > + <div>{t(($) => $['nodes.humanInput.singleRun.button'], { ns: 'workflow' })}</div> </Button> )} </div> diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/panel-wrap.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/panel-wrap.tsx index b8bc44712f3b24..9c45c68112faa0 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/panel-wrap.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/panel-wrap.tsx @@ -1,8 +1,6 @@ 'use client' import type { FC } from 'react' -import { - RiCloseLine, -} from '@remixicon/react' +import { RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -14,20 +12,14 @@ type Props = Readonly<{ children: React.ReactNode }> -const PanelWrap: FC<Props> = ({ - nodeName, - onHide, - children, -}) => { +const PanelWrap: FC<Props> = ({ nodeName, onHide, children }) => { const { t } = useTranslation() return ( <div className="absolute inset-0 z-10 rounded-2xl bg-background-overlay-alt"> <div className="flex h-full flex-col rounded-2xl bg-components-panel-bg"> <div className="flex h-8 shrink-0 items-center justify-between pt-3 pr-3 pl-4"> <div className="truncate text-base font-semibold text-text-primary"> - {t($ => $[`${i18nPrefix}.testRun`], { ns: 'workflow' })} - {' '} - {nodeName} + {t(($) => $[`${i18nPrefix}.testRun`], { ns: 'workflow' })} {nodeName} </div> <div className="ml-2 shrink-0 cursor-pointer p-1" diff --git a/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx b/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx index 279d8d48626d82..e7bda2fc5da88b 100644 --- a/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx @@ -27,19 +27,20 @@ const CodeGenerateBtn: FC<Props> = ({ codeLanguages, onGenerated, }) => { - const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = useBoolean(false) - const handleAutomaticRes = useCallback((res: GenRes) => { - onGenerated?.(res.modified) - showAutomaticFalse() - }, [onGenerated, showAutomaticFalse]) - const configsMap = useHooksStore(s => s.configsMap) + const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = + useBoolean(false) + const handleAutomaticRes = useCallback( + (res: GenRes) => { + onGenerated?.(res.modified) + showAutomaticFalse() + }, + [onGenerated, showAutomaticFalse], + ) + const configsMap = useHooksStore((s) => s.configsMap) return ( <div className={cn(className)}> - <ActionButton - className="hover:bg-[#155EFF]/8" - onClick={showAutomaticTrue} - > + <ActionButton className="hover:bg-[#155EFF]/8" onClick={showAutomaticTrue}> <Generator className="size-4 text-primary-600" /> </ActionButton> {showAutomatic && ( diff --git a/web/app/components/workflow/nodes/_base/components/collapse/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/collapse/__tests__/index.spec.tsx index 001913c8a3a89f..611c7268b9ad1f 100644 --- a/web/app/components/workflow/nodes/_base/components/collapse/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/collapse/__tests__/index.spec.tsx @@ -30,15 +30,9 @@ function TestCollapse({ <CollapseTitle>{title}</CollapseTitle> <CollapseIndicator /> </CollapseTrigger> - {actions != null && ( - <CollapseActions> - {actions} - </CollapseActions> - )} + {actions != null && <CollapseActions>{actions}</CollapseActions>} </CollapseHeader> - <CollapseContent> - {children} - </CollapseContent> + <CollapseContent>{children}</CollapseContent> </Collapse> ) } @@ -55,10 +49,7 @@ describe('Collapse', () => { const onCollapse = vi.fn() render( - <TestCollapse - title="Advanced" - onCollapse={onCollapse} - > + <TestCollapse title="Advanced" onCollapse={onCollapse}> <div>Collapse content</div> </TestCollapse>, ) @@ -76,11 +67,7 @@ describe('Collapse', () => { const onCollapse = vi.fn() render( - <TestCollapse - disabled - title="Disabled section" - onCollapse={onCollapse} - > + <TestCollapse disabled title="Disabled section" onCollapse={onCollapse}> <div>Hidden content</div> </TestCollapse>, ) diff --git a/web/app/components/workflow/nodes/_base/components/collapse/index.tsx b/web/app/components/workflow/nodes/_base/components/collapse/index.tsx index 319552b76b3d1c..7c862e986e3d66 100644 --- a/web/app/components/workflow/nodes/_base/components/collapse/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/collapse/index.tsx @@ -1,28 +1,17 @@ -import type { - ComponentProps, - ReactNode, -} from 'react' +import type { ComponentProps, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - Collapsible, - CollapsiblePanel, - CollapsibleTrigger, -} from '@langgenius/dify-ui/collapsible' +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible' type CollapseProps = Omit<ComponentProps<typeof Collapsible>, 'open' | 'onOpenChange'> & { collapsed?: boolean onCollapse?: (collapsed: boolean) => void } -export function Collapse({ - collapsed, - onCollapse, - ...props -}: CollapseProps) { +export function Collapse({ collapsed, onCollapse, ...props }: CollapseProps) { return ( <Collapsible open={collapsed === undefined ? undefined : !collapsed} - onOpenChange={open => onCollapse?.(!open)} + onOpenChange={(open) => onCollapse?.(!open)} {...props} /> ) @@ -32,36 +21,21 @@ type CollapseHeaderProps = { children: ReactNode } -export function CollapseHeader({ - children, -}: CollapseHeaderProps) { - return ( - <div className="group/collapse flex items-center"> - {children} - </div> - ) +export function CollapseHeader({ children }: CollapseHeaderProps) { + return <div className="group/collapse flex items-center">{children}</div> } type CollapseActionsProps = { children: ReactNode } -export function CollapseActions({ - children, -}: CollapseActionsProps) { - return ( - <div className="ml-auto shrink-0"> - {children} - </div> - ) +export function CollapseActions({ children }: CollapseActionsProps) { + return <div className="ml-auto shrink-0">{children}</div> } type CollapseTriggerProps = ComponentProps<typeof CollapsibleTrigger> -export function CollapseTrigger({ - className, - ...props -}: CollapseTriggerProps) { +export function CollapseTrigger({ className, ...props }: CollapseTriggerProps) { return ( <CollapsibleTrigger className={cn( @@ -78,12 +52,11 @@ type CollapseTitleProps = { className?: string } -export function CollapseTitle({ - children, - className, -}: CollapseTitleProps) { +export function CollapseTitle({ children, className }: CollapseTitleProps) { return ( - <span className={cn('min-w-0 truncate system-sm-semibold-uppercase text-text-secondary', className)}> + <span + className={cn('min-w-0 truncate system-sm-semibold-uppercase text-text-secondary', className)} + > {children} </span> ) @@ -100,16 +73,8 @@ export function CollapseIndicator() { type CollapseContentProps = ComponentProps<typeof CollapsiblePanel> -export function CollapseContent({ - className, - ...props -}: CollapseContentProps) { - return ( - <CollapsiblePanel - className={cn(className)} - {...props} - /> - ) +export function CollapseContent({ className, ...props }: CollapseContentProps) { + return <CollapsiblePanel className={cn(className)} {...props} /> } type FieldCollapseProps = { @@ -135,16 +100,10 @@ export function FieldCollapse({ <CollapseTitle>{title}</CollapseTitle> <CollapseIndicator /> </CollapseTrigger> - {actions != null && ( - <CollapseActions> - {actions} - </CollapseActions> - )} + {actions != null && <CollapseActions>{actions}</CollapseActions>} </CollapseHeader> <CollapseContent> - <div className="px-4"> - {children} - </div> + <div className="px-4">{children}</div> </CollapseContent> </Collapse> </div> diff --git a/web/app/components/workflow/nodes/_base/components/config-vision.tsx b/web/app/components/workflow/nodes/_base/components/config-vision.tsx index c0d9c17ae3c485..aa1155ddcf9c06 100644 --- a/web/app/components/workflow/nodes/_base/components/config-vision.tsx +++ b/web/app/components/workflow/nodes/_base/components/config-vision.tsx @@ -42,57 +42,62 @@ const ConfigVision: FC<Props> = ({ const filterVar = useCallback((payload: Var) => { return [VarType.file, VarType.arrayFile].includes(payload.type) }, []) - const handleVisionResolutionChange = useCallback((resolution: Resolution) => { - const newConfig = produce(config, (draft) => { - draft.detail = resolution - }) - onConfigChange(newConfig) - }, [config, onConfigChange]) + const handleVisionResolutionChange = useCallback( + (resolution: Resolution) => { + const newConfig = produce(config, (draft) => { + draft.detail = resolution + }) + onConfigChange(newConfig) + }, + [config, onConfigChange], + ) - const handleVarSelectorChange = useCallback((valueSelector: ValueSelector | string) => { - const newConfig = produce(config, (draft) => { - draft.variable_selector = valueSelector as ValueSelector - }) - onConfigChange(newConfig) - }, [config, onConfigChange]) + const handleVarSelectorChange = useCallback( + (valueSelector: ValueSelector | string) => { + const newConfig = produce(config, (draft) => { + draft.variable_selector = valueSelector as ValueSelector + }) + onConfigChange(newConfig) + }, + [config, onConfigChange], + ) return ( <Field - title={t($ => $[`${i18nPrefix}.vision`], { ns: 'workflow' })} - tooltip={t($ => $['vision.description'], { ns: 'appDebug' })!} - operations={( + title={t(($) => $[`${i18nPrefix}.vision`], { ns: 'workflow' })} + tooltip={t(($) => $['vision.description'], { ns: 'appDebug' })!} + operations={ <Tooltip> <TooltipTrigger disabled={isVisionModel} - render={( - <Switch disabled={readOnly || !isVisionModel} size="md" checked={!isVisionModel ? false : enabled} onCheckedChange={onEnabledChange} /> - )} + render={ + <Switch + disabled={readOnly || !isVisionModel} + size="md" + checked={!isVisionModel ? false : enabled} + onCheckedChange={onEnabledChange} + /> + } /> <TooltipContent> - {t($ => $['vision.onlySupportVisionModelTip'], { ns: 'appDebug' })!} + {t(($) => $['vision.onlySupportVisionModelTip'], { ns: 'appDebug' })!} </TooltipContent> </Tooltip> - )} + } > - {(enabled && isVisionModel) - ? ( - <div> - <VarReferencePicker - className="mb-4" - filterVar={filterVar} - nodeId={nodeId} - value={config.variable_selector || []} - onChange={handleVarSelectorChange} - readonly={readOnly} - /> - <ResolutionPicker - value={config.detail} - onChange={handleVisionResolutionChange} - /> - </div> - ) - : null} - + {enabled && isVisionModel ? ( + <div> + <VarReferencePicker + className="mb-4" + filterVar={filterVar} + nodeId={nodeId} + value={config.variable_selector || []} + onChange={handleVarSelectorChange} + readonly={readOnly} + /> + <ResolutionPicker value={config.detail} onChange={handleVisionResolutionChange} /> + </div> + ) : null} </Field> ) } diff --git a/web/app/components/workflow/nodes/_base/components/editor/base.tsx b/web/app/components/workflow/nodes/_base/components/editor/base.tsx index ba73ccfab94e95..6de72b7dcffe8c 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/base.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/base.tsx @@ -10,10 +10,7 @@ import { useCallback, useRef, useState } from 'react' import PromptEditorHeightResizeWrap from '@/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap' import ActionButton from '@/app/components/base/action-button' import FileListInLog from '@/app/components/base/file-uploader/file-list-in-log' -import { - Copy, - CopyCheck, -} from '@/app/components/base/icons/src/vender/line/files' +import { Copy, CopyCheck } from '@/app/components/base/icons/src/vender/line/files' import useToggleExpend from '@/app/components/workflow/nodes/_base/hooks/use-toggle-expend' import CodeGeneratorButton from '../code-generator-button' import ToggleExpandBtn from '../toggle-expand-btn' @@ -62,13 +59,11 @@ const Base: FC<Props> = ({ footer, }) => { const ref = useRef<HTMLDivElement>(null) - const { - wrapClassName, - wrapStyle, - isExpand, - setIsExpand, - editorExpandHeight, - } = useToggleExpend({ ref, hasFooter: false, isInNode }) + const { wrapClassName, wrapStyle, isExpand, setIsExpand, editorExpandHeight } = useToggleExpend({ + ref, + hasFooter: false, + isInNode, + }) const editorContentMinHeight = minHeight - 28 const [editorContentHeight, setEditorContentHeight] = useState(editorContentMinHeight) @@ -84,7 +79,16 @@ const Base: FC<Props> = ({ return ( <Wrap className={cn(wrapClassName)} style={wrapStyle} isInNode={isInNode} isExpand={isExpand}> - <div ref={ref} className={cn(className, isExpand ? 'h-full border-0' : 'rounded-lg border', !isFocus ? 'border-transparent bg-components-input-bg-normal' : 'overflow-hidden border-components-input-border-hover bg-components-input-bg-hover')}> + <div + ref={ref} + className={cn( + className, + isExpand ? 'h-full border-0' : 'rounded-lg border', + !isFocus + ? 'border-transparent bg-components-input-bg-normal' + : 'overflow-hidden border-components-input-border-hover bg-components-input-bg-hover', + )} + > <div className="flex h-7 items-center justify-between pt-1 pr-2 pl-3"> <div className="system-xs-semibold-uppercase text-text-secondary">{title}</div> <div @@ -106,13 +110,11 @@ const Base: FC<Props> = ({ </div> )} <ActionButton className="ml-1" onClick={handleCopy}> - {!isCopied - ? ( - <Copy className="size-4 cursor-pointer" /> - ) - : ( - <CopyCheck className="size-4" /> - )} + {!isCopied ? ( + <Copy className="size-4 cursor-pointer" /> + ) : ( + <CopyCheck className="size-4" /> + )} </ActionButton> <div className="ml-1"> <ToggleExpandBtn isExpand={isExpand} onExpandChange={setIsExpand} /> @@ -126,13 +128,9 @@ const Base: FC<Props> = ({ onHeightChange={setEditorContentHeight} hideResize={isExpand} > - <div className="h-full pb-2 pl-2"> - {children} - </div> + <div className="h-full pb-2 pl-2">{children}</div> </PromptEditorHeightResizeWrap> - {showFileList && fileList.length > 0 && ( - <FileListInLog fileList={fileList} /> - )} + {showFileList && fileList.length > 0 && <FileListInLog fileList={fileList} />} {footer} </div> </Wrap> diff --git a/web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx b/web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx index ed2c59248dbeea..f2174f15b1bf06 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx @@ -17,14 +17,10 @@ type Props = Readonly<{ availableVars: NodeOutPutVar[] varList: Variable[] onAddVar?: (payload: Variable) => void -}> & EditorProps - -const CodeEditor: FC<Props> = ({ - availableVars, - varList, - onAddVar, - ...editorProps -}) => { +}> & + EditorProps + +const CodeEditor: FC<Props> = ({ availableVars, varList, onAddVar, ...editorProps }) => { const { t } = useTranslation() const isLeftBraceRef = useRef(false) @@ -33,10 +29,7 @@ const CodeEditor: FC<Props> = ({ const monacoRef = useRef(null) const popupRef = useRef<HTMLDivElement>(null) - const [isShowVarPicker, { - setTrue: showVarPicker, - setFalse: hideVarPicker, - }] = useBoolean(false) + const [isShowVarPicker, { setTrue: showVarPicker, setFalse: hideVarPicker }] = useBoolean(false) const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 }) @@ -56,8 +49,7 @@ const CodeEditor: FC<Props> = ({ setPopupPosition({ x: popupX, y: popupY }) showVarPicker() - } - else { + } else { hideVarPicker() } } @@ -85,13 +77,12 @@ const CodeEditor: FC<Props> = ({ } const getUniqVarName = (varName: string) => { - if (varList.find(v => v.variable === varName)) { + if (varList.find((v) => v.variable === varName)) { const varNameRegex = /_(\d+)$/ const match = varNameRegex.exec(varName) const index = (() => { - if (match) - return Number.parseInt(match[1]!) + 1 + if (match) return Number.parseInt(match[1]!) + 1 return 1 })() @@ -101,7 +92,10 @@ const CodeEditor: FC<Props> = ({ } const getVarName = (varValue: string[]) => { - const existVar = varList.find(v => Array.isArray(v.value_selector) && v.value_selector.join('@@@') === varValue.join('@@@')) + const existVar = varList.find( + (v) => + Array.isArray(v.value_selector) && v.value_selector.join('@@@') === varValue.join('@@@'), + ) if (existVar) { return { name: existVar.variable, @@ -133,7 +127,12 @@ const CodeEditor: FC<Props> = ({ editor?.executeEdits('', [ { // position.column - 1 to remove the text before the cursor - range: new monaco.Range(position.lineNumber, position.column - 1, position.lineNumber, position.column), + range: new monaco.Range( + position.lineNumber, + position.column - 1, + position.lineNumber, + position.column, + ), text: `{{ ${name} }${!isLeftBraceRef.current ? '}' : ''}`, // left brace would auto add one right brace }, ]) @@ -146,26 +145,27 @@ const CodeEditor: FC<Props> = ({ <Editor {...editorProps} onMount={onEditorMounted} - placeholder={t($ => $['common.jinjaEditorPlaceholder'], { ns: 'workflow' })!} + placeholder={t(($) => $['common.jinjaEditorPlaceholder'], { ns: 'workflow' })!} /> - {isShowVarPicker && createPortal( - <div - ref={popupRef} - className="fixed z-50 w-[228px] space-y-1 rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg" - style={{ - top: popupPosition.y, - left: popupPosition.x, - }} - > - <VarReferenceVars - hideSearch - vars={availableVars} - onChange={handleSelectVar} - isSupportFileVar={false} - /> - </div>, - document.body, - )} + {isShowVarPicker && + createPortal( + <div + ref={popupRef} + className="fixed z-50 w-[228px] space-y-1 rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg" + style={{ + top: popupPosition.y, + left: popupPosition.x, + }} + > + <VarReferenceVars + hideSearch + vars={availableVars} + onChange={handleSelectVar} + isSupportFileVar={false} + /> + </div>, + document.body, + )} </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx b/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx index 5c825d6b2b40d8..4b51ab97b420da 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx @@ -5,9 +5,7 @@ import Editor, { loader } from '@monaco-editor/react' import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useMemo, useRef, useState } from 'react' -import { - getFilesInLogs, -} from '@/app/components/base/file-uploader/utils' +import { getFilesInLogs } from '@/app/components/base/file-uploader/utils' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import useTheme from '@/hooks/use-theme' import { Theme } from '@/types/app' @@ -83,8 +81,7 @@ const CodeEditor: FC<Props> = ({ }, [value]) const fileList = useMemo(() => { - if (typeof value === 'object') - return getFilesInLogs(value) + if (typeof value === 'object') return getFilesInLogs(value) return [] }, [value]) @@ -121,19 +118,16 @@ const CodeEditor: FC<Props> = ({ } const outPutValue = (() => { - if (!isJSONStringifyBeauty) - return value as string + if (!isJSONStringifyBeauty) return value as string try { return JSON.stringify(value as object, null, 2) - } - catch { + } catch { return value as string } })() const theme = useMemo(() => { - if (appTheme === Theme.light) - return 'light' + if (appTheme === Theme.light) return 'light' return 'vs-dark' }, [appTheme]) @@ -167,45 +161,47 @@ const CodeEditor: FC<Props> = ({ }} onMount={handleEditorDidMount} /> - {!outPutValue && !isFocus && <div className="pointer-events-none absolute top-0 left-[36px] text-[13px] leading-[18px] font-normal text-components-input-text-placeholder">{placeholder}</div>} + {!outPutValue && !isFocus && ( + <div className="pointer-events-none absolute top-0 left-[36px] text-[13px] leading-[18px] font-normal text-components-input-text-placeholder"> + {placeholder} + </div> + )} </> ) return ( <div className={cn(isExpand && 'h-full', className)}> - {noWrapper - ? ( - <div - className="no-wrapper relative" - style={{ - height: isExpand ? '100%' : (editorContentHeight) / 2 + CODE_EDITOR_LINE_HEIGHT, // In IDE, the last line can always be in lop line. So there is some blank space in the bottom. - minHeight: CODE_EDITOR_LINE_HEIGHT, - }} - > - {main} - </div> - ) - : ( - <Base - nodeId={nodeId} - className="relative" - title={title} - value={outPutValue} - headerRight={headerRight} - isFocus={isFocus && !readOnly} - minHeight={minHeight} - isInNode={isInNode} - onGenerated={onGenerated} - codeLanguages={language} - fileList={fileList as any} - showFileList={showFileList} - showCodeGenerator={showCodeGenerator} - tip={tip} - footer={footer} - > - {main} - </Base> - )} + {noWrapper ? ( + <div + className="no-wrapper relative" + style={{ + height: isExpand ? '100%' : editorContentHeight / 2 + CODE_EDITOR_LINE_HEIGHT, // In IDE, the last line can always be in lop line. So there is some blank space in the bottom. + minHeight: CODE_EDITOR_LINE_HEIGHT, + }} + > + {main} + </div> + ) : ( + <Base + nodeId={nodeId} + className="relative" + title={title} + value={outPutValue} + headerRight={headerRight} + isFocus={isFocus && !readOnly} + minHeight={minHeight} + isInNode={isInNode} + onGenerated={onGenerated} + codeLanguages={language} + fileList={fileList as any} + showFileList={showFileList} + showCodeGenerator={showCodeGenerator} + tip={tip} + footer={footer} + > + {main} + </Base> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/editor/code-editor/style.css b/web/app/components/workflow/nodes/_base/components/editor/code-editor/style.css index d364c1f15a78a4..51048ef7736ae8 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/code-editor/style.css +++ b/web/app/components/workflow/nodes/_base/components/editor/code-editor/style.css @@ -1,4 +1,3 @@ - .monaco-editor { background-color: transparent !important; outline: none !important; diff --git a/web/app/components/workflow/nodes/_base/components/editor/text-editor.tsx b/web/app/components/workflow/nodes/_base/components/editor/text-editor.tsx index aeee4b0001285a..5735603346a576 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/text-editor.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/text-editor.tsx @@ -28,10 +28,7 @@ const TextEditor: FC<Props> = ({ readonly, isInNode, }) => { - const [isFocus, { - setTrue: setIsFocus, - setFalse: setIsNotFocus, - }] = useBoolean(false) + const [isFocus, { setTrue: setIsFocus, setFalse: setIsNotFocus }] = useBoolean(false) const handleBlur = useCallback(() => { setIsNotFocus() @@ -50,7 +47,7 @@ const TextEditor: FC<Props> = ({ > <textarea value={value} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} onFocus={setIsFocus} onBlur={handleBlur} className="h-full w-full resize-none border-none bg-transparent px-3 text-[13px] leading-[18px] font-normal text-gray-900 placeholder:text-gray-300 focus:outline-hidden" diff --git a/web/app/components/workflow/nodes/_base/components/editor/wrap.tsx b/web/app/components/workflow/nodes/_base/components/editor/wrap.tsx index 94442bce6a49d9..fec77fdc9bb5bd 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/wrap.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/wrap.tsx @@ -12,21 +12,16 @@ type Props = Readonly<{ }> // It doesn't has workflow store -const WrapInWebApp = ({ - className, - style, - children, -}: Props) => { - return <div className={className} style={style}>{children}</div> +const WrapInWebApp = ({ className, style, children }: Props) => { + return ( + <div className={className} style={style}> + {children} + </div> + ) } -const Wrap = ({ - className, - style, - isExpand, - children, -}: Props) => { - const panelWidth = useStore(state => state.panelWidth) +const Wrap = ({ className, style, isExpand, children }: Props) => { + const panelWidth = useStore((state) => state.panelWidth) const wrapStyle = (() => { if (isExpand) { return { @@ -36,13 +31,14 @@ const Wrap = ({ } return style })() - return <div className={className} style={wrapStyle}>{children}</div> + return ( + <div className={className} style={wrapStyle}> + {children} + </div> + ) } -const Main: FC<Props> = ({ - isInNode, - ...otherProps -}: Props) => { +const Main: FC<Props> = ({ isInNode, ...otherProps }: Props) => { return isInNode ? <Wrap {...otherProps} /> : <WrapInWebApp {...otherProps} /> } export default React.memo(Main) diff --git a/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx b/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx index 9057d726755918..b8ef6fc88ce768 100644 --- a/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx +++ b/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx @@ -21,16 +21,15 @@ const EntryNodeContainer: FC<EntryNodeContainerProps> = ({ const { t } = useTranslation() const label = useMemo(() => { - const translationKey = nodeType === StartNodeTypeEnum.Start ? 'entryNodeStatus' : 'triggerStatus' - return customLabel || t($ => $[`${translationKey}.enabled`], { ns: 'workflow' }) + const translationKey = + nodeType === StartNodeTypeEnum.Start ? 'entryNodeStatus' : 'triggerStatus' + return customLabel || t(($) => $[`${translationKey}.enabled`], { ns: 'workflow' }) }, [customLabel, nodeType, t]) return ( <div className="w-fit min-w-[242px] rounded-2xl bg-workflow-block-wrapper-bg-1 px-0 pt-0.5 pb-0"> <div className="mb-0.5 flex items-center px-2.5 pt-0.5"> - <span className="text-2xs font-semibold text-text-tertiary uppercase"> - {label} - </span> + <span className="text-2xs font-semibold text-text-tertiary uppercase">{label}</span> </div> {children} </div> diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/__tests__/index.spec.tsx index 392f8e5a2a7e96..7aafc97d05d1bd 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/__tests__/index.spec.tsx @@ -32,7 +32,9 @@ vi.mock('../hooks', () => ({ })) vi.mock('../../node-handle', () => ({ - NodeSourceHandle: ({ handleId }: { handleId: string }) => <div className="react-flow__handle" data-handleid={handleId} />, + NodeSourceHandle: ({ handleId }: { handleId: string }) => ( + <div className="react-flow__handle" data-handleid={handleId} /> + ), })) const mockUseDefaultValue = vi.mocked(useDefaultValue) @@ -52,11 +54,13 @@ const ErrorHandleNodeHarness = ({ id, data }: NodeProps<CommonNodeType>) => ( const renderErrorHandleNode = (data: CommonNodeType) => renderWorkflowFlowComponent(<div />, { - nodes: [createNode({ - id: 'node-1', - type: 'errorHandleNode', - data, - })], + nodes: [ + createNode({ + id: 'node-1', + type: 'errorHandleNode', + data, + }), + ], edges: [], reactFlowProps: { nodeTypes: { @@ -72,7 +76,7 @@ describe('error-handle path', () => { return this } - transformPoint(point: { x: number, y: number }) { + transformPoint(point: { x: number; y: number }) { return point } } @@ -110,8 +114,13 @@ describe('error-handle path', () => { it('should render the fail-branch card with the resolved learn-more link', () => { render(<FailBranchCard />) - expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument() - expect(screen.getByRole('link')).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type') + expect( + screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize'), + ).toBeInTheDocument() + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + 'https://docs.example.com/use-dify/debug/error-type', + ) }) it('should render string forms and surface array forms in the default value editor', () => { @@ -139,12 +148,7 @@ describe('error-handle path', () => { it('should toggle the selector popup and report the selected strategy', async () => { const user = userEvent.setup() const onSelected = vi.fn() - render( - <ErrorHandleTypeSelector - value={ErrorHandleTypeEnum.none} - onSelected={onSelected} - />, - ) + render(<ErrorHandleTypeSelector value={ErrorHandleTypeEnum.none} onSelected={onSelected} />) await user.click(screen.getByRole('button')) await user.click(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.title')) @@ -158,10 +162,14 @@ describe('error-handle path', () => { expect(container).toBeEmptyDOMElement() rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.failBranch} />) - expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.inLog')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.common.errorHandle.failBranch.inLog'), + ).toBeInTheDocument() rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.defaultValue} />) - expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.inLog')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.common.errorHandle.defaultValue.inLog'), + ).toBeInTheDocument() }) }) @@ -176,7 +184,9 @@ describe('error-handle path', () => { ) expect(screen.getByText('workflow.nodes.common.errorHandle.title')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize'), + ).toBeInTheDocument() }) it('should render the default-value panel body and delegate form updates', () => { @@ -214,7 +224,9 @@ describe('error-handle path', () => { />, ) - expect(screen.queryByText('workflow.nodes.common.errorHandle.failBranch.customize')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.common.errorHandle.failBranch.customize'), + ).not.toBeInTheDocument() }) it('should render the default-value node badge', () => { @@ -231,19 +243,28 @@ describe('error-handle path', () => { }, ) - expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output'), + ).toBeInTheDocument() }) it('should render the fail-branch node badge when the node throws an exception', () => { - const { container } = renderErrorHandleNode(baseData({ - error_strategy: ErrorHandleTypeEnum.failBranch, - _runningStatus: NodeRunningStatus.Exception, - })) + const { container } = renderErrorHandleNode( + baseData({ + error_strategy: ErrorHandleTypeEnum.failBranch, + _runningStatus: NodeRunningStatus.Exception, + }), + ) return waitFor(() => { expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument() - expect(container.querySelector('.react-flow__handle')).toHaveAttribute('data-handleid', ErrorHandleTypeEnum.failBranch) + expect( + screen.getByText('workflow.nodes.common.errorHandle.failBranch.title'), + ).toBeInTheDocument() + expect(container.querySelector('.react-flow__handle')).toHaveAttribute( + 'data-handleid', + ErrorHandleTypeEnum.failBranch, + ) }) }) }) diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/default-value.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/default-value.tsx index 2a8f9943f6b8da..eff1e6e7f9eaf8 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/default-value.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/default-value.tsx @@ -10,70 +10,65 @@ type DefaultValueProps = { forms: DefaultValueForm[] onFormChange: (form: DefaultValueForm) => void } -const DefaultValue = ({ - forms, - onFormChange, -}: DefaultValueProps) => { +const DefaultValue = ({ forms, onFormChange }: DefaultValueProps) => { const { t } = useTranslation() - const getFormChangeHandler = useCallback(({ key, type }: DefaultValueForm) => { - return (payload: any) => { - let value - if (type === VarType.string || type === VarType.number) - value = payload.target.value + const getFormChangeHandler = useCallback( + ({ key, type }: DefaultValueForm) => { + return (payload: any) => { + let value + if (type === VarType.string || type === VarType.number) value = payload.target.value - if (type === VarType.array || type === VarType.arrayNumber || type === VarType.arrayString || type === VarType.arrayObject || type === VarType.arrayFile || type === VarType.object) - value = payload + if ( + type === VarType.array || + type === VarType.arrayNumber || + type === VarType.arrayString || + type === VarType.arrayObject || + type === VarType.arrayFile || + type === VarType.object + ) + value = payload - onFormChange({ key, type, value }) - } - }, [onFormChange]) + onFormChange({ key, type, value }) + } + }, + [onFormChange], + ) return ( <div className="px-4 pt-2"> <div className="mb-2 body-xs-regular text-text-tertiary"> - {t($ => $['nodes.common.errorHandle.defaultValue.desc'], { ns: 'workflow' })} + {t(($) => $['nodes.common.errorHandle.defaultValue.desc'], { ns: 'workflow' })}   </div> <div className="space-y-1"> - { - forms.map((form, index) => { - return ( - <div - key={index} - className="py-1" - > - <div className="mb-1 flex items-center"> - <div className="mr-1 system-sm-medium text-text-primary">{form.key}</div> - <div className="system-xs-regular text-text-tertiary">{form.type}</div> - </div> - { - (form.type === VarType.string || form.type === VarType.number) && ( - <Input - type={form.type} - value={form.value || (form.type === VarType.string ? '' : 0)} - onChange={getFormChangeHandler({ key: form.key, type: form.type })} - /> - ) - } - { - ( - form.type === VarType.array - || form.type === VarType.arrayNumber - || form.type === VarType.arrayString - || form.type === VarType.arrayObject - || form.type === VarType.object - ) && ( - <CodeEditor - language={CodeLanguage.json} - value={form.value} - onChange={getFormChangeHandler({ key: form.key, type: form.type })} - /> - ) - } + {forms.map((form, index) => { + return ( + <div key={index} className="py-1"> + <div className="mb-1 flex items-center"> + <div className="mr-1 system-sm-medium text-text-primary">{form.key}</div> + <div className="system-xs-regular text-text-tertiary">{form.type}</div> </div> - ) - }) - } + {(form.type === VarType.string || form.type === VarType.number) && ( + <Input + type={form.type} + value={form.value || (form.type === VarType.string ? '' : 0)} + onChange={getFormChangeHandler({ key: form.key, type: form.type })} + /> + )} + {(form.type === VarType.array || + form.type === VarType.arrayNumber || + form.type === VarType.arrayString || + form.type === VarType.arrayObject || + form.type === VarType.object) && ( + <CodeEditor + language={CodeLanguage.json} + value={form.value} + onChange={getFormChangeHandler({ key: form.key, type: form.type })} + /> + )} + </div> + ) + })} </div> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-node.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-node.tsx index 10b26b192dfe7c..51b88c933fbefd 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-node.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-node.tsx @@ -8,59 +8,49 @@ import { NodeSourceHandle } from '../node-handle' import { ErrorHandleTypeEnum } from './types' type ErrorHandleOnNodeProps = Pick<Node, 'id' | 'data'> -const ErrorHandleOnNode = ({ - id, - data, -}: ErrorHandleOnNodeProps) => { +const ErrorHandleOnNode = ({ id, data }: ErrorHandleOnNodeProps) => { const { t } = useTranslation() const { error_strategy } = data const updateNodeInternals = useUpdateNodeInternals() useEffect(() => { - if (error_strategy === ErrorHandleTypeEnum.failBranch) - updateNodeInternals(id) + if (error_strategy === ErrorHandleTypeEnum.failBranch) updateNodeInternals(id) }, [error_strategy, id, updateNodeInternals]) - if (!error_strategy) - return null + if (!error_strategy) return null return ( <div className="relative px-3 pt-1 pb-2"> - <div className={cn( - 'relative flex h-6 items-center justify-between rounded-md bg-workflow-block-parma-bg px-[5px]', - data._runningStatus === NodeRunningStatus.Exception && 'border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover', - )} + <div + className={cn( + 'relative flex h-6 items-center justify-between rounded-md bg-workflow-block-parma-bg px-[5px]', + data._runningStatus === NodeRunningStatus.Exception && + 'border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover', + )} > <div className="system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['common.onFailure'], { ns: 'workflow' })} + {t(($) => $['common.onFailure'], { ns: 'workflow' })} </div> - <div className={cn( - 'system-xs-medium text-text-secondary', - data._runningStatus === NodeRunningStatus.Exception && 'text-text-warning', - )} + <div + className={cn( + 'system-xs-medium text-text-secondary', + data._runningStatus === NodeRunningStatus.Exception && 'text-text-warning', + )} > - { - error_strategy === ErrorHandleTypeEnum.defaultValue && ( - t($ => $['nodes.common.errorHandle.defaultValue.output'], { ns: 'workflow' }) - ) - } - { - error_strategy === ErrorHandleTypeEnum.failBranch && ( - t($ => $['nodes.common.errorHandle.failBranch.title'], { ns: 'workflow' }) - ) - } + {error_strategy === ErrorHandleTypeEnum.defaultValue && + t(($) => $['nodes.common.errorHandle.defaultValue.output'], { ns: 'workflow' })} + {error_strategy === ErrorHandleTypeEnum.failBranch && + t(($) => $['nodes.common.errorHandle.failBranch.title'], { ns: 'workflow' })} </div> - { - error_strategy === ErrorHandleTypeEnum.failBranch && ( - <NodeSourceHandle - id={id} - data={data} - handleId={ErrorHandleTypeEnum.failBranch} - handleClassName="top-1/2! -right-[21px]! -translate-y-1/2! after:bg-workflow-link-line-failure-button-bg!" - nodeSelectorClassName="bg-workflow-link-line-failure-button-bg!" - /> - ) - } + {error_strategy === ErrorHandleTypeEnum.failBranch && ( + <NodeSourceHandle + id={id} + data={data} + handleId={ErrorHandleTypeEnum.failBranch} + handleClassName="top-1/2! -right-[21px]! -translate-y-1/2! after:bg-workflow-link-line-failure-button-bg!" + nodeSelectorClassName="bg-workflow-link-line-failure-button-bg!" + /> + )} </div> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-panel.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-panel.tsx index 6c8c0664699b95..b8568cafc3f507 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-panel.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-panel.tsx @@ -1,8 +1,5 @@ import type { DefaultValueForm } from './types' -import type { - CommonNodeType, - Node, -} from '@/app/components/workflow/types' +import type { CommonNodeType, Node } from '@/app/components/workflow/types' import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' import { @@ -17,25 +14,15 @@ import { import DefaultValue from './default-value' import ErrorHandleTypeSelector from './error-handle-type-selector' import FailBranchCard from './fail-branch-card' -import { - useDefaultValue, - useErrorHandle, -} from './hooks' +import { useDefaultValue, useErrorHandle } from './hooks' import { ErrorHandleTypeEnum } from './types' type ErrorHandleProps = Pick<Node, 'id' | 'data'> -const ErrorHandle = ({ - id, - data, -}: ErrorHandleProps) => { +const ErrorHandle = ({ id, data }: ErrorHandleProps) => { const { t } = useTranslation() const { error_strategy, default_value } = data - const { - collapsed, - setCollapsed, - handleErrorHandleTypeChange, - } = useErrorHandle(id, data) + const { collapsed, setCollapsed, handleErrorHandleTypeChange } = useErrorHandle(id, data) const { handleFormChange } = useDefaultValue(id) const handleTypeChange = (value: ErrorHandleTypeEnum) => { @@ -48,20 +35,16 @@ const ErrorHandle = ({ return ( <div className="py-4"> - <Collapse - disabled={!error_strategy} - collapsed={collapsed} - onCollapse={setCollapsed} - > + <Collapse disabled={!error_strategy} collapsed={collapsed} onCollapse={setCollapsed}> <CollapseHeader> <CollapseTrigger> <CollapseTitle> - {t($ => $['nodes.common.errorHandle.title'], { ns: 'workflow' })} + {t(($) => $['nodes.common.errorHandle.title'], { ns: 'workflow' })} </CollapseTitle> {!!error_strategy && <CollapseIndicator />} </CollapseTrigger> - <Infotip aria-label={t($ => $['nodes.common.errorHandle.tip'], { ns: 'workflow' })}> - {t($ => $['nodes.common.errorHandle.tip'], { ns: 'workflow' })} + <Infotip aria-label={t(($) => $['nodes.common.errorHandle.tip'], { ns: 'workflow' })}> + {t(($) => $['nodes.common.errorHandle.tip'], { ns: 'workflow' })} </Infotip> <CollapseActions> <div className="pr-4"> @@ -73,15 +56,12 @@ const ErrorHandle = ({ </CollapseActions> </CollapseHeader> <CollapseContent> - {error_strategy === ErrorHandleTypeEnum.failBranch && !collapsed && ( - <FailBranchCard /> - )} - {error_strategy === ErrorHandleTypeEnum.defaultValue && !collapsed && !!default_value?.length && ( - <DefaultValue - forms={default_value} - onFormChange={handleDefaultValueChange} - /> - )} + {error_strategy === ErrorHandleTypeEnum.failBranch && !collapsed && <FailBranchCard />} + {error_strategy === ErrorHandleTypeEnum.defaultValue && + !collapsed && + !!default_value?.length && ( + <DefaultValue forms={default_value} onFormChange={handleDefaultValueChange} /> + )} </CollapseContent> </Collapse> </div> diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip.tsx index b0f640a2c19d83..d088c2f7124cc8 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip.tsx @@ -6,37 +6,30 @@ import { ErrorHandleTypeEnum } from './types' type ErrorHandleTipProps = { type?: ErrorHandleTypeEnum } -const ErrorHandleTip = ({ - type, -}: ErrorHandleTipProps) => { +const ErrorHandleTip = ({ type }: ErrorHandleTipProps) => { const { t } = useTranslation() const text = useMemo(() => { if (type === ErrorHandleTypeEnum.failBranch) - return t($ => $['nodes.common.errorHandle.failBranch.inLog'], { ns: 'workflow' }) + return t(($) => $['nodes.common.errorHandle.failBranch.inLog'], { ns: 'workflow' }) if (type === ErrorHandleTypeEnum.defaultValue) - return t($ => $['nodes.common.errorHandle.defaultValue.inLog'], { ns: 'workflow' }) + return t(($) => $['nodes.common.errorHandle.defaultValue.inLog'], { ns: 'workflow' }) }, [t, type]) - if (!type) - return null + if (!type) return null return ( - <div - className="relative flex rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 pr-[52px] shadow-xs" - > + <div className="relative flex rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 pr-[52px] shadow-xs"> <div className="absolute inset-0 rounded-lg opacity-40" style={{ - background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)', + background: + 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)', }} - > - </div> + ></div> <RiAlertFill className="mr-1 size-4 shrink-0 text-text-warning-secondary" /> - <div className="grow system-xs-medium text-text-primary"> - {text} - </div> + <div className="grow system-xs-medium text-text-primary">{text}</div> </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx index 02e7043eec2bc9..575eae18d14b86 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx @@ -6,10 +6,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { - RiArrowDownSLine, - RiCheckLine, -} from '@remixicon/react' +import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { ErrorHandleTypeEnum } from './types' @@ -17,41 +14,38 @@ type ErrorHandleTypeSelectorProps = { value: ErrorHandleTypeEnum onSelected: (value: ErrorHandleTypeEnum) => void } -const ErrorHandleTypeSelector = ({ - value, - onSelected, -}: ErrorHandleTypeSelectorProps) => { +const ErrorHandleTypeSelector = ({ value, onSelected }: ErrorHandleTypeSelectorProps) => { const { t } = useTranslation() const options = [ { value: ErrorHandleTypeEnum.none, - label: t($ => $['nodes.common.errorHandle.none.title'], { ns: 'workflow' }), - description: t($ => $['nodes.common.errorHandle.none.desc'], { ns: 'workflow' }), + label: t(($) => $['nodes.common.errorHandle.none.title'], { ns: 'workflow' }), + description: t(($) => $['nodes.common.errorHandle.none.desc'], { ns: 'workflow' }), }, { value: ErrorHandleTypeEnum.defaultValue, - label: t($ => $['nodes.common.errorHandle.defaultValue.title'], { ns: 'workflow' }), - description: t($ => $['nodes.common.errorHandle.defaultValue.desc'], { ns: 'workflow' }), + label: t(($) => $['nodes.common.errorHandle.defaultValue.title'], { ns: 'workflow' }), + description: t(($) => $['nodes.common.errorHandle.defaultValue.desc'], { ns: 'workflow' }), }, { value: ErrorHandleTypeEnum.failBranch, - label: t($ => $['nodes.common.errorHandle.failBranch.title'], { ns: 'workflow' }), - description: t($ => $['nodes.common.errorHandle.failBranch.desc'], { ns: 'workflow' }), + label: t(($) => $['nodes.common.errorHandle.failBranch.title'], { ns: 'workflow' }), + description: t(($) => $['nodes.common.errorHandle.failBranch.desc'], { ns: 'workflow' }), }, ] - const selectedOption = options.find(option => option.value === value) + const selectedOption = options.find((option) => option.value === value) return ( <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <Button size="small" onClick={(e) => { e.stopPropagation() }} /> - )} + } > {selectedOption?.label} <RiArrowDownSLine className="size-3.5" /> @@ -61,35 +55,26 @@ const ErrorHandleTypeSelector = ({ sideOffset={4} popupClassName="w-[280px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1" > - <DropdownMenuRadioGroup - value={value} - onValueChange={onSelected} - > - { - options.map(option => ( - <DropdownMenuRadioItem - key={option.value} - value={option.value} - closeOnClick - className="h-auto items-start rounded-lg p-2 pr-3" - onClick={(e) => { - e.stopPropagation() - }} - > - <div className="mr-1 w-4 shrink-0"> - { - value === option.value && ( - <RiCheckLine className="size-4 text-text-accent" /> - ) - } - </div> - <div className="grow"> - <div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div> - <div className="system-xs-regular text-text-tertiary">{option.description}</div> - </div> - </DropdownMenuRadioItem> - )) - } + <DropdownMenuRadioGroup value={value} onValueChange={onSelected}> + {options.map((option) => ( + <DropdownMenuRadioItem + key={option.value} + value={option.value} + closeOnClick + className="h-auto items-start rounded-lg p-2 pr-3" + onClick={(e) => { + e.stopPropagation() + }} + > + <div className="mr-1 w-4 shrink-0"> + {value === option.value && <RiCheckLine className="size-4 text-text-accent" />} + </div> + <div className="grow"> + <div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div> + <div className="system-xs-regular text-text-tertiary">{option.description}</div> + </div> + </DropdownMenuRadioItem> + ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx index 47d9af65133721..91a2621669934b 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx @@ -13,10 +13,10 @@ const FailBranchCard = () => { <RiMindMap className="size-5 text-text-tertiary" /> </div> <div className="mb-1 system-sm-medium text-text-secondary"> - {t($ => $['nodes.common.errorHandle.failBranch.customize'], { ns: 'workflow' })} + {t(($) => $['nodes.common.errorHandle.failBranch.customize'], { ns: 'workflow' })} </div> <div className="system-xs-regular text-text-tertiary"> - {t($ => $['nodes.common.errorHandle.failBranch.customizeTip'], { ns: 'workflow' })} + {t(($) => $['nodes.common.errorHandle.failBranch.customizeTip'], { ns: 'workflow' })}   <a href={docLink('/use-dify/debug/error-type')} @@ -24,7 +24,7 @@ const FailBranchCard = () => { rel="noopener noreferrer" className="text-text-accent" > - {t($ => $['common.learnMore'], { ns: 'workflow' })} + {t(($) => $['common.learnMore'], { ns: 'workflow' })} </a> </div> </div> diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/hooks.ts b/web/app/components/workflow/nodes/_base/components/error-handle/hooks.ts index 810d6bf0d457e6..07186480b022d0 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/hooks.ts +++ b/web/app/components/workflow/nodes/_base/components/error-handle/hooks.ts @@ -1,80 +1,60 @@ import type { DefaultValueForm } from './types' -import type { - CommonNodeType, -} from '@/app/components/workflow/types' -import { - useCallback, - useMemo, - useState, -} from 'react' -import { - useEdgesInteractions, - useNodeDataUpdate, -} from '@/app/components/workflow/hooks' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { useCallback, useMemo, useState } from 'react' +import { useEdgesInteractions, useNodeDataUpdate } from '@/app/components/workflow/hooks' import { ErrorHandleTypeEnum } from './types' import { getDefaultValue } from './utils' -export const useDefaultValue = ( - id: string, -) => { +export const useDefaultValue = (id: string) => { const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() - const handleFormChange = useCallback(( - { - key, - value, - type, - }: DefaultValueForm, - data: CommonNodeType, - ) => { - const default_value = data.default_value || [] - const index = default_value.findIndex(form => form.key === key) + const handleFormChange = useCallback( + ({ key, value, type }: DefaultValueForm, data: CommonNodeType) => { + const default_value = data.default_value || [] + const index = default_value.findIndex((form) => form.key === key) + + if (index > -1) { + const newDefaultValue = default_value.map((form) => { + if (form.key !== key) return form + // clone the entry so we do not mutate the original reference (which would block CRDT diffs) + return { + ...form, + value, + } + }) + handleNodeDataUpdateWithSyncDraft({ + id, + data: { + default_value: newDefaultValue, + }, + }) + return + } - if (index > -1) { - const newDefaultValue = default_value.map((form) => { - if (form.key !== key) - return form - // clone the entry so we do not mutate the original reference (which would block CRDT diffs) - return { - ...form, - value, - } - }) handleNodeDataUpdateWithSyncDraft({ id, data: { - default_value: newDefaultValue, + default_value: [ + ...default_value, + { + key, + value, + type, + }, + ], }, }) - return - } - - handleNodeDataUpdateWithSyncDraft({ - id, - data: { - default_value: [ - ...default_value, - { - key, - value, - type, - }, - ], - }, - }) - }, [handleNodeDataUpdateWithSyncDraft, id]) + }, + [handleNodeDataUpdateWithSyncDraft, id], + ) return { handleFormChange, } } -export const useErrorHandle = ( - id: string, - data: CommonNodeType, -) => { +export const useErrorHandle = (id: string, data: CommonNodeType) => { const initCollapsed = useMemo(() => { - if (data.error_strategy === ErrorHandleTypeEnum.none) - return true + if (data.error_strategy === ErrorHandleTypeEnum.none) return true return false }, [data.error_strategy]) @@ -82,45 +62,47 @@ export const useErrorHandle = ( const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions() - const handleErrorHandleTypeChange = useCallback((value: ErrorHandleTypeEnum, data: CommonNodeType) => { - if (data.error_strategy === value) - return + const handleErrorHandleTypeChange = useCallback( + (value: ErrorHandleTypeEnum, data: CommonNodeType) => { + if (data.error_strategy === value) return - if (value === ErrorHandleTypeEnum.none) { - handleNodeDataUpdateWithSyncDraft({ - id, - data: { - error_strategy: undefined, - default_value: undefined, - }, - }) - setCollapsed(true) - handleEdgeDeleteByDeleteBranch(id, ErrorHandleTypeEnum.failBranch) - } + if (value === ErrorHandleTypeEnum.none) { + handleNodeDataUpdateWithSyncDraft({ + id, + data: { + error_strategy: undefined, + default_value: undefined, + }, + }) + setCollapsed(true) + handleEdgeDeleteByDeleteBranch(id, ErrorHandleTypeEnum.failBranch) + } - if (value === ErrorHandleTypeEnum.failBranch) { - handleNodeDataUpdateWithSyncDraft({ - id, - data: { - error_strategy: value, - default_value: undefined, - }, - }) - setCollapsed(false) - } + if (value === ErrorHandleTypeEnum.failBranch) { + handleNodeDataUpdateWithSyncDraft({ + id, + data: { + error_strategy: value, + default_value: undefined, + }, + }) + setCollapsed(false) + } - if (value === ErrorHandleTypeEnum.defaultValue) { - handleNodeDataUpdateWithSyncDraft({ - id, - data: { - error_strategy: value, - default_value: getDefaultValue(data), - }, - }) - setCollapsed(false) - handleEdgeDeleteByDeleteBranch(id, ErrorHandleTypeEnum.failBranch) - } - }, [id, handleNodeDataUpdateWithSyncDraft, handleEdgeDeleteByDeleteBranch]) + if (value === ErrorHandleTypeEnum.defaultValue) { + handleNodeDataUpdateWithSyncDraft({ + id, + data: { + error_strategy: value, + default_value: getDefaultValue(data), + }, + }) + setCollapsed(false) + handleEdgeDeleteByDeleteBranch(id, ErrorHandleTypeEnum.failBranch) + } + }, + [id, handleNodeDataUpdateWithSyncDraft, handleEdgeDeleteByDeleteBranch], + ) return { collapsed, diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/utils.ts b/web/app/components/workflow/nodes/_base/components/error-handle/utils.ts index 4c8c6898e28e36..5671e0834e8c2a 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/error-handle/utils.ts @@ -1,21 +1,20 @@ import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types' import type { CommonNodeType } from '@/app/components/workflow/types' -import { - BlockEnum, - VarType, -} from '@/app/components/workflow/types' +import { BlockEnum, VarType } from '@/app/components/workflow/types' const getDefaultValueByType = (type: VarType) => { - if (type === VarType.string) - return '' + if (type === VarType.string) return '' - if (type === VarType.number) - return 0 + if (type === VarType.number) return 0 - if (type === VarType.object) - return '{}' + if (type === VarType.object) return '{}' - if (type === VarType.arrayObject || type === VarType.arrayString || type === VarType.arrayNumber || type === VarType.arrayFile) + if ( + type === VarType.arrayObject || + type === VarType.arrayString || + type === VarType.arrayNumber || + type === VarType.arrayFile + ) return '[]' return '' @@ -25,11 +24,13 @@ export const getDefaultValue = (data: CommonNodeType) => { const { type } = data if (type === BlockEnum.LLM) { - return [{ - key: 'text', - type: VarType.string, - value: getDefaultValueByType(VarType.string), - }] + return [ + { + key: 'text', + type: VarType.string, + value: getDefaultValueByType(VarType.string), + }, + ] } if (type === BlockEnum.HttpRequest) { diff --git a/web/app/components/workflow/nodes/_base/components/field.tsx b/web/app/components/workflow/nodes/_base/components/field.tsx index d4de8eb55578de..8325c38893654b 100644 --- a/web/app/components/workflow/nodes/_base/components/field.tsx +++ b/web/app/components/workflow/nodes/_base/components/field.tsx @@ -1,9 +1,7 @@ 'use client' import type { FC, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - RiArrowDownSLine, -} from '@remixicon/react' +import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' import { Infotip } from '@/app/components/base/infotip' @@ -22,11 +20,9 @@ type Props = Readonly<{ }> const getTextFromNode = (node: ReactNode): string | undefined => { - if (typeof node === 'string' || typeof node === 'number') - return `${node}` + if (typeof node === 'string' || typeof node === 'number') return `${node}` - if (Array.isArray(node)) - return node.map(getTextFromNode).filter(Boolean).join(' ') + if (Array.isArray(node)) return node.map(getTextFromNode).filter(Boolean).join(' ') if (React.isValidElement<{ children?: ReactNode }>(node)) return getTextFromNode(node.props.children) @@ -44,10 +40,10 @@ const Field: FC<Props> = ({ required, warningDot, }) => { - const [fold, { - toggle: toggleFold, - }] = useBoolean(true) - const tooltipLabel = tooltip ? getTextFromNode(tooltip) || getTextFromNode(title) || 'Help' : undefined + const [fold, { toggle: toggleFold }] = useBoolean(true) + const tooltipLabel = tooltip + ? getTextFromNode(tooltip) || getTextFromNode(title) || 'Help' + : undefined return ( <div className={cn(className, inline && 'flex w-full items-center justify-between')}> @@ -56,13 +52,18 @@ const Field: FC<Props> = ({ className={cn('flex items-center justify-between', supportFold && 'cursor-pointer')} > <div className="flex h-6 items-center"> - <div className={cn('relative', isSubTitle ? 'system-xs-medium-uppercase text-text-tertiary' : 'system-sm-semibold-uppercase text-text-secondary')}> + <div + className={cn( + 'relative', + isSubTitle + ? 'system-xs-medium-uppercase text-text-tertiary' + : 'system-sm-semibold-uppercase text-text-secondary', + )} + > {warningDot && ( <span className="absolute top-1/2 left-[-9px] size-[5px] -translate-y-1/2 rounded-full bg-text-warning-secondary" /> )} - {title} - {' '} - {required && <span className="text-text-destructive">*</span>} + {title} {required && <span className="text-text-destructive">*</span>} </div> {!!tooltip && !!tooltipLabel && ( <Infotip aria-label={tooltipLabel} className="ml-1"> @@ -73,11 +74,16 @@ const Field: FC<Props> = ({ <div className="flex"> {!!operations && <div>{operations}</div>} {supportFold && ( - <RiArrowDownSLine className="size-4 cursor-pointer text-text-tertiary transition-transform" style={{ transform: fold ? 'rotate(-90deg)' : 'rotate(0deg)' }} /> + <RiArrowDownSLine + className="size-4 cursor-pointer text-text-tertiary transition-transform" + style={{ transform: fold ? 'rotate(-90deg)' : 'rotate(0deg)' }} + /> )} </div> </div> - {!!(children && (!supportFold || (supportFold && !fold))) && <div className={cn(!inline && 'mt-1')}>{children}</div>} + {!!(children && (!supportFold || (supportFold && !fold))) && ( + <div className={cn(!inline && 'mt-1')}>{children}</div> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/file-type-item.tsx b/web/app/components/workflow/nodes/_base/components/file-type-item.tsx index f9f37c1b04bb31..8bdb952fe38b7d 100644 --- a/web/app/components/workflow/nodes/_base/components/file-type-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/file-type-item.tsx @@ -12,7 +12,12 @@ import TagInput from '@/app/components/base/tag-input' import { SupportUploadFileTypes } from '../../../types' type Props = Readonly<{ - type: SupportUploadFileTypes.image | SupportUploadFileTypes.document | SupportUploadFileTypes.audio | SupportUploadFileTypes.video | SupportUploadFileTypes.custom + type: + | SupportUploadFileTypes.image + | SupportUploadFileTypes.document + | SupportUploadFileTypes.audio + | SupportUploadFileTypes.video + | SupportUploadFileTypes.custom selected: boolean onToggle: (type: SupportUploadFileTypes) => void onCustomFileTypesChange?: (customFileTypes: string[]) => void @@ -39,39 +44,56 @@ const FileTypeItem: FC<Props> = ({ className={cn( 'cursor-pointer rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg select-none', !isCustomSelected && 'px-3 py-2', - selected && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg', - !selected && 'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover', + selected && + 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg', + !selected && + 'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover', )} onClick={handleOnSelect} > - {isCustomSelected - ? ( - <div> - <div className="flex items-center border-b border-divider-subtle p-3 pb-2"> - <FileTypeIcon className="shrink-0" type={type} size="lg" /> - <div className="mx-2 grow system-sm-medium text-text-primary">{t($ => $[`variableConfig.file.${type}.name`], { ns: 'appDebug' })}</div> - <Checkbox className="shrink-0" checked={selected} aria-label={t($ => $[`variableConfig.file.${type}.name`], { ns: 'appDebug' })} /> - </div> - <div className="p-3" onClick={e => e.stopPropagation()}> - <TagInput - items={customFileTypes} - onChange={onCustomFileTypesChange} - placeholder={t($ => $['variableConfig.file.custom.createPlaceholder'], { ns: 'appDebug' })!} - /> - </div> + {isCustomSelected ? ( + <div> + <div className="flex items-center border-b border-divider-subtle p-3 pb-2"> + <FileTypeIcon className="shrink-0" type={type} size="lg" /> + <div className="mx-2 grow system-sm-medium text-text-primary"> + {t(($) => $[`variableConfig.file.${type}.name`], { ns: 'appDebug' })} </div> - ) - : ( - <div className="flex items-center"> - <FileTypeIcon className="shrink-0" type={type} size="lg" /> - <div className="mx-2 grow"> - <div className="system-sm-medium text-text-primary">{t($ => $[`variableConfig.file.${type}.name`], { ns: 'appDebug' })}</div> - <div className="mt-1 system-2xs-regular-uppercase text-text-tertiary">{type !== SupportUploadFileTypes.custom ? FILE_EXTS[type]!.join(', ') : t($ => $['variableConfig.file.custom.description'], { ns: 'appDebug' })}</div> - </div> - <Checkbox className="shrink-0" checked={selected} aria-label={t($ => $[`variableConfig.file.${type}.name`], { ns: 'appDebug' })} /> + <Checkbox + className="shrink-0" + checked={selected} + aria-label={t(($) => $[`variableConfig.file.${type}.name`], { ns: 'appDebug' })} + /> + </div> + <div className="p-3" onClick={(e) => e.stopPropagation()}> + <TagInput + items={customFileTypes} + onChange={onCustomFileTypesChange} + placeholder={ + t(($) => $['variableConfig.file.custom.createPlaceholder'], { ns: 'appDebug' })! + } + /> + </div> + </div> + ) : ( + <div className="flex items-center"> + <FileTypeIcon className="shrink-0" type={type} size="lg" /> + <div className="mx-2 grow"> + <div className="system-sm-medium text-text-primary"> + {t(($) => $[`variableConfig.file.${type}.name`], { ns: 'appDebug' })} </div> - )} - + <div className="mt-1 system-2xs-regular-uppercase text-text-tertiary"> + {type !== SupportUploadFileTypes.custom + ? FILE_EXTS[type]!.join(', ') + : t(($) => $['variableConfig.file.custom.description'], { ns: 'appDebug' })} + </div> + </div> + <Checkbox + className="shrink-0" + checked={selected} + aria-label={t(($) => $[`variableConfig.file.${type}.name`], { ns: 'appDebug' })} + /> + </div> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/file-upload-setting.tsx b/web/app/components/workflow/nodes/_base/components/file-upload-setting.tsx index 71681ecdf6c5e5..3aafd8f2a0c8fb 100644 --- a/web/app/components/workflow/nodes/_base/components/file-upload-setting.tsx +++ b/web/app/components/workflow/nodes/_base/components/file-upload-setting.tsx @@ -39,82 +39,96 @@ const FileUploadSetting: FC<Props> = ({ allowed_file_extensions = [], } = payload const { data: fileUploadConfigResponse } = useFileUploadConfig() - const { - imgSizeLimit, - docSizeLimit, - audioSizeLimit, - videoSizeLimit, - maxFileUploadLimit, - } = useFileSizeLimit(fileUploadConfigResponse) + const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit, maxFileUploadLimit } = + useFileSizeLimit(fileUploadConfigResponse) - const handleSupportFileTypeChange = useCallback((type: SupportUploadFileTypes) => { - const newPayload = produce(payload, (draft) => { - if (type === SupportUploadFileTypes.custom) { - if (!draft.allowed_file_types.includes(SupportUploadFileTypes.custom)) - draft.allowed_file_types = [SupportUploadFileTypes.custom] + const handleSupportFileTypeChange = useCallback( + (type: SupportUploadFileTypes) => { + const newPayload = produce(payload, (draft) => { + if (type === SupportUploadFileTypes.custom) { + if (!draft.allowed_file_types.includes(SupportUploadFileTypes.custom)) + draft.allowed_file_types = [SupportUploadFileTypes.custom] + else draft.allowed_file_types = draft.allowed_file_types.filter((v) => v !== type) + } else { + draft.allowed_file_types = draft.allowed_file_types.filter( + (v) => v !== SupportUploadFileTypes.custom, + ) + if (draft.allowed_file_types.includes(type)) + draft.allowed_file_types = draft.allowed_file_types.filter((v) => v !== type) + else draft.allowed_file_types.push(type) + } + }) + onChange(newPayload) + }, + [onChange, payload], + ) - else - draft.allowed_file_types = draft.allowed_file_types.filter(v => v !== type) + const handleUploadMethodChange = useCallback( + (method: TransferMethod) => { + return () => { + const newPayload = produce(payload, (draft) => { + if (method === TransferMethod.all) + draft.allowed_file_upload_methods = [ + TransferMethod.local_file, + TransferMethod.remote_url, + ] + else draft.allowed_file_upload_methods = [method] + }) + onChange(newPayload) } - else { - draft.allowed_file_types = draft.allowed_file_types.filter(v => v !== SupportUploadFileTypes.custom) - if (draft.allowed_file_types.includes(type)) - draft.allowed_file_types = draft.allowed_file_types.filter(v => v !== type) - else - draft.allowed_file_types.push(type) - } - }) - onChange(newPayload) - }, [onChange, payload]) + }, + [onChange, payload], + ) - const handleUploadMethodChange = useCallback((method: TransferMethod) => { - return () => { + const handleCustomFileTypesChange = useCallback( + (customFileTypes: string[]) => { const newPayload = produce(payload, (draft) => { - if (method === TransferMethod.all) - draft.allowed_file_upload_methods = [TransferMethod.local_file, TransferMethod.remote_url] - else - draft.allowed_file_upload_methods = [method] + draft.allowed_file_extensions = customFileTypes.map((v) => { + return v + }) }) onChange(newPayload) - } - }, [onChange, payload]) + }, + [onChange, payload], + ) - const handleCustomFileTypesChange = useCallback((customFileTypes: string[]) => { - const newPayload = produce(payload, (draft) => { - draft.allowed_file_extensions = customFileTypes.map((v) => { - return v + const handleMaxUploadNumLimitChange = useCallback( + (value: number) => { + const normalizedValue = Number.isFinite(value) + ? Math.min(Math.max(value, 1), maxFileUploadLimit) + : value + const newPayload = produce(payload, (draft) => { + draft.max_length = normalizedValue }) - }) - onChange(newPayload) - }, [onChange, payload]) - - const handleMaxUploadNumLimitChange = useCallback((value: number) => { - const normalizedValue = Number.isFinite(value) - ? Math.min(Math.max(value, 1), maxFileUploadLimit) - : value - const newPayload = produce(payload, (draft) => { - draft.max_length = normalizedValue - }) - onChange(newPayload) - }, [maxFileUploadLimit, onChange, payload]) + onChange(newPayload) + }, + [maxFileUploadLimit, onChange, payload], + ) return ( <div> {!inFeaturePanel && ( - <Field - title={t($ => $['variableConfig.file.supportFileTypes'], { ns: 'appDebug' })} - > + <Field title={t(($) => $['variableConfig.file.supportFileTypes'], { ns: 'appDebug' })}> <div className="space-y-1"> - { - [SupportUploadFileTypes.document, SupportUploadFileTypes.image, SupportUploadFileTypes.audio, SupportUploadFileTypes.video].map((type: SupportUploadFileTypes) => ( - <FileTypeItem - key={type} - type={type as SupportUploadFileTypes.image | SupportUploadFileTypes.document | SupportUploadFileTypes.audio | SupportUploadFileTypes.video} - selected={allowed_file_types.includes(type)} - onToggle={handleSupportFileTypeChange} - /> - )) - } + {[ + SupportUploadFileTypes.document, + SupportUploadFileTypes.image, + SupportUploadFileTypes.audio, + SupportUploadFileTypes.video, + ].map((type: SupportUploadFileTypes) => ( + <FileTypeItem + key={type} + type={ + type as + | SupportUploadFileTypes.image + | SupportUploadFileTypes.document + | SupportUploadFileTypes.audio + | SupportUploadFileTypes.video + } + selected={allowed_file_types.includes(type)} + onToggle={handleSupportFileTypeChange} + /> + ))} <FileTypeItem type={SupportUploadFileTypes.custom} selected={allowed_file_types.includes(SupportUploadFileTypes.custom)} @@ -126,23 +140,32 @@ const FileUploadSetting: FC<Props> = ({ </Field> )} <Field - title={t($ => $['variableConfig.uploadFileTypes'], { ns: 'appDebug' })} + title={t(($) => $['variableConfig.uploadFileTypes'], { ns: 'appDebug' })} className="mt-4" > <div className="grid grid-cols-3 gap-2"> <OptionCard - title={t($ => $['variableConfig.localUpload'], { ns: 'appDebug' })} - selected={allowed_file_upload_methods.length === 1 && allowed_file_upload_methods.includes(TransferMethod.local_file)} + title={t(($) => $['variableConfig.localUpload'], { ns: 'appDebug' })} + selected={ + allowed_file_upload_methods.length === 1 && + allowed_file_upload_methods.includes(TransferMethod.local_file) + } onSelect={handleUploadMethodChange(TransferMethod.local_file)} /> <OptionCard title="URL" - selected={allowed_file_upload_methods.length === 1 && allowed_file_upload_methods.includes(TransferMethod.remote_url)} + selected={ + allowed_file_upload_methods.length === 1 && + allowed_file_upload_methods.includes(TransferMethod.remote_url) + } onSelect={handleUploadMethodChange(TransferMethod.remote_url)} /> <OptionCard - title={t($ => $['variableConfig.both'], { ns: 'appDebug' })} - selected={allowed_file_upload_methods.includes(TransferMethod.local_file) && allowed_file_upload_methods.includes(TransferMethod.remote_url)} + title={t(($) => $['variableConfig.both'], { ns: 'appDebug' })} + selected={ + allowed_file_upload_methods.includes(TransferMethod.local_file) && + allowed_file_upload_methods.includes(TransferMethod.remote_url) + } onSelect={handleUploadMethodChange(TransferMethod.all)} /> </div> @@ -150,11 +173,11 @@ const FileUploadSetting: FC<Props> = ({ {isMultiple && ( <Field className="mt-4" - title={t($ => $['variableConfig.maxNumberOfUploads'], { ns: 'appDebug' })!} + title={t(($) => $['variableConfig.maxNumberOfUploads'], { ns: 'appDebug' })!} > <div> <div className="mb-1.5 body-xs-regular text-text-tertiary"> - {t($ => $['variableConfig.maxNumberTip'], { + {t(($) => $['variableConfig.maxNumberTip'], { ns: 'appDebug', imgLimit: formatFileSize(imgSizeLimit), docLimit: formatFileSize(docSizeLimit), @@ -164,7 +187,7 @@ const FileUploadSetting: FC<Props> = ({ </div> <InputNumberWithSlider - label={t($ => $['variableConfig.maxNumberOfUploads'], { ns: 'appDebug' })!} + label={t(($) => $['variableConfig.maxNumberOfUploads'], { ns: 'appDebug' })!} value={max_length} defaultValue={1} min={1} @@ -176,20 +199,29 @@ const FileUploadSetting: FC<Props> = ({ )} {inFeaturePanel && !hideSupportFileType && ( <Field - title={t($ => $['variableConfig.file.supportFileTypes'], { ns: 'appDebug' })} + title={t(($) => $['variableConfig.file.supportFileTypes'], { ns: 'appDebug' })} className="mt-4" > <div className="space-y-1"> - { - [SupportUploadFileTypes.document, SupportUploadFileTypes.image, SupportUploadFileTypes.audio, SupportUploadFileTypes.video].map((type: SupportUploadFileTypes) => ( - <FileTypeItem - key={type} - type={type as SupportUploadFileTypes.image | SupportUploadFileTypes.document | SupportUploadFileTypes.audio | SupportUploadFileTypes.video} - selected={allowed_file_types.includes(type)} - onToggle={handleSupportFileTypeChange} - /> - )) - } + {[ + SupportUploadFileTypes.document, + SupportUploadFileTypes.image, + SupportUploadFileTypes.audio, + SupportUploadFileTypes.video, + ].map((type: SupportUploadFileTypes) => ( + <FileTypeItem + key={type} + type={ + type as + | SupportUploadFileTypes.image + | SupportUploadFileTypes.document + | SupportUploadFileTypes.audio + | SupportUploadFileTypes.video + } + selected={allowed_file_types.includes(type)} + onToggle={handleSupportFileTypeChange} + /> + ))} <FileTypeItem type={SupportUploadFileTypes.custom} selected={allowed_file_types.includes(SupportUploadFileTypes.custom)} @@ -200,7 +232,6 @@ const FileUploadSetting: FC<Props> = ({ </div> </Field> )} - </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx b/web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx index 05013ae6949b7b..d5433f04ccc687 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx @@ -7,17 +7,16 @@ type Props = Readonly<{ onChange: (value: boolean) => void }> -const FormInputBoolean: FC<Props> = ({ - value, - onChange, -}) => { +const FormInputBoolean: FC<Props> = ({ value, onChange }) => { return ( <div className="flex w-full space-x-1"> <div className={cn( 'flex h-8 grow cursor-default items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary', - !value && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', - value && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs', + !value && + 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', + value && + 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs', )} onClick={() => onChange(true)} > @@ -26,8 +25,10 @@ const FormInputBoolean: FC<Props> = ({ <div className={cn( 'flex h-8 grow cursor-default items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary', - value && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', - !value && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs', + value && + 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', + !value && + 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs', )} onClick={() => onChange(false)} > diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.helpers.ts b/web/app/components/workflow/nodes/_base/components/form-input-item.helpers.ts index b5bd785ec3c8dd..12f70742041537 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.helpers.ts +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.helpers.ts @@ -11,13 +11,14 @@ import { FormTypeEnum } from '@/app/components/header/account-setting/model-prov import { VarType } from '@/app/components/workflow/types' import { VarKindType } from '../types' -type FormInputSchema = CredentialFormSchema & Partial<{ - _type: FormTypeEnum - multiple: boolean - options: FormOption[] - placeholder: TypeWithI18N - scope: string -}> +type FormInputSchema = CredentialFormSchema & + Partial<{ + _type: FormTypeEnum + multiple: boolean + options: FormOption[] + placeholder: TypeWithI18N + scope: string + }> type FormInputValue = ResourceVarInputs[string] | undefined @@ -66,14 +67,12 @@ type FormInputState = { variable: string } -const optionMatchesValue = ( - values: ResourceVarInputs, - showOnItem: ShowOnCondition, -) => values[showOnItem.variable]?.value === showOnItem.value || values[showOnItem.variable] === showOnItem.value +const optionMatchesValue = (values: ResourceVarInputs, showOnItem: ShowOnCondition) => + values[showOnItem.variable]?.value === showOnItem.value || + values[showOnItem.variable] === showOnItem.value const getOptionLabel = (option: SelectableOption, language: string) => { - if (typeof option.label === 'string') - return option.label + if (typeof option.label === 'string') return option.label return option.label[language] || option.label.en_US || option.value } @@ -138,91 +137,81 @@ export const getFormInputState = ( } export const getTargetVarType = (state: FormInputState) => { - if (state.isString) - return VarType.string - if (state.isNumber) - return VarType.number - if (state.isFile) - return state.isFiles ? VarType.arrayFile : VarType.file - if (state.isSelect) - return VarType.string - if (state.isBoolean) - return VarType.boolean - if (state.isObject) - return VarType.object - if (state.isArray) - return VarType.arrayObject + if (state.isString) return VarType.string + if (state.isNumber) return VarType.number + if (state.isFile) return state.isFiles ? VarType.arrayFile : VarType.file + if (state.isSelect) return VarType.string + if (state.isBoolean) return VarType.boolean + if (state.isObject) return VarType.object + if (state.isArray) return VarType.arrayObject return VarType.string } export const getFilterVar = (state: FormInputState) => { - if (state.isNumber) - return (varPayload: Var) => varPayload.type === VarType.number + if (state.isNumber) return (varPayload: Var) => varPayload.type === VarType.number if (state.isString) - return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) + return (varPayload: Var) => + [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) if (state.isFile) return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type) - if (state.isBoolean) - return (varPayload: Var) => varPayload.type === VarType.boolean - if (state.isObject) - return (varPayload: Var) => varPayload.type === VarType.object + if (state.isBoolean) return (varPayload: Var) => varPayload.type === VarType.boolean + if (state.isObject) return (varPayload: Var) => varPayload.type === VarType.object if (state.isArray) - return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type) + return (varPayload: Var) => + [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes( + varPayload.type, + ) return undefined } export const getVarKindType = (state: FormInputState) => { - if (state.isFile) - return VarKindType.variable - if (state.isSelect || state.isDynamicSelect || state.isBoolean || state.isNumber || state.isArray || state.isObject) + if (state.isFile) return VarKindType.variable + if ( + state.isSelect || + state.isDynamicSelect || + state.isBoolean || + state.isNumber || + state.isArray || + state.isObject + ) return VarKindType.constant - if (state.isString) - return VarKindType.mixed + if (state.isString) return VarKindType.mixed return undefined } -export const filterVisibleOptions = ( - options: SelectableOption[], - values: ResourceVarInputs, -) => options.filter((option) => { - if (option.show_on?.length) - return option.show_on.every(showOnItem => optionMatchesValue(values, showOnItem)) - return true -}) - -export const mapSelectItems = ( - options: SelectableOption[], - language: string, -): SelectItem[] => options.map(option => ({ - icon: option.icon, - name: getOptionLabel(option, language), - value: option.value, -})) +export const filterVisibleOptions = (options: SelectableOption[], values: ResourceVarInputs) => + options.filter((option) => { + if (option.show_on?.length) + return option.show_on.every((showOnItem) => optionMatchesValue(values, showOnItem)) + return true + }) + +export const mapSelectItems = (options: SelectableOption[], language: string): SelectItem[] => + options.map((option) => ({ + icon: option.icon, + name: getOptionLabel(option, language), + value: option.value, + })) export const getSelectedLabels = ( selectedValues: string[] | undefined, options: SelectableOption[], language: string, ) => { - if (!selectedValues?.length) - return '' + if (!selectedValues?.length) return '' - const selectedOptions = options.filter(option => selectedValues.includes(option.value)) + const selectedOptions = options.filter((option) => selectedValues.includes(option.value)) if (selectedOptions.length <= 2) { - return selectedOptions - .map(option => getOptionLabel(option, language)) - .join(', ') + return selectedOptions.map((option) => getOptionLabel(option, language)).join(', ') } return `${selectedOptions.length} selected` } -export const getCheckboxListOptions = ( - options: SelectableOption[], - language: string, -) => options.map(option => ({ - label: getOptionLabel(option, language), - value: option.value, -})) +export const getCheckboxListOptions = (options: SelectableOption[], language: string) => + options.map((option) => ({ + label: getOptionLabel(option, language), + value: option.value, + })) export const getCheckboxListValue = ( currentValue: unknown, @@ -231,26 +220,20 @@ export const getCheckboxListValue = ( ) => { let current: string[] = [] - if (Array.isArray(currentValue)) - current = currentValue as string[] - else if (typeof currentValue === 'string') - current = [currentValue] - else if (Array.isArray(defaultValue)) - current = defaultValue as string[] + if (Array.isArray(currentValue)) current = currentValue as string[] + else if (typeof currentValue === 'string') current = [currentValue] + else if (Array.isArray(defaultValue)) current = defaultValue as string[] - const allowedValues = new Set(availableOptions.map(option => option.value)) - return current.filter(item => allowedValues.has(item)) + const allowedValues = new Set(availableOptions.map((option) => option.value)) + return current.filter((item) => allowedValues.has(item)) } export const getNumberInputValue = (currentValue: unknown): number | string => { - if (typeof currentValue === 'number') - return Number.isNaN(currentValue) ? '' : currentValue + if (typeof currentValue === 'number') return Number.isNaN(currentValue) ? '' : currentValue - if (typeof currentValue === 'string') - return currentValue + if (typeof currentValue === 'string') return currentValue return '' } -export const normalizeVariableSelectorValue = (value: ValueSelector | string) => - value || '' +export const normalizeVariableSelectorValue = (value: ValueSelector | string) => value || '' diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.sections.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.sections.tsx index 3df4b43a8c3708..269ae01853000a 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.sections.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.sections.tsx @@ -26,7 +26,10 @@ type MultiSelectFieldProps = { } const LoadingIndicator = () => ( - <RiLoader4Line className="mr-1 size-3.5 shrink-0 animate-spin text-text-secondary motion-reduce:animate-none" aria-hidden="true" /> + <RiLoader4Line + className="mr-1 size-3.5 shrink-0 animate-spin text-text-secondary motion-reduce:animate-none" + aria-hidden="true" + /> ) export const MultiSelectField: FC<MultiSelectFieldProps> = ({ @@ -48,8 +51,7 @@ export const MultiSelectField: FC<MultiSelectFieldProps> = ({ ) const renderLabel = () => { - if (isLoading) - return 'Loading…' + if (isLoading) return 'Loading…' return selectedLabel || placeholder || 'Select options' } @@ -67,19 +69,19 @@ export const MultiSelectField: FC<MultiSelectFieldProps> = ({ popupClassName="w-(--anchor-width) bg-components-panel-bg-blur backdrop-blur-xs" listClassName="max-h-60" > - {items.map(item => ( - <DifySelectItem - key={item.value} - value={item.value} - className="h-auto py-2 pr-9 pl-3" - > + {items.map((item) => ( + <DifySelectItem key={item.value} value={item.value} className="h-auto py-2 pr-9 pl-3"> <div className="flex min-w-0 items-center"> {item.icon && ( - <img src={item.icon} alt="" width={16} height={16} className="mr-2 size-4 shrink-0" /> + <img + src={item.icon} + alt="" + width={16} + height={16} + className="mr-2 size-4 shrink-0" + /> )} - <SelectItemText> - {item.name} - </SelectItemText> + <SelectItemText>{item.name}</SelectItemText> </div> <SelectItemIndicator /> </DifySelectItem> @@ -96,11 +98,7 @@ type JsonEditorFieldProps = { value: string } -export const JsonEditorField: FC<JsonEditorFieldProps> = ({ - onChange, - placeholder, - value, -}) => { +export const JsonEditorField: FC<JsonEditorFieldProps> = ({ onChange, placeholder, value }) => { return ( <div className="mt-1 w-full"> <CodeEditor diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx index f2db48f727ffe6..dc0e8b909e0367 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx @@ -1,12 +1,23 @@ 'use client' import type { FC } from 'react' import type { ResourceVarInputs } from '../types' -import type { CredentialFormSchema, FormOption, FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + CredentialFormSchema, + FormOption, + FormTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Event, Tool } from '@/app/components/tools/types' import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { useEffect, useMemo, useState } from 'react' import { CheckboxList } from '@/app/components/base/checkbox-list' import Input from '@/app/components/base/input' @@ -35,10 +46,7 @@ import { mapSelectItems, normalizeVariableSelectorValue, } from './form-input-item.helpers' -import { - JsonEditorField, - MultiSelectField, -} from './form-input-item.sections' +import { JsonEditorField, MultiSelectField } from './form-input-item.sections' import FormInputTypeSwitch from './form-input-type-switch' type Props = Readonly<{ @@ -57,7 +65,14 @@ type Props = Readonly<{ disableVariableInsertion?: boolean }> -type FormInputValue = string | number | boolean | string[] | Record<string, unknown> | null | undefined +type FormInputValue = + | string + | number + | boolean + | string[] + | Record<string, unknown> + | null + | undefined const FormInputItem: FC<Props> = ({ readOnly, @@ -77,12 +92,15 @@ const FormInputItem: FC<Props> = ({ const [toolsOptions, setToolsOptions] = useState<FormOption[] | null>(null) const [isLoadingToolsOptions, setIsLoadingToolsOptions] = useState(false) - const formState = getFormInputState(schema as CredentialFormSchema & { - _type?: FormTypeEnum - multiple?: boolean - options?: FormOption[] - scope?: string - }, value[schema.variable]) + const formState = getFormInputState( + schema as CredentialFormSchema & { + _type?: FormTypeEnum + multiple?: boolean + options?: FormOption[] + scope?: string + }, + value[schema.variable], + ) const { defaultValue, @@ -124,38 +142,48 @@ const FormInputItem: FC<Props> = ({ ) // Fetch dynamic options hook for triggers - const { data: triggerDynamicOptions, isLoading: isTriggerOptionsLoading } = useTriggerPluginDynamicOptions({ - plugin_id: currentProvider?.plugin_id || '', - provider: currentProvider?.name || '', - action: currentTool?.name || '', - parameter: variable || '', - extra: extraParams, - credential_id: currentProvider?.credential_id || '', - }, isDynamicSelect && providerType === PluginCategoryEnum.trigger && !!currentTool && !!currentProvider) + const { data: triggerDynamicOptions, isLoading: isTriggerOptionsLoading } = + useTriggerPluginDynamicOptions( + { + plugin_id: currentProvider?.plugin_id || '', + provider: currentProvider?.name || '', + action: currentTool?.name || '', + parameter: variable || '', + extra: extraParams, + credential_id: currentProvider?.credential_id || '', + }, + isDynamicSelect && + providerType === PluginCategoryEnum.trigger && + !!currentTool && + !!currentProvider, + ) // Computed values for dynamic options (unified for triggers and tools) const triggerOptions = triggerDynamicOptions?.options - const dynamicOptions = providerType === PluginCategoryEnum.trigger - ? triggerOptions ?? toolsOptions - : toolsOptions - const isLoadingOptions = providerType === PluginCategoryEnum.trigger - ? (isTriggerOptionsLoading || isLoadingToolsOptions) - : isLoadingToolsOptions + const dynamicOptions = + providerType === PluginCategoryEnum.trigger ? (triggerOptions ?? toolsOptions) : toolsOptions + const isLoadingOptions = + providerType === PluginCategoryEnum.trigger + ? isTriggerOptionsLoading || isLoadingToolsOptions + : isLoadingToolsOptions // Fetch dynamic options for tools only (triggers use hook directly) useEffect(() => { const fetchPanelDynamicOptions = async () => { - if (isDynamicSelect && currentTool && currentProvider && (providerType === PluginCategoryEnum.tool || providerType === PluginCategoryEnum.trigger)) { + if ( + isDynamicSelect && + currentTool && + currentProvider && + (providerType === PluginCategoryEnum.tool || providerType === PluginCategoryEnum.trigger) + ) { setIsLoadingToolsOptions(true) try { const data = await fetchDynamicOptions() setToolsOptions(data?.options || []) - } - catch (error) { + } catch (error) { console.error('Failed to fetch dynamic options:', error) setToolsOptions([]) - } - finally { + } finally { setIsLoadingToolsOptions(false) } } @@ -182,8 +210,7 @@ const FormInputItem: FC<Props> = ({ value: '', }, }) - } - else { + } else { onChange({ ...value, [variable]: { @@ -243,10 +270,7 @@ const FormInputItem: FC<Props> = ({ [availableCheckboxOptions, defaultValue, varInput?.value], ) - const visibleSelectOptions = useMemo( - () => filterVisibleOptions(options, value), - [options, value], - ) + const visibleSelectOptions = useMemo(() => filterVisibleOptions(options, value), [options, value]) const visibleDynamicOptions = useMemo( () => filterVisibleOptions(dynamicOptions || options || [], value), [dynamicOptions, options, value], @@ -260,7 +284,12 @@ const FormInputItem: FC<Props> = ({ [language, visibleDynamicOptions], ) const selectedLabels = useMemo( - () => getSelectedLabels(varInput?.value as string[] | undefined, isDynamicSelect ? visibleDynamicOptions : visibleSelectOptions, language), + () => + getSelectedLabels( + varInput?.value as string[] | undefined, + isDynamicSelect ? visibleDynamicOptions : visibleSelectOptions, + language, + ), [isDynamicSelect, language, varInput?.value, visibleDynamicOptions, visibleSelectOptions], ) @@ -274,18 +303,24 @@ const FormInputItem: FC<Props> = ({ }, }) } - const selectedStaticOption = staticSelectItems.find(item => item.value === (varInput?.value as string | undefined)) ?? null - const selectedDynamicOption = dynamicSelectItems.find(item => item.value === (varInput?.value as string | undefined)) ?? null + const selectedStaticOption = + staticSelectItems.find((item) => item.value === (varInput?.value as string | undefined)) ?? null + const selectedDynamicOption = + dynamicSelectItems.find((item) => item.value === (varInput?.value as string | undefined)) ?? + null return ( <div className={cn('gap-1', !(isShowJSONEditor && isConstant) && 'flex')}> {showTypeSwitch && ( - <FormInputTypeSwitch value={varInput?.type || VarKindType.constant} onChange={handleTypeChange} /> + <FormInputTypeSwitch + value={varInput?.type || VarKindType.constant} + onChange={handleTypeChange} + /> )} {isString && ( <MixedVariableTextInput readOnly={readOnly} - value={varInput?.value as string || ''} + value={(varInput?.value as string) || ''} onChange={handleValueChange} nodesOutputVars={availableVars} availableNodes={availableNodesWithParent} @@ -299,7 +334,7 @@ const FormInputItem: FC<Props> = ({ className="h-8 grow" type="number" value={getNumberInputValue(varInput?.value)} - onChange={e => handleValueChange(e.target.value)} + onChange={(e) => handleValueChange(e.target.value)} placeholder={placeholder?.[language] || placeholder?.en_US} /> )} @@ -315,26 +350,21 @@ const FormInputItem: FC<Props> = ({ /> )} {isBoolean && isConstant && ( - <FormInputBoolean - value={varInput?.value as boolean} - onChange={handleValueChange} - /> + <FormInputBoolean value={varInput?.value as boolean} onChange={handleValueChange} /> )} {isSelect && isConstant && !isMultipleSelect && ( <Select value={selectedStaticOption?.value ?? null} disabled={readOnly} - onValueChange={value => value && handleValueChange(value)} + onValueChange={(value) => value && handleValueChange(value)} > <SelectTrigger className="h-8 grow"> {selectedStaticOption?.name ?? placeholder?.[language] ?? placeholder?.en_US} </SelectTrigger> <SelectContent> - {staticSelectItems.map(item => ( + {staticSelectItems.map((item) => ( <SelectItem key={item.value} value={item.value}> - {item.icon && ( - <img src={item.icon} alt="" className="mr-2 size-4 shrink-0" /> - )} + {item.icon && <img src={item.icon} alt="" className="mr-2 size-4 shrink-0" />} <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> </SelectItem> @@ -356,17 +386,16 @@ const FormInputItem: FC<Props> = ({ <Select value={selectedDynamicOption?.value ?? null} disabled={readOnly || isLoadingOptions} - onValueChange={value => value && handleValueChange(value)} + onValueChange={(value) => value && handleValueChange(value)} > <SelectTrigger className="h-8 grow"> - {selectedDynamicOption?.name ?? (isLoadingOptions ? 'Loading...' : (placeholder?.[language] ?? placeholder?.en_US))} + {selectedDynamicOption?.name ?? + (isLoadingOptions ? 'Loading...' : (placeholder?.[language] ?? placeholder?.en_US))} </SelectTrigger> <SelectContent> - {dynamicSelectItems.map(item => ( + {dynamicSelectItems.map((item) => ( <SelectItem key={item.value} value={item.value}> - {item.icon && ( - <img src={item.icon} alt="" className="mr-2 size-4 shrink-0" /> - )} + {item.icon && <img src={item.icon} alt="" className="mr-2 size-4 shrink-0" />} <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> </SelectItem> @@ -389,7 +418,9 @@ const FormInputItem: FC<Props> = ({ <JsonEditorField value={(varInput?.value as string) || ''} onChange={handleValueChange} - placeholder={<div className="whitespace-pre">{placeholder?.[language] || placeholder?.en_US}</div>} + placeholder={ + <div className="whitespace-pre">{placeholder?.[language] || placeholder?.en_US}</div> + } /> )} {isAppSelector && ( @@ -418,7 +449,7 @@ const FormInputItem: FC<Props> = ({ isShowNodeName nodeId={nodeId} value={varInput?.value || []} - onChange={value => handleVariableSelectorChange(value, variable)} + onChange={(value) => handleVariableSelectorChange(value, variable)} filterVar={getFilterVar(formState)} schema={schema} valueTypePlaceHolder={getTargetVarType(formState)} diff --git a/web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx b/web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx index 3379cf93611dce..da5eb254a39a83 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx @@ -10,72 +10,65 @@ type Props = Readonly<{ onChange: (value: VarType) => void }> -const FormInputTypeSwitch: FC<Props> = ({ - value, - onChange, -}) => { +const FormInputTypeSwitch: FC<Props> = ({ value, onChange }) => { const { t } = useTranslation() - const variableLabel = t($ => $['nodes.common.typeSwitch.variable'], { ns: 'workflow' }) - const inputLabel = t($ => $['nodes.common.typeSwitch.input'], { ns: 'workflow' }) + const variableLabel = t(($) => $['nodes.common.typeSwitch.variable'], { ns: 'workflow' }) + const inputLabel = t(($) => $['nodes.common.typeSwitch.input'], { ns: 'workflow' }) return ( <div className="inline-flex h-8 shrink-0 gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5"> - {value === VarType.variable - ? ( - <button - type="button" - aria-label={variableLabel} - className="cursor-pointer rounded-lg bg-components-segmented-control-item-active-bg px-2.5 py-1.5 text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg" - onClick={() => onChange(VarType.variable)} - > - <Variable02 className="size-4" /> - </button> - ) - : ( - <Tooltip> - <TooltipTrigger - render={( - <button - type="button" - aria-label={variableLabel} - className="cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover" - onClick={() => onChange(VarType.variable)} - > - <Variable02 className="size-4" /> - </button> - )} - /> - <TooltipContent>{variableLabel}</TooltipContent> - </Tooltip> - )} - {value === VarType.constant - ? ( - <button - type="button" - aria-label={inputLabel} - className="cursor-pointer rounded-lg bg-components-segmented-control-item-active-bg px-2.5 py-1.5 text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg" - onClick={() => onChange(VarType.constant)} - > - <span aria-hidden className="i-ri-edit-line size-4" /> - </button> - ) - : ( - <Tooltip> - <TooltipTrigger - render={( - <button - type="button" - aria-label={inputLabel} - className="cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover" - onClick={() => onChange(VarType.constant)} - > - <span aria-hidden className="i-ri-edit-line size-4" /> - </button> - )} - /> - <TooltipContent>{inputLabel}</TooltipContent> - </Tooltip> - )} + {value === VarType.variable ? ( + <button + type="button" + aria-label={variableLabel} + className="cursor-pointer rounded-lg bg-components-segmented-control-item-active-bg px-2.5 py-1.5 text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg" + onClick={() => onChange(VarType.variable)} + > + <Variable02 className="size-4" /> + </button> + ) : ( + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + aria-label={variableLabel} + className="cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover" + onClick={() => onChange(VarType.variable)} + > + <Variable02 className="size-4" /> + </button> + } + /> + <TooltipContent>{variableLabel}</TooltipContent> + </Tooltip> + )} + {value === VarType.constant ? ( + <button + type="button" + aria-label={inputLabel} + className="cursor-pointer rounded-lg bg-components-segmented-control-item-active-bg px-2.5 py-1.5 text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg" + onClick={() => onChange(VarType.constant)} + > + <span aria-hidden className="i-ri-edit-line size-4" /> + </button> + ) : ( + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + aria-label={inputLabel} + className="cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover" + onClick={() => onChange(VarType.constant)} + > + <span aria-hidden className="i-ri-edit-line size-4" /> + </button> + } + /> + <TooltipContent>{inputLabel}</TooltipContent> + </Tooltip> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/group.tsx b/web/app/components/workflow/nodes/_base/components/group.tsx index 9db006561f6eaa..5cf2d49f11defa 100644 --- a/web/app/components/workflow/nodes/_base/components/group.tsx +++ b/web/app/components/workflow/nodes/_base/components/group.tsx @@ -21,9 +21,7 @@ export const Group: FC<GroupProps> = (props) => { return ( <div className={cn('py-1')}> {label} - <div className="space-y-0.5"> - {children} - </div> + <div className="space-y-0.5">{children}</div> </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/help-link.tsx b/web/app/components/workflow/nodes/_base/components/help-link.tsx index ec73c77a7d6e12..55c8b45e628d96 100644 --- a/web/app/components/workflow/nodes/_base/components/help-link.tsx +++ b/web/app/components/workflow/nodes/_base/components/help-link.tsx @@ -7,21 +7,18 @@ import { useNodeHelpLink } from '../hooks/use-node-help-link' type HelpLinkProps = { nodeType: BlockEnum } -const HelpLink = ({ - nodeType, -}: HelpLinkProps) => { +const HelpLink = ({ nodeType }: HelpLinkProps) => { const { t } = useTranslation() const link = useNodeHelpLink(nodeType) - if (!link) - return null + if (!link) return null - const label = t($ => $['userProfile.helpCenter'], { ns: 'common' }) + const label = t(($) => $['userProfile.helpCenter'], { ns: 'common' }) return ( <Tooltip> <TooltipTrigger - render={( + render={ <a aria-label={label} href={link} @@ -31,7 +28,7 @@ const HelpLink = ({ > <span aria-hidden className="i-ri-book-open-line size-4 text-gray-500" /> </a> - )} + } /> <TooltipContent>{label}</TooltipContent> </Tooltip> diff --git a/web/app/components/workflow/nodes/_base/components/info-panel.tsx b/web/app/components/workflow/nodes/_base/components/info-panel.tsx index bb125062e6f6c9..92fa859028adde 100644 --- a/web/app/components/workflow/nodes/_base/components/info-panel.tsx +++ b/web/app/components/workflow/nodes/_base/components/info-panel.tsx @@ -7,19 +7,12 @@ type Props = Readonly<{ content: ReactNode }> -const InfoPanel: FC<Props> = ({ - title, - content, -}) => { +const InfoPanel: FC<Props> = ({ title, content }) => { return ( <div> <div className="flex flex-col gap-y-0.5 rounded-md bg-workflow-block-parma-bg px-[5px] py-[3px]"> - <div className="system-2xs-semibold-uppercase text-text-secondary uppercase"> - {title} - </div> - <div className="system-xs-regular wrap-break-word text-text-tertiary"> - {content} - </div> + <div className="system-2xs-semibold-uppercase text-text-secondary uppercase">{title}</div> + <div className="system-xs-regular wrap-break-word text-text-tertiary">{content}</div> </div> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/input-number-with-slider.tsx b/web/app/components/workflow/nodes/_base/components/input-number-with-slider.tsx index d2eaecd3f37f14..7fcb65b3096a47 100644 --- a/web/app/components/workflow/nodes/_base/components/input-number-with-slider.tsx +++ b/web/app/components/workflow/nodes/_base/components/input-number-with-slider.tsx @@ -32,13 +32,15 @@ function InputNumberWithSlider({ onChange(max) return } - if (min !== undefined && value < min) - onChange(min) + if (min !== undefined && value < min) onChange(min) }, [defaultValue, max, min, onChange, value]) - const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { - onChange(Number.parseFloat(e.target.value)) - }, [onChange]) + const handleChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + onChange(Number.parseFloat(e.target.value)) + }, + [onChange], + ) return ( <Fieldset> diff --git a/web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx b/web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx index 8d35fbf8b0ddc5..7e93eb0dd3321f 100644 --- a/web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx +++ b/web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx @@ -1,9 +1,6 @@ 'use client' import type { FC } from 'react' -import type { - Node, - NodeOutPutVar, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useBoolean } from 'ahooks' @@ -48,17 +45,14 @@ const Editor: FC<Props> = ({ }) => { const { t } = useTranslation() - const [isFocus, { - setTrue: setFocus, - setFalse: setBlur, - }] = useBoolean(false) + const [isFocus, { setTrue: setFocus, setFalse: setBlur }] = useBoolean(false) useEffect(() => { onFocusChange?.(isFocus) }, [isFocus]) - const pipelineId = useStore(s => s.pipelineId) - const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel) + const pipelineId = useStore((s) => s.pipelineId) + const setShowInputFieldPanel = useStore((s) => s.setShowInputFieldPanel) return ( <div className={cn(className, 'relative min-h-8')}> @@ -101,7 +95,7 @@ const Editor: FC<Props> = ({ } if (node.data.type === BlockEnum.Start) { acc.sys = { - title: t($ => $['blocks.start'], { ns: 'workflow' }), + title: t(($) => $['blocks.start'], { ns: 'workflow' }), type: BlockEnum.Start, } } @@ -118,17 +112,22 @@ const Editor: FC<Props> = ({ {/* to patch Editor not support dynamic change editable status */} {readOnly && <div className="absolute inset-0 z-10"></div>} {isFocus && ( - <div className={cn('absolute z-10', insertVarTipToLeft ? 'top-1.5 left-[-12px]' : 'top-[-9px] right-1')}> + <div + className={cn( + 'absolute z-10', + insertVarTipToLeft ? 'top-1.5 left-[-12px]' : 'top-[-9px] right-1', + )} + > <Tooltip> <TooltipTrigger - render={( + render={ <div className="cursor-pointer rounded-[5px] border-[0.5px] border-divider-regular bg-components-badge-white-to-dark p-0.5 shadow-lg"> <Variable02 className="size-3.5 text-components-button-secondary-accent-text" /> </div> - )} + } /> <TooltipContent> - {`${t($ => $['common.insertVarTip'], { ns: 'workflow' })}`} + {`${t(($) => $['common.insertVarTip'], { ns: 'workflow' })}`} </TooltipContent> </Tooltip> </div> diff --git a/web/app/components/workflow/nodes/_base/components/input-var-type-icon.tsx b/web/app/components/workflow/nodes/_base/components/input-var-type-icon.tsx index 7d0cf9eaf221a1..47579b13a28b03 100644 --- a/web/app/components/workflow/nodes/_base/components/input-var-type-icon.tsx +++ b/web/app/components/workflow/nodes/_base/components/input-var-type-icon.tsx @@ -19,25 +19,24 @@ type Props = Readonly<{ }> const getIcon = (type: InputVarType) => { - return ({ - [InputVarType.textInput]: RiTextSnippet, - [InputVarType.paragraph]: RiAlignLeft, - [InputVarType.select]: RiCheckboxMultipleLine, - [InputVarType.number]: RiHashtag, - [InputVarType.checkbox]: RiCheckboxLine, - [InputVarType.jsonObject]: RiBracesLine, - [InputVarType.singleFile]: RiFileList2Line, - [InputVarType.multiFiles]: RiFileCopy2Line, - } as any)[type] || RiTextSnippet + return ( + ( + { + [InputVarType.textInput]: RiTextSnippet, + [InputVarType.paragraph]: RiAlignLeft, + [InputVarType.select]: RiCheckboxMultipleLine, + [InputVarType.number]: RiHashtag, + [InputVarType.checkbox]: RiCheckboxLine, + [InputVarType.jsonObject]: RiBracesLine, + [InputVarType.singleFile]: RiFileList2Line, + [InputVarType.multiFiles]: RiFileCopy2Line, + } as any + )[type] || RiTextSnippet + ) } -const InputVarTypeIcon: FC<Props> = ({ - className, - type, -}) => { +const InputVarTypeIcon: FC<Props> = ({ className, type }) => { const Icon = getIcon(type) - return ( - <Icon className={className} /> - ) + return <Icon className={className} /> } export default React.memo(InputVarTypeIcon) diff --git a/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx b/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx index dc257245cd725d..cc47fd91196dc1 100644 --- a/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx @@ -15,18 +15,14 @@ type InstallPluginButtonProps = Omit<ComponentProps<typeof Button>, 'children' | } export const InstallPluginButton = (props: InstallPluginButtonProps) => { - const { - className, - uniqueIdentifier, - extraIdentifiers = [], - onSuccess, - ...rest - } = props + const { className, uniqueIdentifier, extraIdentifiers = [], onSuccess, ...rest } = props const { t } = useTranslation() const { canInstallPlugin } = useWorkspacePluginInstallPermission() - const identifiers = Array.from(new Set( - [uniqueIdentifier, ...extraIdentifiers].filter((item): item is string => Boolean(item)), - )) + const identifiers = Array.from( + new Set( + [uniqueIdentifier, ...extraIdentifiers].filter((item): item is string => Boolean(item)), + ), + ) const manifest = useCheckInstalled({ pluginIds: identifiers, enabled: identifiers.length > 0, @@ -36,8 +32,7 @@ export const InstallPluginButton = (props: InstallPluginButtonProps) => { const isLoading = manifest.isLoading || install.isPending || isTracking const handleInstall: MouseEventHandler = (e) => { e.stopPropagation() - if (isLoading) - return + if (isLoading) return setIsTracking(true) install.mutate(uniqueIdentifier, { onSuccess: async (response) => { @@ -72,8 +67,7 @@ export const InstallPluginButton = (props: InstallPluginButtonProps) => { } await finish() - } - catch { + } catch { setIsTracking(false) install.reset() } @@ -84,16 +78,15 @@ export const InstallPluginButton = (props: InstallPluginButtonProps) => { }, }) } - if (!canInstallPlugin || !manifest.data) - return null + if (!canInstallPlugin || !manifest.data) return null const identifierSet = new Set(identifiers) - const isInstalled = manifest.data.plugins.some(plugin => ( - identifierSet.has(plugin.id) - || (plugin.plugin_unique_identifier && identifierSet.has(plugin.plugin_unique_identifier)) - || (plugin.plugin_id && identifierSet.has(plugin.plugin_id)) - )) - if (isInstalled) - return null + const isInstalled = manifest.data.plugins.some( + (plugin) => + identifierSet.has(plugin.id) || + (plugin.plugin_unique_identifier && identifierSet.has(plugin.plugin_unique_identifier)) || + (plugin.plugin_id && identifierSet.has(plugin.plugin_id)), + ) + if (isInstalled) return null return ( <Button variant="secondary" @@ -102,8 +95,14 @@ export const InstallPluginButton = (props: InstallPluginButtonProps) => { onClick={handleInstall} className={cn('flex items-center', className)} > - {!isLoading ? t($ => $['nodes.agent.pluginInstaller.install'], { ns: 'workflow' }) : t($ => $['nodes.agent.pluginInstaller.installing'], { ns: 'workflow' })} - {!isLoading ? <span className="ml-1 i-ri-install-line size-3.5" /> : <span className="ml-1 i-ri-loader-2-line size-3.5 animate-spin" />} + {!isLoading + ? t(($) => $['nodes.agent.pluginInstaller.install'], { ns: 'workflow' }) + : t(($) => $['nodes.agent.pluginInstaller.installing'], { ns: 'workflow' })} + {!isLoading ? ( + <span className="ml-1 i-ri-install-line size-3.5" /> + ) : ( + <span className="ml-1 i-ri-loader-2-line size-3.5 animate-spin" /> + )} </Button> ) } diff --git a/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx b/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx index 371a68fe4903e5..b448fa85b95ede 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx @@ -22,13 +22,7 @@ describe('FieldTitle', () => { it('should toggle local collapsed state and notify onCollapse when enabled', () => { const onCollapse = vi.fn() - const { container } = render( - <FieldTitle - title="Models" - showArrow - onCollapse={onCollapse} - />, - ) + const { container } = render(<FieldTitle title="Models" showArrow onCollapse={onCollapse} />) const header = screen.getByText('Models').closest('.group\\/collapse') const arrow = container.querySelector('[aria-hidden="true"]') diff --git a/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx index 84b4892e1909ec..502557cd56b7b5 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx @@ -12,8 +12,12 @@ describe('layout index', () => { it('should render Box and Group with optional border styles', () => { render( <div> - <Box withBorderBottom className="box-test">Box content</Box> - <Group withBorderBottom className="group-test">Group content</Group> + <Box withBorderBottom className="box-test"> + Box content + </Box> + <Group withBorderBottom className="group-test"> + Group content + </Group> </div>, ) @@ -22,9 +26,7 @@ describe('layout index', () => { }) it('should render BoxGroup with nested children', () => { - render( - <BoxGroup>Inside box group</BoxGroup>, - ) + render(<BoxGroup>Inside box group</BoxGroup>) expect(screen.getByText('Inside box group')).toBeInTheDocument() }) @@ -49,10 +51,7 @@ describe('layout index', () => { it('should collapse and expand Field children when supportCollapse is enabled', async () => { const user = userEvent.setup() render( - <Field - supportCollapse - fieldTitleProps={{ title: 'Advanced' }} - > + <Field supportCollapse fieldTitleProps={{ title: 'Advanced' }}> <div>Extra details</div> </Field>, ) diff --git a/web/app/components/workflow/nodes/_base/components/layout/box-group-field.tsx b/web/app/components/workflow/nodes/_base/components/layout/box-group-field.tsx index a6df026a5bbd83..80c32c35674fe3 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/box-group-field.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/box-group-field.tsx @@ -1,29 +1,17 @@ import type { ReactNode } from 'react' -import type { - BoxGroupProps, - FieldProps, -} from '.' +import type { BoxGroupProps, FieldProps } from '.' import { memo } from 'react' -import { - BoxGroup, - Field, -} from '.' +import { BoxGroup, Field } from '.' type BoxGroupFieldProps = { children?: ReactNode boxGroupProps?: Omit<BoxGroupProps, 'children'> fieldProps?: Omit<FieldProps, 'children'> } -export const BoxGroupField = memo(({ - children, - fieldProps, - boxGroupProps, -}: BoxGroupFieldProps) => { +export const BoxGroupField = memo(({ children, fieldProps, boxGroupProps }: BoxGroupFieldProps) => { return ( <BoxGroup {...boxGroupProps}> - <Field {...fieldProps}> - {children} - </Field> + <Field {...fieldProps}>{children}</Field> </BoxGroup> ) }) diff --git a/web/app/components/workflow/nodes/_base/components/layout/box-group.tsx b/web/app/components/workflow/nodes/_base/components/layout/box-group.tsx index edf0c116ecbd03..dfd7236d454736 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/box-group.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/box-group.tsx @@ -1,29 +1,17 @@ import type { ReactNode } from 'react' -import type { - BoxProps, - GroupProps, -} from '.' +import type { BoxProps, GroupProps } from '.' import { memo } from 'react' -import { - Box, - Group, -} from '.' +import { Box, Group } from '.' export type BoxGroupProps = { children?: ReactNode boxProps?: Omit<BoxProps, 'children'> groupProps?: Omit<GroupProps, 'children'> } -export const BoxGroup = memo(({ - children, - boxProps, - groupProps, -}: BoxGroupProps) => { +export const BoxGroup = memo(({ children, boxProps, groupProps }: BoxGroupProps) => { return ( <Box {...boxProps}> - <Group {...groupProps}> - {children} - </Group> + <Group {...groupProps}>{children}</Group> </Box> ) }) diff --git a/web/app/components/workflow/nodes/_base/components/layout/box.tsx b/web/app/components/workflow/nodes/_base/components/layout/box.tsx index 467e40a1f7d872..b7ee6b89c4e2ea 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/box.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/box.tsx @@ -7,19 +7,9 @@ export type BoxProps = { children?: ReactNode withBorderBottom?: boolean } -export const Box = memo(({ - className, - children, - withBorderBottom, -}: BoxProps) => { +export const Box = memo(({ className, children, withBorderBottom }: BoxProps) => { return ( - <div - className={cn( - 'py-2', - withBorderBottom && 'border-b border-divider-subtle', - className, - )} - > + <div className={cn('py-2', withBorderBottom && 'border-b border-divider-subtle', className)}> {children} </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/layout/field-title.tsx b/web/app/components/workflow/nodes/_base/components/layout/field-title.tsx index 5c5874f62b8cf1..8555c952c980d9 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/field-title.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/field-title.tsx @@ -1,9 +1,6 @@ import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useState, -} from 'react' +import { memo, useState } from 'react' import { Infotip } from '@/app/components/base/infotip' export type FieldTitleProps = { @@ -17,40 +14,40 @@ export type FieldTitleProps = { collapsed?: boolean onCollapse?: (collapsed: boolean) => void } -export const FieldTitle = memo(({ - title, - operation, - subTitle, - tooltip, - warningDot, - showArrow, - disabled, - collapsed, - onCollapse, -}: FieldTitleProps) => { - const [collapsedLocal, setCollapsedLocal] = useState(true) - const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal +export const FieldTitle = memo( + ({ + title, + operation, + subTitle, + tooltip, + warningDot, + showArrow, + disabled, + collapsed, + onCollapse, + }: FieldTitleProps) => { + const [collapsedLocal, setCollapsedLocal] = useState(true) + const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal - return ( - <div className={cn('mb-0.5', !!subTitle && 'mb-1')}> - <div - className="group/collapse flex items-center justify-between py-1" - onClick={() => { - if (!disabled) { - setCollapsedLocal(!collapsedMerged) - onCollapse?.(!collapsedMerged) - } - }} - > - <div className="flex items-center system-sm-semibold-uppercase text-text-secondary"> - <span className="relative"> - {warningDot && ( - <span className="absolute top-1/2 left-[-9px] size-[5px] -translate-y-1/2 rounded-full bg-text-warning-secondary" /> - )} - {title} - </span> - { - showArrow && ( + return ( + <div className={cn('mb-0.5', !!subTitle && 'mb-1')}> + <div + className="group/collapse flex items-center justify-between py-1" + onClick={() => { + if (!disabled) { + setCollapsedLocal(!collapsedMerged) + onCollapse?.(!collapsedMerged) + } + }} + > + <div className="flex items-center system-sm-semibold-uppercase text-text-secondary"> + <span className="relative"> + {warningDot && ( + <span className="absolute top-1/2 left-[-9px] size-[5px] -translate-y-1/2 rounded-full bg-text-warning-secondary" /> + )} + {title} + </span> + {showArrow && ( <span aria-hidden className={cn( @@ -58,21 +55,17 @@ export const FieldTitle = memo(({ collapsedMerged && 'rotate-270', )} /> - ) - } - { - tooltip && ( + )} + {tooltip && ( <Infotip aria-label={tooltip} className="ml-1"> {tooltip} </Infotip> - ) - } + )} + </div> + {operation} </div> - {operation} + {subTitle} </div> - { - subTitle - } - </div> - ) -}) + ) + }, +) diff --git a/web/app/components/workflow/nodes/_base/components/layout/field.tsx b/web/app/components/workflow/nodes/_base/components/layout/field.tsx index 70f35e0ba781d7..e84094081219e9 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/field.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/field.tsx @@ -1,9 +1,6 @@ import type { ReactNode } from 'react' import type { FieldTitleProps } from '.' -import { - memo, - useState, -} from 'react' +import { memo, useState } from 'react' import { FieldTitle } from '.' export type FieldProps = { @@ -12,25 +9,22 @@ export type FieldProps = { disabled?: boolean supportCollapse?: boolean } -export const Field = memo(({ - fieldTitleProps, - children, - supportCollapse, - disabled, -}: FieldProps) => { - const [collapsed, setCollapsed] = useState(false) +export const Field = memo( + ({ fieldTitleProps, children, supportCollapse, disabled }: FieldProps) => { + const [collapsed, setCollapsed] = useState(false) - return ( - <div> - <FieldTitle - {...fieldTitleProps} - collapsed={collapsed} - onCollapse={setCollapsed} - showArrow={supportCollapse} - disabled={disabled} - /> - {supportCollapse && !collapsed && children} - {!supportCollapse && children} - </div> - ) -}) + return ( + <div> + <FieldTitle + {...fieldTitleProps} + collapsed={collapsed} + onCollapse={setCollapsed} + showArrow={supportCollapse} + disabled={disabled} + /> + {supportCollapse && !collapsed && children} + {!supportCollapse && children} + </div> + ) + }, +) diff --git a/web/app/components/workflow/nodes/_base/components/layout/group.tsx b/web/app/components/workflow/nodes/_base/components/layout/group.tsx index 16b8c5072c8bca..09ad1dbf88fa29 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/group.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/group.tsx @@ -7,18 +7,10 @@ export type GroupProps = { children?: ReactNode withBorderBottom?: boolean } -export const Group = memo(({ - className, - children, - withBorderBottom, -}: GroupProps) => { +export const Group = memo(({ className, children, withBorderBottom }: GroupProps) => { return ( <div - className={cn( - 'px-4 py-2', - withBorderBottom && 'border-b border-divider-subtle', - className, - )} + className={cn('px-4 py-2', withBorderBottom && 'border-b border-divider-subtle', className)} > {children} </div> diff --git a/web/app/components/workflow/nodes/_base/components/list-no-data-placeholder.tsx b/web/app/components/workflow/nodes/_base/components/list-no-data-placeholder.tsx index e52659808ad703..baa72d2765c6d9 100644 --- a/web/app/components/workflow/nodes/_base/components/list-no-data-placeholder.tsx +++ b/web/app/components/workflow/nodes/_base/components/list-no-data-placeholder.tsx @@ -6,9 +6,7 @@ type Props = Readonly<{ children: React.ReactNode }> -const ListNoDataPlaceholder: FC<Props> = ({ - children, -}) => { +const ListNoDataPlaceholder: FC<Props> = ({ children }) => { return ( <div className="flex min-h-[42px] w-full items-center justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary"> {children} diff --git a/web/app/components/workflow/nodes/_base/components/mcp-tool-availability.tsx b/web/app/components/workflow/nodes/_base/components/mcp-tool-availability.tsx index c09c31032d08e5..8a486699989d85 100644 --- a/web/app/components/workflow/nodes/_base/components/mcp-tool-availability.tsx +++ b/web/app/components/workflow/nodes/_base/components/mcp-tool-availability.tsx @@ -6,7 +6,9 @@ type MCPToolAvailabilityContextValue = { versionSupported?: boolean } -const MCPToolAvailabilityContext = createContext<MCPToolAvailabilityContextValue | undefined>(undefined) +const MCPToolAvailabilityContext = createContext<MCPToolAvailabilityContextValue | undefined>( + undefined, +) type MCPToolAvailability = { allowed: boolean @@ -27,8 +29,7 @@ export const MCPToolAvailabilityProvider = ({ export const useMCPToolAvailability = (): MCPToolAvailability => { const context = use(MCPToolAvailabilityContext) - if (context === undefined) - return { allowed: true } + if (context === undefined) return { allowed: true } const { versionSupported } = context return { diff --git a/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx b/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx index 90340aeb3a5984..065af0bd0403f5 100644 --- a/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx +++ b/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx @@ -7,11 +7,15 @@ import { useTranslation } from 'react-i18next' const McpToolNotSupportTooltip: FC = () => { const { t } = useTranslation() - const tip = t($ => $['detailPanel.toolSelector.unsupportedMCPTool'], { ns: 'plugin' }) + const tip = t(($) => $['detailPanel.toolSelector.unsupportedMCPTool'], { ns: 'plugin' }) return ( <Popover> - <PopoverTrigger openOnHover aria-label={tip} className="inline-flex border-0 bg-transparent p-0"> + <PopoverTrigger + openOnHover + aria-label={tip} + className="inline-flex border-0 bg-transparent p-0" + > <RiAlertFill className="size-4 text-text-warning-secondary" /> </PopoverTrigger> <PopoverContent popupClassName="w-[256px] px-3 py-2 system-xs-regular text-text-tertiary"> diff --git a/web/app/components/workflow/nodes/_base/components/memory-config.tsx b/web/app/components/workflow/nodes/_base/components/memory-config.tsx index 224b8575fd717b..eda5e45cb4d08f 100644 --- a/web/app/components/workflow/nodes/_base/components/memory-config.tsx +++ b/web/app/components/workflow/nodes/_base/components/memory-config.tsx @@ -23,15 +23,13 @@ type RoleItemProps = { value: string onChange: (value: string) => void } -const RoleItem: FC<RoleItemProps> = ({ - readonly, - title, - value, - onChange, -}) => { - const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { - onChange(e.target.value) - }, [onChange]) +const RoleItem: FC<RoleItemProps> = ({ readonly, title, value, onChange }) => { + const handleChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + onChange(e.target.value) + }, + [onChange], + ) return ( <div className="flex items-center justify-between"> <div className="text-[13px] font-normal text-text-secondary">{title}</div> @@ -70,82 +68,87 @@ const MemoryConfig: FC<Props> = ({ }) => { const { t } = useTranslation() const payload = config.data - const windowSizeLabel = t($ => $[`${i18nPrefix}.windowSize`], { ns: 'workflow' }) - const handleMemoryEnabledChange = useCallback((enabled: boolean) => { - onChange(enabled ? defaultMemory : undefined) - }, [defaultMemory, onChange]) - const handleWindowEnabledChange = useCallback((enabled: boolean) => { - const newPayload = produce(config.data || defaultMemory, (draft) => { - if (!draft.window) - draft.window = { enabled: false, size: WINDOW_SIZE_DEFAULT } + const windowSizeLabel = t(($) => $[`${i18nPrefix}.windowSize`], { ns: 'workflow' }) + const handleMemoryEnabledChange = useCallback( + (enabled: boolean) => { + onChange(enabled ? defaultMemory : undefined) + }, + [defaultMemory, onChange], + ) + const handleWindowEnabledChange = useCallback( + (enabled: boolean) => { + const newPayload = produce(config.data || defaultMemory, (draft) => { + if (!draft.window) draft.window = { enabled: false, size: WINDOW_SIZE_DEFAULT } - draft.window.enabled = enabled - }) + draft.window.enabled = enabled + }) - onChange(newPayload) - }, [config, defaultMemory, onChange]) + onChange(newPayload) + }, + [config, defaultMemory, onChange], + ) - const handleWindowSizeChange = useCallback((size: number | string) => { - const newPayload = produce(payload || defaultMemory, (draft) => { - if (!draft.window) - draft.window = { enabled: true, size: WINDOW_SIZE_DEFAULT } - let limitedSize: null | string | number = size - if (limitedSize === '') { - limitedSize = null - } - else { - limitedSize = Number.parseInt(limitedSize as string, 10) - if (isNaN(limitedSize)) - limitedSize = WINDOW_SIZE_DEFAULT + const handleWindowSizeChange = useCallback( + (size: number | string) => { + const newPayload = produce(payload || defaultMemory, (draft) => { + if (!draft.window) draft.window = { enabled: true, size: WINDOW_SIZE_DEFAULT } + let limitedSize: null | string | number = size + if (limitedSize === '') { + limitedSize = null + } else { + limitedSize = Number.parseInt(limitedSize as string, 10) + if (isNaN(limitedSize)) limitedSize = WINDOW_SIZE_DEFAULT - if (limitedSize < WINDOW_SIZE_MIN) - limitedSize = WINDOW_SIZE_MIN + if (limitedSize < WINDOW_SIZE_MIN) limitedSize = WINDOW_SIZE_MIN - if (limitedSize > WINDOW_SIZE_MAX) - limitedSize = WINDOW_SIZE_MAX - } + if (limitedSize > WINDOW_SIZE_MAX) limitedSize = WINDOW_SIZE_MAX + } - draft.window.size = limitedSize as number - }) - onChange(newPayload) - }, [payload, defaultMemory, onChange]) + draft.window.size = limitedSize as number + }) + onChange(newPayload) + }, + [payload, defaultMemory, onChange], + ) const handleBlur = useCallback(() => { const payload = config.data - if (!payload) - return + if (!payload) return if (payload.window.size === '' || payload.window.size === null) handleWindowSizeChange(WINDOW_SIZE_DEFAULT) }, [handleWindowSizeChange, config]) - const handleRolePrefixChange = useCallback((role: MemoryRole) => { - return (value: string) => { - const newPayload = produce(config.data || defaultMemory, (draft) => { - if (!draft.role_prefix) { - draft.role_prefix = { - user: '', - assistant: '', + const handleRolePrefixChange = useCallback( + (role: MemoryRole) => { + return (value: string) => { + const newPayload = produce(config.data || defaultMemory, (draft) => { + if (!draft.role_prefix) { + draft.role_prefix = { + user: '', + assistant: '', + } } - } - draft.role_prefix[role] = value - }) - onChange(newPayload) - } - }, [config, defaultMemory, onChange]) + draft.role_prefix[role] = value + }) + onChange(newPayload) + } + }, + [config, defaultMemory, onChange], + ) return ( <div className={cn(className)}> <Field - title={t($ => $[`${i18nPrefix}.memory`], { ns: 'workflow' })} - tooltip={t($ => $[`${i18nPrefix}.memoryTip`], { ns: 'workflow' })!} - operations={( + title={t(($) => $[`${i18nPrefix}.memory`], { ns: 'workflow' })} + tooltip={t(($) => $[`${i18nPrefix}.memoryTip`], { ns: 'workflow' })!} + operations={ <Switch checked={!!payload} onCheckedChange={handleMemoryEnabledChange} size="md" disabled={readonly} /> - )} + } > {payload && ( <> @@ -158,7 +161,9 @@ const MemoryConfig: FC<Props> = ({ size="md" disabled={readonly} /> - <div className="system-xs-medium-uppercase text-text-tertiary">{windowSizeLabel}</div> + <div className="system-xs-medium-uppercase text-text-tertiary"> + {windowSizeLabel} + </div> </div> <Fieldset className="flex h-8 items-center space-x-2"> <FieldsetLegend className="sr-only">{windowSizeLabel}</FieldsetLegend> @@ -181,7 +186,7 @@ const MemoryConfig: FC<Props> = ({ min={WINDOW_SIZE_MIN} max={WINDOW_SIZE_MAX} step={1} - onChange={e => handleWindowSizeChange(e.target.value)} + onChange={(e) => handleWindowSizeChange(e.target.value)} onBlur={handleBlur} disabled={readonly || !payload.window?.enabled} /> @@ -189,17 +194,19 @@ const MemoryConfig: FC<Props> = ({ </div> {canSetRoleName && ( <div className="mt-4"> - <div className="text-xs/6 font-medium text-text-tertiary uppercase">{t($ => $[`${i18nPrefix}.conversationRoleName`], { ns: 'workflow' })}</div> + <div className="text-xs/6 font-medium text-text-tertiary uppercase"> + {t(($) => $[`${i18nPrefix}.conversationRoleName`], { ns: 'workflow' })} + </div> <div className="mt-1 space-y-2"> <RoleItem readonly={readonly} - title={t($ => $[`${i18nPrefix}.user`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.user`], { ns: 'workflow' })} value={payload.role_prefix?.user || ''} onChange={handleRolePrefixChange(MemoryRole.user)} /> <RoleItem readonly={readonly} - title={t($ => $[`${i18nPrefix}.assistant`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.assistant`], { ns: 'workflow' })} value={payload.role_prefix?.assistant || ''} onChange={handleRolePrefixChange(MemoryRole.assistant)} /> @@ -208,7 +215,6 @@ const MemoryConfig: FC<Props> = ({ )} </> )} - </Field> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/index.spec.tsx index 82b2ee9603e7b1..6e9317bcd49425 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/index.spec.tsx @@ -1,10 +1,7 @@ import type { ReactNode } from 'react' import type { Edge, Node } from '@/app/components/workflow/types' import { screen } from '@testing-library/react' -import { - createEdge, - createNode, -} from '@/app/components/workflow/__tests__/fixtures' +import { createEdge, createNode } from '@/app/components/workflow/__tests__/fixtures' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { useAvailableBlocks, @@ -52,17 +49,14 @@ const createAvailableBlocksResult = (): ReturnType<typeof useAvailableBlocks> => }) const renderComponent = (selectedNode: Node, nodes: Node[], edges: Edge[] = []) => - renderWorkflowFlowComponent( - <NextStep selectedNode={selectedNode} />, - { - nodes, - edges, - canvasStyle: { - width: 600, - height: 400, - }, + renderWorkflowFlowComponent(<NextStep selectedNode={selectedNode} />, { + nodes, + edges, + canvasStyle: { + width: 600, + height: 400, }, - ) + }) describe('NextStep', () => { beforeEach(() => { @@ -113,10 +107,12 @@ describe('NextStep', () => { data: { type: BlockEnum.Code, title: 'Selected Node', - _targetBranches: [{ - id: 'branch-a', - name: 'Approved', - }], + _targetBranches: [ + { + id: 'branch-a', + name: 'Approved', + }, + ], }, }) const nextNode = createNode({ @@ -145,10 +141,12 @@ describe('NextStep', () => { data: { type: BlockEnum.QuestionClassifier, title: 'Classifier', - _targetBranches: [{ - id: 'branch-b', - name: 'Original branch name', - }], + _targetBranches: [ + { + id: 'branch-b', + name: 'Original branch name', + }, + ], }, }) const danglingEdge = createEdge({ diff --git a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx index 2bdb4ce170a43e..0846cc69b61cac 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx @@ -1,35 +1,46 @@ -import type { - ReactNode, -} from 'react' +import type { ReactNode } from 'react' import type { CommonNodeType } from '@/app/components/workflow/types' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' -import { - useAvailableBlocks, - useNodesInteractions, -} from '@/app/components/workflow/hooks' +import { useAvailableBlocks, useNodesInteractions } from '@/app/components/workflow/hooks' import { BlockEnum } from '@/app/components/workflow/types' import Operator from '../operator' vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') - const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + const DropdownMenuContext = React.createContext<{ + open: boolean + setOpen: (open: boolean) => void + } | null>(null) const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + DropdownMenu: ({ + children, + open, + onOpenChange, + }: { + children: ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( <DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}> <div>{children}</div> </DropdownMenuContext> ), - DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: React.ReactElement<{ children?: ReactNode }> }) => { + DropdownMenuTrigger: ({ + children, + render, + }: { + children: ReactNode + render?: React.ReactElement<{ children?: ReactNode }> + }) => { const { open, setOpen } = useDropdownMenuContext() if (render) { return React.cloneElement( @@ -39,7 +50,11 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { ) } - return <button type="button" onClick={() => setOpen(!open)}>{children}</button> + return ( + <button type="button" onClick={() => setOpen(!open)}> + {children} + </button> + ) }, DropdownMenuContent: ({ children }: { children: ReactNode }) => { const { open } = useDropdownMenuContext() @@ -49,7 +64,15 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { }) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, className, onClick }: { children: ReactNode, className?: string, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => ( + Button: ({ + children, + className, + onClick, + }: { + children: ReactNode + className?: string + onClick?: React.MouseEventHandler<HTMLButtonElement> + }) => ( <button type="button" className={className} onClick={onClick}> {children} </button> @@ -57,10 +80,18 @@ vi.mock('@langgenius/dify-ui/button', () => ({ })) vi.mock('@/app/components/workflow/block-selector', () => ({ - default: ({ trigger, onSelect }: { trigger: ((open: boolean) => ReactNode) | ReactNode, onSelect: (type: BlockEnum) => void }) => ( + default: ({ + trigger, + onSelect, + }: { + trigger: ((open: boolean) => ReactNode) | ReactNode + onSelect: (type: BlockEnum) => void + }) => ( <div> {typeof trigger === 'function' ? trigger(false) : trigger} - <button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button> + <button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}> + select-http + </button> </div> ), })) @@ -132,7 +163,12 @@ describe('NextStep operator', () => { await user.click(screen.getAllByRole('button')[0]!) await user.click(screen.getByText('select-http')) - expect(mockHandleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined) + expect(mockHandleNodeChange).toHaveBeenCalledWith( + 'node-1', + BlockEnum.HttpRequest, + 'source', + undefined, + ) }) it('disconnects and deletes the next step from the menu', async () => { diff --git a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx index bea8617b78d9c2..af1a19003bbb66 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx @@ -1,16 +1,6 @@ -import type { - CommonNodeType, - OnSelectBlock, -} from '@/app/components/workflow/types' -import { - RiAddLine, -} from '@remixicon/react' -import { - memo, - useCallback, - useMemo, - useState, -} from 'react' +import type { CommonNodeType, OnSelectBlock } from '@/app/components/workflow/types' +import { RiAddLine } from '@remixicon/react' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import BlockSelector from '@/app/components/workflow/block-selector' import { @@ -27,64 +17,58 @@ type AddProps = { isParallel?: boolean isFailBranch?: boolean } -const Add = ({ - nodeId, - nodeData, - sourceHandle, - isParallel, - isFailBranch, -}: AddProps) => { +const Add = ({ nodeId, nodeData, sourceHandle, isParallel, isFailBranch }: AddProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const { handleNodeAdd } = useNodesInteractions() const { nodesReadOnly } = useNodesReadOnly() - const { availableNextBlocks } = useAvailableBlocks(getNodeCatalogType(nodeData), nodeData.isInIteration || nodeData.isInLoop) + const { availableNextBlocks } = useAvailableBlocks( + getNodeCatalogType(nodeData), + nodeData.isInIteration || nodeData.isInLoop, + ) - const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { - handleNodeAdd( - { - nodeType: type, - pluginDefaultValue, - }, - { - prevNodeId: nodeId, - prevNodeSourceHandle: sourceHandle, - }, - ) - }, [handleNodeAdd]) + const handleSelect = useCallback<OnSelectBlock>( + (type, pluginDefaultValue) => { + handleNodeAdd( + { + nodeType: type, + pluginDefaultValue, + }, + { + prevNodeId: nodeId, + prevNodeSourceHandle: sourceHandle, + }, + ) + }, + [handleNodeAdd], + ) const handleOpenChange = useCallback((newOpen: boolean) => { setOpen(newOpen) }, []) const tip = useMemo(() => { - if (isFailBranch) - return t($ => $['common.addFailureBranch'], { ns: 'workflow' }) + if (isFailBranch) return t(($) => $['common.addFailureBranch'], { ns: 'workflow' }) - if (isParallel) - return t($ => $['common.addParallelNode'], { ns: 'workflow' }) + if (isParallel) return t(($) => $['common.addParallelNode'], { ns: 'workflow' }) - return t($ => $['panel.selectNextStep'], { ns: 'workflow' }) + return t(($) => $['panel.selectNextStep'], { ns: 'workflow' }) }, [isFailBranch, isParallel, t]) - const renderTrigger = useCallback((open: boolean) => { - return ( - <div - className={` - bg-dropzone-bg hover:bg-dropzone-bg-hover relative flex h-9 cursor-pointer items-center rounded-lg border border-dashed - border-divider-regular px-2 text-xs text-text-placeholder - ${open && 'bg-components-dropzone-bg-alt!'} - ${nodesReadOnly && 'cursor-not-allowed!'} - `} - > - <div className="mr-1.5 flex h-5 w-5 items-center justify-center rounded-[5px] bg-background-default-dimmed"> - <RiAddLine className="size-3" /> + const renderTrigger = useCallback( + (open: boolean) => { + return ( + <div + className={`bg-dropzone-bg hover:bg-dropzone-bg-hover relative flex h-9 cursor-pointer items-center rounded-lg border border-dashed border-divider-regular px-2 text-xs text-text-placeholder ${open && 'bg-components-dropzone-bg-alt!'} ${nodesReadOnly && 'cursor-not-allowed!'} `} + > + <div className="mr-1.5 flex h-5 w-5 items-center justify-center rounded-[5px] bg-background-default-dimmed"> + <RiAddLine className="size-3" /> + </div> + <div className="flex items-center uppercase">{tip}</div> </div> - <div className="flex items-center uppercase"> - {tip} - </div> - </div> - ) - }, [nodesReadOnly, tip]) + ) + }, + [nodesReadOnly, tip], + ) return ( <BlockSelector diff --git a/web/app/components/workflow/nodes/_base/components/next-step/container.tsx b/web/app/components/workflow/nodes/_base/components/next-step/container.tsx index 6f374d58aef8a5..fe60b948218d1a 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/container.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/container.tsx @@ -1,7 +1,4 @@ -import type { - CommonNodeType, - Node, -} from '@/app/components/workflow/types' +import type { CommonNodeType, Node } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import Add from './add' import Item from './item' @@ -24,34 +21,26 @@ const Container = ({ isFailBranch, }: ContainerProps) => { return ( - <div className={cn( - 'space-y-0.5 rounded-[10px] bg-background-section-burn p-0.5', - isFailBranch && 'border-[0.5px] border-state-warning-hover-alt bg-state-warning-hover', - )} + <div + className={cn( + 'space-y-0.5 rounded-[10px] bg-background-section-burn p-0.5', + isFailBranch && 'border-[0.5px] border-state-warning-hover-alt bg-state-warning-hover', + )} > - { - branchName && ( - <div - className={cn( - 'flex items-center truncate px-2 system-2xs-semibold-uppercase text-text-tertiary', - isFailBranch && 'text-text-warning', - )} - title={branchName} - > - {branchName} - </div> - ) - } - { - nextNodes.map(nextNode => ( - <Item - key={nextNode.id} - nodeId={nextNode.id} - data={nextNode.data} - sourceHandle="source" - /> - )) - } + {branchName && ( + <div + className={cn( + 'flex items-center truncate px-2 system-2xs-semibold-uppercase text-text-tertiary', + isFailBranch && 'text-text-warning', + )} + title={branchName} + > + {branchName} + </div> + )} + {nextNodes.map((nextNode) => ( + <Item key={nextNode.id} nodeId={nextNode.id} data={nextNode.data} sourceHandle="source" /> + ))} <Add isParallel={!!nextNodes.length} isFailBranch={isFailBranch} diff --git a/web/app/components/workflow/nodes/_base/components/next-step/index.tsx b/web/app/components/workflow/nodes/_base/components/next-step/index.tsx index e87372f823c77c..6d9424e1b36eb7 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/index.tsx @@ -1,14 +1,8 @@ -import type { - Node, -} from '../../../../types' +import type { Node } from '../../../../types' import { isEqual } from 'es-toolkit/predicate' import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { - getConnectedEdges, - getOutgoers, - useStore, -} from 'reactflow' +import { getConnectedEdges, getOutgoers, useStore } from 'reactflow' import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' import { hasErrorHandleNode } from '@/app/components/workflow/utils' import BlockIcon from '../../../../block-icon' @@ -20,73 +14,86 @@ import Line from './line' type NextStepProps = { selectedNode: Node } -const NextStep = ({ - selectedNode, -}: NextStepProps) => { +const NextStep = ({ selectedNode }: NextStepProps) => { const { t } = useTranslation() const data = selectedNode.data const toolIcon = useToolIcon(data) const branches = useMemo(() => { return data._targetBranches || [] }, [data]) - const edges = useStore(s => s.edges.map(edge => ({ - id: edge.id, - source: edge.source, - sourceHandle: edge.sourceHandle, - target: edge.target, - targetHandle: edge.targetHandle, - })), isEqual) - const nodes = useStore(s => s.getNodes().map(node => ({ - id: node.id, - data: node.data, - })), isEqual) + const edges = useStore( + (s) => + s.edges.map((edge) => ({ + id: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle, + target: edge.target, + targetHandle: edge.targetHandle, + })), + isEqual, + ) + const nodes = useStore( + (s) => + s.getNodes().map((node) => ({ + id: node.id, + data: node.data, + })), + isEqual, + ) const outgoers = getOutgoers(selectedNode as Node, nodes as Node[], edges) - const connectedEdges = getConnectedEdges([selectedNode] as Node[], edges).filter(edge => edge.source === selectedNode!.id) + const connectedEdges = getConnectedEdges([selectedNode] as Node[], edges).filter( + (edge) => edge.source === selectedNode!.id, + ) const list = useMemo(() => { const resolveNextNodes = (connected: typeof connectedEdges) => { return connected.reduce<Node[]>((acc, edge) => { - const nextNode = outgoers.find(outgoer => outgoer.id === edge.target) - if (nextNode) - acc.push(nextNode) + const nextNode = outgoers.find((outgoer) => outgoer.id === edge.target) + if (nextNode) acc.push(nextNode) return acc }, []) } let items = [] if (branches?.length) { items = branches.map((branch, index) => { - const connected = connectedEdges.filter(edge => edge.sourceHandle === branch.id) + const connected = connectedEdges.filter((edge) => edge.sourceHandle === branch.id) const nextNodes = resolveNextNodes(connected) return { branch: { ...branch, - name: data.type === BlockEnum.QuestionClassifier ? `${t($ => $['nodes.questionClassifiers.class'], { ns: 'workflow' })} ${index + 1}` : branch.name, + name: + data.type === BlockEnum.QuestionClassifier + ? `${t(($) => $['nodes.questionClassifiers.class'], { ns: 'workflow' })} ${index + 1}` + : branch.name, }, nextNodes, } }) - } - else { - const connected = connectedEdges.filter(edge => edge.sourceHandle === 'source') + } else { + const connected = connectedEdges.filter((edge) => edge.sourceHandle === 'source') const nextNodes = resolveNextNodes(connected) - items = [{ - branch: { - id: '', - name: '', + items = [ + { + branch: { + id: '', + name: '', + }, + nextNodes, }, - nextNodes, - }] + ] if (data.error_strategy === ErrorHandleTypeEnum.failBranch && hasErrorHandleNode(data.type)) { - const connected = connectedEdges.filter(edge => edge.sourceHandle === ErrorHandleTypeEnum.failBranch) + const connected = connectedEdges.filter( + (edge) => edge.sourceHandle === ErrorHandleTypeEnum.failBranch, + ) const nextNodes = resolveNextNodes(connected) items.push({ branch: { id: ErrorHandleTypeEnum.failBranch, - name: t($ => $['common.onFailure'], { ns: 'workflow' }), + name: t(($) => $['common.onFailure'], { ns: 'workflow' }), }, nextNodes, }) @@ -99,30 +106,23 @@ const NextStep = ({ return ( <div className="flex py-1"> <div className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-background-default shadow-xs"> - <BlockIcon - type={selectedNode!.data.type} - toolIcon={toolIcon} - /> + <BlockIcon type={selectedNode!.data.type} toolIcon={toolIcon} /> </div> - <Line - list={list.length ? list.map(item => item.nextNodes.length + 1) : [1]} - /> + <Line list={list.length ? list.map((item) => item.nextNodes.length + 1) : [1]} /> <div className="grow space-y-2"> - { - list.map((item, index) => { - return ( - <Container - key={index} - nodeId={selectedNode!.id} - nodeData={selectedNode!.data} - sourceHandle={item.branch.id} - nextNodes={item.nextNodes} - branchName={item.branch.name} - isFailBranch={item.branch.id === ErrorHandleTypeEnum.failBranch} - /> - ) - }) - } + {list.map((item, index) => { + return ( + <Container + key={index} + nodeId={selectedNode!.id} + nodeData={selectedNode!.data} + sourceHandle={item.branch.id} + nextNodes={item.nextNodes} + branchName={item.branch.name} + isFailBranch={item.branch.id === ErrorHandleTypeEnum.failBranch} + /> + ) + })} </div> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/next-step/item.tsx b/web/app/components/workflow/nodes/_base/components/next-step/item.tsx index b9216ccf9906ee..49de337b509530 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/item.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/item.tsx @@ -1,13 +1,7 @@ -import type { - CommonNodeType, -} from '@/app/components/workflow/types' +import type { CommonNodeType } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import BlockIcon from '@/app/components/workflow/block-icon' import { @@ -22,11 +16,7 @@ type ItemProps = { sourceHandle: string data: CommonNodeType } -const Item = ({ - nodeId, - sourceHandle, - data, -}: ItemProps) => { +const Item = ({ nodeId, sourceHandle, data }: ItemProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const { nodesReadOnly } = useNodesReadOnly() @@ -38,47 +28,31 @@ const Item = ({ }, []) return ( - <div - className="group relative flex h-9 cursor-pointer items-center rounded-lg border-[0.5px] border-divider-regular bg-background-default px-2 text-xs text-text-secondary shadow-xs last-of-type:mb-0 hover:bg-background-default-hover" - > - <BlockIcon - type={data.type} - toolIcon={toolIcon} - className="mr-1.5 shrink-0" - /> - <div - className="grow truncate system-xs-medium text-text-secondary" - title={data.title} - > + <div className="group relative flex h-9 cursor-pointer items-center rounded-lg border-[0.5px] border-divider-regular bg-background-default px-2 text-xs text-text-secondary shadow-xs last-of-type:mb-0 hover:bg-background-default-hover"> + <BlockIcon type={data.type} toolIcon={toolIcon} className="mr-1.5 shrink-0" /> + <div className="grow truncate system-xs-medium text-text-secondary" title={data.title}> {data.title} </div> - { - !nodesReadOnly && ( - <> - <Button - className="mr-1 hidden shrink-0 group-hover:flex" - size="small" - onClick={() => handleNodeSelect(nodeId)} - > - {t($ => $['common.jumpToNode'], { ns: 'workflow' })} - </Button> - <div - className={cn( - 'hidden shrink-0 items-center group-hover:flex', - open && 'flex', - )} - > - <Operator - data={data} - nodeId={nodeId} - sourceHandle={sourceHandle} - open={open} - onOpenChange={handleOpenChange} - /> - </div> - </> - ) - } + {!nodesReadOnly && ( + <> + <Button + className="mr-1 hidden shrink-0 group-hover:flex" + size="small" + onClick={() => handleNodeSelect(nodeId)} + > + {t(($) => $['common.jumpToNode'], { ns: 'workflow' })} + </Button> + <div className={cn('hidden shrink-0 items-center group-hover:flex', open && 'flex')}> + <Operator + data={data} + nodeId={nodeId} + sourceHandle={sourceHandle} + open={open} + onOpenChange={handleOpenChange} + /> + </div> + </> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/next-step/line.tsx b/web/app/components/workflow/nodes/_base/components/next-step/line.tsx index cc61120c3c2a31..327d28923de7ed 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/line.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/line.tsx @@ -3,15 +3,12 @@ import { memo } from 'react' type LineProps = { list: number[] } -const Line = ({ - list, -}: LineProps) => { +const Line = ({ list }: LineProps) => { const listHeight = list.map((item) => { return item * 36 + (item - 1) * 2 + 12 + 6 }) const processedList = listHeight.map((item, index) => { - if (index === 0) - return item + if (index === 0) return item return listHeight.slice(0, index).reduce((acc, cur) => acc + cur, 0) + item }) @@ -20,52 +17,34 @@ const Line = ({ return ( <svg className="w-6 shrink-0" style={{ height: svgHeight }}> - { - processedList.map((item, index) => { - const prevItem = index > 0 ? processedList[index - 1] : 0 - const space = prevItem! + index * 8 + 16 - return ( - <g key={index}> - { - index === 0 && ( - <> - <path - d="M0,18 L24,18" - strokeWidth={1} - fill="none" - className="stroke-divider-solid" - /> - <rect - x={0} - y={16} - width={1} - height={4} - className="fill-divider-solid-alt" - /> - </> - ) - } - { - index > 0 && ( - <path - d={`M0,18 Q12,18 12,28 L12,${space - 10 + 2} Q12,${space + 2} 24,${space + 2}`} - strokeWidth={1} - fill="none" - className="stroke-divider-solid" - /> - ) - } - <rect - x={23} - y={space} - width={1} - height={4} - className="fill-divider-solid-alt" + {processedList.map((item, index) => { + const prevItem = index > 0 ? processedList[index - 1] : 0 + const space = prevItem! + index * 8 + 16 + return ( + <g key={index}> + {index === 0 && ( + <> + <path + d="M0,18 L24,18" + strokeWidth={1} + fill="none" + className="stroke-divider-solid" + /> + <rect x={0} y={16} width={1} height={4} className="fill-divider-solid-alt" /> + </> + )} + {index > 0 && ( + <path + d={`M0,18 Q12,18 12,28 L12,${space - 10 + 2} Q12,${space + 2} 24,${space + 2}`} + strokeWidth={1} + fill="none" + className="stroke-divider-solid" /> - </g> - ) - }) - } + )} + <rect x={23} y={space} width={1} height={4} className="fill-divider-solid-alt" /> + </g> + ) + })} </svg> ) } diff --git a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx index f16714889e852d..581f901700bb86 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx @@ -1,7 +1,4 @@ -import type { - CommonNodeType, - OnSelectBlock, -} from '@/app/components/workflow/types' +import type { CommonNodeType, OnSelectBlock } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { DropdownMenu, @@ -9,15 +6,10 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { intersection } from 'es-toolkit/array' -import { - useCallback, -} from 'react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import BlockSelector from '@/app/components/workflow/block-selector' -import { - useAvailableBlocks, - useNodesInteractions, -} from '@/app/components/workflow/hooks' +import { useAvailableBlocks, useNodesInteractions } from '@/app/components/workflow/hooks' import { getNodeCatalogType } from '@/app/components/workflow/utils' type ChangeItemProps = { @@ -25,28 +17,27 @@ type ChangeItemProps = { nodeId: string sourceHandle: string } -const ChangeItem = ({ - data, - nodeId, - sourceHandle, -}: ChangeItemProps) => { +const ChangeItem = ({ data, nodeId, sourceHandle }: ChangeItemProps) => { const { t } = useTranslation() const { handleNodeChange } = useNodesInteractions() const nodeCatalogType = getNodeCatalogType(data) - const { - availablePrevBlocks, - availableNextBlocks, - } = useAvailableBlocks(nodeCatalogType, data.isInIteration || data.isInLoop) + const { availablePrevBlocks, availableNextBlocks } = useAvailableBlocks( + nodeCatalogType, + data.isInIteration || data.isInLoop, + ) - const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { - handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue) - }, [nodeId, sourceHandle, handleNodeChange]) + const handleSelect = useCallback<OnSelectBlock>( + (type, pluginDefaultValue) => { + handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue) + }, + [nodeId, sourceHandle, handleNodeChange], + ) const renderTrigger = useCallback(() => { return ( <div className="flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover"> - {t($ => $['panel.change'], { ns: 'workflow' })} + {t(($) => $['panel.change'], { ns: 'workflow' })} </div> ) }, [t]) @@ -61,7 +52,9 @@ const ChangeItem = ({ }} trigger={renderTrigger} popupClassName="w-[328px]!" - availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks).filter(item => item !== nodeCatalogType)} + availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks).filter( + (item) => item !== nodeCatalogType, + )} /> ) } @@ -73,30 +66,21 @@ type OperatorProps = { nodeId: string sourceHandle: string } -const Operator = ({ - open, - onOpenChange, - data, - nodeId, - sourceHandle, -}: OperatorProps) => { +const Operator = ({ open, onOpenChange, data, nodeId, sourceHandle }: OperatorProps) => { const { t } = useTranslation() - const { - handleNodeDelete, - handleNodeDisconnect, - } = useNodesInteractions() + const { handleNodeDelete, handleNodeDisconnect } = useNodesInteractions() return ( - <DropdownMenu - open={open} - onOpenChange={onOpenChange} - > + <DropdownMenu open={open} onOpenChange={onOpenChange}> <DropdownMenuTrigger - render={( - <Button className="size-6 p-0" aria-label={t($ => $['common.moreActions'], { ns: 'workflow' })}> + render={ + <Button + className="size-6 p-0" + aria-label={t(($) => $['common.moreActions'], { ns: 'workflow' })} + > <span aria-hidden className="i-ri-more-fill size-4" /> </Button> - )} + } /> <DropdownMenuContent placement="bottom-end" @@ -106,11 +90,7 @@ const Operator = ({ > <div className="min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur system-md-regular text-text-secondary shadow-lg"> <div className="p-1"> - <ChangeItem - data={data} - nodeId={nodeId} - sourceHandle={sourceHandle} - /> + <ChangeItem data={data} nodeId={nodeId} sourceHandle={sourceHandle} /> <div className="flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover" onClick={() => { @@ -118,7 +98,7 @@ const Operator = ({ handleNodeDisconnect(nodeId) }} > - {t($ => $['common.disconnect'], { ns: 'workflow' })} + {t(($) => $['common.disconnect'], { ns: 'workflow' })} </div> </div> <div className="p-1"> @@ -129,7 +109,7 @@ const Operator = ({ handleNodeDelete(nodeId) }} > - {t($ => $['operation.delete'], { ns: 'common' })} + {t(($) => $['operation.delete'], { ns: 'common' })} </div> </div> </div> diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx index 09991b73aa6aed..7e530f3ffaf183 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx @@ -1,39 +1,26 @@ import type { FC } from 'react' import type { Node } from '../../../types' import { cn } from '@langgenius/dify-ui/cn' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { - Stop, -} from '@/app/components/base/icons/src/vender/line/mediaAndDevices' +import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { NodeActionsDropdown } from '@/app/components/workflow/node-actions-menu' import { useWorkflowStore } from '@/app/components/workflow/store' -import { - useNodesInteractions, - useNodesReadOnly, -} from '../../../hooks' +import { useNodesInteractions, useNodesReadOnly } from '../../../hooks' import { NodeRunningStatus } from '../../../types' import { canRunBySingle } from '../../../utils' type NodeControlProps = Pick<Node, 'id' | 'data'> & { pluginInstallLocked?: boolean } -const NodeControl: FC<NodeControlProps> = ({ - id, - data, - pluginInstallLocked, -}) => { +const NodeControl: FC<NodeControlProps> = ({ id, data, pluginInstallLocked }) => { const { t } = useTranslation() const { handleNodeSelect } = useNodesInteractions() const nodesReadOnly = useNodesReadOnly() const workflowStore = useWorkflowStore() - const canRun = useHooksStore(s => s.accessControl.canRun) + const canRun = useHooksStore((s) => s.accessControl.canRun) const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running const isChildNode = !!(data.isInIteration || data.isInLoop) @@ -48,49 +35,43 @@ const NodeControl: FC<NodeControlProps> = ({ > <div className="nodrag nopan nowheel flex h-6 items-center rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg px-0.5 text-text-tertiary shadow-md backdrop-blur-[5px]" - onMouseDown={e => e.stopPropagation()} - onClick={e => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > - { - canRun && !nodesReadOnly && canRunBySingle(data.type, isChildNode) && ( - <button - type="button" - aria-label={isSingleRunning ? t($ => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' }) : t($ => $['panel.runThisStep'], { ns: 'workflow' })} - className={`flex size-5 items-center justify-center rounded-md ${isSingleRunning && 'cursor-pointer hover:bg-state-base-hover'}`} - onClick={() => { - const action = isSingleRunning ? 'stop' : 'run' + {canRun && !nodesReadOnly && canRunBySingle(data.type, isChildNode) && ( + <button + type="button" + aria-label={ + isSingleRunning + ? t(($) => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' }) + : t(($) => $['panel.runThisStep'], { ns: 'workflow' }) + } + className={`flex size-5 items-center justify-center rounded-md ${isSingleRunning && 'cursor-pointer hover:bg-state-base-hover'}`} + onClick={() => { + const action = isSingleRunning ? 'stop' : 'run' - const store = workflowStore.getState() - store.setInitShowLastRunTab(true) - store.setPendingSingleRun({ - nodeId: id, - action, - }) - handleNodeSelect(id) - }} - > - { - isSingleRunning - ? <Stop className="size-3" /> - : ( - <Tooltip> - <TooltipTrigger - render={<span className="i-ri-play-large-line size-3" />} - /> - <TooltipContent> - {t($ => $['panel.runThisStep'], { ns: 'workflow' })} - </TooltipContent> - </Tooltip> - ) - } - </button> - ) - } - <NodeActionsDropdown - id={id} - data={data} - triggerClassName="w-5! h-5!" - /> + const store = workflowStore.getState() + store.setInitShowLastRunTab(true) + store.setPendingSingleRun({ + nodeId: id, + action, + }) + handleNodeSelect(id) + }} + > + {isSingleRunning ? ( + <Stop className="size-3" /> + ) : ( + <Tooltip> + <TooltipTrigger render={<span className="i-ri-play-large-line size-3" />} /> + <TooltipContent> + {t(($) => $['panel.runThisStep'], { ns: 'workflow' })} + </TooltipContent> + </Tooltip> + )} + </button> + )} + <NodeActionsDropdown id={id} data={data} triggerClassName="w-5! h-5!" /> </div> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx index bdb462f6cf31de..549676132c02b4 100644 --- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx @@ -2,17 +2,9 @@ import type { MouseEvent } from 'react' import type { BlockDefaultValue } from '../../../block-selector/types' import type { Node } from '../../../types' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useCallback, - useEffect, - useState, -} from 'react' +import { memo, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - Handle, - Position, -} from 'reactflow' +import { Handle, Position } from 'reactflow' import BlockSelector from '../../../block-selector' import { useAvailableBlocks, @@ -20,14 +12,8 @@ import { useNodesInteractions, useNodesReadOnly, } from '../../../hooks' -import { - useStore, - useWorkflowStore, -} from '../../../store' -import { - BlockEnum, - NodeRunningStatus, -} from '../../../types' +import { useStore, useWorkflowStore } from '../../../store' +import { BlockEnum, NodeRunningStatus } from '../../../types' import { getNodeCatalogType } from '../../../utils' type NodeHandleProps = { @@ -38,75 +24,82 @@ type NodeHandleProps = { } & Pick<Node, 'id' | 'data'> const canAutoOpenStartNodeSelector = (nodeType: BlockEnum, isChatMode: boolean) => { - if (isChatMode) - return false + if (isChatMode) return false - return nodeType === BlockEnum.Start - || nodeType === BlockEnum.TriggerSchedule - || nodeType === BlockEnum.TriggerWebhook - || nodeType === BlockEnum.TriggerPlugin + return ( + nodeType === BlockEnum.Start || + nodeType === BlockEnum.TriggerSchedule || + nodeType === BlockEnum.TriggerWebhook || + nodeType === BlockEnum.TriggerPlugin + ) } -export const NodeTargetHandle = memo(({ - id, - data, - handleId, - handleClassName, - nodeSelectorClassName, -}: NodeHandleProps) => { - const [open, setOpen] = useState(false) - const { handleNodeAdd } = useNodesInteractions() - const { getNodesReadOnly } = useNodesReadOnly() - const connected = data._connectedTargetHandleIds?.includes(handleId) - const { availablePrevBlocks } = useAvailableBlocks(getNodeCatalogType(data), data.isInIteration || data.isInLoop) - const isConnectable = !!availablePrevBlocks.length +export const NodeTargetHandle = memo( + ({ id, data, handleId, handleClassName, nodeSelectorClassName }: NodeHandleProps) => { + const [open, setOpen] = useState(false) + const { handleNodeAdd } = useNodesInteractions() + const { getNodesReadOnly } = useNodesReadOnly() + const connected = data._connectedTargetHandleIds?.includes(handleId) + const { availablePrevBlocks } = useAvailableBlocks( + getNodeCatalogType(data), + data.isInIteration || data.isInLoop, + ) + const isConnectable = !!availablePrevBlocks.length - const handleOpenChange = useCallback((v: boolean) => { - setOpen(v) - }, []) - const handleHandleClick = useCallback((e: MouseEvent) => { - e.stopPropagation() - if (!connected) - setOpen(v => !v) - }, [connected]) - const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: BlockDefaultValue) => { - handleNodeAdd( - { - nodeType: type, - pluginDefaultValue, + const handleOpenChange = useCallback((v: boolean) => { + setOpen(v) + }, []) + const handleHandleClick = useCallback( + (e: MouseEvent) => { + e.stopPropagation() + if (!connected) setOpen((v) => !v) }, - { - nextNodeId: id, - nextNodeTargetHandle: handleId, + [connected], + ) + const handleSelect = useCallback( + (type: BlockEnum, pluginDefaultValue?: BlockDefaultValue) => { + handleNodeAdd( + { + nodeType: type, + pluginDefaultValue, + }, + { + nextNodeId: id, + nextNodeTargetHandle: handleId, + }, + ) }, + [handleNodeAdd, id, handleId], ) - }, [handleNodeAdd, id, handleId]) - return ( - <> - <Handle - id={handleId} - type="target" - position={Position.Left} - className={cn( - 'z-1 size-4! rounded-none! border-none! bg-transparent! outline-hidden!', - 'after:absolute after:top-1 after:left-1.5 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', - 'transition-all hover:scale-125', - data._runningStatus === NodeRunningStatus.Succeeded && 'after:bg-workflow-link-line-success-handle', - data._runningStatus === NodeRunningStatus.Failed && 'after:bg-workflow-link-line-error-handle', - data._runningStatus === NodeRunningStatus.Exception && 'after:bg-workflow-link-line-failure-handle', - !connected && 'after:opacity-0', - (data.type === BlockEnum.Start - || data.type === BlockEnum.TriggerWebhook - || data.type === BlockEnum.TriggerSchedule - || data.type === BlockEnum.TriggerPlugin) && 'opacity-0', - handleClassName, - )} - isConnectable={isConnectable} - onClick={handleHandleClick} - > - { - !connected && isConnectable && !getNodesReadOnly() && ( + return ( + <> + <Handle + id={handleId} + type="target" + position={Position.Left} + className={cn( + 'z-1 size-4! rounded-none! border-none! bg-transparent! outline-hidden!', + 'after:absolute after:top-1 after:left-1.5 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', + 'transition-all hover:scale-125', + data._runningStatus === NodeRunningStatus.Succeeded && + 'after:bg-workflow-link-line-success-handle', + data._runningStatus === NodeRunningStatus.Failed && + 'after:bg-workflow-link-line-error-handle', + data._runningStatus === NodeRunningStatus.Exception && + 'after:bg-workflow-link-line-failure-handle', + !connected && 'after:opacity-0', + (data.type === BlockEnum.Start || + data.type === BlockEnum.TriggerWebhook || + data.type === BlockEnum.TriggerSchedule || + data.type === BlockEnum.TriggerPlugin) && + 'opacity-0', + handleClassName, + )} + isConnectable={isConnectable} + onClick={handleHandleClick} + > + {!connected && isConnectable && !getNodesReadOnly() && ( <BlockSelector open={open} onOpenChange={handleOpenChange} @@ -116,7 +109,7 @@ export const NodeTargetHandle = memo(({ nextNodeTargetHandle: handleId, }} placement="left" - triggerClassName={open => ` + triggerClassName={(open) => ` absolute left-0 top-0 opacity-0 pointer-events-none transition-opacity duration-150 ${nodeSelectorClassName} group-hover:opacity-100 @@ -125,110 +118,127 @@ export const NodeTargetHandle = memo(({ `} availableBlocksTypes={availablePrevBlocks} /> - ) - } - </Handle> - </> - ) -}) + )} + </Handle> + </> + ) + }, +) NodeTargetHandle.displayName = 'NodeTargetHandle' -export const NodeSourceHandle = memo(({ - id, - data, - handleId, - handleClassName, - nodeSelectorClassName, - showExceptionStatus, -}: NodeHandleProps) => { - const { t } = useTranslation() - const shouldAutoOpenStartNodeSelector = useStore(s => s.shouldAutoOpenStartNodeSelector) - const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector) - const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode) - const workflowStoreApi = useWorkflowStore() - const { handleNodeAdd } = useNodesInteractions() - const { getNodesReadOnly } = useNodesReadOnly() - const { availableNextBlocks } = useAvailableBlocks(getNodeCatalogType(data), data.isInIteration || data.isInLoop) - const isConnectable = !!availableNextBlocks.length - const isChatMode = useIsChatMode() - const shouldAutoOpen = shouldAutoOpenStartNodeSelector && canAutoOpenStartNodeSelector(data.type, isChatMode) - const [open, setOpen] = useState(() => shouldAutoOpen) +export const NodeSourceHandle = memo( + ({ + id, + data, + handleId, + handleClassName, + nodeSelectorClassName, + showExceptionStatus, + }: NodeHandleProps) => { + const { t } = useTranslation() + const shouldAutoOpenStartNodeSelector = useStore((s) => s.shouldAutoOpenStartNodeSelector) + const setShouldAutoOpenStartNodeSelector = useStore((s) => s.setShouldAutoOpenStartNodeSelector) + const setHasSelectedStartNode = useStore((s) => s.setHasSelectedStartNode) + const workflowStoreApi = useWorkflowStore() + const { handleNodeAdd } = useNodesInteractions() + const { getNodesReadOnly } = useNodesReadOnly() + const { availableNextBlocks } = useAvailableBlocks( + getNodeCatalogType(data), + data.isInIteration || data.isInLoop, + ) + const isConnectable = !!availableNextBlocks.length + const isChatMode = useIsChatMode() + const shouldAutoOpen = + shouldAutoOpenStartNodeSelector && canAutoOpenStartNodeSelector(data.type, isChatMode) + const [open, setOpen] = useState(() => shouldAutoOpen) - const connected = data._connectedSourceHandleIds?.includes(handleId) - const handleOpenChange = useCallback((v: boolean) => { - setOpen(v) - }, []) - const handleHandleClick = useCallback((e: MouseEvent) => { - e.stopPropagation() - setOpen(v => !v) - }, []) - const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: BlockDefaultValue) => { - handleNodeAdd( - { - nodeType: type, - pluginDefaultValue, - }, - { - prevNodeId: id, - prevNodeSourceHandle: handleId, + const connected = data._connectedSourceHandleIds?.includes(handleId) + const handleOpenChange = useCallback((v: boolean) => { + setOpen(v) + }, []) + const handleHandleClick = useCallback((e: MouseEvent) => { + e.stopPropagation() + setOpen((v) => !v) + }, []) + const handleSelect = useCallback( + (type: BlockEnum, pluginDefaultValue?: BlockDefaultValue) => { + handleNodeAdd( + { + nodeType: type, + pluginDefaultValue, + }, + { + prevNodeId: id, + prevNodeSourceHandle: handleId, + }, + ) }, + [handleNodeAdd, id, handleId], ) - }, [handleNodeAdd, id, handleId]) - useEffect(() => { - if (!shouldAutoOpenStartNodeSelector) - return + useEffect(() => { + if (!shouldAutoOpenStartNodeSelector) return - if (isChatMode) { - setShouldAutoOpenStartNodeSelector?.(false) - return - } + if (isChatMode) { + setShouldAutoOpenStartNodeSelector?.(false) + return + } - if (canAutoOpenStartNodeSelector(data.type, false)) { - if (setShouldAutoOpenStartNodeSelector) - setShouldAutoOpenStartNodeSelector(false) - else - workflowStoreApi?.setState?.({ shouldAutoOpenStartNodeSelector: false }) + if (canAutoOpenStartNodeSelector(data.type, false)) { + if (setShouldAutoOpenStartNodeSelector) setShouldAutoOpenStartNodeSelector(false) + else workflowStoreApi?.setState?.({ shouldAutoOpenStartNodeSelector: false }) - if (setHasSelectedStartNode) - setHasSelectedStartNode(false) - else - workflowStoreApi?.setState?.({ hasSelectedStartNode: false }) - } - }, [shouldAutoOpenStartNodeSelector, data.type, isChatMode, setShouldAutoOpenStartNodeSelector, setHasSelectedStartNode, workflowStoreApi]) + if (setHasSelectedStartNode) setHasSelectedStartNode(false) + else workflowStoreApi?.setState?.({ hasSelectedStartNode: false }) + } + }, [ + shouldAutoOpenStartNodeSelector, + data.type, + isChatMode, + setShouldAutoOpenStartNodeSelector, + setHasSelectedStartNode, + workflowStoreApi, + ]) - return ( - <Handle - id={handleId} - type="source" - position={Position.Right} - className={cn( - 'group/handle z-1 size-4! rounded-none! border-none! bg-transparent! outline-hidden!', - 'after:absolute after:top-1 after:right-1.5 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', - 'transition-all hover:scale-125', - data._runningStatus === NodeRunningStatus.Succeeded && 'after:bg-workflow-link-line-success-handle', - data._runningStatus === NodeRunningStatus.Failed && 'after:bg-workflow-link-line-error-handle', - showExceptionStatus && data._runningStatus === NodeRunningStatus.Exception && 'after:bg-workflow-link-line-failure-handle', - !connected && 'after:opacity-0', - handleClassName, - )} - isConnectable={isConnectable} - onClick={handleHandleClick} - > - <div className="absolute -top-1 left-1/2 hidden -translate-x-1/2 -translate-y-full rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg group-hover/handle:block"> - <div className="system-xs-regular text-text-tertiary"> - <div className="whitespace-nowrap"> - <span className="system-xs-medium text-text-secondary">{t($ => $['common.parallelTip.click.title'], { ns: 'workflow' })}</span> - {t($ => $['common.parallelTip.click.desc'], { ns: 'workflow' })} - </div> - <div> - <span className="system-xs-medium text-text-secondary">{t($ => $['common.parallelTip.drag.title'], { ns: 'workflow' })}</span> - {t($ => $['common.parallelTip.drag.desc'], { ns: 'workflow' })} + return ( + <Handle + id={handleId} + type="source" + position={Position.Right} + className={cn( + 'group/handle z-1 size-4! rounded-none! border-none! bg-transparent! outline-hidden!', + 'after:absolute after:top-1 after:right-1.5 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', + 'transition-all hover:scale-125', + data._runningStatus === NodeRunningStatus.Succeeded && + 'after:bg-workflow-link-line-success-handle', + data._runningStatus === NodeRunningStatus.Failed && + 'after:bg-workflow-link-line-error-handle', + showExceptionStatus && + data._runningStatus === NodeRunningStatus.Exception && + 'after:bg-workflow-link-line-failure-handle', + !connected && 'after:opacity-0', + handleClassName, + )} + isConnectable={isConnectable} + onClick={handleHandleClick} + > + <div className="absolute -top-1 left-1/2 hidden -translate-x-1/2 -translate-y-full rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg group-hover/handle:block"> + <div className="system-xs-regular text-text-tertiary"> + <div className="whitespace-nowrap"> + <span className="system-xs-medium text-text-secondary"> + {t(($) => $['common.parallelTip.click.title'], { ns: 'workflow' })} + </span> + {t(($) => $['common.parallelTip.click.desc'], { ns: 'workflow' })} + </div> + <div> + <span className="system-xs-medium text-text-secondary"> + {t(($) => $['common.parallelTip.drag.title'], { ns: 'workflow' })} + </span> + {t(($) => $['common.parallelTip.drag.desc'], { ns: 'workflow' })} + </div> </div> </div> - </div> - { - isConnectable && !getNodesReadOnly() && ( + {isConnectable && !getNodesReadOnly() && ( <BlockSelector open={open} onOpenChange={handleOpenChange} @@ -237,7 +247,7 @@ export const NodeSourceHandle = memo(({ prevNodeId: id, prevNodeSourceHandle: handleId, }} - triggerClassName={open => ` + triggerClassName={(open) => ` absolute top-0 left-0 opacity-0 pointer-events-none transition-opacity duration-150 ${nodeSelectorClassName} group-hover:opacity-100 @@ -246,9 +256,9 @@ export const NodeSourceHandle = memo(({ `} availableBlocksTypes={availableNextBlocks} /> - ) - } - </Handle> - ) -}) + )} + </Handle> + ) + }, +) NodeSourceHandle.displayName = 'NodeSourceHandle' diff --git a/web/app/components/workflow/nodes/_base/components/node-resizer.tsx b/web/app/components/workflow/nodes/_base/components/node-resizer.tsx index 398796f6175533..ce1df3d2e349d5 100644 --- a/web/app/components/workflow/nodes/_base/components/node-resizer.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-resizer.tsx @@ -1,17 +1,20 @@ import type { OnResize } from 'reactflow' import type { CommonNodeType } from '../../../types' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' import { NodeResizeControl } from 'reactflow' import { useNodesInteractions } from '../../../hooks' const Icon = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> - <path d="M5.19009 11.8398C8.26416 10.6196 10.7144 8.16562 11.9297 5.08904" stroke="black" strokeOpacity="0.16" strokeWidth="2" strokeLinecap="round" /> + <path + d="M5.19009 11.8398C8.26416 10.6196 10.7144 8.16562 11.9297 5.08904" + stroke="black" + strokeOpacity="0.16" + strokeWidth="2" + strokeLinecap="round" + /> </svg> ) } @@ -34,16 +37,15 @@ const NodeResizer = ({ }: NodeResizerProps) => { const { handleNodeResize } = useNodesInteractions() - const handleResize = useCallback<OnResize>((_, params) => { - handleNodeResize(nodeId, params) - }, [nodeId, handleNodeResize]) + const handleResize = useCallback<OnResize>( + (_, params) => { + handleNodeResize(nodeId, params) + }, + [nodeId, handleNodeResize], + ) return ( - <div className={cn( - 'hidden group-hover:block', - nodeData.selected && 'block!', - )} - > + <div className={cn('hidden group-hover:block', nodeData.selected && 'block!')}> <NodeResizeControl position="bottom-right" className="border-none! bg-transparent!" diff --git a/web/app/components/workflow/nodes/_base/components/node-status-icon.tsx b/web/app/components/workflow/nodes/_base/components/node-status-icon.tsx index d79897519f3086..9ba786bd2ad4b6 100644 --- a/web/app/components/workflow/nodes/_base/components/node-status-icon.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-status-icon.tsx @@ -10,32 +10,21 @@ type NodeStatusIconProps = { status: string className?: string } -const NodeStatusIcon = ({ - status, - className, -}: NodeStatusIconProps) => { +const NodeStatusIcon = ({ status, className }: NodeStatusIconProps) => { return ( <> - { - status === 'succeeded' && ( - <RiCheckboxCircleFill className={cn('size-4 shrink-0 text-text-success', className)} /> - ) - } - { - status === 'failed' && ( - <RiErrorWarningLine className={cn('size-4 shrink-0 text-text-warning', className)} /> - ) - } - { - (status === 'stopped' || status === 'exception') && ( - <RiAlertFill className={cn('size-4 shrink-0 text-text-warning-secondary', className)} /> - ) - } - { - status === 'running' && ( - <RiLoader2Line className={cn('size-4 shrink-0 animate-spin text-text-accent', className)} /> - ) - } + {status === 'succeeded' && ( + <RiCheckboxCircleFill className={cn('size-4 shrink-0 text-text-success', className)} /> + )} + {status === 'failed' && ( + <RiErrorWarningLine className={cn('size-4 shrink-0 text-text-warning', className)} /> + )} + {(status === 'stopped' || status === 'exception') && ( + <RiAlertFill className={cn('size-4 shrink-0 text-text-warning-secondary', className)} /> + )} + {status === 'running' && ( + <RiLoader2Line className={cn('size-4 shrink-0 animate-spin text-text-accent', className)} /> + )} </> ) } diff --git a/web/app/components/workflow/nodes/_base/components/option-card.tsx b/web/app/components/workflow/nodes/_base/components/option-card.tsx index c90a8c76f94c3f..a478bb26caa116 100644 --- a/web/app/components/workflow/nodes/_base/components/option-card.tsx +++ b/web/app/components/workflow/nodes/_base/components/option-card.tsx @@ -28,7 +28,8 @@ type Props = Readonly<{ disabled?: boolean align?: 'left' | 'center' | 'right' tooltip?: string -}> & VariantProps<typeof variants> +}> & + VariantProps<typeof variants> const OptionCard: FC<Props> = ({ className, @@ -40,8 +41,7 @@ const OptionCard: FC<Props> = ({ tooltip, }) => { const handleSelect = useCallback(() => { - if (selected || disabled) - return + if (selected || disabled) return onSelect() }, [onSelect, selected, disabled]) @@ -49,8 +49,12 @@ const OptionCard: FC<Props> = ({ <div className={cn( 'flex h-8 cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary', - (!selected && !disabled) && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', - (selected && !disabled) && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs', + !selected && + !disabled && + 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', + selected && + !disabled && + 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs', disabled && 'text-text-disabled', variants({ align }), className, @@ -58,12 +62,11 @@ const OptionCard: FC<Props> = ({ onClick={handleSelect} > <span>{title}</span> - {tooltip - && ( - <Infotip aria-label={tooltip} popupClassName="w-[240px]"> - {tooltip} - </Infotip> - )} + {tooltip && ( + <Infotip aria-label={tooltip} popupClassName="w-[240px]"> + {tooltip} + </Infotip> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/output-vars.tsx b/web/app/components/workflow/nodes/_base/components/output-vars.tsx index 85d093e7780eee..2c09fb3726c87b 100644 --- a/web/app/components/workflow/nodes/_base/components/output-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/output-vars.tsx @@ -15,17 +15,11 @@ type Props = Readonly<{ onCollapse?: (collapsed: boolean) => void }> -const OutputVars: FC<Props> = ({ - title, - children, - operations, - collapsed, - onCollapse, -}) => { +const OutputVars: FC<Props> = ({ title, children, operations, collapsed, onCollapse }) => { const { t } = useTranslation() return ( <FieldCollapse - title={title || t($ => $['nodes.common.outputVars'], { ns: 'workflow' })} + title={title || t(($) => $['nodes.common.outputVars'], { ns: 'workflow' })} actions={operations} collapsed={collapsed} onCollapse={onCollapse} @@ -46,13 +40,7 @@ type VarItemProps = { isIndent?: boolean } -export const VarItem: FC<VarItemProps> = ({ - name, - type, - description, - subItems, - isIndent, -}) => { +export const VarItem: FC<VarItemProps> = ({ name, type, description, subItems, isIndent }) => { return ( <div className={cn('flex', isIndent && 'relative left-[-7px]')}> {isIndent && <TreeIndentLine depth={1} />} diff --git a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx index bbdfd4c8f50b2d..a853be7373fe05 100644 --- a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx +++ b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx @@ -1,29 +1,18 @@ 'use client' import type { FC, ReactNode } from 'react' -import type { - ModelConfig, - Node, - NodeOutPutVar, - Variable, -} from '../../../../types' +import type { ModelConfig, Node, NodeOutPutVar, Variable } from '../../../../types' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Switch } from '@langgenius/dify-ui/switch' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiDeleteBinLine, -} from '@remixicon/react' +import { RiDeleteBinLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' import * as React from 'react' import { useCallback, useRef } from 'react' - import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import { - Copy, - CopyCheck, -} from '@/app/components/base/icons/src/vender/line/files' +import { Copy, CopyCheck } from '@/app/components/base/icons/src/vender/line/files' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import { Jinja } from '@/app/components/base/icons/src/vender/workflow' import PromptEditor from '@/app/components/base/prompt-editor' @@ -123,28 +112,22 @@ const Editor: FC<Props> = ({ }) => { const { t } = useTranslation() const { eventEmitter } = useEventEmitterContextContext() - const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) + const controlPromptEditorRerenderKey = useStore((s) => s.controlPromptEditorRerenderKey) const isShowHistory = !isChatModel && isChatApp const ref = useRef<HTMLDivElement>(null) - const { - wrapClassName, - wrapStyle, - isExpand, - setIsExpand, - editorExpandHeight, - } = useToggleExpend({ ref, isInNode: true }) + const { wrapClassName, wrapStyle, isExpand, setIsExpand, editorExpandHeight } = useToggleExpend({ + ref, + isInNode: true, + }) const [isCopied, setIsCopied] = React.useState(false) const handleCopy = useCallback(() => { copy(value) setIsCopied(true) }, [value]) - const [isFocus, { - setTrue: setFocus, - setFalse: setBlur, - }] = useBoolean(false) + const [isFocus, { setTrue: setFocus, setFalse: setBlur }] = useBoolean(false) const handleInsertVariable = () => { setFocus() @@ -152,33 +135,63 @@ const Editor: FC<Props> = ({ } const getVarType = useWorkflowVariableType() - const pipelineId = useStore(s => s.pipelineId) - const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel) + const pipelineId = useStore((s) => s.pipelineId) + const setShowInputFieldPanel = useStore((s) => s.setShowInputFieldPanel) return ( <Wrap className={cn(className, wrapClassName)} style={wrapStyle} isInNode isExpand={isExpand}> - <div ref={ref} className={cn(isFocus ? (gradientBorder && 'bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2') : 'bg-transparent', isExpand && 'h-full', 'rounded-[9px]! p-0.5', containerClassName)}> - <div className={cn(isFocus ? 'bg-background-default' : 'bg-components-input-bg-normal', isExpand && 'flex h-full flex-col', 'rounded-lg', containerClassName)}> + <div + ref={ref} + className={cn( + isFocus + ? gradientBorder && + 'bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2' + : 'bg-transparent', + isExpand && 'h-full', + 'rounded-[9px]! p-0.5', + containerClassName, + )} + > + <div + className={cn( + isFocus ? 'bg-background-default' : 'bg-components-input-bg-normal', + isExpand && 'flex h-full flex-col', + 'rounded-lg', + containerClassName, + )} + > <div className={cn('flex items-center justify-between pt-1 pr-2 pl-3', headerClassName)}> <div className="flex gap-2"> - <div className={cn('text-xs/4 font-semibold text-text-secondary uppercase', titleClassName)}> - {title} - {' '} - {required && <span className="text-text-destructive">*</span>} + <div + className={cn( + 'text-xs/4 font-semibold text-text-secondary uppercase', + titleClassName, + )} + > + {title} {required && <span className="text-text-destructive">*</span>} </div> {!!titleTooltip && ( <Popover> <PopoverTrigger openOnHover - aria-label={typeof titleTooltip === 'string' ? titleTooltip : typeof title === 'string' ? title : 'Help'} - render={( + aria-label={ + typeof titleTooltip === 'string' + ? titleTooltip + : typeof title === 'string' + ? title + : 'Help' + } + render={ <button type="button" className="flex size-4 shrink-0 items-center justify-center rounded-sm p-px outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover" > - <span aria-hidden className="i-ri-question-line size-3.5 text-text-quaternary hover:text-text-tertiary" /> + <span + aria-hidden + className="i-ri-question-line size-3.5 text-text-quaternary hover:text-text-tertiary" + /> </button> - )} + } /> <PopoverContent popupClassName="max-w-[300px] px-3 py-2 system-xs-regular text-text-tertiary"> {titleTooltip} @@ -187,7 +200,9 @@ const Editor: FC<Props> = ({ )} </div> <div className="flex items-center"> - <div className="text-xs leading-[18px] font-medium text-text-tertiary">{value?.length || 0}</div> + <div className="text-xs leading-[18px] font-medium text-text-tertiary"> + {value?.length || 0} + </div> {isSupportPromptGenerator && ( <PromptGeneratorBtn nodeId={nodeId!} @@ -203,24 +218,37 @@ const Editor: FC<Props> = ({ {/* Operations */} <div className="flex items-center space-x-[2px]"> {isSupportJinja && ( - <div className={cn(editionType === EditionType.jinja2 && 'border-components-button-ghost-bg-hover bg-components-button-ghost-bg-hover', 'flex h-[22px] items-center space-x-0.5 rounded-[5px] border border-transparent px-1.5 hover:border-components-button-ghost-bg-hover')}> + <div + className={cn( + editionType === EditionType.jinja2 && + 'border-components-button-ghost-bg-hover bg-components-button-ghost-bg-hover', + 'flex h-[22px] items-center space-x-0.5 rounded-[5px] border border-transparent px-1.5 hover:border-components-button-ghost-bg-hover', + )} + > <Popover> <PopoverTrigger openOnHover - aria-label={t($ => $['common.enableJinja'], { ns: 'workflow' })} - render={( + aria-label={t(($) => $['common.enableJinja'], { ns: 'workflow' })} + render={ <button type="button" className="flex h-4 w-7 items-center justify-center rounded-sm outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover" > <Jinja className="h-3 w-6 text-text-quaternary" /> </button> - )} + } /> <PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary"> <div> - <div>{t($ => $['common.enableJinja'], { ns: 'workflow' })}</div> - <a className="text-text-accent hover:underline" target="_blank" rel="noopener noreferrer" href="https://jinja.palletsprojects.com/en/2.10.x/">{t($ => $['common.learnMore'], { ns: 'workflow' })}</a> + <div>{t(($) => $['common.enableJinja'], { ns: 'workflow' })}</div> + <a + className="text-text-accent hover:underline" + target="_blank" + rel="noopener noreferrer" + href="https://jinja.palletsprojects.com/en/2.10.x/" + > + {t(($) => $['common.learnMore'], { ns: 'workflow' })} + </a> </div> </PopoverContent> </Popover> @@ -236,14 +264,14 @@ const Editor: FC<Props> = ({ {!readOnly && ( <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton onClick={handleInsertVariable}> <Variable02 className="size-4" /> </ActionButton> - )} + } /> <TooltipContent> - {t($ => $['common.insertVarTip'], { ns: 'workflow' })} + {t(($) => $['common.insertVarTip'], { ns: 'workflow' })} </TooltipContent> </Tooltip> )} @@ -252,112 +280,118 @@ const Editor: FC<Props> = ({ <RiDeleteBinLine className="size-4" /> </ActionButton> )} - {!isCopied - ? ( - <ActionButton onClick={handleCopy}> - <Copy className="size-4" /> - </ActionButton> - ) - : ( - <ActionButton> - <CopyCheck className="size-4" /> - </ActionButton> - )} + {!isCopied ? ( + <ActionButton onClick={handleCopy}> + <Copy className="size-4" /> + </ActionButton> + ) : ( + <ActionButton> + <CopyCheck className="size-4" /> + </ActionButton> + )} <ToggleExpandBtn isExpand={isExpand} onExpandChange={setIsExpand} /> </div> - </div> </div> {/* Min: 80 Max: 560. Header: 24 */} <div className={cn('pb-2', isExpand && 'flex grow flex-col')}> - {!(isSupportJinja && editionType === EditionType.jinja2) - ? ( - <div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}> - <PromptEditor - key={controlPromptEditorRerenderKey} - placeholder={placeholder} - placeholderClassName={placeholderClassName} - instanceId={instanceId} - compact - className={cn('min-h-[56px]', inputClassName)} - style={isExpand ? { height: editorExpandHeight - 5 } : {}} - value={value} - contextBlock={{ - show: justVar ? false : isShowContext, - selectable: !hasSetBlockStatus?.context, - canNotAddContext: true, - }} - historyBlock={{ - show: justVar ? false : isShowHistory, - selectable: !hasSetBlockStatus?.history, - history: { - user: 'Human', - assistant: 'Assistant', - }, - }} - queryBlock={{ - show: false, // use [sys.query] instead of query block - selectable: false, - }} - workflowVariableBlock={{ - show: true, - variables: nodesOutputVars || [], - getVarType: getVarType as any, - workflowNodesMap: availableNodes.reduce((acc, node) => { - acc[node.id] = { - title: node.data.title, - type: node.data.type, - width: node.width, - height: node.height, - position: node.position, - ...(node.data.type === BlockEnum.LLM && { - modelProvider: (node.data as { model?: ModelConfig }).model?.provider, - }), - } - if (node.data.type === BlockEnum.Start) { - acc.sys = { - title: t($ => $['blocks.start'], { ns: 'workflow' }), - type: BlockEnum.Start, - } - } - return acc - }, {} as any), - showManageInputField: !!pipelineId, - onManageInputField: () => setShowInputFieldPanel?.(true), - }} - onChange={onChange} - onBlur={setBlur} - onFocus={setFocus} - editable={!readOnly} - isSupportFileVar={isSupportFileVar} - /> - {/* to patch Editor not support dynamic change editable status */} - {readOnly && <div className="absolute inset-0 z-10"></div>} - </div> - ) - : ( - <div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}> - <CodeEditor - availableVars={nodesOutputVars || []} - varList={varList} - onAddVar={handleAddVariable} - isInNode - readOnly={readOnly} - language={CodeLanguage.python3} - value={value} - onChange={onChange} - noWrapper - isExpand={isExpand} - className={inputClassName} - /> - </div> + {!(isSupportJinja && editionType === EditionType.jinja2) ? ( + <div + className={cn( + isExpand ? 'grow' : 'max-h-[536px]', + 'relative min-h-[56px] overflow-y-auto px-3', + editorContainerClassName, )} + > + <PromptEditor + key={controlPromptEditorRerenderKey} + placeholder={placeholder} + placeholderClassName={placeholderClassName} + instanceId={instanceId} + compact + className={cn('min-h-[56px]', inputClassName)} + style={isExpand ? { height: editorExpandHeight - 5 } : {}} + value={value} + contextBlock={{ + show: justVar ? false : isShowContext, + selectable: !hasSetBlockStatus?.context, + canNotAddContext: true, + }} + historyBlock={{ + show: justVar ? false : isShowHistory, + selectable: !hasSetBlockStatus?.history, + history: { + user: 'Human', + assistant: 'Assistant', + }, + }} + queryBlock={{ + show: false, // use [sys.query] instead of query block + selectable: false, + }} + workflowVariableBlock={{ + show: true, + variables: nodesOutputVars || [], + getVarType: getVarType as any, + workflowNodesMap: availableNodes.reduce((acc, node) => { + acc[node.id] = { + title: node.data.title, + type: node.data.type, + width: node.width, + height: node.height, + position: node.position, + ...(node.data.type === BlockEnum.LLM && { + modelProvider: (node.data as { model?: ModelConfig }).model?.provider, + }), + } + if (node.data.type === BlockEnum.Start) { + acc.sys = { + title: t(($) => $['blocks.start'], { ns: 'workflow' }), + type: BlockEnum.Start, + } + } + return acc + }, {} as any), + showManageInputField: !!pipelineId, + onManageInputField: () => setShowInputFieldPanel?.(true), + }} + onChange={onChange} + onBlur={setBlur} + onFocus={setFocus} + editable={!readOnly} + isSupportFileVar={isSupportFileVar} + /> + {/* to patch Editor not support dynamic change editable status */} + {readOnly && <div className="absolute inset-0 z-10"></div>} + </div> + ) : ( + <div + className={cn( + isExpand ? 'grow' : 'max-h-[536px]', + 'relative min-h-[56px] overflow-y-auto px-3', + editorContainerClassName, + )} + > + <CodeEditor + availableVars={nodesOutputVars || []} + varList={varList} + onAddVar={handleAddVariable} + isInNode + readOnly={readOnly} + language={CodeLanguage.python3} + value={value} + onChange={onChange} + noWrapper + isExpand={isExpand} + className={inputClassName} + /> + </div> + )} </div> </div> </div> </Wrap> - ) } export default React.memo(Editor) diff --git a/web/app/components/workflow/nodes/_base/components/readonly-input-with-select-var.tsx b/web/app/components/workflow/nodes/_base/components/readonly-input-with-select-var.tsx index 9581f22fd4bce5..6b72fd9914191b 100644 --- a/web/app/components/workflow/nodes/_base/components/readonly-input-with-select-var.tsx +++ b/web/app/components/workflow/nodes/_base/components/readonly-input-with-select-var.tsx @@ -2,9 +2,7 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' -import { - VariableLabelInText, -} from '@/app/components/workflow/nodes/_base/components/variable/variable-label' +import { VariableLabelInText } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { useWorkflow } from '../../../hooks' import { BlockEnum } from '../../../types' import { getNodeInfoById, isSystemVar } from './variable/utils' @@ -17,11 +15,7 @@ type Props = Readonly<{ const VAR_PLACEHOLDER = '@#!@#!' -const ReadonlyInputWithSelectVar: FC<Props> = ({ - nodeId, - value, - className, -}) => { +const ReadonlyInputWithSelectVar: FC<Props> = ({ nodeId, value, className }) => { const { getBeforeNodesInSameBranchIncludeParent } = useWorkflow() const availableNodes = getBeforeNodesInSameBranchIncludeParent(nodeId) const startNode = availableNodes.find((node: any) => { @@ -35,34 +29,36 @@ const ReadonlyInputWithSelectVar: FC<Props> = ({ return VAR_PLACEHOLDER }) - const html: React.JSX.Element[] = strWithVarPlaceholder.split(VAR_PLACEHOLDER).map((str, index) => { - if (!vars[index]) - return <span className="relative top-[-3px] leading-[16px]" key={index}>{str}</span> + const html: React.JSX.Element[] = strWithVarPlaceholder + .split(VAR_PLACEHOLDER) + .map((str, index) => { + if (!vars[index]) + return ( + <span className="relative top-[-3px] leading-[16px]" key={index}> + {str} + </span> + ) - const value = vars[index].split('.') - const isSystem = isSystemVar(value) - const node = (isSystem ? startNode : getNodeInfoById(availableNodes, value[0]!))?.data - const isShowAPart = value.length > 2 + const value = vars[index].split('.') + const isSystem = isSystemVar(value) + const node = (isSystem ? startNode : getNodeInfoById(availableNodes, value[0]!))?.data + const isShowAPart = value.length > 2 - return ( - <span key={index}> - <span className="relative top-[-3px] leading-[16px]">{str}</span> - <VariableLabelInText - nodeTitle={node?.title} - nodeType={node?.type} - notShowFullPath={isShowAPart} - variables={value} - /> - </span> - ) - }) + return ( + <span key={index}> + <span className="relative top-[-3px] leading-[16px]">{str}</span> + <VariableLabelInText + nodeTitle={node?.title} + nodeType={node?.type} + notShowFullPath={isShowAPart} + variables={value} + /> + </span> + ) + }) return html })() - return ( - <div className={cn('text-xs break-all', className)}> - {res} - </div> - ) + return <div className={cn('text-xs break-all', className)}>{res}</div> } export default React.memo(ReadonlyInputWithSelectVar) diff --git a/web/app/components/workflow/nodes/_base/components/remove-button.tsx b/web/app/components/workflow/nodes/_base/components/remove-button.tsx index 0a3b756c2bc7db..4aa63d744441ef 100644 --- a/web/app/components/workflow/nodes/_base/components/remove-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/remove-button.tsx @@ -9,11 +9,13 @@ type Props = Readonly<{ onClick: (e: React.MouseEvent) => void }> -const Remove: FC<Props> = ({ - onClick, -}) => { +const Remove: FC<Props> = ({ onClick }) => { return ( - <ActionButton size="l" className="group shrink-0 hover:bg-state-destructive-hover!" onClick={onClick}> + <ActionButton + size="l" + className="group shrink-0 hover:bg-state-destructive-hover!" + onClick={onClick} + > <RiDeleteBinLine className="size-4 text-text-tertiary group-hover:text-text-destructive" /> </ActionButton> ) diff --git a/web/app/components/workflow/nodes/_base/components/remove-effect-var-confirm.tsx b/web/app/components/workflow/nodes/_base/components/remove-effect-var-confirm.tsx index 7a4a5d5d867c8d..1da22c8f4ec5bc 100644 --- a/web/app/components/workflow/nodes/_base/components/remove-effect-var-confirm.tsx +++ b/web/app/components/workflow/nodes/_base/components/remove-effect-var-confirm.tsx @@ -19,17 +19,13 @@ type Props = Readonly<{ }> const i18nPrefix = 'common.effectVarConfirm' -const RemoveVarConfirm: FC<Props> = ({ - isShow, - onConfirm, - onCancel, -}) => { +const RemoveVarConfirm: FC<Props> = ({ isShow, onConfirm, onCancel }) => { const { t } = useTranslation() - const title = t($ => $[`${i18nPrefix}.title`], { ns: 'workflow' }) - const content = t($ => $[`${i18nPrefix}.content`], { ns: 'workflow' }) + const title = t(($) => $[`${i18nPrefix}.title`], { ns: 'workflow' }) + const content = t(($) => $[`${i18nPrefix}.content`], { ns: 'workflow' }) return ( - <AlertDialog open={isShow} onOpenChange={open => !open && onCancel()}> + <AlertDialog open={isShow} onOpenChange={(open) => !open && onCancel()}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> @@ -41,10 +37,10 @@ const RemoveVarConfirm: FC<Props> = ({ </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={onConfirm}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/workflow/nodes/_base/components/retry/hooks.ts b/web/app/components/workflow/nodes/_base/components/retry/hooks.ts index c3ebfbb56043a4..17dba94a249255 100644 --- a/web/app/components/workflow/nodes/_base/components/retry/hooks.ts +++ b/web/app/components/workflow/nodes/_base/components/retry/hooks.ts @@ -1,24 +1,21 @@ import type { WorkflowRetryConfig } from './types' -import { - useCallback, -} from 'react' -import { - useNodeDataUpdate, -} from '@/app/components/workflow/hooks' +import { useCallback } from 'react' +import { useNodeDataUpdate } from '@/app/components/workflow/hooks' -export const useRetryConfig = ( - id: string, -) => { +export const useRetryConfig = (id: string) => { const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() - const handleRetryConfigChange = useCallback((value?: WorkflowRetryConfig) => { - handleNodeDataUpdateWithSyncDraft({ - id, - data: { - retry_config: value, - }, - }) - }, [id, handleNodeDataUpdateWithSyncDraft]) + const handleRetryConfigChange = useCallback( + (value?: WorkflowRetryConfig) => { + handleNodeDataUpdateWithSyncDraft({ + id, + data: { + retry_config: value, + }, + }) + }, + [id, handleNodeDataUpdateWithSyncDraft], + ) return { handleRetryConfigChange, diff --git a/web/app/components/workflow/nodes/_base/components/retry/retry-on-node.tsx b/web/app/components/workflow/nodes/_base/components/retry/retry-on-node.tsx index eb3dab21307847..cf45216ba9a048 100644 --- a/web/app/components/workflow/nodes/_base/components/retry/retry-on-node.tsx +++ b/web/app/components/workflow/nodes/_base/components/retry/retry-on-node.tsx @@ -1,27 +1,16 @@ import type { Node } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - RiAlertFill, - RiCheckboxCircleFill, - RiLoader2Line, -} from '@remixicon/react' +import { RiAlertFill, RiCheckboxCircleFill, RiLoader2Line } from '@remixicon/react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { NodeRunningStatus } from '@/app/components/workflow/types' type RetryOnNodeProps = Pick<Node, 'id' | 'data'> -const RetryOnNode = ({ - data, -}: RetryOnNodeProps) => { +const RetryOnNode = ({ data }: RetryOnNodeProps) => { const { t } = useTranslation() const { retry_config } = data const showSelectedBorder = data.selected || data._isBundled || data._isEntering - const { - isRunning, - isSuccessful, - isException, - isFailed, - } = useMemo(() => { + const { isRunning, isSuccessful, isException, isFailed } = useMemo(() => { return { isRunning: data._runningStatus === NodeRunningStatus.Running && !showSelectedBorder, isSuccessful: data._runningStatus === NodeRunningStatus.Succeeded && !showSelectedBorder, @@ -31,61 +20,51 @@ const RetryOnNode = ({ }, [data._runningStatus, showSelectedBorder]) const showDefault = !isRunning && !isSuccessful && !isException && !isFailed - if (!retry_config?.retry_enabled) - return null + if (!retry_config?.retry_enabled) return null - if (!showDefault && !data._retryIndex) - return null + if (!showDefault && !data._retryIndex) return null return ( <div className="mb-1 px-3"> - <div className={cn( - 'flex items-center justify-between rounded-md border-[0.5px] border-transparent bg-workflow-block-parma-bg px-[5px] py-1 system-xs-medium-uppercase text-text-tertiary', - isRunning && 'border-state-accent-active bg-state-accent-hover text-text-accent', - isSuccessful && 'border-state-success-active bg-state-success-hover text-text-success', - (isException || isFailed) && 'border-state-warning-active bg-state-warning-hover text-text-warning', - )} + <div + className={cn( + 'flex items-center justify-between rounded-md border-[0.5px] border-transparent bg-workflow-block-parma-bg px-[5px] py-1 system-xs-medium-uppercase text-text-tertiary', + isRunning && 'border-state-accent-active bg-state-accent-hover text-text-accent', + isSuccessful && 'border-state-success-active bg-state-success-hover text-text-success', + (isException || isFailed) && + 'border-state-warning-active bg-state-warning-hover text-text-warning', + )} > <div className="flex items-center"> - { - showDefault && ( - t($ => $['nodes.common.retry.retryTimes'], { ns: 'workflow', times: retry_config.max_retries }) - ) - } - { - isRunning && ( - <> - <RiLoader2Line className="mr-1 size-3.5 animate-spin" /> - {t($ => $['nodes.common.retry.retrying'], { ns: 'workflow' })} - </> - ) - } - { - isSuccessful && ( - <> - <RiCheckboxCircleFill className="mr-1 size-3.5" /> - {t($ => $['nodes.common.retry.retrySuccessful'], { ns: 'workflow' })} - </> - ) - } - { - (isFailed || isException) && ( - <> - <RiAlertFill className="mr-1 size-3.5" /> - {t($ => $['nodes.common.retry.retryFailed'], { ns: 'workflow' })} - </> - ) - } + {showDefault && + t(($) => $['nodes.common.retry.retryTimes'], { + ns: 'workflow', + times: retry_config.max_retries, + })} + {isRunning && ( + <> + <RiLoader2Line className="mr-1 size-3.5 animate-spin" /> + {t(($) => $['nodes.common.retry.retrying'], { ns: 'workflow' })} + </> + )} + {isSuccessful && ( + <> + <RiCheckboxCircleFill className="mr-1 size-3.5" /> + {t(($) => $['nodes.common.retry.retrySuccessful'], { ns: 'workflow' })} + </> + )} + {(isFailed || isException) && ( + <> + <RiAlertFill className="mr-1 size-3.5" /> + {t(($) => $['nodes.common.retry.retryFailed'], { ns: 'workflow' })} + </> + )} </div> - { - !showDefault && !!data._retryIndex && ( - <div> - {data._retryIndex} - / - {data.retry_config?.max_retries} - </div> - ) - } + {!showDefault && !!data._retryIndex && ( + <div> + {data._retryIndex}/{data.retry_config?.max_retries} + </div> + )} </div> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/retry/retry-on-panel.tsx b/web/app/components/workflow/nodes/_base/components/retry/retry-on-panel.tsx index 6b5cedafa40949..abfd95b59c0c42 100644 --- a/web/app/components/workflow/nodes/_base/components/retry/retry-on-panel.tsx +++ b/web/app/components/workflow/nodes/_base/components/retry/retry-on-panel.tsx @@ -1,6 +1,4 @@ -import type { - Node, -} from '@/app/components/workflow/types' +import type { Node } from '@/app/components/workflow/types' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' import { Slider } from '@langgenius/dify-ui/slider' import { Switch } from '@langgenius/dify-ui/switch' @@ -11,15 +9,12 @@ import { useRetryConfig } from './hooks' import s from './style.module.css' type RetryOnPanelProps = Pick<Node, 'id' | 'data'> -const RetryOnPanel = ({ - id, - data, -}: RetryOnPanelProps) => { +const RetryOnPanel = ({ id, data }: RetryOnPanelProps) => { const { t } = useTranslation() const { handleRetryConfigChange } = useRetryConfig(id) const { retry_config } = data - const maxRetriesLabel = t($ => $['nodes.common.retry.maxRetries'], { ns: 'workflow' }) - const retryIntervalLabel = t($ => $['nodes.common.retry.retryInterval'], { ns: 'workflow' }) + const maxRetriesLabel = t(($) => $['nodes.common.retry.maxRetries'], { ns: 'workflow' }) + const retryIntervalLabel = t(($) => $['nodes.common.retry.retryInterval'], { ns: 'workflow' }) const handleRetryEnabledChange = (value: boolean) => { handleRetryConfigChange({ @@ -30,10 +25,8 @@ const RetryOnPanel = ({ } const handleMaxRetriesChange = (value: number) => { - if (value > 10) - value = 10 - else if (value < 1) - value = 1 + if (value > 10) value = 10 + else if (value < 1) value = 1 handleRetryConfigChange({ retry_enabled: true, max_retries: value, @@ -42,10 +35,8 @@ const RetryOnPanel = ({ } const handleRetryIntervalChange = (value: number) => { - if (value > 5000) - value = 5000 - else if (value < 100) - value = 100 + if (value > 5000) value = 5000 + else if (value < 100) value = 100 handleRetryConfigChange({ retry_enabled: true, max_retries: retry_config?.max_retries || 3, @@ -58,67 +49,73 @@ const RetryOnPanel = ({ <div className="pt-2"> <div className="flex h-10 items-center justify-between px-4 py-2"> <div className="flex items-center"> - <div className="mr-0.5 system-sm-semibold-uppercase text-text-secondary">{t($ => $['nodes.common.retry.retryOnFailure'], { ns: 'workflow' })}</div> + <div className="mr-0.5 system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['nodes.common.retry.retryOnFailure'], { ns: 'workflow' })} + </div> </div> <Switch checked={retry_config?.retry_enabled ?? false} - onCheckedChange={v => handleRetryEnabledChange(v)} + onCheckedChange={(v) => handleRetryEnabledChange(v)} /> </div> - { - retry_config?.retry_enabled && ( - <div className="px-4 pb-2"> - <Fieldset className="mb-1 flex w-full items-center"> - <FieldsetLegend className="sr-only">{maxRetriesLabel}</FieldsetLegend> - <div className="mr-2 grow system-xs-medium-uppercase text-text-secondary">{maxRetriesLabel}</div> - <Slider - className="mr-3 w-[108px]" - value={retry_config?.max_retries || 3} - onValueChange={handleMaxRetriesChange} - min={1} - max={10} - aria-label={maxRetriesLabel} - /> - <Input - aria-label={maxRetriesLabel} - type="number" - wrapperClassName="w-[100px]" - value={retry_config?.max_retries || 3} - onChange={e => - handleMaxRetriesChange(Number.parseInt(e.currentTarget.value, 10) || 3)} - min={1} - max={10} - unit={t($ => $['nodes.common.retry.times'], { ns: 'workflow' }) || ''} - className={s.input} - /> - </Fieldset> - <Fieldset className="flex items-center"> - <FieldsetLegend className="sr-only">{retryIntervalLabel}</FieldsetLegend> - <div className="mr-2 grow system-xs-medium-uppercase text-text-secondary">{retryIntervalLabel}</div> - <Slider - className="mr-3 w-[108px]" - value={retry_config?.retry_interval || 1000} - onValueChange={handleRetryIntervalChange} - min={100} - max={5000} - aria-label={retryIntervalLabel} - /> - <Input - aria-label={retryIntervalLabel} - type="number" - wrapperClassName="w-[100px]" - value={retry_config?.retry_interval || 1000} - onChange={e => - handleRetryIntervalChange(Number.parseInt(e.currentTarget.value, 10) || 1000)} - min={100} - max={5000} - unit={t($ => $['nodes.common.retry.ms'], { ns: 'workflow' }) || ''} - className={s.input} - /> - </Fieldset> - </div> - ) - } + {retry_config?.retry_enabled && ( + <div className="px-4 pb-2"> + <Fieldset className="mb-1 flex w-full items-center"> + <FieldsetLegend className="sr-only">{maxRetriesLabel}</FieldsetLegend> + <div className="mr-2 grow system-xs-medium-uppercase text-text-secondary"> + {maxRetriesLabel} + </div> + <Slider + className="mr-3 w-[108px]" + value={retry_config?.max_retries || 3} + onValueChange={handleMaxRetriesChange} + min={1} + max={10} + aria-label={maxRetriesLabel} + /> + <Input + aria-label={maxRetriesLabel} + type="number" + wrapperClassName="w-[100px]" + value={retry_config?.max_retries || 3} + onChange={(e) => + handleMaxRetriesChange(Number.parseInt(e.currentTarget.value, 10) || 3) + } + min={1} + max={10} + unit={t(($) => $['nodes.common.retry.times'], { ns: 'workflow' }) || ''} + className={s.input} + /> + </Fieldset> + <Fieldset className="flex items-center"> + <FieldsetLegend className="sr-only">{retryIntervalLabel}</FieldsetLegend> + <div className="mr-2 grow system-xs-medium-uppercase text-text-secondary"> + {retryIntervalLabel} + </div> + <Slider + className="mr-3 w-[108px]" + value={retry_config?.retry_interval || 1000} + onValueChange={handleRetryIntervalChange} + min={100} + max={5000} + aria-label={retryIntervalLabel} + /> + <Input + aria-label={retryIntervalLabel} + type="number" + wrapperClassName="w-[100px]" + value={retry_config?.retry_interval || 1000} + onChange={(e) => + handleRetryIntervalChange(Number.parseInt(e.currentTarget.value, 10) || 1000) + } + min={100} + max={5000} + unit={t(($) => $['nodes.common.retry.ms'], { ns: 'workflow' }) || ''} + className={s.input} + /> + </Fieldset> + </div> + )} </div> <Split className="mx-4 mt-2" /> </> diff --git a/web/app/components/workflow/nodes/_base/components/selector.tsx b/web/app/components/workflow/nodes/_base/components/selector.tsx index 03a7b1c2ae7c99..f072eff6073cd7 100644 --- a/web/app/components/workflow/nodes/_base/components/selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/selector.tsx @@ -46,46 +46,69 @@ const TypeSelector: FC<Props> = ({ showChecked, }) => { const noValue = value === '' || value === undefined || value === null - const item = allOptions ? allOptions.find(item => item.value === value) : list.find(item => item.value === value) + const item = allOptions + ? allOptions.find((item) => item.value === value) + : list.find((item) => item.value === value) const [showOption, { setFalse: setHide, toggle: toggleShow }] = useBoolean(false) const ref = React.useRef(null) useClickAway(() => { setHide() }, ref) return ( - <div className={cn(!trigger && !noLeft && 'left-[-8px]', 'relative select-none', className)} ref={ref}> - {trigger - ? ( - <div - onClick={toggleShow} - className={cn(!readonly && 'cursor-pointer')} - > - {trigger} - </div> - ) - : ( - <div - onClick={toggleShow} - className={cn(showOption && 'bg-state-base-hover', 'flex h-5 cursor-pointer items-center rounded-md pr-0.5 pl-1 text-xs font-semibold text-text-secondary hover:bg-state-base-hover')} - > - <div className={cn('text-sm font-semibold', uppercase && 'uppercase', noValue && 'text-text-tertiary', triggerClassName)}>{!noValue ? item?.label : placeholder}</div> - {!readonly && <DropDownIcon className="size-3" />} - </div> + <div + className={cn(!trigger && !noLeft && 'left-[-8px]', 'relative select-none', className)} + ref={ref} + > + {trigger ? ( + <div onClick={toggleShow} className={cn(!readonly && 'cursor-pointer')}> + {trigger} + </div> + ) : ( + <div + onClick={toggleShow} + className={cn( + showOption && 'bg-state-base-hover', + 'flex h-5 cursor-pointer items-center rounded-md pr-0.5 pl-1 text-xs font-semibold text-text-secondary hover:bg-state-base-hover', )} + > + <div + className={cn( + 'text-sm font-semibold', + uppercase && 'uppercase', + noValue && 'text-text-tertiary', + triggerClassName, + )} + > + {!noValue ? item?.label : placeholder} + </div> + {!readonly && <DropDownIcon className="size-3" />} + </div> + )} - {(showOption && !readonly) && ( - <div className={cn('absolute top-[24px] z-10 w-[120px] rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg select-none', popupClassName)}> - {list.map(item => ( + {showOption && !readonly && ( + <div + className={cn( + 'absolute top-[24px] z-10 w-[120px] rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg select-none', + popupClassName, + )} + > + {list.map((item) => ( <div key={item.value} onClick={() => { setHide() onChange(item.value) }} - className={cn(itemClassName, uppercase && 'uppercase', 'flex h-[30px] min-w-[44px] cursor-pointer items-center justify-between rounded-lg px-3 text-[13px] font-medium text-text-secondary hover:bg-state-base-hover')} + className={cn( + itemClassName, + uppercase && 'uppercase', + 'flex h-[30px] min-w-[44px] cursor-pointer items-center justify-between rounded-lg px-3 text-[13px] font-medium text-text-secondary hover:bg-state-base-hover', + )} > <div>{item.label}</div> - {showChecked && item.value === value && <Check className="size-4 text-text-primary" />} + {showChecked && item.value === value && ( + <Check className="size-4 text-text-primary" /> + )} </div> ))} </div> diff --git a/web/app/components/workflow/nodes/_base/components/setting-item.tsx b/web/app/components/workflow/nodes/_base/components/setting-item.tsx index 9a60e7fc579fd1..f31ed73672ea98 100644 --- a/web/app/components/workflow/nodes/_base/components/setting-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/setting-item.tsx @@ -12,25 +12,29 @@ type SettingItemProps = PropsWithChildren<{ }> export const SettingItem = memo(({ label, children, status, tooltip }: SettingItemProps) => { - const indicator: StatusDotStatus | undefined = status === 'error' ? 'error' : status === 'warning' ? 'warning' : undefined + const indicator: StatusDotStatus | undefined = + status === 'error' ? 'error' : status === 'warning' ? 'warning' : undefined const needTooltip = ['error', 'warning'].includes(status as any) return ( <div className="relative flex items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1.5 py-1 text-xs font-normal"> - <div className={cn('max-w-full shrink-0 truncate system-xs-medium-uppercase text-text-tertiary', !!children && 'max-w-[100px]')}> + <div + className={cn( + 'max-w-full shrink-0 truncate system-xs-medium-uppercase text-text-tertiary', + !!children && 'max-w-[100px]', + )} + > {label} </div> <Tooltip> <TooltipTrigger disabled={!needTooltip} - render={( + render={ <div className="truncate text-right system-xs-medium text-text-secondary"> {children} </div> - )} + } /> - <TooltipContent> - {tooltip} - </TooltipContent> + <TooltipContent>{tooltip}</TooltipContent> </Tooltip> {indicator && <StatusDot status={indicator} className="absolute -top-0.5 -right-0.5" />} </div> diff --git a/web/app/components/workflow/nodes/_base/components/split.tsx b/web/app/components/workflow/nodes/_base/components/split.tsx index 847e709e78837a..4275803e4cd59c 100644 --- a/web/app/components/workflow/nodes/_base/components/split.tsx +++ b/web/app/components/workflow/nodes/_base/components/split.tsx @@ -7,12 +7,7 @@ type Props = Readonly<{ className?: string }> -const Split: FC<Props> = ({ - className, -}) => { - return ( - <div className={cn(className, 'h-[0.5px] bg-divider-subtle')}> - </div> - ) +const Split: FC<Props> = ({ className }) => { + return <div className={cn(className, 'h-[0.5px] bg-divider-subtle')}></div> } export default React.memo(Split) diff --git a/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx b/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx index bf8e4323be49cb..55d0c028753c4d 100644 --- a/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx +++ b/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx @@ -29,7 +29,8 @@ export const SwitchPluginVersion: FC<SwitchPluginVersionProps> = (props) => { const [pluginId] = uniqueIdentifier?.split(':') || [''] const [isShow, setIsShow] = useState(false) - const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = useBoolean(false) + const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = + useBoolean(false) const [target, setTarget] = useState<{ version: string pluginUniqueIden: string @@ -47,7 +48,9 @@ export const SwitchPluginVersion: FC<SwitchPluginVersionProps> = (props) => { onChange?.(target!.version) }, [hideUpdateModal, onChange, pluginDetails, target]) const { getIconUrl } = useGetIcon() - const icon = pluginDetail?.declaration.icon ? getIconUrl(pluginDetail.declaration.icon) : undefined + const icon = pluginDetail?.declaration.icon + ? getIconUrl(pluginDetail.declaration.icon) + : undefined const mutation = useUpdatePackageFromMarketPlace() const install = () => { mutation.mutate( @@ -65,11 +68,13 @@ export const SwitchPluginVersion: FC<SwitchPluginVersionProps> = (props) => { const { t } = useTranslation() // Guard against null/undefined uniqueIdentifier to prevent app crash - if (!uniqueIdentifier || !pluginId || !canUpdatePlugin) - return null + if (!uniqueIdentifier || !pluginId || !canUpdatePlugin) return null const content = ( - <div className={cn('flex w-fit items-center justify-center', className)} onClick={e => e.stopPropagation()}> + <div + className={cn('flex w-fit items-center justify-center', className)} + onClick={(e) => e.stopPropagation()} + > {isShowUpdateModal && pluginDetail && ( <PluginMutationModel onCancel={hideUpdateModal} @@ -79,30 +84,32 @@ export const SwitchPluginVersion: FC<SwitchPluginVersionProps> = (props) => { })} mutation={mutation} mutate={install} - confirmButtonText={t($ => $['nodes.agent.installPlugin.install'], { ns: 'workflow' })} - cancelButtonText={t($ => $['nodes.agent.installPlugin.cancel'], { ns: 'workflow' })} - modelTitle={t($ => $['nodes.agent.installPlugin.title'], { ns: 'workflow' })} - description={t($ => $['nodes.agent.installPlugin.desc'], { ns: 'workflow' })} - cardTitleLeft={( + confirmButtonText={t(($) => $['nodes.agent.installPlugin.install'], { ns: 'workflow' })} + cancelButtonText={t(($) => $['nodes.agent.installPlugin.cancel'], { ns: 'workflow' })} + modelTitle={t(($) => $['nodes.agent.installPlugin.title'], { ns: 'workflow' })} + description={t(($) => $['nodes.agent.installPlugin.desc'], { ns: 'workflow' })} + cardTitleLeft={ <> <Badge2 className="mx-1" size="s" state={BadgeState.Warning}> {`${pluginDetail.version} -> ${target!.version}`} </Badge2> </> - )} - modalBottomLeft={( + } + modalBottomLeft={ <Link className="flex items-center justify-center gap-1" - href={getMarketplaceUrl(`/plugins/${pluginDetail.declaration.author}/${pluginDetail.declaration.name}`)} + href={getMarketplaceUrl( + `/plugins/${pluginDetail.declaration.author}/${pluginDetail.declaration.name}`, + )} target="_blank" rel="noopener noreferrer" > <span className="system-xs-regular text-xs text-text-accent"> - {t($ => $['nodes.agent.installPlugin.changelog'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.installPlugin.changelog'], { ns: 'workflow' })} </span> <span className="i-ri-external-link-line size-3 text-text-accent" /> </Link> - )} + } /> )} {pluginDetail && ( @@ -118,36 +125,36 @@ export const SwitchPluginVersion: FC<SwitchPluginVersionProps> = (props) => { }) showUpdateModal() }} - trigger={( + trigger={ <Badge - className={cn( - 'mx-1 flex hover:bg-state-base-hover', - isShow && 'bg-state-base-hover', - )} + className={cn('mx-1 flex hover:bg-state-base-hover', isShow && 'bg-state-base-hover')} uppercase={true} - text={( + text={ <> <div>{pluginDetail.version}</div> <span className="ml-1 i-ri-arrow-left-right-line size-3 text-text-tertiary" /> </> - )} + } hasRedCornerMark={true} /> - )} + } /> )} </div> ) - if (!tooltip || isShow || isShowUpdateModal) - return content + if (!tooltip || isShow || isShowUpdateModal) return content return ( <Popover> <PopoverTrigger openOnHover nativeButton={false} - aria-label={typeof tooltip === 'string' ? tooltip : t($ => $['nodes.agent.installPlugin.title'], { ns: 'workflow' })} + aria-label={ + typeof tooltip === 'string' + ? tooltip + : t(($) => $['nodes.agent.installPlugin.title'], { ns: 'workflow' }) + } render={content} /> <PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary"> diff --git a/web/app/components/workflow/nodes/_base/components/title-description-input.tsx b/web/app/components/workflow/nodes/_base/components/title-description-input.tsx index 4ed024fbfc63bd..31a51c9679d81d 100644 --- a/web/app/components/workflow/nodes/_base/components/title-description-input.tsx +++ b/web/app/components/workflow/nodes/_base/components/title-description-input.tsx @@ -1,9 +1,4 @@ -import { - memo, - useCallback, - useEffect, - useState, -} from 'react' +import { memo, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Textarea from 'react-textarea-autosize' @@ -12,10 +7,7 @@ type TitleInputProps = { onBlur: (value: string) => void } -export const TitleInput = memo(({ - value, - onBlur, -}: TitleInputProps) => { +export const TitleInput = memo(({ value, onBlur }: TitleInputProps) => { const { t } = useTranslation() const [localValue, setLocalValue] = useState(value) @@ -52,11 +44,8 @@ export const TitleInput = memo(({ value={localValue} onChange={handleChange} onKeyDown={handleKeyDown} - className={` - mr-2 h-7 min-w-0 grow appearance-none rounded-md border border-transparent bg-transparent px-1 system-xl-semibold text-text-primary - outline-hidden focus:shadow-xs - `} - placeholder={t($ => $['common.addTitle'], { ns: 'workflow' }) || ''} + className={`mr-2 h-7 min-w-0 grow appearance-none rounded-md border border-transparent bg-transparent px-1 system-xl-semibold text-text-primary outline-hidden focus:shadow-xs`} + placeholder={t(($) => $['common.addTitle'], { ns: 'workflow' }) || ''} onBlur={handleBlur} /> ) @@ -67,10 +56,7 @@ type DescriptionInputProps = { value: string onChange: (value: string) => void } -export const DescriptionInput = memo(({ - value, - onChange, -}: DescriptionInputProps) => { +export const DescriptionInput = memo(({ value, onChange }: DescriptionInputProps) => { const { t } = useTranslation() const [focus, setFocus] = useState(false) const handleFocus = useCallback(() => { @@ -82,24 +68,16 @@ export const DescriptionInput = memo(({ return ( <div - className={` - group flex max-h-[60px] overflow-y-auto rounded-lg bg-components-panel-bg px-2 - py-[5px] leading-0 - ${focus && 'shadow-xs!'} - `} + className={`group flex max-h-[60px] overflow-y-auto rounded-lg bg-components-panel-bg px-2 py-[5px] leading-0 ${focus && 'shadow-xs!'} `} > <Textarea value={value} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} minRows={1} onFocus={handleFocus} onBlur={handleBlur} - className={` - w-full resize-none appearance-none bg-transparent text-xs - leading-[18px] text-text-primary caret-[#295EFF] - outline-hidden placeholder:text-text-quaternary - `} - placeholder={t($ => $['common.addDescription'], { ns: 'workflow' }) || ''} + className={`w-full resize-none appearance-none bg-transparent text-xs leading-[18px] text-text-primary caret-[#295EFF] outline-hidden placeholder:text-text-quaternary`} + placeholder={t(($) => $['common.addDescription'], { ns: 'workflow' }) || ''} /> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/toggle-expand-btn.tsx b/web/app/components/workflow/nodes/_base/components/toggle-expand-btn.tsx index 3a0bda6578ea4b..7c706c585c9722 100644 --- a/web/app/components/workflow/nodes/_base/components/toggle-expand-btn.tsx +++ b/web/app/components/workflow/nodes/_base/components/toggle-expand-btn.tsx @@ -1,9 +1,6 @@ 'use client' import type { FC } from 'react' -import { - RiCollapseDiagonalLine, - RiExpandDiagonalLine, -} from '@remixicon/react' +import { RiCollapseDiagonalLine, RiExpandDiagonalLine } from '@remixicon/react' import * as React from 'react' import { useCallback } from 'react' import ActionButton from '@/app/components/base/action-button' @@ -13,10 +10,7 @@ type Props = Readonly<{ onExpandChange: (isExpand: boolean) => void }> -const ExpandBtn: FC<Props> = ({ - isExpand, - onExpandChange, -}) => { +const ExpandBtn: FC<Props> = ({ isExpand, onExpandChange }) => { const handleToggle = useCallback(() => { onExpandChange(!isExpand) }, [isExpand]) diff --git a/web/app/components/workflow/nodes/_base/components/variable-tag.tsx b/web/app/components/workflow/nodes/_base/components/variable-tag.tsx index 39c8a6535d7afe..413fd456532ef0 100644 --- a/web/app/components/workflow/nodes/_base/components/variable-tag.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable-tag.tsx @@ -1,16 +1,16 @@ -import type { - CommonNodeType, - Node, - ValueSelector, - VarType, -} from '@/app/components/workflow/types' +import type { CommonNodeType, Node, ValueSelector, VarType } from '@/app/components/workflow/types' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useNodes, useReactFlow, useStoreApi } from 'reactflow' -import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' import { - VariableLabelInSelect, -} from '@/app/components/workflow/nodes/_base/components/variable/variable-label' + getNodeInfoById, + isConversationVar, + isENV, + isGlobalVar, + isRagVariableVar, + isSystemVar, +} from '@/app/components/workflow/nodes/_base/components/variable/utils' +import { VariableLabelInSelect } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { BlockEnum } from '@/app/components/workflow/types' import { isExceptionVariable } from '@/app/components/workflow/utils' @@ -20,21 +20,18 @@ type VariableTagProps = { isShort?: boolean availableNodes?: Node[] } -const VariableTag = ({ - valueSelector, - varType, - isShort, - availableNodes, -}: VariableTagProps) => { +const VariableTag = ({ valueSelector, varType, isShort, availableNodes }: VariableTagProps) => { const nodes = useNodes<CommonNodeType>() const isRagVar = isRagVariableVar(valueSelector) const node = useMemo(() => { if (isSystemVar(valueSelector)) { - const startNode = availableNodes?.find(n => n.data.type === BlockEnum.Start) - if (startNode) - return startNode + const startNode = availableNodes?.find((n) => n.data.type === BlockEnum.Start) + if (startNode) return startNode } - return getNodeInfoById(availableNodes || nodes, isRagVar ? valueSelector[1]! : valueSelector[0]!) + return getNodeInfoById( + availableNodes || nodes, + isRagVar ? valueSelector[1]! : valueSelector[0]!, + ) }, [nodes, valueSelector, availableNodes, isRagVar]) const isEnv = isENV(valueSelector) @@ -42,7 +39,9 @@ const VariableTag = ({ const isGlobal = isGlobalVar(valueSelector) const isValid = Boolean(node) || isEnv || isChatVar || isRagVar || isGlobal - const variableName = isSystemVar(valueSelector) ? valueSelector.slice(0).join('.') : valueSelector.slice(1).join('.') + const variableName = isSystemVar(valueSelector) + ? valueSelector.slice(0).join('.') + : valueSelector.slice(1).join('.') const isException = isExceptionVariable(variableName, node?.data.type) const reactflow = useReactFlow() @@ -50,14 +49,9 @@ const VariableTag = ({ const handleVariableJump = useCallback(() => { const workflowContainer = document.getElementById('workflow-container') - const { - clientWidth, - clientHeight, - } = workflowContainer! + const { clientWidth, clientHeight } = workflowContainer! - const { - setViewport, - } = reactflow + const { setViewport } = reactflow const { transform } = store.getState() const zoom = transform[2] const position = node.position @@ -81,7 +75,7 @@ const VariableTag = ({ handleVariableJump() } }} - errorMsg={!isValid ? t($ => $['errorMsg.invalidVariable'], { ns: 'workflow' }) : undefined} + errorMsg={!isValid ? t(($) => $['errorMsg.invalidVariable'], { ns: 'workflow' }) : undefined} isExceptionVariable={isException} /> ) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/match-schema-type.spec.ts b/web/app/components/workflow/nodes/_base/components/variable/__tests__/match-schema-type.spec.ts index 0330ae47fcdff1..6c5c93f4c7aa46 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/match-schema-type.spec.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/match-schema-type.spec.ts @@ -11,13 +11,21 @@ describe('match the schema type', () => { }) it('should ignore values and only compare types', () => { - expect(matchTheSchemaType({ type: 'string', value: 'hello' }, { type: 'string', value: 'world' })).toBe(true) - expect(matchTheSchemaType({ type: 'number', value: 42 }, { type: 'number', value: 100 })).toBe(true) + expect( + matchTheSchemaType({ type: 'string', value: 'hello' }, { type: 'string', value: 'world' }), + ).toBe(true) + expect(matchTheSchemaType({ type: 'number', value: 42 }, { type: 'number', value: 100 })).toBe( + true, + ) }) it('should return true for structural differences but no types', () => { - expect(matchTheSchemaType({ type: 'string', other: { b: 'xxx' } }, { type: 'string', other: 'xxx' })).toBe(true) - expect(matchTheSchemaType({ type: 'string', other: { b: 'xxx' } }, { type: 'string' })).toBe(true) + expect( + matchTheSchemaType({ type: 'string', other: { b: 'xxx' } }, { type: 'string', other: 'xxx' }), + ).toBe(true) + expect(matchTheSchemaType({ type: 'string', other: { b: 'xxx' } }, { type: 'string' })).toBe( + true, + ) }) it('should handle nested objects with same structure and types', () => { @@ -111,9 +119,7 @@ describe('match the schema type', () => { description: 'file related id', }, }, - required: [ - 'name', - ], + required: ['name'], } const file = { type: 'object', @@ -153,9 +159,7 @@ describe('match the schema type', () => { description: 'file related id', }, }, - required: [ - 'name', - ], + required: ['name'], } expect(matchTheSchemaType(fileSchema, file)).toBe(true) }) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/output-var-list.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/output-var-list.spec.tsx index 97b6e52ddca25a..d22420edf580e0 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/output-var-list.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/output-var-list.spec.tsx @@ -3,11 +3,11 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import OutputVarList from '../output-var-list' vi.mock('../var-type-picker', () => ({ - default: (props: { value: string, onChange: (v: string) => void, readonly: boolean }) => ( + default: (props: { value: string; onChange: (v: string) => void; readonly: boolean }) => ( <select data-testid="var-type-picker" value={props.value ?? ''} - onChange={e => props.onChange(e.target.value)} + onChange={(e) => props.onChange(e.target.value)} disabled={props.readonly} > <option value="string">string</option> @@ -43,7 +43,9 @@ describe('OutputVarList', () => { readonly={false} outputs={outputs} outputKeyOrders={outputKeyOrders} - onChange={(newOutputs) => { captured = newOutputs }} + onChange={(newOutputs) => { + captured = newOutputs + }} onRemove={vi.fn()} />, ) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/utils.spec.ts b/web/app/components/workflow/nodes/_base/components/variable/__tests__/utils.spec.ts index 8e72be3f6775da..0cb954ca8a514e 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/utils.spec.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/utils.spec.ts @@ -5,7 +5,13 @@ import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { Node, PromptItem } from '@/app/components/workflow/types' import { describe, expect, it } from 'vitest' import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types' -import { BlockEnum, EditionType, InputVarType, PromptRole, VarType } from '@/app/components/workflow/types' +import { + BlockEnum, + EditionType, + InputVarType, + PromptRole, + VarType, +} from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' import { getNodeUsedVars, toNodeAvailableVars, updateNodeVars } from '../utils' @@ -72,7 +78,7 @@ describe('variable utils', () => { }), ]), ) - expect(availableVars.find(item => item.nodeId === 'node-1')?.vars).not.toContainEqual({ + expect(availableVars.find((item) => item.nodeId === 'node-1')?.vars).not.toContainEqual({ variable: 'usage', type: VarType.object, }) @@ -119,7 +125,7 @@ describe('variable utils', () => { }), ]), ) - expect(availableVars.find(item => item.nodeId === 'node-1')?.vars).not.toContainEqual({ + expect(availableVars.find((item) => item.nodeId === 'node-1')?.vars).not.toContainEqual({ variable: 'text', type: VarType.string, }) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx index 6ed63f361eb94a..3bc817678638a5 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx @@ -2,15 +2,17 @@ import type { ComponentProps } from 'react' import type { FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { NodeOutPutVar } from '@/app/components/workflow/types' import { fireEvent, screen, waitFor } from '@testing-library/react' -import { createNode, createStartNode, resetFixtureCounters } from '@/app/components/workflow/__tests__/fixtures' +import { + createNode, + createStartNode, + resetFixtureCounters, +} from '@/app/components/workflow/__tests__/fixtures' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types' import { VarType as VarKindType } from '../../../../tool/types' import VarReferencePicker from '../var-reference-picker' -const { - mockFetchDynamicOptions, -} = vi.hoisted(() => ({ +const { mockFetchDynamicOptions } = vi.hoisted(() => ({ mockFetchDynamicOptions: vi.fn(), })) @@ -37,11 +39,21 @@ vi.mock('../var-reference-popup', () => ({ default: ({ onChange, }: { - onChange: (value: string[], item: { variable: string, type: VarType }) => void + onChange: (value: string[], item: { variable: string; type: VarType }) => void }) => ( <div> - <button onClick={() => onChange(['node-a', 'answer'], { variable: 'answer', type: VarType.string })}>select-normal</button> - <button onClick={() => onChange(['node-a', 'sys.query'], { variable: 'sys.query', type: VarType.string })}>select-system</button> + <button + onClick={() => onChange(['node-a', 'answer'], { variable: 'answer', type: VarType.string })} + > + select-normal + </button> + <button + onClick={() => + onChange(['node-a', 'sys.query'], { variable: 'sys.query', type: VarType.string }) + } + > + select-system + </button> </div> ), })) @@ -51,12 +63,14 @@ describe('VarReferencePicker branches', () => { id: 'start-node', data: { title: 'Start', - variables: [{ - variable: 'query', - label: 'Query', - type: InputVarType.textInput, - required: false, - }], + variables: [ + { + variable: 'query', + label: 'Query', + type: InputVarType.textInput, + required: false, + }, + ], }, }) const sourceNode = createNode({ @@ -77,13 +91,13 @@ describe('VarReferencePicker branches', () => { data: { type: BlockEnum.Code, title: 'Current Node' }, }) - const availableVars: NodeOutPutVar[] = [{ - nodeId: 'node-a', - title: 'Source Node', - vars: [ - { variable: 'answer', type: VarType.string }, - ], - }] + const availableVars: NodeOutPutVar[] = [ + { + nodeId: 'node-a', + title: 'Source Node', + vars: [{ variable: 'answer', type: VarType.string }], + }, + ] const renderPicker = (props: Partial<ComponentProps<typeof VarReferencePicker>> = {}) => { const onChange = vi.fn() @@ -174,11 +188,13 @@ describe('VarReferencePicker branches', () => { it('should fetch dynamic options for supported constant fields', async () => { mockFetchDynamicOptions.mockResolvedValueOnce({ - options: [{ - value: 'dyn-1', - label: { en_US: 'Dynamic 1', zh_Hans: '动态 1' }, - show_on: [], - }], + options: [ + { + value: 'dyn-1', + label: { en_US: 'Dynamic 1', zh_Hans: '动态 1' }, + show_on: [], + }, + ], }) renderPicker({ @@ -215,15 +231,19 @@ describe('VarReferencePicker branches', () => { }) it('should render tooltip branches for partial paths and invalid variables without changing behavior', () => { - const objectVars: NodeOutPutVar[] = [{ - nodeId: 'node-a', - title: 'Source Node', - vars: [{ - variable: 'payload', - type: VarType.object, - children: [{ variable: 'child', type: VarType.string }], - }], - }] + const objectVars: NodeOutPutVar[] = [ + { + nodeId: 'node-a', + title: 'Source Node', + vars: [ + { + variable: 'payload', + type: VarType.object, + children: [{ variable: 'child', type: VarType.string }], + }, + ], + }, + ] const { unmount } = renderPicker({ availableVars: objectVars, diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.helpers.spec.ts b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.helpers.spec.ts index 6b9ec7a6428e25..b5d21850b814c4 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.helpers.spec.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.helpers.spec.ts @@ -1,7 +1,16 @@ import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { CommonNodeType, Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' +import type { + CommonNodeType, + Node, + NodeOutPutVar, + ValueSelector, +} from '@/app/components/workflow/types' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { createLoopNode, createNode, createStartNode } from '@/app/components/workflow/__tests__/fixtures' +import { + createLoopNode, + createNode, + createStartNode, +} from '@/app/components/workflow/__tests__/fixtures' import { BlockEnum, VarType } from '@/app/components/workflow/types' import { getDynamicSelectSchema, @@ -34,74 +43,93 @@ describe('var-reference-picker.helpers', () => { it('should resolve output variable nodes for normal, system, iteration, and loop variables', () => { const startNode = createStartNode({ id: 'inset-s-1', data: { title: 'Start Node' } }) - const normalNode = createNode({ id: 'node-a', data: { type: BlockEnum.Code, title: 'Answer Node' } }) - const iterationNode = createNode({ id: 'iter-parent', data: { type: BlockEnum.Iteration, title: 'Iteration Parent' } }) as Node<CommonNodeType> - const loopNode = createLoopNode({ id: 'loop-parent', data: { title: 'Loop Parent' } }) as Node<CommonNodeType> + const normalNode = createNode({ + id: 'node-a', + data: { type: BlockEnum.Code, title: 'Answer Node' }, + }) + const iterationNode = createNode({ + id: 'iter-parent', + data: { type: BlockEnum.Iteration, title: 'Iteration Parent' }, + }) as Node<CommonNodeType> + const loopNode = createLoopNode({ + id: 'loop-parent', + data: { title: 'Loop Parent' }, + }) as Node<CommonNodeType> - expect(getOutputVarNode({ - availableNodes: [normalNode], - hasValue: true, - isConstant: false, - isIterationVar: false, - isLoopVar: false, - iterationNode: null, - loopNode: null, - outputVarNodeId: 'node-a', - startNode, - value: ['node-a', 'answer'], - })).toMatchObject({ id: 'node-a', title: 'Answer Node' }) + expect( + getOutputVarNode({ + availableNodes: [normalNode], + hasValue: true, + isConstant: false, + isIterationVar: false, + isLoopVar: false, + iterationNode: null, + loopNode: null, + outputVarNodeId: 'node-a', + startNode, + value: ['node-a', 'answer'], + }), + ).toMatchObject({ id: 'node-a', title: 'Answer Node' }) - expect(getOutputVarNode({ - availableNodes: [normalNode], - hasValue: true, - isConstant: false, - isIterationVar: false, - isLoopVar: false, - iterationNode: null, - loopNode: null, - outputVarNodeId: 'sys', - startNode, - value: ['sys', 'files'], - })).toEqual(startNode.data) + expect( + getOutputVarNode({ + availableNodes: [normalNode], + hasValue: true, + isConstant: false, + isIterationVar: false, + isLoopVar: false, + iterationNode: null, + loopNode: null, + outputVarNodeId: 'sys', + startNode, + value: ['sys', 'files'], + }), + ).toEqual(startNode.data) - expect(getOutputVarNode({ - availableNodes: [normalNode], - hasValue: true, - isConstant: false, - isIterationVar: true, - isLoopVar: false, - iterationNode, - loopNode: null, - outputVarNodeId: 'iter-parent', - startNode, - value: ['iter-parent', 'item'], - })).toEqual(iterationNode.data) + expect( + getOutputVarNode({ + availableNodes: [normalNode], + hasValue: true, + isConstant: false, + isIterationVar: true, + isLoopVar: false, + iterationNode, + loopNode: null, + outputVarNodeId: 'iter-parent', + startNode, + value: ['iter-parent', 'item'], + }), + ).toEqual(iterationNode.data) - expect(getOutputVarNode({ - availableNodes: [normalNode], - hasValue: true, - isConstant: false, - isIterationVar: false, - isLoopVar: true, - iterationNode: null, - loopNode, - outputVarNodeId: 'loop-parent', - startNode, - value: ['loop-parent', 'item'], - })).toEqual(loopNode.data) + expect( + getOutputVarNode({ + availableNodes: [normalNode], + hasValue: true, + isConstant: false, + isIterationVar: false, + isLoopVar: true, + iterationNode: null, + loopNode, + outputVarNodeId: 'loop-parent', + startNode, + value: ['loop-parent', 'item'], + }), + ).toEqual(loopNode.data) - expect(getOutputVarNode({ - availableNodes: [normalNode], - hasValue: true, - isConstant: false, - isIterationVar: false, - isLoopVar: false, - iterationNode: null, - loopNode: null, - outputVarNodeId: 'missing-node', - startNode, - value: ['missing-node', 'answer'], - })).toBeNull() + expect( + getOutputVarNode({ + availableNodes: [normalNode], + hasValue: true, + isConstant: false, + isIterationVar: false, + isLoopVar: false, + iterationNode: null, + loopNode: null, + outputVarNodeId: 'missing-node', + startNode, + value: ['missing-node', 'answer'], + }), + ).toBeNull() }) it('should format display names and output node ids correctly', () => { @@ -114,18 +142,22 @@ describe('var-reference-picker.helpers', () => { }) it('should derive variable meta and category from selectors', () => { - const envVars: NodeOutPutVar[] = [{ - nodeId: 'env', - title: 'ENVIRONMENT', - vars: [{ variable: 'env.API_KEY', type: VarType.string }], - }] + const envVars: NodeOutPutVar[] = [ + { + nodeId: 'env', + title: 'ENVIRONMENT', + vars: [{ variable: 'env.API_KEY', type: VarType.string }], + }, + ] const meta = getVariableMeta(null, ['env', 'API_KEY'], 'API_KEY', envVars, true) expect(meta).toMatchObject({ isEnv: true, isValidVar: true, isException: false, }) - expect(getVariableMeta(null, ['env', 'MISSING_KEY'], 'MISSING_KEY', envVars, true)).toMatchObject({ + expect( + getVariableMeta(null, ['env', 'MISSING_KEY'], 'MISSING_KEY', envVars, true), + ).toMatchObject({ isEnv: true, isValidVar: false, }) @@ -134,45 +166,55 @@ describe('var-reference-picker.helpers', () => { isValidVar: true, }) - expect(getVariableCategory({ - isChatVar: true, - isEnv: false, - isGlobal: false, - isLoopVar: false, - isRagVar: false, - })).toBe('conversation') + expect( + getVariableCategory({ + isChatVar: true, + isEnv: false, + isGlobal: false, + isLoopVar: false, + isRagVar: false, + }), + ).toBe('conversation') - expect(getVariableCategory({ - isChatVar: false, - isEnv: false, - isGlobal: true, - isLoopVar: false, - isRagVar: false, - })).toBe('global') + expect( + getVariableCategory({ + isChatVar: false, + isEnv: false, + isGlobal: true, + isLoopVar: false, + isRagVar: false, + }), + ).toBe('global') - expect(getVariableCategory({ - isChatVar: false, - isEnv: false, - isGlobal: false, - isLoopVar: true, - isRagVar: false, - })).toBe('loop') + expect( + getVariableCategory({ + isChatVar: false, + isEnv: false, + isGlobal: false, + isLoopVar: true, + isRagVar: false, + }), + ).toBe('loop') - expect(getVariableCategory({ - isChatVar: false, - isEnv: true, - isGlobal: false, - isLoopVar: false, - isRagVar: false, - })).toBe('environment') + expect( + getVariableCategory({ + isChatVar: false, + isEnv: true, + isGlobal: false, + isLoopVar: false, + isRagVar: false, + }), + ).toBe('environment') - expect(getVariableCategory({ - isChatVar: false, - isEnv: false, - isGlobal: false, - isLoopVar: false, - isRagVar: true, - })).toBe('rag') + expect( + getVariableCategory({ + isChatVar: false, + isEnv: false, + isGlobal: false, + isLoopVar: false, + isRagVar: true, + }), + ).toBe('rag') }) it('should calculate width allocations and tooltip behavior', () => { @@ -203,34 +245,42 @@ describe('var-reference-picker.helpers', () => { type: 'dynamic-select', } as Partial<CredentialFormSchema> - expect(getDynamicSelectSchema({ - dynamicOptions: [{ - value: 'a', - label: { en_US: 'A', zh_Hans: 'A' }, - show_on: [], - }], - isLoading: false, - schema, - value, - })).toMatchObject({ + expect( + getDynamicSelectSchema({ + dynamicOptions: [ + { + value: 'a', + label: { en_US: 'A', zh_Hans: 'A' }, + show_on: [], + }, + ], + isLoading: false, + schema, + value, + }), + ).toMatchObject({ options: [{ value: 'a' }], }) - expect(getDynamicSelectSchema({ - dynamicOptions: null, - isLoading: true, - schema, - value, - })).toMatchObject({ + expect( + getDynamicSelectSchema({ + dynamicOptions: null, + isLoading: true, + schema, + value, + }), + ).toMatchObject({ options: [{ value: 'selected' }], }) - expect(getDynamicSelectSchema({ - dynamicOptions: null, - isLoading: false, - schema, - value, - })).toMatchObject({ options: [] }) + expect( + getDynamicSelectSchema({ + dynamicOptions: null, + isLoading: false, + schema, + value, + }), + ).toMatchObject({ options: [] }) expect(isShowAPartSelector(['node-a', 'payload', 'child'] as ValueSelector)).toBe(true) expect(isShowAPartSelector(['rag', 'node-a', 'payload'] as ValueSelector)).toBe(false) @@ -238,7 +288,9 @@ describe('var-reference-picker.helpers', () => { it('should keep mapped variable names for known workflow aliases', () => { expect(getVarDisplayName(true, ['sys', 'files'])).toBe('files') - expect(getVariableMeta({ type: VarType.string }, ['conversation', 'name'], 'name')).toMatchObject({ + expect( + getVariableMeta({ type: VarType.string }, ['conversation', 'name'], 'name'), + ).toMatchObject({ isChatVar: true, isValidVar: true, }) @@ -249,11 +301,13 @@ describe('var-reference-picker.helpers', () => { type: FormTypeEnum.textInput, } - expect(getDynamicSelectSchema({ - dynamicOptions: null, - isLoading: false, - schema, - value: '', - })).toEqual(schema) + expect( + getDynamicSelectSchema({ + dynamicOptions: null, + isLoading: false, + schema, + value: '', + }), + ).toEqual(schema) }) }) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx index d01f68784deda4..2a76386b33e6ed 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx @@ -1,7 +1,11 @@ import type { ComponentProps } from 'react' import type { NodeOutPutVar } from '@/app/components/workflow/types' import { fireEvent, screen, waitFor } from '@testing-library/react' -import { createNode, createStartNode, resetFixtureCounters } from '@/app/components/workflow/__tests__/fixtures' +import { + createNode, + createStartNode, + resetFixtureCounters, +} from '@/app/components/workflow/__tests__/fixtures' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types' import VarReferencePicker from '../var-reference-picker' @@ -24,12 +28,14 @@ describe('VarReferencePicker', () => { id: 'start-node', data: { title: 'Start', - variables: [{ - variable: 'query', - label: 'Query', - type: InputVarType.textInput, - required: false, - }], + variables: [ + { + variable: 'query', + label: 'Query', + type: InputVarType.textInput, + required: false, + }, + ], }, }) const sourceNode = createNode({ @@ -48,18 +54,20 @@ describe('VarReferencePicker', () => { data: { type: BlockEnum.Code, title: 'Current Node' }, }) - const availableVars: NodeOutPutVar[] = [{ - nodeId: 'node-a', - title: 'Source Node', - vars: [ - { variable: 'answer', type: VarType.string }, - { - variable: 'payload', - type: VarType.object, - children: [{ variable: 'child', type: VarType.string }], - }, - ], - }] + const availableVars: NodeOutPutVar[] = [ + { + nodeId: 'node-a', + title: 'Source Node', + vars: [ + { variable: 'answer', type: VarType.string }, + { + variable: 'payload', + type: VarType.object, + children: [{ variable: 'child', type: VarType.string }], + }, + ], + }, + ] const renderPicker = (props: Partial<ComponentProps<typeof VarReferencePicker>> = {}) => { const onChange = vi.fn() diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx index 422cf9cf5a4d2b..7614498438daf9 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx @@ -1,8 +1,5 @@ import type { ComponentProps } from 'react' -import { - Popover, - PopoverContent, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent } from '@langgenius/dify-ui/popover' import { fireEvent, render, screen } from '@testing-library/react' import { BlockEnum, VarType } from '@/app/components/workflow/types' import { VarType as VarKindType } from '../../../../tool/types' @@ -52,9 +49,7 @@ const renderWithPopover = ( render( <Popover onOpenChange={onOpenChange}> - <VarReferencePickerTrigger - {...createProps(overrides)} - /> + <VarReferencePickerTrigger {...createProps(overrides)} /> <PopoverContent popupClassName="border-none bg-transparent p-0 shadow-none"> <div>picker-content</div> </PopoverContent> diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts index 13b7879bad884b..4240536056e88e 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts @@ -23,59 +23,68 @@ describe('var-reference-vars helpers', () => { it('should build selectors by variable scope and file support', () => { const itemData: Var = { variable: 'output', type: VarType.string } - expect(getValueSelector({ - itemData, - isFlat: true, - isSupportFileVar: true, - isFile: false, - isSys: false, - isEnv: false, - isChatVar: false, - nodeId: 'node-1', - objPath: [], - })).toEqual(['output']) + expect( + getValueSelector({ + itemData, + isFlat: true, + isSupportFileVar: true, + isFile: false, + isSys: false, + isEnv: false, + isChatVar: false, + nodeId: 'node-1', + objPath: [], + }), + ).toEqual(['output']) - expect(getValueSelector({ - itemData: { variable: 'env.apiKey', type: VarType.string }, - isFlat: false, - isSupportFileVar: true, - isFile: false, - isSys: false, - isEnv: true, - isChatVar: false, - nodeId: 'node-1', - objPath: ['parent'], - })).toEqual(['parent', 'env', 'apiKey']) + expect( + getValueSelector({ + itemData: { variable: 'env.apiKey', type: VarType.string }, + isFlat: false, + isSupportFileVar: true, + isFile: false, + isSys: false, + isEnv: true, + isChatVar: false, + nodeId: 'node-1', + objPath: ['parent'], + }), + ).toEqual(['parent', 'env', 'apiKey']) - expect(getValueSelector({ - itemData: { variable: 'file', type: VarType.file }, - isFlat: false, - isSupportFileVar: false, - isFile: true, - isSys: false, - isEnv: false, - isChatVar: false, - nodeId: 'node-1', - objPath: [], - })).toBeUndefined() + expect( + getValueSelector({ + itemData: { variable: 'file', type: VarType.file }, + isFlat: false, + isSupportFileVar: false, + isFile: true, + isSys: false, + isEnv: false, + isChatVar: false, + nodeId: 'node-1', + objPath: [], + }), + ).toBeUndefined() }) it('should filter out invalid vars and apply search text', () => { - const vars = filterReferenceVars([ - { - title: 'Node A', - nodeId: 'node-a', - vars: [ - { variable: 'valid_name', type: VarType.string }, - { variable: 'invalid-key', type: VarType.string }, - ], - }, - { - title: 'Node B', - nodeId: 'node-b', - vars: [{ variable: 'another_value', type: VarType.string }], - }, - ] as NodeOutPutVar[], 'another') + const vars = filterReferenceVars( + [ + { + title: 'Node A', + nodeId: 'node-a', + vars: [ + { variable: 'valid_name', type: VarType.string }, + { variable: 'invalid-key', type: VarType.string }, + ], + }, + { + title: 'Node B', + nodeId: 'node-b', + vars: [{ variable: 'another_value', type: VarType.string }], + }, + ] as NodeOutPutVar[], + 'another', + ) expect(vars).toHaveLength(1) expect(vars[0]!.title).toBe('Node B') @@ -83,22 +92,27 @@ describe('var-reference-vars helpers', () => { }) it('should keep parent vars when search text matches a child variable', () => { - const vars = filterReferenceVars([ - { - title: 'Node A', - nodeId: 'node-a', - vars: [{ - variable: 'payload', - type: VarType.object, - children: [{ variable: 'child_name', type: VarType.string }], - }], - }, - { - title: 'Node B', - nodeId: 'node-b', - vars: [{ variable: 'other_value', type: VarType.string }], - }, - ] as NodeOutPutVar[], 'child') + const vars = filterReferenceVars( + [ + { + title: 'Node A', + nodeId: 'node-a', + vars: [ + { + variable: 'payload', + type: VarType.object, + children: [{ variable: 'child_name', type: VarType.string }], + }, + ], + }, + { + title: 'Node B', + nodeId: 'node-b', + vars: [{ variable: 'other_value', type: VarType.string }], + }, + ] as NodeOutPutVar[], + 'child', + ) expect(vars).toHaveLength(1) expect(vars[0]!.title).toBe('Node A') diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx index b8d1013db922e0..780c8528ffd205 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx @@ -21,27 +21,25 @@ vi.mock('../object-child-tree-panel/picker', () => ({ })) vi.mock('../manage-input-field', () => ({ - default: ({ onManage }: { onManage: () => void }) => <button onClick={onManage}>manage-input</button>, + default: ({ onManage }: { onManage: () => void }) => ( + <button onClick={onManage}>manage-input</button> + ), })) describe('VarReferenceVars', () => { const createVars = (vars: NodeOutPutVar[]) => vars - const baseVars = createVars([{ - title: 'Node A', - nodeId: 'node-a', - vars: [{ variable: 'valid_name', type: VarType.string }], - }]) + const baseVars = createVars([ + { + title: 'Node A', + nodeId: 'node-a', + vars: [{ variable: 'valid_name', type: VarType.string }], + }, + ]) it('should filter vars through the search box and call onClose on escape', () => { const onClose = vi.fn() - render( - <VarReferenceVars - vars={baseVars} - onChange={vi.fn()} - onClose={onClose} - />, - ) + render(<VarReferenceVars vars={baseVars} onChange={vi.fn()} onClose={onClose} />) fireEvent.change(screen.getByPlaceholderText('workflow.common.searchVar'), { target: { value: 'valid' }, @@ -58,14 +56,16 @@ describe('VarReferenceVars', () => { render( <VarReferenceVars hideSearch - vars={createVars([{ - title: 'Node A', - nodeId: 'node-a', - vars: [ - { variable: 'first_value', type: VarType.string }, - { variable: 'second_value', type: VarType.string }, - ], - }])} + vars={createVars([ + { + title: 'Node A', + nodeId: 'node-a', + vars: [ + { variable: 'first_value', type: VarType.string }, + { variable: 'second_value', type: VarType.string }, + ], + }, + ])} onChange={onChange} />, ) @@ -83,26 +83,27 @@ describe('VarReferenceVars', () => { fireEvent.keyDown(document, { key: 'Enter' }) - expect(onChange).toHaveBeenCalledWith(['node-a', 'second_value'], expect.objectContaining({ - variable: 'second_value', - })) + expect(onChange).toHaveBeenCalledWith( + ['node-a', 'second_value'], + expect.objectContaining({ + variable: 'second_value', + }), + ) }) it('should call onChange when a variable item is chosen', () => { const onChange = vi.fn() - render( - <VarReferenceVars - vars={baseVars} - onChange={onChange} - />, - ) + render(<VarReferenceVars vars={baseVars} onChange={onChange} />) fireEvent.click(screen.getByText('valid_name')) - expect(onChange).toHaveBeenCalledWith(['node-a', 'valid_name'], expect.objectContaining({ - variable: 'valid_name', - })) + expect(onChange).toHaveBeenCalledWith( + ['node-a', 'valid_name'], + expect.objectContaining({ + variable: 'valid_name', + }), + ) }) it('should render empty state and manage input action', () => { @@ -202,10 +203,26 @@ describe('VarReferenceVars', () => { fireEvent.click(screen.getByText('current')) fireEvent.click(screen.getByText('asset')) - expect(onChange).toHaveBeenNthCalledWith(1, ['env', 'API_KEY'], expect.objectContaining({ variable: 'env.API_KEY' })) - expect(onChange).toHaveBeenNthCalledWith(2, ['conversation', 'user_name'], expect.objectContaining({ variable: 'conversation.user_name' })) - expect(onChange).toHaveBeenNthCalledWith(3, ['node-special', 'current'], expect.objectContaining({ variable: 'current' })) - expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' })) + expect(onChange).toHaveBeenNthCalledWith( + 1, + ['env', 'API_KEY'], + expect.objectContaining({ variable: 'env.API_KEY' }), + ) + expect(onChange).toHaveBeenNthCalledWith( + 2, + ['conversation', 'user_name'], + expect.objectContaining({ variable: 'conversation.user_name' }), + ) + expect(onChange).toHaveBeenNthCalledWith( + 3, + ['node-special', 'current'], + expect.objectContaining({ variable: 'current' }), + ) + expect(onChange).toHaveBeenNthCalledWith( + 4, + ['node-special', 'asset'], + expect.objectContaining({ variable: 'asset' }), + ) }) it('should resolve selectors for special variables and file support from keyboard selection', () => { @@ -239,10 +256,26 @@ describe('VarReferenceVars', () => { fireEvent.keyDown(document, { key: 'ArrowDown' }) fireEvent.keyDown(document, { key: 'Enter' }) - expect(onChange).toHaveBeenNthCalledWith(1, ['env', 'API_KEY'], expect.objectContaining({ variable: 'env.API_KEY' })) - expect(onChange).toHaveBeenNthCalledWith(2, ['conversation', 'user_name'], expect.objectContaining({ variable: 'conversation.user_name' })) - expect(onChange).toHaveBeenNthCalledWith(3, ['node-special', 'current'], expect.objectContaining({ variable: 'current' })) - expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' })) + expect(onChange).toHaveBeenNthCalledWith( + 1, + ['env', 'API_KEY'], + expect.objectContaining({ variable: 'env.API_KEY' }), + ) + expect(onChange).toHaveBeenNthCalledWith( + 2, + ['conversation', 'user_name'], + expect.objectContaining({ variable: 'conversation.user_name' }), + ) + expect(onChange).toHaveBeenNthCalledWith( + 3, + ['node-special', 'current'], + expect.objectContaining({ variable: 'current' }), + ) + expect(onChange).toHaveBeenNthCalledWith( + 4, + ['node-special', 'asset'], + expect.objectContaining({ variable: 'asset' }), + ) }) it('should render object vars and select them by node path', () => { @@ -255,11 +288,13 @@ describe('VarReferenceVars', () => { { title: 'Object vars', nodeId: 'node-obj', - vars: [{ - variable: 'payload', - type: VarType.object, - children: [{ variable: 'child', type: VarType.string }], - }], + vars: [ + { + variable: 'payload', + type: VarType.object, + children: [{ variable: 'child', type: VarType.string }], + }, + ], }, ])} onChange={onChange} @@ -267,9 +302,12 @@ describe('VarReferenceVars', () => { ) fireEvent.click(screen.getByText('payload')) - expect(onChange).toHaveBeenCalledWith(['node-obj', 'payload'], expect.objectContaining({ - variable: 'payload', - })) + expect(onChange).toHaveBeenCalledWith( + ['node-obj', 'payload'], + expect.objectContaining({ + variable: 'payload', + }), + ) }) it('should filter by externally controlled search text and match child variables', () => { @@ -281,14 +319,17 @@ describe('VarReferenceVars', () => { { title: 'Object vars', nodeId: 'node-obj', - vars: [{ - variable: 'payload', - type: VarType.object, - children: [{ variable: 'child_name', type: VarType.string }], - }, { - variable: 'other_value', - type: VarType.string, - }], + vars: [ + { + variable: 'payload', + type: VarType.object, + children: [{ variable: 'child_name', type: VarType.string }], + }, + { + variable: 'other_value', + type: VarType.string, + }, + ], }, ])} onChange={vi.fn()} diff --git a/web/app/components/workflow/nodes/_base/components/variable/constant-field.tsx b/web/app/components/workflow/nodes/_base/components/variable/constant-field.tsx index aca17258330b85..8bad60e2f8a36d 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/constant-field.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/constant-field.tsx @@ -1,8 +1,19 @@ 'use client' import type { FC } from 'react' -import type { CredentialFormSchema, CredentialFormSchemaNumberInput, CredentialFormSchemaSelect } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + CredentialFormSchema, + CredentialFormSchemaNumberInput, + CredentialFormSchemaSelect, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Var } from '@/app/components/workflow/types' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import * as React from 'react' import { useCallback, useMemo } from 'react' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -31,25 +42,30 @@ const ConstantField: FC<Props> = ({ const language = useLanguage() const placeholder = (schema as CredentialFormSchemaSelect).placeholder const selectOptions = useMemo(() => { - if (schema.type !== FormTypeEnum.select && schema.type !== FormTypeEnum.dynamicSelect) - return [] + if (schema.type !== FormTypeEnum.select && schema.type !== FormTypeEnum.dynamicSelect) return [] - return (schema as CredentialFormSchemaSelect).options.map(option => ({ + return (schema as CredentialFormSchemaSelect).options.map((option) => ({ value: String(option.value), name: option.label[language] || option.label.en_US, })) }, [language, schema]) const selectedOption = useMemo(() => { - return selectOptions.find(option => option.value === String(value)) ?? null + return selectOptions.find((option) => option.value === String(value)) ?? null }, [selectOptions, value]) - const handleStaticChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { - const value = e.target.value === '' ? '' : Number.parseFloat(e.target.value) - onChange(value, VarKindType.constant) - }, [onChange]) - const handleSelectChange = useCallback((value: string | number) => { - value = value === null ? '' : value - onChange(value as string, VarKindType.constant) - }, [onChange]) + const handleStaticChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + const value = e.target.value === '' ? '' : Number.parseFloat(e.target.value) + onChange(value, VarKindType.constant) + }, + [onChange], + ) + const handleSelectChange = useCallback( + (value: string | number) => { + value = value === null ? '' : value + onChange(value as string, VarKindType.constant) + }, + [onChange], + ) return ( <> @@ -57,17 +73,14 @@ const ConstantField: FC<Props> = ({ <Select value={selectedOption?.value ?? null} disabled={readonly || isLoading} - onValueChange={nextValue => nextValue && handleSelectChange(nextValue)} + onValueChange={(nextValue) => nextValue && handleSelectChange(nextValue)} onOpenChange={onOpenChange} > - <SelectTrigger - className="h-8 w-full" - disabled={readonly || isLoading} - > + <SelectTrigger className="h-8 w-full" disabled={readonly || isLoading}> {selectedOption?.name ?? placeholder?.[language] ?? placeholder?.en_US} </SelectTrigger> <SelectContent> - {selectOptions.map(option => ( + {selectOptions.map((option) => ( <SelectItem key={option.value} value={option.value}> <SelectItemText>{option.name}</SelectItemText> <SelectItemIndicator /> diff --git a/web/app/components/workflow/nodes/_base/components/variable/manage-input-field.tsx b/web/app/components/workflow/nodes/_base/components/variable/manage-input-field.tsx index df19e990b102c3..e5d732f08baa60 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/manage-input-field.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/manage-input-field.tsx @@ -5,23 +5,18 @@ type ManageInputFieldProps = { onManage: () => void } -const ManageInputField = ({ - onManage, -}: ManageInputFieldProps) => { +const ManageInputField = ({ onManage }: ManageInputFieldProps) => { const { t } = useTranslation() return ( <div className="flex items-center border-t border-divider-subtle pt-1"> - <div - className="flex h-8 grow cursor-pointer items-center px-3" - onClick={onManage} - > + <div className="flex h-8 grow cursor-pointer items-center px-3" onClick={onManage}> <RiAddLine className="mr-1 size-4 text-text-tertiary" /> <div className="truncate system-xs-medium text-text-tertiary" title="Create user input field" > - {t($ => $['inputField.create'], { ns: 'pipeline' })} + {t(($) => $['inputField.create'], { ns: 'pipeline' })} </div> </div> <div className="mx-1 h-3 w-px shrink-0 bg-divider-regular"></div> @@ -29,7 +24,7 @@ const ManageInputField = ({ className="flex h-8 shrink-0 cursor-pointer items-center justify-center px-3 system-xs-medium text-text-tertiary" onClick={onManage} > - {t($ => $['inputField.manage'], { ns: 'pipeline' })} + {t(($) => $['inputField.manage'], { ns: 'pipeline' })} </div> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/variable/match-schema-type.ts b/web/app/components/workflow/nodes/_base/components/variable/match-schema-type.ts index 79b95942e0245e..b5889a390baf6f 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/match-schema-type.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/match-schema-type.ts @@ -7,15 +7,13 @@ function matchTheSchemaType(scheme: AnyObj, target: AnyObj): boolean { const isMatch = (schema: AnyObj, t: AnyObj): boolean => { const oSchema = isObj(schema) const oT = isObj(t) - if (!oSchema) - return true - if (!oT) { // ignore the object without type + if (!oSchema) return true + if (!oT) { + // ignore the object without type // deep find oSchema has type for (const key in schema) { - if (key === 'type') - return false - if (isObj((schema as any)[key]) && !isMatch((schema as any)[key], null)) - return false + if (key === 'type') return false + if (isObj((schema as any)[key]) && !isMatch((schema as any)[key], null)) return false } return true } @@ -24,18 +22,16 @@ function matchTheSchemaType(scheme: AnyObj, target: AnyObj): boolean { const ty = (t as any).type const isTypeValueObj = isObj(tx) - if (!isTypeValueObj) { // caution: type can be object, so that it would not be compare by value - if (tx !== ty) - return false + if (!isTypeValueObj) { + // caution: type can be object, so that it would not be compare by value + if (tx !== ty) return false } // recurse into all keys const keys = new Set([...Object.keys(schema as object), ...Object.keys(t as object)]) for (const k of keys) { - if (k === 'type' && !isTypeValueObj) - continue // already checked - if (!isMatch((schema as any)[k], (t as any)[k])) - return false + if (k === 'type' && !isTypeValueObj) continue // already checked + if (!isMatch((schema as any)[k], (t as any)[k])) return false } return true } diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx index 4fca9e5d23f259..6788f1de7754a0 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx @@ -22,53 +22,55 @@ type Props = Readonly<{ onSelect?: (valueSelector: ValueSelector) => void }> -const Field: FC<Props> = ({ - valueSelector, - name, - payload, - depth = 1, - readonly, - onSelect, -}) => { +const Field: FC<Props> = ({ valueSelector, name, payload, depth = 1, readonly, onSelect }) => { const { t } = useTranslation() const isLastFieldHighlight = readonly const hasChildren = payload.type === Type.object && payload.properties const isHighlight = isLastFieldHighlight && !hasChildren - if (depth > MAX_DEPTH + 1) - return null + if (depth > MAX_DEPTH + 1) return null return ( <div> <Tooltip> <TooltipTrigger disabled={depth !== MAX_DEPTH + 1} - render={( + render={ <div - className={cn('flex items-center justify-between rounded-md pr-2 outline-hidden focus:outline-hidden focus-visible:outline-hidden', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')} + className={cn( + 'flex items-center justify-between rounded-md pr-2 outline-hidden focus:outline-hidden focus-visible:outline-hidden', + !readonly && 'hover:bg-state-base-hover', + depth !== MAX_DEPTH + 1 && 'cursor-pointer', + )} onMouseDown={() => !readonly && onSelect?.([...valueSelector, name])} > <div className="flex grow items-stretch"> <TreeIndentLine depth={depth} /> - {depth === MAX_DEPTH + 1 - ? ( - <RiMoreFill className="size-3 text-text-tertiary" /> - ) - : (<div className={cn('h-6 w-0 grow truncate system-sm-medium leading-6 text-text-secondary', isHighlight && 'text-text-accent')}>{name}</div>)} - + {depth === MAX_DEPTH + 1 ? ( + <RiMoreFill className="size-3 text-text-tertiary" /> + ) : ( + <div + className={cn( + 'h-6 w-0 grow truncate system-sm-medium leading-6 text-text-secondary', + isHighlight && 'text-text-accent', + )} + > + {name} + </div> + )} </div> {depth < MAX_DEPTH + 1 && ( - <div className="ml-2 shrink-0 system-xs-regular text-text-tertiary">{getFieldType(payload)}</div> + <div className="ml-2 shrink-0 system-xs-regular text-text-tertiary"> + {getFieldType(payload)} + </div> )} </div> - )} + } /> - <TooltipContent> - {t($ => $['structOutput.moreFillTip'], { ns: 'app' })} - </TooltipContent> + <TooltipContent>{t(($) => $['structOutput.moreFillTip'], { ns: 'app' })}</TooltipContent> </Tooltip> {depth <= MAX_DEPTH && payload.type === Type.object && payload.properties && ( <div> - {Object.keys(payload.properties).map(propName => ( + {Object.keys(payload.properties).map((propName) => ( <Field key={propName} name={propName} diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/index.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/index.tsx index 00ca3ca142025a..9800310bda2763 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/index.tsx @@ -10,7 +10,7 @@ import Field from './field' type Props = Readonly<{ className?: string - root: { nodeId?: string, nodeName?: string, attrName: string, attrAlias?: string } + root: { nodeId?: string; nodeName?: string; attrName: string; attrAlias?: string } payload: StructuredOutput readonly?: boolean onSelect?: (valueSelector: ValueSelector) => void @@ -30,8 +30,7 @@ export const PickerPanelMain: FC<Props> = ({ onChange: (hovering) => { if (hovering) { onHovering?.(true) - } - else { + } else { setTimeout(() => { onHovering?.(false) }, 100) @@ -47,15 +46,22 @@ export const PickerPanelMain: FC<Props> = ({ <div className="flex"> {root.nodeName && ( <> - <div className="max-w-[100px] truncate system-sm-medium text-text-tertiary">{root.nodeName}</div> + <div className="max-w-[100px] truncate system-sm-medium text-text-tertiary"> + {root.nodeName} + </div> <div className="system-sm-medium text-text-tertiary">.</div> </> )} <div className="system-sm-medium text-text-secondary">{root.attrName}</div> </div> - <div className="ml-2 truncate system-xs-regular text-text-tertiary" title={root.attrAlias || 'object'}>{root.attrAlias || 'object'}</div> + <div + className="ml-2 truncate system-xs-regular text-text-tertiary" + title={root.attrAlias || 'object'} + > + {root.attrAlias || 'object'} + </div> </div> - {fieldNames.map(name => ( + {fieldNames.map((name) => ( <Field key={name} name={name} @@ -69,12 +75,14 @@ export const PickerPanelMain: FC<Props> = ({ ) } -const PickerPanel: FC<Props> = ({ - className, - ...props -}) => { +const PickerPanel: FC<Props> = ({ className, ...props }) => { return ( - <div className={cn('w-[296px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]', className)}> + <div + className={cn( + 'w-[296px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]', + className, + )} + > <PickerPanelMain {...props} /> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx index 50e161227d7131..12f45b4e6cf1d2 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx @@ -18,20 +18,12 @@ type Props = Readonly<{ rootClassName?: string }> -const Field: FC<Props> = ({ - name, - payload, - depth = 1, - required, - rootClassName, -}) => { +const Field: FC<Props> = ({ name, payload, depth = 1, required, rootClassName }) => { const { t } = useTranslation() const isRoot = depth === 1 const hasChildren = payload.type === Type.object && payload.properties const hasEnum = payload.enum && payload.enum.length > 0 - const [fold, { - toggle: toggleFold, - }] = useBoolean(false) + const [fold, { toggle: toggleFold }] = useBoolean(false) return ( <div> <div className={cn('flex pr-2')}> @@ -40,20 +32,36 @@ const Field: FC<Props> = ({ <div className="relative flex select-none"> {hasChildren && ( <RiArrowDropDownLine - className={cn('absolute top-[50%] left-[-18px] h-4 w-4 translate-y-[-50%] cursor-pointer bg-components-panel-bg text-text-tertiary', fold && 'rotate-270 text-text-accent')} + className={cn( + 'absolute top-[50%] left-[-18px] h-4 w-4 translate-y-[-50%] cursor-pointer bg-components-panel-bg text-text-tertiary', + fold && 'rotate-270 text-text-accent', + )} onClick={toggleFold} /> )} - <div className={cn('ml-[7px] h-6 truncate system-sm-medium leading-6 text-text-secondary', isRoot && rootClassName)}>{name}</div> + <div + className={cn( + 'ml-[7px] h-6 truncate system-sm-medium leading-6 text-text-secondary', + isRoot && rootClassName, + )} + > + {name} + </div> <div className="ml-3 shrink-0 system-xs-regular leading-6 text-text-tertiary"> {getFieldType(payload)} - {(payload.schemaType && payload.schemaType !== 'file' && ` (${payload.schemaType})`)} + {payload.schemaType && payload.schemaType !== 'file' && ` (${payload.schemaType})`} </div> - {required && <div className="ml-3 system-2xs-medium-uppercase leading-6 text-text-warning">{t($ => $['structOutput.required'], { ns: 'app' })}</div>} + {required && ( + <div className="ml-3 system-2xs-medium-uppercase leading-6 text-text-warning"> + {t(($) => $['structOutput.required'], { ns: 'app' })} + </div> + )} </div> {payload.description && ( <div className="ml-[7px] flex"> - <div className="w-0 grow truncate system-xs-regular text-text-tertiary">{payload.description}</div> + <div className="w-0 grow truncate system-xs-regular text-text-tertiary"> + {payload.description} + </div> </div> )} {hasEnum && ( @@ -73,7 +81,7 @@ const Field: FC<Props> = ({ {hasChildren && !fold && ( <div> - {Object.keys(payload.properties!).map(name => ( + {Object.keys(payload.properties!).map((name) => ( <Field key={name} name={name} diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/index.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/index.tsx index b3b92f5042c2f7..d6a655dabb19e7 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/index.tsx @@ -10,21 +10,18 @@ type Props = Readonly<{ rootClassName?: string }> -const ShowPanel: FC<Props> = ({ - payload, - rootClassName, -}) => { +const ShowPanel: FC<Props> = ({ payload, rootClassName }) => { const { t } = useTranslation() const schema = { ...payload, schema: { ...payload.schema, - description: t($ => $['structOutput.LLMResponse'], { ns: 'app' }), + description: t(($) => $['structOutput.LLMResponse'], { ns: 'app' }), }, } return ( <div className="relative left-[-7px]"> - {Object.keys(schema.schema.properties!).map(name => ( + {Object.keys(schema.schema.properties!).map((name) => ( <Field key={name} name={name} diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/tree-indent-line.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/tree-indent-line.tsx index 5b6e865a2f9753..2e9b4ad31ecb79 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/tree-indent-line.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/tree-indent-line.tsx @@ -8,14 +8,11 @@ type Props = Readonly<{ className?: string }> -const TreeIndentLine: FC<Props> = ({ - depth = 1, - className, -}) => { +const TreeIndentLine: FC<Props> = ({ depth = 1, className }) => { const depthArray = Array.from({ length: depth }, (_, index) => index) return ( <div className={cn('flex', className)}> - {depthArray.map(d => ( + {depthArray.map((d) => ( <div key={d} className={cn('mx-2.5 w-px bg-divider-regular')}></div> ))} </div> diff --git a/web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx b/web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx index 17ccf41b49b103..1e276f20cce4f0 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx @@ -21,13 +21,7 @@ type Props = Readonly<{ onRemove: (index: number) => void }> -const OutputVarList: FC<Props> = ({ - readonly, - outputs, - outputKeyOrders, - onChange, - onRemove, -}) => { +const OutputVarList: FC<Props> = ({ readonly, outputs, outputKeyOrders, onChange, onRemove }) => { const { t } = useTranslation() const list = outputKeyOrders.map((key) => { @@ -37,51 +31,70 @@ const OutputVarList: FC<Props> = ({ } }) - const { run: validateVarInput } = useDebounceFn((existingVariables: typeof list, newKey: string) => { - const result = checkKeys([newKey], true) - if (!result.isValid) { - toast.error(t($ => $[`varKeyError.${result.errorMessageKey}`], { ns: 'appDebug', key: result.errorKey })) - return - } - if (existingVariables.some(key => key.variable?.trim() === newKey.trim())) { - toast.error(t($ => $['varKeyError.keyAlreadyExists'], { ns: 'appDebug', key: newKey })) - } - }, { wait: 500 }) + const { run: validateVarInput } = useDebounceFn( + (existingVariables: typeof list, newKey: string) => { + const result = checkKeys([newKey], true) + if (!result.isValid) { + toast.error( + t(($) => $[`varKeyError.${result.errorMessageKey}`], { + ns: 'appDebug', + key: result.errorKey, + }), + ) + return + } + if (existingVariables.some((key) => key.variable?.trim() === newKey.trim())) { + toast.error(t(($) => $['varKeyError.keyAlreadyExists'], { ns: 'appDebug', key: newKey })) + } + }, + { wait: 500 }, + ) - const handleVarNameChange = useCallback((index: number) => { - return (e: React.ChangeEvent<HTMLInputElement>) => { - const oldKey = list[index]!.variable + const handleVarNameChange = useCallback( + (index: number) => { + return (e: React.ChangeEvent<HTMLInputElement>) => { + const oldKey = list[index]!.variable - replaceSpaceWithUnderscoreInVarNameInput(e.target) - const newKey = e.target.value + replaceSpaceWithUnderscoreInVarNameInput(e.target) + const newKey = e.target.value - validateVarInput(list.filter((_, itemIndex) => itemIndex !== index), newKey) + validateVarInput( + list.filter((_, itemIndex) => itemIndex !== index), + newKey, + ) - const newOutputs = produce(outputs, (draft) => { - draft[newKey] = draft[oldKey]! - // Only delete old key if no other entry shares this name - if (!list.some((item, i) => i !== index && item.variable === oldKey)) - delete draft[oldKey] - }) - onChange(newOutputs, index, newKey) - } - }, [list, onChange, outputs, validateVarInput]) + const newOutputs = produce(outputs, (draft) => { + draft[newKey] = draft[oldKey]! + // Only delete old key if no other entry shares this name + if (!list.some((item, i) => i !== index && item.variable === oldKey)) delete draft[oldKey] + }) + onChange(newOutputs, index, newKey) + } + }, + [list, onChange, outputs, validateVarInput], + ) - const handleVarTypeChange = useCallback((index: number) => { - return (value: string) => { - const key = list[index]!.variable - const newOutputs = produce(outputs, (draft) => { - draft[key]!.type = value as VarType - }) - onChange(newOutputs) - } - }, [list, onChange, outputs]) + const handleVarTypeChange = useCallback( + (index: number) => { + return (value: string) => { + const key = list[index]!.variable + const newOutputs = produce(outputs, (draft) => { + draft[key]!.type = value as VarType + }) + onChange(newOutputs) + } + }, + [list, onChange, outputs], + ) - const handleVarRemove = useCallback((index: number) => { - return () => { - onRemove(index) - } - }, [onRemove]) + const handleVarRemove = useCallback( + (index: number) => { + return () => { + onRemove(index) + } + }, + [onRemove], + ) return ( <div className="space-y-2"> diff --git a/web/app/components/workflow/nodes/_base/components/variable/use-match-schema-type.ts b/web/app/components/workflow/nodes/_base/components/variable/use-match-schema-type.ts index 67fb230eeb51eb..7f2b9f21245197 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/use-match-schema-type.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/use-match-schema-type.ts @@ -3,10 +3,12 @@ import type { SchemaTypeDefinition } from '@/service/use-common' import { useSchemaTypeDefinitions } from '@/service/use-common' import matchTheSchemaType from './match-schema-type' -export const getMatchedSchemaType = (obj: AnyObj, schemaTypeDefinitions?: SchemaTypeDefinition[]): string => { - if (!schemaTypeDefinitions || obj === undefined || obj === null) - return '' - const matched = schemaTypeDefinitions.find(def => matchTheSchemaType(obj, def.schema)) +export const getMatchedSchemaType = ( + obj: AnyObj, + schemaTypeDefinitions?: SchemaTypeDefinition[], +): string => { + if (!schemaTypeDefinitions || obj === undefined || obj === null) return '' + const matched = schemaTypeDefinitions.find((def) => matchTheSchemaType(obj, def.schema)) return matched ? matched.name : '' } diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 568768b384d00c..9dfd3e86523462 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -60,18 +60,11 @@ import HumanInputNodeDefault from '@/app/components/workflow/nodes/human-input/d import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types' import ToolNodeDefault from '@/app/components/workflow/nodes/tool/default' import PluginTriggerNodeDefault from '@/app/components/workflow/nodes/trigger-plugin/default' -import { - BlockEnum, - InputVarType, - VarType, -} from '@/app/components/workflow/types' +import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types' import { VAR_REGEX } from '@/config' import { AppModeEnum } from '@/types/app' import { OUTPUT_FILE_SUB_VARIABLES } from '../../../constants' -import { - - Type, -} from '../../../llm/types' +import { Type } from '../../../llm/types' import { VarType as ToolVarType } from '../../../tool/types' type WorkflowTranslate = <const Selector extends SelectorParam<'workflow'>>( @@ -95,12 +88,10 @@ export const isSystemVar = (valueSelector: ValueSelector) => { } export const isGlobalVar = (valueSelector: ValueSelector) => { - if (!isSystemVar(valueSelector)) - return false + if (!isSystemVar(valueSelector)) return false const second = valueSelector[1] - if (['query', 'files'].includes(second!)) - return false + if (['query', 'files'].includes(second!)) return false return true } @@ -113,8 +104,7 @@ export const isConversationVar = (valueSelector: ValueSelector) => { } export const isRagVariableVar = (valueSelector: ValueSelector) => { - if (!valueSelector) - return false + if (!valueSelector) return false return valueSelector[0] === 'rag' } @@ -124,11 +114,10 @@ export const isSpecialVar = (prefix: string): boolean => { const hasValidChildren = (children: any): boolean => { return ( - children - && ((Array.isArray(children) && children.length > 0) - || (!Array.isArray(children) - && Object.keys((children as StructuredOutput)?.schema?.properties || {}) - .length > 0)) + children && + ((Array.isArray(children) && children.length > 0) || + (!Array.isArray(children) && + Object.keys((children as StructuredOutput)?.schema?.properties || {}).length > 0)) ) } @@ -201,14 +190,11 @@ const findExceptVarInStructuredProperties = ( const arrayType = item!.items?.type if ( - !isObj - && !filterVar( + !isObj && + !filterVar( { variable: key, - type: structTypeToVarType( - isArray ? arrayType! : item!.type, - isArray, - ), + type: structTypeToVarType(isArray ? arrayType! : item!.type, isArray), }, [key], ) @@ -217,10 +203,7 @@ const findExceptVarInStructuredProperties = ( return } if (item!.type === Type.object && item!.properties) { - item!.properties = findExceptVarInStructuredProperties( - item!.properties, - filterVar, - ) + item!.properties = findExceptVarInStructuredProperties(item!.properties, filterVar) } }) return draft @@ -240,14 +223,11 @@ const findExceptVarInStructuredOutput = ( const isArray = item!.type === Type.array const arrayType = item!.items?.type if ( - !isObj - && !filterVar( + !isObj && + !filterVar( { variable: key, - type: structTypeToVarType( - isArray ? arrayType! : item!.type, - isArray, - ), + type: structTypeToVarType(isArray ? arrayType! : item!.type, isArray), }, [key], ) @@ -256,10 +236,7 @@ const findExceptVarInStructuredOutput = ( return } if (item!.type === Type.object && item!.properties) { - item!.properties = findExceptVarInStructuredProperties( - item!.properties, - filterVar, - ) + item!.properties = findExceptVarInStructuredProperties(item!.properties, filterVar) } }) return draft @@ -280,8 +257,7 @@ const findExceptVarInObject = ( if (isStructuredOutput) { childrenResult = findExceptVarInStructuredOutput(children, filterVar) - } - else if (Array.isArray(children)) { + } else if (Array.isArray(children)) { childrenResult = children .map((item: Var) => { const { children: itemChildren } = item @@ -295,22 +271,13 @@ const findExceptVarInObject = ( } } - const filteredObj = findExceptVarInObject( - item, - filterVar, - currSelector, - false, - ) + const filteredObj = findExceptVarInObject(item, filterVar, currSelector, false) const itemHasValidChildren = hasValidChildren(filteredObj.children) let passesFilter - if ( - (item.type === VarType.object || item.type === VarType.file) - && itemChildren - ) { + if ((item.type === VarType.object || item.type === VarType.file) && itemChildren) { passesFilter = itemHasValidChildren || filterVar(item, currSelector) - } - else { + } else { passesFilter = itemHasValidChildren } @@ -323,16 +290,14 @@ const findExceptVarInObject = ( .filter(({ passesFilter }) => passesFilter) .map(({ item, filteredObj }) => { const { children: itemChildren } = item - if (!itemChildren || !filteredObj) - return item + if (!itemChildren || !filteredObj) return item return { ...item, children: filteredObj.children, } }) - } - else { + } else { childrenResult = [] } @@ -380,8 +345,7 @@ const formatItem = ( schema: typeof v.json_schema === 'string' ? JSON.parse(v.json_schema) : v.json_schema, } } - } - catch (error) { + } catch (error) { console.error('Error formatting variable:', error) } @@ -401,9 +365,7 @@ const formatItem = ( } case BlockEnum.TriggerWebhook: { - const { - variables = [], - } = data as WebhookTriggerNodeType + const { variables = [] } = data as WebhookTriggerNodeType res.vars = variables.map((v) => { const type = v.value_type || VarType.string const varRes: Var = { @@ -423,9 +385,9 @@ const formatItem = ( case BlockEnum.LLM: { res.vars = [...LLM_OUTPUT_STRUCT] if ( - data.structured_output_enabled - && data.structured_output?.schema?.properties - && Object.keys(data.structured_output.schema.properties).length > 0 + data.structured_output_enabled && + data.structured_output?.schema?.properties && + Object.keys(data.structured_output.schema.properties).length > 0 ) { res.vars.push({ variable: 'structured_output', @@ -470,8 +432,7 @@ const formatItem = ( } case BlockEnum.VariableAssigner: { - const { output_type, advanced_settings } - = data as VariableAssignerNodeType + const { output_type, advanced_settings } = data as VariableAssignerNodeType const isGroup = !!advanced_settings?.group_enabled if (!isGroup) { res.vars = [ @@ -480,8 +441,7 @@ const formatItem = ( type: output_type, }, ] - } - else { + } else { res.vars = advanced_settings?.groups.map((group) => { return { variable: group.group_name, @@ -499,8 +459,7 @@ const formatItem = ( } case BlockEnum.VariableAggregator: { - const { output_type, advanced_settings } - = data as VariableAssignerNodeType + const { output_type, advanced_settings } = data as VariableAssignerNodeType const isGroup = !!advanced_settings?.group_enabled if (!isGroup) { res.vars = [ @@ -509,8 +468,7 @@ const formatItem = ( type: output_type, }, ] - } - else { + } else { res.vars = advanced_settings?.groups.map((group) => { return { variable: group.group_name, @@ -528,13 +486,10 @@ const formatItem = ( } case BlockEnum.Tool: { - const toolOutputVars - = ToolNodeDefault.getOutputVars?.( - data as ToolNodeType, - allPluginInfoList, - [], - { schemaTypeDefinitions }, - ) || [] + const toolOutputVars = + ToolNodeDefault.getOutputVars?.(data as ToolNodeType, allPluginInfoList, [], { + schemaTypeDefinitions, + }) || [] res.vars = toolOutputVars break } @@ -565,8 +520,8 @@ const formatItem = ( case BlockEnum.Loop: { const { loop_variables } = data as LoopNodeType res.isLoop = true - res.vars - = loop_variables?.map((v) => { + res.vars = + loop_variables?.map((v) => { return { variable: v.label, type: v.var_type, @@ -582,17 +537,14 @@ const formatItem = ( res.vars = [ { variable: 'text', - type: (data as DocExtractorNodeType).is_array_file - ? VarType.arrayString - : VarType.string, + type: (data as DocExtractorNodeType).is_array_file ? VarType.arrayString : VarType.string, }, ] break } case BlockEnum.ListFilter: { - if (!(data as ListFilterNodeType).var_type) - break + if (!(data as ListFilterNodeType).var_type) break res.vars = [ { @@ -619,18 +571,16 @@ const formatItem = ( const payload = data as AgentNodeType const outputs: Var[] = [] - Object.keys(payload.output_schema?.properties || {}).forEach( - (outputKey) => { - const output = payload.output_schema.properties[outputKey] - outputs.push({ - variable: outputKey, - type: - output.type === 'array' - ? (`Array[${output.items?.type ? output.items.type.slice(0, 1).toLocaleUpperCase() + output.items.type.slice(1) : 'Unknown'}]` as VarType) - : (`${output.type ? output.type.slice(0, 1).toLocaleUpperCase() + output.type.slice(1) : 'Unknown'}` as VarType), - }) - }, - ) + Object.keys(payload.output_schema?.properties || {}).forEach((outputKey) => { + const output = payload.output_schema.properties[outputKey] + outputs.push({ + variable: outputKey, + type: + output.type === 'array' + ? (`Array[${output.items?.type ? output.items.type.slice(0, 1).toLocaleUpperCase() + output.items.type.slice(1) : 'Unknown'}]` as VarType) + : (`${output.type ? output.type.slice(0, 1).toLocaleUpperCase() + output.type.slice(1) : 'Unknown'}` as VarType), + }) + }) res.vars = [...outputs, ...TOOL_OUTPUT_STRUCT, ...AGENT_OUTPUT_STRUCT] break } @@ -642,35 +592,31 @@ const formatItem = ( case BlockEnum.DataSource: { const payload = data as DataSourceNodeType - const dataSourceVars - = DataSourceNodeDefault.getOutputVars?.( - payload, - allPluginInfoList, - ragVars, - { schemaTypeDefinitions }, - ) || [] + const dataSourceVars = + DataSourceNodeDefault.getOutputVars?.(payload, allPluginInfoList, ragVars, { + schemaTypeDefinitions, + }) || [] res.vars = dataSourceVars break } case BlockEnum.TriggerPlugin: { - const outputSchema = PluginTriggerNodeDefault.getOutputVars?.( - data as PluginTriggerNodeType, - allPluginInfoList, - [], - { schemaTypeDefinitions }, - ) || [] + const outputSchema = + PluginTriggerNodeDefault.getOutputVars?.( + data as PluginTriggerNodeType, + allPluginInfoList, + [], + { schemaTypeDefinitions }, + ) || [] res.vars = outputSchema break } case BlockEnum.HumanInput: { - const outputSchema = HumanInputNodeDefault.getOutputVars?.( - data as HumanInputNodeType, - allPluginInfoList, - [], - { schemaTypeDefinitions }, - ) || [] + const outputSchema = + HumanInputNodeDefault.getOutputVars?.(data as HumanInputNodeType, allPluginInfoList, [], { + schemaTypeDefinitions, + }) || [] res.vars = [...outputSchema, ...HUMAN_INPUT_OUTPUT_STRUCT] break } @@ -741,20 +687,18 @@ const formatItem = ( (() => { const variableArr = v.variable.split('.') const [first] = variableArr - if (isSpecialVar(first!)) - return variableArr + if (isSpecialVar(first!)) return variableArr return [...selector, ...variableArr] })(), ) - if (isCurrentMatched) - return true + if (isCurrentMatched) return true const isFile = v.type === VarType.file const children = (() => { if (isFile) { return OUTPUT_FILE_SUB_VARIABLES.map((key) => { - const def = FILE_STRUCT.find(c => c.variable === key) + const def = FILE_STRUCT.find((c) => c.variable === key) return { variable: key, type: def?.type || VarType.string, @@ -763,8 +707,7 @@ const formatItem = ( } return v.children })() - if (!children) - return false + if (!children) return false const obj = findExceptVarInObject( isFile ? { ...v, children } : v, @@ -780,7 +723,7 @@ const formatItem = ( if (isFile) { return { children: OUTPUT_FILE_SUB_VARIABLES.map((key) => { - const def = FILE_STRUCT.find(c => c.variable === key) + const def = FILE_STRUCT.find((c) => c.variable === key) return { variable: key, type: def?.type || VarType.string, @@ -791,15 +734,9 @@ const formatItem = ( return v })() - if (!children) - return v + if (!children) return v - return findExceptVarInObject( - isFile ? { ...v, children } : v, - filterVar, - selector, - isFile, - ) + return findExceptVarInObject(isFile ? { ...v, children } : v, filterVar, selector, isFile) }) return res @@ -810,12 +747,10 @@ export const removeFileVars = (nodeWithVars: NodeOutPutVar[]) => { .map((item) => { return { ...item, - vars: item.vars.filter( - v => v.type !== VarType.file && v.type !== VarType.arrayFile, - ), + vars: item.vars.filter((v) => v.type !== VarType.file && v.type !== VarType.arrayFile), } }) - .filter(item => item.vars.length > 0) + .filter((item) => item.vars.length > 0) } export const toNodeOutputVars = ( @@ -862,48 +797,36 @@ export const toNodeOutputVars = ( title: 'SHARED INPUTS', type: 'rag', ragVariables: ragVariables.filter( - ragVariable => ragVariable.belong_to_node_id === 'shared', + (ragVariable) => ragVariable.belong_to_node_id === 'shared', ), }, } // Sort nodes in reverse chronological order (most recent first) const sortedNodes = [...nodes].sort((a, b) => { - if (a.data.type === BlockEnum.Start) - return 1 - if (b.data.type === BlockEnum.Start) - return -1 - if (a.data.type === 'env') - return 1 - if (b.data.type === 'env') - return -1 - if (a.data.type === 'conversation') - return 1 - if (b.data.type === 'conversation') - return -1 - if (a.data.type === 'global') - return 1 - if (b.data.type === 'global') - return -1 + if (a.data.type === BlockEnum.Start) return 1 + if (b.data.type === BlockEnum.Start) return -1 + if (a.data.type === 'env') return 1 + if (b.data.type === 'env') return -1 + if (a.data.type === 'conversation') return 1 + if (b.data.type === 'conversation') return -1 + if (a.data.type === 'global') return 1 + if (b.data.type === 'global') return -1 // sort nodes by x position return (b.position?.x || 0) - (a.position?.x || 0) }) const res = [ - ...sortedNodes.filter(node => - SUPPORT_OUTPUT_VARS_NODE.includes(node?.data?.type), - ), + ...sortedNodes.filter((node) => SUPPORT_OUTPUT_VARS_NODE.includes(node?.data?.type)), ...(environmentVariables.length > 0 ? [ENV_NODE] : []), ...(isChatMode && conversationVariables.length > 0 ? [CHAT_VAR_NODE] : []), GLOBAL_VAR_NODE, - ...(RAG_PIPELINE_NODE.data.ragVariables.length > 0 - ? [RAG_PIPELINE_NODE] - : []), + ...(RAG_PIPELINE_NODE.data.ragVariables.length > 0 ? [RAG_PIPELINE_NODE] : []), ] .map((node) => { let ragVariablesInDataSource: RAGPipelineVariable[] = [] if (node.data.type === BlockEnum.DataSource) { ragVariablesInDataSource = ragVariables.filter( - ragVariable => ragVariable.belong_to_node_id === node.id, + (ragVariable) => ragVariable.belong_to_node_id === node.id, ) } return { @@ -912,22 +835,20 @@ export const toNodeOutputVars = ( isChatMode, filterVar, allPluginInfoList, - ragVariablesInDataSource.map( - (ragVariable: RAGPipelineVariable) => { - return { - variable: `rag.${node.id}.${ragVariable.variable}`, - type: inputVarTypeToVarType(ragVariable.type as any), - description: ragVariable.label, - isRagVariable: true, - } as Var - }, - ), + ragVariablesInDataSource.map((ragVariable: RAGPipelineVariable) => { + return { + variable: `rag.${node.id}.${ragVariable.variable}`, + type: inputVarTypeToVarType(ragVariable.type as any), + description: ragVariable.label, + isRagVariable: true, + } as Var + }), schemaTypeDefinitions, ), isStartNode: node.data.type === BlockEnum.Start, } }) - .filter(item => item.vars.length > 0) + .filter((item) => item.vars.length > 0) return res } @@ -943,28 +864,23 @@ const getIterationItemType = ({ const isChatVar = isConversationVar(valueSelector) const targetVar = isSystem - ? beforeNodesOutputVars.find(v => v.isStartNode) - : beforeNodesOutputVars.find(v => v.nodeId === outputVarNodeId) + ? beforeNodesOutputVars.find((v) => v.isStartNode) + : beforeNodesOutputVars.find((v) => v.nodeId === outputVarNodeId) - if (!targetVar) - return VarType.string + if (!targetVar) return VarType.string let arrayType: VarType = VarType.string let curr: any = targetVar.vars if (isSystem || isChatVar) { - arrayType = curr.find( - (v: any) => v.variable === valueSelector.join('.'), - )?.type - } - else { + arrayType = curr.find((v: any) => v.variable === valueSelector.join('.'))?.type + } else { for (let i = 1; i < valueSelector.length; i++) { const key = valueSelector[i] const isLast = i === valueSelector.length - 1 - curr = Array.isArray(curr) ? curr.find(v => v.variable === key) : [] + curr = Array.isArray(curr) ? curr.find((v) => v.variable === key) : [] - if (isLast) - arrayType = curr?.type + if (isLast) arrayType = curr?.type else if (curr?.type === VarType.object || curr?.type === VarType.file) curr = curr.children || [] } @@ -999,29 +915,23 @@ const getLoopItemType = ({ const isSystem = isSystemVar(valueSelector) const targetVar = isSystem - ? beforeNodesOutputVars.find(v => v.isStartNode) - : beforeNodesOutputVars.find(v => v.nodeId === outputVarNodeId) - if (!targetVar) - return VarType.string + ? beforeNodesOutputVars.find((v) => v.isStartNode) + : beforeNodesOutputVars.find((v) => v.nodeId === outputVarNodeId) + if (!targetVar) return VarType.string let arrayType: VarType = VarType.string let curr: any = targetVar.vars if (isSystem) { - arrayType = curr.find( - (v: any) => v.variable === valueSelector.join('.'), - )?.type - } - else { + arrayType = curr.find((v: any) => v.variable === valueSelector.join('.'))?.type + } else { valueSelector.slice(1).forEach((key, i) => { const isLast = i === valueSelector.length - 2 curr = curr?.find((v: any) => v.variable === key) if (isLast) { arrayType = curr?.type - } - else { - if (curr?.type === VarType.object || curr?.type === VarType.file) - curr = curr.children + } else { + if (curr?.type === VarType.object || curr?.type === VarType.file) curr = curr.children } }) } @@ -1073,8 +983,7 @@ export const getVarType = ({ schemaTypeDefinitions?: SchemaTypeDefinition[] preferSchemaType?: boolean }): VarType => { - if (isConstant) - return VarType.string + if (isConstant) return VarType.string const beforeNodesOutputVars = toNodeOutputVars( availableNodes, @@ -1102,8 +1011,7 @@ export const getVarType = ({ }) return itemType } - if (valueSelector[1] === 'index') - return VarType.number + if (valueSelector[1] === 'index') return VarType.number } const isLoopInnerVar = parentNode?.data.type === BlockEnum.Loop @@ -1121,92 +1029,69 @@ export const getVarType = ({ }) return itemType } - if (valueSelector[1] === 'index') - return VarType.number + if (valueSelector[1] === 'index') return VarType.number } const isGlobal = isGlobalVar(valueSelector) const isInStartNodeSysVar = isSystemVar(valueSelector) && !isGlobal const isEnv = isENV(valueSelector) const isChatVar = isConversationVar(valueSelector) - const isSharedRagVariable - = isRagVariableVar(valueSelector) && valueSelector[1] === 'shared' - const isInNodeRagVariable - = isRagVariableVar(valueSelector) && valueSelector[1] !== 'shared' + const isSharedRagVariable = isRagVariableVar(valueSelector) && valueSelector[1] === 'shared' + const isInNodeRagVariable = isRagVariableVar(valueSelector) && valueSelector[1] !== 'shared' const startNode = availableNodes.find((node: any) => { return node?.data.type === BlockEnum.Start }) const targetVarNodeId = (() => { - if (isInStartNodeSysVar) - return startNode?.id - if (isGlobal) - return 'global' - if (isInNodeRagVariable) - return valueSelector[1] + if (isInStartNodeSysVar) return startNode?.id + if (isGlobal) return 'global' + if (isInNodeRagVariable) return valueSelector[1] return valueSelector[0] })() - const targetVar = beforeNodesOutputVars.find( - v => v.nodeId === targetVarNodeId, - ) + const targetVar = beforeNodesOutputVars.find((v) => v.nodeId === targetVarNodeId) - if (!targetVar) - return VarType.string + if (!targetVar) return VarType.string let type: VarType = VarType.string let curr: any = targetVar.vars if (isInStartNodeSysVar || isEnv || isChatVar || isSharedRagVariable || isGlobal) { - return curr.find( - (v: any) => v.variable === (valueSelector as ValueSelector).join('.'), - )?.type - } - else { + return curr.find((v: any) => v.variable === (valueSelector as ValueSelector).join('.'))?.type + } else { const targetVar = curr.find((v: any) => { - if (isInNodeRagVariable) - return v.variable === valueSelector.join('.') + if (isInNodeRagVariable) return v.variable === valueSelector.join('.') return v.variable === valueSelector[1] }) - if (!targetVar) - return VarType.string + if (!targetVar) return VarType.string - if (isInNodeRagVariable) - return targetVar.type + if (isInNodeRagVariable) return targetVar.type const isStructuredOutputVar = !!targetVar.children?.schema?.properties if (isStructuredOutputVar) { if (valueSelector.length === 2) { // root - return preferSchemaType && targetVar.schemaType - ? targetVar.schemaType - : VarType.object + return preferSchemaType && targetVar.schemaType ? targetVar.schemaType : VarType.object } - let currProperties = targetVar.children.schema; - (valueSelector as ValueSelector).slice(2).forEach((key, i) => { + let currProperties = targetVar.children.schema + ;(valueSelector as ValueSelector).slice(2).forEach((key, i) => { const isLast = i === valueSelector.length - 3 - if (!currProperties) - return + if (!currProperties) return currProperties = currProperties.properties[key] - if (isLast) - type = structTypeToVarType(currProperties?.type) + if (isLast) type = structTypeToVarType(currProperties?.type) }) return type } - (valueSelector as ValueSelector).slice(1).forEach((key, i) => { + ;(valueSelector as ValueSelector).slice(1).forEach((key, i) => { const isLast = i === valueSelector.length - 2 - if (Array.isArray(curr)) - curr = curr?.find((v: any) => v.variable === key) + if (Array.isArray(curr)) curr = curr?.find((v: any) => v.variable === key) if (isLast) { - type - = preferSchemaType && curr?.schemaType ? curr?.schemaType : curr?.type - } - else { - if (curr?.type === VarType.object || curr?.type === VarType.file) - curr = curr.children + type = preferSchemaType && curr?.schemaType ? curr?.schemaType : curr?.type + } else { + if (curr?.type === VarType.object || curr?.type === VarType.file) curr = curr.children } }) return type @@ -1265,8 +1150,8 @@ export const toNodeAvailableVars = ({ allPluginInfoList, schemaTypeDefinitions, }) - const itemChildren - = itemType === VarType.file + const itemChildren = + itemType === VarType.file ? { children: OUTPUT_FILE_SUB_VARIABLES.map((key) => { return { @@ -1278,7 +1163,7 @@ export const toNodeAvailableVars = ({ : {} const iterationVar = { nodeId: iterationNode?.id, - title: translateWorkflowString(t!, $ => $['nodes.iteration.currentIteration']), + title: translateWorkflowString(t!, ($) => $['nodes.iteration.currentIteration']), vars: [ { variable: 'item', @@ -1291,60 +1176,42 @@ export const toNodeAvailableVars = ({ }, ], } - const iterationIndex = beforeNodesOutputVars.findIndex( - v => v.nodeId === iterationNode?.id, - ) - if (iterationIndex > -1) - beforeNodesOutputVars.splice(iterationIndex, 1) + const iterationIndex = beforeNodesOutputVars.findIndex((v) => v.nodeId === iterationNode?.id) + if (iterationIndex > -1) beforeNodesOutputVars.splice(iterationIndex, 1) beforeNodesOutputVars.unshift(iterationVar) } return beforeNodesOutputVars } export const getNodeInfoById = (nodes: any, id: string) => { - if (!isArray(nodes)) - return + if (!isArray(nodes)) return return nodes.find((node: any) => node.id === id) } const matchNotSystemVars = (prompts: string[]) => { - if (!prompts) - return [] + if (!prompts) return [] const allVars: string[] = [] prompts.forEach((prompt) => { VAR_REGEX.lastIndex = 0 - if (typeof prompt !== 'string') - return + if (typeof prompt !== 'string') return allVars.push(...(prompt.match(VAR_REGEX) || [])) }) - const uniqVars = uniq(allVars).map(v => - v.replaceAll('{{#', '').replace('#}}', '').split('.'), - ) + const uniqVars = uniq(allVars).map((v) => v.replaceAll('{{#', '').replace('#}}', '').split('.')) return uniqVars } -const replaceOldVarInText = ( - text: string, - oldVar: ValueSelector, - newVar: ValueSelector, -) => { - if (!text || typeof text !== 'string') - return text +const replaceOldVarInText = (text: string, oldVar: ValueSelector, newVar: ValueSelector) => { + if (!text || typeof text !== 'string') return text - if (!newVar || newVar.length === 0) - return text + if (!newVar || newVar.length === 0) return text - return text.replaceAll( - `{{#${oldVar.join('.')}#}}`, - `{{#${newVar.join('.')}#}}`, - ) + return text.replaceAll(`{{#${oldVar.join('.')}#}}`, `{{#${newVar.join('.')}#}}`) } const getPromptItemTexts = (prompt: PromptItem): string[] => { const texts = [prompt.text] - if (prompt.jinja2_text) - texts.push(prompt.jinja2_text) + if (prompt.jinja2_text) texts.push(prompt.jinja2_text) return texts.filter((text): text is string => !!text) } @@ -1380,12 +1247,10 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { const isChatModel = payload.model?.mode === AppModeEnum.CHAT let prompts: string[] = [] if (isChatModel) { - prompts - = (payload.prompt_template as PromptItem[])?.flatMap(getPromptItemTexts) || [] + prompts = (payload.prompt_template as PromptItem[])?.flatMap(getPromptItemTexts) || [] if (payload.memory?.query_prompt_template) prompts.push(payload.memory.query_prompt_template) - } - else { + } else { prompts = getPromptItemTexts(payload.prompt_template as PromptItem) } @@ -1397,10 +1262,8 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { break } case BlockEnum.KnowledgeRetrieval: { - const { - query_variable_selector, - query_attachment_selector = [], - } = data as KnowledgeRetrievalNodeType + const { query_variable_selector, query_attachment_selector = [] } = + data as KnowledgeRetrievalNodeType res = [query_variable_selector, query_attachment_selector] break } @@ -1408,17 +1271,16 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { res = [] res.push( ...((data as IfElseNodeType).cases || []) - .flatMap(c => c.conditions || []) + .flatMap((c) => c.conditions || []) .flatMap((c) => { const selectors: ValueSelector[] = [] - if (c.variable_selector) - selectors.push(c.variable_selector) + if (c.variable_selector) selectors.push(c.variable_selector) // Handle sub-variable conditions if (c.sub_variable_condition && c.sub_variable_condition.conditions) { selectors.push( ...c.sub_variable_condition.conditions - .map(subC => subC.variable_selector || []) - .filter(sel => sel.length > 0), + .map((subC) => subC.variable_selector || []) + .filter((sel) => sel.length > 0), ) } return selectors @@ -1444,7 +1306,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { const varInInstructions = matchNotSystemVars([payload.instruction || '']) res.push(...varInInstructions) - const classes = payload.classes.map(c => c.name) + const classes = payload.classes.map((c) => c.name) res.push(...matchNotSystemVars(classes)) break } @@ -1456,7 +1318,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { payload.params, typeof payload.body.data === 'string' ? payload.body.data - : payload.body.data.map(d => d.value).join(''), + : payload.body.data.map((d) => d.value).join(''), ]) break } @@ -1464,17 +1326,13 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { const payload = data as ToolNodeType const mixVars = matchNotSystemVars( Object.keys(payload.tool_parameters) - ?.filter( - key => payload.tool_parameters[key]!.type === ToolVarType.mixed, - ) - .map(key => payload.tool_parameters[key]!.value) as string[], + ?.filter((key) => payload.tool_parameters[key]!.type === ToolVarType.mixed) + .map((key) => payload.tool_parameters[key]!.value) as string[], ) - const vars - = Object.keys(payload.tool_parameters) - .filter( - key => payload.tool_parameters[key]!.type === ToolVarType.variable, - ) - .map(key => payload.tool_parameters[key]!.value as string) || [] + const vars = + Object.keys(payload.tool_parameters) + .filter((key) => payload.tool_parameters[key]!.type === ToolVarType.variable) + .map((key) => payload.tool_parameters[key]!.value as string) || [] res = [...(mixVars as ValueSelector[]), ...(vars as any)] break } @@ -1487,13 +1345,11 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { const payload = data as AgentNodeType const valueSelectors: ValueSelector[] = [] - if (!payload.agent_parameters) - break + if (!payload.agent_parameters) break Object.keys(payload.agent_parameters || {}).forEach((key) => { const { value } = payload.agent_parameters![key]! - if (typeof value === 'string') - valueSelectors.push(...matchNotSystemVars([value])) + if (typeof value === 'string') valueSelectors.push(...matchNotSystemVars([value])) }) res = valueSelectors break @@ -1507,20 +1363,13 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { const payload = data as DataSourceNodeType const mixVars = matchNotSystemVars( Object.keys(payload.datasource_parameters) - ?.filter( - key => - payload.datasource_parameters[key]!.type === ToolVarType.mixed, - ) - .map(key => payload.datasource_parameters[key]!.value) as string[], + ?.filter((key) => payload.datasource_parameters[key]!.type === ToolVarType.mixed) + .map((key) => payload.datasource_parameters[key]!.value) as string[], ) - const vars - = Object.keys(payload.datasource_parameters) - .filter( - key => - payload.datasource_parameters[key]!.type === ToolVarType.variable, - ) - .map(key => payload.datasource_parameters[key]!.value as string) - || [] + const vars = + Object.keys(payload.datasource_parameters) + .filter((key) => payload.datasource_parameters[key]!.type === ToolVarType.variable) + .map((key) => payload.datasource_parameters[key]!.value as string) || [] res = [...(mixVars as ValueSelector[]), ...(vars as any)] break } @@ -1550,8 +1399,8 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { case BlockEnum.Loop: { const payload = data as LoopNodeType - res - = payload.break_conditions?.map((c) => { + res = + payload.break_conditions?.map((c) => { return c.variable_selector || [] }) || [] break @@ -1566,8 +1415,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { const payload = data as HumanInputNodeType const formContent = payload.form_content const mailTemplates = payload.delivery_methods.flatMap((method) => { - if (method.type !== DeliveryMethodType.Email || !method.config) - return [] + if (method.type !== DeliveryMethodType.Email || !method.config) return [] return [method.config.body] }) const inputSelectors = payload.inputs.flatMap((input) => { @@ -1596,9 +1444,7 @@ export const getNodeUsedVarPassToServerKey = ( case BlockEnum.LLM: { const payload = data as LLMNodeType res = [`#${valueSelector.join('.')}#`] - if ( - payload.context?.variable_selector.join('.') === valueSelector.join('.') - ) + if (payload.context?.variable_selector.join('.') === valueSelector.join('.')) res.push('#context#') break @@ -1611,43 +1457,38 @@ export const getNodeUsedVarPassToServerKey = ( const findConditionInCases = (cases: CaseItem[]): Condition | undefined => { for (const caseItem of cases) { for (const condition of caseItem.conditions || []) { - if (condition.variable_selector?.join('.') === valueSelector.join('.')) - return condition + if (condition.variable_selector?.join('.') === valueSelector.join('.')) return condition if (condition.sub_variable_condition) { const found = findConditionInCases([condition.sub_variable_condition]) - if (found) - return found + if (found) return found } } } return undefined } const targetVar = findConditionInCases((data as IfElseNodeType).cases || []) - if (targetVar) - res = `#${valueSelector.join('.')}#` + if (targetVar) res = `#${valueSelector.join('.')}#` break } case BlockEnum.Code: { const targetVar = (data as CodeNodeType).variables?.find( - v => - Array.isArray(v.value_selector) - && v.value_selector - && v.value_selector.join('.') === valueSelector.join('.'), + (v) => + Array.isArray(v.value_selector) && + v.value_selector && + v.value_selector.join('.') === valueSelector.join('.'), ) - if (targetVar) - res = targetVar.variable + if (targetVar) res = targetVar.variable break } case BlockEnum.TemplateTransform: { const targetVar = (data as TemplateTransformNodeType).variables?.find( - v => - Array.isArray(v.value_selector) - && v.value_selector - && v.value_selector.join('.') === valueSelector.join('.'), + (v) => + Array.isArray(v.value_selector) && + v.value_selector && + v.value_selector.join('.') === valueSelector.join('.'), ) - if (targetVar) - res = targetVar.variable + if (targetVar) res = targetVar.variable break } case BlockEnum.QuestionClassifier: { @@ -1687,15 +1528,11 @@ export const getNodeUsedVarPassToServerKey = ( return res } -export const findUsedVarNodes = ( - varSelector: ValueSelector, - availableNodes: Node[], -): Node[] => { +export const findUsedVarNodes = (varSelector: ValueSelector, availableNodes: Node[]): Node[] => { const res: Node[] = [] availableNodes.forEach((node) => { const vars = getNodeUsedVars(node) - if (vars.find(v => v.join('.') === varSelector.join('.'))) - res.push(node) + if (vars.find((v) => v.join('.') === varSelector.join('.'))) res.push(node) }) return res } @@ -1723,11 +1560,7 @@ export const updateNodeVars = ( } case BlockEnum.Answer: { const payload = data as AnswerNodeType - payload.answer = replaceOldVarInText( - payload.answer, - oldVarSelector, - newVarSelector, - ) + payload.answer = replaceOldVarInText(payload.answer, oldVarSelector, newVarSelector) if (payload.variables) { payload.variables = payload.variables.map((v) => { if (v.value_selector.join('.') === oldVarSelector.join('.')) @@ -1741,9 +1574,9 @@ export const updateNodeVars = ( const payload = data as LLMNodeType const isChatModel = payload.model?.mode === AppModeEnum.CHAT if (isChatModel) { - payload.prompt_template = ( - payload.prompt_template as PromptItem[] - ).map(prompt => replaceOldVarInPromptItem(prompt, oldVarSelector, newVarSelector)) + payload.prompt_template = (payload.prompt_template as PromptItem[]).map((prompt) => + replaceOldVarInPromptItem(prompt, oldVarSelector, newVarSelector), + ) if (payload.memory?.query_prompt_template) { payload.memory.query_prompt_template = replaceOldVarInText( payload.memory.query_prompt_template, @@ -1751,18 +1584,14 @@ export const updateNodeVars = ( newVarSelector, ) } - } - else { + } else { payload.prompt_template = replaceOldVarInPromptItem( payload.prompt_template as PromptItem, oldVarSelector, newVarSelector, ) } - if ( - payload.context?.variable_selector?.join('.') - === oldVarSelector.join('.') - ) { + if (payload.context?.variable_selector?.join('.') === oldVarSelector.join('.')) { payload.context.variable_selector = newVarSelector } @@ -1770,13 +1599,9 @@ export const updateNodeVars = ( } case BlockEnum.KnowledgeRetrieval: { const payload = data as KnowledgeRetrievalNodeType - if ( - payload.query_variable_selector.join('.') === oldVarSelector.join('.') - ) + if (payload.query_variable_selector.join('.') === oldVarSelector.join('.')) payload.query_variable_selector = newVarSelector - if ( - payload.query_attachment_selector?.join('.') === oldVarSelector.join('.') - ) + if (payload.query_attachment_selector?.join('.') === oldVarSelector.join('.')) payload.query_attachment_selector = newVarSelector break } @@ -1789,20 +1614,15 @@ export const updateNodeVars = ( if (c.variable_selector?.join('.') === oldVarSelector.join('.')) c.variable_selector = newVarSelector // Handle sub-variable conditions - if ( - c.sub_variable_condition - && c.sub_variable_condition.conditions - ) { - c.sub_variable_condition.conditions - = c.sub_variable_condition.conditions.map((subC) => { - if ( - subC.variable_selector?.join('.') - === oldVarSelector.join('.') - ) { + if (c.sub_variable_condition && c.sub_variable_condition.conditions) { + c.sub_variable_condition.conditions = c.sub_variable_condition.conditions.map( + (subC) => { + if (subC.variable_selector?.join('.') === oldVarSelector.join('.')) { subC.variable_selector = newVarSelector } return subC - }) + }, + ) } return c }) @@ -1836,16 +1656,14 @@ export const updateNodeVars = ( } case BlockEnum.QuestionClassifier: { const payload = data as QuestionClassifierNodeType - if ( - payload.query_variable_selector.join('.') === oldVarSelector.join('.') - ) + if (payload.query_variable_selector.join('.') === oldVarSelector.join('.')) payload.query_variable_selector = newVarSelector payload.instruction = replaceOldVarInText( payload.instruction, oldVarSelector, newVarSelector, ) - payload.classes = payload.classes.map(topic => ({ + payload.classes = payload.classes.map((topic) => ({ ...topic, name: replaceOldVarInText(topic.name, oldVarSelector, newVarSelector), })) @@ -1853,37 +1671,16 @@ export const updateNodeVars = ( } case BlockEnum.HttpRequest: { const payload = data as HttpNodeType - payload.url = replaceOldVarInText( - payload.url, - oldVarSelector, - newVarSelector, - ) - payload.headers = replaceOldVarInText( - payload.headers, - oldVarSelector, - newVarSelector, - ) - payload.params = replaceOldVarInText( - payload.params, - oldVarSelector, - newVarSelector, - ) + payload.url = replaceOldVarInText(payload.url, oldVarSelector, newVarSelector) + payload.headers = replaceOldVarInText(payload.headers, oldVarSelector, newVarSelector) + payload.params = replaceOldVarInText(payload.params, oldVarSelector, newVarSelector) if (typeof payload.body.data === 'string') { - payload.body.data = replaceOldVarInText( - payload.body.data, - oldVarSelector, - newVarSelector, - ) - } - else { + payload.body.data = replaceOldVarInText(payload.body.data, oldVarSelector, newVarSelector) + } else { payload.body.data = payload.body.data.map((d) => { return { ...d, - value: replaceOldVarInText( - d.value || '', - oldVarSelector, - newVarSelector, - ), + value: replaceOldVarInText(d.value || '', oldVarSelector, newVarSelector), } }) } @@ -1892,15 +1689,15 @@ export const updateNodeVars = ( case BlockEnum.Tool: { const payload = data as ToolNodeType const hasShouldRenameVar = Object.keys(payload.tool_parameters)?.filter( - key => payload.tool_parameters[key]!.type !== ToolVarType.constant, + (key) => payload.tool_parameters[key]!.type !== ToolVarType.constant, ) if (hasShouldRenameVar) { Object.keys(payload.tool_parameters).forEach((key) => { const value = payload.tool_parameters[key]! const { type } = value! if ( - type === ToolVarType.variable - && value!.value.join('.') === oldVarSelector.join('.') + type === ToolVarType.variable && + value!.value.join('.') === oldVarSelector.join('.') ) { payload.tool_parameters[key] = { ...value, @@ -1940,9 +1737,9 @@ export const updateNodeVars = ( const { type } = value! if ( - type === ToolVarType.variable - && Array.isArray(value!.value) - && value!.value.join('.') === oldVarSelector.join('.') + type === ToolVarType.variable && + Array.isArray(value!.value) && + value!.value.join('.') === oldVarSelector.join('.') ) { payload.agent_parameters![key] = { ...value, @@ -1953,11 +1750,7 @@ export const updateNodeVars = ( if (type === ToolVarType.mixed && typeof value!.value === 'string') { payload.agent_parameters![key] = { ...value, - value: replaceOldVarInText( - value!.value, - oldVarSelector, - newVarSelector, - ), + value: replaceOldVarInText(value!.value, oldVarSelector, newVarSelector), } } }) @@ -1983,19 +1776,16 @@ export const updateNodeVars = ( } case BlockEnum.DataSource: { const payload = data as DataSourceNodeType - const hasShouldRenameVar = Object.keys( - payload.datasource_parameters, - )?.filter( - key => - payload.datasource_parameters[key]!.type !== ToolVarType.constant, + const hasShouldRenameVar = Object.keys(payload.datasource_parameters)?.filter( + (key) => payload.datasource_parameters[key]!.type !== ToolVarType.constant, ) if (hasShouldRenameVar) { Object.keys(payload.datasource_parameters).forEach((key) => { const value = payload.datasource_parameters[key]! const { type } = value! if ( - type === ToolVarType.variable - && value!.value.join('.') === oldVarSelector.join('.') + type === ToolVarType.variable && + value!.value.join('.') === oldVarSelector.join('.') ) { payload.datasource_parameters[key] = { ...value, @@ -2021,8 +1811,7 @@ export const updateNodeVars = ( const payload = data as VariableAssignerNodeType if (payload.variables) { payload.variables = payload.variables.map((v) => { - if (v.join('.') === oldVarSelector.join('.')) - v = newVarSelector + if (v.join('.') === oldVarSelector.join('.')) v = newVarSelector return v }) } @@ -2033,8 +1822,7 @@ export const updateNodeVars = ( const payload = data as VariableAssignerNodeType if (payload.variables) { payload.variables = payload.variables.map((v) => { - if (v.join('.') === oldVarSelector.join('.')) - v = newVarSelector + if (v.join('.') === oldVarSelector.join('.')) v = newVarSelector return v }) } @@ -2042,8 +1830,7 @@ export const updateNodeVars = ( } case BlockEnum.ParameterExtractor: { const payload = data as ParameterExtractorNodeType - if (payload.query.join('.') === oldVarSelector.join('.')) - payload.query = newVarSelector + if (payload.query.join('.') === oldVarSelector.join('.')) payload.query = newVarSelector payload.instruction = replaceOldVarInText( payload.instruction, oldVarSelector, @@ -2083,26 +1870,29 @@ export const updateNodeVars = ( newVarSelector, ) payload.delivery_methods = payload.delivery_methods.map((method) => { - if (method.type !== DeliveryMethodType.Email || !method.config) - return method + if (method.type !== DeliveryMethodType.Email || !method.config) return method return { ...method, config: { ...method.config, - body: replaceOldVarInText( - method.config.body, - oldVarSelector, - newVarSelector, - ), + body: replaceOldVarInText(method.config.body, oldVarSelector, newVarSelector), }, } }) payload.inputs = payload.inputs.map((input) => { - if (input.type === InputVarType.paragraph && input.default.type === 'variable' && input.default.selector.join('.') === oldVarSelector.join('.')) { + if ( + input.type === InputVarType.paragraph && + input.default.type === 'variable' && + input.default.selector.join('.') === oldVarSelector.join('.') + ) { input.default.selector = newVarSelector } - if (input.type === InputVarType.select && input.option_source.type === 'variable' && input.option_source.selector.join('.') === oldVarSelector.join('.')) { + if ( + input.type === InputVarType.select && + input.option_source.type === 'variable' && + input.option_source.selector.join('.') === oldVarSelector.join('.') + ) { input.option_source.selector = newVarSelector } return input @@ -2119,26 +1909,21 @@ const varToValueSelectorList = ( parentValueSelector: ValueSelector, res: ValueSelector[], ) => { - if (!v.variable) - return + if (!v.variable) return res.push([...parentValueSelector, v.variable]) const isStructuredOutput = !!(v.children as StructuredOutput)?.schema?.properties if ((v.children as Var[])?.length > 0) { - (v.children as Var[]).forEach((child) => { + ;(v.children as Var[]).forEach((child) => { varToValueSelectorList(child, [...parentValueSelector, v.variable], res) }) } if (isStructuredOutput) { - Object.keys( - (v.children as StructuredOutput)?.schema?.properties || {}, - ).forEach((key) => { + Object.keys((v.children as StructuredOutput)?.schema?.properties || {}).forEach((key) => { const type = (v.children as StructuredOutput)?.schema?.properties[key]!.type const isArray = type === Type.array - const arrayType = (v.children as StructuredOutput)?.schema?.properties[ - key - ]!.items?.type + const arrayType = (v.children as StructuredOutput)?.schema?.properties[key]!.items?.type varToValueSelectorList( { variable: key, @@ -2164,10 +1949,7 @@ const varsToValueSelectorList = ( varToValueSelectorList(vars as Var, parentValueSelector, res) } -export const getNodeOutputVars = ( - node: Node, - isChatMode: boolean, -): ValueSelector[] => { +export const getNodeOutputVars = (node: Node, isChatMode: boolean): ValueSelector[] => { const { data, id } = node const { type } = data let res: ValueSelector[] = [] @@ -2190,9 +1972,9 @@ export const getNodeOutputVars = ( const vars = [...LLM_OUTPUT_STRUCT] const llmNodeData = data as LLMNodeType if ( - llmNodeData.structured_output_enabled - && llmNodeData.structured_output?.schema?.properties - && Object.keys(llmNodeData.structured_output.schema.properties).length > 0 + llmNodeData.structured_output_enabled && + llmNodeData.structured_output?.schema?.properties && + Object.keys(llmNodeData.structured_output.schema.properties).length > 0 ) { vars.push({ variable: 'structured_output', diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-full-path-panel.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-full-path-panel.tsx index 74425897b150e9..4f98167214239c 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-full-path-panel.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-full-path-panel.tsx @@ -14,12 +14,7 @@ type Props = Readonly<{ nodeType?: BlockEnum }> -const VarFullPathPanel: FC<Props> = ({ - nodeName, - path, - varType, - nodeType = BlockEnum.LLM, -}) => { +const VarFullPathPanel: FC<Props> = ({ nodeName, path, varType, nodeType = BlockEnum.LLM }) => { const schema: StructuredOutput = (() => { const schema: StructuredOutput['schema'] = { type: Type.object, @@ -35,7 +30,12 @@ const VarFullPathPanel: FC<Props> = ({ type: isLast ? varType : Type.object, properties: {}, } as Field - current = current.properties[name!] as { type: Type.object, properties: { [key: string]: Field }, required: never[], additionalProperties: false } + current = current.properties[name!] as { + type: Type.object + properties: { [key: string]: Field } + required: never[] + additionalProperties: false + } } return { schema, @@ -47,12 +47,7 @@ const VarFullPathPanel: FC<Props> = ({ <BlockIcon size="xs" type={nodeType} /> <div className="w-0 grow truncate system-xs-medium text-text-secondary">{nodeName}</div> </div> - <Panel - className="px-1 pt-2 pb-3" - root={{ attrName: path[0]! }} - payload={schema} - readonly - /> + <Panel className="px-1 pt-2 pb-3" root={{ attrName: path[0]! }} payload={schema} readonly /> </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx index f55650d4c67ce5..80948525fe3881 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx @@ -42,79 +42,101 @@ const VarList: FC<Props> = ({ }) => { const { t } = useTranslation() - const listWithIds = useMemo(() => list.map((item) => { - const id = uuid4() - return { - id, - variable: { ...item }, - } - }), [list]) + const listWithIds = useMemo( + () => + list.map((item) => { + const id = uuid4() + return { + id, + variable: { ...item }, + } + }), + [list], + ) - const { run: validateVarInput } = useDebounceFn((list: Variable[], newKey: string) => { - const result = checkKeys([newKey], true) - if (!result.isValid) { - toast.error(t($ => $[`varKeyError.${result.errorMessageKey}`], { ns: 'appDebug', key: result.errorKey })) - return - } - if (list.some(item => item.variable?.trim() === newKey.trim())) { - toast.error(t($ => $['varKeyError.keyAlreadyExists'], { ns: 'appDebug', key: newKey })) - } - }, { wait: 500 }) + const { run: validateVarInput } = useDebounceFn( + (list: Variable[], newKey: string) => { + const result = checkKeys([newKey], true) + if (!result.isValid) { + toast.error( + t(($) => $[`varKeyError.${result.errorMessageKey}`], { + ns: 'appDebug', + key: result.errorKey, + }), + ) + return + } + if (list.some((item) => item.variable?.trim() === newKey.trim())) { + toast.error(t(($) => $['varKeyError.keyAlreadyExists'], { ns: 'appDebug', key: newKey })) + } + }, + { wait: 500 }, + ) - const handleVarNameChange = useCallback((index: number) => { - return (e: React.ChangeEvent<HTMLInputElement>) => { - replaceSpaceWithUnderscoreInVarNameInput(e.target) + const handleVarNameChange = useCallback( + (index: number) => { + return (e: React.ChangeEvent<HTMLInputElement>) => { + replaceSpaceWithUnderscoreInVarNameInput(e.target) - const newKey = e.target.value + const newKey = e.target.value - validateVarInput(list.filter((_, itemIndex) => itemIndex !== index), newKey) + validateVarInput( + list.filter((_, itemIndex) => itemIndex !== index), + newKey, + ) - onVarNameChange?.(list[index]!.variable, newKey) - const newList = produce(list, (draft) => { - draft[index]!.variable = newKey - }) - onChange(newList) - } - }, [list, onVarNameChange, onChange, validateVarInput]) + onVarNameChange?.(list[index]!.variable, newKey) + const newList = produce(list, (draft) => { + draft[index]!.variable = newKey + }) + onChange(newList) + } + }, + [list, onVarNameChange, onChange, validateVarInput], + ) - const handleVarReferenceChange = useCallback((index: number) => { - return (value: ValueSelector | string, varKindType: VarKindType, varInfo?: Var) => { - const newList = produce(list, (draft) => { - if (!isSupportConstantValue || varKindType === VarKindType.variable) { - draft[index]!.value_selector = value as ValueSelector - draft[index]!.value_type = varInfo?.type - if (isSupportConstantValue) - draft[index]!.variable_type = VarKindType.variable + const handleVarReferenceChange = useCallback( + (index: number) => { + return (value: ValueSelector | string, varKindType: VarKindType, varInfo?: Var) => { + const newList = produce(list, (draft) => { + if (!isSupportConstantValue || varKindType === VarKindType.variable) { + draft[index]!.value_selector = value as ValueSelector + draft[index]!.value_type = varInfo?.type + if (isSupportConstantValue) draft[index]!.variable_type = VarKindType.variable - if (!draft[index]!.variable) { - const variables = draft.map(v => v.variable) - let newVarName = value[value.length - 1]! - let count = 1 - while (variables.includes(newVarName!)) { - newVarName = `${value[value.length - 1]}_${count}` - count++ + if (!draft[index]!.variable) { + const variables = draft.map((v) => v.variable) + let newVarName = value[value.length - 1]! + let count = 1 + while (variables.includes(newVarName!)) { + newVarName = `${value[value.length - 1]}_${count}` + count++ + } + draft[index]!.variable = newVarName } - draft[index]!.variable = newVarName + } else { + draft[index]!.variable_type = VarKindType.constant + draft[index]!.value_selector = value as ValueSelector + draft[index]!.value = value as string } - } - else { - draft[index]!.variable_type = VarKindType.constant - draft[index]!.value_selector = value as ValueSelector - draft[index]!.value = value as string - } - }) - onChange(newList) - } - }, [isSupportConstantValue, list, onChange]) + }) + onChange(newList) + } + }, + [isSupportConstantValue, list, onChange], + ) - const handleVarRemove = useCallback((index: number) => { - return () => { - const newList = produce(list, (draft) => { - draft.splice(index, 1) - }) - onChange(newList) - } - }, [list, onChange]) + const handleVarRemove = useCallback( + (index: number) => { + return () => { + const newList = produce(list, (draft) => { + draft.splice(index, 1) + }) + onChange(newList) + } + }, + [list, onChange], + ) const varCount = list.length @@ -122,15 +144,16 @@ const VarList: FC<Props> = ({ <ReactSortable className="space-y-2" list={listWithIds} - setList={(list) => { onChange(list.map(item => item.variable)) }} + setList={(list) => { + onChange(list.map((item) => item.variable)) + }} handle=".handle" ghostClass="opacity-50" animation={150} > {list.map((variable, index) => { const canDrag = (() => { - if (readonly) - return false + if (readonly) return false return varCount > 1 })() return ( @@ -140,14 +163,18 @@ const VarList: FC<Props> = ({ disabled={readonly} value={variable.variable} onChange={handleVarNameChange(index)} - placeholder={t($ => $['common.variableNamePlaceholder'], { ns: 'workflow' })!} + placeholder={t(($) => $['common.variableNamePlaceholder'], { ns: 'workflow' })!} /> <VarReferencePicker nodeId={nodeId} readonly={readonly} isShowNodeName className="grow" - value={variable.variable_type === VarKindType.constant ? (variable.value || '') : (variable.value_selector || [])} + value={ + variable.variable_type === VarKindType.constant + ? variable.value || '' + : variable.value_selector || [] + } isSupportConstantValue={isSupportConstantValue} onChange={handleVarReferenceChange(index)} defaultVarKindType={variable.variable_type} @@ -155,14 +182,13 @@ const VarList: FC<Props> = ({ filterVar={filterVar} isSupportFileVar={isSupportFileVar} /> - {!readonly && ( - <RemoveButton onClick={handleVarRemove(index)} /> - )} + {!readonly && <RemoveButton onClick={handleVarRemove(index)} />} {canDrag && ( - <RiDraggable className={cn( - 'handle absolute top-2.5 -left-4 hidden size-3 cursor-pointer text-text-quaternary', - 'group-hover:block', - )} + <RiDraggable + className={cn( + 'handle absolute top-2.5 -left-4 hidden size-3 cursor-pointer text-text-quaternary', + 'group-hover:block', + )} /> )} </div> diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.helpers.ts b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.helpers.ts index f29e99cc37b538..602134d7a2f89d 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.helpers.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.helpers.ts @@ -1,10 +1,25 @@ 'use client' import type { VarType as VarKindType } from '../../../tool/types' -import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { CommonNodeType, Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' +import type { + CredentialFormSchema, + FormOption, +} from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + CommonNodeType, + Node, + NodeOutPutVar, + ValueSelector, +} from '@/app/components/workflow/types' import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants' -import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from './utils' +import { + getNodeInfoById, + isConversationVar, + isENV, + isGlobalVar, + isRagVariableVar, + isSystemVar, +} from './utils' type DynamicSchemaParams = { dynamicOptions: FormOption[] | null @@ -34,10 +49,10 @@ type OutputVarNodeParams = { value: ValueSelector | string } -export const getVarKindOptions = (variableLabel = 'Variable', constantLabel = 'Constant') => ([ +export const getVarKindOptions = (variableLabel = 'Variable', constantLabel = 'Constant') => [ { label: variableLabel, value: 'variable' as VarKindType }, { label: constantLabel, value: 'constant' as VarKindType }, -]) +] export const getHasValue = (isConstant: boolean, value: ValueSelector | string) => !isConstant && value.length > 0 @@ -47,8 +62,7 @@ export const getIsIterationVar = ( value: ValueSelector | string, parentId?: string, ) => { - if (!isInIteration || !Array.isArray(value)) - return false + if (!isInIteration || !Array.isArray(value)) return false return value[0] === parentId && ['item', 'index'].includes(value[1]!) } @@ -57,8 +71,7 @@ export const getIsLoopVar = ( value: ValueSelector | string, parentId?: string, ) => { - if (!isInLoop || !Array.isArray(value)) - return false + if (!isInLoop || !Array.isArray(value)) return false return value[0] === parentId && ['item', 'index'].includes(value[1]!) } @@ -74,21 +87,16 @@ export const getOutputVarNode = ({ startNode, value, }: OutputVarNodeParams) => { - if (!hasValue || isConstant) - return null + if (!hasValue || isConstant) return null - if (isIterationVar) - return iterationNode?.data ?? null + if (isIterationVar) return iterationNode?.data ?? null - if (isLoopVar) - return loopNode?.data ?? null + if (isLoopVar) return loopNode?.data ?? null - if (isSystemVar(value as ValueSelector)) - return startNode?.data ?? null + if (isSystemVar(value as ValueSelector)) return startNode?.data ?? null const node = getNodeInfoById(availableNodes, outputVarNodeId)?.data - if (!node) - return null + if (!node) return null return { ...node, @@ -96,16 +104,11 @@ export const getOutputVarNode = ({ } } -export const getVarDisplayName = ( - hasValue: boolean, - value: ValueSelector | string, -) => { - if (!hasValue || !Array.isArray(value)) - return '' +export const getVarDisplayName = (hasValue: boolean, value: ValueSelector | string) => { + if (!hasValue || !Array.isArray(value)) return '' const showName = VAR_SHOW_NAME_MAP[value.join('.')] - if (showName) - return showName + if (showName) return showName const isSystem = isSystemVar(value) const varName = value[value.length - 1] ?? '' @@ -126,9 +129,12 @@ export const getVariableMeta = ( const isGlobal = isSelectorValue && isGlobalVar(selector) const isRagVar = isSelectorValue && isRagVariableVar(selector) const isSpecialVar = isEnv || isChatVar || isRagVar - const hasAvailableSpecialVar = !canValidateSpecialVars || !isSelectorValue || availableVars.some(nodeWithVars => - nodeWithVars.vars.some(variable => variable.variable === selector.join('.')), - ) + const hasAvailableSpecialVar = + !canValidateSpecialVars || + !isSelectorValue || + availableVars.some((nodeWithVars) => + nodeWithVars.vars.some((variable) => variable.variable === selector.join('.')), + ) const isValidVar = Boolean(outputVarNode) || isGlobal || (isSpecialVar && hasAvailableSpecialVar) return { isChatVar, @@ -147,16 +153,11 @@ export const getVariableCategory = ({ isLoopVar, isRagVar, }: VariableCategoryParams) => { - if (isEnv) - return 'environment' - if (isChatVar) - return 'conversation' - if (isGlobal) - return 'global' - if (isLoopVar) - return 'loop' - if (isRagVar) - return 'rag' + if (isEnv) return 'environment' + if (isChatVar) return 'conversation' + if (isGlobal) return 'global' + if (isLoopVar) return 'loop' + if (isRagVar) return 'rag' return 'system' } @@ -171,11 +172,12 @@ export const getWidthAllocations = ( const priorityWidth = nodeTitle ? 15 : 0 const minVarNameWidth = varName ? 16 : 0 return { - maxNodeNameWidth: priorityWidth + Math.floor(nodeTitle.length / totalTextLength * availableWidth), - maxTypeWidth: Math.floor(type.length / totalTextLength * availableWidth), + maxNodeNameWidth: + priorityWidth + Math.floor((nodeTitle.length / totalTextLength) * availableWidth), + maxTypeWidth: Math.floor((type.length / totalTextLength) * availableWidth), maxVarNameWidth: Math.max( minVarNameWidth, - -priorityWidth + Math.floor(varName.length / totalTextLength * availableWidth), + -priorityWidth + Math.floor((varName.length / totalTextLength) * availableWidth), ), } } @@ -186,8 +188,7 @@ export const getDynamicSelectSchema = ({ schema, value, }: DynamicSchemaParams) => { - if (schema?.type !== 'dynamic-select') - return schema + if (schema?.type !== 'dynamic-select') return schema if (dynamicOptions) { return { @@ -199,11 +200,13 @@ export const getDynamicSelectSchema = ({ if (isLoading && value && typeof value === 'string') { return { ...schema, - options: [{ - value, - label: { en_US: value, zh_Hans: value }, - show_on: [], - }], + options: [ + { + value, + label: { en_US: value, zh_Hans: value }, + show_on: [], + }, + ], } } @@ -213,15 +216,9 @@ export const getDynamicSelectSchema = ({ } } -export const getTooltipContent = ( - hasValue: boolean, - isShowAPart: boolean, - isValidVar: boolean, -) => { - if (isValidVar && isShowAPart) - return 'full-path' - if (!isValidVar && hasValue) - return 'invalid-variable' +export const getTooltipContent = (hasValue: boolean, isShowAPart: boolean, isValidVar: boolean) => { + if (isValidVar && isShowAPart) return 'full-path' + if (!isValidVar && hasValue) return 'invalid-variable' return null } diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx index 5358cbefb683ff..a177258d4d2d8c 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx @@ -2,15 +2,28 @@ import type { FC, ReactElement } from 'react' import type { VarType as VarKindType } from '../../../tool/types' -import type { CredentialFormSchema, CredentialFormSchemaSelect } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + CredentialFormSchema, + CredentialFormSchemaSelect, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Tool } from '@/app/components/tools/types' import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' import type { Node, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { PopoverTrigger } from '@langgenius/dify-ui/popover' -import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card' +import { + PreviewCard, + PreviewCardContent, + PreviewCardTrigger, +} from '@langgenius/dify-ui/preview-card' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { RiArrowDownSLine, RiCloseLine, RiErrorWarningFill, RiLoader4Line, RiMoreLine } from '@remixicon/react' +import { + RiArrowDownSLine, + RiCloseLine, + RiErrorWarningFill, + RiLoader4Line, + RiMoreLine, +} from '@remixicon/react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { Line3 } from '@/app/components/base/icons/src/public/common' @@ -21,9 +34,9 @@ import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/com import RemoveButton from '../remove-button' import ConstantField from './constant-field' -export type HoverPopup - = | { kind: 'full-path', panel: ReactElement } - | { kind: 'invalid-variable', message: string } +export type HoverPopup = + | { kind: 'full-path'; panel: ReactElement } + | { kind: 'invalid-variable'; message: string } type Props = Readonly<{ className?: string @@ -67,7 +80,7 @@ type Props = Readonly<{ value: ValueSelector | string valueTypePlaceHolder?: string varKindType: VarKindType - varKindTypes: Array<{ label: string, value: VarKindType }> + varKindTypes: Array<{ label: string; value: VarKindType }> varName: string variableCategory: string }> @@ -117,217 +130,254 @@ const VarReferencePickerTrigger: FC<Props> = ({ }) => { const { t } = useTranslation() const handleTriggerReadonlyClick = (e: React.MouseEvent<HTMLElement>) => { - if (!readonly) - return + if (!readonly) return e.preventDefault() e.stopPropagation() } const pill = ( - <div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}> - {hasValue - ? ( - <> - {isShowNodeName && ( - <div - className="flex items-center" - onClick={(e) => { - if (e.metaKey || e.ctrlKey) - handleVariableJump(outputVarNodeId || '') - }} - > - <div className="h-3 px-px"> - {'type' in (outputVarNode || {}) && outputVarNode?.type && ( - <VarBlockIcon - type={outputVarNode.type} - className="text-text-primary" - /> - )} - </div> - <div - className="mx-0.5 truncate text-xs font-medium text-text-secondary" - title={outputVarNode?.title as string | undefined} - style={{ maxWidth: maxNodeNameWidth }} - > - {outputVarNode?.title as string | undefined} - </div> - <Line3 className="mr-0.5"></Line3> - </div> - )} - {isShowAPart && ( - <div className="flex items-center"> - <RiMoreLine className="size-3 text-text-secondary" /> - <Line3 className="mr-0.5 text-divider-deep"></Line3> - </div> - )} - <div className="flex items-center text-text-accent"> - {isLoading && <RiLoader4Line className="size-3.5 animate-spin text-text-secondary" />} - <VariableIconWithColor - variables={value as ValueSelector} - variableCategory={variableCategory} - isExceptionVariable={isException} - /> - <div - className={cn('ml-0.5 truncate text-xs font-medium', isException && 'text-text-warning')} - title={varName} - style={{ maxWidth: maxVarNameWidth }} - > - {varName} - </div> + <div + className={cn( + 'h-full items-center rounded-[5px] px-1.5', + hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex', + )} + > + {hasValue ? ( + <> + {isShowNodeName && ( + <div + className="flex items-center" + onClick={(e) => { + if (e.metaKey || e.ctrlKey) handleVariableJump(outputVarNodeId || '') + }} + > + <div className="h-3 px-px"> + {'type' in (outputVarNode || {}) && outputVarNode?.type && ( + <VarBlockIcon type={outputVarNode.type} className="text-text-primary" /> + )} </div> <div - className="ml-0.5 truncate text-center system-xs-regular text-text-tertiary capitalize" - title={type} - style={{ maxWidth: maxTypeWidth }} + className="mx-0.5 truncate text-xs font-medium text-text-secondary" + title={outputVarNode?.title as string | undefined} + style={{ maxWidth: maxNodeNameWidth }} > - {type} + {outputVarNode?.title as string | undefined} </div> - {showErrorIcon && <RiErrorWarningFill data-testid="var-reference-picker-error-icon" className="ml-0.5 size-3 text-text-destructive" />} - </> - ) - : ( - <div className={`overflow-hidden ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'} system-sm-regular text-ellipsis`}> - {isLoading - ? ( - <div className="flex items-center"> - <RiLoader4Line className="mr-1 size-3.5 animate-spin text-text-secondary" /> - <span>{placeholder}</span> - </div> - ) - : placeholder} + <Line3 className="mr-0.5"></Line3> + </div> + )} + {isShowAPart && ( + <div className="flex items-center"> + <RiMoreLine className="size-3 text-text-secondary" /> + <Line3 className="mr-0.5 text-divider-deep"></Line3> </div> )} + <div className="flex items-center text-text-accent"> + {isLoading && <RiLoader4Line className="size-3.5 animate-spin text-text-secondary" />} + <VariableIconWithColor + variables={value as ValueSelector} + variableCategory={variableCategory} + isExceptionVariable={isException} + /> + <div + className={cn( + 'ml-0.5 truncate text-xs font-medium', + isException && 'text-text-warning', + )} + title={varName} + style={{ maxWidth: maxVarNameWidth }} + > + {varName} + </div> + </div> + <div + className="ml-0.5 truncate text-center system-xs-regular text-text-tertiary capitalize" + title={type} + style={{ maxWidth: maxTypeWidth }} + > + {type} + </div> + {showErrorIcon && ( + <RiErrorWarningFill + data-testid="var-reference-picker-error-icon" + className="ml-0.5 size-3 text-text-destructive" + /> + )} + </> + ) : ( + <div + className={`overflow-hidden ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'} system-sm-regular text-ellipsis`} + > + {isLoading ? ( + <div className="flex items-center"> + <RiLoader4Line className="mr-1 size-3.5 animate-spin text-text-secondary" /> + <span>{placeholder}</span> + </div> + ) : ( + placeholder + )} + </div> + )} </div> ) - const hoveredPill = hoverPopup?.kind === 'full-path' - ? ( - <PreviewCard> - <PreviewCardTrigger delay={300} closeDelay={200} render={pill} /> - <PreviewCardContent popupClassName="border-0 bg-transparent p-0 shadow-none"> - {hoverPopup.panel} - </PreviewCardContent> - </PreviewCard> - ) - : hoverPopup?.kind === 'invalid-variable' - ? ( - <Tooltip> - <TooltipTrigger render={pill} /> - <TooltipContent>{hoverPopup.message}</TooltipContent> - </Tooltip> - ) - : pill + const hoveredPill = + hoverPopup?.kind === 'full-path' ? ( + <PreviewCard> + <PreviewCardTrigger delay={300} closeDelay={200} render={pill} /> + <PreviewCardContent popupClassName="border-0 bg-transparent p-0 shadow-none"> + {hoverPopup.panel} + </PreviewCardContent> + </PreviewCard> + ) : hoverPopup?.kind === 'invalid-variable' ? ( + <Tooltip> + <TooltipTrigger render={pill} /> + <TooltipContent>{hoverPopup.message}</TooltipContent> + </Tooltip> + ) : ( + pill + ) const variablePicker = ( <div className="h-full grow"> - <div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}> + <div + ref={isSupportConstantValue ? triggerRef : null} + className={cn( + 'h-full', + isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1', + )} + > {hoveredPill} </div> </div> ) - const resolvedVariablePicker = isSupportConstantValue - ? ( - readonly - ? variablePicker - : ( - <PopoverTrigger - nativeButton={false} - render={variablePicker} - onClick={handleTriggerReadonlyClick} - /> - ) - ) - : variablePicker + const resolvedVariablePicker = isSupportConstantValue ? ( + readonly ? ( + variablePicker + ) : ( + <PopoverTrigger + nativeButton={false} + render={variablePicker} + onClick={handleTriggerReadonlyClick} + /> + ) + ) : ( + variablePicker + ) const triggerContent = ( <div - className={cn(className, 'group/picker-trigger-wrap relative flex!', !readonly && 'cursor-pointer')} + className={cn( + className, + 'group/picker-trigger-wrap relative flex!', + !readonly && 'cursor-pointer', + )} data-testid="var-reference-picker-trigger" onClick={() => { - if (!isConstant || readonly) - return + if (!isConstant || readonly) return setControlFocus(Date.now()) }} > <> - {isAddBtnTrigger - ? ( - <div> - <button - type="button" - aria-label={t($ => $['operation.add'], { ns: 'common' })} - className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={() => {}} - > - <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> - </button> - </div> - ) - : ( - <div ref={!isSupportConstantValue ? triggerRef : null} className={cn((open || isFocus) ? 'border-gray-300' : 'border-gray-100', 'group/wrap relative flex h-8 w-full items-center', !isSupportConstantValue && 'rounded-lg bg-components-input-bg-normal p-1', isInTable && 'border-none bg-transparent', readonly && 'bg-components-input-bg-disabled', isJustShowValue && 'h-6 bg-transparent p-0')}> - {isSupportConstantValue - ? ( - <div - onClick={(e) => { - e.stopPropagation() - setOpen(false) - setControlFocus(Date.now()) - }} - className="mr-1 flex h-full items-center space-x-1" - > - <TypeSelector - noLeft - trigger={( - <div className="flex h-8 items-center rounded-lg bg-components-input-bg-normal px-2"> - <div className="mr-1 system-sm-regular text-components-input-text-filled">{varKindTypes.find(item => item.value === varKindType)?.label}</div> - <RiArrowDownSLine className="size-4 text-text-quaternary" /> - </div> - )} - popupClassName="top-8" - readonly={readonly} - value={varKindType} - options={varKindTypes} - onChange={handleVarKindTypeChange} - showChecked - /> - </div> - ) - : (!hasValue && ( - <div className="mr-1 ml-1.5"> - <Variable02 className={`size-4 ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'}`} /> + {isAddBtnTrigger ? ( + <div> + <button + type="button" + aria-label={t(($) => $['operation.add'], { ns: 'common' })} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => {}} + > + <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> + </button> + </div> + ) : ( + <div + ref={!isSupportConstantValue ? triggerRef : null} + className={cn( + open || isFocus ? 'border-gray-300' : 'border-gray-100', + 'group/wrap relative flex h-8 w-full items-center', + !isSupportConstantValue && 'rounded-lg bg-components-input-bg-normal p-1', + isInTable && 'border-none bg-transparent', + readonly && 'bg-components-input-bg-disabled', + isJustShowValue && 'h-6 bg-transparent p-0', + )} + > + {isSupportConstantValue ? ( + <div + onClick={(e) => { + e.stopPropagation() + setOpen(false) + setControlFocus(Date.now()) + }} + className="mr-1 flex h-full items-center space-x-1" + > + <TypeSelector + noLeft + trigger={ + <div className="flex h-8 items-center rounded-lg bg-components-input-bg-normal px-2"> + <div className="mr-1 system-sm-regular text-components-input-text-filled"> + {varKindTypes.find((item) => item.value === varKindType)?.label} </div> - ))} - {isConstant - ? ( - <ConstantField - value={value as string} - onChange={onChange as ((value: string | number, varKindType: VarKindType, varInfo?: Var) => void)} - schema={schemaWithDynamicSelect as CredentialFormSchemaSelect} - readonly={readonly} - isLoading={isLoading} - /> - ) - : resolvedVariablePicker} - {(hasValue && !readonly && !isInTable && !isJustShowValue) && ( - <button - type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} - className="group invisible absolute top-[50%] right-1 h-5 translate-y-[-50%] cursor-pointer rounded-md border-none bg-transparent p-1 group-hover/wrap:visible hover:bg-state-base-hover" - onClick={handleClearVar} - > - <RiCloseLine className="size-3.5 text-text-tertiary group-hover:text-text-secondary" aria-hidden="true" /> - </button> - )} - {!hasValue && valueTypePlaceHolder && ( - <Badge - className="absolute top-[50%] right-1 translate-y-[-50%] capitalize" - text={valueTypePlaceHolder} - uppercase={false} - /> - )} + <RiArrowDownSLine className="size-4 text-text-quaternary" /> + </div> + } + popupClassName="top-8" + readonly={readonly} + value={varKindType} + options={varKindTypes} + onChange={handleVarKindTypeChange} + showChecked + /> </div> + ) : ( + !hasValue && ( + <div className="mr-1 ml-1.5"> + <Variable02 + className={`size-4 ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'}`} + /> + </div> + ) + )} + {isConstant ? ( + <ConstantField + value={value as string} + onChange={ + onChange as ( + value: string | number, + varKindType: VarKindType, + varInfo?: Var, + ) => void + } + schema={schemaWithDynamicSelect as CredentialFormSchemaSelect} + readonly={readonly} + isLoading={isLoading} + /> + ) : ( + resolvedVariablePicker )} + {hasValue && !readonly && !isInTable && !isJustShowValue && ( + <button + type="button" + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} + className="group invisible absolute top-[50%] right-1 h-5 translate-y-[-50%] cursor-pointer rounded-md border-none bg-transparent p-1 group-hover/wrap:visible hover:bg-state-base-hover" + onClick={handleClearVar} + > + <RiCloseLine + className="size-3.5 text-text-tertiary group-hover:text-text-secondary" + aria-hidden="true" + /> + </button> + )} + {!hasValue && valueTypePlaceHolder && ( + <Badge + className="absolute top-[50%] right-1 translate-y-[-50%] capitalize" + text={valueTypePlaceHolder} + uppercase={false} + /> + )} + </div> + )} {!readonly && isInTable && ( <RemoveButton className="absolute top-0.5 right-1 hidden group-hover/picker-trigger-wrap:block" @@ -336,11 +386,7 @@ const VarReferencePickerTrigger: FC<Props> = ({ )} {!hasValue && typePlaceHolder && ( - <Badge - className="absolute top-1.5 right-2" - text={typePlaceHolder} - uppercase={false} - /> + <Badge className="absolute top-1.5 right-2" text={typePlaceHolder} uppercase={false} /> )} </> <input ref={inputRef} className="sr-only" value={controlFocus} readOnly /> @@ -348,8 +394,7 @@ const VarReferencePickerTrigger: FC<Props> = ({ ) if (!isSupportConstantValue) { - if (readonly) - return triggerContent + if (readonly) return triggerContent return ( <PopoverTrigger diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 2cc162fc0d13d2..ae932f7f2f561c 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -1,31 +1,31 @@ 'use client' import type { FC } from 'react' import type { HoverPopup } from './var-reference-picker.trigger' -import type { CredentialFormSchema, CredentialFormSchemaSelect, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + CredentialFormSchema, + CredentialFormSchemaSelect, + FormOption, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Tool } from '@/app/components/tools/types' import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' -import type { CommonNodeType, Node, NodeOutPutVar, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types' +import type { + CommonNodeType, + Node, + NodeOutPutVar, + ToolWithProvider, + ValueSelector, + Var, +} from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - useNodes, - useReactFlow, - useStoreApi, -} from 'reactflow' +import { useNodes, useReactFlow, useStoreApi } from 'reactflow' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { - useIsChatMode, - useWorkflowVariables, -} from '@/app/components/workflow/hooks' +import { useIsChatMode, useWorkflowVariables } from '@/app/components/workflow/hooks' // import type { BaseResource, BaseResourceProvider } from '@/app/components/workflow/nodes/_base/types' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' import { useStore as useWorkflowStore } from '@/app/components/workflow/store' @@ -124,7 +124,7 @@ const VarReferencePicker: FC<Props> = ({ const store = useStoreApi() const nodes = useNodes<CommonNodeType>() const isChatMode = useIsChatMode() - const isWorkflowDataLoaded = useWorkflowStore(s => s.isWorkflowDataLoaded) + const isWorkflowDataLoaded = useWorkflowStore((s) => s.isWorkflowDataLoaded) const { getCurrentVariableType } = useWorkflowVariables() const { availableVars, availableNodesWithParent: availableNodes } = useAvailableVarList(nodeId, { onlyLeafNodeVar, @@ -138,18 +138,17 @@ const VarReferencePicker: FC<Props> = ({ return node.data.type === BlockEnum.Start }) - const node = nodes.find(n => n.id === nodeId) + const node = nodes.find((n) => n.id === nodeId) const isInIteration = !!node?.data.isInIteration - const iterationNode = isInIteration ? (nodes.find(n => n.id === node?.parentId) ?? null) : null + const iterationNode = isInIteration ? (nodes.find((n) => n.id === node?.parentId) ?? null) : null const isInLoop = !!node?.data.isInLoop - const loopNode = isInLoop ? (nodes.find(n => n.id === node?.parentId) ?? null) : null + const loopNode = isInLoop ? (nodes.find((n) => n.id === node?.parentId) ?? null) : null const triggerRef = useRef<HTMLDivElement>(null) const [triggerWidth, setTriggerWidth] = useState(TRIGGER_DEFAULT_WIDTH) useEffect(() => { - if (triggerRef.current) - setTriggerWidth(triggerRef.current.clientWidth) + if (triggerRef.current) setTriggerWidth(triggerRef.current.clientWidth) }, []) const [varKindType, setVarKindType] = useState<VarKindType>(defaultVarKindType) @@ -177,87 +176,98 @@ const VarReferencePicker: FC<Props> = ({ ) const outputVarNodeId = getOutputVarNodeId(hasValue, value) - const outputVarNode = useMemo(() => getOutputVarNode({ - availableNodes, - hasValue, - isConstant: !!isConstant, - isIterationVar, - isLoopVar, - iterationNode, - loopNode, - outputVarNodeId: outputVarNodeId!, - startNode, - value, - }), [availableNodes, hasValue, isConstant, isIterationVar, isLoopVar, iterationNode, loopNode, outputVarNodeId, startNode, value]) + const outputVarNode = useMemo( + () => + getOutputVarNode({ + availableNodes, + hasValue, + isConstant: !!isConstant, + isIterationVar, + isLoopVar, + iterationNode, + loopNode, + outputVarNodeId: outputVarNodeId!, + startNode, + value, + }), + [ + availableNodes, + hasValue, + isConstant, + isIterationVar, + isLoopVar, + iterationNode, + loopNode, + outputVarNodeId, + startNode, + value, + ], + ) const isShowAPart = isShowAPartSelector(value) - const varName = useMemo( - () => getVarDisplayName(hasValue, value), - [hasValue, value], - ) + const varName = useMemo(() => getVarDisplayName(hasValue, value), [hasValue, value]) const varKindTypes = getVarKindOptions() - const handleVarKindTypeChange = useCallback((value: VarKindType) => { - setVarKindType(value) - if (value === VarKindType.constant) - onChange('', value) - else - onChange([], value) - }, [onChange]) + const handleVarKindTypeChange = useCallback( + (value: VarKindType) => { + setVarKindType(value) + if (value === VarKindType.constant) onChange('', value) + else onChange([], value) + }, + [onChange], + ) const inputRef = useRef<HTMLInputElement>(null) const [controlFocus, setControlFocus] = useState(0) const isFocus = controlFocus > 0 useEffect(() => { - if (controlFocus && inputRef.current) - inputRef.current.focus() + if (controlFocus && inputRef.current) inputRef.current.focus() }, [controlFocus]) - const handleVarReferenceChange = useCallback((value: ValueSelector, varInfo: Var) => { - // sys var not passed to backend - const newValue = produce(value, (draft) => { - if (draft[1] && draft[1].startsWith('sys.')) { - draft.shift() - const paths = draft[0]!.split('.') - paths.forEach((p, i) => { - draft[i] = p - }) - } - }) - onChange(newValue, varKindType, varInfo) - setOpen(false) - }, [onChange, varKindType]) + const handleVarReferenceChange = useCallback( + (value: ValueSelector, varInfo: Var) => { + // sys var not passed to backend + const newValue = produce(value, (draft) => { + if (draft[1] && draft[1].startsWith('sys.')) { + draft.shift() + const paths = draft[0]!.split('.') + paths.forEach((p, i) => { + draft[i] = p + }) + } + }) + onChange(newValue, varKindType, varInfo) + setOpen(false) + }, + [onChange, varKindType], + ) const handleClearVar = useCallback(() => { - if (varKindType === VarKindType.constant) - onChange('', varKindType) - else - onChange([], varKindType) + if (varKindType === VarKindType.constant) onChange('', varKindType) + else onChange([], varKindType) }, [onChange, varKindType]) - const handleVariableJump = useCallback((nodeId: string) => { - const currentNodeIndex = availableNodes.findIndex(node => node.id === nodeId) - const currentNode = availableNodes[currentNodeIndex] - - const workflowContainer = document.getElementById('workflow-container') - const { - clientWidth, - clientHeight, - } = workflowContainer! - const { - setViewport, - } = reactflow - const { transform } = store.getState() - const zoom = transform[2] - const position = currentNode!.position - setViewport({ - x: (clientWidth - 400 - currentNode!.width! * zoom) / 2 - position.x * zoom, - y: (clientHeight - currentNode!.height! * zoom) / 2 - position.y * zoom, - zoom: transform[2], - }) - }, [availableNodes, reactflow, store]) + const handleVariableJump = useCallback( + (nodeId: string) => { + const currentNodeIndex = availableNodes.findIndex((node) => node.id === nodeId) + const currentNode = availableNodes[currentNodeIndex] + + const workflowContainer = document.getElementById('workflow-container') + const { clientWidth, clientHeight } = workflowContainer! + const { setViewport } = reactflow + const { transform } = store.getState() + const zoom = transform[2] + const position = currentNode!.position + setViewport({ + x: (clientWidth - 400 - currentNode!.width! * zoom) / 2 - position.x * zoom, + y: (clientHeight - currentNode!.height! * zoom) / 2 - position.y * zoom, + zoom: transform[2], + }) + }, + [availableNodes, reactflow, store], + ) const type = getCurrentVariableType({ parentNode: isInIteration ? iterationNode : loopNode, @@ -281,11 +291,12 @@ const VarReferencePicker: FC<Props> = ({ const visibleNodeTitle = shouldShowNodeName ? outputVarNode?.title || '' : '' // 8(left/right-padding) + 14(icon) + 4 + 14 + 2 = 42 + 17 buff - const { - maxNodeNameWidth, - maxTypeWidth, - maxVarNameWidth, - } = getWidthAllocations(triggerWidth, visibleNodeTitle, varName || '', type || '') + const { maxNodeNameWidth, maxTypeWidth, maxVarNameWidth } = getWidthAllocations( + triggerWidth, + visibleNodeTitle, + varName || '', + type || '', + ) const hoverPopup = useMemo<HoverPopup | null>(() => { const tooltipType = getTooltipContent(hasValue, isShowAPart, isValidVar) @@ -303,7 +314,10 @@ const VarReferencePicker: FC<Props> = ({ } } if (tooltipType === 'invalid-variable') - return { kind: 'invalid-variable', message: t($ => $['errorMsg.invalidVariable'], { ns: 'workflow' }) } + return { + kind: 'invalid-variable', + message: t(($) => $['errorMsg.invalidVariable'], { ns: 'workflow' }), + } return null }, [isValidVar, isShowAPart, hasValue, t, outputVarNode?.title, outputVarNode?.type, value, type]) @@ -318,14 +332,12 @@ const VarReferencePicker: FC<Props> = ({ 'tool', ) const handleFetchDynamicOptions = useCallback(async () => { - if (schema?.type !== FormTypeEnum.dynamicSelect || !currentTool || !currentProvider) - return + if (schema?.type !== FormTypeEnum.dynamicSelect || !currentTool || !currentProvider) return setIsLoading(true) try { const data = await fetchDynamicOptions() setDynamicOptions(data?.options || []) - } - finally { + } finally { setIsLoading(false) } }, [currentProvider, currentTool, fetchDynamicOptions, schema?.type]) @@ -343,21 +355,18 @@ const VarReferencePicker: FC<Props> = ({ [isChatVar, isEnv, isGlobal, isLoopVar, isRagVar], ) - const triggerPlaceholder = placeholder ?? t($ => $['common.setVarValuePlaceholder'], { ns: 'workflow' }) + const triggerPlaceholder = + placeholder ?? t(($) => $['common.setVarValuePlaceholder'], { ns: 'workflow' }) const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div> return ( <div className={cn(className)}> - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> {!!trigger && ( <PopoverTrigger render={resolvedTrigger} onClick={(e) => { - if (readonly) - e.preventDefault() + if (readonly) e.preventDefault() }} /> )} @@ -419,7 +428,7 @@ const VarReferencePicker: FC<Props> = ({ vars={outputVars} popupFor={popupFor} onChange={handleVarReferenceChange} - itemWidth={isAddBtnTrigger ? 260 : (minWidth || triggerWidth)} + itemWidth={isAddBtnTrigger ? 260 : minWidth || triggerWidth} isSupportFileVar={isSupportFileVar} preferSchemaType={preferSchemaType} /> diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx index 10c85631160833..d447ecd2a94ea2 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx @@ -25,9 +25,9 @@ const VarReferencePopup: FC<Props> = ({ preferSchemaType, }) => { const { t } = useTranslation() - const pipelineId = useStore(s => s.pipelineId) + const pipelineId = useStore((s) => s.pipelineId) const showManageRagInputFields = useMemo(() => !!pipelineId, [pipelineId]) - const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel) + const setShowInputFieldPanel = useStore((s) => s.setShowInputFieldPanel) // max-h-[300px] overflow-y-auto todo: use portal to handle long list return ( @@ -37,40 +37,38 @@ const VarReferencePopup: FC<Props> = ({ width: itemWidth || 228, }} > - {((!vars || vars.length === 0) && popupFor) - ? (popupFor === 'toAssigned' - ? ( - <ListEmpty - title={t($ => $['variableReference.noAvailableVars'], { ns: 'workflow' }) || ''} - description={( - <div className="system-xs-regular text-text-tertiary"> - {t($ => $['variableReference.noVarsForOperation'], { ns: 'workflow' })} - </div> - )} - /> - ) - : ( - <ListEmpty - title={t($ => $['variableReference.noAssignedVars'], { ns: 'workflow' }) || ''} - description={( - <div className="system-xs-regular text-text-tertiary"> - {t($ => $['variableReference.assignedVarsDescription'], { ns: 'workflow' })} - </div> - )} - /> - )) - : ( - <VarReferenceVars - searchBoxClassName="mt-1" - vars={vars} - onChange={onChange} - itemWidth={itemWidth} - isSupportFileVar={isSupportFileVar} - showManageInputField={showManageRagInputFields} - onManageInputField={() => setShowInputFieldPanel?.(true)} - preferSchemaType={preferSchemaType} - /> - )} + {(!vars || vars.length === 0) && popupFor ? ( + popupFor === 'toAssigned' ? ( + <ListEmpty + title={t(($) => $['variableReference.noAvailableVars'], { ns: 'workflow' }) || ''} + description={ + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['variableReference.noVarsForOperation'], { ns: 'workflow' })} + </div> + } + /> + ) : ( + <ListEmpty + title={t(($) => $['variableReference.noAssignedVars'], { ns: 'workflow' }) || ''} + description={ + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['variableReference.assignedVarsDescription'], { ns: 'workflow' })} + </div> + } + /> + ) + ) : ( + <VarReferenceVars + searchBoxClassName="mt-1" + vars={vars} + onChange={onChange} + itemWidth={itemWidth} + isSupportFileVar={isSupportFileVar} + showManageInputField={showManageRagInputFields} + onManageInputField={() => setShowInputFieldPanel?.(true)} + preferSchemaType={preferSchemaType} + /> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts index a9941bf72c6e58..fa27efeea2f666 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts @@ -9,10 +9,8 @@ export const getVariableDisplayName = ( isFlat: boolean, isInCodeGeneratorInstructionEditor?: boolean, ) => { - if (VAR_SHOW_NAME_MAP[variable]) - return VAR_SHOW_NAME_MAP[variable] - if (!isFlat) - return variable + if (VAR_SHOW_NAME_MAP[variable]) return VAR_SHOW_NAME_MAP[variable] + if (!isFlat) return variable if (variable === 'current') return isInCodeGeneratorInstructionEditor ? 'current_code' : 'current_prompt' return variable @@ -29,14 +27,10 @@ export const getVariableCategory = ({ isLoopVar?: boolean isRagVariable?: boolean }) => { - if (isEnv) - return 'environment' - if (isChatVar) - return 'conversation' - if (isLoopVar) - return 'loop' - if (isRagVariable) - return 'rag' + if (isEnv) return 'environment' + if (isChatVar) return 'conversation' + if (isLoopVar) return 'loop' + if (isRagVariable) return 'rag' return 'system' } @@ -63,23 +57,24 @@ export const getValueSelector = ({ nodeId: string objPath: string[] }): ValueSelector | undefined => { - if (!isSupportFileVar && isFile) - return undefined + if (!isSupportFileVar && isFile) return undefined - if (isFlat) - return [itemData.variable] + if (isFlat) return [itemData.variable] if (isSys || isEnv || isChatVar || isRagVariable) return [...objPath, ...itemData.variable.split('.')] return [nodeId, ...objPath, itemData.variable] } const getVisibleChildren = (vars: Var[]) => { - return vars.filter(variable => checkKeys([variable.variable], false).isValid || isSpecialVar(variable.variable.split('.')[0]!)) + return vars.filter( + (variable) => + checkKeys([variable.variable], false).isValid || + isSpecialVar(variable.variable.split('.')[0]!), + ) } const includesSearchText = (value: string | undefined, searchTextLower: string) => { - if (!value) - return false + if (!value) return false return value.toLowerCase().includes(searchTextLower) } @@ -88,36 +83,43 @@ const isStructuredOutputChildren = (children: Var['children']): children is Stru return !!children && !Array.isArray(children) && 'schema' in children } -const matchesStructuredField = (fieldName: string, field: Field, searchTextLower: string): boolean => { - if (includesSearchText(fieldName, searchTextLower)) - return true +const matchesStructuredField = ( + fieldName: string, + field: Field, + searchTextLower: string, +): boolean => { + if (includesSearchText(fieldName, searchTextLower)) return true if (field.properties) - return Object.entries(field.properties).some(([childName, childField]) => matchesStructuredField(childName, childField, searchTextLower)) + return Object.entries(field.properties).some(([childName, childField]) => + matchesStructuredField(childName, childField, searchTextLower), + ) - if (field.items) - return matchesStructuredField(field.items.type, field.items, searchTextLower) + if (field.items) return matchesStructuredField(field.items.type, field.items, searchTextLower) return false } const matchesVariableSearch = (variable: Var, searchTextLower: string): boolean => { if ( - includesSearchText(variable.variable, searchTextLower) - || includesSearchText(variable.des, searchTextLower) - || includesSearchText(variable.schemaType, searchTextLower) + includesSearchText(variable.variable, searchTextLower) || + includesSearchText(variable.des, searchTextLower) || + includesSearchText(variable.schemaType, searchTextLower) ) { return true } - if (!variable.children) - return false + if (!variable.children) return false if (Array.isArray(variable.children)) - return getVisibleChildren(variable.children).some(child => matchesVariableSearch(child, searchTextLower)) + return getVisibleChildren(variable.children).some((child) => + matchesVariableSearch(child, searchTextLower), + ) if (isStructuredOutputChildren(variable.children)) - return Object.entries(variable.children.schema.properties).some(([fieldName, field]) => matchesStructuredField(fieldName, field, searchTextLower)) + return Object.entries(variable.children.schema.properties).some(([fieldName, field]) => + matchesStructuredField(fieldName, field, searchTextLower), + ) return false } @@ -126,21 +128,21 @@ export const filterReferenceVars = (vars: NodeOutPutVar[], searchText: string) = const searchTextLower = searchText.toLowerCase() return vars - .map(node => ({ ...node, vars: getVisibleChildren(node.vars) })) - .filter(node => node.vars.length > 0) + .map((node) => ({ ...node, vars: getVisibleChildren(node.vars) })) + .filter((node) => node.vars.length > 0) .filter((node) => { - if (!searchText) - return true - return node.vars.some(variable => matchesVariableSearch(variable, searchTextLower)) - || node.title.toLowerCase().includes(searchTextLower) + if (!searchText) return true + return ( + node.vars.some((variable) => matchesVariableSearch(variable, searchTextLower)) || + node.title.toLowerCase().includes(searchTextLower) + ) }) .map((node) => { - if (!searchText || node.title.toLowerCase().includes(searchTextLower)) - return node + if (!searchText || node.title.toLowerCase().includes(searchTextLower)) return node return { ...node, - vars: node.vars.filter(variable => matchesVariableSearch(variable, searchTextLower)), + vars: node.vars.filter((variable) => matchesVariableSearch(variable, searchTextLower)), } }) } diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index bcbf5f392eaf03..210533d3793b01 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -4,11 +4,7 @@ import type { StructuredOutput } from '../../../llm/types' import type { Field } from '@/app/components/workflow/nodes/llm/types' import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useHover } from 'ahooks' import { noop } from 'es-toolkit/function' import * as React from 'react' @@ -44,7 +40,8 @@ const resolveValueSelector = ({ nodeId: string objPath: string[] }) => { - const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties + const isStructureOutput = + itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties const isFile = itemData.type === VarType.file && !isStructureOutput const isSys = itemData.variable.startsWith('sys.') const isEnv = itemData.variable.startsWith('env.') @@ -101,14 +98,17 @@ const Item: FC<ItemProps> = ({ isSelected, onActivate, }) => { - const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties - const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && (itemData.children as Var[]).length > 0) + const isStructureOutput = + itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties + const isObj = + [VarType.object, VarType.file].includes(itemData.type) && + itemData.children && + (itemData.children as Var[]).length > 0 const isEnv = itemData.variable.startsWith('env.') const isChatVar = itemData.variable.startsWith('conversation.') const isRagVariable = itemData.isRagVariable const flatVarIcon = useMemo(() => { - if (!isFlat) - return null + if (!isFlat) return null const variable = itemData.variable switch (variable) { case 'current': @@ -117,14 +117,26 @@ const Item: FC<ItemProps> = ({ aria-hidden className={cn( 'size-3.5 shrink-0 text-util-colors-violet-violet-600', - isInCodeGeneratorInstructionEditor ? 'i-custom-vender-line-general-code-assistant' : 'i-custom-vender-line-general-magic-edit', + isInCodeGeneratorInstructionEditor + ? 'i-custom-vender-line-general-code-assistant' + : 'i-custom-vender-line-general-magic-edit', )} /> ) case 'error_message': - return <span aria-hidden className="i-custom-vender-solid-development-variable-02 size-3.5 shrink-0 text-util-colors-orange-dark-orange-dark-600" /> + return ( + <span + aria-hidden + className="i-custom-vender-solid-development-variable-02 size-3.5 shrink-0 text-util-colors-orange-dark-orange-dark-600" + /> + ) default: - return <span aria-hidden className="i-custom-vender-solid-development-variable-02 size-3.5 shrink-0 text-text-accent" /> + return ( + <span + aria-hidden + className="i-custom-vender-solid-development-variable-02 size-3.5 shrink-0 text-text-accent" + /> + ) } }, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable]) @@ -134,8 +146,7 @@ const Item: FC<ItemProps> = ({ ) const objStructuredOutput: StructuredOutput | null = useMemo(() => { - if (!isObj) - return null + if (!isObj) return null const properties: Record<string, Field> = {} const childrenVars = (itemData.children as Var[]) || [] childrenVars.forEach((c) => { @@ -154,8 +165,7 @@ const Item: FC<ItemProps> = ({ }, [isObj, itemData.children]) const structuredOutput = (() => { - if (isStructureOutput) - return itemData.children as StructuredOutput + if (isStructureOutput) return itemData.children as StructuredOutput return objStructuredOutput })() @@ -165,14 +175,12 @@ const Item: FC<ItemProps> = ({ onChange: (hovering) => { if (hovering) { setIsItemHovering(true) - } - else { + } else { if (isObj || isStructureOutput) { setTimeout(() => { setIsItemHovering(false) }, 100) - } - else { + } else { setIsItemHovering(false) } } @@ -195,8 +203,7 @@ const Item: FC<ItemProps> = ({ objPath, }) - if (valueSelector) - onChange(valueSelector, itemData) + if (valueSelector) onChange(valueSelector, itemData) } const variableCategory = useMemo( () => getVariableCategory({ isEnv, isChatVar, isLoopVar, isRagVariable }), @@ -207,8 +214,11 @@ const Item: FC<ItemProps> = ({ <div ref={itemRef} className={cn( - (isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]', - (isHovering || isSelected) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'), + isObj || isStructureOutput ? 'pr-1' : 'pr-[18px]', + (isHovering || isSelected) && + (isObj || isStructureOutput + ? 'bg-components-panel-on-panel-item-bg-hover' + : 'bg-state-base-hover'), 'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3 outline-hidden focus:outline-hidden focus-visible:outline-hidden', className, )} @@ -232,41 +242,72 @@ const Item: FC<ItemProps> = ({ {isFlat && flatVarIcon} {!isEnv && !isChatVar && !isRagVariable && ( - <div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{varName}</div> + <div + title={itemData.variable} + className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary" + > + {varName} + </div> )} {isEnv && ( - <div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('env.', '')}</div> + <div + title={itemData.variable} + className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary" + > + {itemData.variable.replace('env.', '')} + </div> )} {isChatVar && ( - <div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('conversation.', '')}</div> + <div + title={itemData.des} + className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary" + > + {itemData.variable.replace('conversation.', '')} + </div> )} {isRagVariable && ( - <div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.split('.').slice(-1)[0]}</div> + <div + title={itemData.des} + className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary" + > + {itemData.variable.split('.').slice(-1)[0]} + </div> )} </div> - <div className="ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize">{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</div> - { - (isObj || isStructureOutput) && ( - <span aria-hidden className={cn('ml-0.5 i-custom-vender-line-arrows-chevron-right size-3 text-text-quaternary', isHovering && 'text-text-tertiary')} /> - ) - } + <div className="ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize"> + {preferSchemaType && itemData.schemaType ? itemData.schemaType : itemData.type} + </div> + {(isObj || isStructureOutput) && ( + <span + aria-hidden + className={cn( + 'ml-0.5 i-custom-vender-line-arrows-chevron-right size-3 text-text-quaternary', + isHovering && 'text-text-tertiary', + )} + /> + )} </div> ) return ( - <Popover - open={open} - onOpenChange={noop} - > + <Popover open={open} onOpenChange={noop}> <PopoverTrigger nativeButton={false} render={itemTrigger} /> <PopoverContent placement="left-start" sideOffset={0} - popupClassName={cn(VAR_REFERENCE_CHILD_POPUP_CLASS_NAME, 'border-none bg-transparent p-0 shadow-none backdrop-blur-none')} + popupClassName={cn( + VAR_REFERENCE_CHILD_POPUP_CLASS_NAME, + 'border-none bg-transparent p-0 shadow-none backdrop-blur-none', + )} > {(isStructureOutput || isObj) && ( <PickerStructurePanel - root={{ nodeId, nodeName: title, attrName: itemData.variable, attrAlias: itemData.schemaType }} + root={{ + nodeId, + nodeName: title, + attrName: itemData.variable, + attrAlias: itemData.schemaType, + }} payload={structuredOutput!} onHovering={setIsChildrenHovering} onSelect={(valueSelector) => { @@ -319,102 +360,109 @@ const VarReferenceVars: FC<Props> = ({ const searchValue = searchText ?? internalSearchValue const filteredVars = useMemo(() => filterReferenceVars(vars, searchValue), [vars, searchValue]) const selectableItems = useMemo(() => { - return filteredVars.flatMap(node => node.vars.map(item => ({ - nodeId: node.nodeId, - isFlat: node.isFlat, - itemData: item, - }))) + return filteredVars.flatMap((node) => + node.vars.map((item) => ({ + nodeId: node.nodeId, + isFlat: node.isFlat, + itemData: item, + })), + ) }, [filteredVars]) const indexedFilteredVars = useMemo(() => { let optionIndex = 0 - return filteredVars.map(node => ({ + return filteredVars.map((node) => ({ ...node, - vars: node.vars.map(variable => ({ + vars: node.vars.map((variable) => ({ variable, optionIndex: optionIndex++, })), })) }, [filteredVars]) const [selectedIndex, setSelectedIndex] = useState(-1) - const effectiveSelectedIndex = selectableItems.length ? Math.min(Math.max(selectedIndex, 0), selectableItems.length - 1) : -1 + const effectiveSelectedIndex = selectableItems.length + ? Math.min(Math.max(selectedIndex, 0), selectableItems.length - 1) + : -1 useEffect(() => { const listElement = listRef.current - const selectedElement = listElement?.querySelector('[data-selected="true"]') as HTMLElement | null - if (!listElement || !selectedElement) - return + const selectedElement = listElement?.querySelector( + '[data-selected="true"]', + ) as HTMLElement | null + if (!listElement || !selectedElement) return const selectedTop = selectedElement.offsetTop const selectedBottom = selectedTop + selectedElement.offsetHeight const visibleTop = listElement.scrollTop const visibleBottom = visibleTop + listElement.clientHeight - if (selectedTop < visibleTop) - listElement.scrollTop = selectedTop + if (selectedTop < visibleTop) listElement.scrollTop = selectedTop else if (selectedBottom > visibleBottom) listElement.scrollTop = selectedBottom - listElement.clientHeight }, [effectiveSelectedIndex]) - const selectItem = useCallback((index: number) => { - const selectedItem = selectableItems[index] - if (!selectedItem) - return - - const { itemData, nodeId, isFlat } = selectedItem - const valueSelector = resolveValueSelector({ - itemData, - isFlat, - isSupportFileVar, - nodeId, - objPath: [], - }) + const selectItem = useCallback( + (index: number) => { + const selectedItem = selectableItems[index] + if (!selectedItem) return + + const { itemData, nodeId, isFlat } = selectedItem + const valueSelector = resolveValueSelector({ + itemData, + isFlat, + isSupportFileVar, + nodeId, + objPath: [], + }) + + if (valueSelector) onChange(valueSelector, itemData) + }, + [isSupportFileVar, onChange, selectableItems], + ) - if (valueSelector) - onChange(valueSelector, itemData) - }, [isSupportFileVar, onChange, selectableItems]) + const handleKeyboardEvent = useCallback( + (event: Pick<KeyboardEvent, 'key' | 'preventDefault' | 'stopPropagation'>) => { + if (event.key === 'Escape') { + event.preventDefault() + onClose?.() + return + } - const handleKeyboardEvent = useCallback((event: Pick<KeyboardEvent, 'key' | 'preventDefault' | 'stopPropagation'>) => { - if (event.key === 'Escape') { - event.preventDefault() - onClose?.() - return - } + if (!selectableItems.length) return - if (!selectableItems.length) - return - - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault() - event.stopPropagation() - setSelectedIndex( - event.key === 'ArrowDown' - ? Math.min(effectiveSelectedIndex + 1, selectableItems.length - 1) - : Math.max(effectiveSelectedIndex - 1, 0), - ) - return - } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + event.stopPropagation() + setSelectedIndex( + event.key === 'ArrowDown' + ? Math.min(effectiveSelectedIndex + 1, selectableItems.length - 1) + : Math.max(effectiveSelectedIndex - 1, 0), + ) + return + } - if (event.key === 'Enter') { - event.preventDefault() - event.stopPropagation() - selectItem(effectiveSelectedIndex) - } - }, [effectiveSelectedIndex, onClose, selectableItems.length, selectItem]) + if (event.key === 'Enter') { + event.preventDefault() + event.stopPropagation() + selectItem(effectiveSelectedIndex) + } + }, + [effectiveSelectedIndex, onClose, selectableItems.length, selectItem], + ) - const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => { - handleKeyboardEvent(e) - }, [handleKeyboardEvent]) + const handleKeyDown = useCallback( + (e: React.KeyboardEvent<HTMLInputElement>) => { + handleKeyboardEvent(e) + }, + [handleKeyboardEvent], + ) useEffect(() => { - if (!hideSearch) - return + if (!hideSearch) return const handleDocumentKeyDown = (event: KeyboardEvent) => { - if (event.altKey || event.ctrlKey || event.metaKey) - return - if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) - return + if (event.altKey || event.ctrlKey || event.metaKey) return + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) return handleKeyboardEvent(event) } @@ -425,87 +473,88 @@ const VarReferenceVars: FC<Props> = ({ return ( <> - { - !hideSearch && ( - <> - <div className={cn('m-2', searchBoxClassName)} onClick={e => e.stopPropagation()}> - <Input - className={VAR_SEARCH_INPUT_CLASS_NAME} - showLeftIcon - showClearIcon - value={searchValue} - placeholder={t($ => $['common.searchVar'], { ns: 'workflow' }) || ''} - onChange={e => setInternalSearchValue(e.target.value)} - onKeyDown={handleKeyDown} - onClear={() => setInternalSearchValue('')} - onBlur={onBlur} - autoFocus={autoFocus} - /> - </div> + {!hideSearch && ( + <> + <div className={cn('m-2', searchBoxClassName)} onClick={(e) => e.stopPropagation()}> + <Input + className={VAR_SEARCH_INPUT_CLASS_NAME} + showLeftIcon + showClearIcon + value={searchValue} + placeholder={t(($) => $['common.searchVar'], { ns: 'workflow' }) || ''} + onChange={(e) => setInternalSearchValue(e.target.value)} + onKeyDown={handleKeyDown} + onClear={() => setInternalSearchValue('')} + onBlur={onBlur} + autoFocus={autoFocus} + /> + </div> + <div + className="relative left-[-4px] h-[0.5px] bg-black/5" + style={{ + width: 'calc(100% + 8px)', + }} + ></div> + </> + )} + + {filteredVars.length > 0 ? ( + <div + ref={listRef} + className={cn('max-h-[85vh] overflow-x-hidden overflow-y-auto', maxHeightClass)} + > + {indexedFilteredVars.map((item, i) => ( <div - className="relative left-[-4px] h-[0.5px] bg-black/5" - style={{ - width: 'calc(100% + 8px)', - }} + key={item.nodeId} + className={cn(!item.isFlat && 'mt-3', i === 0 && item.isFlat && 'mt-2')} > - </div> - </> - ) - } - - {filteredVars.length > 0 - ? ( - <div ref={listRef} className={cn('max-h-[85vh] overflow-x-hidden overflow-y-auto', maxHeightClass)}> - { - indexedFilteredVars.map((item, i) => ( - <div key={item.nodeId} className={cn(!item.isFlat && 'mt-3', i === 0 && item.isFlat && 'mt-2')}> - {!item.isFlat && ( - <div - className="truncate px-3 system-xs-medium-uppercase leading-[22px] text-text-tertiary" - title={item.title} - > - {item.title} - </div> - )} - {item.vars.map(({ variable, optionIndex }) => ( - <Item - key={optionIndex} - title={item.title} - nodeId={item.nodeId} - objPath={[]} - itemData={variable} - onChange={onChange} - itemWidth={itemWidth} - isSupportFileVar={isSupportFileVar} - isException={variable.isException} - isLoopVar={item.isLoop} - isFlat={item.isFlat} - isInCodeGeneratorInstructionEditor={isInCodeGeneratorInstructionEditor} - preferSchemaType={preferSchemaType} - isSelected={effectiveSelectedIndex === optionIndex} - onActivate={() => setSelectedIndex(optionIndex)} - /> - ))} - {item.isFlat && !indexedFilteredVars[i + 1]?.isFlat && !!indexedFilteredVars.find(item => !item.isFlat) && ( - <div className="relative mt-[14px] flex items-center space-x-1"> - <div className="h-0 w-3 shrink-0 border border-divider-subtle"></div> - <div className="system-2xs-semibold-uppercase text-text-tertiary">{t($ => $['debug.lastOutput'], { ns: 'workflow' })}</div> - <div className="h-0 shrink-0 grow border border-divider-subtle"></div> - </div> - )} + {!item.isFlat && ( + <div + className="truncate px-3 system-xs-medium-uppercase leading-[22px] text-text-tertiary" + title={item.title} + > + {item.title} + </div> + )} + {item.vars.map(({ variable, optionIndex }) => ( + <Item + key={optionIndex} + title={item.title} + nodeId={item.nodeId} + objPath={[]} + itemData={variable} + onChange={onChange} + itemWidth={itemWidth} + isSupportFileVar={isSupportFileVar} + isException={variable.isException} + isLoopVar={item.isLoop} + isFlat={item.isFlat} + isInCodeGeneratorInstructionEditor={isInCodeGeneratorInstructionEditor} + preferSchemaType={preferSchemaType} + isSelected={effectiveSelectedIndex === optionIndex} + onActivate={() => setSelectedIndex(optionIndex)} + /> + ))} + {item.isFlat && + !indexedFilteredVars[i + 1]?.isFlat && + !!indexedFilteredVars.find((item) => !item.isFlat) && ( + <div className="relative mt-[14px] flex items-center space-x-1"> + <div className="h-0 w-3 shrink-0 border border-divider-subtle"></div> + <div className="system-2xs-semibold-uppercase text-text-tertiary"> + {t(($) => $['debug.lastOutput'], { ns: 'workflow' })} + </div> + <div className="h-0 shrink-0 grow border border-divider-subtle"></div> </div> - )) - } + )} </div> - ) - : <div className="mt-2 pl-3 text-xs leading-[18px] font-medium text-gray-500 uppercase">{t($ => $['common.noVar'], { ns: 'workflow' })}</div>} - { - showManageInputField && ( - <ManageInputField - onManage={onManageInputField || noop} - /> - ) - } + ))} + </div> + ) : ( + <div className="mt-2 pl-3 text-xs leading-[18px] font-medium text-gray-500 uppercase"> + {t(($) => $['common.noVar'], { ns: 'workflow' })} + </div> + )} + {showManageInputField && <ManageInputField onManage={onManageInputField || noop} />} </> ) } diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-type-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-type-picker.tsx index c1a5bab70c9196..9980e4febc0ec5 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-type-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-type-picker.tsx @@ -19,21 +19,24 @@ type Props = Readonly<{ onChange: (value: string) => void }> -const TYPES = [VarType.string, VarType.number, VarType.boolean, VarType.arrayNumber, VarType.arrayString, VarType.arrayBoolean, VarType.arrayObject, VarType.object] -const VarReferencePicker: FC<Props> = ({ - readonly, - className, - value, - onChange, -}) => { +const TYPES = [ + VarType.string, + VarType.number, + VarType.boolean, + VarType.arrayNumber, + VarType.arrayString, + VarType.arrayBoolean, + VarType.arrayObject, + VarType.object, +] +const VarReferencePicker: FC<Props> = ({ readonly, className, value, onChange }) => { return ( <div className={cn(className, !readonly && 'cursor-pointer select-none')}> <Select value={value} readOnly={readonly} onValueChange={(type) => { - if (type) - onChange(type) + if (type) onChange(type) }} > <SelectTrigger @@ -47,7 +50,7 @@ const VarReferencePicker: FC<Props> = ({ popupClassName="w-[120px] rounded-lg border-0 p-1 shadow-sm" listClassName="p-0" > - {TYPES.map(type => ( + {TYPES.map((type) => ( <SelectItem key={type} value={type} diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx index d75e6b603630bf..69569c5a84cbb7 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx @@ -5,7 +5,13 @@ import VariableIcon from '../base/variable-icon' import VariableLabel from '../base/variable-label' import VariableName from '../base/variable-name' import VariableNodeLabel from '../base/variable-node-label' -import { VariableIconWithColor, VariableLabelInEditor, VariableLabelInNode, VariableLabelInSelect, VariableLabelInText } from '../index' +import { + VariableIconWithColor, + VariableLabelInEditor, + VariableLabelInNode, + VariableLabelInSelect, + VariableLabelInText, +} from '../index' describe('variable-label index', () => { beforeEach(() => { @@ -83,10 +89,7 @@ describe('variable-label index', () => { const { container } = render( <div> <VariableIcon variables={['env', 'API_KEY']} /> - <VariableIconWithColor - variables={['conversation', 'message']} - isExceptionVariable - /> + <VariableIconWithColor variables={['conversation', 'message']} isExceptionVariable /> </div>, ) @@ -94,12 +97,7 @@ describe('variable-label index', () => { }) it('should render the base variable name with shortened path and title', () => { - render( - <VariableName - variables={['node-id', 'payload', 'answer']} - notShowFullPath - />, - ) + render(<VariableName variables={['node-id', 'payload', 'answer']} notShowFullPath />) expect(screen.getByText('answer')).toHaveAttribute('title', 'answer') }) @@ -109,12 +107,7 @@ describe('variable-label index', () => { expect(container).toBeEmptyDOMElement() - rerender( - <VariableNodeLabel - nodeType={BlockEnum.Code} - nodeTitle="Code Node" - />, - ) + rerender(<VariableNodeLabel nodeType={BlockEnum.Code} nodeTitle="Code Node" />) expect(screen.getByText('Code Node')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-icon.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-icon.tsx index d1c1d8ff1fe56c..52a617500a1d58 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-icon.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-icon.tsx @@ -8,21 +8,10 @@ export type VariableIconProps = { variables?: string[] variableCategory?: VarInInspectType | string } -const VariableIcon = ({ - className, - variables = [], - variableCategory, -}: VariableIconProps) => { +const VariableIcon = ({ className, variables = [], variableCategory }: VariableIconProps) => { const VarIcon = useVarIcon(variables, variableCategory) - return VarIcon && ( - <VarIcon - className={cn( - 'size-3.5 shrink-0', - className, - )} - /> - ) + return VarIcon && <VarIcon className={cn('size-3.5 shrink-0', className)} /> } export default memo(VariableIcon) diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx index 758f0b4c18cf69..9541f2c3bc0cec 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx @@ -24,7 +24,12 @@ const VariableLabel = ({ rightSlot, }: VariablePayload) => { const varColorClassName = useVarColor(variables, isExceptionVariable) - const isShowNodeLabel = !(isENV(variables) || isConversationVar(variables) || isGlobalVar(variables) || isRagVariableVar(variables)) + const isShowNodeLabel = !( + isENV(variables) || + isConversationVar(variables) || + isGlobalVar(variables) || + isRagVariableVar(variables) + ) const badge = ( <div @@ -36,49 +41,30 @@ const VariableLabel = ({ ref={ref} {...(isExceptionVariable ? { 'data-testid': 'exception-variable' } : {})} > - {isShowNodeLabel && ( - <VariableNodeLabel - nodeType={nodeType} - nodeTitle={nodeTitle} - /> + {isShowNodeLabel && <VariableNodeLabel nodeType={nodeType} nodeTitle={nodeTitle} />} + {notShowFullPath && ( + <> + <span className="i-ri-more-line size-3 shrink-0 text-text-secondary" /> + <div className="shrink-0 system-xs-regular text-divider-deep">/</div> + </> )} - { - notShowFullPath && ( - <> - <span className="i-ri-more-line size-3 shrink-0 text-text-secondary" /> - <div className="shrink-0 system-xs-regular text-divider-deep">/</div> - </> - ) - } - <VariableIcon - variables={variables} - className={varColorClassName} - /> + <VariableIcon variables={variables} className={varColorClassName} /> <VariableName variables={variables} className={cn(varColorClassName)} notShowFullPath={notShowFullPath} /> - { - !!variableType && ( - <div className="shrink-0 system-xs-regular text-text-tertiary"> - {capitalize(variableType)} - </div> - ) - } - { - !!errorMsg && ( - <Warning className="size-3 shrink-0 text-text-warning" /> - ) - } - { - rightSlot - } + {!!variableType && ( + <div className="shrink-0 system-xs-regular text-text-tertiary"> + {capitalize(variableType)} + </div> + )} + {!!errorMsg && <Warning className="size-3 shrink-0 text-text-warning" />} + {rightSlot} </div> ) - if (!errorMsg) - return badge + if (!errorMsg) return badge return ( <Tooltip> diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-name.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-name.tsx index aa1c7090fd4447..2158d6de3cecd8 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-name.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-name.tsx @@ -7,21 +7,11 @@ type VariableNameProps = { className?: string notShowFullPath?: boolean } -const VariableName = ({ - variables, - className, - notShowFullPath, -}: VariableNameProps) => { +const VariableName = ({ variables, className, notShowFullPath }: VariableNameProps) => { const varName = useVarName(variables, notShowFullPath) return ( - <div - className={cn( - 'truncate system-xs-medium', - className, - )} - title={varName} - > + <div className={cn('truncate system-xs-medium', className)} title={varName}> {varName} </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-node-label.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-node-label.tsx index 64c7d75dda519a..b3338cf2e7ee23 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-node-label.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-node-label.tsx @@ -6,29 +6,20 @@ type VariableNodeLabelProps = { nodeType?: BlockEnum nodeTitle?: string } -const VariableNodeLabel = ({ - nodeType, - nodeTitle, -}: VariableNodeLabelProps) => { - if (!nodeType) - return null +const VariableNodeLabel = ({ nodeType, nodeTitle }: VariableNodeLabelProps) => { + if (!nodeType) return null return ( <> - <VarBlockIcon - type={nodeType} - className="shrink-0 text-text-secondary" - /> - { - nodeTitle && ( - <div - className="max-w-[60px] truncate system-xs-medium text-text-secondary" - title={nodeTitle} - > - {nodeTitle} - </div> - ) - } + <VarBlockIcon type={nodeType} className="shrink-0 text-text-secondary" /> + {nodeTitle && ( + <div + className="max-w-[60px] truncate system-xs-medium text-text-secondary" + title={nodeTitle} + > + {nodeTitle} + </div> + )} <div className="shrink-0 system-xs-regular text-divider-deep">/</div> </> ) diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts b/web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts index 64e89cc7cd8d8e..3bd7716839ecac 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts @@ -5,45 +5,54 @@ import { Variable02 } from '@/app/components/base/icons/src/vender/solid/develop import { Loop } from '@/app/components/base/icons/src/vender/workflow' import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants' import { VarInInspectType } from '@/types/workflow' -import { - isConversationVar, - isENV, - isGlobalVar, - isRagVariableVar, - isSystemVar, -} from '../utils' +import { isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '../utils' export const useVarIcon = (variables: string[], variableCategory?: VarInInspectType | string) => { - if (variableCategory === 'loop') - return Loop + if (variableCategory === 'loop') return Loop - if (variableCategory === 'rag' || isRagVariableVar(variables)) - return InputField + if (variableCategory === 'rag' || isRagVariableVar(variables)) return InputField - if (isENV(variables) || variableCategory === VarInInspectType.environment || variableCategory === 'environment') + if ( + isENV(variables) || + variableCategory === VarInInspectType.environment || + variableCategory === 'environment' + ) return Env - if (isConversationVar(variables) || variableCategory === VarInInspectType.conversation || variableCategory === 'conversation') + if ( + isConversationVar(variables) || + variableCategory === VarInInspectType.conversation || + variableCategory === 'conversation' + ) return BubbleX - if (isGlobalVar(variables) || variableCategory === VarInInspectType.system) - return GlobalVariable + if (isGlobalVar(variables) || variableCategory === VarInInspectType.system) return GlobalVariable return Variable02 } -export const useVarColor = (variables: string[], isExceptionVariable?: boolean, variableCategory?: VarInInspectType | string) => { +export const useVarColor = ( + variables: string[], + isExceptionVariable?: boolean, + variableCategory?: VarInInspectType | string, +) => { return useMemo(() => { - if (isExceptionVariable) - return 'text-text-warning' + if (isExceptionVariable) return 'text-text-warning' - if (variableCategory === 'loop') - return 'text-util-colors-cyan-cyan-500' + if (variableCategory === 'loop') return 'text-util-colors-cyan-cyan-500' - if (isENV(variables) || variableCategory === VarInInspectType.environment || variableCategory === 'environment') + if ( + isENV(variables) || + variableCategory === VarInInspectType.environment || + variableCategory === 'environment' + ) return 'text-util-colors-violet-violet-600' - if (isConversationVar(variables) || variableCategory === VarInInspectType.conversation || variableCategory === 'conversation') + if ( + isConversationVar(variables) || + variableCategory === VarInInspectType.conversation || + variableCategory === 'conversation' + ) return 'text-util-colors-teal-teal-700' if (isGlobalVar(variables) || variableCategory === VarInInspectType.system) @@ -57,14 +66,12 @@ export const useVarName = (variables: string[], notShowFullPath?: boolean) => { const showName = VAR_SHOW_NAME_MAP[variables.join('.')] let variableFullPathName = variables.slice(1).join('.') - if (isRagVariableVar(variables)) - variableFullPathName = variables.slice(2).join('.') + if (isRagVariableVar(variables)) variableFullPathName = variables.slice(2).join('.') const varName = useMemo(() => { variableFullPathName = variables.slice(1).join('.') - if (isRagVariableVar(variables)) - variableFullPathName = variables.slice(2).join('.') + if (isRagVariableVar(variables)) variableFullPathName = variables.slice(2).join('.') const variablesLength = variables.length const isSystem = isSystemVar(variables) @@ -72,8 +79,7 @@ export const useVarName = (variables: string[], notShowFullPath?: boolean) => { return `${isSystem ? 'sys.' : ''}${varName}` }, [variables, notShowFullPath]) - if (showName) - return showName + if (showName) return showName return varName } diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/types.ts b/web/app/components/workflow/nodes/_base/components/variable/variable-label/types.ts index 6f3b06f6eed86d..be5c5c9282ef00 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/types.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/types.ts @@ -1,8 +1,5 @@ import type { ReactNode } from 'react' -import type { - BlockEnum, - VarType, -} from '@/app/components/workflow/types' +import type { BlockEnum, VarType } from '@/app/components/workflow/types' export type VariablePayload = { className?: string diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-icon-with-color.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-icon-with-color.tsx index c766d3c719b12e..bde965c7eee5a4 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-icon-with-color.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-icon-with-color.tsx @@ -19,10 +19,7 @@ const VariableIconWithColor = ({ <VariableIcon variables={variables} variableCategory={variableCategory} - className={cn( - varColorClassName, - className, - )} + className={cn(varColorClassName, className)} /> ) } diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-editor.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-editor.tsx index 17cf938d5bd452..1b831ec85ef4f8 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-editor.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-editor.tsx @@ -13,12 +13,8 @@ const VariableLabelInEditor = ({ errorMsg, ...rest }: VariableLabelInEditorProps) => { - const { - hoverBorderColor, - hoverBgColor, - selectedBorderColor, - selectedBgColor, - } = useVarBgColorInEditor(variables, !!errorMsg) + const { hoverBorderColor, hoverBgColor, selectedBorderColor, selectedBgColor } = + useVarBgColorInEditor(variables, !!errorMsg) return ( <VariableLabel diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-node.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-node.tsx index f778dbeddfbd0b..adc51a2df77d35 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-node.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-node.tsx @@ -6,9 +6,7 @@ import VariableLabel from './base/variable-label' const VariableLabelInNode = (variablePayload: VariablePayload) => { return ( <VariableLabel - className={cn( - 'w-full space-x-px bg-workflow-block-parma-bg px-1 shadow-none', - )} + className={cn('w-full space-x-px bg-workflow-block-parma-bg px-1 shadow-none')} {...variablePayload} /> ) diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-select.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-select.tsx index 259fc26689ce82..2663a111f32b1d 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-select.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-select.tsx @@ -3,11 +3,7 @@ import { memo } from 'react' import VariableLabel from './base/variable-label' const VariableLabelInSelect = (variablePayload: VariablePayload) => { - return ( - <VariableLabel - {...variablePayload} - /> - ) + return <VariableLabel {...variablePayload} /> } export default memo(VariableLabelInSelect) diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-text.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-text.tsx index b25997e64e85d0..783ed4929cf673 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-text.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/variable-label-in-text.tsx @@ -6,9 +6,7 @@ import VariableLabel from './base/variable-label' const VariableLabelInText = (variablePayload: VariablePayload) => { return ( <VariableLabel - className={cn( - 'h-[18px] space-x-px rounded-[5px] px-1 shadow-xs', - )} + className={cn('h-[18px] space-x-px rounded-[5px] px-1 shadow-xs')} {...variablePayload} /> ) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx index 2929d0e47e48b9..54c19df4f19243 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx @@ -14,9 +14,12 @@ import { describe('workflow-panel helpers', () => { const asToolList = (tools: Array<Partial<ToolWithProvider>>) => tools as ToolWithProvider[] - const asTriggerList = (triggers: Array<Partial<TriggerWithProvider>>) => triggers as TriggerWithProvider[] + const asTriggerList = (triggers: Array<Partial<TriggerWithProvider>>) => + triggers as TriggerWithProvider[] const asNodeData = (data: Partial<Node['data']>) => data as Node['data'] - const createCustomRunFormProps = (payload: Partial<CustomRunFormProps['payload']>): CustomRunFormProps => ({ + const createCustomRunFormProps = ( + payload: Partial<CustomRunFormProps['payload']>, + ): CustomRunFormProps => ({ nodeId: 'node-1', flowId: 'flow-1', flowType: 'app' as CustomRunFormProps['flowType'], @@ -52,25 +55,42 @@ describe('workflow-panel helpers', () => { const storeTools = [{ id: 'legacy/tool', allow_delete: false }] const queryTools = [{ id: 'provider/tool', allow_delete: true }] - expect(getCurrentToolCollection(asToolList(queryTools), asToolList(storeTools), 'provider/tool')).toEqual(queryTools[0]) + expect( + getCurrentToolCollection(asToolList(queryTools), asToolList(storeTools), 'provider/tool'), + ).toEqual(queryTools[0]) }) it('should fall back to store data when query data is unavailable', () => { const storeTools = [{ id: 'provider/tool', allow_delete: false }] - expect(getCurrentToolCollection(undefined, asToolList(storeTools), 'provider/tool')).toEqual(storeTools[0]) + expect(getCurrentToolCollection(undefined, asToolList(storeTools), 'provider/tool')).toEqual( + storeTools[0], + ) }) it('should resolve the current trigger plugin and datasource only for matching node types', () => { const triggerData = asNodeData({ type: BlockEnum.TriggerPlugin, plugin_id: 'trigger-1' }) - const dataSourceData = asNodeData({ type: BlockEnum.DataSource, plugin_id: 'source-1', provider_type: 'remote' }) + const dataSourceData = asNodeData({ + type: BlockEnum.DataSource, + plugin_id: 'source-1', + provider_type: 'remote', + }) const triggerPlugins = [{ plugin_id: 'trigger-1', id: '1' }] const dataSources = [{ plugin_id: 'source-1' }] - expect(getCurrentTriggerPlugin(triggerData, asTriggerList(triggerPlugins))).toEqual(triggerPlugins[0]) + expect(getCurrentTriggerPlugin(triggerData, asTriggerList(triggerPlugins))).toEqual( + triggerPlugins[0], + ) expect(getCurrentDataSource(dataSourceData, dataSources)).toEqual(dataSources[0]) - expect(getCurrentTriggerPlugin(asNodeData({ type: BlockEnum.Tool }), asTriggerList(triggerPlugins))).toBeUndefined() - expect(getCurrentDataSource(asNodeData({ type: BlockEnum.Tool }), dataSources)).toBeUndefined() + expect( + getCurrentTriggerPlugin( + asNodeData({ type: BlockEnum.Tool }), + asTriggerList(triggerPlugins), + ), + ).toBeUndefined() + expect( + getCurrentDataSource(asNodeData({ type: BlockEnum.Tool }), dataSources), + ).toBeUndefined() }) }) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx index 97c055a8534077..d8959247c25f29 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx @@ -18,12 +18,14 @@ const mockHandleRunWithParams = vi.fn() let mockShowMessageLogModal = false let mockNodesReadOnly = false let mockCanRun = true -let mockBuiltInTools = [{ - id: 'provider/tool', - name: 'Tool', - type: 'builtin', - allow_delete: true, -}] +let mockBuiltInTools = [ + { + id: 'provider/tool', + name: 'Tool', + type: 'builtin', + allow_delete: true, + }, +] let mockTriggerPlugins: Array<Record<string, unknown>> = [] const mockLogsState = { @@ -63,29 +65,33 @@ const mockLastRunState = { getFilteredExistVarForms: vi.fn(() => []), } -const createDataSourceCollection = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({ - id: 'source-1', - name: 'Source', - author: 'Author', - description: { en_US: 'Source description', zh_Hans: 'Source description' }, - icon: 'source-icon', - label: { en_US: 'Source', zh_Hans: 'Source' }, - type: 'datasource', - team_credentials: {}, - is_team_authorization: false, - allow_delete: false, - labels: [], - plugin_id: 'source-1', - tools: [], - meta: {} as ToolWithProvider['meta'], - ...overrides, -}) as ToolWithProvider +const createDataSourceCollection = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => + ({ + id: 'source-1', + name: 'Source', + author: 'Author', + description: { en_US: 'Source description', zh_Hans: 'Source description' }, + icon: 'source-icon', + label: { en_US: 'Source', zh_Hans: 'Source' }, + type: 'datasource', + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: [], + plugin_id: 'source-1', + tools: [], + meta: {} as ToolWithProvider['meta'], + ...overrides, + }) as ToolWithProvider vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { showMessageLogModal: boolean, appDetail: { id: string } }) => unknown) => selector({ - showMessageLogModal: mockShowMessageLogModal, - appDetail: { id: 'app-1' }, - }), + useStore: ( + selector: (state: { showMessageLogModal: boolean; appDetail: { id: string } }) => unknown, + ) => + selector({ + showMessageLogModal: mockShowMessageLogModal, + appDetail: { id: 'app-1' }, + }), })) vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ @@ -130,15 +136,21 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/workflow/hooks-store', () => ({ - useHooksStore: (selector: (state: { configsMap: { flowId: string, flowType: string }, accessControl: { canRun: boolean } }) => unknown) => selector({ - configsMap: { - flowId: 'flow-1', - flowType: 'app', - }, - accessControl: { - canRun: mockCanRun, - }, - }), + useHooksStore: ( + selector: (state: { + configsMap: { flowId: string; flowType: string } + accessControl: { canRun: boolean } + }) => unknown, + ) => + selector({ + configsMap: { + flowId: 'flow-1', + flowType: 'app', + }, + accessControl: { + canRun: mockCanRun, + }, + }), })) vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ @@ -193,18 +205,27 @@ vi.mock('../last-run/use-last-run', () => ({ vi.mock('@/app/components/plugins/plugin-auth', () => ({ PluginAuth: ({ children }: PropsWithChildren) => <div>{children}</div>, - AuthorizedInNode: ({ onAuthorizationItemClick }: { onAuthorizationItemClick?: (credentialId: string) => void }) => ( + AuthorizedInNode: ({ + onAuthorizationItemClick, + }: { + onAuthorizationItemClick?: (credentialId: string) => void + }) => ( <button onClick={() => onAuthorizationItemClick?.('credential-1')}>authorized-in-node</button> ), - PluginAuthInDataSourceNode: ({ children, onJumpToDataSourcePage }: PropsWithChildren<{ onJumpToDataSourcePage?: () => void }>) => ( + PluginAuthInDataSourceNode: ({ + children, + onJumpToDataSourcePage, + }: PropsWithChildren<{ onJumpToDataSourcePage?: () => void }>) => ( <div> <button onClick={onJumpToDataSourcePage}>jump-to-datasource</button> {children} </div> ), - AuthorizedInDataSourceNode: ({ onJumpToDataSourcePage }: { onJumpToDataSourcePage?: () => void }) => ( - <button onClick={onJumpToDataSourcePage}>authorized-in-datasource-node</button> - ), + AuthorizedInDataSourceNode: ({ + onJumpToDataSourcePage, + }: { + onJumpToDataSourcePage?: () => void + }) => <button onClick={onJumpToDataSourcePage}>authorized-in-datasource-node</button>, AuthCategory: { tool: 'tool' }, })) @@ -233,7 +254,9 @@ vi.mock('../before-run-form', () => ({ })) vi.mock('../before-run-form/panel-wrap', () => ({ - default: ({ children }: PropsWithChildren<{ nodeName: string, onHide: () => void }>) => <div>{children}</div>, + default: ({ children }: PropsWithChildren<{ nodeName: string; onHide: () => void }>) => ( + <div>{children}</div> + ), })) vi.mock('../error-handle/error-handle-on-panel', () => ({ @@ -257,11 +280,19 @@ vi.mock('../retry/retry-on-panel', () => ({ })) vi.mock('../title-description-input', () => ({ - TitleInput: ({ value, onBlur }: { value: string, onBlur: (value: string) => void }) => ( - <input aria-label="title-input" defaultValue={value} onBlur={event => onBlur(event.target.value)} /> + TitleInput: ({ value, onBlur }: { value: string; onBlur: (value: string) => void }) => ( + <input + aria-label="title-input" + defaultValue={value} + onBlur={(event) => onBlur(event.target.value)} + /> ), - DescriptionInput: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => ( - <textarea aria-label="description-input" defaultValue={value} onChange={event => onChange(event.target.value)} /> + DescriptionInput: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( + <textarea + aria-label="description-input" + defaultValue={value} + onChange={(event) => onChange(event.target.value)} + /> ), })) @@ -275,16 +306,25 @@ vi.mock('../last-run', () => ({ }) => ( <div> <div>{isPaused ? 'paused' : 'active'}</div> - <button onClick={() => updateNodeRunningStatus?.(NodeRunningStatus.Running)}>last-run-update-status</button> + <button onClick={() => updateNodeRunningStatus?.(NodeRunningStatus.Running)}> + last-run-update-status + </button> <div>last-run-panel</div> </div> ), })) vi.mock('../trigger-subscription', () => ({ - TriggerSubscription: ({ children, onSubscriptionChange }: PropsWithChildren<{ onSubscriptionChange?: (value: { id: string }, callback?: () => void) => void }>) => ( + TriggerSubscription: ({ + children, + onSubscriptionChange, + }: PropsWithChildren<{ + onSubscriptionChange?: (value: { id: string }, callback?: () => void) => void + }>) => ( <div> - <button onClick={() => onSubscriptionChange?.({ id: 'subscription-1' }, vi.fn())}>change-subscription</button> + <button onClick={() => onSubscriptionChange?.({ id: 'subscription-1' }, vi.fn())}> + change-subscription + </button> {children} </div> ), @@ -305,12 +345,14 @@ describe('workflow-panel index', () => { mockShowMessageLogModal = false mockNodesReadOnly = false mockCanRun = true - mockBuiltInTools = [{ - id: 'provider/tool', - name: 'Tool', - type: 'builtin', - allow_delete: true, - }] + mockBuiltInTools = [ + { + id: 'provider/tool', + name: 'Tool', + type: 'builtin', + allow_delete: true, + }, + ] mockTriggerPlugins = [] mockLogsState.showSpecialResultPanel = false mockLastRunState.isShowSingleRun = false @@ -339,7 +381,9 @@ describe('workflow-panel index', () => { expect(screen.getByText('authorized-in-node')).toBeInTheDocument() fireEvent.blur(screen.getByDisplayValue('Tool Node'), { target: { value: 'Updated title' } }) - fireEvent.change(screen.getByDisplayValue('Node description'), { target: { value: 'Updated description' } }) + fireEvent.change(screen.getByDisplayValue('Node description'), { + target: { value: 'Updated description' }, + }) await waitFor(() => { expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalled() @@ -353,9 +397,11 @@ describe('workflow-panel index', () => { expect(mockHandleSingleRun).toHaveBeenCalledTimes(1) expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1', true) - expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ credential_id: 'credential-1' }), - })) + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ credential_id: 'credential-1' }), + }), + ) }) it('should hide the single-run action when nodes are readonly even with run permission', () => { @@ -373,7 +419,9 @@ describe('workflow-panel index', () => { }, ) - expect(screen.queryByRole('button', { name: 'workflow.panel.runThisStep' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.panel.runThisStep' }), + ).not.toBeInTheDocument() }) it('should hide the single-run action when run permission is missing', () => { @@ -391,7 +439,9 @@ describe('workflow-panel index', () => { }, ) - expect(screen.queryByRole('button', { name: 'workflow.panel.runThisStep' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.panel.runThisStep' }), + ).not.toBeInTheDocument() }) it('should render the special result panel when logs request it', () => { @@ -451,12 +501,14 @@ describe('workflow-panel index', () => { fireEvent.click(screen.getByText('last-run-update-status')) await waitFor(() => { - expect(mockHandleNodeDataUpdate).toHaveBeenCalledWith(expect.objectContaining({ - id: 'node-plain', - data: expect.objectContaining({ - _singleRunningStatus: NodeRunningStatus.Running, + expect(mockHandleNodeDataUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'node-plain', + data: expect.objectContaining({ + _singleRunningStatus: NodeRunningStatus.Running, + }), }), - })) + ) }) }) @@ -464,7 +516,10 @@ describe('workflow-panel index', () => { mockLastRunState.tabType = 'lastRun' const { rerender } = renderWorkflowComponent( - <BasePanel id="node-pause" data={createData({ _singleRunningStatus: NodeRunningStatus.Running }) as never}> + <BasePanel + id="node-pause" + data={createData({ _singleRunningStatus: NodeRunningStatus.Running }) as never} + > <div>panel-child</div> </BasePanel>, { @@ -478,7 +533,10 @@ describe('workflow-panel index', () => { expect(screen.getByText('active')).toBeInTheDocument() rerender( - <BasePanel id="node-pause" data={createData({ _isSingleRun: true, _singleRunningStatus: undefined }) as never}> + <BasePanel + id="node-pause" + data={createData({ _isSingleRun: true, _singleRunningStatus: undefined }) as never} + > <div>panel-child</div> </BasePanel>, ) @@ -528,7 +586,16 @@ describe('workflow-panel index', () => { it('should render data source authorization controls and jump to the settings modal', () => { renderWorkflowComponent( - <BasePanel id="node-1" data={createData({ type: BlockEnum.DataSource, plugin_id: 'source-1', provider_type: 'remote' }) as never}> + <BasePanel + id="node-1" + data={ + createData({ + type: BlockEnum.DataSource, + plugin_id: 'source-1', + provider_type: 'remote', + }) as never + } + > <div>panel-child</div> </BasePanel>, { @@ -584,21 +651,26 @@ describe('workflow-panel index', () => { }) it('should load trigger plugin details when the selected node is a trigger plugin', async () => { - mockTriggerPlugins = [{ - id: 'trigger-1', - name: 'trigger-name', - plugin_id: 'plugin-id', - plugin_unique_identifier: 'plugin-uid', - label: { - en_US: 'Trigger Name', + mockTriggerPlugins = [ + { + id: 'trigger-1', + name: 'trigger-name', + plugin_id: 'plugin-id', + plugin_unique_identifier: 'plugin-uid', + label: { + en_US: 'Trigger Name', + }, + declaration: {}, + subscription_schema: [], + subscription_constructor: {}, }, - declaration: {}, - subscription_schema: [], - subscription_constructor: {}, - }] + ] renderWorkflowComponent( - <BasePanel id="node-1" data={createData({ type: BlockEnum.TriggerPlugin, plugin_id: 'plugin-id' }) as never}> + <BasePanel + id="node-1" + data={createData({ type: BlockEnum.TriggerPlugin, plugin_id: 'plugin-id' }) as never} + > <div>panel-child</div> </BasePanel>, { @@ -610,10 +682,12 @@ describe('workflow-panel index', () => { ) await waitFor(() => { - expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({ - id: 'trigger-1', - name: 'Trigger Name', - })) + expect(mockSetDetail).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'trigger-1', + name: 'Trigger Name', + }), + ) }) fireEvent.click(screen.getByText('change-subscription')) @@ -627,7 +701,10 @@ describe('workflow-panel index', () => { mockShowMessageLogModal = true const { container } = renderWorkflowComponent( - <BasePanel id="node-1" data={createData({ _singleRunningStatus: NodeRunningStatus.Running }) as never}> + <BasePanel + id="node-1" + data={createData({ _singleRunningStatus: NodeRunningStatus.Running }) as never} + > <div>panel-child</div> </BasePanel>, { @@ -642,7 +719,9 @@ describe('workflow-panel index', () => { expect(root.style.right).toBe('240px') expect(root.className).toContain('absolute') - fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' })) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' }), + ) expect(mockHandleStop).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/helpers.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/helpers.tsx index 2e8e75d2a9b7ec..46a3f5a38e1ba7 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/helpers.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/helpers.tsx @@ -10,9 +10,12 @@ import { canFindTool } from '@/utils' const MIN_NODE_PANEL_WIDTH = 400 const DEFAULT_MAX_NODE_PANEL_WIDTH = 720 -export const getMaxNodePanelWidth = (workflowCanvasWidth?: number, otherPanelWidth?: number, reservedCanvasWidth = MIN_NODE_PANEL_WIDTH) => { - if (!workflowCanvasWidth) - return DEFAULT_MAX_NODE_PANEL_WIDTH +export const getMaxNodePanelWidth = ( + workflowCanvasWidth?: number, + otherPanelWidth?: number, + reservedCanvasWidth = MIN_NODE_PANEL_WIDTH, +) => { + if (!workflowCanvasWidth) return DEFAULT_MAX_NODE_PANEL_WIDTH const available = workflowCanvasWidth - (otherPanelWidth || 0) - reservedCanvasWidth return Math.max(available, MIN_NODE_PANEL_WIDTH) @@ -22,15 +25,21 @@ export const clampNodePanelWidth = (width: number, maxNodePanelWidth: number) => return Math.max(MIN_NODE_PANEL_WIDTH, Math.min(width, maxNodePanelWidth)) } -export const getCompressedNodePanelWidth = (nodePanelWidth: number, workflowCanvasWidth?: number, otherPanelWidth?: number, reservedCanvasWidth = MIN_NODE_PANEL_WIDTH) => { - if (!workflowCanvasWidth) - return undefined +export const getCompressedNodePanelWidth = ( + nodePanelWidth: number, + workflowCanvasWidth?: number, + otherPanelWidth?: number, + reservedCanvasWidth = MIN_NODE_PANEL_WIDTH, +) => { + if (!workflowCanvasWidth) return undefined const total = nodePanelWidth + (otherPanelWidth || 0) + reservedCanvasWidth - if (total <= workflowCanvasWidth) - return undefined + if (total <= workflowCanvasWidth) return undefined - return clampNodePanelWidth(workflowCanvasWidth - (otherPanelWidth || 0) - reservedCanvasWidth, getMaxNodePanelWidth(workflowCanvasWidth, otherPanelWidth, reservedCanvasWidth)) + return clampNodePanelWidth( + workflowCanvasWidth - (otherPanelWidth || 0) - reservedCanvasWidth, + getMaxNodePanelWidth(workflowCanvasWidth, otherPanelWidth, reservedCanvasWidth), + ) } export const getCustomRunForm = (params: CustomRunFormProps): ReactNode => { @@ -49,17 +58,20 @@ export const getCurrentToolCollection = ( providerId?: string, ) => { const candidates = buildInTools ?? storeBuildInTools - return candidates?.find(item => canFindTool(item.id, providerId)) + return candidates?.find((item) => canFindTool(item.id, providerId)) } export const getCurrentDataSource = ( data: Node['data'], - dataSourceList: Array<{ plugin_id?: string, is_authorized?: boolean }> | undefined, + dataSourceList: Array<{ plugin_id?: string; is_authorized?: boolean }> | undefined, ) => { - if (data.type !== BlockEnum.DataSource || data.provider_type === DataSourceClassification.localFile) + if ( + data.type !== BlockEnum.DataSource || + data.provider_type === DataSourceClassification.localFile + ) return undefined - return dataSourceList?.find(item => item.plugin_id === data.plugin_id) + return dataSourceList?.find((item) => item.plugin_id === data.plugin_id) } export const getCurrentTriggerPlugin = ( @@ -69,5 +81,5 @@ export const getCurrentTriggerPlugin = ( if (data.type !== BlockEnum.TriggerPlugin || !data.plugin_id || !triggerPlugins?.length) return undefined - return triggerPlugins.find(plugin => plugin.plugin_id === data.plugin_id) + return triggerPlugins.find((plugin) => plugin.plugin_id === data.plugin_id) } 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 d246cc58df351c..9c845a89eed4c3 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 @@ -3,27 +3,12 @@ import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail- import type { Node } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' -import { - RiCloseLine, - RiPlayLargeLine, -} from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { RiCloseLine, RiPlayLargeLine } from '@remixicon/react' import { debounce } from 'es-toolkit/compat' import { useAtomValue } from 'jotai' import * as React from 'react' -import { - cloneElement, - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { cloneElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' @@ -107,19 +92,17 @@ type BasePanelProps = { data: Node['data'] } -const BasePanel: FC<BasePanelProps> = ({ - id, - data, - children, -}) => { +const BasePanel: FC<BasePanelProps> = ({ id, data, children }) => { const { t } = useTranslation() const language = useLanguage() - const appId = useStore(s => s.appId) + const appId = useStore((s) => s.appId) const userProfile = useAtomValue(userProfileAtom) const { isConnected, nodePanelPresence } = useCollaboration(appId as string) - const { showMessageLogModal } = useAppStore(useShallow(state => ({ - showMessageLogModal: state.showMessageLogModal, - }))) + const { showMessageLogModal } = useAppStore( + useShallow((state) => ({ + showMessageLogModal: state.showMessageLogModal, + })), + ) const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running const currentUserPresence = useMemo(() => { @@ -132,11 +115,16 @@ const BasePanel: FC<BasePanelProps> = ({ username, avatar, } - }, [userProfile?.avatar, userProfile?.avatar_url, userProfile?.email, userProfile?.id, userProfile?.name]) + }, [ + userProfile?.avatar, + userProfile?.avatar_url, + userProfile?.email, + userProfile?.id, + userProfile?.name, + ]) useEffect(() => { - if (!isConnected || !currentUserPresence.userId) - return + if (!isConnected || !currentUserPresence.userId) return collaborationManager.emitNodePanelPresence(id, true, currentUserPresence) @@ -147,25 +135,24 @@ const BasePanel: FC<BasePanelProps> = ({ const viewingUsers = useMemo(() => { const presence = nodePanelPresence?.[id] - if (!presence) - return [] + if (!presence) return [] return Object.values(presence) - .filter(viewer => viewer.userId && viewer.userId !== currentUserPresence.userId) - .map(viewer => ({ + .filter((viewer) => viewer.userId && viewer.userId !== currentUserPresence.userId) + .map((viewer) => ({ id: viewer.userId, name: viewer.username, avatar_url: viewer.avatar || null, })) }, [currentUserPresence.userId, id, nodePanelPresence]) - const showSingleRunPanel = useStore(s => s.showSingleRunPanel) - const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) - const nodePanelWidth = useStore(s => s.nodePanelWidth) - const otherPanelWidth = useStore(s => s.otherPanelWidth) - const setNodePanelWidth = useStore(s => s.setNodePanelWidth) - const pendingSingleRun = useStore(s => s.pendingSingleRun) - const setPendingSingleRun = useStore(s => s.setPendingSingleRun) + const showSingleRunPanel = useStore((s) => s.showSingleRunPanel) + const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth) + const nodePanelWidth = useStore((s) => s.nodePanelWidth) + const otherPanelWidth = useStore((s) => s.otherPanelWidth) + const setNodePanelWidth = useStore((s) => s.setNodePanelWidth) + const pendingSingleRun = useStore((s) => s.pendingSingleRun) + const setPendingSingleRun = useStore((s) => s.setPendingSingleRun) const setNodePanelWidthStorage = useSetWorkflowNodePanelWidth() const reservedCanvasWidth = 400 // Reserve the minimum visible width for the canvas @@ -175,23 +162,25 @@ const BasePanel: FC<BasePanelProps> = ({ [workflowCanvasWidth, otherPanelWidth], ) - const updateNodePanelWidth = useCallback((width: number, source: 'user' | 'system' = 'user') => { - const newValue = clampNodePanelWidth(width, maxNodePanelWidth) + const updateNodePanelWidth = useCallback( + (width: number, source: 'user' | 'system' = 'user') => { + const newValue = clampNodePanelWidth(width, maxNodePanelWidth) - if (source === 'user') - setNodePanelWidthStorage(newValue) + if (source === 'user') setNodePanelWidthStorage(newValue) - setNodePanelWidth(newValue) - }, [maxNodePanelWidth, setNodePanelWidth, setNodePanelWidthStorage]) + setNodePanelWidth(newValue) + }, + [maxNodePanelWidth, setNodePanelWidth, setNodePanelWidthStorage], + ) - const handleResize = useCallback((width: number) => { - updateNodePanelWidth(width, 'user') - }, [updateNodePanelWidth]) + const handleResize = useCallback( + (width: number) => { + updateNodePanelWidth(width, 'user') + }, + [updateNodePanelWidth], + ) - const { - triggerRef, - containerRef, - } = useResizePanel({ + const { triggerRef, containerRef } = useResizePanel({ direction: 'horizontal', triggerDirection: 'left', minWidth: 400, @@ -204,36 +193,46 @@ const BasePanel: FC<BasePanelProps> = ({ }) useEffect(() => { - const compressedWidth = getCompressedNodePanelWidth(nodePanelWidth, workflowCanvasWidth, otherPanelWidth, reservedCanvasWidth) - if (compressedWidth !== undefined) - debounceUpdate(compressedWidth) + const compressedWidth = getCompressedNodePanelWidth( + nodePanelWidth, + workflowCanvasWidth, + otherPanelWidth, + reservedCanvasWidth, + ) + if (compressedWidth !== undefined) debounceUpdate(compressedWidth) }, [nodePanelWidth, otherPanelWidth, workflowCanvasWidth, debounceUpdate]) const { handleNodeSelect } = useNodesInteractions() const { nodesReadOnly } = useNodesReadOnly() - const { availableNextBlocks } = useAvailableBlocks(getNodeCatalogType(data), data.isInIteration || data.isInLoop) + const { availableNextBlocks } = useAvailableBlocks( + getNodeCatalogType(data), + data.isInIteration || data.isInLoop, + ) const toolIcon = useToolIcon(data) const { saveStateToHistory } = useWorkflowHistory() - const { - handleNodeDataUpdate, - handleNodeDataUpdateWithSyncDraft, - } = useNodeDataUpdate() - - const handleTitleBlur = useCallback((title: string) => { - handleNodeDataUpdateWithSyncDraft({ id, data: { title } }) - saveStateToHistory(WorkflowHistoryEvent.NodeTitleChange, { nodeId: id }) - }, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory]) - const handleDescriptionChange = useCallback((desc: string) => { - handleNodeDataUpdateWithSyncDraft({ id, data: { desc } }) - saveStateToHistory(WorkflowHistoryEvent.NodeDescriptionChange, { nodeId: id }) - }, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory]) + const { handleNodeDataUpdate, handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() + + const handleTitleBlur = useCallback( + (title: string) => { + handleNodeDataUpdateWithSyncDraft({ id, data: { title } }) + saveStateToHistory(WorkflowHistoryEvent.NodeTitleChange, { nodeId: id }) + }, + [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory], + ) + const handleDescriptionChange = useCallback( + (desc: string) => { + handleNodeDataUpdateWithSyncDraft({ id, data: { desc } }) + saveStateToHistory(WorkflowHistoryEvent.NodeDescriptionChange, { nodeId: id }) + }, + [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory], + ) const isChildNode = !!(data.isInIteration || data.isInLoop) const nodeMetaType = getNodeCatalogType(data) const isSupportSingleRun = canRunBySingle(data.type, isChildNode) - const appDetail = useAppStore(state => state.appDetail) + const appDetail = useAppStore((state) => state.appDetail) const hasClickRunning = useRef(false) const [isPaused, setIsPaused] = useState(false) @@ -242,33 +241,33 @@ const BasePanel: FC<BasePanelProps> = ({ if (data._singleRunningStatus === NodeRunningStatus.Running) { hasClickRunning.current = true setIsPaused(false) - } - else if (data._isSingleRun && data._singleRunningStatus === undefined && hasClickRunning) { + } else if (data._isSingleRun && data._singleRunningStatus === undefined && hasClickRunning) { setIsPaused(true) hasClickRunning.current = false } }, [data]) - const updateNodeRunningStatus = useCallback((status: NodeRunningStatus) => { - handleNodeDataUpdate({ - id, - data: { - ...data, - _singleRunningStatus: status, - }, - }) - }, [handleNodeDataUpdate, id, data]) + const updateNodeRunningStatus = useCallback( + (status: NodeRunningStatus) => { + handleNodeDataUpdate({ + id, + data: { + ...data, + _singleRunningStatus: status, + }, + }) + }, + [handleNodeDataUpdate, id, data], + ) useEffect(() => { hasClickRunning.current = false }, [id]) - const { - nodesMap, - } = useNodesMetaData() + const { nodesMap } = useNodesMetaData() - const configsMap = useHooksStore(s => s.configsMap) - const canRun = useHooksStore(s => s.accessControl.canRun) + const configsMap = useHooksStore((s) => s.configsMap) + const canRun = useHooksStore((s) => s.accessControl.canRun) const { isShowSingleRun, hideSingleRun, @@ -306,21 +305,24 @@ const BasePanel: FC<BasePanelProps> = ({ }, [tabType]) useEffect(() => { - if (!pendingSingleRun || pendingSingleRun.nodeId !== id) - return + if (!pendingSingleRun || pendingSingleRun.nodeId !== id) return - if (pendingSingleRun.action === 'run') - handleSingleRun() - else - handleStop() + if (pendingSingleRun.action === 'run') handleSingleRun() + else handleStop() setPendingSingleRun(undefined) }, [pendingSingleRun, id, handleSingleRun, handleStop, setPendingSingleRun]) const logParams = useLogs() - const passedLogParams = useMemo(() => [BlockEnum.Tool, BlockEnum.Agent, BlockEnum.Iteration, BlockEnum.Loop].includes(data.type) ? logParams : {}, [data.type, logParams]) + const passedLogParams = useMemo( + () => + [BlockEnum.Tool, BlockEnum.Agent, BlockEnum.Iteration, BlockEnum.Loop].includes(data.type) + ? logParams + : {}, + [data.type, logParams], + ) - const storeBuildInTools = useStore(s => s.buildInTools) + const storeBuildInTools = useStore((s) => s.buildInTools) const { data: buildInTools } = useAllBuiltInTools() const currToolCollection = useMemo( () => getCurrentToolCollection(buildInTools, storeBuildInTools, data.provider_id), @@ -332,7 +334,10 @@ const BasePanel: FC<BasePanelProps> = ({ // only fetch trigger plugins when the node is a trigger plugin const { data: triggerPlugins = [] } = useAllTriggerPlugins(data.type === BlockEnum.TriggerPlugin) - const currentTriggerPlugin = useMemo(() => getCurrentTriggerPlugin(data, triggerPlugins), [data, triggerPlugins]) + const currentTriggerPlugin = useMemo( + () => getCurrentTriggerPlugin(data, triggerPlugins), + [data, triggerPlugins], + ) const { setDetail } = usePluginStore() useEffect(() => { @@ -353,18 +358,24 @@ const BasePanel: FC<BasePanelProps> = ({ } }, [currentTriggerPlugin, language, setDetail]) - const dataSourceList = useStore(s => s.dataSourceList) + const dataSourceList = useStore((s) => s.dataSourceList) - const currentDataSource = useMemo(() => getCurrentDataSource(data, dataSourceList), [data, dataSourceList]) + const currentDataSource = useMemo( + () => getCurrentDataSource(data, dataSourceList), + [data, dataSourceList], + ) - const handleAuthorizationItemClick = useCallback((credential_id: string) => { - handleNodeDataUpdateWithSyncDraft({ - id, - data: { - credential_id, - }, - }) - }, [handleNodeDataUpdateWithSyncDraft, id]) + const handleAuthorizationItemClick = useCallback( + (credential_id: string) => { + handleNodeDataUpdateWithSyncDraft({ + id, + data: { + credential_id, + }, + }) + }, + [handleNodeDataUpdateWithSyncDraft, id], + ) const openIntegrationsSetting = useIntegrationsSetting() @@ -372,19 +383,20 @@ const BasePanel: FC<BasePanelProps> = ({ openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) }, [openIntegrationsSetting]) - const { - appendNodeInspectVars, - } = useInspectVarsCrud() - - const handleSubscriptionChange = useCallback((v: SimpleSubscription, callback?: () => void) => { - handleNodeDataUpdateWithSyncDraft( - { id, data: { subscription_id: v.id } }, - { - sync: true, - callback: { onSettled: callback }, - }, - ) - }, [handleNodeDataUpdateWithSyncDraft, id]) + const { appendNodeInspectVars } = useInspectVarsCrud() + + const handleSubscriptionChange = useCallback( + (v: SimpleSubscription, callback?: () => void) => { + handleNodeDataUpdateWithSyncDraft( + { id, data: { subscription_id: v.id } }, + { + sync: true, + callback: { onSettled: callback }, + }, + ) + }, + [handleNodeDataUpdateWithSyncDraft, id], + ) const readmeEntranceComponent = useMemo(() => { let pluginDetail @@ -402,13 +414,19 @@ const BasePanel: FC<BasePanelProps> = ({ default: break } - return !pluginDetail ? null : <ReadmeEntrance pluginDetail={pluginDetail as any} className="mt-auto" /> + return !pluginDetail ? null : ( + <ReadmeEntrance pluginDetail={pluginDetail as any} className="mt-auto" /> + ) }, [data.type, currToolCollection, currentDataSource, currentTriggerPlugin]) - const selectedNode = useMemo(() => ({ - id, - data, - }) as Node, [id, data]) + const selectedNode = useMemo( + () => + ({ + id, + data, + }) as Node, + [id, data], + ) const singleRunForms = singleRunParams?.forms const isCustomRunFormNode = isSupportCustomRunForm(data.type) const shouldRenderSingleRunPanel = isShowSingleRun && (isCustomRunFormNode || !!singleRunForms) @@ -416,21 +434,18 @@ const BasePanel: FC<BasePanelProps> = ({ if (logParams.showSpecialResultPanel) { return ( - <div className={cn( - 'relative mr-1 h-full', - )} - > + <div className={cn('relative mr-1 h-full')}> <div ref={containerRef} - className={cn('flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg', isSingleRunPanelVisible ? 'overflow-hidden' : 'overflow-y-auto')} + className={cn( + 'flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg', + isSingleRunPanelVisible ? 'overflow-hidden' : 'overflow-y-auto', + )} style={{ width: `${nodePanelWidth}px`, }} > - <PanelWrap - nodeName={data.title} - onHide={hideSingleRun} - > + <PanelWrap nodeName={data.title} onHide={hideSingleRun}> <div className="h-0 grow overflow-y-auto pb-4"> <SpecialResultPanel {...passedLogParams} /> </div> @@ -456,32 +471,30 @@ const BasePanel: FC<BasePanelProps> = ({ appendNodeInspectVars, }) : null - const singleRunPanelContent = isCustomRunFormNode - ? customRunForm - : singleRunForms - ? ( - <BeforeRunForm - nodeName={data.title} - nodeType={data.type} - onHide={hideSingleRun} - onRun={handleRunWithParams} - {...singleRunParams!} - {...passedLogParams} - existVarValuesInForms={getExistVarValuesInForms(singleRunForms)} - filteredExistVarForms={getFilteredExistVarForms(singleRunForms)} - handleAfterHumanInputStepRun={handleAfterCustomSingleRun} - /> - ) - : null + const singleRunPanelContent = isCustomRunFormNode ? ( + customRunForm + ) : singleRunForms ? ( + <BeforeRunForm + nodeName={data.title} + nodeType={data.type} + onHide={hideSingleRun} + onRun={handleRunWithParams} + {...singleRunParams!} + {...passedLogParams} + existVarValuesInForms={getExistVarValuesInForms(singleRunForms)} + filteredExistVarForms={getFilteredExistVarForms(singleRunForms)} + handleAfterHumanInputStepRun={handleAfterCustomSingleRun} + /> + ) : null return ( - <div className={cn( - 'relative mr-1 h-full', - )} - > + <div className={cn('relative mr-1 h-full')}> <div ref={containerRef} - className={cn('flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg', isSingleRunPanelVisible ? 'overflow-hidden' : 'overflow-y-auto')} + className={cn( + 'flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg', + isSingleRunPanelVisible ? 'overflow-hidden' : 'overflow-y-auto', + )} style={{ width: `${nodePanelWidth}px`, }} @@ -492,13 +505,11 @@ const BasePanel: FC<BasePanelProps> = ({ ) } - const runThisStepLabel = t($ => $['panel.runThisStep'], { ns: 'workflow' }) + const runThisStepLabel = t(($) => $['panel.runThisStep'], { ns: 'workflow' }) const singleRunActionLabel = isSingleRunning - ? t($ => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' }) + ? t(($) => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' }) : runThisStepLabel - const nodePanelRightOffset = !showMessageLogModal - ? '4px' - : `${otherPanelWidth + 8}px` + const nodePanelRightOffset = !showMessageLogModal ? '4px' : `${otherPanelWidth + 8}px` const isStartPlaceholderPanel = data.type === BlockEnum.StartPlaceholder const panelChildren = cloneElement(children as any, { id, @@ -516,10 +527,10 @@ const BasePanel: FC<BasePanelProps> = ({ const panelTabs = ( <TabsList> <TabsTab value={TabType.settings}> - {t($ => $['debug.settingsTab'], { ns: 'workflow' }).toLocaleUpperCase()} + {t(($) => $['debug.settingsTab'], { ns: 'workflow' }).toLocaleUpperCase()} </TabsTab> <TabsTab value={TabType.lastRun}> - {t($ => $['debug.lastRunTab'], { ns: 'workflow' }).toLocaleUpperCase()} + {t(($) => $['debug.lastRunTab'], { ns: 'workflow' }).toLocaleUpperCase()} </TabsTab> </TabsList> ) @@ -528,12 +539,15 @@ const BasePanel: FC<BasePanelProps> = ({ <div className={cn( 'relative mr-1 h-full', - showMessageLogModal && 'absolute z-0 mr-2 w-[400px] overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border shadow-lg transition-all', + showMessageLogModal && + 'absolute z-0 mr-2 w-[400px] overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border shadow-lg transition-all', )} - style={{ - 'right': !showMessageLogModal ? '0' : `${otherPanelWidth}px`, - '--workflow-node-panel-right': nodePanelRightOffset, - } as CSSProperties} + style={ + { + right: !showMessageLogModal ? '0' : `${otherPanelWidth}px`, + '--workflow-node-panel-right': nodePanelRightOffset, + } as CSSProperties + } > <div ref={triggerRef} @@ -544,79 +558,64 @@ const BasePanel: FC<BasePanelProps> = ({ <Tabs ref={containerRef} value={tabType} - onValueChange={selectedValue => setTabType(selectedValue)} - className={cn('flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-[width] ease-linear', isSingleRunPanelVisible ? 'overflow-hidden' : 'overflow-y-auto')} - style={{ - 'width': `${nodePanelWidth}px`, - '--workflow-node-panel-width': `${nodePanelWidth}px`, - } as CSSProperties} + onValueChange={(selectedValue) => setTabType(selectedValue)} + className={cn( + 'flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-[width] ease-linear', + isSingleRunPanelVisible ? 'overflow-hidden' : 'overflow-y-auto', + )} + style={ + { + width: `${nodePanelWidth}px`, + '--workflow-node-panel-width': `${nodePanelWidth}px`, + } as CSSProperties + } > <div className="sticky top-0 z-10 shrink-0 border-b-[0.5px] border-divider-regular bg-components-panel-bg"> <div className="flex items-center px-4 pt-4 pb-1"> {!isStartPlaceholderPanel && ( - <BlockIcon - className="mr-1 shrink-0" - type={data.type} - toolIcon={toolIcon} - size="md" - /> + <BlockIcon className="mr-1 shrink-0" type={data.type} toolIcon={toolIcon} size="md" /> + )} + {isStartPlaceholderPanel ? ( + <StartPlaceholderPanelTitle /> + ) : ( + <TitleInput value={data.title || ''} onBlur={handleTitleBlur} /> )} - {isStartPlaceholderPanel - ? ( - <StartPlaceholderPanelTitle /> - ) - : ( - <TitleInput - value={data.title || ''} - onBlur={handleTitleBlur} - /> - )} {viewingUsers.length > 0 && ( <div className="ml-3 shrink-0"> - <UserAvatarList - users={viewingUsers} - maxVisible={3} - size="sm" - /> + <UserAvatarList users={viewingUsers} maxVisible={3} size="sm" /> </div> )} <div className="flex shrink-0 items-center text-text-tertiary"> - { - isSupportSingleRun && canRun && !nodesReadOnly && ( - <Tooltip disabled={isSingleRunning}> - <TooltipTrigger - render={( - <button - type="button" - aria-label={singleRunActionLabel} - className="mr-1 flex size-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden" - onClick={() => { - if (isSingleRunning) - handleStop() - else - handleSingleRun() - }} - > - { - isSingleRunning - ? <Stop aria-hidden className="size-4 text-text-tertiary" /> - : <RiPlayLargeLine aria-hidden className="size-4 text-text-tertiary" /> - } - </button> - )} - /> - <TooltipContent className="mr-1"> - {runThisStepLabel} - </TooltipContent> - </Tooltip> - ) - } + {isSupportSingleRun && canRun && !nodesReadOnly && ( + <Tooltip disabled={isSingleRunning}> + <TooltipTrigger + render={ + <button + type="button" + aria-label={singleRunActionLabel} + className="mr-1 flex size-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden" + onClick={() => { + if (isSingleRunning) handleStop() + else handleSingleRun() + }} + > + {isSingleRunning ? ( + <Stop aria-hidden className="size-4 text-text-tertiary" /> + ) : ( + <RiPlayLargeLine aria-hidden className="size-4 text-text-tertiary" /> + )} + </button> + } + /> + <TooltipContent className="mr-1">{runThisStepLabel}</TooltipContent> + </Tooltip> + )} <HelpLink nodeType={nodeMetaType} /> <NodeActionsDropdown id={id} data={data} showHelpLink={false} /> <div className="mx-3 h-3.5 w-px bg-divider-regular" /> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="flex size-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden" onClick={() => handleNodeSelect(id, true)} > @@ -624,126 +623,91 @@ const BasePanel: FC<BasePanelProps> = ({ </button> </div> </div> - {isStartPlaceholderPanel - ? ( - <StartPlaceholderPanelDescription /> - ) - : ( - <div className="p-2"> - <DescriptionInput - value={data.desc || ''} - onChange={handleDescriptionChange} - /> - </div> - )} + {isStartPlaceholderPanel ? ( + <StartPlaceholderPanelDescription /> + ) : ( + <div className="p-2"> + <DescriptionInput value={data.desc || ''} onChange={handleDescriptionChange} /> + </div> + )} {!isStartPlaceholderPanel && ( <> - { - needsToolAuth && ( - <PluginAuth - className="px-4 pb-2" - pluginPayload={{ - provider: currToolCollection?.name || '', - providerType: currToolCollection?.type || '', - category: AuthCategory.tool, - detail: currToolCollection as any, - }} - > - <div className="flex items-center justify-between pr-3 pl-4"> - {panelTabs} - <AuthorizedInNode - pluginPayload={{ - provider: currToolCollection?.name || '', - providerType: currToolCollection?.type || '', - category: AuthCategory.tool, - detail: currToolCollection as any, - }} - onAuthorizationItemClick={handleAuthorizationItemClick} - credentialId={data.credential_id} - /> - </div> - </PluginAuth> - ) - } - { - !!currentDataSource && ( - <PluginAuthInDataSourceNode - onJumpToDataSourcePage={handleJumpToDataSourcePage} - isAuthorized={currentDataSource.is_authorized} - > - <div className="flex items-center justify-between pr-3 pl-4"> - {panelTabs} - <AuthorizedInDataSourceNode - onJumpToDataSourcePage={handleJumpToDataSourcePage} - authorizationsNum={3} - /> - </div> - </PluginAuthInDataSourceNode> - ) - } - { - currentTriggerPlugin && ( - <TriggerSubscription - subscriptionIdSelected={data.subscription_id} - onSubscriptionChange={handleSubscriptionChange} - > + {needsToolAuth && ( + <PluginAuth + className="px-4 pb-2" + pluginPayload={{ + provider: currToolCollection?.name || '', + providerType: currToolCollection?.type || '', + category: AuthCategory.tool, + detail: currToolCollection as any, + }} + > + <div className="flex items-center justify-between pr-3 pl-4"> {panelTabs} - </TriggerSubscription> - ) - } - { - !needsToolAuth && !currentDataSource && !currentTriggerPlugin && ( + <AuthorizedInNode + pluginPayload={{ + provider: currToolCollection?.name || '', + providerType: currToolCollection?.type || '', + category: AuthCategory.tool, + detail: currToolCollection as any, + }} + onAuthorizationItemClick={handleAuthorizationItemClick} + credentialId={data.credential_id} + /> + </div> + </PluginAuth> + )} + {!!currentDataSource && ( + <PluginAuthInDataSourceNode + onJumpToDataSourcePage={handleJumpToDataSourcePage} + isAuthorized={currentDataSource.is_authorized} + > <div className="flex items-center justify-between pr-3 pl-4"> {panelTabs} + <AuthorizedInDataSourceNode + onJumpToDataSourcePage={handleJumpToDataSourcePage} + authorizationsNum={3} + /> </div> - ) - } + </PluginAuthInDataSourceNode> + )} + {currentTriggerPlugin && ( + <TriggerSubscription + subscriptionIdSelected={data.subscription_id} + onSubscriptionChange={handleSubscriptionChange} + > + {panelTabs} + </TriggerSubscription> + )} + {!needsToolAuth && !currentDataSource && !currentTriggerPlugin && ( + <div className="flex items-center justify-between pr-3 pl-4">{panelTabs}</div> + )} <Split /> </> )} </div> {isStartPlaceholderPanel && ( - <StartPlaceholderPanelBody> - {panelChildren} - </StartPlaceholderPanelBody> + <StartPlaceholderPanelBody>{panelChildren}</StartPlaceholderPanelBody> )} {!isStartPlaceholderPanel && ( <TabsPanel value={TabType.settings} className="flex flex-1 flex-col overflow-y-auto"> - <div> - {panelChildren} - </div> + <div>{panelChildren}</div> <Split /> - { - hasRetryNode(data.type) && ( - <RetryOnPanel - id={id} - data={data} - /> - ) - } - { - hasErrorHandleNode(data.type) && ( - <ErrorHandleOnPanel - id={id} - data={data} - /> - ) - } - { - !!availableNextBlocks.length && ( - <div className="border-t-[0.5px] border-divider-regular p-4"> - <div className="mb-1 flex items-center system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['panel.nextStep'], { ns: 'workflow' }).toLocaleUpperCase()} - </div> - <div className="mb-2 system-xs-regular text-text-tertiary"> - {t($ => $['panel.addNextStep'], { ns: 'workflow' })} - </div> - <NextStep selectedNode={selectedNode} /> + {hasRetryNode(data.type) && <RetryOnPanel id={id} data={data} />} + {hasErrorHandleNode(data.type) && <ErrorHandleOnPanel id={id} data={data} />} + {!!availableNextBlocks.length && ( + <div className="border-t-[0.5px] border-divider-regular p-4"> + <div className="mb-1 flex items-center system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $['panel.nextStep'], { ns: 'workflow' }).toLocaleUpperCase()} </div> - ) - } + <div className="mb-2 system-xs-regular text-text-tertiary"> + {t(($) => $['panel.addNextStep'], { ns: 'workflow' })} + </div> + <NextStep selectedNode={selectedNode} /> + </div> + )} {readmeEntranceComponent} </TabsPanel> )} @@ -765,7 +729,6 @@ const BasePanel: FC<BasePanelProps> = ({ /> </TabsPanel> )} - </Tabs> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/index.spec.tsx index 91d346abc9e762..1e7f425032e8ff 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/index.spec.tsx @@ -11,9 +11,9 @@ vi.mock('@remixicon/react', () => ({ })) vi.mock('@/app/components/workflow/hooks-store', () => ({ - useHooksStore: (selector: (state: { - configsMap?: { flowType?: string, flowId?: string } - }) => unknown) => mockUseHooksStore(selector), + useHooksStore: ( + selector: (state: { configsMap?: { flowType?: string; flowId?: string } }) => unknown, + ) => mockUseHooksStore(selector), })) vi.mock('@/service/use-workflow', () => ({ @@ -44,14 +44,15 @@ describe('LastRun', () => { beforeEach(() => { vi.clearAllMocks() - mockUseHooksStore.mockImplementation((selector: (state: { - configsMap?: { flowType?: string, flowId?: string } - }) => unknown) => selector({ - configsMap: { - flowType: 'appFlow', - flowId: 'flow-1', - }, - })) + mockUseHooksStore.mockImplementation( + (selector: (state: { configsMap?: { flowType?: string; flowId?: string } }) => unknown) => + selector({ + configsMap: { + flowType: 'appFlow', + flowId: 'flow-1', + }, + }), + ) mockUseLastRun.mockReturnValue({ data: undefined, isFetching: false, @@ -100,10 +101,12 @@ describe('LastRun', () => { ) expect(screen.getByTestId('result-panel')).toHaveTextContent('running') - expect(mockResultPanel).toHaveBeenCalledWith(expect.objectContaining({ - status: 'running', - showSteps: false, - })) + expect(mockResultPanel).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'running', + showSteps: false, + }), + ) }) it('should render the no-data state for 404 last-run responses and forward single-run clicks', () => { @@ -156,12 +159,14 @@ describe('LastRun', () => { ) expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Stopped) - expect(mockResultPanel).toHaveBeenCalledWith(expect.objectContaining({ - status: NodeRunningStatus.Stopped, - total_tokens: 9, - created_by: 'Alice', - showSteps: false, - })) + expect(mockResultPanel).toHaveBeenCalledWith( + expect.objectContaining({ + status: NodeRunningStatus.Stopped, + total_tokens: 9, + created_by: 'Alice', + showSteps: false, + }), + ) }) it('should respect stopped and listening one-step statuses', () => { diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.spec.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.spec.ts index c49171c998d43c..8f3c660211a62d 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.spec.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.spec.ts @@ -57,18 +57,20 @@ describe('useLastRun', () => { }) it('syncs the draft before opening a custom single-run form', () => { - const { result } = renderWorkflowHook(() => useLastRun({ - id: 'data-source-node', - flowId: 'flow-id', - flowType: FlowType.appFlow, - data: { - type: BlockEnum.DataSource, - title: 'Data Source', - desc: '', - }, - defaultRunInputData: {}, - isPaused: false, - })) + const { result } = renderWorkflowHook(() => + useLastRun({ + id: 'data-source-node', + flowId: 'flow-id', + flowType: FlowType.appFlow, + data: { + type: BlockEnum.DataSource, + title: 'Data Source', + desc: '', + }, + defaultRunInputData: {}, + isPaused: false, + }), + ) act(() => { result.current.handleSingleRun() diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx index 23d405b587ecf4..aa1b23f345bef0 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx @@ -23,7 +23,8 @@ type Props = Readonly<{ onSingleRunClicked: () => void singleRunResult?: NodeTracing isPaused?: boolean -}> & Partial<ResultPanelProps> +}> & + Partial<ResultPanelProps> const LastRun: FC<Props> = ({ appId: _appId, @@ -38,38 +39,52 @@ const LastRun: FC<Props> = ({ isPaused, ...otherResultPanelProps }) => { - const configsMap = useHooksStore(s => s.configsMap) + const configsMap = useHooksStore((s) => s.configsMap) const isOneStepRunSucceed = oneStepRunRunningStatus === NodeRunningStatus.Succeeded const isOneStepRunFailed = oneStepRunRunningStatus === NodeRunningStatus.Failed // hide page and return to page would lost the oneStepRunRunningStatus - const [hidePageOneStepFinishedStatus, setHidePageOneStepFinishedStatus] = React.useState<NodeRunningStatus | null>(null) + const [hidePageOneStepFinishedStatus, setHidePageOneStepFinishedStatus] = + React.useState<NodeRunningStatus | null>(null) const [pageHasHide, setPageHasHide] = useState(false) const [pageShowed, setPageShowed] = useState(false) - const hidePageOneStepRunFinished = [NodeRunningStatus.Succeeded, NodeRunningStatus.Failed].includes(hidePageOneStepFinishedStatus!) - const canRunLastRun = !isRunAfterSingleRun || isOneStepRunSucceed || isOneStepRunFailed || (pageHasHide && hidePageOneStepRunFinished) - const { data: lastRunResult, isFetching, error } = useLastRun(configsMap?.flowType || FlowType.appFlow, configsMap?.flowId || '', nodeId, canRunLastRun) + const hidePageOneStepRunFinished = [ + NodeRunningStatus.Succeeded, + NodeRunningStatus.Failed, + ].includes(hidePageOneStepFinishedStatus!) + const canRunLastRun = + !isRunAfterSingleRun || + isOneStepRunSucceed || + isOneStepRunFailed || + (pageHasHide && hidePageOneStepRunFinished) + const { + data: lastRunResult, + isFetching, + error, + } = useLastRun( + configsMap?.flowType || FlowType.appFlow, + configsMap?.flowId || '', + nodeId, + canRunLastRun, + ) const isRunning = useMemo(() => { - if (isPaused) - return false + if (isPaused) return false - if (!isRunAfterSingleRun) - return isFetching - return [NodeRunningStatus.Running, NodeRunningStatus.NotStart].includes(oneStepRunRunningStatus!) + if (!isRunAfterSingleRun) return isFetching + return [NodeRunningStatus.Running, NodeRunningStatus.NotStart].includes( + oneStepRunRunningStatus!, + ) }, [isFetching, isPaused, isRunAfterSingleRun, oneStepRunRunningStatus]) const noLastRun = (error as any)?.status === 404 const runResult = (canRunLastRun ? lastRunResult : singleRunResult) || lastRunResult || {} const resolvedStatus = useMemo(() => { - if (isPaused) - return NodeRunningStatus.Stopped + if (isPaused) return NodeRunningStatus.Stopped - if (oneStepRunRunningStatus === NodeRunningStatus.Stopped) - return NodeRunningStatus.Stopped + if (oneStepRunRunningStatus === NodeRunningStatus.Stopped) return NodeRunningStatus.Stopped - if (oneStepRunRunningStatus === NodeRunningStatus.Listening) - return NodeRunningStatus.Listening + if (oneStepRunRunningStatus === NodeRunningStatus.Listening) return NodeRunningStatus.Listening return (runResult as any).status || otherResultPanelProps.status }, [isPaused, oneStepRunRunningStatus, runResult, otherResultPanelProps.status]) @@ -80,7 +95,11 @@ const LastRun: FC<Props> = ({ setHidePageOneStepFinishedStatus(null) }, []) useEffect(() => { - if (pageShowed && hidePageOneStepFinishedStatus && (!oneStepRunRunningStatus || oneStepRunRunningStatus === NodeRunningStatus.NotStart)) { + if ( + pageShowed && + hidePageOneStepFinishedStatus && + (!oneStepRunRunningStatus || oneStepRunRunningStatus === NodeRunningStatus.NotStart) + ) { updateNodeRunningStatus(hidePageOneStepFinishedStatus) resetHidePageStatus() } @@ -96,10 +115,8 @@ const LastRun: FC<Props> = ({ }, [nodeId]) const handlePageVisibilityChange = useCallback(() => { - if (document.visibilityState === 'hidden') - setPageHasHide(true) - else - setPageShowed(true) + if (document.visibilityState === 'hidden') setPageHasHide(true) + else setPageShowed(true) }, []) useEffect(() => { document.addEventListener('visibilitychange', handlePageVisibilityChange) @@ -117,22 +134,24 @@ const LastRun: FC<Props> = ({ ) } - if (isRunning) - return <ResultPanel status="running" showSteps={false} /> + if (isRunning) return <ResultPanel status="running" showSteps={false} /> if (!isPaused && (noLastRun || !runResult)) { - return ( - <NoData canSingleRun={canSingleRun} onSingleRun={onSingleRunClicked} /> - ) + return <NoData canSingleRun={canSingleRun} onSingleRun={onSingleRunClicked} /> } return ( <div> <ResultPanel - {...runResult as any} + {...(runResult as any)} {...otherResultPanelProps} status={resolvedStatus} - total_tokens={(runResult as any)?.execution_metadata?.total_tokens || otherResultPanelProps?.total_tokens} - created_by={(runResult as any)?.created_by_account?.created_by || otherResultPanelProps?.created_by} + total_tokens={ + (runResult as any)?.execution_metadata?.total_tokens || + otherResultPanelProps?.total_tokens + } + created_by={ + (runResult as any)?.created_by_account?.created_by || otherResultPanelProps?.created_by + } nodeInfo={runResult as NodeTracing} showSteps={false} /> diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/no-data.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/no-data.tsx index 009d4e49db903b..6c05a6c079f55d 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/no-data.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/no-data.tsx @@ -11,23 +11,18 @@ type Props = Readonly<{ onSingleRun: () => void }> -const NoData: FC<Props> = ({ - canSingleRun, - onSingleRun, -}) => { +const NoData: FC<Props> = ({ canSingleRun, onSingleRun }) => { const { t } = useTranslation() return ( <div className="flex h-0 grow flex-col items-center justify-center"> <ClockPlay className="size-8 text-text-quaternary" /> - <div className="my-2 system-xs-regular text-text-tertiary">{t($ => $['debug.noData.description'], { ns: 'workflow' })}</div> + <div className="my-2 system-xs-regular text-text-tertiary"> + {t(($) => $['debug.noData.description'], { ns: 'workflow' })} + </div> {canSingleRun && ( - <Button - className="flex" - size="small" - onClick={onSingleRun} - > + <Button className="flex" size="small" onClick={onSingleRun}> <RiPlayLine className="mr-1 size-3.5" /> - <div>{t($ => $['debug.noData.runThisNode'], { ns: 'workflow' })}</div> + <div>{t(($) => $['debug.noData.runThisNode'], { ns: 'workflow' })}</div> </Button> )} </div> diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts index fb38aaaf4d5991..8840435f0219f7 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts @@ -4,9 +4,7 @@ import type { Params as OneStepRunParams } from '@/app/components/workflow/nodes import type { CommonNodeType, ValueSelector } from '@/app/components/workflow/types' import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useEffect, useState } from 'react' -import { - useNodesSyncDraft, -} from '@/app/components/workflow/hooks' +import { useNodesSyncDraft } from '@/app/components/workflow/hooks' import { useWorkflowRunValidation } from '@/app/components/workflow/hooks/use-checklist' import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import useOneStepRun from '@/app/components/workflow/nodes/_base/hooks/use-one-step-run' @@ -22,16 +20,13 @@ import useKnowledgeRetrievalSingleRunFormParams from '@/app/components/workflow/ import useLLMSingleRunFormParams from '@/app/components/workflow/nodes/llm/use-single-run-form-params' import useLoopSingleRunFormParams from '@/app/components/workflow/nodes/loop/use-single-run-form-params' import useParameterExtractorSingleRunFormParams from '@/app/components/workflow/nodes/parameter-extractor/use-single-run-form-params' - import useQuestionClassifierSingleRunFormParams from '@/app/components/workflow/nodes/question-classifier/use-single-run-form-params' import useStartSingleRunFormParams from '@/app/components/workflow/nodes/start/use-single-run-form-params' import useTemplateTransformSingleRunFormParams from '@/app/components/workflow/nodes/template-transform/use-single-run-form-params' - import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/hooks/use-get-data-for-check-more' import useToolSingleRunFormParams from '@/app/components/workflow/nodes/tool/hooks/use-single-run-form-params' import useTriggerPluginGetDataForCheckMore from '@/app/components/workflow/nodes/trigger-plugin/use-check-params' import useVariableAggregatorSingleRunFormParams from '@/app/components/workflow/nodes/variable-assigner/use-single-run-form-params' - import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { isSupportCustomRunForm } from '@/app/components/workflow/utils' @@ -117,18 +112,18 @@ const getDataForCheckMoreHooks: Record<BlockEnum, any> = { const useGetDataForCheckMoreHooks = <T>(nodeType: BlockEnum) => { return (id: string, payload: CommonNodeType<T>) => { - return getDataForCheckMoreHooks[nodeType]?.({ id, payload }) || { - getData: () => { - return {} - }, - } + return ( + getDataForCheckMoreHooks[nodeType]?.({ id, payload }) || { + getData: () => { + return {} + }, + } + ) } } type Params<T> = Omit<OneStepRunParams<T>, 'isRunAfterSingleRun'> -const useLastRun = <T>({ - ...oneStepRunParams -}: Params<T>) => { +const useLastRun = <T>({ ...oneStepRunParams }: Params<T>) => { const { conversationVars, systemVars, hasSetInspectVar } = useInspectVarsCrud() const blockType = oneStepRunParams.data.type const isStartNode = blockType === BlockEnum.Start @@ -138,17 +133,13 @@ const useLastRun = <T>({ const isCustomRunNode = isSupportCustomRunForm(blockType) const isHumanInputNode = blockType === BlockEnum.HumanInput const { handleSyncWorkflowDraft } = useNodesSyncDraft() - const { - getData: getDataForCheckMore, - } = useGetDataForCheckMoreHooks<T>(blockType)(oneStepRunParams.id, oneStepRunParams.data) + const { getData: getDataForCheckMore } = useGetDataForCheckMoreHooks<T>(blockType)( + oneStepRunParams.id, + oneStepRunParams.data, + ) const [isRunAfterSingleRun, setIsRunAfterSingleRun] = useState(false) - const { - id, - flowId, - flowType, - data, - } = oneStepRunParams + const { id, flowId, flowType, data } = oneStepRunParams const oneStepRunRes = useOneStepRun({ ...oneStepRunParams, iteratorInputKey: blockType === BlockEnum.Iteration ? `${id}.input_selector` : '', @@ -158,12 +149,10 @@ const useLastRun = <T>({ const { warningNodes } = useWorkflowRunValidation() const blockIfChecklistFailed = useCallback(() => { - const warningForNode = warningNodes.find(item => item.id === id) - if (!warningForNode) - return false + const warningForNode = warningNodes.find((item) => item.id === id) + if (!warningForNode) return false - if (warningForNode.unConnected && warningForNode.errorMessages.length === 0) - return false + if (warningForNode.unConnected && warningForNode.errorMessages.length === 0) return false const message = warningForNode.errorMessages[0] || 'This node has unresolved checklist issues' toast.error(message) @@ -188,9 +177,7 @@ const useLastRun = <T>({ } = oneStepRunRes const nodeInfo = runResult - const { - ...singleRunParams - } = useSingleRunFormParamsHooks(blockType)({ + const { ...singleRunParams } = useSingleRunFormParamsHooks(blockType)({ id, payload: data, runInputData, @@ -204,22 +191,24 @@ const useLastRun = <T>({ loopRunResult, }) - const toSubmitData = useCallback((data: Record<string, any>) => { - if (!isIterationNode && !isLoopNode) - return data + const toSubmitData = useCallback( + (data: Record<string, any>) => { + if (!isIterationNode && !isLoopNode) return data - const allVarObject = singleRunParams?.allVarObject || {} - const formattedData: Record<string, any> = {} - Object.keys(allVarObject).forEach((key) => { - const [varSectorStr, nodeId] = key.split(DELIMITER) - formattedData[`${nodeId}.${allVarObject[key].inSingleRunPassedKey}`] = data[varSectorStr!] - }) - if (isIterationNode) { - const iteratorInputKey = `${id}.input_selector` - formattedData[iteratorInputKey] = data[iteratorInputKey] - } - return formattedData - }, [isIterationNode, isLoopNode, singleRunParams?.allVarObject, id]) + const allVarObject = singleRunParams?.allVarObject || {} + const formattedData: Record<string, any> = {} + Object.keys(allVarObject).forEach((key) => { + const [varSectorStr, nodeId] = key.split(DELIMITER) + formattedData[`${nodeId}.${allVarObject[key].inSingleRunPassedKey}`] = data[varSectorStr!] + }) + if (isIterationNode) { + const iteratorInputKey = `${id}.input_selector` + formattedData[iteratorInputKey] = data[iteratorInputKey] + } + return formattedData + }, + [isIterationNode, isLoopNode, singleRunParams?.allVarObject, id], + ) const callRunApi = (data: Record<string, any>, cb?: () => void) => { handleSyncWorkflowDraft(true, true, { @@ -231,22 +220,21 @@ const useLastRun = <T>({ } const workflowStore = useWorkflowStore() const { setInitShowLastRunTab, setShowVariableInspectPanel } = workflowStore.getState() - const initShowLastRunTab = useStore(s => s.initShowLastRunTab) - const [tabType, setTabType] = useState<TabType>(initShowLastRunTab ? TabType.lastRun : TabType.settings) + const initShowLastRunTab = useStore((s) => s.initShowLastRunTab) + const [tabType, setTabType] = useState<TabType>( + initShowLastRunTab ? TabType.lastRun : TabType.settings, + ) useEffect(() => { - if (initShowLastRunTab) - setTabType(TabType.lastRun) + if (initShowLastRunTab) setTabType(TabType.lastRun) setInitShowLastRunTab(false) }, [initShowLastRunTab, setInitShowLastRunTab]) const invalidLastRun = useInvalidLastRun(flowType, flowId, id) const handleRunWithParams = async (data: Record<string, any>) => { - if (blockIfChecklistFailed()) - return + if (blockIfChecklistFailed()) return const { isValid } = checkValid() - if (!isValid) - return + if (!isValid) return setNodeRunning() setIsRunAfterSingleRun(true) setTabType(TabType.lastRun) @@ -262,27 +250,26 @@ const useLastRun = <T>({ }, []) const getExistVarValuesInForms = (forms: FormProps[]) => { - if (!forms || forms.length === 0) - return [] + if (!forms || forms.length === 0) return [] const valuesArr = forms.map((form) => { const values: Record<string, boolean> = {} form.inputs.forEach(({ variable, getVarValueFromDependent }) => { const isGetValueFromDependent = getVarValueFromDependent || !variable.includes('.') - if (isGetValueFromDependent && !singleRunParams?.getDependentVar) - return + if (isGetValueFromDependent && !singleRunParams?.getDependentVar) return - const selector = isGetValueFromDependent ? (singleRunParams?.getDependentVar(variable) || []) : variable.slice(1, -1).split('.') - if (!selector || selector.length === 0) - return + const selector = isGetValueFromDependent + ? singleRunParams?.getDependentVar(variable) || [] + : variable.slice(1, -1).split('.') + if (!selector || selector.length === 0) return const [nodeId, varName] = selector.slice(0, 2) - if (!isStartNode && nodeId === id) { // inner vars like loop vars + if (!isStartNode && nodeId === id) { + // inner vars like loop vars values[variable] = true return } const inspectVarValue = hasSetInspectVar(nodeId, varName, systemVars, conversationVars) // also detect system var , env and conversation var - if (inspectVarValue) - values[variable] = true + if (inspectVarValue) values[variable] = true }) return values }) @@ -290,8 +277,7 @@ const useLastRun = <T>({ } const isAllVarsHasValue = (vars?: ValueSelector[]) => { - if (!vars || vars.length === 0) - return true + if (!vars || vars.length === 0) return true return vars.every((varItem) => { const [nodeId, varName] = varItem.slice(0, 2) const inspectVarValue = hasSetInspectVar(nodeId!, varName!, systemVars, conversationVars) // also detect system var , env and conversation var @@ -300,8 +286,7 @@ const useLastRun = <T>({ } const isSomeVarsHasValue = (vars?: ValueSelector[]) => { - if (!vars || vars.length === 0) - return true + if (!vars || vars.length === 0) return true return vars.some((varItem) => { const [nodeId, varName] = varItem.slice(0, 2) const inspectVarValue = hasSetInspectVar(nodeId!, varName!, systemVars, conversationVars) // also detect system var , env and conversation var @@ -309,26 +294,26 @@ const useLastRun = <T>({ }) } const getFilteredExistVarForms = (forms: FormProps[]) => { - if (!forms || forms.length === 0) - return [] + if (!forms || forms.length === 0) return [] const existVarValuesInForms = getExistVarValuesInForms(forms) - const res = forms.map((form, i) => { - const existVarValuesInForm = existVarValuesInForms[i] - const newForm = { ...form } - const inputs = form.inputs.filter((input) => { - return !(input.variable in existVarValuesInForm!) + const res = forms + .map((form, i) => { + const existVarValuesInForm = existVarValuesInForms[i] + const newForm = { ...form } + const inputs = form.inputs.filter((input) => { + return !(input.variable in existVarValuesInForm!) + }) + newForm.inputs = inputs + return newForm }) - newForm.inputs = inputs - return newForm - }).filter(form => form.inputs.length > 0) + .filter((form) => form.inputs.length > 0) return res } const checkAggregatorVarsSet = (vars: ValueSelector[][]) => { - if (!vars || vars.length === 0) - return true + if (!vars || vars.length === 0) return true // in each group, at last one set is ok return vars.every((varItem) => { return isSomeVarsHasValue(varItem) @@ -347,12 +332,14 @@ const useLastRun = <T>({ } const handleSingleRun = () => { - if (blockIfChecklistFailed()) - return + if (blockIfChecklistFailed()) return const { isValid } = checkValid() - if (!isValid) - return - if (blockType === BlockEnum.TriggerWebhook || blockType === BlockEnum.TriggerPlugin || blockType === BlockEnum.TriggerSchedule) + if (!isValid) return + if ( + blockType === BlockEnum.TriggerWebhook || + blockType === BlockEnum.TriggerPlugin || + blockType === BlockEnum.TriggerSchedule + ) setShowVariableInspectPanel(true) if (isCustomRunNode || isHumanInputNode) { showSingleRunWithDraftSync() @@ -367,8 +354,7 @@ const useLastRun = <T>({ invalidLastRun() setTabType(TabType.lastRun) }) - } - else { + } else { showSingleRunWithDraftSync() } } diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/start-placeholder-panel.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/start-placeholder-panel.tsx index bf107d6018a6b3..674161449049f0 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/start-placeholder-panel.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/start-placeholder-panel.tsx @@ -6,7 +6,7 @@ export function StartPlaceholderPanelTitle() { return ( <div className="mr-2 min-w-0 grow system-xl-semibold text-text-primary"> - {t($ => $['nodes.startPlaceholder.panelTitle'], { ns: 'workflow' })} + {t(($) => $['nodes.startPlaceholder.panelTitle'], { ns: 'workflow' })} </div> ) } @@ -16,19 +16,11 @@ export function StartPlaceholderPanelDescription() { return ( <div className="px-4 pb-3 system-xs-regular text-text-tertiary"> - {t($ => $['nodes.startPlaceholder.panelDescription'], { ns: 'workflow' })} + {t(($) => $['nodes.startPlaceholder.panelDescription'], { ns: 'workflow' })} </div> ) } -export function StartPlaceholderPanelBody({ - children, -}: { - children: ReactNode -}) { - return ( - <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> - {children} - </div> - ) +export function StartPlaceholderPanelBody({ children }: { children: ReactNode }) { + return <div className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</div> } diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/trigger-subscription.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/trigger-subscription.tsx index d85d215923f203..f997b02295a382 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/trigger-subscription.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/trigger-subscription.tsx @@ -12,7 +12,11 @@ type TriggerSubscriptionProps = { children: React.ReactNode } -export const TriggerSubscription: FC<TriggerSubscriptionProps> = ({ subscriptionIdSelected, onSubscriptionChange, children }) => { +export const TriggerSubscription: FC<TriggerSubscriptionProps> = ({ + subscriptionIdSelected, + onSubscriptionChange, + children, +}) => { const { subscriptions } = useSubscriptionList() const subscriptionCount = subscriptions?.length || 0 diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/types.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/types.ts index 00aa7deaf17e11..494079bed8e57f 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/types.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/types.ts @@ -4,4 +4,4 @@ export const TabType = { relations: 'relations', } as const -export type TabType = typeof TabType[keyof typeof TabType] +export type TabType = (typeof TabType)[keyof typeof TabType] diff --git a/web/app/components/workflow/nodes/_base/hooks/__tests__/snippet-input-field-vars.spec.ts b/web/app/components/workflow/nodes/_base/hooks/__tests__/snippet-input-field-vars.spec.ts index 6ba689b18431a5..4d250883b8d177 100644 --- a/web/app/components/workflow/nodes/_base/hooks/__tests__/snippet-input-field-vars.spec.ts +++ b/web/app/components/workflow/nodes/_base/hooks/__tests__/snippet-input-field-vars.spec.ts @@ -2,16 +2,17 @@ import type { Node } from '@/app/components/workflow/types' import { BlockEnum } from '@/app/components/workflow/types' import { appendSnippetInputFieldVars } from '../snippet-input-field-vars' -const createNode = (id = 'node-1'): Node => ({ - id, - type: 'custom', - position: { x: 0, y: 0 }, - data: { - type: BlockEnum.LLM, - title: 'Node', - desc: '', - }, -} as Node) +const createNode = (id = 'node-1'): Node => + ({ + id, + type: 'custom', + position: { x: 0, y: 0 }, + data: { + type: BlockEnum.LLM, + title: 'Node', + desc: '', + }, + }) as Node describe('appendSnippetInputFieldVars', () => { beforeEach(() => { @@ -22,11 +23,13 @@ describe('appendSnippetInputFieldVars', () => { globalThis.history.pushState({}, '', '/snippets/snippet-1/orchestrate') const availableNodes = [createNode()] - expect(appendSnippetInputFieldVars({ - availableNodes, - fields: undefined, - title: 'Snippet', - })).toEqual({ + expect( + appendSnippetInputFieldVars({ + availableNodes, + fields: undefined, + title: 'Snippet', + }), + ).toEqual({ availableNodes, availableVars: [], }) diff --git a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-available-var-list.spec.ts b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-available-var-list.spec.ts index 58abbbcafe279b..015d8e02ddcb21 100644 --- a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-available-var-list.spec.ts +++ b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-available-var-list.spec.ts @@ -13,7 +13,8 @@ const mockFlowType = vi.hoisted(() => ({ })) vi.mock('@/app/components/snippets/draft-store', () => ({ - useSnippetDraftStore: (selector: (state: { inputFields: unknown[] }) => unknown) => selector({ inputFields: [] }), + useSnippetDraftStore: (selector: (state: { inputFields: unknown[] }) => unknown) => + selector({ inputFields: [] }), })) vi.mock('@/app/components/workflow/hooks', () => ({ @@ -29,15 +30,17 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { ragPipelineVariables: unknown[] }) => unknown) => selector({ ragPipelineVariables: [] }), + useStore: (selector: (state: { ragPipelineVariables: unknown[] }) => unknown) => + selector({ ragPipelineVariables: [] }), })) vi.mock('@/app/components/workflow/hooks-store/store', () => ({ - useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => selector({ - configsMap: { - flowType: mockFlowType.value, - }, - }), + useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => + selector({ + configsMap: { + flowType: mockFlowType.value, + }, + }), })) vi.mock('../use-node-info', () => ({ @@ -46,17 +49,18 @@ vi.mock('../use-node-info', () => ({ }), })) -const createNode = (overrides: Partial<Node> = {}): Node => ({ - id: 'node-1', - type: 'custom', - position: { x: 0, y: 0 }, - data: { - type: BlockEnum.LLM, - title: 'Node', - desc: '', - }, - ...overrides, -} as Node) +const createNode = (overrides: Partial<Node> = {}): Node => + ({ + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + type: BlockEnum.LLM, + title: 'Node', + desc: '', + }, + ...overrides, + }) as Node const outputVarsWithSystemVars: NodeOutPutVar[] = [ { @@ -76,10 +80,12 @@ const outputVarsWithSystemVars: NodeOutPutVar[] = [ { nodeId: 'global', title: 'SYSTEM', - vars: [{ - variable: 'sys.user_id', - type: VarType.string, - }] satisfies Var[], + vars: [ + { + variable: 'sys.user_id', + type: VarType.string, + }, + ] satisfies Var[], }, ] @@ -97,24 +103,32 @@ describe('useAvailableVarList', () => { it('filters system variables on snippet canvases', () => { globalThis.history.pushState({}, '', '/snippets/snippet-1/orchestrate') - const { result } = renderHook(() => useAvailableVarList('node-1', { - filterVar: () => true, - })) + const { result } = renderHook(() => + useAvailableVarList('node-1', { + filterVar: () => true, + }), + ) - expect(result.current.availableVars).toEqual([{ - nodeId: 'vars-node', - title: 'Vars', - vars: [{ - variable: 'answer', - type: VarType.string, - }], - }]) + expect(result.current.availableVars).toEqual([ + { + nodeId: 'vars-node', + title: 'Vars', + vars: [ + { + variable: 'answer', + type: VarType.string, + }, + ], + }, + ]) }) it('keeps system variables outside snippet canvases', () => { - const { result } = renderHook(() => useAvailableVarList('node-1', { - filterVar: () => true, - })) + const { result } = renderHook(() => + useAvailableVarList('node-1', { + filterVar: () => true, + }), + ) expect(result.current.availableVars).toEqual(outputVarsWithSystemVars) }) @@ -122,17 +136,23 @@ describe('useAvailableVarList', () => { it('filters system variables when the current flow is a snippet', () => { mockFlowType.value = FlowType.snippet - const { result } = renderHook(() => useAvailableVarList('node-1', { - filterVar: () => true, - })) + const { result } = renderHook(() => + useAvailableVarList('node-1', { + filterVar: () => true, + }), + ) - expect(result.current.availableVars).toEqual([{ - nodeId: 'vars-node', - title: 'Vars', - vars: [{ - variable: 'answer', - type: VarType.string, - }], - }]) + expect(result.current.availableVars).toEqual([ + { + nodeId: 'vars-node', + title: 'Vars', + vars: [ + { + variable: 'answer', + type: VarType.string, + }, + ], + }, + ]) }) }) diff --git a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-node-crud.spec.ts b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-node-crud.spec.ts index 1ad0c38e8a560a..ca8558df5288ae 100644 --- a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-node-crud.spec.ts +++ b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-node-crud.spec.ts @@ -31,15 +31,12 @@ describe('useNodeCrud', () => { }) it('keeps setInputs stable across rerenders when id does not change', () => { - const { result, rerender } = renderHook( - ({ id, data }) => useNodeCrud(id, data), - { - initialProps: { - id: 'node-1', - data: createData(), - }, + const { result, rerender } = renderHook(({ id, data }) => useNodeCrud(id, data), { + initialProps: { + id: 'node-1', + data: createData(), }, - ) + }) const firstSetInputs = result.current.setInputs @@ -52,15 +49,12 @@ describe('useNodeCrud', () => { }) it('forwards node data updates with the current node id and latest updater', () => { - const { result, rerender } = renderHook( - ({ id, data }) => useNodeCrud(id, data), - { - initialProps: { - id: 'node-1', - data: createData(), - }, + const { result, rerender } = renderHook(({ id, data }) => useNodeCrud(id, data), { + initialProps: { + id: 'node-1', + data: createData(), }, - ) + }) result.current.setInputs(createData('changed')) diff --git a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts index f42efaa3b1f508..7d6c37bae88236 100644 --- a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts +++ b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts @@ -1,9 +1,5 @@ import { renderHook } from '@testing-library/react' -import { - BlockEnum, - InputVarType, - VarType, -} from '@/app/components/workflow/types' +import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types' import { FlowType } from '@/types/common' import useOneStepRun from '../use-one-step-run' @@ -171,19 +167,22 @@ vi.mock('@/app/components/workflow/nodes/variable-assigner/default', () => ({ default: {}, })) -const renderUseOneStepRun = () => renderHook(() => useOneStepRun({ - id: 'if-else-node', - flowId: 'app-id', - flowType: FlowType.appFlow, - data: { - type: BlockEnum.IfElse, - title: 'IF/ELSE', - desc: '', - }, - defaultRunInputData: {}, - isRunAfterSingleRun: false, - isPaused: false, -})) +const renderUseOneStepRun = () => + renderHook(() => + useOneStepRun({ + id: 'if-else-node', + flowId: 'app-id', + flowType: FlowType.appFlow, + data: { + type: BlockEnum.IfElse, + title: 'IF/ELSE', + desc: '', + }, + defaultRunInputData: {}, + isRunAfterSingleRun: false, + isPaused: false, + }), + ) describe('useOneStepRun single-run input vars', () => { beforeEach(() => { @@ -218,9 +217,7 @@ describe('useOneStepRun single-run input vars', () => { it('resolves global system vars by full variable name', () => { const { result } = renderUseOneStepRun() - const inputs = result.current.varSelectorsToVarInputs([ - ['sys', 'timestamp'], - ]) + const inputs = result.current.varSelectorsToVarInputs([['sys', 'timestamp']]) expect(inputs).toMatchObject([ { diff --git a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-toggle-expend.spec.ts b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-toggle-expend.spec.ts index 266800d9aa335f..315c0a2df8644a 100644 --- a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-toggle-expend.spec.ts +++ b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-toggle-expend.spec.ts @@ -39,9 +39,7 @@ describe('useToggleExpend', () => { describe('expanded state (node context)', () => { it('uses fixed positioning inside a workflow node panel', () => { - const { result } = renderHook(() => - useHarness({ isInNode: true, clientHeight: 400 }), - ) + const { result } = renderHook(() => useHarness({ isInNode: true, clientHeight: 400 })) act(() => { result.current.setIsExpand(true) @@ -58,9 +56,7 @@ describe('useToggleExpend', () => { describe('expanded state (execution-log / webapp context)', () => { it('fills its positioned ancestor edge-to-edge without hardcoded offsets', () => { - const { result } = renderHook(() => - useHarness({ isInNode: false, clientHeight: 400 }), - ) + const { result } = renderHook(() => useHarness({ isInNode: false, clientHeight: 400 })) act(() => { result.current.setIsExpand(true) @@ -82,9 +78,7 @@ describe('useToggleExpend', () => { describe('expanded state height math', () => { it('subtracts the 29px chrome when hasFooter is false', () => { - const { result } = renderHook(() => - useHarness({ hasFooter: false, clientHeight: 400 }), - ) + const { result } = renderHook(() => useHarness({ hasFooter: false, clientHeight: 400 })) act(() => { result.current.setIsExpand(true) @@ -95,9 +89,7 @@ describe('useToggleExpend', () => { }) it('subtracts the 56px chrome when hasFooter is true', () => { - const { result } = renderHook(() => - useHarness({ hasFooter: true, clientHeight: 400 }), - ) + const { result } = renderHook(() => useHarness({ hasFooter: true, clientHeight: 400 })) act(() => { result.current.setIsExpand(true) @@ -108,9 +100,7 @@ describe('useToggleExpend', () => { }) it('never returns a negative height even if chrome exceeds wrap', () => { - const { result } = renderHook(() => - useHarness({ hasFooter: true, clientHeight: 20 }), - ) + const { result } = renderHook(() => useHarness({ hasFooter: true, clientHeight: 20 })) act(() => { result.current.setIsExpand(true) diff --git a/web/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars.ts b/web/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars.ts index 86cd5b49f9465c..c2163e050b840f 100644 --- a/web/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars.ts +++ b/web/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars.ts @@ -8,22 +8,23 @@ import { inputVarTypeToVarType } from '../../data-source/utils' const SNIPPET_INPUT_FIELD_NODE_ID = 'start' export const isSnippetCanvas = () => { - if (typeof globalThis.location === 'undefined') - return false + if (typeof globalThis.location === 'undefined') return false return /^\/snippets\/[^/]+\/orchestrate/.test(globalThis.location.pathname) } -export const filterSnippetSystemVars = (availableVars: NodeOutPutVar[], isSnippetFlow = isSnippetCanvas()) => { - if (!isSnippetFlow) - return availableVars +export const filterSnippetSystemVars = ( + availableVars: NodeOutPutVar[], + isSnippetFlow = isSnippetCanvas(), +) => { + if (!isSnippetFlow) return availableVars return availableVars - .map(nodeVar => ({ + .map((nodeVar) => ({ ...nodeVar, - vars: nodeVar.vars.filter(variable => !variable.variable.startsWith('sys.')), + vars: nodeVar.vars.filter((variable) => !variable.variable.startsWith('sys.')), })) - .filter(nodeVar => nodeVar.vars.length > 0) + .filter((nodeVar) => nodeVar.vars.length > 0) } const toWorkflowInputType = (type: SnippetInputField['type']) => type as unknown as InputVarType @@ -32,10 +33,9 @@ const buildSnippetInputFieldNode = ( fields: SnippetInputField[], title: string, ): Node | undefined => { - const variables = fields.filter(field => !!field.variable) + const variables = fields.filter((field) => !!field.variable) - if (!variables.length) - return undefined + if (!variables.length) return undefined return { id: SNIPPET_INPUT_FIELD_NODE_ID, @@ -47,7 +47,7 @@ const buildSnippetInputFieldNode = ( title, desc: '', type: BlockEnum.Start, - variables: variables.map(field => ({ + variables: variables.map((field) => ({ type: toWorkflowInputType(field.type), label: field.label, variable: field.variable, @@ -70,8 +70,8 @@ const buildSnippetInputFieldVars = ( title: string, ): NodeOutPutVar | undefined => { const vars = fields - .filter(field => !!field.variable) - .map(field => ({ + .filter((field) => !!field.variable) + .map((field) => ({ variable: field.variable, type: inputVarTypeToVarType(field.type as PipelineInputVarType), isParagraph: field.type === PipelineInputVarType.paragraph, @@ -81,8 +81,7 @@ const buildSnippetInputFieldVars = ( des: field.label, })) - if (!vars.length) - return undefined + if (!vars.length) return undefined return { nodeId: SNIPPET_INPUT_FIELD_NODE_ID, @@ -102,9 +101,10 @@ export const appendSnippetInputFieldVars = ({ title: string }) => { const inputFields = fields ?? [] - const shouldAppendSnippetInputFields = isSnippetCanvas() - && inputFields.length > 0 - && !availableNodes.some(node => node.data.type === BlockEnum.Start) + const shouldAppendSnippetInputFields = + isSnippetCanvas() && + inputFields.length > 0 && + !availableNodes.some((node) => node.data.type === BlockEnum.Start) const snippetInputFieldNode = shouldAppendSnippetInputFields ? buildSnippetInputFieldNode(inputFields, title) : undefined diff --git a/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts b/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts index 322445f6d44532..7687a5f55027bc 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts @@ -1,17 +1,17 @@ import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { useTranslation } from 'react-i18next' import { useSnippetDraftStore } from '@/app/components/snippets/draft-store' -import { - useIsChatMode, - useWorkflow, - useWorkflowVariables, -} from '@/app/components/workflow/hooks' +import { useIsChatMode, useWorkflow, useWorkflowVariables } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store/store' import { useStore as useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { FlowType } from '@/types/common' import { inputVarTypeToVarType } from '../../data-source/utils' -import { appendSnippetInputFieldVars, filterSnippetSystemVars, isSnippetCanvas } from './snippet-input-field-vars' +import { + appendSnippetInputFieldVars, + filterSnippetSystemVars, + isSnippetCanvas, +} from './snippet-input-field-vars' import useNodeInfo from './use-node-info' type Params = { @@ -23,44 +23,49 @@ type Params = { } // TODO: loop type? -const useAvailableVarList = (nodeId: string, { - onlyLeafNodeVar, - filterVar, - hideEnv, - hideChatVar, - passedInAvailableNodes, -}: Params = { - onlyLeafNodeVar: false, - filterVar: () => true, -}) => { +const useAvailableVarList = ( + nodeId: string, + { onlyLeafNodeVar, filterVar, hideEnv, hideChatVar, passedInAvailableNodes }: Params = { + onlyLeafNodeVar: false, + filterVar: () => true, + }, +) => { const { t } = useTranslation() - const snippetInputFields = useSnippetDraftStore(s => s.inputFields) + const snippetInputFields = useSnippetDraftStore((s) => s.inputFields) const { getTreeLeafNodes, getNodeById, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() - const isSnippetFlow = useHooksStore(s => s.configsMap?.flowType) === FlowType.snippet || isSnippetCanvas() - const availableNodes = passedInAvailableNodes || (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranchIncludeParent(nodeId)) + const isSnippetFlow = + useHooksStore((s) => s.configsMap?.flowType) === FlowType.snippet || isSnippetCanvas() + const availableNodes = + passedInAvailableNodes || + (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranchIncludeParent(nodeId)) const snippetInputFieldAvailability = appendSnippetInputFieldVars({ availableNodes, fields: snippetInputFields, - title: t($ => $.panelTitle, { ns: 'snippet' }), + title: t(($) => $.panelTitle, { ns: 'snippet' }), }) - const { - parentNode: iterationNode, - } = useNodeInfo(nodeId) + const { parentNode: iterationNode } = useNodeInfo(nodeId) const currNode = getNodeById(nodeId) - const ragPipelineVariables = useWorkflowStore(s => s.ragPipelineVariables) + const ragPipelineVariables = useWorkflowStore((s) => s.ragPipelineVariables) const isDataSourceNode = currNode?.data?.type === BlockEnum.DataSource const dataSourceRagVars: NodeOutPutVar[] = [] if (isDataSourceNode) { - const ragVariablesInDataSource = ragPipelineVariables?.filter(ragVariable => ragVariable.belong_to_node_id === nodeId) - const filterVars = ragVariablesInDataSource?.filter(v => filterVar({ - variable: v.variable, - type: inputVarTypeToVarType(v.type), - nodeId, - isRagVariable: true, - }, ['rag', nodeId, v.variable])) + const ragVariablesInDataSource = ragPipelineVariables?.filter( + (ragVariable) => ragVariable.belong_to_node_id === nodeId, + ) + const filterVars = ragVariablesInDataSource?.filter((v) => + filterVar( + { + variable: v.variable, + type: inputVarTypeToVarType(v.type), + nodeId, + isRagVariable: true, + }, + ['rag', nodeId, v.variable], + ), + ) if (filterVars?.length) { dataSourceRagVars.push({ nodeId, @@ -76,18 +81,21 @@ const useAvailableVarList = (nodeId: string, { }) } } - const availableVars = filterSnippetSystemVars([ - ...snippetInputFieldAvailability.availableVars, - ...getNodeAvailableVars({ - parentNode: iterationNode, - beforeNodes: availableNodes, - isChatMode, - filterVar, - hideEnv, - hideChatVar, - }), - ...dataSourceRagVars, - ], isSnippetFlow) + const availableVars = filterSnippetSystemVars( + [ + ...snippetInputFieldAvailability.availableVars, + ...getNodeAvailableVars({ + parentNode: iterationNode, + beforeNodes: availableNodes, + isChatMode, + filterVar, + hideEnv, + hideChatVar, + }), + ...dataSourceRagVars, + ], + isSnippetFlow, + ) return { availableVars, diff --git a/web/app/components/workflow/nodes/_base/hooks/use-node-crud.ts b/web/app/components/workflow/nodes/_base/hooks/use-node-crud.ts index fad9aa9c5e1014..7dcf6c237d982c 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-node-crud.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-node-crud.ts @@ -10,12 +10,15 @@ const useNodeCrud = <T>(id: string, data: CommonNodeType<T>) => { updateRef.current = handleNodeDataUpdateWithSyncDraft }, [handleNodeDataUpdateWithSyncDraft]) - const setInputs = useCallback((newInputs: CommonNodeType<T>) => { - updateRef.current({ - id, - data: newInputs, - }) - }, [id]) + const setInputs = useCallback( + (newInputs: CommonNodeType<T>) => { + updateRef.current({ + id, + data: newInputs, + }) + }, + [id], + ) return { inputs: data, diff --git a/web/app/components/workflow/nodes/_base/hooks/use-node-info.ts b/web/app/components/workflow/nodes/_base/hooks/use-node-info.ts index a66e0f19b59fa6..ecffe495908ac1 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-node-info.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-node-info.ts @@ -2,15 +2,13 @@ import { useStoreApi } from 'reactflow' const useNodeInfo = (nodeId: string) => { const store = useStoreApi() - const { - getNodes, - } = store.getState() + const { getNodes } = store.getState() const allNodes = getNodes() - const node = allNodes.find(n => n.id === nodeId) + const node = allNodes.find((n) => n.id === nodeId) const isInIteration = !!node?.data.isInIteration const isInLoop = !!node?.data.isInLoop const parentNodeId = node?.parentId - const parentNode = allNodes.find(n => n.id === parentNodeId) + const parentNode = allNodes.find((n) => n.id === parentNodeId) return { node, isInIteration, diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index 263dc9e060eebb..f1524dbb5fb287 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts @@ -1,25 +1,31 @@ -import type { CommonNodeType, InputVar, TriggerNodeType, ValueSelector, Var, Variable } from '@/app/components/workflow/types' +import type { + CommonNodeType, + InputVar, + TriggerNodeType, + ValueSelector, + Var, + Variable, +} from '@/app/components/workflow/types' import type { FlowType } from '@/types/common' import type { NodeRunResult, NodeTracing } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' import { unionBy } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function' - import { produce } from 'immer' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - useStoreApi, -} from 'reactflow' +import { useStoreApi } from 'reactflow' import { trackEvent } from '@/app/components/base/amplitude' import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants' -import { - useIsChatMode, - useNodeDataUpdate, - useWorkflow, -} from '@/app/components/workflow/hooks' +import { useIsChatMode, useNodeDataUpdate, useWorkflow } from '@/app/components/workflow/hooks' import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' -import { getNodeInfoById, isConversationVar, isENV, isSystemVar, toNodeOutputVars } from '@/app/components/workflow/nodes/_base/components/variable/utils' +import { + getNodeInfoById, + isConversationVar, + isENV, + isSystemVar, + toNodeOutputVars, +} from '@/app/components/workflow/nodes/_base/components/variable/utils' import Assigner from '@/app/components/workflow/nodes/assigner/default' import CodeDefault from '@/app/components/workflow/nodes/code/default' import DocumentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default' @@ -53,7 +59,12 @@ import { useAllWorkflowTools, } from '@/service/use-tools' import { useInvalidLastRun } from '@/service/use-workflow' -import { fetchNodeInspectVars, getIterationSingleNodeRunUrl, getLoopSingleNodeRunUrl, singleNodeRun } from '@/service/workflow' +import { + fetchNodeInspectVars, + getIterationSingleNodeRunUrl, + getLoopSingleNodeRunUrl, + singleNodeRun, +} from '@/service/workflow' import useMatchSchemaType from '../components/variable/use-match-schema-type' const { checkValid: checkLLMValid } = LLMDefault @@ -109,27 +120,32 @@ export type Params<T> = { isPaused: boolean } -const varTypeToInputVarType = (type: VarType, { - isSelect, - isParagraph, -}: { - isSelect: boolean - isParagraph: boolean -}) => { - if (isSelect) - return InputVarType.select - if (isParagraph) - return InputVarType.paragraph - if (type === VarType.number) - return InputVarType.number - if (type === VarType.boolean) - return InputVarType.checkbox - if ([VarType.object, VarType.array, VarType.arrayNumber, VarType.arrayString, VarType.arrayObject].includes(type)) +const varTypeToInputVarType = ( + type: VarType, + { + isSelect, + isParagraph, + }: { + isSelect: boolean + isParagraph: boolean + }, +) => { + if (isSelect) return InputVarType.select + if (isParagraph) return InputVarType.paragraph + if (type === VarType.number) return InputVarType.number + if (type === VarType.boolean) return InputVarType.checkbox + if ( + [ + VarType.object, + VarType.array, + VarType.arrayNumber, + VarType.arrayString, + VarType.arrayObject, + ].includes(type) + ) return InputVarType.json - if (type === VarType.file) - return InputVarType.singleFile - if (type === VarType.arrayFile) - return InputVarType.multiFiles + if (type === VarType.file) return InputVarType.singleFile + if (type === VarType.arrayFile) return InputVarType.multiFiles return InputVarType.textInput } @@ -147,8 +163,9 @@ const useOneStepRun = <T>({ isPaused, }: Params<T>) => { const { t } = useTranslation() - const { getBeforeNodesInSameBranch, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() as any - const conversationVariables = useStore(s => s.conversationVariables) + const { getBeforeNodesInSameBranch, getBeforeNodesInSameBranchIncludeParent } = + useWorkflow() as any + const conversationVariables = useStore((s) => s.conversationVariables) const isChatMode = useIsChatMode() const isIteration = data.type === BlockEnum.Iteration const isLoop = data.type === BlockEnum.Loop @@ -166,9 +183,7 @@ const useOneStepRun = <T>({ const getVar = (valueSelector: ValueSelector): Var | undefined => { const isSystem = valueSelector[0] === 'sys' - const { - dataSourceList, - } = workflowStore.getState() + const { dataSourceList } = workflowStore.getState() const allPluginInfoList = { buildInTools: buildInTools || [], customTools: customTools || [], @@ -177,15 +192,25 @@ const useOneStepRun = <T>({ dataSourceList: dataSourceList || [], } - const allOutputVars = toNodeOutputVars(availableNodes, isChatMode, undefined, undefined, conversationVariables, [], allPluginInfoList, schemaTypeDefinitions) + const allOutputVars = toNodeOutputVars( + availableNodes, + isChatMode, + undefined, + undefined, + conversationVariables, + [], + allPluginInfoList, + schemaTypeDefinitions, + ) if (isSystem) { const selectorKey = valueSelector.join('.') - return allOutputVars.flatMap(item => item.vars).find(item => item.variable === selectorKey) + return allOutputVars + .flatMap((item) => item.vars) + .find((item) => item.variable === selectorKey) } - const targetVar = allOutputVars.find(item => item.nodeId === valueSelector[0]) - if (!targetVar) - return undefined + const targetVar = allOutputVars.find((item) => item.nodeId === valueSelector[0]) + if (!targetVar) return undefined let curr: any = targetVar.vars for (let i = 1; i < valueSelector.length; i++) { @@ -195,10 +220,8 @@ const useOneStepRun = <T>({ if (Array.isArray(curr)) curr = curr.find((v: any) => v.variable.replace('conversation.', '') === key) - if (isLast) - return curr - else if (curr?.type === VarType.object || curr?.type === VarType.file) - curr = curr.children + if (isLast) return curr + else if (curr?.type === VarType.object || curr?.type === VarType.file) curr = curr.children } return undefined @@ -225,52 +248,46 @@ const useOneStepRun = <T>({ setListeningTriggerIsAll, setShowVariableInspectPanel, } = workflowStore.getState() - const updateNodeInspectRunningState = useCallback((nodeId: string, isRunning: boolean) => { - const { - nodesWithInspectVars, - setNodesWithInspectVars, - } = workflowStore.getState() - - let hasChanges = false - const nodes = produce(nodesWithInspectVars, (draft) => { - const index = draft.findIndex(node => node.nodeId === nodeId) - if (index !== -1) { - const targetNode = draft[index] - if (targetNode!.isSingRunRunning !== isRunning) { - targetNode!.isSingRunRunning = isRunning - if (isRunning) - targetNode!.isValueFetched = false - hasChanges = true - } - } - else if (isRunning) { - const { getNodes } = store.getState() - const target = getNodes().find(node => node.id === nodeId) - if (target) { - draft.unshift({ - nodeId, - nodeType: target.data.type, - title: target.data.title, - vars: [], - nodePayload: target.data, - isSingRunRunning: true, - isValueFetched: false, - }) - hasChanges = true + const updateNodeInspectRunningState = useCallback( + (nodeId: string, isRunning: boolean) => { + const { nodesWithInspectVars, setNodesWithInspectVars } = workflowStore.getState() + + let hasChanges = false + const nodes = produce(nodesWithInspectVars, (draft) => { + const index = draft.findIndex((node) => node.nodeId === nodeId) + if (index !== -1) { + const targetNode = draft[index] + if (targetNode!.isSingRunRunning !== isRunning) { + targetNode!.isSingRunRunning = isRunning + if (isRunning) targetNode!.isValueFetched = false + hasChanges = true + } + } else if (isRunning) { + const { getNodes } = store.getState() + const target = getNodes().find((node) => node.id === nodeId) + if (target) { + draft.unshift({ + nodeId, + nodeType: target.data.type, + title: target.data.title, + vars: [], + nodePayload: target.data, + isSingRunRunning: true, + isValueFetched: false, + }) + hasChanges = true + } } - } - }) + }) - if (hasChanges) - setNodesWithInspectVars(nodes) - }, [workflowStore, store]) + if (hasChanges) setNodesWithInspectVars(nodes) + }, + [workflowStore, store], + ) const invalidLastRun = useInvalidLastRun(flowType, flowId!, id) const [runResult, doSetRunResult] = useState<NodeRunResult | null>(null) - const { - appendNodeInspectVars, - invalidateSysVarValues, - invalidateConversationVarValues, - } = useInspectVarsCrud() + const { appendNodeInspectVars, invalidateSysVarValues, invalidateConversationVarValues } = + useInspectVarsCrud() const runningStatus = data._singleRunningStatus || NodeRunningStatus.NotStart const webhookSingleRunActiveRef = useRef(false) const webhookSingleRunAbortRef = useRef<AbortController | null>(null) @@ -293,34 +310,50 @@ const useOneStepRun = <T>({ const isPluginTriggerNode = data.type === BlockEnum.TriggerPlugin const isTriggerNode = isWebhookTriggerNode || isPluginTriggerNode || isScheduleTriggerNode - const setRunResult = useCallback(async (data: NodeRunResult | null) => { - const isPaused = isPausedRef.current - - // The backend don't support pause the single run, so the frontend handle the pause state. - if (isPaused) - return + const setRunResult = useCallback( + async (data: NodeRunResult | null) => { + const isPaused = isPausedRef.current - const canRunLastRun = !isRunAfterSingleRun || runningStatus === NodeRunningStatus.Succeeded - if (!canRunLastRun) { - doSetRunResult(data) - return - } + // The backend don't support pause the single run, so the frontend handle the pause state. + if (isPaused) return - // run fail may also update the inspect vars when the node set the error default output. - const vars = await fetchNodeInspectVars(flowType, flowId!, id) - const { getNodes } = store.getState() - const nodes = getNodes() - appendNodeInspectVars(id, vars, nodes) - updateNodeInspectRunningState(id, false) - if (data?.status === NodeRunningStatus.Succeeded) { - invalidLastRun() - if (isStartNode || isTriggerNode) - invalidateSysVarValues() - invalidateConversationVarValues() // loop, iteration, variable assigner node can update the conversation variables, but to simple the logic(some nodes may also can update in the future), all nodes refresh. - } - }, [isRunAfterSingleRun, runningStatus, flowType, flowId, id, store, appendNodeInspectVars, updateNodeInspectRunningState, invalidLastRun, isStartNode, isTriggerNode, invalidateSysVarValues, invalidateConversationVarValues]) + const canRunLastRun = !isRunAfterSingleRun || runningStatus === NodeRunningStatus.Succeeded + if (!canRunLastRun) { + doSetRunResult(data) + return + } - const { handleNodeDataUpdate }: { handleNodeDataUpdate: (data: any) => void } = useNodeDataUpdate() + // run fail may also update the inspect vars when the node set the error default output. + const vars = await fetchNodeInspectVars(flowType, flowId!, id) + const { getNodes } = store.getState() + const nodes = getNodes() + appendNodeInspectVars(id, vars, nodes) + updateNodeInspectRunningState(id, false) + if (data?.status === NodeRunningStatus.Succeeded) { + invalidLastRun() + if (isStartNode || isTriggerNode) invalidateSysVarValues() + invalidateConversationVarValues() // loop, iteration, variable assigner node can update the conversation variables, but to simple the logic(some nodes may also can update in the future), all nodes refresh. + } + }, + [ + isRunAfterSingleRun, + runningStatus, + flowType, + flowId, + id, + store, + appendNodeInspectVars, + updateNodeInspectRunningState, + invalidLastRun, + isStartNode, + isTriggerNode, + invalidateSysVarValues, + invalidateConversationVarValues, + ], + ) + + const { handleNodeDataUpdate }: { handleNodeDataUpdate: (data: any) => void } = + useNodeDataUpdate() const setNodeRunning = () => { handleNodeDataUpdate({ id, @@ -334,8 +367,7 @@ const useOneStepRun = <T>({ const cancelWebhookSingleRun = useCallback(() => { webhookSingleRunActiveRef.current = false webhookSingleRunTokenRef.current += 1 - if (webhookSingleRunAbortRef.current) - webhookSingleRunAbortRef.current.abort() + if (webhookSingleRunAbortRef.current) webhookSingleRunAbortRef.current.abort() webhookSingleRunAbortRef.current = null if (webhookSingleRunTimeoutRef.current !== undefined) { window.clearTimeout(webhookSingleRunTimeoutRef.current) @@ -350,8 +382,7 @@ const useOneStepRun = <T>({ const cancelPluginSingleRun = useCallback(() => { pluginSingleRunActiveRef.current = false pluginSingleRunTokenRef.current += 1 - if (pluginSingleRunAbortRef.current) - pluginSingleRunAbortRef.current.abort() + if (pluginSingleRunAbortRef.current) pluginSingleRunAbortRef.current.abort() pluginSingleRunAbortRef.current = null if (pluginSingleRunTimeoutRef.current !== undefined) { window.clearTimeout(pluginSingleRunTimeoutRef.current) @@ -364,8 +395,7 @@ const useOneStepRun = <T>({ }, []) const startTriggerListening = useCallback(() => { - if (!isTriggerNode) - return + if (!isTriggerNode) return setIsListening(true) setShowVariableInspectPanel(true) @@ -386,8 +416,7 @@ const useOneStepRun = <T>({ ]) const stopTriggerListening = useCallback(() => { - if (!isTriggerNode) - return + if (!isTriggerNode) return setIsListening(false) setListeningTriggerType(null) @@ -412,13 +441,14 @@ const useOneStepRun = <T>({ }) if (!response) { - const message = t($ => $['common.scheduleTriggerRunFailed'], { ns: 'workflow' }) + const message = t(($) => $['common.scheduleTriggerRunFailed'], { ns: 'workflow' }) toast.error(message) throw new Error(message) } if (response?.status === 'error') { - const message = response?.message || t($ => $['common.scheduleTriggerRunFailed'], { ns: 'workflow' }) + const message = + response?.message || t(($) => $['common.scheduleTriggerRunFailed'], { ns: 'workflow' }) toast.error(message) throw new Error(message) } @@ -433,8 +463,7 @@ const useOneStepRun = <T>({ }) return response as NodeRunResult - } - catch (error) { + } catch (error) { console.error('handleRun: schedule trigger single run error', error) handleNodeDataUpdate({ id, @@ -444,7 +473,7 @@ const useOneStepRun = <T>({ _singleRunningStatus: NodeRunningStatus.Failed, }, }) - toast.error(t($ => $['common.scheduleTriggerRunFailed'], { ns: 'workflow' })) + toast.error(t(($) => $['common.scheduleTriggerRunFailed'], { ns: 'workflow' })) throw error } }, [flowId, id, handleNodeDataUpdate, data, t]) @@ -469,7 +498,8 @@ const useOneStepRun = <T>({ return null if (!response) { - const message = response?.message || t($ => $['common.webhookDebugFailed'], { ns: 'workflow' }) + const message = + response?.message || t(($) => $['common.webhookDebugFailed'], { ns: 'workflow' }) toast.error(message) cancelWebhookSingleRun() throw new Error(message) @@ -485,10 +515,14 @@ const useOneStepRun = <T>({ const timeoutId = window.setTimeout(resolve, delay) webhookSingleRunTimeoutRef.current = timeoutId webhookSingleRunDelayResolveRef.current = resolve - controller.signal.addEventListener('abort', () => { - window.clearTimeout(timeoutId) - resolve() - }, { once: true }) + controller.signal.addEventListener( + 'abort', + () => { + window.clearTimeout(timeoutId) + resolve() + }, + { once: true }, + ) }) webhookSingleRunTimeoutRef.current = undefined @@ -497,7 +531,8 @@ const useOneStepRun = <T>({ } if (response?.status === 'error') { - const message = response.message || t($ => $['common.webhookDebugFailed'], { ns: 'workflow' }) + const message = + response.message || t(($) => $['common.webhookDebugFailed'], { ns: 'workflow' }) toast.error(message) cancelWebhookSingleRun() throw new Error(message) @@ -514,20 +549,19 @@ const useOneStepRun = <T>({ cancelWebhookSingleRun() return response - } - catch (error) { - if (controller.signal.aborted && (!webhookSingleRunActiveRef.current || token !== webhookSingleRunTokenRef.current)) - return null - if (controller.signal.aborted) + } catch (error) { + if ( + controller.signal.aborted && + (!webhookSingleRunActiveRef.current || token !== webhookSingleRunTokenRef.current) + ) return null + if (controller.signal.aborted) return null - toast.error(t($ => $['common.webhookDebugRequestFailed'], { ns: 'workflow' })) + toast.error(t(($) => $['common.webhookDebugRequestFailed'], { ns: 'workflow' })) cancelWebhookSingleRun() - if (error instanceof Error) - throw error + if (error instanceof Error) throw error throw new Error(String(error)) - } - finally { + } finally { webhookSingleRunAbortRef.current = null } } @@ -549,24 +583,25 @@ const useOneStepRun = <T>({ const response: any = await post(urlPath, { body: JSON.stringify({}), signal: controller.signal, - }).catch(async (error: Response) => { - const data = await error.clone().json() as Record<string, any> - const { error: respError, status } = data || {} - requestError = { - message: respError, - status, - } - return null - }).finally(() => { - pluginSingleRunAbortRef.current = null }) + .catch(async (error: Response) => { + const data = (await error.clone().json()) as Record<string, any> + const { error: respError, status } = data || {} + requestError = { + message: respError, + status, + } + return null + }) + .finally(() => { + pluginSingleRunAbortRef.current = null + }) if (!pluginSingleRunActiveRef.current || token !== pluginSingleRunTokenRef.current) return null if (requestError) { - if (controller.signal.aborted) - return null + if (controller.signal.aborted) return null toast.error(requestError.message) cancelPluginSingleRun() @@ -589,10 +624,14 @@ const useOneStepRun = <T>({ const timeoutId = window.setTimeout(resolve, delay) pluginSingleRunTimeoutRef.current = timeoutId pluginSingleRunDelayResolveRef.current = resolve - controller.signal.addEventListener('abort', () => { - window.clearTimeout(timeoutId) - resolve() - }, { once: true }) + controller.signal.addEventListener( + 'abort', + () => { + window.clearTimeout(timeoutId) + resolve() + }, + { once: true }, + ) }) pluginSingleRunTimeoutRef.current = undefined @@ -624,8 +663,7 @@ const useOneStepRun = <T>({ }, [flowId, id, data, handleNodeDataUpdate, cancelPluginSingleRun]) const checkValidWrap = () => { - if (!checkValid) - return { isValid: true, errorMessage: '' } + if (!checkValid) return { isValid: true, errorMessage: '' } const res = checkValid(data, t, moreDataForCheckValid) if (!res.isValid) { handleNodeDataUpdate({ @@ -635,8 +673,7 @@ const useOneStepRun = <T>({ _isSingleRun: false, }, }) - if (res.errorMessage) - toast.error(res.errorMessage) + if (res.errorMessage) toast.error(res.errorMessage) } return res } @@ -679,20 +716,17 @@ const useOneStepRun = <T>({ }, }) } - const isCompleted = runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed + const isCompleted = + runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed const handleRun = async (submitData: Record<string, any>) => { - if (isWebhookTriggerNode) - cancelWebhookSingleRun() - if (isPluginTriggerNode) - cancelPluginSingleRun() + if (isWebhookTriggerNode) cancelWebhookSingleRun() + if (isPluginTriggerNode) cancelPluginSingleRun() updateNodeInspectRunningState(id, true) - if (isTriggerNode) - startTriggerListening() - else - stopTriggerListening() + if (isTriggerNode) startTriggerListening() + else stopTriggerListening() handleNodeDataUpdate({ id, @@ -710,8 +744,7 @@ const useOneStepRun = <T>({ if (!isIteration && !isLoop) { if (isScheduleTriggerNode) { res = await runScheduleSingleRun() - } - else if (isWebhookTriggerNode) { + } else if (isWebhookTriggerNode) { res = await runWebhookSingleRun() if (!res) { if (webhookSingleRunActiveRef.current) { @@ -726,8 +759,7 @@ const useOneStepRun = <T>({ } return false } - } - else if (isPluginTriggerNode) { + } else if (isPluginTriggerNode) { res = await runPluginSingleRun() if (!res) { if (pluginSingleRunActiveRef.current) { @@ -742,26 +774,22 @@ const useOneStepRun = <T>({ } return false } - } - else { + } else { const isStartNode = data.type === BlockEnum.Start const postData: Record<string, any> = {} if (isStartNode) { const { '#sys.query#': query, '#sys.files#': files, ...inputs } = submitData - if (isChatMode) - postData.conversation_id = '' + if (isChatMode) postData.conversation_id = '' postData.inputs = inputs postData.query = query postData.files = files || [] - } - else { + } else { postData.inputs = submitData } - res = await singleNodeRun(flowType, flowId!, id, postData) as any + res = (await singleNodeRun(flowType, flowId!, id, postData)) as any } - } - else if (isIteration) { + } else if (isIteration) { setIterationRunResult([]) let _iterationResult: NodeTracing[] = [] let _runResult: any = null @@ -771,8 +799,7 @@ const useOneStepRun = <T>({ { onWorkflowStarted: noop, onWorkflowFinished: (params) => { - if (isPausedRef.current) - return + if (isPausedRef.current) return handleNodeDataUpdate({ id, data: { @@ -804,7 +831,9 @@ const useOneStepRun = <T>({ _runResult = params.data setRunResult(_runResult) const iterationRunResult = _iterationResult - const currentIndex = iterationRunResult.findIndex(trace => trace.id === params.data.id) + const currentIndex = iterationRunResult.findIndex( + (trace) => trace.id === params.data.id, + ) const newIterationRunResult = produce(iterationRunResult, (draft) => { if (currentIndex > -1) { draft[currentIndex] = { @@ -830,7 +859,7 @@ const useOneStepRun = <T>({ const iterationRunResult = _iterationResult const { data } = params - const currentIndex = iterationRunResult.findIndex(trace => trace.id === data.id) + const currentIndex = iterationRunResult.findIndex((trace) => trace.id === data.id) const newIterationRunResult = produce(iterationRunResult, (draft) => { if (currentIndex > -1) { draft[currentIndex] = { @@ -850,8 +879,7 @@ const useOneStepRun = <T>({ setIterationRunResult(newIterationRunResult) }, onError: () => { - if (isPausedRef.current) - return + if (isPausedRef.current) return handleNodeDataUpdate({ id, data: { @@ -863,8 +891,7 @@ const useOneStepRun = <T>({ }, }, ) - } - else if (isLoop) { + } else if (isLoop) { setLoopRunResult([]) let _loopResult: NodeTracing[] = [] let _runResult: any = null @@ -874,8 +901,7 @@ const useOneStepRun = <T>({ { onWorkflowStarted: noop, onWorkflowFinished: (params) => { - if (isPausedRef.current) - return + if (isPausedRef.current) return handleNodeDataUpdate({ id, data: { @@ -900,15 +926,14 @@ const useOneStepRun = <T>({ }, onLoopNext: () => { // loop next trigger time is triggered one more time than loopTimes - if (_loopResult.length >= loopTimes!) - return _loopResult.length >= loopTimes! + if (_loopResult.length >= loopTimes!) return _loopResult.length >= loopTimes! }, onLoopFinish: (params) => { _runResult = params.data setRunResult(_runResult) const loopRunResult = _loopResult - const currentIndex = loopRunResult.findIndex(trace => trace.id === params.data.id) + const currentIndex = loopRunResult.findIndex((trace) => trace.id === params.data.id) const newLoopRunResult = produce(loopRunResult, (draft) => { if (currentIndex > -1) { draft[currentIndex] = { @@ -934,7 +959,7 @@ const useOneStepRun = <T>({ const loopRunResult = _loopResult const { data } = params - const currentIndex = loopRunResult.findIndex(trace => trace.id === data.id) + const currentIndex = loopRunResult.findIndex((trace) => trace.id === data.id) const newLoopRunResult = produce(loopRunResult, (draft) => { if (currentIndex > -1) { draft[currentIndex] = { @@ -954,8 +979,7 @@ const useOneStepRun = <T>({ setLoopRunResult(newLoopRunResult) }, onError: () => { - if (isPausedRef.current) - return + if (isPausedRef.current) return handleNodeDataUpdate({ id, data: { @@ -964,21 +988,23 @@ const useOneStepRun = <T>({ _singleRunningStatus: NodeRunningStatus.Failed, }, }) - trackEvent('workflow_run_failed', { workflow_id: flowId, node_id: id, reason: res.error, node_type: data?.type }) + trackEvent('workflow_run_failed', { + workflow_id: flowId, + node_id: id, + reason: res.error, + node_type: data?.type, + }) }, }, ) } - if (res && res.error) - throw new Error(res.error) - } - catch (e: any) { + if (res && res.error) throw new Error(res.error) + } catch (e: any) { console.error(e) hasError = true invalidLastRun() if (!isIteration && !isLoop) { - if (isPausedRef.current) - return + if (isPausedRef.current) return handleNodeDataUpdate({ id, data: { @@ -989,16 +1015,11 @@ const useOneStepRun = <T>({ }) return false } - } - finally { - if (isWebhookTriggerNode) - cancelWebhookSingleRun() - if (isPluginTriggerNode) - cancelPluginSingleRun() - if (isTriggerNode) - stopTriggerListening() - if (!isIteration && !isLoop) - updateNodeInspectRunningState(id, false) + } finally { + if (isWebhookTriggerNode) cancelWebhookSingleRun() + if (isPluginTriggerNode) cancelPluginSingleRun() + if (isTriggerNode) stopTriggerListening() + if (!isIteration && !isLoop) updateNodeInspectRunningState(id, false) if (!isPausedRef.current && !isIteration && !isLoop && res) { setRunResult({ ...res, @@ -1007,12 +1028,10 @@ const useOneStepRun = <T>({ }) } } - if (isPausedRef.current) - return + if (isPausedRef.current) return if (!isIteration && !isLoop && !hasError) { - if (isPausedRef.current) - return + if (isPausedRef.current) return handleNodeDataUpdate({ id, data: { @@ -1026,13 +1045,12 @@ const useOneStepRun = <T>({ const handleStop = useCallback(() => { if (isTriggerNode) { - const isTriggerActive = runningStatus === NodeRunningStatus.Listening - || webhookSingleRunActiveRef.current - || pluginSingleRunActiveRef.current - if (!isTriggerActive) - return - } - else if (runningStatus !== NodeRunningStatus.Running) { + const isTriggerActive = + runningStatus === NodeRunningStatus.Listening || + webhookSingleRunActiveRef.current || + pluginSingleRunActiveRef.current + if (!isTriggerActive) return + } else if (runningStatus !== NodeRunningStatus.Running) { return } @@ -1054,13 +1072,19 @@ const useOneStepRun = <T>({ deleteNodeInspectVars, } = workflowStore.getState() if (workflowRunningData) { - setWorkflowRunningData(produce(workflowRunningData, (draft) => { - draft.result.status = WorkflowRunningStatus.Stopped - })) + setWorkflowRunningData( + produce(workflowRunningData, (draft) => { + draft.result.status = WorkflowRunningStatus.Stopped + }), + ) } - const inspectNode = nodesWithInspectVars.find(node => node.nodeId === id) - if (inspectNode && !inspectNode.isValueFetched && (!inspectNode.vars || inspectNode.vars.length === 0)) + const inspectNode = nodesWithInspectVars.find((node) => node.nodeId === id) + if ( + inspectNode && + !inspectNode.isValueFetched && + (!inspectNode.vars || inspectNode.vars.length === 0) + ) deleteNodeInspectVars(id) }, [ isTriggerNode, @@ -1075,38 +1099,40 @@ const useOneStepRun = <T>({ ]) const toVarInputs = (variables: Variable[]): InputVar[] => { - if (!variables) - return [] - - const varInputs = variables.filter(item => !isENV(item.value_selector)).map((item) => { - const originalVar = getVar(item.value_selector) - if (!originalVar) { - const fallbackType = item.value_type - ? varTypeToInputVarType(item.value_type, { - isSelect: !!item.options?.length, - isParagraph: !!item.isParagraph, - }) - : InputVarType.textInput + if (!variables) return [] + + const varInputs = variables + .filter((item) => !isENV(item.value_selector)) + .map((item) => { + const originalVar = getVar(item.value_selector) + if (!originalVar) { + const fallbackType = item.value_type + ? varTypeToInputVarType(item.value_type, { + isSelect: !!item.options?.length, + isParagraph: !!item.isParagraph, + }) + : InputVarType.textInput + return { + label: item.label || item.variable, + variable: item.variable, + type: fallbackType, + required: true, + value_selector: item.value_selector, + options: item.options, + } + } return { - label: item.label || item.variable, + label: + (typeof item.label === 'object' ? item.label.variable : item.label) || item.variable, variable: item.variable, - type: fallbackType, - required: true, - value_selector: item.value_selector, - options: item.options, + type: varTypeToInputVarType(originalVar.type, { + isSelect: !!originalVar.isSelect, + isParagraph: !!originalVar.isParagraph, + }), + required: item.required !== false, + options: originalVar.options, } - } - return { - label: (typeof item.label === 'object' ? item.label.variable : item.label) || item.variable, - variable: item.variable, - type: varTypeToInputVarType(originalVar.type, { - isSelect: !!originalVar.isSelect, - isParagraph: !!originalVar.isParagraph, - }), - required: item.required !== false, - options: originalVar.options, - } - }) + }) return varInputs } @@ -1117,7 +1143,7 @@ const useOneStepRun = <T>({ valueSelectors.push(...doGetInputVars(text)) }) - const variables = unionBy(valueSelectors, item => item.join('.')).map((item) => { + const variables = unionBy(valueSelectors, (item) => item.join('.')).map((item) => { const varInfo = getNodeInfoById(availableNodesIncludeParent, item[0]!)?.data return { @@ -1137,14 +1163,15 @@ const useOneStepRun = <T>({ } const varSelectorsToVarInputs = (valueSelectors: ValueSelector[] | string[]): InputVar[] => { - return valueSelectors.filter(item => !!item).map((item) => { - return getInputVars([`{{#${typeof item === 'string' ? item : item.join('.')}#}}`])[0]! - }) + return valueSelectors + .filter((item) => !!item) + .map((item) => { + return getInputVars([`{{#${typeof item === 'string' ? item : item.join('.')}#}}`])[0]! + }) } eventEmitter?.useSubscription((v: any) => { - if (v.type === EVENT_WORKFLOW_STOP) - handleStop() + if (v.type === EVENT_WORKFLOW_STOP) handleStop() }) return { diff --git a/web/app/components/workflow/nodes/_base/hooks/use-output-var-list.ts b/web/app/components/workflow/nodes/_base/hooks/use-output-var-list.ts index 188084345e3b4f..589448e24faff8 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-output-var-list.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-output-var-list.ts @@ -1,22 +1,12 @@ -import type { - CodeNodeType, - OutputVar, -} from '../../code/types' -import type { - ValueSelector, -} from '@/app/components/workflow/types' +import type { CodeNodeType, OutputVar } from '../../code/types' +import type { ValueSelector } from '@/app/components/workflow/types' import { useBoolean, useDebounceFn } from 'ahooks' import { produce } from 'immer' import { useCallback, useRef, useState } from 'react' -import { - useWorkflow, -} from '@/app/components/workflow/hooks' +import { useWorkflow } from '@/app/components/workflow/hooks' import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' import { getDefaultValue } from '@/app/components/workflow/nodes/_base/components/error-handle/utils' -import { - BlockEnum, - VarType, -} from '@/app/components/workflow/types' +import { BlockEnum, VarType } from '@/app/components/workflow/types' import useInspectVarsCrud from '../../../hooks/use-inspect-vars-crud' type Params<T> = { @@ -35,20 +25,14 @@ function useOutputVarList<T>({ outputKeyOrders = [], onOutputKeyOrdersChange, }: Params<T>) { - const { - renameInspectVarName, - deleteInspectVar, - nodesWithInspectVars, - } = useInspectVarsCrud() + const { renameInspectVarName, deleteInspectVar, nodesWithInspectVars } = useInspectVarsCrud() const { handleOutVarRenameChange, isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow() // record the first old name value const oldNameRecord = useRef<Record<string, string>>({}) - const { - run: renameInspectNameWithDebounce, - } = useDebounceFn( + const { run: renameInspectNameWithDebounce } = useDebounceFn( (id: string, newName: string) => { const oldName = oldNameRecord.current[id] renameInspectVarName(id, oldName!, newName) @@ -56,41 +40,58 @@ function useOutputVarList<T>({ }, { wait: 500 }, ) - const handleVarsChange = useCallback((newVars: OutputVar, changedIndex?: number, newKey?: string) => { - const newInputs = produce(inputs, (draft: any) => { - draft[varKey] = newVars - - if ((inputs as CodeNodeType).type === BlockEnum.Code && (inputs as CodeNodeType).error_strategy === ErrorHandleTypeEnum.defaultValue && varKey === 'outputs') - draft.default_value = getDefaultValue(draft as any) - }) - setInputs(newInputs) + const handleVarsChange = useCallback( + (newVars: OutputVar, changedIndex?: number, newKey?: string) => { + const newInputs = produce(inputs, (draft: any) => { + draft[varKey] = newVars - if (changedIndex !== undefined) { - const newOutputKeyOrders = produce(outputKeyOrders, (draft) => { - draft[changedIndex] = newKey! + if ( + (inputs as CodeNodeType).type === BlockEnum.Code && + (inputs as CodeNodeType).error_strategy === ErrorHandleTypeEnum.defaultValue && + varKey === 'outputs' + ) + draft.default_value = getDefaultValue(draft as any) }) - onOutputKeyOrdersChange(newOutputKeyOrders) - } + setInputs(newInputs) - if (newKey) { - handleOutVarRenameChange(id, [id, outputKeyOrders[changedIndex!]!], [id, newKey]) - if (!(id in oldNameRecord.current)) - oldNameRecord.current[id] = outputKeyOrders[changedIndex!]! - renameInspectNameWithDebounce(id, newKey) - } - else if (changedIndex === undefined) { - const varId = nodesWithInspectVars.find(node => node.nodeId === id)?.vars.find((varItem) => { - return varItem.name === Object.keys(newVars)[0] - })?.id - if (varId) - deleteInspectVar(id, varId) - } - }, [inputs, setInputs, varKey, outputKeyOrders, onOutputKeyOrdersChange, handleOutVarRenameChange, id, renameInspectNameWithDebounce, nodesWithInspectVars, deleteInspectVar]) + if (changedIndex !== undefined) { + const newOutputKeyOrders = produce(outputKeyOrders, (draft) => { + draft[changedIndex] = newKey! + }) + onOutputKeyOrdersChange(newOutputKeyOrders) + } + + if (newKey) { + handleOutVarRenameChange(id, [id, outputKeyOrders[changedIndex!]!], [id, newKey]) + if (!(id in oldNameRecord.current)) + oldNameRecord.current[id] = outputKeyOrders[changedIndex!]! + renameInspectNameWithDebounce(id, newKey) + } else if (changedIndex === undefined) { + const varId = nodesWithInspectVars + .find((node) => node.nodeId === id) + ?.vars.find((varItem) => { + return varItem.name === Object.keys(newVars)[0] + })?.id + if (varId) deleteInspectVar(id, varId) + } + }, + [ + inputs, + setInputs, + varKey, + outputKeyOrders, + onOutputKeyOrdersChange, + handleOutVarRenameChange, + id, + renameInspectNameWithDebounce, + nodesWithInspectVars, + deleteInspectVar, + ], + ) const generateNewKey = useCallback(() => { let keyIndex = Object.keys((inputs as any)[varKey]).length + 1 - while (((inputs as any)[varKey])[`var_${keyIndex}`]) - keyIndex++ + while ((inputs as any)[varKey][`var_${keyIndex}`]) keyIndex++ return `var_${keyIndex}` }, [inputs, varKey]) const handleAddVariable = useCallback(() => { @@ -104,55 +105,85 @@ function useOutputVarList<T>({ }, } - if ((inputs as CodeNodeType).type === BlockEnum.Code && (inputs as CodeNodeType).error_strategy === ErrorHandleTypeEnum.defaultValue && varKey === 'outputs') + if ( + (inputs as CodeNodeType).type === BlockEnum.Code && + (inputs as CodeNodeType).error_strategy === ErrorHandleTypeEnum.defaultValue && + varKey === 'outputs' + ) draft.default_value = getDefaultValue(draft as any) }) setInputs(newInputs) onOutputKeyOrdersChange([...outputKeyOrders, newKey]) }, [generateNewKey, inputs, setInputs, onOutputKeyOrdersChange, outputKeyOrders, varKey]) - const [isShowRemoveVarConfirm, { - setTrue: showRemoveVarConfirm, - setFalse: hideRemoveVarConfirm, - }] = useBoolean(false) + const [ + isShowRemoveVarConfirm, + { setTrue: showRemoveVarConfirm, setFalse: hideRemoveVarConfirm }, + ] = useBoolean(false) const [removedVar, setRemovedVar] = useState<ValueSelector>([]) const removeVarInNode = useCallback(() => { - const varId = nodesWithInspectVars.find(node => node.nodeId === id)?.vars.find((varItem) => { - return varItem.name === removedVar[1] - })?.id - if (varId) - deleteInspectVar(id, varId) + const varId = nodesWithInspectVars + .find((node) => node.nodeId === id) + ?.vars.find((varItem) => { + return varItem.name === removedVar[1] + })?.id + if (varId) deleteInspectVar(id, varId) removeUsedVarInNodes(removedVar) hideRemoveVarConfirm() - }, [deleteInspectVar, hideRemoveVarConfirm, id, nodesWithInspectVars, removeUsedVarInNodes, removedVar]) - const handleRemoveVariable = useCallback((index: number) => { - const key = outputKeyOrders[index]! + }, [ + deleteInspectVar, + hideRemoveVarConfirm, + id, + nodesWithInspectVars, + removeUsedVarInNodes, + removedVar, + ]) + const handleRemoveVariable = useCallback( + (index: number) => { + const key = outputKeyOrders[index]! - if (isVarUsedInNodes([id, key])) { - showRemoveVarConfirm() - setRemovedVar([id, key]) - return - } + if (isVarUsedInNodes([id, key])) { + showRemoveVarConfirm() + setRemovedVar([id, key]) + return + } - const newOutputKeyOrders = outputKeyOrders.filter((_, i) => i !== index) - const newInputs = produce(inputs, (draft: any) => { - // Only delete from outputs when no remaining entry shares this name - if (!newOutputKeyOrders.includes(key!)) - delete draft[varKey][key!] + const newOutputKeyOrders = outputKeyOrders.filter((_, i) => i !== index) + const newInputs = produce(inputs, (draft: any) => { + // Only delete from outputs when no remaining entry shares this name + if (!newOutputKeyOrders.includes(key!)) delete draft[varKey][key!] - if ((inputs as CodeNodeType).type === BlockEnum.Code && (inputs as CodeNodeType).error_strategy === ErrorHandleTypeEnum.defaultValue && varKey === 'outputs') - draft.default_value = getDefaultValue(draft as any) - }) - setInputs(newInputs) - onOutputKeyOrdersChange(newOutputKeyOrders) - if (!newOutputKeyOrders.includes(key!)) { - const varId = nodesWithInspectVars.find(node => node.nodeId === id)?.vars.find((varItem) => { - return varItem.name === key - })?.id - if (varId) - deleteInspectVar(id, varId) - } - }, [outputKeyOrders, isVarUsedInNodes, id, inputs, setInputs, onOutputKeyOrdersChange, nodesWithInspectVars, deleteInspectVar, showRemoveVarConfirm, varKey]) + if ( + (inputs as CodeNodeType).type === BlockEnum.Code && + (inputs as CodeNodeType).error_strategy === ErrorHandleTypeEnum.defaultValue && + varKey === 'outputs' + ) + draft.default_value = getDefaultValue(draft as any) + }) + setInputs(newInputs) + onOutputKeyOrdersChange(newOutputKeyOrders) + if (!newOutputKeyOrders.includes(key!)) { + const varId = nodesWithInspectVars + .find((node) => node.nodeId === id) + ?.vars.find((varItem) => { + return varItem.name === key + })?.id + if (varId) deleteInspectVar(id, varId) + } + }, + [ + outputKeyOrders, + isVarUsedInNodes, + id, + inputs, + setInputs, + onOutputKeyOrdersChange, + nodesWithInspectVars, + deleteInspectVar, + showRemoveVarConfirm, + varKey, + ], + ) return { handleVarsChange, diff --git a/web/app/components/workflow/nodes/_base/hooks/use-resize-panel.ts b/web/app/components/workflow/nodes/_base/hooks/use-resize-panel.ts index 3baf6d05a0405a..b8654805fb449c 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-resize-panel.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-resize-panel.ts @@ -1,13 +1,16 @@ -import { - useCallback, - useEffect, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' type UseResizePanelParams = { direction?: 'horizontal' | 'vertical' | 'both' - triggerDirection?: 'top' | 'right' | 'bottom' | 'left' | 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' + triggerDirection?: + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'top-right' + | 'top-left' + | 'bottom-right' + | 'bottom-left' minWidth?: number maxWidth?: number minHeight?: number @@ -33,66 +36,76 @@ export const useResizePanel = (params?: UseResizePanelParams) => { const initContainerWidthRef = useRef(0) const initContainerHeightRef = useRef(0) const isResizingRef = useRef(false) - const [prevUserSelectStyle, setPrevUserSelectStyle] = useState(() => getComputedStyle(document.body).userSelect) + const [prevUserSelectStyle, setPrevUserSelectStyle] = useState( + () => getComputedStyle(document.body).userSelect, + ) - const handleStartResize = useCallback((e: MouseEvent) => { - initXRef.current = e.clientX - initYRef.current = e.clientY - initContainerWidthRef.current = containerRef.current?.offsetWidth || minWidth - initContainerHeightRef.current = containerRef.current?.offsetHeight || minHeight - isResizingRef.current = true - setPrevUserSelectStyle(getComputedStyle(document.body).userSelect) - document.body.style.userSelect = 'none' - }, [minWidth, minHeight]) + const handleStartResize = useCallback( + (e: MouseEvent) => { + initXRef.current = e.clientX + initYRef.current = e.clientY + initContainerWidthRef.current = containerRef.current?.offsetWidth || minWidth + initContainerHeightRef.current = containerRef.current?.offsetHeight || minHeight + isResizingRef.current = true + setPrevUserSelectStyle(getComputedStyle(document.body).userSelect) + document.body.style.userSelect = 'none' + }, + [minWidth, minHeight], + ) - const handleResize = useCallback((e: MouseEvent) => { - if (!isResizingRef.current) - return + const handleResize = useCallback( + (e: MouseEvent) => { + if (!isResizingRef.current) return - if (!containerRef.current) - return + if (!containerRef.current) return - if (direction === 'horizontal' || direction === 'both') { - const offsetX = e.clientX - initXRef.current - let width = 0 - if (triggerDirection === 'left' || triggerDirection === 'top-left' || triggerDirection === 'bottom-left') - width = initContainerWidthRef.current - offsetX - else if (triggerDirection === 'right' || triggerDirection === 'top-right' || triggerDirection === 'bottom-right') - width = initContainerWidthRef.current + offsetX + if (direction === 'horizontal' || direction === 'both') { + const offsetX = e.clientX - initXRef.current + let width = 0 + if ( + triggerDirection === 'left' || + triggerDirection === 'top-left' || + triggerDirection === 'bottom-left' + ) + width = initContainerWidthRef.current - offsetX + else if ( + triggerDirection === 'right' || + triggerDirection === 'top-right' || + triggerDirection === 'bottom-right' + ) + width = initContainerWidthRef.current + offsetX - if (width < minWidth) - width = minWidth - if (width > maxWidth) - width = maxWidth - containerRef.current.style.width = `${width}px` - onResize?.(width, 0) - } + if (width < minWidth) width = minWidth + if (width > maxWidth) width = maxWidth + containerRef.current.style.width = `${width}px` + onResize?.(width, 0) + } - if (direction === 'vertical' || direction === 'both') { - const offsetY = e.clientY - initYRef.current - let height = 0 - if (triggerDirection === 'top' || triggerDirection === 'top-left' || triggerDirection === 'top-right') - height = initContainerHeightRef.current - offsetY - else if (triggerDirection === 'bottom' || triggerDirection === 'bottom-left' || triggerDirection === 'bottom-right') - height = initContainerHeightRef.current + offsetY + if (direction === 'vertical' || direction === 'both') { + const offsetY = e.clientY - initYRef.current + let height = 0 + if ( + triggerDirection === 'top' || + triggerDirection === 'top-left' || + triggerDirection === 'top-right' + ) + height = initContainerHeightRef.current - offsetY + else if ( + triggerDirection === 'bottom' || + triggerDirection === 'bottom-left' || + triggerDirection === 'bottom-right' + ) + height = initContainerHeightRef.current + offsetY - if (height < minHeight) - height = minHeight - if (height > maxHeight) - height = maxHeight + if (height < minHeight) height = minHeight + if (height > maxHeight) height = maxHeight - containerRef.current.style.height = `${height}px` - onResize?.(0, height) - } - }, [ - direction, - triggerDirection, - minWidth, - maxWidth, - minHeight, - maxHeight, - onResize, - ]) + containerRef.current.style.height = `${height}px` + onResize?.(0, height) + } + }, + [direction, triggerDirection, minWidth, maxWidth, minHeight, maxHeight, onResize], + ) const handleStopResize = useCallback(() => { isResizingRef.current = false @@ -108,8 +121,7 @@ export const useResizePanel = (params?: UseResizePanelParams) => { document.addEventListener('mousemove', handleResize) document.addEventListener('mouseup', handleStopResize) return () => { - if (element) - element.removeEventListener('mousedown', handleStartResize) + if (element) element.removeEventListener('mousedown', handleStartResize) document.removeEventListener('mousemove', handleResize) document.removeEventListener('mouseup', handleStopResize) } diff --git a/web/app/components/workflow/nodes/_base/hooks/use-toggle-expend.ts b/web/app/components/workflow/nodes/_base/hooks/use-toggle-expend.ts index 1afeb8db12ffd0..9eb3c2afde887c 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-toggle-expend.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-toggle-expend.ts @@ -30,19 +30,16 @@ const useToggleExpend = ({ ref, hasFooter = true, isInNode }: Params) => { const [wrapHeight, setWrapHeight] = useState<number | undefined>(undefined) useLayoutEffect(() => { - if (!ref?.current) - return + if (!ref?.current) return setWrapHeight(ref.current.clientHeight) }, [isExpand, ref]) const chromeHeight = hasFooter ? CHROME_HEIGHT_WITH_FOOTER : CHROME_HEIGHT_WITHOUT_FOOTER - const editorExpandHeight = isExpand && wrapHeight !== undefined - ? Math.max(0, wrapHeight - chromeHeight) - : 0 + const editorExpandHeight = + isExpand && wrapHeight !== undefined ? Math.max(0, wrapHeight - chromeHeight) : 0 const wrapClassName = (() => { - if (!isExpand) - return '' + if (!isExpand) return '' if (isInNode) return 'fixed z-10 right-[9px] top-[166px] bottom-[8px] p-4 bg-components-panel-bg rounded-xl' @@ -58,7 +55,8 @@ const useToggleExpend = ({ ref, hasFooter = true, isInNode }: Params) => { const wrapStyle = isExpand ? { - boxShadow: '0px 0px 12px -4px rgba(16, 24, 40, 0.05), 0px -3px 6px -2px rgba(16, 24, 40, 0.03)', + boxShadow: + '0px 0px 12px -4px rgba(16, 24, 40, 0.05), 0px -3px 6px -2px rgba(16, 24, 40, 0.03)', } : {} diff --git a/web/app/components/workflow/nodes/_base/hooks/use-var-list.ts b/web/app/components/workflow/nodes/_base/hooks/use-var-list.ts index 9b3bbbb9aa0565..6aff7131d72d8e 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-var-list.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-var-list.ts @@ -7,17 +7,16 @@ type Params<T> = { setInputs: (newInputs: T) => void varKey?: string } -function useVarList<T>({ - inputs, - setInputs, - varKey = 'variables', -}: Params<T>) { - const handleVarListChange = useCallback((newList: Variable[] | string) => { - const newInputs = produce(inputs, (draft: any) => { - draft[varKey] = newList as Variable[] - }) - setInputs(newInputs) - }, [inputs, setInputs, varKey]) +function useVarList<T>({ inputs, setInputs, varKey = 'variables' }: Params<T>) { + const handleVarListChange = useCallback( + (newList: Variable[] | string) => { + const newInputs = produce(inputs, (draft: any) => { + draft[varKey] = newList as Variable[] + }) + setInputs(newInputs) + }, + [inputs, setInputs, varKey], + ) const handleAddVariable = useCallback(() => { const newInputs = produce(inputs, (draft: any) => { diff --git a/web/app/components/workflow/nodes/_base/node-sections.tsx b/web/app/components/workflow/nodes/_base/node-sections.tsx index 9f9d55624274b6..1b782543904eef 100644 --- a/web/app/components/workflow/nodes/_base/node-sections.tsx +++ b/web/app/components/workflow/nodes/_base/node-sections.tsx @@ -18,35 +18,34 @@ export type WorkflowTranslator = ( options: { ns: 'workflow' } & Record<string, unknown>, ) => string -export const NodeHeaderMeta = ({ - data, - hasVarValue, - isLoading, - loopIndex, - t, -}: HeaderMetaProps) => { +export const NodeHeaderMeta = ({ data, hasVarValue, isLoading, loopIndex, t }: HeaderMetaProps) => { return ( <> {data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && ( <Tooltip> <TooltipTrigger> <div className="ml-1 flex items-center justify-center rounded-[5px] border border-text-warning px-[5px] py-[3px] system-2xs-medium-uppercase text-text-warning"> - {t($ => $['nodes.iteration.parallelModeUpper'], { ns: 'workflow' })} + {t(($) => $['nodes.iteration.parallelModeUpper'], { ns: 'workflow' })} </div> </TooltipTrigger> <TooltipContent className="w-[180px]"> <div className="font-extrabold"> - {t($ => $['nodes.iteration.parallelModeEnableTitle'], { ns: 'workflow' })} + {t(($) => $['nodes.iteration.parallelModeEnableTitle'], { ns: 'workflow' })} </div> - {t($ => $['nodes.iteration.parallelModeEnableDesc'], { ns: 'workflow' })} + {t(($) => $['nodes.iteration.parallelModeEnableDesc'], { ns: 'workflow' })} </TooltipContent> </Tooltip> )} - {!!(data._iterationLength && data._iterationIndex && data._runningStatus === NodeRunningStatus.Running) && ( + {!!( + data._iterationLength && + data._iterationIndex && + data._runningStatus === NodeRunningStatus.Running + ) && ( <div className="mr-1.5 text-xs font-medium text-text-accent"> - {data._iterationIndex > data._iterationLength ? data._iterationLength : data._iterationIndex} - / - {data._iterationLength} + {data._iterationIndex > data._iterationLength + ? data._iterationLength + : data._iterationIndex} + /{data._iterationLength} </div> )} {!!(data.type === BlockEnum.Loop && data._loopIndex) && loopIndex} @@ -57,9 +56,11 @@ export const NodeHeaderMeta = ({ {!isLoading && data._runningStatus === NodeRunningStatus.Exception && ( <span className="i-ri-alert-fill size-3.5 text-text-warning-secondary" /> )} - {!isLoading && (data._runningStatus === NodeRunningStatus.Succeeded || (!data._runningStatus && hasVarValue)) && ( - <span className="i-ri-checkbox-circle-fill size-3.5 text-text-success" /> - )} + {!isLoading && + (data._runningStatus === NodeRunningStatus.Succeeded || + (!data._runningStatus && hasVarValue)) && ( + <span className="i-ri-checkbox-circle-fill size-3.5 text-text-success" /> + )} {!isLoading && data._runningStatus === NodeRunningStatus.Paused && ( <span className="i-ri-pause-circle-fill size-3.5 text-text-warning-secondary" /> )} @@ -72,23 +73,21 @@ type NodeBodyProps = { child: ReactElement } -export const NodeBody = ({ - data, - child, -}: NodeBodyProps) => { +export const NodeBody = ({ data, child }: NodeBodyProps) => { if (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) { - return ( - <div className="grow px-1 pb-1"> - {child} - </div> - ) + return <div className="grow px-1 pb-1">{child}</div> } return child } export const NodeDescription = ({ data }: { data: NodeProps['data'] }) => { - if (!data.desc || data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop || data.type === BlockEnum.StartPlaceholder) + if ( + !data.desc || + data.type === BlockEnum.Iteration || + data.type === BlockEnum.Loop || + data.type === BlockEnum.StartPlaceholder + ) return null return ( diff --git a/web/app/components/workflow/nodes/_base/node.helpers.tsx b/web/app/components/workflow/nodes/_base/node.helpers.tsx index 2614d4b058fa69..431edc8d82095c 100644 --- a/web/app/components/workflow/nodes/_base/node.helpers.tsx +++ b/web/app/components/workflow/nodes/_base/node.helpers.tsx @@ -7,16 +7,19 @@ export const getNodeStatusBorders = ( showSelectedBorder: boolean, ) => { return { - showRunningBorder: (runningStatus === NodeRunningStatus.Running || runningStatus === NodeRunningStatus.Paused) && !showSelectedBorder, - showSuccessBorder: (runningStatus === NodeRunningStatus.Succeeded || (hasVarValue && !runningStatus)) && !showSelectedBorder, + showRunningBorder: + (runningStatus === NodeRunningStatus.Running || runningStatus === NodeRunningStatus.Paused) && + !showSelectedBorder, + showSuccessBorder: + (runningStatus === NodeRunningStatus.Succeeded || (hasVarValue && !runningStatus)) && + !showSelectedBorder, showFailedBorder: runningStatus === NodeRunningStatus.Failed && !showSelectedBorder, showExceptionBorder: runningStatus === NodeRunningStatus.Exception && !showSelectedBorder, } } export const getLoopIndexTextKey = (runningStatus: NodeRunningStatus | undefined) => { - if (runningStatus === NodeRunningStatus.Running) - return 'nodes.loop.currentLoopCount' + if (runningStatus === NodeRunningStatus.Running) return 'nodes.loop.currentLoopCount' if (runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed) return 'nodes.loop.totalLoopCount' diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 91a8aa47f51d61..1ac177a5af5ae1 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -1,17 +1,9 @@ -import type { - FC, - ReactElement, -} from 'react' +import type { FC, ReactElement } from 'react' import type { WorkflowTranslator } from './node-sections' import type { NodeProps } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { useAtomValue } from 'jotai' -import { - cloneElement, - memo, - useMemo, - useRef, -} from 'react' +import { cloneElement, memo, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { UserAvatarList } from '@/app/components/base/user-avatar-list' import BlockIcon from '@/app/components/workflow/block-icon' @@ -24,11 +16,7 @@ import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/it import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions' import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id' import { useStore } from '@/app/components/workflow/store' -import { - BlockEnum, - ControlMode, - NodeRunningStatus, -} from '@/app/components/workflow/types' +import { BlockEnum, ControlMode, NodeRunningStatus } from '@/app/components/workflow/types' import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils' import { userProfileAtom } from '@/context/account-state' import { selectWorkflowNode } from '../../utils/node-navigation' @@ -36,17 +24,10 @@ import AddVariablePopupWithPosition from './components/add-variable-popup-with-p import EntryNodeContainer, { StartNodeTypeEnum } from './components/entry-node-container' import ErrorHandleOnNode from './components/error-handle/error-handle-on-node' import NodeControl from './components/node-control' -import { - NodeSourceHandle, - NodeTargetHandle, -} from './components/node-handle' +import { NodeSourceHandle, NodeTargetHandle } from './components/node-handle' import NodeResizer from './components/node-resizer' import RetryOnNode from './components/retry/retry-on-node' -import { - NodeBody, - NodeDescription, - NodeHeaderMeta, -} from './node-sections' +import { NodeBody, NodeDescription, NodeHeaderMeta } from './node-sections' import { getLoopIndexTextKey, getNodeStatusBorders, @@ -66,11 +47,7 @@ type BaseNodeProps = { data: NodeProps['data'] } -const BaseNode: FC<BaseNodeProps> = ({ - id, - data, - children, -}) => { +const BaseNode: FC<BaseNodeProps> = ({ id, data, children }) => { const { t } = useTranslation() const translateWorkflow: WorkflowTranslator = (selector, options) => t(selector, options) const nodeRef = useRef<HTMLDivElement>(null) @@ -80,10 +57,12 @@ const BaseNode: FC<BaseNodeProps> = ({ const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions() const toolIcon = useToolIcon(data) const userProfile = useAtomValue(userProfileAtom) - const appId = useStore(s => s.appId) + const appId = useStore((s) => s.appId) const { nodePanelPresence } = useCollaboration(appId as string) - const controlMode = useStore(s => s.controlMode) - const isContextMenuTarget = useStore(s => s.contextMenuTarget?.type === 'node' && s.contextMenuTarget.nodeId === id) + const controlMode = useStore((s) => s.controlMode) + const isContextMenuTarget = useStore( + (s) => s.contextMenuTarget?.type === 'node' && s.contextMenuTarget.nodeId === id, + ) const currentUserPresence = useMemo(() => { const userId = userProfile?.id || '' @@ -95,23 +74,35 @@ const BaseNode: FC<BaseNodeProps> = ({ username, avatar, } - }, [userProfile?.avatar, userProfile?.avatar_url, userProfile?.email, userProfile?.id, userProfile?.name]) + }, [ + userProfile?.avatar, + userProfile?.avatar_url, + userProfile?.email, + userProfile?.id, + userProfile?.name, + ]) const viewingUsers = useMemo(() => { const presence = nodePanelPresence?.[id] - if (!presence) - return [] + if (!presence) return [] return Object.values(presence) - .filter(viewer => viewer.userId && viewer.userId !== currentUserPresence.userId) - .map(viewer => ({ + .filter((viewer) => viewer.userId && viewer.userId !== currentUserPresence.userId) + .map((viewer) => ({ id: viewer.userId, name: viewer.username, avatar_url: viewer.avatar || null, })) }, [currentUserPresence.userId, id, nodePanelPresence]) - const { shouldDim: pluginDimmed, isChecking: pluginIsChecking, isMissing: pluginIsMissing, canInstall: pluginCanInstall, uniqueIdentifier: pluginUniqueIdentifier } = useNodePluginInstallation(data) - const pluginInstallLocked = !pluginIsChecking && pluginIsMissing && pluginCanInstall && Boolean(pluginUniqueIdentifier) + const { + shouldDim: pluginDimmed, + isChecking: pluginIsChecking, + isMissing: pluginIsMissing, + canInstall: pluginCanInstall, + uniqueIdentifier: pluginUniqueIdentifier, + } = useNodePluginInstallation(data) + const pluginInstallLocked = + !pluginIsChecking && pluginIsMissing && pluginCanInstall && Boolean(pluginUniqueIdentifier) useNodeResizeObserver({ enabled: Boolean(data.selected && data.isInIteration), @@ -126,20 +117,22 @@ const BaseNode: FC<BaseNodeProps> = ({ }) const { hasNodeInspectVars } = useInspectVarsCrud() - const isLoading = data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running + const isLoading = + data._runningStatus === NodeRunningStatus.Running || + data._singleRunningStatus === NodeRunningStatus.Running const hasVarValue = hasNodeInspectVars(id) - const showSelectedBorder = Boolean(data.selected || isContextMenuTarget || data._isBundled || data._isEntering) - const { - showRunningBorder, - showSuccessBorder, - showFailedBorder, - showExceptionBorder, - } = useMemo(() => getNodeStatusBorders(data._runningStatus, hasVarValue, showSelectedBorder), [data._runningStatus, hasVarValue, showSelectedBorder]) + const showSelectedBorder = Boolean( + data.selected || isContextMenuTarget || data._isBundled || data._isEntering, + ) + const { showRunningBorder, showSuccessBorder, showFailedBorder, showExceptionBorder } = useMemo( + () => getNodeStatusBorders(data._runningStatus, hasVarValue, showSelectedBorder), + [data._runningStatus, hasVarValue, showSelectedBorder], + ) const LoopIndex = useMemo(() => { const translationKey = getLoopIndexTextKey(data._runningStatus) const text = translationKey - ? t($ => $[translationKey], { ns: 'workflow', count: data._loopIndex }) + ? t(($) => $[translationKey], { ns: 'workflow', count: data._loopIndex }) : '' if (text) { @@ -162,7 +155,9 @@ const BaseNode: FC<BaseNodeProps> = ({ <div className={cn( 'relative flex rounded-2xl border', - showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent', + showSelectedBorder + ? 'border-components-option-card-option-selected-border' + : 'border-transparent', data._waitingRun && 'opacity-70', pluginInstallLocked && 'cursor-not-allowed', )} @@ -176,7 +171,7 @@ const BaseNode: FC<BaseNodeProps> = ({ <button type="button" disabled - aria-label={t($ => $.installPlugin, { ns: 'plugin' })} + aria-label={t(($) => $.installPlugin, { ns: 'plugin' })} className="pointer-events-auto absolute inset-0 z-30 rounded-2xl border-0 bg-workflow-block-parma-bg opacity-80 backdrop-blur-[2px]" /> )} @@ -186,22 +181,21 @@ const BaseNode: FC<BaseNodeProps> = ({ data-testid="workflow-node-install-overlay" /> )} - { - data.type === BlockEnum.DataSource && ( - <div className="absolute inset-[-2px] top-[-22px] z-[-1] rounded-[18px] bg-node-data-source-bg p-0.5 backdrop-blur-[6px]"> - <div className="flex h-5 items-center px-2.5 system-2xs-semibold-uppercase text-text-tertiary"> - {t($ => $['blocks.datasource'], { ns: 'workflow' })} - </div> + {data.type === BlockEnum.DataSource && ( + <div className="absolute inset-[-2px] top-[-22px] z-[-1] rounded-[18px] bg-node-data-source-bg p-0.5 backdrop-blur-[6px]"> + <div className="flex h-5 items-center px-2.5 system-2xs-semibold-uppercase text-text-tertiary"> + {t(($) => $['blocks.datasource'], { ns: 'workflow' })} </div> - ) - } + </div> + )} <div className={cn( 'group relative pb-1 shadow-xs', 'rounded-[15px] border border-transparent', - (controlMode === ControlMode.Comment) && 'hover:cursor-none', + controlMode === ControlMode.Comment && 'hover:cursor-none', !isContainerNode(data.type) && 'w-[240px] bg-workflow-block-bg', - isContainerNode(data.type) && 'flex size-full flex-col border-workflow-block-border bg-workflow-block-bg-transparent', + isContainerNode(data.type) && + 'flex size-full flex-col border-workflow-block-border bg-workflow-block-bg-transparent', !data._runningStatus && 'hover:shadow-lg', showRunningBorder && 'border-state-accent-solid!', showSuccessBorder && 'border-state-success-solid!', @@ -210,59 +204,32 @@ const BaseNode: FC<BaseNodeProps> = ({ data._isBundled && 'shadow-lg!', )} > - { - data._showAddVariablePopup && ( - <AddVariablePopupWithPosition - nodeId={id} - nodeData={data} - /> - ) - } - { - data.type === BlockEnum.Iteration && ( - <NodeResizer - nodeId={id} - nodeData={data} - /> - ) - } - { - data.type === BlockEnum.Loop && ( - <NodeResizer - nodeId={id} - nodeData={data} - /> - ) - } - { - data.type !== BlockEnum.StartPlaceholder && !data._isCandidate && ( - <NodeTargetHandle - id={id} - data={data} - handleClassName="top-4! -left-[9px]! translate-y-0!" - handleId="target" - /> - ) - } - { - data.type !== BlockEnum.StartPlaceholder && data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && data.type !== BlockEnum.HumanInput && !data._isCandidate && ( + {data._showAddVariablePopup && <AddVariablePopupWithPosition nodeId={id} nodeData={data} />} + {data.type === BlockEnum.Iteration && <NodeResizer nodeId={id} nodeData={data} />} + {data.type === BlockEnum.Loop && <NodeResizer nodeId={id} nodeData={data} />} + {data.type !== BlockEnum.StartPlaceholder && !data._isCandidate && ( + <NodeTargetHandle + id={id} + data={data} + handleClassName="top-4! -left-[9px]! translate-y-0!" + handleId="target" + /> + )} + {data.type !== BlockEnum.StartPlaceholder && + data.type !== BlockEnum.IfElse && + data.type !== BlockEnum.QuestionClassifier && + data.type !== BlockEnum.HumanInput && + !data._isCandidate && ( <NodeSourceHandle id={id} data={data} handleClassName="top-4! -right-[9px]! translate-y-0!" handleId="source" /> - ) - } - { - !data._runningStatus && !nodesReadOnly && !data._isCandidate && ( - <NodeControl - id={id} - data={data} - pluginInstallLocked={pluginInstallLocked} - /> - ) - } + )} + {!data._runningStatus && !nodesReadOnly && !data._isCandidate && ( + <NodeControl id={id} data={data} pluginInstallLocked={pluginInstallLocked} /> + )} <div className={cn( 'flex items-center rounded-t-2xl px-3 pt-3 pb-2', @@ -275,25 +242,14 @@ const BaseNode: FC<BaseNodeProps> = ({ className="mr-1 flex min-w-0 grow appearance-none items-center rounded-md border-0 bg-transparent p-0 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={() => selectWorkflowNode(id)} > - <BlockIcon - className="mr-2 shrink-0" - type={data.type} - size="md" - toolIcon={toolIcon} - /> - <div - className="flex min-w-0 grow items-center system-sm-semibold-uppercase text-text-primary" - > + <BlockIcon className="mr-2 shrink-0" type={data.type} size="md" toolIcon={toolIcon} /> + <div className="flex min-w-0 grow items-center system-sm-semibold-uppercase text-text-primary"> <div title={data.title} className="min-w-0 grow truncate"> {data.title} </div> {viewingUsers.length > 0 && ( <div className="ml-3 shrink-0"> - <UserAvatarList - users={viewingUsers} - maxVisible={3} - size="sm" - /> + <UserAvatarList users={viewingUsers} maxVisible={3} size="sm" /> </div> )} </div> @@ -312,22 +268,8 @@ const BaseNode: FC<BaseNodeProps> = ({ data={data} child={cloneElement(children, { id, data } satisfies Partial<NodeChildProps>)} /> - { - hasRetryNode(data.type) && ( - <RetryOnNode - id={id} - data={data} - /> - ) - } - { - hasErrorHandleNode(data.type) && ( - <ErrorHandleOnNode - id={id} - data={data} - /> - ) - } + {hasRetryNode(data.type) && <RetryOnNode id={id} data={data} />} + {hasErrorHandleNode(data.type) && <ErrorHandleOnNode id={id} data={data} />} <NodeDescription data={data} /> {data.type === BlockEnum.Tool && data.provider_type === ToolTypeEnum.MCP && ( <div className="px-3 pb-2"> @@ -341,15 +283,15 @@ const BaseNode: FC<BaseNodeProps> = ({ const isStartNode = data.type === BlockEnum.Start || data.type === BlockEnum.StartPlaceholder const isEntryNode = isEntryWorkflowNode(data.type) - return isEntryNode - ? ( - <EntryNodeContainer - nodeType={isStartNode ? StartNodeTypeEnum.Start : StartNodeTypeEnum.Trigger} - > - {nodeContent} - </EntryNodeContainer> - ) - : nodeContent + return isEntryNode ? ( + <EntryNodeContainer + nodeType={isStartNode ? StartNodeTypeEnum.Start : StartNodeTypeEnum.Trigger} + > + {nodeContent} + </EntryNodeContainer> + ) : ( + nodeContent + ) } export default memo(BaseNode) diff --git a/web/app/components/workflow/nodes/_base/types.ts b/web/app/components/workflow/nodes/_base/types.ts index 77d5d9118692ec..a7f74a5e6e5789 100644 --- a/web/app/components/workflow/nodes/_base/types.ts +++ b/web/app/components/workflow/nodes/_base/types.ts @@ -8,7 +8,10 @@ export enum VarKindType { } // Generic resource variable inputs -export type ResourceVarInputs = Record<string, { - type: VarKindType - value?: string | ValueSelector | any -}> +export type ResourceVarInputs = Record< + string, + { + type: VarKindType + value?: string | ValueSelector | any + } +> diff --git a/web/app/components/workflow/nodes/_base/use-node-resize-observer.ts b/web/app/components/workflow/nodes/_base/use-node-resize-observer.ts index 34e4791556b4ac..e09a0d28273ba9 100644 --- a/web/app/components/workflow/nodes/_base/use-node-resize-observer.ts +++ b/web/app/components/workflow/nodes/_base/use-node-resize-observer.ts @@ -6,14 +6,9 @@ type ResizeObserverParams = { onResize: () => void } -const useNodeResizeObserver = ({ - enabled, - nodeRef, - onResize, -}: ResizeObserverParams) => { +const useNodeResizeObserver = ({ enabled, nodeRef, onResize }: ResizeObserverParams) => { useEffect(() => { - if (!enabled || !nodeRef.current) - return + if (!enabled || !nodeRef.current) return const resizeObserver = new ResizeObserver(() => { onResize() diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/default.spec.ts b/web/app/components/workflow/nodes/agent-v2/__tests__/default.spec.ts index 417e457eec1eff..69fe6cb94275d3 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/default.spec.ts +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/default.spec.ts @@ -4,15 +4,16 @@ import { withSelectorKey } from '@/test/i18n-mock' import nodeDefault from '../default' import { isAgentV2NodeData } from '../types' -const t = withSelectorKey(vi.fn((key: string, options?: Record<string, unknown>) => { - if (key === 'errorMsg.fieldRequired') - return `required:${options?.field}` +const t = withSelectorKey( + vi.fn((key: string, options?: Record<string, unknown>) => { + if (key === 'errorMsg.fieldRequired') return `required:${options?.field}` - if (key === 'nodes.agent.roster.label') - return 'Agent' + if (key === 'nodes.agent.roster.label') return 'Agent' - return key -}), 'workflow') + return key + }), + 'workflow', +) const createPayload = (overrides: Partial<AgentV2NodeType> = {}): AgentV2NodeType => ({ title: 'Agent', @@ -33,12 +34,15 @@ describe('agent/default', () => { }) it('requires a roster agent id', () => { - const result = nodeDefault.checkValid(createPayload({ - agent_binding: { - binding_type: 'roster_agent', - agent_id: '', - }, - }), t) + const result = nodeDefault.checkValid( + createPayload({ + agent_binding: { + binding_type: 'roster_agent', + agent_id: '', + }, + }), + t, + ) expect(result).toEqual({ isValid: false, @@ -65,11 +69,14 @@ describe('agent/default', () => { }) it('requires complete inline agent binding', () => { - const result = nodeDefault.checkValid(createPayload({ - agent_binding: { - binding_type: 'inline_agent', - }, - }), t) + const result = nodeDefault.checkValid( + createPayload({ + agent_binding: { + binding_type: 'inline_agent', + }, + }), + t, + ) expect(result).toEqual({ isValid: false, @@ -78,13 +85,16 @@ describe('agent/default', () => { }) it('passes validation for complete inline agent binding', () => { - const result = nodeDefault.checkValid(createPayload({ - agent_binding: { - binding_type: 'inline_agent', - agent_id: 'inline-agent-1', - current_snapshot_id: 'inline-snapshot-1', - }, - }), t) + const result = nodeDefault.checkValid( + createPayload({ + agent_binding: { + binding_type: 'inline_agent', + agent_id: 'inline-agent-1', + current_snapshot_id: 'inline-snapshot-1', + }, + }), + t, + ) expect(result).toEqual({ isValid: true, @@ -108,11 +118,13 @@ describe('agent/default', () => { it('identifies version 2 agent data as Agent v2', () => { expect(isAgentV2NodeData(createPayload({ type: BlockEnum.Agent }))).toBe(true) - expect(isAgentV2NodeData({ - title: 'Agent', - desc: '', - type: BlockEnum.Agent, - version: '2', - } as Parameters<typeof isAgentV2NodeData>[0])).toBe(false) + expect( + isAgentV2NodeData({ + title: 'Agent', + desc: '', + type: BlockEnum.Agent, + version: '2', + } as Parameters<typeof isAgentV2NodeData>[0]), + ).toBe(false) }) }) diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx index 9cc7f17b13352e..00d13ea45ee292 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx @@ -25,22 +25,28 @@ const mockDefaultModel = vi.hoisted(() => ({ }, }, })) -const mockComposerMutationFn = vi.hoisted(() => vi.fn(async (variables: unknown) => ({ - agent_soul: (variables as { - body?: { - agent_soul?: unknown - } - }).body?.agent_soul, - binding: { - binding_type: 'inline_agent', - agent_id: 'inline-agent-1', - current_snapshot_id: 'inline-snapshot-1', - }, - variables, -}))) -const mockComposerMutationOptions = vi.hoisted(() => vi.fn(() => ({ - mutationFn: mockComposerMutationFn, -}))) +const mockComposerMutationFn = vi.hoisted(() => + vi.fn(async (variables: unknown) => ({ + agent_soul: ( + variables as { + body?: { + agent_soul?: unknown + } + } + ).body?.agent_soul, + binding: { + binding_type: 'inline_agent', + agent_id: 'inline-agent-1', + current_snapshot_id: 'inline-snapshot-1', + }, + variables, + })), +) +const mockComposerMutationOptions = vi.hoisted(() => + vi.fn(() => ({ + mutationFn: mockComposerMutationFn, + })), +) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { @@ -71,11 +77,11 @@ vi.mock('@/service/client', () => ({ byNodeId: { agentComposer: { get: { - queryKey: ({ input }: { input: { params: { app_id: string, node_id: string } } }) => [ - 'workflow-agent-composer', - input.params.app_id, - input.params.node_id, - ], + queryKey: ({ + input, + }: { + input: { params: { app_id: string; node_id: string } } + }) => ['workflow-agent-composer', input.params.app_id, input.params.node_id], }, put: { mutationOptions: mockComposerMutationOptions, @@ -131,48 +137,55 @@ describe('useCreateInlineAgentBinding', () => { }) await waitFor(() => expect(mockComposerMutationFn).toHaveBeenCalled()) - expect(mockComposerMutationFn).toHaveBeenCalledWith({ - params: { - app_id: 'app-1', - node_id: 'node-1', - }, - body: { - variant: 'workflow', - save_strategy: 'node_job_only', - binding: { - binding_type: 'inline_agent', - }, - soul_lock: { - locked: false, + expect(mockComposerMutationFn).toHaveBeenCalledWith( + { + params: { + app_id: 'app-1', + node_id: 'node-1', }, - agent_soul: { - schema_version: 1, - prompt: { - system_prompt: '', + body: { + variant: 'workflow', + save_strategy: 'node_job_only', + binding: { + binding_type: 'inline_agent', }, - model: { - model_provider: 'langgenius/openai/openai', - model: 'gpt-4o-mini', - plugin_id: 'langgenius/openai', + soul_lock: { + locked: false, + }, + agent_soul: { + schema_version: 1, + prompt: { + system_prompt: '', + }, + model: { + model_provider: 'langgenius/openai/openai', + model: 'gpt-4o-mini', + plugin_id: 'langgenius/openai', + }, }, }, }, - }, expect.any(Object)) - await waitFor(() => expect(onSuccess).toHaveBeenCalledWith({ - binding_type: 'inline_agent', - agent_id: 'inline-agent-1', - current_snapshot_id: 'inline-snapshot-1', - })) - expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual(expect.objectContaining({ - agent_soul: expect.objectContaining({ - schema_version: 1, - }), - binding: expect.objectContaining({ + expect.any(Object), + ) + await waitFor(() => + expect(onSuccess).toHaveBeenCalledWith({ binding_type: 'inline_agent', agent_id: 'inline-agent-1', current_snapshot_id: 'inline-snapshot-1', }), - })) + ) + expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual( + expect.objectContaining({ + agent_soul: expect.objectContaining({ + schema_version: 1, + }), + binding: expect.objectContaining({ + binding_type: 'inline_agent', + agent_id: 'inline-agent-1', + current_snapshot_id: 'inline-snapshot-1', + }), + }), + ) }) it('creates inline agent with a model-less initial soul before the default model loads', async () => { @@ -201,41 +214,47 @@ describe('useCreateInlineAgentBinding', () => { }) await waitFor(() => expect(mockComposerMutationFn).toHaveBeenCalled()) - expect(mockComposerMutationFn).toHaveBeenCalledWith({ - params: { - app_id: 'app-1', - node_id: 'node-1', - }, - body: { - variant: 'workflow', - save_strategy: 'node_job_only', - binding: { - binding_type: 'inline_agent', + expect(mockComposerMutationFn).toHaveBeenCalledWith( + { + params: { + app_id: 'app-1', + node_id: 'node-1', }, - soul_lock: { - locked: false, - }, - agent_soul: { - schema_version: 1, - prompt: { - system_prompt: '', + body: { + variant: 'workflow', + save_strategy: 'node_job_only', + binding: { + binding_type: 'inline_agent', + }, + soul_lock: { + locked: false, + }, + agent_soul: { + schema_version: 1, + prompt: { + system_prompt: '', + }, }, }, }, - }, expect.any(Object)) - await waitFor(() => expect(onSuccess).toHaveBeenCalledWith({ - binding_type: 'inline_agent', - agent_id: 'inline-agent-1', - current_snapshot_id: 'inline-snapshot-1', - })) + expect.any(Object), + ) + await waitFor(() => + expect(onSuccess).toHaveBeenCalledWith({ + binding_type: 'inline_agent', + agent_id: 'inline-agent-1', + current_snapshot_id: 'inline-snapshot-1', + }), + ) }) it('finishes inline creation after the caller component unmounts', async () => { let resolveComposerState!: (value: Awaited<ReturnType<typeof mockComposerMutationFn>>) => void - mockComposerMutationFn.mockImplementationOnce(async _variables => - new Promise((resolve) => { - resolveComposerState = resolve as typeof resolveComposerState - }), + mockComposerMutationFn.mockImplementationOnce( + async (_variables) => + new Promise((resolve) => { + resolveComposerState = resolve as typeof resolveComposerState + }), ) const onSuccess = vi.fn() const queryClient = new QueryClient({ @@ -273,16 +292,20 @@ describe('useCreateInlineAgentBinding', () => { variables: {}, }) - await waitFor(() => expect(onSuccess).toHaveBeenCalledWith({ - binding_type: 'inline_agent', - agent_id: 'inline-agent-1', - current_snapshot_id: 'inline-snapshot-1', - })) - expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual(expect.objectContaining({ - agent_soul: expect.objectContaining({ - schema_version: 1, + await waitFor(() => + expect(onSuccess).toHaveBeenCalledWith({ + binding_type: 'inline_agent', + agent_id: 'inline-agent-1', + current_snapshot_id: 'inline-snapshot-1', }), - })) + ) + expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual( + expect.objectContaining({ + agent_soul: expect.objectContaining({ + schema_version: 1, + }), + }), + ) }) }) @@ -307,26 +330,30 @@ describe('useWorkflowInlineAgentConfigureSync', () => { }, }, }) - const { result } = renderWorkflowHook(() => useWorkflowInlineAgentConfigureSync({ - nodeId: 'node-1', - baseConfig: { - schema_version: 1, - }, - currentModel: { - provider: 'langgenius/openai/openai', - model: 'gpt-4o-mini', - }, - enabled: true, - }), { - queryClient, - hooksStoreProps: { - configsMap: { - flowId: 'app-1', - flowType: FlowType.appFlow, - fileSettings: {} as never, + const { result } = renderWorkflowHook( + () => + useWorkflowInlineAgentConfigureSync({ + nodeId: 'node-1', + baseConfig: { + schema_version: 1, + }, + currentModel: { + provider: 'langgenius/openai/openai', + model: 'gpt-4o-mini', + }, + enabled: true, + }), + { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, }, }, - }) + ) act(() => { getDefaultStore().set(agentComposerDraftAtom, { @@ -339,32 +366,37 @@ describe('useWorkflowInlineAgentConfigureSync', () => { await result.current.saveDraft() }) - expect(mockComposerMutationFn).toHaveBeenCalledWith({ - params: { - app_id: 'app-1', - node_id: 'node-1', + expect(mockComposerMutationFn).toHaveBeenCalledWith( + { + params: { + app_id: 'app-1', + node_id: 'node-1', + }, + body: expect.objectContaining({ + variant: 'workflow', + save_strategy: 'node_job_only', + agent_soul: expect.objectContaining({ + schema_version: 1, + prompt: expect.objectContaining({ + system_prompt: 'Workflow inline prompt', + }), + model: expect.objectContaining({ + model_provider: 'langgenius/openai/openai', + model: 'gpt-4o-mini', + }), + }), + }), }, - body: expect.objectContaining({ - variant: 'workflow', - save_strategy: 'node_job_only', + expect.any(Object), + ) + await waitFor(() => expect(result.current.draftSavedAt).toBe(1710000300000)) + expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual( + expect.objectContaining({ agent_soul: expect.objectContaining({ schema_version: 1, - prompt: expect.objectContaining({ - system_prompt: 'Workflow inline prompt', - }), - model: expect.objectContaining({ - model_provider: 'langgenius/openai/openai', - model: 'gpt-4o-mini', - }), }), }), - }, expect.any(Object)) - await waitFor(() => expect(result.current.draftSavedAt).toBe(1710000300000)) - expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual(expect.objectContaining({ - agent_soul: expect.objectContaining({ - schema_version: 1, - }), - })) + ) }) it('still saves manually when inline agent autosave is disabled', async () => { @@ -378,23 +410,27 @@ describe('useWorkflowInlineAgentConfigureSync', () => { }, }, }) - const { result } = renderWorkflowHook(() => useWorkflowInlineAgentConfigureSync({ - nodeId: 'node-1', - baseConfig: { - schema_version: 1, - }, - autoSaveEnabled: false, - enabled: true, - }), { - queryClient, - hooksStoreProps: { - configsMap: { - flowId: 'app-1', - flowType: FlowType.appFlow, - fileSettings: {} as never, + const { result } = renderWorkflowHook( + () => + useWorkflowInlineAgentConfigureSync({ + nodeId: 'node-1', + baseConfig: { + schema_version: 1, + }, + autoSaveEnabled: false, + enabled: true, + }), + { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, }, }, - }) + ) act(() => { getDefaultStore().set(agentComposerDraftAtom, { @@ -407,22 +443,25 @@ describe('useWorkflowInlineAgentConfigureSync', () => { await result.current.saveDraft() }) - expect(mockComposerMutationFn).toHaveBeenCalledWith({ - params: { - app_id: 'app-1', - node_id: 'node-1', - }, - body: expect.objectContaining({ - variant: 'workflow', - save_strategy: 'node_job_only', - agent_soul: expect.objectContaining({ - schema_version: 1, - prompt: expect.objectContaining({ - system_prompt: 'Manual inline prompt', + expect(mockComposerMutationFn).toHaveBeenCalledWith( + { + params: { + app_id: 'app-1', + node_id: 'node-1', + }, + body: expect.objectContaining({ + variant: 'workflow', + save_strategy: 'node_job_only', + agent_soul: expect.objectContaining({ + schema_version: 1, + prompt: expect.objectContaining({ + system_prompt: 'Manual inline prompt', + }), }), }), - }), - }, expect.any(Object)) + }, + expect.any(Object), + ) }) it('does not save manually when the inline agent composer draft is unchanged', async () => { @@ -436,22 +475,26 @@ describe('useWorkflowInlineAgentConfigureSync', () => { }, }, }) - const { result } = renderWorkflowHook(() => useWorkflowInlineAgentConfigureSync({ - nodeId: 'node-1', - baseConfig: { - schema_version: 1, - }, - enabled: true, - }), { - queryClient, - hooksStoreProps: { - configsMap: { - flowId: 'app-1', - flowType: FlowType.appFlow, - fileSettings: {} as never, + const { result } = renderWorkflowHook( + () => + useWorkflowInlineAgentConfigureSync({ + nodeId: 'node-1', + baseConfig: { + schema_version: 1, + }, + enabled: true, + }), + { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, }, }, - }) + ) await act(async () => { await result.current.saveDraft() @@ -473,41 +516,48 @@ describe('useWorkflowInlineAgentConfigureSync', () => { }, }, }) - const { result } = renderWorkflowHook(() => useWorkflowInlineAgentConfigureSync({ - nodeId: 'node-1', - baseConfig: { - schema_version: 1, - }, - currentModel: { - provider: 'langgenius/openai/openai', - model: 'gpt-4o-mini', - }, - enabled: true, - }), { - queryClient, - hooksStoreProps: { - configsMap: { - flowId: 'app-1', - flowType: FlowType.appFlow, - fileSettings: {} as never, + const { result } = renderWorkflowHook( + () => + useWorkflowInlineAgentConfigureSync({ + nodeId: 'node-1', + baseConfig: { + schema_version: 1, + }, + currentModel: { + provider: 'langgenius/openai/openai', + model: 'gpt-4o-mini', + }, + enabled: true, + }), + { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, }, }, - }) + ) await act(async () => { await result.current.saveDraft() }) - expect(mockComposerMutationFn).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ - agent_soul: expect.objectContaining({ - model: expect.objectContaining({ - model_provider: 'langgenius/openai/openai', - model: 'gpt-4o-mini', - plugin_id: 'langgenius/openai', + expect(mockComposerMutationFn).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + model: expect.objectContaining({ + model_provider: 'langgenius/openai/openai', + model: 'gpt-4o-mini', + plugin_id: 'langgenius/openai', + }), }), }), }), - }), expect.any(Object)) + expect.any(Object), + ) }) }) diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/node.spec.tsx index 25a5fae2762447..0ea5dde4033371 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/node.spec.tsx @@ -4,10 +4,7 @@ import { render, screen } from '@testing-library/react' import { BlockEnum } from '@/app/components/workflow/types' import { AgentV2Node } from '../node' -const { - mockUseAgentRosterDetail, - mockUseWorkflowInlineAgentDetail, -} = vi.hoisted(() => ({ +const { mockUseAgentRosterDetail, mockUseWorkflowInlineAgentDetail } = vi.hoisted(() => ({ mockUseAgentRosterDetail: vi.fn(), mockUseWorkflowInlineAgentDetail: vi.fn(), })) @@ -33,7 +30,8 @@ vi.mock('../../_base/components/setting-item', () => ({ vi.mock('../hooks', () => ({ useAgentRosterDetail: (agentId?: string) => mockUseAgentRosterDetail(agentId), - useWorkflowInlineAgentDetail: (nodeId?: string, agentId?: string | null) => mockUseWorkflowInlineAgentDetail(nodeId, agentId), + useWorkflowInlineAgentDetail: (nodeId?: string, agentId?: string | null) => + mockUseWorkflowInlineAgentDetail(nodeId, agentId), })) const createData = (overrides: Partial<AgentV2NodeType> = {}): AgentV2NodeType => ({ @@ -65,45 +63,46 @@ describe('agent/node', () => { } : undefined, })) - mockUseWorkflowInlineAgentDetail.mockImplementation((nodeId?: string, agentId?: string | null) => ({ - isPending: false, - data: nodeId && agentId - ? { - agent: { - id: agentId, - name: 'Workflow Agent 1', - description: '', - scope: 'workflow_only', - status: 'active', - }, - } - : undefined, - })) + mockUseWorkflowInlineAgentDetail.mockImplementation( + (nodeId?: string, agentId?: string | null) => ({ + isPending: false, + data: + nodeId && agentId + ? { + agent: { + id: agentId, + name: 'Workflow Agent 1', + description: '', + scope: 'workflow_only', + status: 'active', + }, + } + : undefined, + }), + ) }) it('renders the selected roster agent', () => { - const { container } = render( - <AgentV2Node - id="agent-node" - data={createData()} - />, - ) + const { container } = render(<AgentV2Node id="agent-node" data={createData()} />) - expect(screen.getByText('workflow.nodes.agent.roster.label')).toHaveClass('px-2.5', 'py-0.5', 'system-2xs-medium-uppercase') + expect(screen.getByText('workflow.nodes.agent.roster.label')).toHaveClass( + 'px-2.5', + 'py-0.5', + 'system-2xs-medium-uppercase', + ) expect(screen.getByText('Nadia')).toHaveClass('system-xs-regular', 'text-text-secondary') - expect(container.querySelector('.bg-workflow-block-parma-bg')).toHaveClass('gap-1', 'rounded-lg', 'p-1') + expect(container.querySelector('.bg-workflow-block-parma-bg')).toHaveClass( + 'gap-1', + 'rounded-lg', + 'p-1', + ) expect(container.querySelector('.h-1.px-3')).not.toBeInTheDocument() }) it('renders a stable roster placeholder while agent detail is loading', () => { mockUseAgentRosterDetail.mockReturnValue({ data: undefined }) - const { container } = render( - <AgentV2Node - id="agent-node" - data={createData()} - />, - ) + const { container } = render(<AgentV2Node id="agent-node" data={createData()} />) expect(container.querySelector('.bg-workflow-block-parma-bg')).toBeInTheDocument() expect(container.querySelector('.h-2.w-20')).toBeInTheDocument() @@ -121,11 +120,21 @@ describe('agent/node', () => { />, ) - expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.name')).toHaveClass('system-xs-regular', 'text-text-secondary') - expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.type')).toHaveClass('system-2xs-regular', 'text-text-tertiary') + expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.name')).toHaveClass( + 'system-xs-regular', + 'text-text-secondary', + ) + expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.type')).toHaveClass( + 'system-2xs-regular', + 'text-text-tertiary', + ) const configureIcon = container.querySelector('.i-custom-vender-agent-v2-configure') expect(configureIcon).toHaveClass('h-3.5', 'w-3') - expect(configureIcon?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn') + expect(configureIcon?.parentElement).toHaveClass( + 'size-8', + 'rounded-full', + 'bg-background-default-burn', + ) }) it('renders the fixed inline setup name when workflow composer state is loaded', () => { @@ -145,8 +154,14 @@ describe('agent/node', () => { expect(mockUseAgentRosterDetail).toHaveBeenCalledWith(undefined) expect(mockUseWorkflowInlineAgentDetail).toHaveBeenCalledWith('agent-node', 'inline-agent-1') expect(screen.queryByText('Workflow Agent 1')).not.toBeInTheDocument() - expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.name')).toHaveClass('system-xs-regular', 'text-text-secondary') - expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.type')).toHaveClass('system-2xs-regular', 'text-text-tertiary') + expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.name')).toHaveClass( + 'system-xs-regular', + 'text-text-secondary', + ) + expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.type')).toHaveClass( + 'system-2xs-regular', + 'text-text-tertiary', + ) }) it('renders a stable inline placeholder while agent detail is loading', () => { @@ -168,20 +183,21 @@ describe('agent/node', () => { />, ) - expect(screen.queryByText('workflow.nodes.agent.roster.inlineSetup.name')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.agent.roster.inlineSetup.name'), + ).not.toBeInTheDocument() expect(container.querySelector('.bg-workflow-block-parma-bg')).toBeInTheDocument() - expect(container.querySelector('.size-8.shrink-0.rounded-full.bg-text-quaternary\\/20')).toBeInTheDocument() + expect( + container.querySelector('.size-8.shrink-0.rounded-full.bg-text-quaternary\\/20'), + ).toBeInTheDocument() expect(container.querySelector('.h-2.w-20')).toBeInTheDocument() }) it('renders an error state when no roster agent is selected', () => { - render( - <AgentV2Node - id="agent-node" - data={createData({ agent_binding: undefined })} - />, - ) + render(<AgentV2Node id="agent-node" data={createData({ agent_binding: undefined })} />) - expect(screen.getByText(/workflow.nodes.agent.roster.label:error:/)).toHaveTextContent('workflow.errorMsg.fieldRequired') + expect(screen.getByText(/workflow.nodes.agent.roster.label:error:/)).toHaveTextContent( + 'workflow.errorMsg.fieldRequired', + ) }) }) diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx index 9d96441ab37fa6..c9e5986e0f1eee 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx @@ -28,7 +28,9 @@ const { mockEditorFocus: vi.fn(), mockEditorUpdate: vi.fn((callback: () => void) => callback()), mockHandleNodeDataUpdate: vi.fn(), - mockHandleNodeDataUpdateWithSyncDraft: vi.fn((_payload, options) => options?.callback?.onSuccess?.()), + mockHandleNodeDataUpdateWithSyncDraft: vi.fn((_payload, options) => + options?.callback?.onSuccess?.(), + ), mockInsertNodes: vi.fn(), mockOrchestratePanelContentProps: [] as Array<{ agentId?: string @@ -74,7 +76,7 @@ vi.mock('../../_base/components/output-vars', () => ({ return <div>{children}</div> }, - VarItem: ({ name, type, description }: { name: string, type: string, description?: string }) => ( + VarItem: ({ name, type, description }: { name: string; type: string; description?: string }) => ( <div>{`${name}:${type}:${description || ''}`}</div> ), })) @@ -89,7 +91,7 @@ vi.mock('@/app/components/base/prompt-editor', () => ({ aria-label="workflow.nodes.agent.task.label" placeholder={typeof props.placeholder === 'string' ? props.placeholder : undefined} value={props.value} - onChange={event => props.onChange?.(event.currentTarget.value)} + onChange={(event) => props.onChange?.(event.currentTarget.value)} onBlur={props.onBlur} onFocus={props.onFocus} /> @@ -100,10 +102,12 @@ vi.mock('@/app/components/base/prompt-editor', () => ({ })) vi.mock('@lexical/react/LexicalComposerContext', () => ({ - useLexicalComposerContext: () => [{ - focus: mockEditorFocus, - update: mockEditorUpdate, - }], + useLexicalComposerContext: () => [ + { + focus: mockEditorFocus, + update: mockEditorUpdate, + }, + ], })) vi.mock('@tanstack/react-query', async (importOriginal) => { @@ -156,15 +160,17 @@ vi.mock('@/app/components/workflow/block-selector/agent-selector', () => ({ <> <button type="button" - onClick={() => onSelect({ - id: 'agent-2', - name: 'Mara', - description: 'Tender Analyst', - icon: 'M', - icon_background: '#D1E9FF', - icon_type: 'emoji', - role: 'Analyst', - })} + onClick={() => + onSelect({ + id: 'agent-2', + name: 'Mara', + description: 'Tender Analyst', + icon: 'M', + icon_background: '#D1E9FF', + icon_type: 'emoji', + role: 'Analyst', + }) + } > Select Mara </button> @@ -183,7 +189,8 @@ vi.mock('../hooks', () => ({ createInlineAgentBinding: mockCreateInlineAgentBinding, isCreatingInlineAgent: false, }), - useWorkflowInlineAgentDetail: (nodeId?: string, agentId?: string | null) => mockUseWorkflowInlineAgentDetail(nodeId, agentId), + useWorkflowInlineAgentDetail: (nodeId?: string, agentId?: string | null) => + mockUseWorkflowInlineAgentDetail(nodeId, agentId), })) vi.mock('../components/agent-orchestrate-panel-content', () => ({ @@ -194,9 +201,7 @@ vi.mock('../components/agent-orchestrate-panel-content', () => ({ }) => { mockOrchestratePanelContentProps.push(props) - return ( - <div role="region" aria-label="readonly-roster-orchestrate-panel" /> - ) + return <div role="region" aria-label="readonly-roster-orchestrate-panel" /> }, WorkflowInlineAgentConfigureWorkspace: (props: { agentId?: string @@ -216,10 +221,7 @@ vi.mock('../components/agent-orchestrate-panel-content', () => ({ return ( <div role="region" aria-label="inline-orchestrate-panel"> - <button - type="button" - onClick={props.onSaveInlineToRoster} - > + <button type="button" onClick={props.onSaveInlineToRoster}> Inline workspace more </button> <button @@ -253,42 +255,47 @@ vi.mock('../components/save-inline-agent-to-roster-dialog', () => ({ node_id: string workflow_id: string }) => void - }) => open - ? ( - <div role="dialog" aria-label="save-inline-agent-to-roster"> - <button - type="button" - onClick={() => onSaved({ + }) => + open ? ( + <div role="dialog" aria-label="save-inline-agent-to-roster"> + <button + type="button" + onClick={() => + onSaved({ id: 'binding-1', binding_type: 'roster_agent', agent_id: 'saved-roster-agent', current_snapshot_id: 'saved-snapshot', workflow_id: 'workflow-1', node_id: 'agent-node', - })} - > - Save inline agent to roster - </button> - </div> - ) - : null, + }) + } + > + Save inline agent to roster + </button> + </div> + ) : null, })) vi.mock('../../_base/hooks/use-available-var-list', () => ({ default: () => ({ - availableVars: [{ - nodeId: 'start', - title: 'START', - vars: [{ variable: 'question', type: 'string' }], - }], - availableNodesWithParent: [{ - id: 'start', - data: { + availableVars: [ + { + nodeId: 'start', title: 'START', - type: BlockEnum.Start, + vars: [{ variable: 'question', type: 'string' }], }, - position: { x: 0, y: 0 }, - }], + ], + availableNodesWithParent: [ + { + id: 'start', + data: { + title: 'START', + type: BlockEnum.Start, + }, + position: { x: 0, y: 0 }, + }, + ], }), })) @@ -328,29 +335,34 @@ describe('agent/panel', () => { mockStoreState.appId = 'app-1' mockStoreState.openInlineAgentPanelNodeId = undefined mockCopyFromRosterState.isPending = false - mockCopyFromRosterMutate.mockImplementation((_variables, options?: { - onSuccess?: (composerState: { - binding: { - agent_id: string - binding_type: 'inline_agent' - current_snapshot_id: string - id: string - node_id: string - workflow_id: string - } - }) => void - }) => { - options?.onSuccess?.({ - binding: { - id: 'binding-1', - binding_type: 'inline_agent', - agent_id: 'inline-copy-agent', - current_snapshot_id: 'inline-copy-snapshot', - workflow_id: 'workflow-1', - node_id: 'agent-node', + mockCopyFromRosterMutate.mockImplementation( + ( + _variables, + options?: { + onSuccess?: (composerState: { + binding: { + agent_id: string + binding_type: 'inline_agent' + current_snapshot_id: string + id: string + node_id: string + workflow_id: string + } + }) => void }, - }) - }) + ) => { + options?.onSuccess?.({ + binding: { + id: 'binding-1', + binding_type: 'inline_agent', + agent_id: 'inline-copy-agent', + current_snapshot_id: 'inline-copy-snapshot', + workflow_id: 'workflow-1', + node_id: 'agent-node', + }, + }) + }, + ) mockCreateInlineAgentBinding.mockImplementation(() => {}) mockUseNodeCrud.mockImplementation((_id: string, data: AgentV2NodeType) => ({ inputs: data, @@ -369,38 +381,39 @@ describe('agent/panel', () => { } : undefined, })) - mockUseWorkflowInlineAgentDetail.mockImplementation((nodeId?: string, agentId?: string | null) => ({ - data: nodeId && agentId - ? { - agent: { - id: agentId, - name: 'Workflow Agent 1', - description: '', - scope: 'workflow_only', - status: 'active', - }, - } - : undefined, - isFetching: false, - refetch: mockWorkflowInlineAgentDetailRefetch, - })) + mockUseWorkflowInlineAgentDetail.mockImplementation( + (nodeId?: string, agentId?: string | null) => ({ + data: + nodeId && agentId + ? { + agent: { + id: agentId, + name: 'Workflow Agent 1', + description: '', + scope: 'workflow_only', + status: 'active', + }, + } + : undefined, + isFetching: false, + refetch: mockWorkflowInlineAgentDetailRefetch, + }), + ) }) it('renders selected roster agent trigger and default Agent v2 output vars', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) expect(screen.getByText('workflow.nodes.agent.roster.label')).toBeInTheDocument() expect(screen.getByText('Nadia')).toBeInTheDocument() expect(screen.getByText('workflow.nodes.agent.task.label')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'workflow.nodes.agent.task.tooltip' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'workflow.nodes.agent.task.tooltip' }), + ).toBeInTheDocument() expect(screen.getByRole('textbox', { name: 'workflow.nodes.agent.task.label' })).toHaveValue('') - expect(screen.getByRole('button', { name: 'workflow.nodes.agent.advancedSetting' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'workflow.nodes.agent.advancedSetting' }), + ).toBeInTheDocument() expect(screen.getByText('text')).toBeInTheDocument() expect(screen.getByText('workflow.nodes.agent.outputVars.text')).toBeInTheDocument() expect(screen.queryByText('usage')).not.toBeInTheDocument() @@ -409,29 +422,33 @@ describe('agent/panel', () => { expect(screen.getByText('workflow.nodes.agent.outputVars.files.title')).toBeInTheDocument() expect(screen.getByText('json')).toBeInTheDocument() expect(screen.getByText('object')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' }), + ).toBeInTheDocument() }) it('opens and closes the roster agent layered panel', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) - fireEvent.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ })) + fireEvent.click( + screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }), + ) const panel = screen.getByRole('dialog', { name: 'Nadia' }) expect(panel).toBeInTheDocument() expect(within(panel).getByText('Researcher')).toBeInTheDocument() - const consoleLink = within(panel).getByRole('link', { name: 'workflow.nodes.agent.roster.editInConsole' }) + const consoleLink = within(panel).getByRole('link', { + name: 'workflow.nodes.agent.roster.editInConsole', + }) expect(consoleLink).toHaveAttribute('href', '/agents/agent-1/configure') expect(consoleLink).toHaveAttribute('target', '_blank') expect(consoleLink).toHaveAttribute('rel', 'noopener noreferrer') - expect(within(panel).getByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' })).toBeInTheDocument() - expect(within(panel).getByRole('region', { name: 'readonly-roster-orchestrate-panel' })).toBeInTheDocument() + expect( + within(panel).getByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' }), + ).toBeInTheDocument() + expect( + within(panel).getByRole('region', { name: 'readonly-roster-orchestrate-panel' }), + ).toBeInTheDocument() expect(mockOrchestratePanelContentProps.at(-1)).toMatchObject({ agentId: 'agent-1', nodeId: 'agent-node', @@ -444,15 +461,11 @@ describe('agent/panel', () => { }) it('copies a roster agent from the drawer into an inline agent for this node', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) - fireEvent.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ })) + fireEvent.click( + screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }), + ) fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' })) expect(mockCopyFromRosterMutate).toHaveBeenCalledWith( @@ -522,8 +535,12 @@ describe('agent/panel', () => { expect(screen.queryByText(/^workflow\.errorMsg\.fieldRequired/)).not.toBeInTheDocument() expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.change', hidden: true })).toBeDisabled() - expect(screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'workflow.nodes.agent.roster.change', hidden: true }), + ).toBeDisabled() + expect( + screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' }), + ).toBeInTheDocument() expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument() expect(screen.queryByText('workflow.nodes.agent.roster.editInConsole')).not.toBeInTheDocument() expect(screen.queryByText('workflow.nodes.agent.roster.makeCopy')).not.toBeInTheDocument() @@ -550,12 +567,17 @@ describe('agent/panel', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ })) + fireEvent.click( + screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }), + ) expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node') - expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith('agent-node', expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith( + 'agent-node', + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) mockStoreState.openInlineAgentPanelNodeId = 'agent-node' rerender( <AgentV2Panel @@ -569,7 +591,9 @@ describe('agent/panel', () => { />, ) - expect(screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })).toBeInTheDocument() + expect( + screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' }), + ).toBeInTheDocument() expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument() expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument() }) @@ -594,14 +618,30 @@ describe('agent/panel', () => { expect(mockUseAgentRosterDetail).toHaveBeenCalledWith(undefined) expect(mockUseWorkflowInlineAgentDetail).toHaveBeenCalledWith('agent-node', 'inline-agent-1') expect(screen.getByRole('dialog', { name: 'Workflow Agent 1' })).toBeInTheDocument() - const trigger = screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/, hidden: true }) - expect(within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.name')).toBeInTheDocument() - expect(within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.type')).toBeInTheDocument() + const trigger = screen.getByRole('button', { + name: /^workflow\.nodes\.agent\.roster\.openPanel/, + hidden: true, + }) + expect( + within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.name'), + ).toBeInTheDocument() + expect( + within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.type'), + ).toBeInTheDocument() const panel = screen.getByRole('dialog', { name: 'Workflow Agent 1' }) - expect(container.querySelector('.i-custom-vender-agent-v2-configure')).toHaveClass('h-3.5', 'w-3') - expect(container.querySelector('.i-custom-vender-agent-v2-configure')?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn') - expect(screen.queryByText('workflow.nodes.agent.roster.inlineSetup.title')).not.toBeInTheDocument() - expect(within(panel).getByText('workflow.nodes.agent.roster.inlineSetup.description')).toBeInTheDocument() + expect(container.querySelector('.i-custom-vender-agent-v2-configure')).toHaveClass( + 'h-3.5', + 'w-3', + ) + expect( + container.querySelector('.i-custom-vender-agent-v2-configure')?.parentElement, + ).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn') + expect( + screen.queryByText('workflow.nodes.agent.roster.inlineSetup.title'), + ).not.toBeInTheDocument() + expect( + within(panel).getByText('workflow.nodes.agent.roster.inlineSetup.description'), + ).toBeInTheDocument() expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument() expect(mockOrchestratePanelContentProps.at(-1)).toMatchObject({ agentId: 'inline-agent-1', @@ -628,7 +668,9 @@ describe('agent/panel', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ })) + fireEvent.click( + screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }), + ) expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node') expect(mockWorkflowInlineAgentDetailRefetch).toHaveBeenCalled() @@ -650,10 +692,18 @@ describe('agent/panel', () => { const panel = screen.getByRole('dialog', { name: 'Workflow Agent 1' }) expect(panel).toBeInTheDocument() expect(within(panel).getByText('Workflow Agent 1')).toBeInTheDocument() - expect(within(panel).queryByText('workflow.nodes.agent.roster.inlineSetup.title')).not.toBeInTheDocument() - expect(within(panel).getByText('workflow.nodes.agent.roster.inlineSetup.description')).toBeInTheDocument() - expect(within(panel).queryByRole('link', { name: 'workflow.nodes.agent.roster.editInConsole' })).not.toBeInTheDocument() - expect(within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' })).not.toBeInTheDocument() + expect( + within(panel).queryByText('workflow.nodes.agent.roster.inlineSetup.title'), + ).not.toBeInTheDocument() + expect( + within(panel).getByText('workflow.nodes.agent.roster.inlineSetup.description'), + ).toBeInTheDocument() + expect( + within(panel).queryByRole('link', { name: 'workflow.nodes.agent.roster.editInConsole' }), + ).not.toBeInTheDocument() + expect( + within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' }), + ).not.toBeInTheDocument() expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument() }) @@ -685,7 +735,9 @@ describe('agent/panel', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ })) + fireEvent.click( + screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }), + ) expect(mockWorkflowInlineAgentDetailRefetch).toHaveBeenCalled() mockStoreState.openInlineAgentPanelNodeId = 'agent-node' @@ -703,10 +755,17 @@ describe('agent/panel', () => { />, ) - const trigger = screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/, hidden: true }) + const trigger = screen.getByRole('button', { + name: /^workflow\.nodes\.agent\.roster\.openPanel/, + hidden: true, + }) expect(trigger).not.toHaveAttribute('aria-busy') - expect(within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.name')).toBeInTheDocument() - expect(within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.type')).toBeInTheDocument() + expect( + within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.name'), + ).toBeInTheDocument() + expect( + within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.type'), + ).toBeInTheDocument() expect(mockOrchestratePanelContentProps.at(-1)).toMatchObject({ agentId: 'inline-agent-1', inlineComposerState: expect.objectContaining({ @@ -736,9 +795,13 @@ describe('agent/panel', () => { ) const panel = screen.getByRole('dialog', { name: 'Workflow Agent 1' }) - expect(within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.more' })).not.toBeInTheDocument() + expect( + within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.more' }), + ).not.toBeInTheDocument() fireEvent.click(within(panel).getByRole('button', { name: 'Inline workspace more' })) - fireEvent.click(screen.getByRole('button', { name: 'Save inline agent to roster', hidden: true })) + fireEvent.click( + screen.getByRole('button', { name: 'Save inline agent to roster', hidden: true }), + ) expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith(undefined) expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith( @@ -839,9 +902,13 @@ describe('agent/panel', () => { expect(mockUseWorkflowInlineAgentDetail).toHaveBeenCalledWith('agent-node', 'inline-agent-1') expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument() - const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' }) + const panel = screen.getByRole('dialog', { + name: 'workflow.nodes.agent.roster.inlineSetup.name', + }) expect(panel).toBeInTheDocument() - expect(within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.more' })).not.toBeInTheDocument() + expect( + within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.more' }), + ).not.toBeInTheDocument() expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument() expect(mockOrchestratePanelContentProps.at(-1)).toMatchObject({ agentId: 'inline-agent-1', @@ -879,13 +946,7 @@ describe('agent/panel', () => { }) it('updates roster agent binding from the selector', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.change' })) fireEvent.click(screen.getByRole('button', { name: 'Select Mara' })) @@ -908,29 +969,36 @@ describe('agent/panel', () => { }) it('switches a roster agent to a workflow-only inline agent from the selector', () => { - mockCreateInlineAgentBinding.mockImplementation((_nodeId: string, options?: { - onSuccess?: (binding: { - binding_type: 'inline_agent' - agent_id: string - current_snapshot_id: string - }) => void - }) => { - options?.onSuccess?.({ - binding_type: 'inline_agent', - agent_id: 'inline-agent-1', - current_snapshot_id: 'inline-snapshot-1', - }) - }) + mockCreateInlineAgentBinding.mockImplementation( + ( + _nodeId: string, + options?: { + onSuccess?: (binding: { + binding_type: 'inline_agent' + agent_id: string + current_snapshot_id: string + }) => void + }, + ) => { + options?.onSuccess?.({ + binding_type: 'inline_agent', + agent_id: 'inline-agent-1', + current_snapshot_id: 'inline-snapshot-1', + }) + }, + ) render( <AgentV2Panel id="agent-node" data={createData({ agent_task: 'Keep this task', - agent_declared_outputs: [{ - name: 'summary', - type: 'string', - }], + agent_declared_outputs: [ + { + name: 'summary', + type: 'string', + }, + ], })} panelProps={panelProps} />, @@ -940,9 +1008,12 @@ describe('agent/panel', () => { fireEvent.click(screen.getByRole('button', { name: 'Start from Scratch' })) expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node') - expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith('agent-node', expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith( + 'agent-node', + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith( { id: 'agent-node', @@ -951,10 +1022,12 @@ describe('agent/panel', () => { binding_type: 'inline_agent', }, agent_task: 'Keep this task', - agent_declared_outputs: [{ - name: 'summary', - type: 'string', - }], + agent_declared_outputs: [ + { + name: 'summary', + type: 'string', + }, + ], _openInlineAgentPanel: true, }), }, @@ -973,10 +1046,12 @@ describe('agent/panel', () => { current_snapshot_id: 'inline-snapshot-1', }, agent_task: 'Keep this task', - agent_declared_outputs: [{ - name: 'summary', - type: 'string', - }], + agent_declared_outputs: [ + { + name: 'summary', + type: 'string', + }, + ], _openInlineAgentPanel: true, }), }, @@ -999,13 +1074,7 @@ describe('agent/panel', () => { role: '', }, }) - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) expect(screen.getByText('Nadia')).toBeInTheDocument() expect(screen.queryByText('Clarification Drafter')).not.toBeInTheDocument() @@ -1025,31 +1094,37 @@ describe('agent/panel', () => { fireEvent.change(editor, { target: { value: 'Clarify {{#start.question#}}' } }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - agent_task: 'Clarify {{#start.question#}}', - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + agent_task: 'Clarify {{#start.question#}}', + }), + ) expect(mockPromptEditorProps[0]?.workflowVariableBlock).toMatchObject({ show: true, }) expect(mockPromptEditorProps[0]?.isSupportFileVar).toBe(true) expect(mockPromptEditorProps[0]?.agentOutputBlock).toMatchObject({ show: true, - outputs: expect.arrayContaining([ - expect.objectContaining({ name: 'text' }), - ]), + outputs: expect.arrayContaining([expect.objectContaining({ name: 'text' })]), onEdit: expect.any(Function), }) expect(mockPromptEditorProps[0]?.contextBlock).toBeUndefined() - expect(screen.queryByRole('button', { name: 'workflow.nodes.agent.task.insert' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'workflow.nodes.agent.task.mention' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.nodes.agent.task.insert' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.nodes.agent.task.mention' }), + ).not.toBeInTheDocument() fireEvent.focus(editor) fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.task.insert' })) expect(mockEditorFocus).toHaveBeenCalled() expect(mockInsertNodes.mock.calls[0]?.[0]?.[0]?.getTextContent()).toBe('/') - expect(screen.queryByRole('button', { name: 'workflow.nodes.agent.task.mention' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.nodes.agent.task.mention' }), + ).not.toBeInTheDocument() }) it('opens the output variable editor from an agent task output token hover', () => { @@ -1057,12 +1132,14 @@ describe('agent/panel', () => { <AgentV2Panel id="agent-node" data={createData({ - agent_declared_outputs: [{ - name: 'summary', - type: 'string', - required: false, - description: 'Short summary', - }], + agent_declared_outputs: [ + { + name: 'summary', + type: 'string', + required: false, + description: 'Short summary', + }, + ], })} panelProps={panelProps} />, @@ -1073,47 +1150,48 @@ describe('agent/panel', () => { }) expect(mockOutputVarsProps.at(-1)?.collapsed).toBe(false) - expect(screen.getByRole('form', { name: 'workflow.nodes.agent.outputVars.editorLabel' })).toBeInTheDocument() - expect(screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' })).toHaveValue('summary') + expect( + screen.getByRole('form', { name: 'workflow.nodes.agent.outputVars.editorLabel' }), + ).toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }), + ).toHaveValue('summary') }) it('expands output variables when an agent task output token changes declared outputs', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) expect(mockOutputVarsProps.at(-1)?.collapsed).toBe(true) act(() => { - mockPromptEditorProps[0]?.agentOutputBlock?.onChange?.([{ - name: 'summary', - type: 'string', - required: false, - }], 'Generate [§output:summary:summary§]') + mockPromptEditorProps[0]?.agentOutputBlock?.onChange?.( + [ + { + name: 'summary', + type: 'string', + required: false, + }, + ], + 'Generate [§output:summary:summary§]', + ) }) expect(mockOutputVarsProps.at(-1)?.collapsed).toBe(false) }) it('opens the output variable editor for a prompt token missing from declared outputs', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) act(() => { mockPromptEditorProps[0]?.agentOutputBlock?.onEdit?.('qna_report', 'string') }) - expect(screen.getByRole('form', { name: 'workflow.nodes.agent.outputVars.editorLabel' })).toBeInTheDocument() - expect(screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' })).toHaveValue('qna_report') + expect( + screen.getByRole('form', { name: 'workflow.nodes.agent.outputVars.editorLabel' }), + ).toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }), + ).toHaveValue('qna_report') fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.confirm' })) @@ -1151,11 +1229,14 @@ describe('agent/panel', () => { mockPromptEditorProps[0]?.agentOutputBlock?.onEdit?.('qna_report', 'string') }) - fireEvent.change(screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }), { - target: { - value: 'final_report', + fireEvent.change( + screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }), + { + target: { + value: 'final_report', + }, }, - }) + ) fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.confirm' })) expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith( @@ -1176,8 +1257,11 @@ describe('agent/panel', () => { notRefreshWhenSyncError: true, }), ) - const updatedData = mockHandleNodeDataUpdateWithSyncDraft.mock.calls.at(-1)?.[0].data as AgentV2NodeType - expect(updatedData.agent_declared_outputs?.some(output => output.name === 'qna_report')).toBe(false) + const updatedData = mockHandleNodeDataUpdateWithSyncDraft.mock.calls.at(-1)?.[0] + .data as AgentV2NodeType + expect(updatedData.agent_declared_outputs?.some((output) => output.name === 'qna_report')).toBe( + false, + ) expect(screen.getByText('final_report')).toBeInTheDocument() }) @@ -1187,34 +1271,40 @@ describe('agent/panel', () => { id="agent-node" data={createData({ agent_task: 'Generate [§output:qna_report:qna_report§]', - agent_declared_outputs: [{ - name: 'qna_report', - type: 'string', - required: false, - description: 'Old report', - }], + agent_declared_outputs: [ + { + name: 'qna_report', + type: 'string', + required: false, + description: 'Old report', + }, + ], })} panelProps={panelProps} />, ) act(() => { - mockPromptEditorProps[0]?.agentOutputBlock?.onChange?.([ - { - name: 'qna_report', - type: 'string', - required: false, - description: 'Old report', - }, - { - name: 'final_report', - type: 'string', - required: false, - }, - ], 'Generate [§output:final_report:final_report§]') + mockPromptEditorProps[0]?.agentOutputBlock?.onChange?.( + [ + { + name: 'qna_report', + type: 'string', + required: false, + description: 'Old report', + }, + { + name: 'final_report', + type: 'string', + required: false, + }, + ], + 'Generate [§output:final_report:final_report§]', + ) }) - const updatedData = mockHandleNodeDataUpdateWithSyncDraft.mock.calls.at(-1)?.[0].data as AgentV2NodeType + const updatedData = mockHandleNodeDataUpdateWithSyncDraft.mock.calls.at(-1)?.[0] + .data as AgentV2NodeType expect(updatedData.agent_task).toBe('Generate [§output:final_report:final_report§]') expect(updatedData.agent_declared_outputs).toEqual([ expect.objectContaining({ @@ -1231,12 +1321,14 @@ describe('agent/panel', () => { id="agent-node" data={createData({ agent_task: 'Generate [§output:qna_report:qna_report§]', - agent_declared_outputs: [{ - name: 'qna_report', - type: 'string', - required: false, - description: 'Old report', - }], + agent_declared_outputs: [ + { + name: 'qna_report', + type: 'string', + required: false, + description: 'Old report', + }, + ], })} panelProps={panelProps} />, @@ -1246,36 +1338,35 @@ describe('agent/panel', () => { mockPromptEditorProps[0]?.onChange?.('Generate [§output:final_report:final_report§]') }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - agent_task: 'Generate [§output:final_report:final_report§]', - agent_declared_outputs: [ - expect.objectContaining({ - name: 'final_report', - type: 'string', - description: 'Old report', - }), - ], - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + agent_task: 'Generate [§output:final_report:final_report§]', + agent_declared_outputs: [ + expect.objectContaining({ + name: 'final_report', + type: 'string', + description: 'Old report', + }), + ], + }), + ) expect(screen.getByText('final_report')).toBeInTheDocument() }) it('syncs declared outputs created from the agent task editor', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) - mockPromptEditorProps[0]?.agentOutputBlock?.onChange?.([ - ...mockPromptEditorProps[0]!.agentOutputBlock!.outputs!, - { - name: 'summary', - type: 'string', - required: false, - }, - ], 'Use [§output:summary:summary§]') + mockPromptEditorProps[0]?.agentOutputBlock?.onChange?.( + [ + ...mockPromptEditorProps[0]!.agentOutputBlock!.outputs!, + { + name: 'summary', + type: 'string', + required: false, + }, + ], + 'Use [§output:summary:summary§]', + ) expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith( { @@ -1299,13 +1390,7 @@ describe('agent/panel', () => { }) it('keeps the latest local task draft when outputs change before rerender', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) fireEvent.change(screen.getByRole('textbox', { name: 'workflow.nodes.agent.task.label' }), { target: { @@ -1343,13 +1428,7 @@ describe('agent/panel', () => { }) it('saves agent task to workflow draft node data', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) fireEvent.change(screen.getByRole('textbox', { name: 'workflow.nodes.agent.task.label' }), { target: { @@ -1357,9 +1436,11 @@ describe('agent/panel', () => { }, }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - agent_task: 'Use the previous result', - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + agent_task: 'Use the previous result', + }), + ) }) it('removes declared outputs when their prompt output token is deleted', () => { @@ -1391,14 +1472,16 @@ describe('agent/panel', () => { }, }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - agent_task: 'Use [§output:manual:manual§]', - agent_declared_outputs: [ - expect.objectContaining({ - name: 'manual', - }), - ], - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + agent_task: 'Use [§output:manual:manual§]', + agent_declared_outputs: [ + expect.objectContaining({ + name: 'manual', + }), + ], + }), + ) }) it('does not remove prompt output tokens when declared outputs are changed from the output list', () => { @@ -1529,25 +1612,27 @@ describe('agent/panel', () => { }) it('adds a declared output to workflow draft node data', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) - fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' })) - fireEvent.change(screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }), { - target: { - value: 'summary', + fireEvent.click( + screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' }), + ) + fireEvent.change( + screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }), + { + target: { + value: 'summary', + }, }, - }) - fireEvent.change(screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.descriptionLabel' }), { - target: { - value: 'Short summary', + ) + fireEvent.change( + screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.descriptionLabel' }), + { + target: { + value: 'Short summary', + }, }, - }) + ) fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.confirm' })) expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith( @@ -1572,16 +1657,14 @@ describe('agent/panel', () => { }) it('submits the output editor with a scoped Mod+Enter shortcut', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) - fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' })) - const nameInput = screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' }), + ) + const nameInput = screen.getByRole('textbox', { + name: 'workflow.nodes.agent.outputVars.nameLabel', + }) fireEvent.change(nameInput, { target: { value: 'summary', @@ -1612,16 +1695,14 @@ describe('agent/panel', () => { }) it('cancels the output editor with a scoped Escape shortcut', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) - fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' })) - const nameInput = screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' }), + ) + const nameInput = screen.getByRole('textbox', { + name: 'workflow.nodes.agent.outputVars.nameLabel', + }) fireEvent.change(nameInput, { target: { value: 'summary', @@ -1629,49 +1710,57 @@ describe('agent/panel', () => { }) fireEvent.keyDown(document, { key: 'Escape' }) - expect(screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' })).toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }), + ).toBeInTheDocument() fireEvent.keyDown(nameInput, { key: 'Escape' }) - expect(screen.queryByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' })).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' })).toBeInTheDocument() + expect( + screen.queryByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }), + ).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' }), + ).toBeInTheDocument() expect(mockHandleNodeDataUpdateWithSyncDraft).not.toHaveBeenCalled() }) it('reveals output editor advanced options with the collapsible trigger', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) - fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' })) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' }), + ) - expect(screen.queryByRole('textbox', { name: 'workflow.nodes.agent.outputVars.defaultValueLabel' })).not.toBeInTheDocument() + expect( + screen.queryByRole('textbox', { name: 'workflow.nodes.agent.outputVars.defaultValueLabel' }), + ).not.toBeInTheDocument() - const advancedTrigger = screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.showAdvancedOptions' }) + const advancedTrigger = screen.getByRole('button', { + name: 'workflow.nodes.agent.outputVars.showAdvancedOptions', + }) expect(advancedTrigger).toHaveAttribute('aria-expanded', 'false') fireEvent.click(advancedTrigger) expect(advancedTrigger).toHaveAttribute('aria-expanded', 'true') - expect(screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.defaultValueLabel' })).toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.defaultValueLabel' }), + ).toBeInTheDocument() }) it('does not show name validation error before the user enters a name', () => { - render( - <AgentV2Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<AgentV2Panel id="agent-node" data={createData()} panelProps={panelProps} />) - fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' })) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.newOutput' }), + ) - expect(screen.queryByText('workflow.nodes.agent.outputVars.nameInvalid')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.confirm' })).toBeDisabled() + expect( + screen.queryByText('workflow.nodes.agent.outputVars.nameInvalid'), + ).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'workflow.nodes.agent.outputVars.confirm' }), + ).toBeDisabled() }) }) diff --git a/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts b/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts index 8c2f602aa5e115..972c326d17eb9c 100644 --- a/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts +++ b/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts @@ -1,4 +1,7 @@ -import type { AgentSoulConfig, WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { + AgentSoulConfig, + WorkflowAgentComposerResponse, +} from '@dify/contracts/api/console/apps/types.gen' import type { DefaultModelResponse } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useMutation, useQueryClient } from '@tanstack/react-query' import { debounce } from 'es-toolkit/compat' @@ -7,7 +10,10 @@ import { useStore as useJotaiStore, useSetAtom } from 'jotai' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' -import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions' +import { + agentSoulConfigToFormState, + formStateToAgentSoulConfig, +} from '@/features/agent-v2/agent-composer/conversions' import { agentComposerDraftAtom, agentComposerOriginalConfigAtom, @@ -22,8 +28,7 @@ const DRAFT_AUTOSAVE_WAIT = 5000 function getModelProviderPluginId(provider: string) { const [organization, pluginName] = provider.split('/').filter(Boolean) - if (organization && pluginName) - return `${organization}/${pluginName}` + if (organization && pluginName) return `${organization}/${pluginName}` return provider ? `langgenius/${provider}` : '' } @@ -36,8 +41,7 @@ export function getDefaultAgentSoul(defaultModel?: DefaultModelResponse): AgentS }, } - if (!defaultModel) - return baseConfig + if (!defaultModel) return baseConfig const modelProvider = defaultModel.provider.provider @@ -71,7 +75,7 @@ export function useWorkflowInlineAgentConfigureSync({ enabled: boolean }) { const queryClient = useQueryClient() - const configsMap = useHooksStore(state => state.configsMap) + const configsMap = useHooksStore((state) => state.configsMap) const store = useJotaiStore() const setOriginalConfig = useSetAtom(agentComposerOriginalConfigAtom) const setOriginalDraft = useSetAtom(agentComposerOriginalDraftAtom) @@ -90,73 +94,83 @@ export function useWorkflowInlineAgentConfigureSync({ enabledRef.current = enabled onDraftSavedRef.current = onDraftSaved - const getAgentSoulDraft = useCallback(() => formStateToAgentSoulConfig({ - baseConfig: baseConfigRef.current, - formState: store.get(agentComposerDraftAtom), - currentModel: currentModelRef.current, - }), [store]) - - const saveComposer = useSerialAsyncCallback(async (configSnapshot: AgentSoulConfig): Promise<WorkflowAgentComposerResponse | undefined> => { - if (!configsMap?.flowId || configsMap.flowType !== FlowType.appFlow) - return - - const savedDraftKey = JSON.stringify(configSnapshot) - const composerState = await saveComposerMutation.mutateAsync({ - params: { - app_id: configsMap.flowId, - node_id: nodeId, - }, - body: { - variant: 'workflow', - save_strategy: 'node_job_only', - agent_soul: configSnapshot, - }, - }) + const getAgentSoulDraft = useCallback( + () => + formStateToAgentSoulConfig({ + baseConfig: baseConfigRef.current, + formState: store.get(agentComposerDraftAtom), + currentModel: currentModelRef.current, + }), + [store], + ) - queryClient.setQueryData( - consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ - input: { - params: { - app_id: configsMap.flowId, - node_id: nodeId, - }, + const saveComposer = useSerialAsyncCallback( + async (configSnapshot: AgentSoulConfig): Promise<WorkflowAgentComposerResponse | undefined> => { + if (!configsMap?.flowId || configsMap.flowType !== FlowType.appFlow) return + + const savedDraftKey = JSON.stringify(configSnapshot) + const composerState = await saveComposerMutation.mutateAsync({ + params: { + app_id: configsMap.flowId, + node_id: nodeId, }, - }), - composerState, - ) - setOriginalConfig(composerState.agent_soul) - setOriginalDraft(agentSoulConfigToFormState(composerState.agent_soul)) - setDraftSavedAt(Date.now()) - lastAutosavedDraftKeyRef.current = savedDraftKey - onDraftSavedRef.current?.(composerState) - return composerState - }) + body: { + variant: 'workflow', + save_strategy: 'node_job_only', + agent_soul: configSnapshot, + }, + }) + + queryClient.setQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ + input: { + params: { + app_id: configsMap.flowId, + node_id: nodeId, + }, + }, + }), + composerState, + ) + setOriginalConfig(composerState.agent_soul) + setOriginalDraft(agentSoulConfigToFormState(composerState.agent_soul)) + setDraftSavedAt(Date.now()) + lastAutosavedDraftKeyRef.current = savedDraftKey + onDraftSavedRef.current?.(composerState) + return composerState + }, + ) const latestDraftSaveRef = useRef<() => void>(() => undefined) latestDraftSaveRef.current = () => { void saveComposer(getAgentSoulDraft()) } - const debouncedSaveDraft = useMemo(() => debounce(() => { - latestDraftSaveRef.current() - }, DRAFT_AUTOSAVE_WAIT), []) + const debouncedSaveDraft = useMemo( + () => + debounce(() => { + latestDraftSaveRef.current() + }, DRAFT_AUTOSAVE_WAIT), + [], + ) const saveDraft = useCallback(async () => { - if (!enabledRef.current) - return + if (!enabledRef.current) return const configSnapshot = getAgentSoulDraft() const hasEffectiveModelChange = !isEqual(configSnapshot.model, baseConfigRef.current?.model) debouncedSaveDraft.cancel?.() - if (!store.get(isAgentComposerDirtyAtom) && !hasEffectiveModelChange) - return + if (!store.get(isAgentComposerDirtyAtom) && !hasEffectiveModelChange) return return saveComposer(configSnapshot) }, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store]) - const saveAgentSoulConfig = useCallback(async (agentSoulConfig: AgentSoulConfig) => { - debouncedSaveDraft.cancel?.() - return saveComposer(agentSoulConfig) - }, [debouncedSaveDraft, saveComposer]) + const saveAgentSoulConfig = useCallback( + async (agentSoulConfig: AgentSoulConfig) => { + debouncedSaveDraft.cancel?.() + return saveComposer(agentSoulConfig) + }, + [debouncedSaveDraft, saveComposer], + ) useEffect(() => { return store.sub(agentComposerDraftAtom, () => { @@ -164,10 +178,10 @@ export function useWorkflowInlineAgentConfigureSync({ const agentSoulDraftKey = JSON.stringify(agentSoulDraft) if ( - !enabledRef.current - || !autoSaveEnabled - || !store.get(isAgentComposerDirtyAtom) - || lastAutosavedDraftKeyRef.current === agentSoulDraftKey + !enabledRef.current || + !autoSaveEnabled || + !store.get(isAgentComposerDirtyAtom) || + lastAutosavedDraftKeyRef.current === agentSoulDraftKey ) { return } @@ -178,8 +192,7 @@ export function useWorkflowInlineAgentConfigureSync({ useEffect(() => { return () => { - if (autoSaveEnabled) - debouncedSaveDraft.flush?.() + if (autoSaveEnabled) debouncedSaveDraft.flush?.() } }, [autoSaveEnabled, debouncedSaveDraft]) diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx index 8a6fa0106ede6d..794f89349bd424 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx @@ -1,4 +1,7 @@ -import type { AgentSoulConfig, WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { + AgentSoulConfig, + WorkflowAgentComposerResponse, +} from '@dify/contracts/api/console/apps/types.gen' import type { ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -48,10 +51,12 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/orchestrate', asy <input aria-label="local composer draft" value={draft.prompt} - onChange={event => setDraft({ - ...draft, - prompt: event.currentTarget.value, - })} + onChange={(event) => + setDraft({ + ...draft, + prompt: event.currentTarget.value, + }) + } /> {props.bottomAction} </div> @@ -60,20 +65,27 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/orchestrate', asy } }) -vi.mock('@/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar', () => ({ - AgentBuildDraftBar: (props: { - changesCount: number - disabled?: boolean - onApply: () => void - onDiscard: () => void - }) => ( - <div role="region" aria-label="build-draft-bar"> - <span>{`changes:${props.changesCount}`}</span> - <button type="button" disabled={props.disabled} onClick={props.onApply}>apply build draft</button> - <button type="button" disabled={props.disabled} onClick={props.onDiscard}>discard build draft</button> - </div> - ), -})) +vi.mock( + '@/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar', + () => ({ + AgentBuildDraftBar: (props: { + changesCount: number + disabled?: boolean + onApply: () => void + onDiscard: () => void + }) => ( + <div role="region" aria-label="build-draft-bar"> + <span>{`changes:${props.changesCount}`}</span> + <button type="button" disabled={props.disabled} onClick={props.onApply}> + apply build draft + </button> + <button type="button" disabled={props.disabled} onClick={props.onDiscard}> + discard build draft + </button> + </div> + ), + }), +) vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-background', () => ({ AgentBuildPanelBackground: () => null, @@ -110,7 +122,12 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-cha > send build message </button> - <button type="button" onClick={() => props.onConversationComplete?.('build-conversation-new', 'workflow-run-1')}> + <button + type="button" + onClick={() => + props.onConversationComplete?.('build-conversation-new', 'workflow-run-1') + } + > complete build conversation </button> <button @@ -231,31 +248,36 @@ vi.mock('@/service/client', () => ({ get: { queryOptions: () => ({ queryKey: ['sandbox-info'], - queryFn: () => Promise.resolve({ - workspace_cwd: '.', - }), + queryFn: () => + Promise.resolve({ + workspace_cwd: '.', + }), }), }, files: { get: { queryOptions: () => ({ queryKey: ['sandbox-files'], - queryFn: () => Promise.resolve({ - entries: [{ - name: 'result.txt', - type: 'file', - }], - path: '.', - }), + queryFn: () => + Promise.resolve({ + entries: [ + { + name: 'result.txt', + type: 'file', + }, + ], + path: '.', + }), }), }, read: { get: { queryOptions: () => ({ queryKey: ['sandbox-file'], - queryFn: () => Promise.resolve({ - text: 'result', - }), + queryFn: () => + Promise.resolve({ + text: 'result', + }), }), }, }, @@ -280,22 +302,26 @@ vi.mock('@/service/client', () => ({ key: () => ['workflow-agent-node-sandbox-files'], queryOptions: () => ({ queryKey: ['workflow-agent-node-sandbox-files'], - queryFn: () => Promise.resolve({ - entries: [{ - name: 'result.txt', - type: 'file', - }], - path: '.', - }), + queryFn: () => + Promise.resolve({ + entries: [ + { + name: 'result.txt', + type: 'file', + }, + ], + path: '.', + }), }), }, read: { get: { queryOptions: () => ({ queryKey: ['workflow-agent-node-sandbox-file'], - queryFn: () => Promise.resolve({ - text: 'result', - }), + queryFn: () => + Promise.resolve({ + text: 'result', + }), }), }, }, @@ -316,7 +342,9 @@ vi.mock('@/service/client', () => ({ byNodeId: { agentComposer: { get: { - queryKey: ({ input }: { + queryKey: ({ + input, + }: { input: { params: { app_id: string @@ -412,18 +440,24 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { }) mocks.saveDraft.mockResolvedValue(createInlineComposerState()) mocks.saveAgentSoulConfig.mockResolvedValue(createInlineComposerState()) - mocks.uploadAgentSandboxFile.mockResolvedValue({ url: 'https://example.com/agent-sandbox-file' }) - mocks.uploadWorkflowSandboxFile.mockResolvedValue({ url: 'https://example.com/workflow-sandbox-file' }) + mocks.uploadAgentSandboxFile.mockResolvedValue({ + url: 'https://example.com/agent-sandbox-file', + }) + mocks.uploadWorkflowSandboxFile.mockResolvedValue({ + url: 'https://example.com/workflow-sandbox-file', + }) }) afterEach(() => { vi.useRealTimers() }) - function renderWorkspace(props: { - inlineComposerState?: WorkflowAgentComposerResponse - onSaveInlineToRoster?: () => void - } = {}) { + function renderWorkspace( + props: { + inlineComposerState?: WorkflowAgentComposerResponse + onSaveInlineToRoster?: () => void + } = {}, + ) { const queryClient = new QueryClient() return render( @@ -441,9 +475,11 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { } async function restartCurrentChat() { - fireEvent.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.preview.restart', - })) + fireEvent.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.preview.restart', + }), + ) const confirmDialog = await screen.findByRole('alertdialog', { name: 'agentV2.agentDetail.configure.clearSessionConfirm.title', @@ -459,9 +495,15 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { onSaveInlineToRoster: vi.fn(), }) - expect(await screen.findByRole('button', { name: 'common.operation.more' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.operation.save' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument() + expect( + await screen.findByRole('button', { name: 'common.operation.more' }), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.save' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.cancel' }), + ).not.toBeInTheDocument() }) it('should show the working directory panel when the header action is clicked', async () => { @@ -473,60 +515,83 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { }), }) - fireEvent.click(await screen.findByRole('button', { - name: 'send build message', - })) - await waitFor(() => expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new')) - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.workingDirectory.open', - })).not.toBeInTheDocument() - - fireEvent.click(await screen.findByRole('button', { - name: 'complete build conversation', - })) - fireEvent.click(await screen.findByRole('button', { - name: 'send build message', - })) - expect(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.workingDirectory.open', - })).toBeInTheDocument() - - fireEvent.click(await screen.findByRole('button', { - name: 'agentV2.agentDetail.configure.workingDirectory.open', - })) - - expect(await screen.findByRole('dialog', { - name: 'agentV2.agentDetail.configure.workingDirectory.title', - })).toBeInTheDocument() + fireEvent.click( + await screen.findByRole('button', { + name: 'send build message', + }), + ) + await waitFor(() => + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:build-conversation-new', + ), + ) + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.workingDirectory.open', + }), + ).not.toBeInTheDocument() + + fireEvent.click( + await screen.findByRole('button', { + name: 'complete build conversation', + }), + ) + fireEvent.click( + await screen.findByRole('button', { + name: 'send build message', + }), + ) + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.workingDirectory.open', + }), + ).toBeInTheDocument() + + fireEvent.click( + await screen.findByRole('button', { + name: 'agentV2.agentDetail.configure.workingDirectory.open', + }), + ) + + expect( + await screen.findByRole('dialog', { + name: 'agentV2.agentDetail.configure.workingDirectory.title', + }), + ).toBeInTheDocument() expect(await screen.findAllByText('result.txt')).not.toHaveLength(0) }) }) describe('Build Chat', () => { it('should save the workflow agent draft and write that snapshot into the build draft before starting build chat', async () => { - mocks.saveDraft.mockResolvedValue(createInlineComposerState({ - snapshotId: 'snapshot-saved', - systemPrompt: 'Saved workflow snapshot prompt.', - })) + mocks.saveDraft.mockResolvedValue( + createInlineComposerState({ + snapshotId: 'snapshot-saved', + systemPrompt: 'Saved workflow snapshot prompt.', + }), + ) renderWorkspace() fireEvent.click(await screen.findByRole('button', { name: 'send build message' })) await waitFor(() => expect(mocks.saveDraft).toHaveBeenCalled()) - expect(mocks.saveBuildDraft).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - body: { - variant: 'agent_app', - save_strategy: 'save_to_current_version', - agent_soul: expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Saved workflow snapshot prompt.', + expect(mocks.saveBuildDraft).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + body: { + variant: 'agent_app', + save_strategy: 'save_to_current_version', + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Saved workflow snapshot prompt.', + }), }), - }), + }, }, - }, expect.any(Object)) + expect.any(Object), + ) const saveDraftCallOrder = mocks.saveDraft.mock.invocationCallOrder[0] const saveBuildDraftCallOrder = mocks.saveBuildDraft.mock.invocationCallOrder[0] expect(saveDraftCallOrder).toBeDefined() @@ -539,10 +604,12 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { }) it('should use the saved build draft response as the build chat source', async () => { - mocks.saveDraft.mockResolvedValue(createInlineComposerState({ - snapshotId: 'snapshot-saved', - systemPrompt: 'Saved workflow snapshot prompt.', - })) + mocks.saveDraft.mockResolvedValue( + createInlineComposerState({ + snapshotId: 'snapshot-saved', + systemPrompt: 'Saved workflow snapshot prompt.', + }), + ) mocks.saveBuildDraft.mockResolvedValue({ agent_soul: { schema_version: 1, @@ -560,7 +627,9 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { await waitFor(() => { expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes') }) - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('prompt:Normalized build draft prompt.') + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'prompt:Normalized build draft prompt.', + ) }) it('should enter build draft mode without resetting the current inline build chat', async () => { @@ -588,22 +657,34 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { }) renderWorkspace() - expect(await screen.findByRole('button', { - name: 'agentV2.agentDetail.configure.preview.restart', - })).toBeDisabled() + expect( + await screen.findByRole('button', { + name: 'agentV2.agentDetail.configure.preview.restart', + }), + ).toBeDisabled() fireEvent.click(await screen.findByRole('button', { name: 'send build message' })) - await waitFor(() => expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes')) - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:yes') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:yes') + await waitFor(() => + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes'), + ) + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:build-conversation-new', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:yes', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:yes', + ) expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled() expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled() - expect(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.preview.restart', - })).toBeDisabled() + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.preview.restart', + }), + ).toBeDisabled() vi.useFakeTimers() fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' })) @@ -614,12 +695,16 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { expect(mocks.loadBuildDraft).toHaveBeenCalled() expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes') - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new') + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:build-conversation-new', + ) expect(screen.getByRole('button', { name: 'apply build draft' })).toBeEnabled() expect(screen.getByRole('button', { name: 'discard build draft' })).toBeEnabled() - expect(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.preview.restart', - })).toBeEnabled() + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.preview.restart', + }), + ).toBeEnabled() }) it('should seed inline build chat from the workflow composer debug conversation when reopening build draft', async () => { @@ -641,10 +726,14 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { }) expect(await screen.findByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument() - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:inline-debug-conversation-1') - expect(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.preview.restart', - })).toBeEnabled() + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:inline-debug-conversation-1', + ) + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.preview.restart', + }), + ).toBeEnabled() }) it('should keep restart disabled when workflow composer debug conversation has no messages', async () => { @@ -656,10 +745,14 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { }), }) - expect(await screen.findByRole('region', { name: 'build-chat' })).toHaveTextContent('build:inline-debug-conversation-1') - expect(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.preview.restart', - })).toBeDisabled() + expect(await screen.findByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:inline-debug-conversation-1', + ) + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.preview.restart', + }), + ).toBeDisabled() }) it('should refresh inline debug conversation when restarting an existing debug chat', async () => { @@ -671,14 +764,21 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { }), }) - expect(await screen.findByRole('region', { name: 'build-chat' })).toHaveTextContent('build:inline-debug-conversation-1') + expect(await screen.findByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:inline-debug-conversation-1', + ) await restartCurrentChat() - await waitFor(() => expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - }, expect.any(Object))) + await waitFor(() => + expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + }, + expect.any(Object), + ), + ) expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') }) @@ -709,15 +809,19 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { fireEvent.click(await screen.findByRole('button', { name: 'send build message' })) - await waitFor(() => expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()) + await waitFor(() => + expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled(), + ) fireEvent.click(screen.getByRole('button', { name: 'fail build conversation' })) expect(screen.getByRole('button', { name: 'apply build draft' })).toBeEnabled() expect(screen.getByRole('button', { name: 'discard build draft' })).toBeEnabled() - expect(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.preview.restart', - })).toBeEnabled() + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.preview.restart', + }), + ).toBeEnabled() }) it('should not let a previous inline build completion refresh unlock a new build run', async () => { @@ -737,7 +841,9 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { const initialBuildDraftLoadCount = mocks.loadBuildDraft.mock.calls.length fireEvent.click(await screen.findByRole('button', { name: 'send build message' })) - await waitFor(() => expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()) + await waitFor(() => + expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled(), + ) vi.useFakeTimers() fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' })) @@ -780,21 +886,33 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { renderWorkspace() fireEvent.click(await screen.findByRole('button', { name: 'send build message' })) - await waitFor(() => expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new')) + await waitFor(() => + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:build-conversation-new', + ), + ) fireEvent.click(screen.getByRole('button', { name: 'fail build conversation' })) await restartCurrentChat() - await waitFor(() => expect(mocks.deleteBuildDraft).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - }, expect.any(Object))) - expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', + await waitFor(() => + expect(mocks.deleteBuildDraft).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + }, + expect.any(Object), + ), + ) + expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, }, - }, expect.any(Object)) + expect.any(Object), + ) expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') }) @@ -813,21 +931,31 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { fireEvent.click(await screen.findByRole('button', { name: 'apply build draft' })) - await waitFor(() => expect(mocks.saveAgentSoulConfig).toHaveBeenCalledWith(expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Applied inline build prompt', - }), - }))) - expect(mocks.deleteBuildDraft).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', + await waitFor(() => + expect(mocks.saveAgentSoulConfig).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Applied inline build prompt', + }), + }), + ), + ) + expect(mocks.deleteBuildDraft).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, }, - }, expect.any(Object)) - expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', + expect.any(Object), + ) + expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, }, - }, expect.any(Object)) + expect.any(Object), + ) expect(mocks.applyBuildDraft).not.toHaveBeenCalled() }) @@ -849,10 +977,16 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { await waitFor(() => expect(mocks.saveAgentSoulConfig).toHaveBeenCalled()) expect(mocks.deleteBuildDraft).toHaveBeenCalled() - await waitFor(() => expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument()) + await waitFor(() => + expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument(), + ) expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:no') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:no', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:no', + ) }) it('should refresh the inline build debug conversation when discarding the build draft', async () => { @@ -870,16 +1004,24 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { fireEvent.click(await screen.findByRole('button', { name: 'discard build draft' })) - await waitFor(() => expect(mocks.deleteBuildDraft).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - }, expect.any(Object))) - expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', + await waitFor(() => + expect(mocks.deleteBuildDraft).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + }, + expect.any(Object), + ), + ) + expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, }, - }, expect.any(Object)) + expect.any(Object), + ) expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') }) @@ -900,10 +1042,16 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { fireEvent.click(await screen.findByRole('button', { name: 'discard build draft' })) await waitFor(() => expect(mocks.deleteBuildDraft).toHaveBeenCalled()) - await waitFor(() => expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument()) + await waitFor(() => + expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument(), + ) expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:no') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:no', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:no', + ) }) it('should keep the composer session mounted when the inline snapshot changes', async () => { @@ -926,7 +1074,9 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { </QueryClientProvider>, ) - expect(screen.getByRole('textbox', { name: 'local composer draft' })).toHaveValue('draft still mounted') + expect(screen.getByRole('textbox', { name: 'local composer draft' })).toHaveValue( + 'draft still mounted', + ) }) }) }) diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-roster-field.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-roster-field.spec.tsx index b05418bbd12cc2..13a577cd173bbd 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-roster-field.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-roster-field.spec.tsx @@ -36,7 +36,9 @@ describe('AgentRosterField', () => { const user = userEvent.setup() renderInlineRosterField() - const trigger = screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }) + const trigger = screen.getByRole('button', { + name: /^workflow\.nodes\.agent\.roster\.openPanel/, + }) await user.click(trigger) expect(screen.getByRole('dialog', { name: 'Inline Workspace' })).toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx index 131378d292b99c..bdcd4ded475c1e 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx @@ -30,22 +30,21 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({ onSelect, open, }: { - initialEmoji?: { icon: string, background: string } - onSelect: (payload: { type: 'emoji', icon: string, background: string }) => void + initialEmoji?: { icon: string; background: string } + onSelect: (payload: { type: 'emoji'; icon: string; background: string }) => void open: boolean - }) => open - ? ( - <div> - <span>{`${initialEmoji?.icon}:${initialEmoji?.background}`}</span> - <button - type="button" - onClick={() => onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })} - > - Select brain icon - </button> - </div> - ) - : null, + }) => + open ? ( + <div> + <span>{`${initialEmoji?.icon}:${initialEmoji?.background}`}</span> + <button + type="button" + onClick={() => onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })} + > + Select brain icon + </button> + </div> + ) : null, })) vi.mock('@/service/client', () => ({ @@ -115,32 +114,43 @@ describe('SaveInlineAgentToRosterDialog', () => { renderDialog() const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' }) - const nameInput = within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }) + const nameInput = within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.nameLabel', + }) expect(nameInput).toHaveValue('') - expect(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel common.label.optional' })).toHaveValue('Tender Analyst') - expect(within(dialog).getByPlaceholderText('agentV2.roster.createForm.descriptionPlaceholder')).toHaveValue('Drafts tender clarifications.') + expect( + within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.roleLabel common.label.optional', + }), + ).toHaveValue('Tender Analyst') + expect( + within(dialog).getByPlaceholderText('agentV2.roster.createForm.descriptionPlaceholder'), + ).toHaveValue('Drafts tender clarifications.') await user.type(nameInput, 'Roster Tender Agent') await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) - expect(mutationMock.mutate).toHaveBeenCalledWith({ - params: { - app_id: 'app-1', - node_id: 'node-1', - }, - body: { - variant: 'workflow', - save_strategy: 'save_to_roster', - new_agent_name: 'Roster Tender Agent', - description: 'Drafts tender clarifications.', - role: 'Tender Analyst', - icon_type: 'emoji', - icon: '🤖', - icon_background: '#F5F3FF', + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + app_id: 'app-1', + node_id: 'node-1', + }, + body: { + variant: 'workflow', + save_strategy: 'save_to_roster', + new_agent_name: 'Roster Tender Agent', + description: 'Drafts tender clarifications.', + role: 'Tender Analyst', + icon_type: 'emoji', + icon: '🤖', + icon_background: '#F5F3FF', + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] expect(mutationOptions).not.toHaveProperty('onError') }) @@ -155,27 +165,33 @@ describe('SaveInlineAgentToRosterDialog', () => { }) const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' }) - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), 'Roster Tender Agent') + await user.type( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + 'Roster Tender Agent', + ) await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) - expect(mutationMock.mutate).toHaveBeenCalledWith({ - params: { - app_id: 'app-1', - node_id: 'node-1', - }, - body: { - variant: 'workflow', - save_strategy: 'save_to_roster', - new_agent_name: 'Roster Tender Agent', - description: 'Drafts tender clarifications.', - role: 'Tender Analyst', - icon_type: 'emoji', - icon: '🧸', - icon_background: '#F5F3FF', + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + app_id: 'app-1', + node_id: 'node-1', + }, + body: { + variant: 'workflow', + save_strategy: 'save_to_roster', + new_agent_name: 'Roster Tender Agent', + description: 'Drafts tender clarifications.', + role: 'Tender Analyst', + icon_type: 'emoji', + icon: '🧸', + icon_background: '#F5F3FF', + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) }) it('initializes the icon picker from the inline agent and submits changed icon fields', async () => { @@ -183,31 +199,39 @@ describe('SaveInlineAgentToRosterDialog', () => { renderDialog() const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' }) - await user.click(within(dialog).getByRole('button', { name: 'agentV2.roster.saveToRosterForm.changeIcon' })) + await user.click( + within(dialog).getByRole('button', { name: 'agentV2.roster.saveToRosterForm.changeIcon' }), + ) expect(screen.getByText('🤖:#F5F3FF')).toBeInTheDocument() await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' })) - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), 'Roster Tender Agent') + await user.type( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + 'Roster Tender Agent', + ) await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) - expect(mutationMock.mutate).toHaveBeenCalledWith({ - params: { - app_id: 'app-1', - node_id: 'node-1', - }, - body: { - variant: 'workflow', - save_strategy: 'save_to_roster', - new_agent_name: 'Roster Tender Agent', - description: 'Drafts tender clarifications.', - role: 'Tender Analyst', - icon_type: 'emoji', - icon: '🧠', - icon_background: '#E0F2FE', + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + app_id: 'app-1', + node_id: 'node-1', + }, + body: { + variant: 'workflow', + save_strategy: 'save_to_roster', + new_agent_name: 'Roster Tender Agent', + description: 'Drafts tender clarifications.', + role: 'Tender Analyst', + icon_type: 'emoji', + icon: '🧠', + icon_background: '#E0F2FE', + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) }) }) diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-advanced-settings.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-advanced-settings.tsx index 917b21792a800e..10b641252411ee 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-advanced-settings.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-advanced-settings.tsx @@ -1,8 +1,4 @@ -import { - Collapsible, - CollapsiblePanel, - CollapsibleTrigger, -} from '@langgenius/dify-ui/collapsible' +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible' import { useTranslation } from 'react-i18next' export function AgentAdvancedSettings() { @@ -12,7 +8,7 @@ export function AgentAdvancedSettings() { <Collapsible className="border-b border-divider-subtle py-2"> <CollapsibleTrigger className="group h-8 min-h-0 justify-start gap-0 rounded-none px-4 py-0 hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-secondary data-panel-open:text-text-secondary"> <span className="min-w-0 truncate system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['nodes.agent.advancedSetting'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.advancedSetting'], { ns: 'workflow' })} </span> <span aria-hidden="true" diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx index 772108774b75a2..aa664af9f8c38d 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx @@ -1,7 +1,14 @@ 'use client' -import type { AgentAppDetailWithSite, AgentConfigSnapshotSummaryResponse, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' -import type { AgentComposerBindingResponse, WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { + AgentAppDetailWithSite, + AgentConfigSnapshotSummaryResponse, + AgentSoulConfig, +} from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentComposerBindingResponse, + WorkflowAgentComposerResponse, +} from '@dify/contracts/api/console/apps/types.gen' import { DropdownMenu, DropdownMenuContent, @@ -16,18 +23,33 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useDefaultModel, useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' -import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions' +import { + useDefaultModel, + useTextGenerationCurrentProviderAndModelAndModelList, +} from '@/app/components/header/account-setting/model-provider-page/hooks' +import { + agentSoulConfigToFormState, + formStateToAgentSoulConfig, +} from '@/features/agent-v2/agent-composer/conversions' import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' -import { agentComposerDraftAtom, rebaseAgentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store' +import { + agentComposerDraftAtom, + rebaseAgentComposerDraftAtom, +} from '@/features/agent-v2/agent-composer/store' import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model' import { AgentOrchestratePanel } from '@/features/agent-v2/agent-detail/configure/components/orchestrate' import { AgentBuildDraftBar } from '@/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar' import { AgentBuildPanelBackground } from '@/features/agent-v2/agent-detail/configure/components/preview/build-background' import { AgentPreviewHeader } from '@/features/agent-v2/agent-detail/configure/components/preview/header' -import { invalidateAgentWorkingDirectoryFiles, useAgentWorkingDirectoryPanel } from '@/features/agent-v2/agent-detail/configure/components/preview/hook/use-working-directory-panel' +import { + invalidateAgentWorkingDirectoryFiles, + useAgentWorkingDirectoryPanel, +} from '@/features/agent-v2/agent-detail/configure/components/preview/hook/use-working-directory-panel' import { AgentConfigureRightPanelChat } from '@/features/agent-v2/agent-detail/configure/components/preview/right-panel-chat' -import { AgentConfigurePreviewSurface, AgentConfigureWorkspace } from '@/features/agent-v2/agent-detail/configure/components/workspace' +import { + AgentConfigurePreviewSurface, + AgentConfigureWorkspace, +} from '@/features/agent-v2/agent-detail/configure/components/workspace' import { agentConfigureConversationIdsAtom, agentConfigureRightPanelChatModeAtom, @@ -36,7 +58,10 @@ import { resetAgentConfigureConversationAtom, setAgentConfigureConversationIdAtom, } from '@/features/agent-v2/agent-detail/configure/state' -import { useAgentConfigureBuildDraftActions, useAgentConfigureBuildDraftData } from '@/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft' +import { + useAgentConfigureBuildDraftActions, + useAgentConfigureBuildDraftData, +} from '@/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft' import { consoleQuery } from '@/service/client' import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config' @@ -57,26 +82,31 @@ type WorkflowInlineAgentConfigureWorkspaceProps = { open: boolean } -export function WorkflowRosterAgentOrchestratePanelContent(props: WorkflowRosterAgentOrchestratePanelContentProps) { - const { - agentId, - nodeId, - open, - } = props - const rosterComposerQuery = useQuery(consoleQuery.agent.byAgentId.composer.get.queryOptions({ - input: open && agentId - ? { - params: { - agent_id: agentId, - }, - } - : skipToken, - })) +export function WorkflowRosterAgentOrchestratePanelContent( + props: WorkflowRosterAgentOrchestratePanelContentProps, +) { + const { agentId, nodeId, open } = props + const rosterComposerQuery = useQuery( + consoleQuery.agent.byAgentId.composer.get.queryOptions({ + input: + open && agentId + ? { + params: { + agent_id: agentId, + }, + } + : skipToken, + }), + ) const composerState = rosterComposerQuery.data const agentSoulConfig = composerState?.agent_soul - const activeConfigSnapshot = ('active_config_snapshot' in (composerState ?? {})) - ? composerState?.active_config_snapshot as AgentConfigSnapshotSummaryResponse | null | undefined - : undefined + const activeConfigSnapshot = + 'active_config_snapshot' in (composerState ?? {}) + ? (composerState?.active_config_snapshot as + | AgentConfigSnapshotSummaryResponse + | null + | undefined) + : undefined if (!agentId || !agentSoulConfig) { return ( @@ -120,7 +150,8 @@ function WorkflowRosterAgentOrchestratePanelContentInner({ } | null } }) { - const { currentModel, setConfigureModel, textGenerationModelList } = useAgentOrchestrateModelOptions() + const { currentModel, setConfigureModel, textGenerationModelList } = + useAgentOrchestrateModelOptions() return ( <AgentOrchestratePanel @@ -141,17 +172,19 @@ function WorkflowRosterAgentOrchestratePanelContentInner({ ) } -export function WorkflowInlineAgentConfigureWorkspace(props: WorkflowInlineAgentConfigureWorkspaceProps) { - const { - agentId, - inlineComposerState, - nodeId, - } = props +export function WorkflowInlineAgentConfigureWorkspace( + props: WorkflowInlineAgentConfigureWorkspaceProps, +) { + const { agentId, inlineComposerState, nodeId } = props const composerState = inlineComposerState const agentSoulConfig = composerState?.agent_soul as AgentSoulConfig | undefined - const activeConfigSnapshot = ('active_config_snapshot' in (composerState ?? {})) - ? composerState?.active_config_snapshot as AgentConfigSnapshotSummaryResponse | null | undefined - : undefined + const activeConfigSnapshot = + 'active_config_snapshot' in (composerState ?? {}) + ? (composerState?.active_config_snapshot as + | AgentConfigSnapshotSummaryResponse + | null + | undefined) + : undefined if (!agentId || !agentSoulConfig) { return ( @@ -164,7 +197,11 @@ export function WorkflowInlineAgentConfigureWorkspace(props: WorkflowInlineAgent const composerSessionKey = `${nodeId}:${agentId}` return ( - <ScopeProvider key={composerSessionKey} atoms={agentConfigureScopedAtoms} name="WorkflowInlineAgentConfigure"> + <ScopeProvider + key={composerSessionKey} + atoms={agentConfigureScopedAtoms} + name="WorkflowInlineAgentConfigure" + > <WorkflowInlineAgentConfigureWorkspaceComposerScope {...props} activeConfigSnapshot={activeConfigSnapshot} @@ -209,10 +246,13 @@ function WorkflowInlineAgentConfigureWorkspaceComposerScope({ return ( <ScopeProvider atoms={[ - [agentConfigureConversationIdsAtom, { - build: props.inlineComposerState?.debug_conversation_id ?? null, - preview: null, - }], + [ + agentConfigureConversationIdsAtom, + { + build: props.inlineComposerState?.debug_conversation_id ?? null, + preview: null, + }, + ], ]} name="WorkflowInlineAgentConfigureConversation" > @@ -257,7 +297,9 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ const composerState = inlineComposerState const [buildDraftActionsDisabled, setBuildDraftActionsDisabled] = useState(false) const [clearPreviewChat, setClearPreviewChat] = useState(false) - const [completedBuildConversationId, setCompletedBuildConversationId] = useState<string | null>(null) + const [completedBuildConversationId, setCompletedBuildConversationId] = useState<string | null>( + null, + ) const [workflowRunId, setWorkflowRunId] = useState<string | null>(null) const conversationIds = useAtomValue(agentConfigureConversationIdsAtom) const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom) @@ -271,7 +313,8 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ const resetConversation = useSetAtom(resetAgentConfigureConversationAtom) const setConversationId = useSetAtom(setAgentConfigureConversationIdAtom) const rebaseComposerDraft = useSetAtom(rebaseAgentComposerDraftAtom) - const { currentModel, setConfigureModel, textGenerationModelList } = useAgentOrchestrateModelOptions() + const { currentModel, setConfigureModel, textGenerationModelList } = + useAgentOrchestrateModelOptions() const [isApplyingInlineBuildDraft, setIsApplyingInlineBuildDraft] = useState(false) const { draftSavedAt, saveAgentSoulConfig, saveDraft } = useWorkflowInlineAgentConfigureSync({ nodeId, @@ -280,9 +323,9 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ onDraftSaved: (composerState) => { const binding = composerState.binding if ( - binding?.binding_type !== 'inline_agent' - || !binding.agent_id - || !binding.current_snapshot_id + binding?.binding_type !== 'inline_agent' || + !binding.agent_id || + !binding.current_snapshot_id ) { return } @@ -291,58 +334,62 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ }, enabled: open && !!agentSoulConfig && !buildDraft.isActive, }) - const refreshDebugConversationMutation = useMutation(consoleQuery.agent.byAgentId.debugConversation.refresh.post.mutationOptions({ - onSuccess: ({ - debug_conversation_has_messages, - debug_conversation_id, - debug_conversation_message_count, - }) => { - queryClient.setQueryData<AgentAppDetailWithSite | undefined>( - consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }), - (agentDetail) => { - if (!agentDetail) - return agentDetail + const refreshDebugConversationMutation = useMutation( + consoleQuery.agent.byAgentId.debugConversation.refresh.post.mutationOptions({ + onSuccess: ({ + debug_conversation_has_messages, + debug_conversation_id, + debug_conversation_message_count, + }) => { + queryClient.setQueryData<AgentAppDetailWithSite | undefined>( + consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }), + (agentDetail) => { + if (!agentDetail) return agentDetail - return { - ...agentDetail, - debug_conversation_has_messages, - debug_conversation_id, - debug_conversation_message_count, - } - }, - ) - if (!appId) - return - - queryClient.setQueryData<WorkflowAgentComposerResponse | undefined>( - consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ - input: { - params: { - app_id: appId, - node_id: nodeId, - }, - }, - }), - composerState => composerState - ? { - ...composerState, + return { + ...agentDetail, debug_conversation_has_messages, debug_conversation_id, debug_conversation_message_count, } - : composerState, - ) - }, - })) + }, + ) + if (!appId) return + + queryClient.setQueryData<WorkflowAgentComposerResponse | undefined>( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ + input: { + params: { + app_id: appId, + node_id: nodeId, + }, + }, + }), + (composerState) => + composerState + ? { + ...composerState, + debug_conversation_has_messages, + debug_conversation_id, + debug_conversation_message_count, + } + : composerState, + ) + }, + }), + ) const { mutateAsync: refreshDebugConversationRequestAsync, isPending: isRefreshingDebugConversation, } = refreshDebugConversationMutation - const refreshDebugConversationInput = useCallback(() => ({ - params: { - agent_id: agentId, - }, - }), [agentId]) + const refreshDebugConversationInput = useCallback( + () => ({ + params: { + agent_id: agentId, + }, + }), + [agentId], + ) const refreshDebugConversationAsync = useCallback(() => { return refreshDebugConversationRequestAsync(refreshDebugConversationInput()) }, [refreshDebugConversationInput, refreshDebugConversationRequestAsync]) @@ -353,12 +400,15 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ setWorkflowRunId(null) setClearPreviewChat(true) }, [refreshDebugConversationAsync, setClearPreviewChat, setConversationId, setWorkflowRunId]) - const rebaseComposerDraftFromSoulConfig = useCallback((agentSoulConfig?: AgentSoulConfig) => { - rebaseComposerDraft({ - draft: agentSoulConfigToFormState(agentSoulConfig), - originalConfig: agentSoulConfig, - }) - }, [rebaseComposerDraft]) + const rebaseComposerDraftFromSoulConfig = useCallback( + (agentSoulConfig?: AgentSoulConfig) => { + rebaseComposerDraft({ + draft: agentSoulConfigToFormState(agentSoulConfig), + originalConfig: agentSoulConfig, + }) + }, + [rebaseComposerDraft], + ) const buildDraftActions = useAgentConfigureBuildDraftActions({ agentId, buildDraftAgentSoulConfig: buildDraft.agentSoulConfig, @@ -385,13 +435,21 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ }, }, }) - const { mutateAsync: saveBuildDraft } = useMutation(consoleQuery.agent.byAgentId.buildDraft.put.mutationOptions()) - const discardBuildDraftMutation = useMutation(consoleQuery.agent.byAgentId.buildDraft.delete.mutationOptions()) - const getInlineAgentSoulDraft = useCallback(() => formStateToAgentSoulConfig({ - baseConfig: agentSoulConfig, - formState: jotaiStore.get(agentComposerDraftAtom), - currentModel, - }), [agentSoulConfig, currentModel, jotaiStore]) + const { mutateAsync: saveBuildDraft } = useMutation( + consoleQuery.agent.byAgentId.buildDraft.put.mutationOptions(), + ) + const discardBuildDraftMutation = useMutation( + consoleQuery.agent.byAgentId.buildDraft.delete.mutationOptions(), + ) + const getInlineAgentSoulDraft = useCallback( + () => + formStateToAgentSoulConfig({ + baseConfig: agentSoulConfig, + formState: jotaiStore.get(agentComposerDraftAtom), + currentModel, + }), + [agentSoulConfig, currentModel, jotaiStore], + ) const prepareInlineBuildDraftBeforeRun = useCallback(async () => { cancelBuildDraftRefresh() const configSnapshot = getInlineAgentSoulDraft() @@ -413,32 +471,43 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ rebaseComposerDraftFromSoulConfig(savedBuildAgentSoulConfig) buildDraft.setSoulSourceOverride('build-draft') return savedBuildAgentSoulConfig - }, [agentId, buildDraft, buildDraftQueryOptions.queryKey, cancelBuildDraftRefresh, getInlineAgentSoulDraft, queryClient, rebaseComposerDraftFromSoulConfig, saveBuildDraft, saveDraft]) + }, [ + agentId, + buildDraft, + buildDraftQueryOptions.queryKey, + cancelBuildDraftRefresh, + getInlineAgentSoulDraft, + queryClient, + rebaseComposerDraftFromSoulConfig, + saveBuildDraft, + saveDraft, + ]) const applyInlineBuildDraft = async () => { cancelBuildDraftRefresh() setIsApplyingInlineBuildDraft(true) try { - if (!buildDraft.agentSoulConfig) - return + if (!buildDraft.agentSoulConfig) return const savedComposerState = await saveAgentSoulConfig(buildDraft.agentSoulConfig) - await discardBuildDraftMutation.mutateAsync({ - params: { - agent_id: agentId, - }, - }).catch(() => undefined) + await discardBuildDraftMutation + .mutateAsync({ + params: { + agent_id: agentId, + }, + }) + .catch(() => undefined) await resetBuildChatSession().catch(() => undefined) buildDraft.setSoulSourceOverride('draft') queryClient.removeQueries({ queryKey: buildDraftQueryOptions.queryKey, }) - rebaseComposerDraftFromSoulConfig(savedComposerState?.agent_soul ?? buildDraft.agentSoulConfig) - toast.success(t($ => $['api.actionSuccess'])) - } - catch { - toast.error(t($ => $['api.actionFailed'])) - } - finally { + rebaseComposerDraftFromSoulConfig( + savedComposerState?.agent_soul ?? buildDraft.agentSoulConfig, + ) + toast.success(t(($) => $['api.actionSuccess'])) + } catch { + toast.error(t(($) => $['api.actionFailed'])) + } finally { setIsApplyingInlineBuildDraft(false) } } @@ -456,30 +525,28 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ queryKey: buildDraftQueryOptions.queryKey, }) rebaseComposerDraftFromSoulConfig(agentSoulConfig) - toast.success(t($ => $['api.actionSuccess'])) - } - catch { - toast.error(t($ => $['api.actionFailed'])) + toast.success(t(($) => $['api.actionSuccess'])) + } catch { + toast.error(t(($) => $['api.actionFailed'])) } } - const hasRestartCurrentChatTarget = (inlineComposerState?.debug_conversation_has_messages ?? false) - || buildDraft.isActive - const isRestartCurrentChatDisabled = !hasRestartCurrentChatTarget - || buildDraftActionsDisabled - || isApplyingInlineBuildDraft - || discardBuildDraftMutation.isPending - || isRefreshingDebugConversation - const buildConversationHasAgentResponse = !!conversationIds.build && ( - conversationIds.build === completedBuildConversationId - || ( - conversationIds.build === inlineComposerState?.debug_conversation_id - && (inlineComposerState?.debug_conversation_has_messages ?? false) - ) - ) - const showWorkingDirectoryAction = rightPanelChatMode === 'build' && buildConversationHasAgentResponse + const hasRestartCurrentChatTarget = + (inlineComposerState?.debug_conversation_has_messages ?? false) || buildDraft.isActive + const isRestartCurrentChatDisabled = + !hasRestartCurrentChatTarget || + buildDraftActionsDisabled || + isApplyingInlineBuildDraft || + discardBuildDraftMutation.isPending || + isRefreshingDebugConversation + const buildConversationHasAgentResponse = + !!conversationIds.build && + (conversationIds.build === completedBuildConversationId || + (conversationIds.build === inlineComposerState?.debug_conversation_id && + (inlineComposerState?.debug_conversation_has_messages ?? false))) + const showWorkingDirectoryAction = + rightPanelChatMode === 'build' && buildConversationHasAgentResponse const restartCurrentChat = () => { - if (isRestartCurrentChatDisabled) - return + if (isRestartCurrentChatDisabled) return if (buildDraft.isActive) { void discardInlineBuildDraft() @@ -494,7 +561,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ return ( <AgentConfigureWorkspace className="rounded-[inherit]" - leftPanel={( + leftPanel={ <AgentOrchestratePanel agentId={agentId} appId={appId} @@ -509,25 +576,27 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ isBuildDraftActive={buildDraft.isActive} buildDraftChangedKeys={buildDraft.changedKeys} showPublishBar={false} - bottomAction={buildDraft.isActive - ? ( - <AgentBuildDraftBar - changesCount={buildDraft.changesCount} - disabled={buildDraftActionsDisabled} - isApplying={isApplyingInlineBuildDraft} - isDiscarding={discardBuildDraftMutation.isPending} - onApply={() => { - void applyInlineBuildDraft() - }} - onDiscard={() => { - void discardInlineBuildDraft() - }} - /> - ) - : undefined} - headerAction={onSaveInlineToRoster - ? <WorkflowInlineAgentConfigureMoreAction onSaveInlineToRoster={onSaveInlineToRoster} /> - : undefined} + bottomAction={ + buildDraft.isActive ? ( + <AgentBuildDraftBar + changesCount={buildDraft.changesCount} + disabled={buildDraftActionsDisabled} + isApplying={isApplyingInlineBuildDraft} + isDiscarding={discardBuildDraftMutation.isPending} + onApply={() => { + void applyInlineBuildDraft() + }} + onDiscard={() => { + void discardInlineBuildDraft() + }} + /> + ) : undefined + } + headerAction={ + onSaveInlineToRoster ? ( + <WorkflowInlineAgentConfigureMoreAction onSaveInlineToRoster={onSaveInlineToRoster} /> + ) : undefined + } className="min-w-90" onSelectModel={setConfigureModel} onPublish={() => { @@ -535,11 +604,11 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ }} onOpenVersions={() => undefined} /> - )} - rightPanel={( + } + rightPanel={ <AgentConfigurePreviewSurface background={<AgentBuildPanelBackground visible />} - header={( + header={ <AgentPreviewHeader mode="build" previewEnabled={false} @@ -551,24 +620,28 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ refreshDisabled={isRestartCurrentChatDisabled} showWorkingDirectoryAction={showWorkingDirectoryAction} showChatFeaturesAction={false} - trailingAction={( + trailingAction={ <button type="button" onClick={onClose} className="flex size-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'])} + aria-label={t(($) => $['operation.close'])} > <span aria-hidden className="i-ri-close-line size-4" /> </button> - )} + } /> - )} - chat={( + } + chat={ <AgentConfigureRightPanelChat agentId={agentId} agentIcon={composerState?.agent?.icon} agentIconBackground={composerState?.agent?.icon_background} - agentIconType={composerState?.agent?.icon_type as Parameters<typeof AgentConfigureRightPanelChat>[0]['agentIconType']} + agentIconType={ + composerState?.agent?.icon_type as Parameters< + typeof AgentConfigureRightPanelChat + >[0]['agentIconType'] + } agentName={composerState?.agent?.name} agentSoulConfig={buildDraft.agentSoulConfig} clearChatList={clearPreviewChat} @@ -588,23 +661,23 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ queryClient, workflowRunId: completedWorkflowRunId ?? completedConversationId, }) - buildDraftActions.refreshBuildDraftAfterBuildChat(() => setBuildDraftActionsDisabled(false)) + buildDraftActions.refreshBuildDraftAfterBuildChat(() => + setBuildDraftActionsDisabled(false), + ) } }} onConversationIdChange={(mode, conversationId) => { setConversationId({ mode, conversationId }) }} onWorkflowRunIdChange={(nextWorkflowRunId) => { - if (nextWorkflowRunId) - setWorkflowRunId(nextWorkflowRunId) + if (nextWorkflowRunId) setWorkflowRunId(nextWorkflowRunId) }} onSaveDraftBeforeRun={async () => { setBuildDraftActionsDisabled(true) setWorkflowRunId(null) try { return await prepareInlineBuildDraftBeforeRun() - } - catch (error) { + } catch (error) { setBuildDraftActionsDisabled(false) throw error } @@ -613,9 +686,9 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ setBuildDraftActionsDisabled(false) }} /> - )} + } /> - )} + } sidePanels={workingDirectoryPanel.panel} /> ) @@ -631,20 +704,23 @@ function WorkflowInlineAgentConfigureMoreAction({ return ( <DropdownMenu modal={false}> <DropdownMenuTrigger - render={( + render={ <button type="button" className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" - aria-label={t($ => $['operation.more'])} + aria-label={t(($) => $['operation.more'])} > <span aria-hidden className="i-ri-more-fill size-4" /> </button> - )} + } /> <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44 w-max"> <DropdownMenuItem className="gap-2 whitespace-nowrap" onClick={onSaveInlineToRoster}> - <span aria-hidden className="i-ri-inbox-archive-line size-4 shrink-0 text-text-tertiary" /> - <span>{t($ => $['roster.saveToRoster'], { ns: 'agentV2' })}</span> + <span + aria-hidden + className="i-ri-inbox-archive-line size-4 shrink-0 text-text-tertiary" + /> + <span>{t(($) => $['roster.saveToRoster'], { ns: 'agentV2' })}</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> @@ -653,9 +729,7 @@ function WorkflowInlineAgentConfigureMoreAction({ function useAgentOrchestrateModelOptions() { const [model, setModel] = useAtom(agentComposerModelAtom) - const { - data: defaultTextGenerationModel, - } = useDefaultModel(ModelTypeEnum.textGeneration) + const { data: defaultTextGenerationModel } = useDefaultModel(ModelTypeEnum.textGeneration) const defaultModel = defaultTextGenerationModel ? { provider: defaultTextGenerationModel.provider.provider, @@ -663,9 +737,8 @@ function useAgentOrchestrateModelOptions() { } : undefined const currentModel = model ?? defaultModel - const { - textGenerationModelList, - } = useTextGenerationCurrentProviderAndModelAndModelList(currentModel) + const { textGenerationModelList } = + useTextGenerationCurrentProviderAndModelAndModelList(currentModel) return { currentModel, diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/index.spec.tsx index 114cfb1a0e6e0c..d725278cff93c2 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/index.spec.tsx @@ -35,12 +35,14 @@ describe('AgentOutputVariables', () => { it('should add an object child without opening the parent editor', async () => { const user = userEvent.setup() const onChange = vi.fn() - const outputs: DeclaredOutputConfig[] = [{ - name: 'profile', - type: 'object', - required: true, - description: 'User profile', - }] + const outputs: DeclaredOutputConfig[] = [ + { + name: 'profile', + type: 'object', + required: true, + description: 'User profile', + }, + ] render(<AgentOutputVariables outputs={outputs} onChange={onChange} />) @@ -52,30 +54,36 @@ describe('AgentOutputVariables', () => { await confirmEditorName(user, 'email') - expect(onChange).toHaveBeenCalledWith([{ - name: 'profile', - type: 'object', - required: true, - description: 'User profile', - children: [{ - name: 'email', - type: 'string', - required: false, - }], - }]) + expect(onChange).toHaveBeenCalledWith([ + { + name: 'profile', + type: 'object', + required: true, + description: 'User profile', + children: [ + { + name: 'email', + type: 'string', + required: false, + }, + ], + }, + ]) }) it('should append children to array object items', async () => { const user = userEvent.setup() const onChange = vi.fn() - const outputs: DeclaredOutputConfig[] = [{ - name: 'addresses', - type: 'array', - required: false, - array_item: { - type: 'object', + const outputs: DeclaredOutputConfig[] = [ + { + name: 'addresses', + type: 'array', + required: false, + array_item: { + type: 'object', + }, }, - }] + ] render(<AgentOutputVariables outputs={outputs} onChange={onChange} />) @@ -83,34 +91,42 @@ describe('AgentOutputVariables', () => { await user.click(getAddButton('addresses')) await confirmEditorName(user, 'city') - expect(onChange).toHaveBeenCalledWith([{ - name: 'addresses', - type: 'array', - required: false, - array_item: { - type: 'object', - children: [{ - name: 'city', - type: 'string', - required: false, - }], + expect(onChange).toHaveBeenCalledWith([ + { + name: 'addresses', + type: 'array', + required: false, + array_item: { + type: 'object', + children: [ + { + name: 'city', + type: 'string', + required: false, + }, + ], + }, }, - }]) + ]) }) it('should add nested children under the selected object child', async () => { const user = userEvent.setup() const onChange = vi.fn() - const outputs: DeclaredOutputConfig[] = [{ - name: 'profile', - type: 'object', - required: true, - children: [{ - name: 'contact', + const outputs: DeclaredOutputConfig[] = [ + { + name: 'profile', type: 'object', required: true, - }], - }] + children: [ + { + name: 'contact', + type: 'object', + required: true, + }, + ], + }, + ] render(<AgentOutputVariables outputs={outputs} onChange={onChange} />) @@ -118,42 +134,54 @@ describe('AgentOutputVariables', () => { await user.click(getAddButton('contact')) await confirmEditorName(user, 'email') - expect(onChange).toHaveBeenCalledWith([{ - name: 'profile', - type: 'object', - required: true, - children: [{ - name: 'contact', + expect(onChange).toHaveBeenCalledWith([ + { + name: 'profile', type: 'object', required: true, - children: [{ - name: 'email', - type: 'string', - required: false, - }], - }], - }]) + children: [ + { + name: 'contact', + type: 'object', + required: true, + children: [ + { + name: 'email', + type: 'string', + required: false, + }, + ], + }, + ], + }, + ]) }) it('should edit only the selected nested child', async () => { const user = userEvent.setup() const onChange = vi.fn() - const outputs: DeclaredOutputConfig[] = [{ - name: 'profile', - type: 'object', - required: true, - children: [{ - name: 'contact', + const outputs: DeclaredOutputConfig[] = [ + { + name: 'profile', type: 'object', required: true, - children: [{ - name: 'email', - type: 'string', - required: true, - description: 'Primary email', - }], - }], - }] + children: [ + { + name: 'contact', + type: 'object', + required: true, + children: [ + { + name: 'email', + type: 'string', + required: true, + description: 'Primary email', + }, + ], + }, + ], + }, + ] render(<AgentOutputVariables outputs={outputs} onChange={onChange} />) @@ -164,32 +192,40 @@ describe('AgentOutputVariables', () => { await confirmEditorName(user, 'work_email') - expect(onChange).toHaveBeenCalledWith([{ - name: 'profile', - type: 'object', - required: true, - children: [{ - name: 'contact', + expect(onChange).toHaveBeenCalledWith([ + { + name: 'profile', type: 'object', required: true, - children: [{ - name: 'work_email', - type: 'string', - required: true, - description: 'Primary email', - }], - }], - }]) + children: [ + { + name: 'contact', + type: 'object', + required: true, + children: [ + { + name: 'work_email', + type: 'string', + required: true, + description: 'Primary email', + }, + ], + }, + ], + }, + ]) }) it('should reject editing an output name with dots', async () => { const user = userEvent.setup() const onChange = vi.fn() - const outputs: DeclaredOutputConfig[] = [{ - name: 'summary', - type: 'string', - required: true, - }] + const outputs: DeclaredOutputConfig[] = [ + { + name: 'summary', + type: 'string', + required: true, + }, + ] render(<AgentOutputVariables outputs={outputs} onChange={onChange} />) diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/utils.spec.ts b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/utils.spec.ts index 50382fcd8f9fdb..58be559f86bbcc 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/utils.spec.ts +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/utils.spec.ts @@ -1,9 +1,6 @@ import type { OutputDraft } from '../utils' import { describe, expect, it } from 'vitest' -import { - createOutputFromDraft, - getDefaultValueErrorKey, -} from '../utils' +import { createOutputFromDraft, getDefaultValueErrorKey } from '../utils' const createDraft = (overrides: Partial<OutputDraft> = {}): OutputDraft => ({ children: [], @@ -17,12 +14,16 @@ const createDraft = (overrides: Partial<OutputDraft> = {}): OutputDraft => ({ describe('agent output variables utils', () => { it('should build a declared output from the editable draft', () => { - expect(createOutputFromDraft(createDraft({ - defaultValue: '42', - description: 'A numeric score', - required: true, - type: 'number', - }))).toEqual({ + expect( + createOutputFromDraft( + createDraft({ + defaultValue: '42', + description: 'A numeric score', + required: true, + type: 'number', + }), + ), + ).toEqual({ name: 'summary', type: 'number', required: true, @@ -35,68 +36,100 @@ describe('agent output variables utils', () => { }) it('should validate default values against the declared output type', () => { - expect(getDefaultValueErrorKey(createDraft({ - defaultValue: 'not-json', - type: 'object', - }))).toBe('nodes.agent.outputVars.defaultValueObjectInvalid') + expect( + getDefaultValueErrorKey( + createDraft({ + defaultValue: 'not-json', + type: 'object', + }), + ), + ).toBe('nodes.agent.outputVars.defaultValueObjectInvalid') - expect(getDefaultValueErrorKey(createDraft({ - defaultValue: '{}', - type: 'array[string]', - }))).toBe('nodes.agent.outputVars.defaultValueArrayInvalid') + expect( + getDefaultValueErrorKey( + createDraft({ + defaultValue: '{}', + type: 'array[string]', + }), + ), + ).toBe('nodes.agent.outputVars.defaultValueArrayInvalid') - expect(getDefaultValueErrorKey(createDraft({ - defaultValue: 'yes', - type: 'boolean', - }))).toBe('nodes.agent.outputVars.defaultValueBooleanInvalid') + expect( + getDefaultValueErrorKey( + createDraft({ + defaultValue: 'yes', + type: 'boolean', + }), + ), + ).toBe('nodes.agent.outputVars.defaultValueBooleanInvalid') - expect(getDefaultValueErrorKey(createDraft({ - defaultValue: '[]', - type: 'array[file]', - }))).toBe('nodes.agent.outputVars.defaultValueFileUnsupported') + expect( + getDefaultValueErrorKey( + createDraft({ + defaultValue: '[]', + type: 'array[file]', + }), + ), + ).toBe('nodes.agent.outputVars.defaultValueFileUnsupported') }) it('should preserve object children when building an output', () => { - expect(createOutputFromDraft(createDraft({ - children: [{ - name: 'email', - type: 'string', - required: true, - description: 'User email', - }], + expect( + createOutputFromDraft( + createDraft({ + children: [ + { + name: 'email', + type: 'string', + required: true, + description: 'User email', + }, + ], + name: 'profile', + type: 'object', + }), + ), + ).toMatchObject({ name: 'profile', type: 'object', - }))).toMatchObject({ - name: 'profile', - type: 'object', - children: [{ - name: 'email', - type: 'string', - required: true, - description: 'User email', - }], + children: [ + { + name: 'email', + type: 'string', + required: true, + description: 'User email', + }, + ], }) }) it('should preserve array object item children when building an output', () => { - expect(createOutputFromDraft(createDraft({ - children: [{ - name: 'city', - type: 'string', - required: false, - }], - name: 'addresses', - type: 'array[object]', - }))).toMatchObject({ + expect( + createOutputFromDraft( + createDraft({ + children: [ + { + name: 'city', + type: 'string', + required: false, + }, + ], + name: 'addresses', + type: 'array[object]', + }), + ), + ).toMatchObject({ name: 'addresses', type: 'array', array_item: { type: 'object', - children: [{ - name: 'city', - type: 'string', - required: false, - }], + children: [ + { + name: 'city', + type: 'string', + required: false, + }, + ], }, }) }) diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/edit-card.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/edit-card.tsx index 8ab5b26546383d..dc577d849645eb 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/edit-card.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/edit-card.tsx @@ -27,7 +27,7 @@ function ConfirmHotkeyHint() { return ( <KbdGroup aria-hidden="true"> - {displayKeys.map(key => ( + {displayKeys.map((key) => ( <Kbd key={key} color="white"> {key} </Kbd> @@ -56,17 +56,18 @@ export function OutputEditCard({ const editorRef = useRef<HTMLDivElement>(null) const [draft, setDraft] = useState(state.draft) const trimmedName = draft.name.trim() - const duplicateName = existingOutputs.some((output, index) => output.name === trimmedName && index !== editingIndex) + const duplicateName = existingOutputs.some( + (output, index) => output.name === trimmedName && index !== editingIndex, + ) const nameInvalid = !!trimmedName && !OUTPUT_NAME_PATTERN.test(trimmedName) const hasNameError = duplicateName || nameInvalid const defaultValueErrorKey = getDefaultValueErrorKey(draft) const confirmDisabled = !trimmedName || nameInvalid || duplicateName || !!defaultValueErrorKey function updateDraft(next: Partial<OutputDraft>) { - setDraft(prev => ({ ...prev, ...next })) + setDraft((prev) => ({ ...prev, ...next })) } function handleConfirm() { - if (confirmDisabled) - return + if (confirmDisabled) return onConfirm(createOutputFromDraft(draft, { includeDefaultValue: allowDefaultValue }), state) } useHotkey(CONFIRM_HOTKEY, handleConfirm, { target: editorRef, ignoreInputs: false }) @@ -74,7 +75,7 @@ export function OutputEditCard({ return ( <div ref={editorRef}> <Form - aria-label={t($ => $['nodes.agent.outputVars.editorLabel'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.agent.outputVars.editorLabel'], { ns: 'workflow' })} className="flex flex-col overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg shadow-md shadow-shadow-shadow-4" onSubmit={(event) => { event.preventDefault() @@ -85,7 +86,7 @@ export function OutputEditCard({ <div className="flex h-6 items-center gap-x-2"> <Field name="name" invalid={hasNameError} className="contents"> <FieldLabel className="sr-only"> - {t($ => $['nodes.agent.outputVars.nameLabel'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.outputVars.nameLabel'], { ns: 'workflow' })} </FieldLabel> <FieldControl aria-describedby={hasNameError ? nameErrorId : undefined} @@ -95,46 +96,56 @@ export function OutputEditCard({ pattern={OUTPUT_NAME_PATTERN_SOURCE} size="small" value={draft.name} - placeholder={t($ => $['nodes.agent.outputVars.namePlaceholder'], { ns: 'workflow' })} + placeholder={t(($) => $['nodes.agent.outputVars.namePlaceholder'], { + ns: 'workflow', + })} className="h-6 w-24 px-1.5 py-0 code-sm-semibold" - onChange={event => updateDraft({ name: event.currentTarget.value })} + onChange={(event) => updateDraft({ name: event.currentTarget.value })} /> </Field> <OutputTypeSelect value={draft.type} - onChange={value => updateDraft({ type: value })} + onChange={(value) => updateDraft({ type: value })} /> <Field name="required" className="contents"> <FieldLabel className="flex h-6 items-center gap-x-1 system-xs-regular text-text-tertiary"> <Switch - aria-label={t($ => $['nodes.agent.outputVars.requiredLabel'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.agent.outputVars.requiredLabel'], { + ns: 'workflow', + })} size="xs" checked={draft.required} - onCheckedChange={required => updateDraft({ required })} + onCheckedChange={(required) => updateDraft({ required })} /> - {t($ => $['nodes.agent.outputVars.requiredLabel'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.outputVars.requiredLabel'], { ns: 'workflow' })} </FieldLabel> </Field> </div> {hasNameError && ( <Field name="nameError" invalid className="contents"> - <FieldError id={nameErrorId} match className="mt-1 px-1 py-0 system-xs-regular text-text-destructive"> + <FieldError + id={nameErrorId} + match + className="mt-1 px-1 py-0 system-xs-regular text-text-destructive" + > {duplicateName - ? t($ => $['nodes.agent.outputVars.nameDuplicate'], { ns: 'workflow' }) - : t($ => $['nodes.agent.outputVars.nameInvalid'], { ns: 'workflow' })} + ? t(($) => $['nodes.agent.outputVars.nameDuplicate'], { ns: 'workflow' }) + : t(($) => $['nodes.agent.outputVars.nameInvalid'], { ns: 'workflow' })} </FieldError> </Field> )} <Field name="description" className="contents"> <FieldLabel className="sr-only"> - {t($ => $['nodes.agent.outputVars.descriptionLabel'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.outputVars.descriptionLabel'], { ns: 'workflow' })} </FieldLabel> <FieldControl size="small" value={draft.description} - placeholder={t($ => $['nodes.agent.outputVars.descriptionPlaceholder'], { ns: 'workflow' })} + placeholder={t(($) => $['nodes.agent.outputVars.descriptionPlaceholder'], { + ns: 'workflow', + })} className="mt-2 h-5 border-transparent bg-transparent px-1 py-0 system-xs-regular shadow-none hover:border-transparent hover:bg-transparent focus:bg-transparent" - onChange={event => updateDraft({ description: event.currentTarget.value })} + onChange={(event) => updateDraft({ description: event.currentTarget.value })} /> </Field> </div> @@ -145,24 +156,26 @@ export function OutputEditCard({ aria-hidden="true" className="i-ri-arrow-down-double-line size-3 transition-transform duration-100 ease-out group-data-panel-open:rotate-180 motion-reduce:transition-none" /> - {t($ => $['nodes.agent.outputVars.showAdvancedOptions'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.outputVars.showAdvancedOptions'], { ns: 'workflow' })} </CollapsibleTrigger> <CollapsiblePanel className="border-t border-divider-subtle"> <div className="px-3 py-2"> <Field name="defaultValue" className="gap-1"> <FieldLabel className="py-0 system-xs-medium text-text-secondary"> - {t($ => $['nodes.agent.outputVars.defaultValueLabel'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.outputVars.defaultValueLabel'], { ns: 'workflow' })} </FieldLabel> <Textarea size="small" value={draft.defaultValue} - placeholder={t($ => $['nodes.agent.outputVars.defaultValuePlaceholder'], { ns: 'workflow' })} + placeholder={t(($) => $['nodes.agent.outputVars.defaultValuePlaceholder'], { + ns: 'workflow', + })} className="mt-1 min-h-6" - onValueChange={defaultValue => updateDraft({ defaultValue })} + onValueChange={(defaultValue) => updateDraft({ defaultValue })} /> {defaultValueErrorKey && ( <FieldError match className="py-0 system-xs-regular text-text-destructive"> - {t($ => $[defaultValueErrorKey], { ns: 'workflow' })} + {t(($) => $[defaultValueErrorKey], { ns: 'workflow' })} </FieldError> )} </Field> @@ -172,17 +185,17 @@ export function OutputEditCard({ )} <div className="flex h-12 items-center justify-end gap-x-2 px-3"> <Button type="button" size="small" variant="secondary" onClick={onCancel}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button type="submit" size="small" variant="primary" disabled={confirmDisabled} - aria-label={t($ => $['nodes.agent.outputVars.confirm'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.agent.outputVars.confirm'], { ns: 'workflow' })} className="gap-x-1" > - {t($ => $['nodes.agent.outputVars.confirm'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.outputVars.confirm'], { ns: 'workflow' })} <ConfirmHotkeyHint /> </Button> </div> diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/index.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/index.tsx index ab3b59183f7546..4ba9ea938936c6 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/index.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/index.tsx @@ -1,6 +1,11 @@ import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen' import type { ReactNode } from 'react' -import type { AgentOutputVariablesProps, DeclaredOutputChildConfig, EditableOutputConfig, EditingState } from './utils' +import type { + AgentOutputVariablesProps, + DeclaredOutputChildConfig, + EditableOutputConfig, + EditingState, +} from './utils' import { Button } from '@langgenius/dify-ui/button' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -42,12 +47,16 @@ function OutputRow({ <div className="flex h-6 items-center gap-x-1 pr-0.5 pl-1"> <div className="flex min-w-0 grow items-center gap-x-1"> <span className="flex h-5 min-w-0 items-center px-1"> - <span className="truncate code-sm-semibold leading-4 text-text-primary">{output.name}</span> + <span className="truncate code-sm-semibold leading-4 text-text-primary"> + {output.name} + </span> + </span> + <span className="flex h-5 shrink-0 items-center px-1 system-xs-medium text-text-tertiary"> + {getOutputDisplayType(output)} </span> - <span className="flex h-5 shrink-0 items-center px-1 system-xs-medium text-text-tertiary">{getOutputDisplayType(output)}</span> {output.required && ( <span className="flex h-3 shrink-0 items-center px-1 system-2xs-medium-uppercase text-text-warning"> - {t($ => $['nodes.agent.outputVars.requiredLabel'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.outputVars.requiredLabel'], { ns: 'workflow' })} </span> )} </div> @@ -56,7 +65,7 @@ function OutputRow({ {onAddChild && ( <button type="button" - aria-label={`${t($ => $['operation.add'], { ns: 'common' })} ${output.name}`} + aria-label={`${t(($) => $['operation.add'], { ns: 'common' })} ${output.name}`} className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover-alt hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={onAddChild} > @@ -65,7 +74,10 @@ function OutputRow({ )} <button type="button" - aria-label={t($ => $['nodes.agent.outputVars.edit'], { ns: 'workflow', name: output.name })} + aria-label={t(($) => $['nodes.agent.outputVars.edit'], { + ns: 'workflow', + name: output.name, + })} className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover-alt hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={onEdit} > @@ -73,7 +85,10 @@ function OutputRow({ </button> <button type="button" - aria-label={t($ => $['nodes.agent.outputVars.delete'], { ns: 'workflow', name: output.name })} + aria-label={t(($) => $['nodes.agent.outputVars.delete'], { + ns: 'workflow', + name: output.name, + })} className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover-alt hover:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={onDelete} > @@ -83,15 +98,13 @@ function OutputRow({ )} </div> {description && ( - <div className="truncate px-2 pb-1 system-xs-regular text-text-tertiary"> - {description} - </div> + <div className="truncate px-2 pb-1 system-xs-regular text-text-tertiary">{description}</div> )} </div> ) } -function ChildOutputFrame({ children, depth }: { children: ReactNode, depth: number }) { +function ChildOutputFrame({ children, depth }: { children: ReactNode; depth: number }) { return ( <div className="flex items-stretch"> {Array.from({ length: depth }, (_, index) => ( @@ -99,9 +112,7 @@ function ChildOutputFrame({ children, depth }: { children: ReactNode, depth: num <div className="w-px bg-divider-subtle" /> </div> ))} - <div className="min-w-0 flex-1"> - {children} - </div> + <div className="min-w-0 flex-1">{children}</div> </div> ) } @@ -126,7 +137,11 @@ export function AgentOutputVariables({ function handleNewChild(outputIndex: number, parentPath: number[]) { setEditingState({ outputIndex, parentPath, draft: createDraft() }) } - function handleEditChild(outputIndex: number, childPath: number[], child: DeclaredOutputChildConfig) { + function handleEditChild( + outputIndex: number, + childPath: number[], + child: DeclaredOutputChildConfig, + ) { setEditingState({ outputIndex, childPath, draft: createDraft(child) }) } function handleDeleteOutput(index: number) { @@ -134,83 +149,105 @@ export function AgentOutputVariables({ } function handleDeleteChild(outputIndex: number, childPath: number[]) { const parent = outputs[outputIndex] - if (!parent) - return + if (!parent) return - onChange(outputs.map((item, itemIndex) => ( - itemIndex === outputIndex ? deleteOutputChildAtPath(item, childPath) : item - ))) + onChange( + outputs.map((item, itemIndex) => + itemIndex === outputIndex ? deleteOutputChildAtPath(item, childPath) : item, + ), + ) } function handleConfirm(output: DeclaredOutputConfig, state: EditingState) { if (typeof state.outputIndex === 'number' && state.parentPath) { const parent = outputs[state.outputIndex] - if (!parent) - return + if (!parent) return const childOutput = toDeclaredOutputChild(output) - onChange(outputs.map((item, outputIndex) => ( - outputIndex === state.outputIndex ? insertOutputChildAtPath(item, state.parentPath!, childOutput) : item - ))) - } - else if (typeof state.outputIndex === 'number' && state.childPath) { + onChange( + outputs.map((item, outputIndex) => + outputIndex === state.outputIndex + ? insertOutputChildAtPath(item, state.parentPath!, childOutput) + : item, + ), + ) + } else if (typeof state.outputIndex === 'number' && state.childPath) { const childOutput = toDeclaredOutputChild(output) - onChange(outputs.map((item, outputIndex) => ( - outputIndex === state.outputIndex ? updateOutputChildAtPath(item, state.childPath!, childOutput) : item - ))) - } - else if (typeof state.outputIndex === 'number') { - onChange(outputs.map((item, outputIndex) => outputIndex === state.outputIndex ? output : item)) - } - else { + onChange( + outputs.map((item, outputIndex) => + outputIndex === state.outputIndex + ? updateOutputChildAtPath(item, state.childPath!, childOutput) + : item, + ), + ) + } else if (typeof state.outputIndex === 'number') { + onChange( + outputs.map((item, outputIndex) => (outputIndex === state.outputIndex ? output : item)), + ) + } else { onChange([...outputs, output]) } setEditingState(null) } - function renderChildren(output: DeclaredOutputConfig, outputIndex: number, depth: number, children: DeclaredOutputChildConfig[], editable: boolean, parentPath: number[] = []) { + function renderChildren( + output: DeclaredOutputConfig, + outputIndex: number, + depth: number, + children: DeclaredOutputChildConfig[], + editable: boolean, + parentPath: number[] = [], + ) { return ( <> {children.map((child, childIndex) => { const childPath = [...parentPath, childIndex] const nestedChildren = getOutputChildren(child) - const isEditingChild = editingState?.outputIndex === outputIndex && pathsEqual(editingState.childPath, childPath) + const isEditingChild = + editingState?.outputIndex === outputIndex && + pathsEqual(editingState.childPath, childPath) return ( - <div key={`${childPath.join('.')}-${child.name}-${getOutputTypeOptionValue(child)}`} className="flex flex-col"> - {isEditingChild - ? ( - <ChildOutputFrame depth={depth}> - <OutputEditCard - allowDefaultValue={false} - editingIndex={childIndex} - existingOutputs={getOutputChildrenAtPath(output, parentPath)} - state={editingState} - onCancel={() => setEditingState(null)} - onConfirm={handleConfirm} - /> - </ChildOutputFrame> - ) - : ( - <ChildOutputFrame depth={depth}> - <OutputRow - output={child} - editable={editable} - onAddChild={editable && canOutputHaveChildren(child) ? () => handleNewChild(outputIndex, childPath) : undefined} - onDelete={() => handleDeleteChild(outputIndex, childPath)} - onEdit={() => handleEditChild(outputIndex, childPath, child)} - /> - </ChildOutputFrame> - )} - {renderChildren(output, outputIndex, depth + 1, nestedChildren, editable, childPath)} - {editingState?.outputIndex === outputIndex && pathsEqual(editingState.parentPath, childPath) && ( - <ChildOutputFrame depth={depth + 1}> + <div + key={`${childPath.join('.')}-${child.name}-${getOutputTypeOptionValue(child)}`} + className="flex flex-col" + > + {isEditingChild ? ( + <ChildOutputFrame depth={depth}> <OutputEditCard allowDefaultValue={false} - existingOutputs={nestedChildren} + editingIndex={childIndex} + existingOutputs={getOutputChildrenAtPath(output, parentPath)} state={editingState} onCancel={() => setEditingState(null)} onConfirm={handleConfirm} /> </ChildOutputFrame> + ) : ( + <ChildOutputFrame depth={depth}> + <OutputRow + output={child} + editable={editable} + onAddChild={ + editable && canOutputHaveChildren(child) + ? () => handleNewChild(outputIndex, childPath) + : undefined + } + onDelete={() => handleDeleteChild(outputIndex, childPath)} + onEdit={() => handleEditChild(outputIndex, childPath, child)} + /> + </ChildOutputFrame> )} + {renderChildren(output, outputIndex, depth + 1, nestedChildren, editable, childPath)} + {editingState?.outputIndex === outputIndex && + pathsEqual(editingState.parentPath, childPath) && ( + <ChildOutputFrame depth={depth + 1}> + <OutputEditCard + allowDefaultValue={false} + existingOutputs={nestedChildren} + state={editingState} + onCancel={() => setEditingState(null)} + onConfirm={handleConfirm} + /> + </ChildOutputFrame> + )} </div> ) })} @@ -224,69 +261,77 @@ export function AgentOutputVariables({ {outputs.map((output, index) => { const editable = !isDefaultOutput(output) const children = getOutputChildren(output) - const isEditingOutput = editingState?.outputIndex === index && !editingState.childPath && !editingState.parentPath + const isEditingOutput = + editingState?.outputIndex === index && + !editingState.childPath && + !editingState.parentPath return ( - <div key={`${output.name}-${getOutputTypeOptionValue(output)}`} className="flex flex-col"> - {isEditingOutput - ? ( + <div + key={`${output.name}-${getOutputTypeOptionValue(output)}`} + className="flex flex-col" + > + {isEditingOutput ? ( + <OutputEditCard + key={`${output.name}-editing`} + editingIndex={index} + existingOutputs={outputs} + state={editingState} + onCancel={() => setEditingState(null)} + onConfirm={handleConfirm} + /> + ) : ( + <OutputRow + output={output} + editable={editable} + onAddChild={ + editable && canOutputHaveChildren(output) + ? () => handleNewChild(index, []) + : undefined + } + onDelete={() => handleDeleteOutput(index)} + onEdit={() => handleEditOutput(index)} + /> + )} + {renderChildren(output, index, 1, children, editable)} + {editingState?.outputIndex === index && + editingState.parentPath && + !editingState.parentPath.length && ( + <ChildOutputFrame depth={1}> <OutputEditCard - key={`${output.name}-editing`} - editingIndex={index} - existingOutputs={outputs} + allowDefaultValue={false} + existingOutputs={children} state={editingState} onCancel={() => setEditingState(null)} onConfirm={handleConfirm} /> - ) - : ( - <OutputRow - output={output} - editable={editable} - onAddChild={editable && canOutputHaveChildren(output) ? () => handleNewChild(index, []) : undefined} - onDelete={() => handleDeleteOutput(index)} - onEdit={() => handleEditOutput(index)} - /> - )} - {renderChildren(output, index, 1, children, editable)} - {editingState?.outputIndex === index && editingState.parentPath && !editingState.parentPath.length && ( - <ChildOutputFrame depth={1}> - <OutputEditCard - allowDefaultValue={false} - existingOutputs={children} - state={editingState} - onCancel={() => setEditingState(null)} - onConfirm={handleConfirm} - /> - </ChildOutputFrame> - )} + </ChildOutputFrame> + )} </div> ) })} <div className="py-1"> <Divider type="horizontal" className="h-px bg-divider-subtle" /> </div> - {editingState && editingState.outputIndex == null - ? ( - <OutputEditCard - existingOutputs={outputs} - state={editingState} - onCancel={() => setEditingState(null)} - onConfirm={handleConfirm} - /> - ) - : ( - <div className="pt-1"> - <Button - size="small" - variant="tertiary" - className="h-6 w-full gap-x-1 rounded-md bg-components-input-bg-normal text-text-secondary hover:bg-state-base-hover" - onClick={handleNewOutput} - > - <span aria-hidden="true" className="i-ri-add-line size-3.5" /> - {t($ => $['nodes.agent.outputVars.newOutput'], { ns: 'workflow' })} - </Button> - </div> - )} + {editingState && editingState.outputIndex == null ? ( + <OutputEditCard + existingOutputs={outputs} + state={editingState} + onCancel={() => setEditingState(null)} + onConfirm={handleConfirm} + /> + ) : ( + <div className="pt-1"> + <Button + size="small" + variant="tertiary" + className="h-6 w-full gap-x-1 rounded-md bg-components-input-bg-normal text-text-secondary hover:bg-state-base-hover" + onClick={handleNewOutput} + > + <span aria-hidden="true" className="i-ri-add-line size-3.5" /> + {t(($) => $['nodes.agent.outputVars.newOutput'], { ns: 'workflow' })} + </Button> + </div> + )} </div> </div> </OutputVars> @@ -294,8 +339,7 @@ export function AgentOutputVariables({ } function pathsEqual(left?: number[], right?: number[]) { - if (!left || !right || left.length !== right.length) - return false + if (!left || !right || left.length !== right.length) return false return left.every((item, index) => item === right[index]) } diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/type-select.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/type-select.tsx index 75722da2f42343..7f9a3d2f82b3b5 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/type-select.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/type-select.tsx @@ -25,21 +25,20 @@ export function OutputTypeSelect({ <Select<OutputTypeOptionValue> value={value} onValueChange={(nextValue) => { - if (nextValue) - onChange(nextValue) + if (nextValue) onChange(nextValue) }} > <SelectLabel className="sr-only"> - {t($ => $['nodes.agent.outputVars.typeLabel'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.outputVars.typeLabel'], { ns: 'workflow' })} </SelectLabel> <SelectTrigger - aria-label={t($ => $['nodes.agent.outputVars.typeLabel'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.agent.outputVars.typeLabel'], { ns: 'workflow' })} className="h-6 w-auto rounded-md bg-transparent px-1 py-0 system-xs-medium text-text-tertiary hover:bg-state-base-hover" > {selected.label} </SelectTrigger> <SelectContent popupClassName="w-40"> - {OUTPUT_TYPE_OPTIONS.map(option => ( + {OUTPUT_TYPE_OPTIONS.map((option) => ( <SelectItem key={option.value} value={option.value}> <SelectItemText>{option.label}</SelectItemText> <SelectItemIndicator /> diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/utils.ts b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/utils.ts index 2e2a505f62278d..efa51259d6eb29 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/utils.ts +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/utils.ts @@ -1,4 +1,7 @@ -import type { DeclaredOutputConfig, DeclaredOutputType } from '@dify/contracts/api/console/apps/types.gen' +import type { + DeclaredOutputConfig, + DeclaredOutputType, +} from '@dify/contracts/api/console/apps/types.gen' import type { TFunction } from 'i18next' import { defaultAgentV2DeclaredOutputs } from '../../output-variables' @@ -6,13 +9,13 @@ export type DeclaredOutputChildConfig = NonNullable<DeclaredOutputConfig['childr export type EditableOutputConfig = DeclaredOutputConfig | DeclaredOutputChildConfig -export type OutputTypeOptionValue - = DeclaredOutputType - | 'array[boolean]' - | 'array[file]' - | 'array[number]' - | 'array[object]' - | 'array[string]' +export type OutputTypeOptionValue = + | DeclaredOutputType + | 'array[boolean]' + | 'array[file]' + | 'array[number]' + | 'array[object]' + | 'array[string]' export type OutputTypeOption = { label: string @@ -61,14 +64,13 @@ export const OUTPUT_TYPE_OPTIONS: OutputTypeOption[] = [ ] export function getOutputTypeOptionValue(output: EditableOutputConfig): OutputTypeOptionValue { - if (output.type !== 'array') - return output.type + if (output.type !== 'array') return output.type return `array[${output.array_item?.type || 'object'}]` as OutputTypeOptionValue } export function getOutputTypeOption(value: OutputTypeOptionValue) { - return OUTPUT_TYPE_OPTIONS.find(option => option.value === value) || OUTPUT_TYPE_OPTIONS[0]! + return OUTPUT_TYPE_OPTIONS.find((option) => option.value === value) || OUTPUT_TYPE_OPTIONS[0]! } export function createDraft(output?: EditableOutputConfig): OutputDraft { @@ -104,8 +106,7 @@ export function createOutputFromDraft( required: draft.required, } - if (draft.description.trim()) - output.description = draft.description.trim() + if (draft.description.trim()) output.description = draft.description.trim() if (option.type === 'array') { output.array_item = { @@ -113,8 +114,7 @@ export function createOutputFromDraft( } } - if (draft.children.length && draft.type === 'object') - output.children = draft.children + if (draft.children.length && draft.type === 'object') output.children = draft.children if (draft.children.length && draft.type === 'array[object]') { output.array_item = { @@ -156,8 +156,7 @@ export function getOutputChildren(output: EditableOutputConfig): DeclaredOutputC if (getOutputTypeOptionValue(output) === 'array[object]') return readOutputChildren(output.array_item?.children) - if (output.type === 'object') - return readOutputChildren(output.children) + if (output.type === 'object') return readOutputChildren(output.children) return [] } @@ -208,8 +207,7 @@ function getOutputChildAtPath( let children = getOutputChildren(output) for (const index of path) { current = children[index] - if (!current) - return undefined + if (!current) return undefined children = getOutputChildren(current) } @@ -221,7 +219,7 @@ export function insertOutputChildAtPath( parentPath: number[], child: DeclaredOutputChildConfig, ) { - return updateOutputChildrenAtPath(output, parentPath, children => [...children, child]) + return updateOutputChildrenAtPath(output, parentPath, (children) => [...children, child]) } export function updateOutputChildAtPath( @@ -230,27 +228,25 @@ export function updateOutputChildAtPath( child: DeclaredOutputChildConfig, ) { const childIndex = childPath.at(-1) - if (childIndex == null) - return output + if (childIndex == null) return output - return updateOutputChildrenAtPath(output, childPath.slice(0, -1), children => children.map((item, index) => index === childIndex ? child : item)) + return updateOutputChildrenAtPath(output, childPath.slice(0, -1), (children) => + children.map((item, index) => (index === childIndex ? child : item)), + ) } -export function deleteOutputChildAtPath( - output: DeclaredOutputConfig, - childPath: number[], -) { +export function deleteOutputChildAtPath(output: DeclaredOutputConfig, childPath: number[]) { const childIndex = childPath.at(-1) - if (childIndex == null) - return output + if (childIndex == null) return output - return updateOutputChildrenAtPath(output, childPath.slice(0, -1), children => children.filter((_, index) => index !== childIndex)) + return updateOutputChildrenAtPath(output, childPath.slice(0, -1), (children) => + children.filter((_, index) => index !== childIndex), + ) } export function getDefaultValueErrorKey(draft: OutputDraft) { const trimmed = draft.defaultValue.trim() - if (!trimmed) - return null + if (!trimmed) return null const option = getOutputTypeOption(draft.type) if (option.type === 'file' || option.arrayItemType === 'file') @@ -265,12 +261,14 @@ export function getDefaultValueErrorKey(draft: OutputDraft) { if (option.type === 'object' || option.type === 'array') { try { const parsed = JSON.parse(trimmed) - if (option.type === 'object' && (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')) + if ( + option.type === 'object' && + (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') + ) return 'nodes.agent.outputVars.defaultValueObjectInvalid' if (option.type === 'array' && !Array.isArray(parsed)) return 'nodes.agent.outputVars.defaultValueArrayInvalid' - } - catch { + } catch { return option.type === 'object' ? 'nodes.agent.outputVars.defaultValueObjectInvalid' : 'nodes.agent.outputVars.defaultValueArrayInvalid' @@ -281,20 +279,19 @@ export function getDefaultValueErrorKey(draft: OutputDraft) { } export function isDefaultOutput(output: DeclaredOutputConfig) { - return defaultAgentV2DeclaredOutputs.some(defaultOutput => - defaultOutput.name === output.name - && defaultOutput.type === output.type - && getOutputTypeOptionValue(defaultOutput) === getOutputTypeOptionValue(output), + return defaultAgentV2DeclaredOutputs.some( + (defaultOutput) => + defaultOutput.name === output.name && + defaultOutput.type === output.type && + getOutputTypeOptionValue(defaultOutput) === getOutputTypeOptionValue(output), ) } export function getOutputDescription(output: EditableOutputConfig, t: TFunction) { - if (output.name === 'text') - return t($ => $['nodes.agent.outputVars.text'], { ns: 'workflow' }) + if (output.name === 'text') return t(($) => $['nodes.agent.outputVars.text'], { ns: 'workflow' }) if (output.name === 'files') - return t($ => $['nodes.agent.outputVars.files.title'], { ns: 'workflow' }) - if (output.name === 'json') - return t($ => $['nodes.agent.outputVars.json'], { ns: 'workflow' }) + return t(($) => $['nodes.agent.outputVars.files.title'], { ns: 'workflow' }) + if (output.name === 'json') return t(($) => $['nodes.agent.outputVars.json'], { ns: 'workflow' }) return output.description || '' } @@ -311,17 +308,15 @@ function updateOutputChildrenAtPath( parentPath: number[], updater: (children: DeclaredOutputChildConfig[]) => DeclaredOutputChildConfig[], ): DeclaredOutputConfig { - if (!parentPath.length) - return updateOutputChildren(output, updater(getOutputChildren(output))) + if (!parentPath.length) return updateOutputChildren(output, updater(getOutputChildren(output))) const [childIndex, ...restPath] = parentPath - if (childIndex == null) - return output + if (childIndex == null) return output const children = getOutputChildren(output) - const nextChildren = children.map((child, index) => ( - index === childIndex ? updateChildChildrenAtPath(child, restPath, updater) : child - )) + const nextChildren = children.map((child, index) => + index === childIndex ? updateChildChildrenAtPath(child, restPath, updater) : child, + ) return updateOutputChildren(output, nextChildren) } @@ -331,17 +326,15 @@ function updateChildChildrenAtPath( parentPath: number[], updater: (children: DeclaredOutputChildConfig[]) => DeclaredOutputChildConfig[], ): DeclaredOutputChildConfig { - if (!parentPath.length) - return updateChildChildren(child, updater(getOutputChildren(child))) + if (!parentPath.length) return updateChildChildren(child, updater(getOutputChildren(child))) const [childIndex, ...restPath] = parentPath - if (childIndex == null) - return child + if (childIndex == null) return child const children = getOutputChildren(child) - const nextChildren = children.map((nestedChild, index) => ( - index === childIndex ? updateChildChildrenAtPath(nestedChild, restPath, updater) : nestedChild - )) + const nextChildren = children.map((nestedChild, index) => + index === childIndex ? updateChildChildrenAtPath(nestedChild, restPath, updater) : nestedChild, + ) return updateChildChildren(child, nextChildren) } @@ -372,14 +365,11 @@ function updateChildChildren( } function getOutputDefaultValue(output: EditableOutputConfig) { - if (!('failure_strategy' in output)) - return '' + if (!('failure_strategy' in output)) return '' const defaultValue = output.failure_strategy?.default_value - if (defaultValue == null) - return '' - if (typeof defaultValue === 'string') - return defaultValue + if (defaultValue == null) return '' + if (typeof defaultValue === 'string') return defaultValue return JSON.stringify(defaultValue) } @@ -389,13 +379,11 @@ function coerceDefaultValue(value: string, option: OutputTypeOption): unknown { const parsed = Number(trimmed) return Number.isNaN(parsed) ? trimmed : parsed } - if (option.type === 'boolean') - return trimmed === 'true' + if (option.type === 'boolean') return trimmed === 'true' if (option.type === 'object' || option.type === 'array') { try { return JSON.parse(trimmed) - } - catch { + } catch { return trimmed } } diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx index 6328cf92ca170a..69d28cafd8951d 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx @@ -3,7 +3,13 @@ import type { AgentRosterNodeData } from '@/app/components/workflow/block-select import type { AppIconType } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '@langgenius/dify-ui/dialog' import { Drawer, DrawerCloseButton, @@ -20,12 +26,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { Field, FieldLabel } from '@langgenius/dify-ui/field' -import { - Popover, - PopoverContent, - PopoverTitle, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' @@ -47,8 +48,7 @@ type AgentRosterDisplayData = { } const getAppIconType = (iconType?: string | null): AppIconType | null => { - if (iconType === 'emoji' || iconType === 'image' || iconType === 'link') - return iconType + if (iconType === 'emoji' || iconType === 'image' || iconType === 'link') return iconType return null } @@ -76,14 +76,18 @@ function AgentRosterAvatar({ ) } -function InlineSetupAvatar({ - className, -}: { - className?: string -}) { +function InlineSetupAvatar({ className }: { className?: string }) { return ( - <span className={cn('flex size-8 shrink-0 items-center justify-center rounded-full bg-background-default-burn', className)}> - <span aria-hidden className="i-custom-vender-agent-v2-configure h-3.5 w-3 text-text-tertiary" /> + <span + className={cn( + 'flex size-8 shrink-0 items-center justify-center rounded-full bg-background-default-burn', + className, + )} + > + <span + aria-hidden + className="i-custom-vender-agent-v2-configure h-3.5 w-3 text-text-tertiary" + /> </span> ) } @@ -119,8 +123,12 @@ function AgentRosterDrawer({ }) { const { t } = useTranslation() const isSetup = mode === 'setup' - const title = isInlineSetup ? t($ => $[`${i18nPrefix}.roster.inlineSetup.name`], { ns: 'workflow' }) : agent.name - const description = isSetup ? t($ => $[`${i18nPrefix}.roster.inlineSetup.description`], { ns: 'workflow' }) : agent.role + const title = isInlineSetup + ? t(($) => $[`${i18nPrefix}.roster.inlineSetup.name`], { ns: 'workflow' }) + : agent.name + const description = isSetup + ? t(($) => $[`${i18nPrefix}.roster.inlineSetup.description`], { ns: 'workflow' }) + : agent.role const showInlineActions = isInlineSetup && !!onSaveInlineToRoster return ( @@ -130,8 +138,7 @@ function AgentRosterDrawer({ disablePointerDismissal swipeDirection="right" onOpenChange={(nextOpen) => { - if (!nextOpen) - onClose() + if (!nextOpen) onClose() }} > <DrawerPortal container={portalContainerRef}> @@ -155,19 +162,47 @@ function AgentRosterDrawer({ )} > <div className="flex min-w-0 items-start justify-between"> - <div className={cn('flex min-w-0 flex-1', isSetup ? 'min-w-px items-center gap-2' : 'h-10 items-center gap-2 px-0.5 py-0.5')}> - {isInlineSetup - ? <InlineSetupAvatar className="size-9" /> - : <AgentRosterAvatar agent={agent} size="md" className="size-9" />} - <div className={cn('flex min-w-0 flex-1 flex-col', isSetup ? '' : 'gap-0.5 py-px')}> + <div + className={cn( + 'flex min-w-0 flex-1', + isSetup + ? 'min-w-px items-center gap-2' + : 'h-10 items-center gap-2 px-0.5 py-0.5', + )} + > + {isInlineSetup ? ( + <InlineSetupAvatar className="size-9" /> + ) : ( + <AgentRosterAvatar agent={agent} size="md" className="size-9" /> + )} + <div + className={cn('flex min-w-0 flex-1 flex-col', isSetup ? '' : 'gap-0.5 py-px')} + > <div className="flex min-w-0 items-center gap-1"> - <DrawerTitle className={cn('truncate', isSetup ? 'system-xl-semibold text-text-primary' : 'system-sm-medium text-text-secondary')}> + <DrawerTitle + className={cn( + 'truncate', + isSetup + ? 'system-xl-semibold text-text-primary' + : 'system-sm-medium text-text-secondary', + )} + > {title} </DrawerTitle> - {!isSetup && showAccessIcon && <span aria-hidden className="i-ri-lock-line size-3 shrink-0 text-text-tertiary" />} + {!isSetup && showAccessIcon && ( + <span + aria-hidden + className="i-ri-lock-line size-3 shrink-0 text-text-tertiary" + /> + )} </div> {description && ( - <p className={cn(isSetup ? 'min-w-full' : 'truncate', 'system-xs-regular text-text-tertiary')}> + <p + className={cn( + isSetup ? 'min-w-full' : 'truncate', + 'system-xs-regular text-text-tertiary', + )} + > {description} </p> )} @@ -178,15 +213,27 @@ function AgentRosterDrawer({ <> <DropdownMenu modal={false}> <DropdownMenuTrigger - aria-label={t($ => $[`${i18nPrefix}.roster.more`], { ns: 'workflow' })} + aria-label={t(($) => $[`${i18nPrefix}.roster.more`], { + ns: 'workflow', + })} className="flex size-6 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" > <span aria-hidden className="i-ri-more-fill size-4" /> </DropdownMenuTrigger> - <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44 w-max"> - <DropdownMenuItem className="gap-2 whitespace-nowrap" onClick={onSaveInlineToRoster}> - <span aria-hidden className="i-ri-inbox-archive-line size-4 shrink-0 text-text-tertiary" /> - <span>{t($ => $['roster.saveToRoster'], { ns: 'agentV2' })}</span> + <DropdownMenuContent + placement="bottom-end" + sideOffset={4} + popupClassName="min-w-44 w-max" + > + <DropdownMenuItem + className="gap-2 whitespace-nowrap" + onClick={onSaveInlineToRoster} + > + <span + aria-hidden + className="i-ri-inbox-archive-line size-4 shrink-0 text-text-tertiary" + /> + <span>{t(($) => $['roster.saveToRoster'], { ns: 'agentV2' })}</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> @@ -196,7 +243,7 @@ function AgentRosterDrawer({ </> )} <DrawerCloseButton - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="size-6 rounded-md" /> </div> @@ -212,7 +259,7 @@ function AgentRosterDrawer({ > <span aria-hidden className="i-ri-external-link-line size-4 shrink-0" /> <span className="truncate"> - {t($ => $[`${i18nPrefix}.roster.editInConsole`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.roster.editInConsole`], { ns: 'workflow' })} </span> </Link> )} @@ -225,7 +272,7 @@ function AgentRosterDrawer({ > <span aria-hidden className="i-ri-file-copy-2-line size-4 shrink-0" /> <span className="truncate"> - {t($ => $[`${i18nPrefix}.roster.makeCopy`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.roster.makeCopy`], { ns: 'workflow' })} </span> </Button> </div> @@ -233,7 +280,10 @@ function AgentRosterDrawer({ </header> <div role="region" - aria-label={t($ => $[`${i18nPrefix}.roster.panelLabel`], { ns: 'workflow', name: agent.name })} + aria-label={t(($) => $[`${i18nPrefix}.roster.panelLabel`], { + ns: 'workflow', + name: agent.name, + })} className="min-h-0 flex-1 overflow-hidden bg-components-panel-bg" > {children ?? <div className="h-full min-h-80 bg-components-panel-bg" />} @@ -262,18 +312,12 @@ function AgentRosterInlineConfigureDialog({ const { t } = useTranslation() return ( - <Dialog - open={open} - onOpenChange={onOpenChange} - disablePointerDismissal - > + <Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal> <DialogTrigger render={trigger} /> <DialogContent className="h-[min(760px,calc(100dvh-32px))] w-[min(1120px,calc(100vw-32px))] max-w-none overflow-hidden p-0"> - <DialogTitle className="sr-only"> - {agent.name} - </DialogTitle> + <DialogTitle className="sr-only">{agent.name}</DialogTitle> <DialogDescription className="sr-only"> - {t($ => $[`${i18nPrefix}.roster.inlineSetup.description`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.roster.inlineSetup.description`], { ns: 'workflow' })} </DialogDescription> {children ?? <div className="h-full min-h-80 bg-components-panel-bg" />} </DialogContent> @@ -323,27 +367,25 @@ export function AgentRosterField({ const [isSelectorOpen, setIsSelectorOpen] = useState(false) const panelOpen = isPanelOpen ?? localPanelOpen const setPanelOpen = onPanelOpenChange ?? setLocalPanelOpen - const inlineSetupName = t($ => $[`${i18nPrefix}.roster.inlineSetup.name`], { ns: 'workflow' }) - const inlineSetupType = t($ => $[`${i18nPrefix}.roster.inlineSetup.type`], { ns: 'workflow' }) - const rosterRequiredMessage = t($ => $['errorMsg.fieldRequired'], { + const inlineSetupName = t(($) => $[`${i18nPrefix}.roster.inlineSetup.name`], { ns: 'workflow' }) + const inlineSetupType = t(($) => $[`${i18nPrefix}.roster.inlineSetup.type`], { ns: 'workflow' }) + const rosterRequiredMessage = t(($) => $['errorMsg.fieldRequired'], { ns: 'workflow', - field: t($ => $[`${i18nPrefix}.roster.label`], { ns: 'workflow' }), + field: t(($) => $[`${i18nPrefix}.roster.label`], { ns: 'workflow' }), }) - const agentContent = agent - ? ( - <> - {isInlineSetup ? <InlineSetupAvatar /> : <AgentRosterAvatar agent={agent} />} - <span className="flex min-w-0 flex-1 flex-col gap-0.5 py-px"> - <span className="truncate system-sm-medium text-text-secondary"> - {isInlineSetup ? inlineSetupName : agent.name} - </span> - <span className="truncate system-xs-regular text-text-tertiary"> - {isInlineSetup ? inlineSetupType : agent.role} - </span> - </span> - </> - ) - : null + const agentContent = agent ? ( + <> + {isInlineSetup ? <InlineSetupAvatar /> : <AgentRosterAvatar agent={agent} />} + <span className="flex min-w-0 flex-1 flex-col gap-0.5 py-px"> + <span className="truncate system-sm-medium text-text-secondary"> + {isInlineSetup ? inlineSetupName : agent.name} + </span> + <span className="truncate system-xs-regular text-text-tertiary"> + {isInlineSetup ? inlineSetupType : agent.role} + </span> + </span> + </> + ) : null const loadingContent = ( <> <span aria-hidden className="size-8 shrink-0 rounded-lg bg-text-quaternary/20" /> @@ -356,7 +398,7 @@ export function AgentRosterField({ const renderPanelTrigger = (name: string, onClick?: () => void) => ( <button type="button" - aria-label={t($ => $[`${i18nPrefix}.roster.openPanel`], { ns: 'workflow', name })} + aria-label={t(($) => $[`${i18nPrefix}.roster.openPanel`], { ns: 'workflow', name })} aria-busy={isLoading || undefined} className="flex h-13 w-full min-w-0 cursor-pointer items-center gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg py-2 pr-4 pl-2 text-left shadow-xs shadow-shadow-shadow-3 hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={onClick} @@ -372,25 +414,28 @@ export function AgentRosterField({ <Field name="agent_binding" className="gap-1 px-4 py-2"> <div className="flex h-6 items-center gap-2"> <FieldLabel className="min-w-0 flex-1 py-1 system-sm-semibold-uppercase! text-text-secondary"> - {t($ => $['nodes.agent.roster.label'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.roster.label'], { ns: 'workflow' })} </FieldLabel> <Popover open={isPending ? false : isSelectorOpen} onOpenChange={(open) => { - if (!isPending) - setIsSelectorOpen(open) + if (!isPending) setIsSelectorOpen(open) }} > <PopoverTrigger - render={( + render={ <button type="button" disabled={isPending} - className={cn('flex h-6 shrink-0 cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', isPending && 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-text-tertiary')} + className={cn( + 'flex h-6 shrink-0 cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', + isPending && + 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-text-tertiary', + )} > - {t($ => $[`${i18nPrefix}.roster.change`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.roster.change`], { ns: 'workflow' })} </button> - )} + } /> <PopoverContent placement="bottom-end" @@ -398,7 +443,7 @@ export function AgentRosterField({ popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > <PopoverTitle className="sr-only"> - {t($ => $['roster.nodeSelector.dialogLabel'], { ns: 'agentV2' })} + {t(($) => $['roster.nodeSelector.dialogLabel'], { ns: 'agentV2' })} </PopoverTitle> <AgentSelectorContent open={isSelectorOpen} @@ -407,75 +452,72 @@ export function AgentRosterField({ setIsSelectorOpen(false) onChange(nextAgent) }} - onStartFromScratch={onStartFromScratch - ? () => { - setIsSelectorOpen(false) - onStartFromScratch() - } - : undefined} + onStartFromScratch={ + onStartFromScratch + ? () => { + setIsSelectorOpen(false) + onStartFromScratch() + } + : undefined + } /> </PopoverContent> </Popover> </div> - {agent - ? ( - canOpenPanel - ? ( - <> - {isInlineSetup - ? ( - <AgentRosterInlineConfigureDialog - agent={agent} - open={panelOpen} - trigger={renderPanelTrigger(inlineSetupName)} - onOpenChange={setPanelOpen} - > - {panelBody} - </AgentRosterInlineConfigureDialog> - ) - : ( - <> - {renderPanelTrigger(agent.name, () => setPanelOpen(true))} - <AgentRosterDrawer - agent={agent} - mode={panelMode} - open={panelOpen} - portalContainerRef={portalContainerRef} - showAccessIcon - showDetailActions={showPanelDetailActions} - isCopyPending={isPanelCopyPending} - onMakeCopy={onMakeCopy} - onSaveInlineToRoster={onSaveInlineToRoster} - onClose={() => setPanelOpen(false)} - > - {panelBody} - </AgentRosterDrawer> - </> - )} - </> - ) - : ( - <div className="flex h-13 w-full min-w-0 items-center gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg py-2 pr-4 pl-2 text-left shadow-xs shadow-shadow-shadow-3"> - {agentContent} - </div> - ) - ) - : isPending || agentId - ? ( - <div aria-busy="true" className="flex h-13 w-full min-w-0 items-center gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg py-2 pr-4 pl-2 text-left shadow-xs shadow-shadow-shadow-3"> - {loadingContent} - </div> - ) - : ( - <div className="flex h-13 w-full min-w-0 items-center gap-2 rounded-[10px] border-[0.5px] border-state-destructive-border bg-components-panel-on-panel-item-bg py-2 pr-4 pl-2 text-left shadow-xs shadow-shadow-shadow-3"> - <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-state-destructive-hover text-text-destructive"> - <span aria-hidden className="i-ri-error-warning-line size-4" /> - </span> - <span className="min-w-0 flex-1 truncate system-sm-medium text-text-destructive"> - {rosterRequiredMessage} - </span> - </div> + {agent ? ( + canOpenPanel ? ( + <> + {isInlineSetup ? ( + <AgentRosterInlineConfigureDialog + agent={agent} + open={panelOpen} + trigger={renderPanelTrigger(inlineSetupName)} + onOpenChange={setPanelOpen} + > + {panelBody} + </AgentRosterInlineConfigureDialog> + ) : ( + <> + {renderPanelTrigger(agent.name, () => setPanelOpen(true))} + <AgentRosterDrawer + agent={agent} + mode={panelMode} + open={panelOpen} + portalContainerRef={portalContainerRef} + showAccessIcon + showDetailActions={showPanelDetailActions} + isCopyPending={isPanelCopyPending} + onMakeCopy={onMakeCopy} + onSaveInlineToRoster={onSaveInlineToRoster} + onClose={() => setPanelOpen(false)} + > + {panelBody} + </AgentRosterDrawer> + </> )} + </> + ) : ( + <div className="flex h-13 w-full min-w-0 items-center gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg py-2 pr-4 pl-2 text-left shadow-xs shadow-shadow-shadow-3"> + {agentContent} + </div> + ) + ) : isPending || agentId ? ( + <div + aria-busy="true" + className="flex h-13 w-full min-w-0 items-center gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg py-2 pr-4 pl-2 text-left shadow-xs shadow-shadow-shadow-3" + > + {loadingContent} + </div> + ) : ( + <div className="flex h-13 w-full min-w-0 items-center gap-2 rounded-[10px] border-[0.5px] border-state-destructive-border bg-components-panel-on-panel-item-bg py-2 pr-4 pl-2 text-left shadow-xs shadow-shadow-shadow-3"> + <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-state-destructive-hover text-text-destructive"> + <span aria-hidden className="i-ri-error-warning-line size-4" /> + </span> + <span className="min-w-0 flex-1 truncate system-sm-medium text-text-destructive"> + {rosterRequiredMessage} + </span> + </div> + )} </Field> ) } diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-task-field.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-task-field.tsx index 25e403d920b646..9df4317ffb4c24 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-task-field.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-task-field.tsx @@ -18,13 +18,7 @@ import useAvailableVarList from '../../_base/hooks/use-available-var-list' const i18nPrefix = 'nodes.agent' -function AgentTaskToolbar({ - taskLength, - onInsert, -}: { - taskLength: number - onInsert: () => void -}) { +function AgentTaskToolbar({ taskLength, onInsert }: { taskLength: number; onInsert: () => void }) { const { t } = useTranslation() const [editor] = useLexicalComposerContext() @@ -45,7 +39,7 @@ function AgentTaskToolbar({ onClick={handleInsert} > <span aria-hidden className="i-ri-slash-commands-2 size-3.5" /> - {t($ => $[`${i18nPrefix}.task.insert`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.task.insert`], { ns: 'workflow' })} </button> </div> <div className="rounded-sm border border-divider-regular bg-background-default px-1 system-2xs-regular text-text-tertiary"> @@ -74,14 +68,8 @@ export function AgentTaskField({ }) { const { t } = useTranslation() const getVarType = useWorkflowVariableType() - const { - availableVars, - availableNodesWithParent, - } = useAvailableVarList(id) - const [isFocus, { - setTrue: setFocus, - setFalse: setBlur, - }] = useBoolean(false) + const { availableVars, availableNodesWithParent } = useAvailableVarList(id) + const [isFocus, { setTrue: setFocus, setFalse: setBlur }] = useBoolean(false) const workflowNodesMap = availableNodesWithParent.reduce<WorkflowNodesMap>((acc, node) => { acc[node.id] = { @@ -93,7 +81,7 @@ export function AgentTaskField({ } if (node.data.type === BlockEnum.Start) { acc.sys = { - title: t($ => $['blocks.start'], { ns: 'workflow' }), + title: t(($) => $['blocks.start'], { ns: 'workflow' }), type: BlockEnum.Start, } } @@ -104,25 +92,32 @@ export function AgentTaskField({ <Field name="agent_task" className="gap-1 px-4 py-2"> <div className="flex h-6 items-center gap-1"> <FieldLabel className="min-w-0 py-1 system-sm-semibold-uppercase! text-text-secondary"> - {t($ => $[`${i18nPrefix}.task.label`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.task.label`], { ns: 'workflow' })} </FieldLabel> <Infotip - aria-label={t($ => $[`${i18nPrefix}.task.tooltip`], { ns: 'workflow' })} + aria-label={t(($) => $[`${i18nPrefix}.task.tooltip`], { ns: 'workflow' })} popupClassName="whitespace-pre-line" > - {t($ => $[`${i18nPrefix}.task.tooltip`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.task.tooltip`], { ns: 'workflow' })} </Infotip> </div> <div className={cn( 'h-80 rounded-[9px]! p-0.5', - isFocus ? 'bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2' : 'bg-transparent', + isFocus + ? 'bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2' + : 'bg-transparent', readOnly && 'pointer-events-none', )} > - <div className={cn('flex h-full flex-col rounded-lg', isFocus ? 'bg-background-default' : 'bg-components-input-bg-normal')}> + <div + className={cn( + 'flex h-full flex-col rounded-lg', + isFocus ? 'bg-background-default' : 'bg-components-input-bg-normal', + )} + > <PromptEditor - aria-label={t($ => $[`${i18nPrefix}.task.label`], { ns: 'workflow' })} + aria-label={t(($) => $[`${i18nPrefix}.task.label`], { ns: 'workflow' })} wrapperClassName="flex h-full flex-col" value={data.agent_task || ''} onChange={onChange} @@ -130,7 +125,7 @@ export function AgentTaskField({ compact className="min-h-0 flex-1 overflow-y-auto px-3 py-2" placeholderClassName="px-3 py-2" - placeholder={t($ => $[`${i18nPrefix}.task.placeholder`], { ns: 'workflow' })} + placeholder={t(($) => $[`${i18nPrefix}.task.placeholder`], { ns: 'workflow' })} onFocus={setFocus} onBlur={setBlur} workflowVariableBlock={{ @@ -148,10 +143,7 @@ export function AgentTaskField({ }} > {isFocus && ( - <AgentTaskToolbar - taskLength={(data.agent_task || '').length} - onInsert={setFocus} - /> + <AgentTaskToolbar taskLength={(data.agent_task || '').length} onInsert={setFocus} /> )} </PromptEditor> </div> diff --git a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx index 9dbfb6850346e1..5029be8c907278 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx @@ -1,16 +1,31 @@ 'use client' -import type { AgentComposerAgentResponse, AgentComposerBindingResponse } from '@dify/contracts/api/console/apps/types.gen' -import type { AgentFormValues, AgentIconSelection } from '@/features/agent-v2/roster/components/agent-form' +import type { + AgentComposerAgentResponse, + AgentComposerBindingResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import type { + AgentFormValues, + AgentIconSelection, +} from '@/features/agent-v2/roster/components/agent-form' import { Button } from '@langgenius/dify-ui/button' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { Form } from '@langgenius/dify-ui/form' import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import AppIconPicker from '@/app/components/base/app-icon-picker' -import { createAgentIconSelection, defaultAgentIcon } from '@/features/agent-v2/roster/components/agent-form' +import { + createAgentIconSelection, + defaultAgentIcon, +} from '@/features/agent-v2/roster/components/agent-form' import { AgentFormFields } from '@/features/agent-v2/roster/components/agent-form-fields' import { consoleQuery } from '@/service/client' @@ -39,9 +54,9 @@ export function SaveInlineAgentToRosterDialog({ const [description, setDescription] = useState(initialAgent?.description ?? '') const [role, setRole] = useState(initialAgent?.role ?? '') const [iconPickerOpen, setIconPickerOpen] = useState(false) - const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => initialAgent - ? createAgentIconSelection(initialAgent) - : defaultAgentIcon) + const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => + initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon, + ) const saveToRosterMutation = useMutation( consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(), ) @@ -51,52 +66,49 @@ export function SaveInlineAgentToRosterDialog({ setName('') setDescription(initialAgent?.description ?? '') setRole(initialAgent?.role ?? '') - setAgentIcon(initialAgent - ? createAgentIconSelection(initialAgent) - : defaultAgentIcon) - } - else { + setAgentIcon(initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon) + } else { setIconPickerOpen(false) } onOpenChange(nextOpen) } const handleSubmit = (formValues: AgentFormValues) => { - if (saveToRosterMutation.isPending) - return + if (saveToRosterMutation.isPending) return - if (!appId) - return + if (!appId) return const trimmedName = formValues.name?.trim() ?? '' const trimmedRole = formValues.role?.trim() ?? '' - saveToRosterMutation.mutate({ - params: { - app_id: appId, - node_id: nodeId, - }, - body: { - variant: 'workflow', - save_strategy: 'save_to_roster', - new_agent_name: trimmedName, - description: formValues.description?.trim() ?? '', - role: trimmedRole, - icon_type: agentIcon.type, - icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon, - icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined, + saveToRosterMutation.mutate( + { + params: { + app_id: appId, + node_id: nodeId, + }, + body: { + variant: 'workflow', + save_strategy: 'save_to_roster', + new_agent_name: trimmedName, + description: formValues.description?.trim() ?? '', + role: trimmedRole, + icon_type: agentIcon.type, + icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon, + icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined, + }, }, - }, { - onSuccess: (composerState) => { - const binding = composerState.binding - if (binding?.binding_type !== 'roster_agent' || !binding.agent_id) - return + { + onSuccess: (composerState) => { + const binding = composerState.binding + if (binding?.binding_type !== 'roster_agent' || !binding.agent_id) return - toast.success(t($ => $['roster.saveToRosterSuccess'])) - onSaved(binding) - handleOpenChange(false) + toast.success(t(($) => $['roster.saveToRosterSuccess'])) + onSaved(binding) + handleOpenChange(false) + }, }, - }) + ) } return ( @@ -106,10 +118,10 @@ export function SaveInlineAgentToRosterDialog({ <DialogCloseButton /> <div className="shrink-0 pt-6 pr-14 pb-3 pl-6"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['roster.saveToRosterDialog.title'])} + {t(($) => $['roster.saveToRosterDialog.title'])} </DialogTitle> <DialogDescription className="sr-only"> - {t($ => $['roster.saveToRosterDialog.description'])} + {t(($) => $['roster.saveToRosterDialog.description'])} </DialogDescription> </div> <Form<AgentFormValues> @@ -120,7 +132,7 @@ export function SaveInlineAgentToRosterDialog({ <AgentFormFields description={description} icon={agentIcon} - iconAriaLabel={t($ => $['roster.saveToRosterForm.changeIcon'])} + iconAriaLabel={t(($) => $['roster.saveToRosterForm.changeIcon'])} name={name} role={role} onDescriptionChange={setDescription} @@ -129,8 +141,13 @@ export function SaveInlineAgentToRosterDialog({ onRoleChange={setRole} /> <div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6"> - <Button type="button" className="min-w-18" onClick={() => handleOpenChange(false)} disabled={saveToRosterMutation.isPending}> - {tCommon($ => $['operation.cancel'])} + <Button + type="button" + className="min-w-18" + onClick={() => handleOpenChange(false)} + disabled={saveToRosterMutation.isPending} + > + {tCommon(($) => $['operation.cancel'])} </Button> <Button type="submit" @@ -138,7 +155,7 @@ export function SaveInlineAgentToRosterDialog({ className="min-w-18" loading={saveToRosterMutation.isPending} > - {tCommon($ => $['operation.save'])} + {tCommon(($) => $['operation.save'])} </Button> </div> </Form> @@ -146,9 +163,11 @@ export function SaveInlineAgentToRosterDialog({ </Dialog> <AppIconPicker open={iconPickerOpen} - initialEmoji={agentIcon.type === 'emoji' - ? { icon: agentIcon.icon, background: agentIcon.background } - : undefined} + initialEmoji={ + agentIcon.type === 'emoji' + ? { icon: agentIcon.icon, background: agentIcon.background } + : undefined + } onOpenChange={setIconPickerOpen} onSelect={(icon) => { setAgentIcon(icon) diff --git a/web/app/components/workflow/nodes/agent-v2/default.ts b/web/app/components/workflow/nodes/agent-v2/default.ts index d2c1a54aa2c23c..e95f1dbb73bc87 100644 --- a/web/app/components/workflow/nodes/agent-v2/default.ts +++ b/web/app/components/workflow/nodes/agent-v2/default.ts @@ -24,9 +24,9 @@ const nodeDefault: NodeDefault<AgentV2NodeType> = { if (!hasValidAgentBinding(payload)) { return { isValid: false, - errorMessage: t($ => $['errorMsg.fieldRequired'], { + errorMessage: t(($) => $['errorMsg.fieldRequired'], { ns: 'workflow', - field: t($ => $['nodes.agent.roster.label'], { ns: 'workflow' }), + field: t(($) => $['nodes.agent.roster.label'], { ns: 'workflow' }), }), } } diff --git a/web/app/components/workflow/nodes/agent-v2/hooks.ts b/web/app/components/workflow/nodes/agent-v2/hooks.ts index 380aa5759835c3..3067aea18b5fb8 100644 --- a/web/app/components/workflow/nodes/agent-v2/hooks.ts +++ b/web/app/components/workflow/nodes/agent-v2/hooks.ts @@ -21,102 +21,106 @@ type CreateInlineAgentBindingOptions = { } export function useAgentRosterDetail(agentId?: string) { - return useQuery(consoleQuery.agent.byAgentId.get.queryOptions({ - input: agentId - ? { - params: { - agent_id: agentId, - }, - } - : skipToken, - })) + return useQuery( + consoleQuery.agent.byAgentId.get.queryOptions({ + input: agentId + ? { + params: { + agent_id: agentId, + }, + } + : skipToken, + }), + ) } export function useWorkflowInlineAgentDetail(nodeId?: string, agentId?: string | null) { - const configsMap = useHooksStore(state => state.configsMap) + const configsMap = useHooksStore((state) => state.configsMap) - return useQuery(consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryOptions({ - input: configsMap?.flowId && configsMap.flowType === FlowType.appFlow && nodeId && agentId - ? { - params: { - app_id: configsMap.flowId, - node_id: nodeId, - }, - } - : skipToken, - })) + return useQuery( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryOptions({ + input: + configsMap?.flowId && configsMap.flowType === FlowType.appFlow && nodeId && agentId + ? { + params: { + app_id: configsMap.flowId, + node_id: nodeId, + }, + } + : skipToken, + }), + ) } export function useCreateInlineAgentBinding() { const { t } = useTranslation('agentV2') - const configsMap = useHooksStore(state => state.configsMap) + const configsMap = useHooksStore((state) => state.configsMap) const { data: defaultModel } = useDefaultModel(ModelTypeEnum.textGeneration) const queryClient = useQueryClient() - const { - isPending, - mutateAsync, - } = useMutation( + const { isPending, mutateAsync } = useMutation( consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions(), ) - const createInlineAgentBinding = useCallback(async (nodeId: string, options?: CreateInlineAgentBindingOptions) => { - if (!configsMap?.flowId || configsMap.flowType !== FlowType.appFlow) { - toast.error(t($ => $['roster.nodeSelector.createInlineFailed'])) - options?.onError?.() - return - } - - try { - const composerState = await mutateAsync({ - params: { - app_id: configsMap.flowId, - node_id: nodeId, - }, - body: { - variant: 'workflow', - save_strategy: 'node_job_only', - binding: { - binding_type: 'inline_agent', - }, - soul_lock: { - locked: false, - }, - agent_soul: getDefaultAgentSoul(defaultModel), - }, - }) - const binding = composerState.binding - - if ( - binding?.binding_type !== 'inline_agent' - || !binding.agent_id - || !binding.current_snapshot_id - ) { - toast.error(t($ => $['roster.nodeSelector.createInlineFailed'])) + const createInlineAgentBinding = useCallback( + async (nodeId: string, options?: CreateInlineAgentBindingOptions) => { + if (!configsMap?.flowId || configsMap.flowType !== FlowType.appFlow) { + toast.error(t(($) => $['roster.nodeSelector.createInlineFailed'])) options?.onError?.() return } - queryClient.setQueryData( - consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ - input: { - params: { - app_id: configsMap.flowId, - node_id: nodeId, + try { + const composerState = await mutateAsync({ + params: { + app_id: configsMap.flowId, + node_id: nodeId, + }, + body: { + variant: 'workflow', + save_strategy: 'node_job_only', + binding: { + binding_type: 'inline_agent', + }, + soul_lock: { + locked: false, }, + agent_soul: getDefaultAgentSoul(defaultModel), }, - }), - composerState, - ) - options?.onSuccess?.({ - binding_type: 'inline_agent', - agent_id: binding.agent_id, - current_snapshot_id: binding.current_snapshot_id, - }) - } - catch { - options?.onError?.() - } - }, [configsMap?.flowId, configsMap?.flowType, defaultModel, mutateAsync, queryClient, t]) + }) + const binding = composerState.binding + + if ( + binding?.binding_type !== 'inline_agent' || + !binding.agent_id || + !binding.current_snapshot_id + ) { + toast.error(t(($) => $['roster.nodeSelector.createInlineFailed'])) + options?.onError?.() + return + } + + queryClient.setQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ + input: { + params: { + app_id: configsMap.flowId, + node_id: nodeId, + }, + }, + }), + composerState, + ) + options?.onSuccess?.({ + binding_type: 'inline_agent', + agent_id: binding.agent_id, + current_snapshot_id: binding.current_snapshot_id, + }) + } catch { + options?.onError?.() + } + }, + [configsMap?.flowId, configsMap?.flowType, defaultModel, mutateAsync, queryClient, t], + ) return { createInlineAgentBinding, diff --git a/web/app/components/workflow/nodes/agent-v2/node.tsx b/web/app/components/workflow/nodes/agent-v2/node.tsx index 3b1b1f2ed5c6ac..819e3c26c86d9d 100644 --- a/web/app/components/workflow/nodes/agent-v2/node.tsx +++ b/web/app/components/workflow/nodes/agent-v2/node.tsx @@ -8,8 +8,7 @@ import { useAgentRosterDetail, useWorkflowInlineAgentDetail } from './hooks' import { hasInlineAgentBinding, hasValidRosterAgentBinding } from './types' const getAppIconType = (iconType?: string | null): AppIconType | null => { - if (iconType === 'emoji' || iconType === 'image' || iconType === 'link') - return iconType + if (iconType === 'emoji' || iconType === 'image' || iconType === 'link') return iconType return null } @@ -58,38 +57,38 @@ function AgentNodeModel({ }) { const { t } = useTranslation() const isInlineAgent = hasInlineAgentBinding(data) - const name = isInlineAgent ? t($ => $['nodes.agent.roster.inlineSetup.name'], { ns: 'workflow' }) : agent?.name - const role = isInlineAgent ? t($ => $['nodes.agent.roster.inlineSetup.type'], { ns: 'workflow' }) : '' + const name = isInlineAgent + ? t(($) => $['nodes.agent.roster.inlineSetup.name'], { ns: 'workflow' }) + : agent?.name + const role = isInlineAgent + ? t(($) => $['nodes.agent.roster.inlineSetup.type'], { ns: 'workflow' }) + : '' const showPlaceholder = isLoading || (!isInlineAgent && !agent) return ( <div className="flex flex-col gap-0.5 py-1"> <div className="px-2.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['nodes.agent.roster.label'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.roster.label'], { ns: 'workflow' })} </div> <div className="px-2.5"> <div className="flex min-w-0 items-center gap-1 rounded-lg bg-workflow-block-parma-bg p-1"> - {showPlaceholder - ? <AgentNodeAvatarPlaceholder /> - : <AgentNodeAvatar agent={agent} isInlineAgent={isInlineAgent} />} + {showPlaceholder ? ( + <AgentNodeAvatarPlaceholder /> + ) : ( + <AgentNodeAvatar agent={agent} isInlineAgent={isInlineAgent} /> + )} <div className="flex min-w-0 flex-1 flex-col justify-center"> - {showPlaceholder - ? ( - <div aria-hidden className="flex flex-col gap-1.5 py-0.5"> - <span className="h-2 w-20 rounded-xs bg-text-quaternary/20" /> - <span className="h-2 w-14 rounded-xs bg-text-quaternary/15" /> - </div> - ) - : ( - <> - <div className="truncate system-xs-regular text-text-secondary"> - {name} - </div> - <div className="truncate system-2xs-regular text-text-tertiary"> - {role} - </div> - </> - )} + {showPlaceholder ? ( + <div aria-hidden className="flex flex-col gap-1.5 py-0.5"> + <span className="h-2 w-20 rounded-xs bg-text-quaternary/20" /> + <span className="h-2 w-14 rounded-xs bg-text-quaternary/15" /> + </div> + ) : ( + <> + <div className="truncate system-xs-regular text-text-secondary">{name}</div> + <div className="truncate system-2xs-regular text-text-tertiary">{role}</div> + </> + )} </div> </div> </div> @@ -101,21 +100,36 @@ export function AgentV2Node({ id, data }: NodeProps<AgentV2NodeType>) { const { t } = useTranslation() const hasValidAgent = hasValidRosterAgentBinding(data) const isInlineAgent = hasInlineAgentBinding(data) - const rosterAgentId = data.agent_binding?.binding_type === 'roster_agent' ? data.agent_binding.agent_id : undefined - const inlineAgentId = data.agent_binding?.binding_type === 'inline_agent' ? data.agent_binding.agent_id : undefined + const rosterAgentId = + data.agent_binding?.binding_type === 'roster_agent' ? data.agent_binding.agent_id : undefined + const inlineAgentId = + data.agent_binding?.binding_type === 'inline_agent' ? data.agent_binding.agent_id : undefined const rosterAgentQuery = useAgentRosterDetail(rosterAgentId) const inlineAgentQuery = useWorkflowInlineAgentDetail(id, inlineAgentId) const isInlineAgentDetailLoading = isInlineAgent && !!inlineAgentId && inlineAgentQuery.isPending if (isInlineAgent || hasValidAgent) - return <AgentNodeModel data={data} agent={rosterAgentQuery.data} isLoading={isInlineAgentDetailLoading} /> + return ( + <AgentNodeModel + data={data} + agent={rosterAgentQuery.data} + isLoading={isInlineAgentDetailLoading} + /> + ) return ( <div className="mb-1 space-y-1 px-3"> <SettingItem - label={t($ => $['nodes.agent.roster.label'], { ns: 'workflow' })} + label={t(($) => $['nodes.agent.roster.label'], { ns: 'workflow' })} status={hasValidAgent ? undefined : 'error'} - tooltip={hasValidAgent ? undefined : t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: t($ => $['nodes.agent.roster.label'], { ns: 'workflow' }) })} + tooltip={ + hasValidAgent + ? undefined + : t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: t(($) => $['nodes.agent.roster.label'], { ns: 'workflow' }), + }) + } > {rosterAgentQuery.data?.name} </SettingItem> diff --git a/web/app/components/workflow/nodes/agent-v2/output-variables.ts b/web/app/components/workflow/nodes/agent-v2/output-variables.ts index b2c8fec6e44bb1..c4201fd18b9afd 100644 --- a/web/app/components/workflow/nodes/agent-v2/output-variables.ts +++ b/web/app/components/workflow/nodes/agent-v2/output-variables.ts @@ -79,7 +79,7 @@ function getDeclaredOutputVarType(output: DeclaredOutputConfig) { } export function getAgentV2OutputVars(data: AgentV2NodeType): Var[] { - return getAgentV2DeclaredOutputs(data).map(output => ({ + return getAgentV2DeclaredOutputs(data).map((output) => ({ variable: output.name, type: getDeclaredOutputVarType(output), })) diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx index 2719bbdcdc128b..f487d89e162d4a 100644 --- a/web/app/components/workflow/nodes/agent-v2/panel.tsx +++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx @@ -1,4 +1,7 @@ -import type { AgentComposerBindingResponse, DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen' +import type { + AgentComposerBindingResponse, + DeclaredOutputConfig, +} from '@dify/contracts/api/console/apps/types.gen' import type { AgentRosterNodeData } from '../../block-selector/types' import type { NodePanelProps } from '../../types' import type { AgentV2NodeType } from './types' @@ -9,7 +12,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { - createAgentOutputConfig, extractAgentOutputNames, replaceAgentOutputName, @@ -19,14 +21,21 @@ import { useStore } from '@/app/components/workflow/store' import { consoleQuery } from '@/service/client' import useNodeCrud from '../_base/hooks/use-node-crud' import { AgentAdvancedSettings } from './components/agent-advanced-settings' -import { WorkflowInlineAgentConfigureWorkspace, WorkflowRosterAgentOrchestratePanelContent } from './components/agent-orchestrate-panel-content' +import { + WorkflowInlineAgentConfigureWorkspace, + WorkflowRosterAgentOrchestratePanelContent, +} from './components/agent-orchestrate-panel-content' import { AgentOutputVariables } from './components/agent-output-variables' import { OutputEditCard } from './components/agent-output-variables/edit-card' import { createDraft, isDefaultOutput } from './components/agent-output-variables/utils' import { AgentRosterField } from './components/agent-roster-field' import { AgentTaskField } from './components/agent-task-field' import { SaveInlineAgentToRosterDialog } from './components/save-inline-agent-to-roster-dialog' -import { useAgentRosterDetail, useCreateInlineAgentBinding, useWorkflowInlineAgentDetail } from './hooks' +import { + useAgentRosterDetail, + useCreateInlineAgentBinding, + useWorkflowInlineAgentDetail, +} from './hooks' import { getAgentV2DeclaredOutputs } from './output-variables' import { hasValidInlineAgentBinding } from './types' @@ -45,19 +54,18 @@ function FloatingOutputEditor({ editOutputRequestKey?: number editOutputType?: AgentOutputTypeOptionValue outputs: DeclaredOutputConfig[] - position?: { left: number, top: number } + position?: { left: number; top: number } onCancel: () => void onChange: (outputs: DeclaredOutputConfig[], agentTask?: string) => void }) { - if (!editOutputName || !position || typeof document === 'undefined') - return null + if (!editOutputName || !position || typeof document === 'undefined') return null - const outputIndex = outputs.findIndex(output => output.name === editOutputName) + const outputIndex = outputs.findIndex((output) => output.name === editOutputName) const existingOutput = outputs[outputIndex] - if (existingOutput && isDefaultOutput(existingOutput)) - return null + if (existingOutput && isDefaultOutput(existingOutput)) return null - const output = existingOutput ?? createAgentOutputConfig(editOutputName, editOutputType ?? 'string') + const output = + existingOutput ?? createAgentOutputConfig(editOutputName, editOutputType ?? 'string') const isExistingOutput = !!existingOutput return createPortal( @@ -79,13 +87,18 @@ function FloatingOutputEditor({ onCancel={onCancel} onConfirm={(nextOutput) => { const currentAgentTask = agentTask || '' - const nextAgentTask = nextOutput.name !== editOutputName && extractAgentOutputNames(currentAgentTask).has(editOutputName) - ? replaceAgentOutputName(currentAgentTask, editOutputName, nextOutput.name) - : undefined - - onChange(isExistingOutput - ? outputs.map((item, index) => index === outputIndex ? nextOutput : item) - : [...outputs.filter(output => output.name !== editOutputName), nextOutput], nextAgentTask) + const nextAgentTask = + nextOutput.name !== editOutputName && + extractAgentOutputNames(currentAgentTask).has(editOutputName) + ? replaceAgentOutputName(currentAgentTask, editOutputName, nextOutput.name) + : undefined + + onChange( + isExistingOutput + ? outputs.map((item, index) => (index === outputIndex ? nextOutput : item)) + : [...outputs.filter((output) => output.name !== editOutputName), nextOutput], + nextAgentTask, + ) onCancel() }} /> @@ -94,56 +107,71 @@ function FloatingOutputEditor({ ) } -export function AgentV2Panel({ - id, - data, -}: NodePanelProps<AgentV2NodeType>) { +export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) { const { t } = useTranslation() const { inputs, setInputs } = useNodeCrud<AgentV2NodeType>(id, data) const inputsRef = useRef(inputs) const promptOutputNamesRef = useRef(extractAgentOutputNames(inputs.agent_task || '')) const [isRosterAgentPanelOpen, setIsRosterAgentPanelOpen] = useState(false) - const [isInlineAgentPanelOpenedFromTrigger, setIsInlineAgentPanelOpenedFromTrigger] = useState(false) + const [isInlineAgentPanelOpenedFromTrigger, setIsInlineAgentPanelOpenedFromTrigger] = + useState(false) const [isSaveToRosterDialogOpen, setIsSaveToRosterDialogOpen] = useState(false) - const [editingOutputFromTask, setEditingOutputFromTask] = useState<{ name: string, outputType: AgentOutputTypeOptionValue, position: { left: number, top: number }, requestKey: number } | null>(null) + const [editingOutputFromTask, setEditingOutputFromTask] = useState<{ + name: string + outputType: AgentOutputTypeOptionValue + position: { left: number; top: number } + requestKey: number + } | null>(null) const [isOutputVariablesCollapsed, setIsOutputVariablesCollapsed] = useState(true) const [saveToRosterSessionKey, setSaveToRosterSessionKey] = useState(0) const { handleNodeDataUpdate, handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() - const openInlineAgentPanelNodeId = useStore(state => state.openInlineAgentPanelNodeId) - const setOpenInlineAgentPanelNodeId = useStore(state => state.setOpenInlineAgentPanelNodeId) - const appId = useStore(state => state.appId) + const openInlineAgentPanelNodeId = useStore((state) => state.openInlineAgentPanelNodeId) + const setOpenInlineAgentPanelNodeId = useStore((state) => state.setOpenInlineAgentPanelNodeId) + const appId = useStore((state) => state.appId) const drawerPortalContainerRef = useRef<HTMLDivElement>(null) - const [localDeclaredOutputs, setLocalDeclaredOutputs] = useState<DeclaredOutputConfig[] | null>(null) + const [localDeclaredOutputs, setLocalDeclaredOutputs] = useState<DeclaredOutputConfig[] | null>( + null, + ) const declaredOutputs = localDeclaredOutputs ?? getAgentV2DeclaredOutputs(inputs) - const rosterAgentId = inputs.agent_binding?.binding_type === 'roster_agent' ? inputs.agent_binding.agent_id : undefined - const inlineAgentId = inputs.agent_binding?.binding_type === 'inline_agent' ? inputs.agent_binding.agent_id : undefined + const rosterAgentId = + inputs.agent_binding?.binding_type === 'roster_agent' + ? inputs.agent_binding.agent_id + : undefined + const inlineAgentId = + inputs.agent_binding?.binding_type === 'inline_agent' + ? inputs.agent_binding.agent_id + : undefined const isInlineAgentReady = hasValidInlineAgentBinding(inputs) - const isInlineAgentPending = inputs.agent_binding?.binding_type === 'inline_agent' && !isInlineAgentReady - const isInlineAgentPanelOpen = (isInlineAgentReady || isInlineAgentPending) && openInlineAgentPanelNodeId === id + const isInlineAgentPending = + inputs.agent_binding?.binding_type === 'inline_agent' && !isInlineAgentReady + const isInlineAgentPanelOpen = + (isInlineAgentReady || isInlineAgentPending) && openInlineAgentPanelNodeId === id const rosterAgentQuery = useAgentRosterDetail(rosterAgentId) const inlineAgentQuery = useWorkflowInlineAgentDetail(id, inlineAgentId) const { createInlineAgentBinding, isCreatingInlineAgent } = useCreateInlineAgentBinding() const inlineAgent = inlineAgentQuery.data?.agent - const { - isPending: isCopyingFromRoster, - mutate: copyFromRoster, - } = useMutation( + const { isPending: isCopyingFromRoster, mutate: copyFromRoster } = useMutation( consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.copyFromRoster.post.mutationOptions(), ) - const isAgentPanelOpen = isInlineAgentReady || isInlineAgentPending ? isInlineAgentPanelOpen : isRosterAgentPanelOpen + const isAgentPanelOpen = + isInlineAgentReady || isInlineAgentPending ? isInlineAgentPanelOpen : isRosterAgentPanelOpen const isInlineAgentLoading = isInlineAgentPending || (isInlineAgentReady && !inlineAgent) const isAgentBindingPending = isInlineAgentPending || isCreatingInlineAgent const canStartFromScratch = inputs.agent_binding?.binding_type !== 'inline_agent' const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent const inlineComposerStateForPanel = inlineAgentQuery.data - const displayedAgent = rosterAgentQuery.data ?? (isInlineAgentPending || isInlineAgentReady - ? { - id: inlineAgentId ?? id, - name: inlineAgent?.name || t($ => $['nodes.agent.roster.inlineSetup.name'], { ns: 'workflow' }), - description: inlineAgent?.description, - role: t($ => $['nodes.agent.roster.inlineSetup.type'], { ns: 'workflow' }), - } - : undefined) + const displayedAgent = + rosterAgentQuery.data ?? + (isInlineAgentPending || isInlineAgentReady + ? { + id: inlineAgentId ?? id, + name: + inlineAgent?.name || + t(($) => $['nodes.agent.roster.inlineSetup.name'], { ns: 'workflow' }), + description: inlineAgent?.description, + role: t(($) => $['nodes.agent.roster.inlineSetup.type'], { ns: 'workflow' }), + } + : undefined) useEffect(() => { inputsRef.current = inputs @@ -151,8 +179,7 @@ export function AgentV2Panel({ }, [inputs]) useEffect(() => { - if (!inputs._openInlineAgentPanel || !isInlineAgentReady) - return + if (!inputs._openInlineAgentPanel || !isInlineAgentReady) return setOpenInlineAgentPanelNodeId(id) handleNodeDataUpdate({ @@ -161,196 +188,226 @@ export function AgentV2Panel({ _openInlineAgentPanel: false, }, }) - }, [handleNodeDataUpdate, id, inputs._openInlineAgentPanel, isInlineAgentReady, setOpenInlineAgentPanelNodeId]) - - const handleTaskChange = useCallback((value: string) => { - const currentPromptOutputNames = extractAgentOutputNames(value) - const removedPromptOutputNames = [...promptOutputNamesRef.current].filter(name => !currentPromptOutputNames.has(name)) - const addedPromptOutputNames = [...currentPromptOutputNames].filter(name => !promptOutputNamesRef.current.has(name)) - const newInputs = produce(inputsRef.current, (draft) => { - draft.agent_task = value - if (removedPromptOutputNames.length) { - const currentDeclaredOutputs = getAgentV2DeclaredOutputs(draft) - if (removedPromptOutputNames.length === 1 && addedPromptOutputNames.length === 1) { - const oldName = removedPromptOutputNames[0]! - const nextName = addedPromptOutputNames[0]! - draft.agent_declared_outputs = currentDeclaredOutputs.map(output => - output.name === oldName ? { ...output, name: nextName } : output, - ) + }, [ + handleNodeDataUpdate, + id, + inputs._openInlineAgentPanel, + isInlineAgentReady, + setOpenInlineAgentPanelNodeId, + ]) + + const handleTaskChange = useCallback( + (value: string) => { + const currentPromptOutputNames = extractAgentOutputNames(value) + const removedPromptOutputNames = [...promptOutputNamesRef.current].filter( + (name) => !currentPromptOutputNames.has(name), + ) + const addedPromptOutputNames = [...currentPromptOutputNames].filter( + (name) => !promptOutputNamesRef.current.has(name), + ) + const newInputs = produce(inputsRef.current, (draft) => { + draft.agent_task = value + if (removedPromptOutputNames.length) { + const currentDeclaredOutputs = getAgentV2DeclaredOutputs(draft) + if (removedPromptOutputNames.length === 1 && addedPromptOutputNames.length === 1) { + const oldName = removedPromptOutputNames[0]! + const nextName = addedPromptOutputNames[0]! + draft.agent_declared_outputs = currentDeclaredOutputs.map((output) => + output.name === oldName ? { ...output, name: nextName } : output, + ) + } else { + const removedNameSet = new Set(removedPromptOutputNames) + draft.agent_declared_outputs = currentDeclaredOutputs.filter( + (output) => !removedNameSet.has(output.name), + ) + } } - else { - const removedNameSet = new Set(removedPromptOutputNames) - draft.agent_declared_outputs = currentDeclaredOutputs - .filter(output => !removedNameSet.has(output.name)) + }) + inputsRef.current = newInputs + promptOutputNamesRef.current = currentPromptOutputNames + if (removedPromptOutputNames.length) + setLocalDeclaredOutputs(newInputs.agent_declared_outputs ?? []) + setInputs(newInputs) + }, + [setInputs], + ) + + const handleRosterChange = useCallback( + (agent: AgentRosterNodeData) => { + setOpenInlineAgentPanelNodeId(undefined) + const newInputs = produce(inputs, (draft) => { + delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster + draft.agent_binding = { + binding_type: 'roster_agent', + agent_id: agent.id, } - } - }) - inputsRef.current = newInputs - promptOutputNamesRef.current = currentPromptOutputNames - if (removedPromptOutputNames.length) - setLocalDeclaredOutputs(newInputs.agent_declared_outputs ?? []) - setInputs(newInputs) - }, [setInputs]) - - const handleRosterChange = useCallback((agent: AgentRosterNodeData) => { - setOpenInlineAgentPanelNodeId(undefined) - const newInputs = produce(inputs, (draft) => { - delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster - draft.agent_binding = { - binding_type: 'roster_agent', - agent_id: agent.id, - } - }) - handleNodeDataUpdateWithSyncDraft( - { - id, - data: newInputs, - }, - { - sync: true, - notRefreshWhenSyncError: true, - }, - ) - }, [handleNodeDataUpdateWithSyncDraft, id, inputs, setOpenInlineAgentPanelNodeId]) + }) + handleNodeDataUpdateWithSyncDraft( + { + id, + data: newInputs, + }, + { + sync: true, + notRefreshWhenSyncError: true, + }, + ) + }, + [handleNodeDataUpdateWithSyncDraft, id, inputs, setOpenInlineAgentPanelNodeId], + ) const handleMakeRosterCopy = useCallback(() => { - if (!appId || !rosterAgentId || isCopyingFromRoster) - return + if (!appId || !rosterAgentId || isCopyingFromRoster) return - copyFromRoster({ - params: { - app_id: appId, - node_id: id, - }, - body: { - source_agent_id: rosterAgentId, + copyFromRoster( + { + params: { + app_id: appId, + node_id: id, + }, + body: { + source_agent_id: rosterAgentId, + }, }, - }, { - onSuccess: (composerState) => { - const binding = composerState.binding - if ( - binding?.binding_type !== 'inline_agent' - || !binding.agent_id - || !binding.current_snapshot_id - ) { - return - } - - setIsRosterAgentPanelOpen(false) - setIsInlineAgentPanelOpenedFromTrigger(true) - setOpenInlineAgentPanelNodeId(id) - - const newInputs = produce(inputsRef.current, (draft) => { - delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster - draft.agent_binding = { - binding_type: 'inline_agent', - agent_id: binding.agent_id, - current_snapshot_id: binding.current_snapshot_id, + { + onSuccess: (composerState) => { + const binding = composerState.binding + if ( + binding?.binding_type !== 'inline_agent' || + !binding.agent_id || + !binding.current_snapshot_id + ) { + return } - draft._openInlineAgentPanel = true - }) - inputsRef.current = newInputs - handleNodeDataUpdateWithSyncDraft( - { - id, - data: newInputs, - }, - { - sync: true, - notRefreshWhenSyncError: true, - }, - ) + + setIsRosterAgentPanelOpen(false) + setIsInlineAgentPanelOpenedFromTrigger(true) + setOpenInlineAgentPanelNodeId(id) + + const newInputs = produce(inputsRef.current, (draft) => { + delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster + draft.agent_binding = { + binding_type: 'inline_agent', + agent_id: binding.agent_id, + current_snapshot_id: binding.current_snapshot_id, + } + draft._openInlineAgentPanel = true + }) + inputsRef.current = newInputs + handleNodeDataUpdateWithSyncDraft( + { + id, + data: newInputs, + }, + { + sync: true, + notRefreshWhenSyncError: true, + }, + ) + }, }, - }) - }, [appId, copyFromRoster, handleNodeDataUpdateWithSyncDraft, id, isCopyingFromRoster, rosterAgentId, setOpenInlineAgentPanelNodeId]) + ) + }, [ + appId, + copyFromRoster, + handleNodeDataUpdateWithSyncDraft, + id, + isCopyingFromRoster, + rosterAgentId, + setOpenInlineAgentPanelNodeId, + ]) const handleSaveInlineToRosterOpen = useCallback(() => { - setSaveToRosterSessionKey(key => key + 1) + setSaveToRosterSessionKey((key) => key + 1) setIsSaveToRosterDialogOpen(true) }, []) - const handleInlineSavedToRoster = useCallback((binding: AgentComposerBindingResponse) => { - if (binding.binding_type !== 'roster_agent' || !binding.agent_id) - return + const handleInlineSavedToRoster = useCallback( + (binding: AgentComposerBindingResponse) => { + if (binding.binding_type !== 'roster_agent' || !binding.agent_id) return - setOpenInlineAgentPanelNodeId(undefined) - setIsInlineAgentPanelOpenedFromTrigger(false) - setIsRosterAgentPanelOpen(true) + setOpenInlineAgentPanelNodeId(undefined) + setIsInlineAgentPanelOpenedFromTrigger(false) + setIsRosterAgentPanelOpen(true) - const newInputs = produce(inputsRef.current, (draft) => { - delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster - delete draft._openInlineAgentPanel - draft.agent_binding = { - binding_type: 'roster_agent', - agent_id: binding.agent_id!, - } - }) - inputsRef.current = newInputs - handleNodeDataUpdateWithSyncDraft( - { - id, - data: newInputs, - }, - { - sync: true, - notRefreshWhenSyncError: true, - }, - ) - }, [handleNodeDataUpdateWithSyncDraft, id, setOpenInlineAgentPanelNodeId]) - - const handleInlineAgentBindingCreated = useCallback((binding: { - agent_id: string - binding_type: 'inline_agent' - current_snapshot_id: string - }) => { - const newInputs = produce(inputsRef.current, (draft) => { - delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster - draft.agent_binding = binding - draft._openInlineAgentPanel = true - }) - inputsRef.current = newInputs - handleNodeDataUpdateWithSyncDraft( - { - id, - data: newInputs, - }, - { - sync: true, - notRefreshWhenSyncError: true, - }, - ) - }, [handleNodeDataUpdateWithSyncDraft, id]) - - const handleInlineAgentSaved = useCallback((binding: AgentComposerBindingResponse) => { - if ( - binding.binding_type !== 'inline_agent' - || !binding.agent_id - || !binding.current_snapshot_id - ) { - return - } - - const newInputs = produce(inputsRef.current, (draft) => { - delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster - delete draft._openInlineAgentPanel - draft.agent_binding = { - binding_type: 'inline_agent', - agent_id: binding.agent_id, - current_snapshot_id: binding.current_snapshot_id, + const newInputs = produce(inputsRef.current, (draft) => { + delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster + delete draft._openInlineAgentPanel + draft.agent_binding = { + binding_type: 'roster_agent', + agent_id: binding.agent_id!, + } + }) + inputsRef.current = newInputs + handleNodeDataUpdateWithSyncDraft( + { + id, + data: newInputs, + }, + { + sync: true, + notRefreshWhenSyncError: true, + }, + ) + }, + [handleNodeDataUpdateWithSyncDraft, id, setOpenInlineAgentPanelNodeId], + ) + + const handleInlineAgentBindingCreated = useCallback( + (binding: { agent_id: string; binding_type: 'inline_agent'; current_snapshot_id: string }) => { + const newInputs = produce(inputsRef.current, (draft) => { + delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster + draft.agent_binding = binding + draft._openInlineAgentPanel = true + }) + inputsRef.current = newInputs + handleNodeDataUpdateWithSyncDraft( + { + id, + data: newInputs, + }, + { + sync: true, + notRefreshWhenSyncError: true, + }, + ) + }, + [handleNodeDataUpdateWithSyncDraft, id], + ) + + const handleInlineAgentSaved = useCallback( + (binding: AgentComposerBindingResponse) => { + if ( + binding.binding_type !== 'inline_agent' || + !binding.agent_id || + !binding.current_snapshot_id + ) { + return } - }) - inputsRef.current = newInputs - handleNodeDataUpdateWithSyncDraft( - { - id, - data: newInputs, - }, - { - sync: true, - notRefreshWhenSyncError: true, - }, - ) - }, [handleNodeDataUpdateWithSyncDraft, id]) + + const newInputs = produce(inputsRef.current, (draft) => { + delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster + delete draft._openInlineAgentPanel + draft.agent_binding = { + binding_type: 'inline_agent', + agent_id: binding.agent_id, + current_snapshot_id: binding.current_snapshot_id, + } + }) + inputsRef.current = newInputs + handleNodeDataUpdateWithSyncDraft( + { + id, + data: newInputs, + }, + { + sync: true, + notRefreshWhenSyncError: true, + }, + ) + }, + [handleNodeDataUpdateWithSyncDraft, id], + ) const handleStartFromScratch = useCallback(() => { setIsRosterAgentPanelOpen(false) @@ -379,102 +436,129 @@ export function AgentV2Panel({ createInlineAgentBinding(id, { onSuccess: handleInlineAgentBindingCreated, }) - }, [createInlineAgentBinding, handleInlineAgentBindingCreated, handleNodeDataUpdateWithSyncDraft, id, setOpenInlineAgentPanelNodeId]) + }, [ + createInlineAgentBinding, + handleInlineAgentBindingCreated, + handleNodeDataUpdateWithSyncDraft, + id, + setOpenInlineAgentPanelNodeId, + ]) + + const handleAgentPanelOpenChange = useCallback( + (open: boolean) => { + if (isInlineAgentReady || isInlineAgentPending) { + if (open) setIsInlineAgentPanelOpenedFromTrigger(true) + + setOpenInlineAgentPanelNodeId(open ? id : undefined) + if (open && isInlineAgentReady) void inlineAgentQuery.refetch() + + if (open && isInlineAgentPending && !isCreatingInlineAgent) { + createInlineAgentBinding(id, { + onSuccess: handleInlineAgentBindingCreated, + }) + } + return + } - const handleAgentPanelOpenChange = useCallback((open: boolean) => { - if (isInlineAgentReady || isInlineAgentPending) { - if (open) - setIsInlineAgentPanelOpenedFromTrigger(true) + setIsRosterAgentPanelOpen(open) + }, + [ + createInlineAgentBinding, + handleInlineAgentBindingCreated, + id, + inlineAgentQuery, + isCreatingInlineAgent, + isInlineAgentPending, + isInlineAgentReady, + setOpenInlineAgentPanelNodeId, + ], + ) - setOpenInlineAgentPanelNodeId(open ? id : undefined) - if (open && isInlineAgentReady) - void inlineAgentQuery.refetch() + const handleDeclaredOutputsChange = useCallback( + (outputs: ReturnType<typeof getAgentV2DeclaredOutputs>, agentTask?: string) => { + setIsOutputVariablesCollapsed(false) + const previousOutputs = getAgentV2DeclaredOutputs(inputsRef.current) + let nextAgentTask = agentTask + let nextOutputs = outputs + if (agentTask !== undefined) { + const nextPromptOutputNames = extractAgentOutputNames(agentTask) + const removedPromptOutputNames = [...promptOutputNamesRef.current].filter( + (name) => !nextPromptOutputNames.has(name), + ) + const addedPromptOutputNames = [...nextPromptOutputNames].filter( + (name) => !promptOutputNamesRef.current.has(name), + ) - if (open && isInlineAgentPending && !isCreatingInlineAgent) { - createInlineAgentBinding(id, { - onSuccess: handleInlineAgentBindingCreated, - }) - } - return - } - - setIsRosterAgentPanelOpen(open) - }, [createInlineAgentBinding, handleInlineAgentBindingCreated, id, inlineAgentQuery, isCreatingInlineAgent, isInlineAgentPending, isInlineAgentReady, setOpenInlineAgentPanelNodeId]) - - const handleDeclaredOutputsChange = useCallback((outputs: ReturnType<typeof getAgentV2DeclaredOutputs>, agentTask?: string) => { - setIsOutputVariablesCollapsed(false) - const previousOutputs = getAgentV2DeclaredOutputs(inputsRef.current) - let nextAgentTask = agentTask - let nextOutputs = outputs - if (agentTask !== undefined) { - const nextPromptOutputNames = extractAgentOutputNames(agentTask) - const removedPromptOutputNames = [...promptOutputNamesRef.current].filter(name => !nextPromptOutputNames.has(name)) - const addedPromptOutputNames = [...nextPromptOutputNames].filter(name => !promptOutputNamesRef.current.has(name)) - - if (removedPromptOutputNames.length === 1 && addedPromptOutputNames.length === 1) { - const oldName = removedPromptOutputNames[0]! - const nextName = addedPromptOutputNames[0]! - const oldOutputIndex = previousOutputs.findIndex(output => output.name === oldName) - const nextOutput = outputs.find(output => output.name === nextName) - if (oldOutputIndex >= 0 && nextOutput) { - nextOutputs = previousOutputs.map((output, index) => index === oldOutputIndex ? nextOutput : output) + if (removedPromptOutputNames.length === 1 && addedPromptOutputNames.length === 1) { + const oldName = removedPromptOutputNames[0]! + const nextName = addedPromptOutputNames[0]! + const oldOutputIndex = previousOutputs.findIndex((output) => output.name === oldName) + const nextOutput = outputs.find((output) => output.name === nextName) + if (oldOutputIndex >= 0 && nextOutput) { + nextOutputs = previousOutputs.map((output, index) => + index === oldOutputIndex ? nextOutput : output, + ) + } } } - } - if (nextAgentTask === undefined && previousOutputs.length === outputs.length) { - const renamedOutputs = previousOutputs - .map((previousOutput, index) => ({ - oldName: previousOutput.name, - nextName: outputs[index]?.name, - })) - .filter(({ oldName, nextName }) => nextName && oldName !== nextName) - - if (renamedOutputs.length === 1) { - const { oldName, nextName } = renamedOutputs[0]! - const currentAgentTask = inputsRef.current.agent_task || '' - if (extractAgentOutputNames(currentAgentTask).has(oldName)) - nextAgentTask = replaceAgentOutputName(currentAgentTask, oldName, nextName!) + if (nextAgentTask === undefined && previousOutputs.length === outputs.length) { + const renamedOutputs = previousOutputs + .map((previousOutput, index) => ({ + oldName: previousOutput.name, + nextName: outputs[index]?.name, + })) + .filter(({ oldName, nextName }) => nextName && oldName !== nextName) + + if (renamedOutputs.length === 1) { + const { oldName, nextName } = renamedOutputs[0]! + const currentAgentTask = inputsRef.current.agent_task || '' + if (extractAgentOutputNames(currentAgentTask).has(oldName)) + nextAgentTask = replaceAgentOutputName(currentAgentTask, oldName, nextName!) + } } - } - const newInputs = produce(inputsRef.current, (draft) => { - draft.agent_declared_outputs = nextOutputs + const newInputs = produce(inputsRef.current, (draft) => { + draft.agent_declared_outputs = nextOutputs + if (nextAgentTask !== undefined) draft.agent_task = nextAgentTask + }) + inputsRef.current = newInputs + setLocalDeclaredOutputs(nextOutputs) if (nextAgentTask !== undefined) - draft.agent_task = nextAgentTask - }) - inputsRef.current = newInputs - setLocalDeclaredOutputs(nextOutputs) - if (nextAgentTask !== undefined) - promptOutputNamesRef.current = extractAgentOutputNames(nextAgentTask) - handleNodeDataUpdateWithSyncDraft( - { - id, - data: newInputs, - }, - { - sync: true, - notRefreshWhenSyncError: true, - }, - ) - }, [handleNodeDataUpdateWithSyncDraft, id]) - - const handleEditTaskOutput = useCallback((name: string, outputType: AgentOutputTypeOptionValue) => { - setIsOutputVariablesCollapsed(false) - const panelRect = drawerPortalContainerRef.current?.getBoundingClientRect() - const editorWidth = 400 - const gap = 24 - const position = { - left: Math.max(16, (panelRect?.left ?? 0) - editorWidth - gap), - top: Math.max(16, (panelRect?.top ?? 0) + 144), - } - - setEditingOutputFromTask(current => ({ - name, - outputType, - position, - requestKey: (current?.requestKey ?? 0) + 1, - })) - }, []) + promptOutputNamesRef.current = extractAgentOutputNames(nextAgentTask) + handleNodeDataUpdateWithSyncDraft( + { + id, + data: newInputs, + }, + { + sync: true, + notRefreshWhenSyncError: true, + }, + ) + }, + [handleNodeDataUpdateWithSyncDraft, id], + ) + + const handleEditTaskOutput = useCallback( + (name: string, outputType: AgentOutputTypeOptionValue) => { + setIsOutputVariablesCollapsed(false) + const panelRect = drawerPortalContainerRef.current?.getBoundingClientRect() + const editorWidth = 400 + const gap = 24 + const position = { + left: Math.max(16, (panelRect?.left ?? 0) - editorWidth - gap), + top: Math.max(16, (panelRect?.top ?? 0) + 144), + } + + setEditingOutputFromTask((current) => ({ + name, + outputType, + position, + requestKey: (current?.requestKey ?? 0) + 1, + })) + }, + [], + ) return ( <div ref={drawerPortalContainerRef} className="relative pt-2"> @@ -498,31 +582,35 @@ export function AgentV2Panel({ isPanelCopyPending={isCopyingFromRoster} isPanelOpen={isAgentPanelOpen} isPending={isAgentBindingPending} - panelBody={isAgentPanelOpen && displayedAgent - ? ( - isInlineAgentReady || isInlineAgentPending - ? ( - <WorkflowInlineAgentConfigureWorkspace - agentId={inlineAgentId ?? undefined} - appId={appId} - inlineComposerState={inlineComposerStateForPanel} - nodeId={id} - onClose={() => handleAgentPanelOpenChange(false)} - onSaved={handleInlineAgentSaved} - onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined} - open={isAgentPanelOpen} - /> - ) - : ( - <WorkflowRosterAgentOrchestratePanelContent - agentId={rosterAgentId} - nodeId={id} - open={isAgentPanelOpen} - /> - ) + panelBody={ + isAgentPanelOpen && displayedAgent ? ( + isInlineAgentReady || isInlineAgentPending ? ( + <WorkflowInlineAgentConfigureWorkspace + agentId={inlineAgentId ?? undefined} + appId={appId} + inlineComposerState={inlineComposerStateForPanel} + nodeId={id} + onClose={() => handleAgentPanelOpenChange(false)} + onSaved={handleInlineAgentSaved} + onSaveInlineToRoster={ + canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined + } + open={isAgentPanelOpen} + /> + ) : ( + <WorkflowRosterAgentOrchestratePanelContent + agentId={rosterAgentId} + nodeId={id} + open={isAgentPanelOpen} + /> ) - : undefined} - panelMode={isInlineAgentPending || (isInlineAgentReady && !isInlineAgentPanelOpenedFromTrigger) ? 'setup' : 'detail'} + ) : undefined + } + panelMode={ + isInlineAgentPending || (isInlineAgentReady && !isInlineAgentPanelOpenedFromTrigger) + ? 'setup' + : 'detail' + } portalContainerRef={drawerPortalContainerRef} showPanelDetailActions={!isInlineAgentReady && !isInlineAgentPending} onChange={handleRosterChange} diff --git a/web/app/components/workflow/nodes/agent-v2/types.ts b/web/app/components/workflow/nodes/agent-v2/types.ts index 41989ede16bb66..abe023cf7d6bca 100644 --- a/web/app/components/workflow/nodes/agent-v2/types.ts +++ b/web/app/components/workflow/nodes/agent-v2/types.ts @@ -12,16 +12,20 @@ export type AgentV2NodeType = CommonNodeType & { } export function isAgentV2NodeData(data: CommonNodeType): data is AgentV2NodeType { - const payload = data as { agent_node_kind?: string, version?: string } - return (data.type === BlockEnum.Agent || data.type === BlockEnum.AgentV2) - && payload.agent_node_kind === 'dify_agent' - && payload.version === '2' + const payload = data as { agent_node_kind?: string; version?: string } + return ( + (data.type === BlockEnum.Agent || data.type === BlockEnum.AgentV2) && + payload.agent_node_kind === 'dify_agent' && + payload.version === '2' + ) } export function hasValidRosterAgentBinding(data: AgentV2NodeType) { - return data.agent_binding?.binding_type === 'roster_agent' - && typeof data.agent_binding.agent_id === 'string' - && data.agent_binding.agent_id.length > 0 + return ( + data.agent_binding?.binding_type === 'roster_agent' && + typeof data.agent_binding.agent_id === 'string' && + data.agent_binding.agent_id.length > 0 + ) } export function hasInlineAgentBinding(data: AgentV2NodeType) { @@ -29,11 +33,13 @@ export function hasInlineAgentBinding(data: AgentV2NodeType) { } export function hasValidInlineAgentBinding(data: AgentV2NodeType) { - return data.agent_binding?.binding_type === 'inline_agent' - && typeof data.agent_binding.agent_id === 'string' - && data.agent_binding.agent_id.length > 0 - && typeof data.agent_binding.current_snapshot_id === 'string' - && data.agent_binding.current_snapshot_id.length > 0 + return ( + data.agent_binding?.binding_type === 'inline_agent' && + typeof data.agent_binding.agent_id === 'string' && + data.agent_binding.agent_id.length > 0 && + typeof data.agent_binding.current_snapshot_id === 'string' && + data.agent_binding.current_snapshot_id.length > 0 + ) } export function needsInlineAgentBindingCreation(data: AgentV2NodeType) { diff --git a/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx index b203f4e7190f4e..42f6200d6d3b51 100644 --- a/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx @@ -4,7 +4,10 @@ import type { StrategyParamItem } from '@/app/components/plugins/types' import type { PanelProps } from '@/types/workflow' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + FormTypeEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { BlockEnum } from '@/app/components/workflow/types' import { VarType as ToolVarType } from '../../tool/types' import { ModelBar } from '../components/model-bar' @@ -14,12 +17,20 @@ import Panel from '../panel' import { AgentFeature } from '../types' import useConfig from '../use-config' -let mockTextGenerationModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = [] -let mockModerationModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = [] -let mockRerankModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = [] -let mockSpeech2TextModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = [] -let mockTextEmbeddingModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = [] -let mockTtsModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = [] +let mockTextGenerationModels: + | Array<{ provider: string; models: Array<{ model: string }> }> + | undefined = [] +let mockModerationModels: + | Array<{ provider: string; models: Array<{ model: string }> }> + | undefined = [] +let mockRerankModels: Array<{ provider: string; models: Array<{ model: string }> }> | undefined = [] +let mockSpeech2TextModels: + | Array<{ provider: string; models: Array<{ model: string }> }> + | undefined = [] +let mockTextEmbeddingModels: + | Array<{ provider: string; models: Array<{ model: string }> }> + | undefined = [] +let mockTtsModels: Array<{ provider: string; models: Array<{ model: string }> }> | undefined = [] let mockBuiltInTools: Array<any> | undefined = [] let mockCustomTools: Array<any> | undefined = [] @@ -31,23 +42,21 @@ const mockResetEditor = vi.fn() vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useModelList: (modelType: ModelTypeEnum) => { - if (modelType === ModelTypeEnum.textGeneration) - return { data: mockTextGenerationModels } - if (modelType === ModelTypeEnum.moderation) - return { data: mockModerationModels } - if (modelType === ModelTypeEnum.rerank) - return { data: mockRerankModels } - if (modelType === ModelTypeEnum.speech2text) - return { data: mockSpeech2TextModels } - if (modelType === ModelTypeEnum.textEmbedding) - return { data: mockTextEmbeddingModels } + if (modelType === ModelTypeEnum.textGeneration) return { data: mockTextGenerationModels } + if (modelType === ModelTypeEnum.moderation) return { data: mockModerationModels } + if (modelType === ModelTypeEnum.rerank) return { data: mockRerankModels } + if (modelType === ModelTypeEnum.speech2text) return { data: mockSpeech2TextModels } + if (modelType === ModelTypeEnum.textEmbedding) return { data: mockTextEmbeddingModels } return { data: mockTtsModels } }, })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ default: ({ defaultModel, modelList }: any) => ( - <div>{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}:{modelList.length}</div> + <div> + {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}: + {modelList.length} + </div> ), })) @@ -79,30 +88,48 @@ vi.mock('@/hooks/use-i18n', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/components/group', () => ({ - Group: ({ label, children }: any) => <div><div>{label}</div>{children}</div>, + Group: ({ label, children }: any) => ( + <div> + <div>{label}</div> + {children} + </div> + ), GroupLabel: ({ className, children }: any) => <div className={className}>{children}</div>, })) vi.mock('@/app/components/workflow/nodes/_base/components/setting-item', () => ({ - SettingItem: ({ label, status, tooltip, children }: any) => <div>{label}:{status}:{tooltip}:{children}</div>, + SettingItem: ({ label, status, tooltip, children }: any) => ( + <div> + {label}:{status}:{tooltip}:{children} + </div> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ - default: ({ title, children }: any) => <div><div>{title}</div>{children}</div>, + default: ({ title, children }: any) => ( + <div> + <div>{title}</div> + {children} + </div> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/agent-strategy', () => ({ AgentStrategy: ({ onStrategyChange }: any) => ( <button type="button" - onClick={() => onStrategyChange({ - agent_strategy_provider_name: 'provider/updated', - agent_strategy_name: 'updated-strategy', - agent_strategy_label: 'Updated Strategy', - agent_output_schema: { properties: { extra: { type: 'string', description: 'extra output' } } }, - plugin_unique_identifier: 'provider/updated:1.0.0', - meta: { version: '2.0.0' }, - })} + onClick={() => + onStrategyChange({ + agent_strategy_provider_name: 'provider/updated', + agent_strategy_name: 'updated-strategy', + agent_strategy_label: 'Updated Strategy', + agent_output_schema: { + properties: { extra: { type: 'string', description: 'extra output' } }, + }, + plugin_unique_identifier: 'provider/updated:1.0.0', + meta: { version: '2.0.0' }, + }) + } > change-strategy </button> @@ -114,7 +141,16 @@ vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability' })) vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({ - default: ({ onChange }: any) => <button type="button" onClick={() => onChange({ window: { enabled: true, size: 8 }, query_prompt_template: 'history' })}>change-memory</button>, + default: ({ onChange }: any) => ( + <button + type="button" + onClick={() => + onChange({ window: { enabled: true, size: 8 }, query_prompt_template: 'history' }) + } + > + change-memory + </button> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ @@ -127,9 +163,12 @@ vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { setControlPromptEditorRerenderKey: typeof mockResetEditor }) => unknown) => selector({ - setControlPromptEditorRerenderKey: mockResetEditor, - }), + useStore: ( + selector: (state: { setControlPromptEditorRerenderKey: typeof mockResetEditor }) => unknown, + ) => + selector({ + setControlPromptEditorRerenderKey: mockResetEditor, + }), })) vi.mock('@/utils/plugin-version-feature', () => ({ @@ -178,7 +217,9 @@ const createData = (overrides: Partial<AgentNodeType> = {}): AgentNodeType => ({ ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), setInputs: vi.fn(), @@ -240,9 +281,21 @@ describe('agent path', () => { mockSpeech2TextModels = [] mockTextEmbeddingModels = [] mockTtsModels = [] - mockBuiltInTools = [{ name: 'author/tool-a', is_team_authorization: true, icon: 'https://example.com/icon-a.png' }] + mockBuiltInTools = [ + { + name: 'author/tool-a', + is_team_authorization: true, + icon: 'https://example.com/icon-a.png', + }, + ] mockCustomTools = [] - mockWorkflowTools = [{ id: 'author/tool-b', is_team_authorization: false, icon: { content: 'B', background: '#fff' } }] + mockWorkflowTools = [ + { + id: 'author/tool-b', + is_team_authorization: false, + icon: { content: 'B', background: '#fff' }, + }, + ] mockMcpTools = [] mockMarketplaceIcon = 'https://example.com/marketplace.png' mockUseConfig.mockReturnValue(createConfigResult()) @@ -289,12 +342,7 @@ describe('agent path', () => { }) it('should render strategy, models, and toolbox entries in the node', () => { - const { container } = render( - <Node - id="agent-node" - data={createData()} - />, - ) + const { container } = render(<Node id="agent-node" data={createData()} />) expect(screen.getByText(/workflow\.nodes\.agent\.strategy\.shortLabel/)).toBeInTheDocument() expect(container).toHaveTextContent('React Agent') @@ -309,25 +357,23 @@ describe('agent path', () => { const config = createConfigResult() mockUseConfig.mockReturnValue(config) - render( - <Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<Panel id="agent-node" data={createData()} panelProps={panelProps} />) expect(screen.getByText('workflow.nodes.agent.strategy.label')).toBeInTheDocument() - expect(screen.getByText('text:String:workflow.nodes.agent.outputVars.text')).toBeInTheDocument() + expect( + screen.getByText('text:String:workflow.nodes.agent.outputVars.text'), + ).toBeInTheDocument() expect(screen.getByText('jsonField:String:json output')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'change-strategy' })) - expect(config.setInputs).toHaveBeenCalledWith(expect.objectContaining({ - agent_strategy_provider_name: 'provider/updated', - agent_strategy_name: 'updated-strategy', - agent_strategy_label: 'Updated Strategy', - plugin_unique_identifier: 'provider/updated:1.0.0', - })) + expect(config.setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + agent_strategy_provider_name: 'provider/updated', + agent_strategy_name: 'updated-strategy', + agent_strategy_label: 'Updated Strategy', + plugin_unique_identifier: 'provider/updated:1.0.0', + }), + ) expect(mockResetEditor).toHaveBeenCalledTimes(1) await user.click(screen.getByRole('button', { name: 'change-memory' })) diff --git a/web/app/components/workflow/nodes/agent/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/agent/__tests__/node.spec.tsx index 025c9bd84ced8c..a7bf9947a23c16 100644 --- a/web/app/components/workflow/nodes/agent/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/agent/__tests__/node.spec.tsx @@ -18,13 +18,20 @@ vi.mock('../use-config', () => ({ })) vi.mock('@/hooks/use-i18n', () => ({ - useRenderI18nObject: () => (value: string | { en_US?: string }) => typeof value === 'string' ? value : value.en_US || '', + useRenderI18nObject: () => (value: string | { en_US?: string }) => + typeof value === 'string' ? value : value.en_US || '', })) vi.mock('../components/model-bar', () => ({ - ModelBar: (props: { provider?: string, model?: string, param: string }) => { + ModelBar: (props: { provider?: string; model?: string; param: string }) => { mockModelBar(props) - return <div>{props.provider ? `${props.param}:${props.provider}/${props.model}` : `${props.param}:empty-model`}</div> + return ( + <div> + {props.provider + ? `${props.param}:${props.provider}/${props.model}` + : `${props.param}:empty-model`} + </div> + ) }, })) @@ -36,13 +43,15 @@ vi.mock('../components/tool-icon', () => ({ })) vi.mock('../../_base/components/group', () => ({ - Group: ({ label, children }: { label: ReactNode, children: ReactNode }) => ( + Group: ({ label, children }: { label: ReactNode; children: ReactNode }) => ( <div> <div>{label}</div> {children} </div> ), - GroupLabel: ({ className, children }: { className?: string, children: ReactNode }) => <div className={className}>{children}</div>, + GroupLabel: ({ className, children }: { className?: string; children: ReactNode }) => ( + <div className={className}>{children}</div> + ), })) vi.mock('../../_base/components/setting-item', () => ({ @@ -99,16 +108,15 @@ const createData = (overrides: Partial<AgentNodeType> = {}): AgentNodeType => ({ }, multiToolParam: { type: VarType.constant, - value: [ - { provider_name: 'author/tool-b' }, - { provider_name: 'author/tool-c' }, - ], + value: [{ provider_name: 'author/tool-b' }, { provider_name: 'author/tool-c' }], }, }, ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), setInputs: vi.fn(), @@ -150,11 +158,11 @@ const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = { isExistInPlugin: false, }, strategyProvider: undefined, - pluginDetail: ({ + pluginDetail: { declaration: { label: { en_US: 'Plugin Marketplace' } as never, }, - } as never), + } as never, availableVars: [], availableNodesWithParent: [], outputSchema: [], @@ -170,37 +178,33 @@ describe('agent/node', () => { }) it('renders the not-set state when no strategy is configured', () => { - mockUseConfig.mockReturnValue(createConfigResult({ - inputs: createData({ - agent_strategy_name: undefined, - agent_strategy_label: undefined, - agent_parameters: {}, + mockUseConfig.mockReturnValue( + createConfigResult({ + inputs: createData({ + agent_strategy_name: undefined, + agent_strategy_label: undefined, + agent_parameters: {}, + }), + currentStrategy: undefined, }), - currentStrategy: undefined, - })) - - render( - <Node - id="agent-node" - data={createData()} - />, ) + render(<Node id="agent-node" data={createData()} />) + expect(screen.getByText('workflow.nodes.agent.strategyNotSet:normal:')).toBeInTheDocument() expect(mockModelBar).not.toHaveBeenCalled() expect(mockToolIcon).not.toHaveBeenCalled() }) it('renders strategy status, required and selected model bars, and tool icons', () => { - render( - <Node - id="agent-node" - data={createData()} - />, - ) + render(<Node id="agent-node" data={createData()} />) - expect(screen.getByText(/workflow.nodes.agent.strategy.shortLabel:error:/)).toHaveTextContent('React Agent') - expect(screen.getByText(/workflow.nodes.agent.strategy.shortLabel:error:/)).toHaveTextContent('Plugin Marketplace') + expect(screen.getByText(/workflow.nodes.agent.strategy.shortLabel:error:/)).toHaveTextContent( + 'React Agent', + ) + expect(screen.getByText(/workflow.nodes.agent.strategy.shortLabel:error:/)).toHaveTextContent( + 'Plugin Marketplace', + ) expect(screen.getByText('requiredModel:empty-model')).toBeInTheDocument() expect(screen.getByText('optionalModel:openai/gpt-4o')).toBeInTheDocument() expect(screen.getByText('tool:author/tool-a')).toBeInTheDocument() @@ -211,37 +215,34 @@ describe('agent/node', () => { }) it('skips optional models and empty tool values when no configuration is provided', () => { - mockUseConfig.mockReturnValue(createConfigResult({ - inputs: createData({ - agent_parameters: {}, + mockUseConfig.mockReturnValue( + createConfigResult({ + inputs: createData({ + agent_parameters: {}, + }), + currentStrategy: { + ...createConfigResult().currentStrategy!, + parameters: [ + createStrategyParam({ + name: 'optionalModel', + required: false, + }), + createStrategyParam({ + name: 'toolParam', + type: FormTypeEnum.toolSelector, + required: false, + }), + ], + }, + currentStrategyStatus: { + plugin: { source: 'marketplace', installed: true }, + isExistInPlugin: true, + }, }), - currentStrategy: { - ...createConfigResult().currentStrategy!, - parameters: [ - createStrategyParam({ - name: 'optionalModel', - required: false, - }), - createStrategyParam({ - name: 'toolParam', - type: FormTypeEnum.toolSelector, - required: false, - }), - ], - }, - currentStrategyStatus: { - plugin: { source: 'marketplace', installed: true }, - isExistInPlugin: true, - }, - })) - - render( - <Node - id="agent-node" - data={createData()} - />, ) + render(<Node id="agent-node" data={createData()} />) + expect(mockModelBar).not.toHaveBeenCalled() expect(mockToolIcon).not.toHaveBeenCalled() expect(screen.queryByText('optionalModel:empty-model')).not.toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/agent/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent/__tests__/panel.spec.tsx index 15001b4757aee6..4966bbea2ae5c2 100644 --- a/web/app/components/workflow/nodes/agent/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/agent/__tests__/panel.spec.tsx @@ -21,9 +21,12 @@ vi.mock('../use-config', () => ({ })) vi.mock('../../../store', () => ({ - useStore: (selector: (state: { setControlPromptEditorRerenderKey: typeof mockResetEditor }) => unknown) => selector({ - setControlPromptEditorRerenderKey: mockResetEditor, - }), + useStore: ( + selector: (state: { setControlPromptEditorRerenderKey: typeof mockResetEditor }) => unknown, + ) => + selector({ + setControlPromptEditorRerenderKey: mockResetEditor, + }), })) vi.mock('../../_base/components/agent-strategy', () => ({ @@ -36,7 +39,7 @@ vi.mock('../../_base/components/agent-strategy', () => ({ plugin_unique_identifier: string meta?: AgentNodeType['meta'] } - formSchema: Array<{ variable: string, tooltip?: StrategyParamItem['help'] }> + formSchema: Array<{ variable: string; tooltip?: StrategyParamItem['help'] }> formValue: Record<string, unknown> onStrategyChange: (strategy: { agent_strategy_provider_name: string @@ -53,27 +56,32 @@ vi.mock('../../_base/components/agent-strategy', () => ({ <div> <button type="button" - onClick={() => props.onStrategyChange({ - agent_strategy_provider_name: 'provider/updated', - agent_strategy_name: 'updated', - agent_strategy_label: 'Updated Strategy', - agent_output_schema: { - properties: { - structured: { - type: 'string', - description: 'structured output', + onClick={() => + props.onStrategyChange({ + agent_strategy_provider_name: 'provider/updated', + agent_strategy_name: 'updated', + agent_strategy_label: 'Updated Strategy', + agent_output_schema: { + properties: { + structured: { + type: 'string', + description: 'structured output', + }, }, }, - }, - plugin_unique_identifier: 'provider/updated:1.0.0', - meta: { - version: '2.0.0', - } as AgentNodeType['meta'], - })} + plugin_unique_identifier: 'provider/updated:1.0.0', + meta: { + version: '2.0.0', + } as AgentNodeType['meta'], + }) + } > change-strategy </button> - <button type="button" onClick={() => props.onFormValueChange({ instruction: 'Use the tool' })}> + <button + type="button" + onClick={() => props.onFormValueChange({ instruction: 'Use the tool' })} + > change-form </button> </div> @@ -92,13 +100,15 @@ vi.mock('../../_base/components/memory-config', () => ({ return ( <button type="button" - onClick={() => props.onChange({ - window: { - enabled: true, - size: 8, - }, - query_prompt_template: 'history', - } as AgentNodeType['memory'])} + onClick={() => + props.onChange({ + window: { + enabled: true, + size: 8, + }, + query_prompt_template: 'history', + } as AgentNodeType['memory']) + } > change-memory </button> @@ -109,7 +119,7 @@ vi.mock('../../_base/components/memory-config', () => ({ vi.mock('../../_base/components/output-vars', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <div>{children}</div>, - VarItem: ({ name, type, description }: { name: string, type: string, description?: string }) => ( + VarItem: ({ name, type, description }: { name: string; type: string; description?: string }) => ( <div>{`${name}:${type}:${description || ''}`}</div> ), })) @@ -156,7 +166,9 @@ const createData = (overrides: Partial<AgentNodeType> = {}): AgentNodeType => ({ ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), setInputs: vi.fn(), @@ -194,11 +206,13 @@ const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = { pluginDetail: undefined, availableVars: [], availableNodesWithParent: [], - outputSchema: [{ - name: 'summary', - type: 'String', - description: 'summary output', - }], + outputSchema: [ + { + name: 'summary', + type: 'String', + description: 'summary output', + }, + ], handleMemoryChange: vi.fn(), isChatMode: true, ...overrides, @@ -218,79 +232,83 @@ describe('agent/panel', () => { const onFormChange = vi.fn() const handleMemoryChange = vi.fn() - mockUseConfig.mockReturnValue(createConfigResult({ - setInputs, - onFormChange, - handleMemoryChange, - })) - - render( - <Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, + mockUseConfig.mockReturnValue( + createConfigResult({ + setInputs, + onFormChange, + handleMemoryChange, + }), ) + render(<Panel id="agent-node" data={createData()} panelProps={panelProps} />) + expect(screen.getByText('text:String:workflow.nodes.agent.outputVars.text')).toBeInTheDocument() - expect(screen.getByText('usage:object:workflow.nodes.agent.outputVars.usage')).toBeInTheDocument() - expect(screen.getByText('files:Array[File]:workflow.nodes.agent.outputVars.files.title')).toBeInTheDocument() - expect(screen.getByText('json:Array[Object]:workflow.nodes.agent.outputVars.json')).toBeInTheDocument() + expect( + screen.getByText('usage:object:workflow.nodes.agent.outputVars.usage'), + ).toBeInTheDocument() + expect( + screen.getByText('files:Array[File]:workflow.nodes.agent.outputVars.files.title'), + ).toBeInTheDocument() + expect( + screen.getByText('json:Array[Object]:workflow.nodes.agent.outputVars.json'), + ).toBeInTheDocument() expect(screen.getByText('summary:String:summary output')).toBeInTheDocument() - expect(mockAgentStrategy).toHaveBeenCalledWith(expect.objectContaining({ - formSchema: expect.arrayContaining([ - expect.objectContaining({ - variable: 'instruction', - tooltip: { en_US: 'Instruction help' }, - }), - expect.objectContaining({ - variable: 'modelParam', - }), - ]), - formValue: { - instruction: 'Plan and answer', - }, - })) + expect(mockAgentStrategy).toHaveBeenCalledWith( + expect.objectContaining({ + formSchema: expect.arrayContaining([ + expect.objectContaining({ + variable: 'instruction', + tooltip: { en_US: 'Instruction help' }, + }), + expect.objectContaining({ + variable: 'modelParam', + }), + ]), + formValue: { + instruction: 'Plan and answer', + }, + }), + ) await user.click(screen.getByRole('button', { name: 'change-strategy' })) await user.click(screen.getByRole('button', { name: 'change-form' })) await user.click(screen.getByRole('button', { name: 'change-memory' })) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - agent_strategy_provider_name: 'provider/updated', - agent_strategy_name: 'updated', - agent_strategy_label: 'Updated Strategy', - plugin_unique_identifier: 'provider/updated:1.0.0', - output_schema: expect.objectContaining({ - properties: expect.objectContaining({ - structured: expect.any(Object), + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + agent_strategy_provider_name: 'provider/updated', + agent_strategy_name: 'updated', + agent_strategy_label: 'Updated Strategy', + plugin_unique_identifier: 'provider/updated:1.0.0', + output_schema: expect.objectContaining({ + properties: expect.objectContaining({ + structured: expect.any(Object), + }), }), }), - })) + ) expect(onFormChange).toHaveBeenCalledWith({ instruction: 'Use the tool' }) - expect(handleMemoryChange).toHaveBeenCalledWith(expect.objectContaining({ - query_prompt_template: 'history', - })) + expect(handleMemoryChange).toHaveBeenCalledWith( + expect.objectContaining({ + query_prompt_template: 'history', + }), + ) expect(mockResetEditor).toHaveBeenCalledTimes(1) }) it('hides memory config when chat mode support is unavailable', () => { - mockUseConfig.mockReturnValue(createConfigResult({ - isChatMode: false, - currentStrategy: { - ...createConfigResult().currentStrategy!, - features: [], - }, - })) - - render( - <Panel - id="agent-node" - data={createData()} - panelProps={panelProps} - />, + mockUseConfig.mockReturnValue( + createConfigResult({ + isChatMode: false, + currentStrategy: { + ...createConfigResult().currentStrategy!, + features: [], + }, + }), ) + render(<Panel id="agent-node" data={createData()} panelProps={panelProps} />) + expect(screen.queryByRole('button', { name: 'change-memory' })).not.toBeInTheDocument() expect(mockMemoryConfig).not.toHaveBeenCalled() }) diff --git a/web/app/components/workflow/nodes/agent/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/agent/__tests__/use-config.spec.ts index 9e09ab6d786642..e6efc7981a201a 100644 --- a/web/app/components/workflow/nodes/agent/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/agent/__tests__/use-config.spec.ts @@ -42,7 +42,8 @@ vi.mock('@/service/use-strategy', () => ({ })) vi.mock('@/service/use-plugins', () => ({ - useFetchPluginsInMarketPlaceByIds: (...args: unknown[]) => mockUseFetchPluginsInMarketPlaceByIds(...args), + useFetchPluginsInMarketPlaceByIds: (...args: unknown[]) => + mockUseFetchPluginsInMarketPlaceByIds(...args), useCheckInstalled: (...args: unknown[]) => mockUseCheckInstalled(...args), })) @@ -150,37 +151,45 @@ describe('agent/use-config', () => { handleAddVariable, } as never) mockUseAvailableVarList.mockReturnValue({ - availableVars: [{ - nodeId: 'node-1', - title: 'Start', - vars: [{ - variable: 'topic', - type: WorkflowVarType.string, - }], - }], - availableNodesWithParent: [{ - nodeId: 'node-1', - title: 'Start', - }], + availableVars: [ + { + nodeId: 'node-1', + title: 'Start', + vars: [ + { + variable: 'topic', + type: WorkflowVarType.string, + }, + ], + }, + ], + availableNodesWithParent: [ + { + nodeId: 'node-1', + title: 'Start', + }, + ], } as never) mockUseStrategyProviderDetail.mockReturnValue({ isLoading: false, isError: false, data: { declaration: { - strategies: [{ - identity: { - name: 'react', + strategies: [ + { + identity: { + name: 'react', + }, + parameters: [ + createStrategyParam(), + createStrategyParam({ + name: 'modelParam', + type: FormTypeEnum.modelSelector, + required: false, + }), + ], }, - parameters: [ - createStrategyParam(), - createStrategyParam({ - name: 'modelParam', - type: FormTypeEnum.modelSelector, - required: false, - }), - ], - }], + ], }, }, refetch: providerRefetch, @@ -196,18 +205,23 @@ describe('agent/use-config', () => { } as never) mockUseCheckInstalled.mockReturnValue({ data: { - plugins: [{ - declaration: { - label: { en_US: 'Installed Agent Plugin' }, + plugins: [ + { + declaration: { + label: { en_US: 'Installed Agent Plugin' }, + }, }, - }], + ], }, } as never) - mockToolParametersToFormSchemas.mockImplementation(value => value as never) - mockGenerateAgentToolValue.mockImplementation((_value, schemas, isLLM) => ({ - kind: isLLM ? 'llm' : 'setting', - fields: (schemas as Array<{ variable: string }>).map(item => item.variable), - }) as never) + mockToolParametersToFormSchemas.mockImplementation((value) => value as never) + mockGenerateAgentToolValue.mockImplementation( + (_value, schemas, isLLM) => + ({ + kind: isLLM ? 'llm' : 'setting', + fields: (schemas as Array<{ variable: string }>).map((item) => item.variable), + }) as never, + ) }) it('returns an undefined strategy status while strategy data is still loading and can refetch dependencies', () => { @@ -283,10 +297,12 @@ describe('agent/use-config', () => { isExistInPlugin: true, }) expect(result.current.availableVars).toHaveLength(1) - expect(result.current.availableNodesWithParent).toEqual([{ - nodeId: 'node-1', - title: 'Start', - }]) + expect(result.current.availableNodesWithParent).toEqual([ + { + nodeId: 'node-1', + title: 'Start', + }, + ]) expect(result.current.outputSchema).toEqual([ { name: 'summary', type: 'String', description: 'summary output' }, { name: 'items', type: 'Array[Number]', description: 'items output' }, @@ -311,30 +327,36 @@ describe('agent/use-config', () => { } as AgentNodeType['memory']) }) - expect(setInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({ - agent_parameters: { - instruction: { - type: VarType.variable, - value: '#start.updated#', - }, - modelParam: { - type: VarType.constant, - value: { - provider: 'anthropic', - model: 'claude-sonnet', + expect(setInputs).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + agent_parameters: { + instruction: { + type: VarType.variable, + value: '#start.updated#', + }, + modelParam: { + type: VarType.constant, + value: { + provider: 'anthropic', + model: 'claude-sonnet', + }, }, }, - }, - })) - expect(setInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({ - memory: { - window: { - enabled: true, - size: 6, + }), + ) + expect(setInputs).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + memory: { + window: { + enabled: true, + size: 6, + }, + query_prompt_template: 'history', }, - query_prompt_template: 'history', - }, - })) + }), + ) expect(result.current.handleVarListChange).toBe(handleVarListChange) expect(result.current.handleAddVariable).toBe(handleAddVariable) expect(result.current.pluginDetail).toEqual({ @@ -363,23 +385,25 @@ describe('agent/use-config', () => { isError: false, data: { declaration: { - strategies: [{ - identity: { - name: 'react', + strategies: [ + { + identity: { + name: 'react', + }, + parameters: [ + createStrategyParam({ + name: 'toolParam', + type: FormTypeEnum.toolSelector, + required: false, + }), + createStrategyParam({ + name: 'multiToolParam', + type: FormTypeEnum.multiToolSelector, + required: false, + }), + ], }, - parameters: [ - createStrategyParam({ - name: 'toolParam', - type: FormTypeEnum.toolSelector, - required: false, - }), - createStrategyParam({ - name: 'multiToolParam', - type: FormTypeEnum.multiToolSelector, - required: false, - }), - ], - }], + ], }, }, refetch: providerRefetch, @@ -388,35 +412,39 @@ describe('agent/use-config', () => { renderHook(() => useConfig('agent-node', currentInputs)) await waitFor(() => { - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - tool_node_version: '2', - agent_parameters: expect.objectContaining({ - toolParam: expect.objectContaining({ - value: expect.objectContaining({ - settings: { - kind: 'setting', - fields: ['api_key'], - }, - parameters: { - kind: 'llm', - fields: ['query'], - }, + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + tool_node_version: '2', + agent_parameters: expect.objectContaining({ + toolParam: expect.objectContaining({ + value: expect.objectContaining({ + settings: { + kind: 'setting', + fields: ['api_key'], + }, + parameters: { + kind: 'llm', + fields: ['query'], + }, + }), + }), + multiToolParam: expect.objectContaining({ + value: [ + expect.objectContaining({ + settings: { + kind: 'setting', + fields: ['api_key'], + }, + parameters: { + kind: 'llm', + fields: ['query'], + }, + }), + ], }), - }), - multiToolParam: expect.objectContaining({ - value: [expect.objectContaining({ - settings: { - kind: 'setting', - fields: ['api_key'], - }, - parameters: { - kind: 'llm', - fields: ['query'], - }, - })], }), }), - })) + ) }) }) }) diff --git a/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx b/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx index b5abd22c4922da..86f9b500b0474f 100644 --- a/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx +++ b/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx @@ -20,12 +20,11 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec defaultModel, modelList, }: { - defaultModel?: { provider: string, model: string } + defaultModel?: { provider: string; model: string } modelList: ModelProviderItem[] }) => ( <div> - {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'} - : + {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}: {modelList.length} </div> ), @@ -39,7 +38,9 @@ describe('agent/model-bar', () => { beforeEach(() => { vi.clearAllMocks() mockModelLists.clear() - mockModelLists.set('llm' as ModelTypeEnum, [{ provider: 'openai', models: [{ model: 'gpt-4o' }] }]) + mockModelLists.set('llm' as ModelTypeEnum, [ + { provider: 'openai', models: [{ model: 'gpt-4o' }] }, + ]) mockModelLists.set('moderation' as ModelTypeEnum, []) mockModelLists.set('rerank' as ModelTypeEnum, []) mockModelLists.set('speech2text' as ModelTypeEnum, []) diff --git a/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx b/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx index b2dc336be6a7e8..465b4517e11fca 100644 --- a/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx +++ b/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx @@ -4,7 +4,7 @@ import { ToolIcon } from '../tool-icon' type ToolProvider = { id?: string name?: string - icon?: string | { content: string, background: string } + icon?: string | { content: string; background: string } is_team_authorization?: boolean } @@ -12,7 +12,7 @@ let mockBuiltInTools: ToolProvider[] | undefined let mockCustomTools: ToolProvider[] | undefined let mockWorkflowTools: ToolProvider[] | undefined let mockMcpTools: ToolProvider[] | undefined -let mockMarketplaceIcon: string | { content: string, background: string } | undefined +let mockMarketplaceIcon: string | { content: string; background: string } | undefined vi.mock('@/service/use-tools', () => ({ useAllBuiltInTools: () => ({ data: mockBuiltInTools }), @@ -56,11 +56,13 @@ describe('agent/tool-icon', () => { }) it('should render a string icon, recover from fetch errors, and keep installed tools warning-free', () => { - mockBuiltInTools = [{ - name: 'author/tool-a', - icon: 'https://example.com/tool-a.png', - is_team_authorization: true, - }] + mockBuiltInTools = [ + { + name: 'author/tool-a', + icon: 'https://example.com/tool-a.png', + is_team_authorization: true, + }, + ] render(<ToolIcon id="tool-1" providerName="author/tool-a" />) @@ -76,19 +78,23 @@ describe('agent/tool-icon', () => { }) it('should render authorization and installation warnings with the correct icon sources', () => { - mockWorkflowTools = [{ - id: 'author/tool-b', - icon: { - content: 'B', - background: '#fff', + mockWorkflowTools = [ + { + id: 'author/tool-b', + icon: { + content: 'B', + background: '#fff', + }, + is_team_authorization: false, }, - is_team_authorization: false, - }] + ] const { rerender } = render(<ToolIcon id="tool-2" providerName="author/tool-b" />) expect(screen.getByText('indicator:warning')).toBeInTheDocument() - expect(screen.getByLabelText('workflow.nodes.agent.toolNotAuthorizedTooltip:{"tool":"tool-b"}')).toBeInTheDocument() + expect( + screen.getByLabelText('workflow.nodes.agent.toolNotAuthorizedTooltip:{"tool":"tool-b"}'), + ).toBeInTheDocument() mockWorkflowTools = [] mockMarketplaceIcon = 'https://example.com/market-tool.png' @@ -97,7 +103,9 @@ describe('agent/tool-icon', () => { const marketplaceIcon = screen.getByRole('img', { name: 'tool icon' }) expect(marketplaceIcon).toHaveAttribute('src', 'https://example.com/market-tool.png') expect(screen.getByText('indicator:error')).toBeInTheDocument() - expect(screen.getByLabelText('workflow.nodes.agent.toolNotInstallTooltip:{"tool":"tool-c"}')).toBeInTheDocument() + expect( + screen.getByLabelText('workflow.nodes.agent.toolNotInstallTooltip:{"tool":"tool-c"}'), + ).toBeInTheDocument() }) it('should fall back to the group icon while tool data is still loading', () => { diff --git a/web/app/components/workflow/nodes/agent/components/model-bar.tsx b/web/app/components/workflow/nodes/agent/components/model-bar.tsx index 26d1e59de0fce6..3d962163c04c06 100644 --- a/web/app/components/workflow/nodes/agent/components/model-bar.tsx +++ b/web/app/components/workflow/nodes/agent/components/model-bar.tsx @@ -7,13 +7,15 @@ import { ModelTypeEnum } from '@/app/components/header/account-setting/model-pro import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' -type ModelBarProps = { - provider: string - model: string -} | { - provider?: never - model?: never -} +type ModelBarProps = + | { + provider: string + model: string + } + | { + provider?: never + model?: never + } const useAllModel = () => { const { data: textGeneration } = useModelList(ModelTypeEnum.textGeneration) @@ -39,12 +41,12 @@ export const ModelBar: FC<ModelBarProps> = (props) => { const { t } = useTranslation() const modelList = useAllModel() if (props.provider === undefined) { - const tooltip = t($ => $['nodes.agent.modelNotSelected'], { ns: 'workflow' }) + const tooltip = t(($) => $['nodes.agent.modelNotSelected'], { ns: 'workflow' }) return ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="relative" aria-label={tooltip}> <ModelSelector modelList={[]} @@ -56,20 +58,23 @@ export const ModelBar: FC<ModelBarProps> = (props) => { /> <StatusDot status="error" className="absolute -top-0.5 -right-0.5" /> </div> - )} + } /> <TooltipContent>{tooltip}</TooltipContent> </Tooltip> ) } const modelInstalled = modelList?.some( - provider => provider.provider === props.provider && provider.models.some(model => model.model === props.model), + (provider) => + provider.provider === props.provider && + provider.models.some((model) => model.model === props.model), ) const showWarn = modelList && !modelInstalled - if (!modelList) - return null + if (!modelList) return null - const modelNotInstalledTooltip = t($ => $['nodes.agent.modelNotInstallTooltip'], { ns: 'workflow' }) + const modelNotInstalledTooltip = t(($) => $['nodes.agent.modelNotInstallTooltip'], { + ns: 'workflow', + }) const modelSelector = ( <div className="relative" aria-label={showWarn ? modelNotInstalledTooltip : undefined}> <ModelSelector @@ -87,8 +92,7 @@ export const ModelBar: FC<ModelBarProps> = (props) => { </div> ) - if (modelInstalled) - return modelSelector + if (modelInstalled) return modelSelector return ( <Tooltip> diff --git a/web/app/components/workflow/nodes/agent/components/tool-icon.tsx b/web/app/components/workflow/nodes/agent/components/tool-icon.tsx index b857616255087e..34f121f8e10ce4 100644 --- a/web/app/components/workflow/nodes/agent/components/tool-icon.tsx +++ b/web/app/components/workflow/nodes/agent/components/tool-icon.tsx @@ -6,7 +6,12 @@ import { memo, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import { Group } from '@/app/components/base/icons/src/vender/other' -import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' import { getIconFromMarketPlace } from '@/utils/get-icon' type Status = 'not-installed' | 'not-authorized' | undefined @@ -24,7 +29,12 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => { const { data: mcpTools } = useAllMCPTools() const isDataReady = !!buildInTools && !!customTools && !!workflowTools && !!mcpTools const currentProvider = useMemo(() => { - const mergedTools = [...(buildInTools || []), ...(customTools || []), ...(workflowTools || []), ...(mcpTools || [])] + const mergedTools = [ + ...(buildInTools || []), + ...(customTools || []), + ...(workflowTools || []), + ...(mcpTools || []), + ] return mergedTools.find((toolWithProvider) => { return toolWithProvider.name === providerName || toolWithProvider.id === providerName }) @@ -34,32 +44,27 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => { const author = providerNameParts[0] const name = providerNameParts[1] const icon = useMemo(() => { - if (!isDataReady) - return '' - if (currentProvider) - return currentProvider.icon + if (!isDataReady) return '' + if (currentProvider) return currentProvider.icon const iconFromMarketPlace = getIconFromMarketPlace(`${author}/${name}`) return iconFromMarketPlace }, [author, currentProvider, name, isDataReady]) const status: Status = useMemo(() => { - if (!isDataReady) - return undefined - if (!currentProvider) - return 'not-installed' - if (currentProvider.is_team_authorization === false) - return 'not-authorized' + if (!isDataReady) return undefined + if (!currentProvider) return 'not-installed' + if (currentProvider.is_team_authorization === false) return 'not-authorized' return undefined }, [currentProvider, isDataReady]) - const indicator = status === 'not-installed' ? 'error' : status === 'not-authorized' ? 'warning' : undefined + const indicator = + status === 'not-installed' ? 'error' : status === 'not-authorized' ? 'warning' : undefined const notSuccess = (['not-installed', 'not-authorized'] as Array<Status>).includes(status) const { t } = useTranslation() const tooltip = useMemo(() => { - if (!notSuccess) - return undefined + if (!notSuccess) return undefined if (status === 'not-installed') - return t($ => $['nodes.agent.toolNotInstallTooltip'], { ns: 'workflow', tool: name }) + return t(($) => $['nodes.agent.toolNotInstallTooltip'], { ns: 'workflow', tool: name }) if (status === 'not-authorized') - return t($ => $['nodes.agent.toolNotAuthorizedTooltip'], { ns: 'workflow', tool: name }) + return t(($) => $['nodes.agent.toolNotAuthorizedTooltip'], { ns: 'workflow', tool: name }) throw new Error('Unknown status') }, [name, notSuccess, status, t]) const [iconFetchError, setIconFetchError] = useState(false) @@ -75,8 +80,7 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => { onError={() => setIconFetchError(true)} /> ) - } - else if (typeof icon === 'object') { + } else if (typeof icon === 'object') { iconContent = ( <AppIcon className={cn('size-3.5 size-full object-cover', notSuccess && 'opacity-50')} @@ -88,11 +92,7 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => { } const iconNode = ( - <div - aria-label={tooltip} - className={cn('relative')} - ref={containerRef} - > + <div aria-label={tooltip} className={cn('relative')} ref={containerRef}> <div className="flex size-5 items-center justify-center overflow-hidden rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge"> {iconContent} </div> @@ -100,8 +100,7 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => { </div> ) - if (!notSuccess || !tooltip) - return iconNode + if (!notSuccess || !tooltip) return iconNode return ( <Tooltip> diff --git a/web/app/components/workflow/nodes/agent/default.ts b/web/app/components/workflow/nodes/agent/default.ts index 0aeb8607baa3bc..c011c7c19de5f6 100644 --- a/web/app/components/workflow/nodes/agent/default.ts +++ b/web/app/components/workflow/nodes/agent/default.ts @@ -17,12 +17,16 @@ const nodeDefault: NodeDefault<AgentNodeType> = { defaultValue: { tool_node_version: '2', }, - checkValid(payload, t: TFunction<'workflow'>, moreDataForCheckValid: { - strategyProvider?: StrategyPluginDetail - strategy?: StrategyDetail - language: string - isReadyForCheckValid: boolean - }) { + checkValid( + payload, + t: TFunction<'workflow'>, + moreDataForCheckValid: { + strategyProvider?: StrategyPluginDetail + strategy?: StrategyDetail + language: string + isReadyForCheckValid: boolean + }, + ) { const { strategy, language, isReadyForCheckValid } = moreDataForCheckValid if (!isReadyForCheckValid) { return { @@ -33,7 +37,7 @@ const nodeDefault: NodeDefault<AgentNodeType> = { if (!strategy) { return { isValid: false, - errorMessage: t($ => $['nodes.agent.checkList.strategyNotSelected'], { ns: 'workflow' }), + errorMessage: t(($) => $['nodes.agent.checkList.strategyNotSelected'], { ns: 'workflow' }), } } for (const param of strategy.parameters) { @@ -44,14 +48,20 @@ const nodeDefault: NodeDefault<AgentNodeType> = { if (!toolValue) { return { isValid: false, - errorMessage: t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: renderI18nObject(param.label, language) }), + errorMessage: t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: renderI18nObject(param.label, language), + }), } } // not enabled else if (!toolValue.enabled) { return { isValid: false, - errorMessage: t($ => $['errorMsg.noValidTool'], { ns: 'workflow', field: renderI18nObject(param.label, language) }), + errorMessage: t(($) => $['errorMsg.noValidTool'], { + ns: 'workflow', + field: renderI18nObject(param.label, language), + }), } } // check form of tool @@ -67,25 +77,55 @@ const nodeDefault: NodeDefault<AgentNodeType> = { if (schema.form === 'form' && !mergeVersion && !userSettings[schema.name]?.value) { return { isValid: false, - errorMessage: t($ => $['errorMsg.toolParameterRequired'], { ns: 'workflow', field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), + errorMessage: t(($) => $['errorMsg.toolParameterRequired'], { + ns: 'workflow', + field: renderI18nObject(param.label, language), + param: renderI18nObject(schema.label, language), + }), } } - if (schema.form === 'form' && mergeVersion && !userSettings[schema.name]?.value.value) { + if ( + schema.form === 'form' && + mergeVersion && + !userSettings[schema.name]?.value.value + ) { return { isValid: false, - errorMessage: t($ => $['errorMsg.toolParameterRequired'], { ns: 'workflow', field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), + errorMessage: t(($) => $['errorMsg.toolParameterRequired'], { + ns: 'workflow', + field: renderI18nObject(param.label, language), + param: renderI18nObject(schema.label, language), + }), } } - if (schema.form === 'llm' && !mergeVersion && reasoningConfig[schema.name].auto === 0 && !reasoningConfig[schema.name]?.value) { + if ( + schema.form === 'llm' && + !mergeVersion && + reasoningConfig[schema.name].auto === 0 && + !reasoningConfig[schema.name]?.value + ) { return { isValid: false, - errorMessage: t($ => $['errorMsg.toolParameterRequired'], { ns: 'workflow', field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), + errorMessage: t(($) => $['errorMsg.toolParameterRequired'], { + ns: 'workflow', + field: renderI18nObject(param.label, language), + param: renderI18nObject(schema.label, language), + }), } } - if (schema.form === 'llm' && mergeVersion && reasoningConfig[schema.name].auto === 0 && !reasoningConfig[schema.name]?.value.value) { + if ( + schema.form === 'llm' && + mergeVersion && + reasoningConfig[schema.name].auto === 0 && + !reasoningConfig[schema.name]?.value.value + ) { return { isValid: false, - errorMessage: t($ => $['errorMsg.toolParameterRequired'], { ns: 'workflow', field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), + errorMessage: t(($) => $['errorMsg.toolParameterRequired'], { + ns: 'workflow', + field: renderI18nObject(param.label, language), + param: renderI18nObject(schema.label, language), + }), } } } @@ -99,14 +139,20 @@ const nodeDefault: NodeDefault<AgentNodeType> = { if (!tools.length) { return { isValid: false, - errorMessage: t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: renderI18nObject(param.label, language) }), + errorMessage: t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: renderI18nObject(param.label, language), + }), } } // not enabled else if (tools.every((tool: any) => !tool.enabled)) { return { isValid: false, - errorMessage: t($ => $['errorMsg.noValidTool'], { ns: 'workflow', field: renderI18nObject(param.label, language) }), + errorMessage: t(($) => $['errorMsg.noValidTool'], { + ns: 'workflow', + field: renderI18nObject(param.label, language), + }), } } // check form of tools @@ -124,13 +170,25 @@ const nodeDefault: NodeDefault<AgentNodeType> = { if (schema.form === 'form' && !userSettings[schema.name]?.value) { return { isValid: false, - errorMessage: t($ => $['errorMsg.toolParameterRequired'], { ns: 'workflow', field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), + errorMessage: t(($) => $['errorMsg.toolParameterRequired'], { + ns: 'workflow', + field: renderI18nObject(param.label, language), + param: renderI18nObject(schema.label, language), + }), } } - if (schema.form === 'llm' && reasoningConfig[schema.name]?.auto === 0 && !reasoningConfig[schema.name]?.value) { + if ( + schema.form === 'llm' && + reasoningConfig[schema.name]?.auto === 0 && + !reasoningConfig[schema.name]?.value + ) { return { isValid: false, - errorMessage: t($ => $['errorMsg.toolParameterRequired'], { ns: 'workflow', field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), + errorMessage: t(($) => $['errorMsg.toolParameterRequired'], { + ns: 'workflow', + field: renderI18nObject(param.label, language), + param: renderI18nObject(schema.label, language), + }), } } } @@ -143,7 +201,10 @@ const nodeDefault: NodeDefault<AgentNodeType> = { if (param.required && !(payload.agent_parameters?.[param.name]?.value || param.default)) { return { isValid: false, - errorMessage: t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: renderI18nObject(param.label, language) }), + errorMessage: t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: renderI18nObject(param.label, language), + }), } } } diff --git a/web/app/components/workflow/nodes/agent/node.tsx b/web/app/components/workflow/nodes/agent/node.tsx index 8c02b8740f77ec..dcabda0fa44897 100644 --- a/web/app/components/workflow/nodes/agent/node.tsx +++ b/web/app/components/workflow/nodes/agent/node.tsx @@ -13,29 +13,36 @@ import { ToolIcon } from './components/tool-icon' import useConfig from './use-config' const AgentNode: FC<NodeProps<AgentNodeType>> = (props) => { - const { inputs, currentStrategy, currentStrategyStatus, pluginDetail } = useConfig(props.id, props.data) + const { inputs, currentStrategy, currentStrategyStatus, pluginDetail } = useConfig( + props.id, + props.data, + ) const renderI18nObject = useRenderI18nObject() const { t } = useTranslation() const models = useMemo(() => { - if (!inputs) - return [] + if (!inputs) return [] // if selected, show in node // if required and not selected, show empty selector // if not required and not selected, show nothing - const models = currentStrategy?.parameters - .filter(param => param.type === FormTypeEnum.modelSelector) - .reduce((acc, param) => { - const item = inputs.agent_parameters?.[param.name]?.value - if (!item) { - if (param.required) { - acc.push({ param: param.name }) + const models = + currentStrategy?.parameters + .filter((param) => param.type === FormTypeEnum.modelSelector) + .reduce( + (acc, param) => { + const item = inputs.agent_parameters?.[param.name]?.value + if (!item) { + if (param.required) { + acc.push({ param: param.name }) + return acc + } else { + return acc + } + } + acc.push({ provider: item.provider, model: item.model, param: param.name }) return acc - } - else { return acc } - } - acc.push({ provider: item.provider, model: item.model, param: param.name }) - return acc - }, [] as Array<{ param: string } | { provider: string, model: string, param: string }>) || [] + }, + [] as Array<{ param: string } | { provider: string; model: string; param: string }>, + ) || [] return models }, [currentStrategy, inputs]) @@ -56,7 +63,7 @@ const AgentNode: FC<NodeProps<AgentNodeType>> = (props) => { const field = param.name const value = inputs.agent_parameters?.[field]?.value if (value) { - (value as unknown as any[]).forEach((item, idx) => { + ;(value as unknown as any[]).forEach((item, idx) => { tools.push({ id: `${param.name}-${idx}`, providerName: item.provider_name, @@ -69,58 +76,54 @@ const AgentNode: FC<NodeProps<AgentNodeType>> = (props) => { }, [currentStrategy?.parameters, inputs.agent_parameters]) return ( <div className="mb-1 space-y-1 px-3"> - {inputs.agent_strategy_name - ? ( - <SettingItem - label={t($ => $['nodes.agent.strategy.shortLabel'], { ns: 'workflow' })} - status={ - currentStrategyStatus && !currentStrategyStatus.isExistInPlugin - ? 'error' - : undefined - } - tooltip={ - (currentStrategyStatus && !currentStrategyStatus.isExistInPlugin) - ? t($ => $['nodes.agent.strategyNotInstallTooltip'], { - ns: 'workflow', - plugin: pluginDetail?.declaration.label - ? renderI18nObject(pluginDetail?.declaration.label) - : undefined, - strategy: inputs.agent_strategy_label, - }) - : undefined - } - > - {inputs.agent_strategy_label} - </SettingItem> - ) - : <SettingItem label={t($ => $['nodes.agent.strategyNotSet'], { ns: 'workflow' })} />} + {inputs.agent_strategy_name ? ( + <SettingItem + label={t(($) => $['nodes.agent.strategy.shortLabel'], { ns: 'workflow' })} + status={ + currentStrategyStatus && !currentStrategyStatus.isExistInPlugin ? 'error' : undefined + } + tooltip={ + currentStrategyStatus && !currentStrategyStatus.isExistInPlugin + ? t(($) => $['nodes.agent.strategyNotInstallTooltip'], { + ns: 'workflow', + plugin: pluginDetail?.declaration.label + ? renderI18nObject(pluginDetail?.declaration.label) + : undefined, + strategy: inputs.agent_strategy_label, + }) + : undefined + } + > + {inputs.agent_strategy_label} + </SettingItem> + ) : ( + <SettingItem label={t(($) => $['nodes.agent.strategyNotSet'], { ns: 'workflow' })} /> + )} {models.length > 0 && ( <Group - label={( + label={ <GroupLabel className="mt-1"> - {t($ => $['nodes.agent.model'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.model'], { ns: 'workflow' })} </GroupLabel> - )} + } > {models.map((model) => { - return ( - <ModelBar - {...model} - key={model.param} - /> - ) + return <ModelBar {...model} key={model.param} /> })} </Group> )} {tools.length > 0 && ( - <Group label={( - <GroupLabel className="mt-1"> - {t($ => $['nodes.agent.toolbox'], { ns: 'workflow' })} - </GroupLabel> - )} + <Group + label={ + <GroupLabel className="mt-1"> + {t(($) => $['nodes.agent.toolbox'], { ns: 'workflow' })} + </GroupLabel> + } > <div className="grid grid-cols-10 gap-0.5"> - {tools.map((tool, i) => <ToolIcon {...tool} key={tool.id + i} />)} + {tools.map((tool, i) => ( + <ToolIcon {...tool} key={tool.id + i} /> + ))} </div> </Group> )} diff --git a/web/app/components/workflow/nodes/agent/panel.tsx b/web/app/components/workflow/nodes/agent/panel.tsx index ebc82cafc25ba5..1971a22da78c09 100644 --- a/web/app/components/workflow/nodes/agent/panel.tsx +++ b/web/app/components/workflow/nodes/agent/panel.tsx @@ -21,7 +21,7 @@ const i18nPrefix = 'nodes.agent' function strategyParamToCredientialForm(param: StrategyParamItem): CredentialFormSchema { return { - ...param as any, + ...(param as any), variable: param.name, show_on: [], type: toType(param.type), @@ -46,27 +46,29 @@ const AgentPanel: FC<NodePanelProps<AgentNodeType>> = (props) => { const { t } = useTranslation() const isMCPVersionSupported = isSupportMCP(inputs.meta?.version) - const resetEditor = useStore(s => s.setControlPromptEditorRerenderKey) + const resetEditor = useStore((s) => s.setControlPromptEditorRerenderKey) return ( <div className="my-2"> <Field required - title={t($ => $['nodes.agent.strategy.label'], { ns: 'workflow' })} + title={t(($) => $['nodes.agent.strategy.label'], { ns: 'workflow' })} className="px-4 py-2" - tooltip={t($ => $['nodes.agent.strategy.tooltip'], { ns: 'workflow' })} + tooltip={t(($) => $['nodes.agent.strategy.tooltip'], { ns: 'workflow' })} > <MCPToolAvailabilityProvider versionSupported={isMCPVersionSupported}> <AgentStrategy - strategy={inputs.agent_strategy_name - ? { - agent_strategy_provider_name: inputs.agent_strategy_provider_name!, - agent_strategy_name: inputs.agent_strategy_name!, - agent_strategy_label: inputs.agent_strategy_label!, - agent_output_schema: inputs.output_schema, - plugin_unique_identifier: inputs.plugin_unique_identifier!, - meta: inputs.meta, - } - : undefined} + strategy={ + inputs.agent_strategy_name + ? { + agent_strategy_provider_name: inputs.agent_strategy_provider_name!, + agent_strategy_name: inputs.agent_strategy_name!, + agent_strategy_label: inputs.agent_strategy_label!, + agent_output_schema: inputs.output_schema, + plugin_unique_identifier: inputs.plugin_unique_identifier!, + meta: inputs.meta, + } + : undefined + } onStrategyChange={(strategy) => { setInputs({ ...inputs, @@ -107,30 +109,25 @@ const AgentPanel: FC<NodePanelProps<AgentNodeType>> = (props) => { <VarItem name="text" type="String" - description={t($ => $[`${i18nPrefix}.outputVars.text`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.text`], { ns: 'workflow' })} /> <VarItem name="usage" type="object" - description={t($ => $[`${i18nPrefix}.outputVars.usage`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.usage`], { ns: 'workflow' })} /> <VarItem name="files" type="Array[File]" - description={t($ => $[`${i18nPrefix}.outputVars.files.title`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.files.title`], { ns: 'workflow' })} /> <VarItem name="json" type="Array[Object]" - description={t($ => $[`${i18nPrefix}.outputVars.json`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.json`], { ns: 'workflow' })} /> {outputSchema.map(({ name, type, description }) => ( - <VarItem - key={name} - name={name} - type={type} - description={description} - /> + <VarItem key={name} name={name} type={type} description={description} /> ))} </OutputVars> </div> diff --git a/web/app/components/workflow/nodes/agent/use-config.ts b/web/app/components/workflow/nodes/agent/use-config.ts index 7fdb249d1a7cf2..790fc9d9faeb7e 100644 --- a/web/app/components/workflow/nodes/agent/use-config.ts +++ b/web/app/components/workflow/nodes/agent/use-config.ts @@ -4,11 +4,11 @@ import type { AgentNodeType } from './types' import { produce } from 'immer' import { useCallback, useEffect, useMemo } from 'react' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { generateAgentToolValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { - useIsChatMode, - useNodesReadOnly, -} from '@/app/components/workflow/hooks' + generateAgentToolValue, + toolParametersToFormSchemas, +} from '@/app/components/tools/utils/to-form-schema' +import { useIsChatMode, useNodesReadOnly } from '@/app/components/workflow/hooks' import { useCheckInstalled, useFetchPluginsInMarketPlaceByIds } from '@/service/use-plugins' import { useStrategyProviderDetail } from '@/service/use-strategy' import { VarType as VarKindType } from '../../types' @@ -25,23 +25,16 @@ type StrategyStatus = { isExistInPlugin: boolean } -export const useStrategyInfo = ( - strategyProviderName?: string, - strategyName?: string, -) => { - const strategyProvider = useStrategyProviderDetail( - strategyProviderName || '', - { retry: false }, - ) +export const useStrategyInfo = (strategyProviderName?: string, strategyName?: string) => { + const strategyProvider = useStrategyProviderDetail(strategyProviderName || '', { retry: false }) const strategy = strategyProvider.data?.declaration.strategies.find( - str => str.identity.name === strategyName, + (str) => str.identity.name === strategyName, ) const marketplace = useFetchPluginsInMarketPlaceByIds([strategyProviderName!], { retry: false, }) const strategyStatus: StrategyStatus | undefined = useMemo(() => { - if (strategyProvider.isLoading || marketplace.isLoading) - return undefined + if (strategyProvider.isLoading || marketplace.isLoading) return undefined const strategyExist = !!strategy const isPluginInstalled = !strategyProvider.isError const isInMarketplace = !!marketplace.data?.data.plugins.at(0) @@ -77,33 +70,34 @@ const useConfig = (id: string, payload: AgentNodeType) => { strategyStatus: currentStrategyStatus, strategy: currentStrategy, strategyProvider, - } = useStrategyInfo( - inputs.agent_strategy_provider_name, - inputs.agent_strategy_name, - ) + } = useStrategyInfo(inputs.agent_strategy_provider_name, inputs.agent_strategy_name) const pluginId = inputs.agent_strategy_provider_name?.split('/').splice(0, 2).join('/') const pluginDetail = useCheckInstalled({ pluginIds: [pluginId!], enabled: Boolean(pluginId), }) const formData = useMemo(() => { - const paramNameList = (currentStrategy?.parameters || []).map(item => item.name) + const paramNameList = (currentStrategy?.parameters || []).map((item) => item.name) const res = Object.fromEntries( - Object.entries(inputs.agent_parameters || {}).filter(([name]) => paramNameList.includes(name)).map(([key, value]) => { - return [key, value.value] - }), + Object.entries(inputs.agent_parameters || {}) + .filter(([name]) => paramNameList.includes(name)) + .map(([key, value]) => { + return [key, value.value] + }), ) return res }, [inputs.agent_parameters, currentStrategy?.parameters]) - const getParamVarType = useCallback((paramName: string) => { - const isVariable = currentStrategy?.parameters.some( - param => param.name === paramName && param.type === FormTypeEnum.any, - ) - if (isVariable) - return VarType.variable - return VarType.constant - }, [currentStrategy?.parameters]) + const getParamVarType = useCallback( + (paramName: string) => { + const isVariable = currentStrategy?.parameters.some( + (param) => param.name === paramName && param.type === FormTypeEnum.any, + ) + if (isVariable) return VarType.variable + return VarType.constant + }, + [currentStrategy?.parameters], + ) const onFormChange = (value: Record<string, any>) => { const res: ToolVarInputs = {} @@ -120,8 +114,19 @@ const useConfig = (id: string, payload: AgentNodeType) => { } const formattingToolData = (data: any) => { - const settingValues = generateAgentToolValue(data.settings, toolParametersToFormSchemas(data.schemas.filter((param: { form: string }) => param.form !== 'llm') as any)) - const paramValues = generateAgentToolValue(data.parameters, toolParametersToFormSchemas(data.schemas.filter((param: { form: string }) => param.form === 'llm') as any), true) + const settingValues = generateAgentToolValue( + data.settings, + toolParametersToFormSchemas( + data.schemas.filter((param: { form: string }) => param.form !== 'llm') as any, + ), + ) + const paramValues = generateAgentToolValue( + data.parameters, + toolParametersToFormSchemas( + data.schemas.filter((param: { form: string }) => param.form === 'llm') as any, + ), + true, + ) const res = produce(data, (draft: any) => { draft.settings = settingValues draft.parameters = paramValues @@ -130,16 +135,19 @@ const useConfig = (id: string, payload: AgentNodeType) => { } const formattingLegacyData = () => { - if (inputs.version || inputs.tool_node_version) - return inputs + if (inputs.version || inputs.tool_node_version) return inputs const newData = produce(inputs, (draft) => { const schemas = currentStrategy?.parameters || [] Object.keys(draft.agent_parameters || {}).forEach((key) => { - const targetSchema = schemas.find(schema => schema.name === key) + const targetSchema = schemas.find((schema) => schema.name === key) if (targetSchema?.type === FormTypeEnum.toolSelector) - draft.agent_parameters![key]!.value = formattingToolData(draft.agent_parameters![key]!.value) + draft.agent_parameters![key]!.value = formattingToolData( + draft.agent_parameters![key]!.value, + ) if (targetSchema?.type === FormTypeEnum.multiToolSelector) - draft.agent_parameters![key]!.value = draft.agent_parameters![key]!.value.map((tool: any) => formattingToolData(tool)) + draft.agent_parameters![key]!.value = draft.agent_parameters![key]!.value.map( + (tool: any) => formattingToolData(tool), + ) }) draft.tool_node_version = '2' }) @@ -148,8 +156,7 @@ const useConfig = (id: string, payload: AgentNodeType) => { // formatting legacy data useEffect(() => { - if (!currentStrategy) - return + if (!currentStrategy) return const newData = formattingLegacyData() setInputs(newData) }, [currentStrategy]) @@ -170,10 +177,7 @@ const useConfig = (id: string, payload: AgentNodeType) => { ].includes(varPayload.type) }, []) - const { - availableVars, - availableNodesWithParent, - } = useAvailableVarList(id, { + const { availableVars, availableNodesWithParent } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar: filterMemoryPromptVar, }) @@ -182,27 +186,30 @@ const useConfig = (id: string, payload: AgentNodeType) => { const outputSchema = useMemo(() => { const res: any[] = [] - if (!inputs.output_schema || !inputs.output_schema.properties) - return [] + if (!inputs.output_schema || !inputs.output_schema.properties) return [] Object.keys(inputs.output_schema.properties).forEach((outputKey) => { const output = inputs.output_schema.properties[outputKey] res.push({ name: outputKey, - type: output.type === 'array' - ? `Array[${output.items?.type ? output.items.type.slice(0, 1).toLocaleUpperCase() + output.items.type.slice(1) : 'Unknown'}]` - : `${output.type ? output.type.slice(0, 1).toLocaleUpperCase() + output.type.slice(1) : 'Unknown'}`, + type: + output.type === 'array' + ? `Array[${output.items?.type ? output.items.type.slice(0, 1).toLocaleUpperCase() + output.items.type.slice(1) : 'Unknown'}]` + : `${output.type ? output.type.slice(0, 1).toLocaleUpperCase() + output.type.slice(1) : 'Unknown'}`, description: output.description, }) }) return res }, [inputs.output_schema]) - const handleMemoryChange = useCallback((newMemory?: Memory) => { - const newInputs = produce(inputs, (draft) => { - draft.memory = newMemory - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const handleMemoryChange = useCallback( + (newMemory?: Memory) => { + const newInputs = produce(inputs, (draft) => { + draft.memory = newMemory + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) const isChatMode = useIsChatMode() return { readOnly, diff --git a/web/app/components/workflow/nodes/answer/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/answer/__tests__/node.spec.tsx index 38a8b88c81f7cd..a2ecc6cf429961 100644 --- a/web/app/components/workflow/nodes/answer/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/answer/__tests__/node.spec.tsx @@ -55,9 +55,12 @@ describe('AnswerNode', () => { ], } as unknown as ReturnType<typeof useWorkflow>) - renderNodeComponent(Node, createNodeData({ - answer: 'Hello {{#source-node.name#}}', - })) + renderNodeComponent( + Node, + createNodeData({ + answer: 'Hello {{#source-node.name#}}', + }), + ) expect(screen.getByText('Hello')).toBeInTheDocument() expect(screen.getByText('Source Node')).toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/answer/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/answer/__tests__/panel.spec.tsx index b5fbdf163f2521..b5acd23bcb01e6 100644 --- a/web/app/components/workflow/nodes/answer/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/answer/__tests__/panel.spec.tsx @@ -33,9 +33,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => mockEditorRender(props) return ( <button type="button" onClick={() => props.onChange('Updated answer')}> - {props.title} - : - {props.value} + {props.title}:{props.value} </button> ) }, @@ -70,22 +68,28 @@ describe('AnswerPanel', () => { it('should pass editor state and available variables through to the prompt editor', () => { render(<Panel id="answer-node" data={createData()} panelProps={{} as PanelProps} />) - expect(screen.getByRole('button', { name: 'workflow.nodes.answer.answer:Initial answer' })).toBeInTheDocument() - expect(mockEditorRender).toHaveBeenCalledWith(expect.objectContaining({ - readOnly: false, - title: 'workflow.nodes.answer.answer', - value: 'Initial answer', - nodesOutputVars: [{ variable: 'context', type: 'string' }], - availableNodes: [{ value: 'node-1', label: 'Node 1' }], - isSupportFileVar: true, - justVar: true, - })) + expect( + screen.getByRole('button', { name: 'workflow.nodes.answer.answer:Initial answer' }), + ).toBeInTheDocument() + expect(mockEditorRender).toHaveBeenCalledWith( + expect.objectContaining({ + readOnly: false, + title: 'workflow.nodes.answer.answer', + value: 'Initial answer', + nodesOutputVars: [{ variable: 'context', type: 'string' }], + availableNodes: [{ value: 'node-1', label: 'Node 1' }], + isSupportFileVar: true, + justVar: true, + }), + ) }) it('should delegate answer edits to use-config', () => { render(<Panel id="answer-node" data={createData()} panelProps={{} as PanelProps} />) - fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.answer.answer:Initial answer' })) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.nodes.answer.answer:Initial answer' }), + ) expect(handleAnswerChange).toHaveBeenCalledWith('Updated answer') }) diff --git a/web/app/components/workflow/nodes/answer/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/answer/__tests__/use-config.spec.ts index 106355e8c54711..f0b0b074ae35db 100644 --- a/web/app/components/workflow/nodes/answer/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/answer/__tests__/use-config.spec.ts @@ -58,9 +58,11 @@ describe('answer/use-config', () => { result.current.handleAnswerChange('Updated answer') }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - answer: 'Updated answer', - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + answer: 'Updated answer', + }), + ) expect(result.current.handleVarListChange).toBe(mockHandleVarListChange) expect(result.current.handleAddVariable).toBe(mockHandleAddVariable) expect(result.current.readOnly).toBe(false) @@ -69,13 +71,17 @@ describe('answer/use-config', () => { it('should filter out array-object variables from the prompt editor picker', () => { const { result } = renderHook(() => useConfig('answer-node', currentInputs)) - expect(result.current.filterVar({ - variable: 'items', - type: VarType.arrayObject, - })).toBe(false) - expect(result.current.filterVar({ - variable: 'message', - type: VarType.string, - })).toBe(true) + expect( + result.current.filterVar({ + variable: 'items', + type: VarType.arrayObject, + }), + ).toBe(false) + expect( + result.current.filterVar({ + variable: 'message', + type: VarType.string, + }), + ).toBe(true) }) }) diff --git a/web/app/components/workflow/nodes/answer/default.ts b/web/app/components/workflow/nodes/answer/default.ts index 472ebcef29e70f..44c88beb94e59e 100644 --- a/web/app/components/workflow/nodes/answer/default.ts +++ b/web/app/components/workflow/nodes/answer/default.ts @@ -19,7 +19,10 @@ const nodeDefault: NodeDefault<AnswerNodeType> = { let errorMessages = '' const { answer } = payload if (!answer) - errorMessages = t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: t($ => $['nodes.answer.answer'], { ns: 'workflow' }) }) + errorMessages = t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: t(($) => $['nodes.answer.answer'], { ns: 'workflow' }), + }) return { isValid: !errorMessages, diff --git a/web/app/components/workflow/nodes/answer/node.tsx b/web/app/components/workflow/nodes/answer/node.tsx index c3cc0e8f476a62..6db24f3a38be40 100644 --- a/web/app/components/workflow/nodes/answer/node.tsx +++ b/web/app/components/workflow/nodes/answer/node.tsx @@ -6,22 +6,14 @@ import { useTranslation } from 'react-i18next' import InfoPanel from '../_base/components/info-panel' import ReadonlyInputWithSelectVar from '../_base/components/readonly-input-with-select-var' -const Node: FC<NodeProps<AnswerNodeType>> = ({ - id, - data, -}) => { +const Node: FC<NodeProps<AnswerNodeType>> = ({ id, data }) => { const { t } = useTranslation() return ( <div className="mb-1 px-3 py-1"> <InfoPanel - title={t($ => $['nodes.answer.answer'], { ns: 'workflow' })} - content={( - <ReadonlyInputWithSelectVar - value={data.answer} - nodeId={id} - /> - )} + title={t(($) => $['nodes.answer.answer'], { ns: 'workflow' })} + content={<ReadonlyInputWithSelectVar value={data.answer} nodeId={id} />} /> </div> ) diff --git a/web/app/components/workflow/nodes/answer/panel.tsx b/web/app/components/workflow/nodes/answer/panel.tsx index 442d7284cf2637..0a3b6fdd90788e 100644 --- a/web/app/components/workflow/nodes/answer/panel.tsx +++ b/web/app/components/workflow/nodes/answer/panel.tsx @@ -9,18 +9,10 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.answer' -const Panel: FC<NodePanelProps<AnswerNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<AnswerNodeType>> = ({ id, data }) => { const { t } = useTranslation() - const { - readOnly, - inputs, - handleAnswerChange, - filterVar, - } = useConfig(id, data) + const { readOnly, inputs, handleAnswerChange, filterVar } = useConfig(id, data) const { availableVars, availableNodesWithParent } = useAvailableVarList(id, { onlyLeafNodeVar: false, @@ -34,7 +26,7 @@ const Panel: FC<NodePanelProps<AnswerNodeType>> = ({ <Editor readOnly={readOnly} justVar - title={t($ => $[`${i18nPrefix}.answer`], { ns: 'workflow' })!} + title={t(($) => $[`${i18nPrefix}.answer`], { ns: 'workflow' })!} value={inputs.answer} onChange={handleAnswerChange} nodesOutputVars={availableVars} diff --git a/web/app/components/workflow/nodes/answer/use-config.ts b/web/app/components/workflow/nodes/answer/use-config.ts index aad75d65911816..e62e17354b73c8 100644 --- a/web/app/components/workflow/nodes/answer/use-config.ts +++ b/web/app/components/workflow/nodes/answer/use-config.ts @@ -2,9 +2,7 @@ import type { Var } from '../../types' import type { AnswerNodeType } from './types' import { produce } from 'immer' import { useCallback } from 'react' -import { - useNodesReadOnly, -} from '@/app/components/workflow/hooks' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { VarType } from '../../types' import useVarList from '../_base/hooks/use-var-list' @@ -18,12 +16,15 @@ const useConfig = (id: string, payload: AnswerNodeType) => { setInputs, }) - const handleAnswerChange = useCallback((value: string) => { - const newInputs = produce(inputs, (draft) => { - draft.answer = value - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const handleAnswerChange = useCallback( + (value: string) => { + const newInputs = produce(inputs, (draft) => { + draft.answer = value + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) const filterVar = useCallback((varPayload: Var) => { return varPayload.type !== VarType.arrayObject diff --git a/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx index fcdc6ee519a12c..c001fb6227b86f 100644 --- a/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx @@ -15,7 +15,13 @@ import useConfig from '../use-config' const mockHandleAddOperationItem = vi.fn() vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ - default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>, + default: ({ title, operations, children }: any) => ( + <div> + <div>{title}</div> + <div>{operations}</div> + {children} + </div> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/list-no-data-placeholder', () => ({ @@ -23,7 +29,15 @@ vi.mock('@/app/components/workflow/nodes/_base/components/list-no-data-placehold })) vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ - default: ({ value, onChange, onOpen, placeholder, popupFor, valueTypePlaceHolder, filterVar }: any) => ( + default: ({ + value, + onChange, + onOpen, + placeholder, + popupFor, + valueTypePlaceHolder, + filterVar, + }: any) => ( <div> <div>{Array.isArray(value) ? value.join('.') : String(value ?? '')}</div> {valueTypePlaceHolder && <div>{`type:${valueTypePlaceHolder}`}</div>} @@ -48,7 +62,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', ( <textarea aria-label="code-editor" value={value} - onChange={event => onChange(event.target.value)} + onChange={(event) => onChange(event.target.value)} /> ), })) @@ -81,7 +95,9 @@ vi.mock('../use-config', () => ({ const mockUseConfig = vi.mocked(useConfig) -const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({ +const createOperation = ( + overrides: Partial<AssignerNodeOperation> = {}, +): AssignerNodeOperation => ({ variable_selector: ['node-1', 'count'], input_type: AssignerNodeInputType.variable, operation: WriteMode.overwrite, @@ -98,7 +114,9 @@ const createData = (overrides: Partial<AssignerNodeType> = {}): AssignerNodeType ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), handleOperationListChanges: vi.fn(), @@ -126,7 +144,10 @@ const panelProps: PanelProps = { describe('assigner path', () => { beforeEach(() => { vi.clearAllMocks() - mockHandleAddOperationItem.mockReturnValue([createOperation(), createOperation({ variable_selector: [] })]) + mockHandleAddOperationItem.mockReturnValue([ + createOperation(), + createOperation({ variable_selector: [] }), + ]) mockUseConfig.mockReturnValue(createConfigResult()) }) @@ -152,7 +173,10 @@ describe('assigner path', () => { expect(screen.getByText('workflow.nodes.assigner.operations.+='))!.toBeInTheDocument() await user.click(screen.getByText('workflow.nodes.assigner.operations.+=')) - expect(onSelect).toHaveBeenCalledWith({ value: WriteMode.increment, name: WriteMode.increment }) + expect(onSelect).toHaveBeenCalledWith({ + value: WriteMode.increment, + name: WriteMode.increment, + }) }) it('should not open a disabled operation selector', async () => { @@ -177,12 +201,7 @@ describe('assigner path', () => { const onChange = vi.fn() const onOpen = vi.fn() const { rerender } = render( - <VarList - readonly={false} - nodeId="node-1" - list={[]} - onChange={onChange} - />, + <VarList readonly={false} nodeId="node-1" list={[]} onChange={onChange} />, ) expect(screen.getByText('workflow.nodes.assigner.noVarTip'))!.toBeInTheDocument() @@ -206,14 +225,17 @@ describe('assigner path', () => { await user.click(screen.getByText('workflow.nodes.assigner.selectAssignedVariable')) expect(onOpen).toHaveBeenCalledWith(0) - expect(onChange).toHaveBeenLastCalledWith([ - { - variable_selector: ['node-1', 'count'], - operation: WriteMode.overwrite, - input_type: AssignerNodeInputType.variable, - value: undefined, - }, - ], ['node-1', 'count']) + expect(onChange).toHaveBeenLastCalledWith( + [ + { + variable_selector: ['node-1', 'count'], + operation: WriteMode.overwrite, + input_type: AssignerNodeInputType.variable, + value: undefined, + }, + ], + ['node-1', 'count'], + ) onChange.mockClear() rerender( @@ -245,9 +267,10 @@ describe('assigner path', () => { onChange.mockClear() await user.click(screen.getByText('workflow.nodes.assigner.setParameter')) - expect(onChange).toHaveBeenLastCalledWith([ - createOperation({ operation: WriteMode.overwrite, value: ['node-2', 'result'] }), - ], ['node-2', 'result']) + expect(onChange).toHaveBeenLastCalledWith( + [createOperation({ operation: WriteMode.overwrite, value: ['node-2', 'result'] })], + ['node-2', 'result'], + ) onChange.mockClear() rerender( @@ -267,9 +290,10 @@ describe('assigner path', () => { ) fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated text' } }) - expect(onChange).toHaveBeenLastCalledWith([ - createOperation({ operation: WriteMode.set, value: 'updated text' }), - ], 'updated text') + expect(onChange).toHaveBeenLastCalledWith( + [createOperation({ operation: WriteMode.set, value: 'updated text' })], + 'updated text', + ) onChange.mockClear() rerender( @@ -289,9 +313,10 @@ describe('assigner path', () => { ) fireEvent.change(screen.getByDisplayValue('3'), { target: { value: '5' } }) - expect(onChange).toHaveBeenLastCalledWith([ - createOperation({ operation: WriteMode.set, value: 5 }), - ], 5) + expect(onChange).toHaveBeenLastCalledWith( + [createOperation({ operation: WriteMode.set, value: 5 })], + 5, + ) onChange.mockClear() rerender( @@ -311,9 +336,10 @@ describe('assigner path', () => { ) await user.click(screen.getByRole('button', { name: 'bool:false' })) - expect(onChange).toHaveBeenLastCalledWith([ - createOperation({ operation: WriteMode.set, value: true }), - ], true) + expect(onChange).toHaveBeenLastCalledWith( + [createOperation({ operation: WriteMode.set, value: true })], + true, + ) onChange.mockClear() rerender( @@ -333,9 +359,10 @@ describe('assigner path', () => { ) fireEvent.change(screen.getByLabelText('code-editor'), { target: { value: '{"a":2}' } }) - expect(onChange).toHaveBeenLastCalledWith([ - createOperation({ operation: WriteMode.set, value: '{"a":2}' }), - ], '{"a":2}') + expect(onChange).toHaveBeenLastCalledWith( + [createOperation({ operation: WriteMode.set, value: '{"a":2}' })], + '{"a":2}', + ) onChange.mockClear() rerender( @@ -355,9 +382,10 @@ describe('assigner path', () => { ) fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '4' } }) - expect(onChange).toHaveBeenLastCalledWith([ - createOperation({ operation: WriteMode.increment, value: 4 }), - ], 4) + expect(onChange).toHaveBeenLastCalledWith( + [createOperation({ operation: WriteMode.increment, value: 4 })], + 4, + ) const buttons = screen.getAllByRole('button') await user.click(buttons.at(-1)!) @@ -374,8 +402,16 @@ describe('assigner path', () => { />, { nodes: [ - { id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any }, - { id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any }, + { + id: 'node-1', + position: { x: 0, y: 0 }, + data: { title: 'Answer', type: BlockEnum.Answer } as any, + }, + { + id: 'start', + position: { x: 0, y: 0 }, + data: { title: 'Start', type: BlockEnum.Start } as any, + }, ], edges: [], }, @@ -399,13 +435,15 @@ describe('assigner path', () => { rerender( <Node id="assigner-node" - data={{ - title: 'Legacy Assigner', - desc: '', - type: BlockEnum.VariableAssigner, - assigned_variable_selector: ['sys', 'query'], - write_mode: WriteMode.append, - } as any} + data={ + { + title: 'Legacy Assigner', + desc: '', + type: BlockEnum.VariableAssigner, + assigned_variable_selector: ['sys', 'query'], + write_mode: WriteMode.append, + } as any + } />, ) @@ -430,8 +468,16 @@ describe('assigner path', () => { />, { nodes: [ - { id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any }, - { id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any }, + { + id: 'node-1', + position: { x: 0, y: 0 }, + data: { title: 'Answer', type: BlockEnum.Answer } as any, + }, + { + id: 'start', + position: { x: 0, y: 0 }, + data: { title: 'Start', type: BlockEnum.Start } as any, + }, ], edges: [], }, @@ -439,43 +485,59 @@ describe('assigner path', () => { expect(screen.getByText('Start'))!.toBeInTheDocument() expect(screen.getByText('sys.query'))!.toBeInTheDocument() - expect(screen.queryByText('workflow.nodes.assigner.operations.over-write')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.assigner.operations.over-write'), + ).not.toBeInTheDocument() }) it('should return null for legacy nodes without assigned variables and resolve non-system legacy vars', () => { const { rerender } = renderWorkflowFlowComponent( <Node id="assigner-node" - data={{ - title: 'Legacy Assigner', - desc: '', - type: BlockEnum.VariableAssigner, - assigned_variable_selector: [], - write_mode: WriteMode.append, - } as any} + data={ + { + title: 'Legacy Assigner', + desc: '', + type: BlockEnum.VariableAssigner, + assigned_variable_selector: [], + write_mode: WriteMode.append, + } as any + } />, { nodes: [ - { id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any }, - { id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any }, + { + id: 'node-1', + position: { x: 0, y: 0 }, + data: { title: 'Answer', type: BlockEnum.Answer } as any, + }, + { + id: 'start', + position: { x: 0, y: 0 }, + data: { title: 'Start', type: BlockEnum.Start } as any, + }, ], edges: [], }, ) - expect(screen.queryByText('workflow.nodes.assigner.operations.append')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.assigner.operations.append'), + ).not.toBeInTheDocument() expect(screen.queryByText('node-1.count')).not.toBeInTheDocument() rerender( <Node id="assigner-node" - data={{ - title: 'Legacy Assigner', - desc: '', - type: BlockEnum.VariableAssigner, - assigned_variable_selector: ['node-1', 'count'], - write_mode: WriteMode.append, - } as any} + data={ + { + title: 'Legacy Assigner', + desc: '', + type: BlockEnum.VariableAssigner, + assigned_variable_selector: ['node-1', 'count'], + write_mode: WriteMode.append, + } as any + } />, ) @@ -491,13 +553,7 @@ describe('assigner path', () => { }) mockUseConfig.mockReturnValue(config) - render( - <Panel - id="assigner-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<Panel id="assigner-node" data={createData()} panelProps={panelProps} />) await user.click(screen.getAllByRole('button')[0]!) diff --git a/web/app/components/workflow/nodes/assigner/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/assigner/__tests__/node.spec.tsx index a1fd87d386bf15..7dac58306748ed 100644 --- a/web/app/components/workflow/nodes/assigner/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/__tests__/node.spec.tsx @@ -34,7 +34,9 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-labe const mockUseNodes = vi.mocked(useNodes) -const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({ +const createOperation = ( + overrides: Partial<AssignerNodeOperation> = {}, +): AssignerNodeOperation => ({ variable_selector: ['node-1', 'count'], input_type: AssignerNodeInputType.variable, operation: WriteMode.overwrite, @@ -86,12 +88,7 @@ describe('assigner/node', () => { }) it('renders both version 2 and legacy previews with resolved node labels', () => { - const { container, rerender } = render( - <Node - id="assigner-node" - data={createData()} - />, - ) + const { container, rerender } = render(<Node id="assigner-node" data={createData()} />) expect(screen.getByText('Answer:answer:node-1.count')).toBeInTheDocument() expect(screen.getByText('workflow.nodes.assigner.operations.over-write')).toBeInTheDocument() @@ -99,13 +96,15 @@ describe('assigner/node', () => { rerender( <Node id="assigner-node" - data={{ - title: 'Legacy Assigner', - desc: '', - type: BlockEnum.VariableAssigner, - assigned_variable_selector: ['sys', 'query'], - write_mode: WriteMode.append, - } as unknown as AssignerNodeType} + data={ + { + title: 'Legacy Assigner', + desc: '', + type: BlockEnum.VariableAssigner, + assigned_variable_selector: ['sys', 'query'], + write_mode: WriteMode.append, + } as unknown as AssignerNodeType + } />, ) @@ -115,13 +114,15 @@ describe('assigner/node', () => { rerender( <Node id="assigner-node" - data={{ - title: 'Legacy Assigner', - desc: '', - type: BlockEnum.VariableAssigner, - assigned_variable_selector: [], - write_mode: WriteMode.append, - } as unknown as AssignerNodeType} + data={ + { + title: 'Legacy Assigner', + desc: '', + type: BlockEnum.VariableAssigner, + assigned_variable_selector: [], + write_mode: WriteMode.append, + } as unknown as AssignerNodeType + } />, ) diff --git a/web/app/components/workflow/nodes/assigner/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/assigner/__tests__/panel.spec.tsx index c70c84beabfec9..72603335b92c51 100644 --- a/web/app/components/workflow/nodes/assigner/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/__tests__/panel.spec.tsx @@ -17,7 +17,9 @@ const mockUseConfig = vi.hoisted(() => vi.fn()) const mockUseHandleAddOperationItem = vi.hoisted(() => vi.fn()) const mockVarListRender = vi.hoisted(() => vi.fn()) -const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({ +const createOperation = ( + overrides: Partial<AssignerNodeOperation> = {}, +): AssignerNodeOperation => ({ variable_selector: ['node-1', 'count'], input_type: AssignerNodeInputType.variable, operation: WriteMode.overwrite, @@ -40,8 +42,13 @@ vi.mock('../components/var-list', () => ({ mockVarListRender(props) return ( <div> - <div>{props.list.map(item => item.variable_selector.join('.')).join(',')}</div> - <button type="button" onClick={() => props.onChange([createOperation({ variable_selector: ['node-1', 'updated'] })])}> + <div>{props.list.map((item) => item.variable_selector.join('.')).join(',')}</div> + <button + type="button" + onClick={() => + props.onChange([createOperation({ variable_selector: ['node-1', 'updated'] })]) + } + > emit-list-change </button> </div> @@ -86,21 +93,17 @@ describe('assigner/panel', () => { it('passes the resolved config to the variable list and appends operations through the add button', async () => { const user = userEvent.setup() - render( - <Panel - id="assigner-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<Panel id="assigner-node" data={createData()} panelProps={panelProps} />) expect(screen.getByText('workflow.nodes.assigner.variables')).toBeInTheDocument() expect(screen.getByText('node-1.count')).toBeInTheDocument() - expect(mockVarListRender).toHaveBeenCalledWith(expect.objectContaining({ - readonly: false, - nodeId: 'assigner-node', - list: createData().items, - })) + expect(mockVarListRender).toHaveBeenCalledWith( + expect.objectContaining({ + readonly: false, + nodeId: 'assigner-node', + list: createData().items, + }), + ) await user.click(screen.getAllByRole('button')[0]!) diff --git a/web/app/components/workflow/nodes/assigner/__tests__/use-config.helpers.spec.ts b/web/app/components/workflow/nodes/assigner/__tests__/use-config.helpers.spec.ts index 1a2beeff5b5c68..327d7540fe25af 100644 --- a/web/app/components/workflow/nodes/assigner/__tests__/use-config.helpers.spec.ts +++ b/web/app/components/workflow/nodes/assigner/__tests__/use-config.helpers.spec.ts @@ -15,12 +15,14 @@ const createInputs = (version: AssignerNodeType['version'] = '1'): AssignerNodeT desc: '', type: BlockEnum.Assigner, version, - items: [{ - variable_selector: ['conversation', 'count'], - input_type: AssignerNodeInputType.variable, - operation: WriteMode.overwrite, - value: ['node-1', 'value'], - }], + items: [ + { + variable_selector: ['conversation', 'count'], + input_type: AssignerNodeInputType.variable, + operation: WriteMode.overwrite, + value: ['node-1', 'value'], + }, + ], }) describe('assigner use-config helpers', () => { @@ -42,23 +44,39 @@ describe('assigner use-config helpers', () => { }) it('validates assignment targets for append, arithmetic and fallback modes', () => { - expect(canAssignToVar({ type: VarType.number } as never, VarType.number, WriteMode.multiply)).toBe(true) - expect(canAssignToVar({ type: VarType.string } as never, VarType.number, WriteMode.multiply)).toBe(false) - expect(canAssignToVar({ type: VarType.string } as never, VarType.arrayString, WriteMode.append)).toBe(true) - expect(canAssignToVar({ type: VarType.number } as never, VarType.arrayNumber, WriteMode.append)).toBe(true) - expect(canAssignToVar({ type: VarType.object } as never, VarType.arrayObject, WriteMode.append)).toBe(true) - expect(canAssignToVar({ type: VarType.boolean } as never, VarType.arrayString, WriteMode.append)).toBe(false) - expect(canAssignToVar({ type: VarType.string } as never, VarType.string, WriteMode.set)).toBe(true) + expect( + canAssignToVar({ type: VarType.number } as never, VarType.number, WriteMode.multiply), + ).toBe(true) + expect( + canAssignToVar({ type: VarType.string } as never, VarType.number, WriteMode.multiply), + ).toBe(false) + expect( + canAssignToVar({ type: VarType.string } as never, VarType.arrayString, WriteMode.append), + ).toBe(true) + expect( + canAssignToVar({ type: VarType.number } as never, VarType.arrayNumber, WriteMode.append), + ).toBe(true) + expect( + canAssignToVar({ type: VarType.object } as never, VarType.arrayObject, WriteMode.append), + ).toBe(true) + expect( + canAssignToVar({ type: VarType.boolean } as never, VarType.arrayString, WriteMode.append), + ).toBe(false) + expect(canAssignToVar({ type: VarType.string } as never, VarType.string, WriteMode.set)).toBe( + true, + ) }) it('ensures version 2 and replaces operation items immutably', () => { const legacyInputs = createInputs('1') - const nextItems = [{ - variable_selector: ['conversation', 'total'], - input_type: AssignerNodeInputType.constant, - operation: WriteMode.clear, - value: '0', - }] + const nextItems = [ + { + variable_selector: ['conversation', 'total'], + input_type: AssignerNodeInputType.constant, + operation: WriteMode.clear, + value: '0', + }, + ] expect(ensureAssignerVersion(legacyInputs).version).toBe('2') expect(ensureAssignerVersion(createInputs('2')).version).toBe('2') @@ -67,18 +85,23 @@ describe('assigner use-config helpers', () => { }) it('sanitizes variable-selector items restored from collaboration payloads', () => { - const dirtyItems = [{ - variable_selector: null as unknown as AssignerNodeType['items'][number]['variable_selector'], - input_type: AssignerNodeInputType.variable, - operation: WriteMode.overwrite, - value: null, - }] + const dirtyItems = [ + { + variable_selector: + null as unknown as AssignerNodeType['items'][number]['variable_selector'], + input_type: AssignerNodeInputType.variable, + operation: WriteMode.overwrite, + value: null, + }, + ] - expect(updateOperationItems(createInputs('2'), dirtyItems).items).toEqual([{ - variable_selector: [], - input_type: AssignerNodeInputType.variable, - operation: WriteMode.overwrite, - value: [], - }]) + expect(updateOperationItems(createInputs('2'), dirtyItems).items).toEqual([ + { + variable_selector: [], + input_type: AssignerNodeInputType.variable, + operation: WriteMode.overwrite, + value: [], + }, + ]) }) }) diff --git a/web/app/components/workflow/nodes/assigner/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/assigner/__tests__/use-config.spec.tsx index b24473bbd9d12d..3a97d63731c473 100644 --- a/web/app/components/workflow/nodes/assigner/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/__tests__/use-config.spec.tsx @@ -45,7 +45,9 @@ vi.mock('reactflow', async () => { } }) -const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({ +const createOperation = ( + overrides: Partial<AssignerNodeOperation> = {}, +): AssignerNodeOperation => ({ variable_selector: ['conversation', 'count'], input_type: AssignerNodeInputType.variable, operation: WriteMode.overwrite, @@ -73,10 +75,16 @@ describe('useConfig', () => { const { result } = renderHook(() => useConfig('assigner-node', createPayload())) expect(result.current.readOnly).toBe(false) - expect(result.current.writeModeTypes).toEqual([WriteMode.overwrite, WriteMode.clear, WriteMode.set]) + expect(result.current.writeModeTypes).toEqual([ + WriteMode.overwrite, + WriteMode.clear, + WriteMode.set, + ]) expect(result.current.writeModeTypesNum).toEqual(writeModeTypesNum) expect(result.current.getAssignedVarType(['conversation', 'count'])).toBe(VarType.arrayString) - expect(result.current.getToAssignedVarType(VarType.arrayString, WriteMode.append)).toBe(VarType.string) + expect(result.current.getToAssignedVarType(VarType.arrayString, WriteMode.append)).toBe( + VarType.string, + ) expect(result.current.filterVar(VarType.string)({ type: VarType.any } as never)).toBe(true) }) @@ -86,33 +94,53 @@ describe('useConfig', () => { result.current.handleOperationListChanges(nextItems) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - version: '2', - items: nextItems, - })) - expect(result.current.filterAssignedVar({ isLoopVariable: true } as never, ['node', 'value'])).toBe(true) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + version: '2', + items: nextItems, + }), + ) + expect( + result.current.filterAssignedVar({ isLoopVariable: true } as never, ['node', 'value']), + ).toBe(true) expect(result.current.filterAssignedVar({} as never, ['conversation', 'name'])).toBe(true) - expect(result.current.filterToAssignedVar({ type: VarType.string } as never, VarType.arrayString, WriteMode.append)).toBe(true) - expect(result.current.filterToAssignedVar({ type: VarType.number } as never, VarType.arrayString, WriteMode.append)).toBe(false) + expect( + result.current.filterToAssignedVar( + { type: VarType.string } as never, + VarType.arrayString, + WriteMode.append, + ), + ).toBe(true) + expect( + result.current.filterToAssignedVar( + { type: VarType.number } as never, + VarType.arrayString, + WriteMode.append, + ), + ).toBe(false) }) it('should normalize collaboration-restored null selectors before exposing inputs', () => { const dirtyPayload = createPayload({ version: '2', - items: [createOperation({ - variable_selector: null as unknown as AssignerNodeOperation['variable_selector'], - input_type: AssignerNodeInputType.variable, - value: null, - })], + items: [ + createOperation({ + variable_selector: null as unknown as AssignerNodeOperation['variable_selector'], + input_type: AssignerNodeInputType.variable, + value: null, + }), + ], }) const { result } = renderHook(() => useConfig('assigner-node', dirtyPayload)) - expect(result.current.inputs.items).toEqual([expect.objectContaining({ - variable_selector: [], - input_type: AssignerNodeInputType.variable, - operation: WriteMode.overwrite, - value: [], - })]) + expect(result.current.inputs.items).toEqual([ + expect.objectContaining({ + variable_selector: [], + input_type: AssignerNodeInputType.variable, + operation: WriteMode.overwrite, + value: [], + }), + ]) }) }) diff --git a/web/app/components/workflow/nodes/assigner/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/assigner/__tests__/use-single-run-form-params.spec.ts index e386d7718ff136..921ff06b781415 100644 --- a/web/app/components/workflow/nodes/assigner/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/assigner/__tests__/use-single-run-form-params.spec.ts @@ -13,7 +13,9 @@ vi.mock('../../_base/hooks/use-node-crud', () => ({ const mockUseNodeCrud = vi.mocked(useNodeCrud) -const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({ +const createOperation = ( + overrides: Partial<AssignerNodeOperation> = {}, +): AssignerNodeOperation => ({ variable_selector: ['node-1', 'target'], input_type: AssignerNodeInputType.variable, operation: WriteMode.overwrite, @@ -30,8 +32,16 @@ const createData = (overrides: Partial<AssignerNodeType> = {}): AssignerNodeType createOperation(), createOperation({ operation: WriteMode.append, value: ['node-3', 'items'] }), createOperation({ operation: WriteMode.clear, value: ['node-4', 'unused'] }), - createOperation({ operation: WriteMode.set, input_type: AssignerNodeInputType.constant, value: 'fixed' }), - createOperation({ operation: WriteMode.increment, input_type: AssignerNodeInputType.constant, value: 2 }), + createOperation({ + operation: WriteMode.set, + input_type: AssignerNodeInputType.constant, + value: 'fixed', + }), + createOperation({ + operation: WriteMode.increment, + input_type: AssignerNodeInputType.constant, + value: 2, + }), ], ...overrides, }) @@ -47,24 +57,28 @@ describe('assigner/use-single-run-form-params', () => { it('exposes only variable-driven dependencies in the single-run form', () => { const setRunInputData = vi.fn() - const varInputs: InputVar[] = [{ - label: 'Result', - variable: 'result', - type: InputVarType.textInput, - required: true, - }] + const varInputs: InputVar[] = [ + { + label: 'Result', + variable: 'result', + type: InputVarType.textInput, + required: true, + }, + ] const varSelectorsToVarInputs = vi.fn(() => varInputs) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'assigner-node', - payload: createData(), - runInputData: { result: 'hello' }, - runInputDataRef: { current: {} }, - getInputVars: () => [], - setRunInputData, - toVarInputs: () => [], - varSelectorsToVarInputs, - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'assigner-node', + payload: createData(), + runInputData: { result: 'hello' }, + runInputDataRef: { current: {} }, + getInputVars: () => [], + setRunInputData, + toVarInputs: () => [], + varSelectorsToVarInputs, + }), + ) expect(varSelectorsToVarInputs).toHaveBeenCalledWith([ ['node-2', 'result'], diff --git a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx index 937c7aed9caac4..4c2d7c4a0117cf 100644 --- a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx @@ -6,17 +6,27 @@ import OperationSelector from '../operation-selector' vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') - const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + const DropdownMenuContext = React.createContext<{ + open: boolean + setOpen: (open: boolean) => void + } | null>(null) const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + DropdownMenu: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( <DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}> <div>{children}</div> </DropdownMenuContext> diff --git a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx index 037ffaa941e25e..a573f77aaacc43 100644 --- a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx +++ b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx @@ -43,28 +43,43 @@ const OperationSelector: FC<OperationSelectorProps> = ({ const { t } = useTranslation() const [open, setOpen] = useState(false) - const items = getOperationItems(assignedVarType, writeModeTypes, writeModeTypesArr, writeModeTypesNum) + const items = getOperationItems( + assignedVarType, + writeModeTypes, + writeModeTypesArr, + writeModeTypesNum, + ) - const selectedItem = items.find(item => item.value === value) + const selectedItem = items.find((item) => item.value === value) return ( - <DropdownMenu - open={open} - onOpenChange={setOpen} - > + <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger disabled={disabled} - className={cn('group flex items-center gap-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 data-popup-open:bg-state-base-hover-alt', disabled ? 'cursor-not-allowed bg-components-input-bg-disabled!' : 'cursor-pointer hover:bg-state-base-hover-alt', className)} + className={cn( + 'group flex items-center gap-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 data-popup-open:bg-state-base-hover-alt', + disabled + ? 'cursor-not-allowed bg-components-input-bg-disabled!' + : 'cursor-pointer hover:bg-state-base-hover-alt', + className, + )} > <div className="flex items-center p-1"> <span - className={`truncate overflow-hidden system-sm-regular text-ellipsis - ${selectedItem ? 'text-components-input-text-filled' : 'text-components-input-text-disabled'}`} + className={`truncate overflow-hidden system-sm-regular text-ellipsis ${selectedItem ? 'text-components-input-text-filled' : 'text-components-input-text-disabled'}`} > - {selectedItem && isOperationItem(selectedItem) ? t($ => $[`nodes.assigner.operations.${selectedItem.name}`], { ns: 'workflow' }) : t($ => $['nodes.assigner.operations.title'], { ns: 'workflow' })} + {selectedItem && isOperationItem(selectedItem) + ? t(($) => $[`nodes.assigner.operations.${selectedItem.name}`], { ns: 'workflow' }) + : t(($) => $['nodes.assigner.operations.title'], { ns: 'workflow' })} </span> </div> - <span aria-hidden className={cn('i-ri-arrow-down-s-line size-4 text-text-quaternary group-data-popup-open:text-text-secondary', disabled && 'text-components-input-text-placeholder')} /> + <span + aria-hidden + className={cn( + 'i-ri-arrow-down-s-line size-4 text-text-quaternary group-data-popup-open:text-text-secondary', + disabled && 'text-components-input-text-placeholder', + )} + /> </DropdownMenuTrigger> <DropdownMenuContent @@ -73,29 +88,31 @@ const OperationSelector: FC<OperationSelectorProps> = ({ popupClassName={cn('w-[140px]', popupClassName)} > <DropdownMenuGroup> - <DropdownMenuLabel>{t($ => $['nodes.assigner.operations.title'], { ns: 'workflow' })}</DropdownMenuLabel> - {items.map(item => ( - !isOperationItem(item) - ? ( - <DropdownMenuSeparator key="divider" /> - ) - : ( - <DropdownMenuItem - key={item.value} - className="gap-1 px-2 py-1" - onClick={() => onSelect(item)} - > - <div className="flex min-h-5 grow items-center gap-1 px-1"> - <span className="flex grow system-sm-medium text-text-secondary">{t($ => $[`nodes.assigner.operations.${item.name}`], { ns: 'workflow' })}</span> - </div> - {item.value === value && ( - <div className="flex items-center justify-center"> - <span aria-hidden className="i-ri-check-line size-4 text-text-accent" /> - </div> - )} - </DropdownMenuItem> - ) - ))} + <DropdownMenuLabel> + {t(($) => $['nodes.assigner.operations.title'], { ns: 'workflow' })} + </DropdownMenuLabel> + {items.map((item) => + !isOperationItem(item) ? ( + <DropdownMenuSeparator key="divider" /> + ) : ( + <DropdownMenuItem + key={item.value} + className="gap-1 px-2 py-1" + onClick={() => onSelect(item)} + > + <div className="flex min-h-5 grow items-center gap-1 px-1"> + <span className="flex grow system-sm-medium text-text-secondary"> + {t(($) => $[`nodes.assigner.operations.${item.name}`], { ns: 'workflow' })} + </span> + </div> + {item.value === value && ( + <div className="flex items-center justify-center"> + <span aria-hidden className="i-ri-check-line size-4 text-text-accent" /> + </div> + )} + </DropdownMenuItem> + ), + )} </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> diff --git a/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/branches.spec.tsx b/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/branches.spec.tsx index a9b5a304f47de0..60313505d70f4e 100644 --- a/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/branches.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/branches.spec.tsx @@ -23,7 +23,9 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference </button> <button type="button" - onClick={() => onChange(popupFor === 'assigned' ? ['node-b', 'total'] : ['node-c', 'result'])} + onClick={() => + onChange(popupFor === 'assigned' ? ['node-b', 'total'] : ['node-c', 'result']) + } > select- {popupFor} @@ -34,14 +36,14 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference vi.mock('../../operation-selector', () => ({ __esModule: true, - default: ({ - onSelect, - }: { - onSelect: (item: { value: string }) => void - }) => ( + default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => ( <div> - <button type="button" onClick={() => onSelect({ value: WriteMode.set })}>operation-set</button> - <button type="button" onClick={() => onSelect({ value: WriteMode.overwrite })}>operation-overwrite</button> + <button type="button" onClick={() => onSelect({ value: WriteMode.set })}> + operation-set + </button> + <button type="button" onClick={() => onSelect({ value: WriteMode.overwrite })}> + operation-overwrite + </button> </div> ), })) @@ -91,35 +93,42 @@ describe('assigner/var-list branches', () => { it('resets operation metadata when the assigned variable changes', async () => { const user = userEvent.setup() const { handleChange, handleOpen } = renderVarList({ - list: [createOperation({ - operation: WriteMode.set, - input_type: AssignerNodeInputType.constant, - value: 'stale', - })], + list: [ + createOperation({ + operation: WriteMode.set, + input_type: AssignerNodeInputType.constant, + value: 'stale', + }), + ], }) await user.click(screen.getByTestId('assigned-picker-trigger')) await user.click(screen.getByRole('button', { name: 'select-assigned' })) expect(handleOpen).toHaveBeenCalledWith(0) - expect(handleChange).toHaveBeenLastCalledWith([ - createOperation({ - variable_selector: ['node-b', 'total'], - operation: WriteMode.overwrite, - input_type: AssignerNodeInputType.variable, - value: undefined, - }), - ], ['node-b', 'total']) + expect(handleChange).toHaveBeenLastCalledWith( + [ + createOperation({ + variable_selector: ['node-b', 'total'], + operation: WriteMode.overwrite, + input_type: AssignerNodeInputType.variable, + value: undefined, + }), + ], + ['node-b', 'total'], + ) }) it('switches back to variable mode when the selected operation no longer requires a constant', async () => { const user = userEvent.setup() const { handleChange } = renderVarList({ - list: [createOperation({ - operation: WriteMode.set, - input_type: AssignerNodeInputType.constant, - value: 'hello', - })], + list: [ + createOperation({ + operation: WriteMode.set, + input_type: AssignerNodeInputType.constant, + value: 'hello', + }), + ], }) await user.click(screen.getByRole('button', { name: 'operation-overwrite' })) @@ -135,11 +144,13 @@ describe('assigner/var-list branches', () => { it('updates string and number constant inputs through the inline editors', () => { const { handleChange, rerender } = renderVarList({ - list: [createOperation({ - operation: WriteMode.set, - input_type: AssignerNodeInputType.constant, - value: 1, - })], + list: [ + createOperation({ + operation: WriteMode.set, + input_type: AssignerNodeInputType.constant, + value: 1, + }), + ], getAssignedVarType: () => VarType.number, getToAssignedVarType: () => VarType.number, }) @@ -148,23 +159,28 @@ describe('assigner/var-list branches', () => { target: { value: '2' }, }) - expect(handleChange).toHaveBeenLastCalledWith([ - createOperation({ - operation: WriteMode.set, - input_type: AssignerNodeInputType.constant, - value: 2, - }), - ], 2) + expect(handleChange).toHaveBeenLastCalledWith( + [ + createOperation({ + operation: WriteMode.set, + input_type: AssignerNodeInputType.constant, + value: 2, + }), + ], + 2, + ) rerender( <VarList readonly={false} nodeId="node-current" - list={[createOperation({ - operation: WriteMode.set, - input_type: AssignerNodeInputType.constant, - value: 'hello', - })]} + list={[ + createOperation({ + operation: WriteMode.set, + input_type: AssignerNodeInputType.constant, + value: 'hello', + }), + ]} onChange={handleChange} onOpen={vi.fn()} getAssignedVarType={() => VarType.string} @@ -179,21 +195,26 @@ describe('assigner/var-list branches', () => { target: { value: 'updated' }, }) - expect(handleChange).toHaveBeenLastCalledWith([ - createOperation({ - operation: WriteMode.set, - input_type: AssignerNodeInputType.constant, - value: 'updated', - }), - ], 'updated') + expect(handleChange).toHaveBeenLastCalledWith( + [ + createOperation({ + operation: WriteMode.set, + input_type: AssignerNodeInputType.constant, + value: 'updated', + }), + ], + 'updated', + ) }) it('updates numeric write-mode inputs through the dedicated number field', () => { const { handleChange } = renderVarList({ - list: [createOperation({ - operation: WriteMode.increment, - value: 2, - })], + list: [ + createOperation({ + operation: WriteMode.increment, + value: 2, + }), + ], getAssignedVarType: () => VarType.number, getToAssignedVarType: () => VarType.number, writeModeTypesNum: [WriteMode.increment], @@ -203,11 +224,14 @@ describe('assigner/var-list branches', () => { target: { value: '5' }, }) - expect(handleChange).toHaveBeenLastCalledWith([ - createOperation({ - operation: WriteMode.increment, - value: 5, - }), - ], 5) + expect(handleChange).toHaveBeenLastCalledWith( + [ + createOperation({ + operation: WriteMode.increment, + value: 5, + }), + ], + 5, + ) }) }) diff --git a/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/index.spec.tsx index 37d2de8d806b47..d05a9a838816c1 100644 --- a/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/index.spec.tsx @@ -40,7 +40,9 @@ const currentNode = createNode({ }, }) -const createOperation = (overrides: Partial<ComponentProps<typeof VarList>['list'][number]> = {}) => ({ +const createOperation = ( + overrides: Partial<ComponentProps<typeof VarList>['list'][number]> = {}, +) => ({ variable_selector: ['node-a', 'flag'], input_type: AssignerNodeInputType.variable, operation: WriteMode.overwrite, diff --git a/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx b/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx index cdf86390318d0d..814fcd4e5fb203 100644 --- a/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx +++ b/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx @@ -50,76 +50,101 @@ const VarList: FC<Props> = ({ writeModeTypesNum, }) => { const { t } = useTranslation() - const handleAssignedVarChange = useCallback((index: number) => { - return (value: ValueSelector | string) => { - const newList = produce(list, (draft) => { - draft[index]!.variable_selector = value as ValueSelector - draft[index]!.operation = WriteMode.overwrite - draft[index]!.input_type = AssignerNodeInputType.variable - draft[index]!.value = undefined - }) - onChange(newList, value as ValueSelector) - } - }, [list, onChange]) - - const handleOperationChange = useCallback((index: number, varType: VarType) => { - return (item: { value: string | number }) => { - const newList = produce(list, (draft) => { - draft[index]!.operation = item.value as WriteMode - draft[index]!.value = '' // Clear value when operation changes - if (item.value === WriteMode.set || item.value === WriteMode.increment || item.value === WriteMode.decrement - || item.value === WriteMode.multiply || item.value === WriteMode.divide) { - if (varType === VarType.boolean) - draft[index]!.value = false - draft[index]!.input_type = AssignerNodeInputType.constant - } - else { + const handleAssignedVarChange = useCallback( + (index: number) => { + return (value: ValueSelector | string) => { + const newList = produce(list, (draft) => { + draft[index]!.variable_selector = value as ValueSelector + draft[index]!.operation = WriteMode.overwrite draft[index]!.input_type = AssignerNodeInputType.variable - } - }) - onChange(newList) - } - }, [list, onChange]) + draft[index]!.value = undefined + }) + onChange(newList, value as ValueSelector) + } + }, + [list, onChange], + ) + + const handleOperationChange = useCallback( + (index: number, varType: VarType) => { + return (item: { value: string | number }) => { + const newList = produce(list, (draft) => { + draft[index]!.operation = item.value as WriteMode + draft[index]!.value = '' // Clear value when operation changes + if ( + item.value === WriteMode.set || + item.value === WriteMode.increment || + item.value === WriteMode.decrement || + item.value === WriteMode.multiply || + item.value === WriteMode.divide + ) { + if (varType === VarType.boolean) draft[index]!.value = false + draft[index]!.input_type = AssignerNodeInputType.constant + } else { + draft[index]!.input_type = AssignerNodeInputType.variable + } + }) + onChange(newList) + } + }, + [list, onChange], + ) - const handleToAssignedVarChange = useCallback((index: number) => { - return (value: ValueSelector | string | number | boolean) => { - const newList = produce(list, (draft) => { - draft[index]!.value = value as ValueSelector - }) - onChange(newList, value as ValueSelector) - } - }, [list, onChange]) + const handleToAssignedVarChange = useCallback( + (index: number) => { + return (value: ValueSelector | string | number | boolean) => { + const newList = produce(list, (draft) => { + draft[index]!.value = value as ValueSelector + }) + onChange(newList, value as ValueSelector) + } + }, + [list, onChange], + ) - const handleVarRemove = useCallback((index: number) => { - return () => { - const newList = produce(list, (draft) => { - draft.splice(index, 1) - }) - onChange(newList) - } - }, [list, onChange]) + const handleVarRemove = useCallback( + (index: number) => { + return () => { + const newList = produce(list, (draft) => { + draft.splice(index, 1) + }) + onChange(newList) + } + }, + [list, onChange], + ) - const handleOpen = useCallback((index: number) => { - return () => onOpen(index) - }, [onOpen]) + const handleOpen = useCallback( + (index: number) => { + return () => onOpen(index) + }, + [onOpen], + ) - const handleFilterToAssignedVar = useCallback((index: number) => { - return (payload: Var) => { - const { variable_selector, operation } = list[index]! - if (!variable_selector || !operation || !filterToAssignedVar) - return true + const handleFilterToAssignedVar = useCallback( + (index: number) => { + return (payload: Var) => { + const { variable_selector, operation } = list[index]! + if (!variable_selector || !operation || !filterToAssignedVar) return true - const assignedVarType = getAssignedVarType?.(variable_selector) - const isSameVariable = Array.isArray(variable_selector) && variable_selector.join('.') === `${payload.nodeId}.${payload.variable}` + const assignedVarType = getAssignedVarType?.(variable_selector) + const isSameVariable = + Array.isArray(variable_selector) && + variable_selector.join('.') === `${payload.nodeId}.${payload.variable}` - return !isSameVariable && (!assignedVarType || filterToAssignedVar(payload, assignedVarType, operation)) - } - }, [list, filterToAssignedVar, getAssignedVarType]) + return ( + !isSameVariable && + (!assignedVarType || filterToAssignedVar(payload, assignedVarType, operation)) + ) + } + }, + [list, filterToAssignedVar, getAssignedVarType], + ) if (list.length === 0) { return ( <ListNoDataPlaceholder> - {t($ => $['nodes.assigner.noVarTip'], { ns: 'workflow' })} + {t(($) => $['nodes.assigner.noVarTip'], { ns: 'workflow' })} </ListNoDataPlaceholder> ) } @@ -127,10 +152,13 @@ const VarList: FC<Props> = ({ return ( <div className="flex flex-col items-start gap-4 self-stretch"> {list.map((item, index) => { - const assignedVarType = item.variable_selector ? getAssignedVarType?.(item.variable_selector) : undefined - const toAssignedVarType = (assignedVarType && item.operation && getToAssignedVarType) - ? getToAssignedVarType(assignedVarType, item.operation) + const assignedVarType = item.variable_selector + ? getAssignedVarType?.(item.variable_selector) : undefined + const toAssignedVarType = + assignedVarType && item.operation && getToAssignedVarType + ? getToAssignedVarType(assignedVarType, item.operation) + : undefined return ( <div className="flex items-start gap-1 self-stretch" key={index}> @@ -144,7 +172,11 @@ const VarList: FC<Props> = ({ onChange={handleAssignedVarChange(index)} onOpen={handleOpen(index)} filterVar={filterVar} - placeholder={t($ => $['nodes.assigner.selectAssignedVariable'], { ns: 'workflow' }) as string} + placeholder={ + t(($) => $['nodes.assigner.selectAssignedVariable'], { + ns: 'workflow', + }) as string + } minWidth={352} popupFor="assigned" className="w-full" @@ -160,10 +192,11 @@ const VarList: FC<Props> = ({ writeModeTypesNum={writeModeTypesNum} /> </div> - {item.operation !== WriteMode.clear && item.operation !== WriteMode.set - && item.operation !== WriteMode.removeFirst && item.operation !== WriteMode.removeLast - && !writeModeTypesNum?.includes(item.operation) - && ( + {item.operation !== WriteMode.clear && + item.operation !== WriteMode.set && + item.operation !== WriteMode.removeFirst && + item.operation !== WriteMode.removeLast && + !writeModeTypesNum?.includes(item.operation) && ( <VarReferencePicker readonly={readonly || !item.variable_selector || !item.operation} nodeId={nodeId} @@ -172,7 +205,9 @@ const VarList: FC<Props> = ({ onChange={handleToAssignedVarChange(index)} filterVar={handleFilterToAssignedVar(index)} valueTypePlaceHolder={toAssignedVarType} - placeholder={t($ => $['nodes.assigner.setParameter'], { ns: 'workflow' }) as string} + placeholder={ + t(($) => $['nodes.assigner.setParameter'], { ns: 'workflow' }) as string + } minWidth={352} popupFor="toAssigned" className="w-full" @@ -184,45 +219,47 @@ const VarList: FC<Props> = ({ <Input type="number" value={item.value as number} - onChange={e => handleToAssignedVarChange(index)(Number(e.target.value))} + onChange={(e) => handleToAssignedVarChange(index)(Number(e.target.value))} className="w-full" /> )} {assignedVarType === 'string' && ( <Textarea - aria-label={item.variable_selector?.join('.') || t($ => $['nodes.assigner.setParameter'], { ns: 'workflow' })} + aria-label={ + item.variable_selector?.join('.') || + t(($) => $['nodes.assigner.setParameter'], { ns: 'workflow' }) + } value={item.value as string} - onValueChange={value => handleToAssignedVarChange(index)(value)} + onValueChange={(value) => handleToAssignedVarChange(index)(value)} className="w-full" /> )} {assignedVarType === 'boolean' && ( <BoolValue value={item.value as boolean} - onChange={value => handleToAssignedVarChange(index)(value)} + onChange={(value) => handleToAssignedVarChange(index)(value)} /> )} {assignedVarType === 'object' && ( <CodeEditor value={item.value as string} language={CodeLanguage.json} - onChange={value => handleToAssignedVarChange(index)(value)} + onChange={(value) => handleToAssignedVarChange(index)(value)} className="w-full" readOnly={readonly} /> )} </> )} - {writeModeTypesNum?.includes(item.operation) - && ( - <Input - type="number" - value={item.value as number} - onChange={e => handleToAssignedVarChange(index)(Number(e.target.value))} - placeholder="Enter number value..." - className="w-full" - /> - )} + {writeModeTypesNum?.includes(item.operation) && ( + <Input + type="number" + value={item.value as number} + onChange={(e) => handleToAssignedVarChange(index)(Number(e.target.value))} + placeholder="Enter number value..." + className="w-full" + /> + )} </div> <ActionButton size="l" @@ -233,8 +270,7 @@ const VarList: FC<Props> = ({ </ActionButton> </div> ) - }, - )} + })} </div> ) } diff --git a/web/app/components/workflow/nodes/assigner/default.ts b/web/app/components/workflow/nodes/assigner/default.ts index f20f5ffb41a64b..00a905ac557b5f 100644 --- a/web/app/components/workflow/nodes/assigner/default.ts +++ b/web/app/components/workflow/nodes/assigner/default.ts @@ -22,23 +22,38 @@ const nodeDefault: NodeDefault<AssignerNodeType> = { }, checkValid(payload: AssignerNodeType, t: TFunction<'workflow'>) { let errorMessages = '' - const { - items: operationItems, - } = payload + const { items: operationItems } = payload operationItems?.forEach((value) => { if (!errorMessages && !value.variable_selector?.length) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.assigner.assignedVariable'], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.assigner.assignedVariable'], { ns: 'workflow' }), + }) - if (!errorMessages && value.operation !== WriteMode.clear && value.operation !== WriteMode.removeFirst && value.operation !== WriteMode.removeLast) { - if (value.operation === WriteMode.set || value.operation === WriteMode.increment - || value.operation === WriteMode.decrement || value.operation === WriteMode.multiply - || value.operation === WriteMode.divide) { + if ( + !errorMessages && + value.operation !== WriteMode.clear && + value.operation !== WriteMode.removeFirst && + value.operation !== WriteMode.removeLast + ) { + if ( + value.operation === WriteMode.set || + value.operation === WriteMode.increment || + value.operation === WriteMode.decrement || + value.operation === WriteMode.multiply || + value.operation === WriteMode.divide + ) { if (!value.value && value.value !== false && typeof value.value !== 'number') - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.assigner.variable'], { ns: 'workflow' }) }) - } - else if (!value.value?.length) { - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.assigner.variable'], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.assigner.variable'], { ns: 'workflow' }), + }) + } else if (!value.value?.length) { + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.assigner.variable'], { ns: 'workflow' }), + }) } } }) diff --git a/web/app/components/workflow/nodes/assigner/hooks.ts b/web/app/components/workflow/nodes/assigner/hooks.ts index a3a5db8bfbddc0..e2d5508492fb70 100644 --- a/web/app/components/workflow/nodes/assigner/hooks.ts +++ b/web/app/components/workflow/nodes/assigner/hooks.ts @@ -1,15 +1,8 @@ -import type { - Node, - Var, -} from '../../types' +import type { Node, Var } from '../../types' import { uniqBy } from 'es-toolkit/compat' import { useCallback } from 'react' import { useNodes } from 'reactflow' -import { - useIsChatMode, - useWorkflow, - useWorkflowVariables, -} from '../../hooks' +import { useIsChatMode, useWorkflow, useWorkflowVariables } from '../../hooks' import { AssignerNodeInputType, WriteMode } from './types' export const useGetAvailableVars = () => { @@ -17,40 +10,44 @@ export const useGetAvailableVars = () => { const { getBeforeNodesInSameBranchIncludeParent } = useWorkflow() const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() - const getAvailableVars = useCallback((nodeId: string, handleId: string, filterVar: (v: Var) => boolean, hideEnv = false) => { - const availableNodes: Node[] = [] - const currentNode = nodes.find(node => node.id === nodeId)! + const getAvailableVars = useCallback( + (nodeId: string, handleId: string, filterVar: (v: Var) => boolean, hideEnv = false) => { + const availableNodes: Node[] = [] + const currentNode = nodes.find((node) => node.id === nodeId)! - if (!currentNode) - return [] + if (!currentNode) return [] - const beforeNodes = getBeforeNodesInSameBranchIncludeParent(nodeId) - availableNodes.push(...beforeNodes) - const parentNode = nodes.find(node => node.id === currentNode.parentId) + const beforeNodes = getBeforeNodesInSameBranchIncludeParent(nodeId) + availableNodes.push(...beforeNodes) + const parentNode = nodes.find((node) => node.id === currentNode.parentId) + + if (hideEnv) { + return getNodeAvailableVars({ + parentNode, + beforeNodes: uniqBy(availableNodes, 'id').filter((node) => node.id !== nodeId), + isChatMode, + hideEnv, + hideChatVar: hideEnv, + filterVar, + }) + .map((node) => ({ + ...node, + vars: node.isStartNode + ? node.vars.filter((v) => !v.variable.startsWith('sys.')) + : node.vars, + })) + .filter((item) => item.vars.length > 0) + } - if (hideEnv) { return getNodeAvailableVars({ parentNode, - beforeNodes: uniqBy(availableNodes, 'id').filter(node => node.id !== nodeId), + beforeNodes: uniqBy(availableNodes, 'id').filter((node) => node.id !== nodeId), isChatMode, - hideEnv, - hideChatVar: hideEnv, filterVar, }) - .map(node => ({ - ...node, - vars: node.isStartNode ? node.vars.filter(v => !v.variable.startsWith('sys.')) : node.vars, - })) - .filter(item => item.vars.length > 0) - } - - return getNodeAvailableVars({ - parentNode, - beforeNodes: uniqBy(availableNodes, 'id').filter(node => node.id !== nodeId), - isChatMode, - filterVar, - }) - }, [nodes, getBeforeNodesInSameBranchIncludeParent, getNodeAvailableVars, isChatMode]) + }, + [nodes, getBeforeNodesInSameBranchIncludeParent, getNodeAvailableVars, isChatMode], + ) return getAvailableVars } diff --git a/web/app/components/workflow/nodes/assigner/node.tsx b/web/app/components/workflow/nodes/assigner/node.tsx index 2f1319d19677f1..1fbea81b887ed2 100644 --- a/web/app/components/workflow/nodes/assigner/node.tsx +++ b/web/app/components/workflow/nodes/assigner/node.tsx @@ -7,30 +7,29 @@ import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import Badge from '@/app/components/base/badge' import { isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' -import { - VariableLabelInNode, -} from '@/app/components/workflow/nodes/_base/components/variable/variable-label' +import { VariableLabelInNode } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { BlockEnum } from '@/app/components/workflow/types' const i18nPrefix = 'nodes.assigner' -const NodeComponent: FC<NodeProps<AssignerNodeType>> = ({ - data, -}) => { +const NodeComponent: FC<NodeProps<AssignerNodeType>> = ({ data }) => { const { t } = useTranslation() const nodes: Node[] = useNodes() if (data.version === '2') { const { items: operationItems } = data - const validOperationItems = operationItems?.filter(item => - item.variable_selector && item.variable_selector.length > 0, - ) || [] + const validOperationItems = + operationItems?.filter( + (item) => item.variable_selector && item.variable_selector.length > 0, + ) || [] if (validOperationItems.length === 0) { return ( <div className="relative flex flex-col items-start gap-0.5 self-stretch px-3 py-1"> <div className="flex flex-col items-start gap-1 self-stretch"> <div className="flex items-center gap-1 self-stretch rounded-md bg-workflow-block-parma-bg px-[5px] py-1"> - <div className="flex-1 system-xs-medium text-text-tertiary">{t($ => $[`${i18nPrefix}.varNotSet`], { ns: 'workflow' })}</div> + <div className="flex-1 system-xs-medium text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.varNotSet`], { ns: 'workflow' })} + </div> </div> </div> </div> @@ -40,10 +39,11 @@ const NodeComponent: FC<NodeProps<AssignerNodeType>> = ({ <div className="relative flex flex-col items-start gap-0.5 self-stretch px-3 py-1"> {operationItems.map((value, index) => { const variable = value.variable_selector - if (!variable || variable.length === 0) - return null + if (!variable || variable.length === 0) return null const isSystem = isSystemVar(variable) - const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === variable[0]) + const node = isSystem + ? nodes.find((node) => node.data.type === BlockEnum.Start) + : nodes.find((node) => node.id === variable[0]) return ( <VariableLabelInNode key={index} @@ -51,7 +51,14 @@ const NodeComponent: FC<NodeProps<AssignerNodeType>> = ({ nodeType={node?.data.type} nodeTitle={node?.data.title} rightSlot={ - !!value.operation && <Badge className="ml-auto! shrink-0" text={t($ => $[`${i18nPrefix}.operations.${value.operation}`], { ns: 'workflow' })} /> + !!value.operation && ( + <Badge + className="ml-auto! shrink-0" + text={t(($) => $[`${i18nPrefix}.operations.${value.operation}`], { + ns: 'workflow', + })} + /> + ) } /> ) @@ -60,13 +67,15 @@ const NodeComponent: FC<NodeProps<AssignerNodeType>> = ({ ) } // Legacy version - type LegacyAssignerNodeType = { assigned_variable_selector: string[], write_mode: OperationName } - const { assigned_variable_selector: variable, write_mode: writeMode } = data as unknown as LegacyAssignerNodeType + type LegacyAssignerNodeType = { assigned_variable_selector: string[]; write_mode: OperationName } + const { assigned_variable_selector: variable, write_mode: writeMode } = + data as unknown as LegacyAssignerNodeType - if (!variable || variable.length === 0) - return null + if (!variable || variable.length === 0) return null const isSystem = isSystemVar(variable) - const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === variable[0]) + const node = isSystem + ? nodes.find((node) => node.data.type === BlockEnum.Start) + : nodes.find((node) => node.id === variable[0]) return ( <div className="relative flex flex-col items-start gap-0.5 self-stretch px-3 py-1"> @@ -75,7 +84,12 @@ const NodeComponent: FC<NodeProps<AssignerNodeType>> = ({ nodeType={node?.data.type} nodeTitle={node?.data.title} rightSlot={ - writeMode && <Badge className="ml-auto! shrink-0" text={t($ => $[`nodes.assigner.operations.${writeMode}`], { ns: 'workflow' })} /> + writeMode && ( + <Badge + className="ml-auto! shrink-0" + text={t(($) => $[`nodes.assigner.operations.${writeMode}`], { ns: 'workflow' })} + /> + ) } /> </div> diff --git a/web/app/components/workflow/nodes/assigner/panel.tsx b/web/app/components/workflow/nodes/assigner/panel.tsx index 1050ada3f5d105..9d0feb6d4b2f4b 100644 --- a/web/app/components/workflow/nodes/assigner/panel.tsx +++ b/web/app/components/workflow/nodes/assigner/panel.tsx @@ -1,9 +1,7 @@ import type { FC } from 'react' import type { AssignerNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' -import { - RiAddLine, -} from '@remixicon/react' +import { RiAddLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' @@ -13,10 +11,7 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.assigner' -const Panel: FC<NodePanelProps<AssignerNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<AssignerNodeType>> = ({ id, data }) => { const { t } = useTranslation() const handleAddOperationItem = useHandleAddOperationItem() const { @@ -40,7 +35,9 @@ const Panel: FC<NodePanelProps<AssignerNodeType>> = ({ <div className="flex flex-col items-start self-stretch py-2"> <div className="flex w-full flex-col items-start justify-center gap-1 self-stretch px-4 py-2"> <div className="flex items-start gap-2 self-stretch"> - <div className="flex grow flex-col items-start justify-center system-sm-semibold-uppercase text-text-secondary">{t($ => $[`${i18nPrefix}.variables`], { ns: 'workflow' })}</div> + <div className="flex grow flex-col items-start justify-center system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $[`${i18nPrefix}.variables`], { ns: 'workflow' })} + </div> <ActionButton onClick={handleAddOperation}> <RiAddLine className="size-4 shrink-0 text-text-tertiary" /> </ActionButton> diff --git a/web/app/components/workflow/nodes/assigner/types.ts b/web/app/components/workflow/nodes/assigner/types.ts index 22f37bb7cdb440..58697cd83fc095 100644 --- a/web/app/components/workflow/nodes/assigner/types.ts +++ b/web/app/components/workflow/nodes/assigner/types.ts @@ -31,4 +31,9 @@ export type AssignerNodeType = CommonNodeType & { items: AssignerNodeOperation[] } -export const writeModeTypesNum = [WriteMode.increment, WriteMode.decrement, WriteMode.multiply, WriteMode.divide] +export const writeModeTypesNum = [ + WriteMode.increment, + WriteMode.decrement, + WriteMode.multiply, + WriteMode.divide, +] diff --git a/web/app/components/workflow/nodes/assigner/use-config.helpers.ts b/web/app/components/workflow/nodes/assigner/use-config.helpers.ts index ef8ad5520c51f4..ba4277a15887c6 100644 --- a/web/app/components/workflow/nodes/assigner/use-config.helpers.ts +++ b/web/app/components/workflow/nodes/assigner/use-config.helpers.ts @@ -7,8 +7,7 @@ import { normalizeOperationItems } from './utils' export const filterVarByType = (varType: VarType) => { return (variable: Var) => { - if (varType === VarType.any || variable.type === VarType.any) - return true + if (varType === VarType.any || variable.type === VarType.any) return true return variable.type === varType } @@ -16,12 +15,12 @@ export const filterVarByType = (varType: VarType) => { export const normalizeAssignedVarType = (assignedVarType: VarType, writeMode: WriteMode) => { if ( - writeMode === WriteMode.overwrite - || writeMode === WriteMode.increment - || writeMode === WriteMode.decrement - || writeMode === WriteMode.multiply - || writeMode === WriteMode.divide - || writeMode === WriteMode.extend + writeMode === WriteMode.overwrite || + writeMode === WriteMode.increment || + writeMode === WriteMode.decrement || + writeMode === WriteMode.multiply || + writeMode === WriteMode.divide || + writeMode === WriteMode.extend ) { return assignedVarType } @@ -46,18 +45,14 @@ export const canAssignVar = (_varPayload: Var, selector: ValueSelector) => { return selector.join('.').startsWith('conversation') } -export const canAssignToVar = ( - varPayload: Var, - assignedVarType: VarType, - writeMode: WriteMode, -) => { +export const canAssignToVar = (varPayload: Var, assignedVarType: VarType, writeMode: WriteMode) => { if ( - writeMode === WriteMode.overwrite - || writeMode === WriteMode.extend - || writeMode === WriteMode.increment - || writeMode === WriteMode.decrement - || writeMode === WriteMode.multiply - || writeMode === WriteMode.divide + writeMode === WriteMode.overwrite || + writeMode === WriteMode.extend || + writeMode === WriteMode.increment || + writeMode === WriteMode.decrement || + writeMode === WriteMode.multiply || + writeMode === WriteMode.divide ) { return varPayload.type === assignedVarType } @@ -78,14 +73,12 @@ export const canAssignToVar = ( return true } -export const ensureAssignerVersion = (newInputs: AssignerNodeType) => produce(newInputs, (draft) => { - if (draft.version !== '2') - draft.version = '2' -}) +export const ensureAssignerVersion = (newInputs: AssignerNodeType) => + produce(newInputs, (draft) => { + if (draft.version !== '2') draft.version = '2' + }) -export const updateOperationItems = ( - inputs: AssignerNodeType, - items: AssignerNodeOperation[], -) => produce(inputs, (draft) => { - draft.items = normalizeOperationItems(items) -}) +export const updateOperationItems = (inputs: AssignerNodeType, items: AssignerNodeOperation[]) => + produce(inputs, (draft) => { + draft.items = normalizeOperationItems(items) + }) diff --git a/web/app/components/workflow/nodes/assigner/use-config.ts b/web/app/components/workflow/nodes/assigner/use-config.ts index e15f3194955321..50015be77c1b68 100644 --- a/web/app/components/workflow/nodes/assigner/use-config.ts +++ b/web/app/components/workflow/nodes/assigner/use-config.ts @@ -30,43 +30,58 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => { const store = useStoreApi() const { getBeforeNodesInSameBranchIncludeParent } = useWorkflow() - const { - getNodes, - } = store.getState() - const currentNode = getNodes().find(n => n.id === id) + const { getNodes } = store.getState() + const currentNode = getNodes().find((n) => n.id === id) const isInIteration = payload.isInIteration - const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null + const iterationNode = isInIteration + ? getNodes().find((n) => n.id === currentNode!.parentId) + : null const availableNodes = useMemo(() => { return getBeforeNodesInSameBranchIncludeParent(id) }, [getBeforeNodesInSameBranchIncludeParent, id]) const { inputs, setInputs } = useNodeCrud<AssignerNodeType>(id, payload) - const newSetInputs = useCallback((newInputs: AssignerNodeType) => { - setInputs(ensureAssignerVersion(newInputs)) - }, [setInputs]) + const newSetInputs = useCallback( + (newInputs: AssignerNodeType) => { + setInputs(ensureAssignerVersion(newInputs)) + }, + [setInputs], + ) const { getCurrentVariableType } = useWorkflowVariables() - const getAssignedVarType = useCallback((valueSelector: ValueSelector) => { - return getCurrentVariableType({ - parentNode: isInIteration ? iterationNode : null, - valueSelector: valueSelector || [], - availableNodes, - isChatMode, - isConstant: false, - }) - }, [getCurrentVariableType, isInIteration, iterationNode, availableNodes, isChatMode]) + const getAssignedVarType = useCallback( + (valueSelector: ValueSelector) => { + return getCurrentVariableType({ + parentNode: isInIteration ? iterationNode : null, + valueSelector: valueSelector || [], + availableNodes, + isChatMode, + isConstant: false, + }) + }, + [getCurrentVariableType, isInIteration, iterationNode, availableNodes, isChatMode], + ) - const handleOperationListChanges = useCallback((items: AssignerNodeOperation[]) => { - newSetInputs(updateOperationItems(inputs, items)) - }, [inputs, newSetInputs]) + const handleOperationListChanges = useCallback( + (items: AssignerNodeOperation[]) => { + newSetInputs(updateOperationItems(inputs, items)) + }, + [inputs, newSetInputs], + ) - const writeModeTypesArr = [WriteMode.overwrite, WriteMode.clear, WriteMode.append, WriteMode.extend, WriteMode.removeFirst, WriteMode.removeLast] + const writeModeTypesArr = [ + WriteMode.overwrite, + WriteMode.clear, + WriteMode.append, + WriteMode.extend, + WriteMode.removeFirst, + WriteMode.removeLast, + ] const writeModeTypes = [WriteMode.overwrite, WriteMode.clear, WriteMode.set] const getToAssignedVarType = useCallback(normalizeAssignedVarType, []) const filterAssignedVar = useCallback((varPayload: Var, selector: ValueSelector) => { - if (varPayload.isLoopVariable) - return true + if (varPayload.isLoopVariable) return true return canAssignVar(varPayload, selector) }, []) diff --git a/web/app/components/workflow/nodes/assigner/use-single-run-form-params.ts b/web/app/components/workflow/nodes/assigner/use-single-run-form-params.ts index 002fac719df197..dd7913eb79f26b 100644 --- a/web/app/components/workflow/nodes/assigner/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/assigner/use-single-run-form-params.ts @@ -24,11 +24,17 @@ const useSingleRunFormParams = ({ }: Params) => { const { inputs } = useNodeCrud<AssignerNodeType>(id, payload) - const vars = (inputs.items ?? []).filter((item) => { - return item.operation !== WriteMode.clear && item.operation !== WriteMode.set - && item.operation !== WriteMode.removeFirst && item.operation !== WriteMode.removeLast - && !writeModeTypesNum.includes(item.operation) - }).map(item => item.value as ValueSelector) + const vars = (inputs.items ?? []) + .filter((item) => { + return ( + item.operation !== WriteMode.clear && + item.operation !== WriteMode.set && + item.operation !== WriteMode.removeFirst && + item.operation !== WriteMode.removeLast && + !writeModeTypesNum.includes(item.operation) + ) + }) + .map((item) => item.value as ValueSelector) const forms = useMemo(() => { const varInputs = varSelectorsToVarInputs(vars) diff --git a/web/app/components/workflow/nodes/assigner/utils.ts b/web/app/components/workflow/nodes/assigner/utils.ts index f891504258e912..7ba5fc00c6b5e9 100644 --- a/web/app/components/workflow/nodes/assigner/utils.ts +++ b/web/app/components/workflow/nodes/assigner/utils.ts @@ -4,11 +4,13 @@ import { AssignerNodeInputType, WriteMode } from './types' export type OperationName = I18nKeysByPrefix<'workflow', 'nodes.assigner.operations.'> -export type Item - = | { value: 'divider', name: 'divider' } - | { value: string | number, name: OperationName } +export type Item = + | { value: 'divider'; name: 'divider' } + | { value: string | number; name: OperationName } -export function isOperationItem(item: Item): item is { value: string | number, name: OperationName } { +export function isOperationItem( + item: Item, +): item is { value: string | number; name: OperationName } { return item.value !== 'divider' } @@ -19,7 +21,7 @@ export const getOperationItems = ( writeModeTypesNum?: WriteMode[], ): Item[] => { if (assignedVarType?.startsWith('array') && writeModeTypesArr) { - return writeModeTypesArr.map(type => ({ + return writeModeTypesArr.map((type) => ({ value: type, name: type, })) @@ -27,12 +29,12 @@ export const getOperationItems = ( if (assignedVarType === 'number' && writeModeTypes && writeModeTypesNum) { return [ - ...writeModeTypes.map(type => ({ + ...writeModeTypes.map((type) => ({ value: type, name: type, })), { value: 'divider', name: 'divider' } as Item, - ...writeModeTypesNum.map(type => ({ + ...writeModeTypesNum.map((type) => ({ value: type, name: type, })), @@ -40,7 +42,7 @@ export const getOperationItems = ( } if (writeModeTypes && ['string', 'boolean', 'object'].includes(assignedVarType || '')) { - return writeModeTypes.map(type => ({ + return writeModeTypes.map((type) => ({ value: type, name: type, })) @@ -67,24 +69,25 @@ const normalizeVariableSelector = (value: unknown) => { } export const normalizeOperationItems = (items: unknown): AssignerNodeOperation[] => { - if (!Array.isArray(items)) - return [] + if (!Array.isArray(items)) return [] return items.map((item) => { const operationItem = (item || {}) as Partial<AssignerNodeOperation> - const inputType = operationItem.input_type === AssignerNodeInputType.constant - ? AssignerNodeInputType.constant - : AssignerNodeInputType.variable + const inputType = + operationItem.input_type === AssignerNodeInputType.constant + ? AssignerNodeInputType.constant + : AssignerNodeInputType.variable return { variable_selector: normalizeVariableSelector(operationItem.variable_selector), input_type: inputType, operation: Object.values(WriteMode).includes(operationItem.operation as WriteMode) - ? operationItem.operation as WriteMode + ? (operationItem.operation as WriteMode) : WriteMode.overwrite, - value: inputType === AssignerNodeInputType.variable - ? normalizeVariableSelector(operationItem.value) - : operationItem.value, + value: + inputType === AssignerNodeInputType.variable + ? normalizeVariableSelector(operationItem.value) + : operationItem.value, } }) } @@ -100,11 +103,13 @@ export const convertV1ToV2 = (payload: any): AssignerNodeType => { return { ...payload, version: '2', - items: normalizeOperationItems([{ - variable_selector: payload.assigned_variable_selector || [], - input_type: AssignerNodeInputType.variable, - operation: convertOldWriteMode(payload.write_mode), - value: payload.input_variable_selector || [], - }]), + items: normalizeOperationItems([ + { + variable_selector: payload.assigned_variable_selector || [], + input_type: AssignerNodeInputType.variable, + operation: convertOldWriteMode(payload.write_mode), + value: payload.input_variable_selector || [], + }, + ]), } } diff --git a/web/app/components/workflow/nodes/code/__tests__/code-parser.spec.ts b/web/app/components/workflow/nodes/code/__tests__/code-parser.spec.ts index ea2d7f49ef17a0..72142ff9643e73 100644 --- a/web/app/components/workflow/nodes/code/__tests__/code-parser.spec.ts +++ b/web/app/components/workflow/nodes/code/__tests__/code-parser.spec.ts @@ -42,7 +42,10 @@ describe('extractFunctionParams', () => { }) it('extracts multiple parameters', () => { - const result = extractFunctionParams(SAMPLE_CODES.python3.multipleParams, CodeLanguage.python3) + const result = extractFunctionParams( + SAMPLE_CODES.python3.multipleParams, + CodeLanguage.python3, + ) expect(result).toEqual(['param1', 'param2', 'param3']) }) @@ -60,27 +63,42 @@ describe('extractFunctionParams', () => { // JavaScript のテストケース describe('JavaScript', () => { it('handles no parameters', () => { - const result = extractFunctionParams(SAMPLE_CODES.javascript.noParams, CodeLanguage.javascript) + const result = extractFunctionParams( + SAMPLE_CODES.javascript.noParams, + CodeLanguage.javascript, + ) expect(result).toEqual([]) }) it('extracts single parameter', () => { - const result = extractFunctionParams(SAMPLE_CODES.javascript.singleParam, CodeLanguage.javascript) + const result = extractFunctionParams( + SAMPLE_CODES.javascript.singleParam, + CodeLanguage.javascript, + ) expect(result).toEqual(['param1']) }) it('extracts multiple parameters', () => { - const result = extractFunctionParams(SAMPLE_CODES.javascript.multipleParams, CodeLanguage.javascript) + const result = extractFunctionParams( + SAMPLE_CODES.javascript.multipleParams, + CodeLanguage.javascript, + ) expect(result).toEqual(['param1', 'param2', 'param3']) }) it('handles comments in code', () => { - const result = extractFunctionParams(SAMPLE_CODES.javascript.withComments, CodeLanguage.javascript) + const result = extractFunctionParams( + SAMPLE_CODES.javascript.withComments, + CodeLanguage.javascript, + ) expect(result).toEqual(['param1', 'param2']) }) it('handles whitespace', () => { - const result = extractFunctionParams(SAMPLE_CODES.javascript.withSpaces, CodeLanguage.javascript) + const result = extractFunctionParams( + SAMPLE_CODES.javascript.withSpaces, + CodeLanguage.javascript, + ) expect(result).toEqual(['param1', 'param2']) }) }) @@ -175,7 +193,6 @@ function main(name, age, city) { } }; }`, - }, } @@ -183,7 +200,10 @@ describe('extractReturnType', () => { // Python3 のテスト describe('Python3', () => { it('extracts single return value', () => { - const result = extractReturnType(RETURN_TYPE_SAMPLES.python3.singleReturn, CodeLanguage.python3) + const result = extractReturnType( + RETURN_TYPE_SAMPLES.python3.singleReturn, + CodeLanguage.python3, + ) expect(result).toEqual({ result: { type: VarType.string, @@ -193,7 +213,10 @@ describe('extractReturnType', () => { }) it('extracts multiple return values', () => { - const result = extractReturnType(RETURN_TYPE_SAMPLES.python3.multipleReturns, CodeLanguage.python3) + const result = extractReturnType( + RETURN_TYPE_SAMPLES.python3.multipleReturns, + CodeLanguage.python3, + ) expect(result).toEqual({ result: { type: VarType.string, @@ -212,7 +235,10 @@ describe('extractReturnType', () => { }) it('handles complex return statement', () => { - const result = extractReturnType(RETURN_TYPE_SAMPLES.python3.complexReturn, CodeLanguage.python3) + const result = extractReturnType( + RETURN_TYPE_SAMPLES.python3.complexReturn, + CodeLanguage.python3, + ) expect(result).toEqual({ result: { type: VarType.string, @@ -229,7 +255,10 @@ describe('extractReturnType', () => { }) }) it('handles nested object structure', () => { - const result = extractReturnType(RETURN_TYPE_SAMPLES.python3.nestedObject, CodeLanguage.python3) + const result = extractReturnType( + RETURN_TYPE_SAMPLES.python3.nestedObject, + CodeLanguage.python3, + ) expect(result).toEqual({ personal_info: { type: VarType.string, @@ -250,7 +279,10 @@ describe('extractReturnType', () => { // JavaScript のテスト describe('JavaScript', () => { it('extracts single return value', () => { - const result = extractReturnType(RETURN_TYPE_SAMPLES.javascript.singleReturn, CodeLanguage.javascript) + const result = extractReturnType( + RETURN_TYPE_SAMPLES.javascript.singleReturn, + CodeLanguage.javascript, + ) expect(result).toEqual({ result: { type: VarType.string, @@ -260,7 +292,10 @@ describe('extractReturnType', () => { }) it('extracts multiple return values', () => { - const result = extractReturnType(RETURN_TYPE_SAMPLES.javascript.multipleReturns, CodeLanguage.javascript) + const result = extractReturnType( + RETURN_TYPE_SAMPLES.javascript.multipleReturns, + CodeLanguage.javascript, + ) expect(result).toEqual({ result: { type: VarType.string, @@ -274,7 +309,10 @@ describe('extractReturnType', () => { }) it('handles return with parentheses', () => { - const result = extractReturnType(RETURN_TYPE_SAMPLES.javascript.withParentheses, CodeLanguage.javascript) + const result = extractReturnType( + RETURN_TYPE_SAMPLES.javascript.withParentheses, + CodeLanguage.javascript, + ) expect(result).toEqual({ result: { type: VarType.string, @@ -288,12 +326,18 @@ describe('extractReturnType', () => { }) it('returns empty object when no return statement', () => { - const result = extractReturnType(RETURN_TYPE_SAMPLES.javascript.noReturn, CodeLanguage.javascript) + const result = extractReturnType( + RETURN_TYPE_SAMPLES.javascript.noReturn, + CodeLanguage.javascript, + ) expect(result).toEqual({}) }) it('handles quoted keys', () => { - const result = extractReturnType(RETURN_TYPE_SAMPLES.javascript.withQuotes, CodeLanguage.javascript) + const result = extractReturnType( + RETURN_TYPE_SAMPLES.javascript.withQuotes, + CodeLanguage.javascript, + ) expect(result).toEqual({ result: { type: VarType.string, @@ -306,7 +350,10 @@ describe('extractReturnType', () => { }) }) it('handles nested object structure', () => { - const result = extractReturnType(RETURN_TYPE_SAMPLES.javascript.nestedObject, CodeLanguage.javascript) + const result = extractReturnType( + RETURN_TYPE_SAMPLES.javascript.nestedObject, + CodeLanguage.javascript, + ) expect(result).toEqual({ personal_info: { type: VarType.string, diff --git a/web/app/components/workflow/nodes/code/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/code/__tests__/node.spec.tsx index a8648324ed9ccc..3d39c56d0755d9 100644 --- a/web/app/components/workflow/nodes/code/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/code/__tests__/node.spec.tsx @@ -17,12 +17,7 @@ const createData = (overrides: Partial<CodeNodeType> = {}): CodeNodeType => ({ describe('code/node', () => { it('renders an empty summary container', () => { - const { container } = render( - <Node - id="code-node" - data={createData()} - />, - ) + const { container } = render(<Node id="code-node" data={createData()} />) expect(container.firstChild).toBeEmptyDOMElement() }) diff --git a/web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx index ed3342ab57e3c9..3ffaa7f4d9e291 100644 --- a/web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx @@ -55,10 +55,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', ( vi.mock('@/app/components/workflow/nodes/_base/components/selector', () => ({ __esModule: true, - default: (props: { - value: CodeLanguage - onChange: (value: CodeLanguage) => void - }) => ( + default: (props: { value: CodeLanguage; onChange: (value: CodeLanguage) => void }) => ( <button type="button" onClick={() => props.onChange(CodeLanguage.python3)}> {`language:${props.value}`} </button> @@ -78,10 +75,14 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () <div>{props.readonly ? 'var-list:readonly' : 'var-list:editable'}</div> <button type="button" - onClick={() => props.onChange([{ - variable: 'changed', - value_selector: ['start', 'changed'], - }])} + onClick={() => + props.onChange([ + { + variable: 'changed', + value_selector: ['start', 'changed'], + }, + ]) + } > change-var-list </button> @@ -104,12 +105,14 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/output-var-li <div>{props.readonly ? 'output-list:readonly' : 'output-list:editable'}</div> <button type="button" - onClick={() => props.onChange({ - next_result: { - type: VarType.number, - children: null, - }, - })} + onClick={() => + props.onChange({ + next_result: { + type: VarType.number, + children: null, + }, + }) + } > change-output-list </button> @@ -123,24 +126,18 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/output-var-li vi.mock('../../_base/components/remove-effect-var-confirm', () => ({ __esModule: true, - default: (props: { - isShow: boolean - onCancel: () => void - onConfirm: () => void - }) => { + default: (props: { isShow: boolean; onCancel: () => void; onConfirm: () => void }) => { mockRemoveEffectVarConfirm(props) - return props.isShow - ? ( - <div> - <button type="button" onClick={props.onCancel}> - cancel-remove - </button> - <button type="button" onClick={props.onConfirm}> - confirm-remove - </button> - </div> - ) - : null + return props.isShow ? ( + <div> + <button type="button" onClick={props.onCancel}> + cancel-remove + </button> + <button type="button" onClick={props.onConfirm}> + confirm-remove + </button> + </div> + ) : null }, })) @@ -150,11 +147,13 @@ const createData = (overrides: Partial<CodeNodeType> = {}): CodeNodeType => ({ type: BlockEnum.Code, code_language: CodeLanguage.javascript, code: 'function main({ foo }) { return { result: foo } }', - variables: [{ - variable: 'foo', - value_selector: ['start', 'foo'], - value_type: VarType.string, - }], + variables: [ + { + variable: 'foo', + value_selector: ['start', 'foo'], + value_type: VarType.string, + }, + ], outputs: { result: { type: VarType.string, @@ -164,7 +163,9 @@ const createData = (overrides: Partial<CodeNodeType> = {}): CodeNodeType => ({ ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), outputKeyOrders: ['result'], @@ -226,15 +227,21 @@ describe('code/panel', () => { expect(screen.getByText('editor:editable')).toBeInTheDocument() expect(screen.getByText('language:javascript')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.code.inputVars' })) - await user.click(screen.getByRole('button', { name: 'workflow.nodes.code.syncFunctionSignature' })) + await user.click( + screen.getByRole('button', { name: 'common.operation.add workflow.nodes.code.inputVars' }), + ) + await user.click( + screen.getByRole('button', { name: 'workflow.nodes.code.syncFunctionSignature' }), + ) await user.click(screen.getByRole('button', { name: 'change-code' })) await user.click(screen.getByRole('button', { name: 'generate-code' })) await user.click(screen.getByRole('button', { name: 'language:javascript' })) await user.click(screen.getByRole('button', { name: 'change-var-list' })) await user.click(screen.getByRole('button', { name: 'change-output-list' })) await user.click(screen.getByRole('button', { name: 'remove-output' })) - await user.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.code.outputVars' })) + await user.click( + screen.getByRole('button', { name: 'common.operation.add workflow.nodes.code.outputVars' }), + ) await user.click(screen.getByRole('button', { name: 'cancel-remove' })) await user.click(screen.getByRole('button', { name: 'confirm-remove' })) @@ -242,10 +249,12 @@ describe('code/panel', () => { expect(config.handleSyncFunctionSignature).toHaveBeenCalled() expect(config.handleCodeChange).toHaveBeenCalledWith('generated code body') expect(config.handleCodeLanguageChange).toHaveBeenCalledWith(CodeLanguage.python3) - expect(config.handleVarListChange).toHaveBeenCalledWith([{ - variable: 'changed', - value_selector: ['start', 'changed'], - }]) + expect(config.handleVarListChange).toHaveBeenCalledWith([ + { + variable: 'changed', + value_selector: ['start', 'changed'], + }, + ]) expect(config.handleVarsChange).toHaveBeenCalledWith({ next_result: { type: VarType.number, @@ -258,13 +267,16 @@ describe('code/panel', () => { expect(config.onRemoveVarConfirm).toHaveBeenCalled() expect(config.handleCodeAndVarsChange).toHaveBeenCalledWith( 'generated signature code', - [{ - variable: 'summary', - value_selector: [], - }, { - variable: 'count', - value_selector: [], - }], + [ + { + variable: 'summary', + value_selector: [], + }, + { + variable: 'count', + value_selector: [], + }, + ], { result: { type: VarType.string, @@ -272,21 +284,35 @@ describe('code/panel', () => { }, }, ) - expect(mockExtractFunctionParams).toHaveBeenCalledWith('generated signature code', CodeLanguage.javascript) - expect(mockExtractReturnType).toHaveBeenCalledWith('generated signature code', CodeLanguage.javascript) + expect(mockExtractFunctionParams).toHaveBeenCalledWith( + 'generated signature code', + CodeLanguage.javascript, + ) + expect(mockExtractReturnType).toHaveBeenCalledWith( + 'generated signature code', + CodeLanguage.javascript, + ) }) it('removes input actions in readonly mode and passes readonly state to child sections', () => { - mockUseConfig.mockReturnValue(createConfigResult({ - readOnly: true, - isShowRemoveVarConfirm: false, - })) + mockUseConfig.mockReturnValue( + createConfigResult({ + readOnly: true, + isShowRemoveVarConfirm: false, + }), + ) renderPanel() - expect(screen.queryByRole('button', { name: 'workflow.nodes.code.syncFunctionSignature' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.operation.add workflow.nodes.code.inputVars' })).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.code.outputVars' })).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.nodes.code.syncFunctionSignature' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.add workflow.nodes.code.inputVars' }), + ).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.operation.add workflow.nodes.code.outputVars' }), + ).toBeInTheDocument() expect(screen.getByText('editor:readonly')).toBeInTheDocument() expect(screen.getByText('var-list:readonly')).toBeInTheDocument() expect(screen.getByText('output-list:readonly')).toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/code/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/code/__tests__/use-config.spec.ts index b02ff8a4fc333c..b77f7ad26d7d92 100644 --- a/web/app/components/workflow/nodes/code/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/code/__tests__/use-config.spec.ts @@ -139,7 +139,7 @@ describe('code/use-config', () => { hideRemoveVarConfirm: mockHideRemoveVarConfirm, onRemoveVarConfirm: mockOnRemoveVarConfirm, } as ReturnType<typeof useOutputVarList>) - mockUseStore.mockImplementation(selector => selector(workflowStoreState as never)) + mockUseStore.mockImplementation((selector) => selector(workflowStoreState as never)) mockFetchNodeDefault.mockResolvedValue({ config: javaScriptConfig } as never) mockFetchPipelineNodeDefault.mockResolvedValue({ config: javaScriptConfig } as never) mockFetchNodeDefault @@ -160,10 +160,12 @@ describe('code/use-config', () => { const { result } = renderHook(() => useConfig('code-node', currentInputs)) await waitFor(() => { - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - code: workflowStoreState.nodesDefaultConfigs?.[BlockEnum.Code]?.code, - outputs: workflowStoreState.nodesDefaultConfigs?.[BlockEnum.Code]?.outputs, - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + code: workflowStoreState.nodesDefaultConfigs?.[BlockEnum.Code]?.code, + outputs: workflowStoreState.nodesDefaultConfigs?.[BlockEnum.Code]?.outputs, + }), + ) }) expect(result.current.handleVarListChange).toBe(mockHandleVarListChange) @@ -185,10 +187,18 @@ describe('code/use-config', () => { const { result } = renderHook(() => useConfig('code-node', currentInputs)) await waitFor(() => { - expect(mockFetchNodeDefault).toHaveBeenCalledWith('app-1', BlockEnum.Code, { code_language: CodeLanguage.javascript }) - expect(mockFetchNodeDefault).toHaveBeenCalledWith('app-1', BlockEnum.Code, { code_language: CodeLanguage.python3 }) - expect(mockFetchPipelineNodeDefault).toHaveBeenCalledWith('pipeline-1', BlockEnum.Code, { code_language: CodeLanguage.javascript }) - expect(mockFetchPipelineNodeDefault).toHaveBeenCalledWith('pipeline-1', BlockEnum.Code, { code_language: CodeLanguage.python3 }) + expect(mockFetchNodeDefault).toHaveBeenCalledWith('app-1', BlockEnum.Code, { + code_language: CodeLanguage.javascript, + }) + expect(mockFetchNodeDefault).toHaveBeenCalledWith('app-1', BlockEnum.Code, { + code_language: CodeLanguage.python3, + }) + expect(mockFetchPipelineNodeDefault).toHaveBeenCalledWith('pipeline-1', BlockEnum.Code, { + code_language: CodeLanguage.javascript, + }) + expect(mockFetchPipelineNodeDefault).toHaveBeenCalledWith('pipeline-1', BlockEnum.Code, { + code_language: CodeLanguage.python3, + }) }) mockSetInputs.mockClear() @@ -203,20 +213,26 @@ describe('code/use-config', () => { ) }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - code_language: CodeLanguage.python3, - code: pythonConfig.code, - variables: pythonConfig.variables, - outputs: pythonConfig.outputs, - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - code: 'function main({ bar }) { return { result: bar } }', - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - code: 'function main({ amount }) { return { total: amount } }', - variables: [expect.objectContaining({ variable: 'amount' })], - outputs: createOutputs('total', VarType.number), - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + code_language: CodeLanguage.python3, + code: pythonConfig.code, + variables: pythonConfig.variables, + outputs: pythonConfig.outputs, + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'function main({ bar }) { return { result: bar } }', + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'function main({ amount }) { return { total: amount } }', + variables: [expect.objectContaining({ variable: 'amount' })], + outputs: createOutputs('total', VarType.number), + }), + ) expect(result.current.outputKeyOrders).toEqual(['total']) }) @@ -233,9 +249,11 @@ describe('code/use-config', () => { result.current.handleSyncFunctionSignature() }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - code: 'function main({foo, bar}) { return { result: "" } }', - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'function main({foo, bar}) { return { result: "" } }', + }), + ) mockSetInputs.mockClear() currentInputs = createData({ @@ -257,9 +275,11 @@ describe('code/use-config', () => { result.current.handleSyncFunctionSignature() }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - code: 'def main(text: str, score: float, payload: dict, items: list, numbers: list[float], names: list[str], records: list[dict]):\n return {"result": ""}', - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'def main(text: str, score: float, payload: dict, items: list, numbers: list[float], names: list[str], records: list[dict]):\n return {"result": ""}', + }), + ) mockSetInputs.mockClear() currentInputs = createData({ @@ -272,9 +292,11 @@ describe('code/use-config', () => { result.current.handleSyncFunctionSignature() }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - code: '{"result": true}', - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + code: '{"result": true}', + }), + ) }) it('keeps language changes local when no fetched default exists and preserves existing output order', async () => { @@ -305,11 +327,13 @@ describe('code/use-config', () => { result.current.handleCodeLanguageChange(CodeLanguage.python3) }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - code_language: CodeLanguage.python3, - code: currentInputs.code, - variables: currentInputs.variables, - outputs: currentInputs.outputs, - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + code_language: CodeLanguage.python3, + code: currentInputs.code, + variables: currentInputs.variables, + outputs: currentInputs.outputs, + }), + ) }) }) diff --git a/web/app/components/workflow/nodes/code/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/code/__tests__/use-single-run-form-params.spec.ts index 39e9d8139a654a..cccf4dcc063046 100644 --- a/web/app/components/workflow/nodes/code/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/code/__tests__/use-single-run-form-params.spec.ts @@ -18,11 +18,13 @@ const createData = (overrides: Partial<CodeNodeType> = {}): CodeNodeType => ({ type: BlockEnum.Code, code_language: CodeLanguage.javascript, code: 'function main({ amount }) { return { result: amount } }', - variables: [{ - variable: 'amount', - value_selector: ['start', 'amount'], - value_type: VarType.number, - }], + variables: [ + { + variable: 'amount', + value_selector: ['start', 'amount'], + value_type: VarType.number, + }, + ], outputs: { result: { type: VarType.number, @@ -44,31 +46,38 @@ describe('code/use-single-run-form-params', () => { it('builds a single form, updates run input values, and exposes dependent vars', () => { const setRunInputData = vi.fn() - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'code-node', - payload: createData(), - runInputData: { amount: 1 }, - runInputDataRef: { current: { amount: 1 } }, - getInputVars: () => [], - setRunInputData, - toVarInputs: variables => variables.map(variable => ({ - type: InputVarType.number, - label: variable.variable, - variable: variable.variable, - required: false, - })), - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'code-node', + payload: createData(), + runInputData: { amount: 1 }, + runInputDataRef: { current: { amount: 1 } }, + getInputVars: () => [], + setRunInputData, + toVarInputs: (variables) => + variables.map((variable) => ({ + type: InputVarType.number, + label: variable.variable, + variable: variable.variable, + required: false, + })), + }), + ) - expect(result.current.forms).toEqual([{ - inputs: [{ - type: InputVarType.number, - label: 'amount', - variable: 'amount', - required: false, - }], - values: { amount: 1 }, - onChange: expect.any(Function), - }]) + expect(result.current.forms).toEqual([ + { + inputs: [ + { + type: InputVarType.number, + label: 'amount', + variable: 'amount', + required: false, + }, + ], + values: { amount: 1 }, + onChange: expect.any(Function), + }, + ]) result.current.forms[0]?.onChange({ amount: 3 }) diff --git a/web/app/components/workflow/nodes/code/code-parser.ts b/web/app/components/workflow/nodes/code/code-parser.ts index 6e3fa876b7c659..4bfc308af1599d 100644 --- a/web/app/components/workflow/nodes/code/code-parser.ts +++ b/web/app/components/workflow/nodes/code/code-parser.ts @@ -3,8 +3,7 @@ import { VarType } from '../../types' import { CodeLanguage } from './types' export const extractFunctionParams = (code: string, language: CodeLanguage) => { - if (language === CodeLanguage.json) - return [] + if (language === CodeLanguage.json) return [] const patterns: Record<Exclude<CodeLanguage, CodeLanguage.json>, RegExp> = { [CodeLanguage.python3]: /def\s+main\s*\((.*?)\)/, @@ -14,10 +13,12 @@ export const extractFunctionParams = (code: string, language: CodeLanguage) => { const params: string[] = [] if (match?.[1]) { - params.push(...match[1].split(',') - .map(p => p.trim()) - .filter(Boolean) - .map(p => p.split(':')[0]!.trim()), + params.push( + ...match[1] + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => p.split(':')[0]!.trim()), ) } @@ -28,8 +29,7 @@ export const extractReturnType = (code: string, language: CodeLanguage): OutputV // console.log(codeWithoutComments) const returnIndex = codeWithoutComments.indexOf('return') - if (returnIndex === -1) - return {} + if (returnIndex === -1) return {} // Extract the substring starting with 'return'. const codeAfterReturn = codeWithoutComments.slice(returnIndex) @@ -39,18 +39,15 @@ export const extractReturnType = (code: string, language: CodeLanguage): OutputV if (language === CodeLanguage.javascript && startIndex === -1) { const parenStart = codeAfterReturn.indexOf('(') - if (parenStart !== -1) - startIndex = codeAfterReturn.indexOf('{', parenStart) + if (parenStart !== -1) startIndex = codeAfterReturn.indexOf('{', parenStart) } - if (startIndex === -1) - return {} + if (startIndex === -1) return {} let endIndex = -1 for (let i = startIndex; i < codeAfterReturn.length; i++) { - if (codeAfterReturn[i] === '{') - bracketCount++ + if (codeAfterReturn[i] === '{') bracketCount++ if (codeAfterReturn[i] === '}') { bracketCount-- if (bracketCount === 0) { @@ -60,8 +57,7 @@ export const extractReturnType = (code: string, language: CodeLanguage): OutputV } } - if (endIndex === -1) - return {} + if (endIndex === -1) return {} const returnContent = codeAfterReturn.slice(startIndex + 1, endIndex - 1) // console.log(returnContent) diff --git a/web/app/components/workflow/nodes/code/default.ts b/web/app/components/workflow/nodes/code/default.ts index 80948792ce081e..6a7688260566f0 100644 --- a/web/app/components/workflow/nodes/code/default.ts +++ b/web/app/components/workflow/nodes/code/default.ts @@ -24,19 +24,27 @@ const nodeDefault: NodeDefault<CodeNodeType> = { checkValid(payload: CodeNodeType, t: TFunction<'workflow'>) { let errorMessages = '' const { code, variables } = payload - if (!errorMessages && variables.filter(v => !v.variable).length > 0) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variable`], { ns: 'workflow' }) }) - if (!errorMessages && variables.filter(v => !v.value_selector.length).length > 0) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }) }) + if (!errorMessages && variables.filter((v) => !v.variable).length > 0) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variable`], { ns: 'workflow' }), + }) + if (!errorMessages && variables.filter((v) => !v.value_selector.length).length > 0) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }), + }) if (!errorMessages && !code) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.code`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.code`], { ns: 'workflow' }), + }) return { isValid: !errorMessages, errorMessage: errorMessages, } }, - } export default nodeDefault diff --git a/web/app/components/workflow/nodes/code/panel.tsx b/web/app/components/workflow/nodes/code/panel.tsx index 8782f5f355a60e..dd8714cb65dcbe 100644 --- a/web/app/components/workflow/nodes/code/panel.tsx +++ b/web/app/components/workflow/nodes/code/panel.tsx @@ -27,10 +27,7 @@ const codeLanguages = [ value: CodeLanguage.javascript, }, ] -const Panel: FC<NodePanelProps<CodeNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<CodeNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { @@ -68,32 +65,37 @@ const Panel: FC<NodePanelProps<CodeNodeType>> = ({ <div className="mt-2"> <div className="space-y-4 px-4 pb-4"> <Field - title={t($ => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })} operations={ - !readOnly - ? ( - <div className="flex gap-2"> - <Tooltip> - <TooltipTrigger - className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={handleSyncFunctionSignature} - aria-label={t($ => $[`${i18nPrefix}.syncFunctionSignature`], { ns: 'workflow' })} - > - <span className="i-ri-refresh-line size-4 text-text-tertiary" aria-hidden="true" /> - </TooltipTrigger> - <TooltipContent>{t($ => $[`${i18nPrefix}.syncFunctionSignature`], { ns: 'workflow' })}</TooltipContent> - </Tooltip> - <button - type="button" - aria-label={`${t($ => $['operation.add'], { ns: 'common' })} ${t($ => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })}`} - className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={handleAddVariable} - > - <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> - </button> - </div> - ) - : undefined + !readOnly ? ( + <div className="flex gap-2"> + <Tooltip> + <TooltipTrigger + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleSyncFunctionSignature} + aria-label={t(($) => $[`${i18nPrefix}.syncFunctionSignature`], { + ns: 'workflow', + })} + > + <span + className="i-ri-refresh-line size-4 text-text-tertiary" + aria-hidden="true" + /> + </TooltipTrigger> + <TooltipContent> + {t(($) => $[`${i18nPrefix}.syncFunctionSignature`], { ns: 'workflow' })} + </TooltipContent> + </Tooltip> + <button + type="button" + aria-label={`${t(($) => $['operation.add'], { ns: 'common' })} ${t(($) => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleAddVariable} + > + <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> + </button> + </div> + ) : undefined } > <VarList @@ -110,13 +112,13 @@ const Panel: FC<NodePanelProps<CodeNodeType>> = ({ nodeId={id} isInNode readOnly={readOnly} - title={( + title={ <TypeSelector options={codeLanguages} value={inputs.code_language} onChange={handleCodeLanguageChange} /> - )} + } language={inputs.code_language} value={inputs.code} onChange={handleCodeChange} @@ -127,17 +129,17 @@ const Panel: FC<NodePanelProps<CodeNodeType>> = ({ <Split /> <div className="px-4 pt-4 pb-2"> <Field - title={t($ => $[`${i18nPrefix}.outputVars`], { ns: 'workflow' })} - operations={( + title={t(($) => $[`${i18nPrefix}.outputVars`], { ns: 'workflow' })} + operations={ <button type="button" - aria-label={`${t($ => $['operation.add'], { ns: 'common' })} ${t($ => $[`${i18nPrefix}.outputVars`], { ns: 'workflow' })}`} + aria-label={`${t(($) => $['operation.add'], { ns: 'common' })} ${t(($) => $[`${i18nPrefix}.outputVars`], { ns: 'workflow' })}`} className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={handleAddOutputVariable} > <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> </button> - )} + } required > <OutputVarList diff --git a/web/app/components/workflow/nodes/code/types.ts b/web/app/components/workflow/nodes/code/types.ts index c8f1496d210103..ee79821ee6b1c2 100644 --- a/web/app/components/workflow/nodes/code/types.ts +++ b/web/app/components/workflow/nodes/code/types.ts @@ -6,10 +6,13 @@ export enum CodeLanguage { json = 'json', } -export type OutputVar = Record<string, { - type: VarType - children: null // support nest in the future, -}> +export type OutputVar = Record< + string, + { + type: VarType + children: null // support nest in the future, + } +> export type CodeNodeType = CommonNodeType & { variables: Variable[] diff --git a/web/app/components/workflow/nodes/code/use-config.ts b/web/app/components/workflow/nodes/code/use-config.ts index fdb1c8ce519ea5..c413696f32018c 100644 --- a/web/app/components/workflow/nodes/code/use-config.ts +++ b/web/app/components/workflow/nodes/code/use-config.ts @@ -2,14 +2,9 @@ import type { Var, Variable } from '../../types' import type { CodeNodeType, OutputVar } from './types' import { produce } from 'immer' import { useCallback, useEffect, useState } from 'react' -import { - useNodesReadOnly, -} from '@/app/components/workflow/hooks' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' -import { - fetchNodeDefault, - fetchPipelineNodeDefault, -} from '@/service/workflow' +import { fetchNodeDefault, fetchPipelineNodeDefault } from '@/service/workflow' import { useStore } from '../../store' import { BlockEnum, VarType } from '../../types' import useOutputVarList from '../_base/hooks/use-output-var-list' @@ -19,15 +14,22 @@ import { CodeLanguage } from './types' const useConfig = (id: string, payload: CodeNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() - const appId = useStore(s => s.appId) - const pipelineId = useStore(s => s.pipelineId) + const appId = useStore((s) => s.appId) + const pipelineId = useStore((s) => s.pipelineId) - const [allLanguageDefault, setAllLanguageDefault] = useState<Record<CodeLanguage, CodeNodeType> | null>(null) + const [allLanguageDefault, setAllLanguageDefault] = useState<Record< + CodeLanguage, + CodeNodeType + > | null>(null) useEffect(() => { if (appId) { - (async () => { - const { config: javaScriptConfig } = await fetchNodeDefault(appId, BlockEnum.Code, { code_language: CodeLanguage.javascript }) as any - const { config: pythonConfig } = await fetchNodeDefault(appId, BlockEnum.Code, { code_language: CodeLanguage.python3 }) as any + ;(async () => { + const { config: javaScriptConfig } = (await fetchNodeDefault(appId, BlockEnum.Code, { + code_language: CodeLanguage.javascript, + })) as any + const { config: pythonConfig } = (await fetchNodeDefault(appId, BlockEnum.Code, { + code_language: CodeLanguage.python3, + })) as any setAllLanguageDefault({ [CodeLanguage.javascript]: javaScriptConfig as CodeNodeType, [CodeLanguage.python3]: pythonConfig as CodeNodeType, @@ -38,9 +40,17 @@ const useConfig = (id: string, payload: CodeNodeType) => { useEffect(() => { if (pipelineId) { - (async () => { - const { config: javaScriptConfig } = await fetchPipelineNodeDefault(pipelineId, BlockEnum.Code, { code_language: CodeLanguage.javascript }) as any - const { config: pythonConfig } = await fetchPipelineNodeDefault(pipelineId, BlockEnum.Code, { code_language: CodeLanguage.python3 }) as any + ;(async () => { + const { config: javaScriptConfig } = (await fetchPipelineNodeDefault( + pipelineId, + BlockEnum.Code, + { code_language: CodeLanguage.javascript }, + )) as any + const { config: pythonConfig } = (await fetchPipelineNodeDefault( + pipelineId, + BlockEnum.Code, + { code_language: CodeLanguage.python3 }, + )) as any setAllLanguageDefault({ [CodeLanguage.javascript]: javaScriptConfig as CodeNodeType, [CodeLanguage.python3]: pythonConfig as CodeNodeType, @@ -49,7 +59,7 @@ const useConfig = (id: string, payload: CodeNodeType) => { } }, [pipelineId]) - const defaultConfig = useStore(s => s.nodesDefaultConfigs)?.[payload.type] + const defaultConfig = useStore((s) => s.nodesDefaultConfigs)?.[payload.type] const { inputs, setInputs } = useNodeCrud<CodeNodeType>(id, payload) const { handleVarListChange, handleAddVariable } = useVarList<CodeNodeType>({ inputs, @@ -78,26 +88,31 @@ const useConfig = (id: string, payload: CodeNodeType) => { } }, [defaultConfig]) - const handleCodeChange = useCallback((code: string) => { - const newInputs = produce(inputs, (draft) => { - draft.code = code - }) - setInputs(newInputs) - }, [inputs, setInputs]) - - const handleCodeLanguageChange = useCallback((codeLanguage: CodeLanguage) => { - const currDefaultConfig = allLanguageDefault?.[codeLanguage] - - const newInputs = produce(inputs, (draft) => { - draft.code_language = codeLanguage - if (!currDefaultConfig) - return - draft.code = currDefaultConfig.code - draft.variables = currDefaultConfig.variables - draft.outputs = currDefaultConfig.outputs - }) - setInputs(newInputs) - }, [allLanguageDefault, inputs, setInputs]) + const handleCodeChange = useCallback( + (code: string) => { + const newInputs = produce(inputs, (draft) => { + draft.code = code + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) + + const handleCodeLanguageChange = useCallback( + (codeLanguage: CodeLanguage) => { + const currDefaultConfig = allLanguageDefault?.[codeLanguage] + + const newInputs = produce(inputs, (draft) => { + draft.code_language = codeLanguage + if (!currDefaultConfig) return + draft.code = currDefaultConfig.code + draft.variables = currDefaultConfig.variables + draft.outputs = currDefaultConfig.outputs + }) + setInputs(newInputs) + }, + [allLanguageDefault, inputs, setInputs], + ) const handleSyncFunctionSignature = useCallback(() => { const generateSyncSignatureCode = (code: string) => { @@ -106,12 +121,10 @@ const useConfig = (id: string, payload: CodeNodeType) => { if (inputs.code_language === CodeLanguage.javascript) { mainDefRe = /function\s+main\b\s*\([\s\S]*?\)/g newMainDef = 'function main({{var_list}})' - let param_list = inputs.variables?.map(item => item.variable).join(', ') || '' + let param_list = inputs.variables?.map((item) => item.variable).join(', ') || '' param_list = param_list ? `{${param_list}}` : '' newMainDef = newMainDef.replace('{{var_list}}', param_list) - } - - else if (inputs.code_language === CodeLanguage.python3) { + } else if (inputs.code_language === CodeLanguage.python3) { mainDefRe = /def\s+main\b\s*\([\s\S]*?\)/g const param_list = [] for (const item of inputs.variables) { @@ -145,8 +158,9 @@ const useConfig = (id: string, payload: CodeNodeType) => { } newMainDef = `def main(${param_list.join(', ')})` + } else { + return code } - else { return code } const newCode = code.replace(mainDefRe, newMainDef) return newCode @@ -174,18 +188,34 @@ const useConfig = (id: string, payload: CodeNodeType) => { }) const filterVar = useCallback((varPayload: Var) => { - return [VarType.string, VarType.number, VarType.boolean, VarType.secret, VarType.object, VarType.array, VarType.arrayNumber, VarType.arrayString, VarType.arrayObject, VarType.arrayBoolean, VarType.file, VarType.arrayFile].includes(varPayload.type) + return [ + VarType.string, + VarType.number, + VarType.boolean, + VarType.secret, + VarType.object, + VarType.array, + VarType.arrayNumber, + VarType.arrayString, + VarType.arrayObject, + VarType.arrayBoolean, + VarType.file, + VarType.arrayFile, + ].includes(varPayload.type) }, []) - const handleCodeAndVarsChange = useCallback((code: string, inputVariables: Variable[], outputVariables: OutputVar) => { - const newInputs = produce(inputs, (draft) => { - draft.code = code - draft.variables = inputVariables - draft.outputs = outputVariables - }) - setInputs(newInputs) - syncOutputKeyOrders(outputVariables) - }, [inputs, setInputs, syncOutputKeyOrders]) + const handleCodeAndVarsChange = useCallback( + (code: string, inputVariables: Variable[], outputVariables: OutputVar) => { + const newInputs = produce(inputs, (draft) => { + draft.code = code + draft.variables = inputVariables + draft.outputs = outputVariables + }) + setInputs(newInputs) + syncOutputKeyOrders(outputVariables) + }, + [inputs, setInputs, syncOutputKeyOrders], + ) return { readOnly, inputs, diff --git a/web/app/components/workflow/nodes/code/use-single-run-form-params.ts b/web/app/components/workflow/nodes/code/use-single-run-form-params.ts index 18f31f19c46b38..70d30ecc319939 100644 --- a/web/app/components/workflow/nodes/code/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/code/use-single-run-form-params.ts @@ -23,15 +23,17 @@ const useSingleRunFormParams = ({ const { inputs } = useNodeCrud<CodeNodeType>(id, payload) const varInputs = toVarInputs(inputs.variables) - const setInputVarValues = useCallback((newPayload: Record<string, any>) => { - setRunInputData(newPayload) - }, [setRunInputData]) + const setInputVarValues = useCallback( + (newPayload: Record<string, any>) => { + setRunInputData(newPayload) + }, + [setRunInputData], + ) const inputVarValues = (() => { const vars: Record<string, any> = {} - Object.keys(runInputData) - .forEach((key) => { - vars[key] = runInputData[key] - }) + Object.keys(runInputData).forEach((key) => { + vars[key] = runInputData[key] + }) return vars })() @@ -46,13 +48,12 @@ const useSingleRunFormParams = ({ }, [inputVarValues, setInputVarValues, varInputs]) const getDependentVars = () => { - return payload.variables.map(v => v.value_selector) + return payload.variables.map((v) => v.value_selector) } const getDependentVar = (variable: string) => { - const varItem = payload.variables.find(v => v.variable === variable) - if (varItem) - return varItem.value_selector + const varItem = payload.variables.find((v) => v.variable === variable) + if (varItem) return varItem.value_selector } return { diff --git a/web/app/components/workflow/nodes/components.ts b/web/app/components/workflow/nodes/components.ts index fa86c55e0c3fda..d1760365dc94a1 100644 --- a/web/app/components/workflow/nodes/components.ts +++ b/web/app/components/workflow/nodes/components.ts @@ -58,7 +58,9 @@ import VariableAssignerNode from './variable-assigner/node' import VariableAssignerPanel from './variable-assigner/panel' type WorkflowAgentNodeProps = ComponentProps<typeof AgentNode> | ComponentProps<typeof AgentV2Node> -type WorkflowAgentPanelProps = ComponentProps<typeof AgentPanel> | ComponentProps<typeof AgentV2Panel> +type WorkflowAgentPanelProps = + | ComponentProps<typeof AgentPanel> + | ComponentProps<typeof AgentV2Panel> type WorkflowComponentMap = Record<string, ComponentType<Record<string, never>>> function WorkflowAgentNode(props: WorkflowAgentNodeProps) { diff --git a/web/app/components/workflow/nodes/constants.ts b/web/app/components/workflow/nodes/constants.ts index f712034f620093..a98e8c6f31859b 100644 --- a/web/app/components/workflow/nodes/constants.ts +++ b/web/app/components/workflow/nodes/constants.ts @@ -18,5 +18,14 @@ export const TRANSFER_METHOD = [ { value: TransferMethod.remote_url, i18nKey: 'url' }, ] as const satisfies readonly OptionItem[] -export const SUB_VARIABLES = ['type', 'size', 'name', 'url', 'extension', 'mime_type', 'transfer_method', 'related_id'] -export const OUTPUT_FILE_SUB_VARIABLES = SUB_VARIABLES.filter(key => key !== 'transfer_method') +export const SUB_VARIABLES = [ + 'type', + 'size', + 'name', + 'url', + 'extension', + 'mime_type', + 'transfer_method', + 'related_id', +] +export const OUTPUT_FILE_SUB_VARIABLES = SUB_VARIABLES.filter((key) => key !== 'transfer_method') diff --git a/web/app/components/workflow/nodes/data-source-empty/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/data-source-empty/__tests__/index.spec.tsx index 48e679813d8180..6eb06c0eeb673d 100644 --- a/web/app/components/workflow/nodes/data-source-empty/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/data-source-empty/__tests__/index.spec.tsx @@ -23,14 +23,16 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ {typeof trigger === 'function' ? trigger(false) : trigger} <button type="button" - onClick={() => onSelect(BlockEnum.DataSource, { - plugin_id: 'plugin-id', - provider_type: 'datasource', - provider_name: 'file', - datasource_name: 'local-file', - datasource_label: 'Local File', - title: 'Local File', - })} + onClick={() => + onSelect(BlockEnum.DataSource, { + plugin_id: 'plugin-id', + provider_type: 'datasource', + provider_name: 'file', + datasource_name: 'local-file', + datasource_label: 'Local File', + title: 'Local File', + }) + } > select data source </button> @@ -40,21 +42,22 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ type DataSourceEmptyNodeProps = ComponentProps<typeof DataSourceEmptyNode> -const createNodeProps = (): DataSourceEmptyNodeProps => ({ - id: 'data-source-empty-node', - data: { - width: 240, - height: 88, - }, - type: 'default', - selected: false, - zIndex: 0, - isConnectable: true, - xPos: 0, - yPos: 0, - dragging: false, - dragHandle: undefined, -} as unknown as DataSourceEmptyNodeProps) +const createNodeProps = (): DataSourceEmptyNodeProps => + ({ + id: 'data-source-empty-node', + data: { + width: 240, + height: 88, + }, + type: 'default', + selected: false, + zIndex: 0, + isConnectable: true, + xPos: 0, + yPos: 0, + dragging: false, + dragHandle: undefined, + }) as unknown as DataSourceEmptyNodeProps describe('DataSourceEmptyNode', () => { beforeEach(() => { @@ -67,9 +70,7 @@ describe('DataSourceEmptyNode', () => { // The empty datasource node should render the add trigger and forward selector choices. describe('Rendering and Selection', () => { it('should render the datasource add trigger', () => { - render( - <DataSourceEmptyNode {...createNodeProps()} />, - ) + render(<DataSourceEmptyNode {...createNodeProps()} />) expect(screen.getByText('workflow.nodes.dataSource.add')).toBeInTheDocument() expect(screen.getByText('workflow.blocks.datasource')).toBeInTheDocument() @@ -82,9 +83,7 @@ describe('DataSourceEmptyNode', () => { handleReplaceNode, }) - render( - <DataSourceEmptyNode {...createNodeProps()} />, - ) + render(<DataSourceEmptyNode {...createNodeProps()} />) await user.click(screen.getByRole('button', { name: 'select data source' })) diff --git a/web/app/components/workflow/nodes/data-source-empty/hooks.ts b/web/app/components/workflow/nodes/data-source-empty/hooks.ts index 9dd66b6b666a5f..6973e94b143aaa 100644 --- a/web/app/components/workflow/nodes/data-source-empty/hooks.ts +++ b/web/app/components/workflow/nodes/data-source-empty/hooks.ts @@ -9,41 +9,36 @@ export const useReplaceDataSourceNode = (id: string) => { const collaborativeWorkflow = useCollaborativeWorkflow() const { nodesMap: nodesMetaDataMap } = useNodesMetaData() - const handleReplaceNode = useCallback<OnSelectBlock>(( - type, - pluginDefaultValue, - ) => { - const { - nodes, - setNodes, - } = collaborativeWorkflow.getState() - const emptyNodeIndex = nodes.findIndex(node => node.id === id) + const handleReplaceNode = useCallback<OnSelectBlock>( + (type, pluginDefaultValue) => { + const { nodes, setNodes } = collaborativeWorkflow.getState() + const emptyNodeIndex = nodes.findIndex((node) => node.id === id) - if (emptyNodeIndex < 0) - return - const nodeMetaData = nodesMetaDataMap?.[type] - if (!nodeMetaData) - return - const { defaultValue } = nodeMetaData - const emptyNode = nodes[emptyNodeIndex] - const { newNode } = generateNewNode({ - data: { - ...(defaultValue as any), - ...pluginDefaultValue, - }, - position: { - x: emptyNode!.position.x, - y: emptyNode!.position.y, - }, - }) - const newNodes = produce(nodes, (draft) => { - draft[emptyNodeIndex] = newNode - }) - const newNodesWithoutTempNodes = produce(newNodes, (draft) => { - return draft.filter(node => !node.data._isTempNode) - }) - setNodes(newNodesWithoutTempNodes) - }, [collaborativeWorkflow, id, nodesMetaDataMap]) + if (emptyNodeIndex < 0) return + const nodeMetaData = nodesMetaDataMap?.[type] + if (!nodeMetaData) return + const { defaultValue } = nodeMetaData + const emptyNode = nodes[emptyNodeIndex] + const { newNode } = generateNewNode({ + data: { + ...(defaultValue as any), + ...pluginDefaultValue, + }, + position: { + x: emptyNode!.position.x, + y: emptyNode!.position.y, + }, + }) + const newNodes = produce(nodes, (draft) => { + draft[emptyNodeIndex] = newNode + }) + const newNodesWithoutTempNodes = produce(newNodes, (draft) => { + return draft.filter((node) => !node.data._isTempNode) + }) + setNodes(newNodesWithoutTempNodes) + }, + [collaborativeWorkflow, id, nodesMetaDataMap], + ) return { handleReplaceNode, diff --git a/web/app/components/workflow/nodes/data-source-empty/index.tsx b/web/app/components/workflow/nodes/data-source-empty/index.tsx index 99eea30f3fa408..65922cbdc29ec6 100644 --- a/web/app/components/workflow/nodes/data-source-empty/index.tsx +++ b/web/app/components/workflow/nodes/data-source-empty/index.tsx @@ -2,10 +2,7 @@ import type { NodeProps } from 'reactflow' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiAddLine } from '@remixicon/react' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import BlockSelector from '@/app/components/workflow/block-selector' import { useReplaceDataSourceNode } from './hooks' @@ -16,22 +13,16 @@ const DataSourceEmptyNode = ({ id, data }: NodeProps) => { const renderTrigger = useCallback(() => { return ( - <Button - variant="primary" - className="w-full" - > + <Button variant="primary" className="w-full"> <RiAddLine className="mr-1 size-4" /> - {t($ => $['nodes.dataSource.add'], { ns: 'workflow' })} + {t(($) => $['nodes.dataSource.add'], { ns: 'workflow' })} </Button> ) }, []) return ( <div - className={cn( - 'relative flex rounded-2xl border', - 'border-transparent', - )} + className={cn('relative flex rounded-2xl border', 'border-transparent')} style={{ width: data.width, height: data.height, @@ -39,7 +30,7 @@ const DataSourceEmptyNode = ({ id, data }: NodeProps) => { > <div className="absolute inset-[-2px] top-[-22px] z-[-1] rounded-[18px] bg-node-data-source-bg p-0.5 backdrop-blur-[6px]"> <div className="flex h-5 items-center px-2.5 system-2xs-semibold-uppercase text-text-tertiary"> - {t($ => $['blocks.datasource'], { ns: 'workflow' })} + {t(($) => $['blocks.datasource'], { ns: 'workflow' })} </div> </div> <div @@ -49,10 +40,7 @@ const DataSourceEmptyNode = ({ id, data }: NodeProps) => { 'w-[240px] bg-workflow-block-bg', )} > - <div className={cn( - 'flex items-center rounded-t-2xl p-3', - )} - > + <div className={cn('flex items-center rounded-t-2xl p-3')}> <BlockSelector onSelect={handleReplaceNode} trigger={renderTrigger} diff --git a/web/app/components/workflow/nodes/data-source/__tests__/before-run-form.spec.tsx b/web/app/components/workflow/nodes/data-source/__tests__/before-run-form.spec.tsx index c12ec212bf64d7..732dfca9b34168 100644 --- a/web/app/components/workflow/nodes/data-source/__tests__/before-run-form.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/__tests__/before-run-form.spec.tsx @@ -18,36 +18,56 @@ vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/st useDataSourceStore: () => mockUseDataSourceStore(), })) -vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store/provider', () => ({ - __esModule: true, - default: ({ children }: { children: ReactNode }) => <>{children}</>, -})) +vi.mock( + '@/app/components/datasets/documents/create-from-pipeline/data-source/store/provider', + () => ({ + __esModule: true, + default: ({ children }: { children: ReactNode }) => <>{children}</>, + }), +) vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/local-file', () => ({ __esModule: true, - default: ({ allowedExtensions }: { allowedExtensions: string[] }) => <div>{allowedExtensions.join(',')}</div>, -})) - -vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/online-documents', () => ({ - __esModule: true, - default: ({ onCredentialChange }: { onCredentialChange: (credentialId: string) => void }) => ( - <button type="button" onClick={() => onCredentialChange('credential-doc')}>online-documents</button> - ), -})) - -vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl', () => ({ - __esModule: true, - default: ({ onCredentialChange }: { onCredentialChange: (credentialId: string) => void }) => ( - <button type="button" onClick={() => onCredentialChange('credential-site')}>website-crawl</button> + default: ({ allowedExtensions }: { allowedExtensions: string[] }) => ( + <div>{allowedExtensions.join(',')}</div> ), })) -vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/online-drive', () => ({ - __esModule: true, - default: ({ onCredentialChange }: { onCredentialChange: (credentialId: string) => void }) => ( - <button type="button" onClick={() => onCredentialChange('credential-drive')}>online-drive</button> - ), -})) +vi.mock( + '@/app/components/datasets/documents/create-from-pipeline/data-source/online-documents', + () => ({ + __esModule: true, + default: ({ onCredentialChange }: { onCredentialChange: (credentialId: string) => void }) => ( + <button type="button" onClick={() => onCredentialChange('credential-doc')}> + online-documents + </button> + ), + }), +) + +vi.mock( + '@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl', + () => ({ + __esModule: true, + default: ({ onCredentialChange }: { onCredentialChange: (credentialId: string) => void }) => ( + <button type="button" onClick={() => onCredentialChange('credential-site')}> + website-crawl + </button> + ), + }), +) + +vi.mock( + '@/app/components/datasets/documents/create-from-pipeline/data-source/online-drive', + () => ({ + __esModule: true, + default: ({ onCredentialChange }: { onCredentialChange: (credentialId: string) => void }) => ( + <button type="button" onClick={() => onCredentialChange('credential-drive')}> + online-drive + </button> + ), + }), +) vi.mock('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', () => ({ useOnlineDocument: () => ({ clearOnlineDocumentData: mockClearOnlineDocumentData }), @@ -57,10 +77,20 @@ vi.mock('@/app/components/rag-pipeline/components/panel/test-run/preparation/hoo vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/panel-wrap', () => ({ __esModule: true, - default: ({ nodeName, onHide, children }: { nodeName: string, onHide: () => void, children: ReactNode }) => ( + default: ({ + nodeName, + onHide, + children, + }: { + nodeName: string + onHide: () => void + children: ReactNode + }) => ( <div> <div>{nodeName}</div> - <button type="button" onClick={onHide}>hide-panel</button> + <button type="button" onClick={onHide}> + hide-panel + </button> {children} </div> ), @@ -156,7 +186,11 @@ describe('data-source/before-run-form', () => { startRunBtnDisabled: true, }) - render(<BeforeRunForm {...createProps({ payload: createData({ provider_type: DatasourceType.onlineDocument }) })} />) + render( + <BeforeRunForm + {...createProps({ payload: createData({ provider_type: DatasourceType.onlineDocument }) })} + />, + ) await user.click(screen.getByRole('button', { name: 'online-documents' })) @@ -176,7 +210,11 @@ describe('data-source/before-run-form', () => { startRunBtnDisabled: false, }) - render(<BeforeRunForm {...createProps({ payload: createData({ provider_type: DatasourceType.websiteCrawl }) })} />) + render( + <BeforeRunForm + {...createProps({ payload: createData({ provider_type: DatasourceType.websiteCrawl }) })} + />, + ) await user.click(screen.getByRole('button', { name: 'website-crawl' })) @@ -195,7 +233,11 @@ describe('data-source/before-run-form', () => { startRunBtnDisabled: false, }) - render(<BeforeRunForm {...createProps({ payload: createData({ provider_type: DatasourceType.onlineDrive }) })} />) + render( + <BeforeRunForm + {...createProps({ payload: createData({ provider_type: DatasourceType.onlineDrive }) })} + />, + ) await user.click(screen.getByRole('button', { name: 'online-drive' })) diff --git a/web/app/components/workflow/nodes/data-source/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/data-source/__tests__/node.spec.tsx index 686e145ef3aa90..0208448c7ea44f 100644 --- a/web/app/components/workflow/nodes/data-source/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/__tests__/node.spec.tsx @@ -4,9 +4,11 @@ import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-n import { BlockEnum } from '@/app/components/workflow/types' import Node from '../node' -const mockInstallPluginButton = vi.hoisted(() => vi.fn(({ uniqueIdentifier }: { uniqueIdentifier: string }) => ( - <button type="button">{uniqueIdentifier}</button> -))) +const mockInstallPluginButton = vi.hoisted(() => + vi.fn(({ uniqueIdentifier }: { uniqueIdentifier: string }) => ( + <button type="button">{uniqueIdentifier}</button> + )), +) vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({ useNodePluginInstallation: vi.fn(), @@ -61,10 +63,13 @@ describe('DataSourceNode', () => { render(<Node id="data-source-node" data={createNodeData()} />) expect(screen.getByRole('button', { name: 'plugin-id@1.0.0' })).toBeInTheDocument() - expect(mockInstallPluginButton).toHaveBeenCalledWith(expect.objectContaining({ - uniqueIdentifier: 'plugin-id@1.0.0', - extraIdentifiers: ['plugin-id', 'file'], - }), undefined) + expect(mockInstallPluginButton).toHaveBeenCalledWith( + expect.objectContaining({ + uniqueIdentifier: 'plugin-id@1.0.0', + extraIdentifiers: ['plugin-id', 'file'], + }), + undefined, + ) }) it('should render nothing when installation is unavailable', () => { diff --git a/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx index 8160da6502b483..0e69c3a21f99f7 100644 --- a/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx @@ -6,7 +6,9 @@ import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-for import { useNodesReadOnly } from '@/app/components/workflow/hooks' import { useStore } from '@/app/components/workflow/store' import { BlockEnum, VarType } from '@/app/components/workflow/types' -import useMatchSchemaType, { getMatchedSchemaType } from '../../_base/components/variable/use-match-schema-type' +import useMatchSchemaType, { + getMatchedSchemaType, +} from '../../_base/components/variable/use-match-schema-type' import ToolForm from '../../tool/components/tool-form' import { useConfig } from '../hooks/use-config' import Panel from '../panel' @@ -49,7 +51,7 @@ vi.mock('@/app/components/workflow/utils/tool', () => ({ vi.mock('../../_base/components/output-vars', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <div>{children}</div>, - VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>, + VarItem: ({ name, type }: { name: string; type: string }) => <div>{`${name}:${type}`}</div>, })) vi.mock('../../_base/components/variable/object-child-tree-panel/show', () => ({ @@ -65,12 +67,24 @@ vi.mock('../../_base/components/variable/use-match-schema-type', () => ({ vi.mock('../../tool/components/tool-form', () => ({ __esModule: true, - default: vi.fn(({ onChange, onManageInputField }: { onChange: (value: unknown) => void, onManageInputField?: () => void }) => ( - <div> - <button type="button" onClick={() => onChange({ dataset: 'docs' })}>tool-form-change</button> - <button type="button" onClick={() => onManageInputField?.()}>manage-input-field</button> - </div> - )), + default: vi.fn( + ({ + onChange, + onManageInputField, + }: { + onChange: (value: unknown) => void + onManageInputField?: () => void + }) => ( + <div> + <button type="button" onClick={() => onChange({ dataset: 'docs' })}> + tool-form-change + </button> + <button type="button" onClick={() => onManageInputField?.()}> + manage-input-field + </button> + </div> + ), + ), })) vi.mock('../hooks/use-config', () => ({ @@ -111,14 +125,18 @@ describe('data-source/panel', () => { mockUseStore.mockImplementation((selector) => { const select = selector as (state: unknown) => unknown return select({ - dataSourceList: [{ - plugin_id: 'plugin-1', - is_authorized: true, - tools: [{ - name: 'source-a', - parameters: [{ name: 'dataset' }], - }], - }], + dataSourceList: [ + { + plugin_id: 'plugin-1', + is_authorized: true, + tools: [ + { + name: 'source-a', + parameters: [{ name: 'dataset' }], + }, + ], + }, + ], pipelineId: 'pipeline-1', setShowInputFieldPanel, }) @@ -130,7 +148,9 @@ describe('data-source/panel', () => { hasObjectOutput: false, }) mockToolParametersToFormSchemas.mockReturnValue([{ name: 'dataset' }] as never) - mockUseMatchSchemaType.mockReturnValue({ schemaTypeDefinitions: {} } as ReturnType<typeof useMatchSchemaType>) + mockUseMatchSchemaType.mockReturnValue({ schemaTypeDefinitions: {} } as ReturnType< + typeof useMatchSchemaType + >) mockGetMatchedSchemaType.mockReturnValue('') }) @@ -139,32 +159,31 @@ describe('data-source/panel', () => { mockUseConfig.mockReturnValueOnce({ handleFileExtensionsChange: vi.fn(), handleParametersChange, - outputSchema: [{ - name: 'metadata', - value: { type: 'object' }, - }], + outputSchema: [ + { + name: 'metadata', + value: { type: 'object' }, + }, + ], hasObjectOutput: true, }) mockGetMatchedSchemaType.mockReturnValueOnce('json') - render( - <Panel - id="data-source-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<Panel id="data-source-node" data={createData()} panelProps={panelProps} />) fireEvent.click(screen.getByRole('button', { name: 'tool-form-change' })) fireEvent.click(screen.getByRole('button', { name: 'manage-input-field' })) expect(handleParametersChange).toHaveBeenCalledWith({ dataset: 'docs' }) expect(setShowInputFieldPanel).toHaveBeenCalledWith(true) - expect(mockToolForm).toHaveBeenCalledWith(expect.objectContaining({ - nodeId: 'data-source-node', - showManageInputField: true, - value: {}, - }), undefined) + expect(mockToolForm).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: 'data-source-node', + showManageInputField: true, + value: {}, + }), + undefined, + ) expect(screen.getByText('metadata')).toBeInTheDocument() }) @@ -185,7 +204,11 @@ describe('data-source/panel', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.dataSource.supportedFileFormatsPlaceholder' })) + fireEvent.click( + screen.getByRole('button', { + name: 'workflow.nodes.dataSource.supportedFileFormatsPlaceholder', + }), + ) expect(handleFileExtensionsChange).toHaveBeenCalledWith(['pdf', 'txt']) expect(screen.getByText(`datasource_type:${VarType.string}`)).toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/data-source/before-run-form.tsx b/web/app/components/workflow/nodes/data-source/before-run-form.tsx index 0e17f9d992f5af..bb1960628b21c6 100644 --- a/web/app/components/workflow/nodes/data-source/before-run-form.tsx +++ b/web/app/components/workflow/nodes/data-source/before-run-form.tsx @@ -11,17 +11,17 @@ import OnlineDrive from '@/app/components/datasets/documents/create-from-pipelin import { useDataSourceStore } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' import DataSourceProvider from '@/app/components/datasets/documents/create-from-pipeline/data-source/store/provider' import WebsiteCrawl from '@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl' -import { useOnlineDocument, useOnlineDrive, useWebsiteCrawl } from '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks' +import { + useOnlineDocument, + useOnlineDrive, + useWebsiteCrawl, +} from '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks' import { DatasourceType } from '@/models/pipeline' import PanelWrap from '../_base/components/before-run-form/panel-wrap' import useBeforeRunForm from './hooks/use-before-run-form' const BeforeRunForm: FC<CustomRunFormProps> = (props) => { - const { - nodeId, - payload, - onCancel, - } = props + const { nodeId, payload, onCancel } = props const { t } = useTranslation() const dataSourceStore = useDataSourceStore() @@ -38,25 +38,22 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => { const { clearOnlineDriveData } = useOnlineDrive() const clearDataSourceData = useCallback(() => { - if (datasourceType === DatasourceType.onlineDocument) - clearOnlineDocumentData() - else if (datasourceType === DatasourceType.websiteCrawl) - clearWebsiteCrawlData() - else if (datasourceType === DatasourceType.onlineDrive) - clearOnlineDriveData() + if (datasourceType === DatasourceType.onlineDocument) clearOnlineDocumentData() + else if (datasourceType === DatasourceType.websiteCrawl) clearWebsiteCrawlData() + else if (datasourceType === DatasourceType.onlineDrive) clearOnlineDriveData() }, [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData, datasourceType]) - const handleCredentialChange = useCallback((credentialId: string) => { - const { setCurrentCredentialId } = dataSourceStore.getState() - clearDataSourceData() - setCurrentCredentialId(credentialId) - }, [clearDataSourceData, dataSourceStore]) + const handleCredentialChange = useCallback( + (credentialId: string) => { + const { setCurrentCredentialId } = dataSourceStore.getState() + clearDataSourceData() + setCurrentCredentialId(credentialId) + }, + [clearDataSourceData, dataSourceStore], + ) return ( - <PanelWrap - nodeName={payload.title} - onHide={onCancel} - > + <PanelWrap nodeName={payload.title} onHide={onCancel}> <div className="flex flex-col gap-y-5 px-4 pt-4"> {datasourceType === DatasourceType.localFile && ( <LocalFile @@ -92,16 +89,14 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => { /> )} <div className="flex justify-end gap-x-2"> - <Button onClick={onCancel}> - {t($ => $['operation.cancel'], { ns: 'common' })} - </Button> + <Button onClick={onCancel}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> <Button onClick={handleRunWithSyncDraft} variant="primary" loading={isPending} disabled={isPending || startRunBtnDisabled} > - {t($ => $['singleRun.startRun'], { ns: 'workflow' })} + {t(($) => $['singleRun.startRun'], { ns: 'workflow' })} </Button> </div> </div> diff --git a/web/app/components/workflow/nodes/data-source/default.ts b/web/app/components/workflow/nodes/data-source/default.ts index f3ea971c437c2a..2540edd0aada6f 100644 --- a/web/app/components/workflow/nodes/data-source/default.ts +++ b/web/app/components/workflow/nodes/data-source/default.ts @@ -5,10 +5,7 @@ import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/typ import { BlockEnum } from '@/app/components/workflow/types' import { genNodeMetaData } from '@/app/components/workflow/utils' import { getMatchedSchemaType } from '../_base/components/variable/use-match-schema-type' -import { - COMMON_OUTPUT, - LOCAL_FILE_OUTPUT, -} from './constants' +import { COMMON_OUTPUT, LOCAL_FILE_OUTPUT } from './constants' import { DataSourceClassification } from './types' const i18nPrefix = 'errorMsg' @@ -28,28 +25,37 @@ const nodeDefault: NodeDefault<DataSourceNodeType> = { checkValid(payload, t: TFunction<'workflow'>, moreDataForCheckValid) { const { dataSourceInputsSchema, notAuthed } = moreDataForCheckValid let errorMessage = '' - if (notAuthed) - errorMessage = t($ => $[`${i18nPrefix}.authRequired`], { ns: 'workflow' }) + if (notAuthed) errorMessage = t(($) => $[`${i18nPrefix}.authRequired`], { ns: 'workflow' }) if (!errorMessage) { - dataSourceInputsSchema.filter((field: any) => { - return field.required - }).forEach((field: any) => { - const targetVar = payload.datasource_parameters[field.variable] - if (!targetVar) { - errorMessage = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: field.label }) - return - } - const { type: variable_type, value } = targetVar - if (variable_type === VarKindType.variable) { - if (!errorMessage && (!value || value.length === 0)) - errorMessage = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: field.label }) - } - else { - if (!errorMessage && (value === undefined || value === null || value === '')) - errorMessage = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: field.label }) - } - }) + dataSourceInputsSchema + .filter((field: any) => { + return field.required + }) + .forEach((field: any) => { + const targetVar = payload.datasource_parameters[field.variable] + if (!targetVar) { + errorMessage = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: field.label, + }) + return + } + const { type: variable_type, value } = targetVar + if (variable_type === VarKindType.variable) { + if (!errorMessage && (!value || value.length === 0)) + errorMessage = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: field.label, + }) + } else { + if (!errorMessage && (value === undefined || value === null || value === '')) + errorMessage = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: field.label, + }) + } + }) } return { @@ -57,16 +63,21 @@ const nodeDefault: NodeDefault<DataSourceNodeType> = { errorMessage, } }, - getOutputVars(payload, allPluginInfoList, ragVars = [], { schemaTypeDefinitions } = { schemaTypeDefinitions: [] }) { - const { - plugin_id, - datasource_name, - provider_type, - } = payload + getOutputVars( + payload, + allPluginInfoList, + ragVars = [], + { schemaTypeDefinitions } = { schemaTypeDefinitions: [] }, + ) { + const { plugin_id, datasource_name, provider_type } = payload const isLocalFile = provider_type === DataSourceClassification.localFile - const currentDataSource = allPluginInfoList.dataSourceList?.find((ds: any) => ds.plugin_id === plugin_id) - const currentDataSourceItem = currentDataSource?.tools?.find((tool: any) => tool.name === datasource_name) + const currentDataSource = allPluginInfoList.dataSourceList?.find( + (ds: any) => ds.plugin_id === plugin_id, + ) + const currentDataSourceItem = currentDataSource?.tools?.find( + (tool: any) => tool.name === datasource_name, + ) const output_schema = currentDataSourceItem?.output_schema const dynamicOutputSchema: any[] = [] @@ -74,37 +85,36 @@ const nodeDefault: NodeDefault<DataSourceNodeType> = { Object.keys(output_schema.properties).forEach((outputKey) => { const output = output_schema.properties[outputKey] const dataType = output.type - let type = dataType === 'array' - ? `array[${output.items?.type.slice(0, 1).toLocaleLowerCase()}${output.items?.type.slice(1)}]` - : `${dataType.slice(0, 1).toLocaleLowerCase()}${dataType.slice(1)}` + let type = + dataType === 'array' + ? `array[${output.items?.type.slice(0, 1).toLocaleLowerCase()}${output.items?.type.slice(1)}]` + : `${dataType.slice(0, 1).toLocaleLowerCase()}${dataType.slice(1)}` const schemaType = getMatchedSchemaType?.(output, schemaTypeDefinitions) - if (type === 'object' && schemaType === 'file') - type = 'file' + if (type === 'object' && schemaType === 'file') type = 'file' dynamicOutputSchema.push({ variable: outputKey, type, description: output.description, schemaType, - children: output.type === 'object' - ? { - schema: { - type: 'object', - properties: output.properties, - }, - } - : undefined, + children: + output.type === 'object' + ? { + schema: { + type: 'object', + properties: output.properties, + }, + } + : undefined, }) }) } return [ - ...COMMON_OUTPUT.map(item => ({ variable: item.name, type: item.type })), - ...( - isLocalFile - ? LOCAL_FILE_OUTPUT.map(item => ({ variable: item.name, type: item.type })) - : [] - ), + ...COMMON_OUTPUT.map((item) => ({ variable: item.name, type: item.type })), + ...(isLocalFile + ? LOCAL_FILE_OUTPUT.map((item) => ({ variable: item.name, type: item.type })) + : []), ...ragVars, ...dynamicOutputSchema, ] diff --git a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx index 09172dd673b6f1..bb72164938a575 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx @@ -2,7 +2,10 @@ import type { CustomRunFormProps, DataSourceNodeType } from '../../types' import type { NodeRunResult, VarInInspect } from '@/types/workflow' import { act, renderHook } from '@testing-library/react' import { useStoreApi } from 'reactflow' -import { useDataSourceStore, useDataSourceStoreWithSelector } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' +import { + useDataSourceStore, + useDataSourceStoreWithSelector, +} from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' import { DatasourceType } from '@/models/pipeline' import { useDatasourceSingleRun } from '@/service/use-pipeline' @@ -30,7 +33,7 @@ type DataSourceStoreState = { onlineDocuments: Array<Record<string, unknown>> websitePages: Array<Record<string, unknown>> selectedFileIds: string[] - onlineDriveFileList: Array<{ id: string, type: string }> + onlineDriveFileList: Array<{ id: string; type: string }> bucket?: string } @@ -157,16 +160,22 @@ describe('data-source/hooks/use-before-run-form branches', () => { isPending: false, } as ReturnType<typeof useDatasourceSingleRun>) mockUseInvalidLastRunHook.mockReturnValue(mockInvalidLastRun) - mockFetchNodeInspectVarsFn.mockImplementation((...args: unknown[]) => mockFetchNodeInspectVars(...args)) + mockFetchNodeInspectVarsFn.mockImplementation((...args: unknown[]) => + mockFetchNodeInspectVars(...args), + ) mockUseDataSourceStoreHook.mockImplementation(() => mockUseDataSourceStore()) - mockUseDataSourceStoreWithSelectorHook.mockImplementation(selector => - mockUseDataSourceStoreWithSelector(selector as unknown as (state: DataSourceStoreState) => unknown)) + mockUseDataSourceStoreWithSelectorHook.mockImplementation((selector) => + mockUseDataSourceStoreWithSelector( + selector as unknown as (state: DataSourceStoreState) => unknown, + ), + ) mockUseDataSourceStore.mockImplementation(() => ({ getState: () => dataSourceStoreState, })) - mockUseDataSourceStoreWithSelector.mockImplementation((selector: (state: DataSourceStoreState) => unknown) => - selector(dataSourceStoreState)) + mockUseDataSourceStoreWithSelector.mockImplementation( + (selector: (state: DataSourceStoreState) => unknown) => selector(dataSourceStoreState), + ) mockFetchNodeInspectVars.mockResolvedValue([{ name: 'metadata' }] as VarInInspect[]) }) @@ -182,11 +191,13 @@ describe('data-source/hooks/use-before-run-form branches', () => { expect(result.current.startRunBtnDisabled).toBe(true) - dataSourceStoreState.onlineDocuments = [{ - workspace_id: 'workspace-1', - id: 'doc-1', - title: 'Document', - }] + dataSourceStoreState.onlineDocuments = [ + { + workspace_id: 'workspace-1', + id: 'doc-1', + title: 'Document', + }, + ] rerender({ payload: createData({ provider_type: DatasourceType.onlineDocument }) }) expect(result.current.startRunBtnDisabled).toBe(false) @@ -199,16 +210,18 @@ describe('data-source/hooks/use-before-run-form branches', () => { }) it('returns the settled run result directly when chained single-run execution should stop', async () => { - dataSourceStoreState.localFileList = [{ - file: { - id: 'file-1', - name: 'doc.pdf', - type: 'document', - size: 12, - extension: 'pdf', - mime_type: 'application/pdf', + dataSourceStoreState.localFileList = [ + { + file: { + id: 'file-1', + name: 'doc.pdf', + type: 'document', + size: 12, + extension: 'pdf', + mime_type: 'application/pdf', + }, }, - }] + ] mockMutateAsync.mockImplementation((_payload: unknown, options: DatasourceSingleRunOptions) => { options.onSettled?.({ status: NodeRunningStatus.Succeeded } as NodeRunResult) @@ -234,75 +247,95 @@ describe('data-source/hooks/use-before-run-form branches', () => { }) it('builds online document datasource info before running', async () => { - dataSourceStoreState.onlineDocuments = [{ - workspace_id: 'workspace-1', - id: 'doc-1', - title: 'Document', - url: 'https://example.com/doc', - }] + dataSourceStoreState.onlineDocuments = [ + { + workspace_id: 'workspace-1', + id: 'doc-1', + title: 'Document', + url: 'https://example.com/doc', + }, + ] mockMutateAsync.mockImplementation((payload: unknown, options: DatasourceSingleRunOptions) => { options.onSettled?.({ status: NodeRunningStatus.Succeeded } as NodeRunResult) return Promise.resolve(payload) }) - const { result } = renderHook(() => useBeforeRunForm(createProps({ - payload: createData({ provider_type: DatasourceType.onlineDocument }), - }))) + const { result } = renderHook(() => + useBeforeRunForm( + createProps({ + payload: createData({ provider_type: DatasourceType.onlineDocument }), + }), + ), + ) await act(async () => { result.current.handleRunWithSyncDraft() await Promise.resolve() }) - expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({ - datasource_type: DatasourceType.onlineDocument, - datasource_info: { - workspace_id: 'workspace-1', - page: { - id: 'doc-1', - title: 'Document', - url: 'https://example.com/doc', + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + datasource_type: DatasourceType.onlineDocument, + datasource_info: { + workspace_id: 'workspace-1', + page: { + id: 'doc-1', + title: 'Document', + url: 'https://example.com/doc', + }, + credential_id: 'credential-1', }, - credential_id: 'credential-1', - }, - }), expect.any(Object)) + }), + expect.any(Object), + ) }) it('builds website crawl datasource info and skips the failure update while paused', async () => { - dataSourceStoreState.websitePages = [{ - url: 'https://example.com', - title: 'Example', - }] + dataSourceStoreState.websitePages = [ + { + url: 'https://example.com', + title: 'Example', + }, + ] mockMutateAsync.mockImplementation((payload: unknown, options: DatasourceSingleRunOptions) => { options.onError?.() return Promise.resolve(payload) }) - const { result } = renderHook(() => useBeforeRunForm(createProps({ - isPaused: true, - payload: createData({ provider_type: DatasourceType.websiteCrawl }), - }))) + const { result } = renderHook(() => + useBeforeRunForm( + createProps({ + isPaused: true, + payload: createData({ provider_type: DatasourceType.websiteCrawl }), + }), + ), + ) await act(async () => { result.current.handleRunWithSyncDraft() await Promise.resolve() }) - expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({ - datasource_type: DatasourceType.websiteCrawl, - datasource_info: { - url: 'https://example.com', - title: 'Example', - credential_id: 'credential-1', - }, - }), expect.any(Object)) + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + datasource_type: DatasourceType.websiteCrawl, + datasource_info: { + url: 'https://example.com', + title: 'Example', + credential_id: 'credential-1', + }, + }), + expect.any(Object), + ) expect(mockInvalidLastRun).toHaveBeenCalled() - expect(mockHandleNodeDataUpdate).not.toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ - _singleRunningStatus: NodeRunningStatus.Failed, + expect(mockHandleNodeDataUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + _singleRunningStatus: NodeRunningStatus.Failed, + }), }), - })) + ) }) }) diff --git a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx index b4e79b333490aa..971601a6ba683a 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx @@ -2,7 +2,10 @@ import type { CustomRunFormProps, DataSourceNodeType } from '../../types' import type { NodeRunResult, VarInInspect } from '@/types/workflow' import { act, renderHook } from '@testing-library/react' import { useStoreApi } from 'reactflow' -import { useDataSourceStore, useDataSourceStoreWithSelector } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' +import { + useDataSourceStore, + useDataSourceStoreWithSelector, +} from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' import { DatasourceType } from '@/models/pipeline' import { useDatasourceSingleRun } from '@/service/use-pipeline' @@ -31,7 +34,7 @@ type DataSourceStoreState = { onlineDocuments: Array<Record<string, unknown>> websitePages: Array<Record<string, unknown>> selectedFileIds: string[] - onlineDriveFileList: Array<{ id: string, type: string }> + onlineDriveFileList: Array<{ id: string; type: string }> bucket?: string } @@ -165,16 +168,22 @@ describe('data-source/hooks/use-before-run-form', () => { isPending: false, } as ReturnType<typeof useDatasourceSingleRun>) mockUseInvalidLastRunHook.mockReturnValue(mockInvalidLastRun) - mockFetchNodeInspectVarsFn.mockImplementation((...args: unknown[]) => mockFetchNodeInspectVars(...args)) + mockFetchNodeInspectVarsFn.mockImplementation((...args: unknown[]) => + mockFetchNodeInspectVars(...args), + ) mockUseDataSourceStoreHook.mockImplementation(() => mockUseDataSourceStore()) - mockUseDataSourceStoreWithSelectorHook.mockImplementation(selector => - mockUseDataSourceStoreWithSelector(selector as unknown as (state: DataSourceStoreState) => unknown)) + mockUseDataSourceStoreWithSelectorHook.mockImplementation((selector) => + mockUseDataSourceStoreWithSelector( + selector as unknown as (state: DataSourceStoreState) => unknown, + ), + ) mockUseDataSourceStore.mockImplementation(() => ({ getState: () => dataSourceStoreState, })) - mockUseDataSourceStoreWithSelector.mockImplementation((selector: (state: DataSourceStoreState) => unknown) => - selector(dataSourceStoreState)) + mockUseDataSourceStoreWithSelector.mockImplementation( + (selector: (state: DataSourceStoreState) => unknown) => selector(dataSourceStoreState), + ) mockFetchNodeInspectVars.mockResolvedValue([{ name: 'metadata' }] as VarInInspect[]) }) @@ -190,16 +199,18 @@ describe('data-source/hooks/use-before-run-form', () => { expect(result.current.startRunBtnDisabled).toBe(true) - dataSourceStoreState.localFileList = [{ - file: { - id: 'file-1', - name: 'doc.pdf', - type: 'document', - size: 12, - extension: 'pdf', - mime_type: 'application/pdf', + dataSourceStoreState.localFileList = [ + { + file: { + id: 'file-1', + name: 'doc.pdf', + type: 'document', + size: 12, + extension: 'pdf', + mime_type: 'application/pdf', + }, }, - }] + ] rerender({ payload: createData() }) expect(result.current.startRunBtnDisabled).toBe(false) @@ -213,16 +224,18 @@ describe('data-source/hooks/use-before-run-form', () => { }) it('syncs the draft, runs the datasource, and appends inspect vars on success', async () => { - dataSourceStoreState.localFileList = [{ - file: { - id: 'file-1', - name: 'doc.pdf', - type: 'document', - size: 12, - extension: 'pdf', - mime_type: 'application/pdf', + dataSourceStoreState.localFileList = [ + { + file: { + id: 'file-1', + name: 'doc.pdf', + type: 'document', + size: 12, + extension: 'pdf', + mime_type: 'application/pdf', + }, }, - }] + ] mockMutateAsync.mockImplementation((payload: unknown, options: DatasourceSingleRunOptions) => { options.onSettled?.({ status: NodeRunningStatus.Succeeded } as NodeRunResult) @@ -244,24 +257,35 @@ describe('data-source/hooks/use-before-run-form', () => { _singleRunningStatus: NodeRunningStatus.Running, }), }) - expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({ - pipeline_id: 'flow-id', - start_node_id: 'data-source-node', - datasource_type: DatasourceType.localFile, - datasource_info: expect.objectContaining({ - related_id: 'file-1', - transfer_method: TransferMethod.local_file, + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + pipeline_id: 'flow-id', + start_node_id: 'data-source-node', + datasource_type: DatasourceType.localFile, + datasource_info: expect.objectContaining({ + related_id: 'file-1', + transfer_method: TransferMethod.local_file, + }), }), - }), expect.any(Object)) - expect(mockFetchNodeInspectVars).toHaveBeenCalledWith(FlowType.ragPipeline, 'flow-id', 'data-source-node') - expect(props.appendNodeInspectVars).toHaveBeenCalledWith('data-source-node', [{ name: 'metadata' }], [ - { - id: 'data-source-node', - data: { - title: 'Datasource', + expect.any(Object), + ) + expect(mockFetchNodeInspectVars).toHaveBeenCalledWith( + FlowType.ragPipeline, + 'flow-id', + 'data-source-node', + ) + expect(props.appendNodeInspectVars).toHaveBeenCalledWith( + 'data-source-node', + [{ name: 'metadata' }], + [ + { + id: 'data-source-node', + data: { + title: 'Datasource', + }, }, - }, - ]) + ], + ) expect(props.onSuccess).toHaveBeenCalled() expect(mockHandleNodeDataUpdate).toHaveBeenLastCalledWith({ id: 'data-source-node', @@ -274,21 +298,27 @@ describe('data-source/hooks/use-before-run-form', () => { it('marks the last run invalid and updates the node to failed when the single run errors', async () => { dataSourceStoreState.selectedFileIds = ['drive-file-1'] - dataSourceStoreState.onlineDriveFileList = [{ - id: 'drive-file-1', - type: 'file', - }] + dataSourceStoreState.onlineDriveFileList = [ + { + id: 'drive-file-1', + type: 'file', + }, + ] mockMutateAsync.mockImplementation((_payload: unknown, options: DatasourceSingleRunOptions) => { options.onError?.() return Promise.resolve(undefined) }) - const { result } = renderHook(() => useBeforeRunForm(createProps({ - payload: createData({ - provider_type: DatasourceType.onlineDrive, - }), - }))) + const { result } = renderHook(() => + useBeforeRunForm( + createProps({ + payload: createData({ + provider_type: DatasourceType.onlineDrive, + }), + }), + ), + ) await act(async () => { result.current.handleRunWithSyncDraft() diff --git a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts index 83fe0f48751dcf..7c9b58ca5f793a 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts @@ -14,7 +14,9 @@ vi.mock('@/app/components/workflow/hooks', () => ({ useNodeDataUpdate: () => mockUseNodeDataUpdate(), })) -const createNode = (overrides: Partial<DataSourceNodeType> = {}): { id: string, data: DataSourceNodeType } => ({ +const createNode = ( + overrides: Partial<DataSourceNodeType> = {}, +): { id: string; data: DataSourceNodeType } => ({ id: 'data-source-node', data: { title: 'Datasource', @@ -89,29 +91,33 @@ describe('data-source/hooks/use-config', () => { }) it('should derive output schema metadata and detect object outputs', () => { - const dataSourceList = [{ - plugin_id: 'plugin-1', - tools: [{ - name: 'source-a', - output_schema: { - properties: { - items: { - type: 'array', - items: { type: 'string' }, - description: 'List of items', - }, - metadata: { - type: 'object', - description: 'Object field', - }, - count: { - type: 'number', - description: 'Total count', + const dataSourceList = [ + { + plugin_id: 'plugin-1', + tools: [ + { + name: 'source-a', + output_schema: { + properties: { + items: { + type: 'array', + items: { type: 'string' }, + description: 'List of items', + }, + metadata: { + type: 'object', + description: 'Object field', + }, + count: { + type: 'number', + description: 'Total count', + }, + }, }, }, - }, - }], - }] + ], + }, + ] const { result } = renderHook(() => useConfig('data-source-node', dataSourceList)) diff --git a/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts b/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts index 13eb0b5194a62c..7db7f95825776e 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts +++ b/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts @@ -3,7 +3,10 @@ import type { NodeRunResult } from '@/types/workflow' import { useEffect, useMemo, useRef } from 'react' import { useStoreApi } from 'reactflow' import { useShallow } from 'zustand/react/shallow' -import { useDataSourceStore, useDataSourceStoreWithSelector } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' +import { + useDataSourceStore, + useDataSourceStoreWithSelector, +} from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' import { DatasourceType } from '@/models/pipeline' import { useDatasourceSingleRun } from '@/service/use-pipeline' import { useInvalidLastRun } from '@/service/use-workflow' @@ -33,31 +36,32 @@ const useBeforeRunForm = ({ const datasourceType = payload.provider_type as DatasourceType const datasourceNodeData = payload as DataSourceNodeType - const { - localFileList, - onlineDocuments, - websitePages, - selectedFileIds, - } = useDataSourceStoreWithSelector(useShallow(state => ({ - localFileList: state.localFileList, - onlineDocuments: state.onlineDocuments, - websitePages: state.websitePages, - selectedFileIds: state.selectedFileIds, - }))) + const { localFileList, onlineDocuments, websitePages, selectedFileIds } = + useDataSourceStoreWithSelector( + useShallow((state) => ({ + localFileList: state.localFileList, + onlineDocuments: state.onlineDocuments, + websitePages: state.websitePages, + selectedFileIds: state.selectedFileIds, + })), + ) const startRunBtnDisabled = useMemo(() => { - if (!datasourceNodeData) - return false + if (!datasourceNodeData) return false if (datasourceType === DatasourceType.localFile) - return !localFileList.length || localFileList.some(file => !file.file.id) - if (datasourceType === DatasourceType.onlineDocument) - return !onlineDocuments.length - if (datasourceType === DatasourceType.websiteCrawl) - return !websitePages.length - if (datasourceType === DatasourceType.onlineDrive) - return !selectedFileIds.length + return !localFileList.length || localFileList.some((file) => !file.file.id) + if (datasourceType === DatasourceType.onlineDocument) return !onlineDocuments.length + if (datasourceType === DatasourceType.websiteCrawl) return !websitePages.length + if (datasourceType === DatasourceType.onlineDrive) return !selectedFileIds.length return false - }, [datasourceNodeData, datasourceType, localFileList, onlineDocuments.length, selectedFileIds.length, websitePages.length]) + }, [ + datasourceNodeData, + datasourceType, + localFileList, + onlineDocuments.length, + selectedFileIds.length, + websitePages.length, + ]) useEffect(() => { isPausedRef.current = isPaused @@ -81,8 +85,7 @@ const useBeforeRunForm = ({ const isPaused = isPausedRef.current // The backend don't support pause the single run, so the frontend handle the pause state. - if (isPaused) - return + if (isPaused) return const canRunLastRun = !isRunAfterSingleRun || runningStatus === NodeRunningStatus.Succeeded if (!canRunLastRun) { @@ -95,8 +98,7 @@ const useBeforeRunForm = ({ const { getNodes } = store.getState() const nodes = getNodes() appendNodeInspectVars(nodeId, vars, nodes) - if (data?.status === NodeRunningStatus.Succeeded) - onSuccess() + if (data?.status === NodeRunningStatus.Succeeded) onSuccess() } const { mutateAsync: handleDatasourceSingleRun, isPending } = useDatasourceSingleRun() @@ -138,7 +140,7 @@ const useBeforeRunForm = ({ } if (datasourceType === DatasourceType.onlineDrive) { const { bucket, onlineDriveFileList, selectedFileIds } = dataSourceStore.getState() - const file = onlineDriveFileList.find(file => file.id === selectedFileIds[0]) + const file = onlineDriveFileList.find((file) => file.id === selectedFileIds[0]) datasourceInfo = { bucket, id: file?.id, @@ -147,41 +149,43 @@ const useBeforeRunForm = ({ } } let hasError = false - handleDatasourceSingleRun({ - pipeline_id: flowId, - start_node_id: nodeId, - start_node_title: datasourceNodeData.title, - datasource_type: datasourceType, - datasource_info: datasourceInfo, - }, { - onError: () => { - hasError = true - invalidLastRun() - if (isPausedRef.current) - return - handleNodeDataUpdate({ - id: nodeId, - data: { - ...payload, - _isSingleRun: false, - _singleRunningStatus: NodeRunningStatus.Failed, - }, - }) + handleDatasourceSingleRun( + { + pipeline_id: flowId, + start_node_id: nodeId, + start_node_title: datasourceNodeData.title, + datasource_type: datasourceType, + datasource_info: datasourceInfo, }, - onSettled: (data) => { - updateRunResult(data!) - if (!hasError && !isPausedRef.current) { + { + onError: () => { + hasError = true + invalidLastRun() + if (isPausedRef.current) return handleNodeDataUpdate({ id: nodeId, data: { ...payload, _isSingleRun: false, - _singleRunningStatus: NodeRunningStatus.Succeeded, + _singleRunningStatus: NodeRunningStatus.Failed, }, }) - } + }, + onSettled: (data) => { + updateRunResult(data!) + if (!hasError && !isPausedRef.current) { + handleNodeDataUpdate({ + id: nodeId, + data: { + ...payload, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Succeeded, + }, + }) + } + }, }, - }) + ) } const { handleSyncWorkflowDraft } = useNodesSyncDraft() diff --git a/web/app/components/workflow/nodes/data-source/hooks/use-config.ts b/web/app/components/workflow/nodes/data-source/hooks/use-config.ts index 08f66e40896b1e..0f4484d2bf0601 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/use-config.ts +++ b/web/app/components/workflow/nodes/data-source/hooks/use-config.ts @@ -1,12 +1,5 @@ -import type { - DataSourceNodeType, - ToolVarInputs, -} from '../types' -import { - useCallback, - useEffect, - useMemo, -} from 'react' +import type { DataSourceNodeType, ToolVarInputs } from '../types' +import { useCallback, useEffect, useMemo } from 'react' import { useStoreApi } from 'reactflow' import { useNodeDataUpdate } from '@/app/components/workflow/hooks' @@ -18,15 +11,18 @@ export const useConfig = (id: string, dataSourceList?: any[]) => { const { getNodes } = store.getState() const nodes = getNodes() - return nodes.find(node => node.id === id) + return nodes.find((node) => node.id === id) }, [store, id]) - const handleNodeDataUpdate = useCallback((data: Partial<DataSourceNodeType>) => { - handleNodeDataUpdateWithSyncDraft({ - id, - data, - }) - }, [id, handleNodeDataUpdateWithSyncDraft]) + const handleNodeDataUpdate = useCallback( + (data: Partial<DataSourceNodeType>) => { + handleNodeDataUpdateWithSyncDraft({ + id, + data, + }) + }, + [id, handleNodeDataUpdateWithSyncDraft], + ) const handleLocalFileDataSourceInit = useCallback(() => { const nodeData = getNodeData() @@ -43,34 +39,42 @@ export const useConfig = (id: string, dataSourceList?: any[]) => { handleLocalFileDataSourceInit() }, [handleLocalFileDataSourceInit]) - const handleFileExtensionsChange = useCallback((fileExtensions: string[]) => { - const nodeData = getNodeData() - handleNodeDataUpdate({ - ...nodeData?.data, - fileExtensions, - }) - }, [handleNodeDataUpdate, getNodeData]) + const handleFileExtensionsChange = useCallback( + (fileExtensions: string[]) => { + const nodeData = getNodeData() + handleNodeDataUpdate({ + ...nodeData?.data, + fileExtensions, + }) + }, + [handleNodeDataUpdate, getNodeData], + ) - const handleParametersChange = useCallback((datasource_parameters: ToolVarInputs) => { - const nodeData = getNodeData() - handleNodeDataUpdate({ - ...nodeData?.data, - datasource_parameters, - }) - }, [handleNodeDataUpdate, getNodeData]) + const handleParametersChange = useCallback( + (datasource_parameters: ToolVarInputs) => { + const nodeData = getNodeData() + handleNodeDataUpdate({ + ...nodeData?.data, + datasource_parameters, + }) + }, + [handleNodeDataUpdate, getNodeData], + ) const outputSchema = useMemo(() => { const nodeData = getNodeData() - if (!nodeData?.data || !dataSourceList) - return [] - - const currentDataSource = dataSourceList.find((ds: any) => ds.plugin_id === nodeData.data.plugin_id) - const currentDataSourceItem = currentDataSource?.tools?.find((tool: any) => tool.name === nodeData.data.datasource_name) + if (!nodeData?.data || !dataSourceList) return [] + + const currentDataSource = dataSourceList.find( + (ds: any) => ds.plugin_id === nodeData.data.plugin_id, + ) + const currentDataSourceItem = currentDataSource?.tools?.find( + (tool: any) => tool.name === nodeData.data.datasource_name, + ) const output_schema = currentDataSourceItem?.output_schema const res: any[] = [] - if (!output_schema || !output_schema.properties) - return res + if (!output_schema || !output_schema.properties) return res Object.keys(output_schema.properties).forEach((outputKey) => { const output = output_schema.properties[outputKey] @@ -80,13 +84,13 @@ export const useConfig = (id: string, dataSourceList?: any[]) => { name: outputKey, value: output, }) - } - else { + } else { res.push({ name: outputKey, - type: output.type === 'array' - ? `Array[${output.items?.type.slice(0, 1).toLocaleUpperCase()}${output.items?.type.slice(1)}]` - : `${output.type.slice(0, 1).toLocaleUpperCase()}${output.type.slice(1)}`, + type: + output.type === 'array' + ? `Array[${output.items?.type.slice(0, 1).toLocaleUpperCase()}${output.items?.type.slice(1)}]` + : `${output.type.slice(0, 1).toLocaleUpperCase()}${output.type.slice(1)}`, description: output.description, }) } @@ -96,18 +100,20 @@ export const useConfig = (id: string, dataSourceList?: any[]) => { const hasObjectOutput = useMemo(() => { const nodeData = getNodeData() - if (!nodeData?.data || !dataSourceList) - return false - - const currentDataSource = dataSourceList.find((ds: any) => ds.plugin_id === nodeData.data.plugin_id) - const currentDataSourceItem = currentDataSource?.tools?.find((tool: any) => tool.name === nodeData.data.datasource_name) + if (!nodeData?.data || !dataSourceList) return false + + const currentDataSource = dataSourceList.find( + (ds: any) => ds.plugin_id === nodeData.data.plugin_id, + ) + const currentDataSourceItem = currentDataSource?.tools?.find( + (tool: any) => tool.name === nodeData.data.datasource_name, + ) const output_schema = currentDataSourceItem?.output_schema - if (!output_schema || !output_schema.properties) - return false + if (!output_schema || !output_schema.properties) return false const properties = output_schema.properties - return Object.keys(properties).some(key => properties[key].type === 'object') + return Object.keys(properties).some((key) => properties[key].type === 'object') }, [getNodeData, dataSourceList]) return { diff --git a/web/app/components/workflow/nodes/data-source/node.tsx b/web/app/components/workflow/nodes/data-source/node.tsx index 4a9dc0ce6534ab..c2c430402f9d16 100644 --- a/web/app/components/workflow/nodes/data-source/node.tsx +++ b/web/app/components/workflow/nodes/data-source/node.tsx @@ -5,31 +5,20 @@ import { memo } from 'react' import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' -const Node: FC<NodeProps<DataSourceNodeType>> = ({ - data, -}) => { - const { - isChecking, - isMissing, - uniqueIdentifier, - canInstall, - onInstallSuccess, - } = useNodePluginInstallation(data) +const Node: FC<NodeProps<DataSourceNodeType>> = ({ data }) => { + const { isChecking, isMissing, uniqueIdentifier, canInstall, onInstallSuccess } = + useNodePluginInstallation(data) const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier - if (!showInstallButton) - return null + if (!showInstallButton) return null return ( <div className="relative mb-1 px-3 py-1"> <div className="pointer-events-auto absolute -top-8 right-3 z-40"> <InstallPluginButton size="small" - extraIdentifiers={[ - data.plugin_id, - data.provider_name, - ].filter(Boolean) as string[]} + extraIdentifiers={[data.plugin_id, data.provider_name].filter(Boolean) as string[]} className="font-medium! text-text-accent!" uniqueIdentifier={uniqueIdentifier!} onSuccess={onInstallSuccess} diff --git a/web/app/components/workflow/nodes/data-source/panel.tsx b/web/app/components/workflow/nodes/data-source/panel.tsx index 72c3bb94ad30c5..9d9f10dc016f59 100644 --- a/web/app/components/workflow/nodes/data-source/panel.tsx +++ b/web/app/components/workflow/nodes/data-source/panel.tsx @@ -1,160 +1,141 @@ import type { FC } from 'react' import type { DataSourceNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import TagInput from '@/app/components/base/tag-input' import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { useNodesReadOnly } from '@/app/components/workflow/hooks' -import { - BoxGroupField, -} from '@/app/components/workflow/nodes/_base/components/layout' +import { BoxGroupField } from '@/app/components/workflow/nodes/_base/components/layout' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' import StructureOutputItem from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show' import { useStore } from '@/app/components/workflow/store' import { wrapStructuredVarItem } from '@/app/components/workflow/utils/tool' -import useMatchSchemaType, { getMatchedSchemaType } from '../_base/components/variable/use-match-schema-type' +import useMatchSchemaType, { + getMatchedSchemaType, +} from '../_base/components/variable/use-match-schema-type' import ToolForm from '../tool/components/tool-form' -import { - COMMON_OUTPUT, - LOCAL_FILE_OUTPUT, -} from './constants' +import { COMMON_OUTPUT, LOCAL_FILE_OUTPUT } from './constants' import { useConfig } from './hooks/use-config' import { DataSourceClassification } from './types' const Panel: FC<NodePanelProps<DataSourceNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { nodesReadOnly } = useNodesReadOnly() - const dataSourceList = useStore(s => s.dataSourceList) - const { - provider_type, - plugin_id, - fileExtensions = [], - datasource_parameters, - } = data - const { - handleFileExtensionsChange, - handleParametersChange, - outputSchema, - hasObjectOutput, - } = useConfig(id, dataSourceList) + const dataSourceList = useStore((s) => s.dataSourceList) + const { provider_type, plugin_id, fileExtensions = [], datasource_parameters } = data + const { handleFileExtensionsChange, handleParametersChange, outputSchema, hasObjectOutput } = + useConfig(id, dataSourceList) const isLocalFile = provider_type === DataSourceClassification.localFile - const currentDataSource = dataSourceList?.find(ds => ds.plugin_id === plugin_id) - const currentDataSourceItem: any = currentDataSource?.tools?.find((tool: any) => tool.name === data.datasource_name) + const currentDataSource = dataSourceList?.find((ds) => ds.plugin_id === plugin_id) + const currentDataSourceItem: any = currentDataSource?.tools?.find( + (tool: any) => tool.name === data.datasource_name, + ) const formSchemas = useMemo(() => { - return currentDataSourceItem ? toolParametersToFormSchemas(currentDataSourceItem.parameters) : [] + return currentDataSourceItem + ? toolParametersToFormSchemas(currentDataSourceItem.parameters) + : [] }, [currentDataSourceItem]) - const pipelineId = useStore(s => s.pipelineId) - const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel) + const pipelineId = useStore((s) => s.pipelineId) + const setShowInputFieldPanel = useStore((s) => s.setShowInputFieldPanel) const { schemaTypeDefinitions } = useMatchSchemaType() return ( <div> - { - currentDataSource?.is_authorized && !isLocalFile && !!formSchemas?.length && ( - <BoxGroupField - boxGroupProps={{ - boxProps: { withBorderBottom: true }, - }} - fieldProps={{ - fieldTitleProps: { - title: t($ => $['nodes.tool.inputVars'], { ns: 'workflow' }), - }, - supportCollapse: true, - }} - > - {formSchemas.length > 0 && ( - <ToolForm - readOnly={nodesReadOnly} - nodeId={id} - schema={formSchemas as any} - value={datasource_parameters} - onChange={handleParametersChange} - currentProvider={currentDataSource} - currentTool={currentDataSourceItem} - showManageInputField={!!pipelineId} - onManageInputField={() => setShowInputFieldPanel?.(true)} - /> - )} - </BoxGroupField> - ) - } - { - isLocalFile && ( - <BoxGroupField - boxGroupProps={{ - boxProps: { withBorderBottom: true }, - }} - fieldProps={{ - fieldTitleProps: { - title: t($ => $['nodes.dataSource.supportedFileFormats'], { ns: 'workflow' }), - }, - }} - > - <div className="rounded-lg bg-components-input-bg-normal p-1 pt-0"> - <TagInput - items={fileExtensions} - onChange={handleFileExtensionsChange} - placeholder={t($ => $['nodes.dataSource.supportedFileFormatsPlaceholder'], { ns: 'workflow' })} - inputClassName="bg-transparent" - disableAdd={nodesReadOnly} - disableRemove={nodesReadOnly} - /> - </div> - </BoxGroupField> - ) - } - <OutputVars> - { - COMMON_OUTPUT.map((item, index) => ( - <VarItem - key={index} - name={item.name} - type={item.type} - description={item.description} - isIndent={hasObjectOutput} + {currentDataSource?.is_authorized && !isLocalFile && !!formSchemas?.length && ( + <BoxGroupField + boxGroupProps={{ + boxProps: { withBorderBottom: true }, + }} + fieldProps={{ + fieldTitleProps: { + title: t(($) => $['nodes.tool.inputVars'], { ns: 'workflow' }), + }, + supportCollapse: true, + }} + > + {formSchemas.length > 0 && ( + <ToolForm + readOnly={nodesReadOnly} + nodeId={id} + schema={formSchemas as any} + value={datasource_parameters} + onChange={handleParametersChange} + currentProvider={currentDataSource} + currentTool={currentDataSourceItem} + showManageInputField={!!pipelineId} + onManageInputField={() => setShowInputFieldPanel?.(true)} + /> + )} + </BoxGroupField> + )} + {isLocalFile && ( + <BoxGroupField + boxGroupProps={{ + boxProps: { withBorderBottom: true }, + }} + fieldProps={{ + fieldTitleProps: { + title: t(($) => $['nodes.dataSource.supportedFileFormats'], { ns: 'workflow' }), + }, + }} + > + <div className="rounded-lg bg-components-input-bg-normal p-1 pt-0"> + <TagInput + items={fileExtensions} + onChange={handleFileExtensionsChange} + placeholder={t(($) => $['nodes.dataSource.supportedFileFormatsPlaceholder'], { + ns: 'workflow', + })} + inputClassName="bg-transparent" + disableAdd={nodesReadOnly} + disableRemove={nodesReadOnly} /> - )) - } - { - isLocalFile && LOCAL_FILE_OUTPUT.map((item, index) => ( + </div> + </BoxGroupField> + )} + <OutputVars> + {COMMON_OUTPUT.map((item, index) => ( + <VarItem + key={index} + name={item.name} + type={item.type} + description={item.description} + isIndent={hasObjectOutput} + /> + ))} + {isLocalFile && + LOCAL_FILE_OUTPUT.map((item, index) => ( <VarItem key={index} name={item.name} type={item.type} description={item.description} - subItems={item.subItems.map(item => ({ + subItems={item.subItems.map((item) => ({ name: item.name, type: item.type, description: item.description, }))} /> - )) - } + ))} {outputSchema.map((outputItem) => { const schemaType = getMatchedSchemaType(outputItem.value, schemaTypeDefinitions) return ( <div key={outputItem.name}> - {outputItem.value?.type === 'object' - ? ( - <StructureOutputItem - rootClassName="code-sm-semibold text-text-secondary" - payload={wrapStructuredVarItem(outputItem, schemaType)} - /> - ) - : ( - <VarItem - name={outputItem.name} - - type={`${outputItem.type.toLocaleLowerCase()}${schemaType ? ` (${schemaType})` : ''}`} - description={outputItem.description} - isIndent={hasObjectOutput} - /> - )} + {outputItem.value?.type === 'object' ? ( + <StructureOutputItem + rootClassName="code-sm-semibold text-text-secondary" + payload={wrapStructuredVarItem(outputItem, schemaType)} + /> + ) : ( + <VarItem + name={outputItem.name} + type={`${outputItem.type.toLocaleLowerCase()}${schemaType ? ` (${schemaType})` : ''}`} + description={outputItem.description} + isIndent={hasObjectOutput} + /> + )} </div> ) })} diff --git a/web/app/components/workflow/nodes/data-source/utils.ts b/web/app/components/workflow/nodes/data-source/utils.ts index 94c1f81ec81668..dce9c38771c709 100644 --- a/web/app/components/workflow/nodes/data-source/utils.ts +++ b/web/app/components/workflow/nodes/data-source/utils.ts @@ -2,10 +2,14 @@ import { VarType } from '@/app/components/workflow/types' import { PipelineInputVarType } from '@/models/pipeline' export const inputVarTypeToVarType = (type: PipelineInputVarType): VarType => { - return ({ - [PipelineInputVarType.number]: VarType.number, - [PipelineInputVarType.singleFile]: VarType.file, - [PipelineInputVarType.multiFiles]: VarType.arrayFile, - [PipelineInputVarType.checkbox]: VarType.boolean, - } as any)[type] || VarType.string + return ( + ( + { + [PipelineInputVarType.number]: VarType.number, + [PipelineInputVarType.singleFile]: VarType.file, + [PipelineInputVarType.multiFiles]: VarType.arrayFile, + [PipelineInputVarType.checkbox]: VarType.boolean, + } as any + )[type] || VarType.string + ) } diff --git a/web/app/components/workflow/nodes/document-extractor/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/document-extractor/__tests__/integration.spec.tsx index 593bdd06649e91..d9563c3361099b 100644 --- a/web/app/components/workflow/nodes/document-extractor/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/document-extractor/__tests__/integration.spec.tsx @@ -41,7 +41,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-labe vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ __esModule: true, - default: ({ title, children }: { title: ReactNode, children: ReactNode }) => ( + default: ({ title, children }: { title: ReactNode; children: ReactNode }) => ( <div> <div>{title}</div> {children} @@ -52,7 +52,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <div>{children}</div>, - VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>, + VarItem: ({ name, type }: { name: string; type: string }) => <div>{`${name}:${type}`}</div>, })) vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ @@ -62,11 +62,11 @@ vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ __esModule: true, - default: ({ - onChange, - }: { - onChange: (value: string[]) => void - }) => <button type="button" onClick={() => onChange(['node-1', 'files'])}>pick-file-var</button>, + default: ({ onChange }: { onChange: (value: string[]) => void }) => ( + <button type="button" onClick={() => onChange(['node-1', 'files'])}> + pick-file-var + </button> + ), })) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-help-link', () => ({ @@ -101,7 +101,9 @@ const createData = (overrides: Partial<DocExtractorNodeType> = {}): DocExtractor ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), handleVarChanges: vi.fn(), @@ -139,12 +141,7 @@ describe('document-extractor path', () => { }) it('should render the selected input variable on the node', () => { - render( - <Node - id="doc-node" - data={createData()} - />, - ) + render(<Node id="doc-node" data={createData()} />) expect(screen.getByText('workflow.nodes.docExtractor.inputVar'))!.toBeInTheDocument() expect(screen.getByText('Input Files:start:node-1.files'))!.toBeInTheDocument() @@ -154,39 +151,40 @@ describe('document-extractor path', () => { const user = userEvent.setup() const handleVarChanges = vi.fn() - mockUseConfig.mockReturnValueOnce(createConfigResult({ - inputs: createData({ - is_array_file: false, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + inputs: createData({ + is_array_file: false, + }), + handleVarChanges, }), - handleVarChanges, - })) - - render( - <Panel - id="doc-node" - data={createData()} - panelProps={panelProps} - />, ) + render(<Panel id="doc-node" data={createData()} panelProps={panelProps} />) + await user.click(screen.getByRole('button', { name: 'pick-file-var' })) expect(handleVarChanges).toHaveBeenCalledWith(['node-1', 'files']) - expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf, markdown, docx"}'))!.toBeInTheDocument() - expect(screen.getByRole('link', { name: 'workflow.nodes.docExtractor.learnMore' }))!.toHaveAttribute( - 'href', - 'https://docs.example.com/document-extractor', - ) + expect( + screen.getByText( + 'workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf, markdown, docx"}', + ), + )!.toBeInTheDocument() + expect( + screen.getByRole('link', { name: 'workflow.nodes.docExtractor.learnMore' }), + )!.toHaveAttribute('href', 'https://docs.example.com/document-extractor') expect(screen.getByText('text:string'))!.toBeInTheDocument() }) it('should use chinese separators and array output types when the input is an array of files', () => { mockLocale = LanguagesSupported[1]! - mockUseConfig.mockReturnValueOnce(createConfigResult({ - inputs: createData({ - is_array_file: true, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + inputs: createData({ + is_array_file: true, + }), }), - })) + ) render( <Panel @@ -198,7 +196,11 @@ describe('document-extractor path', () => { />, ) - expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf、 markdown、 docx"}'))!.toBeInTheDocument() + expect( + screen.getByText( + 'workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf、 markdown、 docx"}', + ), + )!.toBeInTheDocument() expect(screen.getByText('text:array[string]'))!.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/document-extractor/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/document-extractor/__tests__/node.spec.tsx index 2044d7e6b9bc19..673f1ee23a583b 100644 --- a/web/app/components/workflow/nodes/document-extractor/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/document-extractor/__tests__/node.spec.tsx @@ -51,22 +51,14 @@ describe('document-extractor/node', () => { it('renders nothing when no input variable is configured', () => { const { container } = render( - <Node - id="doc-node" - data={createData({ variable_selector: [] })} - />, + <Node id="doc-node" data={createData({ variable_selector: [] })} />, ) expect(container).toBeEmptyDOMElement() }) it('renders the selected input variable label', () => { - render( - <Node - id="doc-node" - data={createData()} - />, - ) + render(<Node id="doc-node" data={createData()} />) expect(screen.getByText('workflow.nodes.docExtractor.inputVar')).toBeInTheDocument() expect(screen.getByText('Input Files:start:node-1.files')).toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/document-extractor/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/document-extractor/__tests__/panel.spec.tsx index 7e27c2379f444d..8048cae2851538 100644 --- a/web/app/components/workflow/nodes/document-extractor/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/document-extractor/__tests__/panel.spec.tsx @@ -12,7 +12,7 @@ let mockLocale = 'en-US' vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ __esModule: true, - default: ({ title, children }: { title: ReactNode, children: ReactNode }) => ( + default: ({ title, children }: { title: ReactNode; children: ReactNode }) => ( <div> <div>{title}</div> {children} @@ -23,7 +23,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <div>{children}</div>, - VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>, + VarItem: ({ name, type }: { name: string; type: string }) => <div>{`${name}:${type}`}</div>, })) vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ @@ -33,11 +33,11 @@ vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ __esModule: true, - default: ({ - onChange, - }: { - onChange: (value: string[]) => void - }) => <button type="button" onClick={() => onChange(['node-1', 'files'])}>pick-file-var</button>, + default: ({ onChange }: { onChange: (value: string[]) => void }) => ( + <button type="button" onClick={() => onChange(['node-1', 'files'])}> + pick-file-var + </button> + ), })) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-help-link', () => ({ @@ -72,7 +72,9 @@ const createData = (overrides: Partial<DocExtractorNodeType> = {}): DocExtractor ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), handleVarChanges: vi.fn(), @@ -100,45 +102,46 @@ describe('document-extractor/panel', () => { const user = userEvent.setup() const handleVarChanges = vi.fn() - mockUseConfig.mockReturnValueOnce(createConfigResult({ - inputs: createData({ is_array_file: false }), - handleVarChanges, - })) - - render( - <Panel - id="doc-node" - data={createData()} - panelProps={panelProps} - />, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + inputs: createData({ is_array_file: false }), + handleVarChanges, + }), ) + render(<Panel id="doc-node" data={createData()} panelProps={panelProps} />) + await user.click(screen.getByRole('button', { name: 'pick-file-var' })) expect(handleVarChanges).toHaveBeenCalledWith(['node-1', 'files']) - expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf, markdown, docx"}'))!.toBeInTheDocument() - expect(screen.getByRole('link', { name: 'workflow.nodes.docExtractor.learnMore' }))!.toHaveAttribute( - 'href', - 'https://docs.example.com/document-extractor', - ) + expect( + screen.getByText( + 'workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf, markdown, docx"}', + ), + )!.toBeInTheDocument() + expect( + screen.getByRole('link', { name: 'workflow.nodes.docExtractor.learnMore' }), + )!.toHaveAttribute('href', 'https://docs.example.com/document-extractor') expect(screen.getByText('text:string'))!.toBeInTheDocument() }) it('uses chinese separators and array output types when the input is an array of files', () => { mockLocale = LanguagesSupported[1]! - mockUseConfig.mockReturnValueOnce(createConfigResult({ - inputs: createData({ is_array_file: true }), - })) + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + inputs: createData({ is_array_file: true }), + }), + ) render( - <Panel - id="doc-node" - data={createData({ is_array_file: true })} - panelProps={panelProps} - />, + <Panel id="doc-node" data={createData({ is_array_file: true })} panelProps={panelProps} />, ) - expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf、 markdown、 docx"}'))!.toBeInTheDocument() + expect( + screen.getByText( + 'workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf、 markdown、 docx"}', + ), + )!.toBeInTheDocument() expect(screen.getByText('text:array[string]'))!.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/document-extractor/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/document-extractor/__tests__/use-config.spec.ts index d988b2751d3b44..a86e9c194ea62a 100644 --- a/web/app/components/workflow/nodes/document-extractor/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/document-extractor/__tests__/use-config.spec.ts @@ -83,10 +83,12 @@ describe('document-extractor/use-config', () => { result.current.handleVarChanges(['node-2', 'files']) expect(getCurrentVariableType).toHaveBeenCalled() - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - variable_selector: ['node-2', 'files'], - is_array_file: true, - })) + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + variable_selector: ['node-2', 'files'], + is_array_file: true, + }), + ) }) it('only accepts file variables in the picker filter', () => { diff --git a/web/app/components/workflow/nodes/document-extractor/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/document-extractor/__tests__/use-single-run-form-params.spec.ts index 8c44884e987ac9..f2a6745ef283cb 100644 --- a/web/app/components/workflow/nodes/document-extractor/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/document-extractor/__tests__/use-single-run-form-params.spec.ts @@ -16,15 +16,17 @@ describe('document-extractor/use-single-run-form-params', () => { it('exposes a single files form and updates run input values', () => { const setRunInputData = vi.fn() - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'doc-node', - payload: createData(), - runInputData: { files: ['old-file'] }, - runInputDataRef: { current: {} }, - getInputVars: () => [], - setRunInputData, - toVarInputs: () => [], - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'doc-node', + payload: createData(), + runInputData: { files: ['old-file'] }, + runInputDataRef: { current: {} }, + getInputVars: () => [], + setRunInputData, + toVarInputs: () => [], + }), + ) expect(result.current.forms).toHaveLength(1) expect(result.current.forms[0]!.inputs).toEqual([ diff --git a/web/app/components/workflow/nodes/document-extractor/default.ts b/web/app/components/workflow/nodes/document-extractor/default.ts index 39571e41ae5cce..7cc413d856bc9a 100644 --- a/web/app/components/workflow/nodes/document-extractor/default.ts +++ b/web/app/components/workflow/nodes/document-extractor/default.ts @@ -24,7 +24,10 @@ const nodeDefault: NodeDefault<DocExtractorNodeType> = { const { variable_selector: variable } = payload if (!errorMessages && !variable?.length) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.assigner.assignedVariable'], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.assigner.assignedVariable'], { ns: 'workflow' }), + }) return { isValid: !errorMessages, diff --git a/web/app/components/workflow/nodes/document-extractor/node.tsx b/web/app/components/workflow/nodes/document-extractor/node.tsx index 261b25bbad14d8..0a0b8ec4e3a0d9 100644 --- a/web/app/components/workflow/nodes/document-extractor/node.tsx +++ b/web/app/components/workflow/nodes/document-extractor/node.tsx @@ -5,29 +5,28 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import { isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' -import { - VariableLabelInNode, -} from '@/app/components/workflow/nodes/_base/components/variable/variable-label' +import { VariableLabelInNode } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { BlockEnum } from '@/app/components/workflow/types' const i18nPrefix = 'nodes.docExtractor' -const NodeComponent: FC<NodeProps<DocExtractorNodeType>> = ({ - data, -}) => { +const NodeComponent: FC<NodeProps<DocExtractorNodeType>> = ({ data }) => { const { t } = useTranslation() const nodes: Node[] = useNodes() const { variable_selector: variable } = data - if (!variable || variable.length === 0) - return null + if (!variable || variable.length === 0) return null const isSystem = isSystemVar(variable) - const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === variable[0]) + const node = isSystem + ? nodes.find((node) => node.data.type === BlockEnum.Start) + : nodes.find((node) => node.id === variable[0]) return ( <div className="relative mb-1 px-3 py-1"> - <div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">{t($ => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })}</div> + <div className="mb-1 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })} + </div> <VariableLabelInNode variables={variable} nodeType={node?.data.type} diff --git a/web/app/components/workflow/nodes/document-extractor/panel.tsx b/web/app/components/workflow/nodes/document-extractor/panel.tsx index fe5a010d104e4b..e4d2bd3bd62eb7 100644 --- a/web/app/components/workflow/nodes/document-extractor/panel.tsx +++ b/web/app/components/workflow/nodes/document-extractor/panel.tsx @@ -16,10 +16,7 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.docExtractor' -const Panel: FC<NodePanelProps<DocExtractorNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<DocExtractorNodeType>> = ({ id, data }) => { const { t } = useTranslation() const locale = useLocale() const link = useNodeHelpLink(BlockEnum.DocExtractor) @@ -35,25 +32,17 @@ const Panel: FC<NodePanelProps<DocExtractorNodeType>> = ({ } return [...supportTypes] - .map(item => extensionMap[item] || item) // map to standardized extension - .map(item => item.toLowerCase()) // convert to lower case + .map((item) => extensionMap[item] || item) // map to standardized extension + .map((item) => item.toLowerCase()) // convert to lower case .filter((item, index, self) => self.indexOf(item) === index) // remove duplicates .join(locale !== LanguagesSupported[1] ? ', ' : '、 ') })() - const { - readOnly, - inputs, - handleVarChanges, - filterVar, - } = useConfig(id, data) + const { readOnly, inputs, handleVarChanges, filterVar } = useConfig(id, data) return ( <div className="mt-2"> <div className="space-y-4 px-4 pb-4"> - <Field - title={t($ => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })} - required - > + <Field title={t(($) => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })} required> <> <VarReferencePicker readonly={readOnly} @@ -65,8 +54,13 @@ const Panel: FC<NodePanelProps<DocExtractorNodeType>> = ({ typePlaceHolder="File | Array[File]" /> <div className="mt-1 py-0.5 body-xs-regular text-text-tertiary"> - {t($ => $[`${i18nPrefix}.supportFileTypes`], { ns: 'workflow', types: supportTypesShowNames })} - <a className="text-text-accent" href={link} target="_blank" rel="noopener noreferrer">{t($ => $[`${i18nPrefix}.learnMore`], { ns: 'workflow' })}</a> + {t(($) => $[`${i18nPrefix}.supportFileTypes`], { + ns: 'workflow', + types: supportTypesShowNames, + })} + <a className="text-text-accent" href={link} target="_blank" rel="noopener noreferrer"> + {t(($) => $[`${i18nPrefix}.learnMore`], { ns: 'workflow' })} + </a> </div> </> </Field> @@ -77,7 +71,7 @@ const Panel: FC<NodePanelProps<DocExtractorNodeType>> = ({ <VarItem name="text" type={inputs.is_array_file ? 'array[string]' : 'string'} - description={t($ => $[`${i18nPrefix}.outputVars.text`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.text`], { ns: 'workflow' })} /> </OutputVars> </div> diff --git a/web/app/components/workflow/nodes/document-extractor/use-config.ts b/web/app/components/workflow/nodes/document-extractor/use-config.ts index b5ff863821c541..9ad307cbd94f32 100644 --- a/web/app/components/workflow/nodes/document-extractor/use-config.ts +++ b/web/app/components/workflow/nodes/document-extractor/use-config.ts @@ -24,37 +24,43 @@ const useConfig = (id: string, payload: DocExtractorNodeType) => { const store = useStoreApi() const { getBeforeNodesInSameBranch } = useWorkflow() - const { - getNodes, - } = store.getState() - const currentNode = getNodes().find(n => n.id === id) + const { getNodes } = store.getState() + const currentNode = getNodes().find((n) => n.id === id) const isInIteration = payload.isInIteration - const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null + const iterationNode = isInIteration + ? getNodes().find((n) => n.id === currentNode!.parentId) + : null const isInLoop = payload.isInLoop - const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null + const loopNode = isInLoop ? getNodes().find((n) => n.id === currentNode!.parentId) : null const availableNodes = useMemo(() => { return getBeforeNodesInSameBranch(id) }, [getBeforeNodesInSameBranch, id]) const { getCurrentVariableType } = useWorkflowVariables() - const getType = useCallback((variable?: ValueSelector) => { - const varType = getCurrentVariableType({ - parentNode: isInIteration ? iterationNode : loopNode, - valueSelector: variable || [], - availableNodes, - isChatMode, - isConstant: false, - }) - return varType - }, [getCurrentVariableType, isInIteration, availableNodes, isChatMode, iterationNode, loopNode]) + const getType = useCallback( + (variable?: ValueSelector) => { + const varType = getCurrentVariableType({ + parentNode: isInIteration ? iterationNode : loopNode, + valueSelector: variable || [], + availableNodes, + isChatMode, + isConstant: false, + }) + return varType + }, + [getCurrentVariableType, isInIteration, availableNodes, isChatMode, iterationNode, loopNode], + ) - const handleVarChanges = useCallback((variable: ValueSelector | string) => { - const newInputs = produce(inputs, (draft) => { - draft.variable_selector = variable as ValueSelector - draft.is_array_file = getType(draft.variable_selector) === VarType.arrayFile - }) - setInputs(newInputs) - }, [getType, inputs, setInputs]) + const handleVarChanges = useCallback( + (variable: ValueSelector | string) => { + const newInputs = produce(inputs, (draft) => { + draft.variable_selector = variable as ValueSelector + draft.is_array_file = getType(draft.variable_selector) === VarType.arrayFile + }) + setInputs(newInputs) + }, + [getType, inputs, setInputs], + ) return { readOnly, diff --git a/web/app/components/workflow/nodes/document-extractor/use-single-run-form-params.ts b/web/app/components/workflow/nodes/document-extractor/use-single-run-form-params.ts index 82117a62295916..f2261aac714ec3 100644 --- a/web/app/components/workflow/nodes/document-extractor/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/document-extractor/use-single-run-form-params.ts @@ -16,29 +16,30 @@ type Params = { setRunInputData: (data: Record<string, any>) => void toVarInputs: (variables: Variable[]) => InputVar[] } -const useSingleRunFormParams = ({ - payload, - runInputData, - setRunInputData, -}: Params) => { +const useSingleRunFormParams = ({ payload, runInputData, setRunInputData }: Params) => { const { t } = useTranslation() const files = runInputData.files - const setFiles = useCallback((newFiles: []) => { - setRunInputData({ - ...runInputData, - files: newFiles, - }) - }, [runInputData, setRunInputData]) + const setFiles = useCallback( + (newFiles: []) => { + setRunInputData({ + ...runInputData, + files: newFiles, + }) + }, + [runInputData, setRunInputData], + ) const forms = useMemo(() => { return [ { - inputs: [{ - label: t($ => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })!, - variable: 'files', - type: InputVarType.multiFiles, - required: true, - }], + inputs: [ + { + label: t(($) => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })!, + variable: 'files', + type: InputVarType.multiFiles, + required: true, + }, + ], values: { files }, onChange: (keyValue: Record<string, any>) => setFiles(keyValue.files), }, @@ -50,8 +51,7 @@ const useSingleRunFormParams = ({ } const getDependentVar = (variable: string) => { - if (variable === 'files') - return payload.variable_selector + if (variable === 'files') return payload.variable_selector } return { diff --git a/web/app/components/workflow/nodes/end/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/end/__tests__/node.spec.tsx index de5e819267f720..988143b7c0e15a 100644 --- a/web/app/components/workflow/nodes/end/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/end/__tests__/node.spec.tsx @@ -2,11 +2,7 @@ import type { EndNodeType } from '../types' import { screen } from '@testing-library/react' import { createNode, createStartNode } from '@/app/components/workflow/__tests__/fixtures' import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env' -import { - useIsChatMode, - useWorkflow, - useWorkflowVariables, -} from '@/app/components/workflow/hooks' +import { useIsChatMode, useWorkflow, useWorkflowVariables } from '@/app/components/workflow/hooks' import { BlockEnum } from '@/app/components/workflow/types' import Node from '../node' @@ -28,10 +24,12 @@ const createNodeData = (overrides: Partial<EndNodeType> = {}): EndNodeType => ({ title: 'End', desc: '', type: BlockEnum.End, - outputs: [{ - variable: 'answer', - value_selector: ['source-node', 'answer'], - }], + outputs: [ + { + variable: 'answer', + value_selector: ['source-node', 'answer'], + }, + ], ...overrides, }) @@ -68,24 +66,34 @@ describe('EndNode', () => { }) it('should fall back to the start node when the selector node cannot be found', () => { - renderNodeComponent(Node, createNodeData({ - outputs: [{ - variable: 'answer', - value_selector: ['missing-node', 'answer'], - }], - })) + renderNodeComponent( + Node, + createNodeData({ + outputs: [ + { + variable: 'answer', + value_selector: ['missing-node', 'answer'], + }, + ], + }), + ) expect(screen.getByText('Start')).toBeInTheDocument() expect(screen.getByText('answer')).toBeInTheDocument() }) it('should render nothing when every output selector is empty', () => { - const { container } = renderNodeComponent(Node, createNodeData({ - outputs: [{ - variable: 'answer', - value_selector: [], - }], - })) + const { container } = renderNodeComponent( + Node, + createNodeData({ + outputs: [ + { + variable: 'answer', + value_selector: [], + }, + ], + }), + ) expect(container).toBeEmptyDOMElement() }) diff --git a/web/app/components/workflow/nodes/end/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/end/__tests__/panel.spec.tsx index 4340d341ff3293..273850ae1d3072 100644 --- a/web/app/components/workflow/nodes/end/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/end/__tests__/panel.spec.tsx @@ -38,7 +38,11 @@ describe('EndPanel', () => { expect(screen.getByText('workflow.nodes.end.output.variable')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.end.output.variable' })) + fireEvent.click( + screen.getByRole('button', { + name: 'common.operation.add workflow.nodes.end.output.variable', + }), + ) expect(handleAddVariable).toHaveBeenCalledTimes(1) }) @@ -53,6 +57,10 @@ describe('EndPanel', () => { render(<Panel id="end-node" data={createData()} panelProps={{} as PanelProps} />) - expect(screen.queryByRole('button', { name: 'common.operation.add workflow.nodes.end.output.variable' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'common.operation.add workflow.nodes.end.output.variable', + }), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/end/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/end/__tests__/use-config.spec.ts index a9d7def1445cd6..1ea561177e6db0 100644 --- a/web/app/components/workflow/nodes/end/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/end/__tests__/use-config.spec.ts @@ -54,23 +54,29 @@ describe('end/use-config', () => { const { result } = renderHook(() => useConfig('end-node', currentInputs)) const config = mockUseVarList.mock.calls[0]![0] as { setInputs: (inputs: EndNodeType) => void } - expect(mockUseVarList).toHaveBeenCalledWith(expect.objectContaining({ - inputs: currentInputs, - setInputs: expect.any(Function), - varKey: 'outputs', - })) + expect(mockUseVarList).toHaveBeenCalledWith( + expect.objectContaining({ + inputs: currentInputs, + setInputs: expect.any(Function), + varKey: 'outputs', + }), + ) expect(result.current.readOnly).toBe(true) expect(result.current.handleVarListChange).toBe(mockHandleVarListChange) expect(result.current.handleAddVariable).toBe(mockHandleAddVariable) act(() => { - config.setInputs(createPayload({ - outputs: currentInputs.outputs, - })) + config.setInputs( + createPayload({ + outputs: currentInputs.outputs, + }), + ) }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - outputs: currentInputs.outputs, - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + outputs: currentInputs.outputs, + }), + ) }) }) diff --git a/web/app/components/workflow/nodes/end/default.ts b/web/app/components/workflow/nodes/end/default.ts index 6dc98723d31ac3..06e54d379ba457 100644 --- a/web/app/components/workflow/nodes/end/default.ts +++ b/web/app/components/workflow/nodes/end/default.ts @@ -20,9 +20,11 @@ const nodeDefault: NodeDefault<EndNodeType> = { let errorMessage = '' if (!outputs.length) { - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: t($ => $['nodes.end.output.variable'], { ns: 'workflow' }) }) - } - else { + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: t(($) => $['nodes.end.output.variable'], { ns: 'workflow' }), + }) + } else { const invalidOutput = outputs.find((output) => { const variableName = output.variable?.trim() const hasSelector = Array.isArray(output.value_selector) && output.value_selector.length > 0 @@ -30,7 +32,10 @@ const nodeDefault: NodeDefault<EndNodeType> = { }) if (invalidOutput) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: t($ => $['nodes.end.output.variable'], { ns: 'workflow' }) }) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: t(($) => $['nodes.end.output.variable'], { ns: 'workflow' }), + }) } return { diff --git a/web/app/components/workflow/nodes/end/node.tsx b/web/app/components/workflow/nodes/end/node.tsx index dbcb258a80a831..95374c96312136 100644 --- a/web/app/components/workflow/nodes/end/node.tsx +++ b/web/app/components/workflow/nodes/end/node.tsx @@ -2,20 +2,11 @@ import type { FC } from 'react' import type { EndNodeType } from './types' import type { NodeProps, Variable } from '@/app/components/workflow/types' import * as React from 'react' -import { - useIsChatMode, - useWorkflow, - useWorkflowVariables, -} from '@/app/components/workflow/hooks' -import { - VariableLabelInNode, -} from '@/app/components/workflow/nodes/_base/components/variable/variable-label' +import { useIsChatMode, useWorkflow, useWorkflowVariables } from '@/app/components/workflow/hooks' +import { VariableLabelInNode } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { BlockEnum } from '@/app/components/workflow/types' -const Node: FC<NodeProps<EndNodeType>> = ({ - id, - data, -}) => { +const Node: FC<NodeProps<EndNodeType>> = ({ id, data }) => { const { getBeforeNodesInSameBranch } = useWorkflow() const availableNodes = getBeforeNodesInSameBranch(id) const { getCurrentVariableType } = useWorkflowVariables() @@ -26,14 +17,15 @@ const Node: FC<NodeProps<EndNodeType>> = ({ }) const getNode = (id: string) => { - return availableNodes.find(node => node.id === id) || startNode + return availableNodes.find((node) => node.id === id) || startNode } const { outputs } = data - const filteredOutputs = (outputs as Variable[]).filter(({ value_selector }) => value_selector.length > 0) + const filteredOutputs = (outputs as Variable[]).filter( + ({ value_selector }) => value_selector.length > 0, + ) - if (!filteredOutputs.length) - return null + if (!filteredOutputs.length) return null return ( <div className="mb-1 space-y-0.5 px-3 py-1"> @@ -55,7 +47,6 @@ const Node: FC<NodeProps<EndNodeType>> = ({ /> ) })} - </div> ) } diff --git a/web/app/components/workflow/nodes/end/panel.tsx b/web/app/components/workflow/nodes/end/panel.tsx index b2e9bc6f0e75a9..03cb50a3aab672 100644 --- a/web/app/components/workflow/nodes/end/panel.tsx +++ b/web/app/components/workflow/nodes/end/panel.tsx @@ -9,48 +9,32 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.end' -const Panel: FC<NodePanelProps<EndNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<EndNodeType>> = ({ id, data }) => { const { t } = useTranslation() - const { - readOnly, - inputs, - handleVarListChange, - handleAddVariable, - } = useConfig(id, data) + const { readOnly, inputs, handleVarListChange, handleAddVariable } = useConfig(id, data) const outputs = inputs.outputs return ( <div className="mt-2"> <div className="space-y-4 px-4 pb-4"> - <Field - title={t($ => $[`${i18nPrefix}.output.variable`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.output.variable`], { ns: 'workflow' })} required operations={ - !readOnly - ? ( - <button - type="button" - aria-label={`${t($ => $['operation.add'], { ns: 'common' })} ${t($ => $[`${i18nPrefix}.output.variable`], { ns: 'workflow' })}`} - className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={handleAddVariable} - > - <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> - </button> - ) - : undefined + !readOnly ? ( + <button + type="button" + aria-label={`${t(($) => $['operation.add'], { ns: 'common' })} ${t(($) => $[`${i18nPrefix}.output.variable`], { ns: 'workflow' })}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleAddVariable} + > + <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> + </button> + ) : undefined } > - <VarList - nodeId={id} - readonly={readOnly} - list={outputs} - onChange={handleVarListChange} - /> + <VarList nodeId={id} readonly={readOnly} list={outputs} onChange={handleVarListChange} /> </Field> </div> </div> diff --git a/web/app/components/workflow/nodes/end/use-config.ts b/web/app/components/workflow/nodes/end/use-config.ts index 251b47c8218221..93a60e620b7092 100644 --- a/web/app/components/workflow/nodes/end/use-config.ts +++ b/web/app/components/workflow/nodes/end/use-config.ts @@ -1,7 +1,5 @@ import type { EndNodeType } from './types' -import { - useNodesReadOnly, -} from '@/app/components/workflow/hooks' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import useVarList from '../_base/hooks/use-var-list' diff --git a/web/app/components/workflow/nodes/http/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/http/__tests__/node.spec.tsx index 428aabd99ea2f3..bf6c182db99765 100644 --- a/web/app/components/workflow/nodes/http/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/http/__tests__/node.spec.tsx @@ -8,7 +8,7 @@ const mockReadonlyInputWithSelectVar = vi.hoisted(() => vi.fn()) vi.mock('@/app/components/workflow/nodes/_base/components/readonly-input-with-select-var', () => ({ __esModule: true, - default: (props: { value: string, nodeId: string, className?: string }) => { + default: (props: { value: string; nodeId: string; className?: string }) => { mockReadonlyInputWithSelectVar(props) return <div data-testid="readonly-input">{props.value}</div> }, @@ -48,19 +48,16 @@ describe('http/node', () => { expect(screen.getByText('post')).toBeInTheDocument() expect(screen.getByTestId('readonly-input')).toHaveTextContent('https://api.example.com/users') - expect(mockReadonlyInputWithSelectVar).toHaveBeenCalledWith(expect.objectContaining({ - nodeId: 'http-node', - value: 'https://api.example.com/users', - })) + expect(mockReadonlyInputWithSelectVar).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: 'http-node', + value: 'https://api.example.com/users', + }), + ) }) it('renders nothing when the request URL is empty', () => { - const { container } = render( - <Node - id="http-node" - data={createData({ url: '' })} - />, - ) + const { container } = render(<Node id="http-node" data={createData({ url: '' })} />) expect(container).toBeEmptyDOMElement() }) diff --git a/web/app/components/workflow/nodes/http/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/http/__tests__/panel.spec.tsx index e8ce5ac5c33c02..ff0ece4a651880 100644 --- a/web/app/components/workflow/nodes/http/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/http/__tests__/panel.spec.tsx @@ -24,8 +24,8 @@ type ApiInputProps = { type KeyValueProps = { nodeId: string - list: Array<{ key: string, value: string }> - onChange: (value: Array<{ key: string, value: string }>) => void + list: Array<{ key: string; value: string }> + onChange: (value: Array<{ key: string; value: string }>) => void onAdd: () => void } @@ -46,7 +46,12 @@ vi.mock('../use-config', () => ({ vi.mock('../components/authorization', () => ({ __esModule: true, - default: (props: { nodeId: string, payload: HttpNodeType['authorization'], onChange: (value: HttpNodeType['authorization']) => void, onHide: () => void }) => { + default: (props: { + nodeId: string + payload: HttpNodeType['authorization'] + onChange: (value: HttpNodeType['authorization']) => void + onHide: () => void + }) => { mockAuthorizationModal(props) return <div data-testid="authorization-modal">{props.nodeId}</div> }, @@ -54,7 +59,11 @@ vi.mock('../components/authorization', () => ({ vi.mock('../components/curl-panel', () => ({ __esModule: true, - default: (props: { nodeId: string, onHide: () => void, handleCurlImport: (node: HttpNodeType) => void }) => { + default: (props: { + nodeId: string + onHide: () => void + handleCurlImport: (node: HttpNodeType) => void + }) => { mockCurlPanel(props) return <div data-testid="curl-panel">{props.nodeId}</div> }, @@ -67,8 +76,12 @@ vi.mock('../components/api-input', () => ({ return ( <div> <div>{`${props.method}:${props.url}`}</div> - <button type="button" onClick={() => props.onMethodChange(Method.post)}>emit-method-change</button> - <button type="button" onClick={() => props.onUrlChange('https://changed.example.com')}>emit-url-change</button> + <button type="button" onClick={() => props.onMethodChange(Method.post)}> + emit-method-change + </button> + <button type="button" onClick={() => props.onUrlChange('https://changed.example.com')}> + emit-url-change + </button> </div> ) }, @@ -80,11 +93,13 @@ vi.mock('../components/key-value', () => ({ mockKeyValue(props) return ( <div> - <div>{props.list.map(item => `${item.key}:${item.value}`).join(',')}</div> + <div>{props.list.map((item) => `${item.key}:${item.value}`).join(',')}</div> <button type="button" onClick={() => props.onChange([{ key: 'x-token', value: '123' }])}> emit-key-value-change </button> - <button type="button" onClick={props.onAdd}>emit-key-value-add</button> + <button type="button" onClick={props.onAdd}> + emit-key-value-add + </button> </div> ) }, @@ -97,10 +112,12 @@ vi.mock('../components/edit-body', () => ({ return ( <button type="button" - onClick={() => props.onChange({ - type: BodyType.json, - data: [{ type: BodyPayloadValueType.text, value: '{"hello":"world"}' }], - })} + onClick={() => + props.onChange({ + type: BodyType.json, + data: [{ type: BodyPayloadValueType.text, value: '{"hello":"world"}' }], + }) + } > emit-body-change </button> @@ -123,7 +140,7 @@ vi.mock('../components/timeout', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <div>{children}</div>, - VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>, + VarItem: ({ name, type }: { name: string; type: string }) => <div>{`${name}:${type}`}</div>, })) const createData = (overrides: Partial<HttpNodeType> = {}): HttpNodeType => ({ @@ -197,13 +214,7 @@ describe('http/panel', () => { it('renders request fields, forwards child changes, and wires header operations', async () => { const user = userEvent.setup() - render( - <Panel - id="http-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<Panel id="http-node" data={createData()} panelProps={panelProps} />) expect(screen.getByText('get:https://api.example.com')).toBeInTheDocument() expect(screen.getByText('body:string')).toBeInTheDocument() @@ -237,57 +248,49 @@ describe('http/panel', () => { expect(showAuthorization).toHaveBeenCalledTimes(1) expect(showCurlPanel).toHaveBeenCalledTimes(1) expect(handleSSLVerifyChange).toHaveBeenCalledWith(false) - expect(mockApiInput).toHaveBeenCalledWith(expect.objectContaining({ - method: Method.get, - url: 'https://api.example.com', - })) + expect(mockApiInput).toHaveBeenCalledWith( + expect.objectContaining({ + method: Method.get, + url: 'https://api.example.com', + }), + ) }) it('returns null before the config data is ready', () => { mockUseConfig.mockReturnValueOnce(createConfigResult({ isDataReady: false })) const { container } = render( - <Panel - id="http-node" - data={createData()} - panelProps={panelProps} - />, + <Panel id="http-node" data={createData()} panelProps={panelProps} />, ) expect(container).toBeEmptyDOMElement() }) it('renders auth and curl panels only when writable and toggled on', () => { - mockUseConfig.mockReturnValueOnce(createConfigResult({ - isShowAuthorization: true, - isShowCurlPanel: true, - })) + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + isShowAuthorization: true, + isShowCurlPanel: true, + }), + ) const { rerender } = render( - <Panel - id="http-node" - data={createData()} - panelProps={panelProps} - />, + <Panel id="http-node" data={createData()} panelProps={panelProps} />, ) expect(screen.getByTestId('authorization-modal')).toHaveTextContent('http-node') expect(screen.getByTestId('curl-panel')).toHaveTextContent('http-node') - mockUseConfig.mockReturnValueOnce(createConfigResult({ - readOnly: true, - isShowAuthorization: true, - isShowCurlPanel: true, - })) - - rerender( - <Panel - id="http-node" - data={createData()} - panelProps={panelProps} - />, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + readOnly: true, + isShowAuthorization: true, + isShowCurlPanel: true, + }), ) + rerender(<Panel id="http-node" data={createData()} panelProps={panelProps} />) + expect(screen.queryByTestId('authorization-modal')).not.toBeInTheDocument() expect(screen.queryByTestId('curl-panel')).not.toBeInTheDocument() expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true') diff --git a/web/app/components/workflow/nodes/http/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/http/__tests__/use-config.spec.ts index e771122e284a31..6105326ab2d9ab 100644 --- a/web/app/components/workflow/nodes/http/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/http/__tests__/use-config.spec.ts @@ -141,25 +141,31 @@ describe('http/use-config', () => { const { result } = renderHook(() => useConfig('http-node', currentInputs)) await waitFor(() => { - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - method: Method.get, - url: 'https://api.example.com', - body: { - type: BodyType.json, - data: [{ - type: BodyPayloadValueType.text, - value: '{"name":"alice"}', - }], - }, - ssl_verify: true, - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + method: Method.get, + url: 'https://api.example.com', + body: { + type: BodyType.json, + data: [ + { + type: BodyPayloadValueType.text, + value: '{"name":"alice"}', + }, + ], + }, + ssl_verify: true, + }), + ) }) expect(result.current.isDataReady).toBe(true) expect(result.current.readOnly).toBe(false) expect(result.current.handleVarListChange).toBe(mockHandleVarListChange) expect(result.current.handleAddVariable).toBe(mockHandleAddVariable) - expect(result.current.headers).toEqual([{ id: 'header-1', key: 'accept', value: 'application/json' }]) + expect(result.current.headers).toEqual([ + { id: 'header-1', key: 'accept', value: 'application/json' }, + ]) expect(result.current.setHeaders).toBe(headerSetList) expect(result.current.addHeader).toBe(headerAddItem) expect(result.current.isHeaderKeyValueEdit).toBe(true) @@ -186,12 +192,14 @@ describe('http/use-config', () => { renderHook(() => useConfig('http-node', currentInputs)) await waitFor(() => { - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - body: { - type: BodyType.formData, - data: [], - }, - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + type: BodyType.formData, + data: [], + }, + }), + ) }) }) @@ -232,40 +240,58 @@ describe('http/use-config', () => { act(() => { result.current.hideCurlPanel() - result.current.handleCurlImport(createPayload({ - method: Method.patch, - url: 'https://imported.example.com', - headers: 'authorization:Bearer imported', - params: 'debug:true', - body: { type: BodyType.json, data: [{ type: BodyPayloadValueType.text, value: '{"ok":true}' }] }, - })) + result.current.handleCurlImport( + createPayload({ + method: Method.patch, + url: 'https://imported.example.com', + headers: 'authorization:Bearer imported', + params: 'debug:true', + body: { + type: BodyType.json, + data: [{ type: BodyPayloadValueType.text, value: '{"ok":true}' }], + }, + }), + ) result.current.handleSSLVerifyChange(false) }) expect(result.current.isShowAuthorization).toBe(false) expect(result.current.isShowCurlPanel).toBe(false) expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ method: Method.delete })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://changed.example.com' })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ url: 'https://changed.example.com' }), + ) expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ headers: 'x-token:123' })) expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ params: 'size:20' })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - body: { type: BodyType.rawText, data: 'raw payload' }, - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - authorization: expect.objectContaining({ - type: AuthorizationType.apiKey, + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + body: { type: BodyType.rawText, data: 'raw payload' }, }), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - timeout: { connect: 30, read: 40, write: 50 }, - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - method: Method.patch, - url: 'https://imported.example.com', - headers: 'authorization:Bearer imported', - params: 'debug:true', - body: { type: BodyType.json, data: [{ type: BodyPayloadValueType.text, value: '{"ok":true}' }] }, - })) + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + authorization: expect.objectContaining({ + type: AuthorizationType.apiKey, + }), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: { connect: 30, read: 40, write: 50 }, + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + method: Method.patch, + url: 'https://imported.example.com', + headers: 'authorization:Bearer imported', + params: 'debug:true', + body: { + type: BodyType.json, + data: [{ type: BodyPayloadValueType.text, value: '{"ok":true}' }], + }, + }), + ) expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ ssl_verify: false })) }) }) diff --git a/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx b/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx index 069ab4547ea1f6..b31e2e7840a854 100644 --- a/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx +++ b/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx @@ -5,10 +5,7 @@ import { BodyPayloadValueType, BodyType } from '../../types' import CurlPanel from '../curl-panel' import * as curlParser from '../curl-parser' -const { - mockHandleNodeSelect, - mockToastError, -} = vi.hoisted(() => ({ +const { mockHandleNodeSelect, mockToastError } = vi.hoisted(() => ({ mockHandleNodeSelect: vi.fn(), mockToastError: vi.fn(), })) @@ -32,7 +29,9 @@ describe('curl-panel', () => { describe('parseCurl', () => { it('should parse method, headers, json body, and query params from a valid curl command', () => { - const { node, error } = curlParser.parseCurl('curl -X POST -H "Authorization: Bearer token" --json "{"name":"openai"}" https://example.com/users?page=1&size=2') + const { node, error } = curlParser.parseCurl( + 'curl -X POST -H "Authorization: Bearer token" --json "{"name":"openai"}" https://example.com/users?page=1&size=2', + ) expect(error).toBeNull() expect(node).toMatchObject({ @@ -44,11 +43,15 @@ describe('curl-panel', () => { }) it('should return an error for invalid curl input', () => { - expect(curlParser.parseCurl('fetch https://example.com').error).toContain('Invalid cURL command') + expect(curlParser.parseCurl('fetch https://example.com').error).toContain( + 'Invalid cURL command', + ) }) it('should parse form data and attach typed content headers', () => { - const { node, error } = curlParser.parseCurl('curl --request POST --form "file=@report.txt;type=text/plain" --form "name=openai" https://example.com/upload') + const { node, error } = curlParser.parseCurl( + 'curl --request POST --form "file=@report.txt;type=text/plain" --form "name=openai" https://example.com/upload', + ) expect(error).toBeNull() expect(node).toMatchObject({ @@ -63,15 +66,19 @@ describe('curl-panel', () => { }) it('should parse raw payloads and preserve equals signs in the body value', () => { - const { node, error } = curlParser.parseCurl('curl --data-binary "token=abc=123" https://example.com/raw') + const { node, error } = curlParser.parseCurl( + 'curl --data-binary "token=abc=123" https://example.com/raw', + ) expect(error).toBeNull() expect(node?.body).toEqual({ type: BodyType.rawText, - data: [{ - type: BodyPayloadValueType.text, - value: 'token=abc=123', - }], + data: [ + { + type: BodyPayloadValueType.text, + value: 'token=abc=123', + }, + ], }) }) @@ -98,41 +105,33 @@ describe('curl-panel', () => { const handleCurlImport = vi.fn() render( - <CurlPanel - nodeId="node-1" - isShow - onHide={onHide} - handleCurlImport={handleCurlImport} - />, + <CurlPanel nodeId="node-1" isShow onHide={onHide} handleCurlImport={handleCurlImport} />, ) await user.type(screen.getByRole('textbox'), 'curl https://example.com') await user.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(onHide).toHaveBeenCalledTimes(1) - expect(handleCurlImport).toHaveBeenCalledWith(expect.objectContaining({ - method: 'get', - url: 'https://example.com', - })) + expect(handleCurlImport).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + url: 'https://example.com', + }), + ) expect(mockHandleNodeSelect).toHaveBeenNthCalledWith(1, 'node-1', true) }) it('should notify the user when the curl command is invalid', async () => { const user = userEvent.setup() - render( - <CurlPanel - nodeId="node-1" - isShow - onHide={vi.fn()} - handleCurlImport={vi.fn()} - />, - ) + render(<CurlPanel nodeId="node-1" isShow onHide={vi.fn()} handleCurlImport={vi.fn()} />) await user.type(screen.getByRole('textbox'), 'invalid') await user.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(vi.mocked(toast.error)).toHaveBeenCalledWith(expect.stringContaining('Invalid cURL command')) + expect(vi.mocked(toast.error)).toHaveBeenCalledWith( + expect.stringContaining('Invalid cURL command'), + ) }) it('should keep the panel open when parsing returns no node and no error', async () => { @@ -145,12 +144,7 @@ describe('curl-panel', () => { }) render( - <CurlPanel - nodeId="node-1" - isShow - onHide={onHide} - handleCurlImport={handleCurlImport} - />, + <CurlPanel nodeId="node-1" isShow onHide={onHide} handleCurlImport={handleCurlImport} />, ) await user.click(screen.getByRole('button', { name: 'common.operation.save' })) diff --git a/web/app/components/workflow/nodes/http/components/api-input.tsx b/web/app/components/workflow/nodes/http/components/api-input.tsx index 9eca83716fc9f6..043a2c54f2c547 100644 --- a/web/app/components/workflow/nodes/http/components/api-input.tsx +++ b/web/app/components/workflow/nodes/http/components/api-input.tsx @@ -29,14 +29,7 @@ type Props = Readonly<{ onUrlChange: (url: string) => void }> -const ApiInput: FC<Props> = ({ - nodeId, - readonly, - method, - onMethodChange, - url, - onUrlChange, -}) => { +const ApiInput: FC<Props> = ({ nodeId, readonly, method, onMethodChange, url, onUrlChange }) => { const { t } = useTranslation() const [isFocus, setIsFocus] = useState(false) @@ -53,12 +46,19 @@ const ApiInput: FC<Props> = ({ value={method} onChange={onMethodChange} options={MethodOptions} - trigger={( - <div className={cn(readonly && 'cursor-pointer', 'flex h-8 shrink-0 items-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-2.5')}> - <div className="w-12 pl-0.5 text-xs leading-[18px] font-medium text-text-primary uppercase">{method}</div> + trigger={ + <div + className={cn( + readonly && 'cursor-pointer', + 'flex h-8 shrink-0 items-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-2.5', + )} + > + <div className="w-12 pl-0.5 text-xs leading-[18px] font-medium text-text-primary uppercase"> + {method} + </div> {!readonly && <RiArrowDownSLine className="ml-1 size-3.5 text-text-secondary" />} </div> - )} + } popupClassName="top-[34px] w-[108px]" showChecked readonly={readonly} @@ -66,14 +66,19 @@ const ApiInput: FC<Props> = ({ <Input instanceId="http-api-url" - className={cn(isFocus ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-components-input-border-hover bg-components-input-bg-normal', 'w-0 grow rounded-lg border px-3 py-[6px]')} + className={cn( + isFocus + ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' + : 'border-components-input-border-hover bg-components-input-bg-normal', + 'w-0 grow rounded-lg border px-3 py-[6px]', + )} value={url} onChange={onUrlChange} readOnly={readonly} nodesOutputVars={availableVars} availableNodes={availableNodesWithParent} onFocusChange={setIsFocus} - placeholder={!readonly ? t($ => $['nodes.http.apiPlaceholder'], { ns: 'workflow' })! : ''} + placeholder={!readonly ? t(($) => $['nodes.http.apiPlaceholder'], { ns: 'workflow' })! : ''} placeholderClassName="leading-[21px]!" /> </div> diff --git a/web/app/components/workflow/nodes/http/components/authorization/index.tsx b/web/app/components/workflow/nodes/http/components/authorization/index.tsx index 8a9cbf60f35092..aeb099f694a381 100644 --- a/web/app/components/workflow/nodes/http/components/authorization/index.tsx +++ b/web/app/components/workflow/nodes/http/components/authorization/index.tsx @@ -26,7 +26,15 @@ type Props = Readonly<{ onHide: () => void }> -const Field = ({ title, isRequired, children }: { title: string, isRequired?: boolean, children: React.JSX.Element }) => { +const Field = ({ + title, + isRequired, + children, +}: { + title: string + isRequired?: boolean + children: React.JSX.Element +}) => { return ( <div> <div className="text-[13px] leading-8 font-medium text-text-secondary"> @@ -38,13 +46,7 @@ const Field = ({ title, isRequired, children }: { title: string, isRequired?: bo ) } -const Authorization: FC<Props> = ({ - nodeId, - payload, - onChange, - isShow, - onHide, -}) => { +const Authorization: FC<Props> = ({ nodeId, payload, onChange, isShow, onHide }) => { const { t } = useTranslation() const [isFocus, setIsFocus] = useState(false) @@ -56,34 +58,58 @@ const Authorization: FC<Props> = ({ }) const [tempPayload, setTempPayload] = React.useState<AuthorizationPayloadType>(payload) - const handleAuthTypeChange = useCallback((type: string) => { - const newPayload = produce(tempPayload, (draft: AuthorizationPayloadType) => { - draft.type = type as AuthorizationType - if (draft.type === AuthorizationType.apiKey && !draft.config) { - draft.config = { - type: APIType.basic, - api_key: '', + const handleAuthTypeChange = useCallback( + (type: string) => { + const newPayload = produce(tempPayload, (draft: AuthorizationPayloadType) => { + draft.type = type as AuthorizationType + if (draft.type === AuthorizationType.apiKey && !draft.config) { + draft.config = { + type: APIType.basic, + api_key: '', + } } - } - }) - setTempPayload(newPayload) - }, [tempPayload, setTempPayload]) + }) + setTempPayload(newPayload) + }, + [tempPayload, setTempPayload], + ) - const handleAuthAPITypeChange = useCallback((type: string) => { - const newPayload = produce(tempPayload, (draft: AuthorizationPayloadType) => { - if (!draft.config) { - draft.config = { - type: APIType.basic, - api_key: '', + const handleAuthAPITypeChange = useCallback( + (type: string) => { + const newPayload = produce(tempPayload, (draft: AuthorizationPayloadType) => { + if (!draft.config) { + draft.config = { + type: APIType.basic, + api_key: '', + } } + draft.config.type = type as APIType + }) + setTempPayload(newPayload) + }, + [tempPayload, setTempPayload], + ) + + const handleAPIKeyOrHeaderChange = useCallback( + (type: 'api_key' | 'header') => { + return (e: React.ChangeEvent<HTMLInputElement>) => { + const newPayload = produce(tempPayload, (draft: AuthorizationPayloadType) => { + if (!draft.config) { + draft.config = { + type: APIType.basic, + api_key: '', + } + } + draft.config[type] = e.target.value + }) + setTempPayload(newPayload) } - draft.config.type = type as APIType - }) - setTempPayload(newPayload) - }, [tempPayload, setTempPayload]) + }, + [tempPayload, setTempPayload], + ) - const handleAPIKeyOrHeaderChange = useCallback((type: 'api_key' | 'header') => { - return (e: React.ChangeEvent<HTMLInputElement>) => { + const handleAPIKeyChange = useCallback( + (str: string) => { const newPayload = produce(tempPayload, (draft: AuthorizationPayloadType) => { if (!draft.config) { draft.config = { @@ -91,24 +117,12 @@ const Authorization: FC<Props> = ({ api_key: '', } } - draft.config[type] = e.target.value + draft.config.api_key = str }) setTempPayload(newPayload) - } - }, [tempPayload, setTempPayload]) - - const handleAPIKeyChange = useCallback((str: string) => { - const newPayload = produce(tempPayload, (draft: AuthorizationPayloadType) => { - if (!draft.config) { - draft.config = { - type: APIType.basic, - api_key: '', - } - } - draft.config.api_key = str - }) - setTempPayload(newPayload) - }, [tempPayload, setTempPayload]) + }, + [tempPayload, setTempPayload], + ) const handleConfirm = useCallback(() => { onChange(tempPayload) @@ -118,22 +132,27 @@ const Authorization: FC<Props> = ({ <Dialog open={isShow} onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DialogContent className="border-none text-left align-middle"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $[`${i18nPrefix}.authorization`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.authorization`], { ns: 'workflow' })} </DialogTitle> <div> <div className="space-y-2"> - <Field title={t($ => $[`${i18nPrefix}.authorizationType`], { ns: 'workflow' })}> + <Field title={t(($) => $[`${i18nPrefix}.authorizationType`], { ns: 'workflow' })}> <RadioGroup options={[ - { value: AuthorizationType.none, label: t($ => $[`${i18nPrefix}.no-auth`], { ns: 'workflow' }) }, - { value: AuthorizationType.apiKey, label: t($ => $[`${i18nPrefix}.api-key`], { ns: 'workflow' }) }, + { + value: AuthorizationType.none, + label: t(($) => $[`${i18nPrefix}.no-auth`], { ns: 'workflow' }), + }, + { + value: AuthorizationType.apiKey, + label: t(($) => $[`${i18nPrefix}.api-key`], { ns: 'workflow' }), + }, ]} value={tempPayload.type} onChange={handleAuthTypeChange} @@ -142,19 +161,28 @@ const Authorization: FC<Props> = ({ {tempPayload.type === AuthorizationType.apiKey && ( <> - <Field title={t($ => $[`${i18nPrefix}.auth-type`], { ns: 'workflow' })}> + <Field title={t(($) => $[`${i18nPrefix}.auth-type`], { ns: 'workflow' })}> <RadioGroup options={[ - { value: APIType.basic, label: t($ => $[`${i18nPrefix}.basic`], { ns: 'workflow' }) }, - { value: APIType.bearer, label: t($ => $[`${i18nPrefix}.bearer`], { ns: 'workflow' }) }, - { value: APIType.custom, label: t($ => $[`${i18nPrefix}.custom`], { ns: 'workflow' }) }, + { + value: APIType.basic, + label: t(($) => $[`${i18nPrefix}.basic`], { ns: 'workflow' }), + }, + { + value: APIType.bearer, + label: t(($) => $[`${i18nPrefix}.bearer`], { ns: 'workflow' }), + }, + { + value: APIType.custom, + label: t(($) => $[`${i18nPrefix}.custom`], { ns: 'workflow' }), + }, ]} value={tempPayload.config?.type || APIType.basic} onChange={handleAuthAPITypeChange} /> </Field> {tempPayload.config?.type === APIType.custom && ( - <Field title={t($ => $[`${i18nPrefix}.header`], { ns: 'workflow' })} isRequired> + <Field title={t(($) => $[`${i18nPrefix}.header`], { ns: 'workflow' })} isRequired> <BaseInput value={tempPayload.config?.header || ''} onChange={handleAPIKeyOrHeaderChange('header')} @@ -162,11 +190,19 @@ const Authorization: FC<Props> = ({ </Field> )} - <Field title={t($ => $[`${i18nPrefix}.api-key-title`], { ns: 'workflow' })} isRequired> + <Field + title={t(($) => $[`${i18nPrefix}.api-key-title`], { ns: 'workflow' })} + isRequired + > <div className="flex"> <Input instanceId="http-api-key" - className={cn(isFocus ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-components-input-border-hover bg-components-input-bg-normal', 'w-0 grow rounded-lg border px-3 py-[6px]')} + className={cn( + isFocus + ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' + : 'border-components-input-border-hover bg-components-input-bg-normal', + 'w-0 grow rounded-lg border px-3 py-[6px]', + )} value={tempPayload.config?.api_key || ''} onChange={handleAPIKeyChange} nodesOutputVars={availableVars} @@ -181,8 +217,10 @@ const Authorization: FC<Props> = ({ )} </div> <div className="mt-6 flex justify-end space-x-2"> - <Button onClick={onHide}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" onClick={handleConfirm}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button onClick={onHide}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> + <Button variant="primary" onClick={handleConfirm}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> </div> </DialogContent> diff --git a/web/app/components/workflow/nodes/http/components/authorization/radio-group.tsx b/web/app/components/workflow/nodes/http/components/authorization/radio-group.tsx index fff1fe79d6cbc5..659eeae3a3172f 100644 --- a/web/app/components/workflow/nodes/http/components/authorization/radio-group.tsx +++ b/web/app/components/workflow/nodes/http/components/authorization/radio-group.tsx @@ -14,17 +14,15 @@ type ItemProps = { onClick: () => void isSelected: boolean } -const Item: FC<ItemProps> = ({ - title, - onClick, - isSelected, -}) => { +const Item: FC<ItemProps> = ({ title, onClick, isSelected }) => { return ( <div className={cn( 'flex h-8 grow cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary', - !isSelected && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', - isSelected && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs', + !isSelected && + 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', + isSelected && + 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs', )} onClick={onClick} > @@ -39,17 +37,16 @@ type Props = Readonly<{ onChange: (value: string) => void }> -const RadioGroup: FC<Props> = ({ - options, - value, - onChange, -}) => { - const handleChange = useCallback((value: string) => { - return () => onChange(value) - }, [onChange]) +const RadioGroup: FC<Props> = ({ options, value, onChange }) => { + const handleChange = useCallback( + (value: string) => { + return () => onChange(value) + }, + [onChange], + ) return ( <div className="flex space-x-2"> - {options.map(option => ( + {options.map((option) => ( <Item key={option.value} title={option.label} diff --git a/web/app/components/workflow/nodes/http/components/curl-panel.tsx b/web/app/components/workflow/nodes/http/components/curl-panel.tsx index d99445b0edcdd8..e23a374f2ea860 100644 --- a/web/app/components/workflow/nodes/http/components/curl-panel.tsx +++ b/web/app/components/workflow/nodes/http/components/curl-panel.tsx @@ -29,8 +29,7 @@ const CurlPanel: FC<Props> = ({ nodeId, isShow, onHide, handleCurlImport }) => { toast.error(error) return } - if (!node) - return + if (!node) return onHide() handleCurlImport(node) @@ -45,29 +44,30 @@ const CurlPanel: FC<Props> = ({ nodeId, isShow, onHide, handleCurlImport }) => { <Dialog open={isShow} onOpenChange={(open) => { - if (!open) - onHide() + if (!open) onHide() }} > <DialogContent className="w-[400px]! max-w-[400px]! overflow-hidden! border-none p-4! text-left align-middle"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['nodes.http.curl.title'], { ns: 'workflow' })} + {t(($) => $['nodes.http.curl.title'], { ns: 'workflow' })} </DialogTitle> <div> <Textarea - aria-label={t($ => $['nodes.http.curl.title'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.http.curl.title'], { ns: 'workflow' })} value={inputString} className="my-3 h-40 w-full grow" - onValueChange={value => setInputString(value)} - placeholder={t($ => $['nodes.http.curl.placeholder'], { ns: 'workflow' })!} + onValueChange={(value) => setInputString(value)} + placeholder={t(($) => $['nodes.http.curl.placeholder'], { ns: 'workflow' })!} /> </div> <div className="mt-4 flex justify-end space-x-2"> - <Button className="w-[95px]!" onClick={onHide}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> + <Button className="w-[95px]!" onClick={onHide}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> <Button className="w-[95px]!" variant="primary" onClick={handleSave}> {' '} - {t($ => $['operation.save'], { ns: 'common' })} + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/app/components/workflow/nodes/http/components/curl-parser.ts b/web/app/components/workflow/nodes/http/components/curl-parser.ts index 0e2aa1f6f0cd82..b349c510b4f740 100644 --- a/web/app/components/workflow/nodes/http/components/curl-parser.ts +++ b/web/app/components/workflow/nodes/http/components/curl-parser.ts @@ -32,8 +32,7 @@ const buildDefaultNode = (): Partial<HttpNodeType> => ({ const extractUrlParams = (url: string) => { const urlParts = url.split('?') - if (urlParts.length <= 1) - return { url, params: '' } + if (urlParts.length <= 1) return { url, params: '' } return { url: urlParts[0], @@ -41,9 +40,12 @@ const extractUrlParams = (url: string) => { } } -const getNextArg = (args: string[], index: number, error: string): { value: string, error: null } | { value: null, error: string } => { - if (index + 1 >= args.length) - return { value: null, error } +const getNextArg = ( + args: string[], + index: number, + error: string, +): { value: string; error: null } | { value: null; error: string } => { + if (index + 1 >= args.length) return { value: null, error } return { value: stripWrappedQuotes(args[index + 1]!), @@ -51,7 +53,11 @@ const getNextArg = (args: string[], index: number, error: string): { value: stri } } -const applyMethodArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => { +const applyMethodArg = ( + node: Partial<HttpNodeType>, + args: string[], + index: number, +): ParseStepResult => { const nextArg = getNextArg(args, index, 'Missing HTTP method after -X or --request.') if (nextArg.error || nextArg.value === null) return { error: nextArg.error, nextIndex: index, hasData: false } @@ -60,19 +66,29 @@ const applyMethodArg = (node: Partial<HttpNodeType>, args: string[], index: numb return { error: null, nextIndex: index + 1, hasData: true } } -const applyHeaderArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => { +const applyHeaderArg = ( + node: Partial<HttpNodeType>, + args: string[], + index: number, +): ParseStepResult => { const nextArg = getNextArg(args, index, 'Missing header value after -H or --header.') - if (nextArg.error || nextArg.value === null) - return { error: nextArg.error, nextIndex: index } + if (nextArg.error || nextArg.value === null) return { error: nextArg.error, nextIndex: index } node.headers += `${node.headers ? '\n' : ''}${nextArg.value}` return { error: null, nextIndex: index + 1 } } -const applyDataArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => { - const nextArg = getNextArg(args, index, 'Missing data value after -d, --data, --data-raw, or --data-binary.') - if (nextArg.error || nextArg.value === null) - return { error: nextArg.error, nextIndex: index } +const applyDataArg = ( + node: Partial<HttpNodeType>, + args: string[], + index: number, +): ParseStepResult => { + const nextArg = getNextArg( + args, + index, + 'Missing data value after -d, --data, --data-raw, or --data-binary.', + ) + if (nextArg.error || nextArg.value === null) return { error: nextArg.error, nextIndex: index } node.body = { type: BodyType.rawText, @@ -81,17 +97,18 @@ const applyDataArg = (node: Partial<HttpNodeType>, args: string[], index: number return { error: null, nextIndex: index + 1 } } -const applyFormArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => { +const applyFormArg = ( + node: Partial<HttpNodeType>, + args: string[], + index: number, +): ParseStepResult => { const nextArg = getNextArg(args, index, 'Missing form data after -F or --form.') - if (nextArg.error || nextArg.value === null) - return { error: nextArg.error, nextIndex: index } + if (nextArg.error || nextArg.value === null) return { error: nextArg.error, nextIndex: index } - if (node.body?.type !== BodyType.formData) - node.body = { type: BodyType.formData, data: '' } + if (node.body?.type !== BodyType.formData) node.body = { type: BodyType.formData, data: '' } const [key, ...valueParts] = nextArg.value.split('=') - if (!key) - return { error: 'Invalid form data format.', nextIndex: index } + if (!key) return { error: 'Invalid form data format.', nextIndex: index } let value = valueParts.join('=') const typeMatch = /^(.+?);type=(.+)$/.exec(value) @@ -105,10 +122,13 @@ const applyFormArg = (node: Partial<HttpNodeType>, args: string[], index: number return { error: null, nextIndex: index + 1 } } -const applyJsonArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => { +const applyJsonArg = ( + node: Partial<HttpNodeType>, + args: string[], + index: number, +): ParseStepResult => { const nextArg = getNextArg(args, index, 'Missing JSON data after --json.') - if (nextArg.error || nextArg.value === null) - return { error: nextArg.error, nextIndex: index } + if (nextArg.error || nextArg.value === null) return { error: nextArg.error, nextIndex: index } node.body = { type: BodyType.json, data: nextArg.value } return { error: null, nextIndex: index + 1 } @@ -120,28 +140,24 @@ const handleCurlArg = ( args: string[], index: number, ): ParseStepResult => { - if (METHOD_ARG_FLAGS.has(arg)) - return applyMethodArg(node, args, index) + if (METHOD_ARG_FLAGS.has(arg)) return applyMethodArg(node, args, index) - if (HEADER_ARG_FLAGS.has(arg)) - return applyHeaderArg(node, args, index) + if (HEADER_ARG_FLAGS.has(arg)) return applyHeaderArg(node, args, index) - if (DATA_ARG_FLAGS.has(arg)) - return applyDataArg(node, args, index) + if (DATA_ARG_FLAGS.has(arg)) return applyDataArg(node, args, index) - if (FORM_ARG_FLAGS.has(arg)) - return applyFormArg(node, args, index) + if (FORM_ARG_FLAGS.has(arg)) return applyFormArg(node, args, index) - if (arg === '--json') - return applyJsonArg(node, args, index) + if (arg === '--json') return applyJsonArg(node, args, index) - if (arg.startsWith('http') && !node.url) - node.url = arg + if (arg.startsWith('http') && !node.url) node.url = arg return { error: null, nextIndex: index, hasData: false } } -export const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => { +export const parseCurl = ( + curlCommand: string, +): { node: HttpNodeType | null; error: string | null } => { if (!curlCommand.trim().toLowerCase().startsWith('curl')) return { node: null, error: 'Invalid cURL command. Command must start with "curl".' } @@ -151,8 +167,7 @@ export const parseCurl = (curlCommand: string): { node: HttpNodeType | null, err for (let i = 1; i < args.length; i++) { const result = handleCurlArg(stripWrappedQuotes(args[i]!), node, args, i) - if (result.error) - return { node: null, error: result.error } + if (result.error) return { node: null, error: result.error } hasData ||= Boolean(result.hasData) i = result.nextIndex @@ -160,8 +175,7 @@ export const parseCurl = (curlCommand: string): { node: HttpNodeType | null, err node.method = node.method || (hasData ? Method.post : Method.get) - if (!node.url) - return { node: null, error: 'Missing URL or url not start with http.' } + if (!node.url) return { node: null, error: 'Missing URL or url not start with http.' } const parsedUrl = extractUrlParams(node.url) node.url = parsedUrl.url diff --git a/web/app/components/workflow/nodes/http/components/edit-body/index.spec.tsx b/web/app/components/workflow/nodes/http/components/edit-body/index.spec.tsx index a3078464b8f3bd..5b5331df681a75 100644 --- a/web/app/components/workflow/nodes/http/components/edit-body/index.spec.tsx +++ b/web/app/components/workflow/nodes/http/components/edit-body/index.spec.tsx @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import { VarType } from '@/app/components/workflow/types' -import { - HTTP_BODY_VARIABLE_TYPES, - isSupportedHttpBodyVariable, -} from './supported-body-vars' +import { HTTP_BODY_VARIABLE_TYPES, isSupportedHttpBodyVariable } from './supported-body-vars' describe('HTTP body variable support', () => { it('should include structured variables in the selector', () => { diff --git a/web/app/components/workflow/nodes/http/components/edit-body/index.tsx b/web/app/components/workflow/nodes/http/components/edit-body/index.tsx index 88d9cec2941a33..61de251c13129f 100644 --- a/web/app/components/workflow/nodes/http/components/edit-body/index.tsx +++ b/web/app/components/workflow/nodes/http/components/edit-body/index.tsx @@ -41,20 +41,18 @@ const bodyTextMap = { [BodyType.binary]: 'binary', } -const EditBody: FC<Props> = ({ - readonly, - nodeId, - payload, - onChange, -}) => { +const EditBody: FC<Props> = ({ readonly, nodeId, payload, onChange }) => { const { type, data } = payload const bodyPayload = useMemo(() => { - if (typeof data === 'string') { // old data + if (typeof data === 'string') { + // old data return [] } return data }, [data]) - const stringValue = [BodyType.formData, BodyType.xWwwFormUrlencoded].includes(type) ? '' : (bodyPayload[0]?.value || '') + const stringValue = [BodyType.formData, BodyType.xWwwFormUrlencoded].includes(type) + ? '' + : bodyPayload[0]?.value || '' const { availableVars, availableNodes } = useAvailableVarList(nodeId, { onlyLeafNodeVar: false, @@ -63,27 +61,30 @@ const EditBody: FC<Props> = ({ }, }) - const handleTypeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { - const newType = e.target.value as BodyType - const hasKeyValue = [BodyType.formData, BodyType.xWwwFormUrlencoded].includes(newType) - onChange({ - type: newType, - data: hasKeyValue - ? [ - { - id: uniqueId(UNIQUE_ID_PREFIX), - type: BodyPayloadValueType.text, - key: '', - value: '', - }, - ] - : [], - }) - }, [onChange]) + const handleTypeChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + const newType = e.target.value as BodyType + const hasKeyValue = [BodyType.formData, BodyType.xWwwFormUrlencoded].includes(newType) + onChange({ + type: newType, + data: hasKeyValue + ? [ + { + id: uniqueId(UNIQUE_ID_PREFIX), + type: BodyPayloadValueType.text, + key: '', + value: '', + }, + ] + : [], + }) + }, + [onChange], + ) const handleAddBody = useCallback(() => { const newPayload = produce(payload, (draft) => { - (draft.data as BodyPayload).push({ + ;(draft.data as BodyPayload).push({ id: uniqueId(UNIQUE_ID_PREFIX), type: BodyPayloadValueType.text, key: '', @@ -93,51 +94,64 @@ const EditBody: FC<Props> = ({ onChange(newPayload) }, [onChange, payload]) - const handleBodyPayloadChange = useCallback((newList: KeyValueType[]) => { - const newPayload = produce(payload, (draft) => { - draft.data = newList as BodyPayload - }) - onChange(newPayload) - }, [onChange, payload]) + const handleBodyPayloadChange = useCallback( + (newList: KeyValueType[]) => { + const newPayload = produce(payload, (draft) => { + draft.data = newList as BodyPayload + }) + onChange(newPayload) + }, + [onChange, payload], + ) const filterOnlyFileVariable = (varPayload: Var) => { return [VarType.file, VarType.arrayFile].includes(varPayload.type) } - const handleBodyValueChange = useCallback((value: string) => { - const newBody = produce(payload, (draft: Body) => { - if ((draft.data as BodyPayload).length === 0) { - (draft.data as BodyPayload).push({ - id: uniqueId(UNIQUE_ID_PREFIX), - type: BodyPayloadValueType.text, - key: '', - value: '', - }) - } - (draft.data as BodyPayload)[0]!.value = value - }) - onChange(newBody) - }, [onChange, payload]) + const handleBodyValueChange = useCallback( + (value: string) => { + const newBody = produce(payload, (draft: Body) => { + if ((draft.data as BodyPayload).length === 0) { + ;(draft.data as BodyPayload).push({ + id: uniqueId(UNIQUE_ID_PREFIX), + type: BodyPayloadValueType.text, + key: '', + value: '', + }) + } + ;(draft.data as BodyPayload)[0]!.value = value + }) + onChange(newBody) + }, + [onChange, payload], + ) - const handleFileChange = useCallback((value: ValueSelector | string) => { - const newBody = produce(payload, (draft: Body) => { - if ((draft.data as BodyPayload).length === 0) { - (draft.data as BodyPayload).push({ - id: uniqueId(UNIQUE_ID_PREFIX), - type: BodyPayloadValueType.file, - }) - } - (draft.data as BodyPayload)[0]!.file = value as ValueSelector - }) - onChange(newBody) - }, [onChange, payload]) + const handleFileChange = useCallback( + (value: ValueSelector | string) => { + const newBody = produce(payload, (draft: Body) => { + if ((draft.data as BodyPayload).length === 0) { + ;(draft.data as BodyPayload).push({ + id: uniqueId(UNIQUE_ID_PREFIX), + type: BodyPayloadValueType.file, + }) + } + ;(draft.data as BodyPayload)[0]!.file = value as ValueSelector + }) + onChange(newBody) + }, + [onChange, payload], + ) return ( <div> {/* body type */} <div className="flex flex-wrap"> - {allTypes.map(t => ( - <label key={t} htmlFor={`body-type-${t}`} className="mr-4 flex h-7 items-center space-x-2"> + {allTypes.map((t) => ( + <label + key={t} + htmlFor={`body-type-${t}`} + className="mr-4 flex h-7 items-center space-x-2" + > <input type="radio" id={`body-type-${t}`} @@ -146,7 +160,9 @@ const EditBody: FC<Props> = ({ onChange={handleTypeChange} disabled={readonly} /> - <div className="text-[13px] leading-[18px] font-normal text-text-secondary">{bodyTextMap[t]}</div> + <div className="text-[13px] leading-[18px] font-normal text-text-secondary"> + {bodyTextMap[t]} + </div> </label> ))} </div> diff --git a/web/app/components/workflow/nodes/http/components/key-value/key-value-edit/index.tsx b/web/app/components/workflow/nodes/http/components/key-value/key-value-edit/index.tsx index 4917c67e2b2229..068ebe7e039003 100644 --- a/web/app/components/workflow/nodes/http/components/key-value/key-value-edit/index.tsx +++ b/web/app/components/workflow/nodes/http/components/key-value/key-value-edit/index.tsx @@ -35,53 +35,78 @@ const KeyValueList: FC<Props> = ({ }) => { const { t } = useTranslation() - const handleChange = useCallback((index: number) => { - return (newItem: KeyValue) => { - const newList = produce(list, (draft: any) => { - draft[index] = newItem - }) - onChange(newList) - } - }, [list, onChange]) + const handleChange = useCallback( + (index: number) => { + return (newItem: KeyValue) => { + const newList = produce(list, (draft: any) => { + draft[index] = newItem + }) + onChange(newList) + } + }, + [list, onChange], + ) - const handleRemove = useCallback((index: number) => { - return () => { - const newList = produce(list, (draft: any) => { - draft.splice(index, 1) - }) - onChange(newList) - } - }, [list, onChange]) + const handleRemove = useCallback( + (index: number) => { + return () => { + const newList = produce(list, (draft: any) => { + draft.splice(index, 1) + }) + onChange(newList) + } + }, + [list, onChange], + ) - if (!Array.isArray(list)) - return null + if (!Array.isArray(list)) return null return ( <div className="overflow-hidden rounded-lg border border-divider-regular"> - <div className={cn('flex h-7 items-center system-xs-medium-uppercase leading-7 text-text-tertiary')}> - <div className={cn('flex h-full items-center border-r border-divider-regular pl-3', isSupportFile ? 'w-[140px]' : 'w-1/2')}>{t($ => $[`${i18nPrefix}.key`], { ns: 'workflow' })}</div> - {isSupportFile && <div className="flex h-full w-[70px] shrink-0 items-center border-r border-divider-regular pl-3">{t($ => $[`${i18nPrefix}.type`], { ns: 'workflow' })}</div>} - <div className={cn('flex h-full items-center justify-between pr-1 pl-3', isSupportFile ? 'grow' : 'w-1/2')}>{t($ => $[`${i18nPrefix}.value`], { ns: 'workflow' })}</div> + <div + className={cn( + 'flex h-7 items-center system-xs-medium-uppercase leading-7 text-text-tertiary', + )} + > + <div + className={cn( + 'flex h-full items-center border-r border-divider-regular pl-3', + isSupportFile ? 'w-[140px]' : 'w-1/2', + )} + > + {t(($) => $[`${i18nPrefix}.key`], { ns: 'workflow' })} + </div> + {isSupportFile && ( + <div className="flex h-full w-[70px] shrink-0 items-center border-r border-divider-regular pl-3"> + {t(($) => $[`${i18nPrefix}.type`], { ns: 'workflow' })} + </div> + )} + <div + className={cn( + 'flex h-full items-center justify-between pr-1 pl-3', + isSupportFile ? 'grow' : 'w-1/2', + )} + > + {t(($) => $[`${i18nPrefix}.value`], { ns: 'workflow' })} + </div> </div> - { - list.map((item, index) => ( - <KeyValueItem - key={item.id} - instanceId={item.id!} - nodeId={nodeId} - payload={item} - onChange={handleChange(index)} - onRemove={handleRemove(index)} - isLastItem={index === list.length - 1} - onAdd={onAdd} - readonly={readonly} - canRemove={list.length > 1} - isSupportFile={isSupportFile} - keyNotSupportVar={keyNotSupportVar} - insertVarTipToLeft={insertVarTipToLeft} - /> - )) - } + {list.map((item, index) => ( + <KeyValueItem + key={item.id} + instanceId={item.id!} + nodeId={nodeId} + payload={item} + onChange={handleChange(index)} + onRemove={handleRemove(index)} + isLastItem={index === list.length - 1} + onAdd={onAdd} + readonly={readonly} + canRemove={list.length > 1} + isSupportFile={isSupportFile} + keyNotSupportVar={keyNotSupportVar} + insertVarTipToLeft={insertVarTipToLeft} + /> + ))} </div> ) } diff --git a/web/app/components/workflow/nodes/http/components/key-value/key-value-edit/input-item.tsx b/web/app/components/workflow/nodes/http/components/key-value/key-value-edit/input-item.tsx index e0a092a7f5918c..8ce5d182ba417f 100644 --- a/web/app/components/workflow/nodes/http/components/key-value/key-value-edit/input-item.tsx +++ b/web/app/components/workflow/nodes/http/components/key-value/key-value-edit/input-item.tsx @@ -46,60 +46,67 @@ const InputItem: FC<Props> = ({ onlyLeafNodeVar: false, filterVar: (varPayload: Var) => { const supportVarTypes = [VarType.string, VarType.number, VarType.secret] - if (isSupportFile) - supportVarTypes.push(VarType.file, VarType.arrayFile) + if (isSupportFile) supportVarTypes.push(VarType.file, VarType.arrayFile) return supportVarTypes.includes(varPayload.type) }, }) - const handleRemove = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - onRemove?.() - }, [onRemove]) + const handleRemove = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onRemove?.() + }, + [onRemove], + ) return ( <div className={cn(className, 'hover:cursor-text hover:bg-state-base-hover', 'relative flex')}> - {(!readOnly) - ? ( + {!readOnly ? ( + <Input + instanceId={instanceId} + className={cn( + isFocus ? 'bg-components-input-bg-active' : '', + 'clamp group w-0 grow px-3 py-1', + )} + value={value} + onChange={onChange} + readOnly={readOnly} + nodesOutputVars={availableVars} + availableNodes={availableNodesWithParent} + onFocusChange={setIsFocus} + placeholder={t(($) => $['nodes.http.insertVarPlaceholder'], { ns: 'workflow' })!} + placeholderClassName="leading-[21px]!" + insertVarTipToLeft={insertVarTipToLeft} + /> + ) : ( + <div className="h-full w-full pl-0.5 leading-[18px]"> + {!hasValue && ( + <div className="text-xs font-normal text-text-quaternary">{placeholder}</div> + )} + {hasValue && ( <Input instanceId={instanceId} - className={cn(isFocus ? 'bg-components-input-bg-active' : '', 'clamp group w-0 grow px-3 py-1')} + className={cn( + isFocus + ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' + : 'border-components-input-border-hover bg-components-input-bg-normal', + 'clamp group h-full w-0 grow rounded-lg border px-3 py-[6px]', + )} value={value} onChange={onChange} readOnly={readOnly} nodesOutputVars={availableVars} availableNodes={availableNodesWithParent} onFocusChange={setIsFocus} - placeholder={t($ => $['nodes.http.insertVarPlaceholder'], { ns: 'workflow' })!} + placeholder={t(($) => $['nodes.http.insertVarPlaceholder'], { ns: 'workflow' })!} placeholderClassName="leading-[21px]!" + promptMinHeightClassName="h-full" insertVarTipToLeft={insertVarTipToLeft} /> - ) - : ( - <div - className="h-full w-full pl-0.5 leading-[18px]" - > - {!hasValue && <div className="text-xs font-normal text-text-quaternary">{placeholder}</div>} - {hasValue && ( - <Input - instanceId={instanceId} - className={cn(isFocus ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-components-input-border-hover bg-components-input-bg-normal', 'clamp group h-full w-0 grow rounded-lg border px-3 py-[6px]')} - value={value} - onChange={onChange} - readOnly={readOnly} - nodesOutputVars={availableVars} - availableNodes={availableNodesWithParent} - onFocusChange={setIsFocus} - placeholder={t($ => $['nodes.http.insertVarPlaceholder'], { ns: 'workflow' })!} - placeholderClassName="leading-[21px]!" - promptMinHeightClassName="h-full" - insertVarTipToLeft={insertVarTipToLeft} - /> - )} - - </div> )} + </div> + )} {hasRemove && !isFocus && ( <RemoveButton className="absolute top-0.5 right-1 hidden group-hover:block" diff --git a/web/app/components/workflow/nodes/http/components/key-value/key-value-edit/item.tsx b/web/app/components/workflow/nodes/http/components/key-value/key-value-edit/item.tsx index 86e84d6e2aa15a..f2cfe7b4d837a9 100644 --- a/web/app/components/workflow/nodes/http/components/key-value/key-value-edit/item.tsx +++ b/web/app/components/workflow/nodes/http/components/key-value/key-value-edit/item.tsx @@ -55,71 +55,75 @@ const KeyValueItem: FC<Props> = ({ insertVarTipToLeft, }) => { const { t } = useTranslation() - const hasValuePayload = payload.type === 'file' - ? !!payload.file?.length - : !!payload.value + const hasValuePayload = payload.type === 'file' ? !!payload.file?.length : !!payload.value - const handleChange = useCallback((key: string) => { - return (value: string | ValueSelector) => { - const shouldAddNextItem = isLastItem - && ( - (key === 'value' && !payload.value && !!value) - || (key === 'file' && (!payload.file || payload.file.length === 0) && Array.isArray(value) && value.length > 0) - ) + const handleChange = useCallback( + (key: string) => { + return (value: string | ValueSelector) => { + const shouldAddNextItem = + isLastItem && + ((key === 'value' && !payload.value && !!value) || + (key === 'file' && + (!payload.file || payload.file.length === 0) && + Array.isArray(value) && + value.length > 0)) - const newPayload = produce(payload, (draft: any) => { - draft[key] = value - }) - onChange(newPayload) + const newPayload = produce(payload, (draft: any) => { + draft[key] = value + }) + onChange(newPayload) - if (shouldAddNextItem) - onAdd() - } - }, [isLastItem, onAdd, onChange, payload]) + if (shouldAddNextItem) onAdd() + } + }, + [isLastItem, onAdd, onChange, payload], + ) const filterOnlyFileVariable = (varPayload: Var) => { return [VarType.file, VarType.arrayFile].includes(varPayload.type) } const handleValueContainerClick = useCallback(() => { - if (isLastItem && hasValuePayload) - onAdd() + if (isLastItem && hasValuePayload) onAdd() }, [hasValuePayload, isLastItem, onAdd]) return ( // group class name is for hover row show remove button <div className={cn(className, 'group flex min-h-7 border-t border-divider-regular')}> - <div className={cn('shrink-0 border-r border-divider-regular', isSupportFile ? 'w-[140px]' : 'w-1/2')}> - {!keyNotSupportVar - ? ( - <InputItem - instanceId={`http-key-${instanceId}`} - nodeId={nodeId} - value={payload.key} - onChange={handleChange('key')} - hasRemove={false} - placeholder={t($ => $[`${i18nPrefix}.key`], { ns: 'workflow' })!} - readOnly={readonly} - insertVarTipToLeft={insertVarTipToLeft} - /> - ) - : ( - <input - className="appearance-none rounded-none border-none bg-transparent system-sm-regular outline-hidden hover:bg-components-input-bg-hover focus:bg-gray-100! focus:ring-0" - value={payload.key} - onChange={e => handleChange('key')(e.target.value)} - /> - )} + <div + className={cn( + 'shrink-0 border-r border-divider-regular', + isSupportFile ? 'w-[140px]' : 'w-1/2', + )} + > + {!keyNotSupportVar ? ( + <InputItem + instanceId={`http-key-${instanceId}`} + nodeId={nodeId} + value={payload.key} + onChange={handleChange('key')} + hasRemove={false} + placeholder={t(($) => $[`${i18nPrefix}.key`], { ns: 'workflow' })!} + readOnly={readonly} + insertVarTipToLeft={insertVarTipToLeft} + /> + ) : ( + <input + className="appearance-none rounded-none border-none bg-transparent system-sm-regular outline-hidden hover:bg-components-input-bg-hover focus:bg-gray-100! focus:ring-0" + value={payload.key} + onChange={(e) => handleChange('key')(e.target.value)} + /> + )} </div> {isSupportFile && ( <div className="w-[70px] shrink-0 border-r border-divider-regular"> <Select value={payload.type ?? 'text'} - onValueChange={value => value && handleChange('type')(value)} + onValueChange={(value) => value && handleChange('type')(value)} readOnly={readonly} > <SelectTrigger - aria-label={t($ => $[`${i18nPrefix}.type`], { ns: 'workflow' })} + aria-label={t(($) => $[`${i18nPrefix}.type`], { ns: 'workflow' })} className="h-7 rounded-none bg-transparent text-text-primary hover:bg-state-base-hover focus-visible:bg-state-base-hover data-popup-open:bg-state-base-hover" > <SelectValue /> @@ -137,37 +141,31 @@ const KeyValueItem: FC<Props> = ({ </Select> </div> )} - <div - className={cn(isSupportFile ? 'grow' : 'w-1/2')} - onClick={handleValueContainerClick} - > - {(isSupportFile && payload.type === 'file') - ? ( - <VarReferencePicker - nodeId={nodeId} - readonly={readonly} - value={payload.file || []} - onChange={handleChange('file')} - filterVar={filterOnlyFileVariable} - isInTable - onRemove={onRemove} - /> - ) - : ( - <InputItem - instanceId={`http-value-${instanceId}`} - nodeId={nodeId} - value={payload.value} - onChange={handleChange('value')} - hasRemove={!readonly && canRemove} - onRemove={onRemove} - placeholder={t($ => $[`${i18nPrefix}.value`], { ns: 'workflow' })!} - readOnly={readonly} - isSupportFile={isSupportFile} - insertVarTipToLeft={insertVarTipToLeft} - /> - )} - + <div className={cn(isSupportFile ? 'grow' : 'w-1/2')} onClick={handleValueContainerClick}> + {isSupportFile && payload.type === 'file' ? ( + <VarReferencePicker + nodeId={nodeId} + readonly={readonly} + value={payload.file || []} + onChange={handleChange('file')} + filterVar={filterOnlyFileVariable} + isInTable + onRemove={onRemove} + /> + ) : ( + <InputItem + instanceId={`http-value-${instanceId}`} + nodeId={nodeId} + value={payload.value} + onChange={handleChange('value')} + hasRemove={!readonly && canRemove} + onRemove={onRemove} + placeholder={t(($) => $[`${i18nPrefix}.value`], { ns: 'workflow' })!} + readOnly={readonly} + isSupportFile={isSupportFile} + insertVarTipToLeft={insertVarTipToLeft} + /> + )} </div> </div> ) diff --git a/web/app/components/workflow/nodes/http/components/timeout/index.tsx b/web/app/components/workflow/nodes/http/components/timeout/index.tsx index 8214d98fd7097e..a1a2e22d438618 100644 --- a/web/app/components/workflow/nodes/http/components/timeout/index.tsx +++ b/web/app/components/workflow/nodes/http/components/timeout/index.tsx @@ -41,8 +41,7 @@ const InputField: FC<{ if (inputValue === '') { // When user clears the input, set to undefined to let backend use default values onChange(undefined) - } - else { + } else { const parsedValue = Number.parseInt(inputValue, 10) if (!Number.isNaN(parsedValue)) { const value = Math.max(min, Math.min(max, parsedValue)) @@ -61,44 +60,45 @@ const InputField: FC<{ const Timeout: FC<Props> = ({ readonly, payload, onChange }) => { const { t } = useTranslation() - const { connect, read, write, max_connect_timeout, max_read_timeout, max_write_timeout } = payload ?? {} + const { connect, read, write, max_connect_timeout, max_read_timeout, max_write_timeout } = + payload ?? {} // Get default config from store for max timeout values - const nodesDefaultConfigs = useStore(s => s.nodesDefaultConfigs) + const nodesDefaultConfigs = useStore((s) => s.nodesDefaultConfigs) const defaultConfig = nodesDefaultConfigs?.[BlockEnum.HttpRequest] const defaultTimeout = defaultConfig?.timeout || {} return ( - <FieldCollapse title={t($ => $[`${i18nPrefix}.timeout.title`], { ns: 'workflow' })}> + <FieldCollapse title={t(($) => $[`${i18nPrefix}.timeout.title`], { ns: 'workflow' })}> <div className="mt-2 space-y-1"> <div className="space-y-3"> <InputField - title={t($ => $['nodes.http.timeout.connectLabel'], { ns: 'workflow' })!} - description={t($ => $['nodes.http.timeout.connectPlaceholder'], { ns: 'workflow' })!} - placeholder={t($ => $['nodes.http.timeout.connectPlaceholder'], { ns: 'workflow' })!} + title={t(($) => $['nodes.http.timeout.connectLabel'], { ns: 'workflow' })!} + description={t(($) => $['nodes.http.timeout.connectPlaceholder'], { ns: 'workflow' })!} + placeholder={t(($) => $['nodes.http.timeout.connectPlaceholder'], { ns: 'workflow' })!} readOnly={readonly} value={connect} - onChange={v => onChange?.({ ...payload, connect: v })} + onChange={(v) => onChange?.({ ...payload, connect: v })} min={1} max={max_connect_timeout || defaultTimeout.max_connect_timeout || 10} /> <InputField - title={t($ => $['nodes.http.timeout.readLabel'], { ns: 'workflow' })!} - description={t($ => $['nodes.http.timeout.readPlaceholder'], { ns: 'workflow' })!} - placeholder={t($ => $['nodes.http.timeout.readPlaceholder'], { ns: 'workflow' })!} + title={t(($) => $['nodes.http.timeout.readLabel'], { ns: 'workflow' })!} + description={t(($) => $['nodes.http.timeout.readPlaceholder'], { ns: 'workflow' })!} + placeholder={t(($) => $['nodes.http.timeout.readPlaceholder'], { ns: 'workflow' })!} readOnly={readonly} value={read} - onChange={v => onChange?.({ ...payload, read: v })} + onChange={(v) => onChange?.({ ...payload, read: v })} min={1} max={max_read_timeout || defaultTimeout.max_read_timeout || 600} /> <InputField - title={t($ => $['nodes.http.timeout.writeLabel'], { ns: 'workflow' })!} - description={t($ => $['nodes.http.timeout.writePlaceholder'], { ns: 'workflow' })!} - placeholder={t($ => $['nodes.http.timeout.writePlaceholder'], { ns: 'workflow' })!} + title={t(($) => $['nodes.http.timeout.writeLabel'], { ns: 'workflow' })!} + description={t(($) => $['nodes.http.timeout.writePlaceholder'], { ns: 'workflow' })!} + placeholder={t(($) => $['nodes.http.timeout.writePlaceholder'], { ns: 'workflow' })!} readOnly={readonly} value={write} - onChange={v => onChange?.({ ...payload, write: v })} + onChange={(v) => onChange?.({ ...payload, write: v })} min={1} max={max_write_timeout || defaultTimeout.max_write_timeout || 600} /> diff --git a/web/app/components/workflow/nodes/http/default.ts b/web/app/components/workflow/nodes/http/default.ts index 13f78cf2b58073..91adf23f25c407 100644 --- a/web/app/components/workflow/nodes/http/default.ts +++ b/web/app/components/workflow/nodes/http/default.ts @@ -43,12 +43,21 @@ const nodeDefault: NodeDefault<HttpNodeType> = { let errorMessages = '' if (!errorMessages && !payload.url) - errorMessages = t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: t($ => $['nodes.http.api'], { ns: 'workflow' }) }) + errorMessages = t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: t(($) => $['nodes.http.api'], { ns: 'workflow' }), + }) - if (!errorMessages - && payload.body.type === BodyType.binary - && ((!(payload.body.data as BodyPayload)[0]?.file) || (payload.body.data as BodyPayload)[0]?.file?.length === 0)) { - errorMessages = t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: t($ => $['nodes.http.binaryFileVariable'], { ns: 'workflow' }) }) + if ( + !errorMessages && + payload.body.type === BodyType.binary && + (!(payload.body.data as BodyPayload)[0]?.file || + (payload.body.data as BodyPayload)[0]?.file?.length === 0) + ) { + errorMessages = t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: t(($) => $['nodes.http.binaryFileVariable'], { ns: 'workflow' }), + }) } return { diff --git a/web/app/components/workflow/nodes/http/hooks/use-key-value-list.ts b/web/app/components/workflow/nodes/http/hooks/use-key-value-list.ts index cf749260bd0f36..1f270666326106 100644 --- a/web/app/components/workflow/nodes/http/hooks/use-key-value-list.ts +++ b/web/app/components/workflow/nodes/http/hooks/use-key-value-list.ts @@ -16,29 +16,30 @@ const strToKeyValueList = (value: string) => { } const normalizeList = (items: KeyValue[]) => { - return items.map(item => ({ + return items.map((item) => ({ ...item, id: item.id || uniqueId(UNIQUE_ID_PREFIX), })) } const stringifyList = (items: KeyValue[], noFilter?: boolean) => { - const source = noFilter ? items : items.filter(item => item.key && item.value) - return source.map(item => `${item.key}:${item.value}`).join('\n') + const source = noFilter ? items : items.filter((item) => item.key && item.value) + return source.map((item) => `${item.key}:${item.value}`).join('\n') } const useKeyValueList = (value: string, onChange: (value: string) => void, noFilter?: boolean) => { - const [list, doSetList] = useState<KeyValue[]>(() => value ? strToKeyValueList(value) : []) - const setList = useCallback((nextList: KeyValue[]) => { - const normalized = normalizeList(nextList) - doSetList(normalized) - if (noFilter) - return + const [list, doSetList] = useState<KeyValue[]>(() => (value ? strToKeyValueList(value) : [])) + const setList = useCallback( + (nextList: KeyValue[]) => { + const normalized = normalizeList(nextList) + doSetList(normalized) + if (noFilter) return - const newValue = stringifyList(normalized, noFilter) - if (newValue !== value) - onChange(newValue) - }, [noFilter, onChange, value]) + const newValue = stringifyList(normalized, noFilter) + if (newValue !== value) onChange(newValue) + }, + [noFilter, onChange, value], + ) useEffect(() => { Promise.resolve().then(() => { @@ -46,23 +47,23 @@ const useKeyValueList = (value: string, onChange: (value: string) => void, noFil const targetItems = value ? strToKeyValueList(value) : [] const currentValue = stringifyList(prev, noFilter) const targetValue = stringifyList(targetItems, noFilter) - if (currentValue === targetValue) - return prev + if (currentValue === targetValue) return prev return normalizeList(targetItems) }) }) }, [value, noFilter]) const addItem = useCallback(() => { - setList([...list, { - id: uniqueId(UNIQUE_ID_PREFIX), - key: '', - value: '', - }]) + setList([ + ...list, + { + id: uniqueId(UNIQUE_ID_PREFIX), + key: '', + value: '', + }, + ]) }, [list, setList]) - const [isKeyValueEdit, { - toggle: toggleIsKeyValueEdit, - }] = useBoolean(true) + const [isKeyValueEdit, { toggle: toggleIsKeyValueEdit }] = useBoolean(true) return { list: list.length === 0 ? [{ id: uniqueId(UNIQUE_ID_PREFIX), key: '', value: '' }] : list, // no item can not add new item diff --git a/web/app/components/workflow/nodes/http/node.tsx b/web/app/components/workflow/nodes/http/node.tsx index 97c88da18ddcfa..67d504624b7e7a 100644 --- a/web/app/components/workflow/nodes/http/node.tsx +++ b/web/app/components/workflow/nodes/http/node.tsx @@ -4,24 +4,18 @@ import type { NodeProps } from '@/app/components/workflow/types' import * as React from 'react' import ReadonlyInputWithSelectVar from '../_base/components/readonly-input-with-select-var' -const Node: FC<NodeProps<HttpNodeType>> = ({ - id, - data, -}) => { +const Node: FC<NodeProps<HttpNodeType>> = ({ id, data }) => { const { method, url } = data - if (!url) - return null + if (!url) return null return ( <div className="mb-1 px-3 py-1"> <div className="flex items-center justify-start rounded-md bg-workflow-block-parma-bg p-1"> - <div className="flex h-4 shrink-0 items-center rounded-sm bg-components-badge-white-to-dark px-1 text-xs font-semibold text-text-secondary uppercase">{method}</div> + <div className="flex h-4 shrink-0 items-center rounded-sm bg-components-badge-white-to-dark px-1 text-xs font-semibold text-text-secondary uppercase"> + {method} + </div> <div className="w-0 grow pt-1 pl-1"> - <ReadonlyInputWithSelectVar - className="text-text-secondary" - value={url} - nodeId={id} - /> + <ReadonlyInputWithSelectVar className="text-text-secondary" value={url} nodeId={id} /> </div> </div> </div> diff --git a/web/app/components/workflow/nodes/http/panel.tsx b/web/app/components/workflow/nodes/http/panel.tsx index 73e26c08e6d356..73fdde2fba0787 100644 --- a/web/app/components/workflow/nodes/http/panel.tsx +++ b/web/app/components/workflow/nodes/http/panel.tsx @@ -20,10 +20,7 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.http' -const Panel: FC<NodePanelProps<HttpNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<HttpNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { @@ -51,38 +48,47 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({ handleSSLVerifyChange, } = useConfig(id, data) // To prevent prompt editor in body not update data. - if (!isDataReady) - return null + if (!isDataReady) return null return ( <div className="pt-2"> <div className="space-y-4 px-4 pb-4"> <Field - title={t($ => $[`${i18nPrefix}.api`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.api`], { ns: 'workflow' })} required - operations={( + operations={ <div className="flex"> <div onClick={showAuthorization} - className={cn(!readOnly && 'cursor-pointer hover:bg-state-base-hover', 'flex h-6 items-center space-x-1 rounded-md px-2')} + className={cn( + !readOnly && 'cursor-pointer hover:bg-state-base-hover', + 'flex h-6 items-center space-x-1 rounded-md px-2', + )} > {!readOnly && <Settings01 className="size-3 text-text-tertiary" />} <div className="text-xs font-medium text-text-tertiary"> - {t($ => $[`${i18nPrefix}.authorization.authorization`], { ns: 'workflow' })} - <span className="ml-1 text-text-secondary">{t($ => $[`${i18nPrefix}.authorization.${inputs.authorization.type}`], { ns: 'workflow' })}</span> + {t(($) => $[`${i18nPrefix}.authorization.authorization`], { ns: 'workflow' })} + <span className="ml-1 text-text-secondary"> + {t(($) => $[`${i18nPrefix}.authorization.${inputs.authorization.type}`], { + ns: 'workflow', + })} + </span> </div> </div> <div onClick={showCurlPanel} - className={cn(!readOnly && 'cursor-pointer hover:bg-state-base-hover', 'flex h-6 items-center space-x-1 rounded-md px-2')} + className={cn( + !readOnly && 'cursor-pointer hover:bg-state-base-hover', + 'flex h-6 items-center space-x-1 rounded-md px-2', + )} > {!readOnly && <FileArrow01 className="size-3 text-text-tertiary" />} <div className="text-xs font-medium text-text-tertiary"> - {t($ => $[`${i18nPrefix}.curl.title`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.curl.title`], { ns: 'workflow' })} </div> </div> </div> - )} + } > <ApiInput nodeId={id} @@ -93,9 +99,7 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({ onUrlChange={handleUrlChange} /> </Field> - <Field - title={t($ => $[`${i18nPrefix}.headers`], { ns: 'workflow' })} - > + <Field title={t(($) => $[`${i18nPrefix}.headers`], { ns: 'workflow' })}> <KeyValue nodeId={id} list={headers} @@ -104,9 +108,7 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({ readonly={readOnly} /> </Field> - <Field - title={t($ => $[`${i18nPrefix}.params`], { ns: 'workflow' })} - > + <Field title={t(($) => $[`${i18nPrefix}.params`], { ns: 'workflow' })}> <KeyValue nodeId={id} list={params} @@ -115,39 +117,25 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({ readonly={readOnly} /> </Field> - <Field - title={t($ => $[`${i18nPrefix}.body`], { ns: 'workflow' })} - required - > - <EditBody - nodeId={id} - readonly={readOnly} - payload={inputs.body} - onChange={setBody} - /> + <Field title={t(($) => $[`${i18nPrefix}.body`], { ns: 'workflow' })} required> + <EditBody nodeId={id} readonly={readOnly} payload={inputs.body} onChange={setBody} /> </Field> <Field - title={t($ => $[`${i18nPrefix}.verifySSL.title`], { ns: 'workflow' })} - tooltip={t($ => $[`${i18nPrefix}.verifySSL.warningTooltip`], { ns: 'workflow' })} - operations={( + title={t(($) => $[`${i18nPrefix}.verifySSL.title`], { ns: 'workflow' })} + tooltip={t(($) => $[`${i18nPrefix}.verifySSL.warningTooltip`], { ns: 'workflow' })} + operations={ <Switch checked={!!inputs.ssl_verify} onCheckedChange={handleSSLVerifyChange} size="md" disabled={readOnly} /> - )} - > - </Field> + } + ></Field> </div> <Split /> - <Timeout - nodeId={id} - readonly={readOnly} - payload={inputs.timeout} - onChange={setTimeout} - /> - {(isShowAuthorization && !readOnly) && ( + <Timeout nodeId={id} readonly={readOnly} payload={inputs.timeout} onChange={setTimeout} /> + {isShowAuthorization && !readOnly && ( <AuthorizationModal nodeId={id} isShow @@ -163,33 +151,28 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({ <VarItem name="body" type="string" - description={t($ => $[`${i18nPrefix}.outputVars.body`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.body`], { ns: 'workflow' })} /> <VarItem name="status_code" type="number" - description={t($ => $[`${i18nPrefix}.outputVars.statusCode`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.statusCode`], { ns: 'workflow' })} /> <VarItem name="headers" type="object" - description={t($ => $[`${i18nPrefix}.outputVars.headers`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.headers`], { ns: 'workflow' })} /> <VarItem name="files" type="Array[File]" - description={t($ => $[`${i18nPrefix}.outputVars.files`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.files`], { ns: 'workflow' })} /> </> </OutputVars> </div> - {(isShowCurlPanel && !readOnly) && ( - <CurlPanel - nodeId={id} - isShow - onHide={hideCurlPanel} - handleCurlImport={handleCurlImport} - /> + {isShowCurlPanel && !readOnly && ( + <CurlPanel nodeId={id} isShow onHide={hideCurlPanel} handleCurlImport={handleCurlImport} /> )} </div> ) diff --git a/web/app/components/workflow/nodes/http/use-config.ts b/web/app/components/workflow/nodes/http/use-config.ts index fe8c8ac2368330..8e745fa4192997 100644 --- a/web/app/components/workflow/nodes/http/use-config.ts +++ b/web/app/components/workflow/nodes/http/use-config.ts @@ -3,9 +3,7 @@ import type { Authorization, Body, HttpNodeType, Method, Timeout } from './types import { useBoolean } from 'ahooks' import { produce } from 'immer' import { useCallback, useEffect, useState } from 'react' -import { - useNodesReadOnly, -} from '@/app/components/workflow/hooks' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useStore } from '../../store' import { VarType } from '../../types' @@ -17,7 +15,7 @@ import { transformToBodyPayload } from './utils' const useConfig = (id: string, payload: HttpNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() - const defaultConfig = useStore(s => s.nodesDefaultConfigs?.[payload.type]) + const defaultConfig = useStore((s) => s.nodesDefaultConfigs?.[payload.type]) const { inputs, setInputs } = useNodeCrud<HttpNodeType>(id, payload) @@ -39,10 +37,12 @@ const useConfig = (id: string, payload: HttpNodeType) => { if (typeof bodyData === 'string') { newInputs.body = { ...newInputs.body, - data: transformToBodyPayload(bodyData, [BodyType.formData, BodyType.xWwwFormUrlencoded].includes(newInputs.body.type)), + data: transformToBodyPayload( + bodyData, + [BodyType.formData, BodyType.xWwwFormUrlencoded].includes(newInputs.body.type), + ), } - } - else if (!bodyData) { + } else if (!bodyData) { newInputs.body = { ...newInputs.body, data: [], @@ -54,28 +54,37 @@ const useConfig = (id: string, payload: HttpNodeType) => { } }, [defaultConfig]) - const handleMethodChange = useCallback((method: Method) => { - const newInputs = produce(inputs, (draft: HttpNodeType) => { - draft.method = method - }) - setInputs(newInputs) - }, [inputs, setInputs]) - - const handleUrlChange = useCallback((url: string) => { - const newInputs = produce(inputs, (draft: HttpNodeType) => { - draft.url = url - }) - setInputs(newInputs) - }, [inputs, setInputs]) - - const handleFieldChange = useCallback((field: string) => { - return (value: string) => { + const handleMethodChange = useCallback( + (method: Method) => { const newInputs = produce(inputs, (draft: HttpNodeType) => { - (draft as any)[field] = value + draft.method = method }) setInputs(newInputs) - } - }, [inputs, setInputs]) + }, + [inputs, setInputs], + ) + + const handleUrlChange = useCallback( + (url: string) => { + const newInputs = produce(inputs, (draft: HttpNodeType) => { + draft.url = url + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) + + const handleFieldChange = useCallback( + (field: string) => { + return (value: string) => { + const newInputs = produce(inputs, (draft: HttpNodeType) => { + ;(draft as any)[field] = value + }) + setInputs(newInputs) + } + }, + [inputs, setInputs], + ) const { list: headers, @@ -93,60 +102,70 @@ const useConfig = (id: string, payload: HttpNodeType) => { toggleIsKeyValueEdit: toggleIsParamKeyValueEdit, } = useKeyValueList(inputs.params, handleFieldChange('params')) - const setBody = useCallback((data: Body) => { - const newInputs = produce(inputs, (draft: HttpNodeType) => { - draft.body = data - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const setBody = useCallback( + (data: Body) => { + const newInputs = produce(inputs, (draft: HttpNodeType) => { + draft.body = data + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) // authorization - const [isShowAuthorization, { - setTrue: showAuthorization, - setFalse: hideAuthorization, - }] = useBoolean(false) - - const setAuthorization = useCallback((authorization: Authorization) => { - const newInputs = produce(inputs, (draft: HttpNodeType) => { - draft.authorization = authorization - }) - setInputs(newInputs) - }, [inputs, setInputs]) - - const setTimeout = useCallback((timeout: Timeout) => { - const newInputs = produce(inputs, (draft: HttpNodeType) => { - draft.timeout = timeout - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const [isShowAuthorization, { setTrue: showAuthorization, setFalse: hideAuthorization }] = + useBoolean(false) + + const setAuthorization = useCallback( + (authorization: Authorization) => { + const newInputs = produce(inputs, (draft: HttpNodeType) => { + draft.authorization = authorization + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) + + const setTimeout = useCallback( + (timeout: Timeout) => { + const newInputs = produce(inputs, (draft: HttpNodeType) => { + draft.timeout = timeout + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) const filterVar = useCallback((varPayload: Var) => { return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) }, []) // curl import panel - const [isShowCurlPanel, { - setTrue: showCurlPanel, - setFalse: hideCurlPanel, - }] = useBoolean(false) - - const handleCurlImport = useCallback((newNode: HttpNodeType) => { - const newInputs = produce(inputs, (draft: HttpNodeType) => { - draft.method = newNode.method - draft.url = newNode.url - draft.headers = newNode.headers - draft.params = newNode.params - draft.body = newNode.body - }) - setInputs(newInputs) - }, [inputs, setInputs]) - - const handleSSLVerifyChange = useCallback((checked: boolean) => { - const newInputs = produce(inputs, (draft: HttpNodeType) => { - draft.ssl_verify = checked - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const [isShowCurlPanel, { setTrue: showCurlPanel, setFalse: hideCurlPanel }] = useBoolean(false) + + const handleCurlImport = useCallback( + (newNode: HttpNodeType) => { + const newInputs = produce(inputs, (draft: HttpNodeType) => { + draft.method = newNode.method + draft.url = newNode.url + draft.headers = newNode.headers + draft.params = newNode.params + draft.body = newNode.body + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) + + const handleSSLVerifyChange = useCallback( + (checked: boolean) => { + const newInputs = produce(inputs, (draft: HttpNodeType) => { + draft.ssl_verify = checked + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) return { readOnly, diff --git a/web/app/components/workflow/nodes/http/use-single-run-form-params.ts b/web/app/components/workflow/nodes/http/use-single-run-form-params.ts index 735ed082c3d828..661325756a00a4 100644 --- a/web/app/components/workflow/nodes/http/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/http/use-single-run-form-params.ts @@ -23,12 +23,11 @@ const useSingleRunFormParams = ({ const { inputs } = useNodeCrud<HttpNodeType>(id, payload) const fileVarInputs = useMemo(() => { - if (!Array.isArray(inputs.body.data)) - return '' + if (!Array.isArray(inputs.body.data)) return '' const res = inputs.body.data - .filter(item => item.file?.length) - .map(item => item.file ? `{{#${item.file.join('.')}#}}` : '') + .filter((item) => item.file?.length) + .map((item) => (item.file ? `{{#${item.file.join('.')}#}}` : '')) .join(' ') return res }, [inputs.body.data]) @@ -36,18 +35,22 @@ const useSingleRunFormParams = ({ inputs.url, inputs.headers, inputs.params, - typeof inputs.body.data === 'string' ? inputs.body.data : inputs.body.data?.map(item => item.value).join(''), + typeof inputs.body.data === 'string' + ? inputs.body.data + : inputs.body.data?.map((item) => item.value).join(''), fileVarInputs, ]) - const setInputVarValues = useCallback((newPayload: Record<string, any>) => { - setRunInputData(newPayload) - }, [setRunInputData]) + const setInputVarValues = useCallback( + (newPayload: Record<string, any>) => { + setRunInputData(newPayload) + }, + [setRunInputData], + ) const inputVarValues = (() => { const vars: Record<string, any> = {} - Object.keys(runInputData) - .forEach((key) => { - vars[key] = runInputData[key] - }) + Object.keys(runInputData).forEach((key) => { + vars[key] = runInputData[key] + }) return vars })() @@ -62,13 +65,14 @@ const useSingleRunFormParams = ({ }, [inputVarValues, setInputVarValues, varInputs]) const getDependentVars = () => { - return varInputs.map((item) => { - // Guard against null/undefined variable to prevent app crash - if (!item.variable || typeof item.variable !== 'string') - return [] + return varInputs + .map((item) => { + // Guard against null/undefined variable to prevent app crash + if (!item.variable || typeof item.variable !== 'string') return [] - return item.variable.slice(1, -1).split('.') - }).filter(arr => arr.length > 0) + return item.variable.slice(1, -1).split('.') + }) + .filter((arr) => arr.length > 0) } return { diff --git a/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx b/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx index ae115fc30429b4..5c0de350c9d3e0 100644 --- a/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx @@ -1,9 +1,6 @@ import type { ReactNode } from 'react' import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types' -import type { - Edge, - Node, -} from '@/app/components/workflow/types' +import type { Edge, Node } from '@/app/components/workflow/types' import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node' @@ -14,7 +11,10 @@ import { UserActionButtonType, } from '@/app/components/workflow/nodes/human-input/types' import { BlockEnum } from '@/app/components/workflow/types' -import { initialNodes, preprocessNodesAndEdges } from '@/app/components/workflow/utils/workflow-init' +import { + initialNodes, + preprocessNodesAndEdges, +} from '@/app/components/workflow/utils/workflow-init' import { withSelectorKey } from '@/test/i18n-mock' // Mock reactflow which is needed by initialNodes and NodeSourceHandle @@ -127,15 +127,21 @@ const createStartNode = (): Node => ({ } as Node['data'], }) -const createEdge = (source: string, target: string, sourceHandle = 'source', targetHandle = 'target'): Edge => ({ - id: `${source}-${sourceHandle}-${target}-${targetHandle}`, - type: 'custom', - source, - sourceHandle, - target, - targetHandle, - data: {}, -} as Edge) +const createEdge = ( + source: string, + target: string, + sourceHandle = 'source', + targetHandle = 'target', +): Edge => + ({ + id: `${source}-${sourceHandle}-${target}-${targetHandle}`, + type: 'custom', + source, + sourceHandle, + target, + targetHandle, + data: {}, + }) as Edge describe('DSL Import with Human Input Node', () => { // ── preprocessNodesAndEdges: human-input nodes pass through without error ── @@ -176,7 +182,7 @@ describe('DSL Import with Human Input Node', () => { const result = initialNodes(nodes as Node[], edges as Edge[]) - const processedHumanInput = result.find(n => n.id === 'human-input-1') + const processedHumanInput = result.find((n) => n.id === 'human-input-1') expect(processedHumanInput).toBeDefined() expect(processedHumanInput!.data.type).toBe(BlockEnum.HumanInput) // initialNodes sets _connectedSourceHandleIds and _connectedTargetHandleIds @@ -215,24 +221,14 @@ describe('DSL Import with Human Input Node', () => { const node = createHumanInputNode() expect(() => { - render( - <HumanInputNode - id={node.id} - data={node.data as HumanInputNodeType} - />, - ) + render(<HumanInputNode id={node.id} data={node.data as HumanInputNodeType} />) }).not.toThrow() }) it('should display delivery method labels when methods are present', () => { const node = createHumanInputNode() - render( - <HumanInputNode - id={node.id} - data={node.data as HumanInputNodeType} - />, - ) + render(<HumanInputNode id={node.id} data={node.data as HumanInputNodeType} />) // Delivery method type labels are rendered in lowercase // Delivery method type labels are rendered in lowercase @@ -243,12 +239,7 @@ describe('DSL Import with Human Input Node', () => { it('should display user action IDs', () => { const node = createHumanInputNode() - render( - <HumanInputNode - id={node.id} - data={node.data as HumanInputNodeType} - />, - ) + render(<HumanInputNode id={node.id} data={node.data as HumanInputNodeType} />) expect(screen.getByText('approve'))!.toBeInTheDocument() expect(screen.getByText('reject'))!.toBeInTheDocument() @@ -257,12 +248,7 @@ describe('DSL Import with Human Input Node', () => { it('should always display Timeout handle', () => { const node = createHumanInputNode() - render( - <HumanInputNode - id={node.id} - data={node.data as HumanInputNodeType} - />, - ) + render(<HumanInputNode id={node.id} data={node.data as HumanInputNodeType} />) expect(screen.getByText('Timeout'))!.toBeInTheDocument() }) @@ -271,12 +257,7 @@ describe('DSL Import with Human Input Node', () => { const node = createHumanInputNode({ delivery_methods: [] }) expect(() => { - render( - <HumanInputNode - id={node.id} - data={node.data as HumanInputNodeType} - />, - ) + render(<HumanInputNode id={node.id} data={node.data as HumanInputNodeType} />) }).not.toThrow() // Delivery method section should not be rendered @@ -319,12 +300,7 @@ describe('DSL Import with Human Input Node', () => { const node = createHumanInputNode({ user_actions: [] }) expect(() => { - render( - <HumanInputNode - id={node.id} - data={node.data as HumanInputNodeType} - />, - ) + render(<HumanInputNode id={node.id} data={node.data as HumanInputNodeType} />) }).not.toThrow() // Timeout handle should still exist @@ -341,28 +317,16 @@ describe('DSL Import with Human Input Node', () => { }) expect(() => { - render( - <HumanInputNode - id={node.id} - data={node.data as HumanInputNodeType} - />, - ) + render(<HumanInputNode id={node.id} data={node.data as HumanInputNodeType} />) }).not.toThrow() }) it('should render with only webapp delivery method', () => { const node = createHumanInputNode({ - delivery_methods: [ - { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }, - ], + delivery_methods: [{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }], }) - render( - <HumanInputNode - id={node.id} - data={node.data as HumanInputNodeType} - />, - ) + render(<HumanInputNode id={node.id} data={node.data as HumanInputNodeType} />) expect(screen.getByText('webapp'))!.toBeInTheDocument() expect(screen.queryByText('email')).not.toBeInTheDocument() @@ -377,12 +341,7 @@ describe('DSL Import with Human Input Node', () => { ], }) - render( - <HumanInputNode - id={node.id} - data={node.data as HumanInputNodeType} - />, - ) + render(<HumanInputNode id={node.id} data={node.data as HumanInputNodeType} />) expect(screen.getByText('action_1'))!.toBeInTheDocument() expect(screen.getByText('action_2'))!.toBeInTheDocument() @@ -395,9 +354,7 @@ describe('DSL Import with Human Input Node', () => { // of NodeComponentMap/PanelComponentMap which pull in every node's heavy UI deps. describe('Node Registration', () => { it('should have HumanInput included in WORKFLOW_COMMON_NODES', () => { - const entry = WORKFLOW_COMMON_NODES.find( - n => n.metaData.type === BlockEnum.HumanInput, - ) + const entry = WORKFLOW_COMMON_NODES.find((n) => n.metaData.type === BlockEnum.HumanInput) expect(entry).toBeDefined() }) }) @@ -432,9 +389,7 @@ describe('DSL Import with Human Input Node', () => { const t = withSelectorKey((key: string) => key, 'workflow') const payload = { ...humanInputDefault.defaultValue, - delivery_methods: [ - { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: false }, - ], + delivery_methods: [{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: false }], user_actions: [ { id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary }, ], @@ -497,25 +452,26 @@ describe('DSL Import with Human Input Node', () => { it('should validate enabled email subject and body content', () => { const t = withSelectorKey((key: string) => key, 'workflow') - const createPayload = (body: string, subject = 'Review request') => ({ - ...humanInputDefault.defaultValue, - delivery_methods: [ - { - id: 'dm-email', - type: DeliveryMethodType.Email, - enabled: true, - config: { - recipients: { whole_workspace: true, items: [] }, - subject, - body, - debug_mode: false, + const createPayload = (body: string, subject = 'Review request') => + ({ + ...humanInputDefault.defaultValue, + delivery_methods: [ + { + id: 'dm-email', + type: DeliveryMethodType.Email, + enabled: true, + config: { + recipients: { whole_workspace: true, items: [] }, + subject, + body, + debug_mode: false, + }, }, - }, - ], - user_actions: [ - { id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary }, - ], - }) as HumanInputNodeType + ], + user_actions: [ + { id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary }, + ], + }) as HumanInputNodeType expect(humanInputDefault.checkValid(createPayload('{{#url#}}', ' '), t)).toEqual({ isValid: false, @@ -531,9 +487,7 @@ describe('DSL Import with Human Input Node', () => { const t = withSelectorKey((key: string) => key, 'workflow') const payload = { ...humanInputDefault.defaultValue, - delivery_methods: [ - { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }, - ], + delivery_methods: [{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }], user_actions: [], } as HumanInputNodeType @@ -546,9 +500,7 @@ describe('DSL Import with Human Input Node', () => { const t = withSelectorKey((key: string) => key, 'workflow') const payload = { ...humanInputDefault.defaultValue, - delivery_methods: [ - { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }, - ], + delivery_methods: [{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }], user_actions: [ { id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary }, { id: 'approve', title: 'Also Approve', button_style: UserActionButtonType.Default }, @@ -562,23 +514,32 @@ describe('DSL Import with Human Input Node', () => { it('should validate that user action ids and titles are not empty', () => { const t = withSelectorKey((key: string) => key, 'workflow') - const createPayload = (userActions: HumanInputNodeType['user_actions']) => ({ - ...humanInputDefault.defaultValue, - delivery_methods: [ - { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }, - ], - user_actions: userActions, - }) as HumanInputNodeType - - expect(humanInputDefault.checkValid(createPayload([ - { id: ' ', title: 'Approve', button_style: UserActionButtonType.Primary }, - ]), t)).toEqual({ + const createPayload = (userActions: HumanInputNodeType['user_actions']) => + ({ + ...humanInputDefault.defaultValue, + delivery_methods: [{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }], + user_actions: userActions, + }) as HumanInputNodeType + + expect( + humanInputDefault.checkValid( + createPayload([ + { id: ' ', title: 'Approve', button_style: UserActionButtonType.Primary }, + ]), + t, + ), + ).toEqual({ isValid: false, errorMessage: 'nodes.humanInput.errorMsg.emptyActionId', }) - expect(humanInputDefault.checkValid(createPayload([ - { id: 'approve', title: ' ', button_style: UserActionButtonType.Primary }, - ]), t)).toEqual({ + expect( + humanInputDefault.checkValid( + createPayload([ + { id: 'approve', title: ' ', button_style: UserActionButtonType.Primary }, + ]), + t, + ), + ).toEqual({ isValid: false, errorMessage: 'nodes.humanInput.errorMsg.emptyActionTitle', }) @@ -588,9 +549,7 @@ describe('DSL Import with Human Input Node', () => { const t = withSelectorKey((key: string) => key, 'workflow') const payload = { ...humanInputDefault.defaultValue, - delivery_methods: [ - { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }, - ], + delivery_methods: [{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }], user_actions: [ { id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary }, { id: 'reject', title: 'Reject', button_style: UserActionButtonType.Default }, @@ -610,10 +569,31 @@ describe('DSL Import with Human Input Node', () => { const payload = { ...humanInputDefault.defaultValue, inputs: [ - { type: 'paragraph', output_variable_name: 'review_result', default: { selector: [], type: 'constant' as const, value: '' } }, - { type: 'file', output_variable_name: 'attachment', allowed_file_extensions: [], allowed_file_types: [], allowed_file_upload_methods: [] }, - { type: 'file-list', output_variable_name: 'attachments', allowed_file_extensions: [], allowed_file_types: [], allowed_file_upload_methods: [], number_limits: 3 }, - { type: 'select', output_variable_name: 'comment', option_source: { type: 'constant', selector: [], value: ['A', 'B'] } }, + { + type: 'paragraph', + output_variable_name: 'review_result', + default: { selector: [], type: 'constant' as const, value: '' }, + }, + { + type: 'file', + output_variable_name: 'attachment', + allowed_file_extensions: [], + allowed_file_types: [], + allowed_file_upload_methods: [], + }, + { + type: 'file-list', + output_variable_name: 'attachments', + allowed_file_extensions: [], + allowed_file_types: [], + allowed_file_upload_methods: [], + number_limits: 3, + }, + { + type: 'select', + output_variable_name: 'comment', + option_source: { type: 'constant', selector: [], value: ['A', 'B'] }, + }, ], } as HumanInputNodeType @@ -670,7 +650,7 @@ describe('DSL Import with Human Input Node', () => { expect(initialized).toHaveLength(3) // All node types should be preserved - const types = initialized.map(n => n.data.type) + const types = initialized.map((n) => n.data.type) expect(types).toContain(BlockEnum.Start) expect(types).toContain(BlockEnum.HumanInput) expect(types).toContain(BlockEnum.End) @@ -703,7 +683,7 @@ describe('DSL Import with Human Input Node', () => { expect(initialized).toHaveLength(4) // Human input node should still have correct data - const hiNode = initialized.find(n => n.id === 'human-input-1')! + const hiNode = initialized.find((n) => n.id === 'human-input-1')! expect((hiNode.data as HumanInputNodeType).user_actions).toHaveLength(2) expect((hiNode.data as HumanInputNodeType).delivery_methods).toHaveLength(2) }) diff --git a/web/app/components/workflow/nodes/human-input/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/human-input/__tests__/node.spec.tsx index fcffb286e59ab5..406f83cc84b6ae 100644 --- a/web/app/components/workflow/nodes/human-input/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/__tests__/node.spec.tsx @@ -12,34 +12,42 @@ const createData = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNode title: 'Human Input', desc: '', type: BlockEnum.HumanInput, - delivery_methods: [{ - id: 'dm-webapp', - type: DeliveryMethodType.WebApp, - enabled: true, - }, { - id: 'dm-email', - type: DeliveryMethodType.Email, - enabled: true, - }], + delivery_methods: [ + { + id: 'dm-webapp', + type: DeliveryMethodType.WebApp, + enabled: true, + }, + { + id: 'dm-email', + type: DeliveryMethodType.Email, + enabled: true, + }, + ], form_content: 'Please review this request', - inputs: [{ - type: InputVarType.paragraph, - output_variable_name: 'review_result', - default: { - selector: [], - type: 'constant', - value: '', + inputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'review_result', + default: { + selector: [], + type: 'constant', + value: '', + }, + }, + ], + user_actions: [ + { + id: 'approve', + title: 'Approve', + button_style: UserActionButtonType.Primary, }, - }], - user_actions: [{ - id: 'approve', - title: 'Approve', - button_style: UserActionButtonType.Primary, - }, { - id: 'reject', - title: 'Reject', - button_style: UserActionButtonType.Default, - }], + { + id: 'reject', + title: 'Reject', + button_style: UserActionButtonType.Default, + }, + ], timeout: 3, timeout_unit: 'day', ...overrides, @@ -47,12 +55,7 @@ const createData = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNode describe('human-input/node', () => { it('renders delivery methods, user action handles, and the timeout handle', () => { - render( - <Node - id="human-input-node" - data={createData()} - />, - ) + render(<Node id="human-input-node" data={createData()} />) expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.title')).toBeInTheDocument() expect(screen.getByText('webapp')).toBeInTheDocument() @@ -76,7 +79,9 @@ describe('human-input/node', () => { />, ) - expect(screen.queryByText('workflow.nodes.humanInput.deliveryMethod.title')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.humanInput.deliveryMethod.title'), + ).not.toBeInTheDocument() expect(screen.getByText('Timeout')).toBeInTheDocument() expect(screen.getByText('handle:__timeout')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx index b52302965760d3..4bb8ba3e5e2363 100644 --- a/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx @@ -32,10 +32,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ vi.mock('@/app/components/base/action-button', () => ({ __esModule: true, - default: (props: { - children: ReactNode - onClick: () => void - }) => ( + default: (props: { children: ReactNode; onClick: () => void }) => ( <button type="button" aria-label="action-button" onClick={props.onClick}> {props.children} </button> @@ -66,11 +63,15 @@ vi.mock('../components/delivery-method', () => ({ return ( <button type="button" - onClick={() => props.onChange([{ - id: 'dm-email', - type: DeliveryMethodType.Email, - enabled: true, - }])} + onClick={() => + props.onChange([ + { + id: 'dm-email', + type: DeliveryMethodType.Email, + enabled: true, + }, + ]) + } > {props.readonly ? 'delivery-method:readonly' : 'delivery-method:editable'} </button> @@ -91,21 +92,29 @@ vi.mock('../components/form-content', () => ({ mockFormContent(props) return ( <div> - <div>{props.readonly ? 'form-content:readonly' : `form-content:${props.isExpand ? 'expanded' : 'collapsed'}`}</div> + <div> + {props.readonly + ? 'form-content:readonly' + : `form-content:${props.isExpand ? 'expanded' : 'collapsed'}`} + </div> <button type="button" onClick={() => props.onChange('Updated content')}> change-form-content </button> <button type="button" - onClick={() => props.onFormInputsChange([{ - type: InputVarType.paragraph, - output_variable_name: 'email', - default: { - selector: [], - type: 'constant', - value: '', - }, - }])} + onClick={() => + props.onFormInputsChange([ + { + type: InputVarType.paragraph, + output_variable_name: 'email', + default: { + selector: [], + type: 'constant', + value: '', + }, + }, + ]) + } > change-form-inputs </button> @@ -122,9 +131,7 @@ vi.mock('../components/form-content', () => ({ vi.mock('../components/form-content-preview', () => ({ __esModule: true, - default: (props: { - onClose: () => void - }) => { + default: (props: { onClose: () => void }) => { mockFormContentPreview(props) return ( <div> @@ -141,14 +148,11 @@ vi.mock('../components/timeout', () => ({ __esModule: true, default: (props: { readonly: boolean - onChange: (value: { timeout: number, unit: 'hour' | 'day' }) => void + onChange: (value: { timeout: number; unit: 'hour' | 'day' }) => void }) => { mockTimeoutInput(props) return ( - <button - type="button" - onClick={() => props.onChange({ timeout: 8, unit: 'hour' })} - > + <button type="button" onClick={() => props.onChange({ timeout: 8, unit: 'hour' })}> {props.readonly ? 'timeout:readonly' : 'timeout:editable'} </button> ) @@ -169,10 +173,12 @@ vi.mock('../components/user-action', () => ({ <div>{`${props.data.id}:${props.readonly ? 'readonly' : 'editable'}`}</div> <button type="button" - onClick={() => props.onChange({ - ...props.data, - title: `${props.data.title} updated`, - })} + onClick={() => + props.onChange({ + ...props.data, + title: `${props.data.title} updated`, + }) + } > {`change-action-${props.data.id}`} </button> @@ -198,7 +204,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ {props.children} </div> ), - VarItem: ({ name, type, description }: { name: string, type: string, description: string }) => ( + VarItem: ({ name, type, description }: { name: string; type: string; description: string }) => ( <div>{`${name}:${type}:${description}`}</div> ), })) @@ -218,32 +224,40 @@ const createData = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNode title: 'Human Input', desc: '', type: BlockEnum.HumanInput, - delivery_methods: [{ - id: 'dm-webapp', - type: DeliveryMethodType.WebApp, - enabled: true, - }], + delivery_methods: [ + { + id: 'dm-webapp', + type: DeliveryMethodType.WebApp, + enabled: true, + }, + ], form_content: 'Please review this request', - inputs: [{ - type: InputVarType.paragraph, - output_variable_name: 'review_result', - default: { - selector: [], - type: 'constant', - value: '', + inputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'review_result', + default: { + selector: [], + type: 'constant', + value: '', + }, }, - }], - user_actions: [{ - id: 'approve', - title: 'Approve', - button_style: UserActionButtonType.Primary, - }], + ], + user_actions: [ + { + id: 'approve', + title: 'Approve', + button_style: UserActionButtonType.Primary, + }, + ], timeout: 3, timeout_unit: 'day', ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), handleDeliveryMethodChange: vi.fn(), @@ -281,26 +295,36 @@ const renderPanel = (data: HumanInputNodeType = createData()) => { describe('human-input/panel', () => { beforeEach(() => { vi.clearAllMocks() - mockUseStore.mockImplementation(selector => selector({ nodePanelWidth: 480 })) - mockUseAvailableVarList.mockImplementation((_id, options?: { filterVar?: (payload: { type: VarType }) => boolean }) => ({ - availableVars: [{ - variable: ['start', 'email'], - type: VarType.string, - }, { - variable: ['code', 'result'], - type: VarType.arrayString, - }, { - variable: ['start', 'files'], - type: VarType.file, - }].filter(variable => options?.filterVar ? options.filterVar({ type: variable.type } as never) : true), - availableNodesWithParent: [{ - id: 'start-node', - data: { - title: 'Start', - type: BlockEnum.Start, - }, - }], - })) + mockUseStore.mockImplementation((selector) => selector({ nodePanelWidth: 480 })) + mockUseAvailableVarList.mockImplementation( + (_id, options?: { filterVar?: (payload: { type: VarType }) => boolean }) => ({ + availableVars: [ + { + variable: ['start', 'email'], + type: VarType.string, + }, + { + variable: ['code', 'result'], + type: VarType.arrayString, + }, + { + variable: ['start', 'files'], + type: VarType.file, + }, + ].filter((variable) => + options?.filterVar ? options.filterVar({ type: variable.type } as never) : true, + ), + availableNodesWithParent: [ + { + id: 'start-node', + data: { + title: 'Start', + type: BlockEnum.Start, + }, + }, + ], + }), + ) mockUseConfig.mockReturnValue(createConfigResult()) }) @@ -318,15 +342,19 @@ describe('human-input/panel', () => { expect(screen.getByText('__action_id:string:Action ID user triggered')).toBeInTheDocument() expect(screen.getByText('__action_value:string:Selected action value')).toBeInTheDocument() expect(screen.getByText('__rendered_content:string:Rendered content')).toBeInTheDocument() - expect(mockDeliveryMethod).toHaveBeenCalledWith(expect.objectContaining({ - nodesOutputVars: [ - expect.objectContaining({ type: VarType.string }), - expect.objectContaining({ type: VarType.arrayString }), - ], - })) + expect(mockDeliveryMethod).toHaveBeenCalledWith( + expect.objectContaining({ + nodesOutputVars: [ + expect.objectContaining({ type: VarType.string }), + expect.objectContaining({ type: VarType.arrayString }), + ], + }), + ) await user.click(screen.getByRole('button', { name: 'delivery-method:editable' })) - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.formContent\.preview/ })) + await user.click( + screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.formContent\.preview/ }), + ) await user.click(screen.getByRole('button', { name: 'change-form-content' })) await user.click(screen.getByRole('button', { name: 'change-form-inputs' })) await user.click(screen.getByRole('button', { name: 'rename-form-input' })) @@ -341,11 +369,13 @@ describe('human-input/panel', () => { await user.click(screen.getByRole('button', { name: 'common.operation.copy' })) await user.click(screen.getByRole('button', { name: 'share.chat.expand' })) - expect(config.handleDeliveryMethodChange).toHaveBeenCalledWith([{ - id: 'dm-email', - type: DeliveryMethodType.Email, - enabled: true, - }]) + expect(config.handleDeliveryMethodChange).toHaveBeenCalledWith([ + { + id: 'dm-email', + type: DeliveryMethodType.Email, + enabled: true, + }, + ]) expect(config.handleFormContentChange).toHaveBeenCalledWith('Updated content') expect(config.handleFormInputsChange).toHaveBeenCalled() expect(config.handleFormInputItemRename).toHaveBeenCalledWith('name', 'email') @@ -369,47 +399,53 @@ describe('human-input/panel', () => { }) it('renders readonly and empty states without preview or add controls', () => { - mockUseConfig.mockReturnValue(createConfigResult({ - readOnly: true, - inputs: createData({ - user_actions: [], + mockUseConfig.mockReturnValue( + createConfigResult({ + readOnly: true, + inputs: createData({ + user_actions: [], + }), + structuredOutputCollapsed: false, }), - structuredOutputCollapsed: false, - })) + ) renderPanel() expect(screen.getByRole('button', { name: 'delivery-method:readonly' })).toBeInTheDocument() expect(screen.getByText('form-content:readonly')).toBeInTheDocument() expect(screen.getByText('workflow.nodes.humanInput.userActions.emptyTip')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /workflow\.nodes\.humanInput\.formContent\.preview/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /workflow\.nodes\.humanInput\.formContent\.preview/ }), + ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'action-button' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'timeout:readonly' })).toBeInTheDocument() expect(screen.queryByText('form-preview')).not.toBeInTheDocument() }) it('renders file outputs with file-aware types', () => { - mockUseConfig.mockReturnValue(createConfigResult({ - inputs: createData({ - inputs: [ - { - type: InputVarType.singleFile, - output_variable_name: 'attachment', - allowed_file_extensions: [], - allowed_file_types: [], - allowed_file_upload_methods: [], - }, - { - type: InputVarType.multiFiles, - output_variable_name: 'attachments', - allowed_file_extensions: [], - allowed_file_types: [], - allowed_file_upload_methods: [], - number_limits: 3, - }, - ], + mockUseConfig.mockReturnValue( + createConfigResult({ + inputs: createData({ + inputs: [ + { + type: InputVarType.singleFile, + output_variable_name: 'attachment', + allowed_file_extensions: [], + allowed_file_types: [], + allowed_file_upload_methods: [], + }, + { + type: InputVarType.multiFiles, + output_variable_name: 'attachments', + allowed_file_extensions: [], + allowed_file_types: [], + allowed_file_upload_methods: [], + number_limits: 3, + }, + ], + }), }), - })) + ) renderPanel() diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/add-input-field.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/add-input-field.spec.tsx index 2dd829fe411845..3eb9c813118a82 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/add-input-field.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/add-input-field.spec.tsx @@ -19,19 +19,23 @@ vi.mock('@/app/components/base/prompt-editor/plugins/hitl-input-block/input-fiel <div> <button type="button" - onClick={() => props.onChange({ - type: InputVarType.paragraph, - output_variable_name: 'comment', - default: { - type: 'constant', - selector: [], - value: '', - }, - })} + onClick={() => + props.onChange({ + type: InputVarType.paragraph, + output_variable_name: 'comment', + default: { + type: 'constant', + selector: [], + value: '', + }, + }) + } > save </button> - <button type="button" onClick={props.onCancel}>cancel</button> + <button type="button" onClick={props.onCancel}> + cancel + </button> </div> ) }, @@ -55,19 +59,23 @@ describe('human-input/components/add-input-field', () => { />, ) - expect(mockInputField).toHaveBeenCalledWith(expect.objectContaining({ - nodeId: 'human-node', - isEdit: false, - unavailableVariableNames: ['comment'], - onChange: handleSave, - onCancel: handleCancel, - })) + expect(mockInputField).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: 'human-node', + isEdit: false, + unavailableVariableNames: ['comment'], + onChange: handleSave, + onCancel: handleCancel, + }), + ) fireEvent.click(screen.getByRole('button', { name: 'save' })) - expect(handleSave).toHaveBeenCalledWith(expect.objectContaining({ - type: InputVarType.paragraph, - output_variable_name: 'comment', - })) + expect(handleSave).toHaveBeenCalledWith( + expect.objectContaining({ + type: InputVarType.paragraph, + output_variable_name: 'comment', + }), + ) fireEvent.click(screen.getByRole('button', { name: 'cancel' })) expect(handleCancel).toHaveBeenCalledTimes(1) diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx index 79e9d1e724fffb..119099c1672406 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx @@ -12,11 +12,7 @@ vi.mock('react-i18next', () => ({ })) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: (props: { - variant?: string - children?: React.ReactNode - className?: string - }) => { + Button: (props: { variant?: string; children?: React.ReactNode; className?: string }) => { mockButton(props) return <div data-testid={`button-${props.variant ?? 'default'}`}>{props.children}</div> }, @@ -36,16 +32,14 @@ describe('ButtonStyleDropdown', () => { it('should map the current style to the trigger button and update the selected style', () => { render( - <ButtonStyleDropdown - text="Approve" - data={UserActionButtonType.Ghost} - onChange={onChange} - />, + <ButtonStyleDropdown text="Approve" data={UserActionButtonType.Ghost} onChange={onChange} />, ) - expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({ - variant: 'ghost', - })) + expect(mockButton).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'ghost', + }), + ) expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false') fireEvent.click(screen.getByTestId('popover-trigger')) @@ -73,9 +67,11 @@ describe('ButtonStyleDropdown', () => { />, ) - expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({ - variant: 'secondary', - })) + expect(mockButton).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'secondary', + }), + ) fireEvent.click(screen.getByTestId('popover-trigger')) @@ -86,16 +82,14 @@ describe('ButtonStyleDropdown', () => { it('should map the accent style to the secondary-accent trigger button', () => { render( - <ButtonStyleDropdown - text="Approve" - data={UserActionButtonType.Accent} - onChange={onChange} - />, + <ButtonStyleDropdown text="Approve" data={UserActionButtonType.Accent} onChange={onChange} />, ) - expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({ - variant: 'secondary-accent', - })) + expect(mockButton).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'secondary-accent', + }), + ) }) it('should map the primary style to the primary trigger button', () => { @@ -107,8 +101,10 @@ describe('ButtonStyleDropdown', () => { />, ) - expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({ - variant: 'primary', - })) + expect(mockButton).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'primary', + }), + ) }) }) diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.spec.tsx index 24ae8de77dc195..7cde62207e4971 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.spec.tsx @@ -24,7 +24,7 @@ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ vi.mock('@/app/components/base/action-button', () => ({ __esModule: true, - default: ({ children, onClick }: { children?: ReactNode, onClick?: () => void }) => ( + default: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => ( <button type="button" aria-label="close-preview" onClick={onClick}> {children} </button> @@ -37,8 +37,10 @@ vi.mock('@/app/components/base/badge', () => ({ })) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, variant }: { children?: ReactNode, variant?: string }) => ( - <button type="button" data-testid={`action-${variant}`}>{children}</button> + Button: ({ children, variant }: { children?: ReactNode; variant?: string }) => ( + <button type="button" data-testid={`action-${variant}`}> + {children} + </button> ), })) @@ -47,7 +49,9 @@ vi.mock('@/app/components/base/chat/chat/answer/human-input-content/utils', () = })) vi.mock('@/app/components/base/markdown', () => ({ - Markdown: ({ customComponents }: { + Markdown: ({ + customComponents, + }: { customComponents: { variable: (props: { node: { properties: { dataPath: string } } }) => ReactNode section: (props: { node: { properties: { dataName: string } } }) => ReactNode @@ -65,12 +69,21 @@ vi.mock('../variable-in-markdown', () => ({ rehypeNotes: vi.fn(), rehypeVariable: vi.fn(), Variable: ({ path }: { path: string }) => <div data-testid="variable-path">{path}</div>, - Note: ({ input, nodeName }: { - input: { type: string, default?: { selector: string[] }, option_source?: { selector: string[] } } + Note: ({ + input, + nodeName, + }: { + input: { + type: string + default?: { selector: string[] } + option_source?: { selector: string[] } + } nodeName: (nodeId: string) => string }) => ( <div data-testid="note"> - {input.default?.selector?.length ? nodeName(input.default.selector[0]!) : input.option_source?.selector?.join('.') || input.type} + {input.default?.selector?.length + ? nodeName(input.default.selector[0]!) + : input.option_source?.selector?.join('.') || input.type} </div> ), })) @@ -83,11 +96,15 @@ describe('FormContentPreview', () => { mockUseTranslation.mockReturnValue({ t: withSelectorKey((key: string) => key), }) - mockUseStore.mockImplementation((selector: (state: { panelWidth: number }) => unknown) => selector({ panelWidth: 320 })) - mockUseNodes.mockReturnValue([{ - id: 'node-1', - data: { title: 'Classifier' }, - }]) + mockUseStore.mockImplementation((selector: (state: { panelWidth: number }) => unknown) => + selector({ panelWidth: 320 }), + ) + mockUseNodes.mockReturnValue([ + { + id: 'node-1', + data: { title: 'Classifier' }, + }, + ]) mockGetButtonStyle.mockImplementation((style: UserActionButtonType) => style.toLowerCase()) }) @@ -95,20 +112,24 @@ describe('FormContentPreview', () => { const { container } = render( <FormContentPreview content="content" - formInputs={[{ - type: 'text-input' as never, - output_variable_name: 'field_1', - default: { - type: 'variable', - selector: ['node-1', 'answer'], - value: '', + formInputs={[ + { + type: 'text-input' as never, + output_variable_name: 'field_1', + default: { + type: 'variable', + selector: ['node-1', 'answer'], + value: '', + }, + }, + ]} + userActions={[ + { + id: 'approve', + title: 'Approve', + button_style: UserActionButtonType.Primary, }, - }]} - userActions={[{ - id: 'approve', - title: 'Approve', - button_style: UserActionButtonType.Primary, - }]} + ]} onClose={onClose} />, ) @@ -124,12 +145,7 @@ describe('FormContentPreview', () => { it('should close the preview when the close action is clicked', () => { render( - <FormContentPreview - content="content" - formInputs={[]} - userActions={[]} - onClose={onClose} - />, + <FormContentPreview content="content" formInputs={[]} userActions={[]} onClose={onClose} />, ) fireEvent.click(screen.getByRole('button', { name: 'close-preview' })) @@ -141,15 +157,17 @@ describe('FormContentPreview', () => { render( <FormContentPreview content="content" - formInputs={[{ - type: 'select' as never, - output_variable_name: 'field_1', - option_source: { - type: 'variable', - selector: ['node-1', 'items'], - value: [], + formInputs={[ + { + type: 'select' as never, + output_variable_name: 'field_1', + option_source: { + type: 'variable', + selector: ['node-1', 'items'], + value: [], + }, }, - }]} + ]} userActions={[]} onClose={onClose} />, diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx index d43a7c8ae9fd37..d1eeb072942da3 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx @@ -13,22 +13,18 @@ const mockOnInsert = vi.hoisted(() => vi.fn()) vi.mock('react-i18next', async () => { const { withSelectorKeyProps } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => mockUseTranslation(), - Trans: withSelectorKeyProps(({ - i18nKey, - components, - }: { - i18nKey: string - components?: Record<string, ReactNode> - }) => ( - <div> - <div>{i18nKey}</div> - {components?.CtrlKey} - {components?.Key} - </div> - )), - }) + Trans: withSelectorKeyProps( + ({ i18nKey, components }: { i18nKey: string; components?: Record<string, ReactNode> }) => ( + <div> + <div>{i18nKey}</div> + {components?.CtrlKey} + {components?.Key} + </div> + ), + ), + } }) vi.mock('@/app/components/workflow/hooks', () => ({ @@ -50,7 +46,7 @@ vi.mock('@/app/components/base/prompt-editor', () => ({ onFocus: () => void onBlur: () => void shortcutPopups?: Array<{ - Popup: (props: { onClose: () => void, onInsert: typeof mockOnInsert }) => ReactNode + Popup: (props: { onClose: () => void; onInsert: typeof mockOnInsert }) => ReactNode }> editable?: boolean hitlInputBlock: { @@ -62,9 +58,15 @@ vi.mock('@/app/components/base/prompt-editor', () => ({ const Popup = popup?.Popup return ( <div> - <button type="button" onClick={props.onFocus}>focus-editor</button> - <button type="button" onClick={props.onBlur}>blur-editor</button> - <button type="button" onClick={() => props.onChange('updated value')}>change-editor</button> + <button type="button" onClick={props.onFocus}> + focus-editor + </button> + <button type="button" onClick={props.onBlur}> + blur-editor + </button> + <button type="button" onClick={() => props.onChange('updated value')}> + change-editor + </button> {Popup && <Popup onClose={vi.fn()} onInsert={mockOnInsert} />} </div> ) @@ -94,23 +96,27 @@ vi.mock('../add-input-field', () => ({ <input aria-label="field-name" value={draftName} - onChange={event => setDraftName(event.target.value)} + onChange={(event) => setDraftName(event.target.value)} /> <button type="button" - onClick={() => props.onSave({ - type: 'text-input', - output_variable_name: 'approval', - default: { - type: 'variable', - selector: ['node-1', 'answer'], - value: '', - }, - })} + onClick={() => + props.onSave({ + type: 'text-input', + output_variable_name: 'approval', + default: { + type: 'variable', + selector: ['node-1', 'answer'], + value: '', + }, + }) + } > save-input </button> - <button type="button" onClick={props.onCancel}>cancel-input</button> + <button type="button" onClick={props.onCancel}> + cancel-input + </button> </div> ) }, @@ -167,35 +173,40 @@ describe('FormContent', () => { />, ) - expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({ - editable: true, - shortcutPopups: [ - expect.objectContaining({ - hotkey: ['mod', '/'], - displayMode: 'workflow-panel-adjacent-center', - }), - ], - hitlInputBlock: expect.objectContaining({ - workflowNodesMap: expect.objectContaining({ - 'node-1': expect.objectContaining({ title: 'Start' }), - 'node-2': expect.objectContaining({ title: 'Classifier' }), - 'sys': expect.objectContaining({ title: 'blocks.start' }), + expect(mockPromptEditor).toHaveBeenCalledWith( + expect.objectContaining({ + editable: true, + shortcutPopups: [ + expect.objectContaining({ + hotkey: ['mod', '/'], + displayMode: 'workflow-panel-adjacent-center', + }), + ], + hitlInputBlock: expect.objectContaining({ + workflowNodesMap: expect.objectContaining({ + 'node-1': expect.objectContaining({ title: 'Start' }), + 'node-2': expect.objectContaining({ title: 'Classifier' }), + sys: expect.objectContaining({ title: 'blocks.start' }), + }), }), }), - })) + ) fireEvent.click(screen.getByText('focus-editor')) expect(screen.getByText('nodes.humanInput.formContent.hotkeyTip')).toBeInTheDocument() fireEvent.click(screen.getByText('save-input')) - expect(mockOnInsert).toHaveBeenCalledWith('INSERT_HITL_INPUT_BLOCK_COMMAND', expect.objectContaining({ - variableName: 'approval', - nodeId: 'node-2', - formInputs: [expect.objectContaining({ output_variable_name: 'approval' })], - onFormInputsChange, - onFormInputItemRename, - onFormInputItemRemove, - })) + expect(mockOnInsert).toHaveBeenCalledWith( + 'INSERT_HITL_INPUT_BLOCK_COMMAND', + expect.objectContaining({ + variableName: 'approval', + nodeId: 'node-2', + formInputs: [expect.objectContaining({ output_variable_name: 'approval' })], + onFormInputsChange, + onFormInputItemRename, + onFormInputItemRemove, + }), + ) expect(onFormInputsChange).not.toHaveBeenCalled() rerender( @@ -247,10 +258,12 @@ describe('FormContent', () => { />, ) - expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({ - editable: false, - shortcutPopups: [], - })) + expect(mockPromptEditor).toHaveBeenCalledWith( + expect.objectContaining({ + editable: false, + shortcutPopups: [], + }), + ) expect(screen.queryByText('save-input')).not.toBeInTheDocument() expect(container.firstChild).toHaveClass('pointer-events-none') }) @@ -261,15 +274,17 @@ describe('FormContent', () => { nodeId="node-2" value="Initial content" onChange={onChange} - formInputs={[{ - type: 'paragraph', - output_variable_name: 'approval', - default: { - type: 'constant', - selector: [], - value: '', - }, - } as never]} + formInputs={[ + { + type: 'paragraph', + output_variable_name: 'approval', + default: { + type: 'constant', + selector: [], + value: '', + }, + } as never, + ]} onFormInputsChange={onFormInputsChange} onFormInputItemRename={onFormInputItemRename} onFormInputItemRemove={onFormInputItemRemove} @@ -280,9 +295,11 @@ describe('FormContent', () => { />, ) - expect(mockAddInputField).toHaveBeenCalledWith(expect.objectContaining({ - unavailableVariableNames: ['approval'], - })) + expect(mockAddInputField).toHaveBeenCalledWith( + expect.objectContaining({ + unavailableVariableNames: ['approval'], + }), + ) fireEvent.click(screen.getByText('save-input')) diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.spec.tsx index 0763096bbd33be..890f72dcaa4f23 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.spec.tsx @@ -21,23 +21,23 @@ vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item onInputChange: (name: string, value: HumanInputFieldValue) => void }) => { const fieldName = /\{\{#\$output\.([^#]+)#\}\}/.exec(content)?.[1] - if (!fieldName) - return <div>{content}</div> + if (!fieldName) return <div>{content}</div> - const field = formInputFields.find(field => field.output_variable_name === fieldName) - if (!field) - return null + const field = formInputFields.find((field) => field.output_variable_name === fieldName) + if (!field) return null if (field.type === 'select') { return ( <select aria-label={fieldName} value={typeof inputs[fieldName] === 'string' ? inputs[fieldName] : ''} - onChange={event => onInputChange(fieldName, event.target.value)} + onChange={(event) => onInputChange(fieldName, event.target.value)} > <option value="">Select</option> - {field.option_source.value.map(option => ( - <option key={option} value={option}>{option}</option> + {field.option_source.value.map((option) => ( + <option key={option} value={option}> + {option} + </option> ))} </select> ) @@ -48,7 +48,7 @@ vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item <textarea aria-label={fieldName} value={typeof inputs[fieldName] === 'string' ? inputs[fieldName] : ''} - onChange={event => onInputChange(fieldName, event.target.value)} + onChange={(event) => onInputChange(fieldName, event.target.value)} /> ) } @@ -62,20 +62,24 @@ const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInput node_id: 'human-1', node_title: 'Review', form_content: 'Please review {{#$output.review#}}', - inputs: [{ - type: InputVarType.paragraph, - output_variable_name: 'review', - default: { - selector: [], - type: 'constant', - value: 'initial review', + inputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'review', + default: { + selector: [], + type: 'constant', + value: 'initial review', + }, }, - }], - actions: [{ - id: 'approve', - title: 'Approve', - button_style: UserActionButtonType.Primary, - }], + ], + actions: [ + { + id: 'approve', + title: 'Approve', + button_style: UserActionButtonType.Primary, + }, + ], form_token: 'token', resolved_default_values: {}, display_in_ui: true, @@ -101,7 +105,9 @@ describe('SingleRunForm', () => { />, ) - await user.click(screen.getByRole('button', { name: /(?:^|\.)nodes\.humanInput\.singleRun\.back(?=$|:)/ })) + await user.click( + screen.getByRole('button', { name: /(?:^|\.)nodes\.humanInput\.singleRun\.back(?=$|:)/ }), + ) expect(handleBack).toHaveBeenCalledTimes(1) }) @@ -110,13 +116,7 @@ describe('SingleRunForm', () => { const user = userEvent.setup() const onSubmit = vi.fn().mockResolvedValue(undefined) - render( - <SingleRunForm - nodeName="Review" - data={createFormData()} - onSubmit={onSubmit} - />, - ) + render(<SingleRunForm nodeName="Review" data={createFormData()} onSubmit={onSubmit} />) await user.click(screen.getByRole('button', { name: 'Approve' })) @@ -132,13 +132,7 @@ describe('SingleRunForm', () => { const user = userEvent.setup() const onSubmit = vi.fn().mockResolvedValue(undefined) - render( - <SingleRunForm - nodeName="Review" - data={createFormData()} - onSubmit={onSubmit} - />, - ) + render(<SingleRunForm nodeName="Review" data={createFormData()} onSubmit={onSubmit} />) await user.clear(screen.getByRole('textbox', { name: 'review' })) await user.type(screen.getByRole('textbox', { name: 'review' }), 'updated review') @@ -160,15 +154,17 @@ describe('SingleRunForm', () => { <SingleRunForm nodeName="Review" data={createFormData({ - inputs: [{ - type: InputVarType.paragraph, - output_variable_name: 'review', - default: { - selector: ['source', 'answer'], - type: 'variable', - value: 'fallback review', + inputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'review', + default: { + selector: ['source', 'answer'], + type: 'variable', + value: 'fallback review', + }, }, - }], + ], resolved_default_values: { review: 'resolved review', }, @@ -196,15 +192,17 @@ describe('SingleRunForm', () => { nodeName="Review" data={createFormData({ form_content: 'Choose {{#$output.choice#}}', - inputs: [{ - type: InputVarType.select, - output_variable_name: 'choice', - option_source: { - selector: [], - type: 'constant', - value: ['approve', 'reject'], + inputs: [ + { + type: InputVarType.select, + output_variable_name: 'choice', + option_source: { + selector: [], + type: 'constant', + value: ['approve', 'reject'], + }, }, - }], + ], })} onSubmit={onSubmit} />, diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx index cc50c48c0a3a39..13be4ebff39191 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx @@ -19,7 +19,7 @@ vi.mock('@/app/components/base/input', () => ({ data-testid="timeout-input" value={props.value} disabled={props.disabled} - onChange={e => props.onChange({ target: { value: e.target.value } })} + onChange={(e) => props.onChange({ target: { value: e.target.value } })} /> ), })) @@ -35,13 +35,7 @@ describe('TimeoutInput', () => { }) it('should update the numeric timeout value and switch units', () => { - render( - <TimeoutInput - timeout={3} - unit="day" - onChange={onChange} - />, - ) + render(<TimeoutInput timeout={3} unit="day" onChange={onChange} />) fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: '12' } }) fireEvent.click(screen.getByText('nodes.humanInput.timeout.hours')) @@ -51,25 +45,12 @@ describe('TimeoutInput', () => { }) it('should fall back to 1 on invalid input and stay read-only when disabled', () => { - const { rerender } = render( - <TimeoutInput - timeout={5} - unit="hour" - onChange={onChange} - />, - ) + const { rerender } = render(<TimeoutInput timeout={5} unit="hour" onChange={onChange} />) fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: 'abc' } }) expect(onChange).toHaveBeenCalledWith({ timeout: 1, unit: 'hour' }) - rerender( - <TimeoutInput - timeout={5} - unit="hour" - onChange={onChange} - readonly - />, - ) + rerender(<TimeoutInput timeout={5} unit="hour" onChange={onChange} readonly />) fireEvent.click(screen.getByText('nodes.humanInput.timeout.days')) expect(onChange).toHaveBeenCalledTimes(1) diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx index 3647391ceedacf..92b87ad42e2e48 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx @@ -23,16 +23,13 @@ vi.mock('@/app/components/base/input', () => ({ data-testid={props.placeholder} value={props.value} disabled={props.disabled} - onChange={e => props.onChange({ target: { value: e.target.value } })} + onChange={(e) => props.onChange({ target: { value: e.target.value } })} /> ), })) vi.mock('@langgenius/dify-ui/button', () => ({ - Button: (props: { - children?: ReactNode - onClick?: () => void - }) => ( + Button: (props: { children?: ReactNode; onClick?: () => void }) => ( <button type="button" onClick={props.onClick}> {props.children} </button> @@ -51,9 +48,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ vi.mock('../button-style-dropdown', () => ({ __esModule: true, - default: (props: { - onChange: (type: UserActionButtonType) => void - }) => ( + default: (props: { onChange: (type: UserActionButtonType) => void }) => ( <button type="button" onClick={() => props.onChange(UserActionButtonType.Ghost)}> change-style </button> @@ -77,64 +72,75 @@ describe('UserActionItem', () => { }) it('should sanitize ids, enforce length limits, and update the button text', () => { - render( - <UserActionItem - data={action} - onChange={onChange} - onDelete={onDelete} - />, - ) + render(<UserActionItem data={action} onChange={onChange} onDelete={onDelete} />) + + fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { + target: { value: 'Approve action' }, + }) + fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { + target: { value: '1invalid' }, + }) + fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { + target: { value: 'averyveryveryverylongidentifier' }, + }) + fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder'), { + target: { value: 'card_visa_enterprise_001' }, + }) - fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: 'Approve action' } }) - fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: '1invalid' } }) - fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: 'averyveryveryverylongidentifier' } }) - fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder'), { target: { value: 'card_visa_enterprise_001' } }) - - expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ - id: 'Approve_action', - })) - expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ - id: 'averyveryveryverylon', - })) - expect(onChange).toHaveBeenNthCalledWith(3, expect.objectContaining({ - title: 'card_visa_enterprise_001', - })) - expect(mockNotify).toHaveBeenNthCalledWith(1, expect.objectContaining({ - type: 'error', - message: 'nodes.humanInput.userActions.actionIdFormatTip', - })) - expect(mockNotify).toHaveBeenNthCalledWith(2, expect.objectContaining({ - type: 'error', - message: 'nodes.humanInput.userActions.actionIdTooLong', - })) + expect(onChange).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: 'Approve_action', + }), + ) + expect(onChange).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: 'averyveryveryverylon', + }), + ) + expect(onChange).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + title: 'card_visa_enterprise_001', + }), + ) + expect(mockNotify).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + type: 'error', + message: 'nodes.humanInput.userActions.actionIdFormatTip', + }), + ) + expect(mockNotify).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: 'error', + message: 'nodes.humanInput.userActions.actionIdTooLong', + }), + ) expect(mockNotify).toHaveBeenCalledTimes(2) }) it('should support clearing ids, updating button style, deleting, and readonly mode', () => { const { rerender } = render( - <UserActionItem - data={action} - onChange={onChange} - onDelete={onDelete} - />, + <UserActionItem data={action} onChange={onChange} onDelete={onDelete} />, ) - fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: ' ' } }) + fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { + target: { value: ' ' }, + }) fireEvent.click(screen.getByText('change-style')) fireEvent.click(screen.getAllByRole('button')[1]!) expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: '' })) - expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ button_style: UserActionButtonType.Ghost })) + expect(onChange).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ button_style: UserActionButtonType.Ghost }), + ) expect(onDelete).toHaveBeenCalledWith('approve') - rerender( - <UserActionItem - data={action} - onChange={onChange} - onDelete={onDelete} - readonly - />, - ) + rerender(<UserActionItem data={action} onChange={onChange} onDelete={onDelete} readonly />) expect(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'))!.toBeDisabled() expect(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder'))!.toBeDisabled() diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/variable-in-markdown.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/variable-in-markdown.spec.tsx index 212fc8713d2c8b..06564023076bd9 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/variable-in-markdown.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/variable-in-markdown.spec.tsx @@ -98,7 +98,7 @@ describe('variable-in-markdown', () => { value: '', }, }} - nodeName={nodeId => nodeId === 'node-1' ? 'Start Node' : nodeId} + nodeName={(nodeId) => (nodeId === 'node-1' ? 'Start Node' : nodeId)} />, ) @@ -115,7 +115,7 @@ describe('variable-in-markdown', () => { selector: [], }, }} - nodeName={nodeId => nodeId} + nodeName={(nodeId) => nodeId} />, ) @@ -134,7 +134,7 @@ describe('variable-in-markdown', () => { value: ['Approved', 'Rejected'], }, }} - nodeName={nodeId => nodeId} + nodeName={(nodeId) => nodeId} />, ) @@ -154,12 +154,14 @@ describe('variable-in-markdown', () => { value: [], }, }} - nodeName={nodeId => nodeId === 'node-1' ? 'Start Node' : nodeId} + nodeName={(nodeId) => (nodeId === 'node-1' ? 'Start Node' : nodeId)} />, ) expect(screen.queryByTestId('human-input-note-select-preview')).not.toBeInTheDocument() - expect(screen.queryByRole('combobox', { name: 'human-input-note-select' })).not.toBeInTheDocument() + expect( + screen.queryByRole('combobox', { name: 'human-input-note-select' }), + ).not.toBeInTheDocument() expect(screen.getByText('{{Start Node/options}}')).toBeInTheDocument() }) @@ -177,7 +179,7 @@ describe('variable-in-markdown', () => { value: ['Approved', 'Rejected'], }, }} - nodeName={nodeId => nodeId} + nodeName={(nodeId) => nodeId} />, ) @@ -196,7 +198,7 @@ describe('variable-in-markdown', () => { allowed_file_types: [], allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], }} - nodeName={nodeId => nodeId} + nodeName={(nodeId) => nodeId} />, ) diff --git a/web/app/components/workflow/nodes/human-input/components/add-input-field.tsx b/web/app/components/workflow/nodes/human-input/components/add-input-field.tsx index 3a5603cff7bc7e..640fa74eaff081 100644 --- a/web/app/components/workflow/nodes/human-input/components/add-input-field.tsx +++ b/web/app/components/workflow/nodes/human-input/components/add-input-field.tsx @@ -11,12 +11,7 @@ type Props = Readonly<{ onCancel: () => void }> -const AddInputField: FC<Props> = ({ - nodeId, - unavailableVariableNames, - onSave, - onCancel, -}) => { +const AddInputField: FC<Props> = ({ nodeId, unavailableVariableNames, onSave, onCancel }) => { return ( <InputField nodeId={nodeId} diff --git a/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx b/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx index 316feef3eb1333..8bb080d1b83f47 100644 --- a/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx +++ b/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx @@ -1,14 +1,8 @@ import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - RiFontSize, -} from '@remixicon/react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { RiFontSize } from '@remixicon/react' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -23,12 +17,7 @@ type Props = Readonly<{ readonly?: boolean }> -const ButtonStyleDropdown: FC<Props> = ({ - text = 'Button Text', - data, - onChange, - readonly, -}) => { +const ButtonStyleDropdown: FC<Props> = ({ text = 'Button Text', data, onChange, readonly }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const currentStyle = useMemo(() => { @@ -48,19 +37,23 @@ const ButtonStyleDropdown: FC<Props> = ({ <Popover open={open && !readonly} onOpenChange={(nextOpen) => { - if (readonly) - return + if (readonly) return setOpen(nextOpen) }} > <PopoverTrigger - render={( - <div className={cn('flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1 data-popup-open:bg-components-button-tertiary-bg-hover', !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover')}> + render={ + <div + className={cn( + 'flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1 data-popup-open:bg-components-button-tertiary-bg-hover', + !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover', + )} + > <Button size="small" className="pointer-events-none px-1" variant={currentStyle}> <RiFontSize className="size-4" /> </Button> </div> - )} + } /> <PopoverContent placement="bottom-end" @@ -69,43 +62,57 @@ const ButtonStyleDropdown: FC<Props> = ({ popupClassName="border-none bg-transparent shadow-none" > <div className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-4 shadow-lg backdrop-blur-xs"> - <div className="system-md-medium text-text-primary">{t($ => $[`${i18nPrefix}.userActions.chooseStyle`], { ns: 'workflow' })}</div> + <div className="system-md-medium text-text-primary"> + {t(($) => $[`${i18nPrefix}.userActions.chooseStyle`], { ns: 'workflow' })} + </div> <div className="mt-2 flex w-[324px] flex-wrap gap-1"> <div className={cn( 'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn', - data === UserActionButtonType.Primary && 'border-components-option-card-option-selected-border', + data === UserActionButtonType.Primary && + 'border-components-option-card-option-selected-border', )} onClick={() => onChange(UserActionButtonType.Primary)} > - <Button variant="primary" className="pointer-events-none">{text}</Button> + <Button variant="primary" className="pointer-events-none"> + {text} + </Button> </div> <div className={cn( 'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn', - data === UserActionButtonType.Default && 'border-components-option-card-option-selected-border', + data === UserActionButtonType.Default && + 'border-components-option-card-option-selected-border', )} onClick={() => onChange(UserActionButtonType.Default)} > - <Button variant="secondary" className="pointer-events-none">{text}</Button> + <Button variant="secondary" className="pointer-events-none"> + {text} + </Button> </div> <div className={cn( 'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn', - data === UserActionButtonType.Accent && 'border-components-option-card-option-selected-border', + data === UserActionButtonType.Accent && + 'border-components-option-card-option-selected-border', )} onClick={() => onChange(UserActionButtonType.Accent)} > - <Button variant="secondary-accent" className="pointer-events-none">{text}</Button> + <Button variant="secondary-accent" className="pointer-events-none"> + {text} + </Button> </div> <div className={cn( 'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn', - data === UserActionButtonType.Ghost && 'border-components-option-card-option-selected-border', + data === UserActionButtonType.Ghost && + 'border-components-option-card-option-selected-border', )} onClick={() => onChange(UserActionButtonType.Ghost)} > - <Button variant="ghost" className="pointer-events-none">{text}</Button> + <Button variant="ghost" className="pointer-events-none"> + {text} + </Button> </div> </div> </div> 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 7ebc50e195aaf7..af818d41ab3d33 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 @@ -37,22 +37,26 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + 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 }) => ( + default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( <textarea aria-label="mail-body-input" value={value} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} /> ), })) vi.mock('../recipient', () => ({ - default: ({ data, onChange }: { + default: ({ + data, + onChange, + }: { data: EmailConfig['recipients'] onChange: (value: EmailConfig['recipients']) => void }) => ( @@ -62,28 +66,34 @@ vi.mock('../recipient', () => ({ </div> <button type="button" - onClick={() => onChange({ - whole_workspace: false, - items: [{ type: 'external', email: 'notify@example.com' }], - })} + onClick={() => + onChange({ + whole_workspace: false, + items: [{ type: 'external', email: 'notify@example.com' }], + }) + } > set-external-recipient </button> <button type="button" - onClick={() => onChange({ - whole_workspace: true, - items: [], - })} + onClick={() => + onChange({ + whole_workspace: true, + items: [], + }) + } > set-workspace-recipient </button> <button type="button" - onClick={() => onChange({ - whole_workspace: false, - items: [], - })} + onClick={() => + onChange({ + whole_workspace: false, + items: [], + }) + } > clear-recipient </button> @@ -119,11 +129,18 @@ describe('human-input/delivery-method/email-configure-modal', () => { />, ) - expect(screen.getByRole('dialog')).toHaveTextContent('nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1') + expect(screen.getByRole('dialog')).toHaveTextContent( + 'nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1', + ) - fireEvent.change(screen.getByPlaceholderText('workflow.nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder'), { - target: { value: 'Budget alert' }, - }) + fireEvent.change( + screen.getByPlaceholderText( + 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder', + ), + { + target: { value: 'Budget alert' }, + }, + ) fireEvent.change(screen.getByLabelText('mail-body-input'), { target: { value: 'Please review {{#url#}} now' }, }) @@ -145,39 +162,48 @@ describe('human-input/delivery-method/email-configure-modal', () => { it('should validate subject, body, request url placeholder, and recipients before saving', () => { const handleConfirm = vi.fn() - render( - <EmailConfigureModal - open - onOpenChange={vi.fn()} - onConfirm={handleConfirm} - />, - ) + render(<EmailConfigureModal open onOpenChange={vi.fn()} onConfirm={handleConfirm} />) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(mockToastError).toHaveBeenCalledWith('workflow.nodes.humanInput.deliveryMethod.emailConfigure.subjectRequired') + expect(mockToastError).toHaveBeenCalledWith( + 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.subjectRequired', + ) - fireEvent.change(screen.getByPlaceholderText('workflow.nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder'), { - target: { value: 'Subject ready' }, - }) + fireEvent.change( + screen.getByPlaceholderText( + 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder', + ), + { + target: { value: 'Subject ready' }, + }, + ) fireEvent.change(screen.getByLabelText('mail-body-input'), { target: { value: ' ' }, }) fireEvent.click(screen.getByRole('button', { name: 'set-workspace-recipient' })) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(mockToastError).toHaveBeenCalledWith('workflow.nodes.humanInput.deliveryMethod.emailConfigure.bodyRequired') + expect(mockToastError).toHaveBeenCalledWith( + 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.bodyRequired', + ) fireEvent.change(screen.getByLabelText('mail-body-input'), { target: { value: 'Missing placeholder' }, }) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(mockToastError).toHaveBeenCalledWith(expect.stringContaining('workflow.nodes.humanInput.deliveryMethod.emailConfigure.bodyMustContainRequestURL')) + expect(mockToastError).toHaveBeenCalledWith( + expect.stringContaining( + 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.bodyMustContainRequestURL', + ), + ) fireEvent.change(screen.getByLabelText('mail-body-input'), { target: { value: 'Ready {{#url#}}' }, }) fireEvent.click(screen.getByRole('button', { name: 'clear-recipient' })) fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(mockToastError).toHaveBeenCalledWith('workflow.nodes.humanInput.deliveryMethod.emailConfigure.recipientsRequired') + expect(mockToastError).toHaveBeenCalledWith( + 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.recipientsRequired', + ) expect(handleConfirm).not.toHaveBeenCalled() }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx index 99e892dec2f21e..69ed86497d45d2 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx @@ -17,13 +17,15 @@ vi.mock('@/app/components/workflow/hooks', () => ({ vi.mock('../method-selector', () => ({ __esModule: true, default: (props: { - onAdd: (method: { id: string, type: DeliveryMethodType, enabled: boolean }) => void + onAdd: (method: { id: string; type: DeliveryMethodType; enabled: boolean }) => void onShowUpgradeTip: () => void }) => ( <div> <button type="button" - onClick={() => props.onAdd({ id: 'email-1', type: DeliveryMethodType.Email, enabled: false })} + onClick={() => + props.onAdd({ id: 'email-1', type: DeliveryMethodType.Email, enabled: false }) + } > add-method </button> @@ -37,8 +39,8 @@ vi.mock('../method-selector', () => ({ vi.mock('../method-item', () => ({ __esModule: true, default: (props: { - method: { type: DeliveryMethodType, enabled: boolean } - onChange: (method: { type: DeliveryMethodType, enabled: boolean }) => void + method: { type: DeliveryMethodType; enabled: boolean } + onChange: (method: { type: DeliveryMethodType; enabled: boolean }) => void onDelete: (type: DeliveryMethodType) => void }) => ( <div data-testid={`method-${props.method.type}`}> @@ -48,10 +50,7 @@ vi.mock('../method-item', () => ({ > change-method </button> - <button - type="button" - onClick={() => props.onDelete(props.method.type)} - > + <button type="button" onClick={() => props.onDelete(props.method.type)}> delete-method </button> </div> @@ -73,13 +72,7 @@ describe('DeliveryMethodForm', () => { }) it('should render the empty state and add methods through the selector', () => { - render( - <DeliveryMethodForm - nodeId="node-1" - value={[]} - onChange={onChange} - />, - ) + render(<DeliveryMethodForm nodeId="node-1" value={[]} onChange={onChange} />) expect(screen.getByText('nodes.humanInput.deliveryMethod.emptyTip')).toBeInTheDocument() fireEvent.click(screen.getByText('add-method')) @@ -98,11 +91,13 @@ describe('DeliveryMethodForm', () => { render( <DeliveryMethodForm nodeId="node-1" - value={[{ - id: 'email-1', - type: DeliveryMethodType.Email, - enabled: false, - }]} + value={[ + { + id: 'email-1', + type: DeliveryMethodType.Email, + enabled: false, + }, + ]} onChange={onChange} />, ) @@ -110,28 +105,26 @@ describe('DeliveryMethodForm', () => { fireEvent.click(screen.getByText('change-method')) fireEvent.click(screen.getByText('delete-method')) - expect(onChange).toHaveBeenNthCalledWith(1, [{ - id: 'email-1', - type: DeliveryMethodType.Email, - enabled: true, - }]) + expect(onChange).toHaveBeenNthCalledWith(1, [ + { + id: 'email-1', + type: DeliveryMethodType.Email, + enabled: true, + }, + ]) expect(onChange).toHaveBeenNthCalledWith(2, []) expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true) }) it('should open and close the upgrade modal', async () => { - render( - <DeliveryMethodForm - nodeId="node-1" - value={[]} - onChange={onChange} - />, - ) + render(<DeliveryMethodForm nodeId="node-1" value={[]} onChange={onChange} />) fireEvent.click(screen.getByText('show-upgrade')) expect(screen.getByRole('dialog')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'nodes.humanInput.deliveryMethod.upgradeTipHide' })) + fireEvent.click( + screen.getByRole('button', { name: 'nodes.humanInput.deliveryMethod.upgradeTipHide' }), + ) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) }) }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/mail-body-input.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/mail-body-input.spec.tsx index 693edd8f922f1c..4c4cd15314b552 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/mail-body-input.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/mail-body-input.spec.tsx @@ -13,10 +13,13 @@ vi.mock('@/app/components/base/prompt-editor', () => ({ }, })) -vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder', () => ({ - __esModule: true, - default: (_props: { hideBadge?: boolean }) => <div data-testid="placeholder" />, -})) +vi.mock( + '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder', + () => ({ + __esModule: true, + default: (_props: { hideBadge?: boolean }) => <div data-testid="placeholder" />, + }), +) describe('human-input/delivery-method/mail-body-input', () => { beforeEach(() => { @@ -47,48 +50,56 @@ describe('human-input/delivery-method/mail-body-input', () => { ) expect(screen.getByTestId('prompt-editor')).toBeInTheDocument() - expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({ - editable: true, - value: 'Hello {{#url#}}', - onChange: handleChange, - placeholder: expect.any(Object), - requestURLBlock: { - show: true, - selectable: true, - }, - workflowVariableBlock: { - show: true, - variables: nodesOutputVars, - workflowNodesMap: { - start: { title: 'Start Node', type: BlockEnum.Start }, - answer: { title: 'Answer Node', type: BlockEnum.Answer }, - sys: { title: 'workflow.blocks.start', type: BlockEnum.Start }, + expect(mockPromptEditor).toHaveBeenCalledWith( + expect.objectContaining({ + editable: true, + value: 'Hello {{#url#}}', + onChange: handleChange, + placeholder: expect.any(Object), + requestURLBlock: { + show: true, + selectable: true, }, - }, - })) + workflowVariableBlock: { + show: true, + variables: nodesOutputVars, + workflowNodesMap: { + start: { title: 'Start Node', type: BlockEnum.Start }, + answer: { title: 'Answer Node', type: BlockEnum.Answer }, + sys: { title: 'workflow.blocks.start', type: BlockEnum.Start }, + }, + }, + }), + ) }) it('should disable editing and omit sys mapping when there is no start node', () => { render( <MailBodyInput readOnly - availableNodes={[{ - id: 'llm', - data: { title: 'LLM Node', type: BlockEnum.LLM }, - }] as unknown as Node[]} + availableNodes={ + [ + { + id: 'llm', + data: { title: 'LLM Node', type: BlockEnum.LLM }, + }, + ] as unknown as Node[] + } />, ) - expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({ - editable: false, - value: '', - workflowVariableBlock: { - show: true, - variables: [], - workflowNodesMap: { - llm: { title: 'LLM Node', type: BlockEnum.LLM }, + expect(mockPromptEditor).toHaveBeenCalledWith( + expect.objectContaining({ + editable: false, + value: '', + workflowVariableBlock: { + show: true, + variables: [], + workflowNodesMap: { + llm: { title: 'LLM Node', type: BlockEnum.LLM }, + }, }, - }, - })) + }), + ) }) }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx index 43ed33916e2028..9217db3e71c8a1 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx @@ -48,45 +48,50 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('../email-configure-modal', () => ({ default: (props: EmailConfigureModalProps) => { mockEmailConfigureModal(props) - return props.open - ? ( - <div data-testid="email-configure-modal"> - <button - type="button" - onClick={() => props.onConfirm({ - recipients: { whole_workspace: false, items: [] }, - subject: 'Configured subject', - body: '{{#url#}}', - debug_mode: false, - })} - > - confirm-email-config - </button> - <button type="button" onClick={() => props.onOpenChange(false)}>close-email-config</button> - </div> - ) - : null + return props.open ? ( + <div data-testid="email-configure-modal"> + <button + type="button" + onClick={() => + props.onConfirm({ + recipients: { whole_workspace: false, items: [] }, + subject: 'Configured subject', + body: '{{#url#}}', + debug_mode: false, + }) + } + > + confirm-email-config + </button> + <button type="button" onClick={() => props.onOpenChange(false)}> + close-email-config + </button> + </div> + ) : null }, })) vi.mock('../test-email-sender', () => ({ default: (props: TestEmailSenderProps) => { mockTestEmailSender(props) - return props.open - ? ( - <div data-testid="test-email-sender"> - <button type="button" onClick={props.jumpToEmailConfigModal}>jump-to-config</button> - <button type="button" onClick={() => props.onOpenChange(false)}>close-test-sender</button> - </div> - ) - : null + return props.open ? ( + <div data-testid="test-email-sender"> + <button type="button" onClick={props.jumpToEmailConfigModal}> + jump-to-config + </button> + <button type="button" onClick={() => props.onOpenChange(false)}> + close-test-sender + </button> + </div> + ) : null }, })) @@ -101,29 +106,35 @@ const createEmailConfig = (overrides: Partial<EmailConfig> = {}): EmailConfig => ...overrides, }) -const formInputs: FormInputItem[] = [{ - type: InputVarType.paragraph, - output_variable_name: 'name', - default: { - selector: ['start', 'name'], - type: 'constant', - value: '', +const formInputs: FormInputItem[] = [ + { + type: InputVarType.paragraph, + output_variable_name: 'name', + default: { + selector: ['start', 'name'], + type: 'constant', + value: '', + }, }, -}] +] + +const availableNodes = [ + { + id: 'start', + data: { + title: 'Start', + type: 'start', + }, + }, +] as unknown as Node[] -const availableNodes = [{ - id: 'start', - data: { +const nodesOutputVars = [ + { + nodeId: 'start', title: 'Start', - type: 'start', + vars: [], }, -}] as unknown as Node[] - -const nodesOutputVars = [{ - nodeId: 'start', - title: 'Start', - vars: [], -}] as NodeOutPutVar[] +] as NodeOutPutVar[] const getMethodRow = (label: string) => { return screen.getByText(label).closest('div[class*="justify-between"]') as HTMLDivElement @@ -215,13 +226,15 @@ describe('human-input/delivery-method/method-item', () => { expect(screen.getByTestId('email-configure-modal'))!.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'confirm-email-config' })) - expect(handleChange).toHaveBeenCalledWith(expect.objectContaining({ - id: 'email-1', - type: DeliveryMethodType.Email, - config: expect.objectContaining({ - subject: 'Configured subject', + expect(handleChange).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'email-1', + type: DeliveryMethodType.Email, + config: expect.objectContaining({ + subject: 'Configured subject', + }), }), - })) + ) fireEvent.click(actionButtons[2]!) expect(handleDelete).toHaveBeenCalledWith(DeliveryMethodType.Email) @@ -245,7 +258,11 @@ describe('human-input/delivery-method/method-item', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /workflow.nodes.humanInput.deliveryMethod.notConfigured/i })) + fireEvent.click( + screen.getByRole('button', { + name: /workflow.nodes.humanInput.deliveryMethod.notConfigured/i, + }), + ) expect(screen.getByTestId('email-configure-modal'))!.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'close-email-config' })) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-selector.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-selector.spec.tsx index 822a44cd6e29b4..092c3613d3b846 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-selector.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-selector.spec.tsx @@ -18,8 +18,9 @@ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ })) vi.mock('@/context/provider-context', () => ({ - useProviderContextSelector: (selector: (state: { humanInputEmailDeliveryEnabled: boolean }) => boolean) => - mockUseProviderContextSelector(selector), + useProviderContextSelector: ( + selector: (state: { humanInputEmailDeliveryEnabled: boolean }) => boolean, + ) => mockUseProviderContextSelector(selector), })) vi.mock('@/config', () => ({ @@ -30,26 +31,24 @@ describe('human-input/delivery-method/method-selector', () => { beforeEach(() => { vi.clearAllMocks() mockUuid.mockReturnValue('generated-id') - mockUseWorkflowNodes.mockReturnValue([{ - id: 'start-node', - data: { type: BlockEnum.Start }, - }] as Node[]) - mockUseProviderContextSelector.mockImplementation(selector => selector({ - humanInputEmailDeliveryEnabled: true, - })) + mockUseWorkflowNodes.mockReturnValue([ + { + id: 'start-node', + data: { type: BlockEnum.Start }, + }, + ] as Node[]) + mockUseProviderContextSelector.mockImplementation((selector) => + selector({ + humanInputEmailDeliveryEnabled: true, + }), + ) }) it('should add webapp and email delivery methods when both entries are available', () => { const handleAdd = vi.fn() const handleShowUpgradeTip = vi.fn() - render( - <MethodSelector - data={[]} - onAdd={handleAdd} - onShowUpgradeTip={handleShowUpgradeTip} - />, - ) + render(<MethodSelector data={[]} onAdd={handleAdd} onShowUpgradeTip={handleShowUpgradeTip} />) fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByText('workflow.nodes.humanInput.deliveryMethod.types.webapp.title')) @@ -66,17 +65,23 @@ describe('human-input/delivery-method/method-selector', () => { enabled: false, }) expect(handleShowUpgradeTip).not.toHaveBeenCalled() - expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.contactTip1')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.contactTip2')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.humanInput.deliveryMethod.contactTip1'), + ).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.humanInput.deliveryMethod.contactTip2'), + ).toBeInTheDocument() }) it('should disable webapp in trigger mode and show added states without creating duplicates', () => { const handleAdd = vi.fn() - mockUseWorkflowNodes.mockReturnValue([{ - id: 'trigger-node', - data: { type: BlockEnum.TriggerWebhook }, - }] as Node[]) + mockUseWorkflowNodes.mockReturnValue([ + { + id: 'trigger-node', + data: { type: BlockEnum.TriggerWebhook }, + }, + ] as Node[]) render( <MethodSelector @@ -103,25 +108,25 @@ describe('human-input/delivery-method/method-selector', () => { const handleAdd = vi.fn() const handleShowUpgradeTip = vi.fn() - mockUseWorkflowNodes.mockReturnValue([{ - id: 'trigger-node', - data: { type: BlockEnum.TriggerSchedule }, - }] as Node[]) - mockUseProviderContextSelector.mockImplementation(selector => selector({ - humanInputEmailDeliveryEnabled: false, - })) - - render( - <MethodSelector - data={[]} - onAdd={handleAdd} - onShowUpgradeTip={handleShowUpgradeTip} - />, + mockUseWorkflowNodes.mockReturnValue([ + { + id: 'trigger-node', + data: { type: BlockEnum.TriggerSchedule }, + }, + ] as Node[]) + mockUseProviderContextSelector.mockImplementation((selector) => + selector({ + humanInputEmailDeliveryEnabled: false, + }), ) + render(<MethodSelector data={[]} onAdd={handleAdd} onShowUpgradeTip={handleShowUpgradeTip} />) + fireEvent.click(screen.getByRole('button')) - expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.notAvailableInTriggerMode')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.humanInput.deliveryMethod.notAvailableInTriggerMode'), + ).toBeInTheDocument() fireEvent.click(screen.getByText('workflow.nodes.humanInput.deliveryMethod.types.webapp.title')) fireEvent.click(screen.getByText('workflow.nodes.humanInput.deliveryMethod.types.email.title')) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx index 9ca47f6bb6581f..36da6e376063f5 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx @@ -1,5 +1,10 @@ import type { ReactNode } from 'react' -import type { EmailConfig, FormInputItem, ParagraphFormInput, SelectFormInput } from '../../../types' +import type { + EmailConfig, + FormInputItem, + ParagraphFormInput, + SelectFormInput, +} from '../../../types' import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types' import type { App, AppSSO } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' @@ -53,7 +58,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -63,16 +69,17 @@ type RecordedRequest = { body?: unknown } -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - mutations: { - retry: false, +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, }, - }, -}) + }) const renderWithProviders = (ui: ReactNode) => { const queryClient = createQueryClient() @@ -80,51 +87,54 @@ const renderWithProviders = (ui: ReactNode) => { return render( <QueryClientProvider client={queryClient}> - <HooksStoreContext.Provider value={hooksStore}> - {ui} - </HooksStoreContext.Provider> + <HooksStoreContext.Provider value={hooksStore}>{ui}</HooksStoreContext.Provider> </QueryClientProvider>, ) } const setupFetch = () => { const requests: RecordedRequest[] = [] - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (resource: RequestInfo | URL, options?: RequestInit) => { - const request = resource instanceof Request ? resource : new Request(resource, options) - const body = request.method === 'GET' ? undefined : await request.clone().json() - requests.push({ - url: request.url, - method: request.method, - body, - }) + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (resource: RequestInfo | URL, options?: RequestInit) => { + const request = resource instanceof Request ? resource : new Request(resource, options) + const body = request.method === 'GET' ? undefined : await request.clone().json() + requests.push({ + url: request.url, + method: request.method, + body, + }) - if (request.url.includes('/workspaces/current/members')) { - return new Response(JSON.stringify({ - accounts: [ + if (request.url.includes('/workspaces/current/members')) { + return new Response( + JSON.stringify({ + accounts: [ + { + id: 'member-1', + email: 'member@example.com', + name: 'Member One', + avatar: '', + avatar_url: '', + status: 'active', + role: 'normal', + created_at: '', + last_active_at: '', + last_login_at: '', + }, + ], + }), { - id: 'member-1', - email: 'member@example.com', - name: 'Member One', - avatar: '', - avatar_url: '', - status: 'active', - role: 'normal', - created_at: '', - last_active_at: '', - last_login_at: '', + status: 200, + headers: { 'Content-Type': 'application/json' }, }, - ], - }), { + ) + } + + return new Response(JSON.stringify({ result: 'success' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }) - } - - return new Response(JSON.stringify({ result: 'success' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, }) - }) return { fetchSpy, @@ -224,7 +234,9 @@ describe('human-input/delivery-method/test-email-sender', () => { />, ) - const sendButton = screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' }) + const sendButton = screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send', + }) expect(sendButton).toBeDisabled() await user.type(screen.getByPlaceholderText('user_name'), 'Ada') @@ -233,18 +245,24 @@ describe('human-input/delivery-method/test-email-sender', () => { await user.click(sendButton) - await waitFor(() => expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.done')).toBeInTheDocument()) - expect(requests).toContainEqual(expect.objectContaining({ - url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test', - method: 'POST', - body: { - delivery_method_id: 'delivery-1', - inputs: { - '#start.user_name#': 'Ada', - '#start.score#': 42, + await waitFor(() => + expect( + screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.done'), + ).toBeInTheDocument(), + ) + expect(requests).toContainEqual( + expect.objectContaining({ + url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test', + method: 'POST', + body: { + delivery_method_id: 'delivery-1', + inputs: { + '#start.user_name#': 'Ada', + '#start.score#': 42, + }, }, - }, - })) + }), + ) await user.click(screen.getByRole('button', { name: 'common.operation.ok' })) @@ -302,7 +320,9 @@ describe('human-input/delivery-method/test-email-sender', () => { />, ) - const sendButton = screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' }) + const sendButton = screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send', + }) expect(sendButton).toBeDisabled() expect(screen.queryByPlaceholderText('result')).not.toBeInTheDocument() @@ -314,16 +334,20 @@ describe('human-input/delivery-method/test-email-sender', () => { await user.click(sendButton) - await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({ - url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test', - method: 'POST', - body: { - delivery_method_id: 'delivery-1', - inputs: { - '#code.result#': ['approve', 'reject'], - }, - }, - }))) + await waitFor(() => + expect(requests).toContainEqual( + expect.objectContaining({ + url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test', + method: 'POST', + body: { + delivery_method_id: 'delivery-1', + inputs: { + '#code.result#': ['approve', 'reject'], + }, + }, + }), + ), + ) }) it('should render fallback variable inputs and allow cancelling', async () => { @@ -346,7 +370,11 @@ describe('human-input/delivery-method/test-email-sender', () => { expect(screen.getByPlaceholderText('message')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.vars' })) + await user.click( + screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.vars', + }), + ) expect(screen.queryByPlaceholderText('message')).not.toBeInTheDocument() @@ -376,7 +404,9 @@ describe('human-input/delivery-method/test-email-sender', () => { ) expect(screen.getByText('external@example.com')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.tip')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.tip'), + ).toBeInTheDocument() }) it('should show a validation toast when generated JSON input is invalid', async () => { @@ -432,13 +462,19 @@ describe('human-input/delivery-method/test-email-sender', () => { fireEvent.change(screen.getByTestId('monaco-editor'), { target: { value: '{invalid' }, }) - await user.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' })) + await user.click( + screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send', + }), + ) expect(toast.error).toHaveBeenCalledWith('workflow.errorMsg.invalidJson:{"field":"payload"}') - expect(requests).not.toContainEqual(expect.objectContaining({ - url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test', - method: 'POST', - })) + expect(requests).not.toContainEqual( + expect.objectContaining({ + url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test', + method: 'POST', + }), + ) }) it('should show debug success copy after sending in debug mode', async () => { @@ -459,11 +495,21 @@ describe('human-input/delivery-method/test-email-sender', () => { />, ) - expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.debugModeTip')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.debugModeTip'), + ).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' })) + await user.click( + screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send', + }), + ) - await waitFor(() => expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.debugDone')).toBeInTheDocument()) + await waitFor(() => + expect( + screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.debugDone'), + ).toBeInTheDocument(), + ) }) it('should show specific-recipient success copy after sending', async () => { @@ -487,9 +533,17 @@ describe('human-input/delivery-method/test-email-sender', () => { />, ) - await user.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' })) + await user.click( + screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send', + }), + ) - await waitFor(() => expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3')).toBeInTheDocument()) + await waitFor(() => + expect( + screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3'), + ).toBeInTheDocument(), + ) expect(screen.getByText('external@example.com')).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx index 5bb25468b32757..61dcb0fd389878 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx @@ -25,21 +25,26 @@ describe('human-input/delivery-method/upgrade-modal', () => { const handleClose = vi.fn() const handleShowPricingModal = vi.fn() - mockUseModalContextSelector.mockImplementation(selector => selector({ - setShowPricingModal: handleShowPricingModal, - })) - - render( - <UpgradeModal - open - onOpenChange={handleClose} - />, + mockUseModalContextSelector.mockImplementation((selector) => + selector({ + setShowPricingModal: handleShowPricingModal, + }), ) - expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.upgradeTip')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.upgradeTipContent')).toBeInTheDocument() + render(<UpgradeModal open onOpenChange={handleClose} />) - fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.upgradeTipHide' })) + expect( + screen.getByText('workflow.nodes.humanInput.deliveryMethod.upgradeTip'), + ).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.humanInput.deliveryMethod.upgradeTipContent'), + ).toBeInTheDocument() + + fireEvent.click( + screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.upgradeTipHide', + }), + ) expect(handleClose).toHaveBeenCalledWith(false) fireEvent.click(screen.getByRole('button', { name: /billing.upgradeBtn.encourageShort/i })) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx index 2f8a4d68d1c2ce..195775e196fd34 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx @@ -1,8 +1,5 @@ import type { EmailConfig } from '../../types' -import type { - Node, - NodeOutPutVar, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Switch } from '@langgenius/dify-ui/switch' @@ -37,37 +34,50 @@ const EmailConfigureModal = ({ }: EmailConfigureModalProps) => { const { t } = useTranslation() const email = useAtomValue(userProfileEmailAtom) - const [recipients, setRecipients] = useState(config?.recipients || { whole_workspace: false, items: [] }) + const [recipients, setRecipients] = useState( + config?.recipients || { whole_workspace: false, items: [] }, + ) const [subject, setSubject] = useState(config?.subject || '') const [body, setBody] = useState(config?.body || '{{#url#}}') const [debugMode, setDebugMode] = useState(config?.debug_mode || false) const checkValidConfig = useCallback(() => { if (!subject.trim()) { - toast.error(t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.subjectRequired`], { ns: 'workflow' })) + toast.error( + t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.subjectRequired`], { + ns: 'workflow', + }), + ) return false } if (!body.trim()) { - toast.error(t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.bodyRequired`], { ns: 'workflow' })) + toast.error( + t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.bodyRequired`], { ns: 'workflow' }), + ) return false } if (!/\{\{#url#\}\}/.test(body.trim())) { - toast.error(t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.bodyMustContainRequestURL`], { - ns: 'workflow', - field: t($ => $['promptEditor.requestURL.item.title'], { ns: 'common' }), - })) + toast.error( + t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.bodyMustContainRequestURL`], { + ns: 'workflow', + field: t(($) => $['promptEditor.requestURL.item.title'], { ns: 'common' }), + }), + ) return false } if (!recipients || (recipients.items.length === 0 && !recipients.whole_workspace)) { - toast.error(t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.recipientsRequired`], { ns: 'workflow' })) + toast.error( + t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.recipientsRequired`], { + ns: 'workflow', + }), + ) return false } return true }, [recipients, subject, body, t]) const handleConfirm = useCallback(() => { - if (!checkValidConfig()) - return + if (!checkValidConfig()) return onConfirm({ recipients, subject, @@ -77,31 +87,39 @@ const EmailConfigureModal = ({ }, [checkValidConfig, onConfirm, recipients, subject, body, debugMode]) return ( - <Dialog - open={open} - onOpenChange={onOpenChange} - > + <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="max-h-[calc(100dvh-64px)]! w-[720px]!"> <DialogCloseButton /> <div className="space-y-1 pr-8"> - <DialogTitle className="title-2xl-semi-bold text-text-primary">{t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.title`], { ns: 'workflow' })}</DialogTitle> - <div className="system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.description`], { ns: 'workflow' })}</div> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.title`], { ns: 'workflow' })} + </DialogTitle> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.description`], { + ns: 'workflow', + })} + </div> </div> <div className="mt-6 space-y-5"> <div> <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.subject`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.subject`], { + ns: 'workflow', + })} </div> <Input className="w-full" value={subject} - onChange={e => setSubject(e.target.value)} - placeholder={t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.subjectPlaceholder`], { ns: 'workflow' })} + onChange={(e) => setSubject(e.target.value)} + placeholder={t( + ($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.subjectPlaceholder`], + { ns: 'workflow' }, + )} /> </div> <div> <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.body`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.body`], { ns: 'workflow' })} </div> <MailBodyInput value={body} @@ -112,48 +130,47 @@ const EmailConfigureModal = ({ </div> <div> <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.recipient`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.recipient`], { + ns: 'workflow', + })} </div> - <Recipient - data={recipients} - onChange={setRecipients} - /> + <Recipient data={recipients} onChange={setRecipients} /> </div> <div className="flex items-start justify-between gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-3 pl-2.5 shadow-xs"> <div className="rounded-sm border border-divider-regular bg-components-icon-bg-orange-dark-solid p-0.5"> <RiBugLine className="size-3.5 text-text-primary-on-surface" /> </div> <div className="grow space-y-1"> - <div className="system-sm-medium text-text-secondary">{t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.debugMode`], { ns: 'workflow' })}</div> + <div className="system-sm-medium text-text-secondary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.debugMode`], { + ns: 'workflow', + })} + </div> <div className="body-xs-regular text-text-tertiary"> <Trans - i18nKey={$ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip1`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip1`]} ns="workflow" - components={{ email: <span className="body-md-medium text-text-primary">{email}</span> }} + components={{ + email: <span className="body-md-medium text-text-primary">{email}</span>, + }} values={{ email }} /> - <div>{t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip2`], { ns: 'workflow' })}</div> + <div> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip2`], { + ns: 'workflow', + })} + </div> </div> </div> - <Switch - checked={debugMode} - onCheckedChange={checked => setDebugMode(checked)} - /> + <Switch checked={debugMode} onCheckedChange={(checked) => setDebugMode(checked)} /> </div> </div> <div className="mt-6 flex flex-row-reverse gap-2"> - <Button - variant="primary" - className="w-[72px]" - onClick={handleConfirm} - > - {t($ => $['operation.save'], { ns: 'common' })} + <Button variant="primary" className="w-[72px]" onClick={handleConfirm}> + {t(($) => $['operation.save'], { ns: 'common' })} </Button> - <Button - className="w-[72px]" - onClick={() => onOpenChange(false)} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button className="w-[72px]" onClick={() => onOpenChange(false)}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx index ee8361bc49fe23..771c4e8cc7d453 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx @@ -1,8 +1,5 @@ import type { DeliveryMethod, DeliveryMethodType, FormInputItem } from '../../types' -import type { - Node, - NodeOutPutVar, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { produce } from 'immer' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -40,9 +37,8 @@ const DeliveryMethodForm: React.FC<Props> = ({ const handleMethodChange = (target: DeliveryMethod) => { const newMethods = produce(value, (draft) => { - const index = draft.findIndex(method => method.type === target.type) - if (index !== -1) - draft[index] = target + const index = draft.findIndex((method) => method.type === target.type) + if (index !== -1) draft[index] = target }) onChange(newMethods) handleSyncWorkflowDraft(true, true) @@ -54,7 +50,7 @@ const DeliveryMethodForm: React.FC<Props> = ({ } const handleMethodDelete = (type: DeliveryMethodType) => { - const newMethods = value.filter(method => method.type !== type) + const newMethods = value.filter((method) => method.type !== type) onChange(newMethods) } @@ -67,9 +63,13 @@ const DeliveryMethodForm: React.FC<Props> = ({ <div className="px-4 py-2"> <div className="mb-1 flex items-center justify-between"> <div className="flex items-center gap-0.5"> - <div className="system-sm-semibold-uppercase text-text-secondary">{t($ => $[`${i18nPrefix}.deliveryMethod.title`], { ns: 'workflow' })}</div> - <Infotip aria-label={t($ => $[`${i18nPrefix}.deliveryMethod.tooltip`], { ns: 'workflow' })}> - {t($ => $[`${i18nPrefix}.deliveryMethod.tooltip`], { ns: 'workflow' })} + <div className="system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.title`], { ns: 'workflow' })} + </div> + <Infotip + aria-label={t(($) => $[`${i18nPrefix}.deliveryMethod.tooltip`], { ns: 'workflow' })} + > + {t(($) => $[`${i18nPrefix}.deliveryMethod.tooltip`], { ns: 'workflow' })} </Infotip> </div> {!readonly && ( @@ -83,11 +83,13 @@ const DeliveryMethodForm: React.FC<Props> = ({ )} </div> {!value.length && ( - <div className="flex items-center justify-center rounded-[10px] bg-background-section p-3 system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.emptyTip`], { ns: 'workflow' })}</div> + <div className="flex items-center justify-center rounded-[10px] bg-background-section p-3 system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emptyTip`], { ns: 'workflow' })} + </div> )} {value.length > 0 && ( <div className="space-y-1"> - {value.map(method => ( + {value.map((method) => ( <MethodItem nodeId={nodeId} method={method} @@ -103,10 +105,7 @@ const DeliveryMethodForm: React.FC<Props> = ({ ))} </div> )} - <UpgradeModal - open={showUpgradeModal} - onOpenChange={setShowUpgradeModal} - /> + <UpgradeModal open={showUpgradeModal} onOpenChange={setShowUpgradeModal} /> </div> ) } diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/mail-body-input.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/mail-body-input.tsx index 73ee9f12826334..f1fafbe0c43f69 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/mail-body-input.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/mail-body-input.tsx @@ -1,7 +1,4 @@ -import type { - Node, - NodeOutPutVar, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' import PromptEditor from '@/app/components/base/prompt-editor' @@ -42,19 +39,22 @@ const MailBodyInput = ({ workflowVariableBlock={{ show: true, variables: nodesOutputVars || [], - workflowNodesMap: availableNodes.reduce((acc, node) => { - acc[node.id] = { - title: node.data.title, - type: node.data.type, - } - if (node.data.type === BlockEnum.Start) { - acc.sys = { - title: t($ => $['blocks.start'], { ns: 'workflow' }), - type: BlockEnum.Start, + workflowNodesMap: availableNodes.reduce( + (acc, node) => { + acc[node.id] = { + title: node.data.title, + type: node.data.type, } - } - return acc - }, {} as Record<string, Pick<Node['data'], 'title' | 'type'>>), + if (node.data.type === BlockEnum.Start) { + acc.sys = { + title: t(($) => $['blocks.start'], { ns: 'workflow' }), + type: BlockEnum.Start, + } + } + return acc + }, + {} as Record<string, Pick<Node['data'], 'title' | 'type'>>, + ), }} placeholder={<Placeholder hideBadge />} onChange={onChange} diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx index 1929bbe34db35b..57f5162475b4db 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx @@ -1,9 +1,6 @@ import type { FC } from 'react' import type { DeliveryMethod, EmailConfig, FormInputItem } from '../../types' -import type { - Node, - NodeOutPutVar, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' @@ -76,12 +73,15 @@ const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({ return '' } if (method.config?.debug_mode) { - return t($ => $[`${i18nPrefix}.deliveryMethod.emailSender.testSendTipInDebugMode`], { ns: 'workflow', email }) + return t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.testSendTipInDebugMode`], { + ns: 'workflow', + email, + }) } - return t($ => $[`${i18nPrefix}.deliveryMethod.emailSender.testSendTip`], { ns: 'workflow' }) + return t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.testSendTip`], { ns: 'workflow' }) }, [method.type, method.config?.debug_mode, t, email]) - const configureLabel = t($ => $['common.configure'], { ns: 'workflow' }) - const removeLabel = t($ => $['operation.remove'], { ns: 'common' }) + const configureLabel = t(($) => $['common.configure'], { ns: 'workflow' }) + const removeLabel = t(($) => $['operation.remove'], { ns: 'common' }) const jumpToEmailConfigModal = useCallback(() => { setShowTestEmailModal(false) @@ -93,7 +93,8 @@ const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({ <div className={cn( 'group flex h-8 items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pr-2 pl-1.5 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', - isHovering && 'border-state-destructive-border bg-state-destructive-hover hover:bg-state-destructive-hover', + isHovering && + 'border-state-destructive-border bg-state-destructive-hover hover:bg-state-destructive-hover', )} > <div className="flex items-center gap-1.5"> @@ -108,9 +109,12 @@ const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({ </div> )} <div className="system-xs-medium text-text-secondary capitalize">{method.type}</div> - {method.type === DeliveryMethodType.Email - && (method.config as EmailConfig)?.debug_mode - && <Badge size="s" className="px-1! py-0.5!">DEBUG</Badge>} + {method.type === DeliveryMethodType.Email && + (method.config as EmailConfig)?.debug_mode && ( + <Badge size="s" className="px-1! py-0.5!"> + DEBUG + </Badge> + )} </div> <div className="flex items-center gap-1"> {!readonly && ( @@ -119,27 +123,27 @@ const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({ <> <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton aria-label={emailSenderTooltipContent} onClick={() => setShowTestEmailModal(true)} > <RiSendPlane2Line className="size-4" /> </ActionButton> - )} + } /> <TooltipContent>{emailSenderTooltipContent}</TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton aria-label={configureLabel} onClick={() => setShowEmailModal(true)} > <RiEqualizer2Line className="size-4" /> </ActionButton> - )} + } /> <TooltipContent>{configureLabel}</TooltipContent> </Tooltip> @@ -147,7 +151,7 @@ const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({ )} <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton aria-label={removeLabel} state={isHovering ? ActionButtonState.Destructive : ActionButtonState.Default} @@ -157,7 +161,7 @@ const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({ > <RiDeleteBinLine className="size-4" /> </ActionButton> - )} + } /> <TooltipContent>{removeLabel}</TooltipContent> </Tooltip> @@ -177,7 +181,7 @@ const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({ onClick={() => setShowEmailModal(true)} disabled={readonly} > - {t($ => $[`${i18nPrefix}.deliveryMethod.notConfigured`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.deliveryMethod.notConfigured`], { ns: 'workflow' })} <StatusDot status="warning" className="ml-1" /> </Button> )} diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx index b15729f9fc2384..d90e2185938c6b 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx @@ -2,11 +2,7 @@ import type { FC } from 'react' import type { DeliveryMethod } from '../../types' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiAddLine, RiDiscordFill, @@ -34,21 +30,19 @@ type MethodSelectorProps = { onShowUpgradeTip: () => void } -const MethodSelector: FC<MethodSelectorProps> = ({ - data, - onAdd, - onShowUpgradeTip, -}) => { +const MethodSelector: FC<MethodSelectorProps> = ({ data, onAdd, onShowUpgradeTip }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) - const humanInputEmailDeliveryEnabled = useProviderContextSelector(s => s.humanInputEmailDeliveryEnabled) + const humanInputEmailDeliveryEnabled = useProviderContextSelector( + (s) => s.humanInputEmailDeliveryEnabled, + ) const nodes = useWorkflowNodes() const webAppDeliveryInfo = useMemo(() => { const isTriggerMode = isTriggerWorkflow(nodes) return { - disabled: isTriggerMode || data.some(method => method.type === DeliveryMethodType.WebApp), - added: data.some(method => method.type === DeliveryMethodType.WebApp), + disabled: isTriggerMode || data.some((method) => method.type === DeliveryMethodType.WebApp), + added: data.some((method) => method.type === DeliveryMethodType.WebApp), isTriggerMode, } }, [data, nodes]) @@ -56,24 +50,21 @@ const MethodSelector: FC<MethodSelectorProps> = ({ const emailDeliveryInfo = useMemo(() => { return { noPermission: !humanInputEmailDeliveryEnabled, - added: data.some(method => method.type === DeliveryMethodType.Email), + added: data.some((method) => method.type === DeliveryMethodType.Email), } }, [data, humanInputEmailDeliveryEnabled]) return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <ActionButton - aria-label={t($ => $[`${i18nPrefix}.deliveryMethod.title`], { ns: 'workflow' })} + aria-label={t(($) => $[`${i18nPrefix}.deliveryMethod.title`], { ns: 'workflow' })} className="data-popup-open:bg-state-base-hover" > <RiAddLine className="size-4" /> </ActionButton> - )} + } /> <PopoverContent placement="bottom-end" @@ -83,10 +74,13 @@ const MethodSelector: FC<MethodSelectorProps> = ({ <div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs"> <div className="p-1"> <div - className={cn('relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover', webAppDeliveryInfo.disabled && 'cursor-not-allowed bg-transparent hover:bg-transparent')} + className={cn( + 'relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover', + webAppDeliveryInfo.disabled && + 'cursor-not-allowed bg-transparent hover:bg-transparent', + )} onClick={() => { - if (webAppDeliveryInfo.disabled) - return + if (webAppDeliveryInfo.disabled) return onAdd({ id: uuid4(), type: DeliveryMethodType.WebApp, @@ -94,18 +88,37 @@ const MethodSelector: FC<MethodSelectorProps> = ({ }) }} > - <div className={cn('rounded-sm border border-divider-regular bg-components-icon-bg-indigo-solid p-1', webAppDeliveryInfo.disabled && 'opacity-50')}> + <div + className={cn( + 'rounded-sm border border-divider-regular bg-components-icon-bg-indigo-solid p-1', + webAppDeliveryInfo.disabled && 'opacity-50', + )} + > <RiRobot2Fill className="size-4 text-text-primary-on-surface" /> </div> <div className={cn('p-1', webAppDeliveryInfo.disabled && 'opacity-50')}> - <div className="mb-0.5 truncate system-sm-medium text-text-primary">{t($ => $[`${i18nPrefix}.deliveryMethod.types.webapp.title`], { ns: 'workflow' })}</div> - <div className="truncate system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.types.webapp.description`], { ns: 'workflow' })}</div> + <div className="mb-0.5 truncate system-sm-medium text-text-primary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.types.webapp.title`], { + ns: 'workflow', + })} + </div> + <div className="truncate system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.types.webapp.description`], { + ns: 'workflow', + })} + </div> </div> {webAppDeliveryInfo.added && ( - <div className="absolute top-[13px] right-[12px] system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.added`], { ns: 'workflow' })}</div> + <div className="absolute top-[13px] right-[12px] system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.added`], { ns: 'workflow' })} + </div> )} {webAppDeliveryInfo.isTriggerMode && !webAppDeliveryInfo.added && ( - <div className="absolute top-[13px] right-[12px] system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.notAvailableInTriggerMode`], { ns: 'workflow' })}</div> + <div className="absolute top-[13px] right-[12px] system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.notAvailableInTriggerMode`], { + ns: 'workflow', + })} + </div> )} </div> <div @@ -118,8 +131,7 @@ const MethodSelector: FC<MethodSelectorProps> = ({ onShowUpgradeTip() return } - if (emailDeliveryInfo.added) - return + if (emailDeliveryInfo.added) return onAdd({ id: uuid4(), type: DeliveryMethodType.Email, @@ -136,23 +148,49 @@ const MethodSelector: FC<MethodSelectorProps> = ({ <RiMailSendFill className="size-4 text-text-primary-on-surface" /> </div> <div className={cn('p-1', emailDeliveryInfo.added && 'opacity-50')}> - <div className="mb-0.5 truncate system-sm-medium text-text-primary">{t($ => $[`${i18nPrefix}.deliveryMethod.types.email.title`], { ns: 'workflow' })}</div> - <div className="truncate system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.types.email.description`], { ns: 'workflow' })}</div> + <div className="mb-0.5 truncate system-sm-medium text-text-primary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.types.email.title`], { + ns: 'workflow', + })} + </div> + <div className="truncate system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.types.email.description`], { + ns: 'workflow', + })} + </div> </div> {emailDeliveryInfo.added && ( - <div className="absolute top-[13px] right-[12px] system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.added`], { ns: 'workflow' })}</div> + <div className="absolute top-[13px] right-[12px] system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.added`], { ns: 'workflow' })} + </div> )} </div> {/* Slack */} <div - className={cn('relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover', 'cursor-not-allowed bg-transparent hover:bg-transparent')} + className={cn( + 'relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover', + 'cursor-not-allowed bg-transparent hover:bg-transparent', + )} > - <div className={cn('rounded-sm border border-divider-regular bg-background-default-dodge p-1', 'opacity-50')}> + <div + className={cn( + 'rounded-sm border border-divider-regular bg-background-default-dodge p-1', + 'opacity-50', + )} + > <Slack className="size-4 text-text-primary-on-surface" /> </div> <div className={cn('p-1', 'opacity-50')}> - <div className="mb-0.5 truncate system-sm-medium text-text-primary">{t($ => $[`${i18nPrefix}.deliveryMethod.types.slack.title`], { ns: 'workflow' })}</div> - <div className="truncate system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.types.slack.description`], { ns: 'workflow' })}</div> + <div className="mb-0.5 truncate system-sm-medium text-text-primary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.types.slack.title`], { + ns: 'workflow', + })} + </div> + <div className="truncate system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.types.slack.description`], { + ns: 'workflow', + })} + </div> </div> <div className="absolute top-[8px] right-[8px]"> <Badge className="h-4">COMING SOON</Badge> @@ -160,14 +198,30 @@ const MethodSelector: FC<MethodSelectorProps> = ({ </div> {/* Teams */} <div - className={cn('relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover', 'cursor-not-allowed bg-transparent hover:bg-transparent')} + className={cn( + 'relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover', + 'cursor-not-allowed bg-transparent hover:bg-transparent', + )} > - <div className={cn('rounded-sm border border-divider-regular bg-background-default-dodge p-1', 'opacity-50')}> + <div + className={cn( + 'rounded-sm border border-divider-regular bg-background-default-dodge p-1', + 'opacity-50', + )} + > <Teams className="size-4 text-text-primary-on-surface" /> </div> <div className={cn('p-1', 'opacity-50')}> - <div className="mb-0.5 truncate system-sm-medium text-text-primary">{t($ => $[`${i18nPrefix}.deliveryMethod.types.teams.title`], { ns: 'workflow' })}</div> - <div className="truncate system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.types.teams.description`], { ns: 'workflow' })}</div> + <div className="mb-0.5 truncate system-sm-medium text-text-primary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.types.teams.title`], { + ns: 'workflow', + })} + </div> + <div className="truncate system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.types.teams.description`], { + ns: 'workflow', + })} + </div> </div> <div className="absolute top-[8px] right-[8px]"> <Badge className="h-4">COMING SOON</Badge> @@ -175,14 +229,30 @@ const MethodSelector: FC<MethodSelectorProps> = ({ </div> {/* Discord */} <div - className={cn('relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover', 'cursor-not-allowed bg-transparent hover:bg-transparent')} + className={cn( + 'relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover', + 'cursor-not-allowed bg-transparent hover:bg-transparent', + )} > - <div className={cn('rounded-sm border border-divider-regular bg-components-icon-bg-indigo-solid p-0.5', 'opacity-50')}> + <div + className={cn( + 'rounded-sm border border-divider-regular bg-components-icon-bg-indigo-solid p-0.5', + 'opacity-50', + )} + > <RiDiscordFill className="size-5 text-text-primary-on-surface" /> </div> <div className={cn('p-1', 'opacity-50')}> - <div className="mb-0.5 truncate system-sm-medium text-text-primary">{t($ => $[`${i18nPrefix}.deliveryMethod.types.discord.title`], { ns: 'workflow' })}</div> - <div className="truncate system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.types.discord.description`], { ns: 'workflow' })}</div> + <div className="mb-0.5 truncate system-sm-medium text-text-primary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.types.discord.title`], { + ns: 'workflow', + })} + </div> + <div className="truncate system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.types.discord.description`], { + ns: 'workflow', + })} + </div> </div> <div className="absolute top-[8px] right-[8px]"> <Badge className="h-4">COMING SOON</Badge> @@ -193,15 +263,27 @@ const MethodSelector: FC<MethodSelectorProps> = ({ {!IS_CE_EDITION && ( <div className="mt-1 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs"> <div className="flex items-center gap-2 px-4 py-3"> - <div className={cn('rounded-sm border border-divider-regular bg-components-icon-bg-midnight-solid p-1')}> + <div + className={cn( + 'rounded-sm border border-divider-regular bg-components-icon-bg-midnight-solid p-1', + )} + > <RiLightbulbFlashFill className="size-4 text-text-primary-on-surface" /> </div> <div className="system-sm-regular text-text-secondary"> - <div>{t($ => $[`${i18nPrefix}.deliveryMethod.contactTip1`], { ns: 'workflow' })}</div> + <div> + {t(($) => $[`${i18nPrefix}.deliveryMethod.contactTip1`], { ns: 'workflow' })} + </div> <Trans - i18nKey={$ => $[`${i18nPrefix}.deliveryMethod.contactTip2`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.contactTip2`]} ns="workflow" - components={{ email: <a href="mailto:support@dify.ai" className="text-text-accent-light-mode-only">support@dify.ai</a> }} + components={{ + email: ( + <a href="mailto:support@dify.ai" className="text-text-accent-light-mode-only"> + support@dify.ai + </a> + ), + }} /> </div> </div> diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-input.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-input.spec.tsx index 0ca263206bddc6..556757693dcad2 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-input.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-input.spec.tsx @@ -10,17 +10,13 @@ vi.mock('../email-item', () => ({ __esModule: true, default: (props: { email: string - data: { email?: string, name?: string } + data: { email?: string; name?: string } isError: boolean }) => { mockEmailItem(props) return ( <div data-testid="selected-email-item"> - {props.data.email} - | - {props.data.name} - | - {props.isError ? 'error' : 'ok'} + {props.data.email}|{props.data.name}|{props.isError ? 'error' : 'ok'} </div> ) }, @@ -28,10 +24,7 @@ vi.mock('../email-item', () => ({ vi.mock('../member-list', () => ({ __esModule: true, - default: (props: { - searchValue: string - onSelect: (value: string) => void - }) => { + default: (props: { searchValue: string; onSelect: (value: string) => void }) => { mockMemberList(props) return ( <div data-testid="member-list"> @@ -87,13 +80,18 @@ describe('human-input/delivery-method/recipient/email-input', () => { />, ) - expect(screen.getByTestId('selected-email-item')).toHaveTextContent('member-1@example.com|Member One|ok') + expect(screen.getByTestId('selected-email-item')).toHaveTextContent( + 'member-1@example.com|Member One|ok', + ) const input = screen.getByRole('textbox') expect(input).toHaveAttribute('placeholder', '') fireEvent.click(container.querySelector('.max-h-24') as HTMLDivElement) - expect(input).toHaveAttribute('placeholder', 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder') + expect(input).toHaveAttribute( + 'placeholder', + 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder', + ) fireEvent.change(input, { target: { value: 'member' } }) expect(screen.getByTestId('member-list')).toBeInTheDocument() @@ -165,8 +163,7 @@ describe('human-input/delivery-method/recipient/email-input', () => { expect(document.activeElement).toBe(input) expect(handleParentKeyDown).not.toHaveBeenCalled() expect(handleWindowKeyDown).not.toHaveBeenCalled() - } - finally { + } finally { window.removeEventListener('keydown', handleWindowKeyDown) } }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-item.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-item.spec.tsx index 4c759906877fe2..a5ffceb466993d 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-item.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-item.spec.tsx @@ -19,22 +19,19 @@ describe('human-input/delivery-method/recipient/email-item', () => { last_login_at: '2026-01-03T00:00:00Z', } const { container } = render( - <EmailItem - email="owner@example.com" - data={member} - onDelete={handleDelete} - isError={false} - />, + <EmailItem email="owner@example.com" data={member} onDelete={handleDelete} isError={false} />, ) expect(screen.getByText('Owner')).toBeInTheDocument() expect(screen.getByText('common.members.you')).toBeInTheDocument() fireEvent.click(container.querySelector('.cursor-pointer') as SVGElement) - expect(handleDelete).toHaveBeenCalledWith(expect.objectContaining({ - id: 'member-1', - email: 'owner@example.com', - })) + expect(handleDelete).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'member-1', + email: 'owner@example.com', + }), + ) }) it('should show an error style and hide delete when disabled', () => { @@ -52,13 +49,7 @@ describe('human-input/delivery-method/recipient/email-item', () => { last_login_at: '2026-01-03T00:00:00Z', } const { container } = render( - <EmailItem - email="owner@example.com" - data={member} - onDelete={vi.fn()} - disabled - isError - />, + <EmailItem email="owner@example.com" data={member} onDelete={vi.fn()} disabled isError />, ) expect(screen.getByTitle('missing@example.com')).toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx index f72267d5dee4c3..3dc9c79a9f71b7 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ const mockUseAppContext = vi.hoisted(() => vi.fn()) const mockUseMembers = vi.hoisted(() => vi.fn()) const mockAppContextState = vi.hoisted(() => ({ userProfile: { email: 'owner@example.com' }, - currentWorkspace: { name: 'Dify\'s Lab' }, + currentWorkspace: { name: "Dify's Lab" }, })) vi.mock('react-i18next', () => ({ @@ -36,7 +36,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -45,10 +46,7 @@ vi.mock('@/service/use-common', () => ({ })) vi.mock('@langgenius/dify-ui/switch', () => ({ - Switch: (props: { - checked: boolean - onCheckedChange: (value: boolean) => void - }) => ( + Switch: (props: { checked: boolean; onCheckedChange: (value: boolean) => void }) => ( <button type="button" onClick={() => props.onCheckedChange(!props.checked)}> toggle-workspace </button> @@ -69,7 +67,7 @@ vi.mock('../email-input', () => ({ default: (props: { onAdd: (email: string) => void onSelect: (id: string) => void - onDelete: (recipient: { type: 'member' | 'external', user_id?: string, email?: string }) => void + onDelete: (recipient: { type: 'member' | 'external'; user_id?: string; email?: string }) => void }) => ( <div> <button type="button" onClick={() => props.onAdd('new@example.com')}> @@ -81,7 +79,10 @@ vi.mock('../email-input', () => ({ <button type="button" onClick={() => props.onDelete({ type: 'member', user_id: 'member-1' })}> delete-member </button> - <button type="button" onClick={() => props.onDelete({ type: 'external', email: 'external@example.com' })}> + <button + type="button" + onClick={() => props.onDelete({ type: 'external', email: 'external@example.com' })} + > delete-external </button> </div> @@ -94,7 +95,9 @@ describe('Recipient', () => { beforeEach(() => { vi.clearAllMocks() mockUseTranslation.mockReturnValue({ - t: withSelectorKey((key: string, options?: { workspaceName?: string }) => options?.workspaceName ?? key), + t: withSelectorKey( + (key: string, options?: { workspaceName?: string }) => options?.workspaceName ?? key, + ), }) mockUseAppContext.mockReturnValue(mockAppContextState) mockUseMembers.mockReturnValue({ @@ -158,15 +161,11 @@ describe('Recipient', () => { }) expect(onChange).toHaveBeenNthCalledWith(4, { whole_workspace: false, - items: [ - { type: 'external', email: 'external@example.com' }, - ], + items: [{ type: 'external', email: 'external@example.com' }], }) expect(onChange).toHaveBeenNthCalledWith(5, { whole_workspace: false, - items: [ - { type: 'member', user_id: 'member-1' }, - ], + items: [{ type: 'member', user_id: 'member-1' }], }) expect(onChange).toHaveBeenNthCalledWith(6, { whole_workspace: true, diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-list.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-list.spec.tsx index b794f23be214b4..5722b9e346a65b 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-list.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-list.spec.tsx @@ -49,7 +49,11 @@ describe('human-input/delivery-method/recipient/member-list', () => { expect(screen.getByText('Pending User')).toBeInTheDocument() expect(screen.getByText('common.members.pending')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add')).toBeInTheDocument() + expect( + screen.getByText( + 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add', + ), + ).toBeInTheDocument() fireEvent.click(screen.getByText('Pending User')) expect(handleSelect).toHaveBeenCalledWith('member-2') @@ -70,7 +74,11 @@ describe('human-input/delivery-method/recipient/member-list', () => { ) expect(screen.getByText('common.members.you')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added')).toBeInTheDocument() + expect( + screen.getByText( + 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added', + ), + ).toBeInTheDocument() fireEvent.click(screen.getByText('Owner')) expect(handleSelect).not.toHaveBeenCalled() diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx index 0f7e1eca1d793d..01bc96ffc2d21b 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx @@ -20,7 +20,7 @@ vi.mock('../member-list', () => ({ <input aria-label="member search" value={props.searchValue} - onChange={e => props.onSearchChange(e.target.value)} + onChange={(e) => props.onSearchChange(e.target.value)} /> <button type="button" onClick={() => props.onSelect('member-1')}> select member @@ -30,19 +30,21 @@ vi.mock('../member-list', () => ({ }, })) -const members: Member[] = [{ - id: 'member-1', - email: 'member-1@example.com', - name: 'Member One', - avatar: 'avatar-data', - avatar_url: 'avatar.png', - status: 'active', - role: 'normal', - roles: [], - created_at: '2026-01-01T00:00:00Z', - last_active_at: '2026-01-02T00:00:00Z', - last_login_at: '2026-01-03T00:00:00Z', -}] +const members: Member[] = [ + { + id: 'member-1', + email: 'member-1@example.com', + name: 'Member One', + avatar: 'avatar-data', + avatar_url: 'avatar.png', + status: 'active', + role: 'normal', + roles: [], + created_at: '2026-01-01T00:00:00Z', + last_active_at: '2026-01-02T00:00:00Z', + last_login_at: '2026-01-03T00:00:00Z', + }, +] describe('human-input/delivery-method/recipient/member-selector', () => { beforeEach(() => { @@ -71,11 +73,13 @@ describe('human-input/delivery-method/recipient/member-selector', () => { expect(screen.getByTestId('member-list')).toBeInTheDocument() expect(trigger).toHaveAttribute('data-popup-open') expect(trigger).toHaveClass('data-popup-open:bg-state-accent-hover') - expect(mockMemberList).toHaveBeenCalledWith(expect.objectContaining({ - searchValue: '', - list: members, - email: 'owner@example.com', - })) + expect(mockMemberList).toHaveBeenCalledWith( + expect.objectContaining({ + searchValue: '', + list: members, + email: 'owner@example.com', + }), + ) await user.click(trigger) expect(screen.queryByTestId('member-list')).not.toBeInTheDocument() @@ -94,14 +98,18 @@ describe('human-input/delivery-method/recipient/member-selector', () => { />, ) - await user.click(screen.getByRole('button', { - name: 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger', - })) + await user.click( + screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger', + }), + ) await user.type(screen.getByRole('textbox', { name: 'member search' }), 'member one') - expect(mockMemberList).toHaveBeenLastCalledWith(expect.objectContaining({ - searchValue: 'member one', - })) + expect(mockMemberList).toHaveBeenLastCalledWith( + expect.objectContaining({ + searchValue: 'member one', + }), + ) await user.click(screen.getByRole('button', { name: 'select member' })) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx index 8d5676a8193900..035003326a184a 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx @@ -1,10 +1,7 @@ import type { Recipient as RecipientItem } from '../../../types' import type { Member } from '@/models/common' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -23,15 +20,7 @@ type Props = Readonly<{ disabled?: boolean }> -const EmailInput = ({ - email, - value, - list, - onDelete, - onSelect, - onAdd, - disabled = false, -}: Props) => { +const EmailInput = ({ email, value, list, onDelete, onSelect, onAdd, disabled = false }: Props) => { const { t } = useTranslation() const inputRef = useRef<HTMLInputElement>(null) const [isFocus, setIsFocus] = useState(false) @@ -40,22 +29,27 @@ const EmailInput = ({ const selectedEmails = useMemo(() => { return value.map((item) => { - const member = list.find(account => account.id === item.user_id) + const member = list.find((account) => account.id === item.user_id) return member ? { ...item, email: member.email, name: member.name } : item }) }, [list, value]) - const isErrorMember = useCallback((emailItem: RecipientItem) => emailItem.type === 'member' && list.every(item => item.id !== emailItem.user_id), [list]) + const isErrorMember = useCallback( + (emailItem: RecipientItem) => + emailItem.type === 'member' && list.every((item) => item.id !== emailItem.user_id), + [list], + ) const placeholder = useMemo(() => { - return (selectedEmails.length === 0 || isFocus) - ? t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.placeholder`], { ns: 'workflow' }) + return selectedEmails.length === 0 || isFocus + ? t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.placeholder`], { + ns: 'workflow', + }) : '' }, [selectedEmails, t, isFocus]) const setInputFocus = () => { - if (disabled) - return + if (disabled) return setIsFocus(true) inputRef.current?.focus() } @@ -83,15 +77,12 @@ const EmailInput = ({ const handleEmailAdd = () => { const emailAddress = searchKey.trim() - if (!checkEmailValid(emailAddress)) - return - if (value.some(item => item.email === emailAddress)) - return - if (list.some(item => item.email === emailAddress)) { - const item = list.find(item => item.email === emailAddress)! + if (!checkEmailValid(emailAddress)) return + if (value.some((item) => item.email === emailAddress)) return + if (list.some((item) => item.email === emailAddress)) { + const item = list.find((item) => item.email === emailAddress)! onSelect(item.id) - } - else { + } else { onAdd(emailAddress) } setSearchKey('') @@ -109,8 +100,7 @@ const EmailInput = ({ if (e.key === 'Enter' || e.key === 'Tab' || e.key === ' ' || e.key === ',') { e.preventDefault() handleEmailAdd() - } - else if (e.key === 'Backspace') { + } else if (e.key === 'Backspace') { if (searchKey === '' && value.length > 0) { e.preventDefault() onDelete(value[value.length - 1]!) @@ -125,12 +115,14 @@ const EmailInput = ({ <div className={cn( 'flex max-h-24 min-h-16 flex-wrap overflow-y-auto rounded-lg border border-transparent bg-components-input-bg-normal p-2', - isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs', - !disabled && 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', + isFocus && + 'border-components-input-border-active bg-components-input-bg-active shadow-xs', + !disabled && + 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', )} onClick={setInputFocus} > - {selectedEmails.map(item => ( + {selectedEmails.map((item) => ( <EmailItem key={item.user_id || item.email} email={email} diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx index 57cd18706b0e05..c78baa34ad097d 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx @@ -14,13 +14,7 @@ type Props = Readonly<{ isError: boolean }> -const EmailItem = ({ - email, - data, - onDelete, - disabled = false, - isError, -}: Props) => { +const EmailItem = ({ email, data, onDelete, disabled = false, isError }: Props) => { const { t } = useTranslation() return ( @@ -29,15 +23,20 @@ const EmailItem = ({ 'flex h-6 items-center gap-1 rounded-full border border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 shadow-xs', isError && 'border-state-destructive-hover-alt bg-state-destructive-hover', )} - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > - {isError && ( - <RiErrorWarningFill className="size-4 text-text-destructive" /> - )} + {isError && <RiErrorWarningFill className="size-4 text-text-destructive" />} {!isError && <Avatar avatar={data.avatar_url} size="xxs" name={data.name || data.email} />} - <div title={data.email} className="max-w-[500px] truncate system-xs-regular text-text-primary"> + <div + title={data.email} + className="max-w-[500px] truncate system-xs-regular text-text-primary" + > {email === data.email ? data.name : data.email} - {email === data.email && <span className="system-xs-regular text-text-tertiary">{t($ => $['members.you'], { ns: 'common' })}</span>} + {email === data.email && ( + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['members.you'], { ns: 'common' })} + </span> + )} </div> {!disabled && ( <RiCloseCircleFill diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx index dcc51c840f1984..4a445389596c97 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx @@ -19,10 +19,7 @@ type Props = Readonly<{ onChange: (data: RecipientData) => void }> -const Recipient = ({ - data, - onChange, -}: Props) => { +const Recipient = ({ data, onChange }: Props) => { const { t } = useTranslation() const userProfileEmail = useAtomValue(userProfileEmailAtom) const currentWorkspace = useAtomValue(currentWorkspaceAtom) @@ -55,9 +52,9 @@ const Recipient = ({ onChange( produce(data, (draft) => { if (recipient.type === 'member') - draft.items = draft.items.filter(item => item.user_id !== recipient.user_id) + draft.items = draft.items.filter((item) => item.user_id !== recipient.user_id) else if (recipient.type === 'external') - draft.items = draft.items.filter(item => item.email !== recipient.email) + draft.items = draft.items.filter((item) => item.email !== recipient.email) }), ) } @@ -68,7 +65,11 @@ const Recipient = ({ <div className="flex h-10 items-center justify-between pr-1 pl-3"> <div className="flex grow items-center gap-2"> <RiGroupLine className="size-4 text-text-secondary" /> - <div className="system-sm-medium text-text-secondary">{t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.title`], { ns: 'workflow' })}</div> + <div className="system-sm-medium text-text-secondary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.title`], { + ns: 'workflow', + })} + </div> </div> <div className="w-[86px]"> <MemberSelector @@ -90,12 +91,19 @@ const Recipient = ({ </div> <div className="flex h-10 items-center gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pr-3 pl-2.5 shadow-xs"> <div className="flex h-5 w-5 items-center justify-center rounded-xl bg-components-icon-bg-blue-solid text-[14px]"> - <span className="bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold text-shadow-shadow-1 uppercase opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span> + <span className="bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold text-shadow-shadow-1 uppercase opacity-90"> + {currentWorkspace?.name[0]?.toLocaleUpperCase()} + </span> + </div> + <div className={cn('grow system-sm-medium text-text-secondary')}> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.allMembers`], { + workspaceName: currentWorkspace.name.replace(/'/g, '’'), + ns: 'workflow', + })} </div> - <div className={cn('grow system-sm-medium text-text-secondary')}>{t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.allMembers`], { workspaceName: currentWorkspace.name.replace(/'/g, '’'), ns: 'workflow' })}</div> <Switch checked={data.whole_workspace} - onCheckedChange={checked => onChange({ ...data, whole_workspace: checked })} + onCheckedChange={(checked) => onChange({ ...data, whole_workspace: checked })} /> </div> </div> diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx index 4cdb21eb745521..6afc457b9b7607 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx @@ -20,24 +20,31 @@ type Props = Readonly<{ hideSearch?: boolean }> -const MemberList: FC<Props> = ({ searchValue, list, value, onSearchChange, onSelect, email, hideSearch }) => { +const MemberList: FC<Props> = ({ + searchValue, + list, + value, + onSearchChange, + onSelect, + email, + hideSearch, +}) => { const { t } = useTranslation() const filteredList = useMemo(() => { - if (!list.length) - return [] - if (!searchValue) - return list + if (!list.length) return [] + if (!searchValue) return list return list.filter((account) => { const name = account.name || '' const email = account.email || '' - return name.toLowerCase().includes(searchValue.toLowerCase()) - || email.toLowerCase().includes(searchValue.toLowerCase()) + return ( + name.toLowerCase().includes(searchValue.toLowerCase()) || + email.toLowerCase().includes(searchValue.toLowerCase()) + ) }) }, [list, searchValue]) - if (hideSearch && filteredList.length === 0) - return null + if (hideSearch && filteredList.length === 0) return null return ( <div className="min-w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs"> @@ -46,39 +53,65 @@ const MemberList: FC<Props> = ({ searchValue, list, value, onSearchChange, onSel <Input showLeftIcon value={searchValue} - onChange={e => onSearchChange(e.target.value)} + onChange={(e) => onSearchChange(e.target.value)} /> </div> )} {filteredList.length > 0 && ( <div className="max-h-[248px] overflow-y-auto p-1"> - {filteredList.map(account => ( + {filteredList.map((account) => ( <div key={account.id} className={cn( 'group flex cursor-pointer items-center gap-2 rounded-lg py-1 pr-3 pl-2 hover:bg-state-base-hover', - value.some(item => item.user_id === account.id) && 'bg-transparent hover:bg-transparent', + value.some((item) => item.user_id === account.id) && + 'bg-transparent hover:bg-transparent', )} onClick={() => { - if (value.some(item => item.user_id === account.id)) - return + if (value.some((item) => item.user_id === account.id)) return onSelect(account.id) }} > - <Avatar className={cn(value.some(item => item.user_id === account.id) && 'opacity-50')} avatar={account.avatar_url} size="sm" name={account.name} /> - <div className={cn('grow', value.some(item => item.user_id === account.id) && 'opacity-50')}> + <Avatar + className={cn(value.some((item) => item.user_id === account.id) && 'opacity-50')} + avatar={account.avatar_url} + size="sm" + name={account.name} + /> + <div + className={cn( + 'grow', + value.some((item) => item.user_id === account.id) && 'opacity-50', + )} + > <div className="system-sm-medium text-text-secondary"> {account.name} - {account.status === 'pending' && <span className="ml-1 system-xs-medium text-text-warning">{t($ => $['members.pending'], { ns: 'common' })}</span>} - {email === account.email && <span className="system-xs-regular text-text-tertiary">{t($ => $['members.you'], { ns: 'common' })}</span>} + {account.status === 'pending' && ( + <span className="ml-1 system-xs-medium text-text-warning"> + {t(($) => $['members.pending'], { ns: 'common' })} + </span> + )} + {email === account.email && ( + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['members.you'], { ns: 'common' })} + </span> + )} </div> <div className="system-xs-regular text-text-tertiary">{account.email}</div> </div> - {!value.some(item => item.user_id === account.id) && ( - <div className="hidden system-xs-medium text-text-accent group-hover:block">{t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.add`], { ns: 'workflow' })}</div> + {!value.some((item) => item.user_id === account.id) && ( + <div className="hidden system-xs-medium text-text-accent group-hover:block"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.add`], { + ns: 'workflow', + })} + </div> )} - {value.some(item => item.user_id === account.id) && ( - <div className="system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.added`], { ns: 'workflow' })}</div> + {value.some((item) => item.user_id === account.id) && ( + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.added`], { + ns: 'workflow', + })} + </div> )} </div> ))} diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx index 43e4d6ada865d3..54ebb7f98e7d28 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx @@ -3,14 +3,8 @@ import type { FC } from 'react' import type { Recipient } from '@/app/components/workflow/nodes/human-input/types' import type { Member } from '@/models/common' import { Button } from '@langgenius/dify-ui/button' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - RiContactsBookLine, -} from '@remixicon/react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { RiContactsBookLine } from '@remixicon/react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import MemberList from './member-list' @@ -24,33 +18,35 @@ type Props = Readonly<{ list: Member[] }> -const MemberSelector: FC<Props> = ({ - value, - email, - onSelect, - list = [], -}) => { +const MemberSelector: FC<Props> = ({ value, email, onSelect, list = [] }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const [searchValue, setSearchValue] = useState('') - const handleSelect = useCallback((memberId: string) => { - onSelect(memberId) - setOpen(false) - }, [onSelect]) + const handleSelect = useCallback( + (memberId: string) => { + onSelect(memberId) + setOpen(false) + }, + [onSelect], + ) return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <Button className="w-full justify-between data-popup-open:bg-state-accent-hover" variant="ghost-accent" > <RiContactsBookLine className="mr-1 size-4" /> - <div>{t($ => $[`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.trigger`], { ns: 'workflow' })}</div> + <div> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.trigger`], { + ns: 'workflow', + })} + </div> </Button> - )} + } /> <PopoverContent placement="bottom-end" diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx index 40ce2c7cb13046..00f228e0286cea 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx @@ -1,9 +1,5 @@ import type { EmailConfig, FormInputItem } from '../../types' -import type { - Node, - NodeOutPutVar, - Var, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar, Var } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' @@ -48,48 +44,53 @@ type EmailSenderModalProps = { } const getOriginVar = (valueSelector: string[], list: NodeOutPutVar[]) => { - const targetVar = list.find(item => item.nodeId === valueSelector[0]) - if (!targetVar) - return undefined + const targetVar = list.find((item) => item.nodeId === valueSelector[0]) + if (!targetVar) return undefined let curr: Var[] | undefined = targetVar.vars for (let i = 1; i < valueSelector.length; i++) { const key = valueSelector[i] const isLast = i === valueSelector.length - 1 - const currentVar: Var | undefined = curr?.find(v => v.variable.replace('conversation.', '') === key) + const currentVar: Var | undefined = curr?.find( + (v) => v.variable.replace('conversation.', '') === key, + ) - if (!currentVar) - return undefined + if (!currentVar) return undefined - if (isLast) - return currentVar + if (isLast) return currentVar - if ((currentVar.type === VarType.object || currentVar.type === VarType.file) && Array.isArray(currentVar.children)) + if ( + (currentVar.type === VarType.object || currentVar.type === VarType.file) && + Array.isArray(currentVar.children) + ) curr = currentVar.children - else - return undefined + else return undefined } return undefined } const varTypeToInputVarType = (type: VarType) => { - if (type === VarType.number) - return InputVarType.number - if (type === VarType.boolean) - return InputVarType.checkbox - if ([VarType.object, VarType.array, VarType.arrayNumber, VarType.arrayString, VarType.arrayObject].includes(type)) + if (type === VarType.number) return InputVarType.number + if (type === VarType.boolean) return InputVarType.checkbox + if ( + [ + VarType.object, + VarType.array, + VarType.arrayNumber, + VarType.arrayString, + VarType.arrayObject, + ].includes(type) + ) return InputVarType.json - if (type === VarType.file) - return InputVarType.singleFile - if (type === VarType.arrayFile) - return InputVarType.multiFiles + if (type === VarType.file) return InputVarType.singleFile + if (type === VarType.arrayFile) return InputVarType.multiFiles return InputVarType.textInput } const formatEmailSenderInputs = ( - variables: Array<{ variable: string, type: InputVarType, label: unknown }>, + variables: Array<{ variable: string; type: InputVarType; label: unknown }>, values: Record<string, unknown>, ) => { const formattedValues: Record<string, unknown> = {} @@ -98,11 +99,13 @@ const formatEmailSenderInputs = ( variables.forEach((variable) => { try { formattedValues[variable.variable] = formatValue(values[variable.variable], variable.type) - } - catch { - parseErrorJsonField = typeof variable.label === 'object' && variable.label !== null && 'variable' in variable.label - ? String(variable.label.variable) - : variable.variable + } catch { + parseErrorJsonField = + typeof variable.label === 'object' && + variable.label !== null && + 'variable' in variable.label + ? String(variable.label.variable) + : variable.variable } }) @@ -127,13 +130,21 @@ const EmailSenderModal = ({ const { t } = useTranslation() const userProfileEmail = useAtomValue(userProfileEmailAtom) const currentWorkspace = useAtomValue(currentWorkspaceAtom) - const appDetail = useAppStore(state => state.appDetail) + const appDetail = useAppStore((state) => state.appDetail) const { mutateAsync: testEmailSender } = useTestEmailSender() const debugEnabled = !!config?.debug_mode - const onlyWholeTeam = config?.recipients?.whole_workspace && (!config?.recipients?.items || config?.recipients?.items.length === 0) - const onlySpecificUsers = !config?.recipients?.whole_workspace && config?.recipients?.items && config?.recipients?.items.length > 0 - const combinedRecipients = config?.recipients?.whole_workspace && config?.recipients?.items && config?.recipients?.items.length > 0 + const onlyWholeTeam = + config?.recipients?.whole_workspace && + (!config?.recipients?.items || config?.recipients?.items.length === 0) + const onlySpecificUsers = + !config?.recipients?.whole_workspace && + config?.recipients?.items && + config?.recipients?.items.length > 0 + const combinedRecipients = + config?.recipients?.whole_workspace && + config?.recipients?.items && + config?.recipients?.items.length > 0 const { data: members } = useMembers() const accounts = members?.accounts || [] @@ -141,7 +152,9 @@ const EmailSenderModal = ({ const generatedInputs = useMemo(() => { const formInputDependencySelectors = getHumanInputFormDependencySelectors(formInputs || []) const valueSelectors = doGetInputVars((formContent || '') + (config?.body || '')) - const variables = unionBy([...valueSelectors, ...formInputDependencySelectors], item => item.join('.')).map((item) => { + const variables = unionBy([...valueSelectors, ...formInputDependencySelectors], (item) => + item.join('.'), + ).map((item) => { const varInfo = getNodeInfoById(availableNodes, item[0]!)?.data return { @@ -156,24 +169,26 @@ const EmailSenderModal = ({ required: true, } }) - const varInputs = variables.filter(item => !isENV(item.value_selector) && !isOutput(item.value_selector)).map((item) => { - const originalVar = getOriginVar(item.value_selector, nodesOutputVars) - if (!originalVar) { + const varInputs = variables + .filter((item) => !isENV(item.value_selector) && !isOutput(item.value_selector)) + .map((item) => { + const originalVar = getOriginVar(item.value_selector, nodesOutputVars) + if (!originalVar) { + return { + label: item.label || item.variable, + variable: item.variable, + type: InputVarType.textInput, + required: true, + value_selector: item.value_selector, + } + } return { label: item.label || item.variable, variable: item.variable, - type: InputVarType.textInput, + type: varTypeToInputVarType(originalVar.type), required: true, - value_selector: item.value_selector, } - } - return { - label: item.label || item.variable, - variable: item.variable, - type: varTypeToInputVarType(originalVar.type), - required: true, - } - }) + }) return varInputs }, [availableNodes, config?.body, formContent, formInputs, nodesOutputVars]) @@ -202,11 +217,15 @@ const EmailSenderModal = ({ }, [generatedInputs, inputs]) const handleConfirm = useCallback(async () => { - if (!confirmChecked) - return - const { formattedValues, parseErrorJsonField } = formatEmailSenderInputs(generatedInputs, inputs) + if (!confirmChecked) return + const { formattedValues, parseErrorJsonField } = formatEmailSenderInputs( + generatedInputs, + inputs, + ) if (parseErrorJsonField) { - toast.error(t($ => $['errorMsg.invalidJson'], { ns: 'workflow', field: parseErrorJsonField })) + toast.error( + t(($) => $['errorMsg.invalidJson'], { ns: 'workflow', field: parseErrorJsonField }), + ) return } setSendingEmail(true) @@ -218,27 +237,36 @@ const EmailSenderModal = ({ inputs: formattedValues, }) setDone(true) - } - finally { + } finally { setSendingEmail(false) } - }, [confirmChecked, generatedInputs, inputs, testEmailSender, appDetail?.id, nodeId, deliveryId, t]) + }, [ + confirmChecked, + generatedInputs, + inputs, + testEmailSender, + appDetail?.id, + nodeId, + deliveryId, + t, + ]) if (done) { return ( - <Dialog - open={open} - onOpenChange={onOpenChange} - > + <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent> <div className="space-y-2"> - <DialogTitle className="title-2xl-semi-bold text-text-primary">{t($ => $[`${i18nPrefix}.deliveryMethod.emailSender.done`], { ns: 'workflow' })}</DialogTitle> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.done`], { ns: 'workflow' })} + </DialogTitle> {debugEnabled && ( <div className="system-md-regular text-text-secondary"> <Trans - i18nKey={$ => $[`${i18nPrefix}.deliveryMethod.emailSender.debugDone`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.emailSender.debugDone`]} ns="workflow" - components={{ email: <span className="system-md-semibold text-text-secondary"></span> }} + components={{ + email: <span className="system-md-semibold text-text-secondary"></span>, + }} values={{ email: userProfileEmail }} /> </div> @@ -246,22 +274,30 @@ const EmailSenderModal = ({ {!debugEnabled && onlyWholeTeam && ( <div className="system-md-regular text-text-secondary"> <Trans - i18nKey={$ => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone2`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone2`]} ns="workflow" - components={{ team: <span className="system-md-medium text-text-secondary"></span> }} + components={{ + team: <span className="system-md-medium text-text-secondary"></span>, + }} values={{ team: currentWorkspace.name.replace(/'/g, '’') }} /> </div> )} {!debugEnabled && onlySpecificUsers && ( - <div className="system-md-regular text-text-secondary">{t($ => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`], { ns: 'workflow' })}</div> + <div className="system-md-regular text-text-secondary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`], { + ns: 'workflow', + })} + </div> )} {!debugEnabled && combinedRecipients && ( <div className="system-md-regular text-text-secondary"> <Trans - i18nKey={$ => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone1`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone1`]} ns="workflow" - components={{ team: <span className="system-md-medium text-text-secondary"></span> }} + components={{ + team: <span className="system-md-medium text-text-secondary"></span>, + }} values={{ team: currentWorkspace.name.replace(/'/g, '’') }} /> </div> @@ -281,12 +317,8 @@ const EmailSenderModal = ({ </div> )} <div className="mt-6 flex flex-row-reverse gap-2"> - <Button - variant="primary" - className="w-[72px]" - onClick={() => onOpenChange(false)} - > - {t($ => $['operation.ok'], { ns: 'common' })} + <Button variant="primary" className="w-[72px]" onClick={() => onOpenChange(false)}> + {t(($) => $['operation.ok'], { ns: 'common' })} </Button> </div> </DialogContent> @@ -295,22 +327,27 @@ const EmailSenderModal = ({ } return ( - <Dialog - open={open} - onOpenChange={onOpenChange} - > + <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent> <DialogCloseButton /> <div className="space-y-1 pr-8"> - <DialogTitle className="title-2xl-semi-bold text-text-primary">{t($ => $[`${i18nPrefix}.deliveryMethod.emailSender.title`], { ns: 'workflow' })}</DialogTitle> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.title`], { ns: 'workflow' })} + </DialogTitle> {debugEnabled && ( <> - <div className="system-sm-regular text-text-secondary">{t($ => $[`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`], { ns: 'workflow' })}</div> + <div className="system-sm-regular text-text-secondary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`], { + ns: 'workflow', + })} + </div> <div className="system-sm-regular text-text-secondary"> <Trans - i18nKey={$ => $[`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip2`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip2`]} ns="workflow" - components={{ email: <span className="system-sm-semibold text-text-primary"></span> }} + components={{ + email: <span className="system-sm-semibold text-text-primary"></span>, + }} values={{ email: userProfileEmail }} /> </div> @@ -319,22 +356,30 @@ const EmailSenderModal = ({ {!debugEnabled && onlyWholeTeam && ( <div className="system-sm-regular text-text-secondary"> <Trans - i18nKey={$ => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip2`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip2`]} ns="workflow" - components={{ team: <span className="system-sm-semibold text-text-primary"></span> }} + components={{ + team: <span className="system-sm-semibold text-text-primary"></span>, + }} values={{ team: currentWorkspace.name.replace(/'/g, '’') }} /> </div> )} {!debugEnabled && onlySpecificUsers && ( - <div className="system-sm-regular text-text-secondary">{t($ => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`], { ns: 'workflow' })}</div> + <div className="system-sm-regular text-text-secondary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`], { + ns: 'workflow', + })} + </div> )} {!debugEnabled && combinedRecipients && ( <div className="system-sm-regular text-text-secondary"> <Trans - i18nKey={$ => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip1`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip1`]} ns="workflow" - components={{ team: <span className="system-sm-semibold text-text-primary"></span> }} + components={{ + team: <span className="system-sm-semibold text-text-primary"></span>, + }} values={{ team: currentWorkspace.name.replace(/'/g, '’') }} /> </div> @@ -355,7 +400,7 @@ const EmailSenderModal = ({ </div> <div className="mt-1 system-xs-regular text-text-tertiary"> <Trans - i18nKey={$ => $[`${i18nPrefix}.deliveryMethod.emailSender.tip`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.emailSender.tip`]} ns="workflow" components={{ strong: ( @@ -383,22 +428,31 @@ const EmailSenderModal = ({ className="group flex h-6 cursor-pointer items-center border-none bg-transparent p-0 text-left" onClick={() => setCollapsed(!collapsed)} > - <div className="mr-1 system-sm-semibold-uppercase text-text-secondary">{t($ => $[`${i18nPrefix}.deliveryMethod.emailSender.vars`], { ns: 'workflow' })}</div> - <RiArrowRightSFill className={cn('size-4 text-text-quaternary group-hover:text-text-primary', !collapsed && 'rotate-90')} aria-hidden /> + <div className="mr-1 system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.vars`], { ns: 'workflow' })} + </div> + <RiArrowRightSFill + className={cn( + 'size-4 text-text-quaternary group-hover:text-text-primary', + !collapsed && 'rotate-90', + )} + aria-hidden + /> </button> - <div className="system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.emailSender.varsTip`], { ns: 'workflow' })}</div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.varsTip`], { + ns: 'workflow', + })} + </div> {!collapsed && ( <div className="mt-3 space-y-4"> {generatedInputs.map((variable, index) => ( - <div - key={variable.variable} - className="mb-4 last-of-type:mb-0" - > + <div key={variable.variable} className="mb-4 last-of-type:mb-0"> <FormItem autoFocus={index === 0} payload={variable} value={inputs[variable.variable]} - onChange={v => handleValueChange(variable.variable, v)} + onChange={(v) => handleValueChange(variable.variable, v)} /> </div> ))} @@ -414,13 +468,10 @@ const EmailSenderModal = ({ variant="primary" onClick={handleConfirm} > - {t($ => $[`${i18nPrefix}.deliveryMethod.emailSender.send`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.send`], { ns: 'workflow' })} </Button> - <Button - className="w-[72px]" - onClick={() => onOpenChange(false)} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button className="w-[72px]" onClick={() => onOpenChange(false)}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx index 3c2fd7b764ed95..c53fc141b8397b 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx @@ -12,12 +12,9 @@ type UpgradeModalProps = { onOpenChange: (open: boolean) => void } -export function UpgradeModal({ - open, - onOpenChange, -}: UpgradeModalProps) { +export function UpgradeModal({ open, onOpenChange }: UpgradeModalProps) { const { t } = useTranslation() - const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal) + const setShowPricingModal = useModalContextSelector((s) => s.setShowPricingModal) const handleUpgrade = () => { setShowPricingModal() } @@ -27,18 +24,17 @@ export function UpgradeModal({ open={open} onOpenChange={onOpenChange} Icon={RiMailSendFill} - title={t($ => $['nodes.humanInput.deliveryMethod.upgradeTip'], { ns: 'workflow' })} - description={t($ => $['nodes.humanInput.deliveryMethod.upgradeTipContent'], { ns: 'workflow' })} + title={t(($) => $['nodes.humanInput.deliveryMethod.upgradeTip'], { ns: 'workflow' })} + description={t(($) => $['nodes.humanInput.deliveryMethod.upgradeTipContent'], { + ns: 'workflow', + })} classNames={{ content: 'max-w-[580px]', }} - footer={( + footer={ <> - <Button - className="w-[72px]" - onClick={() => onOpenChange(false)} - > - {t($ => $['nodes.humanInput.deliveryMethod.upgradeTipHide'], { ns: 'workflow' })} + <Button className="w-[72px]" onClick={() => onOpenChange(false)}> + {t(($) => $['nodes.humanInput.deliveryMethod.upgradeTipHide'], { ns: 'workflow' })} </Button> {IS_CLOUD_EDITION && ( <PremiumBadgeButton @@ -47,16 +43,19 @@ export function UpgradeModal({ className="h-8 w-[93px]" onClick={handleUpgrade} > - <SparklesSoft aria-hidden="true" className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" /> + <SparklesSoft + aria-hidden="true" + className="flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" + /> <div className="system-sm-medium"> <span className="p-1"> - {t($ => $['upgradeBtn.encourageShort'], { ns: 'billing' })} + {t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })} </span> </div> </PremiumBadgeButton> )} </> - )} + } /> ) } diff --git a/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx b/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx index a589fcec9d2a8b..8218430a00e211 100644 --- a/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx +++ b/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx @@ -30,28 +30,34 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({ onClose, }) => { const { t } = useTranslation() - const panelWidth = useStore(state => state.panelWidth) + const panelWidth = useStore((state) => state.panelWidth) const nodes = useNodes() - const nodeName = React.useCallback((nodeId: string) => { - const node = nodes.find(n => n.id === nodeId) - return node?.data.title || nodeId - }, [nodes]) + const nodeName = React.useCallback( + (nodeId: string) => { + const node = nodes.find((n) => n.id === nodeId) + return node?.data.title || nodeId + }, + [nodes], + ) - const renderInputPreview = React.useCallback(({ node }: { node?: { properties?: Record<string, unknown> } }) => { - const name = String(node?.properties?.dataName ?? '') - const input = formInputs.find(i => i.output_variable_name === name) - if (!input) { - return ( - <div> - Can't find note: - {name} - </div> - ) - } + const renderInputPreview = React.useCallback( + ({ node }: { node?: { properties?: Record<string, unknown> } }) => { + const name = String(node?.properties?.dataName ?? '') + const input = formInputs.find((i) => i.output_variable_name === name) + if (!input) { + return ( + <div> + Can't find note: + {name} + </div> + ) + } - return <Note input={input} nodeName={nodeName} /> - }, [formInputs, nodeName]) + return <Note input={input} nodeName={nodeName} /> + }, + [formInputs, nodeName], + ) return ( <div @@ -61,8 +67,12 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({ }} > <div className="flex h-[26px] items-center justify-between px-4"> - <Badge uppercase className="border-text-accent-secondary text-text-accent-secondary">{t($ => $[`${i18nPrefix}.formContent.preview`], { ns: 'workflow' })}</Badge> - <ActionButton onClick={onClose}><span className="i-ri-close-line size-5 text-text-tertiary" /></ActionButton> + <Badge uppercase className="border-text-accent-secondary text-text-accent-secondary"> + {t(($) => $[`${i18nPrefix}.formContent.preview`], { ns: 'workflow' })} + </Badge> + <ActionButton onClick={onClose}> + <span className="i-ri-close-line size-5 text-text-tertiary" /> + </ActionButton> </div> <div className="max-h-[calc(100vh-167px)] overflow-y-auto px-4"> <Markdown @@ -92,7 +102,9 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({ </Button> ))} </div> - <div className="mt-1 system-xs-regular text-text-tertiary">{t($ => $['nodes.humanInput.editor.previewTip'], { ns: 'workflow' })}</div> + <div className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $['nodes.humanInput.editor.previewTip'], { ns: 'workflow' })} + </div> </div> </div> ) diff --git a/web/app/components/workflow/nodes/human-input/components/form-content.tsx b/web/app/components/workflow/nodes/human-input/components/form-content.tsx index be2da73942a1ef..6fde93e9bfbc6d 100644 --- a/web/app/components/workflow/nodes/human-input/components/form-content.tsx +++ b/web/app/components/workflow/nodes/human-input/components/form-content.tsx @@ -54,48 +54,40 @@ const FormContent: FC<FormContentProps> = ({ value: string formInputs: FormInputItem[] } | null>(null) - const handleInsertHITLNode = useCallback((onInsert: ShortcutPopupInsertHandler) => { - return (payload: FormInputItem) => { - if (formInputs.some(input => input.output_variable_name === payload.output_variable_name)) - return + const handleInsertHITLNode = useCallback( + (onInsert: ShortcutPopupInsertHandler) => { + return (payload: FormInputItem) => { + if (formInputs.some((input) => input.output_variable_name === payload.output_variable_name)) + return - const newFormInputs = [...(formInputs || []), payload] - pendingFormInputsRef.current = { - value, - formInputs: newFormInputs, + const newFormInputs = [...(formInputs || []), payload] + pendingFormInputsRef.current = { + value, + formInputs: newFormInputs, + } + onInsert(INSERT_HITL_INPUT_BLOCK_COMMAND, { + variableName: payload.output_variable_name, + nodeId, + formInputs: newFormInputs, + onFormInputsChange, + onFormInputItemRename, + onFormInputItemRemove, + }) } - onInsert(INSERT_HITL_INPUT_BLOCK_COMMAND, { - variableName: payload.output_variable_name, - nodeId, - formInputs: newFormInputs, - onFormInputsChange, - onFormInputItemRename, - onFormInputItemRemove, - }) - } - }, [ - formInputs, - nodeId, - onFormInputsChange, - onFormInputItemRemove, - onFormInputItemRename, - value, - ]) + }, + [formInputs, nodeId, onFormInputsChange, onFormInputItemRemove, onFormInputItemRename, value], + ) // avoid update formInputs would overwrite the value just inserted useEffect(() => { const pendingFormInputs = pendingFormInputsRef.current - if (!pendingFormInputs || pendingFormInputs.value === value) - return + if (!pendingFormInputs || pendingFormInputs.value === value) return onFormInputsChange(pendingFormInputs.formInputs) pendingFormInputsRef.current = null }, [onFormInputsChange, value]) - const [isFocus, { - setTrue: setFocus, - setFalse: setBlur, - }] = useBoolean(false) + const [isFocus, { setTrue: setFocus, setFalse: setBlur }] = useBoolean(false) const workflowNodesMap = availableNodes.reduce<WorkflowNodesMap>((acc, node) => { acc[node.id] = { @@ -107,14 +99,14 @@ const FormContent: FC<FormContentProps> = ({ } if (node.data.type === BlockEnum.Start) { acc.sys = { - title: t($ => $['blocks.start'], { ns: 'workflow' }), + title: t(($) => $['blocks.start'], { ns: 'workflow' }), type: BlockEnum.Start, } } return acc }, {}) const unavailableVariableNames = useMemo(() => { - return formInputs.map(input => input.output_variable_name) + return formInputs.map((input) => input.output_variable_name) }, [formInputs]) const addInputFieldConfigRef = useRef({ nodeId, @@ -127,34 +119,35 @@ const FormContent: FC<FormContentProps> = ({ handleInsertHITLNode, } const shortcutPopups = useMemo(() => { - if (readonly) - return [] + if (readonly) return [] - return [{ - hotkey: ['mod', '/'], - displayMode: 'workflow-panel-adjacent-center' as const, - // Keep this component type stable while the popup is open; it reads fresh props from a ref. - // eslint-disable-next-line react/no-nested-component-definitions - Popup: ({ onClose, onInsert }: { - onClose: () => void - onInsert: ShortcutPopupInsertHandler - }) => { - const { - nodeId, - unavailableVariableNames, - handleInsertHITLNode, - } = addInputFieldConfigRef.current + return [ + { + hotkey: ['mod', '/'], + displayMode: 'workflow-panel-adjacent-center' as const, + // Keep this component type stable while the popup is open; it reads fresh props from a ref. + // eslint-disable-next-line react/no-nested-component-definitions + Popup: ({ + onClose, + onInsert, + }: { + onClose: () => void + onInsert: ShortcutPopupInsertHandler + }) => { + const { nodeId, unavailableVariableNames, handleInsertHITLNode } = + addInputFieldConfigRef.current - return ( - <AddInputField - nodeId={nodeId} - unavailableVariableNames={unavailableVariableNames} - onSave={handleInsertHITLNode(onInsert)} - onCancel={onClose} - /> - ) + return ( + <AddInputField + nodeId={nodeId} + unavailableVariableNames={unavailableVariableNames} + onSave={handleInsertHITLNode(onInsert)} + onCancel={onClose} + /> + ) + }, }, - }] + ] }, [readonly]) return ( @@ -174,7 +167,7 @@ const FormContent: FC<FormContentProps> = ({ className={cn('min-h-[80px]', isExpand && 'h-full')} onFocus={setFocus} onBlur={setBlur} - placeholder={t($ => $['nodes.humanInput.formContent.placeholder'], { ns: 'workflow' })} + placeholder={t(($) => $['nodes.humanInput.formContent.placeholder'], { ns: 'workflow' })} hitlInputBlock={{ show: true, formInputs, @@ -200,14 +193,14 @@ const FormContent: FC<FormContentProps> = ({ {isFocus && ( <div className="flex h-8 shrink-0 items-center px-3 system-xs-regular text-components-input-text-placeholder"> <Trans - i18nKey={$ => $['nodes.humanInput.formContent.hotkeyTip']} + i18nKey={($) => $['nodes.humanInput.formContent.hotkeyTip']} ns="workflow" - components={ - { - Key: <Kbd className="mx-0.5 text-text-placeholder">/</Kbd>, - CtrlKey: <Kbd className="mx-0.5 text-text-placeholder">{formatForDisplay('Mod')}</Kbd>, - } - } + components={{ + Key: <Kbd className="mx-0.5 text-text-placeholder">/</Kbd>, + CtrlKey: ( + <Kbd className="mx-0.5 text-text-placeholder">{formatForDisplay('Mod')}</Kbd> + ), + }} /> </div> )} diff --git a/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx index efdc4e053a53af..7fc1e94049c264 100644 --- a/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx +++ b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx @@ -5,28 +5,33 @@ import type { UserAction } from '@/app/components/workflow/nodes/human-input/typ import type { HumanInputFormData } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { RiArrowLeftLine } from '@remixicon/react' - import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item' -import { getButtonStyle, getRenderedFormInputs, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils' +import { + getButtonStyle, + getRenderedFormInputs, + hasInvalidSelectOrFileInput, + initializeInputs, + splitByOutputVar, +} from '@/app/components/base/chat/chat/answer/human-input-content/utils' type Props = Readonly<{ nodeName: string data: HumanInputFormData showBackButton?: boolean handleBack?: () => void - onSubmit?: ({ inputs, action }: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void> + onSubmit?: ({ + inputs, + action, + }: { + inputs: Record<string, HumanInputFieldValue> + action: string + }) => Promise<void> }> -const FormContent = ({ - nodeName, - data, - showBackButton, - handleBack, - onSubmit, -}: Props) => { +const FormContent = ({ nodeName, data, showBackButton, handleBack, onSubmit }: Props) => { const { t } = useTranslation() const contentList = splitByOutputVar(data.form_content) const renderedFormInputs = getRenderedFormInputs(data.inputs, data.form_content) @@ -35,7 +40,7 @@ const FormContent = ({ const [isSubmitting, setIsSubmitting] = useState(false) const handleInputsChange = (name: string, value: HumanInputFieldValue) => { - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, [name]: value, })) @@ -59,7 +64,7 @@ const FormContent = ({ onClick={handleBack} > <RiArrowLeftLine className="mr-1 size-4" aria-hidden /> - {t($ => $['nodes.humanInput.singleRun.back'], { ns: 'workflow' })} + {t(($) => $['nodes.humanInput.singleRun.back'], { ns: 'workflow' })} </button> <div className="mx-1 system-xs-regular text-divider-deep">/</div> <div className="system-sm-semibold-uppercase text-text-secondary">{nodeName}</div> diff --git a/web/app/components/workflow/nodes/human-input/components/timeout.tsx b/web/app/components/workflow/nodes/human-input/components/timeout.tsx index 0f11ebfffe831b..758b28ad6a027d 100644 --- a/web/app/components/workflow/nodes/human-input/components/timeout.tsx +++ b/web/app/components/workflow/nodes/human-input/components/timeout.tsx @@ -9,24 +9,17 @@ const i18nPrefix = 'nodes.humanInput' type Props = Readonly<{ timeout: number unit: 'day' | 'hour' - onChange: (state: { timeout: number, unit: 'day' | 'hour' }) => void + onChange: (state: { timeout: number; unit: 'day' | 'hour' }) => void readonly?: boolean }> -const TimeoutInput: FC<Props> = ({ - timeout, - unit, - onChange, - readonly, -}) => { +const TimeoutInput: FC<Props> = ({ timeout, unit, onChange, readonly }) => { const { t } = useTranslation() const handleValueChange = (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value - if (/^\d*$/.test(value)) - onChange({ timeout: Number(value) || 1, unit }) - else - onChange({ timeout: 1, unit }) + if (/^\d*$/.test(value)) onChange({ timeout: Number(value) || 1, unit }) + else onChange({ timeout: 1, unit }) } return ( <div className="flex items-center gap-1"> @@ -43,23 +36,33 @@ const TimeoutInput: FC<Props> = ({ className={cn( 'rounded-lg px-2 py-1 text-text-tertiary', !readonly && 'cursor-pointer hover:bg-state-base-hover hover:text-text-secondary', - unit === 'day' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm', - !readonly && unit === 'day' && 'hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only', + unit === 'day' && + 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm', + !readonly && + unit === 'day' && + 'hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only', )} onClick={() => !readonly && onChange({ timeout, unit: 'day' })} > - <div className="p-0.5 system-sm-medium">{t($ => $[`${i18nPrefix}.timeout.days`], { ns: 'workflow' })}</div> + <div className="p-0.5 system-sm-medium"> + {t(($) => $[`${i18nPrefix}.timeout.days`], { ns: 'workflow' })} + </div> </div> <div className={cn( 'rounded-lg px-2 py-1 text-text-tertiary', !readonly && 'cursor-pointer hover:bg-state-base-hover hover:text-text-secondary', - unit === 'hour' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm', - !readonly && unit === 'hour' && 'hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only', + unit === 'hour' && + 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm', + !readonly && + unit === 'hour' && + 'hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only', )} onClick={() => !readonly && onChange({ timeout, unit: 'hour' })} > - <div className="p-0.5 system-sm-medium">{t($ => $[`${i18nPrefix}.timeout.hours`], { ns: 'workflow' })}</div> + <div className="p-0.5 system-sm-medium"> + {t(($) => $[`${i18nPrefix}.timeout.hours`], { ns: 'workflow' })} + </div> </div> </div> </div> diff --git a/web/app/components/workflow/nodes/human-input/components/user-action.tsx b/web/app/components/workflow/nodes/human-input/components/user-action.tsx index b5019656d4eb85..5e76e9e9152bdf 100644 --- a/web/app/components/workflow/nodes/human-input/components/user-action.tsx +++ b/web/app/components/workflow/nodes/human-input/components/user-action.tsx @@ -2,9 +2,7 @@ import type { FC } from 'react' import type { UserAction } from '../types' import { Button } from '@langgenius/dify-ui/button' import { toast } from '@langgenius/dify-ui/toast' -import { - RiDeleteBinLine, -} from '@remixicon/react' +import { RiDeleteBinLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' @@ -21,12 +19,7 @@ type UserActionItemProps = { readonly?: boolean } -const UserActionItem: FC<UserActionItemProps> = ({ - data, - onChange, - onDelete, - readonly, -}) => { +const UserActionItem: FC<UserActionItemProps> = ({ data, onChange, onDelete, readonly }) => { const { t } = useTranslation() const handleIDChange = (e: React.ChangeEvent<HTMLInputElement>) => { @@ -40,32 +33,40 @@ const UserActionItem: FC<UserActionItemProps> = ({ let sanitized = withUnderscores .split('') .filter((char, index) => { - if (index === 0) - return /^[a-z_]$/i.test(char) + if (index === 0) return /^[a-z_]$/i.test(char) return /^\w$/.test(char) }) .join('') if (sanitized !== withUnderscores) { - toast.error(t($ => $[`${i18nPrefix}.userActions.actionIdFormatTip`], { ns: 'workflow' })) + toast.error(t(($) => $[`${i18nPrefix}.userActions.actionIdFormatTip`], { ns: 'workflow' })) return } // Limit to 20 characters if (sanitized.length > ACTION_ID_MAX_LENGTH) { sanitized = sanitized.slice(0, ACTION_ID_MAX_LENGTH) - toast.error(t($ => $[`${i18nPrefix}.userActions.actionIdTooLong`], { ns: 'workflow', maxLength: ACTION_ID_MAX_LENGTH })) + toast.error( + t(($) => $[`${i18nPrefix}.userActions.actionIdTooLong`], { + ns: 'workflow', + maxLength: ACTION_ID_MAX_LENGTH, + }), + ) } - if (sanitized) - onChange({ ...data, id: sanitized }) + if (sanitized) onChange({ ...data, id: sanitized }) } const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => { let value = e.target.value if (value.length > ACTION_VALUE_MAX_LENGTH) { value = value.slice(0, ACTION_VALUE_MAX_LENGTH) - toast.error(t($ => $[`${i18nPrefix}.userActions.buttonTextTooLong`], { ns: 'workflow', maxLength: ACTION_VALUE_MAX_LENGTH })) + toast.error( + t(($) => $[`${i18nPrefix}.userActions.buttonTextTooLong`], { + ns: 'workflow', + maxLength: ACTION_VALUE_MAX_LENGTH, + }), + ) } onChange({ ...data, title: value }) } @@ -76,7 +77,9 @@ const UserActionItem: FC<UserActionItemProps> = ({ <Input wrapperClassName="w-[120px]" value={data.id} - placeholder={t($ => $[`${i18nPrefix}.userActions.actionNamePlaceholder`], { ns: 'workflow' })} + placeholder={t(($) => $[`${i18nPrefix}.userActions.actionNamePlaceholder`], { + ns: 'workflow', + })} onChange={handleIDChange} disabled={readonly} /> @@ -84,7 +87,9 @@ const UserActionItem: FC<UserActionItemProps> = ({ <div className="grow"> <Input value={data.title} - placeholder={t($ => $[`${i18nPrefix}.userActions.buttonTextPlaceholder`], { ns: 'workflow' })} + placeholder={t(($) => $[`${i18nPrefix}.userActions.buttonTextPlaceholder`], { + ns: 'workflow', + })} onChange={handleTextChange} disabled={readonly} /> @@ -92,15 +97,11 @@ const UserActionItem: FC<UserActionItemProps> = ({ <ButtonStyleDropdown text={data.title} data={data.button_style} - onChange={type => onChange({ ...data, button_style: type })} + onChange={(type) => onChange({ ...data, button_style: type })} readonly={readonly} /> {!readonly && ( - <Button - className="px-2" - variant="tertiary" - onClick={() => onDelete(data.id)} - > + <Button className="px-2" variant="tertiary" onClick={() => onDelete(data.id)}> <RiDeleteBinLine className="size-4" /> </Button> )} diff --git a/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx b/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx index 625f3e9f4207ea..c078aa3a6f00fb 100644 --- a/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx +++ b/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx @@ -2,7 +2,14 @@ import type { TFunction } from 'i18next' import type { FormInputItem } from '../types' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import * as React from 'react' import { useTranslation } from 'react-i18next' import { TransferMethod } from '@/types/app' @@ -49,11 +56,9 @@ const splitTextNode = ( match = regex.exec(value) } - if (!parts.length) - return parts + if (!parts.length) return parts - if (lastIndex < value.length) - parts.push({ type: 'text', value: value.slice(lastIndex) }) + if (lastIndex < value.length) parts.push({ type: 'text', value: value.slice(lastIndex) }) return parts } @@ -62,8 +67,7 @@ const visitTextNodes = ( node: MarkdownNode, transform: (value: string, parent: MarkdownNode) => MarkdownNode[] | null, ) => { - if (!node.children) - return + if (!node.children) return let index = 0 while (index < node.children.length) { @@ -89,17 +93,14 @@ const replaceNodeIdsWithNames = (path: string, nodeName: (nodeId: string) => str } const formatVariablePath = (path: string) => { - return path.replaceAll('.', '/') - .replace('{{#', '{{') - .replace('#}}', '}}') + return path.replaceAll('.', '/').replace('{{#', '{{').replace('#}}', '}}') } const sourceToVariablePath = ( source: { selector: string[] }, nodeName: (nodeId: string) => string, ) => { - if (!source.selector.length) - return '' + if (!source.selector.length) return '' const path = `{{#${source.selector.join('.')}#}}` return replaceNodeIdsWithNames(path, nodeName) @@ -110,11 +111,10 @@ export function rehypeVariable() { visitTextNodes(tree, (value) => { variableRegex.lastIndex = 0 noteRegex.lastIndex = 0 - if (!variableRegex.test(value) || noteRegex.test(value)) - return null + if (!variableRegex.test(value) || noteRegex.test(value)) return null variableRegex.lastIndex = 0 - return splitTextNode(value, variableRegex, match => ({ + return splitTextNode(value, variableRegex, (match) => ({ tagName: 'variable', properties: { dataPath: match[0].trim() }, })) @@ -126,8 +126,7 @@ export function rehypeNotes() { return (tree: MarkdownNode) => { visitTextNodes(tree, (value, parent) => { noteRegex.lastIndex = 0 - if (!noteRegex.test(value)) - return null + if (!noteRegex.test(value)) return null noteRegex.lastIndex = 0 parent.tagName = 'div' @@ -143,24 +142,24 @@ export function rehypeNotes() { } export const Variable: React.FC<{ path: string }> = ({ path }) => { - return ( - <span className="text-text-accent"> - {formatVariablePath(path)} - </span> - ) + return <span className="text-text-accent">{formatVariablePath(path)}</span> } -const SelectPreview: React.FC<{ label: string, options: string[] }> = ({ label, options }) => { +const SelectPreview: React.FC<{ label: string; options: string[] }> = ({ label, options }) => { const [value, setValue] = React.useState(options[0] || label) return ( <div data-testid="human-input-note-select-preview" className="my-3"> - <Select value={value} onValueChange={nextValue => nextValue && setValue(nextValue)}> - <SelectTrigger size="large" className="w-full rounded-[10px]" aria-label="human-input-note-select"> + <Select value={value} onValueChange={(nextValue) => nextValue && setValue(nextValue)}> + <SelectTrigger + size="large" + className="w-full rounded-[10px]" + aria-label="human-input-note-select" + > {value} </SelectTrigger> <SelectContent listClassName="max-h-[140px] overflow-y-auto"> - {options.map(option => ( + {options.map((option) => ( <SelectItem key={option} value={option}> <SelectItemText>{option}</SelectItemText> <SelectItemIndicator /> @@ -172,45 +171,46 @@ const SelectPreview: React.FC<{ label: string, options: string[] }> = ({ label, ) } -const FileUploadPreview: React.FC<{ methods: TransferMethod[], t: TFunction }> = ({ methods, t }) => { +const FileUploadPreview: React.FC<{ methods: TransferMethod[]; t: TFunction }> = ({ + methods, + t, +}) => { const normalizedMethods = methods.length ? methods : [TransferMethod.local_file, TransferMethod.remote_url] const actions = [ normalizedMethods.includes(TransferMethod.local_file) && { iconClassName: 'i-ri-upload-cloud-2-line', - label: t($ => $['fileUploader.uploadFromComputer'], { ns: 'common' }), + label: t(($) => $['fileUploader.uploadFromComputer'], { ns: 'common' }), }, normalizedMethods.includes(TransferMethod.remote_url) && { iconClassName: 'i-ri-link', - label: t($ => $['fileUploader.pasteFileLink'], { ns: 'common' }), + label: t(($) => $['fileUploader.pasteFileLink'], { ns: 'common' }), }, - ].filter(Boolean) as Array<{ iconClassName: string, label: string }> + ].filter(Boolean) as Array<{ iconClassName: string; label: string }> return ( <div data-testid="human-input-note-file-preview" - className={cn( - 'my-3 grid gap-2', - actions.length > 1 ? 'grid-cols-2' : 'grid-cols-1', - )} + className={cn('my-3 grid gap-2', actions.length > 1 ? 'grid-cols-2' : 'grid-cols-1')} > - {actions.map(action => ( + {actions.map((action) => ( <div key={action.label} className="flex h-10 items-center justify-center rounded-xl bg-components-input-bg-normal px-3" > <span className={cn('mr-2 size-5 shrink-0 text-text-tertiary', action.iconClassName)} /> - <span className="truncate system-sm-medium text-text-tertiary"> - {action.label} - </span> + <span className="truncate system-sm-medium text-text-tertiary">{action.label}</span> </div> ))} </div> ) } -export const Note: React.FC<{ input: FormInputItem, nodeName: (nodeId: string) => string }> = ({ input, nodeName }) => { +export const Note: React.FC<{ input: FormInputItem; nodeName: (nodeId: string) => string }> = ({ + input, + nodeName, +}) => { const { t } = useTranslation() if (isSelectFormInput(input)) { const isVariable = input.option_source.type === 'variable' @@ -218,14 +218,19 @@ export const Note: React.FC<{ input: FormInputItem, nodeName: (nodeId: string) = const variablePath = sourceToVariablePath(input.option_source, nodeName) return ( <div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2"> - {variablePath - ? <Variable path={variablePath} /> - : <span>{t($ => $['nodes.humanInput.insertInputField.variable'], { ns: 'workflow' })}</span>} + {variablePath ? ( + <Variable path={variablePath} /> + ) : ( + <span> + {t(($) => $['nodes.humanInput.insertInputField.variable'], { ns: 'workflow' })} + </span> + )} </div> ) } - const label = input.option_source.value[0] || t($ => $['variableConfig.select'], { ns: 'appDebug' }) + const label = + input.option_source.value[0] || t(($) => $['variableConfig.select'], { ns: 'appDebug' }) return <SelectPreview label={label} options={input.option_source.value} /> } diff --git a/web/app/components/workflow/nodes/human-input/default.ts b/web/app/components/workflow/nodes/human-input/default.ts index e6d0c03278ae95..27f075b2baa094 100644 --- a/web/app/components/workflow/nodes/human-input/default.ts +++ b/web/app/components/workflow/nodes/human-input/default.ts @@ -15,11 +15,9 @@ const metaData = genNodeMetaData({ }) const getFormInputVarType = (input: FormInputItem): VarType => { - if (input.type === 'file') - return VarType.file + if (input.type === 'file') return VarType.file - if (input.type === 'file-list') - return VarType.arrayFile + if (input.type === 'file-list') return VarType.arrayFile return VarType.string } @@ -34,23 +32,22 @@ const buildOutputVars = (inputs: FormInputItem[]): Var[] => { } const isEmailConfigComplete = (config?: EmailConfig): boolean => { - if (!config) - return false + if (!config) return false - if (!config.subject?.trim() || !config.body?.trim()) - return false + if (!config.subject?.trim() || !config.body?.trim()) return false - if (!/\{\{#url#\}\}/.test(config.body.trim())) - return false + if (!/\{\{#url#\}\}/.test(config.body.trim())) return false return !!config.recipients?.whole_workspace || !!config.recipients?.items?.length } const hasIncompleteEnabledEmailConfig = (deliveryMethods: DeliveryMethod[]): boolean => { return deliveryMethods.some((method) => { - return method.enabled - && method.type === DeliveryMethodType.Email - && !isEmailConfigComplete(method.config) + return ( + method.enabled && + method.type === DeliveryMethodType.Email && + !isEmailConfigComplete(method.config) + ) }) } @@ -67,34 +64,37 @@ const nodeDefault: NodeDefault<HumanInputNodeType> = { checkValid(payload: HumanInputNodeType, t: TFunction<'workflow'>) { let errorMessages = '' if (!errorMessages && !payload.delivery_methods.length) - errorMessages = t($ => $[`${i18nPrefix}.noDeliveryMethod`], { ns: 'workflow' }) + errorMessages = t(($) => $[`${i18nPrefix}.noDeliveryMethod`], { ns: 'workflow' }) - if (!errorMessages && payload.delivery_methods.length > 0 && !payload.delivery_methods.some(method => method.enabled)) - errorMessages = t($ => $[`${i18nPrefix}.noDeliveryMethodEnabled`], { ns: 'workflow' }) + if ( + !errorMessages && + payload.delivery_methods.length > 0 && + !payload.delivery_methods.some((method) => method.enabled) + ) + errorMessages = t(($) => $[`${i18nPrefix}.noDeliveryMethodEnabled`], { ns: 'workflow' }) if (!errorMessages && hasIncompleteEnabledEmailConfig(payload.delivery_methods)) - errorMessages = t($ => $[`${i18nPrefix}.emailConfigIncomplete`], { ns: 'workflow' }) + errorMessages = t(($) => $[`${i18nPrefix}.emailConfigIncomplete`], { ns: 'workflow' }) if (!errorMessages && !payload.user_actions.length) - errorMessages = t($ => $[`${i18nPrefix}.noUserActions`], { ns: 'workflow' }) + errorMessages = t(($) => $[`${i18nPrefix}.noUserActions`], { ns: 'workflow' }) if (!errorMessages && payload.user_actions.length > 0) { - const actionIds = payload.user_actions.map(action => action.id) + const actionIds = payload.user_actions.map((action) => action.id) const hasDuplicateIds = actionIds.length !== new Set(actionIds).size if (hasDuplicateIds) - errorMessages = t($ => $[`${i18nPrefix}.duplicateActionId`], { ns: 'workflow' }) + errorMessages = t(($) => $[`${i18nPrefix}.duplicateActionId`], { ns: 'workflow' }) } if (!errorMessages && payload.user_actions.length > 0) { - const hasEmptyId = payload.user_actions.some(action => !action.id?.trim()) - if (hasEmptyId) - errorMessages = t($ => $[`${i18nPrefix}.emptyActionId`], { ns: 'workflow' }) + const hasEmptyId = payload.user_actions.some((action) => !action.id?.trim()) + if (hasEmptyId) errorMessages = t(($) => $[`${i18nPrefix}.emptyActionId`], { ns: 'workflow' }) } if (!errorMessages && payload.user_actions.length > 0) { - const hasEmptyTitle = payload.user_actions.some(action => !action.title?.trim()) + const hasEmptyTitle = payload.user_actions.some((action) => !action.title?.trim()) if (hasEmptyTitle) - errorMessages = t($ => $[`${i18nPrefix}.emptyActionTitle`], { ns: 'workflow' }) + errorMessages = t(($) => $[`${i18nPrefix}.emptyActionTitle`], { ns: 'workflow' }) } return { diff --git a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts index ce9bdfc295a796..075136860bc409 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts @@ -35,18 +35,22 @@ const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputN title: 'Human Input', desc: '', type: BlockEnum.HumanInput, - delivery_methods: [{ - id: 'webapp', - type: 'webapp', - enabled: true, - } as DeliveryMethod], + delivery_methods: [ + { + id: 'webapp', + type: 'webapp', + enabled: true, + } as DeliveryMethod, + ], form_content: 'Body', inputs: [], - user_actions: [{ - id: 'approve', - title: 'Approve', - button_style: 'primary', - } as UserAction], + user_actions: [ + { + id: 'approve', + title: 'Approve', + button_style: 'primary', + } as UserAction, + ], timeout: 3, timeout_unit: 'day', ...overrides, @@ -84,11 +88,13 @@ describe('human-input/hooks/use-config', () => { it('should expose form-content helpers and update delivery methods, timeout, and collapsed state', () => { const { result } = renderHook(() => useConfig('human-input-node', currentInputs)) - const methods = [{ - id: 'email', - type: 'email', - enabled: true, - } as DeliveryMethod] + const methods = [ + { + id: 'email', + type: 'email', + enabled: true, + } as DeliveryMethod, + ] expect(result.current.editorKey).toBe(3) expect(result.current.readOnly).toBe(false) @@ -100,13 +106,19 @@ describe('human-input/hooks/use-config', () => { result.current.setStructuredOutputCollapsed(false) }) - expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({ - delivery_methods: methods, - })) - expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({ - timeout: 12, - timeout_unit: 'hour', - })) + expect(mockSetInputs).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + delivery_methods: methods, + }), + ) + expect(mockSetInputs).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + timeout: 12, + timeout_unit: 'hour', + }), + ) expect(result.current.structuredOutputCollapsed).toBe(false) }) @@ -123,15 +135,18 @@ describe('human-input/hooks/use-config', () => { result.current.handleUserActionDelete('approve') }) - expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({ - user_actions: [ - expect.objectContaining({ id: 'approve' }), - newAction, - ], - })) - expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({ - user_actions: [], - })) + expect(mockSetInputs).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + user_actions: [expect.objectContaining({ id: 'approve' }), newAction], + }), + ) + expect(mockSetInputs).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + user_actions: [], + }), + ) expect(mockHandleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('human-input-node', 'approve') }) @@ -147,10 +162,16 @@ describe('human-input/hooks/use-config', () => { result.current.handleUserActionChange(0, renamedAction) }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - user_actions: [renamedAction], - })) - expect(mockHandleEdgeSourceHandleChange).toHaveBeenCalledWith('human-input-node', 'approve', 'approved') + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + user_actions: [renamedAction], + }), + ) + expect(mockHandleEdgeSourceHandleChange).toHaveBeenCalledWith( + 'human-input-node', + 'approve', + 'approved', + ) expect(mockUpdateNodeInternals).toHaveBeenCalledWith('human-input-node') }) }) diff --git a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts index f31c24ae613074..7478b789aac9c2 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts @@ -69,12 +69,18 @@ describe('human-input/use-form-content', () => { result.current.handleFormInputsChange(nextInputs) }) - expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({ - form_content: 'Updated body', - })) - expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({ - inputs: nextInputs, - })) + expect(mockSetInputs).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + form_content: 'Updated body', + }), + ) + expect(mockSetInputs).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + inputs: nextInputs, + }), + ) expect(result.current.editorKey).toBe(1) }) @@ -88,27 +94,33 @@ describe('human-input/use-form-content', () => { result.current.handleFormInputItemRename(renamedInput, 'old_name') }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - form_content: 'Hello {{#$output.new_name#}}', - inputs: [renamedInput], - })) - expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith('human-input-node', ['human-input-node', 'old_name'], ['human-input-node', 'new_name']) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + form_content: 'Hello {{#$output.new_name#}}', + inputs: [renamedInput], + }), + ) + expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith( + 'human-input-node', + ['human-input-node', 'old_name'], + ['human-input-node', 'new_name'], + ) expect(result.current.editorKey).toBe(1) }) it('should not rename an input to an existing variable name', () => { currentInputs = createPayload({ - inputs: [ - createFormInput(), - createFormInput({ output_variable_name: 'existing_name' }), - ], + inputs: [createFormInput(), createFormInput({ output_variable_name: 'existing_name' })], }) const { result } = renderHook(() => useFormContent('human-input-node', currentInputs)) act(() => { - result.current.handleFormInputItemRename(createFormInput({ - output_variable_name: 'existing_name', - }), 'old_name') + result.current.handleFormInputItemRename( + createFormInput({ + output_variable_name: 'existing_name', + }), + 'old_name', + ) }) expect(mockSetInputs).not.toHaveBeenCalled() @@ -123,10 +135,12 @@ describe('human-input/use-form-content', () => { result.current.handleFormInputItemRemove('old_name') }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - form_content: 'Hello ', - inputs: [], - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + form_content: 'Hello ', + inputs: [], + }), + ) expect(result.current.editorKey).toBe(1) }) }) diff --git a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts index 2c0dd652a5a52f..9df3b3eb498746 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts @@ -19,12 +19,15 @@ vi.mock('react-i18next', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { appDetail?: { id?: string, mode?: AppModeEnum } }) => unknown) => mockUseAppStore(selector), + useStore: (selector: (state: { appDetail?: { id?: string; mode?: AppModeEnum } }) => unknown) => + mockUseAppStore(selector), })) vi.mock('@/service/workflow', () => ({ - fetchHumanInputNodeStepRunForm: (...args: unknown[]) => mockFetchHumanInputNodeStepRunForm(...args), - submitHumanInputNodeStepRunForm: (...args: unknown[]) => mockSubmitHumanInputNodeStepRunForm(...args), + fetchHumanInputNodeStepRunForm: (...args: unknown[]) => + mockFetchHumanInputNodeStepRunForm(...args), + submitHumanInputNodeStepRunForm: (...args: unknown[]) => + mockSubmitHumanInputNodeStepRunForm(...args), })) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ @@ -38,15 +41,17 @@ const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputN type: BlockEnum.HumanInput, delivery_methods: [], form_content: 'Summary: {{#start.topic#}}', - inputs: [{ - type: InputVarType.paragraph, - output_variable_name: 'summary', - default: { - type: 'variable', - selector: ['start', 'topic'], - value: '', + inputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'summary', + default: { + type: 'variable', + selector: ['start', 'topic'], + value: '', + }, }, - }], + ], user_actions: [], timeout: 1, timeout_unit: 'day', @@ -81,7 +86,7 @@ describe('human-input/hooks/use-single-run-form-params', () => { const mockSetRunInputData = vi.fn() const getInputVars = vi.fn() let currentInputs = createPayload() - let appDetail: { id?: string, mode?: AppModeEnum } | undefined + let appDetail: { id?: string; mode?: AppModeEnum } | undefined beforeEach(() => { vi.clearAllMocks() @@ -94,7 +99,10 @@ describe('human-input/hooks/use-single-run-form-params', () => { mockUseTranslation.mockReturnValue({ t: withSelectorKey((key: string) => key), }) - mockUseAppStore.mockImplementation((selector: (state: { appDetail?: { id?: string, mode?: AppModeEnum } }) => unknown) => selector({ appDetail })) + mockUseAppStore.mockImplementation( + (selector: (state: { appDetail?: { id?: string; mode?: AppModeEnum } }) => unknown) => + selector({ appDetail }), + ) mockUseNodeCrud.mockImplementation(() => ({ inputs: currentInputs, })) @@ -139,36 +147,35 @@ describe('human-input/hooks/use-single-run-form-params', () => { } it('should build a single before-run form, filter output vars, and expose dependent vars', () => { - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'node-1', - payload: currentInputs, - runInputData: { topic: 'AI' }, - getInputVars, - setRunInputData: mockSetRunInputData, - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'node-1', + payload: currentInputs, + runInputData: { topic: 'AI' }, + getInputVars, + setRunInputData: mockSetRunInputData, + }), + ) - expect(getInputVars).toHaveBeenCalledWith([ - '{{#start.topic#}}', - 'Summary: {{#start.topic#}}', - ]) + expect(getInputVars).toHaveBeenCalledWith(['{{#start.topic#}}', 'Summary: {{#start.topic#}}']) expect(result.current.forms).toHaveLength(1) - expect(result.current.forms[0]).toEqual(expect.objectContaining({ - label: 'nodes.humanInput.singleRun.label', - values: { topic: 'AI' }, - inputs: [ - expect.objectContaining({ variable: '#start.topic#' }), - expect.objectContaining({ label: 'Broken' }), - ], - })) + expect(result.current.forms[0]).toEqual( + expect.objectContaining({ + label: 'nodes.humanInput.singleRun.label', + values: { topic: 'AI' }, + inputs: [ + expect.objectContaining({ variable: '#start.topic#' }), + expect.objectContaining({ label: 'Broken' }), + ], + }), + ) act(() => { result.current.forms[0]!.onChange?.({ topic: 'Updated' }) }) expect(mockSetRunInputData).toHaveBeenCalledWith({ topic: 'Updated' }) - expect(result.current.getDependentVars()).toEqual([ - ['start', 'topic'], - ]) + expect(result.current.getDependentVars()).toEqual([['start', 'topic']]) }) it('should include variables referenced by dynamic select option sources', () => { @@ -203,13 +210,15 @@ describe('human-input/hooks/use-single-run-form-params', () => { }), ]) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'node-1', - payload: currentInputs, - runInputData: {}, - getInputVars, - setRunInputData: mockSetRunInputData, - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'node-1', + payload: currentInputs, + runInputData: {}, + getInputVars, + setRunInputData: mockSetRunInputData, + }), + ) expect(getInputVars).toHaveBeenCalledWith([ '{{#start.topic#}}', @@ -258,13 +267,15 @@ describe('human-input/hooks/use-single-run-form-params', () => { } satisfies HumanInputFormData mockFetchHumanInputNodeStepRunForm.mockResolvedValue(formDataWithFiles) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'node-1', - payload: currentInputs, - runInputData: {}, - getInputVars, - setRunInputData: mockSetRunInputData, - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'node-1', + payload: currentInputs, + runInputData: {}, + getInputVars, + setRunInputData: mockSetRunInputData, + }), + ) await act(async () => { await result.current.handleShowGeneratedForm({ @@ -284,7 +295,11 @@ describe('human-input/hooks/use-single-run-form-params', () => { await act(async () => { await result.current.handleSubmitHumanInputForm({ - inputs: { answer: 'approved', attachment: uploadedFile, references: [uploadedFile, remoteFile] }, + inputs: { + answer: 'approved', + attachment: uploadedFile, + references: [uploadedFile, remoteFile], + }, form_inputs: { ignored: 'value' }, action: 'approve', }) @@ -334,13 +349,15 @@ describe('human-input/hooks/use-single-run-form-params', () => { mode: AppModeEnum.ADVANCED_CHAT, } - const { result, rerender } = renderHook(() => useSingleRunFormParams({ - id: 'node-9', - payload: currentInputs, - runInputData: {}, - getInputVars, - setRunInputData: mockSetRunInputData, - })) + const { result, rerender } = renderHook(() => + useSingleRunFormParams({ + id: 'node-9', + payload: currentInputs, + runInputData: {}, + getInputVars, + setRunInputData: mockSetRunInputData, + }), + ) await act(async () => { await result.current.handleFetchFormContent({ topic: 'hello' }) diff --git a/web/app/components/workflow/nodes/human-input/hooks/use-config.ts b/web/app/components/workflow/nodes/human-input/hooks/use-config.ts index dc87cadf1e1a74..13fd18eae53374 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/use-config.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/use-config.ts @@ -2,9 +2,7 @@ import type { DeliveryMethod, HumanInputNodeType, UserAction } from '../types' import { produce } from 'immer' import { useState } from 'react' import { useUpdateNodeInternals } from 'reactflow' -import { - useNodesReadOnly, -} from '@/app/components/workflow/hooks' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' import { useEdgesInteractions } from '@/app/components/workflow/hooks/use-edges-interactions' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import useFormContent from './use-form-content' @@ -33,8 +31,7 @@ const useConfig = (id: string, payload: HumanInputNodeType) => { const handleUserActionChange = (index: number, updatedAction: UserAction) => { const newActions = produce(inputs.user_actions, (draft) => { - if (draft[index]) - draft[index] = updatedAction + if (draft[index]) draft[index] = updatedAction }) setInputs({ ...inputs, @@ -51,7 +48,7 @@ const useConfig = (id: string, payload: HumanInputNodeType) => { } const handleUserActionDelete = (actionId: string) => { - const newActions = inputs.user_actions.filter(action => action.id !== actionId) + const newActions = inputs.user_actions.filter((action) => action.id !== actionId) setInputs({ ...inputs, user_actions: newActions, @@ -60,7 +57,7 @@ const useConfig = (id: string, payload: HumanInputNodeType) => { handleEdgeDeleteByDeleteBranch(id, actionId) } - const handleTimeoutChange = ({ timeout, unit }: { timeout: number, unit: 'hour' | 'day' }) => { + const handleTimeoutChange = ({ timeout, unit }: { timeout: number; unit: 'hour' | 'day' }) => { setInputs({ ...inputs, timeout, diff --git a/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts b/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts index 476c814c16bad0..8317285473a520 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts @@ -12,53 +12,72 @@ const useFormContent = (id: string, payload: HumanInputNodeType) => { useEffect(() => { inputsRef.current = inputs }, [inputs]) - const handleFormContentChange = useCallback((value: string) => { - setInputs({ - ...inputs, - form_content: value, - }) - }, [inputs, setInputs]) + const handleFormContentChange = useCallback( + (value: string) => { + setInputs({ + ...inputs, + form_content: value, + }) + }, + [inputs, setInputs], + ) - const handleFormInputsChange = useCallback((formInputs: FormInputItem[]) => { - setInputs({ - ...inputs, - inputs: formInputs, - }) - setEditorKey(editorKey => editorKey + 1) - }, [inputs, setInputs]) + const handleFormInputsChange = useCallback( + (formInputs: FormInputItem[]) => { + setInputs({ + ...inputs, + inputs: formInputs, + }) + setEditorKey((editorKey) => editorKey + 1) + }, + [inputs, setInputs], + ) - const handleFormInputItemRename = useCallback((payload: FormInputItem, oldName: string) => { - const inputs = inputsRef.current - if ( - oldName !== payload.output_variable_name - && inputs.inputs.some(item => item.output_variable_name === payload.output_variable_name) - ) { - return - } + const handleFormInputItemRename = useCallback( + (payload: FormInputItem, oldName: string) => { + const inputs = inputsRef.current + if ( + oldName !== payload.output_variable_name && + inputs.inputs.some((item) => item.output_variable_name === payload.output_variable_name) + ) { + return + } - const newInputs = produce(inputs, (draft) => { - draft.form_content = draft.form_content.replaceAll(`{{#$output.${oldName}#}}`, `{{#$output.${payload.output_variable_name}#}}`) - draft.inputs = draft.inputs.map(item => item.output_variable_name === oldName ? payload : item) - if (!draft.inputs.find(item => item.output_variable_name === payload.output_variable_name)) - draft.inputs = [...draft.inputs, payload] - }) - setInputs(newInputs) - setEditorKey(editorKey => editorKey + 1) + const newInputs = produce(inputs, (draft) => { + draft.form_content = draft.form_content.replaceAll( + `{{#$output.${oldName}#}}`, + `{{#$output.${payload.output_variable_name}#}}`, + ) + draft.inputs = draft.inputs.map((item) => + item.output_variable_name === oldName ? payload : item, + ) + if ( + !draft.inputs.find((item) => item.output_variable_name === payload.output_variable_name) + ) + draft.inputs = [...draft.inputs, payload] + }) + setInputs(newInputs) + setEditorKey((editorKey) => editorKey + 1) - // Update downstream nodes that reference this variable - if (oldName !== payload.output_variable_name) - handleOutVarRenameChange(id, [id, oldName], [id, payload.output_variable_name]) - }, [setInputs, handleOutVarRenameChange, id]) + // Update downstream nodes that reference this variable + if (oldName !== payload.output_variable_name) + handleOutVarRenameChange(id, [id, oldName], [id, payload.output_variable_name]) + }, + [setInputs, handleOutVarRenameChange, id], + ) - const handleFormInputItemRemove = useCallback((varName: string) => { - const inputs = inputsRef.current - const newInputs = produce(inputs, (draft) => { - draft.form_content = draft.form_content.replaceAll(`{{#$output.${varName}#}}`, '') - draft.inputs = draft.inputs.filter(item => item.output_variable_name !== varName) - }) - setInputs(newInputs) - setEditorKey(editorKey => editorKey + 1) - }, [setInputs]) + const handleFormInputItemRemove = useCallback( + (varName: string) => { + const inputs = inputsRef.current + const newInputs = produce(inputs, (draft) => { + draft.form_content = draft.form_content.replaceAll(`{{#$output.${varName}#}}`, '') + draft.inputs = draft.inputs.filter((item) => item.output_variable_name !== varName) + }) + setInputs(newInputs) + setEditorKey((editorKey) => editorKey + 1) + }, + [setInputs], + ) return { editorKey, diff --git a/web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts b/web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts index e954d16fea19cd..e508e5906d1257 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts @@ -35,75 +35,85 @@ const useSingleRunFormParams = ({ const [formData, setFormData] = useState<HumanInputFormData | null>(null) const [requiredInputs, setRequiredInputs] = useState<Record<string, string>>({}) const generatedInputs = useMemo(() => { - const formInputDependencyInputs = getHumanInputFormDependencySelectors(inputs.inputs) - .map(selector => `{{#${selector.join('.')}#}}`) - const allInputs = getInputVars([...formInputDependencyInputs, inputs.form_content || '']).filter(item => !isOutput(item.value_selector || [])) + const formInputDependencyInputs = getHumanInputFormDependencySelectors(inputs.inputs).map( + (selector) => `{{#${selector.join('.')}#}}`, + ) + const allInputs = getInputVars([ + ...formInputDependencyInputs, + inputs.form_content || '', + ]).filter((item) => !isOutput(item.value_selector || [])) return allInputs }, [getInputVars, inputs.form_content, inputs.inputs]) const forms = useMemo(() => { - const forms: FormProps[] = [{ - label: t($ => $[`${i18nPrefix}.singleRun.label`], { ns: 'workflow' })!, - inputs: generatedInputs, - values: runInputData, - onChange: setRunInputData, - }] + const forms: FormProps[] = [ + { + label: t(($) => $[`${i18nPrefix}.singleRun.label`], { ns: 'workflow' })!, + inputs: generatedInputs, + values: runInputData, + onChange: setRunInputData, + }, + ] return forms }, [t, generatedInputs, runInputData, setRunInputData]) const getDependentVars = () => { - return generatedInputs.map((item) => { - // Guard against null/undefined variable to prevent app crash - if (!item.variable || typeof item.variable !== 'string') - return [] + return generatedInputs + .map((item) => { + // Guard against null/undefined variable to prevent app crash + if (!item.variable || typeof item.variable !== 'string') return [] - return item.variable.slice(1, -1).split('.') - }).filter(arr => arr.length > 0) + return item.variable.slice(1, -1).split('.') + }) + .filter((arr) => arr.length > 0) } - const appDetail = useAppStore(s => s.appDetail) + const appDetail = useAppStore((s) => s.appDetail) const appId = appDetail?.id const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW const fetchURL = useMemo(() => { - if (!appId) - return '' + if (!appId) return '' if (!isWorkflowMode) { return `/apps/${appId}/advanced-chat/workflows/draft/human-input/nodes/${id}/form` - } - else { + } else { return `/apps/${appId}/workflows/draft/human-input/nodes/${id}/form` } }, [appId, id, isWorkflowMode]) - const handleFetchFormContent = useCallback(async (inputs: Record<string, string>) => { - if (!fetchURL) - return null - let requestParamsObj: Record<string, string> = {} - Object.keys(inputs).forEach((key) => { - if (inputs[key] === undefined) { - delete inputs[key] - } - }) - requestParamsObj = { ...inputs } - const data = await fetchHumanInputNodeStepRunForm(fetchURL, { inputs: requestParamsObj! }) - setFormData(data) - setRequiredInputs(requestParamsObj) - return data - }, [fetchURL]) + const handleFetchFormContent = useCallback( + async (inputs: Record<string, string>) => { + if (!fetchURL) return null + let requestParamsObj: Record<string, string> = {} + Object.keys(inputs).forEach((key) => { + if (inputs[key] === undefined) { + delete inputs[key] + } + }) + requestParamsObj = { ...inputs } + const data = await fetchHumanInputNodeStepRunForm(fetchURL, { inputs: requestParamsObj! }) + setFormData(data) + setRequiredInputs(requestParamsObj) + return data + }, + [fetchURL], + ) - const handleSubmitHumanInputForm = useCallback(async (submission: { - inputs: Record<string, HumanInputFieldValue> | undefined - form_inputs: Record<string, HumanInputFieldValue> | undefined - action: string - }) => { - const formInputs = formData?.inputs?.length ? formData.inputs : inputs.inputs + const handleSubmitHumanInputForm = useCallback( + async (submission: { + inputs: Record<string, HumanInputFieldValue> | undefined + form_inputs: Record<string, HumanInputFieldValue> | undefined + action: string + }) => { + const formInputs = formData?.inputs?.length ? formData.inputs : inputs.inputs - await submitHumanInputNodeStepRunForm(fetchURL, { - inputs: requiredInputs, - form_inputs: getProcessedHumanInputFormInputs(formInputs, submission.inputs), - action: submission.action, - }) - }, [fetchURL, formData?.inputs, inputs.inputs, requiredInputs]) + await submitHumanInputNodeStepRunForm(fetchURL, { + inputs: requiredInputs, + form_inputs: getProcessedHumanInputFormInputs(formInputs, submission.inputs), + action: submission.action, + }) + }, + [fetchURL, formData?.inputs, inputs.inputs, requiredInputs], + ) const handleShowGeneratedForm = async (formValue: Record<string, string>) => { setShowGeneratedForm(true) diff --git a/web/app/components/workflow/nodes/human-input/node.tsx b/web/app/components/workflow/nodes/human-input/node.tsx index e2d35428324c5f..a49a5112b05428 100644 --- a/web/app/components/workflow/nodes/human-input/node.tsx +++ b/web/app/components/workflow/nodes/human-input/node.tsx @@ -1,10 +1,7 @@ import type { FC } from 'react' import type { HumanInputNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' -import { - RiMailSendFill, - RiRobot2Fill, -} from '@remixicon/react' +import { RiMailSendFill, RiRobot2Fill } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import { NodeSourceHandle } from '../_base/components/node-handle' @@ -23,10 +20,15 @@ const Node: FC<NodeProps<HumanInputNodeType>> = (props) => { <> {deliveryMethods.length > 0 && ( <div className="space-y-0.5 py-1"> - <div className="px-2.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">{t($ => $[`${i18nPrefix}.deliveryMethod.title`], { ns: 'workflow' })}</div> + <div className="px-2.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.deliveryMethod.title`], { ns: 'workflow' })} + </div> <div className="space-y-0.5 px-2.5"> - {deliveryMethods.map(method => ( - <div key={method.type} className="flex items-center gap-1 rounded-md bg-workflow-block-parma-bg p-1"> + {deliveryMethods.map((method) => ( + <div + key={method.type} + className="flex items-center gap-1 rounded-md bg-workflow-block-parma-bg p-1" + > {method.type === DeliveryMethodType.WebApp && ( <div className="rounded-sm border border-divider-regular bg-components-icon-bg-indigo-solid p-0.5"> <RiRobot2Fill className="size-3.5 text-text-primary-on-surface" /> @@ -37,7 +39,9 @@ const Node: FC<NodeProps<HumanInputNodeType>> = (props) => { <RiMailSendFill className="size-3.5 text-text-primary-on-surface" /> </div> )} - <span className="system-xs-regular text-text-secondary capitalize">{method.type}</span> + <span className="system-xs-regular text-text-secondary capitalize"> + {method.type} + </span> </div> ))} </div> @@ -46,9 +50,14 @@ const Node: FC<NodeProps<HumanInputNodeType>> = (props) => { <div className="space-y-0.5 py-1"> {userActions.length > 0 && ( <> - {userActions.map(userAction => ( - <div key={userAction.id} className="relative flex flex-row-reverse items-center px-4 py-1"> - <span className="truncate system-xs-semibold-uppercase text-text-secondary">{userAction.id}</span> + {userActions.map((userAction) => ( + <div + key={userAction.id} + className="relative flex flex-row-reverse items-center px-4 py-1" + > + <span className="truncate system-xs-semibold-uppercase text-text-secondary"> + {userAction.id} + </span> <NodeSourceHandle {...props} handleId={userAction.id} diff --git a/web/app/components/workflow/nodes/human-input/panel.tsx b/web/app/components/workflow/nodes/human-input/panel.tsx index db27aabda27f49..79426136eef1ba 100644 --- a/web/app/components/workflow/nodes/human-input/panel.tsx +++ b/web/app/components/workflow/nodes/human-input/panel.tsx @@ -35,19 +35,14 @@ import { UserActionButtonType } from './types' const i18nPrefix = 'nodes.humanInput' const getOutputVarType = (input: FormInputItem): VarType => { - if (input.type === 'file') - return VarType.file + if (input.type === 'file') return VarType.file - if (input.type === 'file-list') - return VarType.arrayFile + if (input.type === 'file-list') return VarType.arrayFile return VarType.string } -const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { readOnly, @@ -69,19 +64,16 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({ const { availableVars, availableNodesWithParent } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar: (varPayload: Var) => { - return [VarType.string, VarType.number, VarType.secret, VarType.arrayString].includes(varPayload.type) + return [VarType.string, VarType.number, VarType.secret, VarType.arrayString].includes( + varPayload.type, + ) }, }) - const [isExpandFormContent, { - toggle: toggleExpandFormContent, - }] = useBoolean(false) - const nodePanelWidth = useStore(state => state.nodePanelWidth) + const [isExpandFormContent, { toggle: toggleExpandFormContent }] = useBoolean(false) + const nodePanelWidth = useStore((state) => state.nodePanelWidth) - const [isPreview, { - toggle: togglePreview, - setFalse: hidePreview, - }] = useBoolean(false) + const [isPreview, { toggle: togglePreview, setFalse: hidePreview }] = useBoolean(false) const onAddUseAction = useCallback(() => { const index = inputs.user_actions.length + 1 @@ -110,16 +102,24 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({ </div> {/* form content */} <div - className={cn('px-4 py-2', isExpandFormContent && 'fixed top-[244px] right-[4px] bottom-[8px] z-10 flex flex-col rounded-b-2xl bg-components-panel-bg')} + className={cn( + 'px-4 py-2', + isExpandFormContent && + 'fixed top-[244px] right-[4px] bottom-[8px] z-10 flex flex-col rounded-b-2xl bg-components-panel-bg', + )} style={{ width: isExpandFormContent ? nodePanelWidth : '100%', }} > <div className="mb-1 flex shrink-0 items-center justify-between"> <div className="flex h-6 items-center gap-0.5"> - <div className="system-sm-semibold-uppercase text-text-secondary">{t($ => $[`${i18nPrefix}.formContent.title`], { ns: 'workflow' })}</div> - <Infotip aria-label={t($ => $[`${i18nPrefix}.formContent.tooltip`], { ns: 'workflow' })}> - {t($ => $[`${i18nPrefix}.formContent.tooltip`], { ns: 'workflow' })} + <div className="system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $[`${i18nPrefix}.formContent.title`], { ns: 'workflow' })} + </div> + <Infotip + aria-label={t(($) => $[`${i18nPrefix}.formContent.tooltip`], { ns: 'workflow' })} + > + {t(($) => $[`${i18nPrefix}.formContent.tooltip`], { ns: 'workflow' })} </Infotip> </div> {!readOnly && ( @@ -134,28 +134,39 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({ onClick={togglePreview} > <RiEyeLine className="size-3.5" /> - <div className="system-xs-medium">{t($ => $[`${i18nPrefix}.formContent.preview`], { ns: 'workflow' })}</div> + <div className="system-xs-medium"> + {t(($) => $[`${i18nPrefix}.formContent.preview`], { ns: 'workflow' })} + </div> </Button> <div className="mx-2 h-3 w-px bg-divider-regular"></div> <div className="flex items-center space-x-1"> <button type="button" - aria-label={t($ => $['operation.copy'], { ns: 'common' })} + aria-label={t(($) => $['operation.copy'], { ns: 'common' })} className="flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 hover:bg-components-button-ghost-bg-hover" onClick={() => { copy(inputs.form_content) - toast.success(t($ => $['actionMsg.copySuccessfully'], { ns: 'common' })) + toast.success(t(($) => $['actionMsg.copySuccessfully'], { ns: 'common' })) }} > <RiClipboardLine className="size-4 text-text-secondary" aria-hidden /> </button> <button type="button" - aria-label={t($ => $[isExpandFormContent ? 'chat.collapse' : 'chat.expand'], { ns: 'share' })} - className={cn('flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-text-secondary hover:bg-components-button-ghost-bg-hover', isExpandFormContent && 'bg-state-accent-active text-text-accent')} + aria-label={t(($) => $[isExpandFormContent ? 'chat.collapse' : 'chat.expand'], { + ns: 'share', + })} + className={cn( + 'flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-text-secondary hover:bg-components-button-ghost-bg-hover', + isExpandFormContent && 'bg-state-accent-active text-text-accent', + )} onClick={toggleExpandFormContent} > - {isExpandFormContent ? <RiCollapseDiagonalLine className="size-4" aria-hidden /> : <RiExpandDiagonalLine className="size-4" aria-hidden />} + {isExpandFormContent ? ( + <RiCollapseDiagonalLine className="size-4" aria-hidden /> + ) : ( + <RiExpandDiagonalLine className="size-4" aria-hidden /> + )} </button> </div> </div> @@ -180,23 +191,27 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({ <div className="px-4 py-2"> <div className="mb-1 flex items-center justify-between"> <div className="flex items-center gap-0.5"> - <div className="system-sm-semibold-uppercase text-text-secondary">{t($ => $[`${i18nPrefix}.userActions.title`], { ns: 'workflow' })}</div> - <Infotip aria-label={t($ => $[`${i18nPrefix}.userActions.tooltip`], { ns: 'workflow' })}> - {t($ => $[`${i18nPrefix}.userActions.tooltip`], { ns: 'workflow' })} + <div className="system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $[`${i18nPrefix}.userActions.title`], { ns: 'workflow' })} + </div> + <Infotip + aria-label={t(($) => $[`${i18nPrefix}.userActions.tooltip`], { ns: 'workflow' })} + > + {t(($) => $[`${i18nPrefix}.userActions.tooltip`], { ns: 'workflow' })} </Infotip> </div> {!readOnly && ( <div className="flex items-center px-1"> - <ActionButton - onClick={onAddUseAction} - > + <ActionButton onClick={onAddUseAction}> <RiAddLine className="size-4" /> </ActionButton> </div> )} </div> {!inputs.user_actions.length && ( - <div className="flex items-center justify-center rounded-[10px] bg-background-section p-3 system-xs-regular text-text-tertiary">{t($ => $[`${i18nPrefix}.userActions.emptyTip`], { ns: 'workflow' })}</div> + <div className="flex items-center justify-center rounded-[10px] bg-background-section p-3 system-xs-regular text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.userActions.emptyTip`], { ns: 'workflow' })} + </div> )} {inputs.user_actions.length > 0 && ( <div className="space-y-2"> @@ -204,7 +219,7 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({ <UserActionItem key={index} data={action} - onChange={data => handleUserActionChange(index, data)} + onChange={(data) => handleUserActionChange(index, data)} onDelete={handleUserActionDelete} readonly={readOnly} /> @@ -217,7 +232,9 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({ </div> {/* timeout */} <div className="flex items-center justify-between px-4 py-2"> - <div className="system-sm-semibold-uppercase text-text-secondary">{t($ => $[`${i18nPrefix}.timeout.title`], { ns: 'workflow' })}</div> + <div className="system-sm-semibold-uppercase text-text-secondary"> + {t(($) => $[`${i18nPrefix}.timeout.title`], { ns: 'workflow' })} + </div> <TimeoutInput timeout={inputs.timeout} unit={inputs.timeout_unit} @@ -227,35 +244,18 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({ </div> {/* output vars */} <Split /> - <OutputVars - collapsed={structuredOutputCollapsed} - onCollapse={setStructuredOutputCollapsed} - > - { - inputs.inputs.map(input => ( - <VarItem - key={input.output_variable_name} - name={input.output_variable_name} - type={getOutputVarType(input)} - description="Form input value" - /> - )) - } - <VarItem - name="__action_id" - type="string" - description="Action ID user triggered" - /> - <VarItem - name="__action_value" - type="string" - description="Selected action value" - /> - <VarItem - name="__rendered_content" - type="string" - description="Rendered content" - /> + <OutputVars collapsed={structuredOutputCollapsed} onCollapse={setStructuredOutputCollapsed}> + {inputs.inputs.map((input) => ( + <VarItem + key={input.output_variable_name} + name={input.output_variable_name} + type={getOutputVarType(input)} + description="Form input value" + /> + ))} + <VarItem name="__action_id" type="string" description="Action ID user triggered" /> + <VarItem name="__action_value" type="string" description="Selected action value" /> + <VarItem name="__rendered_content" type="string" description="Rendered content" /> </OutputVars> {isPreview && ( diff --git a/web/app/components/workflow/nodes/human-input/types.ts b/web/app/components/workflow/nodes/human-input/types.ts index c066e0ada1acff..a6b4796abb77a7 100644 --- a/web/app/components/workflow/nodes/human-input/types.ts +++ b/web/app/components/workflow/nodes/human-input/types.ts @@ -3,10 +3,7 @@ import type { UploadFileSetting, ValueSelector, } from '@/app/components/workflow/types' -import { - InputVarType, - SupportUploadFileTypes, -} from '@/app/components/workflow/types' +import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' export type HumanInputNodeType = CommonNodeType & { @@ -98,48 +95,36 @@ type SharedFileFormInput = Pick< 'allowed_file_extensions' | 'allowed_file_types' | 'allowed_file_upload_methods' > -export type FileFormInput = BaseFormInputItem & SharedFileFormInput & { - type: InputVarType.singleFile -} +export type FileFormInput = BaseFormInputItem & + SharedFileFormInput & { + type: InputVarType.singleFile + } -export type FileListFormInput = BaseFormInputItem & SharedFileFormInput & { - type: InputVarType.multiFiles - number_limits?: UploadFileSetting['number_limits'] -} +export type FileListFormInput = BaseFormInputItem & + SharedFileFormInput & { + type: InputVarType.multiFiles + number_limits?: UploadFileSetting['number_limits'] + } -export type FormInputItem - = | ParagraphFormInput - | SelectFormInput - | FileFormInput - | FileListFormInput +export type FormInputItem = ParagraphFormInput | SelectFormInput | FileFormInput | FileListFormInput -export const isParagraphFormInput = ( - input: FormInputItem, -): input is ParagraphFormInput => { +export const isParagraphFormInput = (input: FormInputItem): input is ParagraphFormInput => { return input.type === InputVarType.paragraph } -export const isSelectFormInput = ( - input: FormInputItem, -): input is SelectFormInput => { +export const isSelectFormInput = (input: FormInputItem): input is SelectFormInput => { return input.type === InputVarType.select } -export const isFileFormInput = ( - input: FormInputItem, -): input is FileFormInput => { +export const isFileFormInput = (input: FormInputItem): input is FileFormInput => { return input.type === InputVarType.singleFile } -export const isFileListFormInput = ( - input: FormInputItem, -): input is FileListFormInput => { +export const isFileListFormInput = (input: FormInputItem): input is FileListFormInput => { return input.type === InputVarType.multiFiles } -export const createDefaultParagraphFormInput = ( - output_variable_name = '', -): ParagraphFormInput => ({ +export const createDefaultParagraphFormInput = (output_variable_name = ''): ParagraphFormInput => ({ type: InputVarType.paragraph, output_variable_name, default: { @@ -149,9 +134,7 @@ export const createDefaultParagraphFormInput = ( }, }) -const createDefaultSelectFormInput = ( - output_variable_name = '', -): SelectFormInput => ({ +const createDefaultSelectFormInput = (output_variable_name = ''): SelectFormInput => ({ type: InputVarType.select, output_variable_name, option_source: { @@ -161,9 +144,7 @@ const createDefaultSelectFormInput = ( }, }) -const createDefaultFileFormInput = ( - output_variable_name = '', -): FileFormInput => ({ +const createDefaultFileFormInput = (output_variable_name = ''): FileFormInput => ({ type: InputVarType.singleFile, output_variable_name, allowed_file_extensions: [], @@ -171,9 +152,7 @@ const createDefaultFileFormInput = ( allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], }) -const createDefaultFileListFormInput = ( - output_variable_name = '', -): FileListFormInput => ({ +const createDefaultFileListFormInput = (output_variable_name = ''): FileListFormInput => ({ type: InputVarType.multiFiles, output_variable_name, allowed_file_extensions: [], diff --git a/web/app/components/workflow/nodes/human-input/utils.ts b/web/app/components/workflow/nodes/human-input/utils.ts index 6b032430bf7841..1a7cd01561c5b6 100644 --- a/web/app/components/workflow/nodes/human-input/utils.ts +++ b/web/app/components/workflow/nodes/human-input/utils.ts @@ -8,10 +8,18 @@ export const isOutput = (valueSelector: string[]) => { export const getHumanInputFormDependencySelectors = (inputs: FormInputItem[]): ValueSelector[] => { return inputs.flatMap((input) => { - if (isParagraphFormInput(input) && input.default.type === 'variable' && input.default.selector.length > 0) + if ( + isParagraphFormInput(input) && + input.default.type === 'variable' && + input.default.selector.length > 0 + ) return [input.default.selector] - if (isSelectFormInput(input) && input.option_source.type === 'variable' && input.option_source.selector.length > 0) + if ( + isSelectFormInput(input) && + input.option_source.type === 'variable' && + input.option_source.selector.length > 0 + ) return [input.option_source.selector] return [] diff --git a/web/app/components/workflow/nodes/if-else/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/if-else/__tests__/integration.spec.tsx index af73cd8581fb52..3a686730d19752 100644 --- a/web/app/components/workflow/nodes/if-else/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/if-else/__tests__/integration.spec.tsx @@ -3,11 +3,7 @@ import type { IfElseNodeType } from '../types' import type { PanelProps } from '@/types/workflow' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { - BlockEnum, - - VarType, -} from '../../../types' +import { BlockEnum, VarType } from '../../../types' import { VarType as NumberVarType } from '../../tool/types' import ConditionAdd from '../components/condition-add' import ConditionFilesListValue from '../components/condition-files-list-value' @@ -17,11 +13,7 @@ import ConditionNumberInput from '../components/condition-number-input' import ConditionValue from '../components/condition-value' import Node from '../node' import Panel from '../panel' -import { - ComparisonOperator, - - LogicalOperator, -} from '../types' +import { ComparisonOperator, LogicalOperator } from '../types' import useConfig from '../use-config' vi.mock('reactflow', async () => { @@ -45,11 +37,12 @@ vi.mock('react-sortablejs', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-vars', () => ({ - default: ({ onChange }: { onChange: (valueSelector: string[], varItem: { type: VarType }) => void }) => ( - <button - type="button" - onClick={() => onChange(['node-1', 'score'], { type: VarType.number })} - > + default: ({ + onChange, + }: { + onChange: (valueSelector: string[], varItem: { type: VarType }) => void + }) => ( + <button type="button" onClick={() => onChange(['node-1', 'score'], { type: VarType.number })}> pick-var </button> ), @@ -66,7 +59,9 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable-tag', () => ( })) vi.mock('@/app/components/workflow/nodes/_base/components/node-handle', () => ({ - NodeSourceHandle: ({ handleId }: { handleId: string }) => <div data-testid={`handle-${handleId}`} />, + NodeSourceHandle: ({ handleId }: { handleId: string }) => ( + <div data-testid={`handle-${handleId}`} /> + ), })) const mockWorkflowStoreState = { @@ -76,7 +71,8 @@ const mockWorkflowStoreState = { } vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: typeof mockWorkflowStoreState) => unknown) => selector(mockWorkflowStoreState), + useStore: (selector: (state: typeof mockWorkflowStoreState) => unknown) => + selector(mockWorkflowStoreState), useWorkflowStore: () => ({ getState: () => ({ ...mockWorkflowStoreState, @@ -141,7 +137,9 @@ const createData = (overrides: Partial<IfElseNodeType> = {}): IfElseNodeType => ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), filterVar: () => true, @@ -220,18 +218,14 @@ describe('if-else path', () => { const user = userEvent.setup() const onSelectVariable = vi.fn() - render( - <ConditionAdd - caseId="case-1" - variables={[]} - onSelectVariable={onSelectVariable} - />, - ) + render(<ConditionAdd caseId="case-1" variables={[]} onSelectVariable={onSelectVariable} />) await user.click(screen.getByRole('button', { name: /workflow.nodes.ifElse.addCondition/i })) await user.click(screen.getByText('pick-var')) - expect(onSelectVariable).toHaveBeenCalledWith('case-1', ['node-1', 'score'], { type: VarType.number }) + expect(onSelectVariable).toHaveBeenCalledWith('case-1', ['node-1', 'score'], { + type: VarType.number, + }) }) it('should switch operators and number input modes', async () => { @@ -408,19 +402,15 @@ describe('if-else path', () => { const handleAddCase = vi.fn() const inputs = createData({ cases: [] }) - mockUseConfig.mockReturnValueOnce(createConfigResult({ - inputs, - handleAddCase, - })) - - render( - <Panel - id="if-else-node" - data={inputs} - panelProps={panelProps} - />, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + inputs, + handleAddCase, + }), ) + render(<Panel id="if-else-node" data={inputs} panelProps={panelProps} />) + await user.click(screen.getByRole('button', { name: /elif/i })) expect(handleAddCase).toHaveBeenCalled() diff --git a/web/app/components/workflow/nodes/if-else/__tests__/use-config.helpers.spec.ts b/web/app/components/workflow/nodes/if-else/__tests__/use-config.helpers.spec.ts index 4966bf1e70cea2..0848b88dfc30d0 100644 --- a/web/app/components/workflow/nodes/if-else/__tests__/use-config.helpers.spec.ts +++ b/web/app/components/workflow/nodes/if-else/__tests__/use-config.helpers.spec.ts @@ -20,33 +20,40 @@ import { type TestIfElseInputs = ReturnType<typeof createInputs> -const createInputs = (): IfElseNodeType => ({ - title: 'If/Else', - desc: '', - type: BlockEnum.IfElse, - cases: [{ - case_id: 'case-1', - logical_operator: LogicalOperator.and, - conditions: [{ - id: 'condition-1', - varType: VarType.string, - variable_selector: ['node', 'value'], - comparison_operator: 'contains', - value: '', - }], - }], - _targetBranches: [ - { id: 'case-1', name: 'Case 1' }, - { id: 'false', name: 'Else' }, - ], -} as unknown as IfElseNodeType) +const createInputs = (): IfElseNodeType => + ({ + title: 'If/Else', + desc: '', + type: BlockEnum.IfElse, + cases: [ + { + case_id: 'case-1', + logical_operator: LogicalOperator.and, + conditions: [ + { + id: 'condition-1', + varType: VarType.string, + variable_selector: ['node', 'value'], + comparison_operator: 'contains', + value: '', + }, + ], + }, + ], + _targetBranches: [ + { id: 'case-1', name: 'Case 1' }, + { id: 'false', name: 'Else' }, + ], + }) as unknown as IfElseNodeType describe('if-else use-config helpers', () => { it('filters vars and derives file attribute flags', () => { expect(filterAllVars()).toBe(true) expect(filterNumberVars({ type: VarType.number } as never)).toBe(true) expect(filterNumberVars({ type: VarType.string } as never)).toBe(false) - expect(getVarsIsVarFileAttribute(createInputs().cases, selector => selector[1] === 'value')).toEqual({ + expect( + getVarsIsVarFileAttribute(createInputs().cases, (selector) => selector[1] === 'value'), + ).toEqual({ 'condition-1': true, }) }) @@ -54,17 +61,21 @@ describe('if-else use-config helpers', () => { it('adds, removes and sorts cases while keeping target branches aligned', () => { const added = addCase(createInputs()) expect(added.cases).toHaveLength(2) - expect(added._targetBranches?.map(branch => branch.id)).toContain('false') + expect(added._targetBranches?.map((branch) => branch.id)).toContain('false') const removed = removeCase(added, 'case-1') - expect(removed.cases?.some(item => item.case_id === 'case-1')).toBe(false) + expect(removed.cases?.some((item) => item.case_id === 'case-1')).toBe(false) const sorted = sortCases(createInputs(), [ { id: 'display-2', case_id: 'case-2', logical_operator: LogicalOperator.or, conditions: [] }, { id: 'display-1', case_id: 'case-1', logical_operator: LogicalOperator.and, conditions: [] }, ] as unknown as Parameters<typeof sortCases>[1]) - expect(sorted.cases?.map(item => item.case_id)).toEqual(['case-2', 'case-1']) - expect(sorted._targetBranches?.map(branch => branch.id)).toEqual(['case-2', 'case-1', 'false']) + expect(sorted.cases?.map((item) => item.case_id)).toEqual(['case-2', 'case-1']) + expect(sorted._targetBranches?.map((branch) => branch.id)).toEqual([ + 'case-2', + 'case-1', + 'false', + ]) }) it('adds, updates, toggles and removes conditions and sub-conditions', () => { @@ -76,46 +87,69 @@ describe('if-else use-config helpers', () => { isVarFileAttribute: false, }) expect(withCondition.cases?.[0]?.conditions).toHaveLength(2) - expect(withCondition.cases?.[0]?.conditions[1]).toEqual(expect.objectContaining({ - value: false, - variable_selector: ['node', 'flag'], - })) + expect(withCondition.cases?.[0]?.conditions[1]).toEqual( + expect.objectContaining({ + value: false, + variable_selector: ['node', 'flag'], + }), + ) const updatedCondition = updateCondition(withCondition, 'case-1', 'condition-1', { id: 'condition-1', value: 'next', comparison_operator: '=', } as Parameters<typeof updateCondition>[3]) - expect(updatedCondition.cases?.[0]?.conditions[0]).toEqual(expect.objectContaining({ - value: 'next', - comparison_operator: '=', - })) + expect(updatedCondition.cases?.[0]?.conditions[0]).toEqual( + expect.objectContaining({ + value: 'next', + comparison_operator: '=', + }), + ) const toggled = toggleConditionLogicalOperator(updatedCondition, 'case-1') expect(toggled.cases?.[0]?.logical_operator).toBe(LogicalOperator.or) const withSubCondition = addSubVariableCondition(toggled, 'case-1', 'condition-1', 'name') - expect(withSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({ - key: 'name', - value: '', - })) + expect( + withSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0], + ).toEqual( + expect.objectContaining({ + key: 'name', + value: '', + }), + ) - const firstSubConditionId = withSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]?.id + const firstSubConditionId = + withSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]?.id expect(firstSubConditionId).toBeTruthy() const updatedSubCondition = updateSubVariableCondition( withSubCondition, 'case-1', 'condition-1', firstSubConditionId!, - { key: 'size', comparison_operator: '>', value: '10' } as TestIfElseInputs['cases'][number]['conditions'][number], + { + key: 'size', + comparison_operator: '>', + value: '10', + } as TestIfElseInputs['cases'][number]['conditions'][number], + ) + expect( + updatedSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0], + ).toEqual( + expect.objectContaining({ + key: 'size', + value: '10', + }), ) - expect(updatedSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({ - key: 'size', - value: '10', - })) - const toggledSub = toggleSubVariableConditionLogicalOperator(updatedSubCondition, 'case-1', 'condition-1') - expect(toggledSub.cases?.[0]?.conditions[0]?.sub_variable_condition?.logical_operator).toBe(LogicalOperator.or) + const toggledSub = toggleSubVariableConditionLogicalOperator( + updatedSubCondition, + 'case-1', + 'condition-1', + ) + expect(toggledSub.cases?.[0]?.conditions[0]?.sub_variable_condition?.logical_operator).toBe( + LogicalOperator.or, + ) const removedSub = removeSubVariableCondition( toggledSub, @@ -126,7 +160,9 @@ describe('if-else use-config helpers', () => { expect(removedSub.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions).toEqual([]) const removedCondition = removeCondition(removedSub, 'case-1', 'condition-1') - expect(removedCondition.cases?.[0]?.conditions.some(item => item.id === 'condition-1')).toBe(false) + expect(removedCondition.cases?.[0]?.conditions.some((item) => item.id === 'condition-1')).toBe( + false, + ) }) it('keeps inputs unchanged when guard branches short-circuit helper updates', () => { @@ -148,10 +184,20 @@ describe('if-else use-config helpers', () => { }) expect(withoutElseBranch._targetBranches).toEqual([{ id: 'case-1', name: 'Case 1' }]) - const unchangedWhenConditionMissing = addSubVariableCondition(createInputs(), 'case-1', 'missing-condition', 'name') + const unchangedWhenConditionMissing = addSubVariableCondition( + createInputs(), + 'case-1', + 'missing-condition', + 'name', + ) expect(unchangedWhenConditionMissing).toEqual(createInputs()) - const unchangedWhenSubConditionMissing = removeSubVariableCondition(createInputs(), 'case-1', 'condition-1', 'missing-sub') + const unchangedWhenSubConditionMissing = removeSubVariableCondition( + createInputs(), + 'case-1', + 'condition-1', + 'missing-sub', + ) expect(unchangedWhenSubConditionMissing).toEqual(createInputs()) const unchangedWhenCaseIsMissingForCondition = addCondition({ @@ -166,7 +212,11 @@ describe('if-else use-config helpers', () => { const unchangedWhenCaseMissing = toggleConditionLogicalOperator(createInputs(), 'missing-case') expect(unchangedWhenCaseMissing).toEqual(createInputs()) - const unchangedWhenSubVariableGroupMissing = toggleSubVariableConditionLogicalOperator(createInputs(), 'case-1', 'condition-1') + const unchangedWhenSubVariableGroupMissing = toggleSubVariableConditionLogicalOperator( + createInputs(), + 'case-1', + 'condition-1', + ) expect(unchangedWhenSubVariableGroupMissing).toEqual(createInputs()) }) }) diff --git a/web/app/components/workflow/nodes/if-else/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/if-else/__tests__/use-config.spec.tsx index e67dbdfc744d1f..64708ec43628d4 100644 --- a/web/app/components/workflow/nodes/if-else/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/if-else/__tests__/use-config.spec.tsx @@ -29,7 +29,8 @@ vi.mock('reactflow', async () => { vi.mock('@/app/components/workflow/hooks', () => ({ useNodesReadOnly: () => ({ nodesReadOnly: false }), useEdgesInteractions: () => ({ - handleEdgeDeleteByDeleteBranch: (...args: unknown[]) => mockHandleEdgeDeleteByDeleteBranch(...args), + handleEdgeDeleteByDeleteBranch: (...args: unknown[]) => + mockHandleEdgeDeleteByDeleteBranch(...args), }), })) @@ -42,7 +43,13 @@ vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () default: (_id: string, { filterVar }: { filterVar: (value: { type: VarType }) => boolean }) => ({ availableVars: filterVar({ type: VarType.number }) ? [{ nodeId: 'node-1', title: 'Start', vars: [{ variable: 'score', type: VarType.number }] }] - : [{ nodeId: 'node-1', title: 'Start', vars: [{ variable: 'answer', type: VarType.string }] }], + : [ + { + nodeId: 'node-1', + title: 'Start', + vars: [{ variable: 'answer', type: VarType.string }], + }, + ], availableNodesWithParent: [], }), })) @@ -60,17 +67,21 @@ const createPayload = (overrides: Partial<IfElseNodeType> = {}): IfElseNodeType type: BlockEnum.IfElse, isInIteration: false, isInLoop: false, - cases: [{ - case_id: 'case-1', - logical_operator: LogicalOperator.and, - conditions: [{ - id: 'condition-1', - varType: VarType.string, - variable_selector: ['node-1', 'answer'], - comparison_operator: ComparisonOperator.contains, - value: 'hello', - }], - }], + cases: [ + { + case_id: 'case-1', + logical_operator: LogicalOperator.and, + conditions: [ + { + id: 'condition-1', + varType: VarType.string, + variable_selector: ['node-1', 'answer'], + comparison_operator: ComparisonOperator.contains, + value: 'hello', + }, + ], + }, + ], _targetBranches: [ { id: 'case-1', name: 'IF' }, { id: 'false', name: 'ELSE' }, @@ -101,7 +112,9 @@ describe('useConfig', () => { result.current.handleAddCase() result.current.handleRemoveCase('generated-id') - result.current.handleAddCondition('case-1', ['node-1', 'score'], { type: VarType.number } as never) + result.current.handleAddCondition('case-1', ['node-1', 'score'], { + type: VarType.number, + } as never) result.current.handleUpdateCondition('case-1', 'condition-1', { id: 'condition-1', varType: VarType.number, @@ -111,93 +124,111 @@ describe('useConfig', () => { }) result.current.handleRemoveCondition('case-1', 'condition-1') result.current.handleToggleConditionLogicalOperator('case-1') - result.current.handleSortCase([{ - id: 'sortable-1', - case_id: 'case-1', - logical_operator: LogicalOperator.or, - conditions: [], - }]) + result.current.handleSortCase([ + { + id: 'sortable-1', + case_id: 'case-1', + logical_operator: LogicalOperator.or, + conditions: [], + }, + ]) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - cases: expect.arrayContaining([ - expect.objectContaining({ - case_id: 'generated-id', - logical_operator: LogicalOperator.and, - }), - ]), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - cases: [ - expect.objectContaining({ - case_id: 'case-1', - logical_operator: LogicalOperator.or, - }), - ], - _targetBranches: [ - { id: 'case-1', name: 'IF' }, - { id: 'false', name: 'ELSE' }, - ], - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - cases: expect.arrayContaining([ - expect.objectContaining({ - conditions: expect.arrayContaining([ - expect.objectContaining({ - id: 'generated-id', - variable_selector: ['node-1', 'score'], - }), - ]), - }), - ]), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - cases: expect.arrayContaining([ - expect.objectContaining({ - conditions: expect.arrayContaining([ - expect.objectContaining({ - id: 'condition-1', - comparison_operator: ComparisonOperator.largerThan, - value: '3', - }), - ]), - }), - ]), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - cases: expect.arrayContaining([ - expect.objectContaining({ - logical_operator: LogicalOperator.or, - }), - ]), - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + case_id: 'generated-id', + logical_operator: LogicalOperator.and, + }), + ]), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + cases: [ + expect.objectContaining({ + case_id: 'case-1', + logical_operator: LogicalOperator.or, + }), + ], + _targetBranches: [ + { id: 'case-1', name: 'IF' }, + { id: 'false', name: 'ELSE' }, + ], + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + id: 'generated-id', + variable_selector: ['node-1', 'score'], + }), + ]), + }), + ]), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + id: 'condition-1', + comparison_operator: ComparisonOperator.largerThan, + value: '3', + }), + ]), + }), + ]), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + logical_operator: LogicalOperator.or, + }), + ]), + }), + ) expect(mockHandleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('if-node', 'generated-id') expect(mockUpdateNodeInternals).toHaveBeenCalledWith('if-node') }) it('should manage sub-variable conditions', () => { const payload = createPayload({ - cases: [{ - case_id: 'case-1', - logical_operator: LogicalOperator.and, - conditions: [{ - id: 'condition-1', - varType: VarType.file, - variable_selector: ['node-1', 'files'], - comparison_operator: ComparisonOperator.exists, - value: '', - sub_variable_condition: { - case_id: 'sub-case-1', - logical_operator: LogicalOperator.and, - conditions: [{ - id: 'sub-1', - key: 'name', - varType: VarType.string, - comparison_operator: ComparisonOperator.contains, + cases: [ + { + case_id: 'case-1', + logical_operator: LogicalOperator.and, + conditions: [ + { + id: 'condition-1', + varType: VarType.file, + variable_selector: ['node-1', 'files'], + comparison_operator: ComparisonOperator.exists, value: '', - }], - }, - }], - }], + sub_variable_condition: { + case_id: 'sub-case-1', + logical_operator: LogicalOperator.and, + conditions: [ + { + id: 'sub-1', + key: 'name', + varType: VarType.string, + comparison_operator: ComparisonOperator.contains, + value: '', + }, + ], + }, + }, + ], + }, + ], }) const { result } = renderHook(() => useConfig('if-node', payload)) @@ -212,55 +243,61 @@ describe('useConfig', () => { result.current.handleRemoveSubVariableCondition('case-1', 'condition-1', 'sub-1') result.current.handleToggleSubVariableConditionLogicalOperator('case-1', 'condition-1') - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - cases: expect.arrayContaining([ - expect.objectContaining({ - conditions: expect.arrayContaining([ - expect.objectContaining({ - sub_variable_condition: expect.objectContaining({ - conditions: expect.arrayContaining([ - expect.objectContaining({ - id: 'generated-id', - key: 'name', - }), - ]), + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + sub_variable_condition: expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + id: 'generated-id', + key: 'name', + }), + ]), + }), }), - }), - ]), - }), - ]), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - cases: expect.arrayContaining([ - expect.objectContaining({ - conditions: expect.arrayContaining([ - expect.objectContaining({ - sub_variable_condition: expect.objectContaining({ - conditions: expect.arrayContaining([ - expect.objectContaining({ - id: 'sub-1', - key: 'size', - value: '2', - }), - ]), + ]), + }), + ]), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + sub_variable_condition: expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + id: 'sub-1', + key: 'size', + value: '2', + }), + ]), + }), }), - }), - ]), - }), - ]), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - cases: expect.arrayContaining([ - expect.objectContaining({ - conditions: expect.arrayContaining([ - expect.objectContaining({ - sub_variable_condition: expect.objectContaining({ - logical_operator: LogicalOperator.or, + ]), + }), + ]), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + sub_variable_condition: expect.objectContaining({ + logical_operator: LogicalOperator.or, + }), }), - }), - ]), - }), - ]), - })) + ]), + }), + ]), + }), + ) }) }) diff --git a/web/app/components/workflow/nodes/if-else/components/condition-add.tsx b/web/app/components/workflow/nodes/if-else/components/condition-add.tsx index f0b97c5bf85b83..f4cee942794040 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-add.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-add.tsx @@ -1,20 +1,9 @@ import type { HandleAddCondition } from '../types' -import type { - NodeOutPutVar, - ValueSelector, - Var, -} from '@/app/components/workflow/types' +import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiAddLine } from '@remixicon/react' -import { - useCallback, - useState, -} from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' @@ -36,27 +25,25 @@ const ConditionAdd = ({ const { t } = useTranslation() const [open, setOpen] = useState(false) - const handleSelectVariable = useCallback((valueSelector: ValueSelector, varItem: Var) => { - onSelectVariable(caseId, valueSelector, varItem) - setOpen(false) - }, [caseId, onSelectVariable]) + const handleSelectVariable = useCallback( + (valueSelector: ValueSelector, varItem: Var) => { + onSelectVariable(caseId, valueSelector, varItem) + setOpen(false) + }, + [caseId, onSelectVariable], + ) return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( - <Button - size="small" - className={className} - disabled={disabled} - > + render={ + <Button size="small" className={className} disabled={disabled}> <RiAddLine className="mr-1 size-3.5" /> - {t($ => $['nodes.ifElse.addCondition'], { ns: 'workflow' })} + {t(($) => $['nodes.ifElse.addCondition'], { ns: 'workflow' })} </Button> - )} + } onClick={(e) => { - if (disabled) - e.preventDefault() + if (disabled) e.preventDefault() }} /> <PopoverContent @@ -65,11 +52,7 @@ const ConditionAdd = ({ popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > <div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg"> - <VarReferenceVars - vars={variables} - isSupportFileVar - onChange={handleSelectVariable} - /> + <VarReferenceVars vars={variables} isSupportFileVar onChange={handleSelectVariable} /> </div> </PopoverContent> </Popover> diff --git a/web/app/components/workflow/nodes/if-else/components/condition-files-list-value.tsx b/web/app/components/workflow/nodes/if-else/components/condition-files-list-value.tsx index 577080095e32e0..31e65ca79d6715 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-files-list-value.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-files-list-value.tsx @@ -1,14 +1,9 @@ import type { ValueSelector } from '../../../types' import type { Condition } from '../types' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' -import { - VariableLabelInNode, -} from '@/app/components/workflow/nodes/_base/components/variable/variable-label' +import { VariableLabelInNode } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from '../../constants' import { ComparisonOperator } from '../types' import { @@ -20,87 +15,97 @@ import { type ConditionValueProps = { condition: Condition } -const ConditionValue = ({ - condition, -}: ConditionValueProps) => { +const ConditionValue = ({ condition }: ConditionValueProps) => { const { t } = useTranslation() - const { - variable_selector, - comparison_operator: operator, - sub_variable_condition, - } = condition + const { variable_selector, comparison_operator: operator, sub_variable_condition } = condition const variableSelector = variable_selector as ValueSelector - const operatorName = isComparisonOperatorNeedTranslate(operator) ? t($ => $[`nodes.ifElse.comparisonOperator.${operator}`], { ns: 'workflow' }) : operator + const operatorName = isComparisonOperatorNeedTranslate(operator) + ? t(($) => $[`nodes.ifElse.comparisonOperator.${operator}`], { ns: 'workflow' }) + : operator const formatValue = useCallback((c: Condition) => { const notHasValue = comparisonOperatorNotRequireValue(c.comparison_operator) - if (notHasValue) - return '' + if (notHasValue) return '' const value = c.value as string return value.replace(/\{\{#([^#]*)#\}\}/g, (a, b) => { const arr: string[] = b.split('.') - if (isSystemVar(arr)) - return `{{${b}}}` + if (isSystemVar(arr)) return `{{${b}}}` return `{{${arr.slice(1).join('.')}}}` }) }, []) const isSelect = useCallback((c: Condition) => { - return c.comparison_operator === ComparisonOperator.in || c.comparison_operator === ComparisonOperator.notIn + return ( + c.comparison_operator === ComparisonOperator.in || + c.comparison_operator === ComparisonOperator.notIn + ) }, []) - const selectName = useCallback((c: Condition) => { - const isSelect = c.comparison_operator === ComparisonOperator.in || c.comparison_operator === ComparisonOperator.notIn - if (isSelect) { - const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(c.value) ? c.value[0] : c.value))[0] - return name - ? t($ => $[`nodes.ifElse.optionName.${name.i18nKey}`], { ns: 'workflow' }).replace(/\{\{#([^#]*)#\}\}/g, (a, b) => { - const arr: string[] = b.split('.') - if (isSystemVar(arr)) - return `{{${b}}}` + const selectName = useCallback( + (c: Condition) => { + const isSelect = + c.comparison_operator === ComparisonOperator.in || + c.comparison_operator === ComparisonOperator.notIn + if (isSelect) { + const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter( + (item) => item.value === (Array.isArray(c.value) ? c.value[0] : c.value), + )[0] + return name + ? t(($) => $[`nodes.ifElse.optionName.${name.i18nKey}`], { ns: 'workflow' }).replace( + /\{\{#([^#]*)#\}\}/g, + (a, b) => { + const arr: string[] = b.split('.') + if (isSystemVar(arr)) return `{{${b}}}` - return `{{${arr.slice(1).join('.')}}}` - }) - : '' - } - return '' - }, [t]) + return `{{${arr.slice(1).join('.')}}}` + }, + ) + : '' + } + return '' + }, + [t], + ) return ( <div className="rounded-md bg-workflow-block-parma-bg"> <div className="flex h-6 items-center px-1"> - <VariableLabelInNode - className="w-0 grow" - variables={variableSelector} - notShowFullPath - /> - <div - className="mx-1 shrink-0 text-xs font-medium text-text-primary" - title={operatorName} - > + <VariableLabelInNode className="w-0 grow" variables={variableSelector} notShowFullPath /> + <div className="mx-1 shrink-0 text-xs font-medium text-text-primary" title={operatorName}> {operatorName} </div> </div> <div className="ml-[10px] border-l border-divider-regular pl-[10px]"> - { - sub_variable_condition?.conditions.map((c: Condition, index) => { - const comparisonOperator = c.comparison_operator - const comparisonOperatorName = comparisonOperator && isComparisonOperatorNeedTranslate(comparisonOperator) - ? t($ => $[`nodes.ifElse.comparisonOperator.${comparisonOperator}`], { ns: 'workflow' }) + {sub_variable_condition?.conditions.map((c: Condition, index) => { + const comparisonOperator = c.comparison_operator + const comparisonOperatorName = + comparisonOperator && isComparisonOperatorNeedTranslate(comparisonOperator) + ? t(($) => $[`nodes.ifElse.comparisonOperator.${comparisonOperator}`], { + ns: 'workflow', + }) : comparisonOperator - return ( - <div className="relative flex h-6 items-center space-x-1" key={c.id}> - <div className="system-xs-medium text-text-accent">{c.key}</div> - <div className="system-xs-medium text-text-primary">{comparisonOperatorName}</div> - {c.comparison_operator && !isEmptyRelatedOperator(c.comparison_operator) && <div className="system-xs-regular text-text-secondary">{isSelect(c) ? selectName(c) : formatValue(c)}</div>} - {index !== sub_variable_condition.conditions.length - 1 && (<div className="absolute right-1 bottom-[-10px] z-10 text-[10px] leading-4 font-medium text-text-accent uppercase">{t($ => $[`nodes.ifElse.${sub_variable_condition.logical_operator}`], { ns: 'workflow' })}</div>)} - </div> - ) - }) - } + return ( + <div className="relative flex h-6 items-center space-x-1" key={c.id}> + <div className="system-xs-medium text-text-accent">{c.key}</div> + <div className="system-xs-medium text-text-primary">{comparisonOperatorName}</div> + {c.comparison_operator && !isEmptyRelatedOperator(c.comparison_operator) && ( + <div className="system-xs-regular text-text-secondary"> + {isSelect(c) ? selectName(c) : formatValue(c)} + </div> + )} + {index !== sub_variable_condition.conditions.length - 1 && ( + <div className="absolute right-1 bottom-[-10px] z-10 text-[10px] leading-4 font-medium text-text-accent uppercase"> + {t(($) => $[`nodes.ifElse.${sub_variable_condition.logical_operator}`], { + ns: 'workflow', + })} + </div> + )} + </div> + ) + })} </div> </div> ) diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-input.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-input.tsx index ccf73cb421719d..1dd87117d3ce91 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-input.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-input.tsx @@ -1,7 +1,4 @@ -import type { - Node, - NodeOutPutVar, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { useTranslation } from 'react-i18next' import PromptEditor from '@/app/components/base/prompt-editor' import { useStore } from '@/app/components/workflow/store' @@ -22,16 +19,16 @@ const ConditionInput = ({ availableNodes, }: ConditionInputProps) => { const { t } = useTranslation() - const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) - const pipelineId = useStore(s => s.pipelineId) - const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel) + const controlPromptEditorRerenderKey = useStore((s) => s.controlPromptEditorRerenderKey) + const pipelineId = useStore((s) => s.pipelineId) + const setShowInputFieldPanel = useStore((s) => s.setShowInputFieldPanel) return ( <PromptEditor key={controlPromptEditorRerenderKey} compact value={value} - placeholder={t($ => $['nodes.ifElse.enterValue'], { ns: 'workflow' }) || ''} + placeholder={t(($) => $['nodes.ifElse.enterValue'], { ns: 'workflow' }) || ''} workflowVariableBlock={{ show: true, variables: nodesOutputVars || [], @@ -45,7 +42,7 @@ const ConditionInput = ({ } if (node.data.type === BlockEnum.Start) { acc.sys = { - title: t($ => $['blocks.start'], { ns: 'workflow' }), + title: t(($) => $['blocks.start'], { ns: 'workflow' }), type: BlockEnum.Start, } } diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx index 6cd4c0bcecff5f..b83f8983482767 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx @@ -8,28 +8,24 @@ import type { HandleUpdateCondition, HandleUpdateSubVariableCondition, } from '../../types' -import type { - Node, - NodeOutPutVar, - ValueSelector, - Var, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { RiDeleteBinLine } from '@remixicon/react' import { produce } from 'immer' -import { - useCallback, - useMemo, - useState, -} from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import { useIsChatMode } from '@/app/components/workflow/hooks/use-workflow' import { getVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils' import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value' import { useWorkflowStore } from '@/app/components/workflow/store' - import { VarType } from '@/app/components/workflow/types' import { useAllBuiltInTools, @@ -39,9 +35,7 @@ import { } from '@/service/use-tools' import useMatchSchemaType from '../../../_base/components/variable/use-match-schema-type' import { FILE_TYPE_OPTIONS, SUB_VARIABLES, TRANSFER_METHOD } from '../../../constants' -import { - ComparisonOperator, -} from '../../types' +import { ComparisonOperator } from '../../types' import { comparisonOperatorNotRequireValue, getOperators } from '../../utils' import ConditionNumberInput from '../condition-number-input' import ConditionWrap from '../condition-wrap' @@ -105,44 +99,62 @@ const ConditionItem = ({ const workflowStore = useWorkflowStore() - const doUpdateCondition = useCallback((newCondition: Condition) => { - if (isSubVariableKey) - onUpdateSubVariableCondition?.(caseId, conditionId, condition.id, newCondition) - else - onUpdateCondition?.(caseId, condition.id, newCondition) - }, [caseId, condition, conditionId, isSubVariableKey, onUpdateCondition, onUpdateSubVariableCondition]) + const doUpdateCondition = useCallback( + (newCondition: Condition) => { + if (isSubVariableKey) + onUpdateSubVariableCondition?.(caseId, conditionId, condition.id, newCondition) + else onUpdateCondition?.(caseId, condition.id, newCondition) + }, + [ + caseId, + condition, + conditionId, + isSubVariableKey, + onUpdateCondition, + onUpdateSubVariableCondition, + ], + ) const canChooseOperator = useMemo(() => { - if (disabled) - return false + if (disabled) return false - if (isSubVariableKey) - return !!condition.key + if (isSubVariableKey) return !!condition.key return true }, [condition.key, disabled, isSubVariableKey]) - const handleUpdateConditionOperator = useCallback((value: ComparisonOperator) => { - const newCondition = { - ...condition, - comparison_operator: value, - } - doUpdateCondition(newCondition) - }, [condition, doUpdateCondition]) + const handleUpdateConditionOperator = useCallback( + (value: ComparisonOperator) => { + const newCondition = { + ...condition, + comparison_operator: value, + } + doUpdateCondition(newCondition) + }, + [condition, doUpdateCondition], + ) - const handleUpdateConditionNumberVarType = useCallback((numberVarType: NumberVarType) => { - const newCondition = { - ...condition, - numberVarType, - value: '', - } - doUpdateCondition(newCondition) - }, [condition, doUpdateCondition]) + const handleUpdateConditionNumberVarType = useCallback( + (numberVarType: NumberVarType) => { + const newCondition = { + ...condition, + numberVarType, + value: '', + } + doUpdateCondition(newCondition) + }, + [condition, doUpdateCondition], + ) - const isSubVariable = condition.varType === VarType.arrayFile && [ComparisonOperator.contains, ComparisonOperator.notContains, ComparisonOperator.allOf].includes(condition.comparison_operator!) + const isSubVariable = + condition.varType === VarType.arrayFile && + [ + ComparisonOperator.contains, + ComparisonOperator.notContains, + ComparisonOperator.allOf, + ].includes(condition.comparison_operator!) const fileAttr = useMemo(() => { - if (file) - return file + if (file) return file if (isSubVariableKey) { return { key: condition.key!, @@ -153,28 +165,36 @@ const ConditionItem = ({ const isArrayValue = fileAttr?.key === 'transfer_method' || fileAttr?.key === 'type' - const handleUpdateConditionValue = useCallback((value: string | boolean) => { - if (value === condition.value || (isArrayValue && value === (condition.value as string[])?.[0])) - return - const newCondition = { - ...condition, - value: isArrayValue ? [value as string] : value, - } - doUpdateCondition(newCondition) - }, [condition, doUpdateCondition, isArrayValue]) + const handleUpdateConditionValue = useCallback( + (value: string | boolean) => { + if ( + value === condition.value || + (isArrayValue && value === (condition.value as string[])?.[0]) + ) + return + const newCondition = { + ...condition, + value: isArrayValue ? [value as string] : value, + } + doUpdateCondition(newCondition) + }, + [condition, doUpdateCondition, isArrayValue], + ) - const isSelect = condition.comparison_operator && [ComparisonOperator.in, ComparisonOperator.notIn].includes(condition.comparison_operator) - const selectOptions = useMemo<Array<{ name: string, value: string }>>(() => { + const isSelect = + condition.comparison_operator && + [ComparisonOperator.in, ComparisonOperator.notIn].includes(condition.comparison_operator) + const selectOptions = useMemo<Array<{ name: string; value: string }>>(() => { if (isSelect) { if (fileAttr?.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) { - return FILE_TYPE_OPTIONS.map(item => ({ - name: t($ => $[`${optionNameI18NPrefix}.${item.i18nKey}`], { ns: 'workflow' }), + return FILE_TYPE_OPTIONS.map((item) => ({ + name: t(($) => $[`${optionNameI18NPrefix}.${item.i18nKey}`], { ns: 'workflow' }), value: item.value, })) } if (fileAttr?.key === 'transfer_method') { - return TRANSFER_METHOD.map(item => ({ - name: t($ => $[`${optionNameI18NPrefix}.${item.i18nKey}`], { ns: 'workflow' }), + return TRANSFER_METHOD.map((item) => ({ + name: t(($) => $[`${optionNameI18NPrefix}.${item.i18nKey}`], { ns: 'workflow' }), value: item.value, })) } @@ -186,138 +206,169 @@ const ConditionItem = ({ const isNotInput = isSelect || isSubVariable const isSubVarSelect = isSubVariableKey - const subVarOptions = SUB_VARIABLES.map(item => ({ + const subVarOptions = SUB_VARIABLES.map((item) => ({ name: item, value: item, })) - const selectedSubVarOption = subVarOptions.find(item => item.value === condition.key) ?? null + const selectedSubVarOption = subVarOptions.find((item) => item.value === condition.key) ?? null - const handleSubVarKeyChange = useCallback((key: string) => { - const newCondition = produce(condition, (draft) => { - draft.key = key - if (key === 'size') - draft.varType = VarType.number - else - draft.varType = VarType.string + const handleSubVarKeyChange = useCallback( + (key: string) => { + const newCondition = produce(condition, (draft) => { + draft.key = key + if (key === 'size') draft.varType = VarType.number + else draft.varType = VarType.string - draft.value = '' - draft.comparison_operator = getOperators(undefined, { key })[0] - }) + draft.value = '' + draft.comparison_operator = getOperators(undefined, { key })[0] + }) - onUpdateSubVariableCondition?.(caseId, conditionId, condition.id, newCondition) - }, [caseId, condition, conditionId, onUpdateSubVariableCondition]) + onUpdateSubVariableCondition?.(caseId, conditionId, condition.id, newCondition) + }, + [caseId, condition, conditionId, onUpdateSubVariableCondition], + ) const doRemoveCondition = useCallback(() => { - if (isSubVariableKey) - onRemoveSubVariableCondition?.(caseId, conditionId, condition.id) - else - onRemoveCondition?.(caseId, condition.id) - }, [caseId, condition, conditionId, isSubVariableKey, onRemoveCondition, onRemoveSubVariableCondition]) + if (isSubVariableKey) onRemoveSubVariableCondition?.(caseId, conditionId, condition.id) + else onRemoveCondition?.(caseId, condition.id) + }, [ + caseId, + condition, + conditionId, + isSubVariableKey, + onRemoveCondition, + onRemoveSubVariableCondition, + ]) const { schemaTypeDefinitions } = useMatchSchemaType() - const handleVarChange = useCallback((valueSelector: ValueSelector, _varItem: Var) => { - const { - conversationVariables, - setControlPromptEditorRerenderKey, - dataSourceList, - } = workflowStore.getState() - const resolvedVarType = getVarType({ - valueSelector, - conversationVariables, + const handleVarChange = useCallback( + (valueSelector: ValueSelector, _varItem: Var) => { + const { conversationVariables, setControlPromptEditorRerenderKey, dataSourceList } = + workflowStore.getState() + const resolvedVarType = getVarType({ + valueSelector, + conversationVariables, + availableNodes, + isChatMode, + allPluginInfoList: { + buildInTools: buildInTools || [], + customTools: customTools || [], + mcpTools: mcpTools || [], + workflowTools: workflowTools || [], + dataSourceList: dataSourceList || [], + }, + schemaTypeDefinitions, + }) + + const newCondition = produce(condition, (draft) => { + draft.variable_selector = valueSelector + draft.varType = resolvedVarType + draft.value = resolvedVarType === VarType.boolean ? false : '' + draft.comparison_operator = getOperators(resolvedVarType)[0] + delete draft.key + delete draft.sub_variable_condition + delete draft.numberVarType + setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) + }) + doUpdateCondition(newCondition) + setOpen(false) + }, + [ + condition, + doUpdateCondition, availableNodes, isChatMode, - allPluginInfoList: { - buildInTools: buildInTools || [], - customTools: customTools || [], - mcpTools: mcpTools || [], - workflowTools: workflowTools || [], - dataSourceList: dataSourceList || [], - }, schemaTypeDefinitions, - }) - - const newCondition = produce(condition, (draft) => { - draft.variable_selector = valueSelector - draft.varType = resolvedVarType - draft.value = resolvedVarType === VarType.boolean ? false : '' - draft.comparison_operator = getOperators(resolvedVarType)[0] - delete draft.key - delete draft.sub_variable_condition - delete draft.numberVarType - setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) - }) - doUpdateCondition(newCondition) - setOpen(false) - }, [condition, doUpdateCondition, availableNodes, isChatMode, schemaTypeDefinitions, buildInTools, customTools, mcpTools, workflowTools]) + buildInTools, + customTools, + mcpTools, + workflowTools, + ], + ) const showBooleanInput = useMemo(() => { - if (condition.varType === VarType.boolean) - return true + if (condition.varType === VarType.boolean) return true - if (condition.varType === VarType.arrayBoolean && [ComparisonOperator.contains, ComparisonOperator.notContains].includes(condition.comparison_operator!)) + if ( + condition.varType === VarType.arrayBoolean && + [ComparisonOperator.contains, ComparisonOperator.notContains].includes( + condition.comparison_operator!, + ) + ) return true return false }, [condition]) - const selectedSelectValue = isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string) - const selectedSelectOption = selectOptions.find(item => item.value === selectedSelectValue) ?? null + const selectedSelectValue = isArrayValue + ? (condition.value as string[])?.[0] + : (condition.value as string) + const selectedSelectOption = + selectOptions.find((item) => item.value === selectedSelectValue) ?? null return ( <div className={cn('mb-1 flex last-of-type:mb-0', className)}> - <div className={cn( - 'grow rounded-lg bg-components-input-bg-normal', - isHovered && 'bg-state-destructive-hover', - )} + <div + className={cn( + 'grow rounded-lg bg-components-input-bg-normal', + isHovered && 'bg-state-destructive-hover', + )} > <div className="flex items-center p-1"> <div className="w-0 grow"> - {isSubVarSelect - ? ( - <Select - value={selectedSubVarOption?.value ?? null} - onValueChange={value => value && handleSubVarKeyChange(value)} - > - <SelectTrigger - render={<div />} - nativeButton={false} - className="h-6 border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden" + {isSubVarSelect ? ( + <Select + value={selectedSubVarOption?.value ?? null} + onValueChange={(value) => value && handleSubVarKeyChange(value)} + > + <SelectTrigger + render={<div />} + nativeButton={false} + className="h-6 border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden" + > + {selectedSubVarOption ? ( + <div className="flex cursor-pointer justify-start"> + <div className="inline-flex h-6 max-w-full items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 text-text-accent shadow-xs"> + <Variable02 className="size-3.5 shrink-0 text-text-accent" /> + <div className="ml-0.5 truncate system-xs-medium"> + {selectedSubVarOption.name} + </div> + </div> + </div> + ) : ( + <div className="text-left system-sm-regular text-components-input-text-placeholder"> + {t(($) => $['placeholder.select'], { ns: 'common' })} + </div> + )} + </SelectTrigger> + <SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1"> + {subVarOptions.map((option) => ( + <SelectItem + key={option.value} + value={option.value} + className="h-8 py-0 pr-5 pl-1" > - {selectedSubVarOption - ? ( - <div className="flex cursor-pointer justify-start"> - <div className="inline-flex h-6 max-w-full items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 text-text-accent shadow-xs"> - <Variable02 className="size-3.5 shrink-0 text-text-accent" /> - <div className="ml-0.5 truncate system-xs-medium">{selectedSubVarOption.name}</div> - </div> - </div> - ) - : <div className="text-left system-sm-regular text-components-input-text-placeholder">{t($ => $['placeholder.select'], { ns: 'common' })}</div>} - </SelectTrigger> - <SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1"> - {subVarOptions.map(option => ( - <SelectItem key={option.value} value={option.value} className="h-8 py-0 pr-5 pl-1"> - <div className="flex h-6 items-center justify-between"> - <div className="flex h-full items-center"> - <Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" /> - <SelectItemText className="mr-0 px-0 system-sm-medium text-text-secondary">{option.name}</SelectItemText> - </div> - </div> - </SelectItem> - ))} - </SelectContent> - </Select> - ) - : ( - <ConditionVarSelector - open={open} - onOpenChange={setOpen} - valueSelector={condition.variable_selector || []} - varType={condition.varType} - availableNodes={availableNodes} - nodesOutputVars={nodesOutputVars} - onChange={handleVarChange} - /> - )} - + <div className="flex h-6 items-center justify-between"> + <div className="flex h-full items-center"> + <Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" /> + <SelectItemText className="mr-0 px-0 system-sm-medium text-text-secondary"> + {option.name} + </SelectItemText> + </div> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <ConditionVarSelector + open={open} + onOpenChange={setOpen} + valueSelector={condition.variable_selector || []} + varType={condition.varType} + availableNodes={availableNodes} + nodesOutputVars={nodesOutputVars} + onChange={handleVarChange} + /> + )} </div> <div className="mx-1 h-3 w-px bg-divider-regular"></div> <ConditionOperator @@ -328,8 +379,10 @@ const ConditionItem = ({ file={fileAttr} /> </div> - { - !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType !== VarType.number && !showBooleanInput && ( + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && + !isNotInput && + condition.varType !== VarType.number && + !showBooleanInput && ( <div className="max-h-[100px] overflow-y-auto border-t border-t-divider-subtle px-2 py-1"> <ConditionInput disabled={disabled} @@ -339,20 +392,17 @@ const ConditionItem = ({ availableNodes={availableNodes} /> </div> - ) - } - { - !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && showBooleanInput && ( + )} + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && + !isNotInput && + showBooleanInput && ( <div className="p-1"> - <BoolValue - value={condition.value as boolean} - onChange={handleUpdateConditionValue} - /> + <BoolValue value={condition.value as boolean} onChange={handleUpdateConditionValue} /> </div> - ) - } - { - !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType === VarType.number && ( + )} + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && + !isNotInput && + condition.varType === VarType.number && ( <div className="border-t border-t-divider-subtle px-2 py-1 pt-[3px]"> <ConditionNumberInput numberVarType={condition.numberVarType} @@ -364,47 +414,47 @@ const ConditionItem = ({ unit={fileAttr?.key === 'size' ? 'Byte' : undefined} /> </div> - ) - } - { - !comparisonOperatorNotRequireValue(condition.comparison_operator) && isSelect && ( - <div className="border-t border-t-divider-subtle"> - <Select value={selectedSelectOption?.value ?? null} onValueChange={value => value && handleUpdateConditionValue(value)}> - <SelectTrigger className="h-8 rounded-t-none border-0 px-2 text-xs hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal"> - {selectedSelectOption?.name ?? t($ => $['placeholder.select'], { ns: 'common' })} - </SelectTrigger> - <SelectContent> - {selectOptions.map(option => ( - <SelectItem key={option.value} value={option.value} className="text-xs"> - <SelectItemText>{option.name}</SelectItemText> - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - ) - } - { - !comparisonOperatorNotRequireValue(condition.comparison_operator) && isSubVariable && ( - <div className="p-1"> - <ConditionWrap - isSubVariable - caseId={caseId} - conditionId={conditionId} - readOnly={!!disabled} - cases={condition.sub_variable_condition ? [condition.sub_variable_condition] : []} - handleAddSubVariableCondition={onAddSubVariableCondition} - handleRemoveSubVariableCondition={onRemoveSubVariableCondition} - handleUpdateSubVariableCondition={onUpdateSubVariableCondition} - handleToggleSubVariableConditionLogicalOperator={onToggleSubVariableConditionLogicalOperator} - nodeId={nodeId} - nodesOutputVars={nodesOutputVars} - availableNodes={availableNodes} - filterVar={filterVar} - /> - </div> - ) - } + )} + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && isSelect && ( + <div className="border-t border-t-divider-subtle"> + <Select + value={selectedSelectOption?.value ?? null} + onValueChange={(value) => value && handleUpdateConditionValue(value)} + > + <SelectTrigger className="h-8 rounded-t-none border-0 px-2 text-xs hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal"> + {selectedSelectOption?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })} + </SelectTrigger> + <SelectContent> + {selectOptions.map((option) => ( + <SelectItem key={option.value} value={option.value} className="text-xs"> + <SelectItemText>{option.name}</SelectItemText> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && isSubVariable && ( + <div className="p-1"> + <ConditionWrap + isSubVariable + caseId={caseId} + conditionId={conditionId} + readOnly={!!disabled} + cases={condition.sub_variable_condition ? [condition.sub_variable_condition] : []} + handleAddSubVariableCondition={onAddSubVariableCondition} + handleRemoveSubVariableCondition={onRemoveSubVariableCondition} + handleUpdateSubVariableCondition={onUpdateSubVariableCondition} + handleToggleSubVariableConditionLogicalOperator={ + onToggleSubVariableConditionLogicalOperator + } + nodeId={nodeId} + nodesOutputVars={nodesOutputVars} + availableNodes={availableNodes} + filterVar={filterVar} + /> + </div> + )} </div> <div className="mt-1 ml-1 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive" diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx index 357450fa40c640..23a61a2333ae76 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx @@ -10,9 +10,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { RiArrowDownSLine } from '@remixicon/react' -import { - useMemo, -} from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { getOperators, isComparisonOperatorNeedTranslate } from '../../utils' @@ -39,29 +37,31 @@ const ConditionOperator = ({ const options = useMemo(() => { return getOperators(varType, file).map((o) => { return { - label: isComparisonOperatorNeedTranslate(o) ? t($ => $[`${i18nPrefix}.comparisonOperator.${o}`], { ns: 'workflow' }) : o, + label: isComparisonOperatorNeedTranslate(o) + ? t(($) => $[`${i18nPrefix}.comparisonOperator.${o}`], { ns: 'workflow' }) + : o, value: o, } }) }, [t, varType, file]) - const selectedOption = options.find(o => Array.isArray(value) ? o.value === value[0] : o.value === value) + const selectedOption = options.find((o) => + Array.isArray(value) ? o.value === value[0] : o.value === value, + ) return ( <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <Button className={cn('shrink-0', !selectedOption && 'opacity-50', className)} size="small" variant="ghost" disabled={disabled} /> - )} - > - { - selectedOption - ? selectedOption.label - : t($ => $[`${i18nPrefix}.select`], { ns: 'workflow' }) } + > + {selectedOption + ? selectedOption.label + : t(($) => $[`${i18nPrefix}.select`], { ns: 'workflow' })} <RiArrowDownSLine className="ml-1 size-3.5" /> </DropdownMenuTrigger> <DropdownMenuContent @@ -69,22 +69,17 @@ const ConditionOperator = ({ sideOffset={4} popupClassName="rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1" > - <DropdownMenuRadioGroup - value={selectedOption?.value} - onValueChange={onSelect} - > - { - options.map(option => ( - <DropdownMenuRadioItem - key={option.value} - value={option.value} - closeOnClick - className="h-7 rounded-lg px-3 py-1.5 text-[13px] font-medium text-text-secondary" - > - {option.label} - </DropdownMenuRadioItem> - )) - } + <DropdownMenuRadioGroup value={selectedOption?.value} onValueChange={onSelect}> + {options.map((option) => ( + <DropdownMenuRadioItem + key={option.value} + value={option.value} + closeOnClick + className="h-7 rounded-lg px-3 py-1.5 text-[13px] font-medium text-text-secondary" + > + {option.label} + </DropdownMenuRadioItem> + ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx index b1582be22dc218..4cc11f7bc7fef6 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx @@ -1,9 +1,11 @@ -import type { Node, NodeOutPutVar, ValueSelector, Var, VarType } from '@/app/components/workflow/types' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import type { + Node, + NodeOutPutVar, + ValueSelector, + Var, + VarType, +} from '@/app/components/workflow/types' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' @@ -29,7 +31,7 @@ const ConditionVarSelector = ({ return ( <Popover open={open} onOpenChange={onOpenChange}> <PopoverTrigger - render={( + render={ <div className="w-full cursor-pointer"> <VariableTag valueSelector={valueSelector} @@ -38,7 +40,7 @@ const ConditionVarSelector = ({ isShort /> </div> - )} + } /> <PopoverContent placement="bottom-start" @@ -46,11 +48,7 @@ const ConditionVarSelector = ({ popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > <div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg"> - <VarReferenceVars - vars={nodesOutputVars} - isSupportFileVar - onChange={onChange} - /> + <VarReferenceVars vars={nodesOutputVars} isSupportFileVar onChange={onChange} /> </div> </PopoverContent> </Popover> diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/index.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/index.tsx index b17e292ceebe1e..ecedd9aa447beb 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/index.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/index.tsx @@ -1,17 +1,18 @@ -import type { CaseItem, HandleAddSubVariableCondition, HandleRemoveCondition, handleRemoveSubVariableCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition } from '../../types' import type { - Node, - NodeOutPutVar, - Var, -} from '@/app/components/workflow/types' + CaseItem, + HandleAddSubVariableCondition, + HandleRemoveCondition, + handleRemoveSubVariableCondition, + HandleToggleConditionLogicalOperator, + HandleToggleSubVariableConditionLogicalOperator, + HandleUpdateCondition, + HandleUpdateSubVariableCondition, +} from '../../types' +import type { Node, NodeOutPutVar, Var } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { RiLoopLeftLine } from '@remixicon/react' import { useCallback, useMemo } from 'react' -import { - - LogicalOperator, - -} from '../../types' +import { LogicalOperator } from '../../types' import ConditionItem from './condition-item' type ConditionListProps = { @@ -57,74 +58,76 @@ const ConditionList = ({ const { conditions, logical_operator } = caseItem const doToggleConditionLogicalOperator = useCallback(() => { - if (isSubVariable) - onToggleSubVariableConditionLogicalOperator?.(caseId!, conditionId!) - else - onToggleConditionLogicalOperator?.(caseId) - }, [caseId, conditionId, isSubVariable, onToggleConditionLogicalOperator, onToggleSubVariableConditionLogicalOperator]) + if (isSubVariable) onToggleSubVariableConditionLogicalOperator?.(caseId!, conditionId!) + else onToggleConditionLogicalOperator?.(caseId) + }, [ + caseId, + conditionId, + isSubVariable, + onToggleConditionLogicalOperator, + onToggleSubVariableConditionLogicalOperator, + ]) const isValueFieldShort = useMemo(() => { - if (isSubVariable && conditions.length > 1) - return true + if (isSubVariable && conditions.length > 1) return true return false }, [conditions.length, isSubVariable]) const conditionItemClassName = useMemo(() => { - if (!isSubVariable) - return '' - if (conditions.length < 2) - return '' + if (!isSubVariable) return '' + if (conditions.length < 2) return '' return logical_operator === LogicalOperator.and ? 'pl-[51px]' : 'pl-[42px]' }, [conditions.length, isSubVariable, logical_operator]) return ( <div className={cn('relative', !isSubVariable && 'pl-[60px]')}> - { - conditions.length > 1 && ( - <div className={cn( + {conditions.length > 1 && ( + <div + className={cn( 'absolute top-0 bottom-0 left-0 w-[60px]', isSubVariable && logical_operator === LogicalOperator.and && 'left-[-10px]', isSubVariable && logical_operator === LogicalOperator.or && 'left-[-18px]', )} + > + <div className="absolute top-4 bottom-4 left-[46px] w-2.5 rounded-l-[8px] border border-r-0 border-divider-deep"></div> + <div className="absolute top-1/2 right-0 h-[29px] w-4 -translate-y-1/2 bg-components-panel-bg"></div> + <div + className="absolute top-1/2 right-1 flex h-[21px] -translate-y-1/2 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1 text-[10px] font-semibold text-text-accent-secondary shadow-xs select-none" + onClick={doToggleConditionLogicalOperator} > - <div className="absolute top-4 bottom-4 left-[46px] w-2.5 rounded-l-[8px] border border-r-0 border-divider-deep"></div> - <div className="absolute top-1/2 right-0 h-[29px] w-4 -translate-y-1/2 bg-components-panel-bg"></div> - <div - className="absolute top-1/2 right-1 flex h-[21px] -translate-y-1/2 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1 text-[10px] font-semibold text-text-accent-secondary shadow-xs select-none" - onClick={doToggleConditionLogicalOperator} - > - {logical_operator.toUpperCase()} - <RiLoopLeftLine className="ml-0.5 size-3" /> - </div> + {logical_operator.toUpperCase()} + <RiLoopLeftLine className="ml-0.5 size-3" /> </div> - ) - } - { - caseItem.conditions.map(condition => ( - <ConditionItem - key={condition.id} - className={conditionItemClassName} - disabled={disabled} - caseId={caseId} - conditionId={isSubVariable ? conditionId! : condition.id} - condition={condition} - isValueFieldShort={isValueFieldShort} - onUpdateCondition={onUpdateCondition} - onRemoveCondition={onRemoveCondition} - onAddSubVariableCondition={onAddSubVariableCondition} - onRemoveSubVariableCondition={onRemoveSubVariableCondition} - onUpdateSubVariableCondition={onUpdateSubVariableCondition} - onToggleSubVariableConditionLogicalOperator={onToggleSubVariableConditionLogicalOperator} - nodeId={nodeId} - nodesOutputVars={nodesOutputVars} - availableNodes={availableNodes} - filterVar={filterVar} - numberVariables={numberVariables} - file={varsIsVarFileAttribute[condition.id] ? { key: (condition.variable_selector || []).slice(-1)[0]! } : undefined} - isSubVariableKey={isSubVariable} - /> - )) - } + </div> + )} + {caseItem.conditions.map((condition) => ( + <ConditionItem + key={condition.id} + className={conditionItemClassName} + disabled={disabled} + caseId={caseId} + conditionId={isSubVariable ? conditionId! : condition.id} + condition={condition} + isValueFieldShort={isValueFieldShort} + onUpdateCondition={onUpdateCondition} + onRemoveCondition={onRemoveCondition} + onAddSubVariableCondition={onAddSubVariableCondition} + onRemoveSubVariableCondition={onRemoveSubVariableCondition} + onUpdateSubVariableCondition={onUpdateSubVariableCondition} + onToggleSubVariableConditionLogicalOperator={onToggleSubVariableConditionLogicalOperator} + nodeId={nodeId} + nodesOutputVars={nodesOutputVars} + availableNodes={availableNodes} + filterVar={filterVar} + numberVariables={numberVariables} + file={ + varsIsVarFileAttribute[condition.id] + ? { key: (condition.variable_selector || []).slice(-1)[0]! } + : undefined + } + isSubVariableKey={isSubVariable} + /> + ))} </div> ) } diff --git a/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx b/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx index b19c0a295f83c6..c961a590997d1a 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx @@ -1,7 +1,4 @@ -import type { - NodeOutPutVar, - ValueSelector, -} from '@/app/components/workflow/types' +import type { NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { @@ -11,19 +8,11 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { capitalize } from 'es-toolkit/string' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' @@ -32,10 +21,7 @@ import { variableTransformer } from '@/app/components/workflow/utils' import VariableTag from '../../_base/components/variable-tag' import { VarType as NumberVarType } from '../../tool/types' -const options = [ - NumberVarType.variable, - NumberVarType.constant, -] +const options = [NumberVarType.variable, NumberVarType.constant] type ConditionNumberInputProps = { numberVarType?: NumberVarType @@ -58,31 +44,20 @@ const ConditionNumberInput = ({ const { t } = useTranslation() const [numberVarTypeVisible, setNumberVarTypeVisible] = useState(false) const [variableSelectorVisible, setVariableSelectorVisible] = useState(false) - const [isFocus, { - setTrue: setFocus, - setFalse: setBlur, - }] = useBoolean() + const [isFocus, { setTrue: setFocus, setFalse: setBlur }] = useBoolean() - const handleSelectVariable = useCallback((valueSelector: ValueSelector) => { - onValueChange(variableTransformer(valueSelector) as string) - setVariableSelectorVisible(false) - }, [onValueChange]) + const handleSelectVariable = useCallback( + (valueSelector: ValueSelector) => { + onValueChange(variableTransformer(valueSelector) as string) + setVariableSelectorVisible(false) + }, + [onValueChange], + ) return ( <div className="flex cursor-pointer items-center"> - <DropdownMenu - open={numberVarTypeVisible} - onOpenChange={setNumberVarTypeVisible} - > - <DropdownMenuTrigger - render={( - <Button - className="shrink-0" - variant="ghost" - size="small" - /> - )} - > + <DropdownMenu open={numberVarTypeVisible} onOpenChange={setNumberVarTypeVisible}> + <DropdownMenuTrigger render={<Button className="shrink-0" variant="ghost" size="small" />}> {capitalize(numberVarType)} <RiArrowDownSLine className="ml-px size-3.5" /> </DropdownMenuTrigger> @@ -91,90 +66,82 @@ const ConditionNumberInput = ({ sideOffset={2} popupClassName="w-[112px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1" > - <DropdownMenuRadioGroup - value={numberVarType} - onValueChange={onNumberVarTypeChange} - > - { - options.map(option => ( - <DropdownMenuRadioItem - key={option} - value={option} - closeOnClick - className={cn( - 'h-7 rounded-md px-3', - 'text-[13px] font-medium text-text-secondary', - numberVarType === option && 'bg-state-base-hover', - )} - > - {capitalize(option)} - </DropdownMenuRadioItem> - )) - } + <DropdownMenuRadioGroup value={numberVarType} onValueChange={onNumberVarTypeChange}> + {options.map((option) => ( + <DropdownMenuRadioItem + key={option} + value={option} + closeOnClick + className={cn( + 'h-7 rounded-md px-3', + 'text-[13px] font-medium text-text-secondary', + numberVarType === option && 'bg-state-base-hover', + )} + > + {capitalize(option)} + </DropdownMenuRadioItem> + ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> <div className="mx-1 h-4 w-px bg-divider-regular"></div> <div className="ml-0.5 w-0 grow"> - { - numberVarType === NumberVarType.variable && ( - <Popover - open={variableSelectorVisible} - onOpenChange={setVariableSelectorVisible} + {numberVarType === NumberVarType.variable && ( + <Popover open={variableSelectorVisible} onOpenChange={setVariableSelectorVisible}> + <PopoverTrigger nativeButton={false} render={<div className="w-full" />}> + {value && ( + <VariableTag + valueSelector={variableTransformer(value) as string[]} + varType={VarType.number} + isShort={isShort} + /> + )} + {!value && ( + <div className="flex h-6 items-center p-1 text-[13px] text-components-input-text-placeholder"> + <Variable02 className="mr-1 size-4 shrink-0" /> + <div className="w-0 grow truncate"> + {t(($) => $['nodes.ifElse.selectVariable'], { ns: 'workflow' })} + </div> + </div> + )} + </PopoverTrigger> + <PopoverContent + placement="bottom-start" + sideOffset={2} + popupClassName="border-none bg-transparent shadow-none" > - <PopoverTrigger - nativeButton={false} - render={<div className="w-full" />} - > - { - value && ( - <VariableTag - valueSelector={variableTransformer(value) as string[]} - varType={VarType.number} - isShort={isShort} - /> - ) - } - { - !value && ( - <div className="flex h-6 items-center p-1 text-[13px] text-components-input-text-placeholder"> - <Variable02 className="mr-1 size-4 shrink-0" /> - <div className="w-0 grow truncate">{t($ => $['nodes.ifElse.selectVariable'], { ns: 'workflow' })}</div> - </div> - ) - } - </PopoverTrigger> - <PopoverContent - placement="bottom-start" - sideOffset={2} - popupClassName="border-none bg-transparent shadow-none" + <div + className={cn( + 'w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pt-1 shadow-lg', + isShort && 'w-[200px]', + )} > - <div className={cn('w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pt-1 shadow-lg', isShort && 'w-[200px]')}> - <VarReferenceVars - vars={variables} - onChange={handleSelectVariable} - /> - </div> - </PopoverContent> - </Popover> - ) - } - { - numberVarType === NumberVarType.constant && ( - <div className="relative"> - <input - className={cn('block w-full appearance-none bg-transparent px-2 text-[13px] text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder', unit && 'pr-6')} - type="number" - value={value} - onChange={e => onValueChange(e.target.value)} - placeholder={t($ => $['nodes.ifElse.enterValue'], { ns: 'workflow' }) || ''} - onFocus={setFocus} - onBlur={setBlur} - /> - {!isFocus && unit && <div className="absolute top-[50%] right-2 translate-y-[-50%] system-sm-regular text-text-tertiary">{unit}</div>} - </div> - ) - } + <VarReferenceVars vars={variables} onChange={handleSelectVariable} /> + </div> + </PopoverContent> + </Popover> + )} + {numberVarType === NumberVarType.constant && ( + <div className="relative"> + <input + className={cn( + 'block w-full appearance-none bg-transparent px-2 text-[13px] text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder', + unit && 'pr-6', + )} + type="number" + value={value} + onChange={(e) => onValueChange(e.target.value)} + placeholder={t(($) => $['nodes.ifElse.enterValue'], { ns: 'workflow' }) || ''} + onFocus={setFocus} + onBlur={setBlur} + /> + {!isFocus && unit && ( + <div className="absolute top-[50%] right-2 translate-y-[-50%] system-sm-regular text-text-tertiary"> + {unit} + </div> + )} + </div> + )} </div> </div> ) diff --git a/web/app/components/workflow/nodes/if-else/components/condition-value.tsx b/web/app/components/workflow/nodes/if-else/components/condition-value.tsx index 1472a82d3b230b..b52d131a61da14 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-value.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-value.tsx @@ -1,24 +1,13 @@ -import type { - CommonNodeType, - Node, -} from '@/app/components/workflow/types' -import { - memo, - useMemo, -} from 'react' +import type { CommonNodeType, Node } from '@/app/components/workflow/types' +import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import { isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' -import { - VariableLabelInText, -} from '@/app/components/workflow/nodes/_base/components/variable/variable-label' +import { VariableLabelInText } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { isExceptionVariable } from '@/app/components/workflow/utils' import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from '../../constants' import { ComparisonOperator } from '../types' -import { - comparisonOperatorNotRequireValue, - isComparisonOperatorNeedTranslate, -} from '../utils' +import { comparisonOperatorNotRequireValue, isComparisonOperatorNeedTranslate } from '../utils' type ConditionValueProps = { variableSelector: string[] @@ -26,33 +15,34 @@ type ConditionValueProps = { operator: ComparisonOperator value: string | string[] | boolean } -const ConditionValue = ({ - variableSelector, - labelName, - operator, - value, -}: ConditionValueProps) => { +const ConditionValue = ({ variableSelector, labelName, operator, value }: ConditionValueProps) => { const { t } = useTranslation() const nodes = useNodes() - const variableName = labelName || (isSystemVar(variableSelector) ? variableSelector.slice(0).join('.') : variableSelector.slice(1).join('.')) - const operatorName = isComparisonOperatorNeedTranslate(operator) ? t($ => $[`nodes.ifElse.comparisonOperator.${operator}`], { ns: 'workflow' }) : operator + const variableName = + labelName || + (isSystemVar(variableSelector) + ? variableSelector.slice(0).join('.') + : variableSelector.slice(1).join('.')) + const operatorName = isComparisonOperatorNeedTranslate(operator) + ? t(($) => $[`nodes.ifElse.comparisonOperator.${operator}`], { ns: 'workflow' }) + : operator const notHasValue = comparisonOperatorNotRequireValue(operator) - const node: Node<CommonNodeType> | undefined = nodes.find(n => n.id === variableSelector[0]) as Node<CommonNodeType> + const node: Node<CommonNodeType> | undefined = nodes.find( + (n) => n.id === variableSelector[0], + ) as Node<CommonNodeType> const isException = isExceptionVariable(variableName, node?.data.type) const formatValue = useMemo(() => { - if (notHasValue) - return '' + if (notHasValue) return '' - if (Array.isArray(value)) // transfer method + if (Array.isArray(value)) + // transfer method return value[0] - if (value === true || value === false) - return value ? 'True' : 'False' + if (value === true || value === false) return value ? 'True' : 'False' return value.replace(/\{\{#([^#]*)#\}\}/g, (a, b) => { const arr: string[] = b.split('.') - if (isSystemVar(arr)) - return `{{${b}}}` + if (isSystemVar(arr)) return `{{${b}}}` return `{{${arr.slice(1).join('.')}}}` }) @@ -61,15 +51,19 @@ const ConditionValue = ({ const isSelect = operator === ComparisonOperator.in || operator === ComparisonOperator.notIn const selectName = useMemo(() => { if (isSelect) { - const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(value) ? value[0] : value))[0] + const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter( + (item) => item.value === (Array.isArray(value) ? value[0] : value), + )[0] return name - ? t($ => $[`nodes.ifElse.optionName.${name.i18nKey}`], { ns: 'workflow' }).replace(/\{\{#([^#]*)#\}\}/g, (a, b) => { - const arr: string[] = b.split('.') - if (isSystemVar(arr)) - return `{{${b}}}` + ? t(($) => $[`nodes.ifElse.optionName.${name.i18nKey}`], { ns: 'workflow' }).replace( + /\{\{#([^#]*)#\}\}/g, + (a, b) => { + const arr: string[] = b.split('.') + if (isSystemVar(arr)) return `{{${b}}}` - return `{{${arr.slice(1).join('.')}}}` - }) + return `{{${arr.slice(1).join('.')}}}` + }, + ) : '' } return '' @@ -85,17 +79,14 @@ const ConditionValue = ({ isExceptionVariable={isException} notShowFullPath={false} /> - <div - className="mx-1 shrink-0 text-xs font-medium text-text-primary" - title={operatorName} - > + <div className="mx-1 shrink-0 text-xs font-medium text-text-primary" title={operatorName}> {operatorName} </div> - { - !notHasValue && ( - <div className="grow truncate px-1.5 text-xs/6 text-text-secondary" title={formatValue}>{isSelect ? selectName : formatValue}</div> - ) - } + {!notHasValue && ( + <div className="grow truncate px-1.5 text-xs/6 text-text-secondary" title={formatValue}> + {isSelect ? selectName : formatValue} + </div> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx b/web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx index b668d611efc1e0..9543ae1850aa3b 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx @@ -1,15 +1,27 @@ 'use client' import type { FC } from 'react' import type { Node, NodeOutPutVar, Var } from '../../../types' -import type { CaseItem, HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, handleRemoveSubVariableCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition } from '../types' +import type { + CaseItem, + HandleAddCondition, + HandleAddSubVariableCondition, + HandleRemoveCondition, + handleRemoveSubVariableCondition, + HandleToggleConditionLogicalOperator, + HandleToggleSubVariableConditionLogicalOperator, + HandleUpdateCondition, + HandleUpdateSubVariableCondition, +} from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { - RiAddLine, - RiDeleteBinLine, - RiDraggable, -} from '@remixicon/react' + Select, + SelectContent, + SelectItem, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' +import { RiAddLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react' import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback, useState } from 'react' @@ -77,7 +89,7 @@ const ConditionWrap: FC<Props> = ({ return varPayload.type === VarType.number }, []) - const subVarOptions = SUB_VARIABLES.map(item => ({ + const subVarOptions = SUB_VARIABLES.map((item) => ({ name: item, value: item, })) @@ -85,156 +97,146 @@ const ConditionWrap: FC<Props> = ({ return ( <> <ReactSortable - list={cases.map(caseItem => ({ ...caseItem, id: caseItem.case_id }))} + list={cases.map((caseItem) => ({ ...caseItem, id: caseItem.case_id }))} setList={handleSortCase} handle=".handle" ghostClass="bg-components-panel-bg" animation={150} disabled={readOnly || isSubVariable} > - { - cases.map((item, index) => ( - <div key={item.case_id}> - <div - className={cn( - 'group relative rounded-[10px] bg-components-panel-bg', - willDeleteCaseId === item.case_id && 'bg-state-destructive-hover', - !isSubVariable && 'min-h-[40px] px-3 py-1', - isSubVariable && 'px-1 py-2', - )} - > - {!isSubVariable && ( - <> - <RiDraggable className={cn( + {cases.map((item, index) => ( + <div key={item.case_id}> + <div + className={cn( + 'group relative rounded-[10px] bg-components-panel-bg', + willDeleteCaseId === item.case_id && 'bg-state-destructive-hover', + !isSubVariable && 'min-h-[40px] px-3 py-1', + isSubVariable && 'px-1 py-2', + )} + > + {!isSubVariable && ( + <> + <RiDraggable + className={cn( 'handle absolute top-2 left-1 hidden size-3 cursor-pointer text-text-quaternary', casesLength > 1 && 'group-hover:block', )} - /> - <div className={cn( + /> + <div + className={cn( 'absolute left-4 text-[13px] leading-4 font-semibold text-text-secondary', casesLength === 1 ? 'top-2.5' : 'top-1', )} - > - { - index === 0 ? 'IF' : 'ELIF' - } - { - casesLength > 1 && ( - <div className="text-[10px] font-medium text-text-tertiary"> - CASE - {index + 1} - </div> - ) - } - </div> - </> - )} + > + {index === 0 ? 'IF' : 'ELIF'} + {casesLength > 1 && ( + <div className="text-[10px] font-medium text-text-tertiary"> + CASE + {index + 1} + </div> + )} + </div> + </> + )} - { - !!item.conditions.length && ( - <div className="mb-2"> - <ConditionList - disabled={readOnly} - caseItem={item} - caseId={isSubVariable ? caseId! : item.case_id} - conditionId={conditionId} - onUpdateCondition={handleUpdateCondition} - onRemoveCondition={handleRemoveCondition} - onToggleConditionLogicalOperator={handleToggleConditionLogicalOperator} - nodeId={id} - nodesOutputVars={nodesOutputVars} - availableNodes={availableNodes} - filterVar={filterVar} - numberVariables={getAvailableVars(id, '', filterNumberVar)} - varsIsVarFileAttribute={varsIsVarFileAttribute} - onAddSubVariableCondition={handleAddSubVariableCondition} - onRemoveSubVariableCondition={handleRemoveSubVariableCondition} - onUpdateSubVariableCondition={handleUpdateSubVariableCondition} - onToggleSubVariableConditionLogicalOperator={handleToggleSubVariableConditionLogicalOperator} - isSubVariable={isSubVariable} - /> - </div> - ) - } + {!!item.conditions.length && ( + <div className="mb-2"> + <ConditionList + disabled={readOnly} + caseItem={item} + caseId={isSubVariable ? caseId! : item.case_id} + conditionId={conditionId} + onUpdateCondition={handleUpdateCondition} + onRemoveCondition={handleRemoveCondition} + onToggleConditionLogicalOperator={handleToggleConditionLogicalOperator} + nodeId={id} + nodesOutputVars={nodesOutputVars} + availableNodes={availableNodes} + filterVar={filterVar} + numberVariables={getAvailableVars(id, '', filterNumberVar)} + varsIsVarFileAttribute={varsIsVarFileAttribute} + onAddSubVariableCondition={handleAddSubVariableCondition} + onRemoveSubVariableCondition={handleRemoveSubVariableCondition} + onUpdateSubVariableCondition={handleUpdateSubVariableCondition} + onToggleSubVariableConditionLogicalOperator={ + handleToggleSubVariableConditionLogicalOperator + } + isSubVariable={isSubVariable} + /> + </div> + )} - <div className={cn( + <div + className={cn( 'flex items-center justify-between pr-[30px]', !item.conditions.length && !isSubVariable && 'mt-1', !item.conditions.length && isSubVariable && 'mt-2', !isSubVariable && 'pl-[60px]', )} - > - {isSubVariable - ? ( - <Select - value={null} - disabled={readOnly} - onValueChange={value => value && handleAddSubVariableCondition?.(caseId!, conditionId!, value)} - > - <SelectTrigger - render={<div />} - nativeButton={false} - className="border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden" - > - <Button - size="small" - disabled={readOnly} - > - <RiAddLine className="mr-1 size-3.5" /> - {t($ => $['nodes.ifElse.addSubVariable'], { ns: 'workflow' })} - </Button> - </SelectTrigger> - <SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1"> - {subVarOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - <SelectItemText>{option.name}</SelectItemText> - </SelectItem> - ))} - </SelectContent> - </Select> - ) - : ( - <ConditionAdd - disabled={readOnly} - caseId={item.case_id} - variables={getAvailableVars(id, '', filterVar)} - onSelectVariable={handleAddCondition!} - /> - )} - - { - ((index === 0 && casesLength > 1) || (index > 0)) && ( - <Button - className="hover:bg-components-button-destructive-ghost-bg-hover hover:text-components-button-destructive-ghost-text" - size="small" - variant="ghost" - disabled={readOnly} - onClick={() => handleRemoveCase?.(item.case_id)} - onMouseEnter={() => setWillDeleteCaseId(item.case_id)} - onMouseLeave={() => setWillDeleteCaseId('')} - > - <RiDeleteBinLine className="mr-1 size-3.5" /> - {t($ => $['operation.remove'], { ns: 'common' })} + > + {isSubVariable ? ( + <Select + value={null} + disabled={readOnly} + onValueChange={(value) => + value && handleAddSubVariableCondition?.(caseId!, conditionId!, value) + } + > + <SelectTrigger + render={<div />} + nativeButton={false} + className="border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden" + > + <Button size="small" disabled={readOnly}> + <RiAddLine className="mr-1 size-3.5" /> + {t(($) => $['nodes.ifElse.addSubVariable'], { ns: 'workflow' })} </Button> - ) - } - </div> + </SelectTrigger> + <SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1"> + {subVarOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + <SelectItemText>{option.name}</SelectItemText> + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <ConditionAdd + disabled={readOnly} + caseId={item.case_id} + variables={getAvailableVars(id, '', filterVar)} + onSelectVariable={handleAddCondition!} + /> + )} + + {((index === 0 && casesLength > 1) || index > 0) && ( + <Button + className="hover:bg-components-button-destructive-ghost-bg-hover hover:text-components-button-destructive-ghost-text" + size="small" + variant="ghost" + disabled={readOnly} + onClick={() => handleRemoveCase?.(item.case_id)} + onMouseEnter={() => setWillDeleteCaseId(item.case_id)} + onMouseLeave={() => setWillDeleteCaseId('')} + > + <RiDeleteBinLine className="mr-1 size-3.5" /> + {t(($) => $['operation.remove'], { ns: 'common' })} + </Button> + )} </div> - {!isSubVariable && ( - <div className="mx-3 my-2 h-px bg-divider-subtle"></div> - )} </div> - )) - } + {!isSubVariable && <div className="mx-3 my-2 h-px bg-divider-subtle"></div>} + </div> + ))} </ReactSortable> - {(cases.length === 0) && ( + {cases.length === 0 && ( <Button size="small" disabled={readOnly} onClick={() => handleAddSubVariableCondition?.(caseId!, conditionId!)} > <RiAddLine className="mr-1 size-3.5" /> - {t($ => $['nodes.ifElse.addSubVariable'], { ns: 'workflow' })} + {t(($) => $['nodes.ifElse.addSubVariable'], { ns: 'workflow' })} </Button> )} </> diff --git a/web/app/components/workflow/nodes/if-else/default.ts b/web/app/components/workflow/nodes/if-else/default.ts index e0022e2325d789..35775559f2b836 100644 --- a/web/app/components/workflow/nodes/if-else/default.ts +++ b/web/app/components/workflow/nodes/if-else/default.ts @@ -41,34 +41,56 @@ const nodeDefault: NodeDefault<IfElseNodeType> = { let errorMessages = '' const { cases } = payload if (!cases || cases.length === 0) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: 'IF' }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: 'IF' }) cases.forEach((caseItem, index) => { if (!caseItem.conditions.length) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: index === 0 ? 'IF' : 'ELIF' }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: index === 0 ? 'IF' : 'ELIF', + }) caseItem.conditions.forEach((condition) => { - if (!errorMessages && (!condition.variable_selector || condition.variable_selector.length === 0)) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variable`], { ns: 'workflow' }) }) + if ( + !errorMessages && + (!condition.variable_selector || condition.variable_selector.length === 0) + ) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variable`], { ns: 'workflow' }), + }) if (!errorMessages && !condition.comparison_operator) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.ifElse.operator'], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.ifElse.operator'], { ns: 'workflow' }), + }) if (!errorMessages) { if (condition.sub_variable_condition) { const isSet = condition.sub_variable_condition.conditions.every((c) => { - if (!c.comparison_operator) - return false + if (!c.comparison_operator) return false - if (isEmptyRelatedOperator(c.comparison_operator!)) - return true + if (isEmptyRelatedOperator(c.comparison_operator!)) return true - return (c.varType === VarType.boolean || c.varType === VarType.arrayBoolean) ? c.value === undefined : !!c.value + return c.varType === VarType.boolean || c.varType === VarType.arrayBoolean + ? c.value === undefined + : !!c.value }) if (!isSet) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }) }) - } - else { - if (!isEmptyRelatedOperator(condition.comparison_operator!) && ((condition.varType === VarType.boolean || condition.varType === VarType.arrayBoolean) ? condition.value === undefined : !condition.value)) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }), + }) + } else { + if ( + !isEmptyRelatedOperator(condition.comparison_operator!) && + (condition.varType === VarType.boolean || condition.varType === VarType.arrayBoolean + ? condition.value === undefined + : !condition.value) + ) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }), + }) } } }) diff --git a/web/app/components/workflow/nodes/if-else/node.tsx b/web/app/components/workflow/nodes/if-else/node.tsx index 7d6dfd607971fe..a8419368c3cef1 100644 --- a/web/app/components/workflow/nodes/if-else/node.tsx +++ b/web/app/components/workflow/nodes/if-else/node.tsx @@ -18,78 +18,82 @@ const IfElseNode: FC<NodeProps<IfElseNodeType>> = (props) => { const { cases } = data const casesLength = cases.length const checkIsConditionSet = useCallback((condition: Condition) => { - if (!condition.variable_selector || condition.variable_selector.length === 0) - return false + if (!condition.variable_selector || condition.variable_selector.length === 0) return false if (condition.sub_variable_condition) { const isSet = condition.sub_variable_condition.conditions.every((c) => { - if (!c.comparison_operator) - return false + if (!c.comparison_operator) return false - return (c.varType === VarType.boolean || c.varType === VarType.arrayBoolean) ? true : !!c.value + return c.varType === VarType.boolean || c.varType === VarType.arrayBoolean + ? true + : !!c.value }) return isSet - } - else { - if (isEmptyRelatedOperator(condition.comparison_operator!)) - return true - return (condition.varType === VarType.boolean || condition.varType === VarType.arrayBoolean) ? true : !!condition.value + } else { + if (isEmptyRelatedOperator(condition.comparison_operator!)) return true + return condition.varType === VarType.boolean || condition.varType === VarType.arrayBoolean + ? true + : !!condition.value } }, []) const conditionNotSet = ( <div className="flex h-6 items-center space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary"> - {t($ => $[`${i18nPrefix}.conditionNotSetup`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.conditionNotSetup`], { ns: 'workflow' })} </div> ) return ( <div className="px-3"> - { - cases.map((caseItem, index) => ( - <div key={caseItem.case_id}> - <div className="relative flex h-6 items-center px-1"> - <div className="flex w-full items-center justify-between"> - <div className="text-[10px] font-semibold text-text-tertiary"> - {casesLength > 1 && `CASE ${index + 1}`} - </div> - <div className="text-[12px] font-semibold text-text-secondary">{index === 0 ? 'IF' : 'ELIF'}</div> + {cases.map((caseItem, index) => ( + <div key={caseItem.case_id}> + <div className="relative flex h-6 items-center px-1"> + <div className="flex w-full items-center justify-between"> + <div className="text-[10px] font-semibold text-text-tertiary"> + {casesLength > 1 && `CASE ${index + 1}`} + </div> + <div className="text-[12px] font-semibold text-text-secondary"> + {index === 0 ? 'IF' : 'ELIF'} </div> - <NodeSourceHandle - {...props} - handleId={caseItem.case_id} - handleClassName="top-1/2! -right-[21px]! -translate-y-1/2!" - /> - </div> - <div className="space-y-0.5"> - {caseItem.conditions.map((condition, i) => ( - <div key={condition.id} className="relative"> - { - checkIsConditionSet(condition) - ? ( - (!isEmptyRelatedOperator(condition.comparison_operator!) && condition.sub_variable_condition) - ? ( - <ConditionFilesListValue condition={condition} /> - ) - : ( - <ConditionValue - variableSelector={condition.variable_selector!} - operator={condition.comparison_operator!} - value={condition.varType === VarType.boolean ? (!condition.value ? 'False' : condition.value) : condition.value} - /> - ) - - ) - : conditionNotSet - } - {i !== caseItem.conditions.length - 1 && ( - <div className="absolute right-1 bottom-[-10px] z-10 text-[10px] leading-4 font-medium text-text-accent uppercase">{t($ => $[`${i18nPrefix}.${caseItem.logical_operator}`], { ns: 'workflow' })}</div> - )} - </div> - ))} </div> + <NodeSourceHandle + {...props} + handleId={caseItem.case_id} + handleClassName="top-1/2! -right-[21px]! -translate-y-1/2!" + /> + </div> + <div className="space-y-0.5"> + {caseItem.conditions.map((condition, i) => ( + <div key={condition.id} className="relative"> + {checkIsConditionSet(condition) ? ( + !isEmptyRelatedOperator(condition.comparison_operator!) && + condition.sub_variable_condition ? ( + <ConditionFilesListValue condition={condition} /> + ) : ( + <ConditionValue + variableSelector={condition.variable_selector!} + operator={condition.comparison_operator!} + value={ + condition.varType === VarType.boolean + ? !condition.value + ? 'False' + : condition.value + : condition.value + } + /> + ) + ) : ( + conditionNotSet + )} + {i !== caseItem.conditions.length - 1 && ( + <div className="absolute right-1 bottom-[-10px] z-10 text-[10px] leading-4 font-medium text-text-accent uppercase"> + {t(($) => $[`${i18nPrefix}.${caseItem.logical_operator}`], { ns: 'workflow' })} + </div> + )} + </div> + ))} </div> - )) - } + </div> + ))} <div className="relative flex h-6 items-center px-1"> <div className="w-full text-right text-xs font-semibold text-text-secondary">ELSE</div> <NodeSourceHandle diff --git a/web/app/components/workflow/nodes/if-else/panel.tsx b/web/app/components/workflow/nodes/if-else/panel.tsx index fe894bef822017..cc5db31bc613f5 100644 --- a/web/app/components/workflow/nodes/if-else/panel.tsx +++ b/web/app/components/workflow/nodes/if-else/panel.tsx @@ -2,12 +2,8 @@ import type { FC } from 'react' import type { IfElseNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' -import { - RiAddLine, -} from '@remixicon/react' -import { - memo, -} from 'react' +import { RiAddLine } from '@remixicon/react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import Field from '@/app/components/workflow/nodes/_base/components/field' import ConditionWrap from './components/condition-wrap' @@ -15,10 +11,7 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.ifElse' -const Panel: FC<NodePanelProps<IfElseNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<IfElseNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { readOnly, @@ -56,7 +49,9 @@ const Panel: FC<NodePanelProps<IfElseNodeType>> = ({ handleAddSubVariableCondition={handleAddSubVariableCondition} handleRemoveSubVariableCondition={handleRemoveSubVariableCondition} handleUpdateSubVariableCondition={handleUpdateSubVariableCondition} - handleToggleSubVariableConditionLogicalOperator={handleToggleSubVariableConditionLogicalOperator} + handleToggleSubVariableConditionLogicalOperator={ + handleToggleSubVariableConditionLogicalOperator + } nodesOutputVars={nodesOutputVars} availableNodes={availableNodes} varsIsVarFileAttribute={varsIsVarFileAttribute} @@ -74,11 +69,10 @@ const Panel: FC<NodePanelProps<IfElseNodeType>> = ({ </Button> </div> <div className="mx-3 my-2 h-px bg-divider-subtle"></div> - <Field - title={t($ => $[`${i18nPrefix}.else`], { ns: 'workflow' })} - className="px-4 py-2" - > - <div className="text-xs leading-[18px] font-normal text-text-tertiary">{t($ => $[`${i18nPrefix}.elseDescription`], { ns: 'workflow' })}</div> + <Field title={t(($) => $[`${i18nPrefix}.else`], { ns: 'workflow' })} className="px-4 py-2"> + <div className="text-xs leading-[18px] font-normal text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.elseDescription`], { ns: 'workflow' })} + </div> </Field> </div> ) diff --git a/web/app/components/workflow/nodes/if-else/types.ts b/web/app/components/workflow/nodes/if-else/types.ts index 5b9128860e91ab..9e884585f1be8e 100644 --- a/web/app/components/workflow/nodes/if-else/types.ts +++ b/web/app/components/workflow/nodes/if-else/types.ts @@ -1,10 +1,5 @@ import type { VarType as NumberVarType } from '../tool/types' -import type { - CommonNodeType, - ValueSelector, - Var, - VarType, -} from '@/app/components/workflow/types' +import type { CommonNodeType, ValueSelector, Var, VarType } from '@/app/components/workflow/types' export enum LogicalOperator { and = 'and', @@ -60,12 +55,36 @@ export type IfElseNodeType = CommonNodeType & { isInLoop: boolean } -export type HandleAddCondition = (caseId: string, valueSelector: ValueSelector, varItem: Var) => void +export type HandleAddCondition = ( + caseId: string, + valueSelector: ValueSelector, + varItem: Var, +) => void export type HandleRemoveCondition = (caseId: string, conditionId: string) => void -export type HandleUpdateCondition = (caseId: string, conditionId: string, newCondition: Condition) => void +export type HandleUpdateCondition = ( + caseId: string, + conditionId: string, + newCondition: Condition, +) => void export type HandleToggleConditionLogicalOperator = (caseId: string) => void -export type HandleAddSubVariableCondition = (caseId: string, conditionId: string, key?: string) => void -export type handleRemoveSubVariableCondition = (caseId: string, conditionId: string, subConditionId: string) => void -export type HandleUpdateSubVariableCondition = (caseId: string, conditionId: string, subConditionId: string, newSubCondition: Condition) => void -export type HandleToggleSubVariableConditionLogicalOperator = (caseId: string, conditionId: string) => void +export type HandleAddSubVariableCondition = ( + caseId: string, + conditionId: string, + key?: string, +) => void +export type handleRemoveSubVariableCondition = ( + caseId: string, + conditionId: string, + subConditionId: string, +) => void +export type HandleUpdateSubVariableCondition = ( + caseId: string, + conditionId: string, + subConditionId: string, + newSubCondition: Condition, +) => void +export type HandleToggleSubVariableConditionLogicalOperator = ( + caseId: string, + conditionId: string, +) => void diff --git a/web/app/components/workflow/nodes/if-else/use-config.helpers.ts b/web/app/components/workflow/nodes/if-else/use-config.helpers.ts index 6c1b3ec0a3db28..6aea626c9ce5ae 100644 --- a/web/app/components/workflow/nodes/if-else/use-config.helpers.ts +++ b/web/app/components/workflow/nodes/if-else/use-config.helpers.ts @@ -4,10 +4,7 @@ import { produce } from 'immer' import { v4 as uuid4 } from 'uuid' import { VarType } from '../../types' import { LogicalOperator } from './types' -import { - branchNameCorrect, - getOperators, -} from './utils' +import { branchNameCorrect, getOperators } from './utils' export const filterAllVars = () => true @@ -28,12 +25,10 @@ export const getVarsIsVarFileAttribute = ( } const getTargetBranchesWithNewCase = (targetBranches: Branch[] | undefined, caseId: string) => { - if (!targetBranches) - return targetBranches + if (!targetBranches) return targetBranches - const elseCaseIndex = targetBranches.findIndex(branch => branch.id === 'false') - if (elseCaseIndex < 0) - return targetBranches + const elseCaseIndex = targetBranches.findIndex((branch) => branch.id === 'false') + if (elseCaseIndex < 0) return targetBranches return branchNameCorrect([ ...targetBranches.slice(0, elseCaseIndex), @@ -45,45 +40,43 @@ const getTargetBranchesWithNewCase = (targetBranches: Branch[] | undefined, case ]) } -export const addCase = (inputs: IfElseNodeType) => produce(inputs, (draft) => { - if (!draft.cases) - return +export const addCase = (inputs: IfElseNodeType) => + produce(inputs, (draft) => { + if (!draft.cases) return - const caseId = uuid4() - draft.cases.push({ - case_id: caseId, - logical_operator: LogicalOperator.and, - conditions: [], + const caseId = uuid4() + draft.cases.push({ + case_id: caseId, + logical_operator: LogicalOperator.and, + conditions: [], + }) + draft._targetBranches = getTargetBranchesWithNewCase(draft._targetBranches, caseId) }) - draft._targetBranches = getTargetBranchesWithNewCase(draft._targetBranches, caseId) -}) -export const removeCase = ( - inputs: IfElseNodeType, - caseId: string, -) => produce(inputs, (draft) => { - draft.cases = draft.cases?.filter(item => item.case_id !== caseId) +export const removeCase = (inputs: IfElseNodeType, caseId: string) => + produce(inputs, (draft) => { + draft.cases = draft.cases?.filter((item) => item.case_id !== caseId) - if (draft._targetBranches) - draft._targetBranches = branchNameCorrect(draft._targetBranches.filter(branch => branch.id !== caseId)) -}) + if (draft._targetBranches) + draft._targetBranches = branchNameCorrect( + draft._targetBranches.filter((branch) => branch.id !== caseId), + ) + }) -export const sortCases = ( - inputs: IfElseNodeType, - newCases: (CaseItem & { id: string })[], -) => produce(inputs, (draft) => { - draft.cases = newCases.filter(Boolean).map(item => ({ - id: item.id, - case_id: item.case_id, - logical_operator: item.logical_operator, - conditions: item.conditions, - })) - - draft._targetBranches = branchNameCorrect([ - ...newCases.filter(Boolean).map(item => ({ id: item.case_id, name: '' })), - { id: 'false', name: '' }, - ]) -}) +export const sortCases = (inputs: IfElseNodeType, newCases: (CaseItem & { id: string })[]) => + produce(inputs, (draft) => { + draft.cases = newCases.filter(Boolean).map((item) => ({ + id: item.id, + case_id: item.case_id, + logical_operator: item.logical_operator, + conditions: item.conditions, + })) + + draft._targetBranches = branchNameCorrect([ + ...newCases.filter(Boolean).map((item) => ({ id: item.case_id, name: '' })), + { id: 'false', name: '' }, + ]) + }) export const addCondition = ({ inputs, @@ -97,106 +90,101 @@ export const addCondition = ({ valueSelector: string[] variable: Var isVarFileAttribute: boolean -}) => produce(inputs, (draft) => { - const targetCase = draft.cases?.find(item => item.case_id === caseId) - if (!targetCase) - return - - targetCase.conditions.push({ - id: uuid4(), - varType: variable.type, - variable_selector: valueSelector, - comparison_operator: getOperators(variable.type, isVarFileAttribute ? { key: valueSelector.slice(-1)[0]! } : undefined)[0], - value: (variable.type === VarType.boolean || variable.type === VarType.arrayBoolean) ? false : '', +}) => + produce(inputs, (draft) => { + const targetCase = draft.cases?.find((item) => item.case_id === caseId) + if (!targetCase) return + + targetCase.conditions.push({ + id: uuid4(), + varType: variable.type, + variable_selector: valueSelector, + comparison_operator: getOperators( + variable.type, + isVarFileAttribute ? { key: valueSelector.slice(-1)[0]! } : undefined, + )[0], + value: + variable.type === VarType.boolean || variable.type === VarType.arrayBoolean ? false : '', + }) }) -}) -export const removeCondition = ( - inputs: IfElseNodeType, - caseId: string, - conditionId: string, -) => produce(inputs, (draft) => { - const targetCase = draft.cases?.find(item => item.case_id === caseId) - if (targetCase) - targetCase.conditions = targetCase.conditions.filter(item => item.id !== conditionId) -}) +export const removeCondition = (inputs: IfElseNodeType, caseId: string, conditionId: string) => + produce(inputs, (draft) => { + const targetCase = draft.cases?.find((item) => item.case_id === caseId) + if (targetCase) + targetCase.conditions = targetCase.conditions.filter((item) => item.id !== conditionId) + }) export const updateCondition = ( inputs: IfElseNodeType, caseId: string, conditionId: string, nextCondition: Condition, -) => produce(inputs, (draft) => { - const targetCondition = draft.cases - ?.find(item => item.case_id === caseId) - ?.conditions - .find(item => item.id === conditionId) +) => + produce(inputs, (draft) => { + const targetCondition = draft.cases + ?.find((item) => item.case_id === caseId) + ?.conditions.find((item) => item.id === conditionId) - if (targetCondition) - Object.assign(targetCondition, nextCondition) -}) + if (targetCondition) Object.assign(targetCondition, nextCondition) + }) -export const toggleConditionLogicalOperator = ( - inputs: IfElseNodeType, - caseId: string, -) => produce(inputs, (draft) => { - const targetCase = draft.cases?.find(item => item.case_id === caseId) - if (!targetCase) - return +export const toggleConditionLogicalOperator = (inputs: IfElseNodeType, caseId: string) => + produce(inputs, (draft) => { + const targetCase = draft.cases?.find((item) => item.case_id === caseId) + if (!targetCase) return - targetCase.logical_operator = targetCase.logical_operator === LogicalOperator.and - ? LogicalOperator.or - : LogicalOperator.and -}) + targetCase.logical_operator = + targetCase.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and + }) export const addSubVariableCondition = ( inputs: IfElseNodeType, caseId: string, conditionId: string, key?: string, -) => produce(inputs, (draft) => { - const condition = draft.cases - ?.find(item => item.case_id === caseId) - ?.conditions - .find(item => item.id === conditionId) - - if (!condition) - return - - if (!condition.sub_variable_condition) { - condition.sub_variable_condition = { - case_id: uuid4(), - logical_operator: LogicalOperator.and, - conditions: [], +) => + produce(inputs, (draft) => { + const condition = draft.cases + ?.find((item) => item.case_id === caseId) + ?.conditions.find((item) => item.id === conditionId) + + if (!condition) return + + if (!condition.sub_variable_condition) { + condition.sub_variable_condition = { + case_id: uuid4(), + logical_operator: LogicalOperator.and, + conditions: [], + } } - } - - condition.sub_variable_condition.conditions.push({ - id: uuid4(), - key: key || '', - varType: VarType.string, - comparison_operator: undefined, - value: '', + + condition.sub_variable_condition.conditions.push({ + id: uuid4(), + key: key || '', + varType: VarType.string, + comparison_operator: undefined, + value: '', + }) }) -}) export const removeSubVariableCondition = ( inputs: IfElseNodeType, caseId: string, conditionId: string, subConditionId: string, -) => produce(inputs, (draft) => { - const subVariableCondition = draft.cases - ?.find(item => item.case_id === caseId) - ?.conditions - .find(item => item.id === conditionId) - ?.sub_variable_condition +) => + produce(inputs, (draft) => { + const subVariableCondition = draft.cases + ?.find((item) => item.case_id === caseId) + ?.conditions.find((item) => item.id === conditionId)?.sub_variable_condition - if (!subVariableCondition) - return + if (!subVariableCondition) return - subVariableCondition.conditions = subVariableCondition.conditions.filter(item => item.id !== subConditionId) -}) + subVariableCondition.conditions = subVariableCondition.conditions.filter( + (item) => item.id !== subConditionId, + ) + }) export const updateSubVariableCondition = ( inputs: IfElseNodeType, @@ -204,34 +192,30 @@ export const updateSubVariableCondition = ( conditionId: string, subConditionId: string, nextCondition: Condition, -) => produce(inputs, (draft) => { - const targetSubCondition = draft.cases - ?.find(item => item.case_id === caseId) - ?.conditions - .find(item => item.id === conditionId) - ?.sub_variable_condition - ?.conditions - .find(item => item.id === subConditionId) - - if (targetSubCondition) - Object.assign(targetSubCondition, nextCondition) -}) +) => + produce(inputs, (draft) => { + const targetSubCondition = draft.cases + ?.find((item) => item.case_id === caseId) + ?.conditions.find((item) => item.id === conditionId) + ?.sub_variable_condition?.conditions.find((item) => item.id === subConditionId) + + if (targetSubCondition) Object.assign(targetSubCondition, nextCondition) + }) export const toggleSubVariableConditionLogicalOperator = ( inputs: IfElseNodeType, caseId: string, conditionId: string, -) => produce(inputs, (draft) => { - const targetSubVariableCondition = draft.cases - ?.find(item => item.case_id === caseId) - ?.conditions - .find(item => item.id === conditionId) - ?.sub_variable_condition - - if (!targetSubVariableCondition) - return - - targetSubVariableCondition.logical_operator = targetSubVariableCondition.logical_operator === LogicalOperator.and - ? LogicalOperator.or - : LogicalOperator.and -}) +) => + produce(inputs, (draft) => { + const targetSubVariableCondition = draft.cases + ?.find((item) => item.case_id === caseId) + ?.conditions.find((item) => item.id === conditionId)?.sub_variable_condition + + if (!targetSubVariableCondition) return + + targetSubVariableCondition.logical_operator = + targetSubVariableCondition.logical_operator === LogicalOperator.and + ? LogicalOperator.or + : LogicalOperator.and + }) diff --git a/web/app/components/workflow/nodes/if-else/use-config.ts b/web/app/components/workflow/nodes/if-else/use-config.ts index 4a5ea2598059c4..252345f160d6bf 100644 --- a/web/app/components/workflow/nodes/if-else/use-config.ts +++ b/web/app/components/workflow/nodes/if-else/use-config.ts @@ -1,6 +1,4 @@ -import type { - Var, -} from '../../types' +import type { Var } from '../../types' import type { CaseItem, HandleAddCondition, @@ -12,16 +10,9 @@ import type { HandleUpdateSubVariableCondition, IfElseNodeType, } from './types' -import { - useCallback, - useMemo, - useRef, -} from 'react' +import { useCallback, useMemo, useRef } from 'react' import { useUpdateNodeInternals } from 'reactflow' -import { - useEdgesInteractions, - useNodesReadOnly, -} from '@/app/components/workflow/hooks' +import { useEdgesInteractions, useNodesReadOnly } from '@/app/components/workflow/hooks' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { @@ -48,26 +39,24 @@ const useConfig = (id: string, payload: IfElseNodeType) => { const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions() const { inputs, setInputs } = useNodeCrud<IfElseNodeType>(id, payload) const inputsRef = useRef(inputs) - const handleInputsChange = useCallback((newInputs: IfElseNodeType) => { - inputsRef.current = newInputs - setInputs(newInputs) - }, [setInputs]) + const handleInputsChange = useCallback( + (newInputs: IfElseNodeType) => { + inputsRef.current = newInputs + setInputs(newInputs) + }, + [setInputs], + ) const filterVar = useCallback(() => filterAllVars(), []) - const { - availableVars, - availableNodesWithParent, - } = useAvailableVarList(id, { + const { availableVars, availableNodesWithParent } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar, }) const filterNumberVar = useCallback((varPayload: Var) => filterNumberVars(varPayload), []) - const { - getIsVarFileAttribute, - } = useIsVarFileAttribute({ + const { getIsVarFileAttribute } = useIsVarFileAttribute({ nodeId: id, isInIteration: payload.isInIteration, isInLoop: payload.isInLoop, @@ -89,53 +78,98 @@ const useConfig = (id: string, payload: IfElseNodeType) => { handleInputsChange(addCase(inputsRef.current)) }, [handleInputsChange]) - const handleRemoveCase = useCallback((caseId: string) => { - handleEdgeDeleteByDeleteBranch(id, caseId) - handleInputsChange(removeCase(inputsRef.current, caseId)) - }, [handleEdgeDeleteByDeleteBranch, handleInputsChange, id]) - - const handleSortCase = useCallback((newCases: (CaseItem & { id: string })[]) => { - handleInputsChange(sortCases(inputsRef.current, newCases)) - updateNodeInternals(id) - }, [handleInputsChange, id, updateNodeInternals]) - - const handleAddCondition = useCallback<HandleAddCondition>((caseId, valueSelector, varItem) => { - handleInputsChange(addCondition({ - inputs: inputsRef.current, - caseId, - valueSelector, - variable: varItem, - isVarFileAttribute: !!getIsVarFileAttribute(valueSelector), - })) - }, [getIsVarFileAttribute, handleInputsChange]) - - const handleRemoveCondition = useCallback<HandleRemoveCondition>((caseId, conditionId) => { - handleInputsChange(removeCondition(inputsRef.current, caseId, conditionId)) - }, [handleInputsChange]) - - const handleUpdateCondition = useCallback<HandleUpdateCondition>((caseId, conditionId, newCondition) => { - handleInputsChange(updateCondition(inputsRef.current, caseId, conditionId, newCondition)) - }, [handleInputsChange]) - - const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>((caseId) => { - handleInputsChange(toggleConditionLogicalOperator(inputsRef.current, caseId)) - }, [handleInputsChange]) - - const handleAddSubVariableCondition = useCallback<HandleAddSubVariableCondition>((caseId: string, conditionId: string, key?: string) => { - handleInputsChange(addSubVariableCondition(inputsRef.current, caseId, conditionId, key)) - }, [handleInputsChange]) - - const handleRemoveSubVariableCondition = useCallback((caseId: string, conditionId: string, subConditionId: string) => { - handleInputsChange(removeSubVariableCondition(inputsRef.current, caseId, conditionId, subConditionId)) - }, [handleInputsChange]) - - const handleUpdateSubVariableCondition = useCallback<HandleUpdateSubVariableCondition>((caseId, conditionId, subConditionId, newSubCondition) => { - handleInputsChange(updateSubVariableCondition(inputsRef.current, caseId, conditionId, subConditionId, newSubCondition)) - }, [handleInputsChange]) - - const handleToggleSubVariableConditionLogicalOperator = useCallback<HandleToggleSubVariableConditionLogicalOperator>((caseId, conditionId) => { - handleInputsChange(toggleSubVariableConditionLogicalOperator(inputsRef.current, caseId, conditionId)) - }, [handleInputsChange]) + const handleRemoveCase = useCallback( + (caseId: string) => { + handleEdgeDeleteByDeleteBranch(id, caseId) + handleInputsChange(removeCase(inputsRef.current, caseId)) + }, + [handleEdgeDeleteByDeleteBranch, handleInputsChange, id], + ) + + const handleSortCase = useCallback( + (newCases: (CaseItem & { id: string })[]) => { + handleInputsChange(sortCases(inputsRef.current, newCases)) + updateNodeInternals(id) + }, + [handleInputsChange, id, updateNodeInternals], + ) + + const handleAddCondition = useCallback<HandleAddCondition>( + (caseId, valueSelector, varItem) => { + handleInputsChange( + addCondition({ + inputs: inputsRef.current, + caseId, + valueSelector, + variable: varItem, + isVarFileAttribute: !!getIsVarFileAttribute(valueSelector), + }), + ) + }, + [getIsVarFileAttribute, handleInputsChange], + ) + + const handleRemoveCondition = useCallback<HandleRemoveCondition>( + (caseId, conditionId) => { + handleInputsChange(removeCondition(inputsRef.current, caseId, conditionId)) + }, + [handleInputsChange], + ) + + const handleUpdateCondition = useCallback<HandleUpdateCondition>( + (caseId, conditionId, newCondition) => { + handleInputsChange(updateCondition(inputsRef.current, caseId, conditionId, newCondition)) + }, + [handleInputsChange], + ) + + const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>( + (caseId) => { + handleInputsChange(toggleConditionLogicalOperator(inputsRef.current, caseId)) + }, + [handleInputsChange], + ) + + const handleAddSubVariableCondition = useCallback<HandleAddSubVariableCondition>( + (caseId: string, conditionId: string, key?: string) => { + handleInputsChange(addSubVariableCondition(inputsRef.current, caseId, conditionId, key)) + }, + [handleInputsChange], + ) + + const handleRemoveSubVariableCondition = useCallback( + (caseId: string, conditionId: string, subConditionId: string) => { + handleInputsChange( + removeSubVariableCondition(inputsRef.current, caseId, conditionId, subConditionId), + ) + }, + [handleInputsChange], + ) + + const handleUpdateSubVariableCondition = useCallback<HandleUpdateSubVariableCondition>( + (caseId, conditionId, subConditionId, newSubCondition) => { + handleInputsChange( + updateSubVariableCondition( + inputsRef.current, + caseId, + conditionId, + subConditionId, + newSubCondition, + ), + ) + }, + [handleInputsChange], + ) + + const handleToggleSubVariableConditionLogicalOperator = + useCallback<HandleToggleSubVariableConditionLogicalOperator>( + (caseId, conditionId) => { + handleInputsChange( + toggleSubVariableConditionLogicalOperator(inputsRef.current, caseId, conditionId), + ) + }, + [handleInputsChange], + ) return { readOnly, diff --git a/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts b/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts index 8bb2b602b85f4d..1dd603251630ab 100644 --- a/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts +++ b/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts @@ -9,27 +9,22 @@ type Params = { isInIteration: boolean isInLoop: boolean } -const useIsVarFileAttribute = ({ - nodeId, - isInIteration, - isInLoop, -}: Params) => { +const useIsVarFileAttribute = ({ nodeId, isInIteration, isInLoop }: Params) => { const isChatMode = useIsChatMode() const store = useStoreApi() const { getBeforeNodesInSameBranch } = useWorkflow() - const { - getNodes, - } = store.getState() - const currentNode = getNodes().find(n => n.id === nodeId) - const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null - const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null + const { getNodes } = store.getState() + const currentNode = getNodes().find((n) => n.id === nodeId) + const iterationNode = isInIteration + ? getNodes().find((n) => n.id === currentNode!.parentId) + : null + const loopNode = isInLoop ? getNodes().find((n) => n.id === currentNode!.parentId) : null const availableNodes = useMemo(() => { return getBeforeNodesInSameBranch(nodeId) }, [getBeforeNodesInSameBranch, nodeId]) const { getCurrentVariableType } = useWorkflowVariables() const getIsVarFileAttribute = (variable: ValueSelector) => { - if (variable.length !== 3) - return false + if (variable.length !== 3) return false const parentVariable = variable.slice(0, 2) const varType = getCurrentVariableType({ parentNode: isInIteration ? iterationNode : loopNode, diff --git a/web/app/components/workflow/nodes/if-else/use-single-run-form-params.ts b/web/app/components/workflow/nodes/if-else/use-single-run-form-params.ts index 8e3d712dedf9c9..f1821468c0153b 100644 --- a/web/app/components/workflow/nodes/if-else/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/if-else/use-single-run-form-params.ts @@ -20,15 +20,17 @@ const useSingleRunFormParams = ({ getInputVars, varSelectorsToVarInputs, }: Params) => { - const setInputVarValues = useCallback((newPayload: Record<string, any>) => { - setRunInputData(newPayload) - }, [setRunInputData]) + const setInputVarValues = useCallback( + (newPayload: Record<string, any>) => { + setRunInputData(newPayload) + }, + [setRunInputData], + ) const inputVarValues = (() => { const vars: Record<string, any> = {} - Object.keys(runInputData) - .forEach((key) => { - vars[key] = runInputData[key] - }) + Object.keys(runInputData).forEach((key) => { + vars[key] = runInputData[key] + }) return vars })() @@ -46,8 +48,7 @@ const useSingleRunFormParams = ({ const getVarSelectorsFromCondition = (condition: Condition) => { const vars: ValueSelector[] = [] - if (condition.variable_selector) - vars.push(condition.variable_selector) + if (condition.variable_selector) vars.push(condition.variable_selector) if (condition.sub_variable_condition && condition.sub_variable_condition.conditions?.length) vars.push(...getVarSelectorsFromCase(condition.sub_variable_condition)) @@ -94,8 +95,7 @@ const useSingleRunFormParams = ({ const existVarsKey: Record<string, boolean> = {} const uniqueVarInputs: InputVar[] = [] varInputs.forEach((input) => { - if (!input) - return + if (!input) return if (!existVarsKey[input.variable]) { existVarsKey[input.variable] = true uniqueVarInputs.push(input) @@ -123,8 +123,7 @@ const useSingleRunFormParams = ({ } const getVarFromCondition = (condition: Condition): ValueSelector[] => { const vars: ValueSelector[] = [] - if (condition.variable_selector) - vars.push(condition.variable_selector) + if (condition.variable_selector) vars.push(condition.variable_selector) if (condition.sub_variable_condition && condition.sub_variable_condition.conditions?.length) vars.push(...getVarFromCaseItem(condition.sub_variable_condition)) diff --git a/web/app/components/workflow/nodes/if-else/utils.ts b/web/app/components/workflow/nodes/if-else/utils.ts index e68a355b3c045d..504efd729eaa38 100644 --- a/web/app/components/workflow/nodes/if-else/utils.ts +++ b/web/app/components/workflow/nodes/if-else/utils.ts @@ -3,7 +3,14 @@ import { VarType } from '@/app/components/workflow/types' import { ComparisonOperator } from './types' export const isEmptyRelatedOperator = (operator: ComparisonOperator) => { - return [ComparisonOperator.empty, ComparisonOperator.notEmpty, ComparisonOperator.isNull, ComparisonOperator.isNotNull, ComparisonOperator.exists, ComparisonOperator.notExists].includes(operator) + return [ + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ComparisonOperator.isNull, + ComparisonOperator.isNotNull, + ComparisonOperator.exists, + ComparisonOperator.notExists, + ].includes(operator) } const notTranslateKey = [ @@ -15,14 +22,19 @@ const notTranslateKey = [ ComparisonOperator.lessThanOrEqual, ] as const -type NotTranslateOperator = typeof notTranslateKey[number] +type NotTranslateOperator = (typeof notTranslateKey)[number] type TranslatableComparisonOperator = Exclude<ComparisonOperator, NotTranslateOperator> -export function isComparisonOperatorNeedTranslate(operator: ComparisonOperator): operator is TranslatableComparisonOperator -export function isComparisonOperatorNeedTranslate(operator?: ComparisonOperator): operator is TranslatableComparisonOperator -export function isComparisonOperatorNeedTranslate(operator?: ComparisonOperator): operator is TranslatableComparisonOperator { - if (!operator) - return false +export function isComparisonOperatorNeedTranslate( + operator: ComparisonOperator, +): operator is TranslatableComparisonOperator +export function isComparisonOperatorNeedTranslate( + operator?: ComparisonOperator, +): operator is TranslatableComparisonOperator +export function isComparisonOperatorNeedTranslate( + operator?: ComparisonOperator, +): operator is TranslatableComparisonOperator { + if (!operator) return false return !(notTranslateKey as readonly ComparisonOperator[]).includes(operator) } @@ -44,10 +56,7 @@ export const getOperators = (type?: VarType, file?: { key: string }) => { ComparisonOperator.notEmpty, ] case 'type': - return [ - ComparisonOperator.in, - ComparisonOperator.notIn, - ] + return [ComparisonOperator.in, ComparisonOperator.notIn] case 'size': return [ ComparisonOperator.largerThan, @@ -74,10 +83,7 @@ export const getOperators = (type?: VarType, file?: { key: string }) => { ComparisonOperator.notEmpty, ] case 'transfer_method': - return [ - ComparisonOperator.in, - ComparisonOperator.notIn, - ] + return [ComparisonOperator.in, ComparisonOperator.notIn] case 'url': return [ ComparisonOperator.contains, @@ -128,15 +134,9 @@ export const getOperators = (type?: VarType, file?: { key: string }) => { ComparisonOperator.notEmpty, ] case VarType.boolean: - return [ - ComparisonOperator.is, - ComparisonOperator.isNot, - ] + return [ComparisonOperator.is, ComparisonOperator.isNot] case VarType.file: - return [ - ComparisonOperator.exists, - ComparisonOperator.notExists, - ] + return [ComparisonOperator.exists, ComparisonOperator.notExists] case VarType.arrayString: case VarType.arrayNumber: case VarType.arrayBoolean: @@ -148,10 +148,7 @@ export const getOperators = (type?: VarType, file?: { key: string }) => { ] case VarType.array: case VarType.arrayObject: - return [ - ComparisonOperator.empty, - ComparisonOperator.notEmpty, - ] + return [ComparisonOperator.empty, ComparisonOperator.notEmpty] case VarType.arrayFile: return [ ComparisonOperator.contains, @@ -171,16 +168,21 @@ export const getOperators = (type?: VarType, file?: { key: string }) => { } export const comparisonOperatorNotRequireValue = (operator?: ComparisonOperator) => { - if (!operator) - return false + if (!operator) return false - return [ComparisonOperator.empty, ComparisonOperator.notEmpty, ComparisonOperator.isNull, ComparisonOperator.isNotNull, ComparisonOperator.exists, ComparisonOperator.notExists].includes(operator) + return [ + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ComparisonOperator.isNull, + ComparisonOperator.isNotNull, + ComparisonOperator.exists, + ComparisonOperator.notExists, + ].includes(operator) } export const branchNameCorrect = (branches: Branch[]) => { const branchLength = branches.length - if (branchLength < 2) - throw new Error('if-else node branch number must than 2') + if (branchLength < 2) throw new Error('if-else node branch number must than 2') if (branchLength === 2) { return branches.map((branch) => { diff --git a/web/app/components/workflow/nodes/index.tsx b/web/app/components/workflow/nodes/index.tsx index 51893953d41256..6b2899302407f2 100644 --- a/web/app/components/workflow/nodes/index.tsx +++ b/web/app/components/workflow/nodes/index.tsx @@ -1,16 +1,10 @@ import type { NodeProps } from 'reactflow' import type { Node } from '../types' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import { CUSTOM_NODE } from '../constants' import BasePanel from './_base/components/workflow-panel' import BaseNode from './_base/node' -import { - NodeComponentMap, - PanelComponentMap, -} from './components' +import { NodeComponentMap, PanelComponentMap } from './components' const CustomNode = (props: NodeProps) => { const nodeData = props.data @@ -18,10 +12,7 @@ const CustomNode = (props: NodeProps) => { return ( <> - <BaseNode - id={props.id} - data={props.data} - > + <BaseNode id={props.id} data={props.data}> <NodeComponent /> </BaseNode> </> @@ -38,18 +29,13 @@ export const Panel = memo((props: PanelProps) => { const nodeClass = props.type const nodeData = props.data const PanelComponent = useMemo(() => { - if (nodeClass === CUSTOM_NODE) - return PanelComponentMap[nodeData.type] + if (nodeClass === CUSTOM_NODE) return PanelComponentMap[nodeData.type] return () => null }, [nodeClass, nodeData.type])! if (nodeClass === CUSTOM_NODE) { return ( - <BasePanel - key={`${props.id}-${nodeData.type}`} - id={props.id} - data={props.data} - > + <BasePanel key={`${props.id}-${nodeData.type}`} id={props.id} data={props.data}> <PanelComponent /> </BasePanel> ) diff --git a/web/app/components/workflow/nodes/iteration-start/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/iteration-start/__tests__/index.spec.tsx index 61d37cbec1d6b7..f7a95fa61e63de 100644 --- a/web/app/components/workflow/nodes/iteration-start/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/iteration-start/__tests__/index.spec.tsx @@ -37,21 +37,21 @@ const createAvailableBlocksResult = (): ReturnType<typeof useAvailableBlocks> => availableNextBlocks: [], }) -const FlowNode = (props: NodeProps<CommonNodeType>) => ( - <IterationStartNode {...props} /> -) +const FlowNode = (props: NodeProps<CommonNodeType>) => <IterationStartNode {...props} /> const renderFlowNode = () => renderWorkflowFlowComponent(<div />, { - nodes: [createNode({ - id: 'iteration-start-node', - type: 'iterationStartNode', - data: { - title: 'Iteration Start', - desc: '', - type: BlockEnum.IterationStart, - }, - })], + nodes: [ + createNode({ + id: 'iteration-start-node', + type: 'iterationStartNode', + data: { + title: 'Iteration Start', + desc: '', + type: BlockEnum.IterationStart, + }, + }), + ], edges: [], reactFlowProps: { nodeTypes: { iterationStartNode: FlowNode }, diff --git a/web/app/components/workflow/nodes/iteration-start/index.tsx b/web/app/components/workflow/nodes/iteration-start/index.tsx index db0faf8e423de9..9675f3757c3d01 100644 --- a/web/app/components/workflow/nodes/iteration-start/index.tsx +++ b/web/app/components/workflow/nodes/iteration-start/index.tsx @@ -12,12 +12,12 @@ const IterationStartNode = ({ id, data }: NodeProps) => { <div className="nodrag group mt-1 flex size-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg shadow-xs"> <Tooltip> <TooltipTrigger - aria-label={t($ => $['blocks.iteration-start'], { ns: 'workflow' })} + aria-label={t(($) => $['blocks.iteration-start'], { ns: 'workflow' })} className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0" > <RiHome5Fill className="size-3 text-text-primary-on-surface" /> </TooltipTrigger> - <TooltipContent>{t($ => $['blocks.iteration-start'], { ns: 'workflow' })}</TooltipContent> + <TooltipContent>{t(($) => $['blocks.iteration-start'], { ns: 'workflow' })}</TooltipContent> </Tooltip> <NodeSourceHandle id={id} @@ -36,12 +36,12 @@ export const IterationStartNodeDumb = () => { <div className="nodrag relative top-[21px] left-[17px] z-11 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg"> <Tooltip> <TooltipTrigger - aria-label={t($ => $['blocks.iteration-start'], { ns: 'workflow' })} + aria-label={t(($) => $['blocks.iteration-start'], { ns: 'workflow' })} className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0" > <RiHome5Fill className="size-3 text-text-primary-on-surface" /> </TooltipTrigger> - <TooltipContent>{t($ => $['blocks.iteration-start'], { ns: 'workflow' })}</TooltipContent> + <TooltipContent>{t(($) => $['blocks.iteration-start'], { ns: 'workflow' })}</TooltipContent> </Tooltip> </div> ) diff --git a/web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx index 4d574f840e5eb1..ea8518fb233e5f 100644 --- a/web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx @@ -94,10 +94,8 @@ vi.mock('../../_base/components/variable/var-reference-picker', () => ({ <button type="button" onClick={() => { - if (availableVars) - onChange(['child-node', 'text'], 'variable', { type: VarType.string }) - else - onChange(['node-1', 'items'], 'variable', { type: VarType.arrayString }) + if (availableVars) onChange(['child-node', 'text'], 'variable', { type: VarType.string }) + else onChange(['node-1', 'items'], 'variable', { type: VarType.arrayString }) }} > {availableVars ? 'pick-output-var' : 'pick-input-var'} @@ -131,7 +129,9 @@ const createData = (overrides: Partial<IterationNodeType> = {}): IterationNodeTy ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), filterInputVar: () => true, @@ -165,22 +165,20 @@ describe('iteration path', () => { it('should add the next block from the iteration start node', async () => { const user = userEvent.setup() - render( - <AddBlock - iterationNodeId="iteration-node" - iterationNodeData={createData()} - />, - ) + render(<AddBlock iterationNodeId="iteration-node" iterationNodeData={createData()} />) await user.click(screen.getByRole('button', { name: 'select-block' })) - expect(mockHandleNodeAdd).toHaveBeenCalledWith({ - nodeType: BlockEnum.Code, - pluginDefaultValue: undefined, - }, { - prevNodeId: 'start-node', - prevNodeSourceHandle: 'source', - }) + expect(mockHandleNodeAdd).toHaveBeenCalledWith( + { + nodeType: BlockEnum.Code, + pluginDefaultValue: undefined, + }, + { + prevNodeId: 'start-node', + prevNodeSourceHandle: 'source', + }, + ) }) it('should render candidate iteration nodes and show the parallel warning once', () => { @@ -212,60 +210,60 @@ describe('iteration path', () => { const changeErrorResponseMode = vi.fn() const changeFlattenOutput = vi.fn() - mockUseConfig.mockReturnValueOnce(createConfigResult({ - inputs: createData({ - is_parallel: true, - flatten_output: false, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + inputs: createData({ + is_parallel: true, + flatten_output: false, + }), + handleInputChange, + handleOutputVarChange, + changeParallel, + changeParallelNums, + changeErrorResponseMode, + changeFlattenOutput, }), - handleInputChange, - handleOutputVarChange, - changeParallel, - changeParallelNums, - changeErrorResponseMode, - changeFlattenOutput, - })) - - render( - <Panel - id="iteration-node" - data={createData()} - panelProps={panelProps} - />, ) + render(<Panel id="iteration-node" data={createData()} panelProps={panelProps} />) + await user.click(screen.getByRole('button', { name: 'pick-input-var' })) await user.click(screen.getByRole('button', { name: 'pick-output-var' })) await user.click(screen.getAllByRole('switch')[0]!) fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } }) await user.click(screen.getByRole('combobox')) - await user.click(screen.getByRole('option', { name: 'workflow.nodes.iteration.ErrorMethod.continueOnError' })) + await user.click( + screen.getByRole('option', { name: 'workflow.nodes.iteration.ErrorMethod.continueOnError' }), + ) await user.click(screen.getAllByRole('switch')[1]!) - expect(handleInputChange).toHaveBeenCalledWith(['node-1', 'items'], 'variable', { type: VarType.arrayString }) - expect(handleOutputVarChange).toHaveBeenCalledWith(['child-node', 'text'], 'variable', { type: VarType.string }) + expect(handleInputChange).toHaveBeenCalledWith(['node-1', 'items'], 'variable', { + type: VarType.arrayString, + }) + expect(handleOutputVarChange).toHaveBeenCalledWith(['child-node', 'text'], 'variable', { + type: VarType.string, + }) expect(changeParallel).toHaveBeenCalledWith(false) expect(changeParallelNums).toHaveBeenCalledWith(7) - expect(changeErrorResponseMode).toHaveBeenCalledWith(expect.objectContaining({ - value: ErrorHandleMode.ContinueOnError, - })) + expect(changeErrorResponseMode).toHaveBeenCalledWith( + expect.objectContaining({ + value: ErrorHandleMode.ContinueOnError, + }), + ) expect(changeFlattenOutput).toHaveBeenCalledWith(true) }) it('should hide parallel controls when parallel mode is disabled', () => { - mockUseConfig.mockReturnValueOnce(createConfigResult({ - inputs: createData({ - is_parallel: false, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + inputs: createData({ + is_parallel: false, + }), }), - })) - - render( - <Panel - id="iteration-node" - data={createData()} - panelProps={panelProps} - />, ) + render(<Panel id="iteration-node" data={createData()} panelProps={panelProps} />) + expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts index 4003c2d1498d03..728ab58077d01f 100644 --- a/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts @@ -121,30 +121,46 @@ describe('iteration/use-config', () => { const { result } = renderHook(() => useConfig('iteration-node', currentInputs)) act(() => { - result.current.handleInputChange(['start', 'documents'], VarKindType.variable, createVar(VarType.arrayObject, 'start.documents')) + result.current.handleInputChange( + ['start', 'documents'], + VarKindType.variable, + createVar(VarType.arrayObject, 'start.documents'), + ) }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - iterator_selector: ['start', 'documents'], - iterator_input_type: VarType.arrayObject, - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + iterator_selector: ['start', 'documents'], + iterator_input_type: VarType.arrayObject, + }), + ) mockSetInputs.mockClear() act(() => { - result.current.handleOutputVarChange(['child', 'score'], VarKindType.variable, createVar(VarType.number, 'child.score')) + result.current.handleOutputVarChange( + ['child', 'score'], + VarKindType.variable, + createVar(VarType.number, 'child.score'), + ) }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - output_selector: ['child', 'score'], - output_type: VarType.arrayNumber, - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + output_selector: ['child', 'score'], + output_type: VarType.arrayNumber, + }), + ) expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('iteration-node') mockSetInputs.mockClear() act(() => { - result.current.handleOutputVarChange(['child', 'result'], VarKindType.variable, createVar(VarType.string, 'child.result')) + result.current.handleOutputVarChange( + ['child', 'result'], + VarKindType.variable, + createVar(VarType.string, 'child.result'), + ) }) expect(mockSetInputs).not.toHaveBeenCalled() @@ -161,18 +177,30 @@ describe('iteration/use-config', () => { result.current.changeFlattenOutput(true) }) - expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({ - is_parallel: true, - })) - expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({ - error_handle_mode: ErrorHandleMode.ContinueOnError, - })) - expect(mockSetInputs).toHaveBeenNthCalledWith(3, expect.objectContaining({ - parallel_nums: 6, - })) - expect(mockSetInputs).toHaveBeenNthCalledWith(4, expect.objectContaining({ - flatten_output: true, - })) + expect(mockSetInputs).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + is_parallel: true, + }), + ) + expect(mockSetInputs).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + error_handle_mode: ErrorHandleMode.ContinueOnError, + }), + ) + expect(mockSetInputs).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + parallel_nums: 6, + }), + ) + expect(mockSetInputs).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + flatten_output: true, + }), + ) }) it('should fall back to empty selectors and empty plugin lists when metadata is missing', () => { @@ -204,22 +232,30 @@ describe('iteration/use-config', () => { result.current.handleInputChange('', VarKindType.variable) }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - iterator_selector: [], - iterator_input_type: VarType.arrayString, - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + iterator_selector: [], + iterator_input_type: VarType.arrayString, + }), + ) mockSetInputs.mockClear() mockDeleteNodeInspectorVars.mockClear() act(() => { - result.current.handleOutputVarChange('', VarKindType.variable, createVar(VarType.boolean, 'child.flag')) + result.current.handleOutputVarChange( + '', + VarKindType.variable, + createVar(VarType.boolean, 'child.flag'), + ) }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - output_selector: [], - output_type: VarType.arrayString, - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + output_selector: [], + output_type: VarType.arrayString, + }), + ) expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('iteration-node') }) }) diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.helpers.spec.ts b/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.helpers.spec.ts index 4bb9e624bb3ca9..4e85e77718c987 100644 --- a/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.helpers.spec.ts +++ b/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.helpers.spec.ts @@ -28,55 +28,69 @@ describe('iteration interaction helpers', () => { const bounds = getIterationContainerBounds(children as Node[]) expect(bounds.rightNode?.id).toBe('b') expect(bounds.bottomNode?.id).toBe('b') - expect(getIterationContainerResize(createNode({ width: 120, height: 80 }) as Node, bounds)).toEqual({ + expect( + getIterationContainerResize(createNode({ width: 120, height: 80 }) as Node, bounds), + ).toEqual({ width: 186, height: 110, }) - expect(getRestrictedIterationPosition( - createNode({ - position: { x: -10, y: 160 }, - width: 80, - height: 40, - data: { isInIteration: true }, - }), - createNode({ width: 200, height: 180 }) as Node, - )).toEqual({ x: 16, y: 120 }) - expect(getRestrictedIterationPosition( - createNode({ - position: { x: 180, y: -4 }, - width: 40, - height: 30, - data: { isInIteration: true }, - }), - createNode({ width: 200, height: 180 }) as Node, - )).toEqual({ x: 144, y: 65 }) + expect( + getRestrictedIterationPosition( + createNode({ + position: { x: -10, y: 160 }, + width: 80, + height: 40, + data: { isInIteration: true }, + }), + createNode({ width: 200, height: 180 }) as Node, + ), + ).toEqual({ x: 16, y: 120 }) + expect( + getRestrictedIterationPosition( + createNode({ + position: { x: 180, y: -4 }, + width: 40, + height: 30, + data: { isInIteration: true }, + }), + createNode({ width: 200, height: 180 }) as Node, + ), + ).toEqual({ x: 144, y: 65 }) }) it('filters iteration children and increments per-type counts', () => { const typeCount = {} as Parameters<typeof getNextChildNodeTypeCount>[0] expect(getNextChildNodeTypeCount(typeCount, BlockEnum.Code, 2)).toBe(3) expect(getNextChildNodeTypeCount(typeCount, BlockEnum.Code, 2)).toBe(4) - expect(getIterationChildren([ - createNode({ id: 'child', parentId: 'iteration-1' }), - createNode({ id: 'start', parentId: 'iteration-1', type: 'custom-iteration-start' }), - createNode({ id: 'other', parentId: 'other-iteration' }), - ] as Node[], 'iteration-1').map(item => item.id)).toEqual(['child']) + expect( + getIterationChildren( + [ + createNode({ id: 'child', parentId: 'iteration-1' }), + createNode({ id: 'start', parentId: 'iteration-1', type: 'custom-iteration-start' }), + createNode({ id: 'other', parentId: 'other-iteration' }), + ] as Node[], + 'iteration-1', + ).map((item) => item.id), + ).toEqual(['child']) }) it('keeps bounds, resize and positions empty when no container restriction applies', () => { expect(getIterationContainerBounds([])).toEqual({}) - expect(getIterationContainerResize(createNode({ width: 300, height: 240 }) as Node, {})).toEqual({ + expect( + getIterationContainerResize(createNode({ width: 300, height: 240 }) as Node, {}), + ).toEqual({ width: undefined, height: undefined, }) - expect(getRestrictedIterationPosition( - createNode({ data: { isInIteration: true } }), - undefined, - )).toEqual({ x: undefined, y: undefined }) - expect(getRestrictedIterationPosition( - createNode({ data: { isInIteration: false } }), - createNode({ width: 200, height: 180 }) as Node, - )).toEqual({ x: undefined, y: undefined }) + expect( + getRestrictedIterationPosition(createNode({ data: { isInIteration: true } }), undefined), + ).toEqual({ x: undefined, y: undefined }) + expect( + getRestrictedIterationPosition( + createNode({ data: { isInIteration: false } }), + createNode({ width: 200, height: 180 }) as Node, + ), + ).toEqual({ x: undefined, y: undefined }) }) it('builds copied iteration children with iteration metadata', () => { @@ -97,15 +111,17 @@ describe('iteration interaction helpers', () => { newNodeId: 'iteration-2', }) - expect(result).toEqual(expect.objectContaining({ - parentId: 'iteration-2', - zIndex: 1001, - data: expect.objectContaining({ - title: 'blocks.code 3', - iteration_id: 'iteration-2', - selected: false, - _isBundled: false, + expect(result).toEqual( + expect.objectContaining({ + parentId: 'iteration-2', + zIndex: 1001, + data: expect.objectContaining({ + title: 'blocks.code 3', + iteration_id: 'iteration-2', + selected: false, + _isBundled: false, + }), }), - })) + ) }) }) diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.spec.tsx b/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.spec.tsx index e4fa09ab928a9f..e5adb792066861 100644 --- a/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.spec.tsx +++ b/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.spec.tsx @@ -1,9 +1,6 @@ import type { Node } from '@/app/components/workflow/types' import { renderHook } from '@testing-library/react' -import { - createIterationNode, - createNode, -} from '@/app/components/workflow/__tests__/fixtures' +import { createIterationNode, createNode } from '@/app/components/workflow/__tests__/fixtures' import { ITERATION_PADDING } from '@/app/components/workflow/constants' import { BlockEnum } from '@/app/components/workflow/types' import { useNodeIterationInteractions } from '../use-interactions' @@ -85,14 +82,16 @@ describe('useNodeIterationInteractions', () => { ]) const { result } = renderHook(() => useNodeIterationInteractions()) - const dragResult = result.current.handleNodeIterationChildDrag(createNode({ - id: 'child-node', - parentId: 'iteration-node', - position: { x: -10, y: -5 }, - width: 80, - height: 60, - data: { type: BlockEnum.Code, title: 'Child', desc: '', isInIteration: true }, - })) + const dragResult = result.current.handleNodeIterationChildDrag( + createNode({ + id: 'child-node', + parentId: 'iteration-node', + position: { x: -10, y: -5 }, + width: 80, + height: 60, + data: { type: BlockEnum.Code, title: 'Child', desc: '', isInIteration: true }, + }), + ) expect(dragResult.restrictPosition).toEqual({ x: ITERATION_PADDING.left, @@ -154,20 +153,31 @@ describe('useNodeIterationInteractions', () => { newNode: createNode({ id: 'generated', parentId: 'new-iteration', - data: { type: BlockEnum.Code, title: 'blocks.code 3', desc: '', iteration_id: 'new-iteration' }, + data: { + type: BlockEnum.Code, + title: 'blocks.code 3', + desc: '', + iteration_id: 'new-iteration', + }, }), }) const { result } = renderHook(() => useNodeIterationInteractions()) - const copyResult = result.current.handleNodeIterationChildrenCopy('iteration-node', 'new-iteration', { existing: 'mapped' }) - - expect(mockGenerateNewNode).toHaveBeenCalledWith(expect.objectContaining({ - type: 'custom', - parentId: 'new-iteration', - })) + const copyResult = result.current.handleNodeIterationChildrenCopy( + 'iteration-node', + 'new-iteration', + { existing: 'mapped' }, + ) + + expect(mockGenerateNewNode).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'custom', + parentId: 'new-iteration', + }), + ) expect(copyResult.copyChildren).toHaveLength(1) expect(copyResult.newIdMapping).toEqual({ - 'existing': 'mapped', + existing: 'mapped', 'child-node': 'new-iterationgenerated0', }) }) diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts index f1845618cf457b..657adc1d8e5abb 100644 --- a/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts @@ -37,15 +37,16 @@ const createInputVar = (variable: string): InputVar => ({ required: false, }) -const createNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({ - id, - position: { x: 0, y: 0 }, - data: { - title, - type, - desc: '', - }, -} as Node) +const createNode = (id: string, title: string, type = BlockEnum.Tool): Node => + ({ + id, + position: { x: 0, y: 0 }, + data: { + title, + type, + desc: '', + }, + }) as Node const createPayload = (overrides: Partial<IterationNodeType> = {}): IterationNodeType => ({ title: 'Iteration', @@ -76,17 +77,21 @@ describe('iteration/use-single-run-form-params', () => { createNode('tool-a', 'Tool A'), createNode('inner-node', 'Inner Node'), ], - getBeforeNodesInSameBranch: () => [ - createNode('start-node', 'Start Node', BlockEnum.Start), - ], + getBeforeNodesInSameBranch: () => [createNode('start-node', 'Start Node', BlockEnum.Start)], }) mockGetNodeUsedVars.mockImplementation((node: Node) => { if (node.id === 'tool-a') - return [['start-node', 'answer'], ['inner-node', 'secret'], ['iteration-node', 'item']] + return [ + ['start-node', 'answer'], + ['inner-node', 'secret'], + ['iteration-node', 'item'], + ] return [] }) mockGetNodeUsedVarPassToServerKey.mockReturnValue('passed_key') - mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id)) + mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => + nodes.find((node) => node.id === id), + ) mockIsSystemVar.mockReturnValue(false) mockFormatTracing.mockReturnValue([{ id: 'formatted-node' }]) }) @@ -94,19 +99,21 @@ describe('iteration/use-single-run-form-params', () => { it('should build single-run forms from external vars and keep iterator state in a dedicated form', () => { const toVarInputs = vi.fn(() => [createInputVar('#start-node.answer#')]) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'iteration-node', - payload: createPayload(), - runInputData: { - 'query': 'hello', - 'iteration-node.input_selector': ['start-node', 'items'], - }, - runInputDataRef: { current: {} }, - getInputVars: vi.fn(), - setRunInputData: vi.fn(), - toVarInputs, - iterationRunResult: [], - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'iteration-node', + payload: createPayload(), + runInputData: { + query: 'hello', + 'iteration-node.input_selector': ['start-node', 'items'], + }, + runInputDataRef: { current: {} }, + getInputVars: vi.fn(), + setRunInputData: vi.fn(), + toVarInputs, + iterationRunResult: [], + }), + ) expect(toVarInputs).toHaveBeenCalledWith([ expect.objectContaining({ @@ -117,7 +124,7 @@ describe('iteration/use-single-run-form-params', () => { expect(result.current.forms).toHaveLength(2) expect(result.current.forms[0]!.inputs).toEqual([createInputVar('#start-node.answer#')]) expect(result.current.forms[0]!.values).toEqual({ - 'query': 'hello', + query: 'hello', 'iteration-node.input_selector': ['start-node', 'items'], }) expect(result.current.forms[1]!.values).toEqual({ @@ -134,21 +141,23 @@ describe('iteration/use-single-run-form-params', () => { it('should forward form updates and expose iterator dependencies', () => { const setRunInputData = vi.fn() - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'iteration-node', - payload: createPayload({ - iterator_selector: ['source-node', 'records'], + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'iteration-node', + payload: createPayload({ + iterator_selector: ['source-node', 'records'], + }), + runInputData: { + query: 'old', + 'iteration-node.input_selector': ['source-node', 'records'], + }, + runInputDataRef: { current: {} }, + getInputVars: vi.fn(), + setRunInputData, + toVarInputs: vi.fn(() => []), + iterationRunResult: [] as NodeTracing[], }), - runInputData: { - 'query': 'old', - 'iteration-node.input_selector': ['source-node', 'records'], - }, - runInputDataRef: { current: {} }, - getInputVars: vi.fn(), - setRunInputData, - toVarInputs: vi.fn(() => []), - iterationRunResult: [] as NodeTracing[], - })) + ) act(() => { result.current.forms[0]!.onChange({ query: 'new' }) @@ -159,10 +168,13 @@ describe('iteration/use-single-run-form-params', () => { expect(setRunInputData).toHaveBeenNthCalledWith(1, { query: 'new' }) expect(setRunInputData).toHaveBeenNthCalledWith(2, { - 'query': 'old', + query: 'old', 'iteration-node.input_selector': ['source-node', 'next'], }) expect(result.current.getDependentVars()).toEqual([['source-node', 'records']]) - expect(result.current.getDependentVar('iteration-node.input_selector')).toEqual(['source-node', 'records']) + expect(result.current.getDependentVar('iteration-node.input_selector')).toEqual([ + 'source-node', + 'records', + ]) }) }) diff --git a/web/app/components/workflow/nodes/iteration/add-block.tsx b/web/app/components/workflow/nodes/iteration/add-block.tsx index 25317707471d62..64fe89453d7ff0 100644 --- a/web/app/components/workflow/nodes/iteration/add-block.tsx +++ b/web/app/components/workflow/nodes/iteration/add-block.tsx @@ -1,64 +1,56 @@ import type { IterationNodeType } from './types' -import type { - OnSelectBlock, -} from '@/app/components/workflow/types' +import type { OnSelectBlock } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - RiAddLine, -} from '@remixicon/react' -import { - memo, - useCallback, -} from 'react' +import { RiAddLine } from '@remixicon/react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import BlockSelector from '@/app/components/workflow/block-selector' -import { - BlockEnum, -} from '@/app/components/workflow/types' -import { - useAvailableBlocks, - useNodesInteractions, - useNodesReadOnly, -} from '../../hooks' +import { BlockEnum } from '@/app/components/workflow/types' +import { useAvailableBlocks, useNodesInteractions, useNodesReadOnly } from '../../hooks' type AddBlockProps = { iterationNodeId: string iterationNodeData: IterationNodeType } -const AddBlock = ({ - iterationNodeData, -}: AddBlockProps) => { +const AddBlock = ({ iterationNodeData }: AddBlockProps) => { const { t } = useTranslation() const { nodesReadOnly } = useNodesReadOnly() const { handleNodeAdd } = useNodesInteractions() const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, true) - const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { - handleNodeAdd( - { - nodeType: type, - pluginDefaultValue, - }, - { - prevNodeId: iterationNodeData.start_node_id, - prevNodeSourceHandle: 'source', - }, - ) - }, [handleNodeAdd, iterationNodeData.start_node_id]) + const handleSelect = useCallback<OnSelectBlock>( + (type, pluginDefaultValue) => { + handleNodeAdd( + { + nodeType: type, + pluginDefaultValue, + }, + { + prevNodeId: iterationNodeData.start_node_id, + prevNodeSourceHandle: 'source', + }, + ) + }, + [handleNodeAdd, iterationNodeData.start_node_id], + ) - const renderTriggerElement = useCallback((open: boolean) => { - return ( - <div className={cn( - 'relative inline-flex h-8 cursor-pointer items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text shadow-xs backdrop-blur-[5px] hover:bg-components-button-secondary-bg-hover', - `${nodesReadOnly && 'cursor-not-allowed! bg-components-button-secondary-bg-disabled'}`, - open && 'bg-components-button-secondary-bg-hover', - )} - > - <RiAddLine className="mr-1 size-4" /> - {t($ => $['common.addBlock'], { ns: 'workflow' })} - </div> - ) - }, [nodesReadOnly, t]) + const renderTriggerElement = useCallback( + (open: boolean) => { + return ( + <div + className={cn( + 'relative inline-flex h-8 cursor-pointer items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text shadow-xs backdrop-blur-[5px] hover:bg-components-button-secondary-bg-hover', + `${nodesReadOnly && 'cursor-not-allowed! bg-components-button-secondary-bg-disabled'}`, + open && 'bg-components-button-secondary-bg-hover', + )} + > + <RiAddLine className="mr-1 size-4" /> + {t(($) => $['common.addBlock'], { ns: 'workflow' })} + </div> + ) + }, + [nodesReadOnly, t], + ) return ( <div className="absolute top-7 left-14 z-10 flex h-8 items-center"> diff --git a/web/app/components/workflow/nodes/iteration/default.ts b/web/app/components/workflow/nodes/iteration/default.ts index 22df2e933e34d3..94c87cfedaae82 100644 --- a/web/app/components/workflow/nodes/iteration/default.ts +++ b/web/app/components/workflow/nodes/iteration/default.ts @@ -29,23 +29,17 @@ const nodeDefault: NodeDefault<IterationNodeType> = { checkValid(payload: IterationNodeType, t: TFunction<'workflow'>) { let errorMessages = '' - if ( - !errorMessages - && (!payload.iterator_selector || payload.iterator_selector.length === 0) - ) { - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { + if (!errorMessages && (!payload.iterator_selector || payload.iterator_selector.length === 0)) { + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', - field: t($ => $[`${i18nPrefix}nodes.iteration.input`], { ns: 'workflow' }), + field: t(($) => $[`${i18nPrefix}nodes.iteration.input`], { ns: 'workflow' }), }) } - if ( - !errorMessages - && (!payload.output_selector || payload.output_selector.length === 0) - ) { - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { + if (!errorMessages && (!payload.output_selector || payload.output_selector.length === 0)) { + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', - field: t($ => $[`${i18nPrefix}nodes.iteration.output`], { ns: 'workflow' }), + field: t(($) => $[`${i18nPrefix}nodes.iteration.output`], { ns: 'workflow' }), }) } diff --git a/web/app/components/workflow/nodes/iteration/node.tsx b/web/app/components/workflow/nodes/iteration/node.tsx index 4a29c5b5deba0e..8e4b2302521d21 100644 --- a/web/app/components/workflow/nodes/iteration/node.tsx +++ b/web/app/components/workflow/nodes/iteration/node.tsx @@ -3,27 +3,16 @@ import type { IterationNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { - memo, - useEffect, - useState, -} from 'react' +import { memo, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - Background, - useNodesInitialized, - useViewport, -} from 'reactflow' +import { Background, useNodesInitialized, useViewport } from 'reactflow' import { IterationStartNodeDumb } from '../iteration-start' import AddBlock from './add-block' import { useNodeIterationInteractions } from './use-interactions' const i18nPrefix = 'nodes.iteration' -const Node: FC<NodeProps<IterationNodeType>> = ({ - id, - data, -}) => { +const Node: FC<NodeProps<IterationNodeType>> = ({ id, data }) => { const { zoom } = useViewport() const nodesInitialized = useNodesInitialized() const { handleNodeIterationRerender } = useNodeIterationInteractions() @@ -31,18 +20,18 @@ const Node: FC<NodeProps<IterationNodeType>> = ({ const [showTips, setShowTips] = useState(data._isShowTips) useEffect(() => { - if (nodesInitialized) - handleNodeIterationRerender(id) + if (nodesInitialized) handleNodeIterationRerender(id) if (data.is_parallel && showTips) { - toast.warning(t($ => $[`${i18nPrefix}.answerNodeWarningDesc`], { ns: 'workflow' })) + toast.warning(t(($) => $[`${i18nPrefix}.answerNodeWarningDesc`], { ns: 'workflow' })) setShowTips(false) } }, [nodesInitialized, id, handleNodeIterationRerender, data.is_parallel, showTips, t]) return ( - <div className={cn( - 'relative h-full min-h-[90px] w-full min-w-[240px] rounded-2xl bg-workflow-canvas-workflow-bg', - )} + <div + className={cn( + 'relative h-full min-h-[90px] w-full min-w-[240px] rounded-2xl bg-workflow-canvas-workflow-bg', + )} > <Background id={`iteration-background-${id}`} @@ -51,19 +40,8 @@ const Node: FC<NodeProps<IterationNodeType>> = ({ size={2 / zoom} color="var(--color-workflow-canvas-workflow-dot-color)" /> - { - data._isCandidate && ( - <IterationStartNodeDumb /> - ) - } - { - data._children?.length === 1 && ( - <AddBlock - iterationNodeId={id} - iterationNodeData={data} - /> - ) - } + {data._isCandidate && <IterationStartNodeDumb />} + {data._children?.length === 1 && <AddBlock iterationNodeId={id} iterationNodeData={data} />} </div> ) } diff --git a/web/app/components/workflow/nodes/iteration/panel.tsx b/web/app/components/workflow/nodes/iteration/panel.tsx index a89ee48b3ef286..216423a62a7aa8 100644 --- a/web/app/components/workflow/nodes/iteration/panel.tsx +++ b/web/app/components/workflow/nodes/iteration/panel.tsx @@ -2,7 +2,15 @@ import type { FC } from 'react' import type { IterationNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectLabel, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { Slider } from '@langgenius/dify-ui/slider' import { Switch } from '@langgenius/dify-ui/switch' import * as React from 'react' @@ -18,25 +26,24 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.iteration' -const Panel: FC<NodePanelProps<IterationNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<IterationNodeType>> = ({ id, data }) => { const { t } = useTranslation() - const maxParallelismLabel = t($ => $[`${i18nPrefix}.MaxParallelismTitle`], { ns: 'workflow' }) - const errorResponseMethodLabel = t($ => $[`${i18nPrefix}.errorResponseMethod`], { ns: 'workflow' }) + const maxParallelismLabel = t(($) => $[`${i18nPrefix}.MaxParallelismTitle`], { ns: 'workflow' }) + const errorResponseMethodLabel = t(($) => $[`${i18nPrefix}.errorResponseMethod`], { + ns: 'workflow', + }) const responseMethod = [ { value: ErrorHandleMode.Terminated, - name: t($ => $[`${i18nPrefix}.ErrorMethod.operationTerminated`], { ns: 'workflow' }), + name: t(($) => $[`${i18nPrefix}.ErrorMethod.operationTerminated`], { ns: 'workflow' }), }, { value: ErrorHandleMode.ContinueOnError, - name: t($ => $[`${i18nPrefix}.ErrorMethod.continueOnError`], { ns: 'workflow' }), + name: t(($) => $[`${i18nPrefix}.ErrorMethod.continueOnError`], { ns: 'workflow' }), }, { value: ErrorHandleMode.RemoveAbnormalOutput, - name: t($ => $[`${i18nPrefix}.ErrorMethod.removeAbnormalOutput`], { ns: 'workflow' }), + name: t(($) => $[`${i18nPrefix}.ErrorMethod.removeAbnormalOutput`], { ns: 'workflow' }), }, ] const { @@ -52,17 +59,21 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({ changeParallelNums, changeFlattenOutput, } = useConfig(id, data) - const selectedResponseMethod = responseMethod.find(item => item.value === inputs.error_handle_mode) + const selectedResponseMethod = responseMethod.find( + (item) => item.value === inputs.error_handle_mode, + ) return ( <div className="py-2"> <div className="space-y-4 px-4 pb-4"> <Field - title={t($ => $[`${i18nPrefix}.input`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.input`], { ns: 'workflow' })} required - operations={( - <div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary capitalize">Array</div> - )} + operations={ + <div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary capitalize"> + Array + </div> + } > <VarReferencePicker readonly={readOnly} @@ -77,11 +88,13 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({ <Split /> <div className="mt-2 space-y-4 px-4 pb-4"> <Field - title={t($ => $[`${i18nPrefix}.output`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.output`], { ns: 'workflow' })} required - operations={( - <div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary capitalize">Array</div> - )} + operations={ + <div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary capitalize"> + Array + </div> + } > <VarReferencePicker readonly={readOnly} @@ -95,31 +108,54 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({ </Field> </div> <div className="px-4 pb-2"> - <Field title={t($ => $[`${i18nPrefix}.parallelMode`], { ns: 'workflow' })} tooltip={<div className="w-[230px]">{t($ => $[`${i18nPrefix}.parallelPanelDesc`], { ns: 'workflow' })}</div>} inline> + <Field + title={t(($) => $[`${i18nPrefix}.parallelMode`], { ns: 'workflow' })} + tooltip={ + <div className="w-[230px]"> + {t(($) => $[`${i18nPrefix}.parallelPanelDesc`], { ns: 'workflow' })} + </div> + } + inline + > <Switch checked={inputs.is_parallel} onCheckedChange={changeParallel} /> </Field> </div> - { - inputs.is_parallel && ( - <div className="px-4 pb-2"> - <Field title={maxParallelismLabel} isSubTitle tooltip={<div className="w-[230px]">{t($ => $[`${i18nPrefix}.MaxParallelismDesc`], { ns: 'workflow' })}</div>}> - <Fieldset className="row flex"> - <FieldsetLegend className="sr-only">{maxParallelismLabel}</FieldsetLegend> - <Input aria-label={maxParallelismLabel} type="number" wrapperClassName="w-18 mr-4" max={MAX_PARALLEL_LIMIT} min={MIN_ITERATION_PARALLEL_NUM} value={inputs.parallel_nums} onChange={(e) => { changeParallelNums(Number(e.target.value)) }} /> - <Slider - value={inputs.parallel_nums} - onValueChange={changeParallelNums} - max={MAX_PARALLEL_LIMIT} - min={MIN_ITERATION_PARALLEL_NUM} - className="mt-4 flex-1 shrink-0" - aria-label={maxParallelismLabel} - /> - </Fieldset> - - </Field> - </div> - ) - } + {inputs.is_parallel && ( + <div className="px-4 pb-2"> + <Field + title={maxParallelismLabel} + isSubTitle + tooltip={ + <div className="w-[230px]"> + {t(($) => $[`${i18nPrefix}.MaxParallelismDesc`], { ns: 'workflow' })} + </div> + } + > + <Fieldset className="row flex"> + <FieldsetLegend className="sr-only">{maxParallelismLabel}</FieldsetLegend> + <Input + aria-label={maxParallelismLabel} + type="number" + wrapperClassName="w-18 mr-4" + max={MAX_PARALLEL_LIMIT} + min={MIN_ITERATION_PARALLEL_NUM} + value={inputs.parallel_nums} + onChange={(e) => { + changeParallelNums(Number(e.target.value)) + }} + /> + <Slider + value={inputs.parallel_nums} + onValueChange={changeParallelNums} + max={MAX_PARALLEL_LIMIT} + min={MIN_ITERATION_PARALLEL_NUM} + className="mt-4 flex-1 shrink-0" + aria-label={maxParallelismLabel} + /> + </Fieldset> + </Field> + </div> + )} <Split /> <div className="px-4 py-2"> @@ -127,19 +163,17 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({ <Select<ErrorHandleMode> value={selectedResponseMethod?.value ?? null} onValueChange={(nextValue) => { - if (nextValue == null) - return - const nextItem = responseMethod.find(item => item.value === nextValue) - if (nextItem) - changeErrorResponseMode(nextItem) + if (nextValue == null) return + const nextItem = responseMethod.find((item) => item.value === nextValue) + if (nextItem) changeErrorResponseMode(nextItem) }} > <SelectLabel className="sr-only">{errorResponseMethodLabel}</SelectLabel> <SelectTrigger className="w-full"> - {selectedResponseMethod?.name ?? t($ => $['placeholder.select'], { ns: 'common' })} + {selectedResponseMethod?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })} </SelectTrigger> <SelectContent> - {responseMethod.map(item => ( + {responseMethod.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -154,8 +188,12 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({ <div className="px-4 py-2"> <Field - title={t($ => $[`${i18nPrefix}.flattenOutput`], { ns: 'workflow' })} - tooltip={<div className="w-[230px]">{t($ => $[`${i18nPrefix}.flattenOutputDesc`], { ns: 'workflow' })}</div>} + title={t(($) => $[`${i18nPrefix}.flattenOutput`], { ns: 'workflow' })} + tooltip={ + <div className="w-[230px]"> + {t(($) => $[`${i18nPrefix}.flattenOutputDesc`], { ns: 'workflow' })} + </div> + } inline > <Switch checked={inputs.flatten_output} onCheckedChange={changeFlattenOutput} /> diff --git a/web/app/components/workflow/nodes/iteration/use-config.ts b/web/app/components/workflow/nodes/iteration/use-config.ts index 34929267c2c207..fca9b95fe4a382 100644 --- a/web/app/components/workflow/nodes/iteration/use-config.ts +++ b/web/app/components/workflow/nodes/iteration/use-config.ts @@ -10,11 +10,7 @@ import { useAllMCPTools, useAllWorkflowTools, } from '@/service/use-tools' -import { - useIsChatMode, - useNodesReadOnly, - useWorkflow, -} from '../../hooks' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' import { useStore } from '../../store' import { VarType } from '../../types' @@ -27,25 +23,33 @@ type SelectOption = { } const useConfig = (id: string, payload: IterationNodeType) => { - const { - deleteNodeInspectorVars, - } = useInspectVarsCrud() + const { deleteNodeInspectorVars } = useInspectVarsCrud() const { nodesReadOnly: readOnly } = useNodesReadOnly() const isChatMode = useIsChatMode() const { inputs, setInputs } = useNodeCrud<IterationNodeType>(id, payload) const filterInputVar = useCallback((varPayload: Var) => { - return [VarType.array, VarType.arrayString, VarType.arrayBoolean, VarType.arrayNumber, VarType.arrayObject, VarType.arrayFile].includes(varPayload.type) + return [ + VarType.array, + VarType.arrayString, + VarType.arrayBoolean, + VarType.arrayNumber, + VarType.arrayObject, + VarType.arrayFile, + ].includes(varPayload.type) }, []) - const handleInputChange = useCallback((input: ValueSelector | string, _varKindType: VarKindType, varInfo?: Var) => { - const newInputs = produce(inputs, (draft) => { - draft.iterator_selector = input as ValueSelector || [] - draft.iterator_input_type = varInfo?.type || VarType.arrayString - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const handleInputChange = useCallback( + (input: ValueSelector | string, _varKindType: VarKindType, varInfo?: Var) => { + const newInputs = produce(inputs, (draft) => { + draft.iterator_selector = (input as ValueSelector) || [] + draft.iterator_input_type = varInfo?.type || VarType.arrayString + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) // output const { getIterationNodeChildren } = useWorkflow() @@ -54,7 +58,7 @@ const useConfig = (id: string, payload: IterationNodeType) => { const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() - const dataSourceList = useStore(s => s.dataSourceList) + const dataSourceList = useStore((s) => s.dataSourceList) const allPluginInfoList = { buildInTools: buildInTools || [], customTools: customTools || [], @@ -62,59 +66,84 @@ const useConfig = (id: string, payload: IterationNodeType) => { mcpTools: mcpTools || [], dataSourceList: dataSourceList || [], } - const childrenNodeVars = toNodeOutputVars(iterationChildrenNodes, isChatMode, undefined, [], [], [], allPluginInfoList) + const childrenNodeVars = toNodeOutputVars( + iterationChildrenNodes, + isChatMode, + undefined, + [], + [], + [], + allPluginInfoList, + ) - const handleOutputVarChange = useCallback((output: ValueSelector | string, _varKindType: VarKindType, varInfo?: Var) => { - if (isEqual(inputs.output_selector, output as ValueSelector)) - return + const handleOutputVarChange = useCallback( + (output: ValueSelector | string, _varKindType: VarKindType, varInfo?: Var) => { + if (isEqual(inputs.output_selector, output as ValueSelector)) return - const newInputs = produce(inputs, (draft) => { - draft.output_selector = output as ValueSelector || [] - const outputItemType = varInfo?.type || VarType.string + const newInputs = produce(inputs, (draft) => { + draft.output_selector = (output as ValueSelector) || [] + const outputItemType = varInfo?.type || VarType.string - draft.output_type = ({ - [VarType.string]: VarType.arrayString, - [VarType.number]: VarType.arrayNumber, - [VarType.object]: VarType.arrayObject, - [VarType.file]: VarType.arrayFile, - // list operator node can output array - [VarType.array]: VarType.array, - [VarType.arrayFile]: VarType.arrayFile, - [VarType.arrayString]: VarType.arrayString, - [VarType.arrayNumber]: VarType.arrayNumber, - [VarType.arrayObject]: VarType.arrayObject, - } as Record<VarType, VarType>)[outputItemType] || VarType.arrayString - }) - setInputs(newInputs) - deleteNodeInspectorVars(id) - }, [deleteNodeInspectorVars, id, inputs, setInputs]) + draft.output_type = + ( + { + [VarType.string]: VarType.arrayString, + [VarType.number]: VarType.arrayNumber, + [VarType.object]: VarType.arrayObject, + [VarType.file]: VarType.arrayFile, + // list operator node can output array + [VarType.array]: VarType.array, + [VarType.arrayFile]: VarType.arrayFile, + [VarType.arrayString]: VarType.arrayString, + [VarType.arrayNumber]: VarType.arrayNumber, + [VarType.arrayObject]: VarType.arrayObject, + } as Record<VarType, VarType> + )[outputItemType] || VarType.arrayString + }) + setInputs(newInputs) + deleteNodeInspectorVars(id) + }, + [deleteNodeInspectorVars, id, inputs, setInputs], + ) - const changeParallel = useCallback((value: boolean) => { - const newInputs = produce(inputs, (draft) => { - draft.is_parallel = value - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const changeParallel = useCallback( + (value: boolean) => { + const newInputs = produce(inputs, (draft) => { + draft.is_parallel = value + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) - const changeErrorResponseMode = useCallback((item: SelectOption) => { - const newInputs = produce(inputs, (draft) => { - draft.error_handle_mode = item.value - }) - setInputs(newInputs) - }, [inputs, setInputs]) - const changeParallelNums = useCallback((num: number) => { - const newInputs = produce(inputs, (draft) => { - draft.parallel_nums = num - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const changeErrorResponseMode = useCallback( + (item: SelectOption) => { + const newInputs = produce(inputs, (draft) => { + draft.error_handle_mode = item.value + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) + const changeParallelNums = useCallback( + (num: number) => { + const newInputs = produce(inputs, (draft) => { + draft.parallel_nums = num + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) - const changeFlattenOutput = useCallback((value: boolean) => { - const newInputs = produce(inputs, (draft) => { - draft.flatten_output = value - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const changeFlattenOutput = useCallback( + (value: boolean) => { + const newInputs = produce(inputs, (draft) => { + draft.flatten_output = value + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) return { readOnly, diff --git a/web/app/components/workflow/nodes/iteration/use-interactions.helpers.ts b/web/app/components/workflow/nodes/iteration/use-interactions.helpers.ts index a6675080a8a4f0..20014ec28c8380 100644 --- a/web/app/components/workflow/nodes/iteration/use-interactions.helpers.ts +++ b/web/app/components/workflow/nodes/iteration/use-interactions.helpers.ts @@ -1,12 +1,5 @@ -import type { - BlockEnum, - ChildNodeTypeCount, - Node, -} from '../../types' -import { - ITERATION_PADDING, - NESTED_ELEMENT_Z_INDEX, -} from '../../constants' +import type { BlockEnum, ChildNodeTypeCount, Node } from '../../types' +import { ITERATION_PADDING, NESTED_ELEMENT_Z_INDEX } from '../../constants' import { CUSTOM_ITERATION_START_NODE } from '../iteration-start/constants' type ContainerBounds = { @@ -16,12 +9,16 @@ type ContainerBounds = { export const getIterationContainerBounds = (childrenNodes: Node[]): ContainerBounds => { return childrenNodes.reduce<ContainerBounds>((acc, node) => { - const nextRightNode = !acc.rightNode || node.position.x + node.width! > acc.rightNode.position.x + acc.rightNode.width! - ? node - : acc.rightNode - const nextBottomNode = !acc.bottomNode || node.position.y + node.height! > acc.bottomNode.position.y + acc.bottomNode.height! - ? node - : acc.bottomNode + const nextRightNode = + !acc.rightNode || + node.position.x + node.width! > acc.rightNode.position.x + acc.rightNode.width! + ? node + : acc.rightNode + const nextBottomNode = + !acc.bottomNode || + node.position.y + node.height! > acc.bottomNode.position.y + acc.bottomNode.height! + ? node + : acc.bottomNode return { rightNode: nextRightNode, @@ -31,12 +28,15 @@ export const getIterationContainerBounds = (childrenNodes: Node[]): ContainerBou } export const getIterationContainerResize = (currentNode: Node, bounds: ContainerBounds) => { - const width = bounds.rightNode && currentNode.width! < bounds.rightNode.position.x + bounds.rightNode.width! - ? bounds.rightNode.position.x + bounds.rightNode.width! + ITERATION_PADDING.right - : undefined - const height = bounds.bottomNode && currentNode.height! < bounds.bottomNode.position.y + bounds.bottomNode.height! - ? bounds.bottomNode.position.y + bounds.bottomNode.height! + ITERATION_PADDING.bottom - : undefined + const width = + bounds.rightNode && currentNode.width! < bounds.rightNode.position.x + bounds.rightNode.width! + ? bounds.rightNode.position.x + bounds.rightNode.width! + ITERATION_PADDING.right + : undefined + const height = + bounds.bottomNode && + currentNode.height! < bounds.bottomNode.position.y + bounds.bottomNode.height! + ? bounds.bottomNode.position.y + bounds.bottomNode.height! + ITERATION_PADDING.bottom + : undefined return { width, @@ -45,15 +45,12 @@ export const getIterationContainerResize = (currentNode: Node, bounds: Container } export const getRestrictedIterationPosition = (node: Node, parentNode?: Node) => { - const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined } + const restrictPosition: { x?: number; y?: number } = { x: undefined, y: undefined } - if (!node.data.isInIteration || !parentNode) - return restrictPosition + if (!node.data.isInIteration || !parentNode) return restrictPosition - if (node.position.y < ITERATION_PADDING.top) - restrictPosition.y = ITERATION_PADDING.top - if (node.position.x < ITERATION_PADDING.left) - restrictPosition.x = ITERATION_PADDING.left + if (node.position.y < ITERATION_PADDING.top) restrictPosition.y = ITERATION_PADDING.top + if (node.position.x < ITERATION_PADDING.left) restrictPosition.x = ITERATION_PADDING.left if (node.position.x + node.width! > parentNode.width! - ITERATION_PADDING.right) restrictPosition.x = parentNode.width! - ITERATION_PADDING.right - node.width! if (node.position.y + node.height! > parentNode.height! - ITERATION_PADDING.bottom) @@ -63,7 +60,9 @@ export const getRestrictedIterationPosition = (node: Node, parentNode?: Node) => } export const getIterationChildren = (nodes: Node[], nodeId: string) => { - return nodes.filter(node => node.parentId === nodeId && node.type !== CUSTOM_ITERATION_START_NODE) + return nodes.filter( + (node) => node.parentId === nodeId && node.type !== CUSTOM_ITERATION_START_NODE, + ) } export const getNextChildNodeTypeCount = ( @@ -73,8 +72,7 @@ export const getNextChildNodeTypeCount = ( ) => { if (!childNodeTypeCount[childNodeType]) childNodeTypeCount[childNodeType] = nodesWithSameTypeCount + 1 - else - childNodeTypeCount[childNodeType] = childNodeTypeCount[childNodeType] + 1 + else childNodeTypeCount[childNodeType] = childNodeTypeCount[childNodeType] + 1 return childNodeTypeCount[childNodeType] } diff --git a/web/app/components/workflow/nodes/iteration/use-interactions.ts b/web/app/components/workflow/nodes/iteration/use-interactions.ts index a10925a6aedb7e..e07a219bf3e06a 100644 --- a/web/app/components/workflow/nodes/iteration/use-interactions.ts +++ b/web/app/components/workflow/nodes/iteration/use-interactions.ts @@ -1,17 +1,10 @@ -import type { - BlockEnum, - ChildNodeTypeCount, - Node, -} from '../../types' +import type { BlockEnum, ChildNodeTypeCount, Node } from '../../types' import { produce } from 'immer' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useNodesMetaData } from '@/app/components/workflow/hooks' import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' -import { - generateNewNode, - getNodeCustomTypeByNodeDataType, -} from '../../utils' +import { generateNewNode, getNodeCustomTypeByNodeDataType } from '../../utils' import { buildIterationChildCopy, getIterationChildren, @@ -26,84 +19,106 @@ export const useNodeIterationInteractions = () => { const { nodesMap: nodesMetaDataMap } = useNodesMetaData() const collaborativeWorkflow = useCollaborativeWorkflow() - const handleNodeIterationRerender = useCallback((nodeId: string) => { - const { nodes, setNodes } = collaborativeWorkflow.getState() + const handleNodeIterationRerender = useCallback( + (nodeId: string) => { + const { nodes, setNodes } = collaborativeWorkflow.getState() - const currentNode = nodes.find(n => n.id === nodeId)! - const childrenNodes = nodes.filter(n => n.parentId === nodeId) - const resize = getIterationContainerResize(currentNode, getIterationContainerBounds(childrenNodes)) + const currentNode = nodes.find((n) => n.id === nodeId)! + const childrenNodes = nodes.filter((n) => n.parentId === nodeId) + const resize = getIterationContainerResize( + currentNode, + getIterationContainerBounds(childrenNodes), + ) - if (resize.width || resize.height) { - const newNodes = produce(nodes, (draft) => { - draft.forEach((n) => { - if (n.id === nodeId) { - if (resize.width) { - n.data.width = resize.width - n.width = resize.width - } - if (resize.height) { - n.data.height = resize.height - n.height = resize.height + if (resize.width || resize.height) { + const newNodes = produce(nodes, (draft) => { + draft.forEach((n) => { + if (n.id === nodeId) { + if (resize.width) { + n.data.width = resize.width + n.width = resize.width + } + if (resize.height) { + n.data.height = resize.height + n.height = resize.height + } } - } + }) }) - }) - setNodes(newNodes) - } - }, [collaborativeWorkflow]) + setNodes(newNodes) + } + }, + [collaborativeWorkflow], + ) - const handleNodeIterationChildDrag = useCallback((node: Node) => { - const { nodes } = collaborativeWorkflow.getState() + const handleNodeIterationChildDrag = useCallback( + (node: Node) => { + const { nodes } = collaborativeWorkflow.getState() - return { - restrictPosition: getRestrictedIterationPosition(node, nodes.find(n => n.id === node.parentId)), - } - }, [collaborativeWorkflow]) + return { + restrictPosition: getRestrictedIterationPosition( + node, + nodes.find((n) => n.id === node.parentId), + ), + } + }, + [collaborativeWorkflow], + ) - const handleNodeIterationChildSizeChange = useCallback((nodeId: string) => { - const { nodes } = collaborativeWorkflow.getState() - const currentNode = nodes.find(n => n.id === nodeId)! - const parentId = currentNode.parentId + const handleNodeIterationChildSizeChange = useCallback( + (nodeId: string) => { + const { nodes } = collaborativeWorkflow.getState() + const currentNode = nodes.find((n) => n.id === nodeId)! + const parentId = currentNode.parentId - if (parentId) - handleNodeIterationRerender(parentId) - }, [collaborativeWorkflow, handleNodeIterationRerender]) + if (parentId) handleNodeIterationRerender(parentId) + }, + [collaborativeWorkflow, handleNodeIterationRerender], + ) - const handleNodeIterationChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record<string, string>) => { - const { nodes } = collaborativeWorkflow.getState() - const childrenNodes = getIterationChildren(nodes, nodeId) - const newIdMapping = { ...idMapping } - const childNodeTypeCount: ChildNodeTypeCount = {} + const handleNodeIterationChildrenCopy = useCallback( + (nodeId: string, newNodeId: string, idMapping: Record<string, string>) => { + const { nodes } = collaborativeWorkflow.getState() + const childrenNodes = getIterationChildren(nodes, nodeId) + const newIdMapping = { ...idMapping } + const childNodeTypeCount: ChildNodeTypeCount = {} - const copyChildren = childrenNodes.map((child, index) => { - const childNodeType = child.data.type as BlockEnum - const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType) - const nextCount = getNextChildNodeTypeCount(childNodeTypeCount, childNodeType, nodesWithSameType.length) - const title = nodesWithSameType.length > 0 - ? `${t($ => $[`blocks.${childNodeType}`], { ns: 'workflow' })} ${nextCount}` - : t($ => $[`blocks.${childNodeType}`], { ns: 'workflow' }) - const childCopy = buildIterationChildCopy({ - child, - childNodeType, - defaultValue: nodesMetaDataMap![childNodeType].defaultValue as Node['data'], - title, - newNodeId, - }) - const { newNode } = generateNewNode({ - ...childCopy, - type: getNodeCustomTypeByNodeDataType(childNodeType), + const copyChildren = childrenNodes.map((child, index) => { + const childNodeType = child.data.type as BlockEnum + const nodesWithSameType = nodes.filter((node) => node.data.type === childNodeType) + const nextCount = getNextChildNodeTypeCount( + childNodeTypeCount, + childNodeType, + nodesWithSameType.length, + ) + const title = + nodesWithSameType.length > 0 + ? `${t(($) => $[`blocks.${childNodeType}`], { ns: 'workflow' })} ${nextCount}` + : t(($) => $[`blocks.${childNodeType}`], { ns: 'workflow' }) + const childCopy = buildIterationChildCopy({ + child, + childNodeType, + defaultValue: nodesMetaDataMap![childNodeType].defaultValue as Node['data'], + title, + newNodeId, + }) + const { newNode } = generateNewNode({ + ...childCopy, + type: getNodeCustomTypeByNodeDataType(childNodeType), + }) + newNode.id = `${newNodeId}${newNode.id + index}` + newIdMapping[child.id] = newNode.id + return newNode }) - newNode.id = `${newNodeId}${newNode.id + index}` - newIdMapping[child.id] = newNode.id - return newNode - }) - return { - copyChildren, - newIdMapping, - } - }, [nodesMetaDataMap, collaborativeWorkflow, t]) + return { + copyChildren, + newIdMapping, + } + }, + [nodesMetaDataMap, collaborativeWorkflow, t], + ) return { handleNodeIterationRerender, diff --git a/web/app/components/workflow/nodes/iteration/use-single-run-form-params.ts b/web/app/components/workflow/nodes/iteration/use-single-run-form-params.ts index 35db54747a2f83..c95068ade958f7 100644 --- a/web/app/components/workflow/nodes/iteration/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/iteration/use-single-run-form-params.ts @@ -8,7 +8,12 @@ import formatTracing from '@/app/components/workflow/run/utils/format-log' import { InputVarType, VarType } from '@/app/components/workflow/types' import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config' import { useIsNodeInIteration, useWorkflow } from '../../hooks' -import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar } from '../_base/components/variable/utils' +import { + getNodeInfoById, + getNodeUsedVarPassToServerKey, + getNodeUsedVars, + isSystemVar, +} from '../_base/components/variable/utils' const i18nPrefix = 'nodes.iteration' @@ -40,27 +45,35 @@ const useSingleRunFormParams = ({ const iteratorInputKey = `${id}.input_selector` const iterator = runInputData[iteratorInputKey] - const setIterator = useCallback((newIterator: string[]) => { - setRunInputData({ - ...runInputData, - [iteratorInputKey]: newIterator, - }) - }, [iteratorInputKey, runInputData, setRunInputData]) + const setIterator = useCallback( + (newIterator: string[]) => { + setRunInputData({ + ...runInputData, + [iteratorInputKey]: newIterator, + }) + }, + [iteratorInputKey, runInputData, setRunInputData], + ) const { usedOutVars, allVarObject } = (() => { const vars: ValueSelector[] = [] const varObjs: Record<string, boolean> = {} - const allVarObject: Record<string, { - inSingleRunPassedKey: string - }> = {} + const allVarObject: Record< + string, + { + inSingleRunPassedKey: string + } + > = {} iterationChildrenNodes.forEach((node) => { - const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0) + const nodeVars = getNodeUsedVars(node).filter((item) => item && item.length > 0) nodeVars.forEach((varSelector) => { - if (varSelector[0] === id) { // skip iteration node itself variable: item, index + if (varSelector[0] === id) { + // skip iteration node itself variable: item, index return } const isInIteration = isNodeInIteration(varSelector[0]!) - if (isInIteration) // not pass iteration inner variable + if (isInIteration) + // not pass iteration inner variable return const varSectorStr = varSelector.join('.') @@ -69,8 +82,7 @@ const useSingleRunFormParams = ({ vars.push(varSelector) } let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector) - if (typeof passToServerKeys === 'string') - passToServerKeys = [passToServerKeys] + if (typeof passToServerKeys === 'string') passToServerKeys = [passToServerKeys] passToServerKeys.forEach((key: string, index: number) => { allVarObject[[varSectorStr, node.id, index].join(DELIMITER)] = { @@ -79,33 +91,37 @@ const useSingleRunFormParams = ({ }) }) }) - const res = toVarInputs(vars.map((item) => { - const varInfo = getNodeInfoById(canChooseVarNodes, item[0]!) - return { - label: { - nodeType: varInfo?.data.type, - nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title, // default start node title - variable: isSystemVar(item) ? item.join('.') : item[item.length - 1]!, - }, - variable: `${item.join('.')}`, - value_selector: item, - } - })) + const res = toVarInputs( + vars.map((item) => { + const varInfo = getNodeInfoById(canChooseVarNodes, item[0]!) + return { + label: { + nodeType: varInfo?.data.type, + nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title, // default start node title + variable: isSystemVar(item) ? item.join('.') : item[item.length - 1]!, + }, + variable: `${item.join('.')}`, + value_selector: item, + } + }), + ) return { usedOutVars: res, allVarObject, } })() - const setInputVarValues = useCallback((newPayload: Record<string, any>) => { - setRunInputData(newPayload) - }, [setRunInputData]) + const setInputVarValues = useCallback( + (newPayload: Record<string, any>) => { + setRunInputData(newPayload) + }, + [setRunInputData], + ) const inputVarValues = (() => { const vars: Record<string, any> = {} - Object.keys(runInputData) - .forEach((key) => { - vars[key] = runInputData[key] - }) + Object.keys(runInputData).forEach((key) => { + vars[key] = runInputData[key] + }) return vars })() @@ -117,20 +133,31 @@ const useSingleRunFormParams = ({ onChange: setInputVarValues, }, { - label: t($ => $[`${i18nPrefix}.input`], { ns: 'workflow' })!, - inputs: [{ - label: '', - variable: iteratorInputKey, - type: InputVarType.iterator, - required: false, - getVarValueFromDependent: true, - isFileItem: payload.iterator_input_type === VarType.arrayFile, - }], + label: t(($) => $[`${i18nPrefix}.input`], { ns: 'workflow' })!, + inputs: [ + { + label: '', + variable: iteratorInputKey, + type: InputVarType.iterator, + required: false, + getVarValueFromDependent: true, + isFileItem: payload.iterator_input_type === VarType.arrayFile, + }, + ], values: { [iteratorInputKey]: iterator }, onChange: (keyValue: Record<string, any>) => setIterator(keyValue[iteratorInputKey]), }, ] - }, [inputVarValues, iterator, iteratorInputKey, payload.iterator_input_type, setInputVarValues, setIterator, t, usedOutVars]) + }, [ + inputVarValues, + iterator, + iteratorInputKey, + payload.iterator_input_type, + setInputVarValues, + setIterator, + t, + usedOutVars, + ]) const nodeInfo = formatTracing(iterationRunResult, t)[0] @@ -138,8 +165,7 @@ const useSingleRunFormParams = ({ return [payload.iterator_selector] } const getDependentVar = (variable: string) => { - if (variable === iteratorInputKey) - return payload.iterator_selector + if (variable === iteratorInputKey) return payload.iterator_selector } return { diff --git a/web/app/components/workflow/nodes/knowledge-base/__tests__/default.spec.ts b/web/app/components/workflow/nodes/knowledge-base/__tests__/default.spec.ts index 30f821e3eac4d1..de6431c9e0a887 100644 --- a/web/app/components/workflow/nodes/knowledge-base/__tests__/default.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-base/__tests__/default.spec.ts @@ -1,5 +1,8 @@ import type { KnowledgeBaseNodeType } from '../types' -import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + Model, + ModelItem, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { ConfigurationMethodEnum, ModelStatusEnum, @@ -11,11 +14,28 @@ import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from ' const t = withSelectorKey((key: string, _options?: Record<string, unknown>) => key) -const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => [{ - provider: 'openai', - icon_small: { en_US: '', zh_Hans: '' }, - label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - models: [{ +const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => [ + { + provider: 'openai', + icon_small: { en_US: '', zh_Hans: '' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [ + { + model: 'text-embedding-3-large', + label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' }, + model_type: ModelTypeEnum.textEmbedding, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status, + model_properties: {}, + load_balancing_enabled: false, + }, + ], + status, + }, +] + +const makeEmbeddingProviderModelList = (status: ModelStatusEnum): ModelItem[] => [ + { model: 'text-embedding-3-large', label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' }, model_type: ModelTypeEnum.textEmbedding, @@ -23,36 +43,26 @@ const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => [{ status, model_properties: {}, load_balancing_enabled: false, - }], - status, -}] - -const makeEmbeddingProviderModelList = (status: ModelStatusEnum): ModelItem[] => [{ - model: 'text-embedding-3-large', - label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' }, - model_type: ModelTypeEnum.textEmbedding, - fetch_from: ConfigurationMethodEnum.predefinedModel, - status, - model_properties: {}, - load_balancing_enabled: false, -}] - -const createPayload = (overrides: Partial<KnowledgeBaseNodeType> = {}): KnowledgeBaseNodeType => ({ - ...nodeDefault.defaultValue, - index_chunk_variable_selector: ['chunks', 'results'], - chunk_structure: ChunkStructureEnum.general, - indexing_technique: IndexMethodEnum.QUALIFIED, - embedding_model: 'text-embedding-3-large', - embedding_model_provider: 'openai', - retrieval_model: { - ...nodeDefault.defaultValue.retrieval_model, - search_method: RetrievalSearchMethodEnum.semantic, }, - _embeddingModelList: makeEmbeddingModelList(ModelStatusEnum.active), - _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.active), - _rerankModelList: [], - ...overrides, -}) as KnowledgeBaseNodeType +] + +const createPayload = (overrides: Partial<KnowledgeBaseNodeType> = {}): KnowledgeBaseNodeType => + ({ + ...nodeDefault.defaultValue, + index_chunk_variable_selector: ['chunks', 'results'], + chunk_structure: ChunkStructureEnum.general, + indexing_technique: IndexMethodEnum.QUALIFIED, + embedding_model: 'text-embedding-3-large', + embedding_model_provider: 'openai', + retrieval_model: { + ...nodeDefault.defaultValue.retrieval_model, + search_method: RetrievalSearchMethodEnum.semantic, + }, + _embeddingModelList: makeEmbeddingModelList(ModelStatusEnum.active), + _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.active), + _rerankModelList: [], + ...overrides, + }) as KnowledgeBaseNodeType describe('knowledge-base default node validation', () => { it('should return an invalid result when the payload has a validation issue', () => { diff --git a/web/app/components/workflow/nodes/knowledge-base/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/__tests__/node.spec.tsx index 5ce60ca959a07d..9321dc34e97234 100644 --- a/web/app/components/workflow/nodes/knowledge-base/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/__tests__/node.spec.tsx @@ -9,11 +9,7 @@ import { } from '@/app/components/header/account-setting/model-provider-page/declarations' import { BlockEnum } from '@/app/components/workflow/types' import Node from '../node' -import { - ChunkStructureEnum, - IndexMethodEnum, - RetrievalSearchMethodEnum, -} from '../types' +import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../types' const mockUseModelList = vi.hoisted(() => vi.fn()) const mockUseSettingsDisplay = vi.hoisted(() => vi.fn()) @@ -27,14 +23,20 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { } }) -vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', async (importOriginal) => { - const actual = await importOriginal<typeof import('@/app/components/header/account-setting/model-provider-page/hooks')>() - return { - ...actual, - useLanguage: () => 'en_US', - useModelList: mockUseModelList, - } -}) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/hooks', + async (importOriginal) => { + const actual = + await importOriginal< + typeof import('@/app/components/header/account-setting/model-provider-page/hooks') + >() + return { + ...actual, + useLanguage: () => 'en_US', + useModelList: mockUseModelList, + } + }, +) vi.mock('../hooks/use-settings-display', () => ({ useSettingsDisplay: mockUseSettingsDisplay, @@ -55,7 +57,9 @@ const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({ ...overrides, }) -const createNodeData = (overrides: Partial<CommonNodeType<KnowledgeBaseNodeType>> = {}): CommonNodeType<KnowledgeBaseNodeType> => ({ +const createNodeData = ( + overrides: Partial<CommonNodeType<KnowledgeBaseNodeType>> = {}, +): CommonNodeType<KnowledgeBaseNodeType> => ({ title: 'Knowledge Base', desc: '', type: BlockEnum.KnowledgeBase, @@ -108,7 +112,9 @@ describe('KnowledgeBaseNode', () => { render(<Node id="knowledge-base-1" data={createNodeData()} />) - expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument() + expect( + screen.getByText('common.modelProvider.selector.configureRequired'), + ).toBeInTheDocument() }) it('should render disabled when embedding model status is disabled', () => { @@ -190,10 +196,12 @@ describe('KnowledgeBaseNode', () => { mockUseModelList.mockImplementation((modelType: ModelTypeEnum) => { if (modelType === ModelTypeEnum.textEmbedding) { return { - data: [{ - provider: 'openai', - models: [createModelItem()], - }], + data: [ + { + provider: 'openai', + models: [createModelItem()], + }, + ], } } return { data: [] } diff --git a/web/app/components/workflow/nodes/knowledge-base/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/__tests__/panel.spec.tsx index 1bb4c7748f1db1..db8c74e4448c6d 100644 --- a/web/app/components/workflow/nodes/knowledge-base/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/__tests__/panel.spec.tsx @@ -10,7 +10,9 @@ const mockUseQuery = vi.hoisted(() => vi.fn()) const mockUseEmbeddingModelStatus = vi.hoisted(() => vi.fn()) const mockChunkStructure = vi.hoisted(() => vi.fn(() => <div data-testid="chunk-structure" />)) const mockEmbeddingModel = vi.hoisted(() => vi.fn(() => <div data-testid="embedding-model" />)) -const mockSummaryIndexSetting = vi.hoisted(() => vi.fn(() => <div data-testid="summary-index-setting" />)) +const mockSummaryIndexSetting = vi.hoisted(() => + vi.fn(() => <div data-testid="summary-index-setting" />), +) const mockQueryOptions = vi.hoisted(() => vi.fn((options: unknown) => options)) vi.mock('@tanstack/react-query', () => ({ @@ -80,9 +82,20 @@ vi.mock('@/config', async (importOriginal) => { vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({ Group: ({ children }: { children: ReactNode }) => <div data-testid="group">{children}</div>, - BoxGroup: ({ children }: { children: ReactNode }) => <div data-testid="box-group">{children}</div>, - BoxGroupField: ({ children, fieldProps }: { children: ReactNode, fieldProps: { fieldTitleProps: { warningDot?: boolean } } }) => ( - <div data-testid="box-group-field" data-warning-dot={String(!!fieldProps.fieldTitleProps.warningDot)}> + BoxGroup: ({ children }: { children: ReactNode }) => ( + <div data-testid="box-group">{children}</div> + ), + BoxGroupField: ({ + children, + fieldProps, + }: { + children: ReactNode + fieldProps: { fieldTitleProps: { warningDot?: boolean } } + }) => ( + <div + data-testid="box-group-field" + data-warning-dot={String(!!fieldProps.fieldTitleProps.warningDot)} + > {children} </div> ), @@ -149,10 +162,12 @@ describe('KnowledgeBasePanel', () => { mockUseModelList.mockImplementation((modelType: ModelTypeEnum) => { if (modelType === ModelTypeEnum.textEmbedding) { return { - data: [{ - provider: 'openai', - models: [{ model: 'text-embedding-3-large' }], - }], + data: [ + { + provider: 'openai', + models: [{ model: 'text-embedding-3-large' }], + }, + ], } } return { data: [] } @@ -161,30 +176,52 @@ describe('KnowledgeBasePanel', () => { }) it('should show a warning dot on chunk structure and skip nested sections when chunk structure is missing', () => { - render(<Panel id="knowledge-base-1" data={createData({ chunk_structure: undefined }) as never} panelProps={panelProps} />) + render( + <Panel + id="knowledge-base-1" + data={createData({ chunk_structure: undefined }) as never} + panelProps={panelProps} + />, + ) - expect(mockChunkStructure).toHaveBeenCalledWith(expect.objectContaining({ - warningDot: true, - }), undefined) + expect(mockChunkStructure).toHaveBeenCalledWith( + expect.objectContaining({ + warningDot: true, + }), + undefined, + ) expect(screen.queryByTestId('box-group-field')).not.toBeInTheDocument() - expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - enabled: true, - })) + expect(mockQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + }), + ) }) it('should pass warning dots and render summary settings when the qualified configuration needs attention', () => { mockUseEmbeddingModelStatus.mockReturnValue({ status: 'disabled' }) - render(<Panel id="knowledge-base-1" data={createData({ index_chunk_variable_selector: [] }) as never} panelProps={panelProps} />) + render( + <Panel + id="knowledge-base-1" + data={createData({ index_chunk_variable_selector: [] }) as never} + panelProps={panelProps} + />, + ) expect(screen.getByTestId('box-group-field')).toHaveAttribute('data-warning-dot', 'true') - expect(mockEmbeddingModel).toHaveBeenCalledWith(expect.objectContaining({ - warningDot: true, - }), undefined) - expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: { params: { provider: 'openai' } }, - enabled: true, - })) + expect(mockEmbeddingModel).toHaveBeenCalledWith( + expect.objectContaining({ + warningDot: true, + }), + undefined, + ) + expect(mockQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: { params: { provider: 'openai' } }, + enabled: true, + }), + ) expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument() }) @@ -199,8 +236,10 @@ describe('KnowledgeBasePanel', () => { expect(screen.queryByTestId('embedding-model')).not.toBeInTheDocument() expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument() - expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - enabled: false, - })) + expect(mockQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }), + ) }) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/knowledge-base/__tests__/use-single-run-form-params.spec.ts index 7124c05b629c2b..72b46993224799 100644 --- a/web/app/components/workflow/nodes/knowledge-base/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-base/__tests__/use-single-run-form-params.spec.ts @@ -32,35 +32,41 @@ describe('useSingleRunFormParams', () => { // The hook should expose the single query form and map chunk dependencies for single-run execution. describe('Forms', () => { it('should build the query form with the current run input value', () => { - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'knowledge-base-1', - payload: createPayload(), - runInputData: { query: 'what is dify' }, - getInputVars: vi.fn(), - setRunInputData: vi.fn(), - toVarInputs: vi.fn(), - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'knowledge-base-1', + payload: createPayload(), + runInputData: { query: 'what is dify' }, + getInputVars: vi.fn(), + setRunInputData: vi.fn(), + toVarInputs: vi.fn(), + }), + ) expect(result.current.forms).toHaveLength(1) - expect(result.current.forms[0]!.inputs).toEqual([{ - label: 'workflow.nodes.common.inputVars', - variable: 'query', - type: InputVarType.paragraph, - required: true, - }]) + expect(result.current.forms[0]!.inputs).toEqual([ + { + label: 'workflow.nodes.common.inputVars', + variable: 'query', + type: InputVarType.paragraph, + required: true, + }, + ]) expect(result.current.forms[0]!.values).toEqual({ query: 'what is dify' }) }) it('should update run input data when the query changes', () => { const setRunInputData = vi.fn() - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'knowledge-base-1', - payload: createPayload(), - runInputData: { query: 'old query' }, - getInputVars: vi.fn(), - setRunInputData, - toVarInputs: vi.fn(), - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'knowledge-base-1', + payload: createPayload(), + runInputData: { query: 'old query' }, + getInputVars: vi.fn(), + setRunInputData, + toVarInputs: vi.fn(), + }), + ) act(() => { result.current.forms[0]!.onChange({ query: 'new query' }) @@ -76,14 +82,16 @@ describe('useSingleRunFormParams', () => { index_chunk_variable_selector: ['node-1', 'chunks'], }) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'knowledge-base-1', - payload, - runInputData: {}, - getInputVars: vi.fn(), - setRunInputData: vi.fn(), - toVarInputs: vi.fn(), - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'knowledge-base-1', + payload, + runInputData: {}, + getInputVars: vi.fn(), + setRunInputData: vi.fn(), + toVarInputs: vi.fn(), + }), + ) expect(result.current.getDependentVars()).toEqual([['node-1', 'chunks']]) expect(result.current.getDependentVar('query')).toEqual(['node-1', 'chunks']) diff --git a/web/app/components/workflow/nodes/knowledge-base/__tests__/utils.spec.ts b/web/app/components/workflow/nodes/knowledge-base/__tests__/utils.spec.ts index 9ced5bad873dd4..f03dc42dcf6bd5 100644 --- a/web/app/components/workflow/nodes/knowledge-base/__tests__/utils.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-base/__tests__/utils.spec.ts @@ -1,17 +1,16 @@ import type { TFunction } from 'i18next' import type { KnowledgeBaseNodeType } from '../types' -import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + Model, + ModelItem, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum, } from '@/app/components/header/account-setting/model-provider-page/declarations' import { withSelectorKey } from '@/test/i18n-mock' -import { - ChunkStructureEnum, - IndexMethodEnum, - RetrievalSearchMethodEnum, -} from '../types' +import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../types' import { getKnowledgeBaseValidationIssue, getKnowledgeBaseValidationMessage, @@ -25,30 +24,34 @@ const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => { provider: 'openai', icon_small: { en_US: '', zh_Hans: '' }, label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, - models: [{ - model: 'gpt-4o', - label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' }, - model_type: ModelTypeEnum.textEmbedding, - fetch_from: ConfigurationMethodEnum.predefinedModel, - status, - model_properties: {}, - load_balancing_enabled: false, - }], + models: [ + { + model: 'gpt-4o', + label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' }, + model_type: ModelTypeEnum.textEmbedding, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status, + model_properties: {}, + load_balancing_enabled: false, + }, + ], status, }, ] } const makeEmbeddingProviderModelList = (status: ModelStatusEnum): ModelItem[] => { - return [{ - model: 'gpt-4o', - label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' }, - model_type: ModelTypeEnum.textEmbedding, - fetch_from: ConfigurationMethodEnum.predefinedModel, - status, - model_properties: {}, - load_balancing_enabled: false, - }] + return [ + { + model: 'gpt-4o', + label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' }, + model_type: ModelTypeEnum.textEmbedding, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status, + model_properties: {}, + load_balancing_enabled: false, + }, + ] } const makePayload = (overrides: Partial<KnowledgeBaseNodeType> = {}): KnowledgeBaseNodeType => { @@ -86,34 +89,46 @@ describe('knowledge-base validation issue', () => { }) it('returns chunks variable issue when chunks selector is empty', () => { - const issue = getKnowledgeBaseValidationIssue(makePayload({ index_chunk_variable_selector: [] })) + const issue = getKnowledgeBaseValidationIssue( + makePayload({ index_chunk_variable_selector: [] }), + ) expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.chunksVariableRequired) }) it('maps no-configure to configure required', () => { const issue = getKnowledgeBaseValidationIssue( - makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.noConfigure) }), + makePayload({ + _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.noConfigure), + }), ) expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired) }) it('maps credential-removed to API key unavailable', () => { const issue = getKnowledgeBaseValidationIssue( - makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.credentialRemoved) }), + makePayload({ + _embeddingProviderModelList: makeEmbeddingProviderModelList( + ModelStatusEnum.credentialRemoved, + ), + }), ) expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable) }) it('maps quota-exceeded to credits exhausted', () => { const issue = getKnowledgeBaseValidationIssue( - makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.quotaExceeded) }), + makePayload({ + _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.quotaExceeded), + }), ) expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted) }) it('maps disabled to disabled', () => { const issue = getKnowledgeBaseValidationIssue( - makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.disabled) }), + makePayload({ + _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.disabled), + }), ) expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelDisabled) }) @@ -129,23 +144,21 @@ describe('knowledge-base validation issue', () => { }) it('falls back to provider model list when provider scoped model list is empty', () => { - const issue = getKnowledgeBaseValidationIssue( - makePayload({ _embeddingProviderModelList: [] }), - ) + const issue = getKnowledgeBaseValidationIssue(makePayload({ _embeddingProviderModelList: [] })) expect(issue).toBeNull() }) it('returns embedding-model-not-configured when the qualified index is missing provider details', () => { - const issue = getKnowledgeBaseValidationIssue( - makePayload({ embedding_model: undefined }), - ) + const issue = getKnowledgeBaseValidationIssue(makePayload({ embedding_model: undefined })) expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured) }) it('maps no-permission embedding models to incompatible', () => { const issue = getKnowledgeBaseValidationIssue( - makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.noPermission) }), + makePayload({ + _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.noPermission), + }), ) expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible) @@ -191,21 +204,56 @@ describe('knowledge-base validation issue', () => { }) describe('knowledge-base validation messaging', () => { - const t = withSelectorKey((key: string, _options?: Record<string, unknown>) => key) as unknown as TFunction + const t = withSelectorKey( + (key: string, _options?: Record<string, unknown>) => key, + ) as unknown as TFunction it.each([ - [KnowledgeBaseValidationIssueCode.chunkStructureRequired, 'nodes.knowledgeBase.chunkIsRequired'], - [KnowledgeBaseValidationIssueCode.chunksVariableRequired, 'nodes.knowledgeBase.chunksVariableIsRequired'], - [KnowledgeBaseValidationIssueCode.indexMethodRequired, 'nodes.knowledgeBase.indexMethodIsRequired'], - [KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured, 'nodes.knowledgeBase.embeddingModelNotConfigured'], - [KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired, 'modelProvider.selector.configureRequired'], - [KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable, 'modelProvider.selector.apiKeyUnavailable'], - [KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted, 'modelProvider.selector.creditsExhausted'], + [ + KnowledgeBaseValidationIssueCode.chunkStructureRequired, + 'nodes.knowledgeBase.chunkIsRequired', + ], + [ + KnowledgeBaseValidationIssueCode.chunksVariableRequired, + 'nodes.knowledgeBase.chunksVariableIsRequired', + ], + [ + KnowledgeBaseValidationIssueCode.indexMethodRequired, + 'nodes.knowledgeBase.indexMethodIsRequired', + ], + [ + KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured, + 'nodes.knowledgeBase.embeddingModelNotConfigured', + ], + [ + KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired, + 'modelProvider.selector.configureRequired', + ], + [ + KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable, + 'modelProvider.selector.apiKeyUnavailable', + ], + [ + KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted, + 'modelProvider.selector.creditsExhausted', + ], [KnowledgeBaseValidationIssueCode.embeddingModelDisabled, 'modelProvider.selector.disabled'], - [KnowledgeBaseValidationIssueCode.embeddingModelIncompatible, 'modelProvider.selector.incompatible'], - [KnowledgeBaseValidationIssueCode.retrievalSettingRequired, 'nodes.knowledgeBase.retrievalSettingIsRequired'], - [KnowledgeBaseValidationIssueCode.rerankingModelRequired, 'nodes.knowledgeBase.rerankingModelIsRequired'], - [KnowledgeBaseValidationIssueCode.rerankingModelInvalid, 'nodes.knowledgeBase.rerankingModelIsInvalid'], + [ + KnowledgeBaseValidationIssueCode.embeddingModelIncompatible, + 'modelProvider.selector.incompatible', + ], + [ + KnowledgeBaseValidationIssueCode.retrievalSettingRequired, + 'nodes.knowledgeBase.retrievalSettingIsRequired', + ], + [ + KnowledgeBaseValidationIssueCode.rerankingModelRequired, + 'nodes.knowledgeBase.rerankingModelIsRequired', + ], + [ + KnowledgeBaseValidationIssueCode.rerankingModelInvalid, + 'nodes.knowledgeBase.rerankingModelIsInvalid', + ], ] as const)('maps %s to the expected translation key', (code, expectedKey) => { expect(getKnowledgeBaseValidationMessage({ code }, t)).toBe(expectedKey) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/__tests__/embedding-model.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/embedding-model.spec.tsx index db8bdeb0e1a906..863ca4291c753c 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/__tests__/embedding-model.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/embedding-model.spec.tsx @@ -4,10 +4,18 @@ import { ModelTypeEnum } from '@/app/components/header/account-setting/model-pro import EmbeddingModel from '../embedding-model' const mockUseModelList = vi.hoisted(() => vi.fn()) -const mockModelSelector = vi.hoisted(() => vi.fn(() => <div data-testid="model-selector">selector</div>)) +const mockModelSelector = vi.hoisted(() => + vi.fn(() => <div data-testid="model-selector">selector</div>), +) vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({ - Field: ({ children, fieldTitleProps }: { children: ReactNode, fieldTitleProps: { warningDot?: boolean } }) => ( + Field: ({ + children, + fieldTitleProps, + }: { + children: ReactNode + fieldTitleProps: { warningDot?: boolean } + }) => ( <div data-testid="field" data-warning-dot={String(!!fieldTitleProps.warningDot)}> {children} </div> @@ -25,7 +33,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec describe('EmbeddingModel', () => { beforeEach(() => { vi.clearAllMocks() - mockUseModelList.mockReturnValue({ data: [{ provider: 'openai', model: 'text-embedding-3-large' }] }) + mockUseModelList.mockReturnValue({ + data: [{ provider: 'openai', model: 'text-embedding-3-large' }], + }) }) it('should pass the selected model configuration and warning state to the selector field', () => { @@ -41,22 +51,28 @@ describe('EmbeddingModel', () => { ) expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.textEmbedding) - expect(mockModelSelector).toHaveBeenCalledWith(expect.objectContaining({ - defaultModel: { - provider: 'openai', - model: 'text-embedding-3-large', - }, - modelList: [{ provider: 'openai', model: 'text-embedding-3-large' }], - readonly: false, - showDeprecatedWarnIcon: true, - }), undefined) + expect(mockModelSelector).toHaveBeenCalledWith( + expect.objectContaining({ + defaultModel: { + provider: 'openai', + model: 'text-embedding-3-large', + }, + modelList: [{ provider: 'openai', model: 'text-embedding-3-large' }], + readonly: false, + showDeprecatedWarnIcon: true, + }), + undefined, + ) }) it('should pass an undefined default model when the embedding model is incomplete', () => { render(<EmbeddingModel embeddingModel="text-embedding-3-large" />) - expect(mockModelSelector).toHaveBeenCalledWith(expect.objectContaining({ - defaultModel: undefined, - }), undefined) + expect(mockModelSelector).toHaveBeenCalledWith( + expect.objectContaining({ + defaultModel: undefined, + }), + undefined, + ) }) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/__tests__/index-method.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/index-method.spec.tsx index 583c1c8966ec12..41a7b6a0f866d0 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/__tests__/index-method.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/index-method.spec.tsx @@ -37,7 +37,9 @@ describe('IndexMethod', () => { />, ) - fireEvent.change(container.querySelector('input') as HTMLInputElement, { target: { value: '7' } }) + fireEvent.change(container.querySelector('input') as HTMLInputElement, { + target: { value: '7' }, + }) expect(onKeywordNumberChange).toHaveBeenCalledWith(7, expect.anything()) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/__tests__/option-card.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/option-card.spec.tsx index 0c4e53b8fdb65c..799baf06ca7fd8 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/__tests__/option-card.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/option-card.spec.tsx @@ -39,14 +39,7 @@ describe('OptionCard', () => { const user = userEvent.setup() const onClick = vi.fn() - render( - <OptionCard - id="economical" - title="Economical" - readonly - onClick={onClick} - />, - ) + render(<OptionCard id="economical" title="Economical" readonly onClick={onClick} />) await user.click(screen.getByText('Economical')) @@ -60,9 +53,11 @@ describe('OptionCard', () => { selectedId="qualified" title="Inactive card" enableSelect={false} - wrapperClassName={isActive => (isActive ? 'wrapper-active' : 'wrapper-inactive')} - className={isActive => (isActive ? 'body-active' : 'body-inactive')} - icon={isActive => <span data-testid="option-icon">{isActive ? 'active' : 'inactive'}</span>} + wrapperClassName={(isActive) => (isActive ? 'wrapper-active' : 'wrapper-inactive')} + className={(isActive) => (isActive ? 'body-active' : 'body-inactive')} + icon={(isActive) => ( + <span data-testid="option-icon">{isActive ? 'active' : 'inactive'}</span> + )} />, ) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/hooks.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/hooks.spec.tsx index a7620d4317b8a5..1a8565f5c7edd7 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/hooks.spec.tsx @@ -2,9 +2,11 @@ import { render, renderHook } from '@testing-library/react' import { ChunkStructureEnum } from '../../../types' import { useChunkStructure } from '../hooks' -const renderIcon = (icon: ReturnType<typeof useChunkStructure>['options'][number]['icon'], isActive: boolean) => { - if (typeof icon !== 'function') - throw new Error('expected icon renderer') +const renderIcon = ( + icon: ReturnType<typeof useChunkStructure>['options'][number]['icon'], + isActive: boolean, +) => { + if (typeof icon !== 'function') throw new Error('expected icon renderer') return icon(isActive) } @@ -20,23 +22,35 @@ describe('useChunkStructure', () => { const { result } = renderHook(() => useChunkStructure()) expect(result.current.options).toHaveLength(3) - expect(result.current.options.map(option => option.id)).toEqual([ + expect(result.current.options.map((option) => option.id)).toEqual([ ChunkStructureEnum.general, ChunkStructureEnum.parent_child, ChunkStructureEnum.question_answer, ]) - expect(result.current.optionMap[ChunkStructureEnum.general].title).toBe('datasetCreation.stepTwo.general') - expect(result.current.optionMap[ChunkStructureEnum.parent_child].title).toBe('datasetCreation.stepTwo.parentChild') + expect(result.current.optionMap[ChunkStructureEnum.general].title).toBe( + 'datasetCreation.stepTwo.general', + ) + expect(result.current.optionMap[ChunkStructureEnum.parent_child].title).toBe( + 'datasetCreation.stepTwo.parentChild', + ) expect(result.current.optionMap[ChunkStructureEnum.question_answer].title).toBe('Q&A') }) it('should expose active and inactive icon renderers for every option', () => { const { result } = renderHook(() => useChunkStructure()) - const generalInactive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.general].icon, false)}</>).container.firstChild as HTMLElement - const generalActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.general].icon, true)}</>).container.firstChild as HTMLElement - const parentChildActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.parent_child].icon, true)}</>).container.firstChild as HTMLElement - const questionAnswerActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.question_answer].icon, true)}</>).container.firstChild as HTMLElement + const generalInactive = render( + <>{renderIcon(result.current.optionMap[ChunkStructureEnum.general].icon, false)}</>, + ).container.firstChild as HTMLElement + const generalActive = render( + <>{renderIcon(result.current.optionMap[ChunkStructureEnum.general].icon, true)}</>, + ).container.firstChild as HTMLElement + const parentChildActive = render( + <>{renderIcon(result.current.optionMap[ChunkStructureEnum.parent_child].icon, true)}</>, + ).container.firstChild as HTMLElement + const questionAnswerActive = render( + <>{renderIcon(result.current.optionMap[ChunkStructureEnum.question_answer].icon, true)}</>, + ).container.firstChild as HTMLElement expect(generalInactive).toHaveClass('text-text-tertiary') expect(generalActive).toHaveClass('text-util-colors-indigo-indigo-600') diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/index.spec.tsx index 454d57e5b50668..9e65d1431d6c17 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/index.spec.tsx @@ -6,7 +6,13 @@ import ChunkStructure from '../index' const mockUseChunkStructure = vi.hoisted(() => vi.fn()) vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({ - Field: ({ children, fieldTitleProps }: { children: ReactNode, fieldTitleProps: { title: string, warningDot?: boolean, operation?: ReactNode } }) => ( + Field: ({ + children, + fieldTitleProps, + }: { + children: ReactNode + fieldTitleProps: { title: string; warningDot?: boolean; operation?: ReactNode } + }) => ( <div data-testid="field" data-warning-dot={String(!!fieldTitleProps.warningDot)}> <div>{fieldTitleProps.title}</div> {fieldTitleProps.operation} @@ -24,7 +30,7 @@ vi.mock('../../option-card', () => ({ })) vi.mock('../selector', () => ({ - default: ({ trigger, value }: { trigger?: ReactNode, value?: string }) => ( + default: ({ trigger, value }: { trigger?: ReactNode; value?: string }) => ( <div data-testid="selector"> {value ?? 'no-value'} {trigger} @@ -33,7 +39,11 @@ vi.mock('../selector', () => ({ })) vi.mock('../instruction', () => ({ - default: ({ className }: { className?: string }) => <div data-testid="instruction" className={className}>Instruction</div>, + default: ({ className }: { className?: string }) => ( + <div data-testid="instruction" className={className}> + Instruction + </div> + ), })) describe('ChunkStructure', () => { @@ -65,11 +75,7 @@ describe('ChunkStructure', () => { }) it('should render the add trigger and instruction when no chunk structure is selected', () => { - render( - <ChunkStructure - onChunkStructureChange={vi.fn()} - />, - ) + render(<ChunkStructure onChunkStructureChange={vi.fn()} />) expect(screen.getByRole('button', { name: /chooseChunkStructure/i })).toBeInTheDocument() expect(screen.getByTestId('instruction')).toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/selector.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/selector.spec.tsx index bd2ec4cf531197..3392ff9fddd9e9 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/selector.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/selector.spec.tsx @@ -23,23 +23,21 @@ describe('ChunkStructureSelector', () => { it('should open the selector panel and close it after selecting an option', async () => { const onChange = vi.fn() - render( - <Selector - options={options} - value={ChunkStructureEnum.general} - onChange={onChange} - />, - ) + render(<Selector options={options} value={ChunkStructureEnum.general} onChange={onChange} />) fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.change' })) - expect(screen.getByText('workflow.nodes.knowledgeBase.changeChunkStructure')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.knowledgeBase.changeChunkStructure'), + ).toBeInTheDocument() fireEvent.click(screen.getByText('Parent child')) expect(onChange).toHaveBeenCalledWith(ChunkStructureEnum.parent_child) await waitFor(() => { - expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure'), + ).not.toBeInTheDocument() }) }) @@ -56,6 +54,8 @@ describe('ChunkStructureSelector', () => { const trigger = screen.getByText('custom-trigger').closest('[role="button"]') fireEvent.click(trigger!) - expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure'), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/hooks.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/hooks.tsx index 11620d63e3231a..99a8f33067b15b 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/hooks.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/hooks.tsx @@ -20,8 +20,8 @@ export const useChunkStructure = () => { )} /> ), - title: t($ => $['stepTwo.general'], { ns: 'datasetCreation' }), - description: t($ => $['stepTwo.generalTip'], { ns: 'datasetCreation' }), + title: t(($) => $['stepTwo.general'], { ns: 'datasetCreation' }), + description: t(($) => $['stepTwo.generalTip'], { ns: 'datasetCreation' }), effectColor: 'blue', } const ParentChildOption: Option = { @@ -34,8 +34,8 @@ export const useChunkStructure = () => { )} /> ), - title: t($ => $['stepTwo.parentChild'], { ns: 'datasetCreation' }), - description: t($ => $['stepTwo.parentChildTip'], { ns: 'datasetCreation' }), + title: t(($) => $['stepTwo.parentChild'], { ns: 'datasetCreation' }), + description: t(($) => $['stepTwo.parentChildTip'], { ns: 'datasetCreation' }), effectColor: 'blue-light', } const QuestionAnswerOption: Option = { @@ -49,7 +49,7 @@ export const useChunkStructure = () => { /> ), title: 'Q&A', - description: t($ => $['stepTwo.qaTip'], { ns: 'datasetCreation' }), + description: t(($) => $['stepTwo.qaTip'], { ns: 'datasetCreation' }), effectColor: 'teal', } @@ -59,11 +59,7 @@ export const useChunkStructure = () => { [ChunkStructureEnum.question_answer]: QuestionAnswerOption, } - const options = [ - GeneralOption, - ParentChildOption, - QuestionAnswerOption, - ] + const options = [GeneralOption, ParentChildOption, QuestionAnswerOption] return { options, diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.tsx index caea9a6a4592f7..11b1f1c3fadb4c 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.tsx @@ -21,16 +21,13 @@ const ChunkStructure = ({ readonly = false, }: ChunkStructureProps) => { const { t } = useTranslation() - const { - options, - optionMap, - } = useChunkStructure() + const { options, optionMap } = useChunkStructure() return ( <Field fieldTitleProps={{ - title: t($ => $['nodes.knowledgeBase.chunkStructure'], { ns: 'workflow' }), - tooltip: t($ => $['nodes.knowledgeBase.chunkStructureTip.message'], { ns: 'workflow' }), + title: t(($) => $['nodes.knowledgeBase.chunkStructure'], { ns: 'workflow' }), + tooltip: t(($) => $['nodes.knowledgeBase.chunkStructureTip.message'], { ns: 'workflow' }), warningDot, operation: chunkStructure && ( <Selector @@ -42,37 +39,30 @@ const ChunkStructure = ({ ), }} > - { - !!chunkStructure && ( - <OptionCard - {...optionMap[chunkStructure]} - selectedId={chunkStructure} - enableSelect={false} - enableHighlightBorder={false} + {!!chunkStructure && ( + <OptionCard + {...optionMap[chunkStructure]} + selectedId={chunkStructure} + enableSelect={false} + enableHighlightBorder={false} + /> + )} + {!chunkStructure && ( + <> + <Selector + options={options} + onChange={onChunkStructureChange} + readonly={readonly} + trigger={ + <Button className="w-full" variant="secondary-accent"> + <span className="mr-1 i-ri-add-line size-4" /> + {t(($) => $['nodes.knowledgeBase.chooseChunkStructure'], { ns: 'workflow' })} + </Button> + } /> - ) - } - { - !chunkStructure && ( - <> - <Selector - options={options} - onChange={onChunkStructureChange} - readonly={readonly} - trigger={( - <Button - className="w-full" - variant="secondary-accent" - > - <span className="mr-1 i-ri-add-line size-4" /> - {t($ => $['nodes.knowledgeBase.chooseChunkStructure'], { ns: 'workflow' })} - </Button> - )} - /> - <Instruction className="mt-2" /> - </> - ) - } + <Instruction className="mt-2" /> + </> + )} </Field> ) } diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/__tests__/index.spec.tsx index 20eee01c00f2dd..0efac0e6a72699 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/__tests__/index.spec.tsx @@ -18,9 +18,17 @@ describe('ChunkStructureInstruction', () => { it('should render the title, message, and learn-more link', () => { render(<Instruction className="custom-class" />) - expect(screen.getByText('workflow.nodes.knowledgeBase.chunkStructureTip.title')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.knowledgeBase.chunkStructureTip.message')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'workflow.nodes.knowledgeBase.chunkStructureTip.learnMore' })).toHaveAttribute( + expect( + screen.getByText('workflow.nodes.knowledgeBase.chunkStructureTip.title'), + ).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.knowledgeBase.chunkStructureTip.message'), + ).toBeInTheDocument() + expect( + screen.getByRole('link', { + name: 'workflow.nodes.knowledgeBase.chunkStructureTip.learnMore', + }), + ).toHaveAttribute( 'href', 'https://docs.example.com/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text', ) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/index.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/index.tsx index d103f629880f14..98ba7888035f5f 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/index.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/index.tsx @@ -9,14 +9,17 @@ type InstructionProps = { className?: string } -const Instruction = ({ - className, -}: InstructionProps) => { +const Instruction = ({ className }: InstructionProps) => { const { t } = useTranslation() const docLink = useDocLink() return ( - <div className={cn('flex flex-col gap-y-2 overflow-hidden rounded-[10px] bg-workflow-process-bg p-4', className)}> + <div + className={cn( + 'flex flex-col gap-y-2 overflow-hidden rounded-[10px] bg-workflow-process-bg p-4', + className, + )} + > <div className="relative flex size-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]"> <AddChunks className="size-5 text-text-accent" /> <Line className="absolute bottom-[-76px] -left-px" type="vertical" /> @@ -26,17 +29,19 @@ const Instruction = ({ </div> <div className="flex flex-col gap-y-1"> <div className="system-sm-medium text-text-secondary"> - {t($ => $['nodes.knowledgeBase.chunkStructureTip.title'], { ns: 'workflow' })} + {t(($) => $['nodes.knowledgeBase.chunkStructureTip.title'], { ns: 'workflow' })} </div> <div className="system-xs-regular"> - <p className="text-text-tertiary">{t($ => $['nodes.knowledgeBase.chunkStructureTip.message'], { ns: 'workflow' })}</p> + <p className="text-text-tertiary"> + {t(($) => $['nodes.knowledgeBase.chunkStructureTip.message'], { ns: 'workflow' })} + </p> <a href={docLink('/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text')} target="_blank" rel="noopener noreferrer" className="text-text-accent" > - {t($ => $['nodes.knowledgeBase.chunkStructureTip.learnMore'], { ns: 'workflow' })} + {t(($) => $['nodes.knowledgeBase.chunkStructureTip.learnMore'], { ns: 'workflow' })} </a> </div> </div> diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/line.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/line.tsx index c4eab5d3704d3c..6dd108a7f65efb 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/line.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/line.tsx @@ -5,16 +5,27 @@ type LineProps = { className?: string } -const Line = ({ - type = 'vertical', - className, -}: LineProps) => { +const Line = ({ type = 'vertical', className }: LineProps) => { if (type === 'vertical') { return ( - <svg xmlns="http://www.w3.org/2000/svg" width="2" height="132" viewBox="0 0 2 132" fill="none" className={className}> + <svg + xmlns="http://www.w3.org/2000/svg" + width="2" + height="132" + viewBox="0 0 2 132" + fill="none" + className={className} + > <path d="M1 0L1 132" stroke="url(#paint0_linear_10882_18766)" /> <defs> - <linearGradient id="paint0_linear_10882_18766" x1="-7.99584" y1="132" x2="-7.96108" y2="6.4974e-07" gradientUnits="userSpaceOnUse"> + <linearGradient + id="paint0_linear_10882_18766" + x1="-7.99584" + y1="132" + x2="-7.96108" + y2="6.4974e-07" + gradientUnits="userSpaceOnUse" + > <stop stopColor="var(--color-background-gradient-mask-transparent)" /> <stop offset="0.877606" stopColor="var(--color-divider-subtle)" /> <stop offset="1" stopColor="var(--color-background-gradient-mask-transparent)" /> @@ -25,10 +36,24 @@ const Line = ({ } return ( - <svg xmlns="http://www.w3.org/2000/svg" width="240" height="2" viewBox="0 0 240 2" fill="none" className={className}> + <svg + xmlns="http://www.w3.org/2000/svg" + width="240" + height="2" + viewBox="0 0 240 2" + fill="none" + className={className} + > <path d="M0 1H240" stroke="url(#paint0_linear_10882_18763)" /> <defs> - <linearGradient id="paint0_linear_10882_18763" x1="240" y1="9.99584" x2="3.95539e-05" y2="9.88094" gradientUnits="userSpaceOnUse"> + <linearGradient + id="paint0_linear_10882_18763" + x1="240" + y1="9.99584" + x2="3.95539e-05" + y2="9.88094" + gradientUnits="userSpaceOnUse" + > <stop stopColor="var(--color-background-gradient-mask-transparent)" /> <stop offset="0.9031" stopColor="var(--color-divider-subtle)" /> <stop offset="1" stopColor="var(--color-background-gradient-mask-transparent)" /> diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/selector.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/selector.tsx index 25523f23a48e9f..bd06067e418afd 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/selector.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/selector.tsx @@ -2,11 +2,7 @@ import type { ReactNode } from 'react' import type { ChunkStructureEnum } from '../../types' import type { Option } from './type' import { Button } from '@langgenius/dify-ui/button' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import OptionCard from '../option-card' @@ -18,54 +14,36 @@ type SelectorProps = { readonly?: boolean trigger?: ReactNode } -const Selector = ({ - options, - value, - onChange, - readonly, - trigger, -}: SelectorProps) => { +const Selector = ({ options, value, onChange, readonly, trigger }: SelectorProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) - const handleSelect = useCallback((optionId: ChunkStructureEnum) => { - onChange(optionId) - setOpen(false) - }, [onChange]) - const handleOpenChange = useCallback((nextOpen: boolean) => { - if (readonly && nextOpen) - return - setOpen(nextOpen) - }, [readonly]) + const handleSelect = useCallback( + (optionId: ChunkStructureEnum) => { + onChange(optionId) + setOpen(false) + }, + [onChange], + ) + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (readonly && nextOpen) return + setOpen(nextOpen) + }, + [readonly], + ) return ( - <Popover - open={open} - onOpenChange={handleOpenChange} - > - { - trigger - ? ( - <PopoverTrigger - nativeButton={false} - render={<div />} - > - {trigger} - </PopoverTrigger> - ) - : ( - <PopoverTrigger - render={( - <Button - size="small" - variant="ghost-accent" - /> - )} - > - {t($ => $['panel.change'], { ns: 'workflow' })} - </PopoverTrigger> - ) - } + <Popover open={open} onOpenChange={handleOpenChange}> + {trigger ? ( + <PopoverTrigger nativeButton={false} render={<div />}> + {trigger} + </PopoverTrigger> + ) : ( + <PopoverTrigger render={<Button size="small" variant="ghost-accent" />}> + {t(($) => $['panel.change'], { ns: 'workflow' })} + </PopoverTrigger> + )} <PopoverContent placement="bottom-end" sideOffset={0} @@ -74,25 +52,22 @@ const Selector = ({ > <div className="w-[404px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]"> <div className="px-3 pt-3.5 system-sm-semibold text-text-primary"> - {t($ => $['nodes.knowledgeBase.changeChunkStructure'], { ns: 'workflow' })} + {t(($) => $['nodes.knowledgeBase.changeChunkStructure'], { ns: 'workflow' })} </div> <div className="space-y-1 p-3 pt-2"> - { - options.map(option => ( - <OptionCard - key={option.id} - id={option.id} - selectedId={value} - icon={option.icon} - title={option.title} - description={option.description} - readonly={readonly} - onClick={handleSelect} - effectColor={option.effectColor} - > - </OptionCard> - )) - } + {options.map((option) => ( + <OptionCard + key={option.id} + id={option.id} + selectedId={value} + icon={option.icon} + title={option.title} + description={option.description} + readonly={readonly} + onClick={handleSelect} + effectColor={option.effectColor} + ></OptionCard> + ))} </div> </div> </PopoverContent> diff --git a/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.tsx b/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.tsx index 7dfc2eede237cf..11622874830be3 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.tsx @@ -1,9 +1,5 @@ import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { - memo, - useCallback, - useMemo, -} from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' @@ -28,12 +24,9 @@ const EmbeddingModel = ({ readonly = false, }: EmbeddingModelProps) => { const { t } = useTranslation() - const { - data: embeddingModelList, - } = useModelList(ModelTypeEnum.textEmbedding) + const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) const embeddingModelConfig = useMemo(() => { - if (!embeddingModel || !embeddingModelProvider) - return undefined + if (!embeddingModel || !embeddingModelProvider) return undefined return { providerName: embeddingModelProvider, @@ -41,22 +34,30 @@ const EmbeddingModel = ({ } }, [embeddingModel, embeddingModelProvider]) - const handleEmbeddingModelChange = useCallback((model: DefaultModel) => { - onEmbeddingModelChange?.({ - embeddingModelProvider: model.provider, - embeddingModel: model.model, - }) - }, [onEmbeddingModelChange]) + const handleEmbeddingModelChange = useCallback( + (model: DefaultModel) => { + onEmbeddingModelChange?.({ + embeddingModelProvider: model.provider, + embeddingModel: model.model, + }) + }, + [onEmbeddingModelChange], + ) return ( <Field fieldTitleProps={{ - title: t($ => $['form.embeddingModel'], { ns: 'datasetSettings' }), + title: t(($) => $['form.embeddingModel'], { ns: 'datasetSettings' }), warningDot, }} > <ModelSelector - defaultModel={embeddingModelConfig && { provider: embeddingModelConfig.providerName, model: embeddingModelConfig.modelName }} + defaultModel={ + embeddingModelConfig && { + provider: embeddingModelConfig.providerName, + model: embeddingModelConfig.modelName, + } + } modelList={embeddingModelList} onSelect={handleEmbeddingModelChange} readonly={readonly} diff --git a/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx b/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx index 85b879ab082b46..33e79e18e3410d 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx @@ -1,22 +1,13 @@ import { cn } from '@langgenius/dify-ui/cn' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' import { Slider } from '@langgenius/dify-ui/slider' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { - Economic, - HighQuality, -} from '@/app/components/base/icons/src/vender/knowledge' +import { Economic, HighQuality } from '@/app/components/base/icons/src/vender/knowledge' import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' import { Field } from '@/app/components/workflow/nodes/_base/components/layout' -import { - ChunkStructureEnum, - IndexMethodEnum, -} from '../types' +import { ChunkStructureEnum, IndexMethodEnum } from '../types' import OptionCard from './option-card' type IndexMethodProps = { @@ -36,96 +27,98 @@ const IndexMethod = ({ readonly = false, }: IndexMethodProps) => { const { t } = useTranslation() - const keywordNumberLabel = t($ => $['form.numberOfKeywords'], { ns: 'datasetSettings' }) + const keywordNumberLabel = t(($) => $['form.numberOfKeywords'], { ns: 'datasetSettings' }) const isHighQuality = indexMethod === IndexMethodEnum.QUALIFIED const isEconomy = indexMethod === IndexMethodEnum.ECONOMICAL - const handleIndexMethodChange = useCallback((newIndexMethod: IndexMethodEnum) => { - onIndexMethodChange(newIndexMethod) - }, [onIndexMethodChange]) + const handleIndexMethodChange = useCallback( + (newIndexMethod: IndexMethodEnum) => { + onIndexMethodChange(newIndexMethod) + }, + [onIndexMethodChange], + ) - const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { - const value = Number(e.target.value) - if (!Number.isNaN(value)) - onKeywordNumberChange(value) - }, [onKeywordNumberChange]) + const handleInputChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + const value = Number(e.target.value) + if (!Number.isNaN(value)) onKeywordNumberChange(value) + }, + [onKeywordNumberChange], + ) return ( <Field fieldTitleProps={{ - title: t($ => $['stepTwo.indexMode'], { ns: 'datasetCreation' }), + title: t(($) => $['stepTwo.indexMode'], { ns: 'datasetCreation' }), }} > <div className="space-y-1"> <OptionCard<IndexMethodEnum> id={IndexMethodEnum.QUALIFIED} selectedId={indexMethod} - icon={( + icon={ <HighQuality className={cn( 'h-[15px] w-[15px] text-text-tertiary group-hover:text-util-colors-orange-orange-500', isHighQuality && 'text-util-colors-orange-orange-500', )} /> - )} - title={t($ => $['stepTwo.qualified'], { ns: 'datasetCreation' })} - description={t($ => $['form.indexMethodHighQualityTip'], { ns: 'datasetSettings' })} + } + title={t(($) => $['stepTwo.qualified'], { ns: 'datasetCreation' })} + description={t(($) => $['form.indexMethodHighQualityTip'], { ns: 'datasetSettings' })} onClick={handleIndexMethodChange} isRecommended effectColor="orange" - > - </OptionCard> - { - chunkStructure === ChunkStructureEnum.general && ( - <OptionCard - id={IndexMethodEnum.ECONOMICAL} - selectedId={indexMethod} - icon={( - <Economic - className={cn( - 'h-[15px] w-[15px] text-text-tertiary group-hover:text-util-colors-indigo-indigo-500', - isEconomy && 'text-util-colors-indigo-indigo-500', - )} - /> - )} - title={t($ => $['form.indexMethodEconomy'], { ns: 'datasetSettings' })} - description={t($ => $['form.indexMethodEconomyTip'], { ns: 'datasetSettings', count: keywordNumber })} - onClick={handleIndexMethodChange} - effectColor="blue" - > - <Fieldset className="flex items-center"> - <FieldsetLegend className="sr-only">{keywordNumberLabel}</FieldsetLegend> - <div className="flex grow items-center"> - <div className="truncate system-xs-medium text-text-secondary"> - {keywordNumberLabel} - </div> - <Infotip - aria-label={keywordNumberLabel} - className="ml-0.5 size-3.5" - > - {keywordNumberLabel} - </Infotip> + ></OptionCard> + {chunkStructure === ChunkStructureEnum.general && ( + <OptionCard + id={IndexMethodEnum.ECONOMICAL} + selectedId={indexMethod} + icon={ + <Economic + className={cn( + 'h-[15px] w-[15px] text-text-tertiary group-hover:text-util-colors-indigo-indigo-500', + isEconomy && 'text-util-colors-indigo-indigo-500', + )} + /> + } + title={t(($) => $['form.indexMethodEconomy'], { ns: 'datasetSettings' })} + description={t(($) => $['form.indexMethodEconomyTip'], { + ns: 'datasetSettings', + count: keywordNumber, + })} + onClick={handleIndexMethodChange} + effectColor="blue" + > + <Fieldset className="flex items-center"> + <FieldsetLegend className="sr-only">{keywordNumberLabel}</FieldsetLegend> + <div className="flex grow items-center"> + <div className="truncate system-xs-medium text-text-secondary"> + {keywordNumberLabel} </div> - <Slider - disabled={readonly} - className="mr-3 w-24 shrink-0" - value={keywordNumber} - onValueChange={onKeywordNumberChange} - aria-label={keywordNumberLabel} - /> - <Input - aria-label={keywordNumberLabel} - disabled={readonly} - className="shrink-0" - wrapperClassName="shrink-0 w-[72px]" - type="number" - value={keywordNumber} - onChange={handleInputChange} - /> - </Fieldset> - </OptionCard> - ) - } + <Infotip aria-label={keywordNumberLabel} className="ml-0.5 size-3.5"> + {keywordNumberLabel} + </Infotip> + </div> + <Slider + disabled={readonly} + className="mr-3 w-24 shrink-0" + value={keywordNumber} + onValueChange={onKeywordNumberChange} + aria-label={keywordNumberLabel} + /> + <Input + aria-label={keywordNumberLabel} + disabled={readonly} + className="shrink-0" + wrapperClassName="shrink-0 w-[72px]" + type="number" + value={keywordNumber} + onChange={handleInputChange} + /> + </Fieldset> + </OptionCard> + )} </div> </Field> ) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/option-card.tsx b/web/app/components/workflow/nodes/knowledge-base/components/option-card.tsx index 7817d19335abdd..4d03e7b98a94ba 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/option-card.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/option-card.tsx @@ -1,9 +1,6 @@ import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { @@ -16,11 +13,11 @@ import { import { ArrowShape } from '@/app/components/base/icons/src/vender/knowledge' const HEADER_EFFECT_MAP: Record<string, ReactNode> = { - 'blue': <OptionCardEffectBlue />, + blue: <OptionCardEffectBlue />, 'blue-light': <OptionCardEffectBlueLight />, - 'orange': <OptionCardEffectOrange />, - 'purple': <OptionCardEffectPurple />, - 'teal': <OptionCardEffectTeal />, + orange: <OptionCardEffectOrange />, + purple: <OptionCardEffectPurple />, + teal: <OptionCardEffectTeal />, } type OptionCardProps<T> = { id?: T @@ -39,115 +36,111 @@ type OptionCardProps<T> = { onClick?: (id: T) => void readonly?: boolean } -const OptionCard = memo(({ - id, - selectedId, - enableSelect = true, - enableHighlightBorder = true, - enableRadio, - wrapperClassName, - className, - icon, - title, - description, - isRecommended, - children, - effectColor, - onClick, - readonly, -}) => { - const { t } = useTranslation() - const isActive = useMemo(() => { - return id === selectedId - }, [id, selectedId]) +const OptionCard = memo( + ({ + id, + selectedId, + enableSelect = true, + enableHighlightBorder = true, + enableRadio, + wrapperClassName, + className, + icon, + title, + description, + isRecommended, + children, + effectColor, + onClick, + readonly, + }) => { + const { t } = useTranslation() + const isActive = useMemo(() => { + return id === selectedId + }, [id, selectedId]) - const effectElement = useMemo(() => { - if (effectColor) { - return ( - <div className={cn( - 'absolute top-[-2px] left-[-2px] hidden h-14 w-14 rounded-full', - 'group-hover:block', - isActive && 'block', - )} - > - {HEADER_EFFECT_MAP[effectColor]} - </div> - ) - } + const effectElement = useMemo(() => { + if (effectColor) { + return ( + <div + className={cn( + 'absolute top-[-2px] left-[-2px] hidden h-14 w-14 rounded-full', + 'group-hover:block', + isActive && 'block', + )} + > + {HEADER_EFFECT_MAP[effectColor]} + </div> + ) + } - return null - }, [effectColor, isActive]) + return null + }, [effectColor, isActive]) - return ( - <div - className={cn( - 'group overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg', - isActive && enableHighlightBorder && 'border-[1.5px] border-components-option-card-option-selected-border', - enableSelect && 'cursor-pointer hover:shadow-xs', - readonly && 'cursor-not-allowed', - wrapperClassName && (typeof wrapperClassName === 'function' ? wrapperClassName(isActive) : wrapperClassName), - )} - onClick={(e) => { - e.stopPropagation() - if (!readonly && enableSelect && id) - onClick?.(id) - }} - > - <div className={cn( - 'relative flex rounded-t-xl p-2', - className && (typeof className === 'function' ? className(isActive) : className), - )} + return ( + <div + className={cn( + 'group overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg', + isActive && + enableHighlightBorder && + 'border-[1.5px] border-components-option-card-option-selected-border', + enableSelect && 'cursor-pointer hover:shadow-xs', + readonly && 'cursor-not-allowed', + wrapperClassName && + (typeof wrapperClassName === 'function' + ? wrapperClassName(isActive) + : wrapperClassName), + )} + onClick={(e) => { + e.stopPropagation() + if (!readonly && enableSelect && id) onClick?.(id) + }} > - {effectElement} - { - !!icon && ( + <div + className={cn( + 'relative flex rounded-t-xl p-2', + className && (typeof className === 'function' ? className(isActive) : className), + )} + > + {effectElement} + {!!icon && ( <div className="mr-1 flex h-[18px] w-[18px] shrink-0 items-center justify-center"> {typeof icon === 'function' ? icon(isActive) : icon} </div> - ) - } - <div className="grow py-1 pt-px"> - <div className="flex items-center"> - <div className="flex grow items-center system-sm-medium text-text-secondary"> - {title} - { - isRecommended && ( + )} + <div className="grow py-1 pt-px"> + <div className="flex items-center"> + <div className="flex grow items-center system-sm-medium text-text-secondary"> + {title} + {isRecommended && ( <Badge className="ml-1 h-4 border-text-accent-secondary text-text-accent-secondary"> - {t($ => $['stepTwo.recommend'], { ns: 'datasetCreation' })} + {t(($) => $['stepTwo.recommend'], { ns: 'datasetCreation' })} </Badge> - ) - } - </div> - { - enableRadio && ( - <div className={cn( - 'ml-2 size-4 shrink-0 rounded-full border border-components-radio-border bg-components-radio-bg', - isActive && 'border-[5px] border-components-radio-border-checked', )} - > - </div> - ) - } - </div> - { - description && ( - <div className="mt-1 system-xs-regular text-text-tertiary"> - {description} </div> - ) - } + {enableRadio && ( + <div + className={cn( + 'ml-2 size-4 shrink-0 rounded-full border border-components-radio-border bg-components-radio-bg', + isActive && 'border-[5px] border-components-radio-border-checked', + )} + ></div> + )} + </div> + {description && ( + <div className="mt-1 system-xs-regular text-text-tertiary">{description}</div> + )} + </div> </div> - </div> - { - !!(children && isActive) && ( + {!!(children && isActive) && ( <div className="relative rounded-b-xl bg-components-panel-bg p-3"> <ArrowShape className="absolute top-[-11px] left-[14px] h-4 w-4 text-components-panel-bg" /> {children} </div> - ) - } - </div> - ) -}) as <T>(props: OptionCardProps<T>) => React.ReactElement + )} + </div> + ) + }, +) as <T>(props: OptionCardProps<T>) => React.ReactElement export default OptionCard diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/hooks.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/hooks.spec.tsx index ac52e807c941b4..e9eef3a7dd4e59 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/hooks.spec.tsx @@ -1,9 +1,5 @@ import { renderHook } from '@testing-library/react' -import { - HybridSearchModeEnum, - IndexMethodEnum, - RetrievalSearchMethodEnum, -} from '../../../types' +import { HybridSearchModeEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../../../types' import { useRetrievalSetting } from '../hooks' describe('useRetrievalSetting', () => { @@ -16,12 +12,12 @@ describe('useRetrievalSetting', () => { it('should return semantic, full-text, and hybrid options for qualified indexing', () => { const { result } = renderHook(() => useRetrievalSetting(IndexMethodEnum.QUALIFIED)) - expect(result.current.options.map(option => option.id)).toEqual([ + expect(result.current.options.map((option) => option.id)).toEqual([ RetrievalSearchMethodEnum.semantic, RetrievalSearchMethodEnum.fullText, RetrievalSearchMethodEnum.hybrid, ]) - expect(result.current.hybridSearchModeOptions.map(option => option.id)).toEqual([ + expect(result.current.hybridSearchModeOptions.map((option) => option.id)).toEqual([ HybridSearchModeEnum.WeightedScore, HybridSearchModeEnum.RerankingModel, ]) @@ -30,7 +26,7 @@ describe('useRetrievalSetting', () => { it('should return only keyword search for economical indexing', () => { const { result } = renderHook(() => useRetrievalSetting(IndexMethodEnum.ECONOMICAL)) - expect(result.current.options.map(option => option.id)).toEqual([ + expect(result.current.options.map((option) => option.id)).toEqual([ RetrievalSearchMethodEnum.keywordSearch, ]) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/index.spec.tsx index b07f87ea037c94..8c49bfde1dd3fa 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/index.spec.tsx @@ -29,14 +29,11 @@ describe('RetrievalSetting', () => { }) it('should render the learn-more link and qualified retrieval method options', () => { - render( - <RetrievalSetting - {...baseProps} - indexMethod={IndexMethodEnum.QUALIFIED} - />, - ) + render(<RetrievalSetting {...baseProps} indexMethod={IndexMethodEnum.QUALIFIED} />) - expect(screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' })).toHaveAttribute( + expect( + screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' }), + ).toHaveAttribute( 'href', resolveDocLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods'), ) @@ -46,12 +43,7 @@ describe('RetrievalSetting', () => { }) it('should render only the economical retrieval method for economical indexing', () => { - render( - <RetrievalSetting - {...baseProps} - indexMethod={IndexMethodEnum.ECONOMICAL} - />, - ) + render(<RetrievalSetting {...baseProps} indexMethod={IndexMethodEnum.ECONOMICAL} />) expect(screen.getByText('dataset.retrieval.keyword_search.title')).toBeInTheDocument() expect(screen.queryByText('dataset.retrieval.semantic_search.title')).not.toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/reranking-model-selector.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/reranking-model-selector.spec.tsx index 7e3f7fdd678740..5a4a7787196f69 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/reranking-model-selector.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/reranking-model-selector.spec.tsx @@ -40,15 +40,19 @@ describe('RerankingModelSelector', () => { beforeEach(() => { vi.clearAllMocks() mockUseModelListAndDefaultModel.mockReturnValue({ - modelList: [createModel({ - provider: 'cohere', - label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, - models: [createModelItem({ - model: 'rerank-v3', - model_type: ModelTypeEnum.rerank, - label: { en_US: 'Rerank V3', zh_Hans: 'Rerank V3' }, - })], - })], + modelList: [ + createModel({ + provider: 'cohere', + label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, + models: [ + createModelItem({ + model: 'rerank-v3', + model_type: ModelTypeEnum.rerank, + label: { en_US: 'Rerank V3', zh_Hans: 'Rerank V3' }, + }), + ], + }), + ], defaultModel: undefined, }) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/search-method-option.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/search-method-option.spec.tsx index 710f3c389822b1..0ec55133477953 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/search-method-option.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/search-method-option.spec.tsx @@ -2,39 +2,42 @@ import type { ComponentType, SVGProps } from 'react' import { Field } from '@langgenius/dify-ui/field' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' import { RadioGroup } from '@langgenius/dify-ui/radio' -import { - fireEvent, - render, - screen, -} from '@testing-library/react' -import { - HybridSearchModeEnum, - RetrievalSearchMethodEnum, - WeightedScoreEnum, -} from '../../../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { HybridSearchModeEnum, RetrievalSearchMethodEnum, WeightedScoreEnum } from '../../../types' import { SearchMethodOption } from '../search-method-option' const mockUseModelListAndDefaultModel = vi.hoisted(() => vi.fn()) const mockUseProviderContext = vi.hoisted(() => vi.fn()) const mockUseCredentialPanelState = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', async (importOriginal) => { - const actual = await importOriginal<typeof import('@/app/components/header/account-setting/model-provider-page/hooks')>() - return { - ...actual, - useModelListAndDefaultModel: (...args: Parameters<typeof actual.useModelListAndDefaultModel>) => mockUseModelListAndDefaultModel(...args), - } -}) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/hooks', + async (importOriginal) => { + const actual = + await importOriginal< + typeof import('@/app/components/header/account-setting/model-provider-page/hooks') + >() + return { + ...actual, + useModelListAndDefaultModel: ( + ...args: Parameters<typeof actual.useModelListAndDefaultModel> + ) => mockUseModelListAndDefaultModel(...args), + } + }, +) vi.mock('@/context/provider-context', () => ({ useProviderContext: () => mockUseProviderContext(), })) -vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({ - useCredentialPanelState: (...args: unknown[]) => mockUseCredentialPanelState(...args), -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', + () => ({ + useCredentialPanelState: (...args: unknown[]) => mockUseCredentialPanelState(...args), + }), +) -const SearchIcon: ComponentType<SVGProps<SVGSVGElement>> = props => ( +const SearchIcon: ComponentType<SVGProps<SVGSVGElement>> = (props) => ( <svg aria-hidden="true" {...props} /> ) @@ -105,20 +108,17 @@ const createProps = () => ({ }) function renderSearchMethodOption(props: ReturnType<typeof createProps>) { - const { - onRetrievalSearchMethodChange, - ...optionProps - } = props + const { onRetrievalSearchMethodChange, ...optionProps } = props render( <Field name="retrieval_search_method"> <Fieldset - render={( + render={ <RadioGroup value={props.searchMethod} - onValueChange={value => onRetrievalSearchMethodChange(value)} + onValueChange={(value) => onRetrievalSearchMethodChange(value)} /> - )} + } > <FieldsetLegend>Retrieval search method</FieldsetLegend> <SearchMethodOption {...optionProps} /> @@ -181,7 +181,9 @@ describe('SearchMethodOption', () => { fireEvent.click(screen.getByText('Full-text title')) - expect(props.onRetrievalSearchMethodChange).toHaveBeenCalledWith(RetrievalSearchMethodEnum.fullText) + expect(props.onRetrievalSearchMethodChange).toHaveBeenCalledWith( + RetrievalSearchMethodEnum.fullText, + ) }) it('should render hybrid weighted-score controls without reranking model selector', () => { @@ -211,11 +213,15 @@ describe('SearchMethodOption', () => { expect(screen.getByText('dataset.weightedScore.semantic'))!.toBeInTheDocument() expect(screen.getByText('dataset.weightedScore.keyword'))!.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.rerankModel.key')).not.toBeInTheDocument() - expect(screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip')).not.toBeInTheDocument() + expect( + screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip'), + ).not.toBeInTheDocument() fireEvent.click(screen.getByText('Rerank mode')) - expect(props.hybridSearch.onModeChange).toHaveBeenCalledWith(HybridSearchModeEnum.RerankingModel) + expect(props.hybridSearch.onModeChange).toHaveBeenCalledWith( + HybridSearchModeEnum.RerankingModel, + ) }) it('should render the hybrid reranking selector when reranking mode is selected', () => { @@ -243,7 +249,9 @@ describe('SearchMethodOption', () => { expect(screen.getByText('plugin.detailPanel.configureModel'))!.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.rerankModel.key')).not.toBeInTheDocument() expect(screen.queryByText('dataset.weightedScore.semantic')).not.toBeInTheDocument() - expect(screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip'))!.toBeInTheDocument() + expect( + screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip'), + )!.toBeInTheDocument() }) it('should hide the score-threshold control for keyword search', () => { diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx index 840b7045773776..29870dd122c582 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx @@ -32,7 +32,9 @@ describe('TopKAndScoreThreshold', () => { it('should notify score-threshold input values without additional rounding', () => { render(<TopKAndScoreThreshold {...defaultProps} />) - fireEvent.change(screen.getByRole('textbox', { name: scoreThresholdLabel }), { target: { value: '0.456' } }) + fireEvent.change(screen.getByRole('textbox', { name: scoreThresholdLabel }), { + target: { value: '0.456' }, + }) expect(defaultProps.scoreThreshold.onChange).toHaveBeenCalledWith(0.456) }) @@ -74,6 +76,9 @@ describe('TopKAndScoreThreshold', () => { />, ) - expect(screen.getByRole('switch', { name: scoreThresholdLabel }))!.toHaveAttribute('aria-checked', 'false') + expect(screen.getByRole('switch', { name: scoreThresholdLabel }))!.toHaveAttribute( + 'aria-checked', + 'false', + ) }) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/hooks.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/hooks.tsx index 52a095396dc2e3..e793e7358564ab 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/hooks.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/hooks.tsx @@ -1,7 +1,4 @@ -import type { - HybridSearchModeOption, - Option, -} from './type' +import type { HybridSearchModeOption, Option } from './type' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { @@ -9,11 +6,7 @@ import { HybridSearch, VectorSearch, } from '@/app/components/base/icons/src/vender/knowledge' -import { - HybridSearchModeEnum, - IndexMethodEnum, - RetrievalSearchMethodEnum, -} from '../../types' +import { HybridSearchModeEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../../types' export const useRetrievalSetting = (indexMethod?: IndexMethodEnum) => { const { t } = useTranslation() @@ -21,8 +14,8 @@ export const useRetrievalSetting = (indexMethod?: IndexMethodEnum) => { return { id: RetrievalSearchMethodEnum.semantic, icon: VectorSearch as any, - title: t($ => $['retrieval.semantic_search.title'], { ns: 'dataset' }), - description: t($ => $['retrieval.semantic_search.description'], { ns: 'dataset' }), + title: t(($) => $['retrieval.semantic_search.title'], { ns: 'dataset' }), + description: t(($) => $['retrieval.semantic_search.description'], { ns: 'dataset' }), effectColor: 'purple', } }, [t]) @@ -30,8 +23,8 @@ export const useRetrievalSetting = (indexMethod?: IndexMethodEnum) => { return { id: RetrievalSearchMethodEnum.fullText, icon: FullTextSearch as any, - title: t($ => $['retrieval.full_text_search.title'], { ns: 'dataset' }), - description: t($ => $['retrieval.full_text_search.description'], { ns: 'dataset' }), + title: t(($) => $['retrieval.full_text_search.title'], { ns: 'dataset' }), + description: t(($) => $['retrieval.full_text_search.description'], { ns: 'dataset' }), effectColor: 'purple', } }, [t]) @@ -39,8 +32,8 @@ export const useRetrievalSetting = (indexMethod?: IndexMethodEnum) => { return { id: RetrievalSearchMethodEnum.hybrid, icon: HybridSearch as any, - title: t($ => $['retrieval.hybrid_search.title'], { ns: 'dataset' }), - description: t($ => $['retrieval.hybrid_search.description'], { ns: 'dataset' }), + title: t(($) => $['retrieval.hybrid_search.title'], { ns: 'dataset' }), + description: t(($) => $['retrieval.hybrid_search.description'], { ns: 'dataset' }), effectColor: 'purple', } }, [t]) @@ -48,8 +41,8 @@ export const useRetrievalSetting = (indexMethod?: IndexMethodEnum) => { return { id: RetrievalSearchMethodEnum.keywordSearch, icon: HybridSearch as any, - title: t($ => $['retrieval.keyword_search.title'], { ns: 'dataset' }), - description: t($ => $['retrieval.keyword_search.description'], { ns: 'dataset' }), + title: t(($) => $['retrieval.keyword_search.title'], { ns: 'dataset' }), + description: t(($) => $['retrieval.keyword_search.description'], { ns: 'dataset' }), effectColor: 'purple', } }, [t]) @@ -57,39 +50,34 @@ export const useRetrievalSetting = (indexMethod?: IndexMethodEnum) => { const WeightedScoreModeOption: HybridSearchModeOption = useMemo(() => { return { id: HybridSearchModeEnum.WeightedScore, - title: t($ => $['weightedScore.title'], { ns: 'dataset' }), - description: t($ => $['weightedScore.description'], { ns: 'dataset' }), + title: t(($) => $['weightedScore.title'], { ns: 'dataset' }), + description: t(($) => $['weightedScore.description'], { ns: 'dataset' }), } }, [t]) const RerankModelModeOption: HybridSearchModeOption = useMemo(() => { return { id: HybridSearchModeEnum.RerankingModel, - title: t($ => $['modelProvider.rerankModel.key'], { ns: 'common' }), - description: t($ => $['modelProvider.rerankModel.tip'], { ns: 'common' }), + title: t(($) => $['modelProvider.rerankModel.key'], { ns: 'common' }), + description: t(($) => $['modelProvider.rerankModel.tip'], { ns: 'common' }), } }, [t]) - return useMemo(() => ({ - options: indexMethod === IndexMethodEnum.ECONOMICAL - ? [ - InvertedIndexOption, - ] - : [ - VectorSearchOption, - FullTextSearchOption, - HybridSearchOption, - ], - hybridSearchModeOptions: [ + return useMemo( + () => ({ + options: + indexMethod === IndexMethodEnum.ECONOMICAL + ? [InvertedIndexOption] + : [VectorSearchOption, FullTextSearchOption, HybridSearchOption], + hybridSearchModeOptions: [WeightedScoreModeOption, RerankModelModeOption], + }), + [ + VectorSearchOption, + FullTextSearchOption, + HybridSearchOption, + InvertedIndexOption, + indexMethod, WeightedScoreModeOption, RerankModelModeOption, ], - }), [ - VectorSearchOption, - FullTextSearchOption, - HybridSearchOption, - InvertedIndexOption, - indexMethod, - WeightedScoreModeOption, - RerankModelModeOption, - ]) + ) } diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/index.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/index.tsx index 4997cf56eb4fd1..35d8fe3ada56a5 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/index.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/index.tsx @@ -5,16 +5,11 @@ import type { WeightedScore, } from '../../types' import type { RerankingModelSelectorProps } from './reranking-model-selector' -import type { - TopKFieldProps, - VisibleScoreThresholdFieldProps, -} from './top-k-and-score-threshold' +import type { TopKFieldProps, VisibleScoreThresholdFieldProps } from './top-k-and-score-threshold' import { Field } from '@langgenius/dify-ui/field' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' import { RadioGroup } from '@langgenius/dify-ui/radio' -import { - memo, -} from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import { Field as WorkflowField } from '@/app/components/workflow/nodes/_base/components/layout' import { useDocLink } from '@/context/i18n' @@ -34,13 +29,13 @@ type RetrievalSettingProps = { onWeightedScoreChange: (value: { value: number[] }) => void showMultiModalTip?: boolean } & RerankingModelSelectorProps & { - topK: TopKFieldProps['value'] - onTopKChange: TopKFieldProps['onChange'] - scoreThreshold: VisibleScoreThresholdFieldProps['value'] - onScoreThresholdChange: VisibleScoreThresholdFieldProps['onChange'] - isScoreThresholdEnabled?: VisibleScoreThresholdFieldProps['enabled'] - onScoreThresholdEnabledChange: VisibleScoreThresholdFieldProps['onEnabledChange'] -} + topK: TopKFieldProps['value'] + onTopKChange: TopKFieldProps['onChange'] + scoreThreshold: VisibleScoreThresholdFieldProps['value'] + onScoreThresholdChange: VisibleScoreThresholdFieldProps['onChange'] + isScoreThresholdEnabled?: VisibleScoreThresholdFieldProps['enabled'] + onScoreThresholdEnabledChange: VisibleScoreThresholdFieldProps['onEnabledChange'] + } const RetrievalSetting = ({ indexMethod, @@ -65,39 +60,43 @@ const RetrievalSetting = ({ }: RetrievalSettingProps) => { const { t } = useTranslation() const docLink = useDocLink() - const { - options, - hybridSearchModeOptions, - } = useRetrievalSetting(indexMethod) + const { options, hybridSearchModeOptions } = useRetrievalSetting(indexMethod) return ( <WorkflowField fieldTitleProps={{ - title: t($ => $['form.retrievalSetting.title'], { ns: 'datasetSettings' }), + title: t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' }), subTitle: ( <div className="flex items-center body-xs-regular text-text-tertiary"> - <a target="_blank" rel="noopener noreferrer" href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')} className="text-text-accent">{t($ => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })}</a> + <a + target="_blank" + rel="noopener noreferrer" + href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')} + className="text-text-accent" + > + {t(($) => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })} + </a>   - {t($ => $['nodes.knowledgeBase.aboutRetrieval'], { ns: 'workflow' })} + {t(($) => $['nodes.knowledgeBase.aboutRetrieval'], { ns: 'workflow' })} </div> ), }} > <Field name="retrieval_search_method" className="gap-0"> <Fieldset - render={( + render={ <RadioGroup<RetrievalSearchMethodEnum> value={searchMethod} - onValueChange={value => onRetrievalSearchMethodChange(value)} + onValueChange={(value) => onRetrievalSearchMethodChange(value)} disabled={readonly} className="flex-col items-stretch gap-1" /> - )} + } > <FieldsetLegend className="sr-only"> - {t($ => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} + {t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })} </FieldsetLegend> - {options.map(option => ( + {options.map((option) => ( <SearchMethodOption key={option.id} option={option} diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/reranking-model-selector.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/reranking-model-selector.tsx index 7ee18a82e51ae3..79f1063f966c5d 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/reranking-model-selector.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/reranking-model-selector.tsx @@ -1,9 +1,6 @@ import type { RerankingModel } from '../../types' import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' @@ -18,9 +15,7 @@ const RerankingModelSelector = ({ onRerankingModelChange, readonly = false, }: RerankingModelSelectorProps) => { - const { - modelList: rerankModelList, - } = useModelListAndDefaultModel(ModelTypeEnum.rerank) + const { modelList: rerankModelList } = useModelListAndDefaultModel(ModelTypeEnum.rerank) const rerankModel = useMemo(() => { if (!rerankingModel?.reranking_provider_name || !rerankingModel?.reranking_model_name) return undefined diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx index 917938a67a27ad..8c839b550a77d7 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx @@ -1,16 +1,8 @@ import type { ReactNode } from 'react' -import type { - WeightedScore, -} from '../../types' +import type { WeightedScore } from '../../types' import type { RerankingModelSelectorProps } from './reranking-model-selector' -import type { - TopKFieldProps, - VisibleScoreThresholdFieldProps, -} from './top-k-and-score-threshold' -import type { - HybridSearchModeOption, - Option, -} from './type' +import type { TopKFieldProps, VisibleScoreThresholdFieldProps } from './top-k-and-score-threshold' +import type { HybridSearchModeOption, Option } from './type' import { cn } from '@langgenius/dify-ui/cn' import { Field, FieldItem, FieldLabel } from '@langgenius/dify-ui/field' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' @@ -28,10 +20,7 @@ import { } from '@/app/components/base/icons/src/public/knowledge' import { Infotip } from '@/app/components/base/infotip' import { DEFAULT_WEIGHTED_SCORE } from '@/models/datasets' -import { - HybridSearchModeEnum, - RetrievalSearchMethodEnum, -} from '../../types' +import { HybridSearchModeEnum, RetrievalSearchMethodEnum } from '../../types' import RerankingModelSelector from './reranking-model-selector' import { TopKAndScoreThreshold } from './top-k-and-score-threshold' @@ -72,16 +61,18 @@ export type SearchMethodOptionProps = { } const HEADER_EFFECT_MAP: Record<string, ReactNode> = { - 'blue': <OptionCardEffectBlue />, + blue: <OptionCardEffectBlue />, 'blue-light': <OptionCardEffectBlueLight />, - 'orange': <OptionCardEffectOrange />, - 'purple': <OptionCardEffectPurple />, - 'teal': <OptionCardEffectTeal />, + orange: <OptionCardEffectOrange />, + purple: <OptionCardEffectPurple />, + teal: <OptionCardEffectTeal />, } function getWeightedScoreValue(weightedScore?: WeightedScore) { - const semanticWeightedScore = weightedScore?.vector_setting.vector_weight ?? DEFAULT_WEIGHTED_SCORE.other.semantic - const keywordWeightedScore = weightedScore?.keyword_setting.keyword_weight ?? DEFAULT_WEIGHTED_SCORE.other.keyword + const semanticWeightedScore = + weightedScore?.vector_setting.vector_weight ?? DEFAULT_WEIGHTED_SCORE.other.semantic + const keywordWeightedScore = + weightedScore?.keyword_setting.keyword_weight ?? DEFAULT_WEIGHTED_SCORE.other.keyword return { value: [semanticWeightedScore, keywordWeightedScore], @@ -89,21 +80,28 @@ function getWeightedScoreValue(weightedScore?: WeightedScore) { } function shouldShowRerankModelSelectorSwitch(searchMethod?: RetrievalSearchMethodEnum) { - return searchMethod === RetrievalSearchMethodEnum.semantic || searchMethod === RetrievalSearchMethodEnum.fullText + return ( + searchMethod === RetrievalSearchMethodEnum.semantic || + searchMethod === RetrievalSearchMethodEnum.fullText + ) } -function shouldShowRerankModelSelector(searchMethod: RetrievalSearchMethodEnum | undefined, hybridSearchMode: HybridSearchModeEnum | undefined) { - if (shouldShowRerankModelSelectorSwitch(searchMethod)) - return true +function shouldShowRerankModelSelector( + searchMethod: RetrievalSearchMethodEnum | undefined, + hybridSearchMode: HybridSearchModeEnum | undefined, +) { + if (shouldShowRerankModelSelectorSwitch(searchMethod)) return true - return searchMethod === RetrievalSearchMethodEnum.hybrid && hybridSearchMode !== HybridSearchModeEnum.WeightedScore + return ( + searchMethod === RetrievalSearchMethodEnum.hybrid && + hybridSearchMode !== HybridSearchModeEnum.WeightedScore + ) } function getSearchMethodEffect(effectColor: string | undefined, isActive: boolean) { const effect = effectColor ? HEADER_EFFECT_MAP[effectColor] : undefined - if (!effect) - return null + if (!effect) return null return ( <div @@ -167,22 +165,16 @@ function SearchMethodRadioCard({ <div className="flex items-center"> <div className="flex grow items-center system-sm-medium text-text-secondary"> {option.title} - {isRecommended - ? ( - <Badge className="ml-1 h-4 border-text-accent-secondary text-text-accent-secondary"> - {t($ => $['stepTwo.recommend'], { ns: 'datasetCreation' })} - </Badge> - ) - : null} + {isRecommended ? ( + <Badge className="ml-1 h-4 border-text-accent-secondary text-text-accent-secondary"> + {t(($) => $['stepTwo.recommend'], { ns: 'datasetCreation' })} + </Badge> + ) : null} </div> </div> - {option.description - ? ( - <div className="mt-1 system-xs-regular text-text-tertiary"> - {option.description} - </div> - ) - : null} + {option.description ? ( + <div className="mt-1 system-xs-regular text-text-tertiary">{option.description}</div> + ) : null} </div> </RadioItem> {!!(children && isActive) && ( @@ -218,12 +210,8 @@ function HybridSearchModeRadioCard({ > <div className="flex items-start gap-2"> <div className="min-w-0 grow"> - <div className="system-sm-medium text-text-secondary"> - {option.title} - </div> - <div className="mt-1 system-xs-regular text-text-tertiary"> - {option.description} - </div> + <div className="system-sm-medium text-text-secondary">{option.title}</div> + <div className="mt-1 system-xs-regular text-text-tertiary">{option.description}</div> </div> <RadioControl className="mt-0.5" aria-hidden="true" /> </div> @@ -245,95 +233,84 @@ export function SearchMethodOption({ const isHybridSearchWeightedScoreMode = hybridSearch.mode === HybridSearchModeEnum.WeightedScore const showRerankModelSelectorSwitch = shouldShowRerankModelSelectorSwitch(option.id) const showRerankModelSelector = shouldShowRerankModelSelector(option.id, hybridSearch.mode) - const rerankModelLabel = t($ => $['modelProvider.rerankModel.key'], { ns: 'common' }) - const rerankModelTip = t($ => $['modelProvider.rerankModel.tip'], { ns: 'common' }) + const rerankModelLabel = t(($) => $['modelProvider.rerankModel.key'], { ns: 'common' }) + const rerankModelTip = t(($) => $['modelProvider.rerankModel.tip'], { ns: 'common' }) const scoreThresholdHidden = option.id === RetrievalSearchMethodEnum.keywordSearch const config = ( <div className="space-y-3"> - {isHybridSearch - ? ( - <Field name="hybrid_search_mode" className="gap-0"> - <Fieldset - render={( - <RadioGroup<HybridSearchModeEnum> - value={hybridSearch.mode} - onValueChange={value => hybridSearch.onModeChange(value)} + {isHybridSearch ? ( + <Field name="hybrid_search_mode" className="gap-0"> + <Fieldset + render={ + <RadioGroup<HybridSearchModeEnum> + value={hybridSearch.mode} + onValueChange={(value) => hybridSearch.onModeChange(value)} + disabled={readonly} + className="flex-col items-stretch gap-1" + /> + } + > + <FieldsetLegend className="sr-only">Hybrid search mode</FieldsetLegend> + {hybridSearch.options.map((hybridOption) => ( + <HybridSearchModeRadioCard + key={hybridOption.id} + option={hybridOption} + readonly={readonly} + /> + ))} + </Fieldset> + </Field> + ) : null} + {isHybridSearch && isHybridSearchWeightedScoreMode ? ( + <WeightedScoreComponent + value={getWeightedScoreValue(hybridSearch.weightedScore)} + onChange={hybridSearch.onWeightedScoreChange} + readonly={readonly} + /> + ) : null} + {showRerankModelSelector ? ( + <div> + {showRerankModelSelectorSwitch ? ( + <Field name="reranking_model_enabled" className="mb-1 gap-0"> + <div className="flex items-center"> + <FieldLabel className="flex min-w-0 items-center py-0 system-sm-semibold text-text-secondary"> + <Switch + className="mr-1" + checked={reranking.enabled ?? false} + onCheckedChange={reranking.onEnabledChange} disabled={readonly} - className="flex-col items-stretch gap-1" - /> - )} - > - <FieldsetLegend className="sr-only">Hybrid search mode</FieldsetLegend> - {hybridSearch.options.map(hybridOption => ( - <HybridSearchModeRadioCard - key={hybridOption.id} - option={hybridOption} - readonly={readonly} /> - ))} - </Fieldset> + <span className="truncate">{rerankModelLabel}</span> + </FieldLabel> + <Infotip aria-label={rerankModelTip} className="ml-0.5 size-3.5 shrink-0"> + {rerankModelTip} + </Infotip> + </div> </Field> - ) - : null} - {isHybridSearch && isHybridSearchWeightedScoreMode - ? ( - <WeightedScoreComponent - value={getWeightedScoreValue(hybridSearch.weightedScore)} - onChange={hybridSearch.onWeightedScoreChange} - readonly={readonly} - /> - ) - : null} - {showRerankModelSelector - ? ( - <div> - {showRerankModelSelectorSwitch - ? ( - <Field name="reranking_model_enabled" className="mb-1 gap-0"> - <div className="flex items-center"> - <FieldLabel className="flex min-w-0 items-center py-0 system-sm-semibold text-text-secondary"> - <Switch - className="mr-1" - checked={reranking.enabled ?? false} - onCheckedChange={reranking.onEnabledChange} - disabled={readonly} - /> - <span className="truncate">{rerankModelLabel}</span> - </FieldLabel> - <Infotip - aria-label={rerankModelTip} - className="ml-0.5 size-3.5 shrink-0" - > - {rerankModelTip} - </Infotip> - </div> - </Field> - ) - : null} - <RerankingModelSelector - rerankingModel={reranking.rerankingModel} - onRerankingModelChange={reranking.onRerankingModelChange} - readonly={readonly} - /> - {reranking.showMultiModalTip - ? ( - <div className="mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]"> - <div className="absolute inset-0 bg-dataset-warning-message-bg opacity-40" /> - <div className="p-1"> - <div className="i-custom-vender-solid-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary" /> - </div> - <span className="system-xs-medium text-text-primary"> - {t($ => $['form.retrievalSetting.multiModalTip'], { ns: 'datasetSettings' })} - </span> - </div> - ) - : null} + ) : null} + <RerankingModelSelector + rerankingModel={reranking.rerankingModel} + onRerankingModelChange={reranking.onRerankingModelChange} + readonly={readonly} + /> + {reranking.showMultiModalTip ? ( + <div className="mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]"> + <div className="absolute inset-0 bg-dataset-warning-message-bg opacity-40" /> + <div className="p-1"> + <div className="i-custom-vender-solid-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary" /> + </div> + <span className="system-xs-medium text-text-primary"> + {t(($) => $['form.retrievalSetting.multiModalTip'], { ns: 'datasetSettings' })} + </span> </div> - ) - : null} + ) : null} + </div> + ) : null} <TopKAndScoreThreshold topK={retrievalParameters.topK} - scoreThreshold={scoreThresholdHidden ? { hidden: true } : retrievalParameters.scoreThreshold} + scoreThreshold={ + scoreThresholdHidden ? { hidden: true } : retrievalParameters.scoreThreshold + } readonly={readonly} /> </div> diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx index ebc08ec62a7c42..2d236fc590cd0e 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx @@ -26,9 +26,9 @@ export type VisibleScoreThresholdFieldProps = { onEnabledChange: (value: boolean) => void } -type ScoreThresholdFieldProps - = | VisibleScoreThresholdFieldProps - | { +type ScoreThresholdFieldProps = + | VisibleScoreThresholdFieldProps + | { hidden: true } @@ -55,10 +55,10 @@ export function TopKAndScoreThreshold({ readonly, }: TopKAndScoreThresholdProps) { const { t } = useTranslation() - const topKLabel = t($ => $['datasetConfig.top_k'], { ns: 'appDebug' }) - const scoreThresholdLabel = t($ => $['datasetConfig.score_threshold'], { ns: 'appDebug' }) - const topKTip = t($ => $['datasetConfig.top_kTip'], { ns: 'appDebug' }) - const scoreThresholdTip = t($ => $['datasetConfig.score_thresholdTip'], { ns: 'appDebug' }) + const topKLabel = t(($) => $['datasetConfig.top_k'], { ns: 'appDebug' }) + const scoreThresholdLabel = t(($) => $['datasetConfig.score_threshold'], { ns: 'appDebug' }) + const topKTip = t(($) => $['datasetConfig.top_kTip'], { ns: 'appDebug' }) + const scoreThresholdTip = t(($) => $['datasetConfig.score_thresholdTip'], { ns: 'appDebug' }) const scoreThresholdHidden = scoreThreshold.hidden === true const scoreThresholdEnabled = scoreThresholdHidden ? false : (scoreThreshold.enabled ?? false) @@ -66,13 +66,8 @@ export function TopKAndScoreThreshold({ <div className="grid grid-cols-2 gap-4"> <Field name="top_k" className="gap-0"> <div className="mb-0.5 flex h-6 items-center"> - <FieldLabel className="py-0 system-xs-medium text-text-secondary"> - {topKLabel} - </FieldLabel> - <Infotip - aria-label={topKTip} - className="ml-0.5 size-3.5" - > + <FieldLabel className="py-0 system-xs-medium text-text-secondary">{topKLabel}</FieldLabel> + <Infotip aria-label={topKTip} className="ml-0.5 size-3.5"> {topKTip} </Infotip> </div> @@ -82,7 +77,7 @@ export function TopKAndScoreThreshold({ min={TOP_K_VALUE_LIMIT.min} max={TOP_K_VALUE_LIMIT.max} value={topK.value} - onValueChange={value => topK.onChange(value ?? 0)} + onValueChange={(value) => topK.onChange(value ?? 0)} > <NumberFieldGroup> <NumberFieldInput /> @@ -93,53 +88,46 @@ export function TopKAndScoreThreshold({ </NumberFieldGroup> </NumberField> </Field> - {scoreThresholdHidden - ? null - : ( - <Fieldset className="min-w-0"> - <FieldsetLegend className="sr-only">{scoreThresholdLabel}</FieldsetLegend> - <Field name="score_threshold_enabled" className="mb-0.5 gap-0"> - <div className="flex h-6 items-center"> - <FieldLabel className="flex w-full min-w-0 grow items-center py-0 system-sm-medium text-text-secondary"> - <Switch - className="mr-2" - checked={scoreThresholdEnabled} - onCheckedChange={scoreThreshold.onEnabledChange} - disabled={readonly} - /> - <span className="grow truncate"> - {scoreThresholdLabel} - </span> - </FieldLabel> - <Infotip - aria-label={scoreThresholdTip} - className="ml-0.5 size-3.5" - > - {scoreThresholdTip} - </Infotip> - </div> - </Field> - <Field name="score_threshold" className="gap-0"> - <FieldLabel className="sr-only">{scoreThresholdLabel}</FieldLabel> - <NumberField - disabled={readonly || !scoreThresholdEnabled} - step={SCORE_THRESHOLD_VALUE_LIMIT.step} - min={SCORE_THRESHOLD_VALUE_LIMIT.min} - max={SCORE_THRESHOLD_VALUE_LIMIT.max} - value={scoreThreshold.value ?? null} - onValueChange={value => scoreThreshold.onChange(value ?? 0)} - > - <NumberFieldGroup> - <NumberFieldInput /> - <NumberFieldControls> - <NumberFieldIncrement /> - <NumberFieldDecrement /> - </NumberFieldControls> - </NumberFieldGroup> - </NumberField> - </Field> - </Fieldset> - )} + {scoreThresholdHidden ? null : ( + <Fieldset className="min-w-0"> + <FieldsetLegend className="sr-only">{scoreThresholdLabel}</FieldsetLegend> + <Field name="score_threshold_enabled" className="mb-0.5 gap-0"> + <div className="flex h-6 items-center"> + <FieldLabel className="flex w-full min-w-0 grow items-center py-0 system-sm-medium text-text-secondary"> + <Switch + className="mr-2" + checked={scoreThresholdEnabled} + onCheckedChange={scoreThreshold.onEnabledChange} + disabled={readonly} + /> + <span className="grow truncate">{scoreThresholdLabel}</span> + </FieldLabel> + <Infotip aria-label={scoreThresholdTip} className="ml-0.5 size-3.5"> + {scoreThresholdTip} + </Infotip> + </div> + </Field> + <Field name="score_threshold" className="gap-0"> + <FieldLabel className="sr-only">{scoreThresholdLabel}</FieldLabel> + <NumberField + disabled={readonly || !scoreThresholdEnabled} + step={SCORE_THRESHOLD_VALUE_LIMIT.step} + min={SCORE_THRESHOLD_VALUE_LIMIT.min} + max={SCORE_THRESHOLD_VALUE_LIMIT.max} + value={scoreThreshold.value ?? null} + onValueChange={(value) => scoreThreshold.onChange(value ?? 0)} + > + <NumberFieldGroup> + <NumberFieldInput /> + <NumberFieldControls> + <NumberFieldIncrement /> + <NumberFieldDecrement /> + </NumberFieldControls> + </NumberFieldGroup> + </NumberField> + </Field> + </Fieldset> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/type.ts b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/type.ts index 73e15fd9d95687..5d4df7009c8d87 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/type.ts +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/type.ts @@ -1,8 +1,5 @@ import type { ComponentType } from 'react' -import type { - HybridSearchModeEnum, - RetrievalSearchMethodEnum, -} from '../../types' +import type { HybridSearchModeEnum, RetrievalSearchMethodEnum } from '../../types' export type Option = { id: RetrievalSearchMethodEnum diff --git a/web/app/components/workflow/nodes/knowledge-base/default.ts b/web/app/components/workflow/nodes/knowledge-base/default.ts index b1ddea49ec9d68..e76c91fa30f825 100644 --- a/web/app/components/workflow/nodes/knowledge-base/default.ts +++ b/web/app/components/workflow/nodes/knowledge-base/default.ts @@ -2,10 +2,7 @@ import type { NodeDefault } from '../../types' import type { KnowledgeBaseNodeType } from './types' import { BlockEnum } from '@/app/components/workflow/types' import { genNodeMetaData } from '@/app/components/workflow/utils' -import { - getKnowledgeBaseValidationIssue, - getKnowledgeBaseValidationMessage, -} from './utils' +import { getKnowledgeBaseValidationIssue, getKnowledgeBaseValidationMessage } from './utils' const metaData = genNodeMetaData({ sort: 3.1, @@ -28,8 +25,7 @@ const nodeDefault: NodeDefault<KnowledgeBaseNodeType> = { }, checkValid(payload, t) { const issue = getKnowledgeBaseValidationIssue(payload) - if (issue) - return { isValid: false, errorMessage: getKnowledgeBaseValidationMessage(issue, t) } + if (issue) return { isValid: false, errorMessage: getKnowledgeBaseValidationMessage(issue, t) } return { isValid: true, diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-config.spec.tsx index a5fbe34ec26225..8b230ca3d46ea2 100644 --- a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-config.spec.tsx @@ -1,9 +1,6 @@ import type { KnowledgeBaseNodeType } from '../../types' import { act } from '@testing-library/react' -import { - createNode, - createNodeDataFactory, -} from '@/app/components/workflow/__tests__/fixtures' +import { createNode, createNodeDataFactory } from '@/app/components/workflow/__tests__/fixtures' import { renderWorkflowFlowHook } from '@/app/components/workflow/__tests__/workflow-test-env' import { RerankingModeEnum } from '@/models/datasets' import { @@ -84,15 +81,17 @@ describe('useConfig', () => { }) it('should reset chunk variables and keep a high-quality search method when switching chunk structures', () => { - const { result } = renderConfigHook(createNodeData({ - retrieval_model: { - search_method: RetrievalSearchMethodEnum.keywordSearch, - reranking_enable: false, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - }, - })) + const { result } = renderConfigHook( + createNodeData({ + retrieval_model: { + search_method: RetrievalSearchMethodEnum.keywordSearch, + reranking_enable: false, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }), + ) act(() => { result.current.handleChunkStructureChange(ChunkStructureEnum.parent_child) @@ -112,15 +111,17 @@ describe('useConfig', () => { }) it('should preserve semantic search when switching to a structured chunk mode from a high-quality search method', () => { - const { result } = renderConfigHook(createNodeData({ - retrieval_model: { - search_method: RetrievalSearchMethodEnum.semantic, - reranking_enable: false, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - }, - })) + const { result } = renderConfigHook( + createNodeData({ + retrieval_model: { + search_method: RetrievalSearchMethodEnum.semantic, + reranking_enable: false, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }), + ) act(() => { result.current.handleChunkStructureChange(ChunkStructureEnum.question_answer) @@ -181,15 +182,17 @@ describe('useConfig', () => { }) it('should create default weights when embedding weights are missing and default reranking mode when switching away from hybrid', () => { - const { result } = renderConfigHook(createNodeData({ - retrieval_model: { - search_method: RetrievalSearchMethodEnum.semantic, - reranking_enable: false, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - }, - })) + const { result } = renderConfigHook( + createNodeData({ + retrieval_model: { + search_method: RetrievalSearchMethodEnum.semantic, + reranking_enable: false, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }), + ) act(() => { result.current.handleEmbeddingModelChange({ @@ -231,31 +234,33 @@ describe('useConfig', () => { }) it('should update embedding model weights and retrieval search method defaults', () => { - const { result } = renderConfigHook(createNodeData({ - retrieval_model: { - search_method: RetrievalSearchMethodEnum.semantic, - reranking_enable: false, - reranking_mode: RerankingModeEnum.RerankingModel, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - weights: { - weight_type: WeightedScoreEnum.Customized, - vector_setting: { - vector_weight: 0.8, - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-3-large', + const { result } = renderConfigHook( + createNodeData({ + retrieval_model: { + search_method: RetrievalSearchMethodEnum.semantic, + reranking_enable: false, + reranking_mode: RerankingModeEnum.RerankingModel, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', }, - keyword_setting: { - keyword_weight: 0.2, + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.8, + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-large', + }, + keyword_setting: { + keyword_weight: 0.2, + }, }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - }, - })) + }), + ) act(() => { result.current.handleEmbeddingModelChange({ @@ -297,15 +302,17 @@ describe('useConfig', () => { }) it('should seed hybrid weights and propagate retrieval tuning updates', () => { - const { result } = renderConfigHook(createNodeData({ - retrieval_model: { - search_method: RetrievalSearchMethodEnum.hybrid, - reranking_enable: false, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - }, - })) + const { result } = renderConfigHook( + createNodeData({ + retrieval_model: { + search_method: RetrievalSearchMethodEnum.hybrid, + reranking_enable: false, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }), + ) act(() => { result.current.handleHybridSearchModeChange(HybridSearchModeEnum.WeightedScore) @@ -401,29 +408,31 @@ describe('useConfig', () => { }) it('should reuse existing hybrid weights and allow empty embedding defaults', () => { - const { result } = renderConfigHook(createNodeData({ - embedding_model: undefined, - embedding_model_provider: undefined, - retrieval_model: { - search_method: RetrievalSearchMethodEnum.hybrid, - reranking_enable: false, - reranking_mode: RerankingModeEnum.WeightedScore, - weights: { - weight_type: WeightedScoreEnum.Customized, - vector_setting: { - vector_weight: 0.9, - embedding_provider_name: 'existing-provider', - embedding_model_name: 'existing-model', - }, - keyword_setting: { - keyword_weight: 0.1, + const { result } = renderConfigHook( + createNodeData({ + embedding_model: undefined, + embedding_model_provider: undefined, + retrieval_model: { + search_method: RetrievalSearchMethodEnum.hybrid, + reranking_enable: false, + reranking_mode: RerankingModeEnum.WeightedScore, + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.9, + embedding_provider_name: 'existing-provider', + embedding_model_name: 'existing-model', + }, + keyword_setting: { + keyword_weight: 0.1, + }, }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - }, - })) + }), + ) act(() => { result.current.handleHybridSearchModeChange(HybridSearchModeEnum.RerankingModel) diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-embedding-model-status.spec.ts b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-embedding-model-status.spec.ts index de44cfa11288e1..dbe7acf1b261d3 100644 --- a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-embedding-model-status.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-embedding-model-status.spec.ts @@ -11,9 +11,12 @@ import { useEmbeddingModelStatus } from '../use-embedding-model-status' const mockUseCredentialPanelState = vi.hoisted(() => vi.fn()) const mockUseProviderContext = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({ - useCredentialPanelState: mockUseCredentialPanelState, -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', + () => ({ + useCredentialPanelState: mockUseCredentialPanelState, + }), +) vi.mock('@/context/provider-context', () => ({ useProviderContext: mockUseProviderContext, @@ -23,9 +26,11 @@ describe('useEmbeddingModelStatus', () => { beforeEach(() => { vi.clearAllMocks() mockUseProviderContext.mockReturnValue({ - modelProviders: [createProviderMeta({ - supported_model_types: [ModelTypeEnum.textEmbedding], - })], + modelProviders: [ + createProviderMeta({ + supported_model_types: [ModelTypeEnum.textEmbedding], + }), + ], }) mockUseCredentialPanelState.mockReturnValue(createCredentialState()) }) @@ -35,11 +40,13 @@ describe('useEmbeddingModelStatus', () => { it('should return the matched provider, current model, and active status', () => { const embeddingModelList = [createModel()] - const { result } = renderHook(() => useEmbeddingModelStatus({ - embeddingModel: 'text-embedding-3-large', - embeddingModelProvider: 'openai', - embeddingModelList, - })) + const { result } = renderHook(() => + useEmbeddingModelStatus({ + embeddingModel: 'text-embedding-3-large', + embeddingModelProvider: 'openai', + embeddingModelList, + }), + ) expect(result.current.providerMeta?.provider).toBe('openai') expect(result.current.modelProvider?.provider).toBe('openai') @@ -54,11 +61,13 @@ describe('useEmbeddingModelStatus', () => { }), ] - const { result } = renderHook(() => useEmbeddingModelStatus({ - embeddingModel: 'text-embedding-3-large', - embeddingModelProvider: 'openai', - embeddingModelList, - })) + const { result } = renderHook(() => + useEmbeddingModelStatus({ + embeddingModel: 'text-embedding-3-large', + embeddingModelProvider: 'openai', + embeddingModelList, + }), + ) expect(result.current.providerMeta?.provider).toBe('openai') expect(result.current.currentModel).toBeUndefined() @@ -66,11 +75,13 @@ describe('useEmbeddingModelStatus', () => { }) it('should return empty when no embedding model is configured', () => { - const { result } = renderHook(() => useEmbeddingModelStatus({ - embeddingModel: undefined, - embeddingModelProvider: undefined, - embeddingModelList: [], - })) + const { result } = renderHook(() => + useEmbeddingModelStatus({ + embeddingModel: undefined, + embeddingModelProvider: undefined, + embeddingModelList: [], + }), + ) expect(result.current.providerMeta).toBeUndefined() expect(result.current.modelProvider).toBeUndefined() diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-settings-display.spec.ts b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-settings-display.spec.ts index e0a17917683b0a..2ff276a249f518 100644 --- a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-settings-display.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-settings-display.spec.ts @@ -1,8 +1,5 @@ import { renderHook } from '@testing-library/react' -import { - IndexMethodEnum, - RetrievalSearchMethodEnum, -} from '../../types' +import { IndexMethodEnum, RetrievalSearchMethodEnum } from '../../types' import { useSettingsDisplay } from '../use-settings-display' describe('useSettingsDisplay', () => { @@ -16,11 +13,21 @@ describe('useSettingsDisplay', () => { const { result } = renderHook(() => useSettingsDisplay()) expect(result.current[IndexMethodEnum.QUALIFIED]).toBe('datasetCreation.stepTwo.qualified') - expect(result.current[IndexMethodEnum.ECONOMICAL]).toBe('datasetSettings.form.indexMethodEconomy') - expect(result.current[RetrievalSearchMethodEnum.semantic]).toBe('dataset.retrieval.semantic_search.title') - expect(result.current[RetrievalSearchMethodEnum.fullText]).toBe('dataset.retrieval.full_text_search.title') - expect(result.current[RetrievalSearchMethodEnum.hybrid]).toBe('dataset.retrieval.hybrid_search.title') - expect(result.current[RetrievalSearchMethodEnum.keywordSearch]).toBe('dataset.retrieval.keyword_search.title') + expect(result.current[IndexMethodEnum.ECONOMICAL]).toBe( + 'datasetSettings.form.indexMethodEconomy', + ) + expect(result.current[RetrievalSearchMethodEnum.semantic]).toBe( + 'dataset.retrieval.semantic_search.title', + ) + expect(result.current[RetrievalSearchMethodEnum.fullText]).toBe( + 'dataset.retrieval.full_text_search.title', + ) + expect(result.current[RetrievalSearchMethodEnum.hybrid]).toBe( + 'dataset.retrieval.hybrid_search.title', + ) + expect(result.current[RetrievalSearchMethodEnum.keywordSearch]).toBe( + 'dataset.retrieval.keyword_search.title', + ) }) }) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/use-config.ts b/web/app/components/workflow/nodes/knowledge-base/hooks/use-config.ts index f26df2c3ee4a9b..649d50831f28a0 100644 --- a/web/app/components/workflow/nodes/knowledge-base/hooks/use-config.ts +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/use-config.ts @@ -1,13 +1,7 @@ -import type { - KnowledgeBaseNodeType, - RerankingModel, - SummaryIndexSetting, -} from '../types' +import type { KnowledgeBaseNodeType, RerankingModel, SummaryIndexSetting } from '../types' import type { ValueSelector } from '@/app/components/workflow/types' import { produce } from 'immer' -import { - useCallback, -} from 'react' +import { useCallback } from 'react' import { useStoreApi } from 'reactflow' import { useNodeDataUpdate } from '@/app/components/workflow/hooks' import { DEFAULT_WEIGHTED_SCORE, RerankingModeEnum } from '@/models/datasets' @@ -28,234 +22,295 @@ export const useConfig = (id: string) => { const { getNodes } = store.getState() const nodes = getNodes() - return nodes.find(node => node.id === id) + return nodes.find((node) => node.id === id) }, [store, id]) - const handleNodeDataUpdate = useCallback((data: Partial<KnowledgeBaseNodeType>) => { - handleNodeDataUpdateWithSyncDraft({ - id, - data, - }) - }, [id, handleNodeDataUpdateWithSyncDraft]) + const handleNodeDataUpdate = useCallback( + (data: Partial<KnowledgeBaseNodeType>) => { + handleNodeDataUpdateWithSyncDraft({ + id, + data, + }) + }, + [id, handleNodeDataUpdateWithSyncDraft], + ) - const getDefaultWeights = useCallback(({ - embeddingModel, - embeddingModelProvider, - }: { - embeddingModel: string - embeddingModelProvider: string - }) => { - return { - vector_setting: { - vector_weight: DEFAULT_WEIGHTED_SCORE.other.semantic, - embedding_provider_name: embeddingModelProvider || '', - embedding_model_name: embeddingModel, - }, - keyword_setting: { - keyword_weight: DEFAULT_WEIGHTED_SCORE.other.keyword, - }, - } - }, []) + const getDefaultWeights = useCallback( + ({ + embeddingModel, + embeddingModelProvider, + }: { + embeddingModel: string + embeddingModelProvider: string + }) => { + return { + vector_setting: { + vector_weight: DEFAULT_WEIGHTED_SCORE.other.semantic, + embedding_provider_name: embeddingModelProvider || '', + embedding_model_name: embeddingModel, + }, + keyword_setting: { + keyword_weight: DEFAULT_WEIGHTED_SCORE.other.keyword, + }, + } + }, + [], + ) - const handleChunkStructureChange = useCallback((chunkStructure: ChunkStructureEnum) => { - const nodeData = getNodeData() - const { - indexing_technique, - retrieval_model, - chunk_structure, - index_chunk_variable_selector, - } = nodeData?.data || {} - const { search_method } = retrieval_model || {} - handleNodeDataUpdate({ - chunk_structure: chunkStructure, - indexing_technique: (chunkStructure === ChunkStructureEnum.parent_child || chunkStructure === ChunkStructureEnum.question_answer) ? IndexMethodEnum.QUALIFIED : indexing_technique, - retrieval_model: { - ...retrieval_model, - search_method: ((chunkStructure === ChunkStructureEnum.parent_child || chunkStructure === ChunkStructureEnum.question_answer) && !isHighQualitySearchMethod(search_method)) ? RetrievalSearchMethodEnum.keywordSearch : search_method, - }, - index_chunk_variable_selector: chunkStructure === chunk_structure ? index_chunk_variable_selector : [], - }) - }, [handleNodeDataUpdate, getNodeData]) + const handleChunkStructureChange = useCallback( + (chunkStructure: ChunkStructureEnum) => { + const nodeData = getNodeData() + const { + indexing_technique, + retrieval_model, + chunk_structure, + index_chunk_variable_selector, + } = nodeData?.data || {} + const { search_method } = retrieval_model || {} + handleNodeDataUpdate({ + chunk_structure: chunkStructure, + indexing_technique: + chunkStructure === ChunkStructureEnum.parent_child || + chunkStructure === ChunkStructureEnum.question_answer + ? IndexMethodEnum.QUALIFIED + : indexing_technique, + retrieval_model: { + ...retrieval_model, + search_method: + (chunkStructure === ChunkStructureEnum.parent_child || + chunkStructure === ChunkStructureEnum.question_answer) && + !isHighQualitySearchMethod(search_method) + ? RetrievalSearchMethodEnum.keywordSearch + : search_method, + }, + index_chunk_variable_selector: + chunkStructure === chunk_structure ? index_chunk_variable_selector : [], + }) + }, + [handleNodeDataUpdate, getNodeData], + ) - const handleIndexMethodChange = useCallback((indexMethod: IndexMethodEnum) => { - const nodeData = getNodeData() + const handleIndexMethodChange = useCallback( + (indexMethod: IndexMethodEnum) => { + const nodeData = getNodeData() - handleNodeDataUpdate(produce(nodeData?.data as KnowledgeBaseNodeType, (draft) => { - draft.indexing_technique = indexMethod + handleNodeDataUpdate( + produce(nodeData?.data as KnowledgeBaseNodeType, (draft) => { + draft.indexing_technique = indexMethod - if (indexMethod === IndexMethodEnum.ECONOMICAL) - draft.retrieval_model.search_method = RetrievalSearchMethodEnum.keywordSearch - else if (indexMethod === IndexMethodEnum.QUALIFIED) - draft.retrieval_model.search_method = RetrievalSearchMethodEnum.semantic - })) - }, [handleNodeDataUpdate, getNodeData]) + if (indexMethod === IndexMethodEnum.ECONOMICAL) + draft.retrieval_model.search_method = RetrievalSearchMethodEnum.keywordSearch + else if (indexMethod === IndexMethodEnum.QUALIFIED) + draft.retrieval_model.search_method = RetrievalSearchMethodEnum.semantic + }), + ) + }, + [handleNodeDataUpdate, getNodeData], + ) - const handleKeywordNumberChange = useCallback((keywordNumber: number) => { - handleNodeDataUpdate({ keyword_number: keywordNumber }) - }, [handleNodeDataUpdate]) + const handleKeywordNumberChange = useCallback( + (keywordNumber: number) => { + handleNodeDataUpdate({ keyword_number: keywordNumber }) + }, + [handleNodeDataUpdate], + ) - const handleEmbeddingModelChange = useCallback(({ - embeddingModel, - embeddingModelProvider, - }: { - embeddingModel: string - embeddingModelProvider: string - }) => { - const nodeData = getNodeData() - const defaultWeights = getDefaultWeights({ + const handleEmbeddingModelChange = useCallback( + ({ embeddingModel, embeddingModelProvider, - }) - const changeData = { - embedding_model: embeddingModel, - embedding_model_provider: embeddingModelProvider, - retrieval_model: { - ...nodeData?.data.retrieval_model, - }, - } - if (changeData.retrieval_model.weights) { - changeData.retrieval_model = { - ...changeData.retrieval_model, - weights: { - ...changeData.retrieval_model.weights, - vector_setting: { - ...changeData.retrieval_model.weights.vector_setting, - embedding_provider_name: embeddingModelProvider, - embedding_model_name: embeddingModel, - }, + }: { + embeddingModel: string + embeddingModelProvider: string + }) => { + const nodeData = getNodeData() + const defaultWeights = getDefaultWeights({ + embeddingModel, + embeddingModelProvider, + }) + const changeData = { + embedding_model: embeddingModel, + embedding_model_provider: embeddingModelProvider, + retrieval_model: { + ...nodeData?.data.retrieval_model, }, } - } - else { - changeData.retrieval_model = { - ...changeData.retrieval_model, - weights: defaultWeights, + if (changeData.retrieval_model.weights) { + changeData.retrieval_model = { + ...changeData.retrieval_model, + weights: { + ...changeData.retrieval_model.weights, + vector_setting: { + ...changeData.retrieval_model.weights.vector_setting, + embedding_provider_name: embeddingModelProvider, + embedding_model_name: embeddingModel, + }, + }, + } + } else { + changeData.retrieval_model = { + ...changeData.retrieval_model, + weights: defaultWeights, + } } - } - handleNodeDataUpdate(changeData) - }, [getNodeData, getDefaultWeights, handleNodeDataUpdate]) + handleNodeDataUpdate(changeData) + }, + [getNodeData, getDefaultWeights, handleNodeDataUpdate], + ) - const handleRetrievalSearchMethodChange = useCallback((searchMethod: RetrievalSearchMethodEnum) => { - const nodeData = getNodeData() - const changeData = { - retrieval_model: { - ...nodeData?.data.retrieval_model, - search_method: searchMethod, - reranking_mode: nodeData?.data.retrieval_model.reranking_mode || RerankingModeEnum.RerankingModel, - }, - } - if (searchMethod === RetrievalSearchMethodEnum.hybrid) { - changeData.retrieval_model = { - ...changeData.retrieval_model, - reranking_enable: changeData.retrieval_model.reranking_mode === RerankingModeEnum.RerankingModel, + const handleRetrievalSearchMethodChange = useCallback( + (searchMethod: RetrievalSearchMethodEnum) => { + const nodeData = getNodeData() + const changeData = { + retrieval_model: { + ...nodeData?.data.retrieval_model, + search_method: searchMethod, + reranking_mode: + nodeData?.data.retrieval_model.reranking_mode || RerankingModeEnum.RerankingModel, + }, + } + if (searchMethod === RetrievalSearchMethodEnum.hybrid) { + changeData.retrieval_model = { + ...changeData.retrieval_model, + reranking_enable: + changeData.retrieval_model.reranking_mode === RerankingModeEnum.RerankingModel, + } } - } - handleNodeDataUpdate(changeData) - }, [getNodeData, handleNodeDataUpdate]) + handleNodeDataUpdate(changeData) + }, + [getNodeData, handleNodeDataUpdate], + ) - const handleHybridSearchModeChange = useCallback((hybridSearchMode: HybridSearchModeEnum) => { - const nodeData = getNodeData() - const defaultWeights = getDefaultWeights({ - embeddingModel: nodeData?.data.embedding_model || '', - embeddingModelProvider: nodeData?.data.embedding_model_provider || '', - }) - handleNodeDataUpdate({ - retrieval_model: { - ...nodeData?.data.retrieval_model, - reranking_mode: hybridSearchMode, - reranking_enable: hybridSearchMode === HybridSearchModeEnum.RerankingModel, - weights: nodeData?.data.retrieval_model.weights || defaultWeights, - }, - }) - }, [getNodeData, getDefaultWeights, handleNodeDataUpdate]) + const handleHybridSearchModeChange = useCallback( + (hybridSearchMode: HybridSearchModeEnum) => { + const nodeData = getNodeData() + const defaultWeights = getDefaultWeights({ + embeddingModel: nodeData?.data.embedding_model || '', + embeddingModelProvider: nodeData?.data.embedding_model_provider || '', + }) + handleNodeDataUpdate({ + retrieval_model: { + ...nodeData?.data.retrieval_model, + reranking_mode: hybridSearchMode, + reranking_enable: hybridSearchMode === HybridSearchModeEnum.RerankingModel, + weights: nodeData?.data.retrieval_model.weights || defaultWeights, + }, + }) + }, + [getNodeData, getDefaultWeights, handleNodeDataUpdate], + ) - const handleRerankingModelEnabledChange = useCallback((rerankingModelEnabled: boolean) => { - const nodeData = getNodeData() - handleNodeDataUpdate({ - retrieval_model: { - ...nodeData?.data.retrieval_model, - reranking_enable: rerankingModelEnabled, - }, - }) - }, [getNodeData, handleNodeDataUpdate]) + const handleRerankingModelEnabledChange = useCallback( + (rerankingModelEnabled: boolean) => { + const nodeData = getNodeData() + handleNodeDataUpdate({ + retrieval_model: { + ...nodeData?.data.retrieval_model, + reranking_enable: rerankingModelEnabled, + }, + }) + }, + [getNodeData, handleNodeDataUpdate], + ) - const handleWeighedScoreChange = useCallback((weightedScore: { value: number[] }) => { - const nodeData = getNodeData() - handleNodeDataUpdate({ - retrieval_model: { - ...nodeData?.data.retrieval_model, - weights: { - weight_type: WeightedScoreEnum.Customized, - vector_setting: { - ...nodeData?.data.retrieval_model.weights?.vector_setting, - vector_weight: weightedScore.value[0], - }, - keyword_setting: { - keyword_weight: weightedScore.value[1], + const handleWeighedScoreChange = useCallback( + (weightedScore: { value: number[] }) => { + const nodeData = getNodeData() + handleNodeDataUpdate({ + retrieval_model: { + ...nodeData?.data.retrieval_model, + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + ...nodeData?.data.retrieval_model.weights?.vector_setting, + vector_weight: weightedScore.value[0], + }, + keyword_setting: { + keyword_weight: weightedScore.value[1], + }, }, }, - }, - }) - }, [getNodeData, handleNodeDataUpdate]) + }) + }, + [getNodeData, handleNodeDataUpdate], + ) - const handleRerankingModelChange = useCallback((rerankingModel: RerankingModel) => { - const nodeData = getNodeData() - handleNodeDataUpdate({ - retrieval_model: { - ...nodeData?.data.retrieval_model, - reranking_model: { - reranking_provider_name: rerankingModel.reranking_provider_name, - reranking_model_name: rerankingModel.reranking_model_name, + const handleRerankingModelChange = useCallback( + (rerankingModel: RerankingModel) => { + const nodeData = getNodeData() + handleNodeDataUpdate({ + retrieval_model: { + ...nodeData?.data.retrieval_model, + reranking_model: { + reranking_provider_name: rerankingModel.reranking_provider_name, + reranking_model_name: rerankingModel.reranking_model_name, + }, }, - }, - }) - }, [getNodeData, handleNodeDataUpdate]) + }) + }, + [getNodeData, handleNodeDataUpdate], + ) - const handleTopKChange = useCallback((topK: number) => { - const nodeData = getNodeData() - handleNodeDataUpdate({ - retrieval_model: { - ...nodeData?.data.retrieval_model, - top_k: topK, - }, - }) - }, [getNodeData, handleNodeDataUpdate]) + const handleTopKChange = useCallback( + (topK: number) => { + const nodeData = getNodeData() + handleNodeDataUpdate({ + retrieval_model: { + ...nodeData?.data.retrieval_model, + top_k: topK, + }, + }) + }, + [getNodeData, handleNodeDataUpdate], + ) - const handleScoreThresholdChange = useCallback((scoreThreshold: number) => { - const nodeData = getNodeData() - handleNodeDataUpdate({ - retrieval_model: { - ...nodeData?.data.retrieval_model, - score_threshold: scoreThreshold, - }, - }) - }, [getNodeData, handleNodeDataUpdate]) + const handleScoreThresholdChange = useCallback( + (scoreThreshold: number) => { + const nodeData = getNodeData() + handleNodeDataUpdate({ + retrieval_model: { + ...nodeData?.data.retrieval_model, + score_threshold: scoreThreshold, + }, + }) + }, + [getNodeData, handleNodeDataUpdate], + ) - const handleScoreThresholdEnabledChange = useCallback((isEnabled: boolean) => { - const nodeData = getNodeData() - handleNodeDataUpdate({ - retrieval_model: { - ...nodeData?.data.retrieval_model, - score_threshold_enabled: isEnabled, - }, - }) - }, [getNodeData, handleNodeDataUpdate]) + const handleScoreThresholdEnabledChange = useCallback( + (isEnabled: boolean) => { + const nodeData = getNodeData() + handleNodeDataUpdate({ + retrieval_model: { + ...nodeData?.data.retrieval_model, + score_threshold_enabled: isEnabled, + }, + }) + }, + [getNodeData, handleNodeDataUpdate], + ) - const handleInputVariableChange = useCallback((inputVariable: string | ValueSelector) => { - handleNodeDataUpdate({ - index_chunk_variable_selector: Array.isArray(inputVariable) ? inputVariable : [], - }) - }, [handleNodeDataUpdate]) + const handleInputVariableChange = useCallback( + (inputVariable: string | ValueSelector) => { + handleNodeDataUpdate({ + index_chunk_variable_selector: Array.isArray(inputVariable) ? inputVariable : [], + }) + }, + [handleNodeDataUpdate], + ) - const handleSummaryIndexSettingChange = useCallback((summaryIndexSetting: SummaryIndexSetting) => { - const nodeData = getNodeData() - handleNodeDataUpdate({ - summary_index_setting: { - ...nodeData?.data.summary_index_setting, - ...summaryIndexSetting, - }, - }) - }, [handleNodeDataUpdate, getNodeData]) + const handleSummaryIndexSettingChange = useCallback( + (summaryIndexSetting: SummaryIndexSetting) => { + const nodeData = getNodeData() + handleNodeDataUpdate({ + summary_index_setting: { + ...nodeData?.data.summary_index_setting, + ...summaryIndexSetting, + }, + }) + }, + [handleNodeDataUpdate, getNodeData], + ) return { handleChunkStructureChange, diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/use-embedding-model-status.ts b/web/app/components/workflow/nodes/knowledge-base/hooks/use-embedding-model-status.ts index b32d998ada05bd..75cb2192ec35b5 100644 --- a/web/app/components/workflow/nodes/knowledge-base/hooks/use-embedding-model-status.ts +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/use-embedding-model-status.ts @@ -29,15 +29,15 @@ export const useEmbeddingModelStatus = ({ const { modelProviders } = useProviderContext() const providerMeta = useMemo(() => { - return modelProviders.find(provider => provider.provider === embeddingModelProvider) + return modelProviders.find((provider) => provider.provider === embeddingModelProvider) }, [embeddingModelProvider, modelProviders]) const modelProvider = useMemo(() => { - return embeddingModelList.find(provider => provider.provider === embeddingModelProvider) + return embeddingModelList.find((provider) => provider.provider === embeddingModelProvider) }, [embeddingModelList, embeddingModelProvider]) const currentModel = useMemo(() => { - return modelProvider?.models.find(model => model.model === embeddingModel) + return modelProvider?.models.find((model) => model.model === embeddingModel) }, [embeddingModel, modelProvider]) const credentialState = useCredentialPanelState(providerMeta) diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/use-settings-display.ts b/web/app/components/workflow/nodes/knowledge-base/hooks/use-settings-display.ts index dd65859161dc60..eb8439ab8f8ea6 100644 --- a/web/app/components/workflow/nodes/knowledge-base/hooks/use-settings-display.ts +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/use-settings-display.ts @@ -1,18 +1,23 @@ import { useTranslation } from 'react-i18next' -import { - IndexMethodEnum, - RetrievalSearchMethodEnum, -} from '../types' +import { IndexMethodEnum, RetrievalSearchMethodEnum } from '../types' export const useSettingsDisplay = () => { const { t } = useTranslation() return { - [IndexMethodEnum.QUALIFIED]: t($ => $['stepTwo.qualified'], { ns: 'datasetCreation' }), - [IndexMethodEnum.ECONOMICAL]: t($ => $['form.indexMethodEconomy'], { ns: 'datasetSettings' }), - [RetrievalSearchMethodEnum.semantic]: t($ => $['retrieval.semantic_search.title'], { ns: 'dataset' }), - [RetrievalSearchMethodEnum.fullText]: t($ => $['retrieval.full_text_search.title'], { ns: 'dataset' }), - [RetrievalSearchMethodEnum.hybrid]: t($ => $['retrieval.hybrid_search.title'], { ns: 'dataset' }), - [RetrievalSearchMethodEnum.keywordSearch]: t($ => $['retrieval.keyword_search.title'], { ns: 'dataset' }), + [IndexMethodEnum.QUALIFIED]: t(($) => $['stepTwo.qualified'], { ns: 'datasetCreation' }), + [IndexMethodEnum.ECONOMICAL]: t(($) => $['form.indexMethodEconomy'], { ns: 'datasetSettings' }), + [RetrievalSearchMethodEnum.semantic]: t(($) => $['retrieval.semantic_search.title'], { + ns: 'dataset', + }), + [RetrievalSearchMethodEnum.fullText]: t(($) => $['retrieval.full_text_search.title'], { + ns: 'dataset', + }), + [RetrievalSearchMethodEnum.hybrid]: t(($) => $['retrieval.hybrid_search.title'], { + ns: 'dataset', + }), + [RetrievalSearchMethodEnum.keywordSearch]: t(($) => $['retrieval.keyword_search.title'], { + ns: 'dataset', + }), } } diff --git a/web/app/components/workflow/nodes/knowledge-base/node.tsx b/web/app/components/workflow/nodes/knowledge-base/node.tsx index 1575cbb7d9600b..369ad627770c9b 100644 --- a/web/app/components/workflow/nodes/knowledge-base/node.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/node.tsx @@ -3,14 +3,9 @@ import type { KnowledgeBaseNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { useQuery } from '@tanstack/react-query' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { - ModelTypeEnum, -} from '@/app/components/header/account-setting/model-provider-page/declarations' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { DERIVED_MODEL_STATUS_BADGE_I18N } from '@/app/components/header/account-setting/model-provider-page/derive-model-status' import { useLanguage, @@ -20,9 +15,7 @@ import { normalizeModelProviderModelsResponse } from '@/app/components/header/ac import { consoleQuery } from '@/service/client' import { useEmbeddingModelStatus } from './hooks/use-embedding-model-status' import { useSettingsDisplay } from './hooks/use-settings-display' -import { - IndexMethodEnum, -} from './types' +import { IndexMethodEnum } from './types' import { getKnowledgeBaseValidationIssue, getKnowledgeBaseValidationMessage, @@ -35,11 +28,7 @@ type SettingRowProps = { warning?: boolean } -const SettingRow = memo(({ - label, - value, - warning = false, -}: SettingRowProps) => { +const SettingRow = memo(({ label, value, warning = false }: SettingRowProps) => { return ( <div className={cn( @@ -49,11 +38,12 @@ const SettingRow = memo(({ : 'bg-workflow-block-parma-bg', )} > - <div className="mr-2 shrink-0 system-xs-medium-uppercase text-text-tertiary"> - {label} - </div> + <div className="mr-2 shrink-0 system-xs-medium-uppercase text-text-tertiary">{label}</div> <div - className={cn('grow truncate text-right system-xs-medium', warning ? 'text-text-warning' : 'text-text-secondary')} + className={cn( + 'grow truncate text-right system-xs-medium', + warning ? 'text-text-warning' : 'text-text-secondary', + )} title={value} > {value} @@ -130,42 +120,60 @@ const Node: FC<NodeProps<KnowledgeBaseNodeType>> = ({ data }) => { const validationIssueMessage = useMemo(() => { return getKnowledgeBaseValidationMessage(validationIssue, t) }, [validationIssue, t]) - const { currentModel: currentEmbeddingModel, status: embeddingModelStatus } = useEmbeddingModelStatus({ - embeddingModel: data.embedding_model, - embeddingModelProvider: data.embedding_model_provider, - embeddingModelList, - }) + const { currentModel: currentEmbeddingModel, status: embeddingModelStatus } = + useEmbeddingModelStatus({ + embeddingModel: data.embedding_model, + embeddingModelProvider: data.embedding_model_provider, + embeddingModelList, + }) const chunksDisplayValue = useMemo(() => { - if (!data.index_chunk_variable_selector?.length) - return '-' + if (!data.index_chunk_variable_selector?.length) return '-' const chunkVar = data.index_chunk_variable_selector.at(-1) return chunkVar || '-' }, [data.index_chunk_variable_selector]) const embeddingModelDisplay = useMemo(() => { - if (data.indexing_technique !== IndexMethodEnum.QUALIFIED) - return '-' + if (data.indexing_technique !== IndexMethodEnum.QUALIFIED) return '-' if (embeddingModelStatus === 'empty') - return t($ => $['detailPanel.configureModel'], { ns: 'plugin' }) + return t(($) => $['detailPanel.configureModel'], { ns: 'plugin' }) if (embeddingModelStatus !== 'active') { - const statusI18nKey = DERIVED_MODEL_STATUS_BADGE_I18N[embeddingModelStatus as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N] - if (statusI18nKey) - return t($ => $[statusI18nKey], { ns: 'common' }) + const statusI18nKey = + DERIVED_MODEL_STATUS_BADGE_I18N[ + embeddingModelStatus as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N + ] + if (statusI18nKey) return t(($) => $[statusI18nKey], { ns: 'common' }) } - return currentEmbeddingModel?.label[language] || currentEmbeddingModel?.label.en_US || data.embedding_model || '-' - }, [currentEmbeddingModel, data.embedding_model, data.indexing_technique, embeddingModelStatus, language, t]) - - const indexMethodDisplay = settingsDisplay[data.indexing_technique as keyof typeof settingsDisplay] || '-' - const retrievalMethodDisplay = settingsDisplay[data.retrieval_model?.search_method as keyof typeof settingsDisplay] || '-' + return ( + currentEmbeddingModel?.label[language] || + currentEmbeddingModel?.label.en_US || + data.embedding_model || + '-' + ) + }, [ + currentEmbeddingModel, + data.embedding_model, + data.indexing_technique, + embeddingModelStatus, + language, + t, + ]) - const chunksWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.chunksVariableRequired - const indexMethodWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.indexMethodRequired - const embeddingWarning = data.indexing_technique === IndexMethodEnum.QUALIFIED && embeddingModelStatus !== 'active' + const indexMethodDisplay = + settingsDisplay[data.indexing_technique as keyof typeof settingsDisplay] || '-' + const retrievalMethodDisplay = + settingsDisplay[data.retrieval_model?.search_method as keyof typeof settingsDisplay] || '-' + + const chunksWarning = + validationIssue?.code === KnowledgeBaseValidationIssueCode.chunksVariableRequired + const indexMethodWarning = + validationIssue?.code === KnowledgeBaseValidationIssueCode.indexMethodRequired + const embeddingWarning = + data.indexing_technique === IndexMethodEnum.QUALIFIED && embeddingModelStatus !== 'active' const showEmbeddingModelRow = data.indexing_technique === IndexMethodEnum.QUALIFIED const retrievalWarning = !!(validationIssue && RETRIEVAL_WARNING_CODES.has(validationIssue.code)) @@ -174,7 +182,10 @@ const Node: FC<NodeProps<KnowledgeBaseNodeType>> = ({ data }) => { <div className="mb-1 space-y-0.5 px-3 py-1"> <div className="flex h-6 items-center rounded-md border-[0.5px] border-state-warning-active bg-state-warning-hover px-1.5"> <span className="mr-1 size-[4px] shrink-0 rounded-xs bg-text-warning-secondary" /> - <div className="grow truncate system-xs-medium text-text-warning" title={validationIssueMessage}> + <div + className="grow truncate system-xs-medium text-text-warning" + title={validationIssueMessage} + > {validationIssueMessage} </div> </div> @@ -185,24 +196,24 @@ const Node: FC<NodeProps<KnowledgeBaseNodeType>> = ({ data }) => { return ( <div className="mb-1 space-y-0.5 px-3 py-1"> <SettingRow - label={t($ => $['nodes.knowledgeBase.chunksInput'], { ns: 'workflow' })} + label={t(($) => $['nodes.knowledgeBase.chunksInput'], { ns: 'workflow' })} value={chunksWarning ? validationIssueMessage : chunksDisplayValue} warning={chunksWarning} /> <SettingRow - label={t($ => $['stepTwo.indexMode'], { ns: 'datasetCreation' })} + label={t(($) => $['stepTwo.indexMode'], { ns: 'datasetCreation' })} value={indexMethodWarning ? validationIssueMessage : indexMethodDisplay} warning={indexMethodWarning} /> {showEmbeddingModelRow && ( <SettingRow - label={t($ => $['form.embeddingModel'], { ns: 'datasetSettings' })} + label={t(($) => $['form.embeddingModel'], { ns: 'datasetSettings' })} value={embeddingModelDisplay} warning={embeddingWarning} /> )} <SettingRow - label={t($ => $['form.retrievalSetting.method'], { ns: 'datasetSettings' })} + label={t(($) => $['form.retrievalSetting.method'], { ns: 'datasetSettings' })} value={retrievalWarning ? validationIssueMessage : retrievalMethodDisplay} warning={retrievalWarning} /> diff --git a/web/app/components/workflow/nodes/knowledge-base/panel.tsx b/web/app/components/workflow/nodes/knowledge-base/panel.tsx index d1e3df80c50b14..bb40f738fb6f36 100644 --- a/web/app/components/workflow/nodes/knowledge-base/panel.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/panel.tsx @@ -2,11 +2,7 @@ import type { FC } from 'react' import type { KnowledgeBaseNodeType } from './types' import type { NodePanelProps, Var } from '@/app/components/workflow/types' import { useQuery } from '@tanstack/react-query' -import { - memo, - useCallback, - useMemo, -} from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import SummaryIndexSetting from '@/app/components/datasets/settings/summary-index-setting' import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils' @@ -29,19 +25,10 @@ import IndexMethod from './components/index-method' import RetrievalSetting from './components/retrieval-setting' import { useConfig } from './hooks/use-config' import { useEmbeddingModelStatus } from './hooks/use-embedding-model-status' -import { - ChunkStructureEnum, - IndexMethodEnum, -} from './types' -import { - getKnowledgeBaseValidationIssue, - KnowledgeBaseValidationIssueCode, -} from './utils' +import { ChunkStructureEnum, IndexMethodEnum } from './types' +import { getKnowledgeBaseValidationIssue, KnowledgeBaseValidationIssueCode } from './utils' -const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { nodesReadOnly } = useNodesReadOnly() const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) @@ -80,24 +67,31 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({ handleSummaryIndexSettingChange, } = useConfig(id) - const filterVar = useCallback((variable: Var) => { - if (!data.chunk_structure) - return false - switch (data.chunk_structure) { - case ChunkStructureEnum.general: - return variable.schemaType === 'general_structure' || variable.schemaType === 'multimodal_general_structure' - case ChunkStructureEnum.parent_child: - return variable.schemaType === 'parent_child_structure' || variable.schemaType === 'multimodal_parent_child_structure' - case ChunkStructureEnum.question_answer: - return variable.schemaType === 'qa_structure' - default: - return false - } - }, [data.chunk_structure]) + const filterVar = useCallback( + (variable: Var) => { + if (!data.chunk_structure) return false + switch (data.chunk_structure) { + case ChunkStructureEnum.general: + return ( + variable.schemaType === 'general_structure' || + variable.schemaType === 'multimodal_general_structure' + ) + case ChunkStructureEnum.parent_child: + return ( + variable.schemaType === 'parent_child_structure' || + variable.schemaType === 'multimodal_parent_child_structure' + ) + case ChunkStructureEnum.question_answer: + return variable.schemaType === 'qa_structure' + default: + return false + } + }, + [data.chunk_structure], + ) const chunkTypePlaceHolder = useMemo(() => { - if (!data.chunk_structure) - return '' + if (!data.chunk_structure) return '' let placeholder = '' switch (data.chunk_structure) { case ChunkStructureEnum.general: @@ -130,7 +124,15 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({ embeddingModelList, rerankModelList, }) - }, [data.embedding_model_provider, data.embedding_model, data.retrieval_model?.reranking_enable, data.retrieval_model?.reranking_model, data.indexing_technique, embeddingModelList, rerankModelList]) + }, [ + data.embedding_model_provider, + data.embedding_model, + data.retrieval_model?.reranking_enable, + data.retrieval_model?.reranking_model, + data.indexing_technique, + embeddingModelList, + rerankModelList, + ]) const validationPayload = useMemo(() => { return { @@ -171,16 +173,16 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({ embeddingModelList, }) - const chunkStructureWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.chunkStructureRequired - const chunksInputWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.chunksVariableRequired - const embeddingModelWarning = indexingTechnique === IndexMethodEnum.QUALIFIED && embeddingModelStatus !== 'active' + const chunkStructureWarning = + validationIssue?.code === KnowledgeBaseValidationIssueCode.chunkStructureRequired + const chunksInputWarning = + validationIssue?.code === KnowledgeBaseValidationIssueCode.chunksVariableRequired + const embeddingModelWarning = + indexingTechnique === IndexMethodEnum.QUALIFIED && embeddingModelStatus !== 'active' return ( <div> - <Group - className="py-3" - withBorderBottom={!!data.chunk_structure} - > + <Group className="py-3" withBorderBottom={!!data.chunk_structure}> <ChunkStructure chunkStructure={data.chunk_structure} onChunkStructureChange={handleChunkStructureChange} @@ -188,100 +190,96 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({ readonly={nodesReadOnly} /> </Group> - { - !!data.chunk_structure && ( - <> - <BoxGroupField - boxGroupProps={{ - boxProps: { withBorderBottom: true }, - }} - fieldProps={{ - fieldTitleProps: { - title: t($ => $['nodes.knowledgeBase.chunksInput'], { ns: 'workflow' }), - tooltip: t($ => $['nodes.knowledgeBase.chunksInputTip'], { ns: 'workflow' }), - warningDot: chunksInputWarning, - }, - }} - > - <VarReferencePicker - nodeId={id} - isShowNodeName - value={data.index_chunk_variable_selector} - onChange={handleInputVariableChange} + {!!data.chunk_structure && ( + <> + <BoxGroupField + boxGroupProps={{ + boxProps: { withBorderBottom: true }, + }} + fieldProps={{ + fieldTitleProps: { + title: t(($) => $['nodes.knowledgeBase.chunksInput'], { ns: 'workflow' }), + tooltip: t(($) => $['nodes.knowledgeBase.chunksInputTip'], { ns: 'workflow' }), + warningDot: chunksInputWarning, + }, + }} + > + <VarReferencePicker + nodeId={id} + isShowNodeName + value={data.index_chunk_variable_selector} + onChange={handleInputVariableChange} + readonly={nodesReadOnly} + filterVar={filterVar} + isFilterFileVar + isSupportFileVar={false} + preferSchemaType + typePlaceHolder={chunkTypePlaceHolder} + /> + </BoxGroupField> + <BoxGroup> + <div className="space-y-3"> + <IndexMethod + chunkStructure={data.chunk_structure} + indexMethod={data.indexing_technique} + onIndexMethodChange={handleIndexMethodChange} + keywordNumber={data.keyword_number} + onKeywordNumberChange={handleKeywordNumberChange} readonly={nodesReadOnly} - filterVar={filterVar} - isFilterFileVar - isSupportFileVar={false} - preferSchemaType - typePlaceHolder={chunkTypePlaceHolder} /> - </BoxGroupField> - <BoxGroup> - <div className="space-y-3"> - <IndexMethod - chunkStructure={data.chunk_structure} - indexMethod={data.indexing_technique} - onIndexMethodChange={handleIndexMethodChange} - keywordNumber={data.keyword_number} - onKeywordNumberChange={handleKeywordNumberChange} + {data.indexing_technique === IndexMethodEnum.QUALIFIED && ( + <EmbeddingModel + embeddingModel={data.embedding_model} + embeddingModelProvider={data.embedding_model_provider} + onEmbeddingModelChange={handleEmbeddingModelChange} + warningDot={embeddingModelWarning} readonly={nodesReadOnly} /> - { - data.indexing_technique === IndexMethodEnum.QUALIFIED && ( - <EmbeddingModel - embeddingModel={data.embedding_model} - embeddingModelProvider={data.embedding_model_provider} - onEmbeddingModelChange={handleEmbeddingModelChange} - warningDot={embeddingModelWarning} + )} + <div className="pt-1"> + <Split className="h-px" /> + </div> + {data.indexing_technique === IndexMethodEnum.QUALIFIED && + [ChunkStructureEnum.general, ChunkStructureEnum.parent_child].includes( + data.chunk_structure, + ) && + IS_CE_EDITION && ( + <> + <SummaryIndexSetting + summaryIndexSetting={data.summary_index_setting} + onSummaryIndexSettingChange={handleSummaryIndexSettingChange} readonly={nodesReadOnly} /> - ) - } - <div className="pt-1"> - <Split className="h-px" /> - </div> - { - data.indexing_technique === IndexMethodEnum.QUALIFIED - && [ChunkStructureEnum.general, ChunkStructureEnum.parent_child].includes(data.chunk_structure) - && IS_CE_EDITION && ( - <> - <SummaryIndexSetting - summaryIndexSetting={data.summary_index_setting} - onSummaryIndexSettingChange={handleSummaryIndexSettingChange} - readonly={nodesReadOnly} - /> - <div className="pt-1"> - <Split className="h-px" /> - </div> - </> - ) - } - <RetrievalSetting - indexMethod={data.indexing_technique} - searchMethod={data.retrieval_model.search_method} - onRetrievalSearchMethodChange={handleRetrievalSearchMethodChange} - hybridSearchMode={data.retrieval_model.reranking_mode} - onHybridSearchModeChange={handleHybridSearchModeChange} - weightedScore={data.retrieval_model.weights} - onWeightedScoreChange={handleWeighedScoreChange} - rerankingModelEnabled={data.retrieval_model.reranking_enable} - onRerankingModelEnabledChange={handleRerankingModelEnabledChange} - rerankingModel={data.retrieval_model.reranking_model} - onRerankingModelChange={handleRerankingModelChange} - topK={data.retrieval_model.top_k} - onTopKChange={handleTopKChange} - scoreThreshold={data.retrieval_model.score_threshold} - onScoreThresholdChange={handleScoreThresholdChange} - isScoreThresholdEnabled={data.retrieval_model.score_threshold_enabled} - onScoreThresholdEnabledChange={handleScoreThresholdEnabledChange} - showMultiModalTip={showMultiModalTip} - readonly={nodesReadOnly} - /> - </div> - </BoxGroup> - </> - ) - } + <div className="pt-1"> + <Split className="h-px" /> + </div> + </> + )} + <RetrievalSetting + indexMethod={data.indexing_technique} + searchMethod={data.retrieval_model.search_method} + onRetrievalSearchMethodChange={handleRetrievalSearchMethodChange} + hybridSearchMode={data.retrieval_model.reranking_mode} + onHybridSearchModeChange={handleHybridSearchModeChange} + weightedScore={data.retrieval_model.weights} + onWeightedScoreChange={handleWeighedScoreChange} + rerankingModelEnabled={data.retrieval_model.reranking_enable} + onRerankingModelEnabledChange={handleRerankingModelEnabledChange} + rerankingModel={data.retrieval_model.reranking_model} + onRerankingModelChange={handleRerankingModelChange} + topK={data.retrieval_model.top_k} + onTopKChange={handleTopKChange} + scoreThreshold={data.retrieval_model.score_threshold} + onScoreThresholdChange={handleScoreThresholdChange} + isScoreThresholdEnabled={data.retrieval_model.score_threshold_enabled} + onScoreThresholdEnabledChange={handleScoreThresholdEnabledChange} + showMultiModalTip={showMultiModalTip} + readonly={nodesReadOnly} + /> + </div> + </BoxGroup> + </> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/knowledge-base/types.ts b/web/app/components/workflow/nodes/knowledge-base/types.ts index afe7370ba61157..8713dd736809d8 100644 --- a/web/app/components/workflow/nodes/knowledge-base/types.ts +++ b/web/app/components/workflow/nodes/knowledge-base/types.ts @@ -1,5 +1,8 @@ import type { IndexingType } from '@/app/components/datasets/create/step-two' -import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + Model, + ModelItem, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { CommonNodeType } from '@/app/components/workflow/types' import type { RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets' import type { RETRIEVE_METHOD } from '@/types/app' diff --git a/web/app/components/workflow/nodes/knowledge-base/use-single-run-form-params.ts b/web/app/components/workflow/nodes/knowledge-base/use-single-run-form-params.ts index 61ee4c4f8f3c3e..a518237d152336 100644 --- a/web/app/components/workflow/nodes/knowledge-base/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/knowledge-base/use-single-run-form-params.ts @@ -12,29 +12,30 @@ type Params = { setRunInputData: (data: Record<string, any>) => void toVarInputs: (variables: Variable[]) => InputVar[] } -const useSingleRunFormParams = ({ - payload, - runInputData, - setRunInputData, -}: Params) => { +const useSingleRunFormParams = ({ payload, runInputData, setRunInputData }: Params) => { const { t } = useTranslation() const query = runInputData.query - const setQuery = useCallback((newQuery: string) => { - setRunInputData({ - ...runInputData, - query: newQuery, - }) - }, [runInputData, setRunInputData]) + const setQuery = useCallback( + (newQuery: string) => { + setRunInputData({ + ...runInputData, + query: newQuery, + }) + }, + [runInputData, setRunInputData], + ) const forms = useMemo(() => { return [ { - inputs: [{ - label: t($ => $['nodes.common.inputVars'], { ns: 'workflow' }), - variable: 'query', - type: InputVarType.paragraph, - required: true, - }], + inputs: [ + { + label: t(($) => $['nodes.common.inputVars'], { ns: 'workflow' }), + variable: 'query', + type: InputVarType.paragraph, + required: true, + }, + ], values: { query }, onChange: (keyValue: Record<string, any>) => setQuery(keyValue.query), }, @@ -45,8 +46,7 @@ const useSingleRunFormParams = ({ return [payload.index_chunk_variable_selector] } const getDependentVar = (variable: string) => { - if (variable === 'query') - return payload.index_chunk_variable_selector + if (variable === 'query') return payload.index_chunk_variable_selector } return { diff --git a/web/app/components/workflow/nodes/knowledge-base/utils.ts b/web/app/components/workflow/nodes/knowledge-base/utils.ts index 5912685351f533..231990a0f15fe1 100644 --- a/web/app/components/workflow/nodes/knowledge-base/utils.ts +++ b/web/app/components/workflow/nodes/knowledge-base/utils.ts @@ -1,19 +1,15 @@ import type { TFunction } from 'i18next' import type { KnowledgeBaseNodeType } from './types' -import { - IndexingType, -} from '@/app/components/datasets/create/step-two' -import { - ModelStatusEnum, -} from '@/app/components/header/account-setting/model-provider-page/declarations' -import { - RetrievalSearchMethodEnum, -} from './types' +import { IndexingType } from '@/app/components/datasets/create/step-two' +import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { RetrievalSearchMethodEnum } from './types' export const isHighQualitySearchMethod = (searchMethod: RetrievalSearchMethodEnum) => { - return searchMethod === RetrievalSearchMethodEnum.semantic - || searchMethod === RetrievalSearchMethodEnum.hybrid - || searchMethod === RetrievalSearchMethodEnum.fullText + return ( + searchMethod === RetrievalSearchMethodEnum.semantic || + searchMethod === RetrievalSearchMethodEnum.hybrid || + searchMethod === RetrievalSearchMethodEnum.fullText + ) } export enum KnowledgeBaseValidationIssueCode { @@ -35,15 +31,30 @@ type KnowledgeBaseValidationIssue = { code: KnowledgeBaseValidationIssueCode } -type KnowledgeBaseValidationPayload = Pick<KnowledgeBaseNodeType, 'chunk_structure' | 'index_chunk_variable_selector' | 'indexing_technique' | 'embedding_model' | 'embedding_model_provider' | '_embeddingModelList' | '_embeddingProviderModelList' | '_rerankModelList'> & { - retrieval_model?: Pick<KnowledgeBaseNodeType['retrieval_model'], 'search_method' | 'reranking_enable' | 'reranking_model'> +type KnowledgeBaseValidationPayload = Pick< + KnowledgeBaseNodeType, + | 'chunk_structure' + | 'index_chunk_variable_selector' + | 'indexing_technique' + | 'embedding_model' + | 'embedding_model_provider' + | '_embeddingModelList' + | '_embeddingProviderModelList' + | '_rerankModelList' +> & { + retrieval_model?: Pick< + KnowledgeBaseNodeType['retrieval_model'], + 'search_method' | 'reranking_enable' | 'reranking_model' + > } const resolveIssue = (code: KnowledgeBaseValidationIssueCode): KnowledgeBaseValidationIssue => ({ code, }) -const resolveEmbeddingIssue = (payload: KnowledgeBaseValidationPayload): KnowledgeBaseValidationIssue | null => { +const resolveEmbeddingIssue = ( + payload: KnowledgeBaseValidationPayload, +): KnowledgeBaseValidationIssue | null => { const { embedding_model, embedding_model_provider, @@ -54,21 +65,28 @@ const resolveEmbeddingIssue = (payload: KnowledgeBaseValidationPayload): Knowled if (!embedding_model || !embedding_model_provider) return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured) - const currentEmbeddingModelProvider = _embeddingModelList?.find(provider => provider.provider === embedding_model_provider) - const hasProviderScopedModelList = !!_embeddingProviderModelList && _embeddingProviderModelList.length > 0 + const currentEmbeddingModelProvider = _embeddingModelList?.find( + (provider) => provider.provider === embedding_model_provider, + ) + const hasProviderScopedModelList = + !!_embeddingProviderModelList && _embeddingProviderModelList.length > 0 const embeddingModelCandidates = hasProviderScopedModelList ? _embeddingProviderModelList : currentEmbeddingModelProvider?.models - const currentEmbeddingModel = embeddingModelCandidates?.find(model => model.model === embedding_model) + const currentEmbeddingModel = embeddingModelCandidates?.find( + (model) => model.model === embedding_model, + ) if (!currentEmbeddingModel) { if (!currentEmbeddingModelProvider) return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible) const providerExists = hasProviderScopedModelList || currentEmbeddingModelProvider - return resolveIssue(providerExists - ? KnowledgeBaseValidationIssueCode.embeddingModelIncompatible - : KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured) + return resolveIssue( + providerExists + ? KnowledgeBaseValidationIssueCode.embeddingModelIncompatible + : KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured, + ) } switch (currentEmbeddingModel.status) { @@ -88,7 +106,9 @@ const resolveEmbeddingIssue = (payload: KnowledgeBaseValidationPayload): Knowled } } -export const getKnowledgeBaseValidationIssue = (payload: KnowledgeBaseValidationPayload): KnowledgeBaseValidationIssue | null => { +export const getKnowledgeBaseValidationIssue = ( + payload: KnowledgeBaseValidationPayload, +): KnowledgeBaseValidationIssue | null => { const { chunk_structure, indexing_technique, @@ -97,36 +117,37 @@ export const getKnowledgeBaseValidationIssue = (payload: KnowledgeBaseValidation _rerankModelList, } = payload - const { - search_method, - reranking_enable, - reranking_model, - } = retrieval_model || {} + const { search_method, reranking_enable, reranking_model } = retrieval_model || {} - if (!chunk_structure) - return resolveIssue(KnowledgeBaseValidationIssueCode.chunkStructureRequired) + if (!chunk_structure) return resolveIssue(KnowledgeBaseValidationIssueCode.chunkStructureRequired) if (index_chunk_variable_selector.length === 0) return resolveIssue(KnowledgeBaseValidationIssueCode.chunksVariableRequired) - if (!indexing_technique) - return resolveIssue(KnowledgeBaseValidationIssueCode.indexMethodRequired) + if (!indexing_technique) return resolveIssue(KnowledgeBaseValidationIssueCode.indexMethodRequired) if (indexing_technique === IndexingType.QUALIFIED) { const embeddingIssue = resolveEmbeddingIssue(payload) - if (embeddingIssue) - return embeddingIssue + if (embeddingIssue) return embeddingIssue } if (!retrieval_model || !search_method) return resolveIssue(KnowledgeBaseValidationIssueCode.retrievalSettingRequired) if (reranking_enable) { - if (!reranking_model || !reranking_model.reranking_provider_name || !reranking_model.reranking_model_name) + if ( + !reranking_model || + !reranking_model.reranking_provider_name || + !reranking_model.reranking_model_name + ) return resolveIssue(KnowledgeBaseValidationIssueCode.rerankingModelRequired) - const currentRerankingModelProvider = _rerankModelList?.find(provider => provider.provider === reranking_model.reranking_provider_name) - const currentRerankingModel = currentRerankingModelProvider?.models.find(model => model.model === reranking_model.reranking_model_name) + const currentRerankingModelProvider = _rerankModelList?.find( + (provider) => provider.provider === reranking_model.reranking_provider_name, + ) + const currentRerankingModel = currentRerankingModelProvider?.models.find( + (model) => model.model === reranking_model.reranking_model_name, + ) if (!currentRerankingModel) return resolveIssue(KnowledgeBaseValidationIssueCode.rerankingModelInvalid) } @@ -138,34 +159,33 @@ export const getKnowledgeBaseValidationMessage = ( issue: KnowledgeBaseValidationIssue | null | undefined, t: TFunction, ) => { - if (!issue) - return '' + if (!issue) return '' switch (issue.code) { case KnowledgeBaseValidationIssueCode.chunkStructureRequired: - return t($ => $['nodes.knowledgeBase.chunkIsRequired'], { ns: 'workflow' }) + return t(($) => $['nodes.knowledgeBase.chunkIsRequired'], { ns: 'workflow' }) case KnowledgeBaseValidationIssueCode.chunksVariableRequired: - return t($ => $['nodes.knowledgeBase.chunksVariableIsRequired'], { ns: 'workflow' }) + return t(($) => $['nodes.knowledgeBase.chunksVariableIsRequired'], { ns: 'workflow' }) case KnowledgeBaseValidationIssueCode.indexMethodRequired: - return t($ => $['nodes.knowledgeBase.indexMethodIsRequired'], { ns: 'workflow' }) + return t(($) => $['nodes.knowledgeBase.indexMethodIsRequired'], { ns: 'workflow' }) case KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured: - return t($ => $['nodes.knowledgeBase.embeddingModelNotConfigured'], { ns: 'workflow' }) + return t(($) => $['nodes.knowledgeBase.embeddingModelNotConfigured'], { ns: 'workflow' }) case KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired: - return t($ => $['modelProvider.selector.configureRequired'], { ns: 'common' }) + return t(($) => $['modelProvider.selector.configureRequired'], { ns: 'common' }) case KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable: - return t($ => $['modelProvider.selector.apiKeyUnavailable'], { ns: 'common' }) + return t(($) => $['modelProvider.selector.apiKeyUnavailable'], { ns: 'common' }) case KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted: - return t($ => $['modelProvider.selector.creditsExhausted'], { ns: 'common' }) + return t(($) => $['modelProvider.selector.creditsExhausted'], { ns: 'common' }) case KnowledgeBaseValidationIssueCode.embeddingModelDisabled: - return t($ => $['modelProvider.selector.disabled'], { ns: 'common' }) + return t(($) => $['modelProvider.selector.disabled'], { ns: 'common' }) case KnowledgeBaseValidationIssueCode.embeddingModelIncompatible: - return t($ => $['modelProvider.selector.incompatible'], { ns: 'common' }) + return t(($) => $['modelProvider.selector.incompatible'], { ns: 'common' }) case KnowledgeBaseValidationIssueCode.retrievalSettingRequired: - return t($ => $['nodes.knowledgeBase.retrievalSettingIsRequired'], { ns: 'workflow' }) + return t(($) => $['nodes.knowledgeBase.retrievalSettingIsRequired'], { ns: 'workflow' }) case KnowledgeBaseValidationIssueCode.rerankingModelRequired: - return t($ => $['nodes.knowledgeBase.rerankingModelIsRequired'], { ns: 'workflow' }) + return t(($) => $['nodes.knowledgeBase.rerankingModelIsRequired'], { ns: 'workflow' }) case KnowledgeBaseValidationIssueCode.rerankingModelInvalid: - return t($ => $['nodes.knowledgeBase.rerankingModelIsInvalid'], { ns: 'workflow' }) + return t(($) => $['nodes.knowledgeBase.rerankingModelIsInvalid'], { ns: 'workflow' }) default: return '' } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx index 15cc0e5d95c2e1..11b2de37e56c8a 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx @@ -1,18 +1,13 @@ -import type { - ComparisonOperator, - MetadataFilteringCondition, - MetadataShape, -} from '../types' +import type { ComparisonOperator, MetadataFilteringCondition, MetadataShape } from '../types' import type { DataSet, MetadataInDoc } from '@/models/datasets' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useEffect, useRef } from 'react' +import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { - ChunkingMode, - DatasetPermission, - DataSourceType, -} from '@/models/datasets' -import { AccountProfileQueryProvider, createAccountProfileQueryClient } from '@/test/account-profile-query' + AccountProfileQueryProvider, + createAccountProfileQueryClient, +} from '@/test/account-profile-query' import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' import { DatasetACLPermission, getDatasetACLCapabilities } from '@/utils/permission' import { DatasetsDetailContext } from '../../../datasets-detail-store/provider' @@ -40,10 +35,16 @@ import { MetadataFilteringVariableType, } from '../types' -const mockHasEditPermissionForDataset = vi.fn(( - _userId: string, - _datasetConfig: { createdBy: string, partialMemberList: string[], permission: DatasetPermission }, -) => true) +const mockHasEditPermissionForDataset = vi.fn( + ( + _userId: string, + _datasetConfig: { + createdBy: string + partialMemberList: string[] + permission: DatasetPermission + }, + ) => true, +) const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ id: 'dataset-1', name: 'Dataset Name', @@ -120,7 +121,9 @@ const createMetadata = (overrides: Partial<MetadataInDoc> = {}): MetadataInDoc = ...overrides, }) -const createCondition = (overrides: Partial<MetadataFilteringCondition> = {}): MetadataFilteringCondition => ({ +const createCondition = ( + overrides: Partial<MetadataFilteringCondition> = {}, +): MetadataFilteringCondition => ({ id: 'condition-1', name: 'topic', metadata_id: 'meta-1', @@ -156,7 +159,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -181,7 +185,11 @@ vi.mock('@/utils/permission', () => ({ })), hasEditPermissionForDataset: ( userId: string, - datasetConfig: { createdBy: string, partialMemberList: string[], permission: DatasetPermission }, + datasetConfig: { + createdBy: string + partialMemberList: string[] + permission: DatasetPermission + }, ) => mockHasEditPermissionForDataset(userId, datasetConfig), })) @@ -202,9 +210,18 @@ vi.mock('@/hooks/use-knowledge', () => ({ vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () => ({ __esModule: true, - default: ({ onSelect, onClose }: { onSelect: (datasets: DataSet[]) => void, onClose: () => void }) => ( + default: ({ + onSelect, + onClose, + }: { + onSelect: (datasets: DataSet[]) => void + onClose: () => void + }) => ( <div> - <button type="button" onClick={() => onSelect([createDataset({ id: 'dataset-2', name: 'Selected Dataset' })])}> + <button + type="button" + onClick={() => onSelect([createDataset({ id: 'dataset-2', name: 'Selected Dataset' })])} + > select-dataset </button> <button type="button" onClick={onClose}> @@ -216,12 +233,19 @@ vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () = vi.mock('@/app/components/app/configuration/dataset-config/settings-modal', () => ({ __esModule: true, - default: function MockSettingsModal({ currentDataset, onSave, onCancel }: { currentDataset: DataSet, onSave: (dataset: DataSet) => void, onCancel: () => void }) { + default: function MockSettingsModal({ + currentDataset, + onSave, + onCancel, + }: { + currentDataset: DataSet + onSave: (dataset: DataSet) => void + onCancel: () => void + }) { const hasSavedRef = useRef(false) useEffect(() => { - if (hasSavedRef.current) - return + if (hasSavedRef.current) return hasSavedRef.current = true onSave(createDataset({ ...currentDataset, name: 'Updated Dataset' })) @@ -240,41 +264,52 @@ vi.mock('@/app/components/app/configuration/dataset-config/settings-modal', () = vi.mock('@/app/components/app/configuration/dataset-config/params-config/config-content', () => ({ __esModule: true, - default: ({ onChange }: { onChange: (config: Record<string, unknown>, isRetrievalModeChange?: boolean) => void }) => ( + default: ({ + onChange, + }: { + onChange: (config: Record<string, unknown>, isRetrievalModeChange?: boolean) => void + }) => ( <div> <button type="button" - onClick={() => onChange({ - retrieval_model: RETRIEVE_TYPE.multiWay, - top_k: 8, - score_threshold_enabled: true, - score_threshold: 0.4, - reranking_model: { - reranking_provider_name: 'cohere', - reranking_model_name: 'rerank-v3', - }, - reranking_mode: 'weighted_score', - weights: { - weight_type: 'customized', - vector_setting: { - vector_weight: 0.7, - embedding_provider_name: 'openai', - embedding_model_name: 'text-embedding-3', + onClick={() => + onChange({ + retrieval_model: RETRIEVE_TYPE.multiWay, + top_k: 8, + score_threshold_enabled: true, + score_threshold: 0.4, + reranking_model: { + reranking_provider_name: 'cohere', + reranking_model_name: 'rerank-v3', }, - keyword_setting: { - keyword_weight: 0.3, + reranking_mode: 'weighted_score', + weights: { + weight_type: 'customized', + vector_setting: { + vector_weight: 0.7, + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3', + }, + keyword_setting: { + keyword_weight: 0.3, + }, }, - }, - reranking_enable: true, - })} + reranking_enable: true, + }) + } > apply-retrieval-config </button> <button type="button" - onClick={() => onChange({ - retrieval_model: RETRIEVE_TYPE.oneWay, - }, true)} + onClick={() => + onChange( + { + retrieval_model: RETRIEVE_TYPE.oneWay, + }, + true, + ) + } > change-retrieval-mode </button> @@ -282,18 +317,22 @@ vi.mock('@/app/components/app/configuration/dataset-config/params-config/config- ), })) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - __esModule: true, - default: () => <div>model-parameter-modal</div>, -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + __esModule: true, + default: () => <div>model-parameter-modal</div>, + }), +) vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-vars', () => ({ __esModule: true, - default: ({ onChange }: { onChange: (valueSelector: string[], varItem: { type: VarType }) => void }) => ( - <button - type="button" - onClick={() => onChange(['node-1', 'field'], { type: VarType.string })} - > + default: ({ + onChange, + }: { + onChange: (valueSelector: string[], varItem: { type: VarType }) => void + }) => ( + <button type="button" onClick={() => onChange(['node-1', 'field'], { type: VarType.string })}> pick-var </button> ), @@ -319,8 +358,7 @@ vi.mock('../components/metadata/metadata-panel', () => ({ describe('knowledge-retrieval path', () => { const getDatasetItem = () => { const datasetItem = screen.getByText('Dataset Name').closest('.group\\/dataset-item') - if (!(datasetItem instanceof HTMLElement)) - throw new Error('Dataset item container not found') + if (!(datasetItem instanceof HTMLElement)) throw new Error('Dataset item container not found') return datasetItem } @@ -347,14 +385,13 @@ describe('knowledge-retrieval path', () => { const user = userEvent.setup() const onChange = vi.fn() - render( - <AddDataset - selectedIds={['dataset-1']} - onChange={onChange} - />, - ) + render(<AddDataset selectedIds={['dataset-1']} onChange={onChange} />) - await user.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge' })) + await user.click( + screen.getByRole('button', { + name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge', + }), + ) await user.click(screen.getByText('select-dataset')) expect(onChange).toHaveBeenCalledWith([ @@ -404,21 +441,11 @@ describe('knowledge-retrieval path', () => { it('should render empty and populated dataset lists', () => { const onChange = vi.fn() - const { rerender } = render( - <DatasetList - list={[]} - onChange={onChange} - />, - ) + const { rerender } = render(<DatasetList list={[]} onChange={onChange} />) expect(screen.getByText('appDebug.datasetConfig.knowledgeTip')).toBeInTheDocument() - rerender( - <DatasetList - list={[createDataset()]} - onChange={onChange} - />, - ) + rerender(<DatasetList list={[createDataset()]} onChange={onChange} />) const datasetItem = getDatasetItem() fireEvent.click(within(datasetItem).getByRole('button', { name: 'common.operation.remove' })) @@ -446,20 +473,20 @@ describe('knowledge-retrieval path', () => { canAccessConfig: false, }) - render( - <DatasetList - list={[dataset]} - onChange={vi.fn()} - />, - ) + render(<DatasetList list={[dataset]} onChange={vi.fn()} />) const datasetItem = getDatasetItem() - expect(within(datasetItem).queryByRole('button', { name: 'common.operation.edit' })).not.toBeInTheDocument() - expect(getDatasetACLCapabilities).toHaveBeenCalledWith(dataset.permission_keys, expect.objectContaining({ - currentUserId: 'user-1', - resourceMaintainer: dataset.maintainer, - })) + expect( + within(datasetItem).queryByRole('button', { name: 'common.operation.edit' }), + ).not.toBeInTheDocument() + expect(getDatasetACLCapabilities).toHaveBeenCalledWith( + dataset.permission_keys, + expect.objectContaining({ + currentUserId: 'user-1', + resourceMaintainer: dataset.maintainer, + }), + ) }) }) @@ -489,15 +516,17 @@ describe('knowledge-retrieval path', () => { await user.click(screen.getByText('apply-retrieval-config')) await user.click(screen.getByText('change-retrieval-mode')) - expect(onMultipleRetrievalConfigChange).toHaveBeenCalledWith(expect.objectContaining({ - top_k: 8, - score_threshold: 0.4, - reranking_model: { - provider: 'cohere', - model: 'rerank-v3', - }, - reranking_enable: true, - })) + expect(onMultipleRetrievalConfigChange).toHaveBeenCalledWith( + expect.objectContaining({ + top_k: 8, + score_threshold: 0.4, + reranking_model: { + provider: 'cohere', + model: 'rerank-v3', + }, + reranking_enable: true, + }), + ) expect(onRetrievalModeChange).toHaveBeenCalledWith(RETRIEVE_TYPE.oneWay) }) }) @@ -508,14 +537,17 @@ describe('knowledge-retrieval path', () => { const onSelect = vi.fn() render( - <MetadataFilterSelector - value={MetadataFilteringModeEnum.disabled} - onSelect={onSelect} - />, + <MetadataFilterSelector value={MetadataFilteringModeEnum.disabled} onSelect={onSelect} />, ) - await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.options.disabled.title/i })) - await user.click(screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.options.manual.title')) + await user.click( + screen.getByRole('button', { + name: /workflow.nodes.knowledgeRetrieval.metadata.options.disabled.title/i, + }), + ) + await user.click( + screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.options.manual.title'), + ) expect(onSelect.mock.calls[0]?.[0]).toBe(MetadataFilteringModeEnum.manual) }) @@ -548,7 +580,11 @@ describe('knowledge-retrieval path', () => { expect(handleRemoveCondition).toHaveBeenCalledWith('condition-stale') - await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.panel.conditions/i })) + await user.click( + screen.getByRole('button', { + name: /workflow.nodes.knowledgeRetrieval.metadata.panel.conditions/i, + }), + ) expect(screen.getByText('metadata-panel')).toBeInTheDocument() }) @@ -556,8 +592,16 @@ describe('knowledge-retrieval path', () => { it('should call handleAddCondition with the correct metadata item when clicking any part of the row', async () => { const user = userEvent.setup() const handleAddCondition = vi.fn() - const permissionMetadata = createMetadata({ id: 'meta-perm', name: 'permission', type: MetadataFilteringVariableType.string }) - const topicMetadata = createMetadata({ id: 'meta-topic', name: 'topic', type: MetadataFilteringVariableType.string }) + const permissionMetadata = createMetadata({ + id: 'meta-perm', + name: 'permission', + type: MetadataFilteringVariableType.string, + }) + const topicMetadata = createMetadata({ + id: 'meta-topic', + name: 'topic', + type: MetadataFilteringVariableType.string, + }) render( <AddCondition @@ -566,14 +610,20 @@ describe('knowledge-retrieval path', () => { />, ) - await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.panel.add/i })) + await user.click( + screen.getByRole('button', { + name: /workflow.nodes.knowledgeRetrieval.metadata.panel.add/i, + }), + ) await user.click(screen.getAllByText('string', { selector: 'div.shrink-0' })[0]!) expect(handleAddCondition).toHaveBeenCalledTimes(1) - expect(handleAddCondition).toHaveBeenCalledWith(expect.objectContaining({ - id: 'meta-perm', - name: 'permission', - })) + expect(handleAddCondition).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'meta-perm', + name: 'permission', + }), + ) }) it('should render automatic and manual metadata filter states', async () => { @@ -599,7 +649,11 @@ describe('knowledge-retrieval path', () => { />, ) - expect(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.options.automatic.title/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: /workflow.nodes.knowledgeRetrieval.metadata.options.automatic.title/i, + }), + ).toBeInTheDocument() rerender( <MetadataFilter @@ -609,7 +663,11 @@ describe('knowledge-retrieval path', () => { />, ) - await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.panel.conditions/i })) + await user.click( + screen.getByRole('button', { + name: /workflow.nodes.knowledgeRetrieval.metadata.panel.conditions/i, + }), + ) expect(screen.getByText('metadata-panel')).toBeInTheDocument() }) @@ -621,10 +679,7 @@ describe('knowledge-retrieval path', () => { const onValueMethodChange = vi.fn() render( - <ConditionValueMethod - valueMethod="variable" - onValueMethodChange={onValueMethodChange} - />, + <ConditionValueMethod valueMethod="variable" onValueMethodChange={onValueMethodChange} />, ) await user.click(screen.getByRole('button', { name: /variable/i })) @@ -642,10 +697,7 @@ describe('knowledge-retrieval path', () => { const onCommonVariableChange = vi.fn() const { rerender } = render( - <ConditionVariableSelector - onChange={onVariableChange} - varType={VarType.string} - />, + <ConditionVariableSelector onChange={onVariableChange} varType={VarType.string} />, ) await user.click(screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.panel.select')) @@ -683,10 +735,7 @@ describe('knowledge-retrieval path', () => { value={MetadataComparisonOperator.contains} onSelect={onSelect} /> - <ConditionDate - value={1710000000} - onChange={onDateChange} - /> + <ConditionDate value={1710000000} onChange={onDateChange} /> <ConditionItem metadataList={[createMetadata()]} condition={createCondition()} @@ -705,7 +754,10 @@ describe('knowledge-retrieval path', () => { expect(onSelect).toHaveBeenCalledWith(MetadataComparisonOperator.is as ComparisonOperator) expect(onDateChange).toHaveBeenCalledWith() - expect(onUpdateCondition).toHaveBeenCalledWith('condition-1', expect.objectContaining({ value: 'updated-agent' })) + expect(onUpdateCondition).toHaveBeenCalledWith( + 'condition-1', + expect.objectContaining({ value: 'updated-agent' }), + ) expect(onRemoveCondition).toHaveBeenCalledWith('condition-1') }) @@ -733,22 +785,23 @@ describe('knowledge-retrieval path', () => { const store = createDatasetsDetailStore() store.getState().updateDatasetsDetail([createDataset()]) - const renderNode = (datasetIds: string[]) => render( - <DatasetsDetailContext.Provider value={store}> - <Node - id="knowledge-node" - data={{ - type: BlockEnum.KnowledgeRetrieval, - title: 'Knowledge Retrieval', - desc: '', - dataset_ids: datasetIds, - query_variable_selector: [], - query_attachment_selector: [], - retrieval_mode: RETRIEVE_TYPE.multiWay, - }} - /> - </DatasetsDetailContext.Provider>, - ) + const renderNode = (datasetIds: string[]) => + render( + <DatasetsDetailContext.Provider value={store}> + <Node + id="knowledge-node" + data={{ + type: BlockEnum.KnowledgeRetrieval, + title: 'Knowledge Retrieval', + desc: '', + dataset_ids: datasetIds, + query_variable_selector: [], + query_attachment_selector: [], + retrieval_mode: RETRIEVE_TYPE.multiWay, + }} + /> + </DatasetsDetailContext.Provider>, + ) const { rerender, container } = renderNode(['dataset-1']) diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/node.spec.tsx index 2bff3e0a606bfa..1d26fa990d5295 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/node.spec.tsx @@ -76,7 +76,9 @@ const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ ...overrides, }) -const createData = (overrides: Partial<KnowledgeRetrievalNodeType> = {}): KnowledgeRetrievalNodeType => ({ +const createData = ( + overrides: Partial<KnowledgeRetrievalNodeType> = {}, +): KnowledgeRetrievalNodeType => ({ title: 'Knowledge Retrieval', desc: '', type: BlockEnum.KnowledgeRetrieval, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/panel.spec.tsx index 36b9a809c4dc6e..09566126f1590c 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/panel.spec.tsx @@ -8,7 +8,12 @@ import { BlockEnum, VarType } from '@/app/components/workflow/types' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' import Panel from '../panel' -import { ComparisonOperator, LogicalOperator, MetadataFilteringModeEnum, MetadataFilteringVariableType } from '../types' +import { + ComparisonOperator, + LogicalOperator, + MetadataFilteringModeEnum, + MetadataFilteringVariableType, +} from '../types' import useConfig from '../use-config' const mockVarReferencePicker = vi.hoisted(() => vi.fn()) @@ -112,17 +117,46 @@ vi.mock('../components/retrieval-config', () => ({ __esModule: true, default: (props: { onRetrievalModeChange: (value: RETRIEVE_TYPE) => void - onMultipleRetrievalConfigChange: (value: KnowledgeRetrievalNodeType['multiple_retrieval_config']) => void - onSingleRetrievalModelChange: (model: { provider: string, modelId: string, mode?: string }) => void + onMultipleRetrievalConfigChange: ( + value: KnowledgeRetrievalNodeType['multiple_retrieval_config'], + ) => void + onSingleRetrievalModelChange: (model: { + provider: string + modelId: string + mode?: string + }) => void onSingleRetrievalModelParamsChange: (params: Record<string, unknown>) => void }) => { mockRetrievalConfig(props) return ( <div> - <button type="button" onClick={() => props.onRetrievalModeChange(RETRIEVE_TYPE.oneWay)}>change-retrieval-mode</button> - <button type="button" onClick={() => props.onMultipleRetrievalConfigChange({ top_k: 8, score_threshold: 0.4 })}>change-multiple-config</button> - <button type="button" onClick={() => props.onSingleRetrievalModelChange({ provider: 'openai', modelId: 'gpt-4o-mini', mode: 'chat' })}>change-model</button> - <button type="button" onClick={() => props.onSingleRetrievalModelParamsChange({ temperature: 0.2 })}>change-model-params</button> + <button type="button" onClick={() => props.onRetrievalModeChange(RETRIEVE_TYPE.oneWay)}> + change-retrieval-mode + </button> + <button + type="button" + onClick={() => props.onMultipleRetrievalConfigChange({ top_k: 8, score_threshold: 0.4 })} + > + change-multiple-config + </button> + <button + type="button" + onClick={() => + props.onSingleRetrievalModelChange({ + provider: 'openai', + modelId: 'gpt-4o-mini', + mode: 'chat', + }) + } + > + change-model + </button> + <button + type="button" + onClick={() => props.onSingleRetrievalModelParamsChange({ temperature: 0.2 })} + > + change-model-params + </button> </div> ) }, @@ -130,12 +164,14 @@ vi.mock('../components/retrieval-config', () => ({ vi.mock('../components/dataset-list', () => ({ __esModule: true, - default: (props: { list: DataSet[], onChange: (datasets: DataSet[]) => void }) => { + default: (props: { list: DataSet[]; onChange: (datasets: DataSet[]) => void }) => { mockDatasetList(props) return ( <button type="button" - onClick={() => props.onChange([createDataset({ id: 'dataset-2', name: 'Updated Dataset' })])} + onClick={() => + props.onChange([createDataset({ id: 'dataset-2', name: 'Updated Dataset' })]) + } > dataset-list </button> @@ -145,7 +181,7 @@ vi.mock('../components/dataset-list', () => ({ vi.mock('../components/add-dataset', () => ({ __esModule: true, - default: (props: { selectedIds: string[], onChange: (datasets: DataSet[]) => void }) => { + default: (props: { selectedIds: string[]; onChange: (datasets: DataSet[]) => void }) => { mockAddKnowledge(props) return ( <button @@ -167,31 +203,60 @@ vi.mock('../components/metadata/metadata-filter', () => ({ handleRemoveCondition: (id: string) => void handleToggleConditionLogicalOperator: () => void handleUpdateCondition: (id: string, condition: unknown) => void - handleMetadataModelChange: (model: { provider: string, modelId: string, mode?: string }) => void + handleMetadataModelChange: (model: { provider: string; modelId: string; mode?: string }) => void handleMetadataCompletionParamsChange: (params: Record<string, unknown>) => void }) => { mockMetadataFilter(props) return ( <div> - <div>{props.metadataList.map(item => item.name).join(',')}</div> - <button type="button" onClick={() => props.handleAddCondition(createMetadata())}>add-condition</button> - <button type="button" onClick={() => props.handleMetadataFilterModeChange(MetadataFilteringModeEnum.manual)}>change-filter-mode</button> - <button type="button" onClick={() => props.handleRemoveCondition('condition-1')}>remove-condition</button> - <button type="button" onClick={() => props.handleToggleConditionLogicalOperator()}>toggle-logical-operator</button> + <div>{props.metadataList.map((item) => item.name).join(',')}</div> + <button type="button" onClick={() => props.handleAddCondition(createMetadata())}> + add-condition + </button> + <button + type="button" + onClick={() => props.handleMetadataFilterModeChange(MetadataFilteringModeEnum.manual)} + > + change-filter-mode + </button> + <button type="button" onClick={() => props.handleRemoveCondition('condition-1')}> + remove-condition + </button> + <button type="button" onClick={() => props.handleToggleConditionLogicalOperator()}> + toggle-logical-operator + </button> <button type="button" - onClick={() => props.handleUpdateCondition('condition-1', { - id: 'condition-1', - name: 'topic', - metadata_id: 'meta-1', - comparison_operator: ComparisonOperator.is, - value: 'agent', - })} + onClick={() => + props.handleUpdateCondition('condition-1', { + id: 'condition-1', + name: 'topic', + metadata_id: 'meta-1', + comparison_operator: ComparisonOperator.is, + value: 'agent', + }) + } > update-condition </button> - <button type="button" onClick={() => props.handleMetadataModelChange({ provider: 'openai', modelId: 'gpt-4.1-mini', mode: 'chat' })}>change-metadata-model</button> - <button type="button" onClick={() => props.handleMetadataCompletionParamsChange({ temperature: 0.3 })}>change-metadata-params</button> + <button + type="button" + onClick={() => + props.handleMetadataModelChange({ + provider: 'openai', + modelId: 'gpt-4.1-mini', + mode: 'chat', + }) + } + > + change-metadata-model + </button> + <button + type="button" + onClick={() => props.handleMetadataCompletionParamsChange({ temperature: 0.3 })} + > + change-metadata-params + </button> </div> ) }, @@ -200,7 +265,7 @@ vi.mock('../components/metadata/metadata-filter', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <div>{children}</div>, - VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>, + VarItem: ({ name, type }: { name: string; type: string }) => <div>{`${name}:${type}`}</div>, })) vi.mock('../use-config', () => ({ @@ -210,7 +275,9 @@ vi.mock('../use-config', () => ({ const mockUseConfig = vi.mocked(useConfig) -const createData = (overrides: Partial<KnowledgeRetrievalNodeType> = {}): KnowledgeRetrievalNodeType => ({ +const createData = ( + overrides: Partial<KnowledgeRetrievalNodeType> = {}, +): KnowledgeRetrievalNodeType => ({ title: 'Knowledge Retrieval', desc: '', type: BlockEnum.KnowledgeRetrieval, @@ -230,13 +297,15 @@ const createData = (overrides: Partial<KnowledgeRetrievalNodeType> = {}): Knowle metadata_filtering_mode: MetadataFilteringModeEnum.disabled, metadata_filtering_conditions: { logical_operator: LogicalOperator.and, - conditions: [{ - id: 'condition-1', - name: 'topic', - metadata_id: 'meta-1', - comparison_operator: ComparisonOperator.contains, - value: 'agent', - }], + conditions: [ + { + id: 'condition-1', + name: 'topic', + metadata_id: 'meta-1', + comparison_operator: ComparisonOperator.contains, + value: 'agent', + }, + ], }, metadata_model_config: { provider: 'openai', @@ -278,8 +347,16 @@ describe('knowledge-retrieval/panel', () => { handleRetrievalModeChange, handleMultipleRetrievalConfigChange, selectedDatasets: [ - createDataset({ doc_metadata: [createMetadata(), createMetadata({ id: 'meta-2', name: 'shared' })] }), - createDataset({ id: 'dataset-2', doc_metadata: [createMetadata({ id: 'meta-3', name: 'shared' }), createMetadata({ id: 'meta-4', name: 'language' })] }), + createDataset({ + doc_metadata: [createMetadata(), createMetadata({ id: 'meta-2', name: 'shared' })], + }), + createDataset({ + id: 'dataset-2', + doc_metadata: [ + createMetadata({ id: 'meta-3', name: 'shared' }), + createMetadata({ id: 'meta-4', name: 'language' }), + ], + }), ], selectedDatasetsLoaded: true, handleOnDatasetsChange, @@ -308,13 +385,7 @@ describe('knowledge-retrieval/panel', () => { it('wires panel actions and passes the intersected metadata list to metadata filters', async () => { const user = userEvent.setup() - render( - <Panel - id="knowledge-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<Panel id="knowledge-node" data={createData()} panelProps={panelProps} />) expect(screen.getByText('result:Array[Object]')).toBeInTheDocument() expect(screen.getByText('shared')).toBeInTheDocument() @@ -338,37 +409,53 @@ describe('knowledge-retrieval/panel', () => { expect(handleQueryVarChange).toHaveBeenCalledWith(['node-2', 'query']) expect(handleQueryAttachmentChange).toHaveBeenCalledWith(['node-2', 'query']) expect(handleRetrievalModeChange).toHaveBeenCalledWith(RETRIEVE_TYPE.oneWay) - expect(handleMultipleRetrievalConfigChange).toHaveBeenCalledWith({ top_k: 8, score_threshold: 0.4 }) - expect(handleModelChanged).toHaveBeenCalledWith({ provider: 'openai', modelId: 'gpt-4o-mini', mode: 'chat' }) + expect(handleMultipleRetrievalConfigChange).toHaveBeenCalledWith({ + top_k: 8, + score_threshold: 0.4, + }) + expect(handleModelChanged).toHaveBeenCalledWith({ + provider: 'openai', + modelId: 'gpt-4o-mini', + mode: 'chat', + }) expect(handleCompletionParamsChange).toHaveBeenCalledWith({ temperature: 0.2 }) - expect(handleOnDatasetsChange).toHaveBeenCalledWith([expect.objectContaining({ id: 'dataset-2' })]) - expect(handleOnDatasetsChange).toHaveBeenCalledWith([expect.objectContaining({ id: 'dataset-3' })]) + expect(handleOnDatasetsChange).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'dataset-2' }), + ]) + expect(handleOnDatasetsChange).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'dataset-3' }), + ]) expect(handleAddCondition).toHaveBeenCalledWith(expect.objectContaining({ id: 'meta-1' })) expect(handleMetadataFilterModeChange).toHaveBeenCalledWith(MetadataFilteringModeEnum.manual) expect(handleRemoveCondition).toHaveBeenCalledWith('condition-1') expect(handleToggleConditionLogicalOperator).toHaveBeenCalledTimes(1) - expect(handleUpdateCondition).toHaveBeenCalledWith('condition-1', expect.objectContaining({ comparison_operator: ComparisonOperator.is })) - expect(handleMetadataModelChange).toHaveBeenCalledWith({ provider: 'openai', modelId: 'gpt-4.1-mini', mode: 'chat' }) + expect(handleUpdateCondition).toHaveBeenCalledWith( + 'condition-1', + expect.objectContaining({ comparison_operator: ComparisonOperator.is }), + ) + expect(handleMetadataModelChange).toHaveBeenCalledWith({ + provider: 'openai', + modelId: 'gpt-4.1-mini', + mode: 'chat', + }) expect(handleMetadataCompletionParamsChange).toHaveBeenCalledWith({ temperature: 0.3 }) - expect(mockMetadataFilter).toHaveBeenCalledWith(expect.objectContaining({ - metadataList: [expect.objectContaining({ name: 'shared' })], - })) + expect(mockMetadataFilter).toHaveBeenCalledWith( + expect.objectContaining({ + metadataList: [expect.objectContaining({ name: 'shared' })], + }), + ) }) it('hides readonly-only controls and the attachment selector when image queries are unavailable', () => { - mockUseConfig.mockReturnValueOnce(createConfigResult({ - readOnly: true, - showImageQueryVarSelector: false, - }) as ReturnType<typeof useConfig>) - - render( - <Panel - id="knowledge-node" - data={createData()} - panelProps={panelProps} - />, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + readOnly: true, + showImageQueryVarSelector: false, + }) as ReturnType<typeof useConfig>, ) + render(<Panel id="knowledge-node" data={createData()} panelProps={panelProps} />) + expect(screen.getAllByRole('button', { name: 'var-reference-picker' })).toHaveLength(1) expect(screen.queryByRole('button', { name: 'add-dataset' })).not.toBeInTheDocument() }) diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts index 292b7b57fd2bf6..851afde5731578 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts @@ -3,13 +3,12 @@ import type { DataSet, MetadataInDoc } from '@/models/datasets' import { act, renderHook, waitFor } from '@testing-library/react' import { isEqual } from 'es-toolkit/predicate' import { useState } from 'react' -import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' -import { useDatasetsDetailStore } from '@/app/components/workflow/datasets-detail-store/store' import { - useIsChatMode, - useNodesReadOnly, - useWorkflow, -} from '@/app/components/workflow/hooks' + useCurrentProviderAndModel, + useModelListAndDefaultModelAndCurrentProviderAndModel, +} from '@/app/components/header/account-setting/model-provider-page/hooks' +import { useDatasetsDetailStore } from '@/app/components/workflow/datasets-detail-store/store' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { BlockEnum, VarType } from '@/app/components/workflow/types' @@ -17,7 +16,12 @@ import { DATASET_DEFAULT } from '@/config' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { fetchDatasets } from '@/service/datasets' import { AppModeEnum, RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' -import { ComparisonOperator, LogicalOperator, MetadataFilteringModeEnum, MetadataFilteringVariableType } from '../types' +import { + ComparisonOperator, + LogicalOperator, + MetadataFilteringModeEnum, + MetadataFilteringVariableType, +} from '../types' import useConfig from '../use-config' let uuidCounter = 0 @@ -61,7 +65,9 @@ vi.mock('@/service/datasets', () => ({ const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) const mockUseIsChatMode = vi.mocked(useIsChatMode) const mockUseWorkflow = vi.mocked(useWorkflow) -const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = vi.mocked(useModelListAndDefaultModelAndCurrentProviderAndModel) +const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = vi.mocked( + useModelListAndDefaultModelAndCurrentProviderAndModel, +) const mockUseCurrentProviderAndModel = vi.mocked(useCurrentProviderAndModel) const mockUseNodeCrud = vi.mocked(useNodeCrud) const mockUseAvailableVarList = vi.mocked(useAvailableVarList) @@ -143,7 +149,9 @@ const createMetadata = (overrides: Partial<MetadataInDoc> = {}): MetadataInDoc = ...overrides, }) -const createData = (overrides: Partial<KnowledgeRetrievalNodeType> = {}): KnowledgeRetrievalNodeType => ({ +const createData = ( + overrides: Partial<KnowledgeRetrievalNodeType> = {}, +): KnowledgeRetrievalNodeType => ({ title: 'Knowledge Retrieval', desc: '', type: BlockEnum.KnowledgeRetrieval, @@ -177,22 +185,28 @@ describe('knowledge-retrieval/use-config', () => { mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false }) mockUseIsChatMode.mockReturnValue(true) mockUseWorkflow.mockReturnValue({ - getBeforeNodesInSameBranch: () => [{ - id: 'start-node', - data: { - type: BlockEnum.Start, + getBeforeNodesInSameBranch: () => [ + { + id: 'start-node', + data: { + type: BlockEnum.Start, + }, }, - }], + ], } as unknown as ReturnType<typeof useWorkflow>) mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockImplementation((type) => { if (type === 'rerank') { return { - modelList: [{ - provider: 'rerank-provider', - models: [{ - model: 'rerank-model', - }], - }], + modelList: [ + { + provider: 'rerank-provider', + models: [ + { + model: 'rerank-model', + }, + ], + }, + ], defaultModel: { provider: { provider: 'rerank-provider', @@ -232,28 +246,36 @@ describe('knowledge-retrieval/use-config', () => { inputs, setInputs: (nextInputs) => { nodeCrudSetInputs(nextInputs as KnowledgeRetrievalNodeType) - setInputs(prev => isEqual(prev, nextInputs) ? prev : nextInputs) + setInputs((prev) => (isEqual(prev, nextInputs) ? prev : nextInputs)) }, } }) mockUseAvailableVarList.mockImplementation((_id, config) => { const activeConfig = config! - const stringVars = [{ - nodeId: 'string-node', - title: 'String Node', - vars: [{ - variable: 'topic', - type: VarType.string, - }], - }] - const numberVars = [{ - nodeId: 'number-node', - title: 'Number Node', - vars: [{ - variable: 'score', - type: VarType.number, - }], - }] + const stringVars = [ + { + nodeId: 'string-node', + title: 'String Node', + vars: [ + { + variable: 'topic', + type: VarType.string, + }, + ], + }, + ] + const numberVars = [ + { + nodeId: 'number-node', + title: 'Number Node', + vars: [ + { + variable: 'score', + type: VarType.number, + }, + ], + }, + ] if (activeConfig.filterVar({ type: VarType.string } as never, ['string-node', 'topic'])) { return { @@ -297,27 +319,37 @@ describe('knowledge-retrieval/use-config', () => { }, }) expect(result.current.inputs.query_variable_selector).toEqual(['start-node', 'sys.query']) - expect(result.current.inputs.multiple_retrieval_config).toEqual(expect.objectContaining({ - top_k: DATASET_DEFAULT.top_k, - reranking_enable: true, - })) + expect(result.current.inputs.multiple_retrieval_config).toEqual( + expect.objectContaining({ + top_k: DATASET_DEFAULT.top_k, + reranking_enable: true, + }), + ) expect(result.current.showImageQueryVarSelector).toBe(true) - expect(result.current.availableStringVars).toEqual([{ - nodeId: 'string-node', - title: 'String Node', - vars: [{ - variable: 'topic', - type: VarType.string, - }], - }]) - expect(result.current.availableNumberVars).toEqual([{ - nodeId: 'number-node', - title: 'Number Node', - vars: [{ - variable: 'score', - type: VarType.number, - }], - }]) + expect(result.current.availableStringVars).toEqual([ + { + nodeId: 'string-node', + title: 'String Node', + vars: [ + { + variable: 'topic', + type: VarType.string, + }, + ], + }, + ]) + expect(result.current.availableNumberVars).toEqual([ + { + nodeId: 'number-node', + title: 'Number Node', + vars: [ + { + variable: 'score', + type: VarType.number, + }, + ], + }, + ]) }) it('updates query and single retrieval model state through the real hook', async () => { @@ -331,27 +363,37 @@ describe('knowledge-retrieval/use-config', () => { result.current.handleQueryVarChange(['start-node', 'question']) result.current.handleQueryAttachmentChange(['start-node', 'files']) result.current.handleRetrievalModeChange(RETRIEVE_TYPE.oneWay) - result.current.handleModelChanged({ provider: 'anthropic', modelId: 'claude-sonnet', mode: AppModeEnum.CHAT }) + result.current.handleModelChanged({ + provider: 'anthropic', + modelId: 'claude-sonnet', + mode: AppModeEnum.CHAT, + }) result.current.handleCompletionParamsChange({ temperature: 0.2 }) result.current.handleCompletionParamsChange({ temperature: 0.2 }) }) - expect(nodeCrudSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - query_variable_selector: ['start-node', 'question'], - })) - expect(nodeCrudSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - query_attachment_selector: ['start-node', 'files'], - })) + expect(nodeCrudSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + query_variable_selector: ['start-node', 'question'], + }), + ) + expect(nodeCrudSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + query_attachment_selector: ['start-node', 'files'], + }), + ) await waitFor(() => { expect(result.current.inputs.retrieval_mode).toBe(RETRIEVE_TYPE.oneWay) - expect(result.current.inputs.single_retrieval_config).toEqual(expect.objectContaining({ - model: expect.objectContaining({ - provider: 'anthropic', - name: 'claude-sonnet', - completion_params: { temperature: 0.2 }, + expect(result.current.inputs.single_retrieval_config).toEqual( + expect.objectContaining({ + model: expect.objectContaining({ + provider: 'anthropic', + name: 'claude-sonnet', + completion_params: { temperature: 0.2 }, + }), }), - })) + ) }) }) @@ -367,29 +409,43 @@ describe('knowledge-retrieval/use-config', () => { result.current.handleRetrievalModeChange(RETRIEVE_TYPE.multiWay) result.current.handleMultipleRetrievalConfigChange({ top_k: 8, score_threshold: 0.4 }) result.current.handleOnDatasetsChange([ - createDataset({ id: 'dataset-2', name: 'Economic', indexing_technique: 'economy' as DataSet['indexing_technique'], is_multimodal: false }), - createDataset({ id: 'dataset-3', name: 'High Quality', indexing_technique: 'high_quality' as DataSet['indexing_technique'], is_multimodal: false }), + createDataset({ + id: 'dataset-2', + name: 'Economic', + indexing_technique: 'economy' as DataSet['indexing_technique'], + is_multimodal: false, + }), + createDataset({ + id: 'dataset-3', + name: 'High Quality', + indexing_technique: 'high_quality' as DataSet['indexing_technique'], + is_multimodal: false, + }), ]) }) - expect(nodeCrudSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - multiple_retrieval_config: expect.objectContaining({ - top_k: 8, - score_threshold: 0.4, + expect(nodeCrudSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + multiple_retrieval_config: expect.objectContaining({ + top_k: 8, + score_threshold: 0.4, + }), }), - })) + ) await waitFor(() => { expect(result.current.inputs.retrieval_mode).toBe(RETRIEVE_TYPE.multiWay) expect(result.current.inputs.query_attachment_selector).toEqual([]) expect(result.current.inputs.dataset_ids).toEqual(['dataset-2', 'dataset-3']) - expect(result.current.inputs.multiple_retrieval_config).toEqual(expect.objectContaining({ - reranking_enable: true, - reranking_model: { - provider: 'rerank-provider', - model: 'rerank-model', - }, - })) + expect(result.current.inputs.multiple_retrieval_config).toEqual( + expect.objectContaining({ + reranking_enable: true, + reranking_model: { + provider: 'rerank-provider', + model: 'rerank-model', + }, + }), + ) expect(result.current.rerankModelOpen).toBe(true) }) @@ -400,9 +456,14 @@ describe('knowledge-retrieval/use-config', () => { }) it('manages metadata conditions, metadata model config, and variable filters', async () => { - const { result } = renderHook(() => useConfig('knowledge-node', createData({ - dataset_ids: [], - }))) + const { result } = renderHook(() => + useConfig( + 'knowledge-node', + createData({ + dataset_ids: [], + }), + ), + ) await waitFor(() => { expect(result.current.selectedDatasetsLoaded).toBe(true) @@ -411,11 +472,13 @@ describe('knowledge-retrieval/use-config', () => { act(() => { result.current.handleMetadataFilterModeChange(MetadataFilteringModeEnum.manual) result.current.handleAddCondition(createMetadata()) - result.current.handleAddCondition(createMetadata({ - id: 'meta-2', - name: 'score', - type: MetadataFilteringVariableType.number, - })) + result.current.handleAddCondition( + createMetadata({ + id: 'meta-2', + name: 'score', + type: MetadataFilteringVariableType.number, + }), + ) }) await waitFor(() => { @@ -433,12 +496,18 @@ describe('knowledge-retrieval/use-config', () => { }) result.current.handleToggleConditionLogicalOperator() result.current.handleRemoveCondition(firstCondition!.id) - result.current.handleMetadataModelChange({ provider: 'openai', modelId: 'gpt-4.1-mini', mode: AppModeEnum.CHAT }) + result.current.handleMetadataModelChange({ + provider: 'openai', + modelId: 'gpt-4.1-mini', + mode: AppModeEnum.CHAT, + }) result.current.handleMetadataCompletionParamsChange({ top_p: 0.3 }) }) await waitFor(() => { - expect(result.current.inputs.metadata_filtering_conditions?.logical_operator).toBe(LogicalOperator.or) + expect(result.current.inputs.metadata_filtering_conditions?.logical_operator).toBe( + LogicalOperator.or, + ) expect(result.current.inputs.metadata_filtering_conditions?.conditions).toHaveLength(1) expect(result.current.inputs.metadata_model_config).toEqual({ provider: 'openai', diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset.tsx index 7a6d6f435f0dcf..12ff51482e792e 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset.tsx @@ -13,26 +13,22 @@ type Props = Readonly<{ onChange: (dataSets: DataSet[]) => void }> -const AddDataset: FC<Props> = ({ - selectedIds, - modal, - onChange, -}) => { +const AddDataset: FC<Props> = ({ selectedIds, modal, onChange }) => { const { t } = useTranslation() - const [isShowModal, { - setTrue: showModal, - setFalse: hideModal, - }] = useBoolean(false) + const [isShowModal, { setTrue: showModal, setFalse: hideModal }] = useBoolean(false) - const handleSelect = useCallback((datasets: DataSet[]) => { - onChange(datasets) - hideModal() - }, [onChange, hideModal]) + const handleSelect = useCallback( + (datasets: DataSet[]) => { + onChange(datasets) + hideModal() + }, + [onChange, hideModal], + ) return ( <div> <button type="button" - aria-label={`${t($ => $['operation.add'], { ns: 'common' })} ${t($ => $['nodes.knowledgeRetrieval.knowledge'], { ns: 'workflow' })}`} + aria-label={`${t(($) => $['operation.add'], { ns: 'common' })} ${t(($) => $['nodes.knowledgeRetrieval.knowledge'], { ns: 'workflow' })}`} className="cursor-pointer rounded-md border-none bg-transparent p-1 outline-hidden select-none hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" onClick={showModal} > diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx index af87af17f8fbd1..f487ca5d96c374 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx @@ -10,10 +10,7 @@ import { DrawerPortal, DrawerViewport, } from '@langgenius/dify-ui/drawer' -import { - RiDeleteBinLine, - RiEditLine, -} from '@remixicon/react' +import { RiDeleteBinLine, RiEditLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useState } from 'react' @@ -56,20 +53,24 @@ const DatasetItem: FC<Props> = ({ const { formatIndexingTechniqueAndMethod } = useKnowledge() const [isDeleteHovered, setIsDeleteHovered] = useState(false) - const [isShowSettingsModal, { - setTrue: showSettingsModal, - setFalse: hideSettingsModal, - }] = useBoolean(false) + const [isShowSettingsModal, { setTrue: showSettingsModal, setFalse: hideSettingsModal }] = + useBoolean(false) - const handleSave = useCallback((newDataset: DataSet) => { - onChange(newDataset) - hideSettingsModal() - }, [hideSettingsModal, onChange]) + const handleSave = useCallback( + (newDataset: DataSet) => { + onChange(newDataset) + hideSettingsModal() + }, + [hideSettingsModal, onChange], + ) - const handleRemove = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - onRemove() - }, [onRemove]) + const handleRemove = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onRemove() + }, + [onRemove], + ) const iconInfo = payload.icon_info || { icon: '📙', @@ -79,12 +80,12 @@ const DatasetItem: FC<Props> = ({ } return ( - <div className={`group/dataset-item flex h-10 cursor-pointer items-center justify-between rounded-lg - border-[0.5px] border-components-panel-border-subtle px-2 - ${isDeleteHovered - ? 'border-state-destructive-border bg-state-destructive-hover' - : 'bg-components-panel-on-panel-item-bg hover:bg-components-panel-on-panel-item-bg-hover' - }`} + <div + className={`group/dataset-item flex h-10 cursor-pointer items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle px-2 ${ + isDeleteHovered + ? 'border-state-destructive-border bg-state-destructive-hover' + : 'bg-components-panel-on-panel-item-bg hover:bg-components-panel-on-panel-item-bg-hover' + }`} > <div className="flex w-0 grow items-center space-x-1.5"> <AppIcon @@ -98,27 +99,27 @@ const DatasetItem: FC<Props> = ({ </div> {!readonly && ( <div className="ml-2 hidden shrink-0 items-center space-x-1 group-hover/dataset-item:flex"> - { - editable && ( - <ActionButton - aria-label={t($ => $['operation.edit'], { ns: 'common' })} - onClick={(e) => { - e.stopPropagation() - showSettingsModal() - }} - > - <RiEditLine className="size-4 shrink-0 text-text-tertiary" /> - </ActionButton> - ) - } + {editable && ( + <ActionButton + aria-label={t(($) => $['operation.edit'], { ns: 'common' })} + onClick={(e) => { + e.stopPropagation() + showSettingsModal() + }} + > + <RiEditLine className="size-4 shrink-0 text-text-tertiary" /> + </ActionButton> + )} <ActionButton - aria-label={t($ => $['operation.remove'], { ns: 'common' })} + aria-label={t(($) => $['operation.remove'], { ns: 'common' })} onClick={handleRemove} state={isDeleteHovered ? ActionButtonState.Destructive : ActionButtonState.Default} onMouseEnter={() => setIsDeleteHovered(true)} onMouseLeave={() => setIsDeleteHovered(false)} > - <RiDeleteBinLine className={`size-4 shrink-0 ${isDeleteHovered ? 'text-text-destructive' : 'text-text-tertiary'}`} /> + <RiDeleteBinLine + className={`size-4 shrink-0 ${isDeleteHovered ? 'text-text-destructive' : 'text-text-tertiary'}`} + /> </ActionButton> </div> )} @@ -127,22 +128,21 @@ const DatasetItem: FC<Props> = ({ <FeatureIcon feature={ModelFeatureEnum.vision} /> </div> )} - { - !!payload.indexing_technique && ( - <Badge - className="shrink-0 group-hover/dataset-item:hidden" - text={formatIndexingTechniqueAndMethod(payload.indexing_technique, payload.retrieval_model_dict?.search_method)} - /> - ) - } - { - payload.provider === 'external' && ( - <Badge - className="shrink-0 group-hover/dataset-item:hidden" - text={t($ => $.externalTag, { ns: 'dataset' })} - /> - ) - } + {!!payload.indexing_technique && ( + <Badge + className="shrink-0 group-hover/dataset-item:hidden" + text={formatIndexingTechniqueAndMethod( + payload.indexing_technique, + payload.retrieval_model_dict?.search_method, + )} + /> + )} + {payload.provider === 'external' && ( + <Badge + className="shrink-0 group-hover/dataset-item:hidden" + text={t(($) => $.externalTag, { ns: 'dataset' })} + /> + )} {isShowSettingsModal && ( <Drawer @@ -150,8 +150,7 @@ const DatasetItem: FC<Props> = ({ modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - hideSettingsModal() + if (!open) hideSettingsModal() }} > <DrawerPortal> @@ -163,10 +162,12 @@ const DatasetItem: FC<Props> = ({ )} /> <DrawerViewport> - <DrawerPopup className={cn( - 'p-0! data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-[640px] data-[swipe-direction=right]:rounded-xl', - settingsDrawerPopupClassName ?? 'data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:bottom-3', - )} + <DrawerPopup + className={cn( + 'p-0! data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-[640px] data-[swipe-direction=right]:rounded-xl', + settingsDrawerPopupClassName ?? + 'data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:bottom-3', + )} > <DrawerContent className="flex h-full min-h-0 flex-1 flex-col p-0 pb-0"> <SettingsModal diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-list.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-list.tsx index 23fb1f1e2d0b03..a4b5a8f5664864 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-list.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-list.tsx @@ -34,23 +34,29 @@ const DatasetList: FC<Props> = ({ const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const handleRemove = useCallback((index: number) => { - return () => { - const newList = produce(list, (draft) => { - draft.splice(index, 1) - }) - onChange(newList) - } - }, [list, onChange]) + const handleRemove = useCallback( + (index: number) => { + return () => { + const newList = produce(list, (draft) => { + draft.splice(index, 1) + }) + onChange(newList) + } + }, + [list, onChange], + ) - const handleChange = useCallback((index: number) => { - return (value: DataSet) => { - const newList = produce(list, (draft) => { - draft[index] = value - }) - onChange(newList) - } - }, [list, onChange]) + const handleChange = useCallback( + (index: number) => { + return (value: DataSet) => { + const newList = produce(list, (draft) => { + draft[index] = value + }) + onChange(newList) + } + }, + [list, onChange], + ) const formattedList = useMemo(() => { return list.map((item) => { @@ -68,29 +74,28 @@ const DatasetList: FC<Props> = ({ return ( <div className="space-y-1"> - {formattedList.length - ? formattedList.map((item, index) => { - return ( - <Item - key={index} - payload={item} - onRemove={handleRemove(index)} - onChange={handleChange(index)} - readonly={readonly} - editable={item.editable} - settingsDrawerBackdropClassName={settingsDrawerBackdropClassName} - settingsDrawerBackdropForceRender={settingsDrawerBackdropForceRender} - settingsDrawerPopupClassName={settingsDrawerPopupClassName} - settingsModalHeight={settingsModalHeight} - /> - ) - }) - : ( - <div className="cursor-default rounded-lg bg-background-section p-3 text-center text-xs text-text-tertiary select-none"> - {t($ => $['datasetConfig.knowledgeTip'], { ns: 'appDebug' })} - </div> - )} - + {formattedList.length ? ( + formattedList.map((item, index) => { + return ( + <Item + key={index} + payload={item} + onRemove={handleRemove(index)} + onChange={handleChange(index)} + readonly={readonly} + editable={item.editable} + settingsDrawerBackdropClassName={settingsDrawerBackdropClassName} + settingsDrawerBackdropForceRender={settingsDrawerBackdropForceRender} + settingsDrawerPopupClassName={settingsDrawerPopupClassName} + settingsModalHeight={settingsModalHeight} + /> + ) + }) + ) : ( + <div className="cursor-default rounded-lg bg-background-section p-3 text-center text-xs text-text-tertiary select-none"> + {t(($) => $['datasetConfig.knowledgeTip'], { ns: 'appDebug' })} + </div> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx index df823fee62311d..c0732d5fcd067d 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx @@ -1,17 +1,9 @@ import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import type { MetadataInDoc } from '@/models/datasets' import { Button } from '@langgenius/dify-ui/button' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiAddLine } from '@remixicon/react' -import { - useCallback, - useMemo, - useState, -} from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import MetadataIcon from './metadata-icon' @@ -25,26 +17,26 @@ const AddCondition = ({ const [searchText, setSearchText] = useState('') const filteredMetadataList = useMemo(() => { - return metadataList?.filter(metadata => metadata.name.includes(searchText)) + return metadataList?.filter((metadata) => metadata.name.includes(searchText)) }, [metadataList, searchText]) - const handleAddConditionWrapped = useCallback((item: MetadataInDoc) => { - handleAddCondition?.(item) - setOpen(false) - }, [handleAddCondition]) + const handleAddConditionWrapped = useCallback( + (item: MetadataInDoc) => { + handleAddCondition?.(item) + setOpen(false) + }, + [handleAddCondition], + ) return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( - <Button - size="small" - variant="secondary" - > + render={ + <Button size="small" variant="secondary"> <RiAddLine className="size-3.5" /> - {t($ => $['nodes.knowledgeRetrieval.metadata.panel.add'], { ns: 'workflow' })} + {t(($) => $['nodes.knowledgeRetrieval.metadata.panel.add'], { ns: 'workflow' })} </Button> - )} + } /> <PopoverContent placement="bottom-start" @@ -55,13 +47,15 @@ const AddCondition = ({ <div className="p-2 pb-1"> <Input showLeftIcon - placeholder={t($ => $['nodes.knowledgeRetrieval.metadata.panel.search'], { ns: 'workflow' })} + placeholder={t(($) => $['nodes.knowledgeRetrieval.metadata.panel.search'], { + ns: 'workflow', + })} value={searchText} - onChange={e => setSearchText(e.target.value)} + onChange={(e) => setSearchText(e.target.value)} /> </div> <div className="p-1"> - {filteredMetadataList?.map(metadata => ( + {filteredMetadataList?.map((metadata) => ( <div key={metadata.name} className="flex h-6 cursor-pointer items-center rounded-md px-3 system-sm-medium text-text-secondary hover:bg-state-base-hover" @@ -70,10 +64,7 @@ const AddCondition = ({ <div className="mr-1 p-px"> <MetadataIcon type={metadata.type} /> </div> - <div - className="grow truncate" - title={metadata.name} - > + <div className="grow truncate" title={metadata.name}> {metadata.name} </div> <div className="shrink-0 system-xs-regular text-text-tertiary">{metadata.type}</div> diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx index 919f7e9d505078..e656cecaf227a9 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx @@ -1,15 +1,11 @@ import type { VarType } from '@/app/components/workflow/types' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' type ConditionCommonVariableSelectorProps = { - variables?: { name: string, type: string, value: string }[] + variables?: { name: string; type: string; value: string }[] value?: string | number varType?: VarType onChange: (v: string) => void @@ -24,16 +20,19 @@ const ConditionCommonVariableSelector = ({ const { t } = useTranslation() const [open, setOpen] = useState(false) - const selected = variables.find(v => v.value === value) - const handleChange = useCallback((v: string) => { - onChange(v) - setOpen(false) - }, [onChange]) + const selected = variables.find((v) => v.value === value) + const handleChange = useCallback( + (v: string) => { + onChange(v) + setOpen(false) + }, + [onChange], + ) return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <div className="flex h-6 grow cursor-pointer items-center"> {selected && ( <div className="inline-flex h-6 items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark pr-1.5 pl-[5px] system-xs-medium text-text-secondary shadow-xs"> @@ -45,7 +44,9 @@ const ConditionCommonVariableSelector = ({ <> <div className="flex grow items-center system-sm-regular text-components-input-text-placeholder"> <Variable02 className="mr-1 size-4" /> - {t($ => $['nodes.knowledgeRetrieval.metadata.panel.select'], { ns: 'workflow' })} + {t(($) => $['nodes.knowledgeRetrieval.metadata.panel.select'], { + ns: 'workflow', + })} </div> <div className="flex h-5 shrink-0 items-center rounded-[5px] border border-divider-deep px-[5px] system-2xs-medium text-text-tertiary"> {varType} @@ -53,10 +54,9 @@ const ConditionCommonVariableSelector = ({ </> )} </div> - )} + } onClick={(e) => { - if (!variables.length) - e.preventDefault() + if (!variables.length) e.preventDefault() }} /> <PopoverContent @@ -65,7 +65,7 @@ const ConditionCommonVariableSelector = ({ popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > <div className="w-[200px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"> - {variables.map(v => ( + {variables.map((v) => ( <div key={v.value} className="flex h-6 cursor-pointer items-center rounded-md px-2 system-xs-medium text-text-secondary hover:bg-state-base-hover" diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx index eb0810d7ed391a..c95ee63fbea474 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx @@ -1,9 +1,6 @@ import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types' import { cn } from '@langgenius/dify-ui/cn' -import { - RiCalendarLine, - RiCloseCircleFill, -} from '@remixicon/react' +import { RiCalendarLine, RiCloseCircleFill } from '@remixicon/react' import { useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' import { useCallback } from 'react' @@ -15,69 +12,68 @@ type ConditionDateProps = { value?: number onChange: (date?: number) => void } -const ConditionDate = ({ - value, - onChange, -}: ConditionDateProps) => { +const ConditionDate = ({ value, onChange }: ConditionDateProps) => { const { t } = useTranslation() const { data: timezone } = useQuery({ ...userProfileQueryOptions(), - select: data => data.profile.timezone ?? undefined, + select: (data) => data.profile.timezone ?? undefined, }) - const handleDateChange = useCallback((date?: dayjs.Dayjs) => { - if (date) - onChange(date.unix()) - else - onChange() - }, [onChange]) + const handleDateChange = useCallback( + (date?: dayjs.Dayjs) => { + if (date) onChange(date.unix()) + else onChange() + }, + [onChange], + ) - const renderTrigger = useCallback(({ - handleClickTrigger, - }: TriggerProps) => { - const hasValue = Boolean(value) - const triggerText = value - ? dayjs(value * 1000).tz(timezone).format('MMMM DD YYYY HH:mm A') - : t($ => $['nodes.knowledgeRetrieval.metadata.panel.datePlaceholder'], { ns: 'workflow' }) + const renderTrigger = useCallback( + ({ handleClickTrigger }: TriggerProps) => { + const hasValue = Boolean(value) + const triggerText = value + ? dayjs(value * 1000) + .tz(timezone) + .format('MMMM DD YYYY HH:mm A') + : t(($) => $['nodes.knowledgeRetrieval.metadata.panel.datePlaceholder'], { ns: 'workflow' }) - return ( - <div className="group flex items-center"> - <button - type="button" - className={cn( - 'mr-0.5 flex h-6 grow cursor-pointer items-center border-none bg-transparent px-1 py-0 text-left system-sm-regular focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', - hasValue ? 'text-text-secondary' : 'text-text-tertiary', - )} - onClick={handleClickTrigger} - > - <span className="grow">{triggerText}</span> - <RiCalendarLine + return ( + <div className="group flex items-center"> + <button + type="button" className={cn( - 'block size-4 shrink-0', - hasValue ? 'text-text-quaternary' : 'text-text-tertiary', - hasValue && 'group-hover:hidden', + 'mr-0.5 flex h-6 grow cursor-pointer items-center border-none bg-transparent px-1 py-0 text-left system-sm-regular focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + hasValue ? 'text-text-secondary' : 'text-text-tertiary', )} - aria-hidden="true" - /> - </button> - {hasValue - ? ( - <button - type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} - className="hidden size-4 shrink-0 cursor-pointer border-none bg-transparent p-0 text-text-quaternary group-hover:block hover:text-components-input-text-filled focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={(e) => { - e.stopPropagation() - handleDateChange() - }} - > - <RiCloseCircleFill className="size-4" aria-hidden="true" /> - </button> - ) - : null} - </div> - ) - }, [value, handleDateChange, timezone, t]) + onClick={handleClickTrigger} + > + <span className="grow">{triggerText}</span> + <RiCalendarLine + className={cn( + 'block size-4 shrink-0', + hasValue ? 'text-text-quaternary' : 'text-text-tertiary', + hasValue && 'group-hover:hidden', + )} + aria-hidden="true" + /> + </button> + {hasValue ? ( + <button + type="button" + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} + className="hidden size-4 shrink-0 cursor-pointer border-none bg-transparent p-0 text-text-quaternary group-hover:block hover:text-components-input-text-filled focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={(e) => { + e.stopPropagation() + handleDateChange() + }} + > + <RiCloseCircleFill className="size-4" aria-hidden="true" /> + </button> + ) : null} + </div> + ) + }, + [value, handleDateChange, timezone, t], + ) return ( <div className="h-8 px-2 py-1"> diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx index 4cc2b548f5b902..71508da8408a91 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx @@ -6,25 +6,15 @@ import type { MetadataShape, } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import { cn } from '@langgenius/dify-ui/cn' -import { - RiDeleteBinLine, -} from '@remixicon/react' -import { - useCallback, - useMemo, - useState, -} from 'react' +import { RiDeleteBinLine } from '@remixicon/react' +import { useCallback, useMemo, useState } from 'react' import { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import MetadataIcon from '../metadata-icon' import ConditionDate from './condition-date' import ConditionNumber from './condition-number' import ConditionOperator from './condition-operator' import ConditionString from './condition-string' -import { - COMMON_VARIABLE_REGEX, - comparisonOperatorNotRequireValue, - VARIABLE_REGEX, -} from './utils' +import { COMMON_VARIABLE_REGEX, comparisonOperatorNotRequireValue, VARIABLE_REGEX } from './utils' type ConditionItemProps = { className?: string @@ -32,7 +22,17 @@ type ConditionItemProps = { condition: MetadataFilteringCondition // condition may the condition of case or condition of sub variable onRemoveCondition?: HandleRemoveCondition onUpdateCondition?: HandleUpdateCondition -} & Pick<MetadataShape, 'metadataList' | 'availableStringVars' | 'availableStringNodesWithParent' | 'availableNumberVars' | 'availableNumberNodesWithParent' | 'isCommonVariable' | 'availableCommonStringVars' | 'availableCommonNumberVars'> +} & Pick< + MetadataShape, + | 'metadataList' + | 'availableStringVars' + | 'availableStringNodesWithParent' + | 'availableNumberVars' + | 'availableNumberNodesWithParent' + | 'isCommonVariable' + | 'availableCommonStringVars' + | 'availableCommonNumberVars' +> const ConditionItem = ({ className, disabled, @@ -51,8 +51,7 @@ const ConditionItem = ({ const [isHovered, setIsHovered] = useState(false) const canChooseOperator = useMemo(() => { - if (disabled) - return false + if (disabled) return false return true }, [disabled]) @@ -63,35 +62,37 @@ const ConditionItem = ({ const currentMetadata = useMemo(() => { if (condition.metadata_id) { - const foundByIdAndName = metadataList.find(metadata => metadata.id === condition.metadata_id && metadata.name === condition.name) - if (foundByIdAndName) - return foundByIdAndName + const foundByIdAndName = metadataList.find( + (metadata) => metadata.id === condition.metadata_id && metadata.name === condition.name, + ) + if (foundByIdAndName) return foundByIdAndName - const foundById = metadataList.filter(metadata => metadata.id === condition.metadata_id) - if (foundById.length === 1) - return foundById[0] + const foundById = metadataList.filter((metadata) => metadata.id === condition.metadata_id) + if (foundById.length === 1) return foundById[0] } - return metadataList.find(metadata => metadata.name === condition.name) + return metadataList.find((metadata) => metadata.name === condition.name) }, [metadataList, condition.metadata_id, condition.name]) - const handleConditionOperatorChange = useCallback((operator: ComparisonOperator) => { - onUpdateCondition?.( - condition.id, - { + const handleConditionOperatorChange = useCallback( + (operator: ComparisonOperator) => { + onUpdateCondition?.(condition.id, { ...condition, - value: comparisonOperatorNotRequireValue(condition.comparison_operator) ? undefined : condition.value, + value: comparisonOperatorNotRequireValue(condition.comparison_operator) + ? undefined + : condition.value, comparison_operator: operator, - }, - ) - }, [onUpdateCondition, condition]) + }) + }, + [onUpdateCondition, condition], + ) const valueAndValueMethod = useMemo(() => { if ( - (currentMetadata?.type === MetadataFilteringVariableType.string - || currentMetadata?.type === MetadataFilteringVariableType.number - || currentMetadata?.type === MetadataFilteringVariableType.select) - && typeof condition.value === 'string' + (currentMetadata?.type === MetadataFilteringVariableType.string || + currentMetadata?.type === MetadataFilteringVariableType.number || + currentMetadata?.type === MetadataFilteringVariableType.select) && + typeof condition.value === 'string' ) { const regex = isCommonVariable ? COMMON_VARIABLE_REGEX : VARIABLE_REGEX const matchedStartNumber = isCommonVariable ? 2 : 3 @@ -102,8 +103,7 @@ const ConditionItem = ({ value: matched[0].slice(matchedStartNumber, -matchedStartNumber), valueMethod: 'variable', } - } - else { + } else { return { value: condition.value, valueMethod: 'constant', @@ -118,21 +118,28 @@ const ConditionItem = ({ }, [currentMetadata, condition.value, isCommonVariable]) const [localValueMethod, setLocalValueMethod] = useState(valueAndValueMethod.valueMethod) - const handleValueMethodChange = useCallback((v: string) => { - setLocalValueMethod(v) - onUpdateCondition?.(condition.id, { ...condition, value: undefined }) - }, [condition, onUpdateCondition]) + const handleValueMethodChange = useCallback( + (v: string) => { + setLocalValueMethod(v) + onUpdateCondition?.(condition.id, { ...condition, value: undefined }) + }, + [condition, onUpdateCondition], + ) - const handleValueChange = useCallback((v: any) => { - onUpdateCondition?.(condition.id, { ...condition, value: v }) - }, [condition, onUpdateCondition]) + const handleValueChange = useCallback( + (v: any) => { + onUpdateCondition?.(condition.id, { ...condition, value: v }) + }, + [condition, onUpdateCondition], + ) return ( <div className={cn('mb-1 flex last-of-type:mb-0', className)}> - <div className={cn( - 'grow rounded-lg bg-components-input-bg-normal', - isHovered && 'bg-state-destructive-hover', - )} + <div + className={cn( + 'grow rounded-lg bg-components-input-bg-normal', + isHovered && 'bg-state-destructive-hover', + )} > <div className="flex items-center p-1"> <div className="w-0 grow"> @@ -140,7 +147,9 @@ const ConditionItem = ({ <div className="mr-0.5 p-px"> <MetadataIcon type={currentMetadata?.type} className="size-3" /> </div> - <div className="mr-0.5 min-w-0 flex-1 truncate system-xs-medium text-text-secondary">{currentMetadata?.name}</div> + <div className="mr-0.5 min-w-0 flex-1 truncate system-xs-medium text-text-secondary"> + {currentMetadata?.name} + </div> <div className="system-xs-regular text-text-tertiary">{currentMetadata?.type}</div> </div> </div> @@ -153,10 +162,9 @@ const ConditionItem = ({ /> </div> <div className="border-t border-t-divider-subtle"> - { - !comparisonOperatorNotRequireValue(condition.comparison_operator) - && (currentMetadata?.type === MetadataFilteringVariableType.string - || currentMetadata?.type === MetadataFilteringVariableType.select) && ( + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && + (currentMetadata?.type === MetadataFilteringVariableType.string || + currentMetadata?.type === MetadataFilteringVariableType.select) && ( <ConditionString valueMethod={localValueMethod} onValueMethodChange={handleValueMethodChange} @@ -167,10 +175,9 @@ const ConditionItem = ({ isCommonVariable={isCommonVariable} commonVariables={availableCommonStringVars} /> - ) - } - { - !comparisonOperatorNotRequireValue(condition.comparison_operator) && currentMetadata?.type === MetadataFilteringVariableType.number && ( + )} + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && + currentMetadata?.type === MetadataFilteringVariableType.number && ( <ConditionNumber valueMethod={localValueMethod} onValueMethodChange={handleValueMethodChange} @@ -181,16 +188,11 @@ const ConditionItem = ({ isCommonVariable={isCommonVariable} commonVariables={availableCommonNumberVars} /> - ) - } - { - !comparisonOperatorNotRequireValue(condition.comparison_operator) && currentMetadata?.type === MetadataFilteringVariableType.time && ( - <ConditionDate - value={condition.value as number} - onChange={handleValueChange} - /> - ) - } + )} + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && + currentMetadata?.type === MetadataFilteringVariableType.time && ( + <ConditionDate value={condition.value as number} onChange={handleValueChange} /> + )} </div> </div> <div diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-number.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-number.tsx index b380b83aba86a2..da51076eaa4e2e 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-number.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-number.tsx @@ -1,9 +1,5 @@ import type { ConditionValueMethodProps } from './condition-value-method' -import type { - Node, - NodeOutPutVar, - ValueSelector, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' @@ -18,7 +14,7 @@ type ConditionNumberProps = { nodesOutputVars: NodeOutPutVar[] availableNodes: Node[] isCommonVariable?: boolean - commonVariables: { name: string, type: string, value: string }[] + commonVariables: { name: string; type: string; value: string }[] } & ConditionValueMethodProps const ConditionNumber = ({ value, @@ -31,56 +27,55 @@ const ConditionNumber = ({ commonVariables, }: ConditionNumberProps) => { const { t } = useTranslation() - const handleVariableValueChange = useCallback((v: ValueSelector) => { - onChange(`{{#${v.join('.')}#}}`) - }, [onChange]) + const handleVariableValueChange = useCallback( + (v: ValueSelector) => { + onChange(`{{#${v.join('.')}#}}`) + }, + [onChange], + ) - const handleCommonVariableValueChange = useCallback((v: string) => { - onChange(`{{${v}}}`) - }, [onChange]) + const handleCommonVariableValueChange = useCallback( + (v: string) => { + onChange(`{{${v}}}`) + }, + [onChange], + ) return ( <div className="flex h-8 items-center pr-2 pl-1"> - <ConditionValueMethod - valueMethod={valueMethod} - onValueMethodChange={onValueMethodChange} - /> + <ConditionValueMethod valueMethod={valueMethod} onValueMethodChange={onValueMethodChange} /> <div className="mr-1.5 ml-1 h-4 w-px bg-divider-regular"></div> - { - valueMethod === 'variable' && !isCommonVariable && ( - <ConditionVariableSelector - valueSelector={value ? (value as string).split('.') : []} - onChange={handleVariableValueChange} - nodesOutputVars={nodesOutputVars} - availableNodes={availableNodes} - varType={VarType.number} - /> - ) - } - { - valueMethod === 'variable' && isCommonVariable && ( - <ConditionCommonVariableSelector - variables={commonVariables} - value={value} - onChange={handleCommonVariableValueChange} - varType={VarType.number} - /> - ) - } - { - valueMethod === 'constant' && ( - <Input - className="border-none bg-transparent outline-hidden hover:bg-transparent focus:bg-transparent focus:shadow-none" - value={value} - onChange={(e) => { - const v = e.target.value - onChange(v ? Number(e.target.value) : undefined) - }} - placeholder={t($ => $['nodes.knowledgeRetrieval.metadata.panel.placeholder'], { ns: 'workflow' })} - type="number" - /> - ) - } + {valueMethod === 'variable' && !isCommonVariable && ( + <ConditionVariableSelector + valueSelector={value ? (value as string).split('.') : []} + onChange={handleVariableValueChange} + nodesOutputVars={nodesOutputVars} + availableNodes={availableNodes} + varType={VarType.number} + /> + )} + {valueMethod === 'variable' && isCommonVariable && ( + <ConditionCommonVariableSelector + variables={commonVariables} + value={value} + onChange={handleCommonVariableValueChange} + varType={VarType.number} + /> + )} + {valueMethod === 'constant' && ( + <Input + className="border-none bg-transparent outline-hidden hover:bg-transparent focus:bg-transparent focus:shadow-none" + value={value} + onChange={(e) => { + const v = e.target.value + onChange(v ? Number(e.target.value) : undefined) + }} + placeholder={t(($) => $['nodes.knowledgeRetrieval.metadata.panel.placeholder'], { + ns: 'workflow', + })} + type="number" + /> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-operator.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-operator.tsx index 570df5c9ada8ed..e22c1dc7d51ace 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-operator.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-operator.tsx @@ -4,21 +4,11 @@ import type { } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowDownSLine } from '@remixicon/react' -import { - useMemo, - useState, -} from 'react' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - getOperators, - isComparisonOperatorNeedTranslate, -} from './utils' +import { getOperators, isComparisonOperatorNeedTranslate } from './utils' const i18nPrefix = 'nodes.ifElse' @@ -42,33 +32,32 @@ const ConditionOperator = ({ const options = useMemo(() => { return getOperators(variableType).map((o) => { return { - label: isComparisonOperatorNeedTranslate(o) ? t($ => $[`${i18nPrefix}.comparisonOperator.${o}`], { ns: 'workflow' }) : o, + label: isComparisonOperatorNeedTranslate(o) + ? t(($) => $[`${i18nPrefix}.comparisonOperator.${o}`], { ns: 'workflow' }) + : o, value: o, } }) }, [t, variableType]) - const selectedOption = options.find(o => Array.isArray(value) ? o.value === value[0] : o.value === value) + const selectedOption = options.find((o) => + Array.isArray(value) ? o.value === value[0] : o.value === value, + ) return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <Button className={cn('shrink-0', !selectedOption && 'opacity-50', className)} size="small" variant="ghost" disabled={disabled} > - { - selectedOption - ? selectedOption.label - : t($ => $[`${i18nPrefix}.select`], { ns: 'workflow' }) - } + {selectedOption + ? selectedOption.label + : t(($) => $[`${i18nPrefix}.select`], { ns: 'workflow' })} <RiArrowDownSLine className="ml-1 size-3.5" /> </Button> - )} + } /> <PopoverContent placement="bottom-end" @@ -76,20 +65,18 @@ const ConditionOperator = ({ popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > <div className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"> - { - options.map(option => ( - <div - key={option.value} - className="flex h-7 cursor-pointer items-center rounded-lg px-3 py-1.5 text-[13px] font-medium text-text-secondary hover:bg-state-base-hover" - onClick={() => { - onSelect(option.value) - setOpen(false) - }} - > - {option.label} - </div> - )) - } + {options.map((option) => ( + <div + key={option.value} + className="flex h-7 cursor-pointer items-center rounded-lg px-3 py-1.5 text-[13px] font-medium text-text-secondary hover:bg-state-base-hover" + onClick={() => { + onSelect(option.value) + setOpen(false) + }} + > + {option.label} + </div> + ))} </div> </PopoverContent> </Popover> diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-string.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-string.tsx index 3f7ca5fbd979ce..e4c9f62ae72245 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-string.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-string.tsx @@ -1,9 +1,5 @@ import type { ConditionValueMethodProps } from './condition-value-method' -import type { - Node, - NodeOutPutVar, - ValueSelector, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' @@ -18,7 +14,7 @@ type ConditionStringProps = { nodesOutputVars: NodeOutPutVar[] availableNodes: Node[] isCommonVariable?: boolean - commonVariables: { name: string, type: string, value: string }[] + commonVariables: { name: string; type: string; value: string }[] } & ConditionValueMethodProps const ConditionString = ({ value, @@ -31,52 +27,51 @@ const ConditionString = ({ commonVariables, }: ConditionStringProps) => { const { t } = useTranslation() - const handleVariableValueChange = useCallback((v: ValueSelector) => { - onChange(`{{#${v.join('.')}#}}`) - }, [onChange]) + const handleVariableValueChange = useCallback( + (v: ValueSelector) => { + onChange(`{{#${v.join('.')}#}}`) + }, + [onChange], + ) - const handleCommonVariableValueChange = useCallback((v: string) => { - onChange(`{{${v}}}`) - }, [onChange]) + const handleCommonVariableValueChange = useCallback( + (v: string) => { + onChange(`{{${v}}}`) + }, + [onChange], + ) return ( <div className="flex h-8 items-center pr-2 pl-1"> - <ConditionValueMethod - valueMethod={valueMethod} - onValueMethodChange={onValueMethodChange} - /> + <ConditionValueMethod valueMethod={valueMethod} onValueMethodChange={onValueMethodChange} /> <div className="mr-1.5 ml-1 h-4 w-px bg-divider-regular"></div> - { - valueMethod === 'variable' && !isCommonVariable && ( - <ConditionVariableSelector - valueSelector={value ? value!.split('.') : []} - onChange={handleVariableValueChange} - nodesOutputVars={nodesOutputVars} - availableNodes={availableNodes} - varType={VarType.string} - /> - ) - } - { - valueMethod === 'variable' && isCommonVariable && ( - <ConditionCommonVariableSelector - variables={commonVariables} - value={value} - onChange={handleCommonVariableValueChange} - varType={VarType.string} - /> - ) - } - { - valueMethod === 'constant' && ( - <Input - className="border-none bg-transparent outline-hidden hover:bg-transparent focus:bg-transparent focus:shadow-none" - value={value} - onChange={e => onChange(e.target.value)} - placeholder={t($ => $['nodes.knowledgeRetrieval.metadata.panel.placeholder'], { ns: 'workflow' })} - /> - ) - } + {valueMethod === 'variable' && !isCommonVariable && ( + <ConditionVariableSelector + valueSelector={value ? value!.split('.') : []} + onChange={handleVariableValueChange} + nodesOutputVars={nodesOutputVars} + availableNodes={availableNodes} + varType={VarType.string} + /> + )} + {valueMethod === 'variable' && isCommonVariable && ( + <ConditionCommonVariableSelector + variables={commonVariables} + value={value} + onChange={handleCommonVariableValueChange} + varType={VarType.string} + /> + )} + {valueMethod === 'constant' && ( + <Input + className="border-none bg-transparent outline-hidden hover:bg-transparent focus:bg-transparent focus:shadow-none" + value={value} + onChange={(e) => onChange(e.target.value)} + placeholder={t(($) => $['nodes.knowledgeRetrieval.metadata.panel.placeholder'], { + ns: 'workflow', + })} + /> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx index acab16a00a9af8..6748afe49f73f2 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx @@ -1,10 +1,6 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowDownSLine } from '@remixicon/react' import { capitalize } from 'es-toolkit/string' import { useState } from 'react' @@ -13,10 +9,7 @@ export type ConditionValueMethodProps = { valueMethod?: string onValueMethodChange: (v: string) => void } -const options = [ - 'variable', - 'constant', -] +const options = ['variable', 'constant'] const ConditionValueMethod = ({ valueMethod = 'variable', onValueMethodChange, @@ -24,21 +17,14 @@ const ConditionValueMethod = ({ const [open, setOpen] = useState(false) return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( - <Button - className="shrink-0" - variant="ghost" - size="small" - > + render={ + <Button className="shrink-0" variant="ghost" size="small"> {capitalize(valueMethod)} <RiArrowDownSLine className="ml-px size-3.5" /> </Button> - )} + } /> <PopoverContent placement="bottom-start" @@ -46,26 +32,23 @@ const ConditionValueMethod = ({ popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > <div className="w-[112px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"> - { - options.map(option => ( - <div - key={option} - className={cn( - 'flex h-7 cursor-pointer items-center rounded-md px-3 hover:bg-state-base-hover', - 'text-[13px] font-medium text-text-secondary', - valueMethod === option && 'bg-state-base-hover', - )} - onClick={() => { - if (valueMethod === option) - return - onValueMethodChange(option) - setOpen(false) - }} - > - {capitalize(option)} - </div> - )) - } + {options.map((option) => ( + <div + key={option} + className={cn( + 'flex h-7 cursor-pointer items-center rounded-md px-3 hover:bg-state-base-hover', + 'text-[13px] font-medium text-text-secondary', + valueMethod === option && 'bg-state-base-hover', + )} + onClick={() => { + if (valueMethod === option) return + onValueMethodChange(option) + setOpen(false) + }} + > + {capitalize(option)} + </div> + ))} </div> </PopoverContent> </Popover> diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx index f238bf70873200..b0d4b51785a73b 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx @@ -1,14 +1,5 @@ -import type { - Node, - NodeOutPutVar, - ValueSelector, - Var, -} from '@/app/components/workflow/types' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' @@ -34,15 +25,18 @@ const ConditionVariableSelector = ({ const { t } = useTranslation() const [open, setOpen] = useState(false) - const handleChange = useCallback((nextValueSelector: ValueSelector, varItem: Var) => { - onChange(nextValueSelector, varItem) - setOpen(false) - }, [onChange]) + const handleChange = useCallback( + (nextValueSelector: ValueSelector, varItem: Var) => { + onChange(nextValueSelector, varItem) + setOpen(false) + }, + [onChange], + ) return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <div className="flex h-6 grow cursor-pointer items-center"> {!!valueSelector.length && ( <VariableTag @@ -56,7 +50,9 @@ const ConditionVariableSelector = ({ <> <div className="flex grow items-center system-sm-regular text-components-input-text-placeholder"> <Variable02 className="mr-1 size-4" /> - {t($ => $['nodes.knowledgeRetrieval.metadata.panel.select'], { ns: 'workflow' })} + {t(($) => $['nodes.knowledgeRetrieval.metadata.panel.select'], { + ns: 'workflow', + })} </div> <div className="flex h-5 shrink-0 items-center rounded-[5px] border border-divider-deep px-[5px] system-2xs-medium text-text-tertiary"> {varType} @@ -64,7 +60,7 @@ const ConditionVariableSelector = ({ </> )} </div> - )} + } /> <PopoverContent placement="bottom-start" @@ -72,11 +68,7 @@ const ConditionVariableSelector = ({ popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > <div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg"> - <VarReferenceVars - vars={nodesOutputVars} - isSupportFileVar - onChange={handleChange} - /> + <VarReferenceVars vars={nodesOutputVars} isSupportFileVar onChange={handleChange} /> </div> </PopoverContent> </Popover> diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/index.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/index.tsx index b1beb5f45f952b..f19a75eb5409ab 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/index.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/index.tsx @@ -30,44 +30,37 @@ const ConditionList = ({ return ( <div className={cn('relative')}> - { - conditions.length > 1 && ( - <div className={cn( - 'absolute top-0 bottom-0 left-0 w-[44px]', - )} + {conditions.length > 1 && ( + <div className={cn('absolute top-0 bottom-0 left-0 w-[44px]')}> + <div className="absolute top-4 right-1 bottom-4 w-2.5 rounded-l-[8px] border border-r-0 border-divider-deep"></div> + <div className="absolute top-1/2 right-0 h-[29px] w-4 -translate-y-1/2 bg-components-panel-bg"></div> + <div + className="absolute top-1/2 right-1 flex h-[21px] -translate-y-1/2 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1 text-[10px] font-semibold text-text-accent-secondary shadow-xs select-none" + onClick={() => handleToggleConditionLogicalOperator()} > - <div className="absolute top-4 right-1 bottom-4 w-2.5 rounded-l-[8px] border border-r-0 border-divider-deep"></div> - <div className="absolute top-1/2 right-0 h-[29px] w-4 -translate-y-1/2 bg-components-panel-bg"></div> - <div - className="absolute top-1/2 right-1 flex h-[21px] -translate-y-1/2 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1 text-[10px] font-semibold text-text-accent-secondary shadow-xs select-none" - onClick={() => handleToggleConditionLogicalOperator()} - > - {logical_operator.toUpperCase()} - <RiLoopLeftLine className="ml-0.5 size-3" /> - </div> + {logical_operator.toUpperCase()} + <RiLoopLeftLine className="ml-0.5 size-3" /> </div> - ) - } + </div> + )} <div className={cn(conditions.length > 1 && 'pl-[44px]')}> - { - conditions.map(condition => ( - <ConditionItem - key={`${condition.id}`} - disabled={disabled} - condition={condition} - onUpdateCondition={handleUpdateCondition} - onRemoveCondition={handleRemoveCondition} - metadataList={metadataList} - availableStringVars={availableStringVars} - availableStringNodesWithParent={availableStringNodesWithParent} - availableNumberVars={availableNumberVars} - availableNumberNodesWithParent={availableNumberNodesWithParent} - isCommonVariable={isCommonVariable} - availableCommonStringVars={availableCommonStringVars} - availableCommonNumberVars={availableCommonNumberVars} - /> - )) - } + {conditions.map((condition) => ( + <ConditionItem + key={`${condition.id}`} + disabled={disabled} + condition={condition} + onUpdateCondition={handleUpdateCondition} + onRemoveCondition={handleRemoveCondition} + metadataList={metadataList} + availableStringVars={availableStringVars} + availableStringNodesWithParent={availableStringNodesWithParent} + availableNumberVars={availableNumberVars} + availableNumberNodesWithParent={availableNumberNodesWithParent} + isCommonVariable={isCommonVariable} + availableCommonStringVars={availableCommonStringVars} + availableCommonNumberVars={availableCommonNumberVars} + /> + ))} </div> </div> ) diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/utils.ts b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/utils.ts index d34303014ba6c2..45e3f4401549d4 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/utils.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/utils.ts @@ -12,14 +12,19 @@ const notTranslateKey = [ ComparisonOperator.lessThanOrEqual, ] as const -type NotTranslateOperator = typeof notTranslateKey[number] +type NotTranslateOperator = (typeof notTranslateKey)[number] type TranslatableComparisonOperator = Exclude<ComparisonOperator, NotTranslateOperator> -export function isComparisonOperatorNeedTranslate(operator: ComparisonOperator): operator is TranslatableComparisonOperator -export function isComparisonOperatorNeedTranslate(operator?: ComparisonOperator): operator is TranslatableComparisonOperator -export function isComparisonOperatorNeedTranslate(operator?: ComparisonOperator): operator is TranslatableComparisonOperator { - if (!operator) - return false +export function isComparisonOperatorNeedTranslate( + operator: ComparisonOperator, +): operator is TranslatableComparisonOperator +export function isComparisonOperatorNeedTranslate( + operator?: ComparisonOperator, +): operator is TranslatableComparisonOperator +export function isComparisonOperatorNeedTranslate( + operator?: ComparisonOperator, +): operator is TranslatableComparisonOperator { + if (!operator) return false return !(notTranslateKey as readonly ComparisonOperator[]).includes(operator) } @@ -62,10 +67,16 @@ export const getOperators = (type?: MetadataFilteringVariableType) => { } export const comparisonOperatorNotRequireValue = (operator?: ComparisonOperator) => { - if (!operator) - return false + if (!operator) return false - return [ComparisonOperator.empty, ComparisonOperator.notEmpty, ComparisonOperator.isNull, ComparisonOperator.isNotNull, ComparisonOperator.exists, ComparisonOperator.notExists].includes(operator) + return [ + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ComparisonOperator.isNull, + ComparisonOperator.isNotNull, + ComparisonOperator.exists, + ComparisonOperator.notExists, + ].includes(operator) } export const VARIABLE_REGEX = /\{\{(#[\w-]{1,50}(\.[a-z_]\w{0,29}){1,10}#)\}\}/gi diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx index b5e6aaf774719e..36f7d2a1750ce8 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx @@ -1,9 +1,6 @@ import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import { noop } from 'es-toolkit/function' -import { - useCallback, - useState, -} from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' @@ -36,28 +33,36 @@ const MetadataFilter = ({ const { t } = useTranslation() const [collapsed, setCollapsed] = useState(true) - const handleMetadataFilterModeChangeWrapped = useCallback((mode: MetadataFilteringModeEnum) => { - if (mode === MetadataFilteringModeEnum.automatic) - setCollapsed(false) + const handleMetadataFilterModeChangeWrapped = useCallback( + (mode: MetadataFilteringModeEnum) => { + if (mode === MetadataFilteringModeEnum.automatic) setCollapsed(false) - handleMetadataFilterModeChange(mode) - }, [handleMetadataFilterModeChange]) + handleMetadataFilterModeChange(mode) + }, + [handleMetadataFilterModeChange], + ) return ( <Collapse - disabled={metadataFilterMode === MetadataFilteringModeEnum.disabled || metadataFilterMode === MetadataFilteringModeEnum.manual} + disabled={ + metadataFilterMode === MetadataFilteringModeEnum.disabled || + metadataFilterMode === MetadataFilteringModeEnum.manual + } collapsed={collapsed} onCollapse={setCollapsed} > <CollapseHeader> <CollapseTrigger> <CollapseTitle> - {t($ => $['nodes.knowledgeRetrieval.metadata.title'], { ns: 'workflow' })} + {t(($) => $['nodes.knowledgeRetrieval.metadata.title'], { ns: 'workflow' })} </CollapseTitle> {metadataFilterMode === MetadataFilteringModeEnum.automatic && <CollapseIndicator />} </CollapseTrigger> - <Infotip aria-label={t($ => $['nodes.knowledgeRetrieval.metadata.tip'], { ns: 'workflow' })} popupClassName="w-[200px]"> - {t($ => $['nodes.knowledgeRetrieval.metadata.tip'], { ns: 'workflow' })} + <Infotip + aria-label={t(($) => $['nodes.knowledgeRetrieval.metadata.tip'], { ns: 'workflow' })} + popupClassName="w-[200px]" + > + {t(($) => $['nodes.knowledgeRetrieval.metadata.tip'], { ns: 'workflow' })} </Infotip> <CollapseActions> <div className="flex items-center pr-4"> @@ -77,7 +82,9 @@ const MetadataFilter = ({ {metadataFilterMode === MetadataFilteringModeEnum.automatic && ( <> <div className="px-4 body-xs-regular text-text-tertiary"> - {t($ => $['nodes.knowledgeRetrieval.metadata.options.automatic.desc'], { ns: 'workflow' })} + {t(($) => $['nodes.knowledgeRetrieval.metadata.options.automatic.desc'], { + ns: 'workflow', + })} </div> <div className="mt-1 px-4"> <ModelParameterModal diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx index 32fc19bdb0662d..d49955b0dfe358 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx @@ -6,10 +6,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { - RiArrowDownSLine, - RiCheckLine, -} from '@remixicon/react' +import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' @@ -25,33 +22,39 @@ const MetadataFilterSelector = ({ const options = [ { key: MetadataFilteringModeEnum.disabled, - value: t($ => $['nodes.knowledgeRetrieval.metadata.options.disabled.title'], { ns: 'workflow' }), - desc: t($ => $['nodes.knowledgeRetrieval.metadata.options.disabled.subTitle'], { ns: 'workflow' }), + value: t(($) => $['nodes.knowledgeRetrieval.metadata.options.disabled.title'], { + ns: 'workflow', + }), + desc: t(($) => $['nodes.knowledgeRetrieval.metadata.options.disabled.subTitle'], { + ns: 'workflow', + }), }, { key: MetadataFilteringModeEnum.automatic, - value: t($ => $['nodes.knowledgeRetrieval.metadata.options.automatic.title'], { ns: 'workflow' }), - desc: t($ => $['nodes.knowledgeRetrieval.metadata.options.automatic.subTitle'], { ns: 'workflow' }), + value: t(($) => $['nodes.knowledgeRetrieval.metadata.options.automatic.title'], { + ns: 'workflow', + }), + desc: t(($) => $['nodes.knowledgeRetrieval.metadata.options.automatic.subTitle'], { + ns: 'workflow', + }), }, { key: MetadataFilteringModeEnum.manual, - value: t($ => $['nodes.knowledgeRetrieval.metadata.options.manual.title'], { ns: 'workflow' }), - desc: t($ => $['nodes.knowledgeRetrieval.metadata.options.manual.subTitle'], { ns: 'workflow' }), + value: t(($) => $['nodes.knowledgeRetrieval.metadata.options.manual.title'], { + ns: 'workflow', + }), + desc: t(($) => $['nodes.knowledgeRetrieval.metadata.options.manual.subTitle'], { + ns: 'workflow', + }), }, ] - const selectedOption = options.find(option => option.key === value)! + const selectedOption = options.find((option) => option.key === value)! return ( <DropdownMenu> <DropdownMenuTrigger - render={( - <Button - variant="secondary" - size="small" - onClick={e => e.stopPropagation()} - /> - )} + render={<Button variant="secondary" size="small" onClick={(e) => e.stopPropagation()} />} > {selectedOption.value} <RiArrowDownSLine className="size-3.5" /> @@ -61,36 +64,23 @@ const MetadataFilterSelector = ({ sideOffset={4} popupClassName="w-[280px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1" > - <DropdownMenuRadioGroup - value={value} - onValueChange={onSelect} - > - { - options.map(option => ( - <DropdownMenuRadioItem - key={option.key} - value={option.key} - closeOnClick - className="h-auto items-start rounded-lg p-2 pr-3" - > - <div className="w-4 shrink-0"> - { - option.key === value && ( - <RiCheckLine className="size-4 text-text-accent" /> - ) - } - </div> - <div className="grow"> - <div className="system-sm-semibold text-text-secondary"> - {option.value} - </div> - <div className="system-xs-regular text-text-tertiary"> - {option.desc} - </div> - </div> - </DropdownMenuRadioItem> - )) - } + <DropdownMenuRadioGroup value={value} onValueChange={onSelect}> + {options.map((option) => ( + <DropdownMenuRadioItem + key={option.key} + value={option.key} + closeOnClick + className="h-auto items-start rounded-lg p-2 pr-3" + > + <div className="w-4 shrink-0"> + {option.key === value && <RiCheckLine className="size-4 text-text-accent" />} + </div> + <div className="grow"> + <div className="system-sm-semibold text-text-secondary">{option.value}</div> + <div className="system-xs-regular text-text-tertiary">{option.desc}</div> + </div> + </DropdownMenuRadioItem> + ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-icon.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-icon.tsx index 5d96a96977ba2b..f1e0066ff986b5 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-icon.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-icon.tsx @@ -1,9 +1,5 @@ import { cn } from '@langgenius/dify-ui/cn' -import { - RiHashtag, - RiTextSnippet, - RiTimeLine, -} from '@remixicon/react' +import { RiHashtag, RiTextSnippet, RiTimeLine } from '@remixicon/react' import { memo } from 'react' import { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types' @@ -11,27 +7,19 @@ type MetadataIconProps = { type?: MetadataFilteringVariableType className?: string } -const MetadataIcon = ({ - type, - className, -}: MetadataIconProps) => { +const MetadataIcon = ({ type, className }: MetadataIconProps) => { return ( <> - { - (type === MetadataFilteringVariableType.string || type === MetadataFilteringVariableType.select) && ( - <RiTextSnippet className={cn('size-3.5', className)} /> - ) - } - { - type === MetadataFilteringVariableType.number && ( - <RiHashtag className={cn('size-3.5', className)} /> - ) - } - { - type === MetadataFilteringVariableType.time && ( - <RiTimeLine className={cn('size-3.5', className)} /> - ) - } + {(type === MetadataFilteringVariableType.string || + type === MetadataFilteringVariableType.select) && ( + <RiTextSnippet className={cn('size-3.5', className)} /> + )} + {type === MetadataFilteringVariableType.number && ( + <RiHashtag className={cn('size-3.5', className)} /> + )} + {type === MetadataFilteringVariableType.time && ( + <RiTimeLine className={cn('size-3.5', className)} /> + )} </> ) } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-panel.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-panel.tsx index 259cc8810a54e7..f6c2e7acaf5dc5 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-panel.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-panel.tsx @@ -20,7 +20,7 @@ const MetadataPanel = ({ <div className="w-[420px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl"> <div className="relative px-3 pt-3.5"> <div className="system-xl-semibold text-text-primary"> - {t($ => $['nodes.knowledgeRetrieval.metadata.panel.title'], { ns: 'workflow' })} + {t(($) => $['nodes.knowledgeRetrieval.metadata.panel.title'], { ns: 'workflow' })} </div> <div className="absolute right-2.5 bottom-0 flex size-8 cursor-pointer items-center justify-center" @@ -38,10 +38,7 @@ const MetadataPanel = ({ {...restProps} /> </div> - <AddCondition - metadataList={metadataList} - handleAddCondition={handleAddCondition} - /> + <AddCondition metadataList={metadataList} handleAddCondition={handleAddCondition} /> </div> </div> </div> diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx index 627a64d6fb3a9c..f3bad3b50d3bc4 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx @@ -1,15 +1,8 @@ import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import { Button } from '@langgenius/dify-ui/button' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiFilter3Line } from '@remixicon/react' -import { - useEffect, - useState, -} from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import MetadataPanel from './metadata-panel' @@ -28,35 +21,32 @@ const MetadataTrigger = ({ if (selectedDatasetsLoaded) { conditions.forEach((condition) => { // First try to match by metadata_id for reliable reference - const foundById = condition.metadata_id && metadataList.find(metadata => metadata.id === condition.metadata_id) + const foundById = + condition.metadata_id && + metadataList.find((metadata) => metadata.id === condition.metadata_id) // Fallback to name matching only for backward compatibility with old conditions - const foundByName = !condition.metadata_id && metadataList.find(metadata => metadata.name === condition.name) + const foundByName = + !condition.metadata_id && + metadataList.find((metadata) => metadata.name === condition.name) // Only remove condition if both metadata_id and name matching fail - if (!foundById && !foundByName) - handleRemoveCondition(condition.id) + if (!foundById && !foundByName) handleRemoveCondition(condition.id) }) } }, [metadataFilteringConditions, metadataList, handleRemoveCondition, selectedDatasetsLoaded]) return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( - <Button - variant="secondary-accent" - size="small" - > + render={ + <Button variant="secondary-accent" size="small"> <RiFilter3Line className="mr-1 size-3.5" /> - {t($ => $['nodes.knowledgeRetrieval.metadata.panel.conditions'], { ns: 'workflow' })} + {t(($) => $['nodes.knowledgeRetrieval.metadata.panel.conditions'], { ns: 'workflow' })} <div className="ml-1 flex items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary"> {metadataFilteringConditions?.conditions.length || 0} </div> </Button> - )} + } /> <PopoverContent placement="left" diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx index fb3244ea348c4a..93f4f664580bc9 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx @@ -7,11 +7,7 @@ import type { DataSet } from '@/models/datasets' import type { DatasetConfigs } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiEqualizer2Line } from '@remixicon/react' import * as React from 'react' import { useCallback, useMemo } from 'react' @@ -54,31 +50,29 @@ const RetrievalConfig: FC<Props> = ({ const { t } = useTranslation() const { retrieval_mode, multiple_retrieval_config } = payload - const handleOpen = useCallback((newOpen: boolean) => { - onRerankModelOpenChange(newOpen) - }, [onRerankModelOpenChange]) + const handleOpen = useCallback( + (newOpen: boolean) => { + onRerankModelOpenChange(newOpen) + }, + [onRerankModelOpenChange], + ) const datasetConfigs = useMemo(() => { - const { - reranking_model, - top_k, - score_threshold, - reranking_mode, - weights, - reranking_enable, - } = multiple_retrieval_config || {} + const { reranking_model, top_k, score_threshold, reranking_mode, weights, reranking_enable } = + multiple_retrieval_config || {} return { retrieval_model: retrieval_mode, - reranking_model: (reranking_model?.provider && reranking_model?.model) - ? { - reranking_provider_name: reranking_model?.provider, - reranking_model_name: reranking_model?.model, - } - : { - reranking_provider_name: '', - reranking_model_name: '', - }, + reranking_model: + reranking_model?.provider && reranking_model?.model + ? { + reranking_provider_name: reranking_model?.provider, + reranking_model_name: reranking_model?.model, + } + : { + reranking_provider_name: '', + reranking_model_name: '', + }, top_k: top_k || DATASET_DEFAULT.top_k, score_threshold_enabled: !(score_threshold === undefined || score_threshold === null), score_threshold, @@ -91,42 +85,46 @@ const RetrievalConfig: FC<Props> = ({ } }, [retrieval_mode, multiple_retrieval_config]) - const handleChange = useCallback((configs: DatasetConfigs, isRetrievalModeChange?: boolean) => { - // Legacy code, for compatibility, have to keep it - if (isRetrievalModeChange) { - onRetrievalModeChange(configs.retrieval_model) - return - } - onMultipleRetrievalConfigChange({ - top_k: configs.top_k, - score_threshold: configs.score_threshold_enabled ? (configs.score_threshold ?? DATASET_DEFAULT.score_threshold) : null, - reranking_model: retrieval_mode === RETRIEVE_TYPE.oneWay - ? undefined - - : (!configs.reranking_model?.reranking_provider_name + const handleChange = useCallback( + (configs: DatasetConfigs, isRetrievalModeChange?: boolean) => { + // Legacy code, for compatibility, have to keep it + if (isRetrievalModeChange) { + onRetrievalModeChange(configs.retrieval_model) + return + } + onMultipleRetrievalConfigChange({ + top_k: configs.top_k, + score_threshold: configs.score_threshold_enabled + ? (configs.score_threshold ?? DATASET_DEFAULT.score_threshold) + : null, + reranking_model: + retrieval_mode === RETRIEVE_TYPE.oneWay ? undefined - : { - provider: configs.reranking_model?.reranking_provider_name, - model: configs.reranking_model?.reranking_model_name, - }), - reranking_mode: configs.reranking_mode, - weights: configs.weights, - reranking_enable: configs.reranking_enable, - }) - }, [onMultipleRetrievalConfigChange, retrieval_mode, onRetrievalModeChange]) + : !configs.reranking_model?.reranking_provider_name + ? undefined + : { + provider: configs.reranking_model?.reranking_provider_name, + model: configs.reranking_model?.reranking_model_name, + }, + reranking_mode: configs.reranking_mode, + weights: configs.weights, + reranking_enable: configs.reranking_enable, + }) + }, + [onMultipleRetrievalConfigChange, retrieval_mode, onRetrievalModeChange], + ) return ( <Popover modal={modal} open={rerankModalOpen} onOpenChange={(nextOpen) => { - if (readonly) - return + if (readonly) return handleOpen(nextOpen) }} > <PopoverTrigger - render={( + render={ <Button variant="ghost" size="small" @@ -134,9 +132,9 @@ const RetrievalConfig: FC<Props> = ({ className={cn(rerankModalOpen && 'bg-components-button-ghost-bg-hover')} > <RiEqualizer2Line className="mr-1 size-3.5" /> - {t($ => $.retrievalSettings, { ns: 'dataset' })} + {t(($) => $.retrievalSettings, { ns: 'dataset' })} </Button> - )} + } /> <PopoverContent placement="bottom-end" diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/default.ts b/web/app/components/workflow/nodes/knowledge-retrieval/default.ts index cf98c450005f2a..2a99e1060a7913 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/default.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/default.ts @@ -30,17 +30,33 @@ const nodeDefault: NodeDefault<KnowledgeRetrievalNodeType> = { let errorMessages = '' if (!errorMessages && (!payload.dataset_ids || payload.dataset_ids.length === 0)) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}nodes.knowledgeRetrieval.knowledge`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}nodes.knowledgeRetrieval.knowledge`], { ns: 'workflow' }), + }) - if (!errorMessages && payload.retrieval_mode === RETRIEVE_TYPE.oneWay && !payload.single_retrieval_config?.model?.provider) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $['modelProvider.systemReasoningModel.key'], { ns: 'common' }) }) + if ( + !errorMessages && + payload.retrieval_mode === RETRIEVE_TYPE.oneWay && + !payload.single_retrieval_config?.model?.provider + ) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['modelProvider.systemReasoningModel.key'], { ns: 'common' }), + }) const { _datasets, multiple_retrieval_config, retrieval_mode } = payload if (retrieval_mode === RETRIEVE_TYPE.multiWay) { - const checked = checkoutRerankModelConfiguredInRetrievalSettings(_datasets || [], multiple_retrieval_config) + const checked = checkoutRerankModelConfiguredInRetrievalSettings( + _datasets || [], + multiple_retrieval_config, + ) if (!errorMessages && !checked) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}errorMsg.fields.rerankModel`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}errorMsg.fields.rerankModel`], { ns: 'workflow' }), + }) } return { diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/hooks.ts b/web/app/components/workflow/nodes/knowledge-retrieval/hooks.ts index 00dcb4939b1c96..936dd75fbd4a60 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/hooks.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/hooks.ts @@ -1,7 +1,4 @@ -import type { - DataSet, - SelectedDatasetsMode, -} from '@/models/datasets' +import type { DataSet, SelectedDatasetsMode } from '@/models/datasets' import { useMemo } from 'react' import { getSelectedDatasetsMode } from './utils' diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-dataset-selection.spec.ts b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-dataset-selection.spec.ts index ec23fe895c6b7d..cc723b900dc543 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-dataset-selection.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-dataset-selection.spec.ts @@ -81,7 +81,9 @@ const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ ...overrides, }) -const createPayload = (overrides: Partial<KnowledgeRetrievalNodeType> = {}): KnowledgeRetrievalNodeType => ({ +const createPayload = ( + overrides: Partial<KnowledgeRetrievalNodeType> = {}, +): KnowledgeRetrievalNodeType => ({ title: 'Knowledge Retrieval', desc: '', type: BlockEnum.KnowledgeRetrieval, @@ -133,17 +135,19 @@ describe('use-knowledge-dataset-selection', () => { it('loads dataset details on mount and exposes multimodal state', async () => { const { inputRef, setInputs } = createState(createPayload()) - const { result } = renderHook(() => useKnowledgeDatasetSelection({ - inputs: inputRef.current, - inputRef, - setInputs, - payloadRetrievalMode: RETRIEVE_TYPE.multiWay, - updateDatasetsDetail, - fallbackRerankModel: { - provider: 'rerank-provider', - model: 'rerank-model', - }, - })) + const { result } = renderHook(() => + useKnowledgeDatasetSelection({ + inputs: inputRef.current, + inputRef, + setInputs, + payloadRetrievalMode: RETRIEVE_TYPE.multiWay, + updateDatasetsDetail, + fallbackRerankModel: { + provider: 'rerank-provider', + model: 'rerank-model', + }, + }), + ) await waitFor(() => { expect(result.current.selectedDatasetsLoaded).toBe(true) @@ -162,28 +166,34 @@ describe('use-knowledge-dataset-selection', () => { ids: ['dataset-1'], }, }) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - dataset_ids: ['dataset-1'], - })) + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + dataset_ids: ['dataset-1'], + }), + ) expect(result.current.showImageQueryVarSelector).toBe(true) }) it('updates dataset ids, retrieval config, attachment selector, and rerank modal state', async () => { - const { inputRef, setInputs } = createState(createPayload({ - dataset_ids: [], - })) + const { inputRef, setInputs } = createState( + createPayload({ + dataset_ids: [], + }), + ) - const { result } = renderHook(() => useKnowledgeDatasetSelection({ - inputs: inputRef.current, - inputRef, - setInputs, - payloadRetrievalMode: RETRIEVE_TYPE.multiWay, - updateDatasetsDetail, - fallbackRerankModel: { - provider: 'rerank-provider', - model: 'rerank-model', - }, - })) + const { result } = renderHook(() => + useKnowledgeDatasetSelection({ + inputs: inputRef.current, + inputRef, + setInputs, + payloadRetrievalMode: RETRIEVE_TYPE.multiWay, + updateDatasetsDetail, + fallbackRerankModel: { + provider: 'rerank-provider', + model: 'rerank-model', + }, + }), + ) await waitFor(() => { expect(result.current.selectedDatasetsLoaded).toBe(true) @@ -209,40 +219,46 @@ describe('use-knowledge-dataset-selection', () => { }) expect(updateDatasetsDetail).toHaveBeenCalledWith(nextDatasets) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - dataset_ids: ['dataset-2', 'dataset-3'], - query_attachment_selector: [], - multiple_retrieval_config: expect.objectContaining({ - reranking_enable: true, - reranking_model: { - provider: 'rerank-provider', - model: 'rerank-model', - }, + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + dataset_ids: ['dataset-2', 'dataset-3'], + query_attachment_selector: [], + multiple_retrieval_config: expect.objectContaining({ + reranking_enable: true, + reranking_model: { + provider: 'rerank-provider', + model: 'rerank-model', + }, + }), }), - })) + ) expect(result.current.rerankModelOpen).toBe(true) expect(result.current.showImageQueryVarSelector).toBe(false) }) it('keeps attachment selectors and skips multiple retrieval updates outside the multi-way flow', async () => { - const { inputRef, setInputs } = createState(createPayload({ - dataset_ids: [], - retrieval_mode: RETRIEVE_TYPE.oneWay, - query_attachment_selector: ['start-node', 'files'], - multiple_retrieval_config: { - top_k: 5, - score_threshold: 0.2, - }, - })) + const { inputRef, setInputs } = createState( + createPayload({ + dataset_ids: [], + retrieval_mode: RETRIEVE_TYPE.oneWay, + query_attachment_selector: ['start-node', 'files'], + multiple_retrieval_config: { + top_k: 5, + score_threshold: 0.2, + }, + }), + ) - const { result } = renderHook(() => useKnowledgeDatasetSelection({ - inputs: inputRef.current, - inputRef, - setInputs, - payloadRetrievalMode: RETRIEVE_TYPE.oneWay, - updateDatasetsDetail, - fallbackRerankModel: {}, - })) + const { result } = renderHook(() => + useKnowledgeDatasetSelection({ + inputs: inputRef.current, + inputRef, + setInputs, + payloadRetrievalMode: RETRIEVE_TYPE.oneWay, + updateDatasetsDetail, + fallbackRerankModel: {}, + }), + ) await waitFor(() => { expect(result.current.selectedDatasetsLoaded).toBe(true) @@ -258,14 +274,16 @@ describe('use-knowledge-dataset-selection', () => { result.current.setRerankModelOpen(false) }) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - dataset_ids: ['dataset-4'], - query_attachment_selector: ['start-node', 'files'], - multiple_retrieval_config: { - top_k: 5, - score_threshold: 0.2, - }, - })) + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + dataset_ids: ['dataset-4'], + query_attachment_selector: ['start-node', 'files'], + multiple_retrieval_config: { + top_k: 5, + score_threshold: 0.2, + }, + }), + ) expect(result.current.rerankModelOpen).toBe(false) expect(result.current.showImageQueryVarSelector).toBe(true) }) diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-metadata-config.spec.ts b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-metadata-config.spec.ts index bb4bfe914745f8..5fa22f43148304 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-metadata-config.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-metadata-config.spec.ts @@ -28,7 +28,9 @@ vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () const mockUseAvailableVarList = vi.mocked(useAvailableVarList) -const createPayload = (overrides: Partial<KnowledgeRetrievalNodeType> = {}): KnowledgeRetrievalNodeType => ({ +const createPayload = ( + overrides: Partial<KnowledgeRetrievalNodeType> = {}, +): KnowledgeRetrievalNodeType => ({ title: 'Knowledge Retrieval', desc: '', type: BlockEnum.KnowledgeRetrieval, @@ -62,14 +64,26 @@ describe('use-knowledge-metadata-config', () => { const activeConfig = config! if (activeConfig.filterVar({ type: VarType.string } as never, ['string-node', 'topic'])) { return { - availableVars: [{ nodeId: 'string-node', title: 'String Node', vars: [{ variable: 'topic', type: VarType.string }] }], + availableVars: [ + { + nodeId: 'string-node', + title: 'String Node', + vars: [{ variable: 'topic', type: VarType.string }], + }, + ], availableNodes: [], availableNodesWithParent: [{ id: 'string-node', data: { title: 'String Node' } }], } as unknown as ReturnType<typeof useAvailableVarList> } return { - availableVars: [{ nodeId: 'number-node', title: 'Number Node', vars: [{ variable: 'score', type: VarType.number }] }], + availableVars: [ + { + nodeId: 'number-node', + title: 'Number Node', + vars: [{ variable: 'score', type: VarType.number }], + }, + ], availableNodes: [], availableNodesWithParent: [{ id: 'number-node', data: { title: 'Number Node' } }], } as unknown as ReturnType<typeof useAvailableVarList> @@ -78,11 +92,13 @@ describe('use-knowledge-metadata-config', () => { it('manages metadata filters, conditions, model state, and available vars', () => { const { inputRef, setInputs } = createState(createPayload()) - const { result } = renderHook(() => useKnowledgeMetadataConfig({ - id: 'knowledge-node', - inputRef, - setInputs, - })) + const { result } = renderHook(() => + useKnowledgeMetadataConfig({ + id: 'knowledge-node', + inputRef, + setInputs, + }), + ) act(() => { result.current.handleMetadataFilterModeChange(MetadataFilteringModeEnum.manual) @@ -100,8 +116,10 @@ describe('use-knowledge-metadata-config', () => { }) }) - const firstCondition = setInputs.mock.calls[1]![0].metadata_filtering_conditions!.conditions[0] as MetadataFilteringCondition - const secondCondition = setInputs.mock.calls[2]![0].metadata_filtering_conditions!.conditions[1] as MetadataFilteringCondition + const firstCondition = setInputs.mock.calls[1]![0].metadata_filtering_conditions! + .conditions[0] as MetadataFilteringCondition + const secondCondition = setInputs.mock.calls[2]![0].metadata_filtering_conditions! + .conditions[1] as MetadataFilteringCondition act(() => { result.current.handleUpdateCondition(secondCondition.id, { @@ -119,27 +137,47 @@ describe('use-knowledge-metadata-config', () => { result.current.handleMetadataCompletionParamsChange({ top_p: 0.3 }) }) - expect(setInputs).toHaveBeenLastCalledWith(expect.objectContaining({ - metadata_model_config: { - provider: 'openai', - name: 'gpt-4.1-mini', - mode: AppModeEnum.CHAT, - completion_params: { top_p: 0.3 }, - }, - })) + expect(setInputs).toHaveBeenLastCalledWith( + expect.objectContaining({ + metadata_model_config: { + provider: 'openai', + name: 'gpt-4.1-mini', + mode: AppModeEnum.CHAT, + completion_params: { top_p: 0.3 }, + }, + }), + ) expect(inputRef.current.metadata_filtering_mode).toBe(MetadataFilteringModeEnum.manual) expect(inputRef.current.metadata_filtering_conditions).toEqual({ logical_operator: LogicalOperator.or, - conditions: [{ - ...secondCondition, - comparison_operator: ComparisonOperator.largerThan, - value: 0.8, - }], + conditions: [ + { + ...secondCondition, + comparison_operator: ComparisonOperator.largerThan, + value: 0.8, + }, + ], }) - expect(result.current.availableStringVars).toEqual([{ nodeId: 'string-node', title: 'String Node', vars: [{ variable: 'topic', type: VarType.string }] }]) - expect(result.current.availableNumberVars).toEqual([{ nodeId: 'number-node', title: 'Number Node', vars: [{ variable: 'score', type: VarType.number }] }]) - expect(result.current.availableStringNodesWithParent).toEqual([{ id: 'string-node', data: { title: 'String Node' } }]) - expect(result.current.availableNumberNodesWithParent).toEqual([{ id: 'number-node', data: { title: 'Number Node' } }]) + expect(result.current.availableStringVars).toEqual([ + { + nodeId: 'string-node', + title: 'String Node', + vars: [{ variable: 'topic', type: VarType.string }], + }, + ]) + expect(result.current.availableNumberVars).toEqual([ + { + nodeId: 'number-node', + title: 'Number Node', + vars: [{ variable: 'score', type: VarType.number }], + }, + ]) + expect(result.current.availableStringNodesWithParent).toEqual([ + { id: 'string-node', data: { title: 'String Node' } }, + ]) + expect(result.current.availableNumberNodesWithParent).toEqual([ + { id: 'number-node', data: { title: 'Number Node' } }, + ]) expect(result.current.filterStringVar({ type: VarType.string } as never)).toBe(true) expect(result.current.filterStringVar({ type: VarType.number } as never)).toBe(false) expect(result.current.filterFileVar({ type: VarType.file } as never)).toBe(true) @@ -151,13 +189,15 @@ describe('use-knowledge-metadata-config', () => { const initialPayload = createPayload({ metadata_filtering_conditions: { logical_operator: LogicalOperator.and, - conditions: [{ - id: 'condition-existing', - metadata_id: 'meta-1', - name: 'topic', - comparison_operator: ComparisonOperator.is, - value: 'city', - }], + conditions: [ + { + id: 'condition-existing', + metadata_id: 'meta-1', + name: 'topic', + comparison_operator: ComparisonOperator.is, + value: 'city', + }, + ], }, metadata_model_config: { provider: 'openai', @@ -167,11 +207,13 @@ describe('use-knowledge-metadata-config', () => { }, }) const { inputRef, setInputs } = createState(initialPayload) - const { result } = renderHook(() => useKnowledgeMetadataConfig({ - id: 'knowledge-node', - inputRef, - setInputs, - })) + const { result } = renderHook(() => + useKnowledgeMetadataConfig({ + id: 'knowledge-node', + inputRef, + setInputs, + }), + ) act(() => { result.current.handleRemoveCondition('missing-condition') @@ -192,13 +234,15 @@ describe('use-knowledge-metadata-config', () => { const initialPayload = createPayload({ metadata_filtering_conditions: { logical_operator: LogicalOperator.or, - conditions: [{ - id: 'condition-existing', - metadata_id: 'meta-1', - name: 'topic', - comparison_operator: ComparisonOperator.is, - value: 'city', - }], + conditions: [ + { + id: 'condition-existing', + metadata_id: 'meta-1', + name: 'topic', + comparison_operator: ComparisonOperator.is, + value: 'city', + }, + ], }, metadata_model_config: { provider: 'openai', @@ -208,11 +252,13 @@ describe('use-knowledge-metadata-config', () => { }, }) const { inputRef, setInputs } = createState(initialPayload) - const { result } = renderHook(() => useKnowledgeMetadataConfig({ - id: 'knowledge-node', - inputRef, - setInputs, - })) + const { result } = renderHook(() => + useKnowledgeMetadataConfig({ + id: 'knowledge-node', + inputRef, + setInputs, + }), + ) act(() => { result.current.handleToggleConditionLogicalOperator() @@ -222,7 +268,9 @@ describe('use-knowledge-metadata-config', () => { }) }) - expect(inputRef.current.metadata_filtering_conditions?.logical_operator).toBe(LogicalOperator.and) + expect(inputRef.current.metadata_filtering_conditions?.logical_operator).toBe( + LogicalOperator.and, + ) expect(inputRef.current.metadata_model_config).toEqual({ provider: 'anthropic', name: 'claude-sonnet', @@ -236,11 +284,13 @@ describe('use-knowledge-metadata-config', () => { metadata_filtering_conditions: undefined, }) const { inputRef, setInputs } = createState(initialPayload) - const { result } = renderHook(() => useKnowledgeMetadataConfig({ - id: 'knowledge-node', - inputRef, - setInputs, - })) + const { result } = renderHook(() => + useKnowledgeMetadataConfig({ + id: 'knowledge-node', + inputRef, + setInputs, + }), + ) act(() => { result.current.handleRemoveCondition('missing-condition') diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-model-config.spec.ts b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-model-config.spec.ts index bc68d35625f144..1bb107d9b21146 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-model-config.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-model-config.spec.ts @@ -75,7 +75,9 @@ const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ ...overrides, }) -const createPayload = (overrides: Partial<KnowledgeRetrievalNodeType> = {}): KnowledgeRetrievalNodeType => ({ +const createPayload = ( + overrides: Partial<KnowledgeRetrievalNodeType> = {}, +): KnowledgeRetrievalNodeType => ({ title: 'Knowledge Retrieval', desc: '', type: BlockEnum.KnowledgeRetrieval, @@ -115,30 +117,36 @@ describe('use-knowledge-model-config', () => { } it('creates missing single retrieval config when the model or completion params change', async () => { - const { inputRef, setInputs } = createState(createPayload({ - retrieval_mode: RETRIEVE_TYPE.multiWay, - single_retrieval_config: undefined, - multiple_retrieval_config: undefined, - })) + const { inputRef, setInputs } = createState( + createPayload({ + retrieval_mode: RETRIEVE_TYPE.multiWay, + single_retrieval_config: undefined, + multiple_retrieval_config: undefined, + }), + ) - const { result } = renderHook(() => useKnowledgeModelConfig({ - inputs: inputRef.current, - inputRef, - setInputs, - selectedDatasets, - currentProvider: undefined, - currentModel: undefined, - fallbackRerankModel: {}, - hasRerankDefaultModel: false, - })) + const { result } = renderHook(() => + useKnowledgeModelConfig({ + inputs: inputRef.current, + inputRef, + setInputs, + selectedDatasets, + currentProvider: undefined, + currentModel: undefined, + fallbackRerankModel: {}, + hasRerankDefaultModel: false, + }), + ) await waitFor(() => { - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - multiple_retrieval_config: expect.objectContaining({ - top_k: DATASET_DEFAULT.top_k, - reranking_enable: false, + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + multiple_retrieval_config: expect.objectContaining({ + top_k: DATASET_DEFAULT.top_k, + reranking_enable: false, + }), }), - })) + ) }) setInputs.mockClear() @@ -154,16 +162,19 @@ describe('use-knowledge-model-config', () => { }) }) - expect(setInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({ - single_retrieval_config: { - model: { - provider: 'anthropic', - name: 'claude-sonnet', - mode: AppModeEnum.CHAT, - completion_params: {}, + expect(setInputs).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + single_retrieval_config: { + model: { + provider: 'anthropic', + name: 'claude-sonnet', + mode: AppModeEnum.CHAT, + completion_params: {}, + }, }, - }, - })) + }), + ) setInputs.mockClear() @@ -177,56 +188,64 @@ describe('use-knowledge-model-config', () => { }) expect(setInputs).toHaveBeenCalledTimes(1) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - single_retrieval_config: { - model: { - provider: '', - name: '', - mode: '', - completion_params: { temperature: 0.2 }, + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + single_retrieval_config: { + model: { + provider: '', + name: '', + mode: '', + completion_params: { temperature: 0.2 }, + }, }, - }, - })) + }), + ) }) it('hydrates defaults, respects initialized rerank state, and updates retrieval config changes', async () => { - const { inputRef, setInputs } = createState(createPayload({ - retrieval_mode: RETRIEVE_TYPE.oneWay, - single_retrieval_config: undefined, - multiple_retrieval_config: undefined, - })) + const { inputRef, setInputs } = createState( + createPayload({ + retrieval_mode: RETRIEVE_TYPE.oneWay, + single_retrieval_config: undefined, + multiple_retrieval_config: undefined, + }), + ) - const { result } = renderHook(() => useKnowledgeModelConfig({ - inputs: inputRef.current, - inputRef, - setInputs, - selectedDatasets, - currentProvider: { provider: 'openai' }, - currentModel: { - model: 'gpt-4o-mini', - model_properties: { - mode: AppModeEnum.CHAT, + const { result } = renderHook(() => + useKnowledgeModelConfig({ + inputs: inputRef.current, + inputRef, + setInputs, + selectedDatasets, + currentProvider: { provider: 'openai' }, + currentModel: { + model: 'gpt-4o-mini', + model_properties: { + mode: AppModeEnum.CHAT, + }, }, - }, - fallbackRerankModel, - hasRerankDefaultModel: true, - })) + fallbackRerankModel, + hasRerankDefaultModel: true, + }), + ) await waitFor(() => { - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - single_retrieval_config: { - model: { - provider: 'openai', - name: 'gpt-4o-mini', - mode: AppModeEnum.CHAT, - completion_params: {}, + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + single_retrieval_config: { + model: { + provider: 'openai', + name: 'gpt-4o-mini', + mode: AppModeEnum.CHAT, + completion_params: {}, + }, }, - }, - multiple_retrieval_config: expect.objectContaining({ - top_k: DATASET_DEFAULT.top_k, - reranking_enable: true, + multiple_retrieval_config: expect.objectContaining({ + top_k: DATASET_DEFAULT.top_k, + reranking_enable: true, + }), }), - })) + ) }) setInputs.mockClear() @@ -235,17 +254,19 @@ describe('use-knowledge-model-config', () => { result.current.handleRetrievalModeChange(RETRIEVE_TYPE.multiWay) }) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - retrieval_mode: RETRIEVE_TYPE.multiWay, - multiple_retrieval_config: expect.objectContaining({ - top_k: DATASET_DEFAULT.top_k, - reranking_enable: true, - reranking_model: { - provider: 'rerank-provider', - model: 'rerank-model', - }, + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + retrieval_mode: RETRIEVE_TYPE.multiWay, + multiple_retrieval_config: expect.objectContaining({ + top_k: DATASET_DEFAULT.top_k, + reranking_enable: true, + reranking_model: { + provider: 'rerank-provider', + model: 'rerank-model', + }, + }), }), - })) + ) setInputs.mockClear() @@ -256,13 +277,15 @@ describe('use-knowledge-model-config', () => { }) }) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - multiple_retrieval_config: expect.objectContaining({ - top_k: 8, - score_threshold: 0.4, - reranking_enable: true, + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + multiple_retrieval_config: expect.objectContaining({ + top_k: 8, + score_threshold: 0.4, + reranking_enable: true, + }), }), - })) + ) setInputs.mockClear() @@ -278,54 +301,60 @@ describe('use-knowledge-model-config', () => { result.current.handleRetrievalModeChange(RETRIEVE_TYPE.oneWay) }) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - retrieval_mode: RETRIEVE_TYPE.oneWay, - single_retrieval_config: { - model: { - provider: 'openai', - name: 'gpt-4o-mini', - mode: AppModeEnum.CHAT, - completion_params: {}, + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + retrieval_mode: RETRIEVE_TYPE.oneWay, + single_retrieval_config: { + model: { + provider: 'openai', + name: 'gpt-4o-mini', + mode: AppModeEnum.CHAT, + completion_params: {}, + }, }, - }, - })) + }), + ) - const configuredState = createState(createPayload({ - retrieval_mode: RETRIEVE_TYPE.oneWay, - single_retrieval_config: { - model: { - provider: 'openai', - name: 'gpt-4o-mini', - mode: AppModeEnum.CHAT, - completion_params: {}, + const configuredState = createState( + createPayload({ + retrieval_mode: RETRIEVE_TYPE.oneWay, + single_retrieval_config: { + model: { + provider: 'openai', + name: 'gpt-4o-mini', + mode: AppModeEnum.CHAT, + completion_params: {}, + }, }, - }, - multiple_retrieval_config: { - top_k: 6, - score_threshold: 0.1, - reranking_enable: true, - reranking_model: { - provider: 'rerank-provider', - model: 'rerank-model', + multiple_retrieval_config: { + top_k: 6, + score_threshold: 0.1, + reranking_enable: true, + reranking_model: { + provider: 'rerank-provider', + model: 'rerank-model', + }, }, - }, - })) + }), + ) - renderHook(() => useKnowledgeModelConfig({ - inputs: configuredState.inputRef.current, - inputRef: configuredState.inputRef, - setInputs: configuredState.setInputs, - selectedDatasets, - currentProvider: { provider: 'openai' }, - currentModel: { - model: 'gpt-4o-mini', - model_properties: { - mode: AppModeEnum.CHAT, + renderHook(() => + useKnowledgeModelConfig({ + inputs: configuredState.inputRef.current, + inputRef: configuredState.inputRef, + setInputs: configuredState.setInputs, + selectedDatasets, + currentProvider: { provider: 'openai' }, + currentModel: { + model: 'gpt-4o-mini', + model_properties: { + mode: AppModeEnum.CHAT, + }, }, - }, - fallbackRerankModel, - hasRerankDefaultModel: true, - })) + fallbackRerankModel, + hasRerankDefaultModel: true, + }), + ) await act(async () => { await Promise.resolve() diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-dataset-selection.ts b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-dataset-selection.ts index 3a8025976be36a..70466798adbc05 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-dataset-selection.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-dataset-selection.ts @@ -1,15 +1,8 @@ import type { MutableRefObject } from 'react' import type { KnowledgeRetrievalNodeType } from '../types' import type { DataSet } from '@/models/datasets' -import { - produce, -} from 'immer' -import { - useCallback, - useEffect, - useMemo, - useState, -} from 'react' +import { produce } from 'immer' +import { useCallback, useEffect, useMemo, useState } from 'react' import { fetchDatasets } from '@/service/datasets' import { RETRIEVE_TYPE } from '@/types/app' import { getMultipleRetrievalConfig, getSelectedDatasetsMode } from '../utils' @@ -63,47 +56,56 @@ const useKnowledgeDatasetSelection = ({ })() }, [inputRef, setInputs]) - const handleOnDatasetsChange = useCallback((newDatasets: DataSet[]) => { - const { - mixtureHighQualityAndEconomic, - mixtureInternalAndExternal, - inconsistentEmbeddingModel, - allInternal, - allExternal, - } = getSelectedDatasetsMode(newDatasets) - const noMultiModalDatasets = newDatasets.every(dataset => !dataset.is_multimodal) + const handleOnDatasetsChange = useCallback( + (newDatasets: DataSet[]) => { + const { + mixtureHighQualityAndEconomic, + mixtureInternalAndExternal, + inconsistentEmbeddingModel, + allInternal, + allExternal, + } = getSelectedDatasetsMode(newDatasets) + const noMultiModalDatasets = newDatasets.every((dataset) => !dataset.is_multimodal) - const nextInputs = produce(inputs, (draft) => { - draft.dataset_ids = newDatasets.map(dataset => dataset.id) + const nextInputs = produce(inputs, (draft) => { + draft.dataset_ids = newDatasets.map((dataset) => dataset.id) - if (payloadRetrievalMode === RETRIEVE_TYPE.multiWay && newDatasets.length > 0) { - draft.multiple_retrieval_config = getMultipleRetrievalConfig( - draft.multiple_retrieval_config!, - newDatasets, - selectedDatasets, - fallbackRerankModel, - ) - } + if (payloadRetrievalMode === RETRIEVE_TYPE.multiWay && newDatasets.length > 0) { + draft.multiple_retrieval_config = getMultipleRetrievalConfig( + draft.multiple_retrieval_config!, + newDatasets, + selectedDatasets, + fallbackRerankModel, + ) + } - if (noMultiModalDatasets) - draft.query_attachment_selector = [] - }) + if (noMultiModalDatasets) draft.query_attachment_selector = [] + }) - updateDatasetsDetail(newDatasets) - setInputs(nextInputs) - setSelectedDatasets(newDatasets) + updateDatasetsDetail(newDatasets) + setInputs(nextInputs) + setSelectedDatasets(newDatasets) - if ( - (allInternal && (mixtureHighQualityAndEconomic || inconsistentEmbeddingModel)) - || mixtureInternalAndExternal - || allExternal - ) { - setRerankModelOpen(true) - } - }, [fallbackRerankModel, inputs, payloadRetrievalMode, selectedDatasets, setInputs, updateDatasetsDetail]) + if ( + (allInternal && (mixtureHighQualityAndEconomic || inconsistentEmbeddingModel)) || + mixtureInternalAndExternal || + allExternal + ) { + setRerankModelOpen(true) + } + }, + [ + fallbackRerankModel, + inputs, + payloadRetrievalMode, + selectedDatasets, + setInputs, + updateDatasetsDetail, + ], + ) const showImageQueryVarSelector = useMemo(() => { - return selectedDatasets.some(dataset => dataset.is_multimodal) + return selectedDatasets.some((dataset) => dataset.is_multimodal) }, [selectedDatasets]) return { diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-input-manager.ts b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-input-manager.ts index b1a957a43807e7..ec094b5da6783a 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-input-manager.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-input-manager.ts @@ -2,11 +2,7 @@ import type { MutableRefObject } from 'react' import type { KnowledgeRetrievalNodeType } from '../types' import type { ValueSelector } from '@/app/components/workflow/types' import { produce } from 'immer' -import { - useCallback, - useEffect, - useRef, -} from 'react' +import { useCallback, useEffect, useRef } from 'react' import { RETRIEVE_TYPE } from '@/types/app' type Params = { @@ -16,42 +12,46 @@ type Params = { const normalizeInputs = (nextInputs: KnowledgeRetrievalNodeType) => { return produce(nextInputs, (draft) => { - if (nextInputs.retrieval_mode === RETRIEVE_TYPE.multiWay) - delete draft.single_retrieval_config - else - delete draft.multiple_retrieval_config + if (nextInputs.retrieval_mode === RETRIEVE_TYPE.multiWay) delete draft.single_retrieval_config + else delete draft.multiple_retrieval_config }) } -const useKnowledgeInputManager = ({ - inputs, - doSetInputs, -}: Params) => { +const useKnowledgeInputManager = ({ inputs, doSetInputs }: Params) => { const inputRef = useRef(inputs) useEffect(() => { inputRef.current = inputs }, [inputs]) - const setInputs = useCallback((nextInputs: KnowledgeRetrievalNodeType) => { - const normalizedInputs = normalizeInputs(nextInputs) - doSetInputs(normalizedInputs) - inputRef.current = normalizedInputs - }, [doSetInputs]) + const setInputs = useCallback( + (nextInputs: KnowledgeRetrievalNodeType) => { + const normalizedInputs = normalizeInputs(nextInputs) + doSetInputs(normalizedInputs) + inputRef.current = normalizedInputs + }, + [doSetInputs], + ) - const handleQueryVarChange = useCallback((newVar: ValueSelector | string) => { - const nextInputs = produce(inputRef.current, (draft) => { - draft.query_variable_selector = newVar as ValueSelector - }) - setInputs(nextInputs) - }, [setInputs]) + const handleQueryVarChange = useCallback( + (newVar: ValueSelector | string) => { + const nextInputs = produce(inputRef.current, (draft) => { + draft.query_variable_selector = newVar as ValueSelector + }) + setInputs(nextInputs) + }, + [setInputs], + ) - const handleQueryAttachmentChange = useCallback((newVar: ValueSelector | string) => { - const nextInputs = produce(inputRef.current, (draft) => { - draft.query_attachment_selector = newVar as ValueSelector - }) - setInputs(nextInputs) - }, [setInputs]) + const handleQueryAttachmentChange = useCallback( + (newVar: ValueSelector | string) => { + const nextInputs = produce(inputRef.current, (draft) => { + draft.query_attachment_selector = newVar as ValueSelector + }) + setInputs(nextInputs) + }, + [setInputs], + ) return { inputRef: inputRef as MutableRefObject<KnowledgeRetrievalNodeType>, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-metadata-config.ts b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-metadata-config.ts index e29e581c9ddafd..84b087cf9f5bbb 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-metadata-config.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-metadata-config.ts @@ -14,11 +14,7 @@ import { v4 as uuid4 } from 'uuid' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import { VarType } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' -import { - ComparisonOperator, - LogicalOperator, - MetadataFilteringVariableType, -} from '../types' +import { ComparisonOperator, LogicalOperator, MetadataFilteringVariableType } from '../types' type Params = { id: string @@ -38,95 +34,108 @@ const filterFileVar = (varPayload: Var) => { return varPayload.type === VarType.file || varPayload.type === VarType.arrayFile } -const useKnowledgeMetadataConfig = ({ - id, - inputRef, - setInputs, -}: Params) => { - const handleMetadataFilterModeChange = useCallback((newMode: MetadataFilteringModeEnum) => { - const nextInputs = produce(inputRef.current, (draft) => { - draft.metadata_filtering_mode = newMode - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) - - const handleAddCondition = useCallback<HandleAddCondition>(({ id, name, type }) => { - const comparisonOperator = type === MetadataFilteringVariableType.number - ? ComparisonOperator.equal - : ComparisonOperator.is - - const nextInputs = produce(inputRef.current, (draft) => { - const newCondition = { - id: uuid4(), - metadata_id: id, - name, - comparison_operator: comparisonOperator, - } - - if (draft.metadata_filtering_conditions) { - draft.metadata_filtering_conditions.conditions.push(newCondition) - return - } - - draft.metadata_filtering_conditions = { - logical_operator: LogicalOperator.and, - conditions: [newCondition], - } - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) - - const handleRemoveCondition = useCallback<HandleRemoveCondition>((conditionId) => { - const nextInputs = produce(inputRef.current, (draft) => { - const conditions = draft.metadata_filtering_conditions?.conditions || [] - const index = conditions.findIndex(condition => condition.id === conditionId) - if (index > -1) - draft.metadata_filtering_conditions?.conditions.splice(index, 1) - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) - - const handleUpdateCondition = useCallback<HandleUpdateCondition>((conditionId, newCondition) => { - const nextInputs = produce(inputRef.current, (draft) => { - const conditions = draft.metadata_filtering_conditions?.conditions || [] - const index = conditions.findIndex(condition => condition.id === conditionId) - if (index > -1) - draft.metadata_filtering_conditions!.conditions[index] = newCondition - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) - - const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>(() => { - const nextInputs = produce(inputRef.current, (draft) => { - const currentLogicalOperator = draft.metadata_filtering_conditions?.logical_operator - draft.metadata_filtering_conditions!.logical_operator = currentLogicalOperator === LogicalOperator.and - ? LogicalOperator.or - : LogicalOperator.and - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) - - const handleMetadataModelChange = useCallback((model: { provider: string, modelId: string, mode?: string }) => { - const nextInputs = produce(inputRef.current, (draft) => { - draft.metadata_model_config = { - provider: model.provider, - name: model.modelId, - mode: model.mode || AppModeEnum.CHAT, - completion_params: draft.metadata_model_config?.completion_params || { temperature: 0.7 }, - } - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) - - const handleMetadataCompletionParamsChange = useCallback((newParams: Record<string, unknown>) => { - const nextInputs = produce(inputRef.current, (draft) => { - draft.metadata_model_config = { - ...draft.metadata_model_config!, - completion_params: newParams, - } - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) +const useKnowledgeMetadataConfig = ({ id, inputRef, setInputs }: Params) => { + const handleMetadataFilterModeChange = useCallback( + (newMode: MetadataFilteringModeEnum) => { + const nextInputs = produce(inputRef.current, (draft) => { + draft.metadata_filtering_mode = newMode + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) + + const handleAddCondition = useCallback<HandleAddCondition>( + ({ id, name, type }) => { + const comparisonOperator = + type === MetadataFilteringVariableType.number + ? ComparisonOperator.equal + : ComparisonOperator.is + + const nextInputs = produce(inputRef.current, (draft) => { + const newCondition = { + id: uuid4(), + metadata_id: id, + name, + comparison_operator: comparisonOperator, + } + + if (draft.metadata_filtering_conditions) { + draft.metadata_filtering_conditions.conditions.push(newCondition) + return + } + + draft.metadata_filtering_conditions = { + logical_operator: LogicalOperator.and, + conditions: [newCondition], + } + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) + + const handleRemoveCondition = useCallback<HandleRemoveCondition>( + (conditionId) => { + const nextInputs = produce(inputRef.current, (draft) => { + const conditions = draft.metadata_filtering_conditions?.conditions || [] + const index = conditions.findIndex((condition) => condition.id === conditionId) + if (index > -1) draft.metadata_filtering_conditions?.conditions.splice(index, 1) + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) + + const handleUpdateCondition = useCallback<HandleUpdateCondition>( + (conditionId, newCondition) => { + const nextInputs = produce(inputRef.current, (draft) => { + const conditions = draft.metadata_filtering_conditions?.conditions || [] + const index = conditions.findIndex((condition) => condition.id === conditionId) + if (index > -1) draft.metadata_filtering_conditions!.conditions[index] = newCondition + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) + + const handleToggleConditionLogicalOperator = + useCallback<HandleToggleConditionLogicalOperator>(() => { + const nextInputs = produce(inputRef.current, (draft) => { + const currentLogicalOperator = draft.metadata_filtering_conditions?.logical_operator + draft.metadata_filtering_conditions!.logical_operator = + currentLogicalOperator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and + }) + setInputs(nextInputs) + }, [inputRef, setInputs]) + + const handleMetadataModelChange = useCallback( + (model: { provider: string; modelId: string; mode?: string }) => { + const nextInputs = produce(inputRef.current, (draft) => { + draft.metadata_model_config = { + provider: model.provider, + name: model.modelId, + mode: model.mode || AppModeEnum.CHAT, + completion_params: draft.metadata_model_config?.completion_params || { temperature: 0.7 }, + } + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) + + const handleMetadataCompletionParamsChange = useCallback( + (newParams: Record<string, unknown>) => { + const nextInputs = produce(inputRef.current, (draft) => { + draft.metadata_model_config = { + ...draft.metadata_model_config!, + completion_params: newParams, + } + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) const { availableVars: availableStringVars, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-model-config.ts b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-model-config.ts index a3d3751f36a06e..343396a5530e57 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-model-config.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-model-config.ts @@ -1,21 +1,12 @@ import type { MutableRefObject } from 'react' -import type { - KnowledgeRetrievalNodeType, - MultipleRetrievalConfig, -} from '../types' +import type { KnowledgeRetrievalNodeType, MultipleRetrievalConfig } from '../types' import type { ModelConfig } from '@/app/components/workflow/types' import type { DataSet } from '@/models/datasets' import { isEqual } from 'es-toolkit/predicate' import { produce } from 'immer' -import { - useCallback, - useEffect, -} from 'react' +import { useCallback, useEffect } from 'react' import { DATASET_DEFAULT } from '@/config' -import { - AppModeEnum, - RETRIEVE_TYPE, -} from '@/types/app' +import { AppModeEnum, RETRIEVE_TYPE } from '@/types/app' import { getMultipleRetrievalConfig } from '../utils' type ModelIdentity = { @@ -45,7 +36,9 @@ type Params = { hasRerankDefaultModel: boolean } -const createSingleRetrievalConfig = (model: ModelConfig): KnowledgeRetrievalNodeType['single_retrieval_config'] => ({ +const createSingleRetrievalConfig = ( + model: ModelConfig, +): KnowledgeRetrievalNodeType['single_retrieval_config'] => ({ model, }) @@ -59,56 +52,65 @@ const useKnowledgeModelConfig = ({ fallbackRerankModel, hasRerankDefaultModel, }: Params) => { - const handleModelChanged = useCallback((model: { provider: string, modelId: string, mode?: string }) => { - const nextInputs = produce(inputRef.current, (draft) => { - if (!draft.single_retrieval_config) { - draft.single_retrieval_config = createSingleRetrievalConfig({ - provider: '', - name: '', - mode: '', - completion_params: {}, - }) - } - - const draftModel = draft.single_retrieval_config!.model - draftModel.provider = model.provider - draftModel.name = model.modelId - draftModel.mode = model.mode || AppModeEnum.CHAT - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) + const handleModelChanged = useCallback( + (model: { provider: string; modelId: string; mode?: string }) => { + const nextInputs = produce(inputRef.current, (draft) => { + if (!draft.single_retrieval_config) { + draft.single_retrieval_config = createSingleRetrievalConfig({ + provider: '', + name: '', + mode: '', + completion_params: {}, + }) + } - const handleCompletionParamsChange = useCallback((newParams: Record<string, unknown>) => { - if (isEqual(newParams, inputRef.current.single_retrieval_config?.model.completion_params)) - return + const draftModel = draft.single_retrieval_config!.model + draftModel.provider = model.provider + draftModel.name = model.modelId + draftModel.mode = model.mode || AppModeEnum.CHAT + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) + + const handleCompletionParamsChange = useCallback( + (newParams: Record<string, unknown>) => { + if (isEqual(newParams, inputRef.current.single_retrieval_config?.model.completion_params)) + return - const nextInputs = produce(inputRef.current, (draft) => { - if (!draft.single_retrieval_config) { - draft.single_retrieval_config = createSingleRetrievalConfig({ - provider: '', - name: '', - mode: '', - completion_params: {}, - }) - } + const nextInputs = produce(inputRef.current, (draft) => { + if (!draft.single_retrieval_config) { + draft.single_retrieval_config = createSingleRetrievalConfig({ + provider: '', + name: '', + mode: '', + completion_params: {}, + }) + } - draft.single_retrieval_config!.model.completion_params = newParams - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) + draft.single_retrieval_config!.model.completion_params = newParams + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) useEffect(() => { const currentInputs = inputRef.current if ( - currentInputs.retrieval_mode === RETRIEVE_TYPE.multiWay - && currentInputs.multiple_retrieval_config?.reranking_model?.provider - && fallbackRerankModel.model - && hasRerankDefaultModel + currentInputs.retrieval_mode === RETRIEVE_TYPE.multiWay && + currentInputs.multiple_retrieval_config?.reranking_model?.provider && + fallbackRerankModel.model && + hasRerankDefaultModel ) { return } - if (currentInputs.retrieval_mode === RETRIEVE_TYPE.oneWay && currentInputs.single_retrieval_config?.model?.provider) + if ( + currentInputs.retrieval_mode === RETRIEVE_TYPE.oneWay && + currentInputs.single_retrieval_config?.model?.provider + ) return const nextInputs = produce(currentInputs, (draft) => { @@ -131,51 +133,73 @@ const useKnowledgeModelConfig = ({ reranking_model: multipleRetrievalConfig?.reranking_model, reranking_mode: multipleRetrievalConfig?.reranking_mode, weights: multipleRetrievalConfig?.weights, - reranking_enable: multipleRetrievalConfig?.reranking_enable !== undefined - ? multipleRetrievalConfig.reranking_enable - : Boolean(fallbackRerankModel.model && hasRerankDefaultModel), + reranking_enable: + multipleRetrievalConfig?.reranking_enable !== undefined + ? multipleRetrievalConfig.reranking_enable + : Boolean(fallbackRerankModel.model && hasRerankDefaultModel), } }) setInputs(nextInputs) - }, [currentModel, currentProvider?.provider, fallbackRerankModel.model, hasRerankDefaultModel, inputRef, setInputs]) + }, [ + currentModel, + currentProvider?.provider, + fallbackRerankModel.model, + hasRerankDefaultModel, + inputRef, + setInputs, + ]) + + const handleRetrievalModeChange = useCallback( + (newMode: RETRIEVE_TYPE) => { + const nextInputs = produce(inputs, (draft) => { + draft.retrieval_mode = newMode + if (newMode === RETRIEVE_TYPE.multiWay) { + draft.multiple_retrieval_config = getMultipleRetrievalConfig( + draft.multiple_retrieval_config as MultipleRetrievalConfig, + selectedDatasets, + selectedDatasets, + fallbackRerankModel, + ) + return + } - const handleRetrievalModeChange = useCallback((newMode: RETRIEVE_TYPE) => { - const nextInputs = produce(inputs, (draft) => { - draft.retrieval_mode = newMode - if (newMode === RETRIEVE_TYPE.multiWay) { + const hasSetModel = draft.single_retrieval_config?.model?.provider + if (!hasSetModel) { + draft.single_retrieval_config = createSingleRetrievalConfig({ + provider: currentProvider?.provider || '', + name: currentModel?.model || '', + mode: currentModel?.model_properties?.mode || AppModeEnum.CHAT, + completion_params: {}, + }) + } + }) + setInputs(nextInputs) + }, + [ + currentModel?.model, + currentModel?.model_properties?.mode, + currentProvider?.provider, + fallbackRerankModel, + inputs, + selectedDatasets, + setInputs, + ], + ) + + const handleMultipleRetrievalConfigChange = useCallback( + (newConfig: MultipleRetrievalConfig) => { + const nextInputs = produce(inputs, (draft) => { draft.multiple_retrieval_config = getMultipleRetrievalConfig( - draft.multiple_retrieval_config as MultipleRetrievalConfig, + newConfig, selectedDatasets, selectedDatasets, fallbackRerankModel, ) - return - } - - const hasSetModel = draft.single_retrieval_config?.model?.provider - if (!hasSetModel) { - draft.single_retrieval_config = createSingleRetrievalConfig({ - provider: currentProvider?.provider || '', - name: currentModel?.model || '', - mode: currentModel?.model_properties?.mode || AppModeEnum.CHAT, - completion_params: {}, - }) - } - }) - setInputs(nextInputs) - }, [currentModel?.model, currentModel?.model_properties?.mode, currentProvider?.provider, fallbackRerankModel, inputs, selectedDatasets, setInputs]) - - const handleMultipleRetrievalConfigChange = useCallback((newConfig: MultipleRetrievalConfig) => { - const nextInputs = produce(inputs, (draft) => { - draft.multiple_retrieval_config = getMultipleRetrievalConfig( - newConfig, - selectedDatasets, - selectedDatasets, - fallbackRerankModel, - ) - }) - setInputs(nextInputs) - }, [fallbackRerankModel, inputs, selectedDatasets, setInputs]) + }) + setInputs(nextInputs) + }, + [fallbackRerankModel, inputs, selectedDatasets, setInputs], + ) return { handleModelChanged, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/node.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/node.tsx index 82fee22eee22be..edb37315318d3f 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/node.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/node.tsx @@ -7,28 +7,23 @@ import { useEffect, useState } from 'react' import AppIcon from '@/app/components/base/app-icon' import { useDatasetsDetailStore } from '../../datasets-detail-store/store' -const Node: FC<NodeProps<KnowledgeRetrievalNodeType>> = ({ - data, -}) => { +const Node: FC<NodeProps<KnowledgeRetrievalNodeType>> = ({ data }) => { const [selectedDatasets, setSelectedDatasets] = useState<DataSet[]>([]) - const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail) + const datasetsDetail = useDatasetsDetailStore((s) => s.datasetsDetail) useEffect(() => { if (data.dataset_ids?.length > 0) { const dataSetsWithDetail = data.dataset_ids.reduce<DataSet[]>((acc, id) => { - if (datasetsDetail[id]) - acc.push(datasetsDetail[id]) + if (datasetsDetail[id]) acc.push(datasetsDetail[id]) return acc }, []) setSelectedDatasets(dataSetsWithDetail) - } - else { + } else { setSelectedDatasets([]) } }, [data.dataset_ids, datasetsDetail]) - if (!selectedDatasets.length) - return null + if (!selectedDatasets.length) return null return ( <div className="mb-1 px-3 py-1"> @@ -41,7 +36,10 @@ const Node: FC<NodeProps<KnowledgeRetrievalNodeType>> = ({ icon_url: '', } return ( - <div key={id} className="flex h-[26px] items-center gap-x-1 rounded-md bg-workflow-block-parma-bg px-1"> + <div + key={id} + className="flex h-[26px] items-center gap-x-1 rounded-md bg-workflow-block-parma-bg px-1" + > <AppIcon size="xs" iconType={iconInfo.icon_type} @@ -50,9 +48,7 @@ const Node: FC<NodeProps<KnowledgeRetrievalNodeType>> = ({ imageUrl={iconInfo.icon_type === 'image' ? iconInfo.icon_url : undefined} className="shrink-0" /> - <div className="w-0 grow truncate system-xs-regular text-text-secondary"> - {name} - </div> + <div className="w-0 grow truncate system-xs-regular text-text-secondary">{name}</div> </div> ) })} diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/panel.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/panel.tsx index 4397ede3a50fef..8279705b02e86d 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/panel.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/panel.tsx @@ -2,10 +2,7 @@ import type { FC } from 'react' import type { KnowledgeRetrievalNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' import { intersectionBy } from 'es-toolkit/compat' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import Field from '@/app/components/workflow/nodes/_base/components/field' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' @@ -19,10 +16,7 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.knowledgeRetrieval' -const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { @@ -56,17 +50,22 @@ const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({ } = useConfig(id, data) const metadataList = useMemo(() => { - return intersectionBy(...selectedDatasets.filter((dataset) => { - return !!dataset.doc_metadata - }).map((dataset) => { - return dataset.doc_metadata! - }), 'name') + return intersectionBy( + ...selectedDatasets + .filter((dataset) => { + return !!dataset.doc_metadata + }) + .map((dataset) => { + return dataset.doc_metadata! + }), + 'name', + ) }, [selectedDatasets]) return ( <div className="pt-2"> <div className="space-y-4 px-4 pb-2"> - <Field title={t($ => $[`${i18nPrefix}.queryText`], { ns: 'workflow' })}> + <Field title={t(($) => $[`${i18nPrefix}.queryText`], { ns: 'workflow' })}> <VarReferencePicker nodeId={id} readonly={readOnly} @@ -78,7 +77,7 @@ const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({ </Field> {showImageQueryVarSelector && ( - <Field title={t($ => $[`${i18nPrefix}.queryAttachment`], { ns: 'workflow' })}> + <Field title={t(($) => $[`${i18nPrefix}.queryAttachment`], { ns: 'workflow' })}> <VarReferencePicker nodeId={id} readonly={readOnly} @@ -91,9 +90,9 @@ const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({ )} <Field - title={t($ => $[`${i18nPrefix}.knowledge`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.knowledge`], { ns: 'workflow' })} required - operations={( + operations={ <div className="flex items-center space-x-1"> <RetrievalConfig payload={{ @@ -111,15 +110,12 @@ const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({ onRerankModelOpenChange={setRerankModelOpen} selectedDatasets={selectedDatasets} /> - {!readOnly && (<div className="h-3 w-px bg-divider-regular"></div>)} + {!readOnly && <div className="h-3 w-px bg-divider-regular"></div>} {!readOnly && ( - <AddKnowledge - selectedIds={inputs.dataset_ids} - onChange={handleOnDatasetsChange} - /> + <AddKnowledge selectedIds={inputs.dataset_ids} onChange={handleOnDatasetsChange} /> )} </div> - )} + } > <DatasetList list={selectedDatasets} @@ -155,42 +151,41 @@ const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({ <VarItem name="result" type="Array[Object]" - description={t($ => $[`${i18nPrefix}.outputVars.output`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.output`], { ns: 'workflow' })} subItems={[ { name: 'content', type: 'string', - description: t($ => $[`${i18nPrefix}.outputVars.content`], { ns: 'workflow' }), + description: t(($) => $[`${i18nPrefix}.outputVars.content`], { ns: 'workflow' }), }, // url, title, link like bing search reference result: link, link page title, link page icon { name: 'title', type: 'string', - description: t($ => $[`${i18nPrefix}.outputVars.title`], { ns: 'workflow' }), + description: t(($) => $[`${i18nPrefix}.outputVars.title`], { ns: 'workflow' }), }, { name: 'url', type: 'string', - description: t($ => $[`${i18nPrefix}.outputVars.url`], { ns: 'workflow' }), + description: t(($) => $[`${i18nPrefix}.outputVars.url`], { ns: 'workflow' }), }, { name: 'icon', type: 'string', - description: t($ => $[`${i18nPrefix}.outputVars.icon`], { ns: 'workflow' }), + description: t(($) => $[`${i18nPrefix}.outputVars.icon`], { ns: 'workflow' }), }, { name: 'metadata', type: 'object', - description: t($ => $[`${i18nPrefix}.outputVars.metadata`], { ns: 'workflow' }), + description: t(($) => $[`${i18nPrefix}.outputVars.metadata`], { ns: 'workflow' }), }, { name: 'files', type: 'Array[File]', - description: t($ => $[`${i18nPrefix}.outputVars.files`], { ns: 'workflow' }), + description: t(($) => $[`${i18nPrefix}.outputVars.files`], { ns: 'workflow' }), }, ]} /> - </> </OutputVars> </div> diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/types.ts b/web/app/components/workflow/nodes/knowledge-retrieval/types.ts index b28c49bffb3b79..766311cdc50762 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/types.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/types.ts @@ -123,13 +123,18 @@ export type MetadataShape = { handleToggleConditionLogicalOperator: HandleToggleConditionLogicalOperator handleUpdateCondition: HandleUpdateCondition metadataModelConfig?: ModelConfig - handleMetadataModelChange?: (model: { modelId: string, provider: string, mode?: string, features?: string[] }) => void + handleMetadataModelChange?: (model: { + modelId: string + provider: string + mode?: string + features?: string[] + }) => void handleMetadataCompletionParamsChange?: (params: Record<string, any>) => void availableStringVars?: NodeOutPutVar[] availableStringNodesWithParent?: Node[] availableNumberVars?: NodeOutPutVar[] availableNumberNodesWithParent?: Node[] isCommonVariable?: boolean - availableCommonStringVars?: { name: string, type: string, value: string }[] - availableCommonNumberVars?: { name: string, type: string, value: string }[] + availableCommonStringVars?: { name: string; type: string; value: string }[] + availableCommonNumberVars?: { name: string; type: string; value: string }[] } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts b/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts index da9b45017e5abe..a95eac3d97ff66 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts @@ -1,19 +1,15 @@ import type { ValueSelector } from '../../types' import type { KnowledgeRetrievalNodeType } from './types' import { produce } from 'immer' -import { - useEffect, - useMemo, -} from 'react' +import { useEffect, useMemo } from 'react' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { + useCurrentProviderAndModel, + useModelListAndDefaultModelAndCurrentProviderAndModel, +} from '@/app/components/header/account-setting/model-provider-page/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useDatasetsDetailStore } from '../../datasets-detail-store/store' -import { - useIsChatMode, - useNodesReadOnly, - useWorkflow, -} from '../../hooks' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks' import { BlockEnum } from '../../types' import useKnowledgeDatasetSelection from './hooks/use-knowledge-dataset-selection' import useKnowledgeInputManager from './hooks/use-knowledge-input-manager' @@ -24,47 +20,43 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() const isChatMode = useIsChatMode() const { getBeforeNodesInSameBranch } = useWorkflow() - const startNode = getBeforeNodesInSameBranch(id).find(node => node.data.type === BlockEnum.Start) + const startNode = getBeforeNodesInSameBranch(id).find( + (node) => node.data.type === BlockEnum.Start, + ) const startNodeId = startNode?.id const { inputs, setInputs: doSetInputs } = useNodeCrud<KnowledgeRetrievalNodeType>(id, payload) - const updateDatasetsDetail = useDatasetsDetailStore(s => s.updateDatasetsDetail) - const { - inputRef, - setInputs, - handleQueryVarChange, - handleQueryAttachmentChange, - } = useKnowledgeInputManager({ - inputs, - doSetInputs, - }) + const updateDatasetsDetail = useDatasetsDetailStore((s) => s.updateDatasetsDetail) + const { inputRef, setInputs, handleQueryVarChange, handleQueryAttachmentChange } = + useKnowledgeInputManager({ + inputs, + doSetInputs, + }) - const { - currentProvider, - currentModel, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) + const { currentProvider, currentModel } = useModelListAndDefaultModelAndCurrentProviderAndModel( + ModelTypeEnum.textGeneration, + ) - const { - modelList: rerankModelList, - defaultModel: rerankDefaultModel, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) + const { modelList: rerankModelList, defaultModel: rerankDefaultModel } = + useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) - const { - currentModel: currentRerankModel, - currentProvider: currentRerankProvider, - } = useCurrentProviderAndModel( - rerankModelList, - rerankDefaultModel - ? { - ...rerankDefaultModel, - provider: rerankDefaultModel.provider.provider, - } - : undefined, - ) + const { currentModel: currentRerankModel, currentProvider: currentRerankProvider } = + useCurrentProviderAndModel( + rerankModelList, + rerankDefaultModel + ? { + ...rerankDefaultModel, + provider: rerankDefaultModel.provider.provider, + } + : undefined, + ) - const fallbackRerankModel = useMemo(() => ({ - provider: currentRerankProvider?.provider, - model: currentRerankModel?.model, - }), [currentRerankModel?.model, currentRerankProvider?.provider]) + const fallbackRerankModel = useMemo( + () => ({ + provider: currentRerankProvider?.provider, + model: currentRerankModel?.model, + }), + [currentRerankModel?.model, currentRerankProvider?.provider], + ) const { selectedDatasets, @@ -104,9 +96,11 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => { if (isChatMode && currentInputs.query_variable_selector.length === 0 && startNodeId) nextQueryVariableSelector = [startNodeId, 'sys.query'] - setInputs(produce(currentInputs, (draft) => { - draft.query_variable_selector = nextQueryVariableSelector - })) + setInputs( + produce(currentInputs, (draft) => { + draft.query_variable_selector = nextQueryVariableSelector + }), + ) }, [inputRef, isChatMode, setInputs, startNodeId]) const metadataConfig = useKnowledgeMetadataConfig({ @@ -126,7 +120,7 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => { handleMultipleRetrievalConfigChange, handleModelChanged, handleCompletionParamsChange, - selectedDatasets: selectedDatasets.filter(d => d.name), + selectedDatasets: selectedDatasets.filter((d) => d.name), selectedDatasetsLoaded, handleOnDatasetsChange, rerankModelOpen, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/use-single-run-form-params.ts b/web/app/components/workflow/nodes/knowledge-retrieval/use-single-run-form-params.ts index d3ad4044f96130..87449c3ce76fcb 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/use-single-run-form-params.ts @@ -29,32 +29,36 @@ const useSingleRunFormParams = ({ setRunInputData, }: Params) => { const { t } = useTranslation() - const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail) + const datasetsDetail = useDatasetsDetailStore((s) => s.datasetsDetail) const query = runInputData.query const queryAttachment = runInputData.queryAttachment - const setQuery = useCallback((newQuery: string) => { - setRunInputData({ - ...runInputDataRef.current, - query: newQuery, - }) - }, [runInputDataRef, setRunInputData]) + const setQuery = useCallback( + (newQuery: string) => { + setRunInputData({ + ...runInputDataRef.current, + query: newQuery, + }) + }, + [runInputDataRef, setRunInputData], + ) - const setQueryAttachment = useCallback((newQueryAttachment: string) => { - setRunInputData({ - ...runInputDataRef.current, - queryAttachment: newQueryAttachment, - }) - }, [runInputDataRef, setRunInputData]) + const setQueryAttachment = useCallback( + (newQueryAttachment: string) => { + setRunInputData({ + ...runInputDataRef.current, + queryAttachment: newQueryAttachment, + }) + }, + [runInputDataRef, setRunInputData], + ) const filterFileVar = useCallback((varPayload: Var) => { return [VarType.file, VarType.arrayFile].includes(varPayload.type) }, []) // Get all variables from previous nodes that are file or array of file - const { - availableVars: availableFileVars, - } = useAvailableVarList(id, { + const { availableVars: availableFileVars } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar: filterFileVar, }) @@ -62,49 +66,61 @@ const useSingleRunFormParams = ({ const forms = useMemo(() => { const datasetIds = payload.dataset_ids const datasets = datasetIds.reduce<DataSet[]>((acc, id) => { - if (datasetsDetail[id]) - acc.push(datasetsDetail[id]) + if (datasetsDetail[id]) acc.push(datasetsDetail[id]) return acc }, []) - const hasMultiModalDatasets = datasets.some(d => d.is_multimodal) + const hasMultiModalDatasets = datasets.some((d) => d.is_multimodal) const inputFields: FormProps[] = [ { - inputs: [{ - label: t($ => $[`${i18nPrefix}.queryText`], { ns: 'workflow' })!, - variable: 'query', - type: InputVarType.paragraph, - required: false, - }], + inputs: [ + { + label: t(($) => $[`${i18nPrefix}.queryText`], { ns: 'workflow' })!, + variable: 'query', + type: InputVarType.paragraph, + required: false, + }, + ], values: { query }, onChange: (keyValue: Record<string, any>) => setQuery(keyValue.query), }, ] if (hasMultiModalDatasets) { - const currentVariable = findVariableWhenOnLLMVision(payload.query_attachment_selector || [], availableFileVars) - inputFields.push( - { - inputs: [{ - label: t($ => $[`${i18nPrefix}.queryAttachment`], { ns: 'workflow' })!, + const currentVariable = findVariableWhenOnLLMVision( + payload.query_attachment_selector || [], + availableFileVars, + ) + inputFields.push({ + inputs: [ + { + label: t(($) => $[`${i18nPrefix}.queryAttachment`], { ns: 'workflow' })!, variable: 'queryAttachment', type: currentVariable?.formType as InputVarType, required: false, - }], - values: { queryAttachment }, - onChange: (keyValue: Record<string, any>) => setQueryAttachment(keyValue.queryAttachment), - }, - ) + }, + ], + values: { queryAttachment }, + onChange: (keyValue: Record<string, any>) => setQueryAttachment(keyValue.queryAttachment), + }) } return inputFields - }, [query, setQuery, t, datasetsDetail, payload.dataset_ids, payload.query_attachment_selector, availableFileVars, queryAttachment, setQueryAttachment]) + }, [ + query, + setQuery, + t, + datasetsDetail, + payload.dataset_ids, + payload.query_attachment_selector, + availableFileVars, + queryAttachment, + setQueryAttachment, + ]) const getDependentVars = () => { return [payload.query_variable_selector, payload.query_attachment_selector || []] } const getDependentVar = (variable: string) => { - if (variable === 'query') - return payload.query_variable_selector - if (variable === 'queryAttachment') - return payload.query_attachment_selector || [] + if (variable === 'query') return payload.query_variable_selector + if (variable === 'queryAttachment') return payload.query_attachment_selector || [] } return { diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts b/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts index 5f41550fef94d0..910dbdef8adce1 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts @@ -1,21 +1,13 @@ import type { MultipleRetrievalConfig } from './types' -import type { - DataSet, - SelectedDatasetsMode, -} from '@/models/datasets' +import type { DataSet, SelectedDatasetsMode } from '@/models/datasets' import { uniq } from 'es-toolkit/array' import { xorBy } from 'es-toolkit/compat' import { DATASET_DEFAULT } from '@/config' -import { - DEFAULT_WEIGHTED_SCORE, - RerankingModeEnum, - WeightedScoreEnum, -} from '@/models/datasets' +import { DEFAULT_WEIGHTED_SCORE, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' export const getSelectedDatasetsMode = (datasets: DataSet[] = []) => { - if (datasets === null) - datasets = [] + if (datasets === null) datasets = [] let allHighQuality = true let allHighQualityVectorSearch = true let allHighQualityFullTextSearch = true @@ -52,8 +44,7 @@ export const getSelectedDatasetsMode = (datasets: DataSet[] = []) => { } if (dataset.provider !== 'external') { allExternal = false - } - else { + } else { allInternal = false allHighQuality = false allHighQualityVectorSearch = false @@ -62,14 +53,12 @@ export const getSelectedDatasetsMode = (datasets: DataSet[] = []) => { } }) - if (allExternal || allInternal) - mixtureInternalAndExternal = false + if (allExternal || allInternal) mixtureInternalAndExternal = false - if (allHighQuality || allEconomic) - mixtureHighQualityAndEconomic = false + if (allHighQuality || allEconomic) mixtureHighQualityAndEconomic = false if (allHighQuality) - inconsistentEmbeddingModel = uniq(datasets.map(item => item.embedding_model)).length > 1 + inconsistentEmbeddingModel = uniq(datasets.map((item) => item.embedding_model)).length > 1 return { allHighQuality, @@ -88,7 +77,7 @@ export const getMultipleRetrievalConfig = ( multipleRetrievalConfig: MultipleRetrievalConfig, selectedDatasets: DataSet[], originalDatasets: DataSet[], - fallbackRerankModel?: { provider?: string, model?: string }, // fallback rerank model + fallbackRerankModel?: { provider?: string; model?: string }, // fallback rerank model ) => { // Check if the selected datasets are different from the original datasets const isDatasetsChanged = xorBy(selectedDatasets, originalDatasets, 'id').length > 0 @@ -131,7 +120,6 @@ export const getMultipleRetrievalConfig = ( vector_setting: { vector_weight: allHighQualityVectorSearch ? DEFAULT_WEIGHTED_SCORE.allHighQualityVectorSearch.semantic - : allHighQualityFullTextSearch ? DEFAULT_WEIGHTED_SCORE.allHighQualityFullTextSearch.semantic : DEFAULT_WEIGHTED_SCORE.other.semantic, @@ -141,7 +129,6 @@ export const getMultipleRetrievalConfig = ( keyword_setting: { keyword_weight: allHighQualityVectorSearch ? DEFAULT_WEIGHTED_SCORE.allHighQualityVectorSearch.keyword - : allHighQualityFullTextSearch ? DEFAULT_WEIGHTED_SCORE.allHighQualityFullTextSearch.keyword : DEFAULT_WEIGHTED_SCORE.other.keyword, @@ -157,7 +144,10 @@ export const getMultipleRetrievalConfig = ( if ((allEconomic && allInternal) || allExternal) { result.reranking_mode = RerankingModeEnum.RerankingModel // Need to check if the reranking model should be set to default when first time initialized - if ((!result.reranking_model?.provider || !result.reranking_model?.model) && isFallbackRerankModelValid) { + if ( + (!result.reranking_model?.provider || !result.reranking_model?.model) && + isFallbackRerankModelValid + ) { result.reranking_model = { provider: fallbackRerankModel.provider || '', model: fallbackRerankModel.model || '', @@ -173,7 +163,10 @@ export const getMultipleRetrievalConfig = ( if (mixtureHighQualityAndEconomic || inconsistentEmbeddingModel || mixtureInternalAndExternal) { result.reranking_mode = RerankingModeEnum.RerankingModel // Need to check if the reranking model should be set to default when first time initialized - if ((!result.reranking_model?.provider || !result.reranking_model?.model) && isFallbackRerankModelValid) { + if ( + (!result.reranking_model?.provider || !result.reranking_model?.model) && + isFallbackRerankModelValid + ) { result.reranking_model = { provider: fallbackRerankModel.provider || '', model: fallbackRerankModel.model || '', @@ -198,8 +191,7 @@ export const getMultipleRetrievalConfig = ( provider: fallbackRerankModel.provider || '', model: fallbackRerankModel.model || '', } - } - else { + } else { result.reranking_mode = RerankingModeEnum.WeightedScore result.reranking_enable = false setDefaultWeights() @@ -209,11 +201,13 @@ export const getMultipleRetrievalConfig = ( // After initialization, if datasets has no change, make sure the config has correct value if (reranking_mode === RerankingModeEnum.WeightedScore) { result.reranking_enable = false - if (!weights) - setDefaultWeights() + if (!weights) setDefaultWeights() } if (reranking_mode === RerankingModeEnum.RerankingModel) { - if ((!result.reranking_model?.provider || !result.reranking_model?.model) && isFallbackRerankModelValid) { + if ( + (!result.reranking_model?.provider || !result.reranking_model?.model) && + isFallbackRerankModelValid + ) { result.reranking_model = { provider: fallbackRerankModel.provider || '', model: fallbackRerankModel.model || '', @@ -224,27 +218,32 @@ export const getMultipleRetrievalConfig = ( // Need to check if reranking_mode should be set to reranking_model when datasets changed if (reranking_mode === RerankingModeEnum.WeightedScore && weights && isDatasetsChanged) { - if ((result.reranking_model?.provider && result.reranking_model?.model) || isFallbackRerankModelValid) { + if ( + (result.reranking_model?.provider && result.reranking_model?.model) || + isFallbackRerankModelValid + ) { result.reranking_mode = RerankingModeEnum.RerankingModel result.reranking_enable = true - if ((!result.reranking_model?.provider || !result.reranking_model?.model) && isFallbackRerankModelValid) { + if ( + (!result.reranking_model?.provider || !result.reranking_model?.model) && + isFallbackRerankModelValid + ) { result.reranking_model = { provider: fallbackRerankModel.provider || '', model: fallbackRerankModel.model || '', } } - } - else { + } else { setDefaultWeights() } } // Need to switch to weighted score when reranking model is not valid and datasets changed if ( - reranking_mode === RerankingModeEnum.RerankingModel - && (!result.reranking_model?.provider || !result.reranking_model?.model) - && !isFallbackRerankModelValid - && isDatasetsChanged + reranking_mode === RerankingModeEnum.RerankingModel && + (!result.reranking_model?.provider || !result.reranking_model?.model) && + !isFallbackRerankModelValid && + isDatasetsChanged ) { result.reranking_mode = RerankingModeEnum.WeightedScore result.reranking_enable = false @@ -259,22 +258,16 @@ export const checkoutRerankModelConfiguredInRetrievalSettings = ( datasets: DataSet[], multipleRetrievalConfig?: MultipleRetrievalConfig, ) => { - if (!multipleRetrievalConfig) - return true + if (!multipleRetrievalConfig) return true - const { - allEconomic, - allExternal, - allInternal, - } = getSelectedDatasetsMode(datasets) + const { allEconomic, allExternal, allInternal } = getSelectedDatasetsMode(datasets) - const { - reranking_enable, - reranking_mode, - reranking_model, - } = multipleRetrievalConfig + const { reranking_enable, reranking_mode, reranking_model } = multipleRetrievalConfig - if (reranking_mode === RerankingModeEnum.RerankingModel && (!reranking_model?.provider || !reranking_model?.model)) + if ( + reranking_mode === RerankingModeEnum.RerankingModel && + (!reranking_model?.provider || !reranking_model?.model) + ) return ((allEconomic && allInternal) || allExternal) && !reranking_enable return true diff --git a/web/app/components/workflow/nodes/list-operator/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/list-operator/__tests__/integration.spec.tsx index 61e783b952ad81..f9c3ac0bf6c34d 100644 --- a/web/app/components/workflow/nodes/list-operator/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/list-operator/__tests__/integration.spec.tsx @@ -18,7 +18,7 @@ vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () availableVars: [ { variable: ['node-1', 'size'], type: VarType.number }, { variable: ['node-1', 'name'], type: VarType.string }, - ].filter(varPayload => options?.filterVar ? options.filterVar(varPayload) : true), + ].filter((varPayload) => (options?.filterVar ? options.filterVar(varPayload) : true)), availableNodesWithParent: [{ id: 'node-1', data: { title: 'Answer', type: BlockEnum.Answer } }], })), })) @@ -32,13 +32,19 @@ vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-v readOnly={readOnly} onFocus={() => onFocusChange?.(true)} onBlur={() => onFocusChange?.(false)} - onChange={event => onChange(event.target.value)} + onChange={(event) => onChange(event.target.value)} /> ), })) vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ - default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>, + default: ({ title, operations, children }: any) => ( + <div> + <div>{title}</div> + <div>{operations}</div> + {children} + </div> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/input-number-with-slider', () => ({ @@ -50,12 +56,20 @@ vi.mock('@/app/components/workflow/nodes/_base/components/input-number-with-slid })) vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({ - default: ({ title, onSelect }: any) => <button type="button" onClick={onSelect}>{title}</button>, + default: ({ title, onSelect }: any) => ( + <button type="button" onClick={onSelect}> + {title} + </button> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ default: ({ children }: any) => <div>{children}</div>, - VarItem: ({ name, type }: any) => <div>{name}:{type}</div>, + VarItem: ({ name, type }: any) => ( + <div> + {name}:{type} + </div> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ @@ -63,11 +77,19 @@ vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ - default: ({ onChange }: any) => <button type="button" onClick={() => onChange(['node-1', 'items'])}>pick-var</button>, + default: ({ onChange }: any) => ( + <button type="button" onClick={() => onChange(['node-1', 'items'])}> + pick-var + </button> + ), })) vi.mock('../components/filter-condition', () => ({ - default: ({ onChange }: any) => <button type="button" onClick={() => onChange({ key: 'size' })}>filter-condition</button>, + default: ({ onChange }: any) => ( + <button type="button" onClick={() => onChange({ key: 'size' })}> + filter-condition + </button> + ), })) vi.mock('../use-config', () => ({ @@ -83,14 +105,19 @@ const createData = (overrides: Partial<ListFilterNodeType> = {}): ListFilterNode variable: ['node-1', 'items'], var_type: VarType.arrayNumber, item_var_type: VarType.number, - filter_by: { enabled: true, conditions: [{ key: 'size', comparison_operator: 'equal', value: '1' }] as any }, + filter_by: { + enabled: true, + conditions: [{ key: 'size', comparison_operator: 'equal', value: '1' }] as any, + }, extract_by: { enabled: true, serial: '1' }, limit: { enabled: true, size: 10 }, order_by: { enabled: true, key: 'size', value: OrderBy.ASC }, ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), filterVar: vi.fn(() => true), @@ -119,9 +146,8 @@ const panelProps: PanelProps = { runResult: null, } -const renderPanel = (data: ListFilterNodeType = createData()) => ( +const renderPanel = (data: ListFilterNodeType = createData()) => render(<Panel id="node-1" data={data} panelProps={panelProps} />) -) describe('list-operator path', () => { beforeEach(() => { @@ -134,26 +160,14 @@ describe('list-operator path', () => { it('should update the extract input', async () => { const onChange = vi.fn() const { rerender } = render( - <ExtractInput - nodeId="node-1" - readOnly={false} - value="1" - onChange={onChange} - />, + <ExtractInput nodeId="node-1" readOnly={false} value="1" onChange={onChange} />, ) fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } }) fireEvent.focus(screen.getByDisplayValue('1')) expect(screen.getByDisplayValue('1')).toHaveClass('border-components-input-border-active') - rerender( - <ExtractInput - nodeId="node-1" - readOnly - value="" - onChange={onChange} - />, - ) + rerender(<ExtractInput nodeId="node-1" readOnly value="" onChange={onChange} />) expect(onChange).toHaveBeenCalled() expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', '') @@ -162,12 +176,7 @@ describe('list-operator path', () => { it('should change the selected sub variable', async () => { const user = userEvent.setup() const onChange = vi.fn() - const { unmount } = render( - <SubVariablePicker - value="size" - onChange={onChange} - />, - ) + const { unmount } = render(<SubVariablePicker value="size" onChange={onChange} />) await user.click(screen.getByRole('combobox')) await user.click(screen.getByRole('option', { name: 'name' })) @@ -177,12 +186,7 @@ describe('list-operator path', () => { }) unmount() - render( - <SubVariablePicker - value="" - onChange={onChange} - />, - ) + render(<SubVariablePicker value="" onChange={onChange} />) expect(screen.getByText('common.placeholder.select')).toBeInTheDocument() }) @@ -191,11 +195,7 @@ describe('list-operator path', () => { const user = userEvent.setup() const onChange = vi.fn() const { rerender } = render( - <LimitConfig - readonly={false} - config={{ enabled: true, size: 10 }} - onChange={onChange} - />, + <LimitConfig readonly={false} config={{ enabled: true, size: 10 }} onChange={onChange} />, ) await user.click(screen.getByText('slider-10')) @@ -203,11 +203,7 @@ describe('list-operator path', () => { expect(onChange).toHaveBeenCalledWith({ enabled: true, size: 11 }) rerender( - <LimitConfig - readonly={false} - config={{ enabled: false, size: 10 }} - onChange={onChange} - />, + <LimitConfig readonly={false} config={{ enabled: false, size: 10 }} onChange={onChange} />, ) expect(screen.queryByText('slider-10')).not.toBeInTheDocument() @@ -216,16 +212,16 @@ describe('list-operator path', () => { }) it('should render the selected input variable in the node preview', () => { - renderWorkflowFlowComponent( - <Node - id="node-2" - data={createData()} - />, - { - nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Answer, title: 'Answer' } as any }], - edges: [], - }, - ) + renderWorkflowFlowComponent(<Node id="node-2" data={createData()} />, { + nodes: [ + { + id: 'node-1', + position: { x: 0, y: 0 }, + data: { type: BlockEnum.Answer, title: 'Answer' } as any, + }, + ], + edges: [], + }) expect(screen.getByText('Answer')).toBeInTheDocument() expect(screen.getByText('items')).toBeInTheDocument() @@ -233,24 +229,22 @@ describe('list-operator path', () => { it('should resolve system variables through the start node and return null without a variable', () => { const { rerender } = renderWorkflowFlowComponent( - <Node - id="node-2" - data={createData({ variable: ['sys', 'files'] as any })} - />, + <Node id="node-2" data={createData({ variable: ['sys', 'files'] as any })} />, { - nodes: [{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start, title: 'Start' } as any }], + nodes: [ + { + id: 'start', + position: { x: 0, y: 0 }, + data: { type: BlockEnum.Start, title: 'Start' } as any, + }, + ], edges: [], }, ) expect(screen.getByText('Start')).toBeInTheDocument() - rerender( - <Node - id="node-2" - data={createData({ variable: [] as any })} - />, - ) + rerender(<Node id="node-2" data={createData({ variable: [] as any })} />) expect(screen.queryByText('workflow.nodes.listFilter.inputVar')).not.toBeInTheDocument() expect(screen.queryByText('Start')).not.toBeInTheDocument() @@ -270,15 +264,17 @@ describe('list-operator path', () => { }) it('should hide disabled sections and render order controls without sub variables', () => { - mockUseConfig.mockReturnValueOnce(createConfigResult({ - inputs: createData({ - variable: undefined as any, - filter_by: { enabled: false, conditions: [] as any }, - extract_by: { enabled: false, serial: '' }, - order_by: { enabled: false, key: '', value: OrderBy.ASC }, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + inputs: createData({ + variable: undefined as any, + filter_by: { enabled: false, conditions: [] as any }, + extract_by: { enabled: false, serial: '' }, + order_by: { enabled: false, key: '', value: OrderBy.ASC }, + }), + hasSubVariable: false, }), - hasSubVariable: false, - })) + ) const { rerender } = renderPanel() @@ -286,12 +282,14 @@ describe('list-operator path', () => { expect(screen.queryByDisplayValue('1')).not.toBeInTheDocument() expect(screen.queryByText('workflow.nodes.listFilter.asc')).not.toBeInTheDocument() - mockUseConfig.mockReturnValueOnce(createConfigResult({ - inputs: createData({ - order_by: { enabled: true, key: '', value: OrderBy.ASC }, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + inputs: createData({ + order_by: { enabled: true, key: '', value: OrderBy.ASC }, + }), + hasSubVariable: false, }), - hasSubVariable: false, - })) + ) rerender(<Panel id="node-1" data={createData()} panelProps={panelProps} />) diff --git a/web/app/components/workflow/nodes/list-operator/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/list-operator/__tests__/node.spec.tsx index da0332538c67f6..e7d42a6679a7aa 100644 --- a/web/app/components/workflow/nodes/list-operator/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/list-operator/__tests__/node.spec.tsx @@ -36,11 +36,13 @@ const createData = (overrides: Partial<ListFilterNodeType> = {}): ListFilterNode item_var_type: VarType.string, filter_by: { enabled: true, - conditions: [{ - key: 'name', - comparison_operator: 'contains' as never, - value: '', - }], + conditions: [ + { + key: 'name', + comparison_operator: 'contains' as never, + value: '', + }, + ], }, extract_by: { enabled: false, @@ -81,12 +83,7 @@ describe('list-operator/node', () => { }) it('renders the referenced node variable label', () => { - render( - <Node - id="list-node" - data={createData()} - />, - ) + render(<Node id="list-node" data={createData()} />) expect(screen.getByText('workflow.nodes.listFilter.inputVar')).toBeInTheDocument() expect(screen.getByText('Answer:answer:answer-node.items')).toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/list-operator/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/list-operator/__tests__/panel.spec.tsx index 945ca701044aed..fb5fff72e26d5e 100644 --- a/web/app/components/workflow/nodes/list-operator/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/list-operator/__tests__/panel.spec.tsx @@ -45,11 +45,7 @@ vi.mock('@langgenius/dify-ui/switch', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ __esModule: true, - default: (props: { - readonly: boolean - value: string[] - onChange: (value: string[]) => void - }) => { + default: (props: { readonly: boolean; value: string[]; onChange: (value: string[]) => void }) => { mockVarReferencePicker(props) return ( <button type="button" onClick={() => props.onChange(['node-2', 'records'])}> @@ -61,10 +57,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference vi.mock('../components/filter-condition', () => ({ __esModule: true, - default: (props: { - readOnly: boolean - onChange: (value: { key: string }) => void - }) => { + default: (props: { readOnly: boolean; onChange: (value: { key: string }) => void }) => { mockFilterCondition(props) return ( <button type="button" onClick={() => props.onChange({ key: 'size' })}> @@ -76,11 +69,7 @@ vi.mock('../components/filter-condition', () => ({ vi.mock('../components/extract-input', () => ({ __esModule: true, - default: (props: { - value: string - readOnly: boolean - onChange: (value: string) => void - }) => { + default: (props: { value: string; readOnly: boolean; onChange: (value: string) => void }) => { mockExtractInput(props) return ( <button type="button" onClick={() => props.onChange('2')}> @@ -94,8 +83,8 @@ vi.mock('../components/limit-config', () => ({ __esModule: true, default: (props: { readonly: boolean - config: { enabled: boolean, size?: number } - onChange: (config: { enabled: boolean, size?: number }) => void + config: { enabled: boolean; size?: number } + onChange: (config: { enabled: boolean; size?: number }) => void }) => { mockLimitConfig(props) return ( @@ -111,10 +100,7 @@ vi.mock('../components/limit-config', () => ({ vi.mock('../components/sub-variable-picker', () => ({ __esModule: true, - default: (props: { - value: string - onChange: (value: string) => void - }) => { + default: (props: { value: string; onChange: (value: string) => void }) => { mockSubVariablePicker(props) return ( <button type="button" onClick={() => props.onChange('name')}> @@ -126,11 +112,7 @@ vi.mock('../components/sub-variable-picker', () => ({ vi.mock('../../_base/components/option-card', () => ({ __esModule: true, - default: (props: { - title: string - selected: boolean - onSelect: () => void - }) => { + default: (props: { title: string; selected: boolean; onSelect: () => void }) => { mockOptionCard(props) return ( <button type="button" onClick={props.onSelect}> @@ -143,7 +125,7 @@ vi.mock('../../_base/components/option-card', () => ({ vi.mock('../../_base/components/output-vars', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <div>{children}</div>, - VarItem: ({ name, type, description }: { name: string, type: string, description: string }) => ( + VarItem: ({ name, type, description }: { name: string; type: string; description: string }) => ( <div>{`${name}:${type}:${description}`}</div> ), })) @@ -157,11 +139,13 @@ const createData = (overrides: Partial<ListFilterNodeType> = {}): ListFilterNode item_var_type: VarType.object, filter_by: { enabled: true, - conditions: [{ - key: 'name', - comparison_operator: 'contains' as never, - value: '', - }], + conditions: [ + { + key: 'name', + comparison_operator: 'contains' as never, + value: '', + }, + ], }, extract_by: { enabled: true, @@ -179,7 +163,9 @@ const createData = (overrides: Partial<ListFilterNodeType> = {}): ListFilterNode ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), varType: VarType.arrayObject, @@ -225,7 +211,9 @@ describe('list-operator/panel', () => { it('renders enabled sections and forwards all main interactions', async () => { const user = userEvent.setup() const config = createConfigResult({ - handleOrderByTypeChange: vi.fn((value: OrderBy) => () => config.handleOrderByEnabledChange(value === OrderBy.ASC)), + handleOrderByTypeChange: vi.fn( + (value: OrderBy) => () => config.handleOrderByEnabledChange(value === OrderBy.ASC), + ), }) mockUseConfig.mockReturnValue(config) @@ -237,9 +225,15 @@ describe('list-operator/panel', () => { expect(screen.getByRole('button', { name: 'extract-input:1' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'limit-config:10' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'sub-variable:size' })).toBeInTheDocument() - expect(screen.getByText('result:Array[Object]:workflow.nodes.listFilter.outputVars.result')).toBeInTheDocument() - expect(screen.getByText('first_record:Object:workflow.nodes.listFilter.outputVars.first_record')).toBeInTheDocument() - expect(screen.getByText('last_record:Object:workflow.nodes.listFilter.outputVars.last_record')).toBeInTheDocument() + expect( + screen.getByText('result:Array[Object]:workflow.nodes.listFilter.outputVars.result'), + ).toBeInTheDocument() + expect( + screen.getByText('first_record:Object:workflow.nodes.listFilter.outputVars.first_record'), + ).toBeInTheDocument() + expect( + screen.getByText('last_record:Object:workflow.nodes.listFilter.outputVars.last_record'), + ).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'var-picker:answer-node.items' })) await user.click(screen.getByRole('button', { name: 'filter-condition:editable' })) @@ -265,35 +259,43 @@ describe('list-operator/panel', () => { }) it('hides disabled sections and forwards readonly state to child controls', () => { - mockUseConfig.mockReturnValue(createConfigResult({ - readOnly: true, - hasSubVariable: false, - inputs: createData({ - filter_by: { - enabled: false, - conditions: [], - }, - extract_by: { - enabled: false, - serial: '', - }, - order_by: { - enabled: false, - key: '', - value: OrderBy.DESC, - }, + mockUseConfig.mockReturnValue( + createConfigResult({ + readOnly: true, + hasSubVariable: false, + inputs: createData({ + filter_by: { + enabled: false, + conditions: [], + }, + extract_by: { + enabled: false, + serial: '', + }, + order_by: { + enabled: false, + key: '', + value: OrderBy.DESC, + }, + }), }), - })) + ) renderPanel() expect(screen.getByRole('button', { name: 'var-picker:readonly' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'limit-config:readonly' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'filter-condition:readonly' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'filter-condition:readonly' }), + ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'extract-input:' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'sub-variable:empty' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'workflow.nodes.listFilter.asc:idle' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'workflow.nodes.listFilter.asc:idle' }), + ).not.toBeInTheDocument() expect(screen.getAllByRole('switch')).toHaveLength(3) - expect(screen.getAllByRole('switch').every(button => button.hasAttribute('disabled'))).toBe(true) + expect(screen.getAllByRole('switch').every((button) => button.hasAttribute('disabled'))).toBe( + true, + ) }) }) diff --git a/web/app/components/workflow/nodes/list-operator/__tests__/use-config.helpers.spec.ts b/web/app/components/workflow/nodes/list-operator/__tests__/use-config.helpers.spec.ts index d7feadd562e884..f1df87cab0b40f 100644 --- a/web/app/components/workflow/nodes/list-operator/__tests__/use-config.helpers.spec.ts +++ b/web/app/components/workflow/nodes/list-operator/__tests__/use-config.helpers.spec.ts @@ -18,31 +18,32 @@ import { updateOrderByType, } from '../use-config.helpers' -const createInputs = (): ListFilterNodeType => ({ - title: 'List Filter', - desc: '', - type: BlockEnum.ListFilter, - variable: ['node', 'list'], - var_type: VarType.arrayString, - item_var_type: VarType.string, - filter_by: { - enabled: false, - conditions: [{ key: '', comparison_operator: 'contains', value: '' }], - }, - extract_by: { - enabled: false, - serial: '', - }, - order_by: { - enabled: false, - key: '', - value: OrderBy.DESC, - }, - limit: { - enabled: false, - size: 20, - }, -} as unknown as ListFilterNodeType) +const createInputs = (): ListFilterNodeType => + ({ + title: 'List Filter', + desc: '', + type: BlockEnum.ListFilter, + variable: ['node', 'list'], + var_type: VarType.arrayString, + item_var_type: VarType.string, + filter_by: { + enabled: false, + conditions: [{ key: '', comparison_operator: 'contains', value: '' }], + }, + extract_by: { + enabled: false, + serial: '', + }, + order_by: { + enabled: false, + key: '', + value: OrderBy.DESC, + }, + limit: { + enabled: false, + size: 20, + }, + }) as unknown as ListFilterNodeType describe('list operator use-config helpers', () => { it('maps item var types, labels and filter support', () => { @@ -58,21 +59,29 @@ describe('list operator use-config helpers', () => { }) it('builds default conditions and updates selected variable metadata', () => { - expect(buildFilterCondition({ - itemVarType: VarType.boolean, - isFileArray: false, - })).toEqual(expect.objectContaining({ - key: '', - value: false, - })) + expect( + buildFilterCondition({ + itemVarType: VarType.boolean, + isFileArray: false, + }), + ).toEqual( + expect.objectContaining({ + key: '', + value: false, + }), + ) - expect(buildFilterCondition({ - itemVarType: VarType.string, - isFileArray: true, - })).toEqual(expect.objectContaining({ - key: 'name', - value: '', - })) + expect( + buildFilterCondition({ + itemVarType: VarType.string, + isFileArray: true, + }), + ).toEqual( + expect.objectContaining({ + key: 'name', + value: '', + }), + ) const nextInputs = updateListFilterVariable({ inputs: { @@ -91,17 +100,30 @@ describe('list operator use-config helpers', () => { it('updates filter, extract, limit and order by sections', () => { const condition = { key: 'size', comparison_operator: '>', value: '10' } expect(updateFilterEnabled(createInputs(), true).filter_by.enabled).toBe(true) - expect(updateFilterCondition(createInputs(), condition as ListFilterNodeType['filter_by']['conditions'][number]).filter_by.conditions[0]).toEqual(condition) - expect(updateLimit(createInputs(), { enabled: true, size: 10 }).limit).toEqual({ enabled: true, size: 10 }) - expect(updateExtractEnabled(createInputs(), true).extract_by).toEqual({ enabled: true, serial: '1' }) + expect( + updateFilterCondition( + createInputs(), + condition as ListFilterNodeType['filter_by']['conditions'][number], + ).filter_by.conditions[0], + ).toEqual(condition) + expect(updateLimit(createInputs(), { enabled: true, size: 10 }).limit).toEqual({ + enabled: true, + size: 10, + }) + expect(updateExtractEnabled(createInputs(), true).extract_by).toEqual({ + enabled: true, + serial: '1', + }) expect(updateExtractSerial(createInputs(), '2').extract_by.serial).toBe('2') const orderEnabled = updateOrderByEnabled(createInputs(), true, true) - expect(orderEnabled.order_by).toEqual(expect.objectContaining({ - enabled: true, - key: 'name', - value: OrderBy.ASC, - })) + expect(orderEnabled.order_by).toEqual( + expect.objectContaining({ + enabled: true, + key: 'name', + value: OrderBy.ASC, + }), + ) expect(updateOrderByKey(createInputs(), 'created_at').order_by.key).toBe('created_at') expect(updateOrderByType(createInputs(), OrderBy.DESC).order_by.value).toBe(OrderBy.DESC) }) diff --git a/web/app/components/workflow/nodes/list-operator/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/list-operator/__tests__/use-config.spec.tsx index 9dd7a115b7ce87..8e7eda14cdbb4e 100644 --- a/web/app/components/workflow/nodes/list-operator/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/list-operator/__tests__/use-config.spec.tsx @@ -50,11 +50,13 @@ const createPayload = (overrides: Partial<ListFilterNodeType> = {}): ListFilterN item_var_type: VarType.file, filter_by: { enabled: false, - conditions: [{ - key: '', - comparison_operator: ComparisonOperator.contains, - value: '', - }], + conditions: [ + { + key: '', + comparison_operator: ComparisonOperator.contains, + value: '', + }, + ], }, extract_by: { enabled: false, @@ -81,13 +83,13 @@ describe('list-operator/use-config', () => { mockIsChatMode = false mockGetBeforeNodesInSameBranch.mockReturnValue([{ id: 'before-node' }]) mockGetNodes.mockReturnValue([{ id: 'list-node' }]) - mockGetCurrentVariableType.mockImplementation(({ valueSelector }: { valueSelector: string[] }) => { - if (valueSelector[1] === 'files') - return VarType.arrayFile - if (valueSelector[1] === 'numbers') - return VarType.arrayNumber - return VarType.arrayString - }) + mockGetCurrentVariableType.mockImplementation( + ({ valueSelector }: { valueSelector: string[] }) => { + if (valueSelector[1] === 'files') return VarType.arrayFile + if (valueSelector[1] === 'numbers') return VarType.arrayNumber + return VarType.arrayString + }, + ) }) it('should expose derived state and update inputs through the helper pipeline', () => { @@ -115,51 +117,71 @@ describe('list-operator/use-config', () => { result.current.handleOrderByKeyChange('size') result.current.handleOrderByTypeChange(OrderBy.DESC)() - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - variable: ['node-2', 'numbers'], - var_type: VarType.arrayNumber, - item_var_type: VarType.number, - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - filter_by: expect.objectContaining({ - enabled: true, + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + variable: ['node-2', 'numbers'], + var_type: VarType.arrayNumber, + item_var_type: VarType.number, }), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - filter_by: expect.objectContaining({ - conditions: [{ - key: 'size', - comparison_operator: ComparisonOperator.largerThan, - value: '2', - }], + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + filter_by: expect.objectContaining({ + enabled: true, + }), }), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - limit: { enabled: true, size: 3 }, - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - extract_by: { enabled: true, serial: '1' }, - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - extract_by: { enabled: false, serial: '5' }, - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - order_by: expect.objectContaining({ - enabled: true, - key: 'name', - value: OrderBy.ASC, + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + filter_by: expect.objectContaining({ + conditions: [ + { + key: 'size', + comparison_operator: ComparisonOperator.largerThan, + value: '2', + }, + ], + }), }), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - order_by: expect.objectContaining({ - key: 'size', + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + limit: { enabled: true, size: 3 }, }), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - order_by: expect.objectContaining({ - value: OrderBy.DESC, + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + extract_by: { enabled: true, serial: '1' }, }), - })) + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + extract_by: { enabled: false, serial: '5' }, + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + order_by: expect.objectContaining({ + enabled: true, + key: 'name', + value: OrderBy.ASC, + }), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + order_by: expect.objectContaining({ + key: 'size', + }), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + order_by: expect.objectContaining({ + value: OrderBy.DESC, + }), + }), + ) }) it('should derive parent nodes from iteration and loop contexts', () => { @@ -172,33 +194,49 @@ describe('list-operator/use-config', () => { { id: 'loop-parent' }, ]) - const { result: iterationResult } = renderHook(() => useConfig('list-node', createPayload({ - isInIteration: true, - variable: ['iteration', 'files'], - }))) - const { result: loopResult } = renderHook(() => useConfig('loop-node', createPayload({ - isInLoop: true, - isInIteration: false, - variable: ['loop', 'names'], - }))) + const { result: iterationResult } = renderHook(() => + useConfig( + 'list-node', + createPayload({ + isInIteration: true, + variable: ['iteration', 'files'], + }), + ), + ) + const { result: loopResult } = renderHook(() => + useConfig( + 'loop-node', + createPayload({ + isInLoop: true, + isInIteration: false, + variable: ['loop', 'names'], + }), + ), + ) iterationResult.current.handleVarChanges(['iteration', 'files']) loopResult.current.handleOrderByEnabledChange(true) expect(iterationResult.current.readOnly).toBe(true) - expect(mockGetCurrentVariableType).toHaveBeenCalledWith(expect.objectContaining({ - parentNode: expect.objectContaining({ id: 'iteration-parent' }), - isChatMode: true, - })) - expect(mockGetCurrentVariableType).toHaveBeenCalledWith(expect.objectContaining({ - parentNode: expect.objectContaining({ id: 'loop-parent' }), - valueSelector: ['loop', 'names'], - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - order_by: expect.objectContaining({ - enabled: true, - key: '', + expect(mockGetCurrentVariableType).toHaveBeenCalledWith( + expect.objectContaining({ + parentNode: expect.objectContaining({ id: 'iteration-parent' }), + isChatMode: true, + }), + ) + expect(mockGetCurrentVariableType).toHaveBeenCalledWith( + expect.objectContaining({ + parentNode: expect.objectContaining({ id: 'loop-parent' }), + valueSelector: ['loop', 'names'], + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + order_by: expect.objectContaining({ + enabled: true, + key: '', + }), }), - })) + ) }) }) diff --git a/web/app/components/workflow/nodes/list-operator/components/__tests__/extract-input.spec.tsx b/web/app/components/workflow/nodes/list-operator/components/__tests__/extract-input.spec.tsx index 7a5abfd040dfe0..d7f49862042829 100644 --- a/web/app/components/workflow/nodes/list-operator/components/__tests__/extract-input.spec.tsx +++ b/web/app/components/workflow/nodes/list-operator/components/__tests__/extract-input.spec.tsx @@ -26,10 +26,20 @@ vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-v default: (props: MockInputProps) => { mockInput(props) return ( - <div data-testid="extract-input" data-class-name={props.className} data-placeholder={props.placeholder || ''}> - <button type="button" onClick={() => props.onFocusChange?.(true)}>focus</button> - <button type="button" onClick={() => props.onFocusChange?.(false)}>blur</button> - <button type="button" onClick={() => props.onChange('12')}>change</button> + <div + data-testid="extract-input" + data-class-name={props.className} + data-placeholder={props.placeholder || ''} + > + <button type="button" onClick={() => props.onFocusChange?.(true)}> + focus + </button> + <button type="button" onClick={() => props.onFocusChange?.(false)}> + blur + </button> + <button type="button" onClick={() => props.onChange('12')}> + change + </button> </div> ) }, @@ -38,76 +48,83 @@ vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-v describe('list-operator/extract-input', () => { beforeEach(() => { vi.clearAllMocks() - mockUseAvailableVarList.mockImplementation((_nodeId: string, options?: { - filterVar?: (payload: { type: VarType }) => boolean - }) => { - const numberVars: NodeOutPutVar[] = [{ - nodeId: 'number-node', - title: 'Numbers', - vars: [{ - variable: 'count', - type: VarType.number, - }], - }] - const stringVars: NodeOutPutVar[] = [{ - nodeId: 'text-node', - title: 'Texts', - vars: [{ - variable: 'name', - type: VarType.string, - }], - }] + mockUseAvailableVarList.mockImplementation( + ( + _nodeId: string, + options?: { + filterVar?: (payload: { type: VarType }) => boolean + }, + ) => { + const numberVars: NodeOutPutVar[] = [ + { + nodeId: 'number-node', + title: 'Numbers', + vars: [ + { + variable: 'count', + type: VarType.number, + }, + ], + }, + ] + const stringVars: NodeOutPutVar[] = [ + { + nodeId: 'text-node', + title: 'Texts', + vars: [ + { + variable: 'name', + type: VarType.string, + }, + ], + }, + ] - const allVars = [...numberVars, ...stringVars] - return { - availableVars: allVars.filter(item => item.vars.every(variable => options?.filterVar?.({ type: variable.type }) ?? true)), - availableNodesWithParent: [{ id: 'number-node' }], - } - }) + const allVars = [...numberVars, ...stringVars] + return { + availableVars: allVars.filter((item) => + item.vars.every((variable) => options?.filterVar?.({ type: variable.type }) ?? true), + ), + availableNodesWithParent: [{ id: 'number-node' }], + } + }, + ) }) it('should filter variables to numeric values and toggle focus styles', () => { const handleChange = vi.fn() - render( - <ExtractInput - nodeId="node-1" - readOnly={false} - value="5" - onChange={handleChange} - />, - ) + render(<ExtractInput nodeId="node-1" readOnly={false} value="5" onChange={handleChange} />) expect(mockInput.mock.calls[0]![0]).toMatchObject({ readOnly: false, value: '5', placeholder: 'workflow.nodes.http.extractListPlaceholder', - nodesOutputVars: [{ - nodeId: 'number-node', - title: 'Numbers', - vars: [expect.objectContaining({ variable: 'count' })], - }], + nodesOutputVars: [ + { + nodeId: 'number-node', + title: 'Numbers', + vars: [expect.objectContaining({ variable: 'count' })], + }, + ], availableNodes: [{ id: 'number-node' }], }) fireEvent.click(screen.getByRole('button', { name: 'focus' })) - expect(screen.getByTestId('extract-input').dataset.className).toContain('border-components-input-border-active') + expect(screen.getByTestId('extract-input').dataset.className).toContain( + 'border-components-input-border-active', + ) fireEvent.click(screen.getByRole('button', { name: 'change' })) expect(handleChange).toHaveBeenCalledWith('12') fireEvent.click(screen.getByRole('button', { name: 'blur' })) - expect(screen.getByTestId('extract-input').dataset.className).toContain('border-components-input-border-hover') + expect(screen.getByTestId('extract-input').dataset.className).toContain( + 'border-components-input-border-hover', + ) }) it('should clear the placeholder when the component is readonly', () => { - render( - <ExtractInput - nodeId="node-1" - readOnly - value="" - onChange={vi.fn()} - />, - ) + render(<ExtractInput nodeId="node-1" readOnly value="" onChange={vi.fn()} />) expect(mockInput.mock.calls[0]![0].placeholder).toBe('') expect(mockInput.mock.calls[0]![0].readOnly).toBe(true) diff --git a/web/app/components/workflow/nodes/list-operator/components/__tests__/filter-condition.spec.tsx b/web/app/components/workflow/nodes/list-operator/components/__tests__/filter-condition.spec.tsx index cedc35ac1b405b..cb39e2728bc507 100644 --- a/web/app/components/workflow/nodes/list-operator/components/__tests__/filter-condition.spec.tsx +++ b/web/app/components/workflow/nodes/list-operator/components/__tests__/filter-condition.spec.tsx @@ -36,7 +36,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-v aria-label="variable-input" className={className} value={value} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} onFocus={() => onFocusChange?.(true)} onBlur={() => onFocusChange?.(false)} readOnly={readOnly} @@ -46,19 +46,13 @@ vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-v })) vi.mock('../../../../panel/chat-variable-panel/components/bool-value', () => ({ - default: ({ value, onChange }: { value: boolean, onChange: (value: boolean) => void }) => ( + default: ({ value, onChange }: { value: boolean; onChange: (value: boolean) => void }) => ( <button onClick={() => onChange(!value)}>{value ? 'true' : 'false'}</button> ), })) vi.mock('../../../if-else/components/condition-list/condition-operator', () => ({ - default: ({ - value, - onSelect, - }: { - value: string - onSelect: (value: string) => void - }) => ( + default: ({ value, onSelect }: { value: string; onSelect: (value: string) => void }) => ( <button onClick={() => onSelect(ComparisonOperator.notEqual)}> operator: {value} @@ -67,13 +61,7 @@ vi.mock('../../../if-else/components/condition-list/condition-operator', () => ( })) vi.mock('../sub-variable-picker', () => ({ - default: ({ - value, - onChange, - }: { - value: string - onChange: (value: string) => void - }) => ( + default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( <button onClick={() => onChange('size')}> sub-variable: {value} diff --git a/web/app/components/workflow/nodes/list-operator/components/__tests__/limit-config.spec.tsx b/web/app/components/workflow/nodes/list-operator/components/__tests__/limit-config.spec.tsx index efcea6db1c6b78..e92dbcde8a135c 100644 --- a/web/app/components/workflow/nodes/list-operator/components/__tests__/limit-config.spec.tsx +++ b/web/app/components/workflow/nodes/list-operator/components/__tests__/limit-config.spec.tsx @@ -72,13 +72,7 @@ describe('list-operator/limit-config', () => { const handleChange = vi.fn() const config: Limit = { enabled: false, size: 10 } - render( - <LimitConfig - readonly={false} - config={config} - onChange={handleChange} - />, - ) + render(<LimitConfig readonly={false} config={config} onChange={handleChange} />) expect(screen.getByText('workflow.nodes.listFilter.limit'))!.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'slider:10:false' })).not.toBeInTheDocument() @@ -95,12 +89,7 @@ describe('list-operator/limit-config', () => { const config: Limit = { enabled: true, size: 6 } render( - <LimitConfig - className="custom-limit" - readonly - config={config} - onChange={handleChange} - />, + <LimitConfig className="custom-limit" readonly config={config} onChange={handleChange} />, ) expect(screen.getByRole('button', { name: 'slider:6:true' }))!.toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/list-operator/components/__tests__/sub-variable-picker.spec.tsx b/web/app/components/workflow/nodes/list-operator/components/__tests__/sub-variable-picker.spec.tsx index c6820aa1c39665..9a583040f3d476 100644 --- a/web/app/components/workflow/nodes/list-operator/components/__tests__/sub-variable-picker.spec.tsx +++ b/web/app/components/workflow/nodes/list-operator/components/__tests__/sub-variable-picker.spec.tsx @@ -11,12 +11,7 @@ describe('list-operator/sub-variable-picker', () => { const user = userEvent.setup() const handleChange = vi.fn() - render( - <SubVariablePicker - value="" - onChange={handleChange} - />, - ) + render(<SubVariablePicker value="" onChange={handleChange} />) expect(screen.getByText('common.placeholder.select')).toBeInTheDocument() @@ -31,11 +26,7 @@ describe('list-operator/sub-variable-picker', () => { const handleChange = vi.fn() const { container } = render( - <SubVariablePicker - value="size" - onChange={handleChange} - className="custom-sub-variable" - />, + <SubVariablePicker value="size" onChange={handleChange} className="custom-sub-variable" />, ) expect(container.firstChild).toHaveClass('custom-sub-variable') diff --git a/web/app/components/workflow/nodes/list-operator/components/extract-input.tsx b/web/app/components/workflow/nodes/list-operator/components/extract-input.tsx index d2f780a3864deb..6f8c6d421cfa62 100644 --- a/web/app/components/workflow/nodes/list-operator/components/extract-input.tsx +++ b/web/app/components/workflow/nodes/list-operator/components/extract-input.tsx @@ -16,12 +16,7 @@ type Props = Readonly<{ onChange: (value: string) => void }> -const ExtractInput: FC<Props> = ({ - nodeId, - readOnly, - value, - onChange, -}) => { +const ExtractInput: FC<Props> = ({ nodeId, readOnly, value, onChange }) => { const { t } = useTranslation() const [isFocus, setIsFocus] = useState(false) @@ -36,14 +31,21 @@ const ExtractInput: FC<Props> = ({ <div className="flex items-start space-x-1"> <Input instanceId="http-extract-number" - className={cn(isFocus ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-components-input-border-hover bg-components-input-bg-normal', 'w-0 grow rounded-lg border px-3 py-[6px]')} + className={cn( + isFocus + ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' + : 'border-components-input-border-hover bg-components-input-bg-normal', + 'w-0 grow rounded-lg border px-3 py-[6px]', + )} value={value} onChange={onChange} readOnly={readOnly} nodesOutputVars={availableVars} availableNodes={availableNodesWithParent} onFocusChange={setIsFocus} - placeholder={!readOnly ? t($ => $['nodes.http.extractListPlaceholder'], { ns: 'workflow' })! : ''} + placeholder={ + !readOnly ? t(($) => $['nodes.http.extractListPlaceholder'], { ns: 'workflow' })! : '' + } placeholderClassName="leading-[21px]!" /> </div> diff --git a/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx b/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx index 9df7a402a613ad..5d43dd3f1e3690 100644 --- a/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx +++ b/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx @@ -2,7 +2,14 @@ import type { FC } from 'react' import type { Condition } from '../types' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -48,19 +55,18 @@ const getSelectOptions = ( isSelect: boolean, t: ReturnType<typeof useTranslation>['t'], ) => { - if (!isSelect) - return [] + if (!isSelect) return [] if (condition.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) { - return FILE_TYPE_OPTIONS.map(item => ({ - name: t($ => $[`${optionNameI18NPrefix}.${item.i18nKey}`], { ns: 'workflow' }), + return FILE_TYPE_OPTIONS.map((item) => ({ + name: t(($) => $[`${optionNameI18NPrefix}.${item.i18nKey}`], { ns: 'workflow' }), value: item.value, })) } if (condition.key === 'transfer_method') { - return TRANSFER_METHOD.map(item => ({ - name: t($ => $[`${optionNameI18NPrefix}.${item.i18nKey}`], { ns: 'workflow' }), + return TRANSFER_METHOD.map((item) => ({ + name: t(($) => $[`${optionNameI18NPrefix}.${item.i18nKey}`], { ns: 'workflow' }), value: item.value, })) } @@ -77,7 +83,8 @@ const getFallbackInputType = ({ condition: Condition varType: VarType }) => { - return ((hasSubVariable && condition.key === 'size') || (!hasSubVariable && varType === VarType.number)) + return (hasSubVariable && condition.key === 'size') || + (!hasSubVariable && varType === VarType.number) ? 'number' : 'text' } @@ -104,7 +111,7 @@ const ValueInput = ({ isArrayValue: boolean isBoolean: boolean supportVariableInput: boolean - selectOptions: Array<{ name: string, value: string }> + selectOptions: Array<{ name: string; value: string }> condition: Condition readOnly: boolean availableVars: VariableInputProps['nodesOutputVars'] @@ -122,20 +129,25 @@ const ValueInput = ({ onFocusChange(value) } - if (comparisonOperatorNotRequireValue(comparisonOperator)) - return null + if (comparisonOperatorNotRequireValue(comparisonOperator)) return null if (isSelect) { - const selectedValue = isArrayValue ? (condition.value as string[])?.[0] : condition.value as string - const selectedOption = selectOptions.find(option => option.value === selectedValue) ?? null + const selectedValue = isArrayValue + ? (condition.value as string[])?.[0] + : (condition.value as string) + const selectedOption = selectOptions.find((option) => option.value === selectedValue) ?? null return ( - <Select value={selectedOption?.value ?? null} disabled={readOnly} onValueChange={value => value && onChange(value)}> + <Select + value={selectedOption?.value ?? null} + disabled={readOnly} + onValueChange={(value) => value && onChange(value)} + > <SelectTrigger className="h-8 grow text-[13px]"> {selectedOption?.name ?? 'Select value'} </SelectTrigger> <SelectContent> - {selectOptions.map(option => ( + {selectOptions.map((option) => ( <SelectItem key={option.value} value={option.value}> <SelectItemText>{option.name}</SelectItemText> <SelectItemIndicator /> @@ -147,12 +159,7 @@ const ValueInput = ({ } if (isBoolean) { - return ( - <BoolValue - value={condition.value as boolean} - onChange={onChange} - /> - ) + return <BoolValue value={condition.value as boolean} onChange={onChange} /> } if (supportVariableInput) { @@ -171,7 +178,9 @@ const ValueInput = ({ nodesOutputVars={availableVars} availableNodes={availableNodesWithParent} onFocusChange={handleFocusChange} - placeholder={!readOnly ? t($ => $['nodes.http.insertVarPlaceholder'], { ns: 'workflow' })! : ''} + placeholder={ + !readOnly ? t(($) => $['nodes.http.insertVarPlaceholder'], { ns: 'workflow' })! : '' + } placeholderClassName="leading-[21px]!" /> ) @@ -182,7 +191,7 @@ const ValueInput = ({ type={getFallbackInputType({ hasSubVariable, condition, varType })} className="grow rounded-lg border border-components-input-border-hover bg-components-input-bg-normal px-3 py-[6px]" value={getConditionValueAsString(condition)} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} readOnly={readOnly} /> ) @@ -208,30 +217,43 @@ const FilterCondition: FC<Props> = ({ }, }) - const isSelect = [ComparisonOperator.in, ComparisonOperator.notIn, ComparisonOperator.allOf].includes(condition.comparison_operator) + const isSelect = [ + ComparisonOperator.in, + ComparisonOperator.notIn, + ComparisonOperator.allOf, + ].includes(condition.comparison_operator) const isArrayValue = condition.key === 'transfer_method' || condition.key === 'type' const isBoolean = varType === VarType.boolean - const selectOptions = useMemo(() => getSelectOptions(condition, isSelect, t), [condition, isSelect, t]) + const selectOptions = useMemo( + () => getSelectOptions(condition, isSelect, t), + [condition, isSelect, t], + ) - const handleChange = useCallback((key: string) => { - return (value: any) => { + const handleChange = useCallback( + (key: string) => { + return (value: any) => { + onChange({ + ...condition, + [key]: isArrayValue && key === 'value' ? [value] : value, + }) + } + }, + [condition, onChange, isArrayValue], + ) + + const handleSubVariableChange = useCallback( + (value: string) => { + const operators = getOperators(expectedVarType ?? VarType.string, { key: value }) + const newOperator = operators.length > 0 ? operators[0]! : ComparisonOperator.equal onChange({ - ...condition, - [key]: (isArrayValue && key === 'value') ? [value] : value, + key: value, + comparison_operator: newOperator, + value: '', }) - } - }, [condition, onChange, isArrayValue]) - - const handleSubVariableChange = useCallback((value: string) => { - const operators = getOperators(expectedVarType ?? VarType.string, { key: value }) - const newOperator = operators.length > 0 ? operators[0]! : ComparisonOperator.equal - onChange({ - key: value, - comparison_operator: newOperator, - value: '', - }) - }, [onChange, expectedVarType]) + }, + [onChange, expectedVarType], + ) return ( <div> diff --git a/web/app/components/workflow/nodes/list-operator/components/limit-config.tsx b/web/app/components/workflow/nodes/list-operator/components/limit-config.tsx index 1cff32dd1c0a16..4d3d3da0d18816 100644 --- a/web/app/components/workflow/nodes/list-operator/components/limit-config.tsx +++ b/web/app/components/workflow/nodes/list-operator/components/limit-config.tsx @@ -27,54 +27,53 @@ const LIMIT_DEFAULT: Limit = { size: LIMIT_SIZE_DEFAULT, } -const LimitConfig: FC<Props> = ({ - className, - readonly, - config = LIMIT_DEFAULT, - onChange, -}) => { +const LimitConfig: FC<Props> = ({ className, readonly, config = LIMIT_DEFAULT, onChange }) => { const { t } = useTranslation() const payload = config - const handleLimitEnabledChange = useCallback((enabled: boolean) => { - onChange({ - ...config, - enabled, - }) - }, [config, onChange]) + const handleLimitEnabledChange = useCallback( + (enabled: boolean) => { + onChange({ + ...config, + enabled, + }) + }, + [config, onChange], + ) - const handleLimitSizeChange = useCallback((size: number | string) => { - onChange({ - ...config, - size: Number.parseInt(size as string), - }) - }, [onChange, config]) + const handleLimitSizeChange = useCallback( + (size: number | string) => { + onChange({ + ...config, + size: Number.parseInt(size as string), + }) + }, + [onChange, config], + ) return ( <div className={cn(className)}> <Field - title={t($ => $[`${i18nPrefix}.limit`], { ns: 'workflow' })} - operations={( + title={t(($) => $[`${i18nPrefix}.limit`], { ns: 'workflow' })} + operations={ <Switch checked={payload.enabled} onCheckedChange={handleLimitEnabledChange} size="md" disabled={readonly} /> - )} + } > - {payload?.enabled - ? ( - <InputNumberWithSlider - label={t($ => $[`${i18nPrefix}.limit`], { ns: 'workflow' })} - value={payload?.size || LIMIT_SIZE_DEFAULT} - min={LIMIT_SIZE_MIN} - max={LIMIT_SIZE_MAX} - onChange={handleLimitSizeChange} - readonly={readonly || !payload?.enabled} - /> - ) - : null} + {payload?.enabled ? ( + <InputNumberWithSlider + label={t(($) => $[`${i18nPrefix}.limit`], { ns: 'workflow' })} + value={payload?.size || LIMIT_SIZE_DEFAULT} + min={LIMIT_SIZE_MIN} + max={LIMIT_SIZE_MAX} + onChange={handleLimitSizeChange} + readonly={readonly || !payload?.enabled} + /> + ) : null} </Field> </div> ) diff --git a/web/app/components/workflow/nodes/list-operator/components/sub-variable-picker.tsx b/web/app/components/workflow/nodes/list-operator/components/sub-variable-picker.tsx index cfbf8ff4a11ef5..27b40eba50d350 100644 --- a/web/app/components/workflow/nodes/list-operator/components/sub-variable-picker.tsx +++ b/web/app/components/workflow/nodes/list-operator/components/sub-variable-picker.tsx @@ -1,7 +1,13 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -19,49 +25,52 @@ type SubVariableOption = { name: string } -const SubVariablePicker: FC<Props> = ({ - value, - onChange, - className, -}) => { +const SubVariablePicker: FC<Props> = ({ value, onChange, className }) => { const { t } = useTranslation() - const subVarOptions = useMemo<SubVariableOption[]>(() => SUB_VARIABLES.map(item => ({ - value: item, - name: item, - })), []) + const subVarOptions = useMemo<SubVariableOption[]>( + () => + SUB_VARIABLES.map((item) => ({ + value: item, + name: item, + })), + [], + ) const selectedOption = useMemo(() => { - return subVarOptions.find(option => option.value === value) ?? null + return subVarOptions.find((option) => option.value === value) ?? null }, [subVarOptions, value]) return ( <div className={cn(className)}> - <Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && onChange(nextValue)}> + <Select + value={selectedOption?.value ?? null} + onValueChange={(nextValue) => nextValue && onChange(nextValue)} + > <SelectTrigger className="h-8 border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"> <div className="group/sub-variable-picker flex h-8 items-center rounded-lg bg-components-input-bg-normal pl-1 hover:bg-state-base-hover-alt"> - {selectedOption - ? ( - <div className="flex cursor-pointer justify-start"> - <div className="inline-flex h-6 max-w-full items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 text-text-accent shadow-xs"> - <Variable02 className="size-3.5 shrink-0 text-text-accent" /> - <div className="ml-0.5 truncate system-xs-medium">{selectedOption.name}</div> - </div> - </div> - ) - : ( - <div className="flex pl-1 system-sm-regular text-components-input-text-placeholder group-hover/sub-variable-picker:text-text-tertiary"> - <Variable02 className="mr-1 size-4 shrink-0" /> - <span>{t($ => $['placeholder.select'], { ns: 'common' })}</span> - </div> - )} + {selectedOption ? ( + <div className="flex cursor-pointer justify-start"> + <div className="inline-flex h-6 max-w-full items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 text-text-accent shadow-xs"> + <Variable02 className="size-3.5 shrink-0 text-text-accent" /> + <div className="ml-0.5 truncate system-xs-medium">{selectedOption.name}</div> + </div> + </div> + ) : ( + <div className="flex pl-1 system-sm-regular text-components-input-text-placeholder group-hover/sub-variable-picker:text-text-tertiary"> + <Variable02 className="mr-1 size-4 shrink-0" /> + <span>{t(($) => $['placeholder.select'], { ns: 'common' })}</span> + </div> + )} </div> </SelectTrigger> <SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1"> - {subVarOptions.map(option => ( + {subVarOptions.map((option) => ( <SelectItem key={option.value} value={option.value} className="h-8 py-0 pr-5 pl-1"> <div className="flex h-6 items-center justify-between"> <div className="flex h-full items-center"> <Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" /> - <SelectItemText className="mr-0 px-0 system-sm-medium text-text-secondary">{option.name}</SelectItemText> + <SelectItemText className="mr-0 px-0 system-sm-medium text-text-secondary"> + {option.name} + </SelectItemText> </div> </div> </SelectItem> diff --git a/web/app/components/workflow/nodes/list-operator/default.ts b/web/app/components/workflow/nodes/list-operator/default.ts index fdb2ff1789c85c..da01dcc67005d2 100644 --- a/web/app/components/workflow/nodes/list-operator/default.ts +++ b/web/app/components/workflow/nodes/list-operator/default.ts @@ -41,18 +41,38 @@ const nodeDefault: NodeDefault<ListFilterNodeType> = { const { variable, var_type, filter_by, item_var_type } = payload if (!errorMessages && !variable?.length) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.listFilter.inputVar'], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.listFilter.inputVar'], { ns: 'workflow' }), + }) // Check filter condition if (!errorMessages && filter_by?.enabled) { if (var_type === VarType.arrayFile && !filter_by.conditions[0]?.key) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.listFilter.filterConditionKey'], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.listFilter.filterConditionKey'], { ns: 'workflow' }), + }) if (!errorMessages && !filter_by.conditions[0]?.comparison_operator) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.listFilter.filterConditionComparisonOperator'], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.listFilter.filterConditionComparisonOperator'], { + ns: 'workflow', + }), + }) - if (!errorMessages && !comparisonOperatorNotRequireValue(filter_by.conditions[0]?.comparison_operator) && (item_var_type === VarType.boolean ? filter_by.conditions[0]?.value === undefined : !filter_by.conditions[0]?.value)) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.listFilter.filterConditionComparisonValue'], { ns: 'workflow' }) }) + if ( + !errorMessages && + !comparisonOperatorNotRequireValue(filter_by.conditions[0]?.comparison_operator) && + (item_var_type === VarType.boolean + ? filter_by.conditions[0]?.value === undefined + : !filter_by.conditions[0]?.value) + ) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.listFilter.filterConditionComparisonValue'], { ns: 'workflow' }), + }) } return { diff --git a/web/app/components/workflow/nodes/list-operator/node.tsx b/web/app/components/workflow/nodes/list-operator/node.tsx index 648bd44ae8c4cf..47cda060799cd0 100644 --- a/web/app/components/workflow/nodes/list-operator/node.tsx +++ b/web/app/components/workflow/nodes/list-operator/node.tsx @@ -5,29 +5,28 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import { isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' -import { - VariableLabelInNode, -} from '@/app/components/workflow/nodes/_base/components/variable/variable-label' +import { VariableLabelInNode } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { BlockEnum } from '@/app/components/workflow/types' const i18nPrefix = 'nodes.listFilter' -const NodeComponent: FC<NodeProps<ListFilterNodeType>> = ({ - data, -}) => { +const NodeComponent: FC<NodeProps<ListFilterNodeType>> = ({ data }) => { const { t } = useTranslation() const nodes: Node[] = useNodes() const { variable } = data - if (!variable || variable.length === 0) - return null + if (!variable || variable.length === 0) return null const isSystem = isSystemVar(variable) - const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === variable[0]) + const node = isSystem + ? nodes.find((node) => node.data.type === BlockEnum.Start) + : nodes.find((node) => node.id === variable[0]) return ( <div className="relative px-3"> - <div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">{t($ => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })}</div> + <div className="mb-1 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })} + </div> <VariableLabelInNode variables={variable} nodeType={node?.data.type} diff --git a/web/app/components/workflow/nodes/list-operator/panel.tsx b/web/app/components/workflow/nodes/list-operator/panel.tsx index 17d451eaf80d6d..ecc5bde355ac5d 100644 --- a/web/app/components/workflow/nodes/list-operator/panel.tsx +++ b/web/app/components/workflow/nodes/list-operator/panel.tsx @@ -18,10 +18,7 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.listFilter' -const Panel: FC<NodePanelProps<ListFilterNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<ListFilterNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { @@ -45,10 +42,7 @@ const Panel: FC<NodePanelProps<ListFilterNodeType>> = ({ return ( <div className="pt-2"> <div className="space-y-4 px-4"> - <Field - title={t($ => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })} - required - > + <Field title={t(($) => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })} required> <VarReferencePicker readonly={readOnly} nodeId={id} @@ -62,100 +56,94 @@ const Panel: FC<NodePanelProps<ListFilterNodeType>> = ({ </Field> <Field - title={t($ => $[`${i18nPrefix}.filterCondition`], { ns: 'workflow' })} - operations={( + title={t(($) => $[`${i18nPrefix}.filterCondition`], { ns: 'workflow' })} + operations={ <Switch checked={inputs.filter_by?.enabled} onCheckedChange={handleFilterEnabledChange} size="md" disabled={readOnly} /> - )} + } > - {inputs.filter_by?.enabled - ? ( - <FilterCondition - condition={inputs.filter_by.conditions[0]!} - onChange={handleFilterChange} - varType={itemVarType} - hasSubVariable={hasSubVariable} - readOnly={readOnly} - nodeId={id} - /> - ) - : null} + {inputs.filter_by?.enabled ? ( + <FilterCondition + condition={inputs.filter_by.conditions[0]!} + onChange={handleFilterChange} + varType={itemVarType} + hasSubVariable={hasSubVariable} + readOnly={readOnly} + nodeId={id} + /> + ) : null} </Field> <Split /> <Field - title={t($ => $[`${i18nPrefix}.extractsCondition`], { ns: 'workflow' })} - operations={( + title={t(($) => $[`${i18nPrefix}.extractsCondition`], { ns: 'workflow' })} + operations={ <Switch checked={inputs.extract_by?.enabled} onCheckedChange={handleExtractsEnabledChange} size="md" disabled={readOnly} /> - )} + } > - {inputs.extract_by?.enabled - ? ( - <div className="flex items-center justify-between"> - <div className="mr-2 grow"> - <ExtractInput - value={inputs.extract_by.serial as string} - onChange={handleExtractsChange} - readOnly={readOnly} - nodeId={id} - /> - </div> - </div> - ) - : null} + {inputs.extract_by?.enabled ? ( + <div className="flex items-center justify-between"> + <div className="mr-2 grow"> + <ExtractInput + value={inputs.extract_by.serial as string} + onChange={handleExtractsChange} + readOnly={readOnly} + nodeId={id} + /> + </div> + </div> + ) : null} </Field> <Split /> - <LimitConfig - config={inputs.limit} - onChange={handleLimitChange} - readonly={readOnly} - /> + <LimitConfig config={inputs.limit} onChange={handleLimitChange} readonly={readOnly} /> <Split /> <Field - title={t($ => $[`${i18nPrefix}.orderBy`], { ns: 'workflow' })} - operations={( + title={t(($) => $[`${i18nPrefix}.orderBy`], { ns: 'workflow' })} + operations={ <Switch checked={inputs.order_by?.enabled} onCheckedChange={handleOrderByEnabledChange} size="md" disabled={readOnly} /> - )} + } > - {inputs.order_by?.enabled - ? ( - <div className="flex items-center justify-between"> - {hasSubVariable && ( - <div className="mr-2 grow"> - <SubVariablePicker - value={inputs.order_by.key as string} - onChange={handleOrderByKeyChange} - /> - </div> - )} - <div className={!hasSubVariable ? 'grid w-full grid-cols-2 gap-1' : 'flex shrink-0 space-x-1'}> - <OptionCard - title={t($ => $[`${i18nPrefix}.asc`], { ns: 'workflow' })} - onSelect={handleOrderByTypeChange(OrderBy.ASC)} - selected={inputs.order_by.value === OrderBy.ASC} - /> - <OptionCard - title={t($ => $[`${i18nPrefix}.desc`], { ns: 'workflow' })} - onSelect={handleOrderByTypeChange(OrderBy.DESC)} - selected={inputs.order_by.value === OrderBy.DESC} - /> - </div> + {inputs.order_by?.enabled ? ( + <div className="flex items-center justify-between"> + {hasSubVariable && ( + <div className="mr-2 grow"> + <SubVariablePicker + value={inputs.order_by.key as string} + onChange={handleOrderByKeyChange} + /> </div> - ) - : null} + )} + <div + className={ + !hasSubVariable ? 'grid w-full grid-cols-2 gap-1' : 'flex shrink-0 space-x-1' + } + > + <OptionCard + title={t(($) => $[`${i18nPrefix}.asc`], { ns: 'workflow' })} + onSelect={handleOrderByTypeChange(OrderBy.ASC)} + selected={inputs.order_by.value === OrderBy.ASC} + /> + <OptionCard + title={t(($) => $[`${i18nPrefix}.desc`], { ns: 'workflow' })} + onSelect={handleOrderByTypeChange(OrderBy.DESC)} + selected={inputs.order_by.value === OrderBy.DESC} + /> + </div> + </div> + ) : null} </Field> <Split /> </div> @@ -165,17 +153,17 @@ const Panel: FC<NodePanelProps<ListFilterNodeType>> = ({ <VarItem name="result" type={`Array[${itemVarTypeShowName}]`} - description={t($ => $[`${i18nPrefix}.outputVars.result`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.result`], { ns: 'workflow' })} /> <VarItem name="first_record" type={itemVarTypeShowName} - description={t($ => $[`${i18nPrefix}.outputVars.first_record`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.first_record`], { ns: 'workflow' })} /> <VarItem name="last_record" type={itemVarTypeShowName} - description={t($ => $[`${i18nPrefix}.outputVars.last_record`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.last_record`], { ns: 'workflow' })} /> </> </OutputVars> diff --git a/web/app/components/workflow/nodes/list-operator/use-config.helpers.ts b/web/app/components/workflow/nodes/list-operator/use-config.helpers.ts index d539ea3604a89d..19573face29cf2 100644 --- a/web/app/components/workflow/nodes/list-operator/use-config.helpers.ts +++ b/web/app/components/workflow/nodes/list-operator/use-config.helpers.ts @@ -23,8 +23,7 @@ export const getItemVarType = (varType?: VarType) => { } export const getItemVarTypeShowName = (itemVarType?: VarType, hasVariable?: boolean) => { - if (!hasVariable) - return '?' + if (!hasVariable) return '?' const fallbackType = itemVarType || WorkflowVarType.string return `${fallbackType.substring(0, 1).toUpperCase()}${fallbackType.substring(1)}` @@ -50,7 +49,7 @@ export const buildFilterCondition = ({ isFileArray: boolean existingKey?: string }): Condition => ({ - key: (isFileArray && !existingKey) ? 'name' : '', + key: isFileArray && !existingKey ? 'name' : '', comparison_operator: getOperators(itemVarType, isFileArray ? { key: 'name' } : undefined)[0]!, value: itemVarType === WorkflowVarType.boolean ? false : '', }) @@ -65,86 +64,70 @@ export const updateListFilterVariable = ({ variable: ValueSelector varType: VarType itemVarType: VarType -}) => produce(inputs, (draft) => { - const isFileArray = varType === WorkflowVarType.arrayFile - - draft.variable = variable - draft.var_type = varType - draft.item_var_type = itemVarType - draft.filter_by.conditions = [ - buildFilterCondition({ - itemVarType, - isFileArray, - existingKey: draft.filter_by.conditions[0]?.key, - }), - ] - - if (isFileArray && draft.order_by.enabled && !draft.order_by.key) - draft.order_by.key = 'name' -}) - -export const updateFilterEnabled = ( - inputs: ListFilterNodeType, - enabled: boolean, -) => produce(inputs, (draft) => { - draft.filter_by.enabled = enabled - if (enabled && !draft.filter_by.conditions) - draft.filter_by.conditions = [] -}) - -export const updateFilterCondition = ( - inputs: ListFilterNodeType, - condition: Condition, -) => produce(inputs, (draft) => { - draft.filter_by.conditions[0] = condition -}) - -export const updateLimit = ( - inputs: ListFilterNodeType, - limit: Limit, -) => produce(inputs, (draft) => { - draft.limit = limit -}) - -export const updateExtractEnabled = ( - inputs: ListFilterNodeType, - enabled: boolean, -) => produce(inputs, (draft) => { - draft.extract_by.enabled = enabled - if (enabled) - draft.extract_by.serial = '1' -}) - -export const updateExtractSerial = ( - inputs: ListFilterNodeType, - value: string, -) => produce(inputs, (draft) => { - draft.extract_by.serial = value -}) +}) => + produce(inputs, (draft) => { + const isFileArray = varType === WorkflowVarType.arrayFile + + draft.variable = variable + draft.var_type = varType + draft.item_var_type = itemVarType + draft.filter_by.conditions = [ + buildFilterCondition({ + itemVarType, + isFileArray, + existingKey: draft.filter_by.conditions[0]?.key, + }), + ] + + if (isFileArray && draft.order_by.enabled && !draft.order_by.key) draft.order_by.key = 'name' + }) + +export const updateFilterEnabled = (inputs: ListFilterNodeType, enabled: boolean) => + produce(inputs, (draft) => { + draft.filter_by.enabled = enabled + if (enabled && !draft.filter_by.conditions) draft.filter_by.conditions = [] + }) + +export const updateFilterCondition = (inputs: ListFilterNodeType, condition: Condition) => + produce(inputs, (draft) => { + draft.filter_by.conditions[0] = condition + }) + +export const updateLimit = (inputs: ListFilterNodeType, limit: Limit) => + produce(inputs, (draft) => { + draft.limit = limit + }) + +export const updateExtractEnabled = (inputs: ListFilterNodeType, enabled: boolean) => + produce(inputs, (draft) => { + draft.extract_by.enabled = enabled + if (enabled) draft.extract_by.serial = '1' + }) + +export const updateExtractSerial = (inputs: ListFilterNodeType, value: string) => + produce(inputs, (draft) => { + draft.extract_by.serial = value + }) export const updateOrderByEnabled = ( inputs: ListFilterNodeType, enabled: boolean, hasSubVariable: boolean, -) => produce(inputs, (draft) => { - draft.order_by.enabled = enabled - if (enabled) { - draft.order_by.value = OrderBy.ASC - if (hasSubVariable && !draft.order_by.key) - draft.order_by.key = 'name' - } -}) - -export const updateOrderByKey = ( - inputs: ListFilterNodeType, - key: string, -) => produce(inputs, (draft) => { - draft.order_by.key = key -}) - -export const updateOrderByType = ( - inputs: ListFilterNodeType, - type: OrderBy, -) => produce(inputs, (draft) => { - draft.order_by.value = type -}) +) => + produce(inputs, (draft) => { + draft.order_by.enabled = enabled + if (enabled) { + draft.order_by.value = OrderBy.ASC + if (hasSubVariable && !draft.order_by.key) draft.order_by.key = 'name' + } + }) + +export const updateOrderByKey = (inputs: ListFilterNodeType, key: string) => + produce(inputs, (draft) => { + draft.order_by.key = key + }) + +export const updateOrderByType = (inputs: ListFilterNodeType, type: OrderBy) => + produce(inputs, (draft) => { + draft.order_by.value = type + }) diff --git a/web/app/components/workflow/nodes/list-operator/use-config.ts b/web/app/components/workflow/nodes/list-operator/use-config.ts index 5a3b8d3a65279e..ff3cca24095cfc 100644 --- a/web/app/components/workflow/nodes/list-operator/use-config.ts +++ b/web/app/components/workflow/nodes/list-operator/use-config.ts @@ -32,14 +32,14 @@ const useConfig = (id: string, payload: ListFilterNodeType) => { const store = useStoreApi() const { getBeforeNodesInSameBranch } = useWorkflow() - const { - getNodes, - } = store.getState() - const currentNode = getNodes().find(n => n.id === id) + const { getNodes } = store.getState() + const currentNode = getNodes().find((n) => n.id === id) const isInIteration = payload.isInIteration - const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null + const iterationNode = isInIteration + ? getNodes().find((n) => n.id === currentNode!.parentId) + : null const isInLoop = payload.isInLoop - const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null + const loopNode = isInLoop ? getNodes().find((n) => n.id === currentNode!.parentId) : null const availableNodes = useMemo(() => { return getBeforeNodesInSameBranch(id) }, [getBeforeNodesInSameBranch, id]) @@ -48,69 +48,112 @@ const useConfig = (id: string, payload: ListFilterNodeType) => { const { getCurrentVariableType } = useWorkflowVariables() - const getType = useCallback((variable?: ValueSelector) => { - const varType = getCurrentVariableType({ - parentNode: isInIteration ? iterationNode : loopNode, - valueSelector: variable || inputs.variable || [], + const getType = useCallback( + (variable?: ValueSelector) => { + const varType = getCurrentVariableType({ + parentNode: isInIteration ? iterationNode : loopNode, + valueSelector: variable || inputs.variable || [], + availableNodes, + isChatMode, + isConstant: false, + }) + const itemVarType = getItemVarType(varType) + return { varType, itemVarType } + }, + [ availableNodes, + getCurrentVariableType, + inputs.variable, isChatMode, - isConstant: false, - }) - const itemVarType = getItemVarType(varType) - return { varType, itemVarType } - }, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, isInIteration, iterationNode, loopNode]) + isInIteration, + iterationNode, + loopNode, + ], + ) const { varType, itemVarType } = getType() - const itemVarTypeShowName = useMemo(() => getItemVarTypeShowName(itemVarType, !!inputs.variable), [inputs.variable, itemVarType]) + const itemVarTypeShowName = useMemo( + () => getItemVarTypeShowName(itemVarType, !!inputs.variable), + [inputs.variable, itemVarType], + ) const hasSubVariable = supportsSubVariable(varType) - const handleVarChanges = useCallback((variable: ValueSelector | string) => { - const nextType = getType(variable as ValueSelector) - setInputs(updateListFilterVariable({ - inputs, - variable: variable as ValueSelector, - varType: nextType.varType, - itemVarType: nextType.itemVarType, - })) - }, [getType, inputs, setInputs]) + const handleVarChanges = useCallback( + (variable: ValueSelector | string) => { + const nextType = getType(variable as ValueSelector) + setInputs( + updateListFilterVariable({ + inputs, + variable: variable as ValueSelector, + varType: nextType.varType, + itemVarType: nextType.itemVarType, + }), + ) + }, + [getType, inputs, setInputs], + ) const filterVar = useCallback((varPayload: Var) => canFilterVariable(varPayload), []) - const handleFilterEnabledChange = useCallback((enabled: boolean) => { - setInputs(updateFilterEnabled(inputs, enabled)) - }, [inputs, setInputs]) - - const handleFilterChange = useCallback((condition: Condition) => { - setInputs(updateFilterCondition(inputs, condition)) - }, [inputs, setInputs]) - - const handleLimitChange = useCallback((limit: Limit) => { - setInputs(updateLimit(inputs, limit)) - }, [inputs, setInputs]) - - const handleExtractsEnabledChange = useCallback((enabled: boolean) => { - setInputs(updateExtractEnabled(inputs, enabled)) - }, [inputs, setInputs]) - - const handleExtractsChange = useCallback((value: string) => { - setInputs(updateExtractSerial(inputs, value)) - }, [inputs, setInputs]) - - const handleOrderByEnabledChange = useCallback((enabled: boolean) => { - setInputs(updateOrderByEnabled(inputs, enabled, hasSubVariable)) - }, [hasSubVariable, inputs, setInputs]) - - const handleOrderByKeyChange = useCallback((key: string) => { - setInputs(updateOrderByKey(inputs, key)) - }, [inputs, setInputs]) - - const handleOrderByTypeChange = useCallback((type: OrderBy) => { - return () => { - setInputs(updateOrderByType(inputs, type)) - } - }, [inputs, setInputs]) + const handleFilterEnabledChange = useCallback( + (enabled: boolean) => { + setInputs(updateFilterEnabled(inputs, enabled)) + }, + [inputs, setInputs], + ) + + const handleFilterChange = useCallback( + (condition: Condition) => { + setInputs(updateFilterCondition(inputs, condition)) + }, + [inputs, setInputs], + ) + + const handleLimitChange = useCallback( + (limit: Limit) => { + setInputs(updateLimit(inputs, limit)) + }, + [inputs, setInputs], + ) + + const handleExtractsEnabledChange = useCallback( + (enabled: boolean) => { + setInputs(updateExtractEnabled(inputs, enabled)) + }, + [inputs, setInputs], + ) + + const handleExtractsChange = useCallback( + (value: string) => { + setInputs(updateExtractSerial(inputs, value)) + }, + [inputs, setInputs], + ) + + const handleOrderByEnabledChange = useCallback( + (enabled: boolean) => { + setInputs(updateOrderByEnabled(inputs, enabled, hasSubVariable)) + }, + [hasSubVariable, inputs, setInputs], + ) + + const handleOrderByKeyChange = useCallback( + (key: string) => { + setInputs(updateOrderByKey(inputs, key)) + }, + [inputs, setInputs], + ) + + const handleOrderByTypeChange = useCallback( + (type: OrderBy) => { + return () => { + setInputs(updateOrderByType(inputs, type)) + } + }, + [inputs, setInputs], + ) return { readOnly, diff --git a/web/app/components/workflow/nodes/llm/__tests__/default.spec.ts b/web/app/components/workflow/nodes/llm/__tests__/default.spec.ts index 4a27ffce693ae8..8539ed8072ce34 100644 --- a/web/app/components/workflow/nodes/llm/__tests__/default.spec.ts +++ b/web/app/components/workflow/nodes/llm/__tests__/default.spec.ts @@ -7,34 +7,40 @@ import nodeDefault from '../default' const t = withSelectorKey((key: string) => key, 'workflow') -const createPayload = (overrides: Partial<LLMNodeType> = {}): LLMNodeType => ({ - ...nodeDefault.defaultValue, - model: { - ...nodeDefault.defaultValue.model, - provider: 'langgenius/openai/gpt-4.1', - mode: AppModeEnum.CHAT, - }, - prompt_template: [{ - role: PromptRole.system, - text: 'You are helpful.', - edition_type: EditionType.basic, - }], - ...overrides, -}) as LLMNodeType +const createPayload = (overrides: Partial<LLMNodeType> = {}): LLMNodeType => + ({ + ...nodeDefault.defaultValue, + model: { + ...nodeDefault.defaultValue.model, + provider: 'langgenius/openai/gpt-4.1', + mode: AppModeEnum.CHAT, + }, + prompt_template: [ + { + role: PromptRole.system, + text: 'You are helpful.', + edition_type: EditionType.basic, + }, + ], + ...overrides, + }) as LLMNodeType describe('llm default node validation', () => { it('should require a model provider before validating the prompt', () => { - const result = nodeDefault.checkValid(createPayload({ - model: { - ...nodeDefault.defaultValue.model, - provider: '', - name: 'gpt-4.1', - mode: AppModeEnum.CHAT, - completion_params: { - temperature: 0.7, + const result = nodeDefault.checkValid( + createPayload({ + model: { + ...nodeDefault.defaultValue.model, + provider: '', + name: 'gpt-4.1', + mode: AppModeEnum.CHAT, + completion_params: { + temperature: 0.7, + }, }, - }, - }), t) + }), + t, + ) expect(result.isValid).toBe(false) expect(result.errorMessage).toBe('errorMsg.fieldRequired') @@ -48,30 +54,38 @@ describe('llm default node validation', () => { }) it('should require sys.query in memory user prompt outside snippet flows', () => { - const result = nodeDefault.checkValid(createPayload({ - memory: { - window: { - enabled: false, - size: 10, + const result = nodeDefault.checkValid( + createPayload({ + memory: { + window: { + enabled: false, + size: 10, + }, + query_prompt_template: 'custom prompt', }, - query_prompt_template: 'custom prompt', - }, - }), t, { flowType: FlowType.appFlow }) + }), + t, + { flowType: FlowType.appFlow }, + ) expect(result.isValid).toBe(false) expect(result.errorMessage).toBe('nodes.llm.sysQueryInUser') }) it('should not require sys.query in memory user prompt for snippet flows', () => { - const result = nodeDefault.checkValid(createPayload({ - memory: { - window: { - enabled: false, - size: 10, + const result = nodeDefault.checkValid( + createPayload({ + memory: { + window: { + enabled: false, + size: 10, + }, + query_prompt_template: 'custom prompt', }, - query_prompt_template: 'custom prompt', - }, - }), t, { flowType: FlowType.snippet }) + }), + t, + { flowType: FlowType.snippet }, + ) expect(result.isValid).toBe(true) expect(result.errorMessage).toBe('') diff --git a/web/app/components/workflow/nodes/llm/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/llm/__tests__/node.spec.tsx index 93fa9ed1c3aed6..c529be9c180cc4 100644 --- a/web/app/components/workflow/nodes/llm/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/llm/__tests__/node.spec.tsx @@ -1,8 +1,6 @@ import type { LLMNodeType } from '../types' import { render, screen } from '@testing-library/react' -import { - useTextGenerationCurrentProviderAndModelAndModelList, -} from '@/app/components/header/account-setting/model-provider-page/hooks' +import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import { BlockEnum } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' import Node from '../node' @@ -13,7 +11,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ __esModule: true, - default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => ( + default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( <div>{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}</div> ), })) @@ -50,12 +48,7 @@ describe('llm/node', () => { }) it('renders the readonly model selector when a model is configured', () => { - render( - <Node - id="llm-node" - data={createData()} - />, - ) + render(<Node id="llm-node" data={createData()} />) expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx index af4803a1a1007f..c7347a060ac8f4 100644 --- a/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx @@ -22,9 +22,12 @@ vi.mock('../use-config', () => ({ default: (...args: unknown[]) => mockUseConfig(...args), })) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - default: () => <div data-testid="model-parameter-modal" />, -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + default: () => <div data-testid="model-parameter-modal" />, + }), +) vi.mock('../_base/components/config-vision', () => ({ default: () => null, @@ -143,16 +146,13 @@ const buildUseConfigResult = (overrides?: Partial<MockUseConfigReturn>) => ({ const renderPanel = (data?: Partial<LLMNodeType>) => { return renderWorkflowFlowComponent( - <ProviderContext.Provider value={createMockProviderContextValue({ - modelProviders: [createMockModelProvider('openai')], - isFetchedPlan: true, - })} + <ProviderContext.Provider + value={createMockProviderContextValue({ + modelProviders: [createMockModelProvider('openai')], + isFetchedPlan: true, + })} > - <Panel - id="llm-node" - data={{ ...baseNodeData, ...data }} - panelProps={panelProps} - /> + <Panel id="llm-node" data={{ ...baseNodeData, ...data }} panelProps={panelProps} /> </ProviderContext.Provider>, { hooksStoreProps: {}, @@ -175,16 +175,18 @@ describe('LLM Panel', () => { }) it('should show the model warning dot when the model is not configured', () => { - mockUseConfig.mockReturnValue(buildUseConfigResult({ - inputs: { - ...baseNodeData, - model: { - ...baseNodeData.model, - provider: '', - name: '', + mockUseConfig.mockReturnValue( + buildUseConfigResult({ + inputs: { + ...baseNodeData, + model: { + ...baseNodeData.model, + provider: '', + name: '', + }, }, - }, - })) + }), + ) renderPanel({ model: { diff --git a/web/app/components/workflow/nodes/llm/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/llm/__tests__/use-config.spec.ts index 363d2fa5aa0dec..a78753db44e14a 100644 --- a/web/app/components/workflow/nodes/llm/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/llm/__tests__/use-config.spec.ts @@ -2,10 +2,7 @@ import type { MutableRefObject } from 'react' import type { LLMNodeType } from '../types' import { act, renderHook, waitFor } from '@testing-library/react' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' -import { - useIsChatMode, - useNodesReadOnly, -} from '@/app/components/workflow/hooks' +import { useIsChatMode, useNodesReadOnly } from '@/app/components/workflow/hooks' import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useStore } from '@/app/components/workflow/store' @@ -70,7 +67,9 @@ const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) const mockUseIsChatMode = vi.mocked(useIsChatMode) const mockUseNodeCrud = vi.mocked(useNodeCrud) const mockUseInspectVarsCrud = vi.mocked(useInspectVarsCrud) -const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = vi.mocked(useModelListAndDefaultModelAndCurrentProviderAndModel) +const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = vi.mocked( + useModelListAndDefaultModelAndCurrentProviderAndModel, +) const mockUseStore = vi.mocked(useStore) const mockUseAvailableVarList = vi.mocked(useAvailableVarList) const mockUseConfigVision = vi.mocked(useConfigVision) @@ -202,7 +201,9 @@ describe('llm/use-config', () => { appendDefaultPromptConfig, } as ReturnType<typeof useLLMInputManager>) mockUseLLMPromptConfig.mockReturnValue(promptConfig as ReturnType<typeof useLLMPromptConfig>) - mockUseLLMStructuredOutputConfig.mockReturnValue(structuredOutputConfig as ReturnType<typeof useLLMStructuredOutputConfig>) + mockUseLLMStructuredOutputConfig.mockReturnValue( + structuredOutputConfig as ReturnType<typeof useLLMStructuredOutputConfig>, + ) }) it('composes the helper hooks, forwards filterVar to available vars, and updates completion params', () => { @@ -212,8 +213,12 @@ describe('llm/use-config', () => { expect(result.current.isChatMode).toBe(true) expect(result.current.isChatModel).toBe(true) expect(result.current.isCompletionModel).toBe(false) - expect(result.current.availableVars).toEqual([{ nodeId: 'previous-node', title: 'Previous', vars: [] }]) - expect(result.current.availableNodesWithParent).toEqual([{ id: 'previous-node', data: { title: 'Previous' } }]) + expect(result.current.availableVars).toEqual([ + { nodeId: 'previous-node', title: 'Previous', vars: [] }, + ]) + expect(result.current.availableNodesWithParent).toEqual([ + { id: 'previous-node', data: { title: 'Previous' } }, + ]) expect(mockUseAvailableVarList).toHaveBeenCalledWith('llm-node', { onlyLeafNodeVar: false, filterVar: promptConfig.filterVar, @@ -243,27 +248,36 @@ describe('llm/use-config', () => { }) }) - expect(setInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({ - vision: { - enabled: true, - configs: { - detail: Resolution.high, - variable_selector: ['sys', 'files'], + expect(setInputs).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + vision: { + enabled: true, + configs: { + detail: Resolution.high, + variable_selector: ['sys', 'files'], + }, }, - }, - })) - expect(setInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({ - model: expect.objectContaining({ - completion_params: { top_p: 0.5 }, }), - })) - expect(setInputs).toHaveBeenNthCalledWith(3, expect.objectContaining({ - model: expect.objectContaining({ - provider: 'openai', - name: 'gpt-4.1', - mode: AppModeEnum.CHAT, + ) + expect(setInputs).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + model: expect.objectContaining({ + completion_params: { top_p: 0.5 }, + }), }), - })) + ) + expect(setInputs).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + model: expect.objectContaining({ + provider: 'openai', + name: 'gpt-4.1', + mode: AppModeEnum.CHAT, + }), + }), + ) expect(appendDefaultPromptConfig).not.toHaveBeenCalled() }) @@ -296,17 +310,21 @@ describe('llm/use-config', () => { await waitFor(() => { expect(appendDefaultPromptConfig).toHaveBeenCalled() - expect(appendDefaultPromptConfig.mock.calls[0]![1]).toEqual(expect.objectContaining({ - prompt_templates: expect.any(Object), - })) + expect(appendDefaultPromptConfig.mock.calls[0]![1]).toEqual( + expect.objectContaining({ + prompt_templates: expect.any(Object), + }), + ) expect(appendDefaultPromptConfig.mock.calls[0]![2]).toBe(true) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - model: expect.objectContaining({ - provider: 'anthropic', - name: 'claude-sonnet', - mode: AppModeEnum.CHAT, + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + model: expect.objectContaining({ + provider: 'anthropic', + name: 'claude-sonnet', + mode: AppModeEnum.CHAT, + }), }), - })) + ) expect(handleVisionConfigAfterModelChanged).toHaveBeenCalled() }) }) diff --git a/web/app/components/workflow/nodes/llm/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/llm/__tests__/use-single-run-form-params.spec.ts index aa8e705bcc998e..6c305a53002f62 100644 --- a/web/app/components/workflow/nodes/llm/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/llm/__tests__/use-single-run-form-params.spec.ts @@ -1,7 +1,13 @@ import type { LLMNodeType } from '../types' import type { InputVar, Variable } from '@/app/components/workflow/types' import { renderHook } from '@testing-library/react' -import { BlockEnum, EditionType, InputVarType, PromptRole, VarType } from '@/app/components/workflow/types' +import { + BlockEnum, + EditionType, + InputVarType, + PromptRole, + VarType, +} from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' import { FlowType } from '@/types/common' import useConfigVision from '../../../hooks/use-config-vision' @@ -33,11 +39,12 @@ const mockFlowType = vi.hoisted(() => ({ })) vi.mock('@/app/components/workflow/hooks-store/store', () => ({ - useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => selector({ - configsMap: { - flowType: mockFlowType.value, - }, - }), + useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => + selector({ + configsMap: { + flowType: mockFlowType.value, + }, + }), })) const mockUseConfigVision = vi.mocked(useConfigVision) @@ -54,11 +61,13 @@ const createData = (overrides: Partial<LLMNodeType> = {}): LLMNodeType => ({ mode: AppModeEnum.CHAT, completion_params: {}, } as LLMNodeType['model'], - prompt_template: [{ - edition_type: EditionType.basic, - role: PromptRole.user, - text: '{{#start.query#}}', - }], + prompt_template: [ + { + edition_type: EditionType.basic, + role: PromptRole.user, + text: '{{#start.query#}}', + }, + ], memory: { window: { enabled: false, @@ -87,10 +96,13 @@ describe('llm/use-single-run-form-params', () => { beforeEach(() => { vi.clearAllMocks() mockFlowType.value = undefined - mockUseNodeCrud.mockImplementation((_id, payload) => ({ - inputs: payload, - setInputs: vi.fn(), - }) as unknown as ReturnType<typeof useNodeCrud>) + mockUseNodeCrud.mockImplementation( + (_id, payload) => + ({ + inputs: payload, + setInputs: vi.fn(), + }) as unknown as ReturnType<typeof useNodeCrud>, + ) mockUseConfigVision.mockReturnValue({ isVisionModel: false, } as ReturnType<typeof useConfigVision>) @@ -111,28 +123,34 @@ describe('llm/use-single-run-form-params', () => { createInputVar('#start.extra#'), ]) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'llm-node', - payload: createData({ - prompt_template: [{ - edition_type: EditionType.jinja2, - role: PromptRole.user, - text: '{{ query }}', - }], - prompt_config: { - jinja2_variables: [{ - variable: 'extra', - value_selector: ['start', 'extra'], - value_type: VarType.string, - }], - }, + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'llm-node', + payload: createData({ + prompt_template: [ + { + edition_type: EditionType.jinja2, + role: PromptRole.user, + text: '{{ query }}', + }, + ], + prompt_config: { + jinja2_variables: [ + { + variable: 'extra', + value_selector: ['start', 'extra'], + value_type: VarType.string, + }, + ], + }, + }), + runInputData: {}, + runInputDataRef: { current: {} }, + getInputVars, + setRunInputData: vi.fn(), + toVarInputs, }), - runInputData: {}, - runInputDataRef: { current: {} }, - getInputVars, - setRunInputData: vi.fn(), - toVarInputs, - })) + ) expect(result.current.forms[0]!.inputs).toEqual([ expect.objectContaining({ diff --git a/web/app/components/workflow/nodes/llm/__tests__/utils.spec.ts b/web/app/components/workflow/nodes/llm/__tests__/utils.spec.ts index bc4ca0a2a43463..b9c10423c72422 100644 --- a/web/app/components/workflow/nodes/llm/__tests__/utils.spec.ts +++ b/web/app/components/workflow/nodes/llm/__tests__/utils.spec.ts @@ -3,21 +3,27 @@ import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from describe('llm utils', () => { describe('getLLMModelIssue', () => { it('returns provider-required when the model provider is missing', () => { - expect(getLLMModelIssue({ modelProvider: undefined })).toBe(LLMModelIssueCode.providerRequired) + expect(getLLMModelIssue({ modelProvider: undefined })).toBe( + LLMModelIssueCode.providerRequired, + ) }) it('returns provider-plugin-unavailable when the provider plugin is not installed', () => { - expect(getLLMModelIssue({ - modelProvider: 'langgenius/openai/gpt-4.1', - isModelProviderInstalled: false, - })).toBe(LLMModelIssueCode.providerPluginUnavailable) + expect( + getLLMModelIssue({ + modelProvider: 'langgenius/openai/gpt-4.1', + isModelProviderInstalled: false, + }), + ).toBe(LLMModelIssueCode.providerPluginUnavailable) }) it('returns null when the provider is present and installed', () => { - expect(getLLMModelIssue({ - modelProvider: 'langgenius/openai/gpt-4.1', - isModelProviderInstalled: true, - })).toBeNull() + expect( + getLLMModelIssue({ + modelProvider: 'langgenius/openai/gpt-4.1', + isModelProviderInstalled: true, + }), + ).toBeNull() }) }) @@ -27,17 +33,15 @@ describe('llm utils', () => { }) it('matches installed plugin ids using the provider plugin prefix', () => { - expect(isLLMModelProviderInstalled( - 'langgenius/openai/gpt-4.1', - new Set(['langgenius/openai']), - )).toBe(true) + expect( + isLLMModelProviderInstalled('langgenius/openai/gpt-4.1', new Set(['langgenius/openai'])), + ).toBe(true) }) it('returns false when the provider plugin id is not installed', () => { - expect(isLLMModelProviderInstalled( - 'langgenius/openai/gpt-4.1', - new Set(['langgenius/anthropic']), - )).toBe(false) + expect( + isLLMModelProviderInstalled('langgenius/openai/gpt-4.1', new Set(['langgenius/anthropic'])), + ).toBe(false) }) }) }) diff --git a/web/app/components/workflow/nodes/llm/components/__tests__/panel-memory-section.spec.tsx b/web/app/components/workflow/nodes/llm/components/__tests__/panel-memory-section.spec.tsx index 58cb8461418dac..347d11a4b88451 100644 --- a/web/app/components/workflow/nodes/llm/components/__tests__/panel-memory-section.spec.tsx +++ b/web/app/components/workflow/nodes/llm/components/__tests__/panel-memory-section.spec.tsx @@ -25,9 +25,17 @@ vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({ __esModule: true, - default: (props: { canSetRoleName: boolean, config: { data?: LLMNodeType['memory'] }, defaultMemory?: Memory }) => { + default: (props: { + canSetRoleName: boolean + config: { data?: LLMNodeType['memory'] } + defaultMemory?: Memory + }) => { mockMemoryConfig(props) - return <div data-testid="memory-config">{props.canSetRoleName ? 'can-set-role' : 'cannot-set-role'}</div> + return ( + <div data-testid="memory-config"> + {props.canSetRoleName ? 'can-set-role' : 'cannot-set-role'} + </div> + ) }, })) @@ -87,11 +95,13 @@ describe('llm/panel-memory-section', () => { expect(screen.getByText('workflow.nodes.common.memories.title')).toBeInTheDocument() expect(screen.getByTestId('editor')).toHaveTextContent('{{#sys.query#}}') expect(screen.getByTestId('memory-config')).toHaveTextContent('cannot-set-role') - expect(mockEditor).toHaveBeenCalledWith(expect.objectContaining({ - isChatApp: true, - isChatModel: true, - isShowContext: false, - })) + expect(mockEditor).toHaveBeenCalledWith( + expect.objectContaining({ + isChatApp: true, + isChatModel: true, + isShowContext: false, + }), + ) }) it('shows the sys query warning when the memory prompt omits the required placeholder', () => { @@ -136,22 +146,21 @@ describe('llm/panel-memory-section', () => { }) it('does not default snippet memory prompts to system variables', () => { - render( - <PanelMemorySection - {...baseProps} - flowType={FlowType.snippet} - />, - ) + render(<PanelMemorySection {...baseProps} flowType={FlowType.snippet} />) expect(screen.getByTestId('editor')).toHaveTextContent('') - expect(mockEditor).toHaveBeenCalledWith(expect.objectContaining({ - value: '', - })) - expect(mockMemoryConfig).toHaveBeenCalledWith(expect.objectContaining({ - defaultMemory: expect.objectContaining({ - query_prompt_template: '', + expect(mockEditor).toHaveBeenCalledWith( + expect.objectContaining({ + value: '', }), - })) + ) + expect(mockMemoryConfig).toHaveBeenCalledWith( + expect.objectContaining({ + defaultMemory: expect.objectContaining({ + query_prompt_template: '', + }), + }), + ) }) it('renders nothing outside chat mode', () => { diff --git a/web/app/components/workflow/nodes/llm/components/__tests__/panel-output-section.spec.tsx b/web/app/components/workflow/nodes/llm/components/__tests__/panel-output-section.spec.tsx index 97d5a8eb276bbe..100feef43a5fd3 100644 --- a/web/app/components/workflow/nodes/llm/components/__tests__/panel-output-section.spec.tsx +++ b/web/app/components/workflow/nodes/llm/components/__tests__/panel-output-section.spec.tsx @@ -33,7 +33,11 @@ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ vi.mock('../structure-output', () => ({ __esModule: true, - StructureOutput: (props: { className?: string, value?: StructuredOutput, onChange: (value: StructuredOutput) => void }) => { + StructureOutput: (props: { + className?: string + value?: StructuredOutput + onChange: (value: StructuredOutput) => void + }) => { mockStructureOutput(props) return <div data-testid="structure-output">structured-output</div> }, diff --git a/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx b/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx index 22b5bc1c31ff23..67e159f152ecc9 100644 --- a/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx +++ b/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx @@ -13,9 +13,9 @@ import { useWorkflowStore } from '../../../store' import { EditionType } from '../../../types' const roleDescriptionSelectors: Record<PromptRole, SelectorParam<'workflow'>> = { - [PromptRole.system]: $ => $['nodes.llm.roleDescription.system'], - [PromptRole.user]: $ => $['nodes.llm.roleDescription.user'], - [PromptRole.assistant]: $ => $['nodes.llm.roleDescription.assistant'], + [PromptRole.system]: ($) => $['nodes.llm.roleDescription.system'], + [PromptRole.user]: ($) => $['nodes.llm.roleDescription.user'], + [PromptRole.assistant]: ($) => $['nodes.llm.roleDescription.assistant'], } type Props = Readonly<{ @@ -62,7 +62,7 @@ const roleOptions = [ }, ] -const roleOptionsWithoutSystemRole = roleOptions.filter(item => item.value !== PromptRole.system) +const roleOptionsWithoutSystemRole = roleOptions.filter((item) => item.value !== PromptRole.system) const ConfigPromptItem: FC<Props> = ({ instanceId, @@ -93,14 +93,15 @@ const ConfigPromptItem: FC<Props> = ({ ? t(roleDescriptionSelectors[payload.role], { ns: 'workflow' }) : undefined const workflowStore = useWorkflowStore() - const { - setControlPromptEditorRerenderKey, - } = workflowStore.getState() + const { setControlPromptEditorRerenderKey } = workflowStore.getState() - const handleGenerated = useCallback((prompt: string) => { - onPromptChange(prompt) - setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) - }, [onPromptChange, setControlPromptEditorRerenderKey]) + const handleGenerated = useCallback( + (prompt: string) => { + onPromptChange(prompt) + setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) + }, + [onPromptChange, setControlPromptEditorRerenderKey], + ) return ( <Editor @@ -108,36 +109,31 @@ const ConfigPromptItem: FC<Props> = ({ headerClassName={headerClassName} instanceId={instanceId} key={instanceId} - title={( + title={ <div className="relative left-1 flex items-center"> - {payload.role === PromptRole.system - ? ( - <div className="relative left-[-4px] text-xs font-semibold text-text-secondary uppercase"> - SYSTEM - </div> - ) - : ( - <TypeSelector - value={payload.role as string} - allOptions={roleOptions} - options={canNotChooseSystemRole ? roleOptionsWithoutSystemRole : roleOptions} - onChange={handleChatModeMessageRoleChange} - triggerClassName="text-xs font-semibold text-text-secondary uppercase" - itemClassName="text-[13px] font-medium text-text-secondary" - /> - )} + {payload.role === PromptRole.system ? ( + <div className="relative left-[-4px] text-xs font-semibold text-text-secondary uppercase"> + SYSTEM + </div> + ) : ( + <TypeSelector + value={payload.role as string} + allOptions={roleOptions} + options={canNotChooseSystemRole ? roleOptionsWithoutSystemRole : roleOptions} + onChange={handleChatModeMessageRoleChange} + triggerClassName="text-xs font-semibold text-text-secondary uppercase" + itemClassName="text-[13px] font-medium text-text-secondary" + /> + )} {roleDescription && ( - <Infotip - aria-label={roleDescription} - popupClassName="w-[180px]" - > + <Infotip aria-label={roleDescription} popupClassName="w-[180px]"> {roleDescription} </Infotip> )} </div> - )} - value={payload.edition_type === EditionType.jinja2 ? (payload.jinja2_text || '') : payload.text} + } + value={payload.edition_type === EditionType.jinja2 ? payload.jinja2_text || '' : payload.text} onChange={onPromptChange} readOnly={readOnly} showRemove={canRemove} diff --git a/web/app/components/workflow/nodes/llm/components/config-prompt.tsx b/web/app/components/workflow/nodes/llm/components/config-prompt.tsx index c500a4df8474e2..17fc6a69d8df43 100644 --- a/web/app/components/workflow/nodes/llm/components/config-prompt.tsx +++ b/web/app/components/workflow/nodes/llm/components/config-prompt.tsx @@ -53,55 +53,62 @@ const ConfigPrompt: FC<Props> = ({ }) => { const { t } = useTranslation() const workflowStore = useWorkflowStore() - const { - setControlPromptEditorRerenderKey, - } = workflowStore.getState() - const payloadWithIds = (isChatModel && Array.isArray(payload)) - ? payload.map((item) => { - const id = uuid4() - return { - id: item.id || id, - p: { - ...item, + const { setControlPromptEditorRerenderKey } = workflowStore.getState() + const payloadWithIds = + isChatModel && Array.isArray(payload) + ? payload.map((item) => { + const id = uuid4() + return { id: item.id || id, - }, - } - }) - : [] - const { - availableVars, - availableNodesWithParent, - } = useAvailableVarList(nodeId, { + p: { + ...item, + id: item.id || id, + }, + } + }) + : [] + const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, { onlyLeafNodeVar: false, filterVar, }) - const handleChatModePromptChange = useCallback((index: number) => { - return (prompt: string) => { - const newPrompt = produce(payload as PromptItem[], (draft) => { - draft[index]![draft[index]!.edition_type === EditionType.jinja2 ? 'jinja2_text' : 'text'] = prompt - }) - onChange(newPrompt) - } - }, [onChange, payload]) + const handleChatModePromptChange = useCallback( + (index: number) => { + return (prompt: string) => { + const newPrompt = produce(payload as PromptItem[], (draft) => { + draft[index]![ + draft[index]!.edition_type === EditionType.jinja2 ? 'jinja2_text' : 'text' + ] = prompt + }) + onChange(newPrompt) + } + }, + [onChange, payload], + ) - const handleChatModeEditionTypeChange = useCallback((index: number) => { - return (editionType: EditionType) => { - const newPrompt = produce(payload as PromptItem[], (draft) => { - draft[index]!.edition_type = editionType - }) - onChange(newPrompt) - } - }, [onChange, payload]) + const handleChatModeEditionTypeChange = useCallback( + (index: number) => { + return (editionType: EditionType) => { + const newPrompt = produce(payload as PromptItem[], (draft) => { + draft[index]!.edition_type = editionType + }) + onChange(newPrompt) + } + }, + [onChange, payload], + ) - const handleChatModeMessageRoleChange = useCallback((index: number) => { - return (role: PromptRole) => { - const newPrompt = produce(payload as PromptItem[], (draft) => { - draft[index]!.role = role - }) - onChange(newPrompt) - } - }, [onChange, payload]) + const handleChatModeMessageRoleChange = useCallback( + (index: number) => { + return (role: PromptRole) => { + const newPrompt = produce(payload as PromptItem[], (draft) => { + draft[index]!.role = role + }) + onChange(newPrompt) + } + }, + [onChange, payload], + ) const handleAddPrompt = useCallback(() => { const newPrompt = produce(payload as PromptItem[], (draft) => { @@ -111,140 +118,170 @@ const ConfigPrompt: FC<Props> = ({ return } const isLastItemUser = draft[draft.length - 1]!.role === PromptRole.user - draft.push({ role: isLastItemUser ? PromptRole.assistant : PromptRole.user, text: '', id: uuid4() }) + draft.push({ + role: isLastItemUser ? PromptRole.assistant : PromptRole.user, + text: '', + id: uuid4(), + }) }) onChange(newPrompt) }, [onChange, payload]) - const handleRemove = useCallback((index: number) => { - return () => { - const newPrompt = produce(payload as PromptItem[], (draft) => { - draft.splice(index, 1) + const handleRemove = useCallback( + (index: number) => { + return () => { + const newPrompt = produce(payload as PromptItem[], (draft) => { + draft.splice(index, 1) + }) + onChange(newPrompt) + } + }, + [onChange, payload], + ) + + const handleCompletionPromptChange = useCallback( + (prompt: string) => { + const newPrompt = produce(payload as PromptItem, (draft) => { + draft[draft.edition_type === EditionType.jinja2 ? 'jinja2_text' : 'text'] = prompt }) onChange(newPrompt) - } - }, [onChange, payload]) - - const handleCompletionPromptChange = useCallback((prompt: string) => { - const newPrompt = produce(payload as PromptItem, (draft) => { - draft[draft.edition_type === EditionType.jinja2 ? 'jinja2_text' : 'text'] = prompt - }) - onChange(newPrompt) - }, [onChange, payload]) + }, + [onChange, payload], + ) - const handleGenerated = useCallback((prompt: string) => { - handleCompletionPromptChange(prompt) - setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) - }, [handleCompletionPromptChange, setControlPromptEditorRerenderKey]) + const handleGenerated = useCallback( + (prompt: string) => { + handleCompletionPromptChange(prompt) + setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) + }, + [handleCompletionPromptChange, setControlPromptEditorRerenderKey], + ) - const handleCompletionEditionTypeChange = useCallback((editionType: EditionType) => { - const newPrompt = produce(payload as PromptItem, (draft) => { - draft.edition_type = editionType - }) - onChange(newPrompt) - }, [onChange, payload]) + const handleCompletionEditionTypeChange = useCallback( + (editionType: EditionType) => { + const newPrompt = produce(payload as PromptItem, (draft) => { + draft.edition_type = editionType + }) + onChange(newPrompt) + }, + [onChange, payload], + ) const canChooseSystemRole = (() => { if (isChatModel && Array.isArray(payload)) - return !payload.find(item => item.role === PromptRole.system) + return !payload.find((item) => item.role === PromptRole.system) return false })() return ( <div> - {(isChatModel && Array.isArray(payload)) - ? ( - <div> - <div className="space-y-2"> - <ReactSortable - className="space-y-1" - list={payloadWithIds} - setList={(list) => { - if ((payload as PromptItem[])?.[0]?.role === PromptRole.system && list[0]!.p?.role !== PromptRole.system) - return - - onChange(list.map(item => item.p)) - }} - handle=".handle" - ghostClass="opacity-50" - animation={150} - > - { - (payload as PromptItem[]).map((item, index) => { - const canDrag = (() => { - if (readOnly) - return false - - if (index === 0 && item.role === PromptRole.system) - return false - - return true - })() - return ( - <div key={item.id || index} className="group relative"> - {canDrag && <DragHandle className="absolute top-2 left-[-14px] hidden h-3.5 w-3.5 text-text-quaternary group-hover:block" />} - <ConfigPromptItem - instanceId={item.role === PromptRole.system ? `${nodeId}-chat-workflow-llm-prompt-editor` : `${nodeId}-chat-workflow-llm-prompt-editor-${index}`} - className={cn(canDrag && 'handle')} - headerClassName={cn(canDrag && 'cursor-grab')} - canNotChooseSystemRole={!canChooseSystemRole} - canRemove={payload.length > 1 && !(index === 0 && item.role === PromptRole.system)} - readOnly={readOnly} - id={item.id!} - nodeId={nodeId} - handleChatModeMessageRoleChange={handleChatModeMessageRoleChange(index)} - isChatModel={isChatModel} - isChatApp={isChatApp} - payload={item} - onPromptChange={handleChatModePromptChange(index)} - onEditionTypeChange={handleChatModeEditionTypeChange(index)} - onRemove={handleRemove(index)} - isShowContext={isShowContext} - hasSetBlockStatus={hasSetBlockStatus} - availableVars={availableVars} - availableNodes={availableNodesWithParent} - varList={varList} - handleAddVariable={handleAddVariable} - modelConfig={modelConfig} - /> - </div> - ) - }) - } - </ReactSortable> - </div> - <AddButton - className="mt-2" - text={t($ => $[`${i18nPrefix}.addMessage`], { ns: 'workflow' })} - onClick={handleAddPrompt} - /> - </div> - ) - : ( - <div> - <Editor - instanceId={`${nodeId}-chat-workflow-llm-prompt-editor`} - title={<span className="capitalize">{t($ => $[`${i18nPrefix}.prompt`], { ns: 'workflow' })}</span>} - value={((payload as PromptItem).edition_type === EditionType.basic || !(payload as PromptItem).edition_type) ? (payload as PromptItem).text : ((payload as PromptItem).jinja2_text || '')} - onChange={handleCompletionPromptChange} - readOnly={readOnly} - isChatModel={isChatModel} - isChatApp={isChatApp} - isShowContext={isShowContext} - hasSetBlockStatus={hasSetBlockStatus} - nodesOutputVars={availableVars} - availableNodes={availableNodesWithParent} - isSupportPromptGenerator - isSupportJinja - editionType={(payload as PromptItem).edition_type} - varList={varList} - onEditionTypeChange={handleCompletionEditionTypeChange} - handleAddVariable={handleAddVariable} - onGenerated={handleGenerated} - modelConfig={modelConfig} - /> - </div> - )} + {isChatModel && Array.isArray(payload) ? ( + <div> + <div className="space-y-2"> + <ReactSortable + className="space-y-1" + list={payloadWithIds} + setList={(list) => { + if ( + (payload as PromptItem[])?.[0]?.role === PromptRole.system && + list[0]!.p?.role !== PromptRole.system + ) + return + + onChange(list.map((item) => item.p)) + }} + handle=".handle" + ghostClass="opacity-50" + animation={150} + > + {(payload as PromptItem[]).map((item, index) => { + const canDrag = (() => { + if (readOnly) return false + + if (index === 0 && item.role === PromptRole.system) return false + + return true + })() + return ( + <div key={item.id || index} className="group relative"> + {canDrag && ( + <DragHandle className="absolute top-2 left-[-14px] hidden h-3.5 w-3.5 text-text-quaternary group-hover:block" /> + )} + <ConfigPromptItem + instanceId={ + item.role === PromptRole.system + ? `${nodeId}-chat-workflow-llm-prompt-editor` + : `${nodeId}-chat-workflow-llm-prompt-editor-${index}` + } + className={cn(canDrag && 'handle')} + headerClassName={cn(canDrag && 'cursor-grab')} + canNotChooseSystemRole={!canChooseSystemRole} + canRemove={ + payload.length > 1 && !(index === 0 && item.role === PromptRole.system) + } + readOnly={readOnly} + id={item.id!} + nodeId={nodeId} + handleChatModeMessageRoleChange={handleChatModeMessageRoleChange(index)} + isChatModel={isChatModel} + isChatApp={isChatApp} + payload={item} + onPromptChange={handleChatModePromptChange(index)} + onEditionTypeChange={handleChatModeEditionTypeChange(index)} + onRemove={handleRemove(index)} + isShowContext={isShowContext} + hasSetBlockStatus={hasSetBlockStatus} + availableVars={availableVars} + availableNodes={availableNodesWithParent} + varList={varList} + handleAddVariable={handleAddVariable} + modelConfig={modelConfig} + /> + </div> + ) + })} + </ReactSortable> + </div> + <AddButton + className="mt-2" + text={t(($) => $[`${i18nPrefix}.addMessage`], { ns: 'workflow' })} + onClick={handleAddPrompt} + /> + </div> + ) : ( + <div> + <Editor + instanceId={`${nodeId}-chat-workflow-llm-prompt-editor`} + title={ + <span className="capitalize"> + {t(($) => $[`${i18nPrefix}.prompt`], { ns: 'workflow' })} + </span> + } + value={ + (payload as PromptItem).edition_type === EditionType.basic || + !(payload as PromptItem).edition_type + ? (payload as PromptItem).text + : (payload as PromptItem).jinja2_text || '' + } + onChange={handleCompletionPromptChange} + readOnly={readOnly} + isChatModel={isChatModel} + isChatApp={isChatApp} + isShowContext={isShowContext} + hasSetBlockStatus={hasSetBlockStatus} + nodesOutputVars={availableVars} + availableNodes={availableNodesWithParent} + isSupportPromptGenerator + isSupportJinja + editionType={(payload as PromptItem).edition_type} + varList={varList} + onEditionTypeChange={handleCompletionEditionTypeChange} + handleAddVariable={handleAddVariable} + onGenerated={handleGenerated} + modelConfig={modelConfig} + /> + </div> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx index 769f7c97901713..0dde441aaab2b3 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx @@ -18,7 +18,8 @@ vi.mock('../visual-editor/context', () => ({ })) vi.mock('../visual-editor/store', () => ({ - useVisualEditorStore: (selector: (state: typeof visualEditorState) => unknown) => selector(visualEditorState), + useVisualEditorStore: (selector: (state: typeof visualEditorState) => unknown) => + selector(visualEditorState), })) vi.mock('../../../utils', () => ({ @@ -26,18 +27,8 @@ vi.mock('../../../utils', () => ({ })) vi.mock('../code-editor', () => ({ - default: ({ - value, - onUpdate, - }: { - value: string - onUpdate: (value: string) => void - }) => ( - <textarea - aria-label="json-editor" - value={value} - onChange={e => onUpdate(e.target.value)} - /> + default: ({ value, onUpdate }: { value: string; onUpdate: (value: string) => void }) => ( + <textarea aria-label="json-editor" value={value} onChange={(e) => onUpdate(e.target.value)} /> ), })) @@ -53,25 +44,26 @@ vi.mock('@langgenius/dify-ui/popover', async () => { onOpenChange?: (open: boolean) => void } | null>(null) - const PopoverTrigger = ReactModule.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>( - ({ children, onClick, ...props }, ref) => { - const context = ReactModule.useContext(PopoverContext) - - return ( - <button - ref={ref} - type="button" - {...props} - onClick={(event) => { - onClick?.(event) - context?.onOpenChange?.(!context.open) - }} - > - {children} - </button> - ) - }, - ) + const PopoverTrigger = ReactModule.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes<HTMLButtonElement> + >(({ children, onClick, ...props }, ref) => { + const context = ReactModule.useContext(PopoverContext) + + return ( + <button + ref={ref} + type="button" + {...props} + onClick={(event) => { + onClick?.(event) + context?.onOpenChange?.(!context.open) + }} + > + {children} + </button> + ) + }) PopoverTrigger.displayName = 'PopoverTrigger' @@ -85,15 +77,12 @@ vi.mock('@langgenius/dify-ui/popover', async () => { open: boolean onOpenChange?: (open: boolean) => void }) => ( - <PopoverContext.Provider value={{ open, onOpenChange }}> - {children} - </PopoverContext.Provider> + <PopoverContext.Provider value={{ open, onOpenChange }}>{children}</PopoverContext.Provider> ), PopoverTrigger, PopoverContent: ({ children }: { children: React.ReactNode }) => { const context = ReactModule.useContext(PopoverContext) - if (!context?.open) - return null + if (!context?.open) return null return <div data-testid="popover-content">{children}</div> }, @@ -132,16 +121,13 @@ describe('JsonImporter', () => { it('measures the trigger width and opens the importer without quitting editing by default', async () => { const user = userEvent.setup() - render( - <JsonImporter - onSubmit={mockOnSubmit} - updateBtnWidth={mockUpdateBtnWidth} - />, - ) + render(<JsonImporter onSubmit={mockOnSubmit} updateBtnWidth={mockUpdateBtnWidth} />) expect(mockUpdateBtnWidth).toHaveBeenCalledWith(88) - await user.click(screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ })) + await user.click( + screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ }), + ) expect(screen.getByTestId('popover-content')).toBeInTheDocument() expect(mockEmit).not.toHaveBeenCalled() @@ -151,14 +137,11 @@ describe('JsonImporter', () => { visualEditorState.advancedEditing = true const user = userEvent.setup() - render( - <JsonImporter - onSubmit={mockOnSubmit} - updateBtnWidth={mockUpdateBtnWidth} - />, - ) + render(<JsonImporter onSubmit={mockOnSubmit} updateBtnWidth={mockUpdateBtnWidth} />) - await user.click(screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ })) + await user.click( + screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ }), + ) expect(mockEmit).toHaveBeenCalledWith('quitEditing', {}) }) @@ -166,18 +149,17 @@ describe('JsonImporter', () => { it('shows a parse error when the root value is not an object', async () => { const user = userEvent.setup() - render( - <JsonImporter - onSubmit={mockOnSubmit} - updateBtnWidth={mockUpdateBtnWidth} - />, - ) + render(<JsonImporter onSubmit={mockOnSubmit} updateBtnWidth={mockUpdateBtnWidth} />) - await user.click(screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ })) + await user.click( + screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ }), + ) fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '[]' } }) await user.click(screen.getByRole('button', { name: /(?:^|\.)operation\.submit(?=$|:)/ })) - expect(screen.getByTestId('error-message')).toHaveTextContent('Root must be an object, not an array or primitive value.') + expect(screen.getByTestId('error-message')).toHaveTextContent( + 'Root must be an object, not an array or primitive value.', + ) expect(mockOnSubmit).not.toHaveBeenCalled() }) @@ -185,18 +167,19 @@ describe('JsonImporter', () => { mockCheckJsonDepth.mockReturnValue(JSON_SCHEMA_MAX_DEPTH + 1) const user = userEvent.setup() - render( - <JsonImporter - onSubmit={mockOnSubmit} - updateBtnWidth={mockUpdateBtnWidth} - />, - ) + render(<JsonImporter onSubmit={mockOnSubmit} updateBtnWidth={mockUpdateBtnWidth} />) - await user.click(screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ })) - fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '{"foo":{"bar":1}}' } }) + await user.click( + screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ }), + ) + fireEvent.change(screen.getByLabelText('json-editor'), { + target: { value: '{"foo":{"bar":1}}' }, + }) await user.click(screen.getByRole('button', { name: /(?:^|\.)operation\.submit(?=$|:)/ })) - expect(screen.getByTestId('error-message')).toHaveTextContent(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`) + expect(screen.getByTestId('error-message')).toHaveTextContent( + `Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`, + ) expect(mockOnSubmit).not.toHaveBeenCalled() }) @@ -206,14 +189,11 @@ describe('JsonImporter', () => { throw new Error('Malformed JSON payload') }) - render( - <JsonImporter - onSubmit={mockOnSubmit} - updateBtnWidth={mockUpdateBtnWidth} - />, - ) + render(<JsonImporter onSubmit={mockOnSubmit} updateBtnWidth={mockUpdateBtnWidth} />) - await user.click(screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ })) + await user.click( + screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ }), + ) fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '{"foo":1}' } }) await user.click(screen.getByRole('button', { name: /(?:^|\.)operation\.submit(?=$|:)/ })) @@ -225,16 +205,15 @@ describe('JsonImporter', () => { it('falls back to the default invalid JSON message when JSON.parse throws a non-Error value', async () => { const user = userEvent.setup() - const parseSpy = vi.spyOn(JSON, 'parse').mockImplementationOnce(() => throwUnknown(Object.create(null))) + const parseSpy = vi + .spyOn(JSON, 'parse') + .mockImplementationOnce(() => throwUnknown(Object.create(null))) - render( - <JsonImporter - onSubmit={mockOnSubmit} - updateBtnWidth={mockUpdateBtnWidth} - />, - ) + render(<JsonImporter onSubmit={mockOnSubmit} updateBtnWidth={mockUpdateBtnWidth} />) - await user.click(screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ })) + await user.click( + screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ }), + ) fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '{"foo":1}' } }) await user.click(screen.getByRole('button', { name: /(?:^|\.)operation\.submit(?=$|:)/ })) @@ -247,21 +226,20 @@ describe('JsonImporter', () => { it('submits valid JSON and closes the popover from footer actions', async () => { const user = userEvent.setup() - render( - <JsonImporter - onSubmit={mockOnSubmit} - updateBtnWidth={mockUpdateBtnWidth} - />, - ) + render(<JsonImporter onSubmit={mockOnSubmit} updateBtnWidth={mockUpdateBtnWidth} />) - await user.click(screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ })) + await user.click( + screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ }), + ) fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '{"foo":"bar"}' } }) await user.click(screen.getByRole('button', { name: /(?:^|\.)operation\.submit(?=$|:)/ })) expect(mockOnSubmit).toHaveBeenCalledWith({ foo: 'bar' }) expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ })) + await user.click( + screen.getByRole('button', { name: /(?:^|\.)nodes\.llm\.jsonSchema\.import(?=$|:)/ }), + ) await user.click(screen.getByRole('button', { name: /(?:^|\.)operation\.cancel(?=$|:)/ })) expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx index 359101958568b2..2c48b06c41cac3 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx @@ -46,61 +46,62 @@ const CodeEditor: FC<CodeEditorProps> = ({ useEffect(() => { if (monacoRef.current) { - if (theme === Theme.light) - monacoRef.current.editor.setTheme('light-theme') - else - monacoRef.current.editor.setTheme('dark-theme') + if (theme === Theme.light) monacoRef.current.editor.setTheme('light-theme') + else monacoRef.current.editor.setTheme('dark-theme') } }, [theme]) - const handleEditorDidMount = useCallback<EditorOnMount>((editor, monaco) => { - editorRef.current = editor - monacoRef.current = monaco + const handleEditorDidMount = useCallback<EditorOnMount>( + (editor, monaco) => { + editorRef.current = editor + monacoRef.current = monaco - editor.onDidFocusEditorText(() => { - onFocus?.() - }) - editor.onDidBlurEditorText(() => { - onBlur?.() - }) + editor.onDidFocusEditorText(() => { + onFocus?.() + }) + editor.onDidBlurEditorText(() => { + onBlur?.() + }) - monaco.editor.defineTheme('light-theme', { - base: 'vs', - inherit: true, - rules: [], - colors: { - 'editor.background': '#00000000', - 'editor.lineHighlightBackground': '#00000000', - 'focusBorder': '#00000000', - }, - }) - monaco.editor.defineTheme('dark-theme', { - base: 'vs-dark', - inherit: true, - rules: [], - colors: { - 'editor.background': '#00000000', - 'editor.lineHighlightBackground': '#00000000', - 'focusBorder': '#00000000', - }, - }) - monaco.editor.setTheme('light-theme') - setIsMounted(true) - }, [onBlur, onFocus]) + monaco.editor.defineTheme('light-theme', { + base: 'vs', + inherit: true, + rules: [], + colors: { + 'editor.background': '#00000000', + 'editor.lineHighlightBackground': '#00000000', + focusBorder: '#00000000', + }, + }) + monaco.editor.defineTheme('dark-theme', { + base: 'vs-dark', + inherit: true, + rules: [], + colors: { + 'editor.background': '#00000000', + 'editor.lineHighlightBackground': '#00000000', + focusBorder: '#00000000', + }, + }) + monaco.editor.setTheme('light-theme') + setIsMounted(true) + }, + [onBlur, onFocus], + ) const formatJsonContent = useCallback(() => { - if (editorRef.current) - editorRef.current.getAction('editor.action.formatDocument')?.run() + if (editorRef.current) editorRef.current.getAction('editor.action.formatDocument')?.run() }, []) - const handleEditorChange = useCallback((value: string | undefined) => { - if (value !== undefined) - onUpdate?.(value) - }, [onUpdate]) + const handleEditorChange = useCallback( + (value: string | undefined) => { + if (value !== undefined) onUpdate?.(value) + }, + [onUpdate], + ) const editorTheme = useMemo(() => { - if (theme === Theme.light) - return 'light-theme' + if (theme === Theme.light) return 'light-theme' return 'dark-theme' }, [theme]) useEffect(() => { @@ -108,8 +109,7 @@ const CodeEditor: FC<CodeEditorProps> = ({ editorRef.current?.layout() }) - if (containerRef.current) - resizeObserver.observe(containerRef.current) + if (containerRef.current) resizeObserver.observe(containerRef.current) return () => { resizeObserver.disconnect() @@ -117,7 +117,13 @@ const CodeEditor: FC<CodeEditorProps> = ({ }, []) return ( - <div className={cn('flex h-full flex-col overflow-hidden bg-components-input-bg-normal', hideTopMenu && 'pt-2', className)}> + <div + className={cn( + 'flex h-full flex-col overflow-hidden bg-components-input-bg-normal', + hideTopMenu && 'pt-2', + className, + )} + > {!hideTopMenu && ( <div className="flex items-center justify-between pt-1 pr-1 pl-2"> <div className="py-0.5 system-xs-semibold-uppercase text-text-secondary"> @@ -127,34 +133,37 @@ const CodeEditor: FC<CodeEditorProps> = ({ {showFormatButton && ( <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['operation.format'], { ns: 'common' })} + aria-label={t(($) => $['operation.format'], { ns: 'common' })} className="flex size-6 items-center justify-center" onClick={formatJsonContent} > - <span aria-hidden className="i-ri-indent-increase size-4 text-text-tertiary" /> + <span + aria-hidden + className="i-ri-indent-increase size-4 text-text-tertiary" + /> </button> - )} + } /> - <TooltipContent>{t($ => $['operation.format'], { ns: 'common' })}</TooltipContent> + <TooltipContent>{t(($) => $['operation.format'], { ns: 'common' })}</TooltipContent> </Tooltip> )} <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['operation.copy'], { ns: 'common' })} + aria-label={t(($) => $['operation.copy'], { ns: 'common' })} className="flex size-6 items-center justify-center" onClick={() => copy(value)} > <span aria-hidden className="i-ri-clipboard-line size-4 text-text-tertiary" /> </button> - )} + } /> - <TooltipContent>{t($ => $['operation.copy'], { ns: 'common' })}</TooltipContent> + <TooltipContent>{t(($) => $['operation.copy'], { ns: 'common' })}</TooltipContent> </Tooltip> </div> </div> diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message.tsx index e2b97f77faa2b3..90d5572f462d11 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message.tsx @@ -7,12 +7,14 @@ type ErrorMessageProps = { message: string } & React.HTMLAttributes<HTMLDivElement> -const ErrorMessage: FC<ErrorMessageProps> = ({ - message, - className, -}) => { +const ErrorMessage: FC<ErrorMessageProps> = ({ message, className }) => { return ( - <div className={cn('mt-1 flex gap-x-1 rounded-lg border-[0.5px] border-components-panel-border bg-toast-error-bg p-2', className)}> + <div + className={cn( + 'mt-1 flex gap-x-1 rounded-lg border-[0.5px] border-components-panel-border bg-toast-error-bg p-2', + className, + )} + > <RiErrorWarningFill className="size-4 shrink-0 text-text-destructive" /> <div className="max-h-12 grow overflow-y-auto system-xs-medium wrap-break-word whitespace-pre-line text-text-primary"> {message} diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx index 5b3d1874061834..de2401d4147a3e 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx @@ -19,17 +19,11 @@ export function JsonSchemaConfigModal({ <Dialog open={isShow} onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DialogContent className="h-[calc(100dvh-32px)] max-h-[800px] w-full max-w-[960px] overflow-hidden! border-none p-0 text-left align-middle"> - - <JsonSchemaConfig - defaultSchema={defaultSchema} - onSave={onSave} - onClose={onClose} - /> + <JsonSchemaConfig defaultSchema={defaultSchema} onSave={onSave} onClose={onClose} /> </DialogContent> </Dialog> ) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx index a6684bd684356f..60f09d067aaae3 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx @@ -18,17 +18,14 @@ type JsonImporterProps = { updateBtnWidth: (width: number) => void } -const JsonImporter: FC<JsonImporterProps> = ({ - onSubmit, - updateBtnWidth, -}) => { +const JsonImporter: FC<JsonImporterProps> = ({ onSubmit, updateBtnWidth }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) const [json, setJson] = useState('') const [parseError, setParseError] = useState<any>(null) const importBtnRef = useRef<HTMLButtonElement>(null) - const advancedEditing = useVisualEditorStore(state => state.advancedEditing) - const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField) + const advancedEditing = useVisualEditorStore((state) => state.advancedEditing) + const isAddingNewField = useVisualEditorStore((state) => state.isAddingNewField) const { emit } = useMittContext() useEffect(() => { @@ -38,11 +35,13 @@ const JsonImporter: FC<JsonImporterProps> = ({ } }, [updateBtnWidth]) - const handleOpenChange = useCallback((nextOpen: boolean) => { - if (nextOpen && (advancedEditing || isAddingNewField)) - emit('quitEditing', {}) - setOpen(nextOpen) - }, [advancedEditing, emit, isAddingNewField]) + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (nextOpen && (advancedEditing || isAddingNewField)) emit('quitEditing', {}) + setOpen(nextOpen) + }, + [advancedEditing, emit, isAddingNewField], + ) const onClose = useCallback(() => { setOpen(false) @@ -66,29 +65,25 @@ const JsonImporter: FC<JsonImporterProps> = ({ onSubmit(parsedJSON) setParseError(null) setOpen(false) - } - catch (e: any) { - if (e instanceof Error) - setParseError(e) - else - setParseError(new Error('Invalid JSON')) + } catch (e: any) { + if (e instanceof Error) setParseError(e) + else setParseError(new Error('Invalid JSON')) } }, [onSubmit, json]) return ( - <Popover - open={open} - onOpenChange={handleOpenChange} - > + <Popover open={open} onOpenChange={handleOpenChange}> <PopoverTrigger ref={importBtnRef} - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} className={cn( 'flex shrink-0 rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary hover:bg-components-button-ghost-bg-hover', open && 'bg-components-button-ghost-bg-hover', )} > - <span className="px-0.5">{t($ => $['nodes.llm.jsonSchema.import'], { ns: 'workflow' })}</span> + <span className="px-0.5"> + {t(($) => $['nodes.llm.jsonSchema.import'], { ns: 'workflow' })} + </span> </PopoverTrigger> <PopoverContent placement="bottom-end" @@ -101,14 +96,14 @@ const JsonImporter: FC<JsonImporterProps> = ({ <div className="relative px-3 pt-3.5 pb-1"> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute right-2.5 bottom-0 flex size-8 items-center justify-center border-none bg-transparent p-0" onClick={onClose} > <RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" /> </button> <div className="flex pr-8 pl-1 system-xl-semibold text-text-primary"> - {t($ => $['nodes.llm.jsonSchema.import'], { ns: 'workflow' })} + {t(($) => $['nodes.llm.jsonSchema.import'], { ns: 'workflow' })} </div> </div> {/* Content */} @@ -125,10 +120,10 @@ const JsonImporter: FC<JsonImporterProps> = ({ {/* Footer */} <div className="flex items-center justify-end gap-x-2 p-4 pt-2"> <Button variant="secondary" onClick={onClose}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button variant="primary" onClick={handleSubmit}> - {t($ => $['operation.submit'], { ns: 'common' })} + {t(($) => $['operation.submit'], { ns: 'common' })} </Button> </div> </div> diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx index 47b2d0caa18d19..90c04ebec60e9a 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx @@ -58,23 +58,21 @@ const DEFAULT_SCHEMA: SchemaRoot = { additionalProperties: false, } -function JsonSchemaConfigContent({ - defaultSchema, - onSave, - onClose, -}: JsonSchemaConfigProps) { +function JsonSchemaConfigContent({ defaultSchema, onSave, onClose }: JsonSchemaConfigProps) { const { t } = useTranslation() - const [selectedSchemaViews, setSelectedSchemaViews] = useState<readonly SchemaView[]>([SchemaView.VisualEditor]) + const [selectedSchemaViews, setSelectedSchemaViews] = useState<readonly SchemaView[]>([ + SchemaView.VisualEditor, + ]) const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA) const [json, setJson] = useState(() => JSON.stringify(jsonSchema, null, 2)) const [btnWidth, setBtnWidth] = useState(0) const [parseError, setParseError] = useState<Error | null>(null) const [validationError, setValidationError] = useState<string>('') - const advancedEditing = useVisualEditorStore(state => state.advancedEditing) - const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing) - const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField) - const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField) - const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty) + const advancedEditing = useVisualEditorStore((state) => state.advancedEditing) + const setAdvancedEditing = useVisualEditorStore((state) => state.setAdvancedEditing) + const isAddingNewField = useVisualEditorStore((state) => state.isAddingNewField) + const setIsAddingNewField = useVisualEditorStore((state) => state.setIsAddingNewField) + const setHoveringProperty = useVisualEditorStore((state) => state.setHoveringProperty) const { emit } = useMittContext() const selectedSchemaView = selectedSchemaViews[0] ?? SchemaView.VisualEditor @@ -83,8 +81,7 @@ function JsonSchemaConfigContent({ } function handleSchemaViewChange(value: SchemaView) { - if (selectedSchemaView === value) - return + if (selectedSchemaView === value) return if (selectedSchemaView === SchemaView.JsonSchema) { try { const schema = JSON.parse(json) @@ -106,37 +103,31 @@ function JsonSchemaConfigContent({ } setJsonSchema(schema) setValidationError('') - } - catch (error) { + } catch (error) { setValidationError('') - if (error instanceof Error) - setParseError(error) - else - setParseError(new Error('Invalid JSON')) + if (error instanceof Error) setParseError(error) + else setParseError(new Error('Invalid JSON')) return } - } - else if (selectedSchemaView === SchemaView.VisualEditor) { + } else if (selectedSchemaView === SchemaView.VisualEditor) { if (advancedEditing || isAddingNewField) - emit('quitEditing', { callback: (backup: SchemaRoot) => setJson(JSON.stringify(backup || jsonSchema, null, 2)) }) - else - setJson(JSON.stringify(jsonSchema, null, 2)) + emit('quitEditing', { + callback: (backup: SchemaRoot) => setJson(JSON.stringify(backup || jsonSchema, null, 2)), + }) + else setJson(JSON.stringify(jsonSchema, null, 2)) } setSelectedSchemaViews([value]) } function handleApplySchema(schema: SchemaRoot) { - if (selectedSchemaView === SchemaView.VisualEditor) - setJsonSchema(schema) - else if (selectedSchemaView === SchemaView.JsonSchema) - setJson(JSON.stringify(schema, null, 2)) + if (selectedSchemaView === SchemaView.VisualEditor) setJsonSchema(schema) + else if (selectedSchemaView === SchemaView.JsonSchema) setJson(JSON.stringify(schema, null, 2)) } function handleSubmit(schema: Record<string, unknown>) { const jsonSchema = jsonToSchema(schema) as SchemaRoot - if (selectedSchemaView === SchemaView.VisualEditor) - setJsonSchema(jsonSchema) + if (selectedSchemaView === SchemaView.VisualEditor) setJsonSchema(jsonSchema) else if (selectedSchemaView === SchemaView.JsonSchema) setJson(JSON.stringify(jsonSchema, null, 2)) } @@ -152,10 +143,8 @@ function JsonSchemaConfigContent({ function handleResetDefaults() { if (selectedSchemaView === SchemaView.VisualEditor) { setHoveringProperty(null) - if (advancedEditing) - setAdvancedEditing(false) - if (isAddingNewField) - setIsAddingNewField(false) + if (advancedEditing) setAdvancedEditing(false) + if (isAddingNewField) setIsAddingNewField(false) } setJsonSchema(DEFAULT_SCHEMA) setJson(JSON.stringify(DEFAULT_SCHEMA, null, 2)) @@ -188,19 +177,17 @@ function JsonSchemaConfigContent({ } setJsonSchema(schema) setValidationError('') - } - catch (error) { + } catch (error) { setValidationError('') - if (error instanceof Error) - setParseError(error) - else - setParseError(new Error('Invalid JSON')) + if (error instanceof Error) setParseError(error) + else setParseError(new Error('Invalid JSON')) return } - } - else if (selectedSchemaView === SchemaView.VisualEditor) { + } else if (selectedSchemaView === SchemaView.VisualEditor) { if (advancedEditing || isAddingNewField) { - toast.warning(t($ => $['nodes.llm.jsonSchema.warningTips.saveSchema'], { ns: 'workflow' })) + toast.warning( + t(($) => $['nodes.llm.jsonSchema.warningTips.saveSchema'], { ns: 'workflow' }), + ) return } } @@ -213,12 +200,12 @@ function JsonSchemaConfigContent({ {/* Header */} <div className="relative flex p-6 pr-14 pb-3"> <div className="grow truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['nodes.llm.jsonSchema.title'], { ns: 'workflow' })} + {t(($) => $['nodes.llm.jsonSchema.title'], { ns: 'workflow' })} </div> <button type="button" className="absolute top-5 right-5 flex size-8 items-center justify-center p-1.5" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onClose} > <span className="i-ri-close-line h-[18px] w-[18px] text-text-tertiary" /> @@ -226,12 +213,11 @@ function JsonSchemaConfigContent({ </div> <div className="flex items-center justify-between px-6 py-2"> <SegmentedControl<SchemaView> - aria-label={t($ => $['nodes.llm.jsonSchema.title'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.llm.jsonSchema.title'], { ns: 'workflow' })} value={selectedSchemaViews} onValueChange={(nextSchemaViews) => { const value = nextSchemaViews[0] - if (value) - handleSchemaViewChange(value) + if (value) handleSchemaViewChange(value) }} > {SCHEMA_VIEW_OPTIONS.map(({ Icon, text, value }) => ( @@ -243,30 +229,18 @@ function JsonSchemaConfigContent({ </SegmentedControl> <div className="flex items-center gap-x-0.5"> {/* JSON Schema Generator */} - <JsonSchemaGenerator - crossAxisOffset={btnWidth} - onApply={handleApplySchema} - /> + <JsonSchemaGenerator crossAxisOffset={btnWidth} onApply={handleApplySchema} /> <Divider type="vertical" className="h-3" /> {/* JSON Schema Importer */} - <JsonImporter - updateBtnWidth={updateBtnWidth} - onSubmit={handleSubmit} - /> + <JsonImporter updateBtnWidth={updateBtnWidth} onSubmit={handleSubmit} /> </div> </div> <div className="flex grow flex-col gap-y-1 overflow-hidden px-6"> {selectedSchemaView === SchemaView.VisualEditor && ( - <VisualEditor - schema={jsonSchema} - onChange={handleVisualEditorUpdate} - /> + <VisualEditor schema={jsonSchema} onChange={handleVisualEditorUpdate} /> )} {selectedSchemaView === SchemaView.JsonSchema && ( - <SchemaEditor - schema={json} - onUpdate={handleSchemaEditorUpdate} - /> + <SchemaEditor schema={json} onUpdate={handleSchemaEditorUpdate} /> )} {parseError && <ErrorMessage message={parseError.message} />} {validationError && <ErrorMessage message={validationError} />} @@ -276,16 +250,16 @@ function JsonSchemaConfigContent({ <div className="flex items-center gap-x-3"> <div className="flex items-center gap-x-2"> <Button variant="secondary" onClick={handleResetDefaults}> - {t($ => $['nodes.llm.jsonSchema.resetDefaults'], { ns: 'workflow' })} + {t(($) => $['nodes.llm.jsonSchema.resetDefaults'], { ns: 'workflow' })} </Button> <Divider type="vertical" className="mr-0 ml-1 h-4" /> </div> <div className="flex items-center gap-x-2"> <Button variant="secondary" onClick={handleCancel}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button variant="primary" onClick={handleSave}> - {t($ => $['operation.save'], { ns: 'common' })} + {t(($) => $['operation.save'], { ns: 'common' })} </Button> </div> </div> diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/index.tsx index 91dc3dfdd5110d..dce42fc7ed80e0 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/index.tsx @@ -1,7 +1,4 @@ import SchemaGeneratorDark from './schema-generator-dark' import SchemaGeneratorLight from './schema-generator-light' -export { - SchemaGeneratorDark, - SchemaGeneratorLight, -} +export { SchemaGeneratorDark, SchemaGeneratorLight } diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-dark.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-dark.tsx index ac4793b1e35c0b..13b3b2911cdee5 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-dark.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-dark.tsx @@ -1,9 +1,20 @@ const SchemaGeneratorDark = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> - <path d="M9.33329 2.95825C10.2308 2.95825 10.9583 2.23071 10.9583 1.33325H11.7083C11.7083 2.23071 12.4358 2.95825 13.3333 2.95825V3.70825C12.4358 3.70825 11.7083 4.43579 11.7083 5.33325H10.9583C10.9583 4.43579 10.2308 3.70825 9.33329 3.70825V2.95825ZM0.666626 7.33325C2.87577 7.33325 4.66663 5.54239 4.66663 3.33325H5.99996C5.99996 5.54239 7.79083 7.33325 9.99996 7.33325V8.66659C7.79083 8.66659 5.99996 10.4575 5.99996 12.6666H4.66663C4.66663 10.4575 2.87577 8.66659 0.666626 8.66659V7.33325ZM11.5 9.33325C11.5 10.5299 10.5299 11.4999 9.33329 11.4999V12.4999C10.5299 12.4999 11.5 13.47 11.5 14.6666H12.5C12.5 13.47 13.47 12.4999 14.6666 12.4999V11.4999C13.47 11.4999 12.5 10.5299 12.5 9.33325H11.5Z" fill="url(#paint0_linear_13059_32065)" fillOpacity="0.95" /> + <path + d="M9.33329 2.95825C10.2308 2.95825 10.9583 2.23071 10.9583 1.33325H11.7083C11.7083 2.23071 12.4358 2.95825 13.3333 2.95825V3.70825C12.4358 3.70825 11.7083 4.43579 11.7083 5.33325H10.9583C10.9583 4.43579 10.2308 3.70825 9.33329 3.70825V2.95825ZM0.666626 7.33325C2.87577 7.33325 4.66663 5.54239 4.66663 3.33325H5.99996C5.99996 5.54239 7.79083 7.33325 9.99996 7.33325V8.66659C7.79083 8.66659 5.99996 10.4575 5.99996 12.6666H4.66663C4.66663 10.4575 2.87577 8.66659 0.666626 8.66659V7.33325ZM11.5 9.33325C11.5 10.5299 10.5299 11.4999 9.33329 11.4999V12.4999C10.5299 12.4999 11.5 13.47 11.5 14.6666H12.5C12.5 13.47 13.47 12.4999 14.6666 12.4999V11.4999C13.47 11.4999 12.5 10.5299 12.5 9.33325H11.5Z" + fill="url(#paint0_linear_13059_32065)" + fillOpacity="0.95" + /> <defs> - <linearGradient id="paint0_linear_13059_32065" x1="14.9996" y1="15" x2="-2.55847" y2="16.6207" gradientUnits="userSpaceOnUse"> + <linearGradient + id="paint0_linear_13059_32065" + x1="14.9996" + y1="15" + x2="-2.55847" + y2="16.6207" + gradientUnits="userSpaceOnUse" + > <stop stopColor="#36BFFA" /> <stop offset="1" stopColor="#296DFF" /> </linearGradient> diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-light.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-light.tsx index 8b898bde6839ad..08eee41f9295af 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-light.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-light.tsx @@ -1,9 +1,20 @@ const SchemaGeneratorLight = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> - <path d="M9.33329 2.95837C10.2308 2.95837 10.9583 2.23083 10.9583 1.33337H11.7083C11.7083 2.23083 12.4358 2.95837 13.3333 2.95837V3.70837C12.4358 3.70837 11.7083 4.43591 11.7083 5.33337H10.9583C10.9583 4.43591 10.2308 3.70837 9.33329 3.70837V2.95837ZM0.666626 7.33337C2.87577 7.33337 4.66663 5.54251 4.66663 3.33337H5.99996C5.99996 5.54251 7.79083 7.33337 9.99996 7.33337V8.66671C7.79083 8.66671 5.99996 10.4576 5.99996 12.6667H4.66663C4.66663 10.4576 2.87577 8.66671 0.666626 8.66671V7.33337ZM11.5 9.33337C11.5 10.53 10.5299 11.5 9.33329 11.5V12.5C10.5299 12.5 11.5 13.4701 11.5 14.6667H12.5C12.5 13.4701 13.47 12.5 14.6666 12.5V11.5C13.47 11.5 12.5 10.53 12.5 9.33337H11.5Z" fill="url(#paint0_linear_13059_18704)" fillOpacity="0.95" /> + <path + d="M9.33329 2.95837C10.2308 2.95837 10.9583 2.23083 10.9583 1.33337H11.7083C11.7083 2.23083 12.4358 2.95837 13.3333 2.95837V3.70837C12.4358 3.70837 11.7083 4.43591 11.7083 5.33337H10.9583C10.9583 4.43591 10.2308 3.70837 9.33329 3.70837V2.95837ZM0.666626 7.33337C2.87577 7.33337 4.66663 5.54251 4.66663 3.33337H5.99996C5.99996 5.54251 7.79083 7.33337 9.99996 7.33337V8.66671C7.79083 8.66671 5.99996 10.4576 5.99996 12.6667H4.66663C4.66663 10.4576 2.87577 8.66671 0.666626 8.66671V7.33337ZM11.5 9.33337C11.5 10.53 10.5299 11.5 9.33329 11.5V12.5C10.5299 12.5 11.5 13.4701 11.5 14.6667H12.5C12.5 13.4701 13.47 12.5 14.6666 12.5V11.5C13.47 11.5 12.5 10.53 12.5 9.33337H11.5Z" + fill="url(#paint0_linear_13059_18704)" + fillOpacity="0.95" + /> <defs> - <linearGradient id="paint0_linear_13059_18704" x1="14.9996" y1="15.0001" x2="-2.55847" y2="16.6209" gradientUnits="userSpaceOnUse"> + <linearGradient + id="paint0_linear_13059_18704" + x1="14.9996" + y1="15.0001" + x2="-2.55847" + y2="16.6209" + gradientUnits="userSpaceOnUse" + > <stop stopColor="#0BA5EC" /> <stop offset="1" stopColor="#155AEF" /> </linearGradient> diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx index 549b268461d8c2..3b68e952a8592a 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx @@ -36,12 +36,9 @@ const GeneratedResult: FC<GeneratedResultProps> = ({ const schema = JSON.stringify(json, null, 2) setParseError(null) return schema - } - catch (e) { - if (e instanceof Error) - setParseError(e) - else - setParseError(new Error('Invalid JSON')) + } catch (e) { + if (e instanceof Error) setParseError(e) + else setParseError(new Error('Invalid JSON')) return '' } } @@ -60,67 +57,66 @@ const GeneratedResult: FC<GeneratedResultProps> = ({ return ( <div className="flex w-[480px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9"> - { - isGenerating ? ( - <div className="flex h-[600px] flex-col items-center justify-center gap-y-3"> - <Loading type="area" /> - <div className="system-xs-regular text-text-tertiary">{t($ => $['nodes.llm.jsonSchema.generating'], { ns: 'workflow' })}</div> + {isGenerating ? ( + <div className="flex h-[600px] flex-col items-center justify-center gap-y-3"> + <Loading type="area" /> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['nodes.llm.jsonSchema.generating'], { ns: 'workflow' })} </div> - ) : ( - <> - <button - type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} - className="absolute top-2.5 right-2.5 flex size-8 items-center justify-center border-none bg-transparent p-0" - onClick={onClose} - > - <RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" /> - </button> - {/* Title */} - <div className="flex flex-col gap-y-[0.5px] px-3 pt-3.5 pb-1"> - <div className="flex pr-8 pl-1 system-xl-semibold text-text-primary"> - {t($ => $['nodes.llm.jsonSchema.generatedResult'], { ns: 'workflow' })} - </div> - <div className="flex px-1 system-xs-regular text-text-tertiary"> - {t($ => $['nodes.llm.jsonSchema.resultTip'], { ns: 'workflow' })} - </div> + </div> + ) : ( + <> + <button + type="button" + aria-label={t(($) => $['operation.close'], { ns: 'common' })} + className="absolute top-2.5 right-2.5 flex size-8 items-center justify-center border-none bg-transparent p-0" + onClick={onClose} + > + <RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" /> + </button> + {/* Title */} + <div className="flex flex-col gap-y-[0.5px] px-3 pt-3.5 pb-1"> + <div className="flex pr-8 pl-1 system-xl-semibold text-text-primary"> + {t(($) => $['nodes.llm.jsonSchema.generatedResult'], { ns: 'workflow' })} </div> - {/* Content */} - <div className="px-4 py-2"> - <CodeEditor - className="rounded-lg" - editorWrapperClassName="h-[424px]" - value={jsonSchema} - readOnly - showFormatButton={false} - /> - {parseError && <ErrorMessage message={parseError.message} />} - {validationError && <ErrorMessage message={validationError} />} + <div className="flex px-1 system-xs-regular text-text-tertiary"> + {t(($) => $['nodes.llm.jsonSchema.resultTip'], { ns: 'workflow' })} </div> - {/* Footer */} - <div className="flex items-center justify-between p-4 pt-2"> - <Button variant="secondary" className="flex items-center gap-x-0.5" onClick={onBack}> - <RiArrowLeftLine className="size-4" /> - <span>{t($ => $['nodes.llm.jsonSchema.back'], { ns: 'workflow' })}</span> + </div> + {/* Content */} + <div className="px-4 py-2"> + <CodeEditor + className="rounded-lg" + editorWrapperClassName="h-[424px]" + value={jsonSchema} + readOnly + showFormatButton={false} + /> + {parseError && <ErrorMessage message={parseError.message} />} + {validationError && <ErrorMessage message={validationError} />} + </div> + {/* Footer */} + <div className="flex items-center justify-between p-4 pt-2"> + <Button variant="secondary" className="flex items-center gap-x-0.5" onClick={onBack}> + <RiArrowLeftLine className="size-4" /> + <span>{t(($) => $['nodes.llm.jsonSchema.back'], { ns: 'workflow' })}</span> + </Button> + <div className="flex items-center gap-x-2"> + <Button + variant="secondary" + className="flex items-center gap-x-0.5" + onClick={onRegenerate} + > + <RiSparklingLine className="size-4" /> + <span>{t(($) => $['nodes.llm.jsonSchema.regenerate'], { ns: 'workflow' })}</span> + </Button> + <Button variant="primary" onClick={handleApply}> + {t(($) => $['nodes.llm.jsonSchema.apply'], { ns: 'workflow' })} </Button> - <div className="flex items-center gap-x-2"> - <Button - variant="secondary" - className="flex items-center gap-x-0.5" - onClick={onRegenerate} - > - <RiSparklingLine className="size-4" /> - <span>{t($ => $['nodes.llm.jsonSchema.regenerate'], { ns: 'workflow' })}</span> - </Button> - <Button variant="primary" onClick={handleApply}> - {t($ => $['nodes.llm.jsonSchema.apply'], { ns: 'workflow' })} - </Button> - </div> </div> - - </> - ) - } + </div> + </> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx index 84177e9ade8d5f..58a16024ede635 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx @@ -3,11 +3,7 @@ import type { SchemaRoot } from '../../../types' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { CompletionParams, Model } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback, useState } from 'react' @@ -33,7 +29,7 @@ const GENERATOR_VIEWS = { result: 'result', } as const -type GeneratorView = typeof GENERATOR_VIEWS[keyof typeof GENERATOR_VIEWS] +type GeneratorView = (typeof GENERATOR_VIEWS)[keyof typeof GENERATOR_VIEWS] const createEmptyModel = (): Model => ({ name: '', @@ -42,25 +38,20 @@ const createEmptyModel = (): Model => ({ completion_params: {} as CompletionParams, }) -const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({ - onApply, - crossAxisOffset, -}) => { +const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({ onApply, crossAxisOffset }) => { const [open, setOpen] = useState(false) const [view, setView] = useState<GeneratorView>(GENERATOR_VIEWS.promptEditor) const [model, setModel] = useAutoGenModel() const [instruction, setInstruction] = useState('') const [schema, setSchema] = useState<SchemaRoot | null>(null) const { theme } = useTheme() - const { - defaultModel, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) + const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel( + ModelTypeEnum.textGeneration, + ) const resolvedModel = React.useMemo<Model>(() => { - if (model) - return model + if (model) return model - if (!defaultModel) - return createEmptyModel() + if (!defaultModel) return createEmptyModel() return { ...createEmptyModel(), @@ -68,43 +59,55 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({ provider: defaultModel.provider.provider, } }, [defaultModel, model]) - const advancedEditing = useVisualEditorStore(state => state.advancedEditing) - const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField) + const advancedEditing = useVisualEditorStore((state) => state.advancedEditing) + const isAddingNewField = useVisualEditorStore((state) => state.isAddingNewField) const { emit } = useMittContext() const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark - const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => { - e.stopPropagation() - if (advancedEditing || isAddingNewField) - emit('quitEditing', {}) - }, [advancedEditing, isAddingNewField, emit]) + const handleTrigger = useCallback( + (e: React.MouseEvent<HTMLElement, MouseEvent>) => { + e.stopPropagation() + if (advancedEditing || isAddingNewField) emit('quitEditing', {}) + }, + [advancedEditing, isAddingNewField, emit], + ) const onClose = useCallback(() => { setOpen(false) }, []) - const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => { - const newModel = { - ...resolvedModel, - provider: newValue.provider, - name: newValue.modelId, - mode: newValue.mode as ModelModeType, - } - setModel(newModel) - }, [resolvedModel, setModel]) + const handleModelChange = useCallback( + (newValue: { modelId: string; provider: string; mode?: string; features?: string[] }) => { + const newModel = { + ...resolvedModel, + provider: newValue.provider, + name: newValue.modelId, + mode: newValue.mode as ModelModeType, + } + setModel(newModel) + }, + [resolvedModel, setModel], + ) - const handleCompletionParamsChange = useCallback((newParams: FormValue) => { - const newModel = { - ...resolvedModel, - completion_params: newParams as CompletionParams, - } - setModel(newModel) - }, [resolvedModel, setModel]) + const handleCompletionParamsChange = useCallback( + (newParams: FormValue) => { + const newModel = { + ...resolvedModel, + completion_params: newParams as CompletionParams, + } + setModel(newModel) + }, + [resolvedModel, setModel], + ) - const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules() + const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = + useGenerateStructuredOutputRules() const generateSchema = useCallback(async () => { - const { output, error } = await generateStructuredOutputRules({ instruction, model_config: resolvedModel }) + const { output, error } = await generateStructuredOutputRules({ + instruction, + model_config: resolvedModel, + }) if (error) { toast.error(error) setSchema(null) @@ -117,8 +120,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({ const handleGenerate = useCallback(async () => { setView(GENERATOR_VIEWS.result) const output = await generateSchema() - if (output === undefined) - return + if (output === undefined) return setSchema(JSON.parse(output)) }, [generateSchema]) @@ -128,8 +130,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({ const handleRegenerate = useCallback(async () => { const output = await generateSchema() - if (output === undefined) - return + if (output === undefined) return setSchema(JSON.parse(output)) }, [generateSchema]) @@ -139,12 +140,9 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({ } return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <button type="button" onClick={handleTrigger} @@ -155,7 +153,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({ > <SchemaGenerator /> </button> - )} + } /> <PopoverContent placement="bottom-end" diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx index 4834435ffc4eec..6a0db343da9d90 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx @@ -38,15 +38,18 @@ const PromptEditor: FC<PromptEditorProps> = ({ }) => { const { t } = useTranslation() - const handleInstructionChange = useCallback((value: string) => { - onInstructionChange(value) - }, [onInstructionChange]) + const handleInstructionChange = useCallback( + (value: string) => { + onInstructionChange(value) + }, + [onInstructionChange], + ) return ( <div className="relative flex w-[480px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9"> <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-2.5 right-2.5 flex size-8 items-center justify-center border-none bg-transparent p-0" onClick={onClose} > @@ -55,16 +58,16 @@ const PromptEditor: FC<PromptEditorProps> = ({ {/* Title */} <div className="flex flex-col gap-y-[0.5px] px-3 pt-3.5 pb-1"> <div className="flex pr-8 pl-1 system-xl-semibold text-text-primary"> - {t($ => $['nodes.llm.jsonSchema.generateJsonSchema'], { ns: 'workflow' })} + {t(($) => $['nodes.llm.jsonSchema.generateJsonSchema'], { ns: 'workflow' })} </div> <div className="flex px-1 system-xs-regular text-text-tertiary"> - {t($ => $['nodes.llm.jsonSchema.generationTip'], { ns: 'workflow' })} + {t(($) => $['nodes.llm.jsonSchema.generationTip'], { ns: 'workflow' })} </div> </div> {/* Content */} <div className="flex flex-col gap-y-1 px-4 py-2"> <div className="flex h-6 items-center system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['modelProvider.model'], { ns: 'common' })} + {t(($) => $['modelProvider.model'], { ns: 'common' })} </div> <ModelParameterModal popupClassName="w-[448px]!" @@ -79,20 +82,20 @@ const PromptEditor: FC<PromptEditorProps> = ({ </div> <div className="flex flex-col gap-y-1 px-4 py-2"> <div className="flex h-6 items-center system-sm-semibold-uppercase text-text-secondary"> - <span>{t($ => $['nodes.llm.jsonSchema.instruction'], { ns: 'workflow' })}</span> + <span>{t(($) => $['nodes.llm.jsonSchema.instruction'], { ns: 'workflow' })}</span> <Infotip - aria-label={t($ => $['nodes.llm.jsonSchema.promptTooltip'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.llm.jsonSchema.promptTooltip'], { ns: 'workflow' })} className="size-3.5" > - {t($ => $['nodes.llm.jsonSchema.promptTooltip'], { ns: 'workflow' })} + {t(($) => $['nodes.llm.jsonSchema.promptTooltip'], { ns: 'workflow' })} </Infotip> </div> <div className="flex items-center"> <Textarea - aria-label={t($ => $['nodes.llm.jsonSchema.instruction'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.llm.jsonSchema.instruction'], { ns: 'workflow' })} className="h-[364px] resize-none px-2 py-1" value={instruction} - placeholder={t($ => $['nodes.llm.jsonSchema.promptPlaceholder'], { ns: 'workflow' })} + placeholder={t(($) => $['nodes.llm.jsonSchema.promptPlaceholder'], { ns: 'workflow' })} onValueChange={handleInstructionChange} /> </div> @@ -100,15 +103,11 @@ const PromptEditor: FC<PromptEditorProps> = ({ {/* Footer */} <div className="flex justify-end gap-x-2 p-4 pt-2"> <Button variant="secondary" onClick={onClose}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> - <Button - variant="primary" - className="flex items-center gap-x-0.5" - onClick={onGenerate} - > + <Button variant="primary" className="flex items-center gap-x-0.5" onClick={onGenerate}> <RiSparklingFill className="size-4" /> - <span>{t($ => $['nodes.llm.jsonSchema.generate'], { ns: 'workflow' })}</span> + <span>{t(($) => $['nodes.llm.jsonSchema.generate'], { ns: 'workflow' })}</span> </Button> </div> </div> diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/add-field.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/add-field.tsx index d610f4bdf2163a..4339255061ab55 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/add-field.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/add-field.tsx @@ -8,7 +8,7 @@ import { useVisualEditorStore } from './store' const AddField = () => { const { t } = useTranslation() - const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField) + const setIsAddingNewField = useVisualEditorStore((state) => state.setIsAddingNewField) const { emit } = useMittContext() const handleAddField = useCallback(() => { @@ -28,7 +28,9 @@ const AddField = () => { onClick={handleAddField} > <RiAddCircleFill className="size-3.5" /> - <span className="px-[3px]">{t($ => $['nodes.llm.jsonSchema.addField'], { ns: 'workflow' })}</span> + <span className="px-[3px]"> + {t(($) => $['nodes.llm.jsonSchema.addField'], { ns: 'workflow' })} + </span> </Button> </div> ) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/card.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/card.tsx index 1f40232e211832..5d163b148136cb 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/card.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/card.tsx @@ -9,12 +9,7 @@ type CardProps = { description?: string } -const Card: FC<CardProps> = ({ - name, - type, - required, - description, -}) => { +const Card: FC<CardProps> = ({ name, type, required, description }) => { const { t } = useTranslation() return ( @@ -23,22 +18,16 @@ const Card: FC<CardProps> = ({ <div className="truncate border border-transparent px-1 py-px system-sm-semibold text-text-primary"> {name} </div> - <div className="px-1 py-0.5 system-xs-medium text-text-tertiary"> - {type} - </div> - { - required && ( - <div className="px-1 py-0.5 system-2xs-medium-uppercase text-text-warning"> - {t($ => $['nodes.llm.jsonSchema.required'], { ns: 'workflow' })} - </div> - ) - } + <div className="px-1 py-0.5 system-xs-medium text-text-tertiary">{type}</div> + {required && ( + <div className="px-1 py-0.5 system-2xs-medium-uppercase text-text-warning"> + {t(($) => $['nodes.llm.jsonSchema.required'], { ns: 'workflow' })} + </div> + )} </div> {description && ( - <div className="truncate px-2 pb-1 system-xs-regular text-text-tertiary"> - {description} - </div> + <div className="truncate px-2 pb-1 system-xs-regular text-text-tertiary">{description}</div> )} </div> ) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx index 176abbcf69af55..9d3e3ec1f5b91f 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx @@ -1,9 +1,5 @@ import { noop } from 'es-toolkit/function' -import { - createContext, - use, - useRef, -} from 'react' +import { createContext, use, useRef } from 'react' import { useMitt } from '@/hooks/use-mitt' import { createVisualEditorStore } from './store' @@ -20,13 +16,10 @@ export const VisualEditorContext = createContext<VisualEditorContextType>(null) export const VisualEditorContextProvider = ({ children }: VisualEditorProviderProps) => { const storeRef = useRef<VisualEditorStore | null>(null) - if (!storeRef.current) - storeRef.current = createVisualEditorStore() + if (!storeRef.current) storeRef.current = createVisualEditorStore() return ( - <VisualEditorContext.Provider value={storeRef.current}> - {children} - </VisualEditorContext.Provider> + <VisualEditorContext.Provider value={storeRef.current}>{children}</VisualEditorContext.Provider> ) } @@ -38,11 +31,7 @@ const MittContext = createContext<ReturnType<typeof useMitt>>({ export const MittProvider = ({ children }: { children: React.ReactNode }) => { const mitt = useMitt() - return ( - <MittContext.Provider value={mitt}> - {children} - </MittContext.Provider> - ) + return <MittContext.Provider value={mitt}>{children}</MittContext.Provider> } export const useMittContext = () => { diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx index c39b3948b3a949..fe6764f9be0bd8 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx @@ -10,22 +10,17 @@ type ActionsProps = { onDelete: () => void } -const Actions: FC<ActionsProps> = ({ - disableAddBtn, - onAddChildField, - onEdit, - onDelete, -}) => { +const Actions: FC<ActionsProps> = ({ disableAddBtn, onAddChildField, onEdit, onDelete }) => { const { t } = useTranslation() - const addChildFieldLabel = t($ => $['nodes.llm.jsonSchema.addChildField'], { ns: 'workflow' }) - const editLabel = t($ => $['operation.edit'], { ns: 'common' }) - const removeLabel = t($ => $['operation.remove'], { ns: 'common' }) + const addChildFieldLabel = t(($) => $['nodes.llm.jsonSchema.addChildField'], { ns: 'workflow' }) + const editLabel = t(($) => $['operation.edit'], { ns: 'common' }) + const removeLabel = t(($) => $['operation.remove'], { ns: 'common' }) return ( <div className="flex items-center gap-x-0.5"> <Tooltip> <TooltipTrigger - render={( + render={ <span className="inline-flex"> <button type="button" @@ -37,13 +32,13 @@ const Actions: FC<ActionsProps> = ({ <span aria-hidden className="i-ri-add-circle-line size-4" /> </button> </span> - )} + } /> <TooltipContent>{addChildFieldLabel}</TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={editLabel} @@ -52,13 +47,13 @@ const Actions: FC<ActionsProps> = ({ > <span aria-hidden className="i-ri-edit-line size-4" /> </button> - )} + } /> <TooltipContent>{editLabel}</TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={removeLabel} @@ -67,7 +62,7 @@ const Actions: FC<ActionsProps> = ({ > <span aria-hidden className="i-ri-delete-bin-line size-4" /> </button> - )} + } /> <TooltipContent>{removeLabel}</TooltipContent> </Tooltip> diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx index 52298115d3e986..48fee3a002b5f7 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx @@ -13,24 +13,24 @@ type AdvancedActionsProps = { onConfirm: () => void } -const AdvancedActions: FC<AdvancedActionsProps> = ({ - isConfirmDisabled, - onCancel, - onConfirm, -}) => { +const AdvancedActions: FC<AdvancedActionsProps> = ({ isConfirmDisabled, onCancel, onConfirm }) => { const { t } = useTranslation() - useHotkey(JSON_SCHEMA_CONFIRM_HOTKEY, () => { - onConfirm() - }, { - enabled: !isConfirmDisabled, - ignoreInputs: false, - }) + useHotkey( + JSON_SCHEMA_CONFIRM_HOTKEY, + () => { + onConfirm() + }, + { + enabled: !isConfirmDisabled, + ignoreInputs: false, + }, + ) return ( <div className="flex items-center gap-x-1"> <Button size="small" variant="secondary" onClick={onCancel}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button className="flex items-center gap-x-1" @@ -39,7 +39,7 @@ const AdvancedActions: FC<AdvancedActionsProps> = ({ variant="primary" onClick={onConfirm} > - <span>{t($ => $['operation.confirm'], { ns: 'common' })}</span> + <span>{t(($) => $['operation.confirm'], { ns: 'common' })}</span> <ShortcutKbd hotkey={JSON_SCHEMA_CONFIRM_HOTKEY} bgColor="white" /> </Button> </div> diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-options.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-options.tsx index 0032a3fc6be8ea..893d44b5ca7c22 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-options.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-options.tsx @@ -14,10 +14,7 @@ type AdvancedOptionsProps = { onChange: (options: AdvancedOptionsType) => void } -const AdvancedOptions: FC<AdvancedOptionsProps> = ({ - onChange, - options, -}) => { +const AdvancedOptions: FC<AdvancedOptionsProps> = ({ onChange, options }) => { const { t } = useTranslation() // const [showAdvancedOptions, setShowAdvancedOptions] = useState(false) const [enumValue, setEnumValue] = useState(options.enum) @@ -26,9 +23,12 @@ const AdvancedOptions: FC<AdvancedOptionsProps> = ({ setEnumValue(value) }, []) - const handleEnumBlur = useCallback((e: React.FocusEvent<HTMLTextAreaElement>) => { - onChange({ enum: e.target.value }) - }, [onChange]) + const handleEnumBlur = useCallback( + (e: React.FocusEvent<HTMLTextAreaElement>) => { + onChange({ enum: e.target.value }) + }, + [onChange], + ) // const handleToggleAdvancedOptions = useCallback(() => { // setShowAdvancedOptions(prev => !prev) @@ -40,7 +40,7 @@ const AdvancedOptions: FC<AdvancedOptionsProps> = ({ <div className="flex flex-col gap-y-1 px-2 py-1.5"> <div className="flex w-full items-center gap-x-2"> <span className="system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['nodes.llm.jsonSchema.stringValidations'], { ns: 'workflow' })} + {t(($) => $['nodes.llm.jsonSchema.stringValidations'], { ns: 'workflow' })} </span> <div className="grow"> <Divider type="horizontal" className="my-0 h-px bg-line-divider-bg" /> @@ -48,10 +48,10 @@ const AdvancedOptions: FC<AdvancedOptionsProps> = ({ </div> <div className="flex flex-col"> <div className="flex h-6 items-center system-xs-medium text-text-secondary"> - {t($ => $['nodes.llm.jsonSchema.enum'], { ns: 'workflow' })} + {t(($) => $['nodes.llm.jsonSchema.enum'], { ns: 'workflow' })} </div> <Textarea - aria-label={t($ => $['nodes.llm.jsonSchema.enum'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.llm.jsonSchema.enum'], { ns: 'workflow' })} size="small" className="min-h-6" value={enumValue} diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx index dd28c29f13c760..f0466c4ddcf45d 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx @@ -30,17 +30,14 @@ const AutoWidthInput: FC<AutoWidthInputProps> = ({ textRef.current.textContent = value || placeholder const textWidth = textRef.current.offsetWidth const newWidth = Math.max(minWidth, Math.min(textWidth + 16, maxWidth)) - if (width !== newWidth) - setWidth(newWidth) + if (width !== newWidth) setWidth(newWidth) } }, [value, placeholder, minWidth, maxWidth, width]) // Handle Enter key const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => { - if (e.key === 'Enter' && e.currentTarget.blur) - e.currentTarget.blur() - if (props.onKeyUp) - props.onKeyUp(e) + if (e.key === 'Enter' && e.currentTarget.blur) e.currentTarget.blur() + if (props.onKeyUp) props.onKeyUp(e) } return ( diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx index 07e743ea7fdaeb..b8c968e23297c5 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx @@ -57,25 +57,23 @@ const MAXIMUM_DEPTH_TYPE_OPTIONS = [ { value: ArrayType.number, text: 'array[number]' }, ] -const EditCard: FC<EditCardProps> = ({ - fields, - depth, - path, - parentPath, -}) => { +const EditCard: FC<EditCardProps> = ({ fields, depth, path, parentPath }) => { const { t } = useTranslation() const [currentFields, setCurrentFields] = useState(fields) const [backupFields, setBackupFields] = useState<EditData | null>(null) - const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField) - const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField) - const advancedEditing = useVisualEditorStore(state => state.advancedEditing) - const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing) + const isAddingNewField = useVisualEditorStore((state) => state.isAddingNewField) + const setIsAddingNewField = useVisualEditorStore((state) => state.setIsAddingNewField) + const advancedEditing = useVisualEditorStore((state) => state.advancedEditing) + const setAdvancedEditing = useVisualEditorStore((state) => state.setAdvancedEditing) const { emit, useSubscribe } = useMittContext() const blurWithActions = useRef(false) const maximumDepthReached = depth === JSON_SCHEMA_MAX_DEPTH - const disableAddBtn = maximumDepthReached || (currentFields.type !== Type.object && currentFields.type !== ArrayType.object) - const hasAdvancedOptions = currentFields.type === Type.string || currentFields.type === Type.number + const disableAddBtn = + maximumDepthReached || + (currentFields.type !== Type.object && currentFields.type !== ArrayType.object) + const hasAdvancedOptions = + currentFields.type === Type.string || currentFields.type === Type.number const isAdvancedEditing = advancedEditing || isAddingNewField const advancedOptions = useMemo(() => { @@ -86,31 +84,45 @@ const EditCard: FC<EditCardProps> = ({ }, [currentFields.type, currentFields.enum]) useSubscribe('restorePropertyName', () => { - setCurrentFields(prev => ({ ...prev, name: fields.name })) + setCurrentFields((prev) => ({ ...prev, name: fields.name })) }) useSubscribe('fieldChangeSuccess', () => { - if (isAddingNewField) - setIsAddingNewField(false) - if (advancedEditing) - setAdvancedEditing(false) + if (isAddingNewField) setIsAddingNewField(false) + if (advancedEditing) setAdvancedEditing(false) }) const emitPropertyNameChange = useCallback(() => { emit('propertyNameChange', { path, parentPath, oldFields: fields, fields: currentFields }) }, [fields, currentFields, path, parentPath, emit]) - const emitPropertyTypeChange = useCallback((type: Type | ArrayType) => { - emit('propertyTypeChange', { path, parentPath, oldFields: fields, fields: { ...currentFields, type } }) - }, [fields, currentFields, path, parentPath, emit]) + const emitPropertyTypeChange = useCallback( + (type: Type | ArrayType) => { + emit('propertyTypeChange', { + path, + parentPath, + oldFields: fields, + fields: { ...currentFields, type }, + }) + }, + [fields, currentFields, path, parentPath, emit], + ) const emitPropertyRequiredToggle = useCallback(() => { emit('propertyRequiredToggle', { path, parentPath, oldFields: fields, fields: currentFields }) }, [emit, path, parentPath, fields, currentFields]) - const emitPropertyOptionsChange = useCallback((options: Options) => { - emit('propertyOptionsChange', { path, parentPath, oldFields: fields, fields: { ...currentFields, ...options } }) - }, [emit, path, parentPath, fields, currentFields]) + const emitPropertyOptionsChange = useCallback( + (options: Options) => { + emit('propertyOptionsChange', { + path, + parentPath, + oldFields: fields, + fields: { ...currentFields, ...options }, + }) + }, + [emit, path, parentPath, fields, currentFields], + ) const emitPropertyDelete = useCallback(() => { emit('propertyDelete', { path, parentPath, oldFields: fields, fields: currentFields }) @@ -126,56 +138,55 @@ const EditCard: FC<EditCardProps> = ({ const handlePropertyNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { // fix: when user add name contains space, the variable reference will not work - setCurrentFields(prev => ({ ...prev, name: e.target.value?.trim() })) + setCurrentFields((prev) => ({ ...prev, name: e.target.value?.trim() })) }, []) const handlePropertyNameBlur = useCallback(() => { - if (isAdvancedEditing) - return + if (isAdvancedEditing) return emitPropertyNameChange() }, [isAdvancedEditing, emitPropertyNameChange]) - const handleTypeChange = useCallback((item: TypeItem) => { - setCurrentFields(prev => ({ ...prev, type: item.value })) - if (isAdvancedEditing) - return - emitPropertyTypeChange(item.value) - }, [isAdvancedEditing, emitPropertyTypeChange]) + const handleTypeChange = useCallback( + (item: TypeItem) => { + setCurrentFields((prev) => ({ ...prev, type: item.value })) + if (isAdvancedEditing) return + emitPropertyTypeChange(item.value) + }, + [isAdvancedEditing, emitPropertyTypeChange], + ) const toggleRequired = useCallback(() => { - setCurrentFields(prev => ({ ...prev, required: !prev.required })) - if (isAdvancedEditing) - return + setCurrentFields((prev) => ({ ...prev, required: !prev.required })) + if (isAdvancedEditing) return emitPropertyRequiredToggle() }, [isAdvancedEditing, emitPropertyRequiredToggle]) const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { - setCurrentFields(prev => ({ ...prev, description: e.target.value })) + setCurrentFields((prev) => ({ ...prev, description: e.target.value })) }, []) const handleDescriptionBlur = useCallback(() => { - if (isAdvancedEditing) - return + if (isAdvancedEditing) return emitPropertyOptionsChange({ description: currentFields.description, enum: currentFields.enum }) }, [isAdvancedEditing, emitPropertyOptionsChange, currentFields]) - const handleAdvancedOptionsChange = useCallback((options: AdvancedOptionsType) => { - let enumValue: SchemaEnumType | undefined - if (options.enum === '') { - enumValue = undefined - } - else { - const stringArray = options.enum.replace(/\s/g, '').split(',') - if (currentFields.type === Type.number) - enumValue = stringArray.map(value => Number(value)).filter(num => !Number.isNaN(num)) - else - enumValue = stringArray - } - setCurrentFields(prev => ({ ...prev, enum: enumValue })) - if (isAdvancedEditing) - return - emitPropertyOptionsChange({ description: currentFields.description, enum: enumValue }) - }, [isAdvancedEditing, emitPropertyOptionsChange, currentFields]) + const handleAdvancedOptionsChange = useCallback( + (options: AdvancedOptionsType) => { + let enumValue: SchemaEnumType | undefined + if (options.enum === '') { + enumValue = undefined + } else { + const stringArray = options.enum.replace(/\s/g, '').split(',') + if (currentFields.type === Type.number) + enumValue = stringArray.map((value) => Number(value)).filter((num) => !Number.isNaN(num)) + else enumValue = stringArray + } + setCurrentFields((prev) => ({ ...prev, enum: enumValue })) + if (isAdvancedEditing) return + emitPropertyOptionsChange({ description: currentFields.description, enum: enumValue }) + }, + [isAdvancedEditing, emitPropertyOptionsChange, currentFields], + ) const handleDelete = useCallback(() => { blurWithActions.current = true @@ -211,8 +222,7 @@ const EditCard: FC<EditCardProps> = ({ }, [isAddingNewField, emit, setIsAddingNewField, setAdvancedEditing, backupFields]) useUnmount(() => { - if (isAdvancedEditing || blurWithActions.current) - return + if (isAdvancedEditing || blurWithActions.current) return emitFieldChange() }) @@ -222,7 +232,9 @@ const EditCard: FC<EditCardProps> = ({ <div className="flex grow items-center gap-x-1"> <AutoWidthInput value={currentFields.name} - placeholder={t($ => $['nodes.llm.jsonSchema.fieldNamePlaceholder'], { ns: 'workflow' })} + placeholder={t(($) => $['nodes.llm.jsonSchema.fieldNamePlaceholder'], { + ns: 'workflow', + })} minWidth={80} maxWidth={300} onChange={handlePropertyNameChange} @@ -233,35 +245,28 @@ const EditCard: FC<EditCardProps> = ({ items={maximumDepthReached ? MAXIMUM_DEPTH_TYPE_OPTIONS : TYPE_OPTIONS} onSelect={handleTypeChange} /> - { - currentFields.required && ( - <div className="px-1 py-0.5 system-2xs-medium-uppercase text-text-warning"> - {t($ => $['nodes.llm.jsonSchema.required'], { ns: 'workflow' })} - </div> - ) - } + {currentFields.required && ( + <div className="px-1 py-0.5 system-2xs-medium-uppercase text-text-warning"> + {t(($) => $['nodes.llm.jsonSchema.required'], { ns: 'workflow' })} + </div> + )} </div> - <RequiredSwitch - defaultValue={currentFields.required} - toggleRequired={toggleRequired} - /> + <RequiredSwitch defaultValue={currentFields.required} toggleRequired={toggleRequired} /> <Divider type="vertical" className="h-3" /> - {isAdvancedEditing - ? ( - <AdvancedActions - isConfirmDisabled={currentFields.name === ''} - onCancel={handleCancel} - onConfirm={handleConfirm} - /> - ) - : ( - <Actions - disableAddBtn={disableAddBtn} - onAddChildField={handleAddChildField} - onDelete={handleDelete} - onEdit={handleAdvancedEdit} - /> - )} + {isAdvancedEditing ? ( + <AdvancedActions + isConfirmDisabled={currentFields.name === ''} + onCancel={handleCancel} + onConfirm={handleConfirm} + /> + ) : ( + <Actions + disableAddBtn={disableAddBtn} + onAddChildField={handleAddChildField} + onDelete={handleDelete} + onEdit={handleAdvancedEdit} + /> + )} </div> {(fields.description || isAdvancedEditing) && ( @@ -269,19 +274,18 @@ const EditCard: FC<EditCardProps> = ({ <input value={currentFields.description} className="h-4 w-full p-0 system-xs-regular text-text-tertiary caret-[#295EFF] outline-hidden placeholder:system-xs-regular placeholder:text-text-placeholder" - placeholder={t($ => $['nodes.llm.jsonSchema.descriptionPlaceholder'], { ns: 'workflow' })} + placeholder={t(($) => $['nodes.llm.jsonSchema.descriptionPlaceholder'], { + ns: 'workflow', + })} onChange={handleDescriptionChange} onBlur={handleDescriptionBlur} - onKeyUp={e => e.key === 'Enter' && e.currentTarget.blur()} + onKeyUp={(e) => e.key === 'Enter' && e.currentTarget.blur()} /> </div> )} {isAdvancedEditing && hasAdvancedOptions && ( - <AdvancedOptions - options={advancedOptions} - onChange={handleAdvancedOptionsChange} - /> + <AdvancedOptions options={advancedOptions} onChange={handleAdvancedOptionsChange} /> )} </div> ) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/required-switch.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/required-switch.tsx index ca31817c300ebd..a447d0a91c969e 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/required-switch.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/required-switch.tsx @@ -8,15 +8,14 @@ type RequiredSwitchProps = { toggleRequired: () => void } -const RequiredSwitch: FC<RequiredSwitchProps> = ({ - defaultValue, - toggleRequired, -}) => { +const RequiredSwitch: FC<RequiredSwitchProps> = ({ defaultValue, toggleRequired }) => { const { t } = useTranslation() return ( <div className="flex items-center gap-x-1 rounded-[5px] border border-divider-subtle bg-background-default-lighter px-1.5 py-1"> - <span className="system-2xs-medium-uppercase text-text-secondary">{t($ => $['nodes.llm.jsonSchema.required'], { ns: 'workflow' })}</span> + <span className="system-2xs-medium-uppercase text-text-secondary"> + {t(($) => $['nodes.llm.jsonSchema.required'], { ns: 'workflow' })} + </span> <Switch size="xs" checked={defaultValue} onCheckedChange={toggleRequired} /> </div> ) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/type-selector.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/type-selector.tsx index 2e2f34f7852a78..80c0ada624dfd4 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/type-selector.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/type-selector.tsx @@ -24,12 +24,7 @@ type TypeSelectorProps = { popupClassName?: string } -const TypeSelector: FC<TypeSelectorProps> = ({ - items, - currentValue, - onSelect, - popupClassName, -}) => { +const TypeSelector: FC<TypeSelectorProps> = ({ items, currentValue, onSelect, popupClassName }) => { const [open, setOpen] = useState(false) return ( @@ -38,9 +33,8 @@ const TypeSelector: FC<TypeSelectorProps> = ({ onOpenChange={setOpen} value={currentValue} onValueChange={(nextValue) => { - const selected = items.find(item => item.value === nextValue) - if (selected) - onSelect(selected) + const selected = items.find((item) => item.value === nextValue) + if (selected) onSelect(selected) }} > <SelectTrigger @@ -65,7 +59,9 @@ const TypeSelector: FC<TypeSelectorProps> = ({ value={item.value} className="gap-x-1 rounded-lg px-2 py-1" > - <SelectItemText className="px-1 system-sm-medium text-text-secondary">{item.text}</SelectItemText> + <SelectItemText className="px-1 system-sm-medium text-text-secondary"> + {item.text} + </SelectItemText> {isSelected && <RiCheckLine className="size-4 text-text-accent" />} <SelectItemIndicator className="hidden" /> </SelectItem> diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts index e498d17d8fd0a2..119a174f27389b 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts @@ -25,13 +25,13 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { const { schema: jsonSchema, onChange: doOnChange } = props const { t } = useTranslation() const onChange = doOnChange || noop - const backupSchema = useVisualEditorStore(state => state.backupSchema) - const setBackupSchema = useVisualEditorStore(state => state.setBackupSchema) - const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField) - const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField) - const advancedEditing = useVisualEditorStore(state => state.advancedEditing) - const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing) - const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty) + const backupSchema = useVisualEditorStore((state) => state.backupSchema) + const setBackupSchema = useVisualEditorStore((state) => state.setBackupSchema) + const isAddingNewField = useVisualEditorStore((state) => state.isAddingNewField) + const setIsAddingNewField = useVisualEditorStore((state) => state.setIsAddingNewField) + const advancedEditing = useVisualEditorStore((state) => state.advancedEditing) + const setAdvancedEditing = useVisualEditorStore((state) => state.setAdvancedEditing) + const setHoveringProperty = useVisualEditorStore((state) => state.setHoveringProperty) const { emit, useSubscribe } = useMittContext() useSubscribe('restoreSchema', () => { @@ -48,10 +48,8 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { onChange(backupSchema) setBackupSchema(null) } - if (isAddingNewField) - setIsAddingNewField(false) - if (advancedEditing) - setAdvancedEditing(false) + if (isAddingNewField) setIsAddingNewField(false) + if (advancedEditing) setAdvancedEditing(false) setHoveringProperty(null) }) @@ -60,28 +58,31 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { const { name: oldName } = oldFields const { name: newName } = fields const newSchema = produce(jsonSchema, (draft) => { - if (oldName === newName) - return + if (oldName === newName) return const schema = findPropertyWithPath(draft, parentPath) as Field if (schema.type === Type.object) { const properties = schema.properties || {} if (properties[newName]) { - toast.error(t($ => $['nodes.llm.jsonSchema.fieldNameAlreadyExists'], { ns: 'workflow' })) + toast.error( + t(($) => $['nodes.llm.jsonSchema.fieldNameAlreadyExists'], { ns: 'workflow' }), + ) emit('restorePropertyName') return } - const newProperties = Object.entries(properties).reduce((acc, [key, value]) => { - acc[key === oldName ? newName : key] = value - return acc - }, {} as Record<string, Field>) + const newProperties = Object.entries(properties).reduce( + (acc, [key, value]) => { + acc[key === oldName ? newName : key] = value + return acc + }, + {} as Record<string, Field>, + ) const required = schema.required || [] const newRequired = produce(required, (draft) => { const index = draft.indexOf(oldName) - if (index !== -1) - draft.splice(index, 1, newName) + if (index !== -1) draft.splice(index, 1, newName) }) schema.properties = newProperties @@ -91,20 +92,24 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { if (schema.type === Type.array && schema.items && schema.items.type === Type.object) { const properties = schema.items.properties || {} if (properties[newName]) { - toast.error(t($ => $['nodes.llm.jsonSchema.fieldNameAlreadyExists'], { ns: 'workflow' })) + toast.error( + t(($) => $['nodes.llm.jsonSchema.fieldNameAlreadyExists'], { ns: 'workflow' }), + ) emit('restorePropertyName') return } - const newProperties = Object.entries(properties).reduce((acc, [key, value]) => { - acc[key === oldName ? newName : key] = value - return acc - }, {} as Record<string, Field>) + const newProperties = Object.entries(properties).reduce( + (acc, [key, value]) => { + acc[key === oldName ? newName : key] = value + return acc + }, + {} as Record<string, Field>, + ) const required = schema.items.required || [] const newRequired = produce(required, (draft) => { const index = draft.indexOf(oldName) - if (index !== -1) - draft.splice(index, 1, newName) + if (index !== -1) draft.splice(index, 1, newName) }) schema.items.properties = newProperties @@ -118,8 +123,7 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { const { path, oldFields, fields } = params as ChangeEventParams const { type: oldType } = oldFields const { type: newType } = fields - if (oldType === newType) - return + if (oldType === newType) return const newSchema = produce(jsonSchema, (draft) => { const schema = findPropertyWithPath(draft, path) as Field @@ -127,8 +131,7 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { delete schema.properties delete schema.required } - if (schema.type === Type.array) - delete schema.items + if (schema.type === Type.array) delete schema.items switch (newType) { case Type.object: schema.type = Type.object @@ -179,14 +182,14 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { if (schema.type === Type.object) { const required = schema.required || [] const newRequired = required.includes(name) - ? required.filter(item => item !== name) + ? required.filter((item) => item !== name) : [...required, name] schema.required = newRequired } if (schema.type === Type.array && schema.items && schema.items.type === Type.object) { const required = schema.items.required || [] const newRequired = required.includes(name) - ? required.filter(item => item !== name) + ? required.filter((item) => item !== name) : [...required, name] schema.items.required = newRequired } @@ -211,19 +214,22 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { const schema = findPropertyWithPath(draft, parentPath) as Field if (schema.type === Type.object && schema.properties) { delete schema.properties[name] - schema.required = schema.required?.filter(item => item !== name) + schema.required = schema.required?.filter((item) => item !== name) } - if (schema.type === Type.array && schema.items?.properties && schema.items?.type === Type.object) { + if ( + schema.type === Type.array && + schema.items?.properties && + schema.items?.type === Type.object + ) { delete schema.items.properties[name] - schema.items.required = schema.items.required?.filter(item => item !== name) + schema.items.required = schema.items.required?.filter((item) => item !== name) } }) onChange(newSchema) }) useSubscribe('addField', (params) => { - if (advancedEditing) - setAdvancedEditing(false) + if (advancedEditing) setAdvancedEditing(false) setBackupSchema(jsonSchema) const { path } = params as AddEventParams setIsAddingNewField(true) @@ -263,20 +269,24 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { if (oldName !== newName) { const properties = parentSchema.properties if (properties[newName]) { - toast.error(t($ => $['nodes.llm.jsonSchema.fieldNameAlreadyExists'], { ns: 'workflow' })) + toast.error( + t(($) => $['nodes.llm.jsonSchema.fieldNameAlreadyExists'], { ns: 'workflow' }), + ) samePropertyNameError = true } - const newProperties = Object.entries(properties).reduce((acc, [key, value]) => { - acc[key === oldName ? newName : key] = value - return acc - }, {} as Record<string, Field>) + const newProperties = Object.entries(properties).reduce( + (acc, [key, value]) => { + acc[key === oldName ? newName : key] = value + return acc + }, + {} as Record<string, Field>, + ) const requiredProperties = parentSchema.required || [] const newRequiredProperties = produce(requiredProperties, (draft) => { const index = draft.indexOf(oldName) - if (index !== -1) - draft.splice(index, 1, newName) + if (index !== -1) draft.splice(index, 1, newName) }) parentSchema.properties = newProperties @@ -287,7 +297,7 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { if (oldRequired !== newRequired) { const required = parentSchema.required || [] const newRequired = required.includes(newName) - ? required.filter(item => item !== newName) + ? required.filter((item) => item !== newName) : [...required, newName] parentSchema.required = newRequired } @@ -300,8 +310,7 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { delete schema!.properties delete schema!.required } - if (schema!.type === Type.array) - delete schema!.items + if (schema!.type === Type.array) delete schema!.items switch (newType) { case Type.object: schema!.type = Type.object @@ -347,24 +356,33 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { schema!.enum = fields.enum } - if (parentSchema.type === Type.array && parentSchema.items && parentSchema.items.type === Type.object && parentSchema.items.properties) { + if ( + parentSchema.type === Type.array && + parentSchema.items && + parentSchema.items.type === Type.object && + parentSchema.items.properties + ) { // name change if (oldName !== newName) { const properties = parentSchema.items.properties || {} if (properties[newName]) { - toast.error(t($ => $['nodes.llm.jsonSchema.fieldNameAlreadyExists'], { ns: 'workflow' })) + toast.error( + t(($) => $['nodes.llm.jsonSchema.fieldNameAlreadyExists'], { ns: 'workflow' }), + ) samePropertyNameError = true } - const newProperties = Object.entries(properties).reduce((acc, [key, value]) => { - acc[key === oldName ? newName : key] = value - return acc - }, {} as Record<string, Field>) + const newProperties = Object.entries(properties).reduce( + (acc, [key, value]) => { + acc[key === oldName ? newName : key] = value + return acc + }, + {} as Record<string, Field>, + ) const required = parentSchema.items.required || [] const newRequired = produce(required, (draft) => { const index = draft.indexOf(oldName) - if (index !== -1) - draft.splice(index, 1, newName) + if (index !== -1) draft.splice(index, 1, newName) }) parentSchema.items.properties = newProperties @@ -375,7 +393,7 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { if (oldRequired !== newRequired) { const required = parentSchema.items.required || [] const newRequired = required.includes(newName) - ? required.filter(item => item !== newName) + ? required.filter((item) => item !== newName) : [...required, newName] parentSchema.items.required = newRequired } @@ -387,8 +405,7 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { delete schema!.properties delete schema!.required } - if (schema!.type === Type.array) - delete schema!.items + if (schema!.type === Type.array) delete schema!.items switch (newType) { case Type.object: schema!.type = Type.object @@ -434,8 +451,7 @@ export const useSchemaNodeOperations = (props: VisualEditorProps) => { schema!.enum = fields.enum } }) - if (samePropertyNameError) - return + if (samePropertyNameError) return onChange(newSchema) emit('fieldChangeSuccess') }) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx index f7897796d5eb1c..d8611b0929e432 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx @@ -17,7 +17,12 @@ const VisualEditor: FC<VisualEditorProps> = (props) => { useSchemaNodeOperations(props) return ( - <div className={cn('h-full overflow-auto rounded-xl bg-background-section-burn p-1 pl-2', className)}> + <div + className={cn( + 'h-full overflow-auto rounded-xl bg-background-section-burn p-1 pl-2', + className, + )} + > <SchemaNode name={props.rootName || 'structured_output'} schema={schema} diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx index 2195e18bbb436e..acf2f59ada3b97 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx @@ -63,14 +63,17 @@ const SchemaNode: FC<SchemaNodeProps> = ({ readOnly, }) => { const [isExpanded, setIsExpanded] = useState(true) - const hoveringProperty = useVisualEditorStore(state => state.hoveringProperty) - const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty) - const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField) - const advancedEditing = useVisualEditorStore(state => state.advancedEditing) - - const { run: setHoveringPropertyDebounced } = useDebounceFn((path: string | null) => { - setHoveringProperty(path) - }, { wait: 50 }) + const hoveringProperty = useVisualEditorStore((state) => state.hoveringProperty) + const setHoveringProperty = useVisualEditorStore((state) => state.setHoveringProperty) + const isAddingNewField = useVisualEditorStore((state) => state.isAddingNewField) + const advancedEditing = useVisualEditorStore((state) => state.advancedEditing) + + const { run: setHoveringPropertyDebounced } = useDebounceFn( + (path: string | null) => { + setHoveringProperty(path) + }, + { wait: 50 }, + ) const hasChildren = useMemo(() => getHasChildren(schema), [schema]) const type = useMemo(() => getFieldType(schema), [schema]) @@ -81,18 +84,14 @@ const SchemaNode: FC<SchemaNodeProps> = ({ } const handleMouseEnter = () => { - if (readOnly) - return - if (advancedEditing || isAddingNewField) - return + if (readOnly) return + if (advancedEditing || isAddingNewField) return setHoveringPropertyDebounced(path.join('.')) } const handleMouseLeave = () => { - if (readOnly) - return - if (advancedEditing || isAddingNewField) - return + if (readOnly) return + if (advancedEditing || isAddingNewField) return setHoveringPropertyDebounced(null) } @@ -100,52 +99,53 @@ const SchemaNode: FC<SchemaNodeProps> = ({ <div className="relative"> <div className={cn('relative z-10', indentPadding[depth])}> {depth > 0 && hasChildren && ( - <div className={cn('absolute top-0 z-10 flex h-7 w-5 items-center bg-background-section-burn px-0.5', indentLeft[depth - 1])}> + <div + className={cn( + 'absolute top-0 z-10 flex h-7 w-5 items-center bg-background-section-burn px-0.5', + indentLeft[depth - 1], + )} + > <button type="button" onClick={handleExpand} className="py-0.5 text-text-tertiary hover:text-text-accent" > - { - isExpanded - ? <RiArrowDropDownLine className="size-4" /> - : <RiArrowDropRightLine className="size-4" /> - } + {isExpanded ? ( + <RiArrowDropDownLine className="size-4" /> + ) : ( + <RiArrowDropRightLine className="size-4" /> + )} </button> </div> )} - <div - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - > - {(isHovering && depth > 0) - ? ( - <EditCard - fields={{ - name, - type, - required, - description: schema.description, - enum: schema.enum, - }} - path={path} - parentPath={parentPath!} - depth={depth} - /> - ) - : ( - <Card - name={name} - type={type} - required={required} - description={schema.description} - /> - )} + <div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}> + {isHovering && depth > 0 ? ( + <EditCard + fields={{ + name, + type, + required, + description: schema.description, + enum: schema.enum, + }} + path={path} + parentPath={parentPath!} + depth={depth} + /> + ) : ( + <Card name={name} type={type} required={required} description={schema.description} /> + )} </div> </div> - <div className={cn('absolute z-0 flex w-5 justify-center', schema.description ? 'top-12 h-[calc(100%-3rem)]' : 'top-7 h-[calc(100%-1.75rem)]', indentLeft[depth])}> + <div + className={cn( + 'absolute z-0 flex w-5 justify-center', + schema.description ? 'top-12 h-[calc(100%-3rem)]' : 'top-7 h-[calc(100%-1.75rem)]', + indentLeft[depth], + )} + > <Divider type="vertical" className={cn('mx-0', isHovering ? 'bg-divider-deep' : 'bg-divider-subtle')} @@ -154,7 +154,8 @@ const SchemaNode: FC<SchemaNodeProps> = ({ {isExpanded && hasChildren && depth < JSON_SCHEMA_MAX_DEPTH && ( <> - {schema.type === Type.object && schema.properties && ( + {schema.type === Type.object && + schema.properties && Object.entries(schema.properties).map(([key, childSchema]) => ( <SchemaNode key={key} @@ -165,34 +166,27 @@ const SchemaNode: FC<SchemaNodeProps> = ({ parentPath={path} depth={depth + 1} /> - )) - )} + ))} - {schema.type === Type.array - && schema.items - && schema.items.type === Type.object - && schema.items.properties - && ( - Object.entries(schema.items.properties).map(([key, childSchema]) => ( - <SchemaNode - key={key} - name={key} - required={!!schema.items?.required?.includes(key)} - schema={childSchema} - path={[...path, 'items', 'properties', key]} - parentPath={path} - depth={depth + 1} - /> - )) - )} + {schema.type === Type.array && + schema.items && + schema.items.type === Type.object && + schema.items.properties && + Object.entries(schema.items.properties).map(([key, childSchema]) => ( + <SchemaNode + key={key} + name={key} + required={!!schema.items?.required?.includes(key)} + schema={childSchema} + path={[...path, 'items', 'properties', key]} + parentPath={path} + depth={depth + 1} + /> + ))} </> )} - { - !readOnly && depth === 0 && !isAddingNewField && ( - <AddField /> - ) - } + {!readOnly && depth === 0 && !isAddingNewField && <AddField />} </div> ) } diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/store.ts b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/store.ts index 1e3acd35ace561..1dd7274ea6c6dc 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/store.ts +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/store.ts @@ -14,21 +14,21 @@ type VisualEditorStore = { setBackupSchema: (schema: SchemaRoot | null) => void } -export const createVisualEditorStore = () => createStore<VisualEditorStore>(set => ({ - hoveringProperty: null, - setHoveringProperty: (propertyPath: string | null) => set({ hoveringProperty: propertyPath }), - isAddingNewField: false, - setIsAddingNewField: (isAdding: boolean) => set({ isAddingNewField: isAdding }), - advancedEditing: false, - setAdvancedEditing: (isEditing: boolean) => set({ advancedEditing: isEditing }), - backupSchema: null, - setBackupSchema: (schema: SchemaRoot | null) => set({ backupSchema: schema }), -})) +export const createVisualEditorStore = () => + createStore<VisualEditorStore>((set) => ({ + hoveringProperty: null, + setHoveringProperty: (propertyPath: string | null) => set({ hoveringProperty: propertyPath }), + isAddingNewField: false, + setIsAddingNewField: (isAdding: boolean) => set({ isAddingNewField: isAdding }), + advancedEditing: false, + setAdvancedEditing: (isEditing: boolean) => set({ advancedEditing: isEditing }), + backupSchema: null, + setBackupSchema: (schema: SchemaRoot | null) => set({ backupSchema: schema }), + })) export const useVisualEditorStore = <T>(selector: (state: VisualEditorStore) => T): T => { const store = use(VisualEditorContext) - if (!store) - throw new Error('Missing VisualEditorContext.Provider in the tree') + if (!store) throw new Error('Missing VisualEditorContext.Provider in the tree') return useStore(store, selector) } diff --git a/web/app/components/workflow/nodes/llm/components/panel-memory-section.tsx b/web/app/components/workflow/nodes/llm/components/panel-memory-section.tsx index f0005cd52ae285..b50f0b80cc7417 100644 --- a/web/app/components/workflow/nodes/llm/components/panel-memory-section.tsx +++ b/web/app/components/workflow/nodes/llm/components/panel-memory-section.tsx @@ -60,8 +60,7 @@ const PanelMemorySection: FC<Props> = ({ const shouldCheckSysQuery = !isSnippetFlow const defaultMemory = isSnippetFlow ? SNIPPET_DEFAULT_MEMORY : DEFAULT_MEMORY - if (!isChatMode) - return null + if (!isChatMode) return null return ( <> @@ -69,28 +68,30 @@ const PanelMemorySection: FC<Props> = ({ <div className="mt-4"> <div className="flex h-8 items-center justify-between rounded-lg bg-components-input-bg-normal pr-2 pl-3"> <div className="flex items-center space-x-1"> - <div className="text-xs font-semibold text-text-secondary uppercase">{t($ => $['nodes.common.memories.title'], { ns: 'workflow' })}</div> - <Infotip aria-label={t($ => $['nodes.common.memories.tip'], { ns: 'workflow' })}> - {t($ => $['nodes.common.memories.tip'], { ns: 'workflow' })} + <div className="text-xs font-semibold text-text-secondary uppercase"> + {t(($) => $['nodes.common.memories.title'], { ns: 'workflow' })} + </div> + <Infotip aria-label={t(($) => $['nodes.common.memories.tip'], { ns: 'workflow' })}> + {t(($) => $['nodes.common.memories.tip'], { ns: 'workflow' })} </Infotip> </div> <div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 text-xs font-semibold text-text-tertiary uppercase"> - {t($ => $['nodes.common.memories.builtIn'], { ns: 'workflow' })} + {t(($) => $['nodes.common.memories.builtIn'], { ns: 'workflow' })} </div> </div> <div className="mt-4"> <Editor - title={( + title={ <div className="flex items-center space-x-1"> <div className="text-xs font-semibold text-text-secondary uppercase">user</div> <Infotip - aria-label={t($ => $['nodes.llm.roleDescription.user'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.llm.roleDescription.user'], { ns: 'workflow' })} popupClassName="w-[180px]" > - {t($ => $['nodes.llm.roleDescription.user'], { ns: 'workflow' })} + {t(($) => $['nodes.llm.roleDescription.user'], { ns: 'workflow' })} </Infotip> </div> - )} + } value={inputs.memory.query_prompt_template || defaultMemory.query_prompt_template} onChange={handleSyeQueryChange} readOnly={readOnly} @@ -103,11 +104,13 @@ const PanelMemorySection: FC<Props> = ({ isSupportFileVar /> - {shouldCheckSysQuery && inputs.memory.query_prompt_template && !inputs.memory.query_prompt_template.includes('{{#sys.query#}}') && ( - <div className="text-xs leading-[18px] font-normal text-[#DC6803]"> - {t($ => $[`${i18nPrefix}.sysQueryInUser`], { ns: 'workflow' })} - </div> - )} + {shouldCheckSysQuery && + inputs.memory.query_prompt_template && + !inputs.memory.query_prompt_template.includes('{{#sys.query#}}') && ( + <div className="text-xs leading-[18px] font-normal text-[#DC6803]"> + {t(($) => $[`${i18nPrefix}.sysQueryInUser`], { ns: 'workflow' })} + </div> + )} </div> </div> )} diff --git a/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx b/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx index 5f80599838bbd5..38c365898d40fb 100644 --- a/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx +++ b/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx @@ -38,29 +38,35 @@ const PanelOutputSection: FC<Props> = ({ <OutputVars collapsed={structuredOutputCollapsed} onCollapse={setStructuredOutputCollapsed} - operations={( + operations={ <div className="mr-4 flex shrink-0 items-center"> - {(!isModelSupportStructuredOutput && !!inputs.structured_output_enabled) && ( + {!isModelSupportStructuredOutput && !!inputs.structured_output_enabled && ( <Tooltip> <TooltipTrigger - render={( + render={ <div> <span className="mr-1 i-ri-alert-fill size-4 text-text-warning-secondary" /> </div> - )} + } /> <TooltipContent className="w-[232px] rounded-xl border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-4 py-3.5 shadow-lg backdrop-blur-[5px]"> - <div className="title-xs-semi-bold text-text-primary">{t($ => $['structOutput.modelNotSupported'], { ns: 'app' })}</div> - <div className="mt-1 body-xs-regular text-text-secondary">{t($ => $['structOutput.modelNotSupportedTip'], { ns: 'app' })}</div> + <div className="title-xs-semi-bold text-text-primary"> + {t(($) => $['structOutput.modelNotSupported'], { ns: 'app' })} + </div> + <div className="mt-1 body-xs-regular text-text-secondary"> + {t(($) => $['structOutput.modelNotSupportedTip'], { ns: 'app' })} + </div> </TooltipContent> </Tooltip> )} - <div className="mr-0.5 system-xs-medium-uppercase text-text-tertiary">{t($ => $['structOutput.structured'], { ns: 'app' })}</div> + <div className="mr-0.5 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['structOutput.structured'], { ns: 'app' })} + </div> <Infotip - aria-label={t($ => $['structOutput.structuredTip'], { ns: 'app' })} + aria-label={t(($) => $['structOutput.structuredTip'], { ns: 'app' })} popupClassName="w-[150px]" > - {t($ => $['structOutput.structuredTip'], { ns: 'app' })} + {t(($) => $['structOutput.structuredTip'], { ns: 'app' })} </Infotip> <Switch className="ml-2" @@ -70,23 +76,25 @@ const PanelOutputSection: FC<Props> = ({ disabled={readOnly} /> </div> - )} + } > <> <VarItem name="text" type="string" - description={t($ => $[`${i18nPrefix}.outputVars.output`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.output`], { ns: 'workflow' })} /> <VarItem name="reasoning_content" type="string" - description={t($ => $[`${i18nPrefix}.outputVars.reasoning_content`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.reasoning_content`], { + ns: 'workflow', + })} /> <VarItem name="usage" type="object" - description={t($ => $[`${i18nPrefix}.outputVars.usage`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.usage`], { ns: 'workflow' })} /> {inputs.structured_output_enabled && ( <> diff --git a/web/app/components/workflow/nodes/llm/components/prompt-generator-btn.tsx b/web/app/components/workflow/nodes/llm/components/prompt-generator-btn.tsx index ea4328a56dac8d..44da08a14e754a 100644 --- a/web/app/components/workflow/nodes/llm/components/prompt-generator-btn.tsx +++ b/web/app/components/workflow/nodes/llm/components/prompt-generator-btn.tsx @@ -28,18 +28,19 @@ const PromptGeneratorBtn: FC<Props> = ({ editorId, currentPrompt, }) => { - const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = useBoolean(false) - const handleAutomaticRes = useCallback((res: GenRes) => { - onGenerated?.(res.modified) - showAutomaticFalse() - }, [onGenerated, showAutomaticFalse]) - const configsMap = useHooksStore(s => s.configsMap) + const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = + useBoolean(false) + const handleAutomaticRes = useCallback( + (res: GenRes) => { + onGenerated?.(res.modified) + showAutomaticFalse() + }, + [onGenerated, showAutomaticFalse], + ) + const configsMap = useHooksStore((s) => s.configsMap) return ( <div className={cn(className)}> - <ActionButton - className="hover:bg-[#155EFF]/8" - onClick={showAutomaticTrue} - > + <ActionButton className="hover:bg-[#155EFF]/8" onClick={showAutomaticTrue}> <Generator className="size-4 text-primary-600" /> </ActionButton> {showAutomatic && ( diff --git a/web/app/components/workflow/nodes/llm/components/reasoning-format-config.tsx b/web/app/components/workflow/nodes/llm/components/reasoning-format-config.tsx index 1a34a19c187c52..d0768034f80aaa 100644 --- a/web/app/components/workflow/nodes/llm/components/reasoning-format-config.tsx +++ b/web/app/components/workflow/nodes/llm/components/reasoning-format-config.tsx @@ -19,18 +19,18 @@ const ReasoningFormatConfig: FC<ReasoningFormatConfigProps> = ({ return ( <Field - title={t($ => $['nodes.llm.reasoningFormat.title'], { ns: 'workflow' })} - tooltip={t($ => $['nodes.llm.reasoningFormat.tooltip'], { ns: 'workflow' })} - operations={( + title={t(($) => $['nodes.llm.reasoningFormat.title'], { ns: 'workflow' })} + tooltip={t(($) => $['nodes.llm.reasoningFormat.tooltip'], { ns: 'workflow' })} + operations={ // ON = separated, OFF = tagged <Switch checked={value === 'separated'} - onCheckedChange={enabled => onChange(enabled ? 'separated' : 'tagged')} + onCheckedChange={(enabled) => onChange(enabled ? 'separated' : 'tagged')} size="md" disabled={readonly} key={value} /> - )} + } > <div /> </Field> diff --git a/web/app/components/workflow/nodes/llm/components/resolution-picker.tsx b/web/app/components/workflow/nodes/llm/components/resolution-picker.tsx index 5dca5b98ab425b..49ea47b03e915f 100644 --- a/web/app/components/workflow/nodes/llm/components/resolution-picker.tsx +++ b/web/app/components/workflow/nodes/llm/components/resolution-picker.tsx @@ -13,28 +13,30 @@ type Props = Readonly<{ onChange: (value: Resolution) => void }> -const ResolutionPicker: FC<Props> = ({ - value, - onChange, -}) => { +const ResolutionPicker: FC<Props> = ({ value, onChange }) => { const { t } = useTranslation() - const handleOnChange = useCallback((value: Resolution) => { - return () => { - onChange(value) - } - }, [onChange]) + const handleOnChange = useCallback( + (value: Resolution) => { + return () => { + onChange(value) + } + }, + [onChange], + ) return ( <div className="flex items-center justify-between"> - <div className="mr-2 text-xs font-medium text-text-secondary uppercase">{t($ => $[`${i18nPrefix}.resolution.name`], { ns: 'workflow' })}</div> + <div className="mr-2 text-xs font-medium text-text-secondary uppercase"> + {t(($) => $[`${i18nPrefix}.resolution.name`], { ns: 'workflow' })} + </div> <div className="flex items-center space-x-1"> <OptionCard - title={t($ => $[`${i18nPrefix}.resolution.high`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.resolution.high`], { ns: 'workflow' })} onSelect={handleOnChange(Resolution.high)} selected={value === Resolution.high} /> <OptionCard - title={t($ => $[`${i18nPrefix}.resolution.low`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.resolution.low`], { ns: 'workflow' })} onSelect={handleOnChange(Resolution.low)} selected={value === Resolution.low} /> diff --git a/web/app/components/workflow/nodes/llm/components/structure-output.tsx b/web/app/components/workflow/nodes/llm/components/structure-output.tsx index 17bfc5e2af33e9..62698ad12188bb 100644 --- a/web/app/components/workflow/nodes/llm/components/structure-output.tsx +++ b/web/app/components/workflow/nodes/llm/components/structure-output.tsx @@ -14,16 +14,9 @@ type Props = Readonly<{ onChange: (value: StructuredOutput) => void }> -export function StructureOutput({ - className, - value, - onChange, -}: Props) { +export function StructureOutput({ className, value, onChange }: Props) { const { t } = useTranslation() - const [showConfig, { - setTrue: showConfigModal, - setFalse: hideConfigModal, - }] = useBoolean(false) + const [showConfig, { setTrue: showConfigModal, setFalse: hideConfigModal }] = useBoolean(false) function handleChange(value: SchemaRoot) { onChange({ @@ -38,41 +31,38 @@ export function StructureOutput({ <div className="code-sm-semibold text-text-secondary">structured_output</div> <div className="ml-2 system-xs-regular text-text-tertiary">object</div> </div> - <Button - size="small" - variant="secondary" - className="flex" - onClick={showConfigModal} - > + <Button size="small" variant="secondary" className="flex" onClick={showConfigModal}> <i className="mr-1 i-ri-edit-line size-3.5" aria-hidden="true" /> - <div className="system-xs-medium text-components-button-secondary-text">{t($ => $['structOutput.configure'], { ns: 'app' })}</div> + <div className="system-xs-medium text-components-button-secondary-text"> + {t(($) => $['structOutput.configure'], { ns: 'app' })} + </div> </Button> </div> - {(value?.schema && value.schema.properties && Object.keys(value.schema.properties).length > 0) - ? ( - <ShowPanel - payload={value} - /> - ) - : ( - <button - type="button" - className="mt-1.5 flex h-10 w-full cursor-pointer items-center justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary" - onClick={showConfigModal} - > - {t($ => $['structOutput.notConfiguredTip'], { ns: 'app' })} - </button> - )} + {value?.schema && + value.schema.properties && + Object.keys(value.schema.properties).length > 0 ? ( + <ShowPanel payload={value} /> + ) : ( + <button + type="button" + className="mt-1.5 flex h-10 w-full cursor-pointer items-center justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary" + onClick={showConfigModal} + > + {t(($) => $['structOutput.notConfiguredTip'], { ns: 'app' })} + </button> + )} {showConfig && ( <JsonSchemaConfigModal isShow - defaultSchema={(value?.schema || { - type: Type.object, - properties: {}, - required: [], - additionalProperties: false, - }) as any} // wait for types change + defaultSchema={ + (value?.schema || { + type: Type.object, + properties: {}, + required: [], + additionalProperties: false, + }) as any + } // wait for types change onSave={handleChange as any} // wait for types change onClose={hideConfigModal} /> diff --git a/web/app/components/workflow/nodes/llm/default.ts b/web/app/components/workflow/nodes/llm/default.ts index 57ce0d36f3856d..426c275e9d98b9 100644 --- a/web/app/components/workflow/nodes/llm/default.ts +++ b/web/app/components/workflow/nodes/llm/default.ts @@ -45,10 +45,12 @@ const nodeDefault: NodeDefault<LLMNodeType> = { temperature: 0.7, }, }, - prompt_template: [{ - role: PromptRole.system, - text: '', - }], + prompt_template: [ + { + role: PromptRole.system, + text: '', + }, + ], context: { enabled: false, variable_selector: [], @@ -61,52 +63,83 @@ const nodeDefault: NodeDefault<LLMNodeType> = { '#context#': [RETRIEVAL_OUTPUT_STRUCT], '#files#': [], }, - checkValid(payload: LLMNodeType, t: TFunction<'workflow'>, moreDataForCheckValid?: { flowType?: FlowType }) { + checkValid( + payload: LLMNodeType, + t: TFunction<'workflow'>, + moreDataForCheckValid?: { flowType?: FlowType }, + ) { let errorMessages = '' const isSnippetFlow = moreDataForCheckValid?.flowType === FlowType.snippet const modelIssue = getLLMModelIssue({ modelProvider: payload.model.provider }) if (!errorMessages && modelIssue === LLMModelIssueCode.providerRequired) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.model`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.model`], { ns: 'workflow' }), + }) if (!errorMessages && !payload.memory) { const isChatModel = payload.model.mode === AppModeEnum.CHAT const isPromptEmpty = isChatModel ? !(payload.prompt_template as PromptItem[]).some((t) => { - if (t.edition_type === EditionType.jinja2) - return t.jinja2_text !== '' + if (t.edition_type === EditionType.jinja2) return t.jinja2_text !== '' return t.text !== '' }) - : ((payload.prompt_template as PromptItem).edition_type === EditionType.jinja2 ? (payload.prompt_template as PromptItem).jinja2_text === '' : (payload.prompt_template as PromptItem).text === '') + : (payload.prompt_template as PromptItem).edition_type === EditionType.jinja2 + ? (payload.prompt_template as PromptItem).jinja2_text === '' + : (payload.prompt_template as PromptItem).text === '' if (isPromptEmpty) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.llm.prompt'], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.llm.prompt'], { ns: 'workflow' }), + }) } if (!errorMessages && !!payload.memory) { const isChatModel = payload.model.mode === AppModeEnum.CHAT // payload.memory.query_prompt_template not pass is default: {{#sys.query#}} - if (!isSnippetFlow && isChatModel && !!payload.memory.query_prompt_template && !payload.memory.query_prompt_template.includes('{{#sys.query#}}')) - errorMessages = t($ => $['nodes.llm.sysQueryInUser'], { ns: 'workflow' }) + if ( + !isSnippetFlow && + isChatModel && + !!payload.memory.query_prompt_template && + !payload.memory.query_prompt_template.includes('{{#sys.query#}}') + ) + errorMessages = t(($) => $['nodes.llm.sysQueryInUser'], { ns: 'workflow' }) } if (!errorMessages) { const isChatModel = payload.model.mode === AppModeEnum.CHAT const isShowVars = (() => { if (isChatModel) - return (payload.prompt_template as PromptItem[]).some(item => item.edition_type === EditionType.jinja2) + return (payload.prompt_template as PromptItem[]).some( + (item) => item.edition_type === EditionType.jinja2, + ) return (payload.prompt_template as PromptItem).edition_type === EditionType.jinja2 })() if (isShowVars && payload.prompt_config?.jinja2_variables) { payload.prompt_config?.jinja2_variables.forEach((i) => { if (!errorMessages && !i.variable) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variable`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variable`], { ns: 'workflow' }), + }) if (!errorMessages && !i.value_selector.length) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }), + }) }) } } - if (!errorMessages && payload.vision?.enabled && !payload.vision.configs?.variable_selector?.length) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.visionVariable`], { ns: 'workflow' }) }) + if ( + !errorMessages && + payload.vision?.enabled && + !payload.vision.configs?.variable_selector?.length + ) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.visionVariable`], { ns: 'workflow' }), + }) return { isValid: !errorMessages, errorMessage: errorMessages, diff --git a/web/app/components/workflow/nodes/llm/hooks/__tests__/use-llm-input-manager.spec.ts b/web/app/components/workflow/nodes/llm/hooks/__tests__/use-llm-input-manager.spec.ts index fea1551dc2bc7f..85d184012e5f4b 100644 --- a/web/app/components/workflow/nodes/llm/hooks/__tests__/use-llm-input-manager.spec.ts +++ b/web/app/components/workflow/nodes/llm/hooks/__tests__/use-llm-input-manager.spec.ts @@ -1,11 +1,7 @@ import type { LLMNodeType } from '../../types' import type { LLMDefaultConfig } from '../use-llm-input-manager' import { act, renderHook, waitFor } from '@testing-library/react' -import { - BlockEnum, - EditionType, - PromptRole, -} from '@/app/components/workflow/types' +import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' import useLLMInputManager from '../use-llm-input-manager' @@ -19,11 +15,13 @@ const createPayload = (overrides: Partial<LLMNodeType> = {}): LLMNodeType => ({ mode: AppModeEnum.CHAT, completion_params: {}, }, - prompt_template: [{ - role: PromptRole.system, - text: 'You are helpful.', - edition_type: EditionType.basic, - }], + prompt_template: [ + { + role: PromptRole.system, + text: 'You are helpful.', + edition_type: EditionType.basic, + }, + ], context: { enabled: false, variable_selector: [], @@ -37,11 +35,13 @@ const createPayload = (overrides: Partial<LLMNodeType> = {}): LLMNodeType => ({ const defaultConfig: LLMDefaultConfig = { prompt_templates: { chat_model: { - prompts: [{ - role: PromptRole.system, - text: 'Default chat prompt', - edition_type: EditionType.basic, - }], + prompts: [ + { + role: PromptRole.system, + text: 'Default chat prompt', + edition_type: EditionType.basic, + }, + ], }, completion_model: { prompt: { @@ -60,12 +60,14 @@ const defaultConfig: LLMDefaultConfig = { describe('use-llm-input-manager', () => { it('ignores default configs that do not define prompt templates', () => { const handleSetInputs = vi.fn() - const { result } = renderHook(() => useLLMInputManager({ - inputs: createPayload(), - doSetInputs: handleSetInputs, - defaultConfig: {}, - isChatModel: true, - })) + const { result } = renderHook(() => + useLLMInputManager({ + inputs: createPayload(), + doSetInputs: handleSetInputs, + defaultConfig: {}, + isChatModel: true, + }), + ) const draftPayload = createPayload() @@ -80,37 +82,43 @@ describe('use-llm-input-manager', () => { it('hydrates the default chat prompt when the payload has no prompt template', async () => { const handleSetInputs = vi.fn() - renderHook(() => useLLMInputManager({ - inputs: createPayload({ - prompt_template: undefined as unknown as LLMNodeType['prompt_template'], + renderHook(() => + useLLMInputManager({ + inputs: createPayload({ + prompt_template: undefined as unknown as LLMNodeType['prompt_template'], + }), + doSetInputs: handleSetInputs, + defaultConfig, + isChatModel: true, }), - doSetInputs: handleSetInputs, - defaultConfig, - isChatModel: true, - })) + ) await waitFor(() => { - expect(handleSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - prompt_template: defaultConfig.prompt_templates!.chat_model.prompts, - })) + expect(handleSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + prompt_template: defaultConfig.prompt_templates!.chat_model.prompts, + }), + ) }) }) it('applies completion defaults and injects the default role prefix when memory has none', async () => { const handleSetInputs = vi.fn() - const { result } = renderHook(() => useLLMInputManager({ - inputs: createPayload({ - model: { - provider: 'openai', - name: 'gpt-4o-mini', - mode: AppModeEnum.COMPLETION, - completion_params: {}, - }, + const { result } = renderHook(() => + useLLMInputManager({ + inputs: createPayload({ + model: { + provider: 'openai', + name: 'gpt-4o-mini', + mode: AppModeEnum.COMPLETION, + completion_params: {}, + }, + }), + doSetInputs: handleSetInputs, + defaultConfig, + isChatModel: false, }), - doSetInputs: handleSetInputs, - defaultConfig, - isChatModel: false, - })) + ) const draftPayload = createPayload({ model: { @@ -126,43 +134,49 @@ describe('use-llm-input-manager', () => { }) act(() => { - result.current.setInputs(createPayload({ - model: { - provider: 'openai', - name: 'gpt-4o-mini', - mode: AppModeEnum.COMPLETION, - completion_params: {}, - }, - prompt_template: draftPayload.prompt_template, - memory: { - window: { - enabled: true, - size: 8, + result.current.setInputs( + createPayload({ + model: { + provider: 'openai', + name: 'gpt-4o-mini', + mode: AppModeEnum.COMPLETION, + completion_params: {}, }, - query_prompt_template: '{{#sys.query#}}', - }, - })) + prompt_template: draftPayload.prompt_template, + memory: { + window: { + enabled: true, + size: 8, + }, + query_prompt_template: '{{#sys.query#}}', + }, + }), + ) }) - expect(handleSetInputs).toHaveBeenLastCalledWith(expect.objectContaining({ - prompt_template: defaultConfig.prompt_templates!.completion_model.prompt, - memory: expect.objectContaining({ - role_prefix: { - user: 'USER', - assistant: 'ASSISTANT', - }, + expect(handleSetInputs).toHaveBeenLastCalledWith( + expect.objectContaining({ + prompt_template: defaultConfig.prompt_templates!.completion_model.prompt, + memory: expect.objectContaining({ + role_prefix: { + user: 'USER', + assistant: 'ASSISTANT', + }, + }), }), - })) + ) }) it('passes inputs through unchanged when memory already has a role prefix or is absent', () => { const handleSetInputs = vi.fn() - const { result } = renderHook(() => useLLMInputManager({ - inputs: createPayload(), - doSetInputs: handleSetInputs, - defaultConfig, - isChatModel: true, - })) + const { result } = renderHook(() => + useLLMInputManager({ + inputs: createPayload(), + doSetInputs: handleSetInputs, + defaultConfig, + isChatModel: true, + }), + ) const payloadWithoutMemory = createPayload() const payloadWithRolePrefix = createPayload({ diff --git a/web/app/components/workflow/nodes/llm/hooks/__tests__/use-llm-prompt-config.spec.ts b/web/app/components/workflow/nodes/llm/hooks/__tests__/use-llm-prompt-config.spec.ts index 518fe6e986ceb2..d0dd204c4a3467 100644 --- a/web/app/components/workflow/nodes/llm/hooks/__tests__/use-llm-prompt-config.spec.ts +++ b/web/app/components/workflow/nodes/llm/hooks/__tests__/use-llm-prompt-config.spec.ts @@ -6,12 +6,7 @@ import { HISTORY_PLACEHOLDER_TEXT, QUERY_PLACEHOLDER_TEXT, } from '@/app/components/base/prompt-editor/constants' -import { - BlockEnum, - EditionType, - PromptRole, - VarType, -} from '@/app/components/workflow/types' +import { BlockEnum, EditionType, PromptRole, VarType } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' import useLLMPromptConfig from '../use-llm-prompt-config' @@ -25,11 +20,13 @@ const createPayload = (overrides: Partial<LLMNodeType> = {}): LLMNodeType => ({ mode: AppModeEnum.CHAT, completion_params: {}, }, - prompt_template: [{ - role: PromptRole.system, - text: 'Base prompt', - edition_type: EditionType.basic, - }], + prompt_template: [ + { + role: PromptRole.system, + text: 'Base prompt', + edition_type: EditionType.basic, + }, + ], prompt_config: { jinja2_variables: [], }, @@ -53,58 +50,70 @@ describe('use-llm-prompt-config', () => { it('initializes prompt config storage when it is missing or incomplete', () => { const missingConfigRef = { current: createPayload({ - prompt_template: [{ - role: PromptRole.user, - text: 'Template', - edition_type: EditionType.jinja2, - jinja2_text: '{{ city }}', - }], + prompt_template: [ + { + role: PromptRole.user, + text: 'Template', + edition_type: EditionType.jinja2, + jinja2_text: '{{ city }}', + }, + ], prompt_config: undefined as unknown as LLMNodeType['prompt_config'], }), } as MutableRefObject<LLMNodeType> const handleMissingConfigInputs = createSetInputs(missingConfigRef) - const { result: missingConfigResult } = renderHook(() => useLLMPromptConfig({ - inputs: missingConfigRef.current, - inputRef: missingConfigRef, - isChatMode: true, - isChatModel: true, - setInputs: handleMissingConfigInputs, - })) + const { result: missingConfigResult } = renderHook(() => + useLLMPromptConfig({ + inputs: missingConfigRef.current, + inputRef: missingConfigRef, + isChatMode: true, + isChatModel: true, + setInputs: handleMissingConfigInputs, + }), + ) act(() => { missingConfigResult.current.handleAddEmptyVariable() }) - expect(handleMissingConfigInputs).toHaveBeenCalledWith(expect.objectContaining({ - prompt_config: { - jinja2_variables: [{ - variable: '', - value_selector: [], - }], - }, - })) + expect(handleMissingConfigInputs).toHaveBeenCalledWith( + expect.objectContaining({ + prompt_config: { + jinja2_variables: [ + { + variable: '', + value_selector: [], + }, + ], + }, + }), + ) const missingVariablesRef = { current: createPayload({ - prompt_template: [{ - role: PromptRole.user, - text: 'Template', - edition_type: EditionType.jinja2, - jinja2_text: '{{ budget }}', - }], + prompt_template: [ + { + role: PromptRole.user, + text: 'Template', + edition_type: EditionType.jinja2, + jinja2_text: '{{ budget }}', + }, + ], prompt_config: {} as LLMNodeType['prompt_config'], }), } as MutableRefObject<LLMNodeType> const handleMissingVariablesInputs = createSetInputs(missingVariablesRef) - const { result: missingVariablesResult } = renderHook(() => useLLMPromptConfig({ - inputs: missingVariablesRef.current, - inputRef: missingVariablesRef, - isChatMode: true, - isChatModel: true, - setInputs: handleMissingVariablesInputs, - })) + const { result: missingVariablesResult } = renderHook(() => + useLLMPromptConfig({ + inputs: missingVariablesRef.current, + inputRef: missingVariablesRef, + isChatMode: true, + isChatModel: true, + setInputs: handleMissingVariablesInputs, + }), + ) act(() => { missingVariablesResult.current.handleAddVariable({ @@ -113,14 +122,18 @@ describe('use-llm-prompt-config', () => { }) }) - expect(handleMissingVariablesInputs).toHaveBeenCalledWith(expect.objectContaining({ - prompt_config: { - jinja2_variables: [{ - variable: 'budget', - value_selector: ['start', 'budget'], - }], - }, - })) + expect(handleMissingVariablesInputs).toHaveBeenCalledWith( + expect.objectContaining({ + prompt_config: { + jinja2_variables: [ + { + variable: 'budget', + value_selector: ['start', 'budget'], + }, + ], + }, + }), + ) }) it('derives chat prompt status and filters the supported variable types', () => { @@ -146,13 +159,15 @@ describe('use-llm-prompt-config', () => { }), } as MutableRefObject<LLMNodeType> - const { result } = renderHook(() => useLLMPromptConfig({ - inputs: inputRef.current, - inputRef, - isChatMode: true, - isChatModel: true, - setInputs: createSetInputs(inputRef), - })) + const { result } = renderHook(() => + useLLMPromptConfig({ + inputs: inputRef.current, + inputRef, + isChatMode: true, + isChatModel: true, + setInputs: createSetInputs(inputRef), + }), + ) expect(result.current.hasSetBlockStatus).toEqual({ history: false, @@ -172,29 +187,35 @@ describe('use-llm-prompt-config', () => { it('updates variables, context, prompt, and memory for chat prompts', () => { const inputRef = { current: createPayload({ - prompt_template: [{ - role: PromptRole.user, - text: 'Template', - edition_type: EditionType.jinja2, - jinja2_text: '{{ old_name }}', - }], + prompt_template: [ + { + role: PromptRole.user, + text: 'Template', + edition_type: EditionType.jinja2, + jinja2_text: '{{ old_name }}', + }, + ], prompt_config: { - jinja2_variables: [{ - variable: 'old_name', - value_selector: ['start', 'old_name'], - }], + jinja2_variables: [ + { + variable: 'old_name', + value_selector: ['start', 'old_name'], + }, + ], }, }), } as MutableRefObject<LLMNodeType> const handleSetInputs = createSetInputs(inputRef) - const { result } = renderHook(() => useLLMPromptConfig({ - inputs: inputRef.current, - inputRef, - isChatMode: true, - isChatModel: true, - setInputs: handleSetInputs, - })) + const { result } = renderHook(() => + useLLMPromptConfig({ + inputs: inputRef.current, + inputRef, + isChatMode: true, + isChatModel: true, + setInputs: handleSetInputs, + }), + ) act(() => { result.current.handleAddEmptyVariable() @@ -202,18 +223,22 @@ describe('use-llm-prompt-config', () => { variable: 'budget', value_selector: ['start', 'budget'], }) - result.current.handleVarListChange([{ - variable: 'city', - value_selector: ['start', 'city'], - }]) + result.current.handleVarListChange([ + { + variable: 'city', + value_selector: ['start', 'city'], + }, + ]) result.current.handleVarNameChange('old_name', 'new_name') result.current.handleContextVarChange(['start', 'sys.query']) result.current.handleContextVarChange([]) - result.current.handlePromptChange([{ - role: PromptRole.system, - text: 'Updated prompt', - edition_type: EditionType.basic, - }]) + result.current.handlePromptChange([ + { + role: PromptRole.system, + text: 'Updated prompt', + edition_type: EditionType.basic, + }, + ]) result.current.handleSyeQueryChange('{{#sys.query#}}') result.current.handleSyeQueryChange('custom query') result.current.handleMemoryChange({ @@ -241,13 +266,19 @@ describe('use-llm-prompt-config', () => { value_selector: ['start', 'budget'], }, ]) - expect(handleSetInputs.mock.calls[2]![0].prompt_config?.jinja2_variables).toEqual([{ - variable: 'city', - value_selector: ['start', 'city'], - }]) - expect((handleSetInputs.mock.calls[3]![0].prompt_template as Array<{ - jinja2_text?: string - }>)[0]!.jinja2_text).toBe('{{ new_name }}') + expect(handleSetInputs.mock.calls[2]![0].prompt_config?.jinja2_variables).toEqual([ + { + variable: 'city', + value_selector: ['start', 'city'], + }, + ]) + expect( + ( + handleSetInputs.mock.calls[3]![0].prompt_template as Array<{ + jinja2_text?: string + }> + )[0]!.jinja2_text, + ).toBe('{{ new_name }}') expect(handleSetInputs.mock.calls[4]![0].context).toEqual({ enabled: true, variable_selector: ['start', 'sys.query'], @@ -256,11 +287,13 @@ describe('use-llm-prompt-config', () => { enabled: false, variable_selector: [], }) - expect(handleSetInputs.mock.calls[6]![0].prompt_template).toEqual([{ - role: PromptRole.system, - text: 'Updated prompt', - edition_type: EditionType.basic, - }]) + expect(handleSetInputs.mock.calls[6]![0].prompt_template).toEqual([ + { + role: PromptRole.system, + text: 'Updated prompt', + edition_type: EditionType.basic, + }, + ]) expect(handleSetInputs.mock.calls[7]![0].memory).toEqual({ window: { enabled: false, @@ -296,13 +329,15 @@ describe('use-llm-prompt-config', () => { } as MutableRefObject<LLMNodeType> const handleSetInputs = createSetInputs(basicInputRef) - const { result } = renderHook(() => useLLMPromptConfig({ - inputs: basicInputRef.current, - inputRef: basicInputRef, - isChatMode: true, - isChatModel: false, - setInputs: handleSetInputs, - })) + const { result } = renderHook(() => + useLLMPromptConfig({ + inputs: basicInputRef.current, + inputRef: basicInputRef, + isChatMode: true, + isChatModel: false, + setInputs: handleSetInputs, + }), + ) expect(result.current.hasSetBlockStatus).toEqual({ history: true, @@ -316,11 +351,13 @@ describe('use-llm-prompt-config', () => { result.current.handleVarNameChange('old_name', 'new_name') }) - expect(handleSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - prompt_template: expect.objectContaining({ - text: `${CONTEXT_PLACEHOLDER_TEXT} ${HISTORY_PLACEHOLDER_TEXT} ${QUERY_PLACEHOLDER_TEXT}`, + expect(handleSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + prompt_template: expect.objectContaining({ + text: `${CONTEXT_PLACEHOLDER_TEXT} ${HISTORY_PLACEHOLDER_TEXT} ${QUERY_PLACEHOLDER_TEXT}`, + }), }), - })) + ) const jinjaInputRef = { current: createPayload({ @@ -340,22 +377,26 @@ describe('use-llm-prompt-config', () => { } as MutableRefObject<LLMNodeType> const handleJinjaInputs = createSetInputs(jinjaInputRef) - const { result: jinjaResult } = renderHook(() => useLLMPromptConfig({ - inputs: jinjaInputRef.current, - inputRef: jinjaInputRef, - isChatMode: false, - isChatModel: false, - setInputs: handleJinjaInputs, - })) + const { result: jinjaResult } = renderHook(() => + useLLMPromptConfig({ + inputs: jinjaInputRef.current, + inputRef: jinjaInputRef, + isChatMode: false, + isChatModel: false, + setInputs: handleJinjaInputs, + }), + ) act(() => { jinjaResult.current.handleVarNameChange('old_name', 'budget') }) - expect(handleJinjaInputs).toHaveBeenCalledWith(expect.objectContaining({ - prompt_template: expect.objectContaining({ - jinja2_text: '{{ budget }}', + expect(handleJinjaInputs).toHaveBeenCalledWith( + expect.objectContaining({ + prompt_template: expect.objectContaining({ + jinja2_text: '{{ budget }}', + }), }), - })) + ) }) }) diff --git a/web/app/components/workflow/nodes/llm/hooks/__tests__/use-llm-structured-output-config.spec.ts b/web/app/components/workflow/nodes/llm/hooks/__tests__/use-llm-structured-output-config.spec.ts index 45fdb0822a7b5c..de73c2da9f8ac2 100644 --- a/web/app/components/workflow/nodes/llm/hooks/__tests__/use-llm-structured-output-config.spec.ts +++ b/web/app/components/workflow/nodes/llm/hooks/__tests__/use-llm-structured-output-config.spec.ts @@ -42,13 +42,17 @@ describe('use-llm-structured-output-config', () => { it('detects supported models and updates structured output state', () => { mockUseModelList.mockReturnValue({ - data: [{ - provider: 'openai', - models: [{ - model: 'gpt-4o', - features: [ModelFeatureEnum.StructuredOutput], - }], - }], + data: [ + { + provider: 'openai', + models: [ + { + model: 'gpt-4o', + features: [ModelFeatureEnum.StructuredOutput], + }, + ], + }, + ], } as ReturnType<typeof useModelList>) const inputRef = { @@ -59,13 +63,15 @@ describe('use-llm-structured-output-config', () => { }) const deleteNodeInspectorVars = vi.fn() - const { result } = renderHook(() => useLLMStructuredOutputConfig({ - id: 'llm-node', - model: inputRef.current.model, - inputRef, - setInputs: handleSetInputs, - deleteNodeInspectorVars, - })) + const { result } = renderHook(() => + useLLMStructuredOutputConfig({ + id: 'llm-node', + model: inputRef.current.model, + inputRef, + setInputs: handleSetInputs, + deleteNodeInspectorVars, + }), + ) expect(result.current.isModelSupportStructuredOutput).toBe(true) expect(result.current.structuredOutputCollapsed).toBe(true) @@ -74,9 +80,12 @@ describe('use-llm-structured-output-config', () => { result.current.handleStructureOutputEnableChange(true) }) - expect(handleSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({ - structured_output_enabled: true, - })) + expect(handleSetInputs).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + structured_output_enabled: true, + }), + ) expect(result.current.structuredOutputCollapsed).toBe(false) expect(deleteNodeInspectorVars).toHaveBeenCalledWith('llm-node') @@ -96,47 +105,62 @@ describe('use-llm-structured-output-config', () => { result.current.handleStructureOutputEnableChange(false) }) - expect(handleSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({ - structured_output: { - schema: { - type: Type.object, - properties: { - answer: { - type: Type.string, + expect(handleSetInputs).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + structured_output: { + schema: { + type: Type.object, + properties: { + answer: { + type: Type.string, + }, }, + additionalProperties: false, }, - additionalProperties: false, }, - }, - })) - expect(handleSetInputs).toHaveBeenNthCalledWith(3, expect.objectContaining({ - reasoning_format: 'separated', - })) - expect(handleSetInputs).toHaveBeenNthCalledWith(4, expect.objectContaining({ - structured_output_enabled: false, - })) + }), + ) + expect(handleSetInputs).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + reasoning_format: 'separated', + }), + ) + expect(handleSetInputs).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + structured_output_enabled: false, + }), + ) }) it('returns undefined support when the model is missing from the list', () => { mockUseModelList.mockReturnValue({ - data: [{ - provider: 'anthropic', - models: [{ - model: 'claude', - features: [], - }], - }], + data: [ + { + provider: 'anthropic', + models: [ + { + model: 'claude', + features: [], + }, + ], + }, + ], mutate: vi.fn(), isLoading: false, } as unknown as ReturnType<typeof useModelList>) - const { result } = renderHook(() => useLLMStructuredOutputConfig({ - id: 'llm-node', - model: createPayload().model, - inputRef: { current: createPayload() } as MutableRefObject<LLMNodeType>, - setInputs: vi.fn(), - deleteNodeInspectorVars: vi.fn(), - })) + const { result } = renderHook(() => + useLLMStructuredOutputConfig({ + id: 'llm-node', + model: createPayload().model, + inputRef: { current: createPayload() } as MutableRefObject<LLMNodeType>, + setInputs: vi.fn(), + deleteNodeInspectorVars: vi.fn(), + }), + ) expect(result.current.isModelSupportStructuredOutput).toBeUndefined() }) diff --git a/web/app/components/workflow/nodes/llm/hooks/use-llm-input-manager.ts b/web/app/components/workflow/nodes/llm/hooks/use-llm-input-manager.ts index 036d9542b1bb5b..85b0c9d4755db1 100644 --- a/web/app/components/workflow/nodes/llm/hooks/use-llm-input-manager.ts +++ b/web/app/components/workflow/nodes/llm/hooks/use-llm-input-manager.ts @@ -1,15 +1,7 @@ import type { LLMNodeType } from '../types' -import type { - PromptItem, - RolePrefix, -} from '@/app/components/workflow/types' +import type { PromptItem, RolePrefix } from '@/app/components/workflow/types' import { produce } from 'immer' -import { - useCallback, - useEffect, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' type CompletionPromptTemplate = { prompt: PromptItem @@ -35,54 +27,56 @@ type Params = { isChatModel: boolean } -const useLLMInputManager = ({ - inputs, - doSetInputs, - defaultConfig, - isChatModel, -}: Params) => { - const [defaultRolePrefix, setDefaultRolePrefix] = useState<RolePrefix>({ user: '', assistant: '' }) +const useLLMInputManager = ({ inputs, doSetInputs, defaultConfig, isChatModel }: Params) => { + const [defaultRolePrefix, setDefaultRolePrefix] = useState<RolePrefix>({ + user: '', + assistant: '', + }) const inputRef = useRef(inputs) useEffect(() => { inputRef.current = inputs }, [inputs]) - const setInputs = useCallback((newInputs: LLMNodeType) => { - if (newInputs.memory && !newInputs.memory.role_prefix) { - const payloadWithRolePrefix = produce(newInputs, (draft) => { - draft.memory!.role_prefix = defaultRolePrefix - }) - doSetInputs(payloadWithRolePrefix) - inputRef.current = payloadWithRolePrefix - return - } + const setInputs = useCallback( + (newInputs: LLMNodeType) => { + if (newInputs.memory && !newInputs.memory.role_prefix) { + const payloadWithRolePrefix = produce(newInputs, (draft) => { + draft.memory!.role_prefix = defaultRolePrefix + }) + doSetInputs(payloadWithRolePrefix) + inputRef.current = payloadWithRolePrefix + return + } - doSetInputs(newInputs) - inputRef.current = newInputs - }, [defaultRolePrefix, doSetInputs]) + doSetInputs(newInputs) + inputRef.current = newInputs + }, + [defaultRolePrefix, doSetInputs], + ) - const appendDefaultPromptConfig = useCallback((draft: LLMNodeType, nextDefaultConfig: LLMDefaultConfig, passInIsChatMode?: boolean) => { - const promptTemplates = nextDefaultConfig.prompt_templates - if (!promptTemplates) - return + const appendDefaultPromptConfig = useCallback( + (draft: LLMNodeType, nextDefaultConfig: LLMDefaultConfig, passInIsChatMode?: boolean) => { + const promptTemplates = nextDefaultConfig.prompt_templates + if (!promptTemplates) return - if (passInIsChatMode === undefined ? isChatModel : passInIsChatMode) { - draft.prompt_template = promptTemplates.chat_model.prompts - return - } + if (passInIsChatMode === undefined ? isChatModel : passInIsChatMode) { + draft.prompt_template = promptTemplates.chat_model.prompts + return + } - draft.prompt_template = promptTemplates.completion_model.prompt - setDefaultRolePrefix({ - user: promptTemplates.completion_model.conversation_histories_role.user_prefix, - assistant: promptTemplates.completion_model.conversation_histories_role.assistant_prefix, - }) - }, [isChatModel]) + draft.prompt_template = promptTemplates.completion_model.prompt + setDefaultRolePrefix({ + user: promptTemplates.completion_model.conversation_histories_role.user_prefix, + assistant: promptTemplates.completion_model.conversation_histories_role.assistant_prefix, + }) + }, + [isChatModel], + ) useEffect(() => { const isReady = defaultConfig && Object.keys(defaultConfig).length > 0 - if (!isReady || inputs.prompt_template) - return + if (!isReady || inputs.prompt_template) return const nextInputs = produce(inputs, (draft) => { appendDefaultPromptConfig(draft, defaultConfig) diff --git a/web/app/components/workflow/nodes/llm/hooks/use-llm-prompt-config.ts b/web/app/components/workflow/nodes/llm/hooks/use-llm-prompt-config.ts index 0e2fae969b64fd..e4b5bb7b3d559e 100644 --- a/web/app/components/workflow/nodes/llm/hooks/use-llm-prompt-config.ts +++ b/web/app/components/workflow/nodes/llm/hooks/use-llm-prompt-config.ts @@ -9,19 +9,13 @@ import type { Variable, } from '@/app/components/workflow/types' import { produce } from 'immer' -import { - useCallback, - useMemo, -} from 'react' +import { useCallback, useMemo } from 'react' import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock, } from '@/app/components/base/prompt-editor/constants' -import { - EditionType, - VarType, -} from '@/app/components/workflow/types' +import { EditionType, VarType } from '@/app/components/workflow/types' type Params = { inputs: LLMNodeType @@ -36,11 +30,9 @@ const createPromptConfig = () => ({ }) const ensurePromptConfig = (draft: Draft<LLMNodeType>): { jinja2_variables: Variable[] } => { - if (!draft.prompt_config) - draft.prompt_config = createPromptConfig() + if (!draft.prompt_config) draft.prompt_config = createPromptConfig() - if (!draft.prompt_config.jinja2_variables) - draft.prompt_config.jinja2_variables = [] + if (!draft.prompt_config.jinja2_variables) draft.prompt_config.jinja2_variables = [] return draft.prompt_config as { jinja2_variables: Variable[] } } @@ -86,17 +78,11 @@ const filterMemoryPromptVar = (varPayload: Var) => { ].includes(varPayload.type) } -const useLLMPromptConfig = ({ - inputs, - inputRef, - isChatMode, - isChatModel, - setInputs, -}: Params) => { +const useLLMPromptConfig = ({ inputs, inputRef, isChatMode, isChatModel, setInputs }: Params) => { const hasSetBlockStatus = useMemo(() => { const promptTemplate = inputs.prompt_template const hasSetContext = isChatModel - ? (promptTemplate as PromptItem[]).some(item => checkHasContextBlock(item.text)) + ? (promptTemplate as PromptItem[]).some((item) => checkHasContextBlock(item.text)) : checkHasContextBlock((promptTemplate as PromptItem).text) if (!isChatMode) { @@ -110,7 +96,7 @@ const useLLMPromptConfig = ({ if (isChatModel) { return { history: false, - query: (promptTemplate as PromptItem[]).some(item => checkHasQueryBlock(item.text)), + query: (promptTemplate as PromptItem[]).some((item) => checkHasQueryBlock(item.text)), context: hasSetContext, } } @@ -126,7 +112,9 @@ const useLLMPromptConfig = ({ const isShowVars = useMemo(() => { if (isChatModel) - return (inputs.prompt_template as PromptItem[]).some(item => item.edition_type === EditionType.jinja2) + return (inputs.prompt_template as PromptItem[]).some( + (item) => item.edition_type === EditionType.jinja2, + ) return (inputs.prompt_template as PromptItem).edition_type === EditionType.jinja2 }, [inputs.prompt_template, isChatModel]) @@ -142,82 +130,108 @@ const useLLMPromptConfig = ({ setInputs(nextInputs) }, [inputRef, setInputs]) - const handleAddVariable = useCallback((payload: Variable) => { - const nextInputs = produce(inputRef.current, (draft) => { - const promptConfig = ensurePromptConfig(draft) - promptConfig.jinja2_variables.push(payload) - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) - - const handleVarListChange = useCallback((newList: Variable[]) => { - const nextInputs = produce(inputRef.current, (draft) => { - const promptConfig = ensurePromptConfig(draft) - promptConfig.jinja2_variables = newList - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) - - const handleVarNameChange = useCallback((oldName: string, newName: string) => { - const nextInputs = produce(inputRef.current, (draft) => { - if (isChatModel) { - const promptTemplate = draft.prompt_template as PromptItem[] - promptTemplate - .filter(item => item.edition_type === EditionType.jinja2) - .forEach((item) => { - item.jinja2_text = (item.jinja2_text || '').replaceAll(`{{ ${oldName} }}`, `{{ ${newName} }}`) - }) - return - } - - const promptTemplate = draft.prompt_template as PromptItem - if (promptTemplate.edition_type !== EditionType.jinja2) - return - - promptTemplate.jinja2_text = (promptTemplate.jinja2_text || '').replaceAll(`{{ ${oldName} }}`, `{{ ${newName} }}`) - }) - setInputs(nextInputs) - }, [inputRef, isChatModel, setInputs]) - - const handleContextVarChange = useCallback((newVar: ValueSelector | string) => { - const nextInputs = produce(inputRef.current, (draft) => { - draft.context.variable_selector = (newVar as ValueSelector) || [] - draft.context.enabled = !!(newVar && newVar.length > 0) - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) - - const handlePromptChange = useCallback((newPrompt: PromptItem[] | PromptItem) => { - const nextInputs = produce(inputRef.current, (draft) => { - draft.prompt_template = newPrompt - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) + const handleAddVariable = useCallback( + (payload: Variable) => { + const nextInputs = produce(inputRef.current, (draft) => { + const promptConfig = ensurePromptConfig(draft) + promptConfig.jinja2_variables.push(payload) + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) + + const handleVarListChange = useCallback( + (newList: Variable[]) => { + const nextInputs = produce(inputRef.current, (draft) => { + const promptConfig = ensurePromptConfig(draft) + promptConfig.jinja2_variables = newList + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) + + const handleVarNameChange = useCallback( + (oldName: string, newName: string) => { + const nextInputs = produce(inputRef.current, (draft) => { + if (isChatModel) { + const promptTemplate = draft.prompt_template as PromptItem[] + promptTemplate + .filter((item) => item.edition_type === EditionType.jinja2) + .forEach((item) => { + item.jinja2_text = (item.jinja2_text || '').replaceAll( + `{{ ${oldName} }}`, + `{{ ${newName} }}`, + ) + }) + return + } - const handleMemoryChange = useCallback((newMemory?: Memory) => { - const nextInputs = produce(inputRef.current, (draft) => { - draft.memory = newMemory - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) + const promptTemplate = draft.prompt_template as PromptItem + if (promptTemplate.edition_type !== EditionType.jinja2) return - const handleSyeQueryChange = useCallback((newQuery: string) => { - const nextInputs = produce(inputRef.current, (draft) => { - if (!draft.memory) { - draft.memory = { - window: { - enabled: false, - size: 10, - }, - query_prompt_template: newQuery, + promptTemplate.jinja2_text = (promptTemplate.jinja2_text || '').replaceAll( + `{{ ${oldName} }}`, + `{{ ${newName} }}`, + ) + }) + setInputs(nextInputs) + }, + [inputRef, isChatModel, setInputs], + ) + + const handleContextVarChange = useCallback( + (newVar: ValueSelector | string) => { + const nextInputs = produce(inputRef.current, (draft) => { + draft.context.variable_selector = (newVar as ValueSelector) || [] + draft.context.enabled = !!(newVar && newVar.length > 0) + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) + + const handlePromptChange = useCallback( + (newPrompt: PromptItem[] | PromptItem) => { + const nextInputs = produce(inputRef.current, (draft) => { + draft.prompt_template = newPrompt + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) + + const handleMemoryChange = useCallback( + (newMemory?: Memory) => { + const nextInputs = produce(inputRef.current, (draft) => { + draft.memory = newMemory + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) + + const handleSyeQueryChange = useCallback( + (newQuery: string) => { + const nextInputs = produce(inputRef.current, (draft) => { + if (!draft.memory) { + draft.memory = { + window: { + enabled: false, + size: 10, + }, + query_prompt_template: newQuery, + } + return } - return - } - draft.memory.query_prompt_template = newQuery - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) + draft.memory.query_prompt_template = newQuery + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) return { hasSetBlockStatus, diff --git a/web/app/components/workflow/nodes/llm/hooks/use-llm-structured-output-config.ts b/web/app/components/workflow/nodes/llm/hooks/use-llm-structured-output-config.ts index b8ed17f551ee00..43bd2f67d4e42a 100644 --- a/web/app/components/workflow/nodes/llm/hooks/use-llm-structured-output-config.ts +++ b/web/app/components/workflow/nodes/llm/hooks/use-llm-structured-output-config.ts @@ -1,10 +1,7 @@ import type { MutableRefObject } from 'react' import type { LLMNodeType, StructuredOutput } from '../types' import { produce } from 'immer' -import { - useCallback, - useState, -} from 'react' +import { useCallback, useState } from 'react' import { ModelFeatureEnum, ModelTypeEnum, @@ -28,38 +25,44 @@ const useLLMStructuredOutputConfig = ({ }: Params) => { const { data: modelList } = useModelList(ModelTypeEnum.textGeneration) const isModelSupportStructuredOutput = modelList - ?.find(providerItem => providerItem.provider === model?.provider) - ?.models - .find(modelItem => modelItem.model === model?.name) - ?.features - ?.includes(ModelFeatureEnum.StructuredOutput) + ?.find((providerItem) => providerItem.provider === model?.provider) + ?.models.find((modelItem) => modelItem.model === model?.name) + ?.features?.includes(ModelFeatureEnum.StructuredOutput) const [structuredOutputCollapsed, setStructuredOutputCollapsed] = useState(true) - const handleStructureOutputEnableChange = useCallback((enabled: boolean) => { - const nextInputs = produce(inputRef.current, (draft) => { - draft.structured_output_enabled = enabled - }) - setInputs(nextInputs) - if (enabled) - setStructuredOutputCollapsed(false) - deleteNodeInspectorVars(id) - }, [deleteNodeInspectorVars, id, inputRef, setInputs]) + const handleStructureOutputEnableChange = useCallback( + (enabled: boolean) => { + const nextInputs = produce(inputRef.current, (draft) => { + draft.structured_output_enabled = enabled + }) + setInputs(nextInputs) + if (enabled) setStructuredOutputCollapsed(false) + deleteNodeInspectorVars(id) + }, + [deleteNodeInspectorVars, id, inputRef, setInputs], + ) - const handleStructureOutputChange = useCallback((newOutput: StructuredOutput) => { - const nextInputs = produce(inputRef.current, (draft) => { - draft.structured_output = newOutput - }) - setInputs(nextInputs) - deleteNodeInspectorVars(id) - }, [deleteNodeInspectorVars, id, inputRef, setInputs]) + const handleStructureOutputChange = useCallback( + (newOutput: StructuredOutput) => { + const nextInputs = produce(inputRef.current, (draft) => { + draft.structured_output = newOutput + }) + setInputs(nextInputs) + deleteNodeInspectorVars(id) + }, + [deleteNodeInspectorVars, id, inputRef, setInputs], + ) - const handleReasoningFormatChange = useCallback((reasoningFormat: 'tagged' | 'separated') => { - const nextInputs = produce(inputRef.current, (draft) => { - draft.reasoning_format = reasoningFormat - }) - setInputs(nextInputs) - }, [inputRef, setInputs]) + const handleReasoningFormatChange = useCallback( + (reasoningFormat: 'tagged' | 'separated') => { + const nextInputs = produce(inputRef.current, (draft) => { + draft.reasoning_format = reasoningFormat + }) + setInputs(nextInputs) + }, + [inputRef, setInputs], + ) return { isModelSupportStructuredOutput, diff --git a/web/app/components/workflow/nodes/llm/node.tsx b/web/app/components/workflow/nodes/llm/node.tsx index 691a5f2a853794..e64c8fe02d7690 100644 --- a/web/app/components/workflow/nodes/llm/node.tsx +++ b/web/app/components/workflow/nodes/llm/node.tsx @@ -2,22 +2,15 @@ import type { FC } from 'react' import type { LLMNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' import * as React from 'react' -import { - useTextGenerationCurrentProviderAndModelAndModelList, -} from '@/app/components/header/account-setting/model-provider-page/hooks' +import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' -const Node: FC<NodeProps<LLMNodeType>> = ({ - data, -}) => { +const Node: FC<NodeProps<LLMNodeType>> = ({ data }) => { const { provider, name: modelId } = data.model || {} - const { - textGenerationModelList, - } = useTextGenerationCurrentProviderAndModelAndModelList() + const { textGenerationModelList } = useTextGenerationCurrentProviderAndModelAndModelList() const hasSetModel = provider && modelId - if (!hasSetModel) - return null + if (!hasSetModel) return null return ( <div className="mb-1 px-3 py-1"> diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx index 53a1a2017cf520..528bd928694b57 100644 --- a/web/app/components/workflow/nodes/llm/panel.tsx +++ b/web/app/components/workflow/nodes/llm/panel.tsx @@ -24,12 +24,9 @@ import { getLLMModelIssue, LLMModelIssueCode } from './utils' const i18nPrefix = 'nodes.llm' -const Panel: FC<NodePanelProps<LLMNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<LLMNodeType>> = ({ id, data }) => { const { t } = useTranslation() - const flowType = useHooksStore(s => s.configsMap?.flowType) + const flowType = useHooksStore((s) => s.configsMap?.flowType) const { readOnly, inputs, @@ -68,50 +65,51 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ const model = inputs.model const isModelProviderInstalled = useProviderContextSelector((state) => { const modelIssue = getLLMModelIssue({ modelProvider: model?.provider }) - if (modelIssue === LLMModelIssueCode.providerRequired) - return true + if (modelIssue === LLMModelIssueCode.providerRequired) return true const modelProviderPluginId = extractPluginId(model.provider) - return state.modelProviders.some(provider => extractPluginId(provider.provider) === modelProviderPluginId) + return state.modelProviders.some( + (provider) => extractPluginId(provider.provider) === modelProviderPluginId, + ) }) - const hasModelWarning = getLLMModelIssue({ - modelProvider: model?.provider, - isModelProviderInstalled, - }) !== null + const hasModelWarning = + getLLMModelIssue({ + modelProvider: model?.provider, + isModelProviderInstalled, + }) !== null - const handleModelChange = useCallback((model: { - provider: string - modelId: string - mode?: string - }) => { - (async () => { - try { - const { params: filtered, removedDetails } = await fetchAndMergeValidCompletionParams( - model.provider, - model.modelId, - inputs.model.completion_params, - true, - ) - const keys = Object.keys(removedDetails) - if (keys.length) - toast.warning(`${t($ => $['modelProvider.parametersInvalidRemoved'], { ns: 'common' })}: ${keys.map(k => `${k} (${removedDetails[k]})`).join(', ')}`) - handleCompletionParamsChange(filtered) - } - catch { - toast.error(t($ => $.error, { ns: 'common' })) - handleCompletionParamsChange({}) - } - finally { - handleModelChanged(model) - } - })() - }, [handleCompletionParamsChange, handleModelChanged, inputs.model.completion_params, t]) + const handleModelChange = useCallback( + (model: { provider: string; modelId: string; mode?: string }) => { + ;(async () => { + try { + const { params: filtered, removedDetails } = await fetchAndMergeValidCompletionParams( + model.provider, + model.modelId, + inputs.model.completion_params, + true, + ) + const keys = Object.keys(removedDetails) + if (keys.length) + toast.warning( + `${t(($) => $['modelProvider.parametersInvalidRemoved'], { ns: 'common' })}: ${keys.map((k) => `${k} (${removedDetails[k]})`).join(', ')}`, + ) + handleCompletionParamsChange(filtered) + } catch { + toast.error(t(($) => $.error, { ns: 'common' })) + handleCompletionParamsChange({}) + } finally { + handleModelChanged(model) + } + })() + }, + [handleCompletionParamsChange, handleModelChanged, inputs.model.completion_params, t], + ) return ( <div className="mt-2"> <div className="space-y-4 px-4 pb-4"> <Field - title={t($ => $[`${i18nPrefix}.model`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.model`], { ns: 'workflow' })} required warningDot={hasModelWarning} > @@ -134,8 +132,8 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ {/* knowledge */} <Field - title={t($ => $[`${i18nPrefix}.context`], { ns: 'workflow' })} - tooltip={t($ => $[`${i18nPrefix}.contextTooltip`], { ns: 'workflow' })!} + title={t(($) => $[`${i18nPrefix}.context`], { ns: 'workflow' })} + tooltip={t(($) => $[`${i18nPrefix}.contextTooltip`], { ns: 'workflow' })!} > <> <VarReferencePicker @@ -147,7 +145,9 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ filterVar={filterVar} /> {shouldShowContextTip && ( - <div className="text-xs leading-[18px] font-normal text-[#DC6803]">{t($ => $[`${i18nPrefix}.notSetContextInPromptTip`], { ns: 'workflow' })}</div> + <div className="text-xs leading-[18px] font-normal text-[#DC6803]"> + {t(($) => $[`${i18nPrefix}.notSetContextInPromptTip`], { ns: 'workflow' })} + </div> )} </> </Field> @@ -172,20 +172,18 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ {isShowVars && ( <Field - title={t($ => $['nodes.templateTransform.inputVars'], { ns: 'workflow' })} + title={t(($) => $['nodes.templateTransform.inputVars'], { ns: 'workflow' })} operations={ - !readOnly - ? ( - <button - type="button" - aria-label={`${t($ => $['operation.add'], { ns: 'common' })} ${t($ => $['nodes.templateTransform.inputVars'], { ns: 'workflow' })}`} - className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={handleAddEmptyVariable} - > - <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> - </button> - ) - : undefined + !readOnly ? ( + <button + type="button" + aria-label={`${t(($) => $['operation.add'], { ns: 'common' })} ${t(($) => $['nodes.templateTransform.inputVars'], { ns: 'workflow' })}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleAddEmptyVariable} + > + <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> + </button> + ) : undefined } > <VarList diff --git a/web/app/components/workflow/nodes/llm/types.ts b/web/app/components/workflow/nodes/llm/types.ts index 70dc4d9cc75e06..cefafa643b7984 100644 --- a/web/app/components/workflow/nodes/llm/types.ts +++ b/web/app/components/workflow/nodes/llm/types.ts @@ -1,4 +1,12 @@ -import type { CommonNodeType, Memory, ModelConfig, PromptItem, ValueSelector, Variable, VisionSetting } from '@/app/components/workflow/types' +import type { + CommonNodeType, + Memory, + ModelConfig, + PromptItem, + ValueSelector, + Variable, + VisionSetting, +} from '@/app/components/workflow/types' export type LLMNodeType = CommonNodeType & { model: ModelConfig @@ -49,7 +57,8 @@ export type SchemaEnumType = string[] | number[] export type Field = { type: Type - properties?: { // Object has properties + properties?: { + // Object has properties [key: string]: Field } required?: string[] // Key of required properties in object diff --git a/web/app/components/workflow/nodes/llm/use-config.ts b/web/app/components/workflow/nodes/llm/use-config.ts index 790906fbba4439..38ce1bef8f9f98 100644 --- a/web/app/components/workflow/nodes/llm/use-config.ts +++ b/web/app/components/workflow/nodes/llm/use-config.ts @@ -1,22 +1,13 @@ import type { LLMDefaultConfig } from './hooks/use-llm-input-manager' import type { LLMNodeType } from './types' import { produce } from 'immer' -import { - useCallback, - useEffect, - useState, -} from 'react' -import { - ModelTypeEnum, -} from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useCallback, useEffect, useState } from 'react' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { AppModeEnum } from '@/types/app' -import { - useIsChatMode, - useNodesReadOnly, -} from '../../hooks' +import { useIsChatMode, useNodesReadOnly } from '../../hooks' import useConfigVision from '../../hooks/use-config-vision' import { useStore } from '../../store' import useAvailableVarList from '../_base/hooks/use-available-var-list' @@ -28,18 +19,16 @@ const useConfig = (id: string, payload: LLMNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() const isChatMode = useIsChatMode() - const defaultConfig = useStore(s => s.nodesDefaultConfigs)?.[payload.type] as LLMDefaultConfig | undefined + const defaultConfig = useStore((s) => s.nodesDefaultConfigs)?.[payload.type] as + | LLMDefaultConfig + | undefined const { inputs, setInputs: doSetInputs } = useNodeCrud<LLMNodeType>(id, payload) const model = inputs.model const modelMode = inputs.model?.mode const isChatModel = modelMode === AppModeEnum.CHAT const isCompletionModel = !isChatModel - const { - inputRef, - setInputs, - appendDefaultPromptConfig, - } = useLLMInputManager({ + const { inputRef, setInputs, appendDefaultPromptConfig } = useLLMInputManager({ inputs, doSetInputs, defaultConfig, @@ -49,10 +38,9 @@ const useConfig = (id: string, payload: LLMNodeType) => { const { deleteNodeInspectorVars } = useInspectVarsCrud() const [modelChanged, setModelChanged] = useState(false) - const { - currentProvider, - currentModel, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) + const { currentProvider, currentModel } = useModelListAndDefaultModelAndCurrentProviderAndModel( + ModelTypeEnum.textGeneration, + ) const { isVisionModel, @@ -69,18 +57,21 @@ const useConfig = (id: string, payload: LLMNodeType) => { }, }) - const handleModelChanged = useCallback((model: { provider: string, modelId: string, mode?: string }) => { - const nextInputs = produce(inputRef.current, (draft) => { - draft.model.provider = model.provider - draft.model.name = model.modelId - draft.model.mode = model.mode! - const isModeChange = model.mode !== inputRef.current.model.mode - if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0) - appendDefaultPromptConfig(draft, defaultConfig, model.mode === AppModeEnum.CHAT) - }) - setInputs(nextInputs) - setModelChanged(true) - }, [setInputs, defaultConfig, appendDefaultPromptConfig]) + const handleModelChanged = useCallback( + (model: { provider: string; modelId: string; mode?: string }) => { + const nextInputs = produce(inputRef.current, (draft) => { + draft.model.provider = model.provider + draft.model.name = model.modelId + draft.model.mode = model.mode! + const isModeChange = model.mode !== inputRef.current.model.mode + if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0) + appendDefaultPromptConfig(draft, defaultConfig, model.mode === AppModeEnum.CHAT) + }) + setInputs(nextInputs) + setModelChanged(true) + }, + [setInputs, defaultConfig, appendDefaultPromptConfig], + ) useEffect(() => { if (currentProvider?.provider && currentModel?.model && !model.provider) { @@ -92,17 +83,19 @@ const useConfig = (id: string, payload: LLMNodeType) => { } }, [model.provider, currentProvider, currentModel, handleModelChanged]) - const handleCompletionParamsChange = useCallback((newParams: Record<string, any>) => { - const newInputs = produce(inputRef.current, (draft) => { - draft.model.completion_params = newParams - }) - setInputs(newInputs) - }, [setInputs]) + const handleCompletionParamsChange = useCallback( + (newParams: Record<string, any>) => { + const newInputs = produce(inputRef.current, (draft) => { + draft.model.completion_params = newParams + }) + setInputs(newInputs) + }, + [setInputs], + ) // change to vision model to set vision enabled, else disabled useEffect(() => { - if (!modelChanged) - return + if (!modelChanged) return setModelChanged(false) handleVisionConfigAfterModelChanged() }, [isVisionModel, modelChanged]) @@ -122,10 +115,7 @@ const useConfig = (id: string, payload: LLMNodeType) => { deleteNodeInspectorVars, }) - const { - availableVars, - availableNodesWithParent, - } = useAvailableVarList(id, { + const { availableVars, availableNodesWithParent } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar: promptConfig.filterVar, }) diff --git a/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts b/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts index fb0aa868c3b9a4..b83dfc510dc29d 100644 --- a/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts @@ -43,73 +43,87 @@ const useSingleRunFormParams = ({ const { inputs } = useNodeCrud<LLMNodeType>(id, payload) const getVarInputs = getInputVars const isChatMode = useIsChatMode() - const flowType = useHooksStore(s => s.configsMap?.flowType) + const flowType = useHooksStore((s) => s.configsMap?.flowType) const isSnippetFlow = flowType === FlowType.snippet const contexts = runInputData['#context#'] - const setContexts = useCallback((newContexts: string[]) => { - setRunInputData?.({ - ...runInputDataRef.current, - '#context#': newContexts, - }) - }, [runInputDataRef, setRunInputData]) + const setContexts = useCallback( + (newContexts: string[]) => { + setRunInputData?.({ + ...runInputDataRef.current, + '#context#': newContexts, + }) + }, + [runInputDataRef, setRunInputData], + ) const visionFiles = runInputData['#files#'] - const setVisionFiles = useCallback((newFiles: any[]) => { - setRunInputData?.({ - ...runInputDataRef.current, - '#files#': newFiles, - }) - }, [runInputDataRef, setRunInputData]) + const setVisionFiles = useCallback( + (newFiles: any[]) => { + setRunInputData?.({ + ...runInputDataRef.current, + '#files#': newFiles, + }) + }, + [runInputDataRef, setRunInputData], + ) // model const model = inputs.model const modelMode = inputs.model?.mode const isChatModel = modelMode === AppModeEnum.CHAT - const { - isVisionModel, - } = useConfigVision(model, { + const { isVisionModel } = useConfigVision(model, { payload: inputs.vision, onChange: noop, }) const isShowVars = (() => { if (isChatModel) - return (inputs.prompt_template as PromptItem[]).some(item => item.edition_type === EditionType.jinja2) + return (inputs.prompt_template as PromptItem[]).some( + (item) => item.edition_type === EditionType.jinja2, + ) return (inputs.prompt_template as PromptItem).edition_type === EditionType.jinja2 })() const filterMemoryPromptVar = useCallback((varPayload: Var) => { - return [VarType.arrayObject, VarType.array, VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.file, VarType.arrayFile].includes(varPayload.type) + return [ + VarType.arrayObject, + VarType.array, + VarType.number, + VarType.string, + VarType.secret, + VarType.arrayString, + VarType.arrayNumber, + VarType.file, + VarType.arrayFile, + ].includes(varPayload.type) }, []) - const { - availableVars, - } = useAvailableVarList(id, { + const { availableVars } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar: filterMemoryPromptVar, }) const allVarStrArr = (() => { - const arr = isChatModel ? (inputs.prompt_template as PromptItem[]).filter(item => item.edition_type !== EditionType.jinja2).map(item => item.text) : [(inputs.prompt_template as PromptItem).text] - if (!isSnippetFlow && isChatMode && isChatModel && !!inputs.memory) - arr.push('{{#sys.query#}}') + const arr = isChatModel + ? (inputs.prompt_template as PromptItem[]) + .filter((item) => item.edition_type !== EditionType.jinja2) + .map((item) => item.text) + : [(inputs.prompt_template as PromptItem).text] + if (!isSnippetFlow && isChatMode && isChatModel && !!inputs.memory) arr.push('{{#sys.query#}}') - if (isChatMode && isChatModel && !!inputs.memory) - arr.push(inputs.memory.query_prompt_template) + if (isChatMode && isChatModel && !!inputs.memory) arr.push(inputs.memory.query_prompt_template) return arr })() const varInputs = (() => { const vars = getVarInputs(allVarStrArr) || [] - const filteredVars = isSnippetFlow - ? vars.filter(item => !isSystemInputVar(item)) - : vars + const filteredVars = isSnippetFlow ? vars.filter((item) => !isSystemInputVar(item)) : vars if (isShowVars) { - const jinjaVars = toVarInputs ? (toVarInputs(inputs.prompt_config?.jinja2_variables || [])) : [] + const jinjaVars = toVarInputs ? toVarInputs(inputs.prompt_config?.jinja2_variables || []) : [] return isSnippetFlow - ? [...filteredVars, ...jinjaVars.filter(item => !isSystemInputVar(item))] + ? [...filteredVars, ...jinjaVars.filter((item) => !isSystemInputVar(item))] : [...filteredVars, ...jinjaVars] } @@ -119,79 +133,84 @@ const useSingleRunFormParams = ({ const inputVarValues = (() => { const vars: Record<string, any> = {} Object.keys(runInputData) - .filter(key => !['#context#', '#files#'].includes(key)) + .filter((key) => !['#context#', '#files#'].includes(key)) .forEach((key) => { vars[key] = runInputData[key] }) return vars })() - const setInputVarValues = useCallback((newPayload: Record<string, any>) => { - const newVars = { - ...newPayload, - '#context#': runInputDataRef.current['#context#'], - '#files#': runInputDataRef.current['#files#'], - } - setRunInputData?.(newVars) - }, [runInputDataRef, setRunInputData]) + const setInputVarValues = useCallback( + (newPayload: Record<string, any>) => { + const newVars = { + ...newPayload, + '#context#': runInputDataRef.current['#context#'], + '#files#': runInputDataRef.current['#files#'], + } + setRunInputData?.(newVars) + }, + [runInputDataRef, setRunInputData], + ) const forms = (() => { const forms: FormProps[] = [] if (varInputs.length > 0) { - forms.push( - { - label: t($ => $[`${i18nPrefix}.singleRun.variable`], { ns: 'workflow' })!, - inputs: varInputs, - values: inputVarValues, - onChange: setInputVarValues, - }, - ) + forms.push({ + label: t(($) => $[`${i18nPrefix}.singleRun.variable`], { ns: 'workflow' })!, + inputs: varInputs, + values: inputVarValues, + onChange: setInputVarValues, + }) } if (inputs.context?.variable_selector && inputs.context?.variable_selector.length > 0) { - forms.push( - { - label: t($ => $[`${i18nPrefix}.context`], { ns: 'workflow' })!, - inputs: [{ + forms.push({ + label: t(($) => $[`${i18nPrefix}.context`], { ns: 'workflow' })!, + inputs: [ + { label: '', variable: '#context#', type: InputVarType.contexts, required: false, - }], - values: { '#context#': contexts }, - onChange: keyValue => setContexts(keyValue['#context#']), - }, - ) + }, + ], + values: { '#context#': contexts }, + onChange: (keyValue) => setContexts(keyValue['#context#']), + }) } if (isVisionModel && payload.vision?.enabled && payload.vision?.configs?.variable_selector) { - const currentVariable = findVariableWhenOnLLMVision(payload.vision.configs.variable_selector, availableVars) + const currentVariable = findVariableWhenOnLLMVision( + payload.vision.configs.variable_selector, + availableVars, + ) - forms.push( - { - label: t($ => $[`${i18nPrefix}.vision`], { ns: 'workflow' })!, - inputs: [{ + forms.push({ + label: t(($) => $[`${i18nPrefix}.vision`], { ns: 'workflow' })!, + inputs: [ + { label: currentVariable?.variable as any, variable: '#files#', type: currentVariable?.formType as any, required: false, - }], - values: { '#files#': visionFiles }, - onChange: keyValue => setVisionFiles((keyValue as any)['#files#']), - }, - ) + }, + ], + values: { '#files#': visionFiles }, + onChange: (keyValue) => setVisionFiles((keyValue as any)['#files#']), + }) } return forms })() const getDependentVars = () => { - const promptVars = varInputs.map((item) => { - // Guard against null/undefined variable to prevent app crash - if (!item.variable || typeof item.variable !== 'string') - return [] + const promptVars = varInputs + .map((item) => { + // Guard against null/undefined variable to prevent app crash + if (!item.variable || typeof item.variable !== 'string') return [] - return item.variable.slice(1, -1).split('.') - }).filter(arr => arr.length > 0) + return item.variable.slice(1, -1).split('.') + }) + .filter((arr) => arr.length > 0) const contextVar = payload.context.variable_selector const vars = [...promptVars, contextVar] if (isVisionModel && payload.vision?.enabled && payload.vision?.configs?.variable_selector) { @@ -202,11 +221,9 @@ const useSingleRunFormParams = ({ } const getDependentVar = (variable: string) => { - if (variable === '#context#') - return payload.context.variable_selector + if (variable === '#context#') return payload.context.variable_selector - if (variable === '#files#') - return payload.vision.configs?.variable_selector + if (variable === '#files#') return payload.vision.configs?.variable_selector return false } diff --git a/web/app/components/workflow/nodes/llm/utils.ts b/web/app/components/workflow/nodes/llm/utils.ts index dbfd315874473d..5aea8255401fbc 100644 --- a/web/app/components/workflow/nodes/llm/utils.ts +++ b/web/app/components/workflow/nodes/llm/utils.ts @@ -17,71 +17,62 @@ export const getLLMModelIssue = ({ modelProvider?: string isModelProviderInstalled?: boolean }) => { - if (!modelProvider) - return LLMModelIssueCode.providerRequired + if (!modelProvider) return LLMModelIssueCode.providerRequired - if (!isModelProviderInstalled) - return LLMModelIssueCode.providerPluginUnavailable + if (!isModelProviderInstalled) return LLMModelIssueCode.providerPluginUnavailable return null } -export const isLLMModelProviderInstalled = (modelProvider: string | undefined, installedPluginIds: ReadonlySet<string>) => { - if (!modelProvider) - return true +export const isLLMModelProviderInstalled = ( + modelProvider: string | undefined, + installedPluginIds: ReadonlySet<string>, +) => { + if (!modelProvider) return true return installedPluginIds.has(extractPluginId(modelProvider)) } export const getFieldType = (field: Field) => { const { type, items, enum: enums } = field - if (field.schemaType === 'file') - return Type.file - if (enums && enums.length > 0) - return Type.enumType - if (type !== Type.array || !items) - return type + if (field.schemaType === 'file') return Type.file + if (enums && enums.length > 0) return Type.enumType + if (type !== Type.array || !items) return type return ArrayType[items.type as keyof typeof ArrayType] } export const getHasChildren = (schema: Field) => { const complexTypes = [Type.object, Type.array] - if (!complexTypes.includes(schema.type)) - return false + if (!complexTypes.includes(schema.type)) return false if (schema.type === Type.object) return schema.properties && Object.keys(schema.properties).length > 0 if (schema.type === Type.array) - return schema.items && schema.items.type === Type.object && schema.items.properties && Object.keys(schema.items.properties).length > 0 + return ( + schema.items && + schema.items.type === Type.object && + schema.items.properties && + Object.keys(schema.items.properties).length > 0 + ) } const getTypeOf = (target: any) => { - if (target === null) - return 'null' + if (target === null) return 'null' if (typeof target !== 'object') { return typeof target - } - else { - return Object.prototype.toString - .call(target) - .slice(8, -1) - .toLocaleLowerCase() + } else { + return Object.prototype.toString.call(target).slice(8, -1).toLocaleLowerCase() } } const inferType = (value: any): Type => { const type = getTypeOf(value) - if (type === 'array') - return Type.array + if (type === 'array') return Type.array // type boolean will be treated as string - if (type === 'boolean') - return Type.string - if (type === 'number') - return Type.number - if (type === 'string') - return Type.string - if (type === 'object') - return Type.object + if (type === 'boolean') return Type.string + if (type === 'number') return Type.number + if (type === 'string') return Type.string + if (type === 'object') return Type.object return Type.string } @@ -99,8 +90,7 @@ export const jsonToSchema = (json: any): Field => { schema.properties![key] = jsonToSchema(value) schema.required!.push(key) }) - } - else if (schema.type === Type.array) { + } else if (schema.type === Type.array) { schema.items = jsonToSchema(json[0]) as ArrayItems } @@ -108,17 +98,14 @@ export const jsonToSchema = (json: any): Field => { } export const checkJsonDepth = (json: any) => { - if (!json || getTypeOf(json) !== 'object') - return 0 + if (!json || getTypeOf(json) !== 'object') return 0 let maxDepth = 0 if (getTypeOf(json) === 'array') { - if (json[0] && getTypeOf(json[0]) === 'object') - maxDepth = checkJsonDepth(json[0]) - } - else if (getTypeOf(json) === 'object') { - const propertyDepths = Object.values(json).map(value => checkJsonDepth(value)) + if (json[0] && getTypeOf(json[0]) === 'object') maxDepth = checkJsonDepth(json[0]) + } else if (getTypeOf(json) === 'object') { + const propertyDepths = Object.values(json).map((value) => checkJsonDepth(value)) maxDepth = propertyDepths.length ? Math.max(...propertyDepths) + 1 : 1 } @@ -126,16 +113,16 @@ export const checkJsonDepth = (json: any) => { } export const checkJsonSchemaDepth = (schema: Field) => { - if (!schema || getTypeOf(schema) !== 'object') - return 0 + if (!schema || getTypeOf(schema) !== 'object') return 0 let maxDepth = 0 if (schema.type === Type.object && schema.properties) { - const propertyDepths = Object.values(schema.properties).map(value => checkJsonSchemaDepth(value)) + const propertyDepths = Object.values(schema.properties).map((value) => + checkJsonSchemaDepth(value), + ) maxDepth = propertyDepths.length ? Math.max(...propertyDepths) + 1 : 1 - } - else if (schema.type === Type.array && schema.items && schema.items.type === Type.object) { + } else if (schema.type === Type.array && schema.items && schema.items.type === Type.object) { maxDepth = checkJsonSchemaDepth(schema.items) + 1 } @@ -144,8 +131,7 @@ export const checkJsonSchemaDepth = (schema: Field) => { export const findPropertyWithPath = (target: any, path: string[]) => { let current = target - for (const key of path) - current = current[key] + for (const key of path) current = current[key] return current } @@ -159,12 +145,12 @@ export const validateSchemaAgainstDraft7 = (schemaToValidate: any) => { } export const getValidationErrorMessage = (errors: Array<ValidationError | string>) => { - const message = errors.map((error) => { - if (typeof error === 'string') - return error - else - return `Error: ${error.stack}\n` - }).join('') + const message = errors + .map((error) => { + if (typeof error === 'string') return error + else return `Error: ${error.stack}\n` + }) + .join('') return message } diff --git a/web/app/components/workflow/nodes/loop-end/default.ts b/web/app/components/workflow/nodes/loop-end/default.ts index 7a63b0d27f4526..c191054bb81858 100644 --- a/web/app/components/workflow/nodes/loop-end/default.ts +++ b/web/app/components/workflow/nodes/loop-end/default.ts @@ -1,7 +1,5 @@ import type { NodeDefault } from '../../types' -import type { - SimpleNodeType, -} from '@/app/components/workflow/simple-node/types' +import type { SimpleNodeType } from '@/app/components/workflow/simple-node/types' import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types' import { BlockEnum } from '@/app/components/workflow/types' import { genNodeMetaData } from '@/app/components/workflow/utils' diff --git a/web/app/components/workflow/nodes/loop-start/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/loop-start/__tests__/index.spec.tsx index 443d34e8d5436e..61ed49ccc55da4 100644 --- a/web/app/components/workflow/nodes/loop-start/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/loop-start/__tests__/index.spec.tsx @@ -37,21 +37,21 @@ const createAvailableBlocksResult = (): ReturnType<typeof useAvailableBlocks> => availableNextBlocks: [], }) -const FlowNode = (props: NodeProps<CommonNodeType>) => ( - <LoopStartNode {...props} /> -) +const FlowNode = (props: NodeProps<CommonNodeType>) => <LoopStartNode {...props} /> const renderFlowNode = () => renderWorkflowFlowComponent(<div />, { - nodes: [createNode({ - id: 'loop-start-node', - type: 'loopStartNode', - data: { - title: 'Loop Start', - desc: '', - type: BlockEnum.LoopStart, - }, - })], + nodes: [ + createNode({ + id: 'loop-start-node', + type: 'loopStartNode', + data: { + title: 'Loop Start', + desc: '', + type: BlockEnum.LoopStart, + }, + }), + ], edges: [], reactFlowProps: { nodeTypes: { loopStartNode: FlowNode }, diff --git a/web/app/components/workflow/nodes/loop-start/index.tsx b/web/app/components/workflow/nodes/loop-start/index.tsx index 941c895add702d..6546e358b4299a 100644 --- a/web/app/components/workflow/nodes/loop-start/index.tsx +++ b/web/app/components/workflow/nodes/loop-start/index.tsx @@ -12,12 +12,12 @@ const LoopStartNode = ({ id, data }: NodeProps) => { <div className="nodrag group mt-1 flex size-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg"> <Tooltip> <TooltipTrigger - aria-label={t($ => $['blocks.loop-start'], { ns: 'workflow' })} + aria-label={t(($) => $['blocks.loop-start'], { ns: 'workflow' })} className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0" > <RiHome5Fill className="size-3 text-text-primary-on-surface" /> </TooltipTrigger> - <TooltipContent>{t($ => $['blocks.loop-start'], { ns: 'workflow' })}</TooltipContent> + <TooltipContent>{t(($) => $['blocks.loop-start'], { ns: 'workflow' })}</TooltipContent> </Tooltip> <NodeSourceHandle id={id} @@ -36,12 +36,12 @@ export const LoopStartNodeDumb = () => { <div className="nodrag relative top-[21px] left-[17px] z-11 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg"> <Tooltip> <TooltipTrigger - aria-label={t($ => $['blocks.loop-start'], { ns: 'workflow' })} + aria-label={t(($) => $['blocks.loop-start'], { ns: 'workflow' })} className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0" > <RiHome5Fill className="size-3 text-text-primary-on-surface" /> </TooltipTrigger> - <TooltipContent>{t($ => $['blocks.loop-start'], { ns: 'workflow' })}</TooltipContent> + <TooltipContent>{t(($) => $['blocks.loop-start'], { ns: 'workflow' })}</TooltipContent> </Tooltip> </div> ) diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-config.helpers.spec.ts b/web/app/components/workflow/nodes/loop/__tests__/use-config.helpers.spec.ts index eaf0eb511308ec..eadd064b232d36 100644 --- a/web/app/components/workflow/nodes/loop/__tests__/use-config.helpers.spec.ts +++ b/web/app/components/workflow/nodes/loop/__tests__/use-config.helpers.spec.ts @@ -74,9 +74,7 @@ describe('loop use-config helpers', () => { }) it('should add, update and remove break conditions for regular and file attributes', () => { - mockUuid - .mockReturnValueOnce('condition-1') - .mockReturnValueOnce('condition-2') + mockUuid.mockReturnValueOnce('condition-1').mockReturnValueOnce('condition-2') const withBooleanCondition = addBreakCondition({ inputs: createInputs({ break_conditions: undefined }), @@ -108,74 +106,93 @@ describe('loop use-config helpers', () => { value: 'false', }), ]) - expect(withFileCondition.break_conditions?.[1]).toEqual(expect.objectContaining({ - id: 'condition-2', - varType: VarType.file, - comparison_operator: ComparisonOperator.in, - value: '', - })) - expect(updated.break_conditions?.[1]).toEqual(expect.objectContaining({ - comparison_operator: ComparisonOperator.notIn, - value: [VarType.file], - })) - expect(removed.break_conditions).toEqual([ - expect.objectContaining({ id: 'condition-2' }), - ]) + expect(withFileCondition.break_conditions?.[1]).toEqual( + expect.objectContaining({ + id: 'condition-2', + varType: VarType.file, + comparison_operator: ComparisonOperator.in, + value: '', + }), + ) + expect(updated.break_conditions?.[1]).toEqual( + expect.objectContaining({ + comparison_operator: ComparisonOperator.notIn, + value: [VarType.file], + }), + ) + expect(removed.break_conditions).toEqual([expect.objectContaining({ id: 'condition-2' })]) }) it('should manage nested sub-variable conditions and ignore missing targets', () => { - mockUuid - .mockReturnValueOnce('sub-condition-1') - .mockReturnValueOnce('sub-condition-2') + mockUuid.mockReturnValueOnce('sub-condition-1').mockReturnValueOnce('sub-condition-2') const inputs = createInputs({ - break_conditions: [{ - id: 'condition-1', - varType: VarType.file, - key: 'name', - variable_selector: ['tool-node', 'file'], - comparison_operator: ComparisonOperator.contains, - value: '', - }], + break_conditions: [ + { + id: 'condition-1', + varType: VarType.file, + key: 'name', + variable_selector: ['tool-node', 'file'], + comparison_operator: ComparisonOperator.contains, + value: '', + }, + ], }) const untouched = addSubVariableCondition(inputs, 'missing-condition') const withKeyedSubCondition = addSubVariableCondition(inputs, 'condition-1', 'transfer_method') const withDefaultKeySubCondition = addSubVariableCondition(withKeyedSubCondition, 'condition-1') - const updated = updateSubVariableCondition(withDefaultKeySubCondition, 'condition-1', 'sub-condition-1', { - id: 'sub-condition-1', - key: 'transfer_method', - varType: VarType.string, - comparison_operator: ComparisonOperator.notIn, - value: ['remote_url'], - }) + const updated = updateSubVariableCondition( + withDefaultKeySubCondition, + 'condition-1', + 'sub-condition-1', + { + id: 'sub-condition-1', + key: 'transfer_method', + varType: VarType.string, + comparison_operator: ComparisonOperator.notIn, + value: ['remote_url'], + }, + ) const toggled = toggleSubVariableConditionOperator(updated, 'condition-1') const removed = removeSubVariableCondition(toggled, 'condition-1', 'sub-condition-1') - const unchangedAfterMissingRemove = removeSubVariableCondition(removed, 'missing-condition', 'sub-condition-2') + const unchangedAfterMissingRemove = removeSubVariableCondition( + removed, + 'missing-condition', + 'sub-condition-2', + ) expect(untouched).toEqual(inputs) expect(withKeyedSubCondition.break_conditions?.[0]!.sub_variable_condition).toEqual({ logical_operator: LogicalOperator.and, - conditions: [{ - id: 'sub-condition-1', - key: 'transfer_method', - varType: VarType.string, - comparison_operator: ComparisonOperator.in, - value: '', - }], + conditions: [ + { + id: 'sub-condition-1', + key: 'transfer_method', + varType: VarType.string, + comparison_operator: ComparisonOperator.in, + value: '', + }, + ], }) - expect(withDefaultKeySubCondition.break_conditions?.[0]!.sub_variable_condition?.conditions[1]).toEqual({ + expect( + withDefaultKeySubCondition.break_conditions?.[0]!.sub_variable_condition?.conditions[1], + ).toEqual({ id: 'sub-condition-2', key: '', varType: VarType.string, comparison_operator: undefined, value: '', }) - expect(updated.break_conditions?.[0]!.sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({ - comparison_operator: ComparisonOperator.notIn, - value: ['remote_url'], - })) - expect(toggled.break_conditions?.[0]!.sub_variable_condition?.logical_operator).toBe(LogicalOperator.or) + expect(updated.break_conditions?.[0]!.sub_variable_condition?.conditions[0]).toEqual( + expect.objectContaining({ + comparison_operator: ComparisonOperator.notIn, + value: ['remote_url'], + }), + ) + expect(toggled.break_conditions?.[0]!.sub_variable_condition?.logical_operator).toBe( + LogicalOperator.or, + ) expect(removed.break_conditions?.[0]!.sub_variable_condition?.conditions).toEqual([ expect.objectContaining({ id: 'sub-condition-2' }), ]) @@ -195,20 +212,24 @@ describe('loop use-config helpers', () => { const unchanged = updateLoopVariable(updated, 'missing-loop-variable', { label: 'ignored' }) const removed = removeLoopVariable(unchanged, 'loop-variable-1') - expect(added.loop_variables).toEqual([{ - id: 'loop-variable-1', - label: '', - var_type: VarType.string, - value_type: ValueType.constant, - value: '', - }]) - expect(updated.loop_variables).toEqual([{ - id: 'loop-variable-1', - label: 'Loop Value', - var_type: VarType.string, - value_type: ValueType.variable, - value: ['tool-node', 'result'], - }]) + expect(added.loop_variables).toEqual([ + { + id: 'loop-variable-1', + label: '', + var_type: VarType.string, + value_type: ValueType.constant, + value: '', + }, + ]) + expect(updated.loop_variables).toEqual([ + { + id: 'loop-variable-1', + label: 'Loop Value', + var_type: VarType.string, + value_type: ValueType.variable, + value: ['tool-node', 'result'], + }, + ]) expect(unchanged).toEqual(updated) expect(removed.loop_variables).toEqual([]) expect(inputs.loop_variables).toBeUndefined() diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx index ef09dbf165bb3b..6d85fa1aeddf49 100644 --- a/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx @@ -18,10 +18,13 @@ vi.mock('uuid', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { conversationVariables: unknown[], dataSourceList: unknown[] }) => unknown) => selector({ - conversationVariables: [], - dataSourceList: [], - }), + useStore: ( + selector: (state: { conversationVariables: unknown[]; dataSourceList: unknown[] }) => unknown, + ) => + selector({ + conversationVariables: [], + dataSourceList: [], + }), })) vi.mock('@/service/use-tools', () => ({ @@ -61,22 +64,26 @@ const createPayload = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ( start_node_id: 'start-node', loop_id: 'loop-node', logical_operator: LogicalOperator.and, - break_conditions: [{ - id: 'condition-1', - varType: VarType.string, - variable_selector: ['node-1', 'answer'], - comparison_operator: ComparisonOperator.contains, - value: 'hello', - }], + break_conditions: [ + { + id: 'condition-1', + varType: VarType.string, + variable_selector: ['node-1', 'answer'], + comparison_operator: ComparisonOperator.contains, + value: 'hello', + }, + ], loop_count: 3, error_handle_mode: ErrorHandleMode.ContinueOnError, - loop_variables: [{ - id: 'loop-var-1', - label: 'item', - var_type: VarType.string, - value_type: ValueType.constant, - value: 'value', - }], + loop_variables: [ + { + id: 'loop-var-1', + label: 'item', + var_type: VarType.string, + value_type: ValueType.constant, + value: 'value', + }, + ], ...overrides, }) @@ -91,7 +98,9 @@ describe('useConfig', () => { const { result } = renderHook(() => useConfig('loop-node', createPayload())) expect(result.current.readOnly).toBe(false) - expect(result.current.childrenNodeVars).toEqual([{ nodeId: 'child-node', title: 'Child', vars: [] }]) + expect(result.current.childrenNodeVars).toEqual([ + { nodeId: 'child-node', title: 'Child', vars: [] }, + ]) expect(result.current.loopChildrenNodes).toHaveLength(1) expect(result.current.filterInputVar({ type: VarType.arrayNumber } as never)).toBe(true) expect(result.current.filterInputVar({ type: VarType.string } as never)).toBe(false) @@ -112,51 +121,63 @@ describe('useConfig', () => { result.current.handleRemoveCondition('condition-1') result.current.handleToggleConditionLogicalOperator() - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - error_handle_mode: ErrorHandleMode.Terminated, - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - break_conditions: expect.arrayContaining([ - expect.objectContaining({ - id: 'generated-id', - variable_selector: ['node-1', 'score'], - varType: VarType.number, - }), - ]), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - break_conditions: expect.arrayContaining([ - expect.objectContaining({ - varType: VarType.number, - comparison_operator: ComparisonOperator.largerThan, - value: '3', - }), - ]), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - logical_operator: LogicalOperator.or, - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + error_handle_mode: ErrorHandleMode.Terminated, + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + break_conditions: expect.arrayContaining([ + expect.objectContaining({ + id: 'generated-id', + variable_selector: ['node-1', 'score'], + varType: VarType.number, + }), + ]), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + break_conditions: expect.arrayContaining([ + expect.objectContaining({ + varType: VarType.number, + comparison_operator: ComparisonOperator.largerThan, + value: '3', + }), + ]), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + logical_operator: LogicalOperator.or, + }), + ) }) it('should manage sub-variable conditions and loop variables', () => { const payload = createPayload({ - break_conditions: [{ - id: 'condition-1', - varType: VarType.file, - variable_selector: ['node-1', 'files'], - comparison_operator: ComparisonOperator.contains, - value: '', - sub_variable_condition: { - logical_operator: LogicalOperator.and, - conditions: [{ - id: 'sub-1', - key: 'name', - varType: VarType.string, - comparison_operator: ComparisonOperator.contains, - value: '', - }], + break_conditions: [ + { + id: 'condition-1', + varType: VarType.file, + variable_selector: ['node-1', 'files'], + comparison_operator: ComparisonOperator.contains, + value: '', + sub_variable_condition: { + logical_operator: LogicalOperator.and, + conditions: [ + { + id: 'sub-1', + key: 'name', + varType: VarType.string, + comparison_operator: ComparisonOperator.contains, + value: '', + }, + ], + }, }, - }], + ], }) const { result } = renderHook(() => useConfig('loop-node', payload)) @@ -175,47 +196,57 @@ describe('useConfig', () => { result.current.handleRemoveLoopVariable('loop-var-1') result.current.handleUpdateLoopVariable('loop-var-1', { label: 'updated' }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - break_conditions: [ - expect.objectContaining({ - sub_variable_condition: expect.objectContaining({ - conditions: expect.arrayContaining([ - expect.objectContaining({ - id: 'generated-id', - key: 'name', - }), - ]), + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + break_conditions: [ + expect.objectContaining({ + sub_variable_condition: expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + id: 'generated-id', + key: 'name', + }), + ]), + }), }), - }), - ], - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - break_conditions: [ - expect.objectContaining({ - sub_variable_condition: expect.objectContaining({ - logical_operator: LogicalOperator.or, + ], + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + break_conditions: [ + expect.objectContaining({ + sub_variable_condition: expect.objectContaining({ + logical_operator: LogicalOperator.or, + }), }), - }), - ], - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - loop_count: 5, - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - loop_variables: expect.arrayContaining([ - expect.objectContaining({ - id: 'generated-id', - value_type: ValueType.constant, - }), - ]), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - loop_variables: [ - expect.objectContaining({ - id: 'generated-id', - value_type: ValueType.constant, - }), - ], - })) + ], + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + loop_count: 5, + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + loop_variables: expect.arrayContaining([ + expect.objectContaining({ + id: 'generated-id', + value_type: ValueType.constant, + }), + ]), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + loop_variables: [ + expect.objectContaining({ + id: 'generated-id', + value_type: ValueType.constant, + }), + ], + }), + ) }) }) diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-interactions.helpers.spec.ts b/web/app/components/workflow/nodes/loop/__tests__/use-interactions.helpers.spec.ts index 7e559e5a5d013e..e3c3185653839c 100644 --- a/web/app/components/workflow/nodes/loop/__tests__/use-interactions.helpers.spec.ts +++ b/web/app/components/workflow/nodes/loop/__tests__/use-interactions.helpers.spec.ts @@ -40,30 +40,41 @@ describe('loop interaction helpers', () => { it('restricts loop positions only for loop children and filters loop-start nodes', () => { const parent = createNode({ id: 'parent', width: 200, height: 180 }) - expect(getRestrictedLoopPosition(createNode({ data: { isInLoop: false } }) as Node, parent as Node)).toEqual({ x: undefined, y: undefined }) - expect(getRestrictedLoopPosition( - createNode({ - position: { x: -10, y: 160 }, - width: 80, - height: 40, - data: { isInLoop: true }, - }), - parent as Node, - )).toEqual({ x: 16, y: 120 }) - expect(getRestrictedLoopPosition( - createNode({ - position: { x: 180, y: -4 }, - width: 40, - height: 30, - data: { isInLoop: true }, - }), - parent as Node, - )).toEqual({ x: 144, y: 65 }) - expect(getLoopChildren([ - createNode({ id: 'child', parentId: 'loop-1' }), - createNode({ id: 'start', parentId: 'loop-1', type: 'custom-loop-start' }), - createNode({ id: 'other', parentId: 'other-loop' }), - ] as Node[], 'loop-1').map(item => item.id)).toEqual(['child']) + expect( + getRestrictedLoopPosition(createNode({ data: { isInLoop: false } }) as Node, parent as Node), + ).toEqual({ x: undefined, y: undefined }) + expect( + getRestrictedLoopPosition( + createNode({ + position: { x: -10, y: 160 }, + width: 80, + height: 40, + data: { isInLoop: true }, + }), + parent as Node, + ), + ).toEqual({ x: 16, y: 120 }) + expect( + getRestrictedLoopPosition( + createNode({ + position: { x: 180, y: -4 }, + width: 40, + height: 30, + data: { isInLoop: true }, + }), + parent as Node, + ), + ).toEqual({ x: 144, y: 65 }) + expect( + getLoopChildren( + [ + createNode({ id: 'child', parentId: 'loop-1' }), + createNode({ id: 'start', parentId: 'loop-1', type: 'custom-loop-start' }), + createNode({ id: 'other', parentId: 'other-loop' }), + ] as Node[], + 'loop-1', + ).map((item) => item.id), + ).toEqual(['child']) }) it('builds copied loop children with derived title and loop metadata', () => { @@ -85,16 +96,18 @@ describe('loop interaction helpers', () => { }) expect(result.newId).toBe('loop-23') - expect(result.params).toEqual(expect.objectContaining({ - parentId: 'loop-2', - zIndex: 1001, - data: expect.objectContaining({ - title: 'Code 3', - isInLoop: true, - loop_id: 'loop-2', - selected: false, - _isBundled: false, + expect(result.params).toEqual( + expect.objectContaining({ + parentId: 'loop-2', + zIndex: 1001, + data: expect.objectContaining({ + title: 'Code 3', + isInLoop: true, + loop_id: 'loop-2', + selected: false, + _isBundled: false, + }), }), - })) + ) }) }) diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-interactions.spec.tsx b/web/app/components/workflow/nodes/loop/__tests__/use-interactions.spec.tsx index e28bd4865f234e..37ba944daeecaf 100644 --- a/web/app/components/workflow/nodes/loop/__tests__/use-interactions.spec.tsx +++ b/web/app/components/workflow/nodes/loop/__tests__/use-interactions.spec.tsx @@ -1,9 +1,6 @@ import type { Node } from '@/app/components/workflow/types' import { renderHook } from '@testing-library/react' -import { - createLoopNode, - createNode, -} from '@/app/components/workflow/__tests__/fixtures' +import { createLoopNode, createNode } from '@/app/components/workflow/__tests__/fixtures' import { LOOP_PADDING } from '@/app/components/workflow/constants' import { BlockEnum } from '@/app/components/workflow/types' import { useNodeLoopInteractions } from '../use-interactions' @@ -85,14 +82,16 @@ describe('useNodeLoopInteractions', () => { ]) const { result } = renderHook(() => useNodeLoopInteractions()) - const dragResult = result.current.handleNodeLoopChildDrag(createNode({ - id: 'child-node', - parentId: 'loop-node', - position: { x: -10, y: -5 }, - width: 80, - height: 60, - data: { type: BlockEnum.Code, title: 'Child', desc: '', isInLoop: true }, - })) + const dragResult = result.current.handleNodeLoopChildDrag( + createNode({ + id: 'child-node', + parentId: 'loop-node', + position: { x: -10, y: -5 }, + width: 80, + height: 60, + data: { type: BlockEnum.Code, title: 'Child', desc: '', isInLoop: true }, + }), + ) expect(dragResult.restrictPosition).toEqual({ x: LOOP_PADDING.left, @@ -154,20 +153,30 @@ describe('useNodeLoopInteractions', () => { newNode: createNode({ id: 'generated', parentId: 'new-loop', - data: { type: BlockEnum.Code, title: 'Code 3', desc: '', isInLoop: true, loop_id: 'new-loop' }, + data: { + type: BlockEnum.Code, + title: 'Code 3', + desc: '', + isInLoop: true, + loop_id: 'new-loop', + }, }), }) const { result } = renderHook(() => useNodeLoopInteractions()) - const copyResult = result.current.handleNodeLoopChildrenCopy('loop-node', 'new-loop', { existing: 'mapped' }) + const copyResult = result.current.handleNodeLoopChildrenCopy('loop-node', 'new-loop', { + existing: 'mapped', + }) - expect(mockGenerateNewNode).toHaveBeenCalledWith(expect.objectContaining({ - type: 'custom', - parentId: 'new-loop', - })) + expect(mockGenerateNewNode).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'custom', + parentId: 'new-loop', + }), + ) expect(copyResult.copyChildren).toHaveLength(1) expect(copyResult.newIdMapping).toEqual({ - 'existing': 'mapped', + existing: 'mapped', 'child-node': 'new-loopgeneratednew-loop0', }) }) diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.helpers.spec.ts b/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.helpers.spec.ts index 7d8438b07ca26e..042726037b0853 100644 --- a/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.helpers.spec.ts +++ b/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.helpers.spec.ts @@ -24,15 +24,16 @@ vi.mock('../../_base/components/variable/utils', () => ({ isSystemVar: (...args: unknown[]) => mockIsSystemVar(...args), })) -const createNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({ - id, - position: { x: 0, y: 0 }, - data: { - title, - desc: '', - type, - }, -} as Node) +const createNode = (id: string, title: string, type = BlockEnum.Tool): Node => + ({ + id, + position: { x: 0, y: 0 }, + data: { + title, + desc: '', + type, + }, + }) as Node const createInputVar = (variable: string, label: InputVar['label'] = variable): InputVar => ({ type: InputVarType.textInput, @@ -73,16 +74,18 @@ describe('use-single-run-form-params helpers', () => { ['tool-node', 'value'], ['start-node', 'answer'], ]) - expect(getVarSelectorsFromCase({ - logical_operator: LogicalOperator.or, - conditions: [ - nestedCondition, - createCondition({ - id: 'condition-2', - variable_selector: ['other-node', 'result'], - }), - ], - })).toEqual([ + expect( + getVarSelectorsFromCase({ + logical_operator: LogicalOperator.or, + conditions: [ + nestedCondition, + createCondition({ + id: 'condition-2', + variable_selector: ['other-node', 'result'], + }), + ], + }), + ).toEqual([ ['tool-node', 'value'], ['start-node', 'answer'], ['other-node', 'result'], @@ -126,7 +129,11 @@ describe('use-single-run-form-params helpers', () => { case 'tool-a': return [['sys', 'files']] case 'tool-b': - return [['start-node', 'answer'], ['current-node', 'self'], ['inner-node', 'secret']] + return [ + ['start-node', 'answer'], + ['current-node', 'self'], + ['inner-node', 'secret'], + ] default: return [] } @@ -134,19 +141,22 @@ describe('use-single-run-form-params helpers', () => { mockGetNodeUsedVarPassToServerKey.mockImplementation((_node: Node, selector: string[]) => { return selector[0] === 'sys' ? ['sys_files', 'sys_files_backup'] : 'answer_key' }) - mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id)) + mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => + nodes.find((node) => node.id === id), + ) mockIsSystemVar.mockImplementation((selector: string[]) => selector[0] === 'sys') - const toVarInputs = vi.fn((variables: Variable[]) => variables.map(variable => createInputVar( - variable.variable, - variable.label as InputVar['label'], - ))) + const toVarInputs = vi.fn((variables: Variable[]) => + variables.map((variable) => + createInputVar(variable.variable, variable.label as InputVar['label']), + ), + ) const result = buildUsedOutVars({ loopChildrenNodes, currentNodeId: 'current-node', canChooseVarNodes: [startNode, sysNode, ...loopChildrenNodes], - isNodeInLoop: nodeId => nodeId === 'inner-node', + isNodeInLoop: (nodeId) => nodeId === 'inner-node', toVarInputs, }) @@ -196,10 +206,7 @@ describe('use-single-run-form-params helpers', () => { it('should derive dependent vars from payload and filter current node references', () => { const dependentVars = getDependentVarsFromLoopPayload({ nodeId: 'loop-node', - usedOutVars: [ - createInputVar('start-node.answer'), - createInputVar('loop-node.internal'), - ], + usedOutVars: [createInputVar('start-node.answer'), createInputVar('loop-node.internal')], breakConditions: [ createCondition({ variable_selector: ['tool-node', 'value'], diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.spec.ts index dfe13b79e4fd49..ba404f70527600 100644 --- a/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.spec.ts @@ -2,7 +2,13 @@ import type { InputVar, Node } from '../../../types' import type { LoopNodeType } from '../types' import type { NodeTracing } from '@/types/workflow' import { act, renderHook } from '@testing-library/react' -import { BlockEnum, ErrorHandleMode, InputVarType, ValueType, VarType } from '@/app/components/workflow/types' +import { + BlockEnum, + ErrorHandleMode, + InputVarType, + ValueType, + VarType, +} from '@/app/components/workflow/types' import { ComparisonOperator, LogicalOperator } from '../types' import useSingleRunFormParams from '../use-single-run-form-params' @@ -43,15 +49,16 @@ const createLoopNode = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ...overrides, }) -const createVariableNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({ - id, - position: { x: 0, y: 0 }, - data: { - title, - type, - desc: '', - }, -} as Node) +const createVariableNode = (id: string, title: string, type = BlockEnum.Tool): Node => + ({ + id, + position: { x: 0, y: 0 }, + data: { + title, + type, + desc: '', + }, + }) as Node const createInputVar = (variable: string): InputVar => ({ type: InputVarType.textInput, @@ -106,62 +113,71 @@ describe('useSingleRunFormParams', () => { ], }) mockGetNodeUsedVars.mockImplementation((node: Node) => { - if (node.id === 'tool-a') - return [['start-node', 'answer']] - if (node.id === 'loop-node') - return [['loop-node', 'item']] - if (node.id === 'inner-node') - return [['inner-node', 'secret']] + if (node.id === 'tool-a') return [['start-node', 'answer']] + if (node.id === 'loop-node') return [['loop-node', 'item']] + if (node.id === 'inner-node') return [['inner-node', 'secret']] return [] }) mockGetNodeUsedVarPassToServerKey.mockReturnValue('passed_key') - mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id)) + mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => + nodes.find((node) => node.id === id), + ) mockIsSystemVar.mockReturnValue(false) - mockFormatTracing.mockReturnValue([{ - id: 'formatted-node', - execution_metadata: { loop_index: 9 }, - }]) + mockFormatTracing.mockReturnValue([ + { + id: 'formatted-node', + execution_metadata: { loop_index: 9 }, + }, + ]) }) it('should build single-run forms and filter out loop-local variables', () => { - const toVarInputs = vi.fn((variables: Array<{ variable: string }>) => variables.map(item => createInputVar(item.variable))) + const toVarInputs = vi.fn((variables: Array<{ variable: string }>) => + variables.map((item) => createInputVar(item.variable)), + ) const varSelectorsToVarInputs = vi.fn(() => [ createInputVar('tool-a.result'), createInputVar('tool-a.result'), createInputVar('start-node.answer'), ]) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'loop-node', - payload: createLoopNode({ - break_conditions: [{ - id: 'condition-1', - varType: VarType.string, - variable_selector: ['tool-a', 'result'], - comparison_operator: ComparisonOperator.equal, - value: '', - sub_variable_condition: { - logical_operator: LogicalOperator.and, - conditions: [], - }, - }], - loop_variables: [{ - id: 'loop-variable-1', - label: 'Loop Value', - var_type: VarType.string, - value_type: ValueType.variable, - value: ['start-node', 'answer'], - }], + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'loop-node', + payload: createLoopNode({ + break_conditions: [ + { + id: 'condition-1', + varType: VarType.string, + variable_selector: ['tool-a', 'result'], + comparison_operator: ComparisonOperator.equal, + value: '', + sub_variable_condition: { + logical_operator: LogicalOperator.and, + conditions: [], + }, + }, + ], + loop_variables: [ + { + id: 'loop-variable-1', + label: 'Loop Value', + var_type: VarType.string, + value_type: ValueType.variable, + value: ['start-node', 'answer'], + }, + ], + }), + runInputData: { + question: 'hello', + }, + runResult: null as unknown as NodeTracing, + loopRunResult: [], + setRunInputData: vi.fn(), + toVarInputs, + varSelectorsToVarInputs, }), - runInputData: { - question: 'hello', - }, - runResult: null as unknown as NodeTracing, - loopRunResult: [], - setRunInputData: vi.fn(), - toVarInputs, - varSelectorsToVarInputs, - })) + ) expect(toVarInputs).toHaveBeenCalledWith([ expect.objectContaining({ variable: 'start-node.answer' }), @@ -189,16 +205,18 @@ describe('useSingleRunFormParams', () => { const setRunInputData = vi.fn() const runResult = createRunTrace() - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'loop-node', - payload: createLoopNode(), - runInputData: {}, - runResult, - loopRunResult: [runResult], - setRunInputData, - toVarInputs: vi.fn(() => []), - varSelectorsToVarInputs: vi.fn(() => []), - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'loop-node', + payload: createLoopNode(), + runInputData: {}, + runResult, + loopRunResult: [runResult], + setRunInputData, + toVarInputs: vi.fn(() => []), + varSelectorsToVarInputs: vi.fn(() => []), + }), + ) act(() => { result.current.forms[0]!.onChange({ retry: true }) diff --git a/web/app/components/workflow/nodes/loop/add-block.tsx b/web/app/components/workflow/nodes/loop/add-block.tsx index e49f3f806911f6..b1becf5327dbfa 100644 --- a/web/app/components/workflow/nodes/loop/add-block.tsx +++ b/web/app/components/workflow/nodes/loop/add-block.tsx @@ -1,65 +1,56 @@ import type { LoopNodeType } from './types' -import type { - OnSelectBlock, -} from '@/app/components/workflow/types' +import type { OnSelectBlock } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - RiAddLine, -} from '@remixicon/react' -import { - memo, - useCallback, -} from 'react' +import { RiAddLine } from '@remixicon/react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import BlockSelector from '@/app/components/workflow/block-selector' - -import { - BlockEnum, -} from '@/app/components/workflow/types' -import { - useAvailableBlocks, - useNodesInteractions, - useNodesReadOnly, -} from '../../hooks' +import { BlockEnum } from '@/app/components/workflow/types' +import { useAvailableBlocks, useNodesInteractions, useNodesReadOnly } from '../../hooks' type AddBlockProps = { loopNodeId: string loopNodeData: LoopNodeType } -const AddBlock = ({ - loopNodeData, -}: AddBlockProps) => { +const AddBlock = ({ loopNodeData }: AddBlockProps) => { const { t } = useTranslation() const { nodesReadOnly } = useNodesReadOnly() const { handleNodeAdd } = useNodesInteractions() const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, true) - const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { - handleNodeAdd( - { - nodeType: type, - pluginDefaultValue, - }, - { - prevNodeId: loopNodeData.start_node_id, - prevNodeSourceHandle: 'source', - }, - ) - }, [handleNodeAdd, loopNodeData.start_node_id]) + const handleSelect = useCallback<OnSelectBlock>( + (type, pluginDefaultValue) => { + handleNodeAdd( + { + nodeType: type, + pluginDefaultValue, + }, + { + prevNodeId: loopNodeData.start_node_id, + prevNodeSourceHandle: 'source', + }, + ) + }, + [handleNodeAdd, loopNodeData.start_node_id], + ) - const renderTriggerElement = useCallback((open: boolean) => { - return ( - <div className={cn( - 'relative inline-flex h-8 cursor-pointer items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text shadow-xs backdrop-blur-[5px] hover:bg-components-button-secondary-bg-hover', - `${nodesReadOnly && 'cursor-not-allowed! bg-components-button-secondary-bg-disabled'}`, - open && 'bg-components-button-secondary-bg-hover', - )} - > - <RiAddLine className="mr-1 size-4" /> - {t($ => $['common.addBlock'], { ns: 'workflow' })} - </div> - ) - }, [nodesReadOnly, t]) + const renderTriggerElement = useCallback( + (open: boolean) => { + return ( + <div + className={cn( + 'relative inline-flex h-8 cursor-pointer items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text shadow-xs backdrop-blur-[5px] hover:bg-components-button-secondary-bg-hover', + `${nodesReadOnly && 'cursor-not-allowed! bg-components-button-secondary-bg-disabled'}`, + open && 'bg-components-button-secondary-bg-hover', + )} + > + <RiAddLine className="mr-1 size-4" /> + {t(($) => $['common.addBlock'], { ns: 'workflow' })} + </div> + ) + }, + [nodesReadOnly, t], + ) return ( <div className="absolute top-7 left-14 z-10 flex h-8 items-center"> diff --git a/web/app/components/workflow/nodes/loop/components/condition-add.tsx b/web/app/components/workflow/nodes/loop/components/condition-add.tsx index 5632f49c7697d2..e5041ca360785c 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-add.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-add.tsx @@ -1,20 +1,9 @@ import type { HandleAddCondition } from '../types' -import type { - NodeOutPutVar, - ValueSelector, - Var, -} from '@/app/components/workflow/types' +import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiAddLine } from '@remixicon/react' -import { - useCallback, - useState, -} from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' @@ -25,36 +14,29 @@ type ConditionAddProps = { disabled?: boolean } -const ConditionAdd = ({ - className, - variables, - onSelectVariable, - disabled, -}: ConditionAddProps) => { +const ConditionAdd = ({ className, variables, onSelectVariable, disabled }: ConditionAddProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) - const handleSelectVariable = useCallback((valueSelector: ValueSelector, varItem: Var) => { - onSelectVariable(valueSelector, varItem) - setOpen(false) - }, [onSelectVariable]) + const handleSelectVariable = useCallback( + (valueSelector: ValueSelector, varItem: Var) => { + onSelectVariable(valueSelector, varItem) + setOpen(false) + }, + [onSelectVariable], + ) return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( - <Button - size="small" - className={className} - disabled={disabled} - > + render={ + <Button size="small" className={className} disabled={disabled}> <RiAddLine className="mr-1 size-3.5" /> - {t($ => $['nodes.ifElse.addCondition'], { ns: 'workflow' })} + {t(($) => $['nodes.ifElse.addCondition'], { ns: 'workflow' })} </Button> - )} + } onClick={(e) => { - if (disabled) - e.preventDefault() + if (disabled) e.preventDefault() }} /> <PopoverContent @@ -63,11 +45,7 @@ const ConditionAdd = ({ popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > <div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg"> - <VarReferenceVars - vars={variables} - isSupportFileVar - onChange={handleSelectVariable} - /> + <VarReferenceVars vars={variables} isSupportFileVar onChange={handleSelectVariable} /> </div> </PopoverContent> </Popover> diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx index 4a8d76f47f57e9..0d7a8ad058c79b 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx @@ -1,6 +1,4 @@ -import type { - Node, -} from '@/app/components/workflow/types' +import type { Node } from '@/app/components/workflow/types' import { useTranslation } from 'react-i18next' import PromptEditor from '@/app/components/base/prompt-editor' import { useStore } from '@/app/components/workflow/store' @@ -12,23 +10,18 @@ type ConditionInputProps = { onChange: (value: string) => void availableNodes: Node[] } -const ConditionInput = ({ - value, - onChange, - disabled, - availableNodes, -}: ConditionInputProps) => { +const ConditionInput = ({ value, onChange, disabled, availableNodes }: ConditionInputProps) => { const { t } = useTranslation() - const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) - const pipelineId = useStore(s => s.pipelineId) - const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel) + const controlPromptEditorRerenderKey = useStore((s) => s.controlPromptEditorRerenderKey) + const pipelineId = useStore((s) => s.pipelineId) + const setShowInputFieldPanel = useStore((s) => s.setShowInputFieldPanel) return ( <PromptEditor key={controlPromptEditorRerenderKey} compact value={value} - placeholder={t($ => $['nodes.ifElse.enterValue'], { ns: 'workflow' }) || ''} + placeholder={t(($) => $['nodes.ifElse.enterValue'], { ns: 'workflow' }) || ''} workflowVariableBlock={{ show: true, variables: [], @@ -39,7 +32,7 @@ const ConditionInput = ({ } if (node.data.type === BlockEnum.Start) { acc.sys = { - title: t($ => $['blocks.start'], { ns: 'workflow' }), + title: t(($) => $['blocks.start'], { ns: 'workflow' }), type: BlockEnum.Start, } } diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx index 90470b0987add6..99de05f7e022e8 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx @@ -8,28 +8,23 @@ import type { HandleUpdateCondition, HandleUpdateSubVariableCondition, } from '../../types' -import type { - Node, - NodeOutPutVar, - ValueSelector, - Var, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { RiDeleteBinLine } from '@remixicon/react' import { produce } from 'immer' -import { - useCallback, - useMemo, - useState, -} from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value' import { VarType } from '@/app/components/workflow/types' -import { - ComparisonOperator, -} from '../../types' +import { ComparisonOperator } from '../../types' import ConditionNumberInput from '../condition-number-input' import ConditionWrap from '../condition-wrap' import { FILE_TYPE_OPTIONS, SUB_VARIABLES, TRANSFER_METHOD } from './../../default' @@ -83,43 +78,53 @@ const ConditionItem = ({ const [isHovered, setIsHovered] = useState(false) const [open, setOpen] = useState(false) - const doUpdateCondition = useCallback((newCondition: Condition) => { - if (isSubVariableKey) - onUpdateSubVariableCondition?.(conditionId, condition.id, newCondition) - else - onUpdateCondition?.(condition.id, newCondition) - }, [condition, conditionId, isSubVariableKey, onUpdateCondition, onUpdateSubVariableCondition]) + const doUpdateCondition = useCallback( + (newCondition: Condition) => { + if (isSubVariableKey) onUpdateSubVariableCondition?.(conditionId, condition.id, newCondition) + else onUpdateCondition?.(condition.id, newCondition) + }, + [condition, conditionId, isSubVariableKey, onUpdateCondition, onUpdateSubVariableCondition], + ) const canChooseOperator = useMemo(() => { - if (disabled) - return false + if (disabled) return false - if (isSubVariableKey) - return !!condition.key + if (isSubVariableKey) return !!condition.key return true }, [condition.key, disabled, isSubVariableKey]) - const handleUpdateConditionOperator = useCallback((value: ComparisonOperator) => { - const newCondition = { - ...condition, - comparison_operator: value, - } - doUpdateCondition(newCondition) - }, [condition, doUpdateCondition]) + const handleUpdateConditionOperator = useCallback( + (value: ComparisonOperator) => { + const newCondition = { + ...condition, + comparison_operator: value, + } + doUpdateCondition(newCondition) + }, + [condition, doUpdateCondition], + ) - const handleUpdateConditionNumberVarType = useCallback((numberVarType: NumberVarType) => { - const newCondition = { - ...condition, - numberVarType, - value: '', - } - doUpdateCondition(newCondition) - }, [condition, doUpdateCondition]) + const handleUpdateConditionNumberVarType = useCallback( + (numberVarType: NumberVarType) => { + const newCondition = { + ...condition, + numberVarType, + value: '', + } + doUpdateCondition(newCondition) + }, + [condition, doUpdateCondition], + ) - const isSubVariable = condition.varType === VarType.arrayFile && [ComparisonOperator.contains, ComparisonOperator.notContains, ComparisonOperator.allOf].includes(condition.comparison_operator!) + const isSubVariable = + condition.varType === VarType.arrayFile && + [ + ComparisonOperator.contains, + ComparisonOperator.notContains, + ComparisonOperator.allOf, + ].includes(condition.comparison_operator!) const fileAttr = useMemo(() => { - if (file) - return file + if (file) return file if (isSubVariableKey) { return { key: condition.key!, @@ -130,28 +135,36 @@ const ConditionItem = ({ const isArrayValue = fileAttr?.key === 'transfer_method' || fileAttr?.key === 'type' - const handleUpdateConditionValue = useCallback((value: string | boolean) => { - if (value === condition.value || (isArrayValue && value === (condition.value as string[])?.[0])) - return - const newCondition = { - ...condition, - value: isArrayValue ? [value as string] : value, - } - doUpdateCondition(newCondition) - }, [condition, doUpdateCondition, isArrayValue]) + const handleUpdateConditionValue = useCallback( + (value: string | boolean) => { + if ( + value === condition.value || + (isArrayValue && value === (condition.value as string[])?.[0]) + ) + return + const newCondition = { + ...condition, + value: isArrayValue ? [value as string] : value, + } + doUpdateCondition(newCondition) + }, + [condition, doUpdateCondition, isArrayValue], + ) - const isSelect = condition.comparison_operator && [ComparisonOperator.in, ComparisonOperator.notIn].includes(condition.comparison_operator) - const selectOptions = useMemo<Array<{ name: string, value: string }>>(() => { + const isSelect = + condition.comparison_operator && + [ComparisonOperator.in, ComparisonOperator.notIn].includes(condition.comparison_operator) + const selectOptions = useMemo<Array<{ name: string; value: string }>>(() => { if (isSelect) { if (fileAttr?.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) { - return FILE_TYPE_OPTIONS.map(item => ({ - name: t($ => $[`${optionNameI18NPrefix}.${item.i18nKey}`], { ns: 'workflow' }), + return FILE_TYPE_OPTIONS.map((item) => ({ + name: t(($) => $[`${optionNameI18NPrefix}.${item.i18nKey}`], { ns: 'workflow' }), value: item.value, })) } if (fileAttr?.key === 'transfer_method') { - return TRANSFER_METHOD.map(item => ({ - name: t($ => $[`${optionNameI18NPrefix}.${item.i18nKey}`], { ns: 'workflow' }), + return TRANSFER_METHOD.map((item) => ({ + name: t(($) => $[`${optionNameI18NPrefix}.${item.i18nKey}`], { ns: 'workflow' }), value: item.value, })) } @@ -163,107 +176,120 @@ const ConditionItem = ({ const isNotInput = isSelect || isSubVariable const isSubVarSelect = isSubVariableKey - const subVarOptions = SUB_VARIABLES.map(item => ({ + const subVarOptions = SUB_VARIABLES.map((item) => ({ name: item, value: item, })) - const selectedSubVarOption = subVarOptions.find(item => item.value === condition.key) ?? null + const selectedSubVarOption = subVarOptions.find((item) => item.value === condition.key) ?? null - const handleSubVarKeyChange = useCallback((key: string) => { - const newCondition = produce(condition, (draft) => { - draft.key = key - if (key === 'size') - draft.varType = VarType.number - else - draft.varType = VarType.string + const handleSubVarKeyChange = useCallback( + (key: string) => { + const newCondition = produce(condition, (draft) => { + draft.key = key + if (key === 'size') draft.varType = VarType.number + else draft.varType = VarType.string - draft.value = '' - draft.comparison_operator = getOperators(undefined, { key })[0] - }) + draft.value = '' + draft.comparison_operator = getOperators(undefined, { key })[0] + }) - onUpdateSubVariableCondition?.(conditionId, condition.id, newCondition) - }, [condition, conditionId, onUpdateSubVariableCondition]) + onUpdateSubVariableCondition?.(conditionId, condition.id, newCondition) + }, + [condition, conditionId, onUpdateSubVariableCondition], + ) const doRemoveCondition = useCallback(() => { - if (isSubVariableKey) - onRemoveSubVariableCondition?.(conditionId, condition.id) - else - onRemoveCondition?.(condition.id) + if (isSubVariableKey) onRemoveSubVariableCondition?.(conditionId, condition.id) + else onRemoveCondition?.(condition.id) }, [condition, conditionId, isSubVariableKey, onRemoveCondition, onRemoveSubVariableCondition]) - const handleVarChange = useCallback((valueSelector: ValueSelector, varItem: Var) => { - const newCondition = produce(condition, (draft) => { - draft.variable_selector = valueSelector - draft.varType = varItem.type - draft.value = '' - draft.comparison_operator = getOperators(varItem.type)[0] - delete draft.key - delete draft.sub_variable_condition - delete draft.numberVarType - }) - doUpdateCondition(newCondition) - setOpen(false) - }, [condition, doUpdateCondition]) - const selectedSelectValue = isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string) - const selectedSelectOption = selectOptions.find(item => item.value === selectedSelectValue) ?? null + const handleVarChange = useCallback( + (valueSelector: ValueSelector, varItem: Var) => { + const newCondition = produce(condition, (draft) => { + draft.variable_selector = valueSelector + draft.varType = varItem.type + draft.value = '' + draft.comparison_operator = getOperators(varItem.type)[0] + delete draft.key + delete draft.sub_variable_condition + delete draft.numberVarType + }) + doUpdateCondition(newCondition) + setOpen(false) + }, + [condition, doUpdateCondition], + ) + const selectedSelectValue = isArrayValue + ? (condition.value as string[])?.[0] + : (condition.value as string) + const selectedSelectOption = + selectOptions.find((item) => item.value === selectedSelectValue) ?? null return ( <div className={cn('mb-1 flex last-of-type:mb-0', className)}> - <div className={cn( - 'grow rounded-lg bg-components-input-bg-normal', - isHovered && 'bg-state-destructive-hover', - )} + <div + className={cn( + 'grow rounded-lg bg-components-input-bg-normal', + isHovered && 'bg-state-destructive-hover', + )} > <div className="flex items-center p-1"> <div className="w-0 grow"> - {isSubVarSelect - ? ( - <Select - value={selectedSubVarOption?.value ?? null} - onValueChange={value => value && handleSubVarKeyChange(value)} - > - <SelectTrigger - render={<div />} - nativeButton={false} - className="h-6 border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden" + {isSubVarSelect ? ( + <Select + value={selectedSubVarOption?.value ?? null} + onValueChange={(value) => value && handleSubVarKeyChange(value)} + > + <SelectTrigger + render={<div />} + nativeButton={false} + className="h-6 border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden" + > + {selectedSubVarOption ? ( + <div className="flex cursor-pointer justify-start"> + <div className="inline-flex h-6 max-w-full items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 text-text-accent shadow-xs"> + <Variable02 className="size-3.5 shrink-0 text-text-accent" /> + <div className="ml-0.5 truncate system-xs-medium"> + {selectedSubVarOption.name} + </div> + </div> + </div> + ) : ( + <div className="text-left system-sm-regular text-components-input-text-placeholder"> + {t(($) => $['placeholder.select'], { ns: 'common' })} + </div> + )} + </SelectTrigger> + <SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1"> + {subVarOptions.map((option) => ( + <SelectItem + key={option.value} + value={option.value} + className="h-8 py-0 pr-5 pl-1" > - {selectedSubVarOption - ? ( - <div className="flex cursor-pointer justify-start"> - <div className="inline-flex h-6 max-w-full items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 text-text-accent shadow-xs"> - <Variable02 className="size-3.5 shrink-0 text-text-accent" /> - <div className="ml-0.5 truncate system-xs-medium">{selectedSubVarOption.name}</div> - </div> - </div> - ) - : <div className="text-left system-sm-regular text-components-input-text-placeholder">{t($ => $['placeholder.select'], { ns: 'common' })}</div>} - </SelectTrigger> - <SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1"> - {subVarOptions.map(option => ( - <SelectItem key={option.value} value={option.value} className="h-8 py-0 pr-5 pl-1"> - <div className="flex h-6 items-center justify-between"> - <div className="flex h-full items-center"> - <Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" /> - <SelectItemText className="mr-0 px-0 system-sm-medium text-text-secondary">{option.name}</SelectItemText> - </div> - </div> - </SelectItem> - ))} - </SelectContent> - </Select> - ) - : ( - <ConditionVarSelector - open={open} - onOpenChange={setOpen} - valueSelector={condition.variable_selector || []} - varType={condition.varType} - availableNodes={availableNodes} - nodesOutputVars={availableVars} - onChange={handleVarChange} - /> - )} - + <div className="flex h-6 items-center justify-between"> + <div className="flex h-full items-center"> + <Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" /> + <SelectItemText className="mr-0 px-0 system-sm-medium text-text-secondary"> + {option.name} + </SelectItemText> + </div> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <ConditionVarSelector + open={open} + onOpenChange={setOpen} + valueSelector={condition.variable_selector || []} + varType={condition.varType} + availableNodes={availableNodes} + nodesOutputVars={availableVars} + onChange={handleVarChange} + /> + )} </div> <div className="mx-1 h-3 w-px bg-divider-regular"></div> <ConditionOperator @@ -274,8 +300,10 @@ const ConditionItem = ({ file={fileAttr} /> </div> - { - !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType !== VarType.number && condition.varType !== VarType.boolean && ( + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && + !isNotInput && + condition.varType !== VarType.number && + condition.varType !== VarType.boolean && ( <div className="max-h-[100px] overflow-y-auto border-t border-t-divider-subtle px-2 py-1"> <ConditionInput disabled={disabled} @@ -284,19 +312,16 @@ const ConditionItem = ({ availableNodes={availableNodes} /> </div> - ) - } - {!comparisonOperatorNotRequireValue(condition.comparison_operator) && condition.varType === VarType.boolean - && ( + )} + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && + condition.varType === VarType.boolean && ( <div className="p-1"> - <BoolValue - value={condition.value as boolean} - onChange={handleUpdateConditionValue} - /> + <BoolValue value={condition.value as boolean} onChange={handleUpdateConditionValue} /> </div> )} - { - !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType === VarType.number && ( + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && + !isNotInput && + condition.varType === VarType.number && ( <div className="border-t border-t-divider-subtle px-2 py-1 pt-[3px]"> <ConditionNumberInput numberVarType={condition.numberVarType} @@ -308,46 +333,46 @@ const ConditionItem = ({ unit={fileAttr?.key === 'size' ? 'Byte' : undefined} /> </div> - ) - } - { - !comparisonOperatorNotRequireValue(condition.comparison_operator) && isSelect && ( - <div className="border-t border-t-divider-subtle"> - <Select value={selectedSelectOption?.value ?? null} onValueChange={value => value && handleUpdateConditionValue(value)}> - <SelectTrigger className="h-8 rounded-t-none border-0 px-2 text-xs hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal"> - {selectedSelectOption?.name ?? t($ => $['placeholder.select'], { ns: 'common' })} - </SelectTrigger> - <SelectContent> - {selectOptions.map(option => ( - <SelectItem key={option.value} value={option.value} className="text-xs"> - <SelectItemText>{option.name}</SelectItemText> - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - ) - } - { - !comparisonOperatorNotRequireValue(condition.comparison_operator) && isSubVariable && ( - <div className="p-1"> - <ConditionWrap - isSubVariable - conditions={condition.sub_variable_condition?.conditions || []} - logicalOperator={condition.sub_variable_condition?.logical_operator} - conditionId={conditionId} - readOnly={!!disabled} - handleAddSubVariableCondition={onAddSubVariableCondition} - handleRemoveSubVariableCondition={onRemoveSubVariableCondition} - handleUpdateSubVariableCondition={onUpdateSubVariableCondition} - handleToggleSubVariableConditionLogicalOperator={onToggleSubVariableConditionLogicalOperator} - nodeId={nodeId} - availableNodes={availableNodes} - availableVars={availableVars} - /> - </div> - ) - } + )} + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && isSelect && ( + <div className="border-t border-t-divider-subtle"> + <Select + value={selectedSelectOption?.value ?? null} + onValueChange={(value) => value && handleUpdateConditionValue(value)} + > + <SelectTrigger className="h-8 rounded-t-none border-0 px-2 text-xs hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal"> + {selectedSelectOption?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })} + </SelectTrigger> + <SelectContent> + {selectOptions.map((option) => ( + <SelectItem key={option.value} value={option.value} className="text-xs"> + <SelectItemText>{option.name}</SelectItemText> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + {!comparisonOperatorNotRequireValue(condition.comparison_operator) && isSubVariable && ( + <div className="p-1"> + <ConditionWrap + isSubVariable + conditions={condition.sub_variable_condition?.conditions || []} + logicalOperator={condition.sub_variable_condition?.logical_operator} + conditionId={conditionId} + readOnly={!!disabled} + handleAddSubVariableCondition={onAddSubVariableCondition} + handleRemoveSubVariableCondition={onRemoveSubVariableCondition} + handleUpdateSubVariableCondition={onUpdateSubVariableCondition} + handleToggleSubVariableConditionLogicalOperator={ + onToggleSubVariableConditionLogicalOperator + } + nodeId={nodeId} + availableNodes={availableNodes} + availableVars={availableVars} + /> + </div> + )} </div> <div className="mt-1 ml-1 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive" diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx index 357450fa40c640..23a61a2333ae76 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx @@ -10,9 +10,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { RiArrowDownSLine } from '@remixicon/react' -import { - useMemo, -} from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { getOperators, isComparisonOperatorNeedTranslate } from '../../utils' @@ -39,29 +37,31 @@ const ConditionOperator = ({ const options = useMemo(() => { return getOperators(varType, file).map((o) => { return { - label: isComparisonOperatorNeedTranslate(o) ? t($ => $[`${i18nPrefix}.comparisonOperator.${o}`], { ns: 'workflow' }) : o, + label: isComparisonOperatorNeedTranslate(o) + ? t(($) => $[`${i18nPrefix}.comparisonOperator.${o}`], { ns: 'workflow' }) + : o, value: o, } }) }, [t, varType, file]) - const selectedOption = options.find(o => Array.isArray(value) ? o.value === value[0] : o.value === value) + const selectedOption = options.find((o) => + Array.isArray(value) ? o.value === value[0] : o.value === value, + ) return ( <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <Button className={cn('shrink-0', !selectedOption && 'opacity-50', className)} size="small" variant="ghost" disabled={disabled} /> - )} - > - { - selectedOption - ? selectedOption.label - : t($ => $[`${i18nPrefix}.select`], { ns: 'workflow' }) } + > + {selectedOption + ? selectedOption.label + : t(($) => $[`${i18nPrefix}.select`], { ns: 'workflow' })} <RiArrowDownSLine className="ml-1 size-3.5" /> </DropdownMenuTrigger> <DropdownMenuContent @@ -69,22 +69,17 @@ const ConditionOperator = ({ sideOffset={4} popupClassName="rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1" > - <DropdownMenuRadioGroup - value={selectedOption?.value} - onValueChange={onSelect} - > - { - options.map(option => ( - <DropdownMenuRadioItem - key={option.value} - value={option.value} - closeOnClick - className="h-7 rounded-lg px-3 py-1.5 text-[13px] font-medium text-text-secondary" - > - {option.label} - </DropdownMenuRadioItem> - )) - } + <DropdownMenuRadioGroup value={selectedOption?.value} onValueChange={onSelect}> + {options.map((option) => ( + <DropdownMenuRadioItem + key={option.value} + value={option.value} + closeOnClick + className="h-7 rounded-lg px-3 py-1.5 text-[13px] font-medium text-text-secondary" + > + {option.label} + </DropdownMenuRadioItem> + ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx index f43c7c0ba90ee1..8dc4cbddccb6b5 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx @@ -1,9 +1,11 @@ -import type { Node, NodeOutPutVar, ValueSelector, Var, VarType } from '@/app/components/workflow/types' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import type { + Node, + NodeOutPutVar, + ValueSelector, + Var, + VarType, +} from '@/app/components/workflow/types' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' @@ -29,7 +31,7 @@ const ConditionVarSelector = ({ return ( <Popover open={open} onOpenChange={onOpenChange}> <PopoverTrigger - render={( + render={ <div className="cursor-pointer"> <VariableTag valueSelector={valueSelector} @@ -38,7 +40,7 @@ const ConditionVarSelector = ({ isShort /> </div> - )} + } /> <PopoverContent placement="bottom-start" @@ -46,11 +48,7 @@ const ConditionVarSelector = ({ popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > <div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg"> - <VarReferenceVars - vars={nodesOutputVars} - isSupportFileVar - onChange={onChange} - /> + <VarReferenceVars vars={nodesOutputVars} isSupportFileVar onChange={onChange} /> </div> </PopoverContent> </Popover> diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/index.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/index.tsx index 6b2ad4d62f0c23..33b189b6e7702b 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-list/index.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-list/index.tsx @@ -1,16 +1,18 @@ -import type { Condition, HandleAddSubVariableCondition, HandleRemoveCondition, handleRemoveSubVariableCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition } from '../../types' import type { - Node, - NodeOutPutVar, -} from '@/app/components/workflow/types' + Condition, + HandleAddSubVariableCondition, + HandleRemoveCondition, + handleRemoveSubVariableCondition, + HandleToggleConditionLogicalOperator, + HandleToggleSubVariableConditionLogicalOperator, + HandleUpdateCondition, + HandleUpdateSubVariableCondition, +} from '../../types' +import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { RiLoopLeftLine } from '@remixicon/react' import { useCallback, useMemo } from 'react' -import { - - LogicalOperator, - -} from '../../types' +import { LogicalOperator } from '../../types' import ConditionItem from './condition-item' type ConditionListProps = { @@ -49,72 +51,67 @@ const ConditionList = ({ numberVariables, availableVars, }: ConditionListProps) => { - const doToggleConditionLogicalOperator = useCallback((conditionId?: string) => { - if (isSubVariable && conditionId) - onToggleSubVariableConditionLogicalOperator?.(conditionId) - else - onToggleConditionLogicalOperator?.() - }, [isSubVariable, onToggleConditionLogicalOperator, onToggleSubVariableConditionLogicalOperator]) + const doToggleConditionLogicalOperator = useCallback( + (conditionId?: string) => { + if (isSubVariable && conditionId) onToggleSubVariableConditionLogicalOperator?.(conditionId) + else onToggleConditionLogicalOperator?.() + }, + [isSubVariable, onToggleConditionLogicalOperator, onToggleSubVariableConditionLogicalOperator], + ) const isValueFieldShort = useMemo(() => { - if (isSubVariable && conditions.length > 1) - return true + if (isSubVariable && conditions.length > 1) return true return false }, [conditions.length, isSubVariable]) const conditionItemClassName = useMemo(() => { - if (!isSubVariable) - return '' - if (conditions.length < 2) - return '' + if (!isSubVariable) return '' + if (conditions.length < 2) return '' return logicalOperator === LogicalOperator.and ? 'pl-[51px]' : 'pl-[42px]' }, [conditions.length, isSubVariable, logicalOperator]) return ( <div className={cn('relative', conditions.length > 1 && !isSubVariable && 'pl-[60px]')}> - { - conditions.length > 1 && ( - <div className={cn( + {conditions.length > 1 && ( + <div + className={cn( 'absolute top-0 bottom-0 left-0 w-[60px]', isSubVariable && logicalOperator === LogicalOperator.and && 'left-[-10px]', isSubVariable && logicalOperator === LogicalOperator.or && 'left-[-18px]', )} + > + <div className="absolute top-4 bottom-4 left-[46px] w-2.5 rounded-l-[8px] border border-r-0 border-divider-deep"></div> + <div className="absolute top-1/2 right-0 h-[29px] w-4 -translate-y-1/2 bg-components-panel-bg"></div> + <div + className="absolute top-1/2 right-1 flex h-[21px] -translate-y-1/2 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1 text-[10px] font-semibold text-text-accent-secondary shadow-xs select-none" + onClick={() => doToggleConditionLogicalOperator(conditionId)} > - <div className="absolute top-4 bottom-4 left-[46px] w-2.5 rounded-l-[8px] border border-r-0 border-divider-deep"></div> - <div className="absolute top-1/2 right-0 h-[29px] w-4 -translate-y-1/2 bg-components-panel-bg"></div> - <div - className="absolute top-1/2 right-1 flex h-[21px] -translate-y-1/2 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1 text-[10px] font-semibold text-text-accent-secondary shadow-xs select-none" - onClick={() => doToggleConditionLogicalOperator(conditionId)} - > - {!!logicalOperator && logicalOperator.toUpperCase()} - <RiLoopLeftLine className="ml-0.5 size-3" /> - </div> + {!!logicalOperator && logicalOperator.toUpperCase()} + <RiLoopLeftLine className="ml-0.5 size-3" /> </div> - ) - } - { - conditions.map(condition => ( - <ConditionItem - key={condition.id} - className={conditionItemClassName} - disabled={disabled} - conditionId={isSubVariable ? conditionId! : condition.id} - condition={condition} - isValueFieldShort={isValueFieldShort} - onUpdateCondition={onUpdateCondition} - onRemoveCondition={onRemoveCondition} - onAddSubVariableCondition={onAddSubVariableCondition} - onRemoveSubVariableCondition={onRemoveSubVariableCondition} - onUpdateSubVariableCondition={onUpdateSubVariableCondition} - onToggleSubVariableConditionLogicalOperator={onToggleSubVariableConditionLogicalOperator} - nodeId={nodeId} - availableNodes={availableNodes} - numberVariables={numberVariables} - isSubVariableKey={isSubVariable} - availableVars={availableVars} - /> - )) - } + </div> + )} + {conditions.map((condition) => ( + <ConditionItem + key={condition.id} + className={conditionItemClassName} + disabled={disabled} + conditionId={isSubVariable ? conditionId! : condition.id} + condition={condition} + isValueFieldShort={isValueFieldShort} + onUpdateCondition={onUpdateCondition} + onRemoveCondition={onRemoveCondition} + onAddSubVariableCondition={onAddSubVariableCondition} + onRemoveSubVariableCondition={onRemoveSubVariableCondition} + onUpdateSubVariableCondition={onUpdateSubVariableCondition} + onToggleSubVariableConditionLogicalOperator={onToggleSubVariableConditionLogicalOperator} + nodeId={nodeId} + availableNodes={availableNodes} + numberVariables={numberVariables} + isSubVariableKey={isSubVariable} + availableVars={availableVars} + /> + ))} </div> ) } diff --git a/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx b/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx index b19c0a295f83c6..c961a590997d1a 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx @@ -1,7 +1,4 @@ -import type { - NodeOutPutVar, - ValueSelector, -} from '@/app/components/workflow/types' +import type { NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { @@ -11,19 +8,11 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { capitalize } from 'es-toolkit/string' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' @@ -32,10 +21,7 @@ import { variableTransformer } from '@/app/components/workflow/utils' import VariableTag from '../../_base/components/variable-tag' import { VarType as NumberVarType } from '../../tool/types' -const options = [ - NumberVarType.variable, - NumberVarType.constant, -] +const options = [NumberVarType.variable, NumberVarType.constant] type ConditionNumberInputProps = { numberVarType?: NumberVarType @@ -58,31 +44,20 @@ const ConditionNumberInput = ({ const { t } = useTranslation() const [numberVarTypeVisible, setNumberVarTypeVisible] = useState(false) const [variableSelectorVisible, setVariableSelectorVisible] = useState(false) - const [isFocus, { - setTrue: setFocus, - setFalse: setBlur, - }] = useBoolean() + const [isFocus, { setTrue: setFocus, setFalse: setBlur }] = useBoolean() - const handleSelectVariable = useCallback((valueSelector: ValueSelector) => { - onValueChange(variableTransformer(valueSelector) as string) - setVariableSelectorVisible(false) - }, [onValueChange]) + const handleSelectVariable = useCallback( + (valueSelector: ValueSelector) => { + onValueChange(variableTransformer(valueSelector) as string) + setVariableSelectorVisible(false) + }, + [onValueChange], + ) return ( <div className="flex cursor-pointer items-center"> - <DropdownMenu - open={numberVarTypeVisible} - onOpenChange={setNumberVarTypeVisible} - > - <DropdownMenuTrigger - render={( - <Button - className="shrink-0" - variant="ghost" - size="small" - /> - )} - > + <DropdownMenu open={numberVarTypeVisible} onOpenChange={setNumberVarTypeVisible}> + <DropdownMenuTrigger render={<Button className="shrink-0" variant="ghost" size="small" />}> {capitalize(numberVarType)} <RiArrowDownSLine className="ml-px size-3.5" /> </DropdownMenuTrigger> @@ -91,90 +66,82 @@ const ConditionNumberInput = ({ sideOffset={2} popupClassName="w-[112px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1" > - <DropdownMenuRadioGroup - value={numberVarType} - onValueChange={onNumberVarTypeChange} - > - { - options.map(option => ( - <DropdownMenuRadioItem - key={option} - value={option} - closeOnClick - className={cn( - 'h-7 rounded-md px-3', - 'text-[13px] font-medium text-text-secondary', - numberVarType === option && 'bg-state-base-hover', - )} - > - {capitalize(option)} - </DropdownMenuRadioItem> - )) - } + <DropdownMenuRadioGroup value={numberVarType} onValueChange={onNumberVarTypeChange}> + {options.map((option) => ( + <DropdownMenuRadioItem + key={option} + value={option} + closeOnClick + className={cn( + 'h-7 rounded-md px-3', + 'text-[13px] font-medium text-text-secondary', + numberVarType === option && 'bg-state-base-hover', + )} + > + {capitalize(option)} + </DropdownMenuRadioItem> + ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> <div className="mx-1 h-4 w-px bg-divider-regular"></div> <div className="ml-0.5 w-0 grow"> - { - numberVarType === NumberVarType.variable && ( - <Popover - open={variableSelectorVisible} - onOpenChange={setVariableSelectorVisible} + {numberVarType === NumberVarType.variable && ( + <Popover open={variableSelectorVisible} onOpenChange={setVariableSelectorVisible}> + <PopoverTrigger nativeButton={false} render={<div className="w-full" />}> + {value && ( + <VariableTag + valueSelector={variableTransformer(value) as string[]} + varType={VarType.number} + isShort={isShort} + /> + )} + {!value && ( + <div className="flex h-6 items-center p-1 text-[13px] text-components-input-text-placeholder"> + <Variable02 className="mr-1 size-4 shrink-0" /> + <div className="w-0 grow truncate"> + {t(($) => $['nodes.ifElse.selectVariable'], { ns: 'workflow' })} + </div> + </div> + )} + </PopoverTrigger> + <PopoverContent + placement="bottom-start" + sideOffset={2} + popupClassName="border-none bg-transparent shadow-none" > - <PopoverTrigger - nativeButton={false} - render={<div className="w-full" />} - > - { - value && ( - <VariableTag - valueSelector={variableTransformer(value) as string[]} - varType={VarType.number} - isShort={isShort} - /> - ) - } - { - !value && ( - <div className="flex h-6 items-center p-1 text-[13px] text-components-input-text-placeholder"> - <Variable02 className="mr-1 size-4 shrink-0" /> - <div className="w-0 grow truncate">{t($ => $['nodes.ifElse.selectVariable'], { ns: 'workflow' })}</div> - </div> - ) - } - </PopoverTrigger> - <PopoverContent - placement="bottom-start" - sideOffset={2} - popupClassName="border-none bg-transparent shadow-none" + <div + className={cn( + 'w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pt-1 shadow-lg', + isShort && 'w-[200px]', + )} > - <div className={cn('w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pt-1 shadow-lg', isShort && 'w-[200px]')}> - <VarReferenceVars - vars={variables} - onChange={handleSelectVariable} - /> - </div> - </PopoverContent> - </Popover> - ) - } - { - numberVarType === NumberVarType.constant && ( - <div className="relative"> - <input - className={cn('block w-full appearance-none bg-transparent px-2 text-[13px] text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder', unit && 'pr-6')} - type="number" - value={value} - onChange={e => onValueChange(e.target.value)} - placeholder={t($ => $['nodes.ifElse.enterValue'], { ns: 'workflow' }) || ''} - onFocus={setFocus} - onBlur={setBlur} - /> - {!isFocus && unit && <div className="absolute top-[50%] right-2 translate-y-[-50%] system-sm-regular text-text-tertiary">{unit}</div>} - </div> - ) - } + <VarReferenceVars vars={variables} onChange={handleSelectVariable} /> + </div> + </PopoverContent> + </Popover> + )} + {numberVarType === NumberVarType.constant && ( + <div className="relative"> + <input + className={cn( + 'block w-full appearance-none bg-transparent px-2 text-[13px] text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder', + unit && 'pr-6', + )} + type="number" + value={value} + onChange={(e) => onValueChange(e.target.value)} + placeholder={t(($) => $['nodes.ifElse.enterValue'], { ns: 'workflow' }) || ''} + onFocus={setFocus} + onBlur={setBlur} + /> + {!isFocus && unit && ( + <div className="absolute top-[50%] right-2 translate-y-[-50%] system-sm-regular text-text-tertiary"> + {unit} + </div> + )} + </div> + )} </div> </div> ) diff --git a/web/app/components/workflow/nodes/loop/components/condition-wrap.tsx b/web/app/components/workflow/nodes/loop/components/condition-wrap.tsx index 522eb99b7c6bcd..1b374775d981e0 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-wrap.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-wrap.tsx @@ -1,13 +1,28 @@ 'use client' import type { FC } from 'react' import type { Node, NodeOutPutVar, Var } from '../../../types' -import type { Condition, HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, handleRemoveSubVariableCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition, LogicalOperator } from '../types' +import type { + Condition, + HandleAddCondition, + HandleAddSubVariableCondition, + HandleRemoveCondition, + handleRemoveSubVariableCondition, + HandleToggleConditionLogicalOperator, + HandleToggleSubVariableConditionLogicalOperator, + HandleUpdateCondition, + HandleUpdateSubVariableCondition, + LogicalOperator, +} from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { - RiAddLine, -} from '@remixicon/react' + Select, + SelectContent, + SelectItem, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' +import { RiAddLine } from '@remixicon/react' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -62,13 +77,12 @@ const ConditionWrap: FC<Props> = ({ return varPayload.type === VarType.number }, []) - const subVarOptions = SUB_VARIABLES.map(item => ({ + const subVarOptions = SUB_VARIABLES.map((item) => ({ name: item, value: item, })) - if (!conditions) - return <div /> + if (!conditions) return <div /> return ( <> @@ -80,74 +94,72 @@ const ConditionWrap: FC<Props> = ({ isSubVariable && 'px-1 py-2', )} > - { - conditions && !!conditions.length && ( - <div className="mb-2"> - <ConditionList - disabled={readOnly} - conditionId={conditionId} - conditions={conditions} - logicalOperator={logicalOperator} - onUpdateCondition={handleUpdateCondition} - onRemoveCondition={handleRemoveCondition} - onToggleConditionLogicalOperator={handleToggleConditionLogicalOperator} - nodeId={id} - availableNodes={availableNodes} - numberVariables={getAvailableVars(id, '', filterNumberVar)} - onAddSubVariableCondition={handleAddSubVariableCondition} - onRemoveSubVariableCondition={handleRemoveSubVariableCondition} - onUpdateSubVariableCondition={handleUpdateSubVariableCondition} - onToggleSubVariableConditionLogicalOperator={handleToggleSubVariableConditionLogicalOperator} - isSubVariable={isSubVariable} - availableVars={availableVars} - /> - </div> - ) - } - - <div className={cn( - 'flex items-center justify-between pr-[30px]', - !conditions.length && !isSubVariable && 'mt-1', - !conditions.length && isSubVariable && 'mt-2', - conditions.length > 1 && !isSubVariable && 'ml-[60px]', + {conditions && !!conditions.length && ( + <div className="mb-2"> + <ConditionList + disabled={readOnly} + conditionId={conditionId} + conditions={conditions} + logicalOperator={logicalOperator} + onUpdateCondition={handleUpdateCondition} + onRemoveCondition={handleRemoveCondition} + onToggleConditionLogicalOperator={handleToggleConditionLogicalOperator} + nodeId={id} + availableNodes={availableNodes} + numberVariables={getAvailableVars(id, '', filterNumberVar)} + onAddSubVariableCondition={handleAddSubVariableCondition} + onRemoveSubVariableCondition={handleRemoveSubVariableCondition} + onUpdateSubVariableCondition={handleUpdateSubVariableCondition} + onToggleSubVariableConditionLogicalOperator={ + handleToggleSubVariableConditionLogicalOperator + } + isSubVariable={isSubVariable} + availableVars={availableVars} + /> + </div> )} + + <div + className={cn( + 'flex items-center justify-between pr-[30px]', + !conditions.length && !isSubVariable && 'mt-1', + !conditions.length && isSubVariable && 'mt-2', + conditions.length > 1 && !isSubVariable && 'ml-[60px]', + )} > - {isSubVariable - ? ( - <Select - value={null} - disabled={readOnly} - onValueChange={value => value && handleAddSubVariableCondition?.(conditionId!, value)} - > - <SelectTrigger - render={<div />} - nativeButton={false} - className="border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden" - > - <Button - size="small" - disabled={readOnly} - > - <RiAddLine className="mr-1 size-3.5" /> - {t($ => $['nodes.ifElse.addSubVariable'], { ns: 'workflow' })} - </Button> - </SelectTrigger> - <SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1"> - {subVarOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - <SelectItemText>{option.name}</SelectItemText> - </SelectItem> - ))} - </SelectContent> - </Select> - ) - : ( - <ConditionAdd - disabled={readOnly} - variables={availableVars} - onSelectVariable={handleAddCondition!} - /> - )} + {isSubVariable ? ( + <Select + value={null} + disabled={readOnly} + onValueChange={(value) => + value && handleAddSubVariableCondition?.(conditionId!, value) + } + > + <SelectTrigger + render={<div />} + nativeButton={false} + className="border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden" + > + <Button size="small" disabled={readOnly}> + <RiAddLine className="mr-1 size-3.5" /> + {t(($) => $['nodes.ifElse.addSubVariable'], { ns: 'workflow' })} + </Button> + </SelectTrigger> + <SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1"> + {subVarOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + <SelectItemText>{option.name}</SelectItemText> + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <ConditionAdd + disabled={readOnly} + variables={availableVars} + onSelectVariable={handleAddCondition!} + /> + )} </div> </div> </div> diff --git a/web/app/components/workflow/nodes/loop/components/loop-variables/empty.tsx b/web/app/components/workflow/nodes/loop/components/loop-variables/empty.tsx index e7982812927b54..a286eb077b3a47 100644 --- a/web/app/components/workflow/nodes/loop/components/loop-variables/empty.tsx +++ b/web/app/components/workflow/nodes/loop/components/loop-variables/empty.tsx @@ -5,7 +5,7 @@ const Empty = () => { return ( <div className="flex h-10 items-center justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary"> - {t($ => $['nodes.loop.setLoopVariables'], { ns: 'workflow' })} + {t(($) => $['nodes.loop.setLoopVariables'], { ns: 'workflow' })} </div> ) } diff --git a/web/app/components/workflow/nodes/loop/components/loop-variables/form-item.tsx b/web/app/components/workflow/nodes/loop/components/loop-variables/form-item.tsx index cb9e0bfa4a1337..91a3f7df7e1eeb 100644 --- a/web/app/components/workflow/nodes/loop/components/loop-variables/form-item.tsx +++ b/web/app/components/workflow/nodes/loop/components/loop-variables/form-item.tsx @@ -1,14 +1,7 @@ -import type { - LoopVariable, -} from '@/app/components/workflow/nodes/loop/types' -import type { - Var, -} from '@/app/components/workflow/types' +import type { LoopVariable } from '@/app/components/workflow/nodes/loop/types' +import type { Var } from '@/app/components/workflow/types' import { Textarea } from '@langgenius/dify-ui/textarea' -import { - useCallback, - useMemo, -} from 'react' +import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' @@ -16,7 +9,6 @@ import VarReferencePicker from '@/app/components/workflow/nodes/_base/components import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import ArrayBoolList from '@/app/components/workflow/panel/chat-variable-panel/components/array-bool-list' import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value' - import { arrayBoolPlaceholder, arrayNumberPlaceholder, @@ -24,108 +16,96 @@ import { arrayStringPlaceholder, objectPlaceholder, } from '@/app/components/workflow/panel/chat-variable-panel/utils' -import { - ValueType, - VarType, -} from '@/app/components/workflow/types' +import { ValueType, VarType } from '@/app/components/workflow/types' type FormItemProps = { nodeId: string item: LoopVariable onChange: (value: any) => void } -const FormItem = ({ - nodeId, - item, - onChange, -}: FormItemProps) => { +const FormItem = ({ nodeId, item, onChange }: FormItemProps) => { const { t } = useTranslation() const { value_type, var_type, value } = item const normalizedVarValue = useMemo(() => { return Array.isArray(value) ? value : [] }, [value]) - const handleInputChange = useCallback((e: any) => { - onChange(e.target.value) - }, [onChange]) + const handleInputChange = useCallback( + (e: any) => { + onChange(e.target.value) + }, + [onChange], + ) - const handleValueChange = useCallback((value: string) => { - onChange(value) - }, [onChange]) + const handleValueChange = useCallback( + (value: string) => { + onChange(value) + }, + [onChange], + ) - const handleChange = useCallback((value: any) => { - onChange(value) - }, [onChange]) + const handleChange = useCallback( + (value: any) => { + onChange(value) + }, + [onChange], + ) - const filterVar = useCallback((variable: Var) => { - return variable.type === var_type - }, [var_type]) + const filterVar = useCallback( + (variable: Var) => { + return variable.type === var_type + }, + [var_type], + ) const editorMinHeight = useMemo(() => { - if (var_type === VarType.arrayObject) - return '240px' + if (var_type === VarType.arrayObject) return '240px' return '120px' }, [var_type]) const placeholder = useMemo(() => { - if (var_type === VarType.arrayString) - return arrayStringPlaceholder - if (var_type === VarType.arrayNumber) - return arrayNumberPlaceholder - if (var_type === VarType.arrayObject) - return arrayObjectPlaceholder - if (var_type === VarType.arrayBoolean) - return arrayBoolPlaceholder + if (var_type === VarType.arrayString) return arrayStringPlaceholder + if (var_type === VarType.arrayNumber) return arrayNumberPlaceholder + if (var_type === VarType.arrayObject) return arrayObjectPlaceholder + if (var_type === VarType.arrayBoolean) return arrayBoolPlaceholder return objectPlaceholder }, [var_type]) return ( <div> - { - value_type === ValueType.variable && ( - <VarReferencePicker - readonly={false} - nodeId={nodeId} - isShowNodeName - value={normalizedVarValue} - onChange={handleChange} - filterVar={filterVar} - placeholder={t($ => $['nodes.assigner.setParameter'], { ns: 'workflow' }) as string} - /> - ) - } - { - value_type === ValueType.constant && var_type === VarType.string && ( - <Textarea - aria-label={item.label} - value={value} - onValueChange={handleValueChange} - className="min-h-12 w-full" - /> - ) - } - { - value_type === ValueType.constant && var_type === VarType.number && ( - <Input - type="number" - value={value} - onChange={handleInputChange} - className="w-full" - /> - ) - } - { - value_type === ValueType.constant && var_type === VarType.boolean && ( - <BoolValue - value={value} - onChange={handleChange} - /> - ) - } - { - value_type === ValueType.constant - && (var_type === VarType.object || var_type === VarType.arrayString || var_type === VarType.arrayNumber || var_type === VarType.arrayObject) - && ( - <div className="w-full rounded-[10px] bg-components-input-bg-normal py-2 pr-1 pl-3" style={{ height: editorMinHeight }}> + {value_type === ValueType.variable && ( + <VarReferencePicker + readonly={false} + nodeId={nodeId} + isShowNodeName + value={normalizedVarValue} + onChange={handleChange} + filterVar={filterVar} + placeholder={t(($) => $['nodes.assigner.setParameter'], { ns: 'workflow' }) as string} + /> + )} + {value_type === ValueType.constant && var_type === VarType.string && ( + <Textarea + aria-label={item.label} + value={value} + onValueChange={handleValueChange} + className="min-h-12 w-full" + /> + )} + {value_type === ValueType.constant && var_type === VarType.number && ( + <Input type="number" value={value} onChange={handleInputChange} className="w-full" /> + )} + {value_type === ValueType.constant && var_type === VarType.boolean && ( + <BoolValue value={value} onChange={handleChange} /> + )} + {value_type === ValueType.constant && + (var_type === VarType.object || + var_type === VarType.arrayString || + var_type === VarType.arrayNumber || + var_type === VarType.arrayObject) && ( + <div + className="w-full rounded-[10px] bg-components-input-bg-normal py-2 pr-1 pl-3" + style={{ height: editorMinHeight }} + > <CodeEditor value={value} isExpand @@ -136,17 +116,10 @@ const FormItem = ({ placeholder={<div className="whitespace-pre">{placeholder}</div>} /> </div> - ) - } - { - value_type === ValueType.constant && var_type === VarType.arrayBoolean && ( - <ArrayBoolList - className="mt-2" - list={value || [false]} - onChange={handleChange} - /> - ) - } + )} + {value_type === ValueType.constant && var_type === VarType.arrayBoolean && ( + <ArrayBoolList className="mt-2" list={value || [false]} onChange={handleChange} /> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/loop/components/loop-variables/index.tsx b/web/app/components/workflow/nodes/loop/components/loop-variables/index.tsx index ddd34baeaeef66..23a3527b130121 100644 --- a/web/app/components/workflow/nodes/loop/components/loop-variables/index.tsx +++ b/web/app/components/workflow/nodes/loop/components/loop-variables/index.tsx @@ -9,20 +9,10 @@ type LoopVariableProps = { variables?: LoopVariable[] } & LoopVariablesComponentShape -const LoopVariableComponent = ({ - variables = [], - ...restProps -}: LoopVariableProps) => { - if (!variables.length) - return <Empty /> +const LoopVariableComponent = ({ variables = [], ...restProps }: LoopVariableProps) => { + if (!variables.length) return <Empty /> - return variables.map(variable => ( - <Item - key={variable.id} - item={variable} - {...restProps} - /> - )) + return variables.map((variable) => <Item key={variable.id} item={variable} {...restProps} />) } export default LoopVariableComponent diff --git a/web/app/components/workflow/nodes/loop/components/loop-variables/input-mode-selec.tsx b/web/app/components/workflow/nodes/loop/components/loop-variables/input-mode-selec.tsx index c6acc9455a06bf..180947e3443dbf 100644 --- a/web/app/components/workflow/nodes/loop/components/loop-variables/input-mode-selec.tsx +++ b/web/app/components/workflow/nodes/loop/components/loop-variables/input-mode-selec.tsx @@ -1,14 +1,18 @@ -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { useTranslation } from 'react-i18next' type InputModeSelectProps = { value?: string onChange: (value: string) => void } -const InputModeSelect = ({ - value, - onChange, -}: InputModeSelectProps) => { +const InputModeSelect = ({ value, onChange }: InputModeSelectProps) => { const { t } = useTranslation() const options = [ { @@ -20,15 +24,18 @@ const InputModeSelect = ({ value: 'constant', }, ] - const selectedOption = options.find(option => option.value === value) ?? null + const selectedOption = options.find((option) => option.value === value) ?? null return ( - <Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && onChange(nextValue)}> + <Select + value={selectedOption?.value ?? null} + onValueChange={(nextValue) => nextValue && onChange(nextValue)} + > <SelectTrigger className="w-full"> - {selectedOption?.label ?? t($ => $['nodes.loop.inputMode'], { ns: 'workflow' })} + {selectedOption?.label ?? t(($) => $['nodes.loop.inputMode'], { ns: 'workflow' })} </SelectTrigger> <SelectContent> - {options.map(option => ( + {options.map((option) => ( <SelectItem key={option.value} value={option.value}> <SelectItemText>{option.label}</SelectItemText> <SelectItemIndicator /> diff --git a/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx b/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx index 48938d52bdacff..3d0cc6d79a8c03 100644 --- a/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx +++ b/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx @@ -17,32 +17,33 @@ import VariableTypeSelect from './variable-type-select' type ItemProps = { item: LoopVariable } & LoopVariablesComponentShape -const Item = ({ - nodeId, - item, - handleRemoveLoopVariable, - handleUpdateLoopVariable, -}: ItemProps) => { +const Item = ({ nodeId, item, handleRemoveLoopVariable, handleUpdateLoopVariable }: ItemProps) => { const { t } = useTranslation() const checkVariableName = (value: string) => { const { isValid, errorMessageKey } = checkKeys([value], false) if (!isValid) { - toast.error(t($ => $[`varKeyError.${errorMessageKey}`], { ns: 'appDebug', key: t($ => $['env.modal.name'], { ns: 'workflow' }) })) + toast.error( + t(($) => $[`varKeyError.${errorMessageKey}`], { + ns: 'appDebug', + key: t(($) => $['env.modal.name'], { ns: 'workflow' }), + }), + ) return false } return true } - const handleUpdateItemLabel = useCallback((e: any) => { - replaceSpaceWithUnderscoreInVarNameInput(e.target) - if (!!e.target.value && !checkVariableName(e.target.value)) - return - handleUpdateLoopVariable(item.id, { label: e.target.value }) - }, [item.id, handleUpdateLoopVariable]) + const handleUpdateItemLabel = useCallback( + (e: any) => { + replaceSpaceWithUnderscoreInVarNameInput(e.target) + if (!!e.target.value && !checkVariableName(e.target.value)) return + handleUpdateLoopVariable(item.id, { label: e.target.value }) + }, + [item.id, handleUpdateLoopVariable], + ) const getDefaultValue = useCallback((varType: VarType, valueType: ValueType) => { - if (valueType === ValueType.variable) - return undefined + if (valueType === ValueType.variable) return undefined switch (varType) { case VarType.boolean: return false @@ -53,17 +54,32 @@ const Item = ({ } }, []) - const handleUpdateItemVarType = useCallback((value: any) => { - handleUpdateLoopVariable(item.id, { var_type: value, value: getDefaultValue(value, item.value_type) }) - }, [item.id, handleUpdateLoopVariable]) + const handleUpdateItemVarType = useCallback( + (value: any) => { + handleUpdateLoopVariable(item.id, { + var_type: value, + value: getDefaultValue(value, item.value_type), + }) + }, + [item.id, handleUpdateLoopVariable], + ) - const handleUpdateItemValueType = useCallback((value: any) => { - handleUpdateLoopVariable(item.id, { value_type: value, value: getDefaultValue(item.var_type, value) }) - }, [item.id, handleUpdateLoopVariable]) + const handleUpdateItemValueType = useCallback( + (value: any) => { + handleUpdateLoopVariable(item.id, { + value_type: value, + value: getDefaultValue(item.var_type, value), + }) + }, + [item.id, handleUpdateLoopVariable], + ) - const handleUpdateItemValue = useCallback((value: any) => { - handleUpdateLoopVariable(item.id, { value }) - }, [item.id, handleUpdateLoopVariable]) + const handleUpdateItemValue = useCallback( + (value: any) => { + handleUpdateLoopVariable(item.id, { value }) + }, + [item.id, handleUpdateLoopVariable], + ) return ( <div className="mb-4 flex last-of-type:mb-0"> @@ -72,32 +88,18 @@ const Item = ({ <Input value={item.label} onChange={handleUpdateItemLabel} - onBlur={e => checkVariableName(e.target.value)} + onBlur={(e) => checkVariableName(e.target.value)} autoFocus={!item.label} - placeholder={t($ => $['nodes.loop.variableName'], { ns: 'workflow' })} - /> - <VariableTypeSelect - value={item.var_type} - onChange={handleUpdateItemVarType} - /> - <InputModeSelect - value={item.value_type} - onChange={handleUpdateItemValueType} + placeholder={t(($) => $['nodes.loop.variableName'], { ns: 'workflow' })} /> + <VariableTypeSelect value={item.var_type} onChange={handleUpdateItemVarType} /> + <InputModeSelect value={item.value_type} onChange={handleUpdateItemValueType} /> </div> <div> - <FormItem - nodeId={nodeId} - item={item} - onChange={handleUpdateItemValue} - /> + <FormItem nodeId={nodeId} item={item} onChange={handleUpdateItemValue} /> </div> </div> - <ActionButton - className="shrink-0" - size="l" - onClick={() => handleRemoveLoopVariable(item.id)} - > + <ActionButton className="shrink-0" size="l" onClick={() => handleRemoveLoopVariable(item.id)}> <RiDeleteBinLine className="size-4 text-text-tertiary" /> </ActionButton> </div> diff --git a/web/app/components/workflow/nodes/loop/components/loop-variables/variable-type-select.tsx b/web/app/components/workflow/nodes/loop/components/loop-variables/variable-type-select.tsx index 9db5a21c19bad4..bac8a337ec88f3 100644 --- a/web/app/components/workflow/nodes/loop/components/loop-variables/variable-type-select.tsx +++ b/web/app/components/workflow/nodes/loop/components/loop-variables/variable-type-select.tsx @@ -1,14 +1,18 @@ -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { VarType } from '@/app/components/workflow/types' type VariableTypeSelectProps = { value?: string onChange: (value: string) => void } -const VariableTypeSelect = ({ - value, - onChange, -}: VariableTypeSelectProps) => { +const VariableTypeSelect = ({ value, onChange }: VariableTypeSelectProps) => { const options = [ { label: 'String', @@ -43,15 +47,16 @@ const VariableTypeSelect = ({ value: VarType.arrayBoolean, }, ] - const selectedOption = options.find(option => option.value === value) ?? null + const selectedOption = options.find((option) => option.value === value) ?? null return ( - <Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && onChange(nextValue)}> - <SelectTrigger className="w-full"> - {selectedOption?.label} - </SelectTrigger> + <Select + value={selectedOption?.value ?? null} + onValueChange={(nextValue) => nextValue && onChange(nextValue)} + > + <SelectTrigger className="w-full">{selectedOption?.label}</SelectTrigger> <SelectContent> - {options.map(option => ( + {options.map((option) => ( <SelectItem key={option.value} value={option.value}> <SelectItemText>{option.label}</SelectItemText> <SelectItemIndicator /> diff --git a/web/app/components/workflow/nodes/loop/default.ts b/web/app/components/workflow/nodes/loop/default.ts index e0f3ccbd859e08..c993db657b5f30 100644 --- a/web/app/components/workflow/nodes/loop/default.ts +++ b/web/app/components/workflow/nodes/loop/default.ts @@ -34,44 +34,72 @@ const nodeDefault: NodeDefault<LoopNodeType> = { payload.loop_variables?.forEach((variable) => { if (!variable.label) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variable`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variable`], { ns: 'workflow' }), + }) }) payload.break_conditions!.forEach((condition) => { - if (!errorMessages && (!condition.variable_selector || condition.variable_selector.length === 0)) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variable`], { ns: 'workflow' }) }) + if ( + !errorMessages && + (!condition.variable_selector || condition.variable_selector.length === 0) + ) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variable`], { ns: 'workflow' }), + }) if (!errorMessages && !condition.comparison_operator) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.ifElse.operator'], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.ifElse.operator'], { ns: 'workflow' }), + }) if (!errorMessages) { - if (condition.sub_variable_condition - && ![ComparisonOperator.empty, ComparisonOperator.notEmpty].includes(condition.comparison_operator!)) { + if ( + condition.sub_variable_condition && + ![ComparisonOperator.empty, ComparisonOperator.notEmpty].includes( + condition.comparison_operator!, + ) + ) { const isSet = condition.sub_variable_condition.conditions.every((c) => { - if (!c.comparison_operator) - return false + if (!c.comparison_operator) return false - if (isEmptyRelatedOperator(c.comparison_operator!)) - return true + if (isEmptyRelatedOperator(c.comparison_operator!)) return true return !!c.value }) if (!isSet) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }) }) - } - else { - if (!isEmptyRelatedOperator(condition.comparison_operator!) && (condition.varType === VarType.boolean ? condition.value === undefined : !condition.value)) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }), + }) + } else { + if ( + !isEmptyRelatedOperator(condition.comparison_operator!) && + (condition.varType === VarType.boolean + ? condition.value === undefined + : !condition.value) + ) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }), + }) } } }) - if (!errorMessages && ( - Number.isNaN(Number(payload.loop_count)) - || !Number.isInteger(Number(payload.loop_count)) - || payload.loop_count < 1 - || payload.loop_count > LOOP_NODE_MAX_COUNT - )) { - errorMessages = t($ => $['nodes.loop.loopMaxCountError'], { ns: 'workflow', maxCount: LOOP_NODE_MAX_COUNT }) + if ( + !errorMessages && + (Number.isNaN(Number(payload.loop_count)) || + !Number.isInteger(Number(payload.loop_count)) || + payload.loop_count < 1 || + payload.loop_count > LOOP_NODE_MAX_COUNT) + ) { + errorMessages = t(($) => $['nodes.loop.loopMaxCountError'], { + ns: 'workflow', + maxCount: LOOP_NODE_MAX_COUNT, + }) } return { @@ -98,7 +126,16 @@ export const TRANSFER_METHOD = [ { value: TransferMethod.remote_url, i18nKey: 'url' }, ] as const satisfies readonly OptionItem[] -export const SUB_VARIABLES = ['type', 'size', 'name', 'url', 'extension', 'mime_type', 'transfer_method', 'related_id'] -export const OUTPUT_FILE_SUB_VARIABLES = SUB_VARIABLES.filter(key => key !== 'transfer_method') +export const SUB_VARIABLES = [ + 'type', + 'size', + 'name', + 'url', + 'extension', + 'mime_type', + 'transfer_method', + 'related_id', +] +export const OUTPUT_FILE_SUB_VARIABLES = SUB_VARIABLES.filter((key) => key !== 'transfer_method') export default nodeDefault diff --git a/web/app/components/workflow/nodes/loop/node.tsx b/web/app/components/workflow/nodes/loop/node.tsx index ab47c11892c20f..5a19ed9ec6aedb 100644 --- a/web/app/components/workflow/nodes/loop/node.tsx +++ b/web/app/components/workflow/nodes/loop/node.tsx @@ -2,37 +2,26 @@ import type { FC } from 'react' import type { LoopNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useEffect, -} from 'react' -import { - Background, - useNodesInitialized, - useViewport, -} from 'reactflow' +import { memo, useEffect } from 'react' +import { Background, useNodesInitialized, useViewport } from 'reactflow' import { LoopStartNodeDumb } from '../loop-start' import AddBlock from './add-block' - import { useNodeLoopInteractions } from './use-interactions' -const Node: FC<NodeProps<LoopNodeType>> = ({ - id, - data, -}) => { +const Node: FC<NodeProps<LoopNodeType>> = ({ id, data }) => { const { zoom } = useViewport() const nodesInitialized = useNodesInitialized() const { handleNodeLoopRerender } = useNodeLoopInteractions() useEffect(() => { - if (nodesInitialized) - handleNodeLoopRerender(id) + if (nodesInitialized) handleNodeLoopRerender(id) }, [nodesInitialized, id, handleNodeLoopRerender]) return ( - <div className={cn( - 'relative h-full min-h-[90px] w-full min-w-[240px] rounded-2xl bg-workflow-canvas-workflow-bg', - )} + <div + className={cn( + 'relative h-full min-h-[90px] w-full min-w-[240px] rounded-2xl bg-workflow-canvas-workflow-bg', + )} > <Background id={`loop-background-${id}`} @@ -41,20 +30,8 @@ const Node: FC<NodeProps<LoopNodeType>> = ({ size={2 / zoom} color="var(--color-workflow-canvas-workflow-dot-color)" /> - { - data._isCandidate && ( - <LoopStartNodeDumb /> - ) - } - { - data._children?.length === 1 && ( - <AddBlock - loopNodeId={id} - loopNodeData={data} - /> - ) - } - + {data._isCandidate && <LoopStartNodeDumb />} + {data._children?.length === 1 && <AddBlock loopNodeId={id} loopNodeData={data} />} </div> ) } diff --git a/web/app/components/workflow/nodes/loop/panel.tsx b/web/app/components/workflow/nodes/loop/panel.tsx index 4188e65fdecda0..7fb2e95468c360 100644 --- a/web/app/components/workflow/nodes/loop/panel.tsx +++ b/web/app/components/workflow/nodes/loop/panel.tsx @@ -10,15 +10,11 @@ import InputNumberWithSlider from '../_base/components/input-number-with-slider' import Split from '../_base/components/split' import ConditionWrap from './components/condition-wrap' import LoopVariable from './components/loop-variables' - import useConfig from './use-config' const i18nPrefix = 'nodes.loop' -const Panel: FC<NodePanelProps<LoopNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<LoopNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { @@ -44,15 +40,19 @@ const Panel: FC<NodePanelProps<LoopNodeType>> = ({ <div className="mt-2"> <div> <Field - title={<div className="pl-3">{t($ => $['nodes.loop.loopVariables'], { ns: 'workflow' })}</div>} - operations={( + title={ + <div className="pl-3"> + {t(($) => $['nodes.loop.loopVariables'], { ns: 'workflow' })} + </div> + } + operations={ <div className="mr-4 flex size-5 cursor-pointer items-center justify-center" onClick={handleAddLoopVariable} > <RiAddLine className="size-4 text-text-tertiary" /> </div> - )} + } > <div className="px-4"> <LoopVariable @@ -65,8 +65,12 @@ const Panel: FC<NodePanelProps<LoopNodeType>> = ({ </Field> <Split className="my-2" /> <Field - title={<div className="pl-3">{t($ => $[`${i18nPrefix}.breakCondition`], { ns: 'workflow' })}</div>} - tooltip={t($ => $[`${i18nPrefix}.breakConditionTip`], { ns: 'workflow' })} + title={ + <div className="pl-3"> + {t(($) => $[`${i18nPrefix}.breakCondition`], { ns: 'workflow' })} + </div> + } + tooltip={t(($) => $[`${i18nPrefix}.breakConditionTip`], { ns: 'workflow' })} > <ConditionWrap nodeId={id} @@ -78,7 +82,9 @@ const Panel: FC<NodePanelProps<LoopNodeType>> = ({ handleAddSubVariableCondition={handleAddSubVariableCondition} handleRemoveSubVariableCondition={handleRemoveSubVariableCondition} handleUpdateSubVariableCondition={handleUpdateSubVariableCondition} - handleToggleSubVariableConditionLogicalOperator={handleToggleSubVariableConditionLogicalOperator} + handleToggleSubVariableConditionLogicalOperator={ + handleToggleSubVariableConditionLogicalOperator + } availableNodes={loopChildrenNodes} availableVars={childrenNodeVars} conditions={inputs.break_conditions || []} @@ -88,11 +94,15 @@ const Panel: FC<NodePanelProps<LoopNodeType>> = ({ <Split className="mt-2" /> <div className="mt-2"> <Field - title={<div className="pl-3">{t($ => $[`${i18nPrefix}.loopMaxCount`], { ns: 'workflow' })}</div>} + title={ + <div className="pl-3"> + {t(($) => $[`${i18nPrefix}.loopMaxCount`], { ns: 'workflow' })} + </div> + } > <div className="px-3 py-2"> <InputNumberWithSlider - label={t($ => $[`${i18nPrefix}.loopMaxCount`], { ns: 'workflow' })} + label={t(($) => $[`${i18nPrefix}.loopMaxCount`], { ns: 'workflow' })} min={1} max={LOOP_NODE_MAX_COUNT} value={inputs.loop_count} diff --git a/web/app/components/workflow/nodes/loop/types.ts b/web/app/components/workflow/nodes/loop/types.ts index 066f0fcbe30516..191bad91415a77 100644 --- a/web/app/components/workflow/nodes/loop/types.ts +++ b/web/app/components/workflow/nodes/loop/types.ts @@ -62,7 +62,11 @@ export type HandleToggleConditionLogicalOperator = () => void export type HandleAddSubVariableCondition = (conditionId: string, key?: string) => void export type handleRemoveSubVariableCondition = (conditionId: string, subConditionId: string) => void -export type HandleUpdateSubVariableCondition = (conditionId: string, subConditionId: string, newSubCondition: Condition) => void +export type HandleUpdateSubVariableCondition = ( + conditionId: string, + subConditionId: string, + newSubCondition: Condition, +) => void export type HandleToggleSubVariableConditionLogicalOperator = (conditionId: string) => void export type LoopVariable = { diff --git a/web/app/components/workflow/nodes/loop/use-config.helpers.ts b/web/app/components/workflow/nodes/loop/use-config.helpers.ts index dadd0aa639529d..2692f3fec71863 100644 --- a/web/app/components/workflow/nodes/loop/use-config.helpers.ts +++ b/web/app/components/workflow/nodes/loop/use-config.helpers.ts @@ -16,12 +16,10 @@ export const canUseAsLoopInput = (variable: Var) => { ].includes(variable.type) } -export const updateErrorHandleMode = ( - inputs: LoopNodeType, - mode: ErrorHandleMode, -) => produce(inputs, (draft) => { - draft.error_handle_mode = mode -}) +export const updateErrorHandleMode = (inputs: LoopNodeType, mode: ErrorHandleMode) => + produce(inputs, (draft) => { + draft.error_handle_mode = mode + }) export const addBreakCondition = ({ inputs, @@ -33,139 +31,137 @@ export const addBreakCondition = ({ valueSelector: string[] variable: { type: VarType } isVarFileAttribute: boolean -}) => produce(inputs, (draft) => { - if (!draft.break_conditions) - draft.break_conditions = [] - - draft.break_conditions.push({ - id: uuid4(), - varType: variable.type, - variable_selector: valueSelector, - comparison_operator: getOperators(variable.type, isVarFileAttribute ? { key: valueSelector.slice(-1)[0]! } : undefined)[0], - value: variable.type === VarType.boolean ? 'false' : '', +}) => + produce(inputs, (draft) => { + if (!draft.break_conditions) draft.break_conditions = [] + + draft.break_conditions.push({ + id: uuid4(), + varType: variable.type, + variable_selector: valueSelector, + comparison_operator: getOperators( + variable.type, + isVarFileAttribute ? { key: valueSelector.slice(-1)[0]! } : undefined, + )[0], + value: variable.type === VarType.boolean ? 'false' : '', + }) }) -}) -export const removeBreakCondition = ( - inputs: LoopNodeType, - conditionId: string, -) => produce(inputs, (draft) => { - draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId) -}) +export const removeBreakCondition = (inputs: LoopNodeType, conditionId: string) => + produce(inputs, (draft) => { + draft.break_conditions = draft.break_conditions?.filter((item) => item.id !== conditionId) + }) export const updateBreakCondition = ( inputs: LoopNodeType, conditionId: string, condition: Condition, -) => produce(inputs, (draft) => { - const targetCondition = draft.break_conditions?.find(item => item.id === conditionId) - if (targetCondition) - Object.assign(targetCondition, condition) -}) +) => + produce(inputs, (draft) => { + const targetCondition = draft.break_conditions?.find((item) => item.id === conditionId) + if (targetCondition) Object.assign(targetCondition, condition) + }) -export const toggleConditionOperator = (inputs: LoopNodeType) => produce(inputs, (draft) => { - draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and -}) +export const toggleConditionOperator = (inputs: LoopNodeType) => + produce(inputs, (draft) => { + draft.logical_operator = + draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and + }) -export const addSubVariableCondition = ( - inputs: LoopNodeType, - conditionId: string, - key?: string, -) => produce(inputs, (draft) => { - const condition = draft.break_conditions?.find(item => item.id === conditionId) - if (!condition) - return - - if (!condition.sub_variable_condition) { - condition.sub_variable_condition = { - logical_operator: LogicalOperator.and, - conditions: [], +export const addSubVariableCondition = (inputs: LoopNodeType, conditionId: string, key?: string) => + produce(inputs, (draft) => { + const condition = draft.break_conditions?.find((item) => item.id === conditionId) + if (!condition) return + + if (!condition.sub_variable_condition) { + condition.sub_variable_condition = { + logical_operator: LogicalOperator.and, + conditions: [], + } } - } - - const comparisonOperators = getOperators(VarType.string, { key: key || '' }) - condition.sub_variable_condition.conditions.push({ - id: uuid4(), - key: key || '', - varType: VarType.string, - comparison_operator: comparisonOperators[0], - value: '', + + const comparisonOperators = getOperators(VarType.string, { key: key || '' }) + condition.sub_variable_condition.conditions.push({ + id: uuid4(), + key: key || '', + varType: VarType.string, + comparison_operator: comparisonOperators[0], + value: '', + }) }) -}) export const removeSubVariableCondition = ( inputs: LoopNodeType, conditionId: string, subConditionId: string, -) => produce(inputs, (draft) => { - const condition = draft.break_conditions?.find(item => item.id === conditionId) - if (!condition?.sub_variable_condition) - return +) => + produce(inputs, (draft) => { + const condition = draft.break_conditions?.find((item) => item.id === conditionId) + if (!condition?.sub_variable_condition) return - condition.sub_variable_condition.conditions = condition.sub_variable_condition.conditions - .filter(item => item.id !== subConditionId) -}) + condition.sub_variable_condition.conditions = + condition.sub_variable_condition.conditions.filter((item) => item.id !== subConditionId) + }) export const updateSubVariableCondition = ( inputs: LoopNodeType, conditionId: string, subConditionId: string, condition: Condition, -) => produce(inputs, (draft) => { - const targetCondition = draft.break_conditions?.find(item => item.id === conditionId) - const targetSubCondition = targetCondition?.sub_variable_condition?.conditions.find(item => item.id === subConditionId) - if (targetSubCondition) - Object.assign(targetSubCondition, condition) -}) - -export const toggleSubVariableConditionOperator = ( - inputs: LoopNodeType, - conditionId: string, -) => produce(inputs, (draft) => { - const targetCondition = draft.break_conditions?.find(item => item.id === conditionId) - if (targetCondition?.sub_variable_condition) { - targetCondition.sub_variable_condition.logical_operator - = targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and - } -}) - -export const updateLoopCount = ( - inputs: LoopNodeType, - value: number, -) => produce(inputs, (draft) => { - draft.loop_count = value -}) - -export const addLoopVariable = (inputs: LoopNodeType) => produce(inputs, (draft) => { - if (!draft.loop_variables) - draft.loop_variables = [] - - draft.loop_variables.push({ - id: uuid4(), - label: '', - var_type: VarType.string, - value_type: ValueType.constant, - value: '', +) => + produce(inputs, (draft) => { + const targetCondition = draft.break_conditions?.find((item) => item.id === conditionId) + const targetSubCondition = targetCondition?.sub_variable_condition?.conditions.find( + (item) => item.id === subConditionId, + ) + if (targetSubCondition) Object.assign(targetSubCondition, condition) }) -}) -export const removeLoopVariable = ( - inputs: LoopNodeType, - id: string, -) => produce(inputs, (draft) => { - draft.loop_variables = draft.loop_variables?.filter(item => item.id !== id) -}) +export const toggleSubVariableConditionOperator = (inputs: LoopNodeType, conditionId: string) => + produce(inputs, (draft) => { + const targetCondition = draft.break_conditions?.find((item) => item.id === conditionId) + if (targetCondition?.sub_variable_condition) { + targetCondition.sub_variable_condition.logical_operator = + targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and + ? LogicalOperator.or + : LogicalOperator.and + } + }) + +export const updateLoopCount = (inputs: LoopNodeType, value: number) => + produce(inputs, (draft) => { + draft.loop_count = value + }) + +export const addLoopVariable = (inputs: LoopNodeType) => + produce(inputs, (draft) => { + if (!draft.loop_variables) draft.loop_variables = [] + + draft.loop_variables.push({ + id: uuid4(), + label: '', + var_type: VarType.string, + value_type: ValueType.constant, + value: '', + }) + }) + +export const removeLoopVariable = (inputs: LoopNodeType, id: string) => + produce(inputs, (draft) => { + draft.loop_variables = draft.loop_variables?.filter((item) => item.id !== id) + }) export const updateLoopVariable = ( inputs: LoopNodeType, id: string, updateData: Partial<LoopVariable>, -) => produce(inputs, (draft) => { - const index = draft.loop_variables?.findIndex(item => item.id === id) ?? -1 - if (index > -1) { - draft.loop_variables![index] = { - ...draft.loop_variables![index]!, - ...updateData, +) => + produce(inputs, (draft) => { + const index = draft.loop_variables?.findIndex((item) => item.id === id) ?? -1 + if (index > -1) { + draft.loop_variables![index] = { + ...draft.loop_variables![index]!, + ...updateData, + } } - } -}) + }) diff --git a/web/app/components/workflow/nodes/loop/use-config.ts b/web/app/components/workflow/nodes/loop/use-config.ts index b6e4bc235ad9d6..b0f76f8232e904 100644 --- a/web/app/components/workflow/nodes/loop/use-config.ts +++ b/web/app/components/workflow/nodes/loop/use-config.ts @@ -9,11 +9,7 @@ import type { HandleUpdateSubVariableCondition, LoopNodeType, } from './types' -import { - useCallback, - useEffect, - useRef, -} from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useStore } from '@/app/components/workflow/store' import { useAllBuiltInTools, @@ -21,11 +17,7 @@ import { useAllMCPTools, useAllWorkflowTools, } from '@/service/use-tools' -import { - useIsChatMode, - useNodesReadOnly, - useWorkflow, -} from '../../hooks' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks' import { toNodeOutputVars } from '../_base/components/variable/utils' import useNodeCrud from '../_base/hooks/use-node-crud' import { @@ -49,17 +41,20 @@ import useIsVarFileAttribute from './use-is-var-file-attribute' const useConfig = (id: string, payload: LoopNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() const isChatMode = useIsChatMode() - const conversationVariables = useStore(s => s.conversationVariables) + const conversationVariables = useStore((s) => s.conversationVariables) const { inputs, setInputs } = useNodeCrud<LoopNodeType>(id, payload) const inputsRef = useRef(inputs) useEffect(() => { inputsRef.current = inputs }, [inputs]) - const handleInputsChange = useCallback((newInputs: LoopNodeType) => { - inputsRef.current = newInputs - setInputs(newInputs) - }, [setInputs]) + const handleInputsChange = useCallback( + (newInputs: LoopNodeType) => { + inputsRef.current = newInputs + setInputs(newInputs) + }, + [setInputs], + ) const filterInputVar = useCallback((varPayload: Var) => canUseAsLoopInput(varPayload), []) @@ -70,7 +65,7 @@ const useConfig = (id: string, payload: LoopNodeType) => { const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() - const dataSourceList = useStore(s => s.dataSourceList) + const dataSourceList = useStore((s) => s.dataSourceList) const allPluginInfoList = { buildInTools: buildInTools || [], customTools: customTools || [], @@ -78,70 +73,115 @@ const useConfig = (id: string, payload: LoopNodeType) => { mcpTools: mcpTools || [], dataSourceList: dataSourceList || [], } - const childrenNodeVars = toNodeOutputVars(loopChildrenNodes, isChatMode, undefined, [], conversationVariables, [], allPluginInfoList) - - const { - getIsVarFileAttribute, - } = useIsVarFileAttribute({ + const childrenNodeVars = toNodeOutputVars( + loopChildrenNodes, + isChatMode, + undefined, + [], + conversationVariables, + [], + allPluginInfoList, + ) + + const { getIsVarFileAttribute } = useIsVarFileAttribute({ nodeId: id, }) - const changeErrorResponseMode = useCallback((item: { value: unknown }) => { - handleInputsChange(updateErrorHandleMode(inputsRef.current, item.value as ErrorHandleMode)) - }, [handleInputsChange]) - - const handleAddCondition = useCallback<HandleAddCondition>((valueSelector, varItem) => { - handleInputsChange(addBreakCondition({ - inputs: inputsRef.current, - valueSelector, - variable: varItem, - isVarFileAttribute: !!getIsVarFileAttribute(valueSelector), - })) - }, [getIsVarFileAttribute, handleInputsChange]) - - const handleRemoveCondition = useCallback<HandleRemoveCondition>((conditionId) => { - handleInputsChange(removeBreakCondition(inputsRef.current, conditionId)) - }, [handleInputsChange]) - - const handleUpdateCondition = useCallback<HandleUpdateCondition>((conditionId, newCondition) => { - handleInputsChange(updateBreakCondition(inputsRef.current, conditionId, newCondition)) - }, [handleInputsChange]) - - const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>(() => { - handleInputsChange(toggleConditionOperator(inputsRef.current)) - }, [handleInputsChange]) - - const handleAddSubVariableCondition = useCallback<HandleAddSubVariableCondition>((conditionId: string, key?: string) => { - handleInputsChange(addSubVariableCondition(inputsRef.current, conditionId, key)) - }, [handleInputsChange]) - - const handleRemoveSubVariableCondition = useCallback((conditionId: string, subConditionId: string) => { - handleInputsChange(removeSubVariableCondition(inputsRef.current, conditionId, subConditionId)) - }, [handleInputsChange]) - - const handleUpdateSubVariableCondition = useCallback<HandleUpdateSubVariableCondition>((conditionId, subConditionId, newSubCondition) => { - handleInputsChange(updateSubVariableCondition(inputsRef.current, conditionId, subConditionId, newSubCondition)) - }, [handleInputsChange]) - - const handleToggleSubVariableConditionLogicalOperator = useCallback<HandleToggleSubVariableConditionLogicalOperator>((conditionId) => { - handleInputsChange(toggleSubVariableConditionOperator(inputsRef.current, conditionId)) - }, [handleInputsChange]) - - const handleUpdateLoopCount = useCallback((value: number) => { - handleInputsChange(updateLoopCount(inputsRef.current, value)) - }, [handleInputsChange]) + const changeErrorResponseMode = useCallback( + (item: { value: unknown }) => { + handleInputsChange(updateErrorHandleMode(inputsRef.current, item.value as ErrorHandleMode)) + }, + [handleInputsChange], + ) + + const handleAddCondition = useCallback<HandleAddCondition>( + (valueSelector, varItem) => { + handleInputsChange( + addBreakCondition({ + inputs: inputsRef.current, + valueSelector, + variable: varItem, + isVarFileAttribute: !!getIsVarFileAttribute(valueSelector), + }), + ) + }, + [getIsVarFileAttribute, handleInputsChange], + ) + + const handleRemoveCondition = useCallback<HandleRemoveCondition>( + (conditionId) => { + handleInputsChange(removeBreakCondition(inputsRef.current, conditionId)) + }, + [handleInputsChange], + ) + + const handleUpdateCondition = useCallback<HandleUpdateCondition>( + (conditionId, newCondition) => { + handleInputsChange(updateBreakCondition(inputsRef.current, conditionId, newCondition)) + }, + [handleInputsChange], + ) + + const handleToggleConditionLogicalOperator = + useCallback<HandleToggleConditionLogicalOperator>(() => { + handleInputsChange(toggleConditionOperator(inputsRef.current)) + }, [handleInputsChange]) + + const handleAddSubVariableCondition = useCallback<HandleAddSubVariableCondition>( + (conditionId: string, key?: string) => { + handleInputsChange(addSubVariableCondition(inputsRef.current, conditionId, key)) + }, + [handleInputsChange], + ) + + const handleRemoveSubVariableCondition = useCallback( + (conditionId: string, subConditionId: string) => { + handleInputsChange(removeSubVariableCondition(inputsRef.current, conditionId, subConditionId)) + }, + [handleInputsChange], + ) + + const handleUpdateSubVariableCondition = useCallback<HandleUpdateSubVariableCondition>( + (conditionId, subConditionId, newSubCondition) => { + handleInputsChange( + updateSubVariableCondition(inputsRef.current, conditionId, subConditionId, newSubCondition), + ) + }, + [handleInputsChange], + ) + + const handleToggleSubVariableConditionLogicalOperator = + useCallback<HandleToggleSubVariableConditionLogicalOperator>( + (conditionId) => { + handleInputsChange(toggleSubVariableConditionOperator(inputsRef.current, conditionId)) + }, + [handleInputsChange], + ) + + const handleUpdateLoopCount = useCallback( + (value: number) => { + handleInputsChange(updateLoopCount(inputsRef.current, value)) + }, + [handleInputsChange], + ) const handleAddLoopVariable = useCallback(() => { handleInputsChange(addLoopVariable(inputsRef.current)) }, [handleInputsChange]) - const handleRemoveLoopVariable = useCallback((id: string) => { - handleInputsChange(removeLoopVariable(inputsRef.current, id)) - }, [handleInputsChange]) - - const handleUpdateLoopVariable = useCallback((id: string, updateData: any) => { - handleInputsChange(updateLoopVariable(inputsRef.current, id, updateData)) - }, [handleInputsChange]) + const handleRemoveLoopVariable = useCallback( + (id: string) => { + handleInputsChange(removeLoopVariable(inputsRef.current, id)) + }, + [handleInputsChange], + ) + + const handleUpdateLoopVariable = useCallback( + (id: string, updateData: any) => { + handleInputsChange(updateLoopVariable(inputsRef.current, id, updateData)) + }, + [handleInputsChange], + ) return { readOnly, diff --git a/web/app/components/workflow/nodes/loop/use-interactions.helpers.ts b/web/app/components/workflow/nodes/loop/use-interactions.helpers.ts index 380b9754638def..b5afbb64660dc4 100644 --- a/web/app/components/workflow/nodes/loop/use-interactions.helpers.ts +++ b/web/app/components/workflow/nodes/loop/use-interactions.helpers.ts @@ -1,11 +1,5 @@ -import type { - BlockEnum, - Node, -} from '../../types' -import { - LOOP_PADDING, - NESTED_ELEMENT_Z_INDEX, -} from '../../constants' +import type { BlockEnum, Node } from '../../types' +import { LOOP_PADDING, NESTED_ELEMENT_Z_INDEX } from '../../constants' import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants' type ContainerBounds = { @@ -15,12 +9,16 @@ type ContainerBounds = { export const getContainerBounds = (childrenNodes: Node[]): ContainerBounds => { return childrenNodes.reduce<ContainerBounds>((acc, node) => { - const nextRightNode = !acc.rightNode || node.position.x + node.width! > acc.rightNode.position.x + acc.rightNode.width! - ? node - : acc.rightNode - const nextBottomNode = !acc.bottomNode || node.position.y + node.height! > acc.bottomNode.position.y + acc.bottomNode.height! - ? node - : acc.bottomNode + const nextRightNode = + !acc.rightNode || + node.position.x + node.width! > acc.rightNode.position.x + acc.rightNode.width! + ? node + : acc.rightNode + const nextBottomNode = + !acc.bottomNode || + node.position.y + node.height! > acc.bottomNode.position.y + acc.bottomNode.height! + ? node + : acc.bottomNode return { rightNode: nextRightNode, @@ -30,12 +28,15 @@ export const getContainerBounds = (childrenNodes: Node[]): ContainerBounds => { } export const getContainerResize = (currentNode: Node, bounds: ContainerBounds) => { - const width = bounds.rightNode && currentNode.width! < bounds.rightNode.position.x + bounds.rightNode.width! - ? bounds.rightNode.position.x + bounds.rightNode.width! + LOOP_PADDING.right - : undefined - const height = bounds.bottomNode && currentNode.height! < bounds.bottomNode.position.y + bounds.bottomNode.height! - ? bounds.bottomNode.position.y + bounds.bottomNode.height! + LOOP_PADDING.bottom - : undefined + const width = + bounds.rightNode && currentNode.width! < bounds.rightNode.position.x + bounds.rightNode.width! + ? bounds.rightNode.position.x + bounds.rightNode.width! + LOOP_PADDING.right + : undefined + const height = + bounds.bottomNode && + currentNode.height! < bounds.bottomNode.position.y + bounds.bottomNode.height! + ? bounds.bottomNode.position.y + bounds.bottomNode.height! + LOOP_PADDING.bottom + : undefined return { width, @@ -44,15 +45,12 @@ export const getContainerResize = (currentNode: Node, bounds: ContainerBounds) = } export const getRestrictedLoopPosition = (node: Node, parentNode?: Node) => { - const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined } + const restrictPosition: { x?: number; y?: number } = { x: undefined, y: undefined } - if (!node.data.isInLoop || !parentNode) - return restrictPosition + if (!node.data.isInLoop || !parentNode) return restrictPosition - if (node.position.y < LOOP_PADDING.top) - restrictPosition.y = LOOP_PADDING.top - if (node.position.x < LOOP_PADDING.left) - restrictPosition.x = LOOP_PADDING.left + if (node.position.y < LOOP_PADDING.top) restrictPosition.y = LOOP_PADDING.top + if (node.position.x < LOOP_PADDING.left) restrictPosition.x = LOOP_PADDING.left if (node.position.x + node.width! > parentNode.width! - LOOP_PADDING.right) restrictPosition.x = parentNode.width! - LOOP_PADDING.right - node.width! if (node.position.y + node.height! > parentNode.height! - LOOP_PADDING.bottom) @@ -62,7 +60,7 @@ export const getRestrictedLoopPosition = (node: Node, parentNode?: Node) => { } export const getLoopChildren = (nodes: Node[], nodeId: string) => { - return nodes.filter(node => node.parentId === nodeId && node.type !== CUSTOM_LOOP_START_NODE) + return nodes.filter((node) => node.parentId === nodeId && node.type !== CUSTOM_LOOP_START_NODE) } export const buildLoopChildCopy = ({ @@ -90,7 +88,10 @@ export const buildLoopChildCopy = ({ _connectedSourceHandleIds: [], _connectedTargetHandleIds: [], _dimmed: false, - title: nodesWithSameTypeCount > 0 ? `${defaultValue.title} ${nodesWithSameTypeCount + 1}` : defaultValue.title, + title: + nodesWithSameTypeCount > 0 + ? `${defaultValue.title} ${nodesWithSameTypeCount + 1}` + : defaultValue.title, isInLoop: true, loop_id: newNodeId, type: childNodeType, diff --git a/web/app/components/workflow/nodes/loop/use-interactions.ts b/web/app/components/workflow/nodes/loop/use-interactions.ts index ed4c608f144051..214e9c7f9c159e 100644 --- a/web/app/components/workflow/nodes/loop/use-interactions.ts +++ b/web/app/components/workflow/nodes/loop/use-interactions.ts @@ -1,15 +1,9 @@ -import type { - BlockEnum, - Node, -} from '../../types' +import type { BlockEnum, Node } from '../../types' import { produce } from 'immer' import { useCallback } from 'react' import { useNodesMetaData } from '@/app/components/workflow/hooks' import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' -import { - generateNewNode, - getNodeCustomTypeByNodeDataType, -} from '../../utils' +import { generateNewNode, getNodeCustomTypeByNodeDataType } from '../../utils' import { buildLoopChildCopy, getContainerBounds, @@ -22,80 +16,94 @@ export const useNodeLoopInteractions = () => { const collaborativeWorkflow = useCollaborativeWorkflow() const { nodesMap: nodesMetaDataMap } = useNodesMetaData() - const handleNodeLoopRerender = useCallback((nodeId: string) => { - const { nodes, setNodes } = collaborativeWorkflow.getState() - const currentNode = nodes.find(n => n.id === nodeId)! - const childrenNodes = nodes.filter(n => n.parentId === nodeId) - const resize = getContainerResize(currentNode, getContainerBounds(childrenNodes)) + const handleNodeLoopRerender = useCallback( + (nodeId: string) => { + const { nodes, setNodes } = collaborativeWorkflow.getState() + const currentNode = nodes.find((n) => n.id === nodeId)! + const childrenNodes = nodes.filter((n) => n.parentId === nodeId) + const resize = getContainerResize(currentNode, getContainerBounds(childrenNodes)) - if (resize.width || resize.height) { - const newNodes = produce(nodes, (draft) => { - draft.forEach((n) => { - if (n.id === nodeId) { - if (resize.width) { - n.data.width = resize.width - n.width = resize.width - } - if (resize.height) { - n.data.height = resize.height - n.height = resize.height + if (resize.width || resize.height) { + const newNodes = produce(nodes, (draft) => { + draft.forEach((n) => { + if (n.id === nodeId) { + if (resize.width) { + n.data.width = resize.width + n.width = resize.width + } + if (resize.height) { + n.data.height = resize.height + n.height = resize.height + } } - } + }) }) - }) - setNodes(newNodes) - } - }, [collaborativeWorkflow]) + setNodes(newNodes) + } + }, + [collaborativeWorkflow], + ) - const handleNodeLoopChildDrag = useCallback((node: Node) => { - const { nodes } = collaborativeWorkflow.getState() + const handleNodeLoopChildDrag = useCallback( + (node: Node) => { + const { nodes } = collaborativeWorkflow.getState() - return { - restrictPosition: getRestrictedLoopPosition(node, nodes.find(n => n.id === node.parentId)), - } - }, [collaborativeWorkflow]) + return { + restrictPosition: getRestrictedLoopPosition( + node, + nodes.find((n) => n.id === node.parentId), + ), + } + }, + [collaborativeWorkflow], + ) - const handleNodeLoopChildSizeChange = useCallback((nodeId: string) => { - const { nodes } = collaborativeWorkflow.getState() - const currentNode = nodes.find(n => n.id === nodeId)! - const parentId = currentNode.parentId + const handleNodeLoopChildSizeChange = useCallback( + (nodeId: string) => { + const { nodes } = collaborativeWorkflow.getState() + const currentNode = nodes.find((n) => n.id === nodeId)! + const parentId = currentNode.parentId - if (parentId) - handleNodeLoopRerender(parentId) - }, [collaborativeWorkflow, handleNodeLoopRerender]) + if (parentId) handleNodeLoopRerender(parentId) + }, + [collaborativeWorkflow, handleNodeLoopRerender], + ) - const handleNodeLoopChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record<string, string>) => { - const { nodes } = collaborativeWorkflow.getState() - const childrenNodes = getLoopChildren(nodes, nodeId) - const newIdMapping = { ...idMapping } + const handleNodeLoopChildrenCopy = useCallback( + (nodeId: string, newNodeId: string, idMapping: Record<string, string>) => { + const { nodes } = collaborativeWorkflow.getState() + const childrenNodes = getLoopChildren(nodes, nodeId) + const newIdMapping = { ...idMapping } - const copyChildren = childrenNodes.map((child, index) => { - const childNodeType = child.data.type as BlockEnum - const defaultValue = nodesMetaDataMap?.[childNodeType]?.defaultValue ?? {} - const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType) - const childCopy = buildLoopChildCopy({ - child, - childNodeType, - defaultValue: defaultValue as Node['data'], - nodesWithSameTypeCount: nodesWithSameType.length, - newNodeId, - index, - }) - const { newNode } = generateNewNode({ - ...childCopy.params, - type: getNodeCustomTypeByNodeDataType(childNodeType), + const copyChildren = childrenNodes.map((child, index) => { + const childNodeType = child.data.type as BlockEnum + const defaultValue = nodesMetaDataMap?.[childNodeType]?.defaultValue ?? {} + const nodesWithSameType = nodes.filter((node) => node.data.type === childNodeType) + const childCopy = buildLoopChildCopy({ + child, + childNodeType, + defaultValue: defaultValue as Node['data'], + nodesWithSameTypeCount: nodesWithSameType.length, + newNodeId, + index, + }) + const { newNode } = generateNewNode({ + ...childCopy.params, + type: getNodeCustomTypeByNodeDataType(childNodeType), + }) + newNode.id = `${newNodeId}${newNode.id + childCopy.newId}` + newIdMapping[child.id] = newNode.id + return newNode }) - newNode.id = `${newNodeId}${newNode.id + childCopy.newId}` - newIdMapping[child.id] = newNode.id - return newNode - }) - return { - copyChildren, - newIdMapping, - } - }, [collaborativeWorkflow, nodesMetaDataMap]) + return { + copyChildren, + newIdMapping, + } + }, + [collaborativeWorkflow, nodesMetaDataMap], + ) return { handleNodeLoopRerender, diff --git a/web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts b/web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts index 455d58e605a96d..7315cda65cd91a 100644 --- a/web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts +++ b/web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts @@ -6,9 +6,7 @@ import { VarType } from '../../types' type Params = { nodeId: string } -const useIsVarFileAttribute = ({ - nodeId, -}: Params) => { +const useIsVarFileAttribute = ({ nodeId }: Params) => { const isChatMode = useIsChatMode() const { getBeforeNodesInSameBranch } = useWorkflow() const availableNodes = useMemo(() => { @@ -16,8 +14,7 @@ const useIsVarFileAttribute = ({ }, [getBeforeNodesInSameBranch, nodeId]) const { getCurrentVariableType } = useWorkflowVariables() const getIsVarFileAttribute = (variable: ValueSelector) => { - if (variable.length !== 3) - return false + if (variable.length !== 3) return false const parentVariable = variable.slice(0, 2) const varType = getCurrentVariableType({ valueSelector: parentVariable, diff --git a/web/app/components/workflow/nodes/loop/use-single-run-form-params.helpers.ts b/web/app/components/workflow/nodes/loop/use-single-run-form-params.helpers.ts index 90df2d577755fa..aeff31197b11d2 100644 --- a/web/app/components/workflow/nodes/loop/use-single-run-form-params.helpers.ts +++ b/web/app/components/workflow/nodes/loop/use-single-run-form-params.helpers.ts @@ -2,7 +2,12 @@ import type { InputVar, Node, ValueSelector, Variable } from '../../types' import type { CaseItem, Condition, LoopVariable } from './types' import { ValueType } from '@/app/components/workflow/types' import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config' -import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar } from '../_base/components/variable/utils' +import { + getNodeInfoById, + getNodeUsedVarPassToServerKey, + getNodeUsedVars, + isSystemVar, +} from '../_base/components/variable/utils' export function getVarSelectorsFromCase(caseItem: CaseItem): ValueSelector[] { const vars: ValueSelector[] = [] @@ -14,8 +19,7 @@ export function getVarSelectorsFromCase(caseItem: CaseItem): ValueSelector[] { export function getVarSelectorsFromCondition(condition: Condition): ValueSelector[] { const vars: ValueSelector[] = [] - if (condition.variable_selector) - vars.push(condition.variable_selector) + if (condition.variable_selector) vars.push(condition.variable_selector) if (condition.sub_variable_condition?.conditions?.length) vars.push(...getVarSelectorsFromCase(condition.sub_variable_condition)) @@ -36,8 +40,7 @@ export const dedupeInputVars = (inputVars: InputVar[]) => { const uniqueInputVars: InputVar[] = [] inputVars.forEach((input) => { - if (!input || seen[input.variable]) - return + if (!input || seen[input.variable]) return seen[input.variable] = true uniqueInputVars.push(input) @@ -64,12 +67,10 @@ export const buildUsedOutVars = ({ const allVarObject: Record<string, { inSingleRunPassedKey: string }> = {} loopChildrenNodes.forEach((node) => { - const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0) + const nodeVars = getNodeUsedVars(node).filter((item) => item && item.length > 0) nodeVars.forEach((varSelector) => { - if (varSelector[0] === currentNodeId) - return - if (isNodeInLoop(varSelector[0]!)) - return + if (varSelector[0] === currentNodeId) return + if (isNodeInLoop(varSelector[0]!)) return const varSelectorStr = varSelector.join('.') if (!seenVarSelectors[varSelectorStr]) { @@ -78,8 +79,7 @@ export const buildUsedOutVars = ({ } let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector) - if (typeof passToServerKeys === 'string') - passToServerKeys = [passToServerKeys] + if (typeof passToServerKeys === 'string') passToServerKeys = [passToServerKeys] passToServerKeys.forEach((key: string, index: number) => { allVarObject[[varSelectorStr, node.id, index].join(DELIMITER)] = { @@ -89,18 +89,22 @@ export const buildUsedOutVars = ({ }) }) - const usedOutVars = toVarInputs(vars.map((valueSelector) => { - const varInfo = getNodeInfoById(canChooseVarNodes, valueSelector[0]!) - return { - label: { - nodeType: varInfo?.data.type, - nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title!, - variable: isSystemVar(valueSelector) ? valueSelector.join('.') : valueSelector[valueSelector.length - 1]!, - }, - variable: valueSelector.join('.'), - value_selector: valueSelector, - } - })) + const usedOutVars = toVarInputs( + vars.map((valueSelector) => { + const varInfo = getNodeInfoById(canChooseVarNodes, valueSelector[0]!) + return { + label: { + nodeType: varInfo?.data.type, + nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title!, + variable: isSystemVar(valueSelector) + ? valueSelector.join('.') + : valueSelector[valueSelector.length - 1]!, + }, + variable: valueSelector.join('.'), + value_selector: valueSelector, + } + }), + ) return { usedOutVars, allVarObject } } @@ -116,16 +120,15 @@ export const getDependentVarsFromLoopPayload = ({ breakConditions?: Condition[] loopVariables?: LoopVariable[] }) => { - const vars: ValueSelector[] = usedOutVars.map(item => item.variable.split('.')) + const vars: ValueSelector[] = usedOutVars.map((item) => item.variable.split('.')) breakConditions?.forEach((condition) => { vars.push(...getVarSelectorsFromCondition(condition)) }) loopVariables?.forEach((loopVariable) => { - if (loopVariable.value_type === ValueType.variable) - vars.push(loopVariable.value) + if (loopVariable.value_type === ValueType.variable) vars.push(loopVariable.value) }) - return vars.filter(item => item[0] !== nodeId) + return vars.filter((item) => item[0] !== nodeId) } diff --git a/web/app/components/workflow/nodes/loop/use-single-run-form-params.ts b/web/app/components/workflow/nodes/loop/use-single-run-form-params.ts index 5cd0abeb794ea4..c4fde98a70aa45 100644 --- a/web/app/components/workflow/nodes/loop/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/loop/use-single-run-form-params.ts @@ -42,15 +42,22 @@ const useSingleRunFormParams = ({ const { getLoopNodeChildren, getBeforeNodesInSameBranch } = useWorkflow() const loopChildrenNodes = getLoopNodeChildren(id) const beforeNodes = getBeforeNodesInSameBranch(id) - const canChooseVarNodes = useMemo(() => [...beforeNodes, ...loopChildrenNodes], [beforeNodes, loopChildrenNodes]) + const canChooseVarNodes = useMemo( + () => [...beforeNodes, ...loopChildrenNodes], + [beforeNodes, loopChildrenNodes], + ) - const { usedOutVars, allVarObject } = useMemo(() => buildUsedOutVars({ - loopChildrenNodes, - currentNodeId: id, - canChooseVarNodes, - isNodeInLoop, - toVarInputs, - }), [loopChildrenNodes, id, canChooseVarNodes, isNodeInLoop, toVarInputs]) + const { usedOutVars, allVarObject } = useMemo( + () => + buildUsedOutVars({ + loopChildrenNodes, + currentNodeId: id, + canChooseVarNodes, + isNodeInLoop, + toVarInputs, + }), + [loopChildrenNodes, id, canChooseVarNodes, isNodeInLoop, toVarInputs], + ) const nodeInfo = useMemo(() => { const formattedNodeInfo = formatTracing(loopRunResult, t)[0] @@ -68,9 +75,12 @@ const useSingleRunFormParams = ({ return formattedNodeInfo }, [runResult, loopRunResult, t]) - const setInputVarValues = useCallback((newPayload: Record<string, any>) => { - setRunInputData(newPayload) - }, [setRunInputData]) + const setInputVarValues = useCallback( + (newPayload: Record<string, any>) => { + setRunInputData(newPayload) + }, + [setRunInputData], + ) const inputVarValues = useMemo(() => createInputVarValues(runInputData), [runInputData]) @@ -82,8 +92,7 @@ const useSingleRunFormParams = ({ }) payload.loop_variables?.forEach((loopVariable) => { - if (loopVariable.value_type === ValueType.variable) - allInputs.push(loopVariable.value) + if (loopVariable.value_type === ValueType.variable) allInputs.push(loopVariable.value) }) const inputVarsFromValue: InputVar[] = [] const varInputs = [...varSelectorsToVarInputs(allInputs), ...inputVarsFromValue] @@ -95,14 +104,25 @@ const useSingleRunFormParams = ({ onChange: setInputVarValues, }, ] - }, [payload.break_conditions, payload.loop_variables, varSelectorsToVarInputs, usedOutVars, inputVarValues, setInputVarValues]) - - const getDependentVars = useCallback(() => getDependentVarsFromLoopPayload({ - nodeId: id, + }, [ + payload.break_conditions, + payload.loop_variables, + varSelectorsToVarInputs, usedOutVars, - breakConditions: payload.break_conditions, - loopVariables: payload.loop_variables, - }), [id, usedOutVars, payload.break_conditions, payload.loop_variables]) + inputVarValues, + setInputVarValues, + ]) + + const getDependentVars = useCallback( + () => + getDependentVarsFromLoopPayload({ + nodeId: id, + usedOutVars, + breakConditions: payload.break_conditions, + loopVariables: payload.loop_variables, + }), + [id, usedOutVars, payload.break_conditions, payload.loop_variables], + ) return { forms, diff --git a/web/app/components/workflow/nodes/loop/utils.ts b/web/app/components/workflow/nodes/loop/utils.ts index 108b240b1856b2..8d587efc538522 100644 --- a/web/app/components/workflow/nodes/loop/utils.ts +++ b/web/app/components/workflow/nodes/loop/utils.ts @@ -2,7 +2,14 @@ import { VarType } from '@/app/components/workflow/types' import { ComparisonOperator } from './types' export const isEmptyRelatedOperator = (operator: ComparisonOperator) => { - return [ComparisonOperator.empty, ComparisonOperator.notEmpty, ComparisonOperator.isNull, ComparisonOperator.isNotNull, ComparisonOperator.exists, ComparisonOperator.notExists].includes(operator) + return [ + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ComparisonOperator.isNull, + ComparisonOperator.isNotNull, + ComparisonOperator.exists, + ComparisonOperator.notExists, + ].includes(operator) } const notTranslateKey = [ @@ -14,14 +21,19 @@ const notTranslateKey = [ ComparisonOperator.lessThanOrEqual, ] as const -type NotTranslateOperator = typeof notTranslateKey[number] +type NotTranslateOperator = (typeof notTranslateKey)[number] type TranslatableComparisonOperator = Exclude<ComparisonOperator, NotTranslateOperator> -export function isComparisonOperatorNeedTranslate(operator: ComparisonOperator): operator is TranslatableComparisonOperator -export function isComparisonOperatorNeedTranslate(operator?: ComparisonOperator): operator is TranslatableComparisonOperator -export function isComparisonOperatorNeedTranslate(operator?: ComparisonOperator): operator is TranslatableComparisonOperator { - if (!operator) - return false +export function isComparisonOperatorNeedTranslate( + operator: ComparisonOperator, +): operator is TranslatableComparisonOperator +export function isComparisonOperatorNeedTranslate( + operator?: ComparisonOperator, +): operator is TranslatableComparisonOperator +export function isComparisonOperatorNeedTranslate( + operator?: ComparisonOperator, +): operator is TranslatableComparisonOperator { + if (!operator) return false return !(notTranslateKey as readonly ComparisonOperator[]).includes(operator) } @@ -43,10 +55,7 @@ export const getOperators = (type?: VarType, file?: { key: string }) => { ComparisonOperator.notEmpty, ] case 'type': - return [ - ComparisonOperator.in, - ComparisonOperator.notIn, - ] + return [ComparisonOperator.in, ComparisonOperator.notIn] case 'size': return [ ComparisonOperator.largerThan, @@ -73,10 +82,7 @@ export const getOperators = (type?: VarType, file?: { key: string }) => { ComparisonOperator.notEmpty, ] case 'transfer_method': - return [ - ComparisonOperator.in, - ComparisonOperator.notIn, - ] + return [ComparisonOperator.in, ComparisonOperator.notIn] case 'url': return [ ComparisonOperator.contains, @@ -122,15 +128,9 @@ export const getOperators = (type?: VarType, file?: { key: string }) => { ComparisonOperator.notEmpty, ] case VarType.object: - return [ - ComparisonOperator.empty, - ComparisonOperator.notEmpty, - ] + return [ComparisonOperator.empty, ComparisonOperator.notEmpty] case VarType.file: - return [ - ComparisonOperator.exists, - ComparisonOperator.notExists, - ] + return [ComparisonOperator.exists, ComparisonOperator.notExists] case VarType.arrayString: case VarType.arrayNumber: return [ @@ -141,10 +141,7 @@ export const getOperators = (type?: VarType, file?: { key: string }) => { ] case VarType.array: case VarType.arrayObject: - return [ - ComparisonOperator.empty, - ComparisonOperator.notEmpty, - ] + return [ComparisonOperator.empty, ComparisonOperator.notEmpty] case VarType.arrayFile: return [ ComparisonOperator.contains, @@ -164,8 +161,14 @@ export const getOperators = (type?: VarType, file?: { key: string }) => { } export const comparisonOperatorNotRequireValue = (operator?: ComparisonOperator) => { - if (!operator) - return false + if (!operator) return false - return [ComparisonOperator.empty, ComparisonOperator.notEmpty, ComparisonOperator.isNull, ComparisonOperator.isNotNull, ComparisonOperator.exists, ComparisonOperator.notExists].includes(operator) + return [ + ComparisonOperator.empty, + ComparisonOperator.notEmpty, + ComparisonOperator.isNull, + ComparisonOperator.isNotNull, + ComparisonOperator.exists, + ComparisonOperator.notExists, + ].includes(operator) } diff --git a/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx index a818b8601bdb9a..ee07c4e3d74f86 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx @@ -7,9 +7,7 @@ import type { PanelProps } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { - useTextGenerationCurrentProviderAndModelAndModelList, -} from '@/app/components/header/account-setting/model-provider-page/hooks' +import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import { CollectionType } from '@/app/components/tools/types' import { AppModeEnum } from '@/types/app' import { BlockEnum } from '../../../types' @@ -22,7 +20,8 @@ import Panel from '../panel' import { ParamType, ReasoningModeType } from '../types' import useConfig from '../use-config' -const reasoningModeFunctionToolCallingLabel = 'workflow.nodes.parameterExtractor.reasoningModeFunctionToolCalling' +const reasoningModeFunctionToolCallingLabel = + 'workflow.nodes.parameterExtractor.reasoningModeFunctionToolCalling' const reasoningModePromptLabel = 'workflow.nodes.parameterExtractor.reasoningModePrompt' type MockToolCollection = { @@ -57,10 +56,7 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ trigger?: (open: boolean) => ReactNode onSelect?: (type: BlockEnum, value?: ToolDefaultValue) => void }) => ( - <button - type="button" - onClick={() => onSelect?.(BlockEnum.Tool, mockSelectedToolInfo)} - > + <button type="button" onClick={() => onSelect?.(BlockEnum.Tool, mockSelectedToolInfo)}> {trigger ? trigger(mockBlockSelectorOpen) : 'select-tool'} </button> ), @@ -79,58 +75,53 @@ vi.mock('@/service/use-tools', () => ({ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ __esModule: true, - default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => ( + default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( <div>{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}</div> ), })) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - __esModule: true, - default: ({ - setModel, - onCompletionParamsChange, - }: { - setModel: (model: { provider: string, modelId: string, mode?: string }) => void - onCompletionParamsChange: (params: Record<string, unknown>) => void - }) => ( - <div> - <button - type="button" - onClick={() => setModel({ provider: 'anthropic', modelId: 'claude-3-7-sonnet', mode: AppModeEnum.CHAT })} - > - set-model - </button> - <button - type="button" - onClick={() => onCompletionParamsChange({ temperature: 0.2 })} - > - set-params - </button> - </div> - ), -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + __esModule: true, + default: ({ + setModel, + onCompletionParamsChange, + }: { + setModel: (model: { provider: string; modelId: string; mode?: string }) => void + onCompletionParamsChange: (params: Record<string, unknown>) => void + }) => ( + <div> + <button + type="button" + onClick={() => + setModel({ + provider: 'anthropic', + modelId: 'claude-3-7-sonnet', + mode: AppModeEnum.CHAT, + }) + } + > + set-model + </button> + <button type="button" onClick={() => onCompletionParamsChange({ temperature: 0.2 })}> + set-params + </button> + </div> + ), + }), +) vi.mock('@langgenius/dify-ui/dialog', () => ({ __esModule: true, - Dialog: ({ - children, - open, - }: { - children: ReactNode - open?: boolean - }) => open !== false - ? ( - <div data-testid="base-modal"> - {children} - </div> - ) - : null, + Dialog: ({ children, open }: { children: ReactNode; open?: boolean }) => + open !== false ? <div data-testid="base-modal">{children}</div> : null, DialogContent: ({ children }: { children: ReactNode }) => <>{children}</>, DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>, })) vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({ - FieldCollapse: ({ title, children }: { title: ReactNode, children: ReactNode }) => ( + FieldCollapse: ({ title, children }: { title: ReactNode; children: ReactNode }) => ( <div> <div>{title}</div> {children} @@ -140,7 +131,15 @@ vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ __esModule: true, - default: ({ title, operations, children }: { title: ReactNode, operations?: ReactNode, children: ReactNode }) => ( + default: ({ + title, + operations, + children, + }: { + title: ReactNode + operations?: ReactNode + children: ReactNode + }) => ( <div> <div>{title}</div> <div>{operations}</div> @@ -152,7 +151,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <div>{children}</div>, - VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>, + VarItem: ({ name, type }: { name: string; type: string }) => <div>{`${name}:${type}`}</div>, })) vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ @@ -167,22 +166,29 @@ vi.mock('@/app/components/workflow/nodes/_base/components/config-vision', () => onConfigChange, }: { onEnabledChange: (enabled: boolean) => void - onConfigChange: (value: { variable_selector: string[], detail: string }) => void + onConfigChange: (value: { variable_selector: string[]; detail: string }) => void }) => ( <div> - <button type="button" onClick={() => onEnabledChange(true)}>vision-toggle</button> - <button type="button" onClick={() => onConfigChange({ variable_selector: ['node-1', 'image'], detail: 'high' })}>vision-config</button> + <button type="button" onClick={() => onEnabledChange(true)}> + vision-toggle + </button> + <button + type="button" + onClick={() => onConfigChange({ variable_selector: ['node-1', 'image'], detail: 'high' })} + > + vision-config + </button> </div> ), })) vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({ __esModule: true, - default: ({ - onChange, - }: { - onChange: (value: { enabled: boolean }) => void - }) => <button type="button" onClick={() => onChange({ enabled: true })}>memory-config</button>, + default: ({ onChange }: { onChange: (value: { enabled: boolean }) => void }) => ( + <button type="button" onClick={() => onChange({ enabled: true })}> + memory-config + </button> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({ @@ -201,7 +207,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => <textarea aria-label="instruction-editor" value={value} - onChange={event => onChange(event.target.value)} + onChange={(event) => onChange(event.target.value)} /> </div> ), @@ -209,11 +215,11 @@ vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ __esModule: true, - default: ({ - onChange, - }: { - onChange: (value: string[]) => void - }) => <button type="button" onClick={() => onChange(['node-1', 'query'])}>pick-var</button>, + default: ({ onChange }: { onChange: (value: string[]) => void }) => ( + <button type="button" onClick={() => onChange(['node-1', 'query'])}> + pick-var + </button> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/list-no-data-placeholder', () => ({ @@ -223,18 +229,16 @@ vi.mock('@/app/components/workflow/nodes/_base/components/list-no-data-placehold vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({ __esModule: true, - default: ({ - title, - onSelect, - }: { - title: string - onSelect: () => void - }) => <button type="button" onClick={onSelect}>{title}</button>, + default: ({ title, onSelect }: { title: string; onSelect: () => void }) => ( + <button type="button" onClick={onSelect}> + {title} + </button> + ), })) vi.mock('@/app/components/app/configuration/config-var/config-modal/field', () => ({ __esModule: true, - default: ({ title, children }: { title: ReactNode, children: ReactNode }) => ( + default: ({ title, children }: { title: ReactNode; children: ReactNode }) => ( <div> <div>{title}</div> {children} @@ -244,16 +248,12 @@ vi.mock('@/app/components/app/configuration/config-var/config-modal/field', () = vi.mock('@/app/components/app/configuration/config-var/config-select', () => ({ __esModule: true, - default: ({ - options, - onChange, - }: { - options: string[] - onChange: (value: string[]) => void - }) => ( + default: ({ options, onChange }: { options: string[]; onChange: (value: string[]) => void }) => ( <div> <div>{options.join(',')}</div> - <button type="button" onClick={() => onChange([...options, 'published'])}>set-options</button> + <button type="button" onClick={() => onChange([...options, 'published'])}> + set-options + </button> </div> ), })) @@ -309,7 +309,9 @@ const createParam = (overrides: Partial<Param> = {}): Param => ({ ...overrides, }) -const createData = (overrides: Partial<ParameterExtractorNodeType> = {}): ParameterExtractorNodeType => ({ +const createData = ( + overrides: Partial<ParameterExtractorNodeType> = {}, +): ParameterExtractorNodeType => ({ title: 'Parameter Extractor', desc: '', type: BlockEnum.ParameterExtractor, @@ -329,7 +331,9 @@ const createData = (overrides: Partial<ParameterExtractorNodeType> = {}): Parame ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, handleInputVarChange: vi.fn(), filterVar: (_varPayload: Var) => true, @@ -407,7 +411,9 @@ describe('parameter-extractor path', () => { render(<ImportFromTool onImport={onImport} />) - await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i })) + await user.click( + screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }), + ) expect(onImport).toHaveBeenCalledWith([ { @@ -428,7 +434,9 @@ describe('parameter-extractor path', () => { render(<ImportFromTool onImport={onImport} />) - await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i })) + await user.click( + screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }), + ) expect(onImport).not.toHaveBeenCalled() }) @@ -447,7 +455,9 @@ describe('parameter-extractor path', () => { tools: [ { name: 'search', - parameters: [createToolParameter({ name: 'custom_city', llm_description: 'Custom city' })], + parameters: [ + createToolParameter({ name: 'custom_city', llm_description: 'Custom city' }), + ], }, ], }, @@ -455,7 +465,9 @@ describe('parameter-extractor path', () => { render(<ImportFromTool onImport={onImport} />) - await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i })) + await user.click( + screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }), + ) expect(onImport).toHaveBeenLastCalledWith([ { @@ -483,14 +495,18 @@ describe('parameter-extractor path', () => { tools: [ { name: 'transform', - parameters: [createToolParameter({ name: 'workflow_city', llm_description: 'Workflow city' })], + parameters: [ + createToolParameter({ name: 'workflow_city', llm_description: 'Workflow city' }), + ], }, ], }, ] render(<ImportFromTool onImport={onImport} />) - await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i })) + await user.click( + screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }), + ) expect(onImport).toHaveBeenLastCalledWith([ { @@ -514,50 +530,51 @@ describe('parameter-extractor path', () => { render(<ImportFromTool onImport={onImport} />) - expect(screen.getByText('workflow.nodes.parameterExtractor.importFromTool')).toHaveClass('bg-state-base-hover') + expect(screen.getByText('workflow.nodes.parameterExtractor.importFromTool')).toHaveClass( + 'bg-state-base-hover', + ) - await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i })) + await user.click( + screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }), + ) expect(onImport).toHaveBeenCalledWith([]) }) it('should show the empty state for an empty parameter list', () => { - render( - <ExtractParameter - readonly={false} - list={[]} - onChange={vi.fn()} - />, - ) + render(<ExtractParameter readonly={false} list={[]} onChange={vi.fn()} />) - expect(screen.getByText('workflow.nodes.parameterExtractor.extractParametersNotSet')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.parameterExtractor.extractParametersNotSet'), + ).toBeInTheDocument() }) it('should edit and delete parameters from the list', async () => { const user = userEvent.setup() const onChange = vi.fn() const { container, rerender } = render( - <ExtractParameter - readonly={false} - list={[createParam()]} - onChange={onChange} - />, + <ExtractParameter readonly={false} list={[createParam()]} onChange={onChange} />, ) const editAndDeleteButtons = container.querySelectorAll('.cursor-pointer.rounded-md.p-1') fireEvent.click(editAndDeleteButtons[0] as HTMLElement) fireEvent.change(screen.getByDisplayValue('city'), { target: { value: 'city_name' } }) - fireEvent.change(screen.getByDisplayValue('City name'), { target: { value: 'Updated city description' } }) + fireEvent.change(screen.getByDisplayValue('City name'), { + target: { value: 'Updated city description' }, + }) await user.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(onChange).toHaveBeenCalledWith([ - { - name: 'city_name', - type: ParamType.string, - description: 'Updated city description', - required: false, - }, - ], undefined) + expect(onChange).toHaveBeenCalledWith( + [ + { + name: 'city_name', + type: ParamType.string, + description: 'Updated city description', + required: false, + }, + ], + undefined, + ) onChange.mockClear() @@ -597,32 +614,33 @@ describe('parameter-extractor path', () => { }) it('should render the add trigger for new parameters', () => { - render( - <AddExtractParameter - type="add" - onSave={vi.fn()} - />, - ) + render(<AddExtractParameter type="add" onSave={vi.fn()} />) - expect(screen.getByRole('button', { name: 'workflow.nodes.parameterExtractor.addExtractParameter' })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'workflow.nodes.parameterExtractor.addExtractParameter', + }), + ).toBeInTheDocument() }) it('should reject invalid names and reset add modal fields after canceling', async () => { const user = userEvent.setup() const onCancel = vi.fn() - render( - <AddExtractParameter - type="add" - onSave={vi.fn()} - onCancel={onCancel} - />, - ) + render(<AddExtractParameter type="add" onSave={vi.fn()} onCancel={onCancel} />) - await user.click(screen.getByRole('button', { name: 'workflow.nodes.parameterExtractor.addExtractParameter' })) + await user.click( + screen.getByRole('button', { + name: 'workflow.nodes.parameterExtractor.addExtractParameter', + }), + ) - const nameInput = screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder') - const descriptionInput = screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder') + const nameInput = screen.getByPlaceholderText( + 'workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder', + ) + const descriptionInput = screen.getByPlaceholderText( + 'workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder', + ) fireEvent.change(nameInput, { target: { value: '1bad' } }) expect(mockToastError).toHaveBeenCalled() @@ -635,9 +653,21 @@ describe('parameter-extractor path', () => { expect(onCancel).toHaveBeenCalledTimes(1) expect(screen.queryByTestId('base-modal')).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'workflow.nodes.parameterExtractor.addExtractParameter' })) - expect(screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder')).toHaveValue('') - expect(screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder')).toHaveValue('') + await user.click( + screen.getByRole('button', { + name: 'workflow.nodes.parameterExtractor.addExtractParameter', + }), + ) + expect( + screen.getByPlaceholderText( + 'workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder', + ), + ).toHaveValue('') + expect( + screen.getByPlaceholderText( + 'workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder', + ), + ).toHaveValue('') }) it('should require select options before saving a select parameter', async () => { @@ -686,13 +716,16 @@ describe('parameter-extractor path', () => { await user.click(screen.getByRole('button', { name: 'set-options' })) await user.click(await screen.findByRole('button', { name: 'common.operation.save' })) - expect(onSave).toHaveBeenCalledWith({ - name: 'approval_status', - type: ParamType.select, - description: 'Status', - options: ['draft', 'published'], - required: false, - }, undefined) + expect(onSave).toHaveBeenCalledWith( + { + name: 'approval_status', + type: ParamType.select, + description: 'Status', + options: ['draft', 'published'], + required: false, + }, + undefined, + ) }) it('should persist rename metadata and required state for edited parameters', async () => { @@ -716,12 +749,15 @@ describe('parameter-extractor path', () => { await user.click(screen.getByRole('switch')) await user.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(onSave).toHaveBeenCalledWith({ - name: 'approval_status', - type: ParamType.string, - description: 'Status description', - required: true, - }, undefined) + expect(onSave).toHaveBeenCalledWith( + { + name: 'approval_status', + type: ParamType.string, + description: 'Status description', + required: true, + }, + undefined, + ) }) }) @@ -730,12 +766,7 @@ describe('parameter-extractor path', () => { const user = userEvent.setup() const onChange = vi.fn() - render( - <ReasoningModePicker - type={ReasoningModeType.prompt} - onChange={onChange} - />, - ) + render(<ReasoningModePicker type={ReasoningModeType.prompt} onChange={onChange} />) await user.click(screen.getByRole('button', { name: reasoningModeFunctionToolCallingLabel })) await user.click(screen.getByRole('button', { name: reasoningModePromptLabel })) @@ -745,12 +776,7 @@ describe('parameter-extractor path', () => { }) it('should render the selected model on the node only when configured', () => { - const { rerender } = render( - <Node - id="parameter-node" - data={createData()} - />, - ) + const { rerender } = render(<Node id="parameter-node" data={createData()} />) expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument() @@ -795,33 +821,34 @@ describe('parameter-extractor path', () => { }, ] - mockUseConfig.mockReturnValueOnce(createConfigResult({ - inputs: createData({ - parameters: [createParam({ name: 'city' }), createParam({ name: 'budget', type: ParamType.number })], + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + inputs: createData({ + parameters: [ + createParam({ name: 'city' }), + createParam({ name: 'budget', type: ParamType.number }), + ], + }), + handleModelChanged, + handleCompletionParamsChange, + handleInputVarChange, + handleImportFromTool, + handleInstructionChange, + handleMemoryChange, + handleReasoningModeChange, + handleVisionResolutionEnabledChange, + handleVisionResolutionChange, }), - handleModelChanged, - handleCompletionParamsChange, - handleInputVarChange, - handleImportFromTool, - handleInstructionChange, - handleMemoryChange, - handleReasoningModeChange, - handleVisionResolutionEnabledChange, - handleVisionResolutionChange, - })) - - render( - <Panel - id="parameter-node" - data={createData()} - panelProps={panelProps} - />, ) + render(<Panel id="parameter-node" data={createData()} panelProps={panelProps} />) + await user.click(screen.getByRole('button', { name: 'set-model' })) await user.click(screen.getByRole('button', { name: 'set-params' })) await user.click(screen.getByRole('button', { name: 'pick-var' })) - await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i })) + await user.click( + screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }), + ) await user.click(screen.getByRole('button', { name: 'vision-toggle' })) await user.click(screen.getByRole('button', { name: 'vision-config' })) fireEvent.change(screen.getByLabelText('instruction-editor'), { diff --git a/web/app/components/workflow/nodes/parameter-extractor/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/__tests__/node.spec.tsx index 686b7fc5747023..960e6a707e3f99 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/__tests__/node.spec.tsx @@ -1,8 +1,6 @@ import type { ParameterExtractorNodeType } from '../types' import { render, screen } from '@testing-library/react' -import { - useTextGenerationCurrentProviderAndModelAndModelList, -} from '@/app/components/header/account-setting/model-provider-page/hooks' +import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import { BlockEnum } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' import Node from '../node' @@ -14,14 +12,16 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ __esModule: true, - default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => ( + default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( <div>{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}</div> ), })) const mockUseTextGeneration = vi.mocked(useTextGenerationCurrentProviderAndModelAndModelList) -const createData = (overrides: Partial<ParameterExtractorNodeType> = {}): ParameterExtractorNodeType => ({ +const createData = ( + overrides: Partial<ParameterExtractorNodeType> = {}, +): ParameterExtractorNodeType => ({ title: 'Parameter Extractor', desc: '', type: BlockEnum.ParameterExtractor, @@ -50,12 +50,7 @@ describe('parameter-extractor/node', () => { }) it('renders the readonly model selector when a model is configured', () => { - render( - <Node - id="node-1" - data={createData()} - />, - ) + render(<Node id="node-1" data={createData()} />) expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/nodes/parameter-extractor/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/__tests__/panel.spec.tsx index f87d88fa855481..001dc97437e37c 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/__tests__/panel.spec.tsx @@ -8,31 +8,41 @@ import Panel from '../panel' import { ParamType, ReasoningModeType } from '../types' import useConfig from '../use-config' -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - __esModule: true, - default: ({ - setModel, - onCompletionParamsChange, - }: { - setModel: (model: { provider: string, modelId: string, mode?: string }) => void - onCompletionParamsChange: (params: Record<string, unknown>) => void - }) => ( - <div> - <button type="button" onClick={() => setModel({ provider: 'anthropic', modelId: 'claude-3-7-sonnet', mode: AppModeEnum.CHAT })}>set-model</button> - <button type="button" onClick={() => onCompletionParamsChange({ temperature: 0.2 })}>set-params</button> - </div> - ), -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + __esModule: true, + default: ({ + setModel, + onCompletionParamsChange, + }: { + setModel: (model: { provider: string; modelId: string; mode?: string }) => void + onCompletionParamsChange: (params: Record<string, unknown>) => void + }) => ( + <div> + <button + type="button" + onClick={() => + setModel({ + provider: 'anthropic', + modelId: 'claude-3-7-sonnet', + mode: AppModeEnum.CHAT, + }) + } + > + set-model + </button> + <button type="button" onClick={() => onCompletionParamsChange({ temperature: 0.2 })}> + set-params + </button> + </div> + ), + }), +) vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({ __esModule: true, - FieldCollapse: ({ - title, - children, - }: { - title: React.ReactNode - children: React.ReactNode - }) => ( + FieldCollapse: ({ title, children }: { title: React.ReactNode; children: React.ReactNode }) => ( <div> <div>{title}</div> {children} @@ -43,7 +53,9 @@ vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ __esModule: true, default: ({ onChange }: { onChange: (value: string[]) => void }) => ( - <button type="button" onClick={() => onChange(['node-1', 'query'])}>pick-var</button> + <button type="button" onClick={() => onChange(['node-1', 'query'])}> + pick-var + </button> ), })) @@ -57,8 +69,12 @@ vi.mock('@/app/components/workflow/nodes/_base/components/config-vision', () => onConfigChange: (value: { detail: string }) => void }) => ( <div> - <button type="button" onClick={() => onEnabledChange(true)}>vision-toggle</button> - <button type="button" onClick={() => onConfigChange({ detail: 'high' })}>vision-config</button> + <button type="button" onClick={() => onEnabledChange(true)}> + vision-toggle + </button> + <button type="button" onClick={() => onConfigChange({ detail: 'high' })}> + vision-config + </button> </div> ), })) @@ -66,14 +82,18 @@ vi.mock('@/app/components/workflow/nodes/_base/components/config-vision', () => vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({ __esModule: true, default: ({ onChange }: { onChange: (value: string) => void }) => ( - <button type="button" onClick={() => onChange('Updated instruction')}>instruction-editor</button> + <button type="button" onClick={() => onChange('Updated instruction')}> + instruction-editor + </button> ), })) vi.mock('../components/extract-parameter/import-from-tool', () => ({ __esModule: true, default: ({ onImport }: { onImport: (params: unknown[]) => void }) => ( - <button type="button" onClick={() => onImport([{ name: 'budget' }])}>import-from-tool</button> + <button type="button" onClick={() => onImport([{ name: 'budget' }])}> + import-from-tool + </button> ), })) @@ -85,28 +105,34 @@ vi.mock('../components/extract-parameter/list', () => ({ vi.mock('../components/extract-parameter/update', () => ({ __esModule: true, default: ({ onSave }: { onSave: (value: unknown) => void }) => ( - <button type="button" onClick={() => onSave({ name: 'city' })}>add-parameter</button> + <button type="button" onClick={() => onSave({ name: 'city' })}> + add-parameter + </button> ), })) vi.mock('../components/reasoning-mode-picker', () => ({ __esModule: true, default: ({ onChange }: { onChange: (value: ReasoningModeType) => void }) => ( - <button type="button" onClick={() => onChange(ReasoningModeType.functionCall)}>set-reasoning-mode</button> + <button type="button" onClick={() => onChange(ReasoningModeType.functionCall)}> + set-reasoning-mode + </button> ), })) vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({ __esModule: true, default: ({ onChange }: { onChange: (value: { enabled: boolean }) => void }) => ( - <button type="button" onClick={() => onChange({ enabled: true })}>memory-config</button> + <button type="button" onClick={() => onChange({ enabled: true })}> + memory-config + </button> ), })) vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ __esModule: true, default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>, + VarItem: ({ name, type }: { name: string; type: string }) => <div>{`${name}:${type}`}</div>, })) vi.mock('../use-config', () => ({ @@ -116,7 +142,9 @@ vi.mock('../use-config', () => ({ const mockUseConfig = vi.mocked(useConfig) -const createData = (overrides: Partial<ParameterExtractorNodeType> = {}): ParameterExtractorNodeType => ({ +const createData = ( + overrides: Partial<ParameterExtractorNodeType> = {}, +): ParameterExtractorNodeType => ({ title: 'Parameter Extractor', desc: '', type: BlockEnum.ParameterExtractor, @@ -128,12 +156,14 @@ const createData = (overrides: Partial<ParameterExtractorNodeType> = {}): Parame } as ParameterExtractorNodeType['model'], query: ['node-1', 'query'], reasoning_mode: ReasoningModeType.prompt, - parameters: [{ - name: 'city', - type: ParamType.string, - description: 'City name', - required: false, - }], + parameters: [ + { + name: 'city', + type: ParamType.string, + description: 'City name', + required: false, + }, + ], instruction: 'Extract city and budget', vision: { enabled: false, @@ -186,13 +216,7 @@ describe('parameter-extractor/panel', () => { it('wires model, parameter, instruction, memory, and reasoning actions to use-config', async () => { const user = userEvent.setup() - render( - <Panel - id="node-1" - data={createData()} - panelProps={panelProps} - />, - ) + render(<Panel id="node-1" data={createData()} panelProps={panelProps} />) await user.click(screen.getByRole('button', { name: 'set-model' })) await user.click(screen.getByRole('button', { name: 'set-params' })) @@ -205,7 +229,11 @@ describe('parameter-extractor/panel', () => { await user.click(screen.getByRole('button', { name: 'memory-config' })) await user.click(screen.getByRole('button', { name: 'set-reasoning-mode' })) - expect(handleModelChanged).toHaveBeenCalledWith({ provider: 'anthropic', modelId: 'claude-3-7-sonnet', mode: AppModeEnum.CHAT }) + expect(handleModelChanged).toHaveBeenCalledWith({ + provider: 'anthropic', + modelId: 'claude-3-7-sonnet', + mode: AppModeEnum.CHAT, + }) expect(handleCompletionParamsChange).toHaveBeenCalledWith({ temperature: 0.2 }) expect(handleInputVarChange).toHaveBeenCalledWith(['node-1', 'query']) expect(handleVisionResolutionEnabledChange).toHaveBeenCalledWith(true) diff --git a/web/app/components/workflow/nodes/parameter-extractor/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/parameter-extractor/__tests__/use-single-run-form-params.spec.ts index f3bc4da8837d37..d731a1d67d9a0c 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/parameter-extractor/__tests__/use-single-run-form-params.spec.ts @@ -28,7 +28,9 @@ const mockUseConfigVision = vi.mocked(useConfigVision) const mockUseAvailableVarList = vi.mocked(useAvailableVarList) const mockUseNodeCrud = vi.mocked(useNodeCrud) -const createData = (overrides: Partial<ParameterExtractorNodeType> = {}): ParameterExtractorNodeType => ({ +const createData = ( + overrides: Partial<ParameterExtractorNodeType> = {}, +): ParameterExtractorNodeType => ({ title: 'Parameter Extractor', desc: '', type: BlockEnum.ParameterExtractor, @@ -40,12 +42,14 @@ const createData = (overrides: Partial<ParameterExtractorNodeType> = {}): Parame } as ParameterExtractorNodeType['model'], query: ['node-1', 'query'], reasoning_mode: ReasoningModeType.prompt, - parameters: [{ - name: 'city', - type: ParamType.string, - description: 'City name', - required: false, - }], + parameters: [ + { + name: 'city', + type: ParamType.string, + description: 'City name', + required: false, + }, + ], instruction: 'Extract {{#node-2.answer#}}', vision: { enabled: true, @@ -68,67 +72,81 @@ describe('parameter-extractor/use-single-run-form-params', () => { isVisionModel: true, } as ReturnType<typeof useConfigVision>) mockUseAvailableVarList.mockReturnValue({ - availableVars: [{ - nodeId: 'node-3', - title: 'Vision Source', - vars: [{ - variable: 'image', - type: VarType.file, - }], - }], + availableVars: [ + { + nodeId: 'node-3', + title: 'Vision Source', + vars: [ + { + variable: 'image', + type: VarType.file, + }, + ], + }, + ], } as unknown as ReturnType<typeof useAvailableVarList>) }) it('builds variable and vision single-run forms and returns dependent vars', () => { const setRunInputData = vi.fn() - const getInputVars = vi.fn<() => InputVar[]>(() => [{ - label: 'Answer', - variable: '#node-2.answer#', - type: InputVarType.textInput, - required: true, - }]) + const getInputVars = vi.fn<() => InputVar[]>(() => [ + { + label: 'Answer', + variable: '#node-2.answer#', + type: InputVarType.textInput, + required: true, + }, + ]) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'node-1', - payload: createData(), - runInputData: { 'query': 'hello', '#context#': 'ctx', '#files#': ['file-1'] }, - runInputDataRef: { current: { 'query': 'hello', '#context#': 'ctx', '#files#': ['file-1'] } }, - getInputVars, - setRunInputData, - toVarInputs: () => [], - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'node-1', + payload: createData(), + runInputData: { query: 'hello', '#context#': 'ctx', '#files#': ['file-1'] }, + runInputDataRef: { current: { query: 'hello', '#context#': 'ctx', '#files#': ['file-1'] } }, + getInputVars, + setRunInputData, + toVarInputs: () => [], + }), + ) expect(result.current.forms).toHaveLength(2) - expect(result.current.forms[0]!.inputs).toEqual(expect.arrayContaining([ - expect.objectContaining({ - variable: 'query', - type: InputVarType.paragraph, - }), - expect.objectContaining({ - variable: '#node-2.answer#', - }), - ])) - expect(result.current.forms[1]!.inputs).toEqual([{ - label: 'image', - variable: '#files#', - type: InputVarType.singleFile, - required: false, - }]) + expect(result.current.forms[0]!.inputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + variable: 'query', + type: InputVarType.paragraph, + }), + expect.objectContaining({ + variable: '#node-2.answer#', + }), + ]), + ) + expect(result.current.forms[1]!.inputs).toEqual([ + { + label: 'image', + variable: '#files#', + type: InputVarType.singleFile, + required: false, + }, + ]) result.current.forms[0]!.onChange({ query: 'updated' }) result.current.forms[1]!.onChange({ '#files#': ['file-2'] }) expect(setRunInputData).toHaveBeenNthCalledWith(1, { - 'query': 'updated', + query: 'updated', '#context#': 'ctx', '#files#': ['file-1'], }) expect(setRunInputData).toHaveBeenNthCalledWith(2, { - 'query': 'hello', + query: 'hello', '#context#': 'ctx', '#files#': ['file-2'], }) - const availableVarOptions = mockUseAvailableVarList.mock.calls[0]![1] as { filterVar: (payload: { type: VarType }) => boolean } + const availableVarOptions = mockUseAvailableVarList.mock.calls[0]![1] as { + filterVar: (payload: { type: VarType }) => boolean + } expect(availableVarOptions.filterVar({ type: VarType.file })).toBe(true) expect(availableVarOptions.filterVar({ type: VarType.string })).toBe(false) @@ -143,20 +161,24 @@ describe('parameter-extractor/use-single-run-form-params', () => { }) it('skips malformed prompt variables when collecting dependent vars', () => { - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'node-1', - payload: createData(), - runInputData: {}, - runInputDataRef: { current: {} }, - getInputVars: () => [{ - label: 'Broken', - variable: undefined as unknown as string, - type: InputVarType.textInput, - required: false, - }], - setRunInputData: vi.fn(), - toVarInputs: () => [], - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'node-1', + payload: createData(), + runInputData: {}, + runInputDataRef: { current: {} }, + getInputVars: () => [ + { + label: 'Broken', + variable: undefined as unknown as string, + type: InputVarType.textInput, + required: false, + }, + ], + setRunInputData: vi.fn(), + toVarInputs: () => [], + }), + ) expect(result.current.getDependentVars()).toEqual([ ['node-1', 'query'], diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/__tests__/reasoning-mode-picker.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/__tests__/reasoning-mode-picker.spec.tsx index 68189872b090a4..ad63e0846eefcc 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/__tests__/reasoning-mode-picker.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/__tests__/reasoning-mode-picker.spec.tsx @@ -9,21 +9,15 @@ describe('parameter-extractor/reasoning-mode-picker', () => { const handleChange = vi.fn() const { rerender } = render( - <ReasoningModePicker - type={ReasoningModeType.prompt} - onChange={handleChange} - />, + <ReasoningModePicker type={ReasoningModeType.prompt} onChange={handleChange} />, ) - await user.click(screen.getByText('workflow.nodes.parameterExtractor.reasoningModeFunctionToolCalling')) - - rerender( - <ReasoningModePicker - type={ReasoningModeType.functionCall} - onChange={handleChange} - />, + await user.click( + screen.getByText('workflow.nodes.parameterExtractor.reasoningModeFunctionToolCalling'), ) + rerender(<ReasoningModePicker type={ReasoningModeType.functionCall} onChange={handleChange} />) + await user.click(screen.getByText('workflow.nodes.parameterExtractor.reasoningModePrompt')) expect(handleChange).toHaveBeenNthCalledWith(1, ReasoningModeType.functionCall) diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.branches.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.branches.spec.tsx index 7709098c991f0f..a4dc26649aa3aa 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.branches.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.branches.spec.tsx @@ -4,20 +4,20 @@ import ImportFromTool from '../import-from-tool' vi.mock('../../../../../block-selector', () => ({ __esModule: true, - default: ({ - onSelect, - }: { - onSelect: (type: string, toolInfo?: unknown) => void - }) => ( + default: ({ onSelect }: { onSelect: (type: string, toolInfo?: unknown) => void }) => ( <div> - <button type="button" onClick={() => onSelect('tool', undefined)}>select-missing-tool</button> + <button type="button" onClick={() => onSelect('tool', undefined)}> + select-missing-tool + </button> <button type="button" - onClick={() => onSelect('tool', { - provider_id: 'provider-1', - provider_type: 'unsupported', - tool_name: 'search', - })} + onClick={() => + onSelect('tool', { + provider_id: 'provider-1', + provider_type: 'unsupported', + tool_name: 'search', + }) + } > select-unsupported-tool </button> diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx index 7e13b8ef345515..b66eafe4ee3547 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx @@ -5,7 +5,10 @@ import userEvent from '@testing-library/user-event' import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import { CollectionType } from '@/app/components/tools/types' import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' -import { createTool, createToolProvider } from '@/app/components/workflow/block-selector/__tests__/factories' +import { + createTool, + createToolProvider, +} from '@/app/components/workflow/block-selector/__tests__/factories' import ImportFromTool from '../import-from-tool' vi.mock('reactflow', () => ({ @@ -41,11 +44,17 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useLanguage: () => 'en_US', })) -const createProviderWithTool = (type: CollectionType, providerId: string, toolName: string, toolLabel: string) => createToolProvider({ - id: providerId, - type, - tools: [createTool(toolName, toolLabel)], -}) +const createProviderWithTool = ( + type: CollectionType, + providerId: string, + toolName: string, + toolLabel: string, +) => + createToolProvider({ + id: providerId, + type, + tools: [createTool(toolName, toolLabel)], + }) const createToolParameter = (overrides: Partial<ToolParameter> = {}): ToolParameter => ({ name: 'field', @@ -64,14 +73,11 @@ const renderImportFromTool = (ui: React.ReactElement) => { const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({ systemFeatures: { enable_marketplace: false }, }) - return renderWorkflowComponent( - <SystemFeaturesWrapper>{ui}</SystemFeaturesWrapper>, - { - hooksStoreProps: { - availableNodesMetaData: { nodes: [] }, - }, + return renderWorkflowComponent(<SystemFeaturesWrapper>{ui}</SystemFeaturesWrapper>, { + hooksStoreProps: { + availableNodesMetaData: { nodes: [] }, }, - ) + }) } describe('parameter-extractor/extract-parameter/import-from-tool', () => { @@ -85,7 +91,12 @@ describe('parameter-extractor/extract-parameter/import-from-tool', () => { it('imports llm parameters from a built-in tool through the real block selector', async () => { const user = userEvent.setup() const handleImport = vi.fn() - const provider = createProviderWithTool(CollectionType.builtIn, 'provider-1', 'search', 'Search Tool') + const provider = createProviderWithTool( + CollectionType.builtIn, + 'provider-1', + 'search', + 'Search Tool', + ) const builtInParameters = [ createToolParameter({ name: 'city', @@ -113,20 +124,27 @@ describe('parameter-extractor/extract-parameter/import-from-tool', () => { await user.click(await screen.findByText('Search Tool')) await waitFor(() => { - expect(handleImport).toHaveBeenCalledWith([{ - name: 'city', - type: 'string', - required: true, - description: 'City name', - options: ['Draft'], - }]) + expect(handleImport).toHaveBeenCalledWith([ + { + name: 'city', + type: 'string', + required: true, + description: 'City name', + options: ['Draft'], + }, + ]) }) }) it('imports llm parameters from workflow tool collections', async () => { const user = userEvent.setup() const handleImport = vi.fn() - const provider = createProviderWithTool(CollectionType.workflow, 'workflow-1', 'transform', 'Workflow Tool') + const provider = createProviderWithTool( + CollectionType.workflow, + 'workflow-1', + 'transform', + 'Workflow Tool', + ) const workflowParameters = [ createToolParameter({ name: 'summary', @@ -144,13 +162,15 @@ describe('parameter-extractor/extract-parameter/import-from-tool', () => { await user.click(await screen.findByText('Workflow Tool')) await waitFor(() => { - expect(handleImport).toHaveBeenCalledWith([{ - name: 'summary', - type: 'string', - required: false, - description: 'Summary text', - options: [], - }]) + expect(handleImport).toHaveBeenCalledWith([ + { + name: 'summary', + type: 'string', + required: false, + description: 'Summary text', + options: [], + }, + ]) }) }) }) diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/item.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/item.spec.tsx index 4755c28cf46891..aea4dacd31fb17 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/item.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/item.spec.tsx @@ -22,7 +22,9 @@ describe('parameter-extractor/extract-parameter/item', () => { expect(screen.getByText('city')).toBeInTheDocument() expect(screen.getByText(ParamType.string)).toBeInTheDocument() expect(screen.getByText('City name')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.parameterExtractor.addExtractParameterContent.required')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.parameterExtractor.addExtractParameterContent.required'), + ).toBeInTheDocument() const actionButtons = container.querySelectorAll('.cursor-pointer.rounded-md.p-1') fireEvent.click(actionButtons[0] as HTMLElement) diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/list.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/list.spec.tsx index 207d4f93a96a70..42888c11b06439 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/list.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/list.spec.tsx @@ -3,12 +3,14 @@ import userEvent from '@testing-library/user-event' import { ParamType } from '../../../types' import List from '../list' -const createParam = (overrides: Partial<{ - name: string - type: ParamType - description: string - required: boolean -}> = {}) => ({ +const createParam = ( + overrides: Partial<{ + name: string + type: ParamType + description: string + required: boolean + }> = {}, +) => ({ name: 'city', type: ParamType.string, description: 'City name', @@ -18,15 +20,11 @@ const createParam = (overrides: Partial<{ describe('parameter-extractor/extract-parameter/list', () => { it('renders the empty placeholder when no parameter is configured', () => { - render( - <List - readonly={false} - list={[]} - onChange={vi.fn()} - />, - ) + render(<List readonly={false} list={[]} onChange={vi.fn()} />) - expect(screen.getByText('workflow.nodes.parameterExtractor.extractParametersNotSet')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.parameterExtractor.extractParametersNotSet'), + ).toBeInTheDocument() }) // TODO: Fix this test. @@ -35,56 +33,52 @@ describe('parameter-extractor/extract-parameter/list', () => { const user = userEvent.setup() const handleChange = vi.fn() const { rerender } = render( - <List - readonly={false} - list={[createParam()]} - onChange={handleChange} - />, + <List readonly={false} list={[createParam()]} onChange={handleChange} />, ) const cityLabel = screen.getByText('city') const cityRow = cityLabel.closest('.group.relative') - if (!cityRow) - throw new Error('Failed to locate city row') + if (!cityRow) throw new Error('Failed to locate city row') const editAndDeleteButtons = cityRow.querySelectorAll('.cursor-pointer.rounded-md.p-1') fireEvent.click(editAndDeleteButtons[0] as HTMLElement) await screen.findByRole('dialog') const dialog = screen.getAllByRole('dialog').at(-1)! fireEvent.change( - within(dialog).getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder'), + within(dialog).getByPlaceholderText( + 'workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder', + ), { target: { value: 'cityname' } }, ) fireEvent.change( - within(dialog).getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder'), + within(dialog).getByPlaceholderText( + 'workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder', + ), { target: { value: 'Updated city description' } }, ) fireEvent.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) await waitFor(() => { expect(handleChange).toHaveBeenCalled() - expect(handleChange.mock.lastCall?.[0]).toEqual([{ - name: 'cityname', - type: ParamType.string, - description: 'Updated city description', - required: false, - }]) + expect(handleChange.mock.lastCall?.[0]).toEqual([ + { + name: 'cityname', + type: ParamType.string, + description: 'Updated city description', + required: false, + }, + ]) }) handleChange.mockClear() rerender( - <List - readonly={false} - list={[createParam({ name: 'budget' })]} - onChange={handleChange} - />, + <List readonly={false} list={[createParam({ name: 'budget' })]} onChange={handleChange} />, ) const budgetLabel = screen.getByText('budget') const budgetRow = budgetLabel.closest('.group.relative') - if (!budgetRow) - throw new Error('Failed to locate budget row') + if (!budgetRow) throw new Error('Failed to locate budget row') const deleteButtons = budgetRow.querySelectorAll('.cursor-pointer.rounded-md.p-1') fireEvent.click(deleteButtons[1] as HTMLElement) diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/update.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/update.spec.tsx index 34d788be50ce2d..7a791536886988 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/update.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/update.spec.tsx @@ -32,24 +32,25 @@ describe('parameter-extractor/extract-parameter/update', () => { it('opens from the add trigger and saves a new parameter', async () => { const handleSave = vi.fn() - render( - <Update - type="add" - onSave={handleSave} - />, - ) + render(<Update type="add" onSave={handleSave} />) const existingDialogs = screen.queryAllByRole('dialog').length - fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.parameterExtractor.addExtractParameter' })) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.nodes.parameterExtractor.addExtractParameter' }), + ) const dialogs = await waitFor(() => { const nextDialogs = screen.getAllByRole('dialog') expect(nextDialogs.length).toBeGreaterThan(existingDialogs) return nextDialogs }) const dialog = dialogs.at(-1)! - const nameInput = within(dialog).getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder') - const descriptionInput = within(dialog).getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder') + const nameInput = within(dialog).getByPlaceholderText( + 'workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder', + ) + const descriptionInput = within(dialog).getByPlaceholderText( + 'workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder', + ) fireEvent.change(nameInput, { target: { value: 'budget' }, @@ -66,12 +67,15 @@ describe('parameter-extractor/extract-parameter/update', () => { fireEvent.click(within(dialog).getByRole('button', { name: 'common.operation.add' })) await waitFor(() => { - expect(handleSave).toHaveBeenCalledWith({ - name: 'budget', - type: ParamType.string, - description: 'Budget amount', - required: false, - }, undefined) + expect(handleSave).toHaveBeenCalledWith( + { + name: 'budget', + type: ParamType.string, + description: 'Budget amount', + required: false, + }, + undefined, + ) }) }) @@ -79,16 +83,13 @@ describe('parameter-extractor/extract-parameter/update', () => { const user = userEvent.setup() const handleSave = vi.fn() - render( - <Update - type="add" - onSave={handleSave} - />, - ) + render(<Update type="add" onSave={handleSave} />) const existingDialogs = screen.queryAllByRole('dialog').length - await user.click(screen.getByRole('button', { name: 'workflow.nodes.parameterExtractor.addExtractParameter' })) + await user.click( + screen.getByRole('button', { name: 'workflow.nodes.parameterExtractor.addExtractParameter' }), + ) const dialogs = await waitFor(() => { const nextDialogs = screen.getAllByRole('dialog') expect(nextDialogs.length).toBeGreaterThan(existingDialogs) @@ -96,13 +97,22 @@ describe('parameter-extractor/extract-parameter/update', () => { }) const dialog = dialogs.at(-1)! - fireEvent.change(within(dialog).getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder'), { - target: { value: '1bad' }, - }) + fireEvent.change( + within(dialog).getByPlaceholderText( + 'workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder', + ), + { + target: { value: '1bad' }, + }, + ) expect(handleSave).not.toHaveBeenCalled() expect(mockToast.error).toHaveBeenCalled() - expect(within(dialog).getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder')).toHaveValue('') + expect( + within(dialog).getByPlaceholderText( + 'workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder', + ), + ).toHaveValue('') }) it('renders the edit modal immediately and validates required fields', async () => { diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx index d9474d4e15b61e..0a9a754891b18c 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx @@ -8,18 +8,11 @@ import type { } from '@/app/components/workflow/block-selector/types' import type { BlockEnum } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { CollectionType } from '@/app/components/tools/types' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools } from '@/service/use-tools' import { canFindTool } from '@/utils' import BlockSelector from '../../../../block-selector' @@ -36,13 +29,11 @@ function toParmExactParams(toolParams: ToolParameter[], lan: string): Param[] { type: item.type as ParamType, required: item.required, description: item.llm_description, - options: item.options?.map(option => option.label[lan] || option.label.en_US) || [], + options: item.options?.map((option) => option.label[lan] || option.label.en_US) || [], } }) } -const ImportFromTool: FC<Props> = ({ - onImport, -}) => { +const ImportFromTool: FC<Props> = ({ onImport }) => { const { t } = useTranslation() const language = useLanguage() @@ -50,43 +41,49 @@ const ImportFromTool: FC<Props> = ({ const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() - const handleSelectTool = useCallback((_type: BlockEnum, toolInfo?: BlockDefaultValue) => { - if (!toolInfo || 'datasource_name' in toolInfo || !('tool_name' in toolInfo)) - return + const handleSelectTool = useCallback( + (_type: BlockEnum, toolInfo?: BlockDefaultValue) => { + if (!toolInfo || 'datasource_name' in toolInfo || !('tool_name' in toolInfo)) return - const { provider_id, provider_type, tool_name } = toolInfo as ToolDefaultValue - const currentTools = (() => { - switch (provider_type) { - case CollectionType.builtIn: - return buildInTools || [] - case CollectionType.custom: - return customTools || [] - case CollectionType.workflow: - return workflowTools || [] - default: - return [] - } - })() - const currCollection = currentTools.find(item => canFindTool(item.id, provider_id)) - const currTool = currCollection?.tools.find(tool => tool.name === tool_name) - const toExactParams = (currTool?.parameters || []).filter(item => item.form === 'llm') - const formattedParams = toParmExactParams(toExactParams, language) - onImport(formattedParams) - }, [buildInTools, customTools, language, onImport, workflowTools]) + const { provider_id, provider_type, tool_name } = toolInfo as ToolDefaultValue + const currentTools = (() => { + switch (provider_type) { + case CollectionType.builtIn: + return buildInTools || [] + case CollectionType.custom: + return customTools || [] + case CollectionType.workflow: + return workflowTools || [] + default: + return [] + } + })() + const currCollection = currentTools.find((item) => canFindTool(item.id, provider_id)) + const currTool = currCollection?.tools.find((tool) => tool.name === tool_name) + const toExactParams = (currTool?.parameters || []).filter((item) => item.form === 'llm') + const formattedParams = toParmExactParams(toExactParams, language) + onImport(formattedParams) + }, + [buildInTools, customTools, language, onImport, workflowTools], + ) - const renderTrigger = useCallback((open: boolean) => { - return ( - <div> - <div className={cn( - 'flex h-6 cursor-pointer items-center rounded-md px-2 text-xs font-medium text-text-tertiary hover:bg-state-base-hover', - open && 'bg-state-base-hover', - )} - > - {t($ => $[`${i18nPrefix}.importFromTool`], { ns: 'workflow' })} + const renderTrigger = useCallback( + (open: boolean) => { + return ( + <div> + <div + className={cn( + 'flex h-6 cursor-pointer items-center rounded-md px-2 text-xs font-medium text-text-tertiary hover:bg-state-base-hover', + open && 'bg-state-base-hover', + )} + > + {t(($) => $[`${i18nPrefix}.importFromTool`], { ns: 'workflow' })} + </div> </div> - </div> - ) - }, [t]) + ) + }, + [t], + ) return ( <BlockSelector diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/item.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/item.tsx index 7980b2125f48f2..cf3de7b0810a40 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/item.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/item.tsx @@ -1,10 +1,7 @@ 'use client' import type { FC } from 'react' import type { Param } from '../../types' -import { - RiDeleteBinLine, - RiEditLine, -} from '@remixicon/react' +import { RiDeleteBinLine, RiEditLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' @@ -17,11 +14,7 @@ type Props = Readonly<{ onDelete: () => void }> -const Item: FC<Props> = ({ - payload, - onEdit, - onDelete, -}) => { +const Item: FC<Props> = ({ payload, onEdit, onDelete }) => { const { t } = useTranslation() return ( @@ -30,20 +23,21 @@ const Item: FC<Props> = ({ <div className="flex items-center"> <Variable02 className="size-3.5 text-text-accent-secondary" /> <div className="ml-1 text-[13px] font-medium text-text-primary">{payload.name}</div> - <div className="ml-2 text-xs font-normal text-text-tertiary capitalize">{payload.type}</div> + <div className="ml-2 text-xs font-normal text-text-tertiary capitalize"> + {payload.type} + </div> </div> {payload.required && ( - <div className="text-xs/4 font-normal text-text-tertiary uppercase">{t($ => $[`${i18nPrefix}.addExtractParameterContent.required`], { ns: 'workflow' })}</div> + <div className="text-xs/4 font-normal text-text-tertiary uppercase"> + {t(($) => $[`${i18nPrefix}.addExtractParameterContent.required`], { ns: 'workflow' })} + </div> )} </div> - <div className="mt-0.5 text-xs leading-[18px] font-normal text-text-tertiary">{payload.description}</div> - <div - className="absolute top-0 right-0 hidden h-full w-[119px] items-center justify-end space-x-1 rounded-lg bg-linear-to-l from-components-panel-on-panel-item-bg to-background-gradient-mask-transparent pr-1 group-hover:flex" - > - <div - className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover" - onClick={onEdit} - > + <div className="mt-0.5 text-xs leading-[18px] font-normal text-text-tertiary"> + {payload.description} + </div> + <div className="absolute top-0 right-0 hidden h-full w-[119px] items-center justify-end space-x-1 rounded-lg bg-linear-to-l from-components-panel-on-panel-item-bg to-background-gradient-mask-transparent pr-1 group-hover:flex"> + <div className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover" onClick={onEdit}> <RiEditLine className="size-4 text-text-tertiary" /> </div> diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/list.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/list.tsx index 4f65848019eb78..8c1b5cd4422fc1 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/list.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/list.tsx @@ -18,48 +18,52 @@ type Props = Readonly<{ onChange: (list: Param[], moreInfo?: MoreInfo) => void }> -const List: FC<Props> = ({ - list, - onChange, -}) => { +const List: FC<Props> = ({ list, onChange }) => { const { t } = useTranslation() - const [isShowEditModal, { - setTrue: showEditModal, - setFalse: hideEditModal, - }] = useBoolean(false) + const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false) - const handleItemChange = useCallback((index: number) => { - return (payload: Param, moreInfo?: MoreInfo) => { - const newList = list.map((item, i) => { - if (i === index) - return payload + const handleItemChange = useCallback( + (index: number) => { + return (payload: Param, moreInfo?: MoreInfo) => { + const newList = list.map((item, i) => { + if (i === index) return payload - return item - }) - onChange(newList, moreInfo) - hideEditModal() - } - }, [hideEditModal, list, onChange]) + return item + }) + onChange(newList, moreInfo) + hideEditModal() + } + }, + [hideEditModal, list, onChange], + ) const [currEditItemIndex, setCurrEditItemIndex] = useState<number>(-1) - const handleItemEdit = useCallback((index: number) => { - return () => { - setCurrEditItemIndex(index) - showEditModal() - } - }, [showEditModal]) + const handleItemEdit = useCallback( + (index: number) => { + return () => { + setCurrEditItemIndex(index) + showEditModal() + } + }, + [showEditModal], + ) - const handleItemDelete = useCallback((index: number) => { - return () => { - const newList = list.filter((_, i) => i !== index) - onChange(newList) - } - }, [list, onChange]) + const handleItemDelete = useCallback( + (index: number) => { + return () => { + const newList = list.filter((_, i) => i !== index) + onChange(newList) + } + }, + [list, onChange], + ) if (list.length === 0) { return ( - <ListNoDataPlaceholder>{t($ => $[`${i18nPrefix}.extractParametersNotSet`], { ns: 'workflow' })}</ListNoDataPlaceholder> + <ListNoDataPlaceholder> + {t(($) => $[`${i18nPrefix}.extractParametersNotSet`], { ns: 'workflow' })} + </ListNoDataPlaceholder> ) } return ( diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx index 4473eb179be94b..6675b4da62fa5d 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx @@ -4,7 +4,14 @@ import type { Param } from '../../types' import type { MoreInfo } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { Switch } from '@langgenius/dify-ui/switch' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' @@ -36,49 +43,56 @@ type Props = Readonly<{ onCancel?: () => void }> -const TYPES = [ParamType.string, ParamType.number, ParamType.bool, ParamType.arrayString, ParamType.arrayNumber, ParamType.arrayObject, ParamType.arrayBool] +const TYPES = [ + ParamType.string, + ParamType.number, + ParamType.bool, + ParamType.arrayString, + ParamType.arrayNumber, + ParamType.arrayObject, + ParamType.arrayBool, +] -const AddExtractParameter: FC<Props> = ({ - type, - payload, - onSave, - onCancel, -}) => { +const AddExtractParameter: FC<Props> = ({ type, payload, onSave, onCancel }) => { const { t } = useTranslation() const isAdd = type === 'add' - const [param, setParam] = useState<Param>(isAdd ? DEFAULT_PARAM : payload as Param) + const [param, setParam] = useState<Param>(isAdd ? DEFAULT_PARAM : (payload as Param)) const [renameInfo, setRenameInfo] = useState<MoreInfo | undefined>(undefined) - const handleParamChange = useCallback((key: string) => { - return (value: any) => { - if (key === 'name') { - const { isValid, errorKey, errorMessageKey } = checkKeys([value], true) - if (!isValid) { - toast.error(t($ => $[`varKeyError.${errorMessageKey}`], { ns: 'appDebug', key: errorKey })) - return - } - } - setRenameInfo(key === 'name' - ? { - type: ChangeType.changeVarName, - payload: { - beforeKey: param.name, - afterKey: value, - }, + const handleParamChange = useCallback( + (key: string) => { + return (value: any) => { + if (key === 'name') { + const { isValid, errorKey, errorMessageKey } = checkKeys([value], true) + if (!isValid) { + toast.error( + t(($) => $[`varKeyError.${errorMessageKey}`], { ns: 'appDebug', key: errorKey }), + ) + return } - : undefined) - setParam((prev) => { - return { - ...prev, - [key]: value, } - }) - } - }, [param.name, t]) + setRenameInfo( + key === 'name' + ? { + type: ChangeType.changeVarName, + payload: { + beforeKey: param.name, + afterKey: value, + }, + } + : undefined, + ) + setParam((prev) => { + return { + ...prev, + [key]: value, + } + }) + } + }, + [param.name, t], + ) - const [isShowModal, { - setTrue: doShowModal, - setFalse: doHideModal, - }] = useBoolean(!isAdd) + const [isShowModal, { setTrue: doShowModal, setFalse: doHideModal }] = useBoolean(!isAdd) const hideModal = useCallback(() => { doHideModal() @@ -86,8 +100,7 @@ const AddExtractParameter: FC<Props> = ({ }, [onCancel, doHideModal]) const showAddModal = useCallback(() => { - if (isAdd) - setParam(DEFAULT_PARAM) + if (isAdd) setParam(DEFAULT_PARAM) doShowModal() }, [isAdd, doShowModal]) @@ -95,11 +108,26 @@ const AddExtractParameter: FC<Props> = ({ const checkValid = useCallback(() => { let errMessage = '' if (!param.name) - errMessage = t($ => $[`${errorI18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.addExtractParameterContent.name`], { ns: 'workflow' }) }) - if (!errMessage && param.type === ParamType.select && (!param.options || param.options.length === 0)) - errMessage = t($ => $[`${errorI18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['variableConfig.options'], { ns: 'appDebug' }) }) + errMessage = t(($) => $[`${errorI18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.addExtractParameterContent.name`], { ns: 'workflow' }), + }) + if ( + !errMessage && + param.type === ParamType.select && + (!param.options || param.options.length === 0) + ) + errMessage = t(($) => $[`${errorI18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['variableConfig.options'], { ns: 'appDebug' }), + }) if (!errMessage && !param.description) - errMessage = t($ => $[`${errorI18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.addExtractParameterContent.description`], { ns: 'workflow' }) }) + errMessage = t(($) => $[`${errorI18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.addExtractParameterContent.description`], { + ns: 'workflow', + }), + }) if (errMessage) { toast.error(errMessage) @@ -109,8 +137,7 @@ const AddExtractParameter: FC<Props> = ({ }, [param, t]) const handleSave = useCallback(() => { - if (!checkValid()) - return + if (!checkValid()) return onSave(param, renameInfo) hideModal() @@ -121,7 +148,7 @@ const AddExtractParameter: FC<Props> = ({ {isAdd && ( <button type="button" - aria-label={t($ => $[`${i18nPrefix}.addExtractParameter`], { ns: 'workflow' })} + aria-label={t(($) => $[`${i18nPrefix}.addExtractParameter`], { ns: 'workflow' })} className="mx-1 cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={showAddModal} > @@ -132,34 +159,43 @@ const AddExtractParameter: FC<Props> = ({ <Dialog open onOpenChange={(open) => { - if (!open) - hideModal() + if (!open) hideModal() }} > <DialogContent className="w-[400px]! max-w-[400px]! overflow-hidden! border-none p-4! text-left align-middle"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $[`${i18nPrefix}.addExtractParameter`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.addExtractParameter`], { ns: 'workflow' })} </DialogTitle> <div> <div className="space-y-2"> - <Field title={t($ => $[`${i18nPrefix}.addExtractParameterContent.name`], { ns: 'workflow' })}> + <Field + title={t(($) => $[`${i18nPrefix}.addExtractParameterContent.name`], { + ns: 'workflow', + })} + > <Input value={param.name} - onChange={e => handleParamChange('name')(e.target.value)} - placeholder={t($ => $[`${i18nPrefix}.addExtractParameterContent.namePlaceholder`], { ns: 'workflow' })!} + onChange={(e) => handleParamChange('name')(e.target.value)} + placeholder={ + t(($) => $[`${i18nPrefix}.addExtractParameterContent.namePlaceholder`], { + ns: 'workflow', + })! + } /> </Field> - <Field title={t($ => $[`${i18nPrefix}.addExtractParameterContent.type`], { ns: 'workflow' })}> + <Field + title={t(($) => $[`${i18nPrefix}.addExtractParameterContent.type`], { + ns: 'workflow', + })} + > <Select value={param.type} - onValueChange={value => value && handleParamChange('type')(value)} + onValueChange={(value) => value && handleParamChange('type')(value)} > - <SelectTrigger className="w-full capitalize"> - {param.type} - </SelectTrigger> + <SelectTrigger className="w-full capitalize">{param.type}</SelectTrigger> <SelectContent> - {TYPES.map(type => ( + {TYPES.map((type) => ( <SelectItem key={type} value={type} className="capitalize"> <SelectItemText className="capitalize">{type}</SelectItemText> <SelectItemIndicator /> @@ -169,28 +205,61 @@ const AddExtractParameter: FC<Props> = ({ </Select> </Field> {param.type === ParamType.select && ( - <Field title={t($ => $['variableConfig.options'], { ns: 'appDebug' })}> - <ConfigSelect options={param.options || []} onChange={handleParamChange('options')} /> + <Field title={t(($) => $['variableConfig.options'], { ns: 'appDebug' })}> + <ConfigSelect + options={param.options || []} + onChange={handleParamChange('options')} + /> </Field> )} - <Field title={t($ => $[`${i18nPrefix}.addExtractParameterContent.description`], { ns: 'workflow' })}> + <Field + title={t(($) => $[`${i18nPrefix}.addExtractParameterContent.description`], { + ns: 'workflow', + })} + > <Textarea - aria-label={t($ => $[`${i18nPrefix}.addExtractParameterContent.description`], { ns: 'workflow' })} + aria-label={t( + ($) => $[`${i18nPrefix}.addExtractParameterContent.description`], + { ns: 'workflow' }, + )} value={param.description} - onValueChange={value => handleParamChange('description')(value)} - placeholder={t($ => $[`${i18nPrefix}.addExtractParameterContent.descriptionPlaceholder`], { ns: 'workflow' })!} + onValueChange={(value) => handleParamChange('description')(value)} + placeholder={ + t( + ($) => $[`${i18nPrefix}.addExtractParameterContent.descriptionPlaceholder`], + { ns: 'workflow' }, + )! + } /> </Field> - <Field title={t($ => $[`${i18nPrefix}.addExtractParameterContent.required`], { ns: 'workflow' })}> + <Field + title={t(($) => $[`${i18nPrefix}.addExtractParameterContent.required`], { + ns: 'workflow', + })} + > <> - <div className="mb-1.5 text-xs leading-[18px] font-normal text-text-tertiary">{t($ => $[`${i18nPrefix}.addExtractParameterContent.requiredContent`], { ns: 'workflow' })}</div> - <Switch size="lg" checked={param.required ?? false} onCheckedChange={handleParamChange('required')} /> + <div className="mb-1.5 text-xs leading-[18px] font-normal text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.addExtractParameterContent.requiredContent`], { + ns: 'workflow', + })} + </div> + <Switch + size="lg" + checked={param.required ?? false} + onCheckedChange={handleParamChange('required')} + /> </> </Field> </div> <div className="mt-4 flex justify-end space-x-2"> - <Button className="w-[95px]!" onClick={hideModal}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button className="w-[95px]!" variant="primary" onClick={handleSave}>{isAdd ? t($ => $['operation.add'], { ns: 'common' }) : t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button className="w-[95px]!" onClick={hideModal}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button className="w-[95px]!" variant="primary" onClick={handleSave}> + {isAdd + ? t(($) => $['operation.add'], { ns: 'common' }) + : t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> </div> </DialogContent> diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/reasoning-mode-picker.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/reasoning-mode-picker.tsx index 02158dcfacb9e8..64b663e07122da 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/reasoning-mode-picker.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/reasoning-mode-picker.tsx @@ -14,37 +14,36 @@ type Props = Readonly<{ onChange: (type: ReasoningModeType) => void }> -const ReasoningModePicker: FC<Props> = ({ - type, - onChange, -}) => { +const ReasoningModePicker: FC<Props> = ({ type, onChange }) => { const { t } = useTranslation() - const handleChange = useCallback((type: ReasoningModeType) => { - return () => { - onChange(type) - } - }, [onChange]) + const handleChange = useCallback( + (type: ReasoningModeType) => { + return () => { + onChange(type) + } + }, + [onChange], + ) return ( <Field - title={t($ => $[`${i18nPrefix}.reasoningMode`], { ns: 'workflow' })} - tooltip={t($ => $[`${i18nPrefix}.reasoningModeTip`], { ns: 'workflow' })!} + title={t(($) => $[`${i18nPrefix}.reasoningMode`], { ns: 'workflow' })} + tooltip={t(($) => $[`${i18nPrefix}.reasoningModeTip`], { ns: 'workflow' })!} > <div className="grid grid-cols-2 gap-x-1"> <OptionCard - title={t($ => $[`${i18nPrefix}.reasoningModeFunctionToolCalling`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.reasoningModeFunctionToolCalling`], { ns: 'workflow' })} onSelect={handleChange(ReasoningModeType.functionCall)} selected={type === ReasoningModeType.functionCall} /> <OptionCard - title={t($ => $[`${i18nPrefix}.reasoningModePrompt`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.reasoningModePrompt`], { ns: 'workflow' })} selected={type === ReasoningModeType.prompt} onSelect={handleChange(ReasoningModeType.prompt)} /> </div> </Field> - ) } export default React.memo(ReasoningModePicker) diff --git a/web/app/components/workflow/nodes/parameter-extractor/default.ts b/web/app/components/workflow/nodes/parameter-extractor/default.ts index 6fe71149f05b05..51438d384b5926 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/default.ts +++ b/web/app/components/workflow/nodes/parameter-extractor/default.ts @@ -34,32 +34,76 @@ const nodeDefault: NodeDefault<ParameterExtractorNodeType> = { checkValid(payload: ParameterExtractorNodeType, t: TFunction<'workflow'>) { let errorMessages = '' if (!errorMessages && (!payload.query || payload.query.length === 0)) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}nodes.parameterExtractor.inputVar`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}nodes.parameterExtractor.inputVar`], { ns: 'workflow' }), + }) if (!errorMessages && !payload.model.provider) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}errorMsg.fields.model`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}errorMsg.fields.model`], { ns: 'workflow' }), + }) if (!errorMessages && (!payload.parameters || payload.parameters.length === 0)) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}nodes.parameterExtractor.extractParameters`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}nodes.parameterExtractor.extractParameters`], { + ns: 'workflow', + }), + }) if (!errorMessages) { payload.parameters.forEach((param) => { - if (errorMessages) - return + if (errorMessages) return if (!param.name) { - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}nodes.parameterExtractor.addExtractParameterContent.namePlaceholder`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t( + ($) => + $[ + `${i18nPrefix}nodes.parameterExtractor.addExtractParameterContent.namePlaceholder` + ], + { ns: 'workflow' }, + ), + }) return } if (!param.type) { - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}nodes.parameterExtractor.addExtractParameterContent.typePlaceholder`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t( + ($) => + $[ + `${i18nPrefix}nodes.parameterExtractor.addExtractParameterContent.typePlaceholder` + ], + { ns: 'workflow' }, + ), + }) return } if (!param.description) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t( + ($) => + $[ + `${i18nPrefix}nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder` + ], + { ns: 'workflow' }, + ), + }) }) } - if (!errorMessages && payload.vision?.enabled && !payload.vision.configs?.variable_selector?.length) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}errorMsg.fields.visionVariable`], { ns: 'workflow' }) }) + if ( + !errorMessages && + payload.vision?.enabled && + !payload.vision.configs?.variable_selector?.length + ) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}errorMsg.fields.visionVariable`], { ns: 'workflow' }), + }) return { isValid: !errorMessages, errorMessage: errorMessages, diff --git a/web/app/components/workflow/nodes/parameter-extractor/node.tsx b/web/app/components/workflow/nodes/parameter-extractor/node.tsx index 388cdf488a3add..00eee0ce006da7 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/node.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/node.tsx @@ -2,18 +2,12 @@ import type { FC } from 'react' import type { ParameterExtractorNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' import * as React from 'react' -import { - useTextGenerationCurrentProviderAndModelAndModelList, -} from '@/app/components/header/account-setting/model-provider-page/hooks' +import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' -const Node: FC<NodeProps<ParameterExtractorNodeType>> = ({ - data, -}) => { +const Node: FC<NodeProps<ParameterExtractorNodeType>> = ({ data }) => { const { provider, name: modelId } = data.model || {} - const { - textGenerationModelList, - } = useTextGenerationCurrentProviderAndModelAndModelList() + const { textGenerationModelList } = useTextGenerationCurrentProviderAndModelAndModelList() const hasSetModel = provider && modelId return ( <div className="mb-1 px-3 py-1"> diff --git a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx index 390d1e75240aa1..8453b8b0188987 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx @@ -23,10 +23,7 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.parameterExtractor' const i18nCommonPrefix = 'common' -const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { @@ -59,10 +56,7 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({ return ( <div className="pt-2"> <div className="space-y-4 px-4"> - <Field - title={t($ => $[`${i18nCommonPrefix}.model`], { ns: 'workflow' })} - required - > + <Field title={t(($) => $[`${i18nCommonPrefix}.model`], { ns: 'workflow' })} required> <ModelParameterModal popupClassName="w-[387px]!" isInWorkflow @@ -79,10 +73,7 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({ availableNodes={availableNodesWithParent} /> </Field> - <Field - title={t($ => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })} - required - > + <Field title={t(($) => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })} required> <> <VarReferencePicker readonly={readOnly} @@ -105,20 +96,16 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({ onConfigChange={handleVisionResolutionChange} /> <Field - title={t($ => $[`${i18nPrefix}.extractParameters`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.extractParameters`], { ns: 'workflow' })} required operations={ - !readOnly - ? ( - <div className="flex items-center space-x-1"> - {!readOnly && ( - <ImportFromTool onImport={handleImportFromTool} /> - )} - {!readOnly && (<div className="h-3 w-px bg-divider-regular"></div>)} - <AddExtractParameter type="add" onSave={addExtractParameter} /> - </div> - ) - : undefined + !readOnly ? ( + <div className="flex items-center space-x-1"> + {!readOnly && <ImportFromTool onImport={handleImportFromTool} />} + {!readOnly && <div className="h-3 w-px bg-divider-regular"></div>} + <AddExtractParameter type="add" onSave={addExtractParameter} /> + </div> + ) : undefined } > <ExtractParameter @@ -128,18 +115,20 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({ /> </Field> <Editor - title={( + title={ <div className="flex items-center space-x-1"> - <span className="uppercase">{t($ => $[`${i18nPrefix}.instruction`], { ns: 'workflow' })}</span> + <span className="uppercase"> + {t(($) => $[`${i18nPrefix}.instruction`], { ns: 'workflow' })} + </span> <Infotip - aria-label={t($ => $[`${i18nPrefix}.instructionTip`], { ns: 'workflow' })} + aria-label={t(($) => $[`${i18nPrefix}.instructionTip`], { ns: 'workflow' })} className="ml-0.5 size-3.5" popupClassName="w-[120px]" > - {t($ => $[`${i18nPrefix}.instructionTip`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.instructionTip`], { ns: 'workflow' })} </Infotip> </div> - )} + } value={inputs.instruction} onChange={handleInstructionChange} readOnly={readOnly} @@ -151,7 +140,7 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({ availableNodes={availableNodesWithParent} /> </div> - <FieldCollapse title={t($ => $[`${i18nPrefix}.advancedSetting`], { ns: 'workflow' })}> + <FieldCollapse title={t(($) => $[`${i18nPrefix}.advancedSetting`], { ns: 'workflow' })}> <> {/* Memory */} {isChatMode && ( @@ -191,17 +180,21 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({ <VarItem name="__is_success" type={VarType.number} - description={t($ => $[`${i18nPrefix}.outputVars.isSuccess`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.isSuccess`], { + ns: 'workflow', + })} /> <VarItem name="__reason" type={VarType.string} - description={t($ => $[`${i18nPrefix}.outputVars.errorReason`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.errorReason`], { + ns: 'workflow', + })} /> <VarItem name="__usage" type="object" - description={t($ => $[`${i18nPrefix}.outputVars.usage`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.usage`], { ns: 'workflow' })} /> </> </OutputVars> diff --git a/web/app/components/workflow/nodes/parameter-extractor/types.ts b/web/app/components/workflow/nodes/parameter-extractor/types.ts index 49e1041547dcda..5fe851be9c856e 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/types.ts +++ b/web/app/components/workflow/nodes/parameter-extractor/types.ts @@ -1,4 +1,10 @@ -import type { CommonNodeType, Memory, ModelConfig, ValueSelector, VisionSetting } from '@/app/components/workflow/types' +import type { + CommonNodeType, + Memory, + ModelConfig, + ValueSelector, + VisionSetting, +} from '@/app/components/workflow/types' export enum ParamType { string = 'string', diff --git a/web/app/components/workflow/nodes/parameter-extractor/use-config.ts b/web/app/components/workflow/nodes/parameter-extractor/use-config.ts index 71e94e95db02f5..2a0e6a64f7caa0 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/use-config.ts +++ b/web/app/components/workflow/nodes/parameter-extractor/use-config.ts @@ -4,84 +4,104 @@ import { produce } from 'immer' import { useCallback, useEffect, useRef, useState } from 'react' import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useModelListAndDefaultModelAndCurrentProviderAndModel, useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { + useModelListAndDefaultModelAndCurrentProviderAndModel, + useTextGenerationCurrentProviderAndModelAndModelList, +} from '@/app/components/header/account-setting/model-provider-page/hooks' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { AppModeEnum } from '@/types/app' import { supportFunctionCall } from '@/utils/tool-call' -import { - useIsChatMode, - useNodesReadOnly, - useWorkflow, -} from '../../hooks' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks' import useConfigVision from '../../hooks/use-config-vision' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' import { useStore } from '../../store' import { ChangeType, VarType } from '../../types' const useConfig = (id: string, payload: ParameterExtractorNodeType) => { - const { - deleteNodeInspectorVars, - renameInspectVarName, - } = useInspectVarsCrud() + const { deleteNodeInspectorVars, renameInspectVarName } = useInspectVarsCrud() const { nodesReadOnly: readOnly } = useNodesReadOnly() const { handleOutVarRenameChange } = useWorkflow() const isChatMode = useIsChatMode() - const defaultConfig = useStore(s => s.nodesDefaultConfigs)?.[payload.type] + const defaultConfig = useStore((s) => s.nodesDefaultConfigs)?.[payload.type] - const [defaultRolePrefix, setDefaultRolePrefix] = useState<{ user: string, assistant: string }>({ user: '', assistant: '' }) + const [defaultRolePrefix, setDefaultRolePrefix] = useState<{ user: string; assistant: string }>({ + user: '', + assistant: '', + }) const { inputs, setInputs: doSetInputs } = useNodeCrud<ParameterExtractorNodeType>(id, payload) const inputRef = useRef(inputs) - const setInputs = useCallback((newInputs: ParameterExtractorNodeType) => { - if (newInputs.memory && !newInputs.memory.role_prefix) { - const newPayload = produce(newInputs, (draft) => { - draft.memory!.role_prefix = defaultRolePrefix - }) - doSetInputs(newPayload) - inputRef.current = newPayload - return - } - doSetInputs(newInputs) - inputRef.current = newInputs - }, [doSetInputs, defaultRolePrefix]) + const setInputs = useCallback( + (newInputs: ParameterExtractorNodeType) => { + if (newInputs.memory && !newInputs.memory.role_prefix) { + const newPayload = produce(newInputs, (draft) => { + draft.memory!.role_prefix = defaultRolePrefix + }) + doSetInputs(newPayload) + inputRef.current = newPayload + return + } + doSetInputs(newInputs) + inputRef.current = newInputs + }, + [doSetInputs, defaultRolePrefix], + ) const filterVar = useCallback((varPayload: Var) => { return [VarType.string].includes(varPayload.type) }, []) - const handleInputVarChange = useCallback((newInputVar: ValueSelector | string) => { - const newInputs = produce(inputs, (draft) => { - draft.query = newInputVar as ValueSelector || [] - }) - setInputs(newInputs) - }, [inputs, setInputs]) - - const handleExactParamsChange = useCallback((newParams: Param[], moreInfo?: MoreInfo) => { - const newInputs = produce(inputs, (draft) => { - draft.parameters = newParams - }) - setInputs(newInputs) - - if (moreInfo && moreInfo?.type === ChangeType.changeVarName && moreInfo.payload) { - handleOutVarRenameChange(id, [id, moreInfo.payload.beforeKey], [id, moreInfo.payload.afterKey!]) - renameInspectVarName(id, moreInfo.payload.beforeKey, moreInfo.payload.afterKey!) - } - else { + const handleInputVarChange = useCallback( + (newInputVar: ValueSelector | string) => { + const newInputs = produce(inputs, (draft) => { + draft.query = (newInputVar as ValueSelector) || [] + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) + + const handleExactParamsChange = useCallback( + (newParams: Param[], moreInfo?: MoreInfo) => { + const newInputs = produce(inputs, (draft) => { + draft.parameters = newParams + }) + setInputs(newInputs) + + if (moreInfo && moreInfo?.type === ChangeType.changeVarName && moreInfo.payload) { + handleOutVarRenameChange( + id, + [id, moreInfo.payload.beforeKey], + [id, moreInfo.payload.afterKey!], + ) + renameInspectVarName(id, moreInfo.payload.beforeKey, moreInfo.payload.afterKey!) + } else { + deleteNodeInspectorVars(id) + } + }, + [ + deleteNodeInspectorVars, + handleOutVarRenameChange, + id, + inputs, + renameInspectVarName, + setInputs, + ], + ) + + const addExtractParameter = useCallback( + (payload: Param) => { + const newInputs = produce(inputs, (draft) => { + if (!draft.parameters) draft.parameters = [] + draft.parameters.push(payload) + }) + setInputs(newInputs) deleteNodeInspectorVars(id) - } - }, [deleteNodeInspectorVars, handleOutVarRenameChange, id, inputs, renameInspectVarName, setInputs]) - - const addExtractParameter = useCallback((payload: Param) => { - const newInputs = produce(inputs, (draft) => { - if (!draft.parameters) - draft.parameters = [] - draft.parameters.push(payload) - }) - setInputs(newInputs) - deleteNodeInspectorVars(id) - }, [deleteNodeInspectorVars, id, inputs, setInputs]) + }, + [deleteNodeInspectorVars, id, inputs, setInputs], + ) // model const model = inputs.model || { @@ -111,34 +131,39 @@ const useConfig = (id: string, payload: ParameterExtractorNodeType) => { }, }) - const appendDefaultPromptConfig = useCallback((draft: ParameterExtractorNodeType, defaultConfig: any, _passInIsChatMode?: boolean) => { - const promptTemplates = defaultConfig.prompt_templates - if (!isChatModel) { - setDefaultRolePrefix({ - user: promptTemplates.completion_model.conversation_histories_role.user_prefix, - assistant: promptTemplates.completion_model.conversation_histories_role.assistant_prefix, - }) - } - }, [isChatModel]) + const appendDefaultPromptConfig = useCallback( + (draft: ParameterExtractorNodeType, defaultConfig: any, _passInIsChatMode?: boolean) => { + const promptTemplates = defaultConfig.prompt_templates + if (!isChatModel) { + setDefaultRolePrefix({ + user: promptTemplates.completion_model.conversation_histories_role.user_prefix, + assistant: promptTemplates.completion_model.conversation_histories_role.assistant_prefix, + }) + } + }, + [isChatModel], + ) const [modelChanged, setModelChanged] = useState(false) - const { - currentProvider, - currentModel, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) - - const handleModelChanged = useCallback((model: { provider: string, modelId: string, mode?: string }) => { - const newInputs = produce(inputRef.current, (draft) => { - draft.model.provider = model.provider - draft.model.name = model.modelId - draft.model.mode = model.mode! - const isModeChange = model.mode !== inputRef.current.model?.mode - if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0) - appendDefaultPromptConfig(draft, defaultConfig, model.mode === AppModeEnum.CHAT) - }) - setInputs(newInputs) - setModelChanged(true) - }, [setInputs, defaultConfig, appendDefaultPromptConfig]) + const { currentProvider, currentModel } = useModelListAndDefaultModelAndCurrentProviderAndModel( + ModelTypeEnum.textGeneration, + ) + + const handleModelChanged = useCallback( + (model: { provider: string; modelId: string; mode?: string }) => { + const newInputs = produce(inputRef.current, (draft) => { + draft.model.provider = model.provider + draft.model.name = model.modelId + draft.model.mode = model.mode! + const isModeChange = model.mode !== inputRef.current.model?.mode + if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0) + appendDefaultPromptConfig(draft, defaultConfig, model.mode === AppModeEnum.CHAT) + }) + setInputs(newInputs) + setModelChanged(true) + }, + [setInputs, defaultConfig, appendDefaultPromptConfig], + ) useEffect(() => { if (currentProvider?.provider && currentModel?.model && !model.provider) { @@ -152,20 +177,15 @@ const useConfig = (id: string, payload: ParameterExtractorNodeType) => { // change to vision model to set vision enabled, else disabled useEffect(() => { - if (!modelChanged) - return + if (!modelChanged) return setModelChanged(false) handleVisionConfigAfterModelChanged() }, [isVisionModel, modelChanged]) - const { - currentModel: currModel, - } = useTextGenerationCurrentProviderAndModelAndModelList( - { - provider: model.provider, - model: model.name, - }, - ) + const { currentModel: currModel } = useTextGenerationCurrentProviderAndModelAndModelList({ + provider: model.provider, + model: model.name, + }) const isSupportFunctionCall = supportFunctionCall(currModel?.features) @@ -173,27 +193,30 @@ const useConfig = (id: string, payload: ParameterExtractorNodeType) => { return [VarType.number, VarType.string].includes(varPayload.type) }, []) - const { - availableVars, - availableNodesWithParent, - } = useAvailableVarList(id, { + const { availableVars, availableNodesWithParent } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar: filterInputVar, }) - const handleCompletionParamsChange = useCallback((newParams: Record<string, any>) => { - const newInputs = produce(inputs, (draft) => { - draft.model.completion_params = newParams - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const handleCompletionParamsChange = useCallback( + (newParams: Record<string, any>) => { + const newInputs = produce(inputs, (draft) => { + draft.model.completion_params = newParams + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) - const handleInstructionChange = useCallback((newInstruction: string) => { - const newInputs = produce(inputs, (draft) => { - draft.instruction = newInstruction - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const handleInstructionChange = useCallback( + (newInstruction: string) => { + const newInputs = produce(inputs, (draft) => { + draft.instruction = newInstruction + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) const hasSetBlockStatus = { history: false, @@ -201,26 +224,35 @@ const useConfig = (id: string, payload: ParameterExtractorNodeType) => { context: false, } - const handleMemoryChange = useCallback((newMemory?: Memory) => { - const newInputs = produce(inputs, (draft) => { - draft.memory = newMemory - }) - setInputs(newInputs) - }, [inputs, setInputs]) - - const handleReasoningModeChange = useCallback((newReasoningMode: ReasoningModeType) => { - const newInputs = produce(inputs, (draft) => { - draft.reasoning_mode = newReasoningMode - }) - setInputs(newInputs) - }, [inputs, setInputs]) - - const handleImportFromTool = useCallback((params: Param[]) => { - const newInputs = produce(inputs, (draft) => { - draft.parameters = params - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const handleMemoryChange = useCallback( + (newMemory?: Memory) => { + const newInputs = produce(inputs, (draft) => { + draft.memory = newMemory + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) + + const handleReasoningModeChange = useCallback( + (newReasoningMode: ReasoningModeType) => { + const newInputs = produce(inputs, (draft) => { + draft.reasoning_mode = newReasoningMode + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) + + const handleImportFromTool = useCallback( + (params: Param[]) => { + const newInputs = produce(inputs, (draft) => { + draft.parameters = params + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) return { readOnly, diff --git a/web/app/components/workflow/nodes/parameter-extractor/use-single-run-form-params.ts b/web/app/components/workflow/nodes/parameter-extractor/use-single-run-form-params.ts index cd570a8ca0a88d..be4cbeda896479 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/parameter-extractor/use-single-run-form-params.ts @@ -35,48 +35,50 @@ const useSingleRunFormParams = ({ const model = inputs.model - const { - isVisionModel, - } = useConfigVision(model, { + const { isVisionModel } = useConfigVision(model, { payload: inputs.vision, onChange: noop, }) const visionFiles = runInputData['#files#'] - const setVisionFiles = useCallback((newFiles: any[]) => { - setRunInputData?.({ - ...runInputDataRef.current, - '#files#': newFiles, - }) - }, [runInputDataRef, setRunInputData]) + const setVisionFiles = useCallback( + (newFiles: any[]) => { + setRunInputData?.({ + ...runInputDataRef.current, + '#files#': newFiles, + }) + }, + [runInputDataRef, setRunInputData], + ) const varInputs = getInputVars([inputs.instruction]) const inputVarValues = (() => { const vars: Record<string, any> = {} Object.keys(runInputData) - .filter(key => !['#context#', '#files#'].includes(key)) + .filter((key) => !['#context#', '#files#'].includes(key)) .forEach((key) => { vars[key] = runInputData[key] }) return vars })() - const setInputVarValues = useCallback((newPayload: Record<string, any>) => { - const newVars = { - ...newPayload, - '#context#': runInputDataRef.current['#context#'], - '#files#': runInputDataRef.current['#files#'], - } - setRunInputData?.(newVars) - }, [runInputDataRef, setRunInputData]) + const setInputVarValues = useCallback( + (newPayload: Record<string, any>) => { + const newVars = { + ...newPayload, + '#context#': runInputDataRef.current['#context#'], + '#files#': runInputDataRef.current['#files#'], + } + setRunInputData?.(newVars) + }, + [runInputDataRef, setRunInputData], + ) const filterVisionInputVar = useCallback((varPayload: Var) => { return [VarType.file, VarType.arrayFile].includes(varPayload.type) }, []) - const { - availableVars: availableVisionVars, - } = useAvailableVarList(id, { + const { availableVars: availableVisionVars } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar: filterVisionInputVar, }) @@ -84,49 +86,54 @@ const useSingleRunFormParams = ({ const forms = (() => { const forms: FormProps[] = [] - forms.push( - { - label: t($ => $['nodes.llm.singleRun.variable'], { ns: 'workflow' })!, - inputs: [{ - label: t($ => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })!, + forms.push({ + label: t(($) => $['nodes.llm.singleRun.variable'], { ns: 'workflow' })!, + inputs: [ + { + label: t(($) => $[`${i18nPrefix}.inputVar`], { ns: 'workflow' })!, variable: 'query', type: InputVarType.paragraph, required: true, - }, ...varInputs], - values: inputVarValues, - onChange: setInputVarValues, - }, - ) + }, + ...varInputs, + ], + values: inputVarValues, + onChange: setInputVarValues, + }) if (isVisionModel && payload.vision?.enabled && payload.vision?.configs?.variable_selector) { - const currentVariable = findVariableWhenOnLLMVision(payload.vision.configs.variable_selector, availableVisionVars) + const currentVariable = findVariableWhenOnLLMVision( + payload.vision.configs.variable_selector, + availableVisionVars, + ) - forms.push( - { - label: t($ => $['nodes.llm.vision'], { ns: 'workflow' })!, - inputs: [{ + forms.push({ + label: t(($) => $['nodes.llm.vision'], { ns: 'workflow' })!, + inputs: [ + { label: currentVariable?.variable as any, variable: '#files#', type: currentVariable?.formType as any, required: false, - }], - values: { '#files#': visionFiles }, - onChange: keyValue => setVisionFiles((keyValue as any)['#files#']), - }, - ) + }, + ], + values: { '#files#': visionFiles }, + onChange: (keyValue) => setVisionFiles((keyValue as any)['#files#']), + }) } return forms })() const getDependentVars = () => { - const promptVars = varInputs.map((item) => { - // Guard against null/undefined variable to prevent app crash - if (!item.variable || typeof item.variable !== 'string') - return [] + const promptVars = varInputs + .map((item) => { + // Guard against null/undefined variable to prevent app crash + if (!item.variable || typeof item.variable !== 'string') return [] - return item.variable.slice(1, -1).split('.') - }).filter(arr => arr.length > 0) + return item.variable.slice(1, -1).split('.') + }) + .filter((arr) => arr.length > 0) const vars = [payload.query, ...promptVars] if (isVisionModel && payload.vision?.enabled && payload.vision?.configs?.variable_selector) { const visionVar = payload.vision.configs.variable_selector @@ -136,10 +143,8 @@ const useSingleRunFormParams = ({ } const getDependentVar = (variable: string) => { - if (variable === 'query') - return payload.query - if (variable === '#files#') - return payload.vision.configs?.variable_selector + if (variable === 'query') return payload.query + if (variable === '#files#') return payload.vision.configs?.variable_selector return false } diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx index a169fe9d096493..c61673353bdc57 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx @@ -18,14 +18,22 @@ vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => default: ({ title, value, onChange, onRemove, showRemove, headerClassName }: any) => ( <div className={headerClassName}> <div>{typeof title === 'string' ? title : 'editor-title'}</div> - <input value={value} onChange={event => onChange(event.target.value)} /> - {showRemove && <button type="button" onClick={onRemove}>remove-item</button>} + <input value={value} onChange={(event) => onChange(event.target.value)} /> + {showRemove && ( + <button type="button" onClick={onRemove}> + remove-item + </button> + )} </div> ), })) vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({ - default: ({ onChange }: any) => <button type="button" onClick={() => onChange({ enabled: true })}>memory-config</button>, + default: ({ onChange }: any) => ( + <button type="button" onClick={() => onChange({ enabled: true })}> + memory-config + </button> + ), })) vi.mock('../../_base/hooks/use-available-var-list', () => ({ @@ -44,7 +52,11 @@ vi.mock('../../../hooks', async (importOriginal) => { }) vi.mock('@/app/components/workflow/nodes/_base/components/add-button', () => ({ - default: ({ text, onClick }: any) => <button type="button" onClick={onClick}>{text}</button>, + default: ({ text, onClick }: any) => ( + <button type="button" onClick={onClick}> + {text} + </button> + ), })) vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ @@ -52,7 +64,11 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ - default: ({ defaultModel }: any) => <div>{defaultModel.provider}:{defaultModel.model}</div>, + default: ({ defaultModel }: any) => ( + <div> + {defaultModel.provider}:{defaultModel.model} + </div> + ), })) vi.mock('@langgenius/dify-ui/tooltip', () => ({ @@ -69,26 +85,48 @@ vi.mock('@/app/components/workflow/nodes/_base/components/node-handle', () => ({ NodeSourceHandle: ({ handleId }: any) => <div>handle-{handleId}</div>, })) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - default: ({ setModel, onCompletionParamsChange }: any) => ( +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + default: ({ setModel, onCompletionParamsChange }: any) => ( + <div> + <button type="button" onClick={() => setModel({ provider: 'openai', name: 'gpt-4o' })}> + set-model + </button> + <button type="button" onClick={() => onCompletionParamsChange({ temperature: 0.2 })}> + set-params + </button> + </div> + ), + }), +) + +vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({ + FieldCollapse: ({ title, children }: any) => ( <div> - <button type="button" onClick={() => setModel({ provider: 'openai', name: 'gpt-4o' })}>set-model</button> - <button type="button" onClick={() => onCompletionParamsChange({ temperature: 0.2 })}>set-params</button> + <div>{title}</div> + {children} </div> ), })) -vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({ - FieldCollapse: ({ title, children }: any) => <div><div>{title}</div>{children}</div>, -})) - vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ - default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>, + default: ({ title, operations, children }: any) => ( + <div> + <div>{title}</div> + <div>{operations}</div> + {children} + </div> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ default: ({ children }: any) => <div>{children}</div>, - VarItem: ({ name, type }: any) => <div>{name}:{type}</div>, + VarItem: ({ name, type }: any) => ( + <div> + {name}:{type} + </div> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ @@ -98,14 +136,22 @@ vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/config-vision', () => ({ default: ({ onEnabledChange, onConfigChange }: any) => ( <div> - <button type="button" onClick={() => onEnabledChange(true)}>vision-toggle</button> - <button type="button" onClick={() => onConfigChange({ resolution: 'high' })}>vision-config</button> + <button type="button" onClick={() => onEnabledChange(true)}> + vision-toggle + </button> + <button type="button" onClick={() => onConfigChange({ resolution: 'high' })}> + vision-config + </button> </div> ), })) vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ - default: ({ onChange }: any) => <button type="button" onClick={() => onChange(['node-1', 'query'])}>var-picker</button>, + default: ({ onChange }: any) => ( + <button type="button" onClick={() => onChange(['node-1', 'query'])}> + var-picker + </button> + ), })) vi.mock('../use-config', () => ({ @@ -122,7 +168,9 @@ const createTopic = (overrides: Partial<Topic> = {}): Topic => ({ ...overrides, }) -const createData = (overrides: Partial<QuestionClassifierNodeType> = {}): QuestionClassifierNodeType => ({ +const createData = ( + overrides: Partial<QuestionClassifierNodeType> = {}, +): QuestionClassifierNodeType => ({ title: 'Question Classifier', desc: '', type: BlockEnum.QuestionClassifier, @@ -142,7 +190,9 @@ const createData = (overrides: Partial<QuestionClassifierNodeType> = {}): Questi ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), handleModelChanged: vi.fn(), @@ -174,9 +224,8 @@ const panelProps: PanelProps = { runResult: null, } -const renderPanel = (data: QuestionClassifierNodeType = createData()) => ( +const renderPanel = (data: QuestionClassifierNodeType = createData()) => render(<Panel id="node-1" data={data} panelProps={panelProps} />) -) describe('question-classifier path', () => { beforeEach(() => { @@ -188,7 +237,9 @@ describe('question-classifier path', () => { currentProvider: undefined, currentModel: undefined, textGenerationModelList: [{ provider: 'openai', model: 'gpt-4o', status: 'active' } as any], - activeTextGenerationModelList: [{ provider: 'openai', model: 'gpt-4o', status: 'active' } as any], + activeTextGenerationModelList: [ + { provider: 'openai', model: 'gpt-4o', status: 'active' } as any, + ], }) mockUseConfig.mockReturnValue(createConfigResult()) }) @@ -257,7 +308,9 @@ describe('question-classifier path', () => { await user.click(screen.getByText('workflow.nodes.questionClassifiers.addClass')) await user.click(screen.getByText('workflow.nodes.questionClassifiers.class')) - expect(screen.queryByText('workflow.nodes.questionClassifiers.addClass')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.questionClassifiers.addClass'), + ).not.toBeInTheDocument() await user.click(screen.getByText('workflow.nodes.questionClassifiers.class')) expect(screen.getByText('workflow.nodes.questionClassifiers.addClass')).toBeInTheDocument() expect(container.querySelector('.handle')).not.toBeNull() @@ -282,12 +335,14 @@ describe('question-classifier path', () => { />, ) - fireEvent.change(screen.getByDisplayValue('Billing questions'), { target: { value: 'Updated billing' } }) + fireEvent.change(screen.getByDisplayValue('Billing questions'), { + target: { value: 'Updated billing' }, + }) await user.click(screen.getAllByText('remove-item')[0]!) - expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ name: 'Updated billing' }), - ])) + expect(onChange).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ name: 'Updated billing' })]), + ) expect(handleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('node-1', 'topic-1') }) @@ -302,7 +357,9 @@ describe('question-classifier path', () => { />, ) - expect(screen.queryByText('workflow.nodes.questionClassifiers.addClass')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.questionClassifiers.addClass'), + ).not.toBeInTheDocument() expect(container.querySelector('.handle')).toBeNull() }) @@ -310,7 +367,9 @@ describe('question-classifier path', () => { renderWorkflowFlowComponent( <Node id="node-1" - data={createData({ classes: [createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })] })} + data={createData({ + classes: [createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })], + })} type="custom" selected={false} zIndex={1} diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx index 3356226a858665..54bb161f5e2bde 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/node.spec.tsx @@ -11,7 +11,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ __esModule: true, - default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => ( + default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => ( <div>{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}</div> ), })) @@ -34,7 +34,9 @@ const createTopic = (overrides: Partial<Topic> = {}): Topic => ({ ...overrides, }) -const createData = (overrides: Partial<QuestionClassifierNodeType> = {}): QuestionClassifierNodeType => ({ +const createData = ( + overrides: Partial<QuestionClassifierNodeType> = {}, +): QuestionClassifierNodeType => ({ title: 'Question Classifier', desc: '', type: BlockEnum.QuestionClassifier, diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/question-classifier/__tests__/panel.spec.tsx index f92bb5abb343a5..dca05eda2e92ad 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/panel.spec.tsx @@ -6,26 +6,38 @@ import { BlockEnum } from '@/app/components/workflow/types' import Panel from '../panel' import useConfig from '../use-config' -vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ - __esModule: true, - default: ({ - setModel, - onCompletionParamsChange, - }: { - setModel: (model: { provider: string, modelId: string, mode?: string }) => void - onCompletionParamsChange: (params: Record<string, unknown>) => void - }) => ( - <div> - <button type="button" onClick={() => setModel({ provider: 'openai', modelId: 'gpt-4o', mode: 'chat' })}>set-model</button> - <button type="button" onClick={() => onCompletionParamsChange({ temperature: 0.2 })}>set-params</button> - </div> - ), -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + __esModule: true, + default: ({ + setModel, + onCompletionParamsChange, + }: { + setModel: (model: { provider: string; modelId: string; mode?: string }) => void + onCompletionParamsChange: (params: Record<string, unknown>) => void + }) => ( + <div> + <button + type="button" + onClick={() => setModel({ provider: 'openai', modelId: 'gpt-4o', mode: 'chat' })} + > + set-model + </button> + <button type="button" onClick={() => onCompletionParamsChange({ temperature: 0.2 })}> + set-params + </button> + </div> + ), + }), +) vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ __esModule: true, default: ({ onChange }: { onChange: (value: string[]) => void }) => ( - <button type="button" onClick={() => onChange(['node-1', 'query'])}>var-picker</button> + <button type="button" onClick={() => onChange(['node-1', 'query'])}> + var-picker + </button> ), })) @@ -39,8 +51,12 @@ vi.mock('@/app/components/workflow/nodes/_base/components/config-vision', () => onConfigChange: (value: { resolution: string }) => void }) => ( <div> - <button type="button" onClick={() => onEnabledChange(true)}>vision-toggle</button> - <button type="button" onClick={() => onConfigChange({ resolution: 'high' })}>vision-config</button> + <button type="button" onClick={() => onEnabledChange(true)}> + vision-toggle + </button> + <button type="button" onClick={() => onConfigChange({ resolution: 'high' })}> + vision-config + </button> </div> ), })) @@ -58,7 +74,7 @@ vi.mock('../components/advanced-setting', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ __esModule: true, default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>, + VarItem: ({ name, type }: { name: string; type: string }) => <div>{`${name}:${type}`}</div>, })) vi.mock('../use-config', () => ({ @@ -68,7 +84,9 @@ vi.mock('../use-config', () => ({ const mockUseConfig = vi.mocked(useConfig) -const createData = (overrides: Partial<QuestionClassifierNodeType> = {}): QuestionClassifierNodeType => ({ +const createData = ( + overrides: Partial<QuestionClassifierNodeType> = {}, +): QuestionClassifierNodeType => ({ title: 'Question Classifier', desc: '', type: BlockEnum.QuestionClassifier, @@ -124,13 +142,7 @@ describe('question-classifier/panel', () => { it('wires panel actions and renders output variables', async () => { const user = userEvent.setup() - render( - <Panel - id="node-1" - data={createData()} - panelProps={panelProps} - />, - ) + render(<Panel id="node-1" data={createData()} panelProps={panelProps} />) await user.click(screen.getByRole('button', { name: 'set-model' })) await user.click(screen.getByRole('button', { name: 'set-params' })) @@ -138,7 +150,11 @@ describe('question-classifier/panel', () => { await user.click(screen.getByRole('button', { name: 'vision-toggle' })) await user.click(screen.getByRole('button', { name: 'vision-config' })) - expect(handleModelChanged).toHaveBeenCalledWith({ provider: 'openai', modelId: 'gpt-4o', mode: 'chat' }) + expect(handleModelChanged).toHaveBeenCalledWith({ + provider: 'openai', + modelId: 'gpt-4o', + mode: 'chat', + }) expect(handleCompletionParamsChange).toHaveBeenCalledWith({ temperature: 0.2 }) expect(handleQueryVarChange).toHaveBeenCalledWith(['node-1', 'query']) expect(handleVisionResolutionEnabledChange).toHaveBeenCalledWith(true) diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts index c60b54be1c8651..ad0ec634959123 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts @@ -1,11 +1,7 @@ import type { QuestionClassifierNodeType } from '../types' import { act, renderHook, waitFor } from '@testing-library/react' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' -import { - useIsChatMode, - useNodesReadOnly, - useWorkflow, -} from '@/app/components/workflow/hooks' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks' import useConfigVision from '@/app/components/workflow/hooks/use-config-vision' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' @@ -43,11 +39,12 @@ const mockFlowType = vi.hoisted(() => ({ })) vi.mock('@/app/components/workflow/hooks-store/store', () => ({ - useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => selector({ - configsMap: { - flowType: mockFlowType.value, - }, - }), + useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => + selector({ + configsMap: { + flowType: mockFlowType.value, + }, + }), })) vi.mock('@/app/components/workflow/hooks/use-config-vision', () => ({ @@ -64,12 +61,16 @@ const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) const mockUseIsChatMode = vi.mocked(useIsChatMode) const mockUseWorkflow = vi.mocked(useWorkflow) const mockUseNodeCrud = vi.mocked(useNodeCrud) -const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = vi.mocked(useModelListAndDefaultModelAndCurrentProviderAndModel) +const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = vi.mocked( + useModelListAndDefaultModelAndCurrentProviderAndModel, +) const mockUseStore = vi.mocked(useStore) const mockUseConfigVision = vi.mocked(useConfigVision) const mockUseAvailableVarList = vi.mocked(useAvailableVarList) -const createPayload = (overrides: Partial<QuestionClassifierNodeType> = {}): QuestionClassifierNodeType => ({ +const createPayload = ( + overrides: Partial<QuestionClassifierNodeType> = {}, +): QuestionClassifierNodeType => ({ type: BlockEnum.QuestionClassifier, title: 'Question Classifier', desc: '', @@ -146,29 +147,33 @@ describe('question-classifier/use-config', () => { }) await waitFor(() => { - expect(setInputs).toHaveBeenLastCalledWith(expect.objectContaining({ - model: expect.objectContaining({ - provider: 'openai', - name: 'gpt-4o', - mode: AppModeEnum.CHAT, + expect(setInputs).toHaveBeenLastCalledWith( + expect.objectContaining({ + model: expect.objectContaining({ + provider: 'openai', + name: 'gpt-4o', + mode: AppModeEnum.CHAT, + }), + vision: { + enabled: false, + }, }), - vision: { - enabled: false, - }, - })) + ) }) }) it('does not default the query selector to sys.query in snippet flows', async () => { mockFlowType.value = FlowType.snippet mockUseWorkflow.mockReturnValue({ - getBeforeNodesInSameBranch: vi.fn(() => [{ - id: 'start-node', - data: { - type: BlockEnum.Start, - title: 'Start', + getBeforeNodesInSameBranch: vi.fn(() => [ + { + id: 'start-node', + data: { + type: BlockEnum.Start, + title: 'Start', + }, }, - }]), + ]), } as unknown as ReturnType<typeof useWorkflow>) mockUseStore.mockImplementation((selector) => { return selector({ @@ -188,9 +193,14 @@ describe('question-classifier/use-config', () => { setInputs, }) - renderHook(() => useConfig('question-classifier-node', createPayload({ - query_variable_selector: [], - }))) + renderHook(() => + useConfig( + 'question-classifier-node', + createPayload({ + query_variable_selector: [], + }), + ), + ) await waitFor(() => { expect(setInputs).not.toHaveBeenCalled() diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/question-classifier/__tests__/use-single-run-form-params.spec.ts index 13fa9f62e6255f..1efe9252e7c65c 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/use-single-run-form-params.spec.ts @@ -27,7 +27,9 @@ const mockUseConfigVision = vi.mocked(useConfigVision) const mockUseAvailableVarList = vi.mocked(useAvailableVarList) const mockUseNodeCrud = vi.mocked(useNodeCrud) -const createData = (overrides: Partial<QuestionClassifierNodeType> = {}): QuestionClassifierNodeType => ({ +const createData = ( + overrides: Partial<QuestionClassifierNodeType> = {}, +): QuestionClassifierNodeType => ({ title: 'Question Classifier', desc: '', type: BlockEnum.QuestionClassifier, @@ -61,65 +63,79 @@ describe('question-classifier/use-single-run-form-params', () => { isVisionModel: true, } as ReturnType<typeof useConfigVision>) mockUseAvailableVarList.mockReturnValue({ - availableVars: [{ - nodeId: 'node-3', - title: 'Vision Source', - vars: [{ - variable: 'image', - type: VarType.file, - }], - }], + availableVars: [ + { + nodeId: 'node-3', + title: 'Vision Source', + vars: [ + { + variable: 'image', + type: VarType.file, + }, + ], + }, + ], } as unknown as ReturnType<typeof useAvailableVarList>) }) it('builds variable and vision forms and exposes dependent variables', () => { const setRunInputData = vi.fn() - const getInputVars = vi.fn<() => InputVar[]>(() => [{ - label: 'Answer', - variable: '#node-2.answer#', - type: InputVarType.textInput, - required: true, - }]) - - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'node-1', - payload: createData(), - runInputData: { 'query': 'hello', '#files#': ['file-1'] }, - runInputDataRef: { current: { 'query': 'hello', '#files#': ['file-1'] } }, - getInputVars, - setRunInputData, - toVarInputs: () => [], - })) - - expect(result.current.forms).toHaveLength(2) - expect(result.current.forms[0]!.inputs).toEqual(expect.arrayContaining([ - expect.objectContaining({ - variable: 'query', - type: InputVarType.paragraph, - }), - expect.objectContaining({ + const getInputVars = vi.fn<() => InputVar[]>(() => [ + { + label: 'Answer', variable: '#node-2.answer#', + type: InputVarType.textInput, + required: true, + }, + ]) + + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'node-1', + payload: createData(), + runInputData: { query: 'hello', '#files#': ['file-1'] }, + runInputDataRef: { current: { query: 'hello', '#files#': ['file-1'] } }, + getInputVars, + setRunInputData, + toVarInputs: () => [], }), - ])) - expect(result.current.forms[1]!.inputs).toEqual([{ - label: 'image', - variable: '#files#', - type: InputVarType.singleFile, - required: false, - }]) + ) + + expect(result.current.forms).toHaveLength(2) + expect(result.current.forms[0]!.inputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + variable: 'query', + type: InputVarType.paragraph, + }), + expect.objectContaining({ + variable: '#node-2.answer#', + }), + ]), + ) + expect(result.current.forms[1]!.inputs).toEqual([ + { + label: 'image', + variable: '#files#', + type: InputVarType.singleFile, + required: false, + }, + ]) result.current.forms[0]!.onChange({ query: 'updated' }) result.current.forms[1]!.onChange({ '#files#': ['file-2'] }) expect(setRunInputData).toHaveBeenNthCalledWith(1, { - 'query': 'updated', + query: 'updated', '#files#': ['file-1'], }) expect(setRunInputData).toHaveBeenNthCalledWith(2, { - 'query': 'hello', + query: 'hello', '#files#': ['file-2'], }) - const availableVarOptions = mockUseAvailableVarList.mock.calls[0]![1] as { filterVar: (payload: { type: VarType }) => boolean } + const availableVarOptions = mockUseAvailableVarList.mock.calls[0]![1] as { + filterVar: (payload: { type: VarType }) => boolean + } expect(availableVarOptions.filterVar({ type: VarType.file })).toBe(true) expect(availableVarOptions.filterVar({ type: VarType.string })).toBe(false) @@ -138,34 +154,40 @@ describe('question-classifier/use-single-run-form-params', () => { isVisionModel: false, } as ReturnType<typeof useConfigVision>) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'node-1', - payload: createData(), - runInputData: {}, - runInputDataRef: { current: {} }, - getInputVars: () => [], - setRunInputData: vi.fn(), - toVarInputs: () => [], - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'node-1', + payload: createData(), + runInputData: {}, + runInputDataRef: { current: {} }, + getInputVars: () => [], + setRunInputData: vi.fn(), + toVarInputs: () => [], + }), + ) expect(result.current.forms).toHaveLength(1) }) it('skips malformed prompt variables when collecting dependent vars', () => { - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'node-1', - payload: createData(), - runInputData: {}, - runInputDataRef: { current: {} }, - getInputVars: () => [{ - label: 'Broken', - variable: undefined as unknown as string, - type: InputVarType.textInput, - required: false, - }], - setRunInputData: vi.fn(), - toVarInputs: () => [], - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'node-1', + payload: createData(), + runInputData: {}, + runInputDataRef: { current: {} }, + getInputVars: () => [ + { + label: 'Broken', + variable: undefined as unknown as string, + type: InputVarType.textInput, + required: false, + }, + ], + setRunInputData: vi.fn(), + toVarInputs: () => [], + }), + ) expect(result.current.getDependentVars()).toEqual([ ['node-1', 'query'], diff --git a/web/app/components/workflow/nodes/question-classifier/components/__tests__/advanced-setting.spec.tsx b/web/app/components/workflow/nodes/question-classifier/components/__tests__/advanced-setting.spec.tsx index 72f507aa8af09e..61058de97e2e79 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/__tests__/advanced-setting.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/__tests__/advanced-setting.spec.tsx @@ -18,7 +18,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => <input aria-label="instruction-input" value={value} - onChange={event => onChange(event.target.value)} + onChange={(event) => onChange(event.target.value)} /> </div> ), @@ -26,11 +26,11 @@ vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({ __esModule: true, - default: ({ - onChange, - }: { - onChange: (value: { enabled: boolean }) => void - }) => <button type="button" onClick={() => onChange({ enabled: true })}>memory-config</button>, + default: ({ onChange }: { onChange: (value: { enabled: boolean }) => void }) => ( + <button type="button" onClick={() => onChange({ enabled: true })}> + memory-config + </button> + ), })) describe('question-classifier/advanced-setting', () => { diff --git a/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-item.spec.tsx b/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-item.spec.tsx index ab1d0e0224481c..c4cd90b0696a6b 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-item.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-item.spec.tsx @@ -26,9 +26,13 @@ vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => <input aria-label="class-name" value={props.value} - onChange={event => props.onChange(event.target.value)} + onChange={(event) => props.onChange(event.target.value)} /> - {props.showRemove && <button type="button" onClick={props.onRemove}>remove-item</button>} + {props.showRemove && ( + <button type="button" onClick={props.onRemove}> + remove-item + </button> + )} </div> ) }, diff --git a/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-list.spec.tsx b/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-list.spec.tsx index 0ce9af524fe065..846a8faeb3998a 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-list.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-list.spec.tsx @@ -32,8 +32,15 @@ vi.mock('../class-item', () => ({ }) => ( <div> <div>{`${index}:${payload.name}`}</div> - <button type="button" onClick={() => onChange({ ...payload, name: `${payload.name} updated` })}>change-item</button> - <button type="button" onClick={onRemove}>remove-item</button> + <button + type="button" + onClick={() => onChange({ ...payload, name: `${payload.name} updated` })} + > + change-item + </button> + <button type="button" onClick={onRemove}> + remove-item + </button> </div> ), })) @@ -72,21 +79,27 @@ describe('question-classifier/class-list', () => { ) await user.click(screen.getAllByText('change-item')[0]!) - expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ name: 'Billing questions updated' }), - ])) + expect(onChange).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ name: 'Billing questions updated' })]), + ) await user.click(screen.getAllByText('remove-item')[0]!) expect(handleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('node-1', 'topic-1') - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.questionClassifiers\.class/ })) - expect(screen.queryByText('workflow.nodes.questionClassifiers.addClass')).not.toBeInTheDocument() + await user.click( + screen.getByRole('button', { name: /workflow\.nodes\.questionClassifiers\.class/ }), + ) + expect( + screen.queryByText('workflow.nodes.questionClassifiers.addClass'), + ).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: /workflow\.nodes\.questionClassifiers\.class/ })) + await user.click( + screen.getByRole('button', { name: /workflow\.nodes\.questionClassifiers\.class/ }), + ) await user.click(screen.getByText('workflow.nodes.questionClassifiers.addClass')) - expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ name: '' }), - ])) + expect(onChange).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ name: '' })]), + ) }) it('hides drag and add affordances when readonly', () => { @@ -100,7 +113,9 @@ describe('question-classifier/class-list', () => { />, ) - expect(screen.queryByText('workflow.nodes.questionClassifiers.addClass')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.questionClassifiers.addClass'), + ).not.toBeInTheDocument() expect(container.querySelector('.handle')).toBeNull() }) }) diff --git a/web/app/components/workflow/nodes/question-classifier/components/advanced-setting.tsx b/web/app/components/workflow/nodes/question-classifier/components/advanced-setting.tsx index 7b0454f05b2dad..1c69fbafe65d7e 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/advanced-setting.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/advanced-setting.tsx @@ -45,18 +45,20 @@ const AdvancedSetting: FC<Props> = ({ return ( <> <Editor - title={( + title={ <div className="flex items-center space-x-1"> - <span className="uppercase">{t($ => $[`${i18nPrefix}.instruction`], { ns: 'workflow' })}</span> + <span className="uppercase"> + {t(($) => $[`${i18nPrefix}.instruction`], { ns: 'workflow' })} + </span> <Infotip - aria-label={t($ => $[`${i18nPrefix}.instructionTip`], { ns: 'workflow' })} + aria-label={t(($) => $[`${i18nPrefix}.instructionTip`], { ns: 'workflow' })} className="ml-0.5 size-3.5" popupClassName="w-[120px]" > - {t($ => $[`${i18nPrefix}.instructionTip`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.instructionTip`], { ns: 'workflow' })} </Infotip> </div> - )} + } value={instruction} onChange={onInstructionChange} readOnly={readonly} diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx index 524582dd537327..f69cc24012ce39 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx @@ -46,23 +46,28 @@ const ClassItem: FC<Props> = ({ const instanceId = `${nodeId}-${reactId}` useEffect(() => { - if (isEditingLabel) - labelInputRef.current?.select() + if (isEditingLabel) labelInputRef.current?.select() }, [isEditingLabel]) - const handleNameChange = useCallback((value: string) => { - onChange({ ...payload, name: value }) - }, [onChange, payload]) + const handleNameChange = useCallback( + (value: string) => { + onChange({ ...payload, name: value }) + }, + [onChange, payload], + ) - const handleLabelSave = useCallback((nextValue: string) => { - const normalizedLabel = getCanonicalClassLabel(nextValue, index, t) - setIsEditingLabel(false) - setDraftLabel(normalizedLabel) - const shouldPersistLabel = normalizedLabel !== displayLabel - || (payload.label !== undefined && payload.label !== normalizedLabel) - if (shouldPersistLabel) - onChange({ ...payload, label: normalizedLabel }) - }, [displayLabel, index, onChange, payload, t]) + const handleLabelSave = useCallback( + (nextValue: string) => { + const normalizedLabel = getCanonicalClassLabel(nextValue, index, t) + setIsEditingLabel(false) + setDraftLabel(normalizedLabel) + const shouldPersistLabel = + normalizedLabel !== displayLabel || + (payload.label !== undefined && payload.label !== normalizedLabel) + if (shouldPersistLabel) onChange({ ...payload, label: normalizedLabel }) + }, + [displayLabel, index, onChange, payload, t], + ) const handleLabelCancel = useCallback(() => { setDraftLabel(displayLabel) @@ -70,8 +75,7 @@ const ClassItem: FC<Props> = ({ }, [displayLabel]) const handleLabelEditStart = useCallback(() => { - if (readonly) - return + if (readonly) return setDraftLabel(displayLabel) setIsEditingLabel(true) @@ -85,66 +89,62 @@ const ClassItem: FC<Props> = ({ filterVar, }) - const title = isEditingLabel - ? ( - <input - ref={labelInputRef} - value={draftLabel} - aria-label={t($ => $[`${i18nPrefix}.labelEditorAriaLabel`], { ns: 'workflow' })} - className={cn( - 'h-6 w-full rounded-md border border-divider-regular bg-components-input-bg-normal px-2 text-xs font-semibold text-text-secondary ring-0 outline-none', - 'focus:border-components-input-border-active', - )} - onChange={event => setDraftLabel(event.target.value)} - onBlur={() => handleLabelSave(draftLabel)} - onClick={event => event.stopPropagation()} - onDoubleClick={event => event.stopPropagation()} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - handleLabelSave(draftLabel) - } + const title = isEditingLabel ? ( + <input + ref={labelInputRef} + value={draftLabel} + aria-label={t(($) => $[`${i18nPrefix}.labelEditorAriaLabel`], { ns: 'workflow' })} + className={cn( + 'h-6 w-full rounded-md border border-divider-regular bg-components-input-bg-normal px-2 text-xs font-semibold text-text-secondary ring-0 outline-none', + 'focus:border-components-input-border-active', + )} + onChange={(event) => setDraftLabel(event.target.value)} + onBlur={() => handleLabelSave(draftLabel)} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleLabelSave(draftLabel) + } - if (event.key === 'Escape') { - event.preventDefault() - handleLabelCancel() - } - }} - autoFocus - /> - ) - : readonly - ? ( - <div className="-ml-1 px-1 py-0.5 text-left text-xs/4 font-semibold text-text-secondary"> - {displayLabel} - </div> - ) - : ( - <button - type="button" - aria-label={displayLabel} - className={cn( - '-ml-1 rounded px-1 py-0.5 text-left text-xs/4 font-semibold text-text-secondary transition-colors', - 'cursor-text hover:bg-state-base-hover', - )} - onDoubleClick={handleLabelEditStart} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - handleLabelEditStart() - } - }} - > - {displayLabel} - </button> - ) + if (event.key === 'Escape') { + event.preventDefault() + handleLabelCancel() + } + }} + autoFocus + /> + ) : readonly ? ( + <div className="-ml-1 px-1 py-0.5 text-left text-xs/4 font-semibold text-text-secondary"> + {displayLabel} + </div> + ) : ( + <button + type="button" + aria-label={displayLabel} + className={cn( + '-ml-1 rounded px-1 py-0.5 text-left text-xs/4 font-semibold text-text-secondary transition-colors', + 'cursor-text hover:bg-state-base-hover', + )} + onDoubleClick={handleLabelEditStart} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + handleLabelEditStart() + } + }} + > + {displayLabel} + </button> + ) return ( <Editor className={className} headerClassName={headerClassName} title={title} - placeholder={t($ => $[`${i18nPrefix}.topicPlaceholder`], { ns: 'workflow' })!} + placeholder={t(($) => $[`${i18nPrefix}.topicPlaceholder`], { ns: 'workflow' })!} value={payload.name} onChange={handleNameChange} showRemove diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-label-utils.ts b/web/app/components/workflow/nodes/question-classifier/components/class-label-utils.ts index 42d1289110163a..01a00934db4100 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/class-label-utils.ts +++ b/web/app/components/workflow/nodes/question-classifier/components/class-label-utils.ts @@ -8,18 +8,17 @@ const DEFAULT_EQUIVALENT_PREFIXES = ['CLASS', '分类', '分類', 'クラス'] const getCanonicalDefaultClassLabel = (index: number) => `${LEGACY_DEFAULT_LABEL_PREFIX} ${index}` const getTranslatedDefaultClassLabel = (t: TFunction, index: number) => { - const translated = t($ => $[`${i18nPrefix}.defaultLabel`], { ns: 'workflow', index }) - if (typeof translated !== 'string') - return undefined + const translated = t(($) => $[`${i18nPrefix}.defaultLabel`], { ns: 'workflow', index }) + if (typeof translated !== 'string') return undefined const resolvedLabel = translated.replace('{{index}}', String(index)) const rawWorkflowKey = `workflow.${i18nPrefix}.defaultLabel` const rawKey = `${i18nPrefix}.defaultLabel` if ( - resolvedLabel === rawWorkflowKey - || resolvedLabel === rawKey - || resolvedLabel.startsWith(`${rawWorkflowKey}:`) - || resolvedLabel.startsWith(`${rawKey}:`) + resolvedLabel === rawWorkflowKey || + resolvedLabel === rawKey || + resolvedLabel.startsWith(`${rawWorkflowKey}:`) || + resolvedLabel.startsWith(`${rawKey}:`) ) { return undefined } @@ -29,38 +28,29 @@ const getTranslatedDefaultClassLabel = (t: TFunction, index: number) => { const normalizeClassLabel = (label?: string | null) => label?.trim() ?? '' -export const getDefaultClassLabel = (_t: TFunction, index: number) => getCanonicalDefaultClassLabel(index) +export const getDefaultClassLabel = (_t: TFunction, index: number) => + getCanonicalDefaultClassLabel(index) -export const getDisplayClassLabel = ( - label: string | undefined, - index: number, - t: TFunction, -) => normalizeClassLabel(label) || getTranslatedDefaultClassLabel(t, index) || getCanonicalDefaultClassLabel(index) +export const getDisplayClassLabel = (label: string | undefined, index: number, t: TFunction) => + normalizeClassLabel(label) || + getTranslatedDefaultClassLabel(t, index) || + getCanonicalDefaultClassLabel(index) -export const isDefaultClassLabel = ( - label: string | undefined, - index: number, - t: TFunction, -) => { +export const isDefaultClassLabel = (label: string | undefined, index: number, t: TFunction) => { const normalizedLabel = normalizeClassLabel(label) - if (!normalizedLabel) - return true + if (!normalizedLabel) return true - return DEFAULT_EQUIVALENT_PREFIXES.some(prefix => normalizedLabel === `${prefix} ${index}`) - || normalizedLabel === getTranslatedDefaultClassLabel(t, index) + return ( + DEFAULT_EQUIVALENT_PREFIXES.some((prefix) => normalizedLabel === `${prefix} ${index}`) || + normalizedLabel === getTranslatedDefaultClassLabel(t, index) + ) } -export const getCanonicalClassLabel = ( - label: string | undefined, - index: number, - t: TFunction, -) => { +export const getCanonicalClassLabel = (label: string | undefined, index: number, t: TFunction) => { const normalizedLabel = normalizeClassLabel(label) - if (!normalizedLabel) - return getCanonicalDefaultClassLabel(index) + if (!normalizedLabel) return getCanonicalDefaultClassLabel(index) - if (isDefaultClassLabel(normalizedLabel, index, t)) - return getCanonicalDefaultClassLabel(index) + if (isDefaultClassLabel(normalizedLabel, index, t)) return getCanonicalDefaultClassLabel(index) return normalizedLabel } diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx index 70de9b3b23d776..6655e4908cc0ad 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx @@ -44,14 +44,17 @@ const ClassList: FC<Props> = ({ const [storedRenameHintDismissed, setIsRenameHintDismissed] = useInlineLabelHintDismissed() const isRenameHintDismissed = storedRenameHintDismissed ?? false - const handleClassChange = useCallback((index: number) => { - return (value: Topic) => { - const newList = produce(list, (draft) => { - draft[index] = value - }) - onChange(newList) - } - }, [list, onChange]) + const handleClassChange = useCallback( + (index: number) => { + return (value: Topic) => { + const newList = produce(list, (draft) => { + draft[index] = value + }) + onChange(newList) + } + }, + [list, onChange], + ) const handleAddClass = useCallback(() => { const newList = produce(list, (draft) => { @@ -63,25 +66,26 @@ const ClassList: FC<Props> = ({ }) onChange(newList) setShouldScrollToEnd(true) - if (collapsed) - setCollapsed(false) + if (collapsed) setCollapsed(false) }, [collapsed, list, onChange, t]) - const handleRemoveClass = useCallback((index: number) => { - return () => { - handleEdgeDeleteByDeleteBranch(nodeId, list[index]!.id) - const newList = produce(list, (draft) => { - draft.splice(index, 1) - }) - onChange(newList) - } - }, [list, onChange, handleEdgeDeleteByDeleteBranch, nodeId]) + const handleRemoveClass = useCallback( + (index: number) => { + return () => { + handleEdgeDeleteByDeleteBranch(nodeId, list[index]!.id) + const newList = produce(list, (draft) => { + draft.splice(index, 1) + }) + onChange(newList) + } + }, + [list, onChange, handleEdgeDeleteByDeleteBranch, nodeId], + ) const topicCount = list.length useEffect(() => { - if (shouldScrollToEnd && list.length > prevListLength.current) - setShouldScrollToEnd(false) + if (shouldScrollToEnd && list.length > prevListLength.current) setShouldScrollToEnd(false) prevListLength.current = list.length }, [list.length, shouldScrollToEnd]) @@ -90,15 +94,17 @@ const ClassList: FC<Props> = ({ }, [collapsed]) const dismissRenameHint = useCallback(() => { - if (isRenameHintDismissed) - return + if (isRenameHintDismissed) return setIsRenameHintDismissed(true) }, [isRenameHintDismissed, setIsRenameHintDismissed]) - const shouldShowRenameHint = !readonly && !isRenameHintDismissed && list.some((item, index) => { - return isDefaultClassLabel(item.label, index + 1, t) - }) + const shouldShowRenameHint = + !readonly && + !isRenameHintDismissed && + list.some((item, index) => { + return isDefaultClassLabel(item.label, index + 1, t) + }) return ( <> @@ -108,8 +114,7 @@ const ClassList: FC<Props> = ({ className="flex cursor-pointer items-center border-none bg-transparent p-0 text-left text-xs font-semibold text-text-secondary uppercase focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={handleCollapse} > - {t($ => $[`${i18nPrefix}.class`], { ns: 'workflow' })} - {' '} + {t(($) => $[`${i18nPrefix}.class`], { ns: 'workflow' })}{' '} <span className="text-text-destructive">*</span> {list.length > 0 && ( <ArrowDownRoundFill @@ -124,17 +129,14 @@ const ClassList: FC<Props> = ({ </div> {shouldShowRenameHint && ( <div className="mb-2 rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-tertiary"> - {t($ => $[`${i18nPrefix}.renameHint`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.renameHint`], { ns: 'workflow' })} </div> )} {!collapsed && ( - <div - ref={listContainerRef} - className="overflow-y-visible pl-3" - > + <div ref={listContainerRef} className="overflow-y-visible pl-3"> <ReactSortable - list={list.map(item => ({ ...item }))} + list={list.map((item) => ({ ...item }))} setList={handleSortTopic} handle=".handle" ghostClass="bg-components-panel-bg" @@ -142,46 +144,45 @@ const ClassList: FC<Props> = ({ disabled={readonly} className="space-y-2" > - { - list.map((item, index) => { - const canDrag = !readonly && topicCount >= 2 - return ( - <div - key={item.id} - className={cn( - 'group relative -ml-3 min-h-[40px] rounded-[10px] bg-components-panel-bg px-0 py-0', - )} - style={{ - // Performance hint for browser - contain: 'layout style paint', - }} - > - <div> - {canDrag && ( - <RiDraggable className={cn( + {list.map((item, index) => { + const canDrag = !readonly && topicCount >= 2 + return ( + <div + key={item.id} + className={cn( + 'group relative -ml-3 min-h-[40px] rounded-[10px] bg-components-panel-bg px-0 py-0', + )} + style={{ + // Performance hint for browser + contain: 'layout style paint', + }} + > + <div> + {canDrag && ( + <RiDraggable + className={cn( 'handle absolute top-3 left-2 hidden size-3 cursor-pointer text-text-tertiary', 'group-hover:block', )} - /> - )} - <Item - className={cn(canDrag && 'handle')} - headerClassName={cn(canDrag && 'cursor-grab group-hover:pl-5')} - nodeId={nodeId} - key={list[index]!.id} - payload={item} - onChange={handleClassChange(index)} - onRemove={handleRemoveClass(index)} - index={index + 1} - readonly={readonly} - filterVar={filterVar} - onLabelEditStart={dismissRenameHint} /> - </div> + )} + <Item + className={cn(canDrag && 'handle')} + headerClassName={cn(canDrag && 'cursor-grab group-hover:pl-5')} + nodeId={nodeId} + key={list[index]!.id} + payload={item} + onChange={handleClassChange(index)} + onRemove={handleRemoveClass(index)} + index={index + 1} + readonly={readonly} + filterVar={filterVar} + onLabelEditStart={dismissRenameHint} + /> </div> - ) - }) - } + </div> + ) + })} </ReactSortable> </div> )} @@ -189,7 +190,7 @@ const ClassList: FC<Props> = ({ <div className="mt-2"> <AddButton onClick={handleAddClass} - text={t($ => $[`${i18nPrefix}.addClass`], { ns: 'workflow' })} + text={t(($) => $[`${i18nPrefix}.addClass`], { ns: 'workflow' })} /> </div> )} diff --git a/web/app/components/workflow/nodes/question-classifier/default.ts b/web/app/components/workflow/nodes/question-classifier/default.ts index 2e0c364be2ea28..947252db903169 100644 --- a/web/app/components/workflow/nodes/question-classifier/default.ts +++ b/web/app/components/workflow/nodes/question-classifier/default.ts @@ -53,20 +53,42 @@ const nodeDefault: NodeDefault<QuestionClassifierNodeType> = { }, checkValid(payload: QuestionClassifierNodeType, t: TFunction<'workflow'>) { let errorMessages = '' - if (!errorMessages && (!payload.query_variable_selector || payload.query_variable_selector.length === 0)) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}nodes.questionClassifiers.inputVars`], { ns: 'workflow' }) }) + if ( + !errorMessages && + (!payload.query_variable_selector || payload.query_variable_selector.length === 0) + ) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}nodes.questionClassifiers.inputVars`], { ns: 'workflow' }), + }) if (!errorMessages && !payload.model.provider) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}nodes.questionClassifiers.model`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}nodes.questionClassifiers.model`], { ns: 'workflow' }), + }) if (!errorMessages && (!payload.classes || payload.classes.length === 0)) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}nodes.questionClassifiers.class`], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}nodes.questionClassifiers.class`], { ns: 'workflow' }), + }) - if (!errorMessages && (payload.classes.some(item => !item.name))) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}nodes.questionClassifiers.topicName`], { ns: 'workflow' }) }) + if (!errorMessages && payload.classes.some((item) => !item.name)) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}nodes.questionClassifiers.topicName`], { ns: 'workflow' }), + }) - if (!errorMessages && payload.vision?.enabled && !payload.vision.configs?.variable_selector?.length) - errorMessages = t($ => $[`${i18nPrefix}errorMsg.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}errorMsg.fields.visionVariable`], { ns: 'workflow' }) }) + if ( + !errorMessages && + payload.vision?.enabled && + !payload.vision.configs?.variable_selector?.length + ) + errorMessages = t(($) => $[`${i18nPrefix}errorMsg.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}errorMsg.fields.visionVariable`], { ns: 'workflow' }), + }) return { isValid: !errorMessages, errorMessage: errorMessages, diff --git a/web/app/components/workflow/nodes/question-classifier/node.tsx b/web/app/components/workflow/nodes/question-classifier/node.tsx index 181416e01497ce..79b279aca83c5b 100644 --- a/web/app/components/workflow/nodes/question-classifier/node.tsx +++ b/web/app/components/workflow/nodes/question-classifier/node.tsx @@ -5,9 +5,7 @@ import type { QuestionClassifierNodeType } from './types' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { - useTextGenerationCurrentProviderAndModelAndModelList, -} from '@/app/components/header/account-setting/model-provider-page/hooks' +import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import { NodeSourceHandle } from '../_base/components/node-handle' import ReadonlyInputWithSelectVar from '../_base/components/readonly-input-with-select-var' @@ -16,7 +14,7 @@ import { getDisplayClassLabel } from './components/class-label-utils' const MAX_CLASS_TEXT_LENGTH = 50 type TruncatedClassItemProps = { - topic: { id: string, name: string, label?: string } + topic: { id: string; name: string; label?: string } index: number nodeId: string t: TFunction @@ -24,43 +22,38 @@ type TruncatedClassItemProps = { const TruncatedClassItem: FC<TruncatedClassItemProps> = ({ topic, index, nodeId, t }) => { const displayLabel = getDisplayClassLabel(topic.label, index + 1, t) - const truncatedText = topic.name.length > MAX_CLASS_TEXT_LENGTH - ? `${topic.name.slice(0, MAX_CLASS_TEXT_LENGTH)}...` - : topic.name + const truncatedText = + topic.name.length > MAX_CLASS_TEXT_LENGTH + ? `${topic.name.slice(0, MAX_CLASS_TEXT_LENGTH)}...` + : topic.name const shouldShowTooltip = topic.name.length > MAX_CLASS_TEXT_LENGTH const content = ( <div className="truncate system-xs-regular text-text-tertiary"> - <ReadonlyInputWithSelectVar - value={truncatedText} - nodeId={nodeId} - className="truncate" - /> + <ReadonlyInputWithSelectVar value={truncatedText} nodeId={nodeId} className="truncate" /> </div> ) return ( <div className="flex flex-col gap-y-0.5 rounded-md bg-workflow-block-parma-bg px-[5px] py-[3px]"> - <div className="text-xs/4 font-semibold text-text-secondary"> - {displayLabel} - </div> - {shouldShowTooltip - ? ( - <Popover> - <PopoverTrigger - openOnHover - aria-label={topic.name} - className="w-full border-0 bg-transparent p-0 text-left" - > - {content} - </PopoverTrigger> - <PopoverContent popupClassName="max-w-[300px] px-3 py-2 system-xs-regular wrap-break-word text-text-tertiary"> - <ReadonlyInputWithSelectVar value={topic.name} nodeId={nodeId} /> - </PopoverContent> - </Popover> - ) - : content} + <div className="text-xs/4 font-semibold text-text-secondary">{displayLabel}</div> + {shouldShowTooltip ? ( + <Popover> + <PopoverTrigger + openOnHover + aria-label={topic.name} + className="w-full border-0 bg-transparent p-0 text-left" + > + {content} + </PopoverTrigger> + <PopoverContent popupClassName="max-w-[300px] px-3 py-2 system-xs-regular wrap-break-word text-text-tertiary"> + <ReadonlyInputWithSelectVar value={topic.name} nodeId={nodeId} /> + </PopoverContent> + </Popover> + ) : ( + content + )} </div> ) } @@ -72,13 +65,10 @@ const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => { const { provider, name: modelId } = data.model // const tempTopics = data.topics const topics = data.classes - const { - textGenerationModelList, - } = useTextGenerationCurrentProviderAndModelAndModelList() + const { textGenerationModelList } = useTextGenerationCurrentProviderAndModelAndModelList() const hasSetModel = provider && modelId - if (!hasSetModel && !topics.length) - return null + if (!hasSetModel && !topics.length) return null return ( <div className="mb-1 px-3 py-1"> @@ -90,32 +80,22 @@ const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => { readonly /> )} - { - !!topics.length && ( - <div className="mt-2 space-y-0.5"> - <div className="space-y-0.5"> - {topics.map((topic, index) => ( - <div - key={topic.id} - className="relative" - > - <TruncatedClassItem - topic={topic} - index={index} - nodeId={id} - t={t} - /> - <NodeSourceHandle - {...props} - handleId={topic.id} - handleClassName="top-1/2! -translate-y-1/2! -right-[21px]!" - /> - </div> - ))} - </div> + {!!topics.length && ( + <div className="mt-2 space-y-0.5"> + <div className="space-y-0.5"> + {topics.map((topic, index) => ( + <div key={topic.id} className="relative"> + <TruncatedClassItem topic={topic} index={index} nodeId={id} t={t} /> + <NodeSourceHandle + {...props} + handleId={topic.id} + handleClassName="top-1/2! -translate-y-1/2! -right-[21px]!" + /> + </div> + ))} </div> - ) - } + </div> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/question-classifier/panel.tsx b/web/app/components/workflow/nodes/question-classifier/panel.tsx index a73ebfb7066e40..ed7d78ca69d466 100644 --- a/web/app/components/workflow/nodes/question-classifier/panel.tsx +++ b/web/app/components/workflow/nodes/question-classifier/panel.tsx @@ -16,10 +16,7 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.questionClassifiers' -const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { @@ -48,10 +45,7 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({ return ( <div className="pt-2"> <div className="space-y-4 px-4"> - <Field - title={t($ => $[`${i18nPrefix}.model`], { ns: 'workflow' })} - required - > + <Field title={t(($) => $[`${i18nPrefix}.model`], { ns: 'workflow' })} required> <ModelParameterModal popupClassName="w-[387px]!" isInWorkflow @@ -68,10 +62,7 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({ availableNodes={availableNodesWithParent} /> </Field> - <Field - title={t($ => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })} - required - > + <Field title={t(($) => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })} required> <VarReferencePicker readonly={readOnly} isShowNodeName @@ -101,9 +92,7 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({ /> <Split /> </div> - <FieldCollapse - title={t($ => $[`${i18nPrefix}.advancedSetting`], { ns: 'workflow' })} - > + <FieldCollapse title={t(($) => $[`${i18nPrefix}.advancedSetting`], { ns: 'workflow' })}> <AdvancedSetting hideMemorySetting={!isChatMode} instruction={inputs.instruction} @@ -125,17 +114,17 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({ <VarItem name="class_name" type="string" - description={t($ => $[`${i18nPrefix}.outputVars.className`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.className`], { ns: 'workflow' })} /> <VarItem name="class_label" type="string" - description={t($ => $[`${i18nPrefix}.outputVars.classLabel`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.classLabel`], { ns: 'workflow' })} /> <VarItem name="usage" type="object" - description={t($ => $[`${i18nPrefix}.outputVars.usage`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.usage`], { ns: 'workflow' })} /> </> </OutputVars> diff --git a/web/app/components/workflow/nodes/question-classifier/storage.ts b/web/app/components/workflow/nodes/question-classifier/storage.ts index e13a5436c7e4f0..9357fd0bd262bf 100644 --- a/web/app/components/workflow/nodes/question-classifier/storage.ts +++ b/web/app/components/workflow/nodes/question-classifier/storage.ts @@ -6,6 +6,4 @@ const [ _useSetInlineLabelHintDismissed, ] = createLocalStorageState<boolean>('question-classifier-inline-label-hint-dismissed') -export { - useInlineLabelHintDismissed, -} +export { useInlineLabelHintDismissed } diff --git a/web/app/components/workflow/nodes/question-classifier/types.ts b/web/app/components/workflow/nodes/question-classifier/types.ts index 3f11299aeba8d7..41189a52d7adc7 100644 --- a/web/app/components/workflow/nodes/question-classifier/types.ts +++ b/web/app/components/workflow/nodes/question-classifier/types.ts @@ -1,4 +1,10 @@ -import type { CommonNodeType, Memory, ModelConfig, ValueSelector, VisionSetting } from '@/app/components/workflow/types' +import type { + CommonNodeType, + Memory, + ModelConfig, + ValueSelector, + VisionSetting, +} from '@/app/components/workflow/types' export type Topic = { id: string diff --git a/web/app/components/workflow/nodes/question-classifier/use-config.ts b/web/app/components/workflow/nodes/question-classifier/use-config.ts index 8a77ae2638f2c4..074af92eed52d5 100644 --- a/web/app/components/workflow/nodes/question-classifier/use-config.ts +++ b/web/app/components/workflow/nodes/question-classifier/use-config.ts @@ -10,11 +10,7 @@ import { useHooksStore } from '@/app/components/workflow/hooks-store/store' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { AppModeEnum } from '@/types/app' import { FlowType } from '@/types/common' -import { - useIsChatMode, - useNodesReadOnly, - useWorkflow, -} from '../../hooks' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks' import useConfigVision from '../../hooks/use-config-vision' import { useStore } from '../../store' import { BlockEnum, VarType } from '../../types' @@ -24,38 +20,45 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { const updateNodeInternals = useUpdateNodeInternals() const { nodesReadOnly: readOnly } = useNodesReadOnly() const isChatMode = useIsChatMode() - const flowType = useHooksStore(s => s.configsMap?.flowType) + const flowType = useHooksStore((s) => s.configsMap?.flowType) const isSnippetFlow = flowType === FlowType.snippet - const defaultConfig = useStore(s => s.nodesDefaultConfigs)?.[payload.type] + const defaultConfig = useStore((s) => s.nodesDefaultConfigs)?.[payload.type] const { getBeforeNodesInSameBranch } = useWorkflow() - const startNode = getBeforeNodesInSameBranch(id).find(node => node.data.type === BlockEnum.Start) + const startNode = getBeforeNodesInSameBranch(id).find( + (node) => node.data.type === BlockEnum.Start, + ) const startNodeId = startNode?.id const { inputs, setInputs: doSetInputs } = useNodeCrud<QuestionClassifierNodeType>(id, payload) const inputRef = useRef(inputs) - const setInputs = useCallback((newInputs: QuestionClassifierNodeType) => { - doSetInputs(newInputs) - inputRef.current = newInputs - }, [doSetInputs]) + const setInputs = useCallback( + (newInputs: QuestionClassifierNodeType) => { + doSetInputs(newInputs) + inputRef.current = newInputs + }, + [doSetInputs], + ) useEffect(() => { inputRef.current = inputs }, [inputs]) const isHandlingModelChangeRef = useRef(false) - const { - currentProvider, - currentModel, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) + const { currentProvider, currentModel } = useModelListAndDefaultModelAndCurrentProviderAndModel( + ModelTypeEnum.textGeneration, + ) const model = inputs.model const modelMode = inputs.model?.mode const isChatModel = modelMode === AppModeEnum.CHAT - const handleVisionChange = useCallback((newPayload: QuestionClassifierNodeType['vision']) => { - const newInputs = produce(inputRef.current, (draft) => { - draft.vision = newPayload - }) - setInputs(newInputs) - }, [setInputs]) + const handleVisionChange = useCallback( + (newPayload: QuestionClassifierNodeType['vision']) => { + const newInputs = produce(inputRef.current, (draft) => { + draft.vision = newPayload + }) + setInputs(newInputs) + }, + [setInputs], + ) const { isVisionModel, @@ -67,15 +70,18 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { onChange: handleVisionChange, }) - const handleModelChanged = useCallback((model: { provider: string, modelId: string, mode?: string }) => { - const newInputs = produce(inputRef.current, (draft) => { - draft.model.provider = model.provider - draft.model.name = model.modelId - draft.model.mode = model.mode! - }) - isHandlingModelChangeRef.current = true - setInputs(newInputs) - }, [setInputs]) + const handleModelChanged = useCallback( + (model: { provider: string; modelId: string; mode?: string }) => { + const newInputs = produce(inputRef.current, (draft) => { + draft.model.provider = model.provider + draft.model.name = model.modelId + draft.model.mode = model.mode! + }) + isHandlingModelChangeRef.current = true + setInputs(newInputs) + }, + [setInputs], + ) useEffect(() => { if (currentProvider?.provider && currentModel?.model && !model.provider) { @@ -89,87 +95,90 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { } }, [model.provider, currentProvider, currentModel, handleModelChanged]) - const handleCompletionParamsChange = useCallback((newParams: Record<string, unknown>) => { - const newInputs = produce(inputs, (draft) => { - draft.model.completion_params = newParams - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const handleCompletionParamsChange = useCallback( + (newParams: Record<string, unknown>) => { + const newInputs = produce(inputs, (draft) => { + draft.model.completion_params = newParams + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) // change to vision model to set vision enabled, else disabled useEffect(() => { - if (!isHandlingModelChangeRef.current) - return + if (!isHandlingModelChangeRef.current) return isHandlingModelChangeRef.current = false startTransition(() => { handleVisionConfigAfterModelChanged() }) }, [handleVisionConfigAfterModelChanged, isVisionModel]) - const handleQueryVarChange = useCallback((newVar: ValueSelector | string) => { - const newInputs = produce(inputs, (draft) => { - draft.query_variable_selector = newVar as ValueSelector - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const handleQueryVarChange = useCallback( + (newVar: ValueSelector | string) => { + const newInputs = produce(inputs, (draft) => { + draft.query_variable_selector = newVar as ValueSelector + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) useEffect(() => { const isReady = defaultConfig && Object.keys(defaultConfig).length > 0 - if (!isReady) - return + if (!isReady) return const currentInputs = inputRef.current let shouldUpdate = false const nextInputs = produce(currentInputs, (draft) => { - if (!draft.model) - draft.model = defaultConfig.model + if (!draft.model) draft.model = defaultConfig.model - if (!draft.classes) - draft.classes = defaultConfig.classes + if (!draft.classes) draft.classes = defaultConfig.classes - if (!draft._targetBranches) - draft._targetBranches = defaultConfig._targetBranches + if (!draft._targetBranches) draft._targetBranches = defaultConfig._targetBranches - if (!draft.vision) - draft.vision = defaultConfig.vision + if (!draft.vision) draft.vision = defaultConfig.vision - if (!isSnippetFlow && draft.query_variable_selector.length === 0 && isChatMode && startNodeId) { + if ( + !isSnippetFlow && + draft.query_variable_selector.length === 0 && + isChatMode && + startNodeId + ) { draft.query_variable_selector = [startNodeId, 'sys.query'] shouldUpdate = true } - if (!currentInputs.model && defaultConfig.model) - shouldUpdate = true + if (!currentInputs.model && defaultConfig.model) shouldUpdate = true - if (!currentInputs.classes && defaultConfig.classes) - shouldUpdate = true + if (!currentInputs.classes && defaultConfig.classes) shouldUpdate = true - if (!currentInputs._targetBranches && defaultConfig._targetBranches) - shouldUpdate = true + if (!currentInputs._targetBranches && defaultConfig._targetBranches) shouldUpdate = true - if (!currentInputs.vision && defaultConfig.vision) - shouldUpdate = true + if (!currentInputs.vision && defaultConfig.vision) shouldUpdate = true }) - if (!shouldUpdate) - return + if (!shouldUpdate) return startTransition(() => { setInputs(nextInputs) }) }, [defaultConfig, isChatMode, isSnippetFlow, setInputs, startNodeId]) - const handleClassesChange = useCallback((newClasses: Topic[]) => { - const newInputs = produce(inputs, (draft) => { - draft.classes = newClasses - draft._targetBranches = newClasses.map((item: Topic) => ({ - id: item.id, - name: item.name, - })) - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const handleClassesChange = useCallback( + (newClasses: Topic[]) => { + const newInputs = produce(inputs, (draft) => { + draft.classes = newClasses + draft._targetBranches = newClasses.map((item: Topic) => ({ + id: item.id, + name: item.name, + })) + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) const filterInputVar = useCallback((varPayload: Var) => { return [VarType.number, VarType.string].includes(varPayload.type) @@ -179,17 +188,12 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { return [VarType.file, VarType.arrayFile].includes(varPayload.type) }, []) - const { - availableVars, - availableNodesWithParent, - } = useAvailableVarList(id, { + const { availableVars, availableNodesWithParent } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar: filterInputVar, }) - const { - availableVars: availableVisionVars, - } = useAvailableVarList(id, { + const { availableVars: availableVisionVars } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar: filterVisionInputVar, }) @@ -200,40 +204,49 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { context: false, } - const handleInstructionChange = useCallback((instruction: string) => { - const newInputs = produce(inputs, (draft) => { - draft.instruction = instruction - }) - setInputs(newInputs) - }, [inputs, setInputs]) - - const handleMemoryChange = useCallback((memory?: Memory) => { - const newInputs = produce(inputs, (draft) => { - draft.memory = memory - }) - setInputs(newInputs) - }, [inputs, setInputs]) + const handleInstructionChange = useCallback( + (instruction: string) => { + const newInputs = produce(inputs, (draft) => { + draft.instruction = instruction + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) + + const handleMemoryChange = useCallback( + (memory?: Memory) => { + const newInputs = produce(inputs, (draft) => { + draft.memory = memory + }) + setInputs(newInputs) + }, + [inputs, setInputs], + ) const filterVar = useCallback((varPayload: Var) => { return varPayload.type === VarType.string }, []) - const handleSortTopic = useCallback((newTopics: (Topic & { id: string })[]) => { - const newInputs = produce(inputs, (draft) => { - const sortedTopics = newTopics.filter(Boolean) - draft.classes = sortedTopics.map(item => ({ - id: item.id, - name: item.name, - label: item.label, - })) - draft._targetBranches = sortedTopics.map(item => ({ - id: item.id, - name: item.name, - })) - }) - setInputs(newInputs) - updateNodeInternals(id) - }, [id, inputs, setInputs, updateNodeInternals]) + const handleSortTopic = useCallback( + (newTopics: (Topic & { id: string })[]) => { + const newInputs = produce(inputs, (draft) => { + const sortedTopics = newTopics.filter(Boolean) + draft.classes = sortedTopics.map((item) => ({ + id: item.id, + name: item.name, + label: item.label, + })) + draft._targetBranches = sortedTopics.map((item) => ({ + id: item.id, + name: item.name, + })) + }) + setInputs(newInputs) + updateNodeInternals(id) + }, + [id, inputs, setInputs, updateNodeInternals], + ) return { readOnly, diff --git a/web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts b/web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts index 6ff80e7ca5702c..dadfc44d43d548 100644 --- a/web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts @@ -35,47 +35,49 @@ const useSingleRunFormParams = ({ const model = inputs.model - const { - isVisionModel, - } = useConfigVision(model, { + const { isVisionModel } = useConfigVision(model, { payload: inputs.vision, onChange: noop, }) const visionFiles = runInputData['#files#'] - const setVisionFiles = useCallback((newFiles: any[]) => { - setRunInputData?.({ - ...runInputDataRef.current, - '#files#': newFiles, - }) - }, [runInputDataRef, setRunInputData]) + const setVisionFiles = useCallback( + (newFiles: any[]) => { + setRunInputData?.({ + ...runInputDataRef.current, + '#files#': newFiles, + }) + }, + [runInputDataRef, setRunInputData], + ) const varInputs = getInputVars([inputs.instruction]) const inputVarValues = (() => { const vars: Record<string, any> = {} Object.keys(runInputData) - .filter(key => !['#files#'].includes(key)) + .filter((key) => !['#files#'].includes(key)) .forEach((key) => { vars[key] = runInputData[key] }) return vars })() - const setInputVarValues = useCallback((newPayload: Record<string, any>) => { - const newVars = { - ...newPayload, - '#files#': runInputDataRef.current['#files#'], - } - setRunInputData?.(newVars) - }, [runInputDataRef, setRunInputData]) + const setInputVarValues = useCallback( + (newPayload: Record<string, any>) => { + const newVars = { + ...newPayload, + '#files#': runInputDataRef.current['#files#'], + } + setRunInputData?.(newVars) + }, + [runInputDataRef, setRunInputData], + ) const filterVisionInputVar = useCallback((varPayload: Var) => { return [VarType.file, VarType.arrayFile].includes(varPayload.type) }, []) - const { - availableVars: availableVisionVars, - } = useAvailableVarList(id, { + const { availableVars: availableVisionVars } = useAvailableVarList(id, { onlyLeafNodeVar: false, filterVar: filterVisionInputVar, }) @@ -83,48 +85,53 @@ const useSingleRunFormParams = ({ const forms = (() => { const forms: FormProps[] = [] - forms.push( - { - label: t($ => $['nodes.llm.singleRun.variable'], { ns: 'workflow' })!, - inputs: [{ - label: t($ => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })!, + forms.push({ + label: t(($) => $['nodes.llm.singleRun.variable'], { ns: 'workflow' })!, + inputs: [ + { + label: t(($) => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })!, variable: 'query', type: InputVarType.paragraph, required: true, - }, ...varInputs], - values: inputVarValues, - onChange: setInputVarValues, - }, - ) + }, + ...varInputs, + ], + values: inputVarValues, + onChange: setInputVarValues, + }) if (isVisionModel && payload.vision?.enabled && payload.vision?.configs?.variable_selector) { - const currentVariable = findVariableWhenOnLLMVision(payload.vision.configs.variable_selector, availableVisionVars) + const currentVariable = findVariableWhenOnLLMVision( + payload.vision.configs.variable_selector, + availableVisionVars, + ) - forms.push( - { - label: t($ => $['nodes.llm.vision'], { ns: 'workflow' })!, - inputs: [{ + forms.push({ + label: t(($) => $['nodes.llm.vision'], { ns: 'workflow' })!, + inputs: [ + { label: currentVariable?.variable as any, variable: '#files#', type: currentVariable?.formType as any, required: false, - }], - values: { '#files#': visionFiles }, - onChange: keyValue => setVisionFiles(keyValue['#files#']), - }, - ) + }, + ], + values: { '#files#': visionFiles }, + onChange: (keyValue) => setVisionFiles(keyValue['#files#']), + }) } return forms })() const getDependentVars = () => { - const promptVars = varInputs.map((item) => { - // Guard against null/undefined variable to prevent app crash - if (!item.variable || typeof item.variable !== 'string') - return [] + const promptVars = varInputs + .map((item) => { + // Guard against null/undefined variable to prevent app crash + if (!item.variable || typeof item.variable !== 'string') return [] - return item.variable.slice(1, -1).split('.') - }).filter(arr => arr.length > 0) + return item.variable.slice(1, -1).split('.') + }) + .filter((arr) => arr.length > 0) const vars = [payload.query_variable_selector, ...promptVars] if (isVisionModel && payload.vision?.enabled && payload.vision?.configs?.variable_selector) { const visionVar = payload.vision.configs.variable_selector @@ -134,10 +141,8 @@ const useSingleRunFormParams = ({ } const getDependentVar = (variable: string) => { - if (variable === 'query') - return payload.query_variable_selector - if (variable === '#files#') - return payload.vision.configs?.variable_selector + if (variable === 'query') return payload.query_variable_selector + if (variable === '#files#') return payload.vision.configs?.variable_selector return false } diff --git a/web/app/components/workflow/nodes/start-placeholder/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/start-placeholder/__tests__/node.spec.tsx index 6d3dcfcce41045..1b7f861872b567 100644 --- a/web/app/components/workflow/nodes/start-placeholder/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/start-placeholder/__tests__/node.spec.tsx @@ -7,10 +7,12 @@ describe('StartPlaceholderNode', () => { const { rerender } = render( <Node id="start-placeholder" - data={{ - type: BlockEnum.StartPlaceholder, - selected: true, - } as never} + data={ + { + type: BlockEnum.StartPlaceholder, + selected: true, + } as never + } />, ) @@ -19,13 +21,17 @@ describe('StartPlaceholderNode', () => { rerender( <Node id="start-placeholder" - data={{ - type: BlockEnum.StartPlaceholder, - selected: false, - } as never} + data={ + { + type: BlockEnum.StartPlaceholder, + selected: false, + } as never + } />, ) - expect(screen.getByText('workflow.nodes.startPlaceholder.nodeCollapsedDescription')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.startPlaceholder.nodeCollapsedDescription'), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/start-placeholder/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/start-placeholder/__tests__/panel.spec.tsx index 5e98de3b043bc2..70b5ce390fe0aa 100644 --- a/web/app/components/workflow/nodes/start-placeholder/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/start-placeholder/__tests__/panel.spec.tsx @@ -39,19 +39,20 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/workflow/hooks-store', () => ({ - useHooksStore: (selector: (state: unknown) => unknown) => selector({ - availableNodesMetaData: { - nodesMap: { - [BlockEnum.Start]: { - defaultValue: { - title: 'User Input', - desc: '', - variables: [], + useHooksStore: (selector: (state: unknown) => unknown) => + selector({ + availableNodesMetaData: { + nodesMap: { + [BlockEnum.Start]: { + defaultValue: { + title: 'User Input', + desc: '', + variables: [], + }, }, }, }, - }, - }), + }), })) vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ @@ -61,10 +62,11 @@ vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: unknown) => unknown) => selector({ - setHasSelectedStartNode: mocks.setHasSelectedStartNode, - setShouldAutoOpenStartNodeSelector: mocks.setShouldAutoOpenStartNodeSelector, - }), + useStore: (selector: (state: unknown) => unknown) => + selector({ + setHasSelectedStartNode: mocks.setHasSelectedStartNode, + setShouldAutoOpenStartNodeSelector: mocks.setShouldAutoOpenStartNodeSelector, + }), })) const createPlaceholderNode = (): Node => ({ @@ -104,11 +106,7 @@ describe('StartPlaceholderPanel', () => { describe('Start node selection', () => { it('should replace the placeholder with user input and auto-open the next node selector', () => { render( - <Panel - id="placeholder-1" - data={createPlaceholderNode().data} - panelProps={{} as never} - />, + <Panel id="placeholder-1" data={createPlaceholderNode().data} panelProps={{} as never} />, ) fireEvent.click(screen.getByRole('button', { name: 'Select User Input' })) @@ -126,9 +124,13 @@ describe('StartPlaceholderPanel', () => { expect(currentNodes[1]?.data.selected).toBe(false) expect(mocks.setHasSelectedStartNode).toHaveBeenCalledWith(true) expect(mocks.setShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(true) - expect(mocks.handleSyncWorkflowDraft).toHaveBeenCalledWith(true, false, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect(mocks.handleSyncWorkflowDraft).toHaveBeenCalledWith( + true, + false, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) const callback = mocks.handleSyncWorkflowDraft.mock.calls[0]?.[2] as { onSuccess: () => void } callback.onSuccess() diff --git a/web/app/components/workflow/nodes/start-placeholder/default.ts b/web/app/components/workflow/nodes/start-placeholder/default.ts index f980fae71867c9..d2fc3f2266ac0e 100644 --- a/web/app/components/workflow/nodes/start-placeholder/default.ts +++ b/web/app/components/workflow/nodes/start-placeholder/default.ts @@ -22,7 +22,7 @@ const nodeDefault: NodeDefault<StartPlaceholderNodeType> = { checkValid(_payload, t: TFunction<'workflow'>) { return { isValid: false, - errorMessage: t($ => $['nodes.startPlaceholder.validationRequired'], { ns: 'workflow' }), + errorMessage: t(($) => $['nodes.startPlaceholder.validationRequired'], { ns: 'workflow' }), } }, } diff --git a/web/app/components/workflow/nodes/start-placeholder/node.tsx b/web/app/components/workflow/nodes/start-placeholder/node.tsx index 01e0b7d3a814b8..a7ddb8df69b019 100644 --- a/web/app/components/workflow/nodes/start-placeholder/node.tsx +++ b/web/app/components/workflow/nodes/start-placeholder/node.tsx @@ -5,9 +5,7 @@ import { useTranslation } from 'react-i18next' const i18nPrefix = 'nodes.startPlaceholder' -const Node: FC<NodeProps> = ({ - data, -}) => { +const Node: FC<NodeProps> = ({ data }) => { const { t } = useTranslation() const descriptionKey = data.selected ? 'nodeDescription' : 'nodeCollapsedDescription' @@ -15,7 +13,7 @@ const Node: FC<NodeProps> = ({ <div className="px-2.5 py-1"> <div className="rounded-md bg-workflow-block-parma-bg px-1.5 py-[5px]"> <div className="system-xs-regular wrap-break-word text-text-tertiary"> - {t($ => $[`${i18nPrefix}.${descriptionKey}`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.${descriptionKey}`], { ns: 'workflow' })} </div> </div> </div> diff --git a/web/app/components/workflow/nodes/start-placeholder/panel.tsx b/web/app/components/workflow/nodes/start-placeholder/panel.tsx index 9180d6638bbe2c..0f775e60afa8e5 100644 --- a/web/app/components/workflow/nodes/start-placeholder/panel.tsx +++ b/web/app/components/workflow/nodes/start-placeholder/panel.tsx @@ -6,10 +6,7 @@ import type { } from '@/app/components/workflow/block-selector/types' import type { NodePanelProps } from '@/app/components/workflow/types' import * as React from 'react' -import { - useCallback, - useState, -} from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import SearchBox from '@/app/components/plugins/marketplace/search-box' @@ -45,92 +42,94 @@ const getTriggerPluginNodeData = ( } } -const Panel: FC<NodePanelProps<StartPlaceholderNodeType>> = ({ - id, -}) => { +const Panel: FC<NodePanelProps<StartPlaceholderNodeType>> = ({ id }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') const [tags, setTags] = useState<string[]>([]) - const availableNodesMetaData = useHooksStore(s => s.availableNodesMetaData) - const setHasSelectedStartNode = useWorkflowStore(s => s.setHasSelectedStartNode) - const setShouldAutoOpenStartNodeSelector = useWorkflowStore(s => s.setShouldAutoOpenStartNodeSelector) + const availableNodesMetaData = useHooksStore((s) => s.availableNodesMetaData) + const setHasSelectedStartNode = useWorkflowStore((s) => s.setHasSelectedStartNode) + const setShouldAutoOpenStartNodeSelector = useWorkflowStore( + (s) => s.setShouldAutoOpenStartNodeSelector, + ) const reactFlowStore = useStoreApi() const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl() const { handleSyncWorkflowDraft } = useNodesSyncDraft() - const handleSelectStartNode = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => { - const nodeDefault = availableNodesMetaData?.nodesMap?.[nodeType] - if (!nodeDefault?.defaultValue) - return + const handleSelectStartNode = useCallback( + (nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => { + const nodeDefault = availableNodesMetaData?.nodesMap?.[nodeType] + if (!nodeDefault?.defaultValue) return + + const baseNodeData = { ...nodeDefault.defaultValue } + const mergedNodeData = (() => { + if (nodeType !== BlockEnum.TriggerPlugin || !toolConfig) { + return { + ...baseNodeData, + ...toolConfig, + } + } + + const triggerNodeData = getTriggerPluginNodeData( + toolConfig as TriggerDefaultValue, + baseNodeData.title, + baseNodeData.desc, + ) - const baseNodeData = { ...nodeDefault.defaultValue } - const mergedNodeData = (() => { - if (nodeType !== BlockEnum.TriggerPlugin || !toolConfig) { return { ...baseNodeData, - ...toolConfig, + ...triggerNodeData, + config: { + ...(baseNodeData as { config?: Record<string, unknown> }).config, + ...triggerNodeData.config, + }, } - } - - const triggerNodeData = getTriggerPluginNodeData( - toolConfig as TriggerDefaultValue, - baseNodeData.title, - baseNodeData.desc, - ) + })() - return { - ...baseNodeData, - ...triggerNodeData, - config: { - ...(baseNodeData as { config?: Record<string, unknown> }).config, - ...triggerNodeData.config, - }, - } - })() + const { getNodes, setNodes } = reactFlowStore.getState() + const nextNodes = getNodes().map((node) => { + if (node.id !== id) { + return { + ...node, + data: { + ...node.data, + selected: false, + }, + } + } - const { getNodes, setNodes } = reactFlowStore.getState() - const nextNodes = getNodes().map((node) => { - if (node.id !== id) { return { ...node, data: { - ...node.data, - selected: false, + ...mergedNodeData, + type: nodeType, + selected: true, }, } - } - - return { - ...node, - data: { - ...mergedNodeData, - type: nodeType, - selected: true, - }, - } - }) + }) - setNodes(nextNodes) - setHasSelectedStartNode?.(true) - setShouldAutoOpenStartNodeSelector?.(true) + setNodes(nextNodes) + setHasSelectedStartNode?.(true) + setShouldAutoOpenStartNodeSelector?.(true) - handleSyncWorkflowDraft(true, false, { - onSuccess: () => { - autoGenerateWebhookUrl(id) - }, - onError: () => { - console.error('Failed to save start node selection to draft') - }, - }) - }, [ - autoGenerateWebhookUrl, - availableNodesMetaData?.nodesMap, - handleSyncWorkflowDraft, - id, - reactFlowStore, - setHasSelectedStartNode, - setShouldAutoOpenStartNodeSelector, - ]) + handleSyncWorkflowDraft(true, false, { + onSuccess: () => { + autoGenerateWebhookUrl(id) + }, + onError: () => { + console.error('Failed to save start node selection to draft') + }, + }) + }, + [ + autoGenerateWebhookUrl, + availableNodesMetaData?.nodesMap, + handleSyncWorkflowDraft, + id, + reactFlowStore, + setHasSelectedStartNode, + setShouldAutoOpenStartNodeSelector, + ], + ) return ( <div className="flex h-full min-h-0 flex-col"> @@ -140,7 +139,7 @@ const Panel: FC<NodePanelProps<StartPlaceholderNodeType>> = ({ onSearchChange={setSearchText} tags={tags} onTagsChange={setTags} - placeholder={t($ => $['tabs.searchTrigger'], { ns: 'workflow' })} + placeholder={t(($) => $['tabs.searchTrigger'], { ns: 'workflow' })} inputClassName="grow" /> </div> diff --git a/web/app/components/workflow/nodes/start/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/start/__tests__/node.spec.tsx index a6c74eb3f77aea..3655db3f5f2d7a 100644 --- a/web/app/components/workflow/nodes/start/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/start/__tests__/node.spec.tsx @@ -8,12 +8,14 @@ const createNodeData = (overrides: Partial<StartNodeType> = {}): StartNodeType = title: 'Start', desc: '', type: BlockEnum.Start, - variables: [{ - label: 'Question', - variable: 'query', - type: InputVarType.textInput, - required: true, - }], + variables: [ + { + label: 'Question', + variable: 'query', + type: InputVarType.textInput, + required: true, + }, + ], ...overrides, }) @@ -25,22 +27,25 @@ describe('StartNode', () => { // Start variables should render required metadata and gracefully disappear when empty. describe('Rendering', () => { it('should render configured input variables and required markers', () => { - renderNodeComponent(Node, createNodeData({ - variables: [ - { - label: 'Question', - variable: 'query', - type: InputVarType.textInput, - required: true, - }, - { - label: 'Count', - variable: 'count', - type: InputVarType.number, - required: false, - }, - ], - })) + renderNodeComponent( + Node, + createNodeData({ + variables: [ + { + label: 'Question', + variable: 'query', + type: InputVarType.textInput, + required: true, + }, + { + label: 'Count', + variable: 'count', + type: InputVarType.number, + required: false, + }, + ], + }), + ) expect(screen.getByText('query')).toBeInTheDocument() expect(screen.getByText('count')).toBeInTheDocument() @@ -48,9 +53,12 @@ describe('StartNode', () => { }) it('should render nothing when there are no start variables', () => { - const { container } = renderNodeComponent(Node, createNodeData({ - variables: [], - })) + const { container } = renderNodeComponent( + Node, + createNodeData({ + variables: [], + }), + ) expect(container).toBeEmptyDOMElement() }) diff --git a/web/app/components/workflow/nodes/start/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/start/__tests__/panel.spec.tsx index 7b7f7a096dc0ed..a65474e6ea9f2d 100644 --- a/web/app/components/workflow/nodes/start/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/start/__tests__/panel.spec.tsx @@ -22,31 +22,27 @@ vi.mock('@/app/components/app/configuration/config-var/config-modal', () => ({ onConfirm: (payload: InputVar) => void }) => { mockConfigVarModal(props) - return props.isShow - ? ( - <button - type="button" - onClick={() => props.onConfirm({ - label: 'Locale', - variable: 'locale', - type: InputVarType.textInput, - required: false, - })} - > - confirm-add-var - </button> - ) - : null + return props.isShow ? ( + <button + type="button" + onClick={() => + props.onConfirm({ + label: 'Locale', + variable: 'locale', + type: InputVarType.textInput, + required: false, + }) + } + > + confirm-add-var + </button> + ) : null }, })) vi.mock('../../_base/components/remove-effect-var-confirm', () => ({ __esModule: true, - default: (props: { - isShow: boolean - onConfirm: () => void - onCancel: () => void - }) => { + default: (props: { isShow: boolean; onConfirm: () => void; onCancel: () => void }) => { mockRemoveEffectVarConfirm(props) return props.isShow ? <div>remove-confirm</div> : null }, @@ -93,7 +89,9 @@ describe('StartPanel', () => { expect(screen.getByText('userinput.files')).toBeInTheDocument() expect(screen.queryByText('LEGACY')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.start.inputField' })) + fireEvent.click( + screen.getByRole('button', { name: 'common.operation.add workflow.nodes.start.inputField' }), + ) expect(showAddVarModal).toHaveBeenCalledTimes(1) }) @@ -121,9 +119,11 @@ describe('StartPanel', () => { fireEvent.click(screen.getByRole('button', { name: 'confirm-add-var' })) - expect(handleAddVariable).toHaveBeenCalledWith(expect.objectContaining({ - variable: 'locale', - })) + expect(handleAddVariable).toHaveBeenCalledWith( + expect.objectContaining({ + variable: 'locale', + }), + ) expect(hideAddVarModal).toHaveBeenCalledTimes(1) }) @@ -133,12 +133,14 @@ describe('StartPanel', () => { readOnly: false, isChatMode: false, inputs: createData({ - variables: [{ - label: 'Locale', - variable: 'locale', - type: InputVarType.textInput, - required: false, - }], + variables: [ + { + label: 'Locale', + variable: 'locale', + type: InputVarType.textInput, + required: false, + }, + ], }), isShowAddVarModal: true, showAddVarModal, @@ -154,9 +156,11 @@ describe('StartPanel', () => { fireEvent.click(screen.getByRole('button', { name: 'confirm-add-var' })) - expect(mockConfigVarModal).toHaveBeenCalledWith(expect.objectContaining({ - varKeys: ['locale'], - })) + expect(mockConfigVarModal).toHaveBeenCalledWith( + expect.objectContaining({ + varKeys: ['locale'], + }), + ) expect(hideAddVarModal).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts index 15ef6aafa9c3d8..a615d551b4254b 100644 --- a/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts @@ -96,10 +96,12 @@ describe('start/use-config', () => { mockUseInspectVarsCrud.mockReturnValue({ deleteNodeInspectorVars: mockDeleteNodeInspectorVars, renameInspectVarName: mockRenameInspectVarName, - nodesWithInspectVars: [{ - nodeId: 'start-node', - vars: [{ id: 'inspect-query', name: 'query' }], - }], + nodesWithInspectVars: [ + { + nodeId: 'start-node', + vars: [{ id: 'inspect-query', name: 'query' }], + }, + ], deleteInspectVar: mockDeleteInspectVar, }) mockIsVarUsedInNodes.mockReturnValue(false) @@ -132,10 +134,16 @@ describe('start/use-config', () => { }) }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - variables: renamedList, - })) - expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith('start-node', ['start-node', 'query'], ['start-node', 'prompt']) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + variables: renamedList, + }), + ) + expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith( + 'start-node', + ['start-node', 'query'], + ['start-node', 'prompt'], + ) expect(mockRenameInspectVarName).toHaveBeenCalledWith('start-node', 'query', 'prompt') expect(result.current.readOnly).toBe(false) expect(result.current.isChatMode).toBe(false) @@ -166,9 +174,11 @@ describe('start/use-config', () => { result.current.onRemoveVarConfirm() }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - variables: [expect.objectContaining({ variable: 'age' })], - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + variables: [expect.objectContaining({ variable: 'age' })], + }), + ) expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['start-node', 'query'] as ValueSelector) expect(result.current.isShowRemoveVarConfirm).toBe(false) }) @@ -178,34 +188,40 @@ describe('start/use-config', () => { let added = true act(() => { - added = result.current.handleAddVariable(createInputVar({ - label: 'Different Label', - variable: 'query', - })) + added = result.current.handleAddVariable( + createInputVar({ + label: 'Different Label', + variable: 'query', + }), + ) }) expect(added).toBe(false) - expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'varKeyError.keyAlreadyExists', - })) + expect(toastSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'varKeyError.keyAlreadyExists', + }), + ) mockSetInputs.mockClear() let addedUnique = false act(() => { - addedUnique = result.current.handleAddVariable(createInputVar({ - label: 'Locale', - variable: 'locale', - required: false, - })) + addedUnique = result.current.handleAddVariable( + createInputVar({ + label: 'Locale', + variable: 'locale', + required: false, + }), + ) }) expect(addedUnique).toBe(true) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - variables: expect.arrayContaining([ - expect.objectContaining({ variable: 'locale' }), - ]), - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.arrayContaining([expect.objectContaining({ variable: 'locale' })]), + }), + ) }) it('should clear inspector vars for non-remove list updates and reject duplicate labels', () => { @@ -223,24 +239,30 @@ describe('start/use-config', () => { result.current.handleVarListChange(typeEditedList) }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - variables: typeEditedList, - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + variables: typeEditedList, + }), + ) expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('start-node') toastSpy.mockClear() let added = true act(() => { - added = result.current.handleAddVariable(createInputVar({ - label: 'Age', - variable: 'new_age', - })) + added = result.current.handleAddVariable( + createInputVar({ + label: 'Age', + variable: 'new_age', + }), + ) }) expect(added).toBe(false) - expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'varKeyError.keyAlreadyExists', - })) + expect(toastSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'varKeyError.keyAlreadyExists', + }), + ) }) }) diff --git a/web/app/components/workflow/nodes/start/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/start/__tests__/use-single-run-form-params.spec.ts index b0b21936791266..938a79a42264c2 100644 --- a/web/app/components/workflow/nodes/start/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/start/__tests__/use-single-run-form-params.spec.ts @@ -19,12 +19,14 @@ const createPayload = (overrides: Partial<StartNodeType> = {}): StartNodeType => title: 'Start', desc: '', type: BlockEnum.Start, - variables: [{ - label: 'Question', - variable: 'query', - type: InputVarType.textInput, - required: true, - }], + variables: [ + { + label: 'Question', + variable: 'query', + type: InputVarType.textInput, + required: true, + }, + ], ...overrides, }) @@ -41,23 +43,27 @@ describe('start/use-single-run-form-params', () => { it('should include sys.query and sys.files dependencies for chat mode', () => { mockUseIsChatMode.mockReturnValue(true) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'start-node', - payload: createPayload(), - runInputData: { query: 'hello' }, - runInputDataRef: { current: {} }, - getInputVars: () => [], - setRunInputData, - toVarInputs: () => [], - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'start-node', + payload: createPayload(), + runInputData: { query: 'hello' }, + runInputDataRef: { current: {} }, + getInputVars: () => [], + setRunInputData, + toVarInputs: () => [], + }), + ) expect(result.current.forms).toHaveLength(1) expect(result.current.forms[0]!.label).toBe('nodes.llm.singleRun.variable') - expect(result.current.forms[0]!.inputs).toEqual(expect.arrayContaining([ - expect.objectContaining({ variable: 'query' }), - expect.objectContaining({ variable: '#sys.query#', required: true }), - expect.objectContaining({ variable: '#sys.files#', required: false }), - ])) + expect(result.current.forms[0]!.inputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ variable: 'query' }), + expect.objectContaining({ variable: '#sys.query#', required: true }), + expect.objectContaining({ variable: '#sys.files#', required: false }), + ]), + ) result.current.forms[0]!.onChange({ query: 'updated' }) @@ -73,19 +79,21 @@ describe('start/use-single-run-form-params', () => { it('should omit sys.query when the workflow is not in chat mode', () => { mockUseIsChatMode.mockReturnValue(false) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'start-node', - payload: createPayload(), - runInputData: {}, - runInputDataRef: { current: {} }, - getInputVars: () => [], - setRunInputData, - toVarInputs: () => [], - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'start-node', + payload: createPayload(), + runInputData: {}, + runInputDataRef: { current: {} }, + getInputVars: () => [], + setRunInputData, + toVarInputs: () => [], + }), + ) - expect(result.current.forms[0]!.inputs).toEqual(expect.not.arrayContaining([ - expect.objectContaining({ variable: '#sys.query#' }), - ])) + expect(result.current.forms[0]!.inputs).toEqual( + expect.not.arrayContaining([expect.objectContaining({ variable: '#sys.query#' })]), + ) expect(result.current.getDependentVars()).toEqual([ ['start-node', 'query'], ['sys', 'files'], diff --git a/web/app/components/workflow/nodes/start/components/__tests__/var-item.spec.tsx b/web/app/components/workflow/nodes/start/components/__tests__/var-item.spec.tsx index 720d0f1bc09120..51d35ae8987031 100644 --- a/web/app/components/workflow/nodes/start/components/__tests__/var-item.spec.tsx +++ b/web/app/components/workflow/nodes/start/components/__tests__/var-item.spec.tsx @@ -5,7 +5,8 @@ import VarItem from '../var-item' vi.mock('@/app/components/app/configuration/config-var/config-modal', () => ({ __esModule: true, - default: ({ isShow }: { isShow: boolean }) => isShow ? <div role="dialog">edit-variable</div> : null, + default: ({ isShow }: { isShow: boolean }) => + isShow ? <div role="dialog">edit-variable</div> : null, })) const createPayload = (overrides: Partial<InputVar> = {}): InputVar => ({ @@ -20,11 +21,7 @@ describe('StartVarItem', () => { it('shows named edit and remove actions on hover', () => { const handleRemove = vi.fn() const { container } = render( - <VarItem - readonly={false} - payload={createPayload()} - onRemove={handleRemove} - />, + <VarItem readonly={false} payload={createPayload()} onRemove={handleRemove} />, ) fireEvent.mouseEnter(container.firstElementChild!) diff --git a/web/app/components/workflow/nodes/start/components/var-item.tsx b/web/app/components/workflow/nodes/start/components/var-item.tsx index b18bb554c288fd..5f24420c15ea6c 100644 --- a/web/app/components/workflow/nodes/start/components/var-item.tsx +++ b/web/app/components/workflow/nodes/start/components/var-item.tsx @@ -2,9 +2,7 @@ import type { FC } from 'react' import type { InputVar, MoreInfo } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - RiDeleteBinLine, -} from '@remixicon/react' +import { RiDeleteBinLine } from '@remixicon/react' import { useBoolean, useHover } from 'ahooks' import { noop } from 'es-toolkit/function' import * as React from 'react' @@ -43,26 +41,44 @@ const VarItem: FC<Props> = ({ const ref = useRef(null) const isHovering = useHover(ref) - const [isShowEditVarModal, { - setTrue: showEditVarModal, - setFalse: hideEditVarModal, - }] = useBoolean(false) + const [isShowEditVarModal, { setTrue: showEditVarModal, setFalse: hideEditVarModal }] = + useBoolean(false) - const handlePayloadChange = useCallback((payload: InputVar, moreInfo?: MoreInfo) => { - const isValid = onChange(payload, moreInfo) - if (!isValid) - return - hideEditVarModal() - }, [onChange, hideEditVarModal]) + const handlePayloadChange = useCallback( + (payload: InputVar, moreInfo?: MoreInfo) => { + const isValid = onChange(payload, moreInfo) + if (!isValid) return + hideEditVarModal() + }, + [onChange, hideEditVarModal], + ) return ( - <div ref={ref} className={cn('flex h-8 cursor-pointer items-center justify-between rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2.5 shadow-xs hover:shadow-md', className)}> + <div + ref={ref} + className={cn( + 'flex h-8 cursor-pointer items-center justify-between rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2.5 shadow-xs hover:shadow-md', + className, + )} + > <div className="flex w-0 grow items-center space-x-1"> - <Variable02 className={cn('size-3.5 text-text-accent', canDrag && 'group-hover:opacity-0')} /> - <div title={payload.variable} className="max-w-[130px] shrink-0 truncate text-[13px] font-medium text-text-secondary">{payload.variable}</div> + <Variable02 + className={cn('size-3.5 text-text-accent', canDrag && 'group-hover:opacity-0')} + /> + <div + title={payload.variable} + className="max-w-[130px] shrink-0 truncate text-[13px] font-medium text-text-secondary" + > + {payload.variable} + </div> {payload.label && ( <> <div className="shrink-0 text-xs font-medium text-text-quaternary">·</div> - <div title={payload.label as string} className="max-w-[130px] truncate text-[13px] font-medium text-text-tertiary">{payload.label as string}</div> + <div + title={payload.label as string} + className="max-w-[130px] truncate text-[13px] font-medium text-text-tertiary" + > + {payload.label as string} + </div> </> )} {showLegacyBadge && ( @@ -75,51 +91,53 @@ const VarItem: FC<Props> = ({ <div className="ml-2 flex shrink-0 items-center"> {rightContent || ( <> - {(!isHovering || readonly) - ? ( - <> - {payload.required && ( - <div className="mr-2 text-xs font-normal text-text-tertiary">{t($ => $['nodes.start.required'], { ns: 'workflow' })}</div> - )} - <InputVarTypeIcon type={payload.type} className="size-3.5 text-text-tertiary" /> - </> - ) - : (!readonly && ( - <> - <button - type="button" - aria-label={t($ => $['operation.edit'], { ns: 'common' })} - className="mr-1 cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={showEditVarModal} - > - <Edit03 className="size-4 text-text-tertiary" aria-hidden="true" /> - </button> - <button - type="button" - aria-label={t($ => $['operation.remove'], { ns: 'common' })} - className="group cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-destructive-hover focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden" - onClick={onRemove} - > - <RiDeleteBinLine className="size-4 text-text-tertiary group-hover:text-text-destructive" aria-hidden="true" /> - </button> - </> - ))} + {!isHovering || readonly ? ( + <> + {payload.required && ( + <div className="mr-2 text-xs font-normal text-text-tertiary"> + {t(($) => $['nodes.start.required'], { ns: 'workflow' })} + </div> + )} + <InputVarTypeIcon type={payload.type} className="size-3.5 text-text-tertiary" /> + </> + ) : ( + !readonly && ( + <> + <button + type="button" + aria-label={t(($) => $['operation.edit'], { ns: 'common' })} + className="mr-1 cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={showEditVarModal} + > + <Edit03 className="size-4 text-text-tertiary" aria-hidden="true" /> + </button> + <button + type="button" + aria-label={t(($) => $['operation.remove'], { ns: 'common' })} + className="group cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-destructive-hover focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden" + onClick={onRemove} + > + <RiDeleteBinLine + className="size-4 text-text-tertiary group-hover:text-text-destructive" + aria-hidden="true" + /> + </button> + </> + ) + )} </> )} - </div> - { - isShowEditVarModal && ( - <ConfigVarModal - isShow - supportFile - payload={payload} - onClose={hideEditVarModal} - onConfirm={handlePayloadChange} - varKeys={varKeys} - /> - ) - } + {isShowEditVarModal && ( + <ConfigVarModal + isShow + supportFile + payload={payload} + onClose={hideEditVarModal} + onConfirm={handlePayloadChange} + varKeys={varKeys} + /> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/start/components/var-list.tsx b/web/app/components/workflow/nodes/start/components/var-list.tsx index 6888a48bf09265..844a76de14698b 100644 --- a/web/app/components/workflow/nodes/start/components/var-list.tsx +++ b/web/app/components/workflow/nodes/start/components/var-list.tsx @@ -16,71 +16,81 @@ import VarItem from './var-item' type Props = Readonly<{ readonly: boolean list: InputVar[] - onChange: (list: InputVar[], moreInfo?: { index: number, payload: MoreInfo }) => void + onChange: (list: InputVar[], moreInfo?: { index: number; payload: MoreInfo }) => void }> -const VarList: FC<Props> = ({ - readonly, - list, - onChange, -}) => { +const VarList: FC<Props> = ({ readonly, list, onChange }) => { const { t } = useTranslation() - const handleVarChange = useCallback((index: number) => { - return (payload: InputVar, moreInfo?: MoreInfo) => { - const newList = produce(list, (draft) => { - draft[index] = payload - }) - let errorMsgKey: 'varKeyError.keyAlreadyExists' | '' = '' - let typeName: 'variableConfig.varName' | 'variableConfig.labelName' | '' = '' - if (hasDuplicateStr(newList.map(item => item.variable))) { - errorMsgKey = 'varKeyError.keyAlreadyExists' - typeName = 'variableConfig.varName' - } - else if (hasDuplicateStr(newList.map(item => item.label as string))) { - errorMsgKey = 'varKeyError.keyAlreadyExists' - typeName = 'variableConfig.labelName' - } + const handleVarChange = useCallback( + (index: number) => { + return (payload: InputVar, moreInfo?: MoreInfo) => { + const newList = produce(list, (draft) => { + draft[index] = payload + }) + let errorMsgKey: 'varKeyError.keyAlreadyExists' | '' = '' + let typeName: 'variableConfig.varName' | 'variableConfig.labelName' | '' = '' + if (hasDuplicateStr(newList.map((item) => item.variable))) { + errorMsgKey = 'varKeyError.keyAlreadyExists' + typeName = 'variableConfig.varName' + } else if (hasDuplicateStr(newList.map((item) => item.label as string))) { + errorMsgKey = 'varKeyError.keyAlreadyExists' + typeName = 'variableConfig.labelName' + } - if (errorMsgKey && typeName) { - toast.error(t($ => $[errorMsgKey], { ns: 'appDebug', key: t($ => $[typeName], { ns: 'appDebug' }) })) - return false + if (errorMsgKey && typeName) { + toast.error( + t(($) => $[errorMsgKey], { + ns: 'appDebug', + key: t(($) => $[typeName], { ns: 'appDebug' }), + }), + ) + return false + } + onChange(newList, moreInfo ? { index, payload: moreInfo } : undefined) + return true } - onChange(newList, moreInfo ? { index, payload: moreInfo } : undefined) - return true - } - }, [list, onChange]) + }, + [list, onChange], + ) - const handleVarRemove = useCallback((index: number) => { - return () => { - const newList = produce(list, (draft) => { - draft.splice(index, 1) - }) - onChange(newList, { - index, - payload: { - type: ChangeType.remove, + const handleVarRemove = useCallback( + (index: number) => { + return () => { + const newList = produce(list, (draft) => { + draft.splice(index, 1) + }) + onChange(newList, { + index, payload: { - beforeKey: list[index]!.variable, + type: ChangeType.remove, + payload: { + beforeKey: list[index]!.variable, + }, }, - }, - }) - } - }, [list, onChange]) + }) + } + }, + [list, onChange], + ) - const listWithIds = useMemo(() => list.map((item) => { - return { - id: item.variable, - variable: { ...item }, - } - }), [list]) + const listWithIds = useMemo( + () => + list.map((item) => { + return { + id: item.variable, + variable: { ...item }, + } + }), + [list], + ) const varCount = list.length if (list.length === 0) { return ( <div className="flex h-[42px] items-center justify-center rounded-md bg-components-panel-bg text-xs leading-[18px] font-normal text-text-tertiary"> - {t($ => $['nodes.start.noVarTip'], { ns: 'workflow' })} + {t(($) => $['nodes.start.noVarTip'], { ns: 'workflow' })} </div> ) } @@ -91,7 +101,9 @@ const VarList: FC<Props> = ({ <ReactSortable className="space-y-1" list={listWithIds} - setList={(list) => { onChange(list.map(item => item.variable)) }} + setList={(list) => { + onChange(list.map((item) => item.variable)) + }} handle=".handle" ghostClass="opacity-50" animation={150} @@ -104,14 +116,15 @@ const VarList: FC<Props> = ({ payload={itemWithId.variable} onChange={handleVarChange(index)} onRemove={handleVarRemove(index)} - varKeys={list.map(item => item.variable)} + varKeys={list.map((item) => item.variable)} canDrag={canDrag} /> {canDrag && ( - <RiDraggable className={cn( - 'handle absolute top-2.5 left-3 hidden size-3 cursor-pointer text-text-tertiary', - 'group-hover:block', - )} + <RiDraggable + className={cn( + 'handle absolute top-2.5 left-3 hidden size-3 cursor-pointer text-text-tertiary', + 'group-hover:block', + )} /> )} </div> diff --git a/web/app/components/workflow/nodes/start/node.tsx b/web/app/components/workflow/nodes/start/node.tsx index f9804347a02059..f3d07042af652a 100644 --- a/web/app/components/workflow/nodes/start/node.tsx +++ b/web/app/components/workflow/nodes/start/node.tsx @@ -8,27 +8,33 @@ import InputVarTypeIcon from '../_base/components/input-var-type-icon' const i18nPrefix = 'nodes.start' -const Node: FC<NodeProps<StartNodeType>> = ({ - data, -}) => { +const Node: FC<NodeProps<StartNodeType>> = ({ data }) => { const { t } = useTranslation() const { variables } = data - if (!variables.length) - return null + if (!variables.length) return null return ( <div className="mb-1 px-3 py-1"> <div className="space-y-0.5"> - {variables.map(variable => ( - <div key={variable.variable} className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1"> + {variables.map((variable) => ( + <div + key={variable.variable} + className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1" + > <div className="flex w-0 grow items-center space-x-1"> <Variable02 className="size-3.5 shrink-0 text-text-accent" /> - <span className="w-0 grow truncate system-xs-regular text-text-secondary">{variable.variable}</span> + <span className="w-0 grow truncate system-xs-regular text-text-secondary"> + {variable.variable} + </span> </div> <div className="ml-1 flex items-center space-x-1"> - {variable.required && <span className="system-2xs-regular-uppercase text-text-tertiary">{t($ => $[`${i18nPrefix}.required`], { ns: 'workflow' })}</span>} + {variable.required && ( + <span className="system-2xs-regular-uppercase text-text-tertiary"> + {t(($) => $[`${i18nPrefix}.required`], { ns: 'workflow' })} + </span> + )} <InputVarTypeIcon type={variable.type} className="size-3 text-text-tertiary" /> </div> </div> diff --git a/web/app/components/workflow/nodes/start/panel.tsx b/web/app/components/workflow/nodes/start/panel.tsx index 0fb39f1f98685c..bd0bb7f801f8c9 100644 --- a/web/app/components/workflow/nodes/start/panel.tsx +++ b/web/app/components/workflow/nodes/start/panel.tsx @@ -13,10 +13,7 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.start' -const Panel: FC<NodePanelProps<StartNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<StartNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { readOnly, @@ -34,8 +31,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ const handleAddVarConfirm = (payload: InputVar) => { const isValid = handleAddVariable(payload) - if (!isValid) - return + if (!isValid) return hideAddVarModal() } @@ -43,20 +39,18 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ <div className="mt-2"> <div className="space-y-4 px-4 pb-2"> <Field - title={t($ => $[`${i18nPrefix}.inputField`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.inputField`], { ns: 'workflow' })} operations={ - !readOnly - ? ( - <button - type="button" - aria-label={`${t($ => $['operation.add'], { ns: 'common' })} ${t($ => $[`${i18nPrefix}.inputField`], { ns: 'workflow' })}`} - className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={showAddVarModal} - > - <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> - </button> - ) - : undefined + !readOnly ? ( + <button + type="button" + aria-label={`${t(($) => $['operation.add'], { ns: 'common' })} ${t(($) => $[`${i18nPrefix}.inputField`], { ns: 'workflow' })}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={showAddVarModal} + > + <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> + </button> + ) : undefined } > <> @@ -68,33 +62,31 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ <div className="mt-1 space-y-1"> <Split className="my-2" /> - { - isChatMode && ( - <VarItem - readonly - payload={{ + {isChatMode && ( + <VarItem + readonly + payload={ + { variable: 'userinput.query', - } as any} - rightContent={( - <div className="text-xs font-normal text-text-tertiary"> - String - </div> - )} - /> - ) - } + } as any + } + rightContent={ + <div className="text-xs font-normal text-text-tertiary">String</div> + } + /> + )} <VarItem readonly showLegacyBadge={!isChatMode} - payload={{ - variable: 'userinput.files', - } as any} - rightContent={( - <div className="text-xs font-normal text-text-tertiary"> - Array[File] - </div> - )} + payload={ + { + variable: 'userinput.files', + } as any + } + rightContent={ + <div className="text-xs font-normal text-text-tertiary">Array[File]</div> + } /> </div> </> @@ -108,7 +100,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ isShow={isShowAddVarModal} onClose={hideAddVarModal} onConfirm={handleAddVarConfirm} - varKeys={inputs.variables.map(v => v.variable)} + varKeys={inputs.variables.map((v) => v.variable)} /> )} diff --git a/web/app/components/workflow/nodes/start/use-config.ts b/web/app/components/workflow/nodes/start/use-config.ts index 90151418ad01e1..7dbbd012a868b3 100644 --- a/web/app/components/workflow/nodes/start/use-config.ts +++ b/web/app/components/workflow/nodes/start/use-config.ts @@ -5,11 +5,7 @@ import { useBoolean } from 'ahooks' import { produce } from 'immer' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - useIsChatMode, - useNodesReadOnly, - useWorkflow, -} from '@/app/components/workflow/hooks' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { ChangeType } from '@/app/components/workflow/types' import { hasDuplicateStr } from '@/utils/var' @@ -23,53 +19,66 @@ const useConfig = (id: string, payload: StartNodeType) => { const { inputs, setInputs } = useNodeCrud<StartNodeType>(id, payload) - const { - deleteNodeInspectorVars, - renameInspectVarName, - nodesWithInspectVars, - deleteInspectVar, - } = useInspectVarsCrud() + const { deleteNodeInspectorVars, renameInspectVarName, nodesWithInspectVars, deleteInspectVar } = + useInspectVarsCrud() - const [isShowAddVarModal, { - setTrue: showAddVarModal, - setFalse: hideAddVarModal, - }] = useBoolean(false) + const [isShowAddVarModal, { setTrue: showAddVarModal, setFalse: hideAddVarModal }] = + useBoolean(false) - const [isShowRemoveVarConfirm, { - setTrue: showRemoveVarConfirm, - setFalse: hideRemoveVarConfirm, - }] = useBoolean(false) + const [ + isShowRemoveVarConfirm, + { setTrue: showRemoveVarConfirm, setFalse: hideRemoveVarConfirm }, + ] = useBoolean(false) const [removedVar, setRemovedVar] = useState<ValueSelector>([]) const [removedIndex, setRemoveIndex] = useState(0) - const handleVarListChange = useCallback((newList: InputVar[], moreInfo?: { index: number, payload: MoreInfo }) => { - if (moreInfo?.payload?.type === ChangeType.remove) { - const varId = nodesWithInspectVars.find(node => node.nodeId === id)?.vars.find((varItem) => { - return varItem.name === moreInfo?.payload?.payload?.beforeKey - })?.id - if (varId) - deleteInspectVar(id, varId) + const handleVarListChange = useCallback( + (newList: InputVar[], moreInfo?: { index: number; payload: MoreInfo }) => { + if (moreInfo?.payload?.type === ChangeType.remove) { + const varId = nodesWithInspectVars + .find((node) => node.nodeId === id) + ?.vars.find((varItem) => { + return varItem.name === moreInfo?.payload?.payload?.beforeKey + })?.id + if (varId) deleteInspectVar(id, varId) - if (isVarUsedInNodes([id, moreInfo?.payload?.payload?.beforeKey || ''])) { - showRemoveVarConfirm() - setRemovedVar([id, moreInfo?.payload?.payload?.beforeKey || '']) - setRemoveIndex(moreInfo?.index as number) - return + if (isVarUsedInNodes([id, moreInfo?.payload?.payload?.beforeKey || ''])) { + showRemoveVarConfirm() + setRemovedVar([id, moreInfo?.payload?.payload?.beforeKey || '']) + setRemoveIndex(moreInfo?.index as number) + return + } } - } - const newInputs = produce(inputs, (draft: any) => { - draft.variables = newList - }) - setInputs(newInputs) - if (moreInfo?.payload?.type === ChangeType.changeVarName) { - const changedVar = newList[moreInfo.index] - handleOutVarRenameChange(id, [id, inputs.variables[moreInfo.index]!.variable], [id, changedVar!.variable]) - renameInspectVarName(id, inputs.variables[moreInfo.index]!.variable, changedVar!.variable) - } - else if (moreInfo?.payload?.type !== ChangeType.remove) { // edit var type - deleteNodeInspectorVars(id) - } - }, [deleteInspectVar, deleteNodeInspectorVars, handleOutVarRenameChange, id, inputs, isVarUsedInNodes, nodesWithInspectVars, renameInspectVarName, setInputs, showRemoveVarConfirm]) + const newInputs = produce(inputs, (draft: any) => { + draft.variables = newList + }) + setInputs(newInputs) + if (moreInfo?.payload?.type === ChangeType.changeVarName) { + const changedVar = newList[moreInfo.index] + handleOutVarRenameChange( + id, + [id, inputs.variables[moreInfo.index]!.variable], + [id, changedVar!.variable], + ) + renameInspectVarName(id, inputs.variables[moreInfo.index]!.variable, changedVar!.variable) + } else if (moreInfo?.payload?.type !== ChangeType.remove) { + // edit var type + deleteNodeInspectorVars(id) + } + }, + [ + deleteInspectVar, + deleteNodeInspectorVars, + handleOutVarRenameChange, + id, + inputs, + isVarUsedInNodes, + nodesWithInspectVars, + renameInspectVarName, + setInputs, + showRemoveVarConfirm, + ], + ) const removeVarInNode = useCallback(() => { const newInputs = produce(inputs, (draft) => { @@ -80,29 +89,36 @@ const useConfig = (id: string, payload: StartNodeType) => { hideRemoveVarConfirm() }, [hideRemoveVarConfirm, inputs, removeUsedVarInNodes, removedIndex, removedVar, setInputs]) - const handleAddVariable = useCallback((payload: InputVar) => { - const newInputs = produce(inputs, (draft: StartNodeType) => { - draft.variables.push(payload) - }) - const newList = newInputs.variables - let errorMsgKey: 'varKeyError.keyAlreadyExists' | '' = '' - let typeName: 'variableConfig.varName' | 'variableConfig.labelName' | '' = '' - if (hasDuplicateStr(newList.map(item => item.variable))) { - errorMsgKey = 'varKeyError.keyAlreadyExists' - typeName = 'variableConfig.varName' - } - else if (hasDuplicateStr(newList.map(item => item.label as string))) { - errorMsgKey = 'varKeyError.keyAlreadyExists' - typeName = 'variableConfig.labelName' - } + const handleAddVariable = useCallback( + (payload: InputVar) => { + const newInputs = produce(inputs, (draft: StartNodeType) => { + draft.variables.push(payload) + }) + const newList = newInputs.variables + let errorMsgKey: 'varKeyError.keyAlreadyExists' | '' = '' + let typeName: 'variableConfig.varName' | 'variableConfig.labelName' | '' = '' + if (hasDuplicateStr(newList.map((item) => item.variable))) { + errorMsgKey = 'varKeyError.keyAlreadyExists' + typeName = 'variableConfig.varName' + } else if (hasDuplicateStr(newList.map((item) => item.label as string))) { + errorMsgKey = 'varKeyError.keyAlreadyExists' + typeName = 'variableConfig.labelName' + } - if (errorMsgKey && typeName) { - toast.error(t($ => $[errorMsgKey], { ns: 'appDebug', key: t($ => $[typeName], { ns: 'appDebug' }) })) - return false - } - setInputs(newInputs) - return true - }, [inputs, setInputs]) + if (errorMsgKey && typeName) { + toast.error( + t(($) => $[errorMsgKey], { + ns: 'appDebug', + key: t(($) => $[typeName], { ns: 'appDebug' }), + }), + ) + return false + } + setInputs(newInputs) + return true + }, + [inputs, setInputs], + ) return { readOnly, isChatMode, diff --git a/web/app/components/workflow/nodes/start/use-single-run-form-params.ts b/web/app/components/workflow/nodes/start/use-single-run-form-params.ts index ca1702ff1d35b3..0bbef15ddef99a 100644 --- a/web/app/components/workflow/nodes/start/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/start/use-single-run-form-params.ts @@ -15,12 +15,7 @@ type Params = { setRunInputData: (data: Record<string, any>) => void toVarInputs: (variables: Variable[]) => InputVar[] } -const useSingleRunFormParams = ({ - id, - payload, - runInputData, - setRunInputData, -}: Params) => { +const useSingleRunFormParams = ({ id, payload, runInputData, setRunInputData }: Params) => { const { t } = useTranslation() const isChatMode = useIsChatMode() @@ -49,14 +44,12 @@ const useSingleRunFormParams = ({ required: false, }) - forms.push( - { - label: t($ => $['nodes.llm.singleRun.variable'], { ns: 'workflow' })!, - inputs, - values: runInputData, - onChange: setRunInputData, - }, - ) + forms.push({ + label: t(($) => $['nodes.llm.singleRun.variable'], { ns: 'workflow' })!, + inputs, + values: runInputData, + onChange: setRunInputData, + }) return forms })() @@ -67,8 +60,7 @@ const useSingleRunFormParams = ({ }) const vars: ValueSelector[] = [...inputVars, ['sys', 'files']] - if (isChatMode) - vars.push(['sys', 'query']) + if (isChatMode) vars.push(['sys', 'query']) return vars } diff --git a/web/app/components/workflow/nodes/template-transform/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/template-transform/__tests__/integration.spec.tsx index c956caf3056c94..b01df05af943de 100644 --- a/web/app/components/workflow/nodes/template-transform/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/template-transform/__tests__/integration.spec.tsx @@ -11,7 +11,15 @@ import useConfig from '../use-config' vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ __esModule: true, - default: ({ title, operations, children }: { title: ReactNode, operations?: ReactNode, children: ReactNode }) => ( + default: ({ + title, + operations, + children, + }: { + title: ReactNode + operations?: ReactNode + children: ReactNode + }) => ( <div> <div>{title}</div> <div>{operations}</div> @@ -23,7 +31,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <div>{children}</div>, - VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>, + VarItem: ({ name, type }: { name: string; type: string }) => <div>{`${name}:${type}`}</div>, })) vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ @@ -43,11 +51,15 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () <div> <button type="button" - onClick={() => onChange([{ - variable: 'updated_input', - value_selector: ['node-1', 'updated_input'], - value_type: VarType.string, - }])} + onClick={() => + onChange([ + { + variable: 'updated_input', + value_selector: ['node-1', 'updated_input'], + value_type: VarType.string, + }, + ]) + } > change-var-list </button> @@ -58,39 +70,44 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () ), })) -vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars', () => ({ - __esModule: true, - default: ({ - onAddVar, - headerRight, - value, - onChange, - }: { - onAddVar: (value: Variable) => void - headerRight?: ReactNode - value: string - onChange: (value: string) => void - }) => ( - <div> - <div>{headerRight}</div> - <button - type="button" - onClick={() => onAddVar({ - variable: 'result_text', - value_selector: ['node-2', 'result_text'], - value_type: VarType.string, - })} - > - add-var - </button> - <textarea - aria-label="template-editor" - value={value} - onChange={event => onChange(event.target.value)} - /> - </div> - ), -})) +vi.mock( + '@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars', + () => ({ + __esModule: true, + default: ({ + onAddVar, + headerRight, + value, + onChange, + }: { + onAddVar: (value: Variable) => void + headerRight?: ReactNode + value: string + onChange: (value: string) => void + }) => ( + <div> + <div>{headerRight}</div> + <button + type="button" + onClick={() => + onAddVar({ + variable: 'result_text', + value_selector: ['node-2', 'result_text'], + value_type: VarType.string, + }) + } + > + add-var + </button> + <textarea + aria-label="template-editor" + value={value} + onChange={(event) => onChange(event.target.value)} + /> + </div> + ), + }), +) vi.mock('../use-config', () => ({ __esModule: true, @@ -106,7 +123,9 @@ const createVariable = (overrides: Partial<Variable> = {}): Variable => ({ ...overrides, }) -const createData = (overrides: Partial<TemplateTransformNodeType> = {}): TemplateTransformNodeType => ({ +const createData = ( + overrides: Partial<TemplateTransformNodeType> = {}, +): TemplateTransformNodeType => ({ title: 'Template Transform', desc: '', type: BlockEnum.TemplateTransform, @@ -115,7 +134,9 @@ const createData = (overrides: Partial<TemplateTransformNodeType> = {}): Templat ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), availableVars: [], @@ -144,12 +165,7 @@ describe('template-transform path', () => { }) it('should render the node shell without summary content', () => { - const { container } = render( - <Node - id="template-node" - data={createData()} - />, - ) + const { container } = render(<Node id="template-node" data={createData()} />) expect(container.firstElementChild).toBeEmptyDOMElement() }) @@ -162,27 +178,29 @@ describe('template-transform path', () => { const handleAddEmptyVariable = vi.fn() const handleCodeChange = vi.fn() - mockUseConfig.mockReturnValueOnce(createConfigResult({ - handleVarListChange, - handleVarNameChange, - handleAddVariable, - handleAddEmptyVariable, - handleCodeChange, - })) - - render( - <Panel - id="template-node" - data={createData()} - panelProps={panelProps} - />, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + handleVarListChange, + handleVarNameChange, + handleAddVariable, + handleAddEmptyVariable, + handleCodeChange, + }), ) - await user.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.templateTransform.inputVars' })) + render(<Panel id="template-node" data={createData()} panelProps={panelProps} />) + + await user.click( + screen.getByRole('button', { + name: 'common.operation.add workflow.nodes.templateTransform.inputVars', + }), + ) await user.click(screen.getByRole('button', { name: 'change-var-list' })) await user.click(screen.getByRole('button', { name: 'rename-var' })) await user.click(screen.getByRole('button', { name: 'add-var' })) - fireEvent.change(screen.getByLabelText('template-editor'), { target: { value: '{{ renamed_input }}' } }) + fireEvent.change(screen.getByLabelText('template-editor'), { + target: { value: '{{ renamed_input }}' }, + }) expect(handleAddEmptyVariable).toHaveBeenCalled() expect(handleVarListChange).toHaveBeenCalledWith([ @@ -200,25 +218,24 @@ describe('template-transform path', () => { }) expect(handleCodeChange).toHaveBeenCalledWith('{{ renamed_input }}') expect(screen.getByText('output:string')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /workflow.nodes.templateTransform.codeSupportTip/i })).toHaveAttribute( - 'href', - 'https://jinja.palletsprojects.com/en/3.1.x/templates/', - ) + expect( + screen.getByRole('link', { name: /workflow.nodes.templateTransform.codeSupportTip/i }), + ).toHaveAttribute('href', 'https://jinja.palletsprojects.com/en/3.1.x/templates/') }) it('should hide the add-variable operation when the panel is read only', () => { - mockUseConfig.mockReturnValueOnce(createConfigResult({ - readOnly: true, - })) - - render( - <Panel - id="template-node" - data={createData()} - panelProps={panelProps} - />, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + readOnly: true, + }), ) - expect(screen.queryByRole('button', { name: 'common.operation.add workflow.nodes.templateTransform.inputVars' })).not.toBeInTheDocument() + render(<Panel id="template-node" data={createData()} panelProps={panelProps} />) + + expect( + screen.queryByRole('button', { + name: 'common.operation.add workflow.nodes.templateTransform.inputVars', + }), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/template-transform/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/template-transform/__tests__/node.spec.tsx index eea9d4cb87b779..3fd96bb62537db 100644 --- a/web/app/components/workflow/nodes/template-transform/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/template-transform/__tests__/node.spec.tsx @@ -3,7 +3,9 @@ import { render } from '@testing-library/react' import { BlockEnum } from '@/app/components/workflow/types' import Node from '../node' -const createData = (overrides: Partial<TemplateTransformNodeType> = {}): TemplateTransformNodeType => ({ +const createData = ( + overrides: Partial<TemplateTransformNodeType> = {}, +): TemplateTransformNodeType => ({ title: 'Template Transform', desc: '', type: BlockEnum.TemplateTransform, @@ -14,12 +16,7 @@ const createData = (overrides: Partial<TemplateTransformNodeType> = {}): Templat describe('template-transform/node', () => { it('renders an empty shell without summary content', () => { - const { container } = render( - <Node - id="template-node" - data={createData()} - />, - ) + const { container } = render(<Node id="template-node" data={createData()} />) expect(container.firstElementChild).toBeEmptyDOMElement() }) diff --git a/web/app/components/workflow/nodes/template-transform/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/template-transform/__tests__/panel.spec.tsx index 3a51948082aa9b..6d29cfbed92e19 100644 --- a/web/app/components/workflow/nodes/template-transform/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/template-transform/__tests__/panel.spec.tsx @@ -20,11 +20,15 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () <div> <button type="button" - onClick={() => onChange([{ - variable: 'updated_input', - value_selector: ['node-1', 'updated_input'], - value_type: VarType.string, - }])} + onClick={() => + onChange([ + { + variable: 'updated_input', + value_selector: ['node-1', 'updated_input'], + value_type: VarType.string, + }, + ]) + } > change-var-list </button> @@ -35,39 +39,44 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () ), })) -vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars', () => ({ - __esModule: true, - default: ({ - onAddVar, - headerRight, - value, - onChange, - }: { - onAddVar: (value: Variable) => void - headerRight?: ReactNode - value: string - onChange: (value: string) => void - }) => ( - <div> - <div>{headerRight}</div> - <button - type="button" - onClick={() => onAddVar({ - variable: 'result_text', - value_selector: ['node-2', 'result_text'], - value_type: VarType.string, - })} - > - add-var - </button> - <textarea - aria-label="template-editor" - value={value} - onChange={event => onChange(event.target.value)} - /> - </div> - ), -})) +vi.mock( + '@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars', + () => ({ + __esModule: true, + default: ({ + onAddVar, + headerRight, + value, + onChange, + }: { + onAddVar: (value: Variable) => void + headerRight?: ReactNode + value: string + onChange: (value: string) => void + }) => ( + <div> + <div>{headerRight}</div> + <button + type="button" + onClick={() => + onAddVar({ + variable: 'result_text', + value_selector: ['node-2', 'result_text'], + value_type: VarType.string, + }) + } + > + add-var + </button> + <textarea + aria-label="template-editor" + value={value} + onChange={(event) => onChange(event.target.value)} + /> + </div> + ), + }), +) vi.mock('../use-config', () => ({ __esModule: true, @@ -83,7 +92,9 @@ const createVariable = (overrides: Partial<Variable> = {}): Variable => ({ ...overrides, }) -const createData = (overrides: Partial<TemplateTransformNodeType> = {}): TemplateTransformNodeType => ({ +const createData = ( + overrides: Partial<TemplateTransformNodeType> = {}, +): TemplateTransformNodeType => ({ title: 'Template Transform', desc: '', type: BlockEnum.TemplateTransform, @@ -92,7 +103,9 @@ const createData = (overrides: Partial<TemplateTransformNodeType> = {}): Templat ...overrides, }) -const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({ +const createConfigResult = ( + overrides: Partial<ReturnType<typeof useConfig>> = {}, +): ReturnType<typeof useConfig> => ({ readOnly: false, inputs: createData(), availableVars: [], @@ -128,27 +141,29 @@ describe('template-transform/panel', () => { const handleAddEmptyVariable = vi.fn() const handleCodeChange = vi.fn() - mockUseConfig.mockReturnValueOnce(createConfigResult({ - handleVarListChange, - handleVarNameChange, - handleAddVariable, - handleAddEmptyVariable, - handleCodeChange, - })) - - render( - <Panel - id="template-node" - data={createData()} - panelProps={panelProps} - />, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + handleVarListChange, + handleVarNameChange, + handleAddVariable, + handleAddEmptyVariable, + handleCodeChange, + }), ) - await user.click(screen.getByRole('button', { name: 'common.operation.add workflow.nodes.templateTransform.inputVars' })) + render(<Panel id="template-node" data={createData()} panelProps={panelProps} />) + + await user.click( + screen.getByRole('button', { + name: 'common.operation.add workflow.nodes.templateTransform.inputVars', + }), + ) await user.click(screen.getByRole('button', { name: 'change-var-list' })) await user.click(screen.getByRole('button', { name: 'rename-var' })) await user.click(screen.getByRole('button', { name: 'add-var' })) - fireEvent.change(screen.getByLabelText('template-editor'), { target: { value: '{{ renamed_input }}' } }) + fireEvent.change(screen.getByLabelText('template-editor'), { + target: { value: '{{ renamed_input }}' }, + }) expect(handleAddEmptyVariable).toHaveBeenCalled() expect(handleVarListChange).toHaveBeenCalledWith([ @@ -166,25 +181,24 @@ describe('template-transform/panel', () => { }) expect(handleCodeChange).toHaveBeenCalledWith('{{ renamed_input }}') expect(screen.getByText('workflow.nodes.common.outputVars')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /workflow.nodes.templateTransform.codeSupportTip/i })).toHaveAttribute( - 'href', - 'https://jinja.palletsprojects.com/en/3.1.x/templates/', - ) + expect( + screen.getByRole('link', { name: /workflow.nodes.templateTransform.codeSupportTip/i }), + ).toHaveAttribute('href', 'https://jinja.palletsprojects.com/en/3.1.x/templates/') }) it('hides the add-variable operation when the panel is read only', () => { - mockUseConfig.mockReturnValueOnce(createConfigResult({ - readOnly: true, - })) - - render( - <Panel - id="template-node" - data={createData()} - panelProps={panelProps} - />, + mockUseConfig.mockReturnValueOnce( + createConfigResult({ + readOnly: true, + }), ) - expect(screen.queryByRole('button', { name: 'common.operation.add workflow.nodes.templateTransform.inputVars' })).not.toBeInTheDocument() + render(<Panel id="template-node" data={createData()} panelProps={panelProps} />) + + expect( + screen.queryByRole('button', { + name: 'common.operation.add workflow.nodes.templateTransform.inputVars', + }), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/template-transform/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/template-transform/__tests__/use-config.spec.ts index 7eca7d7b02f701..a1cce3cd890527 100644 --- a/web/app/components/workflow/nodes/template-transform/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/template-transform/__tests__/use-config.spec.ts @@ -48,7 +48,9 @@ const createVariable = (overrides: Partial<Variable> = {}): Variable => ({ ...overrides, }) -const createData = (overrides: Partial<TemplateTransformNodeType> = {}): TemplateTransformNodeType => ({ +const createData = ( + overrides: Partial<TemplateTransformNodeType> = {}, +): TemplateTransformNodeType => ({ title: 'Template Transform', desc: '', type: BlockEnum.TemplateTransform, @@ -67,7 +69,12 @@ describe('template-transform/use-config', () => { nodesDefaultConfigs: { [BlockEnum.TemplateTransform]: { template: '{{ default_input }}', - variables: [createVariable({ variable: 'default_input', value_selector: ['node-2', 'default_input'] })], + variables: [ + createVariable({ + variable: 'default_input', + value_selector: ['node-2', 'default_input'], + }), + ], }, }, }) @@ -92,9 +99,11 @@ describe('template-transform/use-config', () => { renderHook(() => useConfig('template-node', createData({ template: '', variables: [] }))) await waitFor(() => { - expect(doSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - template: '{{ default_input }}', - })) + expect(doSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + template: '{{ default_input }}', + }), + ) }) }) @@ -105,30 +114,45 @@ describe('template-transform/use-config', () => { } as ReturnType<typeof useNodeCrud>) const { result } = renderHook(() => useConfig('template-node', createData())) - const nextVariable = createVariable({ variable: 'renamed_input', value_selector: ['node-2', 'renamed_input'] }) + const nextVariable = createVariable({ + variable: 'renamed_input', + value_selector: ['node-2', 'renamed_input'], + }) result.current.handleVarListChange([nextVariable]) - result.current.handleAddVariable(createVariable({ variable: 'extra_input', value_selector: ['node-3', 'extra_input'] })) + result.current.handleAddVariable( + createVariable({ variable: 'extra_input', value_selector: ['node-3', 'extra_input'] }), + ) result.current.handleVarNameChange('input_text', 'renamed_input') result.current.handleCodeChange('{{ output }}') - expect(result.current.availableVars).toEqual([['node-1', { variable: 'input_text', type: VarType.string }]]) + expect(result.current.availableVars).toEqual([ + ['node-1', { variable: 'input_text', type: VarType.string }], + ]) expect(result.current.handleAddEmptyVariable).toBe(handleAddEmptyVariable) - expect(doSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - variables: [nextVariable], - })) - expect(doSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - variables: expect.arrayContaining([ - expect.objectContaining({ variable: 'renamed_input' }), - expect.objectContaining({ variable: 'extra_input' }), - ]), - })) - expect(doSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - template: '{{ renamed_input }}', - })) - expect(doSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - template: '{{ output }}', - })) + expect(doSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + variables: [nextVariable], + }), + ) + expect(doSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.arrayContaining([ + expect.objectContaining({ variable: 'renamed_input' }), + expect.objectContaining({ variable: 'extra_input' }), + ]), + }), + ) + expect(doSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + template: '{{ renamed_input }}', + }), + ) + expect(doSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + template: '{{ output }}', + }), + ) }) it('filters to scalar and collection variables used by template interpolation', () => { diff --git a/web/app/components/workflow/nodes/template-transform/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/template-transform/__tests__/use-single-run-form-params.spec.ts index 1e3381a13fb503..48f6142e97d5ac 100644 --- a/web/app/components/workflow/nodes/template-transform/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/template-transform/__tests__/use-single-run-form-params.spec.ts @@ -19,7 +19,9 @@ const createVariable = (overrides: Partial<Variable> = {}): Variable => ({ ...overrides, }) -const createData = (overrides: Partial<TemplateTransformNodeType> = {}): TemplateTransformNodeType => ({ +const createData = ( + overrides: Partial<TemplateTransformNodeType> = {}, +): TemplateTransformNodeType => ({ title: 'Template Transform', desc: '', type: BlockEnum.TemplateTransform, @@ -39,22 +41,26 @@ describe('template-transform/use-single-run-form-params', () => { it('exposes variable inputs and forwards single-run changes', () => { const setRunInputData = vi.fn() - const varInputs: InputVar[] = [{ - label: 'Input Text', - variable: 'input_text', - type: InputVarType.textInput, - required: true, - }] + const varInputs: InputVar[] = [ + { + label: 'Input Text', + variable: 'input_text', + type: InputVarType.textInput, + required: true, + }, + ] - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'template-node', - payload: createData(), - runInputData: { input_text: 'hello' }, - runInputDataRef: { current: {} }, - getInputVars: () => [], - setRunInputData, - toVarInputs: () => varInputs, - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'template-node', + payload: createData(), + runInputData: { input_text: 'hello' }, + runInputDataRef: { current: {} }, + getInputVars: () => [], + setRunInputData, + toVarInputs: () => varInputs, + }), + ) expect(result.current.forms).toHaveLength(1) expect(result.current.forms[0]!.inputs).toEqual(varInputs) diff --git a/web/app/components/workflow/nodes/template-transform/default.ts b/web/app/components/workflow/nodes/template-transform/default.ts index 0e50519eb15c0e..7716df503e736a 100644 --- a/web/app/components/workflow/nodes/template-transform/default.ts +++ b/web/app/components/workflow/nodes/template-transform/default.ts @@ -23,12 +23,21 @@ const nodeDefault: NodeDefault<TemplateTransformNodeType> = { let errorMessages = '' const { template, variables } = payload - if (!errorMessages && variables.filter(v => !v.variable).length > 0) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variable`], { ns: 'workflow' }) }) - if (!errorMessages && variables.filter(v => !v.value_selector.length).length > 0) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }) }) + if (!errorMessages && variables.filter((v) => !v.variable).length > 0) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variable`], { ns: 'workflow' }), + }) + if (!errorMessages && variables.filter((v) => !v.value_selector.length).length > 0) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $[`${i18nPrefix}.fields.variableValue`], { ns: 'workflow' }), + }) if (!errorMessages && !template) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.templateTransform.code'], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.templateTransform.code'], { ns: 'workflow' }), + }) return { isValid: !errorMessages, errorMessage: errorMessages, diff --git a/web/app/components/workflow/nodes/template-transform/panel.tsx b/web/app/components/workflow/nodes/template-transform/panel.tsx index 8070d714090e51..e1b70a850517f1 100644 --- a/web/app/components/workflow/nodes/template-transform/panel.tsx +++ b/web/app/components/workflow/nodes/template-transform/panel.tsx @@ -1,9 +1,7 @@ import type { FC } from 'react' import type { TemplateTransformNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' -import { - RiQuestionLine, -} from '@remixicon/react' +import { RiQuestionLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars' @@ -16,10 +14,7 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.templateTransform' -const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { @@ -37,22 +32,19 @@ const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({ return ( <div className="mt-2"> <div className="space-y-4 px-4 pb-4"> - <Field - title={t($ => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })} operations={ - !readOnly - ? ( - <button - type="button" - aria-label={`${t($ => $['operation.add'], { ns: 'common' })} ${t($ => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })}`} - className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={handleAddEmptyVariable} - > - <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> - </button> - ) - : undefined + !readOnly ? ( + <button + type="button" + aria-label={`${t(($) => $['operation.add'], { ns: 'common' })} ${t(($) => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })}`} + className="cursor-pointer rounded-md border-none bg-transparent p-1 select-none hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleAddEmptyVariable} + > + <span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" /> + </button> + ) : undefined } > <VarList @@ -74,9 +66,9 @@ const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({ readOnly={readOnly} language={CodeLanguage.python3} title={ - <div className="uppercase">{t($ => $[`${i18nPrefix}.code`], { ns: 'workflow' })}</div> + <div className="uppercase">{t(($) => $[`${i18nPrefix}.code`], { ns: 'workflow' })}</div> } - headerRight={( + headerRight={ <div className="flex items-center"> <a className="flex h-[18px] items-center space-x-0.5 text-xs font-normal text-text-tertiary" @@ -84,12 +76,12 @@ const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({ target="_blank" rel="noopener noreferrer" > - <span>{t($ => $[`${i18nPrefix}.codeSupportTip`], { ns: 'workflow' })}</span> + <span>{t(($) => $[`${i18nPrefix}.codeSupportTip`], { ns: 'workflow' })}</span> <RiQuestionLine className="size-3" /> </a> <div className="mx-1.5 h-3 w-px bg-divider-regular"></div> </div> - )} + } value={inputs.template} onChange={handleCodeChange} /> @@ -101,7 +93,7 @@ const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({ <VarItem name="output" type="string" - description={t($ => $[`${i18nPrefix}.outputVars.output`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.output`], { ns: 'workflow' })} /> </> </OutputVars> diff --git a/web/app/components/workflow/nodes/template-transform/use-config.ts b/web/app/components/workflow/nodes/template-transform/use-config.ts index ed62ea2fa0eb91..dd227abf76de94 100644 --- a/web/app/components/workflow/nodes/template-transform/use-config.ts +++ b/web/app/components/workflow/nodes/template-transform/use-config.ts @@ -2,9 +2,7 @@ import type { Var, Variable } from '../../types' import type { TemplateTransformNodeType } from './types' import { produce } from 'immer' import { useCallback, useEffect, useRef } from 'react' -import { - useNodesReadOnly, -} from '@/app/components/workflow/hooks' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useStore } from '../../store' @@ -13,14 +11,17 @@ import useVarList from '../_base/hooks/use-var-list' const useConfig = (id: string, payload: TemplateTransformNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() - const defaultConfig = useStore(s => s.nodesDefaultConfigs)?.[payload.type] + const defaultConfig = useStore((s) => s.nodesDefaultConfigs)?.[payload.type] const { inputs, setInputs: doSetInputs } = useNodeCrud<TemplateTransformNodeType>(id, payload) const inputsRef = useRef(inputs) - const setInputs = useCallback((newPayload: TemplateTransformNodeType) => { - doSetInputs(newPayload) - inputsRef.current = newPayload - }, [doSetInputs]) + const setInputs = useCallback( + (newPayload: TemplateTransformNodeType) => { + doSetInputs(newPayload) + inputsRef.current = newPayload + }, + [doSetInputs], + ) const { availableVars } = useAvailableVarList(id, { onlyLeafNodeVar: false, @@ -32,31 +33,39 @@ const useConfig = (id: string, payload: TemplateTransformNodeType) => { setInputs, }) - const handleVarListChange = useCallback((newList: Variable[]) => { - const newInputs = produce(inputsRef.current, (draft: any) => { - draft.variables = newList - }) - setInputs(newInputs) - }, [setInputs]) + const handleVarListChange = useCallback( + (newList: Variable[]) => { + const newInputs = produce(inputsRef.current, (draft: any) => { + draft.variables = newList + }) + setInputs(newInputs) + }, + [setInputs], + ) - const handleAddVariable = useCallback((payload: Variable) => { - const newInputs = produce(inputsRef.current, (draft: any) => { - draft.variables.push(payload) - }) - setInputs(newInputs) - }, [setInputs]) + const handleAddVariable = useCallback( + (payload: Variable) => { + const newInputs = produce(inputsRef.current, (draft: any) => { + draft.variables.push(payload) + }) + setInputs(newInputs) + }, + [setInputs], + ) // rename var in code - const handleVarNameChange = useCallback((oldName: string, newName: string) => { - const newInputs = produce(inputsRef.current, (draft: any) => { - draft.template = draft.template.replaceAll(`{{ ${oldName} }}`, `{{ ${newName} }}`) - }) - setInputs(newInputs) - }, [setInputs]) + const handleVarNameChange = useCallback( + (oldName: string, newName: string) => { + const newInputs = produce(inputsRef.current, (draft: any) => { + draft.template = draft.template.replaceAll(`{{ ${oldName} }}`, `{{ ${newName} }}`) + }) + setInputs(newInputs) + }, + [setInputs], + ) useEffect(() => { - if (inputs.template) - return + if (inputs.template) return const isReady = defaultConfig && Object.keys(defaultConfig).length > 0 if (isReady) { @@ -67,15 +76,28 @@ const useConfig = (id: string, payload: TemplateTransformNodeType) => { } }, [defaultConfig]) - const handleCodeChange = useCallback((template: string) => { - const newInputs = produce(inputsRef.current, (draft: any) => { - draft.template = template - }) - setInputs(newInputs) - }, [setInputs]) + const handleCodeChange = useCallback( + (template: string) => { + const newInputs = produce(inputsRef.current, (draft: any) => { + draft.template = template + }) + setInputs(newInputs) + }, + [setInputs], + ) const filterVar = useCallback((varPayload: Var) => { - return [VarType.string, VarType.number, VarType.boolean, VarType.object, VarType.array, VarType.arrayNumber, VarType.arrayString, VarType.arrayBoolean, VarType.arrayObject].includes(varPayload.type) + return [ + VarType.string, + VarType.number, + VarType.boolean, + VarType.object, + VarType.array, + VarType.arrayNumber, + VarType.arrayString, + VarType.arrayBoolean, + VarType.arrayObject, + ].includes(varPayload.type) }, []) return { diff --git a/web/app/components/workflow/nodes/template-transform/use-single-run-form-params.ts b/web/app/components/workflow/nodes/template-transform/use-single-run-form-params.ts index d8dcfaea926017..1758ff85a1b247 100644 --- a/web/app/components/workflow/nodes/template-transform/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/template-transform/use-single-run-form-params.ts @@ -23,15 +23,17 @@ const useSingleRunFormParams = ({ const { inputs } = useNodeCrud<TemplateTransformNodeType>(id, payload) const varInputs = toVarInputs(inputs.variables) - const setInputVarValues = useCallback((newPayload: Record<string, any>) => { - setRunInputData(newPayload) - }, [setRunInputData]) + const setInputVarValues = useCallback( + (newPayload: Record<string, any>) => { + setRunInputData(newPayload) + }, + [setRunInputData], + ) const inputVarValues = (() => { const vars: Record<string, any> = {} - Object.keys(runInputData) - .forEach((key) => { - vars[key] = runInputData[key] - }) + Object.keys(runInputData).forEach((key) => { + vars[key] = runInputData[key] + }) return vars })() @@ -46,13 +48,12 @@ const useSingleRunFormParams = ({ }, [inputVarValues, setInputVarValues, varInputs]) const getDependentVars = () => { - return payload.variables.map(v => v.value_selector) + return payload.variables.map((v) => v.value_selector) } const getDependentVar = (variable: string) => { - const varItem = payload.variables.find(v => v.variable === variable) - if (varItem) - return varItem.value_selector + const varItem = payload.variables.find((v) => v.variable === variable) + if (varItem) return varItem.value_selector } return { diff --git a/web/app/components/workflow/nodes/tool/__tests__/auth.spec.ts b/web/app/components/workflow/nodes/tool/__tests__/auth.spec.ts index d8ae232404becb..c53375fa9e7bb9 100644 --- a/web/app/components/workflow/nodes/tool/__tests__/auth.spec.ts +++ b/web/app/components/workflow/nodes/tool/__tests__/auth.spec.ts @@ -3,31 +3,39 @@ import { isToolAuthorizationRequired } from '../auth' describe('isToolAuthorizationRequired', () => { it('should return true for built-in tools that require authorization and are not authorized', () => { - expect(isToolAuthorizationRequired(CollectionType.builtIn, { - allow_delete: true, - is_team_authorization: false, - })).toBe(true) + expect( + isToolAuthorizationRequired(CollectionType.builtIn, { + allow_delete: true, + is_team_authorization: false, + }), + ).toBe(true) }) it('should return false when the built-in tool is already authorized', () => { - expect(isToolAuthorizationRequired(CollectionType.builtIn, { - allow_delete: true, - is_team_authorization: true, - })).toBe(false) + expect( + isToolAuthorizationRequired(CollectionType.builtIn, { + allow_delete: true, + is_team_authorization: true, + }), + ).toBe(false) }) it('should return false for non-built-in tools even if the provider is unauthorized', () => { - expect(isToolAuthorizationRequired(CollectionType.custom, { - allow_delete: true, - is_team_authorization: false, - })).toBe(false) + expect( + isToolAuthorizationRequired(CollectionType.custom, { + allow_delete: true, + is_team_authorization: false, + }), + ).toBe(false) }) it('should return false when the collection is missing or authorization is not required', () => { expect(isToolAuthorizationRequired(CollectionType.builtIn)).toBe(false) - expect(isToolAuthorizationRequired(CollectionType.builtIn, { - allow_delete: false, - is_team_authorization: false, - })).toBe(false) + expect( + isToolAuthorizationRequired(CollectionType.builtIn, { + allow_delete: false, + is_team_authorization: false, + }), + ).toBe(false) }) }) diff --git a/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts b/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts index f5179742b25dd1..6ab8284fe175ec 100644 --- a/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts +++ b/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts @@ -1,9 +1,5 @@ import { VarType } from '@/app/components/workflow/types' -import { - normalizeJsonSchemaType, - pickItemSchema, - resolveVarType, -} from '../output-schema-utils' +import { normalizeJsonSchemaType, pickItemSchema, resolveVarType } from '../output-schema-utils' // Mock the getMatchedSchemaType dependency vi.mock('../../_base/components/variable/use-match-schema-type', () => ({ @@ -36,43 +32,45 @@ describe('output-schema-utils', () => { }) it('should handle oneOf schema', () => { - expect(normalizeJsonSchemaType({ - oneOf: [ - { type: 'string' }, - { type: 'null' }, - ], - })).toBe('string') + expect( + normalizeJsonSchemaType({ + oneOf: [{ type: 'string' }, { type: 'null' }], + }), + ).toBe('string') }) it('should handle anyOf schema', () => { - expect(normalizeJsonSchemaType({ - anyOf: [ - { type: 'number' }, - { type: 'null' }, - ], - })).toBe('number') + expect( + normalizeJsonSchemaType({ + anyOf: [{ type: 'number' }, { type: 'null' }], + }), + ).toBe('number') }) it('should handle allOf schema', () => { - expect(normalizeJsonSchemaType({ - allOf: [ - { type: 'object' }, - ], - })).toBe('object') + expect( + normalizeJsonSchemaType({ + allOf: [{ type: 'object' }], + }), + ).toBe('object') }) it('should infer object type from properties', () => { - expect(normalizeJsonSchemaType({ - properties: { - name: { type: 'string' }, - }, - })).toBe('object') + expect( + normalizeJsonSchemaType({ + properties: { + name: { type: 'string' }, + }, + }), + ).toBe('object') }) it('should infer array type from items', () => { - expect(normalizeJsonSchemaType({ - items: { type: 'string' }, - })).toBe('array') + expect( + normalizeJsonSchemaType({ + items: { type: 'string' }, + }), + ).toBe('array') }) it('should return undefined for empty schema', () => { diff --git a/web/app/components/workflow/nodes/tool/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/tool/__tests__/panel.spec.tsx index e7fc98b58adcfc..7b461533d042f0 100644 --- a/web/app/components/workflow/nodes/tool/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/tool/__tests__/panel.spec.tsx @@ -54,11 +54,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ <div>{props.children}</div> </div> ), - VarItem: (props: { - name: string - type: string - description: string - }) => ( + VarItem: (props: { name: string; type: string; description: string }) => ( <div data-testid={`var-item-${props.name}`}> <span>{props.name}</span> <span>{props.type}</span> @@ -76,7 +72,9 @@ vi.mock('../components/tool-form', () => ({ }) => { mockToolForm(props) return ( - <div data-testid={`tool-form-${props.schema.map(item => item.variable).join('-') || 'empty'}`}> + <div + data-testid={`tool-form-${props.schema.map((item) => item.variable).join('-') || 'empty'}`} + > {props.showManageInputField && props.onManageInputField && ( <button type="button" onClick={props.onManageInputField}> Manage Input Field @@ -87,13 +85,16 @@ vi.mock('../components/tool-form', () => ({ }, })) -vi.mock('@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show', () => ({ - __esModule: true, - default: (props: { payload: { id: string } }) => { - mockStructureOutputItem(props) - return <div data-testid="structured-output">{props.payload.id}</div> - }, -})) +vi.mock( + '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show', + () => ({ + __esModule: true, + default: (props: { payload: { id: string } }) => { + mockStructureOutputItem(props) + return <div data-testid="structured-output">{props.payload.id}</div> + }, + }), +) vi.mock('../../_base/components/split', () => ({ __esModule: true, @@ -173,7 +174,7 @@ describe('ToolPanel', () => { vi.clearAllMocks() mockWorkflowStoreState.pipelineId = undefined mockWorkflowStoreState.setShowInputFieldPanel = vi.fn() - mockUseStore.mockImplementation(selector => selector(mockWorkflowStoreState)) + mockUseStore.mockImplementation((selector) => selector(mockWorkflowStoreState)) mockUseMatchSchemaType.mockReturnValue({ schemaTypeDefinitions: [{ name: 'structured' }], }) @@ -186,9 +187,11 @@ describe('ToolPanel', () => { describe('Loading State', () => { it('should render loading when config data is still loading', () => { - mockUseConfig.mockReturnValue(createConfigResult({ - isLoading: true, - })) + mockUseConfig.mockReturnValue( + createConfigResult({ + isLoading: true, + }), + ) renderPanel() @@ -201,15 +204,17 @@ describe('ToolPanel', () => { describe('Form Rendering', () => { it('should render input and settings forms and forward the manage input field action', () => { mockWorkflowStoreState.pipelineId = 'pipeline-1' - mockUseConfig.mockReturnValue(createConfigResult({ - inputs: { - tool_parameters: { query: { value: 'weather' } }, - tool_configurations: {}, - }, - toolInputVarSchema: [createSchemaItem('query')], - toolSettingSchema: [createSchemaItem('region')], - toolSettingValue: { region: 'us' }, - })) + mockUseConfig.mockReturnValue( + createConfigResult({ + inputs: { + tool_parameters: { query: { value: 'weather' } }, + tool_configurations: {}, + }, + toolInputVarSchema: [createSchemaItem('query')], + toolSettingSchema: [createSchemaItem('region')], + toolSettingValue: { region: 'us' }, + }), + ) renderPanel() @@ -220,23 +225,29 @@ describe('ToolPanel', () => { fireEvent.click(screen.getByRole('button', { name: 'Manage Input Field' })) expect(mockToolForm).toHaveBeenCalledTimes(2) - expect(mockToolForm.mock.calls[0]![0]).toEqual(expect.objectContaining({ - nodeId: 'tool-node-1', - showManageInputField: true, - })) - expect(mockToolForm.mock.calls[1]![0]).toEqual(expect.objectContaining({ - nodeId: 'tool-node-1', - })) + expect(mockToolForm.mock.calls[0]![0]).toEqual( + expect.objectContaining({ + nodeId: 'tool-node-1', + showManageInputField: true, + }), + ) + expect(mockToolForm.mock.calls[1]![0]).toEqual( + expect.objectContaining({ + nodeId: 'tool-node-1', + }), + ) expect(mockToolForm.mock.calls[1]![0]).not.toHaveProperty('showManageInputField') expect(mockWorkflowStoreState.setShowInputFieldPanel).toHaveBeenCalledWith(true) }) it('should hide editable forms when the auth button is shown but keep output variables visible', () => { - mockUseConfig.mockReturnValue(createConfigResult({ - isShowAuthBtn: true, - toolInputVarSchema: [createSchemaItem('query')], - toolSettingSchema: [createSchemaItem('region')], - })) + mockUseConfig.mockReturnValue( + createConfigResult({ + isShowAuthBtn: true, + toolInputVarSchema: [createSchemaItem('query')], + toolSettingSchema: [createSchemaItem('region')], + }), + ) renderPanel() @@ -254,50 +265,61 @@ describe('ToolPanel', () => { mockGetMatchedSchemaType.mockImplementation((value: { type?: string }) => { return value?.type === 'string' ? 'qa_structured' : 'object_structured' }) - mockUseConfig.mockReturnValue(createConfigResult({ - hasObjectOutput: true, - outputSchema: [ - { - name: 'summary', - type: 'String', - description: 'Summary field', - value: { type: 'string' }, - }, - { - name: 'details', - type: 'Object', - description: 'Details field', - value: { type: 'object', properties: {} }, - }, - ], - })) + mockUseConfig.mockReturnValue( + createConfigResult({ + hasObjectOutput: true, + outputSchema: [ + { + name: 'summary', + type: 'String', + description: 'Summary field', + value: { type: 'string' }, + }, + { + name: 'details', + type: 'Object', + description: 'Details field', + value: { type: 'object', properties: {} }, + }, + ], + }), + ) renderPanel() expect(screen.getByText('summary'))!.toBeInTheDocument() expect(screen.getByText('string (qa_structured)'))!.toBeInTheDocument() expect(screen.getByText('Summary field'))!.toBeInTheDocument() - expect(screen.getByTestId('structured-output'))!.toHaveTextContent('details-object_structured') - expect(mockWrapStructuredVarItem).toHaveBeenCalledWith(expect.objectContaining({ - name: 'details', - }), 'object_structured') - expect(mockStructureOutputItem).toHaveBeenCalledWith(expect.objectContaining({ - payload: { id: 'details-object_structured' }, - })) + expect(screen.getByTestId('structured-output'))!.toHaveTextContent( + 'details-object_structured', + ) + expect(mockWrapStructuredVarItem).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'details', + }), + 'object_structured', + ) + expect(mockStructureOutputItem).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { id: 'details-object_structured' }, + }), + ) }) it('should render scalar outputs without a schema suffix when no schema type matches', () => { mockGetMatchedSchemaType.mockReturnValue('') - mockUseConfig.mockReturnValue(createConfigResult({ - outputSchema: [ - { - name: 'summary', - type: 'String', - description: 'Summary field', - value: { type: 'string' }, - }, - ], - })) + mockUseConfig.mockReturnValue( + createConfigResult({ + outputSchema: [ + { + name: 'summary', + type: 'String', + description: 'Summary field', + value: { type: 'string' }, + }, + ], + }), + ) renderPanel() diff --git a/web/app/components/workflow/nodes/tool/auth.ts b/web/app/components/workflow/nodes/tool/auth.ts index fa1c1dfb0fe899..41141c133b0292 100644 --- a/web/app/components/workflow/nodes/tool/auth.ts +++ b/web/app/components/workflow/nodes/tool/auth.ts @@ -8,7 +8,9 @@ export const isToolAuthorizationRequired = ( providerType: ToolNodeType['provider_type'], collection?: ToolAuthorizationCollection, ) => { - return providerType === CollectionType.builtIn - && !!collection?.allow_delete - && collection?.is_team_authorization === false + return ( + providerType === CollectionType.builtIn && + !!collection?.allow_delete && + collection?.is_team_authorization === false + ) } diff --git a/web/app/components/workflow/nodes/tool/components/__tests__/copy-id.spec.tsx b/web/app/components/workflow/nodes/tool/components/__tests__/copy-id.spec.tsx index 2fb7e66e24384b..6d1b5455981bd7 100644 --- a/web/app/components/workflow/nodes/tool/components/__tests__/copy-id.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/__tests__/copy-id.spec.tsx @@ -20,7 +20,9 @@ describe('tool/copy-id', () => { it('should copy content and reset copied state when mouse leaves', () => { const { container } = render(<CopyId content="tool-123" />) - const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }) + const trigger = screen.getByRole('button', { + name: 'appOverview.overview.appInfo.embedded.copy', + }) const wrapper = container.querySelector('.inline-flex') as HTMLElement act(() => { diff --git a/web/app/components/workflow/nodes/tool/components/copy-id.tsx b/web/app/components/workflow/nodes/tool/components/copy-id.tsx index 4074f4623a784e..999bc7dff494b1 100644 --- a/web/app/components/workflow/nodes/tool/components/copy-id.tsx +++ b/web/app/components/workflow/nodes/tool/components/copy-id.tsx @@ -24,33 +24,37 @@ const CopyFeedbackNew = ({ content }: Props) => { const onMouseLeave = debounce(() => { setIsCopied(false) }, 100) - const tooltip = (isCopied - ? t($ => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) - : t($ => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' })) || '' + const tooltip = + (isCopied + ? t(($) => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) + : t(($) => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' })) || '' return ( - <div className="inline-flex w-full pb-0.5" onClick={e => e.stopPropagation()} onMouseLeave={onMouseLeave}> + <div + className="inline-flex w-full pb-0.5" + onClick={(e) => e.stopPropagation()} + onMouseLeave={onMouseLeave} + > <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={tooltip} className="group/copy flex w-full items-center gap-0.5 text-left" onClick={onClickCopy} > - <span - className="w-0 grow cursor-pointer truncate system-2xs-regular text-text-quaternary group-hover:text-text-tertiary" - > + <span className="w-0 grow cursor-pointer truncate system-2xs-regular text-text-quaternary group-hover:text-text-tertiary"> {content} </span> - <span aria-hidden className="i-ri-file-copy-line size-3 shrink-0 text-text-tertiary opacity-0 group-hover/copy:opacity-100" /> + <span + aria-hidden + className="i-ri-file-copy-line size-3 shrink-0 text-text-tertiary opacity-0 group-hover/copy:opacity-100" + /> </button> - )} + } /> - <TooltipContent> - {tooltip} - </TooltipContent> + <TooltipContent>{tooltip}</TooltipContent> </Tooltip> </div> ) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/__tests__/index.spec.tsx index 59cdd1264d9867..418b5d626be153 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/__tests__/index.spec.tsx @@ -13,7 +13,7 @@ type MockPromptEditorProps = { workflowVariableBlock: { show: boolean variables: NodeOutPutVar[] - workflowNodesMap: Record<string, { title: string, type: BlockEnum }> + workflowNodesMap: Record<string, { title: string; type: BlockEnum }> showManageInputField?: boolean onManageInputField?: () => void } @@ -35,9 +35,10 @@ vi.mock('@/app/components/base/prompt-editor', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { controlPromptEditorRerenderKey: string }) => unknown) => selector({ - controlPromptEditorRerenderKey: 'rerender-key', - }), + useStore: (selector: (state: { controlPromptEditorRerenderKey: string }) => unknown) => + selector({ + controlPromptEditorRerenderKey: 'rerender-key', + }), })) describe('tool/mixed-variable-text-input', () => { @@ -48,11 +49,13 @@ describe('tool/mixed-variable-text-input', () => { it('should map workflow variable props into the prompt editor', () => { const handleChange = vi.fn() const handleManageInputField = vi.fn() - const nodesOutputVars: NodeOutPutVar[] = [{ - nodeId: 'tool-node', - title: 'Search Tool', - vars: [], - }] + const nodesOutputVars: NodeOutPutVar[] = [ + { + nodeId: 'tool-node', + title: 'Search Tool', + vars: [], + }, + ] const availableNodes = [ { id: 'start-node', @@ -101,7 +104,7 @@ describe('tool/mixed-variable-text-input', () => { title: 'Start Title', type: BlockEnum.Start, }, - 'sys': { + sys: { title: 'workflow.blocks.start', type: BlockEnum.Start, }, @@ -112,12 +115,7 @@ describe('tool/mixed-variable-text-input', () => { }) it('should disable editing and variable insertion when requested', () => { - render( - <MixedVariableTextInput - readOnly - disableVariableInsertion - />, - ) + render(<MixedVariableTextInput readOnly disableVariableInsertion />) expect(mockPromptEditor.mock.calls[0]![0]).toMatchObject({ editable: false, diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx index bf8b91d9c7e190..e8b1af6cc2b69c 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx @@ -6,10 +6,12 @@ const mockDispatchCommand = vi.fn() const mockInsertNodes = vi.fn() vi.mock('@lexical/react/LexicalComposerContext', () => ({ - useLexicalComposerContext: () => [{ - update: mockUpdate, - dispatchCommand: mockDispatchCommand, - }], + useLexicalComposerContext: () => [ + { + update: mockUpdate, + dispatchCommand: mockDispatchCommand, + }, + ], })) vi.mock('lexical', () => ({ @@ -30,13 +32,15 @@ vi.mock('@/app/components/base/prompt-editor/plugins/custom-text/node', () => ({ describe('tool/mixed-variable-text-input/placeholder', () => { beforeEach(() => { vi.clearAllMocks() - mockUpdate.mockImplementation(callback => callback()) + mockUpdate.mockImplementation((callback) => callback()) }) it('should insert text and focus the editor for click and slash actions', () => { render(<Placeholder />) - const root = screen.getByText('workflow.nodes.tool.insertPlaceholder1').closest('.cursor-text') as HTMLElement + const root = screen + .getByText('workflow.nodes.tool.insertPlaceholder1') + .closest('.cursor-text') as HTMLElement expect(screen.getByText('workflow.nodes.tool.insertPlaceholder1'))!.toBeInTheDocument() expect(screen.getByText('workflow.nodes.tool.insertPlaceholder2'))!.toBeInTheDocument() @@ -52,12 +56,7 @@ describe('tool/mixed-variable-text-input/placeholder', () => { }) it('should hide variable insertion affordance and badge when disabled', () => { - const { container } = render( - <Placeholder - disableVariableInsertion - hideBadge - />, - ) + const { container } = render(<Placeholder disableVariableInsertion hideBadge />) expect(screen.getByText('workflow.nodes.tool.insertPlaceholder1'))!.toBeInTheDocument() expect(screen.queryByText('workflow.nodes.tool.insertPlaceholder2')).not.toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 62f1d98d539fe6..125784d8cb9d5e 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -1,11 +1,6 @@ -import type { - Node, - NodeOutPutVar, -} from '@/app/components/workflow/types' +import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, -} from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import PromptEditor from '@/app/components/base/prompt-editor' import { useStore } from '@/app/components/workflow/store' @@ -33,7 +28,7 @@ const MixedVariableTextInput = ({ disableVariableInsertion = false, }: MixedVariableTextInputProps) => { const { t } = useTranslation() - const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) + const controlPromptEditorRerenderKey = useStore((s) => s.controlPromptEditorRerenderKey) return ( <PromptEditor @@ -56,7 +51,7 @@ const MixedVariableTextInput = ({ } if (node.data.type === BlockEnum.Start) { acc.sys = { - title: t($ => $['blocks.start'], { ns: 'workflow' }), + title: t(($) => $['blocks.start'], { ns: 'workflow' }), type: BlockEnum.Start, } } diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx index 4d1944c85e1bc6..38757055c1e319 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx @@ -16,13 +16,16 @@ const Placeholder = ({ disableVariableInsertion = false, hideBadge = false }: Pl const { t } = useTranslation() const [editor] = useLexicalComposerContext() - const handleInsert = useCallback((text: string) => { - editor.update(() => { - const textNode = new CustomTextNode(text) - $insertNodes([textNode]) - }) - editor.dispatchCommand(FOCUS_COMMAND, undefined as any) - }, [editor]) + const handleInsert = useCallback( + (text: string) => { + editor.update(() => { + const textNode = new CustomTextNode(text) + $insertNodes([textNode]) + }) + editor.dispatchCommand(FOCUS_COMMAND, undefined as any) + }, + [editor], + ) return ( <div @@ -36,30 +39,24 @@ const Placeholder = ({ disableVariableInsertion = false, hideBadge = false }: Pl }} > <div className="flex grow items-center"> - {t($ => $['nodes.tool.insertPlaceholder1'], { ns: 'workflow' })} - {(!disableVariableInsertion) && ( + {t(($) => $['nodes.tool.insertPlaceholder1'], { ns: 'workflow' })} + {!disableVariableInsertion && ( <> <Kbd className="mx-0.5 text-text-placeholder">/</Kbd> <div className="cursor-pointer system-sm-regular text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary" - onMouseDown={((e) => { + onMouseDown={(e) => { e.preventDefault() e.stopPropagation() handleInsert('/') - })} + }} > - {t($ => $['nodes.tool.insertPlaceholder2'], { ns: 'workflow' })} + {t(($) => $['nodes.tool.insertPlaceholder2'], { ns: 'workflow' })} </div> </> )} </div> - {!hideBadge && ( - <Badge - className="shrink-0" - text="String" - uppercase={false} - /> - )} + {!hideBadge && <Badge className="shrink-0" text="String" uppercase={false} />} </div> ) } diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/index.spec.tsx index 95c2cdd95dd852..99705f62c57183 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/index.spec.tsx @@ -23,7 +23,9 @@ const mockToolFormItem = vi.fn<(props: MockToolFormItemProps) => void>() vi.mock('../item', () => ({ default: (props: MockToolFormItemProps) => { mockToolFormItem(props) - return <div data-testid={`tool-form-item-${props.schema.variable}`}>{props.schema.label.en_US}</div> + return ( + <div data-testid={`tool-form-item-${props.schema.variable}`}>{props.schema.label.en_US}</div> + ) }, })) @@ -98,13 +100,7 @@ describe('tool/tool-form', () => { it('should render an empty container when schema is empty', () => { const { container } = render( - <ToolForm - readOnly={false} - nodeId="tool-node" - schema={[]} - value={{}} - onChange={vi.fn()} - />, + <ToolForm readOnly={false} nodeId="tool-node" schema={[]} value={{}} onChange={vi.fn()} />, ) expect(container.firstChild)!.toHaveClass('space-y-1') diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx index 42478ca34dcbd0..7667c810c00289 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx @@ -37,21 +37,25 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector/components', () => ({ SchemaModal: (props: MockSchemaModalProps) => { mockSchemaModal(props) - return props.isShow - ? ( - <div data-testid="schema-modal"> - <span>{props.rootName}</span> - <button type="button" onClick={props.onClose}>close-schema</button> - </div> - ) - : null + return props.isShow ? ( + <div data-testid="schema-modal"> + <span>{props.rootName}</span> + <button type="button" onClick={props.onClose}> + close-schema + </button> + </div> + ) : null }, })) vi.mock('@/app/components/workflow/nodes/_base/components/form-input-item', () => ({ default: (props: MockFormInputItemProps) => { mockFormInputItem(props) - return <div data-testid="form-input-item" data-provider-type={props.providerType}>{props.schema.variable}</div> + return ( + <div data-testid="form-input-item" data-provider-type={props.providerType}> + {props.schema.variable} + </div> + ) }, })) diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx index f858e5dc40153c..a842509b88dca0 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx @@ -36,25 +36,23 @@ const ToolForm: FC<Props> = ({ }) => { return ( <div className="space-y-1"> - { - schema.map((schema, index) => ( - <ToolFormItem - key={index} - readOnly={readOnly} - nodeId={nodeId} - schema={schema} - value={value} - onChange={onChange} - inPanel={inPanel} - currentTool={currentTool} - currentProvider={currentProvider} - showManageInputField={showManageInputField} - onManageInputField={onManageInputField} - extraParams={extraParams} - providerType="tool" - /> - )) - } + {schema.map((schema, index) => ( + <ToolFormItem + key={index} + readOnly={readOnly} + nodeId={nodeId} + schema={schema} + value={value} + onChange={onChange} + inPanel={inPanel} + currentTool={currentTool} + currentProvider={currentProvider} + showManageInputField={showManageInputField} + onManageInputField={onManageInputField} + extraParams={extraParams} + providerType="tool" + /> + ))} </div> ) } diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx index 6680c5ad325bf4..0380b4ae3ba598 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx @@ -5,9 +5,7 @@ import type { CredentialFormSchema } from '@/app/components/header/account-setti import type { Tool } from '@/app/components/tools/types' import type { ToolWithProvider } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' -import { - RiBracesLine, -} from '@remixicon/react' +import { RiBracesLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { Infotip } from '@/app/components/base/infotip' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -20,8 +18,7 @@ const URL_REGEX = /(https?:\/\/\S+)/g const renderDescriptionWithLinks = (description: string): ReactNode => { const matches = [...description.matchAll(URL_REGEX)] - if (!matches.length) - return description + if (!matches.length) return description const parts: ReactNode[] = [] let currentIndex = 0 @@ -30,8 +27,7 @@ const renderDescriptionWithLinks = (description: string): ReactNode => { const [url] = match const start = match.index ?? 0 - if (start > currentIndex) - parts.push(description.slice(currentIndex, start)) + if (start > currentIndex) parts.push(description.slice(currentIndex, start)) parts.push( <a @@ -48,8 +44,7 @@ const renderDescriptionWithLinks = (description: string): ReactNode => { currentIndex = start + url.length }) - if (currentIndex < description.length) - parts.push(description.slice(currentIndex)) + if (currentIndex < description.length) parts.push(description.slice(currentIndex)) return parts } @@ -87,15 +82,14 @@ const ToolFormItem: FC<Props> = ({ const { name, label, type, required, tooltip, input_schema } = schema const showSchemaButton = type === FormTypeEnum.object || type === FormTypeEnum.array const showDescription = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput - const [isShowSchema, { - setTrue: showSchema, - setFalse: hideSchema, - }] = useBoolean(false) + const [isShowSchema, { setTrue: showSchema, setFalse: hideSchema }] = useBoolean(false) return ( <div className="space-y-0.5 py-1"> <div> <div className="flex h-6 items-center"> - <div className="system-sm-medium text-text-secondary">{label[language] || label.en_US}</div> + <div className="system-sm-medium text-text-secondary"> + {label[language] || label.en_US} + </div> {required && ( <div className="ml-1 system-xs-regular text-text-destructive-secondary">*</div> )} @@ -145,12 +139,7 @@ const ToolFormItem: FC<Props> = ({ /> {isShowSchema && ( - <SchemaModal - isShow - onClose={hideSchema} - rootName={name} - schema={input_schema!} - /> + <SchemaModal isShow onClose={hideSchema} rootName={name} schema={input_schema!} /> )} </div> ) diff --git a/web/app/components/workflow/nodes/tool/default.ts b/web/app/components/workflow/nodes/tool/default.ts index 18a8a2211cce5a..12830cb91362a5 100644 --- a/web/app/components/workflow/nodes/tool/default.ts +++ b/web/app/components/workflow/nodes/tool/default.ts @@ -27,40 +27,65 @@ const nodeDefault: NodeDefault<ToolNodeType> = { checkValid(payload: ToolNodeType, t: TFunction<'workflow'>, moreDataForCheckValid: any) { const { toolInputsSchema, toolSettingSchema, language, notAuthed } = moreDataForCheckValid let errorMessages = '' - if (notAuthed) - errorMessages = t($ => $[`${i18nPrefix}.authRequired`], { ns: 'workflow' }) + if (notAuthed) errorMessages = t(($) => $[`${i18nPrefix}.authRequired`], { ns: 'workflow' }) if (!errorMessages) { - toolInputsSchema.filter((field: any) => { - return field.required - }).forEach((field: any) => { - const targetVar = payload.tool_parameters[field.variable] - if (!targetVar) { - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: field.label }) - return - } - const { type: variable_type, value } = targetVar - if (variable_type === VarKindType.variable) { - if (!errorMessages && (!value || value.length === 0)) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: field.label }) - } - else { - if (!errorMessages && (value === undefined || value === null || value === '')) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: field.label }) - } - }) + toolInputsSchema + .filter((field: any) => { + return field.required + }) + .forEach((field: any) => { + const targetVar = payload.tool_parameters[field.variable] + if (!targetVar) { + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: field.label, + }) + return + } + const { type: variable_type, value } = targetVar + if (variable_type === VarKindType.variable) { + if (!errorMessages && (!value || value.length === 0)) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: field.label, + }) + } else { + if (!errorMessages && (value === undefined || value === null || value === '')) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: field.label, + }) + } + }) } if (!errorMessages) { - toolSettingSchema.filter((field: any) => { - return field.required - }).forEach((field: any) => { - const value = payload.tool_configurations[field.variable] - if (!errorMessages && (value === undefined || value === null || value === '')) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: field.label[language] }) - if (!errorMessages && typeof value === 'object' && !!value.type && (value.value === undefined || value.value === null || value.value === '' || (Array.isArray(value.value) && value.value.length === 0))) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: field.label[language] }) - }) + toolSettingSchema + .filter((field: any) => { + return field.required + }) + .forEach((field: any) => { + const value = payload.tool_configurations[field.variable] + if (!errorMessages && (value === undefined || value === null || value === '')) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: field.label[language], + }) + if ( + !errorMessages && + typeof value === 'object' && + !!value.type && + (value.value === undefined || + value.value === null || + value.value === '' || + (Array.isArray(value.value) && value.value.length === 0)) + ) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: field.label[language], + }) + }) } return { @@ -68,7 +93,12 @@ const nodeDefault: NodeDefault<ToolNodeType> = { errorMessage: errorMessages, } }, - getOutputVars(payload: ToolNodeType, allPluginInfoList: Record<string, ToolWithProvider[]>, _ragVars: any, { schemaTypeDefinitions } = { schemaTypeDefinitions: [] }) { + getOutputVars( + payload: ToolNodeType, + allPluginInfoList: Record<string, ToolWithProvider[]>, + _ragVars: any, + { schemaTypeDefinitions } = { schemaTypeDefinitions: [] }, + ) { const { provider_id, provider_type } = payload let currentTools: ToolWithProvider[] = [] switch (provider_type) { @@ -87,14 +117,13 @@ const nodeDefault: NodeDefault<ToolNodeType> = { default: currentTools = [] } - const currCollection = currentTools.find(item => canFindTool(item.id, provider_id)) - const currTool = currCollection?.tools.find(tool => tool.name === payload.tool_name) + const currCollection = currentTools.find((item) => canFindTool(item.id, provider_id)) + const currTool = currCollection?.tools.find((tool) => tool.name === payload.tool_name) const output_schema = currTool?.output_schema let res: Var[] = [] if (!output_schema || !output_schema.properties) { res = TOOL_OUTPUT_STRUCT - } - else { + } else { const outputSchema: Var[] = [] Object.keys(output_schema.properties).forEach((outputKey) => { const output = output_schema.properties[outputKey] @@ -105,21 +134,19 @@ const nodeDefault: NodeDefault<ToolNodeType> = { type, des: output.description, schemaType, - children: output.type === 'object' - ? { - schema: { - type: Type.object, - properties: output.properties, - additionalProperties: false, - }, - } - : undefined, + children: + output.type === 'object' + ? { + schema: { + type: Type.object, + properties: output.properties, + additionalProperties: false, + }, + } + : undefined, }) }) - res = [ - ...TOOL_OUTPUT_STRUCT, - ...outputSchema, - ] + res = [...TOOL_OUTPUT_STRUCT, ...outputSchema] } return res }, diff --git a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-config.spec.tsx index 51b30d501e6dc6..6a83496c95b5cf 100644 --- a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-config.spec.tsx @@ -128,13 +128,18 @@ describe('useConfig', () => { }, }) - mockToolParametersToFormSchemas.mockImplementation(parameters => parameters) - mockGetConfiguredValue.mockImplementation((_value, schema: Array<{ variable: string, default?: string }>) => { - return schema.reduce<Record<string, ReturnType<typeof createToolVarInput>>>((acc, item) => { - acc[item.variable] = createToolVarInput(item.default || '') - return acc - }, {} as Record<string, ReturnType<typeof createToolVarInput>>) - }) + mockToolParametersToFormSchemas.mockImplementation((parameters) => parameters) + mockGetConfiguredValue.mockImplementation( + (_value, schema: Array<{ variable: string; default?: string }>) => { + return schema.reduce<Record<string, ReturnType<typeof createToolVarInput>>>( + (acc, item) => { + acc[item.variable] = createToolVarInput(item.default || '') + return acc + }, + {} as Record<string, ReturnType<typeof createToolVarInput>>, + ) + }, + ) }) afterEach(() => { @@ -150,16 +155,17 @@ describe('useConfig', () => { tool_configurations: { api_key: createToolVarInput('default secret') }, }) - const { rerender } = renderHook( - ({ payload }) => useConfig('tool-node-1', payload), - { initialProps: { payload: emptyPayload } }, - ) + const { rerender } = renderHook(({ payload }) => useConfig('tool-node-1', payload), { + initialProps: { payload: emptyPayload }, + }) expect(mockSetInputs).toHaveBeenCalledTimes(1) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - tool_parameters: { query: createToolVarInput('default query') }, - tool_configurations: { api_key: createToolVarInput('default secret') }, - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + tool_parameters: { query: createToolVarInput('default query') }, + tool_configurations: { api_key: createToolVarInput('default secret') }, + }), + ) rerender({ payload: syncedPayload }) @@ -167,10 +173,15 @@ describe('useConfig', () => { }) it('should not update inputs when tool values are already populated on first render', () => { - renderHook(() => useConfig('tool-node-1', createNodeData({ - tool_parameters: { query: createToolVarInput('existing query') }, - tool_configurations: { api_key: createToolVarInput('existing secret') }, - }))) + renderHook(() => + useConfig( + 'tool-node-1', + createNodeData({ + tool_parameters: { query: createToolVarInput('existing query') }, + tool_configurations: { api_key: createToolVarInput('existing secret') }, + }), + ), + ) expect(mockSetInputs).not.toHaveBeenCalled() }) diff --git a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-current-tool-collection.spec.tsx b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-current-tool-collection.spec.tsx index 939d0b1bb20cf5..3e0b36ecb9c008 100644 --- a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-current-tool-collection.spec.tsx +++ b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-current-tool-collection.spec.tsx @@ -20,27 +20,30 @@ vi.mock('@/utils', () => ({ canFindTool: (...args: unknown[]) => mockCanFindTool(...args), })) -const createToolCollection = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({ - id: 'builtin-search', - name: 'Google Search', - type: CollectionType.builtIn, - label: { en_US: 'Google Search' }, - description: { en_US: 'Search provider' }, - icon: '', - icon_dark: '', - background: '', - tags: [], - tools: [], - allow_delete: true, - is_team_authorization: false, - meta: {} as ToolWithProvider['meta'], - ...overrides, -}) as ToolWithProvider +const createToolCollection = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => + ({ + id: 'builtin-search', + name: 'Google Search', + type: CollectionType.builtIn, + label: { en_US: 'Google Search' }, + description: { en_US: 'Search provider' }, + icon: '', + icon_dark: '', + background: '', + tags: [], + tools: [], + allow_delete: true, + is_team_authorization: false, + meta: {} as ToolWithProvider['meta'], + ...overrides, + }) as ToolWithProvider describe('useCurrentToolCollection', () => { beforeEach(() => { vi.clearAllMocks() - mockCanFindTool.mockImplementation((collectionId: string, providerId: string) => collectionId === providerId) + mockCanFindTool.mockImplementation( + (collectionId: string, providerId: string) => collectionId === providerId, + ) mockUseAllBuiltInTools.mockReturnValue({ data: [] }) mockUseAllCustomTools.mockReturnValue({ data: [] }) mockUseAllWorkflowTools.mockReturnValue({ data: [] }) @@ -51,7 +54,9 @@ describe('useCurrentToolCollection', () => { const builtInCollection = createToolCollection({ id: 'builtin-search' }) mockUseAllBuiltInTools.mockReturnValue({ data: [builtInCollection] }) - const { result } = renderHook(() => useCurrentToolCollection(CollectionType.builtIn, 'builtin-search')) + const { result } = renderHook(() => + useCurrentToolCollection(CollectionType.builtIn, 'builtin-search'), + ) expect(result.current.currentTools).toEqual([builtInCollection]) expect(result.current.currCollection).toBe(builtInCollection) @@ -65,7 +70,9 @@ describe('useCurrentToolCollection', () => { }) mockUseAllCustomTools.mockReturnValue({ data: [customCollection] }) - const { result } = renderHook(() => useCurrentToolCollection(CollectionType.custom, 'custom-search')) + const { result } = renderHook(() => + useCurrentToolCollection(CollectionType.custom, 'custom-search'), + ) expect(result.current.currentTools).toEqual([customCollection]) expect(result.current.currCollection).toBe(customCollection) @@ -76,7 +83,9 @@ describe('useCurrentToolCollection', () => { data: [createToolCollection({ id: 'another-tool' })], }) - const { result } = renderHook(() => useCurrentToolCollection(CollectionType.builtIn, 'builtin-search')) + const { result } = renderHook(() => + useCurrentToolCollection(CollectionType.builtIn, 'builtin-search'), + ) expect(result.current.currentTools).toHaveLength(1) expect(result.current.currCollection).toBeUndefined() diff --git a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-get-data-for-check-more.spec.ts b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-get-data-for-check-more.spec.ts index 917adf239702c8..a92f3f28809d84 100644 --- a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-get-data-for-check-more.spec.ts +++ b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-get-data-for-check-more.spec.ts @@ -37,10 +37,12 @@ describe('useGetDataForCheckMore', () => { getMoreDataForCheckValid, }) - const { result } = renderHook(() => useGetDataForCheckMore({ - id: 'tool-node-1', - payload, - })) + const { result } = renderHook(() => + useGetDataForCheckMore({ + id: 'tool-node-1', + payload, + }), + ) expect(mockUseConfig).toHaveBeenCalledWith('tool-node-1', payload) expect(result.current.getData).toBe(getMoreDataForCheckValid) diff --git a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-single-run-form-params.spec.ts index a0b9a746bf60f7..4621199c74ebfa 100644 --- a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-single-run-form-params.spec.ts @@ -104,16 +104,18 @@ describe('useSingleRunFormParams', () => { createInputVar('#legacy.answer#'), ]) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'tool-node-1', - payload, - runInputData: {}, - runInputDataRef: { current: {} }, - getInputVars, - setRunInputData: vi.fn(), - toVarInputs: vi.fn(), - runResult: null as unknown as NodeTracing, - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'tool-node-1', + payload, + runInputData: {}, + runInputDataRef: { current: {} }, + getInputVars, + setRunInputData: vi.fn(), + toVarInputs: vi.fn(), + runResult: null as unknown as NodeTracing, + }), + ) expect(getInputVars).toHaveBeenCalledWith([ '{{#start.query#}}', @@ -147,16 +149,18 @@ describe('useSingleRunFormParams', () => { const getInputVars = vi.fn(() => [createInputVar('#start.query#')]) const setRunInputData = vi.fn() - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'tool-node-1', - payload, - runInputData: {}, - runInputDataRef: { current: {} }, - getInputVars, - setRunInputData, - toVarInputs: vi.fn(), - runResult: null as unknown as NodeTracing, - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'tool-node-1', + payload, + runInputData: {}, + runInputDataRef: { current: {} }, + getInputVars, + setRunInputData, + toVarInputs: vi.fn(), + runResult: null as unknown as NodeTracing, + }), + ) act(() => { result.current.forms[0]!.onChange({ @@ -188,16 +192,18 @@ describe('useSingleRunFormParams', () => { const payload = createNodeData() const runResult = createRunResult() - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'tool-node-1', - payload, - runInputData: {}, - runInputDataRef: { current: {} }, - getInputVars: vi.fn(() => []), - setRunInputData: vi.fn(), - toVarInputs: vi.fn(), - runResult, - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'tool-node-1', + payload, + runInputData: {}, + runInputDataRef: { current: {} }, + getInputVars: vi.fn(() => []), + setRunInputData: vi.fn(), + toVarInputs: vi.fn(), + runResult, + }), + ) expect(mockFormatToTracingNodeList).toHaveBeenCalledWith([runResult], expect.any(Function)) expect(result.current.nodeInfo).toEqual({ id: 'formatted-node' }) diff --git a/web/app/components/workflow/nodes/tool/hooks/use-config.ts b/web/app/components/workflow/nodes/tool/hooks/use-config.ts index 1466edfeeac92e..c3657210a229f0 100644 --- a/web/app/components/workflow/nodes/tool/hooks/use-config.ts +++ b/web/app/components/workflow/nodes/tool/hooks/use-config.ts @@ -12,15 +12,11 @@ import { getConfiguredValue, toolParametersToFormSchemas, } from '@/app/components/tools/utils/to-form-schema' -import { - useNodesReadOnly, -} from '@/app/components/workflow/hooks' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useWorkflowStore } from '@/app/components/workflow/store' import { updateBuiltInToolCredential } from '@/service/tools' -import { - useInvalidToolsByType, -} from '@/service/use-tools' +import { useInvalidToolsByType } from '@/service/use-tools' import { isToolAuthorizationRequired } from '../auth' import { normalizeJsonSchemaType } from '../output-schema-utils' import useCurrentToolCollection from './use-current-tool-collection' @@ -36,50 +32,33 @@ const useConfig = (id: string, payload: ToolNodeType) => { const { t } = useTranslation() const language = useLanguage() - const { inputs, setInputs: doSetInputs } = useNodeCrud<ToolNodeType>( - id, - payload, - ) + const { inputs, setInputs: doSetInputs } = useNodeCrud<ToolNodeType>(id, payload) /* * tool_configurations: tool setting, not dynamic setting (form type = form) * tool_parameters: tool dynamic setting(form type = llm) */ - const { - provider_id, - provider_type, - tool_name, - tool_configurations, - tool_parameters, - } = inputs + const { provider_id, provider_type, tool_name, tool_configurations, tool_parameters } = inputs const isBuiltIn = provider_type === CollectionType.builtIn const { currCollection } = useCurrentToolCollection(provider_type, provider_id) // Auth const isShowAuthBtn = isToolAuthorizationRequired(provider_type, currCollection) - const [ - showSetAuth, - { setTrue: showSetAuthModal, setFalse: hideSetAuthModal }, - ] = useBoolean(false) + const [showSetAuth, { setTrue: showSetAuthModal, setFalse: hideSetAuthModal }] = useBoolean(false) const invalidToolsByType = useInvalidToolsByType(provider_type) const handleSaveAuth = useCallback( async (value: any) => { await updateBuiltInToolCredential(currCollection?.name as string, value) - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) invalidToolsByType() hideSetAuthModal() }, - [ - currCollection?.name, - hideSetAuthModal, - t, - invalidToolsByType, - ], + [currCollection?.name, hideSetAuthModal, t, invalidToolsByType], ) const currTool = useMemo(() => { - return currCollection?.tools.find(tool => tool.name === tool_name) + return currCollection?.tools.find((tool) => tool.name === tool_name) }, [currCollection, tool_name]) const formSchemas = useMemo(() => { return currTool ? toolParametersToFormSchemas(currTool.parameters) : [] @@ -92,7 +71,7 @@ const useConfig = (id: string, payload: ToolNodeType) => { return formSchemas.filter((item: any) => item.form !== 'llm') }, [formSchemas]) const hasShouldTransferTypeSettingInput = toolSettingSchema.some( - item => item.type === 'boolean' || item.type === 'number-input', + (item) => item.type === 'boolean' || item.type === 'number-input', ) const setInputs = useCallback( @@ -104,19 +83,16 @@ const useConfig = (id: string, payload: ToolNodeType) => { const newInputs = produce(value, (draft) => { const newConfig = { ...draft.tool_configurations } Object.keys(draft.tool_configurations).forEach((key) => { - const schema = formSchemas.find(item => item.variable === key) + const schema = formSchemas.find((item) => item.variable === key) const value = newConfig[key] if (schema?.type === 'boolean') { - if (typeof value === 'string') - newConfig[key] = value === 'true' || value === '1' + if (typeof value === 'string') newConfig[key] = value === 'true' || value === '1' - if (typeof value === 'number') - newConfig[key] = value === 1 + if (typeof value === 'number') newConfig[key] = value === 1 } if (schema?.type === 'number-input') { - if (typeof value === 'string' && value !== '') - newConfig[key] = Number.parseFloat(value) + if (typeof value === 'string' && value !== '') newConfig[key] = Number.parseFloat(value) } }) draft.tool_configurations = newConfig @@ -127,8 +103,7 @@ const useConfig = (id: string, payload: ToolNodeType) => { ) const [notSetDefaultValue, setNotSetDefaultValue] = useState(false) const toolSettingValue = useMemo(() => { - if (notSetDefaultValue) - return tool_configurations + if (notSetDefaultValue) return tool_configurations return getConfiguredValue(tool_configurations, toolSettingSchema) }, [notSetDefaultValue, toolSettingSchema, tool_configurations]) const setToolSettingValue = useCallback( @@ -144,10 +119,7 @@ const useConfig = (id: string, payload: ToolNodeType) => { const formattingParameters = useCallback(() => { const inputsWithDefaultValue = produce(inputs, (draft) => { - if ( - !draft.tool_configurations - || Object.keys(draft.tool_configurations).length === 0 - ) { + if (!draft.tool_configurations || Object.keys(draft.tool_configurations).length === 0) { const configuredToolSettings = getConfiguredValue( tool_configurations, toolSettingSchema, @@ -155,10 +127,7 @@ const useConfig = (id: string, payload: ToolNodeType) => { if (Object.keys(configuredToolSettings).length > 0) draft.tool_configurations = configuredToolSettings } - if ( - !draft.tool_parameters - || Object.keys(draft.tool_parameters).length === 0 - ) { + if (!draft.tool_parameters || Object.keys(draft.tool_parameters).length === 0) { const configuredToolParameters = getConfiguredValue( tool_parameters, toolInputVarSchema, @@ -171,11 +140,9 @@ const useConfig = (id: string, payload: ToolNodeType) => { }, [inputs, toolInputVarSchema, toolSettingSchema, tool_configurations, tool_parameters]) useEffect(() => { - if (!currTool) - return + if (!currTool) return const inputsWithDefaultValue = formattingParameters() - if (inputsWithDefaultValue === inputs) - return + if (inputsWithDefaultValue === inputs) return const { setControlPromptEditorRerenderKey } = workflowStore.getState() setInputs(inputsWithDefaultValue) @@ -222,8 +189,7 @@ const useConfig = (id: string, payload: ToolNodeType) => { const outputSchema = useMemo(() => { const res: any[] = [] const output_schema = currTool?.output_schema - if (!output_schema || !output_schema.properties) - return res + if (!output_schema || !output_schema.properties) return res Object.keys(output_schema.properties).forEach((outputKey) => { const output = output_schema.properties[outputKey] @@ -233,8 +199,7 @@ const useConfig = (id: string, payload: ToolNodeType) => { name: outputKey, value: output, }) - } - else { + } else { const normalizedType = normalizeJsonSchemaType(output) res.push({ name: outputKey, @@ -251,12 +216,9 @@ const useConfig = (id: string, payload: ToolNodeType) => { const hasObjectOutput = useMemo(() => { const output_schema = currTool?.output_schema - if (!output_schema || !output_schema.properties) - return false + if (!output_schema || !output_schema.properties) return false const properties = output_schema.properties - return Object.keys(properties).some( - key => properties[key].type === 'object', - ) + return Object.keys(properties).some((key) => properties[key].type === 'object') }, [currTool]) return { diff --git a/web/app/components/workflow/nodes/tool/hooks/use-current-tool-collection.ts b/web/app/components/workflow/nodes/tool/hooks/use-current-tool-collection.ts index 8a1d0440d2cff6..0d05bff4ed030c 100644 --- a/web/app/components/workflow/nodes/tool/hooks/use-current-tool-collection.ts +++ b/web/app/components/workflow/nodes/tool/hooks/use-current-tool-collection.ts @@ -35,7 +35,7 @@ const useCurrentToolCollection = ( }, [buildInTools, customTools, mcpTools, providerType, workflowTools]) const currCollection = useMemo(() => { - return currentTools.find(item => canFindTool(item.id, providerId)) + return currentTools.find((item) => canFindTool(item.id, providerId)) }, [currentTools, providerId]) return { diff --git a/web/app/components/workflow/nodes/tool/hooks/use-get-data-for-check-more.ts b/web/app/components/workflow/nodes/tool/hooks/use-get-data-for-check-more.ts index d814f14728edb5..b3c0eaef5bf702 100644 --- a/web/app/components/workflow/nodes/tool/hooks/use-get-data-for-check-more.ts +++ b/web/app/components/workflow/nodes/tool/hooks/use-get-data-for-check-more.ts @@ -6,10 +6,7 @@ type Params = { payload: ToolNodeType } -const useGetDataForCheckMore = ({ - id, - payload, -}: Params) => { +const useGetDataForCheckMore = ({ id, payload }: Params) => { const { getMoreDataForCheckValid } = useConfig(id, payload) return { diff --git a/web/app/components/workflow/nodes/tool/hooks/use-single-run-form-params.ts b/web/app/components/workflow/nodes/tool/hooks/use-single-run-form-params.ts index a7b5687b72c739..14d0111131d430 100644 --- a/web/app/components/workflow/nodes/tool/hooks/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/tool/hooks/use-single-run-form-params.ts @@ -32,37 +32,45 @@ const useSingleRunFormParams = ({ const { inputs } = useNodeCrud<ToolNodeType>(id, payload) const hadVarParams = Object.keys(inputs.tool_parameters) - .filter(key => inputs.tool_parameters[key]!.type !== VarType.constant) - .map(k => inputs.tool_parameters[k]) + .filter((key) => inputs.tool_parameters[key]!.type !== VarType.constant) + .map((k) => inputs.tool_parameters[k]) const hadVarSettings = Object.keys(inputs.tool_configurations) - .filter(key => typeof inputs.tool_configurations[key] === 'object' && inputs.tool_configurations[key].type && inputs.tool_configurations[key].type !== VarType.constant) - .map(k => inputs.tool_configurations[k]) + .filter( + (key) => + typeof inputs.tool_configurations[key] === 'object' && + inputs.tool_configurations[key].type && + inputs.tool_configurations[key].type !== VarType.constant, + ) + .map((k) => inputs.tool_configurations[k]) - const varInputs = getInputVars([...hadVarParams, ...hadVarSettings].map((p) => { - if (p.type === VarType.variable) { - // handle the old wrong value not crash the page - if (!(p.value as any).join) - return `{{#${p.value}#}}` + const varInputs = getInputVars( + [...hadVarParams, ...hadVarSettings].map((p) => { + if (p.type === VarType.variable) { + // handle the old wrong value not crash the page + if (!(p.value as any).join) return `{{#${p.value}#}}` - return `{{#${(p.value as ValueSelector).join('.')}#}}` - } + return `{{#${(p.value as ValueSelector).join('.')}#}}` + } - return p.value as string - })) + return p.value as string + }), + ) const [inputVarValues, setInputVarValues] = useState<Record<string, any>>({}) - const handleInputVarValuesChange = useCallback((value: Record<string, any>) => { - setInputVarValues(value) - setRunInputData(value) - }, [setRunInputData]) + const handleInputVarValuesChange = useCallback( + (value: Record<string, any>) => { + setInputVarValues(value) + setRunInputData(value) + }, + [setRunInputData], + ) const inputVarValuesWithConstantValue = useCallback(() => { const res = produce(inputVarValues, (draft) => { Object.keys(inputs.tool_parameters).forEach((key: string) => { const { type, value } = inputs.tool_parameters[key]! if (type === VarType.constant && (value === undefined || value === null)) { - if (!draft.tool_parameters || !draft.tool_parameters[key]) - return + if (!draft.tool_parameters || !draft.tool_parameters[key]) return draft[key] = value } }) @@ -71,30 +79,32 @@ const useSingleRunFormParams = ({ }, [inputs.tool_parameters, inputVarValues]) const forms = useMemo(() => { - const forms: FormProps[] = [{ - inputs: varInputs, - values: inputVarValuesWithConstantValue(), - onChange: handleInputVarValuesChange, - }] + const forms: FormProps[] = [ + { + inputs: varInputs, + values: inputVarValuesWithConstantValue(), + onChange: handleInputVarValuesChange, + }, + ] return forms }, [handleInputVarValuesChange, inputVarValuesWithConstantValue, varInputs]) const nodeInfo = useMemo(() => { - if (!runResult) - return null + if (!runResult) return null return formatToTracingNodeList([runResult], t)[0] }, [runResult, t]) const toolIcon = useToolIcon(payload) const getDependentVars = () => { - return varInputs.map((item) => { - // Guard against null/undefined variable to prevent app crash - if (!item.variable || typeof item.variable !== 'string') - return [] + return varInputs + .map((item) => { + // Guard against null/undefined variable to prevent app crash + if (!item.variable || typeof item.variable !== 'string') return [] - return item.variable.slice(1, -1).split('.') - }).filter(arr => arr.length > 0) + return item.variable.slice(1, -1).split('.') + }) + .filter((arr) => arr.length > 0) } return { diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index 2540d93e6774f7..80bb7cde0b01ae 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -9,28 +9,19 @@ import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/compo import { isToolAuthorizationRequired } from './auth' import useCurrentToolCollection from './hooks/use-current-tool-collection' -const Node: FC<NodeProps<ToolNodeType>> = ({ - data, -}) => { +const Node: FC<NodeProps<ToolNodeType>> = ({ data }) => { const { t } = useTranslation() const { tool_configurations, paramSchemas } = data const toolConfigs = Object.keys(tool_configurations || {}) - const { - isChecking, - isMissing, - uniqueIdentifier, - canInstall, - onInstallSuccess, - shouldDim, - } = useNodePluginInstallation(data) + const { isChecking, isMissing, uniqueIdentifier, canInstall, onInstallSuccess, shouldDim } = + useNodePluginInstallation(data) const { currCollection } = useCurrentToolCollection(data.provider_type, data.provider_id) const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier const showAuthorizationWarning = isToolAuthorizationRequired(data.provider_type, currCollection) const hasConfigs = toolConfigs.length > 0 - if (!showInstallButton && !hasConfigs && !showAuthorizationWarning) - return null + if (!showInstallButton && !hasConfigs && !showAuthorizationWarning) return null return ( <div className="relative mb-1 px-3 py-1"> @@ -39,11 +30,9 @@ const Node: FC<NodeProps<ToolNodeType>> = ({ <InstallPluginButton size="small" className="font-medium! text-text-accent!" - extraIdentifiers={[ - data.plugin_id, - data.provider_id, - data.provider_name, - ].filter(Boolean) as string[]} + extraIdentifiers={ + [data.plugin_id, data.provider_id, data.provider_name].filter(Boolean) as string[] + } uniqueIdentifier={uniqueIdentifier!} onSuccess={onInstallSuccess} /> @@ -51,33 +40,61 @@ const Node: FC<NodeProps<ToolNodeType>> = ({ )} {(hasConfigs || showAuthorizationWarning) && ( <div className="space-y-0.5" aria-disabled={shouldDim}> - {hasConfigs && toolConfigs.map(key => ( - <div key={key} className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary"> - <div title={key} className="max-w-[100px] shrink-0 truncate text-xs font-medium text-text-tertiary uppercase"> - {key} - </div> - {typeof tool_configurations[key].value === 'string' && ( - <div title={tool_configurations[key].value} className="w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary"> - {paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key].value} - </div> - )} - {typeof tool_configurations[key].value === 'number' && ( - <div title={Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} className="w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary"> - {Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} - </div> - )} - {typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.modelSelector && ( - <div title={tool_configurations[key].model} className="w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary"> - {tool_configurations[key].model} + {hasConfigs && + toolConfigs.map((key) => ( + <div + key={key} + className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary" + > + <div + title={key} + className="max-w-[100px] shrink-0 truncate text-xs font-medium text-text-tertiary uppercase" + > + {key} </div> - )} - </div> - ))} + {typeof tool_configurations[key].value === 'string' && ( + <div + title={tool_configurations[key].value} + className="w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary" + > + {paramSchemas?.find((i) => i.name === key)?.type === FormTypeEnum.secretInput + ? '********' + : tool_configurations[key].value} + </div> + )} + {typeof tool_configurations[key].value === 'number' && ( + <div + title={ + Number.isNaN(tool_configurations[key].value) + ? '' + : tool_configurations[key].value + } + className="w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary" + > + {Number.isNaN(tool_configurations[key].value) + ? '' + : tool_configurations[key].value} + </div> + )} + {typeof tool_configurations[key] !== 'string' && + tool_configurations[key]?.type === FormTypeEnum.modelSelector && ( + <div + title={tool_configurations[key].model} + className="w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary" + > + {tool_configurations[key].model} + </div> + )} + </div> + ))} {showAuthorizationWarning && ( <div className="flex h-6 items-center rounded-md border-[0.5px] border-state-warning-active bg-state-warning-hover px-1.5"> <span className="mr-1 size-[4px] shrink-0 rounded-xs bg-text-warning-secondary" /> - <div className="grow truncate system-xs-medium text-text-warning" title={t($ => $['nodes.tool.authorizationRequired'], { ns: 'workflow' })}> - {t($ => $['nodes.tool.authorizationRequired'], { ns: 'workflow' })} + <div + className="grow truncate system-xs-medium text-text-warning" + title={t(($) => $['nodes.tool.authorizationRequired'], { ns: 'workflow' })} + > + {t(($) => $['nodes.tool.authorizationRequired'], { ns: 'workflow' })} </div> </div> )} diff --git a/web/app/components/workflow/nodes/tool/output-schema-utils.ts b/web/app/components/workflow/nodes/tool/output-schema-utils.ts index 5120aee67babf3..7b087f3056cda8 100644 --- a/web/app/components/workflow/nodes/tool/output-schema-utils.ts +++ b/web/app/components/workflow/nodes/tool/output-schema-utils.ts @@ -11,8 +11,7 @@ import { getMatchedSchemaType } from '../_base/components/variable/use-match-sch const resolveDifyCompactTypeString = (typeStr: string): VarType | undefined => { const trimmed = typeStr.trim() const m = /^array\[(string|number|integer|boolean|object|file|any)\]$/i.exec(trimmed) - if (!m) - return undefined + if (!m) return undefined const inner = m[1]!.toLowerCase() const map: Record<string, VarType> = { string: VarType.arrayString, @@ -31,15 +30,13 @@ const resolveDifyCompactTypeString = (typeStr: string): VarType | undefined => { * Handles complex schemas with oneOf, anyOf, allOf. */ export const normalizeJsonSchemaType = (schema: any): string | undefined => { - if (!schema) - return undefined + if (!schema) return undefined const { type, properties, items, oneOf, anyOf, allOf } = schema if (Array.isArray(type)) return type.find((item: string | null) => item && item !== 'null') || type[0] - if (typeof type === 'string') - return type + if (typeof type === 'string') return type const compositeCandidates = [oneOf, anyOf, allOf] .filter((entry): entry is any[] => Array.isArray(entry)) @@ -47,15 +44,12 @@ export const normalizeJsonSchemaType = (schema: any): string | undefined => { for (const candidate of compositeCandidates) { const normalized = normalizeJsonSchemaType(candidate) - if (normalized) - return normalized + if (normalized) return normalized } - if (properties) - return 'object' + if (properties) return 'object' - if (items) - return 'array' + if (items) return 'array' return undefined } @@ -64,8 +58,7 @@ export const normalizeJsonSchemaType = (schema: any): string | undefined => { * Extracts the items schema from an array schema. */ export const pickItemSchema = (schema: any) => { - if (!schema || !schema.items) - return undefined + if (!schema || !schema.items) return undefined return Array.isArray(schema.items) ? schema.items[0] : schema.items } @@ -76,12 +69,11 @@ export const pickItemSchema = (schema: any) => { export const resolveVarType = ( schema: any, schemaTypeDefinitions?: SchemaTypeDefinition[], -): { type: VarType, schemaType?: string } => { +): { type: VarType; schemaType?: string } => { const schemaType = getMatchedSchemaType(schema, schemaTypeDefinitions) if (schema && typeof schema.type === 'string') { const compact = resolveDifyCompactTypeString(schema.type) - if (compact !== undefined) - return { type: compact, schemaType } + if (compact !== undefined) return { type: compact, schemaType } } const normalizedType = normalizeJsonSchemaType(schema) @@ -96,15 +88,16 @@ export const resolveVarType = ( case 'boolean': return { type: VarType.boolean, schemaType } case 'object': - if (schemaType === 'file') - return { type: VarType.file, schemaType } + if (schemaType === 'file') return { type: VarType.file, schemaType } return { type: VarType.object, schemaType } case 'array': { const itemSchema = pickItemSchema(schema) - if (!itemSchema) - return { type: VarType.array, schemaType } + if (!itemSchema) return { type: VarType.array, schemaType } - const { type: itemType, schemaType: itemSchemaType } = resolveVarType(itemSchema, schemaTypeDefinitions) + const { type: itemType, schemaType: itemSchemaType } = resolveVarType( + itemSchema, + schemaTypeDefinitions, + ) const resolvedSchemaType = schemaType || itemSchemaType if (itemSchemaType === 'file') diff --git a/web/app/components/workflow/nodes/tool/panel.tsx b/web/app/components/workflow/nodes/tool/panel.tsx index dbf3f6a0d8309a..6f4c00d6d7487e 100644 --- a/web/app/components/workflow/nodes/tool/panel.tsx +++ b/web/app/components/workflow/nodes/tool/panel.tsx @@ -10,16 +10,15 @@ import StructureOutputItem from '@/app/components/workflow/nodes/_base/component import { useStore } from '@/app/components/workflow/store' import { wrapStructuredVarItem } from '@/app/components/workflow/utils/tool' import Split from '../_base/components/split' -import useMatchSchemaType, { getMatchedSchemaType } from '../_base/components/variable/use-match-schema-type' +import useMatchSchemaType, { + getMatchedSchemaType, +} from '../_base/components/variable/use-match-schema-type' import ToolForm from './components/tool-form' import useConfig from './hooks/use-config' const i18nPrefix = 'nodes.tool' -const Panel: FC<NodePanelProps<ToolNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<ToolNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { readOnly, @@ -38,8 +37,8 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({ } = useConfig(id, data) const [collapsed, setCollapsed] = React.useState(false) - const pipelineId = useStore(s => s.pipelineId) - const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel) + const pipelineId = useStore((s) => s.pipelineId) + const setShowInputFieldPanel = useStore((s) => s.setShowInputFieldPanel) const { schemaTypeDefinitions } = useMatchSchemaType() if (isLoading) { @@ -57,7 +56,7 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({ {toolInputVarSchema.length > 0 && ( <Field className="px-4" - title={t($ => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.inputVars`], { ns: 'workflow' })} > <ToolForm readOnly={readOnly} @@ -80,7 +79,7 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({ {toolSettingSchema.length > 0 && ( <> <OutputVars - title={t($ => $[`${i18nPrefix}.settings`], { ns: 'workflow' })} + title={t(($) => $[`${i18nPrefix}.settings`], { ns: 'workflow' })} collapsed={collapsed} onCollapse={setCollapsed} > @@ -104,19 +103,19 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({ <VarItem name="text" type="string" - description={t($ => $[`${i18nPrefix}.outputVars.text`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.text`], { ns: 'workflow' })} isIndent={hasObjectOutput} /> <VarItem name="files" type="array[file]" - description={t($ => $[`${i18nPrefix}.outputVars.files.title`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.files.title`], { ns: 'workflow' })} isIndent={hasObjectOutput} /> <VarItem name="json" type="array[object]" - description={t($ => $[`${i18nPrefix}.outputVars.json`], { ns: 'workflow' })} + description={t(($) => $[`${i18nPrefix}.outputVars.json`], { ns: 'workflow' })} isIndent={hasObjectOutput} /> {outputSchema.map((outputItem) => { @@ -124,22 +123,19 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({ // TODO empty object type always match `qa_structured` schema type return ( <div key={outputItem.name}> - {outputItem.value?.type === 'object' - ? ( - <StructureOutputItem - rootClassName="code-sm-semibold text-text-secondary" - payload={wrapStructuredVarItem(outputItem, schemaType)} - /> - ) - : ( - <VarItem - name={outputItem.name} - - type={`${outputItem.type.toLocaleLowerCase()}${schemaType ? ` (${schemaType})` : ''}`} - description={outputItem.description} - isIndent={hasObjectOutput} - /> - )} + {outputItem.value?.type === 'object' ? ( + <StructureOutputItem + rootClassName="code-sm-semibold text-text-secondary" + payload={wrapStructuredVarItem(outputItem, schemaType)} + /> + ) : ( + <VarItem + name={outputItem.name} + type={`${outputItem.type.toLocaleLowerCase()}${schemaType ? ` (${schemaType})` : ''}`} + description={outputItem.description} + isIndent={hasObjectOutput} + /> + )} </div> ) })} diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx index d3282f7d94adfa..e7257d7b124ad2 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx @@ -34,23 +34,21 @@ const TriggerForm: FC<Props> = ({ }) => { return ( <div className="space-y-1"> - { - schema.map((schema, index) => ( - <TriggerFormItem - key={index} - readOnly={readOnly} - nodeId={nodeId} - schema={schema} - value={value} - onChange={onChange} - inPanel={inPanel} - currentEvent={currentEvent} - currentProvider={currentProvider} - extraParams={extraParams} - disableVariableInsertion={disableVariableInsertion} - /> - )) - } + {schema.map((schema, index) => ( + <TriggerFormItem + key={index} + readOnly={readOnly} + nodeId={nodeId} + schema={schema} + value={value} + onChange={onChange} + inPanel={inPanel} + currentEvent={currentEvent} + currentProvider={currentProvider} + extraParams={extraParams} + disableVariableInsertion={disableVariableInsertion} + /> + ))} </div> ) } diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx index 9427c8857f2b58..7b9843d2596f2a 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx @@ -5,9 +5,7 @@ import type { Event } from '@/app/components/tools/types' import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' import type { PluginTriggerVarInputs } from '@/app/components/workflow/nodes/trigger-plugin/types' import { Button } from '@langgenius/dify-ui/button' -import { - RiBracesLine, -} from '@remixicon/react' +import { RiBracesLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { Infotip } from '@/app/components/base/infotip' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -44,15 +42,14 @@ const TriggerFormItem: FC<Props> = ({ const { name, label, type, required, tooltip, input_schema } = schema const showSchemaButton = type === FormTypeEnum.object || type === FormTypeEnum.array const showDescription = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput - const [isShowSchema, { - setTrue: showSchema, - setFalse: hideSchema, - }] = useBoolean(false) + const [isShowSchema, { setTrue: showSchema, setFalse: hideSchema }] = useBoolean(false) return ( <div className="space-y-0.5 py-1"> <div> <div className="flex h-6 items-center"> - <div className="system-sm-medium text-text-secondary">{label[language] || label.en_US}</div> + <div className="system-sm-medium text-text-secondary"> + {label[language] || label.en_US} + </div> {required && ( <div className="ml-1 system-xs-regular text-text-destructive-secondary">*</div> )} @@ -81,7 +78,9 @@ const TriggerFormItem: FC<Props> = ({ )} </div> {showDescription && tooltip && ( - <div className="pb-0.5 body-xs-regular text-text-tertiary">{tooltip[language] || tooltip.en_US}</div> + <div className="pb-0.5 body-xs-regular text-text-tertiary"> + {tooltip[language] || tooltip.en_US} + </div> )} </div> <FormInputItem @@ -99,12 +98,7 @@ const TriggerFormItem: FC<Props> = ({ /> {isShowSchema && ( - <SchemaModal - isShow - onClose={hideSchema} - rootName={name} - schema={input_schema!} - /> + <SchemaModal isShow onClose={hideSchema} rootName={name} schema={input_schema!} /> )} </div> ) diff --git a/web/app/components/workflow/nodes/trigger-plugin/default.ts b/web/app/components/workflow/nodes/trigger-plugin/default.ts index da35bc7e282aa3..551ebba9e58ab1 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/default.ts +++ b/web/app/components/workflow/nodes/trigger-plugin/default.ts @@ -9,15 +9,13 @@ import { VarKindType } from '../_base/types' import { Type } from '../llm/types' const normalizeJsonSchemaType = (schema: any): string | undefined => { - if (!schema) - return undefined + if (!schema) return undefined const { type, properties, items, oneOf, anyOf, allOf } = schema if (Array.isArray(type)) return type.find((item: string | null) => item && item !== 'null') || type[0] - if (typeof type === 'string') - return type + if (typeof type === 'string') return type const compositeCandidates = [oneOf, anyOf, allOf] .filter((entry): entry is any[] => Array.isArray(entry)) @@ -25,28 +23,26 @@ const normalizeJsonSchemaType = (schema: any): string | undefined => { for (const candidate of compositeCandidates) { const normalized = normalizeJsonSchemaType(candidate) - if (normalized) - return normalized + if (normalized) return normalized } - if (properties) - return 'object' + if (properties) return 'object' - if (items) - return 'array' + if (items) return 'array' return undefined } const pickItemSchema = (schema: any) => { - if (!schema || !schema.items) - return undefined + if (!schema || !schema.items) return undefined return Array.isArray(schema.items) ? schema.items[0] : schema.items } -const extractSchemaType = (schema: any, _schemaTypeDefinitions?: SchemaTypeDefinition[]): string | undefined => { - if (!schema) - return undefined +const extractSchemaType = ( + schema: any, + _schemaTypeDefinitions?: SchemaTypeDefinition[], +): string | undefined => { + if (!schema) return undefined const schemaTypeFromSchema = schema.schema_type || schema.schemaType if (typeof schemaTypeFromSchema === 'string' && schemaTypeFromSchema.trim().length > 0) @@ -58,7 +54,7 @@ const extractSchemaType = (schema: any, _schemaTypeDefinitions?: SchemaTypeDefin const resolveVarType = ( schema: any, schemaTypeDefinitions?: SchemaTypeDefinition[], -): { type: VarType, schemaType?: string } => { +): { type: VarType; schemaType?: string } => { const schemaType = extractSchemaType(schema, schemaTypeDefinitions) const normalizedType = normalizeJsonSchemaType(schema) @@ -75,10 +71,12 @@ const resolveVarType = ( return { type: VarType.object, schemaType } case 'array': { const itemSchema = pickItemSchema(schema) - if (!itemSchema) - return { type: VarType.array, schemaType } + if (!itemSchema) return { type: VarType.array, schemaType } - const { type: itemType, schemaType: itemSchemaType } = resolveVarType(itemSchema, schemaTypeDefinitions) + const { type: itemType, schemaType: itemSchemaType } = resolveVarType( + itemSchema, + schemaTypeDefinitions, + ) const resolvedSchemaType = schemaType || itemSchemaType if (itemSchemaType === 'file') @@ -106,8 +104,7 @@ const resolveVarType = ( } const toFieldType = (normalizedType: string | undefined, schemaType?: string): Type => { - if (schemaType === 'file') - return normalizedType === 'array' ? Type.array : Type.file + if (schemaType === 'file') return normalizedType === 'array' ? Type.array : Type.file switch (normalizedType) { case 'number': @@ -126,12 +123,14 @@ const toFieldType = (normalizedType: string | undefined, schemaType?: string): T } const toArrayItemType = (type: Type): Exclude<Type, Type.array> => { - if (type === Type.array) - return Type.object + if (type === Type.array) return Type.object return type as Exclude<Type, Type.array> } -const convertJsonSchemaToField = (schema: any, schemaTypeDefinitions?: SchemaTypeDefinition[]): Field => { +const convertJsonSchemaToField = ( + schema: any, + schemaTypeDefinitions?: SchemaTypeDefinition[], +): Field => { const schemaType = extractSchemaType(schema, schemaTypeDefinitions) const normalizedType = normalizeJsonSchemaType(schema) const fieldType = toFieldType(normalizedType, schemaType) @@ -140,21 +139,21 @@ const convertJsonSchemaToField = (schema: any, schemaTypeDefinitions?: SchemaTyp type: fieldType, } - if (schema?.description) - field.description = schema.description + if (schema?.description) field.description = schema.description - if (schemaType) - field.schemaType = schemaType + if (schemaType) field.schemaType = schemaType - if (Array.isArray(schema?.enum)) - field.enum = schema.enum + if (Array.isArray(schema?.enum)) field.enum = schema.enum if (fieldType === Type.object) { const properties = schema?.properties || {} - field.properties = Object.entries(properties).reduce((acc, [key, value]) => { - acc[key] = convertJsonSchemaToField(value, schemaTypeDefinitions) - return acc - }, {} as Record<string, Field>) + field.properties = Object.entries(properties).reduce( + (acc, [key, value]) => { + acc[key] = convertJsonSchemaToField(value, schemaTypeDefinitions) + return acc + }, + {} as Record<string, Field>, + ) const required = Array.isArray(schema?.required) ? schema.required.filter(Boolean) : undefined field.required = required && required.length > 0 ? required : undefined @@ -176,13 +175,14 @@ const convertJsonSchemaToField = (schema: any, schemaTypeDefinitions?: SchemaTyp return field } -const buildOutputVars = (schema: Record<string, any>, schemaTypeDefinitions?: SchemaTypeDefinition[]): Var[] => { - if (!schema || typeof schema !== 'object') - return [] +const buildOutputVars = ( + schema: Record<string, any>, + schemaTypeDefinitions?: SchemaTypeDefinition[], +): Var[] => { + if (!schema || typeof schema !== 'object') return [] const properties = schema.properties as Record<string, any> | undefined - if (!properties) - return [] + if (!properties) return [] return Object.entries(properties).map(([name, propertySchema]) => { const { type, schemaType } = resolveVarType(propertySchema, schemaTypeDefinitions) @@ -197,13 +197,18 @@ const buildOutputVars = (schema: Record<string, any>, schemaTypeDefinitions?: Sc if (normalizedType === 'object') { const childProperties = propertySchema?.properties - ? Object.entries(propertySchema.properties).reduce((acc, [key, value]) => { - acc[key] = convertJsonSchemaToField(value, schemaTypeDefinitions) - return acc - }, {} as Record<string, Field>) + ? Object.entries(propertySchema.properties).reduce( + (acc, [key, value]) => { + acc[key] = convertJsonSchemaToField(value, schemaTypeDefinitions) + return acc + }, + {} as Record<string, Field>, + ) : {} - const required = Array.isArray(propertySchema?.required) ? propertySchema.required.filter(Boolean) : undefined + const required = Array.isArray(propertySchema?.required) + ? propertySchema.required.filter(Boolean) + : undefined varItem.children = { schema: { @@ -235,56 +240,68 @@ const nodeDefault: NodeDefault<PluginTriggerNodeType> = { // event_type: '', config: {}, }, - checkValid(payload: PluginTriggerNodeType, t: TFunction<'workflow'>, moreDataForCheckValid: { - triggerInputsSchema?: Array<{ - variable: string - label: string - required?: boolean - }> - isReadyForCheckValid?: boolean - } = {}) { + checkValid( + payload: PluginTriggerNodeType, + t: TFunction<'workflow'>, + moreDataForCheckValid: { + triggerInputsSchema?: Array<{ + variable: string + label: string + required?: boolean + }> + isReadyForCheckValid?: boolean + } = {}, + ) { let errorMessage = '' if (!payload.subscription_id) - errorMessage = t($ => $['nodes.triggerPlugin.subscriptionRequired'], { ns: 'workflow' }) + errorMessage = t(($) => $['nodes.triggerPlugin.subscriptionRequired'], { ns: 'workflow' }) - const { - triggerInputsSchema = [], - isReadyForCheckValid = true, - } = moreDataForCheckValid || {} + const { triggerInputsSchema = [], isReadyForCheckValid = true } = moreDataForCheckValid || {} if (!errorMessage && isReadyForCheckValid) { - triggerInputsSchema.filter(field => field.required).forEach((field) => { - if (errorMessage) - return - - const rawParam = payload.event_parameters?.[field.variable] - ?? (payload.config as Record<string, any> | undefined)?.[field.variable] - if (!rawParam) { - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: field.label }) - return - } - - const targetParam = typeof rawParam === 'object' && rawParam !== null && 'type' in rawParam - ? rawParam as { type: VarKindType, value: any } - : { type: VarKindType.constant, value: rawParam } - - const { type, value } = targetParam - if (type === VarKindType.variable) { - if (!value || (Array.isArray(value) && value.length === 0)) - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: field.label }) - } - else { - if ( - value === undefined - || value === null - || value === '' - || (Array.isArray(value) && value.length === 0) - ) { - errorMessage = t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: field.label }) + triggerInputsSchema + .filter((field) => field.required) + .forEach((field) => { + if (errorMessage) return + + const rawParam = + payload.event_parameters?.[field.variable] ?? + (payload.config as Record<string, any> | undefined)?.[field.variable] + if (!rawParam) { + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: field.label, + }) + return + } + + const targetParam = + typeof rawParam === 'object' && rawParam !== null && 'type' in rawParam + ? (rawParam as { type: VarKindType; value: any }) + : { type: VarKindType.constant, value: rawParam } + + const { type, value } = targetParam + if (type === VarKindType.variable) { + if (!value || (Array.isArray(value) && value.length === 0)) + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: field.label, + }) + } else { + if ( + value === undefined || + value === null || + value === '' || + (Array.isArray(value) && value.length === 0) + ) { + errorMessage = t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: field.label, + }) + } } - } - }) + }) } return { @@ -292,7 +309,12 @@ const nodeDefault: NodeDefault<PluginTriggerNodeType> = { errorMessage, } }, - getOutputVars(payload, _allPluginInfoList, _ragVars, { schemaTypeDefinitions } = { schemaTypeDefinitions: [] }) { + getOutputVars( + payload, + _allPluginInfoList, + _ragVars, + { schemaTypeDefinitions } = { schemaTypeDefinitions: [] }, + ) { const schema = payload.output_schema || {} return buildOutputVars(schema, schemaTypeDefinitions) }, diff --git a/web/app/components/workflow/nodes/trigger-plugin/node.tsx b/web/app/components/workflow/nodes/trigger-plugin/node.tsx index 9a1d32f16a3aff..d87b0f4645347c 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/node.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/node.tsx @@ -10,27 +10,22 @@ import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/compo import useConfig from './use-config' const formatConfigValue = (rawValue: any): string => { - if (rawValue === null || rawValue === undefined) - return '' + if (rawValue === null || rawValue === undefined) return '' if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') return String(rawValue) - if (Array.isArray(rawValue)) - return rawValue.join('.') + if (Array.isArray(rawValue)) return rawValue.join('.') if (typeof rawValue === 'object') { const { value } = rawValue as { value?: any } - if (value === null || value === undefined) - return '' + if (value === null || value === undefined) return '' if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value) - if (Array.isArray(value)) - return value.join('.') + if (Array.isArray(value)) return value.join('.') try { return JSON.stringify(value) - } - catch { + } catch { return '' } } @@ -38,27 +33,18 @@ const formatConfigValue = (rawValue: any): string => { return '' } -const Node: FC<NodeProps<PluginTriggerNodeType>> = ({ - id, - data, -}) => { +const Node: FC<NodeProps<PluginTriggerNodeType>> = ({ id, data }) => { const { subscriptions } = useConfig(id, data) const { config = {}, subscription_id } = data const configKeys = Object.keys(config) - const { - isChecking, - isMissing, - uniqueIdentifier, - canInstall, - onInstallSuccess, - shouldDim, - } = useNodePluginInstallation(data) + const { isChecking, isMissing, uniqueIdentifier, canInstall, onInstallSuccess, shouldDim } = + useNodePluginInstallation(data) const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier const { t } = useTranslation() const isValidSubscription = useMemo(() => { - return subscription_id && subscriptions?.some(sub => sub.id === subscription_id) + return subscription_id && subscriptions?.some((sub) => sub.id === subscription_id) }, [subscription_id, subscriptions]) return ( @@ -67,11 +53,9 @@ const Node: FC<NodeProps<PluginTriggerNodeType>> = ({ <div className="pointer-events-auto absolute -top-8 right-3 z-40"> <InstallPluginButton size="small" - extraIdentifiers={[ - data.plugin_id, - data.provider_id, - data.provider_name, - ].filter(Boolean) as string[]} + extraIdentifiers={ + [data.plugin_id, data.provider_id, data.provider_name].filter(Boolean) as string[] + } className="font-medium! text-text-accent!" uniqueIdentifier={uniqueIdentifier!} onSuccess={onInstallSuccess} @@ -79,31 +63,36 @@ const Node: FC<NodeProps<PluginTriggerNodeType>> = ({ </div> )} <div className="space-y-0.5" aria-disabled={shouldDim}> - {!isValidSubscription && <NodeStatus status={NodeStatusEnum.warning} message={t($ => $['node.status.warning'], { ns: 'pluginTrigger' })} />} - {isValidSubscription && configKeys.map((key, index) => ( - <div - key={index} - className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary" - > - <div - title={key} - className="max-w-25 shrink-0 truncate text-xs font-medium text-text-tertiary uppercase" - > - {key} - </div> + {!isValidSubscription && ( + <NodeStatus + status={NodeStatusEnum.warning} + message={t(($) => $['node.status.warning'], { ns: 'pluginTrigger' })} + /> + )} + {isValidSubscription && + configKeys.map((key, index) => ( <div - title={formatConfigValue(config[key])} - className="w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary" + key={index} + className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary" > - {(() => { - const displayValue = formatConfigValue(config[key]) - if (displayValue.includes('secret')) - return '********' - return displayValue - })()} + <div + title={key} + className="max-w-25 shrink-0 truncate text-xs font-medium text-text-tertiary uppercase" + > + {key} + </div> + <div + title={formatConfigValue(config[key])} + className="w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary" + > + {(() => { + const displayValue = formatConfigValue(config[key]) + if (displayValue.includes('secret')) return '********' + return displayValue + })()} + </div> </div> - </div> - ))} + ))} </div> </div> ) diff --git a/web/app/components/workflow/nodes/trigger-plugin/panel.tsx b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx index a74639faf56651..337022bcec5edf 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx @@ -10,10 +10,7 @@ import { Type } from '../llm/types' import TriggerForm from './components/trigger-form' import useConfig from './use-config' -const Panel: FC<NodePanelProps<PluginTriggerNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<PluginTriggerNodeType>> = ({ id, data }) => { const { readOnly, triggerParameterSchema, @@ -28,11 +25,13 @@ const Panel: FC<NodePanelProps<PluginTriggerNodeType>> = ({ const disableVariableInsertion = data.type === BlockEnum.TriggerPlugin // Convert output schema to VarItem format - const outputVars = Object.entries(outputSchema.properties || {}).map(([name, schema]: [string, any]) => ({ - name, - type: schema.type || 'string', - description: schema.description || '', - })) + const outputVars = Object.entries(outputSchema.properties || {}).map( + ([name, schema]: [string, any]) => ({ + name, + type: schema.type || 'string', + description: schema.description || '', + }), + ) return ( <div className="mt-2"> @@ -58,7 +57,7 @@ const Panel: FC<NodePanelProps<PluginTriggerNodeType>> = ({ {/* Output Variables - Always show */} <OutputVars> <> - {outputVars.map(varItem => ( + {outputVars.map((varItem) => ( <VarItem key={varItem.name} name={varItem.name} @@ -69,22 +68,20 @@ const Panel: FC<NodePanelProps<PluginTriggerNodeType>> = ({ ))} {Object.entries(outputSchema.properties || {}).map(([name, schema]: [string, any]) => ( <div key={name}> - {schema.type === 'object' - ? ( - <StructureOutputItem - rootClassName="code-sm-semibold text-text-secondary" - payload={{ - schema: { - type: Type.object, - properties: { - [name]: schema, - }, - additionalProperties: false, - }, - }} - /> - ) - : null} + {schema.type === 'object' ? ( + <StructureOutputItem + rootClassName="code-sm-semibold text-text-secondary" + payload={{ + schema: { + type: Type.object, + properties: { + [name]: schema, + }, + additionalProperties: false, + }, + }} + /> + ) : null} </div> ))} </> diff --git a/web/app/components/workflow/nodes/trigger-plugin/use-check-params.ts b/web/app/components/workflow/nodes/trigger-plugin/use-check-params.ts index 7c23378498f7c8..f27f5844d79583 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/use-check-params.ts +++ b/web/app/components/workflow/nodes/trigger-plugin/use-check-params.ts @@ -9,9 +9,7 @@ type Params = { payload: PluginTriggerNodeType } -const useGetDataForCheckMore = ({ - payload, -}: Params) => { +const useGetDataForCheckMore = ({ payload }: Params) => { const { data: triggerPlugins } = useAllTriggerPlugins() const language = useGetLanguage() diff --git a/web/app/components/workflow/nodes/trigger-plugin/use-config.ts b/web/app/components/workflow/nodes/trigger-plugin/use-config.ts index a746f1a4102a88..5627e7b1b3054b 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-plugin/use-config.ts @@ -10,10 +10,7 @@ import { } from '@/app/components/tools/utils/to-form-schema' import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' -import { - useAllTriggerPlugins, - useTriggerSubscriptions, -} from '@/service/use-triggers' +import { useAllTriggerPlugins, useTriggerSubscriptions } from '@/service/use-triggers' import { VarKindType } from '../_base/types' const normalizeEventParameters = ( @@ -24,24 +21,16 @@ const normalizeEventParameters = ( return {} as PluginTriggerVarInputs return Object.entries(params).reduce((acc, [key, entry]) => { - if (!entry && entry !== 0 && entry !== false) - return acc + if (!entry && entry !== 0 && entry !== false) return acc - if ( - typeof entry === 'object' - && !Array.isArray(entry) - && 'type' in entry - && 'value' in entry - ) { + if (typeof entry === 'object' && !Array.isArray(entry) && 'type' in entry && 'value' in entry) { const normalizedEntry = { ...(entry as PluginTriggerVarInputs[string]) } - if (normalizedEntry.type === VarKindType.mixed) - normalizedEntry.type = VarKindType.constant + if (normalizedEntry.type === VarKindType.mixed) normalizedEntry.type = VarKindType.constant acc[key] = normalizedEntry return acc } - if (!allowScalars) - return acc + if (!allowScalars) return acc if (typeof entry === 'string') { acc[key] = { @@ -59,7 +48,7 @@ const normalizeEventParameters = ( return acc } - if (Array.isArray(entry) && entry.every(item => typeof item === 'string')) { + if (Array.isArray(entry) && entry.every((item) => typeof item === 'string')) { acc[key] = { type: VarKindType.variable, value: entry, @@ -74,10 +63,7 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() const { data: triggerPlugins = [] } = useAllTriggerPlugins() - const { inputs, setInputs: doSetInputs } = useNodeCrud<PluginTriggerNodeType>( - id, - payload, - ) + const { inputs, setInputs: doSetInputs } = useNodeCrud<PluginTriggerNodeType>(id, payload) const { provider_id, @@ -99,29 +85,26 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => { const currentProvider = useMemo<TriggerWithProvider | undefined>(() => { return triggerPlugins.find( - provider => - provider.name === provider_name - || provider.id === provider_id - || (provider_id && provider.plugin_id === provider_id), + (provider) => + provider.name === provider_name || + provider.id === provider_id || + (provider_id && provider.plugin_id === provider_id), ) }, [triggerPlugins, provider_name, provider_id]) const { data: subscriptions = [] } = useTriggerSubscriptions(provider_id || '') const subscriptionSelected = useMemo(() => { - return subscriptions?.find(s => s.id === subscription_id) + return subscriptions?.find((s) => s.id === subscription_id) }, [subscriptions, subscription_id]) const currentEvent = useMemo<Event | undefined>(() => { - return currentProvider?.events.find( - event => event.name === event_name, - ) + return currentProvider?.events.find((event) => event.name === event_name) }, [currentProvider, event_name]) // Dynamic trigger parameters (from specific trigger.parameters) const triggerSpecificParameterSchema = useMemo(() => { - if (!currentEvent) - return [] + if (!currentEvent) return [] return toolParametersToFormSchemas(currentEvent.parameters) }, [currentEvent]) @@ -137,38 +120,31 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => { }, [triggerSpecificParameterSchema]) const triggerParameterValue = useMemo(() => { - if (!triggerParameterSchema.length) - return {} as PluginTriggerVarInputs + if (!triggerParameterSchema.length) return {} as PluginTriggerVarInputs const hasStoredParameters = event_parameters && Object.keys(event_parameters).length > 0 const baseValue = hasStoredParameters ? event_parameters : legacy_config_parameters - const configuredValue = getConfiguredValue(baseValue, triggerParameterSchema) as PluginTriggerVarInputs + const configuredValue = getConfiguredValue( + baseValue, + triggerParameterSchema, + ) as PluginTriggerVarInputs return normalizeEventParameters(configuredValue) }, [triggerParameterSchema, event_parameters, legacy_config_parameters]) useEffect(() => { - if (!triggerParameterSchema.length) - return + if (!triggerParameterSchema.length) return - if (event_parameters && Object.keys(event_parameters).length > 0) - return + if (event_parameters && Object.keys(event_parameters).length > 0) return - if (!triggerParameterValue || Object.keys(triggerParameterValue).length === 0) - return + if (!triggerParameterValue || Object.keys(triggerParameterValue).length === 0) return const newInputs = produce(inputs, (draft) => { draft.event_parameters = triggerParameterValue draft.config = triggerParameterValue }) doSetInputs(newInputs) - }, [ - doSetInputs, - event_parameters, - inputs, - triggerParameterSchema, - triggerParameterValue, - ]) + }, [doSetInputs, event_parameters, inputs, triggerParameterSchema, triggerParameterValue]) const setTriggerParameterValue = useCallback( (value: PluginTriggerVarInputs) => { @@ -209,9 +185,7 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => { // Check if trigger has complex output structure const hasObjectOutput = useMemo(() => { const properties = outputSchema.properties || {} - return Object.values(properties).some( - (prop: any) => prop.type === 'object', - ) + return Object.values(properties).some((prop: any) => prop.type === 'object') }, [outputSchema]) return { diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/__tests__/node.spec.tsx index 111f5437076de4..ba16bdbb5c7aaf 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/node.spec.tsx @@ -5,7 +5,9 @@ import { BlockEnum } from '@/app/components/workflow/types' import Node from '../node' import { getNextExecutionTime } from '../utils/execution-time-calculator' -const createNodeData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({ +const createNodeData = ( + overrides: Partial<ScheduleTriggerNodeType> = {}, +): ScheduleTriggerNodeType => ({ title: 'Schedule Trigger', desc: '', type: BlockEnum.TriggerSchedule, @@ -30,15 +32,20 @@ describe('TriggerScheduleNode', () => { renderNodeComponent(Node, data) - expect(screen.getByText('workflow.nodes.triggerSchedule.nextExecutionTime')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.triggerSchedule.nextExecutionTime'), + ).toBeInTheDocument() expect(screen.getByText(getNextExecutionTime(data))).toBeInTheDocument() }) it('should render the placeholder when cron mode has an invalid expression', () => { - renderNodeComponent(Node, createNodeData({ - mode: 'cron', - cron_expression: 'invalid cron', - })) + renderNodeComponent( + Node, + createNodeData({ + mode: 'cron', + cron_expression: 'invalid cron', + }), + ) expect(screen.getByText('--')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx index 32becc265a4802..2a876382cace67 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx @@ -91,9 +91,8 @@ const panelProps: PanelProps = { runResult: null, } -const renderPanel = (id: string, data: ScheduleTriggerNodeType) => ( +const renderPanel = (id: string, data: ScheduleTriggerNodeType) => render(<Panel id={id} data={data} panelProps={panelProps} />) -) describe('TriggerSchedulePanel', () => { const setInputs = vi.fn() @@ -229,7 +228,11 @@ describe('TriggerSchedulePanel', () => { mockUseConfig.mockReturnValueOnce({ readOnly: false, - inputs: createData({ mode: 'cron', frequency: undefined, cron_expression: undefined as any }), + inputs: createData({ + mode: 'cron', + frequency: undefined, + cron_expression: undefined as any, + }), setInputs, handleModeChange, handleFrequencyChange, @@ -239,7 +242,19 @@ describe('TriggerSchedulePanel', () => { handleOnMinuteChange, }) - rerender(<Panel id="node-7" data={createData({ mode: 'cron', frequency: undefined, cron_expression: undefined as any }) as any} panelProps={panelProps} />) + rerender( + <Panel + id="node-7" + data={ + createData({ + mode: 'cron', + frequency: undefined, + cron_expression: undefined as any, + }) as any + } + panelProps={panelProps} + />, + ) expect(screen.getByRole('textbox')).toHaveValue('') }) diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts index 08ea8d7720e7cc..b94b11b12a9216 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts @@ -52,30 +52,47 @@ describe('trigger-schedule/use-config', () => { }) it('hydrates defaults for missing mode, frequency, timezone, and visual config', () => { - renderHook(() => useConfig('schedule-node', createData({ - mode: undefined as never, - frequency: undefined, - timezone: undefined, - visual_config: undefined, - })), { wrapper: createAccountProfileQueryWrapper() }) + renderHook( + () => + useConfig( + 'schedule-node', + createData({ + mode: undefined as never, + frequency: undefined, + timezone: undefined, + visual_config: undefined, + }), + ), + { wrapper: createAccountProfileQueryWrapper() }, + ) - expect(mockUseNodeCrud).toHaveBeenCalledWith('schedule-node', expect.objectContaining({ - mode: 'visual', - frequency: 'daily', - timezone: 'Asia/Shanghai', - visual_config: expect.objectContaining({ - time: '12:00 AM', - weekdays: ['sun'], - on_minute: 0, - monthly_days: [1], + expect(mockUseNodeCrud).toHaveBeenCalledWith( + 'schedule-node', + expect.objectContaining({ + mode: 'visual', + frequency: 'daily', + timezone: 'Asia/Shanghai', + visual_config: expect.objectContaining({ + time: '12:00 AM', + weekdays: ['sun'], + on_minute: 0, + monthly_days: [1], + }), }), - })) + ) }) it('updates visual mode configuration and clears cron expression when needed', () => { - const { result } = renderHook(() => useConfig('schedule-node', createData({ - cron_expression: '0 0 * * *', - })), { wrapper: createAccountProfileQueryWrapper() }) + const { result } = renderHook( + () => + useConfig( + 'schedule-node', + createData({ + cron_expression: '0 0 * * *', + }), + ), + { wrapper: createAccountProfileQueryWrapper() }, + ) result.current.handleModeChange('cron') result.current.handleFrequencyChange('hourly') @@ -84,35 +101,47 @@ describe('trigger-schedule/use-config', () => { result.current.handleOnMinuteChange(45) expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ mode: 'cron' })) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - frequency: 'hourly', - cron_expression: undefined, - visual_config: expect.objectContaining({ on_minute: 15 }), - })) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - visual_config: expect.objectContaining({ weekdays: ['tue', 'thu'] }), - cron_expression: undefined, - })) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - visual_config: expect.objectContaining({ time: '08:15 AM' }), - cron_expression: undefined, - })) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - visual_config: expect.objectContaining({ on_minute: 45 }), - cron_expression: undefined, - })) + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + frequency: 'hourly', + cron_expression: undefined, + visual_config: expect.objectContaining({ on_minute: 15 }), + }), + ) + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + visual_config: expect.objectContaining({ weekdays: ['tue', 'thu'] }), + cron_expression: undefined, + }), + ) + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + visual_config: expect.objectContaining({ time: '08:15 AM' }), + cron_expression: undefined, + }), + ) + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + visual_config: expect.objectContaining({ on_minute: 45 }), + cron_expression: undefined, + }), + ) }) it('switches to raw cron mode and clears visual schedule fields', () => { - const { result } = renderHook(() => useConfig('schedule-node', createData()), { wrapper: createAccountProfileQueryWrapper() }) + const { result } = renderHook(() => useConfig('schedule-node', createData()), { + wrapper: createAccountProfileQueryWrapper(), + }) result.current.handleCronExpressionChange('*/15 * * * *') expect(result.current.readOnly).toBe(false) - expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({ - cron_expression: '*/15 * * * *', - frequency: undefined, - visual_config: undefined, - })) + expect(setInputs).toHaveBeenCalledWith( + expect.objectContaining({ + cron_expression: '*/15 * * * *', + frequency: undefined, + visual_config: undefined, + }), + ) }) }) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/frequency-selector.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/frequency-selector.spec.tsx index ff47a18430934f..04448d97e181af 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/frequency-selector.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/frequency-selector.spec.tsx @@ -7,12 +7,7 @@ describe('trigger-schedule/frequency-selector', () => { const user = userEvent.setup() const onChange = vi.fn() - render( - <FrequencySelector - frequency="daily" - onChange={onChange} - />, - ) + render(<FrequencySelector frequency="daily" onChange={onChange} />) const trigger = screen.getByRole('combobox') await user.click(trigger) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/integration.spec.tsx index 26609885d272e6..7956ef0f767ac1 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/integration.spec.tsx @@ -35,12 +35,7 @@ describe('trigger-schedule components', () => { it('should select a new frequency from the dropdown options', async () => { const user = userEvent.setup() const onChange = vi.fn() - render( - <FrequencySelector - frequency="daily" - onChange={onChange} - />, - ) + render(<FrequencySelector frequency="daily" onChange={onChange} />) const trigger = screen.getByRole('combobox') await user.click(trigger) @@ -119,16 +114,24 @@ describe('trigger-schedule components', () => { it('should render the upcoming execution times when the schedule is valid', () => { render(<NextExecutionTimes data={createData()} />) - expect(screen.getByText('workflow.nodes.triggerSchedule.nextExecutionTimes')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.triggerSchedule.nextExecutionTimes'), + ).toBeInTheDocument() expect(screen.getAllByText(/^\d{2}$/).length).toBeGreaterThan(0) }) it('should hide upcoming execution times when frequency is missing or cron is invalid', () => { - const { rerender, container } = render(<NextExecutionTimes data={createData({ frequency: undefined }) as any} />) + const { rerender, container } = render( + <NextExecutionTimes data={createData({ frequency: undefined }) as any} />, + ) expect(container).toBeEmptyDOMElement() - rerender(<NextExecutionTimes data={createData({ mode: 'cron', cron_expression: 'bad cron' }) as any} />) + rerender( + <NextExecutionTimes + data={createData({ mode: 'cron', cron_expression: 'bad cron' }) as any} + />, + ) expect(container).toBeEmptyDOMElement() }) }) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/next-execution-times.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/next-execution-times.spec.tsx index 42251fb83ff643..b8f10730188522 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/next-execution-times.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/__tests__/next-execution-times.spec.tsx @@ -23,16 +23,22 @@ describe('trigger-schedule/next-execution-times', () => { it('renders the upcoming execution times when the schedule is valid', () => { render(<NextExecutionTimes data={createData()} />) - expect(screen.getByText('workflow.nodes.triggerSchedule.nextExecutionTimes')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.triggerSchedule.nextExecutionTimes'), + ).toBeInTheDocument() expect(screen.getAllByText(/^\d{2}$/).length).toBeGreaterThan(0) }) it('hides upcoming execution times when frequency is missing or cron is invalid', () => { - const { rerender, container } = render(<NextExecutionTimes data={createData({ frequency: undefined })} />) + const { rerender, container } = render( + <NextExecutionTimes data={createData({ frequency: undefined })} />, + ) expect(container).toBeEmptyDOMElement() - rerender(<NextExecutionTimes data={createData({ mode: 'cron', cron_expression: 'bad cron' })} />) + rerender( + <NextExecutionTimes data={createData({ mode: 'cron', cron_expression: 'bad cron' })} />, + ) expect(container).toBeEmptyDOMElement() }) }) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx index 4d6ed2fa6be9cc..9daac325b2634e 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx @@ -24,21 +24,32 @@ type FrequencySelectorProps = { const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => { const { t } = useTranslation() - const groupLabel = t($ => $['nodes.triggerSchedule.frequency.label'], { ns: 'workflow' }) - const fieldLabel = t($ => $['nodes.triggerSchedule.frequencyLabel'], { ns: 'workflow' }) + const groupLabel = t(($) => $['nodes.triggerSchedule.frequency.label'], { ns: 'workflow' }) + const fieldLabel = t(($) => $['nodes.triggerSchedule.frequencyLabel'], { ns: 'workflow' }) const frequencies: FrequencyOption[] = [ - { value: 'hourly', name: t($ => $['nodes.triggerSchedule.frequency.hourly'], { ns: 'workflow' }) }, - { value: 'daily', name: t($ => $['nodes.triggerSchedule.frequency.daily'], { ns: 'workflow' }) }, - { value: 'weekly', name: t($ => $['nodes.triggerSchedule.frequency.weekly'], { ns: 'workflow' }) }, - { value: 'monthly', name: t($ => $['nodes.triggerSchedule.frequency.monthly'], { ns: 'workflow' }) }, + { + value: 'hourly', + name: t(($) => $['nodes.triggerSchedule.frequency.hourly'], { ns: 'workflow' }), + }, + { + value: 'daily', + name: t(($) => $['nodes.triggerSchedule.frequency.daily'], { ns: 'workflow' }), + }, + { + value: 'weekly', + name: t(($) => $['nodes.triggerSchedule.frequency.weekly'], { ns: 'workflow' }), + }, + { + value: 'monthly', + name: t(($) => $['nodes.triggerSchedule.frequency.monthly'], { ns: 'workflow' }), + }, ] - const selectedFrequency = frequencies.find(item => item.value === frequency) + const selectedFrequency = frequencies.find((item) => item.value === frequency) const handleFrequencyChange = (value: string | null) => { - const selected = frequencies.find(item => item.value === value) - if (selected) - onChange(selected.value) + const selected = frequencies.find((item) => item.value === value) + if (selected) onChange(selected.value) } return ( @@ -49,12 +60,13 @@ const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => { > <SelectLabel className="sr-only">{fieldLabel}</SelectLabel> <SelectTrigger className="w-full py-2"> - {selectedFrequency?.name ?? t($ => $['nodes.triggerSchedule.selectFrequency'], { ns: 'workflow' })} + {selectedFrequency?.name ?? + t(($) => $['nodes.triggerSchedule.selectFrequency'], { ns: 'workflow' })} </SelectTrigger> <SelectContent> <SelectGroup> <SelectGroupLabel>{groupLabel}</SelectGroupLabel> - {frequencies.map(item => ( + {frequencies.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx index 2aee868ecb7250..3ecebf16d7bd7e 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx @@ -16,9 +16,10 @@ const ModeToggle = ({ mode, onChange }: ModeToggleProps) => { onChange(newMode) } - const currentText = mode === 'visual' - ? t($ => $['nodes.triggerSchedule.useCronExpression'], { ns: 'workflow' }) - : t($ => $['nodes.triggerSchedule.useVisualPicker'], { ns: 'workflow' }) + const currentText = + mode === 'visual' + ? t(($) => $['nodes.triggerSchedule.useCronExpression'], { ns: 'workflow' }) + : t(($) => $['nodes.triggerSchedule.useVisualPicker'], { ns: 'workflow' }) const currentIcon = mode === 'visual' ? Asterisk : CalendarCheckLine diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx index be04d481172dac..c56da41091706e 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx @@ -12,9 +12,7 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp const handleDayClick = (day: number | 'last') => { const current = selectedDays || [] - const newSelected = current.includes(day) - ? current.filter(d => d !== day) - : [...current, day] + const newSelected = current.includes(day) ? current.filter((d) => d !== day) : [...current, day] // Ensure at least one day is selected (consistent with WeekdaySelector) onChange(newSelected.length > 0 ? newSelected : [day]) } @@ -33,58 +31,59 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp return ( <div className="space-y-2"> <label className="mb-2 block text-xs font-medium text-text-tertiary"> - {t($ => $['nodes.triggerSchedule.days'], { ns: 'workflow' })} + {t(($) => $['nodes.triggerSchedule.days'], { ns: 'workflow' })} </label> <div className="space-y-1.5"> {rows.map((row, rowIndex) => ( <div key={rowIndex} className="grid grid-cols-7 gap-1.5"> - {row.map(day => ( - day === 'last' - ? ( - <div - key={day} - className={`col-span-2 flex min-w-0 items-center rounded-lg border bg-components-option-card-option-bg text-xs transition-colors ${ - isDaySelected(day) - ? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary' - : 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary' - }`} - > - <button - type="button" - onClick={() => handleDayClick(day)} - className="min-w-0 flex-1 py-1" - > - {t($ => $['nodes.triggerSchedule.lastDay'], { ns: 'workflow' })} - </button> - <Infotip - aria-label={t($ => $['nodes.triggerSchedule.lastDayTooltip'], { ns: 'workflow' })} - className="mr-1 size-3" - iconSize="small" - > - {t($ => $['nodes.triggerSchedule.lastDayTooltip'], { ns: 'workflow' })} - </Infotip> - </div> - ) - : ( - <button - key={day} - type="button" - onClick={() => handleDayClick(day)} - className={`rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${ - isDaySelected(day) - ? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary' - : 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary' - }`} - > - {day} - </button> - ) - ))} + {row.map((day) => + day === 'last' ? ( + <div + key={day} + className={`col-span-2 flex min-w-0 items-center rounded-lg border bg-components-option-card-option-bg text-xs transition-colors ${ + isDaySelected(day) + ? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary' + : 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary' + }`} + > + <button + type="button" + onClick={() => handleDayClick(day)} + className="min-w-0 flex-1 py-1" + > + {t(($) => $['nodes.triggerSchedule.lastDay'], { ns: 'workflow' })} + </button> + <Infotip + aria-label={t(($) => $['nodes.triggerSchedule.lastDayTooltip'], { + ns: 'workflow', + })} + className="mr-1 size-3" + iconSize="small" + > + {t(($) => $['nodes.triggerSchedule.lastDayTooltip'], { ns: 'workflow' })} + </Infotip> + </div> + ) : ( + <button + key={day} + type="button" + onClick={() => handleDayClick(day)} + className={`rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${ + isDaySelected(day) + ? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary' + : 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary' + }`} + > + {day} + </button> + ), + )} {/* Fill empty cells in the last row (Last day takes 2 cols, so need 1 less) */} - {rowIndex === rows.length - 1 && Array.from({ length: 7 - row.length - 1 }, (_, i) => ( - <div key={`empty-${i}`} className="invisible"></div> - ))} + {rowIndex === rows.length - 1 && + Array.from({ length: 7 - row.length - 1 }, (_, i) => ( + <div key={`empty-${i}`} className="invisible"></div> + ))} </div> ))} </div> @@ -93,7 +92,7 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp {selectedDays?.includes(31) && ( <div className="mt-1.5 grid grid-cols-7 gap-1.5"> <div className="col-span-7 text-xs text-gray-500"> - {t($ => $['nodes.triggerSchedule.lastDayTooltip'], { ns: 'workflow' })} + {t(($) => $['nodes.triggerSchedule.lastDayTooltip'], { ns: 'workflow' })} </div> </div> )} diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx index 62eda6c6f5d4ee..5e592a212491ce 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx @@ -10,18 +10,16 @@ type NextExecutionTimesProps = { const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => { const { t } = useTranslation() - if (!data.frequency) - return null + if (!data.frequency) return null const executionTimes = getFormattedExecutionTimes(data, 5) - if (executionTimes.length === 0) - return null + if (executionTimes.length === 0) return null return ( <div className="space-y-2"> <label className="block text-xs font-medium text-gray-500"> - {t($ => $['nodes.triggerSchedule.nextExecutionTimes'], { ns: 'workflow' })} + {t(($) => $['nodes.triggerSchedule.nextExecutionTimes'], { ns: 'workflow' })} </label> <div className="flex min-h-[80px] flex-col rounded-xl bg-components-input-bg-normal py-2"> {executionTimes.map((time, index) => ( diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx index a612e4deda2108..4fa5298239ab65 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx @@ -13,7 +13,7 @@ const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => { return ( <div> <label className="mb-2 block text-xs font-medium text-gray-500"> - {t($ => $['nodes.triggerSchedule.onMinute'], { ns: 'workflow' })} + {t(($) => $['nodes.triggerSchedule.onMinute'], { ns: 'workflow' })} </label> <div className="relative flex h-8 items-center rounded-lg bg-components-input-bg-normal"> <div className="flex h-full w-12 shrink-0 items-center justify-center text-[13px] text-components-input-text-filled"> @@ -28,7 +28,7 @@ const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => { max={59} step={1} onValueChange={onChange} - aria-label={t($ => $['nodes.triggerSchedule.onMinute'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.triggerSchedule.onMinute'], { ns: 'workflow' })} /> </div> </div> diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx index ecaecebfc89ba2..3e6911c18f592d 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx @@ -22,7 +22,7 @@ const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => { const handleDaySelect = (dayKey: string) => { const current = selectedDays || [] const newSelected = current.includes(dayKey) - ? current.filter(d => d !== dayKey) + ? current.filter((d) => d !== dayKey) : [...current, dayKey] onChange(newSelected.length > 0 ? newSelected : [dayKey]) } @@ -32,10 +32,10 @@ const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => { return ( <div className="space-y-2"> <label className="mb-2 block text-xs font-medium text-text-tertiary"> - {t($ => $['nodes.triggerSchedule.weekdays'], { ns: 'workflow' })} + {t(($) => $['nodes.triggerSchedule.weekdays'], { ns: 'workflow' })} </label> <div className="flex gap-1.5"> - {weekdays.map(day => ( + {weekdays.map((day) => ( <button key={day.key} type="button" diff --git a/web/app/components/workflow/nodes/trigger-schedule/default.ts b/web/app/components/workflow/nodes/trigger-schedule/default.ts index 950455744b05e6..847d8afec19e92 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/default.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/default.ts @@ -9,22 +9,25 @@ import { getNextExecutionTimes } from './utils/execution-time-calculator' const isValidTimeFormat = (time: string): boolean => { const timeRegex = /^(0?\d|1[0-2]):[0-5]\d (AM|PM)$/ - if (!timeRegex.test(time)) - return false + if (!timeRegex.test(time)) return false const [timePart, period] = time.split(' ') const [hour, minute] = timePart!.split(':') const hourNum = Number.parseInt(hour!, 10) const minuteNum = Number.parseInt(minute!, 10) - return hourNum >= 1 && hourNum <= 12 - && minuteNum >= 0 && minuteNum <= 59 - && ['AM', 'PM'].includes(period!) + return ( + hourNum >= 1 && + hourNum <= 12 && + minuteNum >= 0 && + minuteNum <= 59 && + ['AM', 'PM'].includes(period!) + ) } const validateHourlyConfig = (config: any, t: TFunction<'workflow'>): string => { if (config.on_minute === undefined || config.on_minute < 0 || config.on_minute > 59) - return t($ => $['nodes.triggerSchedule.invalidOnMinute'], { ns: 'workflow' }) + return t(($) => $['nodes.triggerSchedule.invalidOnMinute'], { ns: 'workflow' }) return '' } @@ -33,28 +36,33 @@ const validateDailyConfig = (config: any, t: TFunction<'workflow'>): string => { const i18nPrefix = 'errorMsg' if (!config.time) - return t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.triggerSchedule.time'], { ns: 'workflow' }) }) + return t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.triggerSchedule.time'], { ns: 'workflow' }), + }) if (!isValidTimeFormat(config.time)) - return t($ => $['nodes.triggerSchedule.invalidTimeFormat'], { ns: 'workflow' }) + return t(($) => $['nodes.triggerSchedule.invalidTimeFormat'], { ns: 'workflow' }) return '' } const validateWeeklyConfig = (config: any, t: TFunction<'workflow'>): string => { const dailyError = validateDailyConfig(config, t) - if (dailyError) - return dailyError + if (dailyError) return dailyError const i18nPrefix = 'errorMsg' if (!config.weekdays || config.weekdays.length === 0) - return t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.triggerSchedule.weekdays'], { ns: 'workflow' }) }) + return t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.triggerSchedule.weekdays'], { ns: 'workflow' }), + }) const validWeekdays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] for (const day of config.weekdays) { if (!validWeekdays.includes(day)) - return t($ => $['nodes.triggerSchedule.invalidWeekday'], { ns: 'workflow', weekday: day }) + return t(($) => $['nodes.triggerSchedule.invalidWeekday'], { ns: 'workflow', weekday: day }) } return '' @@ -62,8 +70,7 @@ const validateWeeklyConfig = (config: any, t: TFunction<'workflow'>): string => const validateMonthlyConfig = (config: any, t: TFunction<'workflow'>): string => { const dailyError = validateDailyConfig(config, t) - if (dailyError) - return dailyError + if (dailyError) return dailyError const i18nPrefix = 'errorMsg' @@ -77,22 +84,31 @@ const validateMonthlyConfig = (config: any, t: TFunction<'workflow'>): string => const monthlyDays = getMonthlyDays() if (monthlyDays.length === 0) - return t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.triggerSchedule.monthlyDay'], { ns: 'workflow' }) }) + return t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.triggerSchedule.monthlyDay'], { ns: 'workflow' }), + }) for (const day of monthlyDays) { if (day !== 'last' && (typeof day !== 'number' || day < 1 || day > 31)) - return t($ => $['nodes.triggerSchedule.invalidMonthlyDay'], { ns: 'workflow' }) + return t(($) => $['nodes.triggerSchedule.invalidMonthlyDay'], { ns: 'workflow' }) } return '' } -const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: TFunction<'workflow'>): string => { +const validateVisualConfig = ( + payload: ScheduleTriggerNodeType, + t: TFunction<'workflow'>, +): string => { const i18nPrefix = 'errorMsg' const { visual_config } = payload if (!visual_config) - return t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.triggerSchedule.visualConfig'], { ns: 'workflow' }) }) + return t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.triggerSchedule.visualConfig'], { ns: 'workflow' }), + }) switch (payload.frequency) { case 'hourly': @@ -104,7 +120,7 @@ const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: TFunction<'wo case 'monthly': return validateMonthlyConfig(visual_config, t) default: - return t($ => $['nodes.triggerSchedule.invalidFrequency'], { ns: 'workflow' }) + return t(($) => $['nodes.triggerSchedule.invalidFrequency'], { ns: 'workflow' }) } } @@ -125,40 +141,51 @@ const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = { const i18nPrefix = 'errorMsg' let errorMessages = '' if (!errorMessages && !payload.mode) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.triggerSchedule.mode'], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.triggerSchedule.mode'], { ns: 'workflow' }), + }) // Validate timezone format if provided (timezone will be auto-filled by use-config.ts if undefined) if (!errorMessages && payload.timezone) { try { // eslint-disable-next-line no-new new Intl.DateTimeFormat(undefined, { timeZone: payload.timezone }) - } - catch { - errorMessages = t($ => $['nodes.triggerSchedule.invalidTimezone'], { ns: 'workflow' }) + } catch { + errorMessages = t(($) => $['nodes.triggerSchedule.invalidTimezone'], { ns: 'workflow' }) } } if (!errorMessages) { if (payload.mode === 'cron') { if (!payload.cron_expression || payload.cron_expression.trim() === '') - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.triggerSchedule.cronExpression'], { ns: 'workflow' }) }) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.triggerSchedule.cronExpression'], { ns: 'workflow' }), + }) else if (!isValidCronExpression(payload.cron_expression)) - errorMessages = t($ => $['nodes.triggerSchedule.invalidCronExpression'], { ns: 'workflow' }) - } - else if (payload.mode === 'visual') { + errorMessages = t(($) => $['nodes.triggerSchedule.invalidCronExpression'], { + ns: 'workflow', + }) + } else if (payload.mode === 'visual') { if (!payload.frequency) - errorMessages = t($ => $[`${i18nPrefix}.fieldRequired`], { ns: 'workflow', field: t($ => $['nodes.triggerSchedule.frequencyLabel'], { ns: 'workflow' }) }) - else - errorMessages = validateVisualConfig(payload, t) + errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], { + ns: 'workflow', + field: t(($) => $['nodes.triggerSchedule.frequencyLabel'], { ns: 'workflow' }), + }) + else errorMessages = validateVisualConfig(payload, t) } } if (!errorMessages) { try { const nextTimes = getNextExecutionTimes(payload, 1) if (nextTimes.length === 0) - errorMessages = t($ => $['nodes.triggerSchedule.noValidExecutionTime'], { ns: 'workflow' }) - } - catch { - errorMessages = t($ => $['nodes.triggerSchedule.executionTimeCalculationError'], { ns: 'workflow' }) + errorMessages = t(($) => $['nodes.triggerSchedule.noValidExecutionTime'], { + ns: 'workflow', + }) + } catch { + errorMessages = t(($) => $['nodes.triggerSchedule.executionTimeCalculationError'], { + ns: 'workflow', + }) } } diff --git a/web/app/components/workflow/nodes/trigger-schedule/node.tsx b/web/app/components/workflow/nodes/trigger-schedule/node.tsx index 63a8c58273ed60..133b6d577586ad 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/node.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/node.tsx @@ -7,15 +7,13 @@ import { getNextExecutionTime } from './utils/execution-time-calculator' const i18nPrefix = 'nodes.triggerSchedule' -const Node: FC<NodeProps<ScheduleTriggerNodeType>> = ({ - data, -}) => { +const Node: FC<NodeProps<ScheduleTriggerNodeType>> = ({ data }) => { const { t } = useTranslation() return ( <div className="mb-1 px-3 py-1"> <div className="mb-1 text-[10px] font-medium tracking-wide text-text-tertiary uppercase"> - {t($ => $[`${i18nPrefix}.nextExecutionTime`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.nextExecutionTime`], { ns: 'workflow' })} </div> <div className="flex h-[26px] items-center rounded-md bg-workflow-block-parma-bg px-2 text-xs text-text-secondary"> <div className="w-0 grow"> diff --git a/web/app/components/workflow/nodes/trigger-schedule/panel.tsx b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx index 4c8c51dddfca65..e12b13815a50fa 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx @@ -16,10 +16,7 @@ import useConfig from './use-config' const i18nPrefix = 'nodes.triggerSchedule' -const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { inputs, @@ -36,22 +33,16 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({ <div className="mt-2"> <div className="space-y-4 px-4 pt-2 pb-3"> <Field - title={t($ => $[`${i18nPrefix}.title`], { ns: 'workflow' })} - operations={( - <ModeToggle - mode={inputs.mode} - onChange={handleModeChange} - /> - )} + title={t(($) => $[`${i18nPrefix}.title`], { ns: 'workflow' })} + operations={<ModeToggle mode={inputs.mode} onChange={handleModeChange} />} > <div className="space-y-3"> - {inputs.mode === 'visual' && ( <div className="space-y-3"> <div className="grid grid-cols-3 gap-3"> <div> <label className="mb-2 block text-xs font-medium text-gray-500"> - {t($ => $['nodes.triggerSchedule.frequencyLabel'], { ns: 'workflow' })} + {t(($) => $['nodes.triggerSchedule.frequencyLabel'], { ns: 'workflow' })} </label> <FrequencySelector frequency={inputs.frequency || 'daily'} @@ -59,37 +50,37 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({ /> </div> <div className="col-span-2"> - {inputs.frequency === 'hourly' - ? ( - <OnMinuteSelector - value={inputs.visual_config?.on_minute} - onChange={handleOnMinuteChange} - /> - ) - : ( - <> - <label className="mb-2 block text-xs font-medium text-gray-500"> - {t($ => $['nodes.triggerSchedule.time'], { ns: 'workflow' })} - </label> - <TimePicker - notClearable={true} - timezone={inputs.timezone} - value={inputs.visual_config?.time || '12:00 AM'} - triggerFullWidth={true} - onChange={(time) => { - if (time) { - const timeString = time.format('h:mm A') - handleTimeChange(timeString) - } - }} - onClear={() => { - handleTimeChange('12:00 AM') - }} - placeholder={t($ => $['nodes.triggerSchedule.selectTime'], { ns: 'workflow' })} - showTimezone={true} - /> - </> - )} + {inputs.frequency === 'hourly' ? ( + <OnMinuteSelector + value={inputs.visual_config?.on_minute} + onChange={handleOnMinuteChange} + /> + ) : ( + <> + <label className="mb-2 block text-xs font-medium text-gray-500"> + {t(($) => $['nodes.triggerSchedule.time'], { ns: 'workflow' })} + </label> + <TimePicker + notClearable={true} + timezone={inputs.timezone} + value={inputs.visual_config?.time || '12:00 AM'} + triggerFullWidth={true} + onChange={(time) => { + if (time) { + const timeString = time.format('h:mm A') + handleTimeChange(timeString) + } + }} + onClear={() => { + handleTimeChange('12:00 AM') + }} + placeholder={t(($) => $['nodes.triggerSchedule.selectTime'], { + ns: 'workflow', + })} + showTimezone={true} + /> + </> + )} </div> </div> @@ -122,11 +113,11 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({ <div className="space-y-2"> <div> <label className="mb-2 block text-xs font-medium text-gray-500"> - {t($ => $['nodes.triggerSchedule.cronExpression'], { ns: 'workflow' })} + {t(($) => $['nodes.triggerSchedule.cronExpression'], { ns: 'workflow' })} </label> <Input value={inputs.cron_expression || ''} - onChange={e => handleCronExpressionChange(e.target.value)} + onChange={(e) => handleCronExpressionChange(e.target.value)} placeholder="0 0 * * *" className="font-mono" /> @@ -139,7 +130,6 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({ <div className="border-t border-divider-subtle"></div> <NextExecutionTimes data={inputs} /> - </div> </div> ) diff --git a/web/app/components/workflow/nodes/trigger-schedule/use-config.ts b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts index 128f87b2eddc0a..dbd8a8530011aa 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts @@ -11,7 +11,7 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => { const { data: timezone } = useQuery({ ...userProfileQueryOptions(), - select: data => data.profile.timezone ?? undefined, + select: (data) => data.profile.timezone ?? undefined, }) const frontendPayload = useMemo(() => { @@ -29,74 +29,92 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => { const { inputs, setInputs } = useNodeCrud<ScheduleTriggerNodeType>(id, frontendPayload) - const handleModeChange = useCallback((mode: ScheduleMode) => { - const newInputs = { - ...inputs, - mode, - } - setInputs(newInputs) - }, [inputs, setInputs]) + const handleModeChange = useCallback( + (mode: ScheduleMode) => { + const newInputs = { + ...inputs, + mode, + } + setInputs(newInputs) + }, + [inputs, setInputs], + ) - const handleFrequencyChange = useCallback((frequency: ScheduleFrequency) => { - const newInputs = { - ...inputs, - frequency, - visual_config: { - ...inputs.visual_config, - ...(frequency === 'hourly') && { - on_minute: inputs.visual_config?.on_minute ?? 0, + const handleFrequencyChange = useCallback( + (frequency: ScheduleFrequency) => { + const newInputs = { + ...inputs, + frequency, + visual_config: { + ...inputs.visual_config, + ...(frequency === 'hourly' && { + on_minute: inputs.visual_config?.on_minute ?? 0, + }), }, - }, - cron_expression: undefined, - } - setInputs(newInputs) - }, [inputs, setInputs]) + cron_expression: undefined, + } + setInputs(newInputs) + }, + [inputs, setInputs], + ) - const handleCronExpressionChange = useCallback((value: string) => { - const newInputs = { - ...inputs, - cron_expression: value, - frequency: undefined, - visual_config: undefined, - } - setInputs(newInputs) - }, [inputs, setInputs]) + const handleCronExpressionChange = useCallback( + (value: string) => { + const newInputs = { + ...inputs, + cron_expression: value, + frequency: undefined, + visual_config: undefined, + } + setInputs(newInputs) + }, + [inputs, setInputs], + ) - const handleWeekdaysChange = useCallback((weekdays: string[]) => { - const newInputs = { - ...inputs, - visual_config: { - ...inputs.visual_config, - weekdays, - }, - cron_expression: undefined, - } - setInputs(newInputs) - }, [inputs, setInputs]) + const handleWeekdaysChange = useCallback( + (weekdays: string[]) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + weekdays, + }, + cron_expression: undefined, + } + setInputs(newInputs) + }, + [inputs, setInputs], + ) - const handleTimeChange = useCallback((time: string) => { - const newInputs = { - ...inputs, - visual_config: { - ...inputs.visual_config, - time, - }, - cron_expression: undefined, - } - setInputs(newInputs) - }, [inputs, setInputs]) + const handleTimeChange = useCallback( + (time: string) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + time, + }, + cron_expression: undefined, + } + setInputs(newInputs) + }, + [inputs, setInputs], + ) - const handleOnMinuteChange = useCallback((on_minute: number) => { - const newInputs = { - ...inputs, - visual_config: { - ...inputs.visual_config, - on_minute, - }, - cron_expression: undefined, - } - setInputs(newInputs) - }, [inputs, setInputs]) + const handleOnMinuteChange = useCallback( + (on_minute: number) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + on_minute, + }, + cron_expression: undefined, + } + setInputs(newInputs) + }, + [inputs, setInputs], + ) return { readOnly, diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/__tests__/integration.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/__tests__/integration.spec.ts index b4f79b9b97b921..70e36d5b70e3e0 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/utils/__tests__/integration.spec.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/__tests__/integration.spec.ts @@ -14,14 +14,17 @@ describe('cron-parser + execution-time-calculator integration', () => { vi.useRealTimers() }) - const createCronData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({ - type: BlockEnum.TriggerSchedule, - title: 'test-schedule', - mode: 'cron', - frequency: 'daily', - timezone: 'UTC', - ...overrides, - } as ScheduleTriggerNodeType) + const createCronData = ( + overrides: Partial<ScheduleTriggerNodeType> = {}, + ): ScheduleTriggerNodeType => + ({ + type: BlockEnum.TriggerSchedule, + title: 'test-schedule', + mode: 'cron', + frequency: 'daily', + timezone: 'UTC', + ...overrides, + }) as ScheduleTriggerNodeType describe('backward compatibility validation', () => { it('maintains exact behavior for legacy cron expressions', () => { @@ -72,8 +75,8 @@ describe('cron-parser + execution-time-calculator integration', () => { expect(calculatorResult).toHaveLength(5) // All results should show noon (12:00) in their respective timezone - directResult.forEach(date => expect(date.getHours()).toBe(12)) - calculatorResult.forEach(date => expect(date.getHours()).toBe(12)) + directResult.forEach((date) => expect(date.getHours()).toBe(12)) + calculatorResult.forEach((date) => expect(date.getHours()).toBe(12)) // Cross-validation: results should be identical directResult.forEach((directDate, index) => { @@ -138,17 +141,13 @@ describe('cron-parser + execution-time-calculator integration', () => { expect(date.getMinutes()).toBe(minute) if (month !== undefined) { - if (Array.isArray(month)) - expect(month).toContain(date.getMonth()) - else - expect(date.getMonth()).toBe(month) + if (Array.isArray(month)) expect(month).toContain(date.getMonth()) + else expect(date.getMonth()).toBe(month) } - if (day !== undefined) - expect(date.getDate()).toBe(day) + if (day !== undefined) expect(date.getDate()).toBe(day) - if (weekday !== undefined) - expect(date.getDay()).toBe(weekday) + if (weekday !== undefined) expect(date.getDay()).toBe(weekday) } directResult.forEach(validateDate) @@ -176,12 +175,9 @@ describe('cron-parser + execution-time-calculator integration', () => { expect(date.getHours()).toBe(hour) expect(date.getMinutes()).toBe(minute) - if (weekday !== undefined) - expect(date.getDay()).toBe(weekday) - if (day !== undefined) - expect(date.getDate()).toBe(day) - if (month !== undefined) - expect(date.getMonth()).toBe(month) + if (weekday !== undefined) expect(date.getDay()).toBe(weekday) + if (day !== undefined) expect(date.getDate()).toBe(day) + if (month !== undefined) expect(date.getMonth()).toBe(month) }) }) }) @@ -228,8 +224,8 @@ describe('cron-parser + execution-time-calculator integration', () => { // Both should handle DST gracefully // During DST spring forward, 2 AM becomes 3 AM - this is correct behavior - directResult.forEach(date => expect([2, 3]).toContain(date.getHours())) - calculatorResult.forEach(date => expect([2, 3]).toContain(date.getHours())) + directResult.forEach((date) => expect([2, 3]).toContain(date.getHours())) + calculatorResult.forEach((date) => expect([2, 3]).toContain(date.getHours())) // Results should be identical directResult.forEach((directDate, index) => { diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts index 3832f449889ba1..1fae66bfec298d 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts @@ -25,14 +25,12 @@ const convertToUserTimezoneRepresentation = (utcDate: Date, timezone: string): D * @returns Array of Date objects representing the next 5 execution times */ export const parseCronExpression = (cronExpression: string, timezone: string = 'UTC'): Date[] => { - if (!cronExpression || cronExpression.trim() === '') - return [] + if (!cronExpression || cronExpression.trim() === '') return [] const parts = cronExpression.trim().split(/\s+/) // Support both 5-field format and predefined expressions - if (parts.length !== 5 && !cronExpression.startsWith('@')) - return [] + if (parts.length !== 5 && !cronExpression.startsWith('@')) return [] try { // Parse the cron expression with timezone support @@ -50,8 +48,7 @@ export const parseCronExpression = (cronExpression: string, timezone: string = ' const utcDate = cronDate.toDate() return convertToUserTimezoneRepresentation(utcDate, timezone) }) - } - catch { + } catch { // Return empty array if parsing fails return [] } @@ -64,21 +61,18 @@ export const parseCronExpression = (cronExpression: string, timezone: string = ' * @returns boolean indicating if the cron expression is valid */ export const isValidCronExpression = (cronExpression: string): boolean => { - if (!cronExpression || cronExpression.trim() === '') - return false + if (!cronExpression || cronExpression.trim() === '') return false const parts = cronExpression.trim().split(/\s+/) // Support both 5-field format and predefined expressions - if (parts.length !== 5 && !cronExpression.startsWith('@')) - return false + if (parts.length !== 5 && !cronExpression.startsWith('@')) return false try { // Use cron-parser to validate the expression CronExpressionParser.parse(cronExpression) return true - } - catch { + } catch { return false } } diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts index b94ace3ff610bb..9d8536c0352e2e 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts @@ -5,13 +5,11 @@ import { isValidCronExpression, parseCronExpression } from './cron-parser' const DEFAULT_TIMEZONE = 'UTC' const resolveTimezone = (timezone?: string): string => { - if (timezone) - return timezone + if (timezone) return timezone try { return new Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TIMEZONE - } - catch { + } catch { return DEFAULT_TIMEZONE } } @@ -31,15 +29,19 @@ const getUserTimezoneCurrentTime = (timezone?: string): Date => { } // Format date that is already in user timezone, no timezone conversion -const formatUserTimezoneDate = (date: Date, timezone: string, includeWeekday: boolean = true, includeTimezone: boolean = true): string => { +const formatUserTimezoneDate = ( + date: Date, + timezone: string, + includeWeekday: boolean = true, + includeTimezone: boolean = true, +): string => { const dateOptions: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', } - if (includeWeekday) - dateOptions.weekday = 'long' // Changed from 'short' to 'long' for full weekday name + if (includeWeekday) dateOptions.weekday = 'long' // Changed from 'short' to 'long' for full weekday name const timeOptions: Intl.DateTimeFormatOptions = { hour: 'numeric', @@ -62,8 +64,7 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb const timezone = resolveTimezone(data.timezone) if (data.mode === 'cron') { - if (!data.cron_expression || !isValidCronExpression(data.cron_expression)) - return [] + if (!data.cron_expression || !isValidCronExpression(data.cron_expression)) return [] return parseCronExpression(data.cron_expression, timezone).slice(0, count) } @@ -83,8 +84,7 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb const userCurrentTime = getUserTimezoneCurrentTime(timezone) let hour = userCurrentTime.getHours() - if (userCurrentTime.getMinutes() >= onMinute) - hour += 1 // Start from next hour if current minute has passed + if (userCurrentTime.getMinutes() >= onMinute) hour += 1 // Start from next hour if current minute has passed for (let i = 0; i < count; i++) { const execution = new Date(userToday) @@ -96,15 +96,12 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb } times.push(execution) } - } - else if (data.frequency === 'daily') { + } else if (data.frequency === 'daily') { const [time, period] = defaultTime.split(' ') const [hour, minute] = time!.split(':') let displayHour = Number.parseInt(hour!) - if (period === 'PM' && displayHour !== 12) - displayHour += 12 - if (period === 'AM' && displayHour === 12) - displayHour = 0 + if (period === 'PM' && displayHour !== 12) displayHour += 12 + if (period === 'AM' && displayHour === 12) displayHour = 0 // Check if today's configured time has already passed const todayExecution = new Date(userToday) @@ -120,18 +117,15 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb execution.setHours(displayHour, Number.parseInt(minute!), 0, 0) times.push(execution) } - } - else if (data.frequency === 'weekly') { + } else if (data.frequency === 'weekly') { const selectedDays = data.visual_config?.weekdays || ['sun'] const dayMap = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 } const [time, period] = defaultTime.split(' ') const [hour, minute] = time!.split(':') let displayHour = Number.parseInt(hour!) - if (period === 'PM' && displayHour !== 12) - displayHour += 12 - if (period === 'AM' && displayHour === 12) - displayHour = 0 + if (period === 'PM' && displayHour !== 12) displayHour += 12 + if (period === 'AM' && displayHour === 12) displayHour = 0 // Get current time completely in user timezone const userCurrentTime = getUserTimezoneCurrentTime(timezone) @@ -143,12 +137,10 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb let hasValidDays = false for (const selectedDay of selectedDays) { - if (executionCount >= count) - break + if (executionCount >= count) break const targetDay = dayMap[selectedDay as keyof typeof dayMap] - if (targetDay === undefined) - continue + if (targetDay === undefined) continue hasValidDays = true @@ -160,11 +152,10 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb todayAtTargetTime.setHours(displayHour, Number.parseInt(minute!), 0, 0) let adjustedDays = daysUntilTarget - if (daysUntilTarget === 0 && todayAtTargetTime <= userCurrentTime) - adjustedDays = 7 + if (daysUntilTarget === 0 && todayAtTargetTime <= userCurrentTime) adjustedDays = 7 const execution = new Date(userToday) - execution.setDate(userToday.getDate() + adjustedDays + (weekOffset * 7)) + execution.setDate(userToday.getDate() + adjustedDays + weekOffset * 7) execution.setHours(displayHour, Number.parseInt(minute!), 0, 0) // Only add if execution time is in the future @@ -174,14 +165,12 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb } } - if (!hasValidDays) - break + if (!hasValidDays) break weekOffset++ } times.sort((a, b) => a.getTime() - b.getTime()) - } - else if (data.frequency === 'monthly') { + } else if (data.frequency === 'monthly') { const getSelectedDays = (): (number | 'last')[] => { if (data.visual_config?.monthly_days && data.visual_config.monthly_days.length > 0) return data.visual_config.monthly_days @@ -193,10 +182,8 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb const [time, period] = defaultTime.split(' ') const [hour, minute] = time!.split(':') let displayHour = Number.parseInt(hour!) - if (period === 'PM' && displayHour !== 12) - displayHour += 12 - if (period === 'AM' && displayHour === 12) - displayHour = 0 + if (period === 'PM' && displayHour !== 12) displayHour += 12 + if (period === 'AM' && displayHour === 12) displayHour = 0 // Get current time completely in user timezone const userCurrentTime = getUserTimezoneCurrentTime(timezone) @@ -206,7 +193,11 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb while (executionCount < count) { const targetMonth = new Date(userToday.getFullYear(), userToday.getMonth() + monthOffset, 1) - const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate() + const daysInMonth = new Date( + targetMonth.getFullYear(), + targetMonth.getMonth() + 1, + 0, + ).getDate() const monthlyExecutions: Date[] = [] const processedDays = new Set<number>() @@ -216,40 +207,42 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb if (selectedDay === 'last') { targetDay = daysInMonth - } - else { + } else { const dayNumber = selectedDay as number - if (dayNumber > daysInMonth) - continue + if (dayNumber > daysInMonth) continue targetDay = dayNumber } - if (processedDays.has(targetDay)) - continue + if (processedDays.has(targetDay)) continue processedDays.add(targetDay) - const execution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute!), 0, 0) + const execution = new Date( + targetMonth.getFullYear(), + targetMonth.getMonth(), + targetDay, + displayHour, + Number.parseInt(minute!), + 0, + 0, + ) // Only add if execution time is in the future - if (execution > userCurrentTime) - monthlyExecutions.push(execution) + if (execution > userCurrentTime) monthlyExecutions.push(execution) } monthlyExecutions.sort((a, b) => a.getTime() - b.getTime()) for (const execution of monthlyExecutions) { - if (executionCount >= count) - break + if (executionCount >= count) break times.push(execution) executionCount++ } monthOffset++ } - } - else { + } else { for (let i = 0; i < count; i++) { const execution = new Date(userToday) execution.setDate(userToday.getDate() + i) @@ -260,12 +253,20 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb return times } -const formatExecutionTime = (date: Date, timezone: string | undefined, includeWeekday: boolean = true, includeTimezone: boolean = true): string => { +const formatExecutionTime = ( + date: Date, + timezone: string | undefined, + includeWeekday: boolean = true, + includeTimezone: boolean = true, +): string => { const resolvedTimezone = resolveTimezone(timezone) return formatUserTimezoneDate(date, resolvedTimezone, includeWeekday, includeTimezone) } -export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => { +export const getFormattedExecutionTimes = ( + data: ScheduleTriggerNodeType, + count: number = 5, +): string[] => { const timezone = resolveTimezone(data.timezone) const times = getNextExecutionTimes(data, count) @@ -280,15 +281,22 @@ export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => { // Return placeholder for cron mode with empty or invalid expression if (data.mode === 'cron') { - if (!data.cron_expression || !isValidCronExpression(data.cron_expression)) - return '--' + if (!data.cron_expression || !isValidCronExpression(data.cron_expression)) return '--' } // Get Date objects (not formatted strings) const times = getNextExecutionTimes(data, 1) if (times.length === 0) { const userCurrentTime = getUserTimezoneCurrentTime(timezone) - const fallbackDate = new Date(userCurrentTime.getFullYear(), userCurrentTime.getMonth(), userCurrentTime.getDate(), 12, 0, 0, 0) + const fallbackDate = new Date( + userCurrentTime.getFullYear(), + userCurrentTime.getMonth(), + userCurrentTime.getDate(), + 12, + 0, + 0, + 0, + ) const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly' return formatExecutionTime(fallbackDate, timezone, includeWeekday, false) // Node doesn't show timezone } diff --git a/web/app/components/workflow/nodes/trigger-webhook/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/__tests__/node.spec.tsx index 1585528ff0de50..10cc5859c97439 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/__tests__/node.spec.tsx @@ -4,7 +4,9 @@ import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflo import { BlockEnum } from '@/app/components/workflow/types' import Node from '../node' -const createNodeData = (overrides: Partial<WebhookTriggerNodeType> = {}): WebhookTriggerNodeType => ({ +const createNodeData = ( + overrides: Partial<WebhookTriggerNodeType> = {}, +): WebhookTriggerNodeType => ({ title: 'Webhook Trigger', desc: '', type: BlockEnum.TriggerWebhook, @@ -28,18 +30,24 @@ describe('TriggerWebhookNode', () => { // The node should expose the webhook URL and keep a clear fallback for empty data. describe('Rendering', () => { it('should render the webhook url when it exists', () => { - renderNodeComponent(Node, createNodeData({ - webhook_url: 'https://example.com/webhook', - })) + renderNodeComponent( + Node, + createNodeData({ + webhook_url: 'https://example.com/webhook', + }), + ) expect(screen.getByText('URL')).toBeInTheDocument() expect(screen.getByText('https://example.com/webhook')).toBeInTheDocument() }) it('should render the placeholder when the webhook url is empty', () => { - renderNodeComponent(Node, createNodeData({ - webhook_url: '', - })) + renderNodeComponent( + Node, + createNodeData({ + webhook_url: '', + }), + ) expect(screen.getByText('--')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx index 49485fbaf79b32..13dc0a03700946 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx @@ -39,7 +39,11 @@ vi.mock('@langgenius/dify-ui/select', async () => { }>({}) return { - Select: ({ children, disabled, onValueChange }: { + Select: ({ + children, + disabled, + onValueChange, + }: { children: React.ReactNode disabled?: boolean onValueChange?: (value: string) => void @@ -49,24 +53,37 @@ vi.mock('@langgenius/dify-ui/select', async () => { </SelectContext.Provider> ), SelectLabel: () => null, - SelectTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => { + SelectTrigger: ({ children, className }: { children: React.ReactNode; className?: string }) => { const context = React.useContext(SelectContext) return ( <div> - <button data-testid="select-trigger" type="button" disabled={context.disabled} className={className}> + <button + data-testid="select-trigger" + type="button" + disabled={context.disabled} + className={className} + > {children} </button> - <button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}> + <button + data-testid="select-empty" + type="button" + onClick={() => context.onValueChange?.('')} + > empty select value </button> </div> ) }, SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => { const context = React.useContext(SelectContext) return ( - <button data-testid={`select-${value}`} type="button" onClick={() => context.onValueChange?.(value)}> + <button + data-testid={`select-${value}`} + type="button" + onClick={() => context.onValueChange?.(value)} + > {children} </button> ) @@ -87,16 +104,26 @@ vi.mock('copy-to-clipboard', () => ({ })) vi.mock('@/app/components/base/input-with-copy', () => ({ - default: ({ value, placeholder, onCopy }: { value: string, placeholder: string, onCopy: () => void }) => ( + default: ({ + value, + placeholder, + onCopy, + }: { + value: string + placeholder: string + onCopy: () => void + }) => ( <div> <input value={value} placeholder={placeholder} readOnly /> - <button data-testid="copy-input" type="button" onClick={onCopy}>Copy</button> + <button data-testid="copy-input" type="button" onClick={onCopy}> + Copy + </button> </div> ), })) vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ - default: ({ title, children }: { title: string, children: React.ReactNode }) => ( + default: ({ title, children }: { title: string; children: React.ReactNode }) => ( <div> <span>{title}</span> {children} @@ -105,9 +132,19 @@ vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ - default: ({ children, onCollapse, collapsed }: { children: React.ReactNode, onCollapse: (value: boolean) => void, collapsed: boolean }) => ( + default: ({ + children, + onCollapse, + collapsed, + }: { + children: React.ReactNode + onCollapse: (value: boolean) => void + collapsed: boolean + }) => ( <div> - <button data-testid="toggle-output-vars" type="button" onClick={() => onCollapse(!collapsed)}>toggle output vars</button> + <button data-testid="toggle-output-vars" type="button" onClick={() => onCollapse(!collapsed)}> + toggle output vars + </button> {children} </div> ), @@ -119,14 +156,23 @@ vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ vi.mock('../components/header-table', () => ({ default: ({ onChange }: { onChange: (value: Array<Record<string, string>>) => void }) => ( - <button data-testid="header-table" type="button" onClick={() => onChange([{ key: 'Authorization', value: 'Bearer token' }])}> + <button + data-testid="header-table" + type="button" + onClick={() => onChange([{ key: 'Authorization', value: 'Bearer token' }])} + > header table </button> ), })) vi.mock('../components/parameter-table', () => ({ - default: ({ title, onChange, placeholder, contentType }: { + default: ({ + title, + onChange, + placeholder, + contentType, + }: { title: string onChange: (value: Array<Record<string, string>>) => void placeholder?: string @@ -135,7 +181,11 @@ vi.mock('../components/parameter-table', () => ({ <div> <span>{placeholder}</span> <span>{contentType}</span> - <button data-testid={`parameter-${title}`} type="button" onClick={() => onChange([{ key: title, value: 'value' }])}> + <button + data-testid={`parameter-${title}`} + type="button" + onClick={() => onChange([{ key: title, value: 'value' }])} + > {title} </button> </div> @@ -143,13 +193,23 @@ vi.mock('../components/parameter-table', () => ({ })) vi.mock('../components/paragraph-input', () => ({ - default: ({ value, onChange, placeholder }: { value: string, onChange: (value: string) => void, placeholder: string }) => ( - <textarea value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)} /> + default: ({ + value, + onChange, + placeholder, + }: { + value: string + onChange: (value: string) => void + placeholder: string + }) => ( + <textarea value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} /> ), })) vi.mock('../utils/render-output-vars', () => ({ - OutputVariablesContent: ({ variables }: { variables: unknown[] }) => <div data-testid="output-variables">{variables.length}</div>, + OutputVariablesContent: ({ variables }: { variables: unknown[] }) => ( + <div data-testid="output-variables">{variables.length}</div> + ), })) vi.mock('@/utils/urlValidation', () => ({ @@ -190,8 +250,9 @@ vi.mock('../use-config', () => ({ })) const getStatusCodeInput = () => { - return screen.getAllByDisplayValue('200') - .find(element => element.getAttribute('aria-hidden') !== 'true') as HTMLInputElement + return screen + .getAllByDisplayValue('200') + .find((element) => element.getAttribute('aria-hidden') !== 'true') as HTMLInputElement } describe('WebhookTriggerPanel', () => { @@ -302,9 +363,15 @@ describe('WebhookTriggerPanel', () => { expect(mockToastSuccess).toHaveBeenCalledWith('workflow.nodes.triggerWebhook.urlCopied') expect(mockHandleMethodChange).toHaveBeenCalledWith('GET') expect(mockHandleContentTypeChange).toHaveBeenCalledWith('text/plain') - expect(mockHandleParamsChange).toHaveBeenCalledWith([{ key: 'Query Parameters', value: 'value' }]) - expect(mockHandleHeadersChange).toHaveBeenCalledWith([{ key: 'Authorization', value: 'Bearer token' }]) - expect(mockHandleBodyChange).toHaveBeenCalledWith([{ key: 'Request Body Parameters', value: 'value' }]) + expect(mockHandleParamsChange).toHaveBeenCalledWith([ + { key: 'Query Parameters', value: 'value' }, + ]) + expect(mockHandleHeadersChange).toHaveBeenCalledWith([ + { key: 'Authorization', value: 'Bearer token' }, + ]) + expect(mockHandleBodyChange).toHaveBeenCalledWith([ + { key: 'Request Body Parameters', value: 'value' }, + ]) expect(mockHandleResponseBodyChange).toHaveBeenCalledWith('updated body') }) @@ -321,7 +388,9 @@ describe('WebhookTriggerPanel', () => { fireEvent.click(screen.getByText('http://127.0.0.1:8000/debug')) expect(mockCopy).toHaveBeenCalledWith('http://127.0.0.1:8000/debug') - expect(screen.getByText('workflow.nodes.triggerWebhook.debugUrlPrivateAddressWarning')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.triggerWebhook.debugUrlPrivateAddressWarning'), + ).toBeInTheDocument() vi.runAllTimers() vi.useRealTimers() diff --git a/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.helpers.spec.ts b/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.helpers.spec.ts index 6154131691f128..283f1d359c9eb8 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.helpers.spec.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.helpers.spec.ts @@ -10,23 +10,36 @@ import { } from '../use-config.helpers' import { WEBHOOK_RAW_VARIABLE_NAME } from '../utils/raw-variable' -const createInputs = (): WebhookTriggerNodeType => ({ - title: 'Webhook', - desc: '', - type: BlockEnum.TriggerWebhook, - method: 'POST', - content_type: 'application/json', - headers: [], - params: [], - body: [], - async_mode: false, - status_code: 200, - response_body: '', - variables: [ - { variable: 'existing_header', label: 'header', required: false, value_selector: [], value_type: VarType.string }, - { variable: 'body_value', label: 'body', required: true, value_selector: [], value_type: VarType.string }, - ], -} as unknown as WebhookTriggerNodeType) +const createInputs = (): WebhookTriggerNodeType => + ({ + title: 'Webhook', + desc: '', + type: BlockEnum.TriggerWebhook, + method: 'POST', + content_type: 'application/json', + headers: [], + params: [], + body: [], + async_mode: false, + status_code: 200, + response_body: '', + variables: [ + { + variable: 'existing_header', + label: 'header', + required: false, + value_selector: [], + value_type: VarType.string, + }, + { + variable: 'body_value', + label: 'body', + required: true, + value_selector: [], + value_type: VarType.string, + }, + ], + }) as unknown as WebhookTriggerNodeType describe('trigger webhook config helpers', () => { it('syncs variables, updates existing ones and validates names', () => { @@ -36,91 +49,123 @@ describe('trigger webhook config helpers', () => { const draft = { ...createInputs(), variables: [ - { variable: 'old_param', label: 'param', required: true, value_selector: [], value_type: VarType.number }, - { variable: 'existing_header', label: 'header', required: false, value_selector: [], value_type: VarType.string }, + { + variable: 'old_param', + label: 'param', + required: true, + value_selector: [], + value_type: VarType.number, + }, + { + variable: 'existing_header', + label: 'header', + required: false, + value_selector: [], + value_type: VarType.string, + }, ], } - expect(syncVariables({ - draft, - id: 'node-1', - newData: [{ name: 'existing_header', type: VarType.string, required: true }], - sourceType: 'header', - notifyError, - isVarUsedInNodes, - removeUsedVarInNodes, - })).toBe(true) - expect(draft.variables).toContainEqual(expect.objectContaining({ - variable: 'existing_header', - label: 'header', - required: true, - })) + expect( + syncVariables({ + draft, + id: 'node-1', + newData: [{ name: 'existing_header', type: VarType.string, required: true }], + sourceType: 'header', + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + }), + ).toBe(true) + expect(draft.variables).toContainEqual( + expect.objectContaining({ + variable: 'existing_header', + label: 'header', + required: true, + }), + ) - expect(syncVariables({ - draft, - id: 'node-1', - newData: [{ name: '1invalid', type: VarType.string, required: true }], - sourceType: 'param', - notifyError, - isVarUsedInNodes, - removeUsedVarInNodes, - })).toBe(false) + expect( + syncVariables({ + draft, + id: 'node-1', + newData: [{ name: '1invalid', type: VarType.string, required: true }], + sourceType: 'param', + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + }), + ).toBe(false) expect(notifyError).toHaveBeenCalledWith('varKeyError.notStartWithNumber') - expect(syncVariables({ - draft: createInputs(), - id: 'node-1', - newData: [ - { name: 'x-request-id', type: VarType.string, required: true }, - { name: 'x-request-id', type: VarType.string, required: false }, - ], - sourceType: 'header', - notifyError, - isVarUsedInNodes, - removeUsedVarInNodes, - })).toBe(false) + expect( + syncVariables({ + draft: createInputs(), + id: 'node-1', + newData: [ + { name: 'x-request-id', type: VarType.string, required: true }, + { name: 'x-request-id', type: VarType.string, required: false }, + ], + sourceType: 'header', + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + }), + ).toBe(false) expect(notifyError).toHaveBeenCalledWith('variableConfig.varName') - expect(syncVariables({ - draft: { - ...createInputs(), - variables: undefined, - } as unknown as WebhookTriggerNodeType, - id: 'node-1', - newData: [{ name: WEBHOOK_RAW_VARIABLE_NAME, type: VarType.string, required: true }], - sourceType: 'body', - notifyError, - isVarUsedInNodes, - removeUsedVarInNodes, - })).toBe(false) + expect( + syncVariables({ + draft: { + ...createInputs(), + variables: undefined, + } as unknown as WebhookTriggerNodeType, + id: 'node-1', + newData: [{ name: WEBHOOK_RAW_VARIABLE_NAME, type: VarType.string, required: true }], + sourceType: 'body', + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + }), + ).toBe(false) expect(notifyError).toHaveBeenCalledWith('variableConfig.varName') - expect(syncVariables({ - draft: createInputs(), - id: 'node-1', - newData: [{ name: 'existing_header', type: VarType.string, required: true }], - sourceType: 'param', - notifyError, - isVarUsedInNodes, - removeUsedVarInNodes, - })).toBe(false) + expect( + syncVariables({ + draft: createInputs(), + id: 'node-1', + newData: [{ name: 'existing_header', type: VarType.string, required: true }], + sourceType: 'param', + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + }), + ).toBe(false) expect(notifyError).toHaveBeenCalledWith('existing_header') const removableDraft = { ...createInputs(), variables: [ - { variable: 'old_param', label: 'param', required: true, value_selector: [], value_type: VarType.number }, + { + variable: 'old_param', + label: 'param', + required: true, + value_selector: [], + value_type: VarType.number, + }, ], } - expect(syncVariables({ - draft: removableDraft, - id: 'node-1', - newData: [], - sourceType: 'param', - notifyError, - isVarUsedInNodes, - removeUsedVarInNodes, - })).toBe(true) + expect( + syncVariables({ + draft: removableDraft, + id: 'node-1', + newData: [], + sourceType: 'param', + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + }), + ).toBe(true) expect(removeUsedVarInNodes).toHaveBeenCalledWith(['node-1', 'old_param']) }) @@ -134,63 +179,75 @@ describe('trigger webhook config helpers', () => { removeUsedVarInNodes, }) expect(nextContentType.body).toEqual([]) - expect(nextContentType.variables.every(item => item.label !== 'body')).toBe(true) + expect(nextContentType.variables.every((item) => item.label !== 'body')).toBe(true) expect(removeUsedVarInNodes).toHaveBeenCalledWith(['node-1', 'body_value']) - expect(updateContentType({ - inputs: createInputs(), - id: 'node-1', - contentType: 'application/json', - isVarUsedInNodes: () => false, - removeUsedVarInNodes, - }).body).toEqual([]) + expect( + updateContentType({ + inputs: createInputs(), + id: 'node-1', + contentType: 'application/json', + isVarUsedInNodes: () => false, + removeUsedVarInNodes, + }).body, + ).toEqual([]) - expect(updateContentType({ - inputs: { - ...createInputs(), - variables: undefined, - } as unknown as WebhookTriggerNodeType, - id: 'node-1', - contentType: 'multipart/form-data', - isVarUsedInNodes: () => false, - removeUsedVarInNodes, - }).body).toEqual([]) + expect( + updateContentType({ + inputs: { + ...createInputs(), + variables: undefined, + } as unknown as WebhookTriggerNodeType, + id: 'node-1', + contentType: 'multipart/form-data', + isVarUsedInNodes: () => false, + removeUsedVarInNodes, + }).body, + ).toEqual([]) - expect(updateSourceFields({ - inputs: createInputs(), - id: 'node-1', - sourceType: 'param', - nextData: [{ name: 'page', type: VarType.number, required: true }], - notifyError: vi.fn(), - isVarUsedInNodes: () => false, - removeUsedVarInNodes: vi.fn(), - }).params).toEqual([{ name: 'page', type: VarType.number, required: true }]) + expect( + updateSourceFields({ + inputs: createInputs(), + id: 'node-1', + sourceType: 'param', + nextData: [{ name: 'page', type: VarType.number, required: true }], + notifyError: vi.fn(), + isVarUsedInNodes: () => false, + removeUsedVarInNodes: vi.fn(), + }).params, + ).toEqual([{ name: 'page', type: VarType.number, required: true }]) - expect(updateSourceFields({ - inputs: createInputs(), - id: 'node-1', - sourceType: 'body', - nextData: [{ name: 'payload', type: VarType.string, required: true }], - notifyError: vi.fn(), - isVarUsedInNodes: () => false, - removeUsedVarInNodes: vi.fn(), - }).body).toEqual([{ name: 'payload', type: VarType.string, required: true }]) + expect( + updateSourceFields({ + inputs: createInputs(), + id: 'node-1', + sourceType: 'body', + nextData: [{ name: 'payload', type: VarType.string, required: true }], + notifyError: vi.fn(), + isVarUsedInNodes: () => false, + removeUsedVarInNodes: vi.fn(), + }).body, + ).toEqual([{ name: 'payload', type: VarType.string, required: true }]) - expect(updateSourceFields({ - inputs: createInputs(), - id: 'node-1', - sourceType: 'header', - nextData: [{ name: 'x-request-id', required: true }], - notifyError: vi.fn(), - isVarUsedInNodes: () => false, - removeUsedVarInNodes: vi.fn(), - }).headers).toEqual([{ name: 'x-request-id', required: true }]) + expect( + updateSourceFields({ + inputs: createInputs(), + id: 'node-1', + sourceType: 'header', + nextData: [{ name: 'x-request-id', required: true }], + notifyError: vi.fn(), + isVarUsedInNodes: () => false, + removeUsedVarInNodes: vi.fn(), + }).headers, + ).toEqual([{ name: 'x-request-id', required: true }]) expect(updateMethod(createInputs(), 'GET').method).toBe('GET') expect(updateSimpleField(createInputs(), 'status_code', 204).status_code).toBe(204) - expect(updateWebhookUrls(createInputs(), 'https://hook', 'https://debug')).toEqual(expect.objectContaining({ - webhook_url: 'https://hook', - webhook_debug_url: 'https://debug', - })) + expect(updateWebhookUrls(createInputs(), 'https://hook', 'https://debug')).toEqual( + expect.objectContaining({ + webhook_url: 'https://hook', + webhook_debug_url: 'https://debug', + }), + ) }) }) diff --git a/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.spec.tsx index fbadb8667b6b33..ec7f7288ca527f 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.spec.tsx @@ -14,11 +14,11 @@ const mockUseNodesReadOnly = vi.hoisted(() => vi.fn()) vi.mock('react-i18next', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey((key: string, options?: Record<string, unknown>) => options?.key || key), }), - }) + } }) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -47,7 +47,9 @@ vi.mock('@/service/apps', () => ({ const mockedFetchWebhookUrl = vi.mocked(fetchWebhookUrl) const mockedToastError = vi.mocked(toast.error) -const createPayload = (overrides: Partial<WebhookTriggerNodeType> = {}): WebhookTriggerNodeType => ({ +const createPayload = ( + overrides: Partial<WebhookTriggerNodeType> = {}, +): WebhookTriggerNodeType => ({ title: 'Webhook', desc: '', type: BlockEnum.TriggerWebhook, @@ -78,8 +80,20 @@ describe('useConfig', () => { content_type: 'application/json', body: [{ name: 'payload', type: VarType.string, required: true }], variables: [ - { variable: 'payload', label: 'body', required: true, value_selector: [], value_type: VarType.string }, - { variable: 'token', label: 'header', required: false, value_selector: [], value_type: VarType.string }, + { + variable: 'payload', + label: 'body', + required: true, + value_selector: [], + value_type: VarType.string, + }, + { + variable: 'token', + label: 'header', + required: false, + value_selector: [], + value_type: VarType.string, + }, ], }) mockIsVarUsedInNodes.mockImplementation(([_, variable]) => variable === 'payload') @@ -91,19 +105,23 @@ describe('useConfig', () => { result.current.handleStatusCodeChange(204) result.current.handleResponseBodyChange('ok') - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - method: 'GET', - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - content_type: 'text/plain', - body: [], - variables: [ - expect.objectContaining({ - variable: 'token', - label: 'header', - }), - ], - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + content_type: 'text/plain', + body: [], + variables: [ + expect.objectContaining({ + variable: 'token', + label: 'header', + }), + ], + }), + ) expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['webhook-node', 'payload']) expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ async_mode: true })) expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ status_code: 204 })) @@ -113,7 +131,13 @@ describe('useConfig', () => { it('should sync params, headers and body variables and reject conflicting names', () => { const payload = createPayload({ variables: [ - { variable: 'existing_header', label: 'header', required: false, value_selector: [], value_type: VarType.string }, + { + variable: 'existing_header', + label: 'header', + required: false, + value_selector: [], + value_type: VarType.string, + }, ], }) const { result } = renderHook(() => useConfig('webhook-node', payload)) @@ -121,36 +145,44 @@ describe('useConfig', () => { result.current.handleParamsChange([{ name: 'page', type: VarType.number, required: true }]) result.current.handleHeadersChange([{ name: 'x-request-id', required: false }]) result.current.handleBodyChange([{ name: 'body_field', type: VarType.string, required: true }]) - result.current.handleParamsChange([{ name: 'existing_header', type: VarType.string, required: true }]) - - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - params: [{ name: 'page', type: VarType.number, required: true }], - variables: expect.arrayContaining([ - expect.objectContaining({ - variable: 'page', - label: 'param', - value_type: VarType.number, - }), - ]), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - headers: [{ name: 'x-request-id', required: false }], - variables: expect.arrayContaining([ - expect.objectContaining({ - variable: 'x_request_id', - label: 'header', - }), - ]), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - body: [{ name: 'body_field', type: VarType.string, required: true }], - variables: expect.arrayContaining([ - expect.objectContaining({ - variable: 'body_field', - label: 'body', - }), - ]), - })) + result.current.handleParamsChange([ + { name: 'existing_header', type: VarType.string, required: true }, + ]) + + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + params: [{ name: 'page', type: VarType.number, required: true }], + variables: expect.arrayContaining([ + expect.objectContaining({ + variable: 'page', + label: 'param', + value_type: VarType.number, + }), + ]), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + headers: [{ name: 'x-request-id', required: false }], + variables: expect.arrayContaining([ + expect.objectContaining({ + variable: 'x_request_id', + label: 'header', + }), + ]), + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + body: [{ name: 'body_field', type: VarType.string, required: true }], + variables: expect.arrayContaining([ + expect.objectContaining({ + variable: 'body_field', + label: 'body', + }), + ]), + }), + ) expect(mockedToastError).toHaveBeenCalledTimes(1) }) @@ -169,18 +201,22 @@ describe('useConfig', () => { await result.current.generateWebhookUrl() expect(mockedFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-1', nodeId: 'webhook-node' }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - webhook_url: 'https://example.com/hook', - webhook_debug_url: 'https://example.com/debug', - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + webhook_url: 'https://example.com/hook', + webhook_debug_url: 'https://example.com/debug', + }), + ) rerender({ payload: createPayload(), }) await result.current.generateWebhookUrl() - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - webhook_url: '', - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + webhook_url: '', + }), + ) rerender({ payload: createPayload({ webhook_url: 'https://already-exists' }), diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx index 3682fa5e1a311c..9c2b1874db2596 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx @@ -33,7 +33,12 @@ const advancedColumns = [ title: 'Preview', type: 'custom' as const, width: 'w-[120px]', - render: (_value: unknown, row: { method?: string }, index: number, onChange: (value: unknown) => void) => ( + render: ( + _value: unknown, + row: { method?: string }, + index: number, + onChange: (value: unknown) => void, + ) => ( <button type="button" onClick={() => onChange(`${index}:${row.method || 'empty'}`)}> custom-render </button> @@ -145,15 +150,13 @@ describe('GenericTable', () => { emptyRowData={{ method: '', preview: '' }} onChange={(nextData) => { onChange(nextData) - setData(nextData as { method: string, preview: string }[]) + setData(nextData as { method: string; preview: string }[]) }} /> ) } - render( - <ControlledTable />, - ) + render(<ControlledTable />) await selectOption('Choose method', 'POST') diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/header-table.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/header-table.spec.tsx index 22e89b3cf1ef73..976c7f16f2961d 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/header-table.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/header-table.spec.tsx @@ -7,31 +7,32 @@ describe('trigger-webhook/header-table', () => { it('updates header names and required flags through the real generic table', async () => { const user = userEvent.setup() const onChange = vi.fn() - const headers: WebhookHeader[] = [{ - name: 'x-request-id', - required: false, - }] + const headers: WebhookHeader[] = [ + { + name: 'x-request-id', + required: false, + }, + ] - render( - <HeaderTable - headers={headers} - onChange={onChange} - />, - ) + render(<HeaderTable headers={headers} onChange={onChange} />) fireEvent.change(screen.getAllByRole('textbox')[0]!, { target: { value: 'Auth Token' } }) - expect(onChange).toHaveBeenLastCalledWith([{ - name: 'Auth_Token', - required: false, - }]) + expect(onChange).toHaveBeenLastCalledWith([ + { + name: 'Auth_Token', + required: false, + }, + ]) onChange.mockClear() await user.click(screen.getAllByRole('checkbox')[0]!) - expect(onChange).toHaveBeenCalledWith([{ - name: 'x-request-id', - required: true, - }]) + expect(onChange).toHaveBeenCalledWith([ + { + name: 'x-request-id', + required: true, + }, + ]) }) it('renders readonly rows without the trailing editable row', () => { diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/paragraph-input.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/paragraph-input.spec.tsx index 5f6b8b485cbf85..4274ab882f996f 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/paragraph-input.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/paragraph-input.spec.tsx @@ -23,13 +23,7 @@ describe('trigger-webhook/paragraph-input', () => { }) it('keeps the textarea disabled when requested', () => { - render( - <ParagraphInput - value="" - onChange={vi.fn()} - disabled - />, - ) + render(<ParagraphInput value="" onChange={vi.fn()} disabled />) expect(screen.getByRole('textbox')).toBeDisabled() expect(screen.getByText('03')).toBeInTheDocument() diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/parameter-table.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/parameter-table.spec.tsx index 96a0bc3f26c8d2..a0da7b8be910d5 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/parameter-table.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/parameter-table.spec.tsx @@ -4,18 +4,11 @@ import userEvent from '@testing-library/user-event' import { VarType } from '@/app/components/workflow/types' import ParameterTable from '../parameter-table' -const selectOption = async ({ - rowKey, - triggerName, -}: { - rowKey: string - triggerName: string -}) => { +const selectOption = async ({ rowKey, triggerName }: { rowKey: string; triggerName: string }) => { const user = userEvent.setup() const rowInput = screen.getByDisplayValue(rowKey) const row = rowInput.closest('[style*="min-height"]') - if (!(row instanceof HTMLElement)) - throw new Error('Failed to locate parameter table row') + if (!(row instanceof HTMLElement)) throw new Error('Failed to locate parameter table row') expect(within(row).getByText(triggerName)).toBeInTheDocument() const selectButton = within(row).getByRole('combobox') @@ -28,11 +21,13 @@ describe('trigger-webhook/parameter-table', () => { it('updates parameter types and required flags for json payloads', async () => { const user = userEvent.setup() const onChange = vi.fn() - const parameters: WebhookParameter[] = [{ - name: 'page', - type: VarType.string, - required: false, - }] + const parameters: WebhookParameter[] = [ + { + name: 'page', + type: VarType.string, + required: false, + }, + ] render( <ParameterTable @@ -49,21 +44,25 @@ describe('trigger-webhook/parameter-table', () => { }) await waitFor(() => { - expect(onChange).toHaveBeenCalledWith([{ - name: 'page', - type: VarType.number, - required: false, - }]) + expect(onChange).toHaveBeenCalledWith([ + { + name: 'page', + type: VarType.number, + required: false, + }, + ]) }) onChange.mockClear() await user.click(screen.getAllByRole('checkbox')[0]!) - expect(onChange).toHaveBeenCalledWith([{ - name: 'page', - type: VarType.string, - required: true, - }]) + expect(onChange).toHaveBeenCalledWith([ + { + name: 'page', + type: VarType.string, + required: true, + }, + ]) }) it('forces plain-text bodies to a single string parameter', async () => { @@ -73,11 +72,13 @@ describe('trigger-webhook/parameter-table', () => { render( <ParameterTable title="Body" - parameters={[{ - name: 'message', - type: VarType.number, - required: false, - }]} + parameters={[ + { + name: 'message', + type: VarType.number, + required: false, + }, + ]} onChange={onChange} contentType="text/plain" />, @@ -85,10 +86,12 @@ describe('trigger-webhook/parameter-table', () => { await user.click(screen.getAllByRole('checkbox')[0]!) - expect(onChange).toHaveBeenCalledWith([{ - name: 'message', - type: VarType.string, - required: true, - }]) + expect(onChange).toHaveBeenCalledWith([ + { + name: 'message', + type: VarType.string, + required: true, + }, + ]) }) }) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx index 55cc0ea2e59d1c..644beab54c85ee 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx @@ -2,7 +2,14 @@ import type { FC, ReactNode } from 'react' import { Checkbox } from '@langgenius/dify-ui/checkbox' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { RiDeleteBinLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useMemo } from 'react' @@ -11,8 +18,7 @@ import { replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var' // Tiny utility to judge whether a cell value is effectively present const isPresent = (v: unknown): boolean => { - if (typeof v === 'string') - return v.trim() !== '' + if (typeof v === 'string') return v.trim() !== '' return !(v === '' || v === null || v === undefined || v === false) } // Column configuration types for table components @@ -30,7 +36,12 @@ export type ColumnConfig = { width?: string // CSS class for width (e.g., 'w-1/2', 'w-[140px]') placeholder?: string options?: SelectOption[] // For select type - render?: (value: unknown, row: GenericTableRow, index: number, onChange: (value: unknown) => void) => ReactNode + render?: ( + value: unknown, + row: GenericTableRow, + index: number, + onChange: (value: unknown) => void, + ) => ReactNode required?: boolean } @@ -58,7 +69,7 @@ type DisplayRow = { } const isEmptyRow = (row: GenericTableRow) => { - return Object.values(row).every(v => v === '' || v === null || v === undefined || v === false) + return Object.values(row).every((v) => v === '' || v === null || v === undefined || v === false) } const getDisplayRows = ( @@ -66,15 +77,12 @@ const getDisplayRows = ( emptyRowData: GenericTableRow, readonly: boolean, ): DisplayRow[] => { - if (readonly) - return data.map((row, index) => ({ row, dataIndex: index, isVirtual: false })) + if (readonly) return data.map((row, index) => ({ row, dataIndex: index, isVirtual: false })) - if (!data.length) - return [{ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }] + if (!data.length) return [{ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }] const rows = data.reduce<DisplayRow[]>((acc, row, index) => { - if (isEmptyRow(row) && index < data.length - 1) - return acc + if (isEmptyRow(row) && index < data.length - 1) return acc acc.push({ row, dataIndex: index, isVirtual: false }) return acc @@ -88,7 +96,7 @@ const getDisplayRows = ( } const getPrimaryKey = (columns: ColumnConfig[]) => { - return columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key' + return columns.find((col) => col.key === 'key' || col.key === 'name')?.key ?? 'key' } const renderInputCell = ( @@ -130,12 +138,12 @@ const renderSelectCell = ( handleChange: (value: unknown) => void, ) => { const options = column.options || [] - const selectedOption = options.find(option => option.value === value) ?? null + const selectedOption = options.find((option) => option.value === value) ?? null return ( <Select value={selectedOption?.value ?? null} - onValueChange={nextValue => nextValue && handleChange(nextValue)} + onValueChange={(nextValue) => nextValue && handleChange(nextValue)} disabled={readonly} > <SelectTrigger @@ -148,7 +156,7 @@ const renderSelectCell = ( {selectedOption?.name ?? column.placeholder} </SelectTrigger> <SelectContent className="-translate-x-3" popupClassName="w-26 min-w-26"> - {options.map(option => ( + {options.map((option) => ( <SelectItem key={option.value} value={option.value}> <SelectItemText>{option.name}</SelectItemText> <SelectItemIndicator /> @@ -185,7 +193,7 @@ const renderCustomCell = ( dataIndex: number | null, handleChange: (value: unknown) => void, ) => { - return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null + return column.render ? column.render(value, row, dataIndex ?? -1, handleChange) : null } const GenericTable: FC<GenericTableProps> = ({ @@ -203,32 +211,35 @@ const GenericTable: FC<GenericTableProps> = ({ return getDisplayRows(data, emptyRowData, readonly) }, [data, emptyRowData, readonly]) - const removeRow = useCallback((dataIndex: number) => { - if (readonly) - return - if (dataIndex < 0 || dataIndex >= data.length) - return // ignore virtual rows - const newData = data.filter((_, i) => i !== dataIndex) - onChange(newData) - }, [data, readonly, onChange]) - - const updateRow = useCallback((dataIndex: number | null, key: string, value: unknown) => { - if (readonly) - return - - if (dataIndex !== null && dataIndex < data.length) { - // Editing existing configured row - const newData = [...data] - newData[dataIndex] = { ...newData[dataIndex], [key]: value } + const removeRow = useCallback( + (dataIndex: number) => { + if (readonly) return + if (dataIndex < 0 || dataIndex >= data.length) return // ignore virtual rows + const newData = data.filter((_, i) => i !== dataIndex) onChange(newData) - return - } + }, + [data, readonly, onChange], + ) - // Editing the trailing UI-only empty row: create a new configured row - const newRow = { ...emptyRowData, [key]: value } - const next = [...data, newRow] - onChange(next) - }, [data, emptyRowData, onChange, readonly]) + const updateRow = useCallback( + (dataIndex: number | null, key: string, value: unknown) => { + if (readonly) return + + if (dataIndex !== null && dataIndex < data.length) { + // Editing existing configured row + const newData = [...data] + newData[dataIndex] = { ...newData[dataIndex], [key]: value } + onChange(newData) + return + } + + // Editing the trailing UI-only empty row: create a new configured row + const newRow = { ...emptyRowData, [key]: value } + const next = [...data, newRow] + onChange(next) + }, + [data, emptyRowData, onChange, readonly], + ) // Determine the primary identifier column just once const primaryKey = useMemo(() => getPrimaryKey(columns), [columns]) @@ -336,15 +347,13 @@ const GenericTable: FC<GenericTableProps> = ({ <h4 className="system-sm-semibold-uppercase text-text-secondary">{title}</h4> </div> - {showPlaceholder - ? ( - <div className="flex h-7 items-center justify-center rounded-lg border border-divider-regular bg-components-panel-bg text-xs leading-[18px] font-normal text-text-quaternary"> - {placeholder} - </div> - ) - : ( - renderTable() - )} + {showPlaceholder ? ( + <div className="flex h-7 items-center justify-center rounded-lg border border-divider-regular bg-components-panel-bg text-xs leading-[18px] font-normal text-text-quaternary"> + {placeholder} + </div> + ) : ( + renderTable() + )} </div> ) } diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx index 4bdb16d74a51ed..a01b94a58e49e8 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx @@ -12,25 +12,21 @@ type HeaderTableProps = { onChange: (headers: WebhookHeader[]) => void } -const HeaderTable: FC<HeaderTableProps> = ({ - readonly = false, - headers = [], - onChange, -}) => { +const HeaderTable: FC<HeaderTableProps> = ({ readonly = false, headers = [], onChange }) => { const { t } = useTranslation() // Define columns for header table - matching prototype design const columns: ColumnConfig[] = [ { key: 'name', - title: t($ => $['nodes.triggerWebhook.varName'], { ns: 'workflow' }), + title: t(($) => $['nodes.triggerWebhook.varName'], { ns: 'workflow' }), type: 'input', width: 'flex-1', - placeholder: t($ => $['nodes.triggerWebhook.varNamePlaceholder'], { ns: 'workflow' }), + placeholder: t(($) => $['nodes.triggerWebhook.varNamePlaceholder'], { ns: 'workflow' }), }, { key: 'required', - title: t($ => $['nodes.triggerWebhook.required'], { ns: 'workflow' }), + title: t(($) => $['nodes.triggerWebhook.required'], { ns: 'workflow' }), type: 'switch', width: 'w-[88px]', }, @@ -45,7 +41,7 @@ const HeaderTable: FC<HeaderTableProps> = ({ } // Convert WebhookHeader[] to GenericTableRow[] - const tableData: GenericTableRow[] = headers.map(header => ({ + const tableData: GenericTableRow[] = headers.map((header) => ({ name: header.name, required: header.required, })) @@ -53,8 +49,8 @@ const HeaderTable: FC<HeaderTableProps> = ({ // Handle data changes const handleDataChange = (data: GenericTableRow[]) => { const newHeaders: WebhookHeader[] = data - .filter(row => row.name && typeof row.name === 'string' && row.name.trim() !== '') - .map(row => ({ + .filter((row) => row.name && typeof row.name === 'string' && row.name.trim() !== '') + .map((row) => ({ name: (row.name as string) || '', required: !!row.required, })) @@ -68,7 +64,7 @@ const HeaderTable: FC<HeaderTableProps> = ({ data={tableData} onChange={handleDataChange} readonly={readonly} - placeholder={t($ => $['nodes.triggerWebhook.noHeaders'], { ns: 'workflow' })} + placeholder={t(($) => $['nodes.triggerWebhook.noHeaders'], { ns: 'workflow' })} emptyRowData={emptyRowData} showHeader={true} /> diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/paragraph-input.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/paragraph-input.tsx index df81da7d6e9061..0c808b13826396 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/paragraph-input.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/paragraph-input.tsx @@ -40,7 +40,7 @@ const ParagraphInput: FC<ParagraphInputProps> = ({ <textarea ref={textareaRef} value={value} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} placeholder={placeholder} disabled={disabled} className="w-full resize-none border-0 bg-transparent pl-6 font-mono text-xs leading-[20px] text-text-secondary outline-hidden placeholder:text-text-quaternary" diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx index 4b6304b2b46c74..5364aea3c9410f 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx @@ -29,29 +29,28 @@ const ParameterTable: FC<ParameterTableProps> = ({ const { t } = useTranslation() // Memoize typeOptions to prevent unnecessary re-renders that cause Select state resets - const typeOptions = useMemo(() => - createParameterTypeOptions(contentType), [contentType]) + const typeOptions = useMemo(() => createParameterTypeOptions(contentType), [contentType]) // Define columns based on component type - matching prototype design const columns: ColumnConfig[] = [ { key: 'key', - title: t($ => $['nodes.triggerWebhook.varName'], { ns: 'workflow' }), + title: t(($) => $['nodes.triggerWebhook.varName'], { ns: 'workflow' }), type: 'input', width: 'flex-1', - placeholder: t($ => $['nodes.triggerWebhook.varNamePlaceholder'], { ns: 'workflow' }), + placeholder: t(($) => $['nodes.triggerWebhook.varNamePlaceholder'], { ns: 'workflow' }), }, { key: 'type', - title: t($ => $['nodes.triggerWebhook.varType'], { ns: 'workflow' }), + title: t(($) => $['nodes.triggerWebhook.varType'], { ns: 'workflow' }), type: 'select', width: 'w-[120px]', - placeholder: t($ => $['nodes.triggerWebhook.varType'], { ns: 'workflow' }), + placeholder: t(($) => $['nodes.triggerWebhook.varType'], { ns: 'workflow' }), options: typeOptions, }, { key: 'required', - title: t($ => $['nodes.triggerWebhook.required'], { ns: 'workflow' }), + title: t(($) => $['nodes.triggerWebhook.required'], { ns: 'workflow' }), type: 'switch', width: 'w-[88px]', }, @@ -67,7 +66,7 @@ const ParameterTable: FC<ParameterTableProps> = ({ required: false, } - const tableData: GenericTableRow[] = parameters.map(param => ({ + const tableData: GenericTableRow[] = parameters.map((param) => ({ key: param.name, type: param.type, required: param.required, @@ -80,16 +79,19 @@ const ParameterTable: FC<ParameterTableProps> = ({ const isOctetStream = (contentType || '').toLowerCase() === 'application/octet-stream' const normalized = data - .filter(row => typeof row.key === 'string' && (row.key as string).trim() !== '') - .map(row => ({ + .filter((row) => typeof row.key === 'string' && (row.key as string).trim() !== '') + .map((row) => ({ name: String(row.key), - type: isTextPlain ? VarType.string : isOctetStream ? VarType.file : normalizeParameterType((row.type as string)), + type: isTextPlain + ? VarType.string + : isOctetStream + ? VarType.file + : normalizeParameterType(row.type as string), required: Boolean(row.required), })) - const newParams: WebhookParameter[] = (isTextPlain || isOctetStream) - ? normalized.slice(0, 1) - : normalized + const newParams: WebhookParameter[] = + isTextPlain || isOctetStream ? normalized.slice(0, 1) : normalized onChange(newParams) } @@ -101,7 +103,9 @@ const ParameterTable: FC<ParameterTableProps> = ({ data={tableData} onChange={handleDataChange} readonly={readonly} - placeholder={placeholder || t($ => $['nodes.triggerWebhook.noParameters'], { ns: 'workflow' })} + placeholder={ + placeholder || t(($) => $['nodes.triggerWebhook.noParameters'], { ns: 'workflow' }) + } emptyRowData={emptyRowData} showHeader={true} /> diff --git a/web/app/components/workflow/nodes/trigger-webhook/default.ts b/web/app/components/workflow/nodes/trigger-webhook/default.ts index c2ebbdf08600f2..f8818410344815 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/default.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/default.ts @@ -32,22 +32,21 @@ const nodeDefault: NodeDefault<WebhookTriggerNodeType> = { if (!payload.webhook_url || payload.webhook_url.trim() === '') { return { isValid: false, - errorMessage: t($ => $['nodes.triggerWebhook.validation.webhookUrlRequired'], { ns: 'workflow' }), + errorMessage: t(($) => $['nodes.triggerWebhook.validation.webhookUrlRequired'], { + ns: 'workflow', + }), } } // Validate parameter types for params and body - const parametersWithTypes = [ - ...(payload.params || []), - ...(payload.body || []), - ] + const parametersWithTypes = [...(payload.params || []), ...(payload.body || [])] for (const param of parametersWithTypes) { // Validate parameter type is valid if (!isValidParameterType(param.type)) { return { isValid: false, - errorMessage: t($ => $['nodes.triggerWebhook.validation.invalidParameterType'], { + errorMessage: t(($) => $['nodes.triggerWebhook.validation.invalidParameterType'], { ns: 'workflow', name: param.name, type: param.type, diff --git a/web/app/components/workflow/nodes/trigger-webhook/node.tsx b/web/app/components/workflow/nodes/trigger-webhook/node.tsx index 1777be1cac0130..6a69361e7d3d12 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/node.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/node.tsx @@ -3,9 +3,7 @@ import type { WebhookTriggerNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' import * as React from 'react' -const Node: FC<NodeProps<WebhookTriggerNodeType>> = ({ - data, -}) => { +const Node: FC<NodeProps<WebhookTriggerNodeType>> = ({ data }) => { return ( <div className="mb-1 px-3 py-1"> <div className="mb-1 text-[10px] font-medium tracking-wide text-text-tertiary uppercase"> diff --git a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx index 97802a43a78f9d..edeff700f232f8 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx @@ -9,7 +9,15 @@ import { NumberFieldIncrement, NumberFieldInput, } from '@langgenius/dify-ui/number-field' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectLabel, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import copy from 'copy-to-clipboard' @@ -36,7 +44,7 @@ const HTTP_METHODS = [ { name: 'DELETE', value: 'DELETE' }, { name: 'PATCH', value: 'PATCH' }, { name: 'HEAD', value: 'HEAD' }, -] satisfies Array<{ name: string, value: HttpMethod }> +] satisfies Array<{ name: string; value: HttpMethod }> const CONTENT_TYPES = [ { name: 'application/json', value: 'application/json' }, @@ -61,12 +69,11 @@ const WebhookMethodSelector = ({ disabled, onChange, }: WebhookMethodSelectorProps) => { - const selectedMethod = HTTP_METHODS.find(item => item.value === value) ?? null + const selectedMethod = HTTP_METHODS.find((item) => item.value === value) ?? null const handleMethodChange = (nextValue: string | null) => { - const nextMethod = HTTP_METHODS.find(item => item.value === nextValue) - if (nextMethod) - onChange(nextMethod.value) + const nextMethod = HTTP_METHODS.find((item) => item.value === nextValue) + if (nextMethod) onChange(nextMethod.value) } return ( @@ -80,7 +87,7 @@ const WebhookMethodSelector = ({ {selectedMethod?.name} </SelectTrigger> <SelectContent popupClassName="w-26 min-w-26"> - {HTTP_METHODS.map(item => ( + {HTTP_METHODS.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -91,10 +98,7 @@ const WebhookMethodSelector = ({ ) } -const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ id, data }) => { const { t } = useTranslation() const [debugUrlCopied, setDebugUrlCopied] = React.useState(false) const [outputVarsCollapsed, setOutputVarsCollapsed] = useState(false) @@ -120,19 +124,20 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ } }, [readOnly, inputs.webhook_url, generateWebhookUrl]) - const selectedContentType = CONTENT_TYPES.find(item => item.value === inputs.content_type) ?? null + const selectedContentType = + CONTENT_TYPES.find((item) => item.value === inputs.content_type) ?? null return ( <div className="mt-2"> <div className="space-y-4 px-4 pt-2 pb-3"> {/* Webhook URL Section */} - <Field title={t($ => $[`${i18nPrefix}.webhookUrl`], { ns: 'workflow' })}> + <Field title={t(($) => $[`${i18nPrefix}.webhookUrl`], { ns: 'workflow' })}> <div className="space-y-1"> <div className="flex gap-1" style={{ height: '32px' }}> <div className="w-26 shrink-0"> <WebhookMethodSelector nodeId={id} - label={t($ => $[`${i18nPrefix}.method`], { ns: 'workflow' })} + label={t(($) => $[`${i18nPrefix}.method`], { ns: 'workflow' })} value={inputs.method} disabled={readOnly} onChange={handleMethodChange} @@ -141,10 +146,12 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ <div className="flex-1" style={{ width: '284px' }}> <InputWithCopy value={inputs.webhook_url || ''} - placeholder={t($ => $[`${i18nPrefix}.webhookUrlPlaceholder`], { ns: 'workflow' })} + placeholder={t(($) => $[`${i18nPrefix}.webhookUrlPlaceholder`], { + ns: 'workflow', + })} readOnly onCopy={() => { - toast.success(t($ => $[`${i18nPrefix}.urlCopied`], { ns: 'workflow' })) + toast.success(t(($) => $[`${i18nPrefix}.urlCopied`], { ns: 'workflow' })) }} /> </div> @@ -153,10 +160,10 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ <div className="space-y-2"> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" - aria-label={t($ => $[`${i18nPrefix}.debugUrlCopy`], { ns: 'workflow' })} + aria-label={t(($) => $[`${i18nPrefix}.debugUrlCopy`], { ns: 'workflow' })} className="flex cursor-pointer gap-1.5 rounded-lg px-1 py-1.5 text-left transition-colors" style={{ width: '368px', height: '38px' }} onClick={() => { @@ -165,28 +172,33 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ setTimeout(() => setDebugUrlCopied(false), 2000) }} > - <span className="mt-0.5 w-0.5 bg-divider-regular" style={{ height: '28px' }} /> + <span + className="mt-0.5 w-0.5 bg-divider-regular" + style={{ height: '28px' }} + /> <span className="flex-1" style={{ width: '352px', height: '32px' }}> <span className="block text-xs/4 text-text-tertiary"> - {t($ => $[`${i18nPrefix}.debugUrlTitle`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.debugUrlTitle`], { ns: 'workflow' })} </span> <span className="block truncate text-xs/4 text-text-primary"> {inputs.webhook_debug_url} </span> </span> </button> - )} + } /> <TooltipContent placement="top" className="rounded-md border border-components-panel-border bg-components-tooltip-bg px-1.5 py-1 system-xs-regular text-text-primary shadow-lg backdrop-blur-xs" > - {debugUrlCopied ? t($ => $[`${i18nPrefix}.debugUrlCopied`], { ns: 'workflow' }) : t($ => $[`${i18nPrefix}.debugUrlCopy`], { ns: 'workflow' })} + {debugUrlCopied + ? t(($) => $[`${i18nPrefix}.debugUrlCopied`], { ns: 'workflow' }) + : t(($) => $[`${i18nPrefix}.debugUrlCopy`], { ns: 'workflow' })} </TooltipContent> </Tooltip> {isPrivateOrLocalAddress(inputs.webhook_debug_url) && ( <div className="mt-1 px-0 py-[2px] system-xs-regular text-text-warning"> - {t($ => $[`${i18nPrefix}.debugUrlPrivateAddressWarning`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.debugUrlPrivateAddressWarning`], { ns: 'workflow' })} </div> )} </div> @@ -195,20 +207,22 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ </Field> {/* Content Type */} - <Field title={t($ => $[`${i18nPrefix}.contentType`], { ns: 'workflow' })}> + <Field title={t(($) => $[`${i18nPrefix}.contentType`], { ns: 'workflow' })}> <div className="w-full max-w-[392px]"> <Select key={`${id}-content-type-${inputs.content_type}`} value={selectedContentType?.value ?? null} disabled={readOnly} - onValueChange={value => value && handleContentTypeChange(value)} + onValueChange={(value) => value && handleContentTypeChange(value)} > - <SelectLabel className="sr-only">{t($ => $[`${i18nPrefix}.contentType`], { ns: 'workflow' })}</SelectLabel> + <SelectLabel className="sr-only"> + {t(($) => $[`${i18nPrefix}.contentType`], { ns: 'workflow' })} + </SelectLabel> <SelectTrigger className="h-8 w-full text-sm"> {selectedContentType?.name} </SelectTrigger> <SelectContent> - {CONTENT_TYPES.map(item => ( + {CONTENT_TYPES.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -225,15 +239,11 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ title="Query Parameters" parameters={inputs.params} onChange={handleParamsChange} - placeholder={t($ => $[`${i18nPrefix}.noQueryParameters`], { ns: 'workflow' })} + placeholder={t(($) => $[`${i18nPrefix}.noQueryParameters`], { ns: 'workflow' })} /> {/* Header Parameters */} - <HeaderTable - readonly={readOnly} - headers={inputs.headers} - onChange={handleHeadersChange} - /> + <HeaderTable readonly={readOnly} headers={inputs.headers} onChange={handleHeadersChange} /> {/* Request Body Parameters */} <ParameterTable @@ -241,18 +251,18 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ title="Request Body Parameters" parameters={inputs.body} onChange={handleBodyChange} - placeholder={t($ => $[`${i18nPrefix}.noBodyParameters`], { ns: 'workflow' })} + placeholder={t(($) => $[`${i18nPrefix}.noBodyParameters`], { ns: 'workflow' })} contentType={inputs.content_type} /> <Split /> {/* Response Configuration */} - <Field title={t($ => $[`${i18nPrefix}.responseConfiguration`], { ns: 'workflow' })}> + <Field title={t(($) => $[`${i18nPrefix}.responseConfiguration`], { ns: 'workflow' })}> <div className="space-y-3"> <div className="flex items-center justify-between"> <label className="system-sm-medium text-text-tertiary"> - {t($ => $[`${i18nPrefix}.statusCode`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.statusCode`], { ns: 'workflow' })} </label> <NumberField className="w-[120px]" @@ -260,7 +270,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ max={MAX_STATUS_CODE} value={inputs.status_code ?? DEFAULT_STATUS_CODE} disabled={readOnly} - onValueChange={value => value !== null && handleStatusCodeChange(value)} + onValueChange={(value) => value !== null && handleStatusCodeChange(value)} onValueCommitted={(value, eventDetails) => { if (eventDetails.reason === 'input-clear') handleStatusCodeChange(value ?? DEFAULT_STATUS_CODE) @@ -268,7 +278,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ > <NumberFieldGroup> <NumberFieldInput - aria-label={t($ => $[`${i18nPrefix}.statusCode`], { ns: 'workflow' })} + aria-label={t(($) => $[`${i18nPrefix}.statusCode`], { ns: 'workflow' })} className="h-8" /> <NumberFieldControls> @@ -280,12 +290,14 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ </div> <div> <label className="mb-2 block system-sm-medium text-text-tertiary"> - {t($ => $[`${i18nPrefix}.responseBody`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.responseBody`], { ns: 'workflow' })} </label> <ParagraphInput value={inputs.response_body} onChange={handleResponseBodyChange} - placeholder={t($ => $[`${i18nPrefix}.responseBodyPlaceholder`], { ns: 'workflow' })} + placeholder={t(($) => $[`${i18nPrefix}.responseBodyPlaceholder`], { + ns: 'workflow', + })} disabled={readOnly} /> </div> @@ -296,10 +308,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ <Split /> <div className=""> - <OutputVars - collapsed={outputVarsCollapsed} - onCollapse={setOutputVarsCollapsed} - > + <OutputVars collapsed={outputVarsCollapsed} onCollapse={setOutputVarsCollapsed}> <OutputVariablesContent variables={inputs.variables} /> </OutputVars> </div> diff --git a/web/app/components/workflow/nodes/trigger-webhook/use-config.helpers.ts b/web/app/components/workflow/nodes/trigger-webhook/use-config.helpers.ts index dbbdc161b20420..11fc61d4065602 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/use-config.helpers.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/use-config.helpers.ts @@ -14,7 +14,10 @@ type SanitizedEntry = { type NotifyError = (key: string) => void -const sanitizeEntryName = (item: WebhookParameter | WebhookHeader, sourceType: VariableSyncSource) => { +const sanitizeEntryName = ( + item: WebhookParameter | WebhookHeader, + sourceType: VariableSyncSource, +) => { return sourceType === 'header' ? item.name.replace(/-/g, '_') : item.name } @@ -22,7 +25,7 @@ const getSanitizedEntries = ( newData: (WebhookParameter | WebhookHeader)[], sourceType: VariableSyncSource, ): SanitizedEntry[] => { - return newData.map(item => ({ + return newData.map((item) => ({ item, sanitizedName: sanitizeEntryName(item, sourceType), })) @@ -61,28 +64,29 @@ export const syncVariables = ({ isVarUsedInNodes: (selector: [string, string]) => boolean removeUsedVarInNodes: (selector: [string, string]) => void }) => { - if (!draft.variables) - draft.variables = [] + if (!draft.variables) draft.variables = [] const sanitizedEntries = getSanitizedEntries(newData, sourceType) - if (sanitizedEntries.some(entry => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME)) { + if (sanitizedEntries.some((entry) => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME)) { notifyError('variableConfig.varName') return false } const existingOtherVarNames = new Set( draft.variables - .filter(v => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME) - .map(v => v.variable), + .filter((v) => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME) + .map((v) => v.variable), ) - const crossScopeConflict = sanitizedEntries.find(entry => existingOtherVarNames.has(entry.sanitizedName)) + const crossScopeConflict = sanitizedEntries.find((entry) => + existingOtherVarNames.has(entry.sanitizedName), + ) if (crossScopeConflict) { notifyError(crossScopeConflict.sanitizedName) return false } - if (hasDuplicateStr(sanitizedEntries.map(entry => entry.sanitizedName))) { + if (hasDuplicateStr(sanitizedEntries.map((entry) => entry.sanitizedName))) { notifyError('variableConfig.varName') return false } @@ -95,45 +99,41 @@ export const syncVariables = ({ } } - const nextNames = new Set(sanitizedEntries.map(entry => entry.sanitizedName)) + const nextNames = new Set(sanitizedEntries.map((entry) => entry.sanitizedName)) draft.variables - .filter(v => v.label === sourceType && !nextNames.has(v.variable)) + .filter((v) => v.label === sourceType && !nextNames.has(v.variable)) .forEach((variable) => { - if (isVarUsedInNodes([id, variable.variable])) - removeUsedVarInNodes([id, variable.variable]) + if (isVarUsedInNodes([id, variable.variable])) removeUsedVarInNodes([id, variable.variable]) }) draft.variables = draft.variables.filter((variable) => { - if (variable.label !== sourceType) - return true + if (variable.label !== sourceType) return true return nextNames.has(variable.variable) }) sanitizedEntries.forEach(({ item, sanitizedName }) => { - const existingVarIndex = draft.variables.findIndex(v => v.variable === sanitizedName) + const existingVarIndex = draft.variables.findIndex((v) => v.variable === sanitizedName) const variable = createVariable(item, sourceType, sanitizedName) - if (existingVarIndex >= 0) - draft.variables[existingVarIndex] = variable - else - draft.variables.push(variable) + if (existingVarIndex >= 0) draft.variables[existingVarIndex] = variable + else draft.variables.push(variable) }) return true } -export const updateMethod = (inputs: WebhookTriggerNodeType, method: HttpMethod) => produce(inputs, (draft) => { - draft.method = method -}) +export const updateMethod = (inputs: WebhookTriggerNodeType, method: HttpMethod) => + produce(inputs, (draft) => { + draft.method = method + }) -export const updateSimpleField = < - K extends 'async_mode' | 'status_code' | 'response_body', ->( +export const updateSimpleField = <K extends 'async_mode' | 'status_code' | 'response_body'>( inputs: WebhookTriggerNodeType, key: K, value: WebhookTriggerNodeType[K], -) => produce(inputs, (draft) => { - draft[key] = value -}) +) => + produce(inputs, (draft) => { + draft[key] = value + }) export const updateContentType = ({ inputs, @@ -147,26 +147,24 @@ export const updateContentType = ({ contentType: string isVarUsedInNodes: (selector: [string, string]) => boolean removeUsedVarInNodes: (selector: [string, string]) => void -}) => produce(inputs, (draft) => { - const previousContentType = draft.content_type - draft.content_type = contentType +}) => + produce(inputs, (draft) => { + const previousContentType = draft.content_type + draft.content_type = contentType - if (previousContentType === contentType) - return + if (previousContentType === contentType) return - draft.body = [] - if (!draft.variables) - return + draft.body = [] + if (!draft.variables) return - draft.variables - .filter(v => v.label === 'body') - .forEach((variable) => { - if (isVarUsedInNodes([id, variable.variable])) - removeUsedVarInNodes([id, variable.variable]) - }) + draft.variables + .filter((v) => v.label === 'body') + .forEach((variable) => { + if (isVarUsedInNodes([id, variable.variable])) removeUsedVarInNodes([id, variable.variable]) + }) - draft.variables = draft.variables.filter(v => v.label !== 'body') -}) + draft.variables = draft.variables.filter((v) => v.label !== 'body') + }) type SourceField = 'params' | 'headers' | 'body' @@ -197,24 +195,26 @@ export const updateSourceFields = ({ notifyError: NotifyError isVarUsedInNodes: (selector: [string, string]) => boolean removeUsedVarInNodes: (selector: [string, string]) => void -}) => produce(inputs, (draft) => { - draft[getSourceField(sourceType)] = nextData as never - syncVariables({ - draft, - id, - newData: nextData, - sourceType, - notifyError, - isVarUsedInNodes, - removeUsedVarInNodes, +}) => + produce(inputs, (draft) => { + draft[getSourceField(sourceType)] = nextData as never + syncVariables({ + draft, + id, + newData: nextData, + sourceType, + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + }) }) -}) export const updateWebhookUrls = ( inputs: WebhookTriggerNodeType, webhookUrl: string, webhookDebugUrl?: string, -) => produce(inputs, (draft) => { - draft.webhook_url = webhookUrl - draft.webhook_debug_url = webhookDebugUrl -}) +) => + produce(inputs, (draft) => { + draft.webhook_url = webhookUrl + draft.webhook_debug_url = webhookDebugUrl + }) diff --git a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts index eb9b694a3e52c2..10ef38ecd1e2cd 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts @@ -19,11 +19,11 @@ export const DEFAULT_STATUS_CODE = 200 export const MAX_STATUS_CODE = 399 const varKeyErrorSelectors = { - 'varKeyError.canNoBeEmpty': $ => $['varKeyError.canNoBeEmpty'], - 'varKeyError.keyAlreadyExists': $ => $['varKeyError.keyAlreadyExists'], - 'varKeyError.notStartWithNumber': $ => $['varKeyError.notStartWithNumber'], - 'varKeyError.notValid': $ => $['varKeyError.notValid'], - 'varKeyError.tooLong': $ => $['varKeyError.tooLong'], + 'varKeyError.canNoBeEmpty': ($) => $['varKeyError.canNoBeEmpty'], + 'varKeyError.keyAlreadyExists': ($) => $['varKeyError.keyAlreadyExists'], + 'varKeyError.notStartWithNumber': ($) => $['varKeyError.notStartWithNumber'], + 'varKeyError.notValid': ($) => $['varKeyError.notValid'], + 'varKeyError.tooLong': ($) => $['varKeyError.tooLong'], } satisfies Record<string, SelectorParam<'appDebug'>> function isVarKeyErrorKey(key: string): key is keyof typeof varKeyErrorSelectors { @@ -37,92 +37,125 @@ export const useConfig = (id: string, payload: WebhookTriggerNodeType) => { const appId = useAppStore.getState().appDetail?.id const { isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow() - const notifyVarError = useCallback((key: string) => { - const fieldLabel = key === 'variableConfig.varName' - ? t($ => $['variableConfig.varName'], { ns: 'appDebug' }) - : key - const message = isVarKeyErrorKey(key) - ? t(varKeyErrorSelectors[key], { ns: 'appDebug', key: fieldLabel }) - : t($ => $['varKeyError.keyAlreadyExists'], { ns: 'appDebug', key: fieldLabel }) - - toast.error(message) - }, [t]) - - const handleMethodChange = useCallback((method: HttpMethod) => { - setInputs(updateMethod(inputs, method)) - }, [inputs, setInputs]) - - const handleContentTypeChange = useCallback((contentType: string) => { - setInputs(updateContentType({ - inputs, - id, - contentType, - isVarUsedInNodes, - removeUsedVarInNodes, - })) - }, [inputs, setInputs, id, isVarUsedInNodes, removeUsedVarInNodes]) - - const handleParamsChange = useCallback((params: WebhookParameter[]) => { - setInputs(updateSourceFields({ - inputs, - id, - sourceType: 'param', - nextData: params, - notifyError: notifyVarError, - isVarUsedInNodes, - removeUsedVarInNodes, - })) - }, [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs]) - - const handleHeadersChange = useCallback((headers: WebhookHeader[]) => { - setInputs(updateSourceFields({ - inputs, - id, - sourceType: 'header', - nextData: headers, - notifyError: notifyVarError, - isVarUsedInNodes, - removeUsedVarInNodes, - })) - }, [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs]) - - const handleBodyChange = useCallback((body: WebhookParameter[]) => { - setInputs(updateSourceFields({ - inputs, - id, - sourceType: 'body', - nextData: body, - notifyError: notifyVarError, - isVarUsedInNodes, - removeUsedVarInNodes, - })) - }, [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs]) - - const handleAsyncModeChange = useCallback((asyncMode: boolean) => { - setInputs(updateSimpleField(inputs, 'async_mode', asyncMode)) - }, [inputs, setInputs]) - - const handleStatusCodeChange = useCallback((statusCode: number) => { - setInputs(updateSimpleField(inputs, 'status_code', statusCode)) - }, [inputs, setInputs]) - - const handleResponseBodyChange = useCallback((responseBody: string) => { - setInputs(updateSimpleField(inputs, 'response_body', responseBody)) - }, [inputs, setInputs]) + const notifyVarError = useCallback( + (key: string) => { + const fieldLabel = + key === 'variableConfig.varName' + ? t(($) => $['variableConfig.varName'], { ns: 'appDebug' }) + : key + const message = isVarKeyErrorKey(key) + ? t(varKeyErrorSelectors[key], { ns: 'appDebug', key: fieldLabel }) + : t(($) => $['varKeyError.keyAlreadyExists'], { ns: 'appDebug', key: fieldLabel }) + + toast.error(message) + }, + [t], + ) + + const handleMethodChange = useCallback( + (method: HttpMethod) => { + setInputs(updateMethod(inputs, method)) + }, + [inputs, setInputs], + ) + + const handleContentTypeChange = useCallback( + (contentType: string) => { + setInputs( + updateContentType({ + inputs, + id, + contentType, + isVarUsedInNodes, + removeUsedVarInNodes, + }), + ) + }, + [inputs, setInputs, id, isVarUsedInNodes, removeUsedVarInNodes], + ) + + const handleParamsChange = useCallback( + (params: WebhookParameter[]) => { + setInputs( + updateSourceFields({ + inputs, + id, + sourceType: 'param', + nextData: params, + notifyError: notifyVarError, + isVarUsedInNodes, + removeUsedVarInNodes, + }), + ) + }, + [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs], + ) + + const handleHeadersChange = useCallback( + (headers: WebhookHeader[]) => { + setInputs( + updateSourceFields({ + inputs, + id, + sourceType: 'header', + nextData: headers, + notifyError: notifyVarError, + isVarUsedInNodes, + removeUsedVarInNodes, + }), + ) + }, + [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs], + ) + + const handleBodyChange = useCallback( + (body: WebhookParameter[]) => { + setInputs( + updateSourceFields({ + inputs, + id, + sourceType: 'body', + nextData: body, + notifyError: notifyVarError, + isVarUsedInNodes, + removeUsedVarInNodes, + }), + ) + }, + [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs], + ) + + const handleAsyncModeChange = useCallback( + (asyncMode: boolean) => { + setInputs(updateSimpleField(inputs, 'async_mode', asyncMode)) + }, + [inputs, setInputs], + ) + + const handleStatusCodeChange = useCallback( + (statusCode: number) => { + setInputs(updateSimpleField(inputs, 'status_code', statusCode)) + }, + [inputs, setInputs], + ) + + const handleResponseBodyChange = useCallback( + (responseBody: string) => { + setInputs(updateSimpleField(inputs, 'response_body', responseBody)) + }, + [inputs, setInputs], + ) const generateWebhookUrl = useCallback(async () => { // Idempotency: if we already have a URL, just return it. - if (inputs.webhook_url && inputs.webhook_url.length > 0) - return + if (inputs.webhook_url && inputs.webhook_url.length > 0) return - if (!appId) - return + if (!appId) return try { const response = await fetchWebhookUrl({ appId, nodeId: id }) setInputs(updateWebhookUrls(inputs, response.webhook_url, response.webhook_debug_url)) - } - catch (error: unknown) { + } catch (error: unknown) { console.error('Failed to generate webhook URL:', error) setInputs(updateWebhookUrls(inputs, '')) } diff --git a/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts b/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts index 89707cb24f3b95..84ad9058b115a9 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts @@ -1,14 +1,22 @@ import { VarType } from '@/app/components/workflow/types' // Constants for better maintainability and reusability -const BASIC_TYPES = [VarType.string, VarType.number, VarType.boolean, VarType.object, VarType.file] as const -const ARRAY_ELEMENT_TYPES = [VarType.arrayString, VarType.arrayNumber, VarType.arrayBoolean, VarType.arrayObject] as const +const BASIC_TYPES = [ + VarType.string, + VarType.number, + VarType.boolean, + VarType.object, + VarType.file, +] as const +const ARRAY_ELEMENT_TYPES = [ + VarType.arrayString, + VarType.arrayNumber, + VarType.arrayBoolean, + VarType.arrayObject, +] as const // Generate all valid parameter types programmatically -const VALID_PARAMETER_TYPES: readonly VarType[] = [ - ...BASIC_TYPES, - ...ARRAY_ELEMENT_TYPES, -] as const +const VALID_PARAMETER_TYPES: readonly VarType[] = [...BASIC_TYPES, ...ARRAY_ELEMENT_TYPES] as const // Type display name mappings const TYPE_DISPLAY_NAMES: Record<VarType, string> = { @@ -32,7 +40,7 @@ const TYPE_DISPLAY_NAMES: Record<VarType, string> = { // Content type configurations const CONTENT_TYPE_CONFIGS = { 'application/json': { - supportedTypes: [...BASIC_TYPES.filter(t => t !== 'file'), ...ARRAY_ELEMENT_TYPES], + supportedTypes: [...BASIC_TYPES.filter((t) => t !== 'file'), ...ARRAY_ELEMENT_TYPES], description: 'JSON supports all types including arrays', }, 'text/plain': { @@ -61,29 +69,20 @@ export const isValidParameterType = (type: string): type is VarType => { } export const normalizeParameterType = (input: string | undefined | null): VarType => { - if (!input || typeof input !== 'string') - return VarType.string + if (!input || typeof input !== 'string') return VarType.string const trimmed = input.trim().toLowerCase() - if (trimmed === 'array[string]') - return VarType.arrayString - else if (trimmed === 'array[number]') - return VarType.arrayNumber - else if (trimmed === 'array[boolean]') - return VarType.arrayBoolean - else if (trimmed === 'array[object]') - return VarType.arrayObject + if (trimmed === 'array[string]') return VarType.arrayString + else if (trimmed === 'array[number]') return VarType.arrayNumber + else if (trimmed === 'array[boolean]') return VarType.arrayBoolean + else if (trimmed === 'array[object]') return VarType.arrayObject else if (trimmed === 'array') // Migrate legacy 'array' type to 'array[string]' return VarType.arrayString - else if (trimmed === 'number') - return VarType.number - else if (trimmed === 'boolean') - return VarType.boolean - else if (trimmed === 'object') - return VarType.object - else if (trimmed === 'file') - return VarType.file + else if (trimmed === 'number') return VarType.number + else if (trimmed === 'boolean') return VarType.boolean + else if (trimmed === 'object') return VarType.object + else if (trimmed === 'file') return VarType.file return VarType.string } @@ -100,13 +99,13 @@ const getParameterTypeDisplayName = (type: VarType): string => { * Provides context-aware type filtering for different webhook content types */ const getAvailableParameterTypes = (contentType?: string): VarType[] => { - if (!contentType) - return [VarType.string, VarType.number, VarType.boolean] + if (!contentType) return [VarType.string, VarType.number, VarType.boolean] const normalizedContentType = (contentType || '').toLowerCase() - const configKey = normalizedContentType in CONTENT_TYPE_CONFIGS - ? normalizedContentType as keyof typeof CONTENT_TYPE_CONFIGS - : 'application/json' + const configKey = + normalizedContentType in CONTENT_TYPE_CONFIGS + ? (normalizedContentType as keyof typeof CONTENT_TYPE_CONFIGS) + : 'application/json' const config = CONTENT_TYPE_CONFIGS[configKey] return [...config.supportedTypes] @@ -118,7 +117,7 @@ const getAvailableParameterTypes = (contentType?: string): VarType[] => { export const createParameterTypeOptions = (contentType?: string) => { const availableTypes = getAvailableParameterTypes(contentType) - return availableTypes.map(type => ({ + return availableTypes.map((type) => ({ name: getParameterTypeDisplayName(type), value: type, })) diff --git a/web/app/components/workflow/nodes/trigger-webhook/utils/render-output-vars.tsx b/web/app/components/workflow/nodes/trigger-webhook/utils/render-output-vars.tsx index a0b0e4e8edeaa5..2103e544c36053 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/utils/render-output-vars.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/utils/render-output-vars.tsx @@ -29,10 +29,7 @@ const VarItem: FC<VarItemProps> = ({ prefix, name, type }) => { return ( <div className="py-1"> <div className="flex items-center leading-[18px]"> - <span className="code-sm-regular text-text-tertiary"> - {prefix} - . - </span> + <span className="code-sm-regular text-text-tertiary">{prefix}.</span> <span className="code-sm-semibold text-text-secondary">{name}</span> <span className="ml-2 system-xs-regular text-text-tertiary">{type}</span> </div> @@ -42,11 +39,7 @@ const VarItem: FC<VarItemProps> = ({ prefix, name, type }) => { export const OutputVariablesContent: FC<OutputVariablesContentProps> = ({ variables = [] }) => { if (!variables || variables.length === 0) { - return ( - <div className="py-2 system-sm-regular text-text-tertiary"> - No output variables - </div> - ) + return <div className="py-2 system-sm-regular text-text-tertiary">No output variables</div> } // Sort variables by label to match the table display order: param → header → body @@ -54,8 +47,10 @@ export const OutputVariablesContent: FC<OutputVariablesContentProps> = ({ variab const sortedVariables = [...variables].sort((a, b) => { const labelA = typeof a.label === 'string' ? a.label : '' const labelB = typeof b.label === 'string' ? b.label : '' - return (LABEL_ORDER[labelA as keyof typeof LABEL_ORDER] || 999) - - (LABEL_ORDER[labelB as keyof typeof LABEL_ORDER] || 999) + return ( + (LABEL_ORDER[labelA as keyof typeof LABEL_ORDER] || 999) - + (LABEL_ORDER[labelB as keyof typeof LABEL_ORDER] || 999) + ) }) return ( diff --git a/web/app/components/workflow/nodes/utils.ts b/web/app/components/workflow/nodes/utils.ts index 9e7b1ada6b3409..33647bf147187f 100644 --- a/web/app/components/workflow/nodes/utils.ts +++ b/web/app/components/workflow/nodes/utils.ts @@ -1,40 +1,36 @@ -import type { - NodeOutPutVar, - ValueSelector, -} from '@/app/components/workflow/types' +import type { NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' import { InputVarType } from '@/app/components/workflow/types' -export const findVariableWhenOnLLMVision = (valueSelector: ValueSelector, availableVars: NodeOutPutVar[]) => { +export const findVariableWhenOnLLMVision = ( + valueSelector: ValueSelector, + availableVars: NodeOutPutVar[], +) => { const currentVariableNode = availableVars.find((availableVar) => { - if (valueSelector[0] === 'sys' && availableVar.isStartNode) - return true + if (valueSelector[0] === 'sys' && availableVar.isStartNode) return true return valueSelector[0] === availableVar.nodeId }) const currentVariable = currentVariableNode?.vars.find((variable) => { - if (valueSelector[0] === 'sys' && variable.variable === `sys.${valueSelector[1]}`) - return true + if (valueSelector[0] === 'sys' && variable.variable === `sys.${valueSelector[1]}`) return true return variable.variable === valueSelector[1] }) let formType = '' - if (currentVariable?.type === 'array[file]') - formType = InputVarType.multiFiles - if (currentVariable?.type === 'file') - formType = InputVarType.singleFile + if (currentVariable?.type === 'array[file]') formType = InputVarType.multiFiles + if (currentVariable?.type === 'file') formType = InputVarType.singleFile - return currentVariable && { - ...currentVariable, - formType, - } + return ( + currentVariable && { + ...currentVariable, + formType, + } + ) } export const getConditionValueAsString = (condition: { value: any }) => { - if (Array.isArray(condition.value)) - return condition.value[0] ?? '' + if (Array.isArray(condition.value)) return condition.value[0] ?? '' - if (typeof condition.value === 'number') - return String(condition.value) + if (typeof condition.value === 'number') return String(condition.value) return condition.value ?? '' } diff --git a/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts b/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts index a93ebd4ab0c2b8..a635389c0fc9b3 100644 --- a/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts +++ b/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts @@ -31,11 +31,12 @@ vi.mock('@/app/components/workflow/store', () => ({ })) vi.mock('@/app/components/workflow/hooks-store/store', () => ({ - useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => selector({ - configsMap: { - flowType: mockFlowType.value, - }, - }), + useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => + selector({ + configsMap: { + flowType: mockFlowType.value, + }, + }), })) describe('variable-assigner/hooks', () => { @@ -48,20 +49,24 @@ describe('variable-assigner/hooks', () => { beforeEach(() => { vi.clearAllMocks() mockFlowType.value = undefined - getNodes.mockReturnValue([{ - id: 'assigner-1', - data: { - variables: [['start', 'foo']], - output_type: VarType.string, - advanced_settings: { - groups: [{ - groupId: 'group-1', - variables: [], - output_type: VarType.string, - }], + getNodes.mockReturnValue([ + { + id: 'assigner-1', + data: { + variables: [['start', 'foo']], + output_type: VarType.string, + advanced_settings: { + groups: [ + { + groupId: 'group-1', + variables: [], + output_type: VarType.string, + }, + ], + }, }, }, - }]) + ]) mockUseStoreApi.mockReturnValue({ getState: () => ({ getNodes, @@ -92,9 +97,18 @@ describe('variable-assigner/hooks', () => { const { result } = renderHook(() => useVariableAssigner()) act(() => { - result.current.handleAssignVariableValueChange('assigner-1', ['start', 'bar'], { type: VarType.number } as never) - result.current.handleAssignVariableValueChange('assigner-1', ['start', 'foo'], { type: VarType.number } as never) - result.current.handleAssignVariableValueChange('assigner-1', ['start', 'grouped'], { type: VarType.arrayString } as never, 'group-1') + result.current.handleAssignVariableValueChange('assigner-1', ['start', 'bar'], { + type: VarType.number, + } as never) + result.current.handleAssignVariableValueChange('assigner-1', ['start', 'foo'], { + type: VarType.number, + } as never) + result.current.handleAssignVariableValueChange( + 'assigner-1', + ['start', 'grouped'], + { type: VarType.arrayString } as never, + 'group-1', + ) }) expect(mockHandleNodeDataUpdate).toHaveBeenNthCalledWith(1, { @@ -111,11 +125,13 @@ describe('variable-assigner/hooks', () => { id: 'assigner-1', data: { advanced_settings: { - groups: [{ - groupId: 'group-1', - variables: [['start', 'grouped']], - output_type: VarType.arrayString, - }], + groups: [ + { + groupId: 'group-1', + variables: [['start', 'grouped']], + output_type: VarType.arrayString, + }, + ], }, }, }) @@ -136,10 +152,12 @@ describe('variable-assigner/hooks', () => { data: { variables: [], advanced_settings: { - groups: [{ - groupId: 'group-1', - variables: [], - }], + groups: [ + { + groupId: 'group-1', + variables: [], + }, + ], }, _showAddVariablePopup: true, _holdAddVariablePopup: true, @@ -180,11 +198,13 @@ describe('variable-assigner/hooks', () => { id: 'assigner-1', data: { advanced_settings: { - groups: [{ - groupId: 'group-1', - variables: [['start', 'output']], - output_type: VarType.object, - }], + groups: [ + { + groupId: 'group-1', + variables: [['start', 'output']], + output_type: VarType.object, + }, + ], }, }, }) @@ -219,21 +239,24 @@ describe('variable-assigner/hooks', () => { { id: 'before-1' }, { id: 'before-1' }, ]) - const getNodeAvailableVars = vi.fn() - .mockReturnValueOnce([{ - isStartNode: true, - vars: [ - { variable: 'sys.user_id' }, - { variable: 'foo' }, - ], - }, { - isStartNode: false, - vars: [], - }]) - .mockReturnValueOnce([{ - isStartNode: false, - vars: [{ variable: 'bar' }], - }]) + const getNodeAvailableVars = vi + .fn() + .mockReturnValueOnce([ + { + isStartNode: true, + vars: [{ variable: 'sys.user_id' }, { variable: 'foo' }], + }, + { + isStartNode: false, + vars: [], + }, + ]) + .mockReturnValueOnce([ + { + isStartNode: false, + vars: [{ variable: 'bar' }], + }, + ]) mockUseWorkflow.mockReturnValue({ getBeforeNodesInSameBranchIncludeParent, @@ -244,14 +267,18 @@ describe('variable-assigner/hooks', () => { const { result } = renderHook(() => useGetAvailableVars()) - expect(result.current('current-node', 'target', () => true, true)).toEqual([{ - isStartNode: true, - vars: [{ variable: 'foo' }], - }]) - expect(result.current('current-node', 'target', () => true, false)).toEqual([{ - isStartNode: false, - vars: [{ variable: 'bar' }], - }]) + expect(result.current('current-node', 'target', () => true, true)).toEqual([ + { + isStartNode: true, + vars: [{ variable: 'foo' }], + }, + ]) + expect(result.current('current-node', 'target', () => true, false)).toEqual([ + { + isStartNode: false, + vars: [{ variable: 'bar' }], + }, + ]) expect(result.current('missing-node', 'target', () => true)).toEqual([]) }) @@ -266,13 +293,12 @@ describe('variable-assigner/hooks', () => { }, ]) const getBeforeNodesInSameBranchIncludeParent = vi.fn(() => [{ id: 'before-1' }]) - const getNodeAvailableVars = vi.fn(() => [{ - isStartNode: false, - vars: [ - { variable: 'sys.user_id' }, - { variable: 'answer' }, - ], - }]) + const getNodeAvailableVars = vi.fn(() => [ + { + isStartNode: false, + vars: [{ variable: 'sys.user_id' }, { variable: 'answer' }], + }, + ]) mockUseWorkflow.mockReturnValue({ getBeforeNodesInSameBranchIncludeParent, @@ -283,9 +309,11 @@ describe('variable-assigner/hooks', () => { const { result } = renderHook(() => useGetAvailableVars()) - expect(result.current('current-node', 'target', () => true, false)).toEqual([{ - isStartNode: false, - vars: [{ variable: 'answer' }], - }]) + expect(result.current('current-node', 'target', () => true, false)).toEqual([ + { + isStartNode: false, + vars: [{ variable: 'answer' }], + }, + ]) }) }) diff --git a/web/app/components/workflow/nodes/variable-assigner/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/variable-assigner/__tests__/node.spec.tsx index 7c11747154f7cd..74709b66fe82d7 100644 --- a/web/app/components/workflow/nodes/variable-assigner/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/__tests__/node.spec.tsx @@ -22,13 +22,15 @@ vi.mock('../components/node-group-item', () => ({ mockNodeGroupItemRender(props) return ( <div> - {`${props.item.title}:${props.item.targetHandleId}:${props.item.type}:${props.item.variables.map(variable => variable.join('.')).join('|')}`} + {`${props.item.title}:${props.item.targetHandleId}:${props.item.type}:${props.item.variables.map((variable) => variable.join('.')).join('|')}`} </div> ) }, })) -const createData = (overrides: Partial<VariableAssignerNodeType> = {}): VariableAssignerNodeType => ({ +const createData = ( + overrides: Partial<VariableAssignerNodeType> = {}, +): VariableAssignerNodeType => ({ title: 'Variable Assigner', desc: '', type: BlockEnum.VariableAssigner, @@ -72,12 +74,7 @@ describe('variable-assigner/node', () => { }) it('renders grouped node cards when aggregation is enabled', () => { - render( - <Node - {...baseNodeProps} - data={createData()} - />, - ) + render(<Node {...baseNodeProps} data={createData()} />) expect(screen.getByText('Group1:group-1:string:source-node.groupVar')).toBeInTheDocument() expect(screen.getByText('Group2:group-2:number:')).toBeInTheDocument() @@ -97,13 +94,17 @@ describe('variable-assigner/node', () => { />, ) - expect(screen.getByText('workflow.nodes.variableAssigner.title:target:string:source-node.rootVar')).toBeInTheDocument() - expect(mockNodeGroupItemRender).toHaveBeenCalledWith(expect.objectContaining({ - item: expect.objectContaining({ - groupEnabled: false, - targetHandleId: 'target', - variableAssignerNodeId: 'assigner-node', + expect( + screen.getByText('workflow.nodes.variableAssigner.title:target:string:source-node.rootVar'), + ).toBeInTheDocument() + expect(mockNodeGroupItemRender).toHaveBeenCalledWith( + expect.objectContaining({ + item: expect.objectContaining({ + groupEnabled: false, + targetHandleId: 'target', + variableAssignerNodeId: 'assigner-node', + }), }), - })) + ) }) }) diff --git a/web/app/components/workflow/nodes/variable-assigner/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/variable-assigner/__tests__/panel.spec.tsx index 8fdef00ef2c070..c6ba0fb4c37a16 100644 --- a/web/app/components/workflow/nodes/variable-assigner/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/__tests__/panel.spec.tsx @@ -26,14 +26,16 @@ vi.mock('../components/var-group-item', () => ({ __esModule: true, default: (props: MockVarGroupItemProps) => { mockVarGroupItemRender(props) - return <div>{`${props.payload.group_name || 'root'}:${props.payload.output_type}:${props.groupEnabled}`}</div> + return ( + <div>{`${props.payload.group_name || 'root'}:${props.payload.output_type}:${props.groupEnabled}`}</div> + ) }, })) vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ __esModule: true, default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - VarItem: ({ name, type, description }: { name: string, type: string, description: string }) => ( + VarItem: ({ name, type, description }: { name: string; type: string; description: string }) => ( <div>{`${name}:${type}:${description}`}</div> ), })) @@ -48,17 +50,22 @@ vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-conf isShow: boolean onCancel: () => void onConfirm: () => void - }) => isShow - ? ( - <div> - <button type="button" onClick={onCancel}>cancel-remove</button> - <button type="button" onClick={onConfirm}>confirm-remove</button> - </div> - ) - : null, + }) => + isShow ? ( + <div> + <button type="button" onClick={onCancel}> + cancel-remove + </button> + <button type="button" onClick={onConfirm}> + confirm-remove + </button> + </div> + ) : null, })) -const createData = (overrides: Partial<VariableAssignerNodeType> = {}): VariableAssignerNodeType => ({ +const createData = ( + overrides: Partial<VariableAssignerNodeType> = {}, +): VariableAssignerNodeType => ({ title: 'Variable Assigner', desc: '', type: BlockEnum.VariableAssigner, @@ -115,18 +122,20 @@ describe('variable-assigner/panel', () => { it('renders grouped panels, output vars, and confirm actions when aggregation is enabled', async () => { const user = userEvent.setup() - render( - <Panel - id="assigner-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<Panel id="assigner-node" data={createData()} panelProps={panelProps} />) expect(screen.getByText('Group1:string:true')).toBeInTheDocument() expect(screen.getByText('Group2:number:true')).toBeInTheDocument() - expect(screen.getByText(/Group1\.output:string:workflow\.nodes\.variableAssigner\.outputVars\.varDescribe/)).toBeInTheDocument() - expect(screen.getByText(/Group2\.output:number:workflow\.nodes\.variableAssigner\.outputVars\.varDescribe/)).toBeInTheDocument() + expect( + screen.getByText( + /Group1\.output:string:workflow\.nodes\.variableAssigner\.outputVars\.varDescribe/, + ), + ).toBeInTheDocument() + expect( + screen.getByText( + /Group2\.output:number:workflow\.nodes\.variableAssigner\.outputVars\.varDescribe/, + ), + ).toBeInTheDocument() await user.click(screen.getByRole('switch')) await user.click(screen.getByText('workflow.nodes.variableAssigner.addGroup')) @@ -162,16 +171,14 @@ describe('variable-assigner/panel', () => { filterVar: vi.fn(() => vi.fn(() => true)), }) - render( - <Panel - id="assigner-node" - data={createData()} - panelProps={panelProps} - />, - ) + render(<Panel id="assigner-node" data={createData()} panelProps={panelProps} />) expect(screen.getByText('root:string:false')).toBeInTheDocument() expect(screen.getByText('workflow.nodes.variableAssigner.aggregationGroup')).toBeInTheDocument() - expect(screen.queryByText(/Group1\.output:string:workflow\.nodes\.variableAssigner\.outputVars\.varDescribe/)).not.toBeInTheDocument() + expect( + screen.queryByText( + /Group1\.output:string:workflow\.nodes\.variableAssigner\.outputVars\.varDescribe/, + ), + ).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx index cb8c2db52f9528..467f8040036c81 100644 --- a/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx @@ -65,7 +65,9 @@ vi.mock('../hooks', () => ({ useGetAvailableVars: () => mockGetAvailableVars, })) -const createPayload = (overrides: Partial<VariableAssignerNodeType> = {}): VariableAssignerNodeType => ({ +const createPayload = ( + overrides: Partial<VariableAssignerNodeType> = {}, +): VariableAssignerNodeType => ({ title: 'Variable Assigner', desc: '', type: BlockEnum.VariableAssigner, @@ -129,22 +131,26 @@ describe('useConfig', () => { variables: [['source-node', 'groupVar']], }) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - output_type: VarType.number, - variables: [['source-node', 'changed']], - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - advanced_settings: expect.objectContaining({ - groups: [ - expect.objectContaining({ - groupId: 'group-1', - output_type: VarType.boolean, - variables: [['source-node', 'groupVar']], - }), - expect.anything(), - ], + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + output_type: VarType.number, + variables: [['source-node', 'changed']], + }), + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + advanced_settings: expect.objectContaining({ + groups: [ + expect.objectContaining({ + groupId: 'group-1', + output_type: VarType.boolean, + variables: [['source-node', 'groupVar']], + }), + expect.anything(), + ], + }), }), - })) + ) }) it('should add and remove groups and toggle group mode', () => { @@ -154,30 +160,34 @@ describe('useConfig', () => { result.current.handleGroupRemoved('group-2')() result.current.handleGroupEnabledChange(false) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - advanced_settings: expect.objectContaining({ - groups: expect.arrayContaining([ - expect.objectContaining({ - groupId: 'generated-group-id', - group_name: 'Group3', - }), - ]), + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + advanced_settings: expect.objectContaining({ + groups: expect.arrayContaining([ + expect.objectContaining({ + groupId: 'generated-group-id', + group_name: 'Group3', + }), + ]), + }), }), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - advanced_settings: expect.objectContaining({ - groups: [ - expect.objectContaining({ groupId: 'group-1' }), - ], + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + advanced_settings: expect.objectContaining({ + groups: [expect.objectContaining({ groupId: 'group-1' })], + }), }), - })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - advanced_settings: expect.objectContaining({ - group_enabled: false, + ) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + advanced_settings: expect.objectContaining({ + group_enabled: false, + }), + output_type: VarType.string, + variables: [['source-node', 'initialVar']], }), - output_type: VarType.string, - variables: [['source-node', 'initialVar']], - })) + ) expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('assigner-node') expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith( 'assigner-node', @@ -192,17 +202,19 @@ describe('useConfig', () => { result.current.handleVarGroupNameChange('group-1')('Renamed') result.current.onRemoveVarConfirm() - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - advanced_settings: expect.objectContaining({ - groups: [ - expect.objectContaining({ - groupId: 'group-1', - group_name: 'Renamed', - }), - expect.anything(), - ], + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + advanced_settings: expect.objectContaining({ + groups: [ + expect.objectContaining({ + groupId: 'group-1', + group_name: 'Renamed', + }), + expect.anything(), + ], + }), }), - })) + ) expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith( 'assigner-node', ['assigner-node', 'Group1', 'output'], @@ -212,7 +224,7 @@ describe('useConfig', () => { }) it('should confirm removing a used group before deleting it', () => { - mockIsVarUsedInNodes.mockImplementation(selector => selector[1] === 'Group2') + mockIsVarUsedInNodes.mockImplementation((selector) => selector[1] === 'Group2') const { result } = renderHook(() => useConfig('assigner-node', createPayload())) act(() => { @@ -223,20 +235,27 @@ describe('useConfig', () => { }) expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['assigner-node', 'Group2', 'output']) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - advanced_settings: expect.objectContaining({ - groups: [expect.objectContaining({ groupId: 'group-1' })], + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + advanced_settings: expect.objectContaining({ + groups: [expect.objectContaining({ groupId: 'group-1' })], + }), }), - })) + ) }) it('should enable empty groups and confirm disabling when downstream vars are used', () => { - const { result: enableResult } = renderHook(() => useConfig('assigner-node', createPayload({ - advanced_settings: { - group_enabled: false, - groups: [], - }, - }))) + const { result: enableResult } = renderHook(() => + useConfig( + 'assigner-node', + createPayload({ + advanced_settings: { + group_enabled: false, + groups: [], + }, + }), + ), + ) enableResult.current.handleGroupEnabledChange(true) @@ -246,7 +265,7 @@ describe('useConfig', () => { ['assigner-node', 'Group1', 'output'], ) - mockIsVarUsedInNodes.mockImplementation(selector => selector[1] === 'Group2') + mockIsVarUsedInNodes.mockImplementation((selector) => selector[1] === 'Group2') const { result } = renderHook(() => useConfig('assigner-node', createPayload())) act(() => { @@ -257,13 +276,17 @@ describe('useConfig', () => { }) expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['assigner-node', 'Group2', 'output']) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - advanced_settings: expect.objectContaining({ group_enabled: false }), - })) + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + advanced_settings: expect.objectContaining({ group_enabled: false }), + }), + ) }) it('should not throw when enabling groups with missing advanced settings', () => { - const { result } = renderHook(() => useConfig('assigner-node', createPayloadWithoutAdvancedSettings())) + const { result } = renderHook(() => + useConfig('assigner-node', createPayloadWithoutAdvancedSettings()), + ) expect(() => { result.current.handleGroupEnabledChange(true) @@ -274,12 +297,14 @@ describe('useConfig', () => { ['assigner-node', 'output'], ['assigner-node', 'Group1', 'output'], ) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ - advanced_settings: expect.objectContaining({ - group_enabled: true, - groups: [expect.objectContaining({ group_name: 'Group1' })], + expect(mockSetInputs).toHaveBeenCalledWith( + expect.objectContaining({ + advanced_settings: expect.objectContaining({ + group_enabled: true, + groups: [expect.objectContaining({ group_name: 'Group1' })], + }), }), - })) + ) expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('assigner-node') }) }) diff --git a/web/app/components/workflow/nodes/variable-assigner/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/variable-assigner/__tests__/use-single-run-form-params.spec.ts index acb5c8204f744f..e4d6961f220683 100644 --- a/web/app/components/workflow/nodes/variable-assigner/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/variable-assigner/__tests__/use-single-run-form-params.spec.ts @@ -4,7 +4,9 @@ import { renderHook } from '@testing-library/react' import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types' import useSingleRunFormParams from '../use-single-run-form-params' -const createData = (overrides: Partial<VariableAssignerNodeType> = {}): VariableAssignerNodeType => ({ +const createData = ( + overrides: Partial<VariableAssignerNodeType> = {}, +): VariableAssignerNodeType => ({ title: 'Variable Assigner', desc: '', type: BlockEnum.VariableAssigner, @@ -17,7 +19,10 @@ const createData = (overrides: Partial<VariableAssignerNodeType> = {}): Variable groupId: 'group-1', group_name: 'Group1', output_type: VarType.string, - variables: [['source-node', 'sharedVar'], ['source-node', 'uniqueVar']], + variables: [ + ['source-node', 'sharedVar'], + ['source-node', 'uniqueVar'], + ], }, { groupId: 'group-2', @@ -54,16 +59,18 @@ describe('variable-assigner/use-single-run-form-params', () => { }, ]) - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'assigner-node', - payload: createData(), - runInputData: { sharedVar: 'hello' }, - runInputDataRef: { current: {} }, - getInputVars: () => [], - setRunInputData, - toVarInputs: () => [], - varSelectorsToVarInputs, - })) + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'assigner-node', + payload: createData(), + runInputData: { sharedVar: 'hello' }, + runInputDataRef: { current: {} }, + getInputVars: () => [], + setRunInputData, + toVarInputs: () => [], + varSelectorsToVarInputs, + }), + ) expect(varSelectorsToVarInputs).toHaveBeenCalledWith([ ['source-node', 'sharedVar'], @@ -89,31 +96,34 @@ describe('variable-assigner/use-single-run-form-params', () => { expect(setRunInputData).toHaveBeenCalledWith({ sharedVar: 'updated' }) expect(result.current.getDependentVars()).toEqual([ - [['source-node', 'sharedVar'], ['source-node', 'uniqueVar']], + [ + ['source-node', 'sharedVar'], + ['source-node', 'uniqueVar'], + ], [['source-node', 'sharedVar']], ]) }) it('returns root variables directly when grouping is disabled', () => { - const { result } = renderHook(() => useSingleRunFormParams({ - id: 'assigner-node', - payload: createData({ - advanced_settings: { - group_enabled: false, - groups: [], - }, - variables: [['source-node', 'rootVar']], + const { result } = renderHook(() => + useSingleRunFormParams({ + id: 'assigner-node', + payload: createData({ + advanced_settings: { + group_enabled: false, + groups: [], + }, + variables: [['source-node', 'rootVar']], + }), + runInputData: {}, + runInputDataRef: { current: {} }, + getInputVars: () => [], + setRunInputData: vi.fn(), + toVarInputs: () => [], + varSelectorsToVarInputs: () => [], }), - runInputData: {}, - runInputDataRef: { current: {} }, - getInputVars: () => [], - setRunInputData: vi.fn(), - toVarInputs: () => [], - varSelectorsToVarInputs: () => [], - })) + ) - expect(result.current.getDependentVars()).toEqual([ - [['source-node', 'rootVar']], - ]) + expect(result.current.getDependentVars()).toEqual([[['source-node', 'rootVar']]]) }) }) diff --git a/web/app/components/workflow/nodes/variable-assigner/components/__tests__/node-group-item.spec.tsx b/web/app/components/workflow/nodes/variable-assigner/components/__tests__/node-group-item.spec.tsx index 4f400db572ceaa..59c4e71e9920aa 100644 --- a/web/app/components/workflow/nodes/variable-assigner/components/__tests__/node-group-item.spec.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/components/__tests__/node-group-item.spec.tsx @@ -1,5 +1,9 @@ import { fireEvent, screen } from '@testing-library/react' -import { createNode, createStartNode, resetFixtureCounters } from '@/app/components/workflow/__tests__/fixtures' +import { + createNode, + createStartNode, + resetFixtureCounters, +} from '@/app/components/workflow/__tests__/fixtures' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { BlockEnum, VarType } from '@/app/components/workflow/types' import NodeGroupItem from '../node-group-item' @@ -124,7 +128,10 @@ describe('variable-assigner/node-group-item', () => { targetHandleId: 'group-2', title: 'Group B', type: 'number', - variables: [['sys', 'query'], ['source-node', 'answer']], + variables: [ + ['sys', 'query'], + ['source-node', 'answer'], + ], variableAssignerNodeId: 'assigner-node', variableAssignerNodeData: data, }} diff --git a/web/app/components/workflow/nodes/variable-assigner/components/__tests__/var-group-item.branches.spec.tsx b/web/app/components/workflow/nodes/variable-assigner/components/__tests__/var-group-item.branches.spec.tsx index fd2128342a6f20..3c23534cfba634 100644 --- a/web/app/components/workflow/nodes/variable-assigner/components/__tests__/var-group-item.branches.spec.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/components/__tests__/var-group-item.branches.spec.tsx @@ -19,19 +19,23 @@ vi.mock('../../../_base/components/variable/var-reference-picker', () => ({ <div> <button type="button" - onClick={() => props.onChange(['node-a', 'answer'], 'variable', { - variable: 'answer', - type: VarType.string, - })} + onClick={() => + props.onChange(['node-a', 'answer'], 'variable', { + variable: 'answer', + type: VarType.string, + }) + } > add-string-var </button> <button type="button" - onClick={() => props.onChange(['node-a', 'answer'], 'variable', { - variable: 'answer', - type: VarType.string, - })} + onClick={() => + props.onChange(['node-a', 'answer'], 'variable', { + variable: 'answer', + type: VarType.string, + }) + } > add-duplicate-var </button> @@ -42,25 +46,25 @@ vi.mock('../../../_base/components/variable/var-reference-picker', () => ({ vi.mock('../var-list', () => ({ __esModule: true, - default: (props: { - onChange: (list: string[][], changedItem?: string[]) => void - }) => { + default: (props: { onChange: (list: string[][], changedItem?: string[]) => void }) => { mockVarListRender(props) return ( <div> <button type="button" - onClick={() => props.onChange([ - ['node-a', 'flag'], - ['node-a', 'flag'], - ], ['node-a', 'flag'])} + onClick={() => + props.onChange( + [ + ['node-a', 'flag'], + ['node-a', 'flag'], + ], + ['node-a', 'flag'], + ) + } > replace-with-duplicate </button> - <button - type="button" - onClick={() => props.onChange([], undefined)} - > + <button type="button" onClick={() => props.onChange([], undefined)}> clear-vars </button> </div> @@ -111,10 +115,12 @@ describe('variable-assigner/var-group-item branches', () => { await user.click(screen.getByRole('button', { name: 'add-string-var' })) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - variables: [['node-a', 'answer']], - output_type: VarType.string, - })) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + variables: [['node-a', 'answer']], + output_type: VarType.string, + }), + ) }) it('ignores duplicate additions and only accepts matching types when the output type is fixed', async () => { @@ -141,7 +147,10 @@ describe('variable-assigner/var-group-item branches', () => { const { onChange } = renderGroupItem({ payload: createPayload({ output_type: VarType.string, - variables: [['node-a', 'answer'], ['node-a', 'flag']], + variables: [ + ['node-a', 'answer'], + ['node-a', 'flag'], + ], }), }) @@ -161,9 +170,11 @@ describe('variable-assigner/var-group-item branches', () => { await user.click(screen.getByRole('button', { name: 'clear-vars' })) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - variables: [], - output_type: VarType.any, - })) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + variables: [], + output_type: VarType.any, + }), + ) }) }) diff --git a/web/app/components/workflow/nodes/variable-assigner/components/add-variable/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/variable-assigner/components/add-variable/__tests__/index.spec.tsx index fe91863afd9013..f588b6acf4329a 100644 --- a/web/app/components/workflow/nodes/variable-assigner/components/add-variable/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/components/add-variable/__tests__/index.spec.tsx @@ -12,14 +12,18 @@ vi.mock('../../../hooks', () => ({ }), })) -const availableVars: NodeOutPutVar[] = [{ - nodeId: 'node-source', - title: 'Source Node', - vars: [{ - variable: 'answer', - type: VarType.string, - }], -}] +const availableVars: NodeOutPutVar[] = [ + { + nodeId: 'node-source', + title: 'Source Node', + vars: [ + { + variable: 'answer', + type: VarType.string, + }, + ], + }, +] const nodeData: VariableAssignerNodeType = { title: 'Variable Assigner', @@ -51,7 +55,9 @@ describe('variable-assigner/add-variable', () => { const trigger = container.querySelector('div[class*="group/addvariable"]') fireEvent.click(trigger as HTMLElement) - expect(screen.getByText('workflow.nodes.variableAssigner.setAssignVariable')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.variableAssigner.setAssignVariable'), + ).toBeInTheDocument() fireEvent.click(screen.getByText('answer')) diff --git a/web/app/components/workflow/nodes/variable-assigner/components/add-variable/index.tsx b/web/app/components/workflow/nodes/variable-assigner/components/add-variable/index.tsx index 7388fa64d64dff..ff546beffbfb9b 100644 --- a/web/app/components/workflow/nodes/variable-assigner/components/add-variable/index.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/components/add-variable/index.tsx @@ -1,16 +1,8 @@ import type { VariableAssignerNodeType } from '../../types' -import type { - NodeOutPutVar, - ValueSelector, - Var, -} from '@/app/components/workflow/types' +import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo, useCallback, useState } from 'react' import { Plus02 } from '@/app/components/base/icons/src/vender/line/general' import AddVariablePopup from '@/app/components/workflow/nodes/_base/components/add-variable-popup' import { useVariableAssigner } from '../../hooks' @@ -30,28 +22,19 @@ const AddVariable = ({ const [open, setOpen] = useState(false) const { handleAssignVariableValueChange } = useVariableAssigner() - const handleSelectVariable = useCallback((v: ValueSelector, varDetail: Var) => { - handleAssignVariableValueChange( - variableAssignerNodeId, - v, - varDetail, - handleId, - ) - setOpen(false) - }, [handleAssignVariableValueChange, variableAssignerNodeId, handleId, setOpen]) + const handleSelectVariable = useCallback( + (v: ValueSelector, varDetail: Var) => { + handleAssignVariableValueChange(variableAssignerNodeId, v, varDetail, handleId) + setOpen(false) + }, + [handleAssignVariableValueChange, variableAssignerNodeId, handleId, setOpen], + ) return ( - <div className={cn( - open && 'flex!', - variableAssignerNodeData.selected && 'flex!', - )} - > - <Popover - open={open} - onOpenChange={setOpen} - > + <div className={cn(open && 'flex!', variableAssignerNodeData.selected && 'flex!')}> + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger - render={( + render={ <button type="button" className="block border-none bg-transparent p-0"> <div className={cn( @@ -70,17 +53,14 @@ const AddVariable = ({ /> </div> </button> - )} + } /> <PopoverContent placement="right" sideOffset={4} popupClassName="border-none bg-transparent shadow-none" > - <AddVariablePopup - onSelect={handleSelectVariable} - availableVars={availableVars} - /> + <AddVariablePopup onSelect={handleSelectVariable} availableVars={availableVars} /> </PopoverContent> </Popover> </div> diff --git a/web/app/components/workflow/nodes/variable-assigner/components/node-group-item.tsx b/web/app/components/workflow/nodes/variable-assigner/components/node-group-item.tsx index f1aa97e513819f..f50ebb07bd8701 100644 --- a/web/app/components/workflow/nodes/variable-assigner/components/node-group-item.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/components/node-group-item.tsx @@ -1,27 +1,15 @@ -import type { - Node, - ValueSelector, - VarType, -} from '../../../types' +import type { Node, ValueSelector, VarType } from '../../../types' import type { VariableAssignerNodeType } from '../types' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import { isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' -import { - VariableLabelInNode, -} from '@/app/components/workflow/nodes/_base/components/variable/variable-label' +import { VariableLabelInNode } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { isExceptionVariable } from '@/app/components/workflow/utils' import { useStore } from '../../../store' import { BlockEnum } from '../../../types' -import { - useGetAvailableVars, - useVariableAssigner, -} from '../hooks' +import { useGetAvailableVars, useVariableAssigner } from '../hooks' import { filterVar } from '../utils' import AddVariable from './add-variable' @@ -38,47 +26,66 @@ type GroupItem = { type NodeGroupItemProps = { item: GroupItem } -const NodeGroupItem = ({ - item, -}: NodeGroupItemProps) => { +const NodeGroupItem = ({ item }: NodeGroupItemProps) => { const { t } = useTranslation() - const enteringNodePayload = useStore(s => s.enteringNodePayload) - const hoveringAssignVariableGroupId = useStore(s => s.hoveringAssignVariableGroupId) + const enteringNodePayload = useStore((s) => s.enteringNodePayload) + const hoveringAssignVariableGroupId = useStore((s) => s.hoveringAssignVariableGroupId) const nodes: Node[] = useNodes() - const { - handleGroupItemMouseEnter, - handleGroupItemMouseLeave, - } = useVariableAssigner() + const { handleGroupItemMouseEnter, handleGroupItemMouseLeave } = useVariableAssigner() const getAvailableVars = useGetAvailableVars() const groupEnabled = item.groupEnabled const outputType = useMemo(() => { - if (!groupEnabled) - return item.variableAssignerNodeData.output_type + if (!groupEnabled) return item.variableAssignerNodeData.output_type - const group = item.variableAssignerNodeData.advanced_settings?.groups.find(group => group.groupId === item.targetHandleId) + const group = item.variableAssignerNodeData.advanced_settings?.groups.find( + (group) => group.groupId === item.targetHandleId, + ) return group?.output_type || '' }, [item.variableAssignerNodeData, item.targetHandleId, groupEnabled]) - const availableVars = getAvailableVars(item.variableAssignerNodeId, item.targetHandleId, filterVar(outputType as VarType), true) + const availableVars = getAvailableVars( + item.variableAssignerNodeId, + item.targetHandleId, + filterVar(outputType as VarType), + true, + ) const showSelectionBorder = useMemo(() => { if (groupEnabled && enteringNodePayload?.nodeId === item.variableAssignerNodeId) { if (hoveringAssignVariableGroupId) return hoveringAssignVariableGroupId !== item.targetHandleId else - return enteringNodePayload?.nodeData.advanced_settings?.groups[0]!.groupId !== item.targetHandleId + return ( + enteringNodePayload?.nodeData.advanced_settings?.groups[0]!.groupId !== + item.targetHandleId + ) } return false - }, [enteringNodePayload, groupEnabled, hoveringAssignVariableGroupId, item.targetHandleId, item.variableAssignerNodeId]) + }, [ + enteringNodePayload, + groupEnabled, + hoveringAssignVariableGroupId, + item.targetHandleId, + item.variableAssignerNodeId, + ]) const showSelectedBorder = useMemo(() => { if (groupEnabled && enteringNodePayload?.nodeId === item.variableAssignerNodeId) { if (hoveringAssignVariableGroupId) return hoveringAssignVariableGroupId === item.targetHandleId else - return enteringNodePayload?.nodeData.advanced_settings?.groups[0]!.groupId === item.targetHandleId + return ( + enteringNodePayload?.nodeData.advanced_settings?.groups[0]!.groupId === + item.targetHandleId + ) } return false - }, [enteringNodePayload, groupEnabled, hoveringAssignVariableGroupId, item.targetHandleId, item.variableAssignerNodeId]) + }, [ + enteringNodePayload, + groupEnabled, + hoveringAssignVariableGroupId, + item.targetHandleId, + item.variableAssignerNodeId, + ]) return ( <div @@ -92,10 +99,7 @@ const NodeGroupItem = ({ > <div className="flex h-4 items-center justify-between text-[10px] font-medium text-text-tertiary"> <span - className={cn( - 'grow truncate uppercase', - showSelectedBorder && 'text-text-accent', - )} + className={cn('grow truncate uppercase', showSelectedBorder && 'text-text-accent')} title={item.title} > {item.title} @@ -111,43 +115,41 @@ const NodeGroupItem = ({ /> </div> </div> - { - !item.variables.length && ( - <div - className={cn( - 'relative flex h-[22px] items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-[10px] font-normal text-text-tertiary uppercase', - (showSelectedBorder || showSelectionBorder) && 'bg-black/2!', - )} - > - {t($ => $[`${i18nPrefix}.varNotSet`], { ns: 'workflow' })} - </div> - ) - } - { - !!item.variables.length && ( - <div className="space-y-0.5"> - { - item.variables.map((variable = [], index) => { - const isSystem = isSystemVar(variable) + {!item.variables.length && ( + <div + className={cn( + 'relative flex h-[22px] items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-[10px] font-normal text-text-tertiary uppercase', + (showSelectedBorder || showSelectionBorder) && 'bg-black/2!', + )} + > + {t(($) => $[`${i18nPrefix}.varNotSet`], { ns: 'workflow' })} + </div> + )} + {!!item.variables.length && ( + <div className="space-y-0.5"> + {item.variables.map((variable = [], index) => { + const isSystem = isSystemVar(variable) - const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === variable[0]) - const varName = isSystem ? `sys.${variable[variable.length - 1]}` : variable.slice(1).join('.') - const isException = isExceptionVariable(varName, node?.data.type) + const node = isSystem + ? nodes.find((node) => node.data.type === BlockEnum.Start) + : nodes.find((node) => node.id === variable[0]) + const varName = isSystem + ? `sys.${variable[variable.length - 1]}` + : variable.slice(1).join('.') + const isException = isExceptionVariable(varName, node?.data.type) - return ( - <VariableLabelInNode - key={index} - variables={variable} - nodeType={node?.data.type} - nodeTitle={node?.data.title} - isExceptionVariable={isException} - /> - ) - }) - } - </div> - ) - } + return ( + <VariableLabelInNode + key={index} + variables={variable} + nodeType={node?.data.type} + nodeTitle={node?.data.title} + isExceptionVariable={isException} + /> + ) + })} + </div> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx b/web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx index 86c0821b404ec9..88ace4a2ac06b0 100644 --- a/web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx @@ -3,9 +3,7 @@ import type { ChangeEvent, FC } from 'react' import type { VarGroupItem as VarGroupItemType } from '../types' import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { toast } from '@langgenius/dify-ui/toast' -import { - RiDeleteBinLine, -} from '@remixicon/react' +import { RiDeleteBinLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { produce } from 'immer' import * as React from 'react' @@ -50,124 +48,135 @@ const VarGroupItem: FC<Props> = ({ }) => { const { t } = useTranslation() - const handleAddVariable = useCallback((value: ValueSelector | string, _varKindType: VarKindType, varInfo?: Var) => { - const chosenVariables = payload.variables - if (chosenVariables.some(item => item.join('.') === (value as ValueSelector).join('.'))) - return - - const newPayload = produce(payload, (draft: Payload) => { - draft.variables.push(value as ValueSelector) - if (varInfo && varInfo.type !== VarType.any) - draft.output_type = varInfo.type - }) - onChange(newPayload) - }, [onChange, payload]) - - const handleListChange = useCallback((newList: ValueSelector[], changedItem?: ValueSelector) => { - if (changedItem) { + const handleAddVariable = useCallback( + (value: ValueSelector | string, _varKindType: VarKindType, varInfo?: Var) => { const chosenVariables = payload.variables - if (chosenVariables.some(item => item.join('.') === (changedItem as ValueSelector).join('.'))) + if (chosenVariables.some((item) => item.join('.') === (value as ValueSelector).join('.'))) return - } - const newPayload = produce(payload, (draft) => { - draft.variables = newList - if (newList.length === 0) - draft.output_type = VarType.any - }) - onChange(newPayload) - }, [onChange, payload]) + const newPayload = produce(payload, (draft: Payload) => { + draft.variables.push(value as ValueSelector) + if (varInfo && varInfo.type !== VarType.any) draft.output_type = varInfo.type + }) + onChange(newPayload) + }, + [onChange, payload], + ) + + const handleListChange = useCallback( + (newList: ValueSelector[], changedItem?: ValueSelector) => { + if (changedItem) { + const chosenVariables = payload.variables + if ( + chosenVariables.some( + (item) => item.join('.') === (changedItem as ValueSelector).join('.'), + ) + ) + return + } - const filterVar = useCallback((varPayload: Var) => { - if (payload.output_type === VarType.any) - return true - return varPayload.type === payload.output_type - }, [payload.output_type]) + const newPayload = produce(payload, (draft) => { + draft.variables = newList + if (newList.length === 0) draft.output_type = VarType.any + }) + onChange(newPayload) + }, + [onChange, payload], + ) - const [isEditGroupName, { - setTrue: setEditGroupName, - setFalse: setNotEditGroupName, - }] = useBoolean(false) + const filterVar = useCallback( + (varPayload: Var) => { + if (payload.output_type === VarType.any) return true + return varPayload.type === payload.output_type + }, + [payload.output_type], + ) - const handleGroupNameChange = useCallback((e: ChangeEvent<any>) => { - replaceSpaceWithUnderscoreInVarNameInput(e.target) - const value = e.target.value - const { isValid, errorKey, errorMessageKey } = checkKeys([value], false) - if (!isValid) { - toast.error(t($ => $[`varKeyError.${errorMessageKey}`], { ns: 'appDebug', key: errorKey })) - return - } - onGroupNameChange?.(value) - }, [onGroupNameChange, t]) + const [isEditGroupName, { setTrue: setEditGroupName, setFalse: setNotEditGroupName }] = + useBoolean(false) + + const handleGroupNameChange = useCallback( + (e: ChangeEvent<any>) => { + replaceSpaceWithUnderscoreInVarNameInput(e.target) + const value = e.target.value + const { isValid, errorKey, errorMessageKey } = checkKeys([value], false) + if (!isValid) { + toast.error( + t(($) => $[`varKeyError.${errorMessageKey}`], { ns: 'appDebug', key: errorKey }), + ) + return + } + onGroupNameChange?.(value) + }, + [onGroupNameChange, t], + ) return ( <Field className="group" - title={groupEnabled - ? ( - <div className="flex items-center"> - <div className="flex items-center normal-case!"> - <Folder className="mr-0.5 size-3.5" /> - {(!isEditGroupName) - ? ( - <div className="flex h-6 cursor-text items-center rounded-lg px-1 system-sm-semibold text-text-secondary hover:bg-gray-100" onClick={setEditGroupName}> - {payload.group_name} - </div> - ) - : ( - <input - type="text" - className="h-6 rounded-lg border border-gray-300 bg-white px-1 focus:outline-hidden" - // style={{ - // width: `${((payload.group_name?.length || 0) + 1) / 2}em`, - // }} - size={payload.group_name?.length} // to fit the input width - autoFocus - value={payload.group_name} - onChange={handleGroupNameChange} - onBlur={setNotEditGroupName} - maxLength={30} - /> - )} - - </div> - {canRemove && ( + title={ + groupEnabled ? ( + <div className="flex items-center"> + <div className="flex items-center normal-case!"> + <Folder className="mr-0.5 size-3.5" /> + {!isEditGroupName ? ( <div - className="ml-0.5 hidden cursor-pointer rounded-md p-1 text-text-tertiary group-hover:block hover:bg-state-destructive-hover hover:text-text-destructive" - onClick={onRemove} + className="flex h-6 cursor-text items-center rounded-lg px-1 system-sm-semibold text-text-secondary hover:bg-gray-100" + onClick={setEditGroupName} > - <RiDeleteBinLine - className="size-4" - /> + {payload.group_name} </div> + ) : ( + <input + type="text" + className="h-6 rounded-lg border border-gray-300 bg-white px-1 focus:outline-hidden" + // style={{ + // width: `${((payload.group_name?.length || 0) + 1) / 2}em`, + // }} + size={payload.group_name?.length} // to fit the input width + autoFocus + value={payload.group_name} + onChange={handleGroupNameChange} + onBlur={setNotEditGroupName} + maxLength={30} + /> )} </div> - ) - : t($ => $[`${i18nPrefix}.title`], { ns: 'workflow' })!} - operations={( + {canRemove && ( + <div + className="ml-0.5 hidden cursor-pointer rounded-md p-1 text-text-tertiary group-hover:block hover:bg-state-destructive-hover hover:text-text-destructive" + onClick={onRemove} + > + <RiDeleteBinLine className="size-4" /> + </div> + )} + </div> + ) : ( + t(($) => $[`${i18nPrefix}.title`], { ns: 'workflow' })! + ) + } + operations={ <div className="flex h-6 items-center space-x-2"> {payload.variables.length > 0 && ( - <div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary">{payload.output_type}</div> + <div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary"> + {payload.output_type} + </div> )} - { - !readOnly - ? ( - <VarReferencePicker - isAddBtnTrigger - readonly={false} - nodeId={nodeId} - isShowNodeName - value={[]} - onChange={handleAddVariable} - defaultVarKindType={VarKindType.variable} - filterVar={filterVar} - availableVars={availableVars} - /> - ) - : undefined - } + {!readOnly ? ( + <VarReferencePicker + isAddBtnTrigger + readonly={false} + nodeId={nodeId} + isShowNodeName + value={[]} + onChange={handleAddVariable} + defaultVarKindType={VarKindType.variable} + filterVar={filterVar} + availableVars={availableVars} + /> + ) : undefined} </div> - )} + } > <VarList readonly={readOnly} diff --git a/web/app/components/workflow/nodes/variable-assigner/components/var-list/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/variable-assigner/components/var-list/__tests__/index.spec.tsx index 656347788cac72..3c341c9da6ed35 100644 --- a/web/app/components/workflow/nodes/variable-assigner/components/var-list/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/components/var-list/__tests__/index.spec.tsx @@ -7,16 +7,14 @@ import VarList from '../index' vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ __esModule: true, - default: ({ - onOpen, - onChange, - }: { - onOpen?: () => void - onChange: (value: string[]) => void - }) => ( + default: ({ onOpen, onChange }: { onOpen?: () => void; onChange: (value: string[]) => void }) => ( <div> - <button type="button" data-testid="var-reference-picker-trigger" onClick={onOpen}>open-picker</button> - <button type="button" onClick={() => onChange(['node-a', 'answer'])}>select-answer</button> + <button type="button" data-testid="var-reference-picker-trigger" onClick={onOpen}> + open-picker + </button> + <button type="button" onClick={() => onChange(['node-a', 'answer'])}> + select-answer + </button> </div> ), })) @@ -99,9 +97,6 @@ describe('variable-assigner/var-list', () => { fireEvent.click(screen.getByRole('button', { name: 'select-answer' })) - expect(handleChange).toHaveBeenLastCalledWith( - [['node-a', 'answer']], - ['node-a', 'answer'], - ) + expect(handleChange).toHaveBeenLastCalledWith([['node-a', 'answer']], ['node-a', 'answer']) }) }) diff --git a/web/app/components/workflow/nodes/variable-assigner/components/var-list/index.tsx b/web/app/components/workflow/nodes/variable-assigner/components/var-list/index.tsx index bc779a5b0840be..cc97e9b700f95e 100644 --- a/web/app/components/workflow/nodes/variable-assigner/components/var-list/index.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/components/var-list/index.tsx @@ -20,41 +20,43 @@ type Props = Readonly<{ filterVar?: (payload: Var, valueSelector: ValueSelector) => boolean }> -const VarList: FC<Props> = ({ - readonly, - nodeId, - list, - onChange, - onOpen = noop, - filterVar, -}) => { +const VarList: FC<Props> = ({ readonly, nodeId, list, onChange, onOpen = noop, filterVar }) => { const { t } = useTranslation() - const handleVarReferenceChange = useCallback((index: number) => { - return (value: ValueSelector | string) => { - const newList = produce(list, (draft) => { - draft[index] = value as ValueSelector - }) - onChange(newList, value as ValueSelector) - } - }, [list, onChange]) + const handleVarReferenceChange = useCallback( + (index: number) => { + return (value: ValueSelector | string) => { + const newList = produce(list, (draft) => { + draft[index] = value as ValueSelector + }) + onChange(newList, value as ValueSelector) + } + }, + [list, onChange], + ) - const handleVarRemove = useCallback((index: number) => { - return () => { - const newList = produce(list, (draft) => { - draft.splice(index, 1) - }) - onChange(newList) - } - }, [list, onChange]) + const handleVarRemove = useCallback( + (index: number) => { + return () => { + const newList = produce(list, (draft) => { + draft.splice(index, 1) + }) + onChange(newList) + } + }, + [list, onChange], + ) - const handleOpen = useCallback((index: number) => { - return () => onOpen(index) - }, [onOpen]) + const handleOpen = useCallback( + (index: number) => { + return () => onOpen(index) + }, + [onOpen], + ) if (list.length === 0) { return ( <ListNoDataPlaceholder> - {t($ => $['nodes.variableAssigner.noVarTip'], { ns: 'workflow' })} + {t(($) => $['nodes.variableAssigner.noVarTip'], { ns: 'workflow' })} </ListNoDataPlaceholder> ) } @@ -74,11 +76,7 @@ const VarList: FC<Props> = ({ filterVar={filterVar} defaultVarKindType={VarKindType.variable} /> - {!readonly && ( - <RemoveButton - onClick={handleVarRemove(index)} - /> - )} + {!readonly && <RemoveButton onClick={handleVarRemove(index)} />} </div> ))} </div> diff --git a/web/app/components/workflow/nodes/variable-assigner/default.ts b/web/app/components/workflow/nodes/variable-assigner/default.ts index 4c43586cce1a3e..c47df162e56423 100644 --- a/web/app/components/workflow/nodes/variable-assigner/default.ts +++ b/web/app/components/workflow/nodes/variable-assigner/default.ts @@ -15,7 +15,7 @@ const metaData = genNodeMetaData({ type VariableFieldKey = 'errorMsg.fields.variableValue' const variableFieldSelectors: Record<VariableFieldKey, SelectorParam<'workflow'>> = { - 'errorMsg.fields.variableValue': $ => $['errorMsg.fields.variableValue'], + 'errorMsg.fields.variableValue': ($) => $['errorMsg.fields.variableValue'], } const nodeDefault: NodeDefault<VariableAssignerNodeType> = { @@ -32,25 +32,31 @@ const nodeDefault: NodeDefault<VariableAssignerNodeType> = { const validateVariables = (variables: any[], field: VariableFieldKey) => { variables.forEach((variable) => { if (!variable || variable.length === 0) - errorMessages = t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: t(variableFieldSelectors[field], { ns: 'workflow' }) }) + errorMessages = t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: t(variableFieldSelectors[field], { ns: 'workflow' }), + }) }) } if (group_enabled) { if (!groups || groups.length === 0) { - errorMessages = t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: t($ => $['nodes.variableAssigner.title'], { ns: 'workflow' }) }) - } - else if (!errorMessages) { + errorMessages = t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: t(($) => $['nodes.variableAssigner.title'], { ns: 'workflow' }), + }) + } else if (!errorMessages) { groups.forEach((group) => { validateVariables(group.variables || [], 'errorMsg.fields.variableValue') }) } - } - else { + } else { if (!variables || variables.length === 0) - errorMessages = t($ => $['errorMsg.fieldRequired'], { ns: 'workflow', field: t($ => $['nodes.variableAssigner.title'], { ns: 'workflow' }) }) - else if (!errorMessages) - validateVariables(variables, 'errorMsg.fields.variableValue') + errorMessages = t(($) => $['errorMsg.fieldRequired'], { + ns: 'workflow', + field: t(($) => $['nodes.variableAssigner.title'], { ns: 'workflow' }), + }) + else if (!errorMessages) validateVariables(variables, 'errorMsg.fields.variableValue') } return { diff --git a/web/app/components/workflow/nodes/variable-assigner/hooks.ts b/web/app/components/workflow/nodes/variable-assigner/hooks.ts index 6546b7590490ef..a4dde1bf1831cf 100644 --- a/web/app/components/workflow/nodes/variable-assigner/hooks.ts +++ b/web/app/components/workflow/nodes/variable-assigner/hooks.ts @@ -1,27 +1,11 @@ -import type { - Node, - ValueSelector, - Var, -} from '../../types' -import type { - VarGroupItem, - VariableAssignerNodeType, -} from './types' +import type { Node, ValueSelector, Var } from '../../types' +import type { VarGroupItem, VariableAssignerNodeType } from './types' import { uniqBy } from 'es-toolkit/compat' - import { produce } from 'immer' import { useCallback } from 'react' -import { - useNodes, - useStoreApi, -} from 'reactflow' +import { useNodes, useStoreApi } from 'reactflow' import { FlowType } from '@/types/common' -import { - useIsChatMode, - useNodeDataUpdate, - useWorkflow, - useWorkflowVariables, -} from '../../hooks' +import { useIsChatMode, useNodeDataUpdate, useWorkflow, useWorkflowVariables } from '../../hooks' import { useHooksStore } from '../../hooks-store/store' import { useWorkflowStore } from '../../store' import { filterSnippetSystemVars, isSnippetCanvas } from '../_base/hooks/snippet-input-field-vars' @@ -31,90 +15,101 @@ export const useVariableAssigner = () => { const workflowStore = useWorkflowStore() const { handleNodeDataUpdate } = useNodeDataUpdate() - const handleAssignVariableValueChange = useCallback((nodeId: string, value: ValueSelector, varDetail: Var, groupId?: string) => { - const { getNodes } = store.getState() - const nodes = getNodes() - const node: Node<VariableAssignerNodeType> = nodes.find(node => node.id === nodeId)! - - let payload - if (groupId && groupId !== 'target') { - payload = { - advanced_settings: { - ...node.data.advanced_settings, - groups: node.data.advanced_settings?.groups.map((group: VarGroupItem & { groupId: string }) => { - if (group.groupId === groupId && !group.variables.some(item => item.join('.') === (value as ValueSelector).join('.'))) { - return { - ...group, - variables: [...group.variables, value], - output_type: varDetail.type, - } - } - return group - }), - }, - } - } - else { - if (node.data.variables.some(item => item.join('.') === (value as ValueSelector).join('.'))) - return - payload = { - variables: [...node.data.variables, value], - output_type: varDetail.type, + const handleAssignVariableValueChange = useCallback( + (nodeId: string, value: ValueSelector, varDetail: Var, groupId?: string) => { + const { getNodes } = store.getState() + const nodes = getNodes() + const node: Node<VariableAssignerNodeType> = nodes.find((node) => node.id === nodeId)! + + let payload + if (groupId && groupId !== 'target') { + payload = { + advanced_settings: { + ...node.data.advanced_settings, + groups: node.data.advanced_settings?.groups.map( + (group: VarGroupItem & { groupId: string }) => { + if ( + group.groupId === groupId && + !group.variables.some( + (item) => item.join('.') === (value as ValueSelector).join('.'), + ) + ) { + return { + ...group, + variables: [...group.variables, value], + output_type: varDetail.type, + } + } + return group + }, + ), + }, + } + } else { + if ( + node.data.variables.some((item) => item.join('.') === (value as ValueSelector).join('.')) + ) + return + payload = { + variables: [...node.data.variables, value], + output_type: varDetail.type, + } } - } - handleNodeDataUpdate({ - id: nodeId, - data: payload, - }) - }, [store, handleNodeDataUpdate]) - - const handleAddVariableInAddVariablePopupWithPosition = useCallback(( - nodeId: string, - variableAssignerNodeId: string, - variableAssignerNodeHandleId: string, - value: ValueSelector, - varDetail: Var, - ) => { - const { - getNodes, - setNodes, - } = store.getState() - const { - setShowAssignVariablePopup, - } = workflowStore.getState() - - const newNodes = produce(getNodes(), (draft) => { - draft.forEach((node) => { - if (node.id === nodeId || node.id === variableAssignerNodeId) { - node.data = { - ...node.data, - _showAddVariablePopup: false, - _holdAddVariablePopup: false, + handleNodeDataUpdate({ + id: nodeId, + data: payload, + }) + }, + [store, handleNodeDataUpdate], + ) + + const handleAddVariableInAddVariablePopupWithPosition = useCallback( + ( + nodeId: string, + variableAssignerNodeId: string, + variableAssignerNodeHandleId: string, + value: ValueSelector, + varDetail: Var, + ) => { + const { getNodes, setNodes } = store.getState() + const { setShowAssignVariablePopup } = workflowStore.getState() + + const newNodes = produce(getNodes(), (draft) => { + draft.forEach((node) => { + if (node.id === nodeId || node.id === variableAssignerNodeId) { + node.data = { + ...node.data, + _showAddVariablePopup: false, + _holdAddVariablePopup: false, + } } - } + }) }) - }) - setNodes(newNodes) - setShowAssignVariablePopup(undefined) - handleAssignVariableValueChange(variableAssignerNodeId, value, varDetail, variableAssignerNodeHandleId) - }, [store, workflowStore, handleAssignVariableValueChange]) - - const handleGroupItemMouseEnter = useCallback((groupId: string) => { - const { - setHoveringAssignVariableGroupId, - } = workflowStore.getState() - - setHoveringAssignVariableGroupId(groupId) - }, [workflowStore]) + setNodes(newNodes) + setShowAssignVariablePopup(undefined) + handleAssignVariableValueChange( + variableAssignerNodeId, + value, + varDetail, + variableAssignerNodeHandleId, + ) + }, + [store, workflowStore, handleAssignVariableValueChange], + ) + + const handleGroupItemMouseEnter = useCallback( + (groupId: string) => { + const { setHoveringAssignVariableGroupId } = workflowStore.getState() + + setHoveringAssignVariableGroupId(groupId) + }, + [workflowStore], + ) const handleGroupItemMouseLeave = useCallback(() => { - const { - connectingNodePayload, - setHoveringAssignVariableGroupId, - } = workflowStore.getState() + const { connectingNodePayload, setHoveringAssignVariableGroupId } = workflowStore.getState() - if (connectingNodePayload) - setHoveringAssignVariableGroupId(undefined) + if (connectingNodePayload) setHoveringAssignVariableGroupId(undefined) }, [workflowStore]) return { @@ -130,42 +125,56 @@ export const useGetAvailableVars = () => { const { getBeforeNodesInSameBranchIncludeParent } = useWorkflow() const { getNodeAvailableVars } = useWorkflowVariables() const isChatMode = useIsChatMode() - const isSnippetFlow = useHooksStore(s => s.configsMap?.flowType) === FlowType.snippet || isSnippetCanvas() - const getAvailableVars = useCallback((nodeId: string, handleId: string, filterVar: (v: Var) => boolean, hideEnv = false) => { - const availableNodes: Node[] = [] - const currentNode = nodes.find(node => node.id === nodeId)! - - if (!currentNode) - return [] - const beforeNodes = getBeforeNodesInSameBranchIncludeParent(nodeId) - availableNodes.push(...beforeNodes) - const parentNode = nodes.find(node => node.id === currentNode.parentId) - - if (hideEnv) { - const availableVars = getNodeAvailableVars({ - parentNode, - beforeNodes: uniqBy(availableNodes, 'id').filter(node => node.id !== nodeId), - isChatMode, - hideEnv, - hideChatVar: false, - filterVar, - }) - .map(node => ({ - ...node, - vars: node.isStartNode ? node.vars.filter(v => !v.variable.startsWith('sys.')) : node.vars, - })) - .filter(item => item.vars.length > 0) - - return filterSnippetSystemVars(availableVars, isSnippetFlow) - } - - return filterSnippetSystemVars(getNodeAvailableVars({ - parentNode, - beforeNodes: uniqBy(availableNodes, 'id').filter(node => node.id !== nodeId), + const isSnippetFlow = + useHooksStore((s) => s.configsMap?.flowType) === FlowType.snippet || isSnippetCanvas() + const getAvailableVars = useCallback( + (nodeId: string, handleId: string, filterVar: (v: Var) => boolean, hideEnv = false) => { + const availableNodes: Node[] = [] + const currentNode = nodes.find((node) => node.id === nodeId)! + + if (!currentNode) return [] + const beforeNodes = getBeforeNodesInSameBranchIncludeParent(nodeId) + availableNodes.push(...beforeNodes) + const parentNode = nodes.find((node) => node.id === currentNode.parentId) + + if (hideEnv) { + const availableVars = getNodeAvailableVars({ + parentNode, + beforeNodes: uniqBy(availableNodes, 'id').filter((node) => node.id !== nodeId), + isChatMode, + hideEnv, + hideChatVar: false, + filterVar, + }) + .map((node) => ({ + ...node, + vars: node.isStartNode + ? node.vars.filter((v) => !v.variable.startsWith('sys.')) + : node.vars, + })) + .filter((item) => item.vars.length > 0) + + return filterSnippetSystemVars(availableVars, isSnippetFlow) + } + + return filterSnippetSystemVars( + getNodeAvailableVars({ + parentNode, + beforeNodes: uniqBy(availableNodes, 'id').filter((node) => node.id !== nodeId), + isChatMode, + filterVar, + }), + isSnippetFlow, + ) + }, + [ + nodes, + getBeforeNodesInSameBranchIncludeParent, + getNodeAvailableVars, isChatMode, - filterVar, - }), isSnippetFlow) - }, [nodes, getBeforeNodesInSameBranchIncludeParent, getNodeAvailableVars, isChatMode, isSnippetFlow]) + isSnippetFlow, + ], + ) return getAvailableVars } diff --git a/web/app/components/workflow/nodes/variable-assigner/node.tsx b/web/app/components/workflow/nodes/variable-assigner/node.tsx index d1fb4203f9e909..e049ed3167ac1e 100644 --- a/web/app/components/workflow/nodes/variable-assigner/node.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/node.tsx @@ -1,11 +1,7 @@ import type { FC } from 'react' import type { NodeProps } from 'reactflow' import type { VariableAssignerNodeType } from './types' -import { - memo, - useMemo, - useRef, -} from 'react' +import { memo, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import NodeGroupItem from './components/node-group-item' @@ -19,15 +15,17 @@ const Node: FC<NodeProps<VariableAssignerNodeType>> = (props) => { const groups = useMemo(() => { if (!advanced_settings?.group_enabled) { - return [{ - groupEnabled: false, - targetHandleId: 'target', - title: t($ => $[`${i18nPrefix}.title`], { ns: 'workflow' }), - type: data.output_type, - variables: data.variables, - variableAssignerNodeId: id, - variableAssignerNodeData: data, - }] + return [ + { + groupEnabled: false, + targetHandleId: 'target', + title: t(($) => $[`${i18nPrefix}.title`], { ns: 'workflow' }), + type: data.output_type, + variables: data.variables, + variableAssignerNodeId: id, + variableAssignerNodeData: data, + }, + ] } return advanced_settings.groups.map((group) => { return { @@ -44,16 +42,9 @@ const Node: FC<NodeProps<VariableAssignerNodeType>> = (props) => { return ( <div className="relative mb-1 space-y-0.5 px-1" ref={ref}> - { - groups.map((item) => { - return ( - <NodeGroupItem - key={item.title} - item={item} - /> - ) - }) - } + {groups.map((item) => { + return <NodeGroupItem key={item.title} item={item} /> + })} </div> ) } diff --git a/web/app/components/workflow/nodes/variable-assigner/panel.tsx b/web/app/components/workflow/nodes/variable-assigner/panel.tsx index c7838d86194955..620697c7cd1323 100644 --- a/web/app/components/workflow/nodes/variable-assigner/panel.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/panel.tsx @@ -14,10 +14,7 @@ import VarGroupItem from './components/var-group-item' import useConfig from './use-config' const i18nPrefix = 'nodes.variableAssigner' -const Panel: FC<NodePanelProps<VariableAssignerNodeType>> = ({ - id, - data, -}) => { +const Panel: FC<NodePanelProps<VariableAssignerNodeType>> = ({ id, data }) => { const { t } = useTranslation() const { @@ -40,62 +37,66 @@ const Panel: FC<NodePanelProps<VariableAssignerNodeType>> = ({ return ( <div className="mt-2"> <div className="space-y-4 px-4 pb-4"> - {!isEnableGroup - ? ( - <VarGroupItem - readOnly={readOnly} - nodeId={id} - payload={{ - output_type: inputs.output_type, - variables: inputs.variables, - }} - onChange={handleListOrTypeChange} - groupEnabled={false} - availableVars={getAvailableVars(id, 'target', filterVar(inputs.output_type), true)} - /> - ) - : ( - <div> - <div className="space-y-2"> - {inputs.advanced_settings?.groups.map((item, index) => ( - <div key={item.groupId}> - <VarGroupItem - readOnly={readOnly} - nodeId={id} - payload={item} - onChange={handleListOrTypeChangeInGroup(item.groupId)} - groupEnabled - canRemove={!readOnly && inputs.advanced_settings?.groups.length > 1} - onRemove={handleGroupRemoved(item.groupId)} - onGroupNameChange={handleVarGroupNameChange(item.groupId)} - availableVars={getAvailableVars(id, item.groupId, filterVar(item.output_type), true)} - /> - {index !== inputs.advanced_settings?.groups.length - 1 && <Split className="my-4" />} - </div> - - ))} + {!isEnableGroup ? ( + <VarGroupItem + readOnly={readOnly} + nodeId={id} + payload={{ + output_type: inputs.output_type, + variables: inputs.variables, + }} + onChange={handleListOrTypeChange} + groupEnabled={false} + availableVars={getAvailableVars(id, 'target', filterVar(inputs.output_type), true)} + /> + ) : ( + <div> + <div className="space-y-2"> + {inputs.advanced_settings?.groups.map((item, index) => ( + <div key={item.groupId}> + <VarGroupItem + readOnly={readOnly} + nodeId={id} + payload={item} + onChange={handleListOrTypeChangeInGroup(item.groupId)} + groupEnabled + canRemove={!readOnly && inputs.advanced_settings?.groups.length > 1} + onRemove={handleGroupRemoved(item.groupId)} + onGroupNameChange={handleVarGroupNameChange(item.groupId)} + availableVars={getAvailableVars( + id, + item.groupId, + filterVar(item.output_type), + true, + )} + /> + {index !== inputs.advanced_settings?.groups.length - 1 && ( + <Split className="my-4" /> + )} </div> - <AddButton - className="mt-2" - text={t($ => $[`${i18nPrefix}.addGroup`], { ns: 'workflow' })} - onClick={handleAddGroup} - /> - </div> - )} + ))} + </div> + <AddButton + className="mt-2" + text={t(($) => $[`${i18nPrefix}.addGroup`], { ns: 'workflow' })} + onClick={handleAddGroup} + /> + </div> + )} </div> <Split /> <div className={cn('px-4 pt-4', isEnableGroup ? 'pb-4' : 'pb-2')}> <Field - title={t($ => $[`${i18nPrefix}.aggregationGroup`], { ns: 'workflow' })} - tooltip={t($ => $[`${i18nPrefix}.aggregationGroupTip`], { ns: 'workflow' })!} - operations={( + title={t(($) => $[`${i18nPrefix}.aggregationGroup`], { ns: 'workflow' })} + tooltip={t(($) => $[`${i18nPrefix}.aggregationGroupTip`], { ns: 'workflow' })!} + operations={ <Switch checked={isEnableGroup} onCheckedChange={handleGroupEnabledChange} size="md" disabled={readOnly} /> - )} + } /> </div> {isEnableGroup && ( @@ -108,7 +109,7 @@ const Panel: FC<NodePanelProps<VariableAssignerNodeType>> = ({ key={index} name={`${item.group_name}.output`} type={item.output_type} - description={t($ => $[`${i18nPrefix}.outputVars.varDescribe`], { + description={t(($) => $[`${i18nPrefix}.outputVars.varDescribe`], { ns: 'workflow', groupName: item.group_name, })} diff --git a/web/app/components/workflow/nodes/variable-assigner/types.ts b/web/app/components/workflow/nodes/variable-assigner/types.ts index 0bfc6f56a09ba5..deae92ae9ea2d1 100644 --- a/web/app/components/workflow/nodes/variable-assigner/types.ts +++ b/web/app/components/workflow/nodes/variable-assigner/types.ts @@ -4,12 +4,13 @@ export type VarGroupItem = { output_type: VarType variables: ValueSelector[] } -export type VariableAssignerNodeType = CommonNodeType & VarGroupItem & { - advanced_settings: { - group_enabled: boolean - groups: ({ - group_name: string - groupId: string - } & VarGroupItem)[] +export type VariableAssignerNodeType = CommonNodeType & + VarGroupItem & { + advanced_settings: { + group_enabled: boolean + groups: ({ + group_name: string + groupId: string + } & VarGroupItem)[] + } } -} diff --git a/web/app/components/workflow/nodes/variable-assigner/use-config.helpers.ts b/web/app/components/workflow/nodes/variable-assigner/use-config.helpers.ts index 809ba21808be44..85e8dd9c3bd493 100644 --- a/web/app/components/workflow/nodes/variable-assigner/use-config.helpers.ts +++ b/web/app/components/workflow/nodes/variable-assigner/use-config.helpers.ts @@ -6,8 +6,7 @@ import { VarType } from '../../types' export const filterVarByType = (varType: VarType) => { return (variable: Var) => { - if (varType === VarType.any || variable.type === VarType.any) - return true + if (varType === VarType.any || variable.type === VarType.any) return true return variable.type === varType } @@ -25,31 +24,26 @@ export const updateNestedVarGroupItem = ( inputs: VariableAssignerNodeType, groupId: string, payload: VarGroupItem, -) => produce(inputs, (draft) => { - if (!draft.advanced_settings) - return +) => + produce(inputs, (draft) => { + if (!draft.advanced_settings) return - const index = draft.advanced_settings.groups.findIndex(item => item.groupId === groupId) - if (index < 0) - return + const index = draft.advanced_settings.groups.findIndex((item) => item.groupId === groupId) + if (index < 0) return - draft.advanced_settings.groups[index] = { - ...draft.advanced_settings.groups[index]!, - ...payload, - } -}) + draft.advanced_settings.groups[index] = { + ...draft.advanced_settings.groups[index]!, + ...payload, + } + }) -export const removeGroupByIndex = ( - inputs: VariableAssignerNodeType, - index: number, -) => produce(inputs, (draft) => { - if (!draft.advanced_settings) - return - if (index < 0 || index >= draft.advanced_settings.groups.length) - return - - draft.advanced_settings.groups.splice(index, 1) -}) +export const removeGroupByIndex = (inputs: VariableAssignerNodeType, index: number) => + produce(inputs, (draft) => { + if (!draft.advanced_settings) return + if (index < 0 || index >= draft.advanced_settings.groups.length) return + + draft.advanced_settings.groups.splice(index, 1) + }) export const toggleGroupEnabled = ({ inputs, @@ -57,27 +51,28 @@ export const toggleGroupEnabled = ({ }: { inputs: VariableAssignerNodeType enabled: boolean -}) => produce(inputs, (draft) => { - if (!draft.advanced_settings) - draft.advanced_settings = { group_enabled: false, groups: [] } - - if (enabled) { - if (draft.advanced_settings.groups.length === 0) { - draft.advanced_settings.groups = [{ - output_type: draft.output_type, - variables: draft.variables, - group_name: 'Group1', - groupId: uuid4(), - }] +}) => + produce(inputs, (draft) => { + if (!draft.advanced_settings) draft.advanced_settings = { group_enabled: false, groups: [] } + + if (enabled) { + if (draft.advanced_settings.groups.length === 0) { + draft.advanced_settings.groups = [ + { + output_type: draft.output_type, + variables: draft.variables, + group_name: 'Group1', + groupId: uuid4(), + }, + ] + } + } else if (draft.advanced_settings.groups.length > 0) { + draft.output_type = draft.advanced_settings.groups[0]!.output_type + draft.variables = draft.advanced_settings.groups[0]!.variables } - } - else if (draft.advanced_settings.groups.length > 0) { - draft.output_type = draft.advanced_settings.groups[0]!.output_type - draft.variables = draft.advanced_settings.groups[0]!.variables - } - draft.advanced_settings.group_enabled = enabled -}) + draft.advanced_settings.group_enabled = enabled + }) export const addGroup = (inputs: VariableAssignerNodeType) => { let maxInGroupName = 1 @@ -86,14 +81,12 @@ export const addGroup = (inputs: VariableAssignerNodeType) => { const match = /(\d+)$/.exec(item.group_name) if (match) { const num = Number.parseInt(match[1]!, 10) - if (num > maxInGroupName) - maxInGroupName = num + if (num > maxInGroupName) maxInGroupName = num } }) return produce(inputs, (draft) => { - if (!draft.advanced_settings) - draft.advanced_settings = { group_enabled: false, groups: [] } + if (!draft.advanced_settings) draft.advanced_settings = { group_enabled: false, groups: [] } draft.advanced_settings.groups.push({ output_type: VarType.any, @@ -104,17 +97,12 @@ export const addGroup = (inputs: VariableAssignerNodeType) => { }) } -export const renameGroup = ( - inputs: VariableAssignerNodeType, - groupId: string, - name: string, -) => produce(inputs, (draft) => { - if (!draft.advanced_settings) - return +export const renameGroup = (inputs: VariableAssignerNodeType, groupId: string, name: string) => + produce(inputs, (draft) => { + if (!draft.advanced_settings) return - const index = draft.advanced_settings.groups.findIndex(item => item.groupId === groupId) - if (index < 0) - return + const index = draft.advanced_settings.groups.findIndex((item) => item.groupId === groupId) + if (index < 0) return - draft.advanced_settings.groups[index]!.group_name = name -}) + draft.advanced_settings.groups[index]!.group_name = name + }) diff --git a/web/app/components/workflow/nodes/variable-assigner/use-config.ts b/web/app/components/workflow/nodes/variable-assigner/use-config.ts index 08ccc3d42fb487..e535d9c453da5f 100644 --- a/web/app/components/workflow/nodes/variable-assigner/use-config.ts +++ b/web/app/components/workflow/nodes/variable-assigner/use-config.ts @@ -2,13 +2,9 @@ import type { ValueSelector } from '../../types' import type { VarGroupItem, VariableAssignerNodeType } from './types' import { useBoolean, useDebounceFn } from 'ahooks' import { useCallback, useRef, useState } from 'react' -import { - useNodesReadOnly, - useWorkflow, -} from '@/app/components/workflow/hooks' +import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' - import { useGetAvailableVars } from './hooks' import { addGroup, @@ -21,10 +17,7 @@ import { } from './use-config.helpers' const useConfig = (id: string, payload: VariableAssignerNodeType) => { - const { - deleteNodeInspectorVars, - renameInspectVarName, - } = useInspectVarsCrud() + const { deleteNodeInspectorVars, renameInspectVarName } = useInspectVarsCrud() const { nodesReadOnly: readOnly } = useNodesReadOnly() const { handleOutVarRenameChange, isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow() @@ -32,69 +25,90 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { const isEnableGroup = !!inputs.advanced_settings?.group_enabled // Not Enable Group - const handleListOrTypeChange = useCallback((payload: VarGroupItem) => { - setInputs(updateRootVarGroupItem(inputs, payload)) - }, [inputs, setInputs]) + const handleListOrTypeChange = useCallback( + (payload: VarGroupItem) => { + setInputs(updateRootVarGroupItem(inputs, payload)) + }, + [inputs, setInputs], + ) - const handleListOrTypeChangeInGroup = useCallback((groupId: string) => { - return (payload: VarGroupItem) => { - setInputs(updateNestedVarGroupItem(inputs, groupId, payload)) - } - }, [inputs, setInputs]) + const handleListOrTypeChangeInGroup = useCallback( + (groupId: string) => { + return (payload: VarGroupItem) => { + setInputs(updateNestedVarGroupItem(inputs, groupId, payload)) + } + }, + [inputs, setInputs], + ) const getAvailableVars = useGetAvailableVars() - const [isShowRemoveVarConfirm, { - setTrue: showRemoveVarConfirm, - setFalse: hideRemoveVarConfirm, - }] = useBoolean(false) + const [ + isShowRemoveVarConfirm, + { setTrue: showRemoveVarConfirm, setFalse: hideRemoveVarConfirm }, + ] = useBoolean(false) const [removedVars, setRemovedVars] = useState<ValueSelector[]>([]) const [removeType, setRemoveType] = useState<'group' | 'enableChanged'>('group') const [removedGroupIndex, setRemovedGroupIndex] = useState<number>(-1) - const handleGroupRemoved = useCallback((groupId: string) => { - return () => { - const groups = inputs.advanced_settings?.groups ?? [] - const index = groups.findIndex(item => item.groupId === groupId) - if (index < 0) - return - - const groupName = groups[index]!.group_name - if (isVarUsedInNodes([id, groupName, 'output'])) { - showRemoveVarConfirm() - setRemovedVars([[id, groupName, 'output']]) - setRemoveType('group') - setRemovedGroupIndex(index) - return + const handleGroupRemoved = useCallback( + (groupId: string) => { + return () => { + const groups = inputs.advanced_settings?.groups ?? [] + const index = groups.findIndex((item) => item.groupId === groupId) + if (index < 0) return + + const groupName = groups[index]!.group_name + if (isVarUsedInNodes([id, groupName, 'output'])) { + showRemoveVarConfirm() + setRemovedVars([[id, groupName, 'output']]) + setRemoveType('group') + setRemovedGroupIndex(index) + return + } + setInputs(removeGroupByIndex(inputs, index)) } - setInputs(removeGroupByIndex(inputs, index)) - } - }, [id, inputs, isVarUsedInNodes, setInputs, showRemoveVarConfirm]) + }, + [id, inputs, isVarUsedInNodes, setInputs, showRemoveVarConfirm], + ) - const handleGroupEnabledChange = useCallback((enabled: boolean) => { - const groups = inputs.advanced_settings?.groups ?? [] + const handleGroupEnabledChange = useCallback( + (enabled: boolean) => { + const groups = inputs.advanced_settings?.groups ?? [] - if (enabled && groups.length === 0) { - handleOutVarRenameChange(id, [id, 'output'], [id, 'Group1', 'output']) - } + if (enabled && groups.length === 0) { + handleOutVarRenameChange(id, [id, 'output'], [id, 'Group1', 'output']) + } - if (!enabled && groups.length > 0) { - if (groups.length > 1) { - const useVars = groups.filter((item, index) => index > 0 && isVarUsedInNodes([id, item.group_name, 'output'])) - if (useVars.length > 0) { - showRemoveVarConfirm() - setRemovedVars(useVars.map(item => [id, item.group_name, 'output'])) - setRemoveType('enableChanged') - return + if (!enabled && groups.length > 0) { + if (groups.length > 1) { + const useVars = groups.filter( + (item, index) => index > 0 && isVarUsedInNodes([id, item.group_name, 'output']), + ) + if (useVars.length > 0) { + showRemoveVarConfirm() + setRemovedVars(useVars.map((item) => [id, item.group_name, 'output'])) + setRemoveType('enableChanged') + return + } } - } - handleOutVarRenameChange(id, [id, groups[0]!.group_name, 'output'], [id, 'output']) - } + handleOutVarRenameChange(id, [id, groups[0]!.group_name, 'output'], [id, 'output']) + } - setInputs(toggleGroupEnabled({ inputs, enabled })) - deleteNodeInspectorVars(id) - }, [deleteNodeInspectorVars, handleOutVarRenameChange, id, inputs, isVarUsedInNodes, setInputs, showRemoveVarConfirm]) + setInputs(toggleGroupEnabled({ inputs, enabled })) + deleteNodeInspectorVars(id) + }, + [ + deleteNodeInspectorVars, + handleOutVarRenameChange, + id, + inputs, + isVarUsedInNodes, + setInputs, + showRemoveVarConfirm, + ], + ) const handleAddGroup = useCallback(() => { setInputs(addGroup(inputs)) @@ -104,9 +118,7 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { // record the first old name value const oldNameRef = useRef<Record<string, string>>({}) - const { - run: renameInspectNameWithDebounce, - } = useDebounceFn( + const { run: renameInspectNameWithDebounce } = useDebounceFn( (id: string, newName: string) => { const oldName = oldNameRef.current[id] renameInspectVarName(id, oldName!, newName) @@ -115,21 +127,22 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { { wait: 500 }, ) - const handleVarGroupNameChange = useCallback((groupId: string) => { - return (name: string) => { - const groups = inputs.advanced_settings?.groups ?? [] - const index = groups.findIndex(item => item.groupId === groupId) - if (index < 0) - return - - const oldName = groups[index]!.group_name - handleOutVarRenameChange(id, [id, oldName, 'output'], [id, name, 'output']) - setInputs(renameGroup(inputs, groupId, name)) - if (!(id in oldNameRef.current)) - oldNameRef.current[id] = oldName - renameInspectNameWithDebounce(id, name) - } - }, [handleOutVarRenameChange, id, inputs, renameInspectNameWithDebounce, setInputs]) + const handleVarGroupNameChange = useCallback( + (groupId: string) => { + return (name: string) => { + const groups = inputs.advanced_settings?.groups ?? [] + const index = groups.findIndex((item) => item.groupId === groupId) + if (index < 0) return + + const oldName = groups[index]!.group_name + handleOutVarRenameChange(id, [id, oldName, 'output'], [id, name, 'output']) + setInputs(renameGroup(inputs, groupId, name)) + if (!(id in oldNameRef.current)) oldNameRef.current[id] = oldName + renameInspectNameWithDebounce(id, name) + } + }, + [handleOutVarRenameChange, id, inputs, renameInspectNameWithDebounce, setInputs], + ) const onRemoveVarConfirm = useCallback(() => { removedVars.forEach((v) => { @@ -137,14 +150,20 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { }) hideRemoveVarConfirm() if (removeType === 'group') { - if (removedGroupIndex >= 0) - setInputs(removeGroupByIndex(inputs, removedGroupIndex)) - } - else { + if (removedGroupIndex >= 0) setInputs(removeGroupByIndex(inputs, removedGroupIndex)) + } else { // removeType === 'enableChanged' to enabled setInputs(toggleGroupEnabled({ inputs, enabled: false })) } - }, [removedVars, hideRemoveVarConfirm, removeType, removeUsedVarInNodes, inputs, setInputs, removedGroupIndex]) + }, [ + removedVars, + hideRemoveVarConfirm, + removeType, + removeUsedVarInNodes, + inputs, + setInputs, + removedGroupIndex, + ]) return { readOnly, diff --git a/web/app/components/workflow/nodes/variable-assigner/use-single-run-form-params.ts b/web/app/components/workflow/nodes/variable-assigner/use-single-run-form-params.ts index 874c546fd8110f..24a0b4805c37f4 100644 --- a/web/app/components/workflow/nodes/variable-assigner/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/variable-assigner/use-single-run-form-params.ts @@ -19,15 +19,17 @@ const useSingleRunFormParams = ({ setRunInputData, varSelectorsToVarInputs, }: Params) => { - const setInputVarValues = useCallback((newPayload: Record<string, any>) => { - setRunInputData(newPayload) - }, [setRunInputData]) + const setInputVarValues = useCallback( + (newPayload: Record<string, any>) => { + setRunInputData(newPayload) + }, + [setRunInputData], + ) const inputVarValues = (() => { const vars: Record<string, any> = {} - Object.keys(runInputData) - .forEach((key) => { - vars[key] = runInputData[key] - }) + Object.keys(runInputData).forEach((key) => { + vars[key] = runInputData[key] + }) return vars })() @@ -39,7 +41,12 @@ const useSingleRunFormParams = ({ allInputs.push(varSelector) }) } - if (isGroupEnabled && payload.advanced_settings && payload.advanced_settings.groups && payload.advanced_settings.groups.length) { + if ( + isGroupEnabled && + payload.advanced_settings && + payload.advanced_settings.groups && + payload.advanced_settings.groups.length + ) { payload.advanced_settings.groups.forEach((group) => { group.variables?.forEach((varSelector) => { allInputs.push(varSelector) @@ -52,8 +59,7 @@ const useSingleRunFormParams = ({ const existVarsKey: Record<string, boolean> = {} const uniqueVarInputs: InputVar[] = [] varInputs.forEach((input) => { - if (!input) - return + if (!input) return if (!existVarsKey[input.variable]) { existVarsKey[input.variable] = true uniqueVarInputs.push({ @@ -75,8 +81,7 @@ const useSingleRunFormParams = ({ if (payload.advanced_settings?.group_enabled) { const vars: ValueSelector[][] = [] payload.advanced_settings.groups.forEach((group) => { - if (group.variables) - vars.push([...group.variables]) + if (group.variables) vars.push([...group.variables]) }) return vars } diff --git a/web/app/components/workflow/nodes/variable-assigner/utils.ts b/web/app/components/workflow/nodes/variable-assigner/utils.ts index a7abe4206ad78f..0bffa708f1955d 100644 --- a/web/app/components/workflow/nodes/variable-assigner/utils.ts +++ b/web/app/components/workflow/nodes/variable-assigner/utils.ts @@ -3,10 +3,8 @@ import { VarType } from '../../types' export const filterVar = (varType: VarType) => { return (v: Var) => { - if (varType === VarType.any) - return true - if (v.type === VarType.any) - return true + if (varType === VarType.any) return true + if (v.type === VarType.any) return true return v.type === varType } } diff --git a/web/app/components/workflow/note-node/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/__tests__/index.spec.tsx index 7b20c0de08749b..9f501b634357f4 100644 --- a/web/app/components/workflow/note-node/__tests__/index.spec.tsx +++ b/web/app/components/workflow/note-node/__tests__/index.spec.tsx @@ -72,21 +72,18 @@ const renderNoteNode = (dataOverrides: Partial<NoteNodeType> = {}) => { }), ] - return renderWorkflowFlowComponent( - <div />, - { - nodes, - edges: [], - reactFlowProps: { - nodeTypes: { - [CUSTOM_NOTE_NODE]: NoteNode, - }, - }, - initialStoreState: { - controlPromptEditorRerenderKey: 0, + return renderWorkflowFlowComponent(<div />, { + nodes, + edges: [], + reactFlowProps: { + nodeTypes: { + [CUSTOM_NOTE_NODE]: NoteNode, }, }, - ) + initialStoreState: { + controlPromptEditorRerenderKey: 0, + }, + }) } describe('NoteNode', () => { @@ -103,7 +100,9 @@ describe('NoteNode', () => { expect(screen.getByText('workflow.nodes.note.editor.small')).toBeInTheDocument() }) - expect(screen.getByText('workflow.nodes.note.editor.small').closest('.nodrag.nopan.nowheel')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.note.editor.small').closest('.nodrag.nopan.nowheel'), + ).toBeInTheDocument() }) it('should hide the toolbar for temporary notes', () => { diff --git a/web/app/components/workflow/note-node/constants.ts b/web/app/components/workflow/note-node/constants.ts index d4891977cc092d..ca6bd9a1b8fe7f 100644 --- a/web/app/components/workflow/note-node/constants.ts +++ b/web/app/components/workflow/note-node/constants.ts @@ -3,7 +3,10 @@ import { NoteTheme } from './types' export const CUSTOM_NOTE_NODE = 'custom-note' export const NOTE_SHOW_AUTHOR_STORAGE_KEY = 'workflow-note-show-author' -export const THEME_MAP: Record<string, { outer: string, title: string, bg: string, border: string }> = { +export const THEME_MAP: Record< + string, + { outer: string; title: string; bg: string; border: string } +> = { [NoteTheme.blue]: { outer: 'border-util-colors-blue-blue-500', title: 'bg-util-colors-blue-blue-100', diff --git a/web/app/components/workflow/note-node/hooks.ts b/web/app/components/workflow/note-node/hooks.ts index 0760697179da33..602a57ef5c0717 100644 --- a/web/app/components/workflow/note-node/hooks.ts +++ b/web/app/components/workflow/note-node/hooks.ts @@ -9,23 +9,31 @@ export const useNote = (id: string) => { const { saveStateToHistory } = useWorkflowHistory() const setShowAuthorStorage = useSetWorkflowNoteShowAuthor() - const handleThemeChange = useCallback((theme: NoteTheme) => { - handleNodeDataUpdateWithSyncDraft({ id, data: { theme } }) - saveStateToHistory(WorkflowHistoryEvent.NoteChange, { nodeId: id }) - }, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory]) + const handleThemeChange = useCallback( + (theme: NoteTheme) => { + handleNodeDataUpdateWithSyncDraft({ id, data: { theme } }) + saveStateToHistory(WorkflowHistoryEvent.NoteChange, { nodeId: id }) + }, + [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory], + ) - const handleEditorChange = useCallback((editorState: EditorState) => { - if (!editorState?.isEmpty()) - handleNodeDataUpdateWithSyncDraft({ id, data: { text: JSON.stringify(editorState) } }) - else - handleNodeDataUpdateWithSyncDraft({ id, data: { text: '' } }) - }, [handleNodeDataUpdateWithSyncDraft, id]) + const handleEditorChange = useCallback( + (editorState: EditorState) => { + if (!editorState?.isEmpty()) + handleNodeDataUpdateWithSyncDraft({ id, data: { text: JSON.stringify(editorState) } }) + else handleNodeDataUpdateWithSyncDraft({ id, data: { text: '' } }) + }, + [handleNodeDataUpdateWithSyncDraft, id], + ) - const handleShowAuthorChange = useCallback((showAuthor: boolean) => { - setShowAuthorStorage(String(showAuthor)) - handleNodeDataUpdateWithSyncDraft({ id, data: { showAuthor } }) - saveStateToHistory(WorkflowHistoryEvent.NoteChange, { nodeId: id }) - }, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory, setShowAuthorStorage]) + const handleShowAuthorChange = useCallback( + (showAuthor: boolean) => { + setShowAuthorStorage(String(showAuthor)) + handleNodeDataUpdateWithSyncDraft({ id, data: { showAuthor } }) + saveStateToHistory(WorkflowHistoryEvent.NoteChange, { nodeId: id }) + }, + [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory, setShowAuthorStorage], + ) return { handleThemeChange, diff --git a/web/app/components/workflow/note-node/index.tsx b/web/app/components/workflow/note-node/index.tsx index d5b69503aa6758..411a182a980d3c 100644 --- a/web/app/components/workflow/note-node/index.tsx +++ b/web/app/components/workflow/note-node/index.tsx @@ -2,52 +2,37 @@ import type { NodeProps } from 'reactflow' import type { NoteNodeType } from './types' import { cn } from '@langgenius/dify-ui/cn' import { useClickAway } from 'ahooks' -import { - memo, - useRef, -} from 'react' +import { memo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { - useNodeDataUpdate, - useNodesInteractions, -} from '../hooks' +import { useNodeDataUpdate, useNodesInteractions } from '../hooks' import NodeResizer from '../nodes/_base/components/node-resizer' import { useStore } from '../store/workflow' import { THEME_MAP } from './constants' import { useNote } from './hooks' -import { - NoteEditor, - NoteEditorContextProvider, - NoteEditorToolbar, -} from './note-editor' +import { NoteEditor, NoteEditorContextProvider, NoteEditorToolbar } from './note-editor' const Icon = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none"> - <path fillRule="evenodd" clipRule="evenodd" d="M12 9.75V6H13.5V9.75C13.5 11.8211 11.8211 13.5 9.75 13.5H6V12H9.75C10.9926 12 12 10.9926 12 9.75Z" fill="black" fillOpacity="0.16" /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M12 9.75V6H13.5V9.75C13.5 11.8211 11.8211 13.5 9.75 13.5H6V12H9.75C10.9926 12 12 10.9926 12 9.75Z" + fill="black" + fillOpacity="0.16" + /> </svg> ) } -const NoteNode = ({ - id, - data, -}: NodeProps<NoteNodeType>) => { +const NoteNode = ({ id, data }: NodeProps<NoteNodeType>) => { const { t } = useTranslation() - const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) - const setHistoryShortcutsEnabled = useStore(s => s.setHistoryShortcutsEnabled) + const controlPromptEditorRerenderKey = useStore((s) => s.controlPromptEditorRerenderKey) + const setHistoryShortcutsEnabled = useStore((s) => s.setHistoryShortcutsEnabled) const ref = useRef<HTMLDivElement | null>(null) const theme = data.theme - const { - handleThemeChange, - handleEditorChange, - handleShowAuthorChange, - } = useNote(id) - const { - handleNodesCopy, - handleNodesDuplicate, - handleNodeDelete, - } = useNodesInteractions() + const { handleThemeChange, handleEditorChange, handleShowAuthorChange } = useNote(id) + const { handleNodesCopy, handleNodesDuplicate, handleNodeDelete } = useNodesInteractions() const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() useClickAway(() => { @@ -73,59 +58,44 @@ const NoteNode = ({ editable={!data._isTempNode} > <> - { - !data._isTempNode && ( - <NodeResizer - nodeId={id} - nodeData={data} - icon={<Icon />} - minWidth={240} - minHeight={88} - /> - ) - } + {!data._isTempNode && ( + <NodeResizer + nodeId={id} + nodeData={data} + icon={<Icon />} + minWidth={240} + minHeight={88} + /> + )} <div - className={cn( - 'h-2 shrink-0 rounded-t-md opacity-50', - THEME_MAP[theme]!.title, - )} - > - </div> - { - data.selected && !data._isTempNode && ( - <div className="pointer-events-auto absolute top-[-41px] left-1/2 z-40 -translate-x-1/2"> - <NoteEditorToolbar - theme={theme} - onThemeChange={handleThemeChange} - onCopy={() => handleNodesCopy(id)} - onDuplicate={() => handleNodesDuplicate(id)} - onDelete={() => handleNodeDelete(id)} - showAuthor={data.showAuthor} - onShowAuthorChange={handleShowAuthorChange} - /> - </div> - ) - } + className={cn('h-2 shrink-0 rounded-t-md opacity-50', THEME_MAP[theme]!.title)} + ></div> + {data.selected && !data._isTempNode && ( + <div className="pointer-events-auto absolute top-[-41px] left-1/2 z-40 -translate-x-1/2"> + <NoteEditorToolbar + theme={theme} + onThemeChange={handleThemeChange} + onCopy={() => handleNodesCopy(id)} + onDuplicate={() => handleNodesDuplicate(id)} + onDelete={() => handleNodeDelete(id)} + showAuthor={data.showAuthor} + onShowAuthorChange={handleShowAuthorChange} + /> + </div> + )} <div className="grow overflow-y-auto px-3 py-2.5"> - <div className={cn( - data.selected && 'nodrag nopan nowheel cursor-text', - )} - > + <div className={cn(data.selected && 'nodrag nopan nowheel cursor-text')}> <NoteEditor containerElement={ref.current} - placeholder={t($ => $['nodes.note.editor.placeholder'], { ns: 'workflow' }) || ''} + placeholder={t(($) => $['nodes.note.editor.placeholder'], { ns: 'workflow' }) || ''} onChange={handleEditorChange} setHistoryShortcutsEnabled={setHistoryShortcutsEnabled} /> </div> </div> - { - data.showAuthor && ( - <div className="p-3 pt-0 text-xs text-text-tertiary"> - {data.author} - </div> - ) - } + {data.showAuthor && ( + <div className="p-3 pt-0 text-xs text-text-tertiary">{data.author}</div> + )} </> </NoteEditorContextProvider> </div> diff --git a/web/app/components/workflow/note-node/note-editor/__tests__/context.spec.tsx b/web/app/components/workflow/note-node/note-editor/__tests__/context.spec.tsx index e816a331ded9e5..3b79c062d2709e 100644 --- a/web/app/components/workflow/note-node/note-editor/__tests__/context.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/__tests__/context.spec.tsx @@ -49,13 +49,9 @@ const readEditorText = (editor: LexicalEditor) => { return text } -const ContextProbe = ({ - onReady, -}: { - onReady?: (editor: LexicalEditor) => void -}) => { +const ContextProbe = ({ onReady }: { onReady?: (editor: LexicalEditor) => void }) => { const [editor] = useLexicalComposerContext() - const selectedIsBold = useStore(state => state.selectedIsBold) + const selectedIsBold = useStore((state) => state.selectedIsBold) useEffect(() => { onReady?.(editor) @@ -76,7 +72,7 @@ describe('NoteEditorContextProvider', () => { render( <NoteEditorContextProvider value={emptyValue}> - <ContextProbe onReady={instance => (editor = instance)} /> + <ContextProbe onReady={(instance) => (editor = instance)} /> </NoteEditorContextProvider>, ) @@ -107,7 +103,7 @@ describe('NoteEditorContextProvider', () => { render( <NoteEditorContextProvider value={value}> - <ContextProbe onReady={instance => (editor = instance)} /> + <ContextProbe onReady={(instance) => (editor = instance)} /> </NoteEditorContextProvider>, ) @@ -123,7 +119,7 @@ describe('NoteEditorContextProvider', () => { render( <NoteEditorContextProvider value={populatedValue} editable={false}> - <ContextProbe onReady={instance => (editor = instance)} /> + <ContextProbe onReady={(instance) => (editor = instance)} /> </NoteEditorContextProvider>, ) diff --git a/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx b/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx index 516eb5bcd98696..60a01970019937 100644 --- a/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx @@ -15,11 +15,7 @@ const themeCss = readFileSync( 'utf8', ) -const EditorProbe = ({ - onReady, -}: { - onReady?: (editor: LexicalEditor) => void -}) => { +const EditorProbe = ({ onReady }: { onReady?: (editor: LexicalEditor) => void }) => { const [editor] = useLexicalComposerContext() useEffect(() => { @@ -36,10 +32,7 @@ const renderEditor = ( return render( <NoteEditorContextProvider value={emptyValue}> <> - <Editor - containerElement={document.createElement('div')} - {...props} - /> + <Editor containerElement={document.createElement('div')} {...props} /> <EditorProbe onReady={onEditorReady} /> </> </NoteEditorContextProvider>, @@ -63,22 +56,25 @@ describe('Editor', () => { it('should render linked text with distinct link styling', async () => { let editor: LexicalEditor | null = null - renderEditor({}, instance => (editor = instance)) + renderEditor({}, (instance) => (editor = instance)) await waitFor(() => { expect(editor).not.toBeNull() }) act(() => { - editor!.update(() => { - const root = $getRoot() - root.clear() - const paragraph = $createParagraphNode() - const link = $createLinkNode('https://example.com/docs') - link.append($createTextNode('Linked docs')) - paragraph.append(link) - root.append(paragraph) - }, { discrete: true }) + editor!.update( + () => { + const root = $getRoot() + root.clear() + const paragraph = $createParagraphNode() + const link = $createLinkNode('https://example.com/docs') + link.append($createTextNode('Linked docs')) + paragraph.append(link) + root.append(paragraph) + }, + { discrete: true }, + ) }) const link = await screen.findByRole('link', { name: 'Linked docs' }) @@ -118,34 +114,40 @@ describe('Editor', () => { }) } - renderEditor({ onChange: handleChange }, instance => (editor = instance)) + renderEditor({ onChange: handleChange }, (instance) => (editor = instance)) await waitFor(() => { expect(editor).not.toBeNull() }) await act(async () => { - await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) }) act(() => { - editor!.update(() => { - const root = $getRoot() - root.clear() - const paragraph = $createParagraphNode() - paragraph.append($createTextNode('hello')) - root.append(paragraph) - }, { discrete: true }) + editor!.update( + () => { + const root = $getRoot() + root.clear() + const paragraph = $createParagraphNode() + paragraph.append($createTextNode('hello')) + root.append(paragraph) + }, + { discrete: true }, + ) }) act(() => { - editor!.update(() => { - const root = $getRoot() - root.clear() - const paragraph = $createParagraphNode() - paragraph.append($createTextNode('hello world')) - root.append(paragraph) - }, { discrete: true }) + editor!.update( + () => { + const root = $getRoot() + root.clear() + const paragraph = $createParagraphNode() + paragraph.append($createTextNode('hello world')) + root.append(paragraph) + }, + { discrete: true }, + ) }) await waitFor(() => { diff --git a/web/app/components/workflow/note-node/note-editor/__tests__/store.spec.tsx b/web/app/components/workflow/note-node/note-editor/__tests__/store.spec.tsx index 95687eb1194f13..df0ed05ffc49da 100644 --- a/web/app/components/workflow/note-node/note-editor/__tests__/store.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/__tests__/store.spec.tsx @@ -53,12 +53,12 @@ describe('note editor store', () => { it('reads note editor state from context hooks', () => { const store = createNoteEditorStore() const wrapper = ({ children }: { children: React.ReactNode }) => ( - <NoteEditorContext.Provider value={store}> - {children} - </NoteEditorContext.Provider> + <NoteEditorContext.Provider value={store}>{children}</NoteEditorContext.Provider> ) - const { result: selectedResult } = renderHook(() => useStore(state => state.selectedIsBold), { wrapper }) + const { result: selectedResult } = renderHook(() => useStore((state) => state.selectedIsBold), { + wrapper, + }) const { result: storeResult } = renderHook(() => useNoteEditorStore(), { wrapper }) act(() => { @@ -70,7 +70,7 @@ describe('note editor store', () => { }) it('throws when the note editor store provider is missing', () => { - expect(() => renderHook(() => useStore(state => state.selectedIsBold))).toThrow( + expect(() => renderHook(() => useStore((state) => state.selectedIsBold))).toThrow( 'Missing NoteEditorContext.Provider in the tree', ) }) diff --git a/web/app/components/workflow/note-node/note-editor/context.tsx b/web/app/components/workflow/note-node/note-editor/context.tsx index ec60dd0733d3a1..dafd496d8724af 100644 --- a/web/app/components/workflow/note-node/note-editor/context.tsx +++ b/web/app/components/workflow/note-node/note-editor/context.tsx @@ -1,19 +1,11 @@ 'use client' import { LinkNode } from '@lexical/link' -import { - ListItemNode, - ListNode, -} from '@lexical/list' +import { ListItemNode, ListNode } from '@lexical/list' import { LexicalComposer } from '@lexical/react/LexicalComposer' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { $getRoot } from 'lexical' -import { - createContext, - memo, - useEffect, - useRef, -} from 'react' +import { createContext, memo, useEffect, useRef } from 'react' import { createNoteEditorStore } from './store' import theme from './theme' @@ -23,8 +15,7 @@ const NoteEditorContentSynchronizer = ({ value }: { value?: string }) => { useEffect(() => { const normalizedValue = normalizeEditorState(value) - if (normalizedValue === lastSyncedValueRef.current) - return + if (normalizedValue === lastSyncedValueRef.current) return const currentSerializedState = JSON.stringify(editor.getEditorState().toJSON()) if (normalizedValue === currentSerializedState) { @@ -56,8 +47,7 @@ const NoteEditorContentSynchronizer = ({ value }: { value?: string }) => { const nextState = editor.parseEditorState(normalizedValue) editor.setEditorState(nextState) lastSyncedValueRef.current = normalizedValue - } - catch { + } catch { lastSyncedValueRef.current = '' } }, [editor, value]) @@ -73,65 +63,51 @@ type NoteEditorContextProviderProps = { children: React.JSX.Element | string | (React.JSX.Element | string)[] editable?: boolean } -export const NoteEditorContextProvider = memo(({ - value, - children, - editable = true, -}: NoteEditorContextProviderProps) => { - const storeRef = useRef<NoteEditorStore | undefined>(undefined) - - if (!storeRef.current) - storeRef.current = createNoteEditorStore() - - let initialValue = null - try { - if (value) - initialValue = JSON.parse(value) - } - catch { +export const NoteEditorContextProvider = memo( + ({ value, children, editable = true }: NoteEditorContextProviderProps) => { + const storeRef = useRef<NoteEditorStore | undefined>(undefined) - } + if (!storeRef.current) storeRef.current = createNoteEditorStore() - const initialConfig = { - namespace: 'note-editor', - nodes: [ - LinkNode, - ListNode, - ListItemNode, - ], - editorState: !initialValue?.root.children.length ? null : JSON.stringify(initialValue), - onError: (error: Error) => { - throw error - }, - theme, - editable, - } + let initialValue = null + try { + if (value) initialValue = JSON.parse(value) + } catch {} + + const initialConfig = { + namespace: 'note-editor', + nodes: [LinkNode, ListNode, ListItemNode], + editorState: !initialValue?.root.children.length ? null : JSON.stringify(initialValue), + onError: (error: Error) => { + throw error + }, + theme, + editable, + } - return ( - <NoteEditorContext.Provider value={storeRef.current}> - <LexicalComposer initialConfig={{ ...initialConfig }}> - <NoteEditorContentSynchronizer value={value} /> - {children} - </LexicalComposer> - </NoteEditorContext.Provider> - ) -}) + return ( + <NoteEditorContext.Provider value={storeRef.current}> + <LexicalComposer initialConfig={{ ...initialConfig }}> + <NoteEditorContentSynchronizer value={value} /> + {children} + </LexicalComposer> + </NoteEditorContext.Provider> + ) + }, +) NoteEditorContextProvider.displayName = 'NoteEditorContextProvider' export default NoteEditorContext function normalizeEditorState(value?: string): string { - if (!value) - return '' + if (!value) return '' try { const parsed = JSON.parse(value) - if (!parsed || typeof parsed !== 'object' || !parsed.root) - return '' + if (!parsed || typeof parsed !== 'object' || !parsed.root) return '' return JSON.stringify(parsed) - } - catch { + } catch { return '' } } diff --git a/web/app/components/workflow/note-node/note-editor/editor.tsx b/web/app/components/workflow/note-node/note-editor/editor.tsx index 7c3f84ed604edc..d91759d5f647be 100644 --- a/web/app/components/workflow/note-node/note-editor/editor.tsx +++ b/web/app/components/workflow/note-node/note-editor/editor.tsx @@ -9,10 +9,7 @@ import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin' import { ListPlugin } from '@lexical/react/LexicalListPlugin' import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' -import { - memo, - useCallback, -} from 'react' +import { memo, useCallback } from 'react' // import TreeView from '@/app/components/base/prompt-editor/plugins/tree-view' import Placeholder from '@/app/components/base/prompt-editor/plugins/placeholder' import FormatDetectorPlugin from './plugins/format-detector-plugin' @@ -30,14 +27,17 @@ const Editor = ({ containerElement, setHistoryShortcutsEnabled, }: EditorProps) => { - const handleEditorChange = useCallback((editorState: EditorState) => { - onChange?.(editorState) - }, [onChange]) + const handleEditorChange = useCallback( + (editorState: EditorState) => { + onChange?.(editorState) + }, + [onChange], + ) return ( <div className="relative"> <RichTextPlugin - contentEditable={( + contentEditable={ <div> <ContentEditable onFocus={() => setHistoryShortcutsEnabled?.(false)} @@ -46,7 +46,7 @@ const Editor = ({ className="size-full text-text-secondary caret-primary-600 outline-hidden" /> </div> - )} + } placeholder={<Placeholder value={placeholder} compact />} ErrorBoundary={LexicalErrorBoundary} /> diff --git a/web/app/components/workflow/note-node/note-editor/plugins/format-detector-plugin/__tests__/hooks.spec.tsx b/web/app/components/workflow/note-node/note-editor/plugins/format-detector-plugin/__tests__/hooks.spec.tsx index e202b51c3c65ef..d52b23d765c282 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/format-detector-plugin/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/plugins/format-detector-plugin/__tests__/hooks.spec.tsx @@ -43,25 +43,42 @@ const { })) vi.mock('@lexical/react/LexicalComposerContext', () => ({ - useLexicalComposerContext: () => ([{ - isComposing: mockEditorIsComposing, - getEditorState: () => ({ - read: (callback: () => void) => callback(), - }), - registerUpdateListener: mockRegisterUpdateListener, - }]), + useLexicalComposerContext: () => [ + { + isComposing: mockEditorIsComposing, + getEditorState: () => ({ + read: (callback: () => void) => callback(), + }), + registerUpdateListener: mockRegisterUpdateListener, + }, + ], })) vi.mock('@lexical/utils', () => ({ - mergeRegister: (...cleanups: Array<() => void>) => () => cleanups.forEach(cleanup => cleanup()), + mergeRegister: + (...cleanups: Array<() => void>) => + () => + cleanups.forEach((cleanup) => cleanup()), })) vi.mock('@lexical/link', () => ({ - $isLinkNode: (node: unknown) => Boolean(node && typeof node === 'object' && 'isLink' in (node as object) && (node as { isLink?: boolean }).isLink), + $isLinkNode: (node: unknown) => + Boolean( + node && + typeof node === 'object' && + 'isLink' in (node as object) && + (node as { isLink?: boolean }).isLink, + ), })) vi.mock('@lexical/list', () => ({ - $isListItemNode: (node: unknown) => Boolean(node && typeof node === 'object' && 'isListItem' in (node as object) && (node as { isListItem?: boolean }).isListItem), + $isListItemNode: (node: unknown) => + Boolean( + node && + typeof node === 'object' && + 'isListItem' in (node as object) && + (node as { isListItem?: boolean }).isListItem, + ), })) vi.mock('lexical', () => ({ @@ -95,7 +112,9 @@ describe('format detector hook', () => { vi.clearAllMocks() mockIsRangeSelection.mockReturnValue(true) mockEditorIsComposing.mockReturnValue(false) - mockSelection.hasFormat.mockImplementation((format: string) => format === 'bold' || format === 'strikethrough') + mockSelection.hasFormat.mockImplementation( + (format: string) => format === 'bold' || format === 'strikethrough', + ) mockSelectedNode.isLink = false mockSelectedNode.isListItem = false mockSelectedNode.getURL.mockReturnValue('') diff --git a/web/app/components/workflow/note-node/note-editor/plugins/format-detector-plugin/hooks.ts b/web/app/components/workflow/note-node/note-editor/plugins/format-detector-plugin/hooks.ts index 0ad7e7828370df..85f64210657935 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/format-detector-plugin/hooks.ts +++ b/web/app/components/workflow/note-node/note-editor/plugins/format-detector-plugin/hooks.ts @@ -3,14 +3,8 @@ import { $isLinkNode } from '@lexical/link' import { $isListItemNode } from '@lexical/list' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { - $getSelection, - $isRangeSelection, -} from 'lexical' -import { - useCallback, - useEffect, -} from 'react' +import { $getSelection, $isRangeSelection } from 'lexical' +import { useCallback, useEffect } from 'react' import { useNoteEditorStore } from '../../store' import { getSelectedNode } from '../../utils' @@ -20,8 +14,7 @@ export const useFormatDetector = () => { const handleFormat = useCallback(() => { editor.getEditorState().read(() => { - if (editor.isComposing()) - return + if (editor.isComposing()) return const selection = $getSelection() @@ -40,19 +33,16 @@ export const useFormatDetector = () => { setSelectedIsStrikeThrough(selection.hasFormat('strikethrough')) const parent = node.getParent() if ($isLinkNode(parent) || $isLinkNode(node)) { - const linkUrl = ($isLinkNode(parent) ? parent : node as LinkNode).getURL() + const linkUrl = ($isLinkNode(parent) ? parent : (node as LinkNode)).getURL() setSelectedLinkUrl(linkUrl) setSelectedIsLink(true) - } - else { + } else { setSelectedLinkUrl('') setSelectedIsLink(false) } - if ($isListItemNode(parent) || $isListItemNode(node)) - setSelectedIsBullet(true) - else - setSelectedIsBullet(false) + if ($isListItemNode(parent) || $isListItemNode(node)) setSelectedIsBullet(true) + else setSelectedIsBullet(false) } }) }, [editor, noteEditorStore]) diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/hooks.spec.tsx b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/hooks.spec.tsx index c0c6767b4683c8..93913ecdacfc48 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/hooks.spec.tsx @@ -14,7 +14,11 @@ const { } = vi.hoisted(() => { const listeners: { update?: () => void - click?: (payload: { metaKey?: boolean, ctrlKey?: boolean, target?: EventTarget | null }) => boolean + click?: (payload: { + metaKey?: boolean + ctrlKey?: boolean + target?: EventTarget | null + }) => boolean } = {} const editor = { @@ -44,11 +48,14 @@ const { }) vi.mock('@lexical/react/LexicalComposerContext', () => ({ - useLexicalComposerContext: () => ([mockEditor]), + useLexicalComposerContext: () => [mockEditor], })) vi.mock('@lexical/utils', () => ({ - mergeRegister: (...cleanups: Array<() => void>) => () => cleanups.forEach(cleanup => cleanup()), + mergeRegister: + (...cleanups: Array<() => void>) => + () => + cleanups.forEach((cleanup) => cleanup()), })) vi.mock('@lexical/link', () => ({ @@ -186,7 +193,10 @@ describe('link editor hooks', () => { result.current.handleSaveLink('https://dify.ai/docs') }) - expect(mockDispatchCommand).toHaveBeenCalledWith('toggle-link-command', 'https://dify.ai/docs') + expect(mockDispatchCommand).toHaveBeenCalledWith( + 'toggle-link-command', + 'https://dify.ai/docs', + ) expect(mockSetLinkAnchorElement).toHaveBeenCalledWith() }) diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/index.spec.tsx index 89c554ed4a251f..4344b20328b839 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/index.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/index.spec.tsx @@ -9,11 +9,7 @@ type NoteEditorStore = ReturnType<typeof createNoteEditorStore> const emptyValue = JSON.stringify({ root: { children: [] } }) -const StoreProbe = ({ - onReady, -}: { - onReady?: (store: NoteEditorStore) => void -}) => { +const StoreProbe = ({ onReady }: { onReady?: (store: NoteEditorStore) => void }) => { const store = useNoteEditorStore() useEffect(() => { @@ -46,7 +42,7 @@ describe('LinkEditorPlugin', () => { render( <NoteEditorContextProvider value={emptyValue}> - <StoreProbe onReady={instance => (store = instance)} /> + <StoreProbe onReady={(instance) => (store = instance)} /> <LinkEditorPlugin containerElement={document.createElement('div')} /> </NoteEditorContextProvider>, ) diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx index abe7e646789592..8aa4ceaea9712a 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx @@ -1,26 +1,10 @@ -import { - flip, - FloatingPortal, - offset, - shift, - useFloating, -} from '@floating-ui/react' +import { flip, FloatingPortal, offset, shift, useFloating } from '@floating-ui/react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - RiEditLine, - RiExternalLinkLine, - RiLinkUnlinkM, -} from '@remixicon/react' +import { RiEditLine, RiExternalLinkLine, RiLinkUnlinkM } from '@remixicon/react' import { useClickAway } from 'ahooks' import { escape } from 'es-toolkit/string' -import { - memo, - useCallback, - useEffect, - useRef, - useState, -} from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore } from '../../store' import { useLink } from './hooks' @@ -28,28 +12,19 @@ import { useLink } from './hooks' type LinkEditorComponentProps = { containerElement: HTMLDivElement | null } -const LinkEditorComponent = ({ - containerElement, -}: LinkEditorComponentProps) => { +const LinkEditorComponent = ({ containerElement }: LinkEditorComponentProps) => { const { t } = useTranslation() - const { - handleSaveLink, - handleUnlink, - } = useLink() - const selectedLinkUrl = useStore(s => s.selectedLinkUrl) - const linkAnchorElement = useStore(s => s.linkAnchorElement) - const linkOperatorShow = useStore(s => s.linkOperatorShow) - const setLinkAnchorElement = useStore(s => s.setLinkAnchorElement) - const setLinkOperatorShow = useStore(s => s.setLinkOperatorShow) + const { handleSaveLink, handleUnlink } = useLink() + const selectedLinkUrl = useStore((s) => s.selectedLinkUrl) + const linkAnchorElement = useStore((s) => s.linkAnchorElement) + const linkOperatorShow = useStore((s) => s.linkOperatorShow) + const setLinkAnchorElement = useStore((s) => s.setLinkAnchorElement) + const setLinkOperatorShow = useStore((s) => s.setLinkOperatorShow) const [url, setUrl] = useState(selectedLinkUrl) const floatingRef = useRef<HTMLDivElement | null>(null) const { refs, floatingStyles, elements } = useFloating({ placement: 'top', - middleware: [ - offset(4), - shift(), - flip(), - ], + middleware: [offset(4), shift(), flip()], }) const handleCancelLinkEdit = useCallback(() => { @@ -71,108 +46,97 @@ const LinkEditorComponent = ({ }, [selectedLinkUrl]) useEffect(() => { - if (linkAnchorElement) - refs.setReference(linkAnchorElement) + if (linkAnchorElement) refs.setReference(linkAnchorElement) }, [linkAnchorElement, refs]) return ( <> - { - elements.reference && ( - <FloatingPortal root={containerElement}> - <div - className={cn( - 'nodrag nopan z-10 inline-flex w-max items-center rounded-md border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg', - !linkOperatorShow && 'p-1 shadow-md', - linkOperatorShow && 'p-0.5 system-xs-medium text-text-tertiary shadow-sm', - )} - style={floatingStyles} - ref={(node) => { - refs.setFloating(node) - floatingRef.current = node - }} - > - { - !linkOperatorShow && ( - <> - <input - className="mr-0.5 h-6 w-[196px] appearance-none rounded-xs bg-transparent p-1 text-[13px] text-components-input-text-filled outline-hidden" - value={url} - onChange={e => setUrl(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - e.stopPropagation() - if (url) - handleSaveLink(url) - return - } + {elements.reference && ( + <FloatingPortal root={containerElement}> + <div + className={cn( + 'nodrag nopan z-10 inline-flex w-max items-center rounded-md border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg', + !linkOperatorShow && 'p-1 shadow-md', + linkOperatorShow && 'p-0.5 system-xs-medium text-text-tertiary shadow-sm', + )} + style={floatingStyles} + ref={(node) => { + refs.setFloating(node) + floatingRef.current = node + }} + > + {!linkOperatorShow && ( + <> + <input + className="mr-0.5 h-6 w-[196px] appearance-none rounded-xs bg-transparent p-1 text-[13px] text-components-input-text-filled outline-hidden" + value={url} + onChange={(e) => setUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + if (url) handleSaveLink(url) + return + } - if (e.key === 'Escape') { - e.preventDefault() - e.stopPropagation() - handleCancelLinkEdit() - } - }} - placeholder={t($ => $['nodes.note.editor.enterUrl'], { ns: 'workflow' }) || ''} - autoFocus - /> - <Button - variant="primary" - size="small" - disabled={!url} - onClick={() => handleSaveLink(url)} - > - {t($ => $['operation.ok'], { ns: 'common' })} - </Button> - </> - ) - } - { - linkOperatorShow && ( - <> - <a - className="flex h-6 items-center rounded-md px-2 hover:bg-state-base-hover" - href={escape(url)} - target="_blank" - rel="noopener noreferrer" - > - <RiExternalLinkLine className="mr-1 size-3" /> - <div className="mr-1"> - {t($ => $['nodes.note.editor.openLink'], { ns: 'workflow' })} - </div> - <div - title={escape(url)} - className="max-w-[140px] truncate text-text-accent" - > - {escape(url)} - </div> - </a> - <div className="mx-1 h-3.5 w-px bg-divider-regular"></div> - <div - className="mr-0.5 flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover" - onClick={(e) => { - e.stopPropagation() - setLinkOperatorShow(false) - }} - > - <RiEditLine className="mr-1 size-3" /> - {t($ => $['operation.edit'], { ns: 'common' })} - </div> - <div - className="flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover" - onClick={handleUnlink} - > - <RiLinkUnlinkM className="mr-1 size-3" /> - {t($ => $['nodes.note.editor.unlink'], { ns: 'workflow' })} - </div> - </> - ) - } - </div> - </FloatingPortal> - ) - } + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + handleCancelLinkEdit() + } + }} + placeholder={t(($) => $['nodes.note.editor.enterUrl'], { ns: 'workflow' }) || ''} + autoFocus + /> + <Button + variant="primary" + size="small" + disabled={!url} + onClick={() => handleSaveLink(url)} + > + {t(($) => $['operation.ok'], { ns: 'common' })} + </Button> + </> + )} + {linkOperatorShow && ( + <> + <a + className="flex h-6 items-center rounded-md px-2 hover:bg-state-base-hover" + href={escape(url)} + target="_blank" + rel="noopener noreferrer" + > + <RiExternalLinkLine className="mr-1 size-3" /> + <div className="mr-1"> + {t(($) => $['nodes.note.editor.openLink'], { ns: 'workflow' })} + </div> + <div title={escape(url)} className="max-w-[140px] truncate text-text-accent"> + {escape(url)} + </div> + </a> + <div className="mx-1 h-3.5 w-px bg-divider-regular"></div> + <div + className="mr-0.5 flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover" + onClick={(e) => { + e.stopPropagation() + setLinkOperatorShow(false) + }} + > + <RiEditLine className="mr-1 size-3" /> + {t(($) => $['operation.edit'], { ns: 'common' })} + </div> + <div + className="flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover" + onClick={handleUnlink} + > + <RiLinkUnlinkM className="mr-1 size-3" /> + {t(($) => $['nodes.note.editor.unlink'], { ns: 'workflow' })} + </div> + </> + )} + </div> + </FloatingPortal> + )} </> ) } diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/hooks.ts b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/hooks.ts index 7a39e32c6f30b3..6bf181f6e86686 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/hooks.ts +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/hooks.ts @@ -11,7 +11,7 @@ import { urlRegExp } from '../../utils' const getClickedLinkElement = (target: EventTarget | null) => { return target instanceof HTMLElement - ? target.closest('.note-editor-theme_link') as HTMLElement | null + ? (target.closest('.note-editor-theme_link') as HTMLElement | null) : null } @@ -19,80 +19,85 @@ export const useOpenLink = () => { const [editor] = useLexicalComposerContext() const noteEditorStore = useNoteEditorStore() useEffect(() => { - return mergeRegister(editor.registerUpdateListener(() => { - setTimeout(() => { - const { selectedLinkUrl, selectedIsLink, setLinkAnchorElement, setLinkOperatorShow } = noteEditorStore.getState() - if (selectedIsLink) { - setLinkAnchorElement(true) - if (selectedLinkUrl) - setLinkOperatorShow(true) - else + return mergeRegister( + editor.registerUpdateListener(() => { + setTimeout(() => { + const { selectedLinkUrl, selectedIsLink, setLinkAnchorElement, setLinkOperatorShow } = + noteEditorStore.getState() + if (selectedIsLink) { + setLinkAnchorElement(true) + if (selectedLinkUrl) setLinkOperatorShow(true) + else setLinkOperatorShow(false) + } else { + setLinkAnchorElement() setLinkOperatorShow(false) - } - else { - setLinkAnchorElement() - setLinkOperatorShow(false) - } - }) - }), editor.registerCommand(CLICK_COMMAND, (payload) => { - setTimeout(() => { - const { - selectedLinkUrl, - selectedIsLink, - setLinkAnchorElement, - setLinkOperatorShow, - setSelectedLinkUrl, - setSelectedIsLink, - } = noteEditorStore.getState() - const clickedLinkElement = getClickedLinkElement(payload.target) - const clickedLinkUrl = clickedLinkElement?.getAttribute('href') || selectedLinkUrl - - if (clickedLinkElement && clickedLinkUrl) { - if (payload.metaKey || payload.ctrlKey) { - window.open(clickedLinkUrl, '_blank') - return } + }) + }), + editor.registerCommand( + CLICK_COMMAND, + (payload) => { + setTimeout(() => { + const { + selectedLinkUrl, + selectedIsLink, + setLinkAnchorElement, + setLinkOperatorShow, + setSelectedLinkUrl, + setSelectedIsLink, + } = noteEditorStore.getState() + const clickedLinkElement = getClickedLinkElement(payload.target) + const clickedLinkUrl = clickedLinkElement?.getAttribute('href') || selectedLinkUrl - setSelectedLinkUrl(clickedLinkUrl) - setSelectedIsLink(true) - setLinkAnchorElement(clickedLinkElement) - setLinkOperatorShow(true) - return - } + if (clickedLinkElement && clickedLinkUrl) { + if (payload.metaKey || payload.ctrlKey) { + window.open(clickedLinkUrl, '_blank') + return + } - if (selectedIsLink) { - if ((payload.metaKey || payload.ctrlKey) && selectedLinkUrl) { - window.open(selectedLinkUrl, '_blank') - return - } - setLinkAnchorElement(true) - if (selectedLinkUrl) - setLinkOperatorShow(true) - else - setLinkOperatorShow(false) - } - else { - setLinkAnchorElement() - setLinkOperatorShow(false) - } - }) - return !!getClickedLinkElement(payload.target) - }, COMMAND_PRIORITY_LOW)) + setSelectedLinkUrl(clickedLinkUrl) + setSelectedIsLink(true) + setLinkAnchorElement(clickedLinkElement) + setLinkOperatorShow(true) + return + } + + if (selectedIsLink) { + if ((payload.metaKey || payload.ctrlKey) && selectedLinkUrl) { + window.open(selectedLinkUrl, '_blank') + return + } + setLinkAnchorElement(true) + if (selectedLinkUrl) setLinkOperatorShow(true) + else setLinkOperatorShow(false) + } else { + setLinkAnchorElement() + setLinkOperatorShow(false) + } + }) + return !!getClickedLinkElement(payload.target) + }, + COMMAND_PRIORITY_LOW, + ), + ) }, [editor, noteEditorStore]) } export const useLink = () => { const { t } = useTranslation() const [editor] = useLexicalComposerContext() const noteEditorStore = useNoteEditorStore() - const handleSaveLink = useCallback((url: string) => { - if (url && !urlRegExp.test(url)) { - toast.error(t($ => $['nodes.note.editor.invalidUrl'], { ns: 'workflow' })) - return - } - editor.dispatchCommand(TOGGLE_LINK_COMMAND, escape(url)) - const { setLinkAnchorElement } = noteEditorStore.getState() - setLinkAnchorElement() - }, [editor, noteEditorStore, t]) + const handleSaveLink = useCallback( + (url: string) => { + if (url && !urlRegExp.test(url)) { + toast.error(t(($) => $['nodes.note.editor.invalidUrl'], { ns: 'workflow' })) + return + } + editor.dispatchCommand(TOGGLE_LINK_COMMAND, escape(url)) + const { setLinkAnchorElement } = noteEditorStore.getState() + setLinkAnchorElement() + }, + [editor, noteEditorStore, t], + ) const handleUnlink = useCallback(() => { editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) const { setLinkAnchorElement } = noteEditorStore.getState() diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/index.tsx b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/index.tsx index e7d4a99cfbb2a9..4141f8e7426ecf 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/index.tsx +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/index.tsx @@ -1,6 +1,4 @@ -import { - memo, -} from 'react' +import { memo } from 'react' import { useStore } from '../../store' import LinkEditorComponent from './component' import { useOpenLink } from './hooks' @@ -8,18 +6,13 @@ import { useOpenLink } from './hooks' type LinkEditorPluginProps = { containerElement: HTMLDivElement | null } -const LinkEditorPlugin = ({ - containerElement, -}: LinkEditorPluginProps) => { +const LinkEditorPlugin = ({ containerElement }: LinkEditorPluginProps) => { useOpenLink() - const linkAnchorElement = useStore(s => s.linkAnchorElement) + const linkAnchorElement = useStore((s) => s.linkAnchorElement) - if (!linkAnchorElement) - return null + if (!linkAnchorElement) return null - return ( - <LinkEditorComponent containerElement={containerElement} /> - ) + return <LinkEditorComponent containerElement={containerElement} /> } export default memo(LinkEditorPlugin) diff --git a/web/app/components/workflow/note-node/note-editor/store.ts b/web/app/components/workflow/note-node/note-editor/store.ts index b2eafa2066bb31..996817d35788ea 100644 --- a/web/app/components/workflow/note-node/note-editor/store.ts +++ b/web/app/components/workflow/note-node/note-editor/store.ts @@ -1,7 +1,5 @@ import { use } from 'react' -import { - useStore as useZustandStore, -} from 'zustand' +import { useStore as useZustandStore } from 'zustand' import { createStore } from 'zustand/vanilla' import NoteEditorContext from './context' @@ -25,7 +23,7 @@ type Shape = { } export const createNoteEditorStore = () => { - return createStore<Shape>(set => ({ + return createStore<Shape>((set) => ({ linkAnchorElement: null, setLinkAnchorElement: (open) => { if (open instanceof HTMLElement) { @@ -42,32 +40,31 @@ export const createNoteEditorStore = () => { set(() => ({ linkAnchorElement: parent })) } }) - } - else { + } else { set(() => ({ linkAnchorElement: null })) } }, linkOperatorShow: false, - setLinkOperatorShow: linkOperatorShow => set(() => ({ linkOperatorShow })), + setLinkOperatorShow: (linkOperatorShow) => set(() => ({ linkOperatorShow })), selectedIsBold: false, - setSelectedIsBold: selectedIsBold => set(() => ({ selectedIsBold })), + setSelectedIsBold: (selectedIsBold) => set(() => ({ selectedIsBold })), selectedIsItalic: false, - setSelectedIsItalic: selectedIsItalic => set(() => ({ selectedIsItalic })), + setSelectedIsItalic: (selectedIsItalic) => set(() => ({ selectedIsItalic })), selectedIsStrikeThrough: false, - setSelectedIsStrikeThrough: selectedIsStrikeThrough => set(() => ({ selectedIsStrikeThrough })), + setSelectedIsStrikeThrough: (selectedIsStrikeThrough) => + set(() => ({ selectedIsStrikeThrough })), selectedLinkUrl: '', - setSelectedLinkUrl: selectedLinkUrl => set(() => ({ selectedLinkUrl })), + setSelectedLinkUrl: (selectedLinkUrl) => set(() => ({ selectedLinkUrl })), selectedIsLink: false, - setSelectedIsLink: selectedIsLink => set(() => ({ selectedIsLink })), + setSelectedIsLink: (selectedIsLink) => set(() => ({ selectedIsLink })), selectedIsBullet: false, - setSelectedIsBullet: selectedIsBullet => set(() => ({ selectedIsBullet })), + setSelectedIsBullet: (selectedIsBullet) => set(() => ({ selectedIsBullet })), })) } export function useStore<T>(selector: (state: Shape) => T): T { const store = use(NoteEditorContext) - if (!store) - throw new Error('Missing NoteEditorContext.Provider in the tree') + if (!store) throw new Error('Missing NoteEditorContext.Provider in the tree') return useZustandStore(store, selector) } diff --git a/web/app/components/workflow/note-node/note-editor/theme/index.ts b/web/app/components/workflow/note-node/note-editor/theme/index.ts index a815291d05d61b..80bb4ea0a02056 100644 --- a/web/app/components/workflow/note-node/note-editor/theme/index.ts +++ b/web/app/components/workflow/note-node/note-editor/theme/index.ts @@ -1,5 +1,4 @@ import type { EditorThemeClasses } from 'lexical' - import './theme.css' const theme: EditorThemeClasses = { diff --git a/web/app/components/workflow/note-node/note-editor/theme/theme.css b/web/app/components/workflow/note-node/note-editor/theme/theme.css index 6d22c58b1cc272..9d1cf07c6bcc2b 100644 --- a/web/app/components/workflow/note-node/note-editor/theme/theme.css +++ b/web/app/components/workflow/note-node/note-editor/theme/theme.css @@ -22,7 +22,9 @@ text-decoration-thickness: 1px; text-underline-offset: 2px; text-decoration-color: color-mix(in srgb, var(--color-text-accent) 60%, transparent); - transition: color 0.15s ease, text-decoration-color 0.15s ease; + transition: + color 0.15s ease, + text-decoration-color 0.15s ease; } .note-editor-theme_link:hover { diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx index b70559aa6bfc6c..a81169d006d40f 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx @@ -7,9 +7,7 @@ vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover describe('NoteEditor ColorPicker', () => { it('should open the palette and apply the selected theme', async () => { const onThemeChange = vi.fn() - render( - <ColorPicker theme={NoteTheme.blue} onThemeChange={onThemeChange} />, - ) + render(<ColorPicker theme={NoteTheme.blue} onThemeChange={onThemeChange} />) fireEvent.click(screen.getByTestId('popover-trigger')) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx index bce7bb326d1ca0..0a0e6c8619221d 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx @@ -3,10 +3,7 @@ import FontSizeSelector from '../font-size-selector' vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) -const { - mockHandleFontSize, - mockHandleOpenFontSizeSelector, -} = vi.hoisted(() => ({ +const { mockHandleFontSize, mockHandleOpenFontSizeSelector } = vi.hoisted(() => ({ mockHandleFontSize: vi.fn(), mockHandleOpenFontSizeSelector: vi.fn(), })) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/hooks.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/hooks.spec.tsx index af9aaac915b571..f914b5fffb670a 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/hooks.spec.tsx @@ -47,19 +47,22 @@ const { })) vi.mock('@lexical/react/LexicalComposerContext', () => ({ - useLexicalComposerContext: () => ([{ - dispatchCommand: mockDispatchCommand, - update: mockEditorUpdate, - registerUpdateListener: mockRegisterUpdateListener, - registerCommand: mockRegisterCommand, - getEditorState: () => ({ - read: mockRead, - }), - }]), + useLexicalComposerContext: () => [ + { + dispatchCommand: mockDispatchCommand, + update: mockEditorUpdate, + registerUpdateListener: mockRegisterUpdateListener, + registerCommand: mockRegisterCommand, + getEditorState: () => ({ + read: mockRead, + }), + }, + ], })) vi.mock('@lexical/link', () => ({ - $isLinkNode: (node: unknown) => Boolean(node && typeof node === 'object' && 'isLink' in (node as object)), + $isLinkNode: (node: unknown) => + Boolean(node && typeof node === 'object' && 'isLink' in (node as object)), TOGGLE_LINK_COMMAND: 'toggle-link-command', })) @@ -75,7 +78,10 @@ vi.mock('@lexical/selection', () => ({ })) vi.mock('@lexical/utils', () => ({ - mergeRegister: (...cleanups: Array<() => void>) => () => cleanups.forEach(cleanup => cleanup()), + mergeRegister: + (...cleanups: Array<() => void>) => + () => + cleanups.forEach((cleanup) => cleanup()), })) vi.mock('lexical', () => ({ diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx index cb50f641b41c10..74cc15b5eb1488 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx @@ -2,15 +2,13 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { NoteTheme } from '../../../types' import Toolbar from '../index' -const { - mockHandleCommand, - mockHandleFontSize, - mockHandleOpenFontSizeSelector, -} = vi.hoisted(() => ({ - mockHandleCommand: vi.fn(), - mockHandleFontSize: vi.fn(), - mockHandleOpenFontSizeSelector: vi.fn(), -})) +const { mockHandleCommand, mockHandleFontSize, mockHandleOpenFontSizeSelector } = vi.hoisted( + () => ({ + mockHandleCommand: vi.fn(), + mockHandleFontSize: vi.fn(), + mockHandleOpenFontSizeSelector: vi.fn(), + }), +) let mockFontSizeSelectorShow = false let mockFontSize = '14px' diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx index 52e6c6cdf1526b..f51f9e5724c9fa 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx @@ -1,43 +1,48 @@ -import type { - MouseEvent, - MouseEventHandler, - ReactElement, - ReactNode, -} from 'react' +import type { MouseEvent, MouseEventHandler, ReactElement, ReactNode } from 'react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import Operator from '../operator' type DropdownTriggerRenderProps = { - 'className'?: string - 'role'?: string + className?: string + role?: string 'aria-label'?: string - 'onMouseDown'?: MouseEventHandler<HTMLDivElement> - 'onClick'?: MouseEventHandler<HTMLDivElement> + onMouseDown?: MouseEventHandler<HTMLDivElement> + onClick?: MouseEventHandler<HTMLDivElement> } type DropdownTriggerProps = { - 'children': ReactNode - 'className'?: string - 'render'?: ReactElement<DropdownTriggerRenderProps> - 'onMouseDown'?: MouseEventHandler<HTMLDivElement> - 'onClick'?: MouseEventHandler<HTMLDivElement> + children: ReactNode + className?: string + render?: ReactElement<DropdownTriggerRenderProps> + onMouseDown?: MouseEventHandler<HTMLDivElement> + onClick?: MouseEventHandler<HTMLDivElement> 'aria-label'?: string } vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') - const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + const DropdownMenuContext = React.createContext<{ + open: boolean + setOpen: (open: boolean) => void + } | null>(null) const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + DropdownMenu: ({ + children, + open, + onOpenChange, + }: { + children: ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( <DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}> <div>{children}</div> </DropdownMenuContext> @@ -53,7 +58,9 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const { open, setOpen } = useDropdownMenuContext() if (render) { const handleMouseDown = (event: MouseEvent<HTMLDivElement>) => { - const baseUiEvent = event as MouseEvent<HTMLDivElement> & { preventBaseUIHandler?: () => void } + const baseUiEvent = event as MouseEvent<HTMLDivElement> & { + preventBaseUIHandler?: () => void + } baseUiEvent.preventBaseUIHandler = vi.fn() onMouseDown?.(baseUiEvent) render.props.onMouseDown?.(event) @@ -62,8 +69,7 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const handleClick = (event: MouseEvent<HTMLDivElement>) => { onClick?.(event) render.props.onClick?.(event) - if (!onMouseDown) - setOpen(!open) + if (!onMouseDown) setOpen(!open) } return ( @@ -85,14 +91,15 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { aria-label={ariaLabel} className={className} onMouseDown={(event) => { - const baseUiEvent = event as MouseEvent<HTMLButtonElement> & { preventBaseUIHandler?: () => void } + const baseUiEvent = event as MouseEvent<HTMLButtonElement> & { + preventBaseUIHandler?: () => void + } baseUiEvent.preventBaseUIHandler = vi.fn() onMouseDown?.(baseUiEvent as unknown as MouseEvent<HTMLDivElement>) }} onClick={(event) => { onClick?.(event as unknown as MouseEvent<HTMLDivElement>) - if (!onMouseDown) - setOpen(!open) + if (!onMouseDown) setOpen(!open) }} > {children} @@ -126,7 +133,9 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { </button> ) }, - DropdownMenuSeparator: ({ className }: { className?: string }) => <div className={className} data-testid="dropdown-separator" />, + DropdownMenuSeparator: ({ className }: { className?: string }) => ( + <div className={className} data-testid="dropdown-separator" /> + ), } }) @@ -157,11 +166,7 @@ const renderOperator = (showAuthor = false) => { describe('NoteEditor Toolbar Operator', () => { it('triggers copy, duplicate, and delete from the opened menu', async () => { const user = userEvent.setup() - const { - onCopy, - onDelete, - onDuplicate, - } = renderOperator() + const { onCopy, onDelete, onDuplicate } = renderOperator() await user.click(screen.getByRole('button', { name: 'common.operation.more' })) await user.click(screen.getByText('workflow.common.copy')) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx index 81d9978c1621f9..6ebaf6edbf0547 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx @@ -1,13 +1,6 @@ import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - memo, - useState, -} from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { memo, useState } from 'react' import { THEME_MAP } from '../../constants' import { NoteTheme } from '../../types' @@ -48,20 +41,14 @@ export type ColorPickerProps = { theme: NoteTheme onThemeChange: (theme: NoteTheme) => void } -const ColorPicker = ({ - theme, - onThemeChange, -}: ColorPickerProps) => { +const ColorPicker = ({ theme, onThemeChange }: ColorPickerProps) => { const [open, setOpen] = useState(false) return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton - render={( + render={ <button type="button" className={cn( @@ -70,14 +57,10 @@ const ColorPicker = ({ )} > <div - className={cn( - 'size-4 rounded-full border border-black/5', - THEME_MAP[theme]!.title, - )} - > - </div> + className={cn('size-4 rounded-full border border-black/5', THEME_MAP[theme]!.title)} + ></div> </button> - )} + } /> <PopoverContent placement="top" @@ -85,34 +68,30 @@ const ColorPicker = ({ popupClassName="border-none bg-transparent shadow-none" > <div className="grid grid-cols-3 grid-rows-2 gap-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg"> - { - COLOR_LIST.map(color => ( + {COLOR_LIST.map((color) => ( + <div + key={color.key} + className="group relative flex size-8 cursor-pointer items-center justify-center rounded-md" + onClick={(e) => { + e.stopPropagation() + onThemeChange(color.key) + setOpen(false) + }} + > + <div + className={cn( + 'absolute top-1/2 left-1/2 hidden h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[1.5px] group-hover:block', + color.outer, + )} + ></div> <div - key={color.key} - className="group relative flex size-8 cursor-pointer items-center justify-center rounded-md" - onClick={(e) => { - e.stopPropagation() - onThemeChange(color.key) - setOpen(false) - }} - > - <div - className={cn( - 'absolute top-1/2 left-1/2 hidden h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[1.5px] group-hover:block', - color.outer, - )} - > - </div> - <div - className={cn( - 'absolute top-1/2 left-1/2 size-4 -translate-1/2 rounded-full border border-black/5', - color.inner, - )} - > - </div> - </div> - )) - } + className={cn( + 'absolute top-1/2 left-1/2 size-4 -translate-1/2 rounded-full border border-black/5', + color.inner, + )} + ></div> + </div> + ))} </div> </PopoverContent> </Popover> diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/command.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/command.tsx index fed4db41950b86..e1a61611f315e1 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/command.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/command.tsx @@ -1,9 +1,6 @@ import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useStore } from '../store' import { useCommand } from './hooks' @@ -11,51 +8,84 @@ import { useCommand } from './hooks' type CommandProps = { type: 'bold' | 'italic' | 'strikethrough' | 'link' | 'bullet' } -const Command = ({ - type, -}: CommandProps) => { +const Command = ({ type }: CommandProps) => { const { t } = useTranslation() - const selectedIsBold = useStore(s => s.selectedIsBold) - const selectedIsItalic = useStore(s => s.selectedIsItalic) - const selectedIsStrikeThrough = useStore(s => s.selectedIsStrikeThrough) - const selectedIsLink = useStore(s => s.selectedIsLink) - const selectedIsBullet = useStore(s => s.selectedIsBullet) + const selectedIsBold = useStore((s) => s.selectedIsBold) + const selectedIsItalic = useStore((s) => s.selectedIsItalic) + const selectedIsStrikeThrough = useStore((s) => s.selectedIsStrikeThrough) + const selectedIsLink = useStore((s) => s.selectedIsLink) + const selectedIsBullet = useStore((s) => s.selectedIsBullet) const { handleCommand } = useCommand() const icon = useMemo(() => { switch (type) { case 'bold': - return <span aria-hidden className={cn('i-ri-bold size-4', selectedIsBold && 'text-primary-600')} /> + return ( + <span + aria-hidden + className={cn('i-ri-bold size-4', selectedIsBold && 'text-primary-600')} + /> + ) case 'italic': - return <span aria-hidden className={cn('i-ri-italic size-4', selectedIsItalic && 'text-primary-600')} /> + return ( + <span + aria-hidden + className={cn('i-ri-italic size-4', selectedIsItalic && 'text-primary-600')} + /> + ) case 'strikethrough': - return <span aria-hidden className={cn('i-ri-strikethrough size-4', selectedIsStrikeThrough && 'text-primary-600')} /> + return ( + <span + aria-hidden + className={cn( + 'i-ri-strikethrough size-4', + selectedIsStrikeThrough && 'text-primary-600', + )} + /> + ) case 'link': - return <span aria-hidden className={cn('i-ri-link size-4', selectedIsLink && 'text-primary-600')} /> + return ( + <span + aria-hidden + className={cn('i-ri-link size-4', selectedIsLink && 'text-primary-600')} + /> + ) case 'bullet': - return <span aria-hidden className={cn('i-ri-list-unordered size-4', selectedIsBullet && 'text-primary-600')} /> + return ( + <span + aria-hidden + className={cn('i-ri-list-unordered size-4', selectedIsBullet && 'text-primary-600')} + /> + ) } - }, [type, selectedIsBold, selectedIsItalic, selectedIsStrikeThrough, selectedIsLink, selectedIsBullet]) + }, [ + type, + selectedIsBold, + selectedIsItalic, + selectedIsStrikeThrough, + selectedIsLink, + selectedIsBullet, + ]) const tip = useMemo(() => { switch (type) { case 'bold': - return t($ => $['nodes.note.editor.bold'], { ns: 'workflow' }) + return t(($) => $['nodes.note.editor.bold'], { ns: 'workflow' }) case 'italic': - return t($ => $['nodes.note.editor.italic'], { ns: 'workflow' }) + return t(($) => $['nodes.note.editor.italic'], { ns: 'workflow' }) case 'strikethrough': - return t($ => $['nodes.note.editor.strikethrough'], { ns: 'workflow' }) + return t(($) => $['nodes.note.editor.strikethrough'], { ns: 'workflow' }) case 'link': - return t($ => $['nodes.note.editor.link'], { ns: 'workflow' }) + return t(($) => $['nodes.note.editor.link'], { ns: 'workflow' }) case 'bullet': - return t($ => $['nodes.note.editor.bulletList'], { ns: 'workflow' }) + return t(($) => $['nodes.note.editor.bulletList'], { ns: 'workflow' }) } }, [type, t]) return ( <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" aria-label={tip} @@ -71,7 +101,7 @@ const Command = ({ > {icon} </button> - )} + } /> <TooltipContent>{tip}</TooltipContent> </Tooltip> diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/divider.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/divider.tsx index 246665fec7454a..9d5e3bf5428da0 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/divider.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/divider.tsx @@ -1,7 +1,5 @@ const Divider = () => { - return ( - <div className="mx-1 h-3.5 w-px bg-divider-regular"></div> - ) + return <div className="mx-1 h-3.5 w-px bg-divider-regular"></div> } export default Divider diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx index 343b740554e860..ec2ddd055246da 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx @@ -1,9 +1,5 @@ import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiFontSize } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' @@ -15,32 +11,25 @@ const FontSizeSelector = () => { const FONT_SIZE_LIST = [ { key: '12px', - value: t($ => $['nodes.note.editor.small'], { ns: 'workflow' }), + value: t(($) => $['nodes.note.editor.small'], { ns: 'workflow' }), }, { key: '14px', - value: t($ => $['nodes.note.editor.medium'], { ns: 'workflow' }), + value: t(($) => $['nodes.note.editor.medium'], { ns: 'workflow' }), }, { key: '16px', - value: t($ => $['nodes.note.editor.large'], { ns: 'workflow' }), + value: t(($) => $['nodes.note.editor.large'], { ns: 'workflow' }), }, ] - const { - fontSizeSelectorShow, - handleOpenFontSizeSelector, - fontSize, - handleFontSize, - } = useFontSize() + const { fontSizeSelectorShow, handleOpenFontSizeSelector, fontSize, handleFontSize } = + useFontSize() return ( - <Popover - open={fontSizeSelectorShow} - onOpenChange={handleOpenFontSizeSelector} - > + <Popover open={fontSizeSelectorShow} onOpenChange={handleOpenFontSizeSelector}> <PopoverTrigger nativeButton - render={( + render={ <button type="button" className={cn( @@ -49,9 +38,10 @@ const FontSizeSelector = () => { )} > <RiFontSize className="mr-1 size-4" /> - {FONT_SIZE_LIST.find(font => font.key === fontSize)?.value || t($ => $['nodes.note.editor.small'], { ns: 'workflow' })} + {FONT_SIZE_LIST.find((font) => font.key === fontSize)?.value || + t(($) => $['nodes.note.editor.small'], { ns: 'workflow' })} </button> - )} + } /> <PopoverContent placement="bottom-start" @@ -59,30 +49,20 @@ const FontSizeSelector = () => { popupClassName="border-none bg-transparent shadow-none" > <div className="w-[120px] rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 text-text-secondary shadow-xl"> - { - FONT_SIZE_LIST.map(font => ( - <div - key={font.key} - className="flex h-8 cursor-pointer items-center justify-between rounded-md pr-2 pl-3 hover:bg-state-base-hover" - onClick={(e) => { - e.stopPropagation() - handleFontSize(font.key) - handleOpenFontSizeSelector(false) - }} - > - <div - style={{ fontSize: font.key }} - > - {font.value} - </div> - { - fontSize === font.key && ( - <Check className="size-4 text-text-accent" /> - ) - } - </div> - )) - } + {FONT_SIZE_LIST.map((font) => ( + <div + key={font.key} + className="flex h-8 cursor-pointer items-center justify-between rounded-md pr-2 pl-3 hover:bg-state-base-hover" + onClick={(e) => { + e.stopPropagation() + handleFontSize(font.key) + handleOpenFontSizeSelector(false) + }} + > + <div style={{ fontSize: font.key }}>{font.value}</div> + {fontSize === font.key && <Check className="size-4 text-text-accent" />} + </div> + ))} </div> </PopoverContent> </Popover> diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/hooks.ts b/web/app/components/workflow/note-node/note-editor/toolbar/hooks.ts index 2c233fecda6b31..cb9d7e790210cc 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/hooks.ts +++ b/web/app/components/workflow/note-node/note-editor/toolbar/hooks.ts @@ -1,7 +1,4 @@ -import { - $isLinkNode, - TOGGLE_LINK_COMMAND, -} from '@lexical/link' +import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link' import { INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { @@ -19,11 +16,7 @@ import { FORMAT_TEXT_COMMAND, SELECTION_CHANGE_COMMAND, } from 'lexical' -import { - useCallback, - useEffect, - useState, -} from 'react' +import { useCallback, useEffect, useState } from 'react' import { useNoteEditorStore } from '../store' import { getSelectedNode } from '../utils' @@ -42,8 +35,7 @@ const toggleLink = ( editor.update(() => { const selection = $getSelection() - if (!$isRangeSelection(selection)) - return + if (!$isRangeSelection(selection)) return const node = getSelectedNode(selection) const parent = node.getParent() @@ -71,8 +63,7 @@ const toggleBullet = ( editor.update(() => { const selection = $getSelection() - if ($isRangeSelection(selection)) - $setBlocksType(selection, () => $createParagraphNode()) + if ($isRangeSelection(selection)) $setBlocksType(selection, () => $createParagraphNode()) }) } @@ -80,20 +71,22 @@ export const useCommand = () => { const [editor] = useLexicalComposerContext() const noteEditorStore = useNoteEditorStore() - const handleCommand = useCallback((type: string) => { - if (type === 'bold' || type === 'italic' || type === 'strikethrough') { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, type) - return - } + const handleCommand = useCallback( + (type: string) => { + if (type === 'bold' || type === 'italic' || type === 'strikethrough') { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, type) + return + } - if (type === 'link') { - toggleLink(editor, noteEditorStore) - return - } + if (type === 'link') { + toggleLink(editor, noteEditorStore) + return + } - if (type === 'bullet') - toggleBullet(editor, noteEditorStore.getState().selectedIsBullet) - }, [editor, noteEditorStore]) + if (type === 'bullet') toggleBullet(editor, noteEditorStore.getState().selectedIsBullet) + }, + [editor, noteEditorStore], + ) return { handleCommand, @@ -105,26 +98,30 @@ export const useFontSize = () => { const [fontSize, setFontSize] = useState(DEFAULT_FONT_SIZE) const [fontSizeSelectorShow, setFontSizeSelectorShow] = useState(false) - const handleFontSize = useCallback((fontSize: string) => { - editor.update(() => { - const selection = $getSelection() - - if ($isRangeSelection(selection)) - $patchStyleText(selection, { 'font-size': fontSize }) - }) - }, [editor]) - - const handleOpenFontSizeSelector = useCallback((newFontSizeSelectorShow: boolean) => { - if (newFontSizeSelectorShow) { + const handleFontSize = useCallback( + (fontSize: string) => { editor.update(() => { const selection = $getSelection() - if ($isRangeSelection(selection)) - $setSelection(selection.clone()) + if ($isRangeSelection(selection)) $patchStyleText(selection, { 'font-size': fontSize }) }) - } - setFontSizeSelectorShow(newFontSizeSelectorShow) - }, [editor]) + }, + [editor], + ) + + const handleOpenFontSizeSelector = useCallback( + (newFontSizeSelectorShow: boolean) => { + if (newFontSizeSelectorShow) { + editor.update(() => { + const selection = $getSelection() + + if ($isRangeSelection(selection)) $setSelection(selection.clone()) + }) + } + setFontSizeSelectorShow(newFontSizeSelectorShow) + }, + [editor], + ) useEffect(() => { return mergeRegister( diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/index.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/index.tsx index f00d3464ac1659..5b7634804796c0 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/index.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/index.tsx @@ -20,13 +20,10 @@ const Toolbar = ({ return ( <div className="nodrag nopan nowheel inline-flex items-center rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-sm" - onMouseDown={event => event.stopPropagation()} - onClick={event => event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} > - <ColorPicker - theme={theme} - onThemeChange={onThemeChange} - /> + <ColorPicker theme={theme} onThemeChange={onThemeChange} /> <Divider /> <FontSizeSelector /> <Divider /> diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx index a56b30f160c3f3..9b88ac604ce27b 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx @@ -7,10 +7,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { Switch } from '@langgenius/dify-ui/switch' -import { - memo, - useState, -} from 'react' +import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd' @@ -32,12 +29,9 @@ const Operator = ({ const [open, setOpen] = useState(false) return ( - <DropdownMenu - open={open} - onOpenChange={setOpen} - > + <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger - aria-label={t($ => $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.more'], { ns: 'common' })} className={cn( 'flex size-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', 'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary', @@ -46,9 +40,9 @@ const Operator = ({ event.preventDefault() event.stopPropagation() ;(event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.() - setOpen(prev => !prev) + setOpen((prev) => !prev) }} - onClick={event => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} > <span aria-hidden className="i-ri-more-fill size-4" /> </DropdownMenuTrigger> @@ -66,7 +60,7 @@ const Operator = ({ onCopy() }} > - {t($ => $['common.copy'], { ns: 'workflow' })} + {t(($) => $['common.copy'], { ns: 'workflow' })} <ShortcutKbd shortcut="workflow.copy" /> </DropdownMenuItem> <DropdownMenuItem @@ -76,7 +70,7 @@ const Operator = ({ onDuplicate() }} > - {t($ => $['common.duplicate'], { ns: 'workflow' })} + {t(($) => $['common.duplicate'], { ns: 'workflow' })} <ShortcutKbd shortcut="workflow.duplicate" /> </DropdownMenuItem> </div> @@ -84,14 +78,10 @@ const Operator = ({ <div className="p-1"> <div className="flex h-8 cursor-pointer items-center justify-between rounded-md px-3 text-sm text-text-secondary hover:bg-state-base-hover" - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > - <div>{t($ => $['nodes.note.editor.showAuthor'], { ns: 'workflow' })}</div> - <Switch - size="lg" - checked={showAuthor} - onCheckedChange={onShowAuthorChange} - /> + <div>{t(($) => $['nodes.note.editor.showAuthor'], { ns: 'workflow' })}</div> + <Switch size="lg" checked={showAuthor} onCheckedChange={onShowAuthorChange} /> </div> </div> <DropdownMenuSeparator className="my-0" /> @@ -104,7 +94,7 @@ const Operator = ({ onDelete() }} > - {t($ => $['operation.delete'], { ns: 'common' })} + {t(($) => $['operation.delete'], { ns: 'common' })} <ShortcutKbd shortcut="workflow.delete" /> </DropdownMenuItem> </div> diff --git a/web/app/components/workflow/note-node/note-editor/utils.ts b/web/app/components/workflow/note-node/note-editor/utils.ts index 22cfc3726a6e1e..7bd7a4b6516d60 100644 --- a/web/app/components/workflow/note-node/note-editor/utils.ts +++ b/web/app/components/workflow/note-node/note-editor/utils.ts @@ -1,21 +1,17 @@ import type { ElementNode, RangeSelection, TextNode } from 'lexical' import { $isAtNodeEnd } from '@lexical/selection' -export function getSelectedNode( - selection: RangeSelection, -): TextNode | ElementNode { +export function getSelectedNode(selection: RangeSelection): TextNode | ElementNode { const anchor = selection.anchor const focus = selection.focus const anchorNode = selection.anchor.getNode() const focusNode = selection.focus.getNode() - if (anchorNode === focusNode) - return anchorNode + if (anchorNode === focusNode) return anchorNode const isBackward = selection.isBackward() - if (isBackward) - return $isAtNodeEnd(focus) ? anchorNode : focusNode - else - return $isAtNodeEnd(anchor) ? anchorNode : focusNode + if (isBackward) return $isAtNodeEnd(focus) ? anchorNode : focusNode + else return $isAtNodeEnd(anchor) ? anchorNode : focusNode } -export const urlRegExp = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-]*)?\??[-+=&;%@.\w]*#?\w*)?)/ +export const urlRegExp = + /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-]*)?\??[-+=&;%@.\w]*#?\w*)?)/ diff --git a/web/app/components/workflow/note-node/types.ts b/web/app/components/workflow/note-node/types.ts index e8a18589fd8ee0..f1c08844d896be 100644 --- a/web/app/components/workflow/note-node/types.ts +++ b/web/app/components/workflow/note-node/types.ts @@ -8,7 +8,7 @@ export const NoteTheme = { pink: 'pink', violet: 'violet', } as const -export type NoteTheme = typeof NoteTheme[keyof typeof NoteTheme] +export type NoteTheme = (typeof NoteTheme)[keyof typeof NoteTheme] export type NoteNodeType = CommonNodeType & { text: string diff --git a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx index f4d92a0790b64d..81c4dc85269aac 100644 --- a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx @@ -33,7 +33,7 @@ const { } = vi.hoisted(() => ({ mockHandlePaneContextmenuCancel: vi.fn(), mockWorkflowStoreSetState: vi.fn(), - mockGenerateNewNode: vi.fn(({ type, data }: { type: string, data: Record<string, unknown> }) => ({ + mockGenerateNewNode: vi.fn(({ type, data }: { type: string; data: Record<string, unknown> }) => ({ newNode: { id: 'generated-node', type, @@ -45,22 +45,25 @@ const { }, })), mockGetNodeCustomTypeByNodeDataType: vi.fn((type: string) => `${type}-custom`), - mockGetNodesWithSameDefaultDataType: vi.fn(( - nodes: Array<{ data: { agent_node_kind?: string, type?: BlockEnum, version?: string } }>, - type: BlockEnum, - defaultValue: { agent_node_kind?: string, type?: BlockEnum, version?: string }, - ) => { - const dataType = defaultValue.type ?? type - if (dataType !== type && defaultValue.version) { - return nodes.filter(node => - node.data.type === dataType - && node.data.version === defaultValue.version - && node.data.agent_node_kind === defaultValue.agent_node_kind, - ) - } + mockGetNodesWithSameDefaultDataType: vi.fn( + ( + nodes: Array<{ data: { agent_node_kind?: string; type?: BlockEnum; version?: string } }>, + type: BlockEnum, + defaultValue: { agent_node_kind?: string; type?: BlockEnum; version?: string }, + ) => { + const dataType = defaultValue.type ?? type + if (dataType !== type && defaultValue.version) { + return nodes.filter( + (node) => + node.data.type === dataType && + node.data.version === defaultValue.version && + node.data.agent_node_kind === defaultValue.agent_node_kind, + ) + } - return nodes.filter(node => node.data.type === dataType) - }), + return nodes.filter((node) => node.data.type === dataType) + }, + ), })) let latestBlockSelectorProps: BlockSelectorMockProps | null = null @@ -69,24 +72,21 @@ let mockIsChatMode = false let mockFlowType: FlowType = FlowType.appFlow const mockAvailableNextBlocks = [BlockEnum.Answer, BlockEnum.Code] -const mockNodesMetaDataMap: Partial<Record<BlockEnum, { defaultValue: Record<string, unknown> }>> = { - [BlockEnum.Answer]: { - defaultValue: { - title: 'Answer', - desc: '', - type: BlockEnum.Answer, +const mockNodesMetaDataMap: Partial<Record<BlockEnum, { defaultValue: Record<string, unknown> }>> = + { + [BlockEnum.Answer]: { + defaultValue: { + title: 'Answer', + desc: '', + type: BlockEnum.Answer, + }, }, - }, -} + } vi.mock('@/app/components/workflow/block-selector', () => ({ default: (props: BlockSelectorMockProps) => { latestBlockSelectorProps = props - return ( - <div data-testid="block-selector"> - {props.trigger(props.open)} - </div> - ) + return <div data-testid="block-selector">{props.trigger(props.open)}</div> }, })) @@ -178,19 +178,19 @@ describe('AddBlock', () => { expect(latestBlockSelectorProps?.showStartTab).toBe(false) }) - it.each([ - BlockEnum.Start, - BlockEnum.TriggerWebhook, - ])('should keep the normal default tab when a %s node already exists', async (type) => { - renderWithReactFlow([ - createNode({ id: 'entry-node', position: { x: 0, y: 0 }, data: { type } }), - ]) + it.each([BlockEnum.Start, BlockEnum.TriggerWebhook])( + 'should keep the normal default tab when a %s node already exists', + async (type) => { + renderWithReactFlow([ + createNode({ id: 'entry-node', position: { x: 0, y: 0 }, data: { type } }), + ]) - await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull()) + await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull()) - expect(latestBlockSelectorProps?.showStartTab).toBe(true) - expect(latestBlockSelectorProps?.defaultActiveTab).toBeUndefined() - }) + expect(latestBlockSelectorProps?.showStartTab).toBe(true) + expect(latestBlockSelectorProps?.defaultActiveTab).toBeUndefined() + }, + ) it('should pass keyboard isolation to the selector when requested by the caller', async () => { renderWorkflowFlowComponent(<AddBlock isolateKeyboardEvents />, { nodes: [], edges: [] }) @@ -268,8 +268,16 @@ describe('AddBlock', () => { }, } renderWithReactFlow([ - createNode({ id: 'old-agent', position: { x: 0, y: 0 }, data: { type: BlockEnum.Agent, version: '2' } }), - createNode({ id: 'agent-v2', position: { x: 80, y: 0 }, data: { agent_node_kind: 'dify_agent', type: BlockEnum.Agent, version: '2' } }), + createNode({ + id: 'old-agent', + position: { x: 0, y: 0 }, + data: { type: BlockEnum.Agent, version: '2' }, + }), + createNode({ + id: 'agent-v2', + position: { x: 80, y: 0 }, + data: { agent_node_kind: 'dify_agent', type: BlockEnum.Agent, version: '2' }, + }), ]) await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull()) diff --git a/web/app/components/workflow/operator/__tests__/control.spec.tsx b/web/app/components/workflow/operator/__tests__/control.spec.tsx index 23118185d4d19b..53541ddbc9365a 100644 --- a/web/app/components/workflow/operator/__tests__/control.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/control.spec.tsx @@ -56,7 +56,9 @@ vi.mock('../../store', () => ({ })) vi.mock('../../hooks-store', () => ({ - useHooksStore: <T,>(selector: (state: { accessControl: { canComment: boolean, canEdit: boolean } }) => T): T => + useHooksStore: <T,>( + selector: (state: { accessControl: { canComment: boolean; canEdit: boolean } }) => T, + ): T => selector({ accessControl: { canComment: mockCanComment, @@ -74,13 +76,9 @@ vi.mock('../more-actions', () => ({ })) vi.mock('../tip-popup', () => ({ - default: ({ - children, - title, - }: { - children?: ReactNode - title?: string - }) => <div data-testid={title}>{children}</div>, + default: ({ children, title }: { children?: ReactNode; title?: string }) => ( + <div data-testid={title}>{children}</div> + ), })) describe('Control', () => { @@ -103,8 +101,12 @@ describe('Control', () => { expect(screen.getByTestId('add-block')).toBeInTheDocument() expect(screen.getByTestId('more-actions')).toBeInTheDocument() - expect(screen.getByTestId('workflow.common.pointerMode').firstElementChild).toHaveClass('bg-state-accent-active') - expect(screen.getByTestId('workflow.common.handMode').firstElementChild).not.toHaveClass('bg-state-accent-active') + expect(screen.getByTestId('workflow.common.pointerMode').firstElementChild).toHaveClass( + 'bg-state-accent-active', + ) + expect(screen.getByTestId('workflow.common.handMode').firstElementChild).not.toHaveClass( + 'bg-state-accent-active', + ) }) it('should highlight hand mode when it is active', () => { @@ -114,7 +116,9 @@ describe('Control', () => { render(<Control />) - expect(screen.getByTestId('workflow.common.handMode').firstElementChild).toHaveClass('bg-state-accent-active') + expect(screen.getByTestId('workflow.common.handMode').firstElementChild).toHaveClass( + 'bg-state-accent-active', + ) }) }) @@ -123,11 +127,21 @@ describe('Control', () => { it('should trigger the note, mode, and organize handlers', () => { render(<Control />) - fireEvent.click(screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement) - fireEvent.click(screen.getByTestId('workflow.common.pointerMode').firstElementChild as HTMLElement) - fireEvent.click(screen.getByTestId('workflow.common.handMode').firstElementChild as HTMLElement) - fireEvent.click(screen.getByTestId('workflow.common.commentMode').firstElementChild as HTMLElement) - fireEvent.click(screen.getByTestId('workflow.panel.organizeBlocks').firstElementChild as HTMLElement) + fireEvent.click( + screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement, + ) + fireEvent.click( + screen.getByTestId('workflow.common.pointerMode').firstElementChild as HTMLElement, + ) + fireEvent.click( + screen.getByTestId('workflow.common.handMode').firstElementChild as HTMLElement, + ) + fireEvent.click( + screen.getByTestId('workflow.common.commentMode').firstElementChild as HTMLElement, + ) + fireEvent.click( + screen.getByTestId('workflow.panel.organizeBlocks').firstElementChild as HTMLElement, + ) expect(mockHandleAddNote).toHaveBeenCalledTimes(1) expect(mockHandleModePointer).toHaveBeenCalledTimes(1) @@ -143,7 +157,9 @@ describe('Control', () => { render(<Control />) - fireEvent.click(screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement) + fireEvent.click( + screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement, + ) expect(mockHandleAddNote).not.toHaveBeenCalled() }) @@ -154,7 +170,8 @@ describe('Control', () => { render(<Control />) - const commentButton = screen.getByTestId('workflow.common.commentMode').firstElementChild as HTMLButtonElement + const commentButton = screen.getByTestId('workflow.common.commentMode') + .firstElementChild as HTMLButtonElement expect(commentButton).toBeEnabled() fireEvent.click(commentButton) @@ -167,7 +184,8 @@ describe('Control', () => { render(<Control />) - const commentButton = screen.getByTestId('workflow.common.commentMode').firstElementChild as HTMLButtonElement + const commentButton = screen.getByTestId('workflow.common.commentMode') + .firstElementChild as HTMLButtonElement expect(commentButton).toBeDisabled() fireEvent.click(commentButton) diff --git a/web/app/components/workflow/operator/__tests__/index.spec.tsx b/web/app/components/workflow/operator/__tests__/index.spec.tsx index 49f077341d179c..bb045b63712b8a 100644 --- a/web/app/components/workflow/operator/__tests__/index.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/index.spec.tsx @@ -62,25 +62,24 @@ class MockResizeObserver { } const renderOperator = (initialStoreState: Record<string, unknown> = {}) => { - return renderWorkflowFlowComponent( - <Operator handleUndo={vi.fn()} handleRedo={vi.fn()} />, - { - nodes: [createNode({ + return renderWorkflowFlowComponent(<Operator handleUndo={vi.fn()} handleRedo={vi.fn()} />, { + nodes: [ + createNode({ id: 'node-1', data: { type: BlockEnum.Code, title: 'Code', desc: '', }, - })], + }), + ], + edges: [], + initialStoreState, + historyStore: { + nodes: [], edges: [], - initialStoreState, - historyStore: { - nodes: [], - edges: [], - }, }, - ) + }) } describe('Operator', () => { @@ -119,11 +118,14 @@ describe('Operator', () => { expect(observeSpy).toHaveBeenCalled() act(() => { - resizeObserverCallback?.([ - { - borderBoxSize: [{ inlineSize: 512, blockSize: 188 }], - } as unknown as ResizeObserverEntry, - ], {} as ResizeObserver) + resizeObserverCallback?.( + [ + { + borderBoxSize: [{ inlineSize: 512, blockSize: 188 }], + } as unknown as ResizeObserverEntry, + ], + {} as ResizeObserver, + ) }) await waitFor(() => { diff --git a/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx b/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx index 0cf02774d70623..54410ed01f1d83 100644 --- a/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx @@ -9,10 +9,7 @@ const mockToSvg = vi.fn() const mockDownloadUrl = vi.fn() const mockSetViewport = vi.fn() const mockGetNodesReadOnly = vi.fn() -const { - mockDropdownContentProps, - mockWorkflowState, -} = vi.hoisted(() => ({ +const { mockDropdownContentProps, mockWorkflowState } = vi.hoisted(() => ({ mockDropdownContentProps: vi.fn(), mockWorkflowState: { knowledgeName: '', @@ -21,22 +18,38 @@ const { })) vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') - const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + const DropdownMenuContext = React.createContext<{ + open: boolean + setOpen: (open: boolean) => void + } | null>(null) const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + DropdownMenu: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( <DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}> <div>{children}</div> </DropdownMenuContext> ), - DropdownMenuTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => { + DropdownMenuTrigger: ({ + children, + className, + }: { + children: React.ReactNode + className?: string + }) => { const { open, setOpen } = useDropdownMenuContext() return ( <button type="button" className={className} onClick={() => setOpen(!open)}> @@ -81,7 +94,9 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { </button> ) }, - DropdownMenuSeparator: ({ className }: { className?: string }) => <div className={className} data-testid="dropdown-separator" />, + DropdownMenuSeparator: ({ className }: { className?: string }) => ( + <div className={className} data-testid="dropdown-separator" /> + ), } }) @@ -119,10 +134,12 @@ vi.mock('../tip-popup', () => ({ })) vi.mock('@/app/components/base/image-uploader/image-preview', () => ({ - default: ({ title, onCancel }: { title: string, onCancel: () => void }) => ( + default: ({ title, onCancel }: { title: string; onCancel: () => void }) => ( <div data-testid="image-preview"> <span>{title}</span> - <button type="button" onClick={onCancel}>close-preview</button> + <button type="button" onClick={onCancel}> + close-preview + </button> </div> ), })) diff --git a/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx b/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx index a58bdef727bcd3..d3762e11841442 100644 --- a/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx @@ -63,8 +63,7 @@ const getZoomControls = () => { const zoomOutIcon = document.querySelector('.i-ri-zoom-out-line') const zoomInIcon = document.querySelector('.i-ri-zoom-in-line') - if (!label || !zoomOutIcon || !zoomInIcon) - throw new Error('Missing zoom controls') + if (!label || !zoomOutIcon || !zoomInIcon) throw new Error('Missing zoom controls') return { zoomOutTrigger: zoomOutIcon.parentElement as HTMLElement, @@ -145,12 +144,7 @@ describe('workflow zoom controls', () => { }) it('keeps the show-user-comments action disabled in comment mode', () => { - renderZoomInOut( - <ZoomInOut - isCommentMode - onToggleUserComments={mockToggleUserComments} - />, - ) + renderZoomInOut(<ZoomInOut isCommentMode onToggleUserComments={mockToggleUserComments} />) const menu = openZoomMenu() fireEvent.click(menu.getByText('workflow.operator.showUserComments')) diff --git a/web/app/components/workflow/operator/add-block.tsx b/web/app/components/workflow/operator/add-block.tsx index 5e9fd2e09f0247..499099e479c619 100644 --- a/web/app/components/workflow/operator/add-block.tsx +++ b/web/app/components/workflow/operator/add-block.tsx @@ -1,23 +1,12 @@ import type { OffsetOptions } from '@floating-ui/react' -import type { - Node, - OnSelectBlock, -} from '@/app/components/workflow/types' +import type { Node, OnSelectBlock } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { RiAddCircleFill } from '@remixicon/react' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - useStoreApi, -} from 'reactflow' +import { useStoreApi } from 'reactflow' import BlockSelector from '@/app/components/workflow/block-selector' -import { - BlockEnum, -} from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' import { FlowType } from '@/types/common' import { useAvailableBlocks, @@ -58,58 +47,64 @@ const AddBlock = ({ const [open, setOpen] = useState(false) const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false) const { nodesMap: nodesMetaDataMap } = useNodesMetaData() - const flowType = useHooksStore(s => s.configsMap?.flowType) + const flowType = useHooksStore((s) => s.configsMap?.flowType) const showStartTab = flowType !== FlowType.ragPipeline && !isChatMode - const handleOpenChange = useCallback((open: boolean) => { - setOpen(open) - if (!open) - (onClose ?? handlePaneContextmenuCancel)() - }, [handlePaneContextmenuCancel, onClose]) + const handleOpenChange = useCallback( + (open: boolean) => { + setOpen(open) + if (!open) (onClose ?? handlePaneContextmenuCancel)() + }, + [handlePaneContextmenuCancel, onClose], + ) - const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { - const { - getNodes, - } = store.getState() - const { - defaultValue, - } = nodesMetaDataMap![type] - const nodes = getNodes() - const nodesWithSameType = getNodesWithSameDefaultDataType(nodes, type, defaultValue) - const { newNode } = generateNewNode({ - type: getNodeCustomTypeByNodeDataType(type), - data: { - ...(defaultValue as Node['data']), - title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title, - ...pluginDefaultValue, - _isCandidate: true, - }, - position: { - x: 0, - y: 0, - }, - }) - workflowStore.setState({ - candidateNode: newNode, - }) - }, [store, workflowStore, nodesMetaDataMap]) + const handleSelect = useCallback<OnSelectBlock>( + (type, pluginDefaultValue) => { + const { getNodes } = store.getState() + const { defaultValue } = nodesMetaDataMap![type] + const nodes = getNodes() + const nodesWithSameType = getNodesWithSameDefaultDataType(nodes, type, defaultValue) + const { newNode } = generateNewNode({ + type: getNodeCustomTypeByNodeDataType(type), + data: { + ...(defaultValue as Node['data']), + title: + nodesWithSameType.length > 0 + ? `${defaultValue.title} ${nodesWithSameType.length + 1}` + : defaultValue.title, + ...pluginDefaultValue, + _isCandidate: true, + }, + position: { + x: 0, + y: 0, + }, + }) + workflowStore.setState({ + candidateNode: newNode, + }) + }, + [store, workflowStore, nodesMetaDataMap], + ) - const renderTriggerElement = useCallback((open: boolean) => { - return ( - <TipPopup - title={t($ => $['common.addBlock'], { ns: 'workflow' })} - > - <div className={cn( - 'flex size-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', - `${nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`, - open && 'bg-state-accent-active text-text-accent', - )} - > - <RiAddCircleFill className="size-4" /> - </div> - </TipPopup> - ) - }, [nodesReadOnly, t]) + const renderTriggerElement = useCallback( + (open: boolean) => { + return ( + <TipPopup title={t(($) => $['common.addBlock'], { ns: 'workflow' })}> + <div + className={cn( + 'flex size-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', + `${nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`, + open && 'bg-state-accent-active text-text-accent', + )} + > + <RiAddCircleFill className="size-4" /> + </div> + </TipPopup> + ) + }, + [nodesReadOnly, t], + ) return ( <BlockSelector @@ -118,10 +113,12 @@ const AddBlock = ({ disabled={nodesReadOnly} onSelect={handleSelect} placement="right-start" - offset={offset ?? { - mainAxis: 4, - crossAxis: -8, - }} + offset={ + offset ?? { + mainAxis: 4, + crossAxis: -8, + } + } trigger={renderTrigger || renderTriggerElement} renderTriggerAsButtonRoot={renderTriggerAsButtonRoot} popupClassName="min-w-[256px]!" diff --git a/web/app/components/workflow/operator/control.tsx b/web/app/components/workflow/operator/control.tsx index acf331750d1415..2ad3eef3340f9b 100644 --- a/web/app/components/workflow/operator/control.tsx +++ b/web/app/components/workflow/operator/control.tsx @@ -1,26 +1,13 @@ import type { MouseEvent } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - RiCursorLine, - RiFunctionAddLine, - RiHand, - RiStickyNoteAddLine, -} from '@remixicon/react' -import { - memo, -} from 'react' +import { RiCursorLine, RiFunctionAddLine, RiHand, RiStickyNoteAddLine } from '@remixicon/react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import { Comment } from '@/app/components/base/icons/src/public/other' import Divider from '../../base/divider' -import { - useNodesReadOnly, - useWorkflowMoveMode, - useWorkflowOrganize, -} from '../hooks' +import { useNodesReadOnly, useWorkflowMoveMode, useWorkflowOrganize } from '../hooks' import { useStore } from '../store' -import { - ControlMode, -} from '../types' +import { ControlMode } from '../types' import AddBlock from './add-block' import { useOperator } from './hooks' import MoreActions from './more-actions' @@ -28,7 +15,7 @@ import TipPopup from './tip-popup' const Control = () => { const { t } = useTranslation() - const controlMode = useStore(s => s.controlMode) + const controlMode = useStore((s) => s.controlMode) const { handleModePointer, handleModeHand, @@ -38,14 +25,10 @@ const Control = () => { } = useWorkflowMoveMode() const { handleLayout } = useWorkflowOrganize() const { handleAddNote } = useOperator() - const { - nodesReadOnly, - getNodesReadOnly, - } = useNodesReadOnly() + const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly() const addNote = (e: MouseEvent<HTMLButtonElement>) => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return e.stopPropagation() handleAddNote() @@ -54,10 +37,10 @@ const Control = () => { return ( <div className="pointer-events-auto flex flex-col items-center rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 text-text-tertiary shadow-lg"> <AddBlock /> - <TipPopup title={t($ => $['nodes.note.addNote'], { ns: 'workflow' })}> + <TipPopup title={t(($) => $['nodes.note.addNote'], { ns: 'workflow' })}> <button type="button" - aria-label={t($ => $['nodes.note.addNote'], { ns: 'workflow' })} + aria-label={t(($) => $['nodes.note.addNote'], { ns: 'workflow' })} disabled={nodesReadOnly} className={cn( 'ml-px flex size-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary', @@ -69,14 +52,19 @@ const Control = () => { </button> </TipPopup> <Divider className="my-1 w-3.5" /> - <TipPopup title={t($ => $['common.pointerMode'], { ns: 'workflow' })} shortcut="workflow.pointer-mode"> + <TipPopup + title={t(($) => $['common.pointerMode'], { ns: 'workflow' })} + shortcut="workflow.pointer-mode" + > <button type="button" - aria-label={t($ => $['common.pointerMode'], { ns: 'workflow' })} + aria-label={t(($) => $['common.pointerMode'], { ns: 'workflow' })} disabled={nodesReadOnly} className={cn( 'mr-px flex size-8 cursor-pointer items-center justify-center rounded-lg', - controlMode === ControlMode.Pointer ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary', + controlMode === ControlMode.Pointer + ? 'bg-state-accent-active text-text-accent' + : 'hover:bg-state-base-hover hover:text-text-secondary', `${nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`, )} onClick={handleModePointer} @@ -84,14 +72,19 @@ const Control = () => { <RiCursorLine aria-hidden className="size-4" /> </button> </TipPopup> - <TipPopup title={t($ => $['common.handMode'], { ns: 'workflow' })} shortcut="workflow.hand-mode"> + <TipPopup + title={t(($) => $['common.handMode'], { ns: 'workflow' })} + shortcut="workflow.hand-mode" + > <button type="button" - aria-label={t($ => $['common.handMode'], { ns: 'workflow' })} + aria-label={t(($) => $['common.handMode'], { ns: 'workflow' })} disabled={nodesReadOnly} className={cn( 'flex size-8 cursor-pointer items-center justify-center rounded-lg', - controlMode === ControlMode.Hand ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary', + controlMode === ControlMode.Hand + ? 'bg-state-accent-active text-text-accent' + : 'hover:bg-state-base-hover hover:text-text-secondary', `${nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`, )} onClick={handleModeHand} @@ -100,14 +93,19 @@ const Control = () => { </button> </TipPopup> {isCommentModeAvailable && ( - <TipPopup title={t($ => $['common.commentMode'], { ns: 'workflow' })} shortcut="workflow.comment-mode"> + <TipPopup + title={t(($) => $['common.commentMode'], { ns: 'workflow' })} + shortcut="workflow.comment-mode" + > <button type="button" - aria-label={t($ => $['common.commentMode'], { ns: 'workflow' })} + aria-label={t(($) => $['common.commentMode'], { ns: 'workflow' })} disabled={!canUseCommentMode} className={cn( 'ml-px flex size-8 cursor-pointer items-center justify-center rounded-lg', - controlMode === ControlMode.Comment ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary', + controlMode === ControlMode.Comment + ? 'bg-state-accent-active text-text-accent' + : 'hover:bg-state-base-hover hover:text-text-secondary', `${!canUseCommentMode && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`, )} onClick={handleModeComment} @@ -117,10 +115,13 @@ const Control = () => { </TipPopup> )} <Divider className="my-1 w-3.5" /> - <TipPopup title={t($ => $['panel.organizeBlocks'], { ns: 'workflow' })} shortcut="workflow.organize"> + <TipPopup + title={t(($) => $['panel.organizeBlocks'], { ns: 'workflow' })} + shortcut="workflow.organize" + > <button type="button" - aria-label={t($ => $['panel.organizeBlocks'], { ns: 'workflow' })} + aria-label={t(($) => $['panel.organizeBlocks'], { ns: 'workflow' })} disabled={nodesReadOnly} className={cn( 'flex size-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary', diff --git a/web/app/components/workflow/operator/hooks.ts b/web/app/components/workflow/operator/hooks.ts index e641c2c6e3a01f..bf9bac52e28054 100644 --- a/web/app/components/workflow/operator/hooks.ts +++ b/web/app/components/workflow/operator/hooks.ts @@ -2,9 +2,7 @@ import type { NoteNodeType } from '../note-node/types' import { useAtomValue } from 'jotai' import { useCallback } from 'react' import { userProfileAtom } from '@/context/account-state' -import { - CUSTOM_NOTE_NODE, -} from '../note-node/constants' +import { CUSTOM_NOTE_NODE } from '../note-node/constants' import { NoteTheme } from '../note-node/types' import { useWorkflowNoteShowAuthorValue } from '../persistence/local-storage-options' import { useWorkflowStore } from '../store' diff --git a/web/app/components/workflow/operator/index.tsx b/web/app/components/workflow/operator/index.tsx index 8f731c2f40ee21..53b1404925f08a 100644 --- a/web/app/components/workflow/operator/index.tsx +++ b/web/app/components/workflow/operator/index.tsx @@ -15,18 +15,18 @@ type OperatorProps = { const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { const bottomPanelRef = useRef<HTMLDivElement>(null) - const bottomPanelSizeRef = useRef<{ width?: number, height?: number }>({}) + const bottomPanelSizeRef = useRef<{ width?: number; height?: number }>({}) const bottomPanelResizeFrameRef = useRef<number | undefined>(undefined) const [showMiniMap, setShowMiniMap] = useState(true) - const showUserCursors = useStore(s => s.showUserCursors) - const setShowUserCursors = useStore(s => s.setShowUserCursors) - const showUserComments = useStore(s => s.showUserComments) - const setShowUserComments = useStore(s => s.setShowUserComments) - const controlMode = useStore(s => s.controlMode) + const showUserCursors = useStore((s) => s.showUserCursors) + const setShowUserCursors = useStore((s) => s.setShowUserCursors) + const showUserComments = useStore((s) => s.showUserComments) + const setShowUserComments = useStore((s) => s.setShowUserComments) + const controlMode = useStore((s) => s.controlMode) const isCommentMode = controlMode === ControlMode.Comment const handleToggleMiniMap = useCallback(() => { - setShowMiniMap(prev => !prev) + setShowMiniMap((prev) => !prev) }, []) const handleToggleUserCursors = useCallback(() => { @@ -37,15 +37,14 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { setShowUserComments(!showUserComments) }, [showUserComments, setShowUserComments]) - const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) - const rightPanelWidth = useStore(s => s.rightPanelWidth) - const setBottomPanelWidth = useStore(s => s.setBottomPanelWidth) - const setBottomPanelHeight = useStore(s => s.setBottomPanelHeight) + const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth) + const rightPanelWidth = useStore((s) => s.rightPanelWidth) + const setBottomPanelWidth = useStore((s) => s.setBottomPanelWidth) + const setBottomPanelHeight = useStore((s) => s.setBottomPanelHeight) const bottomPanelWidth = useMemo(() => { - if (!workflowCanvasWidth || !rightPanelWidth) - return 'auto' - return Math.max((workflowCanvasWidth - rightPanelWidth), 400) + if (!workflowCanvasWidth || !rightPanelWidth) return 'auto' + return Math.max(workflowCanvasWidth - rightPanelWidth, 400) }, [workflowCanvasWidth, rightPanelWidth]) const getMiniMapNodeClassName = useCallback((node: Node) => { @@ -58,7 +57,10 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { useEffect(() => { if (bottomPanelRef.current) { const updateBottomPanelSize = (width: number, height: number) => { - if (bottomPanelSizeRef.current.width === width && bottomPanelSizeRef.current.height === height) + if ( + bottomPanelSizeRef.current.width === width && + bottomPanelSizeRef.current.height === height + ) return bottomPanelSizeRef.current = { width, height } @@ -93,11 +95,9 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { <div ref={bottomPanelRef} className="absolute inset-x-0 bottom-0 z-10 px-1" - style={ - { - width: bottomPanelWidth, - } - } + style={{ + width: bottomPanelWidth, + }} > <div className="flex justify-between px-1 pb-2"> <div className="flex items-center gap-2"> @@ -116,8 +116,7 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { maskColor="var(--color-workflow-minimap-bg)" nodeClassName={getMiniMapNodeClassName} nodeStrokeWidth={3} - className="absolute! bottom-10! z-9 m-0! h-[73px]! w-[103px]! rounded-lg! border-[0.5px]! - border-divider-subtle! bg-background-default-subtle! shadow-md! shadow-shadow-shadow-5!" + className="absolute! bottom-10! z-9 m-0! h-[73px]! w-[103px]! rounded-lg! border-[0.5px]! border-divider-subtle! bg-background-default-subtle! shadow-md! shadow-shadow-shadow-5!" /> )} <ZoomInOut diff --git a/web/app/components/workflow/operator/more-actions.tsx b/web/app/components/workflow/operator/more-actions.tsx index 1ddcb9b464252e..de424e4673a23c 100644 --- a/web/app/components/workflow/operator/more-actions.tsx +++ b/web/app/components/workflow/operator/more-actions.tsx @@ -8,11 +8,7 @@ import { } from '@langgenius/dify-ui/dropdown-menu' import { RiExportLine, RiMoreFill } from '@remixicon/react' import { toJpeg, toPng, toSvg } from 'html-to-image' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { getNodesBounds, useReactFlow } from 'reactflow' import ImagePreview from '@/app/components/base/image-uploader/image-preview' @@ -29,126 +25,123 @@ function MoreActions() { const [open, setOpen] = useState(false) const [previewUrl, setPreviewUrl] = useState('') const [previewTitle, setPreviewTitle] = useState('') - const knowledgeName = useStore(s => s.knowledgeName) - const appName = useStore(s => s.appName) + const knowledgeName = useStore((s) => s.knowledgeName) + const appName = useStore((s) => s.appName) const isReadOnly = getNodesReadOnly() - const handleExportImage = useCallback(async (type: 'png' | 'jpeg' | 'svg', currentWorkflow = false) => { - if (!appName && !knowledgeName) - return - - if (getNodesReadOnly()) - return + const handleExportImage = useCallback( + async (type: 'png' | 'jpeg' | 'svg', currentWorkflow = false) => { + if (!appName && !knowledgeName) return - setOpen(false) - const flowElement = document.querySelector('.react-flow__viewport') as HTMLElement - if (!flowElement) - return + if (getNodesReadOnly()) return - try { - let filename = appName || knowledgeName - const filter = (node: HTMLElement) => { - if (node instanceof HTMLImageElement) - return node.complete && node.naturalHeight !== 0 + setOpen(false) + const flowElement = document.querySelector('.react-flow__viewport') as HTMLElement + if (!flowElement) return - return true - } + try { + let filename = appName || knowledgeName + const filter = (node: HTMLElement) => { + if (node instanceof HTMLImageElement) return node.complete && node.naturalHeight !== 0 - let dataUrl - - if (currentWorkflow) { - const nodes = reactFlow.getNodes() - const nodesBounds = getNodesBounds(nodes) - - const currentViewport = reactFlow.getViewport() - - const viewportWidth = window.innerWidth - const viewportHeight = window.innerHeight - const zoom = Math.min( - viewportWidth / (nodesBounds.width + 100), - viewportHeight / (nodesBounds.height + 100), - 1, - ) - - const centerX = nodesBounds.x + nodesBounds.width / 2 - const centerY = nodesBounds.y + nodesBounds.height / 2 - - reactFlow.setViewport({ - x: viewportWidth / 2 - centerX * zoom, - y: viewportHeight / 2 - centerY * zoom, - zoom, - }) - - await new Promise(resolve => setTimeout(resolve, 300)) - - const padding = 50 - const contentWidth = nodesBounds.width + padding * 2 - const contentHeight = nodesBounds.height + padding * 2 - - const exportOptions = { - filter, - backgroundColor: '#1a1a1a', - pixelRatio: 2, - width: contentWidth, - height: contentHeight, - style: { - width: `${contentWidth}px`, - height: `${contentHeight}px`, - transform: `translate(${padding - nodesBounds.x}px, ${padding - nodesBounds.y}px)`, - transformOrigin: 'top left', - }, + return true } - switch (type) { - case 'png': - dataUrl = await toPng(flowElement, exportOptions) - break - case 'jpeg': - dataUrl = await toJpeg(flowElement, exportOptions) - break - case 'svg': - dataUrl = await toSvg(flowElement, { filter }) - break - default: - dataUrl = await toPng(flowElement, exportOptions) - } + let dataUrl + + if (currentWorkflow) { + const nodes = reactFlow.getNodes() + const nodesBounds = getNodesBounds(nodes) + + const currentViewport = reactFlow.getViewport() + + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const zoom = Math.min( + viewportWidth / (nodesBounds.width + 100), + viewportHeight / (nodesBounds.height + 100), + 1, + ) + + const centerX = nodesBounds.x + nodesBounds.width / 2 + const centerY = nodesBounds.y + nodesBounds.height / 2 + + reactFlow.setViewport({ + x: viewportWidth / 2 - centerX * zoom, + y: viewportHeight / 2 - centerY * zoom, + zoom, + }) + + await new Promise((resolve) => setTimeout(resolve, 300)) + + const padding = 50 + const contentWidth = nodesBounds.width + padding * 2 + const contentHeight = nodesBounds.height + padding * 2 + + const exportOptions = { + filter, + backgroundColor: '#1a1a1a', + pixelRatio: 2, + width: contentWidth, + height: contentHeight, + style: { + width: `${contentWidth}px`, + height: `${contentHeight}px`, + transform: `translate(${padding - nodesBounds.x}px, ${padding - nodesBounds.y}px)`, + transformOrigin: 'top left', + }, + } - filename += '-whole-workflow' + switch (type) { + case 'png': + dataUrl = await toPng(flowElement, exportOptions) + break + case 'jpeg': + dataUrl = await toJpeg(flowElement, exportOptions) + break + case 'svg': + dataUrl = await toSvg(flowElement, { filter }) + break + default: + dataUrl = await toPng(flowElement, exportOptions) + } - setTimeout(() => { - reactFlow.setViewport(currentViewport) - }, 500) - } - else { - // Current viewport export (existing functionality) - switch (type) { - case 'png': - dataUrl = await toPng(flowElement, { filter }) - break - case 'jpeg': - dataUrl = await toJpeg(flowElement, { filter }) - break - case 'svg': - dataUrl = await toSvg(flowElement, { filter }) - break - default: - dataUrl = await toPng(flowElement, { filter }) + filename += '-whole-workflow' + + setTimeout(() => { + reactFlow.setViewport(currentViewport) + }, 500) + } else { + // Current viewport export (existing functionality) + switch (type) { + case 'png': + dataUrl = await toPng(flowElement, { filter }) + break + case 'jpeg': + dataUrl = await toJpeg(flowElement, { filter }) + break + case 'svg': + dataUrl = await toSvg(flowElement, { filter }) + break + default: + dataUrl = await toPng(flowElement, { filter }) + } } - } - const fileName = `${filename}.${type}` + const fileName = `${filename}.${type}` - if (currentWorkflow) { - setPreviewUrl(dataUrl) - setPreviewTitle(fileName) - } + if (currentWorkflow) { + setPreviewUrl(dataUrl) + setPreviewTitle(fileName) + } - downloadUrl({ url: dataUrl, fileName }) - } - catch (error) { - console.error('Export image failed:', error) - } - }, [getNodesReadOnly, appName, reactFlow, knowledgeName]) + downloadUrl({ url: dataUrl, fileName }) + } catch (error) { + console.error('Export image failed:', error) + } + }, + [getNodesReadOnly, appName, reactFlow, knowledgeName], + ) return ( <> @@ -165,57 +158,60 @@ function MoreActions() { <DropdownMenuTrigger className={cn( 'flex size-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary', - isReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled', + isReadOnly && + 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled', )} > - <TipPopup title={t($ => $['common.moreActions'], { ns: 'workflow' })}> + <TipPopup title={t(($) => $['common.moreActions'], { ns: 'workflow' })}> <RiMoreFill className="size-4" /> </TipPopup> </DropdownMenuTrigger> - <DropdownMenuContent - placement="right-end" - popupClassName="min-w-[180px]" - > + <DropdownMenuContent placement="right-end" popupClassName="min-w-[180px]"> <div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-text-tertiary"> <RiExportLine className="size-3" /> - {t($ => $['common.exportImage'], { ns: 'workflow' })} + {t(($) => $['common.exportImage'], { ns: 'workflow' })} </div> <div className="px-2 py-1 text-xs font-medium text-text-tertiary"> - {t($ => $['common.currentView'], { ns: 'workflow' })} + {t(($) => $['common.currentView'], { ns: 'workflow' })} </div> <DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('png')}> - {t($ => $['common.exportPNG'], { ns: 'workflow' })} + {t(($) => $['common.exportPNG'], { ns: 'workflow' })} </DropdownMenuItem> <DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('jpeg')}> - {t($ => $['common.exportJPEG'], { ns: 'workflow' })} + {t(($) => $['common.exportJPEG'], { ns: 'workflow' })} </DropdownMenuItem> <DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('svg')}> - {t($ => $['common.exportSVG'], { ns: 'workflow' })} + {t(($) => $['common.exportSVG'], { ns: 'workflow' })} </DropdownMenuItem> <DropdownMenuSeparator className="mx-2" /> <div className="px-2 py-1 text-xs font-medium text-text-tertiary"> - {t($ => $['common.currentWorkflow'], { ns: 'workflow' })} + {t(($) => $['common.currentWorkflow'], { ns: 'workflow' })} </div> - <DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('png', true)}> - {t($ => $['common.exportPNG'], { ns: 'workflow' })} + <DropdownMenuItem + className="system-md-regular" + onClick={() => handleExportImage('png', true)} + > + {t(($) => $['common.exportPNG'], { ns: 'workflow' })} </DropdownMenuItem> - <DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('jpeg', true)}> - {t($ => $['common.exportJPEG'], { ns: 'workflow' })} + <DropdownMenuItem + className="system-md-regular" + onClick={() => handleExportImage('jpeg', true)} + > + {t(($) => $['common.exportJPEG'], { ns: 'workflow' })} </DropdownMenuItem> - <DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('svg', true)}> - {t($ => $['common.exportSVG'], { ns: 'workflow' })} + <DropdownMenuItem + className="system-md-regular" + onClick={() => handleExportImage('svg', true)} + > + {t(($) => $['common.exportSVG'], { ns: 'workflow' })} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> {previewUrl && ( - <ImagePreview - url={previewUrl} - title={previewTitle} - onCancel={() => setPreviewUrl('')} - /> + <ImagePreview url={previewUrl} title={previewTitle} onCancel={() => setPreviewUrl('')} /> )} </> ) diff --git a/web/app/components/workflow/operator/tip-popup.tsx b/web/app/components/workflow/operator/tip-popup.tsx index 7d287f2a06039e..a869ad29e48f1e 100644 --- a/web/app/components/workflow/operator/tip-popup.tsx +++ b/web/app/components/workflow/operator/tip-popup.tsx @@ -1,10 +1,6 @@ import type { ReactElement } from 'react' import type { WorkflowCanvasShortcutId } from '../shortcuts/definitions' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { memo } from 'react' import { ShortcutKbd } from '../shortcuts/shortcut-kbd' @@ -13,23 +9,14 @@ type TipPopupProps = { children: ReactElement shortcut?: WorkflowCanvasShortcutId } -const TipPopup = ({ - title, - children, - shortcut, -}: TipPopupProps) => { +const TipPopup = ({ title, children, shortcut }: TipPopupProps) => { return ( <Tooltip> <TooltipTrigger render={children} /> - <TooltipContent - sideOffset={4} - className="max-w-none bg-transparent p-0 shadow-none" - > + <TooltipContent sideOffset={4} className="max-w-none bg-transparent p-0 shadow-none"> <div className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg backdrop-blur-[5px]"> <span className="system-xs-medium text-text-secondary">{title}</span> - { - shortcut && <ShortcutKbd shortcut={shortcut} /> - } + {shortcut && <ShortcutKbd shortcut={shortcut} />} </div> </TooltipContent> </Tooltip> diff --git a/web/app/components/workflow/operator/zoom-in-out.tsx b/web/app/components/workflow/operator/zoom-in-out.tsx index 8a841023cbf8ae..6fa10732f67939 100644 --- a/web/app/components/workflow/operator/zoom-in-out.tsx +++ b/web/app/components/workflow/operator/zoom-in-out.tsx @@ -7,20 +7,11 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { useSuspenseQuery } from '@tanstack/react-query' -import { - Fragment, - memo, -} from 'react' +import { Fragment, memo } from 'react' import { useTranslation } from 'react-i18next' -import { - useReactFlow, - useViewport, -} from 'reactflow' +import { useReactFlow, useViewport } from 'reactflow' import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { - useNodesSyncDraft, - useWorkflowReadOnly, -} from '../hooks' +import { useNodesSyncDraft, useWorkflowReadOnly } from '../hooks' import { ShortcutKbd } from '../shortcuts/shortcut-kbd' import TipPopup from './tip-popup' @@ -36,7 +27,7 @@ const ZoomType = { toggleMiniMap: 'toggleMiniMap', } as const -type ZoomType = typeof ZoomType[keyof typeof ZoomType] +type ZoomType = (typeof ZoomType)[keyof typeof ZoomType] type ZoomInOutProps = { showMiniMap?: boolean @@ -58,21 +49,13 @@ const ZoomInOut: FC<ZoomInOutProps> = ({ isCommentMode = false, }) => { const { t } = useTranslation() - const { - zoomIn, - zoomOut, - zoomTo, - fitView, - } = useReactFlow() + const { zoomIn, zoomOut, zoomTo, fitView } = useReactFlow() const { zoom } = useViewport() const { handleSyncWorkflowDraft } = useNodesSyncDraft() - const { - workflowReadOnly, - getWorkflowReadOnly, - } = useWorkflowReadOnly() + const { workflowReadOnly, getWorkflowReadOnly } = useWorkflowReadOnly() const { data: isCollaborationEnabled } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), - select: s => s.enable_collaboration_mode, + select: (s) => s.enable_collaboration_mode, }) const zoomOptions = [ @@ -99,57 +82,49 @@ const ZoomInOut: FC<ZoomInOutProps> = ({ }, { key: ZoomType.zoomToFit, - text: t($ => $['operator.zoomToFit'], { ns: 'workflow' }), + text: t(($) => $['operator.zoomToFit'], { ns: 'workflow' }), }, ], isCollaborationEnabled ? [ { key: ZoomType.toggleUserComments, - text: t($ => $['operator.showUserComments'], { ns: 'workflow' }), + text: t(($) => $['operator.showUserComments'], { ns: 'workflow' }), }, { key: ZoomType.toggleUserCursors, - text: t($ => $['operator.showUserCursors'], { ns: 'workflow' }), + text: t(($) => $['operator.showUserCursors'], { ns: 'workflow' }), }, { key: ZoomType.toggleMiniMap, - text: t($ => $['operator.showMiniMap'], { ns: 'workflow' }), + text: t(($) => $['operator.showMiniMap'], { ns: 'workflow' }), }, ] : [ { key: ZoomType.toggleMiniMap, - text: t($ => $['operator.showMiniMap'], { ns: 'workflow' }), + text: t(($) => $['operator.showMiniMap'], { ns: 'workflow' }), }, ], ] const handleZoom = (type: ZoomType) => { - if (workflowReadOnly) - return + if (workflowReadOnly) return - if (type === ZoomType.zoomToFit) - fitView() + if (type === ZoomType.zoomToFit) fitView() - if (type === ZoomType.zoomTo25) - zoomTo(0.25) + if (type === ZoomType.zoomTo25) zoomTo(0.25) - if (type === ZoomType.zoomTo50) - zoomTo(0.5) + if (type === ZoomType.zoomTo50) zoomTo(0.5) - if (type === ZoomType.zoomTo75) - zoomTo(0.75) + if (type === ZoomType.zoomTo75) zoomTo(0.75) - if (type === ZoomType.zoomTo100) - zoomTo(1) + if (type === ZoomType.zoomTo100) zoomTo(1) - if (type === ZoomType.zoomTo200) - zoomTo(2) + if (type === ZoomType.zoomTo200) zoomTo(2) if (type === ZoomType.toggleUserComments) { - if (!isCommentMode) - onToggleUserComments?.() + if (!isCommentMode) onToggleUserComments?.() return } @@ -168,32 +143,30 @@ const ZoomInOut: FC<ZoomInOutProps> = ({ } return ( - <div className={` - h-9 cursor-pointer rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg - p-0.5 text-[13px] shadow-lg backdrop-blur-[5px] - hover:bg-state-base-hover - ${workflowReadOnly && 'cursor-not-allowed! opacity-50'} - `} + <div + className={`h-9 cursor-pointer rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 text-[13px] shadow-lg backdrop-blur-[5px] hover:bg-state-base-hover ${workflowReadOnly && 'cursor-not-allowed! opacity-50'} `} > <div className="flex h-8 w-[98px] items-center justify-between rounded-lg"> <TipPopup - title={t($ => $['operator.zoomOut'], { ns: 'workflow' })} + title={t(($) => $['operator.zoomOut'], { ns: 'workflow' })} shortcut="workflow.zoom-out" > <button type="button" - aria-label={t($ => $['operator.zoomOut'], { ns: 'workflow' })} + aria-label={t(($) => $['operator.zoomOut'], { ns: 'workflow' })} disabled={zoom <= 0.25} className={`flex size-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`} onClick={(e) => { - if (zoom <= 0.25) - return + if (zoom <= 0.25) return e.stopPropagation() zoomOut() }} > - <span aria-hidden className="i-ri-zoom-out-line size-4 text-text-tertiary hover:text-text-secondary" /> + <span + aria-hidden + className="i-ri-zoom-out-line size-4 text-text-tertiary hover:text-text-secondary" + /> </button> </TipPopup> <DropdownMenu> @@ -201,8 +174,7 @@ const ZoomInOut: FC<ZoomInOutProps> = ({ disabled={getWorkflowReadOnly()} className="flex h-8 w-[34px] items-center justify-center rounded-lg system-sm-medium text-text-tertiary hover:bg-black/5 hover:text-text-secondary data-popup-open:bg-black/5 data-popup-open:text-text-secondary" > - {Number.parseFloat(`${zoom * 100}`).toFixed(0)} - % + {Number.parseFloat(`${zoom * 100}`).toFixed(0)}% </DropdownMenuTrigger> <DropdownMenuContent placement="top-start" @@ -213,11 +185,9 @@ const ZoomInOut: FC<ZoomInOutProps> = ({ <div className="w-[192px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"> {zoomOptions.map((options, groupIndex) => ( <Fragment key={options[0]!.key}> - {groupIndex !== 0 && ( - <DropdownMenuSeparator className="my-0" /> - )} + {groupIndex !== 0 && <DropdownMenuSeparator className="my-0" />} <div className="p-1"> - {options.map(option => ( + {options.map((option) => ( <DropdownMenuItem key={option.key} className="justify-between px-3 py-1.5 system-md-regular text-text-secondary" @@ -244,14 +214,17 @@ const ZoomInOut: FC<ZoomInOutProps> = ({ <span aria-hidden className="size-4" /> )} {option.key === ZoomType.zoomToFit && ( - <span aria-hidden className="i-ri-fullscreen-line size-4 text-text-tertiary" /> - )} - {option.key !== ZoomType.toggleUserComments - && option.key !== ZoomType.toggleUserCursors - && option.key !== ZoomType.toggleMiniMap - && option.key !== ZoomType.zoomToFit && ( - <span aria-hidden className="size-4" /> + <span + aria-hidden + className="i-ri-fullscreen-line size-4 text-text-tertiary" + /> )} + {option.key !== ZoomType.toggleUserComments && + option.key !== ZoomType.toggleUserCursors && + option.key !== ZoomType.toggleMiniMap && + option.key !== ZoomType.zoomToFit && ( + <span aria-hidden className="size-4" /> + )} <span>{option.text}</span> </div> <div className="flex items-center space-x-0.5"> @@ -274,23 +247,25 @@ const ZoomInOut: FC<ZoomInOutProps> = ({ </DropdownMenuContent> </DropdownMenu> <TipPopup - title={t($ => $['operator.zoomIn'], { ns: 'workflow' })} + title={t(($) => $['operator.zoomIn'], { ns: 'workflow' })} shortcut="workflow.zoom-in" > <button type="button" - aria-label={t($ => $['operator.zoomIn'], { ns: 'workflow' })} + aria-label={t(($) => $['operator.zoomIn'], { ns: 'workflow' })} disabled={zoom >= 2} className={`flex size-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`} onClick={(e) => { - if (zoom >= 2) - return + if (zoom >= 2) return e.stopPropagation() zoomIn() }} > - <span aria-hidden className="i-ri-zoom-in-line size-4 text-text-tertiary hover:text-text-secondary" /> + <span + aria-hidden + className="i-ri-zoom-in-line size-4 text-text-tertiary hover:text-text-secondary" + /> </button> </TipPopup> </div> diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx index c0899aec7c970d..a88e6db22af1ae 100644 --- a/web/app/components/workflow/panel-contextmenu.tsx +++ b/web/app/components/workflow/panel-contextmenu.tsx @@ -5,9 +5,7 @@ import { ContextMenuItem, ContextMenuSeparator, } from '@langgenius/dify-ui/context-menu' -import { - useCallback, -} from 'react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { FlowType } from '@/types/common' import { TEST_RUN_MENU_HOTKEY } from './header/shortcuts' @@ -26,37 +24,30 @@ import { ShortcutKbd } from './shortcuts/shortcut-kbd' import { useStore } from './store' import { WorkflowRunningStatus } from './types' -export function PanelContextmenu({ - onClose, -}: { - onClose: () => void -}) { +export function PanelContextmenu({ onClose }: { onClose: () => void }) { const { t } = useTranslation() - const isPanelContextMenu = useStore(s => s.contextMenuTarget?.type === 'panel') - const clipboardElements = useStore(s => s.clipboardElements) - const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal) - const pendingComment = useStore(s => s.pendingComment) - const setCommentPlacing = useStore(s => s.setCommentPlacing) - const setCommentQuickAdd = useStore(s => s.setCommentQuickAdd) - const workflowRunningData = useStore(s => s.workflowRunningData) - const historyWorkflowData = useStore(s => s.historyWorkflowData) - const isRestoring = useStore(s => s.isRestoring) + const isPanelContextMenu = useStore((s) => s.contextMenuTarget?.type === 'panel') + const clipboardElements = useStore((s) => s.clipboardElements) + const setShowImportDSLModal = useStore((s) => s.setShowImportDSLModal) + const pendingComment = useStore((s) => s.pendingComment) + const setCommentPlacing = useStore((s) => s.setCommentPlacing) + const setCommentQuickAdd = useStore((s) => s.setCommentQuickAdd) + const workflowRunningData = useStore((s) => s.workflowRunningData) + const historyWorkflowData = useStore((s) => s.historyWorkflowData) + const isRestoring = useStore((s) => s.isRestoring) const { handleNodesPaste } = useNodesInteractions() - const { - handleStartWorkflowRun, - handleWorkflowStartRunInChatflow, - } = useWorkflowStartRun() + const { handleStartWorkflowRun, handleWorkflowStartRunInChatflow } = useWorkflowStartRun() const { handleAddNote } = useOperator() const { isCommentModeAvailable } = useWorkflowMoveMode() const { exportCheck } = useDSL() - const accessControl = useHooksStore(s => s.accessControl) - const flowType = useHooksStore(s => s.configsMap?.flowType) + const accessControl = useHooksStore((s) => s.accessControl) + const flowType = useHooksStore((s) => s.configsMap?.flowType) const isChatMode = useIsChatMode() const workflowOperationReadOnly = !!( - workflowRunningData?.result.status === WorkflowRunningStatus.Running - || workflowRunningData?.result.status === WorkflowRunningStatus.Paused - || historyWorkflowData - || isRestoring + workflowRunningData?.result.status === WorkflowRunningStatus.Running || + workflowRunningData?.result.status === WorkflowRunningStatus.Paused || + historyWorkflowData || + isRestoring ) const canEditWorkflow = accessControl.canEdit && !workflowOperationReadOnly const canCommentWorkflow = accessControl.canComment && !workflowOperationReadOnly @@ -73,28 +64,22 @@ export function PanelContextmenu({ 'justify-between gap-4 border-0 bg-transparent px-3 text-left text-text-secondary', )} > - {t($ => $['common.addBlock'], { ns: 'workflow' })} + {t(($) => $['common.addBlock'], { ns: 'workflow' })} </ContextMenuItem> ) }, [t]) const handleRunAction = useCallback(() => { - if (isChatMode) - handleWorkflowStartRunInChatflow() - else - handleStartWorkflowRun() + if (isChatMode) handleWorkflowStartRunInChatflow() + else handleStartWorkflowRun() onClose() }, [isChatMode, handleWorkflowStartRunInChatflow, handleStartWorkflowRun, onClose]) - if (!isPanelContextMenu) - return null + if (!isPanelContextMenu) return null return ( - <ContextMenuContent - popupClassName="w-[200px] rounded-lg" - sideOffset={4} - > + <ContextMenuContent popupClassName="w-[200px] rounded-lg" sideOffset={4}> <ContextMenuGroup> {canEditWorkflow && ( <AddBlock @@ -117,7 +102,7 @@ export function PanelContextmenu({ onClose() }} > - {t($ => $['nodes.note.addNote'], { ns: 'workflow' })} + {t(($) => $['nodes.note.addNote'], { ns: 'workflow' })} </ContextMenuItem> )} {canCommentWorkflow && isCommentModeAvailable && ( @@ -129,14 +114,13 @@ export function PanelContextmenu({ )} onClick={(e) => { e.stopPropagation() - if (pendingComment) - return + if (pendingComment) return setCommentQuickAdd(true) setCommentPlacing(true) onClose() }} > - {t($ => $['comments.actions.addComment'], { ns: 'workflow' })} + {t(($) => $['comments.actions.addComment'], { ns: 'workflow' })} </ContextMenuItem> )} {accessControl.canRun && ( @@ -144,7 +128,9 @@ export function PanelContextmenu({ className="justify-between gap-4 px-3 text-text-secondary" onClick={handleRunAction} > - {isChatMode ? t($ => $['common.debugAndPreview'], { ns: 'workflow' }) : t($ => $['common.run'], { ns: 'workflow' })} + {isChatMode + ? t(($) => $['common.debugAndPreview'], { ns: 'workflow' }) + : t(($) => $['common.run'], { ns: 'workflow' })} {!isChatMode && <ShortcutKbd hotkey={TEST_RUN_MENU_HOTKEY} />} </ContextMenuItem> )} @@ -166,7 +152,7 @@ export function PanelContextmenu({ } }} > - {t($ => $['common.pasteHere'], { ns: 'workflow' })} + {t(($) => $['common.pasteHere'], { ns: 'workflow' })} <ShortcutKbd shortcut="workflow.paste" /> </ContextMenuItem> </ContextMenuGroup> @@ -180,14 +166,14 @@ export function PanelContextmenu({ className="justify-between gap-4 px-3 text-text-secondary" onClick={() => exportCheck?.()} > - {t($ => $.export, { ns: 'app' })} + {t(($) => $.export, { ns: 'app' })} </ContextMenuItem> {!shouldHideImportApp && ( <ContextMenuItem className="justify-between gap-4 px-3 text-text-secondary" onClick={() => setShowImportDSLModal(true)} > - {t($ => $.importApp, { ns: 'app' })} + {t(($) => $.importApp, { ns: 'app' })} </ContextMenuItem> )} </ContextMenuGroup> diff --git a/web/app/components/workflow/panel/__tests__/human-input-filled-form-list.spec.tsx b/web/app/components/workflow/panel/__tests__/human-input-filled-form-list.spec.tsx index 4a77e71705bdea..f113b49940666f 100644 --- a/web/app/components/workflow/panel/__tests__/human-input-filled-form-list.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/human-input-filled-form-list.spec.tsx @@ -3,7 +3,9 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import HumanInputFilledFormList from '../human-input-filled-form-list' -const createFilledForm = (overrides: Partial<HumanInputFilledFormData> = {}): HumanInputFilledFormData => ({ +const createFilledForm = ( + overrides: Partial<HumanInputFilledFormData> = {}, +): HumanInputFilledFormData => ({ node_id: 'node-1', node_title: 'Approval', rendered_content: 'Approved by Alice', @@ -39,7 +41,9 @@ describe('HumanInputFilledFormList', () => { expect(screen.getAllByTestId('submitted-field-values')).toHaveLength(2) expect(screen.getAllByTestId('executed-action')).toHaveLength(2) expect(screen.getAllByTestId('submitted-field-summary')).toHaveLength(2) - expect(screen.getAllByTestId('submitted-field-summary')[0]).toHaveTextContent('Approved by Alice') + expect(screen.getAllByTestId('submitted-field-summary')[0]).toHaveTextContent( + 'Approved by Alice', + ) await user.click(screen.getAllByTestId('expand-icon')[0]!) diff --git a/web/app/components/workflow/panel/__tests__/human-input-form-list.spec.tsx b/web/app/components/workflow/panel/__tests__/human-input-form-list.spec.tsx index 466c4c207c9e67..d21a8814316acb 100644 --- a/web/app/components/workflow/panel/__tests__/human-input-form-list.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/human-input-form-list.spec.tsx @@ -2,7 +2,10 @@ import type { HumanInputFormData } from '@/types/workflow' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { CUSTOM_NODE } from '@/app/components/workflow/constants' -import { DeliveryMethodType, UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types' +import { + DeliveryMethodType, + UserActionButtonType, +} from '@/app/components/workflow/nodes/human-input/types' import { InputVarType } from '@/app/components/workflow/types' import HumanInputFormList from '../human-input-form-list' @@ -26,20 +29,24 @@ const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInput node_id: 'human-node-1', node_title: 'Need Approval', form_content: 'Before {{#$output.reason#}} after', - inputs: [{ - type: InputVarType.paragraph, - output_variable_name: 'reason', - default: { - selector: [], - type: 'constant', - value: 'prefill', + inputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'reason', + default: { + selector: [], + type: 'constant', + value: 'prefill', + }, + }, + ], + actions: [ + { + id: 'approve', + title: 'Approve', + button_style: UserActionButtonType.Primary, }, - }], - actions: [{ - id: 'approve', - title: 'Approve', - button_style: UserActionButtonType.Primary, - }], + ], form_token: 'token-1', resolved_default_values: {}, display_in_ui: true, @@ -61,20 +68,22 @@ describe('HumanInputFormList', () => { id: 'human-node-1', type: CUSTOM_NODE, data: { - delivery_methods: [{ - id: 'email-1', - type: DeliveryMethodType.Email, - enabled: true, - config: { - recipients: { - whole_workspace: false, - items: [], + delivery_methods: [ + { + id: 'email-1', + type: DeliveryMethodType.Email, + enabled: true, + config: { + recipients: { + whole_workspace: false, + items: [], + }, + subject: 'Need approval', + body: 'Please review', + debug_mode: true, }, - subject: 'Need approval', - body: 'Please review', - debug_mode: true, }, - }], + ], }, }, { @@ -128,23 +137,13 @@ describe('HumanInputFormList', () => { }, }) - render( - <HumanInputFormList - humanInputFormDataList={[ - createFormData(), - ]} - />, - ) + render(<HumanInputFormList humanInputFormDataList={[createFormData()]} />) expect(screen.queryByTestId('tips')).not.toBeInTheDocument() }) it('should render an empty container when there are no visible forms', () => { - render( - <HumanInputFormList - humanInputFormDataList={[]} - />, - ) + render(<HumanInputFormList humanInputFormDataList={[]} />) expect(screen.queryByTestId('content-wrapper')).not.toBeInTheDocument() }) diff --git a/web/app/components/workflow/panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/__tests__/index.spec.tsx index 4a813f392d70fa..c4e1a096c3a43e 100644 --- a/web/app/components/workflow/panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/index.spec.tsx @@ -29,37 +29,33 @@ type MockResizeMode = 'borderBox' | 'contentRect' | 'fallback' let mockResizeModes: MockResizeMode[] = [] let mockResizeObservers: MockResizeObserver[] = [] -const createResizeEntry = (mode: MockResizeMode): ResizeObserverEntry => ({ - borderBoxSize: mode === 'borderBox' - ? [{ inlineSize: 720, blockSize: 0 }] as ResizeObserverSize[] - : [], - contentBoxSize: [], - devicePixelContentBoxSize: [], - contentRect: { - width: mode === 'contentRect' ? 530 : 0, - height: 0, - x: 0, - y: 0, - top: 0, - right: 0, - bottom: 0, - left: 0, - toJSON: () => ({}), - } as DOMRectReadOnly, - target: document.createElement('div'), -} as unknown as ResizeObserverEntry) +const createResizeEntry = (mode: MockResizeMode): ResizeObserverEntry => + ({ + borderBoxSize: + mode === 'borderBox' ? ([{ inlineSize: 720, blockSize: 0 }] as ResizeObserverSize[]) : [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + contentRect: { + width: mode === 'contentRect' ? 530 : 0, + height: 0, + x: 0, + y: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON: () => ({}), + } as DOMRectReadOnly, + target: document.createElement('div'), + }) as unknown as ResizeObserverEntry class MockResizeObserver { callback: ResizeObserverCallback observe = vi.fn(() => { - if (!mockResizeModes.length) - return + if (!mockResizeModes.length) return - this.callback( - mockResizeModes.map(createResizeEntry), - this as unknown as ResizeObserver, - ) + this.callback(mockResizeModes.map(createResizeEntry), this as unknown as ResizeObserver) }) disconnect = vi.fn() @@ -75,9 +71,10 @@ let mockNodes: MockNode[] = [] let mockPanelStoreState: MockPanelStoreState vi.mock('reactflow', () => ({ - useStore: (selector: (state: { getNodes: () => MockNode[] }) => unknown) => selector({ - getNodes: () => mockNodes, - }), + useStore: (selector: (state: { getNodes: () => MockNode[] }) => unknown) => + selector({ + getNodes: () => mockNodes, + }), useStoreApi: () => ({ getState: () => ({ getNodes: () => mockNodes, @@ -91,7 +88,7 @@ vi.mock('../../store', () => ({ })) vi.mock('../../nodes', () => ({ - Panel: ({ id, data }: { id: string, data: MockNodeData }) => ( + Panel: ({ id, data }: { id: string; data: MockNodeData }) => ( <div data-testid="node-panel">{`${id}:${data.title || 'untitled'}`}</div> ), })) @@ -110,17 +107,16 @@ vi.mock('@/next/dynamic', async () => { const ReactModule = await import('react') return { - default: ( - loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>, - ) => { + default: (loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>) => { const DynamicComponent = (props: Record<string, unknown>) => { - const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null) + const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType< + Record<string, unknown> + > | null>(null) ReactModule.useEffect(() => { let mounted = true loader().then((mod) => { - if (mounted) - setLoaded(() => mod.default) + if (mounted) setLoaded(() => mod.default) }) return () => { mounted = false @@ -177,14 +173,16 @@ describe('Panel', () => { }) it('should render slots, selected node details, and secondary panels while constraining oversized preview widths', async () => { - mockNodes = [{ - id: 'node-1', - type: 'custom', - data: { - selected: true, - title: 'Selected Node', + mockNodes = [ + { + id: 'node-1', + type: 'custom', + data: { + selected: true, + title: 'Selected Node', + }, }, - }] + ] mockPanelStoreState = { ...mockPanelStoreState, showEnvPanel: true, @@ -201,7 +199,7 @@ describe('Panel', () => { }} versionHistoryPanelProps={{ latestVersionId: 'version-1', - restoreVersionUrl: versionId => `/apps/app-1/workflows/${versionId}/restore`, + restoreVersionUrl: (versionId) => `/apps/app-1/workflows/${versionId}/restore`, }} />, ) @@ -285,6 +283,6 @@ describe('Panel', () => { unmount() expect(mockResizeObservers).toHaveLength(2) - mockResizeObservers.forEach(observer => expect(observer.disconnect).toHaveBeenCalledTimes(1)) + mockResizeObservers.forEach((observer) => expect(observer.disconnect).toHaveBeenCalledTimes(1)) }) }) diff --git a/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx b/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx index 667b0e9a67fa17..460b20cbd47df0 100644 --- a/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx @@ -68,14 +68,11 @@ const renderInputsPanel = ( options?: Omit<Parameters<typeof renderWorkflowFlowComponent>[1], 'nodes' | 'edges'>, onRun = vi.fn(), ) => - renderWorkflowFlowComponent( - <InputsPanel onRun={onRun} />, - { - nodes: [startNode], - edges: [], - ...options, - }, - ) + renderWorkflowFlowComponent(<InputsPanel onRun={onRun} />, { + nodes: [startNode], + edges: [], + ...options, + }) describe('InputsPanel', () => { beforeEach(() => { @@ -154,12 +151,14 @@ describe('InputsPanel', () => { await user.click(screen.getByRole('button', { name: 'common.operation.ok' })) await waitFor(() => { - expect(store.getState().files).toEqual([{ - type: 'image', - transfer_method: TransferMethod.remote_url, - url: 'https://example.com/image.png', - upload_file_id: '', - }]) + expect(store.getState().files).toEqual([ + { + type: 'image', + transfer_method: TransferMethod.remote_url, + url: 'https://example.com/image.png', + upload_file_id: '', + }, + ]) }) }) @@ -305,18 +304,22 @@ describe('InputsPanel', () => { const onRun = vi.fn() const handleRun = vi.fn() - renderInputsPanel(createStartNode(), { - hooksStoreProps: createHooksStoreProps({ - handleRun, - accessControl: { - canEdit: true, - canComment: true, - canRun: false, - canImportExportDSL: true, - canReleaseAndVersion: true, - }, - }), - }, onRun) + renderInputsPanel( + createStartNode(), + { + hooksStoreProps: createHooksStoreProps({ + handleRun, + accessControl: { + canEdit: true, + canComment: true, + canRun: false, + canImportExportDSL: true, + canReleaseAndVersion: true, + }, + }), + }, + onRun, + ) await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })) diff --git a/web/app/components/workflow/panel/__tests__/record.spec.tsx b/web/app/components/workflow/panel/__tests__/record.spec.tsx index 1d07098427db92..04edb0e219686b 100644 --- a/web/app/components/workflow/panel/__tests__/record.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/record.spec.tsx @@ -5,7 +5,9 @@ import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' import Record from '../record' const mockHandleUpdateWorkflowCanvas = vi.fn() -const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number) => finishedAt ? ' (Finished)' : ' (Running)') +const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number) => + finishedAt ? ' (Finished)' : ' (Running)', +) let latestGetResultCallback: ((res: WorkflowRunDetailResponse) => void) | undefined @@ -40,7 +42,9 @@ vi.mock('@/app/components/workflow/utils', () => ({ formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt), })) -const createRunDetail = (overrides: Partial<WorkflowRunDetailResponse> = {}): WorkflowRunDetailResponse => ({ +const createRunDetail = ( + overrides: Partial<WorkflowRunDetailResponse> = {}, +): WorkflowRunDetailResponse => ({ id: 'run-1', version: '1', graph: { @@ -112,12 +116,14 @@ describe('Record', () => { expect(latestGetResultCallback).toBeDefined() act(() => { - latestGetResultCallback?.(createRunDetail({ - graph: { - nodes, - edges, - }, - })) + latestGetResultCallback?.( + createRunDetail({ + graph: { + nodes, + edges, + }, + }), + ) }) expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({ @@ -145,13 +151,15 @@ describe('Record', () => { }) act(() => { - latestGetResultCallback?.(createRunDetail({ - graph: { - nodes, - edges, - viewport, - }, - })) + latestGetResultCallback?.( + createRunDetail({ + graph: { + nodes, + edges, + viewport, + }, + }), + ) }) expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({ diff --git a/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx b/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx index 93d51acd78d99e..78ac2309b6fde9 100644 --- a/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx @@ -5,7 +5,10 @@ import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import copy from 'copy-to-clipboard' -import { createNodeTracing, createWorkflowRunningData } from '@/app/components/workflow/__tests__/fixtures' +import { + createNodeTracing, + createWorkflowRunningData, +} from '@/app/components/workflow/__tests__/fixtures' import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { submitHumanInputForm } from '@/service/workflow' @@ -34,16 +37,12 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/workflow/run/result-panel', () => ({ - default: ({ - status, - onOpenTracingTab, - }: { - status?: string - onOpenTracingTab?: () => void - }) => ( + default: ({ status, onOpenTracingTab }: { status?: string; onOpenTracingTab?: () => void }) => ( <div data-testid="result-panel"> <div>{status}</div> - <button type="button" onClick={onOpenTracingTab}>open-tracing</button> + <button type="button" onClick={onOpenTracingTab}> + open-tracing + </button> </div> ), })) @@ -62,7 +61,9 @@ vi.mock('@/app/components/workflow/run/result-text', () => ({ }) => ( <div> <div data-testid="result-text">{JSON.stringify({ outputs, isPaused, isRunning })}</div> - <button type="button" onClick={onClick}>open-detail</button> + <button type="button" onClick={onClick}> + open-detail + </button> </div> ), })) @@ -72,8 +73,10 @@ vi.mock('@/app/components/workflow/run/tracing-panel', () => ({ })) vi.mock('@/app/components/base/chat/chat/answer/reasoning-panel', () => ({ - default: ({ content, done }: { content: Record<string, string>, done: boolean }) => ( - <div data-testid="reasoning-panel" data-done={String(done)}>{Object.keys(content).join(',')}</div> + default: ({ content, done }: { content: Record<string, string>; done: boolean }) => ( + <div data-testid="reasoning-panel" data-done={String(done)}> + {Object.keys(content).join(',')} + </div> ), })) @@ -91,11 +94,19 @@ vi.mock('@/app/components/workflow/panel/human-input-form-list', () => ({ onHumanInputFormSubmit, }: { humanInputFormDataList: unknown[] - onHumanInputFormSubmit?: (token: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void> + onHumanInputFormSubmit?: ( + token: string, + formData: { inputs: Record<string, HumanInputFieldValue>; action: string }, + ) => Promise<void> }) => ( <div> <div data-testid="human-form-list">{humanInputFormDataList.length}</div> - <button type="button" onClick={() => onHumanInputFormSubmit?.('form-token', { inputs: { answer: 'ok' }, action: 'approve' })}> + <button + type="button" + onClick={() => + onHumanInputFormSubmit?.('form-token', { inputs: { answer: 'ok' }, action: 'approve' }) + } + > submit-human-form </button> </div> @@ -160,16 +171,13 @@ describe('WorkflowPreview', () => { it('should keep the input tab active, switch to result after running, and close the preview panel', async () => { const user = userEvent.setup() - renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - showInputsPanel: true, - showDebugAndPreviewPanel: true, - previewPanelWidth: 420, - }, + renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + showInputsPanel: true, + showDebugAndPreviewPanel: true, + previewPanelWidth: 420, }, - ) + }) expect(screen.getByRole('button', { name: 'run-inputs' })).toBeInTheDocument() @@ -181,40 +189,34 @@ describe('WorkflowPreview', () => { }) it('should switch to detail when the workflow is listening', () => { - renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - isListening: true, - workflowRunningData: createWorkflowRunningData({ - result: createWorkflowResult({ - status: WorkflowRunningStatus.Running, - }), + renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + isListening: true, + workflowRunningData: createWorkflowRunningData({ + result: createWorkflowResult({ + status: WorkflowRunningStatus.Running, }), - }, + }), }, - ) + }) expect(screen.getByTestId('result-panel')).toHaveTextContent(WorkflowRunningStatus.Running) }) it('should switch to detail when a finished run has no outputs or files', () => { - renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - workflowRunningData: { - ...createWorkflowRunningData({ - result: createWorkflowResult({ - status: WorkflowRunningStatus.Succeeded, - files: [], - }), + renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + workflowRunningData: { + ...createWorkflowRunningData({ + result: createWorkflowResult({ + status: WorkflowRunningStatus.Succeeded, + files: [], }), - resultText: '', - } as NonNullable<Shape['workflowRunningData']>, - }, + }), + resultText: '', + } as NonNullable<Shape['workflowRunningData']>, }, - ) + }) expect(screen.getByTestId('result-panel')).toHaveTextContent(WorkflowRunningStatus.Succeeded) }) @@ -230,41 +232,38 @@ describe('WorkflowPreview', () => { humanInputFilledFormDataList: [createHumanInputFilledFormData()], }) - renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - workflowRunningData: pausedData, - }, + renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + workflowRunningData: pausedData, }, - ) + }) expect(screen.getByTestId('human-form-list')).toHaveTextContent('1') expect(screen.getByTestId('filled-form-list')).toHaveTextContent('1') await user.click(screen.getByRole('button', { name: 'submit-human-form' })) - expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('form-token', { inputs: { answer: 'ok' }, action: 'approve' }) + expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('form-token', { + inputs: { answer: 'ok' }, + action: 'approve', + }) }) it('should copy successful string output and show a success toast', async () => { const user = userEvent.setup() - renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - workflowRunningData: { - ...createWorkflowRunningData({ - result: createWorkflowResult({ - status: WorkflowRunningStatus.Succeeded, - files: [], - }), + renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + workflowRunningData: { + ...createWorkflowRunningData({ + result: createWorkflowResult({ + status: WorkflowRunningStatus.Succeeded, + files: [], }), - resultText: 'final answer', - } as NonNullable<Shape['workflowRunningData']>, - }, + }), + resultText: 'final answer', + } as NonNullable<Shape['workflowRunningData']>, }, - ) + }) await user.click(screen.getByText('runLog.result')) await user.click(screen.getByRole('button', { name: 'common.operation.copy' })) @@ -274,30 +273,24 @@ describe('WorkflowPreview', () => { }) it('should show a loading state for an empty detail panel', () => { - renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - isListening: true, - workflowRunningData: undefined, - }, + renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + isListening: true, + workflowRunningData: undefined, }, - ) + }) expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() }) it('should show a loading state for an empty tracing panel', () => { - renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - workflowRunningData: createWorkflowRunningData({ - tracing: [], - }), - }, + renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + workflowRunningData: createWorkflowRunningData({ + tracing: [], + }), }, - ) + }) expect(screen.getByTestId('tracing-panel')).toHaveTextContent('0') expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() @@ -305,15 +298,12 @@ describe('WorkflowPreview', () => { it('should keep inert tabs disabled without run data and switch among result, detail, and tracing when data exists', async () => { const user = userEvent.setup() - const { store } = renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - showInputsPanel: true, - workflowRunningData: undefined, - }, + const { store } = renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + showInputsPanel: true, + workflowRunningData: undefined, }, - ) + }) await user.click(screen.getByText('runLog.result')) await user.click(screen.getByText('runLog.detail')) @@ -350,20 +340,17 @@ describe('WorkflowPreview', () => { it('should render a single merged reasoning panel above the result on the result tab', async () => { const user = userEvent.setup() - renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - workflowRunningData: { - ...createWorkflowRunningData({ - result: createWorkflowResult({ status: WorkflowRunningStatus.Running }), - }), - resultText: '', - reasoningContent: { 'llm-1': 'thinking a', 'llm-2': 'thinking b' }, - } as NonNullable<Shape['workflowRunningData']>, - }, + renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + workflowRunningData: { + ...createWorkflowRunningData({ + result: createWorkflowResult({ status: WorkflowRunningStatus.Running }), + }), + resultText: '', + reasoningContent: { 'llm-1': 'thinking a', 'llm-2': 'thinking b' }, + } as NonNullable<Shape['workflowRunningData']>, }, - ) + }) await user.click(screen.getByText('runLog.result')) @@ -377,20 +364,17 @@ describe('WorkflowPreview', () => { it('should mark reasoning done once the answer starts streaming while still running', async () => { const user = userEvent.setup() - renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - workflowRunningData: { - ...createWorkflowRunningData({ - result: createWorkflowResult({ status: WorkflowRunningStatus.Running }), - }), - resultText: 'the answer', - reasoningContent: { 'llm-1': 'thinking a' }, - } as NonNullable<Shape['workflowRunningData']>, - }, + renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + workflowRunningData: { + ...createWorkflowRunningData({ + result: createWorkflowResult({ status: WorkflowRunningStatus.Running }), + }), + resultText: 'the answer', + reasoningContent: { 'llm-1': 'thinking a' }, + } as NonNullable<Shape['workflowRunningData']>, }, - ) + }) await user.click(screen.getByText('runLog.result')) @@ -401,20 +385,17 @@ describe('WorkflowPreview', () => { it('should not render a reasoning panel when there is no reasoning content', async () => { const user = userEvent.setup() - renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - workflowRunningData: { - ...createWorkflowRunningData({ - result: createWorkflowResult({ status: WorkflowRunningStatus.Running }), - }), - resultText: '', - reasoningContent: { llm: '' }, - } as NonNullable<Shape['workflowRunningData']>, - }, + renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + workflowRunningData: { + ...createWorkflowRunningData({ + result: createWorkflowResult({ status: WorkflowRunningStatus.Running }), + }), + resultText: '', + reasoningContent: { llm: '' }, + } as NonNullable<Shape['workflowRunningData']>, }, - ) + }) await user.click(screen.getByText('runLog.result')) @@ -424,23 +405,20 @@ describe('WorkflowPreview', () => { it('should switch to the tracing tab when result panel requests it', async () => { const user = userEvent.setup() - renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - workflowRunningData: { - ...createWorkflowRunningData({ - result: createWorkflowResult({ - status: 'partial-succeeded', - files: [], - }), - tracing: [createNodeTracing()], + renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + workflowRunningData: { + ...createWorkflowRunningData({ + result: createWorkflowResult({ + status: 'partial-succeeded', + files: [], }), - resultText: 'ready', - } as NonNullable<Shape['workflowRunningData']>, - }, + tracing: [createNodeTracing()], + }), + resultText: 'ready', + } as NonNullable<Shape['workflowRunningData']>, }, - ) + }) await user.click(screen.getByText('runLog.detail')) await user.click(screen.getByRole('button', { name: 'open-tracing' })) @@ -449,15 +427,12 @@ describe('WorkflowPreview', () => { }) it('should resize the preview panel within the allowed workflow canvas bounds', async () => { - const { container, store } = renderWorkflowComponent( - <WorkflowPreview />, - { - initialStoreState: { - previewPanelWidth: 450, - workflowCanvasWidth: 1000, - }, + const { container, store } = renderWorkflowComponent(<WorkflowPreview />, { + initialStoreState: { + previewPanelWidth: 450, + workflowCanvasWidth: 1000, }, - ) + }) const resizeHandle = container.querySelector('.cursor-col-resize') as HTMLElement diff --git a/web/app/components/workflow/panel/chat-record/__tests__/index.spec.tsx b/web/app/components/workflow/panel/chat-record/__tests__/index.spec.tsx index 7631f7c42d8922..1ee4cc2873142d 100644 --- a/web/app/components/workflow/panel/chat-record/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/chat-record/__tests__/index.spec.tsx @@ -40,8 +40,22 @@ describe('ChatRecord', () => { metadata: {}, message_files: [], }, - { id: 'msg-2', query: 'Question 2', answer: 'Answer 2', parent_message_id: 'msg-1', metadata: {}, message_files: [] }, - { id: 'msg-3', query: 'Question 3', answer: 'Answer 3', parent_message_id: 'msg-1', metadata: {}, message_files: [] }, + { + id: 'msg-2', + query: 'Question 2', + answer: 'Answer 2', + parent_message_id: 'msg-1', + metadata: {}, + message_files: [], + }, + { + id: 'msg-3', + query: 'Question 3', + answer: 'Answer 3', + parent_message_id: 'msg-1', + metadata: {}, + message_files: [], + }, ], } as never) diff --git a/web/app/components/workflow/panel/chat-record/__tests__/user-input.spec.tsx b/web/app/components/workflow/panel/chat-record/__tests__/user-input.spec.tsx index e0e98f6589258c..57fe0f8d8a5ddf 100644 --- a/web/app/components/workflow/panel/chat-record/__tests__/user-input.spec.tsx +++ b/web/app/components/workflow/panel/chat-record/__tests__/user-input.spec.tsx @@ -13,10 +13,7 @@ describe('chat-record UserInput', () => { const user = userEvent.setup() const { container } = render( <UserInput - variables={[ - { variable: 'query' }, - { variable: 'locale' }, - ]} + variables={[{ variable: 'query' }, { variable: 'locale' }]} initialExpanded={false} />, ) diff --git a/web/app/components/workflow/panel/chat-record/index.tsx b/web/app/components/workflow/panel/chat-record/index.tsx index cae82e0f4e7a15..be892c72db6194 100644 --- a/web/app/components/workflow/panel/chat-record/index.tsx +++ b/web/app/components/workflow/panel/chat-record/index.tsx @@ -1,12 +1,7 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type' import type { ChatItem, ChatItemInTree } from '@/app/components/base/chat/types' import { RiCloseLine } from '@remixicon/react' -import { - memo, - useCallback, - useEffect, - useState, -} from 'react' +import { memo, useCallback, useEffect, useState } from 'react' import { useStore as useAppStore } from '@/app/components/app/store' import Chat from '@/app/components/base/chat/chat' import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils' @@ -14,25 +9,26 @@ import { getProcessedFilesFromResponse } from '@/app/components/base/file-upload import Loading from '@/app/components/base/loading' import { fetchConversationMessages } from '@/service/debug' import { useWorkflowRun } from '../../hooks' -import { - useStore, - useWorkflowStore, -} from '../../store' +import { useStore, useWorkflowStore } from '../../store' import { formatWorkflowRunIdentifier } from '../../utils' import UserInput from './user-input' function getFormattedChatList(messages: any[]) { const res: ChatItem[] = [] messages.forEach((item: any) => { - const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || [] + const questionFiles = + item.message_files?.filter((file: any) => file.belongs_to === 'user') || [] res.push({ id: `question-${item.id}`, content: item.query, isAnswer: false, - message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id }))), + message_files: getProcessedFilesFromResponse( + questionFiles.map((item: any) => ({ ...item, related_id: item.id })), + ), parentMessageId: item.parent_message_id || undefined, }) - const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] + const answerFiles = + item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] res.push({ id: item.id, content: item.answer, @@ -41,7 +37,9 @@ function getFormattedChatList(messages: any[]) { citation: item.metadata?.retriever_resources, reasoningContent: item.metadata?.reasoning, reasoningFinished: true, - message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))), + message_files: getProcessedFilesFromResponse( + answerFiles.map((item: any) => ({ ...item, related_id: item.id })), + ), workflow_run_id: item.workflow_run_id, parentMessageId: `question-${item.id}`, }) @@ -53,10 +51,10 @@ const ChatRecord = () => { const [fetched, setFetched] = useState(false) const [chatItemTree, setChatItemTree] = useState<ChatItemInTree[]>([]) const [threadChatItems, setThreadChatItems] = useState<IChatItem[]>([]) - const appDetail = useAppStore(s => s.appDetail) + const appDetail = useAppStore((s) => s.appDetail) const workflowStore = useWorkflowStore() const { handleLoadBackupDraft } = useWorkflowRun() - const historyWorkflowData = useStore(s => s.historyWorkflowData) + const historyWorkflowData = useStore((s) => s.historyWorkflowData) const currentConversationID = historyWorkflowData?.conversation_id const handleFetchConversationMessages = useCallback(async () => { @@ -70,10 +68,8 @@ const ChatRecord = () => { const tree = buildChatItemTree(newAllChatItems) setChatItemTree(tree) setThreadChatItems(getThreadMessages(tree, newAllChatItems.at(-1)?.id)) - } - catch { - } - finally { + } catch { + } finally { setFetched(true) } } @@ -83,9 +79,12 @@ const ChatRecord = () => { handleFetchConversationMessages() }, [currentConversationID, appDetail, handleFetchConversationMessages]) - const switchSibling = useCallback((siblingMessageId: string) => { - setThreadChatItems(getThreadMessages(chatItemTree, siblingMessageId)) - }, [chatItemTree]) + const switchSibling = useCallback( + (siblingMessageId: string) => { + setThreadChatItems(getThreadMessages(chatItemTree, siblingMessageId)) + }, + [chatItemTree], + ) return ( <div @@ -115,10 +114,12 @@ const ChatRecord = () => { </div> <div className="h-0 grow"> <Chat - config={{ - supportCitationHitInfo: true, - questionEditEnable: false, - } as any} + config={ + { + supportCitationHitInfo: true, + questionEditEnable: false, + } as any + } chatList={threadChatItems} chatContainerClassName="px-3" chatContainerInnerClassName="pt-6 w-full max-w-full mx-auto" diff --git a/web/app/components/workflow/panel/chat-record/user-input.tsx b/web/app/components/workflow/panel/chat-record/user-input.tsx index fc7add155e5bcd..12e7df0d2f9bb6 100644 --- a/web/app/components/workflow/panel/chat-record/user-input.tsx +++ b/web/app/components/workflow/panel/chat-record/user-input.tsx @@ -1,8 +1,5 @@ import { RiArrowDownSLine } from '@remixicon/react' -import { - memo, - useState, -} from 'react' +import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' type UserInputVariable = { @@ -14,51 +11,33 @@ type UserInputProps = { initialExpanded?: boolean } -const UserInput = ({ - variables = [], - initialExpanded = true, -}: UserInputProps) => { +const UserInput = ({ variables = [], initialExpanded = true }: UserInputProps) => { const { t } = useTranslation() const [expanded, setExpanded] = useState(initialExpanded) - if (!variables.length) - return null + if (!variables.length) return null return ( <div - className={` - rounded-xl border - ${!expanded ? 'border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-none' : 'border-transparent bg-white shadow-xs'} - `} + className={`rounded-xl border ${!expanded ? 'border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-none' : 'border-transparent bg-white shadow-xs'} `} > <div - className={` - flex h-[18px] cursor-pointer items-center px-2 pt-4 text-[13px] font-semibold - ${!expanded ? 'text-text-accent-secondary' : 'text-text-secondary'} - `} + className={`flex h-[18px] cursor-pointer items-center px-2 pt-4 text-[13px] font-semibold ${!expanded ? 'text-text-accent-secondary' : 'text-text-secondary'} `} onClick={() => setExpanded(!expanded)} > <RiArrowDownSLine className={`mr-1 size-3 ${!expanded ? '-rotate-90 text-text-accent' : 'text-text-tertiary'}`} /> - {t($ => $['panel.userInputField'], { ns: 'workflow' }).toLocaleUpperCase()} + {t(($) => $['panel.userInputField'], { ns: 'workflow' }).toLocaleUpperCase()} </div> <div className="px-2 pt-1 pb-3"> - { - expanded && ( - <div className="py-2 text-[13px] text-text-primary"> - { - variables.map((variable: any) => ( - <div - key={variable.variable} - className="mb-2 last-of-type:mb-0" - > - </div> - )) - } - </div> - ) - } + {expanded && ( + <div className="py-2 text-[13px] text-text-primary"> + {variables.map((variable: any) => ( + <div key={variable.variable} className="mb-2 last-of-type:mb-0"></div> + ))} + </div> + )} </div> </div> ) diff --git a/web/app/components/workflow/panel/chat-variable-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/__tests__/index.spec.tsx index 4cf48980627a87..4467a69c075185 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/__tests__/index.spec.tsx @@ -61,13 +61,14 @@ vi.mock('reactflow', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: <T,>(selector: (state: MockWorkflowStoreState) => T) => selector({ - setShowChatVariablePanel: mockSetShowChatVariablePanel, - appId: 'app-1', - conversationVariables: mockConversationVariables, - setConversationVariables: mockSetConversationVariables, - setControlPromptEditorRerenderKey: mockSetControlPromptEditorRerenderKey, - }), + useStore: <T,>(selector: (state: MockWorkflowStoreState) => T) => + selector({ + setShowChatVariablePanel: mockSetShowChatVariablePanel, + appId: 'app-1', + conversationVariables: mockConversationVariables, + setConversationVariables: mockSetConversationVariables, + setControlPromptEditorRerenderKey: mockSetControlPromptEditorRerenderKey, + }), })) vi.mock('@/service/workflow', () => ({ @@ -81,7 +82,8 @@ vi.mock('../../../hooks/use-inspect-vars-crud', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({ - findUsedVarNodes: (...args: Parameters<typeof mockFindUsedVarNodes>) => mockFindUsedVarNodes(...args), + findUsedVarNodes: (...args: Parameters<typeof mockFindUsedVarNodes>) => + mockFindUsedVarNodes(...args), updateNodeVars: (...args: Parameters<typeof mockUpdateNodeVars>) => mockUpdateNodeVars(...args), })) @@ -103,51 +105,60 @@ vi.mock('@/app/components/workflow/panel/chat-variable-panel/components/variable ), })) -vi.mock('@/app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger', () => ({ - default: ({ - open, - showTip, - chatVar, - onSave, - onClose, - }: { - open: boolean - showTip: boolean - chatVar?: ConversationVariable - onSave: (chatVar: ConversationVariable) => void - onClose: () => void - }) => ( - <div data-testid="variable-modal-trigger"> - <span>{open ? 'open' : 'closed'}</span> - <span>{showTip ? 'tip-on' : 'tip-off'}</span> - <span>{chatVar?.name || 'new-variable'}</span> - <button - type="button" - onClick={() => onSave({ - id: 'var-added', - name: 'fresh_var', - value_type: ChatVarType.String, - value: '', - description: 'Added variable', - })} - > - save-add - </button> - {chatVar && ( +vi.mock( + '@/app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger', + () => ({ + default: ({ + open, + showTip, + chatVar, + onSave, + onClose, + }: { + open: boolean + showTip: boolean + chatVar?: ConversationVariable + onSave: (chatVar: ConversationVariable) => void + onClose: () => void + }) => ( + <div data-testid="variable-modal-trigger"> + <span>{open ? 'open' : 'closed'}</span> + <span>{showTip ? 'tip-on' : 'tip-off'}</span> + <span>{chatVar?.name || 'new-variable'}</span> <button type="button" - onClick={() => onSave({ - ...chatVar, - name: `${chatVar.name}_next`, - })} + onClick={() => + onSave({ + id: 'var-added', + name: 'fresh_var', + value_type: ChatVarType.String, + value: '', + description: 'Added variable', + }) + } > - save-edit + save-add </button> - )} - <button type="button" onClick={onClose}>close-trigger</button> - </div> - ), -})) + {chatVar && ( + <button + type="button" + onClick={() => + onSave({ + ...chatVar, + name: `${chatVar.name}_next`, + }) + } + > + save-edit + </button> + )} + <button type="button" onClick={onClose}> + close-trigger + </button> + </div> + ), + }), +) vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm', () => ({ default: ({ @@ -159,13 +170,16 @@ vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-conf onConfirm: () => void onCancel: () => void }) => { - if (!isShow) - return null + if (!isShow) return null return ( <div data-testid="remove-effect-var-confirm"> - <button type="button" onClick={onConfirm}>confirm-remove</button> - <button type="button" onClick={onCancel}>cancel-remove</button> + <button type="button" onClick={onConfirm}> + confirm-remove + </button> + <button type="button" onClick={onCancel}> + cancel-remove + </button> </div> ) }, @@ -191,7 +205,9 @@ describe('ChatVariablePanel', () => { await user.click(toggleTipButton) expect(screen.queryByText('workflow.chatVariable.panelDescription')).not.toBeInTheDocument() - const closeButton = container.querySelector('.flex.size-6.cursor-pointer.items-center.justify-center') as HTMLElement + const closeButton = container.querySelector( + '.flex.size-6.cursor-pointer.items-center.justify-center', + ) as HTMLElement await user.click(closeButton) expect(mockSetShowChatVariablePanel).toHaveBeenCalledWith(false) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/array-bool-list.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/array-bool-list.spec.tsx index 6f507ed4cd270d..43405d2428907b 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/array-bool-list.spec.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/array-bool-list.spec.tsx @@ -6,12 +6,7 @@ describe('ArrayBoolList', () => { it('toggles, appends, and removes boolean values', async () => { const user = userEvent.setup() const onChange = vi.fn() - const { container } = render( - <ArrayBoolList - list={[true]} - onChange={onChange} - />, - ) + const { container } = render(<ArrayBoolList list={[true]} onChange={onChange} />) await user.click(screen.getByText('False')) await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue')) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/array-value-list.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/array-value-list.spec.tsx index 320586706bdda6..e1c52737e1984e 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/array-value-list.spec.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/array-value-list.spec.tsx @@ -7,11 +7,7 @@ describe('ArrayValueList', () => { const user = userEvent.setup() const onChange = vi.fn() const { container } = render( - <ArrayValueList - isString - list={['alpha', 'beta']} - onChange={onChange} - />, + <ArrayValueList isString list={['alpha', 'beta']} onChange={onChange} />, ) fireEvent.change(screen.getByDisplayValue('alpha'), { target: { value: 'updated' } }) @@ -26,13 +22,7 @@ describe('ArrayValueList', () => { it('coerces number inputs and appends an undefined slot', async () => { const user = userEvent.setup() const onChange = vi.fn() - render( - <ArrayValueList - isString={false} - list={[1]} - onChange={onChange} - />, - ) + render(<ArrayValueList isString={false} list={[1]} onChange={onChange} />) fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '7' } }) await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue')) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/object-value-item.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/object-value-item.spec.tsx index e2e7c2d562b88d..b9970fb8b3ea58 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/object-value-item.spec.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/object-value-item.spec.tsx @@ -17,27 +17,30 @@ describe('ObjectValueItem', () => { it('updates key and value and appends a new row when the last value input is focused', () => { const onChange = vi.fn() - const list = [{ - key: 'reason', - type: ChatVarType.String, - value: 'draft', - }] + const list = [ + { + key: 'reason', + type: ChatVarType.String, + value: 'draft', + }, + ] - render( - <ObjectValueItem - index={0} - list={list} - onChange={onChange} - />, - ) + render(<ObjectValueItem index={0} list={list} onChange={onChange} />) fireEvent.change(screen.getByDisplayValue('reason'), { target: { value: 'status' } }) fireEvent.change(screen.getByDisplayValue('draft'), { target: { value: 'published' } }) fireEvent.focus(screen.getByDisplayValue('draft')) - expect(onChange).toHaveBeenNthCalledWith(1, [{ key: 'status', type: ChatVarType.String, value: 'draft' }]) - expect(onChange).toHaveBeenNthCalledWith(2, [{ key: 'reason', type: ChatVarType.String, value: 'published' }]) - expect(onChange).toHaveBeenNthCalledWith(3, [{ key: 'reason', type: ChatVarType.String, value: 'draft' }, DEFAULT_OBJECT_VALUE]) + expect(onChange).toHaveBeenNthCalledWith(1, [ + { key: 'status', type: ChatVarType.String, value: 'draft' }, + ]) + expect(onChange).toHaveBeenNthCalledWith(2, [ + { key: 'reason', type: ChatVarType.String, value: 'published' }, + ]) + expect(onChange).toHaveBeenNthCalledWith(3, [ + { key: 'reason', type: ChatVarType.String, value: 'draft' }, + DEFAULT_OBJECT_VALUE, + ]) }) it('rejects invalid object keys', () => { diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/object-value-list.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/object-value-list.spec.tsx index c3edf44f1a549e..62a9806e57edb0 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/object-value-list.spec.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/object-value-list.spec.tsx @@ -19,6 +19,8 @@ describe('ObjectValueList', () => { fireEvent.change(screen.getByDisplayValue('reason'), { target: { value: 'status' } }) - expect(onChange).toHaveBeenCalledWith([{ key: 'status', type: ChatVarType.String, value: 'draft' }]) + expect(onChange).toHaveBeenCalledWith([ + { key: 'status', type: ChatVarType.String, value: 'draft' }, + ]) }) }) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts index c3a16c58087c39..983009c6daf07f 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts @@ -26,15 +26,19 @@ describe('useVariableModalState', () => { }) it('should build initial state from an existing array object variable', () => { - const { result } = renderHook(() => useVariableModalState(createOptions({ - chatVar: { - id: 'var-1', - name: 'payload', - description: 'desc', - value_type: ChatVarType.ArrayObject, - value: [{ enabled: true }], - }, - }))) + const { result } = renderHook(() => + useVariableModalState( + createOptions({ + chatVar: { + id: 'var-1', + name: 'payload', + description: 'desc', + value_type: ChatVarType.ArrayObject, + value: [{ enabled: true }], + }, + }), + ), + ) expect(result.current.name).toBe('payload') expect(result.current.description).toBe('desc') @@ -67,15 +71,19 @@ describe('useVariableModalState', () => { }) it('should toggle object values between form and json modes', () => { - const { result } = renderHook(() => useVariableModalState(createOptions({ - chatVar: { - id: 'var-2', - name: 'config', - description: '', - value_type: ChatVarType.Object, - value: { timeout: 30 }, - }, - }))) + const { result } = renderHook(() => + useVariableModalState( + createOptions({ + chatVar: { + id: 'var-2', + name: 'config', + description: '', + value_type: ChatVarType.Object, + value: { timeout: 30 }, + }, + }), + ), + ) act(() => { result.current.handleEditorChange(true) @@ -110,15 +118,19 @@ describe('useVariableModalState', () => { expect(result.current.editorContent).toBe(JSON.stringify({ timeout: 30 })) }) it('should reset object form values when leaving empty json mode', () => { - const { result } = renderHook(() => useVariableModalState(createOptions({ - chatVar: { - id: 'var-3', - name: 'config', - description: '', - value_type: ChatVarType.Object, - value: {}, - }, - }))) + const { result } = renderHook(() => + useVariableModalState( + createOptions({ + chatVar: { + id: 'var-3', + name: 'config', + description: '', + value_type: ChatVarType.Object, + value: {}, + }, + }), + ), + ) act(() => { result.current.handleEditorChange(true) @@ -177,14 +189,20 @@ describe('useVariableModalState', () => { const notify = vi.fn() const onSave = vi.fn() const onClose = vi.fn() - const { result } = renderHook(() => useVariableModalState(createOptions({ - notify, - onClose, - onSave, - }))) + const { result } = renderHook(() => + useVariableModalState( + createOptions({ + notify, + onClose, + onSave, + }), + ), + ) act(() => { - result.current.handleVarNameChange({ target: { value: 'config' } } as ChangeEvent<HTMLInputElement>) + result.current.handleVarNameChange({ + target: { value: 'config' }, + } as ChangeEvent<HTMLInputElement>) result.current.handleTypeChange(ChatVarType.Object) result.current.setObjectValue([{ key: '', type: ChatVarType.String, value: 'secret' }]) }) @@ -193,7 +211,10 @@ describe('useVariableModalState', () => { result.current.handleSave() }) - expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'chatVariable.modal.objectKeyRequired' }) + expect(notify).toHaveBeenCalledWith({ + type: 'error', + message: 'chatVariable.modal.objectKeyRequired', + }) expect(onSave).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled() }) @@ -202,14 +223,20 @@ describe('useVariableModalState', () => { const notify = vi.fn() const onSave = vi.fn() const onClose = vi.fn() - const { result } = renderHook(() => useVariableModalState(createOptions({ - notify, - onClose, - onSave, - }))) + const { result } = renderHook(() => + useVariableModalState( + createOptions({ + notify, + onClose, + onSave, + }), + ), + ) act(() => { - result.current.handleVarNameChange({ target: { value: 'greeting' } } as ChangeEvent<HTMLInputElement>) + result.current.handleVarNameChange({ + target: { value: 'greeting' }, + } as ChangeEvent<HTMLInputElement>) result.current.handleStringOrNumberChange(['hello']) result.current.setDescription('a'.repeat(256)) }) @@ -229,13 +256,19 @@ describe('useVariableModalState', () => { it('should save a new variable and close when state is valid', () => { const onSave = vi.fn() const onClose = vi.fn() - const { result } = renderHook(() => useVariableModalState(createOptions({ - onClose, - onSave, - }))) + const { result } = renderHook(() => + useVariableModalState( + createOptions({ + onClose, + onSave, + }), + ), + ) act(() => { - result.current.handleVarNameChange({ target: { value: 'greeting' } } as ChangeEvent<HTMLInputElement>) + result.current.handleVarNameChange({ + target: { value: 'greeting' }, + } as ChangeEvent<HTMLInputElement>) result.current.handleStringOrNumberChange(['hello']) }) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-item.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-item.spec.tsx index 4c7c1f76f1a4e1..a39610569c5f35 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-item.spec.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-item.spec.tsx @@ -19,11 +19,7 @@ describe('VariableItem', () => { const onEdit = vi.fn() const onDelete = vi.fn() const { container } = render( - <VariableItem - item={createVariable()} - onEdit={onEdit} - onDelete={onDelete} - />, + <VariableItem item={createVariable()} onEdit={onEdit} onDelete={onDelete} />, ) const card = container.firstElementChild as HTMLDivElement diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal-trigger.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal-trigger.spec.tsx index 6705b3b338a1b9..56564e9fdd5437 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal-trigger.spec.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal-trigger.spec.tsx @@ -35,7 +35,9 @@ describe('VariableModalTrigger', () => { />, ) - expect(screen.queryByPlaceholderText('workflow.chatVariable.modal.namePlaceholder')).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), + ).not.toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'workflow.chatVariable.button' })) @@ -61,16 +63,24 @@ describe('VariableModalTrigger', () => { ) await user.clear(screen.getByDisplayValue('conversation_var')) - await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'updated_var') - await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), 'hello') + await user.type( + screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), + 'updated_var', + ) + await user.type( + screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), + 'hello', + ) await user.click(screen.getByText('common.operation.save')) - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - id: 'var-1', - name: 'updated_var', - value: 'hello', - value_type: ChatVarType.String, - })) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'var-1', + name: 'updated_var', + value: 'hello', + value_type: ChatVarType.String, + }), + ) expect(onClose).toHaveBeenCalled() expect(setOpen).toHaveBeenCalledWith(false) }) @@ -96,12 +106,16 @@ describe('VariableModalTrigger', () => { renderWorkflowComponent(<TriggerHarness />) - expect(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder')).toBeInTheDocument() + expect( + screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), + ).toBeInTheDocument() await user.keyboard('{Escape}') await waitFor(() => { - expect(screen.queryByPlaceholderText('workflow.chatVariable.modal.namePlaceholder')).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), + ).not.toBeInTheDocument() }) expect(onClose).toHaveBeenCalled() }) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts index 6b2963c7fa160c..a5e959dbae59d7 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts @@ -16,76 +16,96 @@ describe('variable-modal helpers', () => { it('should build object items from a conversation variable value', () => { expect(buildObjectValueItems()).toHaveLength(1) - expect(buildObjectValueItems({ - id: 'var-1', - name: 'config', - description: '', - value_type: ChatVarType.Object, - value: { apiKey: 'secret', timeout: 30 }, - })).toEqual([ + expect( + buildObjectValueItems({ + id: 'var-1', + name: 'config', + description: '', + value_type: ChatVarType.Object, + value: { apiKey: 'secret', timeout: 30 }, + }), + ).toEqual([ { key: 'apiKey', type: ChatVarType.String, value: 'secret' }, { key: 'timeout', type: ChatVarType.Number, value: 30 }, ]) }) it('should format object and array values for saving', () => { - expect(formatObjectValueFromList([ - { key: 'apiKey', type: ChatVarType.String, value: 'secret' }, - { key: '', type: ChatVarType.Number, value: 1 }, - ])).toEqual({ apiKey: 'secret' }) - - expect(formatObjectValueFromList([ - { key: 'count', type: ChatVarType.Number, value: 0 }, - { key: 'label', type: ChatVarType.String, value: '' }, - ])).toEqual({ count: 0, label: null }) - expect(formatChatVariableValue({ - editInJSON: false, - objectValue: [{ key: 'enabled', type: ChatVarType.String, value: 'true' }], - type: ChatVarType.Object, - value: undefined, - })).toEqual({ enabled: 'true' }) - - expect(formatChatVariableValue({ - editInJSON: true, - objectValue: [], - type: ChatVarType.Object, - value: { count: 1 }, - })).toEqual({ count: 1 }) - - expect(formatChatVariableValue({ - editInJSON: false, - objectValue: [], - type: ChatVarType.ArrayString, - value: ['a', '', 'b'], - })).toEqual(['a', 'b']) - - expect(formatChatVariableValue({ - editInJSON: false, - objectValue: [], - type: ChatVarType.ArrayNumber, - value: [0, 1, undefined, null, ''] as unknown as Array<number | undefined>, - })).toEqual([0, 1]) - - expect(formatChatVariableValue({ - editInJSON: false, - objectValue: [], - type: ChatVarType.Number, - value: undefined, - })).toBe(0) - - expect(formatChatVariableValue({ - editInJSON: false, - objectValue: [], - type: ChatVarType.Boolean, - value: undefined, - })).toBe(true) - - expect(formatChatVariableValue({ - editInJSON: false, - objectValue: [], - type: ChatVarType.ArrayBoolean, - value: undefined, - })).toEqual([]) + expect( + formatObjectValueFromList([ + { key: 'apiKey', type: ChatVarType.String, value: 'secret' }, + { key: '', type: ChatVarType.Number, value: 1 }, + ]), + ).toEqual({ apiKey: 'secret' }) + + expect( + formatObjectValueFromList([ + { key: 'count', type: ChatVarType.Number, value: 0 }, + { key: 'label', type: ChatVarType.String, value: '' }, + ]), + ).toEqual({ count: 0, label: null }) + expect( + formatChatVariableValue({ + editInJSON: false, + objectValue: [{ key: 'enabled', type: ChatVarType.String, value: 'true' }], + type: ChatVarType.Object, + value: undefined, + }), + ).toEqual({ enabled: 'true' }) + + expect( + formatChatVariableValue({ + editInJSON: true, + objectValue: [], + type: ChatVarType.Object, + value: { count: 1 }, + }), + ).toEqual({ count: 1 }) + + expect( + formatChatVariableValue({ + editInJSON: false, + objectValue: [], + type: ChatVarType.ArrayString, + value: ['a', '', 'b'], + }), + ).toEqual(['a', 'b']) + + expect( + formatChatVariableValue({ + editInJSON: false, + objectValue: [], + type: ChatVarType.ArrayNumber, + value: [0, 1, undefined, null, ''] as unknown as Array<number | undefined>, + }), + ).toEqual([0, 1]) + + expect( + formatChatVariableValue({ + editInJSON: false, + objectValue: [], + type: ChatVarType.Number, + value: undefined, + }), + ).toBe(0) + + expect( + formatChatVariableValue({ + editInJSON: false, + objectValue: [], + type: ChatVarType.Boolean, + value: undefined, + }), + ).toBe(true) + + expect( + formatChatVariableValue({ + editInJSON: false, + objectValue: [], + type: ChatVarType.ArrayBoolean, + value: undefined, + }), + ).toEqual([]) }) it('should derive placeholders, editor defaults, and editor toggle labels', () => { @@ -97,43 +117,57 @@ describe('variable-modal helpers', () => { expect(getTypeChangeState(ChatVarType.Object).objectValue).toHaveLength(1) expect(getTypeChangeState(ChatVarType.ArrayObject).editInJSON).toBe(true) expect(getEditorToggleLabelKey(ChatVarType.Object, true)).toBe('chatVariable.modal.editInForm') - expect(getEditorToggleLabelKey(ChatVarType.ArrayString, false)).toBe('chatVariable.modal.editInJSON') + expect(getEditorToggleLabelKey(ChatVarType.ArrayString, false)).toBe( + 'chatVariable.modal.editInJSON', + ) }) it('should parse boolean arrays from JSON editor content', () => { - expect(parseEditorContent({ - content: '["True","false",true,false,"invalid"]', - type: ChatVarType.ArrayBoolean, - })).toEqual([true, false, true, false]) - - expect(() => parseEditorContent({ - content: '{"enabled":true}', - type: ChatVarType.ArrayBoolean, - })).toThrow('JSON array') - expect(parseEditorContent({ - content: '{"enabled":true}', - type: ChatVarType.Object, - })).toEqual({ enabled: true }) + expect( + parseEditorContent({ + content: '["True","false",true,false,"invalid"]', + type: ChatVarType.ArrayBoolean, + }), + ).toEqual([true, false, true, false]) + + expect(() => + parseEditorContent({ + content: '{"enabled":true}', + type: ChatVarType.ArrayBoolean, + }), + ).toThrow('JSON array') + expect( + parseEditorContent({ + content: '{"enabled":true}', + type: ChatVarType.Object, + }), + ).toEqual({ enabled: true }) }) it('should validate variable names and notify when invalid', () => { const notify = vi.fn() const t = withSelectorKey((key: string) => key) - expect(validateVariableName({ - name: 'valid_name', - notify, - t, - })).toBe(true) - - expect(validateVariableName({ - name: '1invalid', - notify, - t, - })).toBe(false) - - expect(notify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - })) + expect( + validateVariableName({ + name: 'valid_name', + notify, + t, + }), + ).toBe(true) + + expect( + validateVariableName({ + name: '1invalid', + notify, + t, + }), + ).toBe(false) + + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) }) }) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx index cf9400636e1ac8..e54a912dff806c 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx @@ -24,14 +24,11 @@ const renderVariableModal = (props?: Partial<React.ComponentProps<typeof Variabl const onSave = vi.fn() const result = renderWorkflowComponent( - React.createElement( - VariableModal, - { - onClose, - onSave, - ...props, - }, - ), + React.createElement(VariableModal, { + onClose, + onSave, + ...props, + }), ) return { ...result, onClose, onSave } @@ -48,9 +45,18 @@ describe('variable-modal', () => { const user = userEvent.setup() const { onClose, onSave } = renderVariableModal() - await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'greeting') - await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), 'hello') - await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.descriptionPlaceholder'), 'demo variable') + await user.type( + screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), + 'greeting', + ) + await user.type( + screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), + 'hello', + ) + await user.type( + screen.getByPlaceholderText('workflow.chatVariable.modal.descriptionPlaceholder'), + 'demo variable', + ) await user.click(screen.getByText('common.operation.save')) expect(onSave).toHaveBeenCalledWith({ @@ -68,19 +74,26 @@ describe('variable-modal', () => { const { onSave, store } = renderVariableModal() store.setState({ - conversationVariables: [{ - id: 'var-1', - name: 'existing_name', - description: '', - value_type: ChatVarType.String, - value: '', - }], + conversationVariables: [ + { + id: 'var-1', + name: 'existing_name', + description: '', + value_type: ChatVarType.String, + value: '', + }, + ], }) - await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'existing_name') + await user.type( + screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), + 'existing_name', + ) await user.click(screen.getByText('common.operation.save')) - expect(mockToastError.mock.calls.at(-1)?.[0]).toBe('appDebug.varKeyError.keyAlreadyExists:{"key":"workflow.chatVariable.modal.name"}') + expect(mockToastError.mock.calls.at(-1)?.[0]).toBe( + 'appDebug.varKeyError.keyAlreadyExists:{"key":"workflow.chatVariable.modal.name"}', + ) expect(onSave).not.toHaveBeenCalled() }) @@ -122,7 +135,10 @@ describe('variable-modal', () => { const user = userEvent.setup() const { onSave } = renderVariableModal() - await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'flags') + await user.type( + screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), + 'flags', + ) await user.click(screen.getByText('string')) await user.click(screen.getByText('array[boolean]')) await user.click(screen.getByText('common.operation.save')) @@ -169,7 +185,9 @@ describe('variable-modal', () => { it('should stop invalid variable names before they are stored in local state', async () => { const { onSave } = renderVariableModal() - const input = screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder') as HTMLInputElement + const input = screen.getByPlaceholderText( + 'workflow.chatVariable.modal.namePlaceholder', + ) as HTMLInputElement fireEvent.change(input, { target: { value: '1bad' } }) await userEvent.click(screen.getByText('common.operation.save')) @@ -183,10 +201,16 @@ describe('variable-modal', () => { const user = userEvent.setup() const { onSave } = renderVariableModal() - await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'timeout') + await user.type( + screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), + 'timeout', + ) await user.click(screen.getByText('string')) await user.click(screen.getByText('number')) - await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), '3') + await user.type( + screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), + '3', + ) await user.click(screen.getByText('common.operation.save')) expect(onSave).toHaveBeenCalledWith({ diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-type-select.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-type-select.spec.tsx index d0831c319cb59c..6e3ee971158055 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-type-select.spec.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-type-select.spec.tsx @@ -22,13 +22,7 @@ describe('VariableTypeSelector', () => { it('dismisses the popup through the real portal flow', async () => { const user = userEvent.setup() - render( - <VariableTypeSelector - value="string" - list={['string', 'number']} - onSelect={vi.fn()} - />, - ) + render(<VariableTypeSelector value="string" list={['string', 'number']} onSelect={vi.fn()} />) await user.click(screen.getByText('string')) expect(screen.getByText('number')).toBeInTheDocument() diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/array-bool-list.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/array-bool-list.tsx index f60706b5b991b2..3de573d0c8a242 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/array-bool-list.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/array-bool-list.tsx @@ -16,30 +16,32 @@ type Props = Readonly<{ onChange: (list: boolean[]) => void }> -const ArrayValueList: FC<Props> = ({ - className, - list, - onChange, -}) => { +const ArrayValueList: FC<Props> = ({ className, list, onChange }) => { const { t } = useTranslation() - const handleChange = useCallback((index: number) => { - return (value: boolean) => { - const newList = produce(list, (draft: any[]) => { - draft[index] = value - }) - onChange(newList) - } - }, [list, onChange]) + const handleChange = useCallback( + (index: number) => { + return (value: boolean) => { + const newList = produce(list, (draft: any[]) => { + draft[index] = value + }) + onChange(newList) + } + }, + [list, onChange], + ) - const handleItemRemove = useCallback((index: number) => { - return () => { - const newList = produce(list, (draft) => { - draft.splice(index, 1) - }) - onChange(newList) - } - }, [list, onChange]) + const handleItemRemove = useCallback( + (index: number) => { + return () => { + const newList = produce(list, (draft) => { + draft.splice(index, 1) + }) + onChange(newList) + } + }, + [list, onChange], + ) const handleItemAdd = useCallback(() => { const newList = produce(list, (draft: any[]) => { @@ -52,10 +54,7 @@ const ArrayValueList: FC<Props> = ({ <div className={cn('w-full space-y-2', className)}> {list.map((item, index) => ( <div className="flex items-center space-x-1" key={index}> - <BoolValue - value={item} - onChange={handleChange(index)} - /> + <BoolValue value={item} onChange={handleChange(index)} /> <RemoveButton className="bg-gray-100! p-2! hover:bg-gray-200!" @@ -65,7 +64,7 @@ const ArrayValueList: FC<Props> = ({ ))} <Button variant="tertiary" className="w-full" onClick={handleItemAdd}> <RiAddLine className="mr-1 size-4" /> - <span>{t($ => $['chatVariable.modal.addArrayValue'], { ns: 'workflow' })}</span> + <span>{t(($) => $['chatVariable.modal.addArrayValue'], { ns: 'workflow' })}</span> </Button> </div> ) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/array-value-list.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/array-value-list.tsx index 13866020f71a75..2426d57a83188f 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/array-value-list.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/array-value-list.tsx @@ -15,30 +15,32 @@ type Props = Readonly<{ onChange: (list: any[]) => void }> -const ArrayValueList: FC<Props> = ({ - isString = true, - list, - onChange, -}) => { +const ArrayValueList: FC<Props> = ({ isString = true, list, onChange }) => { const { t } = useTranslation() - const handleNameChange = useCallback((index: number) => { - return (e: React.ChangeEvent<HTMLInputElement>) => { - const newList = produce(list, (draft: any[]) => { - draft[index] = isString ? e.target.value : Number(e.target.value) - }) - onChange(newList) - } - }, [isString, list, onChange]) + const handleNameChange = useCallback( + (index: number) => { + return (e: React.ChangeEvent<HTMLInputElement>) => { + const newList = produce(list, (draft: any[]) => { + draft[index] = isString ? e.target.value : Number(e.target.value) + }) + onChange(newList) + } + }, + [isString, list, onChange], + ) - const handleItemRemove = useCallback((index: number) => { - return () => { - const newList = produce(list, (draft) => { - draft.splice(index, 1) - }) - onChange(newList) - } - }, [list, onChange]) + const handleItemRemove = useCallback( + (index: number) => { + return () => { + const newList = produce(list, (draft) => { + draft.splice(index, 1) + }) + onChange(newList) + } + }, + [list, onChange], + ) const handleItemAdd = useCallback(() => { const newList = produce(list, (draft: any[]) => { @@ -52,7 +54,7 @@ const ArrayValueList: FC<Props> = ({ {list.map((item, index) => ( <div className="flex items-center space-x-1" key={index}> <Input - placeholder={t($ => $['chatVariable.modal.arrayValue'], { ns: 'workflow' }) || ''} + placeholder={t(($) => $['chatVariable.modal.arrayValue'], { ns: 'workflow' }) || ''} value={list[index]} onChange={handleNameChange(index)} type={isString ? 'text' : 'number'} @@ -65,7 +67,7 @@ const ArrayValueList: FC<Props> = ({ ))} <Button variant="tertiary" className="w-full" onClick={handleItemAdd}> <RiAddLine className="mr-1 size-4" /> - <span>{t($ => $['chatVariable.modal.addArrayValue'], { ns: 'workflow' })}</span> + <span>{t(($) => $['chatVariable.modal.addArrayValue'], { ns: 'workflow' })}</span> </Button> </div> ) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/bool-value.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/bool-value.tsx index e75d48a8262d41..3eb7f5756f9f64 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/bool-value.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/bool-value.tsx @@ -9,16 +9,16 @@ type Props = Readonly<{ onChange: (value: boolean) => void }> -const BoolValue: FC<Props> = ({ - value, - onChange, -}) => { +const BoolValue: FC<Props> = ({ value, onChange }) => { const booleanValue = value - const handleChange = useCallback((newValue: boolean) => { - return () => { - onChange(newValue) - } - }, [onChange]) + const handleChange = useCallback( + (newValue: boolean) => { + return () => { + onChange(newValue) + } + }, + [onChange], + ) return ( <div className="flex w-full space-x-1"> diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/object-value-item.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/object-value-item.tsx index 7e323ea29f559a..2595397bd0f3bc 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/object-value-item.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/object-value-item.tsx @@ -15,10 +15,7 @@ type Props = Readonly<{ onChange: (list: any[]) => void }> -const typeList = [ - ChatVarType.String, - ChatVarType.Number, -] +const typeList = [ChatVarType.String, ChatVarType.Number] export const DEFAULT_OBJECT_VALUE = { key: '', @@ -26,58 +23,72 @@ export const DEFAULT_OBJECT_VALUE = { value: undefined, } -const ObjectValueItem: FC<Props> = ({ - index, - list, - onChange, -}) => { +const ObjectValueItem: FC<Props> = ({ index, list, onChange }) => { const { t } = useTranslation() const [isFocus, setIsFocus] = useState(false) - const handleKeyChange = useCallback((index: number) => { - return (e: React.ChangeEvent<HTMLInputElement>) => { - if (!/^\w+$/.test(e.target.value)) { - toast.error(t($ => $['chatVariable.modal.objectKeyPatternError'], { ns: 'workflow' })) - return - } + const handleKeyChange = useCallback( + (index: number) => { + return (e: React.ChangeEvent<HTMLInputElement>) => { + if (!/^\w+$/.test(e.target.value)) { + toast.error(t(($) => $['chatVariable.modal.objectKeyPatternError'], { ns: 'workflow' })) + return + } - const newList = produce(list, (draft: any[]) => { - draft[index].key = e.target.value - }) - onChange(newList) - } - }, [list, onChange, t]) + const newList = produce(list, (draft: any[]) => { + draft[index].key = e.target.value + }) + onChange(newList) + } + }, + [list, onChange, t], + ) - const handleTypeChange = useCallback((index: number) => { - return (type: ChatVarType) => { - const newList = produce(list, (draft) => { - draft[index].type = type - if (type === ChatVarType.Number) - draft[index].value = Number.isNaN(Number(draft[index].value)) ? undefined : Number(draft[index].value) - else - draft[index].value = draft[index].value ? String(draft[index].value) : undefined - }) - onChange(newList) - } - }, [list, onChange]) + const handleTypeChange = useCallback( + (index: number) => { + return (type: ChatVarType) => { + const newList = produce(list, (draft) => { + draft[index].type = type + if (type === ChatVarType.Number) + draft[index].value = Number.isNaN(Number(draft[index].value)) + ? undefined + : Number(draft[index].value) + else draft[index].value = draft[index].value ? String(draft[index].value) : undefined + }) + onChange(newList) + } + }, + [list, onChange], + ) - const handleValueChange = useCallback((index: number) => { - return (e: React.ChangeEvent<HTMLInputElement>) => { - const newList = produce(list, (draft: any[]) => { - draft[index].value = draft[index].type === ChatVarType.String ? e.target.value : Number.isNaN(Number(e.target.value)) ? undefined : Number(e.target.value) - }) - onChange(newList) - } - }, [list, onChange]) + const handleValueChange = useCallback( + (index: number) => { + return (e: React.ChangeEvent<HTMLInputElement>) => { + const newList = produce(list, (draft: any[]) => { + draft[index].value = + draft[index].type === ChatVarType.String + ? e.target.value + : Number.isNaN(Number(e.target.value)) + ? undefined + : Number(e.target.value) + }) + onChange(newList) + } + }, + [list, onChange], + ) - const handleItemRemove = useCallback((index: number) => { - return () => { - const newList = produce(list, (draft) => { - draft.splice(index, 1) - }) - onChange(newList) - } - }, [list, onChange]) + const handleItemRemove = useCallback( + (index: number) => { + return () => { + const newList = produce(list, (draft) => { + draft.splice(index, 1) + }) + onChange(newList) + } + }, + [list, onChange], + ) const handleItemAdd = useCallback(() => { const newList = produce(list, (draft: any[]) => { @@ -88,8 +99,7 @@ const ObjectValueItem: FC<Props> = ({ const handleFocusChange = useCallback(() => { setIsFocus(true) - if (index === list.length - 1) - handleItemAdd() + if (index === list.length - 1) handleItemAdd() }, [handleItemAdd, index, list.length]) return ( @@ -98,7 +108,7 @@ const ObjectValueItem: FC<Props> = ({ <div className="w-[120px] border-r border-gray-200"> <input className="block h-7 w-full appearance-none px-2 system-xs-regular text-text-secondary caret-primary-600 outline-hidden placeholder:system-xs-regular placeholder:text-components-input-text-placeholder hover:bg-state-base-hover focus:bg-components-input-bg-active" - placeholder={t($ => $['chatVariable.modal.objectKey'], { ns: 'workflow' }) || ''} + placeholder={t(($) => $['chatVariable.modal.objectKey'], { ns: 'workflow' }) || ''} value={list[index].key} onChange={handleKeyChange(index)} /> @@ -117,7 +127,7 @@ const ObjectValueItem: FC<Props> = ({ <div className="relative w-[230px]"> <input className="block h-7 w-full appearance-none px-2 pr-9 system-xs-regular text-text-secondary caret-primary-600 outline-hidden placeholder:system-xs-regular placeholder:text-components-input-text-placeholder hover:bg-state-base-hover focus:bg-components-input-bg-active" - placeholder={t($ => $['chatVariable.modal.objectValue'], { ns: 'workflow' }) || ''} + placeholder={t(($) => $['chatVariable.modal.objectValue'], { ns: 'workflow' }) || ''} value={list[index].value} onChange={handleValueChange(index)} onFocus={() => handleFocusChange()} diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/object-value-list.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/object-value-list.tsx index 01377dce65f5af..64659da2882112 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/object-value-list.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/object-value-list.tsx @@ -9,26 +9,24 @@ type Props = Readonly<{ onChange: (list: any[]) => void }> -const ObjectValueList: FC<Props> = ({ - list, - onChange, -}) => { +const ObjectValueList: FC<Props> = ({ list, onChange }) => { const { t } = useTranslation() return ( <div className="w-full overflow-hidden rounded-lg border border-gray-200"> <div className="flex h-7 items-center system-xs-medium-uppercase text-text-tertiary"> - <div className="flex h-full w-[120px] items-center border-r border-gray-200 pl-2">{t($ => $['chatVariable.modal.objectKey'], { ns: 'workflow' })}</div> - <div className="flex h-full w-[96px] items-center border-r border-gray-200 pl-2">{t($ => $['chatVariable.modal.objectType'], { ns: 'workflow' })}</div> - <div className="flex h-full w-[230px] items-center pr-1 pl-2">{t($ => $['chatVariable.modal.objectValue'], { ns: 'workflow' })}</div> + <div className="flex h-full w-[120px] items-center border-r border-gray-200 pl-2"> + {t(($) => $['chatVariable.modal.objectKey'], { ns: 'workflow' })} + </div> + <div className="flex h-full w-[96px] items-center border-r border-gray-200 pl-2"> + {t(($) => $['chatVariable.modal.objectType'], { ns: 'workflow' })} + </div> + <div className="flex h-full w-[230px] items-center pr-1 pl-2"> + {t(($) => $['chatVariable.modal.objectValue'], { ns: 'workflow' })} + </div> </div> {list.map((item, index) => ( - <ObjectValueItem - key={index} - index={index} - list={list} - onChange={onChange} - /> + <ObjectValueItem key={index} index={index} list={list} onChange={onChange} /> ))} </div> ) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts b/web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts index 8689f0199d68e3..3ae4c25452098e 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts +++ b/web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts @@ -1,4 +1,8 @@ -import type { ChatVariableTranslator, ObjectValueItem, ToastPayload } from './variable-modal.helpers' +import type { + ChatVariableTranslator, + ObjectValueItem, + ToastPayload, +} from './variable-modal.helpers' import type { ConversationVariable } from '@/app/components/workflow/types' import { useMemo, useState } from 'react' import { v4 as uuid4 } from 'uuid' @@ -36,7 +40,7 @@ type VariableModalState = { } const buildObjectValueListFromRecord = (record: Record<string, string | number>) => { - return Object.keys(record).map(key => ({ + return Object.keys(record).map((key) => ({ key, type: typeof record[key] === 'string' ? ChatVarType.String : ChatVarType.Number, value: record[key], @@ -59,7 +63,8 @@ const buildInitialState = (chatVar?: ConversationVariable): VariableModalState = return { description: chatVar.description, editInJSON: chatVar.value_type === ChatVarType.ArrayObject, - editorContent: chatVar.value_type === ChatVarType.ArrayObject ? JSON.stringify(chatVar.value) : undefined, + editorContent: + chatVar.value_type === ChatVarType.ArrayObject ? JSON.stringify(chatVar.value) : undefined, name: chatVar.name, objectValue: buildObjectValueItems(chatVar), type: chatVar.value_type, @@ -81,12 +86,12 @@ export const useVariableModalState = ({ const placeholder = useMemo(() => getPlaceholderByType(state.type), [state.type]) const handleVarNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { - setState(prev => ({ ...prev, name: e.target.value || '' })) + setState((prev) => ({ ...prev, name: e.target.value || '' })) } const handleTypeChange = (nextType: ChatVarType) => { const nextState = getTypeChangeState(nextType) - setState(prev => ({ + setState((prev) => ({ ...prev, editInJSON: nextState.editInJSON, editorContent: nextState.editorContent, @@ -97,7 +102,7 @@ export const useVariableModalState = ({ } const handleStringOrNumberChange = (nextValue: Array<string | number | undefined>) => { - setState(prev => ({ ...prev, value: nextValue[0] })) + setState((prev) => ({ ...prev, value: nextValue[0] })) } const handleEditorChange = (nextEditInJSON: boolean) => { @@ -109,7 +114,9 @@ export const useVariableModalState = ({ if (prev.type === ChatVarType.Object) { if (nextEditInJSON) { - const nextValue = prev.objectValue.some(item => item.key) ? formatObjectValueFromList(prev.objectValue) : undefined + const nextValue = prev.objectValue.some((item) => item.key) + ? formatObjectValueFromList(prev.objectValue) + : undefined nextState.value = nextValue nextState.editorContent = JSON.stringify(nextValue) return nextState @@ -125,8 +132,7 @@ export const useVariableModalState = ({ const nextValue = JSON.parse(prev.editorContent) as Record<string, string | number> nextState.value = nextValue nextState.objectValue = buildObjectValueListFromRecord(nextValue) - } - catch { + } catch { // ignore JSON.parse errors } return nextState @@ -135,14 +141,11 @@ export const useVariableModalState = ({ if (prev.type === ChatVarType.ArrayString || prev.type === ChatVarType.ArrayNumber) { if (nextEditInJSON) { const compactValues = Array.isArray(prev.value) - ? prev.value.filter(item => item !== null && item !== undefined && item !== '') + ? prev.value.filter((item) => item !== null && item !== undefined && item !== '') : [] - const nextValue = compactValues.length - ? compactValues - : undefined + const nextValue = compactValues.length ? compactValues : undefined nextState.value = nextValue - if (!prev.editorContent) - nextState.editorContent = JSON.stringify(nextValue) + if (!prev.editorContent) nextState.editorContent = JSON.stringify(nextValue) return nextState } @@ -151,7 +154,9 @@ export const useVariableModalState = ({ } if (prev.type === ChatVarType.ArrayBoolean && Array.isArray(prev.value) && nextEditInJSON) - nextState.editorContent = JSON.stringify(prev.value.map(item => item ? 'True' : 'False')) + nextState.editorContent = JSON.stringify( + prev.value.map((item) => (item ? 'True' : 'False')), + ) return nextState }) @@ -171,8 +176,7 @@ export const useVariableModalState = ({ try { nextState.value = parseEditorContent({ content, type: prev.type }) - } - catch { + } catch { // ignore JSON.parse errors } @@ -181,29 +185,34 @@ export const useVariableModalState = ({ } const handleSave = () => { - if (!validateVariableName({ name: state.name, notify, t })) - return + if (!validateVariableName({ name: state.name, notify, t })) return - if (!chatVar && conversationVariables.some(item => item.name === state.name)) { + if (!chatVar && conversationVariables.some((item) => item.name === state.name)) { notify({ type: 'error', - message: t($ => $['varKeyError.keyAlreadyExists'], { + message: t(($) => $['varKeyError.keyAlreadyExists'], { ns: 'appDebug', - key: t($ => $['chatVariable.modal.name'], { ns: 'workflow' }), + key: t(($) => $['chatVariable.modal.name'], { ns: 'workflow' }), }), }) return } - if (state.type === ChatVarType.Object && state.objectValue.some(item => !item.key && item.value !== undefined && item.value !== '')) { - notify({ type: 'error', message: t($ => $['chatVariable.modal.objectKeyRequired'], { ns: 'workflow' }) }) + if ( + state.type === ChatVarType.Object && + state.objectValue.some((item) => !item.key && item.value !== undefined && item.value !== '') + ) { + notify({ + type: 'error', + message: t(($) => $['chatVariable.modal.objectKeyRequired'], { ns: 'workflow' }), + }) return } if (state.description.length > MAX_DESCRIPTION_LENGTH) { notify({ type: 'error', - message: t($ => $['chatVariable.modal.descriptionTooLong'], { + message: t(($) => $['chatVariable.modal.descriptionTooLong'], { maxLength: MAX_DESCRIPTION_LENGTH, ns: 'workflow', }), @@ -240,9 +249,10 @@ export const useVariableModalState = ({ name: state.name, objectValue: state.objectValue, placeholder, - setDescription: (description: string) => setState(prev => ({ ...prev, description })), - setObjectValue: (objectValue: ObjectValueItem[]) => setState(prev => ({ ...prev, objectValue })), - setValue: (value: unknown) => setState(prev => ({ ...prev, value })), + setDescription: (description: string) => setState((prev) => ({ ...prev, description })), + setObjectValue: (objectValue: ObjectValueItem[]) => + setState((prev) => ({ ...prev, objectValue })), + setValue: (value: unknown) => setState((prev) => ({ ...prev, value })), type: state.type, value: state.value, } diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-item.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-item.tsx index 29443c881e1869..99c511d2699614 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-item.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-item.tsx @@ -11,17 +11,14 @@ type VariableItemProps = { onDelete: (item: ConversationVariable) => void } -const VariableItem = ({ - item, - onEdit, - onDelete, -}: VariableItemProps) => { +const VariableItem = ({ item, onEdit, onDelete }: VariableItemProps) => { const [destructive, setDestructive] = useState(false) return ( - <div className={cn( - 'mb-1 rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2.5 py-2 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', - destructive && 'border-state-destructive-border hover:bg-state-destructive-hover', - )} + <div + className={cn( + 'mb-1 rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2.5 py-2 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', + destructive && 'border-state-destructive-border hover:bg-state-destructive-hover', + )} > <div className="flex items-center justify-between"> <div className="flex grow items-center gap-1"> diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger.tsx index d40bcfb69acf6d..30a3fc618b592b 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger.tsx @@ -16,33 +16,27 @@ type Props = Readonly<{ onSave: (env: ConversationVariable) => void }> -const VariableModalTrigger = ({ - open, - setOpen, - showTip, - chatVar, - onClose, - onSave, -}: Props) => { +const VariableModalTrigger = ({ open, setOpen, showTip, chatVar, onClose, onSave }: Props) => { const { t } = useTranslation() - const handleOpenChange = React.useCallback((nextOpen: boolean) => { - setOpen(nextOpen) - if (!nextOpen) - onClose() - }, [onClose, setOpen]) + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) onClose() + }, + [onClose, setOpen], + ) return ( - <Popover - open={open} - onOpenChange={handleOpenChange} - > + <Popover open={open} onOpenChange={handleOpenChange}> <PopoverTrigger - render={( + render={ <Button variant="primary"> <RiAddLine className="mr-1 size-4" /> - <span className="system-sm-medium">{t($ => $['chatVariable.button'], { ns: 'workflow' })}</span> + <span className="system-sm-medium"> + {t(($) => $['chatVariable.button'], { ns: 'workflow' })} + </span> </Button> - )} + } /> <PopoverContent placement="left-start" diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts index 2ea3006382c9ad..81f7b3764fd233 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts @@ -40,11 +40,11 @@ export type ChatVariableTranslator = <Namespace extends 'workflow' | 'appDebug'> type VariableNameErrorKey = Exclude<ReturnType<typeof checkKeys>['errorMessageKey'], ''> const variableNameErrorSelectors: Record<VariableNameErrorKey, SelectorParam<'appDebug'>> = { - canNoBeEmpty: $ => $['varKeyError.canNoBeEmpty'], - keyAlreadyExists: $ => $['varKeyError.keyAlreadyExists'], - notStartWithNumber: $ => $['varKeyError.notStartWithNumber'], - notValid: $ => $['varKeyError.notValid'], - tooLong: $ => $['varKeyError.tooLong'], + canNoBeEmpty: ($) => $['varKeyError.canNoBeEmpty'], + keyAlreadyExists: ($) => $['varKeyError.keyAlreadyExists'], + notStartWithNumber: ($) => $['varKeyError.notStartWithNumber'], + notValid: ($) => $['varKeyError.notValid'], + tooLong: ($) => $['varKeyError.tooLong'], } export const typeList = [ @@ -62,14 +62,10 @@ export const getEditorMinHeight = (type: ChatVarType) => type === ChatVarTypeEnum.ArrayObject ? '240px' : '120px' export const getPlaceholderByType = (type: ChatVarType) => { - if (type === ChatVarTypeEnum.ArrayString) - return arrayStringPlaceholder - if (type === ChatVarTypeEnum.ArrayNumber) - return arrayNumberPlaceholder - if (type === ChatVarTypeEnum.ArrayObject) - return arrayObjectPlaceholder - if (type === ChatVarTypeEnum.ArrayBoolean) - return arrayBoolPlaceholder + if (type === ChatVarTypeEnum.ArrayString) return arrayStringPlaceholder + if (type === ChatVarTypeEnum.ArrayNumber) return arrayNumberPlaceholder + if (type === ChatVarTypeEnum.ArrayObject) return arrayObjectPlaceholder + if (type === ChatVarTypeEnum.ArrayBoolean) return arrayBoolPlaceholder return objectPlaceholder } @@ -89,8 +85,7 @@ export const buildObjectValueItems = (chatVar?: ConversationVariable): ObjectVal export const formatObjectValueFromList = (list: ObjectValueItem[]) => { return list.reduce<Record<string, string | number | null>>((acc, curr) => { - if (curr.key) - acc[curr.key] = curr.value === '' || curr.value === undefined ? null : curr.value + if (curr.key) acc[curr.key] = curr.value === '' || curr.value === undefined ? null : curr.value return acc }, {}) } @@ -107,7 +102,7 @@ export const formatChatVariableValue = ({ value: unknown }) => { const compactArrayValue = (items: unknown[]) => - items.filter(item => item !== null && item !== undefined && item !== '') + items.filter((item) => item !== null && item !== undefined && item !== '') switch (type) { case ChatVarTypeEnum.String: return value || '' @@ -141,7 +136,7 @@ export const validateVariableName = ({ type: 'error', message: t(variableNameErrorSelectors[errorMessageKey], { ns: 'appDebug', - key: t($ => $['env.modal.name'], { ns: 'workflow' }), + key: t(($) => $['env.modal.name'], { ns: 'workflow' }), }), }) return false @@ -163,36 +158,30 @@ export const getTypeChangeState = (nextType: ChatVarType) => { } } -export const parseEditorContent = ({ - content, - type, -}: { - content: string - type: ChatVarType -}) => { +export const parseEditorContent = ({ content, type }: { content: string; type: ChatVarType }) => { const parsed = JSON.parse(content) - if (type !== ChatVarTypeEnum.ArrayBoolean) - return parsed + if (type !== ChatVarTypeEnum.ArrayBoolean) return parsed if (!Array.isArray(parsed)) throw new TypeError('ArrayBoolean editor content must be a JSON array') return parsed .map((item: string | boolean) => { - if (item === 'True' || item === 'true' || item === true) - return true - if (item === 'False' || item === 'false' || item === false) - return false + if (item === 'True' || item === 'true' || item === true) return true + if (item === 'False' || item === 'false' || item === false) return false return undefined }) .filter((item?: boolean) => item !== undefined) } -export type EditorToggleLabelKey - = | 'chatVariable.modal.editInForm' - | 'chatVariable.modal.editInJSON' - | 'chatVariable.modal.oneByOne' +export type EditorToggleLabelKey = + | 'chatVariable.modal.editInForm' + | 'chatVariable.modal.editInJSON' + | 'chatVariable.modal.oneByOne' -export const getEditorToggleLabelKey = (type: ChatVarType, editInJSON: boolean): EditorToggleLabelKey => { +export const getEditorToggleLabelKey = ( + type: ChatVarType, + editInJSON: boolean, +): EditorToggleLabelKey => { if (type === ChatVarTypeEnum.Object) return editInJSON ? 'chatVariable.modal.editInForm' : 'chatVariable.modal.editInJSON' diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx index c706fc056b1fd9..e41d183b8a70f8 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx @@ -1,6 +1,10 @@ import type { SelectorParam } from 'i18next' import type { ReactNode } from 'react' -import type { ChatVariableTranslator, EditorToggleLabelKey, ObjectValueItem } from './variable-modal.helpers' +import type { + ChatVariableTranslator, + EditorToggleLabelKey, + ObjectValueItem, +} from './variable-modal.helpers' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiDraftLine, RiInputField } from '@remixicon/react' @@ -15,9 +19,9 @@ import ObjectValueList from './object-value-list' import VariableTypeSelector from './variable-type-select' const editorToggleLabelSelectors: Record<EditorToggleLabelKey, SelectorParam<'workflow'>> = { - 'chatVariable.modal.editInForm': $ => $['chatVariable.modal.editInForm'], - 'chatVariable.modal.editInJSON': $ => $['chatVariable.modal.editInJSON'], - 'chatVariable.modal.oneByOne': $ => $['chatVariable.modal.oneByOne'], + 'chatVariable.modal.editInForm': ($) => $['chatVariable.modal.editInForm'], + 'chatVariable.modal.editInJSON': ($) => $['chatVariable.modal.editInJSON'], + 'chatVariable.modal.oneByOne': ($) => $['chatVariable.modal.oneByOne'], } type SectionTitleProps = { @@ -25,7 +29,9 @@ type SectionTitleProps = { } const SectionTitle = ({ children }: SectionTitleProps) => ( - <div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary">{children}</div> + <div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary"> + {children} + </div> ) type NameSectionProps = { @@ -36,13 +42,7 @@ type NameSectionProps = { title: string } -export const NameSection = ({ - name, - onBlur, - onChange, - placeholder, - title, -}: NameSectionProps) => ( +export const NameSection = ({ name, onBlur, onChange, placeholder, title }: NameSectionProps) => ( <div className="mb-4"> <SectionTitle>{title}</SectionTitle> <div className="flex"> @@ -50,7 +50,7 @@ export const NameSection = ({ placeholder={placeholder} value={name} onChange={onChange} - onBlur={e => onBlur(e.target.value)} + onBlur={(e) => onBlur(e.target.value)} type="text" /> </div> @@ -64,12 +64,7 @@ type TypeSectionProps = { type: ChatVarType } -export const TypeSection = ({ - list, - onSelect, - title, - type, -}: TypeSectionProps) => ( +export const TypeSection = ({ list, onSelect, title, type }: TypeSectionProps) => ( <div className="mb-4"> <SectionTitle>{title}</SectionTitle> <div className="flex"> @@ -120,7 +115,7 @@ export const ValueSection = ({ }: ValueSectionProps) => ( <div className="mb-4"> <div className="mb-1 flex h-6 items-center justify-between system-sm-semibold text-text-secondary"> - <div>{t($ => $['chatVariable.modal.value'], { ns: 'workflow' })}</div> + <div>{t(($) => $['chatVariable.modal.value'], { ns: 'workflow' })}</div> {toggleLabelKey && ( <Button variant="ghost" @@ -128,7 +123,11 @@ export const ValueSection = ({ className="text-text-tertiary" onClick={() => onEditorChange(!editInJSON)} > - {editInJSON ? <RiInputField className="mr-1 size-3.5" /> : <RiDraftLine className="mr-1 size-3.5" />} + {editInJSON ? ( + <RiInputField className="mr-1 size-3.5" /> + ) : ( + <RiDraftLine className="mr-1 size-3.5" /> + )} {t(editorToggleLabelSelectors[toggleLabelKey], { ns: 'workflow' })} </Button> )} @@ -138,13 +137,13 @@ export const ValueSection = ({ <textarea className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:system-sm-regular placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs" value={(value as string) || ''} - placeholder={t($ => $['chatVariable.modal.valuePlaceholder'], { ns: 'workflow' }) || ''} - onChange={e => onArrayChange([e.target.value])} + placeholder={t(($) => $['chatVariable.modal.valuePlaceholder'], { ns: 'workflow' }) || ''} + onChange={(e) => onArrayChange([e.target.value])} /> )} {type === ChatVarType.Number && ( <Input - placeholder={t($ => $['chatVariable.modal.valuePlaceholder'], { ns: 'workflow' }) || ''} + placeholder={t(($) => $['chatVariable.modal.valuePlaceholder'], { ns: 'workflow' }) || ''} value={value as number | undefined} onChange={(e) => { const rawValue = e.target.value @@ -154,16 +153,10 @@ export const ValueSection = ({ /> )} {type === ChatVarType.Boolean && ( - <BoolValue - value={value as boolean} - onChange={onValueChange} - /> + <BoolValue value={value as boolean} onChange={onValueChange} /> )} {type === ChatVarType.Object && !editInJSON && ( - <ObjectValueList - list={objectValue} - onChange={onObjectChange} - /> + <ObjectValueList list={objectValue} onChange={onObjectChange} /> )} {type === ChatVarType.ArrayString && !editInJSON && ( <ArrayValueList @@ -180,13 +173,13 @@ export const ValueSection = ({ /> )} {type === ChatVarType.ArrayBoolean && !editInJSON && ( - <ArrayBoolList - list={(value as boolean[]) || [true]} - onChange={onArrayBoolChange} - /> + <ArrayBoolList list={(value as boolean[]) || [true]} onChange={onArrayBoolChange} /> )} {editInJSON && ( - <div className="w-full rounded-[10px] bg-components-input-bg-normal py-2 pr-1 pl-3" style={{ height: editorMinHeight }}> + <div + className="w-full rounded-[10px] bg-components-input-bg-normal py-2 pr-1 pl-3" + style={{ height: editorMinHeight }} + > <CodeEditor isExpand noWrapper @@ -223,13 +216,16 @@ export const DescriptionSection = ({ className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:system-sm-regular placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs" value={description} placeholder={placeholder} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} /> </div> - <div className={cn('mt-1 text-right system-xs-regular', description.length > maxLength ? 'text-text-destructive' : 'text-text-quaternary')}> - {description.length} - / - {maxLength} + <div + className={cn( + 'mt-1 text-right system-xs-regular', + description.length > maxLength ? 'text-text-destructive' : 'text-text-quaternary', + )} + > + {description.length}/{maxLength} </div> </div> ) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx index c61b21108ebc37..f1b6d80b815110 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx @@ -29,11 +29,7 @@ type ModalPropsType = { onSave: (chatVar: ConversationVariable) => void } -const ChatVariableModal = ({ - chatVar, - onClose, - onSave, -}: ModalPropsType) => { +const ChatVariableModal = ({ chatVar, onClose, onSave }: ModalPropsType) => { const { t } = useTranslation() const translateChatVariable: ChatVariableTranslator = (selector, options) => t(selector, options) const workflowStore = useWorkflowStore() @@ -70,22 +66,27 @@ const ChatVariableModal = ({ const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { replaceSpaceWithUnderscoreInVarNameInput(e.target) - if (e.target.value && !validateVariableName({ name: e.target.value, notify, t: translateChatVariable })) + if ( + e.target.value && + !validateVariableName({ name: e.target.value, notify, t: translateChatVariable }) + ) return handleVarNameChange(e) } return ( <div - className={cn('flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl', type === ChatVarType.Object && 'w-[480px]')} + className={cn( + 'flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl', + type === ChatVarType.Object && 'w-[480px]', + )} > <div className="mb-3 flex shrink-0 items-center justify-between p-4 pb-0 system-xl-semibold text-text-primary"> - {!chatVar ? t($ => $['chatVariable.modal.title'], { ns: 'workflow' }) : t($ => $['chatVariable.modal.editTitle'], { ns: 'workflow' })} + {!chatVar + ? t(($) => $['chatVariable.modal.title'], { ns: 'workflow' }) + : t(($) => $['chatVariable.modal.editTitle'], { ns: 'workflow' })} <div className="flex items-center"> - <div - className="flex size-6 cursor-pointer items-center justify-center" - onClick={onClose} - > + <div className="flex size-6 cursor-pointer items-center justify-center" onClick={onClose}> <RiCloseLine className="size-4 text-text-tertiary" /> </div> </div> @@ -93,16 +94,18 @@ const ChatVariableModal = ({ <div className="max-h-[480px] overflow-y-auto px-4 py-2"> <NameSection name={name} - onBlur={nextName => validateVariableName({ name: nextName, notify, t: translateChatVariable })} + onBlur={(nextName) => + validateVariableName({ name: nextName, notify, t: translateChatVariable }) + } onChange={handleNameChange} - placeholder={t($ => $['chatVariable.modal.namePlaceholder'], { ns: 'workflow' }) || ''} - title={t($ => $['chatVariable.modal.name'], { ns: 'workflow' })} + placeholder={t(($) => $['chatVariable.modal.namePlaceholder'], { ns: 'workflow' }) || ''} + title={t(($) => $['chatVariable.modal.name'], { ns: 'workflow' })} /> <TypeSection type={type} list={typeList} onSelect={handleTypeChange} - title={t($ => $['chatVariable.modal.type'], { ns: 'workflow' })} + title={t(($) => $['chatVariable.modal.type'], { ns: 'workflow' })} /> <ValueSection type={type} @@ -112,7 +115,11 @@ const ChatVariableModal = ({ editorContent={editorContent} editorMinHeight={editorMinHeight} onArrayBoolChange={setValue} - onArrayChange={type === ChatVarType.String || type === ChatVarType.Number ? handleStringOrNumberChange : setValue} + onArrayChange={ + type === ChatVarType.String || type === ChatVarType.Number + ? handleStringOrNumberChange + : setValue + } onEditorChange={handleEditorChange} onEditorValueChange={handleEditorValueChange} onObjectChange={setObjectValue} @@ -120,10 +127,10 @@ const ChatVariableModal = ({ placeholder={placeholder} t={translateChatVariable} toggleLabelKey={ - type === ChatVarType.Object - || type === ChatVarType.ArrayString - || type === ChatVarType.ArrayNumber - || type === ChatVarType.ArrayBoolean + type === ChatVarType.Object || + type === ChatVarType.ArrayString || + type === ChatVarType.ArrayNumber || + type === ChatVarType.ArrayBoolean ? getEditorToggleLabelKey(type, editInJSON) : undefined } @@ -132,14 +139,18 @@ const ChatVariableModal = ({ description={description} maxLength={MAX_DESCRIPTION_LENGTH} onChange={setDescription} - placeholder={t($ => $['chatVariable.modal.descriptionPlaceholder'], { ns: 'workflow' }) || ''} - title={t($ => $['chatVariable.modal.description'], { ns: 'workflow' })} + placeholder={ + t(($) => $['chatVariable.modal.descriptionPlaceholder'], { ns: 'workflow' }) || '' + } + title={t(($) => $['chatVariable.modal.description'], { ns: 'workflow' })} /> </div> <div className="flex flex-row-reverse rounded-b-2xl p-4 pt-2"> <div className="flex gap-2"> - <Button onClick={onClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" onClick={handleSave}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button onClick={onClose}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> + <Button variant="primary" onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> </div> </div> diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-type-select.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-type-select.tsx index 787683daca2cb7..a58a9b579f4fda 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-type-select.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-type-select.tsx @@ -1,6 +1,13 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import * as React from 'react' import { useState } from 'react' @@ -12,7 +19,7 @@ type Props<T extends string> = { popupClassName?: string } -const VariableTypeSelector = <T extends string, >({ +const VariableTypeSelector = <T extends string>({ inCell = false, value, list, @@ -22,12 +29,10 @@ const VariableTypeSelector = <T extends string, >({ const [open, setOpen] = useState(false) const handleValueChange = (nextValue: string | null) => { - if (!nextValue) - return + if (!nextValue) return - const nextItem = list.find(item => item === nextValue) - if (!nextItem) - return + const nextItem = list.find((item) => item === nextValue) + if (!nextItem) return onSelect(nextItem) } @@ -39,35 +44,44 @@ const VariableTypeSelector = <T extends string, >({ onOpenChange={setOpen} onValueChange={handleValueChange} > - <SelectTrigger - className="h-auto w-full max-w-none cursor-pointer rounded-none bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent data-popup-open:bg-transparent [&>*:last-child]:hidden" - > - <div className={cn( - 'flex w-full cursor-pointer items-center px-2', - !inCell && 'rounded-lg bg-components-input-bg-normal py-1 hover:bg-state-base-hover-alt', - inCell && 'py-0.5 hover:bg-state-base-hover', - open && !inCell && 'bg-state-base-hover-alt hover:bg-state-base-hover-alt', - open && inCell && 'bg-state-base-hover hover:bg-state-base-hover', - )} - > - <div className={cn( - 'grow truncate p-1 system-sm-regular text-components-input-text-filled', - inCell && 'system-xs-regular text-text-secondary', + <SelectTrigger className="h-auto w-full max-w-none cursor-pointer rounded-none bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent data-popup-open:bg-transparent [&>*:last-child]:hidden"> + <div + className={cn( + 'flex w-full cursor-pointer items-center px-2', + !inCell && + 'rounded-lg bg-components-input-bg-normal py-1 hover:bg-state-base-hover-alt', + inCell && 'py-0.5 hover:bg-state-base-hover', + open && !inCell && 'bg-state-base-hover-alt hover:bg-state-base-hover-alt', + open && inCell && 'bg-state-base-hover hover:bg-state-base-hover', )} + > + <div + className={cn( + 'grow truncate p-1 system-sm-regular text-components-input-text-filled', + inCell && 'system-xs-regular text-text-secondary', + )} > {value} </div> - <span className="ml-0.5 i-ri-arrow-down-s-line size-4 text-text-quaternary" aria-hidden="true" /> + <span + className="ml-0.5 i-ri-arrow-down-s-line size-4 text-text-quaternary" + aria-hidden="true" + /> </div> </SelectTrigger> - <SelectContent placement="bottom-start" popupClassName={cn('bg-components-panel-bg-blur', popupClassName)}> - {list.map(item => ( + <SelectContent + placement="bottom-start" + popupClassName={cn('bg-components-panel-bg-blur', popupClassName)} + > + {list.map((item) => ( <SelectItem key={item} value={item} className="h-auto gap-2 py-[6px] pr-2 pl-3 system-md-regular font-normal" > - <SelectItemText className="px-0 system-md-regular text-text-secondary">{item}</SelectItemText> + <SelectItemText className="px-0 system-md-regular text-text-secondary"> + {item} + </SelectItemText> <SelectItemIndicator /> </SelectItem> ))} diff --git a/web/app/components/workflow/panel/chat-variable-panel/index.tsx b/web/app/components/workflow/panel/chat-variable-panel/index.tsx index 889261d88f0942..599b3159fc651b 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/index.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/index.tsx @@ -1,22 +1,22 @@ -import type { - ConversationVariable, -} from '@/app/components/workflow/types' +import type { ConversationVariable } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { RiBookOpenLine, RiCloseLine } from '@remixicon/react' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' - import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' -import { BubbleX, LongArrowLeft, LongArrowRight } from '@/app/components/base/icons/src/vender/line/others' +import { + BubbleX, + LongArrowLeft, + LongArrowRight, +} from '@/app/components/base/icons/src/vender/line/others' import BlockIcon from '@/app/components/workflow/block-icon' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' import RemoveEffectVarConfirm from '@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm' -import { findUsedVarNodes, updateNodeVars } from '@/app/components/workflow/nodes/_base/components/variable/utils' +import { + findUsedVarNodes, + updateNodeVars, +} from '@/app/components/workflow/nodes/_base/components/variable/utils' import VariableItem from '@/app/components/workflow/panel/chat-variable-panel/components/variable-item' import VariableModalTrigger from '@/app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger' import { useStore } from '@/app/components/workflow/store' @@ -26,14 +26,12 @@ import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' const ChatVariablePanel = () => { const { t } = useTranslation() - const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) - const varList = useStore(s => s.conversationVariables) as ConversationVariable[] - const updateChatVarList = useStore(s => s.setConversationVariables) - const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey) - const appId = useStore(s => s.appId) as string - const { - invalidateConversationVarValues, - } = useInspectVarsCrud() + const setShowChatVariablePanel = useStore((s) => s.setShowChatVariablePanel) + const varList = useStore((s) => s.conversationVariables) as ConversationVariable[] + const updateChatVarList = useStore((s) => s.setConversationVariables) + const setControlPromptEditorRerenderKey = useStore((s) => s.setControlPromptEditorRerenderKey) + const appId = useStore((s) => s.appId) as string + const { invalidateConversationVarValues } = useInspectVarsCrud() const [showTip, setShowTip] = useState(true) const [showVariableModal, setShowVariableModal] = useState(false) @@ -43,81 +41,134 @@ const ChatVariablePanel = () => { const [cacheForDelete, setCacheForDelete] = useState<ConversationVariable>() const collaborativeWorkflow = useCollaborativeWorkflow() - const getEffectedNodes = useCallback((chatVar: ConversationVariable) => { - const { nodes: allNodes } = collaborativeWorkflow.getState() - return findUsedVarNodes( - ['conversation', chatVar.name], - allNodes, - ) - }, [collaborativeWorkflow]) + const getEffectedNodes = useCallback( + (chatVar: ConversationVariable) => { + const { nodes: allNodes } = collaborativeWorkflow.getState() + return findUsedVarNodes(['conversation', chatVar.name], allNodes) + }, + [collaborativeWorkflow], + ) - const removeUsedVarInNodes = useCallback((chatVar: ConversationVariable) => { - const { nodes, setNodes } = collaborativeWorkflow.getState() - const effectedNodes = getEffectedNodes(chatVar) - const newNodes = nodes.map((node) => { - if (effectedNodes.find(n => n.id === node.id)) - return updateNodeVars(node, ['conversation', chatVar.name], []) + const removeUsedVarInNodes = useCallback( + (chatVar: ConversationVariable) => { + const { nodes, setNodes } = collaborativeWorkflow.getState() + const effectedNodes = getEffectedNodes(chatVar) + const newNodes = nodes.map((node) => { + if (effectedNodes.find((n) => n.id === node.id)) + return updateNodeVars(node, ['conversation', chatVar.name], []) - return node - }) - setNodes(newNodes) - }, [getEffectedNodes, collaborativeWorkflow]) + return node + }) + setNodes(newNodes) + }, + [getEffectedNodes, collaborativeWorkflow], + ) const handleEdit = (chatVar: ConversationVariable) => { setCurrentVar(chatVar) setShowVariableModal(true) } - const handleDelete = useCallback(async (chatVar: ConversationVariable) => { - removeUsedVarInNodes(chatVar) - const newVarList = varList.filter(v => v.id !== chatVar.id) - updateChatVarList(newVarList) - setCacheForDelete(undefined) - setShowRemoveConfirm(false) - - // Use new dedicated conversation variables API instead of workflow draft sync - try { - await updateConversationVariables({ - appId, - conversationVariables: newVarList, - }) + const handleDelete = useCallback( + async (chatVar: ConversationVariable) => { + removeUsedVarInNodes(chatVar) + const newVarList = varList.filter((v) => v.id !== chatVar.id) + updateChatVarList(newVarList) + setCacheForDelete(undefined) + setShowRemoveConfirm(false) - // Emit update event to other connected clients - const socket = webSocketClient.getSocket(appId) - if (socket) { - socket.emit('collaboration_event', { - type: 'vars_and_features_update', + // Use new dedicated conversation variables API instead of workflow draft sync + try { + await updateConversationVariables({ + appId, + conversationVariables: newVarList, }) + + // Emit update event to other connected clients + const socket = webSocketClient.getSocket(appId) + if (socket) { + socket.emit('collaboration_event', { + type: 'vars_and_features_update', + }) + } + + invalidateConversationVarValues() + } catch (error) { + console.error('Failed to update conversation variables:', error) + // Revert local state on error + updateChatVarList(varList) } + }, + [removeUsedVarInNodes, updateChatVarList, varList, appId, invalidateConversationVarValues], + ) - invalidateConversationVarValues() - } - catch (error) { - console.error('Failed to update conversation variables:', error) - // Revert local state on error - updateChatVarList(varList) - } - }, [removeUsedVarInNodes, updateChatVarList, varList, appId, invalidateConversationVarValues]) + const deleteCheck = useCallback( + (chatVar: ConversationVariable) => { + const effectedNodes = getEffectedNodes(chatVar) + if (effectedNodes.length > 0) { + setCacheForDelete(chatVar) + setShowRemoveConfirm(true) + } else { + handleDelete(chatVar) + } + }, + [getEffectedNodes, handleDelete], + ) - const deleteCheck = useCallback((chatVar: ConversationVariable) => { - const effectedNodes = getEffectedNodes(chatVar) - if (effectedNodes.length > 0) { - setCacheForDelete(chatVar) - setShowRemoveConfirm(true) - } - else { - handleDelete(chatVar) - } - }, [getEffectedNodes, handleDelete]) + const handleSave = useCallback( + async (chatVar: ConversationVariable) => { + let newList: ConversationVariable[] - const handleSave = useCallback(async (chatVar: ConversationVariable) => { - let newList: ConversationVariable[] + if (!currentVar) { + // Adding new conversation variable + newList = [chatVar, ...varList] + updateChatVarList(newList) - if (!currentVar) { - // Adding new conversation variable - newList = [chatVar, ...varList] + // Use new dedicated conversation variables API + try { + await updateConversationVariables({ + appId, + conversationVariables: newList, + }) + + const socket = webSocketClient.getSocket(appId) + if (socket) { + socket.emit('collaboration_event', { + type: 'vars_and_features_update', + }) + } + + invalidateConversationVarValues() + } catch (error) { + console.error('Failed to update conversation variables:', error) + // Revert local state on error + updateChatVarList(varList) + } + return + } + + // Updating existing conversation variable + newList = varList.map((v) => (v.id === currentVar.id ? chatVar : v)) updateChatVarList(newList) + // side effects of rename conversation variable + if (currentVar.name !== chatVar.name) { + const { nodes, setNodes } = collaborativeWorkflow.getState() + const effectedNodes = getEffectedNodes(currentVar) + const newNodes = nodes.map((node) => { + if (effectedNodes.find((n) => n.id === node.id)) + return updateNodeVars( + node, + ['conversation', currentVar.name], + ['conversation', chatVar.name], + ) + + return node + }) + setNodes(newNodes) + setControlPromptEditorRerenderKey(Date.now()) + } + // Use new dedicated conversation variables API try { await updateConversationVariables({ @@ -133,55 +184,23 @@ const ChatVariablePanel = () => { } invalidateConversationVarValues() - } - catch (error) { + } catch (error) { console.error('Failed to update conversation variables:', error) // Revert local state on error updateChatVarList(varList) } - return - } - - // Updating existing conversation variable - newList = varList.map(v => v.id === currentVar.id ? chatVar : v) - updateChatVarList(newList) - - // side effects of rename conversation variable - if (currentVar.name !== chatVar.name) { - const { nodes, setNodes } = collaborativeWorkflow.getState() - const effectedNodes = getEffectedNodes(currentVar) - const newNodes = nodes.map((node) => { - if (effectedNodes.find(n => n.id === node.id)) - return updateNodeVars(node, ['conversation', currentVar.name], ['conversation', chatVar.name]) - - return node - }) - setNodes(newNodes) - setControlPromptEditorRerenderKey(Date.now()) - } - - // Use new dedicated conversation variables API - try { - await updateConversationVariables({ - appId, - conversationVariables: newList, - }) - - const socket = webSocketClient.getSocket(appId) - if (socket) { - socket.emit('collaboration_event', { - type: 'vars_and_features_update', - }) - } - - invalidateConversationVarValues() - } - catch (error) { - console.error('Failed to update conversation variables:', error) - // Revert local state on error - updateChatVarList(varList) - } - }, [currentVar, getEffectedNodes, collaborativeWorkflow, updateChatVarList, varList, appId, invalidateConversationVarValues, setControlPromptEditorRerenderKey]) + }, + [ + currentVar, + getEffectedNodes, + collaborativeWorkflow, + updateChatVarList, + varList, + appId, + invalidateConversationVarValues, + setControlPromptEditorRerenderKey, + ], + ) return ( <div @@ -190,9 +209,12 @@ const ChatVariablePanel = () => { )} > <div className="flex shrink-0 items-center justify-between p-4 pb-0 system-xl-semibold text-text-primary"> - {t($ => $['chatVariable.panelTitle'], { ns: 'workflow' })} + {t(($) => $['chatVariable.panelTitle'], { ns: 'workflow' })} <div className="flex items-center gap-1"> - <ActionButton state={showTip ? ActionButtonState.Active : undefined} onClick={() => setShowTip(!showTip)}> + <ActionButton + state={showTip ? ActionButtonState.Active : undefined} + onClick={() => setShowTip(!showTip)} + > <RiBookOpenLine className="size-4" /> </ActionButton> <div @@ -206,9 +228,11 @@ const ChatVariablePanel = () => { {showTip && ( <div className="shrink-0 px-3 pt-2.5 pb-2"> <div className="relative rounded-2xl bg-background-section-burn p-3"> - <div className="inline-block rounded-[5px] border border-divider-deep px-[5px] py-[3px] system-2xs-medium-uppercase text-text-tertiary">TIPS</div> + <div className="inline-block rounded-[5px] border border-divider-deep px-[5px] py-[3px] system-2xs-medium-uppercase text-text-tertiary"> + TIPS + </div> <div className="mt-1 mb-4 system-sm-regular text-text-secondary"> - {t($ => $['chatVariable.panelDescription'], { ns: 'workflow' })} + {t(($) => $['chatVariable.panelDescription'], { ns: 'workflow' })} </div> <div className="flex items-center gap-2"> <div className="flex flex-col rounded-[10px] border border-workflow-block-border bg-workflow-block-bg p-3 pb-4 shadow-md"> @@ -223,7 +247,9 @@ const ChatVariablePanel = () => { <div className="shrink-0 system-2xs-medium text-text-tertiary">WRITE</div> </div> <BlockIcon className="shrink-0" type={BlockEnum.Assigner} /> - <div className="grow truncate system-xs-semibold text-text-secondary">{t($ => $['blocks.assigner'], { ns: 'workflow' })}</div> + <div className="grow truncate system-xs-semibold text-text-secondary"> + {t(($) => $['blocks.assigner'], { ns: 'workflow' })} + </div> </div> <div className="flex items-center gap-2 py-1"> <div className="flex h-3 w-16 shrink-0 items-center gap-1 px-1"> @@ -231,7 +257,9 @@ const ChatVariablePanel = () => { <LongArrowRight className="h-2 grow text-text-quaternary" /> </div> <BlockIcon className="shrink-0" type={BlockEnum.LLM} /> - <div className="grow truncate system-xs-semibold text-text-secondary">{t($ => $['blocks.llm'], { ns: 'workflow' })}</div> + <div className="grow truncate system-xs-semibold text-text-secondary"> + {t(($) => $['blocks.llm'], { ns: 'workflow' })} + </div> </div> </div> </div> @@ -250,7 +278,7 @@ const ChatVariablePanel = () => { /> </div> <div className="grow overflow-y-auto rounded-b-2xl px-4"> - {varList.map(chatVar => ( + {varList.map((chatVar) => ( <VariableItem key={chatVar.id} item={chatVar} diff --git a/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx index d225b0e85940cd..ce0759f27a5683 100644 --- a/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx @@ -86,18 +86,20 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: WorkflowStoreSelectionState) => unknown) => selector({ - activeCommentId: storeState.activeCommentId, - setActiveCommentId: (...args: unknown[]) => mockSetActiveCommentId(...args), - setControlMode: (...args: unknown[]) => mockSetControlMode(...args), - showResolvedComments: storeState.showResolvedComments, - setShowResolvedComments: (...args: unknown[]) => mockSetShowResolvedComments(...args), - }), + useStore: (selector: (state: WorkflowStoreSelectionState) => unknown) => + selector({ + activeCommentId: storeState.activeCommentId, + setActiveCommentId: (...args: unknown[]) => mockSetActiveCommentId(...args), + setControlMode: (...args: unknown[]) => mockSetControlMode(...args), + showResolvedComments: storeState.showResolvedComments, + setShowResolvedComments: (...args: unknown[]) => mockSetShowResolvedComments(...args), + }), })) vi.mock('@/app/components/workflow/hooks/use-workflow-comment', () => ({ @@ -114,8 +116,18 @@ vi.mock('@/app/components/base/user-avatar-list', () => ({ })) vi.mock('@langgenius/dify-ui/switch', () => ({ - Switch: ({ checked, onCheckedChange }: { checked: boolean, onCheckedChange: (value: boolean) => void }) => ( - <button type="button" data-testid="show-resolved-switch" onClick={() => onCheckedChange(!checked)}> + Switch: ({ + checked, + onCheckedChange, + }: { + checked: boolean + onCheckedChange: (value: boolean) => void + }) => ( + <button + type="button" + data-testid="show-resolved-switch" + onClick={() => onCheckedChange(!checked)} + > toggle </button> ), diff --git a/web/app/components/workflow/panel/comments-panel/index.tsx b/web/app/components/workflow/panel/comments-panel/index.tsx index 2763934254d934..5be4f6eaf57e37 100644 --- a/web/app/components/workflow/panel/comments-panel/index.tsx +++ b/web/app/components/workflow/panel/comments-panel/index.tsx @@ -1,7 +1,13 @@ import type { WorkflowCommentList } from '@/app/components/workflow/comment/types' import { cn } from '@langgenius/dify-ui/cn' import { Switch } from '@langgenius/dify-ui/switch' -import { RiCheckboxCircleFill, RiCheckboxCircleLine, RiCheckLine, RiCloseLine, RiFilter3Line } from '@remixicon/react' +import { + RiCheckboxCircleFill, + RiCheckboxCircleLine, + RiCheckLine, + RiCloseLine, + RiFilter3Line, +} from '@remixicon/react' import { useAtomValue } from 'jotai' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -15,85 +21,104 @@ import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' const CommentsPanel = () => { const { t } = useTranslation() - const activeCommentId = useStore(s => s.activeCommentId) - const setActiveCommentId = useStore(s => s.setActiveCommentId) - const setControlMode = useStore(s => s.setControlMode) - const showResolvedComments = useStore(s => s.showResolvedComments) - const setShowResolvedComments = useStore(s => s.setShowResolvedComments) + const activeCommentId = useStore((s) => s.activeCommentId) + const setActiveCommentId = useStore((s) => s.setActiveCommentId) + const setControlMode = useStore((s) => s.setControlMode) + const showResolvedComments = useStore((s) => s.showResolvedComments) + const setShowResolvedComments = useStore((s) => s.setShowResolvedComments) const { comments, loading, handleCommentIconClick, handleCommentResolve } = useWorkflowComment() const { formatTimeFromNow } = useFormatTimeFromNow() const [showOnlyMine, setShowOnlyMine] = useState(false) const [showFilter, setShowFilter] = useState(false) - const handleSelect = useCallback((comment: WorkflowCommentList) => { - handleCommentIconClick(comment) - }, [handleCommentIconClick]) + const handleSelect = useCallback( + (comment: WorkflowCommentList) => { + handleCommentIconClick(comment) + }, + [handleCommentIconClick], + ) const currentUserId = useAtomValue(userProfileIdAtom) const filteredSorted = useMemo(() => { let data = comments - if (!showResolvedComments) - data = data.filter(c => !c.resolved) - if (showOnlyMine) - data = data.filter(c => c.created_by === currentUserId) + if (!showResolvedComments) data = data.filter((c) => !c.resolved) + if (showOnlyMine) data = data.filter((c) => c.created_by === currentUserId) return data }, [comments, currentUserId, showOnlyMine, showResolvedComments]) - const handleResolve = useCallback(async (comment: WorkflowCommentList) => { - if (comment.resolved) - return - try { - await handleCommentResolve(comment.id) - setActiveCommentId(comment.id) - } - catch (e) { - console.error('Resolve comment failed', e) - } - }, [handleCommentResolve, setActiveCommentId]) + const handleResolve = useCallback( + async (comment: WorkflowCommentList) => { + if (comment.resolved) return + try { + await handleCommentResolve(comment.id) + setActiveCommentId(comment.id) + } catch (e) { + console.error('Resolve comment failed', e) + } + }, + [handleCommentResolve, setActiveCommentId], + ) const hasActiveFilter = showOnlyMine || !showResolvedComments return ( - <div className={cn('relative flex h-full w-[420px] flex-col rounded-l-2xl border border-components-panel-border bg-components-panel-bg')}> + <div + className={cn( + 'relative flex h-full w-[420px] flex-col rounded-l-2xl border border-components-panel-border bg-components-panel-bg', + )} + > <div className="flex items-center justify-between p-4 pb-2"> - <div className="system-xl-semibold leading-6 font-semibold text-text-primary">{t($ => $['comments.panelTitle'], { ns: 'workflow' })}</div> + <div className="system-xl-semibold leading-6 font-semibold text-text-primary"> + {t(($) => $['comments.panelTitle'], { ns: 'workflow' })} + </div> <div className="relative flex items-center gap-2"> <button className={cn( 'group flex size-6 items-center justify-center rounded-md hover:bg-state-accent-active', hasActiveFilter && 'bg-state-accent-active', )} - aria-label={t($ => $['comments.aria.filterComments'], { ns: 'workflow' })} - onClick={() => setShowFilter(v => !v)} + aria-label={t(($) => $['comments.aria.filterComments'], { ns: 'workflow' })} + onClick={() => setShowFilter((v) => !v)} > - <RiFilter3Line className={cn( - 'size-4 text-text-secondary group-hover:text-text-accent', - hasActiveFilter && 'text-text-accent', - )} + <RiFilter3Line + className={cn( + 'size-4 text-text-secondary group-hover:text-text-accent', + hasActiveFilter && 'text-text-accent', + )} /> </button> {showFilter && ( <div className="absolute top-9 right-10 z-50 min-w-[184px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[10px]"> <button - className={cn('flex w-full items-center justify-between rounded-md p-2 text-left text-sm hover:bg-state-base-hover', !showOnlyMine && 'bg-components-panel-on-panel-item-bg')} + className={cn( + 'flex w-full items-center justify-between rounded-md p-2 text-left text-sm hover:bg-state-base-hover', + !showOnlyMine && 'bg-components-panel-on-panel-item-bg', + )} onClick={() => { setShowOnlyMine(false) setShowFilter(false) }} > - <span className="text-text-secondary">{t($ => $['comments.filter.all'], { ns: 'workflow' })}</span> + <span className="text-text-secondary"> + {t(($) => $['comments.filter.all'], { ns: 'workflow' })} + </span> {!showOnlyMine && <RiCheckLine className="size-4 text-primary-600" />} </button> <button - className={cn('mt-1 flex w-full items-center justify-between rounded-md p-2 text-left text-sm hover:bg-state-base-hover', showOnlyMine && 'bg-components-panel-on-panel-item-bg')} + className={cn( + 'mt-1 flex w-full items-center justify-between rounded-md p-2 text-left text-sm hover:bg-state-base-hover', + showOnlyMine && 'bg-components-panel-on-panel-item-bg', + )} onClick={() => { setShowOnlyMine(true) setShowFilter(false) }} > - <span className="text-text-secondary">{t($ => $['comments.filter.onlyYourThreads'], { ns: 'workflow' })}</span> + <span className="text-text-secondary"> + {t(($) => $['comments.filter.onlyYourThreads'], { ns: 'workflow' })} + </span> {showOnlyMine && <RiCheckLine className="size-4 text-primary-600" />} </button> <Divider type="horizontal" className="my-1" /> @@ -103,7 +128,9 @@ const CommentsPanel = () => { e.stopPropagation() }} > - <span className="text-sm text-text-secondary">{t($ => $['comments.filter.showResolved'], { ns: 'workflow' })}</span> + <span className="text-sm text-text-secondary"> + {t(($) => $['comments.filter.showResolved'], { ns: 'workflow' })} + </span> <Switch size="md" checked={showResolvedComments} @@ -132,51 +159,50 @@ const CommentsPanel = () => { return ( <div key={c.id} - className={cn('group mb-2 cursor-pointer rounded-xl bg-components-panel-bg p-3 transition-colors hover:bg-components-panel-on-panel-item-bg-hover', isActive && 'bg-components-panel-on-panel-item-bg-hover')} + className={cn( + 'group mb-2 cursor-pointer rounded-xl bg-components-panel-bg p-3 transition-colors hover:bg-components-panel-on-panel-item-bg-hover', + isActive && 'bg-components-panel-on-panel-item-bg-hover', + )} onClick={() => handleSelect(c)} > <div className="min-w-0"> <div className="mb-1 flex items-center justify-between"> - <UserAvatarList - users={c.participants} - maxVisible={3} - size="sm" - /> + <UserAvatarList users={c.participants} maxVisible={3} size="sm" /> <div className="ml-2 flex items-center"> - {c.resolved - ? ( - <RiCheckboxCircleFill className="size-4 text-text-secondary" /> - ) - : ( - <RiCheckboxCircleLine - className="size-4 cursor-pointer text-text-tertiary hover:text-text-secondary" - onClick={(e) => { - e.preventDefault() - e.stopPropagation() - handleResolve(c) - }} - /> - )} + {c.resolved ? ( + <RiCheckboxCircleFill className="size-4 text-text-secondary" /> + ) : ( + <RiCheckboxCircleLine + className="size-4 cursor-pointer text-text-tertiary hover:text-text-secondary" + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + handleResolve(c) + }} + /> + )} </div> </div> {/* Header row: creator + time */} <div className="flex items-start"> <div className="flex min-w-0 items-center gap-2"> - <div className="truncate system-sm-medium text-text-primary">{c.created_by_account?.name ?? ''}</div> + <div className="truncate system-sm-medium text-text-primary"> + {c.created_by_account?.name ?? ''} + </div> <div className="shrink-0 system-2xs-regular text-text-tertiary"> {formatTimeFromNow((c.updated_at ?? c.created_at ?? 0) * 1000)} </div> </div> </div> {/* Content */} - <div className="mt-1 line-clamp-3 system-sm-regular wrap-break-word text-text-secondary">{c.content}</div> + <div className="mt-1 line-clamp-3 system-sm-regular wrap-break-word text-text-secondary"> + {c.content} + </div> {/* Footer */} {c.reply_count > 0 && ( <div className="mt-2 flex items-center justify-between"> <div className="system-2xs-regular text-text-tertiary"> - {c.reply_count} - {' '} - {t($ => $['comments.reply'], { ns: 'workflow' })} + {c.reply_count} {t(($) => $['comments.reply'], { ns: 'workflow' })} </div> </div> )} @@ -185,7 +211,9 @@ const CommentsPanel = () => { ) })} {!loading && filteredSorted.length === 0 && ( - <div className="mt-6 text-center system-sm-regular text-text-tertiary">{t($ => $['comments.noComments'], { ns: 'workflow' })}</div> + <div className="mt-6 text-center system-sm-regular text-text-tertiary"> + {t(($) => $['comments.noComments'], { ns: 'workflow' })} + </div> )} </div> </div> diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/chat-wrapper.spec.tsx b/web/app/components/workflow/panel/debug-and-preview/__tests__/chat-wrapper.spec.tsx index 2167e7c411b7b5..031f2780c17966 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/chat-wrapper.spec.tsx @@ -4,15 +4,10 @@ import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useStore as useAppStore } from '@/app/components/app/store' import { createStartNode } from '@/app/components/workflow/__tests__/fixtures' -import { - renderWorkflowFlowComponent, -} from '@/app/components/workflow/__tests__/workflow-test-env' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { InputVarType } from '@/app/components/workflow/types' import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' -import { - fetchSuggestedQuestions, - stopChatMessageResponding, -} from '@/service/debug' +import { fetchSuggestedQuestions, stopChatMessageResponding } from '@/service/debug' import ChatWrapper from '../chat-wrapper' const mockUseChat = vi.hoisted(() => vi.fn()) @@ -36,28 +31,51 @@ vi.mock('@/app/components/base/chat/chat', () => ({ chatNode: React.ReactNode inputDisabled?: boolean onSend?: (message: string, files: unknown[]) => void - onRegenerate?: (chatItem: { id: string, parentMessageId?: string, content?: string, message_files?: unknown[] }) => void + onRegenerate?: (chatItem: { + id: string + parentMessageId?: string + content?: string + message_files?: unknown[] + }) => void switchSibling?: (siblingMessageId: string) => void - onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void> + onHumanInputFormSubmit?: ( + formToken: string, + formData: { inputs: Record<string, HumanInputFieldValue>; action: string }, + ) => Promise<void> onFeatureBarClick?: (state: boolean) => void }) => ( <div data-testid="chat-shell"> <div data-testid="chat-input-disabled">{`${inputDisabled}`}</div> - <button type="button" onClick={() => onSend?.('hello', [])}>send-chat</button> + <button type="button" onClick={() => onSend?.('hello', [])}> + send-chat + </button> <button type="button" - onClick={() => onRegenerate?.({ - id: 'answer-2', - parentMessageId: 'question-1', - content: 'latest answer', - message_files: [], - })} + onClick={() => + onRegenerate?.({ + id: 'answer-2', + parentMessageId: 'question-1', + content: 'latest answer', + message_files: [], + }) + } > regenerate-chat </button> - <button type="button" onClick={() => switchSibling?.('sibling-2')}>switch-sibling</button> - <button type="button" onClick={() => onHumanInputFormSubmit?.('token-1', { inputs: { answer: 'ok' }, action: 'approve' })}>submit-human-input</button> - <button type="button" onClick={() => onFeatureBarClick?.(true)}>open-feature-panel</button> + <button type="button" onClick={() => switchSibling?.('sibling-2')}> + switch-sibling + </button> + <button + type="button" + onClick={() => + onHumanInputFormSubmit?.('token-1', { inputs: { answer: 'ok' }, action: 'approve' }) + } + > + submit-human-input + </button> + <button type="button" onClick={() => onFeatureBarClick?.(true)}> + open-feature-panel + </button> {chatNode} </div> ), @@ -68,27 +86,30 @@ vi.mock('../hooks', () => ({ })) vi.mock('@/app/components/base/features/hooks', () => ({ - useFeatures: <T,>(selector: (state: { - features: { - opening?: { enabled?: boolean, opening_statement?: string, suggested_questions?: string[] } - suggested: boolean - text2speech: boolean - speech2text: boolean - citation: boolean - moderation: boolean - file: { enabled: boolean } - } - }) => T) => selector({ - features: { - opening: { enabled: false, opening_statement: '', suggested_questions: [] }, - suggested: false, - text2speech: false, - speech2text: false, - citation: false, - moderation: false, - file: { enabled: false }, - }, - }), + useFeatures: <T,>( + selector: (state: { + features: { + opening?: { enabled?: boolean; opening_statement?: string; suggested_questions?: string[] } + suggested: boolean + text2speech: boolean + speech2text: boolean + citation: boolean + moderation: boolean + file: { enabled: boolean } + } + }) => T, + ) => + selector({ + features: { + opening: { enabled: false, opening_statement: '', suggested_questions: [] }, + suggested: false, + text2speech: false, + speech2text: false, + citation: false, + moderation: false, + file: { enabled: false }, + }, + }), })) vi.mock('@/context/event-emitter', () => ({ @@ -116,7 +137,8 @@ const createChatState = (overrides: Record<string, unknown> = {}) => ({ ...overrides, }) -const createChatWrapperRef = () => ({ current: null }) as unknown as React.RefObject<ChatWrapperRefType> +const createChatWrapperRef = () => + ({ current: null }) as unknown as React.RefObject<ChatWrapperRefType> describe('ChatWrapper', () => { beforeEach(() => { @@ -150,12 +172,14 @@ describe('ChatWrapper', () => { nodes: [ createStartNode({ data: { - variables: [{ - type: InputVarType.textInput, - variable: 'name', - label: 'Name', - default: 'Ada', - }], + variables: [ + { + type: InputVarType.textInput, + variable: 'name', + label: 'Name', + default: 'Ada', + }, + ], }, }), ], @@ -192,35 +216,37 @@ describe('ChatWrapper', () => { const handleSubmitHumanInputForm = vi.fn().mockResolvedValue(undefined) const handleStop = vi.fn() - mockUseChat.mockReturnValue(createChatState({ - chatList: [ - { - id: 'answer-1', - isAnswer: true, - content: 'first answer', - }, - { - id: 'question-1', - isAnswer: false, - content: 'first question', - parentMessageId: 'answer-1', - message_files: [], - }, - { - id: 'answer-2', - isAnswer: true, - parentMessageId: 'question-1', - content: 'latest answer', - workflowProcess: { - status: 'paused', + mockUseChat.mockReturnValue( + createChatState({ + chatList: [ + { + id: 'answer-1', + isAnswer: true, + content: 'first answer', }, - }, - ], - handleSend, - handleSwitchSibling, - handleSubmitHumanInputForm, - handleStop, - })) + { + id: 'question-1', + isAnswer: false, + content: 'first question', + parentMessageId: 'answer-1', + message_files: [], + }, + { + id: 'answer-2', + isAnswer: true, + parentMessageId: 'question-1', + content: 'latest answer', + workflowProcess: { + status: 'paused', + }, + }, + ], + handleSend, + handleSwitchSibling, + handleSubmitHumanInputForm, + handleStop, + }), + ) const { store } = renderWorkflowFlowComponent( <ChatWrapper @@ -234,12 +260,14 @@ describe('ChatWrapper', () => { nodes: [ createStartNode({ data: { - variables: [{ - type: InputVarType.textInput, - variable: 'name', - label: 'Name', - default: 'Ada', - }], + variables: [ + { + type: InputVarType.textInput, + variable: 'name', + label: 'Name', + default: 'Ada', + }, + ], }, }), ], @@ -262,34 +290,51 @@ describe('ChatWrapper', () => { expect(screen.getByTestId('chat-input-disabled')).toHaveTextContent('true') await user.click(screen.getByRole('button', { name: 'send-chat' })) - expect(handleSend).toHaveBeenCalledWith(expect.objectContaining({ - query: 'hello', - conversation_id: 'conversation-1', - inputs: { - existing: 'value', - name: 'Ada', - }, - parent_message_id: 'answer-2', - }), expect.objectContaining({ - onGetSuggestedQuestions: expect.any(Function), - })) + expect(handleSend).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'hello', + conversation_id: 'conversation-1', + inputs: { + existing: 'value', + name: 'Ada', + }, + parent_message_id: 'answer-2', + }), + expect.objectContaining({ + onGetSuggestedQuestions: expect.any(Function), + }), + ) const sendCallbacks = handleSend.mock.calls[0]?.[1] as { - onGetSuggestedQuestions: (messageId: string, getAbortController: () => AbortController) => void + onGetSuggestedQuestions: ( + messageId: string, + getAbortController: () => AbortController, + ) => void } sendCallbacks.onGetSuggestedQuestions('message-1', () => new AbortController()) - expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith('app-1', 'message-1', expect.any(Function)) + expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith( + 'app-1', + 'message-1', + expect.any(Function), + ) await user.click(screen.getByRole('button', { name: 'regenerate-chat' })) - expect(handleSend).toHaveBeenNthCalledWith(2, expect.objectContaining({ - query: 'first question', - parent_message_id: 'answer-1', - }), expect.any(Object)) + expect(handleSend).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + query: 'first question', + parent_message_id: 'answer-1', + }), + expect.any(Object), + ) await user.click(screen.getByRole('button', { name: 'switch-sibling' })) - expect(handleSwitchSibling).toHaveBeenCalledWith('sibling-2', expect.objectContaining({ - onGetSuggestedQuestions: expect.any(Function), - })) + expect(handleSwitchSibling).toHaveBeenCalledWith( + 'sibling-2', + expect.objectContaining({ + onGetSuggestedQuestions: expect.any(Function), + }), + ) const stopResponding = mockUseChat.mock.calls[0]?.[3] as (taskId: string) => void stopResponding('task-1') @@ -297,10 +342,15 @@ describe('ChatWrapper', () => { await user.click(screen.getByRole('button', { name: 'submit-human-input' })) await waitFor(() => { - expect(handleSubmitHumanInputForm).toHaveBeenCalledWith('token-1', { inputs: { answer: 'ok' }, action: 'approve' }) + expect(handleSubmitHumanInputForm).toHaveBeenCalledWith('token-1', { + inputs: { answer: 'ok' }, + action: 'approve', + }) }) - const subscription = mockUseSubscription.mock.calls[0]?.[0] as (payload: { type: string }) => void + const subscription = mockUseSubscription.mock.calls[0]?.[0] as (payload: { + type: string + }) => void act(() => { subscription({ type: EVENT_WORKFLOW_STOP }) }) @@ -310,9 +360,11 @@ describe('ChatWrapper', () => { it('collapses the side panel while the chat is responding', async () => { const onHide = vi.fn() - mockUseChat.mockReturnValue(createChatState({ - isResponding: true, - })) + mockUseChat.mockReturnValue( + createChatState({ + isResponding: true, + }), + ) renderWorkflowFlowComponent( <ChatWrapper diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/components.spec.tsx b/web/app/components/workflow/panel/debug-and-preview/__tests__/components.spec.tsx index 13d9108c636934..d0c40597bbb31e 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/components.spec.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/components.spec.tsx @@ -43,7 +43,9 @@ vi.mock('@/hooks/use-timestamp', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ value }: { value?: string }) => <pre data-testid="conversation-code-editor">{value}</pre>, + default: ({ value }: { value?: string }) => ( + <pre data-testid="conversation-code-editor">{value}</pre> + ), })) vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/form-item', () => ({ @@ -52,14 +54,14 @@ vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/form-i value, onChange, }: { - payload: { label?: string, variable: string } + payload: { label?: string; variable: string } value?: string onChange: (value: string) => void }) => ( <input aria-label={payload.label || payload.variable} value={value ?? ''} - onChange={e => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value)} /> ), })) @@ -77,9 +79,17 @@ vi.mock('@/app/components/base/chat/chat', () => ({ chatNode: React.ReactNode inputDisabled?: boolean onSend?: (message: string, files: unknown[]) => void - onRegenerate?: (chatItem: { id: string, parentMessageId?: string, content?: string, message_files?: unknown[] }) => void + onRegenerate?: (chatItem: { + id: string + parentMessageId?: string + content?: string + message_files?: unknown[] + }) => void switchSibling?: (siblingMessageId: string) => void - onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void> + onHumanInputFormSubmit?: ( + formToken: string, + formData: { inputs: Record<string, HumanInputFieldValue>; action: string }, + ) => Promise<void> onFeatureBarClick?: (state: boolean) => void }) => { mockChatRender({ @@ -89,21 +99,36 @@ vi.mock('@/app/components/base/chat/chat', () => ({ return ( <div data-testid="chat-shell"> <div data-testid="chat-input-disabled">{`${inputDisabled}`}</div> - <button type="button" onClick={() => onSend?.('hello', [])}>send-chat</button> + <button type="button" onClick={() => onSend?.('hello', [])}> + send-chat + </button> <button type="button" - onClick={() => onRegenerate?.({ - id: 'answer-2', - parentMessageId: 'question-1', - content: 'latest answer', - message_files: [], - })} + onClick={() => + onRegenerate?.({ + id: 'answer-2', + parentMessageId: 'question-1', + content: 'latest answer', + message_files: [], + }) + } > regenerate-chat </button> - <button type="button" onClick={() => switchSibling?.('sibling-2')}>switch-sibling</button> - <button type="button" onClick={() => onHumanInputFormSubmit?.('token-1', { inputs: { answer: 'ok' }, action: 'approve' })}>submit-human-input</button> - <button type="button" onClick={() => onFeatureBarClick?.(true)}>open-feature-panel</button> + <button type="button" onClick={() => switchSibling?.('sibling-2')}> + switch-sibling + </button> + <button + type="button" + onClick={() => + onHumanInputFormSubmit?.('token-1', { inputs: { answer: 'ok' }, action: 'approve' }) + } + > + submit-human-input + </button> + <button type="button" onClick={() => onFeatureBarClick?.(true)}> + open-feature-panel + </button> {chatNode} </div> ) @@ -115,27 +140,30 @@ vi.mock('../hooks', () => ({ })) vi.mock('@/app/components/base/features/hooks', () => ({ - useFeatures: <T,>(selector: (state: { - features: { - opening?: { enabled?: boolean, opening_statement?: string, suggested_questions?: string[] } - suggested: boolean - text2speech: boolean - speech2text: boolean - citation: boolean - moderation: boolean - file: { enabled: boolean } - } - }) => T) => selector({ - features: { - opening: { enabled: false, opening_statement: '', suggested_questions: [] }, - suggested: false, - text2speech: false, - speech2text: false, - citation: false, - moderation: false, - file: { enabled: false }, - }, - }), + useFeatures: <T,>( + selector: (state: { + features: { + opening?: { enabled?: boolean; opening_statement?: string; suggested_questions?: string[] } + suggested: boolean + text2speech: boolean + speech2text: boolean + citation: boolean + moderation: boolean + file: { enabled: boolean } + } + }) => T, + ) => + selector({ + features: { + opening: { enabled: false, opening_statement: '', suggested_questions: [] }, + suggested: false, + text2speech: false, + speech2text: false, + citation: false, + moderation: false, + file: { enabled: false }, + }, + }), })) vi.mock('@/context/event-emitter', () => ({ @@ -146,7 +174,9 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -const mockFetchCurrentValueOfConversationVariable = vi.mocked(fetchCurrentValueOfConversationVariable) +const mockFetchCurrentValueOfConversationVariable = vi.mocked( + fetchCurrentValueOfConversationVariable, +) const mockCopy = vi.mocked(copy) const mockFetchSuggestedQuestions = vi.mocked(fetchSuggestedQuestions) const mockStopChatMessageResponding = vi.mocked(stopChatMessageResponding) @@ -177,7 +207,9 @@ const createChatState = (overrides: Record<string, unknown> = {}) => ({ }) const createConversationVariableResponse = ( - data: Array<Awaited<ReturnType<typeof fetchCurrentValueOfConversationVariable>>['data'][number]> = [], + data: Array< + Awaited<ReturnType<typeof fetchCurrentValueOfConversationVariable>>['data'][number] + > = [], ): Awaited<ReturnType<typeof fetchCurrentValueOfConversationVariable>> => ({ data, has_more: false, @@ -186,7 +218,8 @@ const createConversationVariableResponse = ( page: 1, }) -const createChatWrapperRef = () => ({ current: null }) as unknown as React.RefObject<ChatWrapperRefType> +const createChatWrapperRef = () => + ({ current: null }) as unknown as React.RefObject<ChatWrapperRefType> describe('debug-and-preview components', () => { beforeEach(() => { @@ -201,7 +234,9 @@ describe('debug-and-preview components', () => { } as ReturnType<typeof useAppStore.getState>['appDetail'], }) mockUseChat.mockReturnValue(createChatState()) - mockFetchCurrentValueOfConversationVariable.mockResolvedValue(createConversationVariableResponse()) + mockFetchCurrentValueOfConversationVariable.mockResolvedValue( + createConversationVariableResponse(), + ) }) afterEach(() => { @@ -212,32 +247,31 @@ describe('debug-and-preview components', () => { it('should load latest values, switch variable tabs, and close the modal', async () => { const user = userEvent.setup() const onHide = vi.fn() - mockFetchCurrentValueOfConversationVariable.mockResolvedValue(createConversationVariableResponse([ - { - ...createConversationVariable({ - id: 'var-1', - value: '{"latest":1}', - }), - updated_at: 100, - created_at: 50, - }, - { - ...createConversationVariable({ - id: 'var-2', - name: 'summary', - value_type: ChatVarType.String, - value: 'latest text', - }), - updated_at: 200, - created_at: 150, - }, - ])) + mockFetchCurrentValueOfConversationVariable.mockResolvedValue( + createConversationVariableResponse([ + { + ...createConversationVariable({ + id: 'var-1', + value: '{"latest":1}', + }), + updated_at: 100, + created_at: 50, + }, + { + ...createConversationVariable({ + id: 'var-2', + name: 'summary', + value_type: ChatVarType.String, + value: 'latest text', + }), + updated_at: 200, + created_at: 150, + }, + ]), + ) renderWorkflowComponent( - <ConversationVariableModal - conversationID="conversation-1" - onHide={onHide} - />, + <ConversationVariableModal conversationID="conversation-1" onHide={onHide} />, { initialStoreState: { appId: 'app-1', @@ -262,10 +296,12 @@ describe('debug-and-preview components', () => { }) expect(screen.getAllByText('session_state')).toHaveLength(2) - expect(screen.getByText(content => content.includes('formatted-100'))).toBeInTheDocument() + expect(screen.getByText((content) => content.includes('formatted-100'))).toBeInTheDocument() expect(screen.getByTestId('conversation-code-editor')).toHaveTextContent('{"latest":1}') - const closeTrigger = document.querySelector('.absolute.right-4.top-4.cursor-pointer') as HTMLElement + const closeTrigger = document.querySelector( + '.absolute.right-4.top-4.cursor-pointer', + ) as HTMLElement await user.click(screen.getByText('summary')) expect(screen.getByText('latest text')).toBeInTheDocument() @@ -277,21 +313,18 @@ describe('debug-and-preview components', () => { it('should copy the current variable value and reset the copied state after the timeout', async () => { vi.useFakeTimers() renderWorkflowComponent( - <ConversationVariableModal - conversationID="conversation-1" - onHide={vi.fn()} - />, + <ConversationVariableModal conversationID="conversation-1" onHide={vi.fn()} />, { initialStoreState: { appId: 'app-1', - conversationVariables: [ - createConversationVariable(), - ], + conversationVariables: [createConversationVariable()], }, }, ) - const copyTrigger = document.querySelector('.flex.items-center.p-1 svg.cursor-pointer') as HTMLElement + const copyTrigger = document.querySelector( + '.flex.items-center.p-1 svg.cursor-pointer', + ) as HTMLElement act(() => { fireEvent.click(copyTrigger) }) @@ -306,37 +339,34 @@ describe('debug-and-preview components', () => { describe('UserInput', () => { it('should hide secret fields outside the expanded panel and persist edits into workflow state', async () => { const user = userEvent.setup() - const { store } = renderWorkflowFlowComponent( - <UserInput />, - { - nodes: [ - createStartNode({ - data: { - variables: [ - { - type: InputVarType.textInput, - variable: 'question', - label: 'Question', - }, - { - type: InputVarType.textInput, - variable: 'internal_note', - label: 'Internal Note', - hide: true, - }, - ], - }, - }), - ], - edges: [], - initialStoreState: { - inputs: { - question: 'draft', + const { store } = renderWorkflowFlowComponent(<UserInput />, { + nodes: [ + createStartNode({ + data: { + variables: [ + { + type: InputVarType.textInput, + variable: 'question', + label: 'Question', + }, + { + type: InputVarType.textInput, + variable: 'internal_note', + label: 'Internal Note', + hide: true, + }, + ], }, - showDebugAndPreviewPanel: false, + }), + ], + edges: [], + initialStoreState: { + inputs: { + question: 'draft', }, + showDebugAndPreviewPanel: false, }, - ) + }) expect(screen.getByLabelText('Question')).toBeInTheDocument() expect(screen.queryByLabelText('Internal Note')).not.toBeInTheDocument() @@ -350,28 +380,27 @@ describe('debug-and-preview components', () => { }) it('should reveal hidden fields when the debug-and-preview panel is expanded', () => { - renderWorkflowFlowComponent( - <UserInput />, - { - nodes: [ - createStartNode({ - data: { - variables: [{ + renderWorkflowFlowComponent(<UserInput />, { + nodes: [ + createStartNode({ + data: { + variables: [ + { type: InputVarType.textInput, variable: 'internal_note', label: 'Internal Note', hide: true, - }], - }, - }), - ], - edges: [], - initialStoreState: { - inputs: {}, - showDebugAndPreviewPanel: true, - }, + }, + ], + }, + }), + ], + edges: [], + initialStoreState: { + inputs: {}, + showDebugAndPreviewPanel: true, }, - ) + }) expect(screen.getByLabelText('Internal Note')).toBeInTheDocument() }) @@ -395,12 +424,14 @@ describe('debug-and-preview components', () => { nodes: [ createStartNode({ data: { - variables: [{ - type: InputVarType.textInput, - variable: 'name', - label: 'Name', - default: 'Ada', - }], + variables: [ + { + type: InputVarType.textInput, + variable: 'name', + label: 'Name', + default: 'Ada', + }, + ], }, }), ], @@ -434,19 +465,23 @@ describe('debug-and-preview components', () => { it('should hide the side panel while responding and render the conversation modal when requested', async () => { const onHide = vi.fn() - mockUseChat.mockReturnValue(createChatState({ - isResponding: true, - })) - mockFetchCurrentValueOfConversationVariable.mockResolvedValue(createConversationVariableResponse([ - { - ...createConversationVariable({ - id: 'var-1', - value: '{"latest":1}', - }), - updated_at: 100, - created_at: 50, - }, - ])) + mockUseChat.mockReturnValue( + createChatState({ + isResponding: true, + }), + ) + mockFetchCurrentValueOfConversationVariable.mockResolvedValue( + createConversationVariableResponse([ + { + ...createConversationVariable({ + id: 'var-1', + value: '{"latest":1}', + }), + updated_at: 100, + created_at: 50, + }, + ]), + ) renderWorkflowFlowComponent( <ChatWrapper @@ -467,9 +502,7 @@ describe('debug-and-preview components', () => { edges: [], initialStoreState: { appId: 'app-1', - conversationVariables: [ - createConversationVariable(), - ], + conversationVariables: [createConversationVariable()], }, }, ) @@ -487,35 +520,37 @@ describe('debug-and-preview components', () => { const handleSwitchSibling = vi.fn() const handleSubmitHumanInputForm = vi.fn().mockResolvedValue(undefined) const handleStop = vi.fn() - mockUseChat.mockReturnValue(createChatState({ - chatList: [ - { - id: 'answer-1', - isAnswer: true, - content: 'first answer', - }, - { - id: 'question-1', - isAnswer: false, - content: 'first question', - parentMessageId: 'answer-1', - message_files: [], - }, - { - id: 'answer-2', - isAnswer: true, - parentMessageId: 'question-1', - content: 'latest answer', - workflowProcess: { - status: 'paused', + mockUseChat.mockReturnValue( + createChatState({ + chatList: [ + { + id: 'answer-1', + isAnswer: true, + content: 'first answer', }, - }, - ], - handleSend, - handleSwitchSibling, - handleSubmitHumanInputForm, - handleStop, - })) + { + id: 'question-1', + isAnswer: false, + content: 'first question', + parentMessageId: 'answer-1', + message_files: [], + }, + { + id: 'answer-2', + isAnswer: true, + parentMessageId: 'question-1', + content: 'latest answer', + workflowProcess: { + status: 'paused', + }, + }, + ], + handleSend, + handleSwitchSibling, + handleSubmitHumanInputForm, + handleStop, + }), + ) const { store } = renderWorkflowFlowComponent( <ChatWrapper @@ -529,12 +564,14 @@ describe('debug-and-preview components', () => { nodes: [ createStartNode({ data: { - variables: [{ - type: InputVarType.textInput, - variable: 'name', - label: 'Name', - default: 'Ada', - }], + variables: [ + { + type: InputVarType.textInput, + variable: 'name', + label: 'Name', + default: 'Ada', + }, + ], }, }), ], @@ -557,51 +594,80 @@ describe('debug-and-preview components', () => { expect(screen.getByTestId('chat-input-disabled')).toHaveTextContent('true') await user.click(screen.getByRole('button', { name: 'send-chat' })) - expect(handleSend).toHaveBeenCalledWith(expect.objectContaining({ - query: 'hello', - conversation_id: 'conversation-1', - inputs: { - existing: 'value', - name: 'Ada', - }, - parent_message_id: 'answer-2', - }), expect.objectContaining({ - onGetSuggestedQuestions: expect.any(Function), - })) + expect(handleSend).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'hello', + conversation_id: 'conversation-1', + inputs: { + existing: 'value', + name: 'Ada', + }, + parent_message_id: 'answer-2', + }), + expect.objectContaining({ + onGetSuggestedQuestions: expect.any(Function), + }), + ) const sendCallbacks = handleSend.mock.calls[0]?.[1] as { - onGetSuggestedQuestions: (messageId: string, getAbortController: () => AbortController) => void + onGetSuggestedQuestions: ( + messageId: string, + getAbortController: () => AbortController, + ) => void } sendCallbacks.onGetSuggestedQuestions('message-1', () => new AbortController()) - expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith('app-1', 'message-1', expect.any(Function)) + expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith( + 'app-1', + 'message-1', + expect.any(Function), + ) await user.click(screen.getByRole('button', { name: 'regenerate-chat' })) - expect(handleSend).toHaveBeenNthCalledWith(2, expect.objectContaining({ - query: 'first question', - parent_message_id: 'answer-1', - }), expect.any(Object)) + expect(handleSend).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + query: 'first question', + parent_message_id: 'answer-1', + }), + expect.any(Object), + ) await user.click(screen.getByRole('button', { name: 'switch-sibling' })) - expect(handleSwitchSibling).toHaveBeenCalledWith('sibling-2', expect.objectContaining({ - onGetSuggestedQuestions: expect.any(Function), - })) + expect(handleSwitchSibling).toHaveBeenCalledWith( + 'sibling-2', + expect.objectContaining({ + onGetSuggestedQuestions: expect.any(Function), + }), + ) const switchCallbacks = handleSwitchSibling.mock.calls[0]?.[1] as { - onGetSuggestedQuestions: (messageId: string, getAbortController: () => AbortController) => void + onGetSuggestedQuestions: ( + messageId: string, + getAbortController: () => AbortController, + ) => void } switchCallbacks.onGetSuggestedQuestions('message-2', () => new AbortController()) - expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith('app-1', 'message-2', expect.any(Function)) + expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith( + 'app-1', + 'message-2', + expect.any(Function), + ) await user.click(screen.getByRole('button', { name: 'submit-human-input' })) await waitFor(() => { - expect(handleSubmitHumanInputForm).toHaveBeenCalledWith('token-1', { inputs: { answer: 'ok' }, action: 'approve' }) + expect(handleSubmitHumanInputForm).toHaveBeenCalledWith('token-1', { + inputs: { answer: 'ok' }, + action: 'approve', + }) }) const stopResponding = mockUseChat.mock.calls[0]?.[3] as (taskId: string) => void stopResponding('task-1') expect(mockStopChatMessageResponding).toHaveBeenCalledWith('app-1', 'task-1') - const subscription = mockUseSubscription.mock.calls[0]?.[0] as (payload: { type: string }) => void + const subscription = mockUseSubscription.mock.calls[0]?.[0] as (payload: { + type: string + }) => void act(() => { subscription({ type: EVENT_WORKFLOW_STOP }) }) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/conversation-variable-modal.spec.tsx b/web/app/components/workflow/panel/debug-and-preview/__tests__/conversation-variable-modal.spec.tsx index fa050b9bc54b88..060d53dcd0e277 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/conversation-variable-modal.spec.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/conversation-variable-modal.spec.tsx @@ -22,10 +22,14 @@ vi.mock('@/hooks/use-timestamp', () => ({ })) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ value }: { value?: string }) => <pre data-testid="conversation-code-editor">{value}</pre>, + default: ({ value }: { value?: string }) => ( + <pre data-testid="conversation-code-editor">{value}</pre> + ), })) -const mockFetchCurrentValueOfConversationVariable = vi.mocked(fetchCurrentValueOfConversationVariable) +const mockFetchCurrentValueOfConversationVariable = vi.mocked( + fetchCurrentValueOfConversationVariable, +) const mockCopy = vi.mocked(copy) const createConversationVariable = ( @@ -40,7 +44,9 @@ const createConversationVariable = ( }) const createConversationVariableResponse = ( - data: Array<Awaited<ReturnType<typeof fetchCurrentValueOfConversationVariable>>['data'][number]> = [], + data: Array< + Awaited<ReturnType<typeof fetchCurrentValueOfConversationVariable>>['data'][number] + > = [], ): Awaited<ReturnType<typeof fetchCurrentValueOfConversationVariable>> => ({ data, has_more: false, @@ -52,7 +58,9 @@ const createConversationVariableResponse = ( describe('ConversationVariableModal', () => { beforeEach(() => { vi.clearAllMocks() - mockFetchCurrentValueOfConversationVariable.mockResolvedValue(createConversationVariableResponse()) + mockFetchCurrentValueOfConversationVariable.mockResolvedValue( + createConversationVariableResponse(), + ) }) afterEach(() => { @@ -62,32 +70,31 @@ describe('ConversationVariableModal', () => { it('loads latest values, switches the active variable, and closes the modal', async () => { const user = userEvent.setup() const onHide = vi.fn() - mockFetchCurrentValueOfConversationVariable.mockResolvedValue(createConversationVariableResponse([ - { - ...createConversationVariable({ - id: 'var-1', - value: '{"latest":1}', - }), - updated_at: 100, - created_at: 50, - }, - { - ...createConversationVariable({ - id: 'var-2', - name: 'summary', - value_type: ChatVarType.String, - value: 'latest text', - }), - updated_at: 200, - created_at: 150, - }, - ])) + mockFetchCurrentValueOfConversationVariable.mockResolvedValue( + createConversationVariableResponse([ + { + ...createConversationVariable({ + id: 'var-1', + value: '{"latest":1}', + }), + updated_at: 100, + created_at: 50, + }, + { + ...createConversationVariable({ + id: 'var-2', + name: 'summary', + value_type: ChatVarType.String, + value: 'latest text', + }), + updated_at: 200, + created_at: 150, + }, + ]), + ) renderWorkflowComponent( - <ConversationVariableModal - conversationID="conversation-1" - onHide={onHide} - />, + <ConversationVariableModal conversationID="conversation-1" onHide={onHide} />, { initialStoreState: { appId: 'app-1', @@ -112,7 +119,7 @@ describe('ConversationVariableModal', () => { }) expect(screen.getAllByText('session_state')).toHaveLength(2) - expect(screen.getByText(content => content.includes('formatted-100'))).toBeInTheDocument() + expect(screen.getByText((content) => content.includes('formatted-100'))).toBeInTheDocument() expect(screen.getByTestId('conversation-code-editor')).toHaveTextContent('{"latest":1}') await user.click(screen.getByText('summary')) @@ -127,10 +134,7 @@ describe('ConversationVariableModal', () => { vi.useFakeTimers() renderWorkflowComponent( - <ConversationVariableModal - conversationID="conversation-1" - onHide={vi.fn()} - />, + <ConversationVariableModal conversationID="conversation-1" onHide={vi.fn()} />, { initialStoreState: { appId: 'app-1', @@ -139,7 +143,9 @@ describe('ConversationVariableModal', () => { }, ) - const copyTrigger = document.querySelector('.flex.items-center.p-1 svg.cursor-pointer') as HTMLElement + const copyTrigger = document.querySelector( + '.flex.items-center.p-1 svg.cursor-pointer', + ) as HTMLElement act(() => { fireEvent.click(copyTrigger) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts index 223a4d769c0244..a6dbda9e4a83a4 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts @@ -10,7 +10,9 @@ const mockSetIterTimes = vi.hoisted(() => vi.fn()) const mockSetLoopTimes = vi.hoisted(() => vi.fn()) const mockSubmitHumanInputForm = vi.hoisted(() => vi.fn()) const mockSseGet = vi.hoisted(() => vi.fn()) -const mockGetNodes = vi.hoisted(() => vi.fn(() => [] as Array<{ id: string, type: string, data: Record<string, unknown> }>)) +const mockGetNodes = vi.hoisted(() => + vi.fn(() => [] as Array<{ id: string; type: string; data: Record<string, unknown> }>), +) let mockWorkflowRunningData: unknown = null @@ -80,8 +82,13 @@ describe('useChat', () => { it('resumes the workflow event stream when switching to a sibling with pending human input', async () => { let sendCallbacks: { - onWorkflowStarted: (payload: { workflow_run_id: string, task_id: string, conversation_id: string | null, message_id: string }) => void - onHumanInputRequired: (payload: { data: { node_id: string, form_token: string } }) => void + onWorkflowStarted: (payload: { + workflow_run_id: string + task_id: string + conversation_id: string | null + message_id: string + }) => void + onHumanInputRequired: (payload: { data: { node_id: string; form_token: string } }) => void onCompleted: (payload: boolean) => Promise<void> } @@ -126,19 +133,22 @@ describe('useChat', () => { const { result } = renderHook(() => useChat({})) await act(async () => { - await result.current.handleSubmitHumanInputForm('token-123', { inputs: { field: 'value' }, action: 'approve' }) + await result.current.handleSubmitHumanInputForm('token-123', { + inputs: { field: 'value' }, + action: 'approve', + }) }) - expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('token-123', { inputs: { field: 'value' }, action: 'approve' }) + expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('token-123', { + inputs: { field: 'value' }, + action: 'approve', + }) expect(submitHumanInputForm).toBeDefined() }) it('returns the matching custom node for a human input request', () => { const node = { id: 'node-1', type: 'custom', data: { title: 'Human Input' } } - mockGetNodes.mockReturnValue([ - node, - { id: 'node-2', type: 'custom', data: { title: 'Other' } }, - ]) + mockGetNodes.mockReturnValue([node, { id: 'node-2', type: 'custom', data: { title: 'Other' } }]) const { result } = renderHook(() => useChat({})) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts index 859ac821956903..1e82dc275e4378 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts @@ -237,7 +237,7 @@ describe('useChat – handleResume', () => { }) }) - const answer = hook.result.current.chatList.find(item => item.id === 'msg-resume') + const answer = hook.result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.workflowProcess!.status).toBe('running') }) }) @@ -259,7 +259,7 @@ describe('useChat – handleResume', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.workflowProcess!.status).toBe('succeeded') }) @@ -279,7 +279,7 @@ describe('useChat – handleResume', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.workflowProcess!.status).toBe('failed') expect(answer!.workflowProcess!.error).toBe('Invalid upload file') }) @@ -297,7 +297,7 @@ describe('useChat – handleResume', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.content).toContain('resumed') }) @@ -321,11 +321,15 @@ describe('useChat – handleResume', () => { const { result } = await setupResumeWithTree() act(() => { - capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: 'resumed ', node_id: 'llm' } }) - capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: 'thought', node_id: 'llm', is_final: true } }) + capturedResumeOptions.onReasoning({ + data: { message_id: 'msg-resume', reasoning: 'resumed ', node_id: 'llm' }, + }) + capturedResumeOptions.onReasoning({ + data: { message_id: 'msg-resume', reasoning: 'thought', node_id: 'llm', is_final: true }, + }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.reasoningContent).toEqual({ llm: 'resumed thought' }) expect(answer!.reasoningFinished).toBe(true) }) @@ -339,7 +343,7 @@ describe('useChat – handleResume', () => { capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: 'b' } }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.reasoningContent).toEqual({ _: 'ab' }) expect(answer!.reasoningFinished).toBeUndefined() }) @@ -522,7 +526,7 @@ describe('useChat – handleResume', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.citation).toEqual([{ id: 'cite-1' }]) }) }) @@ -535,7 +539,7 @@ describe('useChat – handleResume', () => { capturedResumeOptions.onMessageReplace({ answer: 'replaced' }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.content).toBe('replaced') }) }) @@ -550,7 +554,7 @@ describe('useChat – handleResume', () => { }) }) - let answer = result.current.chatList.find(item => item.id === 'msg-resume') + let answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.workflowProcess!.tracing).toHaveLength(1) expect(answer!.workflowProcess!.tracing[0]!.id).toBe('iter-r1') expect(answer!.workflowProcess!.tracing[0]!.status).toBe('running') @@ -561,7 +565,7 @@ describe('useChat – handleResume', () => { }) }) - answer = result.current.chatList.find(item => item.id === 'msg-resume') + answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.workflowProcess!.tracing).toHaveLength(1) expect(answer!.workflowProcess!.tracing[0]!.status).toBe('succeeded') }) @@ -587,7 +591,7 @@ describe('useChat – handleResume', () => { }) }) - let answer = result.current.chatList.find(item => item.id === 'msg-resume') + let answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.workflowProcess!.tracing).toHaveLength(1) expect(answer!.workflowProcess!.tracing[0]!.id).toBe('loop-r1') expect(answer!.workflowProcess!.tracing[0]!.status).toBe('running') @@ -598,7 +602,7 @@ describe('useChat – handleResume', () => { }) }) - answer = result.current.chatList.find(item => item.id === 'msg-resume') + answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.workflowProcess!.tracing).toHaveLength(1) expect(answer!.workflowProcess!.tracing[0]!.status).toBe('succeeded') }) @@ -631,7 +635,7 @@ describe('useChat – handleResume', () => { }) }) - let answer = result.current.chatList.find(item => item.id === 'msg-resume') + let answer = result.current.chatList.find((item) => item.id === 'msg-resume') const startedTrace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'rn-1') expect(startedTrace).toBeDefined() expect(startedTrace!.id).toBe('rtrace-1') @@ -643,7 +647,7 @@ describe('useChat – handleResume', () => { }) }) - answer = result.current.chatList.find(item => item.id === 'msg-resume') + answer = result.current.chatList.find((item) => item.id === 'msg-resume') const finishedTrace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'rn-1') expect(finishedTrace).toBeDefined() expect((finishedTrace as any).status).toBe('succeeded') @@ -665,8 +669,10 @@ describe('useChat – handleResume', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') - expect(answer!.workflowProcess!.tracing.some((t: any) => t.node_id === 'rn-child')).toBe(false) + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') + expect(answer!.workflowProcess!.tracing.some((t: any) => t.node_id === 'rn-child')).toBe( + false, + ) }) it('should skip onNodeFinished when iteration_id is present', async () => { @@ -708,8 +714,10 @@ describe('useChat – handleResume', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') - const matchingTraces = answer!.workflowProcess!.tracing.filter((t: any) => t.node_id === 'rn-1') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') + const matchingTraces = answer!.workflowProcess!.tracing.filter( + (t: any) => t.node_id === 'rn-1', + ) expect(matchingTraces).toHaveLength(1) expect(matchingTraces[0]!.id).toBe('rtrace-1-v2') expect(matchingTraces[0]!.status).toBe('running') @@ -742,7 +750,7 @@ describe('useChat – handleResume', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') const trace = answer!.workflowProcess!.tracing.find((t: any) => t.id === 'rtrace-1') expect(trace).toBeDefined() expect((trace as any).status).toBe('succeeded') @@ -760,7 +768,7 @@ describe('useChat – handleResume', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.humanInputFormDataList).toHaveLength(1) }) @@ -779,7 +787,7 @@ describe('useChat – handleResume', () => { }) }) - let answer = result.current.chatList.find(item => item.id === 'msg-resume') + let answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.humanInputFormDataList).toHaveLength(1) act(() => { @@ -788,7 +796,7 @@ describe('useChat – handleResume', () => { }) }) - answer = result.current.chatList.find(item => item.id === 'msg-resume') + answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.humanInputFormDataList).toHaveLength(2) }) @@ -814,7 +822,7 @@ describe('useChat – handleResume', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') const trace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'rn-human') expect(trace!.status).toBe('paused') }) @@ -836,7 +844,7 @@ describe('useChat – handleResume', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.humanInputFormDataList).toHaveLength(0) expect(answer!.humanInputFilledFormDataList).toHaveLength(1) }) @@ -850,7 +858,7 @@ describe('useChat – handleResume', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.humanInputFilledFormDataList).toHaveLength(1) }) }) @@ -871,7 +879,7 @@ describe('useChat – handleResume', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') const form = answer!.humanInputFormDataList!.find((f: any) => f.node_id === 'rn-human') expect(form!.expiration_time).toBe('2025-06-01') }) @@ -889,7 +897,7 @@ describe('useChat – handleResume', () => { }) expect(mockSseGet.mock.calls.length).toBeGreaterThan(sseGetCallsBefore) - const answer = result.current.chatList.find(item => item.id === 'msg-resume') + const answer = result.current.chatList.find((item) => item.id === 'msg-resume') expect(answer!.workflowProcess!.status).toBe('paused') }) }) @@ -978,7 +986,7 @@ describe('useChat – handleResume with bare prevChatTree (no humanInputFormData }) }) - const answer = result.current.chatList.find(item => item.id === 'bare-msg') + const answer = result.current.chatList.find((item) => item.id === 'bare-msg') expect(answer!.humanInputFormDataList).toHaveLength(1) }) @@ -991,7 +999,7 @@ describe('useChat – handleResume with bare prevChatTree (no humanInputFormData }) }) - const answer = result.current.chatList.find(item => item.id === 'bare-msg') + const answer = result.current.chatList.find((item) => item.id === 'bare-msg') expect(answer!.humanInputFilledFormDataList).toHaveLength(1) }) @@ -1004,7 +1012,7 @@ describe('useChat – handleResume with bare prevChatTree (no humanInputFormData }) }) - const answer = result.current.chatList.find(item => item.id === 'bare-msg-nt') + const answer = result.current.chatList.find((item) => item.id === 'bare-msg-nt') expect(answer!.workflowProcess!.tracing).toHaveLength(1) expect(answer!.workflowProcess!.tracing[0]!.id).toBe('loop-bare') expect(answer!.workflowProcess!.tracing[0]!.node_id).toBe('n-loop-bare') @@ -1030,7 +1038,7 @@ describe('useChat – handleResume with bare prevChatTree (no humanInputFormData }) }) - const answer = result.current.chatList.find(item => item.id === 'bare-msg-nt') + const answer = result.current.chatList.find((item) => item.id === 'bare-msg-nt') expect(answer!.workflowProcess!.tracing).toHaveLength(1) expect(answer!.workflowProcess!.tracing[0]!.id).toBe('iter-bare') expect(answer!.workflowProcess!.tracing[0]!.node_id).toBe('n-iter-bare') @@ -1056,7 +1064,7 @@ describe('useChat – handleResume with bare prevChatTree (no humanInputFormData }) }) - const answer = result.current.chatList.find(item => item.id === 'bare-msg-nt') + const answer = result.current.chatList.find((item) => item.id === 'bare-msg-nt') expect(answer!.workflowProcess!.tracing).toHaveLength(1) expect(answer!.workflowProcess!.tracing[0]!.id).toBe('rtrace-bare') expect(answer!.workflowProcess!.tracing[0]!.node_id).toBe('rn-bare') @@ -1112,7 +1120,7 @@ describe('useChat – handleResume with bare prevChatTree (no humanInputFormData opts.onLoopStart({ data: { id: 'l1', node_id: 'nl1' } }) }) - const answer = hook.result.current.chatList.find(item => item.id === 'bare-nowp') + const answer = hook.result.current.chatList.find((item) => item.id === 'bare-nowp') expect(answer!.workflowProcess).toBeUndefined() }) @@ -1138,7 +1146,7 @@ describe('useChat – handleResume with bare prevChatTree (no humanInputFormData }) }) - const answer = result.current.chatList.find(item => item.id === 'bare-msg') + const answer = result.current.chatList.find((item) => item.id === 'bare-msg') const trace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'hn-with-trace') expect(trace!.status).toBe('paused') }) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-send.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-send.spec.ts index cadf5f6733cee8..f815463a3c9bad 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-send.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-send.spec.ts @@ -52,12 +52,15 @@ vi.mock('../../../../hooks', () => ({ })) vi.mock('../../../../hooks-store', () => ({ - useHooksStore: (selector: (state: { configsMap: null, accessControl: { canRun: boolean } }) => unknown) => selector({ - configsMap: null, - accessControl: { - canRun: mockHooksStoreState.canRun, - }, - }), + useHooksStore: ( + selector: (state: { configsMap: null; accessControl: { canRun: boolean } }) => unknown, + ) => + selector({ + configsMap: null, + accessControl: { + canRun: mockHooksStoreState.canRun, + }, + }), })) vi.mock('../../../../store', () => ({ @@ -142,12 +145,12 @@ describe('useChat – handleSend', () => { result.current.handleSend({ query: 'test question' }, {}) }) - const questionItem = result.current.chatList.find(item => item.content === 'test question') + const questionItem = result.current.chatList.find((item) => item.content === 'test question') expect(questionItem).toBeDefined() expect(questionItem!.isAnswer).toBe(false) const answerPlaceholder = result.current.chatList.find( - item => item.isAnswer && !item.isOpeningStatement && item.content === '', + (item) => item.isAnswer && !item.isOpeningStatement && item.content === '', ) expect(answerPlaceholder).toBeDefined() }) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-stop-restart.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-stop-restart.spec.ts index 2868471bd31634..7beaee43e4ed86 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-stop-restart.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-stop-restart.spec.ts @@ -137,9 +137,12 @@ describe('useChat – handleStop', () => { ) act(() => { - result.current.handleSend({ query: 'test' }, { - onGetSuggestedQuestions: mockGetSuggested, - }) + result.current.handleSend( + { query: 'test' }, + { + onGetSuggestedQuestions: mockGetSuggested, + }, + ) }) await act(async () => { diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/misc.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/misc.spec.ts index a4bd20ef9d0ace..aa730402c6c270 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/misc.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/misc.spec.ts @@ -185,10 +185,13 @@ describe('useChat – handleSwitchSibling', () => { }) act(() => { - result.current.handleSend({ - query: 'child', - parent_message_id: 'msg-parent', - }, {}) + result.current.handleSend( + { + query: 'child', + parent_message_id: 'msg-parent', + }, + {}, + ) }) act(() => { @@ -228,10 +231,16 @@ describe('useChat – handleSubmitHumanInputForm', () => { const { result } = renderHook(() => useChat({})) await act(async () => { - await result.current.handleSubmitHumanInputForm('token-123', { inputs: { field: 'value' }, action: 'approve' }) + await result.current.handleSubmitHumanInputForm('token-123', { + inputs: { field: 'value' }, + action: 'approve', + }) }) - expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('token-123', { inputs: { field: 'value' }, action: 'approve' }) + expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('token-123', { + inputs: { field: 'value' }, + action: 'approve', + }) }) }) @@ -320,23 +329,23 @@ describe('useChat – conversationId and setTargetMessageId', () => { const { result } = renderHook(() => useChat({}, undefined, prevChatTree)) const defaultList = result.current.chatList - expect(defaultList.some(item => item.id === 'a1')).toBe(true) + expect(defaultList.some((item) => item.id === 'a1')).toBe(true) act(() => { result.current.setTargetMessageId('a2-branch-a') }) const listA = result.current.chatList - expect(listA.some(item => item.id === 'a2-branch-a')).toBe(true) - expect(listA.some(item => item.id === 'a2-branch-b')).toBe(false) + expect(listA.some((item) => item.id === 'a2-branch-a')).toBe(true) + expect(listA.some((item) => item.id === 'a2-branch-b')).toBe(false) act(() => { result.current.setTargetMessageId('a2-branch-b') }) const listB = result.current.chatList - expect(listB.some(item => item.id === 'a2-branch-b')).toBe(true) - expect(listB.some(item => item.id === 'a2-branch-a')).toBe(false) + expect(listB.some((item) => item.id === 'a2-branch-b')).toBe(true) + expect(listB.some((item) => item.id === 'a2-branch-a')).toBe(false) }) }) @@ -373,10 +382,13 @@ describe('useChat – updateCurrentQAOnTree with parent_message_id', () => { }) act(() => { - result.current.handleSend({ - query: 'follow up', - parent_message_id: 'msg-1', - }, {}) + result.current.handleSend( + { + query: 'follow up', + parent_message_id: 'msg-1', + }, + {}, + ) }) expect(mockHandleRun).toHaveBeenCalledTimes(2) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/opening-statement.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/opening-statement.spec.ts index cb45029340b3eb..1e562b29923eef 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/opening-statement.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/opening-statement.spec.ts @@ -98,10 +98,9 @@ describe('workflow debug useChat – opening statement stability', () => { } const formSettings = { inputs: { name: 'Alice' }, inputsForm: [] } - const { result, rerender } = renderHook( - ({ fs }) => useChat(config, fs), - { initialProps: { fs: formSettings } }, - ) + const { result, rerender } = renderHook(({ fs }) => useChat(config, fs), { + initialProps: { fs: formSettings }, + }) const openerBefore = result.current.chatList[0] expect(openerBefore!.content).toBe('Hello Alice') @@ -118,10 +117,9 @@ describe('workflow debug useChat – opening statement stability', () => { suggested_questions: [], } - const { result, rerender } = renderHook( - ({ fs }) => useChat(config, fs), - { initialProps: { fs: { inputs: { name: 'Alice' }, inputsForm: [] } } }, - ) + const { result, rerender } = renderHook(({ fs }) => useChat(config, fs), { + initialProps: { fs: { inputs: { name: 'Alice' }, inputsForm: [] } }, + }) const openerBefore = result.current.chatList[0] expect(openerBefore!.content).toBe('Hello Alice') @@ -138,13 +136,15 @@ describe('workflow debug useChat – opening statement stability', () => { opening_statement: 'Updated welcome', suggested_questions: ['S1'], } - const prevChatTree = [{ - id: 'opening-statement', - content: 'old', - isAnswer: true, - isOpeningStatement: true, - suggestedQuestions: [], - }] + const prevChatTree = [ + { + id: 'opening-statement', + content: 'old', + isAnswer: true, + isOpeningStatement: true, + suggestedQuestions: [], + }, + ] const { result, rerender } = renderHook( ({ cfg }) => useChat(cfg, undefined, prevChatTree as ChatItemInTree[]), diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts index 3e2087a7619b69..d90d0381faf13a 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts @@ -83,9 +83,12 @@ describe('useChat – handleSend SSE callbacks', () => { function setupAndSend(config: any = {}) { const hook = renderHook(() => useChat(config)) act(() => { - hook.result.current.handleSend({ query: 'test' }, { - onGetSuggestedQuestions: vi.fn().mockResolvedValue({ data: ['q1'] }), - }) + hook.result.current.handleSend( + { query: 'test' }, + { + onGetSuggestedQuestions: vi.fn().mockResolvedValue({ data: ['q1'] }), + }, + ) }) return hook } @@ -122,7 +125,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.content).toContain('Hello') }) @@ -137,7 +142,7 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-123') + const answer = result.current.chatList.find((item) => item.id === 'msg-123') expect(answer).toBeDefined() }) @@ -188,7 +193,7 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'late-id') + const answer = result.current.chatList.find((item) => item.id === 'late-id') expect(answer).toBeDefined() }) @@ -211,7 +216,7 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const question = result.current.chatList.find(item => !item.isAnswer) + const question = result.current.chatList.find((item) => !item.isAnswer) expect(question!.id).toBe('question-msg-first') }) }) @@ -224,8 +229,12 @@ describe('useChat – handleSend SSE callbacks', () => { const { result } = setupAndSend() act(() => { - capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'let me ', node_id: 'llm' } }) - capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'think', node_id: 'llm' } }) + capturedCallbacks.onReasoning({ + data: { message_id: 'm-1', reasoning: 'let me ', node_id: 'llm' }, + }) + capturedCallbacks.onReasoning({ + data: { message_id: 'm-1', reasoning: 'think', node_id: 'llm' }, + }) }) const answer = findAnswer(result) @@ -238,20 +247,28 @@ describe('useChat – handleSend SSE callbacks', () => { const { result } = setupAndSend() act(() => { - capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'a', node_id: 'llm-1' } }) - capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'b', node_id: 'llm-2' } }) + capturedCallbacks.onReasoning({ + data: { message_id: 'm-1', reasoning: 'a', node_id: 'llm-1' }, + }) + capturedCallbacks.onReasoning({ + data: { message_id: 'm-1', reasoning: 'b', node_id: 'llm-2' }, + }) capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'c' } }) }) - expect(findAnswer(result)!.reasoningContent).toEqual({ 'llm-1': 'a', 'llm-2': 'b', '_': 'c' }) + expect(findAnswer(result)!.reasoningContent).toEqual({ 'llm-1': 'a', 'llm-2': 'b', _: 'c' }) }) it('should ignore empty reasoning and mark finished when is_final is set', () => { const { result } = setupAndSend() act(() => { - capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'done', node_id: 'llm' } }) - capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: '', node_id: 'llm', is_final: true } }) + capturedCallbacks.onReasoning({ + data: { message_id: 'm-1', reasoning: 'done', node_id: 'llm' }, + }) + capturedCallbacks.onReasoning({ + data: { message_id: 'm-1', reasoning: '', node_id: 'llm', is_final: true }, + }) }) const answer = findAnswer(result) @@ -302,7 +319,7 @@ describe('useChat – handleSend SSE callbacks', () => { await capturedCallbacks.onCompleted(true, 'Something went wrong') }) - const answer = result.current.chatList.find(item => item.id === 'msg-err') + const answer = result.current.chatList.find((item) => item.id === 'msg-err') expect(answer!.content).toBe('Something went wrong') expect(answer!.isError).toBe(true) }) @@ -329,9 +346,12 @@ describe('useChat – handleSend SSE callbacks', () => { }) act(() => { - hook.result.current.handleSend({ query: 'test' }, { - onGetSuggestedQuestions: mockGetSuggested, - }) + hook.result.current.handleSend( + { query: 'test' }, + { + onGetSuggestedQuestions: mockGetSuggested, + }, + ) }) await act(async () => { @@ -352,9 +372,12 @@ describe('useChat – handleSend SSE callbacks', () => { }) act(() => { - hook.result.current.handleSend({ query: 'test' }, { - onGetSuggestedQuestions: mockGetSuggested, - }) + hook.result.current.handleSend( + { query: 'test' }, + { + onGetSuggestedQuestions: mockGetSuggested, + }, + ) }) await act(async () => { @@ -394,7 +417,7 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-1') + const answer = result.current.chatList.find((item) => item.id === 'msg-1') expect(answer!.citation).toEqual([{ id: 'r1' }]) }) @@ -413,7 +436,7 @@ describe('useChat – handleSend SSE callbacks', () => { capturedCallbacks.onMessageEnd({ metadata: {}, files: [] }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-1') + const answer = result.current.chatList.find((item) => item.id === 'msg-1') expect(answer!.citation).toEqual([]) }) }) @@ -438,7 +461,7 @@ describe('useChat – handleSend SSE callbacks', () => { capturedCallbacks.onMessageEnd({ metadata: {}, files: [] }) }) - const answer = result.current.chatList.find(item => item.id === 'msg-1') + const answer = result.current.chatList.find((item) => item.id === 'msg-1') expect(answer!.content).toBe('replaced') }) }) @@ -456,7 +479,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.status).toBe('running') expect(answer!.workflowProcess!.tracing).toEqual([]) }) @@ -488,7 +513,9 @@ describe('useChat – handleSend SSE callbacks', () => { startNode('n1', 'trace-1') startWorkflow({ workflow_run_id: 'wfr-2', task_id: 'task-2' }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.status).toBe('running') expect(answer!.workflowProcess!.tracing.length).toBe(1) }) @@ -505,7 +532,7 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.id === 'wf-msg-id') + const answer = result.current.chatList.find((item) => item.id === 'wf-msg-id') expect(answer).toBeDefined() }) }) @@ -519,7 +546,9 @@ describe('useChat – handleSend SSE callbacks', () => { capturedCallbacks.onWorkflowFinished({ data: { status: 'succeeded' } }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.status).toBe('succeeded') }) @@ -528,10 +557,14 @@ describe('useChat – handleSend SSE callbacks', () => { startWorkflow() act(() => { - capturedCallbacks.onWorkflowFinished({ data: { status: 'failed', error: 'Invalid upload file' } }) + capturedCallbacks.onWorkflowFinished({ + data: { status: 'failed', error: 'Invalid upload file' }, + }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.status).toBe('failed') expect(answer!.workflowProcess!.error).toBe('Invalid upload file') }) @@ -548,7 +581,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.tracing).toHaveLength(1) const trace = answer!.workflowProcess!.tracing[0] expect(trace!.id).toBe('iter-1') @@ -572,7 +607,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) const trace = answer!.workflowProcess!.tracing.find((t: any) => t.id === 'iter-1') expect(trace).toBeDefined() expect(trace!.node_id).toBe('n-iter') @@ -595,7 +632,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.tracing).toHaveLength(1) expect((answer!.workflowProcess!.tracing[0] as any).output).toBeUndefined() }) @@ -612,7 +651,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.tracing).toHaveLength(1) const trace = answer!.workflowProcess!.tracing[0] expect(trace!.id).toBe('loop-1') @@ -636,7 +677,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.tracing).toHaveLength(1) const trace = answer!.workflowProcess!.tracing[0] expect(trace!.id).toBe('loop-1') @@ -660,7 +703,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.tracing).toHaveLength(1) expect((answer!.workflowProcess!.tracing[0] as any).output).toBeUndefined() }) @@ -672,7 +717,9 @@ describe('useChat – handleSend SSE callbacks', () => { startWorkflow() startNode('node-1', 'trace-1') - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.tracing).toHaveLength(1) const trace = answer!.workflowProcess!.tracing[0] expect(trace!.id).toBe('trace-1') @@ -686,7 +733,9 @@ describe('useChat – handleSend SSE callbacks', () => { startNode('node-1', 'trace-1') startNode('node-1', 'trace-1-v2') - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.tracing).toHaveLength(1) const trace = answer!.workflowProcess!.tracing[0] expect(trace!.id).toBe('trace-1-v2') @@ -704,7 +753,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.tracing).toHaveLength(1) const trace = answer!.workflowProcess!.tracing[0] expect(trace!.id).toBe('retry-1') @@ -719,11 +770,18 @@ describe('useChat – handleSend SSE callbacks', () => { act(() => { capturedCallbacks.onNodeFinished({ - data: { node_id: 'node-1', id: 'trace-1', status: 'succeeded', outputs: { text: 'done' } }, + data: { + node_id: 'node-1', + id: 'trace-1', + status: 'succeeded', + outputs: { text: 'done' }, + }, }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.tracing).toHaveLength(1) const trace = answer!.workflowProcess!.tracing[0] expect(trace!.id).toBe('trace-1') @@ -742,7 +800,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.tracing).toHaveLength(1) const trace = answer!.workflowProcess!.tracing[0] expect(trace!.id).toBe('trace-1') @@ -767,8 +827,12 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) - const agentTrace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'agent-node') + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) + const agentTrace = answer!.workflowProcess!.tracing.find( + (t: any) => t.node_id === 'agent-node', + ) expect(agentTrace!.execution_metadata!.agent_log).toHaveLength(1) }) @@ -782,8 +846,12 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) - const agentTrace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'agent-node') + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) + const agentTrace = answer!.workflowProcess!.tracing.find( + (t: any) => t.node_id === 'agent-node', + ) expect(agentTrace!.execution_metadata!.agent_log).toHaveLength(1) }) @@ -799,8 +867,12 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) - const agentTrace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'agent-node') + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) + const agentTrace = answer!.workflowProcess!.tracing.find( + (t: any) => t.node_id === 'agent-node', + ) expect(agentTrace!.execution_metadata!.agent_log).toHaveLength(1) expect((agentTrace!.execution_metadata!.agent_log as any[])[0].content).toBe('v2') }) @@ -817,8 +889,12 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) - const agentTrace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'agent-node') + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) + const agentTrace = answer!.workflowProcess!.tracing.find( + (t: any) => t.node_id === 'agent-node', + ) expect(agentTrace!.execution_metadata!.agent_log).toHaveLength(2) }) @@ -841,11 +917,18 @@ describe('useChat – handleSend SSE callbacks', () => { act(() => { capturedCallbacks.onHumanInputRequired({ - data: { node_id: 'human-node', form_token: 'token-1', form_content: '{{#$output.answer#}}', inputs: [] }, + data: { + node_id: 'human-node', + form_token: 'token-1', + form_content: '{{#$output.answer#}}', + inputs: [], + }, }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.humanInputFormDataList).toHaveLength(1) expect(answer!.humanInputFormDataList![0]!.node_id).toBe('human-node') expect((answer!.humanInputFormDataList![0] as any).form_token).toBe('token-1') @@ -858,7 +941,12 @@ describe('useChat – handleSend SSE callbacks', () => { act(() => { capturedCallbacks.onHumanInputRequired({ - data: { node_id: 'human-node', form_token: 'token-1', form_content: '{{#$output.answer#}}', inputs: [] }, + data: { + node_id: 'human-node', + form_token: 'token-1', + form_content: '{{#$output.answer#}}', + inputs: [], + }, }) }) @@ -868,7 +956,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.humanInputFormDataList).toHaveLength(1) expect((answer!.humanInputFormDataList![0] as any).form_token).toBe('token-2') }) @@ -889,7 +979,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.humanInputFormDataList).toHaveLength(2) expect(answer!.humanInputFormDataList![0]!.node_id).toBe('human-node-1') expect(answer!.humanInputFormDataList![1]!.node_id).toBe('human-node-2') @@ -906,7 +998,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) const trace = answer!.workflowProcess!.tracing.find((t: any) => t.node_id === 'human-node') expect(trace!.status).toBe('paused') }) @@ -919,7 +1013,12 @@ describe('useChat – handleSend SSE callbacks', () => { act(() => { capturedCallbacks.onHumanInputRequired({ - data: { node_id: 'human-node', form_token: 'token-1', form_content: '{{#$output.answer#}}', inputs: [] }, + data: { + node_id: 'human-node', + form_token: 'token-1', + form_content: '{{#$output.answer#}}', + inputs: [], + }, }) }) @@ -929,15 +1028,19 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.humanInputFormDataList).toHaveLength(0) expect(answer!.humanInputFilledFormDataList).toHaveLength(1) expect(answer!.humanInputFilledFormDataList![0]!.node_id).toBe('human-node') expect(answer!.humanInputFilledFormDataList![0]!.submitted_data).toEqual({ answer: 'yes' }) - expect(answer!.humanInputFilledFormDataList![0]).toEqual(expect.objectContaining({ - form_content: '{{#$output.answer#}}', - inputs: [], - })) + expect(answer!.humanInputFilledFormDataList![0]).toEqual( + expect.objectContaining({ + form_content: '{{#$output.answer#}}', + inputs: [], + }), + ) }) }) @@ -958,7 +1061,9 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) const form = answer!.humanInputFormDataList!.find((f: any) => f.node_id === 'human-node') expect(form!.expiration_time).toBe('2025-01-01T00:00:00Z') }) @@ -973,7 +1078,9 @@ describe('useChat – handleSend SSE callbacks', () => { capturedCallbacks.onWorkflowPaused({ data: {} }) }) - const answer = result.current.chatList.find(item => item.isAnswer && !item.isOpeningStatement) + const answer = result.current.chatList.find( + (item) => item.isAnswer && !item.isOpeningStatement, + ) expect(answer!.workflowProcess!.status).toBe('paused') }) }) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/index.spec.tsx b/web/app/components/workflow/panel/debug-and-preview/__tests__/index.spec.tsx index 4554dc36c4dfaf..d90737bb3e041d 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/index.spec.tsx @@ -19,9 +19,11 @@ const createMockLocalStorage = () => { delete storage[key] }), clear: vi.fn(() => { - Object.keys(storage).forEach(key => delete storage[key]) + Object.keys(storage).forEach((key) => delete storage[key]) }), - get storage() { return { ...storage } }, + get storage() { + return { ...storage } + }, } } @@ -32,8 +34,7 @@ const createPreviewPanelManager = () => { return { updateWidth: (width: number, source: PanelWidthSource = 'user') => { const newValue = Math.max(400, Math.min(width, 800)) - if (source === 'user') - localStorage.setItem(storageKey, `${newValue}`) + if (source === 'user') localStorage.setItem(storageKey, `${newValue}`) return newValue }, diff --git a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx index 0829f605421944..6939f7b6f15955 100644 --- a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx @@ -11,14 +11,8 @@ import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/cha import { useFeatures } from '@/app/components/base/features/hooks' import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' import { useEventEmitterContextContext } from '@/context/event-emitter' -import { - fetchSuggestedQuestions, - stopChatMessageResponding, -} from '@/service/debug' -import { - useStore, - useWorkflowStore, -} from '../../store' +import { fetchSuggestedQuestions, stopChatMessageResponding } from '@/service/debug' +import { useStore, useWorkflowStore } from '../../store' import { BlockEnum, WorkflowRunningStatus } from '../../types' import ConversationVariableModal from './conversation-variable-modal' import Empty from './empty' @@ -32,41 +26,40 @@ type ChatWrapperProps = { onHide: () => void } -const ChatWrapper = ( - { - ref, - showConversationVariableModal, - onConversationModalHide, - showInputsFieldsPanel, - onHide, - }: ChatWrapperProps & { - ref: React.RefObject<ChatWrapperRefType> - }, -) => { +const ChatWrapper = ({ + ref, + showConversationVariableModal, + onConversationModalHide, + showInputsFieldsPanel, + onHide, +}: ChatWrapperProps & { + ref: React.RefObject<ChatWrapperRefType> +}) => { const nodes = useNodes<StartNodeType>() - const startNode = nodes.find(node => node.data.type === BlockEnum.Start) + const startNode = nodes.find((node) => node.data.type === BlockEnum.Start) const startVariables = startNode?.data.variables - const appDetail = useAppStore(s => s.appDetail) + const appDetail = useAppStore((s) => s.appDetail) const workflowStore = useWorkflowStore() - const inputs = useStore(s => s.inputs) - const setInputs = useStore(s => s.setInputs) + const inputs = useStore((s) => s.inputs) + const setInputs = useStore((s) => s.setInputs) const initialInputs = useMemo(() => { const initInputs: Record<string, any> = {} if (startVariables) { startVariables.forEach((variable) => { - if (variable.default) - initInputs[variable.variable] = variable.default + if (variable.default) initInputs[variable.variable] = variable.default }) } return initInputs }, [startVariables]) - const features = useFeatures(s => s.features) + const features = useFeatures((s) => s.features) const config = useMemo(() => { return { - opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '', - suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [], + opening_statement: features.opening?.enabled ? features.opening?.opening_statement || '' : '', + suggested_questions: features.opening?.enabled + ? features.opening?.suggested_questions || [] + : [], suggested_questions_after_answer: features.suggested, text_to_speech: features.text2speech, speech_to_text: features.speech2text, @@ -74,8 +67,16 @@ const ChatWrapper = ( sensitive_word_avoidance: features.moderation, file_upload: features.file, } - }, [features.opening, features.suggested, features.text2speech, features.speech2text, features.citation, features.moderation, features.file]) - const setShowFeaturesPanel = useStore(s => s.setShowFeaturesPanel) + }, [ + features.opening, + features.suggested, + features.text2speech, + features.speech2text, + features.citation, + features.moderation, + features.file, + ]) + const setShowFeaturesPanel = useStore((s) => s.setShowFeaturesPanel) const { conversationId, @@ -95,7 +96,7 @@ const ChatWrapper = ( inputsForm: (startVariables || []) as any, }, [], - taskId => stopChatMessageResponding(appDetail!.id, taskId), + (taskId) => stopChatMessageResponding(appDetail!.id, taskId), ) const handleRestartChat = useCallback(() => { @@ -103,46 +104,70 @@ const ChatWrapper = ( setInputs(initialInputs) }, [handleRestart, setInputs, initialInputs]) - const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { - handleSend( - { - query: message, - files, - inputs: workflowStore.getState().inputs, - conversation_id: conversationId, - parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || undefined, - }, - { - onGetSuggestedQuestions: (messageId, getAbortController) => fetchSuggestedQuestions(appDetail!.id, messageId, getAbortController), - }, - ) - }, [handleSend, workflowStore, conversationId, chatList, appDetail]) + const doSend: OnSend = useCallback( + (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { + handleSend( + { + query: message, + files, + inputs: workflowStore.getState().inputs, + conversation_id: conversationId, + parent_message_id: + (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || undefined, + }, + { + onGetSuggestedQuestions: (messageId, getAbortController) => + fetchSuggestedQuestions(appDetail!.id, messageId, getAbortController), + }, + ) + }, + [handleSend, workflowStore, conversationId, chatList, appDetail], + ) - const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => { - const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! - const parentAnswer = chatList.find(item => item.id === question.parentMessageId) - doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) - }, [chatList, doSend]) + const doRegenerate = useCallback( + (chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => { + const question = editedQuestion + ? chatItem + : chatList.find((item) => item.id === chatItem.parentMessageId)! + const parentAnswer = chatList.find((item) => item.id === question.parentMessageId) + doSend( + editedQuestion ? editedQuestion.message : question.content, + editedQuestion ? editedQuestion.files : question.message_files, + true, + isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, + ) + }, + [chatList, doSend], + ) - const doSwitchSibling = useCallback((siblingMessageId: string) => { - handleSwitchSibling(siblingMessageId, { - onGetSuggestedQuestions: (messageId, getAbortController) => fetchSuggestedQuestions(appDetail!.id, messageId, getAbortController), - }) - }, [handleSwitchSibling, appDetail]) + const doSwitchSibling = useCallback( + (siblingMessageId: string) => { + handleSwitchSibling(siblingMessageId, { + onGetSuggestedQuestions: (messageId, getAbortController) => + fetchSuggestedQuestions(appDetail!.id, messageId, getAbortController), + }) + }, + [handleSwitchSibling, appDetail], + ) - const doHumanInputFormSubmit = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => { - await handleSubmitHumanInputForm(formToken, formData) - }, [handleSubmitHumanInputForm]) + const doHumanInputFormSubmit = useCallback( + async (formToken: string, formData: HumanInputFormSubmitData) => { + await handleSubmitHumanInputForm(formToken, formData) + }, + [handleSubmitHumanInputForm], + ) const inputDisabled = useMemo(() => { const latestMessage = chatList[chatList.length - 1] - return latestMessage?.isAnswer && (latestMessage.workflowProcess?.status === WorkflowRunningStatus.Paused) + return ( + latestMessage?.isAnswer && + latestMessage.workflowProcess?.status === WorkflowRunningStatus.Paused + ) }, [chatList]) const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((v: any) => { - if (v.type === EVENT_WORKFLOW_STOP) - handleStop() + if (v.type === EVENT_WORKFLOW_STOP) handleStop() }) useImperativeHandle(ref, () => { @@ -161,17 +186,18 @@ const ChatWrapper = ( }, [initialInputs]) useEffect(() => { - if (isResponding) - onHide() + if (isResponding) onHide() }, [isResponding, onHide]) return ( <> <Chat - config={{ - ...config, - supportCitationHitInfo: true, - } as any} + config={ + { + ...config, + supportCitationHitInfo: true, + } as any + } chatList={chatList} isResponding={isResponding} chatContainerClassName="px-3" @@ -188,16 +214,12 @@ const ChatWrapper = ( onStopResponding={handleStop} onHumanInputFormSubmit={doHumanInputFormSubmit} getHumanInputNodeData={getHumanInputNodeData} - chatNode={( + chatNode={ <> {showInputsFieldsPanel && <UserInput />} - { - !chatList.length && ( - <Empty /> - ) - } + {!chatList.length && <Empty />} </> - )} + } noSpacing suggestedQuestions={suggestedQuestions} showPromptLog diff --git a/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx b/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx index e549859b3500c8..7b6439b17c45fa 100644 --- a/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx @@ -1,7 +1,5 @@ 'use client' -import type { - ConversationVariable, -} from '@/app/components/workflow/types' +import type { ConversationVariable } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { RiCloseLine } from '@remixicon/react' @@ -11,10 +9,7 @@ import { capitalize } from 'es-toolkit/string' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { - Copy, - CopyCheck, -} from '@/app/components/base/icons/src/vender/line/files' +import { Copy, CopyCheck } from '@/app/components/base/icons/src/vender/line/files' import { BubbleX } from '@/app/components/base/icons/src/vender/line/others' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' @@ -28,17 +23,16 @@ type Props = Readonly<{ onHide: () => void }> -const ConversationVariableModal = ({ - conversationID, - onHide, -}: Props) => { +const ConversationVariableModal = ({ conversationID, onHide }: Props) => { const { t } = useTranslation() const { formatTime } = useTimestamp() - const varList = useStore(s => s.conversationVariables) as ConversationVariable[] - const appID = useStore(s => s.appId) + const varList = useStore((s) => s.conversationVariables) as ConversationVariable[] + const appID = useStore((s) => s.appId) const [currentVar, setCurrentVar] = React.useState<ConversationVariable>(varList[0]!) const [latestValueMap, setLatestValueMap] = React.useState<Record<string, string>>({}) - const [latestValueTimestampMap, setLatestValueTimestampMap] = React.useState<Record<string, number>>({}) + const [latestValueTimestampMap, setLatestValueTimestampMap] = React.useState< + Record<string, number> + >({}) const getChatVarLatestValues = useCallback(async () => { if (conversationID && varList.length > 0) { @@ -76,11 +70,15 @@ const ConversationVariableModal = ({ return ( <Dialog open> - <DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', cn('h-[min(640px,calc(100dvh-2rem))] max-h-none! w-[920px] max-w-[calc(100vw-2rem)] p-0'))}> - + <DialogContent + className={cn( + 'w-full overflow-hidden! border-none text-left align-middle', + cn('h-[min(640px,calc(100dvh-2rem))] max-h-none! w-[920px] max-w-[calc(100vw-2rem)] p-0'), + )} + > <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="absolute top-4 right-4 cursor-pointer border-none bg-transparent p-2 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={onHide} > @@ -89,17 +87,36 @@ const ConversationVariableModal = ({ <div className="flex size-full"> {/* LEFT */} <div className="flex h-full w-[224px] shrink-0 flex-col border-r border-divider-burn bg-background-sidenav-bg"> - <div className="shrink-0 pt-5 pr-4 pb-3 pl-5 system-xl-semibold text-text-primary">{t($ => $['chatVariable.panelTitle'], { ns: 'workflow' })}</div> + <div className="shrink-0 pt-5 pr-4 pb-3 pl-5 system-xl-semibold text-text-primary"> + {t(($) => $['chatVariable.panelTitle'], { ns: 'workflow' })} + </div> <div className="grow overflow-y-auto px-3 py-2"> - {varList.map(chatVar => ( + {varList.map((chatVar) => ( <button key={chatVar.id} type="button" - className={cn('group mb-0.5 flex w-full cursor-pointer items-center rounded-lg border-none bg-transparent p-2 text-left hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', currentVar.id === chatVar.id && 'bg-state-base-hover')} + className={cn( + 'group mb-0.5 flex w-full cursor-pointer items-center rounded-lg border-none bg-transparent p-2 text-left hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden', + currentVar.id === chatVar.id && 'bg-state-base-hover', + )} onClick={() => setCurrentVar(chatVar)} > - <BubbleX className={cn('mr-1 size-4 shrink-0 text-text-tertiary group-hover:text-util-colors-teal-teal-700', currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700')} aria-hidden="true" /> - <div title={chatVar.name} className={cn('truncate system-sm-medium text-text-tertiary group-hover:text-util-colors-teal-teal-700', currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700')}>{chatVar.name}</div> + <BubbleX + className={cn( + 'mr-1 size-4 shrink-0 text-text-tertiary group-hover:text-util-colors-teal-teal-700', + currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700', + )} + aria-hidden="true" + /> + <div + title={chatVar.name} + className={cn( + 'truncate system-sm-medium text-text-tertiary group-hover:text-util-colors-teal-teal-700', + currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700', + )} + > + {chatVar.name} + </div> </button> ))} </div> @@ -109,56 +126,70 @@ const ConversationVariableModal = ({ <div className="shrink-0 p-4 pb-2"> <div className="flex items-center gap-1 py-1"> <div className="system-xl-semibold text-text-primary">{currentVar.name}</div> - <div className="system-xs-medium text-text-tertiary">{capitalize(currentVar.value_type)}</div> + <div className="system-xs-medium text-text-tertiary"> + {capitalize(currentVar.value_type)} + </div> </div> </div> <div className="flex h-0 grow flex-col p-4 pt-2"> <div className="mb-2 flex shrink-0 items-center gap-2"> - <div className="shrink-0 system-xs-medium-uppercase text-text-tertiary">{t($ => $['chatVariable.storedContent'], { ns: 'workflow' }).toLocaleUpperCase()}</div> + <div className="shrink-0 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['chatVariable.storedContent'], { + ns: 'workflow', + }).toLocaleUpperCase()} + </div> <div className="h-px grow" style={{ - background: 'linear-gradient(to right, rgba(16, 24, 40, 0.08) 0%, rgba(255, 255, 255) 100%)', + background: + 'linear-gradient(to right, rgba(16, 24, 40, 0.08) 0%, rgba(255, 255, 255) 100%)', }} - > - </div> + ></div> {!!latestValueTimestampMap[currentVar.id] && ( <div className="shrink-0 system-xs-regular text-text-tertiary"> - {t($ => $['chatVariable.updatedAt'], { ns: 'workflow' })} - {formatTime(latestValueTimestampMap[currentVar.id]!, t($ => $.dateTimeFormat, { ns: 'appLog' }) as string)} + {t(($) => $['chatVariable.updatedAt'], { ns: 'workflow' })} + {formatTime( + latestValueTimestampMap[currentVar.id]!, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + )} </div> )} </div> <div className="grow overflow-y-auto"> - {currentVar.value_type !== ChatVarType.Number && currentVar.value_type !== ChatVarType.String && ( - <div className="flex h-full flex-col rounded-lg bg-components-input-bg-normal px-2 pb-2"> - <div className="flex h-7 shrink-0 items-center justify-between pt-1 pr-2 pl-3"> - <div className="system-xs-semibold text-text-secondary">JSON</div> - <div className="flex items-center p-1"> - {!isCopied - ? ( - <Copy className="size-4 cursor-pointer text-text-tertiary" onClick={handleCopy} /> - ) - : ( - <CopyCheck className="size-4 text-text-tertiary" /> - )} + {currentVar.value_type !== ChatVarType.Number && + currentVar.value_type !== ChatVarType.String && ( + <div className="flex h-full flex-col rounded-lg bg-components-input-bg-normal px-2 pb-2"> + <div className="flex h-7 shrink-0 items-center justify-between pt-1 pr-2 pl-3"> + <div className="system-xs-semibold text-text-secondary">JSON</div> + <div className="flex items-center p-1"> + {!isCopied ? ( + <Copy + className="size-4 cursor-pointer text-text-tertiary" + onClick={handleCopy} + /> + ) : ( + <CopyCheck className="size-4 text-text-tertiary" /> + )} + </div> + </div> + <div className="grow pl-4"> + <CodeEditor + readOnly + noWrapper + isExpand + language={CodeLanguage.json} + value={latestValueMap[currentVar.id] || ''} + isJSONStringifyBeauty + /> </div> </div> - <div className="grow pl-4"> - <CodeEditor - readOnly - noWrapper - isExpand - language={CodeLanguage.json} - value={latestValueMap[currentVar.id] || ''} - isJSONStringifyBeauty - /> - </div> + )} + {(currentVar.value_type === ChatVarType.Number || + currentVar.value_type === ChatVarType.String) && ( + <div className="h-full overflow-x-hidden overflow-y-auto rounded-lg bg-components-input-bg-normal px-4 py-3 system-md-regular text-components-input-text-filled"> + {latestValueMap[currentVar.id] || ''} </div> )} - {(currentVar.value_type === ChatVarType.Number || currentVar.value_type === ChatVarType.String) && ( - <div className="h-full overflow-x-hidden overflow-y-auto rounded-lg bg-components-input-bg-normal px-4 py-3 system-md-regular text-components-input-text-filled">{latestValueMap[currentVar.id] || ''}</div> - )} </div> </div> </div> diff --git a/web/app/components/workflow/panel/debug-and-preview/empty.tsx b/web/app/components/workflow/panel/debug-and-preview/empty.tsx index fdd209bd652009..488565ea74f8cd 100644 --- a/web/app/components/workflow/panel/debug-and-preview/empty.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/empty.tsx @@ -10,7 +10,7 @@ const Empty = () => { <ChatBotSlim className="size-12 text-gray-300" /> </div> <div className="w-[256px] text-center text-[13px] text-gray-400"> - {t($ => $['common.previewPlaceholder'], { ns: 'workflow' })} + {t(($) => $['common.previewPlaceholder'], { ns: 'workflow' })} </div> </div> ) diff --git a/web/app/components/workflow/panel/debug-and-preview/hooks.ts b/web/app/components/workflow/panel/debug-and-preview/hooks.ts index fd1e7336043369..00b261eddb33e0 100644 --- a/web/app/components/workflow/panel/debug-and-preview/hooks.ts +++ b/web/app/components/workflow/panel/debug-and-preview/hooks.ts @@ -1,54 +1,39 @@ import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type' import type { InputForm } from '@/app/components/base/chat/chat/type' -import type { - ChatItem, - ChatItemInTree, - Inputs, -} from '@/app/components/base/chat/types' +import type { ChatItem, ChatItemInTree, Inputs } from '@/app/components/base/chat/types' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { IOtherOptions } from '@/service/base' import type { ReasoningChunkResponse } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' import { uniqBy } from 'es-toolkit/compat' import { produce, setAutoFreeze } from 'immer' -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import { enrichSubmittedHumanInputFormData } from '@/app/components/base/chat/chat/answer/human-input-content/submitted-utils' -import { - getProcessedInputs, - processOpeningStatement, -} from '@/app/components/base/chat/chat/utils' +import { getProcessedInputs, processOpeningStatement } from '@/app/components/base/chat/chat/utils' import { getThreadMessages } from '@/app/components/base/chat/utils' import { getProcessedFiles, getProcessedFilesFromResponse, } from '@/app/components/base/file-uploader/utils' -import { - CUSTOM_NODE, -} from '@/app/components/workflow/constants' +import { CUSTOM_NODE } from '@/app/components/workflow/constants' import { sseGet } from '@/service/base' import { useInvalidAllLastRun } from '@/service/use-workflow' import { submitHumanInputForm } from '@/service/workflow' import { TransferMethod } from '@/types/app' import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../../constants' -import { - useSetWorkflowVarsWithValue, - useWorkflowRun, -} from '../../hooks' +import { useSetWorkflowVarsWithValue, useWorkflowRun } from '../../hooks' import { useHooksStore } from '../../hooks-store' import { useWorkflowStore } from '../../store' import { NodeRunningStatus, WorkflowRunningStatus } from '../../types' type GetAbortController = (abortController: AbortController) => void type SendCallback = { - onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any> + onGetSuggestedQuestions?: ( + responseItemId: string, + getAbortController: GetAbortController, + ) => Promise<any> } export const useChat = ( config: any, @@ -68,16 +53,13 @@ export const useChat = ( const [isResponding, setIsResponding] = useState(false) const isRespondingRef = useRef(false) const workflowEventsAbortControllerRef = useRef<AbortController | null>(null) - const configsMap = useHooksStore(s => s.configsMap) - const canRun = useHooksStore(s => s.accessControl.canRun) + const configsMap = useHooksStore((s) => s.configsMap) + const canRun = useHooksStore((s) => s.accessControl.canRun) const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId) const { fetchInspectVars } = useSetWorkflowVarsWithValue() const [suggestedQuestions, setSuggestedQuestions] = useState<string[]>([]) const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null) - const { - setIterTimes, - setLoopTimes, - } = workflowStore.getState() + const { setIterTimes, setLoopTimes } = workflowStore.getState() const store = useStoreApi() const handleResponding = useCallback((isResponding: boolean) => { @@ -88,11 +70,21 @@ export const useChat = ( const [chatTree, setChatTree] = useState<ChatItemInTree[]>(prevChatTree || []) const chatTreeRef = useRef<ChatItemInTree[]>(chatTree) const [targetMessageId, setTargetMessageId] = useState<string>() - const threadMessages = useMemo(() => getThreadMessages(chatTree, targetMessageId), [chatTree, targetMessageId]) + const threadMessages = useMemo( + () => getThreadMessages(chatTree, targetMessageId), + [chatTree, targetMessageId], + ) - const getIntroduction = useCallback((str: string) => { - return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || []) - }, [formSettings?.inputs, formSettings?.inputsForm]) + const getIntroduction = useCallback( + (str: string) => { + return processOpeningStatement( + str, + formSettings?.inputs || {}, + formSettings?.inputsForm || [], + ) + }, + [formSettings?.inputs, formSettings?.inputsForm], + ) const processedOpeningContent = config?.opening_statement ? getIntroduction(config.opening_statement) @@ -102,27 +94,25 @@ export const useChat = ( : undefined const openingStatementItem = useMemo<ChatItemInTree | null>(() => { - if (!processedOpeningContent) - return null + if (!processedOpeningContent) return null return { id: 'opening-statement', content: processedOpeningContent, isAnswer: true, isOpeningStatement: true, suggestedQuestions: processedSuggestionsKey - ? JSON.parse(processedSuggestionsKey) as string[] + ? (JSON.parse(processedSuggestionsKey) as string[]) : undefined, } }, [processedOpeningContent, processedSuggestionsKey]) const threadOpener = useMemo( - () => threadMessages.find(item => item.isOpeningStatement) ?? null, + () => threadMessages.find((item) => item.isOpeningStatement) ?? null, [threadMessages], ) const mergedOpeningItem = useMemo<ChatItemInTree | null>(() => { - if (!threadOpener || !openingStatementItem) - return null + if (!threadOpener || !openingStatementItem) return null return { ...threadOpener, content: openingStatementItem.content, @@ -134,11 +124,9 @@ export const useChat = ( const chatList = useMemo(() => { const ret = [...threadMessages] if (openingStatementItem) { - const index = threadMessages.findIndex(item => item.isOpeningStatement) - if (index > -1 && mergedOpeningItem) - ret[index] = mergedOpeningItem - else if (index === -1) - ret.unshift(openingStatementItem) + const index = threadMessages.findIndex((item) => item.isOpeningStatement) + if (index > -1 && mergedOpeningItem) ret[index] = mergedOpeningItem + else if (index === -1) ret.unshift(openingStatementItem) } return ret }, [threadMessages, openingStatementItem, mergedOpeningItem]) @@ -151,55 +139,54 @@ export const useChat = ( }, []) /** Find the target node by bfs and then operate on it */ - const produceChatTreeNode = useCallback((targetId: string, operation: (node: ChatItemInTree) => void) => { - return produce(chatTreeRef.current, (draft) => { - const queue: ChatItemInTree[] = [...draft] - while (queue.length > 0) { - const current = queue.shift()! - if (current.id === targetId) { - operation(current) - break + const produceChatTreeNode = useCallback( + (targetId: string, operation: (node: ChatItemInTree) => void) => { + return produce(chatTreeRef.current, (draft) => { + const queue: ChatItemInTree[] = [...draft] + while (queue.length > 0) { + const current = queue.shift()! + if (current.id === targetId) { + operation(current) + break + } + if (current.children) queue.push(...current.children) } - if (current.children) - queue.push(...current.children) - } - }) - }, []) + }) + }, + [], + ) type UpdateChatTreeNode = { (id: string, fields: Partial<ChatItemInTree>): void (id: string, update: (node: ChatItemInTree) => void): void } - const updateChatTreeNode: UpdateChatTreeNode = useCallback(( - id: string, - fieldsOrUpdate: Partial<ChatItemInTree> | ((node: ChatItemInTree) => void), - ) => { - const nextState = produceChatTreeNode(id, (node) => { - if (typeof fieldsOrUpdate === 'function') { - fieldsOrUpdate(node) - } - else { - Object.keys(fieldsOrUpdate).forEach((key) => { - (node as any)[key] = (fieldsOrUpdate as any)[key] - }) - } - }) - setChatTree(nextState) - chatTreeRef.current = nextState - }, [produceChatTreeNode]) + const updateChatTreeNode: UpdateChatTreeNode = useCallback( + (id: string, fieldsOrUpdate: Partial<ChatItemInTree> | ((node: ChatItemInTree) => void)) => { + const nextState = produceChatTreeNode(id, (node) => { + if (typeof fieldsOrUpdate === 'function') { + fieldsOrUpdate(node) + } else { + Object.keys(fieldsOrUpdate).forEach((key) => { + ;(node as any)[key] = (fieldsOrUpdate as any)[key] + }) + } + }) + setChatTree(nextState) + chatTreeRef.current = nextState + }, + [produceChatTreeNode], + ) const handleStop = useCallback(() => { hasStopRespondedRef.current = true handleResponding(false) - if (stopChat && taskIdRef.current) - stopChat(taskIdRef.current) + if (stopChat && taskIdRef.current) stopChat(taskIdRef.current) setIterTimes(DEFAULT_ITER_TIMES) setLoopTimes(DEFAULT_LOOP_TIMES) if (suggestedQuestionsAbortControllerRef.current) suggestedQuestionsAbortControllerRef.current.abort() - if (workflowEventsAbortControllerRef.current) - workflowEventsAbortControllerRef.current.abort() + if (workflowEventsAbortControllerRef.current) workflowEventsAbortControllerRef.current.abort() }, [handleResponding, setIterTimes, setLoopTimes, stopChat]) const handleRestart = useCallback(() => { @@ -210,139 +197,139 @@ export const useChat = ( setLoopTimes(DEFAULT_LOOP_TIMES) setChatTree([]) setSuggestedQuestions([]) - }, [ - handleStop, - setIterTimes, - setLoopTimes, - ]) + }, [handleStop, setIterTimes, setLoopTimes]) - const updateCurrentQAOnTree = useCallback(({ - parentId, - responseItem, - placeholderQuestionId, - questionItem, - }: { - parentId?: string - responseItem: ChatItem - placeholderQuestionId: string - questionItem: ChatItem - }) => { - let nextState: ChatItemInTree[] - const currentQA = { ...questionItem, children: [{ ...responseItem, children: [] }] } - if (!parentId && !chatTree.some(item => [placeholderQuestionId, questionItem.id].includes(item.id))) { - // QA whose parent is not provided is considered as a first message of the conversation, - // and it should be a root node of the chat tree - nextState = produce(chatTree, (draft) => { - draft.push(currentQA) - }) - } - else { - // find the target QA in the tree and update it; if not found, insert it to its parent node - nextState = produceChatTreeNode(parentId!, (parentNode) => { - const questionNodeIndex = parentNode.children!.findIndex(item => [placeholderQuestionId, questionItem.id].includes(item.id)) - if (questionNodeIndex === -1) - parentNode.children!.push(currentQA) - else - parentNode.children![questionNodeIndex] = currentQA - }) - } - setChatTree(nextState) - chatTreeRef.current = nextState - }, [chatTree, produceChatTreeNode]) - - const handleSend = useCallback(( - params: { - query: string - files?: FileEntity[] - parent_message_id?: string - [key: string]: any + const updateCurrentQAOnTree = useCallback( + ({ + parentId, + responseItem, + placeholderQuestionId, + questionItem, + }: { + parentId?: string + responseItem: ChatItem + placeholderQuestionId: string + questionItem: ChatItem + }) => { + let nextState: ChatItemInTree[] + const currentQA = { ...questionItem, children: [{ ...responseItem, children: [] }] } + if ( + !parentId && + !chatTree.some((item) => [placeholderQuestionId, questionItem.id].includes(item.id)) + ) { + // QA whose parent is not provided is considered as a first message of the conversation, + // and it should be a root node of the chat tree + nextState = produce(chatTree, (draft) => { + draft.push(currentQA) + }) + } else { + // find the target QA in the tree and update it; if not found, insert it to its parent node + nextState = produceChatTreeNode(parentId!, (parentNode) => { + const questionNodeIndex = parentNode.children!.findIndex((item) => + [placeholderQuestionId, questionItem.id].includes(item.id), + ) + if (questionNodeIndex === -1) parentNode.children!.push(currentQA) + else parentNode.children![questionNodeIndex] = currentQA + }) + } + setChatTree(nextState) + chatTreeRef.current = nextState }, - { - onGetSuggestedQuestions, - }: SendCallback, - ) => { - if (canRun === false) - return false + [chatTree, produceChatTreeNode], + ) - if (isRespondingRef.current) { - toast.info(t($ => $['errorMessage.waitForResponse'], { ns: 'appDebug' })) - return false - } + const handleSend = useCallback( + ( + params: { + query: string + files?: FileEntity[] + parent_message_id?: string + [key: string]: any + }, + { onGetSuggestedQuestions }: SendCallback, + ) => { + if (canRun === false) return false - // Abort previous handleResume SSE connection if any - if (workflowEventsAbortControllerRef.current) - workflowEventsAbortControllerRef.current.abort() + if (isRespondingRef.current) { + toast.info(t(($) => $['errorMessage.waitForResponse'], { ns: 'appDebug' })) + return false + } - const parentMessage = threadMessages.find(item => item.id === params.parent_message_id) + // Abort previous handleResume SSE connection if any + if (workflowEventsAbortControllerRef.current) workflowEventsAbortControllerRef.current.abort() - const placeholderQuestionId = `question-${Date.now()}` - const questionItem = { - id: placeholderQuestionId, - content: params.query, - isAnswer: false, - message_files: params.files, - parentMessageId: params.parent_message_id, - } + const parentMessage = threadMessages.find((item) => item.id === params.parent_message_id) - const placeholderAnswerId = `answer-placeholder-${Date.now()}` - const placeholderAnswerItem = { - id: placeholderAnswerId, - content: '', - isAnswer: true, - parentMessageId: questionItem.id, - siblingIndex: parentMessage?.children?.length ?? chatTree.length, - } + const placeholderQuestionId = `question-${Date.now()}` + const questionItem = { + id: placeholderQuestionId, + content: params.query, + isAnswer: false, + message_files: params.files, + parentMessageId: params.parent_message_id, + } - setTargetMessageId(parentMessage?.id) - updateCurrentQAOnTree({ - parentId: params.parent_message_id, - responseItem: placeholderAnswerItem, - placeholderQuestionId, - questionItem, - }) + const placeholderAnswerId = `answer-placeholder-${Date.now()}` + const placeholderAnswerItem = { + id: placeholderAnswerId, + content: '', + isAnswer: true, + parentMessageId: questionItem.id, + siblingIndex: parentMessage?.children?.length ?? chatTree.length, + } - // answer - const responseItem: ChatItem = { - id: placeholderAnswerId, - content: '', - agent_thoughts: [], - message_files: [], - isAnswer: true, - parentMessageId: questionItem.id, - siblingIndex: parentMessage?.children?.length ?? chatTree.length, - humanInputFormDataList: [], - humanInputFilledFormDataList: [], - } + setTargetMessageId(parentMessage?.id) + updateCurrentQAOnTree({ + parentId: params.parent_message_id, + responseItem: placeholderAnswerItem, + placeholderQuestionId, + questionItem, + }) - handleResponding(true) + // answer + const responseItem: ChatItem = { + id: placeholderAnswerId, + content: '', + agent_thoughts: [], + message_files: [], + isAnswer: true, + parentMessageId: questionItem.id, + siblingIndex: parentMessage?.children?.length ?? chatTree.length, + humanInputFormDataList: [], + humanInputFilledFormDataList: [], + } - const { files, inputs, ...restParams } = params - const bodyParams = { - files: getProcessedFiles(files || []), - inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []), - ...restParams, - } - if (bodyParams?.files?.length) { - bodyParams.files = bodyParams.files.map((item) => { - if (item.transfer_method === TransferMethod.local_file) { - return { - ...item, - url: '', + handleResponding(true) + + const { files, inputs, ...restParams } = params + const bodyParams = { + files: getProcessedFiles(files || []), + inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []), + ...restParams, + } + if (bodyParams?.files?.length) { + bodyParams.files = bodyParams.files.map((item) => { + if (item.transfer_method === TransferMethod.local_file) { + return { + ...item, + url: '', + } } - } - return item - }) - } + return item + }) + } - let hasSetResponseId = false + let hasSetResponseId = false - handleRun( - bodyParams, - { + handleRun(bodyParams, { getAbortController: (abortController) => { workflowEventsAbortControllerRef.current = abortController }, - onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => { + onData: ( + message: string, + isFirstMessage: boolean, + { conversationId: newConversationId, messageId, taskId }: any, + ) => { responseItem.content = responseItem.content + message if (messageId && !hasSetResponseId) { @@ -352,12 +339,10 @@ export const useChat = ( hasSetResponseId = true } - if (isFirstMessage && newConversationId) - conversationIdRef.current = newConversationId + if (isFirstMessage && newConversationId) conversationIdRef.current = newConversationId taskIdRef.current = taskId - if (messageId) - responseItem.id = messageId + if (messageId) responseItem.id = messageId updateCurrentQAOnTree({ placeholderQuestionId, @@ -368,12 +353,11 @@ export const useChat = ( }, onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => { const { reasoning, node_id, is_final } = reasoningData - const reasoningContent = responseItem.reasoningContent || (responseItem.reasoningContent = {}) + const reasoningContent = + responseItem.reasoningContent || (responseItem.reasoningContent = {}) const key = node_id || '_' - if (reasoning) - reasoningContent[key] = (reasoningContent[key] || '') + reasoning - if (is_final) - responseItem.reasoningFinished = true + if (reasoning) reasoningContent[key] = (reasoningContent[key] || '') + reasoning + if (is_final) responseItem.reasoningFinished = true updateCurrentQAOnTree({ placeholderQuestionId, @@ -403,16 +387,19 @@ export const useChat = ( return } - if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) { + if ( + config?.suggested_questions_after_answer?.enabled && + !hasStopRespondedRef.current && + onGetSuggestedQuestions + ) { try { const { data }: any = await onGetSuggestedQuestions( responseItem.id, - newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController, + (newAbortController) => + (suggestedQuestionsAbortControllerRef.current = newAbortController), ) setSuggestedQuestions(data) - } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (error) { + } catch { setSuggestedQuestions([]) } } @@ -421,7 +408,10 @@ export const useChat = ( onMessageEnd: (messageEnd) => { responseItem.citation = messageEnd.metadata?.retriever_resources || [] const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || []) - responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id') + responseItem.allFiles = uniqBy( + [...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], + 'id', + ) updateCurrentQAOnTree({ placeholderQuestionId, @@ -455,8 +445,7 @@ export const useChat = ( status: WorkflowRunningStatus.Running, error: undefined, } - } - else { + } else { taskIdRef.current = task_id responseItem.workflow_run_id = workflow_run_id responseItem.workflowProcess = { @@ -497,7 +486,9 @@ export const useChat = ( }) }, onIterationFinish: ({ data }) => { - const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id) + const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex( + (item) => item.id === data.id, + ) if (currentTracingIndex > -1) { responseItem.workflowProcess!.tracing[currentTracingIndex] = { ...responseItem.workflowProcess!.tracing[currentTracingIndex], @@ -524,7 +515,9 @@ export const useChat = ( }) }, onLoopFinish: ({ data }) => { - const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id) + const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex( + (item) => item.id === data.id, + ) if (currentTracingIndex > -1) { responseItem.workflowProcess!.tracing[currentTracingIndex] = { ...responseItem.workflowProcess!.tracing[currentTracingIndex], @@ -539,14 +532,15 @@ export const useChat = ( } }, onNodeStarted: ({ data }) => { - const currentIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id) + const currentIndex = responseItem.workflowProcess!.tracing!.findIndex( + (item) => item.node_id === data.node_id, + ) if (currentIndex > -1) { responseItem.workflowProcess!.tracing![currentIndex] = { ...data, status: NodeRunningStatus.Running, } - } - else { + } else { responseItem.workflowProcess!.tracing!.push({ ...data, status: NodeRunningStatus.Running, @@ -570,7 +564,9 @@ export const useChat = ( }) }, onNodeFinished: ({ data }) => { - const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id) + const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex( + (item) => item.id === data.id, + ) if (currentTracingIndex > -1) { responseItem.workflowProcess!.tracing[currentTracingIndex] = { ...responseItem.workflowProcess!.tracing[currentTracingIndex], @@ -585,28 +581,29 @@ export const useChat = ( } }, onAgentLog: ({ data }) => { - const currentNodeIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id) + const currentNodeIndex = responseItem.workflowProcess!.tracing!.findIndex( + (item) => item.node_id === data.node_id, + ) if (currentNodeIndex > -1) { const current = responseItem.workflowProcess!.tracing![currentNodeIndex]! if (current!.execution_metadata) { if (current!.execution_metadata.agent_log) { - const currentLogIndex = current!.execution_metadata.agent_log.findIndex(log => log.message_id === data.message_id) + const currentLogIndex = current!.execution_metadata.agent_log.findIndex( + (log) => log.message_id === data.message_id, + ) if (currentLogIndex > -1) { current!.execution_metadata.agent_log[currentLogIndex] = { ...current!.execution_metadata.agent_log[currentLogIndex], ...data, } - } - else { + } else { current!.execution_metadata.agent_log.push(data) } - } - else { + } else { current!.execution_metadata.agent_log = [data] } - } - else { + } else { current!.execution_metadata = { agent_log: [data], } as any @@ -627,19 +624,22 @@ export const useChat = ( onHumanInputRequired: ({ data }) => { if (!responseItem.humanInputFormDataList) { responseItem.humanInputFormDataList = [data] - } - else { - const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === data.node_id) + } else { + const currentFormIndex = responseItem.humanInputFormDataList.findIndex( + (item) => item.node_id === data.node_id, + ) if (currentFormIndex > -1) { responseItem.humanInputFormDataList[currentFormIndex] = data - } - else { + } else { responseItem.humanInputFormDataList.push(data) } } - const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id) + const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex( + (item) => item.node_id === data.node_id, + ) if (currentTracingIndex > -1) { - responseItem.workflowProcess!.tracing[currentTracingIndex]!.status = NodeRunningStatus.Paused + responseItem.workflowProcess!.tracing[currentTracingIndex]!.status = + NodeRunningStatus.Paused updateCurrentQAOnTree({ placeholderQuestionId, questionItem, @@ -651,7 +651,9 @@ export const useChat = ( onHumanInputFormFilled: ({ data }) => { let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined if (responseItem.humanInputFormDataList?.length) { - const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === data.node_id) + const currentFormIndex = responseItem.humanInputFormDataList.findIndex( + (item) => item.node_id === data.node_id, + ) if (currentFormIndex > -1) { requiredFormData = responseItem.humanInputFormDataList[currentFormIndex] responseItem.humanInputFormDataList.splice(currentFormIndex, 1) @@ -660,8 +662,7 @@ export const useChat = ( const enrichedData = enrichSubmittedHumanInputFormData(data, requiredFormData) if (!responseItem.humanInputFilledFormDataList) { responseItem.humanInputFilledFormDataList = [enrichedData] - } - else { + } else { responseItem.humanInputFilledFormDataList.push(enrichedData) } updateCurrentQAOnTree({ @@ -673,8 +674,11 @@ export const useChat = ( }, onHumanInputFormTimeout: ({ data }) => { if (responseItem.humanInputFormDataList?.length) { - const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === data.node_id) - responseItem.humanInputFormDataList[currentFormIndex]!.expiration_time = data.expiration_time + const currentFormIndex = responseItem.humanInputFormDataList.findIndex( + (item) => item.node_id === data.node_id, + ) + responseItem.humanInputFormDataList[currentFormIndex]!.expiration_time = + data.expiration_time } updateCurrentQAOnTree({ placeholderQuestionId, @@ -692,333 +696,364 @@ export const useChat = ( parentId: params.parent_message_id, }) }, - }, - ) - }, [canRun, threadMessages, chatTree.length, updateCurrentQAOnTree, handleResponding, formSettings?.inputsForm, handleRun, t, workflowStore, fetchInspectVars, invalidAllLastRun, config?.suggested_questions_after_answer?.enabled]) + }) + }, + [ + canRun, + threadMessages, + chatTree.length, + updateCurrentQAOnTree, + handleResponding, + formSettings?.inputsForm, + handleRun, + t, + workflowStore, + fetchInspectVars, + invalidAllLastRun, + config?.suggested_questions_after_answer?.enabled, + ], + ) - const handleSubmitHumanInputForm = async (formToken: string, formData: HumanInputFormSubmitData) => { + const handleSubmitHumanInputForm = async ( + formToken: string, + formData: HumanInputFormSubmitData, + ) => { await submitHumanInputForm(formToken, formData) } const getHumanInputNodeData = (nodeID: string) => { - const { - getNodes, - } = store.getState() - const nodes = getNodes().filter(node => node.type === CUSTOM_NODE) - const node = nodes.find(n => n.id === nodeID) + const { getNodes } = store.getState() + const nodes = getNodes().filter((node) => node.type === CUSTOM_NODE) + const node = nodes.find((n) => n.id === nodeID) return node } - const handleResume = useCallback(( - messageId: string, - workflowRunId: string, - { - onGetSuggestedQuestions, - }: SendCallback, - ) => { - // Re-subscribe to workflow events for the specific message - const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true` + const handleResume = useCallback( + (messageId: string, workflowRunId: string, { onGetSuggestedQuestions }: SendCallback) => { + // Re-subscribe to workflow events for the specific message + const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true` - const otherOptions: IOtherOptions = { - getAbortController: (abortController) => { - workflowEventsAbortControllerRef.current = abortController - }, - onData: (message: string, _isFirstMessage: boolean, { conversationId: newConversationId, messageId: msgId, taskId }: any) => { - updateChatTreeNode(messageId, (responseItem) => { - responseItem.content = responseItem.content + message - if (msgId) - responseItem.id = msgId - }) + const otherOptions: IOtherOptions = { + getAbortController: (abortController) => { + workflowEventsAbortControllerRef.current = abortController + }, + onData: ( + message: string, + _isFirstMessage: boolean, + { conversationId: newConversationId, messageId: msgId, taskId }: any, + ) => { + updateChatTreeNode(messageId, (responseItem) => { + responseItem.content = responseItem.content + message + if (msgId) responseItem.id = msgId + }) - if (newConversationId) - conversationIdRef.current = newConversationId + if (newConversationId) conversationIdRef.current = newConversationId - if (taskId) - taskIdRef.current = taskId - }, - onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => { - const { message_id, reasoning, node_id, is_final } = reasoningData - updateChatTreeNode(message_id, (responseItem) => { - const reasoningContent = responseItem.reasoningContent || (responseItem.reasoningContent = {}) - const key = node_id || '_' - if (reasoning) - reasoningContent[key] = (reasoningContent[key] || '') + reasoning - if (is_final) - responseItem.reasoningFinished = true - }) - }, + if (taskId) taskIdRef.current = taskId + }, + onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => { + const { message_id, reasoning, node_id, is_final } = reasoningData + updateChatTreeNode(message_id, (responseItem) => { + const reasoningContent = + responseItem.reasoningContent || (responseItem.reasoningContent = {}) + const key = node_id || '_' + if (reasoning) reasoningContent[key] = (reasoningContent[key] || '') + reasoning + if (is_final) responseItem.reasoningFinished = true + }) + }, - async onCompleted(hasError?: boolean) { - const { workflowRunningData } = workflowStore.getState() - handleResponding(false) + async onCompleted(hasError?: boolean) { + const { workflowRunningData } = workflowStore.getState() + handleResponding(false) - if (workflowRunningData?.result.status !== WorkflowRunningStatus.Paused) { - fetchInspectVars({}) - invalidAllLastRun() + if (workflowRunningData?.result.status !== WorkflowRunningStatus.Paused) { + fetchInspectVars({}) + invalidAllLastRun() - if (hasError) - return + if (hasError) return - if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) { - try { - const { data }: any = await onGetSuggestedQuestions( - messageId, - newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController, - ) - setSuggestedQuestions(data) - } - catch { - setSuggestedQuestions([]) - } - } - } - }, - onMessageEnd: (messageEnd) => { - updateChatTreeNode(messageId, (responseItem) => { - responseItem.citation = messageEnd.metadata?.retriever_resources || [] - const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || []) - responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id') - }) - }, - onMessageReplace: (messageReplace) => { - updateChatTreeNode(messageId, (responseItem) => { - responseItem.content = messageReplace.answer - }) - }, - onError() { - handleResponding(false) - }, - onWorkflowStarted: ({ workflow_run_id, task_id }) => { - handleResponding(true) - hasStopRespondedRef.current = false - updateChatTreeNode(messageId, (responseItem) => { - if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) { - responseItem.workflowProcess = { - ...responseItem.workflowProcess, - status: WorkflowRunningStatus.Running, - error: undefined, + if ( + config?.suggested_questions_after_answer?.enabled && + !hasStopRespondedRef.current && + onGetSuggestedQuestions + ) { + try { + const { data }: any = await onGetSuggestedQuestions( + messageId, + (newAbortController) => + (suggestedQuestionsAbortControllerRef.current = newAbortController), + ) + setSuggestedQuestions(data) + } catch { + setSuggestedQuestions([]) + } } } - else { - taskIdRef.current = task_id - responseItem.workflow_run_id = workflow_run_id - responseItem.workflowProcess = { - status: WorkflowRunningStatus.Running, - tracing: [], + }, + onMessageEnd: (messageEnd) => { + updateChatTreeNode(messageId, (responseItem) => { + responseItem.citation = messageEnd.metadata?.retriever_resources || [] + const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || []) + responseItem.allFiles = uniqBy( + [...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], + 'id', + ) + }) + }, + onMessageReplace: (messageReplace) => { + updateChatTreeNode(messageId, (responseItem) => { + responseItem.content = messageReplace.answer + }) + }, + onError() { + handleResponding(false) + }, + onWorkflowStarted: ({ workflow_run_id, task_id }) => { + handleResponding(true) + hasStopRespondedRef.current = false + updateChatTreeNode(messageId, (responseItem) => { + if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) { + responseItem.workflowProcess = { + ...responseItem.workflowProcess, + status: WorkflowRunningStatus.Running, + error: undefined, + } + } else { + taskIdRef.current = task_id + responseItem.workflow_run_id = workflow_run_id + responseItem.workflowProcess = { + status: WorkflowRunningStatus.Running, + tracing: [], + } } - } - }) - }, - onWorkflowFinished: ({ data: workflowFinishedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (responseItem.workflowProcess) { - responseItem.workflowProcess = { - ...responseItem.workflowProcess, - status: workflowFinishedData.status as WorkflowRunningStatus, - error: workflowFinishedData.error, + }) + }, + onWorkflowFinished: ({ data: workflowFinishedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (responseItem.workflowProcess) { + responseItem.workflowProcess = { + ...responseItem.workflowProcess, + status: workflowFinishedData.status as WorkflowRunningStatus, + error: workflowFinishedData.error, + } } - } - }) - }, - onIterationStart: ({ data: iterationStartedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.workflowProcess) - return - if (!responseItem.workflowProcess.tracing) - responseItem.workflowProcess.tracing = [] - responseItem.workflowProcess.tracing.push({ - ...iterationStartedData, - status: WorkflowRunningStatus.Running, }) - }) - }, - onIterationFinish: ({ data: iterationFinishedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.workflowProcess?.tracing) - return - const tracing = responseItem.workflowProcess.tracing - const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id - && (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))! - if (iterationIndex > -1) { - tracing[iterationIndex] = { - ...tracing[iterationIndex], - ...iterationFinishedData, - status: WorkflowRunningStatus.Succeeded, + }, + onIterationStart: ({ data: iterationStartedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.workflowProcess) return + if (!responseItem.workflowProcess.tracing) responseItem.workflowProcess.tracing = [] + responseItem.workflowProcess.tracing.push({ + ...iterationStartedData, + status: WorkflowRunningStatus.Running, + }) + }) + }, + onIterationFinish: ({ data: iterationFinishedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.workflowProcess?.tracing) return + const tracing = responseItem.workflowProcess.tracing + const iterationIndex = tracing.findIndex( + (item) => + item.node_id === iterationFinishedData.node_id && + (item.execution_metadata?.parallel_id === + iterationFinishedData.execution_metadata?.parallel_id || + item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id), + )! + if (iterationIndex > -1) { + tracing[iterationIndex] = { + ...tracing[iterationIndex], + ...iterationFinishedData, + status: WorkflowRunningStatus.Succeeded, + } } - } - }) - }, - onNodeStarted: ({ data: nodeStartedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.workflowProcess) - return - if (!responseItem.workflowProcess.tracing) - responseItem.workflowProcess.tracing = [] + }) + }, + onNodeStarted: ({ data: nodeStartedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.workflowProcess) return + if (!responseItem.workflowProcess.tracing) responseItem.workflowProcess.tracing = [] - const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id) - if (currentIndex > -1) { - responseItem.workflowProcess.tracing[currentIndex] = { - ...nodeStartedData, - status: NodeRunningStatus.Running, + const currentIndex = responseItem.workflowProcess.tracing.findIndex( + (item) => item.node_id === nodeStartedData.node_id, + ) + if (currentIndex > -1) { + responseItem.workflowProcess.tracing[currentIndex] = { + ...nodeStartedData, + status: NodeRunningStatus.Running, + } + } else { + if (nodeStartedData.iteration_id) return + + responseItem.workflowProcess.tracing.push({ + ...nodeStartedData, + status: WorkflowRunningStatus.Running, + }) } - } - else { - if (nodeStartedData.iteration_id) - return + }) + }, + onNodeFinished: ({ data: nodeFinishedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.workflowProcess?.tracing) return + + if (nodeFinishedData.iteration_id) return + const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => { + if (!item.execution_metadata?.parallel_id) return item.id === nodeFinishedData.id + + return ( + item.id === nodeFinishedData.id && + item.execution_metadata?.parallel_id === + nodeFinishedData.execution_metadata?.parallel_id + ) + }) + if (currentIndex > -1) + responseItem.workflowProcess.tracing[currentIndex] = nodeFinishedData as any + }) + }, + onLoopStart: ({ data: loopStartedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.workflowProcess) return + if (!responseItem.workflowProcess.tracing) responseItem.workflowProcess.tracing = [] responseItem.workflowProcess.tracing.push({ - ...nodeStartedData, + ...loopStartedData, status: WorkflowRunningStatus.Running, }) - } - }) - }, - onNodeFinished: ({ data: nodeFinishedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.workflowProcess?.tracing) - return - - if (nodeFinishedData.iteration_id) - return - - const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => { - if (!item.execution_metadata?.parallel_id) - return item.id === nodeFinishedData.id - - return item.id === nodeFinishedData.id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata?.parallel_id) }) - if (currentIndex > -1) - responseItem.workflowProcess.tracing[currentIndex] = nodeFinishedData as any - }) - }, - onLoopStart: ({ data: loopStartedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.workflowProcess) - return - if (!responseItem.workflowProcess.tracing) - responseItem.workflowProcess.tracing = [] - responseItem.workflowProcess.tracing.push({ - ...loopStartedData, - status: WorkflowRunningStatus.Running, + }, + onLoopFinish: ({ data: loopFinishedData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.workflowProcess?.tracing) return + const tracing = responseItem.workflowProcess.tracing + const loopIndex = tracing.findIndex( + (item) => + item.node_id === loopFinishedData.node_id && + (item.execution_metadata?.parallel_id === + loopFinishedData.execution_metadata?.parallel_id || + item.parallel_id === loopFinishedData.execution_metadata?.parallel_id), + )! + if (loopIndex > -1) { + tracing[loopIndex] = { + ...tracing[loopIndex], + ...loopFinishedData, + status: WorkflowRunningStatus.Succeeded, + } + } }) - }) - }, - onLoopFinish: ({ data: loopFinishedData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.workflowProcess?.tracing) - return - const tracing = responseItem.workflowProcess.tracing - const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id - && (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))! - if (loopIndex > -1) { - tracing[loopIndex] = { - ...tracing[loopIndex], - ...loopFinishedData, - status: WorkflowRunningStatus.Succeeded, + }, + onHumanInputRequired: ({ data: humanInputRequiredData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (!responseItem.humanInputFormDataList) { + responseItem.humanInputFormDataList = [humanInputRequiredData] + } else { + const currentFormIndex = responseItem.humanInputFormDataList.findIndex( + (item) => item.node_id === humanInputRequiredData.node_id, + ) + if (currentFormIndex > -1) { + responseItem.humanInputFormDataList[currentFormIndex] = humanInputRequiredData + } else { + responseItem.humanInputFormDataList.push(humanInputRequiredData) + } } - } - }) - }, - onHumanInputRequired: ({ data: humanInputRequiredData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (!responseItem.humanInputFormDataList) { - responseItem.humanInputFormDataList = [humanInputRequiredData] - } - else { - const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputRequiredData.node_id) - if (currentFormIndex > -1) { - responseItem.humanInputFormDataList[currentFormIndex] = humanInputRequiredData + if (responseItem.workflowProcess?.tracing) { + const currentTracingIndex = responseItem.workflowProcess.tracing.findIndex( + (item) => item.node_id === humanInputRequiredData.node_id, + ) + if (currentTracingIndex > -1) + responseItem.workflowProcess.tracing[currentTracingIndex]!.status = + NodeRunningStatus.Paused } - else { - responseItem.humanInputFormDataList.push(humanInputRequiredData) + }) + }, + onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => { + updateChatTreeNode(messageId, (responseItem) => { + let requiredFormData: + | NonNullable<ChatItem['humanInputFormDataList']>[number] + | undefined + if (responseItem.humanInputFormDataList?.length) { + const currentFormIndex = responseItem.humanInputFormDataList.findIndex( + (item) => item.node_id === humanInputFilledFormData.node_id, + ) + if (currentFormIndex > -1) { + requiredFormData = responseItem.humanInputFormDataList[currentFormIndex] + responseItem.humanInputFormDataList.splice(currentFormIndex, 1) + } } - } - if (responseItem.workflowProcess?.tracing) { - const currentTracingIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === humanInputRequiredData.node_id) - if (currentTracingIndex > -1) - responseItem.workflowProcess.tracing[currentTracingIndex]!.status = NodeRunningStatus.Paused - } - }) - }, - onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => { - updateChatTreeNode(messageId, (responseItem) => { - let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined - if (responseItem.humanInputFormDataList?.length) { - const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFilledFormData.node_id) - if (currentFormIndex > -1) { - requiredFormData = responseItem.humanInputFormDataList[currentFormIndex] - responseItem.humanInputFormDataList.splice(currentFormIndex, 1) + const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData( + humanInputFilledFormData, + requiredFormData, + ) + if (!responseItem.humanInputFilledFormDataList) { + responseItem.humanInputFilledFormDataList = [enrichedHumanInputFilledFormData] + } else { + responseItem.humanInputFilledFormDataList.push(enrichedHumanInputFilledFormData) } - } - const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData(humanInputFilledFormData, requiredFormData) - if (!responseItem.humanInputFilledFormDataList) { - responseItem.humanInputFilledFormDataList = [enrichedHumanInputFilledFormData] - } - else { - responseItem.humanInputFilledFormDataList.push(enrichedHumanInputFilledFormData) - } - }) - }, - onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => { - updateChatTreeNode(messageId, (responseItem) => { - if (responseItem.humanInputFormDataList?.length) { - const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFormTimeoutData.node_id) - responseItem.humanInputFormDataList[currentFormIndex]!.expiration_time = humanInputFormTimeoutData.expiration_time - } - }) - }, - onWorkflowPaused: ({ data: workflowPausedData }) => { - const resumeUrl = `/workflow/${workflowPausedData.workflow_run_id}/events` - sseGet( - resumeUrl, - {}, - otherOptions, - ) - updateChatTreeNode(messageId, (responseItem) => { - responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused - }) - }, - } + }) + }, + onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => { + updateChatTreeNode(messageId, (responseItem) => { + if (responseItem.humanInputFormDataList?.length) { + const currentFormIndex = responseItem.humanInputFormDataList.findIndex( + (item) => item.node_id === humanInputFormTimeoutData.node_id, + ) + responseItem.humanInputFormDataList[currentFormIndex]!.expiration_time = + humanInputFormTimeoutData.expiration_time + } + }) + }, + onWorkflowPaused: ({ data: workflowPausedData }) => { + const resumeUrl = `/workflow/${workflowPausedData.workflow_run_id}/events` + sseGet(resumeUrl, {}, otherOptions) + updateChatTreeNode(messageId, (responseItem) => { + responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused + }) + }, + } - if (workflowEventsAbortControllerRef.current) - workflowEventsAbortControllerRef.current.abort() + if (workflowEventsAbortControllerRef.current) workflowEventsAbortControllerRef.current.abort() - sseGet( - url, - {}, - otherOptions, - ) - }, [updateChatTreeNode, handleResponding, workflowStore, fetchInspectVars, invalidAllLastRun, config?.suggested_questions_after_answer]) + sseGet(url, {}, otherOptions) + }, + [ + updateChatTreeNode, + handleResponding, + workflowStore, + fetchInspectVars, + invalidAllLastRun, + config?.suggested_questions_after_answer, + ], + ) - const handleSwitchSibling = useCallback(( - siblingMessageId: string, - callbacks: SendCallback, - ) => { - setTargetMessageId(siblingMessageId) + const handleSwitchSibling = useCallback( + (siblingMessageId: string, callbacks: SendCallback) => { + setTargetMessageId(siblingMessageId) - // Helper to find message in tree - const findMessageInTree = (nodes: ChatItemInTree[], targetId: string): ChatItemInTree | undefined => { - for (const node of nodes) { - if (node.id === targetId) - return node - if (node.children) { - const found = findMessageInTree(node.children, targetId) - if (found) - return found + // Helper to find message in tree + const findMessageInTree = ( + nodes: ChatItemInTree[], + targetId: string, + ): ChatItemInTree | undefined => { + for (const node of nodes) { + if (node.id === targetId) return node + if (node.children) { + const found = findMessageInTree(node.children, targetId) + if (found) return found + } } + return undefined } - return undefined - } - const targetMessage = findMessageInTree(chatTreeRef.current, siblingMessageId) - if (targetMessage?.workflow_run_id && targetMessage.humanInputFormDataList && targetMessage.humanInputFormDataList.length > 0) { - handleResume( - targetMessage.id, - targetMessage.workflow_run_id, - callbacks, - ) - } - }, [handleResume]) + const targetMessage = findMessageInTree(chatTreeRef.current, siblingMessageId) + if ( + targetMessage?.workflow_run_id && + targetMessage.humanInputFormDataList && + targetMessage.humanInputFormDataList.length > 0 + ) { + handleResume(targetMessage.id, targetMessage.workflow_run_id, callbacks) + } + }, + [handleResume], + ) return { conversationId: conversationIdRef.current, diff --git a/web/app/components/workflow/panel/debug-and-preview/index.tsx b/web/app/components/workflow/panel/debug-and-preview/index.tsx index 37bfdaa23e2490..1226e147b1faa5 100644 --- a/web/app/components/workflow/panel/debug-and-preview/index.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/index.tsx @@ -1,17 +1,10 @@ import type { StartNodeType } from '../../nodes/start/types' - import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiCloseLine, RiEqualizer2Line } from '@remixicon/react' import { debounce } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function' -import { - memo, - useCallback, - useMemo, - useRef, - useState, -} from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' @@ -19,9 +12,7 @@ import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync' import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync' import { useStore } from '@/app/components/workflow/store' -import { - useWorkflowInteractions, -} from '../../hooks' +import { useWorkflowInteractions } from '../../hooks' import { useResizePanel } from '../../nodes/_base/hooks/use-resize-panel' import { useSetDebugPreviewPanelWidth } from '../../persistence/local-storage-options' import { BlockEnum } from '../../types' @@ -38,8 +29,8 @@ const DebugAndPreview = () => { const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync() const [expanded, setExpanded] = useState(true) const nodes = useNodes<StartNodeType>() - const selectedNode = nodes.find(node => node.data.selected) - const startNode = nodes.find(node => node.data.type === BlockEnum.Start) + const selectedNode = nodes.find((node) => node.data.selected) + const startNode = nodes.find((node) => node.data.type === BlockEnum.Start) const variables = startNode?.data.variables || [] const visibleVariables = variables @@ -51,29 +42,26 @@ const DebugAndPreview = () => { chatRef.current.handleRestart() } - const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) - const nodePanelWidth = useStore(s => s.nodePanelWidth) - const panelWidth = useStore(s => s.previewPanelWidth) - const setPanelWidth = useStore(s => s.setPreviewPanelWidth) + const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth) + const nodePanelWidth = useStore((s) => s.nodePanelWidth) + const panelWidth = useStore((s) => s.previewPanelWidth) + const setPanelWidth = useStore((s) => s.setPreviewPanelWidth) const setPanelWidthStorage = useSetDebugPreviewPanelWidth() - const handleResize = useCallback((width: number, source: 'user' | 'system' = 'user') => { - if (source === 'user') - setPanelWidthStorage(width) - setPanelWidth(width) - }, [setPanelWidth, setPanelWidthStorage]) + const handleResize = useCallback( + (width: number, source: 'user' | 'system' = 'user') => { + if (source === 'user') setPanelWidthStorage(width) + setPanelWidth(width) + }, + [setPanelWidth, setPanelWidthStorage], + ) const maxPanelWidth = useMemo(() => { - if (!workflowCanvasWidth) - return 720 + if (!workflowCanvasWidth) return 720 - if (!selectedNode) - return workflowCanvasWidth - 400 + if (!selectedNode) return workflowCanvasWidth - 400 return workflowCanvasWidth - 400 - 400 }, [workflowCanvasWidth, selectedNode, nodePanelWidth]) - const { - triggerRef, - containerRef, - } = useResizePanel({ + const { triggerRef, containerRef } = useResizePanel({ direction: 'horizontal', triggerDirection: 'left', minWidth: 400, @@ -99,35 +87,40 @@ const DebugAndPreview = () => { style={{ width: `${panelWidth}px` }} > <div className="flex shrink-0 items-center justify-between px-4 pt-3 pb-2 system-xl-semibold text-text-primary"> - <div className="h-8">{t($ => $['common.debugAndPreview'], { ns: 'workflow' }).toLocaleUpperCase()}</div> + <div className="h-8"> + {t(($) => $['common.debugAndPreview'], { ns: 'workflow' }).toLocaleUpperCase()} + </div> <div className="flex items-center gap-1"> <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton onClick={() => handleRestartChat()}> <RefreshCcw01 className="size-4" /> </ActionButton> - )} + } /> - <TooltipContent> - {t($ => $['operation.refresh'], { ns: 'common' })} - </TooltipContent> + <TooltipContent>{t(($) => $['operation.refresh'], { ns: 'common' })}</TooltipContent> </Tooltip> {visibleVariables.length > 0 && ( <div className="relative"> <Tooltip> <TooltipTrigger - render={( - <ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => setExpanded(!expanded)}> + render={ + <ActionButton + state={expanded ? ActionButtonState.Active : undefined} + onClick={() => setExpanded(!expanded)} + > <RiEqualizer2Line className="size-4" /> </ActionButton> - )} + } /> <TooltipContent> - {t($ => $['panel.userInputField'], { ns: 'workflow' })} + {t(($) => $['panel.userInputField'], { ns: 'workflow' })} </TooltipContent> </Tooltip> - {expanded && <div className="absolute right-[5px] bottom-[-17px] z-10 h-3 w-3 rotate-45 border-t-[0.5px] border-l-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />} + {expanded && ( + <div className="absolute right-[5px] bottom-[-17px] z-10 h-3 w-3 rotate-45 border-t-[0.5px] border-l-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" /> + )} </div> )} <div className="mx-3 h-3.5 w-px bg-divider-regular"></div> diff --git a/web/app/components/workflow/panel/debug-and-preview/user-input.tsx b/web/app/components/workflow/panel/debug-and-preview/user-input.tsx index 35b0a4d36a1461..557541baa9958c 100644 --- a/web/app/components/workflow/panel/debug-and-preview/user-input.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/user-input.tsx @@ -1,52 +1,46 @@ import type { StartNodeType } from '../../nodes/start/types' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, -} from 'react' +import { memo } from 'react' import { useNodes } from 'reactflow' import FormItem from '../../nodes/_base/components/before-run-form/form-item' -import { - useStore, - useWorkflowStore, -} from '../../store' +import { useStore, useWorkflowStore } from '../../store' import { BlockEnum } from '../../types' const UserInput = () => { const workflowStore = useWorkflowStore() - const inputs = useStore(s => s.inputs) - const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel) + const inputs = useStore((s) => s.inputs) + const showDebugAndPreviewPanel = useStore((s) => s.showDebugAndPreviewPanel) const nodes = useNodes<StartNodeType>() - const startNode = nodes.find(node => node.data.type === BlockEnum.Start) + const startNode = nodes.find((node) => node.data.type === BlockEnum.Start) const variables = startNode?.data.variables || [] - const visibleVariables = showDebugAndPreviewPanel ? variables : variables.filter(v => v.hide !== true) + const visibleVariables = showDebugAndPreviewPanel + ? variables + : variables.filter((v) => v.hide !== true) const handleValueChange = (variable: string, v: string) => { - const { - inputs, - setInputs, - } = workflowStore.getState() + const { inputs, setInputs } = workflowStore.getState() setInputs({ ...inputs, [variable]: v, }) } - if (!visibleVariables.length) - return null + if (!visibleVariables.length) return null return ( - <div className={cn('relative z-1 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-xs')}> + <div + className={cn( + 'relative z-1 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-xs', + )} + > <div className="px-4 pt-3 pb-4"> {visibleVariables.map((variable, index) => ( - <div - key={variable.variable} - className="mb-4 last-of-type:mb-0" - > + <div key={variable.variable} className="mb-4 last-of-type:mb-0"> <FormItem autoFocus={index === 0} payload={variable} value={inputs[variable.variable]} - onChange={v => handleValueChange(variable.variable, v)} + onChange={(v) => handleValueChange(variable.variable, v)} /> </div> ))} diff --git a/web/app/components/workflow/panel/env-panel/__tests__/env-item.spec.tsx b/web/app/components/workflow/panel/env-panel/__tests__/env-item.spec.tsx index d2871833df0945..05e4521eba3e7b 100644 --- a/web/app/components/workflow/panel/env-panel/__tests__/env-item.spec.tsx +++ b/web/app/components/workflow/panel/env-panel/__tests__/env-item.spec.tsx @@ -24,14 +24,9 @@ const renderWithProviders = ( ) => { const store = createWorkflowStore({}) - if (options.storeState) - store.setState(options.storeState) - - const result = render( - <WorkflowContext value={store}> - {ui} - </WorkflowContext>, - ) + if (options.storeState) store.setState(options.storeState) + + const result = render(<WorkflowContext value={store}>{ui}</WorkflowContext>) return { ...result, diff --git a/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx index 69b60e6259d567..ff83dc858b4149 100644 --- a/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx @@ -25,8 +25,12 @@ const { mockDoSyncWorkflowDraft: vi.fn(() => Promise.resolve()), mockGetNodes: vi.fn<() => MockWorkflowNode[]>(() => []), mockSetNodes: vi.fn<(nodes: MockWorkflowNode[]) => void>(), - mockFindUsedVarNodes: vi.fn<(selector: string[], nodes: MockWorkflowNode[]) => MockWorkflowNode[]>(() => []), - mockUpdateNodeVars: vi.fn<(node: MockWorkflowNode, currentSelector: string[], nextSelector: string[]) => MockWorkflowNode>((node, _currentSelector, nextSelector) => ({ + mockFindUsedVarNodes: vi.fn< + (selector: string[], nodes: MockWorkflowNode[]) => MockWorkflowNode[] + >(() => []), + mockUpdateNodeVars: vi.fn< + (node: MockWorkflowNode, currentSelector: string[], nextSelector: string[]) => MockWorkflowNode + >((node, _currentSelector, nextSelector) => ({ ...node, data: { ...node.data, @@ -36,8 +40,12 @@ const { mockVariableTriggerState: { savePayload: undefined as EnvironmentVariable | undefined, }, - mockUpdateEnvironmentVariables: vi.fn<(payload: { appId: string, environmentVariables: EnvironmentVariable[] }) => Promise<unknown>>(() => Promise.resolve({})), - mockGetSocket: vi.fn<(appId: string) => { emit: (event: string, payload: unknown) => void } | null>(() => null), + mockUpdateEnvironmentVariables: vi.fn< + (payload: { appId: string; environmentVariables: EnvironmentVariable[] }) => Promise<unknown> + >(() => Promise.resolve({})), + mockGetSocket: vi.fn< + (appId: string) => { emit: (event: string, payload: unknown) => void } | null + >(() => null), })) vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ @@ -61,7 +69,10 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => })) vi.mock('@/service/workflow', () => ({ - updateEnvironmentVariables: (payload: { appId: string, environmentVariables: EnvironmentVariable[] }) => mockUpdateEnvironmentVariables(payload), + updateEnvironmentVariables: (payload: { + appId: string + environmentVariables: EnvironmentVariable[] + }) => mockUpdateEnvironmentVariables(payload), })) vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({ @@ -79,14 +90,13 @@ vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-conf isShow: boolean onCancel: () => void onConfirm: () => void - }) => isShow - ? ( - <div> - <button onClick={onCancel}>Cancel remove</button> - <button onClick={onConfirm}>Confirm remove</button> - </div> - ) - : null, + }) => + isShow ? ( + <div> + <button onClick={onCancel}>Cancel remove</button> + <button onClick={onConfirm}>Confirm remove</button> + </div> + ) : null, })) vi.mock('@/app/components/workflow/panel/env-panel/env-item', () => ({ @@ -101,16 +111,8 @@ vi.mock('@/app/components/workflow/panel/env-panel/env-item', () => ({ }) => ( <div> <span>{env.name}</span> - <button onClick={() => onEdit(env)}> - Edit - {' '} - {env.name} - </button> - <button onClick={() => onDelete(env)}> - Delete - {' '} - {env.name} - </button> + <button onClick={() => onEdit(env)}>Edit {env.name}</button> + <button onClick={() => onDelete(env)}>Delete {env.name}</button> </div> ), })) @@ -132,19 +134,22 @@ vi.mock('@/app/components/workflow/panel/env-panel/variable-trigger', () => ({ <div> <span> Variable trigger: - {open ? 'open' : 'closed'} - : - {env?.name || 'new'} + {open ? 'open' : 'closed'}:{env?.name || 'new'} </span> <button onClick={() => setOpen(true)}>Open variable modal</button> <button - onClick={() => onSave(mockVariableTriggerState.savePayload || env || { - id: 'env-created', - name: 'created_name', - value: 'created-value', - value_type: 'string', - description: 'created', - })} + onClick={() => + onSave( + mockVariableTriggerState.savePayload || + env || { + id: 'env-created', + name: 'created_name', + value: 'created-value', + value_type: 'string', + description: 'created', + }, + ) + } > Save variable </button> @@ -162,20 +167,13 @@ const createEnv = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVar ...overrides, }) -const renderWithProviders = ( - ui: ReactElement, - storeState: Partial<Shape> = {}, -) => { +const renderWithProviders = (ui: ReactElement, storeState: Partial<Shape> = {}) => { const store = createWorkflowStore({}) store.setState(storeState) return { store, - ...render( - <WorkflowContext value={store}> - {ui} - </WorkflowContext>, - ), + ...render(<WorkflowContext value={store}>{ui}</WorkflowContext>), } } diff --git a/web/app/components/workflow/panel/env-panel/__tests__/variable-modal.spec.tsx b/web/app/components/workflow/panel/env-panel/__tests__/variable-modal.spec.tsx index 258784b6922079..caffd1d418e4c1 100644 --- a/web/app/components/workflow/panel/env-panel/__tests__/variable-modal.spec.tsx +++ b/web/app/components/workflow/panel/env-panel/__tests__/variable-modal.spec.tsx @@ -36,14 +36,9 @@ const renderWithProviders = ( ) => { const store = createWorkflowStore({}) - if (options.storeState) - store.setState(options.storeState) + if (options.storeState) store.setState(options.storeState) - const result = render( - <WorkflowContext value={store}> - {ui} - </WorkflowContext>, - ) + const result = render(<WorkflowContext value={store}>{ui}</WorkflowContext>) return { ...result, @@ -61,22 +56,27 @@ describe('VariableModal', () => { const onSave = vi.fn() const onClose = vi.fn() - renderWithProviders( - <VariableModal onClose={onClose} onSave={onSave} />, - { - storeState: { - environmentVariables: [], - }, + renderWithProviders(<VariableModal onClose={onClose} onSave={onSave} />, { + storeState: { + environmentVariables: [], }, - ) + }) await user.click(screen.getByText('Secret')) await user.type(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder'), 'my secret') - await user.type(screen.getByPlaceholderText('workflow.env.modal.valuePlaceholder'), 'top-secret') - await user.type(screen.getByPlaceholderText('workflow.env.modal.descriptionPlaceholder'), 'runtime only') + await user.type( + screen.getByPlaceholderText('workflow.env.modal.valuePlaceholder'), + 'top-secret', + ) + await user.type( + screen.getByPlaceholderText('workflow.env.modal.descriptionPlaceholder'), + 'runtime only', + ) await user.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder')).toHaveValue('my_secret') + expect(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder')).toHaveValue( + 'my_secret', + ) expect(onSave).toHaveBeenCalledWith({ id: expect.any(String), name: 'my_secret', @@ -89,14 +89,13 @@ describe('VariableModal', () => { it('rejects invalid and duplicate variable names', async () => { const user = userEvent.setup() - renderWithProviders( - <VariableModal onClose={vi.fn()} onSave={vi.fn()} />, - { - storeState: { - environmentVariables: [createEnv({ id: 'env-existing', name: 'duplicated', value_type: 'string', value: '1' })], - }, + renderWithProviders(<VariableModal onClose={vi.fn()} onSave={vi.fn()} />, { + storeState: { + environmentVariables: [ + createEnv({ id: 'env-existing', name: 'duplicated', value_type: 'string', value: '1' }), + ], }, - ) + }) fireEvent.change(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder'), { target: { value: '1bad' }, @@ -109,7 +108,9 @@ describe('VariableModal', () => { await user.type(screen.getByPlaceholderText('workflow.env.modal.valuePlaceholder'), '42') await user.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(mockToastError).toHaveBeenCalledWith('appDebug.varKeyError.keyAlreadyExists:{"key":"workflow.env.modal.name"}') + expect(mockToastError).toHaveBeenCalledWith( + 'appDebug.varKeyError.keyAlreadyExists:{"key":"workflow.env.modal.name"}', + ) }) it('loads existing secret values and converts them to numbers when editing', async () => { diff --git a/web/app/components/workflow/panel/env-panel/__tests__/variable-trigger.spec.tsx b/web/app/components/workflow/panel/env-panel/__tests__/variable-trigger.spec.tsx index 2bbef3c0a8a149..c20212cd42412a 100644 --- a/web/app/components/workflow/panel/env-panel/__tests__/variable-trigger.spec.tsx +++ b/web/app/components/workflow/panel/env-panel/__tests__/variable-trigger.spec.tsx @@ -25,14 +25,9 @@ const renderWithProviders = ( ) => { const store = createWorkflowStore({}) - if (options.storeState) - store.setState(options.storeState) + if (options.storeState) store.setState(options.storeState) - const result = render( - <WorkflowContext value={store}> - {ui} - </WorkflowContext>, - ) + const result = render(<WorkflowContext value={store}>{ui}</WorkflowContext>) return { ...result, @@ -52,14 +47,7 @@ describe('VariableTrigger', () => { const TriggerHarness = () => { const [open, setOpen] = React.useState(false) - return ( - <VariableTrigger - open={open} - setOpen={setOpen} - onClose={onClose} - onSave={vi.fn()} - /> - ) + return <VariableTrigger open={open} setOpen={setOpen} onClose={onClose} onSave={vi.fn()} /> } renderWithProviders(<TriggerHarness />) diff --git a/web/app/components/workflow/panel/env-panel/env-item.tsx b/web/app/components/workflow/panel/env-panel/env-item.tsx index 4a86b9d32b647a..ef852f16539b15 100644 --- a/web/app/components/workflow/panel/env-panel/env-item.tsx +++ b/web/app/components/workflow/panel/env-panel/env-item.tsx @@ -12,19 +12,16 @@ type EnvItemProps = { onDelete: (env: EnvironmentVariable) => void } -const EnvItem = ({ - env, - onEdit, - onDelete, -}: EnvItemProps) => { - const envSecrets = useStore(s => s.envSecrets) +const EnvItem = ({ env, onEdit, onDelete }: EnvItemProps) => { + const envSecrets = useStore((s) => s.envSecrets) const [destructive, setDestructive] = useState(false) return ( - <div className={cn( - 'group mb-1 rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', - destructive && 'border-state-destructive-border hover:bg-state-destructive-hover', - )} + <div + className={cn( + 'group mb-1 rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', + destructive && 'border-state-destructive-border hover:bg-state-destructive-hover', + )} > <div className="px-2.5 py-2"> <div className="flex items-center justify-between"> @@ -47,12 +44,19 @@ const EnvItem = ({ </div> </div> </div> - <div className="truncate system-xs-regular text-text-tertiary">{env.value_type === 'secret' ? envSecrets[env.id] : env.value}</div> + <div className="truncate system-xs-regular text-text-tertiary"> + {env.value_type === 'secret' ? envSecrets[env.id] : env.value} + </div> </div> {env.description && ( <> <div className="h-[0.5px] bg-divider-subtle" /> - <div className={cn('rounded-br-[8px] rounded-bl-[8px] bg-background-default-subtle px-2.5 py-2 group-hover:bg-transparent', destructive && 'bg-state-destructive-hover hover:bg-state-destructive-hover')}> + <div + className={cn( + 'rounded-br-[8px] rounded-bl-[8px] bg-background-default-subtle px-2.5 py-2 group-hover:bg-transparent', + destructive && 'bg-state-destructive-hover hover:bg-state-destructive-hover', + )} + > <div className="truncate system-xs-regular text-text-tertiary">{env.description}</div> </div> </> diff --git a/web/app/components/workflow/panel/env-panel/index.tsx b/web/app/components/workflow/panel/env-panel/index.tsx index c0c0be4bcb5014..237d4f584d6b80 100644 --- a/web/app/components/workflow/panel/env-panel/index.tsx +++ b/web/app/components/workflow/panel/env-panel/index.tsx @@ -1,18 +1,15 @@ -import type { - EnvironmentVariable, -} from '@/app/components/workflow/types' +import type { EnvironmentVariable } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { RiCloseLine } from '@remixicon/react' -import { - memo, - useCallback, - useState, -} from 'react' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' import RemoveEffectVarConfirm from '@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm' -import { findUsedVarNodes, updateNodeVars } from '@/app/components/workflow/nodes/_base/components/variable/utils' +import { + findUsedVarNodes, + updateNodeVars, +} from '@/app/components/workflow/nodes/_base/components/variable/utils' import EnvItem from '@/app/components/workflow/panel/env-panel/env-item' import VariableTrigger from '@/app/components/workflow/panel/env-panel/variable-trigger' import { useStore } from '@/app/components/workflow/store' @@ -20,13 +17,13 @@ import { useStore } from '@/app/components/workflow/store' const HIDDEN_SECRET_VALUE = '[__HIDDEN__]' const formatSecret = (secret: string) => { - return secret.length > 8 ? `${secret.slice(0, 6)}************${secret.slice(-2)}` : '********************' + return secret.length > 8 + ? `${secret.slice(0, 6)}************${secret.slice(-2)}` + : '********************' } const sanitizeSecretValue = (env: EnvironmentVariable) => { - return env.value_type === 'secret' - ? { ...env, value: HIDDEN_SECRET_VALUE } - : env + return env.value_type === 'secret' ? { ...env, value: HIDDEN_SECRET_VALUE } : env } const removeSecretFromMap = (secretMap: Record<string, string>, envId: string) => { @@ -54,89 +51,102 @@ const useEnvPanelActions = ({ }) => { const emitVarsAndFeaturesUpdate = useCallback(async () => { try { - const { webSocketClient } = await import('@/app/components/workflow/collaboration/core/websocket-manager') + const { webSocketClient } = + await import('@/app/components/workflow/collaboration/core/websocket-manager') const socket = webSocketClient.getSocket(appId) if (socket) { socket.emit('collaboration_event', { type: 'vars_and_features_update', }) } - } - catch (error) { + } catch (error) { console.error('Failed to emit vars_and_features_update event:', error) } }, [appId]) - const persistEnvironmentVariables = useCallback(async (nextEnvList: EnvironmentVariable[]) => { - try { - const { updateEnvironmentVariables } = await import('@/service/workflow') - await updateEnvironmentVariables({ - appId, - environmentVariables: nextEnvList, - }) - await emitVarsAndFeaturesUpdate() - return true - } - catch (error) { - console.error('Failed to update environment variables:', error) - return false - } - }, [appId, emitVarsAndFeaturesUpdate]) + const persistEnvironmentVariables = useCallback( + async (nextEnvList: EnvironmentVariable[]) => { + try { + const { updateEnvironmentVariables } = await import('@/service/workflow') + await updateEnvironmentVariables({ + appId, + environmentVariables: nextEnvList, + }) + await emitVarsAndFeaturesUpdate() + return true + } catch (error) { + console.error('Failed to update environment variables:', error) + return false + } + }, + [appId, emitVarsAndFeaturesUpdate], + ) - const getAffectedNodes = useCallback((env: EnvironmentVariable) => { - const { nodes: allNodes } = collaborativeWorkflow.getState() - return findUsedVarNodes( - ['env', env.name], - allNodes, - ) - }, [collaborativeWorkflow]) + const getAffectedNodes = useCallback( + (env: EnvironmentVariable) => { + const { nodes: allNodes } = collaborativeWorkflow.getState() + return findUsedVarNodes(['env', env.name], allNodes) + }, + [collaborativeWorkflow], + ) - const updateAffectedNodes = useCallback((currentEnv: EnvironmentVariable, nextSelector: string[]) => { - const { nodes, setNodes } = collaborativeWorkflow.getState() - const affectedNodes = getAffectedNodes(currentEnv) - const nextNodes = nodes.map((node) => { - if (affectedNodes.find(affectedNode => affectedNode.id === node.id)) - return updateNodeVars(node, ['env', currentEnv.name], nextSelector) + const updateAffectedNodes = useCallback( + (currentEnv: EnvironmentVariable, nextSelector: string[]) => { + const { nodes, setNodes } = collaborativeWorkflow.getState() + const affectedNodes = getAffectedNodes(currentEnv) + const nextNodes = nodes.map((node) => { + if (affectedNodes.find((affectedNode) => affectedNode.id === node.id)) + return updateNodeVars(node, ['env', currentEnv.name], nextSelector) - return node - }) - setNodes(nextNodes) - setControlPromptEditorRerenderKey(Date.now()) - }, [collaborativeWorkflow, getAffectedNodes, setControlPromptEditorRerenderKey]) - - const syncEnvList = useCallback(async ( - nextEnvList: EnvironmentVariable[], - options?: { - syncDraft?: boolean + return node + }) + setNodes(nextNodes) + setControlPromptEditorRerenderKey(Date.now()) }, - ) => { - updateEnvList(nextEnvList) - const shouldSyncDraft = options?.syncDraft ?? true + [collaborativeWorkflow, getAffectedNodes, setControlPromptEditorRerenderKey], + ) - let persisted = false - if (shouldSyncDraft) { - const syncDraftPromise = doSyncWorkflowDraft() - persisted = await persistEnvironmentVariables(nextEnvList) - await syncDraftPromise - } - else { - persisted = await persistEnvironmentVariables(nextEnvList) - } + const syncEnvList = useCallback( + async ( + nextEnvList: EnvironmentVariable[], + options?: { + syncDraft?: boolean + }, + ) => { + updateEnvList(nextEnvList) + const shouldSyncDraft = options?.syncDraft ?? true - updateEnvList(nextEnvList.map(sanitizeSecretValue)) - return persisted - }, [doSyncWorkflowDraft, persistEnvironmentVariables, updateEnvList]) + let persisted = false + if (shouldSyncDraft) { + const syncDraftPromise = doSyncWorkflowDraft() + persisted = await persistEnvironmentVariables(nextEnvList) + await syncDraftPromise + } else { + persisted = await persistEnvironmentVariables(nextEnvList) + } - const saveSecretValue = useCallback((env: EnvironmentVariable) => { - setEnvSecrets({ - ...envSecrets, - [env.id]: formatSecret(String(env.value)), - }) - }, [envSecrets, setEnvSecrets]) + updateEnvList(nextEnvList.map(sanitizeSecretValue)) + return persisted + }, + [doSyncWorkflowDraft, persistEnvironmentVariables, updateEnvList], + ) - const removeEnvSecret = useCallback((envId: string) => { - setEnvSecrets(removeSecretFromMap(envSecrets, envId)) - }, [envSecrets, setEnvSecrets]) + const saveSecretValue = useCallback( + (env: EnvironmentVariable) => { + setEnvSecrets({ + ...envSecrets, + [env.id]: formatSecret(String(env.value)), + }) + }, + [envSecrets, setEnvSecrets], + ) + + const removeEnvSecret = useCallback( + (envId: string) => { + setEnvSecrets(removeSecretFromMap(envSecrets, envId)) + }, + [envSecrets, setEnvSecrets], + ) return { getAffectedNodes, @@ -150,29 +160,24 @@ const useEnvPanelActions = ({ const EnvPanel = () => { const { t } = useTranslation() const collaborativeWorkflow = useCollaborativeWorkflow() - const setShowEnvPanel = useStore(s => s.setShowEnvPanel) - const envList = useStore(s => s.environmentVariables) as EnvironmentVariable[] - const envSecrets = useStore(s => s.envSecrets) - const updateEnvList = useStore(s => s.setEnvironmentVariables) - const setEnvSecrets = useStore(s => s.setEnvSecrets) - const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey) - const appId = useStore(s => s.appId) as string + const setShowEnvPanel = useStore((s) => s.setShowEnvPanel) + const envList = useStore((s) => s.environmentVariables) as EnvironmentVariable[] + const envSecrets = useStore((s) => s.envSecrets) + const updateEnvList = useStore((s) => s.setEnvironmentVariables) + const setEnvSecrets = useStore((s) => s.setEnvSecrets) + const setControlPromptEditorRerenderKey = useStore((s) => s.setControlPromptEditorRerenderKey) + const appId = useStore((s) => s.appId) as string const { doSyncWorkflowDraft } = useNodesSyncDraft() - const { - getAffectedNodes, - updateAffectedNodes, - syncEnvList, - saveSecretValue, - removeEnvSecret, - } = useEnvPanelActions({ - collaborativeWorkflow, - appId, - envSecrets, - updateEnvList, - setEnvSecrets, - setControlPromptEditorRerenderKey, - doSyncWorkflowDraft, - }) + const { getAffectedNodes, updateAffectedNodes, syncEnvList, saveSecretValue, removeEnvSecret } = + useEnvPanelActions({ + collaborativeWorkflow, + appId, + envSecrets, + updateEnvList, + setEnvSecrets, + setControlPromptEditorRerenderKey, + doSyncWorkflowDraft, + }) const [showVariableModal, setShowVariableModal] = useState(false) const [currentVar, setCurrentVar] = useState<EnvironmentVariable>() @@ -185,63 +190,73 @@ const EnvPanel = () => { setShowVariableModal(true) } - const handleDelete = useCallback(async (env: EnvironmentVariable) => { - const nextEnvList = envList.filter(e => e.id !== env.id) - setCacheForDelete(undefined) - setShowRemoveVarConfirm(false) - updateAffectedNodes(env, []) - if (env.value_type === 'secret') - removeEnvSecret(env.id) - await syncEnvList(nextEnvList) - }, [envList, removeEnvSecret, syncEnvList, updateAffectedNodes]) + const handleDelete = useCallback( + async (env: EnvironmentVariable) => { + const nextEnvList = envList.filter((e) => e.id !== env.id) + setCacheForDelete(undefined) + setShowRemoveVarConfirm(false) + updateAffectedNodes(env, []) + if (env.value_type === 'secret') removeEnvSecret(env.id) + await syncEnvList(nextEnvList) + }, + [envList, removeEnvSecret, syncEnvList, updateAffectedNodes], + ) - const deleteCheck = useCallback((env: EnvironmentVariable) => { - const affectedNodes = getAffectedNodes(env) - if (affectedNodes.length > 0) { - setCacheForDelete(env) - setShowRemoveVarConfirm(true) - } - else { - handleDelete(env) - } - }, [getAffectedNodes, handleDelete]) + const deleteCheck = useCallback( + (env: EnvironmentVariable) => { + const affectedNodes = getAffectedNodes(env) + if (affectedNodes.length > 0) { + setCacheForDelete(env) + setShowRemoveVarConfirm(true) + } else { + handleDelete(env) + } + }, + [getAffectedNodes, handleDelete], + ) - const handleSave = useCallback(async (env: EnvironmentVariable) => { - let newEnv = env - if (!currentVar) { - if (env.value_type === 'secret') - saveSecretValue(env) - await syncEnvList([env, ...envList]) - return - } + const handleSave = useCallback( + async (env: EnvironmentVariable) => { + let newEnv = env + if (!currentVar) { + if (env.value_type === 'secret') saveSecretValue(env) + await syncEnvList([env, ...envList]) + return + } - if (currentVar.value_type === 'secret') { - if (env.value_type === 'secret') { - if (envSecrets[currentVar.id] !== env.value) { - newEnv = env - saveSecretValue(env) - } - else { - newEnv = sanitizeSecretValue(env) + if (currentVar.value_type === 'secret') { + if (env.value_type === 'secret') { + if (envSecrets[currentVar.id] !== env.value) { + newEnv = env + saveSecretValue(env) + } else { + newEnv = sanitizeSecretValue(env) + } + } else { + removeEnvSecret(currentVar.id) } + } else if (env.value_type === 'secret') { + saveSecretValue(env) } - else { - removeEnvSecret(currentVar.id) - } - } - else if (env.value_type === 'secret') { - saveSecretValue(env) - } - const nextEnvList = envList.map(e => e.id === currentVar.id ? newEnv : e) - const hasEnvNameChanged = currentVar.name !== env.name - if (hasEnvNameChanged) - updateAffectedNodes(currentVar, ['env', env.name]) + const nextEnvList = envList.map((e) => (e.id === currentVar.id ? newEnv : e)) + const hasEnvNameChanged = currentVar.name !== env.name + if (hasEnvNameChanged) updateAffectedNodes(currentVar, ['env', env.name]) - const persisted = await syncEnvList(nextEnvList, { syncDraft: hasEnvNameChanged }) - if (!persisted && !hasEnvNameChanged) - await doSyncWorkflowDraft() - }, [currentVar, doSyncWorkflowDraft, envList, envSecrets, removeEnvSecret, saveSecretValue, syncEnvList, updateAffectedNodes]) + const persisted = await syncEnvList(nextEnvList, { syncDraft: hasEnvNameChanged }) + if (!persisted && !hasEnvNameChanged) await doSyncWorkflowDraft() + }, + [ + currentVar, + doSyncWorkflowDraft, + envList, + envSecrets, + removeEnvSecret, + saveSecretValue, + syncEnvList, + updateAffectedNodes, + ], + ) const handleVariableModalClose = () => { setCurrentVar(undefined) @@ -254,7 +269,7 @@ const EnvPanel = () => { )} > <div className="flex shrink-0 items-center justify-between p-4 pb-0 system-xl-semibold text-text-primary"> - {t($ => $['env.envPanelTitle'], { ns: 'workflow' })} + {t(($) => $['env.envPanelTitle'], { ns: 'workflow' })} <div className="flex items-center"> <div className="flex size-6 cursor-pointer items-center justify-center" @@ -265,7 +280,9 @@ const EnvPanel = () => { </div> </div> </div> - <div className="shrink-0 px-4 py-1 system-sm-regular text-text-tertiary">{t($ => $['env.envDescription'], { ns: 'workflow' })}</div> + <div className="shrink-0 px-4 py-1 system-sm-regular text-text-tertiary"> + {t(($) => $['env.envDescription'], { ns: 'workflow' })} + </div> <div className="shrink-0 px-4 pt-2 pb-3"> <VariableTrigger open={showVariableModal} @@ -276,13 +293,8 @@ const EnvPanel = () => { /> </div> <div className="grow overflow-y-auto rounded-b-2xl px-4"> - {envList.map(env => ( - <EnvItem - key={env.id} - env={env} - onEdit={handleEdit} - onDelete={deleteCheck} - /> + {envList.map((env) => ( + <EnvItem key={env.id} env={env} onEdit={handleEdit} onDelete={deleteCheck} /> ))} </div> <RemoveEffectVarConfirm diff --git a/web/app/components/workflow/panel/env-panel/variable-modal.tsx b/web/app/components/workflow/panel/env-panel/variable-modal.tsx index b6b3c10a9981bf..0cfda0c8f31397 100644 --- a/web/app/components/workflow/panel/env-panel/variable-modal.tsx +++ b/web/app/components/workflow/panel/env-panel/variable-modal.tsx @@ -17,11 +17,7 @@ type ModalPropsType = { onClose: () => void onSave: (env: EnvironmentVariable) => void } -const VariableModal = ({ - env, - onClose, - onSave, -}: ModalPropsType) => { +const VariableModal = ({ env, onClose, onSave }: ModalPropsType) => { const { t } = useTranslation() const workflowStore = useWorkflowStore() const [type, setType] = React.useState<'string' | 'number' | 'secret'>('string') @@ -32,7 +28,12 @@ const VariableModal = ({ const checkVariableName = (value: string) => { const { isValid, errorMessageKey } = checkKeys([value], false) if (!isValid) { - toast.error(t($ => $[`varKeyError.${errorMessageKey}`], { ns: 'appDebug', key: t($ => $['env.modal.name'], { ns: 'workflow' }) })) + toast.error( + t(($) => $[`varKeyError.${errorMessageKey}`], { + ns: 'appDebug', + key: t(($) => $['env.modal.name'], { ns: 'workflow' }), + }), + ) return false } return true @@ -40,24 +41,31 @@ const VariableModal = ({ const handleVarNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { replaceSpaceWithUnderscoreInVarNameInput(e.target) - if (!!e.target.value && !checkVariableName(e.target.value)) - return + if (!!e.target.value && !checkVariableName(e.target.value)) return setName(e.target.value || '') } const handleSave = () => { - if (!checkVariableName(name)) - return - if (!value) - return toast.error(t($ => $['env.modal.valueRequired'], { ns: 'workflow' })) + if (!checkVariableName(name)) return + if (!value) return toast.error(t(($) => $['env.modal.valueRequired'], { ns: 'workflow' })) // Add check for duplicate name when editing const envList = workflowStore.getState().environmentVariables - if (env && env.name !== name && envList.some(e => e.name === name)) - return toast.error(t($ => $['varKeyError.keyAlreadyExists'], { ns: 'appDebug', key: t($ => $['env.modal.name'], { ns: 'workflow' }) })) + if (env && env.name !== name && envList.some((e) => e.name === name)) + return toast.error( + t(($) => $['varKeyError.keyAlreadyExists'], { + ns: 'appDebug', + key: t(($) => $['env.modal.name'], { ns: 'workflow' }), + }), + ) // Original check for create new variable - if (!env && envList.some(e => e.name === name)) - return toast.error(t($ => $['varKeyError.keyAlreadyExists'], { ns: 'appDebug', key: t($ => $['env.modal.name'], { ns: 'workflow' }) })) + if (!env && envList.some((e) => e.name === name)) + return toast.error( + t(($) => $['varKeyError.keyAlreadyExists'], { + ns: 'appDebug', + key: t(($) => $['env.modal.name'], { ns: 'workflow' }), + }), + ) onSave({ id: env ? env.id : uuid4(), @@ -81,15 +89,16 @@ const VariableModal = ({ return ( <div - className={cn('flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl')} + className={cn( + 'flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl', + )} > <div className="mb-3 flex shrink-0 items-center justify-between p-4 pb-0 system-xl-semibold text-text-primary"> - {!env ? t($ => $['env.modal.title'], { ns: 'workflow' }) : t($ => $['env.modal.editTitle'], { ns: 'workflow' })} + {!env + ? t(($) => $['env.modal.title'], { ns: 'workflow' }) + : t(($) => $['env.modal.editTitle'], { ns: 'workflow' })} <div className="flex items-center"> - <div - className="flex size-6 cursor-pointer items-center justify-center" - onClick={onClose} - > + <div className="flex size-6 cursor-pointer items-center justify-center" onClick={onClose}> <RiCloseLine className="size-4 text-text-tertiary" /> </div> </div> @@ -97,12 +106,15 @@ const VariableModal = ({ <div className="px-4 py-2"> {/* type */} <div className="mb-4"> - <div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary">{t($ => $['env.modal.type'], { ns: 'workflow' })}</div> + <div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary"> + {t(($) => $['env.modal.type'], { ns: 'workflow' })} + </div> <div className="flex gap-2"> <div className={cn( 'flex w-[106px] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', - type === 'string' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border', + type === 'string' && + 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border', )} onClick={() => setType('string')} > @@ -111,12 +123,12 @@ const VariableModal = ({ <div className={cn( 'flex w-[106px] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', - type === 'number' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg font-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border', + type === 'number' && + 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg font-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border', )} onClick={() => { setType('number') - if (!(/^\d$/).test(value)) - setValue('') + if (!/^\d$/.test(value)) setValue('') }} > Number @@ -124,76 +136,83 @@ const VariableModal = ({ <div className={cn( 'flex w-[106px] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', - type === 'secret' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg font-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border', + type === 'secret' && + 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg font-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border', )} onClick={() => setType('secret')} > <span>Secret</span> <Infotip - aria-label={t($ => $['env.modal.secretTip'], { ns: 'workflow' })} + aria-label={t(($) => $['env.modal.secretTip'], { ns: 'workflow' })} className="ml-0.5 size-3.5" popupClassName="w-[240px]" > - {t($ => $['env.modal.secretTip'], { ns: 'workflow' })} + {t(($) => $['env.modal.secretTip'], { ns: 'workflow' })} </Infotip> </div> </div> </div> {/* name */} <div className="mb-4"> - <div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary">{t($ => $['env.modal.name'], { ns: 'workflow' })}</div> + <div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary"> + {t(($) => $['env.modal.name'], { ns: 'workflow' })} + </div> <div className="flex"> <Input - placeholder={t($ => $['env.modal.namePlaceholder'], { ns: 'workflow' }) || ''} + placeholder={t(($) => $['env.modal.namePlaceholder'], { ns: 'workflow' }) || ''} value={name} onChange={handleVarNameChange} - onBlur={e => checkVariableName(e.target.value)} + onBlur={(e) => checkVariableName(e.target.value)} type="text" /> </div> </div> {/* value */} <div className="mb-4"> - <div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary">{t($ => $['env.modal.value'], { ns: 'workflow' })}</div> + <div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary"> + {t(($) => $['env.modal.value'], { ns: 'workflow' })} + </div> <div className="flex"> - { - type !== 'number' - ? ( - <textarea - className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:system-sm-regular placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs" - value={value} - placeholder={t($ => $['env.modal.valuePlaceholder'], { ns: 'workflow' }) || ''} - onChange={e => setValue(e.target.value)} - /> - ) - : ( - <Input - placeholder={t($ => $['env.modal.valuePlaceholder'], { ns: 'workflow' }) || ''} - value={value} - onChange={e => setValue(e.target.value)} - type="number" - /> - ) - } + {type !== 'number' ? ( + <textarea + className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:system-sm-regular placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs" + value={value} + placeholder={t(($) => $['env.modal.valuePlaceholder'], { ns: 'workflow' }) || ''} + onChange={(e) => setValue(e.target.value)} + /> + ) : ( + <Input + placeholder={t(($) => $['env.modal.valuePlaceholder'], { ns: 'workflow' }) || ''} + value={value} + onChange={(e) => setValue(e.target.value)} + type="number" + /> + )} </div> </div> {/* description */} <div className=""> - <div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary">{t($ => $['env.modal.description'], { ns: 'workflow' })}</div> + <div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary"> + {t(($) => $['env.modal.description'], { ns: 'workflow' })} + </div> <div className="flex"> <textarea className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:system-sm-regular placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs" value={description} - placeholder={t($ => $['env.modal.descriptionPlaceholder'], { ns: 'workflow' }) || ''} - onChange={e => setDescription(e.target.value)} + placeholder={ + t(($) => $['env.modal.descriptionPlaceholder'], { ns: 'workflow' }) || '' + } + onChange={(e) => setDescription(e.target.value)} /> </div> </div> </div> <div className="flex flex-row-reverse rounded-b-2xl p-4 pt-2"> <div className="flex gap-2"> - <Button onClick={onClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button variant="primary" onClick={handleSave}>{t($ => $['operation.save'], { ns: 'common' })}</Button> + <Button onClick={onClose}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button> + <Button variant="primary" onClick={handleSave}> + {t(($) => $['operation.save'], { ns: 'common' })} + </Button> </div> </div> </div> diff --git a/web/app/components/workflow/panel/env-panel/variable-trigger.tsx b/web/app/components/workflow/panel/env-panel/variable-trigger.tsx index 09bbaa6196f062..4d9f3fbe436a48 100644 --- a/web/app/components/workflow/panel/env-panel/variable-trigger.tsx +++ b/web/app/components/workflow/panel/env-panel/variable-trigger.tsx @@ -15,32 +15,27 @@ type Props = Readonly<{ onSave: (env: EnvironmentVariable) => void }> -const VariableTrigger = ({ - open, - setOpen, - env, - onClose, - onSave, -}: Props) => { +const VariableTrigger = ({ open, setOpen, env, onClose, onSave }: Props) => { const { t } = useTranslation() - const handleOpenChange = React.useCallback((nextOpen: boolean) => { - setOpen(nextOpen) - if (!nextOpen) - onClose() - }, [onClose, setOpen]) + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) onClose() + }, + [onClose, setOpen], + ) return ( - <Popover - open={open} - onOpenChange={handleOpenChange} - > + <Popover open={open} onOpenChange={handleOpenChange}> <PopoverTrigger - render={( + render={ <Button variant="primary"> <RiAddLine className="mr-1 size-4" /> - <span className="system-sm-medium">{t($ => $['env.envPanelButton'], { ns: 'workflow' })}</span> + <span className="system-sm-medium"> + {t(($) => $['env.envPanelButton'], { ns: 'workflow' })} + </span> </Button> - )} + } /> <PopoverContent placement="left-start" diff --git a/web/app/components/workflow/panel/global-variable-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/global-variable-panel/__tests__/index.spec.tsx index 025f90f2113f4b..1b95d76180e33d 100644 --- a/web/app/components/workflow/panel/global-variable-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/global-variable-panel/__tests__/index.spec.tsx @@ -7,9 +7,12 @@ let mockIsWorkflowPage = false const mockSetShowGlobalVariablePanel = vi.fn() vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { setShowGlobalVariablePanel: (visible: boolean) => void }) => unknown) => selector({ - setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel, - }), + useStore: ( + selector: (state: { setShowGlobalVariablePanel: (visible: boolean) => void }) => unknown, + ) => + selector({ + setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel, + }), })) vi.mock('../../../constants', () => ({ @@ -32,8 +35,12 @@ describe('global-variable-panel path', () => { const { container } = render(<Panel />) expect(screen.getByText('workflow.globalVar.title')).toBeInTheDocument() - expect(screen.getByText((_, node) => node?.textContent === 'sys.conversation_id')).toBeInTheDocument() - expect(screen.getByText((_, node) => node?.textContent === 'sys.dialog_count')).toBeInTheDocument() + expect( + screen.getByText((_, node) => node?.textContent === 'sys.conversation_id'), + ).toBeInTheDocument() + expect( + screen.getByText((_, node) => node?.textContent === 'sys.dialog_count'), + ).toBeInTheDocument() expect(screen.queryByText('sys.timestamp')).not.toBeInTheDocument() await user.click(container.querySelector('.cursor-pointer') as HTMLElement) @@ -50,6 +57,8 @@ describe('global-variable-panel path', () => { expect(screen.queryByText('sys.conversation_id')).not.toBeInTheDocument() expect(screen.queryByText('sys.dialog_count')).not.toBeInTheDocument() expect(screen.getByText((_, node) => node?.textContent === 'sys.timestamp')).toBeInTheDocument() - expect(screen.getByText('workflow.globalVar.fieldsDescription.triggerTimestamp')).toBeInTheDocument() + expect( + screen.getByText('workflow.globalVar.fieldsDescription.triggerTimestamp'), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/panel/global-variable-panel/index.tsx b/web/app/components/workflow/panel/global-variable-panel/index.tsx index 78f070fda853c9..736e3ef909390f 100644 --- a/web/app/components/workflow/panel/global-variable-panel/index.tsx +++ b/web/app/components/workflow/panel/global-variable-panel/index.tsx @@ -1,12 +1,8 @@ import type { GlobalVariable } from '../../types' - import { cn } from '@langgenius/dify-ui/cn' import { RiCloseLine } from '@remixicon/react' -import { - memo, -} from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' - import { useStore } from '@/app/components/workflow/store' import { isInWorkflowPage } from '../../constants' import { useIsChatMode } from '../../hooks' @@ -15,48 +11,57 @@ import Item from './item' const Panel = () => { const { t } = useTranslation() const isChatMode = useIsChatMode() - const setShowPanel = useStore(s => s.setShowGlobalVariablePanel) + const setShowPanel = useStore((s) => s.setShowGlobalVariablePanel) const isWorkflowPage = isInWorkflowPage() const globalVariableList: GlobalVariable[] = [ ...(isChatMode - ? [{ - name: 'conversation_id', - value_type: 'string' as const, - description: t($ => $['globalVar.fieldsDescription.conversationId'], { ns: 'workflow' }), - }, { - name: 'dialog_count', - value_type: 'number' as const, - description: t($ => $['globalVar.fieldsDescription.dialogCount'], { ns: 'workflow' }), - }] + ? [ + { + name: 'conversation_id', + value_type: 'string' as const, + description: t(($) => $['globalVar.fieldsDescription.conversationId'], { + ns: 'workflow', + }), + }, + { + name: 'dialog_count', + value_type: 'number' as const, + description: t(($) => $['globalVar.fieldsDescription.dialogCount'], { ns: 'workflow' }), + }, + ] : []), { name: 'user_id', value_type: 'string', - description: t($ => $['globalVar.fieldsDescription.userId'], { ns: 'workflow' }), + description: t(($) => $['globalVar.fieldsDescription.userId'], { ns: 'workflow' }), }, { name: 'app_id', value_type: 'string', - description: t($ => $['globalVar.fieldsDescription.appId'], { ns: 'workflow' }), + description: t(($) => $['globalVar.fieldsDescription.appId'], { ns: 'workflow' }), }, { name: 'workflow_id', value_type: 'string', - description: t($ => $['globalVar.fieldsDescription.workflowId'], { ns: 'workflow' }), + description: t(($) => $['globalVar.fieldsDescription.workflowId'], { ns: 'workflow' }), }, { name: 'workflow_run_id', value_type: 'string', - description: t($ => $['globalVar.fieldsDescription.workflowRunId'], { ns: 'workflow' }), + description: t(($) => $['globalVar.fieldsDescription.workflowRunId'], { ns: 'workflow' }), }, // is workflow - ...((isWorkflowPage && !isChatMode) - ? [{ - name: 'timestamp', - value_type: 'number' as const, - description: t($ => $['globalVar.fieldsDescription.triggerTimestamp'], { ns: 'workflow' }), - }] + ...(isWorkflowPage && !isChatMode + ? [ + { + name: 'timestamp', + value_type: 'number' as const, + description: t(($) => $['globalVar.fieldsDescription.triggerTimestamp'], { + ns: 'workflow', + }), + }, + ] : []), ] @@ -67,7 +72,7 @@ const Panel = () => { )} > <div className="flex shrink-0 items-center justify-between p-4 pb-0 system-xl-semibold text-text-primary"> - {t($ => $['globalVar.title'], { ns: 'workflow' })} + {t(($) => $['globalVar.title'], { ns: 'workflow' })} <div className="flex items-center"> <div className="flex size-6 cursor-pointer items-center justify-center" @@ -77,14 +82,13 @@ const Panel = () => { </div> </div> </div> - <div className="shrink-0 px-4 py-1 system-sm-regular text-text-tertiary">{t($ => $['globalVar.description'], { ns: 'workflow' })}</div> + <div className="shrink-0 px-4 py-1 system-sm-regular text-text-tertiary"> + {t(($) => $['globalVar.description'], { ns: 'workflow' })} + </div> <div className="mt-4 grow overflow-y-auto rounded-b-2xl px-4"> - {globalVariableList.map(item => ( - <Item - key={item.name} - payload={item} - /> + {globalVariableList.map((item) => ( + <Item key={item.name} payload={item} /> ))} </div> </div> diff --git a/web/app/components/workflow/panel/global-variable-panel/item.tsx b/web/app/components/workflow/panel/global-variable-panel/item.tsx index e803570df951fe..8a30816c2d61cd 100644 --- a/web/app/components/workflow/panel/global-variable-panel/item.tsx +++ b/web/app/components/workflow/panel/global-variable-panel/item.tsx @@ -1,7 +1,6 @@ import type { GlobalVariable } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { capitalize } from 'es-toolkit/string' - import { memo } from 'react' import { GlobalVariable as GlobalVariableIcon } from '@/app/components/base/icons/src/vender/line/others' @@ -9,13 +8,12 @@ type Props = Readonly<{ payload: GlobalVariable }> -const Item = ({ - payload, -}: Props) => { +const Item = ({ payload }: Props) => { return ( - <div className={cn( - 'mb-1 rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2.5 py-2 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', - )} + <div + className={cn( + 'mb-1 rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2.5 py-2 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', + )} > <div className="flex items-center justify-between"> <div className="flex grow items-center gap-1"> @@ -24,10 +22,14 @@ const Item = ({ <span className="text-text-tertiary">sys.</span> {payload.name} </div> - <div className="system-xs-medium text-text-tertiary">{capitalize(payload.value_type)}</div> + <div className="system-xs-medium text-text-tertiary"> + {capitalize(payload.value_type)} + </div> </div> </div> - <div className="mt-1.5 truncate system-xs-regular text-text-tertiary">{payload.description}</div> + <div className="mt-1.5 truncate system-xs-regular text-text-tertiary"> + {payload.description} + </div> </div> ) } diff --git a/web/app/components/workflow/panel/human-input-filled-form-list.tsx b/web/app/components/workflow/panel/human-input-filled-form-list.tsx index 7f39e939fe9a81..03855baf518fc5 100644 --- a/web/app/components/workflow/panel/human-input-filled-form-list.tsx +++ b/web/app/components/workflow/panel/human-input-filled-form-list.tsx @@ -11,22 +11,17 @@ const HumanInputFilledFormList = ({ }: HumanInputFilledFormListProps) => { return ( <div className="mt-3 flex flex-col gap-y-3 first:mt-0"> - { - humanInputFilledFormDataList.map(formData => ( - <ContentWrapper - key={formData.node_id} - nodeTitle={formData.node_title} - showExpandIcon - className="bg-components-panel-bg" - expanded - > - <SubmittedHumanInputContent - key={formData.node_id} - formData={formData} - /> - </ContentWrapper> - )) - } + {humanInputFilledFormDataList.map((formData) => ( + <ContentWrapper + key={formData.node_id} + nodeTitle={formData.node_title} + showExpandIcon + className="bg-components-panel-bg" + expanded + > + <SubmittedHumanInputContent key={formData.node_id} formData={formData} /> + </ContentWrapper> + ))} </div> ) } diff --git a/web/app/components/workflow/panel/human-input-form-list.tsx b/web/app/components/workflow/panel/human-input-form-list.tsx index e032bfbb89c9e3..9d9ed38cf9261b 100644 --- a/web/app/components/workflow/panel/human-input-form-list.tsx +++ b/web/app/components/workflow/panel/human-input-form-list.tsx @@ -18,64 +18,79 @@ const HumanInputFormList = ({ }: HumanInputFormListProps) => { const store = useStoreApi() - const getHumanInputNodeData = useCallback((nodeID: string) => { - const { - getNodes, - } = store.getState() - const nodes = getNodes().filter(node => node.type === CUSTOM_NODE) - const node = nodes.find(n => n.id === nodeID) - return node - }, [store]) + const getHumanInputNodeData = useCallback( + (nodeID: string) => { + const { getNodes } = store.getState() + const nodes = getNodes().filter((node) => node.type === CUSTOM_NODE) + const node = nodes.find((n) => n.id === nodeID) + return node + }, + [store], + ) - const deliveryMethodsConfig = useMemo((): Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }> => { - if (!humanInputFormDataList.length) - return {} - return humanInputFormDataList.reduce((acc, formData) => { - const deliveryMethodsConfig = getHumanInputNodeData(formData.node_id)?.data.delivery_methods || [] - if (!deliveryMethodsConfig.length) { + const deliveryMethodsConfig = useMemo((): Record< + string, + { showEmailTip: boolean; isEmailDebugMode: boolean; showDebugModeTip: boolean } + > => { + if (!humanInputFormDataList.length) return {} + return humanInputFormDataList.reduce( + (acc, formData) => { + const deliveryMethodsConfig = + getHumanInputNodeData(formData.node_id)?.data.delivery_methods || [] + if (!deliveryMethodsConfig.length) { + acc[formData.node_id] = { + showEmailTip: false, + isEmailDebugMode: false, + showDebugModeTip: false, + } + return acc + } + const isWebappEnabled = deliveryMethodsConfig.some( + (method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled, + ) + const isEmailEnabled = deliveryMethodsConfig.some( + (method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled, + ) + const isEmailDebugMode = deliveryMethodsConfig.some( + (method: DeliveryMethod) => + method.type === DeliveryMethodType.Email && method.config?.debug_mode, + ) acc[formData.node_id] = { - showEmailTip: false, - isEmailDebugMode: false, - showDebugModeTip: false, + showEmailTip: isEmailEnabled, + isEmailDebugMode, + showDebugModeTip: !isWebappEnabled, } return acc - } - const isWebappEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled) - const isEmailEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled) - const isEmailDebugMode = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.config?.debug_mode) - acc[formData.node_id] = { - showEmailTip: isEmailEnabled, - isEmailDebugMode, - showDebugModeTip: !isWebappEnabled, - } - return acc - }, {} as Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }>) + }, + {} as Record< + string, + { showEmailTip: boolean; isEmailDebugMode: boolean; showDebugModeTip: boolean } + >, + ) }, [getHumanInputNodeData, humanInputFormDataList]) const filteredHumanInputFormDataList = useMemo(() => { - return humanInputFormDataList.filter(formData => formData.display_in_ui) + return humanInputFormDataList.filter((formData) => formData.display_in_ui) }, [humanInputFormDataList]) return ( <div className="flex flex-col gap-y-3"> - { - filteredHumanInputFormDataList.map(formData => ( - <ContentWrapper + {filteredHumanInputFormDataList.map((formData) => ( + <ContentWrapper + key={formData.node_id} + nodeTitle={formData.node_title} + className="bg-components-panel-bg" + > + <UnsubmittedHumanInputContent key={formData.node_id} - nodeTitle={formData.node_title} - className="bg-components-panel-bg" - > - <UnsubmittedHumanInputContent - key={formData.node_id} - formData={formData} - showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip} - isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode} - showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip} - onSubmit={onHumanInputFormSubmit} - /> - </ContentWrapper> - )) - } + formData={formData} + showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip} + isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode} + showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip} + onSubmit={onHumanInputFormSubmit} + /> + </ContentWrapper> + ))} </div> ) } diff --git a/web/app/components/workflow/panel/index.tsx b/web/app/components/workflow/panel/index.tsx index d6f537d0879405..0431246bb57ebd 100644 --- a/web/app/components/workflow/panel/index.tsx +++ b/web/app/components/workflow/panel/index.tsx @@ -9,9 +9,12 @@ import { Panel as NodePanel } from '../nodes' import { useStore } from '../store' import EnvPanel from './env-panel' -const VersionHistoryPanel = dynamic(() => import('@/app/components/workflow/panel/version-history-panel'), { - ssr: false, -}) +const VersionHistoryPanel = dynamic( + () => import('@/app/components/workflow/panel/version-history-panel'), + { + ssr: false, + }, +) export type PanelProps = { components?: { @@ -25,11 +28,9 @@ export type PanelProps = { * Reference MDN standard implementation:https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserverEntry/borderBoxSize */ const getEntryWidth = (entry: ResizeObserverEntry, element: HTMLElement): number => { - if (entry.borderBoxSize?.length > 0) - return entry.borderBoxSize[0]!.inlineSize + if (entry.borderBoxSize?.length > 0) return entry.borderBoxSize[0]!.inlineSize - if (entry.contentRect.width > 0) - return entry.contentRect.width + if (entry.contentRect.width > 0) return entry.contentRect.width return element.getBoundingClientRect().width } @@ -41,18 +42,15 @@ const useResizeObserver = (callback: (width: number) => void) => { useEffect(() => { const element = elementRef.current - if (!element) - return + if (!element) return widthRef.current = undefined const updateWidth = (width: number) => { - if (widthRef.current === width) - return + if (widthRef.current === width) return widthRef.current = width - if (animationFrameRef.current) - cancelAnimationFrame(animationFrameRef.current) + if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current) animationFrameRef.current = requestAnimationFrame(() => { animationFrameRef.current = undefined @@ -61,8 +59,7 @@ const useResizeObserver = (callback: (width: number) => void) => { } const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) - updateWidth(getEntryWidth(entry, element)) + for (const entry of entries) updateWidth(getEntryWidth(entry, element)) }) resizeObserver.observe(element) @@ -81,30 +78,29 @@ const useResizeObserver = (callback: (width: number) => void) => { return elementRef } -const Panel: FC<PanelProps> = ({ - components, - versionHistoryPanelProps, -}) => { - const selectedNode = useReactflow(useShallow((s) => { - const nodes = s.getNodes() - const currentNode = nodes.find(node => node.data.selected) - - if (currentNode) { - return { - id: currentNode.id, - type: currentNode.type, - data: currentNode.data, +const Panel: FC<PanelProps> = ({ components, versionHistoryPanelProps }) => { + const selectedNode = useReactflow( + useShallow((s) => { + const nodes = s.getNodes() + const currentNode = nodes.find((node) => node.data.selected) + + if (currentNode) { + return { + id: currentNode.id, + type: currentNode.type, + data: currentNode.data, + } } - } - })) - const showEnvPanel = useStore(s => s.showEnvPanel) - const isRestoring = useStore(s => s.isRestoring) - const showWorkflowVersionHistoryPanel = useStore(s => s.showWorkflowVersionHistoryPanel) + }), + ) + const showEnvPanel = useStore((s) => s.showEnvPanel) + const isRestoring = useStore((s) => s.isRestoring) + const showWorkflowVersionHistoryPanel = useStore((s) => s.showWorkflowVersionHistoryPanel) // widths used for adaptive layout - const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) - const previewPanelWidth = useStore(s => s.previewPanelWidth) - const setPreviewPanelWidth = useStore(s => s.setPreviewPanelWidth) + const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth) + const previewPanelWidth = useStore((s) => s.previewPanelWidth) + const setPreviewPanelWidth = useStore((s) => s.setPreviewPanelWidth) // When a node is selected and the NodePanel appears, if the current width // of preview/otherPanel is too large, it may result in the total width of @@ -114,19 +110,17 @@ const Panel: FC<PanelProps> = ({ // while still ensuring that previewPanelWidth ≥ 400. useEffect(() => { - if (!selectedNode || !workflowCanvasWidth) - return + if (!selectedNode || !workflowCanvasWidth) return const reservedCanvasWidth = 400 // Reserve the minimum visible width for the canvas const minNodePanelWidth = 400 const maxAllowed = Math.max(workflowCanvasWidth - reservedCanvasWidth - minNodePanelWidth, 400) - if (previewPanelWidth > maxAllowed) - setPreviewPanelWidth(maxAllowed) + if (previewPanelWidth > maxAllowed) setPreviewPanelWidth(maxAllowed) }, [selectedNode, workflowCanvasWidth, previewPanelWidth, setPreviewPanelWidth]) - const setRightPanelWidth = useStore(s => s.setRightPanelWidth) - const setOtherPanelWidth = useStore(s => s.setOtherPanelWidth) + const setRightPanelWidth = useStore((s) => s.setRightPanelWidth) + const setOtherPanelWidth = useStore((s) => s.setOtherPanelWidth) const rightPanelRef = useResizeObserver(setRightPanelWidth) @@ -142,23 +136,12 @@ const Panel: FC<PanelProps> = ({ > {components?.left} {!!selectedNode && <NodePanel {...selectedNode} />} - <div - className="relative" - ref={otherPanelRef} - > - { - components?.right - } - { - showWorkflowVersionHistoryPanel && versionHistoryPanelProps && ( - <VersionHistoryPanel {...versionHistoryPanelProps} /> - ) - } - { - showEnvPanel && ( - <EnvPanel /> - ) - } + <div className="relative" ref={otherPanelRef}> + {components?.right} + {showWorkflowVersionHistoryPanel && versionHistoryPanelProps && ( + <VersionHistoryPanel {...versionHistoryPanelProps} /> + )} + {showEnvPanel && <EnvPanel />} </div> </div> ) diff --git a/web/app/components/workflow/panel/inputs-panel.tsx b/web/app/components/workflow/panel/inputs-panel.tsx index 5d45d9c976934c..0aed00193e7328 100644 --- a/web/app/components/workflow/panel/inputs-panel.tsx +++ b/web/app/components/workflow/panel/inputs-panel.tsx @@ -1,29 +1,16 @@ import type { StartNodeType } from '../nodes/start/types' import { Button } from '@langgenius/dify-ui/button' -import { - memo, - useCallback, - useMemo, -} from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import { useCheckInputsForms } from '@/app/components/base/chat/chat/check-input-forms-hooks' -import { - getProcessedInputs, -} from '@/app/components/base/chat/chat/utils' +import { getProcessedInputs } from '@/app/components/base/chat/chat/utils' import { TransferMethod } from '../../base/text-generation/types' import { useWorkflowRun } from '../hooks' import { useHooksStore } from '../hooks-store' import FormItem from '../nodes/_base/components/before-run-form/form-item' -import { - useStore, - useWorkflowStore, -} from '../store' -import { - BlockEnum, - InputVarType, - WorkflowRunningStatus, -} from '../types' +import { useStore, useWorkflowStore } from '../store' +import { BlockEnum, InputVarType, WorkflowRunningStatus } from '../types' type Props = Readonly<{ onRun: () => void @@ -32,16 +19,14 @@ type Props = Readonly<{ const InputsPanel = ({ onRun }: Props) => { const { t } = useTranslation() const workflowStore = useWorkflowStore() - const inputs = useStore(s => s.inputs) - const fileSettings = useHooksStore(s => s.configsMap?.fileSettings) - const canRunWorkflow = useHooksStore(s => s.accessControl.canRun) + const inputs = useStore((s) => s.inputs) + const fileSettings = useHooksStore((s) => s.configsMap?.fileSettings) + const canRunWorkflow = useHooksStore((s) => s.accessControl.canRun) const nodes = useNodes<StartNodeType>() - const files = useStore(s => s.files) - const workflowRunningData = useStore(s => s.workflowRunningData) - const { - handleRun, - } = useWorkflowRun() - const startNode = nodes.find(node => node.data.type === BlockEnum.Start) + const files = useStore((s) => s.files) + const workflowRunningData = useStore((s) => s.workflowRunningData) + const { handleRun } = useWorkflowRun() + const startNode = nodes.find((node) => node.data.type === BlockEnum.Start) const startVariables = startNode?.data.variables const { checkInputsForm } = useCheckInputsForms() @@ -49,8 +34,7 @@ const InputsPanel = ({ onRun }: Props) => { const result = { ...inputs } if (startVariables) { startVariables.forEach((variable) => { - if (variable.default) - result[variable.variable] = variable.default + if (variable.default) result[variable.variable] = variable.default if (inputs[variable.variable] !== undefined) result[variable.variable] = inputs[variable.variable]! }) @@ -76,16 +60,12 @@ const InputsPanel = ({ onRun }: Props) => { }, [fileSettings?.image?.enabled, startVariables]) const handleValueChange = (variable: string, v: any) => { - const { - inputs, - setInputs, - } = workflowStore.getState() + const { inputs, setInputs } = workflowStore.getState() if (variable === '__image') { workflowStore.setState({ files: v, }) - } - else { + } else { setInputs({ ...inputs, [variable]: v, @@ -94,15 +74,15 @@ const InputsPanel = ({ onRun }: Props) => { } const canRun = useMemo(() => { - const canUploadReady = !(files?.some(item => (item.transfer_method as any) === TransferMethod.local_file && !item.upload_file_id)) + const canUploadReady = !files?.some( + (item) => (item.transfer_method as any) === TransferMethod.local_file && !item.upload_file_id, + ) return canRunWorkflow && canUploadReady }, [canRunWorkflow, files]) const doRun = useCallback(() => { - if (!canRun) - return - if (!checkInputsForm(initialInputs, variables as any)) - return + if (!canRun) return + if (!checkInputsForm(initialInputs, variables as any)) return onRun() handleRun({ inputs: getProcessedInputs(initialInputs, variables as any), files }) }, [canRun, files, handleRun, initialInputs, onRun, variables, checkInputsForm]) @@ -110,31 +90,28 @@ const InputsPanel = ({ onRun }: Props) => { return ( <> <div className="px-4 pt-3 pb-2"> - { - variables.map((variable, index) => ( - <div - key={variable.variable} - className="mb-2 last-of-type:mb-0" - > - <FormItem - autoFocus={index === 0} - className="block!" - payload={variable} - value={initialInputs[variable.variable]} - onChange={v => handleValueChange(variable.variable, v)} - /> - </div> - )) - } + {variables.map((variable, index) => ( + <div key={variable.variable} className="mb-2 last-of-type:mb-0"> + <FormItem + autoFocus={index === 0} + className="block!" + payload={variable} + value={initialInputs[variable.variable]} + onChange={(v) => handleValueChange(variable.variable, v)} + /> + </div> + ))} </div> <div className="flex items-center justify-between px-4 py-2"> <Button variant="primary" - disabled={!canRun || workflowRunningData?.result?.status === WorkflowRunningStatus.Running} + disabled={ + !canRun || workflowRunningData?.result?.status === WorkflowRunningStatus.Running + } className="w-full" onClick={doRun} > - {t($ => $['singleRun.startRun'], { ns: 'workflow' })} + {t(($) => $['singleRun.startRun'], { ns: 'workflow' })} </Button> </div> </> diff --git a/web/app/components/workflow/panel/record.tsx b/web/app/components/workflow/panel/record.tsx index 94aaac3458073b..14bc2b2af9520c 100644 --- a/web/app/components/workflow/panel/record.tsx +++ b/web/app/components/workflow/panel/record.tsx @@ -7,18 +7,21 @@ import { useStore } from '../store' import { formatWorkflowRunIdentifier } from '../utils' const Record = () => { - const historyWorkflowData = useStore(s => s.historyWorkflowData) + const historyWorkflowData = useStore((s) => s.historyWorkflowData) const { handleUpdateWorkflowCanvas } = useWorkflowUpdate() - const getWorkflowRunAndTraceUrl = useHooksStore(s => s.getWorkflowRunAndTraceUrl) + const getWorkflowRunAndTraceUrl = useHooksStore((s) => s.getWorkflowRunAndTraceUrl) - const handleResultCallback = useCallback((res: WorkflowRunDetailResponse) => { - const graph = res.graph - handleUpdateWorkflowCanvas({ - nodes: graph.nodes, - edges: graph.edges, - viewport: graph.viewport || { x: 0, y: 0, zoom: 1 }, - }) - }, [handleUpdateWorkflowCanvas]) + const handleResultCallback = useCallback( + (res: WorkflowRunDetailResponse) => { + const graph = res.graph + handleUpdateWorkflowCanvas({ + nodes: graph.nodes, + edges: graph.edges, + viewport: graph.viewport || { x: 0, y: 0, zoom: 1 }, + }) + }, + [handleUpdateWorkflowCanvas], + ) return ( <div className="flex h-full w-[400px] flex-col rounded-l-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"> diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx index d1ba0aa5be8dd5..12993f4a7cbc6f 100644 --- a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx @@ -53,7 +53,10 @@ const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionH let mockCurrentVersion: VersionHistory | null = null -type MockVersionStoreState = Pick<Shape, 'currentVersion' | 'setCurrentVersion' | 'setShowWorkflowVersionHistoryPanel'> +type MockVersionStoreState = Pick< + Shape, + 'currentVersion' | 'setCurrentVersion' | 'setShowWorkflowVersionHistoryPanel' +> type MockRestoreConfirmModalProps = { isOpen: boolean versionInfo: VersionHistory @@ -87,7 +90,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -181,8 +185,7 @@ vi.mock('../restore-confirm-modal', () => ({ const MockRestoreConfirmModal = () => { const { isOpen, versionInfo, onRestore } = props - if (!isOpen) - return null + if (!isOpen) return null return <button onClick={() => onRestore(versionInfo)}>confirm restore</button> } @@ -213,10 +216,16 @@ vi.mock('../version-history-item', () => ({ <button onClick={() => onClick(item)}>{item.marked_name || item.version}</button> {item.version !== WorkflowVersion.Draft && ( <> - <button onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.restore)}> + <button + onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.restore)} + > {`restore-${item.id}`} </button> - <button onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.exportDSL)}> + <button + onClick={() => + handleClickActionMenuItem(VersionHistoryContextMenuOptions.exportDSL) + } + > {`export-${item.id}`} </button> </> @@ -245,7 +254,7 @@ describe('VersionHistoryPanel', () => { render( <VersionHistoryPanel latestVersionId="published-version-id" - restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`} + restoreVersionUrl={(versionId) => `/apps/app-1/workflows/${versionId}/restore`} />, ) @@ -259,7 +268,7 @@ describe('VersionHistoryPanel', () => { render( <VersionHistoryPanel latestVersionId="published-version-id" - restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`} + restoreVersionUrl={(versionId) => `/apps/app-1/workflows/${versionId}/restore`} />, ) @@ -278,7 +287,7 @@ describe('VersionHistoryPanel', () => { render( <VersionHistoryPanel latestVersionId="published-version-id" - restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`} + restoreVersionUrl={(versionId) => `/apps/app-1/workflows/${versionId}/restore`} />, ) @@ -291,10 +300,14 @@ describe('VersionHistoryPanel', () => { fireEvent.click(screen.getByText('confirm restore')) await waitFor(() => { - expect(mockSetCurrentVersion).toHaveBeenCalledWith(expect.objectContaining({ - id: 'published-version-id', - })) - expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore') + expect(mockSetCurrentVersion).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'published-version-id', + }), + ) + expect(mockRestoreWorkflow).toHaveBeenCalledWith( + '/apps/app-1/workflows/published-version-id/restore', + ) expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isRestoring: false }) expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ backupDraft: undefined }) expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalled() @@ -308,7 +321,7 @@ describe('VersionHistoryPanel', () => { render( <VersionHistoryPanel latestVersionId="published-version-id" - restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`} + restoreVersionUrl={(versionId) => `/apps/app-1/workflows/${versionId}/restore`} />, ) @@ -328,7 +341,7 @@ describe('VersionHistoryPanel', () => { render( <VersionHistoryPanel latestVersionId="published-version-id" - restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`} + restoreVersionUrl={(versionId) => `/apps/app-1/workflows/${versionId}/restore`} />, ) @@ -350,7 +363,7 @@ describe('VersionHistoryPanel', () => { render( <VersionHistoryPanel latestVersionId="published-version-id" - restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`} + restoreVersionUrl={(versionId) => `/apps/app-1/workflows/${versionId}/restore`} />, ) @@ -361,7 +374,9 @@ describe('VersionHistoryPanel', () => { fireEvent.click(screen.getByText('confirm restore')) await waitFor(() => { - expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore') + expect(mockRestoreWorkflow).toHaveBeenCalledWith( + '/apps/app-1/workflows/published-version-id/restore', + ) }) expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ isRestoring: false }) diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx index b28901a8f951c7..d9468b9345cd85 100644 --- a/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx @@ -5,7 +5,8 @@ import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../type import VersionHistoryItem from '../version-history-item' vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { pipelineId?: string }) => unknown) => selector({ pipelineId: undefined }), + useStore: (selector: (state: { pipelineId?: string }) => unknown) => + selector({ pipelineId: undefined }), })) const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({ @@ -69,9 +70,11 @@ describe('VersionHistoryItem', () => { expect(screen.getByText('workflow.versionHistory.currentDraft')).toBeInTheDocument() await waitFor(() => { - expect(onClick).toHaveBeenCalledWith(expect.objectContaining({ - version: WorkflowVersion.Draft, - })) + expect(onClick).toHaveBeenCalledWith( + expect.objectContaining({ + version: WorkflowVersion.Draft, + }), + ) }) expect(screen.queryByText('Initial release')).not.toBeInTheDocument() @@ -99,8 +102,7 @@ describe('VersionHistoryItem', () => { const title = screen.getByText('Release 1') const itemContainer = title.closest('.group') - if (!itemContainer) - throw new Error('Expected version history item container') + if (!itemContainer) throw new Error('Expected version history item container') fireEvent.mouseEnter(itemContainer) @@ -117,8 +119,7 @@ describe('VersionHistoryItem', () => { expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() const restoreItem = screen.getByText('workflow.common.restore').closest('.cursor-pointer') - if (!restoreItem) - throw new Error('Expected restore menu item') + if (!restoreItem) throw new Error('Expected restore menu item') fireEvent.click(restoreItem) @@ -146,8 +147,7 @@ describe('VersionHistoryItem', () => { const title = screen.getByText('Release 1') const itemContainer = title.closest('.group') - if (!itemContainer) - throw new Error('Expected version history item container') + if (!itemContainer) throw new Error('Expected version history item container') fireEvent.mouseEnter(itemContainer) diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx index 56890f01d6ab85..0058663d0b51d6 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx @@ -77,7 +77,9 @@ describe('ActionMenu', () => { />, ) - const upgradeButtons = screen.getAllByRole('button', { name: 'billing.upgradeBtn.encourageShort' }) + const upgradeButtons = screen.getAllByRole('button', { + name: 'billing.upgradeBtn.encourageShort', + }) expect(upgradeButtons).toHaveLength(2) await user.click(upgradeButtons[0]!) diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx index 78944e6cc7927c..8ee11efccf91ce 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx @@ -4,20 +4,22 @@ import useActionMenu from '../use-action-menu' describe('useActionMenu', () => { it('returns restore, edit, export, copy and delete operations for app workflows', () => { - const { result } = renderWorkflowHook(() => useActionMenu({ - isNamedVersion: true, - canImportExportDSL: true, - isShowDelete: false, - open: false, - setOpen: vi.fn(), - handleClickActionMenuItem: vi.fn(), - })) + const { result } = renderWorkflowHook(() => + useActionMenu({ + isNamedVersion: true, + canImportExportDSL: true, + isShowDelete: false, + open: false, + setOpen: vi.fn(), + handleClickActionMenuItem: vi.fn(), + }), + ) expect(result.current.deleteOperation).toEqual({ key: VersionHistoryContextMenuOptions.delete, name: 'common.operation.delete', }) - expect(result.current.options.map(item => item.key)).toEqual([ + expect(result.current.options.map((item) => item.key)).toEqual([ VersionHistoryContextMenuOptions.restore, VersionHistoryContextMenuOptions.edit, VersionHistoryContextMenuOptions.exportDSL, @@ -26,18 +28,22 @@ describe('useActionMenu', () => { }) it('omits export for pipelines and renames the edit action for unnamed versions', () => { - const { result } = renderWorkflowHook(() => useActionMenu({ - isNamedVersion: false, - canImportExportDSL: true, - isShowDelete: true, - open: false, - setOpen: vi.fn(), - handleClickActionMenuItem: vi.fn(), - }), { - initialStoreState: { - pipelineId: 'pipeline-1', + const { result } = renderWorkflowHook( + () => + useActionMenu({ + isNamedVersion: false, + canImportExportDSL: true, + isShowDelete: true, + open: false, + setOpen: vi.fn(), + handleClickActionMenuItem: vi.fn(), + }), + { + initialStoreState: { + pipelineId: 'pipeline-1', + }, }, - }) + ) expect(result.current.options).toEqual([ { @@ -56,16 +62,18 @@ describe('useActionMenu', () => { }) it('omits export when import/export DSL permission is missing', () => { - const { result } = renderWorkflowHook(() => useActionMenu({ - isNamedVersion: true, - canImportExportDSL: false, - isShowDelete: false, - open: false, - setOpen: vi.fn(), - handleClickActionMenuItem: vi.fn(), - })) + const { result } = renderWorkflowHook(() => + useActionMenu({ + isNamedVersion: true, + canImportExportDSL: false, + isShowDelete: false, + open: false, + setOpen: vi.fn(), + handleClickActionMenuItem: vi.fn(), + }), + ) - expect(result.current.options.map(item => item.key)).toEqual([ + expect(result.current.options.map((item) => item.key)).toEqual([ VersionHistoryContextMenuOptions.restore, VersionHistoryContextMenuOptions.edit, VersionHistoryContextMenuOptions.copyId, diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx index a1fc8673effabd..d110c82287db5f 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx @@ -15,11 +15,7 @@ type ActionMenuItemProps = { isDestructive?: boolean } -const ActionMenuItem: FC<ActionMenuItemProps> = ({ - item, - onClick, - isDestructive = false, -}) => { +const ActionMenuItem: FC<ActionMenuItemProps> = ({ item, onClick, isDestructive = false }) => { return ( <DropdownMenuItem variant={isDestructive ? 'destructive' : 'default'} @@ -30,24 +26,21 @@ const ActionMenuItem: FC<ActionMenuItemProps> = ({ onClick={(event) => { event.stopPropagation() const target = event.target - if (target instanceof Element && target.closest('[data-upgrade-action]')) - return + if (target instanceof Element && target.closest('[data-upgrade-action]')) return onClick(item.key) }} > - <div className={cn( - 'flex-1 system-md-regular whitespace-nowrap text-text-primary', - isDestructive && 'text-inherit', - )} + <div + className={cn( + 'flex-1 system-md-regular whitespace-nowrap text-text-primary', + isDestructive && 'text-inherit', + )} > {item.name} </div> {item.showUpgrade && ( - <div - data-upgrade-action - className="shrink-0" - > + <div data-upgrade-action className="shrink-0"> <UpgradeBtn size="custom" isShort diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx index 5d96a761eddbdb..3b2e76c7e9800a 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx @@ -23,19 +23,20 @@ export type ActionMenuProps = { const ActionMenu: FC<ActionMenuProps> = (props: ActionMenuProps) => { const { isShowDelete, handleClickActionMenuItem, open, setOpen } = props - const { - deleteOperation, - options, - } = useActionMenu(props) + const { deleteOperation, options } = useActionMenu(props) return ( - <DropdownMenu - open={open} - onOpenChange={setOpen} - > + <DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenuTrigger nativeButton={false} - render={<Button nativeButton={false} size="small" className="px-1" onClick={e => e.stopPropagation()} />} + render={ + <Button + nativeButton={false} + size="small" + className="px-1" + onClick={(e) => e.stopPropagation()} + /> + } > <RiMoreFill className="size-4" /> </DropdownMenuTrigger> @@ -44,27 +45,26 @@ const ActionMenu: FC<ActionMenuProps> = (props: ActionMenuProps) => { sideOffset={4} popupClassName="w-max min-w-[184px] max-w-[calc(100vw-24px)] shadow-shadow-shadow-5" > - { - options.map(option => ( + {options.map((option) => ( + <ActionMenuItem + key={option.key} + item={option} + onClick={handleClickActionMenuItem.bind(null, option.key)} + /> + ))} + {isShowDelete && ( + <> + <DropdownMenuSeparator className="my-0" /> <ActionMenuItem - key={option.key} - item={option} - onClick={handleClickActionMenuItem.bind(null, option.key)} + item={deleteOperation} + isDestructive + onClick={handleClickActionMenuItem.bind( + null, + VersionHistoryContextMenuOptions.delete, + )} /> - )) - } - { - isShowDelete && ( - <> - <DropdownMenuSeparator className="my-0" /> - <ActionMenuItem - item={deleteOperation} - isDestructive - onClick={handleClickActionMenuItem.bind(null, VersionHistoryContextMenuOptions.delete)} - /> - </> - ) - } + </> + )} </DropdownMenuContent> </DropdownMenu> ) diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts b/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts index 5feffde5167064..9f5d651c1e9d1f 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts @@ -7,47 +7,46 @@ import { useProviderContext } from '@/context/provider-context' import { VersionHistoryContextMenuOptions } from '../../../types' const useActionMenu = (props: ActionMenuProps) => { - const { - isNamedVersion, - canImportExportDSL, - } = props + const { isNamedVersion, canImportExportDSL } = props const { t } = useTranslation() - const pipelineId = useStore(s => s.pipelineId) + const pipelineId = useStore((s) => s.pipelineId) const { plan, enableBilling } = useProviderContext() const shouldShowUpgrade = enableBilling && plan.type === Plan.sandbox const deleteOperation = { key: VersionHistoryContextMenuOptions.delete, - name: t($ => $['operation.delete'], { ns: 'common' }), + name: t(($) => $['operation.delete'], { ns: 'common' }), } const options = useMemo(() => { return [ { key: VersionHistoryContextMenuOptions.restore, - name: t($ => $['common.restore'], { ns: 'workflow' }), + name: t(($) => $['common.restore'], { ns: 'workflow' }), ...(shouldShowUpgrade ? { showUpgrade: true } : {}), }, isNamedVersion ? { key: VersionHistoryContextMenuOptions.edit, - name: t($ => $['versionHistory.editVersionInfo'], { ns: 'workflow' }), + name: t(($) => $['versionHistory.editVersionInfo'], { ns: 'workflow' }), } : { key: VersionHistoryContextMenuOptions.edit, - name: t($ => $['versionHistory.nameThisVersion'], { ns: 'workflow' }), + name: t(($) => $['versionHistory.nameThisVersion'], { ns: 'workflow' }), }, // todo: pipeline support export specific version DSL ...(canImportExportDSL && !pipelineId - ? [{ - key: VersionHistoryContextMenuOptions.exportDSL, - name: t($ => $.export, { ns: 'app' }), - ...(shouldShowUpgrade ? { showUpgrade: true } : {}), - }] + ? [ + { + key: VersionHistoryContextMenuOptions.exportDSL, + name: t(($) => $.export, { ns: 'app' }), + ...(shouldShowUpgrade ? { showUpgrade: true } : {}), + }, + ] : []), { key: VersionHistoryContextMenuOptions.copyId, - name: t($ => $['versionHistory.copyId'], { ns: 'workflow' }), + name: t(($) => $['versionHistory.copyId'], { ns: 'workflow' }), }, ] }, [canImportExportDSL, isNamedVersion, pipelineId, shouldShowUpgrade, shouldShowUpgrade, t]) diff --git a/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx b/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx index e0d27ba0d44dc7..1c9672ff58cca3 100644 --- a/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx +++ b/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx @@ -31,17 +31,16 @@ const DeleteConfirmModal: FC<DeleteConfirmModalProps> = ({ <AlertDialog open={isOpen} onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <AlertDialogContent className="overflow-hidden! border-none text-left align-middle shadow-xl"> <div className="flex flex-col gap-y-2 p-6 pb-4"> <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> - {`${t($ => $['operation.delete'], { ns: 'common' })} ${versionInfo.marked_name || t($ => $['versionHistory.defaultName'], { ns: 'workflow' })}`} + {`${t(($) => $['operation.delete'], { ns: 'common' })} ${versionInfo.marked_name || t(($) => $['versionHistory.defaultName'], { ns: 'workflow' })}`} </AlertDialogTitle> <AlertDialogDescription className="system-md-regular text-text-secondary"> - {t($ => $['versionHistory.deletionTip'], { ns: 'workflow' })} + {t(($) => $['versionHistory.deletionTip'], { ns: 'workflow' })} </AlertDialogDescription> </div> <AlertDialogActions> @@ -50,10 +49,13 @@ const DeleteConfirmModal: FC<DeleteConfirmModalProps> = ({ variant="secondary" closeProps={{ nativeButton: false }} > - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> - <AlertDialogConfirmButton nativeButton={false} onClick={onDelete.bind(null, versionInfo.id)}> - {t($ => $['operation.delete'], { ns: 'common' })} + <AlertDialogConfirmButton + nativeButton={false} + onClick={onDelete.bind(null, versionInfo.id)} + > + {t(($) => $['operation.delete'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/workflow/panel/version-history-panel/empty.tsx b/web/app/components/workflow/panel/version-history-panel/empty.tsx index 1c4eb9a2245fce..39f1ca8b9be00d 100644 --- a/web/app/components/workflow/panel/version-history-panel/empty.tsx +++ b/web/app/components/workflow/panel/version-history-panel/empty.tsx @@ -8,9 +8,7 @@ type EmptyProps = { onResetFilter: () => void } -const Empty: FC<EmptyProps> = ({ - onResetFilter, -}) => { +const Empty: FC<EmptyProps> = ({ onResetFilter }) => { const { t } = useTranslation() return ( @@ -19,11 +17,11 @@ const Empty: FC<EmptyProps> = ({ <RiHistoryLine className="size-10 text-text-empty-state-icon" /> </div> <div className="flex justify-center system-xs-regular text-text-tertiary"> - {t($ => $['versionHistory.filter.empty'], { ns: 'workflow' })} + {t(($) => $['versionHistory.filter.empty'], { ns: 'workflow' })} </div> <div className="flex justify-center"> <Button nativeButton={false} size="small" onClick={onResetFilter}> - {t($ => $['versionHistory.filter.reset'], { ns: 'workflow' })} + {t(($) => $['versionHistory.filter.reset'], { ns: 'workflow' })} </Button> </div> </div> diff --git a/web/app/components/workflow/panel/version-history-panel/filter/__tests__/filter-switch.spec.tsx b/web/app/components/workflow/panel/version-history-panel/filter/__tests__/filter-switch.spec.tsx index 0822a339b38515..ee6abdb02e5699 100644 --- a/web/app/components/workflow/panel/version-history-panel/filter/__tests__/filter-switch.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/filter/__tests__/filter-switch.spec.tsx @@ -6,14 +6,11 @@ describe('FilterSwitch', () => { it('renders the switch label and toggles through the change handler', async () => { const user = userEvent.setup() const handleSwitch = vi.fn() - render( - <FilterSwitch - enabled={false} - handleSwitch={handleSwitch} - />, - ) + render(<FilterSwitch enabled={false} handleSwitch={handleSwitch} />) - expect(screen.getByText('workflow.versionHistory.filter.onlyShowNamedVersions')).toBeInTheDocument() + expect( + screen.getByText('workflow.versionHistory.filter.onlyShowNamedVersions'), + ).toBeInTheDocument() await user.click(screen.getByRole('switch')) diff --git a/web/app/components/workflow/panel/version-history-panel/filter/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/filter/__tests__/index.spec.tsx index 6b9403e2323852..08e5371664d149 100644 --- a/web/app/components/workflow/panel/version-history-panel/filter/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/filter/__tests__/index.spec.tsx @@ -18,7 +18,9 @@ describe('VersionHistory Filter Components', () => { render(<FilterSwitch enabled={false} handleSwitch={handleSwitch} />) - expect(screen.getByText('workflow.versionHistory.filter.onlyShowNamedVersions')).toBeInTheDocument() + expect( + screen.getByText('workflow.versionHistory.filter.onlyShowNamedVersions'), + ).toBeInTheDocument() expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') await user.click(screen.getByRole('switch')) @@ -70,8 +72,7 @@ describe('VersionHistory Filter Components', () => { ) const trigger = container.querySelector('.size-6') - if (!trigger) - throw new Error('Expected filter trigger to exist') + if (!trigger) throw new Error('Expected filter trigger to exist') await user.click(trigger) diff --git a/web/app/components/workflow/panel/version-history-panel/filter/filter-item.tsx b/web/app/components/workflow/panel/version-history-panel/filter/filter-item.tsx index 2a56d084004260..78b42402c27b5e 100644 --- a/web/app/components/workflow/panel/version-history-panel/filter/filter-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/filter/filter-item.tsx @@ -12,11 +12,7 @@ type FilterItemProps = { onClick: (value: WorkflowVersionFilterOptions) => void } -const FilterItem: FC<FilterItemProps> = ({ - item, - isSelected = false, - onClick, -}) => { +const FilterItem: FC<FilterItemProps> = ({ item, isSelected = false, onClick }) => { return ( <div className="flex cursor-pointer items-center justify-between gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover" diff --git a/web/app/components/workflow/panel/version-history-panel/filter/filter-switch.tsx b/web/app/components/workflow/panel/version-history-panel/filter/filter-switch.tsx index 279e08a4ae70a7..a0e7cf826d294e 100644 --- a/web/app/components/workflow/panel/version-history-panel/filter/filter-switch.tsx +++ b/web/app/components/workflow/panel/version-history-panel/filter/filter-switch.tsx @@ -8,21 +8,18 @@ type FilterSwitchProps = { handleSwitch: (value: boolean) => void } -const FilterSwitch: FC<FilterSwitchProps> = ({ - enabled, - handleSwitch, -}) => { +const FilterSwitch: FC<FilterSwitchProps> = ({ enabled, handleSwitch }) => { const { t } = useTranslation() return ( <div className="flex items-center p-1"> <div className="flex w-full items-center gap-x-1 px-2 py-1.5"> <div className="flex-1 px-1 system-md-regular text-text-secondary"> - {t($ => $['versionHistory.filter.onlyShowNamedVersions'], { ns: 'workflow' })} + {t(($) => $['versionHistory.filter.onlyShowNamedVersions'], { ns: 'workflow' })} </div> <Switch checked={enabled} - onCheckedChange={v => handleSwitch(v)} + onCheckedChange={(v) => handleSwitch(v)} size="md" className="shrink-0" /> diff --git a/web/app/components/workflow/panel/version-history-panel/filter/index.tsx b/web/app/components/workflow/panel/version-history-panel/filter/index.tsx index 0f3b276200fc9e..e033f96bae31af 100644 --- a/web/app/components/workflow/panel/version-history-panel/filter/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/filter/index.tsx @@ -1,10 +1,6 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiFilter3Line } from '@remixicon/react' import * as React from 'react' import { useCallback, useState } from 'react' @@ -30,29 +26,31 @@ const Filter: FC<FilterProps> = ({ const [open, setOpen] = useState(false) const options = useFilterOptions() - const handleOnClick = useCallback((value: WorkflowVersionFilterOptions) => { - onClickFilterItem(value) - }, [onClickFilterItem]) + const handleOnClick = useCallback( + (value: WorkflowVersionFilterOptions) => { + onClickFilterItem(value) + }, + [onClickFilterItem], + ) const isFiltering = filterValue !== WorkflowVersionFilterOptions.all || isOnlyShowNamedVersions return ( - <Popover - open={open} - onOpenChange={setOpen} - > + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton={false} - render={( + render={ <div className={cn( 'flex size-6 cursor-pointer items-center justify-center rounded-md p-0.5', isFiltering ? 'bg-state-accent-active-alt' : 'hover:bg-state-base-hover', )} > - <RiFilter3Line className={cn('size-4', isFiltering ? 'text-text-accent' : 'text-text-tertiary')} /> + <RiFilter3Line + className={cn('size-4', isFiltering ? 'text-text-accent' : 'text-text-tertiary')} + /> </div> - )} + } /> <PopoverContent placement="bottom-end" @@ -62,18 +60,16 @@ const Filter: FC<FilterProps> = ({ > <div className="flex w-[248px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"> <div className="flex flex-col p-1"> - { - options.map((option) => { - return ( - <FilterItem - key={option.key} - item={option} - isSelected={filterValue === option.key} - onClick={handleOnClick} - /> - ) - }) - } + {options.map((option) => { + return ( + <FilterItem + key={option.key} + item={option} + isSelected={filterValue === option.key} + onClick={handleOnClick} + /> + ) + })} </div> <Divider type="horizontal" className="my-0 h-px bg-divider-subtle" /> <FilterSwitch enabled={isOnlyShowNamedVersions} handleSwitch={handleSwitch} /> diff --git a/web/app/components/workflow/panel/version-history-panel/filter/use-filter.ts b/web/app/components/workflow/panel/version-history-panel/filter/use-filter.ts index 00680fd6f0c073..1017111bb466ef 100644 --- a/web/app/components/workflow/panel/version-history-panel/filter/use-filter.ts +++ b/web/app/components/workflow/panel/version-history-panel/filter/use-filter.ts @@ -7,11 +7,11 @@ export const useFilterOptions = () => { return [ { key: WorkflowVersionFilterOptions.all, - name: t($ => $['versionHistory.filter.all'], { ns: 'workflow' }), + name: t(($) => $['versionHistory.filter.all'], { ns: 'workflow' }), }, { key: WorkflowVersionFilterOptions.onlyYours, - name: t($ => $['versionHistory.filter.onlyYours'], { ns: 'workflow' }), + name: t(($) => $['versionHistory.filter.onlyYours'], { ns: 'workflow' }), }, ] } diff --git a/web/app/components/workflow/panel/version-history-panel/index.tsx b/web/app/components/workflow/panel/version-history-panel/index.tsx index 051c2ca38c0980..ab9528f7086b45 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/index.tsx @@ -13,11 +13,22 @@ import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { Plan } from '@/app/components/billing/type' import { userProfileAtom } from '@/context/account-state' import { useProviderContext } from '@/context/provider-context' -import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow' +import { + useDeleteWorkflow, + useInvalidAllLastRun, + useResetWorkflowVersionHistory, + useRestoreWorkflow, + useUpdateWorkflow, + useWorkflowVersionHistory, +} from '@/service/use-workflow' import { useDSL, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks' import { useHooksStore } from '../../hooks-store' import { useStore, useWorkflowStore } from '../../store' -import { VersionHistoryContextMenuOptions, WorkflowVersion, WorkflowVersionFilterOptions } from '../../types' +import { + VersionHistoryContextMenuOptions, + WorkflowVersion, + WorkflowVersionFilterOptions, +} from '../../types' import DeleteConfirmModal from './delete-confirm-modal' import Empty from './empty' import Filter from './filter' @@ -56,16 +67,14 @@ export const VersionHistoryPanel = ({ const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun() const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() const { handleExportDSL } = useDSL() - const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel) - const currentVersion = useStore(s => s.currentVersion) - const setCurrentVersion = useStore(s => s.setCurrentVersion) + const setShowWorkflowVersionHistoryPanel = useStore((s) => s.setShowWorkflowVersionHistoryPanel) + const currentVersion = useStore((s) => s.currentVersion) + const setCurrentVersion = useStore((s) => s.setCurrentVersion) const userProfile = useAtomValue(userProfileAtom) - const configsMap = useHooksStore(s => s.configsMap) - const canImportExportDSL = useHooksStore(s => s.accessControl.canImportExportDSL) + const configsMap = useHooksStore((s) => s.configsMap) + const canImportExportDSL = useHooksStore((s) => s.accessControl.canImportExportDSL) const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId) - const { - deleteAllInspectVars, - } = workflowStore.getState() + const { deleteAllInspectVars } = workflowStore.getState() const { t } = useTranslation() const { @@ -81,19 +90,24 @@ export const VersionHistoryPanel = ({ namedOnly: isOnlyShowNamedVersions, }) - const handleVersionClick = useCallback((item: VersionHistory) => { - if (item.id !== currentVersion?.id) { - setCurrentVersion(item) - if (item.version === WorkflowVersion.Draft) - handleLoadBackupDraft() - else - handleRestoreFromPublishedWorkflow(item) - } - }, [currentVersion?.id, setCurrentVersion, handleLoadBackupDraft, handleRestoreFromPublishedWorkflow]) + const handleVersionClick = useCallback( + (item: VersionHistory) => { + if (item.id !== currentVersion?.id) { + setCurrentVersion(item) + if (item.version === WorkflowVersion.Draft) handleLoadBackupDraft() + else handleRestoreFromPublishedWorkflow(item) + } + }, + [ + currentVersion?.id, + setCurrentVersion, + handleLoadBackupDraft, + handleRestoreFromPublishedWorkflow, + ], + ) const handleNextPage = () => { - if (hasNextPage) - fetchNextPage() + if (hasNextPage) fetchNextPage() } const handleClose = () => { @@ -115,37 +129,39 @@ export const VersionHistoryPanel = ({ setIsOnlyShowNamedVersions(false) }, []) - const handleClickActionMenuItem = useCallback((item: VersionHistory, operation: VersionHistoryContextMenuOptions) => { - setOperatedItem(item) - switch (operation) { - case VersionHistoryContextMenuOptions.restore: - if (!canUseWorkflowVersionAction) { - setIsRestorePlanUpgradeModalOpen(true) + const handleClickActionMenuItem = useCallback( + (item: VersionHistory, operation: VersionHistoryContextMenuOptions) => { + setOperatedItem(item) + switch (operation) { + case VersionHistoryContextMenuOptions.restore: + if (!canUseWorkflowVersionAction) { + setIsRestorePlanUpgradeModalOpen(true) + break + } + setRestoreConfirmOpen(true) break - } - setRestoreConfirmOpen(true) - break - case VersionHistoryContextMenuOptions.edit: - setEditModalOpen(true) - break - case VersionHistoryContextMenuOptions.delete: - setDeleteConfirmOpen(true) - break - case VersionHistoryContextMenuOptions.copyId: - copy(item.id) - toast.success(t($ => $['versionHistory.action.copyIdSuccess'], { ns: 'workflow' })) - break - case VersionHistoryContextMenuOptions.exportDSL: - if (!canUseWorkflowVersionAction) { - setIsRestorePlanUpgradeModalOpen(true) + case VersionHistoryContextMenuOptions.edit: + setEditModalOpen(true) break - } - if (!canImportExportDSL) - return - handleExportDSL?.(false, item.id) - break - } - }, [canUseWorkflowVersionAction, canImportExportDSL, t, handleExportDSL]) + case VersionHistoryContextMenuOptions.delete: + setDeleteConfirmOpen(true) + break + case VersionHistoryContextMenuOptions.copyId: + copy(item.id) + toast.success(t(($) => $['versionHistory.action.copyIdSuccess'], { ns: 'workflow' })) + break + case VersionHistoryContextMenuOptions.exportDSL: + if (!canUseWorkflowVersionAction) { + setIsRestorePlanUpgradeModalOpen(true) + break + } + if (!canImportExportDSL) return + handleExportDSL?.(false, item.id) + break + } + }, + [canUseWorkflowVersionAction, canImportExportDSL, t, handleExportDSL], + ) const handleCancel = useCallback((operation: VersionHistoryContextMenuOptions) => { switch (operation) { @@ -161,45 +177,50 @@ export const VersionHistoryPanel = ({ } }, []) - const emitRestoreIntent = useCallback(async (item: VersionHistory) => { - try { - const { collaborationManager } = await import('../../collaboration/core/collaboration-manager') - collaborationManager.emitRestoreIntent({ - versionId: item.id, - versionName: item.marked_name, - initiatorUserId: userProfile.id, - initiatorName: userProfile.name, - }) - } - catch (error) { - console.error('Failed to emit restore intent:', error) - } - }, [userProfile.id, userProfile.name]) + const emitRestoreIntent = useCallback( + async (item: VersionHistory) => { + try { + const { collaborationManager } = + await import('../../collaboration/core/collaboration-manager') + collaborationManager.emitRestoreIntent({ + versionId: item.id, + versionName: item.marked_name, + initiatorUserId: userProfile.id, + initiatorName: userProfile.name, + }) + } catch (error) { + console.error('Failed to emit restore intent:', error) + } + }, + [userProfile.id, userProfile.name], + ) - const emitRestoreComplete = useCallback(async (item: VersionHistory, success: boolean, errorMessage?: string) => { - try { - const { collaborationManager } = await import('../../collaboration/core/collaboration-manager') - collaborationManager.emitRestoreComplete({ - versionId: item.id, - success, - ...(errorMessage ? { error: errorMessage } : {}), - }) - } - catch (error) { - console.error('Failed to emit restore complete:', error) - } - }, []) + const emitRestoreComplete = useCallback( + async (item: VersionHistory, success: boolean, errorMessage?: string) => { + try { + const { collaborationManager } = + await import('../../collaboration/core/collaboration-manager') + collaborationManager.emitRestoreComplete({ + versionId: item.id, + success, + ...(errorMessage ? { error: errorMessage } : {}), + }) + } catch (error) { + console.error('Failed to emit restore complete:', error) + } + }, + [], + ) const emitWorkflowUpdate = useCallback(async () => { try { const appId = configsMap?.flowId - if (!appId) - return + if (!appId) return - const { collaborationManager } = await import('../../collaboration/core/collaboration-manager') + const { collaborationManager } = + await import('../../collaboration/core/collaboration-manager') collaborationManager.emitWorkflowUpdate(appId) - } - catch (error) { + } catch (error) { console.error('Failed to emit workflow update:', error) } }, [configsMap?.flowId]) @@ -207,77 +228,110 @@ export const VersionHistoryPanel = ({ const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() const { mutateAsync: restoreWorkflow } = useRestoreWorkflow() - const handleRestore = useCallback(async (item: VersionHistory) => { - setShowWorkflowVersionHistoryPanel(false) - await emitRestoreIntent(item) + const handleRestore = useCallback( + async (item: VersionHistory) => { + setShowWorkflowVersionHistoryPanel(false) + await emitRestoreIntent(item) - try { - await restoreWorkflow(restoreVersionUrl(item.id)) - setCurrentVersion(item) - workflowStore.setState({ isRestoring: false }) - workflowStore.setState({ backupDraft: undefined }) - handleRefreshWorkflowDraft() - toast.success(t($ => $['versionHistory.action.restoreSuccess'], { ns: 'workflow' })) - deleteAllInspectVars() - invalidAllLastRun() - await emitRestoreComplete(item, true) - await emitWorkflowUpdate() - } - catch { - toast.error(t($ => $['versionHistory.action.restoreFailure'], { ns: 'workflow' })) - await emitRestoreComplete(item, false, 'restore failed') - } - finally { - resetWorkflowVersionHistory() - } - }, [setShowWorkflowVersionHistoryPanel, emitRestoreIntent, restoreWorkflow, restoreVersionUrl, setCurrentVersion, workflowStore, handleRefreshWorkflowDraft, t, deleteAllInspectVars, invalidAllLastRun, emitRestoreComplete, emitWorkflowUpdate, resetWorkflowVersionHistory]) + try { + await restoreWorkflow(restoreVersionUrl(item.id)) + setCurrentVersion(item) + workflowStore.setState({ isRestoring: false }) + workflowStore.setState({ backupDraft: undefined }) + handleRefreshWorkflowDraft() + toast.success(t(($) => $['versionHistory.action.restoreSuccess'], { ns: 'workflow' })) + deleteAllInspectVars() + invalidAllLastRun() + await emitRestoreComplete(item, true) + await emitWorkflowUpdate() + } catch { + toast.error(t(($) => $['versionHistory.action.restoreFailure'], { ns: 'workflow' })) + await emitRestoreComplete(item, false, 'restore failed') + } finally { + resetWorkflowVersionHistory() + } + }, + [ + setShowWorkflowVersionHistoryPanel, + emitRestoreIntent, + restoreWorkflow, + restoreVersionUrl, + setCurrentVersion, + workflowStore, + handleRefreshWorkflowDraft, + t, + deleteAllInspectVars, + invalidAllLastRun, + emitRestoreComplete, + emitWorkflowUpdate, + resetWorkflowVersionHistory, + ], + ) const { mutateAsync: deleteWorkflow } = useDeleteWorkflow() - const handleDelete = useCallback(async (id: string) => { - await deleteWorkflow(deleteVersionUrl?.(id) || '', { - onSuccess: () => { - setDeleteConfirmOpen(false) - toast.success(t($ => $['versionHistory.action.deleteSuccess'], { ns: 'workflow' })) - resetWorkflowVersionHistory() - deleteAllInspectVars() - invalidAllLastRun() - }, - onError: () => { - toast.error(t($ => $['versionHistory.action.deleteFailure'], { ns: 'workflow' })) - }, - onSettled: () => { - setDeleteConfirmOpen(false) - }, - }) - }, [deleteWorkflow, t, resetWorkflowVersionHistory, deleteAllInspectVars, invalidAllLastRun, deleteVersionUrl]) + const handleDelete = useCallback( + async (id: string) => { + await deleteWorkflow(deleteVersionUrl?.(id) || '', { + onSuccess: () => { + setDeleteConfirmOpen(false) + toast.success(t(($) => $['versionHistory.action.deleteSuccess'], { ns: 'workflow' })) + resetWorkflowVersionHistory() + deleteAllInspectVars() + invalidAllLastRun() + }, + onError: () => { + toast.error(t(($) => $['versionHistory.action.deleteFailure'], { ns: 'workflow' })) + }, + onSettled: () => { + setDeleteConfirmOpen(false) + }, + }) + }, + [ + deleteWorkflow, + t, + resetWorkflowVersionHistory, + deleteAllInspectVars, + invalidAllLastRun, + deleteVersionUrl, + ], + ) const { mutateAsync: updateWorkflow } = useUpdateWorkflow() - const handleUpdateWorkflow = useCallback(async (params: { id?: string, title: string, releaseNotes: string }) => { - const { id, ...rest } = params - await updateWorkflow({ - url: updateVersionUrl?.(id || '') || '', - ...rest, - }, { - onSuccess: () => { - setEditModalOpen(false) - toast.success(t($ => $['versionHistory.action.updateSuccess'], { ns: 'workflow' })) - resetWorkflowVersionHistory() - }, - onError: () => { - toast.error(t($ => $['versionHistory.action.updateFailure'], { ns: 'workflow' })) - }, - onSettled: () => { - setEditModalOpen(false) - }, - }) - }, [t, updateWorkflow, resetWorkflowVersionHistory, updateVersionUrl]) + const handleUpdateWorkflow = useCallback( + async (params: { id?: string; title: string; releaseNotes: string }) => { + const { id, ...rest } = params + await updateWorkflow( + { + url: updateVersionUrl?.(id || '') || '', + ...rest, + }, + { + onSuccess: () => { + setEditModalOpen(false) + toast.success(t(($) => $['versionHistory.action.updateSuccess'], { ns: 'workflow' })) + resetWorkflowVersionHistory() + }, + onError: () => { + toast.error(t(($) => $['versionHistory.action.updateFailure'], { ns: 'workflow' })) + }, + onSettled: () => { + setEditModalOpen(false) + }, + }, + ) + }, + [t, updateWorkflow, resetWorkflowVersionHistory, updateVersionUrl], + ) return ( <div className="flex h-full w-[268px] flex-col rounded-l-2xl border-y-[0.5px] border-l-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5"> <div className="flex items-center gap-x-2 px-4 pt-3"> - <div className="flex-1 py-1 system-xl-semibold text-text-primary">{t($ => $['versionHistory.title'], { ns: 'workflow' })}</div> + <div className="flex-1 py-1 system-xl-semibold text-text-primary"> + {t(($) => $['versionHistory.title'], { ns: 'workflow' })} + </div> <Filter filterValue={filterValue} isOnlyShowNamedVersions={isOnlyShowNamedVersions} @@ -294,48 +348,47 @@ export const VersionHistoryPanel = ({ </div> <div className="flex h-0 flex-1 flex-col"> <div className="flex-1 overflow-y-auto px-3 py-2"> - {(isFetching && !versionHistory?.pages?.length) - ? ( - <Loading /> - ) - : ( - <> - {versionHistory?.pages?.map((page, pageNumber) => ( - page.items?.map((item, idx) => { - const isLast = pageNumber === versionHistory.pages.length - 1 && idx === page.items.length - 1 - return ( - <VersionHistoryItem - key={item.id} - item={item} - currentVersion={currentVersion} - latestVersionId={latestVersionId || ''} - onClick={handleVersionClick} - handleClickActionMenuItem={handleClickActionMenuItem.bind(null, item)} - canImportExportDSL={canImportExportDSL} - isLast={isLast} - /> - ) - }) - ))} - {!isFetching && (!versionHistory?.pages?.length || !versionHistory.pages[0]!.items.length) && ( - <Empty onResetFilter={handleResetFilter} /> - )} - </> + {isFetching && !versionHistory?.pages?.length ? ( + <Loading /> + ) : ( + <> + {versionHistory?.pages?.map((page, pageNumber) => + page.items?.map((item, idx) => { + const isLast = + pageNumber === versionHistory.pages.length - 1 && idx === page.items.length - 1 + return ( + <VersionHistoryItem + key={item.id} + item={item} + currentVersion={currentVersion} + latestVersionId={latestVersionId || ''} + onClick={handleVersionClick} + handleClickActionMenuItem={handleClickActionMenuItem.bind(null, item)} + canImportExportDSL={canImportExportDSL} + isLast={isLast} + /> + ) + }), )} + {!isFetching && + (!versionHistory?.pages?.length || !versionHistory.pages[0]!.items.length) && ( + <Empty onResetFilter={handleResetFilter} /> + )} + </> + )} </div> {hasNextPage && ( <div className="p-2"> - <div - className="flex cursor-pointer items-center gap-x-1" - onClick={handleNextPage} - > + <div className="flex cursor-pointer items-center gap-x-1" onClick={handleNextPage}> <div className="item-center flex justify-center p-0.5"> - {isFetching - ? <RiLoader2Line className="size-3.5 animate-spin text-text-accent" /> - : <RiArrowDownDoubleLine className="size-3.5 text-text-accent" />} + {isFetching ? ( + <RiLoader2Line className="size-3.5 animate-spin text-text-accent" /> + ) : ( + <RiArrowDownDoubleLine className="size-3.5 text-text-accent" /> + )} </div> <div className="py-px system-xs-medium-uppercase text-text-accent"> - {t($ => $['common.loadMore'], { ns: 'workflow' })} + {t(($) => $['common.loadMore'], { ns: 'workflow' })} </div> </div> </div> @@ -353,8 +406,8 @@ export const VersionHistoryPanel = ({ <PlanUpgradeModal show onClose={() => setIsRestorePlanUpgradeModalOpen(false)} - title={t($ => $['upgrade.workflowRestore.title'], { ns: 'billing' })!} - description={t($ => $['upgrade.workflowRestore.description'], { ns: 'billing' })!} + title={t(($) => $['upgrade.workflowRestore.title'], { ns: 'billing' })!} + description={t(($) => $['upgrade.workflowRestore.description'], { ns: 'billing' })!} /> )} {deleteConfirmOpen && ( diff --git a/web/app/components/workflow/panel/version-history-panel/loading/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/loading/__tests__/index.spec.tsx index 68fc5441567fc8..11243a6e541969 100644 --- a/web/app/components/workflow/panel/version-history-panel/loading/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/loading/__tests__/index.spec.tsx @@ -11,12 +11,7 @@ describe('VersionHistory Loading', () => { describe('Item', () => { it('should hide the release note placeholder for the first row', () => { const { container } = render( - <Item - titleWidth="w-1/3" - releaseNotesWidth="w-3/4" - isFirst - isLast={false} - />, + <Item titleWidth="w-1/3" releaseNotesWidth="w-3/4" isFirst isLast={false} />, ) expect(container.querySelectorAll('.opacity-20')).toHaveLength(1) @@ -25,12 +20,7 @@ describe('VersionHistory Loading', () => { it('should hide the timeline connector for the last row', () => { const { container } = render( - <Item - titleWidth="w-2/5" - releaseNotesWidth="w-4/6" - isFirst={false} - isLast - />, + <Item titleWidth="w-2/5" releaseNotesWidth="w-4/6" isFirst={false} isLast />, ) expect(container.querySelectorAll('.opacity-20')).toHaveLength(2) diff --git a/web/app/components/workflow/panel/version-history-panel/loading/__tests__/item.spec.tsx b/web/app/components/workflow/panel/version-history-panel/loading/__tests__/item.spec.tsx index c80615f8521b01..5ee8799106d331 100644 --- a/web/app/components/workflow/panel/version-history-panel/loading/__tests__/item.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/loading/__tests__/item.spec.tsx @@ -4,26 +4,14 @@ import Item from '../item' describe('VersionHistoryLoadingItem', () => { it('renders connectors and placeholders based on first and last flags', () => { const { container, rerender } = render( - <Item - isFirst={false} - isLast={false} - releaseNotesWidth="w-24" - titleWidth="w-40" - />, + <Item isFirst={false} isLast={false} releaseNotesWidth="w-24" titleWidth="w-40" />, ) expect(container.querySelector('.bg-divider-subtle')).toBeInTheDocument() expect(container.querySelector('.w-40')).toBeInTheDocument() expect(container.querySelector('.w-24')).toBeInTheDocument() - rerender( - <Item - isFirst - isLast - releaseNotesWidth="w-24" - titleWidth="w-40" - />, - ) + rerender(<Item isFirst isLast releaseNotesWidth="w-24" titleWidth="w-40" />) expect(container.querySelector('.bg-divider-subtle')).not.toBeInTheDocument() }) diff --git a/web/app/components/workflow/panel/version-history-panel/loading/index.tsx b/web/app/components/workflow/panel/version-history-panel/loading/index.tsx index 087a771c01593a..57e98582f4f3c2 100644 --- a/web/app/components/workflow/panel/version-history-panel/loading/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/loading/index.tsx @@ -13,7 +13,9 @@ const Loading = () => { return ( <div className="relative w-full overflow-y-hidden"> <div className="absolute top-0 left-0 z-10 size-full bg-dataset-chunk-list-mask-bg" /> - {itemConfig.map((config, index) => <Item key={index} {...config} />)} + {itemConfig.map((config, index) => ( + <Item key={index} {...config} /> + ))} </div> ) } diff --git a/web/app/components/workflow/panel/version-history-panel/loading/item.tsx b/web/app/components/workflow/panel/version-history-panel/loading/item.tsx index b55219787f91b4..ba9a208f0f279b 100644 --- a/web/app/components/workflow/panel/version-history-panel/loading/item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/loading/item.tsx @@ -9,15 +9,12 @@ type ItemProps = { isLast: boolean } -const Item: FC<ItemProps> = ({ - titleWidth, - releaseNotesWidth, - isFirst, - isLast, -}) => { +const Item: FC<ItemProps> = ({ titleWidth, releaseNotesWidth, isFirst, isLast }) => { return ( <div className="relative flex gap-x-1 p-2"> - {!isLast && <div className="absolute top-6 left-4 h-[calc(100%-0.75rem)] w-0.5 bg-divider-subtle" />} + {!isLast && ( + <div className="absolute top-6 left-4 h-[calc(100%-0.75rem)] w-0.5 bg-divider-subtle" /> + )} <div className="flex h-5 w-[18px] shrink-0 items-center justify-center"> <div className="size-2 rounded-lg border-2 border-text-quaternary" /> </div> @@ -25,16 +22,18 @@ const Item: FC<ItemProps> = ({ <div className="flex h-3.5 items-center"> <div className={cn('h-2 w-full rounded-xs bg-text-quaternary opacity-20', titleWidth)} /> </div> - { - !isFirst && ( - <div className="flex h-3 items-center"> - <div className={cn('h-1.5 w-full rounded-xs bg-text-quaternary opacity-20', releaseNotesWidth)} /> - </div> - ) - } + {!isFirst && ( + <div className="flex h-3 items-center"> + <div + className={cn( + 'h-1.5 w-full rounded-xs bg-text-quaternary opacity-20', + releaseNotesWidth, + )} + /> + </div> + )} </div> </div> - ) } diff --git a/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx b/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx index 1fba884ed00a0d..5032f2fc27cdd5 100644 --- a/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx +++ b/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx @@ -31,17 +31,16 @@ const RestoreConfirmModal: FC<RestoreConfirmModalProps> = ({ <AlertDialog open={isOpen} onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <AlertDialogContent className="overflow-hidden! border-none text-left align-middle shadow-xl"> <div className="flex flex-col gap-y-2 p-6 pb-4"> <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> - {`${t($ => $['common.restore'], { ns: 'workflow' })} ${versionInfo.marked_name || t($ => $['versionHistory.defaultName'], { ns: 'workflow' })}`} + {`${t(($) => $['common.restore'], { ns: 'workflow' })} ${versionInfo.marked_name || t(($) => $['versionHistory.defaultName'], { ns: 'workflow' })}`} </AlertDialogTitle> <AlertDialogDescription className="system-md-regular text-text-secondary"> - {t($ => $['versionHistory.restorationTip'], { ns: 'workflow' })} + {t(($) => $['versionHistory.restorationTip'], { ns: 'workflow' })} </AlertDialogDescription> </div> <AlertDialogActions> @@ -50,10 +49,14 @@ const RestoreConfirmModal: FC<RestoreConfirmModalProps> = ({ variant="secondary" closeProps={{ nativeButton: false }} > - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> - <AlertDialogConfirmButton nativeButton={false} tone="default" onClick={onRestore.bind(null, versionInfo)}> - {t($ => $['common.restore'], { ns: 'workflow' })} + <AlertDialogConfirmButton + nativeButton={false} + tone="default" + onClick={onRestore.bind(null, versionInfo)} + > + {t(($) => $['common.restore'], { ns: 'workflow' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx b/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx index 3608a1485138d2..55c3514a50d348 100644 --- a/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx @@ -21,19 +21,15 @@ type VersionHistoryItemProps = { const formatVersion = (versionHistory: VersionHistory, latestVersionId: string): string => { const { version, id } = versionHistory - if (version === WorkflowVersion.Draft) - return WorkflowVersion.Draft - if (id === latestVersionId) - return WorkflowVersion.Latest + if (version === WorkflowVersion.Draft) return WorkflowVersion.Draft + if (id === latestVersionId) return WorkflowVersion.Latest try { const date = new Date(version) - if (Number.isNaN(date.getTime())) - return version + if (Number.isNaN(date.getTime())) return version // format as YYYY-MM-DD HH:mm:ss return date.toISOString().slice(0, 19).replace('T', ' ') - } - catch { + } catch { return version } } @@ -59,13 +55,11 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({ const isLatest = formattedVersion === WorkflowVersion.Latest useEffect(() => { - if (isDraft) - onClick(item) + if (isDraft) onClick(item) }, []) const handleClickItem = () => { - if (isSelected) - return + if (isSelected) return onClick(item) } @@ -73,7 +67,9 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({ <div className={cn( 'group relative flex gap-x-1 rounded-lg p-2', - isSelected ? 'cursor-not-allowed bg-state-accent-active' : 'cursor-pointer hover:bg-state-base-hover', + isSelected + ? 'cursor-not-allowed bg-state-accent-active' + : 'cursor-pointer hover:bg-state-base-hover', )} onClick={handleClickItem} onMouseEnter={() => setIsHovering(true)} @@ -82,52 +78,51 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({ setOpen(false) }} onContextMenu={(e) => { - if (hideActionMenu) - return + if (hideActionMenu) return e.preventDefault() setOpen(true) }} > - {!isLast && <div className="absolute top-6 left-4 h-[calc(100%-0.75rem)] w-0.5 bg-divider-subtle" />} + {!isLast && ( + <div className="absolute top-6 left-4 h-[calc(100%-0.75rem)] w-0.5 bg-divider-subtle" /> + )} <div className="flex h-5 w-[18px] shrink-0 items-center justify-center"> - <div className={cn( - 'size-2 rounded-lg border-2', - isSelected ? 'border-text-accent' : 'border-text-quaternary', - )} + <div + className={cn( + 'size-2 rounded-lg border-2', + isSelected ? 'border-text-accent' : 'border-text-quaternary', + )} /> </div> <div className="flex grow flex-col gap-y-0.5 overflow-hidden"> <div className="mr-6 flex h-5 items-center gap-x-1"> - <div className={cn( - 'truncate py-px system-sm-semibold', - isSelected ? 'text-text-accent' : 'text-text-secondary', - )} + <div + className={cn( + 'truncate py-px system-sm-semibold', + isSelected ? 'text-text-accent' : 'text-text-secondary', + )} > - {isDraft ? t($ => $['versionHistory.currentDraft'], { ns: 'workflow' }) : item.marked_name || t($ => $['versionHistory.defaultName'], { ns: 'workflow' })} + {isDraft + ? t(($) => $['versionHistory.currentDraft'], { ns: 'workflow' }) + : item.marked_name || t(($) => $['versionHistory.defaultName'], { ns: 'workflow' })} </div> {isLatest && ( - <div className="flex h-5 shrink-0 items-center rounded-md border border-text-accent-secondary bg-components-badge-bg-dimm - px-[5px] system-2xs-medium-uppercase text-text-accent-secondary" - > - {t($ => $['versionHistory.latest'], { ns: 'workflow' })} + <div className="flex h-5 shrink-0 items-center rounded-md border border-text-accent-secondary bg-components-badge-bg-dimm px-[5px] system-2xs-medium-uppercase text-text-accent-secondary"> + {t(($) => $['versionHistory.latest'], { ns: 'workflow' })} </div> )} </div> - { - !isDraft && ( - <div className="system-xs-regular wrap-break-word text-text-secondary"> - {item.marked_comment || ''} - </div> - ) - } - { - !isDraft && ( - <div className="truncate system-xs-regular text-text-tertiary"> - {`${formatTime(item.created_at)} · ${item.created_by.name}`} - </div> - ) - } + {!isDraft && ( + <div className="system-xs-regular wrap-break-word text-text-secondary"> + {item.marked_comment || ''} + </div> + )} + {!isDraft && ( + <div className="truncate system-xs-regular text-text-tertiary"> + {`${formatTime(item.created_at)} · ${item.created_by.name}`} + </div> + )} </div> {/* Action Menu */} {!hideActionMenu && !isDraft && isHovering && ( diff --git a/web/app/components/workflow/panel/workflow-preview.tsx b/web/app/components/workflow/panel/workflow-preview.tsx index 0fa2bc7859a89a..00b463a5f36a96 100644 --- a/web/app/components/workflow/panel/workflow-preview.tsx +++ b/web/app/components/workflow/panel/workflow-preview.tsx @@ -3,26 +3,17 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import copy from 'copy-to-clipboard' -import { - memo, - useCallback, - useEffect, - useState, -} from 'react' +import { memo, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ReasoningPanel from '@/app/components/base/chat/chat/answer/reasoning-panel' import Loading from '@/app/components/base/loading' import { submitHumanInputForm } from '@/service/workflow' -import { - useWorkflowInteractions, -} from '../hooks' +import { useWorkflowInteractions } from '../hooks' import ResultPanel from '../run/result-panel' import ResultText from '../run/result-text' import TracingPanel from '../run/tracing-panel' import { useStore } from '../store' -import { - WorkflowRunningStatus, -} from '../types' +import { WorkflowRunningStatus } from '../types' import { formatWorkflowRunIdentifier } from '../utils' import HumanInputFilledFormList from './human-input-filled-form-list' import HumanInputFormList from './human-input-form-list' @@ -31,15 +22,17 @@ import InputsPanel from './inputs-panel' const WorkflowPreview = () => { const { t } = useTranslation() const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions() - const workflowRunningData = useStore(s => s.workflowRunningData) - const isListening = useStore(s => s.isListening) - const showInputsPanel = useStore(s => s.showInputsPanel) - const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) - const panelWidth = useStore(s => s.previewPanelWidth) - const setPreviewPanelWidth = useStore(s => s.setPreviewPanelWidth) - const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel) - const humanInputFormDataList = useStore(s => s.workflowRunningData?.humanInputFormDataList) - const humanInputFilledFormDataList = useStore(s => s.workflowRunningData?.humanInputFilledFormDataList) + const workflowRunningData = useStore((s) => s.workflowRunningData) + const isListening = useStore((s) => s.isListening) + const showInputsPanel = useStore((s) => s.showInputsPanel) + const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth) + const panelWidth = useStore((s) => s.previewPanelWidth) + const setPreviewPanelWidth = useStore((s) => s.setPreviewPanelWidth) + const showDebugAndPreviewPanel = useStore((s) => s.showDebugAndPreviewPanel) + const humanInputFormDataList = useStore((s) => s.workflowRunningData?.humanInputFormDataList) + const humanInputFilledFormDataList = useStore( + (s) => s.workflowRunningData?.humanInputFilledFormDataList, + ) const [currentTab, setCurrentTab] = useState<string>(showInputsPanel ? 'INPUT' : 'TRACING') const switchTab = async (tab: string) => { @@ -47,24 +40,24 @@ const WorkflowPreview = () => { } useEffect(() => { - if (showDebugAndPreviewPanel && showInputsPanel) - switchTab('INPUT') + if (showDebugAndPreviewPanel && showInputsPanel) switchTab('INPUT') }, [showDebugAndPreviewPanel, showInputsPanel]) useEffect(() => { - if (isListening) - switchTab('DETAIL') + if (isListening) switchTab('DETAIL') }, [isListening]) useEffect(() => { const status = workflowRunningData?.result.status - if (!workflowRunningData) - return + if (!workflowRunningData) return - if ((status === WorkflowRunningStatus.Succeeded || status === WorkflowRunningStatus.Failed) && !workflowRunningData.resultText && !workflowRunningData.result.files?.length) + if ( + (status === WorkflowRunningStatus.Succeeded || status === WorkflowRunningStatus.Failed) && + !workflowRunningData.resultText && + !workflowRunningData.result.files?.length + ) switchTab('DETAIL') - if (status === WorkflowRunningStatus.Paused) - switchTab('RESULT') + if (status === WorkflowRunningStatus.Paused) switchTab('RESULT') }, [workflowRunningData]) const [isResizing, setIsResizing] = useState(false) @@ -78,17 +71,19 @@ const WorkflowPreview = () => { setIsResizing(false) }, []) - const resize = useCallback((e: MouseEvent) => { - if (isResizing) { - const newWidth = window.innerWidth - e.clientX - // width constraints: 400 <= width <= maxAllowed (canvas - reserved 400) - const reservedCanvasWidth = 400 - const maxAllowed = workflowCanvasWidth ? (workflowCanvasWidth - reservedCanvasWidth) : 1024 + const resize = useCallback( + (e: MouseEvent) => { + if (isResizing) { + const newWidth = window.innerWidth - e.clientX + // width constraints: 400 <= width <= maxAllowed (canvas - reserved 400) + const reservedCanvasWidth = 400 + const maxAllowed = workflowCanvasWidth ? workflowCanvasWidth - reservedCanvasWidth : 1024 - if (newWidth >= 400 && newWidth <= maxAllowed) - setPreviewPanelWidth(newWidth) - } - }, [isResizing, workflowCanvasWidth, setPreviewPanelWidth]) + if (newWidth >= 400 && newWidth <= maxAllowed) setPreviewPanelWidth(newWidth) + } + }, + [isResizing, workflowCanvasWidth, setPreviewPanelWidth], + ) useEffect(() => { window.addEventListener('mousemove', resize) @@ -99,9 +94,12 @@ const WorkflowPreview = () => { } }, [resize, stopResizing]) - const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => { - await submitHumanInputForm(formToken, formData) - }, []) + const handleSubmitHumanInputForm = useCallback( + async (formToken: string, formData: HumanInputFormSubmitData) => { + await submitHumanInputForm(formToken, formData) + }, + [], + ) const handleOpenTracingTab = useCallback(() => { switchTab('TRACING') @@ -120,7 +118,7 @@ const WorkflowPreview = () => { {`Test Run${formatWorkflowRunIdentifier(workflowRunningData?.result.finished_at, workflowRunningData?.result.status)}`} <button type="button" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} className="cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={() => handleCancelDebugAndPreviewPanel()} > @@ -137,7 +135,7 @@ const WorkflowPreview = () => { )} onClick={() => switchTab('INPUT')} > - {t($ => $.input, { ns: 'runLog' })} + {t(($) => $.input, { ns: 'runLog' })} </div> )} <div @@ -147,12 +145,11 @@ const WorkflowPreview = () => { !workflowRunningData && 'cursor-not-allowed! opacity-30', )} onClick={() => { - if (!workflowRunningData) - return + if (!workflowRunningData) return switchTab('RESULT') }} > - {t($ => $.result, { ns: 'runLog' })} + {t(($) => $.result, { ns: 'runLog' })} </div> <div className={cn( @@ -161,12 +158,11 @@ const WorkflowPreview = () => { !workflowRunningData && 'cursor-not-allowed! opacity-30', )} onClick={() => { - if (!workflowRunningData) - return + if (!workflowRunningData) return switchTab('DETAIL') }} > - {t($ => $.detail, { ns: 'runLog' })} + {t(($) => $.detail, { ns: 'runLog' })} </div> <div className={cn( @@ -175,18 +171,18 @@ const WorkflowPreview = () => { !workflowRunningData && 'cursor-not-allowed! opacity-30', )} onClick={() => { - if (!workflowRunningData) - return + if (!workflowRunningData) return switchTab('TRACING') }} > - {t($ => $.tracing, { ns: 'runLog' })} + {t(($) => $.tracing, { ns: 'runLog' })} </div> </div> - <div className={cn( - 'h-0 grow overflow-y-auto rounded-b-2xl bg-components-panel-bg', - (currentTab === 'RESULT' || currentTab === 'TRACING') && 'bg-background-section-burn!', - )} + <div + className={cn( + 'h-0 grow overflow-y-auto rounded-b-2xl bg-components-panel-bg', + (currentTab === 'RESULT' || currentTab === 'TRACING') && 'bg-background-section-burn!', + )} > {currentTab === 'INPUT' && showInputsPanel && ( <InputsPanel onRun={() => switchTab('RESULT')} /> @@ -204,41 +200,45 @@ const WorkflowPreview = () => { humanInputFilledFormDataList={humanInputFilledFormDataList} /> )} - {workflowRunningData?.reasoningContent && Object.values(workflowRunningData.reasoningContent).some(Boolean) && ( - <ReasoningPanel - content={workflowRunningData.reasoningContent} - // freeze the timer once the answer starts streaming — reasoningFinished and status only flip at run end - done={ - !!workflowRunningData?.resultText?.trim() - || !!workflowRunningData?.reasoningFinished - || workflowRunningData?.result?.status !== WorkflowRunningStatus.Running - } - /> - )} + {workflowRunningData?.reasoningContent && + Object.values(workflowRunningData.reasoningContent).some(Boolean) && ( + <ReasoningPanel + content={workflowRunningData.reasoningContent} + // freeze the timer once the answer starts streaming — reasoningFinished and status only flip at run end + done={ + !!workflowRunningData?.resultText?.trim() || + !!workflowRunningData?.reasoningFinished || + workflowRunningData?.result?.status !== WorkflowRunningStatus.Running + } + /> + )} <ResultText - isRunning={workflowRunningData?.result?.status === WorkflowRunningStatus.Running || !workflowRunningData?.result} + isRunning={ + workflowRunningData?.result?.status === WorkflowRunningStatus.Running || + !workflowRunningData?.result + } isPaused={workflowRunningData?.result?.status === WorkflowRunningStatus.Paused} outputs={workflowRunningData?.resultText} allFiles={workflowRunningData?.result?.files} error={workflowRunningData?.result?.error} onClick={() => switchTab('DETAIL')} /> - {(workflowRunningData?.result.status === WorkflowRunningStatus.Succeeded && workflowRunningData?.resultText && typeof workflowRunningData?.resultText === 'string') && ( - <Button - className={cn('mb-4 ml-4 space-x-1')} - onClick={() => { - const content = workflowRunningData?.resultText - if (typeof content === 'string') - copy(content) - else - copy(JSON.stringify(content)) - toast.success(t($ => $['actionMsg.copySuccessfully'], { ns: 'common' })) - }} - > - <span className="i-ri-clipboard-line size-3.5" /> - <div>{t($ => $['operation.copy'], { ns: 'common' })}</div> - </Button> - )} + {workflowRunningData?.result.status === WorkflowRunningStatus.Succeeded && + workflowRunningData?.resultText && + typeof workflowRunningData?.resultText === 'string' && ( + <Button + className={cn('mb-4 ml-4 space-x-1')} + onClick={() => { + const content = workflowRunningData?.resultText + if (typeof content === 'string') copy(content) + else copy(JSON.stringify(content)) + toast.success(t(($) => $['actionMsg.copySuccessfully'], { ns: 'common' })) + }} + > + <span className="i-ri-clipboard-line size-3.5" /> + <div>{t(($) => $['operation.copy'], { ns: 'common' })}</div> + </Button> + )} </div> )} {currentTab === 'DETAIL' && ( diff --git a/web/app/components/workflow/persistence/local-storage-bridge.tsx b/web/app/components/workflow/persistence/local-storage-bridge.tsx index 169d2ddcc41a2f..6cab3b32c02d19 100644 --- a/web/app/components/workflow/persistence/local-storage-bridge.tsx +++ b/web/app/components/workflow/persistence/local-storage-bridge.tsx @@ -16,23 +16,21 @@ export const WorkflowLocalStorageBridge = () => { const [storedControlMode, setControlModeStorage] = useWorkflowOperationMode() const workflowStore = useWorkflowStore() - const setNodePanelWidth = useStore(state => state.setNodePanelWidth) - const setPanelWidth = useStore(state => state.setPanelWidth) - const setPreviewPanelWidth = useStore(state => state.setPreviewPanelWidth) - const setVariableInspectPanelHeight = useStore(state => state.setVariableInspectPanelHeight) - const setControlMode = useStore(state => state.setControlMode) + const setNodePanelWidth = useStore((state) => state.setNodePanelWidth) + const setPanelWidth = useStore((state) => state.setPanelWidth) + const setPreviewPanelWidth = useStore((state) => state.setPreviewPanelWidth) + const setVariableInspectPanelHeight = useStore((state) => state.setVariableInspectPanelHeight) + const setControlMode = useStore((state) => state.setControlMode) useLayoutEffect(() => { - if (!isFiniteNumber(storedNodePanelWidth)) - return + if (!isFiniteNumber(storedNodePanelWidth)) return setNodePanelWidth(storedNodePanelWidth) setPanelWidth(storedNodePanelWidth) }, [setNodePanelWidth, setPanelWidth, storedNodePanelWidth]) useLayoutEffect(() => { - if (isFiniteNumber(storedPreviewPanelWidth)) - setPreviewPanelWidth(storedPreviewPanelWidth) + if (isFiniteNumber(storedPreviewPanelWidth)) setPreviewPanelWidth(storedPreviewPanelWidth) }, [setPreviewPanelWidth, storedPreviewPanelWidth]) useLayoutEffect(() => { @@ -41,8 +39,7 @@ export const WorkflowLocalStorageBridge = () => { }, [setVariableInspectPanelHeight, storedVariableInspectPanelHeight]) useLayoutEffect(() => { - if (isControlMode(storedControlMode)) - setControlMode(storedControlMode) + if (isControlMode(storedControlMode)) setControlMode(storedControlMode) }, [setControlMode, storedControlMode]) useEffect(() => { diff --git a/web/app/components/workflow/persistence/local-storage-options.ts b/web/app/components/workflow/persistence/local-storage-options.ts index 1cf90d806f0534..eb769622a47147 100644 --- a/web/app/components/workflow/persistence/local-storage-options.ts +++ b/web/app/components/workflow/persistence/local-storage-options.ts @@ -14,42 +14,36 @@ const numberStorageOptions = { } as const export const isControlMode = (value: string | null): value is ControlMode => { - return value === ControlMode.Pointer || value === ControlMode.Hand || value === ControlMode.Comment + return ( + value === ControlMode.Pointer || value === ControlMode.Hand || value === ControlMode.Comment + ) } export const isFiniteNumber = (value: number | null): value is number => { return value !== null && Number.isFinite(value) } -const [ - _useWorkflowNodePanelWidth, - useWorkflowNodePanelWidthValue, - useSetWorkflowNodePanelWidth, -] = createLocalStorageState<number>(WORKFLOW_NODE_PANEL_WIDTH_KEY, undefined, numberStorageOptions) +const [_useWorkflowNodePanelWidth, useWorkflowNodePanelWidthValue, useSetWorkflowNodePanelWidth] = + createLocalStorageState<number>(WORKFLOW_NODE_PANEL_WIDTH_KEY, undefined, numberStorageOptions) -const [ - _useDebugPreviewPanelWidth, - useDebugPreviewPanelWidthValue, - useSetDebugPreviewPanelWidth, -] = createLocalStorageState<number>(WORKFLOW_PREVIEW_PANEL_WIDTH_KEY, undefined, numberStorageOptions) +const [_useDebugPreviewPanelWidth, useDebugPreviewPanelWidthValue, useSetDebugPreviewPanelWidth] = + createLocalStorageState<number>(WORKFLOW_PREVIEW_PANEL_WIDTH_KEY, undefined, numberStorageOptions) const [ _useWorkflowVariableInspectPanelHeight, useWorkflowVariableInspectPanelHeightValue, useSetWorkflowVariableInspectPanelHeight, -] = createLocalStorageState<number>(WORKFLOW_VARIABLE_INSPECT_PANEL_HEIGHT_KEY, undefined, numberStorageOptions) +] = createLocalStorageState<number>( + WORKFLOW_VARIABLE_INSPECT_PANEL_HEIGHT_KEY, + undefined, + numberStorageOptions, +) -const [ - useWorkflowOperationMode, - _useWorkflowOperationModeValue, - _useSetWorkflowOperationMode, -] = createLocalStorageState<string>(WORKFLOW_OPERATION_MODE_KEY, undefined, rawStorageOptions) +const [useWorkflowOperationMode, _useWorkflowOperationModeValue, _useSetWorkflowOperationMode] = + createLocalStorageState<string>(WORKFLOW_OPERATION_MODE_KEY, undefined, rawStorageOptions) -const [ - _useWorkflowNoteShowAuthor, - useWorkflowNoteShowAuthorValue, - useSetWorkflowNoteShowAuthor, -] = createLocalStorageState<string>(NOTE_SHOW_AUTHOR_STORAGE_KEY, 'true', rawStorageOptions) +const [_useWorkflowNoteShowAuthor, useWorkflowNoteShowAuthorValue, useSetWorkflowNoteShowAuthor] = + createLocalStorageState<string>(NOTE_SHOW_AUTHOR_STORAGE_KEY, 'true', rawStorageOptions) export { useDebugPreviewPanelWidthValue, diff --git a/web/app/components/workflow/plugin-dependency/__tests__/index.spec.tsx b/web/app/components/workflow/plugin-dependency/__tests__/index.spec.tsx index 1ae8605238a756..1f371a56684803 100644 --- a/web/app/components/workflow/plugin-dependency/__tests__/index.spec.tsx +++ b/web/app/components/workflow/plugin-dependency/__tests__/index.spec.tsx @@ -6,16 +6,12 @@ import { useStore } from '../store' vi.mock('@/app/components/plugins/install-plugin/install-bundle', () => ({ __esModule: true, - default: ({ - fromDSLPayload, - onClose, - }: { - fromDSLPayload: Dependency[] - onClose: () => void - }) => ( + default: ({ fromDSLPayload, onClose }: { fromDSLPayload: Dependency[]; onClose: () => void }) => ( <div> <div>{`bundle-size:${fromDSLPayload.length}`}</div> - <button type="button" onClick={onClose}>close-bundle</button> + <button type="button" onClick={onClose}> + close-bundle + </button> </div> ), })) diff --git a/web/app/components/workflow/plugin-dependency/hooks.ts b/web/app/components/workflow/plugin-dependency/hooks.ts index ff7b85fa09c02a..77db868dc6843f 100644 --- a/web/app/components/workflow/plugin-dependency/hooks.ts +++ b/web/app/components/workflow/plugin-dependency/hooks.ts @@ -7,12 +7,15 @@ export const usePluginDependencies = () => { const { mutateAsync: checkWorkflowDependencies } = useMutationCheckDependencies() const { mutateAsync: checkPipelineDependencies } = useCheckPipelineDependencies() - const handleCheckPluginDependencies = useCallback(async (id: string, isPipeline = false) => { - const checkDependencies = isPipeline ? checkPipelineDependencies : checkWorkflowDependencies - const { leaked_dependencies } = await checkDependencies(id) - const { setDependencies } = usePluginDependenciesStore.getState() - setDependencies(leaked_dependencies) - }, [checkWorkflowDependencies, checkPipelineDependencies]) + const handleCheckPluginDependencies = useCallback( + async (id: string, isPipeline = false) => { + const checkDependencies = isPipeline ? checkPipelineDependencies : checkWorkflowDependencies + const { leaked_dependencies } = await checkDependencies(id) + const { setDependencies } = usePluginDependenciesStore.getState() + setDependencies(leaked_dependencies) + }, + [checkWorkflowDependencies, checkPipelineDependencies], + ) return { handleCheckPluginDependencies, diff --git a/web/app/components/workflow/plugin-dependency/index.tsx b/web/app/components/workflow/plugin-dependency/index.tsx index 69ee457527cffe..1630d516c654d4 100644 --- a/web/app/components/workflow/plugin-dependency/index.tsx +++ b/web/app/components/workflow/plugin-dependency/index.tsx @@ -3,22 +3,16 @@ import InstallBundle from '@/app/components/plugins/install-plugin/install-bundl import { useStore } from './store' const PluginDependency = () => { - const dependencies = useStore(s => s.dependencies) + const dependencies = useStore((s) => s.dependencies) const handleCancelInstallBundle = useCallback(() => { const { setDependencies } = useStore.getState() setDependencies([]) }, []) - if (!dependencies.length) - return null + if (!dependencies.length) return null - return ( - <InstallBundle - fromDSLPayload={dependencies} - onClose={handleCancelInstallBundle} - /> - ) + return <InstallBundle fromDSLPayload={dependencies} onClose={handleCancelInstallBundle} /> } export default PluginDependency diff --git a/web/app/components/workflow/plugin-dependency/store.ts b/web/app/components/workflow/plugin-dependency/store.ts index 71b8420697aa9d..fdc75619b31337 100644 --- a/web/app/components/workflow/plugin-dependency/store.ts +++ b/web/app/components/workflow/plugin-dependency/store.ts @@ -5,7 +5,7 @@ type Shape = { dependencies: Dependency[] setDependencies: (dependencies: Dependency[]) => void } -export const useStore = create<Shape>(set => ({ +export const useStore = create<Shape>((set) => ({ dependencies: [], - setDependencies: dependencies => set({ dependencies }), + setDependencies: (dependencies) => set({ dependencies }), })) diff --git a/web/app/components/workflow/run/__tests__/hooks.spec.ts b/web/app/components/workflow/run/__tests__/hooks.spec.ts index d6eefbcd3e0ca6..4f82e2c6e151ae 100644 --- a/web/app/components/workflow/run/__tests__/hooks.spec.ts +++ b/web/app/components/workflow/run/__tests__/hooks.spec.ts @@ -38,7 +38,10 @@ const createNodeTracing = (id: string): NodeTracing => ({ finished_at: 1, }) -const createAgentLog = (id: string, children: AgentLogItemWithChildren[] = []): AgentLogItemWithChildren => ({ +const createAgentLog = ( + id: string, + children: AgentLogItemWithChildren[] = [], +): AgentLogItemWithChildren => ({ node_execution_id: `execution-${id}`, node_id: `node-${id}`, parent_id: undefined, diff --git a/web/app/components/workflow/run/__tests__/index.spec.tsx b/web/app/components/workflow/run/__tests__/index.spec.tsx index 39f26df97ef476..f27c53d3ab8cad 100644 --- a/web/app/components/workflow/run/__tests__/index.spec.tsx +++ b/web/app/components/workflow/run/__tests__/index.spec.tsx @@ -5,17 +5,16 @@ import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' import { BlockEnum, NodeRunningStatus } from '../../types' import RunPanel from '../index' -const { - mockFetchRunDetail, - mockFetchTracingList, - mockToastError, -} = vi.hoisted(() => ({ +const { mockFetchRunDetail, mockFetchTracingList, mockToastError } = vi.hoisted(() => ({ mockFetchRunDetail: vi.fn(), mockFetchTracingList: vi.fn(), mockToastError: vi.fn(), })) -const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight') +const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'clientHeight', +) vi.mock('@/service/log', () => ({ fetchRunDetail: (...args: unknown[]) => mockFetchRunDetail(...args), @@ -28,7 +27,9 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }, })) -const createRunDetail = (overrides: Partial<WorkflowRunDetailResponse> = {}): WorkflowRunDetailResponse => ({ +const createRunDetail = ( + overrides: Partial<WorkflowRunDetailResponse> = {}, +): WorkflowRunDetailResponse => ({ id: 'run-1', version: '1', graph: { @@ -129,7 +130,9 @@ describe('RunPanel', () => { url: '/console/api/runs/run-1/tracing', }) expect(handleResult).toHaveBeenCalledWith(runDetail) - expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain('workflow output') + expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain( + 'workflow output', + ) }) }) @@ -161,7 +164,9 @@ describe('RunPanel', () => { await waitFor(() => { expect(mockFetchRunDetail).toHaveBeenCalledTimes(2) - expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain('workflow output') + expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain( + 'workflow output', + ) }) }) diff --git a/web/app/components/workflow/run/__tests__/node.spec.tsx b/web/app/components/workflow/run/__tests__/node.spec.tsx index b8f4e945cd35c1..0dffd133c19679 100644 --- a/web/app/components/workflow/run/__tests__/node.spec.tsx +++ b/web/app/components/workflow/run/__tests__/node.spec.tsx @@ -69,16 +69,20 @@ describe('Run NodePanel', () => { it('forwards iteration details through the real iteration trigger', async () => { const handleShowIterationDetail = vi.fn() - const details = [[createNodeInfo({ - id: 'iter-trace-1', - node_id: 'iter-node-1', - execution_metadata: { - total_tokens: 8, - total_price: 0, - currency: 'USD', - iteration_index: 0, - }, - })]] + const details = [ + [ + createNodeInfo({ + id: 'iter-trace-1', + node_id: 'iter-node-1', + execution_metadata: { + total_tokens: 8, + total_price: 0, + currency: 'USD', + iteration_index: 0, + }, + }), + ], + ] const iterDurationMap = { 0: 1.2 } render( diff --git a/web/app/components/workflow/run/__tests__/output-panel.spec.tsx b/web/app/components/workflow/run/__tests__/output-panel.spec.tsx index 34b13011edf88e..bd0613cd5917bd 100644 --- a/web/app/components/workflow/run/__tests__/output-panel.spec.tsx +++ b/web/app/components/workflow/run/__tests__/output-panel.spec.tsx @@ -12,7 +12,7 @@ vi.mock('@/app/components/base/chat/chat/loading-anim', () => ({ vi.mock('@/app/components/base/file-uploader', () => ({ FileList: ({ files }: { files: FileEntity[] }) => ( - <div data-testid="file-list">{files.map(file => file.name).join(', ')}</div> + <div data-testid="file-list">{files.map((file) => file.name).join(', ')}</div> ), })) @@ -21,21 +21,15 @@ vi.mock('@/app/components/base/markdown', () => ({ })) vi.mock('@/app/components/workflow/run/status-container', () => ({ - default: ({ status, children }: { status: string, children?: React.ReactNode }) => ( - <div data-status={status} data-testid="status-container">{children}</div> + default: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + <div data-status={status} data-testid="status-container"> + {children} + </div> ), })) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ - language, - value, - height, - }: { - language: string - value: string - height?: number - }) => ( + default: ({ language, value, height }: { language: string; value: string; height?: number }) => ( <div data-height={height} data-language={language} data-testid="code-editor" data-value={value}> {value} </div> @@ -123,10 +117,13 @@ describe('OutputPanel', () => { expect(screen.getByTestId('code-editor')).toHaveAttribute('data-language', 'json') expect(screen.getByTestId('code-editor')).toHaveAttribute('data-height', '92') - expect(screen.getByTestId('code-editor')).toHaveAttribute('data-value', `{ + expect(screen.getByTestId('code-editor')).toHaveAttribute( + 'data-value', + `{ "answer": "hello", "score": 1 -}`) +}`, + ) }) it('skips the code editor when structured outputs have no positive height', () => { diff --git a/web/app/components/workflow/run/__tests__/result-panel.spec.tsx b/web/app/components/workflow/run/__tests__/result-panel.spec.tsx index b10aacbbbaf22f..359adb02d253f1 100644 --- a/web/app/components/workflow/run/__tests__/result-panel.spec.tsx +++ b/web/app/components/workflow/run/__tests__/result-panel.spec.tsx @@ -22,12 +22,7 @@ vi.mock('react-i18next', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ __esModule: true, - default: (props: { - title: ReactNode - value: unknown - footer?: ReactNode - tip?: ReactNode - }) => { + default: (props: { title: ReactNode; value: unknown; footer?: ReactNode; tip?: ReactNode }) => { mockCodeEditor(props) return ( <section data-testid="code-editor"> @@ -51,13 +46,15 @@ vi.mock('@/app/components/workflow/nodes/_base/components/error-handle/error-han vi.mock('@/app/components/workflow/run/iteration-log', () => ({ IterationLogTrigger: (props: { onShowIterationResultList: (detail: unknown, durationMap: unknown) => void - nodeInfo: { details?: unknown, iterDurationMap?: unknown } + nodeInfo: { details?: unknown; iterDurationMap?: unknown } }) => { mockIterationLogTrigger(props) return ( <button type="button" - onClick={() => props.onShowIterationResultList(props.nodeInfo.details, props.nodeInfo.iterDurationMap)} + onClick={() => + props.onShowIterationResultList(props.nodeInfo.details, props.nodeInfo.iterDurationMap) + } > iteration-trigger </button> @@ -68,13 +65,15 @@ vi.mock('@/app/components/workflow/run/iteration-log', () => ({ vi.mock('@/app/components/workflow/run/loop-log', () => ({ LoopLogTrigger: (props: { onShowLoopResultList: (detail: unknown, durationMap: unknown) => void - nodeInfo: { details?: unknown, loopDurationMap?: unknown } + nodeInfo: { details?: unknown; loopDurationMap?: unknown } }) => { mockLoopLogTrigger(props) return ( <button type="button" - onClick={() => props.onShowLoopResultList(props.nodeInfo.details, props.nodeInfo.loopDurationMap)} + onClick={() => + props.onShowLoopResultList(props.nodeInfo.details, props.nodeInfo.loopDurationMap) + } > loop-trigger </button> @@ -89,10 +88,7 @@ vi.mock('@/app/components/workflow/run/retry-log', () => ({ }) => { mockRetryLogTrigger(props) return ( - <button - type="button" - onClick={() => props.onShowRetryResultList(props.nodeInfo.retryDetail)} - > + <button type="button" onClick={() => props.onShowRetryResultList(props.nodeInfo.retryDetail)}> retry-trigger </button> ) @@ -106,10 +102,7 @@ vi.mock('@/app/components/workflow/run/agent-log/agent-log-trigger', () => ({ }) => { mockAgentLogTrigger(props) return ( - <button - type="button" - onClick={() => props.onShowAgentOrToolLog(props.nodeInfo.agentLog)} - > + <button type="button" onClick={() => props.onShowAgentOrToolLog(props.nodeInfo.agentLog)}> agent-trigger </button> ) @@ -175,11 +168,12 @@ const createNodeInfo = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({ ...overrides, }) -const createLogDetail = (id: string): NodeTracing => createNodeInfo({ - id: `trace-${id}`, - node_id: id, - title: id, -}) +const createLogDetail = (id: string): NodeTracing => + createNodeInfo({ + id: `trace-${id}`, + node_id: id, + title: id, + }) const createAgentLog = (label: string): AgentLogItemWithChildren => ({ node_execution_id: `execution-${label}`, @@ -235,27 +229,33 @@ describe('ResultPanel', () => { expect(screen.getAllByTestId('large-data-alert')).toHaveLength(3) expect(screen.getByTestId('error-handle-tip')).toHaveTextContent('continue-on-error') expect(screen.getByTestId('meta-data')).toBeInTheDocument() - expect(mockStatusPanel).toHaveBeenCalledWith(expect.objectContaining({ - status: NodeRunningStatus.Succeeded, - time: 2.5, - tokens: 42, - error: 'boom', - exceptionCounts: 1, - isListening: true, - workflowRunId: 'run-1', - })) - expect(mockMetaData).toHaveBeenCalledWith(expect.objectContaining({ - status: NodeRunningStatus.Succeeded, - executor: 'Alice', - startTime: 1710000000, - time: 2.5, - tokens: 42, - steps: 3, - showSteps: true, - })) - expect(mockLargeDataAlert).toHaveBeenLastCalledWith(expect.objectContaining({ - downloadUrl: 'https://example.com/output.json', - })) + expect(mockStatusPanel).toHaveBeenCalledWith( + expect.objectContaining({ + status: NodeRunningStatus.Succeeded, + time: 2.5, + tokens: 42, + error: 'boom', + exceptionCounts: 1, + isListening: true, + workflowRunId: 'run-1', + }), + ) + expect(mockMetaData).toHaveBeenCalledWith( + expect.objectContaining({ + status: NodeRunningStatus.Succeeded, + executor: 'Alice', + startTime: 1710000000, + time: 2.5, + tokens: 42, + steps: 3, + showSteps: true, + }), + ) + expect(mockLargeDataAlert).toHaveBeenLastCalledWith( + expect.objectContaining({ + downloadUrl: 'https://example.com/output.json', + }), + ) }) it('should render and invoke iteration and loop triggers only when their handlers are provided', () => { @@ -358,11 +358,7 @@ describe('ResultPanel', () => { it('should still render the output editor while the node is running even without outputs', () => { render( - <ResultPanel - nodeInfo={createNodeInfo()} - inputs="{}" - status={NodeRunningStatus.Running} - />, + <ResultPanel nodeInfo={createNodeInfo()} inputs="{}" status={NodeRunningStatus.Running} />, ) expect(screen.getByText('COMMON.OUTPUT')).toBeInTheDocument() diff --git a/web/app/components/workflow/run/__tests__/result-text.spec.tsx b/web/app/components/workflow/run/__tests__/result-text.spec.tsx index b9ba43dedabaeb..7c8d2ac2831429 100644 --- a/web/app/components/workflow/run/__tests__/result-text.spec.tsx +++ b/web/app/components/workflow/run/__tests__/result-text.spec.tsx @@ -9,7 +9,7 @@ vi.mock('@/app/components/base/chat/chat/loading-anim', () => ({ vi.mock('@/app/components/base/file-uploader', () => ({ FileList: ({ files }: { files: FileEntity[] }) => ( - <div data-testid="file-list">{files.map(file => file.name).join(', ')}</div> + <div data-testid="file-list">{files.map((file) => file.name).join(', ')}</div> ), })) @@ -18,8 +18,10 @@ vi.mock('@/app/components/base/markdown', () => ({ })) vi.mock('@/app/components/workflow/run/status-container', () => ({ - default: ({ status, children }: { status: string, children?: React.ReactNode }) => ( - <div data-status={status} data-testid="status-container">{children}</div> + default: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + <div data-status={status} data-testid="status-container"> + {children} + </div> ), })) diff --git a/web/app/components/workflow/run/__tests__/special-result-panel.spec.tsx b/web/app/components/workflow/run/__tests__/special-result-panel.spec.tsx index 459ddf1d86e8d8..e3a6fb903820ab 100644 --- a/web/app/components/workflow/run/__tests__/special-result-panel.spec.tsx +++ b/web/app/components/workflow/run/__tests__/special-result-panel.spec.tsx @@ -32,7 +32,11 @@ vi.mock('../loop-log', () => ({ })) vi.mock('../agent-log/agent-result-panel', () => ({ - default: ({ agentOrToolLogItemStack }: { agentOrToolLogItemStack: AgentLogItemWithChildren[] }) => { + default: ({ + agentOrToolLogItemStack, + }: { + agentOrToolLogItemStack: AgentLogItemWithChildren[] + }) => { mocks.agentPanel(agentOrToolLogItemStack) return <div data-testid="agent-result-panel">{agentOrToolLogItemStack.length}</div> }, @@ -71,7 +75,9 @@ const createNodeTracing = (overrides: Partial<NodeTracing> = {}): NodeTracing => ...overrides, }) -const createAgentLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({ +const createAgentLogItem = ( + overrides: Partial<AgentLogItemWithChildren> = {}, +): AgentLogItemWithChildren => ({ node_execution_id: 'exec-1', message_id: 'message-1', node_id: 'node-1', @@ -99,8 +105,7 @@ describe('SpecialResultPanel', () => { ) const panelRoot = container.firstElementChild?.firstElementChild - if (!panelRoot) - throw new Error('Expected panel root element') + if (!panelRoot) throw new Error('Expected panel root element') fireEvent.click(panelRoot) diff --git a/web/app/components/workflow/run/__tests__/status-container.spec.tsx b/web/app/components/workflow/run/__tests__/status-container.spec.tsx index 210d230b91b651..7a82705c70eb9a 100644 --- a/web/app/components/workflow/run/__tests__/status-container.spec.tsx +++ b/web/app/components/workflow/run/__tests__/status-container.spec.tsx @@ -27,7 +27,11 @@ describe('StatusContainer', () => { expect(screen.getByText('Finished')).toBeInTheDocument() expect(container.firstElementChild).toHaveClass('bg-workflow-display-success-bg') expect(container.firstElementChild).toHaveClass('text-text-success') - expect(container.querySelector('.bg-\\[url\\(\\~\\@\\/app\\/components\\/workflow\\/run\\/assets\\/highlight\\.svg\\)\\]')).toBeInTheDocument() + expect( + container.querySelector( + '.bg-\\[url\\(\\~\\@\\/app\\/components\\/workflow\\/run\\/assets\\/highlight\\.svg\\)\\]', + ), + ).toBeInTheDocument() }) it('should render failed styling for the dark theme', () => { @@ -41,7 +45,11 @@ describe('StatusContainer', () => { expect(container.firstElementChild).toHaveClass('bg-workflow-display-error-bg') expect(container.firstElementChild).toHaveClass('text-text-warning') - expect(container.querySelector('.bg-\\[url\\(\\~\\@\\/app\\/components\\/workflow\\/run\\/assets\\/highlight-dark\\.svg\\)\\]')).toBeInTheDocument() + expect( + container.querySelector( + '.bg-\\[url\\(\\~\\@\\/app\\/components\\/workflow\\/run\\/assets\\/highlight-dark\\.svg\\)\\]', + ), + ).toBeInTheDocument() }) it('should render warning styling for paused runs', () => { diff --git a/web/app/components/workflow/run/__tests__/status.spec.tsx b/web/app/components/workflow/run/__tests__/status.spec.tsx index b1a5b88f718a67..677faf162e3026 100644 --- a/web/app/components/workflow/run/__tests__/status.spec.tsx +++ b/web/app/components/workflow/run/__tests__/status.spec.tsx @@ -9,7 +9,7 @@ const mockUseWorkflowPausedDetails = vi.fn() vi.mock('react-i18next', async () => { const { withSelectorKey, withSelectorKeyProps } = await import('@/test/i18n-mock') - return ({ + return { useTranslation: () => ({ t: withSelectorKey((key: string, options?: Record<string, unknown>) => { const fullKey = options?.ns ? `${options.ns}.${key}` : key @@ -22,40 +22,35 @@ vi.mock('react-i18next', async () => { return `${fullKey}${suffix}` }), }), - Trans: withSelectorKeyProps(({ - i18nKey, - values, - components, - }: { - i18nKey: string - values?: { - num?: string | number - } - components?: Record<string, React.ReactNode> - }) => { - if (i18nKey !== 'nodes.common.errorHandle.partialSucceeded.tip') - return <span>{i18nKey}</span> - - const tracingLink = components?.tracingLink - const tracingNode = isValidElement(tracingLink) - ? cloneElement(tracingLink, undefined, 'TRACING') - : 'TRACING' - - return ( - <span> - There are - {' '} - {values?.num} - {' '} - nodes in the process running abnormally, please go to - {' '} - {tracingNode} - {' '} - to check the logs. - </span> - ) - }), - }) + Trans: withSelectorKeyProps( + ({ + i18nKey, + values, + components, + }: { + i18nKey: string + values?: { + num?: string | number + } + components?: Record<string, React.ReactNode> + }) => { + if (i18nKey !== 'nodes.common.errorHandle.partialSucceeded.tip') + return <span>{i18nKey}</span> + + const tracingLink = components?.tracingLink + const tracingNode = isValidElement(tracingLink) + ? cloneElement(tracingLink, undefined, 'TRACING') + : 'TRACING' + + return ( + <span> + There are {values?.num} nodes in the process running abnormally, please go to{' '} + {tracingNode} to check the logs. + </span> + ) + }, + ), + } }) vi.mock('@/context/i18n', () => ({ @@ -63,10 +58,13 @@ vi.mock('@/context/i18n', () => ({ })) vi.mock('@/service/use-log', () => ({ - useWorkflowPausedDetails: (params: { workflowRunId: string, enabled?: boolean }) => mockUseWorkflowPausedDetails(params), + useWorkflowPausedDetails: (params: { workflowRunId: string; enabled?: boolean }) => + mockUseWorkflowPausedDetails(params), })) -const createPausedDetails = (overrides: Partial<WorkflowPausedDetailsResponse> = {}): WorkflowPausedDetailsResponse => ({ +const createPausedDetails = ( + overrides: Partial<WorkflowPausedDetailsResponse> = {}, +): WorkflowPausedDetailsResponse => ({ paused_at: '2026-03-18T00:00:00Z', paused_nodes: [], ...overrides, @@ -116,20 +114,34 @@ describe('Status', () => { expect(screen.getByText('FAIL')).toBeInTheDocument() expect(screen.getByText('Something broke')).toBeInTheDocument() - expect(screen.getAllByText((_, element) => element?.textContent === 'There are 2 nodes in the process running abnormally, please go to TRACING to check the logs.')).toHaveLength(2) + expect( + screen.getAllByText( + (_, element) => + element?.textContent === + 'There are 2 nodes in the process running abnormally, please go to TRACING to check the logs.', + ), + ).toHaveLength(2) }) it('renders the partial-succeeded warning summary', () => { render(<Status status="partial-succeeded" exceptionCounts={3} />) expect(screen.getByText('PARTIAL SUCCESS')).toBeInTheDocument() - expect(screen.getAllByText((_, element) => element?.textContent === 'There are 3 nodes in the process running abnormally, please go to TRACING to check the logs.')).toHaveLength(2) + expect( + screen.getAllByText( + (_, element) => + element?.textContent === + 'There are 3 nodes in the process running abnormally, please go to TRACING to check the logs.', + ), + ).toHaveLength(2) }) it('opens the tracing tab when clicking the TRACING link', () => { const onOpenTracingTab = vi.fn() - render(<Status status="partial-succeeded" exceptionCounts={3} onOpenTracingTab={onOpenTracingTab} />) + render( + <Status status="partial-succeeded" exceptionCounts={3} onOpenTracingTab={onOpenTracingTab} />, + ) fireEvent.click(screen.getByRole('link', { name: 'TRACING' })) @@ -188,7 +200,13 @@ describe('Status', () => { expect(screen.getByText('workflow.nodes.humanInput.log.reasonContent')).toBeInTheDocument() expect(screen.getByText('workflow.nodes.humanInput.log.backstageInputURL')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'https://example.com/a' })).toHaveAttribute('href', 'https://example.com/a') - expect(screen.getByRole('link', { name: 'https://example.com/b' })).toHaveAttribute('href', 'https://example.com/b') + expect(screen.getByRole('link', { name: 'https://example.com/a' })).toHaveAttribute( + 'href', + 'https://example.com/a', + ) + expect(screen.getByRole('link', { name: 'https://example.com/b' })).toHaveAttribute( + 'href', + 'https://example.com/b', + ) }) }) diff --git a/web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx b/web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx index da615eb44ed41c..9610d2e4edff60 100644 --- a/web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx +++ b/web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx @@ -24,9 +24,7 @@ vi.mock('../hooks', () => ({ vi.mock('../node', () => ({ __esModule: true, - default: (props: { - nodeInfo: { id: string } - }) => { + default: (props: { nodeInfo: { id: string } }) => { mockNodePanel(props) return <div data-testid={`node-${props.nodeInfo.id}`}>{props.nodeInfo.id}</div> }, @@ -76,13 +74,15 @@ describe('TracingPanel', () => { parallelDetail: { isParallelStartNode: true, parallelTitle: 'Parallel Group', - children: [{ - id: 'child-1', - title: 'Child Node', - parallelDetail: { - branchTitle: 'Branch A', + children: [ + { + id: 'child-1', + title: 'Child Node', + parallelDetail: { + branchTitle: 'Branch A', + }, }, - }], + ], }, }, { @@ -115,7 +115,9 @@ describe('TracingPanel', () => { fireEvent.click(container.querySelector('.py-2') as HTMLElement) expect(parentClick).not.toHaveBeenCalled() - const hoverTarget = screen.getByText('Parallel Group').closest('[data-parallel-id="parallel-1"]') as HTMLElement + const hoverTarget = screen + .getByText('Parallel Group') + .closest('[data-parallel-id="parallel-1"]') as HTMLElement const nestedParallelTarget = document.createElement('div') nestedParallelTarget.setAttribute('data-parallel-id', 'parallel-1') const unrelatedTarget = document.createElement('div') @@ -134,13 +136,19 @@ describe('TracingPanel', () => { fireEvent.mouseLeave(hoverTarget) fireEvent.click(screen.getAllByRole('button')[0]!) - expect(container.querySelector('[data-parallel-id="parallel-1"] > div:last-child'))!.toHaveClass('hidden') + expect( + container.querySelector('[data-parallel-id="parallel-1"] > div:last-child'), + )!.toHaveClass('hidden') fireEvent.click(screen.getAllByRole('button')[0]!) - expect(container.querySelector('[data-parallel-id="parallel-1"] > div:last-child')).not.toHaveClass('hidden') - expect(mockNodePanel).toHaveBeenCalledWith(expect.objectContaining({ - hideInfo: true, - hideProcessDetail: true, - })) + expect( + container.querySelector('[data-parallel-id="parallel-1"] > div:last-child'), + ).not.toHaveClass('hidden') + expect(mockNodePanel).toHaveBeenCalledWith( + expect.objectContaining({ + hideInfo: true, + hideProcessDetail: true, + }), + ) nestedParallelTarget.remove() unrelatedTarget.remove() @@ -172,13 +180,15 @@ describe('TracingPanel', () => { render(<TracingPanel list={[]} />) expect(screen.getByTestId('special-result-panel'))!.toBeInTheDocument() - expect(mockSpecialResultPanel).toHaveBeenCalledWith(expect.objectContaining({ - showRetryDetail: true, - retryResultList: [{ id: 'retry-1' }], - showIteratingDetail: true, - showLoopingDetail: true, - agentOrToolLogItemStack: [{ id: 'agent-1' }], - })) + expect(mockSpecialResultPanel).toHaveBeenCalledWith( + expect.objectContaining({ + showRetryDetail: true, + retryResultList: [{ id: 'retry-1' }], + showIteratingDetail: true, + showLoopingDetail: true, + agentOrToolLogItemStack: [{ id: 'agent-1' }], + }), + ) }) it('should resolve hovered parallel ids from related targets', () => { diff --git a/web/app/components/workflow/run/agent-log/__tests__/agent-log-item.spec.tsx b/web/app/components/workflow/run/agent-log/__tests__/agent-log-item.spec.tsx index 6dbe4aa4c17585..c86be039442794 100644 --- a/web/app/components/workflow/run/agent-log/__tests__/agent-log-item.spec.tsx +++ b/web/app/components/workflow/run/agent-log/__tests__/agent-log-item.spec.tsx @@ -3,7 +3,9 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import AgentLogItem from '../agent-log-item' -const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({ +const createLogItem = ( + overrides: Partial<AgentLogItemWithChildren> = {}, +): AgentLogItemWithChildren => ({ message_id: 'message-1', label: 'Planner', children: [], @@ -30,12 +32,7 @@ describe('AgentLogItem', () => { children: [child], }) - render( - <AgentLogItem - item={item} - onShowAgentOrToolLog={onShowAgentOrToolLog} - />, - ) + render(<AgentLogItem item={item} onShowAgentOrToolLog={onShowAgentOrToolLog} />) expect(screen.getByText('Planner')).toBeInTheDocument() expect(screen.getByText((_, node) => node?.textContent === '1.234s')).toBeInTheDocument() @@ -43,7 +40,9 @@ describe('AgentLogItem', () => { await user.click(screen.getByText('Planner')) expect(screen.getByRole('button', { name: /1 Action Logs/i })).toBeInTheDocument() - expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain('inspect data') + expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain( + 'inspect data', + ) await user.click(screen.getByRole('button', { name: /1 Action Logs/i })) diff --git a/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx b/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx index e2837406135524..5c4380f885666c 100644 --- a/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx +++ b/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx @@ -5,34 +5,64 @@ import AgentLogNavMore from '../agent-log-nav-more' vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { const React = await import('react') - const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null) + const DropdownMenuContext = React.createContext<{ + open: boolean + setOpen: (open: boolean) => void + } | null>(null) const useDropdownMenuContext = () => { const context = React.use(DropdownMenuContext) - if (!context) - throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu') return context } return { - DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + DropdownMenu: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( <DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}> <div>{children}</div> </DropdownMenuContext> ), - DropdownMenuTrigger: ({ children, render }: { children: React.ReactNode, render?: React.ReactElement }) => { + DropdownMenuTrigger: ({ + children, + render, + }: { + children: React.ReactNode + render?: React.ReactElement + }) => { const { open, setOpen } = useDropdownMenuContext() if (render) - return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record<string, unknown>, children) + return React.cloneElement( + render, + { onClick: () => setOpen(!open) } as Record<string, unknown>, + children, + ) - return <button type="button" onClick={() => setOpen(!open)}>{children}</button> + return ( + <button type="button" onClick={() => setOpen(!open)}> + {children} + </button> + ) }, DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { const { open } = useDropdownMenuContext() return open ? <div>{children}</div> : null }, - DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => { + DropdownMenuItem: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler<HTMLButtonElement> + }) => { const { setOpen } = useDropdownMenuContext() return ( <button @@ -49,7 +79,9 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { } }) -const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({ +const createLogItem = ( + overrides: Partial<AgentLogItemWithChildren> = {}, +): AgentLogItemWithChildren => ({ message_id: 'message-1', label: 'Planner', children: [], @@ -70,12 +102,7 @@ describe('AgentLogNavMore', () => { const onShowAgentOrToolLog = vi.fn() const option = createLogItem({ message_id: 'mid', label: 'Intermediate Tool' }) - render( - <AgentLogNavMore - options={[option]} - onShowAgentOrToolLog={onShowAgentOrToolLog} - />, - ) + render(<AgentLogNavMore options={[option]} onShowAgentOrToolLog={onShowAgentOrToolLog} />) await user.click(screen.getByRole('button')) await user.click(screen.getByText('Intermediate Tool')) diff --git a/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav.spec.tsx b/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav.spec.tsx index bc66598e74616a..1204393fe81b9d 100644 --- a/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav.spec.tsx +++ b/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav.spec.tsx @@ -3,7 +3,9 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { AgentLogNav } from '../agent-log-nav' -const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({ +const createLogItem = ( + overrides: Partial<AgentLogItemWithChildren> = {}, +): AgentLogItemWithChildren => ({ message_id: 'message-1', label: 'Planner', children: [], @@ -29,14 +31,13 @@ describe('AgentLogNav', () => { ] render( - <AgentLogNav - agentOrToolLogItemStack={stack} - onShowAgentOrToolLog={onShowAgentOrToolLog} - />, + <AgentLogNav agentOrToolLogItemStack={stack} onShowAgentOrToolLog={onShowAgentOrToolLog} />, ) await user.click(screen.getByRole('button', { name: /^AGENT$/i })) - await user.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.strategy\.label$/ })) + await user.click( + screen.getByRole('button', { name: /^workflow\.nodes\.agent\.strategy\.label$/ }), + ) await user.click(screen.getAllByRole('button')[2]!) await user.click(screen.getByText('Tool A')) diff --git a/web/app/components/workflow/run/agent-log/__tests__/agent-log-trigger.spec.tsx b/web/app/components/workflow/run/agent-log/__tests__/agent-log-trigger.spec.tsx index d6d6c8d499c9c7..be5d7fb0da2410 100644 --- a/web/app/components/workflow/run/agent-log/__tests__/agent-log-trigger.spec.tsx +++ b/web/app/components/workflow/run/agent-log/__tests__/agent-log-trigger.spec.tsx @@ -4,7 +4,9 @@ import userEvent from '@testing-library/user-event' import { BlockEnum } from '../../../types' import { AgentLogTrigger } from '../agent-log-trigger' -const createAgentLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({ +const createAgentLogItem = ( + overrides: Partial<AgentLogItemWithChildren> = {}, +): AgentLogItemWithChildren => ({ node_execution_id: 'exec-1', message_id: 'message-1', node_id: 'node-1', diff --git a/web/app/components/workflow/run/agent-log/__tests__/agent-result-panel.spec.tsx b/web/app/components/workflow/run/agent-log/__tests__/agent-result-panel.spec.tsx index 3fd64d5fdc8c09..9719c92f3df1ce 100644 --- a/web/app/components/workflow/run/agent-log/__tests__/agent-result-panel.spec.tsx +++ b/web/app/components/workflow/run/agent-log/__tests__/agent-result-panel.spec.tsx @@ -3,7 +3,9 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import AgentResultPanel from '../agent-result-panel' -const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({ +const createLogItem = ( + overrides: Partial<AgentLogItemWithChildren> = {}, +): AgentLogItemWithChildren => ({ message_id: 'message-1', label: 'Planner', children: [], diff --git a/web/app/components/workflow/run/agent-log/agent-log-item.tsx b/web/app/components/workflow/run/agent-log/agent-log-item.tsx index ebaa2bb9f7f53d..3a4ca4047680aa 100644 --- a/web/app/components/workflow/run/agent-log/agent-log-item.tsx +++ b/web/app/components/workflow/run/agent-log/agent-log-item.tsx @@ -1,14 +1,8 @@ import type { AgentLogItemWithChildren } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - RiArrowRightSLine, - RiListView, -} from '@remixicon/react' -import { - useMemo, - useState, -} from 'react' +import { RiArrowRightSLine, RiListView } from '@remixicon/react' +import { useMemo, useState } from 'react' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' import BlockIcon from '@/app/components/workflow/block-icon' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' @@ -20,25 +14,15 @@ type AgentLogItemProps = { item: AgentLogItemWithChildren onShowAgentOrToolLog: (detail: AgentLogItemWithChildren) => void } -const AgentLogItem = ({ - item, - onShowAgentOrToolLog, -}: AgentLogItemProps) => { - const { - label, - status, - children, - data, - metadata, - } = item +const AgentLogItem = ({ item, onShowAgentOrToolLog }: AgentLogItemProps) => { + const { label, status, children, data, metadata } = item const [expanded, setExpanded] = useState(false) const { getIconUrl } = useGetIcon() const toolIcon = useMemo(() => { const icon = metadata?.icon if (icon) { - if (icon.includes('http')) - return icon + if (icon.includes('http')) return icon return getIconUrl(icon) } @@ -47,8 +31,7 @@ const AgentLogItem = ({ }, [getIconUrl, metadata?.icon]) const mergeStatus = useMemo(() => { - if (status === 'start') - return 'running' + if (status === 'start') return 'running' return status }, [status]) @@ -56,17 +39,14 @@ const AgentLogItem = ({ return ( <div className="rounded-[10px] border-[0.5px] border-components-panel-border bg-background-default"> <div - className={cn( - 'flex cursor-pointer items-center py-2 pr-3 pl-1.5', - expanded && 'pb-1', - )} + className={cn('flex cursor-pointer items-center py-2 pr-3 pl-1.5', expanded && 'pb-1')} onClick={() => setExpanded(!expanded)} > - { - expanded - ? <RiArrowRightSLine className="size-4 shrink-0 rotate-90 text-text-quaternary" /> - : <RiArrowRightSLine className="size-4 shrink-0 text-text-quaternary" /> - } + {expanded ? ( + <RiArrowRightSLine className="size-4 shrink-0 rotate-90 text-text-quaternary" /> + ) : ( + <RiArrowRightSLine className="size-4 shrink-0 text-text-quaternary" /> + )} <BlockIcon className="mr-1.5 shrink-0" type={toolIcon ? BlockEnum.Tool : BlockEnum.Agent} @@ -78,50 +58,41 @@ const AgentLogItem = ({ > {label} </div> - { - !!metadata?.elapsed_time && ( - <div className="mr-2 shrink-0 system-xs-regular text-text-tertiary"> - {metadata?.elapsed_time?.toFixed(3)} - s - </div> - ) - } + {!!metadata?.elapsed_time && ( + <div className="mr-2 shrink-0 system-xs-regular text-text-tertiary"> + {metadata?.elapsed_time?.toFixed(3)}s + </div> + )} <NodeStatusIcon status={mergeStatus} /> </div> - { - expanded && ( - <div className="p-1 pt-0"> - { - !!children?.length && ( - <Button - className="mb-1 flex w-full items-center justify-between" - variant="tertiary" - onClick={() => onShowAgentOrToolLog(item)} - > - <div className="flex items-center"> - <RiListView className="mr-1 size-4 shrink-0 text-components-button-tertiary-text" /> - {`${children.length} Action Logs`} - </div> - <div className="flex"> - <RiArrowRightSLine className="size-4 shrink-0 text-components-button-tertiary-text" /> - </div> - </Button> - ) - } - { - data && ( - <CodeEditor - readOnly - title={<div>{'data'.toLocaleUpperCase()}</div>} - language={CodeLanguage.json} - value={data} - isJSONStringifyBeauty - /> - ) - } - </div> - ) - } + {expanded && ( + <div className="p-1 pt-0"> + {!!children?.length && ( + <Button + className="mb-1 flex w-full items-center justify-between" + variant="tertiary" + onClick={() => onShowAgentOrToolLog(item)} + > + <div className="flex items-center"> + <RiListView className="mr-1 size-4 shrink-0 text-components-button-tertiary-text" /> + {`${children.length} Action Logs`} + </div> + <div className="flex"> + <RiArrowRightSLine className="size-4 shrink-0 text-components-button-tertiary-text" /> + </div> + </Button> + )} + {data && ( + <CodeEditor + readOnly + title={<div>{'data'.toLocaleUpperCase()}</div>} + language={CodeLanguage.json} + value={data} + isJSONStringifyBeauty + /> + )} + </div> + )} </div> ) } diff --git a/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx b/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx index 52fda32f6e8eff..3ac9c34ecade17 100644 --- a/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx +++ b/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx @@ -13,25 +13,12 @@ type AgentLogNavMoreProps = { options: AgentLogItemWithChildren[] onShowAgentOrToolLog: (detail?: AgentLogItemWithChildren) => void } -const AgentLogNavMore = ({ - options, - onShowAgentOrToolLog, -}: AgentLogNavMoreProps) => { +const AgentLogNavMore = ({ options, onShowAgentOrToolLog }: AgentLogNavMoreProps) => { const [open, setOpen] = useState(false) return ( - <DropdownMenu - open={open} - onOpenChange={setOpen} - > - <DropdownMenuTrigger - render={( - <Button - className="size-6" - variant="ghost-accent" - /> - )} - > + <DropdownMenu open={open} onOpenChange={setOpen}> + <DropdownMenuTrigger render={<Button className="size-6" variant="ghost-accent" />}> <RiMoreLine className="size-4" /> </DropdownMenuTrigger> <DropdownMenuContent @@ -40,17 +27,15 @@ const AgentLogNavMore = ({ alignOffset={-54} popupClassName="w-[136px] p-1" > - { - options.map(option => ( - <DropdownMenuItem - key={option.message_id} - className="system-md-regular" - onClick={() => onShowAgentOrToolLog(option)} - > - {option.label} - </DropdownMenuItem> - )) - } + {options.map((option) => ( + <DropdownMenuItem + key={option.message_id} + className="system-md-regular" + onClick={() => onShowAgentOrToolLog(option)} + > + {option.label} + </DropdownMenuItem> + ))} </DropdownMenuContent> </DropdownMenu> ) diff --git a/web/app/components/workflow/run/agent-log/agent-log-nav.tsx b/web/app/components/workflow/run/agent-log/agent-log-nav.tsx index 8eae8f33eacc9e..99b6768e8c54d9 100644 --- a/web/app/components/workflow/run/agent-log/agent-log-nav.tsx +++ b/web/app/components/workflow/run/agent-log/agent-log-nav.tsx @@ -7,10 +7,7 @@ type AgentLogNavProps = { agentOrToolLogItemStack: AgentLogItemWithChildren[] onShowAgentOrToolLog: (detail?: AgentLogItemWithChildren) => void } -export function AgentLogNav({ - agentOrToolLogItemStack, - onShowAgentOrToolLog, -}: AgentLogNavProps) { +export function AgentLogNav({ agentOrToolLogItemStack, onShowAgentOrToolLog }: AgentLogNavProps) { const { t } = useTranslation() const agentOrToolLogItemStackLength = agentOrToolLogItemStack.length const first = agentOrToolLogItemStack[0] @@ -31,45 +28,34 @@ export function AgentLogNav({ AGENT </Button> <div className="mx-0.5 shrink-0 system-xs-regular text-divider-deep">/</div> - { - agentOrToolLogItemStackLength > 1 - ? ( - <Button - className="shrink-0 px-[5px]" - size="small" - variant="ghost-accent" - onClick={() => onShowAgentOrToolLog(first)} - > - {t($ => $['nodes.agent.strategy.label'], { ns: 'workflow' })} - </Button> - ) - : ( - <div className="flex items-center px-1.25 system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['nodes.agent.strategy.label'], { ns: 'workflow' })} - </div> - ) - } - { - !!mid.length && ( - <> - <div className="mx-0.5 shrink-0 system-xs-regular text-divider-deep">/</div> - <AgentLogNavMore - options={mid} - onShowAgentOrToolLog={onShowAgentOrToolLog} - /> - </> - ) - } - { - !!end && agentOrToolLogItemStackLength > 1 && ( - <> - <div className="mx-0.5 shrink-0 system-xs-regular text-divider-deep">/</div> - <div className="flex items-center px-[5px] system-xs-medium-uppercase text-text-tertiary"> - {end.label} - </div> - </> - ) - } + {agentOrToolLogItemStackLength > 1 ? ( + <Button + className="shrink-0 px-[5px]" + size="small" + variant="ghost-accent" + onClick={() => onShowAgentOrToolLog(first)} + > + {t(($) => $['nodes.agent.strategy.label'], { ns: 'workflow' })} + </Button> + ) : ( + <div className="flex items-center px-1.25 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['nodes.agent.strategy.label'], { ns: 'workflow' })} + </div> + )} + {!!mid.length && ( + <> + <div className="mx-0.5 shrink-0 system-xs-regular text-divider-deep">/</div> + <AgentLogNavMore options={mid} onShowAgentOrToolLog={onShowAgentOrToolLog} /> + </> + )} + {!!end && agentOrToolLogItemStackLength > 1 && ( + <> + <div className="mx-0.5 shrink-0 system-xs-regular text-divider-deep">/</div> + <div className="flex items-center px-[5px] system-xs-medium-uppercase text-text-tertiary"> + {end.label} + </div> + </> + )} </div> ) } diff --git a/web/app/components/workflow/run/agent-log/agent-log-trigger.tsx b/web/app/components/workflow/run/agent-log/agent-log-trigger.tsx index f03aaa2a2da499..c04517c4b80e11 100644 --- a/web/app/components/workflow/run/agent-log/agent-log-trigger.tsx +++ b/web/app/components/workflow/run/agent-log/agent-log-trigger.tsx @@ -1,17 +1,11 @@ -import type { - AgentLogItemWithChildren, - NodeTracing, -} from '@/types/workflow' +import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow' import { useTranslation } from 'react-i18next' type AgentLogTriggerProps = { nodeInfo: NodeTracing onShowAgentOrToolLog: (detail?: AgentLogItemWithChildren) => void } -export function AgentLogTrigger({ - nodeInfo, - onShowAgentOrToolLog, -}: AgentLogTriggerProps) { +export function AgentLogTrigger({ nodeInfo, onShowAgentOrToolLog }: AgentLogTriggerProps) { const { t } = useTranslation() const { agentLog, execution_metadata } = nodeInfo const agentStrategy = execution_metadata?.tool_info?.agent_strategy @@ -21,24 +15,23 @@ export function AgentLogTrigger({ type="button" className="w-full cursor-pointer rounded-[10px] bg-components-button-tertiary-bg text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={() => { - onShowAgentOrToolLog({ message_id: nodeInfo.id, children: agentLog || [] } as AgentLogItemWithChildren) + onShowAgentOrToolLog({ + message_id: nodeInfo.id, + children: agentLog || [], + } as AgentLogItemWithChildren) }} > <div className="flex items-center px-3 pt-2 system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['nodes.agent.strategy.label'], { ns: 'workflow' })} + {t(($) => $['nodes.agent.strategy.label'], { ns: 'workflow' })} </div> <div className="flex items-center pt-1 pr-2 pb-1.5 pl-3"> - {agentStrategy - ? ( - <div className="grow system-xs-medium text-text-secondary"> - {agentStrategy} - </div> - ) - : <div className="grow" />} - <div - className="flex shrink-0 cursor-pointer items-center px-px system-xs-regular-uppercase text-text-tertiary" - > - {t($ => $.detail, { ns: 'runLog' })} + {agentStrategy ? ( + <div className="grow system-xs-medium text-text-secondary">{agentStrategy}</div> + ) : ( + <div className="grow" /> + )} + <div className="flex shrink-0 cursor-pointer items-center px-px system-xs-regular-uppercase text-text-tertiary"> + {t(($) => $.detail, { ns: 'runLog' })} <span aria-hidden className="ml-0.5 i-ri-arrow-right-line size-3.5" /> </div> </div> diff --git a/web/app/components/workflow/run/agent-log/agent-result-panel.tsx b/web/app/components/workflow/run/agent-log/agent-result-panel.tsx index 614a05644c408d..f89462f5ba8dfe 100644 --- a/web/app/components/workflow/run/agent-log/agent-result-panel.tsx +++ b/web/app/components/workflow/run/agent-log/agent-result-panel.tsx @@ -24,33 +24,29 @@ const AgentResultPanel = ({ onShowAgentOrToolLog={onShowAgentOrToolLog} /> <div className="space-y-1 p-2"> - { - list!.map(item => ( - <AgentLogItem - key={item.message_id} - item={item} - onShowAgentOrToolLog={onShowAgentOrToolLog} - /> - )) - } + {list!.map((item) => ( + <AgentLogItem + key={item.message_id} + item={item} + onShowAgentOrToolLog={onShowAgentOrToolLog} + /> + ))} </div> - { - top!.hasCircle && ( - <div className="mt-1 flex items-center rounded-xl border border-components-panel-border bg-components-panel-bg-blur px-3 pr-2 shadow-md"> - <div - className="absolute inset-0 rounded-xl opacity-[0.4]" - style={{ - background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)', - }} - > - </div> - <span aria-hidden className="mr-1.5 i-ri-alert-fill size-4 text-text-warning-secondary" /> - <div className="system-xs-medium text-text-primary"> - {t($ => $.circularInvocationTip, { ns: 'runLog' })} - </div> + {top!.hasCircle && ( + <div className="mt-1 flex items-center rounded-xl border border-components-panel-border bg-components-panel-bg-blur px-3 pr-2 shadow-md"> + <div + className="absolute inset-0 rounded-xl opacity-[0.4]" + style={{ + background: + 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)', + }} + ></div> + <span aria-hidden className="mr-1.5 i-ri-alert-fill size-4 text-text-warning-secondary" /> + <div className="system-xs-medium text-text-primary"> + {t(($) => $.circularInvocationTip, { ns: 'runLog' })} </div> - ) - } + </div> + )} </div> ) } diff --git a/web/app/components/workflow/run/get-hovered-parallel-id.ts b/web/app/components/workflow/run/get-hovered-parallel-id.ts index cd369d5eb1e091..b0326911ba0d10 100644 --- a/web/app/components/workflow/run/get-hovered-parallel-id.ts +++ b/web/app/components/workflow/run/get-hovered-parallel-id.ts @@ -2,8 +2,7 @@ export const getHoveredParallelId = (relatedTarget: EventTarget | null) => { const element = relatedTarget as Element | null if (element && 'closest' in element) { const closestParallel = element.closest('[data-parallel-id]') - if (closestParallel) - return closestParallel.getAttribute('data-parallel-id') + if (closestParallel) return closestParallel.getAttribute('data-parallel-id') } return null diff --git a/web/app/components/workflow/run/hooks.ts b/web/app/components/workflow/run/hooks.ts index 593836f5b38487..f430b20bde9204 100644 --- a/web/app/components/workflow/run/hooks.ts +++ b/web/app/components/workflow/run/hooks.ts @@ -6,81 +6,99 @@ import type { NodeTracing, } from '@/types/workflow' import { useBoolean } from 'ahooks' -import { - useCallback, - useRef, - useState, -} from 'react' +import { useCallback, useRef, useState } from 'react' export const useLogs = () => { - const [showRetryDetail, { - setTrue: setShowRetryDetailTrue, - setFalse: setShowRetryDetailFalse, - }] = useBoolean(false) + const [showRetryDetail, { setTrue: setShowRetryDetailTrue, setFalse: setShowRetryDetailFalse }] = + useBoolean(false) const [retryResultList, setRetryResultList] = useState<NodeTracing[]>([]) - const handleShowRetryResultList = useCallback((detail: NodeTracing[]) => { - setShowRetryDetailTrue() - setRetryResultList(detail) - }, [setShowRetryDetailTrue, setRetryResultList]) + const handleShowRetryResultList = useCallback( + (detail: NodeTracing[]) => { + setShowRetryDetailTrue() + setRetryResultList(detail) + }, + [setShowRetryDetailTrue, setRetryResultList], + ) - const [showIteratingDetail, { - setTrue: setShowIteratingDetailTrue, - setFalse: setShowIteratingDetailFalse, - }] = useBoolean(false) + const [ + showIteratingDetail, + { setTrue: setShowIteratingDetailTrue, setFalse: setShowIteratingDetailFalse }, + ] = useBoolean(false) const [iterationResultList, setIterationResultList] = useState<NodeTracing[][]>([]) - const [iterationResultDurationMap, setIterationResultDurationMap] = useState<IterationDurationMap>({}) - const handleShowIterationResultList = useCallback((detail: NodeTracing[][], iterDurationMap: IterationDurationMap) => { - setShowIteratingDetailTrue() - setIterationResultList(detail) - setIterationResultDurationMap(iterDurationMap) - }, [setShowIteratingDetailTrue, setIterationResultList, setIterationResultDurationMap]) + const [iterationResultDurationMap, setIterationResultDurationMap] = + useState<IterationDurationMap>({}) + const handleShowIterationResultList = useCallback( + (detail: NodeTracing[][], iterDurationMap: IterationDurationMap) => { + setShowIteratingDetailTrue() + setIterationResultList(detail) + setIterationResultDurationMap(iterDurationMap) + }, + [setShowIteratingDetailTrue, setIterationResultList, setIterationResultDurationMap], + ) - const [showLoopingDetail, { - setTrue: setShowLoopingDetailTrue, - setFalse: setShowLoopingDetailFalse, - }] = useBoolean(false) + const [ + showLoopingDetail, + { setTrue: setShowLoopingDetailTrue, setFalse: setShowLoopingDetailFalse }, + ] = useBoolean(false) const [loopResultList, setLoopResultList] = useState<NodeTracing[][]>([]) const [loopResultDurationMap, setLoopResultDurationMap] = useState<LoopDurationMap>({}) const [loopResultVariableMap, setLoopResultVariableMap] = useState<Record<string, any>>({}) - const handleShowLoopResultList = useCallback((detail: NodeTracing[][], loopDurationMap: LoopDurationMap, loopVariableMap: LoopVariableMap) => { - setShowLoopingDetailTrue() - setLoopResultList(detail) - setLoopResultDurationMap(loopDurationMap) - setLoopResultVariableMap(loopVariableMap) - }, [setShowLoopingDetailTrue, setLoopResultList, setLoopResultDurationMap]) + const handleShowLoopResultList = useCallback( + ( + detail: NodeTracing[][], + loopDurationMap: LoopDurationMap, + loopVariableMap: LoopVariableMap, + ) => { + setShowLoopingDetailTrue() + setLoopResultList(detail) + setLoopResultDurationMap(loopDurationMap) + setLoopResultVariableMap(loopVariableMap) + }, + [setShowLoopingDetailTrue, setLoopResultList, setLoopResultDurationMap], + ) - const [agentOrToolLogItemStack, setAgentOrToolLogItemStack] = useState<AgentLogItemWithChildren[]>([]) + const [agentOrToolLogItemStack, setAgentOrToolLogItemStack] = useState< + AgentLogItemWithChildren[] + >([]) const agentOrToolLogItemStackRef = useRef(agentOrToolLogItemStack) - const [agentOrToolLogListMap, setAgentOrToolLogListMap] = useState<Record<string, AgentLogItemWithChildren[]>>({}) + const [agentOrToolLogListMap, setAgentOrToolLogListMap] = useState< + Record<string, AgentLogItemWithChildren[]> + >({}) const agentOrToolLogListMapRef = useRef(agentOrToolLogListMap) - const handleShowAgentOrToolLog = useCallback((detail?: AgentLogItemWithChildren) => { - if (!detail) { - setAgentOrToolLogItemStack([]) - agentOrToolLogItemStackRef.current = [] - return - } - const { message_id: id, children } = detail - let currentAgentOrToolLogItemStack = agentOrToolLogItemStackRef.current.slice() - const index = currentAgentOrToolLogItemStack.findIndex(logItem => logItem.message_id === id) + const handleShowAgentOrToolLog = useCallback( + (detail?: AgentLogItemWithChildren) => { + if (!detail) { + setAgentOrToolLogItemStack([]) + agentOrToolLogItemStackRef.current = [] + return + } + const { message_id: id, children } = detail + let currentAgentOrToolLogItemStack = agentOrToolLogItemStackRef.current.slice() + const index = currentAgentOrToolLogItemStack.findIndex((logItem) => logItem.message_id === id) - if (index > -1) - currentAgentOrToolLogItemStack = currentAgentOrToolLogItemStack.slice(0, index + 1) - else - currentAgentOrToolLogItemStack = [...currentAgentOrToolLogItemStack.slice(), detail] + if (index > -1) + currentAgentOrToolLogItemStack = currentAgentOrToolLogItemStack.slice(0, index + 1) + else currentAgentOrToolLogItemStack = [...currentAgentOrToolLogItemStack.slice(), detail] - setAgentOrToolLogItemStack(currentAgentOrToolLogItemStack) - agentOrToolLogItemStackRef.current = currentAgentOrToolLogItemStack + setAgentOrToolLogItemStack(currentAgentOrToolLogItemStack) + agentOrToolLogItemStackRef.current = currentAgentOrToolLogItemStack - if (children) { - setAgentOrToolLogListMap({ - ...agentOrToolLogListMapRef.current, - [id]: children, - }) - } - }, [setAgentOrToolLogItemStack, setAgentOrToolLogListMap]) + if (children) { + setAgentOrToolLogListMap({ + ...agentOrToolLogListMapRef.current, + [id]: children, + }) + } + }, + [setAgentOrToolLogItemStack, setAgentOrToolLogListMap], + ) return { - showSpecialResultPanel: showRetryDetail || showIteratingDetail || showLoopingDetail || !!agentOrToolLogItemStack.length, + showSpecialResultPanel: + showRetryDetail || + showIteratingDetail || + showLoopingDetail || + !!agentOrToolLogItemStack.length, showRetryDetail, setShowRetryDetailTrue, setShowRetryDetailFalse, diff --git a/web/app/components/workflow/run/index.tsx b/web/app/components/workflow/run/index.tsx index ccddf49ee607ee..76a47781472780 100644 --- a/web/app/components/workflow/run/index.tsx +++ b/web/app/components/workflow/run/index.tsx @@ -35,11 +35,10 @@ const RunPanel: FC<RunProps> = ({ const [loading, setLoading] = useState<boolean>(true) const [runDetail, setRunDetail] = useState<WorkflowRunDetailResponse>() const [list, setList] = useState<NodeTracing[]>([]) - const isListening = useStore(s => s.isListening) + const isListening = useStore((s) => s.isListening) const executor = useMemo(() => { - if (runDetail?.created_by_role === 'account') - return runDetail.created_by_account?.name || '' + if (runDetail?.created_by_role === 'account') return runDetail.created_by_account?.name || '' if (runDetail?.created_by_role === 'end_user') return runDetail.created_by_end_user?.session_id || '' return 'N/A' @@ -49,10 +48,8 @@ const RunPanel: FC<RunProps> = ({ try { const res = await fetchRunDetail(runDetailUrl) setRunDetail(res) - if (getResultCallback) - getResultCallback(res) - } - catch (err) { + if (getResultCallback) getResultCallback(res) + } catch (err) { toast.error(`${err}`) } }, [getResultCallback, runDetailUrl]) @@ -63,8 +60,7 @@ const RunPanel: FC<RunProps> = ({ url: tracingListUrl, }) setList(nodeList) - } - catch (err) { + } catch (err) { toast.error(`${err}`) } }, [tracingListUrl]) @@ -79,30 +75,25 @@ const RunPanel: FC<RunProps> = ({ const switchTab = async (tab: string) => { setCurrentTab(tab) if (tab === 'RESULT') { - if (runDetailUrl) - await getResult() + if (runDetailUrl) await getResult() } - if (tracingListUrl) - await getTracingList() + if (tracingListUrl) await getTracingList() } useEffect(() => { - if (isListening) - setCurrentTab('DETAIL') + if (isListening) setCurrentTab('DETAIL') }, [isListening]) useEffect(() => { // fetch data - if (runDetailUrl && tracingListUrl) - getData() + if (runDetailUrl && tracingListUrl) getData() }, [runDetailUrl, tracingListUrl]) const [height, setHeight] = useState(0) const ref = useRef<HTMLDivElement>(null) const adjustResultHeight = () => { - if (ref.current) - setHeight(ref.current?.clientHeight - 16 - 16 - 2 - 1) + if (ref.current) setHeight(ref.current?.clientHeight - 16 - 16 - 2 - 1) } useEffect(() => { @@ -117,45 +108,47 @@ const RunPanel: FC<RunProps> = ({ <div className={cn( 'mr-6 cursor-pointer border-b-2 border-transparent py-3 system-sm-semibold-uppercase text-text-tertiary', - currentTab === 'RESULT' && 'border-util-colors-blue-brand-blue-brand-600! text-text-primary', + currentTab === 'RESULT' && + 'border-util-colors-blue-brand-blue-brand-600! text-text-primary', )} onClick={() => switchTab('RESULT')} > - {t($ => $.result, { ns: 'runLog' })} + {t(($) => $.result, { ns: 'runLog' })} </div> )} <div className={cn( 'mr-6 cursor-pointer border-b-2 border-transparent py-3 system-sm-semibold-uppercase text-text-tertiary', - currentTab === 'DETAIL' && 'border-util-colors-blue-brand-blue-brand-600! text-text-primary', + currentTab === 'DETAIL' && + 'border-util-colors-blue-brand-blue-brand-600! text-text-primary', )} onClick={() => switchTab('DETAIL')} > - {t($ => $.detail, { ns: 'runLog' })} + {t(($) => $.detail, { ns: 'runLog' })} </div> <div className={cn( 'mr-6 cursor-pointer border-b-2 border-transparent py-3 system-sm-semibold-uppercase text-text-tertiary', - currentTab === 'TRACING' && 'border-util-colors-blue-brand-blue-brand-600! text-text-primary', + currentTab === 'TRACING' && + 'border-util-colors-blue-brand-blue-brand-600! text-text-primary', )} onClick={() => switchTab('TRACING')} > - {t($ => $.tracing, { ns: 'runLog' })} + {t(($) => $.tracing, { ns: 'runLog' })} </div> </div> {/* panel detail */} - <div ref={ref} className={cn('relative h-0 grow overflow-y-auto rounded-b-xl bg-components-panel-bg')}> + <div + ref={ref} + className={cn('relative h-0 grow overflow-y-auto rounded-b-xl bg-components-panel-bg')} + > {loading && ( <div className="flex h-full items-center justify-center bg-components-panel-bg"> <Loading /> </div> )} {!loading && currentTab === 'RESULT' && runDetail && ( - <OutputPanel - outputs={runDetail.outputs} - error={runDetail.error} - height={height} - /> + <OutputPanel outputs={runDetail.outputs} error={runDetail.error} height={height} /> )} {!loading && currentTab === 'DETAIL' && runDetail && ( <ResultPanel @@ -178,16 +171,10 @@ const RunPanel: FC<RunProps> = ({ /> )} {!loading && currentTab === 'DETAIL' && !runDetail && isListening && ( - <StatusPanel - status={WorkflowRunningStatus.Running} - isListening={true} - /> + <StatusPanel status={WorkflowRunningStatus.Running} isListening={true} /> )} {!loading && currentTab === 'TRACING' && ( - <TracingPanel - className="bg-background-section-burn" - list={list} - /> + <TracingPanel className="bg-background-section-burn" list={list} /> )} </div> </div> diff --git a/web/app/components/workflow/run/iteration-log/__tests__/iteration-log-trigger.spec.tsx b/web/app/components/workflow/run/iteration-log/__tests__/iteration-log-trigger.spec.tsx index e497b1988eda61..4ab80c73652fbd 100644 --- a/web/app/components/workflow/run/iteration-log/__tests__/iteration-log-trigger.spec.tsx +++ b/web/app/components/workflow/run/iteration-log/__tests__/iteration-log-trigger.spec.tsx @@ -36,7 +36,9 @@ const createNodeTracing = (overrides: Partial<NodeTracing> = {}): NodeTracing => ...overrides, }) -const createExecutionMetadata = (overrides: Partial<NonNullable<NodeTracing['execution_metadata']>> = {}) => ({ +const createExecutionMetadata = ( + overrides: Partial<NonNullable<NodeTracing['execution_metadata']>> = {}, +) => ({ total_tokens: 0, total_price: 0, currency: 'USD', @@ -94,11 +96,7 @@ describe('IterationLogTrigger', () => { await user.click(screen.getByRole('button')) expect(onShowIterationResultList).toHaveBeenCalledWith( - [ - [allExecutions[0]], - [allExecutions[1]], - missingFailedIteration, - ], + [[allExecutions[0]], [allExecutions[1]], missingFailedIteration], iterationDurationMap, ) }) @@ -123,7 +121,9 @@ describe('IterationLogTrigger', () => { />, ) - expect(screen.getByRole('button', { name: /workflow\.nodes\.iteration\.iteration/ })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflow\.nodes\.iteration\.iteration/ }), + ).toBeInTheDocument() await user.click(screen.getByRole('button')) @@ -179,11 +179,16 @@ describe('IterationLogTrigger', () => { />, ) - expect(screen.getByRole('button', { name: /workflow\.nodes\.iteration\.error/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflow\.nodes\.iteration\.error/i }), + ).toBeInTheDocument() await user.click(screen.getByRole('button')) - expect(onShowIterationResultList).toHaveBeenCalledWith([[allExecutions[0]]], iterationDurationMap) + expect(onShowIterationResultList).toHaveBeenCalledWith( + [[allExecutions[0]]], + iterationDurationMap, + ) }) }) }) diff --git a/web/app/components/workflow/run/iteration-log/__tests__/iteration-result-panel.spec.tsx b/web/app/components/workflow/run/iteration-log/__tests__/iteration-result-panel.spec.tsx index a77356a728f467..03217b416be6a7 100644 --- a/web/app/components/workflow/run/iteration-log/__tests__/iteration-result-panel.spec.tsx +++ b/web/app/components/workflow/run/iteration-log/__tests__/iteration-result-panel.spec.tsx @@ -63,11 +63,12 @@ describe('IterationResultPanel', () => { expect(screen.getByText('1.20s')).toBeInTheDocument() expect(container.querySelectorAll('.transition-all.duration-200.opacity-100')).toHaveLength(0) - await user.click(screen.getByText((_, node) => node?.textContent === 'workflow.singleRun.iteration 1')) + await user.click( + screen.getByText((_, node) => node?.textContent === 'workflow.singleRun.iteration 1'), + ) const expandArrow = container.querySelector('.transition-transform.duration-200') - if (!expandArrow) - throw new Error('Expected iteration expand arrow to be rendered') + if (!expandArrow) throw new Error('Expected iteration expand arrow to be rendered') expect(expandArrow).toHaveClass('rotate-90') expect(container.querySelectorAll('.transition-all.duration-200.opacity-100')).toHaveLength(1) diff --git a/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx b/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx index fbdf05d05f768a..e254f8f64c35fa 100644 --- a/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx +++ b/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx @@ -1,7 +1,4 @@ -import type { - IterationDurationMap, - NodeTracing, -} from '@/types/workflow' +import type { IterationDurationMap, NodeTracing } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { RiArrowRightSLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' @@ -11,7 +8,10 @@ import { NodeRunningStatus } from '@/app/components/workflow/types' type IterationLogTriggerProps = { nodeInfo: NodeTracing allExecutions?: NodeTracing[] - onShowIterationResultList: (iterationResultList: NodeTracing[][], iterationResultDurationMap: IterationDurationMap) => void + onShowIterationResultList: ( + iterationResultList: NodeTracing[][], + iterationResultDurationMap: IterationDurationMap, + ) => void } const getIterationDurationMap = (nodeInfo: NodeTracing) => { @@ -20,10 +20,8 @@ const getIterationDurationMap = (nodeInfo: NodeTracing) => { const getDisplayIterationCount = (nodeInfo: NodeTracing) => { const iterationDurationMap = nodeInfo.execution_metadata?.iteration_duration_map - if (iterationDurationMap) - return Object.keys(iterationDurationMap).length - if (nodeInfo.details?.length) - return nodeInfo.details.length + if (iterationDurationMap) return Object.keys(iterationDurationMap).length + if (nodeInfo.details?.length) return nodeInfo.details.length return nodeInfo.metadata?.iterator_length ?? 0 } @@ -32,14 +30,12 @@ const getFailedIterationIndices = ( nodeInfo: NodeTracing, allExecutions?: NodeTracing[], ) => { - if (!details?.length) - return new Set<number>() + if (!details?.length) return new Set<number>() const failedIterationIndices = new Set<number>() details.forEach((iteration, index) => { - if (!iteration.some(item => item.status === NodeRunningStatus.Failed)) - return + if (!iteration.some((item) => item.status === NodeRunningStatus.Failed)) return const iterationIndex = iteration[0]?.execution_metadata?.iteration_index ?? index failedIterationIndices.add(iterationIndex) @@ -50,9 +46,9 @@ const getFailedIterationIndices = ( allExecutions.forEach((execution) => { if ( - execution.execution_metadata?.iteration_id === nodeInfo.node_id - && execution.status === NodeRunningStatus.Failed - && execution.execution_metadata?.iteration_index !== undefined + execution.execution_metadata?.iteration_id === nodeInfo.node_id && + execution.status === NodeRunningStatus.Failed && + execution.execution_metadata?.iteration_index !== undefined ) { failedIterationIndices.add(execution.execution_metadata.iteration_index) } @@ -69,23 +65,21 @@ const IterationLogTrigger = ({ const { t } = useTranslation() const getNodesForInstance = (key: string): NodeTracing[] => { - if (!allExecutions) - return [] + if (!allExecutions) return [] - const parallelNodes = allExecutions.filter(exec => - exec.execution_metadata?.parallel_mode_run_id === key, + const parallelNodes = allExecutions.filter( + (exec) => exec.execution_metadata?.parallel_mode_run_id === key, ) - if (parallelNodes.length > 0) - return parallelNodes + if (parallelNodes.length > 0) return parallelNodes const serialIndex = Number.parseInt(key, 10) if (!isNaN(serialIndex)) { - const serialNodes = allExecutions.filter(exec => - exec.execution_metadata?.iteration_id === nodeInfo.node_id - && exec.execution_metadata?.iteration_index === serialIndex, + const serialNodes = allExecutions.filter( + (exec) => + exec.execution_metadata?.iteration_id === nodeInfo.node_id && + exec.execution_metadata?.iteration_index === serialIndex, ) - if (serialNodes.length > 0) - return serialNodes + if (serialNodes.length > 0) return serialNodes } return [] @@ -94,15 +88,13 @@ const IterationLogTrigger = ({ const getStructuredIterationList = () => { const iterationNodeMeta = nodeInfo.execution_metadata - if (!iterationNodeMeta?.iteration_duration_map) - return nodeInfo.details || [] + if (!iterationNodeMeta?.iteration_duration_map) return nodeInfo.details || [] const structuredList = Object.keys(iterationNodeMeta.iteration_duration_map) .map(getNodesForInstance) - .filter(branchNodes => branchNodes.length > 0) + .filter((branchNodes) => branchNodes.length > 0) - if (!allExecutions || !nodeInfo.details?.length) - return structuredList + if (!allExecutions || !nodeInfo.details?.length) return structuredList const existingIterationIndices = new Set<number>() structuredList.forEach((iteration) => { @@ -114,8 +106,8 @@ const IterationLogTrigger = ({ nodeInfo.details.forEach((iteration, index) => { if ( - !existingIterationIndices.has(index) - && iteration.some(node => node.status === NodeRunningStatus.Failed) + !existingIterationIndices.has(index) && + iteration.some((node) => node.status === NodeRunningStatus.Failed) ) { structuredList.push(iteration) } @@ -146,11 +138,11 @@ const IterationLogTrigger = ({ {/* eslint-disable-next-line hyoban/prefer-tailwind-icons */} <Iteration className="size-4 shrink-0 text-components-button-tertiary-text" /> <div className="flex-1 text-left system-sm-medium text-components-button-tertiary-text"> - {t($ => $['nodes.iteration.iteration'], { ns: 'workflow', count: displayIterationCount })} + {t(($) => $['nodes.iteration.iteration'], { ns: 'workflow', count: displayIterationCount })} {errorCount > 0 && ( <> - {t($ => $['nodes.iteration.comma'], { ns: 'workflow' })} - {t($ => $['nodes.iteration.error'], { ns: 'workflow', count: errorCount })} + {t(($) => $['nodes.iteration.comma'], { ns: 'workflow' })} + {t(($) => $['nodes.iteration.error'], { ns: 'workflow', count: errorCount })} </> )} </div> diff --git a/web/app/components/workflow/run/iteration-log/iteration-result-panel.tsx b/web/app/components/workflow/run/iteration-log/iteration-result-panel.tsx index 46ab732bf22fae..a35e9a1e183286 100644 --- a/web/app/components/workflow/run/iteration-log/iteration-result-panel.tsx +++ b/web/app/components/workflow/run/iteration-log/iteration-result-panel.tsx @@ -23,37 +23,38 @@ type Props = { readonly iterDurationMap?: IterationDurationMap } -const IterationResultPanel: FC<Props> = ({ - list, - onBack, - iterDurationMap, -}) => { +const IterationResultPanel: FC<Props> = ({ list, onBack, iterDurationMap }) => { const { t } = useTranslation() const [expandedIterations, setExpandedIterations] = useState<Record<number, boolean>>({}) const toggleIteration = useCallback((index: number) => { - setExpandedIterations(prev => ({ + setExpandedIterations((prev) => ({ ...prev, [index]: !prev[index], })) }, []) - const countIterDuration = (iteration: NodeTracing[], iterDurationMap: IterationDurationMap): string => { + const countIterDuration = ( + iteration: NodeTracing[], + iterDurationMap: IterationDurationMap, + ): string => { const IterRunIndex = iteration[0]?.execution_metadata?.iteration_index as number const iterRunId = iteration[0]?.execution_metadata?.parallel_mode_run_id const iterItem = iterDurationMap[iterRunId || IterRunIndex] const duration = iterItem - return `${(duration && duration > 0.01) ? duration.toFixed(2) : 0.01}s` + return `${duration && duration > 0.01 ? duration.toFixed(2) : 0.01}s` } - const iterationStatusShow = (index: number, iteration: NodeTracing[], iterDurationMap?: IterationDurationMap) => { - const hasFailed = iteration.some(item => item.status === NodeRunningStatus.Failed) - const isRunning = iteration.some(item => item.status === NodeRunningStatus.Running) + const iterationStatusShow = ( + index: number, + iteration: NodeTracing[], + iterDurationMap?: IterationDurationMap, + ) => { + const hasFailed = iteration.some((item) => item.status === NodeRunningStatus.Failed) + const isRunning = iteration.some((item) => item.status === NodeRunningStatus.Running) const hasDurationMap = iterDurationMap && Object.keys(iterDurationMap).length !== 0 - if (hasFailed) - return <RiErrorWarningLine className="size-4 text-text-destructive" /> + if (hasFailed) return <RiErrorWarningLine className="size-4 text-text-destructive" /> - if (isRunning) - return <RiLoader2Line className="size-3.5 animate-spin text-primary-600" /> + if (isRunning) return <RiLoader2Line className="size-3.5 animate-spin text-primary-600" /> return ( <> @@ -83,12 +84,17 @@ const IterationResultPanel: FC<Props> = ({ }} > <RiArrowLeftLine className="mr-1 size-4" /> - <div className="system-sm-medium">{t($ => $[`${i18nPrefix}.back`], { ns: 'workflow' })}</div> + <div className="system-sm-medium"> + {t(($) => $[`${i18nPrefix}.back`], { ns: 'workflow' })} + </div> </div> {/* List */} <div className="bg-components-panel-bg p-2"> {list.map((iteration, index) => ( - <div key={index} className={cn('mb-1 overflow-hidden rounded-xl border-none bg-background-section-burn')}> + <div + key={index} + className={cn('mb-1 overflow-hidden rounded-xl border-none bg-background-section-burn')} + > <div className={cn( 'flex w-full cursor-pointer items-center justify-between px-3', @@ -102,30 +108,19 @@ const IterationResultPanel: FC<Props> = ({ <Iteration className="size-3 text-text-primary-on-surface" /> </div> <span className="grow system-sm-semibold-uppercase text-text-primary"> - {t($ => $[`${i18nPrefix}.iteration`], { ns: 'workflow' })} - {' '} - {index + 1} + {t(($) => $[`${i18nPrefix}.iteration`], { ns: 'workflow' })} {index + 1} </span> {iterationStatusShow(index, iteration, iterDurationMap)} </div> </div> - {expandedIterations[index] && ( - <div - className="h-px grow bg-divider-subtle" - > - </div> - )} - <div className={cn( - 'transition-all duration-200', - expandedIterations[index] - ? 'opacity-100' - : 'max-h-0 overflow-hidden opacity-0', - )} + {expandedIterations[index] && <div className="h-px grow bg-divider-subtle"></div>} + <div + className={cn( + 'transition-all duration-200', + expandedIterations[index] ? 'opacity-100' : 'max-h-0 overflow-hidden opacity-0', + )} > - <TracingPanel - list={iteration} - className="bg-background-section-burn" - /> + <TracingPanel list={iteration} className="bg-background-section-burn" /> </div> </div> ))} diff --git a/web/app/components/workflow/run/loop-log/__tests__/loop-log-trigger.spec.tsx b/web/app/components/workflow/run/loop-log/__tests__/loop-log-trigger.spec.tsx index 3be1d7f8e25dea..48181aaa1c07ad 100644 --- a/web/app/components/workflow/run/loop-log/__tests__/loop-log-trigger.spec.tsx +++ b/web/app/components/workflow/run/loop-log/__tests__/loop-log-trigger.spec.tsx @@ -82,7 +82,11 @@ describe('LoopLogTrigger', () => { await user.click(screen.getByRole('button')) - expect(onShowLoopResultList).toHaveBeenCalledWith(detailList, loopDurationMap, loopVariableMap) + expect(onShowLoopResultList).toHaveBeenCalledWith( + detailList, + loopDurationMap, + loopVariableMap, + ) }) it('should reconstruct loop detail groups from execution metadata when details are absent', async () => { @@ -134,13 +138,11 @@ describe('LoopLogTrigger', () => { await user.click(screen.getByRole('button')) expect(onShowLoopResultList).toHaveBeenCalledTimes(1) - const [structuredList, durations, variableMap] = (onShowLoopResultList.mock.calls[0] ?? []) as [any, any, any] + const [structuredList, durations, variableMap] = (onShowLoopResultList.mock.calls[0] ?? + []) as [any, any, any] expect(structuredList).toHaveLength(2) expect(structuredList).toEqual( - expect.arrayContaining([ - [allExecutions[0]], - [allExecutions[1]], - ]), + expect.arrayContaining([[allExecutions[0]], [allExecutions[1]]]), ) expect(durations).toEqual(loopDurationMap) expect(variableMap).toEqual({}) diff --git a/web/app/components/workflow/run/loop-log/__tests__/loop-result-panel.spec.tsx b/web/app/components/workflow/run/loop-log/__tests__/loop-result-panel.spec.tsx index 93beb237e3d39c..6d10d9c69b694a 100644 --- a/web/app/components/workflow/run/loop-log/__tests__/loop-result-panel.spec.tsx +++ b/web/app/components/workflow/run/loop-log/__tests__/loop-result-panel.spec.tsx @@ -9,7 +9,7 @@ const mockTracingPanel = vi.hoisted(() => vi.fn()) vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ __esModule: true, - default: (props: { title: ReactNode, value: unknown }) => { + default: (props: { title: ReactNode; value: unknown }) => { mockCodeEditor(props) return ( <section data-testid="code-editor"> @@ -22,7 +22,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', ( vi.mock('@/app/components/workflow/run/tracing-panel', () => ({ __esModule: true, - default: (props: { list: NodeTracing[], className?: string }) => { + default: (props: { list: NodeTracing[]; className?: string }) => { mockTracingPanel(props) return <div data-testid="tracing-panel">{props.list.length}</div> }, @@ -87,8 +87,7 @@ describe('LoopResultPanel', () => { expect(screen.getByText('1.20s')).toBeInTheDocument() const expandArrow = container.querySelector('.transition-transform.duration-200') - if (!expandArrow) - throw new Error('Expected loop expand arrow to be rendered') + if (!expandArrow) throw new Error('Expected loop expand arrow to be rendered') expect(expandArrow).not.toHaveClass('rotate-90') fireEvent.click(screen.getByText('workflow.singleRun.loop 1')) @@ -96,12 +95,16 @@ describe('LoopResultPanel', () => { expect(expandArrow).toHaveClass('rotate-90') expect(screen.getByTestId('code-editor')).toHaveTextContent('{"item":"alpha"}') expect(screen.getByTestId('tracing-panel')).toHaveTextContent('1') - expect(mockCodeEditor).toHaveBeenCalledWith(expect.objectContaining({ - value: loopVariableMap[0], - })) - expect(mockTracingPanel).toHaveBeenCalledWith(expect.objectContaining({ - list: [expect.objectContaining({ title: 'Loop Step 1' })], - })) + expect(mockCodeEditor).toHaveBeenCalledWith( + expect.objectContaining({ + value: loopVariableMap[0], + }), + ) + expect(mockTracingPanel).toHaveBeenCalledWith( + expect.objectContaining({ + list: [expect.objectContaining({ title: 'Loop Step 1' })], + }), + ) fireEvent.click(screen.getByText('workflow.singleRun.back')) @@ -116,16 +119,18 @@ describe('LoopResultPanel', () => { render( <LoopResultPanel - list={[[ - createNodeTracing('loop-2-step-1', { - execution_metadata: { - total_tokens: 0, - total_price: 0, - currency: 'USD', - loop_index: 2, - }, - }), - ]]} + list={[ + [ + createNodeTracing('loop-2-step-1', { + execution_metadata: { + total_tokens: 0, + total_price: 0, + currency: 'USD', + loop_index: 2, + }, + }), + ], + ]} onBack={vi.fn()} loopVariableMap={loopVariableMap} />, @@ -134,9 +139,11 @@ describe('LoopResultPanel', () => { fireEvent.click(screen.getByText('workflow.singleRun.loop 1')) expect(screen.getByTestId('code-editor')).toHaveTextContent('{"item":"alpha"}') - expect(mockCodeEditor).toHaveBeenCalledWith(expect.objectContaining({ - value: loopVariableMap[2], - })) + expect(mockCodeEditor).toHaveBeenCalledWith( + expect.objectContaining({ + value: loopVariableMap[2], + }), + ) }) it('should read loop variables by parallel run id when available', () => { @@ -146,17 +153,19 @@ describe('LoopResultPanel', () => { render( <LoopResultPanel - list={[[ - createNodeTracing('parallel-step-1', { - execution_metadata: { - total_tokens: 0, - total_price: 0, - currency: 'USD', - loop_index: 0, - parallel_mode_run_id: 'parallel-1', - }, - }), - ]]} + list={[ + [ + createNodeTracing('parallel-step-1', { + execution_metadata: { + total_tokens: 0, + total_price: 0, + currency: 'USD', + loop_index: 0, + parallel_mode_run_id: 'parallel-1', + }, + }), + ], + ]} onBack={vi.fn()} loopVariableMap={loopVariableMap} />, @@ -165,9 +174,11 @@ describe('LoopResultPanel', () => { fireEvent.click(screen.getByText('workflow.singleRun.loop 1')) expect(screen.getByTestId('code-editor')).toHaveTextContent('{"item":"beta"}') - expect(mockCodeEditor).toHaveBeenCalledWith(expect.objectContaining({ - value: loopVariableMap['parallel-1'], - })) + expect(mockCodeEditor).toHaveBeenCalledWith( + expect.objectContaining({ + value: loopVariableMap['parallel-1'], + }), + ) }) }) }) diff --git a/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx b/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx index e7fb3d6382a86f..aeb0ec937ffd1c 100644 --- a/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx +++ b/web/app/components/workflow/run/loop-log/loop-log-trigger.tsx @@ -1,8 +1,4 @@ -import type { - LoopDurationMap, - LoopVariableMap, - NodeTracing, -} from '@/types/workflow' +import type { LoopDurationMap, LoopVariableMap, NodeTracing } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { RiArrowRightSLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' @@ -11,33 +7,31 @@ import { Loop } from '@/app/components/base/icons/src/vender/workflow' type LoopLogTriggerProps = { nodeInfo: NodeTracing allExecutions?: NodeTracing[] - onShowLoopResultList: (loopResultList: NodeTracing[][], loopResultDurationMap: LoopDurationMap, loopVariableMap: LoopVariableMap) => void + onShowLoopResultList: ( + loopResultList: NodeTracing[][], + loopResultDurationMap: LoopDurationMap, + loopVariableMap: LoopVariableMap, + ) => void } -const LoopLogTrigger = ({ - nodeInfo, - allExecutions, - onShowLoopResultList, -}: LoopLogTriggerProps) => { +const LoopLogTrigger = ({ nodeInfo, allExecutions, onShowLoopResultList }: LoopLogTriggerProps) => { const { t } = useTranslation() const filterNodesForInstance = (key: string): NodeTracing[] => { - if (!allExecutions) - return [] + if (!allExecutions) return [] - const parallelNodes = allExecutions.filter(exec => - exec.execution_metadata?.parallel_mode_run_id === key, + const parallelNodes = allExecutions.filter( + (exec) => exec.execution_metadata?.parallel_mode_run_id === key, ) - if (parallelNodes.length > 0) - return parallelNodes + if (parallelNodes.length > 0) return parallelNodes const serialIndex = Number.parseInt(key, 10) if (!isNaN(serialIndex)) { - const serialNodes = allExecutions.filter(exec => - exec.execution_metadata?.loop_id === nodeInfo.node_id - && exec.execution_metadata?.loop_index === serialIndex, + const serialNodes = allExecutions.filter( + (exec) => + exec.execution_metadata?.loop_id === nodeInfo.node_id && + exec.execution_metadata?.loop_index === serialIndex, ) - if (serialNodes.length > 0) - return serialNodes + if (serialNodes.length > 0) return serialNodes } return [] @@ -54,36 +48,26 @@ const LoopLogTrigger = ({ let structuredList: NodeTracing[][] = [] if (nodeInfo.details?.length) { structuredList = nodeInfo.details - } - else if (loopNodeMeta?.loop_duration_map) { + } else if (loopNodeMeta?.loop_duration_map) { const instanceKeys = Object.keys(loopNodeMeta.loop_duration_map) structuredList = instanceKeys - .map(key => filterNodesForInstance(key)) - .filter(branchNodes => branchNodes.length > 0) + .map((key) => filterNodesForInstance(key)) + .filter((branchNodes) => branchNodes.length > 0) } - onShowLoopResultList( - structuredList, - loopDurMap, - loopVarMap, - ) + onShowLoopResultList(structuredList, loopDurMap, loopVarMap) } let displayLoopCount = 0 const loopMap = nodeInfo.execution_metadata?.loop_duration_map - if (loopMap) - displayLoopCount = Object.keys(loopMap).length - else if (nodeInfo.details?.length) - displayLoopCount = nodeInfo.details.length - else if (nodeInfo.metadata?.loop_length) - displayLoopCount = nodeInfo.metadata.loop_length + if (loopMap) displayLoopCount = Object.keys(loopMap).length + else if (nodeInfo.details?.length) displayLoopCount = nodeInfo.details.length + else if (nodeInfo.metadata?.loop_length) displayLoopCount = nodeInfo.metadata.loop_length const getErrorCount = (details: NodeTracing[][] | undefined) => { - if (!details || details.length === 0) - return 0 + if (!details || details.length === 0) return 0 return details.reduce((acc, loop) => { - if (loop.some(item => item.status === 'failed')) - acc++ + if (loop.some((item) => item.status === 'failed')) acc++ return acc }, 0) } @@ -96,11 +80,11 @@ const LoopLogTrigger = ({ > <Loop className="size-4 shrink-0 text-components-button-tertiary-text" /> <div className="flex-1 text-left system-sm-medium text-components-button-tertiary-text"> - {t($ => $['nodes.loop.loop'], { ns: 'workflow', count: displayLoopCount })} + {t(($) => $['nodes.loop.loop'], { ns: 'workflow', count: displayLoopCount })} {errorCount > 0 && ( <> - {t($ => $['nodes.loop.comma'], { ns: 'workflow' })} - {t($ => $['nodes.loop.error'], { ns: 'workflow', count: errorCount })} + {t(($) => $['nodes.loop.comma'], { ns: 'workflow' })} + {t(($) => $['nodes.loop.error'], { ns: 'workflow', count: errorCount })} </> )} </div> diff --git a/web/app/components/workflow/run/loop-log/loop-result-panel.tsx b/web/app/components/workflow/run/loop-log/loop-result-panel.tsx index 00bb8a72a6160b..e8ed4fd599f694 100644 --- a/web/app/components/workflow/run/loop-log/loop-result-panel.tsx +++ b/web/app/components/workflow/run/loop-log/loop-result-panel.tsx @@ -25,8 +25,7 @@ const getLoopRunKey = (loop: NodeTracing[], fallbackIndex: number) => { if (executionMetadata?.parallel_mode_run_id !== undefined) return executionMetadata.parallel_mode_run_id - if (executionMetadata?.loop_index !== undefined) - return String(executionMetadata.loop_index) + if (executionMetadata?.loop_index !== undefined) return String(executionMetadata.loop_index) return String(fallbackIndex) } @@ -38,38 +37,39 @@ type Props = { readonly loopVariableMap?: LoopVariableMap } -const LoopResultPanel: FC<Props> = ({ - list, - onBack, - loopDurationMap, - loopVariableMap, -}) => { +const LoopResultPanel: FC<Props> = ({ list, onBack, loopDurationMap, loopVariableMap }) => { const { t } = useTranslation() const [expandedLoops, setExpandedLoops] = useState<Record<number, boolean>>({}) const toggleLoop = useCallback((index: number) => { - setExpandedLoops(prev => ({ + setExpandedLoops((prev) => ({ ...prev, [index]: !prev[index], })) }, []) - const countLoopDuration = (loop: NodeTracing[], index: number, loopDurationMap: LoopDurationMap): string => { + const countLoopDuration = ( + loop: NodeTracing[], + index: number, + loopDurationMap: LoopDurationMap, + ): string => { const loopItem = loopDurationMap[getLoopRunKey(loop, index)] const duration = loopItem - return `${(duration && duration > 0.01) ? duration.toFixed(2) : 0.01}s` + return `${duration && duration > 0.01 ? duration.toFixed(2) : 0.01}s` } - const loopStatusShow = (index: number, loop: NodeTracing[], loopDurationMap?: LoopDurationMap) => { - const hasFailed = loop.some(item => item.status === NodeRunningStatus.Failed) - const isRunning = loop.some(item => item.status === NodeRunningStatus.Running) + const loopStatusShow = ( + index: number, + loop: NodeTracing[], + loopDurationMap?: LoopDurationMap, + ) => { + const hasFailed = loop.some((item) => item.status === NodeRunningStatus.Failed) + const isRunning = loop.some((item) => item.status === NodeRunningStatus.Running) const hasDurationMap = loopDurationMap && Object.keys(loopDurationMap).length !== 0 - if (hasFailed) - return <RiErrorWarningLine className="size-4 text-text-destructive" /> + if (hasFailed) return <RiErrorWarningLine className="size-4 text-text-destructive" /> - if (isRunning) - return <RiLoader2Line className="size-3.5 animate-spin text-primary-600" /> + if (isRunning) return <RiLoader2Line className="size-3.5 animate-spin text-primary-600" /> return ( <> @@ -99,12 +99,17 @@ const LoopResultPanel: FC<Props> = ({ }} > <RiArrowLeftLine className="mr-1 size-4" /> - <div className="system-sm-medium">{t($ => $[`${i18nPrefix}.back`], { ns: 'workflow' })}</div> + <div className="system-sm-medium"> + {t(($) => $[`${i18nPrefix}.back`], { ns: 'workflow' })} + </div> </div> {/* List */} <div className="bg-components-panel-bg p-2"> {list.map((loop, index) => ( - <div key={index} className={cn('mb-1 overflow-hidden rounded-xl border-none bg-background-section-burn')}> + <div + key={index} + className={cn('mb-1 overflow-hidden rounded-xl border-none bg-background-section-burn')} + > <div className={cn( 'flex w-full cursor-pointer items-center justify-between px-3', @@ -118,44 +123,37 @@ const LoopResultPanel: FC<Props> = ({ <Loop className="size-3 text-text-primary-on-surface" /> </div> <span className="grow system-sm-semibold-uppercase text-text-primary"> - {t($ => $[`${i18nPrefix}.loop`], { ns: 'workflow' })} - {' '} - {index + 1} + {t(($) => $[`${i18nPrefix}.loop`], { ns: 'workflow' })} {index + 1} </span> {loopStatusShow(index, loop, loopDurationMap)} </div> </div> - {expandedLoops[index] && ( - <div - className="h-px grow bg-divider-subtle" - > - </div> - )} - <div className={cn( - 'transition-all duration-200', - expandedLoops[index] - ? 'opacity-100' - : 'max-h-0 overflow-hidden opacity-0', - )} + {expandedLoops[index] && <div className="h-px grow bg-divider-subtle"></div>} + <div + className={cn( + 'transition-all duration-200', + expandedLoops[index] ? 'opacity-100' : 'max-h-0 overflow-hidden opacity-0', + )} > - { - loopVariableMap?.[getLoopRunKey(loop, index)] && ( - <div className="p-2 pb-0"> - <CodeEditor - readOnly - title={<div>{t($ => $['nodes.loop.loopVariables'], { ns: 'workflow' }).toLocaleUpperCase()}</div>} - language={CodeLanguage.json} - height={112} - value={loopVariableMap[getLoopRunKey(loop, index)]} - isJSONStringifyBeauty - /> - </div> - ) - } - <TracingPanel - list={loop} - className="bg-background-section-burn" - /> + {loopVariableMap?.[getLoopRunKey(loop, index)] && ( + <div className="p-2 pb-0"> + <CodeEditor + readOnly + title={ + <div> + {t(($) => $['nodes.loop.loopVariables'], { + ns: 'workflow', + }).toLocaleUpperCase()} + </div> + } + language={CodeLanguage.json} + height={112} + value={loopVariableMap[getLoopRunKey(loop, index)]} + isJSONStringifyBeauty + /> + </div> + )} + <TracingPanel list={loop} className="bg-background-section-burn" /> </div> </div> ))} diff --git a/web/app/components/workflow/run/meta.tsx b/web/app/components/workflow/run/meta.tsx index 2e1aabd13520ae..ba875ea5e9b241 100644 --- a/web/app/components/workflow/run/meta.tsx +++ b/web/app/components/workflow/run/meta.tsx @@ -27,88 +27,86 @@ const MetaData: FC<Props> = ({ return ( <div className="relative"> - <div className="h-6 py-1 system-xs-medium-uppercase text-text-tertiary">{t($ => $['meta.title'], { ns: 'runLog' })}</div> + <div className="h-6 py-1 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['meta.title'], { ns: 'runLog' })} + </div> <div className="py-1"> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-1.5 system-xs-regular text-text-tertiary">{t($ => $['meta.status'], { ns: 'runLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-1.5 system-xs-regular text-text-tertiary"> + {t(($) => $['meta.status'], { ns: 'runLog' })} + </div> <div className="grow px-2 py-1.5 system-xs-regular text-text-secondary"> {status === 'running' && ( <div className="my-1 h-2 w-16 rounded-xs bg-text-quaternary" /> )} - {status === 'succeeded' && ( - <span>SUCCESS</span> - )} - {status === 'partial-succeeded' && ( - <span>PARTIAL SUCCESS</span> - )} - {status === 'exception' && ( - <span>EXCEPTION</span> - )} - {status === 'failed' && ( - <span>FAIL</span> - )} - {status === 'stopped' && ( - <span>STOP</span> - )} - {status === 'paused' && ( - <span>PENDING</span> - )} + {status === 'succeeded' && <span>SUCCESS</span>} + {status === 'partial-succeeded' && <span>PARTIAL SUCCESS</span>} + {status === 'exception' && <span>EXCEPTION</span>} + {status === 'failed' && <span>FAIL</span>} + {status === 'stopped' && <span>STOP</span>} + {status === 'paused' && <span>PENDING</span>} </div> </div> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-1.5 system-xs-regular text-text-tertiary">{t($ => $['meta.executor'], { ns: 'runLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-1.5 system-xs-regular text-text-tertiary"> + {t(($) => $['meta.executor'], { ns: 'runLog' })} + </div> <div className="grow px-2 py-1.5 system-xs-regular text-text-secondary"> {status === 'running' && ( <div className="my-1 h-2 w-[88px] rounded-xs bg-text-quaternary" /> )} - {status !== 'running' && ( - <span>{executor || 'N/A'}</span> - )} + {status !== 'running' && <span>{executor || 'N/A'}</span>} </div> </div> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-1.5 system-xs-regular text-text-tertiary">{t($ => $['meta.startTime'], { ns: 'runLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-1.5 system-xs-regular text-text-tertiary"> + {t(($) => $['meta.startTime'], { ns: 'runLog' })} + </div> <div className="grow px-2 py-1.5 system-xs-regular text-text-secondary"> {status === 'running' && ( <div className="my-1 h-2 w-[72px] rounded-xs bg-text-quaternary" /> )} {status !== 'running' && ( - <span>{startTime ? formatTime(startTime, t($ => $.dateTimeFormat, { ns: 'appLog' }) as string) : '-'}</span> + <span> + {startTime + ? formatTime(startTime, t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string) + : '-'} + </span> )} </div> </div> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-1.5 system-xs-regular text-text-tertiary">{t($ => $['meta.time'], { ns: 'runLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-1.5 system-xs-regular text-text-tertiary"> + {t(($) => $['meta.time'], { ns: 'runLog' })} + </div> <div className="grow px-2 py-1.5 system-xs-regular text-text-secondary"> {status === 'running' && ( <div className="my-1 h-2 w-[72px] rounded-xs bg-text-quaternary" /> )} - {status !== 'running' && ( - <span>{time ? `${time.toFixed(3)}s` : '-'}</span> - )} + {status !== 'running' && <span>{time ? `${time.toFixed(3)}s` : '-'}</span>} </div> </div> <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-1.5 system-xs-regular text-text-tertiary">{t($ => $['meta.tokens'], { ns: 'runLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-1.5 system-xs-regular text-text-tertiary"> + {t(($) => $['meta.tokens'], { ns: 'runLog' })} + </div> <div className="grow px-2 py-1.5 system-xs-regular text-text-secondary"> {['running', 'paused'].includes(status) && ( <div className="my-1 h-2 w-[48px] animate-pulse rounded-xs bg-text-quaternary" /> )} - {!['running', 'paused'].includes(status) && ( - <span>{`${tokens || 0} Tokens`}</span> - )} + {!['running', 'paused'].includes(status) && <span>{`${tokens || 0} Tokens`}</span>} </div> </div> {showSteps && ( <div className="flex"> - <div className="w-[104px] shrink-0 truncate px-2 py-1.5 system-xs-regular text-text-tertiary">{t($ => $['meta.steps'], { ns: 'runLog' })}</div> + <div className="w-[104px] shrink-0 truncate px-2 py-1.5 system-xs-regular text-text-tertiary"> + {t(($) => $['meta.steps'], { ns: 'runLog' })} + </div> <div className="grow px-2 py-1.5 system-xs-regular text-text-secondary"> {status === 'running' && ( <div className="my-1 h-2 w-[24px] rounded-xs bg-text-quaternary" /> )} - {status !== 'running' && ( - <span>{steps}</span> - )} + {status !== 'running' && <span>{steps}</span>} </div> </div> )} diff --git a/web/app/components/workflow/run/node.tsx b/web/app/components/workflow/run/node.tsx index 50543a44c9a1a2..8de4456ccb6ad7 100644 --- a/web/app/components/workflow/run/node.tsx +++ b/web/app/components/workflow/run/node.tsx @@ -40,8 +40,15 @@ type Props = { readonly inMessage?: boolean readonly hideInfo?: boolean readonly hideProcessDetail?: boolean - readonly onShowIterationDetail?: (detail: NodeTracing[][], iterDurationMap: IterationDurationMap) => void - readonly onShowLoopDetail?: (detail: NodeTracing[][], loopDurationMap: LoopDurationMap, loopVariableMap: LoopVariableMap) => void + readonly onShowIterationDetail?: ( + detail: NodeTracing[][], + iterDurationMap: IterationDurationMap, + ) => void + readonly onShowLoopDetail?: ( + detail: NodeTracing[][], + loopDurationMap: LoopDurationMap, + loopVariableMap: LoopVariableMap, + ) => void readonly onShowRetryDetail?: (detail: NodeTracing[]) => void readonly onShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void readonly notShowIterationNav?: boolean @@ -63,29 +70,27 @@ const NodePanel: FC<Props> = ({ notShowLoopNav, }) => { const [collapseState, doSetCollapseState] = useState<boolean>(true) - const setCollapseState = useCallback((state: boolean) => { - if (hideProcessDetail) - return - doSetCollapseState(state) - }, [hideProcessDetail]) + const setCollapseState = useCallback( + (state: boolean) => { + if (hideProcessDetail) return + doSetCollapseState(state) + }, + [hideProcessDetail], + ) const { t } = useTranslation() const docLink = useDocLink() const getTime = (time: number) => { - if (time < 1) - return `${(time * 1000).toFixed(3)} ms` - if (time > 60) - return `${Math.floor(time / 60)} m ${(time % 60).toFixed(3)} s` + if (time < 1) return `${(time * 1000).toFixed(3)} ms` + if (time > 60) return `${Math.floor(time / 60)} m ${(time % 60).toFixed(3)} s` return `${time.toFixed(3)} s` } const getTokenCount = (tokens: number) => { - if (tokens < 1000) - return tokens + if (tokens < 1000) return tokens if (tokens >= 1000 && tokens < 1000000) return `${Number.parseFloat((tokens / 1000).toFixed(3))}K` - if (tokens >= 1000000) - return `${Number.parseFloat((tokens / 1000000).toFixed(3))}M` + if (tokens >= 1000000) return `${Number.parseFloat((tokens / 1000000).toFixed(3))}M` } useEffect(() => { @@ -99,16 +104,16 @@ const NodePanel: FC<Props> = ({ const isToolNode = nodeInfo.node_type === BlockEnum.Tool && !!nodeInfo.agentLog?.length const inputsTitle = useMemo(() => { - let text = t($ => $['common.input'], { ns: 'workflow' }) + let text = t(($) => $['common.input'], { ns: 'workflow' }) if (nodeInfo.node_type === BlockEnum.Loop) - text = t($ => $['nodes.loop.initialLoopVariables'], { ns: 'workflow' }) + text = t(($) => $['nodes.loop.initialLoopVariables'], { ns: 'workflow' }) return text.toLocaleUpperCase() }, [nodeInfo.node_type, t]) - const processDataTitle = t($ => $['common.processData'], { ns: 'workflow' }).toLocaleUpperCase() + const processDataTitle = t(($) => $['common.processData'], { ns: 'workflow' }).toLocaleUpperCase() const outputTitle = useMemo(() => { - let text = t($ => $['common.output'], { ns: 'workflow' }) + let text = t(($) => $['common.output'], { ns: 'workflow' }) if (nodeInfo.node_type === BlockEnum.Loop) - text = t($ => $['nodes.loop.finalLoopVariables'], { ns: 'workflow' }) + text = t(($) => $['nodes.loop.finalLoopVariables'], { ns: 'workflow' }) return text.toLocaleUpperCase() }, [nodeInfo.node_type, t]) @@ -131,10 +136,15 @@ const NodePanel: FC<Props> = ({ )} /> )} - <BlockIcon size={inMessage ? 'xs' : 'sm'} className={cn('mr-2 shrink-0', inMessage && 'mr-1!')} type={nodeInfo.node_type} toolIcon={nodeInfo.extras?.icon || nodeInfo.extras} /> + <BlockIcon + size={inMessage ? 'xs' : 'sm'} + className={cn('mr-2 shrink-0', inMessage && 'mr-1!')} + type={nodeInfo.node_type} + toolIcon={nodeInfo.extras?.icon || nodeInfo.extras} + /> <Tooltip> <TooltipTrigger - render={( + render={ <div className={cn( 'min-w-0 grow truncate system-xs-semibold-uppercase text-text-secondary', @@ -143,7 +153,7 @@ const NodePanel: FC<Props> = ({ > {nodeInfo.title} </div> - )} + } /> <TooltipContent> <div className="max-w-xs">{nodeInfo.title}</div> @@ -151,7 +161,9 @@ const NodePanel: FC<Props> = ({ </Tooltip> {!['running', 'paused'].includes(nodeInfo.status) && !hideInfo && ( <div className="shrink-0 system-xs-regular text-text-tertiary"> - {nodeInfo.execution_metadata?.total_tokens ? `${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens · ` : ''} + {nodeInfo.execution_metadata?.total_tokens + ? `${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens · ` + : ''} {`${getTime(nodeInfo.elapsed_time || 0)}`} </div> )} @@ -162,13 +174,28 @@ const NodePanel: FC<Props> = ({ <RiErrorWarningFill className="ml-2 size-3.5 shrink-0 text-text-destructive" /> )} {nodeInfo.status === 'stopped' && ( - <RiAlertFill className={cn('ml-2 size-4 shrink-0 text-text-warning-secondary', inMessage && 'size-3.5')} /> + <RiAlertFill + className={cn( + 'ml-2 size-4 shrink-0 text-text-warning-secondary', + inMessage && 'size-3.5', + )} + /> )} {nodeInfo.status === 'paused' && ( - <RiPauseCircleFill className={cn('ml-2 size-4 shrink-0 text-text-warning-secondary', inMessage && 'size-3.5')} /> + <RiPauseCircleFill + className={cn( + 'ml-2 size-4 shrink-0 text-text-warning-secondary', + inMessage && 'size-3.5', + )} + /> )} {nodeInfo.status === 'exception' && ( - <RiAlertFill className={cn('ml-2 size-4 shrink-0 text-text-warning-secondary', inMessage && 'size-3.5')} /> + <RiAlertFill + className={cn( + 'ml-2 size-4 shrink-0 text-text-warning-secondary', + inMessage && 'size-3.5', + )} + /> )} {nodeInfo.status === 'running' && ( <div className="flex shrink-0 items-center text-[13px] leading-[16px] font-medium text-text-accent"> @@ -196,26 +223,21 @@ const NodePanel: FC<Props> = ({ /> )} {isRetryNode && onShowRetryDetail && ( - <RetryLogTrigger - nodeInfo={nodeInfo} - onShowRetryResultList={onShowRetryDetail} - /> + <RetryLogTrigger nodeInfo={nodeInfo} onShowRetryResultList={onShowRetryDetail} /> + )} + {(isAgentNode || isToolNode) && onShowAgentOrToolLog && ( + <AgentLogTrigger nodeInfo={nodeInfo} onShowAgentOrToolLog={onShowAgentOrToolLog} /> )} - { - (isAgentNode || isToolNode) && onShowAgentOrToolLog && ( - <AgentLogTrigger - nodeInfo={nodeInfo} - onShowAgentOrToolLog={onShowAgentOrToolLog} - /> - ) - } <div className={cn('mb-1', hideInfo && 'px-2! py-0.5!')}> - {(nodeInfo.status === 'stopped') && ( + {nodeInfo.status === 'stopped' && ( <StatusContainer status="stopped"> - {t($ => $['tracing.stopBy'], { ns: 'workflow', user: nodeInfo.created_by ? nodeInfo.created_by.name : 'N/A' })} + {t(($) => $['tracing.stopBy'], { + ns: 'workflow', + user: nodeInfo.created_by ? nodeInfo.created_by.name : 'N/A', + })} </StatusContainer> )} - {(nodeInfo.status === 'exception') && ( + {nodeInfo.status === 'exception' && ( <StatusContainer status="stopped"> {nodeInfo.error} <a @@ -224,23 +246,21 @@ const NodePanel: FC<Props> = ({ rel="noopener noreferrer" className="text-text-accent" > - {t($ => $['common.learnMore'], { ns: 'workflow' })} + {t(($) => $['common.learnMore'], { ns: 'workflow' })} </a> </StatusContainer> )} {nodeInfo.status === 'failed' && ( - <StatusContainer status="failed"> - {nodeInfo.error} - </StatusContainer> + <StatusContainer status="failed">{nodeInfo.error}</StatusContainer> )} {nodeInfo.status === 'retry' && ( - <StatusContainer status="failed"> - {nodeInfo.error} - </StatusContainer> + <StatusContainer status="failed">{nodeInfo.error}</StatusContainer> )} - {(nodeInfo.status === 'paused') && ( + {nodeInfo.status === 'paused' && ( <StatusContainer status="paused"> - <div className="system-xs-regular text-text-warning">{t($ => $['nodes.humanInput.log.reasonContent'], { ns: 'workflow' })}</div> + <div className="system-xs-regular text-text-warning"> + {t(($) => $['nodes.humanInput.log.reasonContent'], { ns: 'workflow' })} + </div> </StatusContainer> )} </div> @@ -252,7 +272,11 @@ const NodePanel: FC<Props> = ({ language={CodeLanguage.json} value={nodeInfo.inputs} isJSONStringifyBeauty - footer={nodeInfo.inputs_truncated && <LargeDataAlert textHasNoExport className="mx-1 mt-2 mb-1 h-7" />} + footer={ + nodeInfo.inputs_truncated && ( + <LargeDataAlert textHasNoExport className="mx-1 mt-2 mb-1 h-7" /> + ) + } /> </div> )} @@ -278,7 +302,15 @@ const NodePanel: FC<Props> = ({ value={nodeInfo.outputs} isJSONStringifyBeauty tip={<ErrorHandleTip type={nodeInfo.execution_metadata?.error_strategy} />} - footer={nodeInfo.outputs_truncated && <LargeDataAlert textHasNoExport downloadUrl={nodeInfo.outputs_full_content?.download_url} className="mx-1 mt-2 mb-1 h-7" />} + footer={ + nodeInfo.outputs_truncated && ( + <LargeDataAlert + textHasNoExport + downloadUrl={nodeInfo.outputs_full_content?.download_url} + className="mx-1 mt-2 mb-1 h-7" + /> + ) + } /> </div> )} diff --git a/web/app/components/workflow/run/output-panel.tsx b/web/app/components/workflow/run/output-panel.tsx index f7ed097df22308..19b68b8461f2d4 100644 --- a/web/app/components/workflow/run/output-panel.tsx +++ b/web/app/components/workflow/run/output-panel.tsx @@ -16,38 +16,29 @@ type OutputPanelProps = { height?: number } -const OutputPanel: FC<OutputPanelProps> = ({ - isRunning, - outputs, - error, - height, -}) => { +const OutputPanel: FC<OutputPanelProps> = ({ isRunning, outputs, error, height }) => { const isTextOutput = useMemo(() => { - if (!outputs || typeof outputs !== 'object') - return false + if (!outputs || typeof outputs !== 'object') return false const keys = Object.keys(outputs) const value = outputs[keys[0]!] - return keys.length === 1 && ( - typeof value === 'string' - || (Array.isArray(value) && value.every(item => typeof item === 'string')) + return ( + keys.length === 1 && + (typeof value === 'string' || + (Array.isArray(value) && value.every((item) => typeof item === 'string'))) ) }, [outputs]) const fileList = useMemo(() => { const fileList: any[] = [] - if (!outputs) - return fileList - if (Object.keys(outputs).length > 1) - return fileList + if (!outputs) return fileList + if (Object.keys(outputs).length > 1) return fileList for (const key in outputs) { if (Array.isArray(outputs[key])) { outputs[key].map((output: any) => { - if (output?.dify_model_identity === '__dify__file__') - fileList.push(output) + if (output?.dify_model_identity === '__dify__file__') fileList.push(output) return null }) - } - else if (outputs[key]?.dify_model_identity === '__dify__file__') { + } else if (outputs[key]?.dify_model_identity === '__dify__file__') { fileList.push(outputs[key]) } } @@ -76,19 +67,14 @@ const OutputPanel: FC<OutputPanelProps> = ({ content={ Array.isArray(outputs[Object.keys(outputs)[0]!]) ? outputs[Object.keys(outputs)[0]!].join('\n') - : (outputs[Object.keys(outputs)[0]!] || '') + : outputs[Object.keys(outputs)[0]!] || '' } /> </div> )} {fileList.length > 0 && ( <div className="px-4 py-2"> - <FileList - files={fileList} - showDeleteAction={false} - showDownloadAction - canPreview - /> + <FileList files={fileList} showDeleteAction={false} showDownloadAction canPreview /> </div> )} {!isTextOutput && outputs && Object.keys(outputs).length > 0 && height! > 0 && ( diff --git a/web/app/components/workflow/run/result-panel.tsx b/web/app/components/workflow/run/result-panel.tsx index b79325399518d9..72c6d722462e82 100644 --- a/web/app/components/workflow/run/result-panel.tsx +++ b/web/app/components/workflow/run/result-panel.tsx @@ -1,9 +1,6 @@ 'use client' import type { FC } from 'react' -import type { - AgentLogItemWithChildren, - NodeTracing, -} from '@/types/workflow' +import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow' import { useTranslation } from 'react-i18next' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip' @@ -98,69 +95,70 @@ const ResultPanel: FC<ResultPanelProps> = ({ /> </div> <div className="px-4"> - { - isIterationNode && handleShowIterationResultList && ( - <IterationLogTrigger - nodeInfo={nodeInfo} - onShowIterationResultList={handleShowIterationResultList} - /> - ) - } - { - isLoopNode && handleShowLoopResultList && ( - <LoopLogTrigger - nodeInfo={nodeInfo} - onShowLoopResultList={handleShowLoopResultList} - /> - ) - } - { - isRetryNode && onShowRetryDetail && ( - <RetryLogTrigger - nodeInfo={nodeInfo} - onShowRetryResultList={onShowRetryDetail} - /> - ) - } - { - (isAgentNode || isToolNode) && handleShowAgentOrToolLog && ( - <AgentLogTrigger - nodeInfo={nodeInfo} - onShowAgentOrToolLog={handleShowAgentOrToolLog} - /> - ) - } + {isIterationNode && handleShowIterationResultList && ( + <IterationLogTrigger + nodeInfo={nodeInfo} + onShowIterationResultList={handleShowIterationResultList} + /> + )} + {isLoopNode && handleShowLoopResultList && ( + <LoopLogTrigger nodeInfo={nodeInfo} onShowLoopResultList={handleShowLoopResultList} /> + )} + {isRetryNode && onShowRetryDetail && ( + <RetryLogTrigger nodeInfo={nodeInfo} onShowRetryResultList={onShowRetryDetail} /> + )} + {(isAgentNode || isToolNode) && handleShowAgentOrToolLog && ( + <AgentLogTrigger nodeInfo={nodeInfo} onShowAgentOrToolLog={handleShowAgentOrToolLog} /> + )} </div> <div className="flex flex-col gap-2 px-4 py-2"> <CodeEditor readOnly - title={<div>{t($ => $['common.input'], { ns: 'workflow' }).toLocaleUpperCase()}</div>} + title={<div>{t(($) => $['common.input'], { ns: 'workflow' }).toLocaleUpperCase()}</div>} language={CodeLanguage.json} value={inputs} isJSONStringifyBeauty - footer={inputs_truncated && <LargeDataAlert textHasNoExport className="mx-1 mt-2 mb-1 h-7" />} + footer={ + inputs_truncated && <LargeDataAlert textHasNoExport className="mx-1 mt-2 mb-1 h-7" /> + } /> {process_data && ( <CodeEditor readOnly showFileList - title={<div>{t($ => $['common.processData'], { ns: 'workflow' }).toLocaleUpperCase()}</div>} + title={ + <div>{t(($) => $['common.processData'], { ns: 'workflow' }).toLocaleUpperCase()}</div> + } language={CodeLanguage.json} value={process_data} isJSONStringifyBeauty - footer={process_data_truncated && <LargeDataAlert textHasNoExport className="mx-1 mt-2 mb-1 h-7" />} + footer={ + process_data_truncated && ( + <LargeDataAlert textHasNoExport className="mx-1 mt-2 mb-1 h-7" /> + ) + } /> )} {(outputs || status === 'running') && ( <CodeEditor readOnly showFileList - title={<div>{t($ => $['common.output'], { ns: 'workflow' }).toLocaleUpperCase()}</div>} + title={ + <div>{t(($) => $['common.output'], { ns: 'workflow' }).toLocaleUpperCase()}</div> + } language={CodeLanguage.json} value={outputs} isJSONStringifyBeauty tip={<ErrorHandleTip type={execution_metadata?.error_strategy} />} - footer={outputs_truncated && <LargeDataAlert textHasNoExport downloadUrl={outputs_full_content?.download_url} className="mx-1 mt-2 mb-1 h-7" />} + footer={ + outputs_truncated && ( + <LargeDataAlert + textHasNoExport + downloadUrl={outputs_full_content?.download_url} + className="mx-1 mt-2 mb-1 h-7" + /> + ) + } /> )} </div> diff --git a/web/app/components/workflow/run/result-text.tsx b/web/app/components/workflow/run/result-text.tsx index e47e51853de8b0..2cae39d4ab6c57 100644 --- a/web/app/components/workflow/run/result-text.tsx +++ b/web/app/components/workflow/run/result-text.tsx @@ -35,25 +35,23 @@ const ResultText: FC<ResultTextProps> = ({ )} {!isRunning && error && ( <div className="px-4 py-2"> - <StatusContainer status="failed"> - {error} - </StatusContainer> + <StatusContainer status="failed">{error}</StatusContainer> </div> )} {!isPaused && !isRunning && !outputs && !error && !allFiles?.length && ( <div className="mt-[120px] flex flex-col items-center px-4 py-2 text-[13px] leading-[18px] text-gray-500"> <ImageIndentLeft className="size-6 text-gray-400" /> - <div className="mr-2">{t($ => $['resultEmpty.title'], { ns: 'runLog' })}</div> + <div className="mr-2">{t(($) => $['resultEmpty.title'], { ns: 'runLog' })}</div> <div> - {t($ => $['resultEmpty.tipLeft'], { ns: 'runLog' })} + {t(($) => $['resultEmpty.tipLeft'], { ns: 'runLog' })} <button type="button" onClick={onClick} className="inline cursor-pointer border-none bg-transparent p-0 text-left text-primary-600" > - {t($ => $['resultEmpty.link'], { ns: 'runLog' })} + {t(($) => $['resultEmpty.link'], { ns: 'runLog' })} </button> - {t($ => $['resultEmpty.tipRight'], { ns: 'runLog' })} + {t(($) => $['resultEmpty.tipRight'], { ns: 'runLog' })} </div> </div> )} @@ -67,17 +65,18 @@ const ResultText: FC<ResultTextProps> = ({ </ChatContextProvider> </div> )} - {!!allFiles?.length && allFiles.map(item => ( - <div key={item.varName} className="flex flex-col gap-1 px-4 py-2 system-xs-regular"> - <div className="py-1 text-text-tertiary">{item.varName}</div> - <FileList - files={item.list} - showDeleteAction={false} - showDownloadAction - canPreview - /> - </div> - ))} + {!!allFiles?.length && + allFiles.map((item) => ( + <div key={item.varName} className="flex flex-col gap-1 px-4 py-2 system-xs-regular"> + <div className="py-1 text-text-tertiary">{item.varName}</div> + <FileList + files={item.list} + showDeleteAction={false} + showDownloadAction + canPreview + /> + </div> + ))} </> )} </div> diff --git a/web/app/components/workflow/run/retry-log/__tests__/retry-log-trigger.spec.tsx b/web/app/components/workflow/run/retry-log/__tests__/retry-log-trigger.spec.tsx index 14cc0e653bd45d..70a3ba8a98dfae 100644 --- a/web/app/components/workflow/run/retry-log/__tests__/retry-log-trigger.spec.tsx +++ b/web/app/components/workflow/run/retry-log/__tests__/retry-log-trigger.spec.tsx @@ -65,7 +65,9 @@ describe('RetryLogTrigger', () => { </div>, ) - await user.click(screen.getByRole('button', { name: 'workflow.nodes.common.retry.retries:{"num":2}' })) + await user.click( + screen.getByRole('button', { name: 'workflow.nodes.common.retry.retries:{"num":2}' }), + ) expect(onShowRetryResultList).toHaveBeenCalledWith(retryDetail) expect(parentClick).not.toHaveBeenCalled() diff --git a/web/app/components/workflow/run/retry-log/retry-log-trigger.tsx b/web/app/components/workflow/run/retry-log/retry-log-trigger.tsx index ceb6bdc34e64bd..0c150cfb76ad86 100644 --- a/web/app/components/workflow/run/retry-log/retry-log-trigger.tsx +++ b/web/app/components/workflow/run/retry-log/retry-log-trigger.tsx @@ -1,19 +1,13 @@ import type { NodeTracing } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' -import { - RiArrowRightSLine, - RiRestartFill, -} from '@remixicon/react' +import { RiArrowRightSLine, RiRestartFill } from '@remixicon/react' import { useTranslation } from 'react-i18next' type RetryLogTriggerProps = { nodeInfo: NodeTracing onShowRetryResultList: (detail: NodeTracing[]) => void } -const RetryLogTrigger = ({ - nodeInfo, - onShowRetryResultList, -}: RetryLogTriggerProps) => { +const RetryLogTrigger = ({ nodeInfo, onShowRetryResultList }: RetryLogTriggerProps) => { const { t } = useTranslation() const { retryDetail } = nodeInfo @@ -31,7 +25,7 @@ const RetryLogTrigger = ({ > <div className="flex items-center"> <RiRestartFill className="mr-0.5 size-4 shrink-0 text-components-button-tertiary-text" /> - {t($ => $['nodes.common.retry.retries'], { ns: 'workflow', num: retryDetail?.length })} + {t(($) => $['nodes.common.retry.retries'], { ns: 'workflow', num: retryDetail?.length })} </div> <RiArrowRightSLine className="size-4 shrink-0 text-components-button-tertiary-text" /> </Button> diff --git a/web/app/components/workflow/run/retry-log/retry-result-panel.tsx b/web/app/components/workflow/run/retry-log/retry-result-panel.tsx index 7cbf9125af9f62..539ba0210b0c3a 100644 --- a/web/app/components/workflow/run/retry-log/retry-result-panel.tsx +++ b/web/app/components/workflow/run/retry-log/retry-result-panel.tsx @@ -2,9 +2,7 @@ import type { FC } from 'react' import type { NodeTracing } from '@/types/workflow' -import { - RiArrowLeftLine, -} from '@remixicon/react' +import { RiArrowLeftLine } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' import TracingPanel from '../tracing-panel' @@ -14,10 +12,7 @@ type Props = { readonly onBack: () => void } -const RetryResultPanel: FC<Props> = ({ - list, - onBack, -}) => { +const RetryResultPanel: FC<Props> = ({ list, onBack }) => { const { t } = useTranslation() return ( @@ -31,12 +26,12 @@ const RetryResultPanel: FC<Props> = ({ }} > <RiArrowLeftLine className="mr-1 size-4" /> - {t($ => $['singleRun.back'], { ns: 'workflow' })} + {t(($) => $['singleRun.back'], { ns: 'workflow' })} </div> <TracingPanel list={list.map((item, index) => ({ ...item, - title: `${t($ => $['nodes.common.retry.retry'], { ns: 'workflow' })} ${index + 1}`, + title: `${t(($) => $['nodes.common.retry.retry'], { ns: 'workflow' })} ${index + 1}`, }))} className="bg-background-section-burn" /> diff --git a/web/app/components/workflow/run/special-result-panel.tsx b/web/app/components/workflow/run/special-result-panel.tsx index 94ce26e79c33ce..70a0bf96a2a264 100644 --- a/web/app/components/workflow/run/special-result-panel.tsx +++ b/web/app/components/workflow/run/special-result-panel.tsx @@ -51,47 +51,37 @@ const SpecialResultPanel = ({ handleShowAgentOrToolLog, }: SpecialResultPanelProps) => { return ( - <div onClick={(e) => { - e.stopPropagation() - e.nativeEvent.stopImmediatePropagation() - }} + <div + onClick={(e) => { + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + }} > - { - !!showRetryDetail && !!retryResultList?.length && setShowRetryDetailFalse && ( - <RetryResultPanel - list={retryResultList} - onBack={setShowRetryDetailFalse} - /> - ) - } - { - showIteratingDetail && !!iterationResultList?.length && setShowIteratingDetailFalse && ( - <IterationResultPanel - list={iterationResultList} - onBack={setShowIteratingDetailFalse} - iterDurationMap={iterationResultDurationMap} - /> - ) - } - { - showLoopingDetail && !!loopResultList?.length && setShowLoopingDetailFalse && ( - <LoopResultPanel - list={loopResultList} - onBack={setShowLoopingDetailFalse} - loopDurationMap={loopResultDurationMap} - loopVariableMap={loopResultVariableMap} - /> - ) - } - { - !!agentOrToolLogItemStack?.length && agentOrToolLogListMap && handleShowAgentOrToolLog && ( - <AgentResultPanel - agentOrToolLogItemStack={agentOrToolLogItemStack} - agentOrToolLogListMap={agentOrToolLogListMap} - onShowAgentOrToolLog={handleShowAgentOrToolLog} - /> - ) - } + {!!showRetryDetail && !!retryResultList?.length && setShowRetryDetailFalse && ( + <RetryResultPanel list={retryResultList} onBack={setShowRetryDetailFalse} /> + )} + {showIteratingDetail && !!iterationResultList?.length && setShowIteratingDetailFalse && ( + <IterationResultPanel + list={iterationResultList} + onBack={setShowIteratingDetailFalse} + iterDurationMap={iterationResultDurationMap} + /> + )} + {showLoopingDetail && !!loopResultList?.length && setShowLoopingDetailFalse && ( + <LoopResultPanel + list={loopResultList} + onBack={setShowLoopingDetailFalse} + loopDurationMap={loopResultDurationMap} + loopVariableMap={loopResultVariableMap} + /> + )} + {!!agentOrToolLogItemStack?.length && agentOrToolLogListMap && handleShowAgentOrToolLog && ( + <AgentResultPanel + agentOrToolLogItemStack={agentOrToolLogItemStack} + agentOrToolLogListMap={agentOrToolLogListMap} + onShowAgentOrToolLog={handleShowAgentOrToolLog} + /> + )} </div> ) } diff --git a/web/app/components/workflow/run/status-container.tsx b/web/app/components/workflow/run/status-container.tsx index 31be85a4e1633b..5e3d40eefdc8c8 100644 --- a/web/app/components/workflow/run/status-container.tsx +++ b/web/app/components/workflow/run/status-container.tsx @@ -9,43 +9,71 @@ type Props = { readonly children?: React.ReactNode } -const StatusContainer: FC<Props> = ({ - status, - children, -}) => { +const StatusContainer: FC<Props> = ({ status, children }) => { const { theme } = useTheme() return ( <div className={cn( 'relative rounded-lg border border-workflow-display-disabled-border-1 px-3 py-2.5 system-xs-regular break-all', - status === 'succeeded' && 'border-[rgba(23,178,106,0.8)] bg-workflow-display-success-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-success.svg)] text-text-success', - status === 'succeeded' && theme === Theme.light && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(23,178,106,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]', - status === 'succeeded' && theme === Theme.dark && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(23,178,106,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]', - status === 'partial-succeeded' && 'border-[rgba(23,178,106,0.8)] bg-workflow-display-success-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-success.svg)] text-text-success', - status === 'partial-succeeded' && theme === Theme.light && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(23,178,106,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]', - status === 'partial-succeeded' && theme === Theme.dark && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(23,178,106,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]', - status === 'failed' && 'border-[rgba(240,68,56,0.8)] bg-workflow-display-error-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-error.svg)] text-text-warning', - status === 'failed' && theme === Theme.light && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(240,68,56,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]', - status === 'failed' && theme === Theme.dark && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(240,68,56,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]', - (status === 'stopped' || status === 'paused') && 'border-[rgba(247,144,9,0.8)] bg-workflow-display-warning-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-warning.svg)] text-text-destructive', - (status === 'stopped' || status === 'paused') && theme === Theme.light && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(247,144,9,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]', - (status === 'stopped' || status === 'paused') && theme === Theme.dark && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(247,144,9,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]', - status === 'exception' && 'border-[rgba(247,144,9,0.8)] bg-workflow-display-warning-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-warning.svg)] text-text-destructive', - status === 'exception' && theme === Theme.light && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(247,144,9,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]', - status === 'exception' && theme === Theme.dark && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(247,144,9,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]', - status === 'running' && 'border-[rgba(11,165,236,0.8)] bg-workflow-display-normal-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-running.svg)] text-util-colors-blue-light-blue-light-600', - status === 'running' && theme === Theme.light && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(11,165,236,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]', - status === 'running' && theme === Theme.dark && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(11,165,236,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]', + status === 'succeeded' && + 'border-[rgba(23,178,106,0.8)] bg-workflow-display-success-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-success.svg)] text-text-success', + status === 'succeeded' && + theme === Theme.light && + 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(23,178,106,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]', + status === 'succeeded' && + theme === Theme.dark && + 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(23,178,106,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]', + status === 'partial-succeeded' && + 'border-[rgba(23,178,106,0.8)] bg-workflow-display-success-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-success.svg)] text-text-success', + status === 'partial-succeeded' && + theme === Theme.light && + 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(23,178,106,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]', + status === 'partial-succeeded' && + theme === Theme.dark && + 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(23,178,106,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]', + status === 'failed' && + 'border-[rgba(240,68,56,0.8)] bg-workflow-display-error-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-error.svg)] text-text-warning', + status === 'failed' && + theme === Theme.light && + 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(240,68,56,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]', + status === 'failed' && + theme === Theme.dark && + 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(240,68,56,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]', + (status === 'stopped' || status === 'paused') && + 'border-[rgba(247,144,9,0.8)] bg-workflow-display-warning-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-warning.svg)] text-text-destructive', + (status === 'stopped' || status === 'paused') && + theme === Theme.light && + 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(247,144,9,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]', + (status === 'stopped' || status === 'paused') && + theme === Theme.dark && + 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(247,144,9,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]', + status === 'exception' && + 'border-[rgba(247,144,9,0.8)] bg-workflow-display-warning-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-warning.svg)] text-text-destructive', + status === 'exception' && + theme === Theme.light && + 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(247,144,9,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]', + status === 'exception' && + theme === Theme.dark && + 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(247,144,9,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]', + status === 'running' && + 'border-[rgba(11,165,236,0.8)] bg-workflow-display-normal-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-running.svg)] text-util-colors-blue-light-blue-light-600', + status === 'running' && + theme === Theme.light && + 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(11,165,236,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]', + status === 'running' && + theme === Theme.dark && + 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(11,165,236,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]', )} > - <div className={cn( - 'absolute top-0 left-0 h-[50px] w-[65%] bg-no-repeat', - theme === Theme.light && 'bg-[url(~@/app/components/workflow/run/assets/highlight.svg)]', - theme === Theme.dark && 'bg-[url(~@/app/components/workflow/run/assets/highlight-dark.svg)]', - )} - > - </div> + <div + className={cn( + 'absolute top-0 left-0 h-[50px] w-[65%] bg-no-repeat', + theme === Theme.light && 'bg-[url(~@/app/components/workflow/run/assets/highlight.svg)]', + theme === Theme.dark && + 'bg-[url(~@/app/components/workflow/run/assets/highlight-dark.svg)]', + )} + ></div> {children} </div> ) diff --git a/web/app/components/workflow/run/status.tsx b/web/app/components/workflow/run/status.tsx index 342bc7887fe62c..db04ffa3da7b72 100644 --- a/web/app/components/workflow/run/status.tsx +++ b/web/app/components/workflow/run/status.tsx @@ -38,25 +38,21 @@ const StatusPanel: FC<ResultProps> = ({ const pausedReasons = useMemo(() => { const reasons: string[] = [] - if (!pausedDetails) - return reasons + if (!pausedDetails) return reasons const hasHumanInputNode = pausedDetails.paused_nodes.some( - node => node.pause_type.type === 'human_input', + (node) => node.pause_type.type === 'human_input', ) if (hasHumanInputNode) { - reasons.push(t($ => $['nodes.humanInput.log.reasonContent'], { ns: 'workflow' })) + reasons.push(t(($) => $['nodes.humanInput.log.reasonContent'], { ns: 'workflow' })) } return reasons }, [pausedDetails, t]) const pausedInputURLs = useMemo(() => { const inputURLs: string[] = [] - if (!pausedDetails) - return inputURLs + if (!pausedDetails) return inputURLs const { paused_nodes } = pausedDetails - const hasHumanInputNode = paused_nodes.some( - node => node.pause_type.type === 'human_input', - ) + const hasHumanInputNode = paused_nodes.some((node) => node.pause_type.type === 'human_input') if (hasHumanInputNode) { paused_nodes.forEach((node) => { if (node.pause_type.type === 'human_input') { @@ -67,46 +63,48 @@ const StatusPanel: FC<ResultProps> = ({ return inputURLs }, [pausedDetails]) - const partialSucceededTip = exceptionCounts - ? ( - <Trans - i18nKey={$ => $['nodes.common.errorHandle.partialSucceeded.tip']} - ns="workflow" - values={{ num: exceptionCounts }} - components={{ - tracingLink: onOpenTracingTab - ? ( - <a - href="#tracing" - className="cursor-pointer text-text-accent hover:underline" - onClick={(e) => { - e.preventDefault() - onOpenTracingTab() - }} - /> - ) - : <span />, - }} - /> - ) - : null + const partialSucceededTip = exceptionCounts ? ( + <Trans + i18nKey={($) => $['nodes.common.errorHandle.partialSucceeded.tip']} + ns="workflow" + values={{ num: exceptionCounts }} + components={{ + tracingLink: onOpenTracingTab ? ( + <a + href="#tracing" + className="cursor-pointer text-text-accent hover:underline" + onClick={(e) => { + e.preventDefault() + onOpenTracingTab() + }} + /> + ) : ( + <span /> + ), + }} + /> + ) : null return ( <StatusContainer status={status}> <div className="flex"> - <div className={cn( - 'max-w-[120px] flex-[33%]', - status === 'partial-succeeded' && 'min-w-[140px]', - )} + <div + className={cn( + 'max-w-[120px] flex-[33%]', + status === 'partial-succeeded' && 'min-w-[140px]', + )} > - <div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['resultPanel.status'], { ns: 'runLog' })}</div> + <div className="mb-1 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['resultPanel.status'], { ns: 'runLog' })} + </div> <div className={cn( 'flex items-center gap-1 system-xs-semibold-uppercase', status === 'succeeded' && 'text-util-colors-green-green-600', status === 'partial-succeeded' && 'text-util-colors-green-green-600', status === 'failed' && 'text-util-colors-red-red-600', - (status === 'stopped' || status === 'paused') && 'text-util-colors-warning-warning-600', + (status === 'stopped' || status === 'paused') && + 'text-util-colors-warning-warning-600', status === 'running' && 'text-util-colors-blue-light-blue-light-600', )} > @@ -155,7 +153,9 @@ const StatusPanel: FC<ResultProps> = ({ </div> </div> <div className="max-w-[152px] flex-[33%]"> - <div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['resultPanel.time'], { ns: 'runLog' })}</div> + <div className="mb-1 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['resultPanel.time'], { ns: 'runLog' })} + </div> <div className="flex items-center gap-1 system-sm-medium text-text-secondary"> {(status === 'running' || status === 'paused') && ( <div className="h-2 w-16 animate-pulse rounded-xs bg-text-quaternary" /> @@ -166,14 +166,14 @@ const StatusPanel: FC<ResultProps> = ({ </div> </div> <div className="flex-[33%]"> - <div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">{t($ => $['resultPanel.tokens'], { ns: 'runLog' })}</div> + <div className="mb-1 system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['resultPanel.tokens'], { ns: 'runLog' })} + </div> <div className="flex items-center gap-1 system-sm-medium text-text-secondary"> {(status === 'running' || status === 'paused') && ( <div className="h-2 w-20 animate-pulse rounded-xs bg-text-quaternary" /> )} - {status !== 'running' && status !== 'paused' && ( - <span>{`${tokens || 0} Tokens`}</span> - )} + {status !== 'running' && status !== 'paused' && <span>{`${tokens || 0} Tokens`}</span>} </div> </div> </div> @@ -181,66 +181,60 @@ const StatusPanel: FC<ResultProps> = ({ <> <div className="my-2 h-[0.5px] bg-divider-subtle" /> <div className="system-xs-regular whitespace-pre-wrap text-text-destructive">{error}</div> - { - !!exceptionCounts && ( - <> - <div className="my-2 h-[0.5px] bg-divider-subtle" /> - <div className="system-xs-regular text-text-destructive"> - {partialSucceededTip} - </div> - </> - ) - } + {!!exceptionCounts && ( + <> + <div className="my-2 h-[0.5px] bg-divider-subtle" /> + <div className="system-xs-regular text-text-destructive">{partialSucceededTip}</div> + </> + )} + </> + )} + {status === 'partial-succeeded' && !!exceptionCounts && ( + <> + <div className="my-2 h-[0.5px] bg-divider-deep" /> + <div className="system-xs-medium text-text-warning">{partialSucceededTip}</div> + </> + )} + {status === 'exception' && ( + <> + <div className="my-2 h-[0.5px] bg-divider-deep" /> + <div className="system-xs-medium text-text-warning"> + {error} + <a + href={docLink('/use-dify/debug/error-type')} + target="_blank" + rel="noopener noreferrer" + className="text-text-accent" + > + {t(($) => $['common.learnMore'], { ns: 'workflow' })} + </a> + </div> </> )} - { - status === 'partial-succeeded' && !!exceptionCounts && ( - <> - <div className="my-2 h-[0.5px] bg-divider-deep" /> - <div className="system-xs-medium text-text-warning"> - {partialSucceededTip} - </div> - </> - ) - } - { - status === 'exception' && ( - <> - <div className="my-2 h-[0.5px] bg-divider-deep" /> - <div className="system-xs-medium text-text-warning"> - {error} - <a - href={docLink('/use-dify/debug/error-type')} - target="_blank" - rel="noopener noreferrer" - className="text-text-accent" - > - {t($ => $['common.learnMore'], { ns: 'workflow' })} - </a> - </div> - </> - ) - } {status === 'paused' && ( <> <div className="my-2 h-[0.5px] bg-divider-deep" /> <div className="flex flex-col gap-y-2 system-xs-medium"> <div className="flex flex-col gap-y-0.5"> - <div className="system-2xs-medium-uppercase text-text-tertiary">{t($ => $['nodes.humanInput.log.reason'], { ns: 'workflow' })}</div> - { - pausedReasons.length > 0 - ? pausedReasons.map(reason => ( - <div className="truncate system-xs-medium text-text-secondary" key={reason}>{reason}</div> - )) - : ( - <div className="h-2 w-20 animate-pulse rounded-xs bg-text-quaternary" /> - ) - } + <div className="system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['nodes.humanInput.log.reason'], { ns: 'workflow' })} + </div> + {pausedReasons.length > 0 ? ( + pausedReasons.map((reason) => ( + <div className="truncate system-xs-medium text-text-secondary" key={reason}> + {reason} + </div> + )) + ) : ( + <div className="h-2 w-20 animate-pulse rounded-xs bg-text-quaternary" /> + )} </div> {pausedInputURLs.length > 0 && ( <div className="flex flex-col gap-y-0.5"> - <div className="system-2xs-medium-uppercase text-text-tertiary">{t($ => $['nodes.humanInput.log.backstageInputURL'], { ns: 'workflow' })}</div> - {pausedInputURLs.map(url => ( + <div className="system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['nodes.humanInput.log.backstageInputURL'], { ns: 'workflow' })} + </div> + {pausedInputURLs.map((url) => ( <a key={url} href={url} diff --git a/web/app/components/workflow/run/tracing-panel.tsx b/web/app/components/workflow/run/tracing-panel.tsx index 8b17062d8cb79f..d644f5c9e78edc 100644 --- a/web/app/components/workflow/run/tracing-panel.tsx +++ b/web/app/components/workflow/run/tracing-panel.tsx @@ -3,10 +3,7 @@ import type { FC } from 'react' import type { NodeTracing } from '@/types/workflow' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' -import { - useCallback, - useState, -} from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import formatNodeList from '@/app/components/workflow/run/utils/format-log' import { getHoveredParallelId } from './get-hovered-parallel-id' @@ -35,11 +32,8 @@ const TracingPanel: FC<TracingPanelProps> = ({ const toggleCollapse = (id: string) => { setCollapsedNodes((prev) => { const newSet = new Set(prev) - if (newSet.has(id)) - newSet.delete(id) - - else - newSet.add(id) + if (newSet.has(id)) newSet.delete(id) + else newSet.add(id) return newSet }) @@ -99,39 +93,49 @@ const TracingPanel: FC<TracingPanelProps> = ({ onClick={() => toggleCollapse(node.id)} className={cn( 'mr-2 transition-colors', - isHovered ? 'rounded-sm border-components-button-primary-border bg-components-button-primary-bg text-text-primary-on-surface' : 'text-text-secondary hover:text-text-primary', + isHovered + ? 'rounded-sm border-components-button-primary-border bg-components-button-primary-bg text-text-primary-on-surface' + : 'text-text-secondary hover:text-text-primary', )} > - {isHovered - ? <span aria-hidden className="i-ri-arrow-down-s-line size-3" /> - : <span aria-hidden className="i-ri-menu-4-line size-3 text-text-tertiary" />} + {isHovered ? ( + <span aria-hidden className="i-ri-arrow-down-s-line size-3" /> + ) : ( + <span aria-hidden className="i-ri-menu-4-line size-3 text-text-tertiary" /> + )} </button> <div className="flex items-center system-xs-semibold-uppercase text-text-secondary"> <span>{parallelDetail.parallelTitle}</span> </div> <div className="mx-2 h-px grow bg-divider-subtle" - style={{ background: 'linear-gradient(to right, rgba(16, 24, 40, 0.08), rgba(255, 255, 255, 0)' }} - > - </div> + style={{ + background: + 'linear-gradient(to right, rgba(16, 24, 40, 0.08), rgba(255, 255, 255, 0)', + }} + ></div> </div> <div className={`relative pl-2 ${isCollapsed ? 'hidden' : ''}`}> - <div className={cn( - 'absolute top-0 bottom-0 left-[5px] w-[2px]', - isHovered ? 'bg-text-accent-secondary' : 'bg-divider-subtle', - )} - > - </div> + <div + className={cn( + 'absolute top-0 bottom-0 left-[5px] w-[2px]', + isHovered ? 'bg-text-accent-secondary' : 'bg-divider-subtle', + )} + ></div> {parallelDetail.children!.map(renderNode)} </div> </div> ) - } - else { + } else { const isHovered = hoveredParallel === node.id return ( <div key={node.id}> - <div className={cn('-mb-1.5 pl-4 system-2xs-medium-uppercase', isHovered ? 'text-text-tertiary' : 'text-text-quaternary')}> + <div + className={cn( + '-mb-1.5 pl-4 system-2xs-medium-uppercase', + isHovered ? 'text-text-tertiary' : 'text-text-quaternary', + )} + > {node?.parallelDetail?.branchTitle} </div> <NodePanel @@ -155,18 +159,15 @@ const TracingPanel: FC<TracingPanelProps> = ({ showRetryDetail={showRetryDetail} setShowRetryDetailFalse={setShowRetryDetailFalse} retryResultList={retryResultList} - showIteratingDetail={showIteratingDetail} setShowIteratingDetailFalse={setShowIteratingDetailFalse} iterationResultList={iterationResultList} iterationResultDurationMap={iterationResultDurationMap} - showLoopingDetail={showLoopingDetail} setShowLoopingDetailFalse={setShowLoopingDetailFalse} loopResultList={loopResultList} loopResultDurationMap={loopResultDurationMap} loopResultVariableMap={loopResultVariableMap} - agentOrToolLogItemStack={agentOrToolLogItemStack} agentOrToolLogListMap={agentOrToolLogListMap} handleShowAgentOrToolLog={handleShowAgentOrToolLog} diff --git a/web/app/components/workflow/run/utils/format-log/__tests__/index.spec.ts b/web/app/components/workflow/run/utils/format-log/__tests__/index.spec.ts index 91c754ccb0751b..7d9b3001edbe70 100644 --- a/web/app/components/workflow/run/utils/format-log/__tests__/index.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/__tests__/index.spec.ts @@ -2,7 +2,6 @@ import type { WorkflowTranslate } from '../parallel' import type { NodeTracing } from '@/types/workflow' import { BlockEnum } from '@/app/components/workflow/types' import { withSelectorKey } from '@/test/i18n-mock' - import formatToTracingNodeList from '../index' const mockFormatAgentNode = vi.hoisted(() => vi.fn()) @@ -75,7 +74,9 @@ const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({ finished_at: 1, }) -const createExecutionMetadata = (overrides: Partial<NonNullable<NodeTracing['execution_metadata']>> = {}): NonNullable<NodeTracing['execution_metadata']> => ({ +const createExecutionMetadata = ( + overrides: Partial<NonNullable<NodeTracing['execution_metadata']>> = {}, +): NonNullable<NodeTracing['execution_metadata']> => ({ total_tokens: 0, total_price: 0, currency: 'USD', @@ -90,23 +91,29 @@ describe('formatToTracingNodeList', () => { mockFormatRetryNode.mockImplementation((list: NodeTracing[]) => list) mockAddChildrenToLoopNode.mockImplementation((item: NodeTracing, children: NodeTracing[]) => ({ ...item, - loopChildren: children.map(child => child.node_id), + loopChildren: children.map((child) => child.node_id), details: [[{ id: 'loop-detail-row' }]], })) - mockAddChildrenToIterationNode.mockImplementation((item: NodeTracing, children: NodeTracing[]) => ({ - ...item, - iterationChildren: children.map(child => child.node_id), - details: [[{ id: 'iteration-detail-row' }]], - })) + mockAddChildrenToIterationNode.mockImplementation( + (item: NodeTracing, children: NodeTracing[]) => ({ + ...item, + iterationChildren: children.map((child) => child.node_id), + details: [[{ id: 'iteration-detail-row' }]], + }), + ) mockFormatParallelNode.mockImplementation((list: unknown[]) => - list.map(item => ({ + list.map((item) => ({ ...(item as Record<string, unknown>), parallelFormatted: true, - }))) + })), + ) }) it('should sort the input by index and run the formatter pipeline in order', () => { - const t = withSelectorKey(vi.fn((key: string) => key), 'workflow') as WorkflowTranslate + const t = withSelectorKey( + vi.fn((key: string) => key), + 'workflow', + ) as WorkflowTranslate const traces = [ createTrace({ id: 'b', node_id: 'b', title: 'B', index: 2 }), createTrace({ id: 'a', node_id: 'a', title: 'A', index: 0 }), @@ -120,8 +127,12 @@ describe('formatToTracingNodeList', () => { expect.objectContaining({ node_id: 'c' }), expect.objectContaining({ node_id: 'b' }), ]) - expect(mockFormatHumanInputNode).toHaveBeenCalledWith(mockFormatAgentNode.mock.results[0]!.value) - expect(mockFormatRetryNode).toHaveBeenCalledWith(mockFormatHumanInputNode.mock.results[0]!.value) + expect(mockFormatHumanInputNode).toHaveBeenCalledWith( + mockFormatAgentNode.mock.results[0]!.value, + ) + expect(mockFormatRetryNode).toHaveBeenCalledWith( + mockFormatHumanInputNode.mock.results[0]!.value, + ) expect(mockFormatParallelNode).toHaveBeenLastCalledWith(expect.any(Array), t) expect(result).toEqual([ expect.objectContaining({ node_id: 'a', parallelFormatted: true }), @@ -131,7 +142,10 @@ describe('formatToTracingNodeList', () => { }) it('should collapse loop and iteration children into parent nodes and propagate child failures', () => { - const t = withSelectorKey(vi.fn((key: string) => key), 'workflow') as WorkflowTranslate + const t = withSelectorKey( + vi.fn((key: string) => key), + 'workflow', + ) as WorkflowTranslate const loopParent = createTrace({ id: 'loop-parent', node_id: 'loop-parent', @@ -161,12 +175,10 @@ describe('formatToTracingNodeList', () => { execution_metadata: createExecutionMetadata({ iteration_id: 'iteration-parent' }), }) - const result = formatToTracingNodeList([ - loopParent, - loopChild, - iterationParent, - iterationChild, - ], t) + const result = formatToTracingNodeList( + [loopParent, loopChild, iterationParent, iterationChild], + t, + ) expect(mockAddChildrenToLoopNode).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/web/app/components/workflow/run/utils/format-log/agent/__tests__/index.spec.ts b/web/app/components/workflow/run/utils/format-log/agent/__tests__/index.spec.ts index 167ef2564961ca..0593effb424629 100644 --- a/web/app/components/workflow/run/utils/format-log/agent/__tests__/index.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/agent/__tests__/index.spec.ts @@ -2,18 +2,20 @@ import type { AgentLogItem, NodeTracing } from '@/types/workflow' import { BlockEnum } from '@/app/components/workflow/types' import format from '..' -const createTrace = (agentLog: AgentLogItem[], nodeType = BlockEnum.Agent): NodeTracing => ({ - node_id: 'agent-node', - node_type: nodeType, - execution_metadata: { - agent_log: agentLog, - }, -} as unknown as NodeTracing) - -const createLog = (messageId: string, parentId?: string): AgentLogItem => ({ - message_id: messageId, - parent_id: parentId, -} as AgentLogItem) +const createTrace = (agentLog: AgentLogItem[], nodeType = BlockEnum.Agent): NodeTracing => + ({ + node_id: 'agent-node', + node_type: nodeType, + execution_metadata: { + agent_log: agentLog, + }, + }) as unknown as NodeTracing + +const createLog = (messageId: string, parentId?: string): AgentLogItem => + ({ + message_id: messageId, + parent_id: parentId, + }) as AgentLogItem describe('agent format log', () => { it('should transform flat agent logs into a tree', () => { @@ -32,9 +34,7 @@ describe('agent format log', () => { children: [ expect.objectContaining({ message_id: 'child-1', - children: [ - expect.objectContaining({ message_id: 'grandchild' }), - ], + children: [expect.objectContaining({ message_id: 'grandchild' })], }), expect.objectContaining({ message_id: 'child-2' }), ], @@ -43,12 +43,7 @@ describe('agent format log', () => { }) it('should remove one-step circle log entries', () => { - const [result] = format([ - createTrace([ - createLog('root'), - createLog('root', 'root'), - ]), - ]) + const [result] = format([createTrace([createLog('root'), createLog('root', 'root')])]) expect(result!.agentLog).toEqual([ expect.objectContaining({ @@ -60,11 +55,7 @@ describe('agent format log', () => { }) it('should not attach agent logs to unsupported node types', () => { - const [result] = format([ - createTrace([ - createLog('root'), - ], BlockEnum.Start), - ]) + const [result] = format([createTrace([createLog('root')], BlockEnum.Start)]) expect(result!.agentLog).toEqual([]) }) diff --git a/web/app/components/workflow/run/utils/format-log/agent/index.ts b/web/app/components/workflow/run/utils/format-log/agent/index.ts index 19acd8c12011d3..ec36cf3182b176 100644 --- a/web/app/components/workflow/run/utils/format-log/agent/index.ts +++ b/web/app/components/workflow/run/utils/format-log/agent/index.ts @@ -6,8 +6,7 @@ const supportedAgentLogNodes = [BlockEnum.Agent, BlockEnum.Tool] const remove = (node: AgentLogItemWithChildren, removeId: string) => { let { children } = node - if (!children || children.length === 0) - return + if (!children || children.length === 0) return const hasCircle = !!children.find((c) => { const childId = c.message_id || (c as any).id @@ -28,8 +27,7 @@ const remove = (node: AgentLogItemWithChildren, removeId: string) => { } const removeRepeatedSiblings = (list: AgentLogItemWithChildren[]) => { - if (!list || list.length === 0) - return [] + if (!list || list.length === 0) return [] const result: AgentLogItemWithChildren[] = [] const addedItemIds: string[] = [] @@ -47,8 +45,7 @@ const removeCircleLogItem = (log: AgentLogItemWithChildren) => { const newLog = cloneDeep(log) // If no children, return as is - if (!newLog.children || newLog.children.length === 0) - return newLog + if (!newLog.children || newLog.children.length === 0) return newLog newLog.children = removeRepeatedSiblings(newLog.children) const id = newLog.message_id || (newLog as any).id @@ -76,8 +73,7 @@ const removeCircleLogItem = (log: AgentLogItemWithChildren) => { } const listToTree = (logs: AgentLogItem[]) => { - if (!logs || logs.length === 0) - return [] + if (!logs || logs.length === 0) return [] // First pass: identify all unique items and track parent-child relationships const itemsById = new Map<string, any>() @@ -87,17 +83,14 @@ const listToTree = (logs: AgentLogItem[]) => { const itemId = item.message_id || (item as any).id // Only add to itemsById if not already there (keep first occurrence) - if (itemId && !itemsById.has(itemId)) - itemsById.set(itemId, item) + if (itemId && !itemsById.has(itemId)) itemsById.set(itemId, item) // Initialize children array for this ID if needed - if (itemId && !childrenById.has(itemId)) - childrenById.set(itemId, []) + if (itemId && !childrenById.has(itemId)) childrenById.set(itemId, []) // If this item has a parent, add it to parent's children list if (item.parent_id) { - if (!childrenById.has(item.parent_id)) - childrenById.set(item.parent_id, []) + if (!childrenById.has(item.parent_id)) childrenById.set(item.parent_id, []) childrenById.get(item.parent_id)!.push(item) } @@ -112,8 +105,7 @@ const listToTree = (logs: AgentLogItem[]) => { if (!hasParent) { const itemId = item.message_id || (item as any).id const children = childrenById.get(itemId) - if (children && children.length > 0) - item.children = children + if (children && children.length > 0) item.children = children tree.push(item as AgentLogItemWithChildren) } @@ -123,8 +115,7 @@ const listToTree = (logs: AgentLogItem[]) => { itemsById.forEach((item) => { const itemId = item.message_id || (item as any).id const children = childrenById.get(itemId) - if (children && children.length > 0) - item.children = children + if (children && children.length > 0) item.children = children }) return tree @@ -134,10 +125,14 @@ const format = (list: NodeTracing[]): NodeTracing[] => { const result: NodeTracing[] = list.map((item) => { let treeList: AgentLogItemWithChildren[] = [] let removedCircleTree: AgentLogItemWithChildren[] = [] - if (supportedAgentLogNodes.includes(item.node_type) && item.execution_metadata?.agent_log && item.execution_metadata?.agent_log.length > 0) + if ( + supportedAgentLogNodes.includes(item.node_type) && + item.execution_metadata?.agent_log && + item.execution_metadata?.agent_log.length > 0 + ) treeList = listToTree(item.execution_metadata.agent_log) // console.log(JSON.stringify(treeList)) - removedCircleTree = treeList.length > 0 ? treeList.map(t => removeCircleLogItem(t)) : [] + removedCircleTree = treeList.length > 0 ? treeList.map((t) => removeCircleLogItem(t)) : [] item.agentLog = removedCircleTree return item diff --git a/web/app/components/workflow/run/utils/format-log/human-input/__tests__/index.spec.ts b/web/app/components/workflow/run/utils/format-log/human-input/__tests__/index.spec.ts index 050dc741f2252f..a1647682946348 100644 --- a/web/app/components/workflow/run/utils/format-log/human-input/__tests__/index.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/human-input/__tests__/index.spec.ts @@ -61,10 +61,7 @@ describe('formatHumanInputNode', () => { }), ] - expect(formatHumanInputNode(list)).toEqual([ - list[2], - list[1], - ]) + expect(formatHumanInputNode(list)).toEqual([list[2], list[1]]) }) it('returns the original list when there are no human-input nodes', () => { diff --git a/web/app/components/workflow/run/utils/format-log/human-input/index.ts b/web/app/components/workflow/run/utils/format-log/human-input/index.ts index f8fbe7974c946c..fa7e5e80ae799c 100644 --- a/web/app/components/workflow/run/utils/format-log/human-input/index.ts +++ b/web/app/components/workflow/run/utils/format-log/human-input/index.ts @@ -27,8 +27,7 @@ const formatHumanInputNode = (list: NodeTracing[]): NodeTracing[] => { }) // If no human-input nodes, return the list as is - if (humanInputNodeIds.size === 0) - return list + if (humanInputNodeIds.size === 0) return list // Second pass: filter the list to remove duplicate human-input nodes // and keep only the latest one for each node_id @@ -46,8 +45,7 @@ const formatHumanInputNode = (list: NodeTracing[]): NodeTracing[] => { } } // Skip duplicate human-input nodes - } - else { + } else { // Keep all non-human-input nodes result.push(item) } diff --git a/web/app/components/workflow/run/utils/format-log/index.ts b/web/app/components/workflow/run/utils/format-log/index.ts index 79a71fdf67b9d8..12a97a05c1f30e 100644 --- a/web/app/components/workflow/run/utils/format-log/index.ts +++ b/web/app/components/workflow/run/utils/format-log/index.ts @@ -14,30 +14,43 @@ const formatIterationAndLoopNode = (list: NodeTracing[], t: WorkflowTranslate) = // Identify all loop and iteration nodes const loopNodeIds = clonedList - .filter(item => item.node_type === BlockEnum.Loop) - .map(item => item.node_id) + .filter((item) => item.node_type === BlockEnum.Loop) + .map((item) => item.node_id) const iterationNodeIds = clonedList - .filter(item => item.node_type === BlockEnum.Iteration) - .map(item => item.node_id) + .filter((item) => item.node_type === BlockEnum.Iteration) + .map((item) => item.node_id) // Identify all child nodes for both loop and iteration const loopChildrenNodeIds = clonedList - .filter(item => item.execution_metadata?.loop_id && loopNodeIds.includes(item.execution_metadata.loop_id)) - .map(item => item.node_id) + .filter( + (item) => + item.execution_metadata?.loop_id && loopNodeIds.includes(item.execution_metadata.loop_id), + ) + .map((item) => item.node_id) const iterationChildrenNodeIds = clonedList - .filter(item => item.execution_metadata?.iteration_id && iterationNodeIds.includes(item.execution_metadata.iteration_id)) - .map(item => item.node_id) + .filter( + (item) => + item.execution_metadata?.iteration_id && + iterationNodeIds.includes(item.execution_metadata.iteration_id), + ) + .map((item) => item.node_id) // Filter out child nodes as they will be included in their parent nodes const result = clonedList - .filter(item => !loopChildrenNodeIds.includes(item.node_id) && !iterationChildrenNodeIds.includes(item.node_id)) + .filter( + (item) => + !loopChildrenNodeIds.includes(item.node_id) && + !iterationChildrenNodeIds.includes(item.node_id), + ) .map((item) => { // Process Loop nodes if (item.node_type === BlockEnum.Loop) { - const childrenNodes = clonedList.filter(child => child.execution_metadata?.loop_id === item.node_id) - const error = childrenNodes.find(child => child.status === 'failed') + const childrenNodes = clonedList.filter( + (child) => child.execution_metadata?.loop_id === item.node_id, + ) + const error = childrenNodes.find((child) => child.status === 'failed') if (error) { item.status = 'failed' item.error = error.error @@ -55,8 +68,10 @@ const formatIterationAndLoopNode = (list: NodeTracing[], t: WorkflowTranslate) = // Process Iteration nodes if (item.node_type === BlockEnum.Iteration) { - const childrenNodes = clonedList.filter(child => child.execution_metadata?.iteration_id === item.node_id) - const error = childrenNodes.find(child => child.status === 'failed') + const childrenNodes = clonedList.filter( + (child) => child.execution_metadata?.iteration_id === item.node_id, + ) + const error = childrenNodes.find((child) => child.status === 'failed') if (error) { item.status = 'failed' item.error = error.error @@ -81,9 +96,9 @@ const formatIterationAndLoopNode = (list: NodeTracing[], t: WorkflowTranslate) = const formatToTracingNodeList = (list: NodeTracing[], t: WorkflowTranslate) => { const allItems = cloneDeep([...list]).sort((a, b) => a.index - b.index) /* - * First handle not change list structure node - * Because Handle struct node will put the node in different - */ + * First handle not change list structure node + * Because Handle struct node will put the node in different + */ const formattedAgentList = formatAgentNode(allItems) const formattedHumanInputList = formatHumanInputNode(formattedAgentList) // Keep only latest status for human-input nodes const formattedRetryList = formatRetryNode(formattedHumanInputList) // retry one node diff --git a/web/app/components/workflow/run/utils/format-log/iteration/__tests__/index.spec.ts b/web/app/components/workflow/run/utils/format-log/iteration/__tests__/index.spec.ts index b2c232c77dcad1..f8ac4662c87e0e 100644 --- a/web/app/components/workflow/run/utils/format-log/iteration/__tests__/index.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/iteration/__tests__/index.spec.ts @@ -3,17 +3,20 @@ import { addChildrenToIterationNode } from '..' type ExecutionMetadata = NonNullable<NodeTracing['execution_metadata']> -const createExecutionMetadata = (overrides: Partial<ExecutionMetadata> = {}): ExecutionMetadata => ({ +const createExecutionMetadata = ( + overrides: Partial<ExecutionMetadata> = {}, +): ExecutionMetadata => ({ total_tokens: 0, total_price: 0, currency: 'USD', ...overrides, }) -const createTrace = (nodeId: string, executionMetadata?: Partial<ExecutionMetadata>): NodeTracing => ({ - node_id: nodeId, - execution_metadata: createExecutionMetadata(executionMetadata), -} as NodeTracing) +const createTrace = (nodeId: string, executionMetadata?: Partial<ExecutionMetadata>): NodeTracing => + ({ + node_id: nodeId, + execution_metadata: createExecutionMetadata(executionMetadata), + }) as NodeTracing describe('iteration format log', () => { it('should place the first child of a new iteration at a new record when its index is missing', () => { @@ -23,10 +26,7 @@ describe('iteration format log', () => { const result = addChildrenToIterationNode(parent, [child, streamingChild]) - expect(result.details).toEqual([ - [child], - [streamingChild], - ]) + expect(result.details).toEqual([[child], [streamingChild]]) }) it('should keep missing iteration index items in the current record when the node has not restarted', () => { @@ -35,12 +35,13 @@ describe('iteration format log', () => { const secondRunChild = createTrace('code', { iteration_id: 'iteration', iteration_index: 1 }) const streamingChild = createTrace('tool', { iteration_id: 'iteration' }) - const result = addChildrenToIterationNode(parent, [firstRunChild, secondRunChild, streamingChild]) - - expect(result.details).toEqual([ - [firstRunChild], - [secondRunChild, streamingChild], + const result = addChildrenToIterationNode(parent, [ + firstRunChild, + secondRunChild, + streamingChild, ]) + + expect(result.details).toEqual([[firstRunChild], [secondRunChild, streamingChild]]) }) it('should not move an earlier missing iteration index item into the latest record', () => { @@ -49,11 +50,12 @@ describe('iteration format log', () => { const streamingChild = createTrace('tool', { iteration_id: 'iteration' }) const secondRunChild = createTrace('code', { iteration_id: 'iteration', iteration_index: 1 }) - const result = addChildrenToIterationNode(parent, [firstRunChild, streamingChild, secondRunChild]) - - expect(result.details).toEqual([ - [firstRunChild, streamingChild], - [secondRunChild], + const result = addChildrenToIterationNode(parent, [ + firstRunChild, + streamingChild, + secondRunChild, ]) + + expect(result.details).toEqual([[firstRunChild, streamingChild], [secondRunChild]]) }) }) diff --git a/web/app/components/workflow/run/utils/format-log/iteration/index.ts b/web/app/components/workflow/run/utils/format-log/iteration/index.ts index 743f632db71683..0c379d6cdd4eba 100644 --- a/web/app/components/workflow/run/utils/format-log/iteration/index.ts +++ b/web/app/components/workflow/run/utils/format-log/iteration/index.ts @@ -1,29 +1,28 @@ import type { NodeTracing } from '@/types/workflow' -export function addChildrenToIterationNode(iterationNode: NodeTracing, childrenNodes: NodeTracing[]): NodeTracing { +export function addChildrenToIterationNode( + iterationNode: NodeTracing, + childrenNodes: NodeTracing[], +): NodeTracing { const details: NodeTracing[][] = [] let lastResolvedIndex = -1 childrenNodes.forEach((item) => { - if (!item.execution_metadata) - return + if (!item.execution_metadata) return const { iteration_index } = item.execution_metadata let runIndex: number if (iteration_index !== undefined) { runIndex = iteration_index - } - else if (lastResolvedIndex >= 0) { + } else if (lastResolvedIndex >= 0) { const currentGroup = details[lastResolvedIndex] || [] - const seenSameNodeInCurrentGroup = currentGroup.some(node => node.node_id === item.node_id) + const seenSameNodeInCurrentGroup = currentGroup.some((node) => node.node_id === item.node_id) runIndex = seenSameNodeInCurrentGroup ? lastResolvedIndex + 1 : lastResolvedIndex - } - else { + } else { runIndex = 0 } - if (!details[runIndex]) - details[runIndex] = [] + if (!details[runIndex]) details[runIndex] = [] details[runIndex]!.push(item) lastResolvedIndex = runIndex diff --git a/web/app/components/workflow/run/utils/format-log/loop/__tests__/index.spec.ts b/web/app/components/workflow/run/utils/format-log/loop/__tests__/index.spec.ts index eaaac33b6fcdb2..e9d056c94ca0f0 100644 --- a/web/app/components/workflow/run/utils/format-log/loop/__tests__/index.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/loop/__tests__/index.spec.ts @@ -3,17 +3,20 @@ import { addChildrenToLoopNode } from '..' type ExecutionMetadata = NonNullable<NodeTracing['execution_metadata']> -const createExecutionMetadata = (overrides: Partial<ExecutionMetadata> = {}): ExecutionMetadata => ({ +const createExecutionMetadata = ( + overrides: Partial<ExecutionMetadata> = {}, +): ExecutionMetadata => ({ total_tokens: 0, total_price: 0, currency: 'USD', ...overrides, }) -const createTrace = (nodeId: string, executionMetadata?: Partial<ExecutionMetadata>): NodeTracing => ({ - node_id: nodeId, - execution_metadata: createExecutionMetadata(executionMetadata), -} as NodeTracing) +const createTrace = (nodeId: string, executionMetadata?: Partial<ExecutionMetadata>): NodeTracing => + ({ + node_id: nodeId, + execution_metadata: createExecutionMetadata(executionMetadata), + }) as NodeTracing describe('loop format log', () => { it('should place the first child of a new loop at a new record when its index is missing', () => { @@ -23,10 +26,7 @@ describe('loop format log', () => { const result = addChildrenToLoopNode(parent, [child, streamingChild]) - expect(result.details).toEqual([ - [child], - [streamingChild], - ]) + expect(result.details).toEqual([[child], [streamingChild]]) }) it('should keep missing loop index items in the current record when the node has not restarted', () => { @@ -37,10 +37,7 @@ describe('loop format log', () => { const result = addChildrenToLoopNode(parent, [firstRunChild, secondRunChild, streamingChild]) - expect(result.details).toEqual([ - [firstRunChild], - [secondRunChild, streamingChild], - ]) + expect(result.details).toEqual([[firstRunChild], [secondRunChild, streamingChild]]) }) it('should group loop children by parallel run id before loop index', () => { @@ -58,9 +55,6 @@ describe('loop format log', () => { const result = addChildrenToLoopNode(parent, [firstParallelChild, secondParallelChild]) - expect(result.details).toEqual([ - [firstParallelChild], - [secondParallelChild], - ]) + expect(result.details).toEqual([[firstParallelChild], [secondParallelChild]]) }) }) diff --git a/web/app/components/workflow/run/utils/format-log/loop/index.ts b/web/app/components/workflow/run/utils/format-log/loop/index.ts index 60b788462cca99..7244e1acffec66 100644 --- a/web/app/components/workflow/run/utils/format-log/loop/index.ts +++ b/web/app/components/workflow/run/utils/format-log/loop/index.ts @@ -1,14 +1,16 @@ import type { NodeTracing } from '@/types/workflow' -export function addChildrenToLoopNode(loopNode: NodeTracing, childrenNodes: NodeTracing[]): NodeTracing { +export function addChildrenToLoopNode( + loopNode: NodeTracing, + childrenNodes: NodeTracing[], +): NodeTracing { const detailsByKey = new Map<string, NodeTracing[]>() let lastResolvedIndex = -1 const order: string[] = [] const ensureGroup = (key: string) => { const group = detailsByKey.get(key) - if (group) - return group + if (group) return group const newGroup: NodeTracing[] = [] detailsByKey.set(key, newGroup) @@ -17,32 +19,27 @@ export function addChildrenToLoopNode(loopNode: NodeTracing, childrenNodes: Node } childrenNodes.forEach((item) => { - if (!item.execution_metadata) - return + if (!item.execution_metadata) return const { parallel_mode_run_id, loop_index } = item.execution_metadata let runIndex: number | string if (parallel_mode_run_id !== undefined) { runIndex = parallel_mode_run_id - } - else if (loop_index !== undefined) { + } else if (loop_index !== undefined) { runIndex = loop_index - } - else if (lastResolvedIndex >= 0) { + } else if (lastResolvedIndex >= 0) { const currentGroup = detailsByKey.get(String(lastResolvedIndex)) || [] - const seenSameNodeInCurrentGroup = currentGroup.some(node => node.node_id === item.node_id) + const seenSameNodeInCurrentGroup = currentGroup.some((node) => node.node_id === item.node_id) runIndex = seenSameNodeInCurrentGroup ? lastResolvedIndex + 1 : lastResolvedIndex - } - else { + } else { runIndex = 0 } ensureGroup(String(runIndex)).push(item) - if (typeof runIndex === 'number') - lastResolvedIndex = runIndex + if (typeof runIndex === 'number') lastResolvedIndex = runIndex }) return { ...loopNode, - details: order.map(key => detailsByKey.get(key) || []), + details: order.map((key) => detailsByKey.get(key) || []), } } diff --git a/web/app/components/workflow/run/utils/format-log/parallel/__tests__/index.spec.ts b/web/app/components/workflow/run/utils/format-log/parallel/__tests__/index.spec.ts index a30f6bde260523..c2875d47b78d09 100644 --- a/web/app/components/workflow/run/utils/format-log/parallel/__tests__/index.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/parallel/__tests__/index.spec.ts @@ -4,10 +4,8 @@ import { withSelectorKey } from '@/test/i18n-mock' import formatParallel from '../index' const t = withSelectorKey((key: string, options?: Record<string, string>) => { - if (key === 'common.parallel') - return 'Parallel' - if (key === 'common.branch') - return 'Branch' + if (key === 'common.parallel') return 'Parallel' + if (key === 'common.branch') return 'Branch' return options?.ns ? `${options.ns}.${key}` : key }, 'workflow') @@ -48,37 +46,40 @@ const createNodeTracing = (overrides: Partial<NodeTracing> = {}): NodeTracing => describe('formatParallel', () => { it('groups parallel nodes into a titled tree and preserves branch titles', () => { - const result = formatParallel([ - createNodeTracing({ - id: 'parallel-start', - node_id: 'parallel-start', - title: 'Parallel Start', - parallel_id: 'parallel-1', - parallel_start_node_id: 'parallel-start', - parallelDetail: { - isParallelStartNode: true, - children: [], - }, - }), - createNodeTracing({ - id: 'branch-a', - node_id: 'branch-a', - title: 'Branch A', - parallel_id: 'parallel-1', - parallel_start_node_id: 'branch-a', - }), - createNodeTracing({ - id: 'branch-child', - node_id: 'branch-child', - title: 'Branch Child', - parallel_id: 'parallel-1', - parallel_start_node_id: 'branch-a', - }), - ], t) + const result = formatParallel( + [ + createNodeTracing({ + id: 'parallel-start', + node_id: 'parallel-start', + title: 'Parallel Start', + parallel_id: 'parallel-1', + parallel_start_node_id: 'parallel-start', + parallelDetail: { + isParallelStartNode: true, + children: [], + }, + }), + createNodeTracing({ + id: 'branch-a', + node_id: 'branch-a', + title: 'Branch A', + parallel_id: 'parallel-1', + parallel_start_node_id: 'branch-a', + }), + createNodeTracing({ + id: 'branch-child', + node_id: 'branch-child', + title: 'Branch Child', + parallel_id: 'parallel-1', + parallel_start_node_id: 'branch-a', + }), + ], + t, + ) expect(result.length).toBeGreaterThan(0) - expect(result.some(node => !!node.parallelDetail)).toBe(true) - expect(result.some(node => node.parallelDetail?.children?.length)).toBe(true) + expect(result.some((node) => !!node.parallelDetail)).toBe(true) + expect(result.some((node) => node.parallelDetail?.children?.length)).toBe(true) }) it('ignores nodes outside parallel groups', () => { diff --git a/web/app/components/workflow/run/utils/format-log/parallel/index.ts b/web/app/components/workflow/run/utils/format-log/parallel/index.ts index bb05e90e693e9b..0ed7b8ceeff1cf 100644 --- a/web/app/components/workflow/run/utils/format-log/parallel/index.ts +++ b/web/app/components/workflow/run/utils/format-log/parallel/index.ts @@ -20,8 +20,7 @@ const translateWorkflowString = <const Selector extends SelectorParam<'workflow' function findLastIndex<T>(list: T[], predicate: (item: T) => boolean): number { for (let index = list.length - 1; index >= 0; index--) { - if (predicate(list[index]!)) - return index + if (predicate(list[index]!)) return index } return -1 @@ -37,32 +36,39 @@ function printNodeStructure(node: NodeTracing, depth: number) { } } -function addTitle({ - list, - depth, - belongParallelIndexInfo, -}: { - list: NodeTracing[] - depth: number - belongParallelIndexInfo?: string -}, t: WorkflowTranslate) { +function addTitle( + { + list, + depth, + belongParallelIndexInfo, + }: { + list: NodeTracing[] + depth: number + belongParallelIndexInfo?: string + }, + t: WorkflowTranslate, +) { let branchIndex = 0 - const hasMoreThanOneParallel = list.filter(node => node.parallelDetail?.isParallelStartNode).length > 1 + const hasMoreThanOneParallel = + list.filter((node) => node.parallelDetail?.isParallelStartNode).length > 1 list.forEach((node) => { const parallel_id = node.parallel_id ?? node.execution_metadata?.parallel_id ?? null - const parallel_start_node_id = node.parallel_start_node_id ?? node.execution_metadata?.parallel_start_node_id ?? null + const parallel_start_node_id = + node.parallel_start_node_id ?? node.execution_metadata?.parallel_start_node_id ?? null const isNotInParallel = !parallel_id || node.node_type === BlockEnum.End - if (isNotInParallel) - return + if (isNotInParallel) return const isParallelStartNode = node.parallelDetail?.isParallelStartNode const parallelIndexLetter = (() => { - if (!isParallelStartNode || !hasMoreThanOneParallel) - return '' + if (!isParallelStartNode || !hasMoreThanOneParallel) return '' - const index = 1 + list.filter(node => node.parallelDetail?.isParallelStartNode).findIndex(item => item.node_id === node.node_id) + const index = + 1 + + list + .filter((node) => node.parallelDetail?.isParallelStartNode) + .findIndex((item) => item.node_id === node.node_id) return String.fromCharCode(64 + index) })() @@ -70,7 +76,7 @@ function addTitle({ if (isParallelStartNode) { node.parallelDetail!.isParallelStartNode = true - node.parallelDetail!.parallelTitle = `${translateWorkflowString(t, $ => $['common.parallel'])}-${parallelIndexInfo}` + node.parallelDetail!.parallelTitle = `${translateWorkflowString(t, ($) => $['common.parallel'])}-${parallelIndexInfo}` } const isBrachStartNode = parallel_start_node_id === node.node_id @@ -83,35 +89,42 @@ function addTitle({ } } - node.parallelDetail!.branchTitle = `${translateWorkflowString(t, $ => $['common.branch'])}-${belongParallelIndexInfo}-${branchLetter}` + node.parallelDetail!.branchTitle = `${translateWorkflowString(t, ($) => $['common.branch'])}-${belongParallelIndexInfo}-${branchLetter}` } if (node.parallelDetail?.children && node.parallelDetail.children.length > 0) { - addTitle({ - list: node.parallelDetail.children, - depth: depth + 1, - belongParallelIndexInfo: parallelIndexInfo, - }, t) + addTitle( + { + list: node.parallelDetail.children, + depth: depth + 1, + belongParallelIndexInfo: parallelIndexInfo, + }, + t, + ) } }) } // list => group by parallel_id(parallel tree). const format = (list: NodeTracing[], t: WorkflowTranslate, isPrint?: boolean): NodeTracing[] => { - if (isPrint) - console.log(list) + if (isPrint) console.log(list) const result: NodeTracing[] = [...list] // list to tree by parent_parallel_start_node_id and branch by parallel_start_node_id. Each parallel may has more than one branch. result.forEach((node) => { const parallel_id = node.parallel_id ?? node.execution_metadata?.parallel_id ?? null - const parallel_start_node_id = node.parallel_start_node_id ?? node.execution_metadata?.parallel_start_node_id ?? null - const parent_parallel_id = node.parent_parallel_id ?? node.execution_metadata?.parent_parallel_id ?? null - const branchStartNodeId = node.parallel_start_node_id ?? node.execution_metadata?.parallel_start_node_id ?? null - const parentParallelBranchStartNodeId = node.parent_parallel_start_node_id ?? node.execution_metadata?.parent_parallel_start_node_id ?? null + const parallel_start_node_id = + node.parallel_start_node_id ?? node.execution_metadata?.parallel_start_node_id ?? null + const parent_parallel_id = + node.parent_parallel_id ?? node.execution_metadata?.parent_parallel_id ?? null + const branchStartNodeId = + node.parallel_start_node_id ?? node.execution_metadata?.parallel_start_node_id ?? null + const parentParallelBranchStartNodeId = + node.parent_parallel_start_node_id ?? + node.execution_metadata?.parent_parallel_start_node_id ?? + null const isNotInParallel = !parallel_id || node.node_type === BlockEnum.End - if (isNotInParallel) - return + if (isNotInParallel) return const isParallelStartNode = parallel_start_node_id === node.node_id // in the same parallel has more than one start node if (isParallelStartNode) { @@ -121,10 +134,11 @@ const format = (list: NodeTracing[], t: WorkflowTranslate, isPrint?: boolean): N children: [selfNode], } const isRootLevel = !parent_parallel_id - if (isRootLevel) - return + if (isRootLevel) return - const parentParallelStartNode = result.find(item => item.node_id === parentParallelBranchStartNodeId) + const parentParallelStartNode = result.find( + (item) => item.node_id === parentParallelBranchStartNodeId, + ) // append to parent parallel start node and after the same branch if (parentParallelStartNode) { if (!parentParallelStartNode?.parallelDetail) { @@ -133,31 +147,48 @@ const format = (list: NodeTracing[], t: WorkflowTranslate, isPrint?: boolean): N } } if (parentParallelStartNode!.parallelDetail.children) { - const sameBranchNodesLastIndex = findLastIndex(parentParallelStartNode.parallelDetail.children, (node) => { - const currStartNodeId = node.parallel_start_node_id ?? node.execution_metadata?.parallel_start_node_id ?? null - return currStartNodeId === parentParallelBranchStartNodeId - }) + const sameBranchNodesLastIndex = findLastIndex( + parentParallelStartNode.parallelDetail.children, + (node) => { + const currStartNodeId = + node.parallel_start_node_id ?? + node.execution_metadata?.parallel_start_node_id ?? + null + return currStartNodeId === parentParallelBranchStartNodeId + }, + ) if (sameBranchNodesLastIndex !== -1) - parentParallelStartNode!.parallelDetail.children.splice(sameBranchNodesLastIndex + 1, 0, node) - else - parentParallelStartNode!.parallelDetail.children.push(node) + parentParallelStartNode!.parallelDetail.children.splice( + sameBranchNodesLastIndex + 1, + 0, + node, + ) + else parentParallelStartNode!.parallelDetail.children.push(node) } } return } // append to parallel start node and after the same branch - const parallelStartNode = result.find(item => parallel_start_node_id === item.node_id) - - if (parallelStartNode && parallelStartNode.parallelDetail && parallelStartNode!.parallelDetail!.children) { - const sameBranchNodesLastIndex = findLastIndex(parallelStartNode.parallelDetail.children, (node) => { - const currStartNodeId = node.parallel_start_node_id ?? node.execution_metadata?.parallel_start_node_id ?? null - return currStartNodeId === branchStartNodeId - }) + const parallelStartNode = result.find((item) => parallel_start_node_id === item.node_id) + + if ( + parallelStartNode && + parallelStartNode.parallelDetail && + parallelStartNode!.parallelDetail!.children + ) { + const sameBranchNodesLastIndex = findLastIndex( + parallelStartNode.parallelDetail.children, + (node) => { + const currStartNodeId = + node.parallel_start_node_id ?? node.execution_metadata?.parallel_start_node_id ?? null + return currStartNodeId === branchStartNodeId + }, + ) if (sameBranchNodesLastIndex !== -1) { parallelStartNode.parallelDetail.children.splice(sameBranchNodesLastIndex + 1, 0, node) - } - else { // new branch + } else { + // new branch parallelStartNode.parallelDetail.children.push(node) } } @@ -167,18 +198,16 @@ const format = (list: NodeTracing[], t: WorkflowTranslate, isPrint?: boolean): N const filteredInParallelSubNodes = result.filter((node) => { const parallel_id = node.parallel_id ?? node.execution_metadata?.parallel_id ?? null const isNotInParallel = !parallel_id || node.node_type === BlockEnum.End - if (isNotInParallel) - return true + if (isNotInParallel) return true - const parent_parallel_id = node.parent_parallel_id ?? node.execution_metadata?.parent_parallel_id ?? null + const parent_parallel_id = + node.parent_parallel_id ?? node.execution_metadata?.parent_parallel_id ?? null - if (parent_parallel_id) - return false + if (parent_parallel_id) return false const isParallelStartNode = node.parallelDetail?.isParallelStartNode - if (!isParallelStartNode) - return false + if (!isParallelStartNode) return false return true }) @@ -193,10 +222,13 @@ const format = (list: NodeTracing[], t: WorkflowTranslate, isPrint?: boolean): N }) } - addTitle({ - list: filteredInParallelSubNodes, - depth: 1, - }, t) + addTitle( + { + list: filteredInParallelSubNodes, + depth: 1, + }, + t, + ) return filteredInParallelSubNodes } diff --git a/web/app/components/workflow/run/utils/format-log/retry/__tests__/index.spec.ts b/web/app/components/workflow/run/utils/format-log/retry/__tests__/index.spec.ts index 86d696228f647f..96de038309afb5 100644 --- a/web/app/components/workflow/run/utils/format-log/retry/__tests__/index.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/retry/__tests__/index.spec.ts @@ -3,16 +3,20 @@ import format from '..' type ExecutionMetadata = NonNullable<NodeTracing['execution_metadata']> -const createExecutionMetadata = (overrides: Partial<ExecutionMetadata> = {}): ExecutionMetadata => ({ +const createExecutionMetadata = ( + overrides: Partial<ExecutionMetadata> = {}, +): ExecutionMetadata => ({ total_tokens: 0, total_price: 0, currency: 'USD', ...overrides, }) -const createTrace = (overrides: Omit<Partial<NodeTracing>, 'execution_metadata'> & { - execution_metadata?: Partial<ExecutionMetadata> -}): NodeTracing => { +const createTrace = ( + overrides: Omit<Partial<NodeTracing>, 'execution_metadata'> & { + execution_metadata?: Partial<ExecutionMetadata> + }, +): NodeTracing => { const { execution_metadata, ...rest } = overrides return { diff --git a/web/app/components/workflow/run/utils/format-log/retry/index.ts b/web/app/components/workflow/run/utils/format-log/retry/index.ts index 5226f797991526..876c2db937e224 100644 --- a/web/app/components/workflow/run/utils/format-log/retry/index.ts +++ b/web/app/components/workflow/run/utils/format-log/retry/index.ts @@ -5,38 +5,45 @@ const format = (list: NodeTracing[]): NodeTracing[] => { return item.status === 'retry' }) - const retryNodeIds = retryNodes.map(item => item.node_id) + const retryNodeIds = retryNodes.map((item) => item.node_id) // move retry nodes to retryDetail - const result = list.filter((item) => { - return item.status !== 'retry' - }).map((item) => { - const { execution_metadata } = item - const isInIteration = !!execution_metadata?.iteration_id - const isInLoop = !!execution_metadata?.loop_id - const nodeId = item.node_id - const isRetryBelongNode = retryNodeIds.includes(nodeId) + const result = list + .filter((item) => { + return item.status !== 'retry' + }) + .map((item) => { + const { execution_metadata } = item + const isInIteration = !!execution_metadata?.iteration_id + const isInLoop = !!execution_metadata?.loop_id + const nodeId = item.node_id + const isRetryBelongNode = retryNodeIds.includes(nodeId) - if (isRetryBelongNode) { - return { - ...item, - retryDetail: retryNodes.filter((node) => { - if (!isInIteration && !isInLoop) - return node.node_id === nodeId + if (isRetryBelongNode) { + return { + ...item, + retryDetail: retryNodes.filter((node) => { + if (!isInIteration && !isInLoop) return node.node_id === nodeId - // retry node in iteration - if (isInIteration) - return node.node_id === nodeId && node.execution_metadata?.iteration_index === execution_metadata?.iteration_index + // retry node in iteration + if (isInIteration) + return ( + node.node_id === nodeId && + node.execution_metadata?.iteration_index === execution_metadata?.iteration_index + ) - // retry node in loop - if (isInLoop) - return node.node_id === nodeId && node.execution_metadata?.loop_index === execution_metadata?.loop_index + // retry node in loop + if (isInLoop) + return ( + node.node_id === nodeId && + node.execution_metadata?.loop_index === execution_metadata?.loop_index + ) - return false - }), + return false + }), + } } - } - return item - }) + return item + }) return result } diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 4510f69b25a67c..8c205b1dd08a40 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -9,9 +9,7 @@ import { } from '@langgenius/dify-ui/context-menu' import { produce } from 'immer' import { useAtomValue } from 'jotai' -import { - useCallback, -} from 'react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useReactFlowStore } from 'reactflow' import { useCreateSnippetFromSelection } from '@/app/components/snippets/hooks/use-create-snippet-from-selection' @@ -61,18 +59,44 @@ const menuSections: MenuSection[] = [ titleKey: 'operator.vertical', items: [ { alignType: AlignType.Top, icon: 'i-ri-align-top', translationKey: 'operator.alignTop' }, - { alignType: AlignType.Middle, icon: 'i-ri-align-center', iconClassName: 'rotate-90', translationKey: 'operator.alignMiddle' }, - { alignType: AlignType.Bottom, icon: 'i-ri-align-bottom', translationKey: 'operator.alignBottom' }, - { alignType: AlignType.DistributeVertical, icon: 'i-ri-align-justify', iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' }, + { + alignType: AlignType.Middle, + icon: 'i-ri-align-center', + iconClassName: 'rotate-90', + translationKey: 'operator.alignMiddle', + }, + { + alignType: AlignType.Bottom, + icon: 'i-ri-align-bottom', + translationKey: 'operator.alignBottom', + }, + { + alignType: AlignType.DistributeVertical, + icon: 'i-ri-align-justify', + iconClassName: 'rotate-90', + translationKey: 'operator.distributeVertical', + }, ], }, { titleKey: 'operator.horizontal', items: [ { alignType: AlignType.Left, icon: 'i-ri-align-left', translationKey: 'operator.alignLeft' }, - { alignType: AlignType.Center, icon: 'i-ri-align-center', translationKey: 'operator.alignCenter' }, - { alignType: AlignType.Right, icon: 'i-ri-align-right', translationKey: 'operator.alignRight' }, - { alignType: AlignType.DistributeHorizontal, icon: 'i-ri-align-justify', translationKey: 'operator.distributeHorizontal' }, + { + alignType: AlignType.Center, + icon: 'i-ri-align-center', + translationKey: 'operator.alignCenter', + }, + { + alignType: AlignType.Right, + icon: 'i-ri-align-right', + translationKey: 'operator.alignRight', + }, + { + alignType: AlignType.DistributeHorizontal, + icon: 'i-ri-align-justify', + translationKey: 'operator.distributeHorizontal', + }, ], }, ] @@ -86,42 +110,43 @@ const unsupportedSnippetNodeTypes = new Set([ ]) const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => { - const selectedNodeIds = new Set(selectedNodes.map(node => node.id)) + const selectedNodeIds = new Set(selectedNodes.map((node) => node.id)) const childNodeIds = new Set<string>() nodes.forEach((node) => { - if (!node.data._children?.length || !selectedNodeIds.has(node.id)) - return + if (!node.data._children?.length || !selectedNodeIds.has(node.id)) return node.data._children.forEach((child) => { childNodeIds.add(child.nodeId) }) }) - return nodes.filter(node => selectedNodeIds.has(node.id) && !childNodeIds.has(node.id)) + return nodes.filter((node) => selectedNodeIds.has(node.id) && !childNodeIds.has(node.id)) } const getAlignBounds = (nodes: Node[]): AlignBounds | null => { - const validNodes = nodes.filter(node => node.width && node.height) - if (validNodes.length <= 1) - return null - - return validNodes.reduce<AlignBounds>((bounds, node) => { - const width = node.width! - const height = node.height! - - return { - minX: Math.min(bounds.minX, node.position.x), - maxX: Math.max(bounds.maxX, node.position.x + width), - minY: Math.min(bounds.minY, node.position.y), - maxY: Math.max(bounds.maxY, node.position.y + height), - } - }, { - minX: Number.MAX_SAFE_INTEGER, - maxX: Number.MIN_SAFE_INTEGER, - minY: Number.MAX_SAFE_INTEGER, - maxY: Number.MIN_SAFE_INTEGER, - }) + const validNodes = nodes.filter((node) => node.width && node.height) + if (validNodes.length <= 1) return null + + return validNodes.reduce<AlignBounds>( + (bounds, node) => { + const width = node.width! + const height = node.height! + + return { + minX: Math.min(bounds.minX, node.position.x), + maxX: Math.max(bounds.maxX, node.position.x + width), + minY: Math.min(bounds.minY, node.position.y), + maxY: Math.max(bounds.maxY, node.position.y + height), + } + }, + { + minX: Number.MAX_SAFE_INTEGER, + maxX: Number.MIN_SAFE_INTEGER, + minY: Number.MAX_SAFE_INTEGER, + maxY: Number.MIN_SAFE_INTEGER, + }, + ) } const alignNodePosition = ( @@ -136,56 +161,46 @@ const alignNodePosition = ( switch (alignType) { case AlignType.Left: currentNode.position.x = bounds.minX - if (currentNode.positionAbsolute) - currentNode.positionAbsolute.x = bounds.minX + if (currentNode.positionAbsolute) currentNode.positionAbsolute.x = bounds.minX break case AlignType.Center: { const centerX = bounds.minX + (bounds.maxX - bounds.minX) / 2 - width / 2 currentNode.position.x = centerX - if (currentNode.positionAbsolute) - currentNode.positionAbsolute.x = centerX + if (currentNode.positionAbsolute) currentNode.positionAbsolute.x = centerX break } case AlignType.Right: { const rightX = bounds.maxX - width currentNode.position.x = rightX - if (currentNode.positionAbsolute) - currentNode.positionAbsolute.x = rightX + if (currentNode.positionAbsolute) currentNode.positionAbsolute.x = rightX break } case AlignType.Top: currentNode.position.y = bounds.minY - if (currentNode.positionAbsolute) - currentNode.positionAbsolute.y = bounds.minY + if (currentNode.positionAbsolute) currentNode.positionAbsolute.y = bounds.minY break case AlignType.Middle: { const middleY = bounds.minY + (bounds.maxY - bounds.minY) / 2 - height / 2 currentNode.position.y = middleY - if (currentNode.positionAbsolute) - currentNode.positionAbsolute.y = middleY + if (currentNode.positionAbsolute) currentNode.positionAbsolute.y = middleY break } case AlignType.Bottom: { const bottomY = Math.round(bounds.maxY - height) currentNode.position.y = bottomY - if (currentNode.positionAbsolute) - currentNode.positionAbsolute.y = bottomY + if (currentNode.positionAbsolute) currentNode.positionAbsolute.y = bottomY break } } } -const distributeNodes = ( - nodesToAlign: Node[], - nodes: Node[], - alignType: AlignTypeValue, -) => { +const distributeNodes = (nodesToAlign: Node[], nodes: Node[], alignType: AlignTypeValue) => { const isHorizontal = alignType === AlignType.DistributeHorizontal const sortedNodes = [...nodesToAlign].sort((a, b) => - isHorizontal ? a.position.x - b.position.x : a.position.y - b.position.y) + isHorizontal ? a.position.x - b.position.x : a.position.y - b.position.y, + ) - if (sortedNodes.length < 3) - return null + if (sortedNodes.length < 3) return null const firstNode = sortedNodes[0] const lastNode = sortedNodes[sortedNodes.length - 1] @@ -194,12 +209,13 @@ const distributeNodes = ( ? lastNode!.position.x + (lastNode!.width || 0) - firstNode!.position.x : lastNode!.position.y + (lastNode!.height || 0) - firstNode!.position.y - const fixedSpace = sortedNodes.reduce((sum, node) => - sum + (isHorizontal ? (node.width || 0) : (node.height || 0)), 0) + const fixedSpace = sortedNodes.reduce( + (sum, node) => sum + (isHorizontal ? node.width || 0 : node.height || 0), + 0, + ) const spacing = (totalGap - fixedSpace) / (sortedNodes.length - 1) - if (spacing <= 0) - return null + if (spacing <= 0) return null return produce(nodes, (draft) => { let currentPosition = isHorizontal @@ -208,61 +224,51 @@ const distributeNodes = ( for (let index = 1; index < sortedNodes.length - 1; index++) { const nodeToAlign = sortedNodes[index] - const currentNode = draft.find(node => node.id === nodeToAlign!.id) - if (!currentNode) - continue + const currentNode = draft.find((node) => node.id === nodeToAlign!.id) + if (!currentNode) continue if (isHorizontal) { const nextX = currentPosition + spacing currentNode.position.x = nextX - if (currentNode.positionAbsolute) - currentNode.positionAbsolute.x = nextX + if (currentNode.positionAbsolute) currentNode.positionAbsolute.x = nextX currentPosition = nextX + (nodeToAlign!.width || 0) - } - else { + } else { const nextY = currentPosition + spacing currentNode.position.y = nextY - if (currentNode.positionAbsolute) - currentNode.positionAbsolute.y = nextY + if (currentNode.positionAbsolute) currentNode.positionAbsolute.y = nextY currentPosition = nextY + (nodeToAlign!.height || 0) } } }) } -export function SelectionContextmenu({ - onClose, -}: { - onClose: () => void -}) { +export function SelectionContextmenu({ onClose }: { onClose: () => void }) { const { t } = useTranslation() const { getNodesReadOnly } = useNodesReadOnly() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions() - const isSelectionContextMenu = useStore(s => s.contextMenuTarget?.type === 'selection') + const isSelectionContextMenu = useStore((s) => s.contextMenuTarget?.type === 'selection') // Access React Flow methods const workflowStore = useWorkflowStore() const collaborativeWorkflow = useCollaborativeWorkflow() // Get selected nodes for alignment logic - const selectedNodes = useReactFlowStore(state => - state.getNodes().filter(node => node.selected), + const selectedNodes = useReactFlowStore((state) => + state.getNodes().filter((node) => node.selected), ) - const edges = useReactFlowStore(state => state.edges) + const edges = useReactFlowStore((state) => state.edges) const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { saveStateToHistory } = useWorkflowHistory() - const { - createSnippetDialog, - handleOpenCreateSnippet, - isCreateSnippetDialogOpen, - } = useCreateSnippetFromSelection({ - edges, - selectedNodes, - onClose, - }) - const canCreateSnippet = canCreateAndModifySnippets(workspacePermissionKeys) - && selectedNodes.every(node => !unsupportedSnippetNodeTypes.has(node.data.type)) + const { createSnippetDialog, handleOpenCreateSnippet, isCreateSnippetDialogOpen } = + useCreateSnippetFromSelection({ + edges, + selectedNodes, + onClose, + }) + const canCreateSnippet = + canCreateAndModifySnippets(workspacePermissionKeys) && + selectedNodes.every((node) => !unsupportedSnippetNodeTypes.has(node.data.type)) const handleCopyNodes = useCallback(() => { handleNodesCopy() @@ -279,97 +285,109 @@ export function SelectionContextmenu({ onClose() }, [handleNodesDelete, onClose]) - const handleAlignNodes = useCallback((alignType: AlignTypeValue) => { - if (getNodesReadOnly() || selectedNodes.length <= 1) { - onClose() - return - } + const handleAlignNodes = useCallback( + (alignType: AlignTypeValue) => { + if (getNodesReadOnly() || selectedNodes.length <= 1) { + onClose() + return + } - workflowStore.setState({ nodeAnimation: false }) + workflowStore.setState({ nodeAnimation: false }) + + // Get all current nodes + const { nodes, setNodes } = collaborativeWorkflow.getState() + + // Get all selected nodes + const selectedNodeIds = selectedNodes.map((node) => node.id) + + // Find container nodes and their children + // Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently + // when the container is selected. This prevents child nodes from being moved outside their containers. + const childNodeIds = new Set<string>() + + nodes.forEach((node) => { + // Check if this is a container node (Iteration or Loop) + if (node.data._children && node.data._children.length > 0) { + // If container node is selected, add its children to the exclusion set + if (selectedNodeIds.includes(node.id)) { + // Add all its children to the childNodeIds set + node.data._children.forEach((child: { nodeId: string; nodeType: string }) => { + childNodeIds.add(child.nodeId) + }) + } + } + }) - // Get all current nodes - const { nodes, setNodes } = collaborativeWorkflow.getState() + // Filter out child nodes from the alignment operation + // Only align nodes that are selected AND are not children of container nodes + // This ensures container nodes can be aligned while their children stay in the same relative position + const nodesToAlign = getAlignableNodes(nodes, selectedNodes) - // Get all selected nodes - const selectedNodeIds = selectedNodes.map(node => node.id) + if (nodesToAlign.length <= 1) { + onClose() + return + } - // Find container nodes and their children - // Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently - // when the container is selected. This prevents child nodes from being moved outside their containers. - const childNodeIds = new Set<string>() + const bounds = getAlignBounds(nodesToAlign) + if (!bounds) { + onClose() + return + } - nodes.forEach((node) => { - // Check if this is a container node (Iteration or Loop) - if (node.data._children && node.data._children.length > 0) { - // If container node is selected, add its children to the exclusion set - if (selectedNodeIds.includes(node.id)) { - // Add all its children to the childNodeIds set - node.data._children.forEach((child: { nodeId: string, nodeType: string }) => { - childNodeIds.add(child.nodeId) - }) + if ( + alignType === AlignType.DistributeHorizontal || + alignType === AlignType.DistributeVertical + ) { + const distributedNodes = distributeNodes(nodesToAlign, nodes, alignType) + if (distributedNodes) { + setNodes(distributedNodes) + onClose() + + const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState() + setHelpLineHorizontal() + setHelpLineVertical() + + handleSyncWorkflowDraft() + saveStateToHistory(WorkflowHistoryEvent.NodeDragStop) + return } } - }) - // Filter out child nodes from the alignment operation - // Only align nodes that are selected AND are not children of container nodes - // This ensures container nodes can be aligned while their children stay in the same relative position - const nodesToAlign = getAlignableNodes(nodes, selectedNodes) + const newNodes = produce(nodes, (draft) => { + const validNodesToAlign = nodesToAlign.filter((node) => node.width && node.height) + validNodesToAlign.forEach((nodeToAlign) => { + const currentNode = draft.find((n) => n.id === nodeToAlign.id) + if (!currentNode) return - if (nodesToAlign.length <= 1) { - onClose() - return - } + alignNodePosition(currentNode, nodeToAlign, alignType, bounds) + }) + }) - const bounds = getAlignBounds(nodesToAlign) - if (!bounds) { - onClose() - return - } + try { + // Directly use setNodes to update nodes - consistent with handleNodeDrag + setNodes(newNodes) - if (alignType === AlignType.DistributeHorizontal || alignType === AlignType.DistributeVertical) { - const distributedNodes = distributeNodes(nodesToAlign, nodes, alignType) - if (distributedNodes) { - setNodes(distributedNodes) + // Close popup onClose() - const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState() setHelpLineHorizontal() setHelpLineVertical() - handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.NodeDragStop) - return + } catch (err) { + console.error('Failed to update nodes:', err) } - } - - const newNodes = produce(nodes, (draft) => { - const validNodesToAlign = nodesToAlign.filter(node => node.width && node.height) - validNodesToAlign.forEach((nodeToAlign) => { - const currentNode = draft.find(n => n.id === nodeToAlign.id) - if (!currentNode) - return - - alignNodePosition(currentNode, nodeToAlign, alignType, bounds) - }) - }) - - try { - // Directly use setNodes to update nodes - consistent with handleNodeDrag - setNodes(newNodes) - - // Close popup - onClose() - const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState() - setHelpLineHorizontal() - setHelpLineVertical() - handleSyncWorkflowDraft() - saveStateToHistory(WorkflowHistoryEvent.NodeDragStop) - } - catch (err) { - console.error('Failed to update nodes:', err) - } - }, [collaborativeWorkflow, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, onClose]) + }, + [ + collaborativeWorkflow, + workflowStore, + selectedNodes, + getNodesReadOnly, + handleSyncWorkflowDraft, + saveStateToHistory, + onClose, + ], + ) if (!isSelectionContextMenu || selectedNodes.length <= 1) return isCreateSnippetDialogOpen ? createSnippetDialog : null @@ -384,7 +402,12 @@ export function SelectionContextmenu({ className="px-3 text-text-secondary" onClick={handleOpenCreateSnippet} > - <span>{t($ => $['snippet.createDialogTitle'], { defaultValue: 'Create Snippet', ns: 'workflow' })}</span> + <span> + {t(($) => $['snippet.createDialogTitle'], { + defaultValue: 'Create Snippet', + ns: 'workflow', + })} + </span> </ContextMenuItem> </ContextMenuGroup> <ContextMenuSeparator /> @@ -395,14 +418,21 @@ export function SelectionContextmenu({ className="justify-between px-3 text-text-secondary" onClick={handleCopyNodes} > - <span>{t($ => $['common.copy'], { defaultValue: 'common.copy', ns: 'workflow' })}</span> + <span> + {t(($) => $['common.copy'], { defaultValue: 'common.copy', ns: 'workflow' })} + </span> <ShortcutKbd shortcut="workflow.copy" /> </ContextMenuItem> <ContextMenuItem className="justify-between px-3 text-text-secondary" onClick={handleDuplicateNodes} > - <span>{t($ => $['common.duplicate'], { defaultValue: 'common.duplicate', ns: 'workflow' })}</span> + <span> + {t(($) => $['common.duplicate'], { + defaultValue: 'common.duplicate', + ns: 'workflow', + })} + </span> <ShortcutKbd shortcut="workflow.duplicate" /> </ContextMenuItem> </ContextMenuGroup> @@ -412,7 +442,9 @@ export function SelectionContextmenu({ className="justify-between px-3 text-text-secondary data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive" onClick={handleDeleteNodes} > - <span>{t($ => $['operation.delete'], { defaultValue: 'operation.delete', ns: 'common' })}</span> + <span> + {t(($) => $['operation.delete'], { defaultValue: 'operation.delete', ns: 'common' })} + </span> <ShortcutKbd shortcut="workflow.delete" /> </ContextMenuItem> </ContextMenuGroup> @@ -421,7 +453,7 @@ export function SelectionContextmenu({ <ContextMenuGroup key={section.titleKey}> {sectionIndex > 0 && <ContextMenuSeparator />} <ContextMenuLabel> - {t($ => $[section.titleKey], { defaultValue: section.titleKey, ns: 'workflow' })} + {t(($) => $[section.titleKey], { defaultValue: section.titleKey, ns: 'workflow' })} </ContextMenuLabel> {section.items.map((item) => { return ( @@ -430,8 +462,14 @@ export function SelectionContextmenu({ data-testid={`selection-contextmenu-item-${item.alignType}`} onClick={() => handleAlignNodes(item.alignType)} > - <span aria-hidden className={`${item.icon} h-4 w-4 ${item.iconClassName ?? ''}`.trim()} /> - {t($ => $[item.translationKey], { defaultValue: item.translationKey, ns: 'workflow' })} + <span + aria-hidden + className={`${item.icon} h-4 w-4 ${item.iconClassName ?? ''}`.trim()} + /> + {t(($) => $[item.translationKey], { + defaultValue: item.translationKey, + ns: 'workflow', + })} </ContextMenuItem> ) })} diff --git a/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx b/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx index e8dc0be3c4f122..d71fa9c93947c7 100644 --- a/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx +++ b/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx @@ -34,9 +34,7 @@ describe('ShortcutKbd', () => { }) it('keeps single-key shortcuts in one keycap', () => { - const { container } = render( - <ShortcutKbd shortcut="workflow.delete" platform="windows" />, - ) + const { container } = render(<ShortcutKbd shortcut="workflow.delete" platform="windows" />) expect(container.querySelectorAll('kbd')).toHaveLength(1) expect(screen.getByText('⌦')).toBeInTheDocument() diff --git a/web/app/components/workflow/shortcuts/definitions.ts b/web/app/components/workflow/shortcuts/definitions.ts index cbba10def6ef59..1e0eb5a1b8990d 100644 --- a/web/app/components/workflow/shortcuts/definitions.ts +++ b/web/app/components/workflow/shortcuts/definitions.ts @@ -1,23 +1,23 @@ import type { RegisterableHotkey } from '@tanstack/react-hotkeys' -export type WorkflowCanvasShortcutId - = | 'workflow.delete' - | 'workflow.copy' - | 'workflow.paste' - | 'workflow.duplicate' - | 'workflow.undo' - | 'workflow.redo' - | 'workflow.pointer-mode' - | 'workflow.hand-mode' - | 'workflow.comment-mode' - | 'workflow.organize' - | 'workflow.zoom-to-fit' - | 'workflow.zoom-to-100' - | 'workflow.zoom-to-50' - | 'workflow.zoom-out' - | 'workflow.zoom-in' - | 'workflow.download-import-log' - | 'workflow.dim-other-nodes' +export type WorkflowCanvasShortcutId = + | 'workflow.delete' + | 'workflow.copy' + | 'workflow.paste' + | 'workflow.duplicate' + | 'workflow.undo' + | 'workflow.redo' + | 'workflow.pointer-mode' + | 'workflow.hand-mode' + | 'workflow.comment-mode' + | 'workflow.organize' + | 'workflow.zoom-to-fit' + | 'workflow.zoom-to-100' + | 'workflow.zoom-to-50' + | 'workflow.zoom-out' + | 'workflow.zoom-in' + | 'workflow.download-import-log' + | 'workflow.dim-other-nodes' export type WorkflowCanvasHotkeyMeta = { id: WorkflowCanvasShortcutId @@ -34,7 +34,10 @@ export type WorkflowCanvasShortcutDefinition = { description: string } -export const WORKFLOW_CANVAS_SHORTCUTS: Record<WorkflowCanvasShortcutId, WorkflowCanvasShortcutDefinition> = { +export const WORKFLOW_CANVAS_SHORTCUTS: Record< + WorkflowCanvasShortcutId, + WorkflowCanvasShortcutDefinition +> = { 'workflow.delete': { id: 'workflow.delete', hotkeys: ['Delete', 'Backspace'], @@ -143,7 +146,9 @@ export const WORKFLOW_CANVAS_SHORTCUTS: Record<WorkflowCanvasShortcutId, Workflo }, } -export const getWorkflowCanvasShortcutDisplayHotkey = (id: WorkflowCanvasShortcutId): RegisterableHotkey | (string & {}) => { +export const getWorkflowCanvasShortcutDisplayHotkey = ( + id: WorkflowCanvasShortcutId, +): RegisterableHotkey | (string & {}) => { const shortcut = WORKFLOW_CANVAS_SHORTCUTS[id] return shortcut.displayHotkey ?? shortcut.hotkeys[0]! } diff --git a/web/app/components/workflow/shortcuts/shortcut-kbd.tsx b/web/app/components/workflow/shortcuts/shortcut-kbd.tsx index b2a614ce7348f0..4e10285d94830c 100644 --- a/web/app/components/workflow/shortcuts/shortcut-kbd.tsx +++ b/web/app/components/workflow/shortcuts/shortcut-kbd.tsx @@ -21,13 +21,12 @@ const getDisplayKeys = ( ) => { const displayOptions = platform ? { platform } : undefined - if (typeof hotkey !== 'string') - return [formatForDisplay(hotkey, displayOptions)] + if (typeof hotkey !== 'string') return [formatForDisplay(hotkey, displayOptions)] return hotkey .split('+') .filter(Boolean) - .map(key => formatForDisplay(key, displayOptions)) + .map((key) => formatForDisplay(key, displayOptions)) } export const ShortcutKbd = ({ @@ -38,30 +37,24 @@ export const ShortcutKbd = ({ bgColor = 'gray', platform, }: ShortcutKbdProps) => { - const displayHotkey = hotkey ?? (shortcut ? getWorkflowCanvasShortcutDisplayHotkey(shortcut) : undefined) + const displayHotkey = + hotkey ?? (shortcut ? getWorkflowCanvasShortcutDisplayHotkey(shortcut) : undefined) - if (!displayHotkey) - return null + if (!displayHotkey) return null const displayKeys = getDisplayKeys(displayHotkey, platform) return ( - <KbdGroup - className={cn( - className, - )} - > - { - displayKeys.map(key => ( - <Kbd - key={key} - color={bgColor} - className={cn(textColor === 'secondary' && 'text-text-tertiary')} - > - {key} - </Kbd> - )) - } + <KbdGroup className={cn(className)}> + {displayKeys.map((key) => ( + <Kbd + key={key} + color={bgColor} + className={cn(textColor === 'secondary' && 'text-text-tertiary')} + > + {key} + </Kbd> + ))} </KbdGroup> ) } diff --git a/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts b/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts index 4feef3435749b6..1aa33fb0472c59 100644 --- a/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts +++ b/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts @@ -1,8 +1,4 @@ -import type { - HotkeyCallback, - UseHotkeyDefinition, - UseHotkeyOptions, -} from '@tanstack/react-hotkeys' +import type { HotkeyCallback, UseHotkeyDefinition, UseHotkeyOptions } from '@tanstack/react-hotkeys' import type { WorkflowCanvasHotkeyMeta, WorkflowCanvasShortcutDefinition } from './definitions' import { useHotkeys, useKeyHold } from '@tanstack/react-hotkeys' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -22,13 +18,14 @@ const workflowHotkeyOptions = { } satisfies UseHotkeyOptions const isInputLikeElement = (element: Element | null) => { - if (!element) - return false - - return element instanceof HTMLInputElement - || element instanceof HTMLTextAreaElement - || element instanceof HTMLSelectElement - || (element instanceof HTMLElement && element.isContentEditable) + if (!element) return false + + return ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement || + (element instanceof HTMLElement && element.isContentEditable) + ) } const toHotkeyDefinitions = ( @@ -36,7 +33,7 @@ const toHotkeyDefinitions = ( callback: HotkeyCallback, options?: UseHotkeyOptions, ): UseHotkeyDefinition[] => { - return shortcut.hotkeys.map(hotkey => ({ + return shortcut.hotkeys.map((hotkey) => ({ hotkey, callback, options: { @@ -64,22 +61,13 @@ export const useWorkflowHotkeys = (): void => { } = useNodesInteractions() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { handleEdgeDelete } = useEdgesInteractions() - const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel) - const historyShortcutsEnabled = useStore(s => s.historyShortcutsEnabled) - const { - handleModeHand, - handleModePointer, - handleModeComment, - isCommentModeAvailable, - } = useWorkflowMoveMode() + const showDebugAndPreviewPanel = useStore((s) => s.showDebugAndPreviewPanel) + const historyShortcutsEnabled = useStore((s) => s.historyShortcutsEnabled) + const { handleModeHand, handleModePointer, handleModeComment, isCommentModeAvailable } = + useWorkflowMoveMode() const { handleLayout } = useWorkflowOrganize() - const { - zoomTo, - getZoom, - fitView, - getNodes, - } = useReactFlow() + const { zoomTo, getZoom, fitView, getNodes } = useReactFlow() const isShiftHeld = useKeyHold('Shift') const shiftDimmedRef = useRef(false) const undimAllNodesRef = useRef(undimAllNodes) @@ -98,126 +86,143 @@ export const useWorkflowHotkeys = (): void => { }, [getZoom, zoomTo]) const shouldHandleCopy = useCallback(() => { - if (getNodes().some(node => node.data._isBundled)) - return true + if (getNodes().some((node) => node.data._isBundled)) return true const selection = document.getSelection() return !selection || selection.isCollapsed || !selection.rangeCount }, [getNodes]) - const handleCopy = useCallback<HotkeyCallback>((event) => { - if (!shouldHandleCopy()) - return + const handleCopy = useCallback<HotkeyCallback>( + (event) => { + if (!shouldHandleCopy()) return - event.preventDefault() - event.stopPropagation() - handleNodesCopy() - }, [handleNodesCopy, shouldHandleCopy]) - - const hotkeys = useMemo<UseHotkeyDefinition[]>(() => [ - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.delete'], () => { - handleNodesDelete() - handleEdgeDelete() - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.copy'], handleCopy, { - preventDefault: false, - stopPropagation: false, - enabled: !showDebugAndPreviewPanel, - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.paste'], () => { - handleNodesPaste() - }, { - enabled: !showDebugAndPreviewPanel, - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.duplicate'], () => { - handleNodesDuplicate() - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.undo'], () => { - handleHistoryBack() - }, { - enabled: !showDebugAndPreviewPanel && historyShortcutsEnabled, - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.redo'], () => { - handleHistoryForward() - }, { - enabled: !showDebugAndPreviewPanel && historyShortcutsEnabled, - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.hand-mode'], () => { - handleModeHand() - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.pointer-mode'], () => { - handleModePointer() - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.comment-mode'], () => { - handleModeComment() - }, { - enabled: isCommentModeAvailable, - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.organize'], () => { - handleLayout() - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.zoom-to-fit'], () => { - fitView() - handleSyncWorkflowDraft() - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.zoom-to-100'], () => { - zoomTo(1) - handleSyncWorkflowDraft() - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.zoom-to-50'], () => { - zoomTo(0.5) - handleSyncWorkflowDraft() - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.zoom-out'], () => { - constrainedZoomOut() - handleSyncWorkflowDraft() - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.zoom-in'], () => { - constrainedZoomIn() - handleSyncWorkflowDraft() - }), - ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.download-import-log'], () => { - collaborationManager.downloadGraphImportLog() - }), - ], [ - constrainedZoomIn, - constrainedZoomOut, - fitView, - handleCopy, - handleEdgeDelete, - handleHistoryBack, - handleHistoryForward, - handleLayout, - handleModeComment, - handleModeHand, - handleModePointer, - handleNodesDelete, - handleNodesDuplicate, - handleNodesPaste, - handleSyncWorkflowDraft, - historyShortcutsEnabled, - isCommentModeAvailable, - showDebugAndPreviewPanel, - zoomTo, - ]) + event.preventDefault() + event.stopPropagation() + handleNodesCopy() + }, + [handleNodesCopy, shouldHandleCopy], + ) + + const hotkeys = useMemo<UseHotkeyDefinition[]>( + () => [ + ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.delete'], () => { + handleNodesDelete() + handleEdgeDelete() + }), + ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.copy'], handleCopy, { + preventDefault: false, + stopPropagation: false, + enabled: !showDebugAndPreviewPanel, + }), + ...toHotkeyDefinitions( + WORKFLOW_CANVAS_SHORTCUTS['workflow.paste'], + () => { + handleNodesPaste() + }, + { + enabled: !showDebugAndPreviewPanel, + }, + ), + ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.duplicate'], () => { + handleNodesDuplicate() + }), + ...toHotkeyDefinitions( + WORKFLOW_CANVAS_SHORTCUTS['workflow.undo'], + () => { + handleHistoryBack() + }, + { + enabled: !showDebugAndPreviewPanel && historyShortcutsEnabled, + }, + ), + ...toHotkeyDefinitions( + WORKFLOW_CANVAS_SHORTCUTS['workflow.redo'], + () => { + handleHistoryForward() + }, + { + enabled: !showDebugAndPreviewPanel && historyShortcutsEnabled, + }, + ), + ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.hand-mode'], () => { + handleModeHand() + }), + ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.pointer-mode'], () => { + handleModePointer() + }), + ...toHotkeyDefinitions( + WORKFLOW_CANVAS_SHORTCUTS['workflow.comment-mode'], + () => { + handleModeComment() + }, + { + enabled: isCommentModeAvailable, + }, + ), + ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.organize'], () => { + handleLayout() + }), + ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.zoom-to-fit'], () => { + fitView() + handleSyncWorkflowDraft() + }), + ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.zoom-to-100'], () => { + zoomTo(1) + handleSyncWorkflowDraft() + }), + ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.zoom-to-50'], () => { + zoomTo(0.5) + handleSyncWorkflowDraft() + }), + ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.zoom-out'], () => { + constrainedZoomOut() + handleSyncWorkflowDraft() + }), + ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.zoom-in'], () => { + constrainedZoomIn() + handleSyncWorkflowDraft() + }), + ...toHotkeyDefinitions(WORKFLOW_CANVAS_SHORTCUTS['workflow.download-import-log'], () => { + collaborationManager.downloadGraphImportLog() + }), + ], + [ + constrainedZoomIn, + constrainedZoomOut, + fitView, + handleCopy, + handleEdgeDelete, + handleHistoryBack, + handleHistoryForward, + handleLayout, + handleModeComment, + handleModeHand, + handleModePointer, + handleNodesDelete, + handleNodesDuplicate, + handleNodesPaste, + handleSyncWorkflowDraft, + historyShortcutsEnabled, + isCommentModeAvailable, + showDebugAndPreviewPanel, + zoomTo, + ], + ) useHotkeys(hotkeys, workflowHotkeyOptions) useEffect(() => { if (isShiftHeld) { - if (shiftDimmedRef.current) - return + if (shiftDimmedRef.current) return - if (isInputLikeElement(document.activeElement)) - return + if (isInputLikeElement(document.activeElement)) return shiftDimmedRef.current = true dimOtherNodes() return } - if (!shiftDimmedRef.current) - return + if (!shiftDimmedRef.current) return shiftDimmedRef.current = false undimAllNodes() @@ -225,8 +230,7 @@ export const useWorkflowHotkeys = (): void => { useEffect(() => { return () => { - if (shiftDimmedRef.current) - undimAllNodesRef.current() + if (shiftDimmedRef.current) undimAllNodesRef.current() } }, []) } diff --git a/web/app/components/workflow/simple-node/__tests__/index.spec.tsx b/web/app/components/workflow/simple-node/__tests__/index.spec.tsx index 0cda2d120b85b4..bf0e4034e89739 100644 --- a/web/app/components/workflow/simple-node/__tests__/index.spec.tsx +++ b/web/app/components/workflow/simple-node/__tests__/index.spec.tsx @@ -38,12 +38,7 @@ describe('simple-node', () => { }) it('should render the block shell, target handle, and node control by default', () => { - render( - <SimpleNode - id="simple-node" - data={createData()} - />, - ) + render(<SimpleNode id="simple-node" data={createData()} />) expect(screen.getByText('Answer')).toBeInTheDocument() expect(screen.getByText('block-icon:answer')).toBeInTheDocument() @@ -119,7 +114,9 @@ describe('simple-node', () => { expect(screen.queryByText('node-handle:target')).not.toBeInTheDocument() expect(screen.queryByText('node-control:simple-node')).not.toBeInTheDocument() - expect(container.querySelector('.border-components-option-card-option-selected-border')).not.toBeNull() + expect( + container.querySelector('.border-components-option-card-option-selected-border'), + ).not.toBeNull() expect(container.querySelector('.opacity-70')).not.toBeNull() }) diff --git a/web/app/components/workflow/simple-node/index.tsx b/web/app/components/workflow/simple-node/index.tsx index 43345ba1414a08..64f6f44cc9f4fc 100644 --- a/web/app/components/workflow/simple-node/index.tsx +++ b/web/app/components/workflow/simple-node/index.tsx @@ -1,9 +1,5 @@ -import type { - FC, -} from 'react' -import type { - NodeProps, -} from '@/app/components/workflow/types' +import type { FC } from 'react' +import type { NodeProps } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { RiAlertFill, @@ -11,50 +7,38 @@ import { RiErrorWarningFill, RiLoader2Line, } from '@remixicon/react' -import { - memo, - useMemo, -} from 'react' +import { memo, useMemo } from 'react' import BlockIcon from '@/app/components/workflow/block-icon' -import { - useNodesReadOnly, -} from '@/app/components/workflow/hooks' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' import NodeControl from '@/app/components/workflow/nodes/_base/components/node-control' -import { - NodeTargetHandle, -} from '@/app/components/workflow/nodes/_base/components/node-handle' -import { - NodeRunningStatus, -} from '@/app/components/workflow/types' +import { NodeTargetHandle } from '@/app/components/workflow/nodes/_base/components/node-handle' +import { NodeRunningStatus } from '@/app/components/workflow/types' type SimpleNodeProps = NodeProps -const SimpleNode: FC<SimpleNodeProps> = ({ - id, - data, -}) => { +const SimpleNode: FC<SimpleNodeProps> = ({ id, data }) => { const { nodesReadOnly } = useNodesReadOnly() const showSelectedBorder = data.selected || data._isBundled || data._isEntering - const { - showRunningBorder, - showSuccessBorder, - showFailedBorder, - showExceptionBorder, - } = useMemo(() => { - return { - showRunningBorder: data._runningStatus === NodeRunningStatus.Running && !showSelectedBorder, - showSuccessBorder: data._runningStatus === NodeRunningStatus.Succeeded && !showSelectedBorder, - showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder, - showExceptionBorder: data._runningStatus === NodeRunningStatus.Exception && !showSelectedBorder, - } - }, [data._runningStatus, showSelectedBorder]) + const { showRunningBorder, showSuccessBorder, showFailedBorder, showExceptionBorder } = + useMemo(() => { + return { + showRunningBorder: data._runningStatus === NodeRunningStatus.Running && !showSelectedBorder, + showSuccessBorder: + data._runningStatus === NodeRunningStatus.Succeeded && !showSelectedBorder, + showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder, + showExceptionBorder: + data._runningStatus === NodeRunningStatus.Exception && !showSelectedBorder, + } + }, [data._runningStatus, showSelectedBorder]) return ( <div className={cn( 'flex rounded-2xl border-2', - showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent', + showSelectedBorder + ? 'border-components-option-card-option-selected-border' + : 'border-transparent', data._waitingRun && 'opacity-70', )} style={{ @@ -75,61 +59,38 @@ const SimpleNode: FC<SimpleNodeProps> = ({ data._isBundled && 'shadow-lg!', )} > - { - !data._isCandidate && ( - <NodeTargetHandle - id={id} - data={data} - handleClassName="top-4! -left-[9px]! translate-y-0!" - handleId="target" - /> - ) - } - { - !data._runningStatus && !nodesReadOnly && !data._isCandidate && ( - <NodeControl - id={id} - data={data} - /> - ) - } - <div className={cn( - 'flex items-center rounded-t-2xl px-3 pt-3 pb-2', - )} - > - <BlockIcon - className="mr-2 shrink-0" - type={data.type} - size="md" + {!data._isCandidate && ( + <NodeTargetHandle + id={id} + data={data} + handleClassName="top-4! -left-[9px]! translate-y-0!" + handleId="target" /> + )} + {!data._runningStatus && !nodesReadOnly && !data._isCandidate && ( + <NodeControl id={id} data={data} /> + )} + <div className={cn('flex items-center rounded-t-2xl px-3 pt-3 pb-2')}> + <BlockIcon className="mr-2 shrink-0" type={data.type} size="md" /> <div title={data.title} className="mr-1 flex grow items-center truncate system-sm-semibold-uppercase text-text-primary" > - <div> - {data.title} - </div> + <div>{data.title}</div> </div> - { - (data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running) && ( - <RiLoader2Line className="size-3.5 animate-spin text-text-accent" /> - ) - } - { - data._runningStatus === NodeRunningStatus.Succeeded && ( - <RiCheckboxCircleFill className="size-3.5 text-text-success" /> - ) - } - { - data._runningStatus === NodeRunningStatus.Failed && ( - <RiErrorWarningFill className="size-3.5 text-text-destructive" /> - ) - } - { - data._runningStatus === NodeRunningStatus.Exception && ( - <RiAlertFill className="size-3.5 text-text-warning-secondary" /> - ) - } + {(data._runningStatus === NodeRunningStatus.Running || + data._singleRunningStatus === NodeRunningStatus.Running) && ( + <RiLoader2Line className="size-3.5 animate-spin text-text-accent" /> + )} + {data._runningStatus === NodeRunningStatus.Succeeded && ( + <RiCheckboxCircleFill className="size-3.5 text-text-success" /> + )} + {data._runningStatus === NodeRunningStatus.Failed && ( + <RiErrorWarningFill className="size-3.5 text-text-destructive" /> + )} + {data._runningStatus === NodeRunningStatus.Exception && ( + <RiAlertFill className="size-3.5 text-text-warning-secondary" /> + )} </div> </div> </div> diff --git a/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts b/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts index 145b5d72fe21a4..03d6edbb05897c 100644 --- a/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts @@ -59,7 +59,9 @@ describe('Chat Variable Slice', () => { describe('setConversationVariables', () => { it('should update conversationVariables', () => { const store = createStore() - const vars: ConversationVariable[] = [{ id: 'cv1', name: 'history', value: [], value_type: ChatVarType.String, description: '' }] + const vars: ConversationVariable[] = [ + { id: 'cv1', name: 'history', value: [], value_type: ChatVarType.String, description: '' }, + ] store.getState().setConversationVariables(vars) expect(store.getState().conversationVariables).toEqual(vars) }) diff --git a/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts b/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts index a8e53e0b8b829d..091f170e9be1a1 100644 --- a/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts @@ -34,7 +34,9 @@ describe('Env Variable Slice', () => { describe('setEnvironmentVariables', () => { it('should update environmentVariables', () => { const store = createStore() - const vars: EnvironmentVariable[] = [{ id: 'v1', name: 'API_KEY', value: 'secret', value_type: 'string', description: '' }] + const vars: EnvironmentVariable[] = [ + { id: 'v1', name: 'API_KEY', value: 'secret', value_type: 'string', description: '' }, + ] store.getState().setEnvironmentVariables(vars) expect(store.getState().environmentVariables).toEqual(vars) }) diff --git a/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts b/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts index 5eaf34ebcc85d3..873f3118c689f8 100644 --- a/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts @@ -27,7 +27,11 @@ function makeVar(overrides: Partial<VarInInspect> = {}): VarInInspect { function makeNodeWithVar(nodeId: string, vars: VarInInspect[]): NodeWithVar { return { nodeId, - nodePayload: { title: `Node ${nodeId}`, desc: '', type: BlockEnum.Code } as NodeWithVar['nodePayload'], + nodePayload: { + title: `Node ${nodeId}`, + desc: '', + type: BlockEnum.Code, + } as NodeWithVar['nodePayload'], nodeType: BlockEnum.Code, title: `Node ${nodeId}`, vars, @@ -81,10 +85,12 @@ describe('Inspect Vars Slice', () => { describe('deleteNodeInspectVars', () => { it('should remove the matching node', () => { const store = createStore() - store.getState().setNodesWithInspectVars([ - makeNodeWithVar('n1', [makeVar()]), - makeNodeWithVar('n2', [makeVar()]), - ]) + store + .getState() + .setNodesWithInspectVars([ + makeNodeWithVar('n1', [makeVar()]), + makeNodeWithVar('n2', [makeVar()]), + ]) store.getState().deleteNodeInspectVars('n1') @@ -151,7 +157,9 @@ describe('Inspect Vars Slice', () => { it('should not change state when var is not found', () => { const store = createStore() - store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [makeVar({ id: 'v1', edited: true })])]) + store + .getState() + .setNodesWithInspectVars([makeNodeWithVar('n1', [makeVar({ id: 'v1', edited: true })])]) store.getState().resetToLastRunVar('n1', 'wrong-var', 'val') diff --git a/web/app/components/workflow/store/__tests__/plugin-dependency-store.spec.ts b/web/app/components/workflow/store/__tests__/plugin-dependency-store.spec.ts index 8c0cdd83378bec..17c62b1886fa20 100644 --- a/web/app/components/workflow/store/__tests__/plugin-dependency-store.spec.ts +++ b/web/app/components/workflow/store/__tests__/plugin-dependency-store.spec.ts @@ -24,8 +24,14 @@ describe('Plugin Dependency Store', () => { }) it('should replace existing dependencies', () => { - const dep1: Dependency = { type: 'marketplace', value: { plugin_unique_identifier: 'p1' } } as Dependency - const dep2: Dependency = { type: 'marketplace', value: { plugin_unique_identifier: 'p2' } } as Dependency + const dep1: Dependency = { + type: 'marketplace', + value: { plugin_unique_identifier: 'p1' }, + } as Dependency + const dep2: Dependency = { + type: 'marketplace', + value: { plugin_unique_identifier: 'p2' }, + } as Dependency useStore.getState().setDependencies([dep1]) useStore.getState().setDependencies([dep2]) @@ -33,7 +39,10 @@ describe('Plugin Dependency Store', () => { }) it('should handle empty array', () => { - const dep: Dependency = { type: 'marketplace', value: { plugin_unique_identifier: 'p1' } } as Dependency + const dep: Dependency = { + type: 'marketplace', + value: { plugin_unique_identifier: 'p1' }, + } as Dependency useStore.getState().setDependencies([dep]) useStore.getState().setDependencies([]) diff --git a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts index f527f8b96914f6..7d9a4b3aeb8649 100644 --- a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts @@ -45,7 +45,18 @@ describe('createWorkflowStore', () => { describe('Workflow Slice Setters', () => { it.each<[StateKey, SetterKey, Shape[StateKey]]>([ - ['workflowRunningData', 'setWorkflowRunningData', { result: { status: 'running', inputs_truncated: false, process_data_truncated: false, outputs_truncated: false } }], + [ + 'workflowRunningData', + 'setWorkflowRunningData', + { + result: { + status: 'running', + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + }, + ], ['isListening', 'setIsListening', true], ['listeningTriggerType', 'setListeningTriggerType', BlockEnum.TriggerWebhook], ['listeningTriggerNodeId', 'setListeningTriggerNodeId', 'node-abc'], @@ -59,7 +70,18 @@ describe('createWorkflowStore', () => { ['showConfirm', 'setShowConfirm', { title: 'Delete?', onConfirm: vi.fn() }], ['controlPromptEditorRerenderKey', 'setControlPromptEditorRerenderKey', 42], ['showImportDSLModal', 'setShowImportDSLModal', true], - ['fileUploadConfig', 'setFileUploadConfig', { batch_count_limit: 5, image_file_batch_limit: 10, single_chunk_attachment_limit: 10, attachment_image_file_size_limit: 2, file_size_limit: 15, file_upload_limit: 5 }], + [ + 'fileUploadConfig', + 'setFileUploadConfig', + { + batch_count_limit: 5, + image_file_batch_limit: 10, + single_chunk_attachment_limit: 10, + attachment_image_file_size_limit: 2, + file_size_limit: 15, + file_upload_limit: 5, + }, + ], ])('should update %s', (stateKey, setter, value) => { testSetter(setter, stateKey, value) }) @@ -89,7 +111,11 @@ describe('createWorkflowStore', () => { ['candidateNode', 'setCandidateNode', undefined], ['showAssignVariablePopup', 'setShowAssignVariablePopup', undefined], ['hoveringAssignVariableGroupId', 'setHoveringAssignVariableGroupId', 'group-1'], - ['connectingNodePayload', 'setConnectingNodePayload', { nodeId: 'n1', nodeType: 'llm', handleType: 'source', handleId: 'h1' }], + [ + 'connectingNodePayload', + 'setConnectingNodePayload', + { nodeId: 'n1', nodeType: 'llm', handleType: 'source', handleId: 'h1' }, + ], ['enteringNodePayload', 'setEnteringNodePayload', undefined], ['iterTimes', 'setIterTimes', 5], ['loopTimes', 'setLoopTimes', 10], @@ -218,16 +244,15 @@ describe('createWorkflowStore', () => { describe('useStore hook', () => { it('should read state via selector when wrapped in WorkflowContext', () => { - const { result } = renderWorkflowHook( - () => useStore(s => s.showSingleRunPanel), - { initialStoreState: { showSingleRunPanel: true } }, - ) + const { result } = renderWorkflowHook(() => useStore((s) => s.showSingleRunPanel), { + initialStoreState: { showSingleRunPanel: true }, + }) expect(result.current).toBe(true) }) it('should throw when used without WorkflowContext.Provider', () => { expect(() => { - renderHook(() => useStore(s => s.showSingleRunPanel)) + renderHook(() => useStore((s) => s.showSingleRunPanel)) }).toThrow('Missing WorkflowContext.Provider in the tree') }) }) diff --git a/web/app/components/workflow/store/trigger-status.ts b/web/app/components/workflow/store/trigger-status.ts index 2f472c79b94b7c..2d0ef7b745ee87 100644 --- a/web/app/components/workflow/store/trigger-status.ts +++ b/web/app/components/workflow/store/trigger-status.ts @@ -19,7 +19,7 @@ export const useTriggerStatusStore = create<TriggerStatusState>()( triggerStatuses: {}, setTriggerStatus: (nodeId: string, status: EntryNodeStatus) => { - set(state => ({ + set((state) => ({ triggerStatuses: { ...state.triggerStatuses, [nodeId]: status, diff --git a/web/app/components/workflow/store/workflow/__tests__/index.spec.tsx b/web/app/components/workflow/store/workflow/__tests__/index.spec.tsx index 24e11194964b94..3e7bb15ffe1542 100644 --- a/web/app/components/workflow/store/workflow/__tests__/index.spec.tsx +++ b/web/app/components/workflow/store/workflow/__tests__/index.spec.tsx @@ -6,15 +6,16 @@ describe('workflow store index', () => { it('creates a merged workflow store and exposes it through both hooks', () => { const store = createWorkflowStore({}) const wrapper = ({ children }: { children: React.ReactNode }) => ( - <WorkflowContext.Provider value={store}> - {children} - </WorkflowContext.Provider> + <WorkflowContext.Provider value={store}>{children}</WorkflowContext.Provider> ) - const { result } = renderHook(() => ({ - nodes: useStore(state => state.nodes), - sameStore: useWorkflowStore() === store, - }), { wrapper }) + const { result } = renderHook( + () => ({ + nodes: useStore((state) => state.nodes), + sameStore: useWorkflowStore() === store, + }), + { wrapper }, + ) expect(result.current.nodes).toEqual([]) expect(result.current.sameStore).toBe(true) diff --git a/web/app/components/workflow/store/workflow/chat-variable-slice.ts b/web/app/components/workflow/store/workflow/chat-variable-slice.ts index 96fe8b00b88e42..fa18ed59d43b36 100644 --- a/web/app/components/workflow/store/workflow/chat-variable-slice.ts +++ b/web/app/components/workflow/store/workflow/chat-variable-slice.ts @@ -18,22 +18,20 @@ export const createChatVariableSlice: StateCreator<ChatVariableSliceShape> = (se showGlobalVariablePanel: false, } - return ({ + return { showChatVariablePanel: false, - setShowChatVariablePanel: showChatVariablePanel => set(() => { - if (showChatVariablePanel) - return { ...hideAllPanel, showChatVariablePanel: true } - else - return { showChatVariablePanel: false } - }), + setShowChatVariablePanel: (showChatVariablePanel) => + set(() => { + if (showChatVariablePanel) return { ...hideAllPanel, showChatVariablePanel: true } + else return { showChatVariablePanel: false } + }), showGlobalVariablePanel: false, - setShowGlobalVariablePanel: showGlobalVariablePanel => set(() => { - if (showGlobalVariablePanel) - return { ...hideAllPanel, showGlobalVariablePanel: true } - else - return { showGlobalVariablePanel: false } - }), + setShowGlobalVariablePanel: (showGlobalVariablePanel) => + set(() => { + if (showGlobalVariablePanel) return { ...hideAllPanel, showGlobalVariablePanel: true } + else return { showGlobalVariablePanel: false } + }), conversationVariables: [], - setConversationVariables: conversationVariables => set(() => ({ conversationVariables })), - }) + setConversationVariables: (conversationVariables) => set(() => ({ conversationVariables })), + } } diff --git a/web/app/components/workflow/store/workflow/comment-slice.ts b/web/app/components/workflow/store/workflow/comment-slice.ts index d9abd9feec9bd6..4595ba41dc9efb 100644 --- a/web/app/components/workflow/store/workflow/comment-slice.ts +++ b/web/app/components/workflow/store/workflow/comment-slice.ts @@ -1,5 +1,9 @@ import type { StateCreator } from 'zustand' -import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/app/components/workflow/comment/types' +import type { + UserProfile, + WorkflowCommentDetail, + WorkflowCommentList, +} from '@/app/components/workflow/comment/types' export type CommentSliceShape = { comments: WorkflowCommentList[] @@ -24,35 +28,38 @@ export type CommentSliceShape = { setMentionableUsersLoading: (appId: string, loading: boolean) => void } -export const createCommentSlice: StateCreator<CommentSliceShape> = set => ({ +export const createCommentSlice: StateCreator<CommentSliceShape> = (set) => ({ comments: [], - setComments: comments => set({ comments }), + setComments: (comments) => set({ comments }), commentsLoading: false, - setCommentsLoading: commentsLoading => set({ commentsLoading }), + setCommentsLoading: (commentsLoading) => set({ commentsLoading }), showResolvedComments: false, - setShowResolvedComments: showResolvedComments => set({ showResolvedComments }), + setShowResolvedComments: (showResolvedComments) => set({ showResolvedComments }), activeCommentDetail: null, - setActiveCommentDetail: activeCommentDetail => set({ activeCommentDetail }), + setActiveCommentDetail: (activeCommentDetail) => set({ activeCommentDetail }), activeCommentDetailLoading: false, - setActiveCommentDetailLoading: activeCommentDetailLoading => set({ activeCommentDetailLoading }), + setActiveCommentDetailLoading: (activeCommentDetailLoading) => + set({ activeCommentDetailLoading }), replySubmitting: false, - setReplySubmitting: replySubmitting => set({ replySubmitting }), + setReplySubmitting: (replySubmitting) => set({ replySubmitting }), replyUpdating: false, - setReplyUpdating: replyUpdating => set({ replyUpdating }), + setReplyUpdating: (replyUpdating) => set({ replyUpdating }), commentDetailCache: {}, - setCommentDetailCache: commentDetailCache => set({ commentDetailCache }), + setCommentDetailCache: (commentDetailCache) => set({ commentDetailCache }), mentionableUsersCache: {}, - setMentionableUsersCache: (appId, users) => set(state => ({ - mentionableUsersCache: { - ...state.mentionableUsersCache, - [appId]: users, - }, - })), + setMentionableUsersCache: (appId, users) => + set((state) => ({ + mentionableUsersCache: { + ...state.mentionableUsersCache, + [appId]: users, + }, + })), mentionableUsersLoading: {}, - setMentionableUsersLoading: (appId, loading) => set(state => ({ - mentionableUsersLoading: { - ...state.mentionableUsersLoading, - [appId]: loading, - }, - })), + setMentionableUsersLoading: (appId, loading) => + set((state) => ({ + mentionableUsersLoading: { + ...state.mentionableUsersLoading, + [appId]: loading, + }, + })), }) diff --git a/web/app/components/workflow/store/workflow/debug/inspect-vars-slice.ts b/web/app/components/workflow/store/workflow/debug/inspect-vars-slice.ts index 5a0f57411660a5..08a53e82ece11b 100644 --- a/web/app/components/workflow/store/workflow/debug/inspect-vars-slice.ts +++ b/web/app/components/workflow/store/workflow/debug/inspect-vars-slice.ts @@ -24,7 +24,7 @@ type InspectVarsActions = { export type InspectVarsSliceShape = InspectVarsState & InspectVarsActions export const createInspectVarsSlice: StateCreator<InspectVarsSliceShape> = (set) => { - return ({ + return { currentFocusNodeId: null, nodesWithInspectVars: [], conversationVars: [], @@ -47,7 +47,7 @@ export const createInspectVarsSlice: StateCreator<InspectVarsSliceShape> = (set) set((state) => { const prevNodes = state.nodesWithInspectVars const nodes = produce(prevNodes, (draft) => { - const index = prevNodes.findIndex(node => node.nodeId === nodeId) + const index = prevNodes.findIndex((node) => node.nodeId === nodeId) if (index !== -1) { draft[index]!.vars = payload draft[index]!.isValueFetched = true @@ -61,22 +61,19 @@ export const createInspectVarsSlice: StateCreator<InspectVarsSliceShape> = (set) }, deleteNodeInspectVars: (nodeId) => { set((state: InspectVarsSliceShape) => { - const nodes = state.nodesWithInspectVars.filter(node => node.nodeId !== nodeId) + const nodes = state.nodesWithInspectVars.filter((node) => node.nodeId !== nodeId) return { nodesWithInspectVars: nodes, } - }, - ) + }) }, setInspectVarValue: (nodeId, varId, value) => { set((state: InspectVarsSliceShape) => { const nodes = produce(state.nodesWithInspectVars, (draft) => { - const targetNode = draft.find(node => node.nodeId === nodeId) - if (!targetNode) - return - const targetVar = targetNode.vars.find(varItem => varItem.id === varId) - if (!targetVar) - return + const targetNode = draft.find((node) => node.nodeId === nodeId) + if (!targetNode) return + const targetVar = targetNode.vars.find((varItem) => varItem.id === varId) + if (!targetVar) return targetVar.value = value targetVar.edited = true }) @@ -88,12 +85,10 @@ export const createInspectVarsSlice: StateCreator<InspectVarsSliceShape> = (set) resetToLastRunVar: (nodeId, varId, value) => { set((state: InspectVarsSliceShape) => { const nodes = produce(state.nodesWithInspectVars, (draft) => { - const targetNode = draft.find(node => node.nodeId === nodeId) - if (!targetNode) - return - const targetVar = targetNode.vars.find(varItem => varItem.id === varId) - if (!targetVar) - return + const targetNode = draft.find((node) => node.nodeId === nodeId) + if (!targetNode) return + const targetVar = targetNode.vars.find((varItem) => varItem.id === varId) + if (!targetVar) return targetVar.value = value targetVar.edited = false }) @@ -105,12 +100,10 @@ export const createInspectVarsSlice: StateCreator<InspectVarsSliceShape> = (set) renameInspectVarName: (nodeId, varId, selector) => { set((state: InspectVarsSliceShape) => { const nodes = produce(state.nodesWithInspectVars, (draft) => { - const targetNode = draft.find(node => node.nodeId === nodeId) - if (!targetNode) - return - const targetVar = targetNode.vars.find(varItem => varItem.id === varId) - if (!targetVar) - return + const targetNode = draft.find((node) => node.nodeId === nodeId) + if (!targetNode) return + const targetVar = targetNode.vars.find((varItem) => varItem.id === varId) + if (!targetVar) return targetVar.name = selector[1]! targetVar.selector = selector }) @@ -122,17 +115,15 @@ export const createInspectVarsSlice: StateCreator<InspectVarsSliceShape> = (set) deleteInspectVar: (nodeId, varId) => { set((state: InspectVarsSliceShape) => { const nodes = produce(state.nodesWithInspectVars, (draft) => { - const targetNode = draft.find(node => node.nodeId === nodeId) - if (!targetNode) - return - const needChangeVarIndex = targetNode.vars.findIndex(varItem => varItem.id === varId) - if (needChangeVarIndex !== -1) - targetNode.vars.splice(needChangeVarIndex, 1) + const targetNode = draft.find((node) => node.nodeId === nodeId) + if (!targetNode) return + const needChangeVarIndex = targetNode.vars.findIndex((varItem) => varItem.id === varId) + if (needChangeVarIndex !== -1) targetNode.vars.splice(needChangeVarIndex, 1) }) return { nodesWithInspectVars: nodes, } }) }, - }) + } } diff --git a/web/app/components/workflow/store/workflow/env-variable-slice.ts b/web/app/components/workflow/store/workflow/env-variable-slice.ts index 2ba6ce084a353b..346841967873f7 100644 --- a/web/app/components/workflow/store/workflow/env-variable-slice.ts +++ b/web/app/components/workflow/store/workflow/env-variable-slice.ts @@ -17,17 +17,16 @@ export const createEnvVariableSlice: StateCreator<EnvVariableSliceShape> = (set) showChatVariablePanel: false, showGlobalVariablePanel: false, } - return ({ + return { showEnvPanel: false, - setShowEnvPanel: showEnvPanel => set(() => { - if (showEnvPanel) - return { ...hideAllPanel, showEnvPanel: true } - else - return { showEnvPanel: false } - }), + setShowEnvPanel: (showEnvPanel) => + set(() => { + if (showEnvPanel) return { ...hideAllPanel, showEnvPanel: true } + else return { showEnvPanel: false } + }), environmentVariables: [], - setEnvironmentVariables: environmentVariables => set(() => ({ environmentVariables })), + setEnvironmentVariables: (environmentVariables) => set(() => ({ environmentVariables })), envSecrets: {}, - setEnvSecrets: envSecrets => set(() => ({ envSecrets })), - }) + setEnvSecrets: (envSecrets) => set(() => ({ envSecrets })), + } } diff --git a/web/app/components/workflow/store/workflow/form-slice.ts b/web/app/components/workflow/store/workflow/form-slice.ts index 46391eddff5833..b025f308563cd9 100644 --- a/web/app/components/workflow/store/workflow/form-slice.ts +++ b/web/app/components/workflow/store/workflow/form-slice.ts @@ -1,7 +1,5 @@ import type { StateCreator } from 'zustand' -import type { - RunFile, -} from '@/app/components/workflow/types' +import type { RunFile } from '@/app/components/workflow/types' export type FormSliceShape = { inputs: Record<string, string | number | boolean> @@ -10,9 +8,9 @@ export type FormSliceShape = { setFiles: (files: RunFile[]) => void } -export const createFormSlice: StateCreator<FormSliceShape> = set => ({ +export const createFormSlice: StateCreator<FormSliceShape> = (set) => ({ inputs: {}, - setInputs: inputs => set(() => ({ inputs })), + setInputs: (inputs) => set(() => ({ inputs })), files: [], - setFiles: files => set(() => ({ files })), + setFiles: (files) => set(() => ({ files })), }) diff --git a/web/app/components/workflow/store/workflow/help-line-slice.ts b/web/app/components/workflow/store/workflow/help-line-slice.ts index 7d9aeac14a72a3..8de150e755796d 100644 --- a/web/app/components/workflow/store/workflow/help-line-slice.ts +++ b/web/app/components/workflow/store/workflow/help-line-slice.ts @@ -11,9 +11,9 @@ export type HelpLineSliceShape = { setHelpLineVertical: (helpLineVertical?: HelpLineVerticalPosition) => void } -export const createHelpLineSlice: StateCreator<HelpLineSliceShape> = set => ({ +export const createHelpLineSlice: StateCreator<HelpLineSliceShape> = (set) => ({ helpLineHorizontal: undefined, - setHelpLineHorizontal: helpLineHorizontal => set(() => ({ helpLineHorizontal })), + setHelpLineHorizontal: (helpLineHorizontal) => set(() => ({ helpLineHorizontal })), helpLineVertical: undefined, - setHelpLineVertical: helpLineVertical => set(() => ({ helpLineVertical })), + setHelpLineVertical: (helpLineVertical) => set(() => ({ helpLineVertical })), }) diff --git a/web/app/components/workflow/store/workflow/history-slice.ts b/web/app/components/workflow/store/workflow/history-slice.ts index 7f527c2c8f25b0..830a608ec99903 100644 --- a/web/app/components/workflow/store/workflow/history-slice.ts +++ b/web/app/components/workflow/store/workflow/history-slice.ts @@ -1,12 +1,8 @@ import type { StateCreator } from 'zustand' import type { WorkflowHistoryEventT } from '../../hooks/use-workflow-history' import type { Edge, Node } from '../../types' -import type { - HistoryWorkflowData, -} from '@/app/components/workflow/types' -import type { - VersionHistory, -} from '@/types/workflow' +import type { HistoryWorkflowData } from '@/app/components/workflow/types' +import type { VersionHistory } from '@/types/workflow' import isDeepEqual from 'fast-deep-equal' export type WorkflowHistoryEventMeta = { @@ -23,7 +19,9 @@ export type WorkflowHistoryState = { export type WorkflowHistoryTemporalState = Pick<HistorySliceShape, 'workflowHistory'> -export const getWorkflowHistoryTemporalState = (state: HistorySliceShape): WorkflowHistoryTemporalState => ({ +export const getWorkflowHistoryTemporalState = ( + state: HistorySliceShape, +): WorkflowHistoryTemporalState => ({ workflowHistory: state.workflowHistory, }) @@ -31,8 +29,7 @@ export const isWorkflowHistoryTemporalStateEqual = ( pastState: WorkflowHistoryTemporalState, currentState: WorkflowHistoryTemporalState, ) => { - if (pastState.workflowHistory === currentState.workflowHistory) - return true + if (pastState.workflowHistory === currentState.workflowHistory) return true return isDeepEqual(pastState.workflowHistory, currentState.workflowHistory) } @@ -50,20 +47,20 @@ export type HistorySliceShape = { setVersionHistory: (versionHistory: VersionHistory[]) => void } -export const createHistorySlice: StateCreator<HistorySliceShape> = set => ({ +export const createHistorySlice: StateCreator<HistorySliceShape> = (set) => ({ workflowHistory: { nodes: [], edges: [], workflowHistoryEvent: undefined, workflowHistoryEventMeta: undefined, }, - setWorkflowHistory: workflowHistory => set(() => ({ workflowHistory })), + setWorkflowHistory: (workflowHistory) => set(() => ({ workflowHistory })), historyShortcutsEnabled: true, - setHistoryShortcutsEnabled: historyShortcutsEnabled => set(() => ({ historyShortcutsEnabled })), + setHistoryShortcutsEnabled: (historyShortcutsEnabled) => set(() => ({ historyShortcutsEnabled })), historyWorkflowData: undefined, - setHistoryWorkflowData: historyWorkflowData => set(() => ({ historyWorkflowData })), + setHistoryWorkflowData: (historyWorkflowData) => set(() => ({ historyWorkflowData })), showRunHistory: false, - setShowRunHistory: showRunHistory => set(() => ({ showRunHistory })), + setShowRunHistory: (showRunHistory) => set(() => ({ showRunHistory })), versionHistory: [], - setVersionHistory: versionHistory => set(() => ({ versionHistory })), + setVersionHistory: (versionHistory) => set(() => ({ versionHistory })), }) diff --git a/web/app/components/workflow/store/workflow/index.ts b/web/app/components/workflow/store/workflow/index.ts index 541fecce2dc3df..eef687c2a28663 100644 --- a/web/app/components/workflow/store/workflow/index.ts +++ b/web/app/components/workflow/store/workflow/index.ts @@ -1,8 +1,5 @@ import type { TemporalState } from 'zundo' -import type { - StateCreator, - StoreApi, -} from 'zustand' +import type { StateCreator, StoreApi } from 'zustand' import type { ChatVariableSliceShape } from './chat-variable-slice' import type { CommentSliceShape } from './comment-slice' import type { InspectVarsSliceShape } from './debug/inspect-vars-slice' @@ -21,9 +18,7 @@ import type { RagPipelineSliceShape } from '@/app/components/rag-pipeline/store' import type { WorkflowSliceShape as WorkflowAppSliceShape } from '@/app/components/workflow-app/store/workflow/workflow-slice' import { use } from 'react' import { temporal } from 'zundo' -import { - useStore as useZustandStore, -} from 'zustand' +import { useStore as useZustandStore } from 'zustand' import { createStore } from 'zustand/vanilla' import { WorkflowContext } from '@/app/components/workflow/context' import { createChatVariableSlice } from './chat-variable-slice' @@ -39,33 +34,29 @@ import { } from './history-slice' import { createLayoutSlice } from './layout-slice' import { createNodeSlice } from './node-slice' - import { createPanelSlice } from './panel-slice' import { createToolSlice } from './tool-slice' import { createVersionSlice } from './version-slice' import { createWorkflowDraftSlice } from './workflow-draft-slice' import { createWorkflowSlice } from './workflow-slice' -export type SliceFromInjection - = Partial<WorkflowAppSliceShape> - & Partial<RagPipelineSliceShape> +export type SliceFromInjection = Partial<WorkflowAppSliceShape> & Partial<RagPipelineSliceShape> -export type Shape - = ChatVariableSliceShape - & EnvVariableSliceShape - & FormSliceShape - & HelpLineSliceShape - & HistorySliceShape - & NodeSliceShape - & PanelSliceShape - & ToolSliceShape - & VersionSliceShape - & WorkflowDraftSliceShape - & WorkflowSliceShape - & CommentSliceShape - & InspectVarsSliceShape - & LayoutSliceShape - & SliceFromInjection +export type Shape = ChatVariableSliceShape & + EnvVariableSliceShape & + FormSliceShape & + HelpLineSliceShape & + HistorySliceShape & + NodeSliceShape & + PanelSliceShape & + ToolSliceShape & + VersionSliceShape & + WorkflowDraftSliceShape & + WorkflowSliceShape & + CommentSliceShape & + InspectVarsSliceShape & + LayoutSliceShape & + SliceFromInjection type WorkflowStoreApi = StoreApi<Shape> & { temporal: StoreApi<TemporalState<WorkflowHistoryTemporalState>> @@ -97,7 +88,7 @@ export const createWorkflowStore = (params: CreateWorkflowStoreParams) => { ...createWorkflowSlice(...args), ...createInspectVarsSlice(...args), ...createLayoutSlice(...args), - ...(injectWorkflowStoreSliceFn?.(...args) || {} as SliceFromInjection), + ...(injectWorkflowStoreSliceFn?.(...args) || ({} as SliceFromInjection)), }), { partialize: getWorkflowHistoryTemporalState, @@ -109,8 +100,7 @@ export const createWorkflowStore = (params: CreateWorkflowStoreParams) => { export function useStore<T>(selector: (state: Shape) => T): T { const store = use(WorkflowContext) - if (!store) - throw new Error('Missing WorkflowContext.Provider in the tree') + if (!store) throw new Error('Missing WorkflowContext.Provider in the tree') return useZustandStore(store, selector) } diff --git a/web/app/components/workflow/store/workflow/layout-slice.ts b/web/app/components/workflow/store/workflow/layout-slice.ts index 010fc11235ccad..67efb84bf379f5 100644 --- a/web/app/components/workflow/store/workflow/layout-slice.ts +++ b/web/app/components/workflow/store/workflow/layout-slice.ts @@ -22,32 +22,36 @@ export type LayoutSliceShape = { setVariableInspectPanelHeight: (height: number) => void } -export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({ +export const createLayoutSlice: StateCreator<LayoutSliceShape> = (set) => ({ workflowCanvasWidth: undefined, workflowCanvasHeight: undefined, - setWorkflowCanvasWidth: width => set(state => - state.workflowCanvasWidth === width ? state : ({ workflowCanvasWidth: width })), - setWorkflowCanvasHeight: height => set(state => - state.workflowCanvasHeight === height ? state : ({ workflowCanvasHeight: height })), + setWorkflowCanvasWidth: (width) => + set((state) => (state.workflowCanvasWidth === width ? state : { workflowCanvasWidth: width })), + setWorkflowCanvasHeight: (height) => + set((state) => + state.workflowCanvasHeight === height ? state : { workflowCanvasHeight: height }, + ), rightPanelWidth: undefined, - setRightPanelWidth: width => set(state => - state.rightPanelWidth === width ? state : ({ rightPanelWidth: width })), + setRightPanelWidth: (width) => + set((state) => (state.rightPanelWidth === width ? state : { rightPanelWidth: width })), nodePanelWidth: 400, - setNodePanelWidth: width => set(state => - state.nodePanelWidth === width ? state : ({ nodePanelWidth: width })), + setNodePanelWidth: (width) => + set((state) => (state.nodePanelWidth === width ? state : { nodePanelWidth: width })), previewPanelWidth: 400, - setPreviewPanelWidth: width => set(state => - state.previewPanelWidth === width ? state : ({ previewPanelWidth: width })), + setPreviewPanelWidth: (width) => + set((state) => (state.previewPanelWidth === width ? state : { previewPanelWidth: width })), otherPanelWidth: 400, - setOtherPanelWidth: width => set(state => - state.otherPanelWidth === width ? state : ({ otherPanelWidth: width })), + setOtherPanelWidth: (width) => + set((state) => (state.otherPanelWidth === width ? state : { otherPanelWidth: width })), bottomPanelWidth: 480, - setBottomPanelWidth: width => set(state => - state.bottomPanelWidth === width ? state : ({ bottomPanelWidth: width })), + setBottomPanelWidth: (width) => + set((state) => (state.bottomPanelWidth === width ? state : { bottomPanelWidth: width })), bottomPanelHeight: 324, - setBottomPanelHeight: height => set(state => - state.bottomPanelHeight === height ? state : ({ bottomPanelHeight: height })), + setBottomPanelHeight: (height) => + set((state) => (state.bottomPanelHeight === height ? state : { bottomPanelHeight: height })), variableInspectPanelHeight: 320, - setVariableInspectPanelHeight: height => set(state => - state.variableInspectPanelHeight === height ? state : ({ variableInspectPanelHeight: height })), + setVariableInspectPanelHeight: (height) => + set((state) => + state.variableInspectPanelHeight === height ? state : { variableInspectPanelHeight: height }, + ), }) diff --git a/web/app/components/workflow/store/workflow/node-slice.ts b/web/app/components/workflow/store/workflow/node-slice.ts index 31f5231c71509d..c1427e987b4f91 100644 --- a/web/app/components/workflow/store/workflow/node-slice.ts +++ b/web/app/components/workflow/store/workflow/node-slice.ts @@ -1,14 +1,8 @@ import type { StateCreator } from 'zustand' import type { ChecklistItem } from '@/app/components/workflow/hooks/use-checklist' -import type { - VariableAssignerNodeType, -} from '@/app/components/workflow/nodes/variable-assigner/types' -import type { - Node, -} from '@/app/components/workflow/types' -import type { - NodeTracing, -} from '@/types/workflow' +import type { VariableAssignerNodeType } from '@/app/components/workflow/nodes/variable-assigner/types' +import type { Node } from '@/app/components/workflow/types' +import type { NodeTracing } from '@/types/workflow' export type NodeSliceShape = { checklistItems: ChecklistItem[] @@ -30,11 +24,20 @@ export type NodeSliceShape = { x: number y: number } - setShowAssignVariablePopup: (showAssignVariablePopup: NodeSliceShape['showAssignVariablePopup']) => void + setShowAssignVariablePopup: ( + showAssignVariablePopup: NodeSliceShape['showAssignVariablePopup'], + ) => void hoveringAssignVariableGroupId?: string setHoveringAssignVariableGroupId: (hoveringAssignVariableGroupId?: string) => void - connectingNodePayload?: { nodeId: string, nodeType: string, handleType: string, handleId: string | null } - setConnectingNodePayload: (startConnectingPayload?: NodeSliceShape['connectingNodePayload']) => void + connectingNodePayload?: { + nodeId: string + nodeType: string + handleType: string + handleId: string | null + } + setConnectingNodePayload: ( + startConnectingPayload?: NodeSliceShape['connectingNodePayload'], + ) => void enteringNodePayload?: { nodeId: string nodeData: VariableAssignerNodeType @@ -53,30 +56,31 @@ export type NodeSliceShape = { setPendingSingleRun: (payload?: NodeSliceShape['pendingSingleRun']) => void } -export const createNodeSlice: StateCreator<NodeSliceShape> = set => ({ +export const createNodeSlice: StateCreator<NodeSliceShape> = (set) => ({ checklistItems: [], showSingleRunPanel: false, - setShowSingleRunPanel: showSingleRunPanel => set(() => ({ showSingleRunPanel })), + setShowSingleRunPanel: (showSingleRunPanel) => set(() => ({ showSingleRunPanel })), nodeAnimation: false, - setNodeAnimation: nodeAnimation => set(() => ({ nodeAnimation })), + setNodeAnimation: (nodeAnimation) => set(() => ({ nodeAnimation })), candidateNode: undefined, - setCandidateNode: candidateNode => set(() => ({ candidateNode })), + setCandidateNode: (candidateNode) => set(() => ({ candidateNode })), openInlineAgentPanelNodeId: undefined, - setOpenInlineAgentPanelNodeId: nodeId => set(() => ({ openInlineAgentPanelNodeId: nodeId })), + setOpenInlineAgentPanelNodeId: (nodeId) => set(() => ({ openInlineAgentPanelNodeId: nodeId })), showAssignVariablePopup: undefined, - setShowAssignVariablePopup: showAssignVariablePopup => set(() => ({ showAssignVariablePopup })), + setShowAssignVariablePopup: (showAssignVariablePopup) => set(() => ({ showAssignVariablePopup })), hoveringAssignVariableGroupId: undefined, - setHoveringAssignVariableGroupId: hoveringAssignVariableGroupId => set(() => ({ hoveringAssignVariableGroupId })), + setHoveringAssignVariableGroupId: (hoveringAssignVariableGroupId) => + set(() => ({ hoveringAssignVariableGroupId })), connectingNodePayload: undefined, - setConnectingNodePayload: connectingNodePayload => set(() => ({ connectingNodePayload })), + setConnectingNodePayload: (connectingNodePayload) => set(() => ({ connectingNodePayload })), enteringNodePayload: undefined, - setEnteringNodePayload: enteringNodePayload => set(() => ({ enteringNodePayload })), + setEnteringNodePayload: (enteringNodePayload) => set(() => ({ enteringNodePayload })), iterTimes: 1, - setIterTimes: iterTimes => set(() => ({ iterTimes })), + setIterTimes: (iterTimes) => set(() => ({ iterTimes })), loopTimes: 1, - setLoopTimes: loopTimes => set(() => ({ loopTimes })), + setLoopTimes: (loopTimes) => set(() => ({ loopTimes })), iterParallelLogMap: new Map<string, Map<string, NodeTracing[]>>(), - setIterParallelLogMap: iterParallelLogMap => set(() => ({ iterParallelLogMap })), + setIterParallelLogMap: (iterParallelLogMap) => set(() => ({ iterParallelLogMap })), pendingSingleRun: undefined, - setPendingSingleRun: payload => set(() => ({ pendingSingleRun: payload })), + setPendingSingleRun: (payload) => set(() => ({ pendingSingleRun: payload })), }) diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index 5f4f459397dcb8..668b04c5ff5a17 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -1,10 +1,10 @@ import type { StateCreator } from 'zustand' -export type WorkflowContextMenuTarget - = | { type: 'panel' } - | { type: 'selection' } - | { type: 'node', nodeId: string } - | { type: 'edge', edgeId: string } +export type WorkflowContextMenuTarget = + | { type: 'panel' } + | { type: 'selection' } + | { type: 'node'; nodeId: string } + | { type: 'edge'; edgeId: string } export type PanelSliceShape = { panelWidth: number @@ -33,30 +33,33 @@ export type PanelSliceShape = { setActiveCommentId: (commentId: string | null) => void } -export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({ +export const createPanelSlice: StateCreator<PanelSliceShape> = (set) => ({ panelWidth: 420, - setPanelWidth: width => set(state => - state.panelWidth === width ? state : ({ panelWidth: width })), + setPanelWidth: (width) => + set((state) => (state.panelWidth === width ? state : { panelWidth: width })), showFeaturesPanel: false, - setShowFeaturesPanel: showFeaturesPanel => set(() => ({ showFeaturesPanel })), + setShowFeaturesPanel: (showFeaturesPanel) => set(() => ({ showFeaturesPanel })), showWorkflowVersionHistoryPanel: false, - setShowWorkflowVersionHistoryPanel: showWorkflowVersionHistoryPanel => set(() => ({ showWorkflowVersionHistoryPanel })), + setShowWorkflowVersionHistoryPanel: (showWorkflowVersionHistoryPanel) => + set(() => ({ showWorkflowVersionHistoryPanel })), showInputsPanel: false, - setShowInputsPanel: showInputsPanel => set(() => ({ showInputsPanel })), + setShowInputsPanel: (showInputsPanel) => set(() => ({ showInputsPanel })), showDebugAndPreviewPanel: false, - setShowDebugAndPreviewPanel: showDebugAndPreviewPanel => set(() => ({ showDebugAndPreviewPanel })), + setShowDebugAndPreviewPanel: (showDebugAndPreviewPanel) => + set(() => ({ showDebugAndPreviewPanel })), showCommentsPanel: false, - setShowCommentsPanel: showCommentsPanel => set(() => ({ showCommentsPanel })), + setShowCommentsPanel: (showCommentsPanel) => set(() => ({ showCommentsPanel })), showUserComments: true, - setShowUserComments: showUserComments => set(() => ({ showUserComments })), + setShowUserComments: (showUserComments) => set(() => ({ showUserComments })), showUserCursors: true, - setShowUserCursors: showUserCursors => set(() => ({ showUserCursors })), + setShowUserCursors: (showUserCursors) => set(() => ({ showUserCursors })), contextMenuTarget: undefined, - setContextMenuTarget: contextMenuTarget => set(() => ({ contextMenuTarget })), + setContextMenuTarget: (contextMenuTarget) => set(() => ({ contextMenuTarget })), showVariableInspectPanel: false, - setShowVariableInspectPanel: showVariableInspectPanel => set(() => ({ showVariableInspectPanel })), + setShowVariableInspectPanel: (showVariableInspectPanel) => + set(() => ({ showVariableInspectPanel })), initShowLastRunTab: false, - setInitShowLastRunTab: initShowLastRunTab => set(() => ({ initShowLastRunTab })), + setInitShowLastRunTab: (initShowLastRunTab) => set(() => ({ initShowLastRunTab })), activeCommentId: null, setActiveCommentId: (commentId: string | null) => set(() => ({ activeCommentId: commentId })), }) diff --git a/web/app/components/workflow/store/workflow/tool-slice.ts b/web/app/components/workflow/store/workflow/tool-slice.ts index d5ff7743bee6f4..2a977aa31afb3a 100644 --- a/web/app/components/workflow/store/workflow/tool-slice.ts +++ b/web/app/components/workflow/store/workflow/tool-slice.ts @@ -12,11 +12,12 @@ export type ToolSliceShape = { mcpTools?: ToolWithProvider[] } -export const createToolSlice: StateCreator<ToolSliceShape> = set => ({ +export const createToolSlice: StateCreator<ToolSliceShape> = (set) => ({ toolPublished: false, - setToolPublished: toolPublished => set(() => ({ toolPublished })), + setToolPublished: (toolPublished) => set(() => ({ toolPublished })), lastPublishedHasUserInput: false, - setLastPublishedHasUserInput: hasUserInput => set(() => ({ lastPublishedHasUserInput: hasUserInput })), + setLastPublishedHasUserInput: (hasUserInput) => + set(() => ({ lastPublishedHasUserInput: hasUserInput })), buildInTools: undefined, customTools: undefined, workflowTools: undefined, diff --git a/web/app/components/workflow/store/workflow/use-nodes.ts b/web/app/components/workflow/store/workflow/use-nodes.ts index 4bc273a6900463..4d088e5a86db7f 100644 --- a/web/app/components/workflow/store/workflow/use-nodes.ts +++ b/web/app/components/workflow/store/workflow/use-nodes.ts @@ -1,7 +1,5 @@ -import { - useStore, -} from '@/app/components/workflow/store' +import { useStore } from '@/app/components/workflow/store' -const useWorkflowNodes = () => useStore(s => s.nodes) +const useWorkflowNodes = () => useStore((s) => s.nodes) export default useWorkflowNodes diff --git a/web/app/components/workflow/store/workflow/version-slice.ts b/web/app/components/workflow/store/workflow/version-slice.ts index df192187863e62..d121156ce6fefb 100644 --- a/web/app/components/workflow/store/workflow/version-slice.ts +++ b/web/app/components/workflow/store/workflow/version-slice.ts @@ -1,7 +1,5 @@ import type { StateCreator } from 'zustand' -import type { - VersionHistory, -} from '@/types/workflow' +import type { VersionHistory } from '@/types/workflow' export type VersionSliceShape = { draftUpdatedAt: number @@ -14,13 +12,15 @@ export type VersionSliceShape = { setIsRestoring: (isRestoring: boolean) => void } -export const createVersionSlice: StateCreator<VersionSliceShape> = set => ({ +export const createVersionSlice: StateCreator<VersionSliceShape> = (set) => ({ draftUpdatedAt: 0, - setDraftUpdatedAt: draftUpdatedAt => set(() => ({ draftUpdatedAt: draftUpdatedAt ? draftUpdatedAt * 1000 : 0 })), + setDraftUpdatedAt: (draftUpdatedAt) => + set(() => ({ draftUpdatedAt: draftUpdatedAt ? draftUpdatedAt * 1000 : 0 })), publishedAt: 0, - setPublishedAt: publishedAt => set(() => ({ publishedAt: publishedAt ? publishedAt * 1000 : 0 })), + setPublishedAt: (publishedAt) => + set(() => ({ publishedAt: publishedAt ? publishedAt * 1000 : 0 })), currentVersion: null, - setCurrentVersion: currentVersion => set(() => ({ currentVersion })), + setCurrentVersion: (currentVersion) => set(() => ({ currentVersion })), isRestoring: false, - setIsRestoring: isRestoring => set(() => ({ isRestoring })), + setIsRestoring: (isRestoring) => set(() => ({ isRestoring })), }) diff --git a/web/app/components/workflow/store/workflow/workflow-draft-slice.ts b/web/app/components/workflow/store/workflow/workflow-draft-slice.ts index 68265a8eba6e4a..3bbd7e5d2206f1 100644 --- a/web/app/components/workflow/store/workflow/workflow-draft-slice.ts +++ b/web/app/components/workflow/store/workflow/workflow-draft-slice.ts @@ -1,10 +1,6 @@ import type { Viewport } from 'reactflow' import type { StateCreator } from 'zustand' -import type { - Edge, - EnvironmentVariable, - Node, -} from '@/app/components/workflow/types' +import type { Edge, EnvironmentVariable, Node } from '@/app/components/workflow/types' import { debounce } from 'es-toolkit/compat' type DebouncedFunc = { @@ -42,20 +38,19 @@ export const createWorkflowDraftSlice: StateCreator<WorkflowDraftSliceShape> = ( return { backupDraft: undefined, - setBackupDraft: backupDraft => set(() => ({ backupDraft })), + setBackupDraft: (backupDraft) => set(() => ({ backupDraft })), debouncedSyncWorkflowDraft: debouncedFn, syncWorkflowDraftHash: '', - setSyncWorkflowDraftHash: syncWorkflowDraftHash => set(() => ({ syncWorkflowDraftHash })), + setSyncWorkflowDraftHash: (syncWorkflowDraftHash) => set(() => ({ syncWorkflowDraftHash })), isSyncingWorkflowDraft: false, - setIsSyncingWorkflowDraft: isSyncingWorkflowDraft => set(() => ({ isSyncingWorkflowDraft })), + setIsSyncingWorkflowDraft: (isSyncingWorkflowDraft) => set(() => ({ isSyncingWorkflowDraft })), isWorkflowDataLoaded: false, - setIsWorkflowDataLoaded: loaded => set(() => ({ isWorkflowDataLoaded: loaded })), + setIsWorkflowDataLoaded: (loaded) => set(() => ({ isWorkflowDataLoaded: loaded })), nodes: [], - setNodes: nodes => set(() => ({ nodes })), + setNodes: (nodes) => set(() => ({ nodes })), flushPendingSync: () => { // Flush any pending debounced sync operations - if (debouncedFn.flush) - debouncedFn.flush() + if (debouncedFn.flush) debouncedFn.flush() }, } } diff --git a/web/app/components/workflow/store/workflow/workflow-slice.ts b/web/app/components/workflow/store/workflow/workflow-slice.ts index ba17f778cbf66e..47421620f88ab9 100644 --- a/web/app/components/workflow/store/workflow/workflow-slice.ts +++ b/web/app/components/workflow/store/workflow/workflow-slice.ts @@ -45,10 +45,10 @@ export type WorkflowSliceShape = { clipboardEdges: Edge[] setClipboardElements: (clipboardElements: Node[]) => void setClipboardEdges: (clipboardEdges: Edge[]) => void - setClipboardData: (clipboardData: { nodes: Node[], edges: Edge[] }) => void - selection: null | { x1: number, y1: number, x2: number, y2: number } + setClipboardData: (clipboardData: { nodes: Node[]; edges: Edge[] }) => void + selection: null | { x1: number; y1: number; x2: number; y2: number } setSelection: (selection: WorkflowSliceShape['selection']) => void - bundleNodeSize: { width: number, height: number } | null + bundleNodeSize: { width: number; height: number } | null setBundleNodeSize: (bundleNodeSize: WorkflowSliceShape['bundleNodeSize']) => void controlMode: 'pointer' | 'hand' | 'comment' setControlMode: (controlMode: WorkflowSliceShape['controlMode']) => void @@ -60,9 +60,9 @@ export type WorkflowSliceShape = { setCommentQuickAdd: (isCommentQuickAdd: boolean) => void isCommentPreviewHovering: boolean setCommentPreviewHovering: (hovering: boolean) => void - mousePosition: { pageX: number, pageY: number, elementX: number, elementY: number } + mousePosition: { pageX: number; pageY: number; elementX: number; elementY: number } setMousePosition: (mousePosition: WorkflowSliceShape['mousePosition']) => void - showConfirm?: { title: string, desc?: string, onConfirm: () => void } + showConfirm?: { title: string; desc?: string; onConfirm: () => void } setShowConfirm: (showConfirm: WorkflowSliceShape['showConfirm']) => void controlPromptEditorRerenderKey: number setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void @@ -72,25 +72,25 @@ export type WorkflowSliceShape = { setFileUploadConfig: (fileUploadConfig: FileUploadConfigResponse) => void } -export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({ +export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = (set) => ({ workflowRunningData: undefined, - setWorkflowRunningData: workflowRunningData => set(() => ({ workflowRunningData })), + setWorkflowRunningData: (workflowRunningData) => set(() => ({ workflowRunningData })), isListening: false, - setIsListening: listening => set(() => ({ isListening: listening })), + setIsListening: (listening) => set(() => ({ isListening: listening })), canvasReadOnly: false, - setCanvasReadOnly: canvasReadOnly => set(() => ({ canvasReadOnly })), + setCanvasReadOnly: (canvasReadOnly) => set(() => ({ canvasReadOnly })), listeningTriggerType: null, - setListeningTriggerType: triggerType => set(() => ({ listeningTriggerType: triggerType })), + setListeningTriggerType: (triggerType) => set(() => ({ listeningTriggerType: triggerType })), listeningTriggerNodeId: null, - setListeningTriggerNodeId: nodeId => set(() => ({ listeningTriggerNodeId: nodeId })), + setListeningTriggerNodeId: (nodeId) => set(() => ({ listeningTriggerNodeId: nodeId })), listeningTriggerNodeIds: [], - setListeningTriggerNodeIds: nodeIds => set(() => ({ listeningTriggerNodeIds: nodeIds })), + setListeningTriggerNodeIds: (nodeIds) => set(() => ({ listeningTriggerNodeIds: nodeIds })), listeningTriggerIsAll: false, - setListeningTriggerIsAll: isAll => set(() => ({ listeningTriggerIsAll: isAll })), + setListeningTriggerIsAll: (isAll) => set(() => ({ listeningTriggerIsAll: isAll })), clipboardElements: [], clipboardEdges: [], - setClipboardElements: clipboardElements => set(() => ({ clipboardElements })), - setClipboardEdges: clipboardEdges => set(() => ({ clipboardEdges })), + setClipboardElements: (clipboardElements) => set(() => ({ clipboardElements })), + setClipboardEdges: (clipboardEdges) => set(() => ({ clipboardEdges })), setClipboardData: ({ nodes, edges }) => { set(() => ({ clipboardElements: nodes, @@ -98,27 +98,28 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({ })) }, selection: null, - setSelection: selection => set(() => ({ selection })), + setSelection: (selection) => set(() => ({ selection })), bundleNodeSize: null, - setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })), + setBundleNodeSize: (bundleNodeSize) => set(() => ({ bundleNodeSize })), controlMode: 'pointer', - setControlMode: controlMode => set(() => ({ controlMode })), + setControlMode: (controlMode) => set(() => ({ controlMode })), pendingComment: null, - setPendingComment: pendingComment => set(() => ({ pendingComment })), + setPendingComment: (pendingComment) => set(() => ({ pendingComment })), isCommentPlacing: false, - setCommentPlacing: isCommentPlacing => set(() => ({ isCommentPlacing })), + setCommentPlacing: (isCommentPlacing) => set(() => ({ isCommentPlacing })), isCommentQuickAdd: false, - setCommentQuickAdd: isCommentQuickAdd => set(() => ({ isCommentQuickAdd })), + setCommentQuickAdd: (isCommentQuickAdd) => set(() => ({ isCommentQuickAdd })), mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 }, - setMousePosition: mousePosition => set(() => ({ mousePosition })), + setMousePosition: (mousePosition) => set(() => ({ mousePosition })), isCommentPreviewHovering: false, - setCommentPreviewHovering: hovering => set(() => ({ isCommentPreviewHovering: hovering })), + setCommentPreviewHovering: (hovering) => set(() => ({ isCommentPreviewHovering: hovering })), showConfirm: undefined, - setShowConfirm: showConfirm => set(() => ({ showConfirm })), + setShowConfirm: (showConfirm) => set(() => ({ showConfirm })), controlPromptEditorRerenderKey: 0, - setControlPromptEditorRerenderKey: controlPromptEditorRerenderKey => set(() => ({ controlPromptEditorRerenderKey })), + setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey) => + set(() => ({ controlPromptEditorRerenderKey })), showImportDSLModal: false, - setShowImportDSLModal: showImportDSLModal => set(() => ({ showImportDSLModal })), + setShowImportDSLModal: (showImportDSLModal) => set(() => ({ showImportDSLModal })), fileUploadConfig: undefined, - setFileUploadConfig: fileUploadConfig => set(() => ({ fileUploadConfig })), + setFileUploadConfig: (fileUploadConfig) => set(() => ({ fileUploadConfig })), }) diff --git a/web/app/components/workflow/style.css b/web/app/components/workflow/style.css index c89738c30f0b71..dc7e5a7043f080 100644 --- a/web/app/components/workflow/style.css +++ b/web/app/components/workflow/style.css @@ -13,13 +13,13 @@ } #workflow-container .react-flow__nodesselection-rect { - border: 1px solid #528BFF; + border: 1px solid #528bff; background: rgba(21, 94, 239, 0.05); cursor: move; } #workflow-container .react-flow__selection { - border: 1px solid #528BFF; + border: 1px solid #528bff; background: rgba(21, 94, 239, 0.05); } diff --git a/web/app/components/workflow/syncing-data-modal.tsx b/web/app/components/workflow/syncing-data-modal.tsx index 735096c4103010..976ce1f8c2f845 100644 --- a/web/app/components/workflow/syncing-data-modal.tsx +++ b/web/app/components/workflow/syncing-data-modal.tsx @@ -1,15 +1,11 @@ import { useStore } from './store' const SyncingDataModal = () => { - const isSyncingWorkflowDraft = useStore(s => s.isSyncingWorkflowDraft) + const isSyncingWorkflowDraft = useStore((s) => s.isSyncingWorkflowDraft) - if (!isSyncingWorkflowDraft) - return null + if (!isSyncingWorkflowDraft) return null - return ( - <div className="absolute inset-0 z-50"> - </div> - ) + return <div className="absolute inset-0 z-50"></div> } export default SyncingDataModal diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index b122a0666d7fe7..8c551ababb5f51 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -1,12 +1,11 @@ -import type { - Edge as ReactFlowEdge, - Node as ReactFlowNode, - Viewport, - XYPosition, -} from 'reactflow' +import type { Edge as ReactFlowEdge, Node as ReactFlowNode, Viewport, XYPosition } from 'reactflow' import type { Plugin, PluginMeta } from '@/app/components/plugins/types' import type { Collection, Tool } from '@/app/components/tools/types' -import type { BlockClassificationEnum, BlockDefaultValue, PluginDefaultValue } from '@/app/components/workflow/block-selector/types' +import type { + BlockClassificationEnum, + BlockDefaultValue, + PluginDefaultValue, +} from '@/app/components/workflow/block-selector/types' import type { DefaultValueForm, ErrorHandleTypeEnum, @@ -85,7 +84,7 @@ export type CommonNodeType<T = {}> = { _singleRunningStatus?: NodeRunningStatus _isCandidate?: boolean _isBundled?: boolean - _children?: { nodeId: string, nodeType: BlockEnum }[] + _children?: { nodeId: string; nodeType: BlockEnum }[] _isEntering?: boolean _showAddVariablePopup?: boolean _holdAddVariablePopup?: boolean @@ -116,7 +115,8 @@ export type CommonNodeType<T = {}> = { subscription_id?: string provider_id?: string _dimmed?: boolean -} & T & Partial<PluginDefaultValue> +} & T & + Partial<PluginDefaultValue> export type CommonEdgeType = { _hovering?: boolean @@ -136,7 +136,7 @@ export type CommonEdgeType = { } export type Node<T = {}> = ReactFlowNode<CommonNodeType<T>> -export type NodeProps<T = unknown> = { id: string, data: CommonNodeType<T> } +export type NodeProps<T = unknown> = { id: string; data: CommonNodeType<T> } export type NodePanelProps<T> = { id: string data: CommonNodeType<T> @@ -154,11 +154,13 @@ export type ValueSelector = string[] // [nodeId, key | obj key path] export type Variable = { variable: string - label?: string | { - nodeType: BlockEnum - nodeName: string - variable: string - } + label?: + | string + | { + nodeType: BlockEnum + nodeName: string + variable: string + } value_selector: ValueSelector value_type?: VarType variable_type?: VarKindType @@ -209,12 +211,14 @@ export enum InputVarType { export type InputVar = { type: InputVarType - label: string | { - nodeType: BlockEnum - nodeName: string - variable: string - isChatVar?: boolean - } + label: + | string + | { + nodeType: BlockEnum + nodeName: string + variable: string + isChatVar?: boolean + } variable: string max_length?: number default?: string | number | boolean @@ -340,10 +344,19 @@ export type NodeDefault<T = {}> = { } defaultValue: Partial<T> defaultRunInputData?: Record<string, any> - checkValid: (payload: T, t: any, moreDataForCheckValid?: any) => { isValid: boolean, errorMessage?: string } - getOutputVars?: (payload: T, allPluginInfoList: Record<string, ToolWithProvider[]>, ragVariables?: Var[], utils?: { - schemaTypeDefinitions?: SchemaTypeDefinition[] - }) => Var[] + checkValid: ( + payload: T, + t: any, + moreDataForCheckValid?: any, + ) => { isValid: boolean; errorMessage?: string } + getOutputVars?: ( + payload: T, + allPluginInfoList: Record<string, ToolWithProvider[]>, + ragVariables?: Var[], + utils?: { + schemaTypeDefinitions?: SchemaTypeDefinition[] + }, + ) => Var[] } export type OnSelectBlock = (type: BlockEnum, defaultValue?: BlockDefaultValue) => void @@ -508,7 +521,7 @@ const TRIGGER_NODE_TYPES = [ ] as const // Type-safe trigger node type extracted from TRIGGER_NODE_TYPES array -export type TriggerNodeType = typeof TRIGGER_NODE_TYPES[number] +export type TriggerNodeType = (typeof TRIGGER_NODE_TYPES)[number] export function isTriggerNode(nodeType: BlockEnum): boolean { return TRIGGER_NODE_TYPES.includes(nodeType as any) diff --git a/web/app/components/workflow/update-dsl-modal.helpers.ts b/web/app/components/workflow/update-dsl-modal.helpers.ts index bec8e8e6dcfd37..ad257b441b3a24 100644 --- a/web/app/components/workflow/update-dsl-modal.helpers.ts +++ b/web/app/components/workflow/update-dsl-modal.helpers.ts @@ -63,8 +63,7 @@ export const validateDSLContent = (content: string, mode?: AppModeEnum) => { const nodes = data?.workflow?.graph?.nodes ?? [] const invalidNodes = getInvalidNodeTypes(mode) return !nodes.some((node: Node<CommonNodeType>) => invalidNodes.includes(node?.data?.type)) - } - catch { + } catch { return false } } @@ -73,15 +72,20 @@ export const isImportCompleted = (status: DSLImportStatus) => { return status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS } -export const getImportNotificationPayload = (status: DSLImportStatus, t: TFunction): ImportNotificationPayload => { +export const getImportNotificationPayload = ( + status: DSLImportStatus, + t: TFunction, +): ImportNotificationPayload => { return { type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', - message: status === DSLImportStatus.COMPLETED - ? t($ => $['common.importSuccess'], { ns: 'workflow' }) - : t($ => $['common.importWarning'], { ns: 'workflow' }), - children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS - ? t($ => $['common.importWarningDetails'], { ns: 'workflow' }) - : undefined, + message: + status === DSLImportStatus.COMPLETED + ? t(($) => $['common.importSuccess'], { ns: 'workflow' }) + : t(($) => $['common.importWarning'], { ns: 'workflow' }), + children: + status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ? t(($) => $['common.importWarningDetails'], { ns: 'workflow' }) + : undefined, } } @@ -91,13 +95,22 @@ export const normalizeWorkflowFeatures = (features: WorkflowFeatures) => { image: { enabled: !!features.file_upload?.image?.enabled, number_limits: features.file_upload?.image?.number_limits || 3, - transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + transfer_methods: features.file_upload?.image?.transfer_methods || [ + 'local_file', + 'remote_url', + ], }, enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled), - allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image], - allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image]!.map(ext => `.${ext}`), - allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], - number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3, + allowed_file_types: features.file_upload?.allowed_file_types || [ + SupportUploadFileTypes.image, + ], + allowed_file_extensions: + features.file_upload?.allowed_file_extensions || + FILE_EXTS[SupportUploadFileTypes.image]!.map((ext) => `.${ext}`), + allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || + features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + number_limits: + features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3, }, opening: { enabled: !!features.opening_statement, diff --git a/web/app/components/workflow/update-dsl-modal.tsx b/web/app/components/workflow/update-dsl-modal.tsx index ada3c9bbd5293e..3537c84afc3e72 100644 --- a/web/app/components/workflow/update-dsl-modal.tsx +++ b/web/app/components/workflow/update-dsl-modal.tsx @@ -4,30 +4,15 @@ import type { MouseEventHandler } from 'react' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' -import { - RiAlertFill, - RiCloseLine, - RiFileDownloadLine, -} from '@remixicon/react' -import { - memo, - useCallback, - useRef, - useState, -} from 'react' +import { RiAlertFill, RiCloseLine, RiFileDownloadLine } from '@remixicon/react' +import { memo, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Uploader from '@/app/components/app/create-from-dsl-modal/uploader' import { useStore as useAppStore } from '@/app/components/app/store' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { useEventEmitterContextContext } from '@/context/event-emitter' -import { - DSLImportMode, - DSLImportStatus, -} from '@/models/app' -import { - importDSL, - importDSLConfirm, -} from '@/service/apps' +import { DSLImportMode, DSLImportStatus } from '@/models/app' +import { importDSL, importDSLConfirm } from '@/service/apps' import { fetchWorkflowDraft } from '@/service/workflow' import { collaborationManager } from './collaboration/core/collaboration-manager' import { WORKFLOW_DATA_UPDATE } from './constants' @@ -37,10 +22,7 @@ import { normalizeWorkflowFeatures, validateDSLContent, } from './update-dsl-modal.helpers' -import { - initialEdges, - initialNodes, -} from './utils' +import { initialEdges, initialNodes } from './utils' type UpdateDSLModalProps = { onCancel: () => void @@ -48,20 +30,16 @@ type UpdateDSLModalProps = { onImport?: () => void } -const UpdateDSLModal = ({ - onCancel, - onBackup, - onImport, -}: UpdateDSLModalProps) => { +const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) => { const { t } = useTranslation() - const appDetail = useAppStore(s => s.appDetail) + const appDetail = useAppStore((s) => s.appDetail) const [currentFile, setDSLFile] = useState<File>() const [fileContent, setFileContent] = useState<string>() const [loading, setLoading] = useState(false) const { eventEmitter } = useEventEmitterContextContext() const [show, setShow] = useState(true) const [showErrorModal, setShowErrorModal] = useState(false) - const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>() + const [versions, setVersions] = useState<{ importedVersion: string; systemVersion: string }>() const [importId, setImportId] = useState<string>() const { handleCheckPluginDependencies } = usePluginDependencies() @@ -76,68 +54,72 @@ const UpdateDSLModal = ({ const handleFile = (file?: File) => { setDSLFile(file) - if (file) - readFile(file) - if (!file) - setFileContent('') + if (file) readFile(file) + if (!file) setFileContent('') } - const handleWorkflowUpdate = useCallback(async (app_id: string) => { - const { - graph, - features, - hash, - conversation_variables, - environment_variables, - } = await fetchWorkflowDraft(`/apps/${app_id}/workflows/draft`) + const handleWorkflowUpdate = useCallback( + async (app_id: string) => { + const { graph, features, hash, conversation_variables, environment_variables } = + await fetchWorkflowDraft(`/apps/${app_id}/workflows/draft`) - const { nodes, edges, viewport } = graph - eventEmitter?.emit({ - type: WORKFLOW_DATA_UPDATE, - payload: { - nodes: initialNodes(nodes, edges), - edges: initialEdges(edges, nodes), - viewport, - features: normalizeWorkflowFeatures(features), - hash, - conversation_variables: conversation_variables || [], - environment_variables: environment_variables || [], - }, - } as any) - }, [eventEmitter]) + const { nodes, edges, viewport } = graph + eventEmitter?.emit({ + type: WORKFLOW_DATA_UPDATE, + payload: { + nodes: initialNodes(nodes, edges), + edges: initialEdges(edges, nodes), + viewport, + features: normalizeWorkflowFeatures(features), + hash, + conversation_variables: conversation_variables || [], + environment_variables: environment_variables || [], + }, + } as any) + }, + [eventEmitter], + ) const isCreatingRef = useRef(false) - const handleCompletedImport = useCallback(async (status: DSLImportStatus, appId?: string) => { - if (!appId) { - toast.error(t($ => $['common.importFailure'], { ns: 'workflow' })) - return - } + const handleCompletedImport = useCallback( + async (status: DSLImportStatus, appId?: string) => { + if (!appId) { + toast.error(t(($) => $['common.importFailure'], { ns: 'workflow' })) + return + } - await handleWorkflowUpdate(appId) - collaborationManager.emitWorkflowUpdate(appId) - onImport?.() - const payload = getImportNotificationPayload(status, t) - toast[payload.type](payload.message, payload.children ? { description: payload.children } : undefined) - await handleCheckPluginDependencies(appId) - setLoading(false) - onCancel() - }, [handleCheckPluginDependencies, handleWorkflowUpdate, onCancel, onImport, t]) + await handleWorkflowUpdate(appId) + collaborationManager.emitWorkflowUpdate(appId) + onImport?.() + const payload = getImportNotificationPayload(status, t) + toast[payload.type]( + payload.message, + payload.children ? { description: payload.children } : undefined, + ) + await handleCheckPluginDependencies(appId) + setLoading(false) + onCancel() + }, + [handleCheckPluginDependencies, handleWorkflowUpdate, onCancel, onImport, t], + ) - const handlePendingImport = useCallback((id: string, importedVersion?: string | null, currentVersion?: string | null) => { - setShow(false) - setTimeout(() => { - setShowErrorModal(true) - }, 300) - setVersions({ - importedVersion: importedVersion ?? '', - systemVersion: currentVersion ?? '', - }) - setImportId(id) - }, []) + const handlePendingImport = useCallback( + (id: string, importedVersion?: string | null, currentVersion?: string | null) => { + setShow(false) + setTimeout(() => { + setShowErrorModal(true) + }, 300) + setVersions({ + importedVersion: importedVersion ?? '', + systemVersion: currentVersion ?? '', + }) + setImportId(id) + }, + [], + ) const handleImport: MouseEventHandler = useCallback(async () => { - if (isCreatingRef.current) - return + if (isCreatingRef.current) return isCreatingRef.current = true if (!currentFile) { isCreatingRef.current = false @@ -146,36 +128,34 @@ const UpdateDSLModal = ({ try { if (appDetail && fileContent && validateDSLContent(fileContent, appDetail.mode)) { setLoading(true) - const response = await importDSL({ mode: DSLImportMode.YAML_CONTENT, yaml_content: fileContent, app_id: appDetail.id }) + const response = await importDSL({ + mode: DSLImportMode.YAML_CONTENT, + yaml_content: fileContent, + app_id: appDetail.id, + }) const { id, status, app_id, imported_dsl_version, current_dsl_version } = response if (isImportCompleted(status)) { await handleCompletedImport(status, app_id) - } - else if (status === DSLImportStatus.PENDING) { + } else if (status === DSLImportStatus.PENDING) { handlePendingImport(id, imported_dsl_version, current_dsl_version) - } - else { + } else { setLoading(false) - toast.error(t($ => $['common.importFailure'], { ns: 'workflow' })) + toast.error(t(($) => $['common.importFailure'], { ns: 'workflow' })) } + } else if (fileContent) { + toast.error(t(($) => $['common.importFailure'], { ns: 'workflow' })) } - else if (fileContent) { - toast.error(t($ => $['common.importFailure'], { ns: 'workflow' })) - } - } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { + } catch { setLoading(false) - toast.error(t($ => $['common.importFailure'], { ns: 'workflow' })) + toast.error(t(($) => $['common.importFailure'], { ns: 'workflow' })) } isCreatingRef.current = false }, [currentFile, fileContent, t, appDetail, handleCompletedImport, handlePendingImport]) const onUpdateDSLConfirm: MouseEventHandler = async () => { try { - if (!importId) - return + if (!importId) return const response = await importDSLConfirm({ import_id: importId, }) @@ -184,16 +164,13 @@ const UpdateDSLModal = ({ if (isImportCompleted(status)) { await handleCompletedImport(status, app_id) - } - else if (status === DSLImportStatus.FAILED) { + } else if (status === DSLImportStatus.FAILED) { setLoading(false) - toast.error(t($ => $['common.importFailure'], { ns: 'workflow' })) + toast.error(t(($) => $['common.importFailure'], { ns: 'workflow' })) } - } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { + } catch { setLoading(false) - toast.error(t($ => $['common.importFailure'], { ns: 'workflow' })) + toast.error(t(($) => $['common.importFailure'], { ns: 'workflow' })) } } @@ -202,18 +179,18 @@ const UpdateDSLModal = ({ <Dialog open={show} onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} > <DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-none p-6 text-left align-middle"> - <div className="mb-3 flex items-center justify-between"> - <div className="title-2xl-semi-bold text-text-primary">{t($ => $.importApp, { ns: 'app' })}</div> + <div className="title-2xl-semi-bold text-text-primary"> + {t(($) => $.importApp, { ns: 'app' })} + </div> <button type="button" className="flex h-[22px] w-[22px] cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.close'], { ns: 'common' })} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onCancel} > <RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" aria-hidden="true" /> @@ -225,17 +202,14 @@ const UpdateDSLModal = ({ <RiAlertFill className="size-4 shrink-0 text-text-warning-secondary" /> </div> <div className="flex grow flex-col items-start gap-0.5 py-1"> - <div className="system-xs-medium whitespace-pre-line text-text-primary">{t($ => $['common.importDSLTip'], { ns: 'workflow' })}</div> + <div className="system-xs-medium whitespace-pre-line text-text-primary"> + {t(($) => $['common.importDSLTip'], { ns: 'workflow' })} + </div> <div className="flex items-start gap-1 self-stretch pt-1 pb-0.5"> - <Button - size="small" - variant="secondary" - className="relative" - onClick={onBackup} - > + <Button size="small" variant="secondary" className="relative" onClick={onBackup}> <RiFileDownloadLine className="size-3.5 text-components-button-secondary-text" /> <div className="flex items-center justify-center gap-1 px-[3px]"> - {t($ => $['common.backupCurrentDraft'], { ns: 'workflow' })} + {t(($) => $['common.backupCurrentDraft'], { ns: 'workflow' })} </div> </Button> </div> @@ -243,18 +217,14 @@ const UpdateDSLModal = ({ </div> <div> <div className="pt-2 system-md-semibold text-text-primary"> - {t($ => $['common.chooseDSL'], { ns: 'workflow' })} + {t(($) => $['common.chooseDSL'], { ns: 'workflow' })} </div> <div className="flex w-full flex-col items-start justify-center gap-4 self-stretch py-4"> - <Uploader - file={currentFile} - updateFile={handleFile} - className="mt-0! w-full" - /> + <Uploader file={currentFile} updateFile={handleFile} className="mt-0! w-full" /> </div> </div> <div className="flex items-center justify-end gap-2 self-stretch pt-5"> - <Button onClick={onCancel}>{t($ => $['newApp.Cancel'], { ns: 'app' })}</Button> + <Button onClick={onCancel}>{t(($) => $['newApp.Cancel'], { ns: 'app' })}</Button> <Button disabled={!currentFile || loading} variant="primary" @@ -262,7 +232,7 @@ const UpdateDSLModal = ({ onClick={handleImport} loading={loading} > - {t($ => $['common.overwriteAndImport'], { ns: 'workflow' })} + {t(($) => $['common.overwriteAndImport'], { ns: 'workflow' })} </Button> </div> </DialogContent> @@ -270,31 +240,35 @@ const UpdateDSLModal = ({ <Dialog open={showErrorModal} onOpenChange={(open) => { - if (!open) - setShowErrorModal(false) + if (!open) setShowErrorModal(false) }} > <DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none text-left align-middle"> - <div className="flex flex-col items-start gap-2 self-stretch pb-4"> - <div className="title-2xl-semi-bold text-text-primary">{t($ => $['newApp.appCreateDSLErrorTitle'], { ns: 'app' })}</div> + <div className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['newApp.appCreateDSLErrorTitle'], { ns: 'app' })} + </div> <div className="flex grow flex-col system-md-regular text-text-secondary"> - <div>{t($ => $['newApp.appCreateDSLErrorPart1'], { ns: 'app' })}</div> - <div>{t($ => $['newApp.appCreateDSLErrorPart2'], { ns: 'app' })}</div> + <div>{t(($) => $['newApp.appCreateDSLErrorPart1'], { ns: 'app' })}</div> + <div>{t(($) => $['newApp.appCreateDSLErrorPart2'], { ns: 'app' })}</div> <br /> <div> - {t($ => $['newApp.appCreateDSLErrorPart3'], { ns: 'app' })} + {t(($) => $['newApp.appCreateDSLErrorPart3'], { ns: 'app' })} <span className="system-md-medium">{versions?.importedVersion}</span> </div> <div> - {t($ => $['newApp.appCreateDSLErrorPart4'], { ns: 'app' })} + {t(($) => $['newApp.appCreateDSLErrorPart4'], { ns: 'app' })} <span className="system-md-medium">{versions?.systemVersion}</span> </div> </div> </div> <div className="flex items-start justify-end gap-2 self-stretch pt-6"> - <Button variant="secondary" onClick={() => setShowErrorModal(false)}>{t($ => $['newApp.Cancel'], { ns: 'app' })}</Button> - <Button variant="primary" tone="destructive" onClick={onUpdateDSLConfirm}>{t($ => $['newApp.Confirm'], { ns: 'app' })}</Button> + <Button variant="secondary" onClick={() => setShowErrorModal(false)}> + {t(($) => $['newApp.Cancel'], { ns: 'app' })} + </Button> + <Button variant="primary" tone="destructive" onClick={onUpdateDSLConfirm}> + {t(($) => $['newApp.Confirm'], { ns: 'app' })} + </Button> </div> </DialogContent> </Dialog> diff --git a/web/app/components/workflow/utils/__tests__/clipboard.spec.ts b/web/app/components/workflow/utils/__tests__/clipboard.spec.ts index ccb3f426d4269c..3cd95004f0fd25 100644 --- a/web/app/components/workflow/utils/__tests__/clipboard.spec.ts +++ b/web/app/components/workflow/utils/__tests__/clipboard.spec.ts @@ -53,12 +53,14 @@ describe('workflow clipboard storage', () => { it('should allow reading clipboard data with different version', async () => { const nodes = [createNode({ id: 'node-1' })] const edges = [createEdge({ id: 'edge-1', source: 'node-1', target: 'node-2' })] - readTextMock.mockResolvedValue(JSON.stringify({ - kind: 'dify-workflow-clipboard', - version: '0.5.0', - nodes, - edges, - })) + readTextMock.mockResolvedValue( + JSON.stringify({ + kind: 'dify-workflow-clipboard', + version: '0.5.0', + nodes, + edges, + }), + ) await expect(readWorkflowClipboard(currentVersion)).resolves.toEqual({ nodes, @@ -77,12 +79,17 @@ describe('workflow clipboard storage', () => { }) it('should return empty clipboard data for invalid structure', () => { - expect(parseWorkflowClipboardText(JSON.stringify({ - kind: 'unknown', - version: 1, - nodes: [], - edges: [], - }), currentVersion)).toEqual({ + expect( + parseWorkflowClipboardText( + JSON.stringify({ + kind: 'unknown', + version: 1, + nodes: [], + edges: [], + }), + currentVersion, + ), + ).toEqual({ nodes: [], edges: [], isVersionMismatch: false, diff --git a/web/app/components/workflow/utils/__tests__/common.spec.ts b/web/app/components/workflow/utils/__tests__/common.spec.ts index 9dc096ca78b7f0..0719da55677e81 100644 --- a/web/app/components/workflow/utils/__tests__/common.spec.ts +++ b/web/app/components/workflow/utils/__tests__/common.spec.ts @@ -1,6 +1,4 @@ -import { - formatWorkflowRunIdentifier, -} from '../common' +import { formatWorkflowRunIdentifier } from '../common' describe('formatWorkflowRunIdentifier', () => { it('should return fallback text when finishedAt is undefined', () => { diff --git a/web/app/components/workflow/utils/__tests__/data-source.spec.ts b/web/app/components/workflow/utils/__tests__/data-source.spec.ts index 2de5b7f7176d15..22b31d7e2bd1a4 100644 --- a/web/app/components/workflow/utils/__tests__/data-source.spec.ts +++ b/web/app/components/workflow/utils/__tests__/data-source.spec.ts @@ -6,14 +6,15 @@ import { getDataSourceCheckParams } from '../data-source' vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ toolParametersToFormSchemas: vi.fn((params: Array<Record<string, unknown>>) => - params.map(p => ({ + params.map((p) => ({ variable: p.name, label: p.label || { en_US: p.name }, type: p.type || 'string', required: p.required ?? false, form: p.form ?? 'llm', hide: p.hide ?? false, - }))), + })), + ), })) function createDataSourceData(overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType { @@ -39,7 +40,12 @@ function createDataSourceCollection(overrides: Partial<ToolWithProvider> = {}): { name: 'mysql_query', parameters: [ - { name: 'query', label: { en_US: 'SQL Query', zh_Hans: 'SQL 查询' }, type: 'string', required: true }, + { + name: 'query', + label: { en_US: 'SQL Query', zh_Hans: 'SQL 查询' }, + type: 'string', + required: true, + }, { name: 'limit', label: { en_US: 'Limit' }, type: 'number', required: false, hide: true }, ], }, diff --git a/web/app/components/workflow/utils/__tests__/edge.spec.ts b/web/app/components/workflow/utils/__tests__/edge.spec.ts index e5067d1866228e..6b954c217bbfbd 100644 --- a/web/app/components/workflow/utils/__tests__/edge.spec.ts +++ b/web/app/components/workflow/utils/__tests__/edge.spec.ts @@ -3,15 +3,21 @@ import { getEdgeColor } from '../edge' describe('getEdgeColor', () => { it('should return success color when status is Succeeded', () => { - expect(getEdgeColor(NodeRunningStatus.Succeeded)).toBe('var(--color-workflow-link-line-success-handle)') + expect(getEdgeColor(NodeRunningStatus.Succeeded)).toBe( + 'var(--color-workflow-link-line-success-handle)', + ) }) it('should return error color when status is Failed', () => { - expect(getEdgeColor(NodeRunningStatus.Failed)).toBe('var(--color-workflow-link-line-error-handle)') + expect(getEdgeColor(NodeRunningStatus.Failed)).toBe( + 'var(--color-workflow-link-line-error-handle)', + ) }) it('should return failure color when status is Exception', () => { - expect(getEdgeColor(NodeRunningStatus.Exception)).toBe('var(--color-workflow-link-line-failure-handle)') + expect(getEdgeColor(NodeRunningStatus.Exception)).toBe( + 'var(--color-workflow-link-line-failure-handle)', + ) }) it('should return default running color when status is Running and not fail branch', () => { @@ -19,7 +25,9 @@ describe('getEdgeColor', () => { }) it('should return failure color when status is Running and is fail branch', () => { - expect(getEdgeColor(NodeRunningStatus.Running, true)).toBe('var(--color-workflow-link-line-failure-handle)') + expect(getEdgeColor(NodeRunningStatus.Running, true)).toBe( + 'var(--color-workflow-link-line-failure-handle)', + ) }) it('should return normal color when status is undefined', () => { diff --git a/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts b/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts index 7cbd5478e39b45..d4966e05690794 100644 --- a/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts +++ b/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts @@ -5,8 +5,21 @@ import { CUSTOM_ITERATION_START_NODE } from '../../nodes/iteration-start/constan import { CUSTOM_LOOP_START_NODE } from '../../nodes/loop-start/constants' import { BlockEnum } from '../../types' -type ElkChild = Record<string, unknown> & { id: string, width?: number, height?: number, x?: number, y?: number, children?: ElkChild[], ports?: Array<{ id: string, layoutOptions?: Record<string, string> }>, layoutOptions?: Record<string, string> } -type ElkGraph = Record<string, unknown> & { id: string, children?: ElkChild[], edges?: Array<Record<string, unknown>> } +type ElkChild = Record<string, unknown> & { + id: string + width?: number + height?: number + x?: number + y?: number + children?: ElkChild[] + ports?: Array<{ id: string; layoutOptions?: Record<string, string> }> + layoutOptions?: Record<string, string> +} +type ElkGraph = Record<string, unknown> & { + id: string + children?: ElkChild[] + edges?: Array<Record<string, unknown>> +} let layoutCallArgs: ElkGraph | null = null let mockReturnOverride: ((graph: ElkGraph) => ElkGraph) | null = null @@ -16,8 +29,7 @@ vi.mock('elkjs/lib/elk.bundled.js', () => { default: class MockELK { async layout(graph: ElkGraph) { layoutCallArgs = graph - if (mockReturnOverride) - return mockReturnOverride(graph) + if (mockReturnOverride) return mockReturnOverride(graph) const children = (graph.children || []).map((child: ElkChild, i: number) => ({ ...child, @@ -34,14 +46,22 @@ vi.mock('elkjs/lib/elk.bundled.js', () => { const { getLayoutByELK, getLayoutForChildNodes } = await import('../elk-layout') -function makeWorkflowNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node { +function makeWorkflowNode( + overrides: Omit<Partial<Node>, 'data'> & { + data?: Partial<CommonNodeType> & Record<string, unknown> + } = {}, +): Node { return createNode({ type: CUSTOM_NODE, ...overrides, }) } -function makeWorkflowEdge(overrides: Omit<Partial<Edge>, 'data'> & { data?: Partial<CommonEdgeType> & Record<string, unknown> } = {}): Edge { +function makeWorkflowEdge( + overrides: Omit<Partial<Edge>, 'data'> & { + data?: Partial<CommonEdgeType> & Record<string, unknown> + } = {}, +): Edge { return createEdge(overrides) } @@ -71,7 +91,11 @@ describe('getLayoutByELK', () => { it('should filter out nodes with parentId', async () => { const nodes = [ makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), - makeWorkflowNode({ id: 'child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'a' }), + makeWorkflowNode({ + id: 'child', + data: { type: BlockEnum.Code, title: '', desc: '' }, + parentId: 'a', + }), ] const result = await getLayoutByELK(nodes, []) @@ -82,7 +106,11 @@ describe('getLayoutByELK', () => { it('should filter out non-CUSTOM_NODE type nodes', async () => { const nodes = [ makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), - makeWorkflowNode({ id: 'iter-start', type: CUSTOM_ITERATION_START_NODE, data: { type: BlockEnum.IterationStart, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'iter-start', + type: CUSTOM_ITERATION_START_NODE, + data: { type: BlockEnum.IterationStart, title: '', desc: '' }, + }), ] const result = await getLayoutByELK(nodes, []) @@ -95,7 +123,11 @@ describe('getLayoutByELK', () => { makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] const edges = [ - makeWorkflowEdge({ source: 'a', target: 'b', data: { isInIteration: true, iteration_id: 'iter-1' } }), + makeWorkflowEdge({ + source: 'a', + target: 'b', + data: { isInIteration: true, iteration_id: 'iter-1' }, + }), ] await getLayoutByELK(nodes, edges) @@ -159,7 +191,12 @@ describe('getLayoutByELK', () => { const nodes = [ makeWorkflowNode({ id: 'hi-1', - data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [{ id: 'action-1' }, { id: 'action-2' }] }, + data: { + type: BlockEnum.HumanInput, + title: '', + desc: '', + user_actions: [{ id: 'action-1' }, { id: 'action-2' }], + }, }), makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }), @@ -178,7 +215,12 @@ describe('getLayoutByELK', () => { const nodes = [ makeWorkflowNode({ id: 'hi-1', - data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [{ id: 'action-1' }] }, + data: { + type: BlockEnum.HumanInput, + title: '', + desc: '', + user_actions: [{ id: 'action-1' }], + }, }), makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] @@ -191,7 +233,9 @@ describe('getLayoutByELK', () => { }) it('should normalise bounds so minX and minY start at 0', async () => { - const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })] + const nodes = [ + makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), + ] const result = await getLayoutByELK(nodes, []) expect(result.bounds.minX).toBe(0) expect(result.bounds.minY).toBe(0) @@ -237,7 +281,12 @@ describe('getLayoutByELK', () => { const nodes = [ makeWorkflowNode({ id: 'hi-1', - data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [{ id: 'a1' }, { id: 'a2' }] }, + data: { + type: BlockEnum.HumanInput, + title: '', + desc: '', + user_actions: [{ id: 'a1' }, { id: 'a2' }], + }, }), makeWorkflowNode({ id: 'x', data: { type: BlockEnum.Code, title: '', desc: '' } }), makeWorkflowNode({ id: 'y', data: { type: BlockEnum.Code, title: '', desc: '' } }), @@ -300,7 +349,9 @@ describe('getLayoutByELK', () => { })), }) - const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })] + const nodes = [ + makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), + ] const result = await getLayoutByELK(nodes, []) const info = result.nodes.get('a')! expect(info.x).toBe(0) @@ -401,7 +452,12 @@ describe('getLayoutByELK', () => { const nodes = [ makeWorkflowNode({ id: 'hi-1', - data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [{ id: 'known-action' }] }, + data: { + type: BlockEnum.HumanInput, + title: '', + desc: '', + user_actions: [{ id: 'known-action' }], + }, }), makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }), @@ -494,7 +550,11 @@ describe('getLayoutByELK', () => { type: BlockEnum.QuestionClassifier, title: '', desc: '', - classes: [{ id: 'cls-a', name: 'A' }, { id: 'cls-b', name: 'B' }, { id: 'cls-c', name: 'C' }], + classes: [ + { id: 'cls-a', name: 'A' }, + { id: 'cls-b', name: 'B' }, + { id: 'cls-c', name: 'C' }, + ], }, }), makeWorkflowNode({ id: 'x', data: { type: BlockEnum.Code, title: '', desc: '' } }), @@ -510,11 +570,7 @@ describe('getLayoutByELK', () => { await getLayoutByELK(nodes, edges) const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')! const portIds = qcNode.ports!.map((p: { id: string }) => p.id) - expect(portIds).toEqual([ - 'qc-1-out-cls-a', - 'qc-1-out-cls-b', - 'qc-1-out-cls-c', - ]) + expect(portIds).toEqual(['qc-1-out-cls-a', 'qc-1-out-cls-b', 'qc-1-out-cls-c']) }) it('should build ports for QuestionClassifier with single class', async () => { @@ -581,7 +637,12 @@ describe('getLayoutByELK', () => { ] const edges = [ makeWorkflowEdge({ source: 'start', target: 'if-1' }), - makeWorkflowEdge({ id: 'e-else', source: 'if-1', target: 'branch-else', sourceHandle: 'false' }), + makeWorkflowEdge({ + id: 'e-else', + source: 'if-1', + target: 'branch-else', + sourceHandle: 'false', + }), makeWorkflowEdge({ id: 'e-a', source: 'if-1', target: 'branch-a', sourceHandle: 'case-a' }), makeWorkflowEdge({ source: 'branch-a', target: 'end' }), makeWorkflowEdge({ source: 'branch-else', target: 'end' }), @@ -604,7 +665,10 @@ describe('getLayoutByELK', () => { type: BlockEnum.QuestionClassifier, title: '', desc: '', - classes: [{ id: 'c1', name: 'C1' }, { id: 'c2', name: 'C2' }], + classes: [ + { id: 'c1', name: 'C1' }, + { id: 'c2', name: 'C2' }, + ], }, }), makeWorkflowNode({ id: 'upper', data: { type: BlockEnum.Code, title: '', desc: '' } }), @@ -624,7 +688,10 @@ describe('getLayoutByELK', () => { it('should handle QuestionClassifier with no classes property', async () => { const nodes = [ - makeWorkflowNode({ id: 'qc-1', data: { type: BlockEnum.QuestionClassifier, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'qc-1', + data: { type: BlockEnum.QuestionClassifier, title: '', desc: '' }, + }), makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] @@ -642,7 +709,12 @@ describe('getLayoutByELK', () => { const nodes = [ makeWorkflowNode({ id: 'qc-1', - data: { type: BlockEnum.QuestionClassifier, title: '', desc: '', classes: [{ id: 'known', name: 'K' }] }, + data: { + type: BlockEnum.QuestionClassifier, + title: '', + desc: '', + classes: [{ id: 'known', name: 'K' }], + }, }), makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }), @@ -663,9 +735,7 @@ describe('getLayoutByELK', () => { makeWorkflowNode({ id: 'connected', data: { type: BlockEnum.Code, title: '', desc: '' } }), makeWorkflowNode({ id: 'isolated', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] - const edges = [ - makeWorkflowEdge({ source: 'start', target: 'connected' }), - ] + const edges = [makeWorkflowEdge({ source: 'start', target: 'connected' })] await getLayoutByELK(nodes, edges) const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id) @@ -690,8 +760,8 @@ describe('getLayoutByELK', () => { ] await getLayoutByELK(nodes, edges) - const elkEdges = layoutCallArgs!.edges as Array<{ sources: string[], targets: string[] }> - const ifEdges = elkEdges.filter(e => e.sources[0] === 'if-1') + const elkEdges = layoutCallArgs!.edges as Array<{ sources: string[]; targets: string[] }> + const ifEdges = elkEdges.filter((e) => e.sources[0] === 'if-1') expect(ifEdges[0]!.targets[0]).toBe('a') expect(ifEdges[1]!.targets[0]).toBe('b') }) @@ -713,13 +783,15 @@ describe('getLayoutByELK', () => { await getLayoutByELK(nodes, edges) - const elkEdges = layoutCallArgs!.edges as Array<{ sources: string[], targets: string[] }> + const elkEdges = layoutCallArgs!.edges as Array<{ sources: string[]; targets: string[] }> expect(elkEdges).toHaveLength(3) - expect(elkEdges).toEqual(expect.arrayContaining([ - expect.objectContaining({ sources: ['if-1'], targets: ['a'] }), - expect.objectContaining({ sources: ['if-1'], targets: ['b'] }), - expect.objectContaining({ sources: ['a'], targets: ['if-1'] }), - ])) + expect(elkEdges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sources: ['if-1'], targets: ['a'] }), + expect.objectContaining({ sources: ['if-1'], targets: ['b'] }), + expect.objectContaining({ sources: ['a'], targets: ['if-1'] }), + ]), + ) }) it('should filter loop internal edges', async () => { @@ -753,10 +825,18 @@ describe('getLayoutForChildNodes', () => { parentId: 'parent', data: { type: BlockEnum.IterationStart, title: '', desc: '' }, }), - makeWorkflowNode({ id: 'child-1', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'child-1', + parentId: 'parent', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), ] const edges = [ - makeWorkflowEdge({ source: 'iter-start', target: 'child-1', data: { isInIteration: true, iteration_id: 'parent' } }), + makeWorkflowEdge({ + source: 'iter-start', + target: 'child-1', + data: { isInIteration: true, iteration_id: 'parent' }, + }), ] const result = await getLayoutForChildNodes('parent', nodes, edges) @@ -774,10 +854,18 @@ describe('getLayoutForChildNodes', () => { parentId: 'loop-p', data: { type: BlockEnum.LoopStart, title: '', desc: '' }, }), - makeWorkflowNode({ id: 'loop-child', parentId: 'loop-p', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'loop-child', + parentId: 'loop-p', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), ] const edges = [ - makeWorkflowEdge({ source: 'loop-start', target: 'loop-child', data: { isInLoop: true, loop_id: 'loop-p' } }), + makeWorkflowEdge({ + source: 'loop-start', + target: 'loop-child', + data: { isInLoop: true, loop_id: 'loop-p' }, + }), ] const result = await getLayoutForChildNodes('loop-p', nodes, edges) @@ -787,12 +875,28 @@ describe('getLayoutForChildNodes', () => { it('should only include edges belonging to the parent iteration', async () => { const nodes = [ - makeWorkflowNode({ id: 'child-a', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), - makeWorkflowNode({ id: 'child-b', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'child-a', + parentId: 'parent', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), + makeWorkflowNode({ + id: 'child-b', + parentId: 'parent', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), ] const edges = [ - makeWorkflowEdge({ source: 'child-a', target: 'child-b', data: { isInIteration: true, iteration_id: 'parent' } }), - makeWorkflowEdge({ source: 'x', target: 'y', data: { isInIteration: true, iteration_id: 'other-parent' } }), + makeWorkflowEdge({ + source: 'child-a', + target: 'child-b', + data: { isInIteration: true, iteration_id: 'parent' }, + }), + makeWorkflowEdge({ + source: 'x', + target: 'y', + data: { isInIteration: true, iteration_id: 'other-parent' }, + }), ] await getLayoutForChildNodes('parent', nodes, edges) @@ -818,7 +922,11 @@ describe('getLayoutForChildNodes', () => { parentId: 'parent', data: { type: BlockEnum.IterationStart, title: '', desc: '' }, }), - makeWorkflowNode({ id: 'child', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'child', + parentId: 'parent', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), ] const result = await getLayoutForChildNodes('parent', nodes, []) @@ -846,7 +954,11 @@ describe('getLayoutForChildNodes', () => { parentId: 'parent', data: { type: BlockEnum.IterationStart, title: '', desc: '' }, }), - makeWorkflowNode({ id: 'child', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'child', + parentId: 'parent', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), ] const result = await getLayoutForChildNodes('parent', nodes, []) @@ -855,8 +967,16 @@ describe('getLayoutForChildNodes', () => { it('should handle child nodes identified by data type LoopStart', async () => { const nodes = [ - makeWorkflowNode({ id: 'ls', parentId: 'parent', data: { type: BlockEnum.LoopStart, title: '', desc: '' } }), - makeWorkflowNode({ id: 'child', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'ls', + parentId: 'parent', + data: { type: BlockEnum.LoopStart, title: '', desc: '' }, + }), + makeWorkflowNode({ + id: 'child', + parentId: 'parent', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), ] const result = await getLayoutForChildNodes('parent', nodes, []) @@ -866,8 +986,16 @@ describe('getLayoutForChildNodes', () => { it('should handle child nodes identified by data type IterationStart', async () => { const nodes = [ - makeWorkflowNode({ id: 'is', parentId: 'parent', data: { type: BlockEnum.IterationStart, title: '', desc: '' } }), - makeWorkflowNode({ id: 'child', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'is', + parentId: 'parent', + data: { type: BlockEnum.IterationStart, title: '', desc: '' }, + }), + makeWorkflowNode({ + id: 'child', + parentId: 'parent', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), ] const result = await getLayoutForChildNodes('parent', nodes, []) @@ -877,8 +1005,16 @@ describe('getLayoutForChildNodes', () => { it('should handle no start node in child layout', async () => { const nodes = [ - makeWorkflowNode({ id: 'c1', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), - makeWorkflowNode({ id: 'c2', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'c1', + parentId: 'parent', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), + makeWorkflowNode({ + id: 'c2', + parentId: 'parent', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), ] const result = await getLayoutForChildNodes('parent', nodes, []) @@ -902,16 +1038,43 @@ describe('getLayoutForChildNodes', () => { type: BlockEnum.QuestionClassifier, title: '', desc: '', - classes: [{ id: 'cls-1', name: 'C1' }, { id: 'cls-2', name: 'C2' }], + classes: [ + { id: 'cls-1', name: 'C1' }, + { id: 'cls-2', name: 'C2' }, + ], }, }), - makeWorkflowNode({ id: 'upper', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), - makeWorkflowNode({ id: 'lower', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'upper', + parentId: 'parent', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), + makeWorkflowNode({ + id: 'lower', + parentId: 'parent', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), ] const edges = [ - makeWorkflowEdge({ source: 'iter-start', target: 'qc-child', data: { isInIteration: true, iteration_id: 'parent' } }), - makeWorkflowEdge({ id: 'e-c2', source: 'qc-child', target: 'lower', sourceHandle: 'cls-2', data: { isInIteration: true, iteration_id: 'parent' } }), - makeWorkflowEdge({ id: 'e-c1', source: 'qc-child', target: 'upper', sourceHandle: 'cls-1', data: { isInIteration: true, iteration_id: 'parent' } }), + makeWorkflowEdge({ + source: 'iter-start', + target: 'qc-child', + data: { isInIteration: true, iteration_id: 'parent' }, + }), + makeWorkflowEdge({ + id: 'e-c2', + source: 'qc-child', + target: 'lower', + sourceHandle: 'cls-2', + data: { isInIteration: true, iteration_id: 'parent' }, + }), + makeWorkflowEdge({ + id: 'e-c1', + source: 'qc-child', + target: 'upper', + sourceHandle: 'cls-1', + data: { isInIteration: true, iteration_id: 'parent' }, + }), ] await getLayoutForChildNodes('parent', nodes, edges) @@ -932,7 +1095,11 @@ describe('getLayoutForChildNodes', () => { }) const nodes = [ - makeWorkflowNode({ id: 'c1', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'c1', + parentId: 'parent', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), ] const result = await getLayoutForChildNodes('parent', nodes, []) diff --git a/web/app/components/workflow/utils/__tests__/node.spec.ts b/web/app/components/workflow/utils/__tests__/node.spec.ts index aa80546b72dca7..50874ea57919fa 100644 --- a/web/app/components/workflow/utils/__tests__/node.spec.ts +++ b/web/app/components/workflow/utils/__tests__/node.spec.ts @@ -27,27 +27,33 @@ describe('nested workflow node layering', () => { describe('getNodeCatalogType', () => { it('should use Agent V2 catalog type for graph agent nodes with the Agent V2 discriminator', () => { - expect(getNodeCatalogType({ - title: 'Agent', - desc: '', - type: BlockEnum.Agent, - agent_node_kind: 'dify_agent', - version: '2', - } as CommonNodeType)).toBe(BlockEnum.AgentV2) + expect( + getNodeCatalogType({ + title: 'Agent', + desc: '', + type: BlockEnum.Agent, + agent_node_kind: 'dify_agent', + version: '2', + } as CommonNodeType), + ).toBe(BlockEnum.AgentV2) }) it('should keep the graph node type for regular nodes and legacy Agent nodes', () => { - expect(getNodeCatalogType({ - title: 'Code', - desc: '', - type: BlockEnum.Code, - })).toBe(BlockEnum.Code) - - expect(getNodeCatalogType({ - title: 'Agent', - desc: '', - type: BlockEnum.Agent, - })).toBe(BlockEnum.Agent) + expect( + getNodeCatalogType({ + title: 'Code', + desc: '', + type: BlockEnum.Code, + }), + ).toBe(BlockEnum.Code) + + expect( + getNodeCatalogType({ + title: 'Agent', + desc: '', + type: BlockEnum.Agent, + }), + ).toBe(BlockEnum.Agent) }) }) @@ -203,10 +209,7 @@ describe('getTopLeftNodePosition', () => { }) it('should handle negative positions', () => { - const nodes = [ - { position: { x: -10, y: -20 } }, - { position: { x: 5, y: -30 } }, - ] as Node[] + const nodes = [{ position: { x: -10, y: -20 } }, { position: { x: 5, y: -30 } }] as Node[] expect(getTopLeftNodePosition(nodes)).toEqual({ x: -10, y: -30 }) }) diff --git a/web/app/components/workflow/utils/__tests__/plugin-install-check.spec.ts b/web/app/components/workflow/utils/__tests__/plugin-install-check.spec.ts index ce085451b85a46..c19a6c0e4183d9 100644 --- a/web/app/components/workflow/utils/__tests__/plugin-install-check.spec.ts +++ b/web/app/components/workflow/utils/__tests__/plugin-install-check.spec.ts @@ -9,28 +9,32 @@ import { matchTriggerProvider, } from '../plugin-install-check' -const createTool = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({ - id: 'langgenius/search/search', - name: 'search', - plugin_id: 'plugin-search', - provider: 'search-provider', - plugin_unique_identifier: 'plugin-search@1.0.0', - ...overrides, -} as ToolWithProvider) - -const createTriggerProvider = (overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider => ({ - id: 'trigger-provider-id', - name: 'trigger-provider', - plugin_id: 'trigger-plugin', - ...overrides, -} as TriggerWithProvider) +const createTool = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => + ({ + id: 'langgenius/search/search', + name: 'search', + plugin_id: 'plugin-search', + provider: 'search-provider', + plugin_unique_identifier: 'plugin-search@1.0.0', + ...overrides, + }) as ToolWithProvider + +const createTriggerProvider = (overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider => + ({ + id: 'trigger-provider-id', + name: 'trigger-provider', + plugin_id: 'trigger-plugin', + ...overrides, + }) as TriggerWithProvider describe('plugin install check', () => { describe('matchToolInCollection', () => { const collection = [createTool()] it('should match a tool by plugin id', () => { - expect(matchToolInCollection(collection, { plugin_id: 'plugin-search' })).toEqual(collection[0]) + expect(matchToolInCollection(collection, { plugin_id: 'plugin-search' })).toEqual( + collection[0], + ) }) it('should match a tool by legacy provider id', () => { @@ -50,11 +54,15 @@ describe('plugin install check', () => { const providers = [createTriggerProvider()] it('should match a trigger provider by name', () => { - expect(matchTriggerProvider(providers, { provider_name: 'trigger-provider' })).toEqual(providers[0]) + expect(matchTriggerProvider(providers, { provider_name: 'trigger-provider' })).toEqual( + providers[0], + ) }) it('should match a trigger provider by id', () => { - expect(matchTriggerProvider(providers, { provider_id: 'trigger-provider-id' })).toEqual(providers[0]) + expect(matchTriggerProvider(providers, { provider_id: 'trigger-provider-id' })).toEqual( + providers[0], + ) }) it('should match a trigger provider by plugin id', () => { @@ -63,22 +71,30 @@ describe('plugin install check', () => { }) describe('matchDataSource', () => { - const dataSources = [createTool({ - provider: 'knowledge-provider', - plugin_id: 'knowledge-plugin', - plugin_unique_identifier: 'knowledge-plugin@1.0.0', - })] + const dataSources = [ + createTool({ + provider: 'knowledge-provider', + plugin_id: 'knowledge-plugin', + plugin_unique_identifier: 'knowledge-plugin@1.0.0', + }), + ] it('should match a data source by unique identifier', () => { - expect(matchDataSource(dataSources, { plugin_unique_identifier: 'knowledge-plugin@1.0.0' })).toEqual(dataSources[0]) + expect( + matchDataSource(dataSources, { plugin_unique_identifier: 'knowledge-plugin@1.0.0' }), + ).toEqual(dataSources[0]) }) it('should match a data source by plugin id', () => { - expect(matchDataSource(dataSources, { plugin_id: 'knowledge-plugin' })).toEqual(dataSources[0]) + expect(matchDataSource(dataSources, { plugin_id: 'knowledge-plugin' })).toEqual( + dataSources[0], + ) }) it('should match a data source by provider name', () => { - expect(matchDataSource(dataSources, { provider_name: 'knowledge-provider' })).toEqual(dataSources[0]) + expect(matchDataSource(dataSources, { provider_name: 'knowledge-provider' })).toEqual( + dataSources[0], + ) }) }) diff --git a/web/app/components/workflow/utils/__tests__/tool.spec.ts b/web/app/components/workflow/utils/__tests__/tool.spec.ts index 3e4372df23e41e..e0fdd6312798a6 100644 --- a/web/app/components/workflow/utils/__tests__/tool.spec.ts +++ b/web/app/components/workflow/utils/__tests__/tool.spec.ts @@ -6,13 +6,14 @@ import { getToolCheckParams, wrapStructuredVarItem } from '../tool' vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ toolParametersToFormSchemas: vi.fn((params: Array<Record<string, unknown>>) => - params.map(p => ({ + params.map((p) => ({ variable: p.name, label: p.label || { en_US: p.name }, type: p.type || 'string', required: p.required ?? false, form: p.form ?? 'llm', - }))), + })), + ), })) vi.mock('@/utils', () => ({ @@ -41,8 +42,20 @@ function createToolCollection(overrides: Partial<ToolWithProvider> = {}): ToolWi { name: 'google_search', parameters: [ - { name: 'query', label: { en_US: 'Query', zh_Hans: '查询' }, type: 'string', required: true, form: 'llm' }, - { name: 'api_key', label: { en_US: 'API Key' }, type: 'string', required: true, form: 'credential' }, + { + name: 'query', + label: { en_US: 'Query', zh_Hans: '查询' }, + type: 'string', + required: true, + form: 'llm', + }, + { + name: 'api_key', + label: { en_US: 'API Key' }, + type: 'string', + required: true, + form: 'credential', + }, ], }, ], @@ -54,13 +67,7 @@ function createToolCollection(overrides: Partial<ToolWithProvider> = {}): ToolWi describe('getToolCheckParams', () => { it('should separate llm inputs from settings', () => { - const result = getToolCheckParams( - createToolData(), - [createToolCollection()], - [], - [], - 'en_US', - ) + const result = getToolCheckParams(createToolData(), [createToolCollection()], [], [], 'en_US') expect(result.toolInputsSchema).toEqual([ { label: 'Query', variable: 'query', type: 'string', required: true }, @@ -70,13 +77,7 @@ describe('getToolCheckParams', () => { }) it('should mark notAuthed for builtin tools without team auth', () => { - const result = getToolCheckParams( - createToolData(), - [createToolCollection()], - [], - [], - 'en_US', - ) + const result = getToolCheckParams(createToolData(), [createToolCollection()], [], [], 'en_US') expect(result.notAuthed).toBe(true) }) @@ -143,19 +144,19 @@ describe('getToolCheckParams', () => { { name: 'google_search', parameters: [ - { name: 'query', label: { en_US: 'Query' }, type: 'string', required: true, form: 'llm' }, + { + name: 'query', + label: { en_US: 'Query' }, + type: 'string', + required: true, + form: 'llm', + }, ], }, ], } as Partial<ToolWithProvider>) - const result = getToolCheckParams( - createToolData(), - [tool], - [], - [], - 'ja_JP', - ) + const result = getToolCheckParams(createToolData(), [tool], [], [], 'ja_JP') expect(result.toolInputsSchema[0]!.label).toBe('Query') }) diff --git a/web/app/components/workflow/utils/__tests__/trigger.spec.ts b/web/app/components/workflow/utils/__tests__/trigger.spec.ts index f3cd2a0c719a54..2f5d05a34d0346 100644 --- a/web/app/components/workflow/utils/__tests__/trigger.spec.ts +++ b/web/app/components/workflow/utils/__tests__/trigger.spec.ts @@ -59,11 +59,7 @@ describe('getTriggerCheckParams', () => { }) it('should match provider by name and extract parameters', () => { - const result = getTriggerCheckParams( - createTriggerData(), - [createTriggerProvider()], - 'en_US', - ) + const result = getTriggerCheckParams(createTriggerData(), [createTriggerProvider()], 'en_US') expect(result.isReadyForCheckValid).toBe(true) expect(result.triggerInputsSchema).toEqual([ @@ -73,32 +69,26 @@ describe('getTriggerCheckParams', () => { }) it('should use the requested language for labels', () => { - const result = getTriggerCheckParams( - createTriggerData(), - [createTriggerProvider()], - 'zh_Hans', - ) + const result = getTriggerCheckParams(createTriggerData(), [createTriggerProvider()], 'zh_Hans') expect(result.triggerInputsSchema[0]!.label).toBe('频道') }) it('should fall back to en_US when language label is missing', () => { - const result = getTriggerCheckParams( - createTriggerData(), - [createTriggerProvider()], - 'ja_JP', - ) + const result = getTriggerCheckParams(createTriggerData(), [createTriggerProvider()], 'ja_JP') expect(result.triggerInputsSchema[0]!.label).toBe('Channel') }) it('should fall back to parameter name when no labels exist', () => { const provider = createTriggerProvider({ - events: [{ - name: 'on_message', - label: { en_US: 'On Message' }, - parameters: [{ name: 'raw_param' }], - }], + events: [ + { + name: 'on_message', + label: { en_US: 'On Message' }, + parameters: [{ name: 'raw_param' }], + }, + ], } as Partial<TriggerWithProvider>) const result = getTriggerCheckParams(createTriggerData(), [provider], 'en_US') @@ -107,7 +97,10 @@ describe('getTriggerCheckParams', () => { }) it('should match provider by provider_id', () => { - const trigger = createTriggerData({ provider_name: 'different-name', provider_id: 'provider-1' }) + const trigger = createTriggerData({ + provider_name: 'different-name', + provider_id: 'provider-1', + }) const provider = createTriggerProvider({ name: 'other-name', id: 'provider-1' }) const result = getTriggerCheckParams(trigger, [provider], 'en_US') diff --git a/web/app/components/workflow/utils/__tests__/variable.spec.ts b/web/app/components/workflow/utils/__tests__/variable.spec.ts index 065e2187ac8d61..616d6ec428975c 100644 --- a/web/app/components/workflow/utils/__tests__/variable.spec.ts +++ b/web/app/components/workflow/utils/__tests__/variable.spec.ts @@ -8,7 +8,13 @@ describe('variableTransformer', () => { }) it('should parse a deeply nested path', () => { - expect(variableTransformer('{{#node1.data.items.0.name#}}')).toEqual(['node1', 'data', 'items', '0', 'name']) + expect(variableTransformer('{{#node1.data.items.0.name#}}')).toEqual([ + 'node1', + 'data', + 'items', + '0', + 'name', + ]) }) it('should handle a single-segment path', () => { @@ -28,11 +34,20 @@ describe('variableTransformer', () => { }) describe('isExceptionVariable', () => { - const errorHandleTypes = [BlockEnum.LLM, BlockEnum.Tool, BlockEnum.HttpRequest, BlockEnum.Code, BlockEnum.Agent] + const errorHandleTypes = [ + BlockEnum.LLM, + BlockEnum.Tool, + BlockEnum.HttpRequest, + BlockEnum.Code, + BlockEnum.Agent, + ] - it.each(errorHandleTypes)('should return true for error_message with %s node type', (nodeType) => { - expect(isExceptionVariable('error_message', nodeType)).toBe(true) - }) + it.each(errorHandleTypes)( + 'should return true for error_message with %s node type', + (nodeType) => { + expect(isExceptionVariable('error_message', nodeType)).toBe(true) + }, + ) it.each(errorHandleTypes)('should return true for error_type with %s node type', (nodeType) => { expect(isExceptionVariable('error_type', nodeType)).toBe(true) diff --git a/web/app/components/workflow/utils/__tests__/workflow-entry.spec.ts b/web/app/components/workflow/utils/__tests__/workflow-entry.spec.ts index 5a2a3d8e47157f..5fe1abd0675f32 100644 --- a/web/app/components/workflow/utils/__tests__/workflow-entry.spec.ts +++ b/web/app/components/workflow/utils/__tests__/workflow-entry.spec.ts @@ -1,4 +1,9 @@ -import { createNode, createStartNode, createTriggerNode, resetFixtureCounters } from '../../__tests__/fixtures' +import { + createNode, + createStartNode, + createTriggerNode, + resetFixtureCounters, +} from '../../__tests__/fixtures' import { BlockEnum } from '../../types' import { getWorkflowEntryNode, isTriggerWorkflow, isWorkflowEntryNode } from '../workflow-entry' @@ -29,9 +34,7 @@ describe('getWorkflowEntryNode', () => { }) it('should return undefined when no entry node exists', () => { - const nodes = [ - createNode({ id: 'code', data: { type: BlockEnum.Code, title: '', desc: '' } }), - ] + const nodes = [createNode({ id: 'code', data: { type: BlockEnum.Code, title: '', desc: '' } })] expect(getWorkflowEntryNode(nodes)).toBeUndefined() }) @@ -68,10 +71,7 @@ describe('isWorkflowEntryNode', () => { describe('isTriggerWorkflow', () => { it('should return true when nodes contain a trigger node', () => { - const nodes = [ - createStartNode(), - createTriggerNode(BlockEnum.TriggerWebhook), - ] + const nodes = [createStartNode(), createTriggerNode(BlockEnum.TriggerWebhook)] expect(isTriggerWorkflow(nodes)).toBe(true) }) diff --git a/web/app/components/workflow/utils/__tests__/workflow-init.spec.ts b/web/app/components/workflow/utils/__tests__/workflow-init.spec.ts index 716a2a0dfc51b2..3144cae6eabe4b 100644 --- a/web/app/components/workflow/utils/__tests__/workflow-init.spec.ts +++ b/web/app/components/workflow/utils/__tests__/workflow-init.spec.ts @@ -5,11 +5,13 @@ import type { LLMNodeType } from '../../nodes/llm/types' import type { LoopNodeType } from '../../nodes/loop/types' import type { ParameterExtractorNodeType } from '../../nodes/parameter-extractor/types' import type { ToolNodeType } from '../../nodes/tool/types' -import type { - Edge, - Node, -} from '@/app/components/workflow/types' -import { CUSTOM_NODE, DEFAULT_RETRY_INTERVAL, DEFAULT_RETRY_MAX, NESTED_ELEMENT_Z_INDEX } from '@/app/components/workflow/constants' +import type { Edge, Node } from '@/app/components/workflow/types' +import { + CUSTOM_NODE, + DEFAULT_RETRY_INTERVAL, + DEFAULT_RETRY_MAX, + NESTED_ELEMENT_Z_INDEX, +} from '@/app/components/workflow/constants' import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants' import { BlockEnum, ErrorHandleMode } from '@/app/components/workflow/types' @@ -22,20 +24,22 @@ vi.mock('reactflow', async (importOriginal) => { ...actual, getConnectedEdges: vi.fn((_nodes: Node[], edges: Edge[]) => { const node = _nodes[0] - return edges.filter(e => e.source === node!.id || e.target === node!.id) + return edges.filter((e) => e.source === node!.id || e.target === node!.id) }), } }) vi.mock('@/utils', () => ({ - correctModelProvider: vi.fn((p: string) => p ? `corrected/${p}` : ''), + correctModelProvider: vi.fn((p: string) => (p ? `corrected/${p}` : '')), })) vi.mock('@/app/components/workflow/nodes/if-else/utils', () => ({ - branchNameCorrect: vi.fn((branches: Array<Record<string, unknown>>) => branches.map((b: Record<string, unknown>, i: number) => ({ - ...b, - name: b.id === 'false' ? 'ELSE' : branches.length === 2 ? 'IF' : `CASE ${i + 1}`, - }))), + branchNameCorrect: vi.fn((branches: Array<Record<string, unknown>>) => + branches.map((b: Record<string, unknown>, i: number) => ({ + ...b, + name: b.id === 'false' ? 'ELSE' : branches.length === 2 ? 'IF' : `CASE ${i + 1}`, + })), + ), })) beforeEach(() => { @@ -55,7 +59,7 @@ describe('preprocessNodesAndEdges', () => { createNode({ id: 'iter-1', data: { type: BlockEnum.Iteration, title: '', desc: '' } }), ] const result = preprocessNodesAndEdges(nodes as Node[], []) - const startNodes = result.nodes.filter(n => n.data.type === BlockEnum.IterationStart) + const startNodes = result.nodes.filter((n) => n.data.type === BlockEnum.IterationStart) expect(startNodes).toHaveLength(1) expect(startNodes[0]!.parentId).toBe('iter-1') }) @@ -69,7 +73,7 @@ describe('preprocessNodesAndEdges', () => { createNode({ id: 'some-node', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] const result = preprocessNodesAndEdges(nodes as Node[], []) - const startNodes = result.nodes.filter(n => n.data.type === BlockEnum.IterationStart) + const startNodes = result.nodes.filter((n) => n.data.type === BlockEnum.IterationStart) expect(startNodes).toHaveLength(1) }) @@ -94,7 +98,7 @@ describe('preprocessNodesAndEdges', () => { createNode({ id: 'loop-1', data: { type: BlockEnum.Loop, title: '', desc: '' } }), ] const result = preprocessNodesAndEdges(nodes as Node[], []) - const startNodes = result.nodes.filter(n => n.data.type === BlockEnum.LoopStart) + const startNodes = result.nodes.filter((n) => n.data.type === BlockEnum.LoopStart) expect(startNodes).toHaveLength(1) }) @@ -107,7 +111,7 @@ describe('preprocessNodesAndEdges', () => { createNode({ id: 'some-node', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] const result = preprocessNodesAndEdges(nodes as Node[], []) - const startNodes = result.nodes.filter(n => n.data.type === BlockEnum.LoopStart) + const startNodes = result.nodes.filter((n) => n.data.type === BlockEnum.LoopStart) expect(startNodes).toHaveLength(1) }) @@ -178,8 +182,8 @@ describe('preprocessNodesAndEdges', () => { }), ] const result = preprocessNodesAndEdges(nodes as Node[], []) - const iterNode = result.nodes.find(n => n.id === 'iter-1') - const loopNode = result.nodes.find(n => n.id === 'loop-1') + const iterNode = result.nodes.find((n) => n.id === 'iter-1') + const loopNode = result.nodes.find((n) => n.id === 'loop-1') expect((iterNode!.data as IterationNodeType).start_node_id).toBeTruthy() expect((loopNode!.data as LoopNodeType).start_node_id).toBeTruthy() }) @@ -209,11 +213,11 @@ describe('initialNodes', () => { const result = initialNodes(nodes, []) - const parentNode = result.find(node => node.id === 'iter-1')! - const childNode = result.find(node => node.id === 'child-1')! + const parentNode = result.find((node) => node.id === 'iter-1')! + const childNode = result.find((node) => node.id === 'child-1')! expect(parentNode.zIndex).toBe(0) expect(childNode.zIndex).toBe(NESTED_ELEMENT_Z_INDEX) - expect(result.find(node => node.id === 'root-1')!.zIndex).toBe(0) + expect(result.find((node) => node.id === 'root-1')!.zIndex).toBe(0) expect(parentNode.zIndex! + 1000).toBeLessThan(childNode.zIndex!) }) @@ -222,7 +226,7 @@ describe('initialNodes', () => { createNode({ id: 'n1', data: { type: BlockEnum.Start, title: '', desc: '' } }), createNode({ id: 'n2', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] - nodes.forEach(n => Reflect.deleteProperty(n, 'position')) + nodes.forEach((n) => Reflect.deleteProperty(n, 'position')) const result = initialNodes(nodes, []) expect(result[0]!.position).toBeDefined() @@ -231,9 +235,7 @@ describe('initialNodes', () => { }) it('should set type to CUSTOM_NODE when type is missing', () => { - const nodes = [ - createNode({ id: 'n1', data: { type: BlockEnum.Start, title: '', desc: '' } }), - ] + const nodes = [createNode({ id: 'n1', data: { type: BlockEnum.Start, title: '', desc: '' } })] Reflect.deleteProperty(nodes[0]!, 'type') const result = initialNodes(nodes, []) @@ -262,9 +264,7 @@ describe('initialNodes', () => { type: BlockEnum.IfElse, title: '', desc: '', - cases: [ - { case_id: 'case-1', logical_operator: 'and', conditions: [] }, - ], + cases: [{ case_id: 'case-1', logical_operator: 'and', conditions: [] }], }, }), ] @@ -349,7 +349,7 @@ describe('initialNodes', () => { ] const result = initialNodes(nodes, []) - const iterNode = result.find(n => n.id === 'iter-1')! + const iterNode = result.find((n) => n.id === 'iter-1')! const data = iterNode.data as IterationNodeType expect(data.is_parallel).toBe(false) expect(data.parallel_nums).toBe(10) @@ -370,7 +370,7 @@ describe('initialNodes', () => { ] const result = initialNodes(nodes, []) - const loopNode = result.find(n => n.id === 'loop-1')! + const loopNode = result.find((n) => n.id === 'loop-1')! const data = loopNode.data as LoopNodeType expect(data.error_handle_mode).toBe(ErrorHandleMode.Terminated) expect(data._children).toBeDefined() @@ -390,7 +390,7 @@ describe('initialNodes', () => { ] const result = initialNodes(nodes, []) - const iterNode = result.find(n => n.id === 'iter-1')! + const iterNode = result.find((n) => n.id === 'iter-1')! const data = iterNode.data as IterationNodeType expect(data._children).toEqual( expect.arrayContaining([ @@ -432,7 +432,10 @@ describe('initialNodes', () => { ] const result = initialNodes(nodes, []) - expect((result[0]!.data as KnowledgeRetrievalNodeType).multiple_retrieval_config!.reranking_model!.provider).toBe('corrected/cohere') + expect( + (result[0]!.data as KnowledgeRetrievalNodeType).multiple_retrieval_config!.reranking_model! + .provider, + ).toBe('corrected/cohere') }) it('should correct model provider for ParameterExtractor nodes', () => { @@ -449,7 +452,9 @@ describe('initialNodes', () => { ] const result = initialNodes(nodes, []) - expect((result[0]!.data as ParameterExtractorNodeType).model.provider).toBe('corrected/anthropic') + expect((result[0]!.data as ParameterExtractorNodeType).model.provider).toBe( + 'corrected/anthropic', + ) }) it('should add default retry_config for HttpRequest nodes', () => { @@ -598,8 +603,16 @@ describe('initialEdges', () => { it('should normalize legacy nested and root edge layers', () => { const nodes = [ createNode({ id: 'iter-1', data: { type: BlockEnum.Iteration, title: '', desc: '' } }), - createNode({ id: 'child-1', parentId: 'iter-1', data: { type: BlockEnum.Code, title: '', desc: '' } }), - createNode({ id: 'child-2', parentId: 'iter-1', data: { type: BlockEnum.Code, title: '', desc: '' } }), + createNode({ + id: 'child-1', + parentId: 'iter-1', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), + createNode({ + id: 'child-2', + parentId: 'iter-1', + data: { type: BlockEnum.Code, title: '', desc: '' }, + }), createNode({ id: 'root-1', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] const edges = [ @@ -610,27 +623,33 @@ describe('initialEdges', () => { const result = initialEdges(edges, nodes) - expect(result.find(edge => edge.id === 'nested-edge')!.zIndex).toBe(NESTED_ELEMENT_Z_INDEX) - expect(result.find(edge => edge.id === 'boundary-edge')!.zIndex).toBe(NESTED_ELEMENT_Z_INDEX) - expect(result.find(edge => edge.id === 'root-edge')!.zIndex).toBe(0) - expect(result.find(edge => edge.id === 'nested-edge')!.data).toEqual(expect.objectContaining({ - isInIteration: true, - iteration_id: 'iter-1', - isInLoop: false, - loop_id: undefined, - })) - expect(result.find(edge => edge.id === 'boundary-edge')!.data).toEqual(expect.objectContaining({ - isInIteration: true, - iteration_id: 'iter-1', - isInLoop: false, - loop_id: undefined, - })) - expect(result.find(edge => edge.id === 'root-edge')!.data).toEqual(expect.objectContaining({ - isInIteration: false, - iteration_id: undefined, - isInLoop: false, - loop_id: undefined, - })) + expect(result.find((edge) => edge.id === 'nested-edge')!.zIndex).toBe(NESTED_ELEMENT_Z_INDEX) + expect(result.find((edge) => edge.id === 'boundary-edge')!.zIndex).toBe(NESTED_ELEMENT_Z_INDEX) + expect(result.find((edge) => edge.id === 'root-edge')!.zIndex).toBe(0) + expect(result.find((edge) => edge.id === 'nested-edge')!.data).toEqual( + expect.objectContaining({ + isInIteration: true, + iteration_id: 'iter-1', + isInLoop: false, + loop_id: undefined, + }), + ) + expect(result.find((edge) => edge.id === 'boundary-edge')!.data).toEqual( + expect.objectContaining({ + isInIteration: true, + iteration_id: 'iter-1', + isInLoop: false, + loop_id: undefined, + }), + ) + expect(result.find((edge) => edge.id === 'root-edge')!.data).toEqual( + expect.objectContaining({ + isInIteration: false, + iteration_id: undefined, + isInLoop: false, + loop_id: undefined, + }), + ) }) it('should set edge type to custom', () => { @@ -697,11 +716,9 @@ describe('initialEdges', () => { const result = initialEdges(edges, nodes) const hasCycleEdge = result.some( - e => (e.source === 'b' && e.target === 'c') || (e.source === 'c' && e.target === 'b'), - ) - const hasABEdge = result.some( - e => e.source === 'a' && e.target === 'b', + (e) => (e.source === 'b' && e.target === 'c') || (e.source === 'c' && e.target === 'b'), ) + const hasABEdge = result.some((e) => e.source === 'a' && e.target === 'b') expect(hasCycleEdge).toBe(false) // In this specific graph, getCycleEdges treats all nodes remaining in the DFS stack (a, b, c) // as part of the cycle, so a→b is also filtered. This assertion documents that behaviour. @@ -722,17 +739,13 @@ describe('initialEdges', () => { }) it('should handle empty edges', () => { - const nodes = [ - createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), - ] + const nodes = [createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })] const result = initialEdges([], nodes) expect(result).toHaveLength(0) }) it('should handle edges where source/target node is missing from nodesMap', () => { - const nodes = [ - createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), - ] + const nodes = [createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })] const edges = [createEdge({ source: 'a', target: 'missing' })] const result = initialEdges(edges, nodes) @@ -755,7 +768,14 @@ describe('initialEdges', () => { createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] - const edges = [createEdge({ source: 'a', target: 'b', sourceHandle: 'custom-src', targetHandle: 'custom-tgt' })] + const edges = [ + createEdge({ + source: 'a', + target: 'b', + sourceHandle: 'custom-src', + targetHandle: 'custom-tgt', + }), + ] const result = initialEdges(edges, nodes) expect(result[0]!.sourceHandle).toBe('custom-src') @@ -787,7 +807,7 @@ describe('initialEdges', () => { ] const result = initialEdges(edges, nodes) - const selfLoop = result.find(e => e.source === 'b' && e.target === 'b') + const selfLoop = result.find((e) => e.source === 'b' && e.target === 'b') expect(selfLoop).toBeUndefined() }) diff --git a/web/app/components/workflow/utils/__tests__/workflow.spec.ts b/web/app/components/workflow/utils/__tests__/workflow.spec.ts index e727c5100b8d62..2c2feda0cb3550 100644 --- a/web/app/components/workflow/utils/__tests__/workflow.spec.ts +++ b/web/app/components/workflow/utils/__tests__/workflow.spec.ts @@ -110,11 +110,21 @@ describe('getNodesConnectedSourceOrTargetHandleIdsMap', () => { it('should remove handle ids when type is remove', () => { const node1 = createNode({ id: 'a', - data: { type: BlockEnum.Start, title: '', desc: '', _connectedSourceHandleIds: ['src-handle'] }, + data: { + type: BlockEnum.Start, + title: '', + desc: '', + _connectedSourceHandleIds: ['src-handle'], + }, }) const node2 = createNode({ id: 'b', - data: { type: BlockEnum.Code, title: '', desc: '', _connectedTargetHandleIds: ['tgt-handle'] }, + data: { + type: BlockEnum.Code, + title: '', + desc: '', + _connectedTargetHandleIds: ['tgt-handle'], + }, }) const edge = createEdge({ source: 'a', @@ -152,10 +162,7 @@ describe('getNodesConnectedSourceOrTargetHandleIdsMap', () => { const node2 = createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }) const edge = createEdge({ source: 'missing', target: 'b', sourceHandle: 'src' }) - const result = getNodesConnectedSourceOrTargetHandleIdsMap( - [{ type: 'add', edge }], - [node2], - ) + const result = getNodesConnectedSourceOrTargetHandleIdsMap([{ type: 'add', edge }], [node2]) expect(result.missing).toBeUndefined() expect(result.b._connectedTargetHandleIds).toBeDefined() @@ -165,10 +172,7 @@ describe('getNodesConnectedSourceOrTargetHandleIdsMap', () => { const node1 = createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }) const edge = createEdge({ source: 'a', target: 'missing', targetHandle: 'tgt' }) - const result = getNodesConnectedSourceOrTargetHandleIdsMap( - [{ type: 'add', edge }], - [node1], - ) + const result = getNodesConnectedSourceOrTargetHandleIdsMap([{ type: 'add', edge }], [node1]) expect(result.a._connectedSourceHandleIds).toBeDefined() expect(result.missing).toBeUndefined() @@ -182,7 +186,10 @@ describe('getNodesConnectedSourceOrTargetHandleIdsMap', () => { const edge2 = createEdge({ source: 'a', target: 'c', sourceHandle: 'h2' }) const result = getNodesConnectedSourceOrTargetHandleIdsMap( - [{ type: 'add', edge: edge1 }, { type: 'add', edge: edge2 }], + [ + { type: 'add', edge: edge1 }, + { type: 'add', edge: edge2 }, + ], [node1, node2, node3], ) @@ -212,9 +219,7 @@ describe('getNodesConnectedSourceOrTargetHandleIdsMap', () => { describe('getValidTreeNodes', () => { it('should return empty when there are no start/trigger nodes', () => { - const nodes = [ - createNode({ id: 'n1', data: { type: BlockEnum.Code, title: '', desc: '' } }), - ] + const nodes = [createNode({ id: 'n1', data: { type: BlockEnum.Code, title: '', desc: '' } })] const result = getValidTreeNodes(nodes, []) expect(result.validNodes).toEqual([]) expect(result.maxDepth).toBe(0) @@ -232,7 +237,7 @@ describe('getValidTreeNodes', () => { ] const result = getValidTreeNodes(nodes, edges) - expect(result.validNodes.map(n => n.id)).toEqual(['start', 'llm', 'end']) + expect(result.validNodes.map((n) => n.id)).toEqual(['start', 'llm', 'end']) expect(result.maxDepth).toBe(3) }) @@ -241,34 +246,38 @@ describe('getValidTreeNodes', () => { createNode({ id: 'trigger', data: { type: BlockEnum.TriggerWebhook, title: '', desc: '' } }), createNode({ id: 'code', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] - const edges = [ - createEdge({ source: 'trigger', target: 'code' }), - ] + const edges = [createEdge({ source: 'trigger', target: 'code' })] const result = getValidTreeNodes(nodes, edges) - expect(result.validNodes.map(n => n.id)).toContain('trigger') - expect(result.validNodes.map(n => n.id)).toContain('code') + expect(result.validNodes.map((n) => n.id)).toContain('trigger') + expect(result.validNodes.map((n) => n.id)).toContain('code') }) it('should include iteration children as valid nodes', () => { const nodes = [ createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }), createNode({ id: 'iter', data: { type: BlockEnum.Iteration, title: '', desc: '' } }), - createNode({ id: 'child1', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'iter' }), - ] - const edges = [ - createEdge({ source: 'start', target: 'iter' }), + createNode({ + id: 'child1', + data: { type: BlockEnum.Code, title: '', desc: '' }, + parentId: 'iter', + }), ] + const edges = [createEdge({ source: 'start', target: 'iter' })] const result = getValidTreeNodes(nodes, edges) - expect(result.validNodes.map(n => n.id)).toContain('child1') + expect(result.validNodes.map((n) => n.id)).toContain('child1') }) it('should include loop children when loop has outgoers', () => { const nodes = [ createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }), createNode({ id: 'loop', data: { type: BlockEnum.Loop, title: '', desc: '' } }), - createNode({ id: 'loop-child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'loop' }), + createNode({ + id: 'loop-child', + data: { type: BlockEnum.Code, title: '', desc: '' }, + parentId: 'loop', + }), createNode({ id: 'end', data: { type: BlockEnum.End, title: '', desc: '' } }), ] const edges = [ @@ -277,21 +286,23 @@ describe('getValidTreeNodes', () => { ] const result = getValidTreeNodes(nodes, edges) - expect(result.validNodes.map(n => n.id)).toContain('loop-child') + expect(result.validNodes.map((n) => n.id)).toContain('loop-child') }) it('should include loop children as valid nodes when loop is a leaf', () => { const nodes = [ createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }), createNode({ id: 'loop', data: { type: BlockEnum.Loop, title: '', desc: '' } }), - createNode({ id: 'loop-child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'loop' }), - ] - const edges = [ - createEdge({ source: 'start', target: 'loop' }), + createNode({ + id: 'loop-child', + data: { type: BlockEnum.Code, title: '', desc: '' }, + parentId: 'loop', + }), ] + const edges = [createEdge({ source: 'start', target: 'loop' })] const result = getValidTreeNodes(nodes, edges) - expect(result.validNodes.map(n => n.id)).toContain('loop-child') + expect(result.validNodes.map((n) => n.id)).toContain('loop-child') }) it('should handle cycles without infinite loop', () => { @@ -316,12 +327,10 @@ describe('getValidTreeNodes', () => { createNode({ id: 'connected', data: { type: BlockEnum.Code, title: '', desc: '' } }), createNode({ id: 'isolated', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] - const edges = [ - createEdge({ source: 'start', target: 'connected' }), - ] + const edges = [createEdge({ source: 'start', target: 'connected' })] const result = getValidTreeNodes(nodes, edges) - expect(result.validNodes.map(n => n.id)).not.toContain('isolated') + expect(result.validNodes.map((n) => n.id)).not.toContain('isolated') }) it('should handle multiple start nodes without double-traversal', () => { @@ -336,9 +345,9 @@ describe('getValidTreeNodes', () => { ] const result = getValidTreeNodes(nodes, edges) - expect(result.validNodes.map(n => n.id)).toContain('start1') - expect(result.validNodes.map(n => n.id)).toContain('trigger') - expect(result.validNodes.map(n => n.id)).toContain('shared') + expect(result.validNodes.map((n) => n.id)).toContain('start1') + expect(result.validNodes.map((n) => n.id)).toContain('trigger') + expect(result.validNodes.map((n) => n.id)).toContain('shared') }) it('should not increase maxDepth when visiting nodes at same or lower depth', () => { @@ -384,8 +393,8 @@ describe('getValidTreeNodes', () => { ] const result = getValidTreeNodes(nodes, edges) - expect(result.validNodes.map(n => n.id)).toContain('start1') - expect(result.validNodes.map(n => n.id)).toContain('start2') - expect(result.validNodes.map(n => n.id)).toContain('shared') + expect(result.validNodes.map((n) => n.id)).toContain('start1') + expect(result.validNodes.map((n) => n.id)).toContain('start2') + expect(result.validNodes.map((n) => n.id)).toContain('shared') }) }) diff --git a/web/app/components/workflow/utils/clipboard.ts b/web/app/components/workflow/utils/clipboard.ts index 246d3351f36789..87c611ff832afa 100644 --- a/web/app/components/workflow/utils/clipboard.ts +++ b/web/app/components/workflow/utils/clipboard.ts @@ -34,9 +34,11 @@ const isEdgeArray = (value: unknown): value is Edge[] => Array.isArray(value) const isPlainObject = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value) -export const sanitizeClipboardValueByDefault = (defaultValue: unknown, incomingValue: unknown): unknown => { - if (defaultValue === undefined) - return incomingValue +export const sanitizeClipboardValueByDefault = ( + defaultValue: unknown, + incomingValue: unknown, +): unknown => { + if (defaultValue === undefined) return incomingValue if (Array.isArray(defaultValue)) return Array.isArray(incomingValue) ? incomingValue : [...defaultValue] @@ -52,19 +54,13 @@ export const sanitizeClipboardValueByDefault = (defaultValue: unknown, incomingV } const merged: Record<string, unknown> = {} - const keys = new Set([ - ...Object.keys(defaultValue), - ...Object.keys(incomingValue), - ]) + const keys = new Set([...Object.keys(defaultValue), ...Object.keys(incomingValue)]) keys.forEach((key) => { const hasDefault = Object.hasOwn(defaultValue, key) const hasIncoming = Object.hasOwn(incomingValue, key) if (hasDefault && hasIncoming) { - merged[key] = sanitizeClipboardValueByDefault( - defaultValue[key], - incomingValue[key], - ) + merged[key] = sanitizeClipboardValueByDefault(defaultValue[key], incomingValue[key]) return } @@ -80,30 +76,28 @@ export const sanitizeClipboardValueByDefault = (defaultValue: unknown, incomingV } if (typeof defaultValue === 'number') - return typeof incomingValue === 'number' && Number.isFinite(incomingValue) ? incomingValue : defaultValue + return typeof incomingValue === 'number' && Number.isFinite(incomingValue) + ? incomingValue + : defaultValue return typeof incomingValue === typeof defaultValue ? incomingValue : defaultValue } -export const isClipboardValueCompatibleWithDefault = (defaultValue: unknown, incomingValue: unknown): boolean => { - if (incomingValue === undefined) - return true +export const isClipboardValueCompatibleWithDefault = ( + defaultValue: unknown, + incomingValue: unknown, +): boolean => { + if (incomingValue === undefined) return true - if (defaultValue === undefined) - return true + if (defaultValue === undefined) return true - if (Array.isArray(defaultValue)) - return Array.isArray(incomingValue) + if (Array.isArray(defaultValue)) return Array.isArray(incomingValue) if (isPlainObject(defaultValue)) { - if (!isPlainObject(incomingValue)) - return false + if (!isPlainObject(incomingValue)) return false return Object.entries(defaultValue).every(([key, value]) => { - return isClipboardValueCompatibleWithDefault( - value, - incomingValue[key], - ) + return isClipboardValueCompatibleWithDefault(value, incomingValue[key]) }) } @@ -114,41 +108,38 @@ export const isClipboardValueCompatibleWithDefault = (defaultValue: unknown, inc } export const isClipboardNodeStructurallyValid = (value: unknown): value is Node => { - if (!isPlainObject(value)) - return false + if (!isPlainObject(value)) return false - if (typeof value.id !== 'string' || typeof value.type !== 'string') - return false + if (typeof value.id !== 'string' || typeof value.type !== 'string') return false - if (!isPlainObject(value.data) || !isPlainObject(value.position)) - return false + if (!isPlainObject(value.data) || !isPlainObject(value.position)) return false return Number.isFinite(value.position.x) && Number.isFinite(value.position.y) } export const isClipboardEdgeStructurallyValid = (value: unknown): value is Edge => { - if (!isPlainObject(value)) - return false + if (!isPlainObject(value)) return false - return typeof value.id === 'string' - && typeof value.source === 'string' - && typeof value.target === 'string' + return ( + typeof value.id === 'string' && + typeof value.source === 'string' && + typeof value.target === 'string' + ) } export const parseWorkflowClipboardText = ( text: string, currentClipboardVersion: string, ): WorkflowClipboardReadResult => { - if (!text) - return emptyClipboardReadResult + if (!text) return emptyClipboardReadResult try { const parsed = JSON.parse(text) as Partial<WorkflowClipboardPayload> if ( - parsed.kind !== WORKFLOW_CLIPBOARD_KIND - || typeof parsed.version !== 'string' - || !isNodeArray(parsed.nodes) - || !isEdgeArray(parsed.edges) + parsed.kind !== WORKFLOW_CLIPBOARD_KIND || + typeof parsed.version !== 'string' || + !isNodeArray(parsed.nodes) || + !isEdgeArray(parsed.edges) ) { return emptyClipboardReadResult } @@ -164,8 +155,7 @@ export const parseWorkflowClipboardText = ( sourceVersion, isVersionMismatch: sourceVersion !== currentClipboardVersion, } - } - catch { + } catch { return emptyClipboardReadResult } } @@ -198,8 +188,7 @@ export const readWorkflowClipboard = async ( try { const text = await navigator.clipboard.readText() return parseWorkflowClipboardText(text, currentClipboardVersion) - } - catch { + } catch { return emptyClipboardReadResult } } diff --git a/web/app/components/workflow/utils/data-source.ts b/web/app/components/workflow/utils/data-source.ts index 3b349e034e7b80..a15c5e0f106661 100644 --- a/web/app/components/workflow/utils/data-source.ts +++ b/web/app/components/workflow/utils/data-source.ts @@ -1,8 +1,5 @@ import type { DataSourceNodeType } from '../nodes/data-source/types' -import type { - InputVar, - ToolWithProvider, -} from '../types' +import type { InputVar, ToolWithProvider } from '../types' import { CollectionType } from '@/app/components/tools/types' import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' @@ -13,9 +10,13 @@ export const getDataSourceCheckParams = ( ) => { const { plugin_id, provider_type, datasource_name } = toolData const isBuiltIn = provider_type === CollectionType.builtIn - const currentDataSource = dataSourceList.find(item => item.plugin_id === plugin_id) - const currentDataSourceItem = currentDataSource?.tools.find(tool => tool.name === datasource_name) - const formSchemas = currentDataSourceItem ? toolParametersToFormSchemas(currentDataSourceItem.parameters) : [] + const currentDataSource = dataSourceList.find((item) => item.plugin_id === plugin_id) + const currentDataSourceItem = currentDataSource?.tools.find( + (tool) => tool.name === datasource_name, + ) + const formSchemas = currentDataSourceItem + ? toolParametersToFormSchemas(currentDataSourceItem.parameters) + : [] return { dataSourceInputsSchema: (() => { diff --git a/web/app/components/workflow/utils/edge.ts b/web/app/components/workflow/utils/edge.ts index b539c218d7d35f..2cdfa3e91322d8 100644 --- a/web/app/components/workflow/utils/edge.ts +++ b/web/app/components/workflow/utils/edge.ts @@ -1,6 +1,4 @@ -import { - NodeRunningStatus, -} from '../types' +import { NodeRunningStatus } from '../types' export const getEdgeColor = (nodeRunningStatus?: NodeRunningStatus, isFailBranch?: boolean) => { if (nodeRunningStatus === NodeRunningStatus.Succeeded) @@ -13,8 +11,7 @@ export const getEdgeColor = (nodeRunningStatus?: NodeRunningStatus, isFailBranch return 'var(--color-workflow-link-line-failure-handle)' if (nodeRunningStatus === NodeRunningStatus.Running) { - if (isFailBranch) - return 'var(--color-workflow-link-line-failure-handle)' + if (isFailBranch) return 'var(--color-workflow-link-line-failure-handle)' return 'var(--color-workflow-link-line-handle)' } diff --git a/web/app/components/workflow/utils/elk-layout.ts b/web/app/components/workflow/utils/elk-layout.ts index 950074a9653c1f..461b9783f2d8c9 100644 --- a/web/app/components/workflow/utils/elk-layout.ts +++ b/web/app/components/workflow/utils/elk-layout.ts @@ -1,11 +1,11 @@ import type { ElkNode, LayoutOptions } from 'elkjs/lib/elk-api' import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types' import type { CaseItem, IfElseNodeType } from '@/app/components/workflow/nodes/if-else/types' -import type { QuestionClassifierNodeType, Topic } from '@/app/components/workflow/nodes/question-classifier/types' import type { - Edge, - Node, -} from '@/app/components/workflow/types' + QuestionClassifierNodeType, + Topic, +} from '@/app/components/workflow/nodes/question-classifier/types' +import type { Edge, Node } from '@/app/components/workflow/types' import { cloneDeep } from 'es-toolkit/object' import { CUSTOM_NODE, @@ -14,9 +14,7 @@ import { } from '@/app/components/workflow/constants' import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants' -import { - BlockEnum, -} from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' let elk: import('elkjs/lib/elk-api').ELK | undefined @@ -255,8 +253,7 @@ const collectLayout = (graph: ElkNode, predicate: (id: string) => boolean): Layo maxY = Math.max(maxY, y + height) } - if (child.children?.length) - visit(child) + if (child.children?.length) visit(child) }) } @@ -287,16 +284,13 @@ const sortIfElseOutEdges = (ifElseNode: Node, outEdges: Edge[]): Edge[] => { if (handleA && handleB) { const cases = (ifElseNode.data as IfElseNodeType).cases || [] - if (handleA === 'false') - return 1 - if (handleB === 'false') - return -1 + if (handleA === 'false') return 1 + if (handleB === 'false') return -1 const indexA = cases.findIndex((c: CaseItem) => c.case_id === handleA) const indexB = cases.findIndex((c: CaseItem) => c.case_id === handleB) - if (indexA !== -1 && indexB !== -1) - return indexA - indexB + if (indexA !== -1 && indexB !== -1) return indexA - indexB } return 0 @@ -313,8 +307,7 @@ const sortQuestionClassifierOutEdges = (classifierNode: Node, outEdges: Edge[]): const indexA = classes.findIndex((t: Topic) => t.id === handleA) const indexB = classes.findIndex((t: Topic) => t.id === handleB) - if (indexA !== -1 && indexB !== -1) - return indexA - indexB + if (indexA !== -1 && indexB !== -1) return indexA - indexB } return 0 @@ -328,16 +321,13 @@ const sortHumanInputOutEdges = (humanInputNode: Node, outEdges: Edge[]): Edge[] if (handleA && handleB) { const userActions = (humanInputNode.data as HumanInputNodeType).user_actions || [] - if (handleA === '__timeout') - return 1 - if (handleB === '__timeout') - return -1 + if (handleA === '__timeout') return 1 + if (handleB === '__timeout') return -1 - const indexA = userActions.findIndex(action => action.id === handleA) - const indexB = userActions.findIndex(action => action.id === handleB) + const indexA = userActions.findIndex((action) => action.id === handleA) + const indexB = userActions.findIndex((action) => action.id === handleB) - if (indexA !== -1 && indexB !== -1) - return indexA - indexB + if (indexA !== -1 && indexB !== -1) return indexA - indexB } return 0 @@ -345,13 +335,9 @@ const sortHumanInputOutEdges = (humanInputNode: Node, outEdges: Edge[]): Edge[] } const normaliseBounds = (layout: LayoutResult): LayoutResult => { - const { - nodes, - bounds, - } = layout + const { nodes, bounds } = layout - if (nodes.size === 0) - return layout + if (nodes.size === 0) return layout const offsetX = bounds.minX const offsetY = bounds.minY @@ -383,8 +369,7 @@ const normaliseBounds = (layout: LayoutResult): LayoutResult => { const buildPortAwareGraph = (nodes: Node[], edges: Edge[]) => { const outEdgesByNode = new Map<string, Edge[]>() edges.forEach((edge) => { - if (!outEdgesByNode.has(edge.source)) - outEdgesByNode.set(edge.source, []) + if (!outEdgesByNode.has(edge.source)) outEdgesByNode.set(edge.source, []) outEdgesByNode.get(edge.source)!.push(edge) }) @@ -396,8 +381,7 @@ const buildPortAwareGraph = (nodes: Node[], edges: Edge[]) => { nodes.forEach((node) => { let outEdges = outEdgesByNode.get(node.id) || [] - if (node.data.type === BlockEnum.IfElse) - outEdges = sortIfElseOutEdges(node, outEdges) + if (node.data.type === BlockEnum.IfElse) outEdges = sortIfElseOutEdges(node, outEdges) else if (node.data.type === BlockEnum.QuestionClassifier) outEdges = sortQuestionClassifierOutEdges(node, outEdges) else if (node.data.type === BlockEnum.HumanInput) @@ -430,24 +414,22 @@ const buildPortAwareGraph = (nodes: Node[], edges: Edge[]) => { // DFS in port order to determine the definitive vertical ordering of nodes. // forceNodeModelOrder makes ELK respect the children-array order within each layer. - const nodeIdSet = new Set(nodes.map(n => n.id)) + const nodeIdSet = new Set(nodes.map((n) => n.id)) const visited = new Set<string>() const orderedIds: string[] = [] const dfs = (id: string) => { - if (visited.has(id) || !nodeIdSet.has(id)) - return + if (visited.has(id) || !nodeIdSet.has(id)) return visited.add(id) orderedIds.push(id) const outEdges = sortedOutEdgesByNode.get(id) || [] - outEdges.forEach(e => dfs(e.target)) + outEdges.forEach((e) => dfs(e.target)) } nodes.forEach((n) => { - if (!edges.some(e => e.target === n.id)) - dfs(n.id) + if (!edges.some((e) => e.target === n.id)) dfs(n.id) }) - nodes.forEach(n => dfs(n.id)) + nodes.forEach((n) => dfs(n.id)) const nodeOrder = new Map(orderedIds.map((id, i) => [id, i])) elkNodes.sort((a, b) => (nodeOrder.get(a.id) ?? 0) - (nodeOrder.get(b.id) ?? 0)) @@ -455,21 +437,22 @@ const buildPortAwareGraph = (nodes: Node[], edges: Edge[]) => { orderedIds.forEach((id) => { const outEdges = sortedOutEdgesByNode.get(id) || [] outEdges.forEach((edge) => { - elkEdges.push(createEdge( - edge.source, - edge.target, - sourcePortMap.get(edge.id), - )) + elkEdges.push(createEdge(edge.source, edge.target, sourcePortMap.get(edge.id))) }) }) return { elkNodes, elkEdges } } -export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): Promise<LayoutResult> => { +export const getLayoutByELK = async ( + originNodes: Node[], + originEdges: Edge[], +): Promise<LayoutResult> => { edgeCounter = 0 - const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE) - const edges = cloneDeep(originEdges).filter(edge => (!edge.data?.isInIteration && !edge.data?.isInLoop)) + const nodes = cloneDeep(originNodes).filter((node) => !node.parentId && node.type === CUSTOM_NODE) + const edges = cloneDeep(originEdges).filter( + (edge) => !edge.data?.isInIteration && !edge.data?.isInLoop, + ) const { elkNodes, elkEdges } = buildPortAwareGraph(nodes, edges) @@ -485,21 +468,19 @@ export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): return normaliseBounds(layout) } -const normaliseChildLayout = ( - layout: LayoutResult, - nodes: Node[], -): LayoutResult => { +const normaliseChildLayout = (layout: LayoutResult, nodes: Node[]): LayoutResult => { const result = new Map<string, LayoutInfo>() layout.nodes.forEach((info, id) => { result.set(id, info) }) // Ensure iteration / loop start nodes do not collapse into the children. - const startNode = nodes.find(node => - node.type === CUSTOM_ITERATION_START_NODE - || node.type === CUSTOM_LOOP_START_NODE - || node.data?.type === BlockEnum.LoopStart - || node.data?.type === BlockEnum.IterationStart, + const startNode = nodes.find( + (node) => + node.type === CUSTOM_ITERATION_START_NODE || + node.type === CUSTOM_LOOP_START_NODE || + node.data?.type === BlockEnum.LoopStart || + node.data?.type === BlockEnum.IterationStart, ) if (startNode) { @@ -540,8 +521,7 @@ const normaliseChildLayout = ( maxY = Math.max(maxY, value.y + value.height) }) - if (!Number.isFinite(minX) || !Number.isFinite(minY)) - return layout + if (!Number.isFinite(minX) || !Number.isFinite(minY)) return layout return normaliseBounds({ nodes: result, @@ -560,13 +540,13 @@ export const getLayoutForChildNodes = async ( originEdges: Edge[], ): Promise<LayoutResult | null> => { edgeCounter = 0 - const nodes = cloneDeep(originNodes).filter(node => node.parentId === parentNodeId) - if (!nodes.length) - return null + const nodes = cloneDeep(originNodes).filter((node) => node.parentId === parentNodeId) + if (!nodes.length) return null - const edges = cloneDeep(originEdges).filter(edge => - (edge.data?.isInIteration && edge.data?.iteration_id === parentNodeId) - || (edge.data?.isInLoop && edge.data?.loop_id === parentNodeId), + const edges = cloneDeep(originEdges).filter( + (edge) => + (edge.data?.isInIteration && edge.data?.iteration_id === parentNodeId) || + (edge.data?.isInLoop && edge.data?.loop_id === parentNodeId), ) const { elkNodes, elkEdges } = buildPortAwareGraph(nodes, edges) diff --git a/web/app/components/workflow/utils/node-navigation.ts b/web/app/components/workflow/utils/node-navigation.ts index 3bded573c2f019..2d85faf8c03e2e 100644 --- a/web/app/components/workflow/utils/node-navigation.ts +++ b/web/app/components/workflow/utils/node-navigation.ts @@ -44,9 +44,7 @@ export function scrollToWorkflowNode(nodeId: string): void { * @param handleNodeSelect - Function to handle node selection * @returns Cleanup function */ -export function setupNodeSelectionListener( - handleNodeSelect: (nodeId: string) => void, -): () => void { +export function setupNodeSelectionListener(handleNodeSelect: (nodeId: string) => void): () => void { // Event handler for node selection const handleNodeSelection = (event: CustomEvent<NodeSelectionDetail>) => { const { nodeId, focus } = event.detail @@ -65,17 +63,11 @@ export function setupNodeSelectionListener( } // Add event listener - document.addEventListener( - 'workflow:select-node', - handleNodeSelection as EventListener, - ) + document.addEventListener('workflow:select-node', handleNodeSelection as EventListener) // Return cleanup function return () => { - document.removeEventListener( - 'workflow:select-node', - handleNodeSelection as EventListener, - ) + document.removeEventListener('workflow:select-node', handleNodeSelection as EventListener) } } @@ -85,16 +77,13 @@ export function setupNodeSelectionListener( * @param reactflow - The ReactFlow instance * @returns Cleanup function */ -export function setupScrollToNodeListener( - nodes: any[], - reactflow: any, -): () => void { +export function setupScrollToNodeListener(nodes: any[], reactflow: any): () => void { // Event handler for scrolling to node const handleScrollToNode = (event: CustomEvent<NodeSelectionDetail>) => { const { nodeId } = event.detail if (nodeId) { // Find the target node - const node = nodes.find(n => n.id === nodeId) + const node = nodes.find((n) => n.id === nodeId) if (node) { // Use ReactFlow's fitView API to scroll to the node const nodePosition = { x: node.position.x, y: node.position.y } @@ -110,16 +99,10 @@ export function setupScrollToNodeListener( } // Add event listener - document.addEventListener( - 'workflow:scroll-to-node', - handleScrollToNode as EventListener, - ) + document.addEventListener('workflow:scroll-to-node', handleScrollToNode as EventListener) // Return cleanup function return () => { - document.removeEventListener( - 'workflow:scroll-to-node', - handleScrollToNode as EventListener, - ) + document.removeEventListener('workflow:scroll-to-node', handleScrollToNode as EventListener) } } diff --git a/web/app/components/workflow/utils/node.ts b/web/app/components/workflow/utils/node.ts index a36ffbf70f6a43..2dc8c6fac1309c 100644 --- a/web/app/components/workflow/utils/node.ts +++ b/web/app/components/workflow/utils/node.ts @@ -1,29 +1,26 @@ import type { IterationNodeType } from '../nodes/iteration/types' import type { LoopNodeType } from '../nodes/loop/types' -import type { - CommonNodeType, - Node, -} from '../types' -import { - Position, -} from 'reactflow' +import type { CommonNodeType, Node } from '../types' +import { Position } from 'reactflow' import { CUSTOM_SIMPLE_NODE } from '@/app/components/workflow/simple-node/constants' -import { - CUSTOM_NODE, - NESTED_ELEMENT_Z_INDEX, -} from '../constants' +import { CUSTOM_NODE, NESTED_ELEMENT_Z_INDEX } from '../constants' import { isAgentV2NodeData } from '../nodes/agent-v2/types' import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants' -import { - BlockEnum, -} from '../types' +import { BlockEnum } from '../types' export function getNodeCatalogType(data: CommonNodeType): BlockEnum { return isAgentV2NodeData(data) ? BlockEnum.AgentV2 : data.type } -export function generateNewNode({ data, position, id, zIndex, type, ...rest }: Omit<Node, 'id'> & { id?: string }): { +export function generateNewNode({ + data, + position, + id, + zIndex, + type, + ...rest +}: Omit<Node, 'id'> & { id?: string }): { newNode: Node newIterationStartNode?: Node newLoopStartNode?: Node @@ -40,9 +37,11 @@ export function generateNewNode({ data, position, id, zIndex, type, ...rest }: O } as Node if (data.type === BlockEnum.Iteration) { - const newIterationStartNode = getIterationStartNode(newNode.id); - (newNode.data as IterationNodeType).start_node_id = newIterationStartNode.id; - (newNode.data as IterationNodeType)._children = [{ nodeId: newIterationStartNode.id, nodeType: BlockEnum.IterationStart }] + const newIterationStartNode = getIterationStartNode(newNode.id) + ;(newNode.data as IterationNodeType).start_node_id = newIterationStartNode.id + ;(newNode.data as IterationNodeType)._children = [ + { nodeId: newIterationStartNode.id, nodeType: BlockEnum.IterationStart }, + ] return { newNode, newIterationStartNode, @@ -50,9 +49,11 @@ export function generateNewNode({ data, position, id, zIndex, type, ...rest }: O } if (data.type === BlockEnum.Loop) { - const newLoopStartNode = getLoopStartNode(newNode.id); - (newNode.data as LoopNodeType).start_node_id = newLoopStartNode.id; - (newNode.data as LoopNodeType)._children = [{ nodeId: newLoopStartNode.id, nodeType: BlockEnum.LoopStart }] + const newLoopStartNode = getLoopStartNode(newNode.id) + ;(newNode.data as LoopNodeType).start_node_id = newLoopStartNode.id + ;(newNode.data as LoopNodeType)._children = [ + { nodeId: newLoopStartNode.id, nodeType: BlockEnum.LoopStart }, + ] return { newNode, newLoopStartNode, @@ -114,8 +115,7 @@ export const genNewNodeTitleFromOld = (oldTitle: string) => { const title = match[1] const num = Number.parseInt(match[2]!, 10) return `${title} (${num + 1})` - } - else { + } else { return `${oldTitle} (1)` } } @@ -125,11 +125,9 @@ export const getTopLeftNodePosition = (nodes: Node[]) => { let minY = Infinity nodes.forEach((node) => { - if (node.position.x < minX) - minX = node.position.x + if (node.position.x < minX) minX = node.position.x - if (node.position.y < minY) - minY = node.position.y + if (node.position.y < minY) minY = node.position.y }) return { @@ -152,24 +150,31 @@ export const getNodesWithSameDefaultDataType = ( ) => { const dataType = (defaultValue.type as BlockEnum | undefined) ?? nodeType const discriminatorEntries = (['agent_node_kind', 'version'] as const) - .map(key => [key, (defaultValue as Record<string, unknown>)[key]] as const) + .map((key) => [key, (defaultValue as Record<string, unknown>)[key]] as const) .filter(([, value]) => value !== undefined) if (dataType !== nodeType && discriminatorEntries.length > 0) { - return nodes.filter(node => - node.data.type === dataType - && discriminatorEntries.every(([key, value]) => (node.data as Record<string, unknown>)[key] === value), + return nodes.filter( + (node) => + node.data.type === dataType && + discriminatorEntries.every( + ([key, value]) => (node.data as Record<string, unknown>)[key] === value, + ), ) } - return nodes.filter(node => node.data.type === dataType) + return nodes.filter((node) => node.data.type === dataType) } export const hasRetryNode = (nodeType?: BlockEnum) => { - return nodeType === BlockEnum.LLM || nodeType === BlockEnum.Tool || nodeType === BlockEnum.HttpRequest || nodeType === BlockEnum.Code + return ( + nodeType === BlockEnum.LLM || + nodeType === BlockEnum.Tool || + nodeType === BlockEnum.HttpRequest || + nodeType === BlockEnum.Code + ) } export const getNodeCustomTypeByNodeDataType = (nodeType: BlockEnum) => { - if (nodeType === BlockEnum.LoopEnd) - return CUSTOM_SIMPLE_NODE + if (nodeType === BlockEnum.LoopEnd) return CUSTOM_SIMPLE_NODE } diff --git a/web/app/components/workflow/utils/plugin-install-check.ts b/web/app/components/workflow/utils/plugin-install-check.ts index 539e705fed3aff..ac40cc4f2765c8 100644 --- a/web/app/components/workflow/utils/plugin-install-check.ts +++ b/web/app/components/workflow/utils/plugin-install-check.ts @@ -9,34 +9,38 @@ import { BlockEnum } from '../types' export function matchToolInCollection( collection: ToolWithProvider[], - data: { plugin_id?: string, provider_id?: string, provider_name?: string }, + data: { plugin_id?: string; provider_id?: string; provider_name?: string }, ): ToolWithProvider | undefined { - return collection.find(tool => - (data.plugin_id && tool.plugin_id === data.plugin_id) - || canFindTool(tool.id, data.provider_id) - || tool.name === data.provider_name, + return collection.find( + (tool) => + (data.plugin_id && tool.plugin_id === data.plugin_id) || + canFindTool(tool.id, data.provider_id) || + tool.name === data.provider_name, ) } export function matchTriggerProvider( providers: TriggerWithProvider[], - data: { provider_name?: string, provider_id?: string, plugin_id?: string }, + data: { provider_name?: string; provider_id?: string; plugin_id?: string }, ): TriggerWithProvider | undefined { - return providers.find(provider => - provider.name === data.provider_name - || provider.id === data.provider_id - || (data.plugin_id && provider.plugin_id === data.plugin_id), + return providers.find( + (provider) => + provider.name === data.provider_name || + provider.id === data.provider_id || + (data.plugin_id && provider.plugin_id === data.plugin_id), ) } export function matchDataSource( list: ToolWithProvider[], - data: { plugin_unique_identifier?: string, plugin_id?: string, provider_name?: string }, + data: { plugin_unique_identifier?: string; plugin_id?: string; provider_name?: string }, ): ToolWithProvider | undefined { - return list.find(item => - (data.plugin_unique_identifier && item.plugin_unique_identifier === data.plugin_unique_identifier) - || (data.plugin_id && item.plugin_id === data.plugin_id) - || (data.provider_name && item.provider === data.provider_name), + return list.find( + (item) => + (data.plugin_unique_identifier && + item.plugin_unique_identifier === data.plugin_unique_identifier) || + (data.plugin_id && item.plugin_id === data.plugin_id) || + (data.provider_name && item.provider === data.provider_name), ) } @@ -63,21 +67,26 @@ export function isNodePluginMissing( [CollectionType.mcp]: context.mcpTools, } const collection = collectionMap[toolData.provider_type] - if (!collection) - return false - return !matchToolInCollection(collection, toolData) && Boolean(toolData.plugin_unique_identifier) + if (!collection) return false + return ( + !matchToolInCollection(collection, toolData) && Boolean(toolData.plugin_unique_identifier) + ) } case BlockEnum.TriggerPlugin: { const triggerData = data as PluginTriggerNodeType - if (!context.triggerPlugins) - return false - return !matchTriggerProvider(context.triggerPlugins, triggerData) && Boolean(triggerData.plugin_unique_identifier) + if (!context.triggerPlugins) return false + return ( + !matchTriggerProvider(context.triggerPlugins, triggerData) && + Boolean(triggerData.plugin_unique_identifier) + ) } case BlockEnum.DataSource: { const dataSourceData = data as DataSourceNodeType - if (!context.dataSourceList) - return false - return !matchDataSource(context.dataSourceList, dataSourceData) && Boolean(dataSourceData.plugin_unique_identifier) + if (!context.dataSourceList) return false + return ( + !matchDataSource(context.dataSourceList, dataSourceData) && + Boolean(dataSourceData.plugin_unique_identifier) + ) } default: return false diff --git a/web/app/components/workflow/utils/tool.ts b/web/app/components/workflow/utils/tool.ts index b201b91ee3920a..d5768e66ef1ff0 100644 --- a/web/app/components/workflow/utils/tool.ts +++ b/web/app/components/workflow/utils/tool.ts @@ -1,8 +1,5 @@ import type { ToolNodeType } from '../nodes/tool/types' -import type { - InputVar, - ToolWithProvider, -} from '../types' +import type { InputVar, ToolWithProvider } from '../types' import type { StructuredOutput } from '@/app/components/workflow/nodes/llm/types' import { CollectionType } from '@/app/components/tools/types' import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' @@ -18,12 +15,17 @@ export const getToolCheckParams = ( language: string, ) => { const { provider_id, provider_type, tool_name } = toolData - const currentTools = provider_type === CollectionType.builtIn ? buildInTools : provider_type === CollectionType.custom ? customTools : workflowTools - const currCollection = currentTools.find(item => canFindTool(item.id, provider_id)) - const currTool = currCollection?.tools.find(tool => tool.name === tool_name) + const currentTools = + provider_type === CollectionType.builtIn + ? buildInTools + : provider_type === CollectionType.custom + ? customTools + : workflowTools + const currCollection = currentTools.find((item) => canFindTool(item.id, provider_id)) + const currTool = currCollection?.tools.find((tool) => tool.name === tool_name) const formSchemas = currTool ? toolParametersToFormSchemas(currTool.parameters) : [] - const toolInputVarSchema = formSchemas.filter(item => item.form === 'llm') - const toolSettingSchema = formSchemas.filter(item => item.form !== 'llm') + const toolInputVarSchema = formSchemas.filter((item) => item.form === 'llm') + const toolSettingSchema = formSchemas.filter((item) => item.form !== 'llm') return { toolInputsSchema: (() => { @@ -43,7 +45,10 @@ export const getToolCheckParams = ( language, } } -export const wrapStructuredVarItem = (outputItem: any, matchedSchemaType: string): StructuredOutput => { +export const wrapStructuredVarItem = ( + outputItem: any, + matchedSchemaType: string, +): StructuredOutput => { const dataType = Type.object return { schema: { diff --git a/web/app/components/workflow/utils/trigger.ts b/web/app/components/workflow/utils/trigger.ts index 569bf818948224..fc0b5b18d446ed 100644 --- a/web/app/components/workflow/utils/trigger.ts +++ b/web/app/components/workflow/utils/trigger.ts @@ -22,19 +22,16 @@ export const getTriggerCheckParams = ( } } - const { - provider_id, - provider_name, - event_name, - } = triggerData + const { provider_id, provider_name, event_name } = triggerData - const provider = triggerProviders.find(item => - item.name === provider_name - || item.id === provider_id - || (provider_id && item.plugin_id === provider_id), + const provider = triggerProviders.find( + (item) => + item.name === provider_name || + item.id === provider_id || + (provider_id && item.plugin_id === provider_id), ) - const currentEvent = provider?.events.find(event => event.name === event_name) + const currentEvent = provider?.events.find((event) => event.name === event_name) const triggerInputsSchema = (currentEvent?.parameters || []).map((parameter) => { const label = parameter.label?.[language] || parameter.label?.en_US || parameter.name diff --git a/web/app/components/workflow/utils/variable.ts b/web/app/components/workflow/utils/variable.ts index 8c40a469d1686e..ae760d77c04a4e 100644 --- a/web/app/components/workflow/utils/variable.ts +++ b/web/app/components/workflow/utils/variable.ts @@ -1,12 +1,8 @@ -import type { - BlockEnum, - ValueSelector, -} from '../types' +import type { BlockEnum, ValueSelector } from '../types' import { hasErrorHandleNode } from '.' export const variableTransformer = (v: ValueSelector | string) => { - if (typeof v === 'string') - return v.replace(/^\{\{#|#\}\}$/g, '').split('.') + if (typeof v === 'string') return v.replace(/^\{\{#|#\}\}$/g, '').split('.') return `{{#${v.join('.')}#}}` } diff --git a/web/app/components/workflow/utils/workflow-entry.ts b/web/app/components/workflow/utils/workflow-entry.ts index bb24fa14664638..a91fa70c996edb 100644 --- a/web/app/components/workflow/utils/workflow-entry.ts +++ b/web/app/components/workflow/utils/workflow-entry.ts @@ -6,11 +6,10 @@ import { BlockEnum, isTriggerNode } from '../types' * Priority: trigger nodes > start node */ export function getWorkflowEntryNode(nodes: Node[]): Node | undefined { - const triggerNode = nodes.find(node => isTriggerNode(node.data.type)) - if (triggerNode) - return triggerNode + const triggerNode = nodes.find((node) => isTriggerNode(node.data.type)) + if (triggerNode) return triggerNode - return nodes.find(node => node.data.type === BlockEnum.Start) + return nodes.find((node) => node.data.type === BlockEnum.Start) } /** @@ -24,5 +23,5 @@ export function isWorkflowEntryNode(nodeType: BlockEnum): boolean { * Check if workflow is in trigger mode */ export function isTriggerWorkflow(nodes: Node[]): boolean { - return nodes.some(node => isTriggerNode(node.data.type)) + return nodes.some((node) => isTriggerNode(node.data.type)) } diff --git a/web/app/components/workflow/utils/workflow-init.ts b/web/app/components/workflow/utils/workflow-init.ts index 6cef8347317ae4..ad1deb378f823c 100644 --- a/web/app/components/workflow/utils/workflow-init.ts +++ b/web/app/components/workflow/utils/workflow-init.ts @@ -3,19 +3,11 @@ import type { IterationNodeType } from '../nodes/iteration/types' import type { LoopNodeType } from '../nodes/loop/types' import type { QuestionClassifierNodeType } from '../nodes/question-classifier/types' import type { ToolNodeType } from '../nodes/tool/types' -import type { - Edge, - Node, -} from '../types' +import type { Edge, Node } from '../types' import { cloneDeep } from 'es-toolkit/object' -import { - getConnectedEdges, -} from 'reactflow' +import { getConnectedEdges } from 'reactflow' import { correctModelProvider } from '@/utils' -import { - getIterationStartNode, - getLoopStartNode, -} from '.' +import { getIterationStartNode, getLoopStartNode } from '.' import { CUSTOM_NODE, DEFAULT_RETRY_INTERVAL, @@ -27,15 +19,17 @@ import { import { branchNameCorrect } from '../nodes/if-else/utils' import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants' -import { - BlockEnum, - ErrorHandleMode, -} from '../types' +import { BlockEnum, ErrorHandleMode } from '../types' const WHITE = 'WHITE' const GRAY = 'GRAY' const BLACK = 'BLACK' -const isCyclicUtil = (nodeId: string, color: Record<string, string>, adjList: Record<string, string[]>, stack: string[]) => { +const isCyclicUtil = ( + nodeId: string, + color: Record<string, string>, + adjList: Record<string, string[]>, + stack: string[], +) => { color[nodeId] = GRAY stack.push(nodeId) @@ -46,12 +40,10 @@ const isCyclicUtil = (nodeId: string, color: Record<string, string>, adjList: Re stack.push(childId!) return true } - if (color[childId!] === WHITE && isCyclicUtil(childId!, color, adjList, stack)) - return true + if (color[childId!] === WHITE && isCyclicUtil(childId!, color, adjList, stack)) return true } color[nodeId] = BLACK - if (stack.length > 0 && stack[stack.length - 1] === nodeId) - stack.pop() + if (stack.length > 0 && stack[stack.length - 1] === nodeId) stack.pop() return false } @@ -65,20 +57,17 @@ const getCycleEdges = (nodes: Node[], edges: Edge[]) => { adjList[node.id] = [] } - for (const edge of edges) - adjList[edge.source]?.push(edge.target) + for (const edge of edges) adjList[edge.source]?.push(edge.target) for (let i = 0; i < nodes.length; i++) { - if (color[nodes[i]!.id] === WHITE) - isCyclicUtil(nodes[i]!.id, color, adjList, stack) + if (color[nodes[i]!.id] === WHITE) isCyclicUtil(nodes[i]!.id, color, adjList, stack) } const cycleEdges = [] if (stack.length > 0) { const cycleNodes = new Set(stack) for (const edge of edges) { - if (cycleNodes.has(edge.source) && cycleNodes.has(edge.target)) - cycleEdges.push(edge) + if (cycleNodes.has(edge.source) && cycleNodes.has(edge.target)) cycleEdges.push(edge) } } @@ -86,8 +75,8 @@ const getCycleEdges = (nodes: Node[], edges: Edge[]) => { } export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { - const hasIterationNode = nodes.some(node => node.data.type === BlockEnum.Iteration) - const hasLoopNode = nodes.some(node => node.data.type === BlockEnum.Loop) + const hasIterationNode = nodes.some((node) => node.data.type === BlockEnum.Iteration) + const hasLoopNode = nodes.some((node) => node.data.type === BlockEnum.Loop) if (!hasIterationNode && !hasLoopNode) { return { @@ -96,10 +85,13 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { } } - const nodesMap = nodes.reduce((prev, next) => { - prev[next.id] = next - return prev - }, {} as Record<string, Node>) + const nodesMap = nodes.reduce( + (prev, next) => { + prev[next.id] = next + return prev + }, + {} as Record<string, Node>, + ) const iterationNodesWithStartNode = [] const iterationNodesWithoutStartNode = [] @@ -113,8 +105,7 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { if (currentNode.data.start_node_id) { if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_ITERATION_START_NODE) iterationNodesWithStartNode.push(currentNode) - } - else { + } else { iterationNodesWithoutStartNode.push(currentNode) } } @@ -123,15 +114,17 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { if (currentNode.data.start_node_id) { if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_LOOP_START_NODE) loopNodesWithStartNode.push(currentNode) - } - else { + } else { loopNodesWithoutStartNode.push(currentNode) } } } const newIterationStartNodesMap = {} as Record<string, Node> - const newIterationStartNodes = [...iterationNodesWithStartNode, ...iterationNodesWithoutStartNode].map((iterationNode, index) => { + const newIterationStartNodes = [ + ...iterationNodesWithStartNode, + ...iterationNodesWithoutStartNode, + ].map((iterationNode, index) => { const newNode = getIterationStartNode(iterationNode.id) newNode.id = newNode.id + index newIterationStartNodesMap[iterationNode.id] = newNode @@ -139,12 +132,14 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { }) const newLoopStartNodesMap = {} as Record<string, Node> - const newLoopStartNodes = [...loopNodesWithStartNode, ...loopNodesWithoutStartNode].map((loopNode, index) => { - const newNode = getLoopStartNode(loopNode.id) - newNode.id = newNode.id + index - newLoopStartNodesMap[loopNode.id] = newNode - return newNode - }) + const newLoopStartNodes = [...loopNodesWithStartNode, ...loopNodesWithoutStartNode].map( + (loopNode, index) => { + const newNode = getLoopStartNode(loopNode.id) + newNode.id = newNode.id + index + newLoopStartNodesMap[loopNode.id] = newNode + return newNode + }, + ) const newEdges = [...iterationNodesWithStartNode, ...loopNodesWithStartNode].map((nodeItem) => { const isIteration = nodeItem.data.type === BlockEnum.Iteration @@ -155,7 +150,7 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { const target = startNode!.id const targetHandle = 'target' - const parentNode = nodes.find(node => node.id === startNode!.parentId) || null + const parentNode = nodes.find((node) => node.id === startNode!.parentId) || null const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop @@ -195,7 +190,7 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => { export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { const { nodes, edges } = preprocessNodesAndEdges(cloneDeep(originNodes), cloneDeep(originEdges)) const firstNode = nodes[0] - const nodesById = new Map(nodes.map(node => [node.id, node])) + const nodesById = new Map(nodes.map((node) => [node.id, node])) if (!firstNode?.position) { nodes.forEach((node, index) => { @@ -206,34 +201,40 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { }) } - const iterationOrLoopNodeMap = nodes.reduce((acc, node) => { - if (node.parentId) { - if (acc[node.parentId]) - acc[node.parentId]!.push({ nodeId: node.id, nodeType: node.data.type }) - else - acc[node.parentId] = [{ nodeId: node.id, nodeType: node.data.type }] - } - return acc - }, {} as Record<string, { nodeId: string, nodeType: BlockEnum }[]>) + const iterationOrLoopNodeMap = nodes.reduce( + (acc, node) => { + if (node.parentId) { + if (acc[node.parentId]) + acc[node.parentId]!.push({ nodeId: node.id, nodeType: node.data.type }) + else acc[node.parentId] = [{ nodeId: node.id, nodeType: node.data.type }] + } + return acc + }, + {} as Record<string, { nodeId: string; nodeType: BlockEnum }[]>, + ) return nodes.map((node) => { const parentNode = node.parentId ? nodesById.get(node.parentId) : undefined - const isNested = parentNode?.data.type === BlockEnum.Iteration || parentNode?.data.type === BlockEnum.Loop + const isNested = + parentNode?.data.type === BlockEnum.Iteration || parentNode?.data.type === BlockEnum.Loop node.zIndex = isNested ? NESTED_ELEMENT_Z_INDEX : 0 - if (!node.type) - node.type = CUSTOM_NODE + if (!node.type) node.type = CUSTOM_NODE const connectedEdges = getConnectedEdges([node], edges) - node.data._connectedSourceHandleIds = connectedEdges.filter(edge => edge.source === node.id).map(edge => edge.sourceHandle || 'source') - node.data._connectedTargetHandleIds = connectedEdges.filter(edge => edge.target === node.id).map(edge => edge.targetHandle || 'target') + node.data._connectedSourceHandleIds = connectedEdges + .filter((edge) => edge.source === node.id) + .map((edge) => edge.sourceHandle || 'source') + node.data._connectedTargetHandleIds = connectedEdges + .filter((edge) => edge.target === node.id) + .map((edge) => edge.targetHandle || 'target') if (node.data.type === BlockEnum.IfElse) { const nodeData = node.data as IfElseNodeType if (!nodeData.cases && nodeData.logical_operator && nodeData.conditions) { - (node.data as IfElseNodeType).cases = [ + ;(node.data as IfElseNodeType).cases = [ { case_id: 'true', logical_operator: nodeData.logical_operator, @@ -242,7 +243,7 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { ] } node.data._targetBranches = branchNameCorrect([ - ...(node.data as IfElseNodeType).cases.map(item => ({ id: item.case_id, name: '' })), + ...(node.data as IfElseNodeType).cases.map((item) => ({ id: item.case_id, name: '' })), { id: 'false', name: '' }, ]) // delete conditions and logical_operator if cases is not empty @@ -263,7 +264,8 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { iterationNodeData._children = iterationOrLoopNodeMap[node.id] || [] iterationNodeData.is_parallel = iterationNodeData.is_parallel || false iterationNodeData.parallel_nums = iterationNodeData.parallel_nums || 10 - iterationNodeData.error_handle_mode = iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated + iterationNodeData.error_handle_mode = + iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated } // TODO: loop error handle mode @@ -277,8 +279,13 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { if (node.data.type === BlockEnum.LLM) (node as any).data.model.provider = correctModelProvider((node as any).data.model.provider) - if (node.data.type === BlockEnum.KnowledgeRetrieval && (node as any).data.multiple_retrieval_config?.reranking_model) - (node as any).data.multiple_retrieval_config.reranking_model.provider = correctModelProvider((node as any).data.multiple_retrieval_config?.reranking_model.provider) + if ( + node.data.type === BlockEnum.KnowledgeRetrieval && + (node as any).data.multiple_retrieval_config?.reranking_model + ) + (node as any).data.multiple_retrieval_config.reranking_model.provider = correctModelProvider( + (node as any).data.multiple_retrieval_config?.reranking_model.provider, + ) if (node.data.type === BlockEnum.QuestionClassifier) (node as any).data.model.provider = correctModelProvider((node as any).data.model.provider) @@ -294,8 +301,12 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { } } - if (node.data.type === BlockEnum.Tool && !(node as Node<ToolNodeType>).data.version && !(node as Node<ToolNodeType>).data.tool_node_version) { - (node as Node<ToolNodeType>).data.tool_node_version = '2' + if ( + node.data.type === BlockEnum.Tool && + !(node as Node<ToolNodeType>).data.version && + !(node as Node<ToolNodeType>).data.tool_node_version + ) { + ;(node as Node<ToolNodeType>).data.tool_node_version = '2' const toolConfigurations = (node as Node<ToolNodeType>).data.tool_configurations if (toolConfigurations && Object.keys(toolConfigurations).length > 0) { @@ -307,8 +318,8 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { value: toolConfigurations[key], } } - }); - (node as Node<ToolNodeType>).data.tool_configurations = newValues + }) + ;(node as Node<ToolNodeType>).data.tool_configurations = newValues } } @@ -319,66 +330,71 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => { export const initialEdges = (originEdges: Edge[], originNodes: Node[]) => { const { nodes, edges } = preprocessNodesAndEdges(cloneDeep(originNodes), cloneDeep(originEdges)) let selectedNode: Node | null = null - const nodesMap = nodes.reduce((acc, node) => { - acc[node.id] = node + const nodesMap = nodes.reduce( + (acc, node) => { + acc[node.id] = node - if (node.data?.selected) - selectedNode = node + if (node.data?.selected) selectedNode = node - return acc - }, {} as Record<string, Node>) + return acc + }, + {} as Record<string, Node>, + ) const cycleEdges = getCycleEdges(nodes, edges) - return edges.filter((edge) => { - return !cycleEdges.find(cycEdge => cycEdge.source === edge.source && cycEdge.target === edge.target) - }).map((edge) => { - edge.type = 'custom' + return edges + .filter((edge) => { + return !cycleEdges.find( + (cycEdge) => cycEdge.source === edge.source && cycEdge.target === edge.target, + ) + }) + .map((edge) => { + edge.type = 'custom' - if (!edge.sourceHandle) - edge.sourceHandle = 'source' + if (!edge.sourceHandle) edge.sourceHandle = 'source' - if (!edge.targetHandle) - edge.targetHandle = 'target' + if (!edge.targetHandle) edge.targetHandle = 'target' - if (!edge.data?.sourceType && edge.source && nodesMap[edge.source]) { - edge.data = { - ...edge.data, - sourceType: nodesMap[edge.source]!.data.type!, - } as any - } + if (!edge.data?.sourceType && edge.source && nodesMap[edge.source]) { + edge.data = { + ...edge.data, + sourceType: nodesMap[edge.source]!.data.type!, + } as any + } - if (!edge.data?.targetType && edge.target && nodesMap[edge.target]) { - edge.data = { - ...edge.data, - targetType: nodesMap[edge.target]!.data.type!, - } as any - } + if (!edge.data?.targetType && edge.target && nodesMap[edge.target]) { + edge.data = { + ...edge.data, + targetType: nodesMap[edge.target]!.data.type!, + } as any + } - if (selectedNode) { + if (selectedNode) { + edge.data = { + ...edge.data, + _connectedNodeIsSelected: + edge.source === selectedNode.id || edge.target === selectedNode.id, + } as any + } + + const sourceNode = nodesMap[edge.source] + const targetNode = nodesMap[edge.target] + const sourceParentNode = sourceNode?.parentId ? nodesMap[sourceNode.parentId] : undefined + const targetParentNode = targetNode?.parentId ? nodesMap[targetNode.parentId] : undefined + const nestedParentNode = [sourceParentNode, targetParentNode].find( + (node) => node?.data.type === BlockEnum.Iteration || node?.data.type === BlockEnum.Loop, + ) + const isInIteration = nestedParentNode?.data.type === BlockEnum.Iteration + const isInLoop = nestedParentNode?.data.type === BlockEnum.Loop edge.data = { ...edge.data, - _connectedNodeIsSelected: edge.source === selectedNode.id || edge.target === selectedNode.id, - } as any - } + isInIteration, + iteration_id: isInIteration ? nestedParentNode.id : undefined, + isInLoop, + loop_id: isInLoop ? nestedParentNode.id : undefined, + } as Edge['data'] + edge.zIndex = nestedParentNode ? NESTED_ELEMENT_Z_INDEX : 0 - const sourceNode = nodesMap[edge.source] - const targetNode = nodesMap[edge.target] - const sourceParentNode = sourceNode?.parentId ? nodesMap[sourceNode.parentId] : undefined - const targetParentNode = targetNode?.parentId ? nodesMap[targetNode.parentId] : undefined - const nestedParentNode = [sourceParentNode, targetParentNode].find(node => node?.data.type === BlockEnum.Iteration || node?.data.type === BlockEnum.Loop) - const isInIteration = nestedParentNode?.data.type === BlockEnum.Iteration - const isInLoop = nestedParentNode?.data.type === BlockEnum.Loop - edge.data = { - ...edge.data, - isInIteration, - iteration_id: isInIteration ? nestedParentNode.id : undefined, - isInLoop, - loop_id: isInLoop ? nestedParentNode.id : undefined, - } as Edge['data'] - edge.zIndex = nestedParentNode - ? NESTED_ELEMENT_Z_INDEX - : 0 - - return edge - }) + return edge + }) } diff --git a/web/app/components/workflow/utils/workflow.ts b/web/app/components/workflow/utils/workflow.ts index c1f8597c551c60..c066968701b32a 100644 --- a/web/app/components/workflow/utils/workflow.ts +++ b/web/app/components/workflow/utils/workflow.ts @@ -1,43 +1,35 @@ -import type { - Edge, - Node, -} from '../types' -import { - uniqBy, -} from 'es-toolkit/compat' -import { - getOutgoers, -} from 'reactflow' -import { - BlockEnum, -} from '../types' +import type { Edge, Node } from '../types' +import { uniqBy } from 'es-toolkit/compat' +import { getOutgoers } from 'reactflow' +import { BlockEnum } from '../types' export const canRunBySingle = (nodeType: BlockEnum, isChildNode: boolean) => { // child node means in iteration or loop. Set value to iteration(or loop) may cause variable not exit problem in backend. - if (isChildNode && nodeType === BlockEnum.Assigner) - return false - return nodeType === BlockEnum.LLM - || nodeType === BlockEnum.KnowledgeRetrieval - || nodeType === BlockEnum.Code - || nodeType === BlockEnum.TemplateTransform - || nodeType === BlockEnum.QuestionClassifier - || nodeType === BlockEnum.HttpRequest - || nodeType === BlockEnum.Tool - || nodeType === BlockEnum.ParameterExtractor - || nodeType === BlockEnum.Iteration - || nodeType === BlockEnum.Agent - || nodeType === BlockEnum.AgentV2 - || nodeType === BlockEnum.DocExtractor - || nodeType === BlockEnum.Loop - || nodeType === BlockEnum.Start - || nodeType === BlockEnum.IfElse - || nodeType === BlockEnum.VariableAggregator - || nodeType === BlockEnum.Assigner - || nodeType === BlockEnum.HumanInput - || nodeType === BlockEnum.DataSource - || nodeType === BlockEnum.TriggerSchedule - || nodeType === BlockEnum.TriggerWebhook - || nodeType === BlockEnum.TriggerPlugin + if (isChildNode && nodeType === BlockEnum.Assigner) return false + return ( + nodeType === BlockEnum.LLM || + nodeType === BlockEnum.KnowledgeRetrieval || + nodeType === BlockEnum.Code || + nodeType === BlockEnum.TemplateTransform || + nodeType === BlockEnum.QuestionClassifier || + nodeType === BlockEnum.HttpRequest || + nodeType === BlockEnum.Tool || + nodeType === BlockEnum.ParameterExtractor || + nodeType === BlockEnum.Iteration || + nodeType === BlockEnum.Agent || + nodeType === BlockEnum.AgentV2 || + nodeType === BlockEnum.DocExtractor || + nodeType === BlockEnum.Loop || + nodeType === BlockEnum.Start || + nodeType === BlockEnum.IfElse || + nodeType === BlockEnum.VariableAggregator || + nodeType === BlockEnum.Assigner || + nodeType === BlockEnum.HumanInput || + nodeType === BlockEnum.DataSource || + nodeType === BlockEnum.TriggerSchedule || + nodeType === BlockEnum.TriggerWebhook || + nodeType === BlockEnum.TriggerPlugin + ) } export const isSupportCustomRunForm = (nodeType: BlockEnum) => { @@ -48,48 +40,64 @@ type ConnectedSourceOrTargetNodesChange = { type: string edge: Edge }[] -export const getNodesConnectedSourceOrTargetHandleIdsMap = (changes: ConnectedSourceOrTargetNodesChange, nodes: Node[]) => { +export const getNodesConnectedSourceOrTargetHandleIdsMap = ( + changes: ConnectedSourceOrTargetNodesChange, + nodes: Node[], +) => { const nodesConnectedSourceOrTargetHandleIdsMap = {} as Record<string, any> changes.forEach((change) => { - const { - edge, - type, - } = change - const sourceNode = nodes.find(node => node.id === edge.source)! + const { edge, type } = change + const sourceNode = nodes.find((node) => node.id === edge.source)! if (sourceNode) { - nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id] = nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id] || { - _connectedSourceHandleIds: [...(sourceNode?.data._connectedSourceHandleIds || [])], - _connectedTargetHandleIds: [...(sourceNode?.data._connectedTargetHandleIds || [])], - } + nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id] = + nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id] || { + _connectedSourceHandleIds: [...(sourceNode?.data._connectedSourceHandleIds || [])], + _connectedTargetHandleIds: [...(sourceNode?.data._connectedTargetHandleIds || [])], + } } - const targetNode = nodes.find(node => node.id === edge.target)! + const targetNode = nodes.find((node) => node.id === edge.target)! if (targetNode) { - nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id] = nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id] || { - _connectedSourceHandleIds: [...(targetNode?.data._connectedSourceHandleIds || [])], - _connectedTargetHandleIds: [...(targetNode?.data._connectedTargetHandleIds || [])], - } + nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id] = + nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id] || { + _connectedSourceHandleIds: [...(targetNode?.data._connectedSourceHandleIds || [])], + _connectedTargetHandleIds: [...(targetNode?.data._connectedTargetHandleIds || [])], + } } if (sourceNode) { if (type === 'remove') { - const index = nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id]._connectedSourceHandleIds.findIndex((handleId: string) => handleId === edge.sourceHandle) - nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id]._connectedSourceHandleIds.splice(index, 1) + const index = nodesConnectedSourceOrTargetHandleIdsMap[ + sourceNode.id + ]._connectedSourceHandleIds.findIndex((handleId: string) => handleId === edge.sourceHandle) + nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id]._connectedSourceHandleIds.splice( + index, + 1, + ) } if (type === 'add') - nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id]._connectedSourceHandleIds.push(edge.sourceHandle || 'source') + nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id]._connectedSourceHandleIds.push( + edge.sourceHandle || 'source', + ) } if (targetNode) { if (type === 'remove') { - const index = nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id]._connectedTargetHandleIds.findIndex((handleId: string) => handleId === edge.targetHandle) - nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id]._connectedTargetHandleIds.splice(index, 1) + const index = nodesConnectedSourceOrTargetHandleIdsMap[ + targetNode.id + ]._connectedTargetHandleIds.findIndex((handleId: string) => handleId === edge.targetHandle) + nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id]._connectedTargetHandleIds.splice( + index, + 1, + ) } if (type === 'add') - nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id]._connectedTargetHandleIds.push(edge.targetHandle || 'target') + nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id]._connectedTargetHandleIds.push( + edge.targetHandle || 'target', + ) } }) @@ -98,11 +106,12 @@ export const getNodesConnectedSourceOrTargetHandleIdsMap = (changes: ConnectedSo export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => { // Find all start nodes (Start and Trigger nodes) - const startNodes = nodes.filter(node => - node.data.type === BlockEnum.Start - || node.data.type === BlockEnum.TriggerSchedule - || node.data.type === BlockEnum.TriggerWebhook - || node.data.type === BlockEnum.TriggerPlugin, + const startNodes = nodes.filter( + (node) => + node.data.type === BlockEnum.Start || + node.data.type === BlockEnum.TriggerSchedule || + node.data.type === BlockEnum.TriggerWebhook || + node.data.type === BlockEnum.TriggerPlugin, ) if (startNodes.length === 0) { @@ -119,37 +128,34 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => { // Add the current node to the list list.push(root) - if (depth > maxDepth) - maxDepth = depth + if (depth > maxDepth) maxDepth = depth const outgoers = getOutgoers(root, nodes, edges) if (outgoers.length) { outgoers.forEach((outgoer) => { // Only traverse if we haven't processed this node yet (avoid cycles) - if (!list.find(n => n.id === outgoer.id)) { + if (!list.find((n) => n.id === outgoer.id)) { if (outgoer.data.type === BlockEnum.Iteration) - list.push(...nodes.filter(node => node.parentId === outgoer.id)) + list.push(...nodes.filter((node) => node.parentId === outgoer.id)) if (outgoer.data.type === BlockEnum.Loop) - list.push(...nodes.filter(node => node.parentId === outgoer.id)) + list.push(...nodes.filter((node) => node.parentId === outgoer.id)) traverse(outgoer, depth + 1) } }) - } - else { + } else { // Leaf node - add iteration/loop children if any if (root.data.type === BlockEnum.Iteration) - list.push(...nodes.filter(node => node.parentId === root.id)) + list.push(...nodes.filter((node) => node.parentId === root.id)) if (root.data.type === BlockEnum.Loop) - list.push(...nodes.filter(node => node.parentId === root.id)) + list.push(...nodes.filter((node) => node.parentId === root.id)) } } // Start traversal from all start nodes startNodes.forEach((startNode) => { - if (!list.find(n => n.id === startNode.id)) - traverse(startNode, 1) + if (!list.find((n) => n.id === startNode.id)) traverse(startNode, 1) }) return { @@ -158,5 +164,12 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => { } } export const hasErrorHandleNode = (nodeType?: BlockEnum) => { - return nodeType === BlockEnum.LLM || nodeType === BlockEnum.Tool || nodeType === BlockEnum.HttpRequest || nodeType === BlockEnum.Code || nodeType === BlockEnum.Agent || nodeType === BlockEnum.AgentV2 + return ( + nodeType === BlockEnum.LLM || + nodeType === BlockEnum.Tool || + nodeType === BlockEnum.HttpRequest || + nodeType === BlockEnum.Code || + nodeType === BlockEnum.Agent || + nodeType === BlockEnum.AgentV2 + ) } diff --git a/web/app/components/workflow/variable-inspect/__tests__/display-content.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/display-content.spec.tsx index e4cbfbf7adca4e..8d64da41e985ee 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/display-content.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/display-content.spec.tsx @@ -13,12 +13,7 @@ describe('variable inspect display content', () => { it('renders markdown code view and forwards text edits', () => { const handleTextChange = vi.fn() - render( - <DisplayContent - {...baseProps} - handleTextChange={handleTextChange} - />, - ) + render(<DisplayContent {...baseProps} handleTextChange={handleTextChange} />) expect(screen.getByText('MARKDOWN')).toBeInTheDocument() diff --git a/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx index c445670717fe66..642bb80e6c9a81 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx @@ -121,7 +121,9 @@ describe('VariableInspect Group', () => { ) fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.view' })) - fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.clearNode' })) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.debug.variableInspect.clearNode' }), + ) expect(handleView).toHaveBeenCalledTimes(1) expect(handleClear).toHaveBeenCalledTimes(1) diff --git a/web/app/components/workflow/variable-inspect/__tests__/large-data-alert.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/large-data-alert.spec.tsx index ce180b2531b49e..d2a28ffe225acd 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/large-data-alert.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/large-data-alert.spec.tsx @@ -3,7 +3,9 @@ import LargeDataAlert from '../large-data-alert' describe('LargeDataAlert', () => { it('should render the default message and export action when a download URL exists', () => { - const { container } = render(<LargeDataAlert downloadUrl="https://example.com/export.json" className="extra-alert" />) + const { container } = render( + <LargeDataAlert downloadUrl="https://example.com/export.json" className="extra-alert" />, + ) expect(screen.getByText('workflow.debug.variableInspect.largeData')).toBeInTheDocument() expect(screen.getByText('workflow.debug.variableInspect.export')).toBeInTheDocument() diff --git a/web/app/components/workflow/variable-inspect/__tests__/listening.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/listening.spec.tsx index 45556020066b20..cde78a099eea5c 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/listening.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/listening.spec.tsx @@ -22,28 +22,33 @@ describe('variable inspect listening', () => { it('renders the listening message and forwards the stop action', () => { const onStop = vi.fn() - renderWorkflowFlowComponent(<Listening onStop={onStop} message="Waiting for webhook payload" />, { - nodes: [ - createTriggerNode(BlockEnum.TriggerWebhook, { - id: 'trigger-1', - data: { - title: 'Webhook Trigger', - webhook_debug_url: 'https://example.com/debug', - }, - }), - ], - edges: [], - initialStoreState: { - listeningTriggerType: BlockEnum.TriggerWebhook, - listeningTriggerNodeId: 'trigger-1', - listeningTriggerNodeIds: [], - listeningTriggerIsAll: false, + renderWorkflowFlowComponent( + <Listening onStop={onStop} message="Waiting for webhook payload" />, + { + nodes: [ + createTriggerNode(BlockEnum.TriggerWebhook, { + id: 'trigger-1', + data: { + title: 'Webhook Trigger', + webhook_debug_url: 'https://example.com/debug', + }, + }), + ], + edges: [], + initialStoreState: { + listeningTriggerType: BlockEnum.TriggerWebhook, + listeningTriggerNodeId: 'trigger-1', + listeningTriggerNodeIds: [], + listeningTriggerIsAll: false, + }, }, - }) + ) expect(screen.getByText('Waiting for webhook payload')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.listening.stopButton' })) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.debug.variableInspect.listening.stopButton' }), + ) expect(onStop).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/workflow/variable-inspect/__tests__/panel.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/panel.spec.tsx index 0519fc4f94ab63..1483effddc6896 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/panel.spec.tsx @@ -97,7 +97,9 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -const createEnvironmentVariable = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVariable => ({ +const createEnvironmentVariable = ( + overrides: Partial<EnvironmentVariable> = {}, +): EnvironmentVariable => ({ id: 'env-1', name: 'API_KEY', value: 'env-value', @@ -107,18 +109,15 @@ const createEnvironmentVariable = (overrides: Partial<EnvironmentVariable> = {}) }) const renderPanel = (initialStoreState: Record<string, unknown> = {}) => { - return renderWorkflowFlowComponent( - <Panel />, - { + return renderWorkflowFlowComponent(<Panel />, { + nodes: [], + edges: [], + initialStoreState, + historyStore: { nodes: [], edges: [], - initialStoreState, - historyStore: { - nodes: [], - edges: [], - }, }, - ) + }) } describe('VariableInspect Panel', () => { @@ -137,7 +136,9 @@ describe('VariableInspect Panel', () => { listeningTriggerType: BlockEnum.TriggerWebhook, }) - fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.listening.stopButton' })) + fireEvent.click( + screen.getByRole('button', { name: 'workflow.debug.variableInspect.listening.stopButton' }), + ) expect(screen.getByText('workflow.debug.variableInspect.listening.title'))!.toBeInTheDocument() expect(mockEmit).toHaveBeenCalledWith({ diff --git a/web/app/components/workflow/variable-inspect/__tests__/trigger.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/trigger.spec.tsx index 6d2f2ffc02a95f..f819d37e015272 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/trigger.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/trigger.spec.tsx @@ -12,10 +12,7 @@ type InspectVarsState = { nodesWithInspectVars: NodeWithVar[] } -const { - mockDeleteAllInspectorVars, - mockEmit, -} = vi.hoisted(() => ({ +const { mockDeleteAllInspectorVars, mockEmit } = vi.hoisted(() => ({ mockDeleteAllInspectorVars: vi.fn(), mockEmit: vi.fn(), })) @@ -62,7 +59,11 @@ const renderTrigger = ({ nodes?: Array<ReturnType<typeof createNode>> initialStoreState?: Record<string, unknown> } = {}) => { - return renderWorkflowFlowComponent(<VariableInspectTrigger />, { nodes, edges: [], initialStoreState }) + return renderWorkflowFlowComponent(<VariableInspectTrigger />, { + nodes, + edges: [], + initialStoreState, + }) } describe('VariableInspectTrigger', () => { @@ -82,7 +83,9 @@ describe('VariableInspectTrigger', () => { }, }) - expect(screen.queryByText('workflow.debug.variableInspect.trigger.normal')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.debug.variableInspect.trigger.normal'), + ).not.toBeInTheDocument() }) it('should open the panel from the normal trigger state', () => { @@ -107,10 +110,12 @@ describe('VariableInspectTrigger', () => { it('should clear cached variables and reset the focused node', () => { inspectVarsState = { - conversationVars: [createVariable({ - id: 'conversation-var', - type: VarInInspectType.conversation, - })], + conversationVars: [ + createVariable({ + id: 'conversation-var', + type: VarInInspectType.conversation, + }), + ], systemVars: [], nodesWithInspectVars: [], } @@ -130,14 +135,16 @@ describe('VariableInspectTrigger', () => { it('should show the running state and open the panel while running', () => { const { store } = renderTrigger({ - nodes: [createNode({ - data: { - type: BlockEnum.Code, - title: 'Code', - desc: '', - _singleRunningStatus: NodeRunningStatus.Running, - }, - })], + nodes: [ + createNode({ + data: { + type: BlockEnum.Code, + title: 'Code', + desc: '', + _singleRunningStatus: NodeRunningStatus.Running, + }, + }), + ], initialStoreState: { workflowRunningData: baseRunningData({ result: { status: WorkflowRunningStatus.Running }, @@ -147,7 +154,9 @@ describe('VariableInspectTrigger', () => { fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.running')) - expect(screen.queryByText('workflow.debug.variableInspect.trigger.clear')).not.toBeInTheDocument() + expect( + screen.queryByText('workflow.debug.variableInspect.trigger.clear'), + ).not.toBeInTheDocument() expect(store.getState().showVariableInspectPanel).toBe(true) }) }) diff --git a/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx index e955a0e465a9ba..88f9db80ae8059 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx @@ -30,11 +30,18 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }), })) -vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor', () => ({ - default: ({ schema, onUpdate }: { schema: string, onUpdate: (value: string) => void }) => ( - <textarea data-testid="schema-editor" value={schema} onChange={event => onUpdate(event.target.value)} /> - ), -})) +vi.mock( + '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor', + () => ({ + default: ({ schema, onUpdate }: { schema: string; onUpdate: (value: string) => void }) => ( + <textarea + data-testid="schema-editor" + value={schema} + onChange={(event) => onUpdate(event.target.value)} + /> + ), + }), +) vi.mock('@/next/navigation', () => ({ useParams: () => ({ token: '' }), @@ -52,14 +59,15 @@ describe('value-content sections', () => { workflow_file_upload_limit: 5, }) - const createVar = (overrides: Partial<VarInInspect>): VarInInspect => ({ - id: 'var-1', - name: 'query', - type: VarInInspectType.node, - value_type: VarType.string, - value: '', - ...overrides, - } as VarInInspect) + const createVar = (overrides: Partial<VarInInspect>): VarInInspect => + ({ + id: 'var-1', + name: 'query', + type: VarInInspectType.node, + value_type: VarType.string, + value: '', + ...overrides, + }) as VarInInspect it('should render the text editor section and forward text changes', () => { const handleTextChange = vi.fn() @@ -115,10 +123,7 @@ describe('value-content sections', () => { isTruncated={false} onChange={onChange} /> - <ErrorMessages - parseError={new Error('Broken JSON')} - validationError="Too deep" - /> + <ErrorMessages parseError={new Error('Broken JSON')} validationError="Too deep" /> </>, ) diff --git a/web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.branches.spec.ts b/web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.branches.spec.ts index 82d9a92ad2616c..9e3cb9a727761f 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.branches.spec.ts +++ b/web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.branches.spec.ts @@ -16,8 +16,7 @@ describe('value-content helpers branch coverage', () => { vi.doMock('../utils', () => ({ validateJSONSchema: (schema: Record<string, unknown>) => { - if (schema.kind === 'invalid') - return { success: false, error: new Error('schema invalid') } + if (schema.kind === 'invalid') return { success: false, error: new Error('schema invalid') } return { success: true } }, })) diff --git a/web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.spec.ts b/web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.spec.ts index dcf22adcc2a2fa..a2bf049c554823 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.spec.ts +++ b/web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.spec.ts @@ -9,43 +9,56 @@ import { } from '../value-content.helpers' describe('value-content helpers', () => { - const createVar = (overrides: Partial<VarInInspect>): VarInInspect => ({ - id: 'var-1', - name: 'query', - type: VarInInspectType.node, - value_type: VarType.string, - value: '', - ...overrides, - } as VarInInspect) + const createVar = (overrides: Partial<VarInInspect>): VarInInspect => + ({ + id: 'var-1', + name: 'query', + type: VarInInspectType.node, + value_type: VarType.string, + value: '', + ...overrides, + }) as VarInInspect it('should derive editor modes from the variable shape', () => { - expect(getValueEditorState(createVar({ - type: VarInInspectType.environment, - name: 'api_key', - value_type: VarType.string, - value: 'secret', - }))).toMatchObject({ + expect( + getValueEditorState( + createVar({ + type: VarInInspectType.environment, + name: 'api_key', + value_type: VarType.string, + value: 'secret', + }), + ), + ).toMatchObject({ showTextEditor: true, textEditorDisabled: true, showJSONEditor: false, }) - expect(getValueEditorState(createVar({ - name: 'payload', - value_type: VarType.object, - value: { foo: 1 }, - schemaType: 'general_structure', - }))).toMatchObject({ + expect( + getValueEditorState( + createVar({ + name: 'payload', + value_type: VarType.object, + value: { foo: 1 }, + schemaType: 'general_structure', + }), + ), + ).toMatchObject({ showJSONEditor: true, hasChunks: true, }) - expect(getValueEditorState(createVar({ - type: VarInInspectType.system, - name: 'files', - value_type: VarType.arrayFile, - value: [], - }))).toMatchObject({ + expect( + getValueEditorState( + createVar({ + type: VarInInspectType.system, + name: 'files', + value_type: VarType.arrayFile, + value: [], + }), + ), + ).toMatchObject({ isSysFiles: true, showFileEditor: true, showJSONEditor: false, @@ -53,20 +66,28 @@ describe('value-content helpers', () => { }) it('should format file values and detect upload completion', () => { - expect(formatInspectFileValue(createVar({ - name: 'file', - value_type: VarType.file, - value: { id: 'file-1' }, - }))).toHaveLength(1) + expect( + formatInspectFileValue( + createVar({ + name: 'file', + value_type: VarType.file, + value: { id: 'file-1' }, + }), + ), + ).toHaveLength(1) expect(isFileValueUploaded([{ upload_file_id: 'file-1' }])).toBe(true) expect(isFileValueUploaded([{ upload_file_id: '' }])).toBe(false) - expect(formatInspectFileValue(createVar({ - type: VarInInspectType.system, - name: 'files', - value_type: VarType.arrayFile, - value: [{ id: 'file-2' }], - }))).toHaveLength(1) + expect( + formatInspectFileValue( + createVar({ + type: VarInInspectType.system, + name: 'files', + value_type: VarType.arrayFile, + value: [{ id: 'file-2' }], + }), + ), + ).toHaveLength(1) }) it('should validate json input and surface parse errors', () => { diff --git a/web/app/components/workflow/variable-inspect/__tests__/value-content.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/value-content.spec.tsx index d96e3adc948d10..265bc57d699d00 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/value-content.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/value-content.spec.tsx @@ -13,11 +13,18 @@ vi.mock('@/app/components/base/file-uploader/utils', async (importOriginal) => { } }) -vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor', () => ({ - default: ({ schema, onUpdate }: { schema: string, onUpdate: (value: string) => void }) => ( - <textarea data-testid="json-editor" value={schema} onChange={event => onUpdate(event.target.value)} /> - ), -})) +vi.mock( + '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor', + () => ({ + default: ({ schema, onUpdate }: { schema: string; onUpdate: (value: string) => void }) => ( + <textarea + data-testid="json-editor" + value={schema} + onChange={(event) => onUpdate(event.target.value)} + /> + ), + }), +) vi.mock('../value-content-sections', () => ({ TextEditorSection: ({ @@ -26,19 +33,23 @@ vi.mock('../value-content-sections', () => ({ }: { value: string onTextChange: (value: string) => void - }) => <textarea aria-label="value-text-editor" value={value ?? ''} onChange={event => onTextChange(event.target.value)} />, - BoolArraySection: ({ - onChange, - }: { - onChange: (value: boolean[]) => void - }) => <button onClick={() => onChange([true, true])}>bool-array-editor</button>, - JsonEditorSection: ({ - json, - onChange, - }: { - json: string - onChange: (value: string) => void - }) => <textarea data-testid="json-editor" value={json} onChange={event => onChange(event.target.value)} />, + }) => ( + <textarea + aria-label="value-text-editor" + value={value ?? ''} + onChange={(event) => onTextChange(event.target.value)} + /> + ), + BoolArraySection: ({ onChange }: { onChange: (value: boolean[]) => void }) => ( + <button onClick={() => onChange([true, true])}>bool-array-editor</button> + ), + JsonEditorSection: ({ json, onChange }: { json: string; onChange: (value: string) => void }) => ( + <textarea + data-testid="json-editor" + value={json} + onChange={(event) => onChange(event.target.value)} + /> + ), FileEditorSection: ({ onChange, }: { @@ -46,11 +57,16 @@ vi.mock('../value-content-sections', () => ({ }) => ( <div> <button onClick={() => onChange([{ upload_file_id: '' }])}>file-pending</button> - <button onClick={() => onChange([{ upload_file_id: 'file-1', name: 'report.pdf' }])}>file-uploaded</button> - <button onClick={() => onChange([ - { upload_file_id: 'file-1', name: 'a.pdf' }, - { upload_file_id: 'file-2', name: 'b.pdf' }, - ])} + <button onClick={() => onChange([{ upload_file_id: 'file-1', name: 'report.pdf' }])}> + file-uploaded + </button> + <button + onClick={() => + onChange([ + { upload_file_id: 'file-1', name: 'a.pdf' }, + { upload_file_id: 'file-2', name: 'b.pdf' }, + ]) + } > file-array-uploaded </button> @@ -75,14 +91,15 @@ vi.mock('@/next/navigation', () => ({ })) describe('ValueContent', () => { - const createVar = (overrides: Partial<VarInInspect>): VarInInspect => ({ - id: 'var-default', - name: 'query', - type: VarInInspectType.node, - value_type: VarType.string, - value: '', - ...overrides, - } as VarInInspect) + const createVar = (overrides: Partial<VarInInspect>): VarInInspect => + ({ + id: 'var-default', + name: 'query', + type: VarInInspectType.node, + value_type: VarType.string, + value: '', + ...overrides, + }) as VarInInspect beforeEach(() => { vi.clearAllMocks() @@ -327,7 +344,10 @@ describe('ValueContent', () => { fireEvent.click(screen.getByText('file-uploaded')) await waitFor(() => { - expect(handleValueChange).toHaveBeenCalledWith('var-8', expect.objectContaining({ upload_file_id: 'file-1' })) + expect(handleValueChange).toHaveBeenCalledWith( + 'var-8', + expect.objectContaining({ upload_file_id: 'file-1' }), + ) }) }) @@ -336,7 +356,10 @@ describe('ValueContent', () => { const observe = vi.fn() const disconnect = vi.fn() const originalResizeObserver = globalThis.ResizeObserver - const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'clientHeight') + const originalClientHeight = Object.getOwnPropertyDescriptor( + HTMLDivElement.prototype, + 'clientHeight', + ) Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', { configurable: true, @@ -352,9 +375,14 @@ describe('ValueContent', () => { observe = (target: Element) => { observe(target) - this.callback([{ - borderBoxSize: [{ inlineSize: 20 }], - } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver) + this.callback( + [ + { + borderBoxSize: [{ inlineSize: 20 }], + } as unknown as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ) } disconnect = disconnect @@ -386,10 +414,13 @@ describe('ValueContent', () => { fireEvent.click(screen.getByText('file-array-uploaded')) await waitFor(() => { - expect(handleValueChange).toHaveBeenCalledWith('var-9', expect.arrayContaining([ - expect.objectContaining({ upload_file_id: 'file-1' }), - expect.objectContaining({ upload_file_id: 'file-2' }), - ])) + expect(handleValueChange).toHaveBeenCalledWith( + 'var-9', + expect.arrayContaining([ + expect.objectContaining({ upload_file_id: 'file-1' }), + expect.objectContaining({ upload_file_id: 'file-2' }), + ]), + ) }) expect(observe).toHaveBeenCalled() @@ -397,13 +428,10 @@ describe('ValueContent', () => { if (originalClientHeight) Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', originalClientHeight) - else - delete (HTMLDivElement.prototype as { clientHeight?: number }).clientHeight + else delete (HTMLDivElement.prototype as { clientHeight?: number }).clientHeight - if (originalResizeObserver) - vi.stubGlobal('ResizeObserver', originalResizeObserver) - else - vi.unstubAllGlobals() + if (originalResizeObserver) vi.stubGlobal('ResizeObserver', originalResizeObserver) + else vi.unstubAllGlobals() expect(disconnect).not.toHaveBeenCalled() }) diff --git a/web/app/components/workflow/variable-inspect/display-content.tsx b/web/app/components/workflow/variable-inspect/display-content.tsx index e2d1ab92db700b..9731ce711f1423 100644 --- a/web/app/components/workflow/variable-inspect/display-content.tsx +++ b/web/app/components/workflow/variable-inspect/display-content.tsx @@ -25,44 +25,63 @@ type DisplayContentProps = { } export function DisplayContent(props: DisplayContentProps) { - const { previewType, varType, schemaType, mdString, jsonString, readonly, handleTextChange, handleEditorChange, className } = props + const { + previewType, + varType, + schemaType, + mdString, + jsonString, + readonly, + handleTextChange, + handleEditorChange, + className, + } = props const [selectedViewModes, setSelectedViewModes] = useState<readonly ViewMode[]>([ViewMode.Code]) const [isFocused, setIsFocused] = useState(false) const { t } = useTranslation() const viewOptions = [ - { value: ViewMode.Code, label: t($ => $['nodes.templateTransform.code'], { ns: 'workflow' }), iconClassName: 'i-ri-braces-line' }, - { value: ViewMode.Preview, label: t($ => $['common.preview'], { ns: 'workflow' }), iconClassName: 'i-ri-eye-line' }, + { + value: ViewMode.Code, + label: t(($) => $['nodes.templateTransform.code'], { ns: 'workflow' }), + iconClassName: 'i-ri-braces-line', + }, + { + value: ViewMode.Preview, + label: t(($) => $['common.preview'], { ns: 'workflow' }), + iconClassName: 'i-ri-eye-line', + }, ] const selectedViewMode = selectedViewModes[0] ?? ViewMode.Code function handleViewModeChange(nextViewModes: ViewMode[]) { const nextViewMode = nextViewModes[0] - if (nextViewMode) - setSelectedViewModes([nextViewMode]) + if (nextViewMode) setSelectedViewModes([nextViewMode]) } const chunkType = useMemo(() => { - if (previewType !== PreviewType.Chunks || !schemaType) - return undefined - if (schemaType === 'general_structure') - return ChunkingMode.text - if (schemaType === 'parent_child_structure') - return ChunkingMode.parentChild - if (schemaType === 'qa_structure') - return ChunkingMode.qa + if (previewType !== PreviewType.Chunks || !schemaType) return undefined + if (schemaType === 'general_structure') return ChunkingMode.text + if (schemaType === 'parent_child_structure') return ChunkingMode.parentChild + if (schemaType === 'qa_structure') return ChunkingMode.qa }, [previewType, schemaType]) const parentMode = useMemo(() => { - if (previewType !== PreviewType.Chunks || !schemaType || !jsonString) - return undefined + if (previewType !== PreviewType.Chunks || !schemaType || !jsonString) return undefined if (schemaType === 'parent_child_structure') return JSON.parse(jsonString!)?.parent_mode as ParentMode return undefined }, [previewType, schemaType, jsonString]) return ( - <div className={cn('flex h-full flex-col rounded-[10px] bg-components-input-bg-normal', isFocused && 'bg-components-input-bg-active outline-1 outline-components-input-border-active outline-solid', className)}> + <div + className={cn( + 'flex h-full flex-col rounded-[10px] bg-components-input-bg-normal', + isFocused && + 'bg-components-input-bg-active outline-1 outline-components-input-border-active outline-solid', + className, + )} + > <div className="flex shrink-0 items-center justify-end p-1"> {previewType === PreviewType.Markdown && ( <div className="flex grow items-center px-2 py-0.5 system-xs-semibold-uppercase text-text-secondary"> @@ -76,7 +95,7 @@ export function DisplayContent(props: DisplayContentProps) { </div> )} <SegmentedControl<ViewMode> - aria-label={t($ => $['common.preview'], { ns: 'workflow' })} + aria-label={t(($) => $['common.preview'], { ns: 'workflow' })} value={selectedViewModes} onValueChange={handleViewModeChange} className="shrink-0 rounded-md p-px" @@ -94,43 +113,42 @@ export function DisplayContent(props: DisplayContentProps) { </SegmentedControl> </div> <div className="flex flex-1 overflow-auto rounded-b-[10px] pr-1 pl-3"> - {selectedViewMode === ViewMode.Code && ( - previewType === PreviewType.Markdown - ? ( - <Textarea - aria-label={t($ => $['debug.variableInspect.markdownContent'], { ns: 'workflow' })} - readOnly={readonly} - disabled={readonly} - className="h-full border-none bg-transparent p-0 text-text-secondary hover:bg-transparent focus:bg-transparent focus:shadow-none" - value={mdString as any} - onValueChange={value => handleTextChange?.(value)} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - /> - ) - : ( - <SchemaEditor - readonly={readonly} - className="overflow-y-auto bg-transparent" - hideTopMenu - schema={jsonString!} - onUpdate={handleEditorChange!} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - /> - ) - )} - {selectedViewMode === ViewMode.Preview && ( - previewType === PreviewType.Markdown - ? <Markdown className="grow overflow-auto rounded-lg px-4 py-3" content={(mdString ?? '') as string} /> - : ( - <ChunkCardList - chunkType={chunkType!} - parentMode={parentMode} - chunkInfo={JSON.parse(jsonString!) as ChunkInfo} - /> - ) - )} + {selectedViewMode === ViewMode.Code && + (previewType === PreviewType.Markdown ? ( + <Textarea + aria-label={t(($) => $['debug.variableInspect.markdownContent'], { ns: 'workflow' })} + readOnly={readonly} + disabled={readonly} + className="h-full border-none bg-transparent p-0 text-text-secondary hover:bg-transparent focus:bg-transparent focus:shadow-none" + value={mdString as any} + onValueChange={(value) => handleTextChange?.(value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + /> + ) : ( + <SchemaEditor + readonly={readonly} + className="overflow-y-auto bg-transparent" + hideTopMenu + schema={jsonString!} + onUpdate={handleEditorChange!} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + /> + ))} + {selectedViewMode === ViewMode.Preview && + (previewType === PreviewType.Markdown ? ( + <Markdown + className="grow overflow-auto rounded-lg px-4 py-3" + content={(mdString ?? '') as string} + /> + ) : ( + <ChunkCardList + chunkType={chunkType!} + parentMode={parentMode} + chunkInfo={JSON.parse(jsonString!) as ChunkInfo} + /> + ))} </div> </div> ) diff --git a/web/app/components/workflow/variable-inspect/empty.tsx b/web/app/components/workflow/variable-inspect/empty.tsx index 92af9c836f80b4..55992c448d8583 100644 --- a/web/app/components/workflow/variable-inspect/empty.tsx +++ b/web/app/components/workflow/variable-inspect/empty.tsx @@ -13,15 +13,19 @@ const Empty: FC = () => { <Variable02 className="size-5 text-text-accent" /> </div> <div className="flex flex-col gap-1"> - <div className="system-sm-semibold text-text-secondary">{t($ => $['debug.variableInspect.title'], { ns: 'workflow' })}</div> - <div className="system-xs-regular text-text-tertiary">{t($ => $['debug.variableInspect.emptyTip'], { ns: 'workflow' })}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['debug.variableInspect.title'], { ns: 'workflow' })} + </div> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['debug.variableInspect.emptyTip'], { ns: 'workflow' })} + </div> <a className="cursor-pointer system-xs-regular text-text-accent" href={docLink('/use-dify/debug/variable-inspect')} target="_blank" rel="noopener noreferrer" > - {t($ => $['debug.variableInspect.emptyLink'], { ns: 'workflow' })} + {t(($) => $['debug.variableInspect.emptyLink'], { ns: 'workflow' })} </a> </div> </div> diff --git a/web/app/components/workflow/variable-inspect/group.tsx b/web/app/components/workflow/variable-inspect/group.tsx index a95310f3ad79cc..094171c58d7c66 100644 --- a/web/app/components/workflow/variable-inspect/group.tsx +++ b/web/app/components/workflow/variable-inspect/group.tsx @@ -45,13 +45,14 @@ const Group = ({ const isEnv = varType === VarInInspectType.environment const isChatVar = varType === VarInInspectType.conversation const isSystem = varType === VarInInspectType.system - const groupTitle = nodeData?.title - || (isEnv && t($ => $['debug.variableInspect.envNode'], { ns: 'workflow' })) - || (isChatVar && t($ => $['debug.variableInspect.chatNode'], { ns: 'workflow' })) - || (isSystem && t($ => $['debug.variableInspect.systemNode'], { ns: 'workflow' })) - || '' + const groupTitle = + nodeData?.title || + (isEnv && t(($) => $['debug.variableInspect.envNode'], { ns: 'workflow' })) || + (isChatVar && t(($) => $['debug.variableInspect.chatNode'], { ns: 'workflow' })) || + (isSystem && t(($) => $['debug.variableInspect.systemNode'], { ns: 'workflow' })) || + '' - const visibleVarList = isEnv ? varList : varList.filter(v => v.visible) + const visibleVarList = isEnv ? varList : varList.filter((v) => v.visible) const handleSelectVar = (varItem: any, type?: string) => { if (type === VarInInspectType.environment) { @@ -91,8 +92,7 @@ const Group = ({ }) return } - if (!nodeData) - return + if (!nodeData) return handleSelect({ nodeId: nodeData.nodeId, nodeType: nodeData.nodeType, @@ -116,7 +116,10 @@ const Group = ({ <RiLoader2Line className="size-3 animate-spin text-text-accent" aria-hidden /> )} {(!nodeData || !nodeData.isSingRunRunning) && visibleVarList.length > 0 && ( - <RiArrowRightSLine className={cn('size-3 text-text-tertiary', !isCollapsed && 'rotate-90')} aria-hidden /> + <RiArrowRightSLine + className={cn('size-3 text-text-tertiary', !isCollapsed && 'rotate-90')} + aria-hidden + /> )} </div> {nodeData && ( @@ -127,7 +130,9 @@ const Group = ({ toolIcon={toolIcon || ''} size="xs" /> - <div className="truncate system-xs-medium-uppercase text-text-tertiary">{nodeData.title}</div> + <div className="truncate system-xs-medium-uppercase text-text-tertiary"> + {nodeData.title} + </div> </> )} {!nodeData && ( @@ -140,26 +145,32 @@ const Group = ({ <div className="hidden shrink-0 items-center group-hover:flex"> <Tooltip> <TooltipTrigger - render={( - <ActionButton aria-label={t($ => $['debug.variableInspect.view'], { ns: 'workflow' })} onClick={handleView}> + render={ + <ActionButton + aria-label={t(($) => $['debug.variableInspect.view'], { ns: 'workflow' })} + onClick={handleView} + > <RiFileList3Line className="size-4" aria-hidden /> </ActionButton> - )} + } /> <TooltipContent> - {t($ => $['debug.variableInspect.view'], { ns: 'workflow' })} + {t(($) => $['debug.variableInspect.view'], { ns: 'workflow' })} </TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger - render={( - <ActionButton aria-label={t($ => $['debug.variableInspect.clearNode'], { ns: 'workflow' })} onClick={handleClear}> + render={ + <ActionButton + aria-label={t(($) => $['debug.variableInspect.clearNode'], { ns: 'workflow' })} + onClick={handleClear} + > <RiDeleteBinLine className="size-4" aria-hidden /> </ActionButton> - )} + } /> <TooltipContent> - {t($ => $['debug.variableInspect.clearNode'], { ns: 'workflow' })} + {t(($) => $['debug.variableInspect.clearNode'], { ns: 'workflow' })} </TooltipContent> </Tooltip> </div> @@ -168,27 +179,32 @@ const Group = ({ {/* var item list */} {!isCollapsed && !nodeData?.isSingRunRunning && ( <div className="px-0.5"> - {visibleVarList.length > 0 && visibleVarList.map(varItem => ( - <button - type="button" - key={varItem.id} - className={cn( - 'relative flex w-full cursor-pointer items-center gap-1 rounded-md border-none px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover', - varItem.id === currentVar?.var?.id - ? 'bg-state-base-hover-alt hover:bg-state-base-hover-alt' - : 'bg-transparent', - )} - onClick={() => handleSelectVar(varItem, varType)} - > - <VariableIconWithColor - variableCategory={varType} - isExceptionVariable={['error_type', 'error_message'].includes(varItem.name)} - className="size-4" - /> - <div className="grow truncate system-sm-medium text-text-secondary">{varItem.name}</div> - <div className="shrink-0 system-xs-regular text-text-tertiary">{varItem.value_type}</div> - </button> - ))} + {visibleVarList.length > 0 && + visibleVarList.map((varItem) => ( + <button + type="button" + key={varItem.id} + className={cn( + 'relative flex w-full cursor-pointer items-center gap-1 rounded-md border-none px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover', + varItem.id === currentVar?.var?.id + ? 'bg-state-base-hover-alt hover:bg-state-base-hover-alt' + : 'bg-transparent', + )} + onClick={() => handleSelectVar(varItem, varType)} + > + <VariableIconWithColor + variableCategory={varType} + isExceptionVariable={['error_type', 'error_message'].includes(varItem.name)} + className="size-4" + /> + <div className="grow truncate system-sm-medium text-text-secondary"> + {varItem.name} + </div> + <div className="shrink-0 system-xs-regular text-text-tertiary"> + {varItem.value_type} + </div> + </button> + ))} </div> )} </div> diff --git a/web/app/components/workflow/variable-inspect/index.tsx b/web/app/components/workflow/variable-inspect/index.tsx index 8d5b500bca6dea..aac454eab83000 100644 --- a/web/app/components/workflow/variable-inspect/index.tsx +++ b/web/app/components/workflow/variable-inspect/index.tsx @@ -1,38 +1,34 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { debounce } from 'es-toolkit/compat' -import { - useCallback, - useMemo, -} from 'react' +import { useCallback, useMemo } from 'react' import { useResizePanel } from '../nodes/_base/hooks/use-resize-panel' import { useSetWorkflowVariableInspectPanelHeight } from '../persistence/local-storage-options' import { useStore } from '../store' import Panel from './panel' const VariableInspectPanel: FC = () => { - const showVariableInspectPanel = useStore(s => s.showVariableInspectPanel) - const workflowCanvasHeight = useStore(s => s.workflowCanvasHeight) - const variableInspectPanelHeight = useStore(s => s.variableInspectPanelHeight) - const setVariableInspectPanelHeight = useStore(s => s.setVariableInspectPanelHeight) + const showVariableInspectPanel = useStore((s) => s.showVariableInspectPanel) + const workflowCanvasHeight = useStore((s) => s.workflowCanvasHeight) + const variableInspectPanelHeight = useStore((s) => s.variableInspectPanelHeight) + const setVariableInspectPanelHeight = useStore((s) => s.setVariableInspectPanelHeight) const maxHeight = useMemo(() => { - if (!workflowCanvasHeight) - return 480 + if (!workflowCanvasHeight) return 480 return workflowCanvasHeight - 60 }, [workflowCanvasHeight]) const setPanelHeightStorage = useSetWorkflowVariableInspectPanelHeight() - const handleResize = useCallback((width: number, height: number) => { - setPanelHeightStorage(height) - setVariableInspectPanelHeight(height) - }, [setVariableInspectPanelHeight, setPanelHeightStorage]) + const handleResize = useCallback( + (width: number, height: number) => { + setPanelHeightStorage(height) + setVariableInspectPanelHeight(height) + }, + [setVariableInspectPanelHeight, setPanelHeightStorage], + ) - const { - triggerRef, - containerRef, - } = useResizePanel({ + const { triggerRef, containerRef } = useResizePanel({ direction: 'vertical', triggerDirection: 'top', minHeight: 120, @@ -40,8 +36,7 @@ const VariableInspectPanel: FC = () => { onResize: debounce(handleResize), }) - if (!showVariableInspectPanel) - return null + if (!showVariableInspectPanel) return null return ( <div className={cn('relative pb-1')}> @@ -53,7 +48,9 @@ const VariableInspectPanel: FC = () => { </div> <div ref={containerRef} - className={cn('overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl')} + className={cn( + 'overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl', + )} style={{ height: `${variableInspectPanelHeight}px` }} > <Panel /> diff --git a/web/app/components/workflow/variable-inspect/large-data-alert.tsx b/web/app/components/workflow/variable-inspect/large-data-alert.tsx index 29ee2a36df89e0..540b45665bc913 100644 --- a/web/app/components/workflow/variable-inspect/large-data-alert.tsx +++ b/web/app/components/workflow/variable-inspect/large-data-alert.tsx @@ -11,21 +11,26 @@ type Props = Readonly<{ className?: string }> -const LargeDataAlert: FC<Props> = ({ - textHasNoExport, - downloadUrl, - className, -}) => { +const LargeDataAlert: FC<Props> = ({ textHasNoExport, downloadUrl, className }) => { const { t } = useTranslation() - const text = textHasNoExport ? t($ => $['debug.variableInspect.largeDataNoExport'], { ns: 'workflow' }) : t($ => $['debug.variableInspect.largeData'], { ns: 'workflow' }) + const text = textHasNoExport + ? t(($) => $['debug.variableInspect.largeDataNoExport'], { ns: 'workflow' }) + : t(($) => $['debug.variableInspect.largeData'], { ns: 'workflow' }) return ( - <div className={cn('flex h-8 items-center justify-between rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-2 shadow-xs', className)}> + <div + className={cn( + 'flex h-8 items-center justify-between rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-2 shadow-xs', + className, + )} + > <div className="flex h-full w-0 grow items-center space-x-1"> <RiInformation2Fill className="size-4 shrink-0 text-text-accent" /> <div className="w-0 grow truncate system-xs-regular text-text-primary">{text}</div> </div> {downloadUrl && ( - <div className="ml-1 shrink-0 cursor-pointer system-xs-medium-uppercase text-text-accent">{t($ => $['debug.variableInspect.export'], { ns: 'workflow' })}</div> + <div className="ml-1 shrink-0 cursor-pointer system-xs-medium-uppercase text-text-accent"> + {t(($) => $['debug.variableInspect.export'], { ns: 'workflow' })} + </div> )} </div> ) diff --git a/web/app/components/workflow/variable-inspect/left.tsx b/web/app/components/workflow/variable-inspect/left.tsx index 9424464222f5ed..4b7b696652200c 100644 --- a/web/app/components/workflow/variable-inspect/left.tsx +++ b/web/app/components/workflow/variable-inspect/left.tsx @@ -1,5 +1,4 @@ import type { currentVarType } from './panel' - import type { VarInInspect } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' @@ -16,14 +15,11 @@ type Props = Readonly<{ handleVarSelect: (state: any) => void }> -const Left = ({ - currentNodeVar, - handleVarSelect, -}: Props) => { +const Left = ({ currentNodeVar, handleVarSelect }: Props) => { const { t } = useTranslation() - const environmentVariables = useStore(s => s.environmentVariables) - const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId) + const environmentVariables = useStore((s) => s.environmentVariables) + const setCurrentFocusNodeId = useStore((s) => s.setCurrentFocusNodeId) const { conversationVars, @@ -34,7 +30,8 @@ const Left = ({ } = useCurrentVars() const { handleNodeSelect } = useNodesInteractions() - const showDivider = environmentVariables.length > 0 || conversationVars.length > 0 || systemVars.length > 0 + const showDivider = + environmentVariables.length > 0 || conversationVars.length > 0 || systemVars.length > 0 const handleClearAll = () => { deleteAllInspectorVars() @@ -50,8 +47,12 @@ const Left = ({ <div className={cn('flex h-full flex-col')}> {/* header */} <div className="flex shrink-0 items-center justify-between gap-1 pt-2 pr-1 pl-4"> - <div className="truncate system-sm-semibold-uppercase text-text-primary">{t($ => $['debug.variableInspect.title'], { ns: 'workflow' })}</div> - <Button variant="ghost" size="small" className="shrink-0" onClick={handleClearAll}>{t($ => $['debug.variableInspect.clearAll'], { ns: 'workflow' })}</Button> + <div className="truncate system-sm-semibold-uppercase text-text-primary"> + {t(($) => $['debug.variableInspect.title'], { ns: 'workflow' })} + </div> + <Button variant="ghost" size="small" className="shrink-0" onClick={handleClearAll}> + {t(($) => $['debug.variableInspect.clearAll'], { ns: 'workflow' })} + </Button> </div> {/* content */} <div className="grow overflow-y-auto py-1"> @@ -89,18 +90,19 @@ const Left = ({ </div> )} {/* group nodes */} - {nodesWithInspectVars.length > 0 && nodesWithInspectVars.map(group => ( - <Group - key={group.nodeId} - varType={VarInInspectType.node} - varList={group.vars} - nodeData={group} - currentVar={currentNodeVar} - handleSelect={handleVarSelect} - handleView={() => handleNodeSelect(group.nodeId, false, true)} - handleClear={() => handleClearNode(group.nodeId)} - /> - ))} + {nodesWithInspectVars.length > 0 && + nodesWithInspectVars.map((group) => ( + <Group + key={group.nodeId} + varType={VarInInspectType.node} + varList={group.vars} + nodeData={group} + currentVar={currentNodeVar} + handleSelect={handleVarSelect} + handleView={() => handleNodeSelect(group.nodeId, false, true)} + handleClear={() => handleClearNode(group.nodeId)} + /> + ))} </div> </div> ) diff --git a/web/app/components/workflow/variable-inspect/listening.tsx b/web/app/components/workflow/variable-inspect/listening.tsx index 0a42dec83423b0..3f1caaf1dbb2cc 100644 --- a/web/app/components/workflow/variable-inspect/listening.tsx +++ b/web/app/components/workflow/variable-inspect/listening.tsx @@ -22,52 +22,55 @@ const resolveListeningDescription = ( triggerType: BlockEnum, t: TFunction, ): string => { - if (message) - return message + if (message) return message if (triggerType === BlockEnum.TriggerSchedule) { const scheduleData = triggerNode?.data as ScheduleTriggerNodeType | undefined const nextTriggerTime = scheduleData ? getNextExecutionTime(scheduleData) : '' - return t($ => $['debug.variableInspect.listening.tipSchedule'], { + return t(($) => $['debug.variableInspect.listening.tipSchedule'], { ns: 'workflow', - nextTriggerTime: nextTriggerTime || t($ => $['debug.variableInspect.listening.defaultScheduleTime'], { ns: 'workflow' }), + nextTriggerTime: + nextTriggerTime || + t(($) => $['debug.variableInspect.listening.defaultScheduleTime'], { ns: 'workflow' }), }) } if (triggerType === BlockEnum.TriggerPlugin) { - const pluginName = (triggerNode?.data as { provider_name?: string, title?: string })?.provider_name - || (triggerNode?.data as { title?: string })?.title - || t($ => $['debug.variableInspect.listening.defaultPluginName'], { ns: 'workflow' }) - return t($ => $['debug.variableInspect.listening.tipPlugin'], { ns: 'workflow', pluginName }) + const pluginName = + (triggerNode?.data as { provider_name?: string; title?: string })?.provider_name || + (triggerNode?.data as { title?: string })?.title || + t(($) => $['debug.variableInspect.listening.defaultPluginName'], { ns: 'workflow' }) + return t(($) => $['debug.variableInspect.listening.tipPlugin'], { ns: 'workflow', pluginName }) } if (triggerType === BlockEnum.TriggerWebhook) { - const nodeName = (triggerNode?.data as { title?: string })?.title || t($ => $['debug.variableInspect.listening.defaultNodeName'], { ns: 'workflow' }) - return t($ => $['debug.variableInspect.listening.tip'], { ns: 'workflow', nodeName }) + const nodeName = + (triggerNode?.data as { title?: string })?.title || + t(($) => $['debug.variableInspect.listening.defaultNodeName'], { ns: 'workflow' }) + return t(($) => $['debug.variableInspect.listening.tip'], { ns: 'workflow', nodeName }) } const nodeDescription = (triggerNode?.data as { desc?: string })?.desc - if (nodeDescription) - return nodeDescription + if (nodeDescription) return nodeDescription - return t($ => $['debug.variableInspect.listening.tipFallback'], { ns: 'workflow' }) + return t(($) => $['debug.variableInspect.listening.tipFallback'], { ns: 'workflow' }) } -const resolveMultipleListeningDescription = ( - nodes: Node[], - t: TFunction, -): string => { +const resolveMultipleListeningDescription = (nodes: Node[], t: TFunction): string => { if (!nodes.length) - return t($ => $['debug.variableInspect.listening.tipFallback'], { ns: 'workflow' }) + return t(($) => $['debug.variableInspect.listening.tipFallback'], { ns: 'workflow' }) const titles = nodes - .map(node => (node.data as { title?: string })?.title) + .map((node) => (node.data as { title?: string })?.title) .filter((title): title is string => Boolean(title)) if (titles.length) - return t($ => $['debug.variableInspect.listening.tip'], { ns: 'workflow', nodeName: titles.join(', ') }) + return t(($) => $['debug.variableInspect.listening.tip'], { + ns: 'workflow', + nodeName: titles.join(', '), + }) - return t($ => $['debug.variableInspect.listening.tipFallback'], { ns: 'workflow' }) + return t(($) => $['debug.variableInspect.listening.tipFallback'], { ns: 'workflow' }) } type ListeningProps = { @@ -75,18 +78,15 @@ type ListeningProps = { message?: string } -const Listening: FC<ListeningProps> = ({ - onStop, - message, -}) => { +const Listening: FC<ListeningProps> = ({ onStop, message }) => { const { t } = useTranslation() const store = useStoreApi() // Get the current trigger type and node ID from store - const listeningTriggerType = useStore(s => s.listeningTriggerType) - const listeningTriggerNodeId = useStore(s => s.listeningTriggerNodeId) - const listeningTriggerNodeIds = useStore(s => s.listeningTriggerNodeIds) - const listeningTriggerIsAll = useStore(s => s.listeningTriggerIsAll) + const listeningTriggerType = useStore((s) => s.listeningTriggerType) + const listeningTriggerNodeId = useStore((s) => s.listeningTriggerNodeId) + const listeningTriggerNodeIds = useStore((s) => s.listeningTriggerNodeIds) + const listeningTriggerIsAll = useStore((s) => s.listeningTriggerIsAll) const getToolIcon = useGetToolIcon() @@ -94,18 +94,18 @@ const Listening: FC<ListeningProps> = ({ const { getNodes } = store.getState() const nodes = getNodes() const triggerNode = listeningTriggerNodeId - ? nodes.find(node => node.id === listeningTriggerNodeId) + ? nodes.find((node) => node.id === listeningTriggerNodeId) : undefined const inferredTriggerType = (triggerNode?.data as { type?: BlockEnum })?.type const triggerType = listeningTriggerType || inferredTriggerType || BlockEnum.TriggerWebhook - const webhookDebugUrl = triggerType === BlockEnum.TriggerWebhook - ? (triggerNode?.data as WebhookTriggerNodeType | undefined)?.webhook_debug_url - : undefined + const webhookDebugUrl = + triggerType === BlockEnum.TriggerWebhook + ? (triggerNode?.data as WebhookTriggerNodeType | undefined)?.webhook_debug_url + : undefined const [debugUrlCopied, setDebugUrlCopied] = useState(false) useEffect(() => { - if (!debugUrlCopied) - return + if (!debugUrlCopied) return const timer = window.setTimeout(() => { setDebugUrlCopied(false) @@ -120,18 +120,18 @@ const Listening: FC<ListeningProps> = ({ if (listeningTriggerIsAll) { if (listeningTriggerNodeIds.length > 0) { - displayNodes = nodes.filter(node => listeningTriggerNodeIds.includes(node.id)) - } - else { + displayNodes = nodes.filter((node) => listeningTriggerNodeIds.includes(node.id)) + } else { displayNodes = nodes.filter((node) => { const nodeType = (node.data as { type?: BlockEnum })?.type - return nodeType === BlockEnum.TriggerSchedule - || nodeType === BlockEnum.TriggerWebhook - || nodeType === BlockEnum.TriggerPlugin + return ( + nodeType === BlockEnum.TriggerSchedule || + nodeType === BlockEnum.TriggerWebhook || + nodeType === BlockEnum.TriggerPlugin + ) }) } - } - else if (triggerNode) { + } else if (triggerNode) { displayNodes = [triggerNode] } @@ -149,7 +149,8 @@ const Listening: FC<ListeningProps> = ({ iconsToRender.push({ key: 'default', type: listeningTriggerIsAll ? BlockEnum.TriggerWebhook : triggerType, - toolIcon: !listeningTriggerIsAll && triggerNode ? getToolIcon(triggerNode.data as any) : undefined, + toolIcon: + !listeningTriggerIsAll && triggerNode ? getToolIcon(triggerNode.data as any) : undefined, }) } @@ -160,7 +161,7 @@ const Listening: FC<ListeningProps> = ({ return ( <div className="flex h-full flex-col gap-4 rounded-xl bg-background-section p-8"> <div className="flex flex-row flex-wrap items-center gap-3"> - {iconsToRender.map(icon => ( + {iconsToRender.map((icon) => ( <BlockIcon key={icon.key} type={icon.type} @@ -171,52 +172,51 @@ const Listening: FC<ListeningProps> = ({ ))} </div> <div className="flex flex-col gap-1"> - <div className="system-sm-semibold text-text-secondary">{t($ => $['debug.variableInspect.listening.title'], { ns: 'workflow' })}</div> - <div className="system-xs-regular whitespace-pre-line text-text-tertiary">{description}</div> + <div className="system-sm-semibold text-text-secondary"> + {t(($) => $['debug.variableInspect.listening.title'], { ns: 'workflow' })} + </div> + <div className="system-xs-regular whitespace-pre-line text-text-tertiary"> + {description} + </div> </div> {webhookDebugUrl && ( <div className="flex items-center gap-2"> <div className="shrink-0 system-xs-regular whitespace-pre-line text-text-tertiary"> - {t($ => $['nodes.triggerWebhook.debugUrlTitle'], { ns: 'workflow' })} + {t(($) => $['nodes.triggerWebhook.debugUrlTitle'], { ns: 'workflow' })} </div> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['nodes.triggerWebhook.debugUrlCopy'], { ns: 'workflow' }) || ''} + aria-label={ + t(($) => $['nodes.triggerWebhook.debugUrlCopy'], { ns: 'workflow' }) || '' + } className={`inline-flex items-center rounded-md border border-divider-regular bg-components-badge-white-to-dark px-1.5 py-[2px] font-mono text-[13px] leading-[18px] text-text-secondary transition-colors hover:bg-components-panel-on-panel-item-bg-hover focus:outline-hidden focus-visible:outline-2 focus-visible:outline-components-panel-border focus-visible:outline-solid ${debugUrlCopied ? 'bg-components-panel-on-panel-item-bg-hover text-text-primary' : ''}`} onClick={() => { copy(webhookDebugUrl) setDebugUrlCopied(true) }} > - <span className="whitespace-nowrap text-text-primary"> - {webhookDebugUrl} - </span> + <span className="whitespace-nowrap text-text-primary">{webhookDebugUrl}</span> </button> - )} + } /> <TooltipContent placement="top" className="rounded-md border border-components-panel-border bg-components-tooltip-bg px-1.5 py-1 system-xs-regular text-text-primary shadow-lg backdrop-blur-xs" > {debugUrlCopied - ? t($ => $['nodes.triggerWebhook.debugUrlCopied'], { ns: 'workflow' }) - : t($ => $['nodes.triggerWebhook.debugUrlCopy'], { ns: 'workflow' })} + ? t(($) => $['nodes.triggerWebhook.debugUrlCopied'], { ns: 'workflow' }) + : t(($) => $['nodes.triggerWebhook.debugUrlCopy'], { ns: 'workflow' })} </TooltipContent> </Tooltip> </div> )} <div> - <Button - size="medium" - className="px-3" - variant="primary" - onClick={onStop} - > + <Button size="medium" className="px-3" variant="primary" onClick={onStop}> <StopCircle className="mr-1 size-4" /> - {t($ => $['debug.variableInspect.listening.stopButton'], { ns: 'workflow' })} + {t(($) => $['debug.variableInspect.listening.stopButton'], { ns: 'workflow' })} </Button> </div> </div> diff --git a/web/app/components/workflow/variable-inspect/panel.tsx b/web/app/components/workflow/variable-inspect/panel.tsx index 90efe3661c2a87..08999675ba6038 100644 --- a/web/app/components/workflow/variable-inspect/panel.tsx +++ b/web/app/components/workflow/variable-inspect/panel.tsx @@ -2,9 +2,7 @@ import type { FC } from 'react' import type { NodeProps } from '../types' import type { VarInInspect } from '@/types/workflow' import { cn } from '@langgenius/dify-ui/cn' -import { - RiCloseLine, -} from '@remixicon/react' +import { RiCloseLine } from '@remixicon/react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' @@ -13,7 +11,6 @@ import { useEventEmitterContextContext } from '@/context/event-emitter' import { VarInInspectType } from '@/types/workflow' import useCurrentVars from '../hooks/use-inspect-vars-crud' import useMatchSchemaType from '../nodes/_base/components/variable/use-match-schema-type' - import { useStore } from '../store' import Empty from './empty' import Left from './left' @@ -32,33 +29,33 @@ export type currentVarType = { const Panel: FC = () => { const { t } = useTranslation() - const bottomPanelWidth = useStore(s => s.bottomPanelWidth) - const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel) + const bottomPanelWidth = useStore((s) => s.bottomPanelWidth) + const setShowVariableInspectPanel = useStore((s) => s.setShowVariableInspectPanel) const [showLeftPanel, setShowLeftPanel] = useState(true) - const isListening = useStore(s => s.isListening) + const isListening = useStore((s) => s.isListening) - const environmentVariables = useStore(s => s.environmentVariables) - const currentFocusNodeId = useStore(s => s.currentFocusNodeId) - const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId) + const environmentVariables = useStore((s) => s.environmentVariables) + const currentFocusNodeId = useStore((s) => s.currentFocusNodeId) + const setCurrentFocusNodeId = useStore((s) => s.setCurrentFocusNodeId) const [currentVarId, setCurrentVarId] = useState('') - const { - conversationVars, - systemVars, - nodesWithInspectVars, - fetchInspectVarValue, - } = useCurrentVars() + const { conversationVars, systemVars, nodesWithInspectVars, fetchInspectVarValue } = + useCurrentVars() const isEmpty = useMemo(() => { - const allVars = [...environmentVariables, ...conversationVars, ...systemVars, ...nodesWithInspectVars] + const allVars = [ + ...environmentVariables, + ...conversationVars, + ...systemVars, + ...nodesWithInspectVars, + ] return allVars.length === 0 }, [environmentVariables, conversationVars, systemVars, nodesWithInspectVars]) const currentNodeInfo = useMemo(() => { - if (!currentFocusNodeId) - return + if (!currentFocusNodeId) return if (currentFocusNodeId === VarInInspectType.environment) { - const currentVar = environmentVariables.find(v => v.id === currentVarId) + const currentVar = environmentVariables.find((v) => v.id === currentVarId) const res = { nodeId: VarInInspectType.environment, title: VarInInspectType.environment, @@ -78,7 +75,7 @@ const Panel: FC = () => { return res } if (currentFocusNodeId === VarInInspectType.conversation) { - const currentVar = conversationVars.find(v => v.id === currentVarId) + const currentVar = conversationVars.find((v) => v.id === currentVarId) const res = { nodeId: VarInInspectType.conversation, title: VarInInspectType.conversation, @@ -96,7 +93,7 @@ const Panel: FC = () => { return res } if (currentFocusNodeId === VarInInspectType.system) { - const currentVar = systemVars.find(v => v.id === currentVarId) + const currentVar = systemVars.find((v) => v.id === currentVarId) const res = { nodeId: VarInInspectType.system, title: VarInInspectType.system, @@ -113,10 +110,9 @@ const Panel: FC = () => { } return res } - const targetNode = nodesWithInspectVars.find(node => node.nodeId === currentFocusNodeId) - if (!targetNode) - return - const currentVar = targetNode.vars.find(v => v.id === currentVarId) + const targetNode = nodesWithInspectVars.find((node) => node.nodeId === currentFocusNodeId) + if (!targetNode) return + const currentVar = targetNode.vars.find((v) => v.id === currentVarId) return { nodeId: targetNode.nodeId, nodeType: targetNode.nodeType, @@ -126,21 +122,29 @@ const Panel: FC = () => { nodeData: targetNode.nodePayload, ...(currentVar ? { var: currentVar } : {}), } - }, [currentFocusNodeId, currentVarId, environmentVariables, conversationVars, systemVars, nodesWithInspectVars]) + }, [ + currentFocusNodeId, + currentVarId, + environmentVariables, + conversationVars, + systemVars, + nodesWithInspectVars, + ]) const isCurrentNodeVarValueFetching = useMemo(() => { - if (!currentNodeInfo) - return false - const targetNode = nodesWithInspectVars.find(node => node.nodeId === currentNodeInfo.nodeId) - if (!targetNode) - return false + if (!currentNodeInfo) return false + const targetNode = nodesWithInspectVars.find((node) => node.nodeId === currentNodeInfo.nodeId) + if (!targetNode) return false return !targetNode.isValueFetched }, [currentNodeInfo, nodesWithInspectVars]) - const handleNodeVarSelect = useCallback((node: currentVarType) => { - setCurrentFocusNodeId(node.nodeId) - setCurrentVarId(node.var.id) - }, [setCurrentFocusNodeId, setCurrentVarId]) + const handleNodeVarSelect = useCallback( + (node: currentVarType) => { + setCurrentFocusNodeId(node.nodeId) + setCurrentVarId(node.var.id) + }, + [setCurrentFocusNodeId, setCurrentVarId], + ) const { isLoading, schemaTypeDefinitions } = useMatchSchemaType() const { eventEmitter } = useEventEmitterContextContext() @@ -151,25 +155,32 @@ const Panel: FC = () => { useEffect(() => { if (currentFocusNodeId && currentVarId && !isLoading) { - const targetNode = nodesWithInspectVars.find(node => node.nodeId === currentFocusNodeId) + const targetNode = nodesWithInspectVars.find((node) => node.nodeId === currentFocusNodeId) if (targetNode && !targetNode.isValueFetched) fetchInspectVarValue([currentFocusNodeId], schemaTypeDefinitions!) } - }, [currentFocusNodeId, currentVarId, nodesWithInspectVars, fetchInspectVarValue, schemaTypeDefinitions, isLoading]) + }, [ + currentFocusNodeId, + currentVarId, + nodesWithInspectVars, + fetchInspectVarValue, + schemaTypeDefinitions, + isLoading, + ]) if (isListening) { return ( <div className={cn('flex h-full flex-col')}> <div className="flex shrink-0 items-center justify-between pt-2 pr-2 pl-4"> - <div className="system-sm-semibold-uppercase text-text-primary">{t($ => $['debug.variableInspect.title'], { ns: 'workflow' })}</div> + <div className="system-sm-semibold-uppercase text-text-primary"> + {t(($) => $['debug.variableInspect.title'], { ns: 'workflow' })} + </div> <ActionButton onClick={() => setShowVariableInspectPanel(false)}> <RiCloseLine className="size-4" /> </ActionButton> </div> <div className="grow p-2"> - <Listening - onStop={handleStopListening} - /> + <Listening onStop={handleStopListening} /> </div> </div> ) @@ -179,7 +190,9 @@ const Panel: FC = () => { return ( <div className={cn('flex h-full flex-col')}> <div className="flex shrink-0 items-center justify-between pt-2 pr-2 pl-4"> - <div className="system-sm-semibold-uppercase text-text-primary">{t($ => $['debug.variableInspect.title'], { ns: 'workflow' })}</div> + <div className="system-sm-semibold-uppercase text-text-primary"> + {t(($) => $['debug.variableInspect.title'], { ns: 'workflow' })} + </div> <ActionButton onClick={() => setShowVariableInspectPanel(false)}> <RiCloseLine className="size-4" /> </ActionButton> @@ -194,7 +207,12 @@ const Panel: FC = () => { return ( <div className={cn('relative flex h-full')}> {/* left */} - {bottomPanelWidth < 488 && showLeftPanel && <div className="absolute top-0 left-0 size-full" onClick={() => setShowLeftPanel(false)}></div>} + {bottomPanelWidth < 488 && showLeftPanel && ( + <div + className="absolute top-0 left-0 size-full" + onClick={() => setShowLeftPanel(false)} + ></div> + )} <div className={cn( 'w-60 shrink-0 border-r border-divider-burn', diff --git a/web/app/components/workflow/variable-inspect/right.tsx b/web/app/components/workflow/variable-inspect/right.tsx index 20d681ccc4d27c..2e07a1cc9106ee 100644 --- a/web/app/components/workflow/variable-inspect/right.tsx +++ b/web/app/components/workflow/variable-inspect/right.tsx @@ -43,35 +43,24 @@ type Props = Readonly<{ isValueFetching?: boolean }> -const Right = ({ - nodeId, - currentNodeVar, - handleOpenMenu, - isValueFetching, -}: Props) => { +const Right = ({ nodeId, currentNodeVar, handleOpenMenu, isValueFetching }: Props) => { const { t } = useTranslation() - const bottomPanelWidth = useStore(s => s.bottomPanelWidth) - const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel) - const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId) + const bottomPanelWidth = useStore((s) => s.bottomPanelWidth) + const setShowVariableInspectPanel = useStore((s) => s.setShowVariableInspectPanel) + const setCurrentFocusNodeId = useStore((s) => s.setCurrentFocusNodeId) const toolIcon = useToolIcon(currentNodeVar?.nodeData) const isTruncated = currentNodeVar?.var.is_truncated const fullContent = currentNodeVar?.var.full_content - const { - resetConversationVar, - resetToLastRunVar, - editInspectVarValue, - } = useCurrentVars() + const { resetConversationVar, resetToLastRunVar, editInspectVarValue } = useCurrentVars() const handleValueChange = (varId: string, value: any) => { - if (!currentNodeVar) - return + if (!currentNodeVar) return editInspectVarValue(currentNodeVar.nodeId, varId, value) } const resetValue = () => { - if (!currentNodeVar) - return + if (!currentNodeVar) return resetToLastRunVar(currentNodeVar.nodeId, currentNodeVar.var.id) } @@ -81,23 +70,20 @@ const Right = ({ } const handleClear = () => { - if (!currentNodeVar) - return + if (!currentNodeVar) return resetConversationVar(currentNodeVar.var.id) } const getCopyContent = () => { const value = currentNodeVar?.var.value - if (value === null || value === undefined) - return '' + if (value === null || value === undefined) return '' - if (typeof value === 'object') - return JSON.stringify(value) + if (typeof value === 'object') return JSON.stringify(value) return String(value) } - const configsMap = useHooksStore(s => s.configsMap) + const configsMap = useHooksStore((s) => s.configsMap) const { eventEmitter } = useEventEmitterContextContext() const { handleNodeSelect } = useNodesInteractions() const { node } = useNodeInfo(nodeId) @@ -106,51 +92,52 @@ const Right = ({ const isCodeBlock = blockType === BlockEnum.Code const canShowPromptGenerator = [BlockEnum.LLM, BlockEnum.Code].includes(blockType) const currentPrompt = useMemo(() => { - if (!canShowPromptGenerator) - return '' + if (!canShowPromptGenerator) return '' if (blockType === BlockEnum.LLM) return node?.data?.prompt_template?.text || node?.data?.prompt_template?.[0].text - if (blockType === BlockEnum.Code) - return node?.data?.code + if (blockType === BlockEnum.Code) return node?.data?.code }, [canShowPromptGenerator]) - const [isShowPromptGenerator, { - setTrue: doShowPromptGenerator, - setFalse: handleHidePromptGenerator, - }] = useBoolean(false) + const [ + isShowPromptGenerator, + { setTrue: doShowPromptGenerator, setFalse: handleHidePromptGenerator }, + ] = useBoolean(false) const handleShowPromptGenerator = useCallback(() => { handleNodeSelect(nodeId) doShowPromptGenerator() }, [doShowPromptGenerator, handleNodeSelect, nodeId]) - const handleUpdatePrompt = useCallback((res: GenRes) => { - const newInputs = produce(node?.data, (draft: any) => { - switch (blockType) { - case BlockEnum.LLM: - if (draft?.prompt_template) { - if (Array.isArray(draft.prompt_template)) - draft.prompt_template[0].text = res.modified - else - draft.prompt_template.text = res.modified - } - break + const handleUpdatePrompt = useCallback( + (res: GenRes) => { + const newInputs = produce(node?.data, (draft: any) => { + switch (blockType) { + case BlockEnum.LLM: + if (draft?.prompt_template) { + if (Array.isArray(draft.prompt_template)) draft.prompt_template[0].text = res.modified + else draft.prompt_template.text = res.modified + } + break - case BlockEnum.Code: - draft.code = res.modified - break - } - }) - setInputs(newInputs) - eventEmitter?.emit({ - type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, - instanceId: `${nodeId}-chat-workflow-llm-prompt-editor`, - payload: res.modified, - } as any) - handleHidePromptGenerator() - }, [setInputs, blockType, nodeId, node?.data, handleHidePromptGenerator]) + case BlockEnum.Code: + draft.code = res.modified + break + } + }) + setInputs(newInputs) + eventEmitter?.emit({ + type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, + instanceId: `${nodeId}-chat-workflow-llm-prompt-editor`, + payload: res.modified, + } as any) + handleHidePromptGenerator() + }, + [setInputs, blockType, nodeId, node?.data, handleHidePromptGenerator], + ) - const displaySchemaType = currentNodeVar?.var.schemaType ? (`(${currentNodeVar.var.schemaType})`) : '' + const displaySchemaType = currentNodeVar?.var.schemaType + ? `(${currentNodeVar.var.schemaType})` + : '' return ( <div className={cn('flex h-full flex-col')}> @@ -164,18 +151,21 @@ const Right = ({ <div className="flex w-0 grow items-center gap-1"> {currentNodeVar?.var && ( <> - { - ([VarInInspectType.environment, VarInInspectType.conversation, VarInInspectType.system] as VarInInspectType[]).includes(currentNodeVar.nodeType as VarInInspectType) && ( - <VariableIconWithColor - variableCategory={currentNodeVar.nodeType as VarInInspectType} - className="size-4" - /> - ) - } - {currentNodeVar.nodeType !== VarInInspectType.environment - && currentNodeVar.nodeType !== VarInInspectType.conversation - && currentNodeVar.nodeType !== VarInInspectType.system - && ( + {( + [ + VarInInspectType.environment, + VarInInspectType.conversation, + VarInInspectType.system, + ] as VarInInspectType[] + ).includes(currentNodeVar.nodeType as VarInInspectType) && ( + <VariableIconWithColor + variableCategory={currentNodeVar.nodeType as VarInInspectType} + className="size-4" + /> + )} + {currentNodeVar.nodeType !== VarInInspectType.environment && + currentNodeVar.nodeType !== VarInInspectType.conversation && + currentNodeVar.nodeType !== VarInInspectType.system && ( <> <BlockIcon className="shrink-0" @@ -183,11 +173,18 @@ const Right = ({ size="xs" toolIcon={toolIcon} /> - <div className="shrink-0 system-sm-regular text-text-secondary">{currentNodeVar.title}</div> + <div className="shrink-0 system-sm-regular text-text-secondary"> + {currentNodeVar.title} + </div> <div className="shrink-0 system-sm-regular text-text-quaternary">/</div> </> )} - <div title={currentNodeVar.var.name} className="truncate system-sm-semibold text-text-secondary">{currentNodeVar.var.name}</div> + <div + title={currentNodeVar.var.name} + className="truncate system-sm-semibold text-text-secondary" + > + {currentNodeVar.var.name} + </div> <div className="ml-1 shrink-0 space-x-2 system-xs-medium text-text-tertiary"> <span>{`${currentNodeVar.var.value_type}${displaySchemaType}`}</span> {isTruncated && ( @@ -200,7 +197,6 @@ const Right = ({ </> )} </div> - </> )} </div> @@ -210,24 +206,24 @@ const Right = ({ {canShowPromptGenerator && ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="cursor-pointer rounded-md p-1 hover:bg-state-accent-active" onClick={handleShowPromptGenerator} > <RiSparklingFill className="size-4 text-components-input-border-active-prompt-1" /> </div> - )} + } /> <TooltipContent> - {t($ => $['generate.optimizePromptTooltip'], { ns: 'appDebug' })} + {t(($) => $['generate.optimizePromptTooltip'], { ns: 'appDebug' })} </TooltipContent> </Tooltip> )} {isTruncated && ( <Tooltip> <TooltipTrigger - render={( + render={ <ActionButton> <a href={fullContent?.download_url} @@ -237,47 +233,55 @@ const Right = ({ <RiFileDownloadFill className="size-4" /> </a> </ActionButton> - )} + } /> <TooltipContent> - {t($ => $['debug.variableInspect.exportToolTip'], { ns: 'workflow' })} + {t(($) => $['debug.variableInspect.exportToolTip'], { ns: 'workflow' })} </TooltipContent> </Tooltip> )} {!isTruncated && currentNodeVar.var.edited && ( <Badge> <span className="mr-[4.5px] ml-[2.5px] h-[3px] w-[3px] rounded-sm bg-text-accent-secondary"></span> - <span className="system-2xs-semibold-uupercase">{t($ => $['debug.variableInspect.edited'], { ns: 'workflow' })}</span> + <span className="system-2xs-semibold-uupercase"> + {t(($) => $['debug.variableInspect.edited'], { ns: 'workflow' })} + </span> </Badge> )} - {!isTruncated && currentNodeVar.var.edited && currentNodeVar.var.type !== VarInInspectType.conversation && ( - <Tooltip> - <TooltipTrigger - render={( - <ActionButton onClick={resetValue}> - <RiArrowGoBackLine className="size-4" /> - </ActionButton> - )} - /> - <TooltipContent> - {t($ => $['debug.variableInspect.reset'], { ns: 'workflow' })} - </TooltipContent> - </Tooltip> - )} - {!isTruncated && currentNodeVar.var.edited && currentNodeVar.var.type === VarInInspectType.conversation && ( - <Tooltip> - <TooltipTrigger - render={( - <ActionButton onClick={handleClear}> - <RiArrowGoBackLine className="size-4" /> - </ActionButton> - )} - /> - <TooltipContent> - {t($ => $['debug.variableInspect.resetConversationVar'], { ns: 'workflow' })} - </TooltipContent> - </Tooltip> - )} + {!isTruncated && + currentNodeVar.var.edited && + currentNodeVar.var.type !== VarInInspectType.conversation && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton onClick={resetValue}> + <RiArrowGoBackLine className="size-4" /> + </ActionButton> + } + /> + <TooltipContent> + {t(($) => $['debug.variableInspect.reset'], { ns: 'workflow' })} + </TooltipContent> + </Tooltip> + )} + {!isTruncated && + currentNodeVar.var.edited && + currentNodeVar.var.type === VarInInspectType.conversation && ( + <Tooltip> + <TooltipTrigger + render={ + <ActionButton onClick={handleClear}> + <RiArrowGoBackLine className="size-4" /> + </ActionButton> + } + /> + <TooltipContent> + {t(($) => $['debug.variableInspect.resetConversationVar'], { + ns: 'workflow', + })} + </TooltipContent> + </Tooltip> + )} {currentNodeVar.var.value_type !== 'secret' && ( <CopyFeedback content={getCopyContent()} /> )} @@ -305,32 +309,29 @@ const Right = ({ /> )} </div> - {isShowPromptGenerator && ( - isCodeBlock - ? ( - <GetCodeGeneratorResModal - isShow - mode={AppModeEnum.CHAT} - onClose={handleHidePromptGenerator} - flowId={configsMap?.flowId || ''} - nodeId={nodeId} - currentCode={currentPrompt} - codeLanguages={node?.data?.code_languages || CodeLanguage.python3} - onFinished={handleUpdatePrompt} - /> - ) - : ( - <GetAutomaticResModal - mode={AppModeEnum.CHAT} - isShow - onClose={handleHidePromptGenerator} - onFinished={handleUpdatePrompt} - flowId={configsMap?.flowId || ''} - nodeId={nodeId} - currentPrompt={currentPrompt} - /> - ) - )} + {isShowPromptGenerator && + (isCodeBlock ? ( + <GetCodeGeneratorResModal + isShow + mode={AppModeEnum.CHAT} + onClose={handleHidePromptGenerator} + flowId={configsMap?.flowId || ''} + nodeId={nodeId} + currentCode={currentPrompt} + codeLanguages={node?.data?.code_languages || CodeLanguage.python3} + onFinished={handleUpdatePrompt} + /> + ) : ( + <GetAutomaticResModal + mode={AppModeEnum.CHAT} + isShow + onClose={handleHidePromptGenerator} + onFinished={handleUpdatePrompt} + flowId={configsMap?.flowId || ''} + nodeId={nodeId} + currentPrompt={currentPrompt} + /> + ))} </div> ) } diff --git a/web/app/components/workflow/variable-inspect/trigger.tsx b/web/app/components/workflow/variable-inspect/trigger.tsx index b0084816464bf9..bc1f21c666ea43 100644 --- a/web/app/components/workflow/variable-inspect/trigger.tsx +++ b/web/app/components/workflow/variable-inspect/trigger.tsx @@ -17,34 +17,37 @@ const VariableInspectTrigger: FC = () => { const { t } = useTranslation() const { eventEmitter } = useEventEmitterContextContext() - const showVariableInspectPanel = useStore(s => s.showVariableInspectPanel) - const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel) + const showVariableInspectPanel = useStore((s) => s.showVariableInspectPanel) + const setShowVariableInspectPanel = useStore((s) => s.setShowVariableInspectPanel) - const environmentVariables = useStore(s => s.environmentVariables) - const setCurrentFocusNodeId = useStore(s => s.setCurrentFocusNodeId) - const { - conversationVars, - systemVars, - nodesWithInspectVars, - deleteAllInspectorVars, - } = useCurrentVars() + const environmentVariables = useStore((s) => s.environmentVariables) + const setCurrentFocusNodeId = useStore((s) => s.setCurrentFocusNodeId) + const { conversationVars, systemVars, nodesWithInspectVars, deleteAllInspectorVars } = + useCurrentVars() const currentVars = useMemo(() => { - const allVars = [...environmentVariables, ...conversationVars, ...systemVars, ...nodesWithInspectVars] + const allVars = [ + ...environmentVariables, + ...conversationVars, + ...systemVars, + ...nodesWithInspectVars, + ] return allVars }, [environmentVariables, conversationVars, systemVars, nodesWithInspectVars]) - const { - nodesReadOnly, - getNodesReadOnly, - } = useNodesReadOnly() - const workflowRunningData = useStore(s => s.workflowRunningData) + const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly() + const workflowRunningData = useStore((s) => s.workflowRunningData) const nodes = useNodes<CommonNodeType>() - const isStepRunning = useMemo(() => nodes.some(node => node.data._singleRunningStatus === NodeRunningStatus.Running), [nodes]) + const isStepRunning = useMemo( + () => nodes.some((node) => node.data._singleRunningStatus === NodeRunningStatus.Running), + [nodes], + ) const isPreviewRunning = useMemo(() => { - if (!workflowRunningData) - return false + if (!workflowRunningData) return false return workflowRunningData.result.status === WorkflowRunningStatus.Running }, [workflowRunningData]) - const isRunning = useMemo(() => isPreviewRunning || isStepRunning, [isPreviewRunning, isStepRunning]) + const isRunning = useMemo( + () => isPreviewRunning || isStepRunning, + [isPreviewRunning, isStepRunning], + ) const handleStop = () => { eventEmitter?.emit({ @@ -57,44 +60,52 @@ const VariableInspectTrigger: FC = () => { setCurrentFocusNodeId('') } - if (showVariableInspectPanel) - return null + if (showVariableInspectPanel) return null return ( <div className={cn('flex shrink-0 flex-nowrap items-center gap-1 whitespace-nowrap')}> {!isRunning && !currentVars.length && ( <div - className={cn('flex h-5 shrink-0 cursor-pointer items-center gap-1 rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-2 system-2xs-semibold-uppercase text-text-tertiary shadow-lg backdrop-blur-xs hover:bg-background-default-hover', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')} + className={cn( + 'flex h-5 shrink-0 cursor-pointer items-center gap-1 rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-2 system-2xs-semibold-uppercase text-text-tertiary shadow-lg backdrop-blur-xs hover:bg-background-default-hover', + nodesReadOnly && + 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled', + )} onClick={() => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return setShowVariableInspectPanel(true) }} > - {t($ => $['debug.variableInspect.trigger.normal'], { ns: 'workflow' })} + {t(($) => $['debug.variableInspect.trigger.normal'], { ns: 'workflow' })} </div> )} {!isRunning && currentVars.length > 0 && ( <> <div - className={cn('flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-2 system-xs-medium text-text-accent shadow-lg backdrop-blur-xs hover:bg-components-actionbar-bg-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')} + className={cn( + 'flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-2 system-xs-medium text-text-accent shadow-lg backdrop-blur-xs hover:bg-components-actionbar-bg-accent', + nodesReadOnly && + 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled', + )} onClick={() => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return setShowVariableInspectPanel(true) }} > - {t($ => $['debug.variableInspect.trigger.cached'], { ns: 'workflow' })} + {t(($) => $['debug.variableInspect.trigger.cached'], { ns: 'workflow' })} </div> <div - className={cn('flex h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-1 system-xs-medium text-text-tertiary shadow-lg backdrop-blur-xs hover:bg-components-actionbar-bg-accent hover:text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')} + className={cn( + 'flex h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-1 system-xs-medium text-text-tertiary shadow-lg backdrop-blur-xs hover:bg-components-actionbar-bg-accent hover:text-text-accent', + nodesReadOnly && + 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled', + )} onClick={() => { - if (getNodesReadOnly()) - return + if (getNodesReadOnly()) return handleClearAll() }} > - {t($ => $['debug.variableInspect.trigger.clear'], { ns: 'workflow' })} + {t(($) => $['debug.variableInspect.trigger.clear'], { ns: 'workflow' })} </div> </> )} @@ -105,22 +116,24 @@ const VariableInspectTrigger: FC = () => { onClick={() => setShowVariableInspectPanel(true)} > <RiLoader2Line className="size-4 shrink-0 animate-spin" /> - <span className="text-text-accent">{t($ => $['debug.variableInspect.trigger.running'], { ns: 'workflow' })}</span> + <span className="text-text-accent"> + {t(($) => $['debug.variableInspect.trigger.running'], { ns: 'workflow' })} + </span> </div> {isPreviewRunning && ( <Tooltip> <TooltipTrigger - render={( + render={ <div className="flex h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-1 shadow-lg backdrop-blur-xs hover:bg-components-actionbar-bg-accent" onClick={handleStop} > <RiStopCircleFill className="size-4 shrink-0 text-text-accent" /> </div> - )} + } /> <TooltipContent> - {t($ => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' })} + {t(($) => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' })} </TooltipContent> </Tooltip> )} diff --git a/web/app/components/workflow/variable-inspect/types.ts b/web/app/components/workflow/variable-inspect/types.ts index 47d525eb31f108..12e5d758c6670f 100644 --- a/web/app/components/workflow/variable-inspect/types.ts +++ b/web/app/components/workflow/variable-inspect/types.ts @@ -6,10 +6,10 @@ export const ViewMode = { Code: 'code', Preview: 'preview', } as const -export type ViewMode = typeof ViewMode[keyof typeof ViewMode] +export type ViewMode = (typeof ViewMode)[keyof typeof ViewMode] export const PreviewType = { Markdown: 'markdown', Chunks: 'chunks', } as const -export type PreviewType = typeof PreviewType[keyof typeof PreviewType] +export type PreviewType = (typeof PreviewType)[keyof typeof PreviewType] diff --git a/web/app/components/workflow/variable-inspect/utils.tsx b/web/app/components/workflow/variable-inspect/utils.tsx index 16f06d1bb074d0..9f0721404fbde4 100644 --- a/web/app/components/workflow/variable-inspect/utils.tsx +++ b/web/app/components/workflow/variable-inspect/utils.tsx @@ -7,27 +7,25 @@ const arrayNumberSchemaParttern = z.array(z.number()) const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]) type Literal = z.infer<typeof literalSchema> type Json = Literal | { [key: string]: Json } | Json[] -const jsonSchema: z.ZodType<Json> = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(z.string(), jsonSchema)])) +const jsonSchema: z.ZodType<Json> = z.lazy(() => + z.union([literalSchema, z.array(jsonSchema), z.record(z.string(), jsonSchema)]), +) const arrayJsonSchema: z.ZodType<Json[]> = z.lazy(() => z.array(jsonSchema)) export const validateJSONSchema = (schema: any, type: string) => { if (type === 'array[string]') { const result = arrayStringSchemaParttern.safeParse(schema) return result - } - else if (type === 'array[number]') { + } else if (type === 'array[number]') { const result = arrayNumberSchemaParttern.safeParse(schema) return result - } - else if (type === 'object') { + } else if (type === 'object') { const result = jsonSchema.safeParse(schema) return result - } - else if (type === 'array[object]') { + } else if (type === 'array[object]') { const result = arrayJsonSchema.safeParse(schema) return result - } - else { + } else { return { success: true } as any } } diff --git a/web/app/components/workflow/variable-inspect/value-content-sections.tsx b/web/app/components/workflow/variable-inspect/value-content-sections.tsx index 23080fb8990db2..a9aa5851b0b727 100644 --- a/web/app/components/workflow/variable-inspect/value-content-sections.tsx +++ b/web/app/components/workflow/variable-inspect/value-content-sections.tsx @@ -36,27 +36,25 @@ export const TextEditorSection = ({ return ( <> {isTruncated && <LargeDataAlert className="absolute inset-x-3 top-1" />} - {currentVar.value_type === 'string' - ? ( - <DisplayContent - previewType={PreviewType.Markdown} - varType={currentVar.value_type} - mdString={typeof value === 'string' ? value : String(value ?? '')} - readonly={textEditorDisabled} - handleTextChange={onTextChange} - className={cn(isTruncated && 'pt-[36px]')} - /> - ) - : ( - <Textarea - aria-label={t($ => $['errorMsg.fields.variableValue'], { ns: 'workflow' })} - readOnly={textEditorDisabled} - disabled={textEditorDisabled || isTruncated} - className={cn('h-full', isTruncated && 'pt-[48px]')} - value={typeof value === 'number' ? value : String(value ?? '')} - onValueChange={value => onTextChange(value)} - /> - )} + {currentVar.value_type === 'string' ? ( + <DisplayContent + previewType={PreviewType.Markdown} + varType={currentVar.value_type} + mdString={typeof value === 'string' ? value : String(value ?? '')} + readonly={textEditorDisabled} + handleTextChange={onTextChange} + className={cn(isTruncated && 'pt-[36px]')} + /> + ) : ( + <Textarea + aria-label={t(($) => $['errorMsg.fields.variableValue'], { ns: 'workflow' })} + readOnly={textEditorDisabled} + disabled={textEditorDisabled || isTruncated} + className={cn('h-full', isTruncated && 'pt-[48px]')} + value={typeof value === 'number' ? value : String(value ?? '')} + onValueChange={(value) => onTextChange(value)} + /> + )} </> ) } @@ -66,10 +64,7 @@ type BoolArraySectionProps = { onChange: (nextValue: boolean[]) => void } -export const BoolArraySection = ({ - values, - onChange, -}: BoolArraySectionProps) => { +export const BoolArraySection = ({ values, onChange }: BoolArraySectionProps) => { return ( <div className="w-[295px] space-y-1"> {values.map((value, index) => ( @@ -165,7 +160,10 @@ export const FileEditorSection = ({ ...(FILE_EXTS[SupportUploadFileTypes.video] ?? []), ], allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], - number_limits: currentVar.value_type === 'file' ? 1 : fileUploadConfig?.workflow_file_upload_limit || 5, + number_limits: + currentVar.value_type === 'file' + ? 1 + : fileUploadConfig?.workflow_file_upload_limit || 5, fileUploadConfig, preview_config: { mode: PreviewMode.NewPage, diff --git a/web/app/components/workflow/variable-inspect/value-content.helpers.ts b/web/app/components/workflow/variable-inspect/value-content.helpers.ts index 2c6fa3f816ec9e..9319e791dda58f 100644 --- a/web/app/components/workflow/variable-inspect/value-content.helpers.ts +++ b/web/app/components/workflow/variable-inspect/value-content.helpers.ts @@ -15,13 +15,26 @@ type UploadedFileLike = { } export const getValueEditorState = (currentVar: VarInInspect) => { - const showTextEditor = currentVar.value_type === 'secret' || currentVar.value_type === 'string' || currentVar.value_type === 'number' + const showTextEditor = + currentVar.value_type === 'secret' || + currentVar.value_type === 'string' || + currentVar.value_type === 'number' const showBoolEditor = typeof currentVar.value === 'boolean' - const showBoolArrayEditor = Array.isArray(currentVar.value) && currentVar.value.every(v => typeof v === 'boolean') + const showBoolArrayEditor = + Array.isArray(currentVar.value) && currentVar.value.every((v) => typeof v === 'boolean') const isSysFiles = currentVar.type === VarInInspectType.system && currentVar.name === 'files' - const showJSONEditor = !isSysFiles && ['object', 'array[string]', 'array[number]', 'array[object]', 'array[any]'].includes(currentVar.value_type) - const showFileEditor = isSysFiles || currentVar.value_type === 'file' || currentVar.value_type === 'array[file]' - const textEditorDisabled = currentVar.type === VarInInspectType.environment || (currentVar.type === VarInInspectType.system && currentVar.name !== 'query' && currentVar.name !== 'files') + const showJSONEditor = + !isSysFiles && + ['object', 'array[string]', 'array[number]', 'array[object]', 'array[any]'].includes( + currentVar.value_type, + ) + const showFileEditor = + isSysFiles || currentVar.value_type === 'file' || currentVar.value_type === 'array[file]' + const textEditorDisabled = + currentVar.type === VarInInspectType.environment || + (currentVar.type === VarInInspectType.system && + currentVar.name !== 'query' && + currentVar.name !== 'files') const JSONEditorDisabled = currentVar.value_type === 'array[any]' const hasChunks = !!currentVar.schemaType && CHUNK_SCHEMA_TYPES.includes(currentVar.schemaType) @@ -41,8 +54,13 @@ export const getValueEditorState = (currentVar: VarInInspect) => { export const formatInspectFileValue = (currentVar: VarInInspect) => { if (currentVar.value_type === 'file') return currentVar.value ? getProcessedFilesFromResponse([currentVar.value]) : [] - if (currentVar.value_type === 'array[file]' || (currentVar.type === VarInInspectType.system && currentVar.name === 'files')) - return currentVar.value && currentVar.value.length > 0 ? getProcessedFilesFromResponse(currentVar.value) : [] + if ( + currentVar.value_type === 'array[file]' || + (currentVar.type === VarInInspectType.system && currentVar.name === 'files') + ) + return currentVar.value && currentVar.value.length > 0 + ? getProcessedFilesFromResponse(currentVar.value) + : [] return [] } @@ -56,16 +74,23 @@ export const validateInspectJsonValue = (value: string, type: string) => { if (type === 'object' || type === 'array[object]') { const schemaDepth = checkJsonSchemaDepth(newJSONSchema) if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) - return { success: false, validationError: `Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`, parseError: null } + return { + success: false, + validationError: `Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`, + parseError: null, + } const validationErrors = validateSchemaAgainstDraft7(newJSONSchema) if (validationErrors.length > 0) - return { success: false, validationError: getValidationErrorMessage(validationErrors), parseError: null } + return { + success: false, + validationError: getValidationErrorMessage(validationErrors), + parseError: null, + } } return { success: true, validationError: '', parseError: null } - } - catch (error) { + } catch (error) { return { success: false, validationError: '', @@ -74,4 +99,5 @@ export const validateInspectJsonValue = (value: string, type: string) => { } } -export const isFileValueUploaded = (fileList: UploadedFileLike[]) => fileList.every(file => file.upload_file_id) +export const isFileValueUploaded = (fileList: UploadedFileLike[]) => + fileList.every((file) => file.upload_file_id) diff --git a/web/app/components/workflow/variable-inspect/value-content.tsx b/web/app/components/workflow/variable-inspect/value-content.tsx index fddaef232fec32..dc99d8f0b00dc8 100644 --- a/web/app/components/workflow/variable-inspect/value-content.tsx +++ b/web/app/components/workflow/variable-inspect/value-content.tsx @@ -26,11 +26,7 @@ type Props = Readonly<{ isTruncated: boolean }> -const ValueContent = ({ - currentVar, - handleValueChange, - isTruncated, -}: Props) => { +const ValueContent = ({ currentVar, handleValueChange, isTruncated }: Props) => { const contentContainerRef = useRef<HTMLDivElement>(null) const errorMessageRef = useRef<HTMLDivElement>(null) const [editorHeight, setEditorHeight] = useState(0) @@ -45,7 +41,7 @@ const ValueContent = ({ JSONEditorDisabled, hasChunks, } = useMemo(() => getValueEditorState(currentVar), [currentVar]) - const fileUploadConfig = useStore(s => s.fileUploadConfig) + const fileUploadConfig = useStore((s) => s.fileUploadConfig) const [value, setValue] = useState<any>() const [json, setJson] = useState('') @@ -58,28 +54,22 @@ const ValueContent = ({ // update default value when id changed useEffect(() => { if (showTextEditor) { - if (currentVar.value_type === 'number') - return setValue(JSON.stringify(currentVar.value)) - if (!currentVar.value) - return setValue('') + if (currentVar.value_type === 'number') return setValue(JSON.stringify(currentVar.value)) + if (!currentVar.value) return setValue('') setValue(currentVar.value) } if (showJSONEditor) setJson(currentVar.value != null ? JSON.stringify(currentVar.value, null, 2) : '') - if (showFileEditor) - setFileValue(formatInspectFileValue(currentVar)) + if (showFileEditor) setFileValue(formatInspectFileValue(currentVar)) }, [currentVar.id, currentVar.value]) const handleTextChange = (value: string) => { - if (isTruncated) - return - if (currentVar.value_type === 'string') - setValue(value) + if (isTruncated) return + if (currentVar.value_type === 'string') setValue(value) if (currentVar.value_type === 'number') { - if (/^-?\d+(\.)?(\d+)?$/.test(value)) - setValue(Number.parseFloat(value)) + if (/^-?\d+(\.)?(\d+)?$/.test(value)) setValue(Number.parseFloat(value)) } const newValue = currentVar.value_type === 'number' ? Number.parseFloat(value) : value debounceValueChange(currentVar.id, newValue) @@ -93,8 +83,7 @@ const ValueContent = ({ } const handleEditorChange = (value: string) => { - if (isTruncated) - return + if (isTruncated) return setJson(value) if (jsonValueValidate(value, currentVar.value_type)) { const parsed = JSON.parse(value) @@ -106,10 +95,8 @@ const ValueContent = ({ setFileValue(value) // check every file upload progress // invoke update api after every file uploaded - if (!isFileValueUploaded(value)) - return - if (currentVar.value_type === 'file') - debounceValueChange(currentVar.id, value[0]) + if (!isFileValueUploaded(value)) return + if (currentVar.value_type === 'file') debounceValueChange(currentVar.id, value[0]) if (currentVar.value_type === 'array[file]' || isSysFiles) debounceValueChange(currentVar.id, value) } @@ -132,10 +119,7 @@ const ValueContent = ({ }, [setEditorHeight]) return ( - <div - ref={contentContainerRef} - className="flex h-full flex-col" - > + <div ref={contentContainerRef} className="flex h-full flex-col"> <div className={cn('relative grow')} style={{ height: `${editorHeight}px` }}> {showTextEditor && ( <TextEditorSection @@ -157,17 +141,15 @@ const ValueContent = ({ /> </div> )} - { - showBoolArrayEditor && ( - <BoolArraySection - values={currentVar.value as boolean[]} - onChange={(newArray) => { - setValue(newArray) - debounceValueChange(currentVar.id, newArray) - }} - /> - ) - } + {showBoolArrayEditor && ( + <BoolArraySection + values={currentVar.value as boolean[]} + onChange={(newArray) => { + setValue(newArray) + debounceValueChange(currentVar.id, newArray) + }} + /> + )} {showJSONEditor && ( <JsonEditorSection hasChunks={hasChunks} @@ -185,15 +167,12 @@ const ValueContent = ({ fileValue={fileValue} fileUploadConfig={fileUploadConfig} textEditorDisabled={textEditorDisabled} - onChange={files => handleFileChange(getProcessedFiles(files))} + onChange={(files) => handleFileChange(getProcessedFiles(files))} /> )} </div> <div ref={errorMessageRef} className="shrink-0"> - <ErrorMessages - parseError={parseError} - validationError={validationError} - /> + <ErrorMessages parseError={parseError} validationError={validationError} /> </div> </div> ) diff --git a/web/app/components/workflow/workflow-contextmenu.tsx b/web/app/components/workflow/workflow-contextmenu.tsx index 0eedac6128b1e9..6371a349ceaa8c 100644 --- a/web/app/components/workflow/workflow-contextmenu.tsx +++ b/web/app/components/workflow/workflow-contextmenu.tsx @@ -1,9 +1,6 @@ import type { ContextMenuActions } from '@langgenius/dify-ui/context-menu' import type { ReactNode } from 'react' -import { - ContextMenu, - ContextMenuTrigger, -} from '@langgenius/dify-ui/context-menu' +import { ContextMenu, ContextMenuTrigger } from '@langgenius/dify-ui/context-menu' import { useCallback, useRef } from 'react' import { EdgeContextmenu } from './edge-contextmenu' import { NodeContextmenu } from './node-contextmenu' @@ -11,11 +8,7 @@ import { PanelContextmenu } from './panel-contextmenu' import { SelectionContextmenu } from './selection-contextmenu' import { useWorkflowStore } from './store' -export function WorkflowContextmenu({ - children, -}: { - children: ReactNode -}) { +export function WorkflowContextmenu({ children }: { children: ReactNode }) { const workflowStore = useWorkflowStore() const actionsRef = useRef<ContextMenuActions | null>(null) @@ -31,13 +24,10 @@ export function WorkflowContextmenu({ <ContextMenu actionsRef={actionsRef} onOpenChangeComplete={(open) => { - if (!open) - clearContextMenuTarget() + if (!open) clearContextMenuTarget() }} > - <ContextMenuTrigger render={<div className="h-full w-full" />}> - {children} - </ContextMenuTrigger> + <ContextMenuTrigger render={<div className="h-full w-full" />}>{children}</ContextMenuTrigger> <PanelContextmenu onClose={closeContextMenu} /> <NodeContextmenu onClose={closeContextMenu} /> <EdgeContextmenu onClose={closeContextMenu} /> diff --git a/web/app/components/workflow/workflow-generator/__tests__/apply.spec.ts b/web/app/components/workflow/workflow-generator/__tests__/apply.spec.ts index b48d5b2f044a43..4b37570fe3ada6 100644 --- a/web/app/components/workflow/workflow-generator/__tests__/apply.spec.ts +++ b/web/app/components/workflow/workflow-generator/__tests__/apply.spec.ts @@ -1,6 +1,11 @@ import type { GeneratedGraph } from '../types' import { AppModeEnum } from '@/types/app' -import { applyToCurrentApp, applyToNewApp, WorkflowApplyHashCollisionError, WorkflowApplyOrphanError } from '../apply' +import { + applyToCurrentApp, + applyToNewApp, + WorkflowApplyHashCollisionError, + WorkflowApplyOrphanError, +} from '../apply' // Stub the service calls so each test can assert what was POSTed without // touching real fetch / next router state. @@ -21,7 +26,12 @@ vi.mock('@/service/workflow', () => ({ const makeGraph = (): GeneratedGraph => ({ nodes: [ - { id: 'node-1', type: 'custom', position: { x: 0, y: 0 }, data: { type: 'start', title: 'Start' } } as never, + { + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { type: 'start', title: 'Start' }, + } as never, ], edges: [], viewport: { x: 0, y: 0, zoom: 0.7 }, @@ -40,10 +50,12 @@ describe('applyToNewApp', () => { const graph = makeGraph() const result = await applyToNewApp({ mode: 'workflow', graph, instruction: 'Summarize a URL' }) - expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ - mode: AppModeEnum.WORKFLOW, - icon_type: 'emoji', - })) + expect(mockCreateApp).toHaveBeenCalledWith( + expect.objectContaining({ + mode: AppModeEnum.WORKFLOW, + icon_type: 'emoji', + }), + ) expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({ url: 'apps/new-app-1/workflows/draft', params: { @@ -67,16 +79,24 @@ describe('applyToNewApp', () => { instruction: 'A chat bot that answers questions', }) - expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ mode: AppModeEnum.ADVANCED_CHAT })) + expect(mockCreateApp).toHaveBeenCalledWith( + expect.objectContaining({ mode: AppModeEnum.ADVANCED_CHAT }), + ) expect(result.appMode).toBe(AppModeEnum.ADVANCED_CHAT) }) // The derived name keeps the user instruction recognisable in the apps list // — strip trailing punctuation and never produce an empty string. it('should derive a sensible app name from the instruction', async () => { - await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: ' Build a translator. ' }) + await applyToNewApp({ + mode: 'workflow', + graph: makeGraph(), + instruction: ' Build a translator. ', + }) - expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'Build a translator' })) + expect(mockCreateApp).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Build a translator' }), + ) }) // Instruction-only-of-punctuation must still produce a usable, non-empty @@ -84,7 +104,9 @@ describe('applyToNewApp', () => { it('should fall back to "Generated Workflow" when the instruction is empty', async () => { await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: ' ' }) - expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'Generated Workflow' })) + expect(mockCreateApp).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Generated Workflow' }), + ) }) // When the planner picks a name + emoji, those win over the @@ -99,10 +121,12 @@ describe('applyToNewApp', () => { icon: '📰', }) - expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ - name: 'URL Summarizer', - icon: '📰', - })) + expect(mockCreateApp).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'URL Summarizer', + icon: '📰', + }), + ) }) // When the planner returns whitespace-only values (older prompts / model @@ -117,10 +141,12 @@ describe('applyToNewApp', () => { icon: '', }) - expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ - name: 'Summarize a URL', - icon: '🤖', - })) + expect(mockCreateApp).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Summarize a URL', + icon: '🤖', + }), + ) }) // Sync failure must roll back the createApp so the user isn't left with an @@ -132,11 +158,13 @@ describe('applyToNewApp', () => { mockSyncWorkflowDraft.mockRejectedValueOnce(syncErr) mockDeleteApp.mockResolvedValueOnce(undefined) - await expect(applyToNewApp({ - mode: 'workflow', - graph: makeGraph(), - instruction: 'x', - })).rejects.toBe(syncErr) + await expect( + applyToNewApp({ + mode: 'workflow', + graph: makeGraph(), + instruction: 'x', + }), + ).rejects.toBe(syncErr) expect(mockDeleteApp).toHaveBeenCalledWith('doomed') }) @@ -153,8 +181,7 @@ describe('applyToNewApp', () => { let caught: unknown try { await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: 'x' }) - } - catch (e) { + } catch (e) { caught = e } expect(caught).toBeInstanceOf(WorkflowApplyOrphanError) @@ -240,9 +267,9 @@ describe('applyToCurrentApp', () => { // ``status`` field, which is what ``isHashCollisionResponse`` consults. mockSyncWorkflowDraft.mockRejectedValueOnce({ status: 409, code: 'draft_workflow_not_sync' }) - await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() })) - .rejects - .toBeInstanceOf(WorkflowApplyHashCollisionError) + await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() })).rejects.toBeInstanceOf( + WorkflowApplyHashCollisionError, + ) }) // Non-409 errors (5xx, network) MUST NOT be misclassified as hash @@ -258,9 +285,7 @@ describe('applyToCurrentApp', () => { const original = { status: 500, code: 'internal_server_error' } mockSyncWorkflowDraft.mockRejectedValueOnce(original) - await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() })) - .rejects - .toBe(original) + await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() })).rejects.toBe(original) }) it('should NOT translate string or null sync rejections', async () => { @@ -274,14 +299,10 @@ describe('applyToCurrentApp', () => { // String error const strError = 'some string error' mockSyncWorkflowDraft.mockRejectedValueOnce(strError) - await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() })) - .rejects - .toBe(strError) + await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() })).rejects.toBe(strError) // Null error mockSyncWorkflowDraft.mockRejectedValueOnce(null) - await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() })) - .rejects - .toBeNull() + await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() })).rejects.toBeNull() }) }) diff --git a/web/app/components/workflow/workflow-generator/__tests__/example-prompts.spec.tsx b/web/app/components/workflow/workflow-generator/__tests__/example-prompts.spec.tsx index 8c2c2642fb6451..f54f3e95410ec9 100644 --- a/web/app/components/workflow/workflow-generator/__tests__/example-prompts.spec.tsx +++ b/web/app/components/workflow/workflow-generator/__tests__/example-prompts.spec.tsx @@ -45,7 +45,9 @@ describe('ExamplePrompts', () => { expect(await screen.findByRole('button', { name: 'Build a triage bot' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Summarize PDFs' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i }), + ).not.toBeInTheDocument() }) // Empty generation (no default model / quota) must silently fall back to the @@ -54,10 +56,20 @@ describe('ExamplePrompts', () => { mockFetch.mockResolvedValue({ suggestions: [] }) render(<ExamplePrompts mode="workflow" onSelect={vi.fn()} />) - expect(await screen.findByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.translate/i })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.rag/i })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.classify/i })).toBeInTheDocument() + expect( + await screen.findByRole('button', { + name: /workflowGenerator\.examples\.workflow\.summarize/i, + }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.translate/i }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.rag/i }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.classify/i }), + ).toBeInTheDocument() }) // Advanced-chat mode falls back to a different curated set with no workflow leakage. @@ -65,8 +77,14 @@ describe('ExamplePrompts', () => { mockFetch.mockResolvedValue({ suggestions: [] }) render(<ExamplePrompts mode="advanced-chat" onSelect={vi.fn()} />) - expect(await screen.findByRole('button', { name: /workflowGenerator\.examples\.chatflow\.support/i })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })).not.toBeInTheDocument() + expect( + await screen.findByRole('button', { + name: /workflowGenerator\.examples\.chatflow\.support/i, + }), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i }), + ).not.toBeInTheDocument() }) // A failed request must not toast or blow up — it degrades to the static list. @@ -74,7 +92,11 @@ describe('ExamplePrompts', () => { mockFetch.mockRejectedValue(new Error('boom')) render(<ExamplePrompts mode="workflow" onSelect={vi.fn()} />) - expect(await screen.findByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })).toBeInTheDocument() + expect( + await screen.findByRole('button', { + name: /workflowGenerator\.examples\.workflow\.summarize/i, + }), + ).toBeInTheDocument() }) it('should silently ignore AbortError when unmounted or refreshed', async () => { @@ -102,7 +124,10 @@ describe('ExamplePrompts', () => { it('should not fetch on mount if suggestions are already cached', async () => { // Simulate already having cached suggestions - sessionStorage.setItem('workflow-gen-suggestions-workflow', JSON.stringify(['cached suggestion'])) + sessionStorage.setItem( + 'workflow-gen-suggestions-workflow', + JSON.stringify(['cached suggestion']), + ) render(<ExamplePrompts mode="workflow" onSelect={vi.fn()} />) @@ -116,7 +141,11 @@ describe('ExamplePrompts', () => { mockFetch.mockResolvedValue({} as any) render(<ExamplePrompts mode="workflow" onSelect={vi.fn()} />) - expect(await screen.findByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })).toBeInTheDocument() + expect( + await screen.findByRole('button', { + name: /workflowGenerator\.examples\.workflow\.summarize/i, + }), + ).toBeInTheDocument() }) it('does not double-fetch on re-renders (simulating React Strict Mode)', async () => { @@ -153,7 +182,7 @@ describe('ExamplePrompts', () => { describe('selection', () => { // Clicking a chip hands its text back to the parent verbatim to populate // the instruction textarea. - it('should forward the clicked chip\'s text to onSelect', async () => { + it("should forward the clicked chip's text to onSelect", async () => { mockFetch.mockResolvedValue({ suggestions: ['Build a triage bot'] }) const user = userEvent.setup() const onSelect = vi.fn() diff --git a/web/app/components/workflow/workflow-generator/__tests__/generation-plan.spec.tsx b/web/app/components/workflow/workflow-generator/__tests__/generation-plan.spec.tsx index a68085afdcfc86..bce5d6269b6a30 100644 --- a/web/app/components/workflow/workflow-generator/__tests__/generation-plan.spec.tsx +++ b/web/app/components/workflow/workflow-generator/__tests__/generation-plan.spec.tsx @@ -37,9 +37,7 @@ describe('GenerationPlan', () => { it('renders correctly when plan has no icon, app_name, or title', () => { const plan: WorkflowGenPlan = { mode: 'workflow', - nodes: [ - { label: 'Start', node_type: 'start' }, - ], + nodes: [{ label: 'Start', node_type: 'start' }], start_inputs: [], } as unknown as WorkflowGenPlan render(<GenerationPlan plan={plan} />) diff --git a/web/app/components/workflow/workflow-generator/__tests__/graph-diff.spec.ts b/web/app/components/workflow/workflow-generator/__tests__/graph-diff.spec.ts index f1e2242e64e64c..bb0eee3a2d0833 100644 --- a/web/app/components/workflow/workflow-generator/__tests__/graph-diff.spec.ts +++ b/web/app/components/workflow/workflow-generator/__tests__/graph-diff.spec.ts @@ -5,9 +5,12 @@ type GraphNode = GeneratedGraph['nodes'][number] // diffGraphs only reads `id` and `data`, so a minimal node shape is enough. const node = (id: string, data: Record<string, unknown> = {}): GraphNode => - ({ id, data } as unknown as GraphNode) -const graph = (nodes: GraphNode[]): GeneratedGraph => - ({ nodes, edges: [], viewport: { x: 0, y: 0, zoom: 1 } }) + ({ id, data }) as unknown as GraphNode +const graph = (nodes: GraphNode[]): GeneratedGraph => ({ + nodes, + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, +}) describe('diffGraphs', () => { it('reports nodes added in the new graph', () => { @@ -24,12 +27,18 @@ describe('diffGraphs', () => { }) it('reports nodes whose data changed', () => { - const result = diffGraphs(graph([node('a', { temperature: 0.2 })]), graph([node('a', { temperature: 0.9 })])) + const result = diffGraphs( + graph([node('a', { temperature: 0.2 })]), + graph([node('a', { temperature: 0.9 })]), + ) expect(result.changed).toEqual(['a']) }) it('treats identical data as unchanged', () => { - const result = diffGraphs(graph([node('a', { temperature: 0.2 })]), graph([node('a', { temperature: 0.2 })])) + const result = diffGraphs( + graph([node('a', { temperature: 0.2 })]), + graph([node('a', { temperature: 0.2 })]), + ) expect(result.changed).toEqual([]) }) diff --git a/web/app/components/workflow/workflow-generator/__tests__/store.spec.ts b/web/app/components/workflow/workflow-generator/__tests__/store.spec.ts index 2d43f04c7af839..eca6bde27b1f48 100644 --- a/web/app/components/workflow/workflow-generator/__tests__/store.spec.ts +++ b/web/app/components/workflow/workflow-generator/__tests__/store.spec.ts @@ -71,7 +71,11 @@ describe('useWorkflowGeneratorStore', () => { const { result } = renderHook(() => useWorkflowGeneratorStore()) act(() => { - result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-1', currentAppMode: 'workflow' }) + result.current.openGenerator({ + mode: 'workflow', + currentAppId: 'app-1', + currentAppMode: 'workflow', + }) }) act(() => { result.current.openGenerator({ mode: 'advanced-chat' }) @@ -90,7 +94,11 @@ describe('useWorkflowGeneratorStore', () => { const { result } = renderHook(() => useWorkflowGeneratorStore()) act(() => { - result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-9', currentAppMode: 'workflow' }) + result.current.openGenerator({ + mode: 'workflow', + currentAppId: 'app-9', + currentAppMode: 'workflow', + }) }) act(() => { result.current.closeGenerator() @@ -111,7 +119,10 @@ describe('useWorkflowGeneratorStore', () => { // their history so close+reopen of the toolbar Generate doesn't lose // the versions the user was comparing. it('should clear new-app sessionStorage keys when opened without a currentAppId', () => { - sessionStorage.setItem('workflow-gen-workflow-new-versions', JSON.stringify([{ ghost: true }])) + sessionStorage.setItem( + 'workflow-gen-workflow-new-versions', + JSON.stringify([{ ghost: true }]), + ) sessionStorage.setItem('workflow-gen-workflow-new-version-index', '3') const { result } = renderHook(() => useWorkflowGeneratorStore()) @@ -127,8 +138,11 @@ describe('useWorkflowGeneratorStore', () => { // workflow must not wipe a parallel advanced-chat /create session in // another tab's sessionStorage path (we share the same sessionStorage // namespace per tab, but only the corresponding mode key is wiped). - it('should leave the other mode\'s new-app history alone', () => { - sessionStorage.setItem('workflow-gen-advanced-chat-new-versions', JSON.stringify([{ keep: true }])) + it("should leave the other mode's new-app history alone", () => { + sessionStorage.setItem( + 'workflow-gen-advanced-chat-new-versions', + JSON.stringify([{ keep: true }]), + ) const { result } = renderHook(() => useWorkflowGeneratorStore()) act(() => { @@ -142,11 +156,18 @@ describe('useWorkflowGeneratorStore', () => { // history — the user expects to find their previous versions when they // reopen the toolbar Generate button. it('should NOT clear history when opened with a currentAppId', () => { - sessionStorage.setItem('workflow-gen-workflow-app-42-versions', JSON.stringify([{ keep: true }])) + sessionStorage.setItem( + 'workflow-gen-workflow-app-42-versions', + JSON.stringify([{ keep: true }]), + ) const { result } = renderHook(() => useWorkflowGeneratorStore()) act(() => { - result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-42', currentAppMode: 'workflow' }) + result.current.openGenerator({ + mode: 'workflow', + currentAppId: 'app-42', + currentAppMode: 'workflow', + }) }) expect(sessionStorage.getItem('workflow-gen-workflow-app-42-versions')).not.toBeNull() diff --git a/web/app/components/workflow/workflow-generator/apply.ts b/web/app/components/workflow/workflow-generator/apply.ts index c8946a41cec303..e51c0d991d895e 100644 --- a/web/app/components/workflow/workflow-generator/apply.ts +++ b/web/app/components/workflow/workflow-generator/apply.ts @@ -4,7 +4,7 @@ import { fetchWorkflowDraft, syncWorkflowDraft } from '@/service/workflow' import { AppModeEnum } from '@/types/app' const MODE_TO_APP_MODE: Record<WorkflowGeneratorMode, AppModeEnum> = { - 'workflow': AppModeEnum.WORKFLOW, + workflow: AppModeEnum.WORKFLOW, 'advanced-chat': AppModeEnum.ADVANCED_CHAT, } @@ -49,8 +49,7 @@ const isHashCollisionResponse = (e: unknown): boolean => { // The shared ``post()`` wrapper rejects with the raw ``Response`` for non-401 // failures (see ``service/base.ts::request`` catch branch). At this layer the // only reliable signal is the HTTP status. - if (!e || typeof e !== 'object') - return false + if (!e || typeof e !== 'object') return false return (e as { status?: number }).status === 409 } @@ -89,7 +88,11 @@ export const applyToNewApp = async ({ instruction, appName, icon, -}: ApplyToNewAppParams): Promise<{ appId: string, appMode: AppModeEnum, permissionKeys?: string[] }> => { +}: ApplyToNewAppParams): Promise<{ + appId: string + appMode: AppModeEnum + permissionKeys?: string[] +}> => { const appMode = MODE_TO_APP_MODE[mode] const name = (appName ?? '').trim() || deriveAppName(instruction) const appIcon = (icon ?? '').trim() || '🤖' @@ -120,12 +123,10 @@ export const applyToNewApp = async ({ conversation_variables: [], }, }) - } - catch (syncErr) { + } catch (syncErr) { try { await deleteApp(app.id) - } - catch (deleteErr) { + } catch (deleteErr) { throw new WorkflowApplyOrphanError(app.id, deleteErr) } throw syncErr @@ -165,8 +166,7 @@ export const applyToCurrentApp = async ({ let existing: Awaited<ReturnType<typeof fetchWorkflowDraft>> | null = null try { existing = await fetchWorkflowDraft(url) - } - catch { + } catch { existing = null } @@ -183,13 +183,11 @@ export const applyToCurrentApp = async ({ ...(existing?.hash ? { hash: existing.hash } : {}), } as Parameters<typeof syncWorkflowDraft>[0]['params'], }) - } - catch (e) { + } catch (e) { // 409 → draft was edited in another tab between our fetch and sync. // Translate the raw Response rejection into a typed error so the caller // can show a Reload affordance instead of a generic "apply failed" toast. - if (isHashCollisionResponse(e)) - throw new WorkflowApplyHashCollisionError() + if (isHashCollisionResponse(e)) throw new WorkflowApplyHashCollisionError() throw e } } diff --git a/web/app/components/workflow/workflow-generator/example-prompts.tsx b/web/app/components/workflow/workflow-generator/example-prompts.tsx index 95fdaa020fb69c..0d94c139162a1c 100644 --- a/web/app/components/workflow/workflow-generator/example-prompts.tsx +++ b/web/app/components/workflow/workflow-generator/example-prompts.tsx @@ -40,25 +40,24 @@ const ExamplePrompts = ({ mode, onSelect }: Props) => { const staticPrompts = useMemo(() => { if (mode === 'workflow') { return [ - t($ => $['workflowGenerator.examples.workflow.summarize']), - t($ => $['workflowGenerator.examples.workflow.translate']), - t($ => $['workflowGenerator.examples.workflow.rag']), - t($ => $['workflowGenerator.examples.workflow.classify']), + t(($) => $['workflowGenerator.examples.workflow.summarize']), + t(($) => $['workflowGenerator.examples.workflow.translate']), + t(($) => $['workflowGenerator.examples.workflow.rag']), + t(($) => $['workflowGenerator.examples.workflow.classify']), ] } return [ - t($ => $['workflowGenerator.examples.chatflow.support']), - t($ => $['workflowGenerator.examples.chatflow.tutor']), - t($ => $['workflowGenerator.examples.chatflow.triage']), + t(($) => $['workflowGenerator.examples.chatflow.support']), + t(($) => $['workflowGenerator.examples.chatflow.tutor']), + t(($) => $['workflowGenerator.examples.chatflow.triage']), ] }, [mode, t]) // Session-cached AI suggestions, keyed per mode so Workflow / Chatflow don't // clobber each other and a reopen within the same session skips the refetch. - const [cached, setCached] = useSessionStorageState<string[]>( - `workflow-gen-suggestions-${mode}`, - { defaultValue: [] }, - ) + const [cached, setCached] = useSessionStorageState<string[]>(`workflow-gen-suggestions-${mode}`, { + defaultValue: [], + }) const [isLoading, setIsLoading] = useState(false) const abortRef = useRef<AbortController | null>(null) const didInit = useRef(false) @@ -69,19 +68,19 @@ const ExamplePrompts = ({ mode, onSelect }: Props) => { try { const res = await fetchWorkflowInstructionSuggestions( { mode, language: i18n.language, count: SUGGESTION_COUNT }, - { getAbortController: (c) => { abortRef.current = c } }, + { + getAbortController: (c) => { + abortRef.current = c + }, + }, ) - const next = (res?.suggestions ?? []).map(s => s.trim()).filter(Boolean) + const next = (res?.suggestions ?? []).map((s) => s.trim()).filter(Boolean) // Keep the previous set on an empty refresh so the row never flashes empty. - if (next.length) - setCached(next) - } - catch (e) { - if (isAbortError(e)) - return + if (next.length) setCached(next) + } catch (e) { + if (isAbortError(e)) return // Silent: the static fallback keeps the row populated. - } - finally { + } finally { setIsLoading(false) abortRef.current = null } @@ -91,11 +90,9 @@ const ExamplePrompts = ({ mode, onSelect }: Props) => { // is fixed per open (the modal remounts each time), so a mount-only effect is // correct here. useEffect(() => { - if (didInit.current) - return + if (didInit.current) return didInit.current = true - if (!cached || cached.length === 0) - void fetchSuggestions() + if (!cached || cached.length === 0) void fetchSuggestions() return () => { abortRef.current?.abort() abortRef.current = null @@ -110,15 +107,17 @@ const ExamplePrompts = ({ mode, onSelect }: Props) => { <div className="mt-3"> <div className="mb-1.5 flex items-center gap-1"> <span className="system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['workflowGenerator.examples.label'])} + {t(($) => $['workflowGenerator.examples.label'])} </span> <button type="button" data-testid="workflow-gen-suggestions-refresh" - aria-label={t($ => $['workflowGenerator.examples.refresh'])} - title={t($ => $['workflowGenerator.examples.refresh'])} + aria-label={t(($) => $['workflowGenerator.examples.refresh'])} + title={t(($) => $['workflowGenerator.examples.refresh'])} className="flex size-4 cursor-pointer items-center justify-center rounded text-text-quaternary hover:text-text-tertiary disabled:cursor-not-allowed disabled:opacity-50" - onClick={() => { void fetchSuggestions() }} + onClick={() => { + void fetchSuggestions() + }} disabled={isLoading} > <RiRefreshLine className={cn('size-3.5', isLoading && 'animate-spin')} /> @@ -133,7 +132,7 @@ const ExamplePrompts = ({ mode, onSelect }: Props) => { style={{ width: w }} /> )) - : prompts.map(prompt => ( + : prompts.map((prompt) => ( <button key={prompt} type="button" diff --git a/web/app/components/workflow/workflow-generator/generation-plan.tsx b/web/app/components/workflow/workflow-generator/generation-plan.tsx index a2845606f0141f..1928394ff9adbf 100644 --- a/web/app/components/workflow/workflow-generator/generation-plan.tsx +++ b/web/app/components/workflow/workflow-generator/generation-plan.tsx @@ -33,7 +33,7 @@ const PlanningSkeleton = memo(() => { </SkeletonRow> <div className="grow overflow-hidden rounded-2xl border border-divider-subtle bg-background-default p-4"> <SkeletonContainer className="gap-3"> - {SKELETON_ROWS.map(key => ( + {SKELETON_ROWS.map((key) => ( <SkeletonRow key={key} className="items-start"> <SkeletonRectangle className="size-6 shrink-0 rounded-lg" /> <div className="flex grow flex-col gap-1.5"> @@ -46,7 +46,7 @@ const PlanningSkeleton = memo(() => { </div> <div className="mt-3 flex items-center gap-1.5 text-[13px] text-text-tertiary"> <RiLoader4Line className="size-4 animate-spin" /> - <span>{t($ => $['workflowGenerator.phases.planning'])}</span> + <span>{t(($) => $['workflowGenerator.phases.planning'])}</span> </div> </div> ) @@ -65,8 +65,7 @@ PlanningSkeleton.displayName = 'PlanningSkeleton' const GenerationPlan = ({ plan }: Props) => { const { t } = useTranslation('workflow') - if (!plan) - return <PlanningSkeleton /> + if (!plan) return <PlanningSkeleton /> return ( <div className="flex h-full w-0 grow flex-col bg-background-default-subtle p-6"> @@ -74,8 +73,14 @@ const GenerationPlan = ({ plan }: Props) => { <div className="mb-3 flex items-center gap-2"> {plan.icon && <span className="text-xl leading-none">{plan.icon}</span>} <div className="min-w-0"> - <div className="truncate text-sm font-semibold text-text-primary">{plan.app_name || plan.title}</div> - {plan.description && <div className="truncate system-xs-regular text-text-tertiary">{plan.description}</div>} + <div className="truncate text-sm font-semibold text-text-primary"> + {plan.app_name || plan.title} + </div> + {plan.description && ( + <div className="truncate system-xs-regular text-text-tertiary"> + {plan.description} + </div> + )} </div> </div> )} @@ -89,11 +94,12 @@ const GenerationPlan = ({ plan }: Props) => { <div className="system-sm-medium text-text-secondary"> {node.label} <span className="ml-1 system-xs-regular text-text-quaternary"> - · - {node.node_type} + ·{node.node_type} </span> </div> - {node.purpose && <div className="system-xs-regular text-text-tertiary">{node.purpose}</div>} + {node.purpose && ( + <div className="system-xs-regular text-text-tertiary">{node.purpose}</div> + )} </div> </li> ))} @@ -102,7 +108,7 @@ const GenerationPlan = ({ plan }: Props) => { <div className="mt-3 flex items-center gap-1.5 text-[13px] text-text-tertiary"> <RiLoader4Line className="size-4 animate-spin" /> - <span>{t($ => $['workflowGenerator.phases.building'])}</span> + <span>{t(($) => $['workflowGenerator.phases.building'])}</span> </div> </div> ) diff --git a/web/app/components/workflow/workflow-generator/graph-diff.ts b/web/app/components/workflow/workflow-generator/graph-diff.ts index 33596fff2fac6d..134d828f199c75 100644 --- a/web/app/components/workflow/workflow-generator/graph-diff.ts +++ b/web/app/components/workflow/workflow-generator/graph-diff.ts @@ -19,8 +19,8 @@ export type GraphDiff = { * so cosmetic re-layouts don't read as changes. */ export const diffGraphs = (base: GeneratedGraph, next: GeneratedGraph): GraphDiff => { - const baseById = new Map(base.nodes.map(node => [node.id, node])) - const nextById = new Map(next.nodes.map(node => [node.id, node])) + const baseById = new Map(base.nodes.map((node) => [node.id, node])) + const nextById = new Map(next.nodes.map((node) => [node.id, node])) const added: string[] = [] const changed: string[] = [] @@ -30,10 +30,9 @@ export const diffGraphs = (base: GeneratedGraph, next: GeneratedGraph): GraphDif added.push(id) continue } - if (JSON.stringify(prev.data) !== JSON.stringify(node.data)) - changed.push(id) + if (JSON.stringify(prev.data) !== JSON.stringify(node.data)) changed.push(id) } - const removed = [...baseById.keys()].filter(id => !nextById.has(id)) + const removed = [...baseById.keys()].filter((id) => !nextById.has(id)) return { added, removed, changed } } diff --git a/web/app/components/workflow/workflow-generator/index.tsx b/web/app/components/workflow/workflow-generator/index.tsx index 5b75be2ecb75e2..8af216cc770003 100644 --- a/web/app/components/workflow/workflow-generator/index.tsx +++ b/web/app/components/workflow/workflow-generator/index.tsx @@ -2,7 +2,11 @@ import type { SelectorParam, TFunction } from 'i18next' import type { GeneratedGraph } from './types' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { GenerateWorkflowBody, GenerateWorkflowResponse as StreamResult, WorkflowGenPlan } from '@/service/debug' +import type { + GenerateWorkflowBody, + GenerateWorkflowResponse as StreamResult, + WorkflowGenPlan, +} from '@/service/debug' import type { CompletionParams, ModelModeType } from '@/types/app' import { AlertDialog, @@ -33,11 +37,20 @@ import { useRouter } from '@/next/navigation' import { generateWorkflow, generateWorkflowStream } from '@/service/debug' import { fetchWorkflowDraft } from '@/service/workflow' import { getRedirectionPath } from '@/utils/app-redirection' -import { applyToCurrentApp, applyToNewApp, WorkflowApplyHashCollisionError, WorkflowApplyOrphanError } from './apply' +import { + applyToCurrentApp, + applyToNewApp, + WorkflowApplyHashCollisionError, + WorkflowApplyOrphanError, +} from './apply' import ExamplePrompts from './example-prompts' import GenerationPlan from './generation-plan' import { diffGraphs } from './graph-diff' -import { EMPTY_WORKFLOW_GENERATOR_MODEL, useWorkflowGeneratorLastInstruction, useWorkflowGeneratorModel } from './storage' +import { + EMPTY_WORKFLOW_GENERATOR_MODEL, + useWorkflowGeneratorLastInstruction, + useWorkflowGeneratorModel, +} from './storage' import { useWorkflowGeneratorStore } from './store' import useGenGraph from './use-gen-graph' @@ -54,41 +67,44 @@ const MAX_INSTRUCTION_LENGTH = 10_000 // A single structured generation error. Mirrors the backend ``errors[]`` entry // (stable ``code`` + human ``detail`` + optional ``node_id``) so the error panel // can localise the message and point at the offending node. -type GenError = { code: string, detail: string, node_id?: string } - -type WorkflowGeneratorErrorCode - = | 'DANGLING_EDGE' - | 'DUPLICATE_NODE_ID' - | 'EMPTY_INSTRUCTION' - | 'EMPTY_PLAN' - | 'GRAPH_CYCLE' - | 'INSTRUCTION_TOO_LONG' - | 'INVALID_CONTAINER' - | 'INVALID_JSON' - | 'INVALID_SCHEMA' - | 'MISSING_START' - | 'MISSING_TERMINAL' - | 'MODEL_ERROR' - | 'UNKNOWN_NODE_REFERENCE' - | 'UNKNOWN_TOOL' - | 'UNRESOLVED_REFERENCE' - -const workflowGeneratorErrorSelectors: Record<WorkflowGeneratorErrorCode, SelectorParam<'workflow'>> = { - DANGLING_EDGE: $ => $['workflowGenerator.errors.DANGLING_EDGE'], - DUPLICATE_NODE_ID: $ => $['workflowGenerator.errors.DUPLICATE_NODE_ID'], - EMPTY_INSTRUCTION: $ => $['workflowGenerator.errors.EMPTY_INSTRUCTION'], - EMPTY_PLAN: $ => $['workflowGenerator.errors.EMPTY_PLAN'], - GRAPH_CYCLE: $ => $['workflowGenerator.errors.GRAPH_CYCLE'], - INSTRUCTION_TOO_LONG: $ => $['workflowGenerator.errors.INSTRUCTION_TOO_LONG'], - INVALID_CONTAINER: $ => $['workflowGenerator.errors.INVALID_CONTAINER'], - INVALID_JSON: $ => $['workflowGenerator.errors.INVALID_JSON'], - INVALID_SCHEMA: $ => $['workflowGenerator.errors.INVALID_SCHEMA'], - MISSING_START: $ => $['workflowGenerator.errors.MISSING_START'], - MISSING_TERMINAL: $ => $['workflowGenerator.errors.MISSING_TERMINAL'], - MODEL_ERROR: $ => $['workflowGenerator.errors.MODEL_ERROR'], - UNKNOWN_NODE_REFERENCE: $ => $['workflowGenerator.errors.UNKNOWN_NODE_REFERENCE'], - UNKNOWN_TOOL: $ => $['workflowGenerator.errors.UNKNOWN_TOOL'], - UNRESOLVED_REFERENCE: $ => $['workflowGenerator.errors.UNRESOLVED_REFERENCE'], +type GenError = { code: string; detail: string; node_id?: string } + +type WorkflowGeneratorErrorCode = + | 'DANGLING_EDGE' + | 'DUPLICATE_NODE_ID' + | 'EMPTY_INSTRUCTION' + | 'EMPTY_PLAN' + | 'GRAPH_CYCLE' + | 'INSTRUCTION_TOO_LONG' + | 'INVALID_CONTAINER' + | 'INVALID_JSON' + | 'INVALID_SCHEMA' + | 'MISSING_START' + | 'MISSING_TERMINAL' + | 'MODEL_ERROR' + | 'UNKNOWN_NODE_REFERENCE' + | 'UNKNOWN_TOOL' + | 'UNRESOLVED_REFERENCE' + +const workflowGeneratorErrorSelectors: Record< + WorkflowGeneratorErrorCode, + SelectorParam<'workflow'> +> = { + DANGLING_EDGE: ($) => $['workflowGenerator.errors.DANGLING_EDGE'], + DUPLICATE_NODE_ID: ($) => $['workflowGenerator.errors.DUPLICATE_NODE_ID'], + EMPTY_INSTRUCTION: ($) => $['workflowGenerator.errors.EMPTY_INSTRUCTION'], + EMPTY_PLAN: ($) => $['workflowGenerator.errors.EMPTY_PLAN'], + GRAPH_CYCLE: ($) => $['workflowGenerator.errors.GRAPH_CYCLE'], + INSTRUCTION_TOO_LONG: ($) => $['workflowGenerator.errors.INSTRUCTION_TOO_LONG'], + INVALID_CONTAINER: ($) => $['workflowGenerator.errors.INVALID_CONTAINER'], + INVALID_JSON: ($) => $['workflowGenerator.errors.INVALID_JSON'], + INVALID_SCHEMA: ($) => $['workflowGenerator.errors.INVALID_SCHEMA'], + MISSING_START: ($) => $['workflowGenerator.errors.MISSING_START'], + MISSING_TERMINAL: ($) => $['workflowGenerator.errors.MISSING_TERMINAL'], + MODEL_ERROR: ($) => $['workflowGenerator.errors.MODEL_ERROR'], + UNKNOWN_NODE_REFERENCE: ($) => $['workflowGenerator.errors.UNKNOWN_NODE_REFERENCE'], + UNKNOWN_TOOL: ($) => $['workflowGenerator.errors.UNKNOWN_TOOL'], + UNRESOLVED_REFERENCE: ($) => $['workflowGenerator.errors.UNRESOLVED_REFERENCE'], } function isWorkflowGeneratorErrorCode(code: string): code is WorkflowGeneratorErrorCode { @@ -99,15 +115,13 @@ function getWorkflowGeneratorErrorMessage(error: GenError, t: TFunction<'workflo if (isWorkflowGeneratorErrorCode(error.code)) return t(workflowGeneratorErrorSelectors[error.code]) - return error.detail || t($ => $['workflowGenerator.generateFailed']) + return error.detail || t(($) => $['workflowGenerator.generateFailed']) } const renderPlaceholder = (label: string) => ( <div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8"> <span className="i-custom-vender-other-generator size-8 text-text-quaternary" /> - <div className="text-center text-[13px] leading-5 font-normal text-text-tertiary"> - {label} - </div> + <div className="text-center text-[13px] leading-5 font-normal text-text-tertiary">{label}</div> </div> ) @@ -131,8 +145,16 @@ type RecoveryDialogProps = { // dialogs. The overwrite-confirm and hash-collision dialogs differ only in // copy and confirm handler; this collapses 30 lines of duplicate JSX to // one props bag and keeps the visual styling in lockstep across both. -const RecoveryDialog = ({ open, onOpenChange, title, description, cancelLabel, confirmLabel, onConfirm }: RecoveryDialogProps) => ( - <AlertDialog open={open} onOpenChange={o => !o && onOpenChange(false)}> +const RecoveryDialog = ({ + open, + onOpenChange, + title, + description, + cancelLabel, + confirmLabel, + onConfirm, +}: RecoveryDialogProps) => ( + <AlertDialog open={open} onOpenChange={(o) => !o && onOpenChange(false)}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> @@ -156,27 +178,29 @@ const WorkflowGeneratorModal: React.FC = () => { const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isRbacEnabled = systemFeatures.rbac_enabled - const isOpen = useWorkflowGeneratorStore(s => s.isOpen) - const mode = useWorkflowGeneratorStore(s => s.mode) - const intent = useWorkflowGeneratorStore(s => s.intent) - const currentAppId = useWorkflowGeneratorStore(s => s.currentAppId) - const currentAppMode = useWorkflowGeneratorStore(s => s.currentAppMode) - const initialInstruction = useWorkflowGeneratorStore(s => s.initialInstruction) - const autoMode = useWorkflowGeneratorStore(s => s.autoMode) - const closeGenerator = useWorkflowGeneratorStore(s => s.closeGenerator) + const isOpen = useWorkflowGeneratorStore((s) => s.isOpen) + const mode = useWorkflowGeneratorStore((s) => s.mode) + const intent = useWorkflowGeneratorStore((s) => s.intent) + const currentAppId = useWorkflowGeneratorStore((s) => s.currentAppId) + const currentAppMode = useWorkflowGeneratorStore((s) => s.currentAppMode) + const initialInstruction = useWorkflowGeneratorStore((s) => s.initialInstruction) + const autoMode = useWorkflowGeneratorStore((s) => s.autoMode) + const closeGenerator = useWorkflowGeneratorStore((s) => s.closeGenerator) const isRefine = intent === 'refine' && !!currentAppId const [model, setModel] = useWorkflowGeneratorModel() - const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) + const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel( + ModelTypeEnum.textGeneration, + ) // Hydrate model from defaultModel once it loads (async). We deliberately set state // from an effect here because defaultModel only resolves after the workspace's model // catalogue fetch completes. useEffect(() => { if (defaultModel && !model.name) { - setModel(prev => ({ + setModel((prev) => ({ ...(prev ?? EMPTY_WORKFLOW_GENERATOR_MODEL), name: defaultModel.model, provider: defaultModel.provider.provider, @@ -184,21 +208,27 @@ const WorkflowGeneratorModal: React.FC = () => { } }, [defaultModel, model.name, setModel]) - const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => { - setModel(prev => ({ - ...(prev ?? EMPTY_WORKFLOW_GENERATOR_MODEL), - provider: newValue.provider, - name: newValue.modelId, - mode: newValue.mode as ModelModeType, - })) - }, [setModel]) - - const handleCompletionParamsChange = useCallback((newParams: FormValue) => { - setModel(prev => ({ - ...(prev ?? EMPTY_WORKFLOW_GENERATOR_MODEL), - completion_params: newParams as CompletionParams, - })) - }, [setModel]) + const handleModelChange = useCallback( + (newValue: { modelId: string; provider: string; mode?: string; features?: string[] }) => { + setModel((prev) => ({ + ...(prev ?? EMPTY_WORKFLOW_GENERATOR_MODEL), + provider: newValue.provider, + name: newValue.modelId, + mode: newValue.mode as ModelModeType, + })) + }, + [setModel], + ) + + const handleCompletionParamsChange = useCallback( + (newParams: FormValue) => { + setModel((prev) => ({ + ...(prev ?? EMPTY_WORKFLOW_GENERATOR_MODEL), + completion_params: newParams as CompletionParams, + })) + }, + [setModel], + ) const [lastInstruction, setLastInstruction] = useWorkflowGeneratorLastInstruction() // Seed from the palette's inline-captured instruction, else the last instruction @@ -215,21 +245,26 @@ const WorkflowGeneratorModal: React.FC = () => { const [refineBaseGraph, setRefineBaseGraph] = useState<GeneratedGraph | null>(null) const storageKey = `${mode}-${currentAppId ?? 'new'}` - const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenGraph({ - storageKey, - }) + const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = + useGenGraph({ + storageKey, + }) const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false) const [isApplying, { setTrue: setApplyingTrue, setFalse: setApplyingFalse }] = useBoolean(false) // Confirmation dialog for "Apply to current draft" - const [isShowConfirmOverwrite, { setTrue: showConfirmOverwrite, setFalse: hideConfirmOverwrite }] = useBoolean(false) + const [ + isShowConfirmOverwrite, + { setTrue: showConfirmOverwrite, setFalse: hideConfirmOverwrite }, + ] = useBoolean(false) // Surfaced when the backend rejects the draft sync because another tab // edited the workspace after we fetched it. Dedicated dialog instead of a // toast because the user needs an explicit Reload action — without that, // a generic "apply failed" toast leaves them stuck and confused. - const [isShowHashCollision, { setTrue: showHashCollision, setFalse: hideHashCollision }] = useBoolean(false) + const [isShowHashCollision, { setTrue: showHashCollision, setFalse: hideHashCollision }] = + useBoolean(false) // Holds the AbortController of the in-flight ``/workflow-generate`` request // so we can cancel it on (a) modal close, (b) a second Generate click @@ -281,7 +316,7 @@ const WorkflowGeneratorModal: React.FC = () => { const isValid = () => { const trimmed = instruction.trim() if (!trimmed) { - toast.error(t($ => $['workflowGenerator.instructionRequired'])) + toast.error(t(($) => $['workflowGenerator.instructionRequired'])) return false } if (!model.name) { @@ -289,7 +324,7 @@ const WorkflowGeneratorModal: React.FC = () => { // loading). Without this guard the request would fly with an empty // ``model_config.name`` and surface as a backend 400 — not actionable // for the user. Tell them to pick a model. - toast.error(t($ => $['workflowGenerator.modelRequired'])) + toast.error(t(($) => $['workflowGenerator.modelRequired'])) return false } return true @@ -298,22 +333,26 @@ const WorkflowGeneratorModal: React.FC = () => { // Apply a finished generation result (from the stream's ``result`` event or // the non-streaming fallback). Structured errors go to the actionable error // panel rather than a transient toast; a version is added only for a real graph. - const handleResult = useCallback((res: StreamResult) => { - if (res.errors?.length) { - setGenError(res.errors as GenError[]) - return - } - if (!res.graph?.nodes?.length) { - setGenError([{ code: 'EMPTY', detail: res.error || t($ => $['workflowGenerator.generateFailed']) }]) - return - } - setGenError(null) - addVersion(res) - }, [addVersion, t]) + const handleResult = useCallback( + (res: StreamResult) => { + if (res.errors?.length) { + setGenError(res.errors as GenError[]) + return + } + if (!res.graph?.nodes?.length) { + setGenError([ + { code: 'EMPTY', detail: res.error || t(($) => $['workflowGenerator.generateFailed']) }, + ]) + return + } + setGenError(null) + addVersion(res) + }, + [addVersion, t], + ) const onGenerate = async () => { - if (!isValid()) - return + if (!isValid()) return // Cancel any previous in-flight request (double-click guard). abortInFlight() @@ -328,7 +367,7 @@ const WorkflowGeneratorModal: React.FC = () => { timeoutRef.current = setTimeout(() => { abortRef.current?.abort() abortRef.current = null - toast.error(t($ => $['workflowGenerator.errors.timeout'])) + toast.error(t(($) => $['workflowGenerator.errors.timeout'])) setLoadingFalse() }, FE_TIMEOUT_MS) @@ -341,14 +380,11 @@ const WorkflowGeneratorModal: React.FC = () => { if (isRefine && currentAppId) { try { const draft = await fetchWorkflowDraft(`apps/${currentAppId}/workflows/draft`) - if (draft?.graph?.nodes?.length) - currentGraph = draft.graph as GeneratedGraph - } - catch { + if (draft?.graph?.nodes?.length) currentGraph = draft.graph as GeneratedGraph + } catch { currentGraph = undefined } - if (!currentGraph) - toast.warning(t($ => $['workflowGenerator.refineDraftUnavailable'])) + if (!currentGraph) toast.warning(t(($) => $['workflowGenerator.refineDraftUnavailable'])) } setRefineBaseGraph(currentGraph ?? null) @@ -373,7 +409,9 @@ const WorkflowGeneratorModal: React.FC = () => { // fall back to the single-shot endpoint instead of failing the user. let settled = false generateWorkflowStream(body, { - getAbortController: (c) => { abortRef.current = c }, + getAbortController: (c) => { + abortRef.current = c + }, onPlan: (p) => { settled = true setPlan(p) @@ -386,25 +424,24 @@ const WorkflowGeneratorModal: React.FC = () => { onError: (msg) => { if (!settled) { generateWorkflow(body, { - getAbortController: (c) => { abortRef.current = c }, + getAbortController: (c) => { + abortRef.current = c + }, }) - .then(res => handleResult(res)) + .then((res) => handleResult(res)) .catch((e: unknown) => { - if (isAbortError(e)) - return + if (isAbortError(e)) return const message = e instanceof Error ? e.message : '' - toast.error(message || t($ => $['workflowGenerator.generateFailed'])) + toast.error(message || t(($) => $['workflowGenerator.generateFailed'])) }) .finally(finish) return } - if (msg) - toast.error(msg) + if (msg) toast.error(msg) finish() }, onCompleted: () => { - if (settled) - finish() + if (settled) finish() }, }) } @@ -425,8 +462,7 @@ const WorkflowGeneratorModal: React.FC = () => { const canApplyToCurrent = !!currentAppId && currentAppMode === mode && generatedModeMatches const handleApplyToNew = useCallback(async () => { - if (!current?.graph || isApplying) - return + if (!current?.graph || isApplying) return setApplyingTrue() try { const { appId, appMode, permissionKeys } = await applyToNewApp({ @@ -440,43 +476,54 @@ const WorkflowGeneratorModal: React.FC = () => { }) // Nudge the freshly-created Studio toward iterating with cmd+k /refine // instead of regenerating from scratch for a small tweak. - toast.success(t($ => $['workflowGenerator.appliedRefineHint'])) + toast.success(t(($) => $['workflowGenerator.appliedRefineHint'])) closeGenerator() - router.push(getRedirectionPath({ id: appId, mode: appMode, permission_keys: permissionKeys }, { isRbacEnabled })) - } - catch (e: unknown) { + router.push( + getRedirectionPath( + { id: appId, mode: appMode, permission_keys: permissionKeys }, + { isRbacEnabled }, + ), + ) + } catch (e: unknown) { if (e instanceof WorkflowApplyOrphanError) { // Sync failed AND we couldn't roll back. Route the user to /apps so // the orphan is still discoverable — they can delete it by hand. - toast.error(t($ => $['workflowGenerator.errors.apply_failed_orphan'])) + toast.error(t(($) => $['workflowGenerator.errors.apply_failed_orphan'])) closeGenerator() router.push('/apps') return } const message = e instanceof Error ? e.message : '' - toast.error(message || t($ => $['workflowGenerator.applyFailed'])) - } - finally { + toast.error(message || t(($) => $['workflowGenerator.applyFailed'])) + } finally { setApplyingFalse() } - }, [current, instruction, mode, router, closeGenerator, t, isApplying, isRbacEnabled, setApplyingTrue, setApplyingFalse]) + }, [ + current, + instruction, + mode, + router, + closeGenerator, + t, + isApplying, + isRbacEnabled, + setApplyingTrue, + setApplyingFalse, + ]) const handleApplyToCurrentConfirmed = useCallback(async () => { - if (!current?.graph || !currentAppId || isApplying) - return + if (!current?.graph || !currentAppId || isApplying) return hideConfirmOverwrite() setApplyingTrue() try { await applyToCurrentApp({ appId: currentAppId, graph: current.graph as GeneratedGraph }) - toast.success(t($ => $['workflowGenerator.applied'])) + toast.success(t(($) => $['workflowGenerator.applied'])) closeGenerator() // Hard reload the workflow page so the canvas picks up the new draft — // ``router.refresh()`` only revalidates server-rendered route data, and // the Studio canvas is hydrated client-side via react-query / zustand. - if (typeof window !== 'undefined') - window.location.reload() - } - catch (e: unknown) { + if (typeof window !== 'undefined') window.location.reload() + } catch (e: unknown) { if (e instanceof WorkflowApplyHashCollisionError) { // Another tab edited the draft after we fetched it. Show a // dedicated dialog with a Reload affordance instead of a generic @@ -487,29 +534,40 @@ const WorkflowGeneratorModal: React.FC = () => { return } const message = e instanceof Error ? e.message : '' - toast.error(message || t($ => $['workflowGenerator.applyFailed'])) - } - finally { + toast.error(message || t(($) => $['workflowGenerator.applyFailed'])) + } finally { setApplyingFalse() } - }, [current, currentAppId, hideConfirmOverwrite, closeGenerator, t, isApplying, setApplyingTrue, setApplyingFalse, showHashCollision]) - - const modeLabel = mode === 'workflow' ? t($ => $['workflowGenerator.modes.workflow']) : t($ => $['workflowGenerator.modes.chatflow']) + }, [ + current, + currentAppId, + hideConfirmOverwrite, + closeGenerator, + t, + isApplying, + setApplyingTrue, + setApplyingFalse, + showHashCollision, + ]) + + const modeLabel = + mode === 'workflow' + ? t(($) => $['workflowGenerator.modes.workflow']) + : t(($) => $['workflowGenerator.modes.chatflow']) // Refine diff — what an "apply" would change vs. the draft we started from. const refineDiff = useMemo(() => { - if (!isRefine || !refineBaseGraph || !current?.graph?.nodes?.length) - return null + if (!isRefine || !refineBaseGraph || !current?.graph?.nodes?.length) return null return diffGraphs(refineBaseGraph, current.graph as GeneratedGraph) }, [isRefine, refineBaseGraph, current]) - const hasRefineChanges = !!refineDiff && (refineDiff.added.length > 0 || refineDiff.removed.length > 0 || refineDiff.changed.length > 0) + const hasRefineChanges = + !!refineDiff && + (refineDiff.added.length > 0 || refineDiff.removed.length > 0 || refineDiff.changed.length > 0) // Derived view of the last structured error for the actionable error panel. const firstGenError = genError?.[0] - const genErrorMessage = firstGenError - ? getWorkflowGeneratorErrorMessage(firstGenError, t) - : '' - const genErrorHasUnknownTool = !!genError?.some(e => e.code === 'UNKNOWN_TOOL') + const genErrorMessage = firstGenError ? getWorkflowGeneratorErrorMessage(firstGenError, t) : '' + const genErrorHasUnknownTool = !!genError?.some((e) => e.code === 'UNKNOWN_TOOL') return ( <Dialog @@ -531,11 +589,13 @@ const WorkflowGeneratorModal: React.FC = () => { <div className="mb-5"> <div className="text-lg leading-[28px] font-bold text-text-primary"> {isRefine - ? t($ => $['workflowGenerator.refineTitle'], { mode: modeLabel }) - : t($ => $['workflowGenerator.title'], { mode: modeLabel })} + ? t(($) => $['workflowGenerator.refineTitle'], { mode: modeLabel }) + : t(($) => $['workflowGenerator.title'], { mode: modeLabel })} </div> <div className="mt-1 text-[13px] font-normal text-text-tertiary"> - {isRefine ? t($ => $['workflowGenerator.refineDescription']) : t($ => $['workflowGenerator.description'])} + {isRefine + ? t(($) => $['workflowGenerator.refineDescription']) + : t(($) => $['workflowGenerator.description'])} </div> </div> @@ -554,7 +614,7 @@ const WorkflowGeneratorModal: React.FC = () => { <div className="mt-4"> <div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary"> - {t($ => $['workflowGenerator.instruction'])} + {t(($) => $['workflowGenerator.instruction'])} </div> <Textarea // Autofocus is appropriate here: the modal's sole purpose is to @@ -562,9 +622,11 @@ const WorkflowGeneratorModal: React.FC = () => { // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus className="h-[160px]" - placeholder={isRefine - ? t($ => $['workflowGenerator.refineInstructionPlaceholder']) - : t($ => $['workflowGenerator.instructionPlaceholder'])} + placeholder={ + isRefine + ? t(($) => $['workflowGenerator.refineInstructionPlaceholder']) + : t(($) => $['workflowGenerator.instructionPlaceholder']) + } value={instruction} onValueChange={setInstruction} onKeyDown={(e) => { @@ -572,8 +634,7 @@ const WorkflowGeneratorModal: React.FC = () => { // the palette, so let it finish without reaching for the mouse. if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault() - if (!isLoading) - onGenerate() + if (!isLoading) onGenerate() } }} maxLength={MAX_INSTRUCTION_LENGTH} @@ -585,157 +646,153 @@ const WorkflowGeneratorModal: React.FC = () => { {!isRefine && <ExamplePrompts mode={mode} onSelect={setInstruction} />} <div className="mt-7 flex justify-end space-x-2"> - <Button onClick={closeGenerator}> - {t($ => $['workflowGenerator.dismiss'])} - </Button> - {isLoading - ? ( - // Cancel surfaces the abort affordance during the 60 s - // window where the user might want to bail (slow - // model, wrong instruction, etc.). Hidden when idle so - // the row stays focused on the primary action. - <Button - className="flex space-x-1" - variant="secondary" - onClick={onCancelGeneration} - > - <span className="text-xs font-semibold">{t($ => $['workflowGenerator.cancel'])}</span> - </Button> - ) - : ( - <Button - className="flex space-x-1" - variant="primary" - onClick={onGenerate} - disabled={!model.name} - > - <span className="i-custom-vender-other-generator size-4" /> - <span className="text-xs font-semibold">{t($ => $['workflowGenerator.generate'])}</span> - </Button> - )} + <Button onClick={closeGenerator}>{t(($) => $['workflowGenerator.dismiss'])}</Button> + {isLoading ? ( + // Cancel surfaces the abort affordance during the 60 s + // window where the user might want to bail (slow + // model, wrong instruction, etc.). Hidden when idle so + // the row stays focused on the primary action. + <Button + className="flex space-x-1" + variant="secondary" + onClick={onCancelGeneration} + > + <span className="text-xs font-semibold"> + {t(($) => $['workflowGenerator.cancel'])} + </span> + </Button> + ) : ( + <Button + className="flex space-x-1" + variant="primary" + onClick={onGenerate} + disabled={!model.name} + > + <span className="i-custom-vender-other-generator size-4" /> + <span className="text-xs font-semibold"> + {t(($) => $['workflowGenerator.generate'])} + </span> + </Button> + )} </div> </div> </div> {/* Right pane: planning → graph result / actionable error / empty placeholder */} - {isLoading - ? ( - <GenerationPlan plan={plan} /> - ) - : genError?.length - ? ( - <div className="flex h-full w-0 grow flex-col items-center justify-center gap-4 px-8"> - <RiErrorWarningLine className="size-8 text-text-quaternary" /> - <div className="text-center"> - <div className="system-md-medium text-text-secondary">{genErrorMessage}</div> - {firstGenError?.node_id && ( - <div className="mt-1 system-xs-regular text-text-tertiary"> - {t($ => $['workflowGenerator.errors.atNode'], { node: firstGenError.node_id })} - </div> - )} - </div> - <div className="flex items-center gap-2"> - <Button size="small" variant="primary" onClick={onGenerate} disabled={!model.name}> - {t($ => $['workflowGenerator.regenerate'])} - </Button> - {genErrorHasUnknownTool && ( - <Button - size="small" - variant="secondary" - onClick={() => { - closeGenerator() - router.push('/tools') - }} - > - {t($ => $['workflowGenerator.errors.installTools'])} - </Button> - )} - </div> + {isLoading ? ( + <GenerationPlan plan={plan} /> + ) : genError?.length ? ( + <div className="flex h-full w-0 grow flex-col items-center justify-center gap-4 px-8"> + <RiErrorWarningLine className="size-8 text-text-quaternary" /> + <div className="text-center"> + <div className="system-md-medium text-text-secondary">{genErrorMessage}</div> + {firstGenError?.node_id && ( + <div className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $['workflowGenerator.errors.atNode'], { + node: firstGenError.node_id, + })} </div> - ) - : current?.graph?.nodes?.length - ? ( - <div className="flex h-full w-0 grow flex-col bg-background-default-subtle p-6"> - {/* Planner-picked identity — surfaces the app_name + icon the + )} + </div> + <div className="flex items-center gap-2"> + <Button size="small" variant="primary" onClick={onGenerate} disabled={!model.name}> + {t(($) => $['workflowGenerator.regenerate'])} + </Button> + {genErrorHasUnknownTool && ( + <Button + size="small" + variant="secondary" + onClick={() => { + closeGenerator() + router.push('/tools') + }} + > + {t(($) => $['workflowGenerator.errors.installTools'])} + </Button> + )} + </div> + </div> + ) : current?.graph?.nodes?.length ? ( + <div className="flex h-full w-0 grow flex-col bg-background-default-subtle p-6"> + {/* Planner-picked identity — surfaces the app_name + icon the UI used to discard so the user sees what they'll create. */} - {(current.icon || current.app_name) && ( - <div className="mb-2 flex items-center gap-2"> - {current.icon && <span className="text-lg leading-none">{current.icon}</span>} - {current.app_name && ( - <span className="truncate text-sm font-semibold text-text-primary">{current.app_name}</span> - )} - </div> - )} - <div className="mb-3 flex items-center justify-between"> - <VersionSelector - versionLen={versions?.length || 0} - value={currentVersionIndex || 0} - onChange={setCurrentVersionIndex} - /> - <div className="flex items-center space-x-2"> - {canApplyToCurrent - ? ( - // Studio button entry — overwrite the current draft - // is the only meaningful Apply action, so collapse - // the two buttons into one primary "Apply". - <Button - size="small" - variant="primary" - onClick={showConfirmOverwrite} - disabled={isApplying} - > - {t($ => $['workflowGenerator.studioApply'])} - </Button> - ) - : ( - // cmd+k /create entry — no current-app context, so - // the only path is "Create new app". - <Button - size="small" - variant="primary" - onClick={handleApplyToNew} - disabled={isApplying} - > - {t($ => $['workflowGenerator.applyToNew'])} - </Button> - )} - </div> - </div> - <div className="relative w-full grow overflow-hidden rounded-2xl border border-divider-subtle bg-background-default"> - <WorkflowPreview - nodes={current.graph.nodes} - edges={current.graph.edges} - viewport={current.graph.viewport} - miniMapToRight - /> - </div> - {/* Refine diff — what an apply changes vs. the draft we started from. */} - {hasRefineChanges && refineDiff && ( - <div className="mt-2 system-xs-regular text-text-tertiary"> - {t($ => $['workflowGenerator.diff.summary'], { - added: refineDiff.added.length, - removed: refineDiff.removed.length, - changed: refineDiff.changed.length, - })} - </div> - )} - {current.message && ( - <div className="mt-2 system-xs-regular text-text-tertiary"> - {current.message} - </div> - )} - </div> - ) - : renderPlaceholder(t($ => $['workflowGenerator.placeholder']))} + {(current.icon || current.app_name) && ( + <div className="mb-2 flex items-center gap-2"> + {current.icon && <span className="text-lg leading-none">{current.icon}</span>} + {current.app_name && ( + <span className="truncate text-sm font-semibold text-text-primary"> + {current.app_name} + </span> + )} + </div> + )} + <div className="mb-3 flex items-center justify-between"> + <VersionSelector + versionLen={versions?.length || 0} + value={currentVersionIndex || 0} + onChange={setCurrentVersionIndex} + /> + <div className="flex items-center space-x-2"> + {canApplyToCurrent ? ( + // Studio button entry — overwrite the current draft + // is the only meaningful Apply action, so collapse + // the two buttons into one primary "Apply". + <Button + size="small" + variant="primary" + onClick={showConfirmOverwrite} + disabled={isApplying} + > + {t(($) => $['workflowGenerator.studioApply'])} + </Button> + ) : ( + // cmd+k /create entry — no current-app context, so + // the only path is "Create new app". + <Button + size="small" + variant="primary" + onClick={handleApplyToNew} + disabled={isApplying} + > + {t(($) => $['workflowGenerator.applyToNew'])} + </Button> + )} + </div> + </div> + <div className="relative w-full grow overflow-hidden rounded-2xl border border-divider-subtle bg-background-default"> + <WorkflowPreview + nodes={current.graph.nodes} + edges={current.graph.edges} + viewport={current.graph.viewport} + miniMapToRight + /> + </div> + {/* Refine diff — what an apply changes vs. the draft we started from. */} + {hasRefineChanges && refineDiff && ( + <div className="mt-2 system-xs-regular text-text-tertiary"> + {t(($) => $['workflowGenerator.diff.summary'], { + added: refineDiff.added.length, + removed: refineDiff.removed.length, + changed: refineDiff.changed.length, + })} + </div> + )} + {current.message && ( + <div className="mt-2 system-xs-regular text-text-tertiary">{current.message}</div> + )} + </div> + ) : ( + renderPlaceholder(t(($) => $['workflowGenerator.placeholder'])) + )} </div> <RecoveryDialog open={isShowConfirmOverwrite} onOpenChange={() => hideConfirmOverwrite()} - title={t($ => $['workflowGenerator.overwriteTitle'])} - description={t($ => $['workflowGenerator.overwriteMessage'])} - cancelLabel={t($ => $['operation.cancel'], { ns: 'common' })} - confirmLabel={t($ => $['operation.confirm'], { ns: 'common' })} + title={t(($) => $['workflowGenerator.overwriteTitle'])} + description={t(($) => $['workflowGenerator.overwriteMessage'])} + cancelLabel={t(($) => $['operation.cancel'], { ns: 'common' })} + confirmLabel={t(($) => $['operation.confirm'], { ns: 'common' })} onConfirm={handleApplyToCurrentConfirmed} /> @@ -746,14 +803,13 @@ const WorkflowGeneratorModal: React.FC = () => { <RecoveryDialog open={isShowHashCollision} onOpenChange={() => hideHashCollision()} - title={t($ => $['workflowGenerator.errors.hash_collision_title'])} - description={t($ => $['workflowGenerator.errors.hash_collision'])} - cancelLabel={t($ => $['operation.cancel'], { ns: 'common' })} - confirmLabel={t($ => $['workflowGenerator.reload'])} + title={t(($) => $['workflowGenerator.errors.hash_collision_title'])} + description={t(($) => $['workflowGenerator.errors.hash_collision'])} + cancelLabel={t(($) => $['operation.cancel'], { ns: 'common' })} + confirmLabel={t(($) => $['workflowGenerator.reload'])} onConfirm={() => { hideHashCollision() - if (typeof window !== 'undefined') - window.location.reload() + if (typeof window !== 'undefined') window.location.reload() }} /> </DialogContent> diff --git a/web/app/components/workflow/workflow-generator/mount.tsx b/web/app/components/workflow/workflow-generator/mount.tsx index 5f56a18503467c..30d56fdb76f7bd 100644 --- a/web/app/components/workflow/workflow-generator/mount.tsx +++ b/web/app/components/workflow/workflow-generator/mount.tsx @@ -13,9 +13,8 @@ const WorkflowGeneratorModal = dynamic(() => import('./index'), { ssr: false }) * zustand store flips ``isOpen`` to true. */ const WorkflowGeneratorMount: React.FC = () => { - const isOpen = useWorkflowGeneratorStore(s => s.isOpen) - if (!isOpen) - return null + const isOpen = useWorkflowGeneratorStore((s) => s.isOpen) + if (!isOpen) return null return <WorkflowGeneratorModal /> } diff --git a/web/app/components/workflow/workflow-generator/storage.ts b/web/app/components/workflow/workflow-generator/storage.ts index 5b197845f82c94..203db56d67552e 100644 --- a/web/app/components/workflow/workflow-generator/storage.ts +++ b/web/app/components/workflow/workflow-generator/storage.ts @@ -9,11 +9,8 @@ export const EMPTY_WORKFLOW_GENERATOR_MODEL: Model = { completion_params: {} as CompletionParams, } -const [ - useWorkflowGeneratorModel, - _useWorkflowGeneratorModelValue, - _useSetWorkflowGeneratorModel, -] = createLocalStorageState<Model>('workflow-gen-model', EMPTY_WORKFLOW_GENERATOR_MODEL) +const [useWorkflowGeneratorModel, _useWorkflowGeneratorModelValue, _useSetWorkflowGeneratorModel] = + createLocalStorageState<Model>('workflow-gen-model', EMPTY_WORKFLOW_GENERATOR_MODEL) // Last instruction the user generated from, persisted across opens so reopening // the generator resumes where they left off instead of a blank box (the palette's @@ -24,7 +21,4 @@ const [ _useSetWorkflowGeneratorLastInstruction, ] = createLocalStorageState<string>('workflow-gen-last-instruction', '') -export { - useWorkflowGeneratorLastInstruction, - useWorkflowGeneratorModel, -} +export { useWorkflowGeneratorLastInstruction, useWorkflowGeneratorModel } diff --git a/web/app/components/workflow/workflow-generator/store.ts b/web/app/components/workflow/workflow-generator/store.ts index bc8f293dddc758..9be8d17ebfb6cc 100644 --- a/web/app/components/workflow/workflow-generator/store.ts +++ b/web/app/components/workflow/workflow-generator/store.ts @@ -36,20 +36,18 @@ type WorkflowGeneratorStore = { * the versions they were comparing. */ const resetNewAppHistory = (mode: WorkflowGeneratorMode) => { - if (typeof window === 'undefined') - return + if (typeof window === 'undefined') return const storageKey = `${mode}-new` try { sessionStorage.removeItem(`workflow-gen-${storageKey}-versions`) sessionStorage.removeItem(`workflow-gen-${storageKey}-version-index`) - } - catch { + } catch { // sessionStorage can throw in privacy-restricted contexts; the stale // state will still flush on tab close, so swallowing here is fine. } } -export const useWorkflowGeneratorStore = create<WorkflowGeneratorStore>(set => ({ +export const useWorkflowGeneratorStore = create<WorkflowGeneratorStore>((set) => ({ isOpen: false, mode: 'workflow', intent: 'create', @@ -57,9 +55,15 @@ export const useWorkflowGeneratorStore = create<WorkflowGeneratorStore>(set => ( currentAppMode: null, initialInstruction: '', autoMode: false, - openGenerator: ({ mode, intent = 'create', currentAppId = null, currentAppMode = null, initialInstruction = '', autoMode = false }) => { - if (!currentAppId) - resetNewAppHistory(mode) + openGenerator: ({ + mode, + intent = 'create', + currentAppId = null, + currentAppMode = null, + initialInstruction = '', + autoMode = false, + }) => { + if (!currentAppId) resetNewAppHistory(mode) set({ isOpen: true, mode, intent, currentAppId, currentAppMode, initialInstruction, autoMode }) }, closeGenerator: () => set({ isOpen: false }), diff --git a/web/app/components/workflow/workflow-generator/use-gen-graph.ts b/web/app/components/workflow/workflow-generator/use-gen-graph.ts index f8fe8e8a8a4a9e..b721fa5b867b36 100644 --- a/web/app/components/workflow/workflow-generator/use-gen-graph.ts +++ b/web/app/components/workflow/workflow-generator/use-gen-graph.ts @@ -46,14 +46,17 @@ const useGenGraph = ({ storageKey }: Params) => { versionCountRef.current = versions?.length ?? 0 }, [versions]) - const addVersion = useCallback((version: GenerateWorkflowResponse) => { - const nextCount = Math.min(versionCountRef.current + 1, MAX_VERSIONS) - versionCountRef.current = nextCount - // Functional update so batched adds append instead of clobbering each - // other; the slice keeps the retained history under the cap. - setVersions(prev => [...(prev ?? []), version].slice(-MAX_VERSIONS)) - setCurrentVersionIndex(nextCount - 1) - }, [setVersions, setCurrentVersionIndex]) + const addVersion = useCallback( + (version: GenerateWorkflowResponse) => { + const nextCount = Math.min(versionCountRef.current + 1, MAX_VERSIONS) + versionCountRef.current = nextCount + // Functional update so batched adds append instead of clobbering each + // other; the slice keeps the retained history under the cap. + setVersions((prev) => [...(prev ?? []), version].slice(-MAX_VERSIONS)) + setCurrentVersionIndex(nextCount - 1) + }, + [setVersions, setCurrentVersionIndex], + ) return { versions, diff --git a/web/app/components/workflow/workflow-history-store.ts b/web/app/components/workflow/workflow-history-store.ts index 83b32ed3e9e654..377c2b462f30eb 100644 --- a/web/app/components/workflow/workflow-history-store.ts +++ b/web/app/components/workflow/workflow-history-store.ts @@ -1,7 +1,5 @@ import type { TemporalState } from 'zundo' -import type { - WorkflowHistoryState, -} from './store/workflow/history-slice' +import type { WorkflowHistoryState } from './store/workflow/history-slice' import type { Edge, Node } from './types' import { use, useMemo } from 'react' import { WorkflowContext } from './context' @@ -32,10 +30,13 @@ const sanitizeWorkflowHistory = (state: WorkflowHistoryState): WorkflowHistorySt selected: false, }, })), - edges: state.edges.map((edge: Edge) => ({ - ...edge, - selected: false, - }) as Edge), + edges: state.edges.map( + (edge: Edge) => + ({ + ...edge, + selected: false, + }) as Edge, + ), }) const toHistoryState = ( @@ -55,44 +56,46 @@ const toTemporalState = ( isTracking: temporalState.isTracking, pause: temporalState.pause, resume: temporalState.resume, - setOnSave: onSave => temporalState.setOnSave( - onSave - ? (pastState, currentState) => { - onSave( - toHistoryState(pastState) as WorkflowHistoryState, - toHistoryState(currentState) as WorkflowHistoryState, - ) - } - : undefined, - ), + setOnSave: (onSave) => + temporalState.setOnSave( + onSave + ? (pastState, currentState) => { + onSave( + toHistoryState(pastState) as WorkflowHistoryState, + toHistoryState(currentState) as WorkflowHistoryState, + ) + } + : undefined, + ), }) export function useWorkflowHistoryStore() { const workflowStore = use(WorkflowContext) - if (!workflowStore) - throw new Error('Missing WorkflowContext.Provider in the tree') + if (!workflowStore) throw new Error('Missing WorkflowContext.Provider in the tree') return { store: useMemo( - () => ({ - getState: () => workflowStore.getState().workflowHistory, - setState: (state: WorkflowHistoryState) => { - workflowStore.getState().setWorkflowHistory(sanitizeWorkflowHistory(state)) - }, - subscribe: (listener: (state: WorkflowHistoryState) => void) => { - return workflowStore.subscribe((state, previousState) => { - if (state.workflowHistory !== previousState.workflowHistory) - listener(state.workflowHistory) - }) - }, - temporal: { - getState: () => toTemporalState(workflowStore.temporal.getState()), - subscribe: listener => workflowStore.temporal.subscribe((state) => { - listener(toTemporalState(state)) - }), - }, - }) satisfies WorkflowHistoryStore, + () => + ({ + getState: () => workflowStore.getState().workflowHistory, + setState: (state: WorkflowHistoryState) => { + workflowStore.getState().setWorkflowHistory(sanitizeWorkflowHistory(state)) + }, + subscribe: (listener: (state: WorkflowHistoryState) => void) => { + return workflowStore.subscribe((state, previousState) => { + if (state.workflowHistory !== previousState.workflowHistory) + listener(state.workflowHistory) + }) + }, + temporal: { + getState: () => toTemporalState(workflowStore.temporal.getState()), + subscribe: (listener) => + workflowStore.temporal.subscribe((state) => { + listener(toTemporalState(state)) + }), + }, + }) satisfies WorkflowHistoryStore, [workflowStore], ), } diff --git a/web/app/components/workflow/workflow-preview/__tests__/index.spec.tsx b/web/app/components/workflow/workflow-preview/__tests__/index.spec.tsx index 1d8c831875b8be..e02af88579fce9 100644 --- a/web/app/components/workflow/workflow-preview/__tests__/index.spec.tsx +++ b/web/app/components/workflow/workflow-preview/__tests__/index.spec.tsx @@ -30,12 +30,7 @@ describe('WorkflowPreview', () => { it('should move the minimap to the right when requested', async () => { const { container } = render( <div style={{ width: 800, height: 600 }}> - <WorkflowPreview - nodes={[]} - edges={[]} - viewport={defaultViewport} - miniMapToRight - /> + <WorkflowPreview nodes={[]} edges={[]} viewport={defaultViewport} miniMapToRight /> </div>, ) diff --git a/web/app/components/workflow/workflow-preview/components/__tests__/custom-edge.spec.tsx b/web/app/components/workflow/workflow-preview/components/__tests__/custom-edge.spec.tsx index f6e54b2ba09aa3..8aeaad078069bb 100644 --- a/web/app/components/workflow/workflow-preview/components/__tests__/custom-edge.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/__tests__/custom-edge.spec.tsx @@ -84,6 +84,9 @@ describe('workflow preview custom edge', () => { </svg>, ) - expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-handle)') + expect(screen.getByTestId('base-edge')).toHaveAttribute( + 'data-stroke', + 'var(--color-workflow-link-line-handle)', + ) }) }) diff --git a/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx b/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx index 83e964c864d513..c75eec15dee9a3 100644 --- a/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx @@ -22,11 +22,13 @@ const ErrorNode = ({ id, data }: NodeProps<CommonNodeType>) => ( const renderErrorNode = (data: CommonNodeType) => renderWorkflowFlowComponent(<div />, { - nodes: [createNode({ - id: 'node-1', - type: 'errorNode', - data, - })], + nodes: [ + createNode({ + id: 'node-1', + type: 'errorNode', + data, + }), + ], edges: [], reactFlowProps: { nodeTypes: { errorNode: ErrorNode }, @@ -48,28 +50,47 @@ describe('ErrorHandleOnNode', () => { await waitFor(() => expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()) expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output')).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output'), + ).toBeInTheDocument() }) }) // Fail-branch behavior and warning styling. describe('Effects', () => { it('should render the fail-branch source handle', async () => { - const { container } = renderErrorNode(createNodeData({ error_strategy: ErrorHandleTypeEnum.failBranch })) + const { container } = renderErrorNode( + createNodeData({ error_strategy: ErrorHandleTypeEnum.failBranch }), + ) - await waitFor(() => expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument()) - expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument() - expect(container.querySelector('.react-flow__handle')).toHaveAttribute('data-handleid', ErrorHandleTypeEnum.failBranch) + await waitFor(() => + expect( + screen.getByText('workflow.nodes.common.errorHandle.failBranch.title'), + ).toBeInTheDocument(), + ) + expect( + screen.getByText('workflow.nodes.common.errorHandle.failBranch.title'), + ).toBeInTheDocument() + expect(container.querySelector('.react-flow__handle')).toHaveAttribute( + 'data-handleid', + ErrorHandleTypeEnum.failBranch, + ) }) it('should add warning styles when the node is in exception status', async () => { - const { container } = renderErrorNode(createNodeData({ - error_strategy: ErrorHandleTypeEnum.defaultValue, - _runningStatus: NodeRunningStatus.Exception, - })) + const { container } = renderErrorNode( + createNodeData({ + error_strategy: ErrorHandleTypeEnum.defaultValue, + _runningStatus: NodeRunningStatus.Exception, + }), + ) - await waitFor(() => expect(container.querySelector('.bg-state-warning-hover')).toBeInTheDocument()) - expect(container.querySelector('.bg-state-warning-hover')).toHaveClass('border-components-badge-status-light-warning-halo') + await waitFor(() => + expect(container.querySelector('.bg-state-warning-hover')).toBeInTheDocument(), + ) + expect(container.querySelector('.bg-state-warning-hover')).toHaveClass( + 'border-components-badge-status-light-warning-halo', + ) expect(container.querySelector('.text-text-warning')).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx b/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx index a783523929628b..2130a851829abf 100644 --- a/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx @@ -15,33 +15,25 @@ const createNodeData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType const TargetHandleNode = ({ id, data }: NodeProps<CommonNodeType>) => ( <div> - <NodeTargetHandle - id={id} - data={data} - handleId="target-1" - handleClassName="target-marker" - /> + <NodeTargetHandle id={id} data={data} handleId="target-1" handleClassName="target-marker" /> </div> ) const SourceHandleNode = ({ id, data }: NodeProps<CommonNodeType>) => ( <div> - <NodeSourceHandle - id={id} - data={data} - handleId="source-1" - handleClassName="source-marker" - /> + <NodeSourceHandle id={id} data={data} handleId="source-1" handleClassName="source-marker" /> </div> ) const renderFlowNode = (type: 'targetNode' | 'sourceNode', data: CommonNodeType) => renderWorkflowFlowComponent(<div />, { - nodes: [createNode({ - id: 'node-1', - type, - data, - })], + nodes: [ + createNode({ + id: 'node-1', + type, + data, + }), + ], edges: [], reactFlowProps: { nodeTypes: { @@ -67,11 +59,13 @@ describe('node-handle', () => { it('should merge custom classes and hide start-like nodes completely', async () => { const { container } = renderWorkflowFlowComponent(<div />, { - nodes: [createNode({ - id: 'node-2', - type: 'targetNode', - data: createNodeData({ type: BlockEnum.Start }), - })], + nodes: [ + createNode({ + id: 'node-2', + type: 'targetNode', + data: createNodeData({ type: BlockEnum.Start }), + }), + ], edges: [], reactFlowProps: { nodeTypes: { @@ -101,7 +95,10 @@ describe('node-handle', () => { // Source handle connection state. describe('NodeSourceHandle', () => { it('should keep the source indicator visible when the handle is connected', async () => { - const { container } = renderFlowNode('sourceNode', createNodeData({ _connectedSourceHandleIds: ['source-1'] })) + const { container } = renderFlowNode( + 'sourceNode', + createNodeData({ _connectedSourceHandleIds: ['source-1'] }), + ) await waitFor(() => expect(container.querySelector('.source-marker')).toBeInTheDocument()) diff --git a/web/app/components/workflow/workflow-preview/components/__tests__/zoom-in-out.spec.tsx b/web/app/components/workflow/workflow-preview/components/__tests__/zoom-in-out.spec.tsx index 6e8ad909337787..983e9db6bc3bca 100644 --- a/web/app/components/workflow/workflow-preview/components/__tests__/zoom-in-out.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/__tests__/zoom-in-out.spec.tsx @@ -1,13 +1,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react' import ZoomInOut from '../zoom-in-out' -const { - mockZoomIn, - mockZoomOut, - mockZoomTo, - mockFitView, - mockViewport, -} = vi.hoisted(() => ({ +const { mockZoomIn, mockZoomOut, mockZoomTo, mockFitView, mockViewport } = vi.hoisted(() => ({ mockZoomIn: vi.fn(), mockZoomOut: vi.fn(), mockZoomTo: vi.fn(), @@ -32,8 +26,7 @@ const getZoomControls = () => { const zoomOutIcon = document.querySelector('.i-ri-zoom-out-line') const zoomInIcon = document.querySelector('.i-ri-zoom-in-line') - if (!label || !zoomOutIcon || !zoomInIcon) - throw new Error('Missing zoom controls') + if (!label || !zoomOutIcon || !zoomInIcon) throw new Error('Missing zoom controls') return { zoomOutTrigger: zoomOutIcon.parentElement as HTMLElement, diff --git a/web/app/components/workflow/workflow-preview/components/custom-edge.tsx b/web/app/components/workflow/workflow-preview/components/custom-edge.tsx index 6294d3f88ae390..4dc9a7450d0f6d 100644 --- a/web/app/components/workflow/workflow-preview/components/custom-edge.tsx +++ b/web/app/components/workflow/workflow-preview/components/custom-edge.tsx @@ -1,13 +1,6 @@ import type { EdgeProps } from 'reactflow' -import { - memo, - useMemo, -} from 'react' -import { - BaseEdge, - getBezierPath, - Position, -} from 'reactflow' +import { memo, useMemo } from 'react' +import { BaseEdge, getBezierPath, Position } from 'reactflow' import CustomEdgeLinearGradientRender from '@/app/components/workflow/custom-edge-linear-gradient-render' import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' import { NodeRunningStatus } from '@/app/components/workflow/types' @@ -23,9 +16,7 @@ const CustomEdge = ({ targetY, selected, }: EdgeProps) => { - const [ - edgePath, - ] = getBezierPath({ + const [edgePath] = getBezierPath({ sourceX: sourceX - 8, sourceY, sourcePosition: Position.Right, @@ -34,58 +25,51 @@ const CustomEdge = ({ targetPosition: Position.Left, curvature: 0.16, }) - const { - _sourceRunningStatus, - _targetRunningStatus, - } = data + const { _sourceRunningStatus, _targetRunningStatus } = data const linearGradientId = useMemo(() => { if ( - ( - _sourceRunningStatus === NodeRunningStatus.Succeeded - || _sourceRunningStatus === NodeRunningStatus.Failed - || _sourceRunningStatus === NodeRunningStatus.Exception - ) && ( - _targetRunningStatus === NodeRunningStatus.Succeeded - || _targetRunningStatus === NodeRunningStatus.Failed - || _targetRunningStatus === NodeRunningStatus.Exception - || _targetRunningStatus === NodeRunningStatus.Running - ) + (_sourceRunningStatus === NodeRunningStatus.Succeeded || + _sourceRunningStatus === NodeRunningStatus.Failed || + _sourceRunningStatus === NodeRunningStatus.Exception) && + (_targetRunningStatus === NodeRunningStatus.Succeeded || + _targetRunningStatus === NodeRunningStatus.Failed || + _targetRunningStatus === NodeRunningStatus.Exception || + _targetRunningStatus === NodeRunningStatus.Running) ) { return id } }, [_sourceRunningStatus, _targetRunningStatus, id]) const stroke = useMemo(() => { - if (selected) - return getEdgeColor(NodeRunningStatus.Running) + if (selected) return getEdgeColor(NodeRunningStatus.Running) - if (linearGradientId) - return `url(#${linearGradientId})` + if (linearGradientId) return `url(#${linearGradientId})` if (data?._connectedNodeIsHovering) - return getEdgeColor(NodeRunningStatus.Running, sourceHandleId === ErrorHandleTypeEnum.failBranch) + return getEdgeColor( + NodeRunningStatus.Running, + sourceHandleId === ErrorHandleTypeEnum.failBranch, + ) return getEdgeColor() }, [data._connectedNodeIsHovering, linearGradientId, selected, sourceHandleId]) return ( <> - { - linearGradientId && ( - <CustomEdgeLinearGradientRender - id={linearGradientId} - startColor={getEdgeColor(_sourceRunningStatus)} - stopColor={getEdgeColor(_targetRunningStatus)} - position={{ - x1: sourceX, - y1: sourceY, - x2: targetX, - y2: targetY, - }} - /> - ) - } + {linearGradientId && ( + <CustomEdgeLinearGradientRender + id={linearGradientId} + startColor={getEdgeColor(_sourceRunningStatus)} + stopColor={getEdgeColor(_targetRunningStatus)} + position={{ + x1: sourceX, + y1: sourceY, + x2: targetX, + y2: targetY, + }} + /> + )} <BaseEdge id={id} path={edgePath} diff --git a/web/app/components/workflow/workflow-preview/components/error-handle-on-node.tsx b/web/app/components/workflow/workflow-preview/components/error-handle-on-node.tsx index 658d636d851137..35e2b1fecb14d5 100644 --- a/web/app/components/workflow/workflow-preview/components/error-handle-on-node.tsx +++ b/web/app/components/workflow/workflow-preview/components/error-handle-on-node.tsx @@ -8,58 +8,48 @@ import { NodeRunningStatus } from '@/app/components/workflow/types' import { NodeSourceHandle } from './node-handle' type ErrorHandleOnNodeProps = Pick<Node, 'id' | 'data'> -const ErrorHandleOnNode = ({ - id, - data, -}: ErrorHandleOnNodeProps) => { +const ErrorHandleOnNode = ({ id, data }: ErrorHandleOnNodeProps) => { const { t } = useTranslation() const { error_strategy } = data const updateNodeInternals = useUpdateNodeInternals() useEffect(() => { - if (error_strategy === ErrorHandleTypeEnum.failBranch) - updateNodeInternals(id) + if (error_strategy === ErrorHandleTypeEnum.failBranch) updateNodeInternals(id) }, [error_strategy, id, updateNodeInternals]) - if (!error_strategy) - return null + if (!error_strategy) return null return ( <div className="relative px-3 pt-1 pb-2"> - <div className={cn( - 'relative flex h-6 items-center justify-between rounded-md bg-workflow-block-parma-bg px-[5px]', - data._runningStatus === NodeRunningStatus.Exception && 'border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover', - )} + <div + className={cn( + 'relative flex h-6 items-center justify-between rounded-md bg-workflow-block-parma-bg px-[5px]', + data._runningStatus === NodeRunningStatus.Exception && + 'border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover', + )} > <div className="system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['common.onFailure'], { ns: 'workflow' })} + {t(($) => $['common.onFailure'], { ns: 'workflow' })} </div> - <div className={cn( - 'system-xs-medium text-text-secondary', - data._runningStatus === NodeRunningStatus.Exception && 'text-text-warning', - )} + <div + className={cn( + 'system-xs-medium text-text-secondary', + data._runningStatus === NodeRunningStatus.Exception && 'text-text-warning', + )} > - { - error_strategy === ErrorHandleTypeEnum.defaultValue && ( - t($ => $['nodes.common.errorHandle.defaultValue.output'], { ns: 'workflow' }) - ) - } - { - error_strategy === ErrorHandleTypeEnum.failBranch && ( - t($ => $['nodes.common.errorHandle.failBranch.title'], { ns: 'workflow' }) - ) - } + {error_strategy === ErrorHandleTypeEnum.defaultValue && + t(($) => $['nodes.common.errorHandle.defaultValue.output'], { ns: 'workflow' })} + {error_strategy === ErrorHandleTypeEnum.failBranch && + t(($) => $['nodes.common.errorHandle.failBranch.title'], { ns: 'workflow' })} </div> - { - error_strategy === ErrorHandleTypeEnum.failBranch && ( - <NodeSourceHandle - id={id} - data={data} - handleId={ErrorHandleTypeEnum.failBranch} - handleClassName="top-1/2! -right-[21px]! -translate-y-1/2! after:bg-workflow-link-line-failure-button-bg!" - /> - ) - } + {error_strategy === ErrorHandleTypeEnum.failBranch && ( + <NodeSourceHandle + id={id} + data={data} + handleId={ErrorHandleTypeEnum.failBranch} + handleClassName="top-1/2! -right-[21px]! -translate-y-1/2! after:bg-workflow-link-line-failure-button-bg!" + /> + )} </div> </div> ) diff --git a/web/app/components/workflow/workflow-preview/components/node-handle.tsx b/web/app/components/workflow/workflow-preview/components/node-handle.tsx index 41b932ab93ebeb..c0029d4de1e37b 100644 --- a/web/app/components/workflow/workflow-preview/components/node-handle.tsx +++ b/web/app/components/workflow/workflow-preview/components/node-handle.tsx @@ -1,26 +1,15 @@ import type { Node } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, -} from 'react' -import { - Handle, - Position, -} from 'reactflow' -import { - BlockEnum, -} from '@/app/components/workflow/types' +import { memo } from 'react' +import { Handle, Position } from 'reactflow' +import { BlockEnum } from '@/app/components/workflow/types' type NodeHandleProps = { handleId: string handleClassName?: string } & Pick<Node, 'id' | 'data'> -export const NodeTargetHandle = memo(({ - data, - handleId, - handleClassName, -}: NodeHandleProps) => { +export const NodeTargetHandle = memo(({ data, handleId, handleClassName }: NodeHandleProps) => { const connected = data._connectedTargetHandleIds?.includes(handleId) return ( @@ -34,24 +23,20 @@ export const NodeTargetHandle = memo(({ 'after:absolute after:top-1 after:left-1.5 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', 'transition-all hover:scale-125', !connected && 'after:opacity-0', - (data.type === BlockEnum.Start - || data.type === BlockEnum.TriggerWebhook - || data.type === BlockEnum.TriggerSchedule - || data.type === BlockEnum.TriggerPlugin) && 'opacity-0', + (data.type === BlockEnum.Start || + data.type === BlockEnum.TriggerWebhook || + data.type === BlockEnum.TriggerSchedule || + data.type === BlockEnum.TriggerPlugin) && + 'opacity-0', handleClassName, )} - > - </Handle> + ></Handle> </> ) }) NodeTargetHandle.displayName = 'NodeTargetHandle' -export const NodeSourceHandle = memo(({ - data, - handleId, - handleClassName, -}: NodeHandleProps) => { +export const NodeSourceHandle = memo(({ data, handleId, handleClassName }: NodeHandleProps) => { const connected = data._connectedSourceHandleIds?.includes(handleId) return ( @@ -66,8 +51,7 @@ export const NodeSourceHandle = memo(({ !connected && 'after:opacity-0', handleClassName, )} - > - </Handle> + ></Handle> ) }) NodeSourceHandle.displayName = 'NodeSourceHandle' diff --git a/web/app/components/workflow/workflow-preview/components/nodes/__tests__/base.spec.tsx b/web/app/components/workflow/workflow-preview/components/nodes/__tests__/base.spec.tsx index 6913fffd504a72..6b2c52ff0e9949 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/__tests__/base.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/__tests__/base.spec.tsx @@ -4,8 +4,13 @@ import { BlockEnum } from '@/app/components/workflow/types' import BaseCard from '../base' vi.mock('reactflow', () => ({ - Handle: (props: { id: string, type: string, className?: string }) => ( - <div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} /> + Handle: (props: { id: string; type: string; className?: string }) => ( + <div + data-testid="handle" + data-handleid={props.id} + data-type={props.type} + className={props.className} + /> ), Position: { Left: 'left', @@ -22,11 +27,13 @@ describe('workflow preview base node card', () => { render( <BaseCard id="node-1" - data={{ - type: BlockEnum.Answer, - title: 'Answer node', - desc: 'This is a preview node', - } as never} + data={ + { + type: BlockEnum.Answer, + title: 'Answer node', + desc: 'This is a preview node', + } as never + } > <ChildNode /> </BaseCard>, @@ -43,14 +50,16 @@ describe('workflow preview base node card', () => { render( <BaseCard id="iteration-1" - data={{ - type: BlockEnum.Iteration, - title: 'Iteration node', - desc: 'Ignored description', - width: 360, - height: 220, - is_parallel: true, - } as never} + data={ + { + type: BlockEnum.Iteration, + title: 'Iteration node', + desc: 'Ignored description', + width: 360, + height: 220, + is_parallel: true, + } as never + } > <ChildNode /> </BaseCard>, diff --git a/web/app/components/workflow/workflow-preview/components/nodes/__tests__/index.spec.tsx b/web/app/components/workflow/workflow-preview/components/nodes/__tests__/index.spec.tsx index 44cfeed0b13d00..2604ecbd1f3b2a 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/__tests__/index.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/__tests__/index.spec.tsx @@ -4,8 +4,13 @@ import { BlockEnum } from '@/app/components/workflow/types' import CustomNode from '../index' vi.mock('reactflow', () => ({ - Handle: (props: { id: string, type: string, className?: string }) => ( - <div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} /> + Handle: (props: { id: string; type: string; className?: string }) => ( + <div + data-testid="handle" + data-handleid={props.id} + data-type={props.type} + className={props.className} + /> ), Position: { Left: 'left', @@ -29,15 +34,11 @@ describe('workflow preview custom node', () => { type: BlockEnum.QuestionClassifier, title: 'Classifier node', desc: '', - classes: [ - { id: 'class-a', name: 'Billing' }, - ], + classes: [{ id: 'class-a', name: 'Billing' }], } as never, } - const { container, getByText } = render( - <CustomNode {...props} />, - ) + const { container, getByText } = render(<CustomNode {...props} />) expect(getByText('Classifier node')).toBeInTheDocument() expect(container.querySelector('[data-handleid="class-a"]')).toBeInTheDocument() @@ -69,9 +70,7 @@ describe('workflow preview custom node', () => { } as never, } - const { container, getByText } = render( - <CustomNode {...props} />, - ) + const { container, getByText } = render(<CustomNode {...props} />) expect(getByText('Human Input')).toBeInTheDocument() expect(container.querySelector('[data-handleid="approve"]')).toBeInTheDocument() diff --git a/web/app/components/workflow/workflow-preview/components/nodes/base.tsx b/web/app/components/workflow/workflow-preview/components/nodes/base.tsx index 8bd1dc88c6827d..f5980ff9c33f9d 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/base.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/base.tsx @@ -1,27 +1,15 @@ -import type { - ReactElement, -} from 'react' +import type { ReactElement } from 'react' import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types' -import type { - NodeProps, -} from '@/app/components/workflow/types' +import type { NodeProps } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { - cloneElement, - memo, -} from 'react' +import { cloneElement, memo } from 'react' import { useTranslation } from 'react-i18next' import BlockIcon from '@/app/components/workflow/block-icon' -import { - BlockEnum, -} from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' import { hasErrorHandleNode } from '@/app/components/workflow/utils' import ErrorHandleOnNode from '../error-handle-on-node' -import { - NodeSourceHandle, - NodeTargetHandle, -} from '../node-handle' +import { NodeSourceHandle, NodeTargetHandle } from '../node-handle' type NodeChildElement = ReactElement<Partial<NodeProps>> @@ -29,21 +17,17 @@ type NodeCardProps = NodeProps & { children?: NodeChildElement } -const BaseCard = ({ - id, - data, - children, -}: NodeCardProps) => { +const BaseCard = ({ id, data, children }: NodeCardProps) => { const { t } = useTranslation() return ( <div - className={cn( - 'flex rounded-2xl border-2 border-transparent', - )} + className={cn('flex rounded-2xl border-2 border-transparent')} style={{ - width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : 'auto', - height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto', + width: + data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop ? data.width : 'auto', + height: + data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop ? data.height : 'auto', }} > <div @@ -53,90 +37,75 @@ const BaseCard = ({ 'bg-workflow-block-bg hover:shadow-lg', )} style={{ - width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : '240px', - height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto', + width: + data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop + ? data.width + : '240px', + height: + data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop + ? data.height + : 'auto', }} > - <div className={cn( - 'flex items-center rounded-t-2xl px-3 pt-3 pb-2', - )} - > + <div className={cn('flex items-center rounded-t-2xl px-3 pt-3 pb-2')}> <NodeTargetHandle id={id} data={data} handleClassName="top-4! -left-[9px]! translate-y-0!" handleId="target" /> - { - data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && data.type !== BlockEnum.HumanInput && ( + {data.type !== BlockEnum.IfElse && + data.type !== BlockEnum.QuestionClassifier && + data.type !== BlockEnum.HumanInput && ( <NodeSourceHandle id={id} data={data} handleClassName="top-4! -right-[9px]! translate-y-0!" handleId="source" /> - ) - } - <BlockIcon - className="mr-2 shrink-0" - type={data.type} - size="md" - /> + )} + <BlockIcon className="mr-2 shrink-0" type={data.type} size="md" /> <div title={data.title} className="mr-1 flex grow items-center truncate system-sm-semibold-uppercase text-text-primary" > - <div> - {data.title} - </div> - { - data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && ( - <Popover> - <PopoverTrigger - openOnHover - aria-label={t($ => $['nodes.iteration.parallelModeEnableTitle'], { ns: 'workflow' })} - className="ml-1 flex items-center justify-center rounded-[5px] border border-text-warning bg-transparent px-[5px] py-[3px] system-2xs-medium-uppercase text-text-warning" - > - {t($ => $['nodes.iteration.parallelModeUpper'], { ns: 'workflow' })} - </PopoverTrigger> - <PopoverContent popupClassName="w-[180px] px-3 py-2 system-xs-regular text-text-tertiary"> - <div className="font-extrabold"> - {t($ => $['nodes.iteration.parallelModeEnableTitle'], { ns: 'workflow' })} - </div> - {t($ => $['nodes.iteration.parallelModeEnableDesc'], { ns: 'workflow' })} - </PopoverContent> - </Popover> - ) - } + <div>{data.title}</div> + {data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && ( + <Popover> + <PopoverTrigger + openOnHover + aria-label={t(($) => $['nodes.iteration.parallelModeEnableTitle'], { + ns: 'workflow', + })} + className="ml-1 flex items-center justify-center rounded-[5px] border border-text-warning bg-transparent px-[5px] py-[3px] system-2xs-medium-uppercase text-text-warning" + > + {t(($) => $['nodes.iteration.parallelModeUpper'], { ns: 'workflow' })} + </PopoverTrigger> + <PopoverContent popupClassName="w-[180px] px-3 py-2 system-xs-regular text-text-tertiary"> + <div className="font-extrabold"> + {t(($) => $['nodes.iteration.parallelModeEnableTitle'], { ns: 'workflow' })} + </div> + {t(($) => $['nodes.iteration.parallelModeEnableDesc'], { ns: 'workflow' })} + </PopoverContent> + </Popover> + )} </div> </div> - { - data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && children && ( - cloneElement(children, { id, data }) - ) - } - { - (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && children && ( - <div className="h-[calc(100%-42px)] w-full grow pr-1 pb-1 pl-1"> - {cloneElement(children, { id, data })} - </div> - ) - } - { - hasErrorHandleNode(data.type) && ( - <ErrorHandleOnNode - id={id} - data={data} - /> - ) - } - { - data.desc && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && ( - <div className="px-3 pt-1 pb-2 system-xs-regular wrap-break-word whitespace-pre-line text-text-tertiary"> - {data.desc} - </div> - ) - } + {data.type !== BlockEnum.Iteration && + data.type !== BlockEnum.Loop && + children && + cloneElement(children, { id, data })} + {(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && children && ( + <div className="h-[calc(100%-42px)] w-full grow pr-1 pb-1 pl-1"> + {cloneElement(children, { id, data })} + </div> + )} + {hasErrorHandleNode(data.type) && <ErrorHandleOnNode id={id} data={data} />} + {data.desc && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && ( + <div className="px-3 pt-1 pb-2 system-xs-regular wrap-break-word whitespace-pre-line text-text-tertiary"> + {data.desc} + </div> + )} </div> </div> ) diff --git a/web/app/components/workflow/workflow-preview/components/nodes/human-input/__tests__/node.spec.tsx b/web/app/components/workflow/workflow-preview/components/nodes/human-input/__tests__/node.spec.tsx index 5bdb591198be05..5e90a8748ec005 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/human-input/__tests__/node.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/human-input/__tests__/node.spec.tsx @@ -4,8 +4,13 @@ import { BlockEnum } from '@/app/components/workflow/types' import Node from '../node' vi.mock('reactflow', () => ({ - Handle: (props: { id: string, type: string, className?: string }) => ( - <div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} /> + Handle: (props: { id: string; type: string; className?: string }) => ( + <div + data-testid="handle" + data-handleid={props.id} + data-type={props.type} + className={props.className} + /> ), Position: { Right: 'right', @@ -40,9 +45,7 @@ describe('workflow preview human input node', () => { } as never, } - const { container, getByText } = render( - <Node {...props} />, - ) + const { container, getByText } = render(<Node {...props} />) expect(getByText('approve')).toBeInTheDocument() expect(getByText('regenerate')).toBeInTheDocument() diff --git a/web/app/components/workflow/workflow-preview/components/nodes/human-input/node.tsx b/web/app/components/workflow/workflow-preview/components/nodes/human-input/node.tsx index 9248640d1e6ca5..341fc5153ff0a3 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/human-input/node.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/human-input/node.tsx @@ -8,9 +8,11 @@ function HumanInputNode(props: NodeProps<HumanInputNodeType>) { return ( <div className="space-y-0.5 px-3 py-1"> - {data.user_actions.map(userAction => ( + {data.user_actions.map((userAction) => ( <div key={userAction.id} className="relative flex h-6 flex-row-reverse items-center px-1"> - <span className="truncate system-xs-semibold-uppercase text-text-secondary">{userAction.id}</span> + <span className="truncate system-xs-semibold-uppercase text-text-secondary"> + {userAction.id} + </span> <NodeSourceHandle {...props} handleId={userAction.id} diff --git a/web/app/components/workflow/workflow-preview/components/nodes/if-else/__tests__/node.spec.tsx b/web/app/components/workflow/workflow-preview/components/nodes/if-else/__tests__/node.spec.tsx index 0cf2608dc1baaa..c28da5c0b8c554 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/if-else/__tests__/node.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/if-else/__tests__/node.spec.tsx @@ -3,8 +3,13 @@ import { BlockEnum } from '@/app/components/workflow/types' import Node from '../node' vi.mock('reactflow', () => ({ - Handle: (props: { id: string, type: string, className?: string }) => ( - <div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} /> + Handle: (props: { id: string; type: string; className?: string }) => ( + <div + data-testid="handle" + data-handleid={props.id} + data-type={props.type} + className={props.className} + /> ), Position: { Right: 'right', @@ -46,9 +51,7 @@ describe('workflow preview if-else node', () => { } as never, } - const { container, getByText } = render( - <Node {...props} />, - ) + const { container, getByText } = render(<Node {...props} />) expect(getByText('workflow.nodes.ifElse.conditionNotSetup')).toBeInTheDocument() expect(container.querySelector('[data-handleid="case-1"]')).toBeInTheDocument() diff --git a/web/app/components/workflow/workflow-preview/components/nodes/if-else/node.tsx b/web/app/components/workflow/workflow-preview/components/nodes/if-else/node.tsx index 01ff7655bf9df8..a60c8a1f6e5266 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/if-else/node.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/if-else/node.tsx @@ -17,82 +17,75 @@ const IfElseNode: FC<NodeProps<IfElseNodeType>> = (props) => { const { cases } = data const casesLength = cases.length const checkIsConditionSet = useCallback((condition: Condition) => { - if (!condition.variable_selector || condition.variable_selector.length === 0) - return false + if (!condition.variable_selector || condition.variable_selector.length === 0) return false if (condition.sub_variable_condition) { const isSet = condition.sub_variable_condition.conditions.every((c) => { - if (!c.comparison_operator) - return false + if (!c.comparison_operator) return false - if (isEmptyRelatedOperator(c.comparison_operator!)) - return true + if (isEmptyRelatedOperator(c.comparison_operator!)) return true return !!c.value }) return isSet - } - else { - if (isEmptyRelatedOperator(condition.comparison_operator!)) - return true + } else { + if (isEmptyRelatedOperator(condition.comparison_operator!)) return true return !!condition.value } }, []) const conditionNotSet = ( <div className="flex h-6 items-center space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary"> - {t($ => $[`${i18nPrefix}.conditionNotSetup`], { ns: 'workflow' })} + {t(($) => $[`${i18nPrefix}.conditionNotSetup`], { ns: 'workflow' })} </div> ) return ( <div className="px-3"> - { - cases.map((caseItem, index) => ( - <div key={caseItem.case_id}> - <div className="relative flex h-6 items-center px-1"> - <div className="flex w-full items-center justify-between"> - <div className="text-[10px] font-semibold text-text-tertiary"> - {casesLength > 1 && `CASE ${index + 1}`} - </div> - <div className="text-[12px] font-semibold text-text-secondary">{index === 0 ? 'IF' : 'ELIF'}</div> + {cases.map((caseItem, index) => ( + <div key={caseItem.case_id}> + <div className="relative flex h-6 items-center px-1"> + <div className="flex w-full items-center justify-between"> + <div className="text-[10px] font-semibold text-text-tertiary"> + {casesLength > 1 && `CASE ${index + 1}`} + </div> + <div className="text-[12px] font-semibold text-text-secondary"> + {index === 0 ? 'IF' : 'ELIF'} </div> - <NodeSourceHandle - {...props} - handleId={caseItem.case_id} - handleClassName="top-1/2! -right-[21px]! -translate-y-1/2!" - /> - </div> - <div className="space-y-0.5"> - {caseItem.conditions.map((condition, i) => ( - <div key={condition.id} className="relative"> - { - checkIsConditionSet(condition) - ? ( - (!isEmptyRelatedOperator(condition.comparison_operator!) && condition.sub_variable_condition) - ? ( - <ConditionFilesListValue condition={condition} /> - ) - : ( - <ConditionValue - variableSelector={condition.variable_selector!} - operator={condition.comparison_operator!} - value={condition.value} - /> - ) - - ) - : conditionNotSet - } - {i !== caseItem.conditions.length - 1 && ( - <div className="absolute right-1 bottom-[-10px] z-10 text-[10px] leading-4 font-medium text-text-accent uppercase">{t($ => $[`${i18nPrefix}.${caseItem.logical_operator}`], { ns: 'workflow' })}</div> - )} - </div> - ))} </div> + <NodeSourceHandle + {...props} + handleId={caseItem.case_id} + handleClassName="top-1/2! -right-[21px]! -translate-y-1/2!" + /> + </div> + <div className="space-y-0.5"> + {caseItem.conditions.map((condition, i) => ( + <div key={condition.id} className="relative"> + {checkIsConditionSet(condition) ? ( + !isEmptyRelatedOperator(condition.comparison_operator!) && + condition.sub_variable_condition ? ( + <ConditionFilesListValue condition={condition} /> + ) : ( + <ConditionValue + variableSelector={condition.variable_selector!} + operator={condition.comparison_operator!} + value={condition.value} + /> + ) + ) : ( + conditionNotSet + )} + {i !== caseItem.conditions.length - 1 && ( + <div className="absolute right-1 bottom-[-10px] z-10 text-[10px] leading-4 font-medium text-text-accent uppercase"> + {t(($) => $[`${i18nPrefix}.${caseItem.logical_operator}`], { ns: 'workflow' })} + </div> + )} + </div> + ))} </div> - )) - } + </div> + ))} <div className="relative flex h-6 items-center px-1"> <div className="w-full text-right text-xs font-semibold text-text-secondary">ELSE</div> <NodeSourceHandle diff --git a/web/app/components/workflow/workflow-preview/components/nodes/index.tsx b/web/app/components/workflow/workflow-preview/components/nodes/index.tsx index 95214934461d91..292b4864283ed4 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/index.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/index.tsx @@ -8,9 +8,7 @@ const CustomNode = (props: NodeProps) => { return ( <> - <BaseNode {...props}> - { NodeComponent && <NodeComponent /> } - </BaseNode> + <BaseNode {...props}>{NodeComponent && <NodeComponent />}</BaseNode> </> ) } diff --git a/web/app/components/workflow/workflow-preview/components/nodes/iteration-start/__tests__/index.spec.tsx b/web/app/components/workflow/workflow-preview/components/nodes/iteration-start/__tests__/index.spec.tsx index c22a1c9f14673c..c4fd48d32fd6c8 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/iteration-start/__tests__/index.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/iteration-start/__tests__/index.spec.tsx @@ -2,8 +2,13 @@ import { render } from '@testing-library/react' import IterationStartNode from '..' vi.mock('reactflow', () => ({ - Handle: (props: { id: string, type: string, className?: string }) => ( - <div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} /> + Handle: (props: { id: string; type: string; className?: string }) => ( + <div + data-testid="handle" + data-handleid={props.id} + data-type={props.type} + className={props.className} + /> ), Position: { Right: 'right', @@ -25,9 +30,7 @@ describe('workflow preview iteration-start node', () => { data: {}, } - const { container } = render( - <IterationStartNode {...props} />, - ) + const { container } = render(<IterationStartNode {...props} />) expect(container.querySelector('[data-handleid="source"]')).toBeInTheDocument() expect(container.firstChild).toHaveClass('rounded-2xl') diff --git a/web/app/components/workflow/workflow-preview/components/nodes/iteration-start/index.tsx b/web/app/components/workflow/workflow-preview/components/nodes/iteration-start/index.tsx index 5e80f03c186b39..d0ff8314f322df 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/iteration-start/index.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/iteration-start/index.tsx @@ -12,12 +12,12 @@ const IterationStartNode = ({ id, data }: NodeProps) => { <div className="nodrag group mt-1 flex size-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg shadow-xs"> <Tooltip> <TooltipTrigger - aria-label={t($ => $['blocks.iteration-start'], { ns: 'workflow' })} + aria-label={t(($) => $['blocks.iteration-start'], { ns: 'workflow' })} className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0" > <RiHome5Fill className="size-3 text-text-primary-on-surface" /> </TooltipTrigger> - <TooltipContent>{t($ => $['blocks.iteration-start'], { ns: 'workflow' })}</TooltipContent> + <TooltipContent>{t(($) => $['blocks.iteration-start'], { ns: 'workflow' })}</TooltipContent> </Tooltip> <NodeSourceHandle id={id} diff --git a/web/app/components/workflow/workflow-preview/components/nodes/iteration/__tests__/node.spec.tsx b/web/app/components/workflow/workflow-preview/components/nodes/iteration/__tests__/node.spec.tsx index f62649b0be6cf9..0e629bde407ce5 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/iteration/__tests__/node.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/iteration/__tests__/node.spec.tsx @@ -2,12 +2,7 @@ import { render, screen } from '@testing-library/react' import IterationNode from '../node' vi.mock('reactflow', () => ({ - Background: (props: { - id: string - gap: [number, number] - size: number - color: string - }) => ( + Background: (props: { id: string; gap: [number, number]; size: number; color: string }) => ( <div data-testid="background" data-id={props.id} @@ -23,14 +18,12 @@ vi.mock('reactflow', () => ({ describe('workflow preview iteration node', () => { it('scales the dotted background with the current viewport zoom', () => { - render( - <IterationNode - id="iteration-1" - data={{} as never} - />, - ) + render(<IterationNode id="iteration-1" data={{} as never} />) - expect(screen.getByTestId('background')).toHaveAttribute('data-id', 'iteration-background-iteration-1') + expect(screen.getByTestId('background')).toHaveAttribute( + 'data-id', + 'iteration-background-iteration-1', + ) expect(screen.getByTestId('background')).toHaveAttribute('data-gap', '[7,7]') expect(screen.getByTestId('background')).toHaveAttribute('data-size', '1') }) diff --git a/web/app/components/workflow/workflow-preview/components/nodes/iteration/node.tsx b/web/app/components/workflow/workflow-preview/components/nodes/iteration/node.tsx index ae9a86cab72683..220316ce5efb78 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/iteration/node.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/iteration/node.tsx @@ -2,23 +2,17 @@ import type { FC } from 'react' import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types' import type { NodeProps } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, -} from 'react' -import { - Background, - useViewport, -} from 'reactflow' +import { memo } from 'react' +import { Background, useViewport } from 'reactflow' -const Node: FC<NodeProps<IterationNodeType>> = ({ - id, -}) => { +const Node: FC<NodeProps<IterationNodeType>> = ({ id }) => { const { zoom } = useViewport() return ( - <div className={cn( - 'relative h-full min-h-[90px] w-full min-w-[240px] rounded-2xl bg-workflow-canvas-workflow-bg', - )} + <div + className={cn( + 'relative h-full min-h-[90px] w-full min-w-[240px] rounded-2xl bg-workflow-canvas-workflow-bg', + )} > <Background id={`iteration-background-${id}`} diff --git a/web/app/components/workflow/workflow-preview/components/nodes/loop-start/__tests__/index.spec.tsx b/web/app/components/workflow/workflow-preview/components/nodes/loop-start/__tests__/index.spec.tsx index 34fc0284863d35..b1b15b55f0807b 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/loop-start/__tests__/index.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/loop-start/__tests__/index.spec.tsx @@ -2,8 +2,13 @@ import { render } from '@testing-library/react' import LoopStartNode from '..' vi.mock('reactflow', () => ({ - Handle: (props: { id: string, type: string, className?: string }) => ( - <div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} /> + Handle: (props: { id: string; type: string; className?: string }) => ( + <div + data-testid="handle" + data-handleid={props.id} + data-type={props.type} + className={props.className} + /> ), Position: { Right: 'right', @@ -25,9 +30,7 @@ describe('workflow preview loop-start node', () => { data: {}, } - const { container } = render( - <LoopStartNode {...props} />, - ) + const { container } = render(<LoopStartNode {...props} />) expect(container.querySelector('[data-handleid="source"]')).toBeInTheDocument() expect(container.firstChild).toHaveClass('rounded-2xl') diff --git a/web/app/components/workflow/workflow-preview/components/nodes/loop-start/index.tsx b/web/app/components/workflow/workflow-preview/components/nodes/loop-start/index.tsx index e02ee5c6d485e7..bf3cd5dfdbbc33 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/loop-start/index.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/loop-start/index.tsx @@ -12,12 +12,12 @@ const LoopStartNode = ({ id, data }: NodeProps) => { <div className="nodrag group mt-1 flex size-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg"> <Tooltip> <TooltipTrigger - aria-label={t($ => $['blocks.loop-start'], { ns: 'workflow' })} + aria-label={t(($) => $['blocks.loop-start'], { ns: 'workflow' })} className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500 p-0" > <RiHome5Fill className="size-3 text-text-primary-on-surface" /> </TooltipTrigger> - <TooltipContent>{t($ => $['blocks.loop-start'], { ns: 'workflow' })}</TooltipContent> + <TooltipContent>{t(($) => $['blocks.loop-start'], { ns: 'workflow' })}</TooltipContent> </Tooltip> <NodeSourceHandle id={id} diff --git a/web/app/components/workflow/workflow-preview/components/nodes/loop/__tests__/hooks.spec.tsx b/web/app/components/workflow/workflow-preview/components/nodes/loop/__tests__/hooks.spec.tsx index 5d93983a4a1577..0b4284f50c5f1b 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/loop/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/loop/__tests__/hooks.spec.tsx @@ -46,7 +46,9 @@ describe('workflow preview loop interactions', () => { result.current.handleNodeLoopRerender('loop-node') expect(mockSetNodes).toHaveBeenCalledTimes(1) - const updatedLoopNode = mockSetNodes.mock.calls[0]![0].find((node: Node) => node.id === 'loop-node') + const updatedLoopNode = mockSetNodes.mock.calls[0]![0].find( + (node: Node) => node.id === 'loop-node', + ) expect(updatedLoopNode.width).toBe(100 + 60 + LOOP_PADDING.right) expect(updatedLoopNode.height).toBe(90 + 40 + LOOP_PADDING.bottom) }) diff --git a/web/app/components/workflow/workflow-preview/components/nodes/loop/__tests__/node.spec.tsx b/web/app/components/workflow/workflow-preview/components/nodes/loop/__tests__/node.spec.tsx index 7d17f2b6e6acb8..97ee08a88bc309 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/loop/__tests__/node.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/loop/__tests__/node.spec.tsx @@ -4,9 +4,7 @@ import LoopNode from '../node' const mockHandleNodeLoopRerender = vi.hoisted(() => vi.fn()) vi.mock('reactflow', () => ({ - Background: (props: { - id: string - }) => <div data-testid="background" data-id={props.id} />, + Background: (props: { id: string }) => <div data-testid="background" data-id={props.id} />, useViewport: () => ({ zoom: 1, }), @@ -25,12 +23,7 @@ describe('workflow preview loop node', () => { }) it('rerenders the loop bounds once child nodes are initialized', () => { - render( - <LoopNode - id="loop-1" - data={{} as never} - />, - ) + render(<LoopNode id="loop-1" data={{} as never} />) expect(screen.getByTestId('background')).toHaveAttribute('data-id', 'loop-background-loop-1') expect(mockHandleNodeLoopRerender).toHaveBeenCalledWith('loop-1') diff --git a/web/app/components/workflow/workflow-preview/components/nodes/loop/hooks.ts b/web/app/components/workflow/workflow-preview/components/nodes/loop/hooks.ts index fd518c72c7a373..1fcf37aaafa5f6 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/loop/hooks.ts +++ b/web/app/components/workflow/workflow-preview/components/nodes/loop/hooks.ts @@ -1,67 +1,61 @@ -import type { - Node, -} from '@/app/components/workflow/types' +import type { Node } from '@/app/components/workflow/types' import { produce } from 'immer' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' -import { - LOOP_PADDING, -} from '@/app/components/workflow/constants' +import { LOOP_PADDING } from '@/app/components/workflow/constants' export const useNodeLoopInteractions = () => { const store = useStoreApi() - const handleNodeLoopRerender = useCallback((nodeId: string) => { - const { - getNodes, - setNodes, - } = store.getState() + const handleNodeLoopRerender = useCallback( + (nodeId: string) => { + const { getNodes, setNodes } = store.getState() - const nodes = getNodes() - const currentNode = nodes.find(n => n.id === nodeId)! - const childrenNodes = nodes.filter(n => n.parentId === nodeId) - let rightNode: Node - let bottomNode: Node + const nodes = getNodes() + const currentNode = nodes.find((n) => n.id === nodeId)! + const childrenNodes = nodes.filter((n) => n.parentId === nodeId) + let rightNode: Node + let bottomNode: Node - childrenNodes.forEach((n) => { - if (rightNode) { - if (n.position.x + n.width! > rightNode.position.x + rightNode.width!) + childrenNodes.forEach((n) => { + if (rightNode) { + if (n.position.x + n.width! > rightNode.position.x + rightNode.width!) rightNode = n + } else { rightNode = n - } - else { - rightNode = n - } - if (bottomNode) { - if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!) + } + if (bottomNode) { + if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!) bottomNode = n + } else { bottomNode = n - } - else { - bottomNode = n - } - }) - - const widthShouldExtend = rightNode! && currentNode.width! < rightNode.position.x + rightNode.width! - const heightShouldExtend = bottomNode! && currentNode.height! < bottomNode.position.y + bottomNode.height! + } + }) - if (widthShouldExtend || heightShouldExtend) { - const newNodes = produce(nodes, (draft) => { - draft.forEach((n) => { - if (n.id === nodeId) { - if (widthShouldExtend) { - n.data.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right - n.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right - } - if (heightShouldExtend) { - n.data.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom - n.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom + const widthShouldExtend = + rightNode! && currentNode.width! < rightNode.position.x + rightNode.width! + const heightShouldExtend = + bottomNode! && currentNode.height! < bottomNode.position.y + bottomNode.height! + + if (widthShouldExtend || heightShouldExtend) { + const newNodes = produce(nodes, (draft) => { + draft.forEach((n) => { + if (n.id === nodeId) { + if (widthShouldExtend) { + n.data.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right + n.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right + } + if (heightShouldExtend) { + n.data.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom + n.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom + } } - } + }) }) - }) - setNodes(newNodes) - } - }, [store]) + setNodes(newNodes) + } + }, + [store], + ) return { handleNodeLoopRerender, diff --git a/web/app/components/workflow/workflow-preview/components/nodes/loop/node.tsx b/web/app/components/workflow/workflow-preview/components/nodes/loop/node.tsx index 9ad06da20a569c..f7391487505377 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/loop/node.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/loop/node.tsx @@ -2,34 +2,24 @@ import type { FC } from 'react' import type { LoopNodeType } from '@/app/components/workflow/nodes/loop/types' import type { NodeProps } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useEffect, -} from 'react' -import { - Background, - useNodesInitialized, - useViewport, -} from 'reactflow' +import { memo, useEffect } from 'react' +import { Background, useNodesInitialized, useViewport } from 'reactflow' import { useNodeLoopInteractions } from './hooks' -const Node: FC<NodeProps<LoopNodeType>> = ({ - id, - data: _data, -}) => { +const Node: FC<NodeProps<LoopNodeType>> = ({ id, data: _data }) => { const { zoom } = useViewport() const nodesInitialized = useNodesInitialized() const { handleNodeLoopRerender } = useNodeLoopInteractions() useEffect(() => { - if (nodesInitialized) - handleNodeLoopRerender(id) + if (nodesInitialized) handleNodeLoopRerender(id) }, [nodesInitialized, id, handleNodeLoopRerender]) return ( - <div className={cn( - 'relative h-full min-h-[90px] w-full min-w-[240px] rounded-2xl bg-workflow-canvas-workflow-bg', - )} + <div + className={cn( + 'relative h-full min-h-[90px] w-full min-w-[240px] rounded-2xl bg-workflow-canvas-workflow-bg', + )} // style={{ // width: data.width || 'auto', // }} diff --git a/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/__tests__/node.spec.tsx b/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/__tests__/node.spec.tsx index 463c4dd43dca08..f1f4419f11f48c 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/__tests__/node.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/__tests__/node.spec.tsx @@ -3,8 +3,13 @@ import { BlockEnum } from '@/app/components/workflow/types' import Node from '../node' vi.mock('reactflow', () => ({ - Handle: (props: { id: string, type: string, className?: string }) => ( - <div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} /> + Handle: (props: { id: string; type: string; className?: string }) => ( + <div + data-testid="handle" + data-handleid={props.id} + data-type={props.type} + className={props.className} + /> ), Position: { Right: 'right', @@ -34,9 +39,7 @@ describe('workflow preview question classifier node', () => { } as never, } - const { container, getByText } = render( - <Node {...props} />, - ) + const { container, getByText } = render(<Node {...props} />) expect(getByText('Billing label')).toBeInTheDocument() expect(getByText('CLASS 2')).toBeInTheDocument() diff --git a/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/node.tsx b/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/node.tsx index 98849903094c7a..0c78b061cddd15 100644 --- a/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/node.tsx +++ b/web/app/components/workflow/workflow-preview/components/nodes/question-classifier/node.tsx @@ -14,28 +14,20 @@ const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => { return ( <div className="mb-1 px-3 py-1"> - { - !!topics.length && ( - <div className="mt-2 space-y-0.5"> - {topics.map((topic, index) => ( - <div - key={index} - className="relative" - > - <InfoPanel - title={getDisplayClassLabel(topic.label, index + 1, t)} - content="" - /> - <NodeSourceHandle - {...props} - handleId={topic.id} - handleClassName="top-1/2! -translate-y-1/2! -right-[21px]!" - /> - </div> - ))} - </div> - ) - } + {!!topics.length && ( + <div className="mt-2 space-y-0.5"> + {topics.map((topic, index) => ( + <div key={index} className="relative"> + <InfoPanel title={getDisplayClassLabel(topic.label, index + 1, t)} content="" /> + <NodeSourceHandle + {...props} + handleId={topic.id} + handleClassName="top-1/2! -translate-y-1/2! -right-[21px]!" + /> + </div> + ))} + </div> + )} </div> ) } diff --git a/web/app/components/workflow/workflow-preview/components/note-node/__tests__/index.spec.tsx b/web/app/components/workflow/workflow-preview/components/note-node/__tests__/index.spec.tsx index 49d2c49a6d5ae5..1bcfd32a6f42d1 100644 --- a/web/app/components/workflow/workflow-preview/components/note-node/__tests__/index.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/note-node/__tests__/index.spec.tsx @@ -19,7 +19,9 @@ const createNoteData = (overrides: Partial<NoteNodeType> = {}): NoteNodeType => ...overrides, }) -const createNoteProps = (overrides: Partial<React.ComponentProps<typeof NoteNode>> = {}): React.ComponentProps<typeof NoteNode> => ({ +const createNoteProps = ( + overrides: Partial<React.ComponentProps<typeof NoteNode>> = {}, +): React.ComponentProps<typeof NoteNode> => ({ id: 'note-node-1', type: 'note-node', selected: false, @@ -41,9 +43,7 @@ describe('workflow preview note node', () => { // The preview node should expose the same readonly editor surface and metadata as the live note node. describe('Rendering', () => { it('should render the readonly note editor, author, and themed frame', () => { - const { container } = render( - <NoteNode {...createNoteProps()} />, - ) + const { container } = render(<NoteNode {...createNoteProps()} />) const noteRoot = container.firstElementChild as HTMLElement @@ -73,7 +73,10 @@ describe('workflow preview note node', () => { const noteRoot = container.firstElementChild as HTMLElement - expect(noteRoot).toHaveClass('bg-util-colors-pink-pink-50', 'border-util-colors-pink-pink-300') + expect(noteRoot).toHaveClass( + 'bg-util-colors-pink-pink-50', + 'border-util-colors-pink-pink-300', + ) expect(container.querySelector('.cursor-text')).toBeInTheDocument() expect(container.querySelector('.nodrag.nopan.nowheel')).toBeInTheDocument() expect(screen.queryByText('Alice')).not.toBeInTheDocument() diff --git a/web/app/components/workflow/workflow-preview/components/note-node/index.tsx b/web/app/components/workflow/workflow-preview/components/note-node/index.tsx index 87de79a923bf37..4e97ac51b98b69 100644 --- a/web/app/components/workflow/workflow-preview/components/note-node/index.tsx +++ b/web/app/components/workflow/workflow-preview/components/note-node/index.tsx @@ -1,10 +1,7 @@ import type { NodeProps } from 'reactflow' import type { NoteNodeType } from '@/app/components/workflow/note-node/types' import { cn } from '@langgenius/dify-ui/cn' -import { - memo, - useRef, -} from 'react' +import { memo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { THEME_MAP } from '@/app/components/workflow/note-node/constants' import { @@ -12,9 +9,7 @@ import { NoteEditorContextProvider, } from '@/app/components/workflow/note-node/note-editor' -const NoteNode = ({ - data, -}: NodeProps<NoteNodeType>) => { +const NoteNode = ({ data }: NodeProps<NoteNodeType>) => { const { t } = useTranslation() const ref = useRef<HTMLDivElement | null>(null) const theme = data.theme @@ -32,36 +27,22 @@ const NoteNode = ({ }} ref={ref} > - <NoteEditorContextProvider - value={data.text} - editable={false} - > + <NoteEditorContextProvider value={data.text} editable={false}> <> <div - className={cn( - 'h-2 shrink-0 rounded-t-md opacity-50', - THEME_MAP[theme]!.title, - )} - > - </div> + className={cn('h-2 shrink-0 rounded-t-md opacity-50', THEME_MAP[theme]!.title)} + ></div> <div className="grow overflow-y-auto px-3 py-2.5"> - <div className={cn( - data.selected && 'nodrag nopan nowheel cursor-text', - )} - > + <div className={cn(data.selected && 'nodrag nopan nowheel cursor-text')}> <NoteEditor containerElement={ref.current} - placeholder={t($ => $['nodes.note.editor.placeholder'], { ns: 'workflow' }) || ''} + placeholder={t(($) => $['nodes.note.editor.placeholder'], { ns: 'workflow' }) || ''} /> </div> </div> - { - data.showAuthor && ( - <div className="p-3 pt-0 text-xs text-text-tertiary"> - {data.author} - </div> - ) - } + {data.showAuthor && ( + <div className="p-3 pt-0 text-xs text-text-tertiary">{data.author}</div> + )} </> </NoteEditorContextProvider> </div> diff --git a/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx b/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx index d647781faa849e..d617ddf724b67f 100644 --- a/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx +++ b/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx @@ -7,15 +7,9 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { - Fragment, - memo, -} from 'react' +import { Fragment, memo } from 'react' import { useTranslation } from 'react-i18next' -import { - useReactFlow, - useViewport, -} from 'reactflow' +import { useReactFlow, useViewport } from 'reactflow' import TipPopup from '@/app/components/workflow/operator/tip-popup' import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd' @@ -28,16 +22,11 @@ const ZoomType = { zoomTo200: 'zoomTo200', } as const -type ZoomType = typeof ZoomType[keyof typeof ZoomType] +type ZoomType = (typeof ZoomType)[keyof typeof ZoomType] const ZoomInOut: FC = () => { const { t } = useTranslation() - const { - zoomIn, - zoomOut, - zoomTo, - fitView, - } = useReactFlow() + const { zoomIn, zoomOut, zoomTo, fitView } = useReactFlow() const { zoom } = useViewport() const zoomOptions = [ @@ -66,29 +55,23 @@ const ZoomInOut: FC = () => { [ { key: ZoomType.zoomToFit, - text: t($ => $['operator.zoomToFit'], { ns: 'workflow' }), + text: t(($) => $['operator.zoomToFit'], { ns: 'workflow' }), }, ], ] const handleZoom = (type: ZoomType) => { - if (type === ZoomType.zoomToFit) - fitView() + if (type === ZoomType.zoomToFit) fitView() - if (type === ZoomType.zoomTo25) - zoomTo(0.25) + if (type === ZoomType.zoomTo25) zoomTo(0.25) - if (type === ZoomType.zoomTo50) - zoomTo(0.5) + if (type === ZoomType.zoomTo50) zoomTo(0.5) - if (type === ZoomType.zoomTo75) - zoomTo(0.75) + if (type === ZoomType.zoomTo75) zoomTo(0.75) - if (type === ZoomType.zoomTo100) - zoomTo(1) + if (type === ZoomType.zoomTo100) zoomTo(1) - if (type === ZoomType.zoomTo200) - zoomTo(2) + if (type === ZoomType.zoomTo200) zoomTo(2) } return ( @@ -101,29 +84,30 @@ const ZoomInOut: FC = () => { > <div className="flex h-8 w-[98px] items-center justify-between rounded-lg"> <TipPopup - title={t($ => $['operator.zoomOut'], { ns: 'workflow' })} + title={t(($) => $['operator.zoomOut'], { ns: 'workflow' })} shortcut="workflow.zoom-out" > <button type="button" - aria-label={t($ => $['operator.zoomOut'], { ns: 'workflow' })} + aria-label={t(($) => $['operator.zoomOut'], { ns: 'workflow' })} disabled={zoom <= 0.25} className={`flex size-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`} onClick={(e) => { - if (zoom <= 0.25) - return + if (zoom <= 0.25) return e.stopPropagation() zoomOut() }} > - <span aria-hidden className="i-ri-zoom-out-line size-4 text-text-tertiary hover:text-text-secondary" /> + <span + aria-hidden + className="i-ri-zoom-out-line size-4 text-text-tertiary hover:text-text-secondary" + /> </button> </TipPopup> <DropdownMenu> <DropdownMenuTrigger className="flex h-8 w-[34px] items-center justify-center rounded-lg system-sm-medium text-text-tertiary hover:bg-black/5 hover:text-text-secondary data-popup-open:bg-black/5 data-popup-open:text-text-secondary"> - {Number.parseFloat(`${zoom * 100}`).toFixed(0)} - % + {Number.parseFloat(`${zoom * 100}`).toFixed(0)}% </DropdownMenuTrigger> <DropdownMenuContent placement="top-start" @@ -134,11 +118,9 @@ const ZoomInOut: FC = () => { <div className="w-[145px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"> {zoomOptions.map((options, groupIndex) => ( <Fragment key={options[0]!.key}> - {groupIndex !== 0 && ( - <DropdownMenuSeparator className="my-0" /> - )} + {groupIndex !== 0 && <DropdownMenuSeparator className="my-0" />} <div className="p-1"> - {options.map(option => ( + {options.map((option) => ( <DropdownMenuItem key={option.key} className="justify-between px-3 py-1.5 system-md-regular text-text-secondary" @@ -165,23 +147,25 @@ const ZoomInOut: FC = () => { </DropdownMenuContent> </DropdownMenu> <TipPopup - title={t($ => $['operator.zoomIn'], { ns: 'workflow' })} + title={t(($) => $['operator.zoomIn'], { ns: 'workflow' })} shortcut="workflow.zoom-in" > <button type="button" - aria-label={t($ => $['operator.zoomIn'], { ns: 'workflow' })} + aria-label={t(($) => $['operator.zoomIn'], { ns: 'workflow' })} disabled={zoom >= 2} className={`flex size-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`} onClick={(e) => { - if (zoom >= 2) - return + if (zoom >= 2) return e.stopPropagation() zoomIn() }} > - <span aria-hidden className="i-ri-zoom-in-line size-4 text-text-tertiary hover:text-text-secondary" /> + <span + aria-hidden + className="i-ri-zoom-in-line size-4 text-text-tertiary hover:text-text-secondary" + /> </button> </TipPopup> </div> diff --git a/web/app/components/workflow/workflow-preview/index.tsx b/web/app/components/workflow/workflow-preview/index.tsx index 2f281efe2f3c02..bb53b48f724d7b 100644 --- a/web/app/components/workflow/workflow-preview/index.tsx +++ b/web/app/components/workflow/workflow-preview/index.tsx @@ -1,19 +1,9 @@ 'use client' -import type { - EdgeChange, - NodeChange, - Viewport, -} from 'reactflow' -import type { - Edge, - Node, -} from '@/app/components/workflow/types' +import type { EdgeChange, NodeChange, Viewport } from 'reactflow' +import type { Edge, Node } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { - useCallback, - useState, -} from 'react' +import { useCallback, useState } from 'react' import ReactFlow, { applyEdgeChanges, applyNodeChanges, @@ -22,19 +12,13 @@ import ReactFlow, { ReactFlowProvider, SelectionMode, } from 'reactflow' -import { - CUSTOM_EDGE, - CUSTOM_NODE, -} from '@/app/components/workflow/constants' +import { CUSTOM_EDGE, CUSTOM_NODE } from '@/app/components/workflow/constants' import CustomConnectionLine from '@/app/components/workflow/custom-connection-line' import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants' import { CUSTOM_NOTE_NODE } from '@/app/components/workflow/note-node/constants' import { CUSTOM_SIMPLE_NODE } from '@/app/components/workflow/simple-node/constants' -import { - initialEdges, - initialNodes, -} from '@/app/components/workflow/utils/workflow-init' +import { initialEdges, initialNodes } from '@/app/components/workflow/utils/workflow-init' import CustomEdge from './components/custom-edge' import CustomNode from './components/nodes' import IterationStartNode from './components/nodes/iteration-start' @@ -73,22 +57,16 @@ const WorkflowPreview = ({ const [edgesData, setEdgesData] = useState(() => initialEdges(edges, nodes)) const onNodesChange = useCallback( - (changes: NodeChange[]) => setNodesData(nds => applyNodeChanges(changes, nds)), + (changes: NodeChange[]) => setNodesData((nds) => applyNodeChanges(changes, nds)), [], ) const onEdgesChange = useCallback( - (changes: EdgeChange[]) => setEdgesData(eds => applyEdgeChanges(changes, eds)), + (changes: EdgeChange[]) => setEdgesData((eds) => applyEdgeChanges(changes, eds)), [], ) return ( - <div - id="workflow-container" - className={cn( - 'relative size-full', - className, - )} - > + <div id="workflow-container" className={cn('relative size-full', className)}> <> <MiniMap pannable @@ -98,7 +76,10 @@ const WorkflowPreview = ({ height: 72, }} maskColor="var(--color-workflow-minimap-bg)" - className={cn('absolute! bottom-14! z-9 m-0! h-[72px]! w-[102px]! rounded-lg! border-[0.5px]! border-divider-subtle! bg-background-default-subtle! shadow-md! shadow-shadow-shadow-5!', miniMapToRight ? 'right-4!' : 'left-4!')} + className={cn( + 'absolute! bottom-14! z-9 m-0! h-[72px]! w-[102px]! rounded-lg! border-[0.5px]! border-divider-subtle! bg-background-default-subtle! shadow-md! shadow-shadow-shadow-5!', + miniMapToRight ? 'right-4!' : 'left-4!', + )} /> <div className="absolute bottom-4 left-4 z-9 mt-1 flex items-center gap-2"> <ZoomInOut /> diff --git a/web/app/device/__tests__/page-terminal.spec.tsx b/web/app/device/__tests__/page-terminal.spec.tsx index 8dadde3dea71d1..9317da86e97700 100644 --- a/web/app/device/__tests__/page-terminal.spec.tsx +++ b/web/app/device/__tests__/page-terminal.spec.tsx @@ -64,7 +64,9 @@ beforeEach(async () => { mockSearchParams = {} }) mockUseQuery.mockReturnValue({ data: undefined, isError: false } as ReturnType<typeof useQuery>) - const mod = await import('@/service/device-flow') as { DeviceFlowError: MockDeviceFlowErrorCtor } + const mod = (await import('@/service/device-flow')) as { + DeviceFlowError: MockDeviceFlowErrorCtor + } MockDeviceFlowError = mod.DeviceFlowError }) @@ -85,7 +87,9 @@ describe('error_expired terminal state', () => { it('ghost button resets to code_entry', async () => { await reachTerminal(new Error('expired')) await screen.findByText('deviceFlow.errorExpired.title') - fireEvent.click(screen.getByRole('button', { name: /deviceFlow.errorExpired.tryDifferentCode/i })) + fireEvent.click( + screen.getByRole('button', { name: /deviceFlow.errorExpired.tryDifferentCode/i }), + ) expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.queryByText('deviceFlow.errorExpired.title')).not.toBeInTheDocument() }) diff --git a/web/app/device/_header.tsx b/web/app/device/_header.tsx index 60e729366d90c8..f59901516293a8 100644 --- a/web/app/device/_header.tsx +++ b/web/app/device/_header.tsx @@ -23,19 +23,19 @@ const Header = () => { return ( <div className="flex w-full items-center justify-between p-6"> - {systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo - ? ( - <img - src={systemFeatures.branding.login_page_logo} - className="block h-7 w-auto object-contain" - alt="logo" - /> - ) - : <DifyLogo size="large" />} + {systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo ? ( + <img + src={systemFeatures.branding.login_page_logo} + className="block h-7 w-auto object-contain" + alt="logo" + /> + ) : ( + <DifyLogo size="large" /> + )} <div className="flex items-center gap-1"> <LocaleMenu value={locale} - items={languages.filter(item => item.supported)} + items={languages.filter((item) => item.supported)} onChange={(value) => { setLocaleOnClient(value, false) }} diff --git a/web/app/device/components/__tests__/authorize-sso.spec.tsx b/web/app/device/components/__tests__/authorize-sso.spec.tsx index 048bc403ff91b4..6bf17ee4c04164 100644 --- a/web/app/device/components/__tests__/authorize-sso.spec.tsx +++ b/web/app/device/components/__tests__/authorize-sso.spec.tsx @@ -44,14 +44,18 @@ describe('AuthorizeSSO', () => { it('renders single Authorize button with no Cancel', async () => { render(<AuthorizeSSO onApproved={vi.fn()} onError={vi.fn()} />) await screen.findByRole('button', { name: /deviceFlow.authorize.approve/i }) - expect(screen.queryByRole('button', { name: /common.operation.cancel/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /common.operation.cancel/i }), + ).not.toBeInTheDocument() }) it('calls approveExternal with ctx and user_code on Authorize click', async () => { render(<AuthorizeSSO onApproved={vi.fn()} onError={vi.fn()} />) await screen.findByRole('button', { name: /deviceFlow.authorize.approve/i }) await userEvent.click(screen.getByRole('button', { name: /deviceFlow.authorize.approve/i })) - await waitFor(() => expect(mockApproveExternal).toHaveBeenCalledWith(mockCtx, mockCtx.user_code)) + await waitFor(() => + expect(mockApproveExternal).toHaveBeenCalledWith(mockCtx, mockCtx.user_code), + ) }) it('calls onApproved after successful approve', async () => { diff --git a/web/app/device/components/__tests__/chooser.spec.tsx b/web/app/device/components/__tests__/chooser.spec.tsx index 6b0afd7ad533ee..a189248db6c721 100644 --- a/web/app/device/components/__tests__/chooser.spec.tsx +++ b/web/app/device/components/__tests__/chooser.spec.tsx @@ -16,17 +16,23 @@ vi.mock('@/app/signin/utils/post-login-redirect', () => ({ describe('Chooser', () => { it('renders account button', () => { render(<Chooser userCode="ABCD-3456" ssoAvailable={false} />) - expect(screen.getByRole('button', { name: /deviceFlow.chooser.signInAccount/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /deviceFlow.chooser.signInAccount/i }), + ).toBeInTheDocument() }) it('hides SSO button when ssoAvailable is false', () => { render(<Chooser userCode="ABCD-3456" ssoAvailable={false} />) - expect(screen.queryByRole('button', { name: /deviceFlow.chooser.signInSSO/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /deviceFlow.chooser.signInSSO/i }), + ).not.toBeInTheDocument() }) it('shows SSO button when ssoAvailable is true', () => { render(<Chooser userCode="ABCD-3456" ssoAvailable={true} />) - expect(screen.getByRole('button', { name: /deviceFlow.chooser.signInSSO/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /deviceFlow.chooser.signInSSO/i }), + ).toBeInTheDocument() }) it('sets post-login redirect and navigates to /signin on account button click', () => { @@ -50,8 +56,6 @@ describe('Chooser', () => { }) render(<Chooser userCode="ABCD-3456" ssoAvailable={true} />) fireEvent.click(screen.getByRole('button', { name: /deviceFlow.chooser.signInSSO/i })) - expect(window.location.href).toBe( - '/openapi/v1/oauth/device/sso-initiate?user_code=ABCD-3456', - ) + expect(window.location.href).toBe('/openapi/v1/oauth/device/sso-initiate?user_code=ABCD-3456') }) }) diff --git a/web/app/device/components/authorize-account.tsx b/web/app/device/components/authorize-account.tsx index 4e55a0f6116c9c..a5001c8958b6a6 100644 --- a/web/app/device/components/authorize-account.tsx +++ b/web/app/device/components/authorize-account.tsx @@ -43,11 +43,9 @@ const AuthorizeAccount: FC<Props> = ({ try { await deviceApproveAccount(userCode) onApproved() - } - catch (e) { + } catch (e) { onError(approveErrorCopy(e, t)) - } - finally { + } finally { setBusy(false) } } @@ -57,11 +55,9 @@ const AuthorizeAccount: FC<Props> = ({ try { await deviceDenyAccount(userCode) onDenied() - } - catch (e) { + } catch (e) { onError(approveErrorCopy(e, t)) - } - finally { + } finally { setBusy(false) } } @@ -69,9 +65,11 @@ const AuthorizeAccount: FC<Props> = ({ return ( <div className="flex flex-col gap-5"> <div> - <h2 className="text-2xl font-semibold text-text-primary">{t($ => $['authorize.title'])}</h2> + <h2 className="text-2xl font-semibold text-text-primary"> + {t(($) => $['authorize.title'])} + </h2> <p className="mt-2 text-sm text-text-secondary"> - {t($ => $['authorize.accountSubtitle'])} + {t(($) => $['authorize.accountSubtitle'])} </p> </div> <div className="flex items-center gap-2.5 rounded-lg bg-background-section-burn px-3 py-2.5"> @@ -81,39 +79,22 @@ const AuthorizeAccount: FC<Props> = ({ name={accountName || accountEmail || ''} /> <div> - {accountName && ( - <p className="text-sm font-semibold text-text-primary">{accountName}</p> - )} - {accountEmail && ( - <p className="text-xs text-text-secondary">{accountEmail}</p> - )} + {accountName && <p className="text-sm font-semibold text-text-primary">{accountName}</p>} + {accountEmail && <p className="text-xs text-text-secondary">{accountEmail}</p>} </div> </div> {defaultWorkspace && ( <div className="rounded-lg bg-background-section-burn px-3 py-2 text-sm text-text-secondary"> - {t($ => $['authorize.workspace'])} - {' '} + {t(($) => $['authorize.workspace'])}{' '} <span className="font-semibold text-text-primary">{defaultWorkspace}</span> </div> )} <div className="flex gap-3"> - <Button - variant="primary" - size="large" - className="flex-1" - onClick={approve} - disabled={busy} - > - {t($ => $['authorize.approve'])} + <Button variant="primary" size="large" className="flex-1" onClick={approve} disabled={busy}> + {t(($) => $['authorize.approve'])} </Button> - <Button - variant="secondary" - size="large" - className="flex-1" - onClick={deny} - disabled={busy} - > - {t($ => $['operation.cancel'], { ns: 'common' })} + <Button variant="secondary" size="large" className="flex-1" onClick={deny} disabled={busy}> + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> </div> </div> diff --git a/web/app/device/components/authorize-sso.tsx b/web/app/device/components/authorize-sso.tsx index 02cbc9e516fed7..897b21335c07fe 100644 --- a/web/app/device/components/authorize-sso.tsx +++ b/web/app/device/components/authorize-sso.tsx @@ -41,8 +41,7 @@ const AuthorizeSSO: FC<Props> = ({ onApproved, onError }) => { } }) .catch((e) => { - if (!cancelled) - setLoadErr(approveErrorCopy(e, t)) + if (!cancelled) setLoadErr(approveErrorCopy(e, t)) }) return () => { cancelled = true @@ -50,17 +49,14 @@ const AuthorizeSSO: FC<Props> = ({ onApproved, onError }) => { }, [t]) const approve = async () => { - if (!ctx) - return + if (!ctx) return setBusy(true) try { await approveExternal(ctx, ctx.user_code) onApproved() - } - catch (e) { + } catch (e) { onError(approveErrorCopy(e, t)) - } - finally { + } finally { setBusy(false) } } @@ -71,55 +67,50 @@ const AuthorizeSSO: FC<Props> = ({ onApproved, onError }) => { if (loadErr) { return ( <div> - <h2 className="text-2xl font-semibold text-text-primary">{t($ => $['authorize.sessionInvalidTitle'])}</h2> + <h2 className="text-2xl font-semibold text-text-primary"> + {t(($) => $['authorize.sessionInvalidTitle'])} + </h2> <p className="mt-2 text-sm text-text-secondary"> <Trans - i18nKey={$ => $['authorize.sessionInvalidBody']} + i18nKey={($) => $['authorize.sessionInvalidBody']} ns="deviceFlow" - components={{ codeTag: <code className="rounded bg-components-input-bg-normal px-1 font-mono" /> }} + components={{ + codeTag: <code className="rounded bg-components-input-bg-normal px-1 font-mono" />, + }} /> </p> </div> ) } if (!ctx) { - return <div className="text-sm text-text-secondary">{t($ => $['authorize.loadingSession'])}</div> + return ( + <div className="text-sm text-text-secondary">{t(($) => $['authorize.loadingSession'])}</div> + ) } return ( <div className="flex flex-col gap-5"> <div> - <h2 className="text-2xl font-semibold text-text-primary">{t($ => $['authorize.title'])}</h2> - <p className="mt-2 text-sm text-text-secondary"> - {t($ => $['authorize.ssoSubtitle'])} - </p> + <h2 className="text-2xl font-semibold text-text-primary"> + {t(($) => $['authorize.title'])} + </h2> + <p className="mt-2 text-sm text-text-secondary">{t(($) => $['authorize.ssoSubtitle'])}</p> </div> <div className="flex items-center gap-2.5 rounded-lg bg-background-section-burn px-3 py-2.5"> - <Avatar - size="md" - avatar={null} - name={ctx.subject_email} - /> + <Avatar size="md" avatar={null} name={ctx.subject_email} /> <div> <p className="text-sm font-semibold text-text-primary">{ctx.subject_email}</p> - <p className="text-xs text-text-secondary">{t($ => $['authorize.viaSSO'])}</p> + <p className="text-xs text-text-secondary">{t(($) => $['authorize.viaSSO'])}</p> </div> </div> {ctx.subject_issuer && ( <div className="rounded-lg bg-background-section-burn px-3 py-2 text-sm text-text-secondary"> - {t($ => $['authorize.identityProvider'])} - {' '} + {t(($) => $['authorize.identityProvider'])}{' '} <span className="font-semibold text-text-primary">{ctx.subject_issuer}</span> </div> )} - <Button - variant="primary" - size="large" - className="w-full" - onClick={approve} - disabled={busy} - > - {t($ => $['authorize.approve'])} + <Button variant="primary" size="large" className="w-full" onClick={approve} disabled={busy}> + {t(($) => $['authorize.approve'])} </Button> </div> ) diff --git a/web/app/device/components/chooser.tsx b/web/app/device/components/chooser.tsx index 86b092a8eb07f4..045042077af7a7 100644 --- a/web/app/device/components/chooser.tsx +++ b/web/app/device/components/chooser.tsx @@ -39,24 +39,14 @@ const Chooser: FC<Props> = ({ userCode, ssoAvailable }) => { return ( <div className="flex flex-col gap-3"> - <Button - variant="primary" - size="large" - className="w-full gap-2" - onClick={onAccount} - > + <Button variant="primary" size="large" className="w-full gap-2" onClick={onAccount}> <span className="i-ri-user-3-line h-4 w-4" /> - {t($ => $['chooser.signInAccount'])} + {t(($) => $['chooser.signInAccount'])} </Button> {ssoAvailable && ( - <Button - variant="secondary" - size="large" - className="w-full gap-2" - onClick={onSSO} - > + <Button variant="secondary" size="large" className="w-full gap-2" onClick={onSSO}> <span className="i-ri-shield-line h-4 w-4" /> - {t($ => $['chooser.signInSSO'])} + {t(($) => $['chooser.signInSSO'])} </Button> )} </div> diff --git a/web/app/device/components/code-input.tsx b/web/app/device/components/code-input.tsx index c92fd8d7a45246..4f6d7776a60f3d 100644 --- a/web/app/device/components/code-input.tsx +++ b/web/app/device/components/code-input.tsx @@ -21,9 +21,12 @@ type Props = { */ const CodeInput: FC<Props> = ({ value, onChange, disabled, autoFocus }) => { const { t } = useTranslation('deviceFlow') - const handle = useCallback((raw: string) => { - onChange(normaliseUserCodeInput(raw)) - }, [onChange]) + const handle = useCallback( + (raw: string) => { + onChange(normaliseUserCodeInput(raw)) + }, + [onChange], + ) return ( <input @@ -34,12 +37,12 @@ const CodeInput: FC<Props> = ({ value, onChange, disabled, autoFocus }) => { spellCheck={false} placeholder="ABCD-1234" maxLength={9} - aria-label={t($ => $['codeEntry.codeAriaLabel'])} + aria-label={t(($) => $['codeEntry.codeAriaLabel'])} className="border-components-input-border-normal w-full rounded-lg border bg-components-input-bg-normal px-4 py-3 text-center font-mono text-2xl tracking-wider text-text-primary focus:border-components-input-border-active focus:outline-none" value={value} disabled={disabled} autoFocus={autoFocus} - onChange={e => handle(e.target.value)} + onChange={(e) => handle(e.target.value)} /> ) } diff --git a/web/app/device/layout.tsx b/web/app/device/layout.tsx index 25eccf5551601c..d7d7999963158f 100644 --- a/web/app/device/layout.tsx +++ b/web/app/device/layout.tsx @@ -10,20 +10,20 @@ export default function DeviceLayout({ children }: { children: React.ReactNode } useDocumentTitle('') return ( <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> - <div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}> + <div + className={cn( + 'flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle', + )} + > <Header /> - <div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}> - <div className="flex flex-col md:w-[400px]"> - {children} - </div> + <div + className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')} + > + <div className="flex flex-col md:w-[400px]">{children}</div> </div> {systemFeatures.branding.enabled === false && ( <div className="px-8 py-6 system-xs-regular text-text-tertiary"> - © - {' '} - {new Date().getFullYear()} - {' '} - LangGenius, Inc. All rights reserved. + © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. </div> )} </div> diff --git a/web/app/device/page.tsx b/web/app/device/page.tsx index 079a6964e50d07..e15a68acaaa311 100644 --- a/web/app/device/page.tsx +++ b/web/app/device/page.tsx @@ -17,16 +17,16 @@ import CodeInput from './components/code-input' import { classifyLookupError, ssoErrorCopy } from './utils/error-copy' import { isValidUserCode } from './utils/user-code' -type View - = | { kind: 'code_entry' } - | { kind: 'chooser', userCode: string } - | { kind: 'authorize_account', userCode: string } - | { kind: 'authorize_sso' } - | { kind: 'success' } - | { kind: 'error_expired' } - | { kind: 'error_rate_limited' } - | { kind: 'error_lookup_failed' } - | { kind: 'error_sso', code: string, userCode: string } +type View = + | { kind: 'code_entry' } + | { kind: 'chooser'; userCode: string } + | { kind: 'authorize_account'; userCode: string } + | { kind: 'authorize_sso' } + | { kind: 'success' } + | { kind: 'error_expired' } + | { kind: 'error_rate_limited' } + | { kind: 'error_lookup_failed' } + | { kind: 'error_sso'; code: string; userCode: string } export default function DevicePage() { const { t } = useTranslation('deviceFlow') @@ -63,9 +63,10 @@ export default function DevicePage() { // Device-flow SSO branch uses external-user (webapp) SSO, not console SSO — // backend mints EXTERNAL_SSO tokens via Enterprise's external ACS. Gate on // webapp_auth.{enabled, allow_sso} + a configured webapp SSO protocol. - const ssoAvailable = !!sys?.webapp_auth?.enabled - && !!sys?.webapp_auth?.allow_sso - && (sys?.webapp_auth?.sso_config?.protocol || '') !== '' + const ssoAvailable = + !!sys?.webapp_auth?.enabled && + !!sys?.webapp_auth?.allow_sso && + (sys?.webapp_auth?.sso_config?.protocol || '') !== '' // URL-driven view transitions. Only advances while the user is still on // the entry/chooser screens — never clobbers terminal views (success / @@ -73,8 +74,7 @@ export default function DevicePage() { // After consuming the params, scrub them from the URL so they don't // leak via history / Referer / server logs (RFC 8628 §5.4). useEffect(() => { - if (view.kind !== 'code_entry' && view.kind !== 'chooser') - return + if (view.kind !== 'code_entry' && view.kind !== 'chooser') return if (ssoError) { setView({ kind: 'error_sso', code: ssoError, userCode: urlUserCode }) // eslint-disable-line react/set-state-in-effect router.replace(pathname) @@ -91,16 +91,13 @@ export default function DevicePage() { if (ssoVerified) { setView({ kind: 'authorize_sso' }) // eslint-disable-line react/set-state-in-effect consumed = true - } - else if (urlUserCode && isValidUserCode(urlUserCode)) { + } else if (urlUserCode && isValidUserCode(urlUserCode)) { if (account) setView({ kind: 'authorize_account', userCode: urlUserCode }) // eslint-disable-line react/set-state-in-effect - else - setView({ kind: 'chooser', userCode: urlUserCode }) // eslint-disable-line react/set-state-in-effect + else setView({ kind: 'chooser', userCode: urlUserCode }) // eslint-disable-line react/set-state-in-effect consumed = true } - if (consumed && (urlUserCode || ssoVerified)) - router.replace(pathname) + if (consumed && (urlUserCode || ssoVerified)) router.replace(pathname) }, [urlUserCode, ssoVerified, ssoError, account, view, router, pathname]) const advanceFromCode = async (code: string) => { @@ -110,25 +107,19 @@ export default function DevicePage() { setView({ kind: 'error_expired' }) return } - } - catch (e) { + } catch (e) { const outcome = classifyLookupError(e) - if (outcome === 'rate_limited') - setView({ kind: 'error_rate_limited' }) - else if (outcome === 'failed') - setView({ kind: 'error_lookup_failed' }) - else - setView({ kind: 'error_expired' }) + if (outcome === 'rate_limited') setView({ kind: 'error_rate_limited' }) + else if (outcome === 'failed') setView({ kind: 'error_lookup_failed' }) + else setView({ kind: 'error_expired' }) return } - if (account) - setView({ kind: 'authorize_account', userCode: code }) + if (account) setView({ kind: 'authorize_account', userCode: code }) else setView({ kind: 'chooser', userCode: code }) } const onContinue = async () => { - if (!isValidUserCode(typed)) - return + if (!isValidUserCode(typed)) return await advanceFromCode(typed) } @@ -137,10 +128,10 @@ export default function DevicePage() { {view.kind === 'code_entry' && ( <div className="flex flex-col gap-5"> <div> - <h1 className="text-2xl font-semibold text-text-primary">{t($ => $['codeEntry.title'])}</h1> - <p className="mt-2 text-sm text-text-secondary"> - {t($ => $['codeEntry.subtitle'])} - </p> + <h1 className="text-2xl font-semibold text-text-primary"> + {t(($) => $['codeEntry.title'])} + </h1> + <p className="mt-2 text-sm text-text-secondary">{t(($) => $['codeEntry.subtitle'])}</p> </div> <CodeInput value={typed} onChange={setTyped} autoFocus /> <Button @@ -150,7 +141,7 @@ export default function DevicePage() { onClick={onContinue} disabled={!isValidUserCode(typed)} > - {t($ => $['codeEntry.continue'])} + {t(($) => $['codeEntry.continue'])} </Button> </div> )} @@ -158,13 +149,19 @@ export default function DevicePage() { {view.kind === 'chooser' && ( <div className="flex flex-col gap-5"> <div> - <h1 className="text-2xl font-semibold text-text-primary">{t($ => $['chooser.title'])}</h1> + <h1 className="text-2xl font-semibold text-text-primary"> + {t(($) => $['chooser.title'])} + </h1> <p className="mt-2 text-sm text-text-secondary"> <Trans - i18nKey={$ => $['chooser.subtitle']} + i18nKey={($) => $['chooser.subtitle']} ns="deviceFlow" values={{ code: view.userCode }} - components={{ codeTag: <code className="rounded bg-components-input-bg-normal px-1 font-mono" /> }} + components={{ + codeTag: ( + <code className="rounded bg-components-input-bg-normal px-1 font-mono" /> + ), + }} /> </p> </div> @@ -181,14 +178,14 @@ export default function DevicePage() { defaultWorkspace={currentWorkspace?.name ?? undefined} onApproved={() => setView({ kind: 'success' })} onDenied={() => setView({ kind: 'error_expired' })} - onError={e => setErrMsg(e)} + onError={(e) => setErrMsg(e)} /> )} {view.kind === 'authorize_sso' && ( <AuthorizeSSO onApproved={() => setView({ kind: 'success' })} - onError={e => setErrMsg(e)} + onError={(e) => setErrMsg(e)} /> )} @@ -197,11 +194,13 @@ export default function DevicePage() { <div className="mb-2.5 flex h-[38px] w-[38px] items-center justify-center rounded-full bg-state-success-hover"> <span className="i-ri-checkbox-circle-line h-[18px] w-[18px] text-util-colors-green-green-600" /> </div> - <h1 className="text-xl font-semibold text-text-primary">{t($ => $['success.title'])}</h1> - <p className="text-sm text-text-secondary">{t($ => $['success.subtitle'])}</p> + <h1 className="text-xl font-semibold text-text-primary"> + {t(($) => $['success.title'])} + </h1> + <p className="text-sm text-text-secondary">{t(($) => $['success.subtitle'])}</p> <Divider className="my-3" /> <Button variant="ghost" className="w-full" onClick={() => router.push('/')}> - {t($ => $['success.goToConsole'])} + {t(($) => $['success.goToConsole'])} </Button> </div> )} @@ -211,12 +210,16 @@ export default function DevicePage() { <div className="mb-2.5 flex h-[38px] w-[38px] items-center justify-center rounded-full bg-state-warning-hover"> <span className="i-ri-error-warning-line h-[18px] w-[18px] text-util-colors-yellow-yellow-600" /> </div> - <h1 className="text-xl font-semibold text-text-primary">{t($ => $['errorExpired.title'])}</h1> + <h1 className="text-xl font-semibold text-text-primary"> + {t(($) => $['errorExpired.title'])} + </h1> <p className="text-sm text-text-secondary"> <Trans - i18nKey={$ => $['errorExpired.body']} + i18nKey={($) => $['errorExpired.body']} ns="deviceFlow" - components={{ codeTag: <code className="rounded bg-components-input-bg-normal px-1 font-mono" /> }} + components={{ + codeTag: <code className="rounded bg-components-input-bg-normal px-1 font-mono" />, + }} /> </p> <Divider className="my-3" /> @@ -228,7 +231,7 @@ export default function DevicePage() { setErrMsg(null) }} > - {t($ => $['errorExpired.tryDifferentCode'])} + {t(($) => $['errorExpired.tryDifferentCode'])} </Button> </div> )} @@ -238,8 +241,10 @@ export default function DevicePage() { <div className="mb-2.5 flex h-[38px] w-[38px] items-center justify-center rounded-full bg-state-warning-hover"> <span className="i-ri-error-warning-line h-[18px] w-[18px] text-util-colors-yellow-yellow-600" /> </div> - <h1 className="text-xl font-semibold text-text-primary">{t($ => $['errorRateLimited.title'])}</h1> - <p className="text-sm text-text-secondary">{t($ => $['errorRateLimited.body'])}</p> + <h1 className="text-xl font-semibold text-text-primary"> + {t(($) => $['errorRateLimited.title'])} + </h1> + <p className="text-sm text-text-secondary">{t(($) => $['errorRateLimited.body'])}</p> <Divider className="my-3" /> <Button variant="ghost" @@ -249,7 +254,7 @@ export default function DevicePage() { setErrMsg(null) }} > - {t($ => $.tryAgain)} + {t(($) => $.tryAgain)} </Button> </div> )} @@ -259,10 +264,10 @@ export default function DevicePage() { <div className="mb-2.5 flex h-[38px] w-[38px] items-center justify-center rounded-full bg-state-destructive-hover"> <span className="i-ri-close-circle-line h-[18px] w-[18px] text-util-colors-red-red-600" /> </div> - <h1 className="text-xl font-semibold text-text-primary">{t($ => $['errorLookupFailed.title'])}</h1> - <p className="text-sm text-text-secondary"> - {t($ => $['errorLookupFailed.body'])} - </p> + <h1 className="text-xl font-semibold text-text-primary"> + {t(($) => $['errorLookupFailed.title'])} + </h1> + <p className="text-sm text-text-secondary">{t(($) => $['errorLookupFailed.body'])}</p> <Divider className="my-3" /> <Button variant="ghost" @@ -272,7 +277,7 @@ export default function DevicePage() { setErrMsg(null) }} > - {t($ => $.tryAgain)} + {t(($) => $.tryAgain)} </Button> </div> )} @@ -280,9 +285,14 @@ export default function DevicePage() { {view.kind === 'error_sso' && ( <div className="flex flex-col gap-1"> <div className="mb-2.5 flex h-[38px] w-[38px] items-center justify-center rounded-full bg-state-warning-hover"> - <span aria-hidden="true" className="i-ri-error-warning-line h-[18px] w-[18px] text-util-colors-yellow-yellow-600" /> + <span + aria-hidden="true" + className="i-ri-error-warning-line h-[18px] w-[18px] text-util-colors-yellow-yellow-600" + /> </div> - <h1 className="text-xl font-semibold text-text-primary">{t($ => $['errorSso.title'])}</h1> + <h1 className="text-xl font-semibold text-text-primary"> + {t(($) => $['errorSso.title'])} + </h1> <p className="text-sm text-text-secondary">{ssoErrorCopy(view.code, t)}</p> <Divider className="my-3" /> <Button @@ -291,20 +301,16 @@ export default function DevicePage() { className="w-full" onClick={() => { setErrMsg(null) - if (view.userCode) - advanceFromCode(view.userCode) - else - setView({ kind: 'code_entry' }) + if (view.userCode) advanceFromCode(view.userCode) + else setView({ kind: 'code_entry' }) }} > - {t($ => $['errorSso.backToLoginOptions'])} + {t(($) => $['errorSso.backToLoginOptions'])} </Button> </div> )} - {errMsg && ( - <p className="mt-4 text-sm text-text-destructive">{errMsg}</p> - )} + {errMsg && <p className="mt-4 text-sm text-text-destructive">{errMsg}</p>} </> ) } diff --git a/web/app/device/utils/error-copy.ts b/web/app/device/utils/error-copy.ts index 0bcced750bf853..fee7a08dcfb8d8 100644 --- a/web/app/device/utils/error-copy.ts +++ b/web/app/device/utils/error-copy.ts @@ -29,8 +29,8 @@ const APPROVE_KEY: Record<string, DeviceFlowKey> = { export function approveErrorCopy(err: unknown, t: TFunction<'deviceFlow'>): string { if (err instanceof DeviceFlowError) - return t($ => $[APPROVE_KEY[err.code] ?? 'approveError.default']) - return t($ => $['approveError.default']) + return t(($) => $[APPROVE_KEY[err.code] ?? 'approveError.default']) + return t(($) => $['approveError.default']) } // SSO-branch failures arrive as a `sso_error` query param set by the backend @@ -40,17 +40,15 @@ const SSO_ERROR_KEY: Record<string, DeviceFlowKey> = { } export function ssoErrorCopy(code: string, t: TFunction<'deviceFlow'>): string { - return t($ => $[SSO_ERROR_KEY[code] ?? 'ssoError.default']) + return t(($) => $[SSO_ERROR_KEY[code] ?? 'ssoError.default']) } export type LookupOutcome = 'expired' | 'rate_limited' | 'failed' export function classifyLookupError(err: unknown): LookupOutcome { if (err instanceof DeviceFlowError) { - if (err.code === 'rate_limited' || err.status === 429) - return 'rate_limited' - if (err.code === 'server_error' || err.status >= 500) - return 'failed' + if (err.code === 'rate_limited' || err.status === 429) return 'rate_limited' + if (err.code === 'server_error' || err.status >= 500) return 'failed' } return 'expired' } diff --git a/web/app/device/utils/user-code.ts b/web/app/device/utils/user-code.ts index 30281ae08733d7..4f9272d1b6ba61 100644 --- a/web/app/device/utils/user-code.ts +++ b/web/app/device/utils/user-code.ts @@ -17,13 +17,10 @@ const USER_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXY3456789' // excludes 0 O 1 I export function normaliseUserCodeInput(raw: string): string { const cleaned: string[] = [] for (const ch of raw.toUpperCase()) { - if (USER_CODE_ALPHABET.includes(ch)) - cleaned.push(ch) - if (cleaned.length === 8) - break + if (USER_CODE_ALPHABET.includes(ch)) cleaned.push(ch) + if (cleaned.length === 8) break } - if (cleaned.length <= 4) - return cleaned.join('') + if (cleaned.length <= 4) return cleaned.join('') return `${cleaned.slice(0, 4).join('')}-${cleaned.slice(4).join('')}` } @@ -32,6 +29,8 @@ export function normaliseUserCodeInput(raw: string): string { * token suitable for submission to /openapi/v1/oauth/device/lookup. */ export function isValidUserCode(normalised: string): boolean { - return /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(normalised) - && [...normalised.replace('-', '')].every(c => USER_CODE_ALPHABET.includes(c)) + return ( + /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(normalised) && + [...normalised.replace('-', '')].every((c) => USER_CODE_ALPHABET.includes(c)) + ) } diff --git a/web/app/education-apply/__tests__/search-input.spec.tsx b/web/app/education-apply/__tests__/search-input.spec.tsx index 38ccb2c9a5579a..5efe9c0ffb6b10 100644 --- a/web/app/education-apply/__tests__/search-input.spec.tsx +++ b/web/app/education-apply/__tests__/search-input.spec.tsx @@ -33,7 +33,9 @@ describe('education-apply/search-input', () => { render(<ControlledSearchInput />) - const input = screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/) as HTMLInputElement + const input = screen.getByPlaceholderText( + /(?:^|\.)form\.schoolName\.placeholder(?=$|:)/, + ) as HTMLInputElement expect(input.type).toBe('text') await user.type(input, 'Alpha') @@ -51,7 +53,10 @@ describe('education-apply/search-input', () => { render(<ControlledSearchInput />) - await user.type(screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/), 'A') + await user.type( + screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/), + 'A', + ) expect(screen.getByText('Alpha University')).toBeInTheDocument() @@ -67,7 +72,10 @@ describe('education-apply/search-input', () => { render(<ControlledSearchInput />) - await user.type(screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/), 'A') + await user.type( + screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/), + 'A', + ) const scrollContainer = screen.getByText('Alpha University').parentElement as HTMLDivElement Object.defineProperties(scrollContainer, { diff --git a/web/app/education-apply/applied-education-content.tsx b/web/app/education-apply/applied-education-content.tsx index 1eed870c0730e4..c8e07170f0bb6e 100644 --- a/web/app/education-apply/applied-education-content.tsx +++ b/web/app/education-apply/applied-education-content.tsx @@ -4,10 +4,7 @@ import type { TenantListItemResponse } from '@dify/contracts/api/console/workspa import type { ReactNode } from 'react' import type { Plan as PlanType } from '@/app/components/billing/type' import type { ICurrentWorkspace } from '@/models/common' -import { - Select, - SelectTrigger, -} from '@langgenius/dify-ui/select' +import { Select, SelectTrigger } from '@langgenius/dify-ui/select' import { useTranslation } from 'react-i18next' import { Plan } from '@/app/components/billing/type' import { WorkplaceSelectorContent } from '@/app/components/header/account-dropdown/workplace-selector' @@ -35,7 +32,7 @@ const AppliedEducationContent = ({ onSwitchWorkspace, }: AppliedEducationContentProps) => { const { t } = useTranslation() - const currentWorkspaceInList = workspaces.find(workspace => workspace.current) + const currentWorkspaceInList = workspaces.find((workspace) => workspace.current) const workspacePlan = isWorkspacePlan(currentWorkspaceInList?.plan) ? currentWorkspaceInList.plan : isWorkspacePlan(plan) @@ -53,7 +50,7 @@ const AppliedEducationContent = ({ </div> <div> <div className="text-text-secondary"> - {t($ => $['applied.step1.description'], { ns: 'education' })} + {t(($) => $['applied.step1.description'], { ns: 'education' })} </div> </div> </div> @@ -65,7 +62,7 @@ const AppliedEducationContent = ({ </div> <div> <div className="system-xl-medium text-text-secondary"> - {t($ => $['applied.step2.description'], { ns: 'education' })} + {t(($) => $['applied.step2.description'], { ns: 'education' })} </div> </div> </div> @@ -73,8 +70,7 @@ const AppliedEducationContent = ({ <Select value={workspaceId ?? ''} onValueChange={(value) => { - if (value) - onSwitchWorkspace(value) + if (value) onSwitchWorkspace(value) }} > <SelectTrigger className="h-12! w-fit max-w-full min-w-[280px] cursor-pointer justify-between rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3! py-1.5! hover:bg-state-base-hover"> @@ -84,15 +80,15 @@ const AppliedEducationContent = ({ {workspaceName?.[0]?.toLocaleUpperCase()} </span> </span> - <span className="min-w-0 truncate system-md-semibold text-text-primary">{workspaceName}</span> + <span className="min-w-0 truncate system-md-semibold text-text-primary"> + {workspaceName} + </span> <PlanBadge plan={workspacePlan} /> </span> </SelectTrigger> <WorkplaceSelectorContent workspaces={workspaces} /> </Select> - <div className="mt-3 pr-5"> - {action} - </div> + <div className="mt-3 pr-5">{action}</div> </div> </div> </div> diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index 362424bd2588e8..e0a13bf1d0eb78 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -19,15 +19,9 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useProviderContext } from '@/context/provider-context' import { currentWorkspaceAtom } from '@/context/workspace-state' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' -import { - useRouter, - useSearchParams, -} from '@/next/navigation' +import { useRouter, useSearchParams } from '@/next/navigation' import { consoleClient, consoleQuery } from '@/service/client' -import { - useEducationAdd, - useInvalidateEducationStatus, -} from '@/service/use-education' +import { useEducationAdd, useInvalidateEducationStatus } from '@/service/use-education' import { BillingPermission, hasPermission } from '@/utils/permission' import DifyLogo from '../components/base/logo/dify-logo' import AppliedEducationContent from './applied-education-content' @@ -49,10 +43,7 @@ const EducationApplyAgeContent = () => { const [inSchoolChecked, setInSchoolChecked] = useState(false) const [hasSubmittedEducation, setHasSubmittedEducation] = useState(false) const [isOpeningBillingPortal, setIsOpeningBillingPortal] = useState(false) - const { - isPending, - mutateAsync: educationAdd, - } = useEducationAdd({ onSuccess: noop }) + const { isPending, mutateAsync: educationAdd } = useEducationAdd({ onSuccess: noop }) const { onPlanInfoChanged, isEducationAccount, plan } = useProviderContext() const currentWorkspace = useAtomValue(currentWorkspaceAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) @@ -69,11 +60,9 @@ const EducationApplyAgeContent = () => { const token = searchParams.get('token') const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage) const appliedEducationCase = (() => { - if (!canManageBilling) - return AppliedEducationCase.noPaymentPermission + if (!canManageBilling) return AppliedEducationCase.noPaymentPermission - if (plan.type === Plan.sandbox) - return AppliedEducationCase.eligible + if (plan.type === Plan.sandbox) return AppliedEducationCase.eligible return AppliedEducationCase.activeSubscription })() @@ -88,31 +77,30 @@ const EducationApplyAgeContent = () => { updateEducationStatus() setEducationVerifying(null) setHasSubmittedEducation(true) - } - else { - toast.error(t($ => $.submitError, { ns: 'education' })) + } else { + toast.error(t(($) => $.submitError, { ns: 'education' })) } }) } const handleOpenBillingPortal = async () => { - if (isOpeningBillingPortal) - return + if (isOpeningBillingPortal) return setIsOpeningBillingPortal(true) try { - await openAsyncWindow(async () => { - const res = await consoleClient.billing.invoices.get() - if (res.url) - return res.url + await openAsyncWindow( + async () => { + const res = await consoleClient.billing.invoices.get() + if (res.url) return res.url - throw new Error('Failed to open billing page') - }, { - onError: (err) => { - toast.error(err.message || String(err)) + throw new Error('Failed to open billing page') }, - }) - } - finally { + { + onError: (err) => { + toast.error(err.message || String(err)) + }, + }, + ) + } finally { setIsOpeningBillingPortal(false) } } @@ -122,12 +110,11 @@ const EducationApplyAgeContent = () => { const renderBackToDifyButton = () => ( <Button variant="ghost-accent" onClick={handleReturnHome}> <span className="mr-1 i-ri-arrow-left-line size-4" /> - {t($ => $['applied.noPaymentPermission.returnHome'], { ns: 'education' })} + {t(($) => $['applied.noPaymentPermission.returnHome'], { ns: 'education' })} </Button> ) const handleSwitchWorkspace = async (tenantId: string) => { - if (tenantId === currentWorkspace?.id) - return + if (tenantId === currentWorkspace?.id) return try { await switchWorkspaceMutation.mutateAsync({ body: { tenant_id: tenantId } }) @@ -137,9 +124,8 @@ const EducationApplyAgeContent = () => { ]) onPlanInfoChanged() updateEducationStatus() - } - catch { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } catch { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) } } @@ -147,7 +133,7 @@ const EducationApplyAgeContent = () => { if (appliedEducationCase === AppliedEducationCase.eligible) { return ( <Button variant="primary" onClick={handleEducationDiscount}> - {t($ => $.useEducationDiscount, { ns: 'education' })} + {t(($) => $.useEducationDiscount, { ns: 'education' })} </Button> ) } @@ -159,7 +145,7 @@ const EducationApplyAgeContent = () => { <span className="mt-0.5 mr-2 i-ri-alert-fill size-4 shrink-0 text-text-warning-secondary" /> <div className="system-md-regular text-text-warning"> <Trans - i18nKey={$ => $['applied.activeSubscription.description']} + i18nKey={($) => $['applied.activeSubscription.description']} ns="education" components={{ stripeLink: ( @@ -184,7 +170,7 @@ const EducationApplyAgeContent = () => { <div className="flex w-full items-start rounded-lg border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover px-3 py-2.5"> <span className="mt-0.5 mr-2 i-ri-alert-fill size-4 shrink-0 text-text-warning-secondary" /> <div className="system-md-regular text-text-warning"> - {t($ => $['applied.noPaymentPermission.description'], { ns: 'education' })} + {t(($) => $['applied.noPaymentPermission.description'], { ns: 'education' })} </div> </div> {renderBackToDifyButton()} @@ -200,109 +186,116 @@ const EducationApplyAgeContent = () => { style={{ backgroundImage: 'url(/education/bg.png)', }} - > - </div> + ></div> <div className="mt-[-349px] box-content flex h-7 items-center justify-between p-6"> <DifyLogo size="large" style="monochromeWhite" /> </div> <div className="mx-auto max-w-[720px] px-8 pb-[180px]"> <div className="mb-2 flex h-[192px] flex-col justify-end pt-3 pb-4 text-text-primary-on-surface"> - <div className="mb-2 title-5xl-bold shadow-xs">{t($ => $.toVerified, { ns: 'education' })}</div> + <div className="mb-2 title-5xl-bold shadow-xs"> + {t(($) => $.toVerified, { ns: 'education' })} + </div> <div className="system-md-medium shadow-xs"> - {t($ => $['toVerifiedTip.front'], { ns: 'education' })} + {t(($) => $['toVerifiedTip.front'], { ns: 'education' })}   - <span className="system-md-semibold underline">{t($ => $['toVerifiedTip.coupon'], { ns: 'education' })}</span> + <span className="system-md-semibold underline"> + {t(($) => $['toVerifiedTip.coupon'], { ns: 'education' })} + </span>   - {t($ => $['toVerifiedTip.end'], { ns: 'education' })} + {t(($) => $['toVerifiedTip.end'], { ns: 'education' })} </div> </div> <div className="mb-7"> <UserInfo /> </div> - {isEducationAccount || hasSubmittedEducation - ? ( - <div className="flex"> - <AppliedEducationWorkspaceContent - currentWorkspace={currentWorkspace} - plan={plan.type} - action={renderAppliedEducationAction()} - onSwitchWorkspace={(value) => { - void handleSwitchWorkspace(value) - }} - /> + {isEducationAccount || hasSubmittedEducation ? ( + <div className="flex"> + <AppliedEducationWorkspaceContent + currentWorkspace={currentWorkspace} + plan={plan.type} + action={renderAppliedEducationAction()} + onSwitchWorkspace={(value) => { + void handleSwitchWorkspace(value) + }} + /> + </div> + ) : ( + <> + <div className="mb-7"> + <div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary"> + {t(($) => $['form.schoolName.title'], { ns: 'education' })} </div> - ) - : ( - <> - <div className="mb-7"> - <div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary"> - {t($ => $['form.schoolName.title'], { ns: 'education' })} - </div> - <SearchInput - value={schoolName} - onChange={setSchoolName} - /> - </div> - <div className="mb-7"> - <div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary"> - {t($ => $['form.schoolRole.title'], { ns: 'education' })} - </div> - <RoleSelector - value={role} - onChange={setRole} - /> - </div> - <div className="mb-7"> - <div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary"> - {t($ => $['form.terms.title'], { ns: 'education' })} - </div> - <div className="mb-1 system-md-regular text-text-tertiary"> - {t($ => $['form.terms.desc.front'], { ns: 'education' })} -   - <a href="https://dify.ai/terms" target="_blank" className="text-text-secondary hover:underline">{t($ => $['form.terms.desc.termsOfService'], { ns: 'education' })}</a> -   - {t($ => $['form.terms.desc.and'], { ns: 'education' })} -   - <a href="https://dify.ai/privacy" target="_blank" className="text-text-secondary hover:underline">{t($ => $['form.terms.desc.privacyPolicy'], { ns: 'education' })}</a> - {t($ => $['form.terms.desc.end'], { ns: 'education' })} - </div> - <div className="py-2 system-md-regular text-text-primary"> - <label className="mb-2 flex"> - <Checkbox - className="mr-2 shrink-0" - checked={ageChecked} - onCheckedChange={setAgeChecked} - /> - {t($ => $['form.terms.option.age'], { ns: 'education' })} - </label> - <label className="flex"> - <Checkbox - className="mr-2 shrink-0" - checked={inSchoolChecked} - onCheckedChange={setInSchoolChecked} - /> - {t($ => $['form.terms.option.inSchool'], { ns: 'education' })} - </label> - </div> - </div> - <Button - variant="primary" - disabled={!ageChecked || !inSchoolChecked || !schoolName || !role || isPending} - onClick={handleSubmit} + <SearchInput value={schoolName} onChange={setSchoolName} /> + </div> + <div className="mb-7"> + <div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary"> + {t(($) => $['form.schoolRole.title'], { ns: 'education' })} + </div> + <RoleSelector value={role} onChange={setRole} /> + </div> + <div className="mb-7"> + <div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary"> + {t(($) => $['form.terms.title'], { ns: 'education' })} + </div> + <div className="mb-1 system-md-regular text-text-tertiary"> + {t(($) => $['form.terms.desc.front'], { ns: 'education' })} +   + <a + href="https://dify.ai/terms" + target="_blank" + className="text-text-secondary hover:underline" > - {t($ => $.submit, { ns: 'education' })} - </Button> - <div className="mt-5 mb-4 h-px bg-linear-to-r from-[rgba(16,24,40,0.08)]"></div> + {t(($) => $['form.terms.desc.termsOfService'], { ns: 'education' })} + </a> +   + {t(($) => $['form.terms.desc.and'], { ns: 'education' })} +   <a - className="flex items-center system-xs-regular text-text-accent" - href={docLink('/use-dify/workspace/subscription-management#dify-for-education')} + href="https://dify.ai/privacy" target="_blank" + className="text-text-secondary hover:underline" > - {t($ => $.learn, { ns: 'education' })} - <span className="ml-1 i-ri-external-link-line size-3" /> + {t(($) => $['form.terms.desc.privacyPolicy'], { ns: 'education' })} </a> - </> - )} + {t(($) => $['form.terms.desc.end'], { ns: 'education' })} + </div> + <div className="py-2 system-md-regular text-text-primary"> + <label className="mb-2 flex"> + <Checkbox + className="mr-2 shrink-0" + checked={ageChecked} + onCheckedChange={setAgeChecked} + /> + {t(($) => $['form.terms.option.age'], { ns: 'education' })} + </label> + <label className="flex"> + <Checkbox + className="mr-2 shrink-0" + checked={inSchoolChecked} + onCheckedChange={setInSchoolChecked} + /> + {t(($) => $['form.terms.option.inSchool'], { ns: 'education' })} + </label> + </div> + </div> + <Button + variant="primary" + disabled={!ageChecked || !inSchoolChecked || !schoolName || !role || isPending} + onClick={handleSubmit} + > + {t(($) => $.submit, { ns: 'education' })} + </Button> + <div className="mt-5 mb-4 h-px bg-linear-to-r from-[rgba(16,24,40,0.08)]"></div> + <a + className="flex items-center system-xs-regular text-text-accent" + href={docLink('/use-dify/workspace/subscription-management#dify-for-education')} + target="_blank" + > + {t(($) => $.learn, { ns: 'education' })} + <span className="ml-1 i-ri-external-link-line size-3" /> + </a> + </> + )} </div> </div> </div> @@ -340,4 +333,4 @@ const EducationApplyAge = () => <EducationApplyAgeContent /> export default EducationApplyAge -type AppliedEducationCase = typeof AppliedEducationCase[keyof typeof AppliedEducationCase] +type AppliedEducationCase = (typeof AppliedEducationCase)[keyof typeof AppliedEducationCase] diff --git a/web/app/education-apply/expire-notice-modal.tsx b/web/app/education-apply/expire-notice-modal.tsx index 08c523e83dd88d..cfec9829aa527a 100644 --- a/web/app/education-apply/expire-notice-modal.tsx +++ b/web/app/education-apply/expire-notice-modal.tsx @@ -28,13 +28,12 @@ const ExpireNoticeModal: React.FC<Props> = ({ expireAt, expired, onClose }) => { const docLink = useDocLink() const eduDocLink = docLink('/use-dify/workspace/subscription-management#dify-for-education') const { formatTime } = useTimestamp() - const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal) + const setShowPricingModal = useModalContextSelector((s) => s.setShowPricingModal) const { mutateAsync } = useEducationVerify() const router = useRouter() const handleVerify = async () => { const { token } = await mutateAsync() - if (token) - router.push(`/education-apply?token=${token}`) + if (token) router.push(`/education-apply?token=${token}`) } const handleConfirm = async () => { await handleVerify() @@ -45,62 +44,85 @@ const ExpireNoticeModal: React.FC<Props> = ({ expireAt, expired, onClose }) => { <Dialog open onOpenChange={(open) => { - if (!open) - onClose() + if (!open) onClose() }} > <DialogContent className="w-full max-w-[600px] overflow-hidden! border-none text-left align-middle"> <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {expired ? t($ => $[`${i18nPrefix}.expired.title`], { ns: 'education' }) : t($ => $[`${i18nPrefix}.isAboutToExpire.title`], { ns: 'education', date: formatTime(expireAt, t($ => $[`${i18nPrefix}.dateFormat`], { ns: 'education' }) as string), interpolation: { escapeValue: false } })} + {expired + ? t(($) => $[`${i18nPrefix}.expired.title`], { ns: 'education' }) + : t(($) => $[`${i18nPrefix}.isAboutToExpire.title`], { + ns: 'education', + date: formatTime( + expireAt, + t(($) => $[`${i18nPrefix}.dateFormat`], { ns: 'education' }) as string, + ), + interpolation: { escapeValue: false }, + })} </DialogTitle> <div className="mt-5 space-y-5 body-md-regular text-text-secondary"> <div> - {expired - ? ( - <> - <div>{t($ => $[`${i18nPrefix}.expired.summary.line1`], { ns: 'education' })}</div> - <div>{t($ => $[`${i18nPrefix}.expired.summary.line2`], { ns: 'education' })}</div> - </> - ) - : t($ => $[`${i18nPrefix}.isAboutToExpire.summary`], { ns: 'education' })} + {expired ? ( + <> + <div>{t(($) => $[`${i18nPrefix}.expired.summary.line1`], { ns: 'education' })}</div> + <div>{t(($) => $[`${i18nPrefix}.expired.summary.line2`], { ns: 'education' })}</div> + </> + ) : ( + t(($) => $[`${i18nPrefix}.isAboutToExpire.summary`], { ns: 'education' }) + )} </div> <div> - <strong className="block title-md-semi-bold">{t($ => $[`${i18nPrefix}.stillInEducation.title`], { ns: 'education' })}</strong> - {t($ => $[`${i18nPrefix}.stillInEducation.${expired ? 'expired' : 'isAboutToExpire'}`], { ns: 'education' })} + <strong className="block title-md-semi-bold"> + {t(($) => $[`${i18nPrefix}.stillInEducation.title`], { ns: 'education' })} + </strong> + {t( + ($) => $[`${i18nPrefix}.stillInEducation.${expired ? 'expired' : 'isAboutToExpire'}`], + { ns: 'education' }, + )} </div> <div> - <strong className="block title-md-semi-bold">{t($ => $[`${i18nPrefix}.alreadyGraduated.title`], { ns: 'education' })}</strong> - {t($ => $[`${i18nPrefix}.alreadyGraduated.${expired ? 'expired' : 'isAboutToExpire'}`], { ns: 'education' })} + <strong className="block title-md-semi-bold"> + {t(($) => $[`${i18nPrefix}.alreadyGraduated.title`], { ns: 'education' })} + </strong> + {t( + ($) => $[`${i18nPrefix}.alreadyGraduated.${expired ? 'expired' : 'isAboutToExpire'}`], + { ns: 'education' }, + )} </div> </div> <div className="mt-7 flex items-center justify-between space-x-2"> - <Link className="flex items-center space-x-1 system-xs-regular text-text-accent" href={eduDocLink} target="_blank" rel="noopener noreferrer"> - <div>{t($ => $.learn, { ns: 'education' })}</div> + <Link + className="flex items-center space-x-1 system-xs-regular text-text-accent" + href={eduDocLink} + target="_blank" + rel="noopener noreferrer" + > + <div>{t(($) => $.learn, { ns: 'education' })}</div> <RiExternalLinkLine className="size-3" /> </Link> <div className="flex space-x-2"> - {expired && IS_CLOUD_EDITION - ? ( - <Button - onClick={() => { - onClose() - setShowPricingModal() - }} - className="flex items-center space-x-1" - > - <SparklesSoftAccent className="size-4" /> - <div className="text-components-button-secondary-accent-text">{t($ => $[`${i18nPrefix}.action.upgrade`], { ns: 'education' })}</div> - </Button> - ) - : ( - <Button onClick={onClose}> - {t($ => $[`${i18nPrefix}.action.dismiss`], { ns: 'education' })} - </Button> - )} + {expired && IS_CLOUD_EDITION ? ( + <Button + onClick={() => { + onClose() + setShowPricingModal() + }} + className="flex items-center space-x-1" + > + <SparklesSoftAccent className="size-4" /> + <div className="text-components-button-secondary-accent-text"> + {t(($) => $[`${i18nPrefix}.action.upgrade`], { ns: 'education' })} + </div> + </Button> + ) : ( + <Button onClick={onClose}> + {t(($) => $[`${i18nPrefix}.action.dismiss`], { ns: 'education' })} + </Button> + )} <Button variant="primary" onClick={handleConfirm}> - {t($ => $[`${i18nPrefix}.action.reVerify`], { ns: 'education' })} + {t(($) => $[`${i18nPrefix}.action.reVerify`], { ns: 'education' })} </Button> </div> </div> diff --git a/web/app/education-apply/hooks.ts b/web/app/education-apply/hooks.ts index d4a1e6fa2748b0..cb51f74695b9b9 100644 --- a/web/app/education-apply/hooks.ts +++ b/web/app/education-apply/hooks.ts @@ -4,11 +4,7 @@ import { useDebounceFn } from 'ahooks' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' -import { - useCallback, - useEffect, - useState, -} from 'react' +import { useCallback, useEffect, useState } from 'react' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useEducationExpiredHasNoticed, @@ -21,39 +17,37 @@ import { useProviderContext } from '@/context/provider-context' import { userProfileQueryOptions } from '@/features/account-profile/client' import { useRouter, useSearchParams } from '@/next/navigation' import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education' -import { - EDUCATION_RE_VERIFY_ACTION, - EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, -} from './constants' +import { EDUCATION_RE_VERIFY_ACTION, EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION } from './constants' dayjs.extend(utc) dayjs.extend(timezone) export const useEducation = () => { - const { - mutateAsync, - isPending, - data, - } = useEducationAutocomplete() + const { mutateAsync, isPending, data } = useEducationAutocomplete() const [prevSchools, setPrevSchools] = useState<string[]>([]) - const handleUpdateSchools = useCallback((searchParams: SearchParams) => { - if (searchParams.keywords) { - mutateAsync(searchParams).then((res) => { - const currentPage = searchParams.page || 0 - const resSchools = res.data - if (currentPage > 0) - setPrevSchools(prevSchools => [...(prevSchools || []), ...resSchools]) - else - setPrevSchools(resSchools) - }) - } - }, [mutateAsync]) + const handleUpdateSchools = useCallback( + (searchParams: SearchParams) => { + if (searchParams.keywords) { + mutateAsync(searchParams).then((res) => { + const currentPage = searchParams.page || 0 + const resSchools = res.data + if (currentPage > 0) + setPrevSchools((prevSchools) => [...(prevSchools || []), ...resSchools]) + else setPrevSchools(resSchools) + }) + } + }, + [mutateAsync], + ) - const { run: querySchoolsWithDebounced } = useDebounceFn((searchParams: SearchParams) => { - handleUpdateSchools(searchParams) - }, { - wait: 300, - }) + const { run: querySchoolsWithDebounced } = useDebounceFn( + (searchParams: SearchParams) => { + handleUpdateSchools(searchParams) + }, + { + wait: 300, + }, + ) return { schools: prevSchools, @@ -66,40 +60,34 @@ export const useEducation = () => { } type useEducationReverifyNoticeParams = { - onNotice: ({ - expireAt, - expired, - }: { - expireAt: number - expired: boolean - }) => void + onNotice: ({ expireAt, expired }: { expireAt: number; expired: boolean }) => void } const isExpired = (expireAt?: number, timezone?: string) => { - if (!expireAt || !timezone) - return false + if (!expireAt || !timezone) return false const today = dayjs().tz(timezone).startOf('day') const expiredDay = dayjs.unix(expireAt).tz(timezone).startOf('day') return today.isSame(expiredDay) || today.isAfter(expiredDay) } -const useEducationReverifyNotice = ({ - onNotice, -}: useEducationReverifyNoticeParams) => { +const useEducationReverifyNotice = ({ onNotice }: useEducationReverifyNoticeParams) => { const { data: timezone } = useQuery({ ...userProfileQueryOptions(), - select: data => data.profile.timezone ?? undefined, + select: (data) => data.profile.timezone ?? undefined, }) // const [educationInfo, setEducationInfo] = useState<{ is_student: boolean, allow_refresh: boolean, expire_at: number | null } | null>(null) // const isLoading = !educationInfo - const { educationAccountExpireAt, allowRefreshEducationVerify, isLoadingEducationAccountInfo: isLoading } = useProviderContext() + const { + educationAccountExpireAt, + allowRefreshEducationVerify, + isLoadingEducationAccountInfo: isLoading, + } = useProviderContext() const [prevExpireAt, setPrevExpireAt] = useEducationReverifyPrevExpireAt() const [reverifyHasNoticed, setReverifyHasNoticed] = useEducationReverifyHasNoticed() const [expiredHasNoticed, setExpiredHasNoticed] = useEducationExpiredHasNoticed() useEffect(() => { - if (isLoading || !timezone) - return + if (isLoading || !timezone) return if (allowRefreshEducationVerify) { const expired = isExpired(educationAccountExpireAt!, timezone) const isExpireAtChanged = prevExpireAt !== educationAccountExpireAt @@ -109,8 +97,7 @@ const useEducationReverifyNotice = ({ setExpiredHasNoticed(false) } const shouldNotice = (() => { - if (isExpireAtChanged) - return true + if (isExpireAtChanged) return true return expired ? !expiredHasNoticed : !reverifyHasNoticed })() if (shouldNotice) { @@ -118,10 +105,8 @@ const useEducationReverifyNotice = ({ expireAt: educationAccountExpireAt!, expired, }) - if (expired) - setExpiredHasNoticed(true) - else - setReverifyHasNoticed(true) + if (expired) setExpiredHasNoticed(true) + else setReverifyHasNoticed(true) } } }, [allowRefreshEducationVerify, timezone]) @@ -134,8 +119,10 @@ const useEducationReverifyNotice = ({ } export const useEducationInit = () => { - const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) - const setShowEducationExpireNoticeModal = useModalContextSelector(s => s.setShowEducationExpireNoticeModal) + const setShowAccountSettingModal = useModalContextSelector((s) => s.setShowAccountSettingModal) + const setShowEducationExpireNoticeModal = useModalContextSelector( + (s) => s.setShowEducationExpireNoticeModal, + ) const [educationVerifying, setEducationVerifying] = useEducationVerifying() const searchParams = useSearchParams() const educationVerifyAction = searchParams.get('action') @@ -150,18 +137,19 @@ export const useEducationInit = () => { const { mutateAsync } = useEducationVerify() const handleVerify = async () => { const { token } = await mutateAsync() - if (token) - router.push(`/education-apply?token=${token}`) + if (token) router.push(`/education-apply?token=${token}`) } useEffect(() => { - if (educationVerifying === 'yes' || educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) { + if ( + educationVerifying === 'yes' || + educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION + ) { setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) setEducationVerifying('yes') } - if (educationVerifyAction === EDUCATION_RE_VERIFY_ACTION) - handleVerify() + if (educationVerifyAction === EDUCATION_RE_VERIFY_ACTION) handleVerify() }, [setShowAccountSettingModal, setEducationVerifying, educationVerifying, educationVerifyAction]) } diff --git a/web/app/education-apply/role-selector.tsx b/web/app/education-apply/role-selector.tsx index 342eff7b4aad8d..68878475abfa81 100644 --- a/web/app/education-apply/role-selector.tsx +++ b/web/app/education-apply/role-selector.tsx @@ -6,46 +6,40 @@ type RoleSelectorProps = { value: string } -const RoleSelector = ({ - onChange, - value, -}: RoleSelectorProps) => { +const RoleSelector = ({ onChange, value }: RoleSelectorProps) => { const { t } = useTranslation() const options = [ { key: 'Student', - value: t($ => $['form.schoolRole.option.student'], { ns: 'education' }), + value: t(($) => $['form.schoolRole.option.student'], { ns: 'education' }), }, { key: 'Teacher', - value: t($ => $['form.schoolRole.option.teacher'], { ns: 'education' }), + value: t(($) => $['form.schoolRole.option.teacher'], { ns: 'education' }), }, { key: 'School-Administrator', - value: t($ => $['form.schoolRole.option.administrator'], { ns: 'education' }), + value: t(($) => $['form.schoolRole.option.administrator'], { ns: 'education' }), }, ] return ( <div className="flex"> - { - options.map(option => ( + {options.map((option) => ( + <div + key={option.key} + className="mr-6 flex h-5 cursor-pointer items-center system-md-regular text-text-primary" + onClick={() => onChange(option.key)} + > <div - key={option.key} - className="mr-6 flex h-5 cursor-pointer items-center system-md-regular text-text-primary" - onClick={() => onChange(option.key)} - > - <div - className={cn( - 'mr-2 size-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs', - option.key === value && 'border-[5px] border-components-radio-border-checked', - )} - > - </div> - {option.value} - </div> - )) - } + className={cn( + 'mr-2 size-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs', + option.key === value && 'border-[5px] border-components-radio-border-checked', + )} + ></div> + {option.value} + </div> + ))} </div> ) } diff --git a/web/app/education-apply/search-input.tsx b/web/app/education-apply/search-input.tsx index fddeb393ce81d5..0add876a7bc576 100644 --- a/web/app/education-apply/search-input.tsx +++ b/web/app/education-apply/search-input.tsx @@ -1,14 +1,6 @@ import type { ChangeEventHandler, UIEventHandler } from 'react' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - useCallback, - useRef, - useState, -} from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import { useEducation } from './hooks' @@ -18,103 +10,95 @@ type SearchInputProps = { onChange: (value: string) => void } -const SearchInput = ({ - value, - onChange, -}: SearchInputProps) => { +const SearchInput = ({ value, onChange }: SearchInputProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) - const { - schools, - setSchools, - querySchoolsWithDebounced, - handleUpdateSchools, - hasNext, - } = useEducation() + const { schools, setSchools, querySchoolsWithDebounced, handleUpdateSchools, hasNext } = + useEducation() const pageRef = useRef(0) const valueRef = useRef(value) - const handleSearch = useCallback((debounced?: boolean) => { - const keywords = valueRef.current - const page = pageRef.current - if (debounced) { - querySchoolsWithDebounced({ + const handleSearch = useCallback( + (debounced?: boolean) => { + const keywords = valueRef.current + const page = pageRef.current + if (debounced) { + querySchoolsWithDebounced({ + keywords, + page, + }) + return + } + + handleUpdateSchools({ keywords, page, }) - return - } - - handleUpdateSchools({ - keywords, - page, - }) - }, [handleUpdateSchools, querySchoolsWithDebounced]) + }, + [handleUpdateSchools, querySchoolsWithDebounced], + ) - const handleValueChange: ChangeEventHandler<HTMLInputElement> = useCallback((e) => { - setOpen(true) - setSchools([]) - pageRef.current = 0 - const inputValue = e.target.value - valueRef.current = inputValue - onChange(inputValue) - handleSearch(true) - }, [handleSearch, onChange, setSchools]) + const handleValueChange: ChangeEventHandler<HTMLInputElement> = useCallback( + (e) => { + setOpen(true) + setSchools([]) + pageRef.current = 0 + const inputValue = e.target.value + valueRef.current = inputValue + onChange(inputValue) + handleSearch(true) + }, + [handleSearch, onChange, setSchools], + ) - const handleScroll: UIEventHandler<HTMLDivElement> = useCallback((e) => { - const target = e.currentTarget - const { - scrollTop, - scrollHeight, - clientHeight, - } = target - if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0 && hasNext) { - pageRef.current += 1 - handleSearch() - } - }, [handleSearch, hasNext]) + const handleScroll: UIEventHandler<HTMLDivElement> = useCallback( + (e) => { + const target = e.currentTarget + const { scrollTop, scrollHeight, clientHeight } = target + if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0 && hasNext) { + pageRef.current += 1 + handleSearch() + } + }, + [handleSearch, hasNext], + ) return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton={false} - render={( + render={ <Input className="w-full" - placeholder={t($ => $['form.schoolName.placeholder'], { ns: 'education' })} + placeholder={t(($) => $['form.schoolName.placeholder'], { ns: 'education' })} value={value} onChange={handleValueChange} /> - )} + } /> - {!!schools.length && !!value - ? ( - <PopoverContent - placement="bottom" - sideOffset={4} - popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg" - > + {!!schools.length && !!value ? ( + <PopoverContent + placement="bottom" + sideOffset={4} + popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg" + > + <div className="max-h-[330px] overflow-y-auto" onScroll={handleScroll}> + {schools.map((school) => ( <div - className="max-h-[330px] overflow-y-auto" - onScroll={handleScroll} + key={school} + className="flex h-8 cursor-pointer items-center truncate rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover" + title={school} + onClick={() => { + onChange(school) + setOpen(false) + }} > - {schools.map(school => ( - <div - key={school} - className="flex h-8 cursor-pointer items-center truncate rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover" - title={school} - onClick={() => { - onChange(school) - setOpen(false) - }} - > - {school} - </div> - ))} + {school} </div> - </PopoverContent> - ) - : null} + ))} + </div> + </PopoverContent> + ) : null} </Popover> ) } diff --git a/web/app/education-apply/storage.ts b/web/app/education-apply/storage.ts index 209045b087ed1c..d460d622d317f9 100644 --- a/web/app/education-apply/storage.ts +++ b/web/app/education-apply/storage.ts @@ -2,11 +2,8 @@ import { createLocalStorageState } from 'foxact/create-local-storage-state' const EDUCATION_VERIFYING_LOCALSTORAGE_ITEM = 'educationVerifying' -const [ - useEducationVerifying, - _useEducationVerifyingValue, - useSetEducationVerifying, -] = createLocalStorageState<string>(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'no', { raw: true }) +const [useEducationVerifying, _useEducationVerifyingValue, useSetEducationVerifying] = + createLocalStorageState<string>(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'no', { raw: true }) const [ useEducationReverifyPrevExpireAt, diff --git a/web/app/education-apply/user-info.tsx b/web/app/education-apply/user-info.tsx index 709a65e6dfe04c..7bad32a261b81a 100644 --- a/web/app/education-apply/user-info.tsx +++ b/web/app/education-apply/user-info.tsx @@ -25,7 +25,7 @@ const UserInfo = () => { <div className="relative flex items-center justify-between rounded-xl border-4 border-components-panel-on-panel-item-bg bg-linear-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 pt-9 pr-8 pb-6 pl-6 shadow-shadow-shadow-5"> <div className="absolute top-0 left-0 flex items-center"> <div className="flex h-[22px] items-center bg-components-panel-on-panel-item-bg pt-1 pl-2 system-2xs-semibold-uppercase text-text-accent-light-mode-only"> - {t($ => $.currentSigned, { ns: 'education' })} + {t(($) => $.currentSigned, { ns: 'education' })} </div> <Triangle className="h-[22px] w-4 text-components-panel-on-panel-item-bg" /> </div> @@ -37,19 +37,12 @@ const UserInfo = () => { size="2xl" /> <div className="pt-1.5"> - <div className="system-md-semibold text-text-primary"> - {userProfile.name} - </div> - <div className="system-sm-regular text-text-secondary"> - {userProfile.email} - </div> + <div className="system-md-semibold text-text-primary">{userProfile.name}</div> + <div className="system-sm-regular text-text-secondary">{userProfile.email}</div> </div> </div> - <Button - variant="secondary" - onClick={handleLogout} - > - {t($ => $['userProfile.logout'], { ns: 'common' })} + <Button variant="secondary" onClick={handleLogout}> + {t(($) => $['userProfile.logout'], { ns: 'common' })} </Button> </div> ) diff --git a/web/app/education-apply/verify-state-modal.tsx b/web/app/education-apply/verify-state-modal.tsx index 2e6be19fcd4f04..1a26bdacf4c2ac 100644 --- a/web/app/education-apply/verify-state-modal.tsx +++ b/web/app/education-apply/verify-state-modal.tsx @@ -1,8 +1,6 @@ import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' -import { - RiExternalLinkLine, -} from '@remixicon/react' +import { RiExternalLinkLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' @@ -43,8 +41,7 @@ function Confirm({ <Dialog open={isShow} onOpenChange={(open) => { - if (!open) - onCancel() + if (!open) onCancel() }} disablePointerDismissal={!maskClosable} > @@ -56,20 +53,38 @@ function Confirm({ </div> {email && ( <div className="w-full space-y-1 px-6 py-3"> - <div className="py-1 system-sm-semibold text-text-secondary">{t($ => $.emailLabel, { ns: 'education' })}</div> - <div className="rounded-lg bg-components-input-bg-disabled px-3 py-2 system-sm-regular text-components-input-text-filled-disabled">{email}</div> + <div className="py-1 system-sm-semibold text-text-secondary"> + {t(($) => $.emailLabel, { ns: 'education' })} + </div> + <div className="rounded-lg bg-components-input-bg-disabled px-3 py-2 system-sm-regular text-components-input-text-filled-disabled"> + {email} + </div> </div> )} <div className="flex items-center justify-between gap-2 self-stretch p-6"> <div className="flex items-center gap-1"> {showLink && ( <> - <a onClick={handleClick} href={eduDocLink} target="_blank" rel="noopener noreferrer" className="cursor-pointer system-xs-regular text-text-accent">{t($ => $.learn, { ns: 'education' })}</a> + <a + onClick={handleClick} + href={eduDocLink} + target="_blank" + rel="noopener noreferrer" + className="cursor-pointer system-xs-regular text-text-accent" + > + {t(($) => $.learn, { ns: 'education' })} + </a> <RiExternalLinkLine className="size-3 text-text-accent" /> </> )} </div> - <Button variant="primary" className={confirmText ? 'min-w-20!' : 'w-20!'} onClick={onConfirm}>{confirmText || t($ => $['operation.ok'], { ns: 'common' })}</Button> + <Button + variant="primary" + className={confirmText ? 'min-w-20!' : 'w-20!'} + onClick={onConfirm} + > + {confirmText || t(($) => $['operation.ok'], { ns: 'common' })} + </Button> </div> </div> </DialogContent> diff --git a/web/app/error.tsx b/web/app/error.tsx index 5f113694d78f6b..0d17208ebe16a7 100644 --- a/web/app/error.tsx +++ b/web/app/error.tsx @@ -17,17 +17,16 @@ export default function AppError({ error, reset, unstable_retry }: Props) { console.error(error) - if (isLegacyBase401(error)) - return <FullScreenLoading /> + if (isLegacyBase401(error)) return <FullScreenLoading /> return ( <div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body"> <div className="system-sm-regular text-text-tertiary"> - {t($ => $['errorBoundary.message'])} + {t(($) => $['errorBoundary.message'])} </div> {retry && ( <Button size="small" variant="secondary" onClick={() => retry()}> - {t($ => $['errorBoundary.tryAgain'])} + {t(($) => $['errorBoundary.tryAgain'])} </Button> )} </div> diff --git a/web/app/forgot-password/ChangePasswordForm.spec.tsx b/web/app/forgot-password/ChangePasswordForm.spec.tsx index b69ca1bf1e6795..f9d8cc5ec318e3 100644 --- a/web/app/forgot-password/ChangePasswordForm.spec.tsx +++ b/web/app/forgot-password/ChangePasswordForm.spec.tsx @@ -50,7 +50,9 @@ describe('ChangePasswordForm', () => { render(<ChangePasswordForm />) - const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('input[type="password"]')) as [HTMLInputElement, HTMLInputElement] + const inputs = Array.from( + document.querySelectorAll<HTMLInputElement>('input[type="password"]'), + ) as [HTMLInputElement, HTMLInputElement] fireEvent.change(inputs[0], { target: { value: VALID_PASSWORD } }) fireEvent.change(inputs[1], { target: { value: VALID_PASSWORD } }) @@ -80,7 +82,9 @@ describe('ChangePasswordForm', () => { it('shows invalid token state and no form', () => { render(<ChangePasswordForm />) expect(screen.getByText('login.invalid')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /common\.operation\.reset/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /common\.operation\.reset/ }), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/app/forgot-password/ChangePasswordForm.tsx b/web/app/forgot-password/ChangePasswordForm.tsx index 2a4bb1135a902f..eb4b2b632176e9 100644 --- a/web/app/forgot-password/ChangePasswordForm.tsx +++ b/web/app/forgot-password/ChangePasswordForm.tsx @@ -19,10 +19,7 @@ const ChangePasswordForm = () => { const token = searchParams.get('token') const isTokenMissing = !token - const { - data: verifyTokenRes, - refetch: revalidateToken, - } = useVerifyForgotPasswordToken(token) + const { data: verifyTokenRes, refetch: revalidateToken } = useVerifyForgotPasswordToken(token) const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') @@ -34,15 +31,15 @@ const ChangePasswordForm = () => { const valid = useCallback(() => { if (!password.trim()) { - showErrorMessage(t($ => $['error.passwordEmpty'], { ns: 'login' })) + showErrorMessage(t(($) => $['error.passwordEmpty'], { ns: 'login' })) return false } if (!validPassword.test(password)) { - showErrorMessage(t($ => $['error.passwordInvalid'], { ns: 'login' })) + showErrorMessage(t(($) => $['error.passwordInvalid'], { ns: 'login' })) return false } if (password !== confirmPassword) { - showErrorMessage(t($ => $['account.notEqual'], { ns: 'common' })) + showErrorMessage(t(($) => $['account.notEqual'], { ns: 'common' })) return false } return true @@ -51,8 +48,7 @@ const ChangePasswordForm = () => { const handleChangePassword = useCallback(async () => { const resetToken = verifyTokenRes?.token ?? '' - if (!valid()) - return + if (!valid()) return try { await changePasswordWithToken({ url: '/forgot-password/resets', @@ -63,31 +59,33 @@ const ChangePasswordForm = () => { }, }) setShowSuccess(true) - } - catch { + } catch { await revalidateToken() } }, [confirmPassword, password, revalidateToken, verifyTokenRes?.token, valid]) return ( - <div className={ - cn( + <div + className={cn( 'flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]', - ) - } + )} > {!isTokenMissing && !verifyTokenRes && <Loading />} {(isTokenMissing || (verifyTokenRes && !verifyTokenRes.is_valid)) && ( <div className="flex flex-col md:w-[400px]"> <div className="mx-auto w-full"> - <div className="mb-3 flex h-20 w-20 items-center justify-center rounded-[20px] border border-divider-regular bg-components-option-card-option-bg p-5 text-[40px] font-bold shadow-lg">🤷‍♂️</div> - <h2 className="text-[32px] font-bold text-text-primary">{t($ => $.invalid, { ns: 'login' })}</h2> + <div className="mb-3 flex h-20 w-20 items-center justify-center rounded-[20px] border border-divider-regular bg-components-option-card-option-bg p-5 text-[40px] font-bold shadow-lg"> + 🤷‍♂️ + </div> + <h2 className="text-[32px] font-bold text-text-primary"> + {t(($) => $.invalid, { ns: 'login' })} + </h2> </div> <div className="mx-auto mt-6 w-full"> <Button variant="primary" className="w-full text-sm!"> - <a href="https://dify.ai">{t($ => $.explore, { ns: 'login' })}</a> + <a href="https://dify.ai">{t(($) => $.explore, { ns: 'login' })}</a> </Button> </div> </div> @@ -96,10 +94,10 @@ const ChangePasswordForm = () => { <div className="flex flex-col md:w-[400px]"> <div className="mx-auto w-full"> <h2 className="text-[32px] font-bold text-text-primary"> - {t($ => $.changePassword, { ns: 'login' })} + {t(($) => $.changePassword, { ns: 'login' })} </h2> <p className="mt-1 text-sm text-text-secondary"> - {t($ => $.changePasswordTip, { ns: 'login' })} + {t(($) => $.changePasswordTip, { ns: 'login' })} </p> </div> @@ -107,30 +105,38 @@ const ChangePasswordForm = () => { <div className="relative"> {/* Password */} <div className="mb-5"> - <label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary"> - {t($ => $['account.newPassword'], { ns: 'common' })} + <label + htmlFor="password" + className="my-2 flex items-center justify-between text-sm font-medium text-text-primary" + > + {t(($) => $['account.newPassword'], { ns: 'common' })} </label> <Input id="password" type="password" value={password} - onChange={e => setPassword(e.target.value)} - placeholder={t($ => $.passwordPlaceholder, { ns: 'login' }) || ''} + onChange={(e) => setPassword(e.target.value)} + placeholder={t(($) => $.passwordPlaceholder, { ns: 'login' }) || ''} className="mt-1" /> - <div className="mt-1 text-xs text-text-secondary">{t($ => $['error.passwordInvalid'], { ns: 'login' })}</div> + <div className="mt-1 text-xs text-text-secondary"> + {t(($) => $['error.passwordInvalid'], { ns: 'login' })} + </div> </div> {/* Confirm Password */} <div className="mb-5"> - <label htmlFor="confirmPassword" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary"> - {t($ => $['account.confirmPassword'], { ns: 'common' })} + <label + htmlFor="confirmPassword" + className="my-2 flex items-center justify-between text-sm font-medium text-text-primary" + > + {t(($) => $['account.confirmPassword'], { ns: 'common' })} </label> <Input id="confirmPassword" type="password" value={confirmPassword} - onChange={e => setConfirmPassword(e.target.value)} - placeholder={t($ => $.confirmPasswordPlaceholder, { ns: 'login' }) || ''} + onChange={(e) => setConfirmPassword(e.target.value)} + placeholder={t(($) => $.confirmPasswordPlaceholder, { ns: 'login' }) || ''} className="mt-1" /> </div> @@ -140,7 +146,7 @@ const ChangePasswordForm = () => { className="w-full text-sm!" onClick={handleChangePassword} > - {t($ => $['operation.reset'], { ns: 'common' })} + {t(($) => $['operation.reset'], { ns: 'common' })} </Button> </div> </div> @@ -154,12 +160,12 @@ const ChangePasswordForm = () => { <CheckCircleIcon className="h-10 w-10 text-[#039855]" /> </div> <h2 className="text-[32px] font-bold text-text-primary"> - {t($ => $.passwordChangedTip, { ns: 'login' })} + {t(($) => $.passwordChangedTip, { ns: 'login' })} </h2> </div> <div className="mx-auto mt-6 w-full"> <Button variant="primary" className="w-full"> - <a href={`${basePath}/signin`}>{t($ => $.passwordChanged, { ns: 'login' })}</a> + <a href={`${basePath}/signin`}>{t(($) => $.passwordChanged, { ns: 'login' })}</a> </Button> </div> </div> diff --git a/web/app/forgot-password/ForgotPasswordForm.spec.tsx b/web/app/forgot-password/ForgotPasswordForm.spec.tsx index 8ed120d146691e..79fabe78719771 100644 --- a/web/app/forgot-password/ForgotPasswordForm.spec.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.spec.tsx @@ -1,6 +1,10 @@ import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { fetchInitValidateStatus, fetchSetupStatus, sendForgotPasswordEmail } from '@/service/common' +import { + fetchInitValidateStatus, + fetchSetupStatus, + sendForgotPasswordEmail, +} from '@/service/common' import ForgotPasswordForm from './ForgotPasswordForm' const mockPush = vi.fn() @@ -21,7 +25,9 @@ const mockSendForgotPasswordEmail = vi.mocked(sendForgotPasswordEmail) const prepareLoadedState = () => { mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' } as SetupStatusResponse) - mockFetchInitValidateStatus.mockResolvedValue({ status: 'finished' } as InitValidateStatusResponse) + mockFetchInitValidateStatus.mockResolvedValue({ + status: 'finished', + } as InitValidateStatusResponse) } describe('ForgotPasswordForm', () => { @@ -79,7 +85,9 @@ describe('ForgotPasswordForm', () => { render(<ForgotPasswordForm />) - fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'test@example.com' } }) + fireEvent.change(await screen.findByLabelText('login.email'), { + target: { value: 'test@example.com' }, + }) const form = screen.getByRole('button', { name: /login\.sendResetLink/ }).closest('form') expect(form).not.toBeNull() @@ -103,7 +111,9 @@ describe('ForgotPasswordForm', () => { render(<ForgotPasswordForm />) - fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'test@example.com' } }) + fireEvent.change(await screen.findByLabelText('login.email'), { + target: { value: 'test@example.com' }, + }) const button = screen.getByRole('button', { name: /login\.sendResetLink/ }) fireEvent.click(button) @@ -128,7 +138,9 @@ describe('ForgotPasswordForm', () => { render(<ForgotPasswordForm />) - fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'test@example.com' } }) + fireEvent.change(await screen.findByLabelText('login.email'), { + target: { value: 'test@example.com' }, + }) fireEvent.click(screen.getByRole('button', { name: /login\.sendResetLink/ })) await waitFor(() => { @@ -147,7 +159,9 @@ describe('ForgotPasswordForm', () => { value: { href: '' }, writable: true, }) - mockFetchInitValidateStatus.mockResolvedValue({ status: 'not_started' } as InitValidateStatusResponse) + mockFetchInitValidateStatus.mockResolvedValue({ + status: 'not_started', + } as InitValidateStatusResponse) render(<ForgotPasswordForm />) diff --git a/web/app/forgot-password/ForgotPasswordForm.tsx b/web/app/forgot-password/ForgotPasswordForm.tsx index b2ce9c9da12ef5..5a6041be4b0090 100644 --- a/web/app/forgot-password/ForgotPasswordForm.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.tsx @@ -1,9 +1,7 @@ 'use client' import type { InitValidateStatusResponse } from '@/models/common' import { Button } from '@langgenius/dify-ui/button' - import { useStore } from '@tanstack/react-form' - import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -17,15 +15,13 @@ import { sendForgotPasswordEmail, } from '@/service/common' import { basePath } from '@/utils/var' - import Input from '../components/base/input' import Loading from '../components/base/loading' const accountFormSchema = z.object({ - email: z.email('error.emailInValid') - .min(1, { - error: 'error.emailInValid', - }), + email: z.email('error.emailInValid').min(1, { + error: 'error.emailInValid', + }), }) const ForgotPasswordForm = () => { @@ -45,27 +41,23 @@ const ForgotPasswordForm = () => { url: '/forgot-password', body: { email: value.email }, }) - if (res.result === 'success') - setIsEmailSent(true) + if (res.result === 'success') setIsEmailSent(true) else console.error('Email verification failed') - } - catch (error) { + } catch (error) { console.error('Request failed:', error) } }, }) - const isSubmitting = useStore(form.store, state => state.isSubmitting) - const emailErrors = useStore(form.store, state => state.fieldMeta.email?.errors) + const isSubmitting = useStore(form.store, (state) => state.isSubmitting) + const emailErrors = useStore(form.store, (state) => state.fieldMeta.email?.errors) const handleSendResetPasswordClick = async () => { - if (isSubmitting) - return + if (isSubmitting) return if (isEmailSent) { router.push('/signin') - } - else { + } else { form.handleSubmit() } } @@ -73,78 +65,84 @@ const ForgotPasswordForm = () => { useEffect(() => { fetchSetupStatus().then(() => { fetchInitValidateStatus().then((res: InitValidateStatusResponse) => { - if (res.status === 'not_started') - window.location.href = `${basePath}/init` + if (res.status === 'not_started') window.location.href = `${basePath}/init` }) setLoading(false) }) }, []) - return ( - loading - ? <Loading /> - : ( - <> - <div className="sm:mx-auto sm:w-full sm:max-w-md"> - <h2 className="text-[32px] font-bold text-text-primary"> - {isEmailSent ? t($ => $.resetLinkSent, { ns: 'login' }) : t($ => $.forgotPassword, { ns: 'login' })} - </h2> - <p className="mt-1 text-sm text-text-secondary"> - {isEmailSent ? t($ => $.checkEmailForResetLink, { ns: 'login' }) : t($ => $.forgotPasswordDesc, { ns: 'login' })} - </p> - </div> - <div className="mt-8 grow sm:mx-auto sm:w-full sm:max-w-md"> - <div className="relative"> - <formContext.Provider value={form}> - <form - onSubmit={(e) => { - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - }} + return loading ? ( + <Loading /> + ) : ( + <> + <div className="sm:mx-auto sm:w-full sm:max-w-md"> + <h2 className="text-[32px] font-bold text-text-primary"> + {isEmailSent + ? t(($) => $.resetLinkSent, { ns: 'login' }) + : t(($) => $.forgotPassword, { ns: 'login' })} + </h2> + <p className="mt-1 text-sm text-text-secondary"> + {isEmailSent + ? t(($) => $.checkEmailForResetLink, { ns: 'login' }) + : t(($) => $.forgotPasswordDesc, { ns: 'login' })} + </p> + </div> + <div className="mt-8 grow sm:mx-auto sm:w-full sm:max-w-md"> + <div className="relative"> + <formContext.Provider value={form}> + <form + onSubmit={(e) => { + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} + > + {!isEmailSent && ( + <div className="mb-5"> + <label + htmlFor="email" + className="my-2 flex items-center justify-between text-sm font-medium text-text-primary" > - {!isEmailSent && ( - <div className="mb-5"> - <label - htmlFor="email" - className="my-2 flex items-center justify-between text-sm font-medium text-text-primary" - > - {t($ => $.email, { ns: 'login' })} - </label> - <div className="mt-1"> - <form.AppField - name="email" - > - {field => ( - <Input - id="email" - value={field.state.value} - onChange={e => field.handleChange(e.target.value)} - onBlur={field.handleBlur} - placeholder={t($ => $.emailPlaceholder, { ns: 'login' }) || ''} - /> - )} - </form.AppField> - {emailErrors && emailErrors.length > 0 && ( - <span className="text-sm text-red-400"> - {t($ => $[`${emailErrors[0]}` as 'error.emailInValid'], { ns: 'login' })} - </span> - )} - </div> - </div> + {t(($) => $.email, { ns: 'login' })} + </label> + <div className="mt-1"> + <form.AppField name="email"> + {(field) => ( + <Input + id="email" + value={field.state.value} + onChange={(e) => field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) || ''} + /> + )} + </form.AppField> + {emailErrors && emailErrors.length > 0 && ( + <span className="text-sm text-red-400"> + {t(($) => $[`${emailErrors[0]}` as 'error.emailInValid'], { ns: 'login' })} + </span> )} - <div> - <Button variant="primary" className="w-full" disabled={isSubmitting} onClick={handleSendResetPasswordClick}> - {isEmailSent ? t($ => $.backToSignIn, { ns: 'login' }) : t($ => $.sendResetLink, { ns: 'login' })} - </Button> - </div> - </form> - </formContext.Provider> + </div> + </div> + )} + <div> + <Button + variant="primary" + className="w-full" + disabled={isSubmitting} + onClick={handleSendResetPasswordClick} + > + {isEmailSent + ? t(($) => $.backToSignIn, { ns: 'login' }) + : t(($) => $.sendResetLink, { ns: 'login' })} + </Button> </div> - </div> - </> - ) + </form> + </formContext.Provider> + </div> + </div> + </> ) } diff --git a/web/app/forgot-password/page.tsx b/web/app/forgot-password/page.tsx index 5a307bf2d9f9c1..c66f6e0b641b7b 100644 --- a/web/app/forgot-password/page.tsx +++ b/web/app/forgot-password/page.tsx @@ -17,16 +17,16 @@ const ForgotPassword = () => { return ( <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> - <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> + <div + className={cn( + 'flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle', + )} + > <Header /> {token ? <ChangePasswordForm /> : <ForgotPasswordForm />} {!systemFeatures.branding.enabled && ( <div className="px-8 py-6 text-sm font-normal text-text-tertiary"> - © - {' '} - {new Date().getFullYear()} - {' '} - LangGenius, Inc. All rights reserved. + © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. </div> )} </div> diff --git a/web/app/init/InitPasswordPopup.tsx b/web/app/init/InitPasswordPopup.tsx index 9d26bb1921e871..1d7e8009295f7a 100644 --- a/web/app/init/InitPasswordPopup.tsx +++ b/web/app/init/InitPasswordPopup.tsx @@ -26,12 +26,10 @@ const InitPasswordPopup = () => { if (response.result === 'success') { setValidated(true) router.push('/install') // or render setup form - } - else { + } else { throw new Error('Validation failed') } - } - catch (e: any) { + } catch (e: any) { toast.error(e.message) setLoading(false) } @@ -39,44 +37,39 @@ const InitPasswordPopup = () => { useEffect(() => { fetchInitValidateStatus().then((res: InitValidateStatusResponse) => { - if (res.status === 'finished') - window.location.href = `${basePath}/install` - else - setLoading(false) + if (res.status === 'finished') window.location.href = `${basePath}/install` + else setLoading(false) }) }, []) - return ( - loading - ? <Loading /> - : ( - <div> - {!validated && ( - <div className="mx-12 block min-w-28"> - <div className="mb-4"> - <label htmlFor="password" className="block text-sm font-medium text-text-secondary"> - {t($ => $.adminInitPassword, { ns: 'login' })} - - </label> - <div className="relative mt-1 rounded-md shadow-sm"> - <input - id="password" - type="password" - value={password} - onChange={e => setPassword(e.target.value)} - className="block w-full appearance-none rounded-md border border-divider-regular px-3 py-2 shadow-sm placeholder:text-text-quaternary focus:border-indigo-500 focus:ring-indigo-500 focus:outline-hidden sm:text-sm" - /> - </div> - </div> - <div className="flex flex-row flex-wrap justify-stretch p-0"> - <Button variant="primary" onClick={handleValidation} className="min-w-28 basis-full"> - {t($ => $.validate, { ns: 'login' })} - </Button> - </div> - </div> - )} + return loading ? ( + <Loading /> + ) : ( + <div> + {!validated && ( + <div className="mx-12 block min-w-28"> + <div className="mb-4"> + <label htmlFor="password" className="block text-sm font-medium text-text-secondary"> + {t(($) => $.adminInitPassword, { ns: 'login' })} + </label> + <div className="relative mt-1 rounded-md shadow-sm"> + <input + id="password" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + className="block w-full appearance-none rounded-md border border-divider-regular px-3 py-2 shadow-sm placeholder:text-text-quaternary focus:border-indigo-500 focus:ring-indigo-500 focus:outline-hidden sm:text-sm" + /> + </div> + </div> + <div className="flex flex-row flex-wrap justify-stretch p-0"> + <Button variant="primary" onClick={handleValidation} className="min-w-28 basis-full"> + {t(($) => $.validate, { ns: 'login' })} + </Button> </div> - ) + </div> + )} + </div> ) } diff --git a/web/app/init/page.tsx b/web/app/init/page.tsx index e6dc3ebec0bfd6..f8d493a03ce3d4 100644 --- a/web/app/init/page.tsx +++ b/web/app/init/page.tsx @@ -5,7 +5,11 @@ import InitPasswordPopup from './InitPasswordPopup' const Install = () => { return ( <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> - <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> + <div + className={cn( + 'flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle', + )} + > <div className="m-auto block w-96"> <InitPasswordPopup /> </div> diff --git a/web/app/install/installForm.spec.tsx b/web/app/install/installForm.spec.tsx index a3e180b2140c1e..086c3c26b13a4a 100644 --- a/web/app/install/installForm.spec.tsx +++ b/web/app/install/installForm.spec.tsx @@ -30,7 +30,9 @@ const mockLogin = vi.mocked(login) const prepareLoadedState = () => { mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' } as SetupStatusResponse) - mockFetchInitValidateStatus.mockResolvedValue({ status: 'finished' } as InitValidateStatusResponse) + mockFetchInitValidateStatus.mockResolvedValue({ + status: 'finished', + } as InitValidateStatusResponse) } describe('InstallForm', () => { @@ -66,7 +68,9 @@ describe('InstallForm', () => { render(<InstallForm />) - fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'admin@example.com' } }) + fireEvent.change(await screen.findByLabelText('login.email'), { + target: { value: 'admin@example.com' }, + }) fireEvent.change(screen.getByLabelText('login.name'), { target: { value: 'Admin' } }) fireEvent.change(screen.getByLabelText('login.password'), { target: { value: 'Password123' } }) @@ -103,11 +107,18 @@ describe('InstallForm', () => { it('should redirect to sign in when login fails', async () => { mockSetup.mockResolvedValue({ result: 'success' } as any) - mockLogin.mockResolvedValue({ result: 'fail', data: 'error', code: 'login_failed', message: 'login failed' } as any) + mockLogin.mockResolvedValue({ + result: 'fail', + data: 'error', + code: 'login_failed', + message: 'login failed', + } as any) render(<InstallForm />) - fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'admin@example.com' } }) + fireEvent.change(await screen.findByLabelText('login.email'), { + target: { value: 'admin@example.com' }, + }) fireEvent.change(screen.getByLabelText('login.name'), { target: { value: 'Admin' } }) fireEvent.change(screen.getByLabelText('login.password'), { target: { value: 'Password123' } }) @@ -128,7 +139,9 @@ describe('InstallForm', () => { render(<InstallForm />) - fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'admin@example.com' } }) + fireEvent.change(await screen.findByLabelText('login.email'), { + target: { value: 'admin@example.com' }, + }) fireEvent.change(screen.getByLabelText('login.name'), { target: { value: 'Admin' } }) fireEvent.change(screen.getByLabelText('login.password'), { target: { value: 'Password123' } }) diff --git a/web/app/install/installForm.tsx b/web/app/install/installForm.tsx index 4aaee724346b92..59ec18a52ee87f 100644 --- a/web/app/install/installForm.tsx +++ b/web/app/install/installForm.tsx @@ -2,7 +2,6 @@ import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' - import { useStore } from '@tanstack/react-form' import { useQueryClient } from '@tanstack/react-query' import * as React from 'react' @@ -14,7 +13,6 @@ import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit- import Input from '@/app/components/base/input' import { validPassword } from '@/config' import { LICENSE_LINK } from '@/constants/link' - import useDocumentTitle from '@/hooks/use-document-title' import Link from '@/next/link' import { useRouter } from '@/next/navigation' @@ -24,16 +22,18 @@ import { encryptPassword as encodePassword } from '@/utils/encryption' import Loading from '../components/base/loading' const accountFormSchema = z.object({ - email: z.email('error.emailInValid') - .min(1, { - error: 'error.emailInValid', - }), + email: z.email('error.emailInValid').min(1, { + error: 'error.emailInValid', + }), name: z.string().min(1, { error: 'error.nameEmpty', }), - password: z.string().min(8, { - error: 'error.passwordLengthInValid', - }).regex(validPassword, 'error.passwordInvalid'), + password: z + .string() + .min(8, { + error: 'error.passwordLengthInValid', + }) + .regex(validPassword, 'error.passwordInvalid'), }) const InstallForm = () => { @@ -75,163 +75,177 @@ const InstallForm = () => { if (loginRes.result === 'success') { await queryClient.resetQueries({ queryKey: consoleQuery.account.profile.get.key() }) router.replace('/') - } - else { + } else { // Fallback to signin page if auto-login fails router.replace('/signin') } }, }) - const isSubmitting = useStore(form.store, state => state.isSubmitting) - const emailErrors = useStore(form.store, state => state.fieldMeta.email?.errors) - const nameErrors = useStore(form.store, state => state.fieldMeta.name?.errors) - const passwordErrors = useStore(form.store, state => state.fieldMeta.password?.errors) + const isSubmitting = useStore(form.store, (state) => state.isSubmitting) + const emailErrors = useStore(form.store, (state) => state.fieldMeta.email?.errors) + const nameErrors = useStore(form.store, (state) => state.fieldMeta.name?.errors) + const passwordErrors = useStore(form.store, (state) => state.fieldMeta.password?.errors) useEffect(() => { fetchSetupStatus().then((res: SetupStatusResponse) => { if (res.step === 'finished') { router.push('/signin') - } - else { + } else { fetchInitValidateStatus().then((res: InitValidateStatusResponse) => { - if (res.status === 'not_started') - router.push('/init') + if (res.status === 'not_started') router.push('/init') }) } setLoading(false) }) }, []) - return ( - loading - ? <Loading /> - : ( - <> - <div className="sm:mx-auto sm:w-full sm:max-w-md"> - <h2 className="text-[32px] font-bold text-text-primary">{t($ => $.setAdminAccount, { ns: 'login' })}</h2> - <p className="mt-1 text-sm text-text-secondary">{t($ => $.setAdminAccountDesc, { ns: 'login' })}</p> - </div> - <div className="mt-8 grow sm:mx-auto sm:w-full sm:max-w-md"> - <div className="relative"> - <formContext.Provider value={form}> - <form - onSubmit={(e) => { - e.preventDefault() - e.stopPropagation() - if (isSubmitting) - return - form.handleSubmit() - }} - > - <div className="mb-5"> - <label htmlFor="email" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary"> - {t($ => $.email, { ns: 'login' })} - </label> - <div className="mt-1"> - <form.AppField name="email"> - {field => ( - <Input - id="email" - value={field.state.value} - onChange={e => field.handleChange(e.target.value)} - onBlur={field.handleBlur} - placeholder={t($ => $.emailPlaceholder, { ns: 'login' }) || ''} - /> - )} - </form.AppField> - {emailErrors && emailErrors.length > 0 && ( - <span className="text-sm text-red-400"> - {t($ => $[`${emailErrors[0]}` as 'error.emailInValid'], { ns: 'login' })} - </span> - )} - </div> - </div> - - <div className="mb-5"> - <label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary"> - {t($ => $.name, { ns: 'login' })} - </label> - <div className="relative mt-1"> - <form.AppField name="name"> - {field => ( - <Input - id="name" - value={field.state.value} - onChange={e => field.handleChange(e.target.value)} - onBlur={field.handleBlur} - placeholder={t($ => $.namePlaceholder, { ns: 'login' }) || ''} - /> - )} - </form.AppField> - </div> - {nameErrors && nameErrors.length > 0 && ( - <span className="text-sm text-red-400"> - {t($ => $[`${nameErrors[0]}` as 'error.nameEmpty'], { ns: 'login' })} - </span> - )} - </div> - - <div className="mb-5"> - <label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary"> - {t($ => $.password, { ns: 'login' })} - </label> - <div className="relative mt-1"> - <form.AppField name="password"> - {field => ( - <Input - id="password" - type={showPassword ? 'text' : 'password'} - value={field.state.value} - onChange={e => field.handleChange(e.target.value)} - onBlur={field.handleBlur} - placeholder={t($ => $.passwordPlaceholder, { ns: 'login' }) || ''} - /> - )} - </form.AppField> + return loading ? ( + <Loading /> + ) : ( + <> + <div className="sm:mx-auto sm:w-full sm:max-w-md"> + <h2 className="text-[32px] font-bold text-text-primary"> + {t(($) => $.setAdminAccount, { ns: 'login' })} + </h2> + <p className="mt-1 text-sm text-text-secondary"> + {t(($) => $.setAdminAccountDesc, { ns: 'login' })} + </p> + </div> + <div className="mt-8 grow sm:mx-auto sm:w-full sm:max-w-md"> + <div className="relative"> + <formContext.Provider value={form}> + <form + onSubmit={(e) => { + e.preventDefault() + e.stopPropagation() + if (isSubmitting) return + form.handleSubmit() + }} + > + <div className="mb-5"> + <label + htmlFor="email" + className="my-2 flex items-center justify-between text-sm font-medium text-text-primary" + > + {t(($) => $.email, { ns: 'login' })} + </label> + <div className="mt-1"> + <form.AppField name="email"> + {(field) => ( + <Input + id="email" + value={field.state.value} + onChange={(e) => field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) || ''} + /> + )} + </form.AppField> + {emailErrors && emailErrors.length > 0 && ( + <span className="text-sm text-red-400"> + {t(($) => $[`${emailErrors[0]}` as 'error.emailInValid'], { ns: 'login' })} + </span> + )} + </div> + </div> - <div className="absolute inset-y-0 right-0 flex items-center pr-3"> - <button - type="button" - onClick={() => setShowPassword(!showPassword)} - className="text-text-quaternary hover:text-text-tertiary focus:text-text-tertiary focus:outline-hidden" - > - {showPassword ? '👀' : '😝'} - </button> - </div> - </div> + <div className="mb-5"> + <label + htmlFor="name" + className="my-2 flex items-center justify-between text-sm font-medium text-text-primary" + > + {t(($) => $.name, { ns: 'login' })} + </label> + <div className="relative mt-1"> + <form.AppField name="name"> + {(field) => ( + <Input + id="name" + value={field.state.value} + onChange={(e) => field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder={t(($) => $.namePlaceholder, { ns: 'login' }) || ''} + /> + )} + </form.AppField> + </div> + {nameErrors && nameErrors.length > 0 && ( + <span className="text-sm text-red-400"> + {t(($) => $[`${nameErrors[0]}` as 'error.nameEmpty'], { ns: 'login' })} + </span> + )} + </div> - <div className={cn('mt-1 text-xs text-text-secondary', { - 'text-sm! text-red-400': passwordErrors && passwordErrors.length > 0, - })} - > - {t($ => $['error.passwordInvalid'], { ns: 'login' })} - </div> - </div> + <div className="mb-5"> + <label + htmlFor="password" + className="my-2 flex items-center justify-between text-sm font-medium text-text-primary" + > + {t(($) => $.password, { ns: 'login' })} + </label> + <div className="relative mt-1"> + <form.AppField name="password"> + {(field) => ( + <Input + id="password" + type={showPassword ? 'text' : 'password'} + value={field.state.value} + onChange={(e) => field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder={t(($) => $.passwordPlaceholder, { ns: 'login' }) || ''} + /> + )} + </form.AppField> + + <div className="absolute inset-y-0 right-0 flex items-center pr-3"> + <button + type="button" + onClick={() => setShowPassword(!showPassword)} + className="text-text-quaternary hover:text-text-tertiary focus:text-text-tertiary focus:outline-hidden" + > + {showPassword ? '👀' : '😝'} + </button> + </div> + </div> - <div> - <Button variant="primary" type="submit" disabled={isSubmitting} loading={isSubmitting} className="w-full"> - {t($ => $.installBtn, { ns: 'login' })} - </Button> - </div> - </form> - </formContext.Provider> - <div className="mt-2 block w-full text-xs text-text-secondary"> - {t($ => $['license.tip'], { ns: 'login' })} -   - <Link - className="text-text-accent" - target="_blank" - rel="noopener noreferrer" - href={LICENSE_LINK} - > - {t($ => $['license.link'], { ns: 'login' })} - </Link> + <div + className={cn('mt-1 text-xs text-text-secondary', { + 'text-sm! text-red-400': passwordErrors && passwordErrors.length > 0, + })} + > + {t(($) => $['error.passwordInvalid'], { ns: 'login' })} </div> </div> - </div> - </> - ) + + <div> + <Button + variant="primary" + type="submit" + disabled={isSubmitting} + loading={isSubmitting} + className="w-full" + > + {t(($) => $.installBtn, { ns: 'login' })} + </Button> + </div> + </form> + </formContext.Provider> + <div className="mt-2 block w-full text-xs text-text-secondary"> + {t(($) => $['license.tip'], { ns: 'login' })} +   + <Link + className="text-text-accent" + target="_blank" + rel="noopener noreferrer" + href={LICENSE_LINK} + > + {t(($) => $['license.link'], { ns: 'login' })} + </Link> + </div> + </div> + </div> + </> ) } diff --git a/web/app/install/page.tsx b/web/app/install/page.tsx index 9c35868e60329f..978e593b459fa2 100644 --- a/web/app/install/page.tsx +++ b/web/app/install/page.tsx @@ -10,16 +10,16 @@ const Install = () => { const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) return ( <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> - <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> + <div + className={cn( + 'flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle', + )} + > <Header /> <InstallForm /> {!systemFeatures.branding.enabled && ( <div className="px-8 py-6 text-sm font-normal text-text-tertiary"> - © - {' '} - {new Date().getFullYear()} - {' '} - LangGenius, Inc. All rights reserved. + © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. </div> )} </div> diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 9b59cb8e11f49d..7dcf01b2a7746d 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -24,14 +24,10 @@ export const viewport: Viewport = { viewportFit: 'cover', } -const LocaleLayout = async ({ - children, -}: { - children: React.ReactNode -}) => { +const LocaleLayout = async ({ children }: { children: React.ReactNode }) => { const locale = await getLocaleOnServer() const datasetMap = getDatasetMap() - const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? undefined : undefined + const nonce = IS_PROD ? ((await headers()).get('x-nonce') ?? undefined) : undefined return ( <html lang={locale ?? 'en'} className="h-full" suppressHydrationWarning> @@ -50,10 +46,7 @@ const LocaleLayout = async ({ <ReactScanLoader /> </head> - <body - className="h-full bg-background-body" - {...datasetMap} - > + <body className="h-full bg-background-body" {...datasetMap}> <div className="isolate h-full"> <JotaiProvider> <ThemeProvider diff --git a/web/app/reset-password/check-code/page.tsx b/web/app/reset-password/check-code/page.tsx index 9b60a21a64fd9e..117316221e80cf 100644 --- a/web/app/reset-password/check-code/page.tsx +++ b/web/app/reset-password/check-code/page.tsx @@ -23,11 +23,11 @@ export default function CheckCode() { const verify = async () => { try { if (!code.trim()) { - toast.error(t($ => $['checkCode.emptyCode'], { ns: 'login' })) + toast.error(t(($) => $['checkCode.emptyCode'], { ns: 'login' })) return } if (!/\d{6}/.test(code)) { - toast.error(t($ => $['checkCode.invalidCode'], { ns: 'login' })) + toast.error(t(($) => $['checkCode.invalidCode'], { ns: 'login' })) return } setIsLoading(true) @@ -37,9 +37,9 @@ export default function CheckCode() { params.set('token', encodeURIComponent(ret.token)) router.push(`/reset-password/set-password?${params.toString()}`) } - } - catch (error) { console.error(error) } - finally { + } catch (error) { + console.error(error) + } finally { setIsLoading(false) } } @@ -52,8 +52,9 @@ export default function CheckCode() { params.set('token', encodeURIComponent(res.data)) router.replace(`/reset-password/check-code?${params.toString()}`) } + } catch (error) { + console.error(error) } - catch (error) { console.error(error) } } return ( @@ -62,32 +63,55 @@ export default function CheckCode() { <RiMailSendFill className="size-6 text-2xl" /> </div> <div className="pt-2 pb-4"> - <h2 className="title-4xl-semi-bold text-text-primary">{t($ => $['checkCode.checkYourEmail'], { ns: 'login' })}</h2> + <h2 className="title-4xl-semi-bold text-text-primary"> + {t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })} + </h2> <p className="mt-2 body-md-regular text-text-secondary"> <span> - {t($ => $['checkCode.tipsPrefix'], { ns: 'login' })} + {t(($) => $['checkCode.tipsPrefix'], { ns: 'login' })} <strong>{email}</strong> </span> <br /> - {t($ => $['checkCode.validTime'], { ns: 'login' })} + {t(($) => $['checkCode.validTime'], { ns: 'login' })} </p> </div> <form action=""> <input type="text" className="hidden" /> - <label htmlFor="code" className="mb-1 system-md-semibold text-text-secondary">{t($ => $['checkCode.verificationCode'], { ns: 'login' })}</label> - <Input value={code} onChange={e => setVerifyCode(e.target.value)} maxLength={6} className="mt-1" placeholder={t($ => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) as string} /> - <Button loading={loading} disabled={loading} className="my-3 w-full" variant="primary" onClick={verify}>{t($ => $['checkCode.verify'], { ns: 'login' })}</Button> + <label htmlFor="code" className="mb-1 system-md-semibold text-text-secondary"> + {t(($) => $['checkCode.verificationCode'], { ns: 'login' })} + </label> + <Input + value={code} + onChange={(e) => setVerifyCode(e.target.value)} + maxLength={6} + className="mt-1" + placeholder={ + t(($) => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) as string + } + /> + <Button + loading={loading} + disabled={loading} + className="my-3 w-full" + variant="primary" + onClick={verify} + > + {t(($) => $['checkCode.verify'], { ns: 'login' })} + </Button> <Countdown onResend={resendCode} /> </form> <div className="py-2"> <div className="h-px bg-linear-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div> </div> - <div onClick={() => router.back()} className="flex h-9 cursor-pointer items-center justify-center text-text-tertiary"> + <div + onClick={() => router.back()} + className="flex h-9 cursor-pointer items-center justify-center text-text-tertiary" + > <div className="inline-block rounded-full bg-background-default-dimmed p-1"> <RiArrowLeftLine size={12} /> </div> - <span className="ml-2 system-xs-regular">{t($ => $.back, { ns: 'login' })}</span> + <span className="ml-2 system-xs-regular">{t(($) => $.back, { ns: 'login' })}</span> </div> </div> ) diff --git a/web/app/reset-password/layout.tsx b/web/app/reset-password/layout.tsx index c795b1d47754c3..97c58ec60d153c 100644 --- a/web/app/reset-password/layout.tsx +++ b/web/app/reset-password/layout.tsx @@ -1,7 +1,6 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' - import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Header from '../signin/_header' @@ -10,27 +9,24 @@ export default function SignInLayout({ children }: any) { return ( <> <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> - <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> + <div + className={cn( + 'flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle', + )} + > <Header /> - <div className={ - cn( + <div + className={cn( 'flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]', - ) - } + )} > - <div className="flex flex-col md:w-[400px]"> - {children} - </div> + <div className="flex flex-col md:w-[400px]">{children}</div> </div> {!systemFeatures.branding.enabled && ( <div className="px-8 py-6 system-xs-regular text-text-tertiary"> - © - {' '} - {new Date().getFullYear()} - {' '} - LangGenius, Inc. All rights reserved. + © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. </div> )} </div> diff --git a/web/app/reset-password/page.tsx b/web/app/reset-password/page.tsx index dde149f3a25a7e..e7b551afb6d2cc 100644 --- a/web/app/reset-password/page.tsx +++ b/web/app/reset-password/page.tsx @@ -27,12 +27,12 @@ export default function CheckCode() { const handleGetEMailVerificationCode = async () => { try { if (!email) { - toast.error(t($ => $['error.emailEmpty'], { ns: 'login' })) + toast.error(t(($) => $['error.emailEmpty'], { ns: 'login' })) return } if (!emailRegex.test(email)) { - toast.error(t($ => $['error.emailInValid'], { ns: 'login' })) + toast.error(t(($) => $['error.emailInValid'], { ns: 'login' })) return } setIsLoading(true) @@ -43,15 +43,12 @@ export default function CheckCode() { params.set('token', encodeURIComponent(res.data)) params.set('email', encodeURIComponent(email)) router.push(`/reset-password/check-code?${params.toString()}`) - } - else { + } else { toast.error(res.data) } - } - catch (error) { + } catch (error) { console.error(error) - } - finally { + } finally { setIsLoading(false) } } @@ -62,32 +59,54 @@ export default function CheckCode() { <RiLockPasswordLine className="size-6 text-2xl text-text-accent-light-mode-only" /> </div> <div className="pt-2 pb-4"> - <h2 className="title-4xl-semi-bold text-text-primary">{t($ => $.resetPassword, { ns: 'login' })}</h2> + <h2 className="title-4xl-semi-bold text-text-primary"> + {t(($) => $.resetPassword, { ns: 'login' })} + </h2> <p className="mt-2 body-md-regular text-text-secondary"> - {t($ => $.resetPasswordDesc, { ns: 'login' })} + {t(($) => $.resetPasswordDesc, { ns: 'login' })} </p> </div> <form onSubmit={noop}> <input type="text" className="hidden" /> <div className="mb-2"> - <label htmlFor="email" className="my-2 system-md-semibold text-text-secondary">{t($ => $.email, { ns: 'login' })}</label> + <label htmlFor="email" className="my-2 system-md-semibold text-text-secondary"> + {t(($) => $.email, { ns: 'login' })} + </label> <div className="mt-1"> - <Input id="email" type="email" disabled={loading} value={email} placeholder={t($ => $.emailPlaceholder, { ns: 'login' }) as string} onChange={e => setEmail(e.target.value)} /> + <Input + id="email" + type="email" + disabled={loading} + value={email} + placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) as string} + onChange={(e) => setEmail(e.target.value)} + /> </div> <div className="mt-3"> - <Button loading={loading} disabled={loading} variant="primary" className="w-full" onClick={handleGetEMailVerificationCode}>{t($ => $.sendVerificationCode, { ns: 'login' })}</Button> + <Button + loading={loading} + disabled={loading} + variant="primary" + className="w-full" + onClick={handleGetEMailVerificationCode} + > + {t(($) => $.sendVerificationCode, { ns: 'login' })} + </Button> </div> </div> </form> <div className="py-2"> <div className="h-px bg-linear-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div> </div> - <Link href={`/signin?${searchParams.toString()}`} className="flex h-9 items-center justify-center text-text-tertiary hover:text-text-primary"> + <Link + href={`/signin?${searchParams.toString()}`} + className="flex h-9 items-center justify-center text-text-tertiary hover:text-text-primary" + > <div className="inline-block rounded-full bg-background-default-dimmed p-1"> <RiArrowLeftLine size={12} /> </div> - <span className="ml-2 system-xs-regular">{t($ => $.backToLogin, { ns: 'login' })}</span> + <span className="ml-2 system-xs-regular">{t(($) => $.backToLogin, { ns: 'login' })}</span> </Link> </div> ) diff --git a/web/app/reset-password/set-password/page.tsx b/web/app/reset-password/set-password/page.tsx index 2d78e6a50b0c1a..1c8b72024c3fde 100644 --- a/web/app/reset-password/set-password/page.tsx +++ b/web/app/reset-password/set-password/page.tsx @@ -47,23 +47,22 @@ const ChangePasswordForm = () => { const valid = useCallback(() => { if (!password.trim()) { - showErrorMessage(t($ => $['error.passwordEmpty'], { ns: 'login' })) + showErrorMessage(t(($) => $['error.passwordEmpty'], { ns: 'login' })) return false } if (!validPassword.test(password)) { - showErrorMessage(t($ => $['error.passwordInvalid'], { ns: 'login' })) + showErrorMessage(t(($) => $['error.passwordInvalid'], { ns: 'login' })) return false } if (password !== confirmPassword) { - showErrorMessage(t($ => $['account.notEqual'], { ns: 'common' })) + showErrorMessage(t(($) => $['account.notEqual'], { ns: 'common' })) return false } return true }, [password, confirmPassword, showErrorMessage, t]) const handleChangePassword = useCallback(async () => { - if (!valid()) - return + if (!valid()) return try { await changePasswordWithToken({ url: '/forgot-password/resets', @@ -75,29 +74,27 @@ const ChangePasswordForm = () => { }) setShowSuccess(true) setLeftTime(AUTO_REDIRECT_TIME) - } - catch (error) { + } catch (error) { console.error(error) } }, [password, token, valid, confirmPassword]) return ( - <div className={ - cn( + <div + className={cn( 'flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]', - ) - } + )} > {!showSuccess && ( <div className="flex flex-col md:w-[400px]"> <div className="mx-auto w-full"> <h2 className="title-4xl-semi-bold text-text-primary"> - {t($ => $.changePassword, { ns: 'login' })} + {t(($) => $.changePassword, { ns: 'login' })} </h2> <p className="mt-2 body-md-regular text-text-secondary"> - {t($ => $.changePasswordTip, { ns: 'login' })} + {t(($) => $.changePasswordTip, { ns: 'login' })} </p> </div> @@ -106,15 +103,15 @@ const ChangePasswordForm = () => { {/* Password */} <div className="mb-5"> <label htmlFor="password" className="my-2 system-md-semibold text-text-secondary"> - {t($ => $['account.newPassword'], { ns: 'common' })} + {t(($) => $['account.newPassword'], { ns: 'common' })} </label> <div className="relative mt-1"> <Input id="password" type={showPassword ? 'text' : 'password'} value={password} - onChange={e => setPassword(e.target.value)} - placeholder={t($ => $.passwordPlaceholder, { ns: 'login' }) || ''} + onChange={(e) => setPassword(e.target.value)} + placeholder={t(($) => $.passwordPlaceholder, { ns: 'login' }) || ''} /> <div className="absolute inset-y-0 right-0 flex items-center"> @@ -127,20 +124,25 @@ const ChangePasswordForm = () => { </Button> </div> </div> - <div className="mt-1 body-xs-regular text-text-secondary">{t($ => $['error.passwordInvalid'], { ns: 'login' })}</div> + <div className="mt-1 body-xs-regular text-text-secondary"> + {t(($) => $['error.passwordInvalid'], { ns: 'login' })} + </div> </div> {/* Confirm Password */} <div className="mb-5"> - <label htmlFor="confirmPassword" className="my-2 system-md-semibold text-text-secondary"> - {t($ => $['account.confirmPassword'], { ns: 'common' })} + <label + htmlFor="confirmPassword" + className="my-2 system-md-semibold text-text-secondary" + > + {t(($) => $['account.confirmPassword'], { ns: 'common' })} </label> <div className="relative mt-1"> <Input id="confirmPassword" type={showConfirmPassword ? 'text' : 'password'} value={confirmPassword} - onChange={e => setConfirmPassword(e.target.value)} - placeholder={t($ => $.confirmPasswordPlaceholder, { ns: 'login' }) || ''} + onChange={(e) => setConfirmPassword(e.target.value)} + placeholder={t(($) => $.confirmPasswordPlaceholder, { ns: 'login' }) || ''} /> <div className="absolute inset-y-0 right-0 flex items-center"> <Button @@ -154,12 +156,8 @@ const ChangePasswordForm = () => { </div> </div> <div> - <Button - variant="primary" - className="w-full" - onClick={handleChangePassword} - > - {t($ => $.changePasswordBtn, { ns: 'login' })} + <Button variant="primary" className="w-full" onClick={handleChangePassword}> + {t(($) => $.changePasswordBtn, { ns: 'login' })} </Button> </div> </div> @@ -173,7 +171,7 @@ const ChangePasswordForm = () => { <RiCheckboxCircleFill className="size-6 text-text-success" /> </div> <h2 className="title-4xl-semi-bold text-text-primary"> - {t($ => $.passwordChangedTip, { ns: 'login' })} + {t(($) => $.passwordChangedTip, { ns: 'login' })} </h2> </div> <div className="mx-auto mt-6 w-full"> @@ -185,12 +183,7 @@ const ChangePasswordForm = () => { router.replace(getSignInUrl()) }} > - {t($ => $.passwordChanged, { ns: 'login' })} - {' '} - ( - {Math.round(countdown / 1000)} - ) - {' '} + {t(($) => $.passwordChanged, { ns: 'login' })} ({Math.round(countdown / 1000)}){' '} </Button> </div> </div> diff --git a/web/app/routePrefixHandle.tsx b/web/app/routePrefixHandle.tsx index e772c7964a38f2..e153a085d757c6 100644 --- a/web/app/routePrefixHandle.tsx +++ b/web/app/routePrefixHandle.tsx @@ -10,7 +10,12 @@ export default function RoutePrefixHandle() { const addPrefixToImg = (e: HTMLImageElement) => { const url = new URL(e.src) const prefix = url.pathname.slice(0, basePath.length) - if (prefix !== basePath && !url.href.startsWith('blob:') && !url.href.startsWith('data:') && !url.href.startsWith('http')) { + if ( + prefix !== basePath && + !url.href.startsWith('blob:') && + !url.href.startsWith('data:') && + !url.href.startsWith('http') + ) { url.pathname = basePath + url.pathname e.src = url.toString() } @@ -21,14 +26,14 @@ export default function RoutePrefixHandle() { if (mutation.type === 'childList') { // listen for newly added img tags mutation.addedNodes.forEach((node) => { - if (((node as HTMLElement).tagName) === 'IMG') - addPrefixToImg(node as HTMLImageElement) + if ((node as HTMLElement).tagName === 'IMG') addPrefixToImg(node as HTMLImageElement) }) - } - else if (mutation.type === 'attributes' && (mutation.target as HTMLElement).tagName === 'IMG') { + } else if ( + mutation.type === 'attributes' && + (mutation.target as HTMLElement).tagName === 'IMG' + ) { // if the src of an existing img tag changes, update the prefix - if (mutation.attributeName === 'src') - addPrefixToImg(mutation.target as HTMLImageElement) + if (mutation.attributeName === 'src') addPrefixToImg(mutation.target as HTMLImageElement) } } }) @@ -45,8 +50,7 @@ export default function RoutePrefixHandle() { } useEffect(() => { - if (basePath) - handleRouteChange() + if (basePath) handleRouteChange() }, [pathname]) return null diff --git a/web/app/signin/__tests__/_locale-menu.spec.tsx b/web/app/signin/__tests__/_locale-menu.spec.tsx index 86edd69cf87a5c..494269487be12c 100644 --- a/web/app/signin/__tests__/_locale-menu.spec.tsx +++ b/web/app/signin/__tests__/_locale-menu.spec.tsx @@ -15,25 +15,13 @@ describe('LocaleMenu', () => { describe('Rendering', () => { it('should render selected locale name when value matches an item', () => { - render( - <LocaleMenu - items={localeItems} - value="en-US" - onChange={vi.fn()} - />, - ) + render(<LocaleMenu items={localeItems} value="en-US" onChange={vi.fn()} />) expect(screen.getByRole('button', { name: /english \(us\)/i })).toBeInTheDocument() }) it('should render trigger without selected label when value is not found', () => { - render( - <LocaleMenu - items={localeItems} - value="missing" - onChange={vi.fn()} - />, - ) + render(<LocaleMenu items={localeItems} value="missing" onChange={vi.fn()} />) const trigger = screen.getByRole('button') expect(trigger).toBeInTheDocument() @@ -46,13 +34,7 @@ describe('LocaleMenu', () => { const user = userEvent.setup() const onChange = vi.fn() - render( - <LocaleMenu - items={localeItems} - value="en-US" - onChange={onChange} - />, - ) + render(<LocaleMenu items={localeItems} value="en-US" onChange={onChange} />) await user.click(screen.getByRole('button', { name: /english \(us\)/i })) await user.click(screen.getByRole('menuitemradio', { name: '日本語' })) @@ -63,13 +45,7 @@ describe('LocaleMenu', () => { it('should render all locale options when menu is opened', async () => { const user = userEvent.setup() - render( - <LocaleMenu - items={localeItems} - value="en-US" - onChange={vi.fn()} - />, - ) + render(<LocaleMenu items={localeItems} value="en-US" onChange={vi.fn()} />) await user.click(screen.getByRole('button', { name: /english \(us\)/i })) @@ -83,12 +59,7 @@ describe('LocaleMenu', () => { it('should not throw when onChange is undefined and option is selected', async () => { const user = userEvent.setup() - render( - <LocaleMenu - items={localeItems} - value="en-US" - />, - ) + render(<LocaleMenu items={localeItems} value="en-US" />) await user.click(screen.getByRole('button', { name: /english \(us\)/i })) await user.click(screen.getByRole('menuitemradio', { name: '简体中文' })) @@ -99,13 +70,7 @@ describe('LocaleMenu', () => { it('should render no options when items are empty', async () => { const user = userEvent.setup() - render( - <LocaleMenu - items={[]} - value="en-US" - onChange={vi.fn()} - />, - ) + render(<LocaleMenu items={[]} value="en-US" onChange={vi.fn()} />) await user.click(screen.getByRole('button')) diff --git a/web/app/signin/__tests__/normal-form.spec.tsx b/web/app/signin/__tests__/normal-form.spec.tsx index 0e03b7c327061d..f5dfd5076d47ef 100644 --- a/web/app/signin/__tests__/normal-form.spec.tsx +++ b/web/app/signin/__tests__/normal-form.spec.tsx @@ -5,7 +5,8 @@ import { useRouter, useSearchParams } from '@/next/navigation' import NormalForm from '../normal-form' vi.mock('@tanstack/react-query', async () => { - const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query') + const actual = + await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query') return { ...actual, useQuery: vi.fn(), @@ -125,7 +126,9 @@ describe('NormalForm', () => { render(<NormalForm />) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/signin/invite-settings?invite_token=invite-token') + expect(mockReplace).toHaveBeenCalledWith( + '/signin/invite-settings?invite_token=invite-token', + ) }) }) }) diff --git a/web/app/signin/_header.tsx b/web/app/signin/_header.tsx index df2a4ca9763805..44a8cd4364ae6f 100644 --- a/web/app/signin/_header.tsx +++ b/web/app/signin/_header.tsx @@ -24,19 +24,19 @@ const Header = () => { return ( <div className="flex w-full items-center justify-between p-6"> - {systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo - ? ( - <img - src={systemFeatures.branding.login_page_logo} - className="block h-7 w-auto object-contain" - alt="logo" - /> - ) - : <DifyLogo size="large" />} + {systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo ? ( + <img + src={systemFeatures.branding.login_page_logo} + className="block h-7 w-auto object-contain" + alt="logo" + /> + ) : ( + <DifyLogo size="large" /> + )} <div className="flex items-center gap-1"> <LocaleMenu value={locale} - items={languages.filter(item => item.supported)} + items={languages.filter((item) => item.supported)} onChange={(value) => { setLocaleOnClient(value, false) }} diff --git a/web/app/signin/_locale-menu.tsx b/web/app/signin/_locale-menu.tsx index 02c8035b308a1c..0e7f2db8df0459 100644 --- a/web/app/signin/_locale-menu.tsx +++ b/web/app/signin/_locale-menu.tsx @@ -23,11 +23,10 @@ export default function LocaleMenu<T extends string>({ value, onChange, }: LocaleMenuProps<T>) { - const selectedItem = items.find(item => item.value === value) + const selectedItem = items.find((item) => item.value === value) const handleValueChange = (nextValue: string) => { - const nextItem = items.find(item => item.value === nextValue) - if (nextItem) - onChange?.(nextItem.value) + const nextItem = items.find((item) => item.value === nextValue) + if (nextItem) onChange?.(nextItem.value) } return ( @@ -36,25 +35,21 @@ export default function LocaleMenu<T extends string>({ <div className="relative inline-block text-left"> <div> <DropdownMenuTrigger - render={( + render={ <button type="button" className="inline-flex w-full items-center rounded-lg border border-components-button-secondary-border px-[10px] py-[6px] text-[13px] font-medium text-text-primary hover:bg-state-base-hover" /> - )} + } > <span className="mr-1 i-heroicons-globe-alt size-5" aria-hidden="true" /> {selectedItem?.name} </DropdownMenuTrigger> </div> </div> - <DropdownMenuContent - placement="bottom-end" - sideOffset={8} - popupClassName="w-[200px]" - > + <DropdownMenuContent placement="bottom-end" sideOffset={8} popupClassName="w-[200px]"> <DropdownMenuRadioGroup value={value} onValueChange={handleValueChange}> - {items.map(item => ( + {items.map((item) => ( <DropdownMenuRadioItem key={item.value} value={item.value} diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx index d420c087677778..14d4914ab231fe 100644 --- a/web/app/signin/check-code/page.tsx +++ b/web/app/signin/check-code/page.tsx @@ -10,7 +10,6 @@ import { trackEvent } from '@/app/components/base/amplitude' import Input from '@/app/components/base/input' import Countdown from '@/app/components/signin/countdown' import { useLocale } from '@/context/i18n' - import { useRouter, useSearchParams } from '@/next/navigation' import { consoleQuery } from '@/service/client' import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common' @@ -35,11 +34,11 @@ export default function CheckCode() { const verify = async () => { try { if (!code.trim()) { - toast.error(t($ => $['checkCode.emptyCode'], { ns: 'login' })) + toast.error(t(($) => $['checkCode.emptyCode'], { ns: 'login' })) return } if (!/\d{6}/.test(code)) { - toast.error(t($ => $['checkCode.invalidCode'], { ns: 'login' })) + toast.error(t(($) => $['checkCode.invalidCode'], { ns: 'login' })) return } setIsLoading(true) @@ -59,16 +58,15 @@ export default function CheckCode() { if (invite_token) { router.replace(`/signin/invite-settings?${searchParams.toString()}`) - } - else { + } else { await queryClient.resetQueries({ queryKey: consoleQuery.account.profile.get.key() }) const redirectUrl = resolvePostLoginRedirect(searchParams) router.replace(redirectUrl || '/') } } - } - catch (error) { console.error(error) } - finally { + } catch (error) { + console.error(error) + } finally { setIsLoading(false) } } @@ -90,8 +88,9 @@ export default function CheckCode() { params.set('token', encodeURIComponent(ret.data)) router.replace(`/signin/check-code?${params.toString()}`) } + } catch (error) { + console.error(error) } - catch (error) { console.error(error) } } return ( @@ -100,39 +99,56 @@ export default function CheckCode() { <RiMailSendFill className="size-6 text-2xl text-text-accent-light-mode-only" /> </div> <div className="pt-2 pb-4"> - <h2 className="title-4xl-semi-bold text-text-primary">{t($ => $['checkCode.checkYourEmail'], { ns: 'login' })}</h2> + <h2 className="title-4xl-semi-bold text-text-primary"> + {t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })} + </h2> <p className="mt-2 body-md-regular text-text-secondary"> <span> - {t($ => $['checkCode.tipsPrefix'], { ns: 'login' })} + {t(($) => $['checkCode.tipsPrefix'], { ns: 'login' })} <strong>{email}</strong> </span> <br /> - {t($ => $['checkCode.validTime'], { ns: 'login' })} + {t(($) => $['checkCode.validTime'], { ns: 'login' })} </p> </div> <form onSubmit={handleSubmit}> - <label htmlFor="code" className="mb-1 system-md-semibold text-text-secondary">{t($ => $['checkCode.verificationCode'], { ns: 'login' })}</label> + <label htmlFor="code" className="mb-1 system-md-semibold text-text-secondary"> + {t(($) => $['checkCode.verificationCode'], { ns: 'login' })} + </label> <Input ref={codeInputRef} id="code" value={code} - onChange={e => setVerifyCode(e.target.value)} + onChange={(e) => setVerifyCode(e.target.value)} maxLength={6} className="mt-1" - placeholder={t($ => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) as string} + placeholder={ + t(($) => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) as string + } /> - <Button type="submit" loading={loading} disabled={loading} className="my-3 w-full" variant="primary">{t($ => $['checkCode.verify'], { ns: 'login' })}</Button> + <Button + type="submit" + loading={loading} + disabled={loading} + className="my-3 w-full" + variant="primary" + > + {t(($) => $['checkCode.verify'], { ns: 'login' })} + </Button> <Countdown onResend={resendCode} /> </form> <div className="py-2"> <div className="h-px bg-linear-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div> </div> - <div onClick={() => router.back()} className="flex h-9 cursor-pointer items-center justify-center text-text-tertiary"> + <div + onClick={() => router.back()} + className="flex h-9 cursor-pointer items-center justify-center text-text-tertiary" + > <div className="inline-block rounded-full bg-background-default-dimmed p-1"> <RiArrowLeftLine size={12} /> </div> - <span className="ml-2 system-xs-regular">{t($ => $.back, { ns: 'login' })}</span> + <span className="ml-2 system-xs-regular">{t(($) => $.back, { ns: 'login' })}</span> </div> </div> ) diff --git a/web/app/signin/components/__tests__/social-auth.spec.tsx b/web/app/signin/components/__tests__/social-auth.spec.tsx index bf1183f32434f0..1d5e74e6c85757 100644 --- a/web/app/signin/components/__tests__/social-auth.spec.tsx +++ b/web/app/signin/components/__tests__/social-auth.spec.tsx @@ -24,7 +24,9 @@ const mockGetBrowserTimezone = vi.mocked(getBrowserTimezone) describe('SocialAuth', () => { beforeEach(() => { vi.clearAllMocks() - mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>) + mockUseSearchParams.mockReturnValue( + new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>, + ) mockUseLocale.mockReturnValue('zh-Hans') mockGetBrowserTimezone.mockReturnValue('Asia/Shanghai') }) @@ -62,14 +64,19 @@ describe('SocialAuth', () => { it('should preserve invite token when adding timezone', () => { mockUseSearchParams.mockReturnValue( - new URLSearchParams('invite_token=invite-123') as unknown as ReturnType<typeof useSearchParams>, + new URLSearchParams('invite_token=invite-123') as unknown as ReturnType< + typeof useSearchParams + >, ) render(<SocialAuth />) const githubLink = screen.getByRole('link', { name: 'login.withGitHub' }) expect(githubLink).toHaveAttribute('href', expect.stringContaining('invite_token=invite-123')) - expect(githubLink).toHaveAttribute('href', expect.stringContaining('timezone=Asia%2FShanghai')) + expect(githubLink).toHaveAttribute( + 'href', + expect.stringContaining('timezone=Asia%2FShanghai'), + ) expect(githubLink).toHaveAttribute('href', expect.stringContaining('language=zh-Hans')) }) }) @@ -80,7 +87,9 @@ describe('SocialAuth', () => { render(<SocialAuth />) - expect(screen.getByRole('link', { name: 'login.withGitHub' }).getAttribute('href')).not.toContain('timezone=') + expect( + screen.getByRole('link', { name: 'login.withGitHub' }).getAttribute('href'), + ).not.toContain('timezone=') }) }) }) diff --git a/web/app/signin/components/mail-and-code-auth.tsx b/web/app/signin/components/mail-and-code-auth.tsx index e6850933b00678..7d9de58c3f3eee 100644 --- a/web/app/signin/components/mail-and-code-auth.tsx +++ b/web/app/signin/components/mail-and-code-auth.tsx @@ -27,12 +27,12 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) { const handleGetEMailVerificationCode = async () => { try { if (!email) { - toast.error(t($ => $['error.emailEmpty'], { ns: 'login' })) + toast.error(t(($) => $['error.emailEmpty'], { ns: 'login' })) return } if (!emailRegex.test(email)) { - toast.error(t($ => $['error.emailInValid'], { ns: 'login' })) + toast.error(t(($) => $['error.emailInValid'], { ns: 'login' })) return } setLoading(true) @@ -44,11 +44,9 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) { params.set('token', encodeURIComponent(ret.data)) router.push(`/signin/check-code?${params.toString()}`) } - } - catch (error) { + } catch (error) { console.error(error) - } - finally { + } finally { setLoading(false) } } @@ -60,18 +58,28 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) { }} > <Field name="email" disabled={isInvite} className="mb-2"> - <FieldLabel className="my-2 py-0 system-md-semibold text-text-secondary">{t($ => $.email, { ns: 'login' })}</FieldLabel> + <FieldLabel className="my-2 py-0 system-md-semibold text-text-secondary"> + {t(($) => $.email, { ns: 'login' })} + </FieldLabel> <FieldControl type="email" autoComplete="email" spellCheck={false} disabled={isInvite} value={email} - placeholder={t($ => $.emailPlaceholder, { ns: 'login' }) as string} + placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) as string} onValueChange={setEmail} /> <div className="mt-3"> - <Button type="submit" loading={loading} disabled={loading || !email} variant="primary" className="w-full">{t($ => $['signup.verifyMail'], { ns: 'login' })}</Button> + <Button + type="submit" + loading={loading} + disabled={loading || !email} + variant="primary" + className="w-full" + > + {t(($) => $['signup.verifyMail'], { ns: 'login' })} + </Button> </div> </Field> </Form> diff --git a/web/app/signin/components/mail-and-password-auth.tsx b/web/app/signin/components/mail-and-password-auth.tsx index f39553bfab6d9f..baa339384791eb 100644 --- a/web/app/signin/components/mail-and-password-auth.tsx +++ b/web/app/signin/components/mail-and-password-auth.tsx @@ -30,10 +30,7 @@ type LoginRequestBody = { } function hasErrorCode(error: unknown, code: string) { - return typeof error === 'object' - && error !== null - && 'code' in error - && error.code === code + return typeof error === 'object' && error !== null && 'code' in error && error.code === code } export default function MailAndPasswordAuth({ isInvite, isEmailSetup }: MailAndPasswordAuthProps) { @@ -51,15 +48,15 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup }: MailAndP const handleEmailPasswordLogin = async () => { if (!email) { - toast.error(t($ => $['error.emailEmpty'], { ns: 'login' })) + toast.error(t(($) => $['error.emailEmpty'], { ns: 'login' })) return } if (!emailRegex.test(email)) { - toast.error(t($ => $['error.emailInValid'], { ns: 'login' })) + toast.error(t(($) => $['error.emailInValid'], { ns: 'login' })) return } if (!password?.trim()) { - toast.error(t($ => $['error.passwordEmpty'], { ns: 'login' })) + toast.error(t(($) => $['error.passwordEmpty'], { ns: 'login' })) return } @@ -89,23 +86,19 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup }: MailAndP if (isInvite) { router.replace(`/signin/invite-settings?${searchParams.toString()}`) - } - else { + } else { await queryClient.resetQueries({ queryKey: consoleQuery.account.profile.get.key() }) const redirectUrl = resolvePostLoginRedirect(searchParams) router.replace(redirectUrl || '/') } - } - else { + } else { toast.error(res.data) } - } - catch (error) { + } catch (error) { if (hasErrorCode(error, 'authentication_failed')) { - toast.error(t($ => $['error.invalidEmailOrPassword'], { ns: 'login' })) + toast.error(t(($) => $['error.invalidEmailOrPassword'], { ns: 'login' })) } - } - finally { + } finally { setIsLoading(false) } } @@ -118,7 +111,7 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup }: MailAndP > <Field name="email" disabled={isInvite} className="mb-3"> <FieldLabel className="my-2 py-0 system-md-semibold text-text-secondary"> - {t($ => $.email, { ns: 'login' })} + {t(($) => $.email, { ns: 'login' })} </FieldLabel> <FieldControl value={email} @@ -127,20 +120,22 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup }: MailAndP type="email" autoComplete="email" spellCheck={false} - placeholder={t($ => $.emailPlaceholder, { ns: 'login' }) || ''} + placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) || ''} /> </Field> <Field name="password" className="mb-3"> <div className="my-2 flex items-center justify-between"> - <FieldLabel className="py-0 system-md-semibold text-text-secondary">{t($ => $.password, { ns: 'login' })}</FieldLabel> + <FieldLabel className="py-0 system-md-semibold text-text-secondary"> + {t(($) => $.password, { ns: 'login' })} + </FieldLabel> <Link href={`/reset-password?${searchParams.toString()}`} className={`system-xs-regular ${isEmailSetup ? 'text-components-button-secondary-accent-text' : 'pointer-events-none text-components-button-secondary-accent-text-disabled'}`} tabIndex={isEmailSetup ? 0 : -1} aria-disabled={!isEmailSetup} > - {t($ => $.forget, { ns: 'login' })} + {t(($) => $.forget, { ns: 'login' })} </Link> </div> <div className="relative mt-1"> @@ -150,21 +145,25 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup }: MailAndP type={showPassword ? 'text' : 'password'} autoComplete="current-password" spellCheck={false} - placeholder={t($ => $.passwordPlaceholder, { ns: 'login' }) || ''} + placeholder={t(($) => $.passwordPlaceholder, { ns: 'login' }) || ''} className="pr-10" /> <div className="absolute inset-y-0 right-0 flex items-center"> <Button type="button" variant="ghost" - aria-label={t($ => $[showPassword ? 'hidePassword' : 'showPassword'], { ns: 'login' })} + aria-label={t(($) => $[showPassword ? 'hidePassword' : 'showPassword'], { + ns: 'login', + })} aria-pressed={showPassword} className="mr-1 size-8 p-0 text-text-tertiary hover:text-text-secondary" onClick={() => setShowPassword(!showPassword)} > - {showPassword - ? <span className="i-ri-eye-off-line size-4" aria-hidden="true" /> - : <span className="i-ri-eye-line size-4" aria-hidden="true" />} + {showPassword ? ( + <span className="i-ri-eye-off-line size-4" aria-hidden="true" /> + ) : ( + <span className="i-ri-eye-line size-4" aria-hidden="true" /> + )} </Button> </div> </div> @@ -178,7 +177,7 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup }: MailAndP disabled={isLoading || !email || !password} className="w-full" > - {t($ => $.signBtn, { ns: 'login' })} + {t(($) => $.signBtn, { ns: 'login' })} </Button> </div> </Form> diff --git a/web/app/signin/components/social-auth.tsx b/web/app/signin/components/social-auth.tsx index 8b67d2ba0b9ad9..93132e748e75bb 100644 --- a/web/app/signin/components/social-auth.tsx +++ b/web/app/signin/components/social-auth.tsx @@ -21,13 +21,11 @@ export default function SocialAuth(props: SocialAuthProps) { const url = getPurifyHref(`${API_PREFIX}${href}`) const params = new URLSearchParams(searchParams.toString()) const timezone = getBrowserTimezone() - if (timezone) - params.set('timezone', timezone) + if (timezone) params.set('timezone', timezone) params.set('language', locale) const query = params.toString() - if (query) - return `${url}?${query}` + if (query) return `${url}?${query}` return url } @@ -35,32 +33,24 @@ export default function SocialAuth(props: SocialAuthProps) { <> <div className="w-full"> <a href={getOAuthLink('/oauth/login/github')}> - <Button - disabled={props.disabled} - className="w-full" - > + <Button disabled={props.disabled} className="w-full"> <> - <span className={ - cn(style.githubIcon, 'mr-2 size-5') - } - /> - <span className="truncate leading-normal">{t($ => $.withGitHub, { ns: 'login' })}</span> + <span className={cn(style.githubIcon, 'mr-2 size-5')} /> + <span className="truncate leading-normal"> + {t(($) => $.withGitHub, { ns: 'login' })} + </span> </> </Button> </a> </div> <div className="w-full"> <a href={getOAuthLink('/oauth/login/google')}> - <Button - disabled={props.disabled} - className="w-full" - > + <Button disabled={props.disabled} className="w-full"> <> - <span className={ - cn(style.googleIcon, 'mr-2 size-5') - } - /> - <span className="truncate leading-normal">{t($ => $.withGoogle, { ns: 'login' })}</span> + <span className={cn(style.googleIcon, 'mr-2 size-5')} /> + <span className="truncate leading-normal"> + {t(($) => $.withGoogle, { ns: 'login' })} + </span> </> </Button> </a> diff --git a/web/app/signin/components/sso-auth.tsx b/web/app/signin/components/sso-auth.tsx index 35d1c5d80326d2..e219ffe41089d9 100644 --- a/web/app/signin/components/sso-auth.tsx +++ b/web/app/signin/components/sso-auth.tsx @@ -13,9 +13,7 @@ type SSOAuthProps = { protocol: string } -const SSOAuth: FC<SSOAuthProps> = ({ - protocol, -}) => { +const SSOAuth: FC<SSOAuthProps> = ({ protocol }) => { const router = useRouter() const { t } = useTranslation() const searchParams = useSearchParams() @@ -26,30 +24,33 @@ const SSOAuth: FC<SSOAuthProps> = ({ const handleSSOLogin = () => { setIsLoading(true) if (protocol === SSOProtocol.SAML) { - getUserSAMLSSOUrl(invite_token).then((res) => { - router.push(res.url) - }).finally(() => { - setIsLoading(false) - }) - } - else if (protocol === SSOProtocol.OIDC) { - getUserOIDCSSOUrl(invite_token).then((res) => { - document.cookie = `user-oidc-state=${res.state};Path=/` - router.push(res.url) - }).finally(() => { - setIsLoading(false) - }) - } - else if (protocol === SSOProtocol.OAuth2) { - getUserOAuth2SSOUrl(invite_token).then((res) => { - document.cookie = `user-oauth2-state=${res.state};Path=/` - router.push(res.url) - }).finally(() => { - setIsLoading(false) - }) - } - else { - toast.error(t($ => $['error.invalidSSOProtocol'], { ns: 'login' })) + getUserSAMLSSOUrl(invite_token) + .then((res) => { + router.push(res.url) + }) + .finally(() => { + setIsLoading(false) + }) + } else if (protocol === SSOProtocol.OIDC) { + getUserOIDCSSOUrl(invite_token) + .then((res) => { + document.cookie = `user-oidc-state=${res.state};Path=/` + router.push(res.url) + }) + .finally(() => { + setIsLoading(false) + }) + } else if (protocol === SSOProtocol.OAuth2) { + getUserOAuth2SSOUrl(invite_token) + .then((res) => { + document.cookie = `user-oauth2-state=${res.state};Path=/` + router.push(res.url) + }) + .finally(() => { + setIsLoading(false) + }) + } else { + toast.error(t(($) => $['error.invalidSSOProtocol'], { ns: 'login' })) setIsLoading(false) } } @@ -57,12 +58,14 @@ const SSOAuth: FC<SSOAuthProps> = ({ return ( <Button tabIndex={0} - onClick={() => { handleSSOLogin() }} + onClick={() => { + handleSSOLogin() + }} disabled={isLoading} className="w-full" > <Lock01 className="mr-2 size-5 text-text-accent-light-mode-only" /> - <span className="truncate">{t($ => $.withSSO, { ns: 'login' })}</span> + <span className="truncate">{t(($) => $.withSSO, { ns: 'login' })}</span> </Button> ) } diff --git a/web/app/signin/invite-settings/__tests__/page.spec.tsx b/web/app/signin/invite-settings/__tests__/page.spec.tsx index 4038aff328fe61..86f35ac54e9997 100644 --- a/web/app/signin/invite-settings/__tests__/page.spec.tsx +++ b/web/app/signin/invite-settings/__tests__/page.spec.tsx @@ -9,7 +9,8 @@ import { getBrowserTimezone } from '@/utils/timezone' import InviteSettingsPage from '../page' vi.mock('@tanstack/react-query', async () => { - const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query') + const actual = + await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query') return { ...actual, useQueryClient: vi.fn(() => ({ @@ -68,16 +69,24 @@ const mockUseLocale = useLocale as unknown as MockedFunction<typeof useLocale> const mockUseRouter = useRouter as unknown as MockedFunction<typeof useRouter> const mockUseSearchParams = useSearchParams as unknown as MockedFunction<typeof useSearchParams> const mockActivateMember = activateMember as unknown as MockedFunction<typeof activateMember> -const mockUseInvitationCheck = useInvitationCheck as unknown as MockedFunction<typeof useInvitationCheck> -const mockGetBrowserTimezone = getBrowserTimezone as unknown as MockedFunction<typeof getBrowserTimezone> +const mockUseInvitationCheck = useInvitationCheck as unknown as MockedFunction< + typeof useInvitationCheck +> +const mockGetBrowserTimezone = getBrowserTimezone as unknown as MockedFunction< + typeof getBrowserTimezone +> describe('InviteSettingsPage', () => { beforeEach(() => { vi.clearAllMocks() mockUseLocale.mockReturnValue('zh-Hans') - mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>) + mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType< + typeof useRouter + >) mockUseSearchParams.mockReturnValue( - new URLSearchParams('invite_token=invite-token') as unknown as ReturnType<typeof useSearchParams>, + new URLSearchParams('invite_token=invite-token') as unknown as ReturnType< + typeof useSearchParams + >, ) mockUseInvitationCheck.mockReturnValue({ data: { diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index 44a01f97dc1c57..8c5024ae3f2271 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -2,7 +2,14 @@ import type { Locale } from '@/i18n-config' import { Button } from '@langgenius/dify-ui/button' import { Input } from '@langgenius/dify-ui/input' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' import { RiAccountCircleLine } from '@remixicon/react' import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' @@ -34,20 +41,19 @@ type TimezoneSelectOption = { } const LANGUAGE_OPTIONS: LanguageSelectOption[] = languages - .filter(item => item.supported) - .map(item => ({ + .filter((item) => item.supported) + .map((item) => ({ value: item.value, name: item.name, })) -const TIMEZONE_OPTIONS: TimezoneSelectOption[] = timezones.map(item => ({ +const TIMEZONE_OPTIONS: TimezoneSelectOption[] = timezones.map((item) => ({ value: String(item.value), name: item.name, })) const getInitialLanguage = (locale: Locale): Locale => { - if (LANGUAGE_OPTIONS.some(item => item.value === locale)) - return locale + if (LANGUAGE_OPTIONS.some((item) => item.value === locale)) return locale return i18n.defaultLocale } @@ -64,19 +70,17 @@ export default function InviteSettingsPage() { const [isActivating, setIsActivating] = useState(false) const [language, setLanguage] = useState(() => getInitialLanguage(locale)) const [timezone, setTimezone] = useState(() => getBrowserTimezone() || 'America/Los_Angeles') - const selectedLanguage = LANGUAGE_OPTIONS.find(item => item.value === language) - const selectedTimezone = TIMEZONE_OPTIONS.find(item => item.value === timezone) + const selectedLanguage = LANGUAGE_OPTIONS.find((item) => item.value === language) + const selectedTimezone = TIMEZONE_OPTIONS.find((item) => item.value === timezone) const handleLanguageChange = (nextValue: string | null) => { - const nextLanguage = LANGUAGE_OPTIONS.find(item => item.value === nextValue) - if (nextLanguage) - setLanguage(nextLanguage.value) + const nextLanguage = LANGUAGE_OPTIONS.find((item) => item.value === nextValue) + if (nextLanguage) setLanguage(nextLanguage.value) } const handleTimezoneChange = (nextValue: string | null) => { - const nextTimezone = TIMEZONE_OPTIONS.find(item => item.value === nextValue) - if (nextTimezone) - setTimezone(nextTimezone.value) + const nextTimezone = TIMEZONE_OPTIONS.find((item) => item.value === nextValue) + if (nextTimezone) setTimezone(nextTimezone.value) } const checkParams = { @@ -86,12 +90,13 @@ export default function InviteSettingsPage() { }, } const { data: checkRes, refetch: recheck } = useInvitationCheck(checkParams.params, !!token) - const requiresAccountSetup = checkRes?.data?.requires_setup ?? checkRes?.data?.account_status === 'pending' + const requiresAccountSetup = + checkRes?.data?.requires_setup ?? checkRes?.data?.account_status === 'pending' const handleActivate = useCallback(async () => { try { if (requiresAccountSetup && !name) { - toast.error(t($ => $.enterYourName, { ns: 'login' })) + toast.error(t(($) => $.enterYourName, { ns: 'login' })) return } setIsActivating(true) @@ -111,31 +116,44 @@ export default function InviteSettingsPage() { }) if (res.result === 'success') { // Tokens are now stored in cookies by the backend - if (requiresAccountSetup) - await setLocaleOnClient(language!, false) + if (requiresAccountSetup) await setLocaleOnClient(language!, false) await queryClient.resetQueries({ queryKey: consoleQuery.account.profile.get.key() }) const redirectUrl = resolvePostLoginRedirect(searchParams) router.replace(redirectUrl || '/') } - } - catch { + } catch { recheck() setIsActivating(false) } - }, [isActivating, language, name, queryClient, recheck, requiresAccountSetup, searchParams, timezone, token, router, t]) + }, [ + isActivating, + language, + name, + queryClient, + recheck, + requiresAccountSetup, + searchParams, + timezone, + token, + router, + t, + ]) - if (!checkRes) - return <Loading /> + if (!checkRes) return <Loading /> if (!checkRes.is_valid) { return ( <div className="flex flex-col md:w-[400px]"> <div className="mx-auto w-full"> - <div className="mb-3 flex size-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle text-2xl font-bold shadow-lg">🤷‍♂️</div> - <h2 className="title-4xl-semi-bold text-text-primary">{t($ => $.invalid, { ns: 'login' })}</h2> + <div className="mb-3 flex size-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle text-2xl font-bold shadow-lg"> + 🤷‍♂️ + </div> + <h2 className="title-4xl-semi-bold text-text-primary"> + {t(($) => $.invalid, { ns: 'login' })} + </h2> </div> <div className="mx-auto mt-6 w-full"> <Button variant="primary" className="w-full text-sm!"> - <a href="https://dify.ai">{t($ => $.explore, { ns: 'login' })}</a> + <a href="https://dify.ai">{t(($) => $.explore, { ns: 'login' })}</a> </Button> </div> </div> @@ -150,8 +168,8 @@ export default function InviteSettingsPage() { <div className="pt-2 pb-4"> <h2 className="title-4xl-semi-bold text-text-primary"> {requiresAccountSetup - ? t($ => $.setYourAccount, { ns: 'login' }) - : `${t($ => $.join, { ns: 'login' })}${checkRes?.data?.workspace_name}`} + ? t(($) => $.setYourAccount, { ns: 'login' }) + : `${t(($) => $.join, { ns: 'login' })}${checkRes?.data?.workspace_name}`} </h2> </div> <form onSubmit={noop}> @@ -159,15 +177,15 @@ export default function InviteSettingsPage() { <> <div className="mb-5"> <label htmlFor="name" className="my-2 system-md-semibold text-text-secondary"> - {t($ => $.name, { ns: 'login' })} + {t(($) => $.name, { ns: 'login' })} </label> <div className="mt-1"> <Input id="name" type="text" value={name} - onChange={e => setName(e.target.value)} - placeholder={t($ => $.namePlaceholder, { ns: 'login' }) || ''} + onChange={(e) => setName(e.target.value)} + placeholder={t(($) => $.namePlaceholder, { ns: 'login' }) || ''} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() @@ -179,8 +197,11 @@ export default function InviteSettingsPage() { </div> </div> <div className="mb-5"> - <label htmlFor="interface_language" className="my-2 system-md-semibold text-text-secondary"> - {t($ => $.interfaceLanguage, { ns: 'login' })} + <label + htmlFor="interface_language" + className="my-2 system-md-semibold text-text-secondary" + > + {t(($) => $.interfaceLanguage, { ns: 'login' })} </label> <div className="mt-1"> <Select @@ -188,10 +209,10 @@ export default function InviteSettingsPage() { onValueChange={handleLanguageChange} > <SelectTrigger id="interface_language" size="large"> - {selectedLanguage?.name ?? t($ => $['placeholder.select'], { ns: 'common' })} + {selectedLanguage?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })} </SelectTrigger> <SelectContent> - {LANGUAGE_OPTIONS.map(item => ( + {LANGUAGE_OPTIONS.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -203,7 +224,7 @@ export default function InviteSettingsPage() { </div> <div className="mb-5"> <label htmlFor="timezone" className="system-md-semibold text-text-secondary"> - {t($ => $.timezone, { ns: 'login' })} + {t(($) => $.timezone, { ns: 'login' })} </label> <div className="mt-1"> <Select @@ -211,10 +232,10 @@ export default function InviteSettingsPage() { onValueChange={handleTimezoneChange} > <SelectTrigger id="timezone" size="large"> - {selectedTimezone?.name ?? t($ => $['placeholder.select'], { ns: 'common' })} + {selectedTimezone?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })} </SelectTrigger> <SelectContent> - {TIMEZONE_OPTIONS.map(item => ( + {TIMEZONE_OPTIONS.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -234,21 +255,21 @@ export default function InviteSettingsPage() { loading={isActivating} disabled={isActivating} > - {`${t($ => $.join, { ns: 'login' })} ${checkRes?.data?.workspace_name}`} + {`${t(($) => $.join, { ns: 'login' })} ${checkRes?.data?.workspace_name}`} </Button> </div> </form> {!systemFeatures.branding.enabled && ( <div className="mt-2 block w-full system-xs-regular text-text-tertiary"> - {t($ => $['license.tip'], { ns: 'login' })} -   + {t(($) => $['license.tip'], { ns: 'login' })} +   <Link className="system-xs-medium text-text-accent-secondary" target="_blank" rel="noopener noreferrer" href={LICENSE_LINK} > - {t($ => $['license.link'], { ns: 'login' })} + {t(($) => $['license.link'], { ns: 'login' })} </Link> </div> )} diff --git a/web/app/signin/layout.tsx b/web/app/signin/layout.tsx index 8160a2b59bdd40..e63f00c10f2b9f 100644 --- a/web/app/signin/layout.tsx +++ b/web/app/signin/layout.tsx @@ -1,7 +1,6 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' - import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useDocumentTitle from '@/hooks/use-document-title' import Header from './_header' @@ -12,20 +11,22 @@ export default function SignInLayout({ children }: any) { return ( <> <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> - <div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}> + <div + className={cn( + 'flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle', + )} + > <Header /> - <div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}> - <div className="flex flex-col md:w-[400px]"> - {children} - </div> + <div + className={cn( + 'flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]', + )} + > + <div className="flex flex-col md:w-[400px]">{children}</div> </div> {systemFeatures.branding.enabled === false && ( <div className="px-8 py-6 system-xs-regular text-text-tertiary"> - © - {' '} - {new Date().getFullYear()} - {' '} - LangGenius, Inc. All rights reserved. + © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. </div> )} </div> diff --git a/web/app/signin/normal-form.tsx b/web/app/signin/normal-form.tsx index 13292dd4bbfb90..4af4a4b954d798 100644 --- a/web/app/signin/normal-form.tsx +++ b/web/app/signin/normal-form.tsx @@ -28,9 +28,13 @@ function NormalForm() { // Login probe: 401 stays as `error` (legitimate "not logged in" state on /signin), // other errors throw to error.tsx. jumpTo same-pathname guard in service/base.ts // prevents the redirect loop on 401. - const { isPending: isCheckLoading, data: userResp, error: probeError } = useQuery({ + const { + isPending: isCheckLoading, + data: userResp, + error: probeError, + } = useQuery({ ...userProfileQueryOptions(), - throwOnError: err => !isLegacyBase401(err), + throwOnError: (err) => !isLegacyBase401(err), refetchOnWindowFocus: false, }) const isLoggedIn = !!userResp && !probeError @@ -40,14 +44,19 @@ function NormalForm() { const [selectedAuthType, setSelectedAuthType] = useState<AuthType | null>(null) const isInviteLink = Boolean(inviteToken && inviteToken !== 'null') - const { data: invitationCheckResp, isPending: isInviteCheckLoading, isError: isInviteCheckError } = useQuery({ + const { + data: invitationCheckResp, + isPending: isInviteCheckLoading, + isError: isInviteCheckError, + } = useQuery({ queryKey: ['signin', 'invite-check', inviteToken], - queryFn: () => invitationCheck({ - url: '/activate/check', - params: { - token: inviteToken, - }, - }), + queryFn: () => + invitationCheck({ + url: '/activate/check', + params: { + token: inviteToken, + }, + }), enabled: isInviteLink, retry: false, refetchOnWindowFocus: false, @@ -60,19 +69,20 @@ function NormalForm() { const hasEmailPasswordLogin = systemFeatures.enable_email_password_login const hasEmailLogin = hasEmailCodeLogin || hasEmailPasswordLogin const defaultAuthType: AuthType = hasEmailPasswordLogin ? 'password' : 'code' - const authType = selectedAuthType === 'password' && hasEmailPasswordLogin - ? 'password' - : selectedAuthType === 'code' && hasEmailCodeLogin - ? 'code' - : defaultAuthType + const authType = + selectedAuthType === 'password' && hasEmailPasswordLogin + ? 'password' + : selectedAuthType === 'code' && hasEmailCodeLogin + ? 'code' + : defaultAuthType const showORLine = (hasSocialLogin || hasSsoLogin) && hasEmailLogin - const noLoginMethodsConfigured = !hasSocialLogin && !hasEmailCodeLogin && !hasEmailPasswordLogin && !hasSsoLogin + const noLoginMethodsConfigured = + !hasSocialLogin && !hasEmailCodeLogin && !hasEmailPasswordLogin && !hasSsoLogin const allMethodsAreDisabled = noLoginMethodsConfigured || isInviteCheckError const isLoading = isCheckLoading || isLoggedIn || (isInviteLink && isInviteCheckLoading) useEffect(() => { - if (!isLoggedIn) - return + if (!isLoggedIn) return if (isInviteLink) { router.replace(`/signin/invite-settings?${searchParams.toString()}`) @@ -84,19 +94,17 @@ function NormalForm() { }, [isInviteLink, isLoggedIn, router, searchParams]) useEffect(() => { - if (message) - toast.error(message) + if (message) toast.error(message) }, [message]) if (isLoading) { return ( - <div className={ - cn( + <div + className={cn( 'flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]', - ) - } + )} > <Loading type="area" /> </div> @@ -111,8 +119,12 @@ function NormalForm() { <RiContractLine className="size-5" /> <RiErrorWarningFill className="absolute -top-1 -right-1 size-4 text-text-warning-secondary" /> </div> - <p className="system-sm-medium text-text-primary">{t($ => $.licenseLost, { ns: 'login' })}</p> - <p className="mt-1 system-xs-regular text-text-tertiary">{t($ => $.licenseLostTip, { ns: 'login' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $.licenseLost, { ns: 'login' })} + </p> + <p className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $.licenseLostTip, { ns: 'login' })} + </p> </div> </div> </div> @@ -127,8 +139,12 @@ function NormalForm() { <RiContractLine className="size-5" /> <RiErrorWarningFill className="absolute -top-1 -right-1 size-4 text-text-warning-secondary" /> </div> - <p className="system-sm-medium text-text-primary">{t($ => $.licenseExpired, { ns: 'login' })}</p> - <p className="mt-1 system-xs-regular text-text-tertiary">{t($ => $.licenseExpiredTip, { ns: 'login' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $.licenseExpired, { ns: 'login' })} + </p> + <p className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $.licenseExpiredTip, { ns: 'login' })} + </p> </div> </div> </div> @@ -143,8 +159,12 @@ function NormalForm() { <RiContractLine className="size-5" /> <RiErrorWarningFill className="absolute -top-1 -right-1 size-4 text-text-warning-secondary" /> </div> - <p className="system-sm-medium text-text-primary">{t($ => $.licenseInactive, { ns: 'login' })}</p> - <p className="mt-1 system-xs-regular text-text-tertiary">{t($ => $.licenseInactiveTip, { ns: 'login' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $.licenseInactive, { ns: 'login' })} + </p> + <p className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $.licenseInactiveTip, { ns: 'login' })} + </p> </div> </div> </div> @@ -154,28 +174,32 @@ function NormalForm() { return ( <> <div className="mx-auto mt-8 w-full"> - {isInviteLink - ? ( - <div className="mx-auto w-full"> - <h2 className="title-4xl-semi-bold text-text-primary"> - {t($ => $.join, { ns: 'login' })} - {workspaceName} - </h2> - {!systemFeatures.branding.enabled && ( - <p className="mt-2 body-md-regular text-text-tertiary"> - {t($ => $.joinTipStart, { ns: 'login' })} - {workspaceName} - {t($ => $.joinTipEnd, { ns: 'login' })} - </p> - )} - </div> - ) - : ( - <div className="mx-auto w-full"> - <h2 className="title-4xl-semi-bold text-text-primary">{systemFeatures.branding.enabled ? t($ => $.pageTitleForE, { ns: 'login' }) : t($ => $.pageTitle, { ns: 'login' })}</h2> - <p className="mt-2 body-md-regular text-text-tertiary">{t($ => $.welcome, { ns: 'login' })}</p> - </div> + {isInviteLink ? ( + <div className="mx-auto w-full"> + <h2 className="title-4xl-semi-bold text-text-primary"> + {t(($) => $.join, { ns: 'login' })} + {workspaceName} + </h2> + {!systemFeatures.branding.enabled && ( + <p className="mt-2 body-md-regular text-text-tertiary"> + {t(($) => $.joinTipStart, { ns: 'login' })} + {workspaceName} + {t(($) => $.joinTipEnd, { ns: 'login' })} + </p> )} + </div> + ) : ( + <div className="mx-auto w-full"> + <h2 className="title-4xl-semi-bold text-text-primary"> + {systemFeatures.branding.enabled + ? t(($) => $.pageTitleForE, { ns: 'login' }) + : t(($) => $.pageTitle, { ns: 'login' })} + </h2> + <p className="mt-2 body-md-regular text-text-tertiary"> + {t(($) => $.welcome, { ns: 'login' })} + </p> + </div> + )} <div className="relative"> <div className="mt-6 flex flex-col gap-3"> {hasSocialLogin && <SocialAuth />} @@ -190,55 +214,63 @@ function NormalForm() { <div className="relative mt-6"> <div className="flex items-center"> <div className="h-px flex-1 bg-linear-to-r from-background-gradient-mask-transparent to-divider-regular"></div> - <span className="px-3 system-xs-medium-uppercase text-text-tertiary">{t($ => $.or, { ns: 'login' })}</span> + <span className="px-3 system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $.or, { ns: 'login' })} + </span> <div className="h-px flex-1 bg-linear-to-l from-background-gradient-mask-transparent to-divider-regular"></div> </div> </div> )} - { - hasEmailLogin && ( - <> - {hasEmailCodeLogin && authType === 'code' && ( - <> - <MailAndCodeAuth isInvite={isInviteLink} /> - {hasEmailPasswordLogin && ( - <button - type="button" - className="w-full cursor-pointer py-1 text-center" - onClick={() => { setSelectedAuthType('password') }} - > - <span className="system-xs-medium text-components-button-secondary-accent-text">{t($ => $.usePassword, { ns: 'login' })}</span> - </button> - )} - </> - )} - {hasEmailPasswordLogin && authType === 'password' && ( - <> - <MailAndPasswordAuth isInvite={isInviteLink} isEmailSetup={systemFeatures.is_email_setup} /> - {hasEmailCodeLogin && ( - <button - type="button" - className="w-full cursor-pointer py-1 text-center" - onClick={() => { setSelectedAuthType('code') }} - > - <span className="system-xs-medium text-components-button-secondary-accent-text">{t($ => $.useVerificationCode, { ns: 'login' })}</span> - </button> - )} - </> - )} - <Split className="mt-4 mb-5" /> - </> - ) - } + {hasEmailLogin && ( + <> + {hasEmailCodeLogin && authType === 'code' && ( + <> + <MailAndCodeAuth isInvite={isInviteLink} /> + {hasEmailPasswordLogin && ( + <button + type="button" + className="w-full cursor-pointer py-1 text-center" + onClick={() => { + setSelectedAuthType('password') + }} + > + <span className="system-xs-medium text-components-button-secondary-accent-text"> + {t(($) => $.usePassword, { ns: 'login' })} + </span> + </button> + )} + </> + )} + {hasEmailPasswordLogin && authType === 'password' && ( + <> + <MailAndPasswordAuth + isInvite={isInviteLink} + isEmailSetup={systemFeatures.is_email_setup} + /> + {hasEmailCodeLogin && ( + <button + type="button" + className="w-full cursor-pointer py-1 text-center" + onClick={() => { + setSelectedAuthType('code') + }} + > + <span className="system-xs-medium text-components-button-secondary-accent-text"> + {t(($) => $.useVerificationCode, { ns: 'login' })} + </span> + </button> + )} + </> + )} + <Split className="mt-4 mb-5" /> + </> + )} {systemFeatures.is_allow_register && authType === 'password' && ( <div className="mb-3 text-[13px] leading-4 font-medium text-text-secondary"> - <span>{t($ => $['signup.noAccount'], { ns: 'login' })}</span> - <Link - className="text-text-accent" - href="/signup" - > - {t($ => $['signup.signUp'], { ns: 'login' })} + <span>{t(($) => $['signup.noAccount'], { ns: 'login' })}</span> + <Link className="text-text-accent" href="/signup"> + {t(($) => $['signup.signUp'], { ns: 'login' })} </Link> </div> )} @@ -248,8 +280,12 @@ function NormalForm() { <div className="shadows-shadow-lg mb-2 flex size-10 items-center justify-center rounded-xl bg-components-card-bg shadow"> <RiDoorLockLine className="size-5" /> </div> - <p className="system-sm-medium text-text-primary">{t($ => $.noLoginMethod, { ns: 'login' })}</p> - <p className="mt-1 system-xs-regular text-text-tertiary">{t($ => $.noLoginMethodTip, { ns: 'login' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $.noLoginMethod, { ns: 'login' })} + </p> + <p className="mt-1 system-xs-regular text-text-tertiary"> + {t(($) => $.noLoginMethodTip, { ns: 'login' })} + </p> </div> <div className="relative my-2 py-2"> <div className="absolute inset-0 flex items-center" aria-hidden="true"> @@ -261,35 +297,35 @@ function NormalForm() { {!systemFeatures.branding.enabled && ( <> <div className="mt-2 block w-full system-xs-regular text-text-tertiary"> - {t($ => $.tosDesc, { ns: 'login' })} -   + {t(($) => $.tosDesc, { ns: 'login' })} +   <Link className="system-xs-medium text-text-secondary hover:underline" target="_blank" rel="noopener noreferrer" href="https://dify.ai/terms" > - {t($ => $.tos, { ns: 'login' })} + {t(($) => $.tos, { ns: 'login' })} </Link> -  &  +  &  <Link className="system-xs-medium text-text-secondary hover:underline" target="_blank" rel="noopener noreferrer" href="https://dify.ai/privacy" > - {t($ => $.pp, { ns: 'login' })} + {t(($) => $.pp, { ns: 'login' })} </Link> </div> {IS_CE_EDITION && ( <div className="w-hull mt-2 block system-xs-regular text-text-tertiary"> - {t($ => $.goToInit, { ns: 'login' })} -   + {t(($) => $.goToInit, { ns: 'login' })} +   <Link className="system-xs-medium text-text-secondary hover:underline" href="/install" > - {t($ => $.setAdminAccount, { ns: 'login' })} + {t(($) => $.setAdminAccount, { ns: 'login' })} </Link> </div> )} diff --git a/web/app/signin/one-more-step.tsx b/web/app/signin/one-more-step.tsx index 41a925cb834f7e..350248b399e065 100644 --- a/web/app/signin/one-more-step.tsx +++ b/web/app/signin/one-more-step.tsx @@ -2,7 +2,14 @@ import type { Reducer } from 'react' import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' import { useQueryClient } from '@tanstack/react-query' import { useReducer } from 'react' @@ -22,11 +29,11 @@ type IState = { timezone: string } -type IAction - = | { type: 'failed', payload: null } - | { type: 'invitation_code', value: string } - | { type: 'interface_language', value: string } - | { type: 'timezone', value: string } +type IAction = + | { type: 'failed'; payload: null } + | { type: 'invitation_code'; value: string } + | { type: 'interface_language'; value: string } + | { type: 'timezone'; value: string } const reducer: Reducer<IState, IAction> = (state: IState, action: IAction) => { switch (action.type) { @@ -52,17 +59,19 @@ type SelectOption = { name: string } -const LANGUAGE_OPTIONS: SelectOption[] = languages.filter(item => item.supported) -const TIMEZONE_OPTIONS: SelectOption[] = timezones.map(item => ({ +const LANGUAGE_OPTIONS: SelectOption[] = languages.filter((item) => item.supported) +const TIMEZONE_OPTIONS: SelectOption[] = timezones.map((item) => ({ value: String(item.value), name: item.name, })) const hasStatus = (error: unknown): error is { status: number } => { - return typeof error === 'object' - && error !== null - && 'status' in error - && typeof error.status === 'number' + return ( + typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number' + ) } const OneMoreStep = () => { @@ -77,24 +86,21 @@ const OneMoreStep = () => { timezone: 'Asia/Shanghai', }) const { mutateAsync: submitOneMoreStep, isPending } = useOneMoreStep() - const selectedLanguage = LANGUAGE_OPTIONS.find(item => item.value === state.interface_language) - const selectedTimezone = TIMEZONE_OPTIONS.find(item => item.value === state.timezone) + const selectedLanguage = LANGUAGE_OPTIONS.find((item) => item.value === state.interface_language) + const selectedTimezone = TIMEZONE_OPTIONS.find((item) => item.value === state.timezone) const handleLanguageChange = (nextValue: string | null) => { - const nextLanguage = LANGUAGE_OPTIONS.find(item => item.value === nextValue) - if (nextLanguage) - dispatch({ type: 'interface_language', value: nextLanguage.value }) + const nextLanguage = LANGUAGE_OPTIONS.find((item) => item.value === nextValue) + if (nextLanguage) dispatch({ type: 'interface_language', value: nextLanguage.value }) } const handleTimezoneChange = (nextValue: string | null) => { - const nextTimezone = TIMEZONE_OPTIONS.find(item => item.value === nextValue) - if (nextTimezone) - dispatch({ type: 'timezone', value: nextTimezone.value }) + const nextTimezone = TIMEZONE_OPTIONS.find((item) => item.value === nextValue) + if (nextTimezone) dispatch({ type: 'timezone', value: nextTimezone.value }) } const handleSubmit = async () => { - if (isPending) - return + if (isPending) return try { await submitOneMoreStep({ invitation_code: state.invitation_code, @@ -103,10 +109,9 @@ const OneMoreStep = () => { }) await queryClient.resetQueries({ queryKey: consoleQuery.account.profile.get.key() }) router.replace('/') - } - catch (error: unknown) { + } catch (error: unknown) { if (hasStatus(error) && error.status === 400) - toast.error(t($ => $.invalidInvitationCode, { ns: 'login' })) + toast.error(t(($) => $.invalidInvitationCode, { ns: 'login' })) dispatch({ type: 'failed', payload: null }) } } @@ -114,37 +119,41 @@ const OneMoreStep = () => { return ( <> <div className="mx-auto w-full"> - <h2 className="title-4xl-semi-bold text-text-secondary">{t($ => $.oneMoreStep, { ns: 'login' })}</h2> - <p className="mt-1 body-md-regular text-text-tertiary">{t($ => $.createSample, { ns: 'login' })}</p> + <h2 className="title-4xl-semi-bold text-text-secondary"> + {t(($) => $.oneMoreStep, { ns: 'login' })} + </h2> + <p className="mt-1 body-md-regular text-text-tertiary"> + {t(($) => $.createSample, { ns: 'login' })} + </p> </div> <div className="mx-auto mt-6 w-full"> <div className="relative"> <div className="mb-5"> <div className="my-2 flex items-center justify-between system-md-semibold text-text-secondary"> - <label htmlFor="invitation_code"> - {t($ => $.invitationCode, { ns: 'login' })} - </label> + <label htmlFor="invitation_code">{t(($) => $.invitationCode, { ns: 'login' })}</label> <Popover> <PopoverTrigger openOnHover - render={( + render={ <button type="button" className="cursor-pointer rounded-sm text-text-accent-secondary outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-hover" > - {t($ => $.dontHave, { ns: 'login' })} + {t(($) => $.dontHave, { ns: 'login' })} </button> - )} + } /> <PopoverContent placement="top" popupClassName="w-[256px] px-3 py-2 text-xs font-medium text-text-tertiary" > <div> - <div className="font-medium">{t($ => $.sendUsMail, { ns: 'login' })}</div> + <div className="font-medium">{t(($) => $.sendUsMail, { ns: 'login' })}</div> <div className="cursor-pointer text-xs font-medium text-text-accent-secondary"> - <a href="mailto:request-invitation@langgenius.ai">request-invitation@langgenius.ai</a> + <a href="mailto:request-invitation@langgenius.ai"> + request-invitation@langgenius.ai + </a> </div> </div> </PopoverContent> @@ -155,7 +164,7 @@ const OneMoreStep = () => { id="invitation_code" value={state.invitation_code} type="text" - placeholder={t($ => $.invitationCodePlaceholder, { ns: 'login' }) || ''} + placeholder={t(($) => $.invitationCodePlaceholder, { ns: 'login' }) || ''} onChange={(e) => { dispatch({ type: 'invitation_code', value: e.target.value.trim() }) }} @@ -163,19 +172,19 @@ const OneMoreStep = () => { </div> </div> <div className="mb-5"> - <label htmlFor="interface_language" className="my-2 system-md-semibold text-text-secondary"> - {t($ => $.interfaceLanguage, { ns: 'login' })} + <label + htmlFor="interface_language" + className="my-2 system-md-semibold text-text-secondary" + > + {t(($) => $.interfaceLanguage, { ns: 'login' })} </label> <div className="mt-1"> - <Select - value={selectedLanguage?.value ?? null} - onValueChange={handleLanguageChange} - > + <Select value={selectedLanguage?.value ?? null} onValueChange={handleLanguageChange}> <SelectTrigger id="interface_language" size="large"> - {selectedLanguage?.name ?? t($ => $['placeholder.select'], { ns: 'common' })} + {selectedLanguage?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })} </SelectTrigger> <SelectContent> - {LANGUAGE_OPTIONS.map(item => ( + {LANGUAGE_OPTIONS.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -187,18 +196,15 @@ const OneMoreStep = () => { </div> <div className="mb-4"> <label htmlFor="timezone" className="system-md-semibold text-text-tertiary"> - {t($ => $.timezone, { ns: 'login' })} + {t(($) => $.timezone, { ns: 'login' })} </label> <div className="mt-1"> - <Select - value={selectedTimezone?.value ?? null} - onValueChange={handleTimezoneChange} - > + <Select value={selectedTimezone?.value ?? null} onValueChange={handleTimezoneChange}> <SelectTrigger id="timezone" size="large"> - {selectedTimezone?.name ?? t($ => $['placeholder.select'], { ns: 'common' })} + {selectedTimezone?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })} </SelectTrigger> <SelectContent> - {TIMEZONE_OPTIONS.map(item => ( + {TIMEZONE_OPTIONS.map((item) => ( <SelectItem key={item.value} value={item.value}> <SelectItemText>{item.name}</SelectItemText> <SelectItemIndicator /> @@ -215,11 +221,11 @@ const OneMoreStep = () => { disabled={isPending} onClick={handleSubmit} > - {t($ => $.go, { ns: 'login' })} + {t(($) => $.go, { ns: 'login' })} </Button> </div> <div className="mt-2 block w-full system-xs-regular text-text-tertiary"> - {t($ => $['license.tip'], { ns: 'login' })} + {t(($) => $['license.tip'], { ns: 'login' })}   <Link className="system-xs-medium text-text-accent-secondary" @@ -227,7 +233,7 @@ const OneMoreStep = () => { rel="noopener noreferrer" href={LICENSE_LINK} > - {t($ => $['license.link'], { ns: 'login' })} + {t(($) => $['license.link'], { ns: 'login' })} </Link> </div> </div> diff --git a/web/app/signin/page.module.css b/web/app/signin/page.module.css index e9759b23a85d0c..e49096c9ac0a25 100644 --- a/web/app/signin/page.module.css +++ b/web/app/signin/page.module.css @@ -2,7 +2,7 @@ background: center/contain url('./assets/github.svg') no-repeat; } -html[data-theme="dark"] .githubIcon { +html[data-theme='dark'] .githubIcon { background: center/contain url('./assets/github-dark.svg') no-repeat; } diff --git a/web/app/signin/page.tsx b/web/app/signin/page.tsx index 3f893b12fa1db1..7baf3348aa73a0 100644 --- a/web/app/signin/page.tsx +++ b/web/app/signin/page.tsx @@ -7,8 +7,7 @@ const SignIn = () => { const searchParams = useSearchParams() const step = searchParams.get('step') - if (step === 'next') - return <OneMoreStep /> + if (step === 'next') return <OneMoreStep /> return <NormalForm /> } diff --git a/web/app/signin/split.tsx b/web/app/signin/split.tsx index 708baac003a0d1..f5f812bf0db345 100644 --- a/web/app/signin/split.tsx +++ b/web/app/signin/split.tsx @@ -7,14 +7,14 @@ type Props = { className?: string } -const Split: FC<Props> = ({ - className, -}) => { +const Split: FC<Props> = ({ className }) => { return ( <div - className={cn('h-px w-[400px] bg-[linear-gradient(90deg,rgba(255,255,255,0.01)_0%,rgba(16,24,40,0.08)_50.5%,rgba(255,255,255,0.01)_100%)]', className)} - > - </div> + className={cn( + 'h-px w-[400px] bg-[linear-gradient(90deg,rgba(255,255,255,0.01)_0%,rgba(16,24,40,0.08)_50.5%,rgba(255,255,255,0.01)_100%)]', + className, + )} + ></div> ) } export default React.memo(Split) diff --git a/web/app/signin/utils/__tests__/post-login-redirect.spec.ts b/web/app/signin/utils/__tests__/post-login-redirect.spec.ts index 00e270db2b00ff..5a5c26570c6186 100644 --- a/web/app/signin/utils/__tests__/post-login-redirect.spec.ts +++ b/web/app/signin/utils/__tests__/post-login-redirect.spec.ts @@ -10,10 +10,18 @@ describe('post-login redirect utilities', () => { it('should use the redirect_url query param first', () => { const searchParams = new URLSearchParams({ - redirect_url: encodeURIComponent('/account/oauth/authorize?client_id=app&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback'), + redirect_url: encodeURIComponent( + '/account/oauth/authorize?client_id=app&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback', + ), }) - expect(resolvePostLoginRedirect(searchParams as unknown as Parameters<typeof resolvePostLoginRedirect>[0])).toBe('/account/oauth/authorize?client_id=app&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback') + expect( + resolvePostLoginRedirect( + searchParams as unknown as Parameters<typeof resolvePostLoginRedirect>[0], + ), + ).toBe( + '/account/oauth/authorize?client_id=app&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback', + ) }) it('should recover a valid device redirect from sessionStorage once', () => { diff --git a/web/app/signin/utils/post-login-redirect.ts b/web/app/signin/utils/post-login-redirect.ts index 363cd6bdf6dc25..21477cbf5836c1 100644 --- a/web/app/signin/utils/post-login-redirect.ts +++ b/web/app/signin/utils/post-login-redirect.ts @@ -10,22 +10,17 @@ const ALLOWED: Record<string, ReadonlySet<string>> = { } function validate(target: string): string | null { - if (typeof window === 'undefined') - return null + if (typeof window === 'undefined') return null try { const url = new URL(target, window.location.origin) - if (url.origin !== window.location.origin) - return null + if (url.origin !== window.location.origin) return null const allowedKeys = ALLOWED[url.pathname] - if (!allowedKeys) - return null + if (!allowedKeys) return null for (const key of url.searchParams.keys()) { - if (!allowedKeys.has(key)) - return null + if (!allowedKeys.has(key)) return null } return url.pathname + (url.search || '') - } - catch { + } catch { return null } } @@ -35,46 +30,36 @@ function validate(target: string): string | null { // /device tabs don't clobber each other. 15-min TTL drops stale values. // Same-origin + exact-path whitelist prevents open-redirect. export const setPostLoginRedirect = (value: string | null) => { - if (typeof window === 'undefined') - return + if (typeof window === 'undefined') return if (value === null) { try { sessionStorage.removeItem(DEVICE_REDIRECT_KEY) - } - catch {} + } catch {} return } const safe = validate(value) - if (!safe) - return + if (!safe) return try { sessionStorage.setItem(DEVICE_REDIRECT_KEY, JSON.stringify({ target: safe, ts: Date.now() })) - } - catch {} + } catch {} } function getDeviceRedirect(): string | null { - if (typeof window === 'undefined') - return null + if (typeof window === 'undefined') return null let raw: string | null = null try { raw = sessionStorage.getItem(DEVICE_REDIRECT_KEY) sessionStorage.removeItem(DEVICE_REDIRECT_KEY) - } - catch { + } catch { return null } - if (!raw) - return null + if (!raw) return null try { const parsed = JSON.parse(raw) - if (typeof parsed?.target !== 'string' || typeof parsed?.ts !== 'number') - return null - if (Date.now() - parsed.ts > DEVICE_TTL_MS) - return null + if (typeof parsed?.target !== 'string' || typeof parsed?.ts !== 'number') return null + if (Date.now() - parsed.ts > DEVICE_TTL_MS) return null return validate(parsed.target) - } - catch { + } catch { return null } } @@ -85,14 +70,12 @@ export const resolvePostLoginRedirect = (searchParams?: ReadonlyURLSearchParams) if (redirectUrl) { try { return decodeURIComponent(redirectUrl) - } - catch { + } catch { return redirectUrl } } } const device = getDeviceRedirect() - if (device) - return device + if (device) return device return null } diff --git a/web/app/signup/check-code/page.tsx b/web/app/signup/check-code/page.tsx index 76f0994e2d9acc..0cf12c91899002 100644 --- a/web/app/signup/check-code/page.tsx +++ b/web/app/signup/check-code/page.tsx @@ -26,11 +26,11 @@ export default function CheckCode() { const verify = async () => { try { if (!code.trim()) { - toast.error(t($ => $['checkCode.emptyCode'], { ns: 'login' })) + toast.error(t(($) => $['checkCode.emptyCode'], { ns: 'login' })) return } if (!/\d{6}/.test(code)) { - toast.error(t($ => $['checkCode.invalidCode'], { ns: 'login' })) + toast.error(t(($) => $['checkCode.invalidCode'], { ns: 'login' })) return } setIsLoading(true) @@ -39,13 +39,12 @@ export default function CheckCode() { const params = new URLSearchParams(searchParams) params.set('token', encodeURIComponent((res as MailValidityResponse).token)) router.push(`/signup/set-password?${params.toString()}`) + } else { + toast.error(t(($) => $['checkCode.invalidCode'], { ns: 'login' })) } - else { - toast.error(t($ => $['checkCode.invalidCode'], { ns: 'login' })) - } - } - catch (error) { console.error(error) } - finally { + } catch (error) { + console.error(error) + } finally { setIsLoading(false) } } @@ -60,8 +59,9 @@ export default function CheckCode() { setToken(newToken) router.replace(`/signup/check-code?${params.toString()}`) } + } catch (error) { + console.error(error) } - catch (error) { console.error(error) } } return ( @@ -70,31 +70,54 @@ export default function CheckCode() { <RiMailSendFill className="size-6 text-2xl text-text-accent-light-mode-only" /> </div> <div className="pt-2 pb-4"> - <h2 className="title-4xl-semi-bold text-text-primary">{t($ => $['checkCode.checkYourEmail'], { ns: 'login' })}</h2> + <h2 className="title-4xl-semi-bold text-text-primary"> + {t(($) => $['checkCode.checkYourEmail'], { ns: 'login' })} + </h2> <p className="mt-2 body-md-regular text-text-secondary"> <span> - {t($ => $['checkCode.tipsPrefix'], { ns: 'login' })} + {t(($) => $['checkCode.tipsPrefix'], { ns: 'login' })} <strong>{email}</strong> </span> <br /> - {t($ => $['checkCode.validTime'], { ns: 'login' })} + {t(($) => $['checkCode.validTime'], { ns: 'login' })} </p> </div> <form action=""> - <label htmlFor="code" className="mb-1 system-md-semibold text-text-secondary">{t($ => $['checkCode.verificationCode'], { ns: 'login' })}</label> - <Input value={code} onChange={e => setVerifyCode(e.target.value)} maxLength={6} className="mt-1" placeholder={t($ => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) as string} /> - <Button loading={loading} disabled={loading} className="my-3 w-full" variant="primary" onClick={verify}>{t($ => $['checkCode.verify'], { ns: 'login' })}</Button> + <label htmlFor="code" className="mb-1 system-md-semibold text-text-secondary"> + {t(($) => $['checkCode.verificationCode'], { ns: 'login' })} + </label> + <Input + value={code} + onChange={(e) => setVerifyCode(e.target.value)} + maxLength={6} + className="mt-1" + placeholder={ + t(($) => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) as string + } + /> + <Button + loading={loading} + disabled={loading} + className="my-3 w-full" + variant="primary" + onClick={verify} + > + {t(($) => $['checkCode.verify'], { ns: 'login' })} + </Button> <Countdown onResend={resendCode} /> </form> <div className="py-2"> <div className="h-px bg-linear-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div> </div> - <div onClick={() => router.back()} className="flex h-9 cursor-pointer items-center justify-center text-text-tertiary"> + <div + onClick={() => router.back()} + className="flex h-9 cursor-pointer items-center justify-center text-text-tertiary" + > <div className="bg-background-default-dimm inline-block rounded-full p-1"> <RiArrowLeftLine size={12} /> </div> - <span className="ml-2 system-xs-regular">{t($ => $.back, { ns: 'login' })}</span> + <span className="ml-2 system-xs-regular">{t(($) => $.back, { ns: 'login' })}</span> </div> </div> ) diff --git a/web/app/signup/components/input-mail.spec.tsx b/web/app/signup/components/input-mail.spec.tsx index 9afea73df7e743..9cf02717c9c22d 100644 --- a/web/app/signup/components/input-mail.spec.tsx +++ b/web/app/signup/components/input-mail.spec.tsx @@ -10,7 +10,19 @@ const mockSubmitMail = vi.fn() const mockOnSuccess = vi.fn() vi.mock('@/next/link', () => ({ - default: ({ children, href, className, target, rel }: { children: React.ReactNode, href: string, className?: string, target?: string, rel?: string }) => ( + default: ({ + children, + href, + className, + target, + rel, + }: { + children: React.ReactNode + href: string + className?: string + target?: string + rel?: string + }) => ( <a href={href} className={className} target={target} rel={rel}> {children} </a> diff --git a/web/app/signup/components/input-mail.tsx b/web/app/signup/components/input-mail.tsx index 0190c8eb9b2fdf..79be544779e927 100644 --- a/web/app/signup/components/input-mail.tsx +++ b/web/app/signup/components/input-mail.tsx @@ -16,9 +16,7 @@ import { useSendMail } from '@/service/use-common' type Props = { onSuccess: (email: string, payload: string) => void } -export default function Form({ - onSuccess, -}: Props) { +export default function Form({ onSuccess }: Props) { const { t } = useTranslation() const [email, setEmail] = useState('') const locale = useLocale() @@ -27,15 +25,14 @@ export default function Form({ const { mutateAsync: submitMail, isPending } = useSendMail() const handleSubmit = useCallback(async () => { - if (isPending) - return + if (isPending) return if (!email) { - toast.error(t($ => $['error.emailEmpty'], { ns: 'login' })) + toast.error(t(($) => $['error.emailEmpty'], { ns: 'login' })) return } if (!emailRegex.test(email)) { - toast.error(t($ => $['error.emailInValid'], { ns: 'login' })) + toast.error(t(($) => $['error.emailInValid'], { ns: 'login' })) return } const res = await submitMail({ email, language: locale }) @@ -44,23 +41,24 @@ export default function Form({ }, [email, locale, submitMail, t, isPending, onSuccess]) return ( - <form onSubmit={(e) => { - e.preventDefault() - handleSubmit() - }} + <form + onSubmit={(e) => { + e.preventDefault() + handleSubmit() + }} > <div className="mb-3"> <label htmlFor="email" className="my-2 system-md-semibold text-text-secondary"> - {t($ => $.email, { ns: 'login' })} + {t(($) => $.email, { ns: 'login' })} </label> <div className="mt-1"> <Input value={email} - onChange={e => setEmail(e.target.value)} + onChange={(e) => setEmail(e.target.value)} id="email" type="email" autoComplete="email" - placeholder={t($ => $.emailPlaceholder, { ns: 'login' }) || ''} + placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) || ''} tabIndex={1} /> </div> @@ -73,25 +71,22 @@ export default function Form({ disabled={isPending || !email} className="w-full" > - {t($ => $['signup.verifyMail'], { ns: 'login' })} + {t(($) => $['signup.verifyMail'], { ns: 'login' })} </Button> </div> <Split className="mt-4 mb-5" /> <div className="text-[13px] leading-4 font-medium text-text-secondary"> - <span>{t($ => $['signup.haveAccount'], { ns: 'login' })}</span> - <Link - className="text-text-accent" - href="/signin" - > - {t($ => $['signup.signIn'], { ns: 'login' })} + <span>{t(($) => $['signup.haveAccount'], { ns: 'login' })}</span> + <Link className="text-text-accent" href="/signin"> + {t(($) => $['signup.signIn'], { ns: 'login' })} </Link> </div> {!systemFeatures.branding.enabled && ( <> <div className="mt-3 block w-full system-xs-regular text-text-tertiary"> - {t($ => $.tosDesc, { ns: 'login' })} + {t(($) => $.tosDesc, { ns: 'login' })}   <Link className="system-xs-medium text-text-secondary hover:underline" @@ -99,7 +94,7 @@ export default function Form({ rel="noopener noreferrer" href="https://dify.ai/terms" > - {t($ => $.tos, { ns: 'login' })} + {t(($) => $.tos, { ns: 'login' })} </Link>  &  <Link @@ -108,12 +103,11 @@ export default function Form({ rel="noopener noreferrer" href="https://dify.ai/privacy" > - {t($ => $.pp, { ns: 'login' })} + {t(($) => $.pp, { ns: 'login' })} </Link> </div> </> )} - </form> ) } diff --git a/web/app/signup/layout.tsx b/web/app/signup/layout.tsx index 68571dd570a88e..cc2ddbec1280b2 100644 --- a/web/app/signup/layout.tsx +++ b/web/app/signup/layout.tsx @@ -1,6 +1,5 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' - import { useSuspenseQuery } from '@tanstack/react-query' import Header from '@/app/signin/_header' import { systemFeaturesQueryOptions } from '@/features/system-features/client' @@ -12,20 +11,22 @@ export default function RegisterLayout({ children }: any) { return ( <> <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> - <div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}> + <div + className={cn( + 'flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle', + )} + > <Header /> - <div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}> - <div className="flex flex-col md:w-[400px]"> - {children} - </div> + <div + className={cn( + 'flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]', + )} + > + <div className="flex flex-col md:w-[400px]">{children}</div> </div> {systemFeatures.branding.enabled === false && ( <div className="px-8 py-6 system-xs-regular text-text-tertiary"> - © - {' '} - {new Date().getFullYear()} - {' '} - LangGenius, Inc. All rights reserved. + © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. </div> )} </div> diff --git a/web/app/signup/page.tsx b/web/app/signup/page.tsx index 5c3f8f5a9816fe..c30c6654005a12 100644 --- a/web/app/signup/page.tsx +++ b/web/app/signup/page.tsx @@ -9,18 +9,25 @@ const Signup = () => { const searchParams = useSearchParams() const { t } = useTranslation() - const handleInputMailSubmitted = useCallback((email: string, result: string) => { - const params = new URLSearchParams(searchParams) - params.set('token', encodeURIComponent(result)) - params.set('email', encodeURIComponent(email)) - router.push(`/signup/check-code?${params.toString()}`) - }, [router, searchParams]) + const handleInputMailSubmitted = useCallback( + (email: string, result: string) => { + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(result)) + params.set('email', encodeURIComponent(email)) + router.push(`/signup/check-code?${params.toString()}`) + }, + [router, searchParams], + ) return ( <div className="mx-auto mt-8 w-full"> <div className="mx-auto mb-10 w-full"> - <h2 className="title-4xl-semi-bold text-text-primary">{t($ => $['signup.createAccount'], { ns: 'login' })}</h2> - <p className="mt-2 body-md-regular text-text-tertiary">{t($ => $['signup.welcome'], { ns: 'login' })}</p> + <h2 className="title-4xl-semi-bold text-text-primary"> + {t(($) => $['signup.createAccount'], { ns: 'login' })} + </h2> + <p className="mt-2 body-md-regular text-text-tertiary"> + {t(($) => $['signup.welcome'], { ns: 'login' })} + </p> </div> <MailForm onSuccess={handleInputMailSubmitted} /> </div> diff --git a/web/app/signup/set-password/__tests__/page.spec.tsx b/web/app/signup/set-password/__tests__/page.spec.tsx index f7cb7fb3ba374f..c8f09482e833fd 100644 --- a/web/app/signup/set-password/__tests__/page.spec.tsx +++ b/web/app/signup/set-password/__tests__/page.spec.tsx @@ -10,7 +10,11 @@ import { useMailRegister } from '@/service/use-common' import { getBrowserTimezone } from '@/utils/timezone' import ChangePasswordForm from '../page' -const { mockRememberCreateAppExternalAttribution, mockRememberRegistrationSuccess, mockSendGAEvent } = vi.hoisted(() => ({ +const { + mockRememberCreateAppExternalAttribution, + mockRememberRegistrationSuccess, + mockSendGAEvent, +} = vi.hoisted(() => ({ mockRememberCreateAppExternalAttribution: vi.fn(), mockRememberRegistrationSuccess: vi.fn(), mockSendGAEvent: vi.fn(), @@ -42,7 +46,8 @@ vi.mock('@/app/components/base/amplitude/registration-tracking', () => ({ })) vi.mock('@/utils/create-app-tracking', () => ({ - rememberCreateAppExternalAttribution: (...args: unknown[]) => mockRememberCreateAppExternalAttribution(...args), + rememberCreateAppExternalAttribution: (...args: unknown[]) => + mockRememberCreateAppExternalAttribution(...args), })) const mockRegister = vi.fn() @@ -52,7 +57,9 @@ const mockUseLocale = useLocale as unknown as MockedFunction<typeof useLocale> const mockUseSearchParams = useSearchParams as unknown as MockedFunction<typeof useSearchParams> const mockUseRouter = useRouter as unknown as MockedFunction<typeof useRouter> const mockUseMailRegister = useMailRegister as unknown as MockedFunction<typeof useMailRegister> -const mockGetBrowserTimezone = getBrowserTimezone as unknown as MockedFunction<typeof getBrowserTimezone> +const mockGetBrowserTimezone = getBrowserTimezone as unknown as MockedFunction< + typeof getBrowserTimezone +> const renderWithQueryClient = (ui: ReactElement) => { const queryClient = new QueryClient({ @@ -61,11 +68,7 @@ const renderWithQueryClient = (ui: ReactElement) => { mutations: { retry: false }, }, }) - return render( - <QueryClientProvider client={queryClient}> - {ui} - </QueryClientProvider>, - ) + return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>) } describe('Signup Set Password Page', () => { @@ -73,8 +76,12 @@ describe('Signup Set Password Page', () => { vi.clearAllMocks() Cookies.remove('utm_info') mockUseLocale.mockReturnValue('zh-Hans') - mockUseSearchParams.mockReturnValue(new URLSearchParams('token=register-token') as unknown as ReturnType<typeof useSearchParams>) - mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>) + mockUseSearchParams.mockReturnValue( + new URLSearchParams('token=register-token') as unknown as ReturnType<typeof useSearchParams>, + ) + mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType< + typeof useRouter + >) mockUseMailRegister.mockReturnValue({ mutateAsync: mockRegister, isPending: false, diff --git a/web/app/signup/set-password/page.tsx b/web/app/signup/set-password/page.tsx index 609171487063b8..32918747a115d3 100644 --- a/web/app/signup/set-password/page.tsx +++ b/web/app/signup/set-password/page.tsx @@ -20,12 +20,10 @@ import { getBrowserTimezone } from '@/utils/timezone' const parseUtmInfo = () => { const utmInfoStr = Cookies.get('utm_info') - if (!utmInfoStr) - return null + if (!utmInfoStr) return null try { return JSON.parse(utmInfoStr) - } - catch (e) { + } catch (e) { console.error('Failed to parse utm_info cookie:', e) return null } @@ -49,23 +47,22 @@ const ChangePasswordForm = () => { const valid = useCallback(() => { if (!password.trim()) { - showErrorMessage(t($ => $['error.passwordEmpty'], { ns: 'login' })) + showErrorMessage(t(($) => $['error.passwordEmpty'], { ns: 'login' })) return false } if (!validPassword.test(password)) { - showErrorMessage(t($ => $['error.passwordInvalid'], { ns: 'login' })) + showErrorMessage(t(($) => $['error.passwordInvalid'], { ns: 'login' })) return false } if (password !== confirmPassword) { - showErrorMessage(t($ => $['account.notEqual'], { ns: 'common' })) + showErrorMessage(t(($) => $['account.notEqual'], { ns: 'common' })) return false } return true }, [password, confirmPassword, showErrorMessage, t]) const handleSubmit = useCallback(async () => { - if (!valid()) - return + if (!valid()) return try { const res = await register({ token, @@ -89,32 +86,30 @@ const ChangePasswordForm = () => { }) Cookies.remove('utm_info') // Clean up: remove utm_info cookie - toast.success(t($ => $['api.actionSuccess'], { ns: 'common' })) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) await queryClient.resetQueries({ queryKey: consoleQuery.account.profile.get.key() }) router.replace('/') } - } - catch (error) { + } catch (error) { console.error(error) } }, [password, token, valid, confirmPassword, register, locale, queryClient, router, t]) return ( - <div className={ - cn( + <div + className={cn( 'flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]', - ) - } + )} > <div className="flex flex-col md:w-[400px]"> <div className="mx-auto w-full"> <h2 className="title-4xl-semi-bold text-text-primary"> - {t($ => $.changePassword, { ns: 'login' })} + {t(($) => $.changePassword, { ns: 'login' })} </h2> <p className="mt-2 body-md-regular text-text-secondary"> - {t($ => $.changePasswordTip, { ns: 'login' })} + {t(($) => $.changePasswordTip, { ns: 'login' })} </p> </div> @@ -123,32 +118,36 @@ const ChangePasswordForm = () => { {/* Password */} <div className="mb-5"> <label htmlFor="password" className="my-2 system-md-semibold text-text-secondary"> - {t($ => $['account.newPassword'], { ns: 'common' })} + {t(($) => $['account.newPassword'], { ns: 'common' })} </label> <div className="relative mt-1"> <Input id="password" type="password" value={password} - onChange={e => setPassword(e.target.value)} - placeholder={t($ => $.passwordPlaceholder, { ns: 'login' }) || ''} + onChange={(e) => setPassword(e.target.value)} + placeholder={t(($) => $.passwordPlaceholder, { ns: 'login' }) || ''} /> - </div> - <div className="mt-1 body-xs-regular text-text-secondary">{t($ => $['error.passwordInvalid'], { ns: 'login' })}</div> + <div className="mt-1 body-xs-regular text-text-secondary"> + {t(($) => $['error.passwordInvalid'], { ns: 'login' })} + </div> </div> {/* Confirm Password */} <div className="mb-5"> - <label htmlFor="confirmPassword" className="my-2 system-md-semibold text-text-secondary"> - {t($ => $['account.confirmPassword'], { ns: 'common' })} + <label + htmlFor="confirmPassword" + className="my-2 system-md-semibold text-text-secondary" + > + {t(($) => $['account.confirmPassword'], { ns: 'common' })} </label> <div className="relative mt-1"> <Input id="confirmPassword" type="password" value={confirmPassword} - onChange={e => setConfirmPassword(e.target.value)} - placeholder={t($ => $.confirmPasswordPlaceholder, { ns: 'login' }) || ''} + onChange={(e) => setConfirmPassword(e.target.value)} + placeholder={t(($) => $.confirmPasswordPlaceholder, { ns: 'login' }) || ''} /> </div> </div> @@ -159,7 +158,7 @@ const ChangePasswordForm = () => { onClick={handleSubmit} disabled={isPending || !password || !confirmPassword} > - {t($ => $.changePasswordBtn, { ns: 'login' })} + {t(($) => $.changePasswordBtn, { ns: 'login' })} </Button> </div> </div> diff --git a/web/app/styles/glass-surface.css b/web/app/styles/glass-surface.css index 7ac33aa3799442..e5667f5a711e35 100644 --- a/web/app/styles/glass-surface.css +++ b/web/app/styles/glass-surface.css @@ -15,32 +15,43 @@ 0 8px 16px -4px var(--color-components-main-nav-glass-shadow-reflection); &::before { - content: ""; + content: ''; pointer-events: none; position: absolute; inset: 0; border-radius: inherit; border: 1px solid transparent; background: linear-gradient( - 0deg, - var(--color-components-main-nav-glass-edge-reflection-first, rgb(0 51 255 / 0)) 0%, - var(--color-components-main-nav-glass-edge-reflection-middle, rgb(0 51 255 / 0.6)) 50%, - var(--color-components-main-nav-glass-edge-reflection-end, rgb(0 51 255 / 0)) 100% - ) border-box; - -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); + 0deg, + var(--color-components-main-nav-glass-edge-reflection-first, rgb(0 51 255 / 0)) 0%, + var(--color-components-main-nav-glass-edge-reflection-middle, rgb(0 51 255 / 0.6)) 50%, + var(--color-components-main-nav-glass-edge-reflection-end, rgb(0 51 255 / 0)) 100% + ) + border-box; + -webkit-mask: + linear-gradient(#fff 0 0) padding-box, + linear-gradient(#fff 0 0); -webkit-mask-composite: destination-out; mask-composite: exclude; } &::after { - content: ""; + content: ''; pointer-events: none; position: absolute; inset: 0; border-radius: inherit; border: 1px solid transparent; - background: linear-gradient(180deg, var(--color-components-main-nav-glass-edge-highlight-first, rgb(255 255 255 / 0.98)) 0%, var(--color-components-main-nav-glass-edge-highlight-middle, rgb(255 255 255 / 0)) 18%, var(--color-components-main-nav-glass-edge-highlight-end, rgb(255 255 255 / 0.42)) 100%) border-box; - -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); + background: linear-gradient( + 180deg, + var(--color-components-main-nav-glass-edge-highlight-first, rgb(255 255 255 / 0.98)) 0%, + var(--color-components-main-nav-glass-edge-highlight-middle, rgb(255 255 255 / 0)) 18%, + var(--color-components-main-nav-glass-edge-highlight-end, rgb(255 255 255 / 0.42)) 100% + ) + border-box; + -webkit-mask: + linear-gradient(#fff 0 0) padding-box, + linear-gradient(#fff 0 0); -webkit-mask-composite: destination-out; mask-composite: exclude; box-shadow: inset 0 0 8px 0 var(--color-components-main-nav-glass-inner-glow); diff --git a/web/app/styles/markdown.css b/web/app/styles/markdown.css index c4ccf82297e0d2..8004a96f975e5b 100644 --- a/web/app/styles/markdown.css +++ b/web/app/styles/markdown.css @@ -28,7 +28,7 @@ .markdown-body h6:hover .anchor .octicon-link:before { width: 16px; height: 16px; - content: " "; + content: ' '; display: inline-block; background-color: currentColor; -webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>"); @@ -145,13 +145,13 @@ .markdown-body hr::before { display: table; - content: ""; + content: ''; } .markdown-body hr::after { display: table; clear: both; - content: ""; + content: ''; } .markdown-body input { @@ -163,23 +163,23 @@ line-height: inherit; } -.markdown-body [type="button"], -.markdown-body [type="reset"], -.markdown-body [type="submit"] { +.markdown-body [type='button'], +.markdown-body [type='reset'], +.markdown-body [type='submit'] { -webkit-appearance: button; } -.markdown-body [type="checkbox"], -.markdown-body [type="radio"] { +.markdown-body [type='checkbox'], +.markdown-body [type='radio'] { box-sizing: border-box; padding: 0; } -.markdown-body [type="number"]::-webkit-inner-spin-button, -.markdown-body [type="number"]::-webkit-outer-spin-button { +.markdown-body [type='number']::-webkit-inner-spin-button, +.markdown-body [type='number']::-webkit-outer-spin-button { height: auto; } -.markdown-body [type="search"]::-webkit-search-cancel-button, -.markdown-body [type="search"]::-webkit-search-decoration { +.markdown-body [type='search']::-webkit-search-cancel-button, +.markdown-body [type='search']::-webkit-search-decoration { -webkit-appearance: none; } @@ -202,7 +202,6 @@ opacity: 1; } - .markdown-body table { border-spacing: 0; border-collapse: separate; @@ -223,30 +222,30 @@ cursor: pointer; } -.markdown-body details:not([open])>*:not(summary) { +.markdown-body details:not([open]) > *:not(summary) { display: none !important; } .markdown-body a:focus, -.markdown-body [role="button"]:focus, -.markdown-body input[type="radio"]:focus, -.markdown-body input[type="checkbox"]:focus { +.markdown-body [role='button']:focus, +.markdown-body input[type='radio']:focus, +.markdown-body input[type='checkbox']:focus { outline: 2px solid var(--color-accent-fg); outline-offset: -2px; box-shadow: none; } .markdown-body a:focus:not(:focus-visible), -.markdown-body [role="button"]:focus:not(:focus-visible), -.markdown-body input[type="radio"]:focus:not(:focus-visible), -.markdown-body input[type="checkbox"]:focus:not(:focus-visible) { +.markdown-body [role='button']:focus:not(:focus-visible), +.markdown-body input[type='radio']:focus:not(:focus-visible), +.markdown-body input[type='checkbox']:focus:not(:focus-visible) { outline: solid 1px transparent; } .markdown-body a:focus-visible, -.markdown-body [role="button"]:focus-visible, -.markdown-body input[type="radio"]:focus-visible, -.markdown-body input[type="checkbox"]:focus-visible { +.markdown-body [role='button']:focus-visible, +.markdown-body input[type='radio']:focus-visible, +.markdown-body input[type='checkbox']:focus-visible { outline: 2px solid var(--color-accent-fg); outline-offset: -2px; box-shadow: none; @@ -254,18 +253,24 @@ .markdown-body a:not([class]):focus, .markdown-body a:not([class]):focus-visible, -.markdown-body input[type="radio"]:focus, -.markdown-body input[type="radio"]:focus-visible, -.markdown-body input[type="checkbox"]:focus, -.markdown-body input[type="checkbox"]:focus-visible { +.markdown-body input[type='radio']:focus, +.markdown-body input[type='radio']:focus-visible, +.markdown-body input[type='checkbox']:focus, +.markdown-body input[type='checkbox']:focus-visible { outline-offset: 0; } .markdown-body kbd { display: inline-block; padding: 2px 6px; - font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, - Liberation Mono, monospace; + font: + 11px ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + Consolas, + Liberation Mono, + monospace; line-height: 1; color: var(--color-text-primary); vertical-align: middle; @@ -302,8 +307,8 @@ list-style: disc; } -.markdown-body>ol, -.markdown-body>ul { +.markdown-body > ol, +.markdown-body > ul { padding: 0; } @@ -326,16 +331,28 @@ .markdown-body tt, .markdown-body code, .markdown-body samp { - font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, - Liberation Mono, monospace; + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + Consolas, + Liberation Mono, + monospace; font-size: 12px; } .markdown-body pre { margin-top: 0; margin-bottom: 0; - font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, - Liberation Mono, monospace; + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + Consolas, + Liberation Mono, + monospace; font-size: 12px; word-wrap: normal; } @@ -356,13 +373,13 @@ .markdown-body::before { display: table; - content: ""; + content: ''; } .markdown-body::after { display: table; clear: both; - content: ""; + content: ''; } .markdown-body a:not([href]) { @@ -389,15 +406,15 @@ .markdown-body ol { padding-left: 2em; } -.markdown-body ul[role="listbox"] { +.markdown-body ul[role='listbox'] { list-style: none !important; padding-left: 0 !important; } -.markdown-body blockquote> :first-child { +.markdown-body blockquote > :first-child { margin-top: 0; } -.markdown-body blockquote> :last-child { +.markdown-body blockquote > :last-child { margin-bottom: 0; } @@ -476,27 +493,27 @@ list-style-type: none; } -.markdown-body ol[type="a"] { +.markdown-body ol[type='a'] { list-style-type: lower-alpha; } -.markdown-body ol[type="A"] { +.markdown-body ol[type='A'] { list-style-type: upper-alpha; } -.markdown-body ol[type="i"] { +.markdown-body ol[type='i'] { list-style-type: lower-roman; } -.markdown-body ol[type="I"] { +.markdown-body ol[type='I'] { list-style-type: upper-roman; } -.markdown-body ol[type="1"] { +.markdown-body ol[type='1'] { list-style-type: decimal; } -.markdown-body div>ol:not([type]) { +.markdown-body div > ol:not([type]) { list-style-type: decimal; } @@ -544,8 +561,8 @@ padding: 6px 13px; } -.markdown-body table tr>th:not(:last-child), -.markdown-body table tr>td:not(:last-child) { +.markdown-body table tr > th:not(:last-child), +.markdown-body table tr > td:not(:last-child) { border-right: 1px solid var(--color-divider-subtle); } @@ -558,67 +575,67 @@ } /* streamdown table: bridge shadcn/ui tokens to Dify design system */ -[data-streamdown="table-wrapper"] { +[data-streamdown='table-wrapper'] { border-color: var(--color-divider-subtle); } -[data-streamdown="table-wrapper"] > div:has(> [data-streamdown="table"]) { +[data-streamdown='table-wrapper'] > div:has(> [data-streamdown='table']) { border: none; } -[data-streamdown="table-wrapper"] > div:first-child button { +[data-streamdown='table-wrapper'] > div:first-child button { color: var(--color-text-tertiary); } -[data-streamdown="table-wrapper"] > div:first-child button:hover { +[data-streamdown='table-wrapper'] > div:first-child button:hover { color: var(--color-text-primary); } -[data-streamdown="table-wrapper"] > div:first-child > div > div { +[data-streamdown='table-wrapper'] > div:first-child > div > div { background-color: var(--color-components-panel-bg); border-color: var(--color-divider-subtle); } -[data-streamdown="table-wrapper"] > div:first-child > div > div button:hover { +[data-streamdown='table-wrapper'] > div:first-child > div > div button:hover { color: var(--color-components-menu-item-text-hover); background-color: var(--color-components-menu-item-bg-hover); } -[data-streamdown="table-fullscreen"] { +[data-streamdown='table-fullscreen'] { background-color: var(--color-components-panel-bg); color: var(--color-text-primary); } -[data-streamdown="table-fullscreen"] button { +[data-streamdown='table-fullscreen'] button { color: var(--color-text-tertiary); } -[data-streamdown="table-fullscreen"] button:hover { +[data-streamdown='table-fullscreen'] button:hover { color: var(--color-text-primary); background-color: var(--color-background-section-burn); } -[data-streamdown="table-fullscreen"] table[data-streamdown="table"] { +[data-streamdown='table-fullscreen'] table[data-streamdown='table'] { border-color: var(--color-divider-regular); } -[data-streamdown="table-fullscreen"] [data-streamdown="table-header"] { +[data-streamdown='table-fullscreen'] [data-streamdown='table-header'] { background-color: var(--color-background-section-burn); } -[data-streamdown="table-fullscreen"] [data-streamdown="table-header"] th { +[data-streamdown='table-fullscreen'] [data-streamdown='table-header'] th { color: var(--color-text-tertiary); } -[data-streamdown="table-fullscreen"] [data-streamdown="table-body"] { +[data-streamdown='table-fullscreen'] [data-streamdown='table-body'] { border-color: var(--color-divider-subtle); } -[data-streamdown="table-fullscreen"] [data-streamdown="table-body"] > tr { +[data-streamdown='table-fullscreen'] [data-streamdown='table-body'] > tr { border-color: var(--color-divider-subtle); } -[data-streamdown="table-fullscreen"] [data-streamdown="table-body"] td { +[data-streamdown='table-fullscreen'] [data-streamdown='table-body'] td { color: var(--color-text-secondary); } @@ -626,11 +643,11 @@ background-color: transparent; } -.markdown-body img[align="right"] { +.markdown-body img[align='right'] { padding-left: 20px; } -.markdown-body img[align="left"] { +.markdown-body img[align='left'] { padding-right: 20px; } @@ -645,7 +662,7 @@ overflow: hidden; } -.markdown-body span.frame>span { +.markdown-body span.frame > span { display: block; float: left; width: auto; @@ -673,7 +690,7 @@ clear: both; } -.markdown-body span.align-center>span { +.markdown-body span.align-center > span { display: block; margin: 13px auto 0; overflow: hidden; @@ -691,7 +708,7 @@ clear: both; } -.markdown-body span.align-right>span { +.markdown-body span.align-right > span { display: block; margin: 13px 0 0; overflow: hidden; @@ -721,7 +738,7 @@ overflow: hidden; } -.markdown-body span.float-right>span { +.markdown-body span.float-right > span { display: block; margin: 13px auto 0; overflow: hidden; @@ -756,7 +773,7 @@ white-space: pre-wrap !important; } -.markdown-body pre>code { +.markdown-body pre > code { padding: 0; margin: 0; word-break: normal; @@ -828,11 +845,11 @@ } .markdown-body [data-footnote-ref]::before { - content: "["; + content: '['; } .markdown-body [data-footnote-ref]::after { - content: "]"; + content: ']'; } .markdown-body .footnotes { @@ -862,7 +879,7 @@ bottom: -8px; left: -24px; pointer-events: none; - content: ""; + content: ''; border: 2px solid var(--color-accent-emphasis); border-radius: 6px; } @@ -883,7 +900,7 @@ /* Allow long inline formulas to wrap instead of overflowing */ white-space: normal !important; overflow-wrap: break-word; /* better cross-browser support */ - word-break: break-word; /* non-standard fallback for older WebKit/Blink */ + word-break: break-word; /* non-standard fallback for older WebKit/Blink */ } .markdown-body .katex-display { @@ -1010,7 +1027,7 @@ .markdown-body g-emoji { display: inline-block; min-width: 1ch; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 1em; font-style: normal !important; font-weight: var(--base-text-weight-normal, 400); @@ -1035,7 +1052,7 @@ cursor: pointer; } -.markdown-body .task-list-item+.task-list-item { +.markdown-body .task-list-item + .task-list-item { margin-top: 4px; } diff --git a/web/app/styles/monaco-sticky-fix.css b/web/app/styles/monaco-sticky-fix.css index 982c62eacdc116..c83cccfa8baacc 100644 --- a/web/app/styles/monaco-sticky-fix.css +++ b/web/app/styles/monaco-sticky-fix.css @@ -1,13 +1,13 @@ -html[data-theme="dark"] .monaco-editor .sticky-widget { +html[data-theme='dark'] .monaco-editor .sticky-widget { background-color: var(--color-monaco-sticky-header-bg) !important; border-bottom: 1px solid var(--color-monaco-sticky-header-border) !important; box-shadow: var(--vscode-editorStickyScroll-shadow) 0 4px 2px -2px !important; } -html[data-theme="dark"] .monaco-editor .sticky-line-content:hover { +html[data-theme='dark'] .monaco-editor .sticky-line-content:hover { background-color: var(--color-monaco-sticky-header-bg-hover) !important; } -html[data-theme="dark"] .monaco-editor .sticky-line-root { +html[data-theme='dark'] .monaco-editor .sticky-line-root { background-color: var(--color-monaco-sticky-header-bg) !important; } diff --git a/web/app/styles/plugins/typography-config.js b/web/app/styles/plugins/typography-config.js index e8ea65026bafd2..2b8fe52c2a5445 100644 --- a/web/app/styles/plugins/typography-config.js +++ b/web/app/styles/plugins/typography-config.js @@ -38,15 +38,15 @@ export default ({ theme }) => ({ '--tw-prose-invert-td-borders': theme('colors.zinc.700'), // Base - 'color': 'var(--tw-prose-body)', - 'fontSize': theme('fontSize.sm')[0], - 'lineHeight': theme('lineHeight.7'), + color: 'var(--tw-prose-body)', + fontSize: theme('fontSize.sm')[0], + lineHeight: theme('lineHeight.7'), // Layout '> *': { - 'maxWidth': theme('maxWidth.2xl'), - 'marginLeft': 'auto', - 'marginRight': 'auto', + maxWidth: theme('maxWidth.2xl'), + marginLeft: 'auto', + marginRight: 'auto', '@media (min-width: 1024px)': { maxWidth: theme('maxWidth.3xl'), marginLeft: `calc(50% - min(50%, ${theme('maxWidth.lg')}))`, @@ -55,7 +55,7 @@ export default ({ theme }) => ({ }, // Text - 'p': { + p: { marginTop: theme('spacing.6'), marginBottom: theme('spacing.6'), }, @@ -65,7 +65,7 @@ export default ({ theme }) => ({ }, // Lists - 'ol': { + ol: { listStyleType: 'decimal', marginTop: theme('spacing.5'), marginBottom: theme('spacing.5'), @@ -98,13 +98,13 @@ export default ({ theme }) => ({ 'ol[type="1"]': { listStyleType: 'decimal', }, - 'ul': { + ul: { listStyleType: 'disc', marginTop: theme('spacing.5'), marginBottom: theme('spacing.5'), paddingLeft: '1.625rem', }, - 'li': { + li: { marginTop: theme('spacing.2'), marginBottom: theme('spacing.2'), }, @@ -140,14 +140,14 @@ export default ({ theme }) => ({ }, // Horizontal rules - 'hr': { - 'borderColor': 'var(--tw-prose-hr)', - 'borderTopWidth': 1, - 'marginTop': theme('spacing.16'), - 'marginBottom': theme('spacing.16'), - 'maxWidth': 'none', - 'marginLeft': `calc(-1 * ${theme('spacing.4')})`, - 'marginRight': `calc(-1 * ${theme('spacing.4')})`, + hr: { + borderColor: 'var(--tw-prose-hr)', + borderTopWidth: 1, + marginTop: theme('spacing.16'), + marginBottom: theme('spacing.16'), + maxWidth: 'none', + marginLeft: `calc(-1 * ${theme('spacing.4')})`, + marginRight: `calc(-1 * ${theme('spacing.4')})`, '@media (min-width: 640px)': { marginLeft: `calc(-1 * ${theme('spacing.6')})`, marginRight: `calc(-1 * ${theme('spacing.6')})`, @@ -159,7 +159,7 @@ export default ({ theme }) => ({ }, // Quotes - 'blockquote': { + blockquote: { fontWeight: '500', fontStyle: 'italic', color: 'var(--tw-prose-quotes)', @@ -178,14 +178,14 @@ export default ({ theme }) => ({ }, // Headings - 'h1': { + h1: { color: 'var(--tw-prose-headings)', fontWeight: '700', fontSize: theme('fontSize.2xl')[0], ...theme('fontSize.2xl')[1], marginBottom: theme('spacing.2'), }, - 'h2': { + h2: { color: 'var(--tw-prose-headings)', fontWeight: '600', fontSize: theme('fontSize.lg')[0], @@ -193,7 +193,7 @@ export default ({ theme }) => ({ marginTop: theme('spacing.16'), marginBottom: theme('spacing.2'), }, - 'h3': { + h3: { color: 'var(--tw-prose-headings)', fontSize: theme('fontSize.base')[0], ...theme('fontSize.base')[1], @@ -211,7 +211,7 @@ export default ({ theme }) => ({ marginTop: '0', marginBottom: '0', }, - 'figcaption': { + figcaption: { color: 'var(--tw-prose-captions)', fontSize: theme('fontSize.xs')[0], ...theme('fontSize.xs')[1], @@ -219,7 +219,7 @@ export default ({ theme }) => ({ }, // Tables - 'table': { + table: { width: '100%', tableLayout: 'auto', textAlign: 'left', @@ -227,7 +227,7 @@ export default ({ theme }) => ({ marginBottom: theme('spacing.8'), lineHeight: theme('lineHeight.6'), }, - 'thead': { + thead: { borderBottomWidth: '1px', borderBottomColor: 'var(--tw-prose-th-borders)', }, @@ -255,7 +255,7 @@ export default ({ theme }) => ({ 'tbody td': { verticalAlign: 'baseline', }, - 'tfoot': { + tfoot: { borderTopWidth: '1px', borderTopColor: 'var(--tw-prose-th-borders)', }, @@ -276,13 +276,13 @@ export default ({ theme }) => ({ }, // Inline elements - 'a': { - 'color': 'var(--tw-prose-links)', - 'textDecoration': 'underline transparent', - 'fontWeight': '500', - 'transitionProperty': 'color, text-decoration-color', - 'transitionDuration': theme('transitionDuration.DEFAULT'), - 'transitionTimingFunction': theme('transitionTimingFunction.DEFAULT'), + a: { + color: 'var(--tw-prose-links)', + textDecoration: 'underline transparent', + fontWeight: '500', + transitionProperty: 'color, text-decoration-color', + transitionDuration: theme('transitionDuration.DEFAULT'), + transitionTimingFunction: theme('transitionTimingFunction.DEFAULT'), '&:hover': { color: 'var(--tw-prose-links-hover)', textDecorationColor: 'var(--tw-prose-links-underline)', @@ -291,14 +291,14 @@ export default ({ theme }) => ({ ':is(h1, h2, h3) a': { fontWeight: 'inherit', }, - 'strong': { + strong: { color: 'var(--tw-prose-bold)', fontWeight: '600', }, ':is(a, blockquote, thead th) strong': { color: 'inherit', }, - 'code': { + code: { color: 'var(--tw-prose-code)', borderRadius: theme('borderRadius.lg'), paddingTop: theme('padding.1'), diff --git a/web/app/styles/preflight.css b/web/app/styles/preflight.css index e47baeab84ce0f..4391401239c8b2 100644 --- a/web/app/styles/preflight.css +++ b/web/app/styles/preflight.css @@ -33,7 +33,16 @@ html, -webkit-text-size-adjust: 100%; /* 2 */ -moz-tab-size: 4; /* 3 */ tab-size: 4; /* 3 */ - font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); /* 4 */ + font-family: var( + --font-sans, + ui-sans-serif, + system-ui, + sans-serif, + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Segoe UI Symbol', + 'Noto Color Emoji' + ); /* 4 */ -webkit-tap-highlight-color: transparent; /* 7 */ } @@ -111,7 +120,17 @@ code, kbd, samp, pre { - font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); /* 1 */ + font-family: var( + --font-mono, + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + 'Liberation Mono', + 'Courier New', + monospace + ); /* 1 */ font-size: 1em; /* 4 */ } @@ -335,7 +354,7 @@ Set the default cursor for buttons. */ button, -[role="button"] { +[role='button'] { cursor: pointer; } diff --git a/web/app/styles/tailwind-core.css b/web/app/styles/tailwind-core.css index 57ae587bfe3494..e1c6545678e92c 100644 --- a/web/app/styles/tailwind-core.css +++ b/web/app/styles/tailwind-core.css @@ -51,7 +51,9 @@ * indirection survives without re-declaring values at :root. */ @theme inline { /* Flat color (used as `bg-background-gradient-bg-fill-chat-bubble-bg-3`). */ - --color-background-gradient-bg-fill-chat-bubble-bg-3: var(--color-background-gradient-bg-fill-chat-bubble-bg-3); + --color-background-gradient-bg-fill-chat-bubble-bg-3: var( + --color-background-gradient-bg-fill-chat-bubble-bg-3 + ); /* Gradients — registered under v4's --background-image-* namespace. */ --background-image-chatbot-bg: var(--color-chatbot-bg); @@ -61,7 +63,9 @@ --background-image-workflow-process-paused-bg: var(--color-workflow-process-paused-bg); --background-image-workflow-run-failed-bg: var(--color-workflow-run-failed-bg); --background-image-workflow-batch-failed-bg: var(--color-workflow-batch-failed-bg); - --background-image-mask-top2bottom-gray-50-to-transparent: var(--mask-top2bottom-gray-50-to-transparent); + --background-image-mask-top2bottom-gray-50-to-transparent: var( + --mask-top2bottom-gray-50-to-transparent + ); --background-image-marketplace-divider-bg: var(--color-marketplace-divider-bg); --background-image-marketplace-plugin-empty: var(--color-marketplace-plugin-empty); --background-image-toast-success-bg: var(--color-toast-success-bg); @@ -70,18 +74,32 @@ --background-image-toast-info-bg: var(--color-toast-info-bg); --background-image-app-detail-bg: var(--color-app-detail-bg); --background-image-app-detail-overlay-bg: var(--color-app-detail-overlay-bg); - --background-image-dataset-chunk-process-success-bg: var(--color-dataset-chunk-process-success-bg); + --background-image-dataset-chunk-process-success-bg: var( + --color-dataset-chunk-process-success-bg + ); --background-image-dataset-chunk-process-error-bg: var(--color-dataset-chunk-process-error-bg); - --background-image-dataset-chunk-detail-card-hover-bg: var(--color-dataset-chunk-detail-card-hover-bg); - --background-image-dataset-child-chunk-expand-btn-bg: var(--color-dataset-child-chunk-expand-btn-bg); - --background-image-dataset-option-card-blue-gradient: var(--color-dataset-option-card-blue-gradient); - --background-image-dataset-option-card-purple-gradient: var(--color-dataset-option-card-purple-gradient); - --background-image-dataset-option-card-orange-gradient: var(--color-dataset-option-card-orange-gradient); + --background-image-dataset-chunk-detail-card-hover-bg: var( + --color-dataset-chunk-detail-card-hover-bg + ); + --background-image-dataset-child-chunk-expand-btn-bg: var( + --color-dataset-child-chunk-expand-btn-bg + ); + --background-image-dataset-option-card-blue-gradient: var( + --color-dataset-option-card-blue-gradient + ); + --background-image-dataset-option-card-purple-gradient: var( + --color-dataset-option-card-purple-gradient + ); + --background-image-dataset-option-card-orange-gradient: var( + --color-dataset-option-card-orange-gradient + ); --background-image-dataset-chunk-list-mask-bg: var(--color-dataset-chunk-list-mask-bg); --background-image-line-divider-bg: var(--color-line-divider-bg); --background-image-dataset-warning-message-bg: var(--color-dataset-warning-message-bg); --background-image-price-premium-badge-background: var(--color-premium-badge-background); - --background-image-premium-yearly-tip-text-background: var(--color-premium-yearly-tip-text-background); + --background-image-premium-yearly-tip-text-background: var( + --color-premium-yearly-tip-text-background + ); --background-image-price-premium-text-background: var(--color-premium-text-background); --background-image-price-enterprise-background: var(--color-price-enterprise-background); --background-image-grid-mask-background: var(--color-grid-mask-background); @@ -93,9 +111,15 @@ --background-image-billing-plan-title-bg: var(--color-billing-plan-title-bg); --background-image-billing-plan-card-premium-bg: var(--color-billing-plan-card-premium-bg); --background-image-billing-plan-card-enterprise-bg: var(--color-billing-plan-card-enterprise-bg); - --background-image-knowledge-pipeline-creation-footer-bg: var(--color-knowledge-pipeline-creation-footer-bg); - --background-image-progress-bar-indeterminate-stripe: var(--color-progress-bar-indeterminate-stripe); - --background-image-chat-answer-human-input-form-divider-bg: var(--color-chat-answer-human-input-form-divider-bg); + --background-image-knowledge-pipeline-creation-footer-bg: var( + --color-knowledge-pipeline-creation-footer-bg + ); + --background-image-progress-bar-indeterminate-stripe: var( + --color-progress-bar-indeterminate-stripe + ); + --background-image-chat-answer-human-input-form-divider-bg: var( + --color-chat-answer-human-input-form-divider-bg + ); } /* ---------- App-level component CSS ----------------------------------- */ diff --git a/web/bin/uglify-embed.js b/web/bin/uglify-embed.js index 4477c6d1a6cee8..a593f6f34dfe34 100644 --- a/web/bin/uglify-embed.js +++ b/web/bin/uglify-embed.js @@ -2,6 +2,10 @@ import { readFileSync, writeFileSync } from 'node:fs' // https://www.npmjs.com/package/uglify-js import UglifyJS from 'uglify-js' -writeFileSync('public/embed.min.js', UglifyJS.minify({ - 'embed.js': readFileSync('public/embed.js', 'utf8'), -}).code, 'utf8') +writeFileSync( + 'public/embed.min.js', + UglifyJS.minify({ + 'embed.js': readFileSync('public/embed.js', 'utf8'), + }).code, + 'utf8', +) diff --git a/web/config/index.ts b/web/config/index.ts index 6eeb57eedc735b..3065a55df6f867 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -6,12 +6,8 @@ import { PipelineInputVarType } from '@/models/pipeline' import { AgentStrategy } from '@/types/app' import pkg from '../package.json' -const getStringConfig = ( - envVar: string | undefined, - defaultValue: string, -) => { - if (envVar) - return envVar +const getStringConfig = (envVar: string | undefined, defaultValue: string) => { + if (envVar) return envVar return defaultValue } @@ -27,20 +23,14 @@ export const MARKETPLACE_API_PREFIX = getStringConfig( env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX, 'http://localhost:5002/api', ) -export const MARKETPLACE_URL_PREFIX = getStringConfig( - env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX, - '', -) +export const MARKETPLACE_URL_PREFIX = getStringConfig(env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX, '') const EDITION = env.NEXT_PUBLIC_EDITION export const IS_CE_EDITION = EDITION === 'SELF_HOSTED' export const IS_CLOUD_EDITION = EDITION === 'CLOUD' -export const AMPLITUDE_API_KEY = getStringConfig( - env.NEXT_PUBLIC_AMPLITUDE_API_KEY, - '', -) +export const AMPLITUDE_API_KEY = getStringConfig(env.NEXT_PUBLIC_AMPLITUDE_API_KEY, '') export const isAmplitudeEnabled = IS_CLOUD_EDITION && !!AMPLITUDE_API_KEY @@ -104,20 +94,13 @@ export const DEFAULT_COMPLETION_PROMPT_CONFIG = { } export const LOCALE_COOKIE_NAME = 'locale' -const COOKIE_DOMAIN = getStringConfig( - env.NEXT_PUBLIC_COOKIE_DOMAIN, - '', -).trim() -export const SOCKET_URL = getStringConfig( - env.NEXT_PUBLIC_SOCKET_URL, - 'ws://localhost:5001', -).trim() +const COOKIE_DOMAIN = getStringConfig(env.NEXT_PUBLIC_COOKIE_DOMAIN, '').trim() +export const SOCKET_URL = getStringConfig(env.NEXT_PUBLIC_SOCKET_URL, 'ws://localhost:5001').trim() export const BATCH_CONCURRENCY = env.NEXT_PUBLIC_BATCH_CONCURRENCY export const CSRF_COOKIE_NAME = () => { - if (COOKIE_DOMAIN) - return 'csrf_token' + if (COOKIE_DOMAIN) return 'csrf_token' const isSecure = API_PREFIX.startsWith('https://') return isSecure ? '__Host-csrf_token' : 'csrf_token' } @@ -132,8 +115,7 @@ export const emailRegex = /^[\w.!#$%&'*+\-/=?^{|}~]+@([\w-]+\.)+[\w-]{2,}$/m const MAX_ZN_VAR_NAME_LENGTH = 8 const MAX_EN_VAR_VALUE_LENGTH = 30 export const getMaxVarNameLength = (value: string) => { - if (zhRegex.test(value)) - return MAX_ZN_VAR_NAME_LENGTH + if (zhRegex.test(value)) return MAX_ZN_VAR_NAME_LENGTH return MAX_EN_VAR_VALUE_LENGTH } @@ -303,37 +285,16 @@ export const VALUE_SELECTOR_DELIMITER = '@@@' export const validPassword = /^(?=.*[a-z])(?=.*\d)\S{8,}$/i -export const ZENDESK_WIDGET_KEY = getStringConfig( - env.NEXT_PUBLIC_ZENDESK_WIDGET_KEY, - '', -) +export const ZENDESK_WIDGET_KEY = getStringConfig(env.NEXT_PUBLIC_ZENDESK_WIDGET_KEY, '') export const ZENDESK_FIELD_IDS = { - ENVIRONMENT: getStringConfig( - env.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT, - '', - ), - VERSION: getStringConfig( - env.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION, - '', - ), - EMAIL: getStringConfig( - env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL, - '', - ), - WORKSPACE_ID: getStringConfig( - env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID, - '', - ), - PLAN: getStringConfig( - env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN, - '', - ), + ENVIRONMENT: getStringConfig(env.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT, ''), + VERSION: getStringConfig(env.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION, ''), + EMAIL: getStringConfig(env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL, ''), + WORKSPACE_ID: getStringConfig(env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID, ''), + PLAN: getStringConfig(env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN, ''), } -export const SUPPORT_EMAIL_ADDRESS = getStringConfig( - env.NEXT_PUBLIC_SUPPORT_EMAIL_ADDRESS, - '', -) +export const SUPPORT_EMAIL_ADDRESS = getStringConfig(env.NEXT_PUBLIC_SUPPORT_EMAIL_ADDRESS, '') export const APP_VERSION = pkg.version @@ -341,12 +302,16 @@ export const IS_MARKETPLACE = env.NEXT_PUBLIC_IS_MARKETPLACE export const RAG_PIPELINE_PREVIEW_CHUNK_NUM = 20 -export const PROVIDER_WITH_PRESET_TONE = ['langgenius/openai/openai', 'langgenius/azure_openai/azure_openai'] +export const PROVIDER_WITH_PRESET_TONE = [ + 'langgenius/openai/openai', + 'langgenius/azure_openai/azure_openai', +] export const STOP_PARAMETER_RULE: ModelParameterRule = { default: [], help: { - en_US: 'Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.', + en_US: + 'Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.', zh_Hans: '最多四个序列,API 将停止生成更多的 token。返回的文本将不包含停止序列。', }, label: { diff --git a/web/config/server.ts b/web/config/server.ts index f1ecb4654bd39f..2699a1234349dc 100644 --- a/web/config/server.ts +++ b/web/config/server.ts @@ -1,8 +1,7 @@ import { env } from '@/env' - import 'server-only' -const withoutTrailingSlash = (value: string) => value.endsWith('/') ? value.slice(0, -1) : value +const withoutTrailingSlash = (value: string) => (value.endsWith('/') ? value.slice(0, -1) : value) // Server-side requests need the origin; browser requests should keep using NEXT_PUBLIC_API_PREFIX. const serverConsoleApiUrl = env.SERVER_CONSOLE_API_URL || env.CONSOLE_API_URL diff --git a/web/context/__tests__/console-bootstrap.spec.tsx b/web/context/__tests__/console-bootstrap.spec.tsx index a54c21667a2898..4fe4eac66ea750 100644 --- a/web/context/__tests__/console-bootstrap.spec.tsx +++ b/web/context/__tests__/console-bootstrap.spec.tsx @@ -12,7 +12,10 @@ import { setZendeskConversationFields } from '@/app/components/base/zendesk/util import { ZENDESK_FIELD_IDS } from '@/config' import { refreshUserProfileAtom, userProfileAtom } from '../account-state' import { initialWorkspaceInfo } from '../app-context-defaults' -import { workspacePermissionKeysAtom, workspacePermissionKeysLoadingAtom } from '../permission-state' +import { + workspacePermissionKeysAtom, + workspacePermissionKeysLoadingAtom, +} from '../permission-state' import { langGeniusVersionInfoAtom } from '../version-state' import { currentWorkspaceAtom, @@ -91,16 +94,18 @@ const mockLangGeniusVersionState = vi.hoisted(() => ({ model_load_balancing_enabled: false, }, can_auto_update: false, - } as { - version: string - release_date: string - release_notes: string - features: { - can_replace_logo: boolean - model_load_balancing_enabled: boolean - } - can_auto_update: boolean - } | undefined, + } as + | { + version: string + release_date: string + release_notes: string + features: { + can_replace_logo: boolean + model_load_balancing_enabled: boolean + } + can_auto_update: boolean + } + | undefined, })) vi.mock('@/config', async (importOriginal) => { @@ -141,8 +146,7 @@ vi.mock('@/service/client', () => ({ }) => ({ queryKey: ['current-workspace'], queryFn: async () => { - if (mockCurrentWorkspaceQueryState.isPending) - return new Promise(() => {}) + if (mockCurrentWorkspaceQueryState.isPending) return new Promise(() => {}) return mockCurrentWorkspaceQueryState.data }, @@ -250,14 +254,15 @@ function ConsoleBootstrapProbe() { </span> <span> version: - {langGeniusVersionInfo.current_version} - / - {langGeniusVersionInfo.latest_version} - / + {langGeniusVersionInfo.current_version}/{langGeniusVersionInfo.latest_version}/ {langGeniusVersionInfo.current_env} </span> - <button type="button" onClick={refreshUserProfile}>refresh user</button> - <button type="button" onClick={refreshCurrentWorkspace}>refresh workspace</button> + <button type="button" onClick={refreshUserProfile}> + refresh user + </button> + <button type="button" onClick={refreshCurrentWorkspace}> + refresh workspace + </button> </> ) } @@ -347,8 +352,7 @@ describe('Console bootstrap', () => { } mockGetRequest.mockImplementation((url: string) => { if (url === '/workspaces/current/rbac/my-permissions') { - if (mockPermissionKeysState.isPending) - return new Promise(() => {}) + if (mockPermissionKeysState.isPending) return new Promise(() => {}) return Promise.resolve({ workspace: { @@ -365,8 +369,7 @@ describe('Console bootstrap', () => { }) } - if (url === '/version') - return Promise.resolve(mockLangGeniusVersionState.data) + if (url === '/version') return Promise.resolve(mockLangGeniusVersionState.data) return Promise.reject(new Error(`Unexpected GET ${url}`)) }) @@ -452,32 +455,42 @@ describe('Console bootstrap', () => { renderConsoleBootstrap() await waitFor(() => { - expect(setZendeskConversationFields).toHaveBeenCalledWith([{ - id: ZENDESK_FIELD_IDS.ENVIRONMENT, - value: 'cloud', - }]) + expect(setZendeskConversationFields).toHaveBeenCalledWith([ + { + id: ZENDESK_FIELD_IDS.ENVIRONMENT, + value: 'cloud', + }, + ]) }) - expect(setZendeskConversationFields).toHaveBeenCalledWith([{ - id: ZENDESK_FIELD_IDS.VERSION, - value: '1.0.1', - }]) - expect(setZendeskConversationFields).toHaveBeenCalledWith([{ - id: ZENDESK_FIELD_IDS.EMAIL, - value: 'user@example.com', - }]) + expect(setZendeskConversationFields).toHaveBeenCalledWith([ + { + id: ZENDESK_FIELD_IDS.VERSION, + value: '1.0.1', + }, + ]) + expect(setZendeskConversationFields).toHaveBeenCalledWith([ + { + id: ZENDESK_FIELD_IDS.EMAIL, + value: 'user@example.com', + }, + ]) await waitFor(() => { - expect(setZendeskConversationFields).toHaveBeenCalledWith([{ - id: ZENDESK_FIELD_IDS.WORKSPACE_ID, - value: 'workspace-1', - }]) + expect(setZendeskConversationFields).toHaveBeenCalledWith([ + { + id: ZENDESK_FIELD_IDS.WORKSPACE_ID, + value: 'workspace-1', + }, + ]) }) await waitFor(() => { expect(setUserId).toHaveBeenCalledWith('user@example.com') - expect(setUserProperties).toHaveBeenCalledWith(expect.objectContaining({ - email: 'user@example.com', - workspace_id: 'workspace-1', - workspace_role: 'editor', - })) + expect(setUserProperties).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'user@example.com', + workspace_id: 'workspace-1', + workspace_role: 'editor', + }), + ) expect(flushRegistrationSuccess).toHaveBeenCalled() }) }) diff --git a/web/context/access-control-store.ts b/web/context/access-control-store.ts index 1cb8eb98489506..49769cd982ebc2 100644 --- a/web/context/access-control-store.ts +++ b/web/context/access-control-store.ts @@ -19,15 +19,16 @@ type AccessControlStore = { const useAccessControlStore = create<AccessControlStore>((set) => { return { appId: '', - setAppId: appId => set({ appId }), + setAppId: (appId) => set({ appId }), specificGroups: [], - setSpecificGroups: specificGroups => set({ specificGroups }), + setSpecificGroups: (specificGroups) => set({ specificGroups }), specificMembers: [], - setSpecificMembers: specificMembers => set({ specificMembers }), + setSpecificMembers: (specificMembers) => set({ specificMembers }), currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - setCurrentMenu: currentMenu => set({ currentMenu }), + setCurrentMenu: (currentMenu) => set({ currentMenu }), selectedGroupsForBreadcrumb: [], - setSelectedGroupsForBreadcrumb: selectedGroupsForBreadcrumb => set({ selectedGroupsForBreadcrumb }), + setSelectedGroupsForBreadcrumb: (selectedGroupsForBreadcrumb) => + set({ selectedGroupsForBreadcrumb }), } }) diff --git a/web/context/amplitude-identity-sync.ts b/web/context/amplitude-identity-sync.ts index 0fab0b3fb098ee..c8684c5fd8d6e1 100644 --- a/web/context/amplitude-identity-sync.ts +++ b/web/context/amplitude-identity-sync.ts @@ -41,8 +41,7 @@ export const amplitudeIdentitySyncAtom = atomEffect((get, set) => { const userProfile = get(userProfileAtom) const currentWorkspace = get(currentWorkspaceAtom) - if (!userProfile.id) - return + if (!userProfile.id) return const properties = buildAmplitudeProperties({ currentWorkspace, @@ -53,8 +52,7 @@ export const amplitudeIdentitySyncAtom = atomEffect((get, set) => { properties, }) - if (identity === get.peek(amplitudeIdentityAtom)) - return + if (identity === get.peek(amplitudeIdentityAtom)) return setUserId(userProfile.email) setUserProperties(properties) diff --git a/web/context/app-context-normalizers.ts b/web/context/app-context-normalizers.ts index 24afe00f81e154..3c7779cacb8d49 100644 --- a/web/context/app-context-normalizers.ts +++ b/web/context/app-context-normalizers.ts @@ -4,7 +4,13 @@ import type { LangGeniusVersionInfo } from './app-context-types' import type { ICurrentWorkspace } from '@/models/common' import { initialLangGeniusVersionInfo, initialWorkspaceInfo } from './app-context-defaults' -const workspaceRoles = new Set<ICurrentWorkspace['role']>(['owner', 'admin', 'editor', 'dataset_operator', 'normal']) +const workspaceRoles = new Set<ICurrentWorkspace['role']>([ + 'owner', + 'admin', + 'editor', + 'dataset_operator', + 'normal', +]) export const emptyWorkspacePermissionKeys: string[] = [] @@ -20,16 +26,19 @@ export type ProfileMeta = { currentEnv: string | null } -function resolveWorkspaceRole(role: PostWorkspacesCurrentResponse['role']): ICurrentWorkspace['role'] { +function resolveWorkspaceRole( + role: PostWorkspacesCurrentResponse['role'], +): ICurrentWorkspace['role'] { if (role && workspaceRoles.has(role as ICurrentWorkspace['role'])) return role as ICurrentWorkspace['role'] return initialWorkspaceInfo.role } -export function normalizeCurrentWorkspace(workspace?: PostWorkspacesCurrentResponse): ICurrentWorkspace { - if (!workspace) - return initialWorkspaceInfo +export function normalizeCurrentWorkspace( + workspace?: PostWorkspacesCurrentResponse, +): ICurrentWorkspace { + if (!workspace) return initialWorkspaceInfo return { id: workspace.id, @@ -41,7 +50,8 @@ export function normalizeCurrentWorkspace(workspace?: PostWorkspacesCurrentRespo providers: initialWorkspaceInfo.providers, trial_credits: workspace.trial_credits ?? initialWorkspaceInfo.trial_credits, trial_credits_used: workspace.trial_credits_used ?? initialWorkspaceInfo.trial_credits_used, - next_credit_reset_date: workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date, + next_credit_reset_date: + workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date, trial_end_reason: workspace.trial_end_reason ?? undefined, custom_config: workspace.custom_config ? { @@ -68,8 +78,7 @@ export function getLangGeniusVersionInfo({ meta: ProfileMeta versionData?: GetVersionResponse }): LangGeniusVersionInfo { - if (!meta.currentVersion || !versionData) - return initialLangGeniusVersionInfo + if (!meta.currentVersion || !versionData) return initialLangGeniusVersionInfo return { ...versionData, diff --git a/web/context/dataset-detail.ts b/web/context/dataset-detail.ts index 3aa88c584dfd0b..ae38dfdd49ab9e 100644 --- a/web/context/dataset-detail.ts +++ b/web/context/dataset-detail.ts @@ -6,13 +6,17 @@ import { createContext, useContext, useContextSelector } from 'use-context-selec type DatasetDetailContextValue = { indexingTechnique?: IndexingType dataset?: DataSet - mutateDatasetRes?: (options?: RefetchOptions | undefined) => Promise<QueryObserverResult<DataSet, Error>> + mutateDatasetRes?: ( + options?: RefetchOptions | undefined, + ) => Promise<QueryObserverResult<DataSet, Error>> } const DatasetDetailContext = createContext<DatasetDetailContextValue>({}) export const useDatasetDetailContext = () => useContext(DatasetDetailContext) -export const useDatasetDetailContextWithSelector = <T>(selector: (value: DatasetDetailContextValue) => T): T => { +export const useDatasetDetailContextWithSelector = <T>( + selector: (value: DatasetDetailContextValue) => T, +): T => { return useContextSelector(DatasetDetailContext, selector) } export default DatasetDetailContext diff --git a/web/context/debug-configuration.ts b/web/context/debug-configuration.ts index a5f80f06840bd4..17f67e73445fb8 100644 --- a/web/context/debug-configuration.ts +++ b/web/context/debug-configuration.ts @@ -24,7 +24,12 @@ import type { import type { VisionSettings } from '@/types/app' import { noop } from 'es-toolkit/function' import { createContext, useContext } from 'use-context-selector' -import { ANNOTATION_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' +import { + ANNOTATION_DEFAULT, + DEFAULT_AGENT_SETTING, + DEFAULT_CHAT_PROMPT_CONFIG, + DEFAULT_COMPLETION_PROMPT_CONFIG, +} from '@/config' import { PromptMode } from '@/models/debug' import { AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } from '@/types/app' @@ -66,7 +71,9 @@ type IDebugConfiguration = { moreLikeThisConfig: MoreLikeThisConfig setMoreLikeThisConfig: (moreLikeThisConfig: MoreLikeThisConfig) => void suggestedQuestionsAfterAnswerConfig: SuggestedQuestionsAfterAnswerConfig - setSuggestedQuestionsAfterAnswerConfig: (suggestedQuestionsAfterAnswerConfig: SuggestedQuestionsAfterAnswerConfig) => void + setSuggestedQuestionsAfterAnswerConfig: ( + suggestedQuestionsAfterAnswerConfig: SuggestedQuestionsAfterAnswerConfig, + ) => void speechToTextConfig: SpeechToTextConfig setSpeechToTextConfig: (speechToTextConfig: SpeechToTextConfig) => void textToSpeechConfig: TextToSpeechConfig diff --git a/web/context/event-emitter-provider.tsx b/web/context/event-emitter-provider.tsx index da8d2d78c2b80b..4642a5a2a1c016 100644 --- a/web/context/event-emitter-provider.tsx +++ b/web/context/event-emitter-provider.tsx @@ -9,14 +9,10 @@ type EventEmitterContextProviderProps = { children: ReactNode } -export const EventEmitterContextProvider = ({ - children, -}: EventEmitterContextProviderProps) => { +export const EventEmitterContextProvider = ({ children }: EventEmitterContextProviderProps) => { const eventEmitter = useEventEmitter<EventEmitterValue>() return ( - <EventEmitterContext.Provider value={{ eventEmitter }}> - {children} - </EventEmitterContext.Provider> + <EventEmitterContext.Provider value={{ eventEmitter }}>{children}</EventEmitterContext.Provider> ) } diff --git a/web/context/event-emitter.ts b/web/context/event-emitter.ts index 30944cc0ff5517..939c80c3492f4a 100644 --- a/web/context/event-emitter.ts +++ b/web/context/event-emitter.ts @@ -15,7 +15,9 @@ type EventEmitterMessage = { export type EventEmitterValue = string | EventEmitterMessage -export const EventEmitterContext = createContext<{ eventEmitter: EventEmitter<EventEmitterValue> | null }>({ +export const EventEmitterContext = createContext<{ + eventEmitter: EventEmitter<EventEmitterValue> | null +}>({ eventEmitter: null, }) diff --git a/web/context/external-knowledge-api-context.tsx b/web/context/external-knowledge-api-context.tsx index 854aab47660ac5..0e3f40809c91e7 100644 --- a/web/context/external-knowledge-api-context.tsx +++ b/web/context/external-knowledge-api-context.tsx @@ -11,28 +11,35 @@ type ExternalKnowledgeApiContextType = { isLoading: boolean } -const ExternalKnowledgeApiContext = createContext<ExternalKnowledgeApiContextType | undefined>(undefined) +const ExternalKnowledgeApiContext = createContext<ExternalKnowledgeApiContextType | undefined>( + undefined, +) type ExternalKnowledgeApiProviderProps = { children: ReactNode enabled?: boolean } -export const ExternalKnowledgeApiProvider: FC<ExternalKnowledgeApiProviderProps> = ({ children, enabled = true }) => { +export const ExternalKnowledgeApiProvider: FC<ExternalKnowledgeApiProviderProps> = ({ + children, + enabled = true, +}) => { const { data, refetch, isLoading } = useExternalKnowledgeApiList({ enabled }) const mutateExternalKnowledgeApis = useCallback(() => { - if (!enabled) - return Promise.resolve(undefined) + if (!enabled) return Promise.resolve(undefined) - return refetch().then(res => res.data) + return refetch().then((res) => res.data) }, [enabled, refetch]) - const contextValue = useMemo<ExternalKnowledgeApiContextType>(() => ({ - externalKnowledgeApiList: data?.data || [], - mutateExternalKnowledgeApis, - isLoading, - }), [data, mutateExternalKnowledgeApis, isLoading]) + const contextValue = useMemo<ExternalKnowledgeApiContextType>( + () => ({ + externalKnowledgeApiList: data?.data || [], + mutateExternalKnowledgeApis, + isLoading, + }), + [data, mutateExternalKnowledgeApis, isLoading], + ) return ( <ExternalKnowledgeApiContext.Provider value={contextValue}> diff --git a/web/context/hooks/use-trigger-events-limit-modal.ts b/web/context/hooks/use-trigger-events-limit-modal.ts index 72342cd0d350fa..2ace70c4509c8f 100644 --- a/web/context/hooks/use-trigger-events-limit-modal.ts +++ b/web/context/hooks/use-trigger-events-limit-modal.ts @@ -30,7 +30,9 @@ type UseTriggerEventsLimitModalOptions = { type UseTriggerEventsLimitModalResult = { showTriggerEventsLimitModal: ModalState<TriggerEventsLimitModalPayload> | null - setShowTriggerEventsLimitModal: Dispatch<SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null>> + setShowTriggerEventsLimitModal: Dispatch< + SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null> + > persistTriggerEventsLimitModalDismiss: () => void } @@ -41,16 +43,14 @@ export const useTriggerEventsLimitModal = ({ isFetchedPlan, currentWorkspaceId, }: UseTriggerEventsLimitModalOptions): UseTriggerEventsLimitModalResult => { - const [showTriggerEventsLimitModal, setShowTriggerEventsLimitModal] = useState<ModalState<TriggerEventsLimitModalPayload> | null>(null) + const [showTriggerEventsLimitModal, setShowTriggerEventsLimitModal] = + useState<ModalState<TriggerEventsLimitModalPayload> | null>(null) const dismissedTriggerEventsLimitStorageKeysRef = useRef<Record<string, boolean>>({}) useEffect(() => { - if (!IS_CLOUD_EDITION) - return - if (isServer) - return - if (!currentWorkspaceId) - return + if (!IS_CLOUD_EDITION) return + if (isServer) return + if (!currentWorkspaceId) return if (!isFetchedPlan) { setShowTriggerEventsLimitModal(null) return @@ -61,39 +61,33 @@ export const useTriggerEventsLimitModal = ({ const reachedLimit = total.triggerEvents > 0 && usage.triggerEvents >= total.triggerEvents if (type === Plan.team || isUnlimited || !reachedLimit) { - if (showTriggerEventsLimitModal) - setShowTriggerEventsLimitModal(null) + if (showTriggerEventsLimitModal) setShowTriggerEventsLimitModal(null) return } - const triggerResetInDays = type === Plan.professional && total.triggerEvents !== NUM_INFINITE - ? reset.triggerEvents ?? undefined - : undefined + const triggerResetInDays = + type === Plan.professional && total.triggerEvents !== NUM_INFINITE + ? (reset.triggerEvents ?? undefined) + : undefined const cycleTag = (() => { if (typeof reset.triggerEvents === 'number') return dayjs().startOf('day').add(reset.triggerEvents, 'day').format('YYYY-MM-DD') - if (type === Plan.sandbox) - return dayjs().endOf('month').format('YYYY-MM-DD') + if (type === Plan.sandbox) return dayjs().endOf('month').format('YYYY-MM-DD') return 'none' })() const storageKey = `${TRIGGER_EVENTS_LOCALSTORAGE_PREFIX}-${currentWorkspaceId}-${type}-${total.triggerEvents}-${cycleTag}` - if (dismissedTriggerEventsLimitStorageKeysRef.current[storageKey]) - return + if (dismissedTriggerEventsLimitStorageKeysRef.current[storageKey]) return let persistDismiss = true let hasDismissed = false try { - if (localStorage.getItem(storageKey) === '1') - hasDismissed = true - } - catch { + if (localStorage.getItem(storageKey) === '1') hasDismissed = true + } catch { persistDismiss = false } - if (hasDismissed) - return + if (hasDismissed) return - if (showTriggerEventsLimitModal?.payload.storageKey === storageKey) - return + if (showTriggerEventsLimitModal?.payload.storageKey === storageKey) return setShowTriggerEventsLimitModal({ payload: { @@ -108,14 +102,12 @@ export const useTriggerEventsLimitModal = ({ const persistTriggerEventsLimitModalDismiss = useCallback(() => { const storageKey = showTriggerEventsLimitModal?.payload.storageKey - if (!storageKey) - return + if (!storageKey) return if (showTriggerEventsLimitModal?.payload.persistDismiss) { try { localStorage.setItem(storageKey, '1') return - } - catch { + } catch { // ignore error and fall back to in-memory guard } } diff --git a/web/context/i18n.spec.ts b/web/context/i18n.spec.ts index 613d15564a04de..77af2105a8acf2 100644 --- a/web/context/i18n.spec.ts +++ b/web/context/i18n.spec.ts @@ -108,7 +108,9 @@ describe('useDocLink', () => { it('should keep explicit product docs path without adding another product prefix', () => { const { result } = renderHook(() => useDocLink()) - const url = result.current('/cloud/use-dify/getting-started/introduction' as DocPathWithoutLang) + const url = result.current( + '/cloud/use-dify/getting-started/introduction' as DocPathWithoutLang, + ) expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`) }) }) @@ -175,7 +177,9 @@ describe('useDocLink', () => { const { result } = renderHook(() => useDocLink()) const url = result.current('/use-dify/workspace/subscription-management#dify-for-education') - expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/workspace/subscription-management#dify-for-education`) + expect(url).toBe( + `${defaultDocBaseUrl}/en/cloud/use-dify/workspace/subscription-management#dify-for-education`, + ) }) it('should use the self-host Start node docs path outside cloud edition', () => { @@ -336,8 +340,12 @@ describe('useDocLink', () => { describe('Edge Cases', () => { it('should handle path with anchor', () => { const { result } = renderHook(() => useDocLink()) - const url = result.current('/use-dify/getting-started/introduction#overview' as DocPathWithoutLang) - expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction#overview`) + const url = result.current( + '/use-dify/getting-started/introduction#overview' as DocPathWithoutLang, + ) + expect(url).toBe( + `${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction#overview`, + ) }) it('should handle multiple calls with same hook instance', () => { diff --git a/web/context/i18n.ts b/web/context/i18n.ts index 4a16ed5745b25c..6e172231eab77d 100644 --- a/web/context/i18n.ts +++ b/web/context/i18n.ts @@ -49,35 +49,34 @@ const splitPathHash = (path: string) => { const getProductAwarePath = (path: string): string => { const { pathname, hash } = splitPathHash(path) const availableProducts = docPathProductAvailability[pathname] - if (!availableProducts?.length) - return path + if (!availableProducts?.length) return path const currentProduct = getCurrentDocsProduct() const targetProduct = availableProducts.includes(currentProduct) ? currentProduct : availableProducts[0] - if (!targetProduct) - return path + if (!targetProduct) return path return `/${targetProduct}${pathname}${hash}` } -export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathMap?: DocPathMap) => string) => { +export const useDocLink = ( + baseUrl?: string, +): ((path?: DocPathWithoutLang, pathMap?: DocPathMap) => string) => { let baseDocUrl = baseUrl || defaultDocBaseUrl - baseDocUrl = (baseDocUrl.endsWith('/')) ? baseDocUrl.slice(0, -1) : baseDocUrl + baseDocUrl = baseDocUrl.endsWith('/') ? baseDocUrl.slice(0, -1) : baseDocUrl const locale = useLocale() return useCallback( (path?: DocPathWithoutLang, pathMap?: DocPathMap): string => { const docLanguage = getDocLanguage(locale) const pathUrl = path || '' - let targetPath = (pathMap) ? pathMap[locale] || pathUrl : pathUrl + let targetPath = pathMap ? pathMap[locale] || pathUrl : pathUrl const languagePrefix = `/${docLanguage}` if (!targetPath) { targetPath = getDocHomePath() - } - else { + } else { targetPath = getProductAwarePath(targetPath) } diff --git a/web/context/mitt-context-provider.tsx b/web/context/mitt-context-provider.tsx index b177694d8d693c..7fee75eb5b624f 100644 --- a/web/context/mitt-context-provider.tsx +++ b/web/context/mitt-context-provider.tsx @@ -11,9 +11,5 @@ type MittProviderProps = { export const MittProvider = ({ children }: MittProviderProps) => { const mitt = useMitt() - return ( - <MittContext.Provider value={mitt}> - {children} - </MittContext.Provider> - ) + return <MittContext.Provider value={mitt}>{children}</MittContext.Provider> } diff --git a/web/context/modal-context-provider.tsx b/web/context/modal-context-provider.tsx index 75fbec631b8a9b..54e733323d4c6f 100644 --- a/web/context/modal-context-provider.tsx +++ b/web/context/modal-context-provider.tsx @@ -23,46 +23,67 @@ import { import { useSetEducationVerifying } from '@/app/education-apply/storage' import { useProviderContext } from '@/context/provider-context' import { currentWorkspaceIdAtom } from '@/context/workspace-state' -import { - useAccountSettingModal, - usePricingModal, -} from '@/hooks/use-query-params' +import { useAccountSettingModal, usePricingModal } from '@/hooks/use-query-params' import dynamic from '@/next/dynamic' import { useTriggerEventsLimitModal } from './hooks/use-trigger-events-limit-modal' -import { - ModalContext, -} from './modal-context' +import { ModalContext } from './modal-context' const AccountSetting = dynamic(() => import('@/app/components/header/account-setting'), { ssr: false, }) -const IntegrationsSettingModal = dynamic(() => import('@/app/components/tools/integrations-setting-modal'), { - ssr: false, -}) -const ModerationSettingModal = dynamic(() => import('@/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal'), { - ssr: false, -}) -const ExternalDataToolModal = dynamic(() => import('@/app/components/app/configuration/tools/external-data-tool-modal'), { - ssr: false, -}) +const IntegrationsSettingModal = dynamic( + () => import('@/app/components/tools/integrations-setting-modal'), + { + ssr: false, + }, +) +const ModerationSettingModal = dynamic( + () => + import('@/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal'), + { + ssr: false, + }, +) +const ExternalDataToolModal = dynamic( + () => import('@/app/components/app/configuration/tools/external-data-tool-modal'), + { + ssr: false, + }, +) const Pricing = dynamic(() => import('@/app/components/billing/pricing'), { ssr: false, }) -const AnnotationFullModal = dynamic(() => import('@/app/components/billing/annotation-full/modal'), { - ssr: false, -}) -const ModelModal = dynamic(() => import('@/app/components/header/account-setting/model-provider-page/model-modal'), { - ssr: false, -}) -const ExternalAPIModal = dynamic(() => import('@/app/components/datasets/external-api/external-api-modal'), { - ssr: false, -}) -const ModelLoadBalancingModal = dynamic(() => import('@/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal'), { - ssr: false, -}) -const OpeningSettingModal = dynamic(() => import('@/app/components/base/features/new-feature-panel/conversation-opener/modal'), { - ssr: false, -}) +const AnnotationFullModal = dynamic( + () => import('@/app/components/billing/annotation-full/modal'), + { + ssr: false, + }, +) +const ModelModal = dynamic( + () => import('@/app/components/header/account-setting/model-provider-page/model-modal'), + { + ssr: false, + }, +) +const ExternalAPIModal = dynamic( + () => import('@/app/components/datasets/external-api/external-api-modal'), + { + ssr: false, + }, +) +const ModelLoadBalancingModal = dynamic( + () => + import('@/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal'), + { + ssr: false, + }, +) +const OpeningSettingModal = dynamic( + () => import('@/app/components/base/features/new-feature-panel/conversation-opener/modal'), + { + ssr: false, + }, +) const UpdatePlugin = dynamic(() => import('@/app/components/plugins/update-plugin'), { ssr: false, }) @@ -70,73 +91,90 @@ const UpdatePlugin = dynamic(() => import('@/app/components/plugins/update-plugi const ExpireNoticeModal = dynamic(() => import('@/app/education-apply/expire-notice-modal'), { ssr: false, }) -const TriggerEventsLimitModal = dynamic(() => import('@/app/components/billing/trigger-events-limit-modal'), { - ssr: false, -}) +const TriggerEventsLimitModal = dynamic( + () => import('@/app/components/billing/trigger-events-limit-modal'), + { + ssr: false, + }, +) type ModalContextProviderProps = { children: ReactNode } -export const ModalContextProvider = ({ - children, -}: ModalContextProviderProps) => { +export const ModalContextProvider = ({ children }: ModalContextProviderProps) => { // Use nuqs hooks for URL-based modal state management const [showPricingModal, setPricingModalOpen] = usePricingModal() const [urlAccountModalState, setUrlAccountModalState] = useAccountSettingModal() const accountSettingCallbacksRef = useRef<Omit<ModalState<SettingsTab>, 'payload'> | null>(null) const settingsTab = urlAccountModalState.isOpen - ? (isValidSettingsTab(urlAccountModalState.payload) - ? urlAccountModalState.payload - : DEFAULT_ACCOUNT_SETTING_TAB) + ? isValidSettingsTab(urlAccountModalState.payload) + ? urlAccountModalState.payload + : DEFAULT_ACCOUNT_SETTING_TAB : null - const accountSettingModalTab = isWorkspaceSettingTab(settingsTab) || isUserSettingTab(settingsTab) ? settingsTab : null + const accountSettingModalTab = + isWorkspaceSettingTab(settingsTab) || isUserSettingTab(settingsTab) ? settingsTab : null const integrationSettingModalSection = isIntegrationSettingTab(settingsTab) ? settingsTab : null - const [showModerationSettingModal, setShowModerationSettingModal] = useState<ModalState<ModerationConfig> | null>(null) - const [showExternalDataToolModal, setShowExternalDataToolModal] = useState<ModalState<ExternalDataTool> | null>(null) + const [showModerationSettingModal, setShowModerationSettingModal] = + useState<ModalState<ModerationConfig> | null>(null) + const [showExternalDataToolModal, setShowExternalDataToolModal] = + useState<ModalState<ExternalDataTool> | null>(null) const [showModelModal, setShowModelModal] = useState<ModalState<ModelModalType> | null>(null) - const [showExternalKnowledgeAPIModal, setShowExternalKnowledgeAPIModal] = useState<ModalState<CreateExternalAPIReq> | null>(null) - const [showModelLoadBalancingModal, setShowModelLoadBalancingModal] = useState<ModelLoadBalancingModalProps | null>(null) - const [showOpeningModal, setShowOpeningModal] = useState<ModalState<OpeningStatement & { - promptVariables?: PromptVariable[] - workflowVariables?: InputVar[] - onAutoAddPromptVariable?: (variable: PromptVariable[]) => void - }> | null>(null) - const [showUpdatePluginModal, setShowUpdatePluginModal] = useState<ModalState<UpdatePluginPayload> | null>(null) - const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState<ModalState<ExpireNoticeModalPayloadProps> | null>(null) + const [showExternalKnowledgeAPIModal, setShowExternalKnowledgeAPIModal] = + useState<ModalState<CreateExternalAPIReq> | null>(null) + const [showModelLoadBalancingModal, setShowModelLoadBalancingModal] = + useState<ModelLoadBalancingModalProps | null>(null) + const [showOpeningModal, setShowOpeningModal] = useState<ModalState< + OpeningStatement & { + promptVariables?: PromptVariable[] + workflowVariables?: InputVar[] + onAutoAddPromptVariable?: (variable: PromptVariable[]) => void + } + > | null>(null) + const [showUpdatePluginModal, setShowUpdatePluginModal] = + useState<ModalState<UpdatePluginPayload> | null>(null) + const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = + useState<ModalState<ExpireNoticeModalPayloadProps> | null>(null) const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) const setEducationVerifying = useSetEducationVerifying() const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false) const handleCancelAccountSettingModal = () => { - setEducationVerifying(educationVerifying => educationVerifying === 'yes' ? null : educationVerifying) + setEducationVerifying((educationVerifying) => + educationVerifying === 'yes' ? null : educationVerifying, + ) accountSettingCallbacksRef.current?.onCancelCallback?.() accountSettingCallbacksRef.current = null setUrlAccountModalState(null) } - const handleAccountSettingTabChange = useCallback((tab: SettingsTab) => { - setUrlAccountModalState({ payload: tab }) - }, [setUrlAccountModalState]) + const handleAccountSettingTabChange = useCallback( + (tab: SettingsTab) => { + setUrlAccountModalState({ payload: tab }) + }, + [setUrlAccountModalState], + ) - const setShowAccountSettingModal = useCallback((next: SetStateAction<ModalState<SettingsTab> | null>) => { - const currentState = settingsTab - ? { payload: settingsTab, ...accountSettingCallbacksRef.current } - : null - const resolvedState = typeof next === 'function' ? next(currentState) : next - if (!resolvedState) { - accountSettingCallbacksRef.current = null - setUrlAccountModalState(null) - return - } - const { payload, ...callbacks } = resolvedState - accountSettingCallbacksRef.current = callbacks - setUrlAccountModalState({ payload }) - }, [settingsTab, setUrlAccountModalState]) + const setShowAccountSettingModal = useCallback( + (next: SetStateAction<ModalState<SettingsTab> | null>) => { + const currentState = settingsTab + ? { payload: settingsTab, ...accountSettingCallbacksRef.current } + : null + const resolvedState = typeof next === 'function' ? next(currentState) : next + if (!resolvedState) { + accountSettingCallbacksRef.current = null + setUrlAccountModalState(null) + return + } + const { payload, ...callbacks } = resolvedState + accountSettingCallbacksRef.current = callbacks + setUrlAccountModalState({ payload }) + }, + [settingsTab, setUrlAccountModalState], + ) useEffect(() => { - if (!urlAccountModalState.isOpen) - accountSettingCallbacksRef.current = null + if (!urlAccountModalState.isOpen) accountSettingCallbacksRef.current = null }, [urlAccountModalState.isOpen]) const { plan, isFetchedPlan } = useProviderContext() @@ -152,33 +190,36 @@ export const ModalContextProvider = ({ const handleCancelModerationSettingModal = () => { setShowModerationSettingModal(null) - if (showModerationSettingModal?.onCancelCallback) - showModerationSettingModal.onCancelCallback() + if (showModerationSettingModal?.onCancelCallback) showModerationSettingModal.onCancelCallback() } const handleCancelExternalDataToolModal = () => { setShowExternalDataToolModal(null) - if (showExternalDataToolModal?.onCancelCallback) - showExternalDataToolModal.onCancelCallback() + if (showExternalDataToolModal?.onCancelCallback) showExternalDataToolModal.onCancelCallback() } const handleCancelModelModal = useCallback(() => { setShowModelModal(null) - if (showModelModal?.onCancelCallback) - showModelModal.onCancelCallback() + if (showModelModal?.onCancelCallback) showModelModal.onCancelCallback() }, [showModelModal]) - const handleSaveModelModal = useCallback((formValues?: Record<string, unknown>) => { - if (showModelModal?.onSaveCallback) - showModelModal.onSaveCallback(showModelModal.payload, formValues) - setShowModelModal(null) - }, [showModelModal]) + const handleSaveModelModal = useCallback( + (formValues?: Record<string, unknown>) => { + if (showModelModal?.onSaveCallback) + showModelModal.onSaveCallback(showModelModal.payload, formValues) + setShowModelModal(null) + }, + [showModelModal], + ) - const handleRemoveModelModal = useCallback((formValues?: Record<string, unknown>) => { - if (showModelModal?.onRemoveCallback) - showModelModal.onRemoveCallback(showModelModal.payload, formValues) - setShowModelModal(null) - }, [showModelModal]) + const handleRemoveModelModal = useCallback( + (formValues?: Record<string, unknown>) => { + if (showModelModal?.onRemoveCallback) + showModelModal.onRemoveCallback(showModelModal.payload, formValues) + setShowModelModal(null) + }, + [showModelModal], + ) const handleCancelExternalApiModal = useCallback(() => { setShowExternalKnowledgeAPIModal(null) @@ -186,22 +227,27 @@ export const ModalContextProvider = ({ showExternalKnowledgeAPIModal.onCancelCallback() }, [showExternalKnowledgeAPIModal]) - const handleSaveExternalApiModal = useCallback(async (updatedFormValue: CreateExternalAPIReq) => { - if (showExternalKnowledgeAPIModal?.onSaveCallback) - showExternalKnowledgeAPIModal.onSaveCallback(updatedFormValue) - setShowExternalKnowledgeAPIModal(null) - }, [showExternalKnowledgeAPIModal]) + const handleSaveExternalApiModal = useCallback( + async (updatedFormValue: CreateExternalAPIReq) => { + if (showExternalKnowledgeAPIModal?.onSaveCallback) + showExternalKnowledgeAPIModal.onSaveCallback(updatedFormValue) + setShowExternalKnowledgeAPIModal(null) + }, + [showExternalKnowledgeAPIModal], + ) - const handleEditExternalApiModal = useCallback(async (updatedFormValue: CreateExternalAPIReq) => { - if (showExternalKnowledgeAPIModal?.onEditCallback) - showExternalKnowledgeAPIModal.onEditCallback(updatedFormValue) - setShowExternalKnowledgeAPIModal(null) - }, [showExternalKnowledgeAPIModal]) + const handleEditExternalApiModal = useCallback( + async (updatedFormValue: CreateExternalAPIReq) => { + if (showExternalKnowledgeAPIModal?.onEditCallback) + showExternalKnowledgeAPIModal.onEditCallback(updatedFormValue) + setShowExternalKnowledgeAPIModal(null) + }, + [showExternalKnowledgeAPIModal], + ) const handleCancelOpeningModal = useCallback(() => { setShowOpeningModal(null) - if (showOpeningModal?.onCancelCallback) - showOpeningModal.onCancelCallback() + if (showOpeningModal?.onCancelCallback) showOpeningModal.onCancelCallback() }, [showOpeningModal]) const handleSaveModeration = (newModerationConfig: ModerationConfig) => { @@ -223,8 +269,7 @@ export const ModalContextProvider = ({ } const handleSaveOpeningModal = (newOpening: OpeningStatement) => { - if (showOpeningModal?.onSaveCallback) - showOpeningModal.onSaveCallback(newOpening) + if (showOpeningModal?.onSaveCallback) showOpeningModal.onSaveCallback(newOpening) setShowOpeningModal(null) } @@ -237,110 +282,93 @@ export const ModalContextProvider = ({ }, [setPricingModalOpen]) return ( - <ModalContext.Provider value={{ - setShowAccountSettingModal, - setShowModerationSettingModal, - setShowExternalDataToolModal, - setShowPricingModal: handleShowPricingModal, - setShowAnnotationFullModal: () => setShowAnnotationFullModal(true), - setShowModelModal, - setShowExternalKnowledgeAPIModal, - setShowModelLoadBalancingModal, - setShowOpeningModal, - setShowUpdatePluginModal, - setShowEducationExpireNoticeModal, - setShowTriggerEventsLimitModal, - }} + <ModalContext.Provider + value={{ + setShowAccountSettingModal, + setShowModerationSettingModal, + setShowExternalDataToolModal, + setShowPricingModal: handleShowPricingModal, + setShowAnnotationFullModal: () => setShowAnnotationFullModal(true), + setShowModelModal, + setShowExternalKnowledgeAPIModal, + setShowModelLoadBalancingModal, + setShowOpeningModal, + setShowUpdatePluginModal, + setShowEducationExpireNoticeModal, + setShowTriggerEventsLimitModal, + }} > <> {children} - { - accountSettingModalTab && ( - <AccountSetting - activeTab={accountSettingModalTab} - onCancelAction={handleCancelAccountSettingModal} - onTabChangeAction={handleAccountSettingTabChange} - /> - ) - } - { - integrationSettingModalSection && ( - <IntegrationsSettingModal - section={integrationSettingModalSection} - source={accountSettingCallbacksRef.current?.source} - onCancel={handleCancelAccountSettingModal} - onSectionChange={section => setUrlAccountModalState({ payload: section })} - /> - ) - } + {accountSettingModalTab && ( + <AccountSetting + activeTab={accountSettingModalTab} + onCancelAction={handleCancelAccountSettingModal} + onTabChangeAction={handleAccountSettingTabChange} + /> + )} + {integrationSettingModalSection && ( + <IntegrationsSettingModal + section={integrationSettingModalSection} + source={accountSettingCallbacksRef.current?.source} + onCancel={handleCancelAccountSettingModal} + onSectionChange={(section) => setUrlAccountModalState({ payload: section })} + /> + )} - { - !!showModerationSettingModal && ( - <ModerationSettingModal - data={showModerationSettingModal.payload} - onCancel={handleCancelModerationSettingModal} - onSave={handleSaveModeration} - /> - ) - } - { - !!showExternalDataToolModal && ( - <ExternalDataToolModal - data={showExternalDataToolModal.payload} - onCancel={handleCancelExternalDataToolModal} - onSave={handleSaveExternalDataTool} - onValidateBeforeSave={handleValidateBeforeSaveExternalDataTool} - /> - ) - } + {!!showModerationSettingModal && ( + <ModerationSettingModal + data={showModerationSettingModal.payload} + onCancel={handleCancelModerationSettingModal} + onSave={handleSaveModeration} + /> + )} + {!!showExternalDataToolModal && ( + <ExternalDataToolModal + data={showExternalDataToolModal.payload} + onCancel={handleCancelExternalDataToolModal} + onSave={handleSaveExternalDataTool} + onValidateBeforeSave={handleValidateBeforeSaveExternalDataTool} + /> + )} - { - !!showPricingModal && ( - <Pricing onCancel={handleCancelPricingModal} /> - ) - } + {!!showPricingModal && <Pricing onCancel={handleCancelPricingModal} />} - { - showAnnotationFullModal && ( - <AnnotationFullModal - show={showAnnotationFullModal} - onHide={() => setShowAnnotationFullModal(false)} - /> - ) - } - { - !!showModelModal && ( - <ModelModal - provider={showModelModal.payload.currentProvider} - configurateMethod={showModelModal.payload.currentConfigurationMethod} - currentCustomConfigurationModelFixedFields={showModelModal.payload.currentCustomConfigurationModelFixedFields} - isModelCredential={showModelModal.payload.isModelCredential} - credential={showModelModal.payload.credential} - model={showModelModal.payload.model} - mode={showModelModal.payload.mode} - onCancel={handleCancelModelModal} - onSave={handleSaveModelModal} - onRemove={handleRemoveModelModal} - /> - ) - } - { - !!showExternalKnowledgeAPIModal && ( - <ExternalAPIModal - data={showExternalKnowledgeAPIModal.payload} - datasetBindings={showExternalKnowledgeAPIModal.datasetBindings ?? []} - onSave={handleSaveExternalApiModal} - onCancel={handleCancelExternalApiModal} - onEdit={handleEditExternalApiModal} - isEditMode={showExternalKnowledgeAPIModal.isEditMode ?? false} - /> - ) - } - { - Boolean(showModelLoadBalancingModal) && ( - <ModelLoadBalancingModal {...showModelLoadBalancingModal!} /> - ) - } + {showAnnotationFullModal && ( + <AnnotationFullModal + show={showAnnotationFullModal} + onHide={() => setShowAnnotationFullModal(false)} + /> + )} + {!!showModelModal && ( + <ModelModal + provider={showModelModal.payload.currentProvider} + configurateMethod={showModelModal.payload.currentConfigurationMethod} + currentCustomConfigurationModelFixedFields={ + showModelModal.payload.currentCustomConfigurationModelFixedFields + } + isModelCredential={showModelModal.payload.isModelCredential} + credential={showModelModal.payload.credential} + model={showModelModal.payload.model} + mode={showModelModal.payload.mode} + onCancel={handleCancelModelModal} + onSave={handleSaveModelModal} + onRemove={handleRemoveModelModal} + /> + )} + {!!showExternalKnowledgeAPIModal && ( + <ExternalAPIModal + data={showExternalKnowledgeAPIModal.payload} + datasetBindings={showExternalKnowledgeAPIModal.datasetBindings ?? []} + onSave={handleSaveExternalApiModal} + onCancel={handleCancelExternalApiModal} + onEdit={handleEditExternalApiModal} + isEditMode={showExternalKnowledgeAPIModal.isEditMode ?? false} + /> + )} + {Boolean(showModelLoadBalancingModal) && ( + <ModelLoadBalancingModal {...showModelLoadBalancingModal!} /> + )} {showOpeningModal && ( <OpeningSettingModal data={showOpeningModal.payload} @@ -352,48 +380,42 @@ export const ModalContextProvider = ({ /> )} - { - !!showUpdatePluginModal && ( - <UpdatePlugin - {...showUpdatePluginModal.payload} - onCancel={() => { - setShowUpdatePluginModal(null) - showUpdatePluginModal.onCancelCallback?.() - }} - onSave={() => { - setShowUpdatePluginModal(null) - showUpdatePluginModal.onSaveCallback?.() - }} - /> - ) - } - { - !!showEducationExpireNoticeModal && ( - <ExpireNoticeModal - {...showEducationExpireNoticeModal.payload} - onClose={() => setShowEducationExpireNoticeModal(null)} - /> - ) - } - { - !!showTriggerEventsLimitModal && ( - <TriggerEventsLimitModal - show - usage={showTriggerEventsLimitModal.payload.usage} - total={showTriggerEventsLimitModal.payload.total} - resetInDays={showTriggerEventsLimitModal.payload.resetInDays} - onClose={() => { - persistTriggerEventsLimitModalDismiss() - setShowTriggerEventsLimitModal(null) - }} - onUpgrade={() => { - persistTriggerEventsLimitModalDismiss() - setShowTriggerEventsLimitModal(null) - handleShowPricingModal() - }} - /> - ) - } + {!!showUpdatePluginModal && ( + <UpdatePlugin + {...showUpdatePluginModal.payload} + onCancel={() => { + setShowUpdatePluginModal(null) + showUpdatePluginModal.onCancelCallback?.() + }} + onSave={() => { + setShowUpdatePluginModal(null) + showUpdatePluginModal.onSaveCallback?.() + }} + /> + )} + {!!showEducationExpireNoticeModal && ( + <ExpireNoticeModal + {...showEducationExpireNoticeModal.payload} + onClose={() => setShowEducationExpireNoticeModal(null)} + /> + )} + {!!showTriggerEventsLimitModal && ( + <TriggerEventsLimitModal + show + usage={showTriggerEventsLimitModal.payload.usage} + total={showTriggerEventsLimitModal.payload.total} + resetInDays={showTriggerEventsLimitModal.payload.resetInDays} + onClose={() => { + persistTriggerEventsLimitModalDismiss() + setShowTriggerEventsLimitModal(null) + }} + onUpgrade={() => { + persistTriggerEventsLimitModalDismiss() + setShowTriggerEventsLimitModal(null) + handleShowPricingModal() + }} + /> + )} </> </ModalContext.Provider> ) diff --git a/web/context/modal-context.test.tsx b/web/context/modal-context.test.tsx index 5dac8396386466..40222ac1c2381d 100644 --- a/web/context/modal-context.test.tsx +++ b/web/context/modal-context.test.tsx @@ -30,10 +30,12 @@ vi.mock('@/app/components/billing/pricing', () => ({ })) vi.mock('@/app/components/header/account-setting', () => ({ - default: ({ activeTab, onCancelAction }: { activeTab: string, onCancelAction: () => void }) => ( + default: ({ activeTab, onCancelAction }: { activeTab: string; onCancelAction: () => void }) => ( <> <div data-testid="account-setting-active-tab">{activeTab}</div> - <button type="button" onClick={onCancelAction}>cancel account setting</button> + <button type="button" onClick={onCancelAction}> + cancel account setting + </button> </> ), })) @@ -76,7 +78,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -110,14 +113,14 @@ const createPlan = (overrides: PlanOverrides = {}): PlanShape => ({ }, }) -const renderProvider = (children: React.ReactNode = <div data-testid="modal-context-test-child" />) => renderWithNuqs( - <ModalContextProvider> - {children} - </ModalContextProvider>, -) +const renderProvider = ( + children: React.ReactNode = <div data-testid="modal-context-test-child" />, +) => renderWithNuqs(<ModalContextProvider>{children}</ModalContextProvider>) const AccountSettingOpener = () => { - const setShowAccountSettingModal = useModalContextSelector(state => state.setShowAccountSettingModal) + const setShowAccountSettingModal = useModalContextSelector( + (state) => state.setShowAccountSettingModal, + ) return ( <button @@ -130,7 +133,9 @@ const AccountSettingOpener = () => { } const PreferencesOpener = () => { - const setShowAccountSettingModal = useModalContextSelector(state => state.setShowAccountSettingModal) + const setShowAccountSettingModal = useModalContextSelector( + (state) => state.setShowAccountSettingModal, + ) return ( <button @@ -204,7 +209,9 @@ describe('ModalContextProvider trigger events limit modal', () => { await user.click(await screen.findByRole('button', { name: 'cancel account setting' })) expect(mockSetEducationVerifying).toHaveBeenCalledWith(expect.any(Function)) - const updater = mockSetEducationVerifying.mock.calls[0]?.[0] as (educationVerifying: string) => string | null + const updater = mockSetEducationVerifying.mock.calls[0]?.[0] as ( + educationVerifying: string, + ) => string | null expect(updater('yes')).toBeNull() expect(updater('no')).toBe('no') }) @@ -220,7 +227,9 @@ describe('ModalContextProvider trigger events limit modal', () => { await user.click(screen.getByRole('button', { name: 'open preferences' })) - expect(await screen.findByTestId('account-setting-active-tab')).toHaveTextContent(ACCOUNT_SETTING_TAB.PREFERENCES) + expect(await screen.findByTestId('account-setting-active-tab')).toHaveTextContent( + ACCOUNT_SETTING_TAB.PREFERENCES, + ) }) it('relies on the in-memory guard when localStorage reads throw', async () => { @@ -294,7 +303,9 @@ describe('ModalContextProvider trigger events limit modal', () => { await user.click(screen.getByText('billing.triggerLimitModal.upgrade')) - await waitFor(() => expect(screen.getByText('billing.plansCommon.mostPopular')).toBeInTheDocument()) + await waitFor(() => + expect(screen.getByText('billing.plansCommon.mostPopular')).toBeInTheDocument(), + ) expect(screen.queryByText('400')).not.toBeInTheDocument() }) }) diff --git a/web/context/modal-context.ts b/web/context/modal-context.ts index 89c337a041ca42..33d7c5f9e0b2e7 100644 --- a/web/context/modal-context.ts +++ b/web/context/modal-context.ts @@ -17,9 +17,7 @@ import type { ModelLoadBalancingModalProps } from '@/app/components/header/accou import type { UpdatePluginPayload } from '@/app/components/plugins/types' import type { InputVar } from '@/app/components/workflow/types' import type { ExpireNoticeModalPayloadProps } from '@/app/education-apply/expire-notice-modal' -import type { - ExternalDataTool, -} from '@/models/common' +import type { ExternalDataTool } from '@/models/common' import type { ModerationConfig, PromptVariable } from '@/models/debug' import { noop } from 'es-toolkit/function' import { createContext, useContext, useContextSelector } from 'use-context-selector' @@ -33,7 +31,7 @@ export type ModalState<T> = { onEditCallback?: (newPayload: T) => void onValidateBeforeSaveCallback?: (newPayload: T) => boolean isEditMode?: boolean - datasetBindings?: { id: string, name: string }[] + datasetBindings?: { id: string; name: string }[] } export type ModelModalType = { @@ -53,16 +51,26 @@ export type ModalContextState = { setShowPricingModal: () => void setShowAnnotationFullModal: () => void setShowModelModal: Dispatch<SetStateAction<ModalState<ModelModalType> | null>> - setShowExternalKnowledgeAPIModal: Dispatch<SetStateAction<ModalState<CreateExternalAPIReq> | null>> + setShowExternalKnowledgeAPIModal: Dispatch< + SetStateAction<ModalState<CreateExternalAPIReq> | null> + > setShowModelLoadBalancingModal: Dispatch<SetStateAction<ModelLoadBalancingModalProps | null>> - setShowOpeningModal: Dispatch<SetStateAction<ModalState<OpeningStatement & { - promptVariables?: PromptVariable[] - workflowVariables?: InputVar[] - onAutoAddPromptVariable?: (variable: PromptVariable[]) => void - }> | null>> + setShowOpeningModal: Dispatch< + SetStateAction<ModalState< + OpeningStatement & { + promptVariables?: PromptVariable[] + workflowVariables?: InputVar[] + onAutoAddPromptVariable?: (variable: PromptVariable[]) => void + } + > | null> + > setShowUpdatePluginModal: Dispatch<SetStateAction<ModalState<UpdatePluginPayload> | null>> - setShowEducationExpireNoticeModal: Dispatch<SetStateAction<ModalState<ExpireNoticeModalPayloadProps> | null>> - setShowTriggerEventsLimitModal: Dispatch<SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null>> + setShowEducationExpireNoticeModal: Dispatch< + SetStateAction<ModalState<ExpireNoticeModalPayloadProps> | null> + > + setShowTriggerEventsLimitModal: Dispatch< + SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null> + > } export const ModalContext = createContext<ModalContextState>({ diff --git a/web/context/permission-state.ts b/web/context/permission-state.ts index 8ce72b64f0e162..c1c0b7269f6f3a 100644 --- a/web/context/permission-state.ts +++ b/web/context/permission-state.ts @@ -13,7 +13,10 @@ const workspacePermissionKeysQueryAtom = atomWithQuery((get) => { }) export const workspacePermissionKeysAtom = atom((get) => { - return get(workspacePermissionKeysQueryAtom).data?.workspace?.permission_keys ?? emptyWorkspacePermissionKeys + return ( + get(workspacePermissionKeysQueryAtom).data?.workspace?.permission_keys ?? + emptyWorkspacePermissionKeys + ) }) export const workspacePermissionKeysLoadingAtom = atom((get) => { diff --git a/web/context/provider-context-mock.spec.tsx b/web/context/provider-context-mock.spec.tsx index 5b5f71c9720f16..23463f19644ef7 100644 --- a/web/context/provider-context-mock.spec.tsx +++ b/web/context/provider-context-mock.spec.tsx @@ -1,6 +1,11 @@ import type { UsagePlanInfo } from '@/app/components/billing/type' import { render } from '@testing-library/react' -import { createMockPlan, createMockPlanReset, createMockPlanTotal, createMockPlanUsage } from '@/__mocks__/provider-context' +import { + createMockPlan, + createMockPlanReset, + createMockPlanTotal, + createMockPlanUsage, +} from '@/__mocks__/provider-context' import { Plan } from '@/app/components/billing/type' import ProviderContextMock from './provider-context-mock' diff --git a/web/context/provider-context-provider.tsx b/web/context/provider-context-provider.tsx index 1d8ec406db0dd6..93464d14c0c985 100644 --- a/web/context/provider-context-provider.tsx +++ b/web/context/provider-context-provider.tsx @@ -40,9 +40,10 @@ const unlimitedMemberInviteLimit: MemberInviteLimit = { limit: 0, } -const resolveMemberInviteLimit = (data: Awaited<ReturnType<typeof fetchCurrentPlanInfo>>): MemberInviteLimit => { - if (!data) - return unlimitedMemberInviteLimit +const resolveMemberInviteLimit = ( + data: Awaited<ReturnType<typeof fetchCurrentPlanInfo>>, +): MemberInviteLimit => { + if (!data) return unlimitedMemberInviteLimit if (data.workspace_members?.enabled) { return { @@ -61,9 +62,7 @@ const resolveMemberInviteLimit = (data: Awaited<ReturnType<typeof fetchCurrentPl return unlimitedMemberInviteLimit } -export const ProviderContextProvider = ({ - children, -}: ProviderContextProviderProps) => { +export const ProviderContextProvider = ({ children }: ProviderContextProviderProps) => { const queryClient = useQueryClient() const { data: providersData, isLoading: isLoadingModelProviders } = useModelProviders() const { data: textGenerationModelList } = useModelListByType(ModelTypeEnum.textGeneration) @@ -86,9 +85,17 @@ export const ProviderContextProvider = ({ const [enableEducationPlan, setEnableEducationPlan] = useState(false) const [isEducationWorkspace, setIsEducationWorkspace] = useState(false) - const { data: educationAccountInfo, isLoading: isLoadingEducationAccountInfo, isFetching: isFetchingEducationAccountInfo, isFetchedAfterMount: isEducationDataFetchedAfterMount } = useEducationStatus(!enableEducationPlan) + const { + data: educationAccountInfo, + isLoading: isLoadingEducationAccountInfo, + isFetching: isFetchingEducationAccountInfo, + isFetchedAfterMount: isEducationDataFetchedAfterMount, + } = useEducationStatus(!enableEducationPlan) const [isAllowTransferWorkspace, setIsAllowTransferWorkspace] = useState(false) - const [isAllowPublishAsCustomKnowledgePipelineTemplate, setIsAllowPublishAsCustomKnowledgePipelineTemplate] = useState(false) + const [ + isAllowPublishAsCustomKnowledgePipelineTemplate, + setIsAllowPublishAsCustomKnowledgePipelineTemplate, + ] = useState(false) const [humanInputEmailDeliveryEnabled, setHumanInputEmailDeliveryEnabled] = useState(false) const refreshModelProviders = () => { @@ -114,12 +121,9 @@ export const ProviderContextProvider = ({ setIsFetchedPlan(true) } - if (data.model_load_balancing_enabled) - setModelLoadBalancingEnabled(true) - if (data.dataset_operator_enabled) - setDatasetOperatorEnabled(true) - if (data.webapp_copyright_enabled) - setWebappCopyrightEnabled(true) + if (data.model_load_balancing_enabled) setModelLoadBalancingEnabled(true) + if (data.dataset_operator_enabled) setDatasetOperatorEnabled(true) + if (data.webapp_copyright_enabled) setWebappCopyrightEnabled(true) setLicenseLimit({ workspace_members: resolveMemberInviteLimit(data) }) if (data.is_allow_transfer_workspace) setIsAllowTransferWorkspace(data.is_allow_transfer_workspace) @@ -127,16 +131,14 @@ export const ProviderContextProvider = ({ setIsAllowPublishAsCustomKnowledgePipelineTemplate(data.knowledge_pipeline?.publish_enabled) if (data.human_input_email_delivery_enabled) setHumanInputEmailDeliveryEnabled(data.human_input_email_delivery_enabled) - } - catch (error) { + } catch (error) { console.error('Failed to fetch plan info:', error) // set default value to avoid undefined error setEnableBilling(false) setEnableEducationPlan(false) setIsEducationWorkspace(false) setEnableReplaceWebAppLogo(false) - } - finally { + } finally { setIsFetchedPlanInfo(true) } } @@ -147,10 +149,12 @@ export const ProviderContextProvider = ({ // #region Zendesk conversation fields useEffect(() => { if (ZENDESK_FIELD_IDS.PLAN && plan.type) { - setZendeskConversationFields([{ - id: ZENDESK_FIELD_IDS.PLAN, - value: `${plan.type}-plan`, - }]) + setZendeskConversationFields([ + { + id: ZENDESK_FIELD_IDS.PLAN, + value: `${plan.type}-plan`, + }, + ]) } }, [plan.type]) // #endregion Zendesk conversation fields @@ -159,56 +163,71 @@ export const ProviderContextProvider = ({ const [anthropicQuotaNotice, setAnthropicQuotaNotice] = useAnthropicQuotaNotice() useEffect(() => { - if (anthropicQuotaNotice === 'true') - return + if (anthropicQuotaNotice === 'true') return - if (dayjs().isAfter(dayjs('2025-03-17'))) - return + if (dayjs().isAfter(dayjs('2025-03-17'))) return if (providersData?.data && providersData.data.length > 0) { - const anthropic = providersData.data.find(provider => provider.provider === 'anthropic') - if (anthropic && anthropic.system_configuration.current_quota_type === CurrentSystemQuotaTypeEnum.trial) { - const quota = anthropic.system_configuration.quota_configurations.find(item => item.quota_type === anthropic.system_configuration.current_quota_type) + const anthropic = providersData.data.find((provider) => provider.provider === 'anthropic') + if ( + anthropic && + anthropic.system_configuration.current_quota_type === CurrentSystemQuotaTypeEnum.trial + ) { + const quota = anthropic.system_configuration.quota_configurations.find( + (item) => item.quota_type === anthropic.system_configuration.current_quota_type, + ) if (quota && quota.is_valid && quota.quota_used < quota.quota_limit) { setAnthropicQuotaNotice('true') - toast.info(t($ => $['provider.anthropicHosted.trialQuotaTip'], { ns: 'common' }), { - timeout: 60000, - }) + toast.info( + t(($) => $['provider.anthropicHosted.trialQuotaTip'], { ns: 'common' }), + { + timeout: 60000, + }, + ) } } } }, [anthropicQuotaNotice, providersData, setAnthropicQuotaNotice, t]) return ( - <ProviderContext.Provider value={{ - modelProviders: providersData?.data || [], - isLoadingModelProviders, - refreshModelProviders, - textGenerationModelList: textGenerationModelList?.data || [], - isAPIKeySet: !!textGenerationModelList?.data?.some(model => model.status === ModelStatusEnum.active), - supportRetrievalMethods: supportRetrievalMethods?.retrieval_method || [], - plan, - isFetchedPlan, - isFetchedPlanInfo, - enableBilling, - onPlanInfoChanged: fetchPlan, - enableReplaceWebAppLogo, - modelLoadBalancingEnabled, - datasetOperatorEnabled, - enableEducationPlan, - isEducationWorkspace, - isEducationAccount: isEducationDataFetchedAfterMount ? (educationAccountInfo?.is_student ?? false) : false, - allowRefreshEducationVerify: isEducationDataFetchedAfterMount ? (educationAccountInfo?.allow_refresh ?? false) : false, - educationAccountExpireAt: isEducationDataFetchedAfterMount ? (educationAccountInfo?.expire_at ?? null) : null, - isLoadingEducationAccountInfo, - isFetchingEducationAccountInfo, - webappCopyrightEnabled, - licenseLimit, - refreshLicenseLimit: fetchPlan, - isAllowTransferWorkspace, - isAllowPublishAsCustomKnowledgePipelineTemplate, - humanInputEmailDeliveryEnabled, - }} + <ProviderContext.Provider + value={{ + modelProviders: providersData?.data || [], + isLoadingModelProviders, + refreshModelProviders, + textGenerationModelList: textGenerationModelList?.data || [], + isAPIKeySet: !!textGenerationModelList?.data?.some( + (model) => model.status === ModelStatusEnum.active, + ), + supportRetrievalMethods: supportRetrievalMethods?.retrieval_method || [], + plan, + isFetchedPlan, + isFetchedPlanInfo, + enableBilling, + onPlanInfoChanged: fetchPlan, + enableReplaceWebAppLogo, + modelLoadBalancingEnabled, + datasetOperatorEnabled, + enableEducationPlan, + isEducationWorkspace, + isEducationAccount: isEducationDataFetchedAfterMount + ? (educationAccountInfo?.is_student ?? false) + : false, + allowRefreshEducationVerify: isEducationDataFetchedAfterMount + ? (educationAccountInfo?.allow_refresh ?? false) + : false, + educationAccountExpireAt: isEducationDataFetchedAfterMount + ? (educationAccountInfo?.expire_at ?? null) + : null, + isLoadingEducationAccountInfo, + isFetchingEducationAccountInfo, + webappCopyrightEnabled, + licenseLimit, + refreshLicenseLimit: fetchPlan, + isAllowTransferWorkspace, + isAllowPublishAsCustomKnowledgePipelineTemplate, + humanInputEmailDeliveryEnabled, + }} > {children} </ProviderContext.Provider> diff --git a/web/context/provider-context.ts b/web/context/provider-context.ts index 60ea79541ba005..f366f7e134054f 100644 --- a/web/context/provider-context.ts +++ b/web/context/provider-context.ts @@ -1,7 +1,10 @@ 'use client' import type { Plan, UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type' -import type { Model, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + Model, + ModelProvider, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { RETRIEVE_METHOD } from '@/types/app' import { noop } from 'es-toolkit/function' import { createContext, useContext, useContextSelector } from 'use-context-selector' diff --git a/web/context/provider-storage.ts b/web/context/provider-storage.ts index 7bd951d959a666..caa95e18ce5f34 100644 --- a/web/context/provider-storage.ts +++ b/web/context/provider-storage.ts @@ -2,12 +2,7 @@ import { createLocalStorageState } from 'foxact/create-local-storage-state' const ANTHROPIC_QUOTA_NOTICE_STORAGE_KEY = 'anthropic_quota_notice' -const [ - useAnthropicQuotaNotice, - _useAnthropicQuotaNoticeValue, - _useSetAnthropicQuotaNotice, -] = createLocalStorageState<string>(ANTHROPIC_QUOTA_NOTICE_STORAGE_KEY, 'false', { raw: true }) +const [useAnthropicQuotaNotice, _useAnthropicQuotaNoticeValue, _useSetAnthropicQuotaNotice] = + createLocalStorageState<string>(ANTHROPIC_QUOTA_NOTICE_STORAGE_KEY, 'false', { raw: true }) -export { - useAnthropicQuotaNotice, -} +export { useAnthropicQuotaNotice } diff --git a/web/context/query-client.tsx b/web/context/query-client.tsx index 84e726c1c0e516..fe2c43ab06bcd3 100644 --- a/web/context/query-client.tsx +++ b/web/context/query-client.tsx @@ -13,8 +13,7 @@ function getQueryClient() { if (isServer) { return makeQueryClient() } - if (!browserQueryClient) - browserQueryClient = makeQueryClient() + if (!browserQueryClient) browserQueryClient = makeQueryClient() return browserQueryClient } @@ -22,9 +21,7 @@ export const TanstackQueryInitializer = ({ children }: { children: React.ReactNo const queryClient = getQueryClient() return ( <QueryClientProvider client={queryClient}> - <HydrateJotaiQueryClient queryClient={queryClient}> - {children} - </HydrateJotaiQueryClient> + <HydrateJotaiQueryClient queryClient={queryClient}>{children}</HydrateJotaiQueryClient> </QueryClientProvider> ) } diff --git a/web/context/version-state.ts b/web/context/version-state.ts index 795104b6b18bd1..8a4242f805d939 100644 --- a/web/context/version-state.ts +++ b/web/context/version-state.ts @@ -27,8 +27,7 @@ export const langGeniusVersionInfoAtom = atom((get) => { const meta = get(accountProfileMetaAtom) const versionData = get(versionQueryAtom).data - if (!versionData) - return initialLangGeniusVersionInfo + if (!versionData) return initialLangGeniusVersionInfo return getLangGeniusVersionInfo({ meta, diff --git a/web/context/web-app-context.tsx b/web/context/web-app-context.tsx index e04e0b5dc27ba6..e532c1b2cf7dd3 100644 --- a/web/context/web-app-context.tsx +++ b/web/context/web-app-context.tsx @@ -32,7 +32,7 @@ type WebAppStore = { updateEmbeddedConversationId: (conversationId: string | null) => void } -export const useWebAppStore = create<WebAppStore>(set => ({ +export const useWebAppStore = create<WebAppStore>((set) => ({ shareCode: null, updateShareCode: (shareCode: string | null) => set(() => ({ shareCode })), appInfo: null, @@ -53,36 +53,34 @@ export const useWebAppStore = create<WebAppStore>(set => ({ })) const getShareCodeFromRedirectUrl = (redirectUrl: string | null): string | null => { - if (!redirectUrl || redirectUrl.length === 0) - return null + if (!redirectUrl || redirectUrl.length === 0) return null try { const url = new URL(decodeURIComponent(redirectUrl), 'https://dify.local') return url.pathname.split('/').pop() || null - } - catch { + } catch { return null } } const getShareCodeFromPathname = (pathname: string): string | null => { const code = pathname.split('/').pop() || null - if (code === 'webapp-signin') - return null + if (code === 'webapp-signin') return null return code } const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => { const { isPending: isGlobalPending } = useQuery(systemFeaturesQueryOptions()) - const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode) - const updateShareCode = useWebAppStore(state => state.updateShareCode) - const updateEmbeddedUserId = useWebAppStore(state => state.updateEmbeddedUserId) - const updateEmbeddedConversationId = useWebAppStore(state => state.updateEmbeddedConversationId) + const updateWebAppAccessMode = useWebAppStore((state) => state.updateWebAppAccessMode) + const updateShareCode = useWebAppStore((state) => state.updateShareCode) + const updateEmbeddedUserId = useWebAppStore((state) => state.updateEmbeddedUserId) + const updateEmbeddedConversationId = useWebAppStore((state) => state.updateEmbeddedConversationId) const pathname = usePathname() const searchParams = useSearchParams() const redirectUrlParam = searchParams.get('redirect_url') const searchParamsString = searchParams.toString() // Compute shareCode directly - const shareCode = getShareCodeFromRedirectUrl(redirectUrlParam) || getShareCodeFromPathname(pathname) + const shareCode = + getShareCodeFromRedirectUrl(redirectUrlParam) || getShareCodeFromPathname(pathname) useEffect(() => { updateShareCode(shareCode) }, [shareCode, updateShareCode]) @@ -96,8 +94,7 @@ const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => { updateEmbeddedUserId(user_id || null) updateEmbeddedConversationId(conversation_id || null) } - } - catch { + } catch { if (!cancelled) { updateEmbeddedUserId(null) updateEmbeddedConversationId(null) @@ -113,8 +110,7 @@ const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => { const { isLoading, data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode) useEffect(() => { - if (accessModeResult?.accessMode) - updateWebAppAccessMode(accessModeResult.accessMode) + if (accessModeResult?.accessMode) updateWebAppAccessMode(accessModeResult.accessMode) }, [accessModeResult, updateWebAppAccessMode, shareCode]) if (isGlobalPending || isLoading) { @@ -124,10 +120,6 @@ const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => { </div> ) } - return ( - <> - {children} - </> - ) + return <>{children}</> } export default WebAppStoreProvider diff --git a/web/context/workspace-state.ts b/web/context/workspace-state.ts index 73462b35634282..a73b235a805a40 100644 --- a/web/context/workspace-state.ts +++ b/web/context/workspace-state.ts @@ -4,10 +4,7 @@ import { atom } from 'jotai' import { atomWithQuery, queryClientAtom } from 'jotai-tanstack-query' import { consoleQuery } from '@/service/client' import { initialWorkspaceInfo } from './app-context-defaults' -import { - getWorkspaceRoleFlags, - normalizeCurrentWorkspace, -} from './app-context-normalizers' +import { getWorkspaceRoleFlags, normalizeCurrentWorkspace } from './app-context-normalizers' const currentWorkspaceQueryAtom = atomWithQuery(() => { return consoleQuery.workspaces.current.post.queryOptions({ diff --git a/web/context/zendesk-conversation-sync.ts b/web/context/zendesk-conversation-sync.ts index 0d38888965f665..b43e29099220f8 100644 --- a/web/context/zendesk-conversation-sync.ts +++ b/web/context/zendesk-conversation-sync.ts @@ -28,13 +28,14 @@ function syncZendeskField({ setNextValue: (value: string) => void value: string }) { - if (!fieldId || !value || value === previousValue) - return false + if (!fieldId || !value || value === previousValue) return false - setZendeskConversationFields([{ - id: fieldId, - value, - }]) + setZendeskConversationFields([ + { + id: fieldId, + value, + }, + ]) setNextValue(value) return true @@ -48,39 +49,42 @@ export const zendeskConversationSyncAtom = atomEffect((get, set) => { const nextState = { ...state } let didSync = false - didSync = syncZendeskField({ - fieldId: ZENDESK_FIELD_IDS.ENVIRONMENT, - value: langGeniusVersionInfo.current_env.toLowerCase(), - previousValue: state.environment, - setNextValue: (value) => { - nextState.environment = value - }, - }) || didSync - didSync = syncZendeskField({ - fieldId: ZENDESK_FIELD_IDS.VERSION, - value: langGeniusVersionInfo.version, - previousValue: state.version, - setNextValue: (value) => { - nextState.version = value - }, - }) || didSync - didSync = syncZendeskField({ - fieldId: ZENDESK_FIELD_IDS.EMAIL, - value: userProfile.email, - previousValue: state.email, - setNextValue: (value) => { - nextState.email = value - }, - }) || didSync - didSync = syncZendeskField({ - fieldId: ZENDESK_FIELD_IDS.WORKSPACE_ID, - value: currentWorkspace.id, - previousValue: state.workspaceId, - setNextValue: (value) => { - nextState.workspaceId = value - }, - }) || didSync + didSync = + syncZendeskField({ + fieldId: ZENDESK_FIELD_IDS.ENVIRONMENT, + value: langGeniusVersionInfo.current_env.toLowerCase(), + previousValue: state.environment, + setNextValue: (value) => { + nextState.environment = value + }, + }) || didSync + didSync = + syncZendeskField({ + fieldId: ZENDESK_FIELD_IDS.VERSION, + value: langGeniusVersionInfo.version, + previousValue: state.version, + setNextValue: (value) => { + nextState.version = value + }, + }) || didSync + didSync = + syncZendeskField({ + fieldId: ZENDESK_FIELD_IDS.EMAIL, + value: userProfile.email, + previousValue: state.email, + setNextValue: (value) => { + nextState.email = value + }, + }) || didSync + didSync = + syncZendeskField({ + fieldId: ZENDESK_FIELD_IDS.WORKSPACE_ID, + value: currentWorkspace.id, + previousValue: state.workspaceId, + setNextValue: (value) => { + nextState.workspaceId = value + }, + }) || didSync - if (didSync) - set(zendeskConversationSyncStateAtom, nextState) + if (didSync) set(zendeskConversationSyncStateAtom, nextState) }) diff --git a/web/dev-proxy.config.ts b/web/dev-proxy.config.ts index bbd43f823037a5..c1b5215ff9ef2c 100644 --- a/web/dev-proxy.config.ts +++ b/web/dev-proxy.config.ts @@ -45,16 +45,12 @@ export default { cookieRewrite: difyCookieRewrite, }, { - paths: [ - '/console/api', - ], + paths: ['/console/api'], target: DEV_PROXY_TARGET, cookieRewrite: difyCookieRewrite, }, { - paths: [ - '/api', - ], + paths: ['/api'], target: DEV_PROXY_PUBLIC_TARGET, cookieRewrite: difyCookieRewrite, }, diff --git a/web/docs/test.md b/web/docs/test.md index 1f66f9fc9035d0..20d4469428241b 100644 --- a/web/docs/test.md +++ b/web/docs/test.md @@ -344,10 +344,12 @@ describe('ComponentName', () => { ```typescript import { createReactI18nextMock } from '@/test/i18n-mock' - vi.mock('react-i18next', () => createReactI18nextMock({ - 'my.custom.key': 'Custom translation', - 'button.save': 'Save', - })) + vi.mock('react-i18next', () => + createReactI18nextMock({ + 'my.custom.key': 'Custom translation', + 'button.save': 'Save', + }), + ) ``` **Avoid**: Manually defining `useTranslation` mocks that just return the key - the global mock already does this. @@ -367,8 +369,7 @@ vi.mock('external-overlay-library', () => ({ }, OverlayContent: ({ children }) => { // ✅ Matches actual: returns null when open is false - if (!mockOverlayOpenState) - return null + if (!mockOverlayOpenState) return null return <div>{children}</div> }, })) diff --git a/web/env.ts b/web/env.ts index bcfd55e8a00c0c..438f7d53378df8 100644 --- a/web/env.ts +++ b/web/env.ts @@ -8,9 +8,10 @@ import { ObjectFromEntries, ObjectKeys } from './utils/object' const CLIENT_ENV_PREFIX = 'NEXT_PUBLIC_' type ClientSchema = Record<`${typeof CLIENT_ENV_PREFIX}${string}`, z.ZodType> -const coercedBoolean = z.string() - .refine(s => s === 'true' || s === 'false' || s === '0' || s === '1') - .transform(s => s === 'true' || s === '1') +const coercedBoolean = z + .string() + .refine((s) => s === 'true' || s === 'false' || s === '0' || s === '1') + .transform((s) => s === 'true' || s === '1') const coercedNumber = z.coerce.number().int().positive() /// Keep keys sorted except grouped feature-specific blocks. @@ -41,7 +42,11 @@ const clientSchema = { /** * The base path for the application */ - NEXT_PUBLIC_BASE_PATH: z.string().regex(/^\/.*[^/]$/).or(z.literal('')).default(''), + NEXT_PUBLIC_BASE_PATH: z + .string() + .regex(/^\/.*[^/]$/) + .or(z.literal('')) + .default(''), /** * number of concurrency */ @@ -187,70 +192,186 @@ export const env = createEnv({ }, client: clientSchema, experimental__runtimeEnv: { - NEXT_PUBLIC_ALLOW_EMBED: isServer ? process.env.NEXT_PUBLIC_ALLOW_EMBED : getRuntimeEnvFromBody('allowEmbed'), - NEXT_PUBLIC_ALLOW_INLINE_STYLES: isServer ? process.env.NEXT_PUBLIC_ALLOW_INLINE_STYLES : getRuntimeEnvFromBody('allowInlineStyles'), - NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME: isServer ? process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME : getRuntimeEnvFromBody('allowUnsafeDataScheme'), - NEXT_PUBLIC_AMPLITUDE_API_KEY: isServer ? process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY : getRuntimeEnvFromBody('amplitudeApiKey'), - NEXT_PUBLIC_API_PREFIX: isServer ? process.env.NEXT_PUBLIC_API_PREFIX : getRuntimeEnvFromBody('apiPrefix'), - NEXT_PUBLIC_BASE_PATH: isServer ? process.env.NEXT_PUBLIC_BASE_PATH : getRuntimeEnvFromBody('basePath'), - NEXT_PUBLIC_BATCH_CONCURRENCY: isServer ? process.env.NEXT_PUBLIC_BATCH_CONCURRENCY : getRuntimeEnvFromBody('batchConcurrency'), - NEXT_PUBLIC_COOKIE_DOMAIN: isServer ? process.env.NEXT_PUBLIC_COOKIE_DOMAIN : getRuntimeEnvFromBody('cookieDomain'), - NEXT_PUBLIC_CSP_WHITELIST: isServer ? process.env.NEXT_PUBLIC_CSP_WHITELIST : getRuntimeEnvFromBody('cspWhitelist'), - NEXT_PUBLIC_DEPLOY_ENV: isServer ? process.env.NEXT_PUBLIC_DEPLOY_ENV : getRuntimeEnvFromBody('deployEnv'), - NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON: isServer ? process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON : getRuntimeEnvFromBody('disableUploadImageAsIcon'), - NEXT_PUBLIC_EDITION: isServer ? process.env.NEXT_PUBLIC_EDITION : getRuntimeEnvFromBody('edition'), - NEXT_PUBLIC_ENABLE_AGENT_V2: isServer ? process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 : getRuntimeEnvFromBody('enableAgentV2'), - NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW: isServer ? process.env.NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW : getRuntimeEnvFromBody('enableFeaturePreview'), + NEXT_PUBLIC_ALLOW_EMBED: isServer + ? process.env.NEXT_PUBLIC_ALLOW_EMBED + : getRuntimeEnvFromBody('allowEmbed'), + NEXT_PUBLIC_ALLOW_INLINE_STYLES: isServer + ? process.env.NEXT_PUBLIC_ALLOW_INLINE_STYLES + : getRuntimeEnvFromBody('allowInlineStyles'), + NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME: isServer + ? process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME + : getRuntimeEnvFromBody('allowUnsafeDataScheme'), + NEXT_PUBLIC_AMPLITUDE_API_KEY: isServer + ? process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY + : getRuntimeEnvFromBody('amplitudeApiKey'), + NEXT_PUBLIC_API_PREFIX: isServer + ? process.env.NEXT_PUBLIC_API_PREFIX + : getRuntimeEnvFromBody('apiPrefix'), + NEXT_PUBLIC_BASE_PATH: isServer + ? process.env.NEXT_PUBLIC_BASE_PATH + : getRuntimeEnvFromBody('basePath'), + NEXT_PUBLIC_BATCH_CONCURRENCY: isServer + ? process.env.NEXT_PUBLIC_BATCH_CONCURRENCY + : getRuntimeEnvFromBody('batchConcurrency'), + NEXT_PUBLIC_COOKIE_DOMAIN: isServer + ? process.env.NEXT_PUBLIC_COOKIE_DOMAIN + : getRuntimeEnvFromBody('cookieDomain'), + NEXT_PUBLIC_CSP_WHITELIST: isServer + ? process.env.NEXT_PUBLIC_CSP_WHITELIST + : getRuntimeEnvFromBody('cspWhitelist'), + NEXT_PUBLIC_DEPLOY_ENV: isServer + ? process.env.NEXT_PUBLIC_DEPLOY_ENV + : getRuntimeEnvFromBody('deployEnv'), + NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON: isServer + ? process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON + : getRuntimeEnvFromBody('disableUploadImageAsIcon'), + NEXT_PUBLIC_EDITION: isServer + ? process.env.NEXT_PUBLIC_EDITION + : getRuntimeEnvFromBody('edition'), + NEXT_PUBLIC_ENABLE_AGENT_V2: isServer + ? process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 + : getRuntimeEnvFromBody('enableAgentV2'), + NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW: isServer + ? process.env.NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW + : getRuntimeEnvFromBody('enableFeaturePreview'), /** * Cloud-only system-features defaults. * These values are only used when NEXT_PUBLIC_EDITION=CLOUD (IS_CLOUD_EDITION). */ - NEXT_PUBLIC_ENABLE_MARKETPLACE: isServer ? process.env.NEXT_PUBLIC_ENABLE_MARKETPLACE : getRuntimeEnvFromBody('enableMarketplace'), - NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN: isServer ? process.env.NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN : getRuntimeEnvFromBody('enableEmailCodeLogin'), - NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: isServer ? process.env.NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN : getRuntimeEnvFromBody('enableEmailPasswordLogin'), - NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN: isServer ? process.env.NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN : getRuntimeEnvFromBody('enableSocialOauthLogin'), - NEXT_PUBLIC_ENABLE_COLLABORATION_MODE: isServer ? process.env.NEXT_PUBLIC_ENABLE_COLLABORATION_MODE : getRuntimeEnvFromBody('enableCollaborationMode'), - NEXT_PUBLIC_ALLOW_REGISTER: isServer ? process.env.NEXT_PUBLIC_ALLOW_REGISTER : getRuntimeEnvFromBody('allowRegister'), - NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE: isServer ? process.env.NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE : getRuntimeEnvFromBody('allowCreateWorkspace'), - NEXT_PUBLIC_IS_EMAIL_SETUP: isServer ? process.env.NEXT_PUBLIC_IS_EMAIL_SETUP : getRuntimeEnvFromBody('isEmailSetup'), - NEXT_PUBLIC_ENABLE_CHANGE_EMAIL: isServer ? process.env.NEXT_PUBLIC_ENABLE_CHANGE_EMAIL : getRuntimeEnvFromBody('enableChangeEmail'), - NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: isServer ? process.env.NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED : getRuntimeEnvFromBody('creatorsPlatformFeaturesEnabled'), - NEXT_PUBLIC_ENABLE_TRIAL_APP: isServer ? process.env.NEXT_PUBLIC_ENABLE_TRIAL_APP : getRuntimeEnvFromBody('enableTrialApp'), - NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: isServer ? process.env.NEXT_PUBLIC_ENABLE_EXPLORE_BANNER : getRuntimeEnvFromBody('enableExploreBanner'), - NEXT_PUBLIC_ENABLE_LEARN_APP: isServer ? process.env.NEXT_PUBLIC_ENABLE_LEARN_APP : getRuntimeEnvFromBody('enableLearnApp'), - NEXT_PUBLIC_RBAC_ENABLED: isServer ? process.env.NEXT_PUBLIC_RBAC_ENABLED : getRuntimeEnvFromBody('rbacEnabled'), + NEXT_PUBLIC_ENABLE_MARKETPLACE: isServer + ? process.env.NEXT_PUBLIC_ENABLE_MARKETPLACE + : getRuntimeEnvFromBody('enableMarketplace'), + NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN: isServer + ? process.env.NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN + : getRuntimeEnvFromBody('enableEmailCodeLogin'), + NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: isServer + ? process.env.NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN + : getRuntimeEnvFromBody('enableEmailPasswordLogin'), + NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN: isServer + ? process.env.NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN + : getRuntimeEnvFromBody('enableSocialOauthLogin'), + NEXT_PUBLIC_ENABLE_COLLABORATION_MODE: isServer + ? process.env.NEXT_PUBLIC_ENABLE_COLLABORATION_MODE + : getRuntimeEnvFromBody('enableCollaborationMode'), + NEXT_PUBLIC_ALLOW_REGISTER: isServer + ? process.env.NEXT_PUBLIC_ALLOW_REGISTER + : getRuntimeEnvFromBody('allowRegister'), + NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE: isServer + ? process.env.NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE + : getRuntimeEnvFromBody('allowCreateWorkspace'), + NEXT_PUBLIC_IS_EMAIL_SETUP: isServer + ? process.env.NEXT_PUBLIC_IS_EMAIL_SETUP + : getRuntimeEnvFromBody('isEmailSetup'), + NEXT_PUBLIC_ENABLE_CHANGE_EMAIL: isServer + ? process.env.NEXT_PUBLIC_ENABLE_CHANGE_EMAIL + : getRuntimeEnvFromBody('enableChangeEmail'), + NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: isServer + ? process.env.NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED + : getRuntimeEnvFromBody('creatorsPlatformFeaturesEnabled'), + NEXT_PUBLIC_ENABLE_TRIAL_APP: isServer + ? process.env.NEXT_PUBLIC_ENABLE_TRIAL_APP + : getRuntimeEnvFromBody('enableTrialApp'), + NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: isServer + ? process.env.NEXT_PUBLIC_ENABLE_EXPLORE_BANNER + : getRuntimeEnvFromBody('enableExploreBanner'), + NEXT_PUBLIC_ENABLE_LEARN_APP: isServer + ? process.env.NEXT_PUBLIC_ENABLE_LEARN_APP + : getRuntimeEnvFromBody('enableLearnApp'), + NEXT_PUBLIC_RBAC_ENABLED: isServer + ? process.env.NEXT_PUBLIC_RBAC_ENABLED + : getRuntimeEnvFromBody('rbacEnabled'), - NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: isServer ? process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX : getRuntimeEnvFromBody('enableSingleDollarLatex'), - NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL : getRuntimeEnvFromBody('enableWebsiteFirecrawl'), - NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER : getRuntimeEnvFromBody('enableWebsiteJinareader'), - NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL : getRuntimeEnvFromBody('enableWebsiteWatercrawl'), - NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: isServer ? process.env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH : getRuntimeEnvFromBody('indexingMaxSegmentationTokensLength'), - NEXT_PUBLIC_IS_MARKETPLACE: isServer ? process.env.NEXT_PUBLIC_IS_MARKETPLACE : getRuntimeEnvFromBody('isMarketplace'), - NEXT_PUBLIC_LOOP_NODE_MAX_COUNT: isServer ? process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT : getRuntimeEnvFromBody('loopNodeMaxCount'), - NEXT_PUBLIC_MAINTENANCE_NOTICE: isServer ? process.env.NEXT_PUBLIC_MAINTENANCE_NOTICE : getRuntimeEnvFromBody('maintenanceNotice'), - NEXT_PUBLIC_MARKETPLACE_API_PREFIX: isServer ? process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX : getRuntimeEnvFromBody('marketplaceApiPrefix'), - NEXT_PUBLIC_MARKETPLACE_URL_PREFIX: isServer ? process.env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX : getRuntimeEnvFromBody('marketplaceUrlPrefix'), - NEXT_PUBLIC_MAX_ITERATIONS_NUM: isServer ? process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM : getRuntimeEnvFromBody('maxIterationsNum'), - NEXT_PUBLIC_MAX_PARALLEL_LIMIT: isServer ? process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT : getRuntimeEnvFromBody('maxParallelLimit'), - NEXT_PUBLIC_MAX_TOOLS_NUM: isServer ? process.env.NEXT_PUBLIC_MAX_TOOLS_NUM : getRuntimeEnvFromBody('maxToolsNum'), - NEXT_PUBLIC_MAX_TREE_DEPTH: isServer ? process.env.NEXT_PUBLIC_MAX_TREE_DEPTH : getRuntimeEnvFromBody('maxTreeDepth'), - NEXT_PUBLIC_PUBLIC_API_PREFIX: isServer ? process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX : getRuntimeEnvFromBody('publicApiPrefix'), - NEXT_PUBLIC_SENTRY_DSN: isServer ? process.env.NEXT_PUBLIC_SENTRY_DSN : getRuntimeEnvFromBody('sentryDsn'), - NEXT_PUBLIC_SITE_ABOUT: isServer ? process.env.NEXT_PUBLIC_SITE_ABOUT : getRuntimeEnvFromBody('siteAbout'), - NEXT_PUBLIC_SOCKET_URL: isServer ? process.env.NEXT_PUBLIC_SOCKET_URL : getRuntimeEnvFromBody('socketUrl'), - NEXT_PUBLIC_SUPPORT_EMAIL_ADDRESS: isServer ? process.env.NEXT_PUBLIC_SUPPORT_EMAIL_ADDRESS : getRuntimeEnvFromBody('supportEmailAddress'), - NEXT_PUBLIC_SUPPORT_MAIL_LOGIN: isServer ? process.env.NEXT_PUBLIC_SUPPORT_MAIL_LOGIN : getRuntimeEnvFromBody('supportMailLogin'), - NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS: isServer ? process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS : getRuntimeEnvFromBody('textGenerationTimeoutMs'), - NEXT_PUBLIC_TOP_K_MAX_VALUE: isServer ? process.env.NEXT_PUBLIC_TOP_K_MAX_VALUE : getRuntimeEnvFromBody('topKMaxValue'), - NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON: isServer ? process.env.NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON : getRuntimeEnvFromBody('uploadImageAsIcon'), - NEXT_PUBLIC_WEB_PREFIX: isServer ? process.env.NEXT_PUBLIC_WEB_PREFIX : getRuntimeEnvFromBody('webPrefix'), - NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL : getRuntimeEnvFromBody('zendeskFieldIdEmail'), - NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT : getRuntimeEnvFromBody('zendeskFieldIdEnvironment'), - NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN : getRuntimeEnvFromBody('zendeskFieldIdPlan'), - NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION : getRuntimeEnvFromBody('zendeskFieldIdVersion'), - NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID : getRuntimeEnvFromBody('zendeskFieldIdWorkspaceId'), - NEXT_PUBLIC_ZENDESK_WIDGET_KEY: isServer ? process.env.NEXT_PUBLIC_ZENDESK_WIDGET_KEY : getRuntimeEnvFromBody('zendeskWidgetKey'), + NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: isServer + ? process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX + : getRuntimeEnvFromBody('enableSingleDollarLatex'), + NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL: isServer + ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL + : getRuntimeEnvFromBody('enableWebsiteFirecrawl'), + NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER: isServer + ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER + : getRuntimeEnvFromBody('enableWebsiteJinareader'), + NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL: isServer + ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL + : getRuntimeEnvFromBody('enableWebsiteWatercrawl'), + NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: isServer + ? process.env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH + : getRuntimeEnvFromBody('indexingMaxSegmentationTokensLength'), + NEXT_PUBLIC_IS_MARKETPLACE: isServer + ? process.env.NEXT_PUBLIC_IS_MARKETPLACE + : getRuntimeEnvFromBody('isMarketplace'), + NEXT_PUBLIC_LOOP_NODE_MAX_COUNT: isServer + ? process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT + : getRuntimeEnvFromBody('loopNodeMaxCount'), + NEXT_PUBLIC_MAINTENANCE_NOTICE: isServer + ? process.env.NEXT_PUBLIC_MAINTENANCE_NOTICE + : getRuntimeEnvFromBody('maintenanceNotice'), + NEXT_PUBLIC_MARKETPLACE_API_PREFIX: isServer + ? process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX + : getRuntimeEnvFromBody('marketplaceApiPrefix'), + NEXT_PUBLIC_MARKETPLACE_URL_PREFIX: isServer + ? process.env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX + : getRuntimeEnvFromBody('marketplaceUrlPrefix'), + NEXT_PUBLIC_MAX_ITERATIONS_NUM: isServer + ? process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM + : getRuntimeEnvFromBody('maxIterationsNum'), + NEXT_PUBLIC_MAX_PARALLEL_LIMIT: isServer + ? process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT + : getRuntimeEnvFromBody('maxParallelLimit'), + NEXT_PUBLIC_MAX_TOOLS_NUM: isServer + ? process.env.NEXT_PUBLIC_MAX_TOOLS_NUM + : getRuntimeEnvFromBody('maxToolsNum'), + NEXT_PUBLIC_MAX_TREE_DEPTH: isServer + ? process.env.NEXT_PUBLIC_MAX_TREE_DEPTH + : getRuntimeEnvFromBody('maxTreeDepth'), + NEXT_PUBLIC_PUBLIC_API_PREFIX: isServer + ? process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX + : getRuntimeEnvFromBody('publicApiPrefix'), + NEXT_PUBLIC_SENTRY_DSN: isServer + ? process.env.NEXT_PUBLIC_SENTRY_DSN + : getRuntimeEnvFromBody('sentryDsn'), + NEXT_PUBLIC_SITE_ABOUT: isServer + ? process.env.NEXT_PUBLIC_SITE_ABOUT + : getRuntimeEnvFromBody('siteAbout'), + NEXT_PUBLIC_SOCKET_URL: isServer + ? process.env.NEXT_PUBLIC_SOCKET_URL + : getRuntimeEnvFromBody('socketUrl'), + NEXT_PUBLIC_SUPPORT_EMAIL_ADDRESS: isServer + ? process.env.NEXT_PUBLIC_SUPPORT_EMAIL_ADDRESS + : getRuntimeEnvFromBody('supportEmailAddress'), + NEXT_PUBLIC_SUPPORT_MAIL_LOGIN: isServer + ? process.env.NEXT_PUBLIC_SUPPORT_MAIL_LOGIN + : getRuntimeEnvFromBody('supportMailLogin'), + NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS: isServer + ? process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS + : getRuntimeEnvFromBody('textGenerationTimeoutMs'), + NEXT_PUBLIC_TOP_K_MAX_VALUE: isServer + ? process.env.NEXT_PUBLIC_TOP_K_MAX_VALUE + : getRuntimeEnvFromBody('topKMaxValue'), + NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON: isServer + ? process.env.NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON + : getRuntimeEnvFromBody('uploadImageAsIcon'), + NEXT_PUBLIC_WEB_PREFIX: isServer + ? process.env.NEXT_PUBLIC_WEB_PREFIX + : getRuntimeEnvFromBody('webPrefix'), + NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL: isServer + ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL + : getRuntimeEnvFromBody('zendeskFieldIdEmail'), + NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT: isServer + ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT + : getRuntimeEnvFromBody('zendeskFieldIdEnvironment'), + NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN: isServer + ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN + : getRuntimeEnvFromBody('zendeskFieldIdPlan'), + NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION: isServer + ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION + : getRuntimeEnvFromBody('zendeskFieldIdVersion'), + NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID: isServer + ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID + : getRuntimeEnvFromBody('zendeskFieldIdWorkspaceId'), + NEXT_PUBLIC_ZENDESK_WIDGET_KEY: isServer + ? process.env.NEXT_PUBLIC_ZENDESK_WIDGET_KEY + : getRuntimeEnvFromBody('zendeskWidgetKey'), }, emptyStringAsUndefined: true, }) @@ -283,10 +404,6 @@ export function getDatasetMap() { throw new TypeError('getDatasetMap can only be called on the server') } return ObjectFromEntries( - ObjectKeys(clientSchema) - .map(envKey => [ - getDatasetAttributeName(envKey), - env[envKey], - ]), + ObjectKeys(clientSchema).map((envKey) => [getDatasetAttributeName(envKey), env[envKey]]), ) } diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index b6180b932c65ca..347ae01a4fbe48 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -32,82 +32,56 @@ const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [ const BASE_UI_RESTRICTED_IMPORT_PATTERNS = [ { - group: [ - '@base-ui/react', - '@base-ui/react/*', - ], + group: ['@base-ui/react', '@base-ui/react/*'], message: 'Do not import Base UI directly in web. Use @langgenius/dify-ui/* primitives instead.', }, ] const FLOATING_UI_RESTRICTED_IMPORT_PATTERNS = [ { - group: [ - '@floating-ui/*', - ], - message: 'Do not import Floating UI directly in web. Use @langgenius/dify-ui/* primitives instead.', + group: ['@floating-ui/*'], + message: + 'Do not import Floating UI directly in web. Use @langgenius/dify-ui/* primitives instead.', }, ] const LEGACY_WEB_INPUT_RESTRICTED_IMPORT_PATTERNS = [ { - group: [ - '**/base/input', - '**/base/input/*', - ], - message: 'Do not import the deprecated web base Input. Use @langgenius/dify-ui/input for standalone inputs, and @langgenius/dify-ui/field for labelled or validated form composition.', + group: ['**/base/input', '**/base/input/*'], + message: + 'Do not import the deprecated web base Input. Use @langgenius/dify-ui/input for standalone inputs, and @langgenius/dify-ui/field for labelled or validated form composition.', }, ] const LEGACY_SERVICE_BASE_RESTRICTED_IMPORT_PATTERNS = [ { - group: [ - '@/service/base', - '@/service/base/*', - '**/service/base', - '**/service/base/*', - ], - message: 'Do not import legacy service/base fetch helpers. Use generated service clients or feature-specific service modules instead.', + group: ['@/service/base', '@/service/base/*', '**/service/base', '**/service/base/*'], + message: + 'Do not import legacy service/base fetch helpers. Use generated service clients or feature-specific service modules instead.', }, ] const LEGACY_SERVICE_FETCH_RESTRICTED_IMPORT_PATTERNS = [ { - group: [ - '@/service/fetch', - '@/service/fetch/*', - '**/service/fetch', - '**/service/fetch/*', - ], - message: 'Do not import low-level service/fetch helpers directly. Use generated service clients or feature-specific service modules instead.', + group: ['@/service/fetch', '@/service/fetch/*', '**/service/fetch', '**/service/fetch/*'], + message: + 'Do not import low-level service/fetch helpers directly. Use generated service clients or feature-specific service modules instead.', }, ] export const WEB_SERVICE_BASE_RESTRICTED_IMPORT_PATTERNS = [ { - group: [ - './base', - './base/*', - '../base', - '../base/*', - '../../base', - '../../base/*', - ], - message: 'Do not import legacy service/base fetch helpers. Use generated service clients or feature-specific service modules instead.', + group: ['./base', './base/*', '../base', '../base/*', '../../base', '../../base/*'], + message: + 'Do not import legacy service/base fetch helpers. Use generated service clients or feature-specific service modules instead.', }, ] export const WEB_SERVICE_FETCH_RESTRICTED_IMPORT_PATTERNS = [ { - group: [ - './fetch', - './fetch/*', - '../fetch', - '../fetch/*', - '../../fetch', - '../../fetch/*', - ], - message: 'Do not import low-level service/fetch helpers directly. Use generated service clients or feature-specific service modules instead.', + group: ['./fetch', './fetch/*', '../fetch', '../fetch/*', '../../fetch', '../../fetch/*'], + message: + 'Do not import low-level service/fetch helpers directly. Use generated service clients or feature-specific service modules instead.', }, ] diff --git a/web/features/account-profile/__tests__/server.spec.ts b/web/features/account-profile/__tests__/server.spec.ts index 0f45e5263cfabe..7650317b1d7cf5 100644 --- a/web/features/account-profile/__tests__/server.spec.ts +++ b/web/features/account-profile/__tests__/server.spec.ts @@ -18,7 +18,9 @@ vi.mock('@/next/headers', () => ({ cookies: () => cookiesMock(), })) -const createProfile = (overrides: Partial<GetAccountProfileResponse> = {}): GetAccountProfileResponse => ({ +const createProfile = ( + overrides: Partial<GetAccountProfileResponse> = {}, +): GetAccountProfileResponse => ({ id: 'account-id', name: 'Dify User', email: 'user@example.com', @@ -38,14 +40,16 @@ describe('serverUserProfileQueryOptions', () => { }) it('should reuse the client profile query key and return the same data shape', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(createProfile()), { - status: 200, - headers: { - 'content-type': 'application/json', - 'x-version': '1.2.3', - 'x-env': 'DEVELOPMENT', - }, - })) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(createProfile()), { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-version': '1.2.3', + 'x-env': 'DEVELOPMENT', + }, + }), + ) vi.stubGlobal('fetch', fetchMock) const { serverUserProfileQueryOptions } = await import('../server') const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) @@ -72,10 +76,22 @@ describe('serverUserProfileQueryOptions', () => { it('should skip relative API prefixes unless a server API origin is configured', () => { expect(resolveServerConsoleApiUrl('/account/profile', undefined, '/console/api')).toBeNull() - expect(resolveServerConsoleApiUrl('/account/profile', 'https://console.example.com/console/api', '/console/api')).toBe('https://console.example.com/console/api/account/profile') + expect( + resolveServerConsoleApiUrl( + '/account/profile', + 'https://console.example.com/console/api', + '/console/api', + ), + ).toBe('https://console.example.com/console/api/account/profile') }) it('should preserve absolute API prefixes', () => { - expect(resolveServerConsoleApiUrl('/account/profile', undefined, 'https://console.example.com/console/api')).toBe('https://console.example.com/console/api/account/profile') + expect( + resolveServerConsoleApiUrl( + '/account/profile', + undefined, + 'https://console.example.com/console/api', + ), + ).toBe('https://console.example.com/console/api/account/profile') }) }) diff --git a/web/features/account-profile/client.ts b/web/features/account-profile/client.ts index fd56d8827bc8fe..a97707028752f0 100644 --- a/web/features/account-profile/client.ts +++ b/web/features/account-profile/client.ts @@ -20,18 +20,20 @@ export const userProfileQueryOptions = () => queryOptions<UserProfileWithMeta>({ queryKey: consoleQuery.account.profile.get.queryKey(), queryFn: async () => { - const response = await get<Response>('/account/profile', {}, { - needAllResponseContent: true, - silent: true, - }) + const response = await get<Response>( + '/account/profile', + {}, + { + needAllResponseContent: true, + silent: true, + }, + ) const profile: GetAccountProfileResponse = await response.clone().json() return { profile, meta: { currentVersion: response.headers.get('x-version'), - currentEnv: IS_DEV - ? 'DEVELOPMENT' - : response.headers.get('x-env'), + currentEnv: IS_DEV ? 'DEVELOPMENT' : response.headers.get('x-env'), }, } }, diff --git a/web/features/account-profile/server.ts b/web/features/account-profile/server.ts index 90aba7cc5a0f7a..49f279f34a66ae 100644 --- a/web/features/account-profile/server.ts +++ b/web/features/account-profile/server.ts @@ -1,7 +1,11 @@ import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' import type { UserProfileWithMeta } from './client' import { queryOptions } from '@tanstack/react-query' -import { getServerConsoleRequestHeaders, resolveServerConsoleApiUrl, serverConsoleQuery } from '@/service/server' +import { + getServerConsoleRequestHeaders, + resolveServerConsoleApiUrl, + serverConsoleQuery, +} from '@/service/server' const ACCOUNT_PROFILE_PATH = '/account/profile' @@ -10,8 +14,7 @@ export const serverUserProfileQueryOptions = () => queryKey: serverConsoleQuery.account.profile.get.queryKey(), queryFn: async () => { const profileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH) - if (!profileUrl) - throw new Error('Server account profile URL is not configured') + if (!profileUrl) throw new Error('Server account profile URL is not configured') const response = await fetch(profileUrl, { method: 'GET', @@ -19,8 +22,7 @@ export const serverUserProfileQueryOptions = () => cache: 'no-store', }) - if (!response.ok) - throw response + if (!response.ok) throw response const profile: GetAccountProfileResponse = await response.clone().json() return { diff --git a/web/features/agent-v2/agent-composer/__tests__/knowledge-validation.spec.ts b/web/features/agent-v2/agent-composer/__tests__/knowledge-validation.spec.ts index 1665c1c89cbe70..d97d0e42cf7c02 100644 --- a/web/features/agent-v2/agent-composer/__tests__/knowledge-validation.spec.ts +++ b/web/features/agent-v2/agent-composer/__tests__/knowledge-validation.spec.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { LogicalOperator, MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' +import { + LogicalOperator, + MetadataFilteringModeEnum, +} from '@/app/components/workflow/nodes/knowledge-retrieval/types' import { RETRIEVE_TYPE } from '@/types/app' import { validateKnowledgeRetrievals } from '../knowledge-validation' diff --git a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts index 480c0d115ea17a..46e56169d7b690 100644 --- a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts +++ b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts @@ -32,7 +32,9 @@ describe('agent composer store conversions', () => { expect(store.get(agentComposerDraftAtom).prompt).toBe('Build draft prompt') expect(store.get(agentComposerOriginalDraftAtom)?.prompt).toBe('Build draft prompt') expect(store.get(agentComposerPublishedDraftAtom)?.prompt).toBe('Build draft prompt') - expect(store.get(agentComposerOriginalConfigAtom)?.prompt?.system_prompt).toBe('Build draft prompt') + expect(store.get(agentComposerOriginalConfigAtom)?.prompt?.system_prompt).toBe( + 'Build draft prompt', + ) }) it('should hydrate editable form state from an AgentSoulConfig and preserve it in the config snapshot', () => { @@ -203,22 +205,24 @@ describe('agent composer store conversions', () => { }), ], }) - expect(formState.tools).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'duckduckgo', - kind: 'provider', - actions: [ - expect.objectContaining({ - toolName: 'ddg_search', - }), - ], - }), - expect.objectContaining({ - id: 'run-tests', - kind: 'cli', - installCommand: 'pnpm install', - }), - ])) + expect(formState.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'duckduckgo', + kind: 'provider', + actions: [ + expect.objectContaining({ + toolName: 'ddg_search', + }), + ], + }), + expect.objectContaining({ + id: 'run-tests', + kind: 'cli', + installCommand: 'pnpm install', + }), + ]), + ) expect(formState.toolSettings['duckduckgo:ddg_search']).toEqual({ query: 'latest docs', used_in_agent_nodes: true, diff --git a/web/features/agent-v2/agent-composer/conversions.ts b/web/features/agent-v2/agent-composer/conversions.ts index 046f4962a2b021..dad3a9d1073015 100644 --- a/web/features/agent-v2/agent-composer/conversions.ts +++ b/web/features/agent-v2/agent-composer/conversions.ts @@ -32,14 +32,22 @@ import { checkKey } from '@/utils/var' import { defaultAgentSoulConfigFormState } from './form-state' import { getKnowledgeRetrievalSetName } from './knowledge-validation' -type AgentSoulDifyToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['dify_tools']>[number] -type AgentSoulCliToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['cli_tools']>[number] -type AgentSoulToolRuntimeParameterValue = NonNullable<AgentSoulDifyToolConfig['runtime_parameters']>[string] -type AgentSoulEnvVariableConfig = NonNullable<NonNullable<AgentSoulConfig['env']>['variables']>[number] +type AgentSoulDifyToolConfig = NonNullable< + NonNullable<AgentSoulConfig['tools']>['dify_tools'] +>[number] +type AgentSoulCliToolConfig = NonNullable< + NonNullable<AgentSoulConfig['tools']>['cli_tools'] +>[number] +type AgentSoulToolRuntimeParameterValue = NonNullable< + AgentSoulDifyToolConfig['runtime_parameters'] +>[string] +type AgentSoulEnvVariableConfig = NonNullable< + NonNullable<AgentSoulConfig['env']>['variables'] +>[number] const toKnowledgeDatasetRefs = (item: AgentKnowledgeRetrievalItem) => { if (item.selectedDatasets !== undefined) { - return item.selectedDatasets.map(dataset => ({ + return item.selectedDatasets.map((dataset) => ({ description: dataset.description, id: dataset.id, name: dataset.name, @@ -70,8 +78,7 @@ const toRetrievalConfig = (item: AgentKnowledgeRetrievalItem): AgentKnowledgeRet } const toModelFormState = (model?: AgentKnowledgeModelConfig | null): ModelConfig | undefined => { - if (!model) - return undefined + if (!model) return undefined return { provider: model.provider, @@ -81,7 +88,9 @@ const toModelFormState = (model?: AgentKnowledgeModelConfig | null): ModelConfig } } -const toMultipleRetrievalFormState = (config?: AgentKnowledgeRetrievalConfig): MultipleRetrievalConfig => ({ +const toMultipleRetrievalFormState = ( + config?: AgentKnowledgeRetrievalConfig, +): MultipleRetrievalConfig => ({ top_k: config?.top_k ?? DATASET_DEFAULT.top_k, score_threshold: config?.score_threshold ?? null, reranking_model: config?.reranking_model ?? undefined, @@ -90,52 +99,64 @@ const toMultipleRetrievalFormState = (config?: AgentKnowledgeRetrievalConfig): M reranking_enable: config?.reranking_enable ?? false, }) -const toSingleRetrievalFormState = (config?: AgentKnowledgeRetrievalConfig): SingleRetrievalConfig | undefined => ( +const toSingleRetrievalFormState = ( + config?: AgentKnowledgeRetrievalConfig, +): SingleRetrievalConfig | undefined => config?.model ? { model: toModelFormState(config.model)!, } : undefined -) -const toMetadataFilteringConfig = (item: AgentKnowledgeRetrievalItem): AgentKnowledgeSetConfig['metadata_filtering'] => { +const toMetadataFilteringConfig = ( + item: AgentKnowledgeRetrievalItem, +): AgentKnowledgeSetConfig['metadata_filtering'] => { const mode = item.metadataFilterMode ?? MetadataFilteringModeEnum.disabled return { mode, - model_config: mode === MetadataFilteringModeEnum.automatic ? item.metadataModelConfig : undefined, - conditions: mode === MetadataFilteringModeEnum.manual - ? item.metadataFilteringConditions as AgentKnowledgeMetadataConditions | undefined - : undefined, + model_config: + mode === MetadataFilteringModeEnum.automatic ? item.metadataModelConfig : undefined, + conditions: + mode === MetadataFilteringModeEnum.manual + ? (item.metadataFilteringConditions as AgentKnowledgeMetadataConditions | undefined) + : undefined, } } -const toKnowledgeSets = (knowledgeRetrievals: AgentKnowledgeRetrievalItem[]): AgentKnowledgeSetConfig[] => knowledgeRetrievals.map(item => ({ - id: item.id, - name: getKnowledgeRetrievalSetName(item), - description: item.description, - datasets: toKnowledgeDatasetRefs(item), - query: { - mode: item.queryMode === 'custom' ? ('user_query' as const) : ('generated_query' as const), - value: item.queryMode === 'custom' ? (item.customQuery?.trim() || undefined) : undefined, - }, - retrieval: toRetrievalConfig(item), - metadata_filtering: toMetadataFilteringConfig(item), -})) +const toKnowledgeSets = ( + knowledgeRetrievals: AgentKnowledgeRetrievalItem[], +): AgentKnowledgeSetConfig[] => + knowledgeRetrievals.map((item) => ({ + id: item.id, + name: getKnowledgeRetrievalSetName(item), + description: item.description, + datasets: toKnowledgeDatasetRefs(item), + query: { + mode: item.queryMode === 'custom' ? ('user_query' as const) : ('generated_query' as const), + value: item.queryMode === 'custom' ? item.customQuery?.trim() || undefined : undefined, + }, + retrieval: toRetrievalConfig(item), + metadata_filtering: toMetadataFilteringConfig(item), + })) const toKnowledgeRetrievalFormState = (config?: AgentSoulConfig): AgentKnowledgeRetrievalItem[] => { - return (config?.knowledge?.sets ?? []).map(knowledgeSet => ({ + return (config?.knowledge?.sets ?? []).map((knowledgeSet) => ({ id: knowledgeSet.id, name: knowledgeSet.name, description: knowledgeSet.description ?? undefined, queryMode: knowledgeSet.query.mode === 'user_query' ? 'custom' : 'agent', customQuery: knowledgeSet.query.value ?? undefined, datasetRefs: knowledgeSet.datasets, - retrievalMode: knowledgeSet.retrieval.mode === 'single' ? RETRIEVE_TYPE.oneWay : RETRIEVE_TYPE.multiWay, + retrievalMode: + knowledgeSet.retrieval.mode === 'single' ? RETRIEVE_TYPE.oneWay : RETRIEVE_TYPE.multiWay, multipleRetrievalConfig: toMultipleRetrievalFormState(knowledgeSet.retrieval), singleRetrievalConfig: toSingleRetrievalFormState(knowledgeSet.retrieval), - metadataFilterMode: (knowledgeSet.metadata_filtering?.mode ?? MetadataFilteringModeEnum.disabled) as MetadataFilteringModeEnum, - metadataFilteringConditions: knowledgeSet.metadata_filtering?.conditions as MetadataFilteringConditions | undefined, + metadataFilterMode: (knowledgeSet.metadata_filtering?.mode ?? + MetadataFilteringModeEnum.disabled) as MetadataFilteringModeEnum, + metadataFilteringConditions: knowledgeSet.metadata_filtering?.conditions as + | MetadataFilteringConditions + | undefined, metadataModelConfig: toModelFormState(knowledgeSet.metadata_filtering?.model_config), })) } @@ -146,53 +167,59 @@ const toKnowledgeConfig = ( sets: toKnowledgeSets(knowledgeRetrievals), }) -const isToolRuntimeParameterValue = (value: unknown): value is AgentSoulToolRuntimeParameterValue => { - if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') +const isToolRuntimeParameterValue = ( + value: unknown, +): value is AgentSoulToolRuntimeParameterValue => { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) return true - if (!Array.isArray(value)) - return false + if (!Array.isArray(value)) return false - return value.every(item => typeof item === 'string') - || value.every(item => typeof item === 'number') - || value.every(item => typeof item === 'boolean') + return ( + value.every((item) => typeof item === 'string') || + value.every((item) => typeof item === 'number') || + value.every((item) => typeof item === 'boolean') + ) } const toToolRuntimeParameters = (settings: Record<string, unknown> | undefined) => { const runtimeParameters: Record<string, AgentSoulToolRuntimeParameterValue> = {} Object.entries(settings ?? {}).forEach(([key, value]) => { - if (isToolRuntimeParameterValue(value)) - runtimeParameters[key] = value + if (isToolRuntimeParameterValue(value)) runtimeParameters[key] = value }) return runtimeParameters } -const getDifyToolActionId = (tool: AgentSoulDifyToolConfig) => `${tool.provider_id ?? tool.provider ?? tool.plugin_id ?? 'provider'}:${tool.tool_name ?? tool.name ?? 'tool'}` +const getDifyToolActionId = (tool: AgentSoulDifyToolConfig) => + `${tool.provider_id ?? tool.provider ?? tool.plugin_id ?? 'provider'}:${tool.tool_name ?? tool.name ?? 'tool'}` const toCredentialVariant = (tool: AgentSoulDifyToolConfig) => { const credentialType = tool.credential_type - if (credentialType === 'api-key') - return 'authorized' as const + if (credentialType === 'api-key') return 'authorized' as const if (credentialType === 'oauth2') - return tool.credential_ref?.id ? 'authorized' as const : 'unauthorized' as const + return tool.credential_ref?.id ? ('authorized' as const) : ('unauthorized' as const) - if (credentialType === 'unauthorized') - return 'unauthorized' as const + if (credentialType === 'unauthorized') return 'unauthorized' as const - if (tool.credential_ref?.id) - return 'authorized' as const + if (tool.credential_ref?.id) return 'authorized' as const - if (credentialType === 'api-key' || credentialType === 'oauth2') - return 'unauthorized' as const + if (credentialType === 'api-key' || credentialType === 'oauth2') return 'unauthorized' as const return 'none' as const } -const toProviderToolFormState = (config?: AgentSoulConfig): { +const toProviderToolFormState = ( + config?: AgentSoulConfig, +): { tools: AgentProviderTool[] toolSettings: AgentSoulConfigFormState['toolSettings'] } => { @@ -202,8 +229,7 @@ const toProviderToolFormState = (config?: AgentSoulConfig): { for (const tool of config?.tools?.dify_tools ?? []) { const providerId = tool.provider_id ?? tool.provider ?? tool.plugin_id ?? '' const toolName = tool.tool_name ?? tool.name ?? '' - if (!providerId || !toolName) - continue + if (!providerId || !toolName) continue const actionId = getDifyToolActionId(tool) const existingTool = toolByProviderId.get(providerId) @@ -227,11 +253,15 @@ const toProviderToolFormState = (config?: AgentSoulConfig): { kind: 'provider', iconClassName: 'i-custom-public-other-default-tool-icon text-text-tertiary', providerType: tool.provider_type, - allowDelete: tool.credential_type === 'api-key' || tool.credential_type === 'oauth2' || tool.credential_type === 'unauthorized', + allowDelete: + tool.credential_type === 'api-key' || + tool.credential_type === 'oauth2' || + tool.credential_type === 'unauthorized', credentialId: tool.credential_ref?.id ?? undefined, - credentialKey: tool.credential_type === 'api-key' || tool.credential_type === 'oauth2' - ? 'agentDetail.configure.tools.credential.authOne' - : undefined, + credentialKey: + tool.credential_type === 'api-key' || tool.credential_type === 'oauth2' + ? 'agentDetail.configure.tools.credential.authOne' + : undefined, credentialType: tool.credential_type, credentialVariant: toCredentialVariant(tool), actions: [action], @@ -247,222 +277,226 @@ const toProviderToolFormState = (config?: AgentSoulConfig): { const toDifyToolConfigs = ( tools: AgentTool[], toolSettings: Record<string, Record<string, unknown>>, -) => tools.flatMap((tool) => { - if (tool.kind !== 'provider') - return [] - - const credentialType = tool.credentialId - ? tool.credentialType ?? 'api-key' - : 'unauthorized' - - return tool.actions.map(action => ({ - enabled: true, - provider: tool.name, - provider_id: tool.id, - provider_type: tool.providerType ?? 'builtin', - tool_name: action.toolName, - runtime_parameters: toToolRuntimeParameters(toolSettings[action.id]), - credential_type: credentialType, - credential_ref: tool.credentialId - ? { - id: tool.credentialId, - provider: tool.id, - type: 'provider' as const, - } - : undefined, - })) -}) +) => + tools.flatMap((tool) => { + if (tool.kind !== 'provider') return [] + + const credentialType = tool.credentialId ? (tool.credentialType ?? 'api-key') : 'unauthorized' + + return tool.actions.map((action) => ({ + enabled: true, + provider: tool.name, + provider_id: tool.id, + provider_type: tool.providerType ?? 'builtin', + tool_name: action.toolName, + runtime_parameters: toToolRuntimeParameters(toolSettings[action.id]), + credential_type: credentialType, + credential_ref: tool.credentialId + ? { + id: tool.credentialId, + provider: tool.id, + type: 'provider' as const, + } + : undefined, + })) + }) const toEnvVariableValue = (variable: AgentSoulEnvVariableConfig) => { const value = variable.value ?? variable.default ?? '' - if (value === null) - return '' + if (value === null) return '' - if (typeof value === 'string') - return value + if (typeof value === 'string') return value return JSON.stringify(value) } -const toCliEnvVariables = (tool: AgentSoulCliToolConfig): EnvVariable[] => [ - ...(tool.env?.variables ?? []).map((variable): EnvVariable => { - const key = variable.key ?? variable.name ?? variable.variable ?? variable.env_name ?? '' - return { - id: variable.env_name ?? variable.key ?? variable.name ?? variable.variable ?? key, - key, - value: toEnvVariableValue(variable), - scope: 'plain', - } - }), - ...(tool.env?.secret_refs ?? []).map((secret): EnvVariable => { - const key = secret.key ?? secret.name ?? secret.variable ?? secret.env_name ?? '' - const value = secret.value ?? secret.ref ?? secret.credential_id ?? '' - return { - id: secret.id ?? secret.ref ?? secret.credential_id ?? key, - key, - value, - scope: 'secret', - masked: true, - } - }), -].filter(variable => variable.key) - -const toCliToolFormState = (config?: AgentSoulConfig): AgentCliTool[] => ( - config?.tools?.cli_tools ?? [] -).flatMap((tool) => { - const id = tool.tool_name ?? tool.id ?? tool.name - if (!id) - return [] - - return [{ - id, - name: tool.name ?? tool.label ?? tool.tool_name ?? id, - kind: 'cli', - action: tool.pre_authorized ? 'preAuthorize' : undefined, - installCommand: tool.install_command ?? tool.install_commands?.[0] ?? tool.install ?? tool.setup_command ?? undefined, - envVariables: toCliEnvVariables(tool), - }] -}) +const toCliEnvVariables = (tool: AgentSoulCliToolConfig): EnvVariable[] => + [ + ...(tool.env?.variables ?? []).map((variable): EnvVariable => { + const key = variable.key ?? variable.name ?? variable.variable ?? variable.env_name ?? '' + return { + id: variable.env_name ?? variable.key ?? variable.name ?? variable.variable ?? key, + key, + value: toEnvVariableValue(variable), + scope: 'plain', + } + }), + ...(tool.env?.secret_refs ?? []).map((secret): EnvVariable => { + const key = secret.key ?? secret.name ?? secret.variable ?? secret.env_name ?? '' + const value = secret.value ?? secret.ref ?? secret.credential_id ?? '' + return { + id: secret.id ?? secret.ref ?? secret.credential_id ?? key, + key, + value, + scope: 'secret', + masked: true, + } + }), + ].filter((variable) => variable.key) + +const toCliToolFormState = (config?: AgentSoulConfig): AgentCliTool[] => + (config?.tools?.cli_tools ?? []).flatMap((tool) => { + const id = tool.tool_name ?? tool.id ?? tool.name + if (!id) return [] + + return [ + { + id, + name: tool.name ?? tool.label ?? tool.tool_name ?? id, + kind: 'cli', + action: tool.pre_authorized ? 'preAuthorize' : undefined, + installCommand: + tool.install_command ?? + tool.install_commands?.[0] ?? + tool.install ?? + tool.setup_command ?? + undefined, + envVariables: toCliEnvVariables(tool), + }, + ] + }) const hasValidEnvKey = (variable: EnvVariable) => checkKey(variable.key.trim(), false) === true const hasEnvValue = (variable: EnvVariable) => variable.value.trim().length > 0 -const isPublishablePlainEnvVariable = (variable: EnvVariable) => ( +const isPublishablePlainEnvVariable = (variable: EnvVariable) => variable.scope === 'plain' && hasValidEnvKey(variable) && hasEnvValue(variable) -) -const isPublishableSecretEnvVariable = (variable: EnvVariable) => ( +const isPublishableSecretEnvVariable = (variable: EnvVariable) => variable.scope === 'secret' && hasValidEnvKey(variable) && hasEnvValue(variable) -) - -const toCliToolConfigs = (tools: AgentTool[]) => tools.flatMap((tool) => { - if (tool.kind !== 'cli') - return [] - - const envVariables = tool.envVariables ?? [] - - return [{ - enabled: false, - env: { - variables: envVariables - .filter(isPublishablePlainEnvVariable) - .map(variable => ({ - id: variable.id, - key: variable.key.trim(), - name: variable.key.trim(), - value: variable.value, - variable: variable.key.trim(), - })), - secret_refs: envVariables - .filter(isPublishableSecretEnvVariable) - .map(variable => ({ - id: variable.id, - key: variable.key.trim(), - name: variable.key.trim(), - value: variable.value, - variable: variable.key.trim(), - })), - }, - install_command: tool.installCommand, - install_commands: tool.installCommand ? [tool.installCommand] : [], - name: tool.name, - tool_name: tool.id, - pre_authorized: false, - }] -}) -const toEnvVariableFormState = (config?: AgentSoulConfig): EnvVariable[] => [ - ...(config?.env?.variables ?? []).map((variable): EnvVariable => { - const key = variable.key ?? variable.name ?? variable.variable ?? variable.env_name ?? '' - return { - id: variable.env_name ?? variable.key ?? variable.name ?? variable.variable ?? key, - key, - value: toEnvVariableValue(variable), - scope: 'plain', - } - }), - ...(config?.env?.secret_refs ?? []).map((secret): EnvVariable => { - const key = secret.key ?? secret.name ?? secret.variable ?? secret.env_name ?? '' - const value = secret.value ?? secret.ref ?? secret.credential_id ?? '' - return { - id: secret.id ?? secret.ref ?? secret.credential_id ?? key, - key, - value, - scope: 'secret', - masked: true, - } - }), -].filter(variable => variable.key) +const toCliToolConfigs = (tools: AgentTool[]) => + tools.flatMap((tool) => { + if (tool.kind !== 'cli') return [] + + const envVariables = tool.envVariables ?? [] + + return [ + { + enabled: false, + env: { + variables: envVariables.filter(isPublishablePlainEnvVariable).map((variable) => ({ + id: variable.id, + key: variable.key.trim(), + name: variable.key.trim(), + value: variable.value, + variable: variable.key.trim(), + })), + secret_refs: envVariables.filter(isPublishableSecretEnvVariable).map((variable) => ({ + id: variable.id, + key: variable.key.trim(), + name: variable.key.trim(), + value: variable.value, + variable: variable.key.trim(), + })), + }, + install_command: tool.installCommand, + install_commands: tool.installCommand ? [tool.installCommand] : [], + name: tool.name, + tool_name: tool.id, + pre_authorized: false, + }, + ] + }) + +const toEnvVariableFormState = (config?: AgentSoulConfig): EnvVariable[] => + [ + ...(config?.env?.variables ?? []).map((variable): EnvVariable => { + const key = variable.key ?? variable.name ?? variable.variable ?? variable.env_name ?? '' + return { + id: variable.env_name ?? variable.key ?? variable.name ?? variable.variable ?? key, + key, + value: toEnvVariableValue(variable), + scope: 'plain', + } + }), + ...(config?.env?.secret_refs ?? []).map((secret): EnvVariable => { + const key = secret.key ?? secret.name ?? secret.variable ?? secret.env_name ?? '' + const value = secret.value ?? secret.ref ?? secret.credential_id ?? '' + return { + id: secret.id ?? secret.ref ?? secret.credential_id ?? key, + key, + value, + scope: 'secret', + masked: true, + } + }), + ].filter((variable) => variable.key) const toEnvConfig = (variables: EnvVariable[]): AgentSoulConfig['env'] => ({ - variables: variables - .filter(isPublishablePlainEnvVariable) - .map(variable => ({ - id: variable.id, - key: variable.key.trim(), - name: variable.key.trim(), - value: variable.value, - variable: variable.key.trim(), - })), - secret_refs: variables - .filter(isPublishableSecretEnvVariable) - .map(variable => ({ - id: variable.id, - key: variable.key.trim(), - name: variable.key.trim(), - value: variable.value, - variable: variable.key.trim(), - })), + variables: variables.filter(isPublishablePlainEnvVariable).map((variable) => ({ + id: variable.id, + key: variable.key.trim(), + name: variable.key.trim(), + value: variable.value, + variable: variable.key.trim(), + })), + secret_refs: variables.filter(isPublishableSecretEnvVariable).map((variable) => ({ + id: variable.id, + key: variable.key.trim(), + name: variable.key.trim(), + value: variable.value, + variable: variable.key.trim(), + })), }) -const toConfigSkillConfigs = (skills: AgentSkill[], baseConfig?: AgentSoulConfig): AgentConfigSkillRefConfig[] => { - const existingByName = new Map((baseConfig?.config_skills ?? []).map(skill => [skill.name, skill])) +const toConfigSkillConfigs = ( + skills: AgentSkill[], + baseConfig?: AgentSoulConfig, +): AgentConfigSkillRefConfig[] => { + const existingByName = new Map( + (baseConfig?.config_skills ?? []).map((skill) => [skill.name, skill]), + ) return skills.flatMap((skill) => { const existing = existingByName.get(skill.name) const fileId = skill.fileId ?? existing?.file_id - if (!fileId) - return [] - - return [{ - name: skill.name, - description: skill.description ?? existing?.description ?? '', - file_id: fileId, - file_kind: existing?.file_kind ?? 'tool_file', - size: skill.size ?? existing?.size, - hash: skill.hash ?? existing?.hash, - mime_type: skill.mimeType ?? existing?.mime_type, - }] + if (!fileId) return [] + + return [ + { + name: skill.name, + description: skill.description ?? existing?.description ?? '', + file_id: fileId, + file_kind: existing?.file_kind ?? 'tool_file', + size: skill.size ?? existing?.size, + hash: skill.hash ?? existing?.hash, + mime_type: skill.mimeType ?? existing?.mime_type, + }, + ] }) } -const toConfigFileConfigs = (files: AgentFileNode[], baseConfig?: AgentSoulConfig): AgentConfigFileRefConfig[] => { - const existingByName = new Map((baseConfig?.config_files ?? []).map(file => [file.name, file])) +const toConfigFileConfigs = ( + files: AgentFileNode[], + baseConfig?: AgentSoulConfig, +): AgentConfigFileRefConfig[] => { + const existingByName = new Map((baseConfig?.config_files ?? []).map((file) => [file.name, file])) return files.flatMap((file) => { - if (file.children?.length) - return toConfigFileConfigs(file.children, baseConfig) + if (file.children?.length) return toConfigFileConfigs(file.children, baseConfig) const configName = file.configName ?? file.name const existing = existingByName.get(configName) const fileId = file.fileId ?? existing?.file_id - if (!fileId) - return [] - - return [{ - name: configName, - file_id: fileId, - file_kind: existing?.file_kind ?? 'upload_file', - size: file.size ?? existing?.size, - hash: file.hash ?? existing?.hash, - mime_type: file.mimeType ?? existing?.mime_type, - }] + if (!fileId) return [] + + return [ + { + name: configName, + file_id: fileId, + file_kind: existing?.file_kind ?? 'upload_file', + size: file.size ?? existing?.size, + hash: file.hash ?? existing?.hash, + mime_type: file.mimeType ?? existing?.mime_type, + }, + ] }) } const toSkillFormState = (config?: AgentSoulConfig): AgentSkill[] => { - return (config?.config_skills ?? []).map(skill => ({ + return (config?.config_skills ?? []).map((skill) => ({ id: skill.name, name: skill.name, description: skill.description ?? undefined, @@ -474,7 +508,7 @@ const toSkillFormState = (config?: AgentSoulConfig): AgentSkill[] => { } const toFileFormState = (config?: AgentSoulConfig): AgentFileNode[] => { - return (config?.config_files ?? []).map(file => ({ + return (config?.config_files ?? []).map((file) => ({ id: file.name, name: file.name, icon: getFileIconType(file.name, file.mime_type ?? undefined), @@ -490,8 +524,7 @@ const toDraftModel = (config?: AgentSoulConfig): AgentComposerModel | undefined const modelProvider = config?.model?.model_provider const model = config?.model?.model - if (!modelProvider || !model) - return undefined + if (!modelProvider || !model) return undefined return { provider: modelProvider, @@ -501,17 +534,18 @@ const toDraftModel = (config?: AgentSoulConfig): AgentComposerModel | undefined } } -const getModelProviderPluginId = (model: AgentComposerModel, baseModel?: AgentSoulConfig['model']) => { - if (model.plugin_id) - return model.plugin_id +const getModelProviderPluginId = ( + model: AgentComposerModel, + baseModel?: AgentSoulConfig['model'], +) => { + if (model.plugin_id) return model.plugin_id if (baseModel?.model_provider === model.provider && baseModel.plugin_id) return baseModel.plugin_id const [organization, pluginName] = model.provider.split('/').filter(Boolean) - if (organization && pluginName) - return `${organization}/${pluginName}` + if (organization && pluginName) return `${organization}/${pluginName}` return model.provider ? `langgenius/${model.provider}` : '' } @@ -568,10 +602,7 @@ export const agentSoulConfigToFormState = ( appFeatures: config?.app_features, skills: toSkillFormState(config), files: toFileFormState(config), - tools: [ - ...providerToolState.tools, - ...toCliToolFormState(config), - ], + tools: [...providerToolState.tools, ...toCliToolFormState(config)], knowledgeRetrievals: toKnowledgeRetrievalFormState(config), envVariables: toEnvVariableFormState(config), toolSettings: providerToolState.toolSettings, diff --git a/web/features/agent-v2/agent-composer/form-state.ts b/web/features/agent-v2/agent-composer/form-state.ts index 257842e4406f4e..ee72fbf2815593 100644 --- a/web/features/agent-v2/agent-composer/form-state.ts +++ b/web/features/agent-v2/agent-composer/form-state.ts @@ -1,4 +1,8 @@ -import type { AgentKnowledgeDatasetConfig, AgentSoulAppFeaturesConfig, AgentSoulModelConfig } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentKnowledgeDatasetConfig, + AgentSoulAppFeaturesConfig, + AgentSoulModelConfig, +} from '@dify/contracts/api/console/agent/types.gen' import type { FileTreeIconType } from '@langgenius/dify-ui/file-tree' import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types' diff --git a/web/features/agent-v2/agent-composer/knowledge-validation.ts b/web/features/agent-v2/agent-composer/knowledge-validation.ts index 60625b619f5d71..cbc47fe6000fca 100644 --- a/web/features/agent-v2/agent-composer/knowledge-validation.ts +++ b/web/features/agent-v2/agent-composer/knowledge-validation.ts @@ -3,14 +3,14 @@ import { useTranslation } from 'react-i18next' import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import { RETRIEVE_TYPE } from '@/types/app' -export type KnowledgeValidationIssueCode - = | 'name_required' - | 'name_duplicate' - | 'datasets_required' - | 'custom_query_required' - | 'single_model_required' - | 'metadata_model_required' - | 'metadata_conditions_required' +export type KnowledgeValidationIssueCode = + | 'name_required' + | 'name_duplicate' + | 'datasets_required' + | 'custom_query_required' + | 'single_model_required' + | 'metadata_model_required' + | 'metadata_conditions_required' type KnowledgeValidationField = 'name' | 'datasets' | 'query' | 'retrieval' | 'metadata' @@ -26,7 +26,8 @@ export type KnowledgeValidationResult = { isValid: boolean } -const getKnowledgeRetrievalConcreteName = (item: AgentKnowledgeRetrievalItem) => item.name?.trim() ?? '' +const getKnowledgeRetrievalConcreteName = (item: AgentKnowledgeRetrievalItem) => + item.name?.trim() ?? '' export const getKnowledgeRetrievalSetName = (item: AgentKnowledgeRetrievalItem) => getKnowledgeRetrievalConcreteName(item) || item.id @@ -40,30 +41,34 @@ export const useKnowledgeValidationMessage = () => { return (issueCode?: KnowledgeValidationIssueCode) => { switch (issueCode) { case 'name_required': - return tCommon($ => $['errorMsg.fieldRequired'], { - field: t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.nameLabel']), + return tCommon(($) => $['errorMsg.fieldRequired'], { + field: t(($) => $['agentDetail.configure.knowledgeRetrieval.dialog.nameLabel']), }) case 'name_duplicate': - return tAppDebug($ => $['varKeyError.keyAlreadyExists'], { - key: t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.nameLabel']), + return tAppDebug(($) => $['varKeyError.keyAlreadyExists'], { + key: t(($) => $['agentDetail.configure.knowledgeRetrieval.dialog.nameLabel']), }) case 'datasets_required': - return tCommon($ => $['errorMsg.fieldRequired'], { - field: t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label']), + return tCommon(($) => $['errorMsg.fieldRequired'], { + field: t(($) => $['agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label']), }) case 'custom_query_required': - return tCommon($ => $['errorMsg.fieldRequired'], { - field: t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel']), + return tCommon(($) => $['errorMsg.fieldRequired'], { + field: t( + ($) => $['agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel'], + ), }) case 'single_model_required': - return tCommon($ => $['errorMsg.fieldRequired'], { - field: tCommon($ => $['modelProvider.systemReasoningModel.key']), + return tCommon(($) => $['errorMsg.fieldRequired'], { + field: tCommon(($) => $['modelProvider.systemReasoningModel.key']), }) case 'metadata_model_required': - return t($ => $['agentDetail.configure.knowledgeRetrieval.validation.metadataModelRequired']) + return t( + ($) => $['agentDetail.configure.knowledgeRetrieval.validation.metadataModelRequired'], + ) case 'metadata_conditions_required': - return tCommon($ => $['errorMsg.fieldRequired'], { - field: tWorkflow($ => $['nodes.knowledgeRetrieval.metadata.panel.conditions']), + return tCommon(($) => $['errorMsg.fieldRequired'], { + field: tWorkflow(($) => $['nodes.knowledgeRetrieval.metadata.panel.conditions']), }) default: return undefined @@ -74,7 +79,8 @@ export const useKnowledgeValidationMessage = () => { const getKnowledgeDatasetCount = (item: AgentKnowledgeRetrievalItem) => item.selectedDatasets?.length ?? item.datasetRefs?.length ?? 0 -const getNormalizedKnowledgeName = (item: AgentKnowledgeRetrievalItem) => getKnowledgeRetrievalConcreteName(item).toLowerCase() +const getNormalizedKnowledgeName = (item: AgentKnowledgeRetrievalItem) => + getKnowledgeRetrievalConcreteName(item).toLowerCase() export const validateKnowledgeRetrievals = ( retrievals: AgentKnowledgeRetrievalItem[], @@ -85,8 +91,7 @@ export const validateKnowledgeRetrievals = ( retrievals.forEach((item) => { const normalizedName = getNormalizedKnowledgeName(item) - if (!normalizedName) - return + if (!normalizedName) return nameCounts.set(normalizedName, (nameCounts.get(normalizedName) ?? 0) + 1) }) @@ -94,8 +99,7 @@ export const validateKnowledgeRetrievals = ( const pushIssue = (issue: KnowledgeValidationIssue) => { byId[issue.itemId] ??= {} const itemIssues = byId[issue.itemId] - if (itemIssues) - itemIssues[issue.field] ??= issue.code + if (itemIssues) itemIssues[issue.field] ??= issue.code issues.push(issue) } @@ -109,8 +113,7 @@ export const validateKnowledgeRetrievals = ( code: 'name_required', field: 'name', }) - } - else if ((nameCounts.get(normalizedName) ?? 0) > 1) { + } else if ((nameCounts.get(normalizedName) ?? 0) > 1) { pushIssue({ itemId: item.id, code: 'name_duplicate', @@ -135,8 +138,8 @@ export const validateKnowledgeRetrievals = ( } if ( - item.retrievalMode === RETRIEVE_TYPE.oneWay - && (!item.singleRetrievalConfig?.model?.provider || !item.singleRetrievalConfig.model.name) + item.retrievalMode === RETRIEVE_TYPE.oneWay && + (!item.singleRetrievalConfig?.model?.provider || !item.singleRetrievalConfig.model.name) ) { pushIssue({ itemId: item.id, @@ -146,8 +149,8 @@ export const validateKnowledgeRetrievals = ( } if ( - item.metadataFilterMode === MetadataFilteringModeEnum.automatic - && (!item.metadataModelConfig?.provider || !item.metadataModelConfig.name) + item.metadataFilterMode === MetadataFilteringModeEnum.automatic && + (!item.metadataModelConfig?.provider || !item.metadataModelConfig.name) ) { pushIssue({ itemId: item.id, @@ -157,8 +160,8 @@ export const validateKnowledgeRetrievals = ( } if ( - item.metadataFilterMode === MetadataFilteringModeEnum.manual - && !item.metadataFilteringConditions?.conditions.length + item.metadataFilterMode === MetadataFilteringModeEnum.manual && + !item.metadataFilteringConditions?.conditions.length ) { pushIssue({ itemId: item.id, diff --git a/web/features/agent-v2/agent-composer/reference-labels.ts b/web/features/agent-v2/agent-composer/reference-labels.ts index 0616129fd7c3b0..edaa66c0479cdf 100644 --- a/web/features/agent-v2/agent-composer/reference-labels.ts +++ b/web/features/agent-v2/agent-composer/reference-labels.ts @@ -1,12 +1,17 @@ -import type { AgentFileNode, AgentKnowledgeRetrievalItem, AgentSkill, AgentTool } from './form-state' +import type { + AgentFileNode, + AgentKnowledgeRetrievalItem, + AgentSkill, + AgentTool, +} from './form-state' -const getKnowledgeRetrievalName = (item: AgentKnowledgeRetrievalItem) => item.name ?? item.nameKey ?? item.id +const getKnowledgeRetrievalName = (item: AgentKnowledgeRetrievalItem) => + item.name ?? item.nameKey ?? item.id const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -const createReferenceToken = (kind: string, id: string, label: string) => ( +const createReferenceToken = (kind: string, id: string, label: string) => `[§${kind}:${id}${label ? `:${label}` : ''}§]` -) const syncReferenceLabels = ({ prompt, @@ -16,18 +21,16 @@ const syncReferenceLabels = ({ }: { prompt: string kind: string - currentItems: Array<{ id: string, name: string }> - nextItems: Array<{ id: string, name: string }> + currentItems: Array<{ id: string; name: string }> + nextItems: Array<{ id: string; name: string }> }) => { - const currentItemById = new Map(currentItems.map(item => [item.id, item])) + const currentItemById = new Map(currentItems.map((item) => [item.id, item])) return nextItems.reduce((nextPrompt, nextItem) => { const currentItem = currentItemById.get(nextItem.id) - if (!currentItem) - return nextPrompt + if (!currentItem) return nextPrompt - if (currentItem.name === nextItem.name) - return nextPrompt + if (currentItem.name === nextItem.name) return nextPrompt return nextPrompt.replace( new RegExp(`\\[§${escapeRegExp(kind)}:${escapeRegExp(nextItem.id)}(?::[^§\\]]*)?§\\]`, 'g'), @@ -39,10 +42,11 @@ const syncReferenceLabels = ({ const toReferenceLabelItems = <Item extends { id: string }>( items: Item[], getName: (item: Item) => string, -) => items.map(item => ({ - id: item.id, - name: getName(item), -})) +) => + items.map((item) => ({ + id: item.id, + name: getName(item), + })) export const syncKnowledgeReferenceLabels = ({ prompt, @@ -52,12 +56,13 @@ export const syncKnowledgeReferenceLabels = ({ prompt: string currentRetrievals: AgentKnowledgeRetrievalItem[] nextRetrievals: AgentKnowledgeRetrievalItem[] -}) => syncReferenceLabels({ - prompt, - kind: 'knowledge', - currentItems: toReferenceLabelItems(currentRetrievals, getKnowledgeRetrievalName), - nextItems: toReferenceLabelItems(nextRetrievals, getKnowledgeRetrievalName), -}) +}) => + syncReferenceLabels({ + prompt, + kind: 'knowledge', + currentItems: toReferenceLabelItems(currentRetrievals, getKnowledgeRetrievalName), + nextItems: toReferenceLabelItems(nextRetrievals, getKnowledgeRetrievalName), + }) export const syncCliToolReferenceLabels = ({ prompt, @@ -67,12 +72,19 @@ export const syncCliToolReferenceLabels = ({ prompt: string currentTools: AgentTool[] nextTools: AgentTool[] -}) => syncReferenceLabels({ - prompt, - kind: 'cli_tool', - currentItems: toReferenceLabelItems(currentTools.filter(tool => tool.kind === 'cli'), tool => tool.name), - nextItems: toReferenceLabelItems(nextTools.filter(tool => tool.kind === 'cli'), tool => tool.name), -}) +}) => + syncReferenceLabels({ + prompt, + kind: 'cli_tool', + currentItems: toReferenceLabelItems( + currentTools.filter((tool) => tool.kind === 'cli'), + (tool) => tool.name, + ), + nextItems: toReferenceLabelItems( + nextTools.filter((tool) => tool.kind === 'cli'), + (tool) => tool.name, + ), + }) export const syncSkillReferenceLabels = ({ prompt, @@ -82,16 +94,16 @@ export const syncSkillReferenceLabels = ({ prompt: string currentSkills: AgentSkill[] nextSkills: AgentSkill[] -}) => syncReferenceLabels({ - prompt, - kind: 'skill', - currentItems: toReferenceLabelItems(currentSkills, skill => skill.name), - nextItems: toReferenceLabelItems(nextSkills, skill => skill.name), -}) +}) => + syncReferenceLabels({ + prompt, + kind: 'skill', + currentItems: toReferenceLabelItems(currentSkills, (skill) => skill.name), + nextItems: toReferenceLabelItems(nextSkills, (skill) => skill.name), + }) -const flattenFileNodes = (files: AgentFileNode[]): AgentFileNode[] => files.flatMap(file => ( - file.children?.length ? flattenFileNodes(file.children) : [file] -)) +const flattenFileNodes = (files: AgentFileNode[]): AgentFileNode[] => + files.flatMap((file) => (file.children?.length ? flattenFileNodes(file.children) : [file])) export const syncFileReferenceLabels = ({ prompt, @@ -101,9 +113,10 @@ export const syncFileReferenceLabels = ({ prompt: string currentFiles: AgentFileNode[] nextFiles: AgentFileNode[] -}) => syncReferenceLabels({ - prompt, - kind: 'file', - currentItems: toReferenceLabelItems(flattenFileNodes(currentFiles), file => file.name), - nextItems: toReferenceLabelItems(flattenFileNodes(nextFiles), file => file.name), -}) +}) => + syncReferenceLabels({ + prompt, + kind: 'file', + currentItems: toReferenceLabelItems(flattenFileNodes(currentFiles), (file) => file.name), + nextItems: toReferenceLabelItems(flattenFileNodes(nextFiles), (file) => file.name), + }) diff --git a/web/features/agent-v2/agent-composer/store-modules/__tests__/files.spec.ts b/web/features/agent-v2/agent-composer/store-modules/__tests__/files.spec.ts index 50f477b2a1882a..bc777778b88b2a 100644 --- a/web/features/agent-v2/agent-composer/store-modules/__tests__/files.spec.ts +++ b/web/features/agent-v2/agent-composer/store-modules/__tests__/files.spec.ts @@ -2,11 +2,7 @@ import { createStore } from 'jotai' import { describe, expect, it } from 'vitest' import { defaultAgentSoulConfigFormState } from '../../form-state' import { agentComposerDraftAtom } from '../../store' -import { - clearAgentConfigNoteAtom, - removeAgentFileAtom, - upsertAgentFileAtom, -} from '../files' +import { clearAgentConfigNoteAtom, removeAgentFileAtom, upsertAgentFileAtom } from '../files' describe('agent composer files store', () => { it('should upsert and remove files from the latest draft state', () => { diff --git a/web/features/agent-v2/agent-composer/store-modules/__tests__/skills.spec.ts b/web/features/agent-v2/agent-composer/store-modules/__tests__/skills.spec.ts index 7127c944694d2a..269c87c43f177e 100644 --- a/web/features/agent-v2/agent-composer/store-modules/__tests__/skills.spec.ts +++ b/web/features/agent-v2/agent-composer/store-modules/__tests__/skills.spec.ts @@ -2,10 +2,7 @@ import { createStore } from 'jotai' import { describe, expect, it } from 'vitest' import { defaultAgentSoulConfigFormState } from '../../form-state' import { agentComposerDraftAtom } from '../../store' -import { - removeAgentSkillAtom, - upsertAgentSkillAtom, -} from '../skills' +import { removeAgentSkillAtom, upsertAgentSkillAtom } from '../skills' describe('agent composer skills store', () => { it('should upsert and remove skills from the latest draft state', () => { diff --git a/web/features/agent-v2/agent-composer/store-modules/app-features.ts b/web/features/agent-v2/agent-composer/store-modules/app-features.ts index 27a037901cf5d2..79da67fe43d3cf 100644 --- a/web/features/agent-v2/agent-composer/store-modules/app-features.ts +++ b/web/features/agent-v2/agent-composer/store-modules/app-features.ts @@ -5,7 +5,7 @@ import { agentComposerDraftAtom } from '../store' import { resolveDraftFieldUpdate } from './utils' export const agentComposerAppFeaturesAtom = atom( - get => get(agentComposerDraftAtom).appFeatures, + (get) => get(agentComposerDraftAtom).appFeatures, (get, set, appFeaturesUpdate: DraftFieldUpdate<AgentSoulAppFeaturesConfig | undefined>) => { const draft = get(agentComposerDraftAtom) diff --git a/web/features/agent-v2/agent-composer/store-modules/env.ts b/web/features/agent-v2/agent-composer/store-modules/env.ts index 0945c01dd158f6..5871697df55c69 100644 --- a/web/features/agent-v2/agent-composer/store-modules/env.ts +++ b/web/features/agent-v2/agent-composer/store-modules/env.ts @@ -5,7 +5,7 @@ import { agentComposerDraftAtom } from '../store' import { resolveDraftFieldUpdate } from './utils' export const agentComposerEnvVariablesAtom = atom( - get => get(agentComposerDraftAtom).envVariables, + (get) => get(agentComposerDraftAtom).envVariables, (get, set, envVariablesUpdate: DraftFieldUpdate<EnvVariable[]>) => { const draft = get(agentComposerDraftAtom) @@ -22,88 +22,106 @@ const updateEnvVariable = ( id: string, updater: (variable: EnvVariable) => EnvVariable, ) => { - const existingVariable = envVariables.find(variable => variable.id === id) + const existingVariable = envVariables.find((variable) => variable.id === id) if (existingVariable) { - return envVariables.map(variable => ( - variable.id === id ? updater(variable) : variable - )) + return envVariables.map((variable) => (variable.id === id ? updater(variable) : variable)) } - if (id === starterVariable.id) - return [updater(starterVariable)] + if (id === starterVariable.id) return [updater(starterVariable)] return envVariables } -export const setEnvVariableKeyAtom = atom(null, (_get, set, { - id, - key, - starterVariable, -}: { - id: string - key: string - starterVariable: EnvVariable -}) => { - set(agentComposerEnvVariablesAtom, envVariables => updateEnvVariable( - envVariables, - starterVariable, - id, - variable => ({ ...variable, key }), - )) -}) +export const setEnvVariableKeyAtom = atom( + null, + ( + _get, + set, + { + id, + key, + starterVariable, + }: { + id: string + key: string + starterVariable: EnvVariable + }, + ) => { + set(agentComposerEnvVariablesAtom, (envVariables) => + updateEnvVariable(envVariables, starterVariable, id, (variable) => ({ ...variable, key })), + ) + }, +) -export const setEnvVariableScopeAtom = atom(null, (_get, set, { - id, - scope, - starterVariable, -}: { - id: string - scope: EnvScope - starterVariable: EnvVariable -}) => { - set(agentComposerEnvVariablesAtom, envVariables => updateEnvVariable( - envVariables, - starterVariable, - id, - variable => ({ ...variable, scope }), - )) -}) +export const setEnvVariableScopeAtom = atom( + null, + ( + _get, + set, + { + id, + scope, + starterVariable, + }: { + id: string + scope: EnvScope + starterVariable: EnvVariable + }, + ) => { + set(agentComposerEnvVariablesAtom, (envVariables) => + updateEnvVariable(envVariables, starterVariable, id, (variable) => ({ ...variable, scope })), + ) + }, +) -export const setEnvVariableValueAtom = atom(null, (_get, set, { - id, - starterVariable, - value, -}: { - id: string - starterVariable: EnvVariable - value: string -}) => { - set(agentComposerEnvVariablesAtom, envVariables => updateEnvVariable( - envVariables, - starterVariable, - id, - variable => ({ ...variable, value }), - )) -}) +export const setEnvVariableValueAtom = atom( + null, + ( + _get, + set, + { + id, + starterVariable, + value, + }: { + id: string + starterVariable: EnvVariable + value: string + }, + ) => { + set(agentComposerEnvVariablesAtom, (envVariables) => + updateEnvVariable(envVariables, starterVariable, id, (variable) => ({ ...variable, value })), + ) + }, +) -export const addEnvVariableAtom = atom(null, (_get, set, { - starterVariable, - variable, -}: { - starterVariable: EnvVariable - variable: EnvVariable -}) => { - set(agentComposerEnvVariablesAtom, envVariables => [ - ...(envVariables.length > 0 ? envVariables : [starterVariable]), - variable, - ]) -}) +export const addEnvVariableAtom = atom( + null, + ( + _get, + set, + { + starterVariable, + variable, + }: { + starterVariable: EnvVariable + variable: EnvVariable + }, + ) => { + set(agentComposerEnvVariablesAtom, (envVariables) => [ + ...(envVariables.length > 0 ? envVariables : [starterVariable]), + variable, + ]) + }, +) export const importEnvVariablesAtom = atom(null, (_get, set, variables: EnvVariable[]) => { - set(agentComposerEnvVariablesAtom, envVariables => [...envVariables, ...variables]) + set(agentComposerEnvVariablesAtom, (envVariables) => [...envVariables, ...variables]) }) export const removeEnvVariableAtom = atom(null, (_get, set, id: string) => { - set(agentComposerEnvVariablesAtom, envVariables => envVariables.filter(variable => variable.id !== id)) + set(agentComposerEnvVariablesAtom, (envVariables) => + envVariables.filter((variable) => variable.id !== id), + ) }) diff --git a/web/features/agent-v2/agent-composer/store-modules/files.ts b/web/features/agent-v2/agent-composer/store-modules/files.ts index 1a931a4ac4b4a0..76025b23f5d966 100644 --- a/web/features/agent-v2/agent-composer/store-modules/files.ts +++ b/web/features/agent-v2/agent-composer/store-modules/files.ts @@ -5,8 +5,12 @@ import { syncFileReferenceLabels } from '../reference-labels' import { agentComposerDraftAtom } from '../store' import { resolveDraftFieldUpdate } from './utils' -export const agentComposerFilesAtom = atom<AgentFileNode[], [DraftFieldUpdate<AgentFileNode[]>], void>( - get => get(agentComposerDraftAtom).files, +export const agentComposerFilesAtom = atom< + AgentFileNode[], + [DraftFieldUpdate<AgentFileNode[]>], + void +>( + (get) => get(agentComposerDraftAtom).files, (get, set, filesUpdate: DraftFieldUpdate<AgentFileNode[]>) => { const draft = get(agentComposerDraftAtom) const files = resolveDraftFieldUpdate(draft.files, filesUpdate) @@ -23,25 +27,21 @@ export const agentComposerFilesAtom = atom<AgentFileNode[], [DraftFieldUpdate<Ag }, ) -const removeAgentFileNode = (files: AgentFileNode[], fileId: string): AgentFileNode[] => files.flatMap((file) => { - if (file.id === fileId) - return [] +const removeAgentFileNode = (files: AgentFileNode[], fileId: string): AgentFileNode[] => + files.flatMap((file) => { + if (file.id === fileId) return [] - if (file.children) - return [{ ...file, children: removeAgentFileNode(file.children, fileId) }] + if (file.children) return [{ ...file, children: removeAgentFileNode(file.children, fileId) }] - return [file] -}) + return [file] + }) export const upsertAgentFileAtom = atom(null, (_get, set, file: AgentFileNode) => { - set(agentComposerFilesAtom, files => [ - ...removeAgentFileNode(files, file.id), - file, - ]) + set(agentComposerFilesAtom, (files) => [...removeAgentFileNode(files, file.id), file]) }) export const removeAgentFileAtom = atom(null, (_get, set, fileId: string) => { - set(agentComposerFilesAtom, files => removeAgentFileNode(files, fileId)) + set(agentComposerFilesAtom, (files) => removeAgentFileNode(files, fileId)) }) export const clearAgentConfigNoteAtom = atom(null, (get, set) => { diff --git a/web/features/agent-v2/agent-composer/store-modules/knowledge.ts b/web/features/agent-v2/agent-composer/store-modules/knowledge.ts index 53d25f74b78f5d..0b1425aa31eaf2 100644 --- a/web/features/agent-v2/agent-composer/store-modules/knowledge.ts +++ b/web/features/agent-v2/agent-composer/store-modules/knowledge.ts @@ -6,10 +6,13 @@ import { agentComposerDraftAtom } from '../store' import { resolveDraftFieldUpdate } from './utils' export const agentComposerKnowledgeRetrievalsAtom = atom( - get => get(agentComposerDraftAtom).knowledgeRetrievals, + (get) => get(agentComposerDraftAtom).knowledgeRetrievals, (get, set, knowledgeRetrievalsUpdate: DraftFieldUpdate<AgentKnowledgeRetrievalItem[]>) => { const draft = get(agentComposerDraftAtom) - const knowledgeRetrievals = resolveDraftFieldUpdate(draft.knowledgeRetrievals, knowledgeRetrievalsUpdate) + const knowledgeRetrievals = resolveDraftFieldUpdate( + draft.knowledgeRetrievals, + knowledgeRetrievalsUpdate, + ) set(agentComposerDraftAtom, { ...draft, @@ -23,16 +26,26 @@ export const agentComposerKnowledgeRetrievalsAtom = atom( }, ) -export const addKnowledgeRetrievalAtom = atom(null, (_get, set, retrieval: AgentKnowledgeRetrievalItem) => { - set(agentComposerKnowledgeRetrievalsAtom, retrievals => [...retrievals, retrieval]) -}) +export const addKnowledgeRetrievalAtom = atom( + null, + (_get, set, retrieval: AgentKnowledgeRetrievalItem) => { + set(agentComposerKnowledgeRetrievalsAtom, (retrievals) => [...retrievals, retrieval]) + }, +) -export const updateKnowledgeRetrievalAtom = atom(null, (_get, set, retrieval: AgentKnowledgeRetrievalItem) => { - set(agentComposerKnowledgeRetrievalsAtom, retrievals => retrievals.map(currentRetrieval => ( - currentRetrieval.id === retrieval.id ? retrieval : currentRetrieval - ))) -}) +export const updateKnowledgeRetrievalAtom = atom( + null, + (_get, set, retrieval: AgentKnowledgeRetrievalItem) => { + set(agentComposerKnowledgeRetrievalsAtom, (retrievals) => + retrievals.map((currentRetrieval) => + currentRetrieval.id === retrieval.id ? retrieval : currentRetrieval, + ), + ) + }, +) export const removeKnowledgeRetrievalAtom = atom(null, (_get, set, retrievalId: string) => { - set(agentComposerKnowledgeRetrievalsAtom, retrievals => retrievals.filter(retrieval => retrieval.id !== retrievalId)) + set(agentComposerKnowledgeRetrievalsAtom, (retrievals) => + retrievals.filter((retrieval) => retrieval.id !== retrievalId), + ) }) diff --git a/web/features/agent-v2/agent-composer/store-modules/model.ts b/web/features/agent-v2/agent-composer/store-modules/model.ts index 28d8d8c59c4a27..3ce577fac12ff5 100644 --- a/web/features/agent-v2/agent-composer/store-modules/model.ts +++ b/web/features/agent-v2/agent-composer/store-modules/model.ts @@ -5,7 +5,7 @@ import { agentComposerDraftAtom } from '../store' import { resolveDraftFieldUpdate } from './utils' export const agentComposerModelAtom = atom( - get => get(agentComposerDraftAtom).model, + (get) => get(agentComposerDraftAtom).model, (get, set, modelUpdate: DraftFieldUpdate<AgentComposerModel | undefined>) => { const draft = get(agentComposerDraftAtom) diff --git a/web/features/agent-v2/agent-composer/store-modules/prompt.ts b/web/features/agent-v2/agent-composer/store-modules/prompt.ts index 3b4cb2b528cf22..db4574c9797303 100644 --- a/web/features/agent-v2/agent-composer/store-modules/prompt.ts +++ b/web/features/agent-v2/agent-composer/store-modules/prompt.ts @@ -4,7 +4,7 @@ import { agentComposerDraftAtom } from '../store' import { resolveDraftFieldUpdate } from './utils' export const agentComposerPromptAtom = atom( - get => get(agentComposerDraftAtom).prompt, + (get) => get(agentComposerDraftAtom).prompt, (get, set, promptUpdate: DraftFieldUpdate<string>) => { const draft = get(agentComposerDraftAtom) diff --git a/web/features/agent-v2/agent-composer/store-modules/skills.ts b/web/features/agent-v2/agent-composer/store-modules/skills.ts index 4fd342cf1d10ff..81267532d13da5 100644 --- a/web/features/agent-v2/agent-composer/store-modules/skills.ts +++ b/web/features/agent-v2/agent-composer/store-modules/skills.ts @@ -6,7 +6,7 @@ import { agentComposerDraftAtom } from '../store' import { resolveDraftFieldUpdate } from './utils' export const agentComposerSkillsAtom = atom<AgentSkill[], [DraftFieldUpdate<AgentSkill[]>], void>( - get => get(agentComposerDraftAtom).skills, + (get) => get(agentComposerDraftAtom).skills, (get, set, skillsUpdate: DraftFieldUpdate<AgentSkill[]>) => { const draft = get(agentComposerDraftAtom) const skills = resolveDraftFieldUpdate(draft.skills, skillsUpdate) @@ -24,12 +24,12 @@ export const agentComposerSkillsAtom = atom<AgentSkill[], [DraftFieldUpdate<Agen ) export const upsertAgentSkillAtom = atom(null, (_get, set, skill: AgentSkill) => { - set(agentComposerSkillsAtom, skills => [ - ...skills.filter(item => item.id !== skill.id), + set(agentComposerSkillsAtom, (skills) => [ + ...skills.filter((item) => item.id !== skill.id), skill, ]) }) export const removeAgentSkillAtom = atom(null, (_get, set, skillId: string) => { - set(agentComposerSkillsAtom, skills => skills.filter(item => item.id !== skillId)) + set(agentComposerSkillsAtom, (skills) => skills.filter((item) => item.id !== skillId)) }) diff --git a/web/features/agent-v2/agent-composer/store-modules/tools.ts b/web/features/agent-v2/agent-composer/store-modules/tools.ts index 1f20efefdae30b..22d9beffb05393 100644 --- a/web/features/agent-v2/agent-composer/store-modules/tools.ts +++ b/web/features/agent-v2/agent-composer/store-modules/tools.ts @@ -1,4 +1,9 @@ -import type { AgentCliTool, AgentProviderTool, AgentSoulConfigFormState, AgentTool } from '../form-state' +import type { + AgentCliTool, + AgentProviderTool, + AgentSoulConfigFormState, + AgentTool, +} from '../form-state' import type { DraftFieldUpdate } from './utils' import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types' import { atom } from 'jotai' @@ -13,7 +18,7 @@ export type AgentProviderToolDefaultValue = ToolDefaultValue & { } export const agentComposerToolsAtom = atom( - get => get(agentComposerDraftAtom).tools, + (get) => get(agentComposerDraftAtom).tools, (get, set, toolsUpdate: DraftFieldUpdate<AgentTool[]>) => { const draft = get(agentComposerDraftAtom) const tools = resolveDraftFieldUpdate(draft.tools, toolsUpdate) @@ -38,44 +43,44 @@ const toProviderToolAction = (tool: AgentProviderToolDefaultValue) => ({ }) const getCredentialVariant = (tool: AgentProviderToolDefaultValue) => { - if (!tool.credentialRequired) - return 'none' as const + if (!tool.credentialRequired) return 'none' as const if (!tool.allowDelete) - return tool.credential_id ? 'authorized' as const : 'unauthorized' as const + return tool.credential_id ? ('authorized' as const) : ('unauthorized' as const) - return tool.is_team_authorization ? 'authorized' as const : 'unauthorized' as const + return tool.is_team_authorization ? ('authorized' as const) : ('unauthorized' as const) } const getCredentialType = (tool: AgentProviderToolDefaultValue) => { - if (!tool.credentialRequired) - return undefined + if (!tool.credentialRequired) return undefined - if (tool.credentialType === 'oauth2') - return 'oauth2' as const + if (tool.credentialType === 'oauth2') return 'oauth2' as const if (!tool.allowDelete) - return tool.credential_id ? 'api-key' as const : 'unauthorized' as const + return tool.credential_id ? ('api-key' as const) : ('unauthorized' as const) - return tool.is_team_authorization ? 'api-key' as const : 'unauthorized' as const + return tool.is_team_authorization ? ('api-key' as const) : ('unauthorized' as const) } export const addProviderTools = ( currentTools: AgentTool[], selectedTools: AgentProviderToolDefaultValue[], ): AgentTool[] => { - if (selectedTools.length === 0) - return currentTools + if (selectedTools.length === 0) return currentTools const nextTools = [...currentTools] selectedTools.forEach((selectedTool) => { const action = toProviderToolAction(selectedTool) - const existingToolIndex = nextTools.findIndex(tool => tool.kind === 'provider' && tool.id === selectedTool.provider_id) + const existingToolIndex = nextTools.findIndex( + (tool) => tool.kind === 'provider' && tool.id === selectedTool.provider_id, + ) const existingTool = nextTools[existingToolIndex] if (existingTool?.kind === 'provider') { - if (existingTool.actions.some(existingAction => existingAction.toolName === action.toolName)) + if ( + existingTool.actions.some((existingAction) => existingAction.toolName === action.toolName) + ) return nextTools[existingToolIndex] = { @@ -112,24 +117,27 @@ export const addProviderTools = ( return nextTools } -export const addProviderToolsAtom = atom(null, (_get, set, selectedTools: AgentProviderToolDefaultValue[]) => { - set(agentComposerToolsAtom, tools => addProviderTools(tools, selectedTools)) -}) +export const addProviderToolsAtom = atom( + null, + (_get, set, selectedTools: AgentProviderToolDefaultValue[]) => { + set(agentComposerToolsAtom, (tools) => addProviderTools(tools, selectedTools)) + }, +) export const saveCliToolAtom = atom(null, (_get, set, cliTool: AgentCliTool) => { - set(agentComposerToolsAtom, tools => ( - tools.some(tool => tool.kind === 'cli' && tool.id === cliTool.id) - ? tools.map(tool => tool.id === cliTool.id ? cliTool : tool) - : [...tools, cliTool] - )) + set(agentComposerToolsAtom, (tools) => + tools.some((tool) => tool.kind === 'cli' && tool.id === cliTool.id) + ? tools.map((tool) => (tool.id === cliTool.id ? cliTool : tool)) + : [...tools, cliTool], + ) }) export const removeCliToolAtom = atom(null, (_get, set, toolId: string) => { - set(agentComposerToolsAtom, tools => tools.filter(tool => tool.id !== toolId)) + set(agentComposerToolsAtom, (tools) => tools.filter((tool) => tool.id !== toolId)) }) export const agentComposerToolSettingsAtom = atom( - get => get(agentComposerDraftAtom).toolSettings, + (get) => get(agentComposerDraftAtom).toolSettings, (get, set, toolSettingsUpdate: DraftFieldUpdate<Record<string, Record<string, unknown>>>) => { const draft = get(agentComposerDraftAtom) @@ -155,77 +163,94 @@ const omitToolSettings = ( export const removeProviderToolAtom = atom(null, (get, set, toolId: string) => { const draft = get(agentComposerDraftAtom) - const toolToRemove = draft.tools.find(tool => tool.kind === 'provider' && tool.id === toolId) - const actionIds = toolToRemove?.kind === 'provider' - ? toolToRemove.actions.map(action => action.id) - : [] + const toolToRemove = draft.tools.find((tool) => tool.kind === 'provider' && tool.id === toolId) + const actionIds = + toolToRemove?.kind === 'provider' ? toolToRemove.actions.map((action) => action.id) : [] set(agentComposerDraftAtom, { ...draft, - tools: draft.tools.filter(tool => tool.id !== toolId), + tools: draft.tools.filter((tool) => tool.id !== toolId), toolSettings: omitToolSettings(draft.toolSettings, actionIds), }) }) -export const removeProviderToolActionAtom = atom(null, (get, set, { - toolId, - actionId, -}: { - toolId: string - actionId: string -}) => { - const draft = get(agentComposerDraftAtom) - - set(agentComposerDraftAtom, { - ...draft, - tools: draft.tools.flatMap((tool) => { - if (tool.kind !== 'provider' || tool.id !== toolId) - return [tool] - - const nextActions = tool.actions.filter(action => action.id !== actionId) - return nextActions.length > 0 - ? [{ ...tool, actions: nextActions }] - : [] - }), - toolSettings: omitToolSettings(draft.toolSettings, [actionId]), - }) -}) +export const removeProviderToolActionAtom = atom( + null, + ( + get, + set, + { + toolId, + actionId, + }: { + toolId: string + actionId: string + }, + ) => { + const draft = get(agentComposerDraftAtom) -export const setProviderToolCredentialAtom = atom(null, (_get, set, { - toolId, - credentialId, - credentialType, -}: { - toolId: string - credentialId?: string - credentialType?: AgentProviderTool['credentialType'] -}) => { - set(agentComposerToolsAtom, tools => tools.map((tool) => { - if (tool.kind !== 'provider' || tool.id !== toolId) - return tool + set(agentComposerDraftAtom, { + ...draft, + tools: draft.tools.flatMap((tool) => { + if (tool.kind !== 'provider' || tool.id !== toolId) return [tool] - const nextCredentialType = credentialType === 'oauth2' || tool.credentialType === 'oauth2' - ? 'oauth2' - : 'api-key' + const nextActions = tool.actions.filter((action) => action.id !== actionId) + return nextActions.length > 0 ? [{ ...tool, actions: nextActions }] : [] + }), + toolSettings: omitToolSettings(draft.toolSettings, [actionId]), + }) + }, +) - return { - ...tool, +export const setProviderToolCredentialAtom = atom( + null, + ( + _get, + set, + { + toolId, credentialId, - credentialType: nextCredentialType, - credentialVariant: 'authorized', - } - })) -}) + credentialType, + }: { + toolId: string + credentialId?: string + credentialType?: AgentProviderTool['credentialType'] + }, + ) => { + set(agentComposerToolsAtom, (tools) => + tools.map((tool) => { + if (tool.kind !== 'provider' || tool.id !== toolId) return tool + + const nextCredentialType = + credentialType === 'oauth2' || tool.credentialType === 'oauth2' ? 'oauth2' : 'api-key' + + return { + ...tool, + credentialId, + credentialType: nextCredentialType, + credentialVariant: 'authorized', + } + }), + ) + }, +) -export const saveProviderToolActionSettingsAtom = atom(null, (_get, set, { - actionId, - value, -}: { - actionId: string - value: Record<string, unknown> -}) => { - set(agentComposerToolSettingsAtom, toolSettings => ({ - ...toolSettings, - [actionId]: value, - })) -}) +export const saveProviderToolActionSettingsAtom = atom( + null, + ( + _get, + set, + { + actionId, + value, + }: { + actionId: string + value: Record<string, unknown> + }, + ) => { + set(agentComposerToolSettingsAtom, (toolSettings) => ({ + ...toolSettings, + [actionId]: value, + })) + }, +) diff --git a/web/features/agent-v2/agent-composer/store-modules/utils.ts b/web/features/agent-v2/agent-composer/store-modules/utils.ts index c5272001e3aec2..50a265a77db90c 100644 --- a/web/features/agent-v2/agent-composer/store-modules/utils.ts +++ b/web/features/agent-v2/agent-composer/store-modules/utils.ts @@ -3,8 +3,5 @@ export type DraftFieldUpdate<Value> = Value | ((currentValue: Value) => Value) export const resolveDraftFieldUpdate = <Value>( currentValue: Value, update: DraftFieldUpdate<Value>, -) => ( - typeof update === 'function' - ? (update as (currentValue: Value) => Value)(currentValue) - : update -) +) => + typeof update === 'function' ? (update as (currentValue: Value) => Value)(currentValue) : update diff --git a/web/features/agent-v2/agent-composer/store.ts b/web/features/agent-v2/agent-composer/store.ts index c83283b0429612..330db0f22abf90 100644 --- a/web/features/agent-v2/agent-composer/store.ts +++ b/web/features/agent-v2/agent-composer/store.ts @@ -5,22 +5,35 @@ import { atom } from 'jotai' import { defaultAgentSoulConfigFormState } from './form-state' export const agentComposerOriginalConfigAtom = atom<AgentSoulConfig | undefined>(undefined) -export const agentComposerOriginalDraftAtom = atom<AgentSoulConfigFormState | undefined>(defaultAgentSoulConfigFormState) -export const agentComposerPublishedDraftAtom = atom<AgentSoulConfigFormState | undefined>(defaultAgentSoulConfigFormState) -export const agentComposerDraftAtom = atom<AgentSoulConfigFormState>(defaultAgentSoulConfigFormState) +export const agentComposerOriginalDraftAtom = atom<AgentSoulConfigFormState | undefined>( + defaultAgentSoulConfigFormState, +) +export const agentComposerPublishedDraftAtom = atom<AgentSoulConfigFormState | undefined>( + defaultAgentSoulConfigFormState, +) +export const agentComposerDraftAtom = atom<AgentSoulConfigFormState>( + defaultAgentSoulConfigFormState, +) -export const rebaseAgentComposerDraftAtom = atom(null, (_get, set, { - draft, - originalConfig, -}: { - draft: AgentSoulConfigFormState - originalConfig?: AgentSoulConfig -}) => { - set(agentComposerOriginalConfigAtom, originalConfig) - set(agentComposerDraftAtom, draft) - set(agentComposerOriginalDraftAtom, draft) - set(agentComposerPublishedDraftAtom, draft) -}) +export const rebaseAgentComposerDraftAtom = atom( + null, + ( + _get, + set, + { + draft, + originalConfig, + }: { + draft: AgentSoulConfigFormState + originalConfig?: AgentSoulConfig + }, + ) => { + set(agentComposerOriginalConfigAtom, originalConfig) + set(agentComposerDraftAtom, draft) + set(agentComposerOriginalDraftAtom, draft) + set(agentComposerPublishedDraftAtom, draft) + }, +) export const isAgentComposerDirtyAtom = atom((get) => { const originalDraft = get(agentComposerOriginalDraftAtom) diff --git a/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx b/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx index f3a0240f309c68..1e89981f793b8d 100644 --- a/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx +++ b/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx @@ -37,7 +37,7 @@ vi.mock('@/service/client', () => ({ agent: { byAgentId: { get: { - queryOptions: vi.fn(input => input), + queryOptions: vi.fn((input) => input), }, }, }, diff --git a/web/features/agent-v2/agent-detail/__tests__/navigation.spec.tsx b/web/features/agent-v2/agent-detail/__tests__/navigation.spec.tsx index 89bbd5bb234736..a6875e96ec46b7 100644 --- a/web/features/agent-v2/agent-detail/__tests__/navigation.spec.tsx +++ b/web/features/agent-v2/agent-detail/__tests__/navigation.spec.tsx @@ -29,7 +29,7 @@ vi.mock('@/next/navigation', () => ({ })) vi.mock('@/app/components/app-sidebar/nav-link', () => ({ - default: ({ href, name }: { href: string, name: string }) => <a href={href}>{name}</a>, + default: ({ href, name }: { href: string; name: string }) => <a href={href}>{name}</a>, })) vi.mock('@/app/components/base/divider', () => ({ @@ -41,7 +41,10 @@ vi.mock('@/service/client', () => ({ agent: { byAgentId: { get: { - queryKey: ({ input }: { input: { params: { agent_id: string } } }) => ['agent-detail', input.params.agent_id], + queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [ + 'agent-detail', + input.params.agent_id, + ], queryOptions: () => ({ queryKey: ['agent-detail'] }), }, copy: { @@ -110,7 +113,12 @@ describe('AgentDetailSection', () => { expect(agentAvatar).toHaveClass('h-10', 'w-10', 'rounded-full') expect(agentAvatar?.parentElement?.parentElement).toHaveClass('mr-2') expect(agentName.parentElement?.parentElement).toHaveClass('h-10') - expect(agentName.parentElement?.parentElement?.parentElement).toHaveClass('h-13', 'py-1.5', 'pl-1.5', 'pr-2') + expect(agentName.parentElement?.parentElement?.parentElement).toHaveClass( + 'h-13', + 'py-1.5', + 'pl-1.5', + 'pr-2', + ) }) it('renders compact more actions beside the expanded sidebar agent identity', async () => { @@ -131,7 +139,9 @@ describe('AgentDetailSection', () => { it('does not render more actions in collapsed sidebar mode', () => { renderAgentDetailSection(false) - expect(screen.queryByRole('button', { name: /agentV2\.roster\.moreActions/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /agentV2\.roster\.moreActions/ }), + ).not.toBeInTheDocument() }) }) diff --git a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx index da2b8e0a645529..ffc9db412e5b5e 100644 --- a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx +++ b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx @@ -135,7 +135,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -173,11 +174,17 @@ vi.mock('@/service/client', () => ({ agent: { byAgentId: { get: { - queryKey: ({ input }: { input: { params: { agent_id: string } } }) => ['agent-detail', input.params.agent_id], + queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [ + 'agent-detail', + input.params.agent_id, + ], }, apiAccess: { get: { - queryKey: ({ input }: { input: { params: { agent_id: string } } }) => ['agent-api-access', input.params.agent_id], + queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [ + 'agent-api-access', + input.params.agent_id, + ], queryOptions: ({ input }: { input: { params: { agent_id: string } } }) => ({ queryKey: ['agent-api-access', input.params.agent_id], queryFn: () => mocks.apiAccessQueryFn(input), @@ -250,11 +257,7 @@ function createAgent(overrides: Partial<AgentAppDetailWithSite> = {}): AgentAppD function renderWithQueryClient(ui: React.ReactElement) { const queryClient = createTestQueryClient() - render( - <QueryClientProvider client={queryClient}> - {ui} - </QueryClientProvider>, - ) + render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>) return queryClient } @@ -287,10 +290,16 @@ describe('Agent access surface cards', () => { ) expect(screen.getByText('https://chat.example.test/agent/site-token')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'agentV2.agentDetail.access.webApp.actions.launch' })).toHaveAttribute('href', 'https://chat.example.test/agent/site-token') + expect( + screen.getByRole('link', { name: 'agentV2.agentDetail.access.webApp.actions.launch' }), + ).toHaveAttribute('href', 'https://chat.example.test/agent/site-token') expect(screen.getByText('agentV2.agentDetail.access.webApp.ssoEnabled')).toBeInTheDocument() - await user.click(screen.getByRole('switch', { name: 'agentV2.agentDetail.access.toggleSurface:{"name":"agentV2.agentDetail.access.webApp.title"}' })) + await user.click( + screen.getByRole('switch', { + name: 'agentV2.agentDetail.access.toggleSurface:{"name":"agentV2.agentDetail.access.webApp.title"}', + }), + ) await waitFor(() => { expect(mocks.siteEnableMutation.mock.calls[0]?.[0]).toEqual({ @@ -311,12 +320,20 @@ describe('Agent access surface cards', () => { <WebAppAccessCard agent={createAgent()} agentId="agent-1" isLoading={false} />, ) - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.customize' })) + await user.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.customize' }), + ) - const dialog = await screen.findByRole('dialog', { name: 'appOverview.overview.appInfo.customize.title' }) + const dialog = await screen.findByRole('dialog', { + name: 'appOverview.overview.appInfo.customize.title', + }) expect(dialog).toHaveTextContent(/NEXT_PUBLIC_APP_ID=\s*'app-1'/) expect(dialog).toHaveTextContent(/NEXT_PUBLIC_API_URL=\s*'https:\/\/api\.example\.test\/v1'/) - expect(within(dialog).getByRole('button', { name: /appOverview\.overview\.appInfo\.customize\.way1\.step1Operation/ })).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation') + expect( + within(dialog).getByRole('button', { + name: /appOverview\.overview\.appInfo\.customize\.way1\.step1Operation/, + }), + ).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation') }) it('should open the embedded dialog with the Agent web app route', async () => { @@ -326,17 +343,25 @@ describe('Agent access surface cards', () => { <WebAppAccessCard agent={createAgent()} agentId="agent-1" isLoading={false} />, ) - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.embedded' })) + await user.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.embedded' }), + ) - const dialog = await screen.findByRole('dialog', { name: 'appOverview.overview.appInfo.embedded.title' }) + const dialog = await screen.findByRole('dialog', { + name: 'appOverview.overview.appInfo.embedded.title', + }) await waitFor(() => { expect(dialog).toHaveTextContent('https://chat.example.test/agent/site-token') }) - await user.click(within(dialog).getByRole('button', { name: 'appOverview.overview.appInfo.embedded.scripts' })) + await user.click( + within(dialog).getByRole('button', { + name: 'appOverview.overview.appInfo.embedded.scripts', + }), + ) await waitFor(() => { - expect(dialog).toHaveTextContent('routeSegment: \'agent\'') + expect(dialog).toHaveTextContent("routeSegment: 'agent'") }) }) @@ -347,13 +372,19 @@ describe('Agent access surface cards', () => { <WebAppAccessCard agent={createAgent()} agentId="agent-1" isLoading={false} />, ) - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.embedded' })) - const dialog = await screen.findByRole('dialog', { name: 'appOverview.overview.appInfo.embedded.title' }) + await user.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.embedded' }), + ) + const dialog = await screen.findByRole('dialog', { + name: 'appOverview.overview.appInfo.embedded.title', + }) await user.click(within(dialog).getByRole('button', { name: 'Close' })) await waitFor(() => { - expect(screen.queryByRole('dialog', { name: 'appOverview.overview.appInfo.embedded.title' })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: 'appOverview.overview.appInfo.embedded.title' }), + ).not.toBeInTheDocument() }) }) @@ -389,13 +420,29 @@ describe('Agent access surface cards', () => { queryClient.setQueryData(['agent-detail', 'agent-1'], agent) const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.settings' })) - const dialog = await screen.findByRole('dialog', { name: 'appOverview.overview.appInfo.settings.title' }) + await user.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.settings' }), + ) + const dialog = await screen.findByRole('dialog', { + name: 'appOverview.overview.appInfo.settings.title', + }) await user.clear(within(dialog).getByPlaceholderText('app.appNamePlaceholder')) - await user.type(within(dialog).getByPlaceholderText('app.appNamePlaceholder'), 'Support Portal') - await user.clear(within(dialog).getByRole('textbox', { name: 'appOverview.overview.appInfo.settings.webDesc' })) - await user.type(within(dialog).getByRole('textbox', { name: 'appOverview.overview.appInfo.settings.webDesc' }), 'Updated web description.') + await user.type( + within(dialog).getByPlaceholderText('app.appNamePlaceholder'), + 'Support Portal', + ) + await user.clear( + within(dialog).getByRole('textbox', { + name: 'appOverview.overview.appInfo.settings.webDesc', + }), + ) + await user.type( + within(dialog).getByRole('textbox', { + name: 'appOverview.overview.appInfo.settings.webDesc', + }), + 'Updated web description.', + ) await user.clear(within(dialog).getByPlaceholderText('E.g #A020F0')) await user.type(within(dialog).getByPlaceholderText('E.g #A020F0'), '#123456') await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) @@ -413,7 +460,9 @@ describe('Agent access surface cards', () => { }) }) expect(mocks.siteMutation.mock.calls[0]?.[0].body).not.toHaveProperty('enable_sso') - expect(queryClient.getQueryData<AgentAppDetailWithSite>(['agent-detail', 'agent-1'])).toMatchObject({ + expect( + queryClient.getQueryData<AgentAppDetailWithSite>(['agent-detail', 'agent-1']), + ).toMatchObject({ site: { access_token: 'new-site-token', chat_color_theme: '#123456', @@ -458,22 +507,29 @@ describe('Agent access surface cards', () => { use_icon_as_answer_icon: false, }) - renderWithQueryClient( - <WebAppAccessCard agent={agent} agentId="agent-1" isLoading={false} />, - ) + renderWithQueryClient(<WebAppAccessCard agent={agent} agentId="agent-1" isLoading={false} />) - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.settings' })) - const dialog = await screen.findByRole('dialog', { name: 'appOverview.overview.appInfo.settings.title' }) - expect(within(dialog).getByAltText('app icon')).toHaveAttribute('src', 'https://files.example.test/agent-icon.png') + await user.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.settings' }), + ) + const dialog = await screen.findByRole('dialog', { + name: 'appOverview.overview.appInfo.settings.title', + }) + expect(within(dialog).getByAltText('app icon')).toHaveAttribute( + 'src', + 'https://files.example.test/agent-icon.png', + ) await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) await waitFor(() => { - expect(mocks.siteMutation.mock.calls[0]?.[0].body).toEqual(expect.objectContaining({ - icon: 'agent-image-file-id', - icon_background: undefined, - icon_type: 'image', - })) + expect(mocks.siteMutation.mock.calls[0]?.[0].body).toEqual( + expect.objectContaining({ + icon: 'agent-image-file-id', + icon_background: undefined, + icon_type: 'image', + }), + ) }) }) @@ -493,7 +549,9 @@ describe('Agent access surface cards', () => { />, ) - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.embedded' })).toBeDisabled() + expect( + screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.embedded' }), + ).toBeDisabled() }) it('should keep settings disabled until the backing app id and site data are available', () => { @@ -510,7 +568,9 @@ describe('Agent access surface cards', () => { </QueryClientProvider>, ) - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.settings' })).toBeDisabled() + expect( + screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.settings' }), + ).toBeDisabled() rerender( <QueryClientProvider client={queryClient}> @@ -518,15 +578,23 @@ describe('Agent access surface cards', () => { </QueryClientProvider>, ) - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.settings' })).toBeDisabled() + expect( + screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.settings' }), + ).toBeDisabled() }) it('should keep customize disabled until the generated contract provides the required fields', () => { renderWithQueryClient( - <WebAppAccessCard agent={createAgent({ api_base_url: null })} agentId="agent-1" isLoading={false} />, + <WebAppAccessCard + agent={createAgent({ api_base_url: null })} + agentId="agent-1" + isLoading={false} + />, ) - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.customize' })).toBeDisabled() + expect( + screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.customize' }), + ).toBeDisabled() }) }) @@ -549,7 +617,11 @@ describe('Agent access surface cards', () => { expect(await screen.findByText('https://api.example.test/v1')).toBeInTheDocument() expect(screen.getByText('2')).toBeInTheDocument() - await user.click(screen.getByRole('switch', { name: 'agentV2.agentDetail.access.toggleSurface:{"name":"agentV2.agentDetail.access.serviceApi.title"}' })) + await user.click( + screen.getByRole('switch', { + name: 'agentV2.agentDetail.access.toggleSurface:{"name":"agentV2.agentDetail.access.serviceApi.title"}', + }), + ) await waitFor(() => { expect(mocks.apiEnableMutation.mock.calls[0]?.[0]).toEqual({ @@ -592,12 +664,18 @@ describe('Agent access surface cards', () => { renderWithQueryClient(<ServiceApiAccessCard agentId="agent-1" />) - await user.click(await screen.findByRole('button', { name: /agentV2\.agentDetail\.access\.serviceApi\.actions\.apiKey/ })) + await user.click( + await screen.findByRole('button', { + name: /agentV2\.agentDetail\.access\.serviceApi\.actions\.apiKey/, + }), + ) const dialog = await screen.findByRole('dialog', { name: 'appApi.apiKeyModal.apiSecretKey' }) expect(await within(dialog).findByText('app...ing-secret-key-token')).toBeInTheDocument() - await user.click(within(dialog).getByRole('button', { name: 'appApi.apiKeyModal.createNewSecretKey' })) + await user.click( + within(dialog).getByRole('button', { name: 'appApi.apiKeyModal.createNewSecretKey' }), + ) await waitFor(() => { expect(mocks.createApiKeyMutation.mock.calls[0]?.[0]).toEqual({ diff --git a/web/features/agent-v2/agent-detail/access/components/__tests__/workflow-references-table.spec.tsx b/web/features/agent-v2/agent-detail/access/components/__tests__/workflow-references-table.spec.tsx index eda95721bbf1d9..63cdd9a729b190 100644 --- a/web/features/agent-v2/agent-detail/access/components/__tests__/workflow-references-table.spec.tsx +++ b/web/features/agent-v2/agent-detail/access/components/__tests__/workflow-references-table.spec.tsx @@ -5,7 +5,7 @@ import { WorkflowReferencesTable } from '../workflow-references-table' const mocks = vi.hoisted(() => ({ queryFn: vi.fn(), - queryOptions: vi.fn(({ enabled = true, input }: { enabled?: boolean, input: unknown }) => ({ + queryOptions: vi.fn(({ enabled = true, input }: { enabled?: boolean; input: unknown }) => ({ queryKey: ['agent-referencing-workflows', input], queryFn: () => mocks.queryFn(input), enabled, @@ -87,7 +87,9 @@ describe('WorkflowReferencesTable', () => { }) }) expect(mocks.queryFn).not.toHaveBeenCalled() - expect(screen.queryByText('agentV2.agentDetail.access.workflow.loading')).not.toBeInTheDocument() + expect( + screen.queryByText('agentV2.agentDetail.access.workflow.loading'), + ).not.toBeInTheDocument() }) }) @@ -114,9 +116,13 @@ describe('WorkflowReferencesTable', () => { expect(await screen.findByText('Support Workflow')).toBeInTheDocument() expect(screen.getByText('v3')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.access.workflow.nodeCount:{"count":2}')).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.access.workflow.nodeCount:{"count":2}'), + ).toBeInTheDocument() expect(screen.getByText('formatted-1781660000')).toBeInTheDocument() - const studioLink = screen.getByRole('link', { name: 'agentV2.agentDetail.access.workflow.openInStudioFor:{"name":"Support Workflow"}' }) + const studioLink = screen.getByRole('link', { + name: 'agentV2.agentDetail.access.workflow.openInStudioFor:{"name":"Support Workflow"}', + }) expect(studioLink).toHaveAttribute('href', '/app/workflow-app-id/workflow') expect(studioLink).toHaveAttribute('target', '_blank') expect(studioLink).toHaveAttribute('rel', 'noopener noreferrer') @@ -127,7 +133,9 @@ describe('WorkflowReferencesTable', () => { renderTable() - expect(await screen.findByText('agentV2.agentDetail.access.workflow.empty')).toBeInTheDocument() + expect( + await screen.findByText('agentV2.agentDetail.access.workflow.empty'), + ).toBeInTheDocument() }) it('should render a loading row while workflow references are pending', () => { @@ -142,13 +150,13 @@ describe('WorkflowReferencesTable', () => { describe('Error state', () => { it('should render a retry action when loading workflow references fails', async () => { const user = userEvent.setup() - mocks.queryFn - .mockRejectedValueOnce(new Error('failed')) - .mockResolvedValueOnce({ data: [] }) + mocks.queryFn.mockRejectedValueOnce(new Error('failed')).mockResolvedValueOnce({ data: [] }) renderTable() - expect(await screen.findByText('agentV2.agentDetail.access.workflow.loadFailed')).toBeInTheDocument() + expect( + await screen.findByText('agentV2.agentDetail.access.workflow.loadFailed'), + ).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) await waitFor(() => { diff --git a/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx b/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx index c8cf5b15330200..bc6f1671ff1557 100644 --- a/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx +++ b/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx @@ -26,7 +26,8 @@ export type AccessSurfaceCardProps = { busy?: boolean } -export const accessSurfaceActionClassName = 'inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 text-[13px] leading-4 font-medium text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +export const accessSurfaceActionClassName = + 'inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 text-[13px] leading-4 font-medium text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' export function AccessSurfaceCard({ title, @@ -49,25 +50,32 @@ export function AccessSurfaceCard({ const { copied, copy } = useClipboard({ timeout: 2000, onCopyError: () => { - toast.error(t($ => $['agentDetail.access.copyFailed'])) + toast.error(t(($) => $['agentDetail.access.copyFailed'])) }, }) const canCopyEndpoint = Boolean(endpoint) const switchDisabled = disabled || busy const handleCopyEndpoint = () => { - if (!canCopyEndpoint) - return + if (!canCopyEndpoint) return void copy(endpoint) } return ( - <article aria-labelledby={titleId} className="rounded-xl border border-components-panel-border bg-components-panel-bg shadow-xs"> + <article + aria-labelledby={titleId} + className="rounded-xl border border-components-panel-border bg-components-panel-bg shadow-xs" + > <div className="px-4 pt-4 pb-4"> <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2"> <div className="flex min-w-48 flex-1 items-center gap-2"> - <span className={cn('flex size-6 shrink-0 items-center justify-center rounded-lg', iconClassName)}> + <span + className={cn( + 'flex size-6 shrink-0 items-center justify-center rounded-lg', + iconClassName, + )} + > <span aria-hidden className={cn(icon, 'size-4')} /> </span> <h3 id={titleId} className="truncate system-md-semibold text-text-secondary"> @@ -77,41 +85,53 @@ export function AccessSurfaceCard({ </div> <div className="flex shrink-0 items-center gap-3"> - <span className={cn( - 'inline-flex items-center gap-1 system-xs-semibold-uppercase', - enabled ? 'text-util-colors-green-green-700' : 'text-text-tertiary', - )} + <span + className={cn( + 'inline-flex items-center gap-1 system-xs-semibold-uppercase', + enabled ? 'text-util-colors-green-green-700' : 'text-text-tertiary', + )} > <StatusDot status={enabled ? 'success' : 'disabled'} size="small" /> - {t($ => $[enabled ? 'agentDetail.access.status.inService' : 'agentDetail.access.status.outOfService'])} + {t( + ($) => + $[ + enabled + ? 'agentDetail.access.status.inService' + : 'agentDetail.access.status.outOfService' + ], + )} </span> <Switch size="md" checked={enabled} disabled={switchDisabled} - aria-label={t($ => $['agentDetail.access.toggleSurface'], { name: title })} + aria-label={t(($) => $['agentDetail.access.toggleSurface'], { name: title })} onCheckedChange={onEnabledChange} /> </div> </div> <div className="mt-3"> - <div className="system-xs-medium text-text-tertiary"> - {endpointLabel} - </div> + <div className="system-xs-medium text-text-tertiary">{endpointLabel}</div> <div className="mt-1 flex h-8 min-w-0 items-center rounded-lg bg-components-input-bg-normal px-2"> - <span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary" translate="no"> - {endpoint || t($ => $['agentDetail.access.workflow.notAvailable'])} + <span + className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary" + translate="no" + > + {endpoint || t(($) => $['agentDetail.access.workflow.notAvailable'])} </span> <Button variant="ghost" size="small" className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary" - aria-label={copied ? tCommon($ => $['operation.copied']) : copyLabel} + aria-label={copied ? tCommon(($) => $['operation.copied']) : copyLabel} disabled={!canCopyEndpoint} onClick={handleCopyEndpoint} > - <span aria-hidden className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'size-4')} /> + <span + aria-hidden + className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'size-4')} + /> </Button> {endpointActions} </div> diff --git a/web/features/agent-v2/agent-detail/access/components/agent-api-key-modal.tsx b/web/features/agent-v2/agent-detail/access/components/agent-api-key-modal.tsx index dc5c934003b911..d3106ac47e900d 100644 --- a/web/features/agent-v2/agent-detail/access/components/agent-api-key-modal.tsx +++ b/web/features/agent-v2/agent-detail/access/components/agent-api-key-modal.tsx @@ -11,7 +11,13 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' @@ -46,44 +52,48 @@ export function AgentApiKeyModal({ ...apiKeysQueryOptions, enabled: open, }) - const createApiKeyMutation = useMutation(consoleQuery.agent.byAgentId.apiKeys.post.mutationOptions({ - onSuccess: (createdKey) => { - setNewKey(createdKey) - queryClient.invalidateQueries({ queryKey: apiKeysQueryOptions.queryKey }) - queryClient.invalidateQueries({ - queryKey: consoleQuery.agent.byAgentId.apiAccess.get.queryKey({ - input: { - params: { - agent_id: agentId, + const createApiKeyMutation = useMutation( + consoleQuery.agent.byAgentId.apiKeys.post.mutationOptions({ + onSuccess: (createdKey) => { + setNewKey(createdKey) + queryClient.invalidateQueries({ queryKey: apiKeysQueryOptions.queryKey }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.agent.byAgentId.apiAccess.get.queryKey({ + input: { + params: { + agent_id: agentId, + }, }, - }, - }), - }) - toast.success(tCommon($ => $['actionMsg.modifiedSuccessfully'])) - }, - onError: () => { - toast.error(tCommon($ => $['actionMsg.modifiedUnsuccessfully'])) - }, - })) - const deleteApiKeyMutation = useMutation(consoleQuery.agent.byAgentId.apiKeys.byApiKeyId.delete.mutationOptions({ - onSuccess: () => { - setApiKeyToDelete(null) - queryClient.invalidateQueries({ queryKey: apiKeysQueryOptions.queryKey }) - queryClient.invalidateQueries({ - queryKey: consoleQuery.agent.byAgentId.apiAccess.get.queryKey({ - input: { - params: { - agent_id: agentId, + }), + }) + toast.success(tCommon(($) => $['actionMsg.modifiedSuccessfully'])) + }, + onError: () => { + toast.error(tCommon(($) => $['actionMsg.modifiedUnsuccessfully'])) + }, + }), + ) + const deleteApiKeyMutation = useMutation( + consoleQuery.agent.byAgentId.apiKeys.byApiKeyId.delete.mutationOptions({ + onSuccess: () => { + setApiKeyToDelete(null) + queryClient.invalidateQueries({ queryKey: apiKeysQueryOptions.queryKey }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.agent.byAgentId.apiAccess.get.queryKey({ + input: { + params: { + agent_id: agentId, + }, }, - }, - }), - }) - toast.success(tCommon($ => $['actionMsg.modifiedSuccessfully'])) - }, - onError: () => { - toast.error(tCommon($ => $['actionMsg.modifiedUnsuccessfully'])) - }, - })) + }), + }) + toast.success(tCommon(($) => $['actionMsg.modifiedSuccessfully'])) + }, + onError: () => { + toast.error(tCommon(($) => $['actionMsg.modifiedUnsuccessfully'])) + }, + }), + ) const apiKeys = apiKeysQuery.data?.data ?? [] const isCreating = createApiKeyMutation.isPending const isDeleting = deleteApiKeyMutation.isPending @@ -97,8 +107,7 @@ export function AgentApiKeyModal({ } function handleDeleteApiKey() { - if (!apiKeyToDelete) - return + if (!apiKeyToDelete) return deleteApiKeyMutation.mutate({ params: { @@ -123,28 +132,31 @@ export function AgentApiKeyModal({ <DialogContent className="flex w-full max-w-[800px]! flex-col overflow-hidden px-8"> <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['apiKeyModal.apiSecretKey'])} + {t(($) => $['apiKeyModal.apiSecretKey'])} </DialogTitle> <DialogDescription className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $['apiKeyModal.apiSecretKeyTips'])} + {t(($) => $['apiKeyModal.apiSecretKeyTips'])} </DialogDescription> <div className="mt-4 min-h-20 overflow-hidden"> <div className="flex h-9 shrink-0 items-center border-b border-divider-regular text-xs font-semibold text-text-tertiary"> - <div className="w-64 shrink-0 px-3">{t($ => $['apiKeyModal.secretKey'])}</div> - <div className="w-[200px] shrink-0 px-3">{t($ => $['apiKeyModal.created'])}</div> - <div className="w-[200px] shrink-0 px-3">{t($ => $['apiKeyModal.lastUsed'])}</div> + <div className="w-64 shrink-0 px-3">{t(($) => $['apiKeyModal.secretKey'])}</div> + <div className="w-[200px] shrink-0 px-3">{t(($) => $['apiKeyModal.created'])}</div> + <div className="w-[200px] shrink-0 px-3">{t(($) => $['apiKeyModal.lastUsed'])}</div> <div className="grow px-3" /> </div> <div className="max-h-[280px] overflow-auto"> {apiKeysQuery.isPending && ( - <div role="status" className="flex h-20 items-center justify-center system-sm-regular text-text-tertiary"> - {t($ => $.loading)} + <div + role="status" + className="flex h-20 items-center justify-center system-sm-regular text-text-tertiary" + > + {t(($) => $.loading)} </div> )} {apiKeysQuery.isError && ( <div className="flex h-20 items-center justify-center gap-2 system-sm-regular text-text-tertiary"> - <span>{tCommon($ => $['api.actionFailed'])}</span> + <span>{tCommon(($) => $['api.actionFailed'])}</span> <Button variant="secondary" size="small" @@ -152,80 +164,90 @@ export function AgentApiKeyModal({ void apiKeysQuery.refetch() }} > - {tCommon($ => $['operation.retry'])} + {tCommon(($) => $['operation.retry'])} </Button> </div> )} {apiKeysQuery.isSuccess && apiKeys.length === 0 && ( <div className="flex h-20 items-center justify-center system-sm-regular text-text-tertiary"> - {tCommon($ => $.noData)} + {tCommon(($) => $.noData)} </div> )} - {apiKeysQuery.isSuccess && apiKeys.map(apiKey => ( - <div className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary last:border-b-0" key={apiKey.id}> - <div className="w-64 shrink-0 truncate px-3 font-mono" translate="no"> - {maskApiKey(apiKey.token)} - </div> - <div className="w-[200px] shrink-0 truncate px-3"> - {apiKey.created_at ? formatTime(apiKey.created_at, t($ => $.dateTimeFormat, { ns: 'appLog' })) : t($ => $.never)} - </div> - <div className="w-[200px] shrink-0 truncate px-3"> - {apiKey.last_used_at ? formatTime(apiKey.last_used_at, t($ => $.dateTimeFormat, { ns: 'appLog' })) : t($ => $.never)} - </div> - <div className="flex grow gap-2 px-3"> - <CopyFeedback content={apiKey.token} /> - <Button - variant="ghost" - size="small" - className="size-6 px-0 text-text-tertiary hover:text-text-secondary" - aria-label={tCommon($ => $['operation.delete'])} - disabled={isDeleting} - onClick={() => setApiKeyToDelete(apiKey)} - > - <span aria-hidden className="i-ri-delete-bin-line size-4" /> - </Button> + {apiKeysQuery.isSuccess && + apiKeys.map((apiKey) => ( + <div + className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary last:border-b-0" + key={apiKey.id} + > + <div className="w-64 shrink-0 truncate px-3 font-mono" translate="no"> + {maskApiKey(apiKey.token)} + </div> + <div className="w-[200px] shrink-0 truncate px-3"> + {apiKey.created_at + ? formatTime( + apiKey.created_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }), + ) + : t(($) => $.never)} + </div> + <div className="w-[200px] shrink-0 truncate px-3"> + {apiKey.last_used_at + ? formatTime( + apiKey.last_used_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }), + ) + : t(($) => $.never)} + </div> + <div className="flex grow gap-2 px-3"> + <CopyFeedback content={apiKey.token} /> + <Button + variant="ghost" + size="small" + className="size-6 px-0 text-text-tertiary hover:text-text-secondary" + aria-label={tCommon(($) => $['operation.delete'])} + disabled={isDeleting} + onClick={() => setApiKeyToDelete(apiKey)} + > + <span aria-hidden className="i-ri-delete-bin-line size-4" /> + </Button> + </div> </div> - </div> - ))} + ))} </div> </div> <div className="mt-4 flex justify-start"> <Button onClick={handleCreateApiKey} loading={isCreating}> <span aria-hidden className="mr-1 i-heroicons-plus-20-solid size-4" /> - {t($ => $['apiKeyModal.createNewSecretKey'])} + {t(($) => $['apiKeyModal.createNewSecretKey'])} </Button> </div> </DialogContent> </Dialog> - <AgentApiKeyGenerateModal - apiKey={newKey} - onClose={() => setNewKey(null)} - /> + <AgentApiKeyGenerateModal apiKey={newKey} onClose={() => setNewKey(null)} /> <AlertDialog open={Boolean(apiKeyToDelete)} onOpenChange={(nextOpen) => { - if (!nextOpen) - setApiKeyToDelete(null) + if (!nextOpen) setApiKeyToDelete(null) }} > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['actionMsg.deleteConfirmTitle'])} + {t(($) => $['actionMsg.deleteConfirmTitle'])} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['actionMsg.deleteConfirmTips'])} + {t(($) => $['actionMsg.deleteConfirmTips'])} </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton> - {tCommon($ => $['operation.cancel'])} + {tCommon(($) => $['operation.cancel'])} </AlertDialogCancelButton> <AlertDialogConfirmButton loading={isDeleting} onClick={handleDeleteApiKey}> - {tCommon($ => $['operation.confirm'])} + {tCommon(($) => $['operation.confirm'])} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> @@ -247,27 +269,29 @@ function AgentApiKeyGenerateModal({ <Dialog open={Boolean(apiKey)} onOpenChange={(nextOpen) => { - if (!nextOpen) - onClose() + if (!nextOpen) onClose() }} > <DialogContent className="w-full max-w-[480px]! overflow-hidden px-8"> <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['apiKeyModal.apiSecretKey'])} + {t(($) => $['apiKeyModal.apiSecretKey'])} </DialogTitle> <DialogDescription className="mt-1 text-[13px] leading-5 font-normal text-text-tertiary"> - {t($ => $['apiKeyModal.generateTips'])} + {t(($) => $['apiKeyModal.generateTips'])} </DialogDescription> <div className="my-4 flex h-9 min-w-0 items-center rounded-lg bg-components-input-bg-normal px-2"> - <span className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary" translate="no"> + <span + className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary" + translate="no" + > {apiKey?.token} </span> {apiKey && <CopyFeedback content={apiKey.token} />} </div> <div className="my-4 flex justify-end"> <Button className="w-16 shrink-0" onClick={onClose}> - {t($ => $['actionMsg.ok'])} + {t(($) => $['actionMsg.ok'])} </Button> </div> </DialogContent> @@ -276,8 +300,7 @@ function AgentApiKeyGenerateModal({ } function maskApiKey(token: string) { - if (token.length <= 24) - return token + if (token.length <= 24) return token return `${token.slice(0, 3)}...${token.slice(-20)}` } diff --git a/web/features/agent-v2/agent-detail/access/components/service-api-access-card.tsx b/web/features/agent-v2/agent-detail/access/components/service-api-access-card.tsx index bed8e1a8d715a9..c2b91c303b2f2d 100644 --- a/web/features/agent-v2/agent-detail/access/components/service-api-access-card.tsx +++ b/web/features/agent-v2/agent-detail/access/components/service-api-access-card.tsx @@ -11,11 +11,7 @@ import { consoleQuery } from '@/service/client' import { accessSurfaceActionClassName, AccessSurfaceCard } from './access-surface-card' import { AgentApiKeyModal } from './agent-api-key-modal' -export function ServiceApiAccessCard({ - agentId, -}: { - agentId: string -}) { +export function ServiceApiAccessCard({ agentId }: { agentId: string }) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const docLink = useDocLink() @@ -30,24 +26,27 @@ export function ServiceApiAccessCard({ }) const apiAccessQuery = useQuery(apiAccessQueryOptions) const apiAccess = apiAccessQuery.data - const toggleServiceApiMutation = useMutation(consoleQuery.agent.byAgentId.apiEnable.post.mutationOptions({ - onSuccess: (updatedApiAccess, variables) => { - queryClient.setQueryData(apiAccessQueryOptions.queryKey, updatedApiAccess) - queryClient.setQueryData<AgentAppDetailWithSite | undefined>( - consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }), - agentDetail => agentDetail - ? { - ...agentDetail, - enable_api: variables.body.enable_api, - } - : agentDetail, - ) - toast.success(tCommon($ => $['actionMsg.modifiedSuccessfully'])) - }, - onError: () => { - toast.error(tCommon($ => $['actionMsg.modifiedUnsuccessfully'])) - }, - })) + const toggleServiceApiMutation = useMutation( + consoleQuery.agent.byAgentId.apiEnable.post.mutationOptions({ + onSuccess: (updatedApiAccess, variables) => { + queryClient.setQueryData(apiAccessQueryOptions.queryKey, updatedApiAccess) + queryClient.setQueryData<AgentAppDetailWithSite | undefined>( + consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }), + (agentDetail) => + agentDetail + ? { + ...agentDetail, + enable_api: variables.body.enable_api, + } + : agentDetail, + ) + toast.success(tCommon(($) => $['actionMsg.modifiedSuccessfully'])) + }, + onError: () => { + toast.error(tCommon(($) => $['actionMsg.modifiedUnsuccessfully'])) + }, + }), + ) const isBusy = apiAccessQuery.isPending || toggleServiceApiMutation.isPending function handleEnabledChange(enabled: boolean) { @@ -64,14 +63,14 @@ export function ServiceApiAccessCard({ return ( <> <AccessSurfaceCard - title={t($ => $['agentDetail.access.serviceApi.title'])} + title={t(($) => $['agentDetail.access.serviceApi.title'])} icon="i-ri-node-tree" iconClassName="bg-state-accent-solid text-text-primary-on-surface" - endpointLabel={t($ => $['agentDetail.access.serviceApi.endpoint'])} + endpointLabel={t(($) => $['agentDetail.access.serviceApi.endpoint'])} endpoint={apiAccess?.service_api_base_url ?? ''} enabled={Boolean(apiAccess?.enabled)} onEnabledChange={handleEnabledChange} - copyLabel={t($ => $['agentDetail.access.copyServiceEndpoint'])} + copyLabel={t(($) => $['agentDetail.access.copyServiceEndpoint'])} disabled={apiAccessQuery.isPending || apiAccessQuery.isError} busy={toggleServiceApiMutation.isPending} > @@ -83,7 +82,7 @@ export function ServiceApiAccessCard({ onClick={() => setApiKeyModalOpen(true)} > <span aria-hidden className="i-ri-key-2-line size-4" /> - {t($ => $['agentDetail.access.serviceApi.actions.apiKey'])} + {t(($) => $['agentDetail.access.serviceApi.actions.apiKey'])} <span className="rounded-md bg-components-badge-bg-gray-soft px-1.5 code-xs-regular text-text-tertiary"> {apiAccess?.api_key_count ?? 0} </span> @@ -92,11 +91,11 @@ export function ServiceApiAccessCard({ href={docLink('/api-reference/guides/get-started')} target="_blank" rel="noreferrer" - aria-label={t($ => $['agentDetail.access.serviceApi.actions.apiReference'])} + aria-label={t(($) => $['agentDetail.access.serviceApi.actions.apiReference'])} className={accessSurfaceActionClassName} > <span aria-hidden className="i-ri-book-open-line size-4" /> - {t($ => $['agentDetail.access.serviceApi.actions.apiReference'])} + {t(($) => $['agentDetail.access.serviceApi.actions.apiReference'])} </a> {apiAccessQuery.isError && ( <Button @@ -108,7 +107,7 @@ export function ServiceApiAccessCard({ }} > <span aria-hidden className="i-ri-refresh-line size-4" /> - {tCommon($ => $['operation.retry'])} + {tCommon(($) => $['operation.retry'])} </Button> )} </AccessSurfaceCard> diff --git a/web/features/agent-v2/agent-detail/access/components/web-app-access-card.tsx b/web/features/agent-v2/agent-detail/access/components/web-app-access-card.tsx index 340d20e5f4f16d..4ba49c85248328 100644 --- a/web/features/agent-v2/agent-detail/access/components/web-app-access-card.tsx +++ b/web/features/agent-v2/agent-detail/access/components/web-app-access-card.tsx @@ -34,80 +34,91 @@ export function WebAppAccessCard({ const apiBaseUrl = agent?.api_base_url const site = agent?.site const accessToken = site?.access_token ?? site?.code - const appBaseUrl = site?.app_base_url || (typeof window === 'undefined' ? '' : window.location.origin) + const appBaseUrl = + site?.app_base_url || (typeof window === 'undefined' ? '' : window.location.origin) const webAppUrl = getAgentWebAppUrl(agent) const isEnabled = Boolean(agent?.enable_site) const canManageWebApp = Boolean(appId) - const embeddedConfig = appId && accessToken - ? { - accessToken, - appBaseUrl, - siteInfo: { - title: site?.title ?? agent?.name ?? '', - chat_color_theme: site?.chat_color_theme ?? undefined, - chat_color_theme_inverted: site?.chat_color_theme_inverted ?? undefined, - }, - } - : null + const embeddedConfig = + appId && accessToken + ? { + accessToken, + appBaseUrl, + siteInfo: { + title: site?.title ?? agent?.name ?? '', + chat_color_theme: site?.chat_color_theme ?? undefined, + chat_color_theme_inverted: site?.chat_color_theme_inverted ?? undefined, + }, + } + : null const settingsAppInfo = agent ? createSettingsAppInfo(agent) : null - const customizeConfig = appId && apiBaseUrl - ? { - apiBaseUrl, - appId, - } - : null + const customizeConfig = + appId && apiBaseUrl + ? { + apiBaseUrl, + appId, + } + : null const showSsoBadge = agent?.access_mode === AccessMode.EXTERNAL_MEMBERS const [showCustomizeModal, setShowCustomizeModal] = useState(false) const [showEmbeddedModal, setShowEmbeddedModal] = useState(false) const [showSettingsModal, setShowSettingsModal] = useState(false) - const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }) - const toggleSiteMutation = useMutation(consoleQuery.apps.byAppId.siteEnable.post.mutationOptions({ - onSuccess: (_updatedApp, variables) => { - queryClient.setQueryData<AgentAppDetailWithSite | undefined>( - agentDetailQueryKey, - agentDetail => agentDetail - ? { + const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ + input: { params: { agent_id: agentId } }, + }) + const toggleSiteMutation = useMutation( + consoleQuery.apps.byAppId.siteEnable.post.mutationOptions({ + onSuccess: (_updatedApp, variables) => { + queryClient.setQueryData<AgentAppDetailWithSite | undefined>( + agentDetailQueryKey, + (agentDetail) => + agentDetail + ? { + ...agentDetail, + enable_site: variables.body.enable_site, + } + : agentDetail, + ) + toast.success(tCommon(($) => $['actionMsg.modifiedSuccessfully'])) + }, + onError: () => { + toast.error(tCommon(($) => $['actionMsg.modifiedUnsuccessfully'])) + }, + }), + ) + const resetAccessTokenMutation = useMutation( + consoleQuery.apps.byAppId.site.accessTokenReset.post.mutationOptions({ + onSuccess: (site) => { + queryClient.setQueryData<AgentAppDetailWithSite | undefined>( + agentDetailQueryKey, + (agentDetail) => { + if (!agentDetail || !agentDetail.site) return agentDetail + + return { ...agentDetail, - enable_site: variables.body.enable_site, + site: { + ...agentDetail.site, + ...site, + access_token: site.code, + }, } - : agentDetail, - ) - toast.success(tCommon($ => $['actionMsg.modifiedSuccessfully'])) - }, - onError: () => { - toast.error(tCommon($ => $['actionMsg.modifiedUnsuccessfully'])) - }, - })) - const resetAccessTokenMutation = useMutation(consoleQuery.apps.byAppId.site.accessTokenReset.post.mutationOptions({ - onSuccess: (site) => { - queryClient.setQueryData<AgentAppDetailWithSite | undefined>( - agentDetailQueryKey, - (agentDetail) => { - if (!agentDetail || !agentDetail.site) - return agentDetail - - return { - ...agentDetail, - site: { - ...agentDetail.site, - ...site, - access_token: site.code, - }, - } - }, - ) - toast.success(tCommon($ => $['actionMsg.generatedSuccessfully'])) - }, - onError: () => { - toast.error(tCommon($ => $['actionMsg.generatedUnsuccessfully'])) - }, - })) + }, + ) + toast.success(tCommon(($) => $['actionMsg.generatedSuccessfully'])) + }, + onError: () => { + toast.error(tCommon(($) => $['actionMsg.generatedUnsuccessfully'])) + }, + }), + ) const updateSiteMutation = useMutation(consoleQuery.apps.byAppId.site.post.mutationOptions()) - const isBusy = toggleSiteMutation.isPending || resetAccessTokenMutation.isPending || updateSiteMutation.isPending + const isBusy = + toggleSiteMutation.isPending || + resetAccessTokenMutation.isPending || + updateSiteMutation.isPending function handleEnabledChange(enabled: boolean) { - if (!appId) - return + if (!appId) return toggleSiteMutation.mutate({ params: { @@ -120,8 +131,7 @@ export function WebAppAccessCard({ } function handleRefreshUrl() { - if (!appId) - return + if (!appId) return resetAccessTokenMutation.mutate({ params: { @@ -131,8 +141,7 @@ export function WebAppAccessCard({ } async function handleSaveSettings(params: ConfigParams) { - if (!appId) - return + if (!appId) return const { enable_sso: _enableSso, ...body } = params const sitePayload = body satisfies AppSiteUpdatePayload @@ -147,80 +156,86 @@ export function WebAppAccessCard({ queryClient.setQueryData<AgentAppDetailWithSite | undefined>( agentDetailQueryKey, - agentDetail => agentDetail - ? { - ...agentDetail, - site: { - ...agentDetail.site, - ...updatedSite, - ...sitePayload, - access_token: updatedSite.code ?? agentDetail.site?.access_token ?? agentDetail.site?.code ?? null, - code: updatedSite.code ?? agentDetail.site?.code ?? agentDetail.site?.access_token ?? null, - app_base_url: agentDetail.site?.app_base_url ?? site?.app_base_url ?? null, - icon_url: null, - }, - } - : agentDetail, + (agentDetail) => + agentDetail + ? { + ...agentDetail, + site: { + ...agentDetail.site, + ...updatedSite, + ...sitePayload, + access_token: + updatedSite.code ?? + agentDetail.site?.access_token ?? + agentDetail.site?.code ?? + null, + code: + updatedSite.code ?? + agentDetail.site?.code ?? + agentDetail.site?.access_token ?? + null, + app_base_url: agentDetail.site?.app_base_url ?? site?.app_base_url ?? null, + icon_url: null, + }, + } + : agentDetail, ) await queryClient.invalidateQueries({ queryKey: agentDetailQueryKey }) - toast.success(tCommon($ => $['actionMsg.modifiedSuccessfully'])) - } - catch { - toast.error(tCommon($ => $['actionMsg.modifiedUnsuccessfully'])) + toast.success(tCommon(($) => $['actionMsg.modifiedSuccessfully'])) + } catch { + toast.error(tCommon(($) => $['actionMsg.modifiedUnsuccessfully'])) } } return ( <AccessSurfaceCard - title={t($ => $['agentDetail.access.webApp.title'])} + title={t(($) => $['agentDetail.access.webApp.title'])} icon="i-ri-window-line" iconClassName="bg-state-accent-solid text-text-primary-on-surface" - endpointLabel={t($ => $['agentDetail.access.webApp.accessUrl'])} + endpointLabel={t(($) => $['agentDetail.access.webApp.accessUrl'])} endpoint={webAppUrl} enabled={isEnabled} onEnabledChange={handleEnabledChange} - copyLabel={t($ => $['agentDetail.access.copyAccessUrl'])} + copyLabel={t(($) => $['agentDetail.access.copyAccessUrl'])} badge={showSsoBadge ? <SsoBadge /> : undefined} - endpointActions={webAppUrl - ? ( - <> - <span className="mx-1.5 h-3.5 w-px shrink-0 bg-divider-regular" /> - <ShareQRCode content={webAppUrl} /> - <Button - variant="ghost" - size="small" - className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary" - aria-label={t($ => $['agentDetail.access.webApp.refreshUrl'])} - disabled={!canManageWebApp || isBusy} - onClick={handleRefreshUrl} - > - <span aria-hidden className="i-ri-refresh-line size-4" /> - </Button> - </> - ) - : undefined} + endpointActions={ + webAppUrl ? ( + <> + <span className="mx-1.5 h-3.5 w-px shrink-0 bg-divider-regular" /> + <ShareQRCode content={webAppUrl} /> + <Button + variant="ghost" + size="small" + className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary" + aria-label={t(($) => $['agentDetail.access.webApp.refreshUrl'])} + disabled={!canManageWebApp || isBusy} + onClick={handleRefreshUrl} + > + <span aria-hidden className="i-ri-refresh-line size-4" /> + </Button> + </> + ) : undefined + } disabled={isLoading || !canManageWebApp} busy={isBusy} > - {webAppUrl && isEnabled - ? ( - <a - href={webAppUrl} - target="_blank" - rel="noreferrer" - aria-label={t($ => $['agentDetail.access.webApp.actions.launch'])} - className={accessSurfaceActionClassName} - > - <span aria-hidden className="i-ri-external-link-line size-4" /> - {t($ => $['agentDetail.access.webApp.actions.launch'])} - </a> - ) - : ( - <Button variant="secondary" size="medium" className="gap-1.5 px-3" disabled> - <span aria-hidden className="i-ri-external-link-line size-4" /> - {t($ => $['agentDetail.access.webApp.actions.launch'])} - </Button> - )} + {webAppUrl && isEnabled ? ( + <a + href={webAppUrl} + target="_blank" + rel="noreferrer" + aria-label={t(($) => $['agentDetail.access.webApp.actions.launch'])} + className={accessSurfaceActionClassName} + > + <span aria-hidden className="i-ri-external-link-line size-4" /> + {t(($) => $['agentDetail.access.webApp.actions.launch'])} + </a> + ) : ( + <Button variant="secondary" size="medium" className="gap-1.5 px-3" disabled> + <span aria-hidden className="i-ri-external-link-line size-4" /> + {t(($) => $['agentDetail.access.webApp.actions.launch'])} + </Button> + )} <Button variant="secondary" size="medium" @@ -229,7 +244,7 @@ export function WebAppAccessCard({ onClick={() => setShowEmbeddedModal(true)} > <span aria-hidden className="i-ri-window-line size-4" /> - {t($ => $['agentDetail.access.webApp.actions.embedded'])} + {t(($) => $['agentDetail.access.webApp.actions.embedded'])} </Button> <Button variant="secondary" @@ -239,7 +254,7 @@ export function WebAppAccessCard({ onClick={() => setShowCustomizeModal(true)} > <span aria-hidden className="i-ri-paint-brush-line size-4" /> - {t($ => $['agentDetail.access.webApp.actions.customize'])} + {t(($) => $['agentDetail.access.webApp.actions.customize'])} </Button> <Button variant="secondary" @@ -249,7 +264,7 @@ export function WebAppAccessCard({ onClick={() => setShowSettingsModal(true)} > <span aria-hidden className="i-ri-palette-line size-4" /> - {t($ => $['agentDetail.access.webApp.actions.settings'])} + {t(($) => $['agentDetail.access.webApp.actions.settings'])} </Button> {settingsAppInfo && ( <SettingsModal @@ -286,8 +301,7 @@ export function WebAppAccessCard({ function createSettingsAppInfo(agent: AgentAppDetailWithSite): SettingsAppInfo | null { const site = agent.site const appId = agent.app_id - if (!site || !appId) - return null + if (!site || !appId) return null const icon = getSettingsIcon(agent) return { @@ -296,7 +310,8 @@ function createSettingsAppInfo(agent: AgentAppDetailWithSite): SettingsAppInfo | site: { title: site.title ?? agent.name, description: site.description ?? agent.description ?? '', - default_language: (site.default_language ?? 'en-US') as SettingsAppInfo['site']['default_language'], + default_language: (site.default_language ?? + 'en-US') as SettingsAppInfo['site']['default_language'], chat_color_theme: site.chat_color_theme ?? '', chat_color_theme_inverted: site.chat_color_theme_inverted ?? false, copyright: site.copyright ?? '', @@ -333,9 +348,7 @@ function getSettingsIcon(agent: AgentAppDetailWithSite) { icon_type: agent.icon_type, icon: agent.icon, icon_background: agent.icon_background ?? null, - icon_url: agent.icon_type === 'image' || agent.icon_type === 'link' - ? agent.icon_url - : null, + icon_url: agent.icon_type === 'image' || agent.icon_type === 'link' ? agent.icon_url : null, } } @@ -350,10 +363,10 @@ function getSettingsIcon(agent: AgentAppDetailWithSite) { function getAgentWebAppUrl(agent?: AgentAppDetailWithSite) { const site = agent?.site const token = site?.access_token ?? site?.code - if (!token) - return '' + if (!token) return '' - const baseUrl = site?.app_base_url || (typeof window === 'undefined' ? '' : window.location.origin) + const baseUrl = + site?.app_base_url || (typeof window === 'undefined' ? '' : window.location.origin) return `${baseUrl.replace(/\/$/, '')}/agent/${token}` } @@ -363,7 +376,7 @@ function SsoBadge() { return ( <span className="inline-flex h-4.5 shrink-0 items-center gap-1 rounded-sm border border-divider-deep px-1.5 system-2xs-semibold-uppercase text-text-tertiary"> <span aria-hidden className="i-ri-shield-check-line size-3" /> - {t($ => $['agentDetail.access.webApp.ssoEnabled'])} + {t(($) => $['agentDetail.access.webApp.ssoEnabled'])} </span> ) } diff --git a/web/features/agent-v2/agent-detail/access/components/workflow-references-table.tsx b/web/features/agent-v2/agent-detail/access/components/workflow-references-table.tsx index 2cebb0a2cc994c..173bca5608af51 100644 --- a/web/features/agent-v2/agent-detail/access/components/workflow-references-table.tsx +++ b/web/features/agent-v2/agent-detail/access/components/workflow-references-table.tsx @@ -1,6 +1,9 @@ 'use client' -import type { AgentIconType, AgentReferencingWorkflowResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentIconType, + AgentReferencingWorkflowResponse, +} from '@dify/contracts/api/console/agent/types.gen' import type { ReactNode } from 'react' import { Button } from '@langgenius/dify-ui/button' import { useQuery } from '@tanstack/react-query' @@ -17,22 +20,22 @@ type WorkflowReferencesTableProps = { const workflowTableColSpan = 5 -const getWorkflowReferenceHref = (reference: AgentReferencingWorkflowResponse) => `/app/${reference.app_id}/workflow` +const getWorkflowReferenceHref = (reference: AgentReferencingWorkflowResponse) => + `/app/${reference.app_id}/workflow` -export function WorkflowReferencesTable({ - agentId, - enabled = true, -}: WorkflowReferencesTableProps) { +export function WorkflowReferencesTable({ agentId, enabled = true }: WorkflowReferencesTableProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') - const workflowReferencesQuery = useQuery(consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({ - input: { - params: { - agent_id: agentId, + const workflowReferencesQuery = useQuery( + consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, }, - }, - enabled, - })) + enabled, + }), + ) const workflowReferences = workflowReferencesQuery.data?.data ?? [] return ( @@ -48,32 +51,32 @@ export function WorkflowReferencesTable({ <thead> <tr className="h-7 rounded-lg bg-background-section-burn text-left system-xs-semibold-uppercase text-text-tertiary"> <th scope="col" className="rounded-l-lg px-3 font-semibold"> - {t($ => $['agentDetail.access.workflow.table.name'])} + {t(($) => $['agentDetail.access.workflow.table.name'])} </th> <th scope="col" className="px-3 font-semibold"> - {t($ => $['agentDetail.access.workflow.table.version'])} + {t(($) => $['agentDetail.access.workflow.table.version'])} </th> <th scope="col" className="px-3 font-semibold"> - {t($ => $['agentDetail.access.workflow.table.nodes'])} + {t(($) => $['agentDetail.access.workflow.table.nodes'])} </th> <th scope="col" className="px-3 font-semibold"> - {t($ => $['agentDetail.access.workflow.table.lastUpdated'])} + {t(($) => $['agentDetail.access.workflow.table.lastUpdated'])} </th> <th scope="col" className="rounded-r-lg px-3 font-semibold"> - {t($ => $['agentDetail.access.workflow.table.actions'])} + {t(($) => $['agentDetail.access.workflow.table.actions'])} </th> </tr> </thead> <tbody className="system-sm-regular text-text-secondary"> {enabled && workflowReferencesQuery.isPending && ( <WorkflowAccessStateRow> - {t($ => $['agentDetail.access.workflow.loading'])} + {t(($) => $['agentDetail.access.workflow.loading'])} </WorkflowAccessStateRow> )} {enabled && workflowReferencesQuery.isError && ( <WorkflowAccessStateRow> <div className="flex items-center justify-center gap-2"> - <span>{t($ => $['agentDetail.access.workflow.loadFailed'])}</span> + <span>{t(($) => $['agentDetail.access.workflow.loadFailed'])}</span> <Button variant="secondary" size="small" @@ -81,40 +84,48 @@ export function WorkflowReferencesTable({ void workflowReferencesQuery.refetch() }} > - {tCommon($ => $['operation.retry'])} + {tCommon(($) => $['operation.retry'])} </Button> </div> </WorkflowAccessStateRow> )} {enabled && workflowReferencesQuery.isSuccess && workflowReferences.length === 0 && ( <WorkflowAccessStateRow> - {t($ => $['agentDetail.access.workflow.empty'])} + {t(($) => $['agentDetail.access.workflow.empty'])} </WorkflowAccessStateRow> )} - {enabled && workflowReferencesQuery.isSuccess && workflowReferences.map(reference => ( - <WorkflowAccessRow - key={`${reference.app_id}:${reference.workflow_id}`} - reference={reference} - /> - ))} + {enabled && + workflowReferencesQuery.isSuccess && + workflowReferences.map((reference) => ( + <WorkflowAccessRow + key={`${reference.app_id}:${reference.workflow_id}`} + reference={reference} + /> + ))} </tbody> </table> </div> ) } -function WorkflowAccessRow({ - reference, -}: { - reference: AgentReferencingWorkflowResponse -}) { +function WorkflowAccessRow({ reference }: { reference: AgentReferencingWorkflowResponse }) { const { t } = useTranslation('agentV2') const { formatTime } = useTimestamp() - const imageUrl = (reference.app_icon_type === 'image' || reference.app_icon_type === 'link') ? reference.app_icon : undefined - const iconType = (imageUrl ? 'image' : reference.app_icon_type) as AgentIconType | null | undefined - const updatedAt = reference.app_updated_at != null - ? formatTime(reference.app_updated_at, t($ => $['roster.dateTimeFormat'])) - : t($ => $['agentDetail.access.workflow.notAvailable']) + const imageUrl = + reference.app_icon_type === 'image' || reference.app_icon_type === 'link' + ? reference.app_icon + : undefined + const iconType = (imageUrl ? 'image' : reference.app_icon_type) as + | AgentIconType + | null + | undefined + const updatedAt = + reference.app_updated_at != null + ? formatTime( + reference.app_updated_at, + t(($) => $['roster.dateTimeFormat']), + ) + : t(($) => $['agentDetail.access.workflow.notAvailable']) const nodeCount = reference.node_ids?.length ?? 0 return ( @@ -131,16 +142,14 @@ function WorkflowAccessRow({ imageUrl={imageUrl} /> </span> - <span className="truncate"> - {reference.app_name} - </span> + <span className="truncate">{reference.app_name}</span> </div> </td> <td className="truncate px-3" translate="no"> {reference.workflow_version} </td> <td className="px-3 tabular-nums"> - {t($ => $['agentDetail.access.workflow.nodeCount'], { count: nodeCount })} + {t(($) => $['agentDetail.access.workflow.nodeCount'], { count: nodeCount })} </td> <td className="px-3 tabular-nums" translate="no"> {updatedAt} @@ -150,10 +159,12 @@ function WorkflowAccessRow({ href={getWorkflowReferenceHref(reference)} target="_blank" rel="noopener noreferrer" - aria-label={t($ => $['agentDetail.access.workflow.openInStudioFor'], { name: reference.app_name })} + aria-label={t(($) => $['agentDetail.access.workflow.openInStudioFor'], { + name: reference.app_name, + })} className="inline-flex items-center gap-0.5 rounded-sm text-text-secondary hover:text-text-accent hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > - {t($ => $['agentDetail.access.workflow.openInStudio'])} + {t(($) => $['agentDetail.access.workflow.openInStudio'])} <span aria-hidden className="i-ri-external-link-line size-4" /> </Link> </td> @@ -161,14 +172,14 @@ function WorkflowAccessRow({ ) } -function WorkflowAccessStateRow({ - children, -}: { - children: ReactNode -}) { +function WorkflowAccessStateRow({ children }: { children: ReactNode }) { return ( <tr className="h-20 border-b border-divider-subtle"> - <td colSpan={workflowTableColSpan} aria-live="polite" className="px-3 text-center text-text-tertiary"> + <td + colSpan={workflowTableColSpan} + aria-live="polite" + className="px-3 text-center text-text-tertiary" + > {children} </td> </tr> diff --git a/web/features/agent-v2/agent-detail/access/page.tsx b/web/features/agent-v2/agent-detail/access/page.tsx index ffbd790be596ad..05663bd0aadc24 100644 --- a/web/features/agent-v2/agent-detail/access/page.tsx +++ b/web/features/agent-v2/agent-detail/access/page.tsx @@ -14,35 +14,35 @@ type AgentAccessPageProps = { agentId: string } -export function AgentAccessPage({ - agentId, -}: AgentAccessPageProps) { +export function AgentAccessPage({ agentId }: AgentAccessPageProps) { const { t } = useTranslation('agentV2') const docLink = useDocLink() - const agentQuery = useQuery(consoleQuery.agent.byAgentId.get.queryOptions({ - input: { - params: { - agent_id: agentId, + const agentQuery = useQuery( + consoleQuery.agent.byAgentId.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, }, - }, - })) + }), + ) return ( - <AgentDetailSectionSurface label={t($ => $['agentDetail.sections.access'])}> + <AgentDetailSectionSurface label={t(($) => $['agentDetail.sections.access'])}> <header className="h-15.5 shrink-0 px-6 pt-3 pb-2"> <div className="min-w-0"> <h2 className="system-xl-semibold text-text-primary"> - {t($ => $['agentDetail.access.title'])} + {t(($) => $['agentDetail.access.title'])} </h2> <p className="mt-1 flex min-w-0 flex-wrap items-center gap-x-0.5 system-xs-regular text-text-tertiary"> - <span>{t($ => $['agentDetail.access.description'])}</span> + <span>{t(($) => $['agentDetail.access.description'])}</span> <a href={docLink('/use-dify/publish/webapp/web-app-settings')} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-0.5 rounded-sm text-text-accent hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > - {t($ => $['agentDetail.access.learnMore'])} + {t(($) => $['agentDetail.access.learnMore'])} <span aria-hidden className="i-ri-external-link-line size-3" /> </a> </p> @@ -57,17 +57,21 @@ export function AgentAccessPage({ > <div className="w-full min-w-0 space-y-6"> <div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2"> - <WebAppAccessCard agent={agentQuery.data} agentId={agentId} isLoading={agentQuery.isPending} /> + <WebAppAccessCard + agent={agentQuery.data} + agentId={agentId} + isLoading={agentQuery.isPending} + /> <ServiceApiAccessCard agentId={agentId} /> </div> <section aria-labelledby="agent-workflow-access-title"> <div className="mb-3"> <h3 id="agent-workflow-access-title" className="system-md-semibold text-text-primary"> - {t($ => $['agentDetail.access.workflow.title'])} + {t(($) => $['agentDetail.access.workflow.title'])} </h3> <p className="mt-0.5 system-xs-regular text-text-tertiary"> - {t($ => $['agentDetail.access.workflow.description'])} + {t(($) => $['agentDetail.access.workflow.description'])} </p> </div> diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts b/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts index 1a17dd2e773e91..296fd60ad7f64f 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts +++ b/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts @@ -1,4 +1,7 @@ -import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + Model, + ModelItem, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { ConfigurationMethodEnum, ModelStatusEnum, @@ -25,10 +28,15 @@ const createModelItem = (model: string, overrides: Partial<ModelItem> = {}): Mod ...overrides, }) -const createModelItemWithLabel = (model: string, label: string, overrides: Partial<ModelItem> = {}): ModelItem => createModelItem(model, { - label: { en_US: label, zh_Hans: label }, - ...overrides, -}) +const createModelItemWithLabel = ( + model: string, + label: string, + overrides: Partial<ModelItem> = {}, +): ModelItem => + createModelItem(model, { + label: { en_US: label, zh_Hans: label }, + ...overrides, + }) describe('isAgentCompatibleModel', () => { it('should reject configured GPT models below the Agent-compatible baseline', () => { @@ -60,20 +68,28 @@ describe('isAgentCompatibleModel', () => { }) it('should allow models that are not in the blacklist', () => { - expect(isAgentCompatibleModel(createModel('any-provider'), createModelItem('gpt-5.5'))).toBe(true) - expect(isAgentCompatibleModel(createModel('any-provider'), createModelItem('other-model'))).toBe(true) + expect(isAgentCompatibleModel(createModel('any-provider'), createModelItem('gpt-5.5'))).toBe( + true, + ) + expect( + isAgentCompatibleModel(createModel('any-provider'), createModelItem('other-model')), + ).toBe(true) }) it('should reject specifically configured models that do not meet the Agent baseline', () => { const provider = createModel('any-provider') expect(isAgentCompatibleModel(provider, createModelItem('claude-3-haiku-20240307'))).toBe(false) - expect(isAgentCompatibleModel(provider, createModelItem('claude-3.5-sonnet-20241022'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('claude-3.5-sonnet-20241022'))).toBe( + false, + ) expect(isAgentCompatibleModel(provider, createModelItem('gemini-2.5-flash-lite'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('Gemini 2.5 Flash'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('Gemini 2.0 Flash'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('gemini-2.5-pro-preview'))).toBe(false) - expect(isAgentCompatibleModel(provider, createModelItem('Gemini 3.1 Flash-Lite Preview'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('Gemini 3.1 Flash-Lite Preview'))).toBe( + false, + ) expect(isAgentCompatibleModel(provider, createModelItem('gemini-1.5-flash-8b'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('Nano Banana Pro'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('grok-code-fast'))).toBe(false) @@ -86,7 +102,9 @@ describe('isAgentCompatibleModel', () => { expect(isAgentCompatibleModel(provider, createModelItem('deepseek-reasoner'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('deepseek-chat-v3'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('deepseek-R1'))).toBe(false) - expect(isAgentCompatibleModel(provider, createModelItem('DeepSeek-R1-Distill-Qwen-32B'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('DeepSeek-R1-Distill-Qwen-32B'))).toBe( + false, + ) expect(isAgentCompatibleModel(provider, createModelItem('deepseek-v3.1'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('abab6.5-chat'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('minimax-text-01'))).toBe(false) @@ -99,7 +117,9 @@ describe('isAgentCompatibleModel', () => { expect(isAgentCompatibleModel(provider, createModelItem('qwq-plus'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('qwen-max'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('qwen2.5-72b-instruct'))).toBe(false) - expect(isAgentCompatibleModel(provider, createModelItem('qwen2.5-coder-32b-instruct'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('qwen2.5-coder-32b-instruct'))).toBe( + false, + ) expect(isAgentCompatibleModel(provider, createModelItem('qwen3-coder-plus'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('qwen3.5-max'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('qwen3.7-flash'))).toBe(false) @@ -114,16 +134,29 @@ describe('isAgentCompatibleModel', () => { }) it('should ignore provider when evaluating blacklist patterns', () => { - expect(isAgentCompatibleModel(createModel('custom-provider'), createModelItem('gpt-4o'))).toBe(false) - expect(isAgentCompatibleModel(createModel('custom-provider'), createModelItem('claude-3-haiku-20240307'))).toBe(false) - expect(isAgentCompatibleModel(createModel('openai'), createModelItem('claude-sonnet-4'))).toBe(true) + expect(isAgentCompatibleModel(createModel('custom-provider'), createModelItem('gpt-4o'))).toBe( + false, + ) + expect( + isAgentCompatibleModel( + createModel('custom-provider'), + createModelItem('claude-3-haiku-20240307'), + ), + ).toBe(false) + expect(isAgentCompatibleModel(createModel('openai'), createModelItem('claude-sonnet-4'))).toBe( + true, + ) }) it('should evaluate blacklist patterns against the English model label', () => { const provider = createModel('any-provider') - expect(isAgentCompatibleModel(provider, createModelItemWithLabel('model-id', 'gpt-4o'))).toBe(false) - expect(isAgentCompatibleModel(provider, createModelItemWithLabel('gpt-4o', 'safe-model-label'))).toBe(true) + expect(isAgentCompatibleModel(provider, createModelItemWithLabel('model-id', 'gpt-4o'))).toBe( + false, + ) + expect( + isAgentCompatibleModel(provider, createModelItemWithLabel('gpt-4o', 'safe-model-label')), + ).toBe(true) }) it('should allow unconfigured models from providers with blacklisted model families', () => { @@ -147,7 +180,9 @@ describe('isAgentSuggestedModel', () => { expect(isAgentSuggestedModel(provider, createModelItem('opus-4.7'))).toBe(true) expect(isAgentSuggestedModel(provider, createModelItem('Claude Sonnet 4.6'))).toBe(true) expect(isAgentSuggestedModel(provider, createModelItem('Gemini 3.1 Pro Preview'))).toBe(false) - expect(isAgentSuggestedModel(provider, createModelItem('Gemini 3.1 Pro Preview 001'))).toBe(false) + expect(isAgentSuggestedModel(provider, createModelItem('Gemini 3.1 Pro Preview 001'))).toBe( + false, + ) expect(isAgentSuggestedModel(provider, createModelItem('grok-4.3'))).toBe(true) expect(isAgentSuggestedModel(provider, createModelItem('deepseek-v4-pro'))).toBe(true) expect(isAgentSuggestedModel(provider, createModelItem('kimi-k2.6'))).toBe(true) @@ -161,7 +196,11 @@ describe('isAgentSuggestedModel', () => { it('should evaluate suggestions against the English model label', () => { const provider = createModel('any-provider') - expect(isAgentSuggestedModel(provider, createModelItemWithLabel('model-id', 'GPT 5.5 Pro'))).toBe(true) - expect(isAgentSuggestedModel(provider, createModelItemWithLabel('gpt-5.5', 'safe-model-label'))).toBe(false) + expect( + isAgentSuggestedModel(provider, createModelItemWithLabel('model-id', 'GPT 5.5 Pro')), + ).toBe(true) + expect( + isAgentSuggestedModel(provider, createModelItemWithLabel('gpt-5.5', 'safe-model-label')), + ).toBe(false) }) }) diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx index 0dbe6f0354e859..96b4119836135c 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx @@ -64,7 +64,7 @@ const modelHooksState = vi.hoisted(() => ({ provider: 'langgenius/openai/openai', }, model: 'gpt-4o-mini', - } as { provider: { provider: string }, model: string } | undefined, + } as { provider: { provider: string }; model: string } | undefined, })) function createDeferredPromise<T>() { @@ -76,7 +76,10 @@ function createDeferredPromise<T>() { return { promise, resolve } } -function expectFirstMockCallBefore(first: ReturnType<typeof vi.fn>, second: ReturnType<typeof vi.fn>) { +function expectFirstMockCallBefore( + first: ReturnType<typeof vi.fn>, + second: ReturnType<typeof vi.fn>, +) { const firstCallOrder = first.mock.invocationCallOrder[0] const secondCallOrder = second.mock.invocationCallOrder[0] @@ -94,14 +97,10 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { useQuery: vi.fn((options: { queryKey?: readonly string[] }) => { const queryKey = options.queryKey?.[0] - if (queryKey === 'agent') - return mocks.queryState.agent - if (queryKey === 'composer') - return mocks.queryState.composer - if (queryKey === 'version') - return mocks.queryState.version - if (queryKey === 'build-draft') - return mocks.queryState.buildDraft + if (queryKey === 'agent') return mocks.queryState.agent + if (queryKey === 'composer') return mocks.queryState.composer + if (queryKey === 'version') return mocks.queryState.version + if (queryKey === 'build-draft') return mocks.queryState.buildDraft return { data: undefined, @@ -219,7 +218,8 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () vi.mock('../components/orchestrate', async () => { const { useAtomValue } = await import('jotai') - const { agentComposerPromptAtom } = await import('@/features/agent-v2/agent-composer/store-modules/prompt') + const { agentComposerPromptAtom } = + await import('@/features/agent-v2/agent-composer/store-modules/prompt') return { AgentOrchestratePanel: (props: { @@ -237,7 +237,9 @@ vi.mock('../components/orchestrate', async () => { <span>{`readonly:${props.readOnly ? 'yes' : 'no'}`}</span> <span>{`publish:${props.showPublishBar ? 'yes' : 'no'}`}</span> <span>{`prompt:${prompt}`}</span> - <button type="button" onClick={props.onOpenVersions}>open versions</button> + <button type="button" onClick={props.onOpenVersions}> + open versions + </button> {props.bottomAction} </div> ) @@ -255,8 +257,12 @@ vi.mock('../components/orchestrate/build-draft-bar', () => ({ }) => ( <div role="region" aria-label="build-draft-bar"> <span>{`changes:${props.changesCount}`}</span> - <button type="button" disabled={props.disabled} onClick={props.onApply}>apply build draft</button> - <button type="button" disabled={props.disabled} onClick={props.onDiscard}>discard build draft</button> + <button type="button" disabled={props.disabled} onClick={props.onApply}> + apply build draft + </button> + <button type="button" disabled={props.disabled} onClick={props.onDiscard}> + discard build draft + </button> </div> ), })) @@ -279,21 +285,30 @@ vi.mock('../components/preview/build-chat', async () => { <span>{`build:${props.conversationId ?? 'none'}`}</span> <span>{`clear:${props.clearChatList ? 'yes' : 'no'}`}</span> <span>{`sent:${messageSent ? 'yes' : 'no'}`}</span> - <button type="button" onClick={() => props.onConversationIdChange?.('build-conversation-new')}> + <button + type="button" + onClick={() => props.onConversationIdChange?.('build-conversation-new')} + > save build conversation </button> <button type="button" onClick={() => { - void props.onSaveDraftBeforeRun?.().then(() => { - setMessageSent(true) - props.onConversationIdChange?.('build-conversation-new') - }).catch(() => undefined) + void props + .onSaveDraftBeforeRun?.() + .then(() => { + setMessageSent(true) + props.onConversationIdChange?.('build-conversation-new') + }) + .catch(() => undefined) }} > send build message </button> - <button type="button" onClick={() => props.onConversationComplete?.('build-conversation-new')}> + <button + type="button" + onClick={() => props.onConversationComplete?.('build-conversation-new')} + > complete build conversation </button> </div> @@ -309,7 +324,10 @@ vi.mock('../components/preview/preview-chat', () => ({ }) => ( <div role="region" aria-label="preview-chat"> <span>{`preview:${props.conversationId ?? 'none'}`}</span> - <button type="button" onClick={() => props.onConversationIdChange?.('preview-conversation-new')}> + <button + type="button" + onClick={() => props.onConversationIdChange?.('preview-conversation-new')} + > save preview conversation </button> </div> @@ -323,14 +341,13 @@ vi.mock('../components/preview/chat-features-panel', () => ({ } disabled?: boolean show: boolean - }) => props.show - ? ( - <div role="region" aria-label="chat-features-panel"> - <span>{`chatFeaturesDisabled:${props.disabled ? 'yes' : 'no'}`}</span> - <span>{`opening:${props.appFeatures?.opening_statement ?? ''}`}</span> - </div> - ) - : null, + }) => + props.show ? ( + <div role="region" aria-label="chat-features-panel"> + <span>{`chatFeaturesDisabled:${props.disabled ? 'yes' : 'no'}`}</span> + <span>{`opening:${props.appFeatures?.opening_statement ?? ''}`}</span> + </div> + ) : null, })) vi.mock('../components/preview/header', () => ({ @@ -346,7 +363,11 @@ vi.mock('../components/preview/header', () => ({ }) => ( <div> <div>{props.mode}</div> - <button type="button" disabled={!props.previewEnabled} onClick={() => props.onModeChange('preview')}> + <button + type="button" + disabled={!props.previewEnabled} + onClick={() => props.onModeChange('preview')} + > preview mode </button> <button type="button" onClick={() => props.onModeChange('build')}> @@ -369,7 +390,9 @@ vi.mock('../components/preview/header', () => ({ vi.mock('../components/preview/versions-panel', () => ({ AgentPreviewVersionsPanel: (props: { onSelectVersion: (versionId: string) => void }) => ( - <button type="button" onClick={() => props.onSelectVersion('snapshot-2')}>select version</button> + <button type="button" onClick={() => props.onSelectVersion('snapshot-2')}> + select version + </button> ), })) @@ -451,7 +474,9 @@ describe('AgentConfigurePage', () => { </QueryClientProvider>, ) - const configureSection = screen.getByRole('region', { name: 'agentV2.agentDetail.sections.configure' }) + const configureSection = screen.getByRole('region', { + name: 'agentV2.agentDetail.sections.configure', + }) expect(configureSection).toHaveAttribute('aria-busy', 'true') expect(configureSection).toHaveClass('bg-background-body') expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() @@ -475,8 +500,12 @@ describe('AgentConfigurePage', () => { </QueryClientProvider>, ) - expect(screen.getAllByRole('region', { name: 'agentV2.agentDetail.sections.configure' })).toHaveLength(1) - expect(screen.getByRole('region', { name: 'agentV2.agentDetail.sections.configure' })).toBeVisible() + expect( + screen.getAllByRole('region', { name: 'agentV2.agentDetail.sections.configure' }), + ).toHaveLength(1) + expect( + screen.getByRole('region', { name: 'agentV2.agentDetail.sections.configure' }), + ).toBeVisible() expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toBeInTheDocument() }) }) @@ -500,21 +529,28 @@ describe('AgentConfigurePage', () => { </QueryClientProvider>, ) - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:debug-conversation-old') + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:debug-conversation-old', + ) expect(screen.queryByRole('region', { name: 'preview-chat' })).not.toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'save build conversation' })) - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new') + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:build-conversation-new', + ) expect(mocks.refreshDebugConversation).not.toHaveBeenCalled() await user.click(screen.getByRole('button', { name: 'restart preview' })) - expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', + expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, }, - }, expect.any(Object)) + expect.any(Object), + ) expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') @@ -545,7 +581,9 @@ describe('AgentConfigurePage', () => { </QueryClientProvider>, ) - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:debug-conversation-old') + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:debug-conversation-old', + ) expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('clear:no') await user.click(screen.getByRole('button', { name: 'restart preview' })) @@ -591,7 +629,9 @@ describe('AgentConfigurePage', () => { </QueryClientProvider>, ) - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:debug-conversation-new') + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:debug-conversation-new', + ) expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('clear:no') }) @@ -618,8 +658,12 @@ describe('AgentConfigurePage', () => { await user.click(previewButton) - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:debug-conversation-old') - expect(screen.queryByRole('region', { name: 'preview-chat', hidden: true })).not.toBeInTheDocument() + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:debug-conversation-old', + ) + expect( + screen.queryByRole('region', { name: 'preview-chat', hidden: true }), + ).not.toBeInTheDocument() }) it('should disable restart when the debug conversation has no messages', async () => { @@ -691,9 +735,15 @@ describe('AgentConfigurePage', () => { </QueryClientProvider>, ) - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:no') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('publish:yes') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:no', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:no', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'publish:yes', + ) expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument() }) @@ -775,9 +825,15 @@ describe('AgentConfigurePage', () => { </QueryClientProvider>, ) - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:yes') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:yes') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('publish:no') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:yes', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:yes', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'publish:no', + ) expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument() }) @@ -822,7 +878,9 @@ describe('AgentConfigurePage', () => { </QueryClientProvider>, ) - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:yes') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:yes', + ) fireEvent.click(screen.getByRole('button', { name: 'send build message' })) @@ -882,7 +940,9 @@ describe('AgentConfigurePage', () => { expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes') }) expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled() - expect(screen.queryByRole('button', { name: 'open working directory' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'open working directory' }), + ).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' })) @@ -943,7 +1003,9 @@ describe('AgentConfigurePage', () => { </QueryClientProvider>, ) - expect(screen.queryByRole('button', { name: 'open working directory' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'open working directory' }), + ).not.toBeInTheDocument() }) it('should show chat features from the active build draft source as read-only', async () => { @@ -996,8 +1058,12 @@ describe('AgentConfigurePage', () => { await user.click(screen.getByRole('button', { name: 'chat features' })) - expect(screen.getByRole('region', { name: 'chat-features-panel' })).toHaveTextContent('chatFeaturesDisabled:yes') - expect(screen.getByRole('region', { name: 'chat-features-panel' })).toHaveTextContent('opening:build opening') + expect(screen.getByRole('region', { name: 'chat-features-panel' })).toHaveTextContent( + 'chatFeaturesDisabled:yes', + ) + expect(screen.getByRole('region', { name: 'chat-features-panel' })).toHaveTextContent( + 'opening:build opening', + ) }) it('should switch to build draft mode without resetting the sending chat when sending from normal draft mode', async () => { @@ -1033,27 +1099,40 @@ describe('AgentConfigurePage', () => { </QueryClientProvider>, ) - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:no') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:no', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:no', + ) fireEvent.click(screen.getByRole('button', { name: 'send build message' })) await waitFor(() => { - expect(mocks.checkoutBuildDraft).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - body: { - force: false, + expect(mocks.checkoutBuildDraft).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + body: { + force: false, + }, }, - }, expect.any(Object)) + expect.any(Object), + ) }) await waitFor(() => { expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes') }) - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:yes') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:yes') + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:build-conversation-new', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:yes', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:yes', + ) expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled() expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled() @@ -1100,7 +1179,9 @@ describe('AgentConfigurePage', () => { }) expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:no') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:no', + ) }) it('should keep the build draft bar disabled while a build conversation is responding', async () => { @@ -1462,7 +1543,9 @@ describe('AgentConfigurePage', () => { await staleRefresh.promise }) - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('prompt:build prompt') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'prompt:build prompt', + ) expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled() expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled() }) @@ -1501,19 +1584,24 @@ describe('AgentConfigurePage', () => { await user.click(screen.getByRole('button', { name: 'restart preview' })) - await waitFor(() => expect(mocks.discardBuildDraft).toHaveBeenCalledWith( + await waitFor(() => + expect(mocks.discardBuildDraft).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + }, + expect.any(Object), + ), + ) + expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( { params: { agent_id: 'agent-1', }, }, expect.any(Object), - )) - expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - }, expect.any(Object)) + ) expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') }) @@ -1564,8 +1652,12 @@ describe('AgentConfigurePage', () => { await user.click(screen.getByRole('button', { name: 'open versions' })) await user.click(screen.getByRole('button', { name: 'select version' })) - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:yes') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('publish:yes') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:yes', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'publish:yes', + ) expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument() }) @@ -1628,22 +1720,26 @@ describe('AgentConfigurePage', () => { await user.click(screen.getByRole('button', { name: 'apply build draft' })) - await waitFor(() => expect(mocks.finalizeBuildChat).toHaveBeenCalledWith( - { - params: { - agent_id: 'agent-1', + await waitFor(() => + expect(mocks.finalizeBuildChat).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, }, - }, - expect.any(Object), - )) - await waitFor(() => expect(mocks.applyBuildDraft).toHaveBeenCalledWith( - { - params: { - agent_id: 'agent-1', + expect.any(Object), + ), + ) + await waitFor(() => + expect(mocks.applyBuildDraft).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, }, - }, - expect.any(Object), - )) + expect.any(Object), + ), + ) expectFirstMockCallBefore(mocks.finalizeBuildChat, mocks.applyBuildDraft) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['agent'], @@ -1651,15 +1747,20 @@ describe('AgentConfigurePage', () => { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['agents'], }) - expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', + expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, }, - }, expect.any(Object)) + expect.any(Object), + ) expect(refetchComposer).toHaveBeenCalled() expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') await waitFor(() => { - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('prompt:applied prompt') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'prompt:applied prompt', + ) }) }) @@ -1722,33 +1823,44 @@ describe('AgentConfigurePage', () => { await user.click(screen.getByRole('button', { name: 'apply build draft' })) - await waitFor(() => expect(mocks.finalizeBuildChat).toHaveBeenCalledWith( - { - params: { - agent_id: 'agent-1', + await waitFor(() => + expect(mocks.finalizeBuildChat).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, }, - }, - expect.any(Object), - )) - await waitFor(() => expect(mocks.applyBuildDraft).toHaveBeenCalledWith( + expect.any(Object), + ), + ) + await waitFor(() => + expect(mocks.applyBuildDraft).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + }, + expect.any(Object), + ), + ) + expectFirstMockCallBefore(mocks.finalizeBuildChat, mocks.applyBuildDraft) + expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( { params: { agent_id: 'agent-1', }, }, expect.any(Object), - )) - expectFirstMockCallBefore(mocks.finalizeBuildChat, mocks.applyBuildDraft) - expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - }, expect.any(Object)) + ) expect(refetchComposer).toHaveBeenCalled() expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') await waitFor(() => { - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no') - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('prompt:applied prompt') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:no', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'prompt:applied prompt', + ) }) }) @@ -1786,19 +1898,24 @@ describe('AgentConfigurePage', () => { await user.click(screen.getByRole('button', { name: 'discard build draft' })) - await waitFor(() => expect(mocks.discardBuildDraft).toHaveBeenCalledWith( + await waitFor(() => + expect(mocks.discardBuildDraft).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + }, + expect.any(Object), + ), + ) + expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( { params: { agent_id: 'agent-1', }, }, expect.any(Object), - )) - expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - }, expect.any(Object)) + ) expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') }) @@ -1837,22 +1954,29 @@ describe('AgentConfigurePage', () => { await user.click(screen.getByRole('button', { name: 'discard build draft' })) - await waitFor(() => expect(mocks.discardBuildDraft).toHaveBeenCalledWith( + await waitFor(() => + expect(mocks.discardBuildDraft).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + }, + expect.any(Object), + ), + ) + expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( { params: { agent_id: 'agent-1', }, }, expect.any(Object), - )) - expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - }, expect.any(Object)) + ) expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') await waitFor(() => { - expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:no', + ) }) }) }) diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx index 0061772e233f78..25fec02876afed 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx @@ -4,7 +4,10 @@ import { act, renderHook } from '@testing-library/react' import { createStore, Provider as JotaiProvider } from 'jotai' import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' -import { agentComposerDraftAtom, agentComposerPublishedDraftAtom } from '@/features/agent-v2/agent-composer/store' +import { + agentComposerDraftAtom, + agentComposerPublishedDraftAtom, +} from '@/features/agent-v2/agent-composer/store' import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files' import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt' import { agentComposerSkillsAtom } from '@/features/agent-v2/agent-composer/store-modules/skills' @@ -15,33 +18,44 @@ const toastMock = vi.hoisted(() => ({ success: vi.fn(), })) -const composerPutMutationFn = vi.hoisted(() => vi.fn(async (variables: { - body: { - agent_soul: Record<string, unknown> - } -}) => ({ - agent_soul: variables.body.agent_soul, -}))) - -const composerPutMutationOptions = vi.hoisted(() => vi.fn((options?: { - onSuccess?: (data: { agent_soul: Record<string, unknown> }, variables: { - params: { agent_id: string } - body: { - agent_soul: Record<string, unknown> - } - }) => void -}) => ({ - mutationFn: async (variables: { - params: { agent_id: string } - body: { - agent_soul: Record<string, unknown> - } - }) => { - const data = await composerPutMutationFn(variables) - options?.onSuccess?.(data, variables) - return data - }, -}))) +const composerPutMutationFn = vi.hoisted(() => + vi.fn( + async (variables: { + body: { + agent_soul: Record<string, unknown> + } + }) => ({ + agent_soul: variables.body.agent_soul, + }), + ), +) + +const composerPutMutationOptions = vi.hoisted(() => + vi.fn( + (options?: { + onSuccess?: ( + data: { agent_soul: Record<string, unknown> }, + variables: { + params: { agent_id: string } + body: { + agent_soul: Record<string, unknown> + } + }, + ) => void + }) => ({ + mutationFn: async (variables: { + params: { agent_id: string } + body: { + agent_soul: Record<string, unknown> + } + }) => { + const data = await composerPutMutationFn(variables) + options?.onSuccess?.(data, variables) + return data + }, + }), + ), +) type PublishAgentVariables = { params: { agent_id: string } @@ -54,26 +68,31 @@ type PublishAgentResponse = { result: string } -const publishAgentMutationFn = vi.hoisted(() => vi.fn(async (_variables: PublishAgentVariables): Promise<PublishAgentResponse> => ({ - active_config_snapshot: { - id: 'snapshot-1', - }, - active_config_snapshot_id: 'snapshot-1', - result: 'success', -}))) - -const publishAgentMutationOptions = vi.hoisted(() => vi.fn((options?: { - onSuccess?: ( - data: PublishAgentResponse, - variables: PublishAgentVariables, - ) => void -}) => ({ - mutationFn: async (variables: PublishAgentVariables) => { - const data = await publishAgentMutationFn(variables) - options?.onSuccess?.(data, variables) - return data - }, -}))) +const publishAgentMutationFn = vi.hoisted(() => + vi.fn( + async (_variables: PublishAgentVariables): Promise<PublishAgentResponse> => ({ + active_config_snapshot: { + id: 'snapshot-1', + }, + active_config_snapshot_id: 'snapshot-1', + result: 'success', + }), + ), +) + +const publishAgentMutationOptions = vi.hoisted(() => + vi.fn( + (options?: { + onSuccess?: (data: PublishAgentResponse, variables: PublishAgentVariables) => void + }) => ({ + mutationFn: async (variables: PublishAgentVariables) => { + const data = await publishAgentMutationFn(variables) + options?.onSuccess?.(data, variables) + return data + }, + }), + ), +) function createDeferredPromise<T>() { let resolve!: (value: T) => void @@ -155,19 +174,21 @@ function renderUseAgentConfigureSync({ const store = createStore() const wrapper = ({ children }: PropsWithChildren) => ( <QueryClientProvider client={queryClient}> - <JotaiProvider store={store}> - {children} - </JotaiProvider> + <JotaiProvider store={store}>{children}</JotaiProvider> </QueryClientProvider> ) return { - ...renderHook(() => useAgentConfigureSync({ - agentId: 'agent-1', - baseConfig, - currentModel, - enabled: true, - }), { wrapper }), + ...renderHook( + () => + useAgentConfigureSync({ + agentId: 'agent-1', + baseConfig, + currentModel, + enabled: true, + }), + { wrapper }, + ), queryClient, store, } @@ -212,20 +233,22 @@ describe('useAgentConfigureSync', () => { await vi.advanceTimersByTimeAsync(5000) }) - expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ - params: { - agent_id: 'agent-1', - }, - body: expect.objectContaining({ - variant: 'agent_app', - save_strategy: 'save_to_current_version', - agent_soul: expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Draft only prompt', + expect(composerPutMutationFn).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + agent_id: 'agent-1', + }, + body: expect.objectContaining({ + variant: 'agent_app', + save_strategy: 'save_to_current_version', + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Draft only prompt', + }), }), }), }), - })) + ) expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toEqual({ agent_soul: expect.objectContaining({ prompt: expect.objectContaining({ @@ -294,20 +317,22 @@ describe('useAgentConfigureSync', () => { }) expect(composerPutMutationFn).toHaveBeenCalledTimes(1) - expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ - params: { - agent_id: 'agent-1', - }, - body: expect.objectContaining({ - variant: 'agent_app', - save_strategy: 'save_to_current_version', - agent_soul: expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Closing prompt', + expect(composerPutMutationFn).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + agent_id: 'agent-1', + }, + body: expect.objectContaining({ + variant: 'agent_app', + save_strategy: 'save_to_current_version', + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Closing prompt', + }), }), }), }), - })) + ) await act(async () => { saveDeferred.resolve({ agent_soul: {} }) @@ -333,20 +358,22 @@ describe('useAgentConfigureSync', () => { }) expect(composerPutMutationFn).toHaveBeenCalledTimes(1) - expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ - params: { - agent_id: 'agent-1', - }, - body: expect.objectContaining({ - variant: 'agent_app', - save_strategy: 'save_to_current_version', - agent_soul: expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Route leave prompt', + expect(composerPutMutationFn).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + agent_id: 'agent-1', + }, + body: expect.objectContaining({ + variant: 'agent_app', + save_strategy: 'save_to_current_version', + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Route leave prompt', + }), }), }), }), - })) + ) }) it('should include Agent Soul files when autosaving file changes', async () => { @@ -371,20 +398,22 @@ describe('useAgentConfigureSync', () => { await vi.advanceTimersByTimeAsync(5000) }) - expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ - agent_soul: expect.objectContaining({ - config_files: [ - { - file_id: 'drive-file-1', - file_kind: 'upload_file', - name: 'uploaded.md', - }, - ], - config_skills: [], + expect(composerPutMutationFn).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + config_files: [ + { + file_id: 'drive-file-1', + file_kind: 'upload_file', + name: 'uploaded.md', + }, + ], + config_skills: [], + }), }), }), - })) + ) }) it('should preserve uploaded skills when prompt is updated immediately after upload', async () => { @@ -409,27 +438,29 @@ describe('useAgentConfigureSync', () => { await vi.advanceTimersByTimeAsync(5000) }) - expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ - agent_soul: expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Use [§skill:Tender Analyzer:Tender Analyzer§]', + expect(composerPutMutationFn).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Use [§skill:Tender Analyzer:Tender Analyzer§]', + }), + config_skills: [ + { + description: 'Extracts tender requirements.', + file_id: 'tool-file-1', + file_kind: 'tool_file', + hash: 'sha256:skill-1', + mime_type: 'application/zip', + name: 'Tender Analyzer', + size: 42, + }, + ], + config_files: [], }), - config_skills: [ - { - description: 'Extracts tender requirements.', - file_id: 'tool-file-1', - file_kind: 'tool_file', - hash: 'sha256:skill-1', - mime_type: 'application/zip', - name: 'Tender Analyzer', - size: 42, - }, - ], - config_files: [], }), }), - })) + ) }) it('should preserve uploaded files when prompt is updated immediately after upload', async () => { @@ -455,26 +486,28 @@ describe('useAgentConfigureSync', () => { await vi.advanceTimersByTimeAsync(5000) }) - expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ - agent_soul: expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Use [§file:uploaded.md:uploaded.md§]', + expect(composerPutMutationFn).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Use [§file:uploaded.md:uploaded.md§]', + }), + config_files: [ + { + file_id: 'drive-file-1', + file_kind: 'upload_file', + hash: 'sha256:file-1', + mime_type: 'text/markdown', + name: 'uploaded.md', + size: 5, + }, + ], + config_skills: [], }), - config_files: [ - { - file_id: 'drive-file-1', - file_kind: 'upload_file', - hash: 'sha256:file-1', - mime_type: 'text/markdown', - name: 'uploaded.md', - size: 5, - }, - ], - config_skills: [], }), }), - })) + ) }) it('should autosave when knowledge retrieval validation fails', async () => { @@ -538,20 +571,22 @@ describe('useAgentConfigureSync', () => { }) expect(composerPutMutationFn).toHaveBeenCalledTimes(1) - expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ - params: { - agent_id: 'agent-1', - }, - body: expect.objectContaining({ - variant: 'agent_app', - save_strategy: 'save_to_current_version', - agent_soul: expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Run prompt', + expect(composerPutMutationFn).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + agent_id: 'agent-1', + }, + body: expect.objectContaining({ + variant: 'agent_app', + save_strategy: 'save_to_current_version', + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Run prompt', + }), }), }), }), - })) + ) expect(result.current.draftSavedAt).toBe(1710000200000) }) @@ -609,17 +644,19 @@ describe('useAgentConfigureSync', () => { await result.current.saveDraft() }) - expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ - agent_soul: expect.objectContaining({ - model: expect.objectContaining({ - model_provider: 'langgenius/openai/openai', - model: 'gpt-4o-mini', - plugin_id: 'langgenius/openai', + expect(composerPutMutationFn).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + model: expect.objectContaining({ + model_provider: 'langgenius/openai/openai', + model: 'gpt-4o-mini', + plugin_id: 'langgenius/openai', + }), }), }), }), - })) + ) }) it('should save draft manually when knowledge retrieval validation fails', async () => { @@ -665,20 +702,22 @@ describe('useAgentConfigureSync', () => { await result.current.publishDraft() }) - expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ - params: { - agent_id: 'agent-1', - }, - body: expect.objectContaining({ - variant: 'agent_app', - save_strategy: 'save_to_current_version', - agent_soul: expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Published prompt', + expect(composerPutMutationFn).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + agent_id: 'agent-1', + }, + body: expect.objectContaining({ + variant: 'agent_app', + save_strategy: 'save_to_current_version', + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Published prompt', + }), }), }), }), - })) + ) expect(publishAgentMutationFn).toHaveBeenCalledWith({ params: { agent_id: 'agent-1', @@ -781,16 +820,18 @@ describe('useAgentConfigureSync', () => { await result.current.publishDraft() }) - expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ - save_strategy: 'save_to_current_version', - agent_soul: expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Current draft prompt', + expect(composerPutMutationFn).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + save_strategy: 'save_to_current_version', + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Current draft prompt', + }), }), }), }), - })) + ) expect(publishAgentMutationFn).toHaveBeenCalledTimes(1) }) @@ -811,7 +852,9 @@ describe('useAgentConfigureSync', () => { }) }) - await expect(result.current.publishDraft()).rejects.toThrow('Failed to save agent composer draft.') + await expect(result.current.publishDraft()).rejects.toThrow( + 'Failed to save agent composer draft.', + ) expect(publishAgentMutationFn).not.toHaveBeenCalled() expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({ @@ -845,7 +888,9 @@ describe('useAgentConfigureSync', () => { expect(composerPutMutationFn).not.toHaveBeenCalled() expect(publishAgentMutationFn).not.toHaveBeenCalled() - expect(toastMock.error).toHaveBeenCalledWith('common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label"}') + expect(toastMock.error).toHaveBeenCalledWith( + 'common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label"}', + ) }) it('should toast metadata filtering model error when publishing with automatic metadata filtering and no model', async () => { @@ -873,7 +918,9 @@ describe('useAgentConfigureSync', () => { expect(composerPutMutationFn).not.toHaveBeenCalled() expect(publishAgentMutationFn).not.toHaveBeenCalled() - expect(toastMock.error).toHaveBeenCalledWith('agentV2.agentDetail.configure.knowledgeRetrieval.validation.metadataModelRequired') + expect(toastMock.error).toHaveBeenCalledWith( + 'agentV2.agentDetail.configure.knowledgeRetrieval.validation.metadataModelRequired', + ) }) it('should expose publishing status from the publish mutation while publish is pending', async () => { diff --git a/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx index 5b1f4e55367aea..75e288181c25bf 100644 --- a/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx @@ -114,15 +114,17 @@ vi.mock('@/app/components/base/prompt-editor', () => ({ })) vi.mock('@lexical/react/LexicalComposerContext', () => ({ - useLexicalComposerContext: () => [{ - focus: (callback: () => void) => callback(), - getEditorState: () => ({ - read: (callback: () => void) => callback(), - }), - registerCommand: () => vi.fn(), - registerUpdateListener: () => vi.fn(), - update: (callback: () => void) => callback(), - }], + useLexicalComposerContext: () => [ + { + focus: (callback: () => void) => callback(), + getEditorState: () => ({ + read: (callback: () => void) => callback(), + }), + registerCommand: () => vi.fn(), + registerUpdateListener: () => vi.fn(), + update: (callback: () => void) => callback(), + }, + ], })) vi.mock('lexical', () => ({ @@ -188,7 +190,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -231,9 +234,7 @@ const duckDuckGoProviderTool: AgentTool = { iconClassName: 'i-simple-icons-duckduckgo', credentialKey: 'agentDetail.configure.tools.credential.authOne', credentialVariant: 'authorized', - actions: [ - duckDuckGoSearchAction, - ], + actions: [duckDuckGoSearchAction], } const promptEditorDraft = { @@ -302,9 +303,11 @@ describe('AgentPromptEditor', () => { it('should label the editable prompt with the visible prompt heading', () => { renderAgentPromptEditor('Review these tenders') - expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({ - 'aria-labelledby': 'agent-configure-prompt-label', - })) + expect(mockPromptEditor).toHaveBeenCalledWith( + expect.objectContaining({ + 'aria-labelledby': 'agent-configure-prompt-label', + }), + ) }) it('should copy the current prompt when the copy button is clicked', () => { @@ -318,11 +321,15 @@ describe('AgentPromptEditor', () => { it('should let clipboard timeout restore the copied state instead of resetting on mouse leave', () => { renderAgentPromptEditor('Review these tenders') - expect(mockUseClipboard).toHaveBeenCalledWith(expect.objectContaining({ - timeout: 2000, - })) + expect(mockUseClipboard).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: 2000, + }), + ) - fireEvent.mouseLeave(screen.getByRole('button', { name: /agentDetail\.configure\.prompt\.copy/i })) + fireEvent.mouseLeave( + screen.getByRole('button', { name: /agentDetail\.configure\.prompt\.copy/i }), + ) expect(mockReset).not.toHaveBeenCalled() }) @@ -331,7 +338,8 @@ describe('AgentPromptEditor', () => { const store = createStore() store.set(agentComposerDraftAtom, { ...defaultAgentSoulConfigFormState, - prompt: 'Use [§knowledge:retrieval-1:Old Search§] and [§knowledge:retrieval-2:Keep Search§]', + prompt: + 'Use [§knowledge:retrieval-1:Old Search§] and [§knowledge:retrieval-2:Keep Search§]', knowledgeRetrievals: [ { id: 'retrieval-1', name: 'Old Search' }, { id: 'retrieval-2', name: 'Keep Search' }, @@ -343,18 +351,18 @@ describe('AgentPromptEditor', () => { { id: 'retrieval-2', name: 'Keep Search' }, ]) - expect(store.get(agentComposerPromptAtom)).toBe('Use [§knowledge:retrieval-1:Release Search§] and [§knowledge:retrieval-2:Keep Search§]') + expect(store.get(agentComposerPromptAtom)).toBe( + 'Use [§knowledge:retrieval-1:Release Search§] and [§knowledge:retrieval-2:Keep Search§]', + ) }) it('should update CLI tool reference labels when the tool title changes', () => { const store = createStore() store.set(agentComposerDraftAtom, { ...defaultAgentSoulConfigFormState, - prompt: 'Run [§cli_tool:cli-1:Old CLI§] and [§tool:duckduckgo/ddg_search:DuckDuckGo Search§]', - tools: [ - { id: 'cli-1', kind: 'cli', name: 'Old CLI' }, - duckDuckGoProviderTool, - ], + prompt: + 'Run [§cli_tool:cli-1:Old CLI§] and [§tool:duckduckgo/ddg_search:DuckDuckGo Search§]', + tools: [{ id: 'cli-1', kind: 'cli', name: 'Old CLI' }, duckDuckGoProviderTool], }) store.set(agentComposerToolsAtom, [ @@ -370,15 +378,19 @@ describe('AgentPromptEditor', () => { }, ]) - expect(store.get(agentComposerPromptAtom)).toBe('Run [§cli_tool:cli-1:Release CLI§] and [§tool:duckduckgo/ddg_search:DuckDuckGo Search§]') + expect(store.get(agentComposerPromptAtom)).toBe( + 'Run [§cli_tool:cli-1:Release CLI§] and [§tool:duckduckgo/ddg_search:DuckDuckGo Search§]', + ) }) it('should render selected tool reference icons from configured tools', () => { renderAgentPromptEditor('Run tools', { - tools: [{ - ...duckDuckGoProviderTool, - iconClassName: 'i-custom-public-other-default-tool-icon', - }], + tools: [ + { + ...duckDuckGoProviderTool, + iconClassName: 'i-custom-public-other-default-tool-icon', + }, + ], }) const promptEditorProps = mockPromptEditor.mock.calls.at(-1)?.[0] as PromptEditorProps @@ -395,8 +407,9 @@ describe('AgentPromptEditor', () => { </>, ) - const providerIcon = Array.from(container.querySelectorAll<HTMLElement>('[style]')) - .find(element => element.style.backgroundImage) + const providerIcon = Array.from(container.querySelectorAll<HTMLElement>('[style]')).find( + (element) => element.style.backgroundImage, + ) expect(providerIcon).toHaveStyle({ backgroundImage: `url(${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=workspace-123&filename=duckduckgo.svg)`, }) @@ -415,64 +428,96 @@ describe('AgentPromptEditor', () => { }) it('should warn only for prompt references missing from the current configuration', () => { - mockConfigFiles.current = [{ - id: 'folder', - name: 'Folder', - children: [{ - id: 'file-1', - name: 'Spec.md', - driveKey: 'drive/spec.md', - }], - }] + mockConfigFiles.current = [ + { + id: 'folder', + name: 'Folder', + children: [ + { + id: 'file-1', + name: 'Spec.md', + driveKey: 'drive/spec.md', + }, + ], + }, + ] renderAgentPromptEditor('Review these tenders', { knowledgeRetrievals: [{ id: 'retrieval-1', name: 'Release Notes' }], - tools: [ - duckDuckGoProviderTool, - { id: 'cli-1', kind: 'cli', name: 'Lark CLI' }, - ], + tools: [duckDuckGoProviderTool, { id: 'cli-1', kind: 'cli', name: 'Lark CLI' }], }) const promptEditorProps = mockPromptEditor.mock.calls.at(-1)?.[0] as PromptEditorProps const getWarning = promptEditorProps.rosterReferenceBlock?.getWarning expect(getWarning).toBeDefined() - expect(getWarning?.({ kind: 'skill', id: 'skills%2Fplaywright%2FSKILL.md', label: 'Playwright' })).toBeUndefined() - expect(getWarning?.({ kind: 'file', id: 'drive%2Fspec.md', label: 'Spec.md' })).toBeUndefined() - expect(getWarning?.({ kind: 'knowledge', id: 'retrieval-1', label: 'Release Notes' })).toBeUndefined() - expect(getWarning?.({ kind: 'tool', id: 'duckduckgo/ddg_search', label: 'DuckDuckGo Search' })).toBeUndefined() - expect(getWarning?.({ kind: 'tool-all', id: 'duckduckgo/*', label: 'DuckDuckGo' })).toBeUndefined() - - expect(getWarning?.({ kind: 'skill', id: 'missing-skill', label: 'Missing Skill' })).toContain('agentDetail.configure.prompt.referenceMissing') - expect(getWarning?.({ kind: 'file', id: 'missing-file', label: 'Missing File' })).toContain('agentDetail.configure.prompt.referenceMissing') - expect(getWarning?.({ kind: 'knowledge', id: 'missing-retrieval', label: 'Missing Retrieval' })).toContain('agentDetail.configure.prompt.referenceMissing') - expect(getWarning?.({ kind: 'tool', id: 'missing/action', label: 'Missing Tool' })).toContain('agentDetail.configure.prompt.referenceMissing') + expect( + getWarning?.({ kind: 'skill', id: 'skills%2Fplaywright%2FSKILL.md', label: 'Playwright' }), + ).toBeUndefined() + expect( + getWarning?.({ kind: 'file', id: 'drive%2Fspec.md', label: 'Spec.md' }), + ).toBeUndefined() + expect( + getWarning?.({ kind: 'knowledge', id: 'retrieval-1', label: 'Release Notes' }), + ).toBeUndefined() + expect( + getWarning?.({ kind: 'tool', id: 'duckduckgo/ddg_search', label: 'DuckDuckGo Search' }), + ).toBeUndefined() + expect( + getWarning?.({ kind: 'tool-all', id: 'duckduckgo/*', label: 'DuckDuckGo' }), + ).toBeUndefined() + + expect( + getWarning?.({ kind: 'skill', id: 'missing-skill', label: 'Missing Skill' }), + ).toContain('agentDetail.configure.prompt.referenceMissing') + expect(getWarning?.({ kind: 'file', id: 'missing-file', label: 'Missing File' })).toContain( + 'agentDetail.configure.prompt.referenceMissing', + ) + expect( + getWarning?.({ kind: 'knowledge', id: 'missing-retrieval', label: 'Missing Retrieval' }), + ).toContain('agentDetail.configure.prompt.referenceMissing') + expect(getWarning?.({ kind: 'tool', id: 'missing/action', label: 'Missing Tool' })).toContain( + 'agentDetail.configure.prompt.referenceMissing', + ) }) }) // Prompt slash commands should use the Agent Roster category menu and replace it with submenus. describe('Slash Commands', () => { it('should open category menu, show skill submenu, and append the selected reference', async () => { - const { store, rerenderWithValue, container } = renderAgentPromptEditor('Review these tenders') + const { store, rerenderWithValue, container } = + renderAgentPromptEditor('Review these tenders') - expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({ - disableBracePicker: true, - disableSlashPicker: true, - rosterReferenceBlock: expect.objectContaining({ - show: true, + expect(mockPromptEditor).toHaveBeenCalledWith( + expect.objectContaining({ + disableBracePicker: true, + disableSlashPicker: true, + rosterReferenceBlock: expect.objectContaining({ + show: true, + }), }), - })) + ) rerenderWithValue('Review these tenders/') await openSlashMenuFromEditor() - expect(container).toContainElement(screen.getByRole('dialog', { name: /agentDetail\.configure\.prompt\.insert\.label/i })) - expect(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })).toBeInTheDocument() + expect(container).toContainElement( + screen.getByRole('dialog', { name: /agentDetail\.configure\.prompt\.insert\.label/i }), + ) + expect( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })) - expect(screen.queryByRole('button', { name: /agentDetail\.configure\.files\.label/i })).not.toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ) + expect( + screen.queryByRole('button', { name: /agentDetail\.configure\.files\.label/i }), + ).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /Playwright/i })) - expect(store.get(agentComposerPromptAtom)).toBe('Review these tenders [§skill:playwright:Playwright§]') + expect(store.get(agentComposerPromptAtom)).toBe( + 'Review these tenders [§skill:playwright:Playwright§]', + ) await waitFor(() => { expect(screen.queryByRole('button', { name: /Playwright/i })).not.toBeInTheDocument() }) @@ -486,8 +531,12 @@ describe('AgentPromptEditor', () => { textbox.focus() await openSlashMenuFromEditor(textbox) - const skillsCategory = screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }) - const filesCategory = screen.getByRole('button', { name: /agentDetail\.configure\.files\.label/i }) + const skillsCategory = screen.getByRole('button', { + name: /agentDetail\.configure\.skills\.label/i, + }) + const filesCategory = screen.getByRole('button', { + name: /agentDetail\.configure\.files\.label/i, + }) await waitFor(() => { expect(textbox).toHaveFocus() expect(textbox).toHaveAttribute('aria-controls', 'agent-configure-prompt-slash-menu') @@ -507,35 +556,51 @@ describe('AgentPromptEditor', () => { await user.keyboard('{ArrowRight}') await waitFor(() => { expect(textbox).toHaveFocus() - expect(screen.getByRole('button', { name: /agentDetail\.configure\.files\.label/i })).toHaveAttribute('data-agent-prompt-menu-active') + expect( + screen.getByRole('button', { name: /agentDetail\.configure\.files\.label/i }), + ).toHaveAttribute('data-agent-prompt-menu-active') }) await user.keyboard('{ArrowLeft}') await waitFor(() => { expect(textbox).toHaveFocus() - expect(screen.getByRole('button', { name: /agentDetail\.configure\.files\.label/i })).toHaveAttribute('data-agent-prompt-menu-active') - expect(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })).not.toHaveAttribute('data-agent-prompt-menu-active') + expect( + screen.getByRole('button', { name: /agentDetail\.configure\.files\.label/i }), + ).toHaveAttribute('data-agent-prompt-menu-active') + expect( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ).not.toHaveAttribute('data-agent-prompt-menu-active') }) await user.keyboard('{ArrowUp}') await waitFor(() => { expect(textbox).toHaveFocus() - expect(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })).toHaveAttribute('data-agent-prompt-menu-active') + expect( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ).toHaveAttribute('data-agent-prompt-menu-active') }) await user.keyboard('{ArrowRight}') await waitFor(() => { expect(textbox).toHaveFocus() - expect(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })).toHaveAttribute('data-agent-prompt-menu-active') + expect( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ).toHaveAttribute('data-agent-prompt-menu-active') }) await user.keyboard('{ArrowDown}') await waitFor(() => { expect(textbox).toHaveFocus() - expect(screen.getByRole('button', { name: /Playwright/i })).toHaveAttribute('data-agent-prompt-menu-active') + expect(screen.getByRole('button', { name: /Playwright/i })).toHaveAttribute( + 'data-agent-prompt-menu-active', + ) }) await user.keyboard('{Enter}') - expect(store.get(agentComposerPromptAtom)).toBe('Review these tenders [§skill:playwright:Playwright§]') + expect(store.get(agentComposerPromptAtom)).toBe( + 'Review these tenders [§skill:playwright:Playwright§]', + ) await waitFor(() => { - expect(screen.queryByRole('dialog', { name: /agentDetail\.configure\.prompt\.insert\.label/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: /agentDetail\.configure\.prompt\.insert\.label/i }), + ).not.toBeInTheDocument() }) }) @@ -547,7 +612,9 @@ describe('AgentPromptEditor', () => { textbox.focus() await openSlashMenuFromEditor(textbox) - await user.click(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })) + await user.click( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ) await waitFor(() => { expect(textbox).toHaveFocus() expect(screen.getByRole('button', { name: /Playwright/i })).toBeInTheDocument() @@ -555,9 +622,13 @@ describe('AgentPromptEditor', () => { await user.click(screen.getByRole('button', { name: /Playwright/i })) - expect(store.get(agentComposerPromptAtom)).toBe('Review these tenders [§skill:playwright:Playwright§]') + expect(store.get(agentComposerPromptAtom)).toBe( + 'Review these tenders [§skill:playwright:Playwright§]', + ) await waitFor(() => { - expect(screen.queryByRole('dialog', { name: /agentDetail\.configure\.prompt\.insert\.label/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: /agentDetail\.configure\.prompt\.insert\.label/i }), + ).not.toBeInTheDocument() }) }) @@ -575,7 +646,9 @@ describe('AgentPromptEditor', () => { fireEvent.keyDown(textbox, { key: 'Escape' }) await waitFor(() => { - expect(screen.queryByRole('dialog', { name: /agentDetail\.configure\.prompt\.insert\.label/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: /agentDetail\.configure\.prompt\.insert\.label/i }), + ).not.toBeInTheDocument() }) await waitFor(() => { expect(textbox).toHaveFocus() @@ -583,15 +656,17 @@ describe('AgentPromptEditor', () => { }) it('should keep focus in the editor and position the menu from the typed slash', async () => { - const getClientRectsSpy = vi.spyOn(Range.prototype, 'getClientRects').mockImplementation(function (this: Range) { - const rect = this.collapsed - ? DOMRect.fromRect({ x: 480, y: 76, width: 0, height: 18 }) - : DOMRect.fromRect({ x: 82, y: 76, width: 8, height: 18 }) - return [rect] as unknown as DOMRectList - }) - const getBoundingClientRectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ( - DOMRect.fromRect({ x: 10, y: 50, width: 500, height: 240 }) - )) + const getClientRectsSpy = vi + .spyOn(Range.prototype, 'getClientRects') + .mockImplementation(function (this: Range) { + const rect = this.collapsed + ? DOMRect.fromRect({ x: 480, y: 76, width: 0, height: 18 }) + : DOMRect.fromRect({ x: 82, y: 76, width: 8, height: 18 }) + return [rect] as unknown as DOMRectList + }) + const getBoundingClientRectSpy = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(() => DOMRect.fromRect({ x: 10, y: 50, width: 500, height: 240 })) try { renderAgentPromptEditor('Review/') @@ -613,8 +688,7 @@ describe('AgentPromptEditor', () => { left: '80px', top: '48px', }) - } - finally { + } finally { window.getSelection()?.removeAllRanges() getClientRectsSpy.mockRestore() getBoundingClientRectSpy.mockRestore() @@ -648,7 +722,9 @@ describe('AgentPromptEditor', () => { textbox.focus() await openSlashMenuFromEditor(textbox) - fireEvent.click(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })) + fireEvent.click( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ) fireEvent.click(screen.getByRole('button', { name: /Playwright/i })) expect(store.get(agentComposerPromptAtom)).toBe('Review [§skill:playwright:Playwright§] now') @@ -661,14 +737,20 @@ describe('AgentPromptEditor', () => { const { store } = renderAgentPromptEditor('Review these tenders') fireEvent.focus(screen.getByRole('textbox')) - const insertButton = screen.getByRole('button', { name: /agentDetail\.configure\.prompt\.insert\.label/i }) + const insertButton = screen.getByRole('button', { + name: /agentDetail\.configure\.prompt\.insert\.label/i, + }) fireEvent.pointerDown(insertButton) fireEvent.click(insertButton) fireEvent.pointerUp(insertButton) expect(store.get(agentComposerPromptAtom)).toBe('Review these tenders/') - expect(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /agentDetail\.configure\.prompt\.mention\.label/i })).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /agentDetail\.configure\.prompt\.mention\.label/i }), + ).not.toBeInTheDocument() }) it('should insert references after prompt add actions create skills, files, or knowledge retrievals', () => { @@ -688,7 +770,7 @@ describe('AgentPromptEditor', () => { files={[]} configuredTools={[]} onAddProviderTools={vi.fn()} - onAddSkill={options => options?.onAdded?.({ id: 'skill-1', name: 'Skill One' })} + onAddSkill={(options) => options?.onAdded?.({ id: 'skill-1', name: 'Skill One' })} knowledgeRetrievals={[]} onBack={vi.fn()} onOpenCategory={vi.fn()} @@ -706,7 +788,14 @@ describe('AgentPromptEditor', () => { files={[]} configuredTools={[]} onAddProviderTools={vi.fn()} - onAddFile={options => options?.onAdded?.({ id: 'file-1', name: 'Guide.md', icon: 'markdown', configName: 'Guide.md' })} + onAddFile={(options) => + options?.onAdded?.({ + id: 'file-1', + name: 'Guide.md', + icon: 'markdown', + configName: 'Guide.md', + }) + } knowledgeRetrievals={[]} onBack={vi.fn()} onOpenCategory={vi.fn()} @@ -724,14 +813,18 @@ describe('AgentPromptEditor', () => { files={[]} configuredTools={[]} onAddProviderTools={vi.fn()} - onAddKnowledge={options => options?.onAdded?.({ id: 'retrieval-1', name: 'Retrieval One', queryMode: 'agent' })} + onAddKnowledge={(options) => + options?.onAdded?.({ id: 'retrieval-1', name: 'Retrieval One', queryMode: 'agent' }) + } knowledgeRetrievals={[]} onBack={vi.fn()} onOpenCategory={vi.fn()} onInsertToken={onInsertToken} />, ) - fireEvent.click(screen.getByRole('button', { name: /agentDetail\.configure\.knowledgeRetrieval\.add/i })) + fireEvent.click( + screen.getByRole('button', { name: /agentDetail\.configure\.knowledgeRetrieval\.add/i }), + ) expect(onInsertToken).toHaveBeenCalledWith('[§knowledge:retrieval-1:Retrieval One§]') rerender( @@ -742,15 +835,21 @@ describe('AgentPromptEditor', () => { files={[]} configuredTools={[]} onAddProviderTools={vi.fn()} - onAddCliTool={options => options?.onAdded?.({ id: 'cli-1', kind: 'cli', name: 'Lark CLI' })} + onAddCliTool={(options) => + options?.onAdded?.({ id: 'cli-1', kind: 'cli', name: 'Lark CLI' }) + } knowledgeRetrievals={[]} onBack={vi.fn()} onOpenCategory={vi.fn()} onInsertToken={onInsertToken} />, ) - expect(screen.queryByRole('button', { name: /agentDetail\.configure\.tools\.cliDialog\.title/i })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: /agentDetail\.configure\.tools\.toolTabs\.cli/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /agentDetail\.configure\.tools\.cliDialog\.title/i }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /agentDetail\.configure\.tools\.toolTabs\.cli/i }), + ).not.toBeInTheDocument() }) it('should append available provider tool references and add missing tools to the configuration', async () => { @@ -759,14 +858,19 @@ describe('AgentPromptEditor', () => { await openSlashMenuFromEditor() fireEvent.click(screen.getByRole('button', { name: /agentDetail\.configure\.tools\.label/i })) - const providerButton = screen.getByRole('button', { name: /DuckDuckGo.*agentDetail\.configure\.tools\.toolTabs\.plugins/i }) - const providerIcon = Array.from(providerButton.querySelectorAll<HTMLElement>('[style]')) - .find(element => element.style.backgroundImage) + const providerButton = screen.getByRole('button', { + name: /DuckDuckGo.*agentDetail\.configure\.tools\.toolTabs\.plugins/i, + }) + const providerIcon = Array.from(providerButton.querySelectorAll<HTMLElement>('[style]')).find( + (element) => element.style.backgroundImage, + ) expect(providerIcon).toHaveStyle({ backgroundImage: `url(${expectedProviderIcon})` }) fireEvent.click(screen.getByRole('button', { name: 'DuckDuckGo' })) fireEvent.click(screen.getByRole('button', { name: /DuckDuckGo Search/i })) - expect(store.get(agentComposerPromptAtom)).toBe('Research [§tool:duckduckgo/ddg_search:DuckDuckGo Search§]') + expect(store.get(agentComposerPromptAtom)).toBe( + 'Research [§tool:duckduckgo/ddg_search:DuckDuckGo Search§]', + ) expect(store.get(agentComposerDraftAtom).tools).toEqual([ expect.objectContaining({ id: 'duckduckgo', @@ -783,7 +887,11 @@ describe('AgentPromptEditor', () => { rerenderWithValue('Research/') await openSlashMenuFromEditor() fireEvent.click(screen.getByRole('button', { name: /agentDetail\.configure\.tools\.label/i })) - fireEvent.click(screen.getByRole('button', { name: /DuckDuckGo.*agentDetail\.configure\.tools\.toolTabs\.plugins/i })) + fireEvent.click( + screen.getByRole('button', { + name: /DuckDuckGo.*agentDetail\.configure\.tools\.toolTabs\.plugins/i, + }), + ) expect(store.get(agentComposerPromptAtom)).toBe('Research [§tool:duckduckgo/*:DuckDuckGo§]') expect(store.get(agentComposerDraftAtom).tools).toEqual([ @@ -801,23 +909,31 @@ describe('AgentPromptEditor', () => { const { rerenderWithValue } = renderAgentPromptEditor('Review/') await openSlashMenuFromEditor() - expect(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ).toBeInTheDocument() rerenderWithValue('Review') fireEvent.keyUp(screen.getByRole('textbox'), { key: 'Backspace' }) await waitFor(() => { - expect(screen.queryByRole('button', { name: /agentDetail\.configure\.skills\.label/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ).not.toBeInTheDocument() }) rerenderWithValue('Review/') await openSlashMenuFromEditor() - expect(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ).toBeInTheDocument() fireEvent.pointerDown(document.body) await waitFor(() => { - expect(screen.queryByRole('button', { name: /agentDetail\.configure\.skills\.label/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ).not.toBeInTheDocument() }) }) @@ -829,15 +945,20 @@ describe('AgentPromptEditor', () => { renderAgentPromptEditor('Review/') await openSlashMenuFromEditor() - expect(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ).toBeInTheDocument() fireEvent.focusIn(outsideButton) await waitFor(() => { - expect(screen.queryByRole('dialog', { name: /agentDetail\.configure\.prompt\.insert\.label/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { + name: /agentDetail\.configure\.prompt\.insert\.label/i, + }), + ).not.toBeInTheDocument() }) - } - finally { + } finally { outsideButton.remove() } }) @@ -848,7 +969,9 @@ describe('AgentPromptEditor', () => { fireEvent.keyUp(screen.getByRole('textbox'), { key: 'ArrowRight' }) await waitFor(() => { - expect(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }), + ).toBeInTheDocument() }) }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/build-grid-texture.tsx b/web/features/agent-v2/agent-detail/configure/components/build-grid-texture.tsx index e6122129216474..f77cabb953fea3 100644 --- a/web/features/agent-v2/agent-detail/configure/components/build-grid-texture.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/build-grid-texture.tsx @@ -12,10 +12,11 @@ function getBuildGridCellOpacity(row: number, column: number) { const horizontalWeight = Math.min(1, column / 160) const verticalWeight = (1 - verticalProgress) ** 1.7 - if (noise < densityThreshold) - return 0 + if (noise < densityThreshold) return 0 - return Number(Math.min(0.272, (0.032 + noise * 0.058 + horizontalWeight * 0.09) * verticalWeight).toFixed(3)) + return Number( + Math.min(0.272, (0.032 + noise * 0.058 + horizontalWeight * 0.09) * verticalWeight).toFixed(3), + ) } const buildGridCells = Array.from( @@ -32,7 +33,7 @@ const buildGridCells = Array.from( row: row + 1, } }, -).filter(cell => cell.opacity > 0) +).filter((cell) => cell.opacity > 0) export function AgentBuildGridTexture({ cellOpacityMultiplier = 1, @@ -45,14 +46,21 @@ export function AgentBuildGridTexture({ }) { return ( <div - className={cn('grid grid-cols-[repeat(384,4px)] grid-rows-[repeat(32,4px)] gap-0.5 opacity-70', className)} + className={cn( + 'grid grid-cols-[repeat(384,4px)] grid-rows-[repeat(32,4px)] gap-0.5 opacity-70', + className, + )} {...props} > - {buildGridCells.map(cell => ( + {buildGridCells.map((cell) => ( <span key={cell.id} className={cn('rounded-[1px] bg-[#98A2B2]', dotClassName)} - style={{ gridColumn: `${cell.column}`, gridRow: `${cell.row}`, opacity: Math.min(1, cell.opacity * cellOpacityMultiplier) }} + style={{ + gridColumn: `${cell.column}`, + gridRow: `${cell.row}`, + opacity: Math.min(1, cell.opacity * cellOpacityMultiplier), + }} /> ))} </div> diff --git a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx index 98e4503106617b..267a69220b5434 100644 --- a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx @@ -1,6 +1,10 @@ 'use client' -import type { AgentAppDetailWithSite, AgentIconType, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentAppDetailWithSite, + AgentIconType, + AgentSoulConfig, +} from '@dify/contracts/api/console/agent/types.gen' import type { useAgentConfigureData } from '../hooks' import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useQueryClient } from '@tanstack/react-query' @@ -23,7 +27,10 @@ import { resetAgentConfigureConversationAtom, setAgentConfigureConversationIdAtom, } from '../state' -import { useAgentConfigureBuildDraftActions, useAgentConfigureBuildDraftData } from '../use-agent-configure-build-draft' +import { + useAgentConfigureBuildDraftActions, + useAgentConfigureBuildDraftData, +} from '../use-agent-configure-build-draft' import { useAgentConfigureSync } from '../use-agent-configure-sync' import { AgentOrchestratePanel } from './orchestrate' import { AgentBuildDraftBar } from './orchestrate/build-draft-bar' @@ -31,7 +38,10 @@ import { AgentConfigurePageLoading } from './page-loading' import { AgentBuildPanelBackground } from './preview/build-background' import { AgentChatFeaturesPanel } from './preview/chat-features-panel' import { AgentPreviewHeader } from './preview/header' -import { invalidateAgentWorkingDirectoryFiles, useAgentWorkingDirectoryPanel } from './preview/hook/use-working-directory-panel' +import { + invalidateAgentWorkingDirectoryFiles, + useAgentWorkingDirectoryPanel, +} from './preview/hook/use-working-directory-panel' import { AgentConfigureRightPanelChat } from './preview/right-panel-chat' import { AgentPreviewVersionsPanel } from './preview/versions-panel' import { AgentConfigurePreviewSurface, AgentConfigureWorkspace } from './workspace' @@ -50,12 +60,7 @@ export function AgentConfigureComposerScope({ onSelectVersion: (versionId: string | null) => void }) { const { t } = useTranslation('agentV2') - const { - composerQuery, - selectedVersionId, - activeVersionId, - agentSoulConfig, - } = configureData + const { composerQuery, selectedVersionId, activeVersionId, agentSoulConfig } = configureData const soulSourceOverride = useAtomValue(agentConfigureSoulSourceOverrideAtom) const setSoulSourceOverride = useSetAtom(agentConfigureSoulSourceOverrideAtom) const isViewingVersion = !!selectedVersionId @@ -70,9 +75,7 @@ export function AgentConfigureComposerScope({ }) if (buildDraft.isPending) { - return ( - <AgentConfigurePageLoading label={t($ => $['agentDetail.sections.configure'])} /> - ) + return <AgentConfigurePageLoading label={t(($) => $['agentDetail.sections.configure'])} /> } const composerSessionKey = `${agentId}:${activeVersionId ?? selectedVersionId ?? 'draft'}:${composerRebaseRevision}` @@ -107,43 +110,45 @@ function AgentConfigurePageComposerSession({ onComposerRebase: () => void onSelectVersion: (versionId: string | null) => void }) { - const { - agentQuery, - } = configureData + const { agentQuery } = configureData const queryClient = useQueryClient() const agentIconType = agentQuery.data?.icon_type as AgentIconType | null | undefined - const refreshDebugConversationMutation = useMutation(consoleQuery.agent.byAgentId.debugConversation.refresh.post.mutationOptions({ - onSuccess: ({ - debug_conversation_id, - debug_conversation_has_messages, - debug_conversation_message_count, - }) => { - queryClient.setQueryData<AgentAppDetailWithSite | undefined>( - consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }), - (agentDetail) => { - if (!agentDetail) - return agentDetail + const refreshDebugConversationMutation = useMutation( + consoleQuery.agent.byAgentId.debugConversation.refresh.post.mutationOptions({ + onSuccess: ({ + debug_conversation_id, + debug_conversation_has_messages, + debug_conversation_message_count, + }) => { + queryClient.setQueryData<AgentAppDetailWithSite | undefined>( + consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }), + (agentDetail) => { + if (!agentDetail) return agentDetail - return { - ...agentDetail, - debug_conversation_id, - debug_conversation_has_messages: debug_conversation_has_messages ?? false, - debug_conversation_message_count: debug_conversation_message_count ?? 0, - } - }, - ) - }, - })) + return { + ...agentDetail, + debug_conversation_id, + debug_conversation_has_messages: debug_conversation_has_messages ?? false, + debug_conversation_message_count: debug_conversation_message_count ?? 0, + } + }, + ) + }, + }), + ) const { mutate: refreshDebugConversationRequest, mutateAsync: refreshDebugConversationRequestAsync, isPending: isRefreshingDebugConversation, } = refreshDebugConversationMutation - const refreshDebugConversationInput = useCallback(() => ({ - params: { - agent_id: agentId, - }, - }), [agentId]) + const refreshDebugConversationInput = useCallback( + () => ({ + params: { + agent_id: agentId, + }, + }), + [agentId], + ) const refreshDebugConversation = useCallback(() => { refreshDebugConversationRequest(refreshDebugConversationInput()) }, [refreshDebugConversationInput, refreshDebugConversationRequest]) @@ -154,10 +159,13 @@ function AgentConfigurePageComposerSession({ return ( <ScopeProvider atoms={[ - [agentConfigureConversationIdsAtom, { - build: agentQuery.data?.debug_conversation_id ?? null, - preview: null, - }], + [ + agentConfigureConversationIdsAtom, + { + build: agentQuery.data?.debug_conversation_id ?? null, + preview: null, + }, + ], ]} name="AgentConfigureConversation" > @@ -218,7 +226,9 @@ function AgentConfigurePageComposerContent({ const { t: tCommon } = useTranslation('common') const [buildDraftActionsDisabled, setBuildDraftActionsDisabled] = useState(false) const [clearPreviewChat, setClearPreviewChat] = useState(false) - const [completedBuildConversationId, setCompletedBuildConversationId] = useState<string | null>(null) + const [completedBuildConversationId, setCompletedBuildConversationId] = useState<string | null>( + null, + ) const conversationIds = useAtomValue(agentConfigureConversationIdsAtom) const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom) const workingDirectoryPanel = useAgentWorkingDirectoryPanel({ @@ -238,30 +248,24 @@ function AgentConfigurePageComposerContent({ const resetBuildChatSession = useCallback(async () => { try { await onRefreshDebugConversationAsync() - } - finally { + } finally { setCompletedBuildConversationId(null) setConversationId({ mode: 'build', conversationId: null }) setClearPreviewChat(true) } }, [onRefreshDebugConversationAsync, setClearPreviewChat, setConversationId]) - const rebaseComposerDraftFromSoulConfig = useCallback((agentSoulConfig?: AgentSoulConfig) => { - rebaseComposerDraft({ - draft: agentSoulConfigToFormState(agentSoulConfig), - originalConfig: agentSoulConfig, - }) - }, [rebaseComposerDraft]) - const { - currentModel, - setConfigureModel, - textGenerationModelList, - } = useAgentConfigureModelOptions() - const { - draftSavedAt, - isPublishing, - publishDraft, - saveDraft, - } = useAgentConfigureSync({ + const rebaseComposerDraftFromSoulConfig = useCallback( + (agentSoulConfig?: AgentSoulConfig) => { + rebaseComposerDraft({ + draft: agentSoulConfigToFormState(agentSoulConfig), + originalConfig: agentSoulConfig, + }) + }, + [rebaseComposerDraft], + ) + const { currentModel, setConfigureModel, textGenerationModelList } = + useAgentConfigureModelOptions() + const { draftSavedAt, isPublishing, publishDraft, saveDraft } = useAgentConfigureSync({ agentId, baseConfig: agentSoulConfig, currentModel, @@ -280,37 +284,39 @@ function AgentConfigurePageComposerContent({ onComposerRebased: onComposerRebase, setSoulSourceOverride: buildDraft.setSoulSourceOverride, }) - const selectVersion = useCallback((versionId: string | null) => { - onSelectVersion(versionId) - }, [onSelectVersion]) - const hasRestartCurrentChatTarget = rightPanelChatMode === 'build' - ? (agentQuery.data?.debug_conversation_has_messages ?? false) || buildDraft.isActive - : !!conversationIds[rightPanelChatMode] - const isRestartCurrentChatDisabled = !hasRestartCurrentChatTarget - || buildDraftActionsDisabled - || isRefreshingDebugConversation - || buildDraftActions.isApplyingBuildDraft - || buildDraftActions.isDiscardingBuildDraft - const isChatFeaturesReadOnly = (isViewingVersion && versionQuery.isPending) || buildDraft.isActive - const buildConversationHasAgentResponse = !!conversationIds.build && ( - conversationIds.build === completedBuildConversationId - || ( - conversationIds.build === agentQuery.data?.debug_conversation_id - && (agentQuery.data?.debug_conversation_has_messages ?? false) - ) + const selectVersion = useCallback( + (versionId: string | null) => { + onSelectVersion(versionId) + }, + [onSelectVersion], ) - const showWorkingDirectoryAction = rightPanelChatMode === 'build' && buildConversationHasAgentResponse + const hasRestartCurrentChatTarget = + rightPanelChatMode === 'build' + ? (agentQuery.data?.debug_conversation_has_messages ?? false) || buildDraft.isActive + : !!conversationIds[rightPanelChatMode] + const isRestartCurrentChatDisabled = + !hasRestartCurrentChatTarget || + buildDraftActionsDisabled || + isRefreshingDebugConversation || + buildDraftActions.isApplyingBuildDraft || + buildDraftActions.isDiscardingBuildDraft + const isChatFeaturesReadOnly = (isViewingVersion && versionQuery.isPending) || buildDraft.isActive + const buildConversationHasAgentResponse = + !!conversationIds.build && + (conversationIds.build === completedBuildConversationId || + (conversationIds.build === agentQuery.data?.debug_conversation_id && + (agentQuery.data?.debug_conversation_has_messages ?? false))) + const showWorkingDirectoryAction = + rightPanelChatMode === 'build' && buildConversationHasAgentResponse const restartCurrentChat = () => { - if (isRestartCurrentChatDisabled) - return + if (isRestartCurrentChatDisabled) return if (rightPanelChatMode === 'build' && buildDraft.isActive) { void buildDraftActions.discardBuildDraft() return } - if (rightPanelChatMode === 'build') - onRefreshDebugConversation() + if (rightPanelChatMode === 'build') onRefreshDebugConversation() resetConversation(rightPanelChatMode) setClearPreviewChat(true) @@ -319,7 +325,7 @@ function AgentConfigurePageComposerContent({ return ( <AgentConfigureWorkspace aria-busy={agentQuery.isFetching} - leftPanel={( + leftPanel={ <AgentOrchestratePanel agentId={agentId} activeConfigIsPublished={agentQuery.data?.active_config_is_published} @@ -336,23 +342,23 @@ function AgentConfigurePageComposerContent({ buildDraftChangedKeys={buildDraft.changedKeys} showPublishBar={!buildDraft.isActive} workflowReferencesEnabled={agentQuery.isSuccess} - bottomAction={showBuildDraftBar - ? ( - <AgentBuildDraftBar - changeSummary={buildDraft.changeSummary} - changesCount={buildDraft.changesCount} - disabled={buildDraftActionsDisabled} - isApplying={buildDraftActions.isApplyingBuildDraft} - isDiscarding={buildDraftActions.isDiscardingBuildDraft} - onApply={() => { - void buildDraftActions.applyBuildDraft() - }} - onDiscard={() => { - void buildDraftActions.discardBuildDraft() - }} - /> - ) - : undefined} + bottomAction={ + showBuildDraftBar ? ( + <AgentBuildDraftBar + changeSummary={buildDraft.changeSummary} + changesCount={buildDraft.changesCount} + disabled={buildDraftActionsDisabled} + isApplying={buildDraftActions.isApplyingBuildDraft} + isDiscarding={buildDraftActions.isDiscardingBuildDraft} + onApply={() => { + void buildDraftActions.applyBuildDraft() + }} + onDiscard={() => { + void buildDraftActions.discardBuildDraft() + }} + /> + ) : undefined + } onSelectModel={setConfigureModel} onPublish={publishDraft} onOpenVersions={() => { @@ -361,17 +367,17 @@ function AgentConfigurePageComposerContent({ }} onExitVersions={() => selectVersion(null)} /> - )} - rightPanel={( + } + rightPanel={ <AgentConfigurePreviewSurface background={<AgentBuildPanelBackground visible={rightPanelChatMode === 'build'} />} - header={( + header={ <AgentPreviewHeader mode={rightPanelChatMode} previewEnabled={false} isChatFeaturesOpen={showChatFeatures} onModeChange={setRightPanelMode} - onToggleChatFeatures={() => setShowChatFeatures(open => !open)} + onToggleChatFeatures={() => setShowChatFeatures((open) => !open)} onOpenWorkingDirectory={() => { setShowPreviewVersions(false) workingDirectoryPanel.openWorkingDirectory() @@ -380,8 +386,8 @@ function AgentConfigurePageComposerContent({ refreshDisabled={isRestartCurrentChatDisabled} showWorkingDirectoryAction={showWorkingDirectoryAction} /> - )} - chat={( + } + chat={ <AgentConfigureRightPanelChat agentId={agentId} agentIcon={agentQuery.data?.icon} @@ -402,38 +408,40 @@ function AgentConfigurePageComposerContent({ conversationId: completedConversationId, queryClient, }) - buildDraftActions.refreshBuildDraftAfterBuildChat(() => setBuildDraftActionsDisabled(false)) + buildDraftActions.refreshBuildDraftAfterBuildChat(() => + setBuildDraftActionsDisabled(false), + ) } }} onConversationIdChange={(mode, conversationId) => { setConversationId({ mode, conversationId }) }} - onSaveDraftBeforeRun={rightPanelChatMode === 'build' - ? async () => { - if (!currentModel?.provider || !currentModel.model) { - toast.error(tCommon($ => $['modelProvider.selectModel'])) - throw new Error('Agent model is required.') - } + onSaveDraftBeforeRun={ + rightPanelChatMode === 'build' + ? async () => { + if (!currentModel?.provider || !currentModel.model) { + toast.error(tCommon(($) => $['modelProvider.selectModel'])) + throw new Error('Agent model is required.') + } - setBuildDraftActionsDisabled(true) - try { - return await buildDraftActions.prepareBuildDraftBeforeRun() - } - catch (error) { - setBuildDraftActionsDisabled(false) - throw error - } - } - : saveDraft} + setBuildDraftActionsDisabled(true) + try { + return await buildDraftActions.prepareBuildDraftBeforeRun() + } catch (error) { + setBuildDraftActionsDisabled(false) + throw error + } + } + : saveDraft + } onSendInterrupted={() => { - if (rightPanelChatMode === 'build') - setBuildDraftActionsDisabled(false) + if (rightPanelChatMode === 'build') setBuildDraftActionsDisabled(false) }} /> - )} + } /> - )} - sidePanels={( + } + sidePanels={ <> {showPreviewVersions && ( <AgentPreviewVersionsPanel @@ -451,7 +459,7 @@ function AgentConfigurePageComposerContent({ onClose={() => setShowChatFeatures(false)} /> </> - )} + } /> ) } diff --git a/web/features/agent-v2/agent-detail/configure/components/confirm-clear-session-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/confirm-clear-session-dialog.tsx index f52489736cf548..86743278684d95 100644 --- a/web/features/agent-v2/agent-detail/configure/components/confirm-clear-session-dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/confirm-clear-session-dialog.tsx @@ -38,18 +38,18 @@ export function AgentConfigureClearSessionConfirmDialog({ <AlertDialogContent className="w-100"> <div className="flex flex-col gap-1 p-6 pb-0"> <AlertDialogTitle className="title-md-semi-bold text-text-primary"> - {t($ => $['agentDetail.configure.clearSessionConfirm.title'])} + {t(($) => $['agentDetail.configure.clearSessionConfirm.title'])} </AlertDialogTitle> <AlertDialogDescription className="system-sm-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.clearSessionConfirm.description'])} + {t(($) => $['agentDetail.configure.clearSessionConfirm.description'])} </AlertDialogDescription> </div> <AlertDialogActions className="pt-6"> <AlertDialogCancelButton disabled={confirmDisabled}> - {tCommon($ => $['operation.cancel'])} + {tCommon(($) => $['operation.cancel'])} </AlertDialogCancelButton> <AlertDialogConfirmButton disabled={confirmDisabled} onClick={handleConfirm}> - {tCommon($ => $['operation.confirm'])} + {tCommon(($) => $['operation.confirm'])} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/add-actions.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/add-actions.spec.tsx index 9e168f3eeb2dfc..4751d21ea41dc0 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/add-actions.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/add-actions.spec.tsx @@ -1,7 +1,10 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AgentOrchestrateAddActionsProvider } from '../add-actions' -import { useAgentOrchestrateAddActions, useRegisterAgentOrchestrateAddAction } from '../add-actions-context' +import { + useAgentOrchestrateAddActions, + useRegisterAgentOrchestrateAddAction, +} from '../add-actions-context' function InlineActionRegisterer({ label, diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/bottom-actions.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/bottom-actions.spec.tsx index 084599d650cc74..089b53d815741b 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/bottom-actions.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/bottom-actions.spec.tsx @@ -9,7 +9,9 @@ describe('AgentOrchestrateBottomActions', () => { </AgentOrchestrateBottomActions>, ) - expect(screen.getByTestId('bottom-action').parentElement).toHaveClass('has-[[data-open]]:max-w-96') + expect(screen.getByTestId('bottom-action').parentElement).toHaveClass( + 'has-[[data-open]]:max-w-96', + ) rerender( <AgentOrchestrateBottomActions shrinkOnOpen={false}> @@ -17,6 +19,8 @@ describe('AgentOrchestrateBottomActions', () => { </AgentOrchestrateBottomActions>, ) - expect(screen.getByTestId('bottom-action').parentElement).not.toHaveClass('has-[[data-open]]:max-w-96') + expect(screen.getByTestId('bottom-action').parentElement).not.toHaveClass( + 'has-[[data-open]]:max-w-96', + ) }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/build-draft-bar.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/build-draft-bar.spec.tsx index 7fb42c13134145..f53ef640f64a6e 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/build-draft-bar.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/build-draft-bar.spec.tsx @@ -20,9 +20,7 @@ const changeSummary: AgentBuildDraftChangeSummary = { }, { id: 'file-1', name: 'index.json', operation: 'added', icon: 'json' }, ], - envVariables: [ - { id: 'env-1', name: 'API_KEY', operation: 'updated' }, - ], + envVariables: [{ id: 'env-1', name: 'API_KEY', operation: 'updated' }], } describe('AgentBuildDraftBar', () => { @@ -31,17 +29,12 @@ describe('AgentBuildDraftBar', () => { const onApply = vi.fn() const onDiscard = vi.fn() - render( - <AgentBuildDraftBar - changesCount={1} - disabled - onApply={onApply} - onDiscard={onDiscard} - />, - ) + render(<AgentBuildDraftBar changesCount={1} disabled onApply={onApply} onDiscard={onDiscard} />) const applyButton = screen.getByRole('button', { name: 'custom.apply' }) - const discardButton = screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' }) + const discardButton = screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.buildDraft.discard', + }) expect(applyButton).toBeDisabled() expect(discardButton).toBeDisabled() @@ -59,16 +52,13 @@ describe('AgentBuildDraftBar', () => { const onDiscard = vi.fn() render( - <AgentBuildDraftBar - changesCount={1} - isApplying - onApply={onApply} - onDiscard={onDiscard} - />, + <AgentBuildDraftBar changesCount={1} isApplying onApply={onApply} onDiscard={onDiscard} />, ) const applyButton = screen.getByRole('button', { name: 'custom.apply' }) - const discardButton = screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' }) + const discardButton = screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.buildDraft.discard', + }) expect(applyButton).toHaveAttribute('aria-disabled', 'true') expect(applyButton.querySelector('[aria-hidden="true"]')).toBeInTheDocument() @@ -83,16 +73,13 @@ describe('AgentBuildDraftBar', () => { it('should disable both actions while discard is pending', () => { render( - <AgentBuildDraftBar - changesCount={1} - isDiscarding - onApply={vi.fn()} - onDiscard={vi.fn()} - />, + <AgentBuildDraftBar changesCount={1} isDiscarding onApply={vi.fn()} onDiscard={vi.fn()} />, ) expect(screen.getByRole('button', { name: 'custom.apply' })).toBeDisabled() - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })).toBeDisabled() + expect( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' }), + ).toBeDisabled() }) it('should show build draft change metadata', () => { @@ -108,7 +95,11 @@ describe('AgentBuildDraftBar', () => { expect(container.firstElementChild).toHaveClass('w-full') expect(container.firstElementChild).not.toHaveClass('w-fit') expect(screen.getByText('agentV2.agentDetail.configure.buildDraft.title')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.changesToApply:{"count":0}' })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.buildDraft.changesToApply:{"count":0}', + }), + ).toBeInTheDocument() expect(document.querySelector('.i-ri-arrow-right-s-line')).toBeInTheDocument() rerender( @@ -121,7 +112,11 @@ describe('AgentBuildDraftBar', () => { ) expect(screen.getByText('agentV2.agentDetail.configure.buildDraft.title')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.changesToApply:{"count":2}' })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.buildDraft.changesToApply:{"count":2}', + }), + ).toBeInTheDocument() }) it('should open build draft change details from the metadata trigger', async () => { @@ -151,7 +146,9 @@ describe('AgentBuildDraftBar', () => { />, ) - const changesTrigger = screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.changesToApply:{"count":5}' }) + const changesTrigger = screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.buildDraft.changesToApply:{"count":5}', + }) await user.click(changesTrigger) @@ -162,15 +159,25 @@ describe('AgentBuildDraftBar', () => { expect(screen.getByText('tender-analyzer')).toBeInTheDocument() expect(screen.getByText('figma-code-connect')).toBeInTheDocument() expect(screen.getByText('build_note.md')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.configure.buildDraft.buildNoteDescription')).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.buildDraft.buildNoteDescription'), + ).toBeInTheDocument() expect(screen.getByText('index.json')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.configure.advancedSettings.envEditor.shortLabel')).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.advancedSettings.envEditor.shortLabel'), + ).toBeInTheDocument() expect(screen.getByText('API_KEY')).toBeInTheDocument() expect(document.querySelector('.text-text-accent')).toHaveClass('i-ri-add-circle-fill') - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })) + await user.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' }), + ) expect(onDiscard).not.toHaveBeenCalled() - expect(screen.getByRole('alertdialog', { name: 'agentV2.agentDetail.configure.clearSessionConfirm.title' })).toBeInTheDocument() + expect( + screen.getByRole('alertdialog', { + name: 'agentV2.agentDetail.configure.clearSessionConfirm.title', + }), + ).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await user.click(screen.getByRole('button', { name: 'custom.apply' })) @@ -186,15 +193,11 @@ describe('AgentBuildDraftBar', () => { const onApply = vi.fn() const onDiscard = vi.fn() - render( - <AgentBuildDraftBar - changesCount={0} - onApply={onApply} - onDiscard={onDiscard} - />, - ) + render(<AgentBuildDraftBar changesCount={0} onApply={onApply} onDiscard={onDiscard} />) - const discardButton = screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' }) + const discardButton = screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.buildDraft.discard', + }) const applyButton = screen.getByRole('button', { name: 'custom.apply' }) expect(discardButton).toBeEnabled() diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/empty-sections.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/empty-sections.spec.tsx index c09998b1eb581a..773270007fb1cc 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/empty-sections.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/empty-sections.spec.tsx @@ -45,12 +45,22 @@ describe('Agent configure empty sections', () => { renderEmptySections() expect(screen.getByText('agentV2.agentDetail.configure.skills.empty.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.configure.skills.empty.description')).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.skills.empty.description'), + ).toBeInTheDocument() expect(screen.getByText('agentV2.agentDetail.configure.files.empty.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.configure.files.empty.description')).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.files.empty.description'), + ).toBeInTheDocument() expect(screen.getByText('agentV2.agentDetail.configure.tools.empty.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.configure.tools.empty.description')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.empty.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.empty.description')).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.tools.empty.description'), + ).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.empty.title'), + ).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.empty.description'), + ).toBeInTheDocument() }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/header.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/header.spec.tsx index 56460278d3426c..4f2b940a743222 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/header.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/header.spec.tsx @@ -5,15 +5,23 @@ describe('AgentOrchestrateHeader', () => { it('should render configure title without build mode copy by default', () => { render(<AgentOrchestrateHeader headingId="configure-heading" />) - expect(screen.getByRole('heading', { name: 'agentV2.agentDetail.configure.title' })).toBeInTheDocument() - expect(screen.queryByText('agentV2.agentDetail.configure.buildDraft.modeBadge')).not.toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'agentV2.agentDetail.configure.title' }), + ).toBeInTheDocument() + expect( + screen.queryByText('agentV2.agentDetail.configure.buildDraft.modeBadge'), + ).not.toBeInTheDocument() }) it('should render build mode copy when build draft is active', () => { render(<AgentOrchestrateHeader headingId="configure-heading" isBuildDraftActive />) - expect(screen.getByText('agentV2.agentDetail.configure.buildDraft.modeBadge')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.configure.buildDraft.modeDescription')).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.buildDraft.modeBadge'), + ).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.buildDraft.modeDescription'), + ).toBeInTheDocument() }) it('should render trailing action on the configure title row', () => { diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx index b5782ad795ff0b..0062ffe0452762 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx @@ -1,29 +1,44 @@ -import type { AgentConfigSnapshotSummaryResponse, AgentReferencingWorkflowResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentConfigSnapshotSummaryResponse, + AgentReferencingWorkflowResponse, +} from '@dify/contracts/api/console/agent/types.gen' import type { ComponentProps } from 'react' import type { Mock } from 'vitest' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { createStore, Provider as JotaiProvider } from 'jotai' import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' -import { agentComposerDraftAtom, agentComposerOriginalDraftAtom, agentComposerPublishedDraftAtom } from '@/features/agent-v2/agent-composer/store' +import { + agentComposerDraftAtom, + agentComposerOriginalDraftAtom, + agentComposerPublishedDraftAtom, +} from '@/features/agent-v2/agent-composer/store' import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt' import { AgentConfigurePublishBar } from '../publish-bar' type PublishHandler = NonNullable<ComponentProps<typeof AgentConfigurePublishBar>['onPublish']> type PublishMock = Mock<PublishHandler> -const hotkeyRegistrations = vi.hoisted(() => new Map<string, { - callback: (event: { preventDefault: () => void }) => void - options?: { enabled?: boolean, ignoreInputs?: boolean } -}>()) +const hotkeyRegistrations = vi.hoisted( + () => + new Map< + string, + { + callback: (event: { preventDefault: () => void }) => void + options?: { enabled?: boolean; ignoreInputs?: boolean } + } + >(), +) const mockFormatForDisplay = vi.hoisted(() => vi.fn((hotkey: string) => `display:${hotkey}`)) const mockFormatTimeFromNow = vi.hoisted(() => vi.fn(() => 'just now')) const mockFormatTime = vi.hoisted(() => vi.fn((timestamp: number) => `formatted:${timestamp}`)) -const restoreVersionMutation = vi.hoisted(() => vi.fn(async (_input: unknown) => ({ - active_config_snapshot_id: 'snapshot-2', - result: 'success', -}))) +const restoreVersionMutation = vi.hoisted(() => + vi.fn(async (_input: unknown) => ({ + active_config_snapshot_id: 'snapshot-2', + result: 'success', + })), +) const toastMock = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), @@ -42,7 +57,11 @@ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { return { ...actual, formatForDisplay: mockFormatForDisplay, - useHotkey: (hotkey: string, callback: (event: { preventDefault: () => void }) => void, options?: { enabled?: boolean, ignoreInputs?: boolean }) => { + useHotkey: ( + hotkey: string, + callback: (event: { preventDefault: () => void }) => void, + options?: { enabled?: boolean; ignoreInputs?: boolean }, + ) => { hotkeyRegistrations.set(hotkey, { callback, options }) }, } @@ -69,12 +88,21 @@ vi.mock('@/service/client', () => ({ }, composer: { get: { - queryKey: ({ input }: { input: { params: { agent_id: string } } }) => ['agent-composer', input], + queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [ + 'agent-composer', + input, + ], }, }, referencingWorkflows: { get: { - queryOptions: ({ enabled = true, input }: { enabled?: boolean, input: { params: { agent_id: string } } }) => ({ + queryOptions: ({ + enabled = true, + input, + }: { + enabled?: boolean + input: { params: { agent_id: string } } + }) => ({ queryKey: ['agent-referencing-workflows', input], enabled, queryFn: async () => ({ @@ -197,8 +225,16 @@ function renderPublishBar({ <JotaiProvider store={store}> <AgentConfigurePublishBar agentId="agent-1" - activeConfigIsPublished={nextProps && 'activeConfigIsPublished' in nextProps ? nextProps.activeConfigIsPublished : activeConfigIsPublished} - activeConfigSnapshot={nextProps && 'activeConfigSnapshot' in nextProps ? nextProps.activeConfigSnapshot : activeConfigSnapshot} + activeConfigIsPublished={ + nextProps && 'activeConfigIsPublished' in nextProps + ? nextProps.activeConfigIsPublished + : activeConfigIsPublished + } + activeConfigSnapshot={ + nextProps && 'activeConfigSnapshot' in nextProps + ? nextProps.activeConfigSnapshot + : activeConfigSnapshot + } draftSavedAt={draftSavedAt} agentName="Iris" isPublishing={nextProps?.isPublishing ?? isPublishing} @@ -244,7 +280,9 @@ describe('AgentConfigurePublishBar', () => { expect(screen.getByText('agentV2.agentDetail.configure.publishBar.draft')).toBeInTheDocument() expect(screen.getByText('agentV2.agentDetail.configure.publishBar.saved')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.publish/ })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /agentV2\.agentDetail\.publish/ }), + ).toBeInTheDocument() expect(screen.getByText('display:Mod')).toBeInTheDocument() expect(screen.getByText('display:Shift')).toBeInTheDocument() expect(screen.getByText('display:P')).toBeInTheDocument() @@ -270,13 +308,25 @@ describe('AgentConfigurePublishBar', () => { }, }) - expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeEnabled() - expect(screen.queryByText('common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label"}')).not.toBeInTheDocument() + expect( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ).toBeEnabled() + expect( + screen.queryByText( + 'common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label"}', + ), + ).not.toBeInTheDocument() expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual( expect.objectContaining({ enabled: true, ignoreInputs: false }), ) - fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) + fireEvent.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ) await waitFor(() => { expect(onPublish).toHaveBeenCalledTimes(1) @@ -299,7 +349,9 @@ describe('AgentConfigurePublishBar', () => { expect(screen.getByText('Stable version')).toBeInTheDocument() expect(screen.getByText('agentV2.agentDetail.versionHistory.viewOnly')).toBeInTheDocument() expect(screen.getByText('formatted:1710000000 · Alice')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'agentV2.agentDetail.versionHistory.restore' })) + fireEvent.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.versionHistory.restore' }), + ) await waitFor(() => { expect(restoreVersionMutation).toHaveBeenCalled() @@ -317,7 +369,9 @@ describe('AgentConfigurePublishBar', () => { it('should render saved time from the latest draft save timestamp', () => { renderPublishBar({ draftSavedAt: 1710000100000 }) - expect(screen.getByText(/agentV2\.agentDetail\.configure\.publishBar\.savedAt/)).toBeInTheDocument() + expect( + screen.getByText(/agentV2\.agentDetail\.configure\.publishBar\.savedAt/), + ).toBeInTheDocument() expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1710000100000) }) @@ -327,16 +381,24 @@ describe('AgentConfigurePublishBar', () => { activeConfigSnapshot, }) - expect(screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate')).toBeInTheDocument() - expect(screen.getByText(/agentV2\.agentDetail\.configure\.publishBar\.publishedAt/)).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' })).toBeDisabled() + expect( + screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate'), + ).toBeInTheDocument() + expect( + screen.getByText(/agentV2\.agentDetail\.configure\.publishBar\.publishedAt/), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' }), + ).toBeDisabled() expect(screen.queryByText('display:Mod')).not.toBeInTheDocument() expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1710000000 * 1000) expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual( expect.objectContaining({ enabled: false, ignoreInputs: false }), ) - fireEvent.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' })) + fireEvent.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' }), + ) expect(onPublish).not.toHaveBeenCalled() }) @@ -347,13 +409,19 @@ describe('AgentConfigurePublishBar', () => { activeConfigSnapshot: null, }) - rerender(rerenderPublishBar({ - activeConfigIsPublished: undefined, - activeConfigSnapshot: undefined, - })) + rerender( + rerenderPublishBar({ + activeConfigIsPublished: undefined, + activeConfigSnapshot: undefined, + }), + ) - expect(screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' })).toBeDisabled() + expect( + screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate'), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' }), + ).toBeDisabled() expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual( expect.objectContaining({ enabled: false, ignoreInputs: false }), ) @@ -366,8 +434,14 @@ describe('AgentConfigurePublishBar', () => { prompt: 'Updated system prompt', }) - expect(screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges'), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ).toBeInTheDocument() expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual( expect.objectContaining({ enabled: true, ignoreInputs: false }), ) @@ -379,21 +453,33 @@ describe('AgentConfigurePublishBar', () => { activeConfigSnapshot, }) - expect(screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges')).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges'), + ).toBeInTheDocument() expect(screen.getByText('agentV2.agentDetail.configure.publishBar.saved')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ).toBeInTheDocument() expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual( expect.objectContaining({ enabled: true, ignoreInputs: false }), ) - fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) + fireEvent.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ) await waitFor(() => { expect(onPublish).toHaveBeenCalledTimes(1) }) - expect(screen.queryByRole('region', { - name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, - })).not.toBeInTheDocument() + expect( + screen.queryByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).not.toBeInTheDocument() }) it('should publish the current draft payload from the unpublished changes state', async () => { @@ -402,9 +488,15 @@ describe('AgentConfigurePublishBar', () => { prompt: 'Updated system prompt', }) - expect(screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges')).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges'), + ).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) + fireEvent.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ) await waitFor(() => { expect(onPublish).toHaveBeenCalledTimes(1) @@ -422,15 +514,21 @@ describe('AgentConfigurePublishBar', () => { await waitFor(() => { expect(workflowReferences.fetchCount).toBe(0) }) - fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) + fireEvent.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ) await waitFor(() => { expect(onPublish).toHaveBeenCalledTimes(1) }) expect(workflowReferences.fetchCount).toBe(0) - expect(screen.queryByRole('region', { - name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, - })).not.toBeInTheDocument() + expect( + screen.queryByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).not.toBeInTheDocument() }) it('should mark non-prompt draft changes as unpublished', () => { @@ -446,7 +544,9 @@ describe('AgentConfigurePublishBar', () => { }, }) - expect(screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges')).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges'), + ).toBeInTheDocument() }) it('should keep unpublished state after draft autosave updates the saved draft baseline', () => { @@ -468,8 +568,14 @@ describe('AgentConfigurePublishBar', () => { }, }) - expect(screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges'), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ).toBeInTheDocument() }) it('should trust backend published state after autosave confirms the draft matches the active snapshot', () => { @@ -492,17 +598,31 @@ describe('AgentConfigurePublishBar', () => { }, }) - expect(screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' })).toBeDisabled() - expect(screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).not.toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate'), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' }), + ).toBeDisabled() + expect( + screen.queryByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ).not.toBeInTheDocument() }) it('should render publishing as a single disabled action state', () => { renderPublishBar({ isPublishing: true, prompt: 'Updated system prompt' }) - expect(screen.getByText('agentV2.agentDetail.configure.publishBar.publishing')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.publishing' })).toHaveAttribute('aria-disabled', 'true') - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.publishing' })).not.toHaveAttribute('aria-busy') + expect( + screen.getByText('agentV2.agentDetail.configure.publishBar.publishing'), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.publishing' }), + ).toHaveAttribute('aria-disabled', 'true') + expect( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.publishing' }), + ).not.toHaveAttribute('aria-busy') expect(screen.queryByText('display:Mod')).not.toBeInTheDocument() expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual( expect.objectContaining({ enabled: false, ignoreInputs: false }), @@ -516,14 +636,18 @@ describe('AgentConfigurePublishBar', () => { usedByAppReferences: publishedReferences, }) - expect(screen.queryByRole('region', { - name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, - })).not.toBeInTheDocument() + expect( + screen.queryByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).not.toBeInTheDocument() await waitFor(() => { expect(workflowReferences.fetchCount).toBe(1) }) - const publishButton = screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ }) + const publishButton = screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }) fireEvent.click(publishButton) expect(onPublish).not.toHaveBeenCalled() @@ -533,16 +657,38 @@ describe('AgentConfigurePublishBar', () => { }) expect(impactDetails).toBeInTheDocument() expect(workflowReferences.fetchCount).toBe(1) - expect(screen.getAllByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toHaveLength(1) - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishImpact.cancel' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.versionHistory' })).toHaveClass('group-data-open/publish-bar:hidden') - expect(screen.getByText(/agentV2\.agentDetail\.configure\.publishImpact\.title/)).toBeInTheDocument() - expect(screen.getByText(/agentV2\.agentDetail\.configure\.publishImpact\.descriptionPrefix/)).toBeInTheDocument() - expect(screen.getByText(/agentV2\.agentDetail\.configure\.publishImpact\.workflowCount/)).toBeInTheDocument() + expect( + screen.getAllByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ).toHaveLength(1) + expect( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishImpact.cancel' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.publishBar.versionHistory', + }), + ).toHaveClass('group-data-open/publish-bar:hidden') + expect( + screen.getByText(/agentV2\.agentDetail\.configure\.publishImpact\.title/), + ).toBeInTheDocument() + expect( + screen.getByText(/agentV2\.agentDetail\.configure\.publishImpact\.descriptionPrefix/), + ).toBeInTheDocument() + expect( + screen.getByText(/agentV2\.agentDetail\.configure\.publishImpact\.workflowCount/), + ).toBeInTheDocument() expect(screen.getByText('Python bug fixer')).toBeInTheDocument() expect(screen.getByText('Translation Workflow')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /Python bug fixer/ })).toHaveAttribute('target', '_blank') - expect(screen.getByRole('link', { name: /Python bug fixer/ })).toHaveAttribute('rel', 'noopener noreferrer') + expect(screen.getByRole('link', { name: /Python bug fixer/ })).toHaveAttribute( + 'target', + '_blank', + ) + expect(screen.getByRole('link', { name: /Python bug fixer/ })).toHaveAttribute( + 'rel', + 'noopener noreferrer', + ) expect(within(impactDetails).getAllByText('just now')).toHaveLength(2) expect(screen.getByText('display:Mod')).toBeInTheDocument() expect(screen.getByText('display:Shift')).toBeInTheDocument() @@ -559,20 +705,34 @@ describe('AgentConfigurePublishBar', () => { usedByAppReferences: publishedReferences, }) - fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) - expect(await screen.findByRole('region', { - name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, - })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) + fireEvent.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ) + expect( + await screen.findByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ) expect(onPublish).toHaveBeenCalledTimes(1) - expect(screen.getByRole('region', { - name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, - })).toBeInTheDocument() + expect( + screen.getByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).toBeInTheDocument() rerender(rerenderPublishBar({ isPublishing: true })) - expect(await screen.findByRole('button', { - name: 'agentV2.agentDetail.configure.publishBar.publishing', - })).not.toHaveAttribute('aria-busy') + expect( + await screen.findByRole('button', { + name: 'agentV2.agentDetail.configure.publishBar.publishing', + }), + ).not.toHaveAttribute('aria-busy') await act(async () => { publishDeferred.resolve() @@ -580,9 +740,11 @@ describe('AgentConfigurePublishBar', () => { }) await waitFor(() => { - expect(screen.queryByRole('region', { - name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, - })).not.toBeInTheDocument() + expect( + screen.queryByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).not.toBeInTheDocument() }) }) @@ -593,17 +755,27 @@ describe('AgentConfigurePublishBar', () => { usedByAppReferences: publishedReferences, }) - fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) - expect(await screen.findByRole('region', { - name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, - })).toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ) + expect( + await screen.findByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishImpact.cancel' })) + fireEvent.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishImpact.cancel' }), + ) await waitFor(() => { - expect(screen.queryByRole('region', { - name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, - })).not.toBeInTheDocument() + expect( + screen.queryByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).not.toBeInTheDocument() }) expect(onPublish).not.toHaveBeenCalled() }) @@ -621,9 +793,11 @@ describe('AgentConfigurePublishBar', () => { }) expect(onPublish).not.toHaveBeenCalled() - expect(await screen.findByRole('region', { - name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, - })).toBeInTheDocument() + expect( + await screen.findByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).toBeInTheDocument() await act(async () => { await hotkeyRegistrations.get('Mod+Shift+P')?.callback({ preventDefault: vi.fn() }) @@ -643,9 +817,11 @@ describe('AgentConfigurePublishBar', () => { await publishShortcut?.callback({ preventDefault: vi.fn() }) }) - expect(screen.queryByRole('region', { - name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, - })).not.toBeInTheDocument() + expect( + screen.queryByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).not.toBeInTheDocument() expect(onPublish).toHaveBeenCalledTimes(1) }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/add-actions-context.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/add-actions-context.ts index 3017c2c5fa9309..163073177152f7 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/add-actions-context.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/add-actions-context.ts @@ -1,9 +1,18 @@ -import type { AgentCliTool, AgentFileNode, AgentKnowledgeRetrievalItem, AgentSkill } from '@/features/agent-v2/agent-composer/form-state' +import type { + AgentCliTool, + AgentFileNode, + AgentKnowledgeRetrievalItem, + AgentSkill, +} from '@/features/agent-v2/agent-composer/form-state' import { createContext, use, useCallback, useEffect, useRef } from 'react' export type AgentOrchestrateAddActionKey = 'cli' | 'files' | 'knowledge' | 'skills' -export type AgentOrchestrateAddedItem = AgentCliTool | AgentFileNode | AgentKnowledgeRetrievalItem | AgentSkill +export type AgentOrchestrateAddedItem = + | AgentCliTool + | AgentFileNode + | AgentKnowledgeRetrievalItem + | AgentSkill export type AgentOrchestrateAddActionOptions = { onAdded?: (item: AgentOrchestrateAddedItem) => void @@ -11,19 +20,24 @@ export type AgentOrchestrateAddActionOptions = { export type AgentOrchestrateAddAction = (options?: AgentOrchestrateAddActionOptions) => void -export type AgentOrchestrateAddActions = Partial<Record<AgentOrchestrateAddActionKey, AgentOrchestrateAddAction>> +export type AgentOrchestrateAddActions = Partial< + Record<AgentOrchestrateAddActionKey, AgentOrchestrateAddAction> +> export type AgentOrchestrateAddActionsContextValue = { actions: AgentOrchestrateAddActions - registerAction: (key: AgentOrchestrateAddActionKey, action: AgentOrchestrateAddAction) => () => void + registerAction: ( + key: AgentOrchestrateAddActionKey, + action: AgentOrchestrateAddAction, + ) => () => void } -export const AgentOrchestrateAddActionsContext = createContext<AgentOrchestrateAddActionsContextValue | null>(null) +export const AgentOrchestrateAddActionsContext = + createContext<AgentOrchestrateAddActionsContextValue | null>(null) export function useAgentOrchestrateAddActions() { const context = use(AgentOrchestrateAddActionsContext) - if (!context) - return {} + if (!context) return {} return context.actions } @@ -43,8 +57,7 @@ export function useRegisterAgentOrchestrateAddAction( }, []) useEffect(() => { - if (!registerAction) - return + if (!registerAction) return return registerAction(key, stableAction) }, [key, registerAction, stableAction]) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/add-actions.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/add-actions.tsx index 2d2b952c3b67c7..7617916895fbd0 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/add-actions.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/add-actions.tsx @@ -1,53 +1,54 @@ 'use client' import type { ReactNode } from 'react' -import type { AgentOrchestrateAddAction, AgentOrchestrateAddActionKey, AgentOrchestrateAddActions } from './add-actions-context' +import type { + AgentOrchestrateAddAction, + AgentOrchestrateAddActionKey, + AgentOrchestrateAddActions, +} from './add-actions-context' import { useCallback, useMemo, useState } from 'react' import { AgentOrchestrateAddActionsContext } from './add-actions-context' import { useAgentOrchestrateReadOnly } from './read-only-context' -export function AgentOrchestrateAddActionsProvider({ - children, -}: { - children: ReactNode -}) { +export function AgentOrchestrateAddActionsProvider({ children }: { children: ReactNode }) { const readOnly = useAgentOrchestrateReadOnly() const [actions, setActions] = useState<AgentOrchestrateAddActions>({}) - const registerAction = useCallback((key: AgentOrchestrateAddActionKey, action: AgentOrchestrateAddAction) => { - if (readOnly) - return () => undefined + const registerAction = useCallback( + (key: AgentOrchestrateAddActionKey, action: AgentOrchestrateAddAction) => { + if (readOnly) return () => undefined - setActions((currentActions) => { - if (currentActions[key] === action) - return currentActions - - return { - ...currentActions, - [key]: action, - } - }) - - return () => { setActions((currentActions) => { - if (currentActions[key] !== action) - return currentActions + if (currentActions[key] === action) return currentActions - const nextActions = { ...currentActions } - delete nextActions[key] - return nextActions + return { + ...currentActions, + [key]: action, + } }) - } - }, [readOnly]) - const value = useMemo(() => ({ - actions: readOnly ? {} : actions, - registerAction, - }), [actions, readOnly, registerAction]) + return () => { + setActions((currentActions) => { + if (currentActions[key] !== action) return currentActions + + const nextActions = { ...currentActions } + delete nextActions[key] + return nextActions + }) + } + }, + [readOnly], + ) + + const value = useMemo( + () => ({ + actions: readOnly ? {} : actions, + registerAction, + }), + [actions, readOnly, registerAction], + ) return ( - <AgentOrchestrateAddActionsContext value={value}> - {children} - </AgentOrchestrateAddActionsContext> + <AgentOrchestrateAddActionsContext value={value}>{children}</AgentOrchestrateAddActionsContext> ) } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/__tests__/env.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/__tests__/env.spec.tsx index 7e823298fe8bba..b8776c08ddc8bb 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/__tests__/env.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/__tests__/env.spec.tsx @@ -29,12 +29,14 @@ function renderReadonlyAgentEnvEditor() { <AgentComposerProvider initialDraft={{ ...defaultAgentSoulConfigFormState, - envVariables: [{ - id: 'env-1', - key: 'API_KEY', - value: 'secret-value', - scope: 'secret', - }], + envVariables: [ + { + id: 'env-1', + key: 'API_KEY', + value: 'secret-value', + scope: 'secret', + }, + ], }} > <AgentOrchestrateReadOnlyContext value> @@ -51,13 +53,17 @@ describe('AgentEnvEditor', () => { describe('Env parsing', () => { it('should report invalid dotenv lines without blocking valid entries', () => { - expect(parseEnvImport([ - '# ignored', - 'API_KEY=abc123', - 'INVALID_LINE', - '=missing_key', - 'SECOND_KEY=enabled', - ].join('\n'))).toEqual({ + expect( + parseEnvImport( + [ + '# ignored', + 'API_KEY=abc123', + 'INVALID_LINE', + '=missing_key', + 'SECOND_KEY=enabled', + ].join('\n'), + ), + ).toEqual({ invalidLineCount: 2, variables: [ { key: 'API_KEY', value: 'abc123' }, @@ -71,7 +77,9 @@ describe('AgentEnvEditor', () => { it('should detect the hidden-file help platform from browser values', () => { expect(getEnvImportPlatform({ platform: 'MacIntel' })).toBe('mac') expect(getEnvImportPlatform({ platform: 'Win32' })).toBe('windows') - expect(getEnvImportPlatform({ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' })).toBe('windows') + expect(getEnvImportPlatform({ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' })).toBe( + 'windows', + ) expect(getEnvImportPlatform({ platform: 'Linux x86_64' })).toBe('other') }) }) @@ -81,8 +89,12 @@ describe('AgentEnvEditor', () => { const user = userEvent.setup() renderAgentEnvEditor() - const keyInput = screen.getByPlaceholderText('agentV2.agentDetail.configure.advancedSettings.envEditor.keyPlaceholder') - const valueInput = screen.getByPlaceholderText('agentV2.agentDetail.configure.advancedSettings.envEditor.valuePlaceholder') + const keyInput = screen.getByPlaceholderText( + 'agentV2.agentDetail.configure.advancedSettings.envEditor.keyPlaceholder', + ) + const valueInput = screen.getByPlaceholderText( + 'agentV2.agentDetail.configure.advancedSettings.envEditor.valuePlaceholder', + ) await user.type(keyInput, 'API KEY') await user.type(valueInput, 'secret-value') @@ -94,23 +106,33 @@ describe('AgentEnvEditor', () => { it('should reject environment variable keys that do not match workflow variable rules', () => { renderAgentEnvEditor() - const keyInput = screen.getByPlaceholderText('agentV2.agentDetail.configure.advancedSettings.envEditor.keyPlaceholder') + const keyInput = screen.getByPlaceholderText( + 'agentV2.agentDetail.configure.advancedSettings.envEditor.keyPlaceholder', + ) fireEvent.change(keyInput, { target: { value: '1BAD' }, }) expect(keyInput).toHaveValue('') - expect(mockToastError).toHaveBeenCalledWith('appDebug.varKeyError.notStartWithNumber:{"key":"agentV2.agentDetail.configure.advancedSettings.envEditor.keyColumn"}') + expect(mockToastError).toHaveBeenCalledWith( + 'appDebug.varKeyError.notStartWithNumber:{"key":"agentV2.agentDetail.configure.advancedSettings.envEditor.keyColumn"}', + ) }) it('should add another editable variable row from the add button', async () => { const user = userEvent.setup() renderAgentEnvEditor() - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.add' })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.add', + }), + ) - const keyInputs = screen.getAllByPlaceholderText('agentV2.agentDetail.configure.advancedSettings.envEditor.keyPlaceholder') + const keyInputs = screen.getAllByPlaceholderText( + 'agentV2.agentDetail.configure.advancedSettings.envEditor.keyPlaceholder', + ) expect(keyInputs).toHaveLength(2) const newKeyInput = keyInputs[1]! expect(newKeyInput).toHaveFocus() @@ -127,16 +149,14 @@ describe('AgentEnvEditor', () => { expect(input).not.toHaveAttribute('accept') - const file = new File([ - 'API_KEY=abc123\n', - 'export BASE_URL="https://example.com"\n', - ], '.env', { type: 'text/plain' }) - - await user.upload( - input, - file, + const file = new File( + ['API_KEY=abc123\n', 'export BASE_URL="https://example.com"\n'], + '.env', + { type: 'text/plain' }, ) + await user.upload(input, file) + await waitFor(() => { expect(screen.getByDisplayValue('API_KEY')).toBeInTheDocument() }) @@ -150,11 +170,9 @@ describe('AgentEnvEditor', () => { const { container } = renderAgentEnvEditor() const input = container.querySelector('input[type="file"]') as HTMLInputElement - const file = new File([ - 'API_KEY=abc123\n', - 'INVALID_LINE\n', - '=missing_key\n', - ], '.env', { type: 'text/plain' }) + const file = new File(['API_KEY=abc123\n', 'INVALID_LINE\n', '=missing_key\n'], '.env', { + type: 'text/plain', + }) await user.upload(input, file) @@ -171,11 +189,21 @@ describe('AgentEnvEditor', () => { expect(screen.getByText('API_KEY')).toBeInTheDocument() expect(screen.getByText('secret-value')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.importEnv' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.add' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.deleteVariable:{"key":"API_KEY"}', - })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.importEnv', + }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.add', + }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.deleteVariable:{"key":"API_KEY"}', + }), + ).not.toBeInTheDocument() expect(screen.queryByDisplayValue('API_KEY')).not.toBeInTheDocument() }) @@ -185,13 +213,15 @@ describe('AgentEnvEditor', () => { render( <EnvVariablesTable editable - envVariables={[{ - id: 'env-1', - key: 'API_KEY', - value: 'sk-original', - scope: 'secret', - masked: true, - }]} + envVariables={[ + { + id: 'env-1', + key: 'API_KEY', + value: 'sk-original', + scope: 'secret', + masked: true, + }, + ]} onDelete={vi.fn()} onScopeChange={vi.fn()} showDraftRow={false} @@ -200,11 +230,19 @@ describe('AgentEnvEditor', () => { expect(screen.queryByDisplayValue('sk-original')).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.revealValue:{"key":"API_KEY"}' })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.revealValue:{"key":"API_KEY"}', + }), + ) expect(screen.getByDisplayValue('sk-original')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.hideValue:{"key":"API_KEY"}' })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.hideValue:{"key":"API_KEY"}', + }), + ) expect(screen.queryByDisplayValue('sk-original')).not.toBeInTheDocument() }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/content-moderation.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/content-moderation.tsx index b8f188886bab91..eb6a411687eafb 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/content-moderation.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/content-moderation.tsx @@ -11,7 +11,10 @@ import { FeaturesProvider } from '@/app/components/base/features' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { useLocale } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' -import { useAppFeatures, useSetAppFeatures } from '@/features/agent-v2/agent-composer/store-modules/app-features' +import { + useAppFeatures, + useSetAppFeatures, +} from '@/features/agent-v2/agent-composer/store-modules/app-features' import { useCodeBasedExtensions } from '@/service/use-common' import { ConfigureSection } from '../common/section' import { useAgentOrchestrateReadOnly } from '../read-only-context' @@ -34,138 +37,143 @@ function AgentContentModerationSettingsContent() { const readOnly = useAgentOrchestrateReadOnly() const setAppFeatures = useSetAppFeatures() const { setShowModerationSettingModal } = useModalContext() - const moderation = useFeatures(state => state.features.moderation) + const moderation = useFeatures((state) => state.features.moderation) const { data: codeBasedExtensionList } = useCodeBasedExtensions('moderation') const panelId = 'agent-configure-content-moderation-panel' - const persistModeration = useCallback((nextModeration?: ModerationConfig) => { - if (!nextModeration) - return - - const store = featuresStore?.getState() - if (!store) - return - - store.setFeatures({ - ...store.features, - moderation: nextModeration, - }) - setAppFeatures(appFeatures => ({ - ...appFeatures, - sensitive_word_avoidance: nextModeration as AgentSoulAppFeaturesConfig['sensitive_word_avoidance'], - })) - }, [featuresStore, setAppFeatures]) - - const openSettings = useCallback((payload: ModerationConfig = moderation as ModerationConfig) => { - setShowModerationSettingModal({ - payload, - onSaveCallback: persistModeration, - onCancelCallback: () => {}, - }) - }, [moderation, persistModeration, setShowModerationSettingModal]) - - const handleEnabledChange = useCallback((enabled: boolean) => { - if (enabled) { - if (!moderation?.type) { - openSettings(defaultModerationConfig) - return - } + const persistModeration = useCallback( + (nextModeration?: ModerationConfig) => { + if (!nextModeration) return + + const store = featuresStore?.getState() + if (!store) return - persistModeration({ - ...(moderation as ModerationConfig), - enabled: true, + store.setFeatures({ + ...store.features, + moderation: nextModeration, }) - return - } + setAppFeatures((appFeatures) => ({ + ...appFeatures, + sensitive_word_avoidance: + nextModeration as AgentSoulAppFeaturesConfig['sensitive_word_avoidance'], + })) + }, + [featuresStore, setAppFeatures], + ) - persistModeration({ enabled: false }) - }, [moderation, openSettings, persistModeration]) + const openSettings = useCallback( + (payload: ModerationConfig = moderation as ModerationConfig) => { + setShowModerationSettingModal({ + payload, + onSaveCallback: persistModeration, + onCancelCallback: () => {}, + }) + }, + [moderation, persistModeration, setShowModerationSettingModal], + ) + + const handleEnabledChange = useCallback( + (enabled: boolean) => { + if (enabled) { + if (!moderation?.type) { + openSettings(defaultModerationConfig) + return + } + + persistModeration({ + ...(moderation as ModerationConfig), + enabled: true, + }) + return + } + + persistModeration({ enabled: false }) + }, + [moderation, openSettings, persistModeration], + ) const providerContent = useMemo(() => { if (moderation?.type === 'openai_moderation') - return t($ => $['feature.moderation.modal.provider.openai'], { ns: 'appDebug' }) + return t(($) => $['feature.moderation.modal.provider.openai'], { ns: 'appDebug' }) if (moderation?.type === 'keywords') - return t($ => $['feature.moderation.modal.provider.keywords'], { ns: 'appDebug' }) + return t(($) => $['feature.moderation.modal.provider.keywords'], { ns: 'appDebug' }) if (moderation?.type === 'api') - return t($ => $['apiBasedExtension.selector.title'], { ns: 'common' }) + return t(($) => $['apiBasedExtension.selector.title'], { ns: 'common' }) - return codeBasedExtensionList?.data.find(item => item.name === moderation?.type)?.label[locale] || '-' + return ( + codeBasedExtensionList?.data.find((item) => item.name === moderation?.type)?.label[locale] || + '-' + ) }, [codeBasedExtensionList?.data, locale, moderation?.type, t]) const enabledContent = useMemo(() => { if (moderation?.config?.inputs_config?.enabled && moderation.config?.outputs_config?.enabled) - return t($ => $['feature.moderation.allEnabled'], { ns: 'appDebug' }) + return t(($) => $['feature.moderation.allEnabled'], { ns: 'appDebug' }) if (moderation?.config?.inputs_config?.enabled) - return t($ => $['feature.moderation.inputEnabled'], { ns: 'appDebug' }) + return t(($) => $['feature.moderation.inputEnabled'], { ns: 'appDebug' }) if (moderation?.config?.outputs_config?.enabled) - return t($ => $['feature.moderation.outputEnabled'], { ns: 'appDebug' }) + return t(($) => $['feature.moderation.outputEnabled'], { ns: 'appDebug' }) return '-' }, [moderation?.config?.inputs_config?.enabled, moderation?.config?.outputs_config?.enabled, t]) return ( <ConfigureSection - label={t($ => $['feature.moderation.title'], { ns: 'appDebug' })} + label={t(($) => $['feature.moderation.title'], { ns: 'appDebug' })} labelId="agent-configure-content-moderation-label" headingLevel="h4" panelId={panelId} rootClassName="gap-1 border-t border-divider-subtle py-3" headerClassName="mb-0 gap-1 px-3" panelContentClassName="px-3 pt-1" - actions={!readOnly - ? ( - <div className="flex shrink-0 items-center gap-2"> - {!!moderation?.enabled && ( - <Button - size="small" - variant="ghost" - className="h-6 gap-0.5 px-1.5 py-1 text-text-tertiary" - onClick={() => openSettings()} - > - <span className="i-ri-equalizer-2-line size-3.5" aria-hidden /> - <span className="px-0.5 system-xs-medium"> - {t($ => $['operation.settings'], { ns: 'common' })} - </span> - </Button> - )} - <div className="h-3 w-px bg-divider-regular" /> - <Switch - checked={!!moderation?.enabled} - onCheckedChange={handleEnabledChange} - size="sm" - aria-label={t($ => $['feature.moderation.title'], { ns: 'appDebug' }) as string} - /> - </div> - ) - : undefined} + actions={ + !readOnly ? ( + <div className="flex shrink-0 items-center gap-2"> + {!!moderation?.enabled && ( + <Button + size="small" + variant="ghost" + className="h-6 gap-0.5 px-1.5 py-1 text-text-tertiary" + onClick={() => openSettings()} + > + <span className="i-ri-equalizer-2-line size-3.5" aria-hidden /> + <span className="px-0.5 system-xs-medium"> + {t(($) => $['operation.settings'], { ns: 'common' })} + </span> + </Button> + )} + <div className="h-3 w-px bg-divider-regular" /> + <Switch + checked={!!moderation?.enabled} + onCheckedChange={handleEnabledChange} + size="sm" + aria-label={t(($) => $['feature.moderation.title'], { ns: 'appDebug' }) as string} + /> + </div> + ) : undefined + } > - {moderation?.enabled - ? ( - <div className="flex min-w-0 items-center gap-4"> - <div className="min-w-0"> - <div className="mb-0.5 truncate system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['feature.moderation.modal.provider.title'], { ns: 'appDebug' })} - </div> - <div className="truncate system-xs-regular text-text-secondary"> - {providerContent} - </div> - </div> - <div className="h-[27px] w-px shrink-0 rotate-12 bg-divider-subtle" /> - <div className="min-w-0"> - <div className="mb-0.5 truncate system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['feature.moderation.contentEnableLabel'], { ns: 'appDebug' })} - </div> - <div className="truncate system-xs-regular text-text-secondary"> - {enabledContent} - </div> - </div> + {moderation?.enabled ? ( + <div className="flex min-w-0 items-center gap-4"> + <div className="min-w-0"> + <div className="mb-0.5 truncate system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['feature.moderation.modal.provider.title'], { ns: 'appDebug' })} + </div> + <div className="truncate system-xs-regular text-text-secondary">{providerContent}</div> + </div> + <div className="h-[27px] w-px shrink-0 rotate-12 bg-divider-subtle" /> + <div className="min-w-0"> + <div className="mb-0.5 truncate system-2xs-medium-uppercase text-text-tertiary"> + {t(($) => $['feature.moderation.contentEnableLabel'], { ns: 'appDebug' })} </div> - ) - : ( - <p className="system-xs-regular text-text-tertiary"> - {t($ => $['feature.moderation.description'], { ns: 'appDebug' })} - </p> - )} + <div className="truncate system-xs-regular text-text-secondary">{enabledContent}</div> + </div> + </div> + ) : ( + <p className="system-xs-regular text-text-tertiary"> + {t(($) => $['feature.moderation.description'], { ns: 'appDebug' })} + </p> + )} </ConfigureSection> ) } @@ -173,14 +181,17 @@ function AgentContentModerationSettingsContent() { export function AgentContentModerationSettings() { const appFeatures = useAppFeatures() const moderation = appFeatures?.sensitive_word_avoidance - const features = useMemo<Features>(() => ({ - moderation: moderation - ? { - ...moderation, - type: moderation.type ?? undefined, - } - : { enabled: false }, - }), [moderation]) + const features = useMemo<Features>( + () => ({ + moderation: moderation + ? { + ...moderation, + type: moderation.type ?? undefined, + } + : { enabled: false }, + }), + [moderation], + ) const featuresKey = useMemo(() => JSON.stringify(moderation ?? {}), [moderation]) return ( diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env-utils.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env-utils.ts index 1445aedd8ae668..a96fc606be97a0 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env-utils.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env-utils.ts @@ -13,7 +13,7 @@ const parseEnvValue = (rawValue: string) => { const value = rawValue.trim() const quote = value[0] - if ((quote === '"' || quote === '\'') && value.endsWith(quote)) { + if ((quote === '"' || quote === "'") && value.endsWith(quote)) { const unquotedValue = value.slice(1, -1) if (quote === '"') { @@ -25,21 +25,20 @@ const parseEnvValue = (rawValue: string) => { .replaceAll('\\\\', '\\') } - return unquotedValue.replaceAll('\\\'', '\'') + return unquotedValue.replaceAll("\\'", "'") } return stripInlineComment(value).trim() } export const parseEnvImport = (content: string) => { - const variables: Array<{ key: string, value: string }> = [] + const variables: Array<{ key: string; value: string }> = [] let invalidLineCount = 0 for (const line of content.split(/\r?\n/)) { const trimmedLine = line.trim() - if (!trimmedLine || trimmedLine.startsWith('#')) - continue + if (!trimmedLine || trimmedLine.startsWith('#')) continue const envLine = trimmedLine.startsWith('export ') ? trimmedLine.slice('export '.length).trimStart() @@ -82,8 +81,7 @@ export const getEnvImportPlatform = ({ const normalizedPlatform = platform?.toLowerCase() ?? '' const normalizedUserAgent = userAgent?.toLowerCase() ?? '' - if (normalizedPlatform.includes('mac') || normalizedUserAgent.includes('mac os')) - return 'mac' + if (normalizedPlatform.includes('mac') || normalizedUserAgent.includes('mac os')) return 'mac' if (normalizedPlatform.includes('win') || normalizedUserAgent.includes('windows')) return 'windows' diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env.tsx index a4107dad158cad..23258fd27650e5 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env.tsx @@ -4,7 +4,14 @@ import type { EnvScope, EnvVariable } from '@/features/agent-v2/agent-composer/f import type { I18nKeysWithPrefix } from '@/types/i18n' import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useAtomValue, useSetAtom } from 'jotai' @@ -25,7 +32,10 @@ import { AgentConfigureTipContent } from '../common/tip-content' import { useAgentOrchestrateReadOnly } from '../read-only-context' import { getEnvImportPlatform, parseEnvImport } from './env-utils' -const scopeLabelKeys: Record<EnvScope, I18nKeysWithPrefix<'agentV2', 'agentDetail.configure.advancedSettings.envEditor.'>> = { +const scopeLabelKeys: Record< + EnvScope, + I18nKeysWithPrefix<'agentV2', 'agentDetail.configure.advancedSettings.envEditor.'> +> = { plain: 'agentDetail.configure.advancedSettings.envEditor.scopePlain', secret: 'agentDetail.configure.advancedSettings.envEditor.scopeSecret', } @@ -41,10 +51,10 @@ const envImportTipKeys = { const maskedEnvValue = '••••••••••••' const getCurrentEnvImportPlatform = () => { - if (typeof navigator === 'undefined') - return 'other' + if (typeof navigator === 'undefined') return 'other' - const userAgentData = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData + const userAgentData = (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData return getEnvImportPlatform({ platform: userAgentData?.platform ?? navigator.platform, @@ -85,7 +95,7 @@ function EnvEditorScope({ if (!editable) { return ( <span className="min-w-0 truncate px-3 system-xs-regular text-text-secondary"> - {t($ => $[scopeLabelKeys[scope]])} + {t(($) => $[scopeLabelKeys[scope]])} </span> ) } @@ -94,22 +104,21 @@ function EnvEditorScope({ <Select value={scope} onValueChange={(nextValue) => { - if (!nextValue) - return + if (!nextValue) return onChange?.(nextValue as EnvScope) }} > <SelectTrigger - aria-label={t($ => $['agentDetail.configure.advancedSettings.envEditor.scopeSelector'])} + aria-label={t(($) => $['agentDetail.configure.advancedSettings.envEditor.scopeSelector'])} className="h-full w-full max-w-none rounded-none bg-transparent px-3 py-0 system-xs-regular text-text-secondary hover:bg-state-base-hover focus-visible:bg-state-base-hover data-popup-open:bg-state-base-hover [&>*:last-child]:size-3.5" > - {t($ => $[scopeLabelKeys[scope]])} + {t(($) => $[scopeLabelKeys[scope]])} </SelectTrigger> <SelectContent placement="bottom-start" popupClassName="min-w-24"> - {envScopeOptions.map(option => ( + {envScopeOptions.map((option) => ( <SelectItem key={option} value={option} className="h-7 system-xs-regular"> - <SelectItemText>{t($ => $[scopeLabelKeys[option]])}</SelectItemText> + <SelectItemText>{t(($) => $[scopeLabelKeys[option]])}</SelectItemText> <SelectItemIndicator /> </SelectItem> ))} @@ -126,7 +135,12 @@ function EnvEditorCell({ className?: string }) { return ( - <div className={cn('flex min-h-7 min-w-0 items-center border-r border-divider-subtle last:border-r-0', className)}> + <div + className={cn( + 'flex min-h-7 min-w-0 items-center border-r border-divider-subtle last:border-r-0', + className, + )} + > {children} </div> ) @@ -140,16 +154,15 @@ function EnvEditorInput({ onValueChange, }: { 'aria-label': string - 'placeholder': string - 'shouldFocus'?: boolean - 'value': string - 'onValueChange': (value: string) => void + placeholder: string + shouldFocus?: boolean + value: string + onValueChange: (value: string) => void }) { const inputRef = useRef<HTMLInputElement>(null) useEffect(() => { - if (shouldFocus) - inputRef.current?.focus() + if (shouldFocus) inputRef.current?.focus() }, [shouldFocus]) return ( @@ -194,48 +207,70 @@ function EnvEditorRow({ const displayedValue = shouldMaskValue ? maskedEnvValue : variable.value return ( - <div className={cn('grid min-h-7 border-t border-divider-subtle', gridClassName, isHighlighted && 'bg-background-default-hover')}> + <div + className={cn( + 'grid min-h-7 border-t border-divider-subtle', + gridClassName, + isHighlighted && 'bg-background-default-hover', + )} + > <EnvEditorCell> - {editable - ? ( - <EnvEditorInput - aria-label={t($ => $['agentDetail.configure.advancedSettings.envEditor.keyColumn'])} - placeholder={t($ => $['agentDetail.configure.advancedSettings.envEditor.keyPlaceholder'])} - shouldFocus={autoFocusField === 'key'} - value={variable.key} - onValueChange={onKeyChange ?? (() => {})} - /> - ) - : ( - <span className={cn('min-w-0 truncate px-3 system-xs-regular text-text-secondary', isHighlighted && 'text-text-primary')}> - {variable.key} - </span> + {editable ? ( + <EnvEditorInput + aria-label={t(($) => $['agentDetail.configure.advancedSettings.envEditor.keyColumn'])} + placeholder={t( + ($) => $['agentDetail.configure.advancedSettings.envEditor.keyPlaceholder'], + )} + shouldFocus={autoFocusField === 'key'} + value={variable.key} + onValueChange={onKeyChange ?? (() => {})} + /> + ) : ( + <span + className={cn( + 'min-w-0 truncate px-3 system-xs-regular text-text-secondary', + isHighlighted && 'text-text-primary', )} + > + {variable.key} + </span> + )} </EnvEditorCell> <EnvEditorCell> - {editable && !shouldMaskValue - ? ( - <EnvEditorInput - aria-label={t($ => $['agentDetail.configure.advancedSettings.envEditor.valueColumn'])} - placeholder={t($ => $['agentDetail.configure.advancedSettings.envEditor.valuePlaceholder'])} - shouldFocus={autoFocusField === 'value'} - value={displayedValue} - onValueChange={onValueChange ?? (() => {})} - /> - ) - : ( - <span className="min-w-0 truncate px-3 system-xs-regular text-text-secondary"> - {displayedValue} - </span> + {editable && !shouldMaskValue ? ( + <EnvEditorInput + aria-label={t(($) => $['agentDetail.configure.advancedSettings.envEditor.valueColumn'])} + placeholder={t( + ($) => $['agentDetail.configure.advancedSettings.envEditor.valuePlaceholder'], )} + shouldFocus={autoFocusField === 'value'} + value={displayedValue} + onValueChange={onValueChange ?? (() => {})} + /> + ) : ( + <span className="min-w-0 truncate px-3 system-xs-regular text-text-secondary"> + {displayedValue} + </span> + )} {variable.masked && ( <button type="button" - aria-label={t($ => $[isValueRevealed ? 'agentDetail.configure.advancedSettings.envEditor.hideValue' : 'agentDetail.configure.advancedSettings.envEditor.revealValue'], { key: variable.key })} - onClick={() => setIsValueRevealed(revealed => !revealed)} + aria-label={t( + ($) => + $[ + isValueRevealed + ? 'agentDetail.configure.advancedSettings.envEditor.hideValue' + : 'agentDetail.configure.advancedSettings.envEditor.revealValue' + ], + { key: variable.key }, + )} + onClick={() => setIsValueRevealed((revealed) => !revealed)} className="mr-2 ml-auto flex size-5 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > - <span aria-hidden className={cn(isValueRevealed ? 'i-ri-eye-off-line' : 'i-ri-eye-line', 'size-4')} /> + <span + aria-hidden + className={cn(isValueRevealed ? 'i-ri-eye-off-line' : 'i-ri-eye-line', 'size-4')} + /> </button> )} </EnvEditorCell> @@ -248,7 +283,14 @@ function EnvEditorRow({ {editable && ( <button type="button" - aria-label={t($ => $['agentDetail.configure.advancedSettings.envEditor.deleteVariable'], { key: variable.key || t($ => $['agentDetail.configure.advancedSettings.envEditor.keyPlaceholder']) })} + aria-label={t( + ($) => $['agentDetail.configure.advancedSettings.envEditor.deleteVariable'], + { + key: + variable.key || + t(($) => $['agentDetail.configure.advancedSettings.envEditor.keyPlaceholder']), + }, + )} onClick={onDelete} className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > @@ -264,15 +306,19 @@ function EnvEditorDraftRow({ onAdd, showScope = true, }: { - onAdd?: (options?: { focusField?: 'key' | 'value', scope?: EnvScope }) => void + onAdd?: (options?: { focusField?: 'key' | 'value'; scope?: EnvScope }) => void showScope?: boolean }) { const { t } = useTranslation('agentV2') const gridClassName = showScope ? 'grid-cols-[minmax(76px,1fr)_minmax(84px,1.25fr)_72px_28px]' : 'grid-cols-[minmax(120px,180px)_minmax(160px,1fr)_28px]' - const keyPlaceholder = t($ => $['agentDetail.configure.advancedSettings.envEditor.keyPlaceholder']) - const valuePlaceholder = t($ => $['agentDetail.configure.advancedSettings.envEditor.valuePlaceholder']) + const keyPlaceholder = t( + ($) => $['agentDetail.configure.advancedSettings.envEditor.keyPlaceholder'], + ) + const valuePlaceholder = t( + ($) => $['agentDetail.configure.advancedSettings.envEditor.valuePlaceholder'], + ) const renderDraftPlaceholder = (label: string) => ( <span className="min-w-0 truncate px-3 system-xs-regular text-components-input-text-placeholder"> @@ -281,13 +327,12 @@ function EnvEditorDraftRow({ ) const renderDraftValueCell = () => { - if (!onAdd) - return renderDraftPlaceholder(valuePlaceholder) + if (!onAdd) return renderDraftPlaceholder(valuePlaceholder) return ( <button type="button" - aria-label={t($ => $['agentDetail.configure.advancedSettings.envEditor.add'])} + aria-label={t(($) => $['agentDetail.configure.advancedSettings.envEditor.add'])} onClick={() => onAdd({ focusField: 'value' })} className="flex h-full w-full min-w-0 items-center px-3 text-left system-xs-regular text-components-input-text-placeholder hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > @@ -298,12 +343,8 @@ function EnvEditorDraftRow({ return ( <div className={cn('grid min-h-7 border-t border-divider-subtle', gridClassName)}> - <EnvEditorCell> - {renderDraftPlaceholder(keyPlaceholder)} - </EnvEditorCell> - <EnvEditorCell> - {renderDraftValueCell()} - </EnvEditorCell> + <EnvEditorCell>{renderDraftPlaceholder(keyPlaceholder)}</EnvEditorCell> + <EnvEditorCell>{renderDraftValueCell()}</EnvEditorCell> {showScope && ( <EnvEditorCell> <EnvEditorScope scope="plain" /> @@ -329,9 +370,9 @@ export function EnvVariablesTable({ }: { editable?: boolean envVariables: EnvVariable[] - focusedVariable?: { id: string, field: 'key' | 'value' } + focusedVariable?: { id: string; field: 'key' | 'value' } highlightedIndex?: number - onAdd?: (options?: { focusField?: 'key' | 'value', scope?: EnvScope }) => void + onAdd?: (options?: { focusField?: 'key' | 'value'; scope?: EnvScope }) => void onDelete: (id: string) => void onKeyChange?: (id: string, key: string) => void onScopeChange: (id: string, scope: EnvScope) => void @@ -346,10 +387,12 @@ export function EnvVariablesTable({ const checkEnvVariableKey = (key: string) => { const { isValid, errorMessageKey } = checkKeys([key], false) if (!isValid) { - toast.error(t($ => $[`varKeyError.${errorMessageKey}`], { - ns: 'appDebug', - key: t($ => $['agentDetail.configure.advancedSettings.envEditor.keyColumn']), - })) + toast.error( + t(($) => $[`varKeyError.${errorMessageKey}`], { + ns: 'appDebug', + key: t(($) => $['agentDetail.configure.advancedSettings.envEditor.keyColumn']), + }), + ) return false } @@ -357,8 +400,7 @@ export function EnvVariablesTable({ } const handleKeyChange = (id: string, key: string) => { const normalizedKey = key.replaceAll(' ', '_') - if (normalizedKey && !checkEnvVariableKey(normalizedKey)) - return + if (normalizedKey && !checkEnvVariableKey(normalizedKey)) return onKeyChange?.(id, normalizedKey) } @@ -368,18 +410,18 @@ export function EnvVariablesTable({ <div className={cn('grid min-h-7 text-text-tertiary', gridClassName)}> <EnvEditorCell> <span className="px-3 system-xs-medium-uppercase"> - {t($ => $['agentDetail.configure.advancedSettings.envEditor.keyColumn'])} + {t(($) => $['agentDetail.configure.advancedSettings.envEditor.keyColumn'])} </span> </EnvEditorCell> <EnvEditorCell> <span className="px-3 system-xs-medium-uppercase"> - {t($ => $['agentDetail.configure.advancedSettings.envEditor.valueColumn'])} + {t(($) => $['agentDetail.configure.advancedSettings.envEditor.valueColumn'])} </span> </EnvEditorCell> {showScope && ( <EnvEditorCell> <span className="px-3 system-xs-medium-uppercase"> - {t($ => $['agentDetail.configure.advancedSettings.envEditor.scopeColumn'])} + {t(($) => $['agentDetail.configure.advancedSettings.envEditor.scopeColumn'])} </span> </EnvEditorCell> )} @@ -387,7 +429,7 @@ export function EnvVariablesTable({ {onAdd && ( <button type="button" - aria-label={t($ => $['agentDetail.configure.advancedSettings.envEditor.add'])} + aria-label={t(($) => $['agentDetail.configure.advancedSettings.envEditor.add'])} onClick={() => onAdd()} className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > @@ -404,9 +446,9 @@ export function EnvVariablesTable({ editable={editable} isHighlighted={index === highlightedIndex} onDelete={() => onDelete(variable.id)} - onKeyChange={key => handleKeyChange(variable.id, key)} - onScopeChange={scope => onScopeChange(variable.id, scope)} - onValueChange={value => onValueChange?.(variable.id, value)} + onKeyChange={(key) => handleKeyChange(variable.id, key)} + onScopeChange={(scope) => onScopeChange(variable.id, scope)} + onValueChange={(value) => onValueChange?.(variable.id, value)} showScope={showScope} /> ))} @@ -426,13 +468,12 @@ export function AgentEnvEditor() { const setEnvVariableScope = useSetAtom(setEnvVariableScopeAtom) const setEnvVariableValue = useSetAtom(setEnvVariableValueAtom) const starterVariableRef = useRef<EnvVariable | undefined>(undefined) - if (!starterVariableRef.current) - starterVariableRef.current = createEnvVariable() + if (!starterVariableRef.current) starterVariableRef.current = createEnvVariable() const starterVariable = starterVariableRef.current - const [focusedVariable, setFocusedVariable] = useState<{ id: string, field: 'key' | 'value' }>() + const [focusedVariable, setFocusedVariable] = useState<{ id: string; field: 'key' | 'value' }>() const envImportInputRef = useRef<HTMLInputElement>(null) - const envEditorTip = t($ => $['agentDetail.configure.advancedSettings.envEditor.tip']) - const envImportTip = t($ => $[envImportTipKeys[getCurrentEnvImportPlatform()]]) + const envEditorTip = t(($) => $['agentDetail.configure.advancedSettings.envEditor.tip']) + const envImportTip = t(($) => $[envImportTipKeys[getCurrentEnvImportPlatform()]]) const envEditorTableId = 'agent-configure-env-editor-table' const visibleEnvVariables = envVariables.length > 0 ? envVariables : [starterVariable] @@ -455,20 +496,18 @@ export function AgentEnvEditor() { setFocusedVariable({ id: variable.id, field: focusField }) } const handleImportEnvVariables = async (file: File) => { - const { - invalidLineCount, - variables, - } = parseEnvImport(await file.text()) + const { invalidLineCount, variables } = parseEnvImport(await file.text()) const importedVariables = variables.map(createEnvVariableFromEntry) if (invalidLineCount > 0) { - toast.error(t($ => $['agentDetail.configure.advancedSettings.envEditor.importSkippedInvalidLines'], { - count: invalidLineCount, - })) + toast.error( + t(($) => $['agentDetail.configure.advancedSettings.envEditor.importSkippedInvalidLines'], { + count: invalidLineCount, + }), + ) } - if (importedVariables.length === 0) - return + if (importedVariables.length === 0) return importEnvVariables(importedVariables) } @@ -487,7 +526,7 @@ export function AgentEnvEditor() { return ( <ConfigureSection - label={t($ => $['agentDetail.configure.advancedSettings.envEditor.label'])} + label={t(($) => $['agentDetail.configure.advancedSettings.envEditor.label'])} labelId="agent-configure-env-editor-label" headingLevel="h4" panelId={envEditorTableId} @@ -496,42 +535,43 @@ export function AgentEnvEditor() { rootClassName="gap-1 pt-3" headerClassName="mb-0 gap-1 px-3" panelContentClassName="px-3 pb-3" - actions={!readOnly - ? ( - <> - <input - ref={envImportInputRef} - className="hidden" - type="file" - onChange={(event) => { - const file = event.target.files?.[0] - event.target.value = '' - - if (file) - void handleImportEnvVariables(file) - }} + actions={ + !readOnly ? ( + <> + <input + ref={envImportInputRef} + className="hidden" + type="file" + onChange={(event) => { + const file = event.target.files?.[0] + event.target.value = '' + + if (file) void handleImportEnvVariables(file) + }} + /> + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + aria-label={t( + ($) => $['agentDetail.configure.advancedSettings.envEditor.importEnv'], + )} + onClick={() => envImportInputRef.current?.click()} + className="flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" + > + <span aria-hidden className="i-ri-file-upload-line size-3.5" /> + <span className="system-xs-medium"> + {t(($) => $['agentDetail.configure.advancedSettings.envEditor.importEnv'])} + </span> + </button> + } /> - <Tooltip> - <TooltipTrigger - render={( - <button - type="button" - aria-label={t($ => $['agentDetail.configure.advancedSettings.envEditor.importEnv'])} - onClick={() => envImportInputRef.current?.click()} - className="flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" - > - <span aria-hidden className="i-ri-file-upload-line size-3.5" /> - <span className="system-xs-medium">{t($ => $['agentDetail.configure.advancedSettings.envEditor.importEnv'])}</span> - </button> - )} - /> - <TooltipContent className="max-w-72"> - {envImportTip} - </TooltipContent> - </Tooltip> - </> - ) - : undefined} + <TooltipContent className="max-w-72">{envImportTip}</TooltipContent> + </Tooltip> + </> + ) : undefined + } > <EnvVariablesTable editable={!readOnly} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/index.tsx index e2212f3f075d84..7ac4415af7c5ce 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/index.tsx @@ -12,10 +12,10 @@ export function AgentAdvancedSettings() { return ( <ConfigureSection - label={t($ => $['agentDetail.configure.advancedSettings.label'])} + label={t(($) => $['agentDetail.configure.advancedSettings.label'])} labelId="agent-configure-advanced-settings-label" panelId={advancedSettingsPanelId} - description={t($ => $['agentDetail.configure.advancedSettings.description'])} + description={t(($) => $['agentDetail.configure.advancedSettings.description'])} defaultOpen={false} buildDraftChangeSection="advancedSettings" rootClassName="gap-2 pt-1 pb-3" diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/bottom-actions.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/bottom-actions.tsx index bf798ba9b8f448..66ffa1c75301fb 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/bottom-actions.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/bottom-actions.tsx @@ -9,9 +9,7 @@ export function AgentOrchestrateBottomActions({ shrinkOnOpen?: boolean }) { return ( - <div - className="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex h-[72px] flex-col items-center justify-end px-4 pt-4 pb-2 transition-[height] duration-150 ease-out has-[[data-open]]:h-[307px] motion-reduce:transition-none" - > + <div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex h-[72px] flex-col items-center justify-end px-4 pt-4 pb-2 transition-[height] duration-150 ease-out has-[[data-open]]:h-[307px] motion-reduce:transition-none"> <div aria-hidden className="pointer-events-none absolute inset-0 z-0 bg-gradient-to-t from-components-panel-bg to-components-panel-bg-transparent [mask-image:linear-gradient(to_top,black,transparent)] backdrop-blur-[2px] [-webkit-mask-image:linear-gradient(to_top,black,transparent)]" diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx index 7d534f7c02f826..74e9cb415eb47b 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx @@ -37,15 +37,15 @@ export function AgentBuildDraftBar({ const isActionPending = isApplying || isDiscarding const applyDisabled = disabled || isActionPending const discardDisabled = disabled || isActionPending - const changesLabel = t($ => $['agentDetail.configure.buildDraft.changesToApply'], { count: changesCount }) + const changesLabel = t(($) => $['agentDetail.configure.buildDraft.changesToApply'], { + count: changesCount, + }) const handleOpenChange = (nextOpen: boolean) => { if (nextOpen) { const width = collapsedBarRef.current?.getBoundingClientRect().width - if (width) - setPanelWidth(width) - } - else { + if (width) setPanelWidth(width) + } else { setPanelWidth(undefined) } @@ -57,7 +57,7 @@ export function AgentBuildDraftBar({ open={open} onOpenChange={handleOpenChange} role="group" - aria-label={t($ => $['agentDetail.configure.buildDraft.title'])} + aria-label={t(($) => $['agentDetail.configure.buildDraft.title'])} className="group/build-draft pointer-events-auto relative w-full max-w-full min-w-0 overflow-hidden rounded-xl border-[1.5px] border-[#A0BDFF] bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[10px]" > <AgentBuildGridTexture @@ -66,17 +66,24 @@ export function AgentBuildDraftBar({ className="pointer-events-none absolute top-[-104px] left-[-1171px] z-0 opacity-70" dotClassName="bg-[#5C90FF]" /> - <CollapsiblePanel id={changesPanelId} className="relative z-1" style={panelWidth ? { width: panelWidth } : undefined}> + <CollapsiblePanel + id={changesPanelId} + className="relative z-1" + style={panelWidth ? { width: panelWidth } : undefined} + > <AgentBuildDraftChangesPanel changeSummary={changeSummary} changesLabel={changesLabel} onToggle={() => handleOpenChange(!open)} /> </CollapsiblePanel> - <div ref={collapsedBarRef} className="relative z-1 flex h-[50px] max-w-full min-w-0 items-center gap-2 p-2 group-data-open/build-draft:justify-end"> + <div + ref={collapsedBarRef} + className="relative z-1 flex h-[50px] max-w-full min-w-0 items-center gap-2 p-2 group-data-open/build-draft:justify-end" + > <div className="flex min-w-0 flex-1 items-center gap-3 pr-8 pl-2 group-data-open/build-draft:hidden"> <p className="min-w-0 truncate system-sm-semibold text-text-primary"> - {t($ => $['agentDetail.configure.buildDraft.title'])} + {t(($) => $['agentDetail.configure.buildDraft.title'])} </p> <button type="button" @@ -85,9 +92,7 @@ export function AgentBuildDraftBar({ className="flex min-w-0 cursor-pointer items-center gap-0.5 rounded-sm text-text-tertiary hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={() => handleOpenChange(true)} > - <span className="min-w-0 truncate system-xs-regular"> - {changesLabel} - </span> + <span className="min-w-0 truncate system-xs-regular">{changesLabel}</span> <span aria-hidden className="i-ri-arrow-right-s-line size-4 shrink-0" /> </button> </div> @@ -101,7 +106,7 @@ export function AgentBuildDraftBar({ disabled={discardDisabled} className="relative z-1 h-8 shrink-0 rounded-lg px-3" > - {t($ => $['agentDetail.configure.buildDraft.discard'])} + {t(($) => $['agentDetail.configure.buildDraft.discard'])} </Button> </AgentConfigureClearSessionConfirmDialog> <Button @@ -112,7 +117,7 @@ export function AgentBuildDraftBar({ className="relative z-1 h-8 min-w-20 shrink-0 rounded-lg px-3" onClick={onApply} > - {tCustom($ => $.apply)} + {tCustom(($) => $.apply)} </Button> </div> </Collapsible> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-context.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-context.ts index 7454049d52d770..7eeed478ac2f77 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-context.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-context.ts @@ -26,18 +26,20 @@ export type AgentBuildDraftChangeSummary = { envVariables: readonly AgentBuildDraftChangeItem[] } -export type AgentBuildDraftChangeSection - = | 'skills' - | 'files' - | 'advancedSettings' +export type AgentBuildDraftChangeSection = 'skills' | 'files' | 'advancedSettings' -const changedKeysBySection: Record<AgentBuildDraftChangeSection, readonly AgentBuildDraftChangedKey[]> = { +const changedKeysBySection: Record< + AgentBuildDraftChangeSection, + readonly AgentBuildDraftChangedKey[] +> = { skills: ['skills'], files: ['files'], advancedSettings: ['envVariables'], } -const AgentBuildDraftChangedKeysContext = createContext<ReadonlySet<AgentBuildDraftChangedKey>>(new Set()) +const AgentBuildDraftChangedKeysContext = createContext<ReadonlySet<AgentBuildDraftChangedKey>>( + new Set(), +) export function AgentBuildDraftChangedKeysProvider({ changedKeys, @@ -58,8 +60,7 @@ export function AgentBuildDraftChangedKeysProvider({ export function useIsAgentBuildDraftSectionChanged(section?: AgentBuildDraftChangeSection) { const changedKeys = useContext(AgentBuildDraftChangedKeysContext) - if (!section) - return false + if (!section) return false - return changedKeysBySection[section].some(key => changedKeys.has(key)) + return changedKeysBySection[section].some((key) => changedKeys.has(key)) } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-panel.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-panel.tsx index a07be8bf220c86..ff5e239c6b5349 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-panel.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-panel.tsx @@ -1,7 +1,10 @@ 'use client' import type { TFunction } from 'i18next' -import type { AgentBuildDraftChangeItem, AgentBuildDraftChangeSummary } from './build-draft-changes-context' +import type { + AgentBuildDraftChangeItem, + AgentBuildDraftChangeSummary, +} from './build-draft-changes-context' import { cn } from '@langgenius/dify-ui/cn' import { FileTreeIcon } from '@langgenius/dify-ui/file-tree' import { useTranslation } from 'react-i18next' @@ -28,21 +31,19 @@ export function AgentBuildDraftChangesPanel({ <section className="flex w-full max-w-full flex-col px-2 pt-2"> <div className="flex h-6 min-w-0 items-center gap-3 pr-8 pl-2"> <p className="min-w-0 truncate system-sm-semibold text-text-primary"> - {t($ => $['agentDetail.configure.buildDraft.title'])} + {t(($) => $['agentDetail.configure.buildDraft.title'])} </p> <button type="button" className="flex min-w-0 cursor-pointer items-center gap-0.5 rounded-sm text-text-tertiary hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={onToggle} > - <span className="min-w-0 truncate system-xs-regular"> - {changesLabel} - </span> + <span className="min-w-0 truncate system-xs-regular">{changesLabel}</span> <span aria-hidden className="i-ri-arrow-right-s-line size-4 shrink-0 rotate-90" /> </button> </div> <div className="flex max-h-[232px] flex-col gap-1 overflow-y-auto pt-2 pb-1"> - {sections.map(section => ( + {sections.map((section) => ( <AgentBuildDraftChangeSectionRow key={section.key} section={section} /> ))} </div> @@ -50,11 +51,7 @@ export function AgentBuildDraftChangesPanel({ ) } -function AgentBuildDraftChangeSectionRow({ - section, -}: { - section: AgentBuildDraftChangeSection -}) { +function AgentBuildDraftChangeSectionRow({ section }: { section: AgentBuildDraftChangeSection }) { return ( <div className="flex w-full items-start p-2"> <div className="flex min-w-20 shrink-0 items-center gap-1.5"> @@ -63,26 +60,18 @@ function AgentBuildDraftChangeSectionRow({ {section.label} </p> </div> - {section.items?.length - ? ( - <div className="flex min-w-0 flex-1 flex-col gap-2"> - {section.items.map(item => ( - <AgentBuildDraftChangeItemRow key={`${item.operation}-${item.id}`} item={item} /> - ))} - </div> - ) - : ( - null - )} + {section.items?.length ? ( + <div className="flex min-w-0 flex-1 flex-col gap-2"> + {section.items.map((item) => ( + <AgentBuildDraftChangeItemRow key={`${item.operation}-${item.id}`} item={item} /> + ))} + </div> + ) : null} </div> ) } -function AgentBuildDraftChangeItemRow({ - item, -}: { - item: AgentBuildDraftChangeItem -}) { +function AgentBuildDraftChangeItemRow({ item }: { item: AgentBuildDraftChangeItem }) { const { t } = useTranslation('agentV2') const descriptionKey = item.descriptionKey @@ -101,18 +90,19 @@ function AgentBuildDraftChangeItemRow({ )} /> <div className="flex min-w-0 flex-1 items-center gap-1"> - {item.icon - ? <FileTreeIcon type={item.icon} className="size-4" /> - : <span aria-hidden className="i-custom-public-agent-building-blocks size-4 shrink-0 text-text-tertiary" />} - <p className="min-w-0 truncate system-sm-regular text-text-secondary"> - {item.name} - </p> + {item.icon ? ( + <FileTreeIcon type={item.icon} className="size-4" /> + ) : ( + <span + aria-hidden + className="i-custom-public-agent-building-blocks size-4 shrink-0 text-text-tertiary" + /> + )} + <p className="min-w-0 truncate system-sm-regular text-text-secondary">{item.name}</p> </div> </div> {descriptionKey && ( - <p className="ms-4 system-xs-regular text-text-tertiary"> - {t($ => $[descriptionKey])} - </p> + <p className="ms-4 system-xs-regular text-text-tertiary">{t(($) => $[descriptionKey])}</p> )} </div> ) @@ -125,13 +115,15 @@ function getChangeSections({ changeSummary?: AgentBuildDraftChangeSummary t: TFunction<'agentV2'> }): AgentBuildDraftChangeSection[] { - if (!changeSummary) - return [] + if (!changeSummary) return [] const sections: AgentBuildDraftChangeSection[] = [] - const pushItemSection = (key: string, label: string, items: readonly AgentBuildDraftChangeItem[]) => { - if (items.length === 0) - return + const pushItemSection = ( + key: string, + label: string, + items: readonly AgentBuildDraftChangeItem[], + ) => { + if (items.length === 0) return sections.push({ key, @@ -140,9 +132,21 @@ function getChangeSections({ }) } - pushItemSection('skills', t($ => $['agentDetail.configure.skills.label']), changeSummary.skills) - pushItemSection('files', t($ => $['agentDetail.configure.files.label']), changeSummary.files) - pushItemSection('envVariables', t($ => $['agentDetail.configure.advancedSettings.envEditor.shortLabel']), changeSummary.envVariables) + pushItemSection( + 'skills', + t(($) => $['agentDetail.configure.skills.label']), + changeSummary.skills, + ) + pushItemSection( + 'files', + t(($) => $['agentDetail.configure.files.label']), + changeSummary.files, + ) + pushItemSection( + 'envVariables', + t(($) => $['agentDetail.configure.advancedSettings.envEditor.shortLabel']), + changeSummary.envVariables, + ) return sections } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/__tests__/section.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/__tests__/section.spec.tsx index 0052bab0b05b78..66605b0dae9c85 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/__tests__/section.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/__tests__/section.spec.tsx @@ -33,24 +33,34 @@ describe('ConfigureSection', () => { it('should show a build draft change dot when Skills changed', () => { renderSection({ section: 'skills', changedKeys: ['skills'] }) - expect(screen.getByRole('heading', { name: 'Skills' }).querySelector('.bg-text-warning-secondary')).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'Skills' }).querySelector('.bg-text-warning-secondary'), + ).toBeInTheDocument() }) it('should show a build draft change dot when Files changed', () => { renderSection({ section: 'files', changedKeys: ['files'] }) - expect(screen.getByRole('heading', { name: 'Files' }).querySelector('.bg-text-warning-secondary')).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'Files' }).querySelector('.bg-text-warning-secondary'), + ).toBeInTheDocument() }) it('should show a build draft change dot when Advanced Settings changed', () => { renderSection({ section: 'advancedSettings', changedKeys: ['envVariables'] }) - expect(screen.getByRole('heading', { name: 'Advanced Settings' }).querySelector('.bg-text-warning-secondary')).toBeInTheDocument() + expect( + screen + .getByRole('heading', { name: 'Advanced Settings' }) + .querySelector('.bg-text-warning-secondary'), + ).toBeInTheDocument() }) it('should not show a build draft change dot when only another key changed', () => { renderSection({ section: 'skills', changedKeys: ['prompt'] }) - expect(screen.getByRole('heading', { name: 'Skills' }).querySelector('.bg-text-warning-secondary')).not.toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'Skills' }).querySelector('.bg-text-warning-secondary'), + ).not.toBeInTheDocument() }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/add-button.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/add-button.tsx index 0b1dd9f65ca19f..98530767e4c0a4 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/add-button.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/add-button.tsx @@ -6,7 +6,10 @@ import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' import { useAgentOrchestrateReadOnly } from '../read-only-context' -type ConfigureSectionAddButtonProps = Omit<ButtonProps, 'aria-label' | 'children' | 'size' | 'variant'> & { +type ConfigureSectionAddButtonProps = Omit< + ButtonProps, + 'aria-label' | 'children' | 'size' | 'variant' +> & { ariaLabel: string } @@ -18,8 +21,7 @@ export function ConfigureSectionAddButton({ const { t } = useTranslation('common') const readOnly = useAgentOrchestrateReadOnly() - if (readOnly) - return null + if (readOnly) return null return ( <Button @@ -30,7 +32,7 @@ export function ConfigureSectionAddButton({ className={cn('shrink-0 gap-1 px-2', className)} > <span aria-hidden className="i-ri-add-line size-3.5" /> - <span>{t($ => $['operation.add'])}</span> + <span>{t(($) => $['operation.add'])}</span> </Button> ) } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/configurable-item.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/configurable-item.tsx index f46a3abb60b9da..357ab4dfc431b8 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/configurable-item.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/configurable-item.tsx @@ -27,9 +27,7 @@ export function ConfigureSectionConfigurableItem({ <div className="group relative flex min-h-8 items-center gap-1 overflow-hidden rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg py-1.5 pr-1.5 pl-2 shadow-xs shadow-shadow-shadow-3 focus-within:border-components-panel-border focus-within:bg-components-panel-on-panel-item-bg-hover focus-within:shadow-sm hover:border-components-panel-border hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm"> <div className="flex min-w-0 flex-1 items-center gap-2 py-0.5 pr-1"> {icon} - <span className="min-w-0 truncate system-sm-medium text-text-primary"> - {label} - </span> + <span className="min-w-0 truncate system-sm-medium text-text-primary">{label}</span> </div> {!readOnly && ( <div className="pointer-events-none absolute top-1/2 right-1.5 flex -translate-y-1/2 items-center gap-1 bg-components-panel-on-panel-item-bg-hover opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/docs-link.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/docs-link.tsx index bc530dc4ba5c09..6b1e6193ef2228 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/docs-link.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/docs-link.tsx @@ -1,12 +1,6 @@ import type { ReactNode } from 'react' -export function DocsLink({ - children, - href, -}: { - children?: ReactNode - href: string -}) { +export function DocsLink({ children, href }: { children?: ReactNode; href: string }) { return ( <a href={href} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/empty.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/empty.tsx index 8631c885be3712..860bfed3bce055 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/empty.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/empty.tsx @@ -9,12 +9,8 @@ export function ConfigureSectionEmpty({ }) { return ( <div className="flex w-full flex-col items-start gap-1 rounded-xl bg-background-section p-3 text-text-tertiary"> - <p className="w-full system-xs-medium"> - {title} - </p> - <p className="w-full system-xs-regular"> - {description} - </p> + <p className="w-full system-xs-medium">{title}</p> + <p className="w-full system-xs-regular">{description}</p> </div> ) } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/section.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/section.tsx index e6434ab008e309..95d7c2d472f9c1 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/section.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/section.tsx @@ -3,11 +3,7 @@ import type { ReactNode } from 'react' import type { AgentBuildDraftChangeSection } from '../build-draft-changes-context' import { cn } from '@langgenius/dify-ui/cn' -import { - Collapsible, - CollapsiblePanel, - CollapsibleTrigger, -} from '@langgenius/dify-ui/collapsible' +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible' import { Infotip } from '@/app/components/base/infotip' import { AgentBuildDraftChangeDot } from '../build-draft-change-dot' import { useIsAgentBuildDraftSectionChanged } from '../build-draft-changes-context' @@ -28,16 +24,17 @@ type ConfigureSectionBaseProps = { panelContentClassName?: string } -type ConfigureSectionProps = ConfigureSectionBaseProps & ( - | { - tip: ReactNode - tipAriaLabel: string - } - | { - tip?: undefined - tipAriaLabel?: undefined - } -) +type ConfigureSectionProps = ConfigureSectionBaseProps & + ( + | { + tip: ReactNode + tipAriaLabel: string + } + | { + tip?: undefined + tipAriaLabel?: undefined + } + ) export function ConfigureSection({ label, @@ -73,16 +70,16 @@ export function ConfigureSection({ <div className={cn('flex min-w-0 items-center', titleRowClassName)}> <Heading id={labelId} className="relative min-w-0 shrink-0"> {isBuildDraftChanged && <AgentBuildDraftChangeDot />} - <CollapsibleTrigger - className="h-6 min-h-0 w-auto max-w-full justify-start gap-0 rounded-sm px-0 text-text-secondary hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-secondary data-panel-open:text-text-secondary" - > - <span className="min-w-0 truncate system-sm-semibold-uppercase"> - {label} - </span> + <CollapsibleTrigger className="h-6 min-h-0 w-auto max-w-full justify-start gap-0 rounded-sm px-0 text-text-secondary hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-secondary data-panel-open:text-text-secondary"> + <span className="min-w-0 truncate system-sm-semibold-uppercase">{label}</span> </CollapsibleTrigger> </Heading> {hasTip && ( - <Infotip aria-label={tipAriaLabel} className="ml-0.5 size-3.5" popupClassName="max-w-64"> + <Infotip + aria-label={tipAriaLabel} + className="ml-0.5 size-3.5" + popupClassName="max-w-64" + > {tip} </Infotip> )} @@ -96,18 +93,12 @@ export function ConfigureSection({ /> </CollapsibleTrigger> </div> - {hasDescription && ( - <p className="system-xs-regular text-text-tertiary"> - {description} - </p> - )} + {hasDescription && <p className="system-xs-regular text-text-tertiary">{description}</p>} </div> {actions} </div> <CollapsiblePanel id={panelId}> - <div className={panelContentClassName}> - {children} - </div> + <div className={panelContentClassName}>{children}</div> </CollapsiblePanel> </Collapsible> ) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/tip-content.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/tip-content.tsx index 6bf5bc5624a7fa..3bf3cc1421cec6 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/tip-content.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/tip-content.tsx @@ -16,7 +16,7 @@ export function AgentConfigureTipContent({ type }: AgentConfigureTipContentProps return ( <span className="whitespace-pre-line"> <Trans - i18nKey={$ => $['agentDetail.configure.advancedSettings.envEditor.richTip']} + i18nKey={($) => $['agentDetail.configure.advancedSettings.envEditor.richTip']} ns="agentV2" components={{ docLink: <DocsLink href={docLink('/use-dify/build/agent')} />, @@ -30,7 +30,7 @@ export function AgentConfigureTipContent({ type }: AgentConfigureTipContentProps return ( <span className="whitespace-pre-line"> <Trans - i18nKey={$ => $['agentDetail.configure.skills.richTip']} + i18nKey={($) => $['agentDetail.configure.skills.richTip']} ns="agentV2" components={{ docLink: <DocsLink href={docLink('/use-dify/build/new-agent/build#skills')} />, @@ -44,7 +44,7 @@ export function AgentConfigureTipContent({ type }: AgentConfigureTipContentProps return ( <span className="whitespace-pre-line"> <Trans - i18nKey={$ => $['agentDetail.configure.tools.richTip']} + i18nKey={($) => $['agentDetail.configure.tools.richTip']} ns="agentV2" components={{ docLink: <DocsLink href={docLink('/use-dify/build/new-agent/build#tools')} />, @@ -57,17 +57,21 @@ export function AgentConfigureTipContent({ type }: AgentConfigureTipContentProps if (type === 'knowledge') { return ( <Trans - i18nKey={$ => $['agentDetail.configure.knowledgeRetrieval.richTip']} + i18nKey={($) => $['agentDetail.configure.knowledgeRetrieval.richTip']} ns="agentV2" components={{ - docLink: <DocsLink href={docLink('/use-dify/build/new-agent/build#knowledge-retrieval')} />, + docLink: ( + <DocsLink href={docLink('/use-dify/build/new-agent/build#knowledge-retrieval')} /> + ), }} /> ) } if (type === 'files') - return <span className="whitespace-pre-line">{t($ => $['agentDetail.configure.files.tip'])}</span> + return ( + <span className="whitespace-pre-line">{t(($) => $['agentDetail.configure.files.tip'])}</span> + ) - return <>{t($ => $[`agentDetail.configure.${type}.tip`])}</> + return <>{t(($) => $[`agentDetail.configure.${type}.tip`])}</> } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/config-context.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/config-context.ts index faa1892cf7e7be..807bec9f59136c 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/config-context.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/config-context.ts @@ -21,8 +21,7 @@ export const AgentConfigApiContextProvider = AgentConfigApiContext.Provider export const useAgentConfigApiContext = () => { const context = use(AgentConfigApiContext) - if (!context) - throw new Error('AgentConfigApiContextProvider is required for config-backed UI.') + if (!context) throw new Error('AgentConfigApiContextProvider is required for config-backed UI.') return context } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/file-icon.spec.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/file-icon.spec.ts index 28827f3c560506..16d6df93eded28 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/file-icon.spec.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/file-icon.spec.ts @@ -3,27 +3,35 @@ import { getDriveFileIconType, getFileIconType } from '../file-icon' describe('agent file icon helpers', () => { it('should infer supported icons for uploaded drive file pointer kinds', () => { - expect(getDriveFileIconType({ - fileKind: 'upload_file', - fileName: 'report.md', - mimeType: 'text/markdown', - })).toBe('markdown') - expect(getDriveFileIconType({ - fileKind: 'tool_file', - fileName: 'image.png', - mimeType: 'image/png', - })).toBe('image') + expect( + getDriveFileIconType({ + fileKind: 'upload_file', + fileName: 'report.md', + mimeType: 'text/markdown', + }), + ).toBe('markdown') + expect( + getDriveFileIconType({ + fileKind: 'tool_file', + fileName: 'image.png', + mimeType: 'image/png', + }), + ).toBe('image') }) it('should keep supported drive file kinds and normalize directories', () => { - expect(getDriveFileIconType({ - fileKind: 'directory', - fileName: 'files', - })).toBe('folder') - expect(getDriveFileIconType({ - fileKind: 'pdf', - fileName: 'guide', - })).toBe('pdf') + expect( + getDriveFileIconType({ + fileKind: 'directory', + fileName: 'files', + }), + ).toBe('folder') + expect( + getDriveFileIconType({ + fileKind: 'pdf', + fileName: 'guide', + }), + ).toBe('pdf') }) it('should infer icons from file extension when mime type is not enough', () => { diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx index a80fcf95986269..6001c8dc257cd4 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx @@ -36,7 +36,10 @@ const mocks = vi.hoisted(() => ({ size: 5, }, })), - deleteFileMutationFn: vi.fn(async (_input: unknown) => ({ removed_names: ['brief.md'], result: 'success' })), + deleteFileMutationFn: vi.fn(async (_input: unknown) => ({ + removed_names: ['brief.md'], + result: 'success', + })), previewQueryOptions: vi.fn((_options: ConfigFileQueryOptionsInput) => ({})), downloadQueryOptions: vi.fn((_options: ConfigFileQueryOptionsInput) => ({})), downloadBlob: vi.fn(), @@ -125,14 +128,12 @@ function ConfigSnapshotProbe() { const draft = useAtomValue(agentComposerDraftAtom) const configSnapshot = formStateToAgentSoulConfig({ formState: draft }) - return ( - <pre data-testid="config-snapshot-probe"> - {JSON.stringify(configSnapshot)} - </pre> - ) + return <pre data-testid="config-snapshot-probe">{JSON.stringify(configSnapshot)}</pre> } -function createInitialDraft(overrides: Partial<AgentSoulConfigFormState> = {}): AgentSoulConfigFormState { +function createInitialDraft( + overrides: Partial<AgentSoulConfigFormState> = {}, +): AgentSoulConfigFormState { return { ...defaultAgentSoulConfigFormState, files: [ @@ -176,7 +177,10 @@ function renderAgentFiles({ return render( <QueryClientProvider client={queryClient}> <AgentConfigApiContextProvider value={apiContext}> - <AgentComposerProvider initialDraft={initialDraft} initialOriginalConfig={initialOriginalConfig}> + <AgentComposerProvider + initialDraft={initialDraft} + initialOriginalConfig={initialOriginalConfig} + > <AgentOrchestrateReadOnlyContext value={readOnly}> <AgentFiles /> <ConfigSnapshotProbe /> @@ -236,7 +240,9 @@ describe('AgentFiles', () => { const user = userEvent.setup() renderAgentFiles({ initialDraft: defaultAgentSoulConfigFormState }) - await user.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i })) + await user.click( + screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }), + ) const input = await waitFor(() => { const element = document.querySelector('input[type="file"]') @@ -245,7 +251,9 @@ describe('AgentFiles', () => { }) const file = new File(['hello'], 'uploaded.md', { type: 'text/markdown' }) await user.upload(input, file) - await user.click(screen.getByRole('button', { name: /agentDetail\.configure\.files\.upload\.action/i })) + await user.click( + screen.getByRole('button', { name: /agentDetail\.configure\.files\.upload\.action/i }), + ) await waitFor(() => { expect(mocks.uploadFileMutationFn).toHaveBeenCalled() @@ -300,7 +308,9 @@ describe('AgentFiles', () => { }, }) - await user.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i })) + await user.click( + screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }), + ) const input = await waitFor(() => { const element = document.querySelector('input[type="file"]') @@ -309,7 +319,9 @@ describe('AgentFiles', () => { }) const file = new File(['hello'], 'uploaded.md', { type: 'text/markdown' }) await user.upload(input, file) - await user.click(screen.getByRole('button', { name: /agentDetail\.configure\.files\.upload\.action/i })) + await user.click( + screen.getByRole('button', { name: /agentDetail\.configure\.files\.upload\.action/i }), + ) await waitFor(() => { expect(mocks.commitFileMutationFn.mock.calls[0]?.[0]).toEqual({ @@ -330,19 +342,21 @@ describe('AgentFiles', () => { await user.click(screen.getByText('diagram.png').closest('button')!) await waitFor(() => { - expect(mocks.previewQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: { - app_id: 'app-1', - name: 'diagram.png', - }, - query: { - draft_type: 'draft', - node_id: 'node-1', - version_id: 'draft-1', - }, + expect(mocks.previewQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: { + app_id: 'app-1', + name: 'diagram.png', + }, + query: { + draft_type: 'draft', + node_id: 'node-1', + version_id: 'draft-1', + }, + }), }), - })) + ) }) }) @@ -353,25 +367,29 @@ describe('AgentFiles', () => { await user.click(screen.getByText('diagram.png').closest('button')!) await waitFor(() => { - expect(mocks.previewQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: { - agent_id: 'agent-1', - name: 'diagram.png', - }, + expect(mocks.previewQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: { + agent_id: 'agent-1', + name: 'diagram.png', + }, + }), }), - })) + ) }) await waitFor(() => { - expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: { - agent_id: 'agent-1', - name: 'diagram.png', - }, + expect(mocks.downloadQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: { + agent_id: 'agent-1', + name: 'diagram.png', + }, + }), }), - })) + ) }) }) @@ -379,19 +397,23 @@ describe('AgentFiles', () => { const user = userEvent.setup() renderAgentFiles() - await user.click(screen.getByRole('button', { - name: /agentV2\.agentDetail\.configure\.files\.download.*diagram\.png/, - })) + await user.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.files\.download.*diagram\.png/, + }), + ) await waitFor(() => { - expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: { - agent_id: 'agent-1', - name: 'diagram.png', - }, + expect(mocks.downloadQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: { + agent_id: 'agent-1', + name: 'diagram.png', + }, + }), }), - })) + ) }) expect(mocks.downloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/diagram.png', @@ -406,19 +428,23 @@ describe('AgentFiles', () => { await user.click(screen.getByText('diagram.png').closest('button')!) const dialog = await screen.findByRole('dialog') - await user.click(within(dialog).getByRole('button', { - name: /common\.operation\.download.*diagram\.png/, - })) + await user.click( + within(dialog).getByRole('button', { + name: /common\.operation\.download.*diagram\.png/, + }), + ) await waitFor(() => { - expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: { - agent_id: 'agent-1', - name: 'diagram.png', - }, + expect(mocks.downloadQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: { + agent_id: 'agent-1', + name: 'diagram.png', + }, + }), }), - })) + ) }) expect(mocks.downloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/diagram.png', @@ -433,7 +459,9 @@ describe('AgentFiles', () => { }) expect(screen.getByText('build_note.md')).toBeInTheDocument() - const fileNames = screen.getAllByText(/^(build_note\.md|diagram\.png|brief\.md)$/).map(element => element.textContent) + const fileNames = screen + .getAllByText(/^(build_note\.md|diagram\.png|brief\.md)$/) + .map((element) => element.textContent) expect(fileNames).toEqual(['build_note.md', 'diagram.png', 'brief.md']) vi.clearAllMocks() @@ -441,20 +469,24 @@ describe('AgentFiles', () => { await user.click(screen.getByText('build_note.md').closest('button')!) expect(await screen.findByText('Build context from the latest build chat.')).toBeInTheDocument() - expect(mocks.previewQueryOptions).not.toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: expect.objectContaining({ - name: 'build_note.md', + expect(mocks.previewQueryOptions).not.toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: expect.objectContaining({ + name: 'build_note.md', + }), }), }), - })) - expect(mocks.downloadQueryOptions).not.toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: expect.objectContaining({ - name: 'build_note.md', + ) + expect(mocks.downloadQueryOptions).not.toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: expect.objectContaining({ + name: 'build_note.md', + }), }), }), - })) + ) }) it('should download the virtual build note file as markdown content', async () => { @@ -463,9 +495,11 @@ describe('AgentFiles', () => { initialDraft: createInitialDraft({ configNote: 'Build context from the latest build chat.' }), }) - await user.click(screen.getByRole('button', { - name: /agentV2\.agentDetail\.configure\.files\.download.*build_note\.md/, - })) + await user.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.files\.download.*build_note\.md/, + }), + ) expect(mocks.downloadBlob).toHaveBeenCalledWith({ data: expect.any(Blob), @@ -473,13 +507,15 @@ describe('AgentFiles', () => { }) const blob = mocks.downloadBlob.mock.calls[0]?.[0].data as Blob await expect(blob.text()).resolves.toBe('Build context from the latest build chat.') - expect(mocks.downloadQueryOptions).not.toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: expect.objectContaining({ - name: 'build_note.md', + expect(mocks.downloadQueryOptions).not.toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: expect.objectContaining({ + name: 'build_note.md', + }), }), }), - })) + ) }) it('should download the virtual build note from the preview header action', async () => { @@ -491,9 +527,11 @@ describe('AgentFiles', () => { await user.click(screen.getByText('build_note.md').closest('button')!) const dialog = await screen.findByRole('dialog') - await user.click(within(dialog).getByRole('button', { - name: /common\.operation\.download.*build_note\.md/, - })) + await user.click( + within(dialog).getByRole('button', { + name: /common\.operation\.download.*build_note\.md/, + }), + ) expect(mocks.downloadBlob).toHaveBeenCalledWith({ data: expect.any(Blob), @@ -509,15 +547,23 @@ describe('AgentFiles', () => { initialDraft: createInitialDraft({ configNote: 'Build context from the latest build chat.' }), }) - const generatedBadge = screen.getByText('agentV2.agentDetail.configure.files.buildNote.generated') + const generatedBadge = screen.getByText( + 'agentV2.agentDetail.configure.files.buildNote.generated', + ) const buildNoteRow = generatedBadge.closest('li') expect(generatedBadge).toBeInTheDocument() expect(buildNoteRow).not.toBeNull() - await user.click(within(buildNoteRow!).getByRole('button', { name: 'agentV2.agentDetail.configure.files.buildNote.tooltip' })) + await user.click( + within(buildNoteRow!).getByRole('button', { + name: 'agentV2.agentDetail.configure.files.buildNote.tooltip', + }), + ) - expect(await screen.findByText('agentV2.agentDetail.configure.files.buildNote.richTooltip')).toBeInTheDocument() + expect( + await screen.findByText('agentV2.agentDetail.configure.files.buildNote.richTooltip'), + ).toBeInTheDocument() }) it('should clear config note when deleting the virtual build note file', async () => { @@ -526,9 +572,11 @@ describe('AgentFiles', () => { initialDraft: createInitialDraft({ configNote: 'Build context from the latest build chat.' }), }) - await user.click(screen.getByRole('button', { - name: /agentV2\.agentDetail\.configure\.files\.remove.*build_note\.md/, - })) + await user.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.files\.remove.*build_note\.md/, + }), + ) expect(screen.queryByText('build_note.md')).not.toBeInTheDocument() expect(mocks.deleteFileMutationFn).not.toHaveBeenCalled() @@ -542,6 +590,8 @@ describe('AgentFiles', () => { expect(screen.getByText('diagram.png')).toBeInTheDocument() expect(screen.getByText('brief.md')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }), + ).not.toBeInTheDocument() }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/file-icon.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/file-icon.ts index 296a887d2a2580..8d17e725d98b73 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/file-icon.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/file-icon.ts @@ -19,7 +19,18 @@ const codeFileExtensions = new Set([ ]) const tableFileExtensions = new Set(['csv', 'xls', 'xlsx']) const archiveFileExtensions = new Set(['7z', 'gz', 'rar', 'tar', 'zip']) -const imageFileExtensions = new Set(['apng', 'avif', 'bmp', 'gif', 'ico', 'jpeg', 'jpg', 'png', 'svg', 'webp']) +const imageFileExtensions = new Set([ + 'apng', + 'avif', + 'bmp', + 'gif', + 'ico', + 'jpeg', + 'jpg', + 'png', + 'svg', + 'webp', +]) const driveFileIconTypes = new Set<FileTreeIconType>([ 'archive', 'code', @@ -41,22 +52,14 @@ function getFileExtension(fileName: string) { export function getFileIconType(fileName: string, mimeType?: string | null): FileTreeIconType { const extension = getFileExtension(fileName) - if (mimeType?.startsWith('image/') || imageFileExtensions.has(extension)) - return 'image' - if (mimeType === 'application/pdf' || extension === 'pdf') - return 'pdf' - if (extension === 'md' || extension === 'markdown' || extension === 'mdx') - return 'markdown' - if (extension === 'json') - return 'json' - if (tableFileExtensions.has(extension)) - return 'table' - if (archiveFileExtensions.has(extension)) - return 'archive' - if (codeFileExtensions.has(extension)) - return 'code' - if (mimeType?.startsWith('text/')) - return 'text' + if (mimeType?.startsWith('image/') || imageFileExtensions.has(extension)) return 'image' + if (mimeType === 'application/pdf' || extension === 'pdf') return 'pdf' + if (extension === 'md' || extension === 'markdown' || extension === 'mdx') return 'markdown' + if (extension === 'json') return 'json' + if (tableFileExtensions.has(extension)) return 'table' + if (archiveFileExtensions.has(extension)) return 'archive' + if (codeFileExtensions.has(extension)) return 'code' + if (mimeType?.startsWith('text/')) return 'text' return 'file' } @@ -72,8 +75,7 @@ export function getDriveFileIconType({ }): FileTreeIconType { const normalizedFileKind = fileKind?.toLowerCase() - if (normalizedFileKind === 'directory') - return 'folder' + if (normalizedFileKind === 'directory') return 'folder' if (normalizedFileKind && driveFileIconTypes.has(normalizedFileKind as FileTreeIconType)) return normalizedFileKind as FileTreeIconType diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx index 64004b75372b0a..4205da11d2dfed 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx @@ -4,10 +4,7 @@ import type { MouseEvent, ReactNode } from 'react' import type { AgentOrchestrateAddActionOptions } from '../add-actions-context' import type { AgentConfigApiContext } from '../config-context' import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state' -import { - Dialog, - DialogTrigger, -} from '@langgenius/dify-ui/dialog' +import { Dialog, DialogTrigger } from '@langgenius/dify-ui/dialog' import { FileTreeBadge, FileTreeGuide, @@ -47,8 +44,7 @@ const BUILD_NOTE_FILE_NAME = 'build_note.md' const getAgentFilePreviewKey = (file: AgentFileNode) => file.configName ?? file.name const getBuildNoteFile = (configNote: string | undefined): AgentFileNode | undefined => { - if (!configNote?.trim()) - return undefined + if (!configNote?.trim()) return undefined return { id: BUILD_NOTE_FILE_ID, @@ -60,12 +56,10 @@ const getBuildNoteFile = (configNote: string | undefined): AgentFileNode | undef const findAgentFileNode = (files: AgentFileNode[], fileId: string): AgentFileNode | undefined => { for (const file of files) { - if (file.id === fileId) - return file + if (file.id === fileId) return file const child = file.children ? findAgentFileNode(file.children, fileId) : undefined - if (child) - return child + if (child) return child } } @@ -95,7 +89,9 @@ function AgentFileItem({ const selectedPreviewFile = selectedFile ?? file const isVirtualPreviewFile = selectedPreviewFile.virtualContent !== undefined const isBuildNoteFile = file.id === BUILD_NOTE_FILE_ID - const previewFileId = isVirtualPreviewFile ? undefined : getAgentFilePreviewKey(selectedPreviewFile) + const previewFileId = isVirtualPreviewFile + ? undefined + : getAgentFilePreviewKey(selectedPreviewFile) const agentPreviewQuery = useQuery({ ...consoleQuery.agent.byAgentId.config.files.byName.preview.get.queryOptions({ input: { @@ -129,7 +125,11 @@ function AgentFileItem({ }) const previewQuery = apiContext.workflow ? workflowPreviewQuery : agentPreviewQuery const isImagePreviewFile = selectedPreviewFile.icon === 'image' - const shouldDownloadPreviewFile = isPreviewOpen && !!previewFileId && !isVirtualPreviewFile && (isImagePreviewFile || !!previewQuery.data?.binary) + const shouldDownloadPreviewFile = + isPreviewOpen && + !!previewFileId && + !isVirtualPreviewFile && + (isImagePreviewFile || !!previewQuery.data?.binary) const agentDownloadQuery = useQuery({ ...consoleQuery.agent.byAgentId.config.files.byName.download.get.queryOptions({ input: { @@ -165,57 +165,69 @@ function AgentFileItem({ const handleRemove = useCallback(() => { onRemove(file.id) }, [file.id, onRemove]) - const downloadFile = useCallback(async (targetFile: AgentFileNode) => { - if (targetFile.virtualContent !== undefined) { - downloadBlob({ - data: new Blob([targetFile.virtualContent], { type: 'text/markdown;charset=utf-8' }), - fileName: targetFile.name, - }) - return - } + const downloadFile = useCallback( + async (targetFile: AgentFileNode) => { + if (targetFile.virtualContent !== undefined) { + downloadBlob({ + data: new Blob([targetFile.virtualContent], { type: 'text/markdown;charset=utf-8' }), + fileName: targetFile.name, + }) + return + } - const fileName = getAgentFilePreviewKey(targetFile) - if (apiContext.workflow) { - const result = await queryClient.fetchQuery(consoleQuery.apps.byAppId.agent.config.files.byName.download.get.queryOptions({ - input: { - params: { - app_id: apiContext.workflow.appId, - name: fileName, - }, - query: { - node_id: apiContext.workflow.nodeId, - draft_type: apiContext.draftType, - version_id: apiContext.versionId, + const fileName = getAgentFilePreviewKey(targetFile) + if (apiContext.workflow) { + const result = await queryClient.fetchQuery( + consoleQuery.apps.byAppId.agent.config.files.byName.download.get.queryOptions({ + input: { + params: { + app_id: apiContext.workflow.appId, + name: fileName, + }, + query: { + node_id: apiContext.workflow.nodeId, + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + }, + }), + ) + downloadUrl({ url: result.url, fileName: targetFile.name }) + return + } + + const result = await queryClient.fetchQuery( + consoleQuery.agent.byAgentId.config.files.byName.download.get.queryOptions({ + input: { + params: { + agent_id: apiContext.agentId, + name: fileName, + }, + query: { + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, }, - }, - })) + }), + ) downloadUrl({ url: result.url, fileName: targetFile.name }) - return - } - - const result = await queryClient.fetchQuery(consoleQuery.agent.byAgentId.config.files.byName.download.get.queryOptions({ - input: { - params: { - agent_id: apiContext.agentId, - name: fileName, - }, - query: { - draft_type: apiContext.draftType, - version_id: apiContext.versionId, - }, - }, - })) - downloadUrl({ url: result.url, fileName: targetFile.name }) - }, [apiContext, queryClient]) - const handleDownload = useCallback(async (event: MouseEvent<HTMLButtonElement>) => { - event.stopPropagation() - await downloadFile(file) - }, [downloadFile, file]) - const handlePreviewOpenChange = useCallback((open: boolean) => { - if (open) - setSelectedFileId(file.id) - setIsPreviewOpen(open) - }, [file.id]) + }, + [apiContext, queryClient], + ) + const handleDownload = useCallback( + async (event: MouseEvent<HTMLButtonElement>) => { + event.stopPropagation() + await downloadFile(file) + }, + [downloadFile, file], + ) + const handlePreviewOpenChange = useCallback( + (open: boolean) => { + if (open) setSelectedFileId(file.id) + setIsPreviewOpen(open) + }, + [file.id], + ) const canRemoveFile = !readOnly && (!file.virtualContent || isBuildNoteFile) return ( @@ -225,25 +237,23 @@ function AgentFileItem({ > <Dialog open={isPreviewOpen} onOpenChange={handlePreviewOpenChange}> <DialogTrigger - render={( + render={ <button type="button" aria-current={selected ? 'true' : undefined} className="group/file-tree-row relative flex h-full min-w-0 flex-1 cursor-pointer items-center rounded-md pl-2 text-left outline-hidden select-none focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid" /> - )} + } > {Array.from({ length: Math.max(depth - 1, 0) }, (_, index) => ( <FileTreeGuide key={index} /> ))} - <div className="flex min-w-0 flex-1 items-center overflow-hidden py-0.5"> - {children} - </div> + <div className="flex min-w-0 flex-1 items-center overflow-hidden py-0.5">{children}</div> </DialogTrigger> <AgentSkillDetailDialog skillName={file.name} detail={{ - description: t($ => $['agentDetail.configure.files.tip']), + description: t(($) => $['agentDetail.configure.files.tip']), files, filePreview: { binary: previewQuery.data?.binary, @@ -257,7 +267,7 @@ function AgentFileItem({ isLoading: !isVirtualPreviewFile && previewQuery.isPending, }, onDownloadFile: () => downloadFile(selectedPreviewFile), - onSelectFile: selectedFile => setSelectedFileId(selectedFile.id), + onSelectFile: (selectedFile) => setSelectedFileId(selectedFile.id), selectedFileId: selectedFileId ?? file.id, sections: [], }} @@ -266,7 +276,7 @@ function AgentFileItem({ <div className="pointer-events-none absolute top-1/2 right-1 z-10 flex -translate-y-1/2 items-center justify-end gap-1 opacity-0 group-focus-within/file-row:pointer-events-auto group-focus-within/file-row:opacity-100 group-hover/file-row:pointer-events-auto group-hover/file-row:opacity-100"> <button type="button" - aria-label={t($ => $['agentDetail.configure.files.download'], { name: file.name })} + aria-label={t(($) => $['agentDetail.configure.files.download'], { name: file.name })} onClick={handleDownload} className="flex size-5 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > @@ -276,7 +286,7 @@ function AgentFileItem({ <button type="button" data-agent-file-remove-button - aria-label={t($ => $['agentDetail.configure.files.remove'], { name: file.name })} + aria-label={t(($) => $['agentDetail.configure.files.remove'], { name: file.name })} onClick={handleRemove} className="flex size-5 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:bg-state-destructive-hover focus-visible:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > @@ -309,7 +319,7 @@ function AgentBuildNoteBadge() { return ( <FileTreeBadge className="ms-0 gap-0.5 px-1 py-0.5"> <span aria-hidden className="i-ri-sparkling-line size-3 shrink-0" /> - <span>{t($ => $['agentDetail.configure.files.buildNote.generated'])}</span> + <span>{t(($) => $['agentDetail.configure.files.buildNote.generated'])}</span> </FileTreeBadge> ) } @@ -320,14 +330,14 @@ function AgentBuildNoteInfotip() { return ( <Infotip - aria-label={t($ => $['agentDetail.configure.files.buildNote.tooltip'])} + aria-label={t(($) => $['agentDetail.configure.files.buildNote.tooltip'])} className="size-5 text-text-quaternary hover:text-text-quaternary" iconSize="large" popupClassName="w-[230px] rounded-xl bg-components-tooltip-bg px-4 py-3.5 text-text-secondary shadow-lg backdrop-blur-[5px]" > <p className="body-xs-regular text-text-secondary"> <Trans - i18nKey={$ => $['agentDetail.configure.files.buildNote.richTooltip']} + i18nKey={($) => $['agentDetail.configure.files.buildNote.richTooltip']} ns="agentV2" components={{ docLink: <DocsLink href={docLink('/use-dify/build/new-agent/build#the-build-note')} />, @@ -340,7 +350,7 @@ function AgentBuildNoteInfotip() { export function AgentFiles() { const { t } = useTranslation('agentV2') - const filesTip = t($ => $['agentDetail.configure.files.tip']) + const filesTip = t(($) => $['agentDetail.configure.files.tip']) const filesTreeId = 'agent-configure-files-tree' const [isUploadOpen, setIsUploadOpen] = useState(false) const promptAddCallbackRef = useRef<AgentOrchestrateAddActionOptions['onAdded']>(undefined) @@ -352,114 +362,133 @@ export function AgentFiles() { const upsertAgentFile = useSetAtom(upsertAgentFileAtom) const buildNoteFile = getBuildNoteFile(draft.configNote) const visibleFiles = buildNoteFile ? [buildNoteFile, ...files] : files - const { mutate: deleteAgentFile } = useMutation(consoleQuery.agent.byAgentId.config.files.byName.delete.mutationOptions()) - const { mutate: deleteWorkflowAgentFile } = useMutation(consoleQuery.apps.byAppId.agent.config.files.byName.delete.mutationOptions()) - const removeFile = useCallback((fileId: string) => { - if (fileId === BUILD_NOTE_FILE_ID) { - clearAgentConfigNote() - return - } + const { mutate: deleteAgentFile } = useMutation( + consoleQuery.agent.byAgentId.config.files.byName.delete.mutationOptions(), + ) + const { mutate: deleteWorkflowAgentFile } = useMutation( + consoleQuery.apps.byAppId.agent.config.files.byName.delete.mutationOptions(), + ) + const removeFile = useCallback( + (fileId: string) => { + if (fileId === BUILD_NOTE_FILE_ID) { + clearAgentConfigNote() + return + } - const file = findAgentFileNode(files, fileId) - const configName = file?.configName ?? file?.name + const file = findAgentFileNode(files, fileId) + const configName = file?.configName ?? file?.name - if (!configName) - return + if (!configName) return - const onSuccess = () => { - removeAgentFile(fileId) - } - if (apiContext.workflow) { - deleteWorkflowAgentFile({ - params: { - app_id: apiContext.workflow.appId, - name: configName, - }, - query: { - node_id: apiContext.workflow.nodeId, - draft_type: apiContext.draftType, - version_id: apiContext.versionId, - }, - }, { onSuccess }) - return - } + const onSuccess = () => { + removeAgentFile(fileId) + } + if (apiContext.workflow) { + deleteWorkflowAgentFile( + { + params: { + app_id: apiContext.workflow.appId, + name: configName, + }, + query: { + node_id: apiContext.workflow.nodeId, + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + }, + { onSuccess }, + ) + return + } - deleteAgentFile({ - params: { - agent_id: apiContext.agentId, - name: configName, - }, - query: { - draft_type: apiContext.draftType, - version_id: apiContext.versionId, - }, - }, { onSuccess }) - }, [apiContext, clearAgentConfigNote, deleteAgentFile, deleteWorkflowAgentFile, files, removeAgentFile]) + deleteAgentFile( + { + params: { + agent_id: apiContext.agentId, + name: configName, + }, + query: { + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + }, + { onSuccess }, + ) + }, + [ + apiContext, + clearAgentConfigNote, + deleteAgentFile, + deleteWorkflowAgentFile, + files, + removeAgentFile, + ], + ) const handleOpenUpload = useCallback((options?: AgentOrchestrateAddActionOptions) => { promptAddCallbackRef.current = options?.onAdded setIsUploadOpen(true) }, []) useRegisterAgentOrchestrateAddAction('files', handleOpenUpload) - const handleUploaded = useCallback((file: AgentFileNode) => { - upsertAgentFile(file) - promptAddCallbackRef.current?.(file) - promptAddCallbackRef.current = undefined - }, [upsertAgentFile]) - const handleUploadOpenChange = useCallback((open: boolean) => { - if (!open) + const handleUploaded = useCallback( + (file: AgentFileNode) => { + upsertAgentFile(file) + promptAddCallbackRef.current?.(file) promptAddCallbackRef.current = undefined + }, + [upsertAgentFile], + ) + const handleUploadOpenChange = useCallback((open: boolean) => { + if (!open) promptAddCallbackRef.current = undefined setIsUploadOpen(open) }, []) return ( <> <ConfigureSection - label={t($ => $['agentDetail.configure.files.label'])} + label={t(($) => $['agentDetail.configure.files.label'])} labelId="agent-configure-files-label" buildDraftChangeSection="files" tip={<AgentConfigureTipContent type="files" />} tipAriaLabel={filesTip} rootClassName="border-b border-divider-subtle pt-4" panelContentClassName="pb-4" - actions={( + actions={ <ConfigureSectionAddButton - ariaLabel={t($ => $['agentDetail.configure.files.add'])} + ariaLabel={t(($) => $['agentDetail.configure.files.add'])} onClick={() => handleOpenUpload()} /> - )} + } > - {visibleFiles.length === 0 - ? ( - <ConfigureSectionEmpty - title={t($ => $['agentDetail.configure.files.empty.title'])} - description={t($ => $['agentDetail.configure.files.empty.description'])} - /> - ) - : ( - <AgentFileTree - id={filesTreeId} - files={visibleFiles} - treeLabel={t($ => $['agentDetail.configure.files.treeLabel'])} - className="rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1 shadow-xs shadow-shadow-shadow-3" - scrollAreaClassName="max-h-[250px] flex-none" - renderFile={({ depth, file, selected, children }) => { - const isBuildNoteFile = file.id === BUILD_NOTE_FILE_ID + {visibleFiles.length === 0 ? ( + <ConfigureSectionEmpty + title={t(($) => $['agentDetail.configure.files.empty.title'])} + description={t(($) => $['agentDetail.configure.files.empty.description'])} + /> + ) : ( + <AgentFileTree + id={filesTreeId} + files={visibleFiles} + treeLabel={t(($) => $['agentDetail.configure.files.treeLabel'])} + className="rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1 shadow-xs shadow-shadow-shadow-3" + scrollAreaClassName="max-h-[250px] flex-none" + renderFile={({ depth, file, selected, children }) => { + const isBuildNoteFile = file.id === BUILD_NOTE_FILE_ID - return ( - <AgentFileItem - depth={depth} - file={file} - files={visibleFiles} - apiContext={apiContext} - selected={selected} - onRemove={removeFile} - > - {isBuildNoteFile ? <AgentBuildNoteFileRow /> : children} - </AgentFileItem> - ) - }} - /> - )} + return ( + <AgentFileItem + depth={depth} + file={file} + files={visibleFiles} + apiContext={apiContext} + selected={selected} + onRemove={removeFile} + > + {isBuildNoteFile ? <AgentBuildNoteFileRow /> : children} + </AgentFileItem> + ) + }} + /> + )} </ConfigureSection> <AgentFileUploadDialog apiContext={apiContext} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/tree.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/tree.tsx index 453c4d1976c283..cfd6aa79c05e8d 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/tree.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/tree.tsx @@ -16,10 +16,7 @@ import { import { ScrollArea } from '@langgenius/dify-ui/scroll-area' import { Fragment } from 'react' -type AgentFileTreeFolderOpenStrategy = (context: { - file: AgentFileNode - depth: number -}) => boolean +type AgentFileTreeFolderOpenStrategy = (context: { file: AgentFileNode; depth: number }) => boolean type AgentFileTreeRenderFile = (context: { depth: number @@ -28,20 +25,14 @@ type AgentFileTreeRenderFile = (context: { children: ReactNode }) => ReactNode -type AgentFileTreeRenderFolderPanel = (context: { - depth: number - file: AgentFileNode -}) => ReactNode +type AgentFileTreeRenderFolderPanel = (context: { depth: number; file: AgentFileNode }) => ReactNode type AgentFileTreeRenderFolderSuffix = (context: { depth: number file: AgentFileNode }) => ReactNode -type AgentFileTreeFolderOpenState = (context: { - file: AgentFileNode - depth: number -}) => boolean +type AgentFileTreeFolderOpenState = (context: { file: AgentFileNode; depth: number }) => boolean const firstLevelFolderOpenStrategy: AgentFileTreeFolderOpenStrategy = ({ depth }) => depth === 1 @@ -63,8 +54,8 @@ function AgentFileTreeRows({ depth: number folderOpenStrategy: AgentFileTreeFolderOpenStrategy folderOpenState?: AgentFileTreeFolderOpenState - onFolderOpenChange?: (context: { file: AgentFileNode, depth: number, open: boolean }) => void - onFolderDoubleClick?: (context: { file: AgentFileNode, depth: number }) => void + onFolderOpenChange?: (context: { file: AgentFileNode; depth: number; open: boolean }) => void + onFolderDoubleClick?: (context: { file: AgentFileNode; depth: number }) => void onFolderOpen?: (file: AgentFileNode) => void renderFile: AgentFileTreeRenderFile renderFolderSuffix?: AgentFileTreeRenderFolderSuffix @@ -74,7 +65,9 @@ function AgentFileTreeRows({ const children = ( <> <FileTreeIcon type={file.icon} /> - <FileTreeLabel className="max-w-full" title={file.name}>{file.name}</FileTreeLabel> + <FileTreeLabel className="max-w-full" title={file.name}> + {file.name} + </FileTreeLabel> </> ) @@ -84,14 +77,16 @@ function AgentFileTreeRows({ key={file.id} defaultOpen={folderOpenStrategy({ file, depth })} open={folderOpenState?.({ file, depth })} - onOpenChange={open => onFolderOpenChange?.({ file, depth, open })} + onOpenChange={(open) => onFolderOpenChange?.({ file, depth, open })} > <FileTreeFolderTrigger onClick={() => onFolderOpen?.(file)} onDoubleClick={() => onFolderDoubleClick?.({ file, depth })} > <FileTreeIcon type="folder" /> - <FileTreeLabel className="max-w-full" title={file.name}>{file.name}</FileTreeLabel> + <FileTreeLabel className="max-w-full" title={file.name}> + {file.name} + </FileTreeLabel> {renderFolderSuffix?.({ depth, file })} </FileTreeFolderTrigger> <FileTreeFolderPanel> @@ -167,8 +162,8 @@ export function AgentFileTree({ listClassName?: string folderOpenStrategy?: AgentFileTreeFolderOpenStrategy folderOpenState?: AgentFileTreeFolderOpenState - onFolderOpenChange?: (context: { file: AgentFileNode, depth: number, open: boolean }) => void - onFolderDoubleClick?: (context: { file: AgentFileNode, depth: number }) => void + onFolderOpenChange?: (context: { file: AgentFileNode; depth: number; open: boolean }) => void + onFolderDoubleClick?: (context: { file: AgentFileNode; depth: number }) => void onFolderOpen?: (file: AgentFileNode) => void renderFile?: AgentFileTreeRenderFile renderFolderSuffix?: AgentFileTreeRenderFolderSuffix diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/upload-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/upload-dialog.tsx index 42b133d2adf93e..10e45b2d216032 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/upload-dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/upload-dialog.tsx @@ -1,13 +1,22 @@ 'use client' -import type { AgentConfigFileItemResponse, AgentConfigFileUploadResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentConfigFileItemResponse, + AgentConfigFileUploadResponse, +} from '@dify/contracts/api/console/agent/types.gen' import type { FileResponse } from '@dify/contracts/api/console/files/types.gen' import type { ChangeEvent, DragEvent } from 'react' import type { AgentConfigApiContext } from '../config-context' import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { FileTreeIcon } from '@langgenius/dify-ui/file-tree' import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' @@ -35,13 +44,7 @@ function hasDraggedFiles(event: DragEvent<HTMLDivElement>) { return Array.from(event.dataTransfer.types).includes('Files') } -function AgentFileUploader({ - file, - onChange, -}: { - file?: File - onChange: (file?: File) => void -}) { +function AgentFileUploader({ file, onChange }: { file?: File; onChange: (file?: File) => void }) { const { t } = useTranslation('agentV2') const fileInputRef = useRef<HTMLInputElement>(null) const dragDepthRef = useRef(0) @@ -50,7 +53,7 @@ function AgentFileUploader({ const setUploadFiles = (files: File[]) => { const [uploadFile] = files if (files.length !== 1 || !uploadFile) { - toast.error(t($ => $['agentDetail.configure.files.upload.invalidFile'])) + toast.error(t(($) => $['agentDetail.configure.files.upload.invalidFile'])) return } @@ -64,8 +67,7 @@ function AgentFileUploader({ } const handleDragEnter = (event: DragEvent<HTMLDivElement>) => { - if (!hasDraggedFiles(event)) - return + if (!hasDraggedFiles(event)) return event.preventDefault() event.stopPropagation() @@ -74,27 +76,23 @@ function AgentFileUploader({ } const handleDragOver = (event: DragEvent<HTMLDivElement>) => { - if (!hasDraggedFiles(event)) - return + if (!hasDraggedFiles(event)) return event.preventDefault() event.dataTransfer.dropEffect = 'copy' } const handleDragLeave = (event: DragEvent<HTMLDivElement>) => { - if (!hasDraggedFiles(event)) - return + if (!hasDraggedFiles(event)) return event.preventDefault() event.stopPropagation() dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) - if (dragDepthRef.current === 0) - setDragging(false) + if (dragDepthRef.current === 0) setDragging(false) } const handleDrop = (event: DragEvent<HTMLDivElement>) => { - if (!hasDraggedFiles(event)) - return + if (!hasDraggedFiles(event)) return event.preventDefault() event.stopPropagation() @@ -108,18 +106,13 @@ function AgentFileUploader({ <div className="mt-6" role="group" - aria-label={t($ => $['agentDetail.configure.files.upload.title'])} + aria-label={t(($) => $['agentDetail.configure.files.upload.title'])} onDragEnter={handleDragEnter} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} > - <input - ref={fileInputRef} - className="hidden" - type="file" - onChange={handleFileChange} - /> + <input ref={fileInputRef} className="hidden" type="file" onChange={handleFileChange} /> {!file && ( <div className={cn( @@ -130,13 +123,13 @@ function AgentFileUploader({ <div className="flex w-full items-center justify-center space-x-2"> <span aria-hidden className="i-ri-upload-cloud-2-line size-6 text-text-tertiary" /> <div className="text-text-tertiary"> - {t($ => $['agentDetail.configure.files.upload.dropzone'])} + {t(($) => $['agentDetail.configure.files.upload.dropzone'])} <button type="button" className="inline cursor-pointer border-none bg-transparent p-0 pl-1 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={() => fileInputRef.current?.click()} > - {t($ => $['agentDetail.configure.files.upload.browse'])} + {t(($) => $['agentDetail.configure.files.upload.browse'])} </button> </div> </div> @@ -149,9 +142,11 @@ function AgentFileUploader({ <FileTreeIcon type={getFileIconType(file.name, file.type)} /> </div> <div className="flex min-w-0 grow flex-col items-start gap-0.5 py-1 pr-2"> - <span className="max-w-full min-w-0 truncate text-[12px] leading-4 font-medium text-text-secondary">{file.name}</span> + <span className="max-w-full min-w-0 truncate text-[12px] leading-4 font-medium text-text-secondary"> + {file.name} + </span> <div className="flex h-3 items-center gap-1 self-stretch text-[10px] leading-3 font-medium text-text-tertiary uppercase"> - <span>{t($ => $['agentDetail.configure.files.upload.fileType'])}</span> + <span>{t(($) => $['agentDetail.configure.files.upload.fileType'])}</span> <span className="text-text-quaternary">·</span> <span>{formatFileSize(file.size)}</span> </div> @@ -182,73 +177,89 @@ export function AgentFileUploadDialog({ const { t: tCommon } = useTranslation('common') const [file, setFile] = useState<File>() const uploadFileMutation = useMutation(consoleQuery.files.upload.post.mutationOptions()) - const commitAgentFileMutation = useMutation(consoleQuery.agent.byAgentId.config.files.post.mutationOptions()) - const commitWorkflowAgentFileMutation = useMutation(consoleQuery.apps.byAppId.agent.config.files.post.mutationOptions()) - const isUploading = uploadFileMutation.isPending - || commitAgentFileMutation.isPending - || commitWorkflowAgentFileMutation.isPending + const commitAgentFileMutation = useMutation( + consoleQuery.agent.byAgentId.config.files.post.mutationOptions(), + ) + const commitWorkflowAgentFileMutation = useMutation( + consoleQuery.apps.byAppId.agent.config.files.post.mutationOptions(), + ) + const isUploading = + uploadFileMutation.isPending || + commitAgentFileMutation.isPending || + commitWorkflowAgentFileMutation.isPending - const commitUploadedFile = (uploadedFile: FileResponse, options: { - onSuccess: (committedFile: AgentConfigFileUploadResponse) => void - onError: () => void - }) => { + const commitUploadedFile = ( + uploadedFile: FileResponse, + options: { + onSuccess: (committedFile: AgentConfigFileUploadResponse) => void + onError: () => void + }, + ) => { const body = { upload_file_id: uploadedFile.id, } if (apiContext.workflow) { - commitWorkflowAgentFileMutation.mutate({ + commitWorkflowAgentFileMutation.mutate( + { + params: { + app_id: apiContext.workflow.appId, + }, + query: { + node_id: apiContext.workflow.nodeId, + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + body, + }, + options, + ) + return + } + + commitAgentFileMutation.mutate( + { params: { - app_id: apiContext.workflow.appId, + agent_id: apiContext.agentId, }, query: { - node_id: apiContext.workflow.nodeId, draft_type: apiContext.draftType, version_id: apiContext.versionId, }, body, - }, options) - return - } - - commitAgentFileMutation.mutate({ - params: { - agent_id: apiContext.agentId, - }, - query: { - draft_type: apiContext.draftType, - version_id: apiContext.versionId, }, - body, - }, options) + options, + ) } const handleUpload = () => { - if (!file || isUploading) - return + if (!file || isUploading) return - uploadFileMutation.mutate({ - body: { - file, - }, - }, { - onSuccess: (uploadedFile) => { - commitUploadedFile(uploadedFile, { - onSuccess: (committedFile) => { - toast.success(t($ => $['agentDetail.configure.files.upload.success'])) - onUploaded(toAgentFileNode(committedFile.file)) - setFile(undefined) - onOpenChange(false) - }, - onError: () => { - toast.error(t($ => $['agentDetail.configure.files.upload.failed'])) - }, - }) + uploadFileMutation.mutate( + { + body: { + file, + }, }, - onError: () => { - toast.error(t($ => $['agentDetail.configure.files.upload.failed'])) + { + onSuccess: (uploadedFile) => { + commitUploadedFile(uploadedFile, { + onSuccess: (committedFile) => { + toast.success(t(($) => $['agentDetail.configure.files.upload.success'])) + onUploaded(toAgentFileNode(committedFile.file)) + setFile(undefined) + onOpenChange(false) + }, + onError: () => { + toast.error(t(($) => $['agentDetail.configure.files.upload.failed'])) + }, + }) + }, + onError: () => { + toast.error(t(($) => $['agentDetail.configure.files.upload.failed'])) + }, }, - }) + ) } const handleOpenChange = (nextOpen: boolean) => { @@ -266,18 +277,15 @@ export function AgentFileUploadDialog({ <DialogContent backdropProps={{ forceRender: true }} backdropClassName="fixed"> <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['agentDetail.configure.files.upload.title'])} + {t(($) => $['agentDetail.configure.files.upload.title'])} </DialogTitle> <DialogDescription className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.files.upload.description'])} + {t(($) => $['agentDetail.configure.files.upload.description'])} </DialogDescription> - <AgentFileUploader - file={file} - onChange={setFile} - /> + <AgentFileUploader file={file} onChange={setFile} /> <div className="flex justify-end gap-2 pt-6"> <Button type="button" onClick={() => handleOpenChange(false)} disabled={isUploading}> - {tCommon($ => $['operation.cancel'])} + {tCommon(($) => $['operation.cancel'])} </Button> <Button type="button" @@ -286,7 +294,7 @@ export function AgentFileUploadDialog({ loading={isUploading} onClick={handleUpload} > - {t($ => $['agentDetail.configure.files.upload.action'])} + {t(($) => $['agentDetail.configure.files.upload.action'])} </Button> </div> </DialogContent> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/header.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/header.tsx index 5faf9d22d4f71a..0632b3c4434e51 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/header.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/header.tsx @@ -16,14 +16,16 @@ export function AgentOrchestrateHeader({ isBuildDraftActive = false, }: AgentOrchestrateHeaderProps) { const { t } = useTranslation('agentV2') - const communityEditionIsolationTip = t($ => $['agentDetail.configure.communityEditionIsolationTip']) + const communityEditionIsolationTip = t( + ($) => $['agentDetail.configure.communityEditionIsolationTip'], + ) return ( <div className="shrink-0 px-4 py-3"> <div className="flex h-6 min-w-0 items-center justify-between gap-2"> <div className="flex min-w-0 items-center gap-2"> <h2 id={headingId} className="truncate title-xl-semi-bold text-text-primary"> - {t($ => $['agentDetail.configure.title'])} + {t(($) => $['agentDetail.configure.title'])} </h2> <Popover> <PopoverTrigger @@ -31,14 +33,17 @@ export function AgentOrchestrateHeader({ delay={300} closeDelay={200} aria-label={communityEditionIsolationTip} - render={( + render={ <button type="button" className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" > - <span aria-hidden className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary" /> + <span + aria-hidden + className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary" + /> </button> - )} + } /> <PopoverContent placement="bottom" @@ -49,19 +54,15 @@ export function AgentOrchestrateHeader({ </Popover> {isBuildDraftActive && ( <span className="flex min-w-[18px] shrink-0 items-center justify-center rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-1.25 py-0.75 system-2xs-medium-uppercase text-text-accent-secondary"> - {t($ => $['agentDetail.configure.buildDraft.modeBadge'])} + {t(($) => $['agentDetail.configure.buildDraft.modeBadge'])} </span> )} </div> - {trailingAction != null && ( - <div className="shrink-0"> - {trailingAction} - </div> - )} + {trailingAction != null && <div className="shrink-0">{trailingAction}</div>} </div> {isBuildDraftActive && ( <p className="mt-1 w-full system-xs-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.buildDraft.modeDescription'])} + {t(($) => $['agentDetail.configure.buildDraft.modeDescription'])} </p> )} </div> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx index 2009f967806d5f..211c20acaa554a 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx @@ -1,6 +1,9 @@ 'use client' -import type { AgentConfigSnapshotDetailResponse, AgentConfigSnapshotSummaryResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentConfigSnapshotDetailResponse, + AgentConfigSnapshotSummaryResponse, +} from '@dify/contracts/api/console/agent/types.gen' import type { ReactNode } from 'react' import type { AgentBuildDraftChangedKey } from './build-draft-changes-context' import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -83,51 +86,63 @@ export function AgentOrchestratePanel({ }: AgentOrchestratePanelProps) { const { t } = useTranslation('agentV2') const orchestrateHeadingId = 'agent-configure-orchestrate-heading' - const orchestrateLabel = t($ => $['agentDetail.configure.title']) - const orchestrateBottomAction = bottomAction ?? (showPublishBar - ? ( - <AgentConfigurePublishBar - agentId={agentId} - activeConfigIsPublished={activeConfigIsPublished} - activeConfigSnapshot={activeConfigSnapshot} - agentName={agentName} - draftSavedAt={draftSavedAt} - isPublishing={isPublishing} - selectedVersionSnapshot={selectedVersionSnapshot} - workflowReferencesEnabled={workflowReferencesEnabled} - onPublish={onPublish} - onExitVersions={onExitVersions} - onOpenVersions={onOpenVersions} - /> - ) - : null) + const orchestrateLabel = t(($) => $['agentDetail.configure.title']) + const orchestrateBottomAction = + bottomAction ?? + (showPublishBar ? ( + <AgentConfigurePublishBar + agentId={agentId} + activeConfigIsPublished={activeConfigIsPublished} + activeConfigSnapshot={activeConfigSnapshot} + agentName={agentName} + draftSavedAt={draftSavedAt} + isPublishing={isPublishing} + selectedVersionSnapshot={selectedVersionSnapshot} + workflowReferencesEnabled={workflowReferencesEnabled} + onPublish={onPublish} + onExitVersions={onExitVersions} + onOpenVersions={onOpenVersions} + /> + ) : null) const hasBottomAction = !!orchestrateBottomAction const draftType = isBuildDraftActive ? ('debug_build' as const) : ('draft' as const) - const configApiContext = useMemo(() => appId && nodeId - ? { - agentId, - draftType, - versionId: selectedVersionSnapshot?.id ?? undefined, - workflow: { - appId, - nodeId, - }, - } - : { - agentId, - draftType, - versionId: selectedVersionSnapshot?.id ?? undefined, - }, [agentId, appId, draftType, nodeId, selectedVersionSnapshot?.id]) + const configApiContext = useMemo( + () => + appId && nodeId + ? { + agentId, + draftType, + versionId: selectedVersionSnapshot?.id ?? undefined, + workflow: { + appId, + nodeId, + }, + } + : { + agentId, + draftType, + versionId: selectedVersionSnapshot?.id ?? undefined, + }, + [agentId, appId, draftType, nodeId, selectedVersionSnapshot?.id], + ) return ( - <div className={cn('relative flex max-w-140 min-w-90 flex-[0_0_min(41.08280255%,560px)] flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg', className)}> - {showHeader && <AgentOrchestrateHeader headingId={orchestrateHeadingId} trailingAction={headerAction} isBuildDraftActive={isBuildDraftActive} />} + <div + className={cn( + 'relative flex max-w-140 min-w-90 flex-[0_0_min(41.08280255%,560px)] flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg', + className, + )} + > + {showHeader && ( + <AgentOrchestrateHeader + headingId={orchestrateHeadingId} + trailingAction={headerAction} + isBuildDraftActive={isBuildDraftActive} + /> + )} <AgentOrchestrateReadOnlyContext value={readOnly}> - <div - aria-readonly={readOnly} - className="flex min-h-0 flex-1 flex-col" - > + <div aria-readonly={readOnly} className="flex min-h-0 flex-1 flex-col"> <ScrollArea className="min-h-0 flex-1 overflow-hidden" label={showHeader ? undefined : orchestrateLabel} @@ -139,7 +154,11 @@ export function AgentOrchestratePanel({ > <AgentConfigApiContextProvider value={configApiContext}> <AgentOrchestrateAddActionsProvider> - <AgentBuildDraftChangedKeysProvider changedKeys={isBuildDraftActive ? buildDraftChangedKeys : EMPTY_BUILD_DRAFT_CHANGED_KEYS}> + <AgentBuildDraftChangedKeysProvider + changedKeys={ + isBuildDraftActive ? buildDraftChangedKeys : EMPTY_BUILD_DRAFT_CHANGED_KEYS + } + > <AgentModelField currentModel={currentModel} textGenerationModelList={textGenerationModelList} @@ -158,13 +177,11 @@ export function AgentOrchestratePanel({ </div> </AgentOrchestrateReadOnlyContext> - {orchestrateBottomAction - ? ( - <AgentOrchestrateBottomActions shrinkOnOpen={!bottomAction}> - {orchestrateBottomAction} - </AgentOrchestrateBottomActions> - ) - : null} + {orchestrateBottomAction ? ( + <AgentOrchestrateBottomActions shrinkOnOpen={!bottomAction}> + {orchestrateBottomAction} + </AgentOrchestrateBottomActions> + ) : null} </div> ) } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/__tests__/index.spec.tsx index 7a65452fb567f5..e0e17aa0349392 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/__tests__/index.spec.tsx @@ -17,35 +17,41 @@ vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/add-data default: function MockAddKnowledge({ onChange, }: { - onChange: (datasets: Array<{ - id: string - name: string - indexing_technique: string - provider: string - embedding_model_provider: string - embedding_model: string - retrieval_model_dict: { - search_method: string - } - is_multimodal: boolean - }>) => void + onChange: ( + datasets: Array<{ + id: string + name: string + indexing_technique: string + provider: string + embedding_model_provider: string + embedding_model: string + retrieval_model_dict: { + search_method: string + } + is_multimodal: boolean + }>, + ) => void }) { return ( <button type="button" aria-label="common.operation.add workflow.nodes.knowledgeRetrieval.knowledge" - onClick={() => onChange([{ - id: 'dataset-2', - name: 'Release Docs', - indexing_technique: 'high_quality', - provider: 'internal', - embedding_model_provider: 'openai', - embedding_model: 'text-embedding-3', - retrieval_model_dict: { - search_method: 'semantic', - }, - is_multimodal: false, - }])} + onClick={() => + onChange([ + { + id: 'dataset-2', + name: 'Release Docs', + indexing_technique: 'high_quality', + provider: 'internal', + embedding_model_provider: 'openai', + embedding_model: 'text-embedding-3', + retrieval_model_dict: { + search_method: 'semantic', + }, + is_multimodal: false, + }, + ]) + } > Add mock knowledge </button> @@ -55,10 +61,12 @@ vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/add-data vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(() => ({ - modelList: [{ - provider: 'rerank-provider', - models: [{ model: 'rerank-model' }], - }], + modelList: [ + { + provider: 'rerank-provider', + models: [{ model: 'rerank-model' }], + }, + ], defaultModel: { provider: { provider: 'rerank-provider', @@ -86,11 +94,7 @@ function ConfigSnapshotPreview() { const draft = useAtomValue(agentComposerDraftAtom) const configSnapshot = formStateToAgentSoulConfig({ formState: draft }) - return ( - <output aria-label="config snapshot"> - {JSON.stringify(configSnapshot.knowledge)} - </output> - ) + return <output aria-label="config snapshot">{JSON.stringify(configSnapshot.knowledge)}</output> } function renderKnowledgeRetrieval({ @@ -131,21 +135,35 @@ describe('AgentKnowledgeRetrieval', () => { it('should render configured retrieval rows', () => { renderKnowledgeRetrieval() - expect(screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne')).toBeInTheDocument() - expect(screen.queryByText('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo')).not.toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne'), + ).toBeInTheDocument() + expect( + screen.queryByText('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo'), + ).not.toBeInTheDocument() }) it('should hide add, edit, and remove actions when readonly', () => { renderKnowledgeRetrieval({ readOnly: true }) - expect(screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', - })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.remove:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', - })).not.toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne'), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add', + }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', + }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.remove:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', + }), + ).not.toBeInTheDocument() }) }) @@ -154,47 +172,71 @@ describe('AgentKnowledgeRetrieval', () => { const user = userEvent.setup() renderKnowledgeRetrieval() - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add', + }), + ) const dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', }) const titleButton = getDialogNameEditButton(dialog) expect(titleButton).toBeInTheDocument() - expect(titleButton).toHaveTextContent('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo') - expect(within(dialog).queryByRole('textbox', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel', - })).not.toBeInTheDocument() + expect(titleButton).toHaveTextContent( + 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo', + ) + expect( + within(dialog).queryByRole('textbox', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel', + }), + ).not.toBeInTheDocument() await user.click(titleButton) - expect(within(dialog).getByRole('textbox', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel', - })).toHaveValue('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo') - expect(within(dialog).queryByText('appDebug.datasetConfig.knowledgeTip')).not.toBeInTheDocument() - expect(within(dialog).getByRole('button', { - name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge', - })).toBeInTheDocument() - expect(within(dialog).getByRole('button', { - name: 'workflow.nodes.knowledgeRetrieval.metadata.options.disabled.title', - })).toBeInTheDocument() - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo"}', - })).not.toBeInTheDocument() + expect( + within(dialog).getByRole('textbox', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel', + }), + ).toHaveValue('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo') + expect( + within(dialog).queryByText('appDebug.datasetConfig.knowledgeTip'), + ).not.toBeInTheDocument() + expect( + within(dialog).getByRole('button', { + name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge', + }), + ).toBeInTheDocument() + expect( + within(dialog).getByRole('button', { + name: 'workflow.nodes.knowledgeRetrieval.metadata.options.disabled.title', + }), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo"}', + }), + ).not.toBeInTheDocument() }) it('should show the custom query input when query mode changes', async () => { const user = userEvent.setup() renderKnowledgeRetrieval() - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add', + }), + ) const dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', }) - await user.click(within(dialog).getByRole('radio', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom', - })) + await user.click( + within(dialog).getByRole('radio', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom', + }), + ) const customQueryInput = within(dialog).getByRole('textbox', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel', @@ -202,42 +244,72 @@ describe('AgentKnowledgeRetrieval', () => { await user.type(customQueryInput, 'release notes') expect(customQueryInput).toHaveValue('release notes') - expect(within(dialog).getByPlaceholderText('agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customPlaceholder')).toBeInTheDocument() - expect(within(dialog).getByText('agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customDescription')).toBeInTheDocument() - expect(within(dialog).queryByText('agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.agentDescription')).not.toBeInTheDocument() + expect( + within(dialog).getByPlaceholderText( + 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customPlaceholder', + ), + ).toBeInTheDocument() + expect( + within(dialog).getByText( + 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customDescription', + ), + ).toBeInTheDocument() + expect( + within(dialog).queryByText( + 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.agentDescription', + ), + ).not.toBeInTheDocument() }) it('should not create a new retrieval until knowledge is selected', async () => { const user = userEvent.setup() renderKnowledgeRetrieval() - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add', + }), + ) await user.click(screen.getByRole('button', { name: 'Close' })) - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo"}', - })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo"}', + }), + ).not.toBeInTheDocument() }) it('should show inline validation for blank custom queries after knowledge is selected', async () => { const user = userEvent.setup() renderKnowledgeRetrieval() - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add', + }), + ) const dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', }) - await user.click(within(dialog).getByRole('button', { - name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge', - })) + await user.click( + within(dialog).getByRole('button', { + name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge', + }), + ) - await user.click(within(dialog).getByRole('radio', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom', - })) + await user.click( + within(dialog).getByRole('radio', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom', + }), + ) - expect(within(dialog).getByText('common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel"}')).toBeInTheDocument() + expect( + within(dialog).getByText( + 'common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel"}', + ), + ).toBeInTheDocument() }) it('should not show inline validation for automatic metadata filtering without a model', async () => { @@ -256,15 +328,21 @@ describe('AgentKnowledgeRetrieval', () => { }, }) - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"Docs Search"}', - })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"Docs Search"}', + }), + ) const dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', }) - expect(within(dialog).queryByText('agentV2.agentDetail.configure.knowledgeRetrieval.validation.metadataModelRequired')).not.toBeInTheDocument() + expect( + within(dialog).queryByText( + 'agentV2.agentDetail.configure.knowledgeRetrieval.validation.metadataModelRequired', + ), + ).not.toBeInTheDocument() }) it('should show duplicate-name validation in the dialog', async () => { @@ -287,9 +365,11 @@ describe('AgentKnowledgeRetrieval', () => { }, }) - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"FAQ Search"}', - })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"FAQ Search"}', + }), + ) const dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', @@ -303,82 +383,107 @@ describe('AgentKnowledgeRetrieval', () => { await user.type(nameInput, 'Docs Search') fireEvent.blur(nameInput) - expect(within(dialog).getByText('appDebug.varKeyError.keyAlreadyExists:{"key":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel"}')).toBeInTheDocument() + expect( + within(dialog).getByText( + 'appDebug.varKeyError.keyAlreadyExists:{"key":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel"}', + ), + ).toBeInTheDocument() }) it('should save newly added retrieval data into the config snapshot', async () => { const user = userEvent.setup() renderKnowledgeRetrieval({ showConfigSnapshot: true }) - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add', + }), + ) const dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', }) - expect(getDialogNameEditButton(dialog)).toHaveTextContent('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo') + expect(getDialogNameEditButton(dialog)).toHaveTextContent( + 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo', + ) - await user.click(within(dialog).getByRole('radio', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom', - })) - await user.type(within(dialog).getByRole('textbox', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel', - }), 'new release notes') - await user.click(within(dialog).getByRole('button', { - name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge', - })) - - const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}') - expect(knowledgeConfig.sets).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'retrieval-1', - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne', - datasets: [], - query: { - mode: 'generated_query', - }, + await user.click( + within(dialog).getByRole('radio', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom', }), - expect.objectContaining({ - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo', - datasets: [ - expect.objectContaining({ - id: 'dataset-2', - name: 'Release Docs', - }), - ], - query: { - mode: 'user_query', - value: 'new release notes', - }, - retrieval: expect.objectContaining({ - mode: 'multiple', - reranking_enable: true, - reranking_mode: RerankingModeEnum.RerankingModel, - reranking_model: { - provider: 'rerank-provider', - model: 'rerank-model', + ) + await user.type( + within(dialog).getByRole('textbox', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel', + }), + 'new release notes', + ) + await user.click( + within(dialog).getByRole('button', { + name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge', + }), + ) + + const knowledgeConfig = JSON.parse( + screen.getByLabelText('config snapshot').textContent ?? '{}', + ) + expect(knowledgeConfig.sets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'retrieval-1', + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne', + datasets: [], + query: { + mode: 'generated_query', }, - top_k: 4, }), - }), - ])) + expect.objectContaining({ + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo', + datasets: [ + expect.objectContaining({ + id: 'dataset-2', + name: 'Release Docs', + }), + ], + query: { + mode: 'user_query', + value: 'new release notes', + }, + retrieval: expect.objectContaining({ + mode: 'multiple', + reranking_enable: true, + reranking_mode: RerankingModeEnum.RerankingModel, + reranking_model: { + provider: 'rerank-provider', + model: 'rerank-model', + }, + top_k: 4, + }), + }), + ]), + ) }) it('should open the knowledge retrieval dialog from the edit button', async () => { const user = userEvent.setup() renderKnowledgeRetrieval() - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', - })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', + }), + ) const dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', }) await user.click(getDialogNameEditButton(dialog)) - expect(within(dialog).getByRole('textbox', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel', - })).toHaveValue('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne') + expect( + within(dialog).getByRole('textbox', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel', + }), + ).toHaveValue('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne') }) it('should show hydrated backend datasets in the edit dialog', async () => { @@ -402,16 +507,20 @@ describe('AgentKnowledgeRetrieval', () => { }, }) - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"Search Docs"}', - })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"Search Docs"}', + }), + ) const dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', }) expect(within(dialog).getByText('Product Docs')).toBeInTheDocument() - expect(within(dialog).queryByText('appDebug.datasetConfig.knowledgeTip')).not.toBeInTheDocument() + expect( + within(dialog).queryByText('appDebug.datasetConfig.knowledgeTip'), + ).not.toBeInTheDocument() }) it('should preserve retrieval settings when renaming a retrieval', async () => { @@ -441,9 +550,11 @@ describe('AgentKnowledgeRetrieval', () => { }, }) - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"Search Docs"}', - })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"Search Docs"}', + }), + ) const dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', @@ -458,7 +569,9 @@ describe('AgentKnowledgeRetrieval', () => { const titleButton = getDialogNameEditButton(dialog) expect(titleButton).toHaveTextContent('Renamed Docs') expect(titleButton).toHaveFocus() - const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}') + const knowledgeConfig = JSON.parse( + screen.getByLabelText('config snapshot').textContent ?? '{}', + ) expect(knowledgeConfig.sets[0]).toMatchObject({ name: 'Renamed Docs', retrieval: { @@ -474,9 +587,11 @@ describe('AgentKnowledgeRetrieval', () => { const user = userEvent.setup() renderKnowledgeRetrieval({ showConfigSnapshot: true }) - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', - })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', + }), + ) const dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', }) @@ -490,19 +605,27 @@ describe('AgentKnowledgeRetrieval', () => { expect(dialog).toBeInTheDocument() const titleButton = getDialogNameEditButton(dialog) - expect(titleButton).toHaveTextContent('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne') + expect(titleButton).toHaveTextContent( + 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne', + ) expect(titleButton).toHaveFocus() - const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}') - expect(knowledgeConfig.sets[0].name).toBe('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne') + const knowledgeConfig = JSON.parse( + screen.getByLabelText('config snapshot').textContent ?? '{}', + ) + expect(knowledgeConfig.sets[0].name).toBe( + 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne', + ) }) it('should keep editing when Enter confirms an IME composition', async () => { const user = userEvent.setup() renderKnowledgeRetrieval() - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', - })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', + }), + ) const dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', }) @@ -521,9 +644,11 @@ describe('AgentKnowledgeRetrieval', () => { const user = userEvent.setup() renderKnowledgeRetrieval({ showConfigSnapshot: true }) - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', - })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', + }), + ) const dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', }) @@ -534,14 +659,21 @@ describe('AgentKnowledgeRetrieval', () => { }) await user.clear(nameInput) await user.type(nameInput, 'Release Search') - await user.click(within(dialog).getByRole('radio', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom', - })) - await user.type(within(dialog).getByRole('textbox', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel', - }), 'release notes') + await user.click( + within(dialog).getByRole('radio', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom', + }), + ) + await user.type( + within(dialog).getByRole('textbox', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel', + }), + 'release notes', + ) - const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}') + const knowledgeConfig = JSON.parse( + screen.getByLabelText('config snapshot').textContent ?? '{}', + ) expect(knowledgeConfig).toMatchObject({ sets: [ { @@ -565,12 +697,18 @@ describe('AgentKnowledgeRetrieval', () => { const user = userEvent.setup() renderKnowledgeRetrieval() - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.remove:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', - })) - - expect(screen.queryByText('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne')).not.toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.empty.title')).toBeInTheDocument() + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.remove:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', + }), + ) + + expect( + screen.queryByText('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne'), + ).not.toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.empty.title'), + ).toBeInTheDocument() }) }) @@ -579,40 +717,63 @@ describe('AgentKnowledgeRetrieval', () => { const user = userEvent.setup() renderKnowledgeRetrieval() - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add', + }), + ) await user.click(screen.getByRole('button', { name: 'Close' })) - expect(screen.queryByRole('dialog', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', - })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', + }), + ).not.toBeInTheDocument() }) it('should reset an uncreated dialog draft after closing', async () => { const user = userEvent.setup() renderKnowledgeRetrieval() - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add', + }), + ) let dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', }) - await user.click(within(dialog).getByRole('radio', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom', - })) - await user.type(within(dialog).getByRole('textbox', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel', - }), 'temporary query') + await user.click( + within(dialog).getByRole('radio', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom', + }), + ) + await user.type( + within(dialog).getByRole('textbox', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel', + }), + 'temporary query', + ) await user.click(within(dialog).getByRole('button', { name: 'Close' })) - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add', + }), + ) dialog = screen.getByRole('dialog', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', }) - expect(within(dialog).getByRole('radio', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.agent', - })).toBeChecked() - expect(within(dialog).queryByRole('textbox', { - name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel', - })).not.toBeInTheDocument() + expect( + within(dialog).getByRole('radio', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.agent', + }), + ).toBeChecked() + expect( + within(dialog).queryByRole('textbox', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel', + }), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx index c67838e3667479..2d94295f28bad1 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx @@ -22,7 +22,10 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { IndexingType } from '@/app/components/datasets/create/step-two/hooks/use-indexing-config' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { + useCurrentProviderAndModel, + useModelListAndDefaultModelAndCurrentProviderAndModel, +} from '@/app/components/header/account-setting/model-provider-page/hooks' import Field from '@/app/components/workflow/nodes/_base/components/field' import AddKnowledge from '@/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset' import DatasetList from '@/app/components/workflow/nodes/knowledge-retrieval/components/dataset-list' @@ -37,7 +40,10 @@ import { import { getMultipleRetrievalConfig } from '@/app/components/workflow/nodes/knowledge-retrieval/utils' import { DATASET_DEFAULT } from '@/config' import { useDocLink } from '@/context/i18n' -import { useKnowledgeValidationMessage, validateKnowledgeRetrievals } from '@/features/agent-v2/agent-composer/knowledge-validation' +import { + useKnowledgeValidationMessage, + validateKnowledgeRetrievals, +} from '@/features/agent-v2/agent-composer/knowledge-validation' import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { AppModeEnum, RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' @@ -56,7 +62,7 @@ type KnowledgeRetrievalDialogState = { multipleRetrievalConfig: MultipleRetrievalConfig name: string queryMode: KnowledgeRetrievalQueryMode - retrievalMode: typeof RETRIEVE_TYPE[keyof typeof RETRIEVE_TYPE] + retrievalMode: (typeof RETRIEVE_TYPE)[keyof typeof RETRIEVE_TYPE] selectedDatasets: DataSet[] singleRetrievalConfig?: SingleRetrievalConfig } @@ -87,15 +93,12 @@ function KnowledgeRetrievalDialogIcon() { ) } -function DialogFormLabel({ - children, - id, -}: { - children: ReactNode - id?: string -}) { +function DialogFormLabel({ children, id }: { children: ReactNode; id?: string }) { return ( - <div id={id} className="flex min-h-6 items-center system-sm-semibold-uppercase text-text-secondary"> + <div + id={id} + className="flex min-h-6 items-center system-sm-semibold-uppercase text-text-secondary" + > {children} </div> ) @@ -183,9 +186,8 @@ const createDatasetFromRef = (dataset: AgentKnowledgeDatasetConfig, index: numbe } } -const getSelectedDatasets = (item?: AgentKnowledgeRetrievalItem) => ( +const getSelectedDatasets = (item?: AgentKnowledgeRetrievalItem) => item?.selectedDatasets ?? item?.datasetRefs?.map(createDatasetFromRef) ?? [] -) const getDialogName = ( item: AgentKnowledgeRetrievalItem | undefined, @@ -200,7 +202,8 @@ const createDialogState = ( ): KnowledgeRetrievalDialogState => ({ customQuery: item?.customQuery ?? '', metadataFilterMode: item?.metadataFilterMode ?? WorkflowMetadataFilteringModeEnum.disabled, - metadataFilteringConditions: item?.metadataFilteringConditions ?? createDefaultMetadataFilteringConditions(), + metadataFilteringConditions: + item?.metadataFilteringConditions ?? createDefaultMetadataFilteringConditions(), metadataModelConfig: item?.metadataModelConfig, multipleRetrievalConfig: item?.multipleRetrievalConfig ?? createDefaultRetrievalConfig(), name: getDialogName(item, initialName, fallbackName), @@ -210,13 +213,18 @@ const createDialogState = ( singleRetrievalConfig: item?.singleRetrievalConfig, }) -const createMetadataCondition = ({ id, name, type }: MetadataInDoc): MetadataFilteringCondition => ({ +const createMetadataCondition = ({ + id, + name, + type, +}: MetadataInDoc): MetadataFilteringCondition => ({ id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`, metadata_id: id, name, - comparison_operator: type === MetadataFilteringVariableType.number - ? ComparisonOperator.equal - : ComparisonOperator.is, + comparison_operator: + type === MetadataFilteringVariableType.number + ? ComparisonOperator.equal + : ComparisonOperator.is, }) function EditableKnowledgeRetrievalName({ @@ -245,13 +253,11 @@ function EditableKnowledgeRetrievalName({ return } - if (!restoreButtonFocusRef.current) - return + if (!restoreButtonFocusRef.current) return restoreButtonFocusRef.current = false const button = buttonRef.current - if (!button) - return + if (!button) return const activeElement = button.ownerDocument.activeElement if (!activeElement || activeElement === button.ownerDocument.body) @@ -259,10 +265,8 @@ function EditableKnowledgeRetrievalName({ }, [editing]) const finishEditing = (commit: boolean) => { - if (commit) - onCommit(draftName) - else - setDraftName(name) + if (commit) onCommit(draftName) + else setDraftName(name) restoreButtonFocusRef.current = true setEditing(false) @@ -282,10 +286,9 @@ function EditableKnowledgeRetrievalName({ onCommit(draftName) setEditing(false) }} - onChange={event => setDraftName(event.currentTarget.value)} + onChange={(event) => setDraftName(event.currentTarget.value)} onKeyDown={(event) => { - if (event.nativeEvent.isComposing) - return + if (event.nativeEvent.isComposing) return if (event.key === 'Enter') { event.preventDefault() @@ -314,9 +317,7 @@ function EditableKnowledgeRetrievalName({ setEditing(true) }} > - <span className="min-w-0 truncate"> - {name} - </span> + <span className="min-w-0 truncate">{name}</span> </button> ) } @@ -331,8 +332,10 @@ function AgentKnowledgeRetrievalDialogContent({ const docLink = useDocLink() const retrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom) const getValidationMessage = useKnowledgeValidationMessage() - const fallbackName = t($ => $['agentDetail.configure.knowledgeRetrieval.retrievalOne']) - const [dialogState, setDialogState] = useState(() => createDialogState(item, initialName, fallbackName)) + const fallbackName = t(($) => $['agentDetail.configure.knowledgeRetrieval.retrievalOne']) + const [dialogState, setDialogState] = useState(() => + createDialogState(item, initialName, fallbackName), + ) const [rerankModelOpen, setRerankModelOpen] = useState(false) const queryModeLabelId = 'agent-knowledge-retrieval-query-mode-label' const { @@ -347,22 +350,18 @@ function AgentKnowledgeRetrievalDialogContent({ selectedDatasets, singleRetrievalConfig, } = dialogState - const { - modelList: rerankModelList, - defaultModel: rerankDefaultModel, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) - const { - currentModel: currentRerankModel, - currentProvider: currentRerankProvider, - } = useCurrentProviderAndModel( - rerankModelList, - rerankDefaultModel - ? { - ...rerankDefaultModel, - provider: rerankDefaultModel.provider.provider, - } - : undefined, - ) + const { modelList: rerankModelList, defaultModel: rerankDefaultModel } = + useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) + const { currentModel: currentRerankModel, currentProvider: currentRerankProvider } = + useCurrentProviderAndModel( + rerankModelList, + rerankDefaultModel + ? { + ...rerankDefaultModel, + provider: rerankDefaultModel.provider.provider, + } + : undefined, + ) const fallbackRerankModel = { provider: currentRerankProvider?.provider, model: currentRerankModel?.model, @@ -371,15 +370,12 @@ function AgentKnowledgeRetrievalDialogContent({ config: MultipleRetrievalConfig, nextSelectedDatasets = selectedDatasets, originalDatasets = selectedDatasets, - ) => getMultipleRetrievalConfig( - config, - nextSelectedDatasets, - originalDatasets, - fallbackRerankModel, - ) - const effectiveMultipleRetrievalConfig = retrievalMode === RETRIEVE_TYPE.multiWay && selectedDatasets.length > 0 - ? resolveMultipleRetrievalConfig(multipleRetrievalConfig) - : multipleRetrievalConfig + ) => + getMultipleRetrievalConfig(config, nextSelectedDatasets, originalDatasets, fallbackRerankModel) + const effectiveMultipleRetrievalConfig = + retrievalMode === RETRIEVE_TYPE.multiWay && selectedDatasets.length > 0 + ? resolveMultipleRetrievalConfig(multipleRetrievalConfig) + : multipleRetrievalConfig const createItemFromDialogState = ( state: KnowledgeRetrievalDialogState, id = globalThis.crypto?.randomUUID?.() ?? `retrieval-${Date.now()}`, @@ -413,27 +409,25 @@ function AgentKnowledgeRetrievalDialogContent({ return nextState } const handleSelectedDatasetsChange = (nextDatasets: DataSet[]) => { - const nextMultipleRetrievalConfig = retrievalMode === RETRIEVE_TYPE.multiWay && nextDatasets.length > 0 - ? resolveMultipleRetrievalConfig(multipleRetrievalConfig, nextDatasets) - : multipleRetrievalConfig + const nextMultipleRetrievalConfig = + retrievalMode === RETRIEVE_TYPE.multiWay && nextDatasets.length > 0 + ? resolveMultipleRetrievalConfig(multipleRetrievalConfig, nextDatasets) + : multipleRetrievalConfig const nextState = applyDialogStatePatch({ multipleRetrievalConfig: nextMultipleRetrievalConfig, selectedDatasets: nextDatasets, }) - if (item) - return + if (item) return - if (nextDatasets.length > 0) - onItemCreate?.(createItemFromDialogState(nextState)) + if (nextDatasets.length > 0) onItemCreate?.(createItemFromDialogState(nextState)) } const metadataList = useMemo(() => { - const datasetsWithMetadata = selectedDatasets.filter(dataset => !!dataset.doc_metadata) + const datasetsWithMetadata = selectedDatasets.filter((dataset) => !!dataset.doc_metadata) - if (datasetsWithMetadata.length === 0) - return [] + if (datasetsWithMetadata.length === 0) return [] - return intersectionBy(...datasetsWithMetadata.map(dataset => dataset.doc_metadata!), 'name') + return intersectionBy(...datasetsWithMetadata.map((dataset) => dataset.doc_metadata!), 'name') }, [selectedDatasets]) const validation = useMemo(() => validateKnowledgeRetrievals(retrievals), [retrievals]) const itemValidation = item ? validation.byId[item.id] : undefined @@ -441,23 +435,24 @@ function AgentKnowledgeRetrievalDialogContent({ const datasetsError = getValidationMessage(itemValidation?.datasets) const queryError = getValidationMessage(itemValidation?.query) const retrievalError = getValidationMessage(itemValidation?.retrieval) - const metadataError = itemValidation?.metadata === 'metadata_model_required' - ? undefined - : getValidationMessage(itemValidation?.metadata) + const metadataError = + itemValidation?.metadata === 'metadata_model_required' + ? undefined + : getValidationMessage(itemValidation?.metadata) return ( <> <DialogTitle className="sr-only"> - {t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.title'])} + {t(($) => $['agentDetail.configure.knowledgeRetrieval.dialog.title'])} </DialogTitle> <div className="flex items-center gap-2 px-4 pt-3"> <KnowledgeRetrievalDialogIcon /> <EditableKnowledgeRetrievalName - editLabel={t($ => $['agentDetail.configure.knowledgeRetrieval.edit'], { name })} - inputLabel={t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.nameLabel'])} + editLabel={t(($) => $['agentDetail.configure.knowledgeRetrieval.edit'], { name })} + inputLabel={t(($) => $['agentDetail.configure.knowledgeRetrieval.dialog.nameLabel'])} invalid={!!nameError} name={name} - onCommit={nextName => applyDialogStatePatch({ name: nextName })} + onCommit={(nextName) => applyDialogStatePatch({ name: nextName })} /> <DialogCloseButton className="static size-7 shrink-0 rounded-md" /> </div> @@ -470,18 +465,17 @@ function AgentKnowledgeRetrievalDialogContent({ <div className="flex min-h-0 flex-1 flex-col gap-1 py-2"> <div className="flex flex-col gap-1 px-4 py-2"> <DialogFormLabel id={queryModeLabelId}> - {t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.label'])} + {t(($) => $['agentDetail.configure.knowledgeRetrieval.dialog.query.label'])} </DialogFormLabel> <RadioGroup<KnowledgeRetrievalQueryMode> aria-labelledby={queryModeLabelId} className="w-full gap-2" value={queryMode} onValueChange={(nextMode) => { - if (nextMode) - applyDialogStatePatch({ queryMode: nextMode }) + if (nextMode) applyDialogStatePatch({ queryMode: nextMode }) }} > - {queryModeOptions.map(mode => ( + {queryModeOptions.map((mode) => ( <RadioItem<KnowledgeRetrievalQueryMode> key={mode} value={mode} @@ -489,46 +483,55 @@ function AgentKnowledgeRetrievalDialogContent({ render={<button type="button" className={optionCardClassName} />} > <span className="min-w-0 truncate"> - {t($ => $[`agentDetail.configure.knowledgeRetrieval.dialog.query.${mode}`])} + {t(($) => $[`agentDetail.configure.knowledgeRetrieval.dialog.query.${mode}`])} </span> </RadioItem> ))} </RadioGroup> - {queryMode === 'custom' - ? ( - <> - <div className="pt-1"> - <Textarea - aria-label={t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel'])} - aria-invalid={queryError ? true : undefined} - className="h-20 resize-none rounded-lg px-3 py-2 system-sm-regular" - placeholder={t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.customPlaceholder'])} - value={customQuery} - onValueChange={nextQuery => applyDialogStatePatch({ customQuery: nextQuery })} - /> - </div> - <p className="system-xs-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.customDescription'])} - </p> - {queryError && ( - <p role="alert" className="system-xs-regular text-text-destructive"> - {queryError} - </p> + {queryMode === 'custom' ? ( + <> + <div className="pt-1"> + <Textarea + aria-label={t( + ($) => + $['agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel'], + )} + aria-invalid={queryError ? true : undefined} + className="h-20 resize-none rounded-lg px-3 py-2 system-sm-regular" + placeholder={t( + ($) => + $['agentDetail.configure.knowledgeRetrieval.dialog.query.customPlaceholder'], )} - </> - ) - : ( - <p className="pt-1 system-xs-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.agentDescription'])} + value={customQuery} + onValueChange={(nextQuery) => applyDialogStatePatch({ customQuery: nextQuery })} + /> + </div> + <p className="system-xs-regular text-text-tertiary"> + {t( + ($) => + $['agentDetail.configure.knowledgeRetrieval.dialog.query.customDescription'], + )} + </p> + {queryError && ( + <p role="alert" className="system-xs-regular text-text-destructive"> + {queryError} </p> )} + </> + ) : ( + <p className="pt-1 system-xs-regular text-text-tertiary"> + {t( + ($) => $['agentDetail.configure.knowledgeRetrieval.dialog.query.agentDescription'], + )} + </p> + )} </div> <div className="px-4 py-2"> <Field - title={t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label'])} + title={t(($) => $['agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label'])} required - operations={( + operations={ <div className="flex items-center space-x-1"> <RetrievalConfig payload={{ @@ -537,9 +540,10 @@ function AgentKnowledgeRetrievalDialogContent({ single_retrieval_config: singleRetrievalConfig, }} onRetrievalModeChange={(nextRetrievalMode) => { - const nextMultipleRetrievalConfig = nextRetrievalMode === RETRIEVE_TYPE.multiWay - ? resolveMultipleRetrievalConfig(multipleRetrievalConfig) - : multipleRetrievalConfig + const nextMultipleRetrievalConfig = + nextRetrievalMode === RETRIEVE_TYPE.multiWay + ? resolveMultipleRetrievalConfig(multipleRetrievalConfig) + : multipleRetrievalConfig applyDialogStatePatch({ multipleRetrievalConfig: nextMultipleRetrievalConfig, @@ -547,9 +551,13 @@ function AgentKnowledgeRetrievalDialogContent({ }) }} onMultipleRetrievalConfigChange={(nextMultipleRetrievalConfig) => { - const normalizedMultipleRetrievalConfig = resolveMultipleRetrievalConfig(nextMultipleRetrievalConfig) + const normalizedMultipleRetrievalConfig = resolveMultipleRetrievalConfig( + nextMultipleRetrievalConfig, + ) - applyDialogStatePatch({ multipleRetrievalConfig: normalizedMultipleRetrievalConfig }) + applyDialogStatePatch({ + multipleRetrievalConfig: normalizedMultipleRetrievalConfig, + }) }} singleRetrievalModelConfig={singleRetrievalConfig?.model} onSingleRetrievalModelChange={(model) => { @@ -559,7 +567,9 @@ function AgentKnowledgeRetrievalDialogContent({ provider: model.provider, name: model.modelId, mode: model.mode ?? singleRetrievalConfig?.model.mode ?? AppModeEnum.CHAT, - completion_params: singleRetrievalConfig?.model.completion_params ?? { temperature: 0.7 }, + completion_params: singleRetrievalConfig?.model.completion_params ?? { + temperature: 0.7, + }, }, }, }) @@ -584,12 +594,12 @@ function AgentKnowledgeRetrievalDialogContent({ /> <div className="h-3 w-px bg-divider-regular" /> <AddKnowledge - selectedIds={selectedDatasets.map(dataset => dataset.id)} + selectedIds={selectedDatasets.map((dataset) => dataset.id)} modal onChange={handleSelectedDatasetsChange} /> </div> - )} + } > <> {selectedDatasets.length > 0 && ( @@ -617,7 +627,9 @@ function AgentKnowledgeRetrievalDialogContent({ selectedDatasetsLoaded metadataFilterMode={metadataFilterMode} metadataFilteringConditions={metadataFilteringConditions} - handleMetadataFilterModeChange={nextMode => applyDialogStatePatch({ metadataFilterMode: nextMode })} + handleMetadataFilterModeChange={(nextMode) => + applyDialogStatePatch({ metadataFilterMode: nextMode }) + } handleAddCondition={(metadataItem) => { applyDialogStatePatch({ metadataFilteringConditions: { @@ -633,7 +645,9 @@ function AgentKnowledgeRetrievalDialogContent({ applyDialogStatePatch({ metadataFilteringConditions: { ...metadataFilteringConditions, - conditions: metadataFilteringConditions.conditions.filter(condition => condition.id !== conditionId), + conditions: metadataFilteringConditions.conditions.filter( + (condition) => condition.id !== conditionId, + ), }, }) }} @@ -641,9 +655,10 @@ function AgentKnowledgeRetrievalDialogContent({ applyDialogStatePatch({ metadataFilteringConditions: { ...metadataFilteringConditions, - logical_operator: metadataFilteringConditions.logical_operator === LogicalOperator.and - ? LogicalOperator.or - : LogicalOperator.and, + logical_operator: + metadataFilteringConditions.logical_operator === LogicalOperator.and + ? LogicalOperator.or + : LogicalOperator.and, }, }) }} @@ -651,7 +666,9 @@ function AgentKnowledgeRetrievalDialogContent({ applyDialogStatePatch({ metadataFilteringConditions: { ...metadataFilteringConditions, - conditions: metadataFilteringConditions.conditions.map(condition => condition.id === conditionId ? nextCondition : condition), + conditions: metadataFilteringConditions.conditions.map((condition) => + condition.id === conditionId ? nextCondition : condition, + ), }, }) }} @@ -695,7 +712,7 @@ function AgentKnowledgeRetrievalDialogContent({ > <span aria-hidden className="i-ri-book-read-line size-3 shrink-0" /> <span className="min-w-0 truncate"> - {t($ => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })} + {t(($) => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })} </span> </a> </div> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx index 3d3c0b8ef0d903..ed26eed5660ab8 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx @@ -28,12 +28,9 @@ function KnowledgeRetrievalIcon() { ) } -function getKnowledgeRetrievalName( - item: AgentKnowledgeRetrievalItem, - t: TFunction<'agentV2'>, -) { +function getKnowledgeRetrievalName(item: AgentKnowledgeRetrievalItem, t: TFunction<'agentV2'>) { const nameKey = item.nameKey - return item.name ?? (nameKey ? t($ => $[nameKey]) : item.id) + return item.name ?? (nameKey ? t(($) => $[nameKey]) : item.id) } function AgentKnowledgeRetrievalRow({ @@ -52,8 +49,12 @@ function AgentKnowledgeRetrievalRow({ <ConfigureSectionConfigurableItem icon={<KnowledgeRetrievalIcon />} label={itemName} - editAriaLabel={t($ => $['agentDetail.configure.knowledgeRetrieval.edit'], { name: itemName })} - removeAriaLabel={t($ => $['agentDetail.configure.knowledgeRetrieval.remove'], { name: itemName })} + editAriaLabel={t(($) => $['agentDetail.configure.knowledgeRetrieval.edit'], { + name: itemName, + })} + removeAriaLabel={t(($) => $['agentDetail.configure.knowledgeRetrieval.remove'], { + name: itemName, + })} onEdit={onEdit} onRemove={onDelete} /> @@ -70,7 +71,7 @@ export function AgentKnowledgeRetrieval() { const [addDialogName, setAddDialogName] = useState<string>() const [editingRetrieval, setEditingRetrieval] = useState<AgentKnowledgeRetrievalItem | null>(null) const addOptionsRef = useRef<AgentOrchestrateAddActionOptions | undefined>(undefined) - const knowledgeRetrievalTip = t($ => $['agentDetail.configure.knowledgeRetrieval.tip']) + const knowledgeRetrievalTip = t(($) => $['agentDetail.configure.knowledgeRetrieval.tip']) const retrievalListId = 'agent-configure-knowledge-retrieval-list' const isDialogOpen = isAddDialogOpen || !!editingRetrieval const updateRetrieval = (nextRetrieval: AgentKnowledgeRetrievalItem) => { @@ -78,12 +79,10 @@ export function AgentKnowledgeRetrieval() { setEditingRetrieval(nextRetrieval) } const getDefaultRetrievalName = (index: number) => { - if (index === 1) - return t($ => $['agentDetail.configure.knowledgeRetrieval.retrievalOne']) - if (index === 2) - return t($ => $['agentDetail.configure.knowledgeRetrieval.retrievalTwo']) + if (index === 1) return t(($) => $['agentDetail.configure.knowledgeRetrieval.retrievalOne']) + if (index === 2) return t(($) => $['agentDetail.configure.knowledgeRetrieval.retrievalTwo']) - return t($ => $['agentDetail.configure.knowledgeRetrieval.defaultName'], { index }) + return t(($) => $['agentDetail.configure.knowledgeRetrieval.defaultName'], { index }) } const addRetrieval = (options?: AgentOrchestrateAddActionOptions) => { addOptionsRef.current = options @@ -102,39 +101,41 @@ export function AgentKnowledgeRetrieval() { return ( <> <ConfigureSection - label={t($ => $['agentDetail.configure.knowledgeRetrieval.label'])} + label={t(($) => $['agentDetail.configure.knowledgeRetrieval.label'])} labelId="agent-configure-knowledge-retrieval-label" panelId={retrievalListId} tip={<AgentConfigureTipContent type="knowledge" />} tipAriaLabel={knowledgeRetrievalTip} rootClassName="border-b border-divider-subtle pt-4" panelContentClassName="flex flex-col gap-1 pb-4" - actions={( + actions={ <ConfigureSectionAddButton - ariaLabel={t($ => $['agentDetail.configure.knowledgeRetrieval.add'])} + ariaLabel={t(($) => $['agentDetail.configure.knowledgeRetrieval.add'])} onClick={() => addRetrieval()} /> - )} + } > - {retrievals.length === 0 - ? ( - <ConfigureSectionEmpty - title={t($ => $['agentDetail.configure.knowledgeRetrieval.empty.title'])} - description={t($ => $['agentDetail.configure.knowledgeRetrieval.empty.description'])} - /> - ) - : retrievals.map(item => ( - <AgentKnowledgeRetrievalRow - key={item.id} - item={item} - onDelete={() => removeKnowledgeRetrieval(item.id)} - onEdit={() => setEditingRetrieval(item)} - /> - ))} + {retrievals.length === 0 ? ( + <ConfigureSectionEmpty + title={t(($) => $['agentDetail.configure.knowledgeRetrieval.empty.title'])} + description={t(($) => $['agentDetail.configure.knowledgeRetrieval.empty.description'])} + /> + ) : ( + retrievals.map((item) => ( + <AgentKnowledgeRetrievalRow + key={item.id} + item={item} + onDelete={() => removeKnowledgeRetrieval(item.id)} + onEdit={() => setEditingRetrieval(item)} + /> + )) + )} </ConfigureSection> <AgentKnowledgeRetrievalDialog item={editingRetrieval ?? undefined} - initialName={editingRetrieval ? getKnowledgeRetrievalName(editingRetrieval, t) : addDialogName} + initialName={ + editingRetrieval ? getKnowledgeRetrievalName(editingRetrieval, t) : addDialogName + } onItemCreate={createRetrieval} onItemChange={updateRetrieval} open={isDialogOpen} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/memory.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/memory.tsx index 53aae3f7f73892..59a781a124be0d 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/memory.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/memory.tsx @@ -43,31 +43,26 @@ function MemoryConfigValue({ <span aria-hidden className={`${icon} size-4`} /> </div> <div className="min-w-0 flex-1"> - <div className="system-xs-semibold-uppercase text-text-tertiary"> - {label} - </div> - {isPending - ? <SkeletonRectangle className="mt-2 h-4 w-28 animate-pulse rounded-md" /> - : ( - <div - className={cn( - 'mt-1 truncate system-sm-semibold', - value ? 'text-text-primary' : 'text-text-quaternary', - )} - translate={value ? 'no' : undefined} - > - {value || t($ => $['agentDetail.memorySettings.notConfigured'])} - </div> + <div className="system-xs-semibold-uppercase text-text-tertiary">{label}</div> + {isPending ? ( + <SkeletonRectangle className="mt-2 h-4 w-28 animate-pulse rounded-md" /> + ) : ( + <div + className={cn( + 'mt-1 truncate system-sm-semibold', + value ? 'text-text-primary' : 'text-text-quaternary', )} + translate={value ? 'no' : undefined} + > + {value || t(($) => $['agentDetail.memorySettings.notConfigured'])} + </div> + )} </div> </div> ) } -export function MemorySettings({ - isPending, - memory, -}: MemorySettingsProps) { +export function MemorySettings({ isPending, memory }: MemorySettingsProps) { const { t } = useTranslation('agentV2') return ( @@ -78,21 +73,21 @@ export function MemorySettings({ </div> <div className="min-w-0"> <h2 className="system-xl-semibold text-text-primary"> - {t($ => $['agentDetail.memorySettings.title'])} + {t(($) => $['agentDetail.memorySettings.title'])} </h2> <p className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $['agentDetail.memorySettings.description'])} + {t(($) => $['agentDetail.memorySettings.description'])} </p> </div> </div> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> - {memoryConfigFields.map(field => ( + {memoryConfigFields.map((field) => ( <MemoryConfigValue key={field.key} icon={field.icon} isPending={isPending} - label={t($ => $[field.labelKey])} + label={t(($) => $[field.labelKey])} value={memory?.[field.key]} /> ))} @@ -101,23 +96,18 @@ export function MemorySettings({ <div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-divider-subtle pt-4"> <div className="min-w-0 flex-1"> <div className="flex min-w-0 flex-wrap items-center gap-1.5 system-xs-regular text-text-tertiary"> - <span>{t($ => $['agentDetail.memorySettings.export.title'])}</span> + <span>{t(($) => $['agentDetail.memorySettings.export.title'])}</span> <span className="inline-flex items-center gap-1 rounded-[5px] bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['agentDetail.memorySettings.export.soon'])} + {t(($) => $['agentDetail.memorySettings.export.soon'])} </span> </div> <p className="mt-0.5 system-2xs-regular text-text-tertiary"> - {t($ => $['agentDetail.memorySettings.export.description'])} + {t(($) => $['agentDetail.memorySettings.export.description'])} </p> </div> - <Button - variant="secondary" - size="small" - disabled - className="gap-1.5" - > + <Button variant="secondary" size="small" disabled className="gap-1.5"> <span aria-hidden className="i-ri-download-line size-3.5" /> - {t($ => $['agentDetail.memorySettings.export.download'])} + {t(($) => $['agentDetail.memorySettings.export.download'])} </Button> </div> </div> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx index d296e17747909b..1de8273dbcf71e 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx @@ -1,6 +1,9 @@ 'use client' -import type { FormValue, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + FormValue, + Model, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import type { AgentComposerModel } from '@/features/agent-v2/agent-composer/form-state' import { Field, FieldLabel } from '@langgenius/dify-ui/field' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' @@ -29,73 +32,72 @@ export function AgentModelField({ return ( <Field name="model" className="gap-1 pb-4"> <FieldLabel className="py-0 system-sm-semibold-uppercase! text-text-secondary"> - {t($ => $['agentDetail.configure.model.label'])} + {t(($) => $['agentDetail.configure.model.label'])} </FieldLabel> <div className="relative h-8 min-w-0"> - {readOnly - ? ( - <div className="flex h-8 w-full min-w-0 items-center rounded-lg bg-components-input-bg-disabled px-3 system-sm-regular text-components-input-text-filled"> - <span className="truncate">{currentModel?.model}</span> - </div> - ) - : ( - <div className="flex h-8 min-w-0 items-center gap-px overflow-hidden rounded-lg"> - <ModelSelector - defaultModel={currentModel} - modelList={textGenerationModelList} - triggerClassName="h-8! w-full rounded-r-none! [&_.i-ri-arrow-down-s-line]:hidden" - popupClassName="w-(--anchor-width) max-w-[min(var(--anchor-width),var(--available-width),calc(100vw-32px))]" - providerSettingsSource="agent" - showModelMeta={false} - modelPredicate={isAgentCompatibleModel} - modelSuggestionPredicate={isAgentSuggestedModel} - onSelect={onSelect} - /> - <div className="w-8 shrink-0"> - <ModelParameterModal - isAdvancedMode - modelId={currentModel?.model ?? ''} - provider={currentModel?.provider ?? ''} - completionParams={(currentModel?.model_settings ?? {}) as FormValue} - readonly={!canConfigureModelSettings} - hideDebugWithMultipleModel - popupClassName="w-[400px]" - setModel={({ modelId, provider }) => { - onSelect({ - ...currentModel, - provider, - model: modelId, - }) - }} - onCompletionParamsChange={(modelSettings) => { - if (!currentModel) - return + {readOnly ? ( + <div className="flex h-8 w-full min-w-0 items-center rounded-lg bg-components-input-bg-disabled px-3 system-sm-regular text-components-input-text-filled"> + <span className="truncate">{currentModel?.model}</span> + </div> + ) : ( + <div className="flex h-8 min-w-0 items-center gap-px overflow-hidden rounded-lg"> + <ModelSelector + defaultModel={currentModel} + modelList={textGenerationModelList} + triggerClassName="h-8! w-full rounded-r-none! [&_.i-ri-arrow-down-s-line]:hidden" + popupClassName="w-(--anchor-width) max-w-[min(var(--anchor-width),var(--available-width),calc(100vw-32px))]" + providerSettingsSource="agent" + showModelMeta={false} + modelPredicate={isAgentCompatibleModel} + modelSuggestionPredicate={isAgentSuggestedModel} + onSelect={onSelect} + /> + <div className="w-8 shrink-0"> + <ModelParameterModal + isAdvancedMode + modelId={currentModel?.model ?? ''} + provider={currentModel?.provider ?? ''} + completionParams={(currentModel?.model_settings ?? {}) as FormValue} + readonly={!canConfigureModelSettings} + hideDebugWithMultipleModel + popupClassName="w-[400px]" + setModel={({ modelId, provider }) => { + onSelect({ + ...currentModel, + provider, + model: modelId, + }) + }} + onCompletionParamsChange={(modelSettings) => { + if (!currentModel) return - onSelect({ - ...currentModel, - model_settings: modelSettings, - }) - }} - renderTrigger={() => ( - <Tooltip> - <TooltipTrigger - disabled={!canConfigureModelSettings} - render={( - <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-l-none rounded-r-lg bg-components-button-tertiary-bg text-text-tertiary hover:bg-components-button-tertiary-bg-hover hover:text-text-secondary aria-disabled:cursor-not-allowed aria-disabled:text-text-disabled"> - <span className="sr-only">{tCommon($ => $['modelProvider.modelSettings'])}</span> - <span className="i-ri-equalizer-2-line size-4" /> - </span> - )} - /> - <TooltipContent placement="top"> - {tCommon($ => $['modelProvider.modelSettings'])} - </TooltipContent> - </Tooltip> - )} - /> - </div> - </div> - )} + onSelect({ + ...currentModel, + model_settings: modelSettings, + }) + }} + renderTrigger={() => ( + <Tooltip> + <TooltipTrigger + disabled={!canConfigureModelSettings} + render={ + <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-l-none rounded-r-lg bg-components-button-tertiary-bg text-text-tertiary hover:bg-components-button-tertiary-bg-hover hover:text-text-secondary aria-disabled:cursor-not-allowed aria-disabled:text-text-disabled"> + <span className="sr-only"> + {tCommon(($) => $['modelProvider.modelSettings'])} + </span> + <span className="i-ri-equalizer-2-line size-4" /> + </span> + } + /> + <TooltipContent placement="top"> + {tCommon(($) => $['modelProvider.modelSettings'])} + </TooltipContent> + </Tooltip> + )} + /> + </div> + </div> + )} </div> </Field> ) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/options.spec.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/options.spec.ts index 12af5c3af6266d..22640e37de570a 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/options.spec.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/options.spec.ts @@ -5,33 +5,31 @@ describe('prompt editor token replacement', () => { // Replacing the tracked slash range keeps insertion at the user's caret instead of appending. describe('insertTokenAtTextRange', () => { it('should replace a slash in the middle of the prompt and place the cursor after the token', () => { - expect(insertTokenAtTextRange( - 'Review / before replying', - { start: 7, end: 8 }, - '[§file:file-1:Spec§]', - )).toEqual({ + expect( + insertTokenAtTextRange( + 'Review / before replying', + { start: 7, end: 8 }, + '[§file:file-1:Spec§]', + ), + ).toEqual({ value: 'Review [§file:file-1:Spec§] before replying', cursorOffset: 'Review [§file:file-1:Spec§]'.length, }) }) it('should add spacing when the slash is adjacent to text and place the cursor after the spacer', () => { - expect(insertTokenAtTextRange( - 'Review/now', - { start: 6, end: 7 }, - '[§skill:analysis:Analysis§]', - )).toEqual({ + expect( + insertTokenAtTextRange('Review/now', { start: 6, end: 7 }, '[§skill:analysis:Analysis§]'), + ).toEqual({ value: 'Review [§skill:analysis:Analysis§] now', cursorOffset: 'Review [§skill:analysis:Analysis§] '.length, }) }) it('should clamp out-of-bound ranges before replacing', () => { - expect(insertTokenAtTextRange( - 'Review/', - { start: 6, end: 99 }, - '[§knowledge:kb-1:KB§]', - )).toEqual({ + expect( + insertTokenAtTextRange('Review/', { start: 6, end: 99 }, '[§knowledge:kb-1:KB§]'), + ).toEqual({ value: 'Review [§knowledge:kb-1:KB§]', cursorOffset: 'Review [§knowledge:kb-1:KB§]'.length, }) @@ -41,17 +39,15 @@ describe('prompt editor token replacement', () => { // Existing fallback behavior is retained for callers that only know about a trailing slash. describe('replaceTrailingSlashWithToken', () => { it('should replace a trailing slash', () => { - expect(replaceTrailingSlashWithToken( - 'Review /', - '[§file:file-1:Spec§]', - )).toBe('Review [§file:file-1:Spec§]') + expect(replaceTrailingSlashWithToken('Review /', '[§file:file-1:Spec§]')).toBe( + 'Review [§file:file-1:Spec§]', + ) }) it('should append when no trailing slash exists', () => { - expect(replaceTrailingSlashWithToken( - 'Review', - '[§file:file-1:Spec§]', - )).toBe('Review [§file:file-1:Spec§]') + expect(replaceTrailingSlashWithToken('Review', '[§file:file-1:Spec§]')).toBe( + 'Review [§file:file-1:Spec§]', + ) }) }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/hooks.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/hooks.ts index c31023c82da8b8..1be033ec265d0b 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/hooks.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/hooks.ts @@ -20,35 +20,24 @@ type ProviderTool = Extract<AgentTool, { kind: 'provider' }> const hasUrlProtocol = (value: string) => /^[a-z][a-z\d+.-]*:/i.test(value) -function normalizeProviderIcon( - icon: ToolWithProvider['icon'] | undefined, - workspaceId: string, -) { - if (!icon || typeof icon !== 'string') - return icon +function normalizeProviderIcon(icon: ToolWithProvider['icon'] | undefined, workspaceId: string) { + if (!icon || typeof icon !== 'string') return icon - if (hasUrlProtocol(icon)) - return icon + if (hasUrlProtocol(icon)) return icon if (icon.startsWith('/')) { - if (basePath && !icon.startsWith(`${basePath}/`)) - return `${basePath}${icon}` + if (basePath && !icon.startsWith(`${basePath}/`)) return `${basePath}${icon}` return icon } - if (!workspaceId) - return icon + if (!workspaceId) return icon return `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${workspaceId}&filename=${icon}` } -function getProviderByTool( - providerById: Map<string, ToolWithProvider>, - tool: ProviderTool, -) { - return providerById.get(tool.id) - ?? providerById.get(tool.name) +function getProviderByTool(providerById: Map<string, ToolWithProvider>, tool: ProviderTool) { + return providerById.get(tool.id) ?? providerById.get(tool.name) } function createProviderMap(providers: ToolWithProvider[]) { @@ -85,22 +74,27 @@ export function useAgentPromptToolIconResolver() { return createProviderMap(allProviders) }, [builtInTools, customTools, mcpTools, workflowTools]) - return useMemo(() => ({ - getProviderIcon: (provider: ToolWithProvider) => { - const rawIcon = theme === Theme.dark && provider.icon_dark ? provider.icon_dark : provider.icon - return normalizeProviderIcon(rawIcon, currentWorkspaceId) - }, - getProviderIcons: (provider: ToolWithProvider) => ({ - icon: normalizeProviderIcon(provider.icon, currentWorkspaceId), - iconDark: normalizeProviderIcon(provider.icon_dark, currentWorkspaceId), + return useMemo( + () => ({ + getProviderIcon: (provider: ToolWithProvider) => { + const rawIcon = + theme === Theme.dark && provider.icon_dark ? provider.icon_dark : provider.icon + return normalizeProviderIcon(rawIcon, currentWorkspaceId) + }, + getProviderIcons: (provider: ToolWithProvider) => ({ + icon: normalizeProviderIcon(provider.icon, currentWorkspaceId), + iconDark: normalizeProviderIcon(provider.icon_dark, currentWorkspaceId), + }), + getConfiguredToolIcon: (tool: ProviderTool) => { + const provider = getProviderByTool(providerById, tool) + const rawIcon = + theme === Theme.dark && (tool.iconDark ?? provider?.icon_dark) + ? (tool.iconDark ?? provider?.icon_dark) + : (tool.icon ?? provider?.icon) + + return normalizeProviderIcon(rawIcon, currentWorkspaceId) + }, }), - getConfiguredToolIcon: (tool: ProviderTool) => { - const provider = getProviderByTool(providerById, tool) - const rawIcon = theme === Theme.dark && (tool.iconDark ?? provider?.icon_dark) - ? tool.iconDark ?? provider?.icon_dark - : tool.icon ?? provider?.icon - - return normalizeProviderIcon(rawIcon, currentWorkspaceId) - }, - }), [currentWorkspaceId, providerById, theme]) + [currentWorkspaceId, providerById, theme], + ) } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/index.tsx index 6f2e6d3ba62af9..4acaa58fe2e83a 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/index.tsx @@ -1,11 +1,19 @@ 'use client' import type { LexicalNode } from 'lexical' -import type { MouseEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from 'react' +import type { + MouseEvent, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, +} from 'react' import type { TextRange } from './options' import type { SlashMenuCategory, SlashMenuView } from './slash' import type { RosterReferenceToken } from '@/app/components/base/prompt-editor/plugins/roster-reference-block/utils' -import type { AgentFileNode, AgentProviderTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state' +import type { + AgentFileNode, + AgentProviderTool, + AgentTool, +} from '@/features/agent-v2/agent-composer/form-state' import { cn } from '@langgenius/dify-ui/cn' import { Kbd } from '@langgenius/dify-ui/kbd' import { toast } from '@langgenius/dify-ui/toast' @@ -44,43 +52,30 @@ import { useAgentPromptToolIconResolver } from './hooks' import { insertTokenAtTextRange, replaceTrailingSlashWithToken } from './options' import { AgentPromptSlashMenu } from './slash' -function AgentPromptPlaceholder({ - insertLabel, - text, -}: { - insertLabel: string - text: string -}) { +function AgentPromptPlaceholder({ insertLabel, text }: { insertLabel: string; text: string }) { return ( <span className="flex items-center gap-0.5 system-sm-regular whitespace-nowrap text-components-input-text-placeholder"> <span>{text}</span> - <Kbd className="text-text-placeholder"> - / - </Kbd> - <span className="underline decoration-dotted underline-offset-2"> - {insertLabel} - </span> + <Kbd className="text-text-placeholder">/</Kbd> + <span className="underline decoration-dotted underline-offset-2">{insertLabel}</span> </span> ) } function getProviderToolFromToken(token: RosterReferenceToken, tools: AgentTool[]) { - if (token.kind !== 'tool' && token.kind !== 'tool-all') - return - - return tools.find(tool => - tool.kind === 'provider' - && ( - token.id === tool.id - || token.id === `${tool.id}/*` - || tool.actions.some(action => token.id === `${tool.id}/${action.toolName}`) - ), + if (token.kind !== 'tool' && token.kind !== 'tool-all') return + + return tools.find( + (tool) => + tool.kind === 'provider' && + (token.id === tool.id || + token.id === `${tool.id}/*` || + tool.actions.some((action) => token.id === `${tool.id}/${action.toolName}`)), ) } -const flattenFileNodes = (files: AgentFileNode[]): AgentFileNode[] => files.flatMap(file => ( - file.children?.length ? flattenFileNodes(file.children) : [file] -)) +const flattenFileNodes = (files: AgentFileNode[]): AgentFileNode[] => + files.flatMap((file) => (file.children?.length ? flattenFileNodes(file.children) : [file])) function AgentPromptRosterReferenceIcon({ token, @@ -101,37 +96,26 @@ function AgentPromptRosterReferenceIcon({ } const providerTool = getProviderToolFromToken(token, tools) - if (!providerTool || providerTool.kind !== 'provider') - return null + if (!providerTool || providerTool.kind !== 'provider') return null const icon = getConfiguredToolIcon(providerTool) if (icon) { - return ( - <BlockIcon - className="shrink-0" - type={BlockEnum.Tool} - size="xs" - toolIcon={icon} - /> - ) + return <BlockIcon className="shrink-0" type={BlockEnum.Tool} size="xs" toolIcon={icon} /> } - return ( - <span - aria-hidden - className={cn('size-3.5 shrink-0', providerTool.iconClassName)} - /> - ) + return <span aria-hidden className={cn('size-3.5 shrink-0', providerTool.iconClassName)} /> } const getLastTextContent = (node: Node): string => { - if (node.nodeType === Node.TEXT_NODE) - return node.textContent ?? '' + if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '' const textParts: string[] = [] const ownerDocument = node.ownerDocument ?? document - const walker = ownerDocument.createTreeWalker(node, ownerDocument.defaultView?.NodeFilter.SHOW_TEXT ?? NodeFilter.SHOW_TEXT) + const walker = ownerDocument.createTreeWalker( + node, + ownerDocument.defaultView?.NodeFilter.SHOW_TEXT ?? NodeFilter.SHOW_TEXT, + ) let current = walker.nextNode() while (current) { textParts.push(current.textContent ?? '') @@ -142,16 +126,14 @@ const getLastTextContent = (node: Node): string => { } const isSelectionAfterSlash = (rootElement: HTMLElement | null, fallbackValue: string) => { - if (!rootElement) - return fallbackValue.endsWith('/') + if (!rootElement) return fallbackValue.endsWith('/') const selection = rootElement.ownerDocument.getSelection() if (!selection || !selection.isCollapsed || selection.rangeCount === 0) return fallbackValue.endsWith('/') const anchorNode = selection.anchorNode - if (!anchorNode || !rootElement.contains(anchorNode)) - return false + if (!anchorNode || !rootElement.contains(anchorNode)) return false if (anchorNode.nodeType === Node.TEXT_NODE) return (anchorNode.textContent ?? '').slice(0, selection.anchorOffset).endsWith('/') @@ -166,18 +148,15 @@ const getNodeOffset = ( node: LexicalNode, anchorNode: LexicalNode, anchorOffset: number, -): { found: boolean, offset: number } => { - if (node.getKey() === anchorNode.getKey()) - return { found: true, offset: anchorOffset } +): { found: boolean; offset: number } => { + if (node.getKey() === anchorNode.getKey()) return { found: true, offset: anchorOffset } - if (!$isElementNode(node)) - return { found: false, offset: node.getTextContent().length } + if (!$isElementNode(node)) return { found: false, offset: node.getTextContent().length } let offset = 0 for (const child of node.getChildren()) { const childOffset = getNodeOffset(child, anchorNode, anchorOffset) - if (childOffset.found) - return { found: true, offset: offset + childOffset.offset } + if (childOffset.found) return { found: true, offset: offset + childOffset.offset } offset += childOffset.offset } @@ -187,8 +166,7 @@ const getNodeOffset = ( const getSelectionTextOffset = () => { const selection = $getSelection() - if (!$isRangeSelection(selection) || !selection.isCollapsed()) - return null + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null const anchor = selection.anchor const anchorNode = anchor.getNode() @@ -197,8 +175,7 @@ const getSelectionTextOffset = () => { for (const child of root.getChildren()) { const childOffset = getNodeOffset(child, anchorNode, anchor.offset) - if (childOffset.found) - return offset + childOffset.offset + if (childOffset.found) return offset + childOffset.offset offset += childOffset.offset + 1 } @@ -208,12 +185,13 @@ const getSelectionTextOffset = () => { const readSlashInsertRange = (): TextRange | null => { const offset = getSelectionTextOffset() - if (!offset) - return null + if (!offset) return null - const value = $getRoot().getChildren().map(node => node.getTextContent()).join('\n') - if (value[offset - 1] !== '/') - return null + const value = $getRoot() + .getChildren() + .map((node) => node.getTextContent()) + .join('\n') + if (value[offset - 1] !== '/') return null return { start: offset - 1, @@ -228,8 +206,7 @@ const selectNodeTextOffset = (node: LexicalNode, textOffset: number): boolean => return true } - if (!$isElementNode(node)) - return false + if (!$isElementNode(node)) return false const children = node.getChildren() let currentOffset = 0 @@ -289,20 +266,17 @@ const agentPromptSlashMenuId = 'agent-configure-prompt-slash-menu' const getRangeRect = (range: Range) => { const rects = range.getClientRects() - if (rects.length) - return rects[rects.length - 1]! + if (rects.length) return rects[rects.length - 1]! return range.getBoundingClientRect() } const getLastTextNode = (node: Node): Text | null => { - if (node.nodeType === Node.TEXT_NODE) - return node as Text + if (node.nodeType === Node.TEXT_NODE) return node as Text for (let index = node.childNodes.length - 1; index >= 0; index--) { const textNode = getLastTextNode(node.childNodes.item(index)) - if (textNode) - return textNode + if (textNode) return textNode } return null @@ -310,8 +284,7 @@ const getLastTextNode = (node: Node): Text | null => { const getSlashAnchorRange = (selection: Selection, editorElement: HTMLElement) => { const anchorNode = selection.anchorNode - if (!anchorNode || !editorElement.contains(anchorNode)) - return null + if (!anchorNode || !editorElement.contains(anchorNode)) return null const ownerDocument = editorElement.ownerDocument @@ -325,17 +298,14 @@ const getSlashAnchorRange = (selection: Selection, editorElement: HTMLElement) = } } - if (anchorNode.nodeType !== Node.ELEMENT_NODE) - return null + if (anchorNode.nodeType !== Node.ELEMENT_NODE) return null const previousChild = anchorNode.childNodes.item(selection.anchorOffset - 1) - if (!previousChild) - return null + if (!previousChild) return null const textNode = getLastTextNode(previousChild) const text = textNode?.textContent ?? '' - if (!textNode || !text.endsWith('/')) - return null + if (!textNode || !text.endsWith('/')) return null const range = ownerDocument.createRange() range.setStart(textNode, text.length - 1) @@ -343,14 +313,15 @@ const getSlashAnchorRange = (selection: Selection, editorElement: HTMLElement) = return range } -const getSlashMenuPosition = (rootElement: HTMLElement, editorElement: HTMLElement): SlashMenuPosition | null => { +const getSlashMenuPosition = ( + rootElement: HTMLElement, + editorElement: HTMLElement, +): SlashMenuPosition | null => { const selection = editorElement.ownerDocument.getSelection() - if (!selection || !selection.isCollapsed || selection.rangeCount === 0) - return null + if (!selection || !selection.isCollapsed || selection.rangeCount === 0) return null const anchorNode = selection.anchorNode - if (!anchorNode || !editorElement.contains(anchorNode)) - return null + if (!anchorNode || !editorElement.contains(anchorNode)) return null const slashRange = getSlashAnchorRange(selection, editorElement) const caretRange = selection.getRangeAt(0).cloneRange() @@ -368,8 +339,7 @@ const getSlashMenuPosition = (rootElement: HTMLElement, editorElement: HTMLEleme } const editorRect = editorElement.getBoundingClientRect() - if (!rect || rect.bottom < editorRect.top || rect.top > editorRect.bottom) - return null + if (!rect || rect.bottom < editorRect.top || rect.top > editorRect.bottom) return null const rootRect = rootElement.getBoundingClientRect() @@ -386,10 +356,7 @@ const getSlashMenuLeft = (position: SlashMenuPosition, width: number) => { position.containerWidth - width - slashMenuViewportPadding, ) - return Math.max( - slashMenuViewportPadding, - Math.min(position.left, maxLeft), - ) + return Math.max(slashMenuViewportPadding, Math.min(position.left, maxLeft)) } /* v8 ignore stop */ @@ -414,11 +381,7 @@ function AgentPromptSelectionBridge({ updateSlashRange() return mergeRegister( - editor.registerCommand( - SELECTION_CHANGE_COMMAND, - updateSlashRange, - COMMAND_PRIORITY_LOW, - ), + editor.registerCommand(SELECTION_CHANGE_COMMAND, updateSlashRange, COMMAND_PRIORITY_LOW), editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { onSlashRangeChange(readSlashInsertRange()) @@ -428,8 +391,7 @@ function AgentPromptSelectionBridge({ }, [editor, onSlashRangeChange]) useEffect(() => { - if (!restoreRequest) - return + if (!restoreRequest) return editor.focus(() => { editor.update(() => { @@ -452,23 +414,24 @@ export function AgentPromptEditor() { const { getConfiguredToolIcon } = useAgentPromptToolIconResolver() const retrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom) const addActions = useAgentOrchestrateAddActions() - const promptTip = t($ => $['agentDetail.configure.prompt.tip']) + const promptTip = t(($) => $['agentDetail.configure.prompt.tip']) const promptPlaceholder = ( <AgentPromptPlaceholder - text={t($ => $['agentDetail.configure.prompt.placeholder'])} - insertLabel={t($ => $['agentDetail.configure.prompt.insert.label']).toLocaleLowerCase()} + text={t(($) => $['agentDetail.configure.prompt.placeholder'])} + insertLabel={t(($) => $['agentDetail.configure.prompt.insert.label']).toLocaleLowerCase()} /> ) const { copied, copy } = useClipboard({ timeout: 2000, onCopyError: () => { - toast.error(t($ => $['agentDetail.configure.prompt.copyFailed'])) + toast.error(t(($) => $['agentDetail.configure.prompt.copyFailed'])) }, }) const [slashMenuView, setSlashMenuView] = useState<SlashMenuView>('main') const [isSlashMenuOpen, setIsSlashMenuOpen] = useState(false) const [slashMenuPosition, setSlashMenuPosition] = useState<SlashMenuPosition | null>(null) - const [selectionRestoreRequest, setSelectionRestoreRequest] = useState<SelectionRestoreRequest | null>(null) + const [selectionRestoreRequest, setSelectionRestoreRequest] = + useState<SelectionRestoreRequest | null>(null) const positioningRootRef = useRef<HTMLDivElement>(null) const promptEditorHostRef = useRef<HTMLDivElement>(null) @@ -508,8 +471,8 @@ export function AgentPromptEditor() { return { skills: skillIds, files: fileIds, - knowledge: new Set(retrievals.map(retrieval => retrieval.id)), - cliTools: new Set(tools.flatMap(tool => tool.kind === 'cli' ? [tool.id] : [])), + knowledge: new Set(retrievals.map((retrieval) => retrieval.id)), + cliTools: new Set(tools.flatMap((tool) => (tool.kind === 'cli' ? [tool.id] : []))), } }, [files, retrievals, skills, tools]) @@ -520,8 +483,7 @@ export function AgentPromptEditor() { const closeSlashMenu = useCallback(() => { setIsSlashMenuOpen(false) setSlashMenuPosition(null) - if (slashMenuLiveRegionRef.current) - slashMenuLiveRegionRef.current.textContent = '' + if (slashMenuLiveRegionRef.current) slashMenuLiveRegionRef.current.textContent = '' setSlashMenuView('main') shouldFocusSlashMenuRef.current = false shouldKeepEditorFocusRef.current = false @@ -533,37 +495,35 @@ export function AgentPromptEditor() { const updateSlashMenuPosition = useCallback(() => { const rootElement = positioningRootRef.current const editorElement = promptEditorHostRef.current - if (!rootElement || !editorElement) - return + if (!rootElement || !editorElement) return const position = getSlashMenuPosition(rootElement, editorElement) - if (!position) - return + if (!position) return setSlashMenuPosition(position) }, []) - const openSlashMenu = useCallback((options: { autoFocus: boolean }) => { - shouldFocusSlashMenuRef.current = options.autoFocus - shouldKeepEditorFocusRef.current = !options.autoFocus - activeSlashMenuItemIndexRef.current = options.autoFocus ? 0 : -1 - parentSlashMenuItemIndexRef.current = -1 - if (slashMenuLiveRegionRef.current) - slashMenuLiveRegionRef.current.textContent = '' - setSlashMenuView('main') - updateSlashMenuPosition() - setIsSlashMenuOpen(true) - }, [updateSlashMenuPosition]) + const openSlashMenu = useCallback( + (options: { autoFocus: boolean }) => { + shouldFocusSlashMenuRef.current = options.autoFocus + shouldKeepEditorFocusRef.current = !options.autoFocus + activeSlashMenuItemIndexRef.current = options.autoFocus ? 0 : -1 + parentSlashMenuItemIndexRef.current = -1 + if (slashMenuLiveRegionRef.current) slashMenuLiveRegionRef.current.textContent = '' + setSlashMenuView('main') + updateSlashMenuPosition() + setIsSlashMenuOpen(true) + }, + [updateSlashMenuPosition], + ) const syncSlashMenuWithSelection = useCallback(() => { - if (readOnly) - return + if (readOnly) return if (isSelectionAfterSlash(promptEditorHostRef.current, value)) { updateSlashMenuPosition() openSlashMenu({ autoFocus: false }) - } - else { + } else { pendingSlashInsertRangeRef.current = null closeSlashMenu() } @@ -572,7 +532,9 @@ export function AgentPromptEditor() { const scheduleSlashMenuSync = useCallback(() => { const ownerWindow = promptEditorHostRef.current?.ownerDocument.defaultView ?? window if (slashMenuSyncFrameIdRef.current !== null) - (slashMenuSyncOwnerWindowRef.current ?? ownerWindow).cancelAnimationFrame(slashMenuSyncFrameIdRef.current) + (slashMenuSyncOwnerWindowRef.current ?? ownerWindow).cancelAnimationFrame( + slashMenuSyncFrameIdRef.current, + ) slashMenuSyncOwnerWindowRef.current = ownerWindow slashMenuSyncFrameIdRef.current = ownerWindow.requestAnimationFrame(() => { @@ -585,49 +547,64 @@ export function AgentPromptEditor() { useEffect(() => { return () => { if (slashMenuSyncFrameIdRef.current !== null) - (slashMenuSyncOwnerWindowRef.current ?? window).cancelAnimationFrame(slashMenuSyncFrameIdRef.current) + (slashMenuSyncOwnerWindowRef.current ?? window).cancelAnimationFrame( + slashMenuSyncFrameIdRef.current, + ) } }, []) const handleSlashRangeChange = useCallback((range: TextRange | null) => { - if (range) - pendingSlashInsertRangeRef.current = range + if (range) pendingSlashInsertRangeRef.current = range }, []) const focusPromptEditor = useCallback(() => { - const editable = promptEditorHostRef.current?.querySelector<HTMLElement>('[contenteditable="true"], [role="textbox"]') + const editable = promptEditorHostRef.current?.querySelector<HTMLElement>( + '[contenteditable="true"], [role="textbox"]', + ) editable?.focus({ preventScroll: true }) }, []) - const getSlashMenuItems = useCallback(() => Array.from(slashMenuElementRef.current?.querySelectorAll<HTMLElement>('[data-agent-prompt-menu-item]') ?? []) - .filter(item => !item.hasAttribute('disabled') && item.getAttribute('aria-disabled') !== 'true'), []) + const getSlashMenuItems = useCallback( + () => + Array.from( + slashMenuElementRef.current?.querySelectorAll<HTMLElement>( + '[data-agent-prompt-menu-item]', + ) ?? [], + ).filter( + (item) => !item.hasAttribute('disabled') && item.getAttribute('aria-disabled') !== 'true', + ), + [], + ) const setActiveSlashMenuItem = useCallback((menuItems: HTMLElement[], index: number) => { activeSlashMenuItemIndexRef.current = index menuItems.forEach((item, itemIndex) => { item.toggleAttribute('data-agent-prompt-menu-active', itemIndex === index) }) - const announcement = index >= 0 - ? menuItems[index]?.textContent?.replace(/\s+/g, ' ').trim() ?? '' - : '' - if (slashMenuLiveRegionRef.current && slashMenuLiveRegionRef.current.textContent !== announcement) + const announcement = + index >= 0 ? (menuItems[index]?.textContent?.replace(/\s+/g, ' ').trim() ?? '') : '' + if ( + slashMenuLiveRegionRef.current && + slashMenuLiveRegionRef.current.textContent !== announcement + ) slashMenuLiveRegionRef.current.textContent = announcement }, []) - const activateSlashMenuItem = useCallback((item: HTMLElement | undefined) => { - if (!item) - return + const activateSlashMenuItem = useCallback( + (item: HTMLElement | undefined) => { + if (!item) return - if (item.hasAttribute('data-agent-prompt-menu-category')) { - const itemIndex = getSlashMenuItems().indexOf(item) - parentSlashMenuItemIndexRef.current = itemIndex >= 0 - ? itemIndex - : Math.max(0, activeSlashMenuItemIndexRef.current) - activeSlashMenuItemIndexRef.current = 0 - } + if (item.hasAttribute('data-agent-prompt-menu-category')) { + const itemIndex = getSlashMenuItems().indexOf(item) + parentSlashMenuItemIndexRef.current = + itemIndex >= 0 ? itemIndex : Math.max(0, activeSlashMenuItemIndexRef.current) + activeSlashMenuItemIndexRef.current = 0 + } - item.click() - }, [getSlashMenuItems]) + item.click() + }, + [getSlashMenuItems], + ) const returnToSlashMenuMain = useCallback(() => { activeSlashMenuItemIndexRef.current = Math.max(0, parentSlashMenuItemIndexRef.current) @@ -637,8 +614,7 @@ export function AgentPromptEditor() { const handleEditorKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => { handledEditorMenuKeyRef.current = false - if (readOnly) - return + if (readOnly) return if (event.key === 'Escape' && isSlashMenuOpen) { event.preventDefault() @@ -647,12 +623,10 @@ export function AgentPromptEditor() { return } - if (!isSlashMenuOpen) - return + if (!isSlashMenuOpen) return const menuItems = getSlashMenuItems() - if (!menuItems.length) - return + if (!menuItems.length) return if (event.key === 'ArrowLeft' && slashMenuView !== 'main') { event.preventDefault() @@ -664,7 +638,10 @@ export function AgentPromptEditor() { if (event.key === 'ArrowRight' && activeSlashMenuItemIndexRef.current >= 0) { const activeItem = menuItems[activeSlashMenuItemIndexRef.current] - if (!activeItem?.hasAttribute('data-agent-prompt-menu-category') && !activeItem?.hasAttribute('aria-expanded')) + if ( + !activeItem?.hasAttribute('data-agent-prompt-menu-category') && + !activeItem?.hasAttribute('aria-expanded') + ) return event.preventDefault() @@ -683,7 +660,12 @@ export function AgentPromptEditor() { return } - if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End') + if ( + event.key !== 'ArrowDown' && + event.key !== 'ArrowUp' && + event.key !== 'Home' && + event.key !== 'End' + ) return event.preventDefault() @@ -701,9 +683,12 @@ export function AgentPromptEditor() { } const activeIndex = activeSlashMenuItemIndexRef.current - const nextIndex = activeIndex === -1 - ? event.key === 'ArrowUp' ? menuItems.length - 1 : 0 - : (activeIndex + (event.key === 'ArrowDown' ? 1 : -1) + menuItems.length) % menuItems.length + const nextIndex = + activeIndex === -1 + ? event.key === 'ArrowUp' + ? menuItems.length - 1 + : 0 + : (activeIndex + (event.key === 'ArrowDown' ? 1 : -1) + menuItems.length) % menuItems.length setActiveSlashMenuItem(menuItems, nextIndex) } @@ -719,10 +704,7 @@ export function AgentPromptEditor() { const handleEditorPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => { const target = event.target - if ( - target instanceof Element - && target.closest('[data-agent-prompt-toolbar]') - ) { + if (target instanceof Element && target.closest('[data-agent-prompt-toolbar]')) { return } @@ -730,29 +712,23 @@ export function AgentPromptEditor() { } const handleSlashMenuPointerDownCapture = (event: ReactPointerEvent<HTMLDivElement>) => { - if (shouldKeepEditorFocusRef.current) - event.preventDefault() + if (shouldKeepEditorFocusRef.current) event.preventDefault() } const handleSlashMenuMouseDownCapture = (event: MouseEvent<HTMLDivElement>) => { - if (shouldKeepEditorFocusRef.current) - event.preventDefault() + if (shouldKeepEditorFocusRef.current) event.preventDefault() } const handleRootPointerDown = (event: MouseEvent<HTMLDivElement>) => { const target = event.target - if (!(target instanceof Node)) - return + if (!(target instanceof Node)) return - if (promptEditorHostRef.current?.contains(target)) - return + if (promptEditorHostRef.current?.contains(target)) return if ( - target instanceof Element - && ( - target.closest('[data-agent-prompt-slash-menu]') - || target.closest('[data-agent-prompt-toolbar]') - ) + target instanceof Element && + (target.closest('[data-agent-prompt-slash-menu]') || + target.closest('[data-agent-prompt-toolbar]')) ) { return } @@ -765,8 +741,7 @@ export function AgentPromptEditor() { let insertionResult if (slashRange) { insertionResult = insertTokenAtTextRange(value, slashRange, token) - } - else { + } else { const nextValue = replaceTrailingSlashWithToken(value, token) insertionResult = { value: nextValue, @@ -788,73 +763,77 @@ export function AgentPromptEditor() { openSlashMenu({ autoFocus: true }) } - const renderRosterReferenceIcon = useCallback((token: RosterReferenceToken) => { - if (!ENABLE_AGENT_CLI_TOOLS && token.kind === 'cli_tool') - return null + const renderRosterReferenceIcon = useCallback( + (token: RosterReferenceToken) => { + if (!ENABLE_AGENT_CLI_TOOLS && token.kind === 'cli_tool') return null - if (token.kind !== 'tool' && token.kind !== 'tool-all' && token.kind !== 'cli_tool') - return null + if (token.kind !== 'tool' && token.kind !== 'tool-all' && token.kind !== 'cli_tool') + return null - if ((token.kind === 'tool' || token.kind === 'tool-all') && !getProviderToolFromToken(token, tools)) - return null - - return ( - <AgentPromptRosterReferenceIcon - token={token} - tools={tools} - getConfiguredToolIcon={getConfiguredToolIcon} - /> - ) - }, [getConfiguredToolIcon, tools]) + if ( + (token.kind === 'tool' || token.kind === 'tool-all') && + !getProviderToolFromToken(token, tools) + ) + return null + + return ( + <AgentPromptRosterReferenceIcon + token={token} + tools={tools} + getConfiguredToolIcon={getConfiguredToolIcon} + /> + ) + }, + [getConfiguredToolIcon, tools], + ) - const getRosterReferenceWarning = useCallback((token: RosterReferenceToken) => { - const warning = t($ => $['agentDetail.configure.prompt.referenceMissing'], { name: token.label }) + const getRosterReferenceWarning = useCallback( + (token: RosterReferenceToken) => { + const warning = t(($) => $['agentDetail.configure.prompt.referenceMissing'], { + name: token.label, + }) - if (token.kind === 'skill') - return configuredReferenceIds.skills.has(token.id) ? undefined : warning + if (token.kind === 'skill') + return configuredReferenceIds.skills.has(token.id) ? undefined : warning - if (token.kind === 'file') - return configuredReferenceIds.files.has(token.id) ? undefined : warning + if (token.kind === 'file') + return configuredReferenceIds.files.has(token.id) ? undefined : warning - if (token.kind === 'knowledge') - return configuredReferenceIds.knowledge.has(token.id) ? undefined : warning + if (token.kind === 'knowledge') + return configuredReferenceIds.knowledge.has(token.id) ? undefined : warning - if (token.kind === 'cli_tool') - return ENABLE_AGENT_CLI_TOOLS && configuredReferenceIds.cliTools.has(token.id) ? undefined : warning + if (token.kind === 'cli_tool') + return ENABLE_AGENT_CLI_TOOLS && configuredReferenceIds.cliTools.has(token.id) + ? undefined + : warning - if (token.kind === 'tool' || token.kind === 'tool-all') - return getProviderToolFromToken(token, tools) ? undefined : warning - }, [configuredReferenceIds, t, tools]) + if (token.kind === 'tool' || token.kind === 'tool-all') + return getProviderToolFromToken(token, tools) ? undefined : warning + }, + [configuredReferenceIds, t, tools], + ) useEffect(() => { - if (!isSlashMenuOpen) - return + if (!isSlashMenuOpen) return const rootElement = positioningRootRef.current - if (!rootElement) - return + if (!rootElement) return const ownerDocument = rootElement.ownerDocument const handlePointerDown = (event: PointerEvent) => { const target = event.target - if (!(target instanceof Node)) - return + if (!(target instanceof Node)) return - if ( - target instanceof Element - && target.closest('[data-agent-prompt-slash-menu]') - ) { + if (target instanceof Element && target.closest('[data-agent-prompt-slash-menu]')) { return } - if (!rootElement.contains(target)) - closeSlashMenu() + if (!rootElement.contains(target)) closeSlashMenu() } const handleFocusIn = (event: FocusEvent) => { const target = event.target - if (target instanceof Node && !rootElement.contains(target)) - closeSlashMenu() + if (target instanceof Node && !rootElement.contains(target)) closeSlashMenu() } ownerDocument.addEventListener('pointerdown', handlePointerDown) @@ -866,17 +845,14 @@ export function AgentPromptEditor() { }, [closeSlashMenu, isSlashMenuOpen]) useEffect(() => { - if (!isSlashMenuOpen) - return + if (!isSlashMenuOpen) return const menuElement = slashMenuElementRef.current - if (!menuElement) - return + if (!menuElement) return const handleKeyDown = (event: globalThis.KeyboardEvent) => { const activeElement = menuElement.ownerDocument.activeElement - if (!menuElement.contains(activeElement)) - return + if (!menuElement.contains(activeElement)) return if (event.key === 'Escape') { event.preventDefault() @@ -894,14 +870,16 @@ export function AgentPromptEditor() { } const menuItems = getSlashMenuItems() - if (!menuItems.length) - return + if (!menuItems.length) return - const currentIndex = menuItems.findIndex(item => item === activeElement) + const currentIndex = menuItems.findIndex((item) => item === activeElement) if (event.key === 'ArrowRight') { const currentItem = menuItems[currentIndex] - if (currentItem?.hasAttribute('data-agent-prompt-menu-category') || currentItem?.hasAttribute('aria-expanded')) { + if ( + currentItem?.hasAttribute('data-agent-prompt-menu-category') || + currentItem?.hasAttribute('aria-expanded') + ) { event.preventDefault() event.stopPropagation() activateSlashMenuItem(currentItem) @@ -910,7 +888,12 @@ export function AgentPromptEditor() { return } - if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End') + if ( + event.key !== 'ArrowDown' && + event.key !== 'ArrowUp' && + event.key !== 'Home' && + event.key !== 'End' + ) return event.preventDefault() @@ -929,9 +912,12 @@ export function AgentPromptEditor() { } const direction = event.key === 'ArrowDown' ? 1 : -1 - const nextIndex = currentIndex === -1 - ? direction === 1 ? 0 : menuItems.length - 1 - : (currentIndex + direction + menuItems.length) % menuItems.length + const nextIndex = + currentIndex === -1 + ? direction === 1 + ? 0 + : menuItems.length - 1 + : (currentIndex + direction + menuItems.length) % menuItems.length setActiveSlashMenuItem(menuItems, nextIndex) menuItems[nextIndex]?.focus() @@ -941,31 +927,39 @@ export function AgentPromptEditor() { return () => { menuElement.removeEventListener('keydown', handleKeyDown) } - }, [activateSlashMenuItem, closeSlashMenu, focusPromptEditor, getSlashMenuItems, isSlashMenuOpen, returnToSlashMenuMain, setActiveSlashMenuItem, slashMenuView]) + }, [ + activateSlashMenuItem, + closeSlashMenu, + focusPromptEditor, + getSlashMenuItems, + isSlashMenuOpen, + returnToSlashMenuMain, + setActiveSlashMenuItem, + slashMenuView, + ]) useLayoutEffect(() => { - if (!isSlashMenuOpen) - return + if (!isSlashMenuOpen) return const menuItems = getSlashMenuItems() - if (!menuItems.length) - return + if (!menuItems.length) return if (shouldKeepEditorFocusRef.current) { - const activeIndex = activeSlashMenuItemIndexRef.current < 0 - ? -1 - : Math.min(activeSlashMenuItemIndexRef.current, menuItems.length - 1) + const activeIndex = + activeSlashMenuItemIndexRef.current < 0 + ? -1 + : Math.min(activeSlashMenuItemIndexRef.current, menuItems.length - 1) setActiveSlashMenuItem(menuItems, activeIndex) return } - if (!shouldFocusSlashMenuRef.current && slashMenuView === 'main') - return + if (!shouldFocusSlashMenuRef.current && slashMenuView === 'main') return shouldFocusSlashMenuRef.current = true - const activeIndex = activeSlashMenuItemIndexRef.current < 0 - ? 0 - : Math.min(activeSlashMenuItemIndexRef.current, menuItems.length - 1) + const activeIndex = + activeSlashMenuItemIndexRef.current < 0 + ? 0 + : Math.min(activeSlashMenuItemIndexRef.current, menuItems.length - 1) setActiveSlashMenuItem(menuItems, activeIndex) menuItems[activeIndex]?.focus({ preventScroll: true }) }, [getSlashMenuItems, isSlashMenuOpen, setActiveSlashMenuItem, slashMenuView]) @@ -973,80 +967,84 @@ export function AgentPromptEditor() { const slashMenuCategories: SlashMenuCategory[] = [ { key: 'skills', - label: t($ => $['agentDetail.configure.skills.label']), + label: t(($) => $['agentDetail.configure.skills.label']), icon: 'i-ri-box-3-line', }, { key: 'files', - label: t($ => $['agentDetail.configure.files.label']), + label: t(($) => $['agentDetail.configure.files.label']), icon: 'i-ri-file-line', }, { key: 'tools', - label: t($ => $['agentDetail.configure.tools.label']), + label: t(($) => $['agentDetail.configure.tools.label']), icon: 'i-ri-box-3-line', }, { key: 'knowledge', - label: t($ => $['agentDetail.configure.knowledgeRetrieval.label']), + label: t(($) => $['agentDetail.configure.knowledgeRetrieval.label']), icon: 'i-ri-book-open-line', }, ] const handleOpenSlashMenuCategory = (view: Exclude<SlashMenuView, 'main'>) => { parentSlashMenuItemIndexRef.current = Math.max( 0, - slashMenuCategories.findIndex(category => category.key === view), + slashMenuCategories.findIndex((category) => category.key === view), ) activeSlashMenuItemIndexRef.current = 0 setSlashMenuView(view) } const slashMenuWidth = slashMenuView === 'main' ? slashMenuMainWidth : slashMenuSubmenuWidth - const slashMenu = !readOnly && isSlashMenuOpen - ? ( - <div - id={agentPromptSlashMenuId} - ref={slashMenuElementRef} - data-agent-prompt-slash-menu - role="dialog" - aria-label={t($ => $['agentDetail.configure.prompt.insert.label'])} - tabIndex={-1} - className="absolute z-30" - onPointerDownCapture={handleSlashMenuPointerDownCapture} - onMouseDownCapture={handleSlashMenuMouseDownCapture} - style={{ - left: slashMenuPosition ? `${getSlashMenuLeft(slashMenuPosition, slashMenuWidth)}px` : '12px', - top: slashMenuPosition ? `${slashMenuPosition.top}px` : '36px', - }} - > - <AgentPromptSlashMenu - view={slashMenuView} - categories={slashMenuCategories} - skills={skills} - files={files} - configuredTools={tools} - onAddProviderTools={addProviderTools} - onAddCliTool={addActions.cli} - onAddFile={addActions.files} - onAddKnowledge={addActions.knowledge} - onAddSkill={addActions.skills} - knowledgeRetrievals={retrievals} - onBack={returnToSlashMenuMain} - onOpenCategory={handleOpenSlashMenuCategory} - onInsertToken={handleSlashSelect} - /> - </div> - ) - : null + const slashMenu = + !readOnly && isSlashMenuOpen ? ( + <div + id={agentPromptSlashMenuId} + ref={slashMenuElementRef} + data-agent-prompt-slash-menu + role="dialog" + aria-label={t(($) => $['agentDetail.configure.prompt.insert.label'])} + tabIndex={-1} + className="absolute z-30" + onPointerDownCapture={handleSlashMenuPointerDownCapture} + onMouseDownCapture={handleSlashMenuMouseDownCapture} + style={{ + left: slashMenuPosition + ? `${getSlashMenuLeft(slashMenuPosition, slashMenuWidth)}px` + : '12px', + top: slashMenuPosition ? `${slashMenuPosition.top}px` : '36px', + }} + > + <AgentPromptSlashMenu + view={slashMenuView} + categories={slashMenuCategories} + skills={skills} + files={files} + configuredTools={tools} + onAddProviderTools={addProviderTools} + onAddCliTool={addActions.cli} + onAddFile={addActions.files} + onAddKnowledge={addActions.knowledge} + onAddSkill={addActions.skills} + knowledgeRetrievals={retrievals} + onBack={returnToSlashMenuMain} + onOpenCategory={handleOpenSlashMenuCategory} + onInsertToken={handleSlashSelect} + /> + </div> + ) : null return ( - <section className="flex flex-col gap-1 px-0 py-0" aria-labelledby="agent-configure-prompt-label"> + <section + className="flex flex-col gap-1 px-0 py-0" + aria-labelledby="agent-configure-prompt-label" + > <div className="flex items-center gap-2"> <div className="flex min-h-6 min-w-0 flex-1 items-center gap-0.5"> <h3 id="agent-configure-prompt-label" className="truncate system-sm-semibold-uppercase text-text-secondary" > - {t($ => $['agentDetail.configure.prompt.label'])} + {t(($) => $['agentDetail.configure.prompt.label'])} </h3> <Infotip aria-label={promptTip} popupClassName="max-w-64"> <AgentConfigureTipContent type="prompt" /> @@ -1054,19 +1052,28 @@ export function AgentPromptEditor() { </div> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" - aria-label={copied ? t($ => $['agentDetail.configure.prompt.copied']) : t($ => $['agentDetail.configure.prompt.copy'])} + aria-label={ + copied + ? t(($) => $['agentDetail.configure.prompt.copied']) + : t(($) => $['agentDetail.configure.prompt.copy']) + } className="flex size-6 shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={handleCopyPrompt} > - <span aria-hidden className={copied ? 'i-ri-check-line size-4' : 'i-ri-clipboard-line size-4'} /> + <span + aria-hidden + className={copied ? 'i-ri-check-line size-4' : 'i-ri-clipboard-line size-4'} + /> </button> - )} + } /> <TooltipContent> - {copied ? t($ => $['agentDetail.configure.prompt.copied']) : t($ => $['agentDetail.configure.prompt.copy'])} + {copied + ? t(($) => $['agentDetail.configure.prompt.copied']) + : t(($) => $['agentDetail.configure.prompt.copy'])} </TooltipContent> </Tooltip> </div> @@ -1129,7 +1136,7 @@ export function AgentPromptEditor() { onClick={handleInsertSlash} > <span aria-hidden className="i-ri-slash-commands-2 size-3.5" /> - {t($ => $['agentDetail.configure.prompt.insert.label'])} + {t(($) => $['agentDetail.configure.prompt.insert.label'])} </button> </div> <div className="rounded-sm border border-divider-regular bg-background-default px-1 system-2xs-regular text-text-tertiary"> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/option-menu.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/option-menu.tsx index fa75cd8c2fd291..bc70ebb6315deb 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/option-menu.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/option-menu.tsx @@ -27,7 +27,7 @@ export function AgentPromptOptionMenu({ return ( <DropdownMenu> <DropdownMenuTrigger - render={( + render={ <button type="button" className="flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" @@ -35,21 +35,17 @@ export function AgentPromptOptionMenu({ <span aria-hidden className={`${icon} size-3.5`} /> <span className="system-xs-medium">{label}</span> </button> - )} + } /> - <DropdownMenuContent - placement="bottom-start" - sideOffset={4} - popupClassName="w-60 p-1" - > - {options.map(option => ( + <DropdownMenuContent placement="bottom-start" sideOffset={4} popupClassName="w-60 p-1"> + {options.map((option) => ( <DropdownMenuItem key={option.key} className="gap-2" onClick={() => onInsert(option.token)} > <span aria-hidden className={`${option.icon} size-4 text-text-tertiary`} /> - <span className="min-w-0 flex-1 truncate">{t($ => $[option.labelKey])}</span> + <span className="min-w-0 flex-1 truncate">{t(($) => $[option.labelKey])}</span> <span className="code-xs-regular text-text-quaternary">{option.token}</span> </DropdownMenuItem> ))} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/options.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/options.ts index 79b29430504466..39a849e181415a 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/options.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/options.ts @@ -2,12 +2,12 @@ * @public */ // TODO: Remove this marker after prompt option menus are wired. -export type AgentPromptOptionLabelKey - = | 'agentDetail.configure.prompt.insert.tenders' - | 'agentDetail.configure.prompt.insert.question' - | 'agentDetail.configure.prompt.insert.reportFile' - | 'agentDetail.configure.prompt.mention.davidHayes' - | 'agentDetail.configure.prompt.mention.priyaRamanathan' +export type AgentPromptOptionLabelKey = + | 'agentDetail.configure.prompt.insert.tenders' + | 'agentDetail.configure.prompt.insert.question' + | 'agentDetail.configure.prompt.insert.reportFile' + | 'agentDetail.configure.prompt.mention.davidHayes' + | 'agentDetail.configure.prompt.mention.priyaRamanathan' /** * @public @@ -65,8 +65,7 @@ export const mentionOptions: InsertOption[] = [ ] const appendToken = (value: string, token: string) => { - if (!value) - return token + if (!value) return token return `${value}${value.endsWith(' ') || value.endsWith('\n') ? '' : ' '}${token}` } @@ -86,17 +85,19 @@ const hasTrailingSpace = (value: string) => value.endsWith(' ') || value.endsWit const hasLeadingSpace = (value: string) => value.startsWith(' ') || value.startsWith('\n') export const replaceTrailingSlashWithToken = (value: string, token: string) => { - if (!value.endsWith('/')) - return appendToken(value, token) + if (!value.endsWith('/')) return appendToken(value, token) const valueWithoutSlash = value.slice(0, -1) - if (!valueWithoutSlash) - return token + if (!valueWithoutSlash) return token return `${valueWithoutSlash}${hasTrailingSpace(valueWithoutSlash) ? '' : ' '}${token}` } -export const insertTokenAtTextRange = (value: string, range: TextRange, token: string): TokenInsertionResult => { +export const insertTokenAtTextRange = ( + value: string, + range: TextRange, + token: string, +): TokenInsertionResult => { const start = Math.max(0, Math.min(range.start, value.length)) const end = Math.max(start, Math.min(range.end, value.length)) const prefix = value.slice(0, start) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx index c1e3b5acb5046e..df6b4c5911b134 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx @@ -6,7 +6,12 @@ import type { AgentOrchestrateAddAction, AgentOrchestrateAddedItem } from '../ad import type { Tool } from '@/app/components/tools/types' import type { ToolTypeEnum, ToolValue } from '@/app/components/workflow/block-selector/types' import type { ToolWithProvider } from '@/app/components/workflow/types' -import type { AgentFileNode, AgentKnowledgeRetrievalItem, AgentSkill, AgentTool } from '@/features/agent-v2/agent-composer/form-state' +import type { + AgentFileNode, + AgentKnowledgeRetrievalItem, + AgentSkill, + AgentTool, +} from '@/features/agent-v2/agent-composer/form-state' import type { AgentProviderToolDefaultValue } from '@/features/agent-v2/agent-composer/store-modules/tools' import { cn } from '@langgenius/dify-ui/cn' import { FileTreeIcon } from '@langgenius/dify-ui/file-tree' @@ -57,29 +62,26 @@ const agentPromptSlashMenuItemProps = { 'data-agent-prompt-menu-item': '', } as const -const createReferenceToken = (kind: string, id: string, label?: string) => ( +const createReferenceToken = (kind: string, id: string, label?: string) => `[§${kind}:${id}${label ? `:${label}` : ''}§]` -) -const createConfigReferenceToken = (kind: 'skill' | 'file', name: string, label: string) => ( +const createConfigReferenceToken = (kind: 'skill' | 'file', name: string, label: string) => createReferenceToken(kind, name, label) -) -const isPromptReferenceItem = (item: AgentOrchestrateAddedItem): item is AgentFileNode | AgentSkill => ( - 'id' in item && 'name' in item -) +const isPromptReferenceItem = ( + item: AgentOrchestrateAddedItem, +): item is AgentFileNode | AgentSkill => 'id' in item && 'name' in item -const isAgentFileNode = (item: AgentOrchestrateAddedItem): item is AgentFileNode => ( - 'icon' in item -) +const isAgentFileNode = (item: AgentOrchestrateAddedItem): item is AgentFileNode => 'icon' in item -const isCliToolItem = (item: AgentOrchestrateAddedItem): item is Extract<AgentTool, { kind: 'cli' }> => ( - 'kind' in item && item.kind === 'cli' -) +const isCliToolItem = ( + item: AgentOrchestrateAddedItem, +): item is Extract<AgentTool, { kind: 'cli' }> => 'kind' in item && item.kind === 'cli' -const isKnowledgeRetrievalItem = (item: AgentOrchestrateAddedItem): item is AgentKnowledgeRetrievalItem => ( +const isKnowledgeRetrievalItem = ( + item: AgentOrchestrateAddedItem, +): item is AgentKnowledgeRetrievalItem => 'queryMode' in item || 'customQuery' in item || 'selectedDatasets' in item || 'nameKey' in item -) export function AgentPromptSlashMenu({ view, @@ -98,7 +100,7 @@ export function AgentPromptSlashMenu({ onInsertToken, }: AgentPromptSlashMenuProps) { const { t } = useTranslation('agentV2') - const title = categories.find(category => category.key === view)?.label + const title = categories.find((category) => category.key === view)?.label const handleAddFromFooter = () => { if (view === 'skills') { onAddSkill?.({ @@ -124,7 +126,9 @@ export function AgentPromptSlashMenu({ onAddKnowledge?.({ onAdded: (item) => { if (isKnowledgeRetrievalItem(item)) - onInsertToken(createReferenceToken('knowledge', item.id, getKnowledgeRetrievalName(item, t))) + onInsertToken( + createReferenceToken('knowledge', item.id, getKnowledgeRetrievalName(item, t)), + ) }, }) } @@ -134,7 +138,7 @@ export function AgentPromptSlashMenu({ return ( <AgentPromptSlashPanel className="w-[200px]"> <div className="flex flex-col gap-px p-1"> - {categories.map(category => ( + {categories.map((category) => ( <button key={category.key} type="button" @@ -143,9 +147,17 @@ export function AgentPromptSlashMenu({ className="flex h-6 w-full items-center gap-1 rounded-md pr-2 pl-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden data-[agent-prompt-menu-active]:bg-state-base-hover" onClick={() => onOpenCategory(category.key)} > - <span aria-hidden className={`${category.icon} size-4 shrink-0 text-text-secondary`} /> - <span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary">{category.label}</span> - <span aria-hidden className="i-ri-arrow-right-s-line size-3.5 shrink-0 text-text-tertiary" /> + <span + aria-hidden + className={`${category.icon} size-4 shrink-0 text-text-secondary`} + /> + <span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary"> + {category.label} + </span> + <span + aria-hidden + className="i-ri-arrow-right-s-line size-3.5 shrink-0 text-text-tertiary" + /> </button> ))} </div> @@ -169,9 +181,7 @@ export function AgentPromptSlashMenu({ {view === 'skills' && ( <AgentPromptSkillRows skills={skills} onInsertToken={onInsertToken} /> )} - {view === 'files' && ( - <AgentPromptFileRows files={files} onInsertToken={onInsertToken} /> - )} + {view === 'files' && <AgentPromptFileRows files={files} onInsertToken={onInsertToken} />} {view === 'tools' && ( <AgentPromptToolRows configuredTools={configuredTools} @@ -180,41 +190,44 @@ export function AgentPromptSlashMenu({ /> )} {view === 'knowledge' && ( - <AgentPromptKnowledgeRows knowledgeRetrievals={knowledgeRetrievals} onInsertToken={onInsertToken} /> + <AgentPromptKnowledgeRows + knowledgeRetrievals={knowledgeRetrievals} + onInsertToken={onInsertToken} + /> )} </div> - {view === 'tools' - ? ( - <AgentPromptToolFooter - onAddCliTool={ENABLE_AGENT_CLI_TOOLS - ? () => { - onAddCliTool?.({ - onAdded: (item) => { - if (isCliToolItem(item)) - onInsertToken(createReferenceToken('cli_tool', item.id, item.name)) - }, - }) - } - : undefined} - /> - ) - : ( - <div className="border-t border-divider-subtle p-1"> - <button - type="button" - {...agentPromptSlashMenuItemProps} - className="flex h-6 w-full items-center gap-1 rounded-md pr-2 pl-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden data-[agent-prompt-menu-active]:bg-state-base-hover" - onClick={handleAddFromFooter} - > - <span aria-hidden className="i-ri-add-line size-4 shrink-0 text-text-secondary" /> - <span className="system-sm-regular text-text-secondary"> - {view === 'skills' && t($ => $['agentDetail.configure.skills.add'])} - {view === 'files' && t($ => $['agentDetail.configure.files.add'])} - {view === 'knowledge' && t($ => $['agentDetail.configure.knowledgeRetrieval.add'])} - </span> - </button> - </div> - )} + {view === 'tools' ? ( + <AgentPromptToolFooter + onAddCliTool={ + ENABLE_AGENT_CLI_TOOLS + ? () => { + onAddCliTool?.({ + onAdded: (item) => { + if (isCliToolItem(item)) + onInsertToken(createReferenceToken('cli_tool', item.id, item.name)) + }, + }) + } + : undefined + } + /> + ) : ( + <div className="border-t border-divider-subtle p-1"> + <button + type="button" + {...agentPromptSlashMenuItemProps} + className="flex h-6 w-full items-center gap-1 rounded-md pr-2 pl-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden data-[agent-prompt-menu-active]:bg-state-base-hover" + onClick={handleAddFromFooter} + > + <span aria-hidden className="i-ri-add-line size-4 shrink-0 text-text-secondary" /> + <span className="system-sm-regular text-text-secondary"> + {view === 'skills' && t(($) => $['agentDetail.configure.skills.add'])} + {view === 'files' && t(($) => $['agentDetail.configure.files.add'])} + {view === 'knowledge' && t(($) => $['agentDetail.configure.knowledgeRetrieval.add'])} + </span> + </button> + </div> + )} </AgentPromptSlashPanel> ) } @@ -227,7 +240,9 @@ function AgentPromptSlashPanel({ children: ReactNode }) { return ( - <div className={`${className} isolate overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]`}> + <div + className={`${className} isolate overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]`} + > {children} </div> ) @@ -242,7 +257,7 @@ function AgentPromptSkillRows({ }) { return ( <> - {skills.map(skill => ( + {skills.map((skill) => ( <AgentPromptSubmenuRow key={skill.id} icon="i-ri-box-3-line" @@ -265,7 +280,7 @@ function AgentPromptFileRows({ }) { return ( <> - {files.map(file => ( + {files.map((file) => ( <div key={file.id}> <AgentPromptSubmenuRow icon={<FileTreeIcon type={file.children?.length ? 'folder' : file.icon} />} @@ -274,11 +289,17 @@ function AgentPromptFileRows({ hasChildren={!!file.children?.length} onClick={() => { if (!file.children?.length) - onInsertToken(createConfigReferenceToken('file', file.configName ?? file.id, file.name)) + onInsertToken( + createConfigReferenceToken('file', file.configName ?? file.id, file.name), + ) }} /> {!!file.children?.length && ( - <AgentPromptFileRows files={file.children} depth={depth + 1} onInsertToken={onInsertToken} /> + <AgentPromptFileRows + files={file.children} + depth={depth + 1} + onInsertToken={onInsertToken} + /> )} </div> ))} @@ -297,10 +318,7 @@ function AgentPromptToolRows({ }) { const { t } = useTranslation('agentV2') const language = useGetLanguage() - const { - getProviderIcon, - getProviderIcons, - } = useAgentPromptToolIconResolver() + const { getProviderIcon, getProviderIcons } = useAgentPromptToolIconResolver() const [activeTab, setActiveTab] = useState<ToolPromptTab>('all') const [expandedProviderIds, setExpandedProviderIds] = useState<Set<string>>(() => new Set()) const { data: builtInTools = [] } = useAllBuiltInTools() @@ -308,32 +326,36 @@ function AgentPromptToolRows({ const { data: workflowTools = [] } = useAllWorkflowTools() const { data: mcpTools = [] } = useAllMCPTools() const configuredCliTools = ENABLE_AGENT_CLI_TOOLS - ? configuredTools.filter(tool => tool.kind === 'cli') + ? configuredTools.filter((tool) => tool.kind === 'cli') : [] const availableProviders = useMemo(() => { - if (activeTab === 'all') - return [...builtInTools, ...workflowTools, ...customTools, ...mcpTools] - if (activeTab === ToolTabEnum.BuiltIn) - return builtInTools - if (activeTab === ToolTabEnum.Workflow) - return workflowTools - if (activeTab === ToolTabEnum.Custom) - return customTools - if (activeTab === ToolTabEnum.MCP) - return mcpTools + if (activeTab === 'all') return [...builtInTools, ...workflowTools, ...customTools, ...mcpTools] + if (activeTab === ToolTabEnum.BuiltIn) return builtInTools + if (activeTab === ToolTabEnum.Workflow) return workflowTools + if (activeTab === ToolTabEnum.Custom) return customTools + if (activeTab === ToolTabEnum.MCP) return mcpTools return [] }, [activeTab, builtInTools, customTools, mcpTools, workflowTools]) - const selectedTools = useMemo(() => configuredTools.flatMap(toSelectedToolValue), [configuredTools]) + const selectedTools = useMemo( + () => configuredTools.flatMap(toSelectedToolValue), + [configuredTools], + ) const tabs = [ - { key: 'all' as const, label: t($ => $['agentDetail.configure.tools.toolTabs.all']) }, - { key: ToolTabEnum.BuiltIn, label: t($ => $['agentDetail.configure.tools.toolTabs.plugins']) }, - { key: ToolTabEnum.Workflow, label: t($ => $['agentDetail.configure.tools.toolTabs.workflow']) }, - { key: ToolTabEnum.Custom, label: t($ => $['agentDetail.configure.tools.toolTabs.custom']) }, - { key: ToolTabEnum.MCP, label: t($ => $['agentDetail.configure.tools.toolTabs.mcp']) }, + { key: 'all' as const, label: t(($) => $['agentDetail.configure.tools.toolTabs.all']) }, + { + key: ToolTabEnum.BuiltIn, + label: t(($) => $['agentDetail.configure.tools.toolTabs.plugins']), + }, + { + key: ToolTabEnum.Workflow, + label: t(($) => $['agentDetail.configure.tools.toolTabs.workflow']), + }, + { key: ToolTabEnum.Custom, label: t(($) => $['agentDetail.configure.tools.toolTabs.custom']) }, + { key: ToolTabEnum.MCP, label: t(($) => $['agentDetail.configure.tools.toolTabs.mcp']) }, ...(ENABLE_AGENT_CLI_TOOLS - ? [{ key: 'cli' as const, label: t($ => $['agentDetail.configure.tools.toolTabs.cli']) }] + ? [{ key: 'cli' as const, label: t(($) => $['agentDetail.configure.tools.toolTabs.cli']) }] : []), ] @@ -344,10 +366,8 @@ function AgentPromptToolRows({ const toggleProvider = (providerId: string) => { setExpandedProviderIds((currentIds) => { const nextIds = new Set(currentIds) - if (nextIds.has(providerId)) - nextIds.delete(providerId) - else - nextIds.add(providerId) + if (nextIds.has(providerId)) nextIds.delete(providerId) + else nextIds.add(providerId) return nextIds }) @@ -355,21 +375,27 @@ function AgentPromptToolRows({ const handleSelectProvider = (provider: ToolWithProvider) => { const { icon, iconDark } = getProviderIcons(provider) - selectTools(provider.tools.map(tool => toToolDefaultValue(provider, tool, language, icon, iconDark))) - onInsertToken(createReferenceToken('tool', `${provider.id}/*`, getProviderLabel(provider, language))) + selectTools( + provider.tools.map((tool) => toToolDefaultValue(provider, tool, language, icon, iconDark)), + ) + onInsertToken( + createReferenceToken('tool', `${provider.id}/*`, getProviderLabel(provider, language)), + ) } const handleSelectTool = (provider: ToolWithProvider, tool: Tool) => { const { icon, iconDark } = getProviderIcons(provider) const selectedTool = toToolDefaultValue(provider, tool, language, icon, iconDark) selectTools([selectedTool]) - onInsertToken(createReferenceToken('tool', `${provider.id}/${tool.name}`, selectedTool.tool_label)) + onInsertToken( + createReferenceToken('tool', `${provider.id}/${tool.name}`, selectedTool.tool_label), + ) } return ( <div> <div className="flex gap-1 px-2 py-1"> - {tabs.map(tab => ( + {tabs.map((tab) => ( <button key={tab.key} type="button" @@ -386,35 +412,38 @@ function AgentPromptToolRows({ </div> <div className="max-h-[464px] overflow-y-auto px-1 pb-1"> {activeTab === 'cli' - ? configuredCliTools.map(tool => ( + ? configuredCliTools.map((tool) => ( <AgentPromptCliToolRow key={tool.id} tool={tool} onClick={() => onInsertToken(createReferenceToken('cli_tool', tool.id, tool.name))} /> )) - : availableProviders.filter(provider => provider.tools.length > 0).map(provider => ( - <div key={provider.id}> - <AgentPromptProviderToolRow - provider={provider} - typeLabel={getProviderTypeLabel(provider, t)} - selectedTools={selectedTools} - language={language} - isExpanded={expandedProviderIds.has(provider.id)} - getProviderIcon={getProviderIcon} - onClick={() => handleSelectProvider(provider)} - onToggle={() => toggleProvider(provider.id)} - /> - {expandedProviderIds.has(provider.id) && provider.tools.map(tool => ( - <AgentPromptProviderToolActionRow - key={tool.name} - tool={tool} + : availableProviders + .filter((provider) => provider.tools.length > 0) + .map((provider) => ( + <div key={provider.id}> + <AgentPromptProviderToolRow + provider={provider} + typeLabel={getProviderTypeLabel(provider, t)} + selectedTools={selectedTools} language={language} - onClick={() => handleSelectTool(provider, tool)} + isExpanded={expandedProviderIds.has(provider.id)} + getProviderIcon={getProviderIcon} + onClick={() => handleSelectProvider(provider)} + onToggle={() => toggleProvider(provider.id)} /> - ))} - </div> - ))} + {expandedProviderIds.has(provider.id) && + provider.tools.map((tool) => ( + <AgentPromptProviderToolActionRow + key={tool.name} + tool={tool} + language={language} + onClick={() => handleSelectTool(provider, tool)} + /> + ))} + </div> + ))} </div> </div> ) @@ -423,22 +452,22 @@ function AgentPromptToolRows({ type ToolPromptTab = ToolTypeEnum | 'all' | 'cli' function getLocalizedText(text: Record<string, string> | undefined | null, language: string) { - if (!text) - return '' - - return text[language] - ?? text['en-US'] - ?? text.en_US - ?? text.zh_Hans - ?? Object.values(text).find(Boolean) - ?? '' + if (!text) return '' + + return ( + text[language] ?? + text['en-US'] ?? + text.en_US ?? + text.zh_Hans ?? + Object.values(text).find(Boolean) ?? + '' + ) } function toSelectedToolValue(tool: AgentTool): ToolValue[] { - if (tool.kind !== 'provider') - return [] + if (tool.kind !== 'provider') return [] - return tool.actions.map(action => ({ + return tool.actions.map((action) => ({ provider_name: tool.id, tool_name: action.toolName, tool_label: action.name, @@ -485,9 +514,11 @@ function toToolDefaultValue( } function isToolSelected(selectedTools: ToolValue[], provider: ToolWithProvider, tool: Tool) { - return selectedTools.some(selectedTool => - (selectedTool.provider_name === provider.name || selectedTool.provider_name === provider.id) - && selectedTool.tool_name === tool.name, + return selectedTools.some( + (selectedTool) => + (selectedTool.provider_name === provider.name || + selectedTool.provider_name === provider.id) && + selectedTool.tool_name === tool.name, ) } @@ -495,18 +526,15 @@ function getProviderLabel(provider: ToolWithProvider, language: string) { return getLocalizedText(provider.label, language) || provider.name } -function getProviderTypeLabel( - provider: ToolWithProvider, - t: TFunction<'agentV2'>, -) { +function getProviderTypeLabel(provider: ToolWithProvider, t: TFunction<'agentV2'>) { if (provider.type === CollectionType.workflow) - return t($ => $['agentDetail.configure.tools.toolTabs.workflow']) + return t(($) => $['agentDetail.configure.tools.toolTabs.workflow']) if (provider.type === CollectionType.custom) - return t($ => $['agentDetail.configure.tools.toolTabs.custom']) + return t(($) => $['agentDetail.configure.tools.toolTabs.custom']) if (provider.type === CollectionType.mcp) - return t($ => $['agentDetail.configure.tools.toolTabs.mcp']) + return t(($) => $['agentDetail.configure.tools.toolTabs.mcp']) - return t($ => $['agentDetail.configure.tools.toolTabs.plugins']) + return t(($) => $['agentDetail.configure.tools.toolTabs.plugins']) } function AgentPromptProviderToolRow({ @@ -528,7 +556,9 @@ function AgentPromptProviderToolRow({ onClick: () => void onToggle: () => void }) { - const selectedToolsCount = provider.tools.filter(tool => isToolSelected(selectedTools, provider, tool)).length + const selectedToolsCount = provider.tools.filter((tool) => + isToolSelected(selectedTools, provider, tool), + ).length const providerLabel = getProviderLabel(provider, language) return ( @@ -541,7 +571,9 @@ function AgentPromptProviderToolRow({ > <AgentPromptProviderIcon provider={provider} getProviderIcon={getProviderIcon} /> <span className="flex min-w-0 flex-1 items-center"> - <span className="min-w-0 truncate system-sm-regular text-text-secondary">{providerLabel}</span> + <span className="min-w-0 truncate system-sm-regular text-text-secondary"> + {providerLabel} + </span> {selectedToolsCount > 0 && selectedToolsCount < provider.tools.length && ( <span className="ml-1.5 shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> {selectedToolsCount} @@ -558,7 +590,10 @@ function AgentPromptProviderToolRow({ className="flex size-7 shrink-0 items-center justify-center rounded-r-md text-text-tertiary group-hover:bg-state-base-hover hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden data-[agent-prompt-menu-active]:bg-state-base-hover" onClick={onToggle} > - <span aria-hidden className={`${isExpanded ? 'i-ri-arrow-down-s-line' : 'i-ri-arrow-right-s-line'} size-4`} /> + <span + aria-hidden + className={`${isExpanded ? 'i-ri-arrow-down-s-line' : 'i-ri-arrow-right-s-line'} size-4`} + /> </button> </div> ) @@ -573,21 +608,10 @@ function AgentPromptProviderIcon({ }) { const icon = getProviderIcon(provider) - return ( - <BlockIcon - className="shrink-0" - type={BlockEnum.Tool} - size="sm" - toolIcon={icon} - /> - ) + return <BlockIcon className="shrink-0" type={BlockEnum.Tool} size="sm" toolIcon={icon} /> } -function AgentPromptToolFooter({ - onAddCliTool, -}: { - onAddCliTool?: () => void -}) { +function AgentPromptToolFooter({ onAddCliTool }: { onAddCliTool?: () => void }) { const { t } = useTranslation() return ( @@ -600,7 +624,9 @@ function AgentPromptToolFooter({ className="flex h-7 w-full items-center gap-1.5 rounded-md px-2 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden data-[agent-prompt-menu-active]:bg-state-base-hover" > <span aria-hidden className="i-ri-store-2-line size-4 shrink-0 text-text-secondary" /> - <span className="system-sm-regular text-text-secondary">{t($ => $.findMoreInMarketplace, { ns: 'plugin' })}</span> + <span className="system-sm-regular text-text-secondary"> + {t(($) => $.findMoreInMarketplace, { ns: 'plugin' })} + </span> </a> {onAddCliTool && ( <button @@ -610,7 +636,9 @@ function AgentPromptToolFooter({ onClick={onAddCliTool} > <span aria-hidden className="i-ri-add-line size-4 shrink-0 text-text-secondary" /> - <span className="system-sm-regular text-text-secondary">{t($ => $['agentDetail.configure.tools.cliDialog.title'], { ns: 'agentV2' })}</span> + <span className="system-sm-regular text-text-secondary"> + {t(($) => $['agentDetail.configure.tools.cliDialog.title'], { ns: 'agentV2' })} + </span> </button> )} </div> @@ -663,9 +691,11 @@ function AgentPromptCliToolRow({ <span className="flex size-5 shrink-0 items-center justify-center rounded-md border-[0.5px] border-effects-icon-border bg-background-default-dodge"> <span aria-hidden className="i-ri-terminal-box-line size-3.5 text-text-tertiary" /> </span> - <span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary">{tool.name}</span> + <span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary"> + {tool.name} + </span> <span className="shrink-0 system-xs-regular text-text-quaternary"> - {t($ => $['agentDetail.configure.tools.toolTabs.cli'])} + {t(($) => $['agentDetail.configure.tools.toolTabs.cli'])} </span> </span> </button> @@ -683,12 +713,20 @@ function AgentPromptKnowledgeRows({ return ( <> - {knowledgeRetrievals.map(retrieval => ( + {knowledgeRetrievals.map((retrieval) => ( <AgentPromptSubmenuRow key={retrieval.id} icon="i-ri-book-open-line" label={getKnowledgeRetrievalName(retrieval, t)} - onClick={() => onInsertToken(createReferenceToken('knowledge', retrieval.id, getKnowledgeRetrievalName(retrieval, t)))} + onClick={() => + onInsertToken( + createReferenceToken( + 'knowledge', + retrieval.id, + getKnowledgeRetrievalName(retrieval, t), + ), + ) + } /> ))} </> @@ -700,7 +738,7 @@ function getKnowledgeRetrievalName( t: TFunction<'agentV2'>, ) { const nameKey = retrieval.nameKey - return retrieval.name ?? (nameKey ? t($ => $[nameKey]) : retrieval.id) + return retrieval.name ?? (nameKey ? t(($) => $[nameKey]) : retrieval.id) } function AgentPromptSubmenuRow({ @@ -726,11 +764,20 @@ function AgentPromptSubmenuRow({ onClick={onClick} > <span className={`flex min-w-0 flex-1 items-center gap-1 ${indent} pr-2`}> - {typeof icon === 'string' && <span aria-hidden className={`${icon} size-4 shrink-0 text-text-secondary`} />} + {typeof icon === 'string' && ( + <span aria-hidden className={`${icon} size-4 shrink-0 text-text-secondary`} /> + )} {typeof icon !== 'string' && icon} - <span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary">{label}</span> + <span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary"> + {label} + </span> </span> - {hasChildren && <span aria-hidden className="mr-1.5 i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" />} + {hasChildren && ( + <span + aria-hidden + className="mr-1.5 i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" + /> + )} </button> ) } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx index c2c8fcb957858f..78ea268048df1a 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx @@ -1,6 +1,10 @@ 'use client' -import type { AgentConfigSnapshotSummaryResponse, AgentReferencingWorkflowResponse, AgentReferencingWorkflowsResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentConfigSnapshotSummaryResponse, + AgentReferencingWorkflowResponse, + AgentReferencingWorkflowsResponse, +} from '@dify/contracts/api/console/agent/types.gen' import type { RegisterableHotkey } from '@tanstack/react-hotkeys' import { Button } from '@langgenius/dify-ui/button' import { Collapsible, CollapsiblePanel } from '@langgenius/dify-ui/collapsible' @@ -12,7 +16,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { hasAgentComposerUnpublishedChangesAtom, isAgentComposerDirtyAtom } from '@/features/agent-v2/agent-composer/store' +import { + hasAgentComposerUnpublishedChangesAtom, + isAgentComposerDirtyAtom, +} from '@/features/agent-v2/agent-composer/store' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import useTimestamp from '@/hooks/use-timestamp' import { consoleQuery } from '@/service/client' @@ -22,8 +29,9 @@ const PUBLISH_AGENT_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey type AgentConfigurePublishState = 'draft' | 'publishing' | 'published' | 'unpublished' -type PublishBarMode = { status: 'compact' } - | { status: 'confirmingImpact', references: AgentReferencingWorkflowResponse[] } +type PublishBarMode = + | { status: 'compact' } + | { status: 'confirmingImpact'; references: AgentReferencingWorkflowResponse[] } type AgentConfigurePublishBarProps = { agentId: string @@ -52,23 +60,17 @@ function getPublishState({ hasUnpublishedChanges: boolean isPublishing: boolean }): AgentConfigurePublishState { - if (isPublishing) - return 'publishing' + if (isPublishing) return 'publishing' - if (hasLocalChanges) - return 'unpublished' + if (hasLocalChanges) return 'unpublished' - if (activeConfigIsPublished) - return 'published' + if (activeConfigIsPublished) return 'published' - if (hasUnpublishedChanges) - return 'unpublished' + if (hasUnpublishedChanges) return 'unpublished' - if (!activeConfigSnapshot) - return 'draft' + if (!activeConfigSnapshot) return 'draft' - if (!activeConfigIsPublished) - return 'unpublished' + if (!activeConfigIsPublished) return 'unpublished' return 'published' } @@ -76,8 +78,10 @@ function getPublishState({ function PublishShortcut() { return ( <KbdGroup aria-hidden> - {PUBLISH_AGENT_HOTKEY.split('+').map(key => ( - <Kbd key={key} color="white">{formatForDisplay(key)}</Kbd> + {PUBLISH_AGENT_HOTKEY.split('+').map((key) => ( + <Kbd key={key} color="white"> + {formatForDisplay(key)} + </Kbd> ))} </KbdGroup> ) @@ -102,11 +106,10 @@ export function AgentConfigurePublishBar({ const queryClient = useQueryClient() const [publishBarMode, setPublishBarMode] = useState<PublishBarMode>({ status: 'compact' }) const lastKnownPublishedRef = useRef(false) - if (activeConfigIsPublished === true) - lastKnownPublishedRef.current = true - if (activeConfigIsPublished === false) - lastKnownPublishedRef.current = false - const stableActiveConfigIsPublished = activeConfigIsPublished ?? (lastKnownPublishedRef.current ? true : undefined) + if (activeConfigIsPublished === true) lastKnownPublishedRef.current = true + if (activeConfigIsPublished === false) lastKnownPublishedRef.current = false + const stableActiveConfigIsPublished = + activeConfigIsPublished ?? (lastKnownPublishedRef.current ? true : undefined) const hasUnpublishedChanges = useAtomValue(hasAgentComposerUnpublishedChangesAtom) const hasLocalChanges = useAtomValue(isAgentComposerDirtyAtom) const publishableState = getPublishState({ @@ -123,80 +126,90 @@ export function AgentConfigurePublishBar({ hasUnpublishedChanges, isPublishing, }) - const publishIsAvailable = !isPublishing && (publishableState === 'draft' || publishableState === 'unpublished') - const workflowReferencesQueryOptions = consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({ - input: { - params: { - agent_id: agentId, + const publishIsAvailable = + !isPublishing && (publishableState === 'draft' || publishableState === 'unpublished') + const workflowReferencesQueryOptions = + consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, }, - }, - enabled: workflowReferencesEnabled && publishIsAvailable && !selectedVersionSnapshot, - }) + enabled: workflowReferencesEnabled && publishIsAvailable && !selectedVersionSnapshot, + }) const workflowReferencesQuery = useQuery(workflowReferencesQueryOptions) - const restoreVersionMutation = useMutation(consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions()) + const restoreVersionMutation = useMutation( + consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions(), + ) const canPublish = publishIsAvailable const handleRestoreVersion = (versionId: string) => { - if (restoreVersionMutation.isPending) - return + if (restoreVersionMutation.isPending) return - restoreVersionMutation.mutate({ - params: { - agent_id: agentId, - version_id: versionId, + restoreVersionMutation.mutate( + { + params: { + agent_id: agentId, + version_id: versionId, + }, }, - }, { - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: consoleQuery.agent.byAgentId.get.queryKey({ - input: { - params: { - agent_id: agentId, + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: consoleQuery.agent.byAgentId.get.queryKey({ + input: { + params: { + agent_id: agentId, + }, }, - }, - }), - }) - void queryClient.invalidateQueries({ - queryKey: consoleQuery.agent.byAgentId.composer.get.queryKey({ - input: { - params: { - agent_id: agentId, + }), + }) + void queryClient.invalidateQueries({ + queryKey: consoleQuery.agent.byAgentId.composer.get.queryKey({ + input: { + params: { + agent_id: agentId, + }, }, - }, - }), - }) - void queryClient.invalidateQueries({ - queryKey: consoleQuery.agent.byAgentId.versions.get.key(), - }) - onExitVersions?.() - toast.success(tCommon($ => $['api.actionSuccess'])) + }), + }) + void queryClient.invalidateQueries({ + queryKey: consoleQuery.agent.byAgentId.versions.get.key(), + }) + onExitVersions?.() + toast.success(tCommon(($) => $['api.actionSuccess'])) + }, + onError: () => { + toast.error(tCommon(($) => $['api.actionFailed'])) + }, }, - onError: () => { - toast.error(tCommon($ => $['api.actionFailed'])) - }, - }) + ) } const handlePublish = async () => { - if (!canPublish) - return + if (!canPublish) return await onPublish?.() setPublishBarMode({ status: 'compact' }) } const handlePublishRequest = async () => { - if (!canPublish) - return + if (!canPublish) return if (publishBarMode.status === 'confirmingImpact') { await handlePublish() return } - const cachedReferences = queryClient.getQueryData<AgentReferencingWorkflowsResponse>(workflowReferencesQueryOptions.queryKey) + const cachedReferences = queryClient.getQueryData<AgentReferencingWorkflowsResponse>( + workflowReferencesQueryOptions.queryKey, + ) const references = workflowReferencesEnabled - ? (cachedReferences ?? workflowReferencesQuery.data ?? await queryClient.ensureQueryData(workflowReferencesQueryOptions))?.data ?? [] + ? (( + cachedReferences ?? + workflowReferencesQuery.data ?? + (await queryClient.ensureQueryData(workflowReferencesQueryOptions)) + )?.data ?? []) : [] if (references.length > 0) { @@ -207,13 +220,17 @@ export function AgentConfigurePublishBar({ await handlePublish() } - useHotkey(PUBLISH_AGENT_HOTKEY, (event) => { - event.preventDefault() - void handlePublishRequest() - }, { - enabled: canPublish && !selectedVersionSnapshot, - ignoreInputs: false, - }) + useHotkey( + PUBLISH_AGENT_HOTKEY, + (event) => { + event.preventDefault() + void handlePublishRequest() + }, + { + enabled: canPublish && !selectedVersionSnapshot, + ignoreInputs: false, + }, + ) if (selectedVersionSnapshot) { return ( @@ -227,59 +244,64 @@ export function AgentConfigurePublishBar({ } const publishedMeta = activeConfigSnapshot?.created_at - ? t($ => $['agentDetail.configure.publishBar.publishedAt'], { + ? t(($) => $['agentDetail.configure.publishBar.publishedAt'], { time: formatTimeFromNow(activeConfigSnapshot.created_at * 1000), }) - : t($ => $['agentDetail.configure.publishBar.published']) + : t(($) => $['agentDetail.configure.publishBar.published']) const savedMeta = draftSavedAt - ? t($ => $['agentDetail.configure.publishBar.savedAt'], { + ? t(($) => $['agentDetail.configure.publishBar.savedAt'], { time: formatTimeFromNow(draftSavedAt), }) - : t($ => $['agentDetail.configure.publishBar.saved']) + : t(($) => $['agentDetail.configure.publishBar.saved']) const stateMeta = { draft: { actionIcon: null, - actionLabel: t($ => $['agentDetail.publish']), + actionLabel: t(($) => $['agentDetail.publish']), dotStatus: 'disabled', metaLabel: savedMeta, showShortcut: true, - statusLabel: t($ => $['agentDetail.configure.publishBar.draft']), + statusLabel: t(($) => $['agentDetail.configure.publishBar.draft']), }, publishing: { actionIcon: null, - actionLabel: t($ => $['agentDetail.configure.publishBar.publishing']), + actionLabel: t(($) => $['agentDetail.configure.publishBar.publishing']), dotStatus: 'disabled', metaLabel: savedMeta, showShortcut: false, - statusLabel: t($ => $['agentDetail.configure.publishBar.draft']), + statusLabel: t(($) => $['agentDetail.configure.publishBar.draft']), }, published: { actionIcon: 'i-ri-check-line', - actionLabel: t($ => $['agentDetail.configure.publishBar.published']), + actionLabel: t(($) => $['agentDetail.configure.publishBar.published']), dotStatus: 'success', metaLabel: publishedMeta, showShortcut: false, - statusLabel: t($ => $['agentDetail.configure.publishBar.upToDate']), + statusLabel: t(($) => $['agentDetail.configure.publishBar.upToDate']), }, unpublished: { actionIcon: null, - actionLabel: t($ => $['agentDetail.configure.publishBar.publishUpdate']), + actionLabel: t(($) => $['agentDetail.configure.publishBar.publishUpdate']), dotStatus: 'warning', metaLabel: savedMeta, showShortcut: true, - statusLabel: t($ => $['agentDetail.configure.publishBar.unpublishedChanges']), + statusLabel: t(($) => $['agentDetail.configure.publishBar.unpublishedChanges']), }, - } satisfies Record<AgentConfigurePublishState, { - actionIcon: string | null - actionLabel: string - dotStatus: 'disabled' | 'success' | 'warning' - metaLabel: string - showShortcut: boolean - statusLabel: string - }> + } satisfies Record< + AgentConfigurePublishState, + { + actionIcon: string | null + actionLabel: string + dotStatus: 'disabled' | 'success' | 'warning' + metaLabel: string + showShortcut: boolean + statusLabel: string + } + > const currentStateMeta = stateMeta[publishState] - const isConfirmingImpact = publishBarMode.status === 'confirmingImpact' && (canPublish || isPublishing) - const impactReferences = publishBarMode.status === 'confirmingImpact' ? publishBarMode.references : [] + const isConfirmingImpact = + publishBarMode.status === 'confirmingImpact' && (canPublish || isPublishing) + const impactReferences = + publishBarMode.status === 'confirmingImpact' ? publishBarMode.references : [] return ( <Collapsible @@ -348,14 +370,14 @@ function PublishBarActions({ <StatusDot size="small" status={dotStatus} /> </span> <span className="shrink-0">{statusLabel}</span> - <span aria-hidden className="shrink-0">·</span> - <span className="min-w-0 truncate"> - {metaLabel} + <span aria-hidden className="shrink-0"> + · </span> + <span className="min-w-0 truncate">{metaLabel}</span> </div> <button type="button" - aria-label={t($ => $['agentDetail.configure.publishBar.versionHistory'])} + aria-label={t(($) => $['agentDetail.configure.publishBar.versionHistory'])} className="flex size-8 shrink-0 items-center justify-center rounded-lg text-text-tertiary group-data-open/publish-bar:hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={onOpenVersions} > @@ -367,7 +389,7 @@ function PublishBarActions({ className="hidden h-8 min-w-18 rounded-lg px-3 group-data-open/publish-bar:inline-flex" onClick={onCancelImpact} > - {t($ => $['agentDetail.configure.publishImpact.cancel'])} + {t(($) => $['agentDetail.configure.publishImpact.cancel'])} </Button> <Button type="button" @@ -379,9 +401,7 @@ function PublishBarActions({ void onPublishRequest() }} > - {actionIcon && ( - <span aria-hidden className={`${actionIcon} size-4 shrink-0`} /> - )} + {actionIcon && <span aria-hidden className={`${actionIcon} size-4 shrink-0`} />} <span className="shrink-0">{actionLabel}</span> {showShortcut && <PublishShortcut />} </Button> @@ -402,20 +422,24 @@ function AgentVersionRestoreBar({ }) { const { t } = useTranslation('agentV2') const { formatTime } = useTimestamp() - const versionLabel = version.version_note || t($ => $['agentDetail.versionHistory.versionName'], { version: version.version }) - const createdAt = version.created_at == null - ? null - : formatTime(version.created_at, t($ => $['roster.dateTimeFormat'])) + const versionLabel = + version.version_note || + t(($) => $['agentDetail.versionHistory.versionName'], { version: version.version }) + const createdAt = + version.created_at == null + ? null + : formatTime( + version.created_at, + t(($) => $['roster.dateTimeFormat']), + ) return ( <div className="pointer-events-auto flex max-w-full min-w-0 items-center gap-2 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-2 pr-2.5 pl-2 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"> <div className="flex min-w-0 flex-col justify-center gap-0.5 pr-4 pl-2"> <div className="flex min-w-0 items-center gap-1"> - <p className="min-w-0 truncate system-sm-semibold text-text-primary"> - {versionLabel} - </p> + <p className="min-w-0 truncate system-sm-semibold text-text-primary">{versionLabel}</p> <span className="shrink-0 rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-accent-secondary"> - {t($ => $['agentDetail.versionHistory.viewOnly'])} + {t(($) => $['agentDetail.versionHistory.viewOnly'])} </span> </div> {(createdAt || version.created_by) && ( @@ -434,7 +458,7 @@ function AgentVersionRestoreBar({ className="h-8 rounded-lg px-3" onClick={() => onRestoreVersion?.(version.id)} > - {t($ => $['agentDetail.versionHistory.restore'])} + {t(($) => $['agentDetail.versionHistory.restore'])} </Button> <Button type="button" @@ -443,7 +467,7 @@ function AgentVersionRestoreBar({ onClick={onExitVersions} > <span aria-hidden className="i-ri-arrow-go-back-line size-4 shrink-0" /> - <span className="shrink-0">{t($ => $['agentDetail.versionHistory.exitVersions'])}</span> + <span className="shrink-0">{t(($) => $['agentDetail.versionHistory.exitVersions'])}</span> </Button> </div> ) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/publish-impact-details.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/publish-impact-details.tsx index 7076a173c41b86..06cd9f161ba45b 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/publish-impact-details.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/publish-impact-details.tsx @@ -1,6 +1,9 @@ 'use client' -import type { AgentIconType, AgentReferencingWorkflowResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentIconType, + AgentReferencingWorkflowResponse, +} from '@dify/contracts/api/console/agent/types.gen' import { useId } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' @@ -13,7 +16,8 @@ type AgentPublishImpactDetailsProps = { references: AgentReferencingWorkflowResponse[] } -const getWorkflowReferenceHref = (reference: AgentReferencingWorkflowResponse) => `/app/${reference.app_id}/workflow` +const getWorkflowReferenceHref = (reference: AgentReferencingWorkflowResponse) => + `/app/${reference.app_id}/workflow` export function AgentPublishImpactDetails({ publishActionLabel, @@ -24,34 +28,38 @@ export function AgentPublishImpactDetails({ const titleId = useId() return ( - <section - aria-labelledby={titleId} - className="flex w-full max-w-full flex-col" - > + <section aria-labelledby={titleId} className="flex w-full max-w-full flex-col"> <div className="flex flex-col gap-0.5 px-3 pt-3.5 pb-1"> - <h2 id={titleId} className="w-full px-1 pr-8 system-xl-semibold wrap-break-word text-text-primary"> - {t($ => $['agentDetail.configure.publishImpact.title'], { + <h2 + id={titleId} + className="w-full px-1 pr-8 system-xl-semibold wrap-break-word text-text-primary" + > + {t(($) => $['agentDetail.configure.publishImpact.title'], { action: publishActionLabel, - name: agentName || t($ => $['agentDetail.configure.publishImpact.fallbackAgentName']), + name: agentName || t(($) => $['agentDetail.configure.publishImpact.fallbackAgentName']), })} </h2> <p className="px-1 system-xs-regular wrap-break-word text-text-warning"> - {t($ => $['agentDetail.configure.publishImpact.descriptionPrefix'])} - {' '} + {t(($) => $['agentDetail.configure.publishImpact.descriptionPrefix'])}{' '} <span className="system-xs-medium"> - {t($ => $['agentDetail.configure.publishImpact.workflowCount'], { count: references.length })} + {t(($) => $['agentDetail.configure.publishImpact.workflowCount'], { + count: references.length, + })} </span> - {t($ => $['agentDetail.configure.publishImpact.descriptionSuffix'])} + {t(($) => $['agentDetail.configure.publishImpact.descriptionSuffix'])} </p> </div> <div className="flex w-full flex-col gap-1 px-4 py-2"> <div className="flex min-h-6 items-center system-sm-medium text-text-secondary"> - {t($ => $['agentDetail.configure.publishImpact.affectedWorkflows'])} + {t(($) => $['agentDetail.configure.publishImpact.affectedWorkflows'])} </div> <div className="flex max-h-[123px] flex-col gap-px overflow-y-auto rounded-xl border border-components-panel-border p-1"> - {references.map(reference => ( - <ReferenceLink key={`${reference.app_id}-${reference.workflow_id}`} reference={reference} /> + {references.map((reference) => ( + <ReferenceLink + key={`${reference.app_id}-${reference.workflow_id}`} + reference={reference} + /> ))} </div> </div> @@ -59,17 +67,18 @@ export function AgentPublishImpactDetails({ ) } -function ReferenceLink({ - reference, -}: { - reference: AgentReferencingWorkflowResponse -}) { +function ReferenceLink({ reference }: { reference: AgentReferencingWorkflowResponse }) { const { formatTimeFromNow } = useFormatTimeFromNow() - const imageUrl = (reference.app_icon_type === 'image' || reference.app_icon_type === 'link') ? reference.app_icon : undefined - const iconType = (imageUrl ? 'image' : reference.app_icon_type) as AgentIconType | null | undefined - const updatedAt = reference.app_updated_at == null - ? null - : formatTimeFromNow(reference.app_updated_at * 1000) + const imageUrl = + reference.app_icon_type === 'image' || reference.app_icon_type === 'link' + ? reference.app_icon + : undefined + const iconType = (imageUrl ? 'image' : reference.app_icon_type) as + | AgentIconType + | null + | undefined + const updatedAt = + reference.app_updated_at == null ? null : formatTimeFromNow(reference.app_updated_at * 1000) return ( <Link diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx index a9951b441cdb62..e279d75e9c217b 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx @@ -39,7 +39,10 @@ type ConfigSkillDownloadQueryOptionsInput = { } const mocks = vi.hoisted(() => ({ - deleteSkillMutationFn: vi.fn(async (_input: unknown) => ({ removed_names: ['Tender Analyzer'], result: 'success' })), + deleteSkillMutationFn: vi.fn(async (_input: unknown) => ({ + removed_names: ['Tender Analyzer'], + result: 'success', + })), uploadSkillMutationFn: vi.fn(async (_input: unknown) => ({ config_version: { id: 'draft-1', kind: 'draft', writable: true }, skill: { @@ -163,11 +166,7 @@ function ConfigSnapshotProbe() { const draft = useAtomValue(agentComposerDraftAtom) const configSnapshot = formStateToAgentSoulConfig({ formState: draft }) - return ( - <pre data-testid="config-snapshot-probe"> - {JSON.stringify(configSnapshot)} - </pre> - ) + return <pre data-testid="config-snapshot-probe">{JSON.stringify(configSnapshot)}</pre> } function renderAgentSkills({ @@ -298,7 +297,9 @@ describe('AgentSkills', () => { const user = userEvent.setup() renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState }) - await user.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i })) + await user.click( + screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }), + ) const input = await waitFor(() => { const element = document.querySelector('input[type="file"]') @@ -307,7 +308,9 @@ describe('AgentSkills', () => { }) const file = new File(['skill'], 'invoice-helper.skill', { type: 'application/zip' }) await user.upload(input, file) - await user.click(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.upload\.action/i })) + await user.click( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.upload\.action/i }), + ) await waitFor(() => { expect(mocks.uploadSkillMutationFn).toHaveBeenCalled() @@ -345,7 +348,9 @@ describe('AgentSkills', () => { mocks.uploadSkillMutationFn.mockRejectedValueOnce(new Error('Backend upload error')) renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState }) - await user.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i })) + await user.click( + screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }), + ) const input = await waitFor(() => { const element = document.querySelector('input[type="file"]') @@ -354,13 +359,17 @@ describe('AgentSkills', () => { }) const file = new File(['skill'], 'invoice-helper.skill', { type: 'application/zip' }) await user.upload(input, file) - await user.click(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.upload\.action/i })) + await user.click( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.upload\.action/i }), + ) await waitFor(() => { expect(mocks.uploadSkillMutationFn).toHaveBeenCalled() }) - expect(toast.error).not.toHaveBeenCalledWith('agentV2.agentDetail.configure.skills.upload.failed') + expect(toast.error).not.toHaveBeenCalledWith( + 'agentV2.agentDetail.configure.skills.upload.failed', + ) }) it('should use workflow config skill endpoints with node_id for uploads and skill member queries', async () => { @@ -377,7 +386,9 @@ describe('AgentSkills', () => { }, }) - await user.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i })) + await user.click( + screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }), + ) const input = await waitFor(() => { const element = document.querySelector('input[type="file"]') expect(element).not.toBeNull() @@ -385,7 +396,9 @@ describe('AgentSkills', () => { }) const file = new File(['skill'], 'invoice-helper.skill', { type: 'application/zip' }) await user.upload(input, file) - await user.click(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.upload\.action/i })) + await user.click( + screen.getByRole('button', { name: /agentDetail\.configure\.skills\.upload\.action/i }), + ) await waitFor(() => { expect(mocks.uploadSkillMutationFn.mock.calls[0]?.[0]).toEqual({ @@ -406,19 +419,21 @@ describe('AgentSkills', () => { await user.click(screen.getByText('Tender Analyzer').closest('button')!) await waitFor(() => { - expect(mocks.inspectQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: { - app_id: 'app-1', - name: 'Tender Analyzer', - }, - query: { - draft_type: 'draft', - node_id: 'node-1', - version_id: 'draft-1', - }, + expect(mocks.inspectQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: { + app_id: 'app-1', + name: 'Tender Analyzer', + }, + query: { + draft_type: 'draft', + node_id: 'node-1', + version_id: 'draft-1', + }, + }), }), - })) + ) }) }) @@ -426,23 +441,27 @@ describe('AgentSkills', () => { const user = userEvent.setup() renderAgentSkills() - await user.click(screen.getByRole('button', { - name: /common\.operation\.download.*Tender Analyzer/, - })) + await user.click( + screen.getByRole('button', { + name: /common\.operation\.download.*Tender Analyzer/, + }), + ) await waitFor(() => { - expect(mocks.skillDownloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: { - agent_id: 'agent-1', - name: 'Tender Analyzer', - }, - query: { - draft_type: 'draft', - version_id: undefined, - }, + expect(mocks.skillDownloadQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: { + agent_id: 'agent-1', + name: 'Tender Analyzer', + }, + query: { + draft_type: 'draft', + version_id: undefined, + }, + }), }), - })) + ) }) expect(mocks.downloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/Tender Analyzer.skill', @@ -464,24 +483,28 @@ describe('AgentSkills', () => { }, }) - await user.click(screen.getByRole('button', { - name: /common\.operation\.download.*Tender Analyzer/, - })) + await user.click( + screen.getByRole('button', { + name: /common\.operation\.download.*Tender Analyzer/, + }), + ) await waitFor(() => { - expect(mocks.skillDownloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: { - app_id: 'app-1', - name: 'Tender Analyzer', - }, - query: { - draft_type: 'draft', - node_id: 'node-1', - version_id: 'draft-1', - }, + expect(mocks.skillDownloadQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: { + app_id: 'app-1', + name: 'Tender Analyzer', + }, + query: { + draft_type: 'draft', + node_id: 'node-1', + version_id: 'draft-1', + }, + }), }), - })) + ) }) }) @@ -492,31 +515,35 @@ describe('AgentSkills', () => { await user.click(screen.getByText('Tender Analyzer').closest('button')!) await waitFor(() => { - expect(mocks.inspectQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: { - agent_id: 'agent-1', - name: 'Tender Analyzer', - }, + expect(mocks.inspectQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: { + agent_id: 'agent-1', + name: 'Tender Analyzer', + }, + }), }), - })) + ) }) await user.click(screen.getByText('references').closest('button')!) await user.click(screen.getByText('guide.md').closest('button')!) await waitFor(() => { - expect(mocks.previewQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: { - agent_id: 'agent-1', - name: 'Tender Analyzer', - }, - query: expect.objectContaining({ - path: 'references/guide.md', + expect(mocks.previewQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: { + agent_id: 'agent-1', + name: 'Tender Analyzer', + }, + query: expect.objectContaining({ + path: 'references/guide.md', + }), }), }), - })) + ) }) }) @@ -542,22 +569,26 @@ describe('AgentSkills', () => { await user.click(screen.getByText('Tender Analyzer').closest('button')!) await user.click(await screen.findByText('references')) await user.click(screen.getByText('guide.md').closest('button')!) - await user.click(screen.getByRole('button', { - name: /common\.operation\.download.*guide\.md/, - })) + await user.click( + screen.getByRole('button', { + name: /common\.operation\.download.*guide\.md/, + }), + ) await waitFor(() => { - expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - params: { - agent_id: 'agent-1', - name: 'Tender Analyzer', - }, - query: expect.objectContaining({ - path: 'references/guide.md', + expect(mocks.downloadQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + params: { + agent_id: 'agent-1', + name: 'Tender Analyzer', + }, + query: expect.objectContaining({ + path: 'references/guide.md', + }), }), }), - })) + ) }) expect(mocks.downloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/references/guide.md', @@ -570,9 +601,11 @@ describe('AgentSkills', () => { renderAgentSkills() await user.click(screen.getByText('Tender Analyzer').closest('button')!) - await user.click(await screen.findByRole('button', { - name: /common\.operation\.download.*SKILL\.md/, - })) + await user.click( + await screen.findByRole('button', { + name: /common\.operation\.download.*SKILL\.md/, + }), + ) expect(mocks.downloadBlob).toHaveBeenCalledWith({ data: expect.any(Blob), @@ -580,19 +613,23 @@ describe('AgentSkills', () => { }) const blob = mocks.downloadBlob.mock.calls[0]?.[0].data as Blob await expect(blob.text()).resolves.toBe('# Skill\n') - expect(mocks.downloadQueryOptions).not.toHaveBeenCalledWith(expect.objectContaining({ - input: expect.objectContaining({ - query: expect.objectContaining({ - path: 'SKILL.md', + expect(mocks.downloadQueryOptions).not.toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + query: expect.objectContaining({ + path: 'SKILL.md', + }), }), }), - })) + ) }) it('should disable add and remove actions when the section is read only', () => { const { container } = renderAgentSkills({ readOnly: true }) - expect(screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }), + ).not.toBeInTheDocument() expect(container.querySelector('[data-agent-skill-remove-button]')).toBeNull() }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx index 2020f27debe37b..0806132464ff52 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx @@ -36,7 +36,7 @@ export type AgentSkillDetail = { fileListTreeListClassName?: string fileListTitle?: string files: AgentSkillFileNode[] - folderOpenState?: (context: { file: AgentSkillFileNode, depth: number }) => boolean + folderOpenState?: (context: { file: AgentSkillFileNode; depth: number }) => boolean filePreview?: { binary?: boolean content?: string @@ -49,11 +49,11 @@ export type AgentSkillDetail = { isImage?: boolean isLoading?: boolean } - onFolderOpenChange?: (context: { file: AgentSkillFileNode, depth: number, open: boolean }) => void - onFolderDoubleClick?: (context: { file: AgentSkillFileNode, depth: number }) => void + onFolderOpenChange?: (context: { file: AgentSkillFileNode; depth: number; open: boolean }) => void + onFolderDoubleClick?: (context: { file: AgentSkillFileNode; depth: number }) => void onDownloadFile?: (action: AgentSkillDetailDownloadAction) => void onSelectFile?: (file: AgentSkillFileNode) => void - renderFolderSuffix?: (context: { file: AgentSkillFileNode, depth: number }) => ReactNode + renderFolderSuffix?: (context: { file: AgentSkillFileNode; depth: number }) => ReactNode selectedFileId?: string sections: AgentSkillDetailSection[] } @@ -93,8 +93,11 @@ function AgentSkillFileList({ return ( <div className={cn('flex h-full flex-col bg-background-section', fileListTreeClassName)}> {fileListHeader ?? ( - <h3 id="agent-skill-detail-files-heading" className="px-4 pt-3.5 pb-3 system-xl-semibold text-text-primary"> - {fileListTitle ?? t($ => $['agentDetail.configure.skills.detail.files'])} + <h3 + id="agent-skill-detail-files-heading" + className="px-4 pt-3.5 pb-3 system-xl-semibold text-text-primary" + > + {fileListTitle ?? t(($) => $['agentDetail.configure.skills.detail.files'])} </h3> )} <div className="flex min-h-0 flex-1 items-center justify-center"> @@ -116,41 +119,42 @@ function AgentSkillFileList({ folderOpenState={folderOpenState} onFolderOpenChange={onFolderOpenChange} onFolderDoubleClick={onFolderDoubleClick} - renderFile={onSelectFile - ? ({ depth, file, selected, children }) => ( - <FileTreeFile level={depth} selected={selected} onClick={() => onSelectFile(file)}> - {children} - </FileTreeFile> - ) - : undefined} + renderFile={ + onSelectFile + ? ({ depth, file, selected, children }) => ( + <FileTreeFile level={depth} selected={selected} onClick={() => onSelectFile(file)}> + {children} + </FileTreeFile> + ) + : undefined + } renderFolderSuffix={renderFolderSuffix} - header={( + header={ fileListHeader ?? ( - <h3 id="agent-skill-detail-files-heading" className="px-4 pt-3.5 pb-3 system-xl-semibold text-text-primary"> - {fileListTitle ?? t($ => $['agentDetail.configure.skills.detail.files'])} + <h3 + id="agent-skill-detail-files-heading" + className="px-4 pt-3.5 pb-3 system-xl-semibold text-text-primary" + > + {fileListTitle ?? t(($) => $['agentDetail.configure.skills.detail.files'])} </h3> ) - )} + } /> ) } -function AgentSkillDetailSectionBlock({ - section, -}: { - section: AgentSkillDetailSection -}) { +function AgentSkillDetailSectionBlock({ section }: { section: AgentSkillDetailSection }) { return ( <section className="clear-none"> <h3 className="system-sm-semibold text-text-primary">{section.title}</h3> - {section.paragraphs?.map(paragraph => ( + {section.paragraphs?.map((paragraph) => ( <p key={paragraph} className="mt-1 system-xs-regular text-text-secondary"> {paragraph} </p> ))} {!!section.items?.length && ( <ul className="mt-1 flex list-disc flex-col gap-0.5 pl-5 system-xs-regular text-text-secondary"> - {section.items.map(item => ( + {section.items.map((item) => ( <li key={item} className="pl-0.5"> {item} </li> @@ -201,7 +205,7 @@ function AgentFilePreviewContent({ if (isError || isDownloadError) { return ( <p className="px-4 system-sm-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.files.preview.failed'])} + {t(($) => $['agentDetail.configure.files.preview.failed'])} </p> ) } @@ -222,7 +226,7 @@ function AgentFilePreviewContent({ return ( <div className="flex min-w-0 flex-wrap items-center gap-2 px-4"> <span className="system-sm-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.files.preview.unsupported'])} + {t(($) => $['agentDetail.configure.files.preview.unsupported'])} </span> <a href={downloadUrl || '#'} @@ -245,11 +249,15 @@ function AgentFilePreviewContent({ aria-hidden className={cn( 'size-4 shrink-0', - isPreviewDownloadLoading ? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none' : 'i-ri-download-2-line', + isPreviewDownloadLoading + ? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none' + : 'i-ri-download-2-line', )} /> <span className="shrink-0"> - {isPreviewDownloadLoading ? tCommon($ => $['operation.downloading']) : tCommon($ => $['operation.download'])} + {isPreviewDownloadLoading + ? tCommon(($) => $['operation.downloading']) + : tCommon(($) => $['operation.download'])} </span> </a> </div> @@ -259,7 +267,7 @@ function AgentFilePreviewContent({ if (!content) { return ( <p className="px-4 system-sm-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.files.preview.empty'])} + {t(($) => $['agentDetail.configure.files.preview.empty'])} </p> ) } @@ -272,7 +280,7 @@ function AgentFilePreviewContent({ return ( <div className="min-h-0 flex-1 overflow-auto px-2 pb-4 font-mono text-[13px] leading-[22px]"> - {lines.map(line => ( + {lines.map((line) => ( <div key={line.key} className="flex min-w-0 items-start"> <span aria-hidden="true" @@ -302,14 +310,19 @@ export function AgentSkillDetailDialog({ const isHeaderDownloadLoading = detail.filePreview?.downloadActionLoadingTarget === 'header' return ( - <DialogContent backdropProps={{ forceRender: true }} backdropClassName="fixed" className="flex h-[min(720px,calc(100dvh-2rem))] max-h-none w-[min(960px,calc(100vw-2rem))] flex-row overflow-hidden rounded-2xl p-0"> - <div className={cn('flex w-56 min-w-0 shrink-0 border-r-[0.5px] border-divider-subtle bg-background-section', detail.fileListPanelClassName)}> - <DialogDescription className="sr-only"> - {detail.description} - </DialogDescription> - <DialogTitle className="sr-only"> - {previewTitle || skillName} - </DialogTitle> + <DialogContent + backdropProps={{ forceRender: true }} + backdropClassName="fixed" + className="flex h-[min(720px,calc(100dvh-2rem))] max-h-none w-[min(960px,calc(100vw-2rem))] flex-row overflow-hidden rounded-2xl p-0" + > + <div + className={cn( + 'flex w-56 min-w-0 shrink-0 border-r-[0.5px] border-divider-subtle bg-background-section', + detail.fileListPanelClassName, + )} + > + <DialogDescription className="sr-only">{detail.description}</DialogDescription> + <DialogTitle className="sr-only">{previewTitle || skillName}</DialogTitle> <div className="min-h-0 w-full"> <AgentSkillFileList fileListHeader={detail.fileListHeader} @@ -331,7 +344,10 @@ export function AgentSkillDetailDialog({ <div className="flex shrink-0 items-start gap-2 px-4 pt-3.5 pb-2"> <div className="flex min-w-0 flex-1 items-center gap-2"> {!!previewTitle && ( - <h2 className="min-w-0 truncate system-xl-semibold text-text-primary" title={previewTitle}> + <h2 + className="min-w-0 truncate system-xl-semibold text-text-primary" + title={previewTitle} + > {previewTitle} </h2> )} @@ -340,7 +356,7 @@ export function AgentSkillDetailDialog({ {detail.onDownloadFile && previewTitle && ( <button type="button" - aria-label={`${isHeaderDownloadLoading ? tCommon($ => $['operation.downloading']) : tCommon($ => $['operation.download'])} ${previewTitle}`} + aria-label={`${isHeaderDownloadLoading ? tCommon(($) => $['operation.downloading']) : tCommon(($) => $['operation.download'])} ${previewTitle}`} onClick={() => detail.onDownloadFile?.('header')} disabled={isHeaderDownloadLoading} className="flex size-7 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid" @@ -349,7 +365,9 @@ export function AgentSkillDetailDialog({ aria-hidden className={cn( 'size-4', - isHeaderDownloadLoading ? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none' : 'i-ri-download-line', + isHeaderDownloadLoading + ? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none' + : 'i-ri-download-line', )} /> </button> @@ -359,7 +377,7 @@ export function AgentSkillDetailDialog({ </div> <ScrollArea className="relative min-h-0 flex-1 overflow-hidden has-[>_:first-child:focus-visible]:outline-2 has-[>_:first-child:focus-visible]:outline-offset-0 has-[>_:first-child:focus-visible]:outline-state-accent-solid" - label={t($ => $['agentDetail.configure.skills.detail.contentRegion'])} + label={t(($) => $['agentDetail.configure.skills.detail.contentRegion'])} slotClassNames={{ viewport: 'overscroll-contain outline-none focus-visible:outline-none', content: 'flex min-h-full w-full max-w-full min-w-0 flex-col gap-2', @@ -380,7 +398,7 @@ export function AgentSkillDetailDialog({ onDownloadFile={detail.onDownloadFile} /> )} - {detail.sections.map(section => ( + {detail.sections.map((section) => ( <div key={section.id} className="px-4"> <AgentSkillDetailSectionBlock section={section} /> </div> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx index 1e6a5f4b2de467..f4d3c88c1545f2 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx @@ -23,7 +23,7 @@ import { AgentSkillUploadDialog } from './upload-dialog' export function AgentSkills() { const { t } = useTranslation('agentV2') - const skillsTip = t($ => $['agentDetail.configure.skills.tip']) + const skillsTip = t(($) => $['agentDetail.configure.skills.tip']) const skillsListId = 'agent-configure-skills-list' const [isUploadOpen, setIsUploadOpen] = useState(false) const promptAddCallbackRef = useRef<AgentOrchestrateAddActionOptions['onAdded']>(undefined) @@ -31,8 +31,12 @@ export function AgentSkills() { const skills = useAtomValue(agentComposerSkillsAtom) const upsertAgentSkill = useSetAtom(upsertAgentSkillAtom) const removeAgentSkill = useSetAtom(removeAgentSkillAtom) - const { mutate: deleteAgentSkill } = useMutation(consoleQuery.agent.byAgentId.config.skills.byName.delete.mutationOptions()) - const { mutate: deleteAppSkill } = useMutation(consoleQuery.apps.byAppId.agent.config.skills.byName.delete.mutationOptions()) + const { mutate: deleteAgentSkill } = useMutation( + consoleQuery.agent.byAgentId.config.skills.byName.delete.mutationOptions(), + ) + const { mutate: deleteAppSkill } = useMutation( + consoleQuery.apps.byAppId.agent.config.skills.byName.delete.mutationOptions(), + ) const handleOpenUpload = useCallback((options?: AgentOrchestrateAddActionOptions) => { promptAddCallbackRef.current = options?.onAdded @@ -40,57 +44,67 @@ export function AgentSkills() { }, []) useRegisterAgentOrchestrateAddAction('skills', handleOpenUpload) - const handleUploaded = useCallback((skill: AgentSkill) => { - upsertAgentSkill(skill) - promptAddCallbackRef.current?.(skill) - promptAddCallbackRef.current = undefined - }, [upsertAgentSkill]) + const handleUploaded = useCallback( + (skill: AgentSkill) => { + upsertAgentSkill(skill) + promptAddCallbackRef.current?.(skill) + promptAddCallbackRef.current = undefined + }, + [upsertAgentSkill], + ) const handleUploadOpenChange = useCallback((open: boolean) => { - if (!open) - promptAddCallbackRef.current = undefined + if (!open) promptAddCallbackRef.current = undefined setIsUploadOpen(open) }, []) - const handleRemoveSkill = useCallback((skillId: string) => { - const skill = skills.find(item => item.id === skillId) - if (!skill) - return + const handleRemoveSkill = useCallback( + (skillId: string) => { + const skill = skills.find((item) => item.id === skillId) + if (!skill) return - const onSuccess = () => { - removeAgentSkill(skillId) - } - if (apiContext.workflow) { - deleteAppSkill({ - params: { - app_id: apiContext.workflow.appId, - name: skill.name, - }, - query: { - node_id: apiContext.workflow.nodeId, - draft_type: apiContext.draftType, - version_id: apiContext.versionId, - }, - }, { onSuccess }) - return - } + const onSuccess = () => { + removeAgentSkill(skillId) + } + if (apiContext.workflow) { + deleteAppSkill( + { + params: { + app_id: apiContext.workflow.appId, + name: skill.name, + }, + query: { + node_id: apiContext.workflow.nodeId, + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + }, + { onSuccess }, + ) + return + } - deleteAgentSkill({ - params: { - agent_id: apiContext.agentId, - name: skill.name, - }, - query: { - draft_type: apiContext.draftType, - version_id: apiContext.versionId, - }, - }, { onSuccess }) - }, [apiContext, deleteAgentSkill, deleteAppSkill, removeAgentSkill, skills]) + deleteAgentSkill( + { + params: { + agent_id: apiContext.agentId, + name: skill.name, + }, + query: { + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + }, + { onSuccess }, + ) + }, + [apiContext, deleteAgentSkill, deleteAppSkill, removeAgentSkill, skills], + ) return ( <> <ConfigureSection - label={t($ => $['agentDetail.configure.skills.label'])} + label={t(($) => $['agentDetail.configure.skills.label'])} labelId="agent-configure-skills-label" buildDraftChangeSection="skills" panelId={skillsListId} @@ -98,23 +112,28 @@ export function AgentSkills() { tipAriaLabel={skillsTip} rootClassName="border-b border-divider-subtle pt-4" panelContentClassName="flex flex-col gap-1 pb-4" - actions={( + actions={ <ConfigureSectionAddButton - ariaLabel={t($ => $['agentDetail.configure.skills.add'])} + ariaLabel={t(($) => $['agentDetail.configure.skills.add'])} onClick={() => handleOpenUpload()} /> - )} + } > - {skills.length === 0 - ? ( - <ConfigureSectionEmpty - title={t($ => $['agentDetail.configure.skills.empty.title'])} - description={t($ => $['agentDetail.configure.skills.empty.description'])} - /> - ) - : skills.map(skill => ( - <AgentSkillItem key={skill.id} apiContext={apiContext} skill={skill} onRemove={handleRemoveSkill} /> - ))} + {skills.length === 0 ? ( + <ConfigureSectionEmpty + title={t(($) => $['agentDetail.configure.skills.empty.title'])} + description={t(($) => $['agentDetail.configure.skills.empty.description'])} + /> + ) : ( + skills.map((skill) => ( + <AgentSkillItem + key={skill.id} + apiContext={apiContext} + skill={skill} + onRemove={handleRemoveSkill} + /> + )) + )} </ConfigureSection> <AgentSkillUploadDialog apiContext={apiContext} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx index 90f8cde053e789..fc7a43bc0a8856 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx @@ -3,9 +3,7 @@ import type { AgentConfigApiContext } from '../config-context' import type { AgentSkill } from '@/features/agent-v2/agent-composer/form-state' import { cn } from '@langgenius/dify-ui/cn' -import { - Dialog, -} from '@langgenius/dify-ui/dialog' +import { Dialog } from '@langgenius/dify-ui/dialog' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -34,35 +32,39 @@ export function AgentSkillItem({ }, [onRemove, skill.id]) const handleDownload = useCallback(async () => { if (apiContext.workflow) { - const result = await queryClient.fetchQuery(consoleQuery.apps.byAppId.agent.config.skills.byName.download.get.queryOptions({ + const result = await queryClient.fetchQuery( + consoleQuery.apps.byAppId.agent.config.skills.byName.download.get.queryOptions({ + input: { + params: { + app_id: apiContext.workflow.appId, + name: skill.name, + }, + query: { + node_id: apiContext.workflow.nodeId, + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + }, + }), + ) + downloadUrl({ url: result.url, fileName: skill.name }) + return + } + + const result = await queryClient.fetchQuery( + consoleQuery.agent.byAgentId.config.skills.byName.download.get.queryOptions({ input: { params: { - app_id: apiContext.workflow.appId, + agent_id: apiContext.agentId, name: skill.name, }, query: { - node_id: apiContext.workflow.nodeId, draft_type: apiContext.draftType, version_id: apiContext.versionId, }, }, - })) - downloadUrl({ url: result.url, fileName: skill.name }) - return - } - - const result = await queryClient.fetchQuery(consoleQuery.agent.byAgentId.config.skills.byName.download.get.queryOptions({ - input: { - params: { - agent_id: apiContext.agentId, - name: skill.name, - }, - query: { - draft_type: apiContext.draftType, - version_id: apiContext.versionId, - }, - }, - })) + }), + ) downloadUrl({ url: result.url, fileName: skill.name }) }, [apiContext, queryClient, skill.name]) const handleOpenPreview = useCallback(() => { @@ -70,7 +72,7 @@ export function AgentSkillItem({ }, []) const detail = useAgentSkillDetail({ apiContext, - description: skill.description ?? t($ => $['agentDetail.configure.skills.tip']), + description: skill.description ?? t(($) => $['agentDetail.configure.skills.tip']), isOpen: isPreviewOpen, skill, }) @@ -94,12 +96,12 @@ export function AgentSkillItem({ 'group-focus-within:opacity-0 group-hover:opacity-0', )} > - {t($ => $['agentDetail.configure.skills.itemType'])} + {t(($) => $['agentDetail.configure.skills.itemType'])} </span> </button> <button type="button" - aria-label={`${tCommon($ => $['operation.download'])} ${skill.name}`} + aria-label={`${tCommon(($) => $['operation.download'])} ${skill.name}`} onClick={handleDownload} className={cn( 'pointer-events-none absolute top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', @@ -112,7 +114,7 @@ export function AgentSkillItem({ <button type="button" data-agent-skill-remove-button - aria-label={t($ => $['agentDetail.configure.skills.remove'], { name: skill.name })} + aria-label={t(($) => $['agentDetail.configure.skills.remove'], { name: skill.name })} onClick={handleRemove} className="pointer-events-none absolute top-1/2 right-1 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:bg-state-destructive-hover focus-visible:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > @@ -120,12 +122,7 @@ export function AgentSkillItem({ </button> )} </div> - {isPreviewOpen && ( - <AgentSkillDetailDialog - skillName={skill.name} - detail={detail} - /> - )} + {isPreviewOpen && <AgentSkillDetailDialog skillName={skill.name} detail={detail} />} </Dialog> ) } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx index bf7f28c3f803ac..04c66403747a40 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx @@ -7,7 +7,13 @@ import type { AgentConfigApiContext } from '../config-context' import type { AgentSkill } from '@/features/agent-v2/agent-composer/form-state' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' import { useRef, useState } from 'react' @@ -19,10 +25,13 @@ import { formatFileSize } from '@/utils/format' const skillPackageAccept = '.zip,.skill' const skillPackageExtensions = ['.zip', '.skill'] -const getSkillNameFromFile = (file: File) => file.name.replace(/\.(?:skill|zip)$/iu, '') || file.name +const getSkillNameFromFile = (file: File) => + file.name.replace(/\.(?:skill|zip)$/iu, '') || file.name const toUploadedSkill = ( - response: PostAgentByAgentIdConfigSkillsUploadResponse | PostAppsByAppIdAgentConfigSkillsUploadResponse, + response: + | PostAgentByAgentIdConfigSkillsUploadResponse + | PostAppsByAppIdAgentConfigSkillsUploadResponse, file: File, ): AgentSkill => { const name = response.skill?.name ?? getSkillNameFromFile(file) @@ -41,7 +50,7 @@ const toUploadedSkill = ( function isSupportedSkillPackage(file: File) { const fileName = file.name.toLowerCase() - return skillPackageExtensions.some(extension => fileName.endsWith(extension)) + return skillPackageExtensions.some((extension) => fileName.endsWith(extension)) } function hasDraggedFiles(event: DragEvent<HTMLDivElement>) { @@ -63,7 +72,7 @@ function AgentSkillPackageUploader({ const setUploadFiles = (files: File[]) => { const [uploadFile] = files if (files.length !== 1 || !uploadFile || !isSupportedSkillPackage(uploadFile)) { - toast.error(t($ => $['agentDetail.configure.skills.upload.invalidFile'])) + toast.error(t(($) => $['agentDetail.configure.skills.upload.invalidFile'])) return } @@ -77,8 +86,7 @@ function AgentSkillPackageUploader({ } const handleDragEnter = (event: DragEvent<HTMLDivElement>) => { - if (!hasDraggedFiles(event)) - return + if (!hasDraggedFiles(event)) return event.preventDefault() event.stopPropagation() @@ -87,27 +95,23 @@ function AgentSkillPackageUploader({ } const handleDragOver = (event: DragEvent<HTMLDivElement>) => { - if (!hasDraggedFiles(event)) - return + if (!hasDraggedFiles(event)) return event.preventDefault() event.dataTransfer.dropEffect = 'copy' } const handleDragLeave = (event: DragEvent<HTMLDivElement>) => { - if (!hasDraggedFiles(event)) - return + if (!hasDraggedFiles(event)) return event.preventDefault() event.stopPropagation() dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) - if (dragDepthRef.current === 0) - setDragging(false) + if (dragDepthRef.current === 0) setDragging(false) } const handleDrop = (event: DragEvent<HTMLDivElement>) => { - if (!hasDraggedFiles(event)) - return + if (!hasDraggedFiles(event)) return event.preventDefault() event.stopPropagation() @@ -121,7 +125,7 @@ function AgentSkillPackageUploader({ <div className="mt-6" role="group" - aria-label={t($ => $['agentDetail.configure.skills.upload.title'])} + aria-label={t(($) => $['agentDetail.configure.skills.upload.title'])} onDragEnter={handleDragEnter} onDragOver={handleDragOver} onDragLeave={handleDragLeave} @@ -144,13 +148,13 @@ function AgentSkillPackageUploader({ <div className="flex w-full items-center justify-center space-x-2"> <span aria-hidden className="i-ri-upload-cloud-2-line size-6 text-text-tertiary" /> <div className="text-text-tertiary"> - {t($ => $['agentDetail.configure.skills.upload.dropzone'])} + {t(($) => $['agentDetail.configure.skills.upload.dropzone'])} <button type="button" className="inline cursor-pointer border-none bg-transparent p-0 pl-1 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" onClick={() => fileInputRef.current?.click()} > - {t($ => $['agentDetail.configure.skills.upload.browse'])} + {t(($) => $['agentDetail.configure.skills.upload.browse'])} </button> </div> </div> @@ -163,9 +167,11 @@ function AgentSkillPackageUploader({ <span aria-hidden className="i-custom-public-files-yaml size-6 shrink-0" /> </div> <div className="flex min-w-0 grow flex-col items-start gap-0.5 py-1 pr-2"> - <span className="max-w-full min-w-0 truncate text-[12px] leading-4 font-medium text-text-secondary">{file.name}</span> + <span className="max-w-full min-w-0 truncate text-[12px] leading-4 font-medium text-text-secondary"> + {file.name} + </span> <div className="flex h-3 items-center gap-1 self-stretch text-[10px] leading-3 font-medium text-text-tertiary uppercase"> - <span>{t($ => $['agentDetail.configure.skills.upload.fileType'])}</span> + <span>{t(($) => $['agentDetail.configure.skills.upload.fileType'])}</span> <span className="text-text-quaternary">·</span> <span>{formatFileSize(file.size)}</span> </div> @@ -195,19 +201,26 @@ export function AgentSkillUploadDialog({ const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const [file, setFile] = useState<File>() - const uploadAgentSkillMutation = useMutation(consoleQuery.agent.byAgentId.config.skills.upload.post.mutationOptions()) - const uploadWorkflowSkillMutation = useMutation(consoleQuery.apps.byAppId.agent.config.skills.upload.post.mutationOptions()) - const uploadSkillMutation = apiContext.workflow ? uploadWorkflowSkillMutation : uploadAgentSkillMutation + const uploadAgentSkillMutation = useMutation( + consoleQuery.agent.byAgentId.config.skills.upload.post.mutationOptions(), + ) + const uploadWorkflowSkillMutation = useMutation( + consoleQuery.apps.byAppId.agent.config.skills.upload.post.mutationOptions(), + ) + const uploadSkillMutation = apiContext.workflow + ? uploadWorkflowSkillMutation + : uploadAgentSkillMutation const handleUpload = () => { - if (!file || uploadSkillMutation.isPending) - return + if (!file || uploadSkillMutation.isPending) return const options = { onSuccess: ( - response: PostAgentByAgentIdConfigSkillsUploadResponse | PostAppsByAppIdAgentConfigSkillsUploadResponse, + response: + | PostAgentByAgentIdConfigSkillsUploadResponse + | PostAppsByAppIdAgentConfigSkillsUploadResponse, ) => { - toast.success(t($ => $['agentDetail.configure.skills.upload.success'])) + toast.success(t(($) => $['agentDetail.configure.skills.upload.success'])) onUploaded?.(toUploadedSkill(response, file)) setFile(undefined) onOpenChange(false) @@ -215,34 +228,40 @@ export function AgentSkillUploadDialog({ } if (apiContext.workflow) { - uploadWorkflowSkillMutation.mutate({ + uploadWorkflowSkillMutation.mutate( + { + params: { + app_id: apiContext.workflow.appId, + }, + query: { + node_id: apiContext.workflow.nodeId, + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + body: { + file, + }, + }, + options, + ) + return + } + + uploadAgentSkillMutation.mutate( + { params: { - app_id: apiContext.workflow.appId, + agent_id: apiContext.agentId, }, query: { - node_id: apiContext.workflow.nodeId, draft_type: apiContext.draftType, version_id: apiContext.versionId, }, body: { file, }, - }, options) - return - } - - uploadAgentSkillMutation.mutate({ - params: { - agent_id: apiContext.agentId, - }, - query: { - draft_type: apiContext.draftType, - version_id: apiContext.versionId, }, - body: { - file, - }, - }, options) + options, + ) } const handleOpenChange = (nextOpen: boolean) => { @@ -259,18 +278,19 @@ export function AgentSkillUploadDialog({ <DialogContent backdropProps={{ forceRender: true }} backdropClassName="fixed"> <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['agentDetail.configure.skills.upload.title'])} + {t(($) => $['agentDetail.configure.skills.upload.title'])} </DialogTitle> <DialogDescription className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.skills.upload.description'])} + {t(($) => $['agentDetail.configure.skills.upload.description'])} </DialogDescription> - <AgentSkillPackageUploader - file={file} - onChange={setFile} - /> + <AgentSkillPackageUploader file={file} onChange={setFile} /> <div className="flex justify-end gap-2 pt-6"> - <Button type="button" onClick={() => handleOpenChange(false)} disabled={uploadSkillMutation.isPending}> - {tCommon($ => $['operation.cancel'])} + <Button + type="button" + onClick={() => handleOpenChange(false)} + disabled={uploadSkillMutation.isPending} + > + {tCommon(($) => $['operation.cancel'])} </Button> <Button type="button" @@ -279,7 +299,7 @@ export function AgentSkillUploadDialog({ loading={uploadSkillMutation.isPending} onClick={handleUpload} > - {t($ => $['agentDetail.configure.skills.upload.action'])} + {t(($) => $['agentDetail.configure.skills.upload.action'])} </Button> </div> </DialogContent> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/use-skill-detail.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/use-skill-detail.ts index fa3cfe673a3c8b..68995149a149eb 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/use-skill-detail.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/use-skill-detail.ts @@ -10,8 +10,7 @@ import { consoleQuery } from '@/service/client' import { downloadBlob, downloadUrl } from '@/utils/download' import { getDriveFileIconType } from '../files/file-icon' -const isSkillFolder = (file: AgentConfigSkillFileResponse) => - file.type === 'directory' +const isSkillFolder = (file: AgentConfigSkillFileResponse) => file.type === 'directory' const toSkillFileNode = (item: AgentConfigSkillFileResponse): AgentFileNode => { const fileName = item.name || item.path.split('/').pop() || item.path @@ -30,15 +29,19 @@ const toSkillFileNode = (item: AgentConfigSkillFileResponse): AgentFileNode => { } } -const sortSkillFileNodes = (files: AgentFileNode[]): AgentFileNode[] => [...files].sort((first, second) => { - const firstIsFolder = first.icon === 'folder' - const secondIsFolder = second.icon === 'folder' +const sortSkillFileNodes = (files: AgentFileNode[]): AgentFileNode[] => + [...files] + .sort((first, second) => { + const firstIsFolder = first.icon === 'folder' + const secondIsFolder = second.icon === 'folder' - if (firstIsFolder !== secondIsFolder) - return firstIsFolder ? -1 : 1 + if (firstIsFolder !== secondIsFolder) return firstIsFolder ? -1 : 1 - return first.name.localeCompare(second.name) -}).map(file => file.children ? { ...file, children: sortSkillFileNodes(file.children) } : file) + return first.name.localeCompare(second.name) + }) + .map((file) => + file.children ? { ...file, children: sortSkillFileNodes(file.children) } : file, + ) const toSkillFileTree = (files: AgentConfigSkillFileResponse[]): AgentFileNode[] => { const root: AgentFileNode[] = [] @@ -46,8 +49,7 @@ const toSkillFileTree = (files: AgentConfigSkillFileResponse[]): AgentFileNode[] for (const file of files) { const relativePath = file.path.split('/').filter(Boolean).join('/') - if (!relativePath) - continue + if (!relativePath) continue const segments = relativePath.split('/').filter(Boolean) let siblings = root @@ -91,12 +93,10 @@ const countSkillPackageFiles = (files: AgentConfigSkillFileResponse[] | undefine const filePaths = new Set<string>() for (const file of files ?? []) { - if (isSkillFolder(file)) - continue + if (isSkillFolder(file)) continue const relativePath = file.path.split('/').filter(Boolean).join('/') - if (!relativePath) - continue + if (!relativePath) continue filePaths.add(relativePath) } @@ -106,37 +106,30 @@ const countSkillPackageFiles = (files: AgentConfigSkillFileResponse[] | undefine const getSkillMdFileId = (files: AgentFileNode[]): string | undefined => { for (const file of files) { - if (file.icon !== 'folder' && file.name === 'SKILL.md') - return file.id + if (file.icon !== 'folder' && file.name === 'SKILL.md') return file.id const childFileId = file.children ? getSkillMdFileId(file.children) : undefined - if (childFileId) - return childFileId + if (childFileId) return childFileId } } const getFirstSkillFileId = (files: AgentFileNode[]): string | undefined => { for (const file of files) { - if (file.icon !== 'folder') - return file.id + if (file.icon !== 'folder') return file.id const childFileId = file.children ? getFirstSkillFileId(file.children) : undefined - if (childFileId) - return childFileId + if (childFileId) return childFileId } } const findSkillFileById = (files: AgentFileNode[], fileId?: string): AgentFileNode | undefined => { - if (!fileId) - return undefined + if (!fileId) return undefined for (const file of files) { - if (file.id === fileId) - return file + if (file.id === fileId) return file const childFile = file.children ? findSkillFileById(file.children, fileId) : undefined - if (childFile) - return childFile + if (childFile) return childFile } } @@ -189,12 +182,18 @@ export function useAgentSkillDetail({ () => toSkillFileTree(inspectQuery.data?.files ?? []), [inspectQuery.data?.files], ) - const previewFileId = selectedFileId - ?? inspectQuery.data?.skill_md.path - ?? (inspectQuery.isSuccess ? getSkillMdFileId(detailFiles) ?? getFirstSkillFileId(detailFiles) : undefined) + const previewFileId = + selectedFileId ?? + inspectQuery.data?.skill_md.path ?? + (inspectQuery.isSuccess + ? (getSkillMdFileId(detailFiles) ?? getFirstSkillFileId(detailFiles)) + : undefined) const selectedFile = findSkillFileById(detailFiles, previewFileId) - const isSkillMdSelected = previewFileId === inspectQuery.data?.skill_md.path || selectedFile?.name === 'SKILL.md' - const selectedPreviewPath = isSkillMdSelected ? undefined : selectedFile?.configName ?? selectedFile?.id + const isSkillMdSelected = + previewFileId === inspectQuery.data?.skill_md.path || selectedFile?.name === 'SKILL.md' + const selectedPreviewPath = isSkillMdSelected + ? undefined + : (selectedFile?.configName ?? selectedFile?.id) const agentPreviewQuery = useQuery({ ...consoleQuery.agent.byAgentId.config.skills.byName.files.preview.get.queryOptions({ input: { @@ -230,7 +229,8 @@ export function useAgentSkillDetail({ }) const previewQuery = apiContext.workflow ? workflowPreviewQuery : agentPreviewQuery const isImagePreviewFile = selectedFile?.icon === 'image' - const shouldDownloadPreviewFile = isOpen && !!selectedPreviewPath && (isImagePreviewFile || !!previewQuery.data?.binary) + const shouldDownloadPreviewFile = + isOpen && !!selectedPreviewPath && (isImagePreviewFile || !!previewQuery.data?.binary) const agentDownloadQuery = useQuery({ ...consoleQuery.agent.byAgentId.config.skills.byName.files.download.get.queryOptions({ input: { @@ -266,8 +266,7 @@ export function useAgentSkillDetail({ }) const downloadQuery = apiContext.workflow ? workflowDownloadQuery : agentDownloadQuery const handleDownloadFile = useCallback(async () => { - if (!selectedFile) - return + if (!selectedFile) return const file = selectedFile const path = file.configName ?? file.id @@ -282,39 +281,50 @@ export function useAgentSkillDetail({ } if (apiContext.workflow) { - const result = await queryClient.fetchQuery(consoleQuery.apps.byAppId.agent.config.skills.byName.files.download.get.queryOptions({ + const result = await queryClient.fetchQuery( + consoleQuery.apps.byAppId.agent.config.skills.byName.files.download.get.queryOptions({ + input: { + params: { + app_id: apiContext.workflow.appId, + name: skill.name, + }, + query: { + node_id: apiContext.workflow.nodeId, + path, + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + }, + }), + ) + downloadUrl({ url: result.url, fileName: file.name }) + return + } + + const result = await queryClient.fetchQuery( + consoleQuery.agent.byAgentId.config.skills.byName.files.download.get.queryOptions({ input: { params: { - app_id: apiContext.workflow.appId, + agent_id: apiContext.agentId, name: skill.name, }, query: { - node_id: apiContext.workflow.nodeId, path, draft_type: apiContext.draftType, version_id: apiContext.versionId, }, }, - })) - downloadUrl({ url: result.url, fileName: file.name }) - return - } - - const result = await queryClient.fetchQuery(consoleQuery.agent.byAgentId.config.skills.byName.files.download.get.queryOptions({ - input: { - params: { - agent_id: apiContext.agentId, - name: skill.name, - }, - query: { - path, - draft_type: apiContext.draftType, - version_id: apiContext.versionId, - }, - }, - })) + }), + ) downloadUrl({ url: result.url, fileName: file.name }) - }, [apiContext, inspectQuery.data?.skill_md.path, inspectQuery.data?.skill_md.text, queryClient, selectedFile, skill.name]) + }, [ + apiContext, + inspectQuery.data?.skill_md.path, + inspectQuery.data?.skill_md.text, + queryClient, + selectedFile, + skill.name, + ]) return { description, @@ -322,17 +332,23 @@ export function useAgentSkillDetail({ files: detailFiles, filePreview: { binary: isSkillMdSelected ? inspectQuery.data?.skill_md.binary : previewQuery.data?.binary, - content: isSkillMdSelected ? inspectQuery.data?.skill_md.text ?? undefined : previewQuery.data?.text ?? undefined, + content: isSkillMdSelected + ? (inspectQuery.data?.skill_md.text ?? undefined) + : (previewQuery.data?.text ?? undefined), downloadUrl: downloadQuery.data?.url, fileName: selectedFile?.name, isDownloadError: downloadQuery.isError, isDownloadLoading: shouldDownloadPreviewFile && downloadQuery.isPending, - isError: isSkillMdSelected ? inspectQuery.isError : !!selectedPreviewPath && previewQuery.isError, + isError: isSkillMdSelected + ? inspectQuery.isError + : !!selectedPreviewPath && previewQuery.isError, isImage: isImagePreviewFile, - isLoading: isSkillMdSelected ? inspectQuery.isPending : !!selectedPreviewPath && previewQuery.isPending, + isLoading: isSkillMdSelected + ? inspectQuery.isPending + : !!selectedPreviewPath && previewQuery.isPending, }, onDownloadFile: handleDownloadFile, - onSelectFile: file => setSelectedFileId(file.id), + onSelectFile: (file) => setSelectedFileId(file.id), selectedFileId: previewFileId, sections: [], } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx index 6c8e62dde3525d..adb2b0ce4118f1 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx @@ -23,15 +23,11 @@ const toolProviderState = vi.hoisted(() => ({ })) vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({ - ToolPickerContent: () => ( - <div> - Mock tool picker - </div> - ), + ToolPickerContent: () => <div>Mock tool picker</div>, })) vi.mock('@/app/components/workflow/block-icon', () => ({ - default: ({ toolIcon }: { toolIcon?: string | { content: string, background: string } }) => ( + default: ({ toolIcon }: { toolIcon?: string | { content: string; background: string } }) => ( <span aria-hidden data-testid="tool-icon"> {typeof toolIcon === 'string' ? toolIcon : toolIcon?.content} </span> @@ -56,9 +52,13 @@ vi.mock('@/app/components/plugins/plugin-auth/authorize/add-oauth-button', () => })) vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({ - default: ({ formSchemas }: { formSchemas: Array<{ label?: Record<string, string>, variable?: string }> }) => ( + default: ({ + formSchemas, + }: { + formSchemas: Array<{ label?: Record<string, string>; variable?: string }> + }) => ( <div data-testid="tool-setting-form"> - {formSchemas.map(schema => ( + {formSchemas.map((schema) => ( <div key={schema.variable}>{schema.label?.en_US}</div> ))} </div> @@ -340,13 +340,17 @@ describe('AgentTools', () => { const user = userEvent.setup() renderAgentTools() - await user.click(screen.getByRole('button', { - name: 'DuckDuckGo', - })) + await user.click( + screen.getByRole('button', { + name: 'DuckDuckGo', + }), + ) - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.tools.removeAction:{"name":"DuckDuckGo Image Search"}', - })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.tools.removeAction:{"name":"DuckDuckGo Image Search"}', + }), + ) expect(screen.queryByText('DuckDuckGo Image Search')).not.toBeInTheDocument() expect(screen.getByText('DuckDuckGo Search')).toBeInTheDocument() @@ -357,12 +361,16 @@ describe('AgentTools', () => { const user = userEvent.setup() renderAgentTools() - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.tools.moreActions:{"name":"DuckDuckGo"}', - })) - await user.click(screen.getByRole('menuitem', { - name: /agentV2\.agentDetail\.configure\.tools\.removeProvider/, - })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.tools.moreActions:{"name":"DuckDuckGo"}', + }), + ) + await user.click( + screen.getByRole('menuitem', { + name: /agentV2\.agentDetail\.configure\.tools\.removeProvider/, + }), + ) expect(screen.queryByText('DuckDuckGo')).not.toBeInTheDocument() expect(screen.queryByText('DuckDuckGo Search')).not.toBeInTheDocument() @@ -373,81 +381,113 @@ describe('AgentTools', () => { const user = userEvent.setup() renderAgentTools() - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.tools.add', - })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.tools.add', + }), + ) expect(screen.getByText('Mock tool picker')).toBeInTheDocument() - expect(screen.queryByRole('button', { - name: /agentV2\.agentDetail\.configure\.tools\.addMenu\.cliTool\.label/, - })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { - name: /agentV2\.agentDetail\.configure\.tools\.addMenu\.tool\.label/, - })).not.toBeInTheDocument() - expect(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.tools.add', - })).toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: /agentV2\.agentDetail\.configure\.tools\.addMenu\.cliTool\.label/, + }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: /agentV2\.agentDetail\.configure\.tools\.addMenu\.tool\.label/, + }), + ).not.toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.tools.add', + }), + ).toBeInTheDocument() }) it('should hide add, edit, and remove actions when readonly', async () => { const user = userEvent.setup() renderReadonlyAgentTools() - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.tools.add', - })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.tools.moreActions:{"name":"DuckDuckGo"}', - })).not.toBeInTheDocument() - - await user.click(screen.getByRole('button', { - name: 'DuckDuckGo', - })) - - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.tools.editAction:{"name":"DuckDuckGo Search"}', - })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.tools.removeAction:{"name":"DuckDuckGo Image Search"}', - })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.tools.editAction:{"name":"Lark CLI"}', - })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.tools.removeAction:{"name":"Lark CLI"}', - })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.tools.add', + }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.tools.moreActions:{"name":"DuckDuckGo"}', + }), + ).not.toBeInTheDocument() + + await user.click( + screen.getByRole('button', { + name: 'DuckDuckGo', + }), + ) + + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.tools.editAction:{"name":"DuckDuckGo Search"}', + }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.tools.removeAction:{"name":"DuckDuckGo Image Search"}', + }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.tools.editAction:{"name":"Lark CLI"}', + }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.tools.removeAction:{"name":"Lark CLI"}', + }), + ).not.toBeInTheDocument() }) it('should hide CLI tool rows while CLI tools are disabled', () => { renderAgentTools() expect(screen.queryByText('Lark CLI')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.tools.editAction:{"name":"Lark CLI"}', - })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.tools.removeAction:{"name":"Lark CLI"}', - })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.tools.editAction:{"name":"Lark CLI"}', + }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.tools.removeAction:{"name":"Lark CLI"}', + }), + ).not.toBeInTheDocument() }) }) describe('Display Metadata', () => { it('should enrich reflected provider tools with provider icon and localized names', async () => { const user = userEvent.setup() - toolProviderState.builtInTools = [{ - ...googleProvider, - allow_delete: false, - }] + toolProviderState.builtInTools = [ + { + ...googleProvider, + allow_delete: false, + }, + ] renderAgentTools(reflectedAgentToolsDraft) - expect(screen.getByRole('button', { - name: 'Google Tools', - })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'Google Tools', + }), + ).toBeInTheDocument() expect(screen.getByText('https://example.com/google.svg')).toBeInTheDocument() - await user.click(screen.getByRole('button', { - name: 'Google Tools', - })) + await user.click( + screen.getByRole('button', { + name: 'Google Tools', + }), + ) expect(screen.getByText('Google Search')).toBeInTheDocument() }) @@ -456,9 +496,11 @@ describe('AgentTools', () => { toolProviderState.builtInTools = [duckDuckGoProvider] renderAgentTools(reflectedUnauthorizedNoCredentialDraft) - expect(screen.getByRole('button', { - name: 'DuckDuckGo', - })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'DuckDuckGo', + }), + ).toBeInTheDocument() expect(screen.queryByText('tools.notAuthorized')).not.toBeInTheDocument() }) @@ -466,9 +508,11 @@ describe('AgentTools', () => { toolProviderState.builtInTools = [duckDuckGoProvider] const { store } = renderAgentToolsWithStore(reflectedUnauthorizedNoCredentialDraft) - expect(screen.getByRole('button', { - name: 'DuckDuckGo', - })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'DuckDuckGo', + }), + ).toBeInTheDocument() expect(screen.queryByText('tools.notAuthorized')).not.toBeInTheDocument() expect(store.get(agentComposerDraftAtom).tools[0]).toMatchObject({ credentialType: 'unauthorized', @@ -478,17 +522,21 @@ describe('AgentTools', () => { }) it('should show authorization action for reflected OAuth provider tools with unauthorized credential type', () => { - toolProviderState.builtInTools = [{ - ...googleProvider, - allow_delete: true, - is_team_authorization: false, - team_credentials: {}, - }] + toolProviderState.builtInTools = [ + { + ...googleProvider, + allow_delete: true, + is_team_authorization: false, + team_credentials: {}, + }, + ] renderAgentTools(reflectedUnauthorizedOAuthCredentialTypeDraft) - expect(screen.getByRole('button', { - name: 'tools.notAuthorized', - })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'tools.notAuthorized', + }), + ).toBeInTheDocument() expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument() }) @@ -497,12 +545,16 @@ describe('AgentTools', () => { toolProviderState.builtInTools = [duckDuckGoProvider] const { baseElement } = renderAgentTools() - await user.click(screen.getByRole('button', { - name: 'DuckDuckGo', - })) - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.tools.editAction:{"name":"DuckDuckGo Search"}', - })) + await user.click( + screen.getByRole('button', { + name: 'DuckDuckGo', + }), + ) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.tools.editAction:{"name":"DuckDuckGo Search"}', + }), + ) expect(baseElement.querySelector('[style*="duckduckgo.svg"]')).toBeInTheDocument() expect(screen.getByTestId('tool-setting-form')).toBeInTheDocument() @@ -514,12 +566,16 @@ describe('AgentTools', () => { toolProviderState.builtInTools = [duckDuckGoProvider] const { store } = renderAgentToolsWithStore(agentToolsDraft) - await user.click(screen.getByRole('button', { - name: 'DuckDuckGo', - })) - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.tools.editAction:{"name":"DuckDuckGo Search"}', - })) + await user.click( + screen.getByRole('button', { + name: 'DuckDuckGo', + }), + ) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.tools.editAction:{"name":"DuckDuckGo Search"}', + }), + ) expect(screen.getByTestId('tool-setting-form')).toBeInTheDocument() diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/cli-tool/__tests__/dialog.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/cli-tool/__tests__/dialog.spec.tsx index 763bd30e31b9b9..7210537f9861ac 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/cli-tool/__tests__/dialog.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/cli-tool/__tests__/dialog.spec.tsx @@ -17,12 +17,7 @@ function renderCliToolDialog(props?: Partial<CliToolDialogProps>) { const onSaveCliTool = vi.fn() render( - <CliToolDialog - open - onOpenChange={onOpenChange} - onSaveCliTool={onSaveCliTool} - {...props} - />, + <CliToolDialog open onOpenChange={onOpenChange} onSaveCliTool={onSaveCliTool} {...props} />, ) return { @@ -41,14 +36,20 @@ describe('CliToolDialog', () => { const user = userEvent.setup() const { onOpenChange, onSaveCliTool } = renderCliToolDialog() - await user.click(screen.getByRole('button', { - name: 'common.operation.add', - })) + await user.click( + screen.getByRole('button', { + name: 'common.operation.add', + }), + ) expect(onSaveCliTool).not.toHaveBeenCalled() expect(onOpenChange).not.toHaveBeenCalledWith(false) - expect(toast.error).toHaveBeenCalledWith('agentV2.agentDetail.configure.tools.cliDialog.installCommand.required') - expect(screen.queryByText('agentV2.agentDetail.configure.tools.cliDialog.installCommand.required')).not.toBeInTheDocument() + expect(toast.error).toHaveBeenCalledWith( + 'agentV2.agentDetail.configure.tools.cliDialog.installCommand.required', + ) + expect( + screen.queryByText('agentV2.agentDetail.configure.tools.cliDialog.installCommand.required'), + ).not.toBeInTheDocument() }) it('should show a toast error when CLI tool name is empty', async () => { @@ -61,14 +62,20 @@ describe('CliToolDialog', () => { }), 'npm install -g @lark/cli', ) - await user.click(screen.getByRole('button', { - name: 'common.operation.add', - })) + await user.click( + screen.getByRole('button', { + name: 'common.operation.add', + }), + ) expect(onSaveCliTool).not.toHaveBeenCalled() expect(onOpenChange).not.toHaveBeenCalledWith(false) - expect(toast.error).toHaveBeenCalledWith('agentV2.agentDetail.configure.tools.cliDialog.name.required') - expect(screen.queryByText('agentV2.agentDetail.configure.tools.cliDialog.name.required')).not.toBeInTheDocument() + expect(toast.error).toHaveBeenCalledWith( + 'agentV2.agentDetail.configure.tools.cliDialog.name.required', + ) + expect( + screen.queryByText('agentV2.agentDetail.configure.tools.cliDialog.name.required'), + ).not.toBeInTheDocument() }) it('should save a CLI tool when required fields are filled', async () => { @@ -87,29 +94,37 @@ describe('CliToolDialog', () => { }), 'Lark CLI', ) - await user.click(screen.getByRole('button', { - name: 'common.operation.add', - })) - - expect(onSaveCliTool).toHaveBeenCalledWith(expect.objectContaining({ - kind: 'cli', - name: 'Lark CLI', - installCommand: 'npm install -g @lark/cli', - })) + await user.click( + screen.getByRole('button', { + name: 'common.operation.add', + }), + ) + + expect(onSaveCliTool).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'cli', + name: 'Lark CLI', + installCommand: 'npm install -g @lark/cli', + }), + ) expect(onOpenChange).toHaveBeenCalledWith(false) }) it('should reject environment variable keys using the shared env editor rules', () => { renderCliToolDialog() - const keyInput = screen.getByPlaceholderText('agentV2.agentDetail.configure.advancedSettings.envEditor.keyPlaceholder') + const keyInput = screen.getByPlaceholderText( + 'agentV2.agentDetail.configure.advancedSettings.envEditor.keyPlaceholder', + ) fireEvent.change(keyInput, { target: { value: '1BAD' }, }) expect(keyInput).toHaveValue('') - expect(toast.error).toHaveBeenCalledWith('appDebug.varKeyError.notStartWithNumber:{"key":"agentV2.agentDetail.configure.advancedSettings.envEditor.keyColumn"}') + expect(toast.error).toHaveBeenCalledWith( + 'appDebug.varKeyError.notStartWithNumber:{"key":"agentV2.agentDetail.configure.advancedSettings.envEditor.keyColumn"}', + ) }) }) @@ -127,9 +142,11 @@ describe('CliToolDialog', () => { expect(onOpenChange).not.toHaveBeenCalledWith(false) expect(dialog).toBeInTheDocument() - await user.click(screen.getByRole('button', { - name: 'common.operation.cancel', - })) + await user.click( + screen.getByRole('button', { + name: 'common.operation.cancel', + }), + ) expect(onOpenChange).toHaveBeenCalledWith(false) }) @@ -144,12 +161,16 @@ describe('CliToolDialog', () => { }, }) - expect(screen.getByRole('button', { - name: 'common.operation.save', - })).toBeInTheDocument() - expect(screen.queryByRole('button', { - name: 'common.operation.add', - })).not.toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'common.operation.save', + }), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'common.operation.add', + }), + ).not.toBeInTheDocument() }) it('should remove a CLI tool from the edit footer', async () => { @@ -166,9 +187,11 @@ describe('CliToolDialog', () => { }, }) - await user.click(screen.getByRole('button', { - name: 'common.operation.remove', - })) + await user.click( + screen.getByRole('button', { + name: 'common.operation.remove', + }), + ) expect(onDeleteCliTool).toHaveBeenCalledWith('lark-cli') expect(onOpenChange).toHaveBeenCalledWith(false) @@ -180,9 +203,11 @@ describe('CliToolDialog', () => { onDeleteCliTool: vi.fn(), }) - expect(screen.queryByRole('button', { - name: 'common.operation.remove', - })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'common.operation.remove', + }), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/cli-tool/dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/cli-tool/dialog.tsx index b3090bc81479fb..23c114b49cfc37 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/cli-tool/dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/cli-tool/dialog.tsx @@ -1,8 +1,18 @@ 'use client' -import type { AgentCliTool, EnvScope, EnvVariable } from '@/features/agent-v2/agent-composer/form-state' +import type { + AgentCliTool, + EnvScope, + EnvVariable, +} from '@/features/agent-v2/agent-composer/form-state' import { Button } from '@langgenius/dify-ui/button' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { Field, FieldControl, FieldDescription, FieldLabel } from '@langgenius/dify-ui/field' import { Form } from '@langgenius/dify-ui/form' import { toast } from '@langgenius/dify-ui/toast' @@ -15,7 +25,8 @@ type CliToolFormValues = { name?: string } -const createCliToolId = () => `cli-tool-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}` +const createCliToolId = () => + `cli-tool-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}` const createCliEnvVariable = (): EnvVariable => ({ id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`, @@ -43,7 +54,9 @@ export function CliToolDialog({ const { t: tCommon } = useTranslation('common') const [installCommand, setInstallCommand] = useState(tool?.installCommand ?? '') const [toolName, setToolName] = useState(tool?.name ?? '') - const [envVariables, setEnvVariables] = useState<EnvVariable[]>(() => tool?.envVariables?.length ? tool.envVariables : [createCliEnvVariable()]) + const [envVariables, setEnvVariables] = useState<EnvVariable[]>(() => + tool?.envVariables?.length ? tool.envVariables : [createCliEnvVariable()], + ) const resetForm = useCallback(() => { setInstallCommand(tool?.installCommand ?? '') @@ -51,76 +64,93 @@ export function CliToolDialog({ setEnvVariables(tool?.envVariables?.length ? tool.envVariables : [createCliEnvVariable()]) }, [tool]) - const handleOpenChange = useCallback((nextOpen: boolean) => { - if (nextOpen) - resetForm() + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (nextOpen) resetForm() - onOpenChange(nextOpen) - }, [onOpenChange, resetForm]) + onOpenChange(nextOpen) + }, + [onOpenChange, resetForm], + ) - const updateEnvVariable = useCallback((id: string, updater: (variable: EnvVariable) => EnvVariable) => { - setEnvVariables(currentVariables => currentVariables.map(variable => ( - variable.id === id ? updater(variable) : variable - ))) - }, []) + const updateEnvVariable = useCallback( + (id: string, updater: (variable: EnvVariable) => EnvVariable) => { + setEnvVariables((currentVariables) => + currentVariables.map((variable) => (variable.id === id ? updater(variable) : variable)), + ) + }, + [], + ) const addEnvVariable = useCallback(() => { - setEnvVariables(currentVariables => [...currentVariables, createCliEnvVariable()]) + setEnvVariables((currentVariables) => [...currentVariables, createCliEnvVariable()]) }, []) const deleteEnvVariable = useCallback((id: string) => { - setEnvVariables(currentVariables => currentVariables.length > 1 - ? currentVariables.filter(variable => variable.id !== id) - : [createCliEnvVariable()], + setEnvVariables((currentVariables) => + currentVariables.length > 1 + ? currentVariables.filter((variable) => variable.id !== id) + : [createCliEnvVariable()], ) }, []) - const handleKeyChange = useCallback((id: string, key: string) => { - updateEnvVariable(id, variable => ({ ...variable, key })) - }, [updateEnvVariable]) - - const handleScopeChange = useCallback((id: string, scope: EnvScope) => { - updateEnvVariable(id, variable => ({ ...variable, scope })) - }, [updateEnvVariable]) - - const handleValueChange = useCallback((id: string, value: string) => { - updateEnvVariable(id, variable => ({ ...variable, value })) - }, [updateEnvVariable]) - - const handleSubmit = useCallback((formValues: CliToolFormValues) => { - const trimmedName = formValues.name?.trim() || toolName.trim() - const trimmedInstallCommand = formValues.installCommand?.trim() || installCommand.trim() - - if (!trimmedInstallCommand) { - toast.error(t($ => $['agentDetail.configure.tools.cliDialog.installCommand.required'])) - return - } - if (!trimmedName) { - toast.error(t($ => $['agentDetail.configure.tools.cliDialog.name.required'])) - return - } - - onSaveCliTool({ - ...tool, - id: tool?.id ?? createCliToolId(), - kind: 'cli', - name: trimmedName, - installCommand: trimmedInstallCommand, - envVariables, - }) - setInstallCommand('') - setToolName('') - setEnvVariables([createCliEnvVariable()]) - onOpenChange(false) - }, [envVariables, installCommand, onOpenChange, onSaveCliTool, t, tool, toolName]) + const handleKeyChange = useCallback( + (id: string, key: string) => { + updateEnvVariable(id, (variable) => ({ ...variable, key })) + }, + [updateEnvVariable], + ) + + const handleScopeChange = useCallback( + (id: string, scope: EnvScope) => { + updateEnvVariable(id, (variable) => ({ ...variable, scope })) + }, + [updateEnvVariable], + ) + + const handleValueChange = useCallback( + (id: string, value: string) => { + updateEnvVariable(id, (variable) => ({ ...variable, value })) + }, + [updateEnvVariable], + ) + + const handleSubmit = useCallback( + (formValues: CliToolFormValues) => { + const trimmedName = formValues.name?.trim() || toolName.trim() + const trimmedInstallCommand = formValues.installCommand?.trim() || installCommand.trim() + + if (!trimmedInstallCommand) { + toast.error(t(($) => $['agentDetail.configure.tools.cliDialog.installCommand.required'])) + return + } + if (!trimmedName) { + toast.error(t(($) => $['agentDetail.configure.tools.cliDialog.name.required'])) + return + } + + onSaveCliTool({ + ...tool, + id: tool?.id ?? createCliToolId(), + kind: 'cli', + name: trimmedName, + installCommand: trimmedInstallCommand, + envVariables, + }) + setInstallCommand('') + setToolName('') + setEnvVariables([createCliEnvVariable()]) + onOpenChange(false) + }, + [envVariables, installCommand, onOpenChange, onSaveCliTool, t, tool, toolName], + ) const handleCancel = useCallback(() => { onOpenChange(false) }, [onOpenChange]) const handleDelete = useCallback(() => { - if (!tool) - return + if (!tool) return onDeleteCliTool?.(tool.id) onOpenChange(false) @@ -132,10 +162,17 @@ export function CliToolDialog({ <div className="relative px-6 pt-6 pb-3"> <DialogCloseButton /> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $[mode === 'edit' ? 'agentDetail.configure.tools.cliDialog.editTitle' : 'agentDetail.configure.tools.cliDialog.title'])} + {t( + ($) => + $[ + mode === 'edit' + ? 'agentDetail.configure.tools.cliDialog.editTitle' + : 'agentDetail.configure.tools.cliDialog.title' + ], + )} </DialogTitle> <DialogDescription className="mt-1 system-xs-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.tools.cliDialog.description'])} + {t(($) => $['agentDetail.configure.tools.cliDialog.description'])} </DialogDescription> </div> <Form<CliToolFormValues> @@ -145,26 +182,28 @@ export function CliToolDialog({ <div className="flex flex-col gap-4"> <Field name="installCommand"> <FieldLabel> - {t($ => $['agentDetail.configure.tools.cliDialog.installCommand.label'])} + {t(($) => $['agentDetail.configure.tools.cliDialog.installCommand.label'])} </FieldLabel> <FieldDescription> - {t($ => $['agentDetail.configure.tools.cliDialog.installCommand.description'])} + {t(($) => $['agentDetail.configure.tools.cliDialog.installCommand.description'])} </FieldDescription> <FieldControl autoComplete="off" onValueChange={setInstallCommand} - placeholder={t($ => $['agentDetail.configure.tools.cliDialog.installCommand.placeholder'])} + placeholder={t( + ($) => $['agentDetail.configure.tools.cliDialog.installCommand.placeholder'], + )} value={installCommand} /> </Field> <Field name="name"> <FieldLabel> - {t($ => $['agentDetail.configure.tools.cliDialog.name.label'])} + {t(($) => $['agentDetail.configure.tools.cliDialog.name.label'])} </FieldLabel> <FieldControl autoComplete="off" onValueChange={setToolName} - placeholder={t($ => $['agentDetail.configure.tools.cliDialog.name.placeholder'])} + placeholder={t(($) => $['agentDetail.configure.tools.cliDialog.name.placeholder'])} value={toolName} /> </Field> @@ -172,14 +211,14 @@ export function CliToolDialog({ <div className="mb-3 h-px bg-divider-subtle" /> <div className="mb-1 flex min-h-6 items-center gap-1"> <span className="system-sm-medium text-text-secondary"> - {t($ => $['agentDetail.configure.tools.cliDialog.env.label'])} + {t(($) => $['agentDetail.configure.tools.cliDialog.env.label'])} </span> <span className="system-xs-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.tools.cliDialog.env.optional'])} + {t(($) => $['agentDetail.configure.tools.cliDialog.env.optional'])} </span> </div> <p className="mb-2 body-xs-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.tools.cliDialog.env.description'])} + {t(($) => $['agentDetail.configure.tools.cliDialog.env.description'])} </p> <EnvVariablesTable editable @@ -201,24 +240,24 @@ export function CliToolDialog({ rel="noreferrer" className="inline-flex min-w-0 flex-1 items-center gap-1 system-xs-regular text-text-accent hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > - <span>{t($ => $['agentDetail.configure.tools.cliDialog.learnMore'])}</span> + <span>{t(($) => $['agentDetail.configure.tools.cliDialog.learnMore'])}</span> <span aria-hidden className="i-ri-external-link-line size-3" /> </a> <div className="flex shrink-0 items-center gap-3"> {mode === 'edit' && tool && onDeleteCliTool && ( <div className="flex items-center gap-3"> <Button type="button" tone="destructive" onClick={handleDelete}> - {tCommon($ => $['operation.remove'])} + {tCommon(($) => $['operation.remove'])} </Button> <div className="h-4 w-px bg-divider-regular" aria-hidden /> </div> )} <div className="flex items-center gap-2"> <Button type="button" onClick={handleCancel}> - {tCommon($ => $['operation.cancel'])} + {tCommon(($) => $['operation.cancel'])} </Button> <Button type="submit" variant="primary"> - {tCommon($ => $[mode === 'edit' ? 'operation.save' : 'operation.add'])} + {tCommon(($) => $[mode === 'edit' ? 'operation.save' : 'operation.add'])} </Button> </div> </div> @@ -227,7 +266,7 @@ export function CliToolDialog({ <div className="flex items-start justify-center gap-1 border-t-[0.5px] border-divider-subtle bg-background-soft px-2 py-3"> <span aria-hidden className="mt-0.5 i-ri-lock-2-fill size-3 text-text-tertiary" /> <p className="system-xs-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.tools.cliDialog.securityTip'])} + {t(($) => $['agentDetail.configure.tools.cliDialog.securityTip'])} </p> </div> </DialogContent> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/cli-tool/item.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/cli-tool/item.tsx index 9d16d3176da8ae..f7c4d0faa311df 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/cli-tool/item.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/cli-tool/item.tsx @@ -27,9 +27,9 @@ export function AgentCliToolItem({ <ConfigureSectionConfigurableItem icon={<CliIcon />} label={tool.name} - badge={t($ => $['agentDetail.configure.tools.cliTool'])} - editAriaLabel={t($ => $['agentDetail.configure.tools.editAction'], { name: tool.name })} - removeAriaLabel={t($ => $['agentDetail.configure.tools.removeAction'], { name: tool.name })} + badge={t(($) => $['agentDetail.configure.tools.cliTool'])} + editAriaLabel={t(($) => $['agentDetail.configure.tools.editAction'], { name: tool.name })} + removeAriaLabel={t(($) => $['agentDetail.configure.tools.removeAction'], { name: tool.name })} onEdit={onEdit} onRemove={onDelete} /> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/hooks.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/hooks.ts index cc3dd64ac31003..34dfd509d1124c 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/hooks.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/hooks.ts @@ -14,10 +14,9 @@ import { } from '@/features/agent-v2/agent-composer/store-modules/tools' const toSelectedToolValue = (tool: AgentTool): ToolValue[] => { - if (tool.kind !== 'provider') - return [] + if (tool.kind !== 'provider') return [] - return tool.actions.map(action => ({ + return tool.actions.map((action) => ({ provider_name: tool.id, tool_name: action.toolName, tool_label: action.name, @@ -38,24 +37,28 @@ export function useProviderToolSettingsSurface() { const closeSettingTargetIfRemoved = useCallback((toolId: string, actionId?: string) => { setSettingTarget((target) => { - if (!target || target.toolId !== toolId) - return target - if (actionId && target.actionId !== actionId) - return target + if (!target || target.toolId !== toolId) return target + if (actionId && target.actionId !== actionId) return target return null }) }, []) - const deleteProviderTool = useCallback((toolId: string) => { - closeSettingTargetIfRemoved(toolId) - removeProviderTool(toolId) - }, [closeSettingTargetIfRemoved, removeProviderTool]) - - const deleteProviderToolAction = useCallback((toolId: string, actionId: string) => { - closeSettingTargetIfRemoved(toolId, actionId) - removeProviderToolAction({ toolId, actionId }) - }, [closeSettingTargetIfRemoved, removeProviderToolAction]) + const deleteProviderTool = useCallback( + (toolId: string) => { + closeSettingTargetIfRemoved(toolId) + removeProviderTool(toolId) + }, + [closeSettingTargetIfRemoved, removeProviderTool], + ) + + const deleteProviderToolAction = useCallback( + (toolId: string, actionId: string) => { + closeSettingTargetIfRemoved(toolId, actionId) + removeProviderToolAction({ toolId, actionId }) + }, + [closeSettingTargetIfRemoved, removeProviderToolAction], + ) const closeProviderSettingsDialog = useCallback(() => { setSettingTarget(null) @@ -86,14 +89,16 @@ export function useCliToolDialogSurface() { setIsCliToolDialogOpen(true) }, []) - const handleCliDialogSave = useCallback((tool: AgentCliTool) => { - saveCliTool(tool) - setEditingCliTool(null) - }, [saveCliTool]) + const handleCliDialogSave = useCallback( + (tool: AgentCliTool) => { + saveCliTool(tool) + setEditingCliTool(null) + }, + [saveCliTool], + ) const handleCliDialogOpenChange = useCallback((open: boolean) => { - if (!open) - setEditingCliTool(null) + if (!open) setEditingCliTool(null) setIsCliToolDialogOpen(open) }, []) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx index c8d0e2e039c823..157e747b58b952 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx @@ -4,7 +4,11 @@ import type { AgentOrchestrateAddActionOptions } from '../add-actions-context' import type { ToolSettingTarget } from './types' import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types' import type { ToolWithProvider } from '@/app/components/workflow/types' -import type { AgentCliTool, AgentProviderTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state' +import type { + AgentCliTool, + AgentProviderTool, + AgentTool, +} from '@/features/agent-v2/agent-composer/form-state' import type { AgentProviderToolDefaultValue } from '@/features/agent-v2/agent-composer/store-modules/tools' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' @@ -42,68 +46,75 @@ import { import { ProviderToolSettingsDialog } from './provider-tool/dialog' import { AgentProviderToolItem } from './provider-tool/item' -const AgentToolItem = memo(({ - tool, - onConfigureAction, - onDeleteCliTool, - onDeleteProviderTool, - onDeleteProviderToolAction, - onEditCliTool, - onCredentialChange, -}: { - tool: AgentTool - onConfigureAction: (target: ToolSettingTarget) => void - onDeleteCliTool: (toolId: string) => void - onDeleteProviderTool: (toolId: string) => void - onDeleteProviderToolAction: (toolId: string, actionId: string) => void - onEditCliTool: (tool: AgentCliTool) => void - onCredentialChange: (toolId: string, credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => void -}) => { - const [isExpanded, setIsExpanded] = useState(false) - - const handleRemoveProvider = useCallback(() => { - onDeleteProviderTool(tool.id) - }, [onDeleteProviderTool, tool.id]) - - const handleRemoveProviderAction = useCallback((actionId: string) => { - onDeleteProviderToolAction(tool.id, actionId) - }, [onDeleteProviderToolAction, tool.id]) - - const handleDeleteCliTool = useCallback(() => { - onDeleteCliTool(tool.id) - }, [onDeleteCliTool, tool.id]) - - const handleEditCliTool = useCallback(() => { - if (tool.kind === 'cli') - onEditCliTool(tool) - }, [onEditCliTool, tool]) - - const handleCredentialChange = useCallback((credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => { - onCredentialChange(tool.id, credentialId, credentialType) - }, [onCredentialChange, tool.id]) - - if (tool.kind === 'provider') { - return ( - <AgentProviderToolItem - tool={tool} - isExpanded={isExpanded} - onOpenChange={setIsExpanded} - onConfigureAction={onConfigureAction} - onRemoveAction={handleRemoveProviderAction} - onRemoveProvider={handleRemoveProvider} - onCredentialChange={handleCredentialChange} - /> +const AgentToolItem = memo( + ({ + tool, + onConfigureAction, + onDeleteCliTool, + onDeleteProviderTool, + onDeleteProviderToolAction, + onEditCliTool, + onCredentialChange, + }: { + tool: AgentTool + onConfigureAction: (target: ToolSettingTarget) => void + onDeleteCliTool: (toolId: string) => void + onDeleteProviderTool: (toolId: string) => void + onDeleteProviderToolAction: (toolId: string, actionId: string) => void + onEditCliTool: (tool: AgentCliTool) => void + onCredentialChange: ( + toolId: string, + credentialId?: string, + credentialType?: AgentProviderTool['credentialType'], + ) => void + }) => { + const [isExpanded, setIsExpanded] = useState(false) + + const handleRemoveProvider = useCallback(() => { + onDeleteProviderTool(tool.id) + }, [onDeleteProviderTool, tool.id]) + + const handleRemoveProviderAction = useCallback( + (actionId: string) => { + onDeleteProviderToolAction(tool.id, actionId) + }, + [onDeleteProviderToolAction, tool.id], ) - } - return ( - <AgentCliToolItem - tool={tool} - onDelete={handleDeleteCliTool} - onEdit={handleEditCliTool} - /> - ) -}) + const handleDeleteCliTool = useCallback(() => { + onDeleteCliTool(tool.id) + }, [onDeleteCliTool, tool.id]) + + const handleEditCliTool = useCallback(() => { + if (tool.kind === 'cli') onEditCliTool(tool) + }, [onEditCliTool, tool]) + + const handleCredentialChange = useCallback( + (credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => { + onCredentialChange(tool.id, credentialId, credentialType) + }, + [onCredentialChange, tool.id], + ) + + if (tool.kind === 'provider') { + return ( + <AgentProviderToolItem + tool={tool} + isExpanded={isExpanded} + onOpenChange={setIsExpanded} + onConfigureAction={onConfigureAction} + onRemoveAction={handleRemoveProviderAction} + onRemoveProvider={handleRemoveProvider} + onCredentialChange={handleCredentialChange} + /> + ) + } + + return ( + <AgentCliToolItem tool={tool} onDelete={handleDeleteCliTool} onEdit={handleEditCliTool} /> + ) + }, +) function useAgentToolProviderMap() { const { data: buildInTools } = useAllBuiltInTools() @@ -137,22 +148,18 @@ function useAgentToolProviderMap() { }, [buildInTools, customTools, workflowTools, mcpTools]) } -function getLocalizedText( - text: Record<string, string> | undefined, - language: string, -) { +function getLocalizedText(text: Record<string, string> | undefined, language: string) { return text?.[language] ?? text?.en_US ?? text?.zh_Hans } -function getProviderCredentialType(provider?: ToolWithProvider): AgentProviderTool['credentialType'] { - if (!provider) - return undefined +function getProviderCredentialType( + provider?: ToolWithProvider, +): AgentProviderTool['credentialType'] { + if (!provider) return undefined - if (Object.keys(provider.team_credentials ?? {}).length > 0) - return 'api-key' + if (Object.keys(provider.team_credentials ?? {}).length > 0) return 'api-key' - if (provider.type === CollectionType.builtIn && provider.allow_delete) - return 'oauth2' + if (provider.type === CollectionType.builtIn && provider.allow_delete) return 'oauth2' return undefined } @@ -161,8 +168,7 @@ function getDisplayCredentialType( tool: AgentProviderTool, providerCredentialType: AgentProviderTool['credentialType'], ) { - if (!providerCredentialType) - return undefined + if (!providerCredentialType) return undefined if (providerCredentialType === 'oauth2' && tool.credentialType === 'unauthorized') return 'oauth2' as const @@ -175,33 +181,29 @@ function getProviderCredentialVariant( provider: ToolWithProvider, providerCredentialType: AgentProviderTool['credentialType'], ) { - if (!providerCredentialType) - return 'none' as const + if (!providerCredentialType) return 'none' as const - if (tool.credentialVariant !== 'none') - return tool.credentialVariant + if (tool.credentialVariant !== 'none') return tool.credentialVariant - return tool.credentialId || provider.is_team_authorization ? 'authorized' as const : 'unauthorized' as const + return tool.credentialId || provider.is_team_authorization + ? ('authorized' as const) + : ('unauthorized' as const) } -function useDisplayTools( - tools: AgentTool[], - providerById: Map<string, ToolWithProvider>, -) { +function useDisplayTools(tools: AgentTool[], providerById: Map<string, ToolWithProvider>) { const language = useGetLanguage() return useMemo(() => { return tools.map((tool) => { - if (tool.kind !== 'provider') - return tool + if (tool.kind !== 'provider') return tool - const provider = providerById.get(tool.id) - ?? providerById.get(tool.name) + const provider = providerById.get(tool.id) ?? providerById.get(tool.name) - if (!provider) - return tool + if (!provider) return tool - const providerToolByName = new Map(provider.tools.map(providerTool => [providerTool.name, providerTool])) + const providerToolByName = new Map( + provider.tools.map((providerTool) => [providerTool.name, providerTool]), + ) const providerCredentialType = getProviderCredentialType(provider) return { @@ -212,22 +214,23 @@ function useDisplayTools( providerType: tool.providerType ?? provider.type, allowDelete: tool.allowDelete ?? provider.allow_delete, credentialKey: providerCredentialType - ? tool.credentialKey ?? 'agentDetail.configure.tools.credential.authOne' + ? (tool.credentialKey ?? 'agentDetail.configure.tools.credential.authOne') : undefined, credentialType: getDisplayCredentialType(tool, providerCredentialType), credentialVariant: getProviderCredentialVariant(tool, provider, providerCredentialType), actions: tool.actions.map((action) => { const providerTool = providerToolByName.get(action.toolName) - if (!providerTool) - return action + if (!providerTool) return action return { ...action, - name: action.name === action.toolName - ? getLocalizedText(providerTool.label, language) ?? action.name - : action.name, - description: action.description || getLocalizedText(providerTool.description, language) || '', + name: + action.name === action.toolName + ? (getLocalizedText(providerTool.label, language) ?? action.name) + : action.name, + description: + action.description || getLocalizedText(providerTool.description, language) || '', } }), } satisfies AgentProviderTool @@ -254,21 +257,20 @@ function AddToolMenuItem({ onClick={onClick} className="flex w-full items-start gap-2 rounded-lg py-2 pr-3 pl-2 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > - <span aria-hidden className={cn('mt-0.5 size-4 shrink-0 text-text-secondary', iconClassName)} /> + <span + aria-hidden + className={cn('mt-0.5 size-4 shrink-0 text-text-secondary', iconClassName)} + /> <span className="flex min-w-0 flex-1 flex-col gap-0.5"> <span className="flex min-w-0 items-center gap-1"> - <span className="truncate system-sm-semibold text-text-secondary"> - {label} - </span> + <span className="truncate system-sm-semibold text-text-secondary">{label}</span> {badge && ( <span className="shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> {badge} </span> )} </span> - <span className="system-xs-regular text-text-tertiary"> - {description} - </span> + <span className="system-xs-regular text-text-tertiary">{description}</span> </span> </button> ) @@ -306,75 +308,89 @@ function AddToolMenu({ const handleOpenChange = useCallback((nextOpen: boolean) => { setOpen(nextOpen) - if (nextOpen) - setView(addToolDefaultView) + if (nextOpen) setView(addToolDefaultView) }, []) - const toAgentToolDefaultValue = useCallback((tool: ToolDefaultValue): AgentProviderToolDefaultValue => ({ - ...tool, - allowDelete: (providerById.get(tool.provider_id) - ?? providerById.get(tool.provider_name) - ?? (tool.plugin_id ? providerById.get(tool.plugin_id) : undefined))?.allow_delete, - credentialType: getProviderCredentialType(providerById.get(tool.provider_id) - ?? providerById.get(tool.provider_name) - ?? (tool.plugin_id ? providerById.get(tool.plugin_id) : undefined)), - credentialRequired: !!getProviderCredentialType(providerById.get(tool.provider_id) - ?? providerById.get(tool.provider_name) - ?? (tool.plugin_id ? providerById.get(tool.plugin_id) : undefined)), - }), [providerById]) - - const handleSelectTool = useCallback((tool: ToolDefaultValue) => { - onAddTools([toAgentToolDefaultValue(tool)]) - }, [onAddTools, toAgentToolDefaultValue]) - - const handleSelectMultipleTools = useCallback((tools: ToolDefaultValue[]) => { - onAddTools(tools.map(toAgentToolDefaultValue)) - }, [onAddTools, toAgentToolDefaultValue]) + const toAgentToolDefaultValue = useCallback( + (tool: ToolDefaultValue): AgentProviderToolDefaultValue => ({ + ...tool, + allowDelete: ( + providerById.get(tool.provider_id) ?? + providerById.get(tool.provider_name) ?? + (tool.plugin_id ? providerById.get(tool.plugin_id) : undefined) + )?.allow_delete, + credentialType: getProviderCredentialType( + providerById.get(tool.provider_id) ?? + providerById.get(tool.provider_name) ?? + (tool.plugin_id ? providerById.get(tool.plugin_id) : undefined), + ), + credentialRequired: !!getProviderCredentialType( + providerById.get(tool.provider_id) ?? + providerById.get(tool.provider_name) ?? + (tool.plugin_id ? providerById.get(tool.plugin_id) : undefined), + ), + }), + [providerById], + ) + + const handleSelectTool = useCallback( + (tool: ToolDefaultValue) => { + onAddTools([toAgentToolDefaultValue(tool)]) + }, + [onAddTools, toAgentToolDefaultValue], + ) + + const handleSelectMultipleTools = useCallback( + (tools: ToolDefaultValue[]) => { + onAddTools(tools.map(toAgentToolDefaultValue)) + }, + [onAddTools, toAgentToolDefaultValue], + ) return ( <Popover open={open} onOpenChange={handleOpenChange}> <PopoverTrigger - render={( - <ConfigureSectionAddButton ariaLabel={t($ => $['agentDetail.configure.tools.add'])} /> - )} + render={ + <ConfigureSectionAddButton ariaLabel={t(($) => $['agentDetail.configure.tools.add'])} /> + } /> <PopoverContent placement="bottom-end" sideOffset={4} - popupClassName={view === 'menu' - ? 'w-[280px] bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]' - : 'w-[400px] overflow-hidden border-none bg-transparent p-0 shadow-none'} + popupClassName={ + view === 'menu' + ? 'w-[280px] bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]' + : 'w-[400px] overflow-hidden border-none bg-transparent p-0 shadow-none' + } > - {view === 'menu' - ? ( - <> - <AddToolMenuItem - iconClassName="i-ri-box-3-line" - label={t($ => $['agentDetail.configure.tools.addMenu.tool.label'])} - description={t($ => $['agentDetail.configure.tools.addMenu.tool.description'])} - onClick={openToolPicker} - /> - {ENABLE_AGENT_CLI_TOOLS && ( - <AddToolMenuItem - iconClassName="i-ri-terminal-box-line" - label={t($ => $['agentDetail.configure.tools.addMenu.cliTool.label'])} - badge={t($ => $['agentDetail.configure.tools.addMenu.cliTool.badge'])} - description={t($ => $['agentDetail.configure.tools.addMenu.cliTool.description'])} - onClick={openCliToolDialog} - /> - )} - </> - ) - : ( - <ToolPickerContent - focusSearchOnMount - panelClassName="w-full overflow-hidden" - supportAddCustomTool - selectedTools={selectedTools} - onSelect={handleSelectTool} - onSelectMultiple={handleSelectMultipleTools} + {view === 'menu' ? ( + <> + <AddToolMenuItem + iconClassName="i-ri-box-3-line" + label={t(($) => $['agentDetail.configure.tools.addMenu.tool.label'])} + description={t(($) => $['agentDetail.configure.tools.addMenu.tool.description'])} + onClick={openToolPicker} + /> + {ENABLE_AGENT_CLI_TOOLS && ( + <AddToolMenuItem + iconClassName="i-ri-terminal-box-line" + label={t(($) => $['agentDetail.configure.tools.addMenu.cliTool.label'])} + badge={t(($) => $['agentDetail.configure.tools.addMenu.cliTool.badge'])} + description={t(($) => $['agentDetail.configure.tools.addMenu.cliTool.description'])} + onClick={openCliToolDialog} /> )} + </> + ) : ( + <ToolPickerContent + focusSearchOnMount + panelClassName="w-full overflow-hidden" + supportAddCustomTool + selectedTools={selectedTools} + onSelect={handleSelectTool} + onSelectMultiple={handleSelectMultipleTools} + /> + )} </PopoverContent> </Popover> ) @@ -404,11 +420,18 @@ export function AgentTools() { handleCliDialogSave, handleCliDialogOpenChange, } = useCliToolDialogSurface() - const handleProviderCredentialChange = useCallback((toolId: string, credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => { - setProviderToolCredential({ toolId, credentialId, credentialType }) - }, [setProviderToolCredential]) + const handleProviderCredentialChange = useCallback( + ( + toolId: string, + credentialId?: string, + credentialType?: AgentProviderTool['credentialType'], + ) => { + setProviderToolCredential({ toolId, credentialId, credentialType }) + }, + [setProviderToolCredential], + ) const visibleTools = useMemo( - () => ENABLE_AGENT_CLI_TOOLS ? tools : tools.filter(tool => tool.kind !== 'cli'), + () => (ENABLE_AGENT_CLI_TOOLS ? tools : tools.filter((tool) => tool.kind !== 'cli')), [tools], ) const displayTools = useDisplayTools(visibleTools, providerById) @@ -459,75 +482,83 @@ export function AgentTools() { * knip-ignore-end */ const promptAddCallbackRef = useRef<AgentOrchestrateAddActionOptions['onAdded']>(undefined) - const openCliToolDialogFromPrompt = useCallback((options?: AgentOrchestrateAddActionOptions) => { - promptAddCallbackRef.current = options?.onAdded - openCliToolDialog() - }, [openCliToolDialog]) - const handleCliDialogSaveWithPromptInsert = useCallback((tool: AgentCliTool) => { - handleCliDialogSave(tool) - if (!editingCliTool) { - promptAddCallbackRef.current?.(tool) - promptAddCallbackRef.current = undefined - } - }, [editingCliTool, handleCliDialogSave]) - const handleCliDialogOpenChangeWithPromptInsert = useCallback((open: boolean) => { - if (!open) - promptAddCallbackRef.current = undefined - handleCliDialogOpenChange(open) - }, [handleCliDialogOpenChange]) + const openCliToolDialogFromPrompt = useCallback( + (options?: AgentOrchestrateAddActionOptions) => { + promptAddCallbackRef.current = options?.onAdded + openCliToolDialog() + }, + [openCliToolDialog], + ) + const handleCliDialogSaveWithPromptInsert = useCallback( + (tool: AgentCliTool) => { + handleCliDialogSave(tool) + if (!editingCliTool) { + promptAddCallbackRef.current?.(tool) + promptAddCallbackRef.current = undefined + } + }, + [editingCliTool, handleCliDialogSave], + ) + const handleCliDialogOpenChangeWithPromptInsert = useCallback( + (open: boolean) => { + if (!open) promptAddCallbackRef.current = undefined + handleCliDialogOpenChange(open) + }, + [handleCliDialogOpenChange], + ) useRegisterAgentOrchestrateAddAction( 'cli', - ENABLE_AGENT_CLI_TOOLS ? openCliToolDialogFromPrompt : () => { }, + ENABLE_AGENT_CLI_TOOLS ? openCliToolDialogFromPrompt : () => {}, ) - const toolsTip = t($ => $['agentDetail.configure.tools.tip']) + const toolsTip = t(($) => $['agentDetail.configure.tools.tip']) const toolsListId = 'agent-configure-tools-list' const settingTargetTool = settingTarget - ? tools.find(tool => tool.kind === 'provider' && tool.id === settingTarget.toolId) + ? tools.find((tool) => tool.kind === 'provider' && tool.id === settingTarget.toolId) : undefined const settingTargetCollection = settingTarget - ? providerById.get(settingTarget.toolId) - ?? providerById.get(settingTargetTool?.name ?? settingTarget.toolId) + ? (providerById.get(settingTarget.toolId) ?? + providerById.get(settingTargetTool?.name ?? settingTarget.toolId)) : undefined return ( <> <ConfigureSection - label={t($ => $['agentDetail.configure.tools.label'])} + label={t(($) => $['agentDetail.configure.tools.label'])} labelId="agent-configure-tools-label" panelId={toolsListId} tip={<AgentConfigureTipContent type="tools" />} tipAriaLabel={toolsTip} rootClassName="border-b border-divider-subtle pt-4" panelContentClassName="flex flex-col gap-1 pb-4" - actions={!readOnly - ? ( - <AddToolMenu - onAddCliTool={openCliToolDialog} - onAddTools={addTools} - selectedTools={selectedTools} - /> - ) - : undefined} + actions={ + !readOnly ? ( + <AddToolMenu + onAddCliTool={openCliToolDialog} + onAddTools={addTools} + selectedTools={selectedTools} + /> + ) : undefined + } > - {displayTools.length === 0 - ? ( - <ConfigureSectionEmpty - title={t($ => $['agentDetail.configure.tools.empty.title'])} - description={t($ => $['agentDetail.configure.tools.empty.description'])} - /> - ) - : displayTools.map(tool => ( - <AgentToolItem - key={tool.id} - tool={tool} - onConfigureAction={setSettingTarget} - onDeleteCliTool={deleteCliTool} - onDeleteProviderTool={deleteProviderTool} - onDeleteProviderToolAction={deleteProviderToolAction} - onEditCliTool={editCliTool} - onCredentialChange={handleProviderCredentialChange} - /> - ))} + {displayTools.length === 0 ? ( + <ConfigureSectionEmpty + title={t(($) => $['agentDetail.configure.tools.empty.title'])} + description={t(($) => $['agentDetail.configure.tools.empty.description'])} + /> + ) : ( + displayTools.map((tool) => ( + <AgentToolItem + key={tool.id} + tool={tool} + onConfigureAction={setSettingTarget} + onDeleteCliTool={deleteCliTool} + onDeleteProviderTool={deleteProviderTool} + onDeleteProviderToolAction={deleteProviderToolAction} + onEditCliTool={editCliTool} + onCredentialChange={handleProviderCredentialChange} + /> + )) + )} </ConfigureSection> <ProviderToolSettingsDialog settingTarget={settingTarget} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/dialog.tsx index 73b7095db3cb8a..c81d8309e97073 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/dialog.tsx @@ -19,32 +19,33 @@ const localize = (value: string) => ({ zh_Hans: value, }) -const createFallbackToolCollection = (tool: AgentProviderTool): ToolWithProvider => ({ - id: tool.id, - name: tool.id, - author: tool.name, - description: localize(`${tool.name} tools`), - icon: tool.icon ?? '', - icon_dark: tool.iconDark, - label: localize(tool.displayName ?? tool.name), - type: (tool.providerType as CollectionType | undefined) ?? CollectionType.builtIn, - team_credentials: {}, - is_team_authorization: true, - allow_delete: tool.allowDelete ?? false, - labels: [], - meta: { - version: '0.0.0', - }, - tools: tool.actions.map<Tool>(action => ({ - name: action.toolName, +const createFallbackToolCollection = (tool: AgentProviderTool): ToolWithProvider => + ({ + id: tool.id, + name: tool.id, author: tool.name, - label: localize(action.name), - description: localize(action.description), - parameters: [], + description: localize(`${tool.name} tools`), + icon: tool.icon ?? '', + icon_dark: tool.iconDark, + label: localize(tool.displayName ?? tool.name), + type: (tool.providerType as CollectionType | undefined) ?? CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: true, + allow_delete: tool.allowDelete ?? false, labels: [], - output_schema: {}, - })), -}) as ToolWithProvider + meta: { + version: '0.0.0', + }, + tools: tool.actions.map<Tool>((action) => ({ + name: action.toolName, + author: tool.name, + label: localize(action.name), + description: localize(action.description), + parameters: [], + labels: [], + output_schema: {}, + })), + }) as ToolWithProvider export function ProviderToolSettingsDialog({ settingTarget, @@ -59,38 +60,35 @@ export function ProviderToolSettingsDialog({ const toolSettings = useAtomValue(agentComposerToolSettingsAtom) const saveProviderToolActionSettings = useSetAtom(saveProviderToolActionSettingsAtom) const currentTarget = useMemo(() => { - if (!settingTarget) - return null + if (!settingTarget) return null - const tool = tools.find(tool => tool.kind === 'provider' && tool.id === settingTarget.toolId) - if (tool?.kind !== 'provider') - return null + const tool = tools.find((tool) => tool.kind === 'provider' && tool.id === settingTarget.toolId) + if (tool?.kind !== 'provider') return null - const action = tool.actions.find(action => action.id === settingTarget.actionId) - if (!action) - return null + const action = tool.actions.find((action) => action.id === settingTarget.actionId) + if (!action) return null return { action, tool } }, [settingTarget, tools]) const toolCollection = useMemo(() => { - if (!currentTarget) - return null + if (!currentTarget) return null return collection ?? createFallbackToolCollection(currentTarget.tool) }, [collection, currentTarget]) - const handleSave = useCallback((value: Record<string, unknown>) => { - if (!currentTarget) - return + const handleSave = useCallback( + (value: Record<string, unknown>) => { + if (!currentTarget) return - saveProviderToolActionSettings({ - actionId: currentTarget.action.id, - value, - }) - onClose() - }, [currentTarget, onClose, saveProviderToolActionSettings]) + saveProviderToolActionSettings({ + actionId: currentTarget.action.id, + value, + }) + onClose() + }, + [currentTarget, onClose, saveProviderToolActionSettings], + ) - if (!currentTarget || !toolCollection) - return null + if (!currentTarget || !toolCollection) return null return ( <SettingBuiltInTool diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx index 8acad4ccc4349a..df5c7045bbd98e 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx @@ -1,14 +1,13 @@ 'use client' import type { ToolSettingTarget } from '../types' -import type { AgentProviderTool, AgentToolAction } from '@/features/agent-v2/agent-composer/form-state' +import type { + AgentProviderTool, + AgentToolAction, +} from '@/features/agent-v2/agent-composer/form-state' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - Collapsible, - CollapsiblePanel, - CollapsibleTrigger, -} from '@langgenius/dify-ui/collapsible' +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible' import { DropdownMenu, DropdownMenuContent, @@ -18,9 +17,7 @@ import { import { StatusDot } from '@langgenius/dify-ui/status-dot' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - AuthCategory, -} from '@/app/components/plugins/plugin-auth' +import { AuthCategory } from '@/app/components/plugins/plugin-auth' import AddOAuthButton from '@/app/components/plugins/plugin-auth/authorize/add-oauth-button' import ApiKeyModal from '@/app/components/plugins/plugin-auth/authorize/api-key-modal' import AuthorizedInNode from '@/app/components/plugins/plugin-auth/authorized-in-node' @@ -40,13 +37,7 @@ function ProviderIcon({ iconClassName: string }) { if (icon) { - return ( - <BlockIcon - className="shrink-0" - type={BlockEnum.Tool} - toolIcon={icon} - /> - ) + return <BlockIcon className="shrink-0" type={BlockEnum.Tool} toolIcon={icon} /> } return ( @@ -61,15 +52,21 @@ function UnauthorizedCredentialStatus({ onCredentialChange, }: { tool: AgentProviderTool - onCredentialChange: (credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => void + onCredentialChange: ( + credentialId?: string, + credentialType?: AgentProviderTool['credentialType'], + ) => void }) { const { t } = useTranslation() const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false) - const pluginPayload = useMemo(() => ({ - provider: tool.id, - category: AuthCategory.tool, - providerType: tool.providerType ?? CollectionType.builtIn, - }), [tool.id, tool.providerType]) + const pluginPayload = useMemo( + () => ({ + provider: tool.id, + category: AuthCategory.tool, + providerType: tool.providerType ?? CollectionType.builtIn, + }), + [tool.id, tool.providerType], + ) const invalidPluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload) const handleApiKeyModalOpen = useCallback(() => { setIsApiKeyModalOpen(true) @@ -95,7 +92,7 @@ function UnauthorizedCredentialStatus({ disabled={disabled} onClick={onClick} > - {t($ => $.notAuthorized, { ns: 'tools' })} + {t(($) => $.notAuthorized, { ns: 'tools' })} <StatusDot className="ml-2" status="warning" /> </Button> )} @@ -105,13 +102,8 @@ function UnauthorizedCredentialStatus({ return ( <> - <Button - variant="secondary" - size="small" - className="shrink-0" - onClick={handleApiKeyModalOpen} - > - {t($ => $.notAuthorized, { ns: 'tools' })} + <Button variant="secondary" size="small" className="shrink-0" onClick={handleApiKeyModalOpen}> + {t(($) => $.notAuthorized, { ns: 'tools' })} <StatusDot className="ml-2" status="warning" /> </Button> <ApiKeyModal @@ -130,31 +122,36 @@ function CredentialStatus({ onCredentialChange, }: { tool: AgentProviderTool - onCredentialChange: (credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => void + onCredentialChange: ( + credentialId?: string, + credentialType?: AgentProviderTool['credentialType'], + ) => void }) { - const canSwitchCredential = (tool.providerType ?? CollectionType.builtIn) === CollectionType.builtIn && tool.allowDelete - const handleAuthorizationItemClick = useCallback((id: string) => { - onCredentialChange(id === '__workspace_default__' ? undefined : id || undefined, tool.credentialType) - }, [onCredentialChange, tool.credentialType]) - const handleDefaultCredentialChange = useCallback((id?: string) => { - if (!tool.credentialId && id) - onCredentialChange(id, tool.credentialType) - }, [onCredentialChange, tool.credentialId, tool.credentialType]) + const canSwitchCredential = + (tool.providerType ?? CollectionType.builtIn) === CollectionType.builtIn && tool.allowDelete + const handleAuthorizationItemClick = useCallback( + (id: string) => { + onCredentialChange( + id === '__workspace_default__' ? undefined : id || undefined, + tool.credentialType, + ) + }, + [onCredentialChange, tool.credentialType], + ) + const handleDefaultCredentialChange = useCallback( + (id?: string) => { + if (!tool.credentialId && id) onCredentialChange(id, tool.credentialType) + }, + [onCredentialChange, tool.credentialId, tool.credentialType], + ) - if (tool.credentialVariant === 'none') - return null + if (tool.credentialVariant === 'none') return null if (tool.credentialVariant === 'unauthorized') { - return ( - <UnauthorizedCredentialStatus - tool={tool} - onCredentialChange={onCredentialChange} - /> - ) + return <UnauthorizedCredentialStatus tool={tool} onCredentialChange={onCredentialChange} /> } - if (!canSwitchCredential) - return null + if (!canSwitchCredential) return null return ( <div className="shrink-0"> @@ -172,145 +169,158 @@ function CredentialStatus({ ) } -const ProviderToolActionItem = memo(({ - action, - tool, - onConfigureAction, - onRemoveAction, -}: { - action: AgentToolAction - tool: AgentProviderTool - onConfigureAction: (target: ToolSettingTarget) => void - onRemoveAction: (actionId: string) => void -}) => { - const { t } = useTranslation('agentV2') - const readOnly = useAgentOrchestrateReadOnly() - const handleConfigureAction = useCallback(() => { - onConfigureAction({ actionId: action.id, toolId: tool.id }) - }, [action.id, onConfigureAction, tool.id]) - const handleRemoveAction = useCallback(() => { - onRemoveAction(action.id) - }, [action.id, onRemoveAction]) - - return ( - <div className="group relative flex min-h-7 items-center gap-1 rounded-md py-px pr-0 pl-1 hover:bg-state-base-hover"> - <div className="absolute top-0 bottom-0 left-[13.5px] w-px bg-divider-regular" /> - <div className="flex min-w-0 flex-1 items-center py-1 pl-7"> - <span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary"> - {action.name} - </span> - </div> - {!readOnly && ( - <div className="hidden shrink-0 items-center gap-1 px-0.5 group-focus-within:flex group-hover:flex"> - <button - type="button" - aria-label={t($ => $['agentDetail.configure.tools.editAction'], { name: action.name })} - onClick={handleConfigureAction} - className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" - > - <span aria-hidden className="i-ri-equalizer-2-line size-4" /> - </button> - <button - type="button" - aria-label={t($ => $['agentDetail.configure.tools.removeAction'], { name: action.name })} - onClick={handleRemoveAction} - className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" - > - <span aria-hidden className="i-ri-delete-bin-line size-4" /> - </button> - </div> - )} - </div> - ) -}) - -export const AgentProviderToolItem = memo(({ - tool, - isExpanded, - onOpenChange, - onConfigureAction, - onRemoveAction, - onRemoveProvider, - onCredentialChange, -}: { - tool: AgentProviderTool - isExpanded: boolean - onOpenChange: (open: boolean) => void - onConfigureAction: (target: ToolSettingTarget) => void - onRemoveAction: (actionId: string) => void - onRemoveProvider: () => void - onCredentialChange: (credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => void -}) => { - const { t } = useTranslation('agentV2') - const readOnly = useAgentOrchestrateReadOnly() - const { theme } = useTheme() - const icon = theme === Theme.dark && tool.iconDark ? tool.iconDark : tool.icon - const displayName = tool.displayName ?? tool.name +const ProviderToolActionItem = memo( + ({ + action, + tool, + onConfigureAction, + onRemoveAction, + }: { + action: AgentToolAction + tool: AgentProviderTool + onConfigureAction: (target: ToolSettingTarget) => void + onRemoveAction: (actionId: string) => void + }) => { + const { t } = useTranslation('agentV2') + const readOnly = useAgentOrchestrateReadOnly() + const handleConfigureAction = useCallback(() => { + onConfigureAction({ actionId: action.id, toolId: tool.id }) + }, [action.id, onConfigureAction, tool.id]) + const handleRemoveAction = useCallback(() => { + onRemoveAction(action.id) + }, [action.id, onRemoveAction]) - return ( - <Collapsible - open={isExpanded} - onOpenChange={onOpenChange} - className="overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1 shadow-xs shadow-shadow-shadow-3" - > - <div className="flex min-h-7 items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-1"> - <CollapsibleTrigger - className="group min-h-0 min-w-0 flex-1 justify-start gap-2 rounded-md px-0 pr-1 text-left hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-secondary data-panel-open:text-text-secondary" - > - <ProviderIcon icon={icon} iconClassName={tool.iconClassName} /> - <span className="flex min-w-0 items-center"> - <span className="min-w-0 truncate system-sm-medium text-text-primary"> - {displayName} - </span> - <span - aria-hidden - className={cn( - 'i-custom-vender-solid-arrows-arrow-down-round-fill size-4 shrink-0 -rotate-90 text-text-quaternary transition-transform group-data-panel-open:rotate-0 motion-reduce:transition-none', - )} - /> + return ( + <div className="group relative flex min-h-7 items-center gap-1 rounded-md py-px pr-0 pl-1 hover:bg-state-base-hover"> + <div className="absolute top-0 bottom-0 left-[13.5px] w-px bg-divider-regular" /> + <div className="flex min-w-0 flex-1 items-center py-1 pl-7"> + <span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary"> + {action.name} </span> - </CollapsibleTrigger> + </div> {!readOnly && ( - <> - <DropdownMenu modal={false}> - <DropdownMenuTrigger - aria-label={t($ => $['agentDetail.configure.tools.moreActions'], { name: tool.name })} - className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" - > - <span className="sr-only">{t($ => $['agentDetail.configure.tools.moreActions'], { name: tool.name })}</span> - <span aria-hidden className="i-ri-more-fill size-4" /> - </DropdownMenuTrigger> - <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-44"> - <DropdownMenuItem - variant="destructive" - className="gap-2" - onClick={onRemoveProvider} - > - <span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" /> - <span>{t($ => $['agentDetail.configure.tools.removeProvider'])}</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - <CredentialStatus tool={tool} onCredentialChange={onCredentialChange} /> - </> + <div className="hidden shrink-0 items-center gap-1 px-0.5 group-focus-within:flex group-hover:flex"> + <button + type="button" + aria-label={t(($) => $['agentDetail.configure.tools.editAction'], { + name: action.name, + })} + onClick={handleConfigureAction} + className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" + > + <span aria-hidden className="i-ri-equalizer-2-line size-4" /> + </button> + <button + type="button" + aria-label={t(($) => $['agentDetail.configure.tools.removeAction'], { + name: action.name, + })} + onClick={handleRemoveAction} + className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" + > + <span aria-hidden className="i-ri-delete-bin-line size-4" /> + </button> + </div> )} </div> + ) + }, +) - <CollapsiblePanel> - {isExpanded && ( - <div className="flex flex-col"> - {tool.actions.map(action => ( - <ProviderToolActionItem - key={action.id} - action={action} - tool={tool} - onConfigureAction={onConfigureAction} - onRemoveAction={onRemoveAction} +export const AgentProviderToolItem = memo( + ({ + tool, + isExpanded, + onOpenChange, + onConfigureAction, + onRemoveAction, + onRemoveProvider, + onCredentialChange, + }: { + tool: AgentProviderTool + isExpanded: boolean + onOpenChange: (open: boolean) => void + onConfigureAction: (target: ToolSettingTarget) => void + onRemoveAction: (actionId: string) => void + onRemoveProvider: () => void + onCredentialChange: ( + credentialId?: string, + credentialType?: AgentProviderTool['credentialType'], + ) => void + }) => { + const { t } = useTranslation('agentV2') + const readOnly = useAgentOrchestrateReadOnly() + const { theme } = useTheme() + const icon = theme === Theme.dark && tool.iconDark ? tool.iconDark : tool.icon + const displayName = tool.displayName ?? tool.name + + return ( + <Collapsible + open={isExpanded} + onOpenChange={onOpenChange} + className="overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1 shadow-xs shadow-shadow-shadow-3" + > + <div className="flex min-h-7 items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-1"> + <CollapsibleTrigger className="group min-h-0 min-w-0 flex-1 justify-start gap-2 rounded-md px-0 pr-1 text-left hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-secondary data-panel-open:text-text-secondary"> + <ProviderIcon icon={icon} iconClassName={tool.iconClassName} /> + <span className="flex min-w-0 items-center"> + <span className="min-w-0 truncate system-sm-medium text-text-primary"> + {displayName} + </span> + <span + aria-hidden + className={cn( + 'i-custom-vender-solid-arrows-arrow-down-round-fill size-4 shrink-0 -rotate-90 text-text-quaternary transition-transform group-data-panel-open:rotate-0 motion-reduce:transition-none', + )} /> - ))} - </div> - )} - </CollapsiblePanel> - </Collapsible> - ) -}) + </span> + </CollapsibleTrigger> + {!readOnly && ( + <> + <DropdownMenu modal={false}> + <DropdownMenuTrigger + aria-label={t(($) => $['agentDetail.configure.tools.moreActions'], { + name: tool.name, + })} + className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" + > + <span className="sr-only"> + {t(($) => $['agentDetail.configure.tools.moreActions'], { name: tool.name })} + </span> + <span aria-hidden className="i-ri-more-fill size-4" /> + </DropdownMenuTrigger> + <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-44"> + <DropdownMenuItem + variant="destructive" + className="gap-2" + onClick={onRemoveProvider} + > + <span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" /> + <span>{t(($) => $['agentDetail.configure.tools.removeProvider'])}</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + <CredentialStatus tool={tool} onCredentialChange={onCredentialChange} /> + </> + )} + </div> + + <CollapsiblePanel> + {isExpanded && ( + <div className="flex flex-col"> + {tool.actions.map((action) => ( + <ProviderToolActionItem + key={action.id} + action={action} + tool={tool} + onConfigureAction={onConfigureAction} + onRemoveAction={onRemoveAction} + /> + ))} + </div> + )} + </CollapsiblePanel> + </Collapsible> + ) + }, +) diff --git a/web/features/agent-v2/agent-detail/configure/components/page-loading.tsx b/web/features/agent-v2/agent-detail/configure/components/page-loading.tsx index 61229fc4e121fb..dbd2d815e38659 100644 --- a/web/features/agent-v2/agent-detail/configure/components/page-loading.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/page-loading.tsx @@ -2,17 +2,9 @@ import Loading from '@/app/components/base/loading' -export function AgentConfigurePageLoading({ - label, -}: { - label: string -}) { +export function AgentConfigurePageLoading({ label }: { label: string }) { return ( - <section - aria-label={label} - aria-busy - className="flex h-full min-w-0 flex-1 bg-background-body" - > + <section aria-label={label} aria-busy className="flex h-full min-w-0 flex-1 bg-background-body"> <Loading type="app" /> </section> ) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx index 458eb0ad4a9830..2c08a9b46e4527 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx @@ -26,74 +26,75 @@ vi.mock('@/next/dynamic', async () => { const { useState } = await import('react') return { - default: () => function MockChat(props: { - onSend: (message: string) => unknown - onStopResponding: () => void - sendButtonLabel?: string - sendButtonLoading?: boolean - showPromptLog?: boolean - footerNotice?: string - chatNode?: ReactNode - }) { - const [sent, setSent] = useState(false) - - return ( - <div - data-testid="mock-chat" - data-send-button-label={props.sendButtonLabel ?? ''} - data-send-button-loading={String(!!props.sendButtonLoading)} - data-show-prompt-log={String(!!props.showPromptLog)} - data-footer-notice={props.footerNotice ?? ''} - > - {props.chatNode} - <span>{`sessionSent:${sent ? 'yes' : 'no'}`}</span> - <button - type="button" - onClick={() => { - setSent(true) - sendResultRef.current = props.onSend('hello') - }} + default: () => + function MockChat(props: { + onSend: (message: string) => unknown + onStopResponding: () => void + sendButtonLabel?: string + sendButtonLoading?: boolean + showPromptLog?: boolean + footerNotice?: string + chatNode?: ReactNode + }) { + const [sent, setSent] = useState(false) + + return ( + <div + data-testid="mock-chat" + data-send-button-label={props.sendButtonLabel ?? ''} + data-send-button-loading={String(!!props.sendButtonLoading)} + data-show-prompt-log={String(!!props.showPromptLog)} + data-footer-notice={props.footerNotice ?? ''} > - send - </button> - <button type="button" onClick={props.onStopResponding}> - stop - </button> - </div> - ) - }, + {props.chatNode} + <span>{`sessionSent:${sent ? 'yes' : 'no'}`}</span> + <button + type="button" + onClick={() => { + setSent(true) + sendResultRef.current = props.onSend('hello') + }} + > + send + </button> + <button type="button" onClick={props.onStopResponding}> + stop + </button> + </div> + ) + }, } }) vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({ default: ({ footerNotice }: { footerNotice?: ReactNode }) => ( - <div data-testid="agent-preview-chat-input"> - {footerNotice} - </div> + <div data-testid="agent-preview-chat-input">{footerNotice}</div> ), })) vi.mock('@/app/components/base/chat/chat/hooks', () => ({ - useChat: useChatMock.mockImplementation(( - _config: unknown, - _formSettings: unknown, - chatList: unknown[], - stopCallback: (taskId: string) => void, - ) => { - stopCallbackRef.current = stopCallback - - return { - chatList, - setTargetMessageId: vi.fn(), - isResponding: false, - handleSend: handleSendMock, - suggestedQuestions: [], - handleStop: () => stopCallback('task-1'), - handleAnnotationAdded: vi.fn(), - handleAnnotationEdited: vi.fn(), - handleAnnotationRemoved: vi.fn(), - } - }), + useChat: useChatMock.mockImplementation( + ( + _config: unknown, + _formSettings: unknown, + chatList: unknown[], + stopCallback: (taskId: string) => void, + ) => { + stopCallbackRef.current = stopCallback + + return { + chatList, + setTargetMessageId: vi.fn(), + isResponding: false, + handleSend: handleSendMock, + suggestedQuestions: [], + handleStop: () => stopCallback('task-1'), + handleAnnotationAdded: vi.fn(), + handleAnnotationEdited: vi.fn(), + handleAnnotationRemoved: vi.fn(), + } + }, + ), })) vi.mock('@/context/account-state', async (importOriginal) => { @@ -148,7 +149,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -261,11 +263,7 @@ function RuntimeConversationHarness() { ) } -function RuntimeClearCommandHarness({ - inputPlaceholder, -}: { - inputPlaceholder: string -}) { +function RuntimeClearCommandHarness({ inputPlaceholder }: { inputPlaceholder: string }) { const [clearChatList, setClearChatList] = useState(true) return ( @@ -412,7 +410,9 @@ describe('AgentPreviewChat', () => { await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1)) expect(saveDraftBeforeRun).toHaveBeenCalledTimes(1) - expect(saveDraftBeforeRun.mock.invocationCallOrder[0]).toBeLessThan(handleSendMock.mock.invocationCallOrder[0]!) + expect(saveDraftBeforeRun.mock.invocationCallOrder[0]).toBeLessThan( + handleSendMock.mock.invocationCallOrder[0]!, + ) expect(handleSendMock).toHaveBeenCalledWith( 'agent/agent-1/chat-messages', expect.not.objectContaining({ @@ -464,29 +464,36 @@ describe('AgentPreviewChat', () => { await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1)) const callbacks = handleSendMock.mock.calls.at(0)?.[2] - expect(callbacks.onUnhandledEvent({ - event: 'error', - conversation_id: 'conversation-1', - message_id: 'message-1', - code: 'agent_run_failed', - message: 'Agent execution failed', - })).toEqual({ + expect( + callbacks.onUnhandledEvent({ + event: 'error', + conversation_id: 'conversation-1', + message_id: 'message-1', + code: 'agent_run_failed', + message: 'Agent execution failed', + }), + ).toEqual({ conversationId: 'conversation-1', messageId: 'message-1', errorCode: 'agent_run_failed', errorMessage: 'Agent execution failed', }) - expect(callbacks.onUnhandledEvent({ - event: 'unknown', - message: 'Ignored', - })).toBeUndefined() + expect( + callbacks.onUnhandledEvent({ + event: 'unknown', + message: 'Ignored', + }), + ).toBeUndefined() }) it('should show the send button loading state while preparing a build run', async () => { let resolveSaveDraftBeforeRun: () => void = () => {} - const saveDraftBeforeRun = vi.fn(() => new Promise<void>((resolve) => { - resolveSaveDraftBeforeRun = resolve - })) + const saveDraftBeforeRun = vi.fn( + () => + new Promise<void>((resolve) => { + resolveSaveDraftBeforeRun = resolve + }), + ) renderPreviewChat({ sendButtonLabel: 'Start build', renderEmptyState: ({ inputNode }) => <div>{inputNode}</div>, @@ -525,26 +532,28 @@ describe('AgentPreviewChat', () => { }) it('should not show the send button loading state while an icon send button is responding', async () => { - useChatMock.mockImplementationOnce(( - _config: unknown, - _formSettings: unknown, - chatList: unknown[], - stopCallback: (taskId: string) => void, - ) => { - stopCallbackRef.current = stopCallback - - return { - chatList, - setTargetMessageId: vi.fn(), - isResponding: true, - handleSend: handleSendMock, - suggestedQuestions: [], - handleStop: () => stopCallback('task-1'), - handleAnnotationAdded: vi.fn(), - handleAnnotationEdited: vi.fn(), - handleAnnotationRemoved: vi.fn(), - } - }) + useChatMock.mockImplementationOnce( + ( + _config: unknown, + _formSettings: unknown, + chatList: unknown[], + stopCallback: (taskId: string) => void, + ) => { + stopCallbackRef.current = stopCallback + + return { + chatList, + setTargetMessageId: vi.fn(), + isResponding: true, + handleSend: handleSendMock, + suggestedQuestions: [], + handleStop: () => stopCallback('task-1'), + handleAnnotationAdded: vi.fn(), + handleAnnotationEdited: vi.fn(), + handleAnnotationRemoved: vi.fn(), + } + }, + ) renderPreviewChat() @@ -554,37 +563,39 @@ describe('AgentPreviewChat', () => { }) it('should use the default send button after the first build message', async () => { - useChatMock.mockImplementationOnce(( - _config: unknown, - _formSettings: unknown, - _chatList: unknown[], - stopCallback: (taskId: string) => void, - ) => { - stopCallbackRef.current = stopCallback - - return { - chatList: [ - { - id: 'question-1', - content: 'Build an agent', - isAnswer: false, - }, - { - id: 'answer-1', - content: 'Done', - isAnswer: true, - }, - ], - setTargetMessageId: vi.fn(), - isResponding: false, - handleSend: handleSendMock, - suggestedQuestions: [], - handleStop: () => stopCallback('task-1'), - handleAnnotationAdded: vi.fn(), - handleAnnotationEdited: vi.fn(), - handleAnnotationRemoved: vi.fn(), - } - }) + useChatMock.mockImplementationOnce( + ( + _config: unknown, + _formSettings: unknown, + _chatList: unknown[], + stopCallback: (taskId: string) => void, + ) => { + stopCallbackRef.current = stopCallback + + return { + chatList: [ + { + id: 'question-1', + content: 'Build an agent', + isAnswer: false, + }, + { + id: 'answer-1', + content: 'Done', + isAnswer: true, + }, + ], + setTargetMessageId: vi.fn(), + isResponding: false, + handleSend: handleSendMock, + suggestedQuestions: [], + handleStop: () => stopCallback('task-1'), + handleAnnotationAdded: vi.fn(), + handleAnnotationEdited: vi.fn(), + handleAnnotationRemoved: vi.fn(), + } + }, + ) renderPreviewChat({ sendButtonLabel: 'Start build', }) @@ -603,16 +614,18 @@ describe('AgentPreviewChat', () => { inputs: {}, message: [], message_files: [], - agent_thoughts: [{ - id: 'thought-with-answer', - message_id: 'message-after-send', - thought: '', - answer: 'history thought answer', - tool: '', - tool_input: '', - observation: '', - position: 1, - }], + agent_thoughts: [ + { + id: 'thought-with-answer', + message_id: 'message-after-send', + thought: '', + answer: 'history thought answer', + tool: '', + tool_input: '', + observation: '', + position: 1, + }, + ], feedbacks: [], answer_tokens: 1, message_tokens: 1, @@ -632,16 +645,20 @@ describe('AgentPreviewChat', () => { await callbacks.onGetConversationMessages('conversation-1') - expect(queryClient.getQueryData(consoleQuery.agent.byAgentId.chatMessages.get.queryKey({ - input: { - params: { - agent_id: 'agent-1', - }, - query: { - conversation_id: 'conversation-1', - }, - }, - }))).toBe(conversationMessagesResponse) + expect( + queryClient.getQueryData( + consoleQuery.agent.byAgentId.chatMessages.get.queryKey({ + input: { + params: { + agent_id: 'agent-1', + }, + query: { + conversation_id: 'conversation-1', + }, + }, + }), + ), + ).toBe(conversationMessagesResponse) }) it('should preserve historical agent thought answer when formatting chat history', async () => { @@ -655,16 +672,18 @@ describe('AgentPreviewChat', () => { inputs: {}, message: [], message_files: [], - agent_thoughts: [{ - id: 'thought-with-answer', - message_id: 'message-with-thought-answer', - thought: '', - answer: 'history thought answer', - tool: '', - tool_input: '', - observation: '', - position: 1, - }], + agent_thoughts: [ + { + id: 'thought-with-answer', + message_id: 'message-with-thought-answer', + thought: '', + answer: 'history thought answer', + tool: '', + tool_input: '', + observation: '', + position: 1, + }, + ], feedbacks: [], status: 'success', from_source: 'console', @@ -682,10 +701,12 @@ describe('AgentPreviewChat', () => { return JSON.stringify(chatTree).includes('history thought answer') })?.[2] - expect(formattedTree?.[0]?.children?.[0]?.agent_thoughts?.[0]).toEqual(expect.objectContaining({ - id: 'thought-with-answer', - answer: 'history thought answer', - })) + expect(formattedTree?.[0]?.children?.[0]?.agent_thoughts?.[0]).toEqual( + expect.objectContaining({ + id: 'thought-with-answer', + answer: 'history thought answer', + }), + ) }) }) @@ -788,12 +809,16 @@ describe('AgentPreviewChat', () => { renderEmptyState: ({ inputNode }) => <div>{inputNode}</div>, }) - expect(screen.getByText('agentV2.agentDetail.configure.preview.sandboxNotice')).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.preview.sandboxNotice'), + ).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'send' })) await waitFor(() => { - expect(screen.queryByText('agentV2.agentDetail.configure.preview.sandboxNotice')).not.toBeInTheDocument() + expect( + screen.queryByText('agentV2.agentDetail.configure.preview.sandboxNotice'), + ).not.toBeInTheDocument() }) }) @@ -889,10 +914,12 @@ describe('AgentPreviewChat', () => { await waitFor(() => expect(useChatMock).toHaveBeenCalled()) const config = useChatMock.mock.calls.at(-1)?.[0] - expect(config.file_upload).toEqual(expect.objectContaining({ - enabled: false, - allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], - })) + expect(config.file_upload).toEqual( + expect.objectContaining({ + enabled: false, + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + }), + ) }) it('should enable build chat file upload when chat features file upload is enabled', async () => { @@ -909,16 +936,20 @@ describe('AgentPreviewChat', () => { await waitFor(() => expect(useChatMock).toHaveBeenCalled()) const config = useChatMock.mock.calls.at(-1)?.[0] - expect(config.file_upload).toEqual(expect.objectContaining({ - enabled: true, - allowed_file_types: [SupportUploadFileTypes.image], - allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], - number_limits: 3, - })) - expect(config.file_upload.image).toEqual(expect.objectContaining({ - enabled: true, - transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url], - number_limits: 3, - })) + expect(config.file_upload).toEqual( + expect.objectContaining({ + enabled: true, + allowed_file_types: [SupportUploadFileTypes.image], + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + number_limits: 3, + }), + ) + expect(config.file_upload.image).toEqual( + expect.objectContaining({ + enabled: true, + transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url], + number_limits: 3, + }), + ) }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/header.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/header.spec.tsx index cefdf559f8be43..f498bdc4d5f6d8 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/header.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/header.spec.tsx @@ -46,10 +46,16 @@ describe('AgentPreviewHeader', () => { const onRefresh = vi.fn() renderHeader({ mode: 'build', onRefresh }) - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.restart' })) + await user.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.restart' }), + ) expect(onRefresh).not.toHaveBeenCalled() - expect(screen.getByRole('alertdialog', { name: 'agentV2.agentDetail.configure.clearSessionConfirm.title' })).toBeInTheDocument() + expect( + screen.getByRole('alertdialog', { + name: 'agentV2.agentDetail.configure.clearSessionConfirm.title', + }), + ).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) @@ -61,7 +67,9 @@ describe('AgentPreviewHeader', () => { const onRefresh = vi.fn() renderHeader({ mode: 'build', onRefresh, refreshDisabled: true }) - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.restart' })) + await user.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.restart' }), + ) expect(onRefresh).not.toHaveBeenCalled() }) @@ -71,7 +79,9 @@ describe('AgentPreviewHeader', () => { const onToggleChatFeatures = vi.fn() renderHeader({ mode: 'build', onToggleChatFeatures }) - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.chatFeatures' })) + await user.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.chatFeatures' }), + ) expect(onToggleChatFeatures).toHaveBeenCalledTimes(1) }) @@ -81,8 +91,12 @@ describe('AgentPreviewHeader', () => { const onOpenWorkingDirectory = vi.fn() renderHeader({ mode: 'build', onOpenWorkingDirectory, showWorkingDirectoryAction: true }) - const fileSystemButton = screen.getByRole('button', { name: 'agentV2.agentDetail.configure.workingDirectory.open' }) - expect(fileSystemButton).toHaveTextContent('agentV2.agentDetail.configure.workingDirectory.fileSystem') + const fileSystemButton = screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.workingDirectory.open', + }) + expect(fileSystemButton).toHaveTextContent( + 'agentV2.agentDetail.configure.workingDirectory.fileSystem', + ) await user.click(fileSystemButton) @@ -92,7 +106,9 @@ describe('AgentPreviewHeader', () => { it('should hide the working directory action when unavailable', () => { renderHeader({ mode: 'build' }) - expect(screen.queryByRole('button', { name: 'agentV2.agentDetail.configure.workingDirectory.open' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'agentV2.agentDetail.configure.workingDirectory.open' }), + ).not.toBeInTheDocument() }) it('should disable preview mode when preview is unavailable', async () => { @@ -104,9 +120,15 @@ describe('AgentPreviewHeader', () => { onModeChange, }) - const modeControl = screen.getByRole('group', { name: 'agentV2.agentDetail.configure.rightPanel.modeLabel' }) + const modeControl = screen.getByRole('group', { + name: 'agentV2.agentDetail.configure.rightPanel.modeLabel', + }) - await user.click(within(modeControl).getByRole('button', { name: 'agentV2.agentDetail.configure.rightPanel.preview' })) + await user.click( + within(modeControl).getByRole('button', { + name: 'agentV2.agentDetail.configure.rightPanel.preview', + }), + ) expect(onModeChange).not.toHaveBeenCalled() }) @@ -118,8 +140,12 @@ describe('AgentPreviewHeader', () => { previewEnabled: false, }) - await user.hover(screen.getByLabelText('agentV2.agentDetail.configure.rightPanel.previewDisabledTip')) + await user.hover( + screen.getByLabelText('agentV2.agentDetail.configure.rightPanel.previewDisabledTip'), + ) - expect(await screen.findByText('agentV2.agentDetail.configure.rightPanel.previewDisabledTip')).toBeInTheDocument() + expect( + await screen.findByText('agentV2.agentDetail.configure.rightPanel.previewDisabledTip'), + ).toBeInTheDocument() }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/versions-panel.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/versions-panel.spec.tsx index ac147a4d3733b8..42b4f4c80673b1 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/versions-panel.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/versions-panel.spec.tsx @@ -115,7 +115,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/working-directory-breadcrumb.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/working-directory-breadcrumb.spec.tsx index f7332f3dd3f93f..65ac322094042b 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/working-directory-breadcrumb.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/working-directory-breadcrumb.spec.tsx @@ -9,66 +9,60 @@ describe('AgentWorkingDirectoryBreadcrumb', () => { describe('Rendering', () => { it('should render the root path by default', () => { - render( - <AgentWorkingDirectoryBreadcrumb - path="." - onPathChange={vi.fn()} - />, - ) + render(<AgentWorkingDirectoryBreadcrumb path="." onPathChange={vi.fn()} />) - expect(screen.getByRole('navigation', { - name: 'agentV2.agentDetail.configure.workingDirectory.breadcrumbLabel', - })).toBeInTheDocument() + expect( + screen.getByRole('navigation', { + name: 'agentV2.agentDetail.configure.workingDirectory.breadcrumbLabel', + }), + ).toBeInTheDocument() expect(screen.getByRole('button', { name: '.' })).toHaveAttribute('aria-current', 'page') }) it('should render home as the current path when path is home', () => { - render( - <AgentWorkingDirectoryBreadcrumb - path="~" - onPathChange={vi.fn()} - />, - ) - - expect(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.workingDirectory.home', - })).toHaveAttribute('aria-current', 'page') - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.workingDirectory.workingDirectory', - })).not.toBeInTheDocument() + render(<AgentWorkingDirectoryBreadcrumb path="~" onPathChange={vi.fn()} />) + + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.workingDirectory.home', + }), + ).toHaveAttribute('aria-current', 'page') + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.workingDirectory.workingDirectory', + }), + ).not.toBeInTheDocument() }) it('should render the workspace cwd directly and show tilde as home', () => { - render( - <AgentWorkingDirectoryBreadcrumb - path="~/web-game" - onPathChange={vi.fn()} - />, - ) - - expect(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.workingDirectory.home', - })).toBeInTheDocument() - expect(screen.getByRole('button', { - name: 'web-game', - })).toHaveAttribute('aria-current', 'page') + render(<AgentWorkingDirectoryBreadcrumb path="~/web-game" onPathChange={vi.fn()} />) + + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.workingDirectory.home', + }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'web-game', + }), + ).toHaveAttribute('aria-current', 'page') }) it('should collapse middle breadcrumb layers when path is deeper than three layers', () => { - render( - <AgentWorkingDirectoryBreadcrumb - path="~/web-game/src/app" - onPathChange={vi.fn()} - />, - ) + render(<AgentWorkingDirectoryBreadcrumb path="~/web-game/src/app" onPathChange={vi.fn()} />) - expect(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.workingDirectory.home', - })).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.workingDirectory.home', + }), + ).toBeInTheDocument() expect(screen.getByRole('button', { name: '...' })).toBeInTheDocument() - expect(screen.queryByRole('button', { - name: 'agentV2.agentDetail.configure.workingDirectory.workingDirectory', - })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.workingDirectory.workingDirectory', + }), + ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'web-game' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'src' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'app' })).toHaveAttribute('aria-current', 'page') @@ -79,16 +73,13 @@ describe('AgentWorkingDirectoryBreadcrumb', () => { it('should request home path when home is clicked', async () => { const user = userEvent.setup() const handlePathChange = vi.fn() - render( - <AgentWorkingDirectoryBreadcrumb - path="~/web-game" - onPathChange={handlePathChange} - />, - ) + render(<AgentWorkingDirectoryBreadcrumb path="~/web-game" onPathChange={handlePathChange} />) - await user.click(screen.getByRole('button', { - name: 'agentV2.agentDetail.configure.workingDirectory.home', - })) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.workingDirectory.home', + }), + ) expect(handlePathChange).toHaveBeenCalledWith('~') }) @@ -97,10 +88,7 @@ describe('AgentWorkingDirectoryBreadcrumb', () => { const user = userEvent.setup() const handlePathChange = vi.fn() render( - <AgentWorkingDirectoryBreadcrumb - path="~/web-game/src" - onPathChange={handlePathChange} - />, + <AgentWorkingDirectoryBreadcrumb path="~/web-game/src" onPathChange={handlePathChange} />, ) await user.click(screen.getByRole('button', { name: 'web-game' })) @@ -119,9 +107,11 @@ describe('AgentWorkingDirectoryBreadcrumb', () => { ) await user.click(screen.getByRole('button', { name: '...' })) - await user.click(screen.getByRole('menuitem', { - name: 'web-game', - })) + await user.click( + screen.getByRole('menuitem', { + name: 'web-game', + }), + ) expect(handlePathChange).toHaveBeenCalledWith('~/web-game') }) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/working-directory-panel.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/working-directory-panel.spec.tsx index 9cb15a9171d390..b9c40fbceea7fd 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/working-directory-panel.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/working-directory-panel.spec.tsx @@ -111,7 +111,9 @@ vi.mock('@/service/client', () => ({ }, upload: { post: { - mutationOptions: () => ({ mutationFn: mocks.workflowSandboxFileUploadMutationFn }), + mutationOptions: () => ({ + mutationFn: mocks.workflowSandboxFileUploadMutationFn, + }), }, }, }, @@ -204,9 +206,11 @@ describe('AgentWorkingDirectoryPanel', () => { renderWorkingDirectoryPanel() await user.click(await screen.findByText('notes.md')) - await user.click(await screen.findByRole('button', { - name: /common\.operation\.download.*notes\.md/i, - })) + await user.click( + await screen.findByRole('button', { + name: /common\.operation\.download.*notes\.md/i, + }), + ) const downloadingButton = await screen.findByRole('button', { name: /common\.operation\.downloading.*notes\.md/i, @@ -242,10 +246,14 @@ describe('AgentWorkingDirectoryPanel', () => { await user.click(await screen.findByText('model.bin')) - expect(await screen.findByText('agentV2.agentDetail.configure.files.preview.unsupported')).toBeInTheDocument() + expect( + await screen.findByText('agentV2.agentDetail.configure.files.preview.unsupported'), + ).toBeInTheDocument() await user.click(screen.getByRole('link', { name: /common\.operation\.download/i })) - const downloadingLink = await screen.findByRole('link', { name: /common\.operation\.downloading/i }) + const downloadingLink = await screen.findByRole('link', { + name: /common\.operation\.downloading/i, + }) expect(downloadingLink.querySelector('.animate-spin')).toBeInTheDocument() const headerDownloadButton = screen.getByRole('button', { name: /common\.operation\.download.*model\.bin/i, @@ -293,7 +301,9 @@ describe('AgentWorkingDirectoryPanel', () => { path: '~/workspace/chart.png', }, }) - expect(screen.queryByText('agentV2.agentDetail.configure.files.preview.unsupported')).not.toBeInTheDocument() + expect( + screen.queryByText('agentV2.agentDetail.configure.files.preview.unsupported'), + ).not.toBeInTheDocument() expect(mocks.downloadUrl).not.toHaveBeenCalled() }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/build-background.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/build-background.tsx index 2f5ff6c74f3022..ba610eafced387 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/build-background.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/build-background.tsx @@ -1,11 +1,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { AgentBuildGridTexture } from '../build-grid-texture' -export function AgentBuildPanelBackground({ - visible, -}: { - visible: boolean -}) { +export function AgentBuildPanelBackground({ visible }: { visible: boolean }) { return ( <div aria-hidden diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx index b4f37b47ccffcd..868715dbf20233 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx @@ -14,27 +14,30 @@ const buildIconGridCellOpacities = [ '0.241 0.206 0.124 0.181 0.212 0.211 0.315 0.127', '0.133 0.21 0.166 0.476 0.167 0.22 0.136 0.246', '0 0.132 0.151 0.146 0.276 0.256 0.269 0', -].flatMap(row => row.split(' ').map(Number)) +].flatMap((row) => row.split(' ').map(Number)) const buildIconGridCells = buildIconGridCellOpacities.map((opacity, index) => ({ id: `build-icon-cell-${Math.floor(index / 8)}-${index % 8}`, opacity, })) -type AgentBuildChatProps = Omit<AgentChatRuntimeProps, 'inputPlaceholder' | 'renderEmptyState' | 'sendButtonLabel'> +type AgentBuildChatProps = Omit< + AgentChatRuntimeProps, + 'inputPlaceholder' | 'renderEmptyState' | 'sendButtonLabel' +> -function AgentBuildChatEmptyState({ - inputNode, -}: AgentChatRuntimeEmptyStateProps) { +function AgentBuildChatEmptyState({ inputNode }: AgentChatRuntimeEmptyStateProps) { const { t } = useTranslation('agentV2') - const communityEditionBuildModeTip = t($ => $['agentDetail.configure.build.empty.communityEditionTip']) + const communityEditionBuildModeTip = t( + ($) => $['agentDetail.configure.build.empty.communityEditionTip'], + ) return ( <div className="flex h-full items-center justify-center"> <div className="flex w-full max-w-150 flex-col items-start p-3 text-left"> <div className="dify-blue-glass-surface relative flex h-[50px] w-12 items-center justify-center rounded-xl p-2"> <div className="absolute inset-x-px inset-y-0.5 grid grid-cols-[repeat(8,4px)] grid-rows-[repeat(8,4px)] gap-0.5 opacity-25"> - {buildIconGridCells.map(cell => ( + {buildIconGridCells.map((cell) => ( <span key={cell.id} className={cell.opacity > 0 ? 'rounded-[1px] bg-[#98A2B2]' : 'invisible'} @@ -42,11 +45,14 @@ function AgentBuildChatEmptyState({ /> ))} </div> - <span aria-hidden className="absolute i-ri-hammer-line size-5 text-saas-dify-blue-inverted" /> + <span + aria-hidden + className="absolute i-ri-hammer-line size-5 text-saas-dify-blue-inverted" + /> </div> <div className="mt-3 flex max-w-full items-center gap-1.5"> <div className="min-w-0 truncate system-md-medium text-text-secondary"> - {t($ => $['agentDetail.configure.build.empty.title'])} + {t(($) => $['agentDetail.configure.build.empty.title'])} </div> <Popover> <PopoverTrigger @@ -54,14 +60,17 @@ function AgentBuildChatEmptyState({ delay={300} closeDelay={200} aria-label={communityEditionBuildModeTip} - render={( + render={ <button type="button" className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" > - <span aria-hidden className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary" /> + <span + aria-hidden + className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary" + /> </button> - )} + } /> <PopoverContent placement="top" @@ -72,7 +81,7 @@ function AgentBuildChatEmptyState({ </Popover> </div> <p className="mt-1 max-w-full body-md-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.build.empty.description'])} + {t(($) => $['agentDetail.configure.build.empty.description'])} </p> {inputNode} </div> @@ -86,9 +95,9 @@ export function AgentBuildChat(props: AgentBuildChatProps) { return ( <AgentChatRuntime {...props} - inputPlaceholder={t($ => $['agentDetail.configure.build.inputPlaceholder'])} + inputPlaceholder={t(($) => $['agentDetail.configure.build.inputPlaceholder'])} inputAutoFocus={false} - sendButtonLabel={t($ => $['agentDetail.configure.build.startBuild'])} + sendButtonLabel={t(($) => $['agentDetail.configure.build.startBuild'])} renderEmptyState={(emptyStateProps: AgentChatRuntimeEmptyStateProps) => ( <AgentBuildChatEmptyState {...emptyStateProps} /> )} diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/chat-features-panel.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/chat-features-panel.tsx index 75c9d658fc1ef0..e31c4f33d92999 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/chat-features-panel.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/chat-features-panel.tsx @@ -1,6 +1,10 @@ 'use client' -import type { AgentSoulAppFeaturesConfig, FileTransferMethod, FileType } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentSoulAppFeaturesConfig, + FileTransferMethod, + FileType, +} from '@dify/contracts/api/console/agent/types.gen' import type { Features } from '@/app/components/base/features/types' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -38,7 +42,12 @@ const defaultFeatureState: Features = { } const agentFileTypes = new Set<string>(['audio', 'custom', 'document', 'image', 'video']) -const agentFileTransferMethods = new Set<string>(['datasource_file', 'local_file', 'remote_url', 'tool_file']) +const agentFileTransferMethods = new Set<string>([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) function isAgentFileType(value: string): value is FileType { return agentFileTypes.has(value) @@ -52,9 +61,10 @@ function toAgentFileTransferMethods(values?: readonly string[]): FileTransferMet return values?.filter(isAgentFileTransferMethod) } -function toAgentFileUploadFeatureConfig(file: Features['file']): AgentSoulAppFeaturesConfig['file_upload'] { - if (!file) - return undefined +function toAgentFileUploadFeatureConfig( + file: Features['file'], +): AgentSoulAppFeaturesConfig['file_upload'] { + if (!file) return undefined const { allowed_file_types, allowed_file_upload_methods } = file const fileUpload: Record<string, unknown> = { ...file } @@ -80,26 +90,41 @@ function toPanelFeatures(appFeatures?: AgentSoulAppFeaturesConfig): Features { opening_statement: appFeatures?.opening_statement ?? '', suggested_questions: appFeatures?.suggested_questions ?? [], }, - suggested: (appFeatures?.suggested_questions_after_answer as Features['suggested'] | undefined) ?? defaultFeatureState.suggested, - text2speech: (appFeatures?.text_to_speech as Features['text2speech'] | undefined) ?? defaultFeatureState.text2speech, + suggested: + (appFeatures?.suggested_questions_after_answer as Features['suggested'] | undefined) ?? + defaultFeatureState.suggested, + text2speech: + (appFeatures?.text_to_speech as Features['text2speech'] | undefined) ?? + defaultFeatureState.text2speech, speech2text: appFeatures?.speech_to_text ?? defaultFeatureState.speech2text, citation: appFeatures?.retriever_resource ?? defaultFeatureState.citation, - moderation: (appFeatures?.sensitive_word_avoidance as Features['moderation'] | undefined) ?? defaultFeatureState.moderation, + moderation: + (appFeatures?.sensitive_word_avoidance as Features['moderation'] | undefined) ?? + defaultFeatureState.moderation, file: (appFeatures?.file_upload as Features['file'] | undefined) ?? defaultFeatureState.file, - annotationReply: (appFeatures?.annotation_reply as Features['annotationReply'] | undefined) ?? defaultFeatureState.annotationReply, + annotationReply: + (appFeatures?.annotation_reply as Features['annotationReply'] | undefined) ?? + defaultFeatureState.annotationReply, } } -function toAppFeatures(features: Features, appFeatures?: AgentSoulAppFeaturesConfig): AgentSoulAppFeaturesConfig { +function toAppFeatures( + features: Features, + appFeatures?: AgentSoulAppFeaturesConfig, +): AgentSoulAppFeaturesConfig { return { ...appFeatures, opening_statement: features.opening?.enabled ? (features.opening.opening_statement ?? '') : '', - suggested_questions: features.opening?.enabled ? (features.opening.suggested_questions ?? []) : [], - suggested_questions_after_answer: features.suggested as AgentSoulAppFeaturesConfig['suggested_questions_after_answer'], + suggested_questions: features.opening?.enabled + ? (features.opening.suggested_questions ?? []) + : [], + suggested_questions_after_answer: + features.suggested as AgentSoulAppFeaturesConfig['suggested_questions_after_answer'], text_to_speech: features.text2speech as AgentSoulAppFeaturesConfig['text_to_speech'], speech_to_text: features.speech2text, retriever_resource: features.citation, - sensitive_word_avoidance: features.moderation as AgentSoulAppFeaturesConfig['sensitive_word_avoidance'], + sensitive_word_avoidance: + features.moderation as AgentSoulAppFeaturesConfig['sensitive_word_avoidance'], file_upload: toAgentFileUploadFeatureConfig(features.file), annotation_reply: features.annotationReply, } @@ -115,14 +140,14 @@ function AgentChatFeaturesPanelContent({ const featuresStore = useFeaturesStore() const setAppFeatures = useSetAppFeatures() const handleChange = useCallback(() => { - if (disabled) - return + if (disabled) return const features = featuresStore?.getState().features - if (!features) - return + if (!features) return - setAppFeatures(currentAppFeatures => toAppFeatures(features, currentAppFeatures ?? appFeatures)) + setAppFeatures((currentAppFeatures) => + toAppFeatures(features, currentAppFeatures ?? appFeatures), + ) }, [appFeatures, disabled, featuresStore, setAppFeatures]) return ( @@ -134,18 +159,15 @@ function AgentChatFeaturesPanelContent({ showModeration={false} showAnnotationReply={false} drawerClassName="bg-components-panel-bg! data-[swipe-direction=right]:top-1! data-[swipe-direction=right]:right-0! data-[swipe-direction=right]:bottom-1! data-[swipe-direction=right]:rounded-r-none!" - title={t($ => $['agentDetail.configure.chatFeatures.title'])} - description={t($ => $['agentDetail.configure.chatFeatures.description'])} + title={t(($) => $['agentDetail.configure.chatFeatures.title'])} + description={t(($) => $['agentDetail.configure.chatFeatures.description'])} onChange={handleChange} onClose={onClose} /> ) } -export function AgentChatFeaturesPanel({ - appFeatures, - ...props -}: AgentChatFeaturesPanelProps) { +export function AgentChatFeaturesPanel({ appFeatures, ...props }: AgentChatFeaturesPanelProps) { const features = useMemo(() => toPanelFeatures(appFeatures), [appFeatures]) const featuresKey = useMemo(() => JSON.stringify(appFeatures ?? {}), [appFeatures]) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/chat-runtime.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/chat-runtime.tsx index 146057845e5a58..7d9b2dc12ede0e 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/chat-runtime.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/chat-runtime.tsx @@ -8,10 +8,13 @@ import type { AgentThought, MessageDetailResponse, } from '@dify/contracts/api/console/agent/types.gen' +import type { ReactNode } from 'react' import type { - ReactNode, -} from 'react' -import type { FeedbackType, IChatItem, InputForm, ThoughtItem } from '@/app/components/base/chat/chat/type' + FeedbackType, + IChatItem, + InputForm, + ThoughtItem, +} from '@/app/components/base/chat/chat/type' import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types' import type { FileUpload } from '@/app/components/base/features/types' import type { FileEntity } from '@/app/components/base/file-uploader/types' @@ -28,7 +31,11 @@ import { useTranslation } from 'react-i18next' import { AgentRosterResponseContent } from '@/app/components/base/chat/chat/answer/agent-roster-response-content' import ChatInputArea from '@/app/components/base/chat/chat/chat-input-area' import { useChat } from '@/app/components/base/chat/chat/hooks' -import { buildChatItemTree, getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils' +import { + buildChatItemTree, + getLastAnswer, + isValidGeneratedAnswer, +} from '@/app/components/base/chat/utils' import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import Loading from '@/app/components/base/loading' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -93,8 +100,7 @@ const disabledFileUploadConfig = { const defaultFileUploadMethods = [TransferMethod.local_file, TransferMethod.remote_url] const toPreviewFileUploadConfig = (fileUpload: FileUpload | undefined) => { - if (!fileUpload?.enabled) - return disabledFileUploadConfig + if (!fileUpload?.enabled) return disabledFileUploadConfig const allowedFileUploadMethods = fileUpload.allowed_file_upload_methods?.length ? fileUpload.allowed_file_upload_methods @@ -123,7 +129,8 @@ const toPreviewFileUploadConfig = (fileUpload: FileUpload | undefined) => { } as ChatConfig['file_upload'] } -const getModelSettings = (agentSoulConfig?: AgentSoulConfig) => agentSoulConfig?.model?.model_settings ?? {} +const getModelSettings = (agentSoulConfig?: AgentSoulConfig) => + agentSoulConfig?.model?.model_settings ?? {} const toEnabledConfig = (config?: { enabled?: boolean } | null) => ({ ...config, @@ -131,14 +138,10 @@ const toEnabledConfig = (config?: { enabled?: boolean } | null) => ({ }) const toInputType = (type: string): InputVarType => { - if (type === InputVarType.paragraph) - return InputVarType.paragraph - if (type === InputVarType.select) - return InputVarType.select - if (type === InputVarType.number) - return InputVarType.number - if (type === InputVarType.json || type === InputVarType.jsonObject) - return InputVarType.json + if (type === InputVarType.paragraph) return InputVarType.paragraph + if (type === InputVarType.select) return InputVarType.select + if (type === InputVarType.number) return InputVarType.number + if (type === InputVarType.json || type === InputVarType.jsonObject) return InputVarType.json return InputVarType.textInput } @@ -157,7 +160,8 @@ const toInputForm = (variable: NonNullable<AgentSoulConfig['app_variables']>[num } } -const getAgentSoulInputsForm = (agentSoulConfig?: AgentSoulConfig) => (agentSoulConfig?.app_variables ?? []).map(toInputForm) +const getAgentSoulInputsForm = (agentSoulConfig?: AgentSoulConfig) => + (agentSoulConfig?.app_variables ?? []).map(toInputForm) const getAgentSoulInputs = (inputsForm: InputForm[]) => { return inputsForm.reduce<Inputs>((acc, input) => { @@ -206,13 +210,16 @@ const toLegacyPreviewDatasetConfigs = ( reranking_model_name: retrieval?.reranking_model?.model ?? '', }, top_k: retrieval?.top_k ?? 4, - score_threshold_enabled: retrieval?.score_threshold !== undefined && retrieval?.score_threshold !== null, + score_threshold_enabled: + retrieval?.score_threshold !== undefined && retrieval?.score_threshold !== null, score_threshold: retrieval?.score_threshold ?? 0.8, datasets: { - datasets: datasets.map(dataset => ({ - enabled: true, - id: dataset.id ?? '', - })).filter(dataset => dataset.id), + datasets: datasets + .map((dataset) => ({ + enabled: true, + id: dataset.id ?? '', + })) + .filter((dataset) => dataset.id), }, } } @@ -226,7 +233,9 @@ const stopAgentChatMessageResponding = (agentId: string, taskId: string) => { }) } -const toFileResponse = (file: NonNullable<MessageDetailResponse['message_files']>[number]): FileResponse => ({ +const toFileResponse = ( + file: NonNullable<MessageDetailResponse['message_files']>[number], +): FileResponse => ({ related_id: file.id ?? file.upload_file_id, extension: '', filename: file.filename, @@ -239,20 +248,24 @@ const toFileResponse = (file: NonNullable<MessageDetailResponse['message_files'] remote_url: file.url ?? '', }) -const toLogMessages = (message: MessageDetailResponse['message'], answer: string, files: MessageDetailResponse['message_files']) => { - if (!Array.isArray(message)) - return [] +const toLogMessages = ( + message: MessageDetailResponse['message'], + answer: string, + files: MessageDetailResponse['message_files'], +) => { + if (!Array.isArray(message)) return [] const logMessages = message as IChatItem['log'] - if (logMessages?.at(-1)?.role === 'assistant') - return logMessages + if (logMessages?.at(-1)?.role === 'assistant') return logMessages return [ ...(logMessages ?? []), { role: 'assistant', text: answer, - files: getProcessedFilesFromResponse((files?.filter(file => file.belongs_to === 'assistant') || []).map(toFileResponse)), + files: getProcessedFilesFromResponse( + (files?.filter((file) => file.belongs_to === 'assistant') || []).map(toFileResponse), + ), }, ] } @@ -270,13 +283,13 @@ const toAgentThoughtItem = (thought: AgentThought, conversationId: string): Thou files: thought.files, }) -const toFeedback = (feedback: NonNullable<MessageDetailResponse['feedbacks']>[number] | undefined): FeedbackType | undefined => { - if (!feedback) - return undefined +const toFeedback = ( + feedback: NonNullable<MessageDetailResponse['feedbacks']>[number] | undefined, +): FeedbackType | undefined => { + if (!feedback) return undefined const rating = feedback.rating as MessageRating - if (rating !== 'like' && rating !== 'dislike' && rating !== null) - return undefined + if (rating !== 'like' && rating !== 'dislike' && rating !== null) return undefined return { rating, @@ -291,8 +304,7 @@ const getAgentDebugMessageAnswer = (message: MessageDetailResponse) => { function getLastWorkflowRunId(messages: MessageDetailResponse[]) { for (let index = messages.length - 1; index >= 0; index--) { const workflowRunId = messages[index]?.workflow_run_id - if (workflowRunId) - return workflowRunId + if (workflowRunId) return workflowRunId } return null @@ -303,8 +315,8 @@ function getFormattedAgentDebugChatTree(messages: MessageDetailResponse[]): Chat messages.forEach((item) => { const answer = getAgentDebugMessageAnswer(item) - const questionFiles = item.message_files?.filter(file => file.belongs_to === 'user') || [] - const answerFiles = item.message_files?.filter(file => file.belongs_to === 'assistant') || [] + const questionFiles = item.message_files?.filter((file) => file.belongs_to === 'user') || [] + const answerFiles = item.message_files?.filter((file) => file.belongs_to === 'assistant') || [] const answerTokens = item.answer_tokens ?? 0 const messageTokens = item.message_tokens ?? 0 const latency = item.provider_response_latency ?? 0 @@ -320,10 +332,14 @@ function getFormattedAgentDebugChatTree(messages: MessageDetailResponse[]): Chat id: item.id, content: answer, agent_thoughts: addFileInfos( - sortAgentSorts((item.agent_thoughts ?? []).map(thought => toAgentThoughtItem(thought, item.conversation_id))), + sortAgentSorts( + (item.agent_thoughts ?? []).map((thought) => + toAgentThoughtItem(thought, item.conversation_id), + ), + ), item.message_files as unknown as FileEntity[], ), - feedback: toFeedback(item.feedbacks?.find(feedback => feedback.from_source === 'user')), + feedback: toFeedback(item.feedbacks?.find((feedback) => feedback.from_source === 'user')), isAnswer: true, log: toLogMessages(item.message, answer, item.message_files), message_files: getProcessedFilesFromResponse(answerFiles.map(toFileResponse)), @@ -374,7 +390,7 @@ const buildChatConfig = ({ prompt_type: PromptMode.simple, chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG, completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG, - user_input_form: (agentSoulConfig?.app_variables ?? []).map(variable => ({ + user_input_form: (agentSoulConfig?.app_variables ?? []).map((variable) => ({ 'text-input': { default: String(variable.default ?? ''), label: variable.name, @@ -387,7 +403,9 @@ const buildChatConfig = ({ dataset_query_variable: '', opening_statement: appFeatures.opening_statement ?? '', suggested_questions: appFeatures.suggested_questions ?? [], - suggested_questions_after_answer: toEnabledConfig(appFeatures.suggested_questions_after_answer) as ChatConfig['suggested_questions_after_answer'], + suggested_questions_after_answer: toEnabledConfig( + appFeatures.suggested_questions_after_answer, + ) as ChatConfig['suggested_questions_after_answer'], more_like_this: { enabled: false }, text_to_speech: toEnabledConfig(appFeatures.text_to_speech) as ChatConfig['text_to_speech'], speech_to_text: toEnabledConfig(appFeatures.speech_to_text), @@ -483,32 +501,38 @@ export function AgentChatRuntime({ onSendInterrupted, onSaveDraftBeforeRun, }: AgentChatRuntimeProps) { - const [currentSessionConversationId, setCurrentSessionConversationId] = useState<string | null>(null) - const handleClearChatListChange = useCallback((nextClearChatList: boolean) => { - if (!nextClearChatList) - setCurrentSessionConversationId(null) - onClearChatListChange(nextClearChatList) - }, [onClearChatListChange]) - const historyQuery = useQuery(consoleQuery.agent.byAgentId.chatMessages.get.queryOptions({ - input: conversationId - ? { - params: { - agent_id: agentId, - }, - query: { - conversation_id: conversationId, - }, - } - : skipToken, - })) - const conversationBelongsToCurrentSession = !!conversationId && conversationId === currentSessionConversationId + const [currentSessionConversationId, setCurrentSessionConversationId] = useState<string | null>( + null, + ) + const handleClearChatListChange = useCallback( + (nextClearChatList: boolean) => { + if (!nextClearChatList) setCurrentSessionConversationId(null) + onClearChatListChange(nextClearChatList) + }, + [onClearChatListChange], + ) + const historyQuery = useQuery( + consoleQuery.agent.byAgentId.chatMessages.get.queryOptions({ + input: conversationId + ? { + params: { + agent_id: agentId, + }, + query: { + conversation_id: conversationId, + }, + } + : skipToken, + }), + ) + const conversationBelongsToCurrentSession = + !!conversationId && conversationId === currentSessionConversationId const initialChatTree = useMemo( () => getFormattedAgentDebugChatTree(historyQuery.data?.data ?? []), [historyQuery.data?.data], ) useEffect(() => { - if (!conversationId || !historyQuery.data) - return + if (!conversationId || !historyQuery.data) return onWorkflowRunIdChange?.(getLastWorkflowRunId(historyQuery.data.data ?? [])) }, [conversationId, historyQuery.data, onWorkflowRunIdChange]) @@ -520,9 +544,10 @@ export function AgentChatRuntime({ </div> ) } - const chatSessionKey = !conversationId || conversationBelongsToCurrentSession - ? 'current-session' - : `${conversationId}-${historyQuery.dataUpdatedAt}` + const chatSessionKey = + !conversationId || conversationBelongsToCurrentSession + ? 'current-session' + : `${conversationId}-${historyQuery.dataUpdatedAt}` return ( <AgentPreviewChatSession @@ -599,25 +624,27 @@ function AgentPreviewChatSession({ const userProfile = useAtomValue(userProfileAtom) const prompt = useAtomValue(agentComposerPromptAtom) const currentModel = useAtomValue(agentComposerModelAtom) - const config = useMemo(() => buildChatConfig({ - agentSoulConfig, - currentModel, - prompt, - }), [agentSoulConfig, currentModel, prompt]) + const config = useMemo( + () => + buildChatConfig({ + agentSoulConfig, + currentModel, + prompt, + }), + [agentSoulConfig, currentModel, prompt], + ) const inputsForm = useMemo(() => getAgentSoulInputsForm(agentSoulConfig), [agentSoulConfig]) const inputs = useMemo(() => getAgentSoulInputs(inputsForm), [inputsForm]) const sendInterruptedRef = useRef(false) const [isSendPending, setIsSendPending] = useState(false) const notifySendInterrupted = useCallback(() => { - if (sendInterruptedRef.current) - return + if (sendInterruptedRef.current) return sendInterruptedRef.current = true onSendInterrupted?.() }, [onSendInterrupted]) - const { - textGenerationModelList, - } = useTextGenerationCurrentProviderAndModelAndModelList(currentModel) + const { textGenerationModelList } = + useTextGenerationCurrentProviderAndModelAndModelList(currentModel) const { chatList, setTargetMessageId, @@ -644,43 +671,48 @@ function AgentPreviewChatSession({ { isNewAgent: true }, ) - const doSend: OnSend = useCallback(async (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { - sendInterruptedRef.current = false - setIsSendPending(true) - let sendStarted = false - - try { - const preparedAgentSoulConfig = await onSaveDraftBeforeRun?.() - const runtimeAgentSoulConfig = preparedAgentSoulConfig || agentSoulConfig - const runtimeInputsForm = preparedAgentSoulConfig ? getAgentSoulInputsForm(runtimeAgentSoulConfig) : inputsForm - const runtimeInputs = preparedAgentSoulConfig ? getAgentSoulInputs(runtimeInputsForm) : inputs - const runtimeConfig = preparedAgentSoulConfig - ? buildChatConfig({ - agentSoulConfig: runtimeAgentSoulConfig, - currentModel: undefined, - prompt: runtimeAgentSoulConfig?.prompt?.system_prompt ?? '', - }) - : config - - const currentProvider = textGenerationModelList.find(item => item.provider === runtimeConfig.model.provider) - const selectedModel = currentProvider?.models.find(model => model.model === runtimeConfig.model.name) - const supportVision = selectedModel?.features?.includes(ModelFeatureEnum.vision) - const data: Record<string, unknown> = { - query: message, - inputs: runtimeInputs, - overrideInputsForm: runtimeInputsForm, - parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, - } - if (draftType) - data.draft_type = draftType + const doSend: OnSend = useCallback( + async (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { + sendInterruptedRef.current = false + setIsSendPending(true) + let sendStarted = false + + try { + const preparedAgentSoulConfig = await onSaveDraftBeforeRun?.() + const runtimeAgentSoulConfig = preparedAgentSoulConfig || agentSoulConfig + const runtimeInputsForm = preparedAgentSoulConfig + ? getAgentSoulInputsForm(runtimeAgentSoulConfig) + : inputsForm + const runtimeInputs = preparedAgentSoulConfig + ? getAgentSoulInputs(runtimeInputsForm) + : inputs + const runtimeConfig = preparedAgentSoulConfig + ? buildChatConfig({ + agentSoulConfig: runtimeAgentSoulConfig, + currentModel: undefined, + prompt: runtimeAgentSoulConfig?.prompt?.system_prompt ?? '', + }) + : config + + const currentProvider = textGenerationModelList.find( + (item) => item.provider === runtimeConfig.model.provider, + ) + const selectedModel = currentProvider?.models.find( + (model) => model.model === runtimeConfig.model.name, + ) + const supportVision = selectedModel?.features?.includes(ModelFeatureEnum.vision) + const data: Record<string, unknown> = { + query: message, + inputs: runtimeInputs, + overrideInputsForm: runtimeInputsForm, + parent_message_id: + (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, + } + if (draftType) data.draft_type = draftType - if (files?.length && supportVision) - data.files = files + if (files?.length && supportVision) data.files = files - handleSend( - `agent/${agentId}/chat-messages`, - data as Parameters<typeof handleSend>[1], - { + handleSend(`agent/${agentId}/chat-messages`, data as Parameters<typeof handleSend>[1], { onGetConversationMessages: async (conversationId) => { return queryClient.fetchQuery({ ...consoleQuery.agent.byAgentId.chatMessages.get.queryOptions({ @@ -696,13 +728,14 @@ function AgentPreviewChatSession({ staleTime: 0, }) }, - onGetSuggestedQuestions: responseItemId => fetchAgentSuggestedQuestions(agentId, responseItemId), + onGetSuggestedQuestions: (responseItemId) => + fetchAgentSuggestedQuestions(agentId, responseItemId), onUnhandledEvent: (event) => { - if (event.event !== 'error' || typeof event.message !== 'string') - return + if (event.event !== 'error' || typeof event.message !== 'string') return return { - conversationId: typeof event.conversation_id === 'string' ? event.conversation_id : undefined, + conversationId: + typeof event.conversation_id === 'string' ? event.conversation_id : undefined, messageId: typeof event.message_id === 'string' ? event.message_id : undefined, errorMessage: event.message, errorCode: typeof event.code === 'string' ? event.code : undefined, @@ -716,45 +749,63 @@ function AgentPreviewChatSession({ }, onSendSettled: (hasError) => { setIsSendPending(false) - if (hasError) - notifySendInterrupted() + if (hasError) notifySendInterrupted() }, - }, - ) - sendStarted = true - } - catch { - return false - } - finally { - if (!sendStarted) - setIsSendPending(false) - } - }, [agentId, agentSoulConfig, chatList, config, conversationId, draftType, handleSend, inputs, inputsForm, notifySendInterrupted, onConversationComplete, onConversationIdChange, onCurrentSessionConversationIdChange, onSaveDraftBeforeRun, queryClient, textGenerationModelList]) + }) + sendStarted = true + } catch { + return false + } finally { + if (!sendStarted) setIsSendPending(false) + } + }, + [ + agentId, + agentSoulConfig, + chatList, + config, + conversationId, + draftType, + handleSend, + inputs, + inputsForm, + notifySendInterrupted, + onConversationComplete, + onConversationIdChange, + onCurrentSessionConversationIdChange, + onSaveDraftBeforeRun, + queryClient, + textGenerationModelList, + ], + ) const doStopResponding = useCallback(() => { handleStop() notifySendInterrupted() }, [handleStop, notifySendInterrupted]) - const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => { - const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId) - if (!question) - return - - const parentAnswer = chatList.find(item => item.id === question.parentMessageId) - doSend( - editedQuestion ? editedQuestion.message : question.content, - editedQuestion ? editedQuestion.files : question.message_files, - true, - isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, - ) - }, [chatList, doSend]) + const doRegenerate = useCallback( + (chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => { + const question = editedQuestion + ? chatItem + : chatList.find((item) => item.id === chatItem.parentMessageId) + if (!question) return + + const parentAnswer = chatList.find((item) => item.id === question.parentMessageId) + doSend( + editedQuestion ? editedQuestion.message : question.content, + editedQuestion ? editedQuestion.files : question.message_files, + true, + isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, + ) + }, + [chatList, doSend], + ) const isEmptyChat = chatList.length === 0 const hasInstructions = !!config.pre_prompt.trim() const sendButtonLoading = isEmptyChat && !!sendButtonLabel && (isSendPending || isResponding) - const sandboxNotice = t($ => $['agentDetail.configure.preview.sandboxNotice']) - const sandboxNoticeTooltip = t($ => $['agentDetail.configure.preview.sandboxNoticeTooltip']) + const sandboxNotice = t(($) => $['agentDetail.configure.preview.sandboxNotice']) + const sandboxNoticeTooltip = t(($) => $['agentDetail.configure.preview.sandboxNoticeTooltip']) const showSandboxNotice = isEmptyChat && !isSendPending && !isResponding const emptyChatInputNode = ( <div className="pointer-events-auto mt-5 w-full"> @@ -785,21 +836,20 @@ function AgentPreviewChatSession({ chatList={chatList} isResponding={isResponding} sendButtonLoading={sendButtonLoading} - chatNode={isEmptyChat - ? renderEmptyState({ - agentIcon, - agentIconBackground, - agentIconType, - agentName, - hasInstructions, - inputNode: emptyChatInputNode, - }) - : null} + chatNode={ + isEmptyChat + ? renderEmptyState({ + agentIcon, + agentIconBackground, + agentIconType, + agentName, + hasInstructions, + inputNode: emptyChatInputNode, + }) + : null + } chatContainerClassName={cn('pt-6', isEmptyChat ? 'px-12 pt-2 !pb-[88px]' : 'px-3')} - chatFooterClassName={cn( - '!bottom-2 pb-0', - isEmptyChat ? 'hidden' : 'px-3 pt-10', - )} + chatFooterClassName={cn('!bottom-2 pb-0', isEmptyChat ? 'hidden' : 'px-3 pt-10')} inputPlaceholder={inputPlaceholder} sendButtonLabel={isEmptyChat ? sendButtonLabel : undefined} showFileUpload={false} @@ -808,7 +858,7 @@ function AgentPreviewChatSession({ inputs={inputs} inputsForm={inputsForm} onRegenerate={doRegenerate} - switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)} + switchSibling={(siblingMessageId) => setTargetMessageId(siblingMessageId)} onStopResponding={doStopResponding} noChatInput={isEmptyChat} questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />} diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx index cbf4321b1584cb..123d1aa89c9aab 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx @@ -1,7 +1,11 @@ import type { MouseEvent, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { SegmentedControl, SegmentedControlDivider, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control' +import { + SegmentedControl, + SegmentedControlDivider, + SegmentedControlItem, +} from '@langgenius/dify-ui/segmented-control' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' import { AgentConfigureClearSessionConfirmDialog } from '../confirm-clear-session-dialog' @@ -28,13 +32,7 @@ function AgentModeTipSection({ ) } -function ModeInfoTip({ - children, - ariaLabel, -}: { - children: ReactNode - ariaLabel: string -}) { +function ModeInfoTip({ children, ariaLabel }: { children: ReactNode; ariaLabel: string }) { const handleClick = (event: MouseEvent<HTMLButtonElement>) => { event.stopPropagation() } @@ -49,7 +47,10 @@ function ModeInfoTip({ onClick={handleClick} className="inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" > - <span aria-hidden className="i-ri-question-line size-4 text-text-tertiary hover:text-text-secondary" /> + <span + aria-hidden + className="i-ri-question-line size-4 text-text-tertiary hover:text-text-secondary" + /> </PopoverTrigger> <PopoverContent placement="bottom" @@ -81,8 +82,7 @@ function PreviewModeItem({ </SegmentedControlItem> ) - if (previewEnabled) - return item + if (previewEnabled) return item return ( <Popover> @@ -132,12 +132,12 @@ export function AgentPreviewHeader({ }) { const { t } = useTranslation('agentV2') const docLink = useDocLink() - const buildLabel = t($ => $['agentDetail.configure.rightPanel.build']) - const buildTipBody = t($ => $['agentDetail.configure.rightPanel.buildTipBody']) - const previewLabel = t($ => $['agentDetail.configure.rightPanel.preview']) - const previewTipBody = t($ => $['agentDetail.configure.rightPanel.previewTipBody']) - const previewDisabledTip = t($ => $['agentDetail.configure.rightPanel.previewDisabledTip']) - const learnMoreLabel = t($ => $['agentDetail.configure.rightPanel.learnMore']) + const buildLabel = t(($) => $['agentDetail.configure.rightPanel.build']) + const buildTipBody = t(($) => $['agentDetail.configure.rightPanel.buildTipBody']) + const previewLabel = t(($) => $['agentDetail.configure.rightPanel.preview']) + const previewTipBody = t(($) => $['agentDetail.configure.rightPanel.previewTipBody']) + const previewDisabledTip = t(($) => $['agentDetail.configure.rightPanel.previewDisabledTip']) + const learnMoreLabel = t(($) => $['agentDetail.configure.rightPanel.learnMore']) const modeTip = `${buildLabel}. ${buildTipBody} ${learnMoreLabel} ${previewLabel}. ${previewTipBody}` return ( @@ -147,27 +147,28 @@ export function AgentPreviewHeader({ value={[mode]} onValueChange={(value) => { const nextMode = value[0] - if (nextMode && (nextMode !== 'preview' || previewEnabled)) - onModeChange(nextMode) + if (nextMode && (nextMode !== 'preview' || previewEnabled)) onModeChange(nextMode) }} - aria-label={t($ => $['agentDetail.configure.rightPanel.modeLabel'])} + aria-label={t(($) => $['agentDetail.configure.rightPanel.modeLabel'])} > <SegmentedControlItem<AgentConfigureRightPanelMode> value="build" className="uppercase"> <span aria-hidden className="i-custom-vender-agent-v2-configure-build size-4" /> - {t($ => $['agentDetail.configure.rightPanel.build'])} + {t(($) => $['agentDetail.configure.rightPanel.build'])} </SegmentedControlItem> <PreviewModeItem previewEnabled={previewEnabled} disabledTip={previewDisabledTip}> <span aria-hidden className="i-custom-vender-agent-v2-configure-preview size-4" /> - {t($ => $['agentDetail.configure.rightPanel.preview'])} + {t(($) => $['agentDetail.configure.rightPanel.preview'])} </PreviewModeItem> </SegmentedControl> <ModeInfoTip ariaLabel={modeTip}> <div className="flex flex-col gap-2"> <div className="flex flex-col gap-3"> - <AgentModeTipSection iconClassName="i-custom-vender-agent-v2-configure-build" title={buildLabel}> + <AgentModeTipSection + iconClassName="i-custom-vender-agent-v2-configure-build" + title={buildLabel} + > <> - {buildTipBody} - {' '} + {buildTipBody}{' '} <a href={docLink('/use-dify/build/new-agent/build#build-by-chatting')} target="_blank" @@ -178,7 +179,10 @@ export function AgentPreviewHeader({ </a> </> </AgentModeTipSection> - <AgentModeTipSection iconClassName="i-custom-vender-agent-v2-configure-preview" title={previewLabel}> + <AgentModeTipSection + iconClassName="i-custom-vender-agent-v2-configure-preview" + title={previewLabel} + > {previewTipBody} </AgentModeTipSection> </div> @@ -192,7 +196,7 @@ export function AgentPreviewHeader({ type="button" disabled={refreshDisabled} className="flex size-6 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50" - aria-label={t($ => $['agentDetail.configure.preview.restart'])} + aria-label={t(($) => $['agentDetail.configure.preview.restart'])} > <span aria-hidden className="i-custom-vender-other-replay-line size-4" /> </button> @@ -202,10 +206,12 @@ export function AgentPreviewHeader({ type="button" onClick={onOpenWorkingDirectory} className="flex h-8 items-center justify-center gap-0.5 rounded-lg px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" - aria-label={t($ => $['agentDetail.configure.workingDirectory.open'])} + aria-label={t(($) => $['agentDetail.configure.workingDirectory.open'])} > <span aria-hidden className="i-ri-folder-3-line size-4" /> - <span className="px-0.5 system-sm-medium">{t($ => $['agentDetail.configure.workingDirectory.fileSystem'])}</span> + <span className="px-0.5 system-sm-medium"> + {t(($) => $['agentDetail.configure.workingDirectory.fileSystem'])} + </span> </button> )} </div> @@ -220,10 +226,12 @@ export function AgentPreviewHeader({ 'flex h-8 items-center justify-center gap-1 rounded-lg px-2 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', isChatFeaturesOpen && 'bg-state-base-hover text-text-secondary', )} - aria-label={t($ => $['agentDetail.configure.preview.chatFeatures'])} + aria-label={t(($) => $['agentDetail.configure.preview.chatFeatures'])} > <span aria-hidden className="i-ri-chat-settings-line size-4" /> - <span className="px-0.5 system-sm-medium">{t($ => $['agentDetail.configure.preview.chatFeatures'])}</span> + <span className="px-0.5 system-sm-medium"> + {t(($) => $['agentDetail.configure.preview.chatFeatures'])} + </span> </button> </> )} diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/hook/use-working-directory-panel.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/hook/use-working-directory-panel.tsx index 3e982083bdbf33..b0ec68ddfe2671 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/hook/use-working-directory-panel.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/hook/use-working-directory-panel.tsx @@ -22,13 +22,15 @@ export function invalidateAgentWorkingDirectoryFiles({ }) { if (appId && nodeId) { void queryClient.invalidateQueries({ - queryKey: consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.get.key({ type: 'query' }), + queryKey: + consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.get.key( + { type: 'query' }, + ), }) return } - if (!conversationId) - return + if (!conversationId) return void queryClient.invalidateQueries({ queryKey: consoleQuery.agent.byAgentId.sandbox.files.get.key({ type: 'query' }), @@ -53,31 +55,26 @@ export function useAgentWorkingDirectoryPanel({ panel: ReactNode } { const [open, setOpen] = useState(false) - const source: AgentWorkingDirectorySource = appId && nodeId - ? { - type: 'workflow-node', - appId, - conversationId, - nodeId, - workflowRunId, - } - : { - type: 'agent', - agentId, - conversationId, - } + const source: AgentWorkingDirectorySource = + appId && nodeId + ? { + type: 'workflow-node', + appId, + conversationId, + nodeId, + workflowRunId, + } + : { + type: 'agent', + agentId, + conversationId, + } return { closeWorkingDirectory: () => setOpen(false), openWorkingDirectory: () => setOpen(true), - panel: open - ? ( - <AgentWorkingDirectoryPanel - source={source} - open={open} - onOpenChange={setOpen} - /> - ) - : null, + panel: open ? ( + <AgentWorkingDirectoryPanel source={source} open={open} onOpenChange={setOpen} /> + ) : null, } } diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/preview-chat.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/preview-chat.tsx index 1e8ac0bc67286f..d251bb3ec30f4c 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/preview-chat.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/preview-chat.tsx @@ -16,7 +16,7 @@ function AgentPreviewChatEmptyState({ inputNode, }: AgentChatRuntimeEmptyStateProps) { const { t } = useTranslation('agentV2') - const imageUrl = (agentIconType === 'image' || agentIconType === 'link') ? agentIcon : undefined + const imageUrl = agentIconType === 'image' || agentIconType === 'link' ? agentIcon : undefined const iconType = imageUrl ? 'image' : agentIconType return ( @@ -32,16 +32,16 @@ function AgentPreviewChatEmptyState({ className="bg-background-default" /> <div className="mt-3 max-w-full truncate system-md-medium text-text-secondary"> - {t($ => $['agentDetail.configure.preview.empty.title'], { - name: agentName || t($ => $['agentDetail.configure.preview.empty.defaultAgentName']), + {t(($) => $['agentDetail.configure.preview.empty.title'], { + name: agentName || t(($) => $['agentDetail.configure.preview.empty.defaultAgentName']), })} </div> <p className="mt-1 max-w-full body-md-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.preview.empty.description'])} + {t(($) => $['agentDetail.configure.preview.empty.description'])} </p> {!hasInstructions && ( <p className="mt-1 max-w-full body-md-regular text-text-tertiary"> - {t($ => $['agentDetail.configure.preview.empty.noInstructionsDescription'])} + {t(($) => $['agentDetail.configure.preview.empty.noInstructionsDescription'])} </p> )} {inputNode} @@ -52,17 +52,16 @@ function AgentPreviewChatEmptyState({ export function AgentPreviewChat(props: AgentPreviewChatProps) { const { t } = useTranslation('agentV2') - const agentName = props.agentName || t($ => $['agentDetail.configure.preview.empty.defaultAgentName']) + const agentName = + props.agentName || t(($) => $['agentDetail.configure.preview.empty.defaultAgentName']) return ( <AgentChatRuntime {...props} - inputPlaceholder={t($ => $['agentDetail.configure.preview.inputPlaceholder'], { + inputPlaceholder={t(($) => $['agentDetail.configure.preview.inputPlaceholder'], { name: agentName, })} - renderEmptyState={emptyStateProps => ( - <AgentPreviewChatEmptyState {...emptyStateProps} /> - )} + renderEmptyState={(emptyStateProps) => <AgentPreviewChatEmptyState {...emptyStateProps} />} /> ) } diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/right-panel-chat.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/right-panel-chat.tsx index ad461c5ff2d189..52db74013f5354 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/right-panel-chat.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/right-panel-chat.tsx @@ -13,11 +13,18 @@ export function AgentConfigureRightPanelChat({ onConversationComplete, onConversationIdChange, ...props -}: Omit<Parameters<typeof AgentPreviewChat>[0], 'agentSoulConfig' | 'conversationId' | 'onConversationComplete' | 'onConversationIdChange'> & { +}: Omit< + Parameters<typeof AgentPreviewChat>[0], + 'agentSoulConfig' | 'conversationId' | 'onConversationComplete' | 'onConversationIdChange' +> & { agentSoulConfig?: AgentSoulConfig conversationIds: AgentConfigureConversationIds mode: AgentConfigureRightPanelMode - onConversationComplete?: (mode: AgentConfigureRightPanelMode, conversationId: string, workflowRunId?: string) => void + onConversationComplete?: ( + mode: AgentConfigureRightPanelMode, + conversationId: string, + workflowRunId?: string, + ) => void onConversationIdChange: (mode: AgentConfigureRightPanelMode, conversationId: string) => void }) { const previewAgentSoulConfig = useAgentPreviewSoulConfig(agentSoulConfig) @@ -29,23 +36,21 @@ export function AgentConfigureRightPanelChat({ onConversationComplete?.(mode, completedConversationId, workflowRunId) } - return mode === 'build' - ? ( - <AgentBuildChat - {...props} - conversationId={conversationId} - agentSoulConfig={previewAgentSoulConfig} - onConversationComplete={handleConversationComplete} - onConversationIdChange={handleConversationIdChange} - /> - ) - : ( - <AgentPreviewChat - {...props} - conversationId={conversationId} - agentSoulConfig={previewAgentSoulConfig} - onConversationComplete={handleConversationComplete} - onConversationIdChange={handleConversationIdChange} - /> - ) + return mode === 'build' ? ( + <AgentBuildChat + {...props} + conversationId={conversationId} + agentSoulConfig={previewAgentSoulConfig} + onConversationComplete={handleConversationComplete} + onConversationIdChange={handleConversationIdChange} + /> + ) : ( + <AgentPreviewChat + {...props} + conversationId={conversationId} + agentSoulConfig={previewAgentSoulConfig} + onConversationComplete={handleConversationComplete} + onConversationIdChange={handleConversationIdChange} + /> + ) } diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/current-draft-item.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/current-draft-item.tsx index 0c012db40fac29..191feddcb70596 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/current-draft-item.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/current-draft-item.tsx @@ -25,8 +25,13 @@ export function CurrentDraftItem({ > <VersionTimelineDot isActive={isActive} isFirst isLast={isLast} /> <div className="min-w-0 flex-1 py-1"> - <p className={cn('truncate system-sm-semibold', isActive ? 'text-text-accent' : 'text-text-secondary')}> - {tWorkflow($ => $['versionHistory.currentDraft'])} + <p + className={cn( + 'truncate system-sm-semibold', + isActive ? 'text-text-accent' : 'text-text-secondary', + )} + > + {tWorkflow(($) => $['versionHistory.currentDraft'])} </p> </div> </button> diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/filter.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/filter.tsx index bd059357bcbd5d..0399725c5c729c 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/filter.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/filter.tsx @@ -1,11 +1,7 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -27,7 +23,9 @@ function FilterItem({ className="flex w-full cursor-pointer items-center justify-between gap-x-1 rounded-lg px-2 py-1.5 text-left hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > <span className="min-w-0 flex-1 truncate system-md-regular text-text-primary">{label}</span> - {selected && <span aria-hidden className="i-ri-check-line size-4 shrink-0 text-text-accent" />} + {selected && ( + <span aria-hidden className="i-ri-check-line size-4 shrink-0 text-text-accent" /> + )} </button> ) } @@ -48,10 +46,10 @@ export function VersionFilter({ <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger nativeButton={false} - render={( + render={ <button type="button" - aria-label={t($ => $['agentDetail.versionHistory.filter'])} + aria-label={t(($) => $['agentDetail.versionHistory.filter'])} className={cn( 'flex size-6 shrink-0 items-center justify-center rounded-md p-0.5 focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', isFiltering @@ -61,7 +59,7 @@ export function VersionFilter({ > <span aria-hidden className="i-ri-filter-3-line size-4" /> </button> - )} + } /> <PopoverContent placement="bottom-end" @@ -72,12 +70,12 @@ export function VersionFilter({ <div className="flex w-[248px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"> <div className="flex flex-col p-1"> <FilterItem - label={tWorkflow($ => $['versionHistory.filter.all'])} + label={tWorkflow(($) => $['versionHistory.filter.all'])} selected={filterValue === 'all'} onClick={() => onFilterChange('all')} /> <FilterItem - label={tWorkflow($ => $['versionHistory.filter.onlyYours'])} + label={tWorkflow(($) => $['versionHistory.filter.onlyYours'])} selected={filterValue === 'onlyYours'} onClick={() => onFilterChange('onlyYours')} /> diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/index.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/index.tsx index 7a5985e3705fc0..ce419386d225de 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/index.tsx @@ -30,20 +30,20 @@ export function AgentPreviewVersionsPanel({ const { t: tWorkflow } = useTranslation('workflow') const userProfile = useAtomValue(userProfileAtom) const [filterValue, setFilterValue] = useState<AgentVersionFilter>('all') - const versionsQuery = useQuery(consoleQuery.agent.byAgentId.versions.get.queryOptions({ - input: { - params: { - agent_id: agentId, + const versionsQuery = useQuery( + consoleQuery.agent.byAgentId.versions.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, }, - }, - })) + }), + ) const versions = versionsQuery.data?.data ?? [] const latestVersionId = versions[0]?.id - const currentUserCreatedByValues = new Set([ - userProfile.id, - userProfile.name, - userProfile.email, - ].filter(Boolean)) + const currentUserCreatedByValues = new Set( + [userProfile.id, userProfile.name, userProfile.email].filter(Boolean), + ) const filteredVersions = versions.filter((version) => { if (filterValue === 'onlyYours') return !!version.created_by && currentUserCreatedByValues.has(version.created_by) @@ -60,16 +60,13 @@ export function AgentPreviewVersionsPanel({ <aside className="flex h-full w-[268px] shrink-0 flex-col rounded-l-lg bg-components-panel-bg shadow-xl shadow-shadow-shadow-5"> <div className="flex shrink-0 items-center gap-2 pt-3 pr-3 pl-4"> <h2 className="min-w-0 flex-1 truncate system-xl-semibold text-text-primary"> - {tWorkflow($ => $['versionHistory.title'])} + {tWorkflow(($) => $['versionHistory.title'])} </h2> - <VersionFilter - filterValue={filterValue} - onFilterChange={setFilterValue} - /> + <VersionFilter filterValue={filterValue} onFilterChange={setFilterValue} /> <div className="h-3.5 w-px shrink-0 bg-divider-regular" /> <button type="button" - aria-label={tCommon($ => $['operation.close'])} + aria-label={tCommon(($) => $['operation.close'])} onClick={onClose} className="flex size-6 shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > @@ -87,7 +84,7 @@ export function AgentPreviewVersionsPanel({ )} {!versionsQuery.isPending && versions.length === 0 && ( <div className="rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3 py-6 text-center system-sm-regular text-text-tertiary"> - {t($ => $['agentDetail.versionHistory.empty'])} + {t(($) => $['agentDetail.versionHistory.empty'])} </div> )} {!versionsQuery.isPending && versions.length > 0 && ( diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/version-filter-empty.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/version-filter-empty.tsx index 4e3f7d83e3e3b4..2027d02c841070 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/version-filter-empty.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/version-filter-empty.tsx @@ -1,23 +1,19 @@ import { useTranslation } from 'react-i18next' -export function VersionFilterEmpty({ - onReset, -}: { - onReset: () => void -}) { +export function VersionFilterEmpty({ onReset }: { onReset: () => void }) { const { t: tWorkflow } = useTranslation('workflow') return ( <div className="rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3 py-6 text-center"> <p className="system-sm-regular text-text-tertiary"> - {tWorkflow($ => $['versionHistory.filter.empty'])} + {tWorkflow(($) => $['versionHistory.filter.empty'])} </p> <button type="button" onClick={onReset} className="mt-2 rounded-md px-2 py-1 system-xs-medium text-text-accent hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > - {tWorkflow($ => $['versionHistory.filter.reset'])} + {tWorkflow(($) => $['versionHistory.filter.reset'])} </button> </div> ) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/version-item.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/version-item.tsx index 3cc8d182404080..0a06245726897c 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/version-item.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/versions-panel/version-item.tsx @@ -4,20 +4,19 @@ import { useTranslation } from 'react-i18next' import useTimestamp from '@/hooks/use-timestamp' import { VersionTimelineDot } from './version-timeline-dot' -function VersionMetadata({ - version, -}: { - version: AgentConfigSnapshotSummaryResponse -}) { +function VersionMetadata({ version }: { version: AgentConfigSnapshotSummaryResponse }) { const { t } = useTranslation('agentV2') const { formatTime } = useTimestamp() - if (version.created_at == null && !version.created_by) - return null + if (version.created_at == null && !version.created_by) return null return ( <p className="truncate system-xs-regular text-text-tertiary"> - {version.created_at != null && formatTime(version.created_at, t($ => $['roster.dateTimeFormat']))} + {version.created_at != null && + formatTime( + version.created_at, + t(($) => $['roster.dateTimeFormat']), + )} {version.created_at != null && version.created_by && ' · '} {version.created_by} </p> @@ -42,7 +41,9 @@ export function VersionItem({ const { t } = useTranslation('agentV2') const { t: tWorkflow } = useTranslation('workflow') const isActive = version.id === activeVersionId - const label = version.version_note || t($ => $['agentDetail.versionHistory.versionName'], { version: version.version }) + const label = + version.version_note || + t(($) => $['agentDetail.versionHistory.versionName'], { version: version.version }) return ( <button @@ -57,12 +58,17 @@ export function VersionItem({ <VersionTimelineDot isActive={isActive} isFirst={isFirst} isLast={isLast} /> <div className="min-w-0 flex-1 py-0.5"> <div className="flex min-w-0 items-center gap-1"> - <p className={cn('truncate system-sm-semibold', isActive ? 'text-text-accent' : 'text-text-secondary')}> + <p + className={cn( + 'truncate system-sm-semibold', + isActive ? 'text-text-accent' : 'text-text-secondary', + )} + > {label} </p> {isLatest && ( <span className="shrink-0 rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-[5px] py-[3px] system-2xs-medium-uppercase text-text-accent-secondary"> - {tWorkflow($ => $['versionHistory.latest'])} + {tWorkflow(($) => $['versionHistory.latest'])} </span> )} </div> diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-breadcrumb.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-breadcrumb.tsx index bde1498af9473c..d22b7d86538392 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-breadcrumb.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-breadcrumb.tsx @@ -12,10 +12,10 @@ import { useTranslation } from 'react-i18next' const AGENT_WORKING_DIRECTORY_HOME_PATH = '~' const AGENT_WORKING_DIRECTORY_ROOT_PATH = '.' -export type AgentWorkingDirectoryPath - = | typeof AGENT_WORKING_DIRECTORY_HOME_PATH - | typeof AGENT_WORKING_DIRECTORY_ROOT_PATH - | string +export type AgentWorkingDirectoryPath = + | typeof AGENT_WORKING_DIRECTORY_HOME_PATH + | typeof AGENT_WORKING_DIRECTORY_ROOT_PATH + | string type AgentWorkingDirectoryBreadcrumbItemData = { iconClassName: string @@ -35,7 +35,9 @@ const normalizeWorkingDirectoryPath = (path: AgentWorkingDirectoryPath) => { function buildPathFromSegments(segments: string[], options: { startsFromHome: boolean }) { if (options.startsFromHome) - return segments.length ? `${AGENT_WORKING_DIRECTORY_HOME_PATH}/${segments.join('/')}` : AGENT_WORKING_DIRECTORY_HOME_PATH + return segments.length + ? `${AGENT_WORKING_DIRECTORY_HOME_PATH}/${segments.join('/')}` + : AGENT_WORKING_DIRECTORY_HOME_PATH return segments.length ? segments.join('/') : AGENT_WORKING_DIRECTORY_ROOT_PATH } @@ -51,19 +53,23 @@ function getBreadcrumbItems({ const normalizedHomeLabel = homeLabel === 'home' ? 'Home' : homeLabel if (normalizedPath === AGENT_WORKING_DIRECTORY_HOME_PATH) { - return [{ - iconClassName: 'i-ri-folder-3-line', - label: normalizedHomeLabel, - path: AGENT_WORKING_DIRECTORY_HOME_PATH, - }] + return [ + { + iconClassName: 'i-ri-folder-3-line', + label: normalizedHomeLabel, + path: AGENT_WORKING_DIRECTORY_HOME_PATH, + }, + ] } if (normalizedPath === AGENT_WORKING_DIRECTORY_ROOT_PATH) { - return [{ - iconClassName: 'i-ri-folder-3-line', - label: AGENT_WORKING_DIRECTORY_ROOT_PATH, - path: AGENT_WORKING_DIRECTORY_ROOT_PATH, - }] + return [ + { + iconClassName: 'i-ri-folder-3-line', + label: AGENT_WORKING_DIRECTORY_ROOT_PATH, + path: AGENT_WORKING_DIRECTORY_ROOT_PATH, + }, + ] } const startsFromHome = normalizedPath.startsWith(`${AGENT_WORKING_DIRECTORY_HOME_PATH}/`) @@ -143,19 +149,21 @@ export function AgentWorkingDirectoryBreadcrumb({ }) { const { t } = useTranslation('agentV2') const items = getBreadcrumbItems({ - homeLabel: t($ => $['agentDetail.configure.workingDirectory.home']), + homeLabel: t(($) => $['agentDetail.configure.workingDirectory.home']), path, }) const { hiddenItems, visibleItems } = getVisibleBreadcrumbItems(items) const renderSeparator = (key: string) => ( - <span key={key} aria-hidden className="system-xs-regular text-divider-deep">/</span> + <span key={key} aria-hidden className="system-xs-regular text-divider-deep"> + / + </span> ) return ( <div className="mb-1 flex w-full shrink-0 flex-col border-y-[0.5px] border-divider-regular px-2.5"> <nav - aria-label={t($ => $['agentDetail.configure.workingDirectory.breadcrumbLabel'])} + aria-label={t(($) => $['agentDetail.configure.workingDirectory.breadcrumbLabel'])} className="flex min-w-0 items-center gap-0.5 py-1" > {visibleItems.map((item, index) => { @@ -173,15 +181,27 @@ export function AgentWorkingDirectoryBreadcrumb({ > <span aria-hidden className="i-ri-more-fill size-4" /> </DropdownMenuTrigger> - <DropdownMenuContent placement="bottom-start" sideOffset={4} popupClassName="w-[136px] p-1"> - {hiddenItems.map(hiddenItem => ( + <DropdownMenuContent + placement="bottom-start" + sideOffset={4} + popupClassName="w-[136px] p-1" + > + {hiddenItems.map((hiddenItem) => ( <DropdownMenuItem key={hiddenItem.path} className="gap-1 px-2 py-1.5" onClick={() => onPathChange(hiddenItem.path)} > - <span aria-hidden className={cn('size-4 shrink-0 text-text-secondary', hiddenItem.iconClassName)} /> - <span className="min-w-0 truncate px-1 system-md-regular">{hiddenItem.label}</span> + <span + aria-hidden + className={cn( + 'size-4 shrink-0 text-text-secondary', + hiddenItem.iconClassName, + )} + /> + <span className="min-w-0 truncate px-1 system-md-regular"> + {hiddenItem.label} + </span> </DropdownMenuItem> ))} </DropdownMenuContent> diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-panel.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-panel.tsx index 084fd84f94d0e7..ec5f6f9b61b4e7 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-panel.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-panel.tsx @@ -1,6 +1,10 @@ 'use client' -import type { SandboxFileEntryResponse, SandboxListResponse, SandboxReadResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { + SandboxFileEntryResponse, + SandboxListResponse, + SandboxReadResponse, +} from '@dify/contracts/api/console/agent/types.gen' import type { AgentSkillDetailDownloadAction } from '../orchestrate/skills/detail-dialog' import type { AgentWorkingDirectoryPath } from './working-directory-breadcrumb' import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state' @@ -22,30 +26,34 @@ type AgentWorkingDirectoryPanelProps = { open: boolean } -export type AgentWorkingDirectorySource = { - type: 'agent' - agentId: string - conversationId?: string | null -} | { - type: 'workflow-node' - appId?: string - conversationId?: string | null - nodeId: string - workflowRunId?: string | null -} +export type AgentWorkingDirectorySource = + | { + type: 'agent' + agentId: string + conversationId?: string | null + } + | { + type: 'workflow-node' + appId?: string + conversationId?: string | null + nodeId: string + workflowRunId?: string | null + } type SandboxErrorPayload = { code?: string } const normalizeSandboxPath = (path: string) => { - const normalizedPath = path.replace(/^~(?:\/|$)/, '').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '') + const normalizedPath = path + .replace(/^~(?:\/|$)/, '') + .replace(/^\.\//, '') + .replace(/^\/+|\/+$/g, '') return normalizedPath === '.' ? '' : normalizedPath } const toSandboxHomePath = (path: string) => { - if (path === '.') - return '.' + if (path === '.') return '.' const normalizedPath = normalizeSandboxPath(path) return normalizedPath ? `~/${normalizedPath}` : '~' @@ -62,17 +70,17 @@ function getSandboxEntryRelativePathSegments(entryName: string, basePath: string const normalizedBasePath = normalizeSandboxPath(basePath) const normalizedEntryName = normalizeSandboxPath(entryName) - if (!normalizedEntryName) - return [] + if (!normalizedEntryName) return [] - if (!normalizedBasePath) - return normalizedEntryName.split('/').filter(Boolean) + if (!normalizedBasePath) return normalizedEntryName.split('/').filter(Boolean) - if (normalizedEntryName === normalizedBasePath) - return [] + if (normalizedEntryName === normalizedBasePath) return [] if (normalizedEntryName.startsWith(`${normalizedBasePath}/`)) - return normalizedEntryName.slice(normalizedBasePath.length + 1).split('/').filter(Boolean) + return normalizedEntryName + .slice(normalizedBasePath.length + 1) + .split('/') + .filter(Boolean) return normalizedEntryName.split('/').filter(Boolean) } @@ -80,7 +88,7 @@ function getSandboxEntryRelativePathSegments(entryName: string, basePath: string function buildSandboxFileTree( entries: SandboxFileEntryResponse[] = [], basePath = '.', - options: { nestRootPath?: string, nestUnderBasePath?: boolean } = {}, + options: { nestRootPath?: string; nestUnderBasePath?: boolean } = {}, ): AgentFileNode[] { const normalizedBasePath = normalizeSandboxPath(basePath) const normalizedNestRootPath = normalizeSandboxPath(options.nestRootPath ?? '.') @@ -92,12 +100,12 @@ function buildSandboxFileTree( let currentPath = normalizedNestRootPath const basePathSegments = normalizedBasePath.split('/').filter(Boolean) const nestRootPathSegments = normalizedNestRootPath.split('/').filter(Boolean) - const nestedBasePathSegments = normalizedNestRootPath && ( - normalizedBasePath === normalizedNestRootPath - || normalizedBasePath.startsWith(`${normalizedNestRootPath}/`) - ) - ? basePathSegments.slice(nestRootPathSegments.length) - : basePathSegments + const nestedBasePathSegments = + normalizedNestRootPath && + (normalizedBasePath === normalizedNestRootPath || + normalizedBasePath.startsWith(`${normalizedNestRootPath}/`)) + ? basePathSegments.slice(nestRootPathSegments.length) + : basePathSegments nestedBasePathSegments.forEach((segment) => { currentPath = joinSandboxPath(currentPath, segment) @@ -116,8 +124,7 @@ function buildSandboxFileTree( for (const entry of entries) { const pathSegments = getSandboxEntryRelativePathSegments(entry.name, basePath) - if (!pathSegments.length) - continue + if (!pathSegments.length) continue let currentFiles = baseFolder?.children ?? rootFiles let currentPath = normalizedBasePath @@ -126,7 +133,7 @@ function buildSandboxFileTree( const isLeaf = index === pathSegments.length - 1 const isFolder = !isLeaf || entry.type === 'dir' const nodePath = joinSandboxPath(currentPath, segment) - let node = currentFiles.find(file => file.id === nodePath) + let node = currentFiles.find((file) => file.id === nodePath) if (!node) { node = { @@ -150,11 +157,14 @@ function buildSandboxFileTree( return rootFiles } -function mergeSandboxFileTree(targetFiles: AgentFileNode[], sourceFiles: AgentFileNode[]): AgentFileNode[] { +function mergeSandboxFileTree( + targetFiles: AgentFileNode[], + sourceFiles: AgentFileNode[], +): AgentFileNode[] { const mergedFiles = [...targetFiles] for (const sourceFile of sourceFiles) { - const targetFileIndex = mergedFiles.findIndex(file => file.id === sourceFile.id) + const targetFileIndex = mergedFiles.findIndex((file) => file.id === sourceFile.id) if (targetFileIndex === -1) { mergedFiles.push(sourceFile) continue @@ -175,47 +185,39 @@ function findFirstReadableFile(files: AgentFileNode[]): AgentFileNode | undefine for (const file of files) { if (file.children?.length) { const childFile = findFirstReadableFile(file.children) - if (childFile) - return childFile - } - else if (file.icon !== 'folder') { + if (childFile) return childFile + } else if (file.icon !== 'folder') { return file } } } function findReadableFile(files: AgentFileNode[], fileId?: string): AgentFileNode | undefined { - if (!fileId) - return undefined + if (!fileId) return undefined for (const file of files) { - if (file.id === fileId && file.icon !== 'folder') - return file + if (file.id === fileId && file.icon !== 'folder') return file const childFile = findReadableFile(file.children ?? [], fileId) - if (childFile) - return childFile + if (childFile) return childFile } } function countReadableFiles(files: AgentFileNode[]): number { return files.reduce((count, file) => { - if (file.icon === 'folder') - return count + countReadableFiles(file.children ?? []) + if (file.icon === 'folder') return count + countReadableFiles(file.children ?? []) return count + 1 }, 0) } async function isNoActiveSessionError(error: unknown) { - if (!(error instanceof Response) || error.status !== 404) - return false + if (!(error instanceof Response) || error.status !== 404) return false try { - const payload = await error.clone().json() as SandboxErrorPayload + const payload = (await error.clone().json()) as SandboxErrorPayload return payload.code === 'no_active_session' - } - catch { + } catch { return false } } @@ -226,10 +228,11 @@ function isSandboxPathWithinDirectory(path: string, directory: string) { const normalizedPath = normalizeSandboxPath(path) const normalizedDirectory = normalizeSandboxPath(directory) - if (!normalizedDirectory) - return true + if (!normalizedDirectory) return true - return normalizedPath === normalizedDirectory || normalizedPath.startsWith(`${normalizedDirectory}/`) + return ( + normalizedPath === normalizedDirectory || normalizedPath.startsWith(`${normalizedDirectory}/`) + ) } export function AgentWorkingDirectoryPanel({ @@ -244,24 +247,24 @@ export function AgentWorkingDirectoryPanel({ const [loadedFolderPaths, setLoadedFolderPaths] = useState<string[]>([]) const [openFolderPaths, setOpenFolderPaths] = useState<string[]>([]) const [pendingOpenFolderPaths, setPendingOpenFolderPaths] = useState<string[]>([]) - const [downloadActionLoadingTarget, setDownloadActionLoadingTarget] = useState<AgentSkillDetailDownloadAction | null>(null) - const workflowNodeRunId = source.type === 'workflow-node' - ? (source.workflowRunId ?? source.conversationId) - : undefined - const hasWorkingDirectorySource = source.type === 'agent' - ? !!source.conversationId - : !!source.appId && !!workflowNodeRunId + const [downloadActionLoadingTarget, setDownloadActionLoadingTarget] = + useState<AgentSkillDetailDownloadAction | null>(null) + const workflowNodeRunId = + source.type === 'workflow-node' ? (source.workflowRunId ?? source.conversationId) : undefined + const hasWorkingDirectorySource = + source.type === 'agent' ? !!source.conversationId : !!source.appId && !!workflowNodeRunId const sandboxInfoQueryOptions = consoleQuery.agent.byAgentId.sandbox.get.queryOptions({ - input: source.type === 'agent' && source.conversationId - ? { - params: { - agent_id: source.agentId, - }, - query: { - conversation_id: source.conversationId, - }, - } - : skipToken, + input: + source.type === 'agent' && source.conversationId + ? { + params: { + agent_id: source.agentId, + }, + query: { + conversation_id: source.conversationId, + }, + } + : skipToken, context: { silent: true, }, @@ -271,44 +274,51 @@ export function AgentWorkingDirectoryPanel({ enabled: open && source.type === 'agent' && !!source.conversationId, retry: false, }) - const isSandboxInfoLoading = source.type === 'agent' && !!source.conversationId && sandboxInfoQuery.isPending + const isSandboxInfoLoading = + source.type === 'agent' && !!source.conversationId && sandboxInfoQuery.isPending const workspaceDirectoryPath = sandboxInfoQuery.data?.workspace_cwd const directoryPath = selectedDirectoryPath ?? workspaceDirectoryPath ?? '.' - const showReturnToWorkspaceButton = !!workspaceDirectoryPath && !isSandboxPathWithinDirectory(directoryPath, workspaceDirectoryPath) - const getFileListQueryOptions = (path: string) => source.type === 'agent' - ? consoleQuery.agent.byAgentId.sandbox.files.get.queryOptions({ - input: source.conversationId && !isSandboxInfoLoading - ? { - params: { - agent_id: source.agentId, - }, - query: { - conversation_id: source.conversationId, - path: toSandboxApiPath(path), - }, - } - : skipToken, - context: { - silent: true, - }, - }) - : consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.get.queryOptions({ - input: source.appId && workflowNodeRunId - ? { - params: { - app_id: source.appId, - workflow_run_id: workflowNodeRunId, - node_id: source.nodeId, - }, - query: { - path: toSandboxApiPath(path), - }, - } - : skipToken, - context: { - silent: true, - }, - }) + const showReturnToWorkspaceButton = + !!workspaceDirectoryPath && !isSandboxPathWithinDirectory(directoryPath, workspaceDirectoryPath) + const getFileListQueryOptions = (path: string) => + source.type === 'agent' + ? consoleQuery.agent.byAgentId.sandbox.files.get.queryOptions({ + input: + source.conversationId && !isSandboxInfoLoading + ? { + params: { + agent_id: source.agentId, + }, + query: { + conversation_id: source.conversationId, + path: toSandboxApiPath(path), + }, + } + : skipToken, + context: { + silent: true, + }, + }) + : consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.get.queryOptions( + { + input: + source.appId && workflowNodeRunId + ? { + params: { + app_id: source.appId, + workflow_run_id: workflowNodeRunId, + node_id: source.nodeId, + }, + query: { + path: toSandboxApiPath(path), + }, + } + : skipToken, + context: { + silent: true, + }, + }, + ) const handleDirectoryPathChange = (path: AgentWorkingDirectoryPath) => { setSelectedDirectoryPath(path) setSelectedFileId(undefined) @@ -322,8 +332,7 @@ export function AgentWorkingDirectoryPanel({ queryFn: async (context): Promise<SandboxListResponse> => { try { return await fileListQueryOptions.queryFn(context) - } - catch (error) { + } catch (error) { if (await isNoActiveSessionError(error)) { return { entries: [], @@ -346,8 +355,7 @@ export function AgentWorkingDirectoryPanel({ queryFn: async (context): Promise<SandboxListResponse> => { try { return await queryOptions.queryFn(context) - } - catch (error) { + } catch (error) { if (await isNoActiveSessionError(error)) { return { entries: [], @@ -363,63 +371,74 @@ export function AgentWorkingDirectoryPanel({ }) : [], }) - const workingDirectoryFiles = expandedFolderQueries.reduce((files, query, index) => { - return mergeSandboxFileTree(files, buildSandboxFileTree( - query.data?.entries, - loadedFolderPaths[index] ?? query.data?.path, - { - nestRootPath: directoryPath, - nestUnderBasePath: true, - }, - )) - }, buildSandboxFileTree(fileListQuery.data?.entries, fileListQuery.data?.path)) - const selectedWorkingDirectoryFile = findReadableFile(workingDirectoryFiles, selectedFileId) - ?? findFirstReadableFile(workingDirectoryFiles) - const isFileListLoading = hasWorkingDirectorySource && (isSandboxInfoLoading || fileListQuery.isPending) - const loadingFolderPaths = new Set(loadedFolderPaths.filter((path, index) => expandedFolderQueries[index]?.isPending)) + const workingDirectoryFiles = expandedFolderQueries.reduce( + (files, query, index) => { + return mergeSandboxFileTree( + files, + buildSandboxFileTree(query.data?.entries, loadedFolderPaths[index] ?? query.data?.path, { + nestRootPath: directoryPath, + nestUnderBasePath: true, + }), + ) + }, + buildSandboxFileTree(fileListQuery.data?.entries, fileListQuery.data?.path), + ) + const selectedWorkingDirectoryFile = + findReadableFile(workingDirectoryFiles, selectedFileId) ?? + findFirstReadableFile(workingDirectoryFiles) + const isFileListLoading = + hasWorkingDirectorySource && (isSandboxInfoLoading || fileListQuery.isPending) + const loadingFolderPaths = new Set( + loadedFolderPaths.filter((path, index) => expandedFolderQueries[index]?.isPending), + ) const loadedFolderPathIndexes = new Map(loadedFolderPaths.map((path, index) => [path, index])) - const fileReadQueryOptions = source.type === 'agent' - ? consoleQuery.agent.byAgentId.sandbox.files.read.get.queryOptions({ - input: source.conversationId && selectedWorkingDirectoryFile?.id - ? { - params: { - agent_id: source.agentId, - }, - query: { - conversation_id: source.conversationId, - path: toSandboxApiPath(selectedWorkingDirectoryFile.id), - }, - } - : skipToken, - context: { - silent: true, - }, - }) - : consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.read.get.queryOptions({ - input: source.appId && workflowNodeRunId && selectedWorkingDirectoryFile?.id - ? { - params: { - app_id: source.appId, - workflow_run_id: workflowNodeRunId, - node_id: source.nodeId, - }, - query: { - path: toSandboxApiPath(selectedWorkingDirectoryFile.id), - }, - } - : skipToken, - context: { - silent: true, - }, - }) + const fileReadQueryOptions = + source.type === 'agent' + ? consoleQuery.agent.byAgentId.sandbox.files.read.get.queryOptions({ + input: + source.conversationId && selectedWorkingDirectoryFile?.id + ? { + params: { + agent_id: source.agentId, + }, + query: { + conversation_id: source.conversationId, + path: toSandboxApiPath(selectedWorkingDirectoryFile.id), + }, + } + : skipToken, + context: { + silent: true, + }, + }) + : consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.read.get.queryOptions( + { + input: + source.appId && workflowNodeRunId && selectedWorkingDirectoryFile?.id + ? { + params: { + app_id: source.appId, + workflow_run_id: workflowNodeRunId, + node_id: source.nodeId, + }, + query: { + path: toSandboxApiPath(selectedWorkingDirectoryFile.id), + }, + } + : skipToken, + context: { + silent: true, + }, + }, + ) const fileReadQuery = useQuery({ ...fileReadQueryOptions, - enabled: open && !!selectedWorkingDirectoryFile && selectedWorkingDirectoryFile.icon !== 'image', + enabled: + open && !!selectedWorkingDirectoryFile && selectedWorkingDirectoryFile.icon !== 'image', queryFn: async (context): Promise<SandboxReadResponse> => { try { return await fileReadQueryOptions.queryFn(context) - } - catch (error) { + } catch (error) { if (isNotFoundResponse(error)) { return { binary: false, @@ -434,14 +453,20 @@ export function AgentWorkingDirectoryPanel({ }, retry: false, }) - const agentSandboxUploadMutation = useMutation(consoleQuery.agent.byAgentId.sandbox.files.upload.post.mutationOptions()) - const workflowSandboxUploadMutation = useMutation(consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.upload.post.mutationOptions()) + const agentSandboxUploadMutation = useMutation( + consoleQuery.agent.byAgentId.sandbox.files.upload.post.mutationOptions(), + ) + const workflowSandboxUploadMutation = useMutation( + consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.upload.post.mutationOptions(), + ) const { mutateAsync: uploadAgentSandboxFile } = agentSandboxUploadMutation const isImagePreviewFile = selectedWorkingDirectoryFile?.icon === 'image' const selectedWorkingDirectoryFilePath = selectedWorkingDirectoryFile?.id const { mutateAsync: uploadWorkflowSandboxFile } = workflowSandboxUploadMutation - const isFileDownloadPending = agentSandboxUploadMutation.isPending || workflowSandboxUploadMutation.isPending - const isFileReadLoading = !!selectedWorkingDirectoryFile && !isImagePreviewFile && fileReadQuery.isPending + const isFileDownloadPending = + agentSandboxUploadMutation.isPending || workflowSandboxUploadMutation.isPending + const isFileReadLoading = + !!selectedWorkingDirectoryFile && !isImagePreviewFile && fileReadQuery.isPending const imagePreviewQuery = useQuery({ queryKey: [ 'agent-v2', @@ -458,8 +483,7 @@ export function AgentWorkingDirectoryPanel({ throw new Error('Missing selected working directory file') if (source.type === 'agent') { - if (!source.conversationId) - throw new Error('Missing agent sandbox conversation ID') + if (!source.conversationId) throw new Error('Missing agent sandbox conversation ID') return consoleClient.agent.byAgentId.sandbox.files.upload.post({ params: { @@ -472,118 +496,132 @@ export function AgentWorkingDirectoryPanel({ }) } - if (!source.appId || !workflowNodeRunId) - throw new Error('Missing workflow sandbox source') + if (!source.appId || !workflowNodeRunId) throw new Error('Missing workflow sandbox source') - return consoleClient.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.upload.post({ - params: { - app_id: source.appId, - workflow_run_id: workflowNodeRunId, - node_id: source.nodeId, - }, - body: { - path: toSandboxApiPath(selectedWorkingDirectoryFilePath), + return consoleClient.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.upload.post( + { + params: { + app_id: source.appId, + workflow_run_id: workflowNodeRunId, + node_id: source.nodeId, + }, + body: { + path: toSandboxApiPath(selectedWorkingDirectoryFilePath), + }, }, - }) + ) }, - enabled: open && !!selectedWorkingDirectoryFile && isImagePreviewFile && hasWorkingDirectorySource, + enabled: + open && !!selectedWorkingDirectoryFile && isImagePreviewFile && hasWorkingDirectorySource, }) - const handleDownloadFile = useCallback(async (action: AgentSkillDetailDownloadAction) => { - if (!selectedWorkingDirectoryFile || isFileDownloadPending) - return + const handleDownloadFile = useCallback( + async (action: AgentSkillDetailDownloadAction) => { + if (!selectedWorkingDirectoryFile || isFileDownloadPending) return - if (source.type === 'agent') { - if (!source.conversationId) + if (source.type === 'agent') { + if (!source.conversationId) return + + setDownloadActionLoadingTarget(action) + try { + const result = await uploadAgentSandboxFile({ + params: { + agent_id: source.agentId, + }, + body: { + conversation_id: source.conversationId, + path: toSandboxApiPath(selectedWorkingDirectoryFile.id), + }, + }) + downloadUrl({ url: result.url, fileName: selectedWorkingDirectoryFile.name }) + toast.success(tCommon(($) => $['operation.downloadSuccess'])) + } finally { + setDownloadActionLoadingTarget(null) + } return + } + + if (!source.appId || !workflowNodeRunId) return setDownloadActionLoadingTarget(action) try { - const result = await uploadAgentSandboxFile({ + const result = await uploadWorkflowSandboxFile({ params: { - agent_id: source.agentId, + app_id: source.appId, + workflow_run_id: workflowNodeRunId, + node_id: source.nodeId, }, body: { - conversation_id: source.conversationId, path: toSandboxApiPath(selectedWorkingDirectoryFile.id), }, }) downloadUrl({ url: result.url, fileName: selectedWorkingDirectoryFile.name }) - toast.success(tCommon($ => $['operation.downloadSuccess'])) - } - finally { + toast.success(tCommon(($) => $['operation.downloadSuccess'])) + } finally { setDownloadActionLoadingTarget(null) } - return - } - - if (!source.appId || !workflowNodeRunId) - return - - setDownloadActionLoadingTarget(action) - try { - const result = await uploadWorkflowSandboxFile({ - params: { - app_id: source.appId, - workflow_run_id: workflowNodeRunId, - node_id: source.nodeId, - }, - body: { - path: toSandboxApiPath(selectedWorkingDirectoryFile.id), - }, - }) - downloadUrl({ url: result.url, fileName: selectedWorkingDirectoryFile.name }) - toast.success(tCommon($ => $['operation.downloadSuccess'])) - } - finally { - setDownloadActionLoadingTarget(null) - } - }, [isFileDownloadPending, selectedWorkingDirectoryFile, source, tCommon, uploadAgentSandboxFile, uploadWorkflowSandboxFile, workflowNodeRunId]) + }, + [ + isFileDownloadPending, + selectedWorkingDirectoryFile, + source, + tCommon, + uploadAgentSandboxFile, + uploadWorkflowSandboxFile, + workflowNodeRunId, + ], + ) return ( <Dialog open={open} onOpenChange={onOpenChange}> <AgentSkillDetailDialog - skillName={t($ => $['agentDetail.configure.workingDirectory.title'])} + skillName={t(($) => $['agentDetail.configure.workingDirectory.title'])} detail={{ - description: t($ => $['agentDetail.configure.workingDirectory.description']), + description: t(($) => $['agentDetail.configure.workingDirectory.description']), fileCount: countReadableFiles(workingDirectoryFiles), - fileListHeader: isSandboxInfoLoading - ? ( - <h3 id="agent-skill-detail-files-heading" className="px-4 pt-3.5 pb-3 system-xl-semibold text-text-primary"> - {t($ => $['agentDetail.configure.workingDirectory.fileSystem'])} + fileListHeader: isSandboxInfoLoading ? ( + <h3 + id="agent-skill-detail-files-heading" + className="px-4 pt-3.5 pb-3 system-xl-semibold text-text-primary" + > + {t(($) => $['agentDetail.configure.workingDirectory.fileSystem'])} + </h3> + ) : ( + <div className="flex shrink-0 flex-col"> + <div className="flex items-center gap-1 px-4 pt-3.5 pb-3"> + <h3 + id="agent-skill-detail-files-heading" + className="min-w-0 flex-1 system-xl-semibold text-text-primary" + > + {t(($) => $['agentDetail.configure.workingDirectory.fileSystem'])} </h3> - ) - : ( - <div className="flex shrink-0 flex-col"> - <div className="flex items-center gap-1 px-4 pt-3.5 pb-3"> - <h3 id="agent-skill-detail-files-heading" className="min-w-0 flex-1 system-xl-semibold text-text-primary"> - {t($ => $['agentDetail.configure.workingDirectory.fileSystem'])} - </h3> - {showReturnToWorkspaceButton && ( - <Tooltip> - <TooltipTrigger - aria-label={t($ => $['agentDetail.configure.workingDirectory.returnToWorkspace'])} - className="flex size-6 shrink-0 items-center justify-center rounded-md p-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" - onClick={() => handleDirectoryPathChange(workspaceDirectoryPath)} - > - <span aria-hidden className="i-ri-arrow-go-back-line size-3.5" /> - </TooltipTrigger> - <TooltipContent placement="top"> - {t($ => $['agentDetail.configure.workingDirectory.returnToWorkspace'])} - </TooltipContent> - </Tooltip> - )} - </div> - <AgentWorkingDirectoryBreadcrumb - path={directoryPath} - onPathChange={handleDirectoryPathChange} - /> - </div> - ), + {showReturnToWorkspaceButton && ( + <Tooltip> + <TooltipTrigger + aria-label={t( + ($) => $['agentDetail.configure.workingDirectory.returnToWorkspace'], + )} + className="flex size-6 shrink-0 items-center justify-center rounded-md p-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" + onClick={() => handleDirectoryPathChange(workspaceDirectoryPath)} + > + <span aria-hidden className="i-ri-arrow-go-back-line size-3.5" /> + </TooltipTrigger> + <TooltipContent placement="top"> + {t(($) => $['agentDetail.configure.workingDirectory.returnToWorkspace'])} + </TooltipContent> + </Tooltip> + )} + </div> + <AgentWorkingDirectoryBreadcrumb + path={directoryPath} + onPathChange={handleDirectoryPathChange} + /> + </div> + ), fileListLoading: isSandboxInfoLoading, fileListPanelClassName: 'w-[360px]', fileListTreeClassName: 'px-0', fileListTreeListClassName: 'px-1', - fileListTitle: t($ => $['agentDetail.configure.workingDirectory.title']), + fileListTitle: t(($) => $['agentDetail.configure.workingDirectory.title']), files: workingDirectoryFiles, filePreview: { binary: fileReadQuery.data?.binary, @@ -600,33 +638,43 @@ export function AgentWorkingDirectoryPanel({ onDownloadFile: selectedWorkingDirectoryFile ? handleDownloadFile : undefined, folderOpenState: ({ file }) => { const queryIndex = loadedFolderPathIndexes.get(file.id) - const folderLoaded = queryIndex !== undefined && expandedFolderQueries[queryIndex]?.isSuccess + const folderLoaded = + queryIndex !== undefined && expandedFolderQueries[queryIndex]?.isSuccess - return openFolderPaths.includes(file.id) - || (pendingOpenFolderPaths.includes(file.id) && !!folderLoaded) + return ( + openFolderPaths.includes(file.id) || + (pendingOpenFolderPaths.includes(file.id) && !!folderLoaded) + ) }, onFolderOpenChange: ({ file, open }) => { - if (loadingFolderPaths.has(file.id)) - return + if (loadingFolderPaths.has(file.id)) return if (open && !loadedFolderPaths.includes(file.id)) { - setLoadedFolderPaths(paths => [...paths, file.id]) - setPendingOpenFolderPaths(paths => paths.includes(file.id) ? paths : [...paths, file.id]) + setLoadedFolderPaths((paths) => [...paths, file.id]) + setPendingOpenFolderPaths((paths) => + paths.includes(file.id) ? paths : [...paths, file.id], + ) return } - setPendingOpenFolderPaths(paths => paths.filter(path => path !== file.id)) - setOpenFolderPaths(paths => open - ? (paths.includes(file.id) ? paths : [...paths, file.id]) - : paths.filter(path => path !== file.id)) + setPendingOpenFolderPaths((paths) => paths.filter((path) => path !== file.id)) + setOpenFolderPaths((paths) => + open + ? paths.includes(file.id) + ? paths + : [...paths, file.id] + : paths.filter((path) => path !== file.id), + ) }, onFolderDoubleClick: ({ file }) => handleDirectoryPathChange(toSandboxHomePath(file.id)), - onSelectFile: selectedFile => setSelectedFileId(selectedFile.id), - renderFolderSuffix: ({ file }) => loadingFolderPaths.has(file.id) - ? ( - <span aria-label={tCommon($ => $.loading)} className="ms-auto i-ri-loader-4-line size-4 shrink-0 animate-spin text-text-tertiary" /> - ) - : null, + onSelectFile: (selectedFile) => setSelectedFileId(selectedFile.id), + renderFolderSuffix: ({ file }) => + loadingFolderPaths.has(file.id) ? ( + <span + aria-label={tCommon(($) => $.loading)} + className="ms-auto i-ri-loader-4-line size-4 shrink-0 animate-spin text-text-tertiary" + /> + ) : null, selectedFileId: selectedWorkingDirectoryFile?.id, sections: [], }} diff --git a/web/features/agent-v2/agent-detail/configure/components/workspace.tsx b/web/features/agent-v2/agent-detail/configure/components/workspace.tsx index 31c69de6c4f0f4..3f2ff307532155 100644 --- a/web/features/agent-v2/agent-detail/configure/components/workspace.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/workspace.tsx @@ -6,10 +6,10 @@ import { useTranslation } from 'react-i18next' type AgentConfigureWorkspaceProps = { 'aria-busy'?: boolean - 'className'?: string - 'leftPanel': ReactNode - 'rightPanel': ReactNode - 'sidePanels'?: ReactNode + className?: string + leftPanel: ReactNode + rightPanel: ReactNode + sidePanels?: ReactNode } export function AgentConfigureWorkspace({ @@ -23,9 +23,12 @@ export function AgentConfigureWorkspace({ return ( <section - aria-label={t($ => $['agentDetail.sections.configure'])} + aria-label={t(($) => $['agentDetail.sections.configure'])} aria-busy={ariaBusy} - className={cn('flex h-full min-w-0 flex-1 gap-1 overflow-hidden bg-background-body p-1', className)} + className={cn( + 'flex h-full min-w-0 flex-1 gap-1 overflow-hidden bg-background-body p-1', + className, + )} > {leftPanel} <div className="flex min-w-105 flex-1 gap-1 overflow-hidden"> @@ -51,9 +54,7 @@ export function AgentConfigurePreviewSurface({ <div className="relative isolate flex min-w-105 flex-1 flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-linear-to-b from-background-gradient-bg-fill-chat-bg-1 to-background-gradient-bg-fill-chat-bg-2 shadow-xl shadow-shadow-shadow-5"> {background} {header} - <div className="relative z-1 min-h-0 flex-1"> - {chat} - </div> + <div className="relative z-1 min-h-0 flex-1">{chat}</div> </div> ) } diff --git a/web/features/agent-v2/agent-detail/configure/hooks.ts b/web/features/agent-v2/agent-detail/configure/hooks.ts index 785a803728d024..ea4ab4085026b6 100644 --- a/web/features/agent-v2/agent-detail/configure/hooks.ts +++ b/web/features/agent-v2/agent-detail/configure/hooks.ts @@ -1,50 +1,67 @@ 'use client' -import type { AgentConfigSnapshotDetailResponse, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentConfigSnapshotDetailResponse, + AgentSoulConfig, +} from '@dify/contracts/api/console/agent/types.gen' import { skipToken, useQuery } from '@tanstack/react-query' import { useAtom, useAtomValue } from 'jotai' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useDefaultModel, useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { + useDefaultModel, + useTextGenerationCurrentProviderAndModelAndModelList, +} from '@/app/components/header/account-setting/model-provider-page/hooks' import { agentComposerAppFeaturesAtom } from '@/features/agent-v2/agent-composer/store-modules/app-features' import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model' import { consoleQuery } from '@/service/client' export function useAgentConfigureData(agentId: string, selectedVersionId: string | null) { - const agentQuery = useQuery(consoleQuery.agent.byAgentId.get.queryOptions({ - input: { - params: { - agent_id: agentId, + const agentQuery = useQuery( + consoleQuery.agent.byAgentId.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, }, - }, - })) - const composerQuery = useQuery(consoleQuery.agent.byAgentId.composer.get.queryOptions({ - input: { - params: { - agent_id: agentId, + }), + ) + const composerQuery = useQuery( + consoleQuery.agent.byAgentId.composer.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, }, - }, - })) + }), + ) const publishedVersionId = composerQuery.data?.active_config_snapshot?.id const shouldLoadPublishedVersion = !selectedVersionId && !composerQuery.data?.agent_soul - const versionIdToLoad = selectedVersionId ?? (shouldLoadPublishedVersion ? publishedVersionId : undefined) + const versionIdToLoad = + selectedVersionId ?? (shouldLoadPublishedVersion ? publishedVersionId : undefined) const shouldLoadVersion = !!versionIdToLoad - const versionQuery = useQuery(consoleQuery.agent.byAgentId.versions.byVersionId.get.queryOptions({ - input: versionIdToLoad - ? { - params: { - agent_id: agentId, - version_id: versionIdToLoad, - }, - } - : skipToken, - })) + const versionQuery = useQuery( + consoleQuery.agent.byAgentId.versions.byVersionId.get.queryOptions({ + input: versionIdToLoad + ? { + params: { + agent_id: agentId, + version_id: versionIdToLoad, + }, + } + : skipToken, + }), + ) const versionDetail = versionQuery.data as AgentConfigSnapshotDetailResponse | undefined - const activeVersionId = selectedVersionId ?? (shouldLoadPublishedVersion ? publishedVersionId : null) - const activeConfigSnapshot = selectedVersionId ? versionDetail : (composerQuery.data?.active_config_snapshot ?? versionDetail) - const agentSoulConfig = selectedVersionId ? versionDetail?.config_snapshot : (composerQuery.data?.agent_soul ?? versionDetail?.config_snapshot) - const isPending = agentQuery.isPending - || composerQuery.isPending - || (shouldLoadVersion && versionQuery.isPending) + const activeVersionId = + selectedVersionId ?? (shouldLoadPublishedVersion ? publishedVersionId : null) + const activeConfigSnapshot = selectedVersionId + ? versionDetail + : (composerQuery.data?.active_config_snapshot ?? versionDetail) + const agentSoulConfig = selectedVersionId + ? versionDetail?.config_snapshot + : (composerQuery.data?.agent_soul ?? versionDetail?.config_snapshot) + const isPending = + agentQuery.isPending || composerQuery.isPending || (shouldLoadVersion && versionQuery.isPending) return { agentQuery, @@ -61,9 +78,7 @@ export function useAgentConfigureData(agentId: string, selectedVersionId: string export function useAgentConfigureModelOptions() { const [model, setModel] = useAtom(agentComposerModelAtom) - const { - data: defaultTextGenerationModel, - } = useDefaultModel(ModelTypeEnum.textGeneration) + const { data: defaultTextGenerationModel } = useDefaultModel(ModelTypeEnum.textGeneration) const defaultModel = defaultTextGenerationModel ? { provider: defaultTextGenerationModel.provider.provider, @@ -71,9 +86,8 @@ export function useAgentConfigureModelOptions() { } : undefined const currentModel = model ?? defaultModel - const { - textGenerationModelList, - } = useTextGenerationCurrentProviderAndModelAndModelList(currentModel) + const { textGenerationModelList } = + useTextGenerationCurrentProviderAndModelAndModelList(currentModel) return { currentModel, @@ -82,13 +96,10 @@ export function useAgentConfigureModelOptions() { } } -export function useAgentPreviewSoulConfig( - agentSoulConfig: AgentSoulConfig | undefined, -) { +export function useAgentPreviewSoulConfig(agentSoulConfig: AgentSoulConfig | undefined) { const draftAppFeatures = useAtomValue(agentComposerAppFeaturesAtom) - if (!agentSoulConfig || !draftAppFeatures) - return agentSoulConfig + if (!agentSoulConfig || !draftAppFeatures) return agentSoulConfig return { ...agentSoulConfig, diff --git a/web/features/agent-v2/agent-detail/configure/model-compatibility.ts b/web/features/agent-v2/agent-detail/configure/model-compatibility.ts index 39747570efce61..373dd760b2aeea 100644 --- a/web/features/agent-v2/agent-detail/configure/model-compatibility.ts +++ b/web/features/agent-v2/agent-detail/configure/model-compatibility.ts @@ -1,4 +1,7 @@ -import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + Model, + ModelItem, +} from '@/app/components/header/account-setting/model-provider-page/declarations' const agentIncompatibleModelPatterns: RegExp[] = [ // openai @@ -91,9 +94,9 @@ const agentSuggestedModelPatterns: RegExp[] = [ ] export function isAgentCompatibleModel(_provider: Model, modelItem: ModelItem) { - return !agentIncompatibleModelPatterns.some(pattern => pattern.test(modelItem.label.en_US)) + return !agentIncompatibleModelPatterns.some((pattern) => pattern.test(modelItem.label.en_US)) } export function isAgentSuggestedModel(_provider: Model, modelItem: ModelItem) { - return agentSuggestedModelPatterns.some(pattern => pattern.test(modelItem.label.en_US)) + return agentSuggestedModelPatterns.some((pattern) => pattern.test(modelItem.label.en_US)) } diff --git a/web/features/agent-v2/agent-detail/configure/page.tsx b/web/features/agent-v2/agent-detail/configure/page.tsx index 9229302e62fc33..0e481c1105a1e5 100644 --- a/web/features/agent-v2/agent-detail/configure/page.tsx +++ b/web/features/agent-v2/agent-detail/configure/page.tsx @@ -18,23 +18,15 @@ type AgentConfigurePageProps = { agentId: string } -export function AgentConfigurePage({ - agentId, -}: AgentConfigurePageProps) { +export function AgentConfigurePage({ agentId }: AgentConfigurePageProps) { return ( - <ScopeProvider - key={agentId} - atoms={agentConfigureScopedAtoms} - name="AgentConfigure" - > + <ScopeProvider key={agentId} atoms={agentConfigureScopedAtoms} name="AgentConfigure"> <AgentConfigurePageContent agentId={agentId} /> </ScopeProvider> ) } -function AgentConfigurePageContent({ - agentId, -}: AgentConfigurePageProps) { +function AgentConfigurePageContent({ agentId }: AgentConfigurePageProps) { const { t } = useTranslation('agentV2') const selectedVersionId = useAtomValue(agentConfigureSelectedVersionIdAtom) const composerRebaseRevision = useAtomValue(agentConfigureComposerRebaseRevisionAtom) @@ -43,9 +35,7 @@ function AgentConfigurePageContent({ const configureData = useAgentConfigureData(agentId, selectedVersionId) if (configureData.isPending) { - return ( - <AgentConfigurePageLoading label={t($ => $['agentDetail.sections.configure'])} /> - ) + return <AgentConfigurePageLoading label={t(($) => $['agentDetail.sections.configure'])} /> } return ( diff --git a/web/features/agent-v2/agent-detail/configure/state.ts b/web/features/agent-v2/agent-detail/configure/state.ts index 610a86b74d776b..8cb71df2b41f94 100644 --- a/web/features/agent-v2/agent-detail/configure/state.ts +++ b/web/features/agent-v2/agent-detail/configure/state.ts @@ -31,25 +31,35 @@ export const rebaseAgentConfigureComposerAtom = atom(null, (get, set) => { set(agentConfigureComposerRebaseRevisionAtom, get(agentConfigureComposerRebaseRevisionAtom) + 1) }) -export const setAgentConfigureConversationIdAtom = atom(null, (get, set, { - mode, - conversationId, -}: { - mode: AgentConfigureRightPanelMode - conversationId: string | null -}) => { - set(agentConfigureConversationIdsAtom, { - ...get(agentConfigureConversationIdsAtom), - [mode]: conversationId, - }) -}) +export const setAgentConfigureConversationIdAtom = atom( + null, + ( + get, + set, + { + mode, + conversationId, + }: { + mode: AgentConfigureRightPanelMode + conversationId: string | null + }, + ) => { + set(agentConfigureConversationIdsAtom, { + ...get(agentConfigureConversationIdsAtom), + [mode]: conversationId, + }) + }, +) -export const resetAgentConfigureConversationAtom = atom(null, (get, set, mode: AgentConfigureRightPanelMode) => { - set(agentConfigureConversationIdsAtom, { - ...get(agentConfigureConversationIdsAtom), - [mode]: null, - }) -}) +export const resetAgentConfigureConversationAtom = atom( + null, + (get, set, mode: AgentConfigureRightPanelMode) => { + set(agentConfigureConversationIdsAtom, { + ...get(agentConfigureConversationIdsAtom), + [mode]: null, + }) + }, +) export const agentConfigureScopedAtoms = [ agentConfigureSelectedVersionIdAtom, diff --git a/web/features/agent-v2/agent-detail/configure/use-agent-build-draft-run.ts b/web/features/agent-v2/agent-detail/configure/use-agent-build-draft-run.ts index f624ac30c58a79..2086daab4c5f87 100644 --- a/web/features/agent-v2/agent-detail/configure/use-agent-build-draft-run.ts +++ b/web/features/agent-v2/agent-detail/configure/use-agent-build-draft-run.ts @@ -29,15 +29,16 @@ export function usePrepareAgentBuildDraftBeforeRun({ }, }, }) - const checkoutBuildDraftMutation = useMutation(consoleQuery.agent.byAgentId.buildDraft.checkout.post.mutationOptions()) - const { mutateAsync: checkoutBuildDraft, isPending: isCheckingOutBuildDraft } = checkoutBuildDraftMutation + const checkoutBuildDraftMutation = useMutation( + consoleQuery.agent.byAgentId.buildDraft.checkout.post.mutationOptions(), + ) + const { mutateAsync: checkoutBuildDraft, isPending: isCheckingOutBuildDraft } = + checkoutBuildDraftMutation const prepareBuildDraftBeforeRun = useCallback(async () => { - if (!agentId) - return + if (!agentId) return - if (isBuildDraftActive) - return buildDraftAgentSoulConfig + if (isBuildDraftActive) return buildDraftAgentSoulConfig await saveDraft() @@ -53,7 +54,17 @@ export function usePrepareAgentBuildDraftBeforeRun({ rebaseComposerDraft?.(buildDraft.agent_soul as AgentSoulConfig | undefined) setSoulSourceOverride?.('build-draft') return buildDraft.agent_soul as AgentSoulConfig | undefined - }, [agentId, buildDraftAgentSoulConfig, buildDraftQueryOptions.queryKey, checkoutBuildDraft, isBuildDraftActive, queryClient, rebaseComposerDraft, saveDraft, setSoulSourceOverride]) + }, [ + agentId, + buildDraftAgentSoulConfig, + buildDraftQueryOptions.queryKey, + checkoutBuildDraft, + isBuildDraftActive, + queryClient, + rebaseComposerDraft, + saveDraft, + setSoulSourceOverride, + ]) return { isCheckingOutBuildDraft, diff --git a/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts b/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts index d4d6164dc21591..cdce96fd5bec27 100644 --- a/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts +++ b/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts @@ -1,9 +1,16 @@ 'use client' import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' -import type { AgentBuildDraftChangedKey, AgentBuildDraftChangeItem, AgentBuildDraftChangeSummary } from './components/orchestrate/build-draft-changes-context' +import type { + AgentBuildDraftChangedKey, + AgentBuildDraftChangeItem, + AgentBuildDraftChangeSummary, +} from './components/orchestrate/build-draft-changes-context' import type { AgentConfigureSoulSource } from './state' -import type { AgentFileNode, AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' +import type { + AgentFileNode, + AgentSoulConfigFormState, +} from '@/features/agent-v2/agent-composer/form-state' import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import isEqual from 'fast-deep-equal' @@ -20,9 +27,10 @@ const BUILD_NOTE_FILE_NAME = 'build_note.md' const getAgentSoulConfigFromRefetchResult = (result: unknown) => { return (result as { data?: { agent_soul?: AgentSoulConfig } } | undefined)?.data?.agent_soul } -const flattenFileNodes = (files: AgentFileNode[]): AgentFileNode[] => files.flatMap(file => ( - file.children?.length ? [file, ...flattenFileNodes(file.children)] : [file] -)) +const flattenFileNodes = (files: AgentFileNode[]): AgentFileNode[] => + files.flatMap((file) => + file.children?.length ? [file, ...flattenFileNodes(file.children)] : [file], + ) function getItemDiff<TItem>({ currentItems, @@ -37,8 +45,8 @@ function getItemDiff<TItem>({ getKey: (item: TItem) => string getName: (item: TItem) => string }): AgentBuildDraftChangeItem[] { - const currentByKey = new Map(currentItems.map(item => [getKey(item), item])) - const nextByKey = new Map(nextItems.map(item => [getKey(item), item])) + const currentByKey = new Map(currentItems.map((item) => [getKey(item), item])) + const nextByKey = new Map(nextItems.map((item) => [getKey(item), item])) const changes: AgentBuildDraftChangeItem[] = [] for (const item of nextItems) { @@ -66,8 +74,7 @@ function getItemDiff<TItem>({ for (const item of currentItems) { const key = getKey(item) - if (nextByKey.has(key)) - continue + if (nextByKey.has(key)) continue changes.push({ id: key, @@ -103,22 +110,22 @@ function getAgentBuildDraftChangeSummary({ ...getItemDiff({ currentItems: flattenFileNodes(normalDraft.files), nextItems: flattenFileNodes(buildDraft.files), - getIcon: file => file.icon, - getKey: file => file.configName ?? file.id ?? file.name, - getName: file => file.name, + getIcon: (file) => file.icon, + getKey: (file) => file.configName ?? file.id ?? file.name, + getName: (file) => file.name, }), ] const skillChanges = getItemDiff({ currentItems: normalDraft.skills, nextItems: buildDraft.skills, - getKey: skill => skill.id || skill.name, - getName: skill => skill.name, + getKey: (skill) => skill.id || skill.name, + getName: (skill) => skill.name, }) const envVariableChanges = getItemDiff({ currentItems: normalDraft.envVariables, nextItems: buildDraft.envVariables, - getKey: variable => variable.id || variable.key, - getName: variable => variable.key, + getKey: (variable) => variable.id || variable.key, + getName: (variable) => variable.key, }) return { @@ -170,7 +177,8 @@ export function useAgentConfigureBuildDraftData({ }) const buildDraftQuery = useQuery({ ...buildDraftQueryOptions, - enabled: !isViewingVersion && soulSourceOverride !== 'draft' && soulSourceOverride !== 'view-version', + enabled: + !isViewingVersion && soulSourceOverride !== 'draft' && soulSourceOverride !== 'view-version', queryFn: async (context) => { try { const queryOptions = shouldSilenceBuildDraftCheckRef.current @@ -179,10 +187,8 @@ export function useAgentConfigureBuildDraftData({ shouldSilenceBuildDraftCheckRef.current = false return await queryOptions.queryFn(context) - } - catch (error) { - if (isNotFoundResponse(error)) - setSoulSourceOverride('draft') + } catch (error) { + if (isNotFoundResponse(error)) setSoulSourceOverride('draft') throw error } }, @@ -201,10 +207,13 @@ export function useAgentConfigureBuildDraftData({ const buildDraftNotFound = isNotFoundResponse(buildDraftError) const soulSource: AgentConfigureSoulSource = isViewingVersion ? 'view-version' - : soulSourceOverride ?? (!buildDraftNotFound && !!buildDraftData && !isBuildDraftError ? 'build-draft' : 'draft') + : (soulSourceOverride ?? + (!buildDraftNotFound && !!buildDraftData && !isBuildDraftError ? 'build-draft' : 'draft')) const isBuildDraftActive = soulSource === 'build-draft' const buildDraftAgentSoulConfig = buildDraftData?.agent_soul as AgentSoulConfig | undefined - const visibleAgentSoulConfig = isBuildDraftActive ? buildDraftAgentSoulConfig : normalAgentSoulConfig + const visibleAgentSoulConfig = isBuildDraftActive + ? buildDraftAgentSoulConfig + : normalAgentSoulConfig const buildDraftChangeSummary = useMemo<AgentBuildDraftChangeSummary>(() => { if (!buildDraftAgentSoulConfig || !composerAgentSoulConfig) { return { @@ -218,8 +227,9 @@ export function useAgentConfigureBuildDraftData({ const normalDraft = agentSoulConfigToFormState(composerAgentSoulConfig) const buildDraft = agentSoulConfigToFormState(buildDraftAgentSoulConfig) - const changedKeys = (Object.keys(buildDraft) as Array<keyof typeof buildDraft>) - .filter(key => !isEqual(buildDraft[key], normalDraft[key])) + const changedKeys = (Object.keys(buildDraft) as Array<keyof typeof buildDraft>).filter( + (key) => !isEqual(buildDraft[key], normalDraft[key]), + ) return getAgentBuildDraftChangeSummary({ buildDraft, @@ -230,13 +240,19 @@ export function useAgentConfigureBuildDraftData({ }, [buildDraftAgentSoulConfig, composerAgentSoulConfig]) return { - activeVersionId: isBuildDraftActive ? `build-draft:${buildDraftDataUpdatedAt}` : activeVersionId, + activeVersionId: isBuildDraftActive + ? `build-draft:${buildDraftDataUpdatedAt}` + : activeVersionId, agentSoulConfig: visibleAgentSoulConfig, changedKeys: buildDraftChangeSummary.changedKeys, changeSummary: buildDraftChangeSummary, changesCount: buildDraftChangeSummary.changesCount, isActive: isBuildDraftActive, - isPending: !isViewingVersion && soulSourceOverride !== 'draft' && soulSourceOverride !== 'view-version' && isBuildDraftPending, + isPending: + !isViewingVersion && + soulSourceOverride !== 'draft' && + soulSourceOverride !== 'view-version' && + isBuildDraftPending, refetch: refetchBuildDraft, setSoulSourceOverride, soulSource, @@ -279,13 +295,24 @@ export function useAgentConfigureBuildDraftActions({ }, }, }) - const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }) - const finalizeBuildChatMutation = useMutation(consoleQuery.agent.byAgentId.buildChat.finalize.post.mutationOptions()) - const applyBuildDraftMutation = useMutation(consoleQuery.agent.byAgentId.buildDraft.apply.post.mutationOptions()) - const discardBuildDraftMutation = useMutation(consoleQuery.agent.byAgentId.buildDraft.delete.mutationOptions()) - const { mutateAsync: finalizeBuildChatRequest, isPending: isFinalizingBuildChat } = finalizeBuildChatMutation - const { mutateAsync: applyBuildDraftRequest, isPending: isApplyingBuildDraft } = applyBuildDraftMutation - const { mutateAsync: discardBuildDraftRequest, isPending: isDiscardingBuildDraft } = discardBuildDraftMutation + const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ + input: { params: { agent_id: agentId } }, + }) + const finalizeBuildChatMutation = useMutation( + consoleQuery.agent.byAgentId.buildChat.finalize.post.mutationOptions(), + ) + const applyBuildDraftMutation = useMutation( + consoleQuery.agent.byAgentId.buildDraft.apply.post.mutationOptions(), + ) + const discardBuildDraftMutation = useMutation( + consoleQuery.agent.byAgentId.buildDraft.delete.mutationOptions(), + ) + const { mutateAsync: finalizeBuildChatRequest, isPending: isFinalizingBuildChat } = + finalizeBuildChatMutation + const { mutateAsync: applyBuildDraftRequest, isPending: isApplyingBuildDraft } = + applyBuildDraftMutation + const { mutateAsync: discardBuildDraftRequest, isPending: isDiscardingBuildDraft } = + discardBuildDraftMutation const { prepareBuildDraftBeforeRun } = usePrepareAgentBuildDraftBeforeRun({ agentId, buildDraftAgentSoulConfig, @@ -297,8 +324,7 @@ export function useAgentConfigureBuildDraftActions({ const cancelBuildDraftRefresh = useCallback(() => { buildDraftRefreshGenerationRef.current += 1 - if (!buildDraftRefreshTimerRef.current) - return + if (!buildDraftRefreshTimerRef.current) return clearTimeout(buildDraftRefreshTimerRef.current) buildDraftRefreshTimerRef.current = null @@ -309,45 +335,56 @@ export function useAgentConfigureBuildDraftActions({ return prepareBuildDraftBeforeRun() }, [cancelBuildDraftRefresh, prepareBuildDraftBeforeRun]) - const refreshBuildDraftAfterBuildChat = useCallback((onRefreshed?: () => void) => { - cancelBuildDraftRefresh() - const refreshGeneration = buildDraftRefreshGenerationRef.current + const refreshBuildDraftAfterBuildChat = useCallback( + (onRefreshed?: () => void) => { + cancelBuildDraftRefresh() + const refreshGeneration = buildDraftRefreshGenerationRef.current - buildDraftRefreshTimerRef.current = setTimeout(async () => { - buildDraftRefreshTimerRef.current = null - try { - const result = await refetchBuildDraft() - if (refreshGeneration !== buildDraftRefreshGenerationRef.current) - return + buildDraftRefreshTimerRef.current = setTimeout(async () => { + buildDraftRefreshTimerRef.current = null + try { + const result = await refetchBuildDraft() + if (refreshGeneration !== buildDraftRefreshGenerationRef.current) return - const agentSoulConfig = getAgentSoulConfigFromRefetchResult(result) - if (agentSoulConfig) - rebaseComposerDraft(agentSoulConfig) - } - catch {} - finally { - if (refreshGeneration === buildDraftRefreshGenerationRef.current) - onRefreshed?.() - } - }, 1000) - }, [cancelBuildDraftRefresh, rebaseComposerDraft, refetchBuildDraft]) + const agentSoulConfig = getAgentSoulConfigFromRefetchResult(result) + if (agentSoulConfig) rebaseComposerDraft(agentSoulConfig) + } catch { + } finally { + if (refreshGeneration === buildDraftRefreshGenerationRef.current) onRefreshed?.() + } + }, 1000) + }, + [cancelBuildDraftRefresh, rebaseComposerDraft, refetchBuildDraft], + ) - const exitBuildDraftMode = useCallback(async (shouldRefetchComposer: boolean) => { - cancelBuildDraftRefresh() - await resetBuildChatSession().catch(() => undefined) - setSoulSourceOverride('draft') - queryClient.removeQueries({ - queryKey: buildDraftQueryOptions.queryKey, - }) - if (shouldRefetchComposer) { - const result = await refetchComposer() - rebaseComposerDraft(getAgentSoulConfigFromRefetchResult(result) ?? normalAgentSoulConfig) - onComposerRebased?.() - } - else { - rebaseComposerDraft(normalAgentSoulConfig) - } - }, [buildDraftQueryOptions.queryKey, cancelBuildDraftRefresh, normalAgentSoulConfig, onComposerRebased, queryClient, rebaseComposerDraft, refetchComposer, resetBuildChatSession, setSoulSourceOverride]) + const exitBuildDraftMode = useCallback( + async (shouldRefetchComposer: boolean) => { + cancelBuildDraftRefresh() + await resetBuildChatSession().catch(() => undefined) + setSoulSourceOverride('draft') + queryClient.removeQueries({ + queryKey: buildDraftQueryOptions.queryKey, + }) + if (shouldRefetchComposer) { + const result = await refetchComposer() + rebaseComposerDraft(getAgentSoulConfigFromRefetchResult(result) ?? normalAgentSoulConfig) + onComposerRebased?.() + } else { + rebaseComposerDraft(normalAgentSoulConfig) + } + }, + [ + buildDraftQueryOptions.queryKey, + cancelBuildDraftRefresh, + normalAgentSoulConfig, + onComposerRebased, + queryClient, + rebaseComposerDraft, + refetchComposer, + resetBuildChatSession, + setSoulSourceOverride, + ], + ) const applyBuildDraft = async () => { try { @@ -368,10 +405,9 @@ export function useAgentConfigureBuildDraftActions({ queryKey: consoleQuery.agent.get.key(), }) await exitBuildDraftMode(true) - toast.success(tCommon($ => $['api.actionSuccess'])) - } - catch { - toast.error(tCommon($ => $['api.actionFailed'])) + toast.success(tCommon(($) => $['api.actionSuccess'])) + } catch { + toast.error(tCommon(($) => $['api.actionFailed'])) } } @@ -383,10 +419,9 @@ export function useAgentConfigureBuildDraftActions({ }, }) await exitBuildDraftMode(false) - toast.success(tCommon($ => $['api.actionSuccess'])) - } - catch { - toast.error(tCommon($ => $['api.actionFailed'])) + toast.success(tCommon(($) => $['api.actionSuccess'])) + } catch { + toast.error(tCommon(($) => $['api.actionFailed'])) } } diff --git a/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts b/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts index 4b034ec71abb33..e0c09e0d0a13dd 100644 --- a/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts +++ b/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts @@ -12,7 +12,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' import { formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions' -import { useKnowledgeValidationMessage, validateKnowledgeRetrievals } from '@/features/agent-v2/agent-composer/knowledge-validation' +import { + useKnowledgeValidationMessage, + validateKnowledgeRetrievals, +} from '@/features/agent-v2/agent-composer/knowledge-validation' import { agentComposerDraftAtom, agentComposerOriginalConfigAtom, @@ -55,69 +58,73 @@ export function useAgentConfigureSync({ currentModelRef.current = currentModel enabledRef.current = enabled - const getAgentSoulDraft = useCallback(() => formStateToAgentSoulConfig({ - baseConfig: baseConfigRef.current, - formState: store.get(agentComposerDraftAtom), - currentModel: currentModelRef.current, - }), [store]) + const getAgentSoulDraft = useCallback( + () => + formStateToAgentSoulConfig({ + baseConfig: baseConfigRef.current, + formState: store.get(agentComposerDraftAtom), + currentModel: currentModelRef.current, + }), + [store], + ) - const { - mutateAsync: saveComposerDraft, - } = useMutation( + const { mutateAsync: saveComposerDraft } = useMutation( consoleQuery.agent.byAgentId.composer.put.mutationOptions(), ) - const { - isPending: isPublishingAgent, - mutateAsync: publishAgent, - } = useMutation( + const { isPending: isPublishingAgent, mutateAsync: publishAgent } = useMutation( consoleQuery.agent.byAgentId.publish.post.mutationOptions(), ) - const saveComposer = useSerialAsyncCallback(async ({ - configSnapshot, - draftBaseline, - silent = true, - }: { - configSnapshot: AgentSoulConfig - draftBaseline: AgentSoulConfigFormState - silent?: boolean - }) => { - const savedDraftKey = JSON.stringify(configSnapshot) - const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }) - try { - const composerState = await saveComposerDraft({ - params: { - agent_id: agentId, - }, - body: { - variant: 'agent_app', - save_strategy: 'save_to_current_version', - agent_soul: configSnapshot, - }, - }) - queryClient.setQueryData( - consoleQuery.agent.byAgentId.composer.get.queryKey({ input: { params: { agent_id: agentId } } }), - composerState, - ) - await queryClient.invalidateQueries({ - queryKey: agentDetailQueryKey, + const saveComposer = useSerialAsyncCallback( + async ({ + configSnapshot, + draftBaseline, + silent = true, + }: { + configSnapshot: AgentSoulConfig + draftBaseline: AgentSoulConfigFormState + silent?: boolean + }) => { + const savedDraftKey = JSON.stringify(configSnapshot) + const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ + input: { params: { agent_id: agentId } }, }) - } - catch { - // Autosave is silent and keeps the local draft intact; explicit commands must stop at this boundary. - if (!silent) { - toast.error(tCommon($ => $['api.actionFailed'])) - throw new Error('Failed to save agent composer draft.') + try { + const composerState = await saveComposerDraft({ + params: { + agent_id: agentId, + }, + body: { + variant: 'agent_app', + save_strategy: 'save_to_current_version', + agent_soul: configSnapshot, + }, + }) + queryClient.setQueryData( + consoleQuery.agent.byAgentId.composer.get.queryKey({ + input: { params: { agent_id: agentId } }, + }), + composerState, + ) + await queryClient.invalidateQueries({ + queryKey: agentDetailQueryKey, + }) + } catch { + // Autosave is silent and keeps the local draft intact; explicit commands must stop at this boundary. + if (!silent) { + toast.error(tCommon(($) => $['api.actionFailed'])) + throw new Error('Failed to save agent composer draft.') + } + + return false } - return false - } - - setOriginalDraft(draftBaseline) - setDraftSavedAt(Date.now()) - lastAutosavedDraftKeyRef.current = savedDraftKey - return true - }) + setOriginalDraft(draftBaseline) + setDraftSavedAt(Date.now()) + lastAutosavedDraftKeyRef.current = savedDraftKey + return true + }, + ) const latestDraftSaveRef = useRef<() => void>(() => undefined) latestDraftSaveRef.current = () => { @@ -129,20 +136,22 @@ export function useAgentConfigureSync({ }) } - const debouncedSaveDraft = useMemo(() => debounce(() => { - latestDraftSaveRef.current() - }, DRAFT_AUTOSAVE_WAIT), []) + const debouncedSaveDraft = useMemo( + () => + debounce(() => { + latestDraftSaveRef.current() + }, DRAFT_AUTOSAVE_WAIT), + [], + ) const saveDraft = useCallback(async () => { - if (!enabledRef.current) - return + if (!enabledRef.current) return const draft = store.get(agentComposerDraftAtom) const configSnapshot = getAgentSoulDraft() const hasEffectiveModelChange = !isEqual(configSnapshot.model, baseConfigRef.current?.model) debouncedSaveDraft.cancel?.() - if (!store.get(isAgentComposerDirtyAtom) && !hasEffectiveModelChange) - return + if (!store.get(isAgentComposerDirtyAtom) && !hasEffectiveModelChange) return await saveComposer({ configSnapshot, @@ -157,17 +166,15 @@ export function useAgentConfigureSync({ } const draft = store.get(agentComposerDraftAtom) - if ( - !store.get(isAgentComposerDirtyAtom) - ) { + if (!store.get(isAgentComposerDirtyAtom)) { return } const configSnapshot = getAgentSoulDraft() const draftKey = JSON.stringify(configSnapshot) if ( - lastAutosavedDraftKeyRef.current === draftKey - || pageCloseSavingDraftKeyRef.current === draftKey + lastAutosavedDraftKeyRef.current === draftKey || + pageCloseSavingDraftKeyRef.current === draftKey ) { return } @@ -189,18 +196,12 @@ export function useAgentConfigureSync({ const agentSoulDraftKey = JSON.stringify(agentSoulDraft) const isDirty = store.get(isAgentComposerDirtyAtom) - if ( - !enabledRef.current - || !isDirty - ) { - if (!isDirty) - debouncedSaveDraft.cancel?.() + if (!enabledRef.current || !isDirty) { + if (!isDirty) debouncedSaveDraft.cancel?.() return } - if ( - lastAutosavedDraftKeyRef.current === agentSoulDraftKey - ) { + if (lastAutosavedDraftKeyRef.current === agentSoulDraftKey) { return } @@ -210,8 +211,7 @@ export function useAgentConfigureSync({ useEffect(() => { const saveDraftWhenPageHidden = () => { - if (document.visibilityState === 'hidden') - saveDirtyDraftOnPageClose() + if (document.visibilityState === 'hidden') saveDirtyDraftOnPageClose() } const saveDraftBeforeUnload = () => { saveDirtyDraftOnPageClose() @@ -233,8 +233,7 @@ export function useAgentConfigureSync({ }, [saveDirtyDraftOnPageClose]) const publishDraft = useCallback(async () => { - if (publishInFlightRef.current) - return + if (publishInFlightRef.current) return const draft = store.get(agentComposerDraftAtom) const configSnapshot = formStateToAgentSoulConfig({ @@ -243,13 +242,16 @@ export function useAgentConfigureSync({ currentModel: currentModelRef.current, }) if (!configSnapshot.model?.model_provider || !configSnapshot.model.model) { - toast.error(tCommon($ => $['modelProvider.selectModel'])) + toast.error(tCommon(($) => $['modelProvider.selectModel'])) return } const knowledgeValidation = validateKnowledgeRetrievals(draft.knowledgeRetrievals) if (!knowledgeValidation.isValid) { - toast.error(getKnowledgeValidationMessage(knowledgeValidation.firstIssue?.code) ?? tCommon($ => $['api.actionFailed'])) + toast.error( + getKnowledgeValidationMessage(knowledgeValidation.firstIssue?.code) ?? + tCommon(($) => $['api.actionFailed']), + ) return } @@ -262,8 +264,7 @@ export function useAgentConfigureSync({ draftBaseline: draft, silent: false, }) - if (!saved) - return + if (!saved) return await publishAgent({ params: { @@ -274,8 +275,7 @@ export function useAgentConfigureSync({ queryClient.setQueryData( consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }), (agentDetail) => { - if (!agentDetail) - return agentDetail + if (!agentDetail) return agentDetail return { ...agentDetail, @@ -284,7 +284,9 @@ export function useAgentConfigureSync({ }, ) void queryClient.invalidateQueries({ - queryKey: consoleQuery.agent.byAgentId.composer.get.queryKey({ input: { params: { agent_id: agentId } } }), + queryKey: consoleQuery.agent.byAgentId.composer.get.queryKey({ + input: { params: { agent_id: agentId } }, + }), }) void queryClient.invalidateQueries({ queryKey: consoleQuery.agent.byAgentId.versions.get.key(), @@ -293,13 +295,24 @@ export function useAgentConfigureSync({ const publishedDraft = draft setOriginalDraft(publishedDraft) setPublishedDraft(publishedDraft) - toast.success(tCommon($ => $['api.actionSuccess'])) - } - finally { + toast.success(tCommon(($) => $['api.actionSuccess'])) + } finally { publishInFlightRef.current = false setIsPublishInFlight(false) } - }, [agentId, debouncedSaveDraft, getKnowledgeValidationMessage, publishAgent, queryClient, saveComposer, setOriginalConfig, setOriginalDraft, setPublishedDraft, store, tCommon]) + }, [ + agentId, + debouncedSaveDraft, + getKnowledgeValidationMessage, + publishAgent, + queryClient, + saveComposer, + setOriginalConfig, + setOriginalDraft, + setPublishedDraft, + store, + tCommon, + ]) return { draftSavedAt, diff --git a/web/features/agent-v2/agent-detail/detail-sidebar.tsx b/web/features/agent-v2/agent-detail/detail-sidebar.tsx index 4e67f0b4a34dba..e8503441223d3c 100644 --- a/web/features/agent-v2/agent-detail/detail-sidebar.tsx +++ b/web/features/agent-v2/agent-detail/detail-sidebar.tsx @@ -6,12 +6,7 @@ import { AgentDetailSection, AgentDetailTop } from './navigation' export function AgentDetailSidebar() { return ( <DetailSidebarFrame - renderTop={({ expand, onToggle }) => ( - <AgentDetailTop - expand={expand} - onToggle={onToggle} - /> - )} + renderTop={({ expand, onToggle }) => <AgentDetailTop expand={expand} onToggle={onToggle} />} renderSection={({ expand }) => <AgentDetailSection expand={expand} />} /> ) diff --git a/web/features/agent-v2/agent-detail/layout.tsx b/web/features/agent-v2/agent-detail/layout.tsx index 283b4802b559b7..a4fd57b0a4f752 100644 --- a/web/features/agent-v2/agent-detail/layout.tsx +++ b/web/features/agent-v2/agent-detail/layout.tsx @@ -15,36 +15,31 @@ type AgentDetailLayoutProps = { const isNotFoundResponse = (error: unknown) => error instanceof Response && error.status === 404 -export function AgentDetailLayout({ - agentId, - children, -}: AgentDetailLayoutProps) { +export function AgentDetailLayout({ agentId, children }: AgentDetailLayoutProps) { const { t } = useTranslation('agentV2') const router = useRouter() - const agentQuery = useQuery(consoleQuery.agent.byAgentId.get.queryOptions({ - input: { - params: { - agent_id: agentId, + const agentQuery = useQuery( + consoleQuery.agent.byAgentId.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, }, - }, - })) + }), + ) const shouldRedirectToRoster = isNotFoundResponse(agentQuery.error) - useDocumentTitle(agentQuery.data?.name ?? t($ => $['agentDetail.documentTitle'])) + useDocumentTitle(agentQuery.data?.name ?? t(($) => $['agentDetail.documentTitle'])) useEffect(() => { - if (shouldRedirectToRoster) - router.replace('/agents') + if (shouldRedirectToRoster) router.replace('/agents') }, [router, shouldRedirectToRoster]) - if (shouldRedirectToRoster) - return null + if (shouldRedirectToRoster) return null return ( <div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> - <div className="min-h-0 min-w-0 flex-1 overflow-auto"> - {children} - </div> + <div className="min-h-0 min-w-0 flex-1 overflow-auto">{children}</div> </div> ) } diff --git a/web/features/agent-v2/agent-detail/logs/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/logs/__tests__/page.spec.tsx index 21f55d0abe5f8c..81da7e84efc304 100644 --- a/web/features/agent-v2/agent-detail/logs/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/logs/__tests__/page.spec.tsx @@ -1,4 +1,8 @@ -import type { AgentLogListResponse, AgentLogMessageListResponse, AgentLogSourceListResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentLogListResponse, + AgentLogMessageListResponse, + AgentLogSourceListResponse, +} from '@dify/contracts/api/console/agent/types.gen' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -190,8 +194,7 @@ const renderPage = () => { const getLatestLogsQueryInput = () => { const latestCall = mocks.logsQueryOptions.mock.calls.at(-1) - if (!latestCall) - throw new Error('Expected logs query options to be called') + if (!latestCall) throw new Error('Expected logs query options to be called') return latestCall[0] } @@ -235,17 +238,23 @@ describe('AgentLogsPage', () => { renderPage() - await user.click(await screen.findByRole('combobox', { name: 'agentV2.agentDetail.logs.filters.source.label' })) + await user.click( + await screen.findByRole('combobox', { + name: 'agentV2.agentDetail.logs.filters.source.label', + }), + ) await user.click(await screen.findByRole('option', { name: /Book Translation/ })) await user.click(await screen.findByRole('option', { name: /SVG Logo Design/ })) await waitFor(() => { - expect(getLatestLogsQueryInput().input.query).toEqual(expect.objectContaining({ - sources: [ - 'webapp:webapp-app-id', - 'workflow:workflow-app-id:workflow-id:v3:agent-node-id', - ], - })) + expect(getLatestLogsQueryInput().input.query).toEqual( + expect.objectContaining({ + sources: [ + 'webapp:webapp-app-id', + 'workflow:workflow-app-id:workflow-id:v3:agent-node-id', + ], + }), + ) }) expect(getLatestLogsQueryInput().input.query).not.toHaveProperty('source') @@ -257,22 +266,30 @@ describe('AgentLogsPage', () => { renderPage() await user.click(screen.getByRole('button', { name: /appLog\.filter\.sortBy/ })) - await user.click(await screen.findByRole('menuitemradio', { name: 'agentV2.agentDetail.logs.filters.sort.lastUpdatedTime' })) + await user.click( + await screen.findByRole('menuitemradio', { + name: 'agentV2.agentDetail.logs.filters.sort.lastUpdatedTime', + }), + ) await waitFor(() => { - expect(getLatestLogsQueryInput().input.query).toEqual(expect.objectContaining({ - sort_by: 'updated_at', - sort_order: 'desc', - })) + expect(getLatestLogsQueryInput().input.query).toEqual( + expect.objectContaining({ + sort_by: 'updated_at', + sort_order: 'desc', + }), + ) }) await user.click(screen.getByRole('button', { name: 'appLog.filter.ascending' })) await waitFor(() => { - expect(getLatestLogsQueryInput().input.query).toEqual(expect.objectContaining({ - sort_by: 'updated_at', - sort_order: 'asc', - })) + expect(getLatestLogsQueryInput().input.query).toEqual( + expect.objectContaining({ + sort_by: 'updated_at', + sort_order: 'asc', + }), + ) }) }) @@ -290,13 +307,19 @@ describe('AgentLogsPage', () => { expect(await screen.findByText('Previous conversation')).toBeInTheDocument() - await user.click(await screen.findByRole('combobox', { name: 'agentV2.agentDetail.logs.filters.source.label' })) + await user.click( + await screen.findByRole('combobox', { + name: 'agentV2.agentDetail.logs.filters.source.label', + }), + ) await user.click(await screen.findByRole('option', { name: /Book Translation/ })) await waitFor(() => { - expect(getLatestLogsQueryInput().input.query).toEqual(expect.objectContaining({ - sources: ['webapp:webapp-app-id'], - })) + expect(getLatestLogsQueryInput().input.query).toEqual( + expect.objectContaining({ + sources: ['webapp:webapp-app-id'], + }), + ) expect(mocks.logsQueryFn).toHaveBeenCalledTimes(2) }) diff --git a/web/features/agent-v2/agent-detail/logs/components/log-detail-panel.tsx b/web/features/agent-v2/agent-detail/logs/components/log-detail-panel.tsx index 2e05c6a7cbead7..0d3dbb2db1e53b 100644 --- a/web/features/agent-v2/agent-detail/logs/components/log-detail-panel.tsx +++ b/web/features/agent-v2/agent-detail/logs/components/log-detail-panel.tsx @@ -1,4 +1,7 @@ -import type { AgentLogConversationItemResponse, AgentLogMessageItemResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentLogConversationItemResponse, + AgentLogMessageItemResponse, +} from '@dify/contracts/api/console/agent/types.gen' import type { IChatItem } from '@/app/components/base/chat/chat/type' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { skipToken, useQuery } from '@tanstack/react-query' @@ -23,27 +26,33 @@ export function AgentLogDetailPanel({ const { t } = useTranslation() const { t: tAgentV2 } = useTranslation('agentV2') const { formatTime } = useTimestamp() - const messagesQuery = useQuery(consoleQuery.agent.byAgentId.logs.byConversationId.messages.get.queryOptions({ - input: log - ? { - params: { - agent_id: agentId, - conversation_id: log.conversation_id, - }, - query: { - limit: 100, - page: 1, - sort_by: 'created_at', - sort_order: 'asc', - ...(log.source ? { sources: [log.source.id] } : {}), - }, - } - : skipToken, - })) + const messagesQuery = useQuery( + consoleQuery.agent.byAgentId.logs.byConversationId.messages.get.queryOptions({ + input: log + ? { + params: { + agent_id: agentId, + conversation_id: log.conversation_id, + }, + query: { + limit: 100, + page: 1, + sort_by: 'created_at', + sort_order: 'asc', + ...(log.source ? { sources: [log.source.id] } : {}), + }, + } + : skipToken, + }), + ) const chatList = log ? formatAgentLogMessages({ conversationId: log.conversation_id, - formatLogTime: value => formatTime(value, tAgentV2($ => $['roster.dateTimeFormat'])), + formatLogTime: (value) => + formatTime( + value, + tAgentV2(($) => $['roster.dateTimeFormat']), + ), messages: messagesQuery.data?.data ?? [], }) : [] @@ -52,19 +61,15 @@ export function AgentLogDetailPanel({ <div className="flex h-full flex-col rounded-xl border-[0.5px] border-components-panel-border"> <div className="flex shrink-0 items-center gap-2 rounded-t-xl bg-components-panel-bg pt-3 pr-3 pb-2 pl-4"> <div className="min-w-0 shrink-0"> - <div className="mb-0.5 system-xs-semibold-uppercase text-text-primary">{t($ => $['detail.conversationId'], { ns: 'appLog' })}</div> + <div className="mb-0.5 system-xs-semibold-uppercase text-text-primary"> + {t(($) => $['detail.conversationId'], { ns: 'appLog' })} + </div> <div className="flex min-w-0 items-center system-2xs-regular-uppercase text-text-secondary"> {log && ( <> <Tooltip> - <TooltipTrigger - render={( - <div className="truncate">{log.conversation_id}</div> - )} - /> - <TooltipContent> - {log.conversation_id} - </TooltipContent> + <TooltipTrigger render={<div className="truncate">{log.conversation_id}</div>} /> + <TooltipContent>{log.conversation_id}</TooltipContent> </Tooltip> <CopyIcon content={log.conversation_id} /> </> @@ -76,7 +81,11 @@ export function AgentLogDetailPanel({ {log?.title || log?.conversation_id} </div> </div> - <ActionButton size="l" aria-label={t($ => $['operation.close'], { ns: 'common' })} onClick={onClose}> + <ActionButton + size="l" + aria-label={t(($) => $['operation.close'], { ns: 'common' })} + onClick={onClose} + > <span aria-hidden className="i-ri-close-line size-4 text-text-tertiary" /> </ActionButton> </div> @@ -91,12 +100,12 @@ export function AgentLogDetailPanel({ )} {messagesQuery.isError && ( <div className="flex h-full items-center justify-center text-center system-sm-regular text-text-tertiary"> - {t($ => $['agentDetail.logs.loadFailed'], { ns: 'agentV2' })} + {t(($) => $['agentDetail.logs.loadFailed'], { ns: 'agentV2' })} </div> )} {messagesQuery.isSuccess && chatList.length === 0 && ( <div className="flex h-full items-center justify-center text-center system-sm-regular text-text-tertiary"> - {t($ => $['agentDetail.logs.empty'], { ns: 'agentV2' })} + {t(($) => $['agentDetail.logs.empty'], { ns: 'agentV2' })} </div> )} {messagesQuery.isSuccess && chatList.length > 0 && ( diff --git a/web/features/agent-v2/agent-detail/logs/components/logs-table.tsx b/web/features/agent-v2/agent-detail/logs/components/logs-table.tsx index d1dc467fa9b46f..235148aae03494 100644 --- a/web/features/agent-v2/agent-detail/logs/components/logs-table.tsx +++ b/web/features/agent-v2/agent-detail/logs/components/logs-table.tsx @@ -32,15 +32,15 @@ export function AgentLogsTable({ }) { const { t } = useTranslation('agentV2') const tableHeaderLabels = { - unread: t($ => $['agentDetail.logs.table.unread']), - title: t($ => $['agentDetail.logs.table.title']), - source: t($ => $['agentDetail.logs.table.source']), - endUser: t($ => $['agentDetail.logs.table.endUser']), - messageCount: t($ => $['agentDetail.logs.table.messageCount']), - userRate: t($ => $['agentDetail.logs.table.userRate']), - operationRate: t($ => $['agentDetail.logs.table.operationRate']), - updatedTime: t($ => $['agentDetail.logs.table.updatedTime']), - createdTime: t($ => $['agentDetail.logs.table.createdTime']), + unread: t(($) => $['agentDetail.logs.table.unread']), + title: t(($) => $['agentDetail.logs.table.title']), + source: t(($) => $['agentDetail.logs.table.source']), + endUser: t(($) => $['agentDetail.logs.table.endUser']), + messageCount: t(($) => $['agentDetail.logs.table.messageCount']), + userRate: t(($) => $['agentDetail.logs.table.userRate']), + operationRate: t(($) => $['agentDetail.logs.table.operationRate']), + updatedTime: t(($) => $['agentDetail.logs.table.updatedTime']), + createdTime: t(($) => $['agentDetail.logs.table.createdTime']), } return ( @@ -54,7 +54,7 @@ export function AgentLogsTable({ <ScrollAreaRoot className="relative min-h-0 flex-1 overflow-hidden"> <ScrollAreaViewport - aria-label={t($ => $['agentDetail.logs.title'])} + aria-label={t(($) => $['agentDetail.logs.title'])} role="region" tabIndex={-1} className="overscroll-contain" @@ -103,87 +103,82 @@ function AgentLogsTableBody({ const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const { formatTime } = useTimestamp() - const notAvailable = t($ => $['agentDetail.logs.notAvailable']) + const notAvailable = t(($) => $['agentDetail.logs.notAvailable']) const formatLogTime = (value?: number | null) => - value == null ? notAvailable : formatTime(value, t($ => $['roster.dateTimeFormat'])) + value == null + ? notAvailable + : formatTime( + value, + t(($) => $['roster.dateTimeFormat']), + ) return ( <tbody className="system-sm-regular text-text-secondary"> - {isPending && ( - <LogsSkeletonRows /> - )} + {isPending && <LogsSkeletonRows />} {isError && ( <LogsStateRow> <div className="flex items-center justify-center gap-2"> - <span>{t($ => $['agentDetail.logs.loadFailed'])}</span> + <span>{t(($) => $['agentDetail.logs.loadFailed'])}</span> <Button variant="secondary" size="small" onClick={onRetry}> - {tCommon($ => $['operation.retry'])} + {tCommon(($) => $['operation.retry'])} </Button> </div> </LogsStateRow> )} {isSuccess && logs.length === 0 && ( - <LogsStateRow> - {t($ => $['agentDetail.logs.empty'])} - </LogsStateRow> + <LogsStateRow>{t(($) => $['agentDetail.logs.empty'])}</LogsStateRow> )} - {isSuccess && logs.map((log) => { - const logTitle = log.title || log.conversation_id - const isSelected = selectedLogId === log.id + {isSuccess && + logs.map((log) => { + const logTitle = log.title || log.conversation_id + const isSelected = selectedLogId === log.id - return ( - <tr - key={log.id} - className={cn( - 'h-10 cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover', - isSelected && 'bg-background-default-hover', - )} - onClick={() => onOpenLog(log)} - > - <td className="px-0"> - <span className={cn( - 'mx-auto block size-1.5 rounded-full', - log.unread ? 'bg-util-colors-blue-blue-500' : 'bg-transparent', + return ( + <tr + key={log.id} + className={cn( + 'h-10 cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover', + isSelected && 'bg-background-default-hover', )} - /> - </td> - <TableCell> - <button - type="button" - aria-label={logTitle} - className="block w-full truncate rounded-sm text-left system-sm-medium text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" - onClick={(event) => { - event.stopPropagation() - onOpenLog(log) - }} - > - {log.title || notAvailable} - </button> - </TableCell> - <td className="px-3"> - <LogSourceCell source={log.source} /> - </td> - <TableCell translate="no"> - {log.end_user_id || notAvailable} - </TableCell> - <TableCell className="tabular-nums"> - {log.message_count} - </TableCell> - <TableCell className="text-text-quaternary"> - {formatRate(log.user_rate, notAvailable)} - </TableCell> - <TableCell className="text-text-quaternary"> - {formatRate(log.operation_rate, notAvailable)} - </TableCell> - <TableCell> - {formatLogTime(log.updated_at)} - </TableCell> - <TableCell> - {formatLogTime(log.created_at)} - </TableCell> - </tr> - ) - })} + onClick={() => onOpenLog(log)} + > + <td className="px-0"> + <span + className={cn( + 'mx-auto block size-1.5 rounded-full', + log.unread ? 'bg-util-colors-blue-blue-500' : 'bg-transparent', + )} + /> + </td> + <TableCell> + <button + type="button" + aria-label={logTitle} + className="block w-full truncate rounded-sm text-left system-sm-medium text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" + onClick={(event) => { + event.stopPropagation() + onOpenLog(log) + }} + > + {log.title || notAvailable} + </button> + </TableCell> + <td className="px-3"> + <LogSourceCell source={log.source} /> + </td> + <TableCell translate="no">{log.end_user_id || notAvailable}</TableCell> + <TableCell className="tabular-nums">{log.message_count}</TableCell> + <TableCell className="text-text-quaternary"> + {formatRate(log.user_rate, notAvailable)} + </TableCell> + <TableCell className="text-text-quaternary"> + {formatRate(log.operation_rate, notAvailable)} + </TableCell> + <TableCell>{formatLogTime(log.updated_at)}</TableCell> + <TableCell>{formatLogTime(log.created_at)}</TableCell> + </tr> + ) + })} </tbody> ) } @@ -213,7 +208,12 @@ function LogsTableHeader({ }) { return ( <thead> - <tr className={cn('h-7 bg-background-section-burn text-left system-xs-medium-uppercase text-text-tertiary', rowClassName)}> + <tr + className={cn( + 'h-7 bg-background-section-burn text-left system-xs-medium-uppercase text-text-tertiary', + rowClassName, + )} + > <th scope="col" className="rounded-l-lg px-0"> <span className="sr-only">{labels.unread}</span> </th> @@ -246,11 +246,7 @@ function LogsTableColGroup() { ) } -function LogsStateRow({ - children, -}: { - children: ReactNode -}) { +function LogsStateRow({ children }: { children: ReactNode }) { return ( <tr className="h-20 border-b border-divider-subtle"> <td colSpan={9} className="px-3 text-center text-text-tertiary"> @@ -301,32 +297,14 @@ function LogsSkeletonRows() { ) } -function TableHead({ - className, - ...props -}: ThHTMLAttributes<HTMLTableCellElement>) { - return ( - <th - scope="col" - className={cn('px-3 text-left whitespace-nowrap', className)} - {...props} - /> - ) +function TableHead({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) { + return <th scope="col" className={cn('px-3 text-left whitespace-nowrap', className)} {...props} /> } -function TableCell({ - children, - className, - ...props -}: TdHTMLAttributes<HTMLTableCellElement>) { +function TableCell({ children, className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) { return ( - <td - className={cn('min-w-0 px-3 whitespace-nowrap', className)} - {...props} - > - <div className="truncate"> - {children} - </div> + <td className={cn('min-w-0 px-3 whitespace-nowrap', className)} {...props}> + <div className="truncate">{children}</div> </td> ) } diff --git a/web/features/agent-v2/agent-detail/logs/components/source-cell.tsx b/web/features/agent-v2/agent-detail/logs/components/source-cell.tsx index c8882eb1466a26..119bc5a4d2ebc6 100644 --- a/web/features/agent-v2/agent-detail/logs/components/source-cell.tsx +++ b/web/features/agent-v2/agent-detail/logs/components/source-cell.tsx @@ -2,17 +2,13 @@ import type { AgentLogConversationItemResponse } from '@dify/contracts/api/conso import { useTranslation } from 'react-i18next' import { LogSourceIcon } from './source-icon' -export function LogSourceCell({ - source, -}: { - source?: AgentLogConversationItemResponse['source'] -}) { +export function LogSourceCell({ source }: { source?: AgentLogConversationItemResponse['source'] }) { const { t } = useTranslation('agentV2') if (!source) { return ( <div className="truncate text-text-quaternary"> - {t($ => $['agentDetail.logs.notAvailable'])} + {t(($) => $['agentDetail.logs.notAvailable'])} </div> ) } @@ -20,9 +16,7 @@ export function LogSourceCell({ return ( <div className="flex min-w-0 items-center gap-2"> <LogSourceIcon source={source} /> - <div className="min-w-0 flex-1 truncate"> - {source.app_name} - </div> + <div className="min-w-0 flex-1 truncate">{source.app_name}</div> </div> ) } diff --git a/web/features/agent-v2/agent-detail/logs/components/source-icon.tsx b/web/features/agent-v2/agent-detail/logs/components/source-icon.tsx index 5af0c77ab70e12..e61a5361dd1606 100644 --- a/web/features/agent-v2/agent-detail/logs/components/source-icon.tsx +++ b/web/features/agent-v2/agent-detail/logs/components/source-icon.tsx @@ -1,8 +1,11 @@ -import type { AgentIconType, AgentLogSourceResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentIconType, + AgentLogSourceResponse, +} from '@dify/contracts/api/console/agent/types.gen' import AppIcon from '@/app/components/base/app-icon' const getLogSourceImageUrl = (source?: AgentLogSourceResponse | null) => - (source?.app_icon_type === 'image' || source?.app_icon_type === 'link') + source?.app_icon_type === 'image' || source?.app_icon_type === 'link' ? source.app_icon : undefined @@ -11,11 +14,7 @@ const getLogSourceIconType = (source?: AgentLogSourceResponse | null) => { return (imageUrl ? 'image' : source?.app_icon_type) as AgentIconType | null | undefined } -export function LogSourceIcon({ - source, -}: { - source?: AgentLogSourceResponse | null -}) { +export function LogSourceIcon({ source }: { source?: AgentLogSourceResponse | null }) { if (!source) return <span aria-hidden className="i-ri-apps-2-line size-5 shrink-0 text-text-quaternary" /> diff --git a/web/features/agent-v2/agent-detail/logs/components/source-picker.tsx b/web/features/agent-v2/agent-detail/logs/components/source-picker.tsx index cac6da7ebbc6da..60a4091e850175 100644 --- a/web/features/agent-v2/agent-detail/logs/components/source-picker.tsx +++ b/web/features/agent-v2/agent-detail/logs/components/source-picker.tsx @@ -1,4 +1,7 @@ -import type { AgentLogSourceGroupResponse, AgentLogSourceResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentLogSourceGroupResponse, + AgentLogSourceResponse, +} from '@dify/contracts/api/console/agent/types.gen' import type { TFunction } from 'i18next' import type { ReactNode } from 'react' import { Button } from '@langgenius/dify-ui/button' @@ -24,14 +27,9 @@ import { LogSourceIcon } from './source-icon' export type SourceFilterValue = AgentLogSourceResponse['id'][] -const getSourceGroupLabel = ( - group: AgentLogSourceGroupResponse, - t: TFunction<'agentV2'>, -) => { - if (group.type === 'webapp') - return t($ => $['agentDetail.logs.filters.source.webapp']) - if (group.type === 'workflow') - return t($ => $['agentDetail.logs.filters.source.workflow']) +const getSourceGroupLabel = (group: AgentLogSourceGroupResponse, t: TFunction<'agentV2'>) => { + if (group.type === 'webapp') return t(($) => $['agentDetail.logs.filters.source.webapp']) + if (group.type === 'workflow') return t(($) => $['agentDetail.logs.filters.source.workflow']) return group.label } @@ -55,8 +53,8 @@ export function AgentLogSourcePicker({ const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const [inputValue, setInputValue] = useState('') - const sources = groups.flatMap(group => group.sources ?? []) - const selectedSources = sources.filter(source => value.includes(source.id)) + const sources = groups.flatMap((group) => group.sources ?? []) + const selectedSources = sources.filter((source) => value.includes(source.id)) return ( <Combobox<AgentLogSourceResponse, true> @@ -66,53 +64,55 @@ export function AgentLogSourcePicker({ itemToStringLabel={getSourceLabel} onValueChange={(nextSources) => { setInputValue('') - onChange(nextSources.map(source => source.id)) + onChange(nextSources.map((source) => source.id)) }} inputValue={inputValue} onInputValueChange={setInputValue} > <ComboboxTrigger - aria-label={t($ => $['agentDetail.logs.filters.source.label'])} + aria-label={t(($) => $['agentDetail.logs.filters.source.label'])} className="mt-0 w-fit max-w-full min-w-22" > - <ComboboxValue placeholder={t($ => $['agentDetail.logs.filters.source.all'])}> + <ComboboxValue placeholder={t(($) => $['agentDetail.logs.filters.source.all'])}> {(selectedValue: AgentLogSourceResponse[]) => { if (selectedValue.length === 0) - return t($ => $['agentDetail.logs.filters.source.all']) - if (selectedValue.length === 1) - return selectedValue[0]!.app_name - return tCommon($ => $['dynamicSelect.selected'], { count: selectedValue.length }) + return t(($) => $['agentDetail.logs.filters.source.all']) + if (selectedValue.length === 1) return selectedValue[0]!.app_name + return tCommon(($) => $['dynamicSelect.selected'], { count: selectedValue.length }) }} </ComboboxValue> </ComboboxTrigger> <ComboboxContent popupClassName="w-80 p-0"> <div className="p-2 pb-1"> <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" /> + <span + aria-hidden + className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" + /> <ComboboxInput - aria-label={t($ => $['agentDetail.logs.filters.source.searchLabel'])} - placeholder={t($ => $['agentDetail.logs.filters.source.searchPlaceholder'])} + aria-label={t(($) => $['agentDetail.logs.filters.source.searchLabel'])} + placeholder={t(($) => $['agentDetail.logs.filters.source.searchPlaceholder'])} className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" /> </ComboboxInputGroup> </div> {isLoading && ( <SourcePickerStatus> - {t($ => $['agentDetail.logs.filters.source.loading'])} + {t(($) => $['agentDetail.logs.filters.source.loading'])} </SourcePickerStatus> )} {isError && ( <SourcePickerStatus className="flex items-center justify-center gap-2"> - <span>{t($ => $['agentDetail.logs.filters.source.loadFailed'])}</span> + <span>{t(($) => $['agentDetail.logs.filters.source.loadFailed'])}</span> <Button variant="secondary" size="small" onClick={onRetry}> - {t($ => $['operation.retry'], { ns: 'common' })} + {t(($) => $['operation.retry'], { ns: 'common' })} </Button> </SourcePickerStatus> )} {!isLoading && !isError && ( <> <ComboboxList className="max-h-69 p-2 pt-1"> - {groups.map(group => ( + {groups.map((group) => ( <ComboboxGroup key={group.type} items={group.sources ?? []}> <ComboboxGroupLabel className="px-1 pt-2 pb-1"> {getSourceGroupLabel(group, t)} @@ -127,9 +127,7 @@ export function AgentLogSourcePicker({ <ComboboxItemText className="flex min-w-0 items-center gap-2 px-0 system-sm-regular"> <SourceCheckbox checked={value.includes(source.id)} /> <LogSourceIcon source={source} /> - <span className="min-w-0 flex-1 truncate"> - {source.app_name} - </span> + <span className="min-w-0 flex-1 truncate">{source.app_name}</span> </ComboboxItemText> </ComboboxItem> )} @@ -138,7 +136,7 @@ export function AgentLogSourcePicker({ ))} </ComboboxList> <ComboboxEmpty className="px-3 py-3 text-center system-xs-regular"> - {t($ => $['agentDetail.logs.filters.source.empty'])} + {t(($) => $['agentDetail.logs.filters.source.empty'])} </ComboboxEmpty> </> )} @@ -147,13 +145,7 @@ export function AgentLogSourcePicker({ ) } -function SourcePickerStatus({ - children, - className, -}: { - children: ReactNode - className?: string -}) { +function SourcePickerStatus({ children, className }: { children: ReactNode; className?: string }) { return ( <div className={cn('px-3 py-3 text-center system-xs-regular text-text-tertiary', className)}> {children} @@ -161,11 +153,7 @@ function SourcePickerStatus({ ) } -function SourceCheckbox({ - checked, -}: { - checked: boolean -}) { +function SourceCheckbox({ checked }: { checked: boolean }) { return ( <span aria-hidden diff --git a/web/features/agent-v2/agent-detail/logs/page.tsx b/web/features/agent-v2/agent-detail/logs/page.tsx index cf6909db4c178b..c26f6faafd5fca 100644 --- a/web/features/agent-v2/agent-detail/logs/page.tsx +++ b/web/features/agent-v2/agent-detail/logs/page.tsx @@ -38,7 +38,10 @@ const queryDateFormat = 'YYYY-MM-DD HH:mm' const periodOptions: Array<{ value: PeriodKey - labelKey: 'agentDetail.logs.filters.period.last7days' | 'agentDetail.logs.filters.period.last30days' | 'agentDetail.logs.filters.period.allTime' + labelKey: + | 'agentDetail.logs.filters.period.last7days' + | 'agentDetail.logs.filters.period.last30days' + | 'agentDetail.logs.filters.period.allTime' }> = [ { value: 'last7days', labelKey: 'agentDetail.logs.filters.period.last7days' }, { value: 'last30days', labelKey: 'agentDetail.logs.filters.period.last30days' }, @@ -46,8 +49,7 @@ const periodOptions: Array<{ ] const getPeriodQuery = (period: PeriodKey) => { - if (period === 'allTime') - return {} + if (period === 'allTime') return {} const days = period === 'last7days' ? 7 : 30 return { @@ -56,7 +58,9 @@ const getPeriodQuery = (period: PeriodKey) => { } } -const parseSortValue = (value: string): { +const parseSortValue = ( + value: string, +): { field: LogsSortField order: LogsSortOrder } => { @@ -69,9 +73,7 @@ const parseSortValue = (value: string): { } } -export function AgentLogsPage({ - agentId, -}: AgentLogsPageProps) { +export function AgentLogsPage({ agentId }: AgentLogsPageProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const docLink = useDocLink() @@ -80,24 +82,26 @@ export function AgentLogsPage({ const [period, setPeriod] = useState<PeriodKey>('last7days') const [source, setSource] = useState<SourceFilterValue>([]) const [keyword, setKeyword] = useState('') - const [sort, setSort] = useState<{ field: LogsSortField, order: LogsSortOrder }>({ + const [sort, setSort] = useState<{ field: LogsSortField; order: LogsSortOrder }>({ field: 'created_at', order: 'desc', }) const [page, setPage] = useState(1) const [limit, setLimit] = useState(25) const [selectedLog, setSelectedLog] = useState<AgentLogConversationItemResponse>() - const periodItems = periodOptions.map(option => ({ + const periodItems = periodOptions.map((option) => ({ value: option.value, - name: t($ => $[option.labelKey]), + name: t(($) => $[option.labelKey]), })) - const logSourcesQuery = useQuery(consoleQuery.agent.byAgentId.logSources.get.queryOptions({ - input: { - params: { - agent_id: agentId, + const logSourcesQuery = useQuery( + consoleQuery.agent.byAgentId.logSources.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, }, - }, - })) + }), + ) const logsQuery = useQuery({ ...consoleQuery.agent.byAgentId.logs.get.queryOptions({ input: { @@ -126,21 +130,21 @@ export function AgentLogsPage({ } return ( - <AgentDetailSectionSurface label={t($ => $['agentDetail.sections.logs'])}> + <AgentDetailSectionSurface label={t(($) => $['agentDetail.sections.logs'])}> <header className="h-26.5 shrink-0 px-6 pt-3 pb-2"> <div className="min-w-0"> <h2 className="system-xl-semibold text-text-primary"> - {t($ => $['agentDetail.logs.title'])} + {t(($) => $['agentDetail.logs.title'])} </h2> <p className="mt-1 flex min-w-0 flex-wrap items-center gap-x-0.5 system-xs-regular text-text-tertiary"> - <span>{t($ => $['agentDetail.logs.description'])}</span> + <span>{t(($) => $['agentDetail.logs.description'])}</span> <a href={docLink('/use-dify/monitor/logs')} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-0.5 rounded-sm text-text-accent hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > - {t($ => $['agentDetail.logs.learnMore'])} + {t(($) => $['agentDetail.logs.learnMore'])} <span aria-hidden className="i-ri-external-link-line size-3" /> </a> </p> @@ -151,7 +155,9 @@ export function AgentLogsPage({ <Chip value={period} items={periodItems} - leftIcon={<span aria-hidden className="i-ri-calendar-line block size-4 text-text-secondary" />} + leftIcon={ + <span aria-hidden className="i-ri-calendar-line block size-4 text-text-secondary" /> + } className="min-w-32" onSelect={(item) => { setPage(1) @@ -178,9 +184,9 @@ export function AgentLogsPage({ /> <SearchInput - aria-label={t($ => $['agentDetail.logs.filters.search.label'])} + aria-label={t(($) => $['agentDetail.logs.filters.search.label'])} value={keyword} - placeholder={t($ => $['agentDetail.logs.filters.search.placeholder'])} + placeholder={t(($) => $['agentDetail.logs.filters.search.placeholder'])} className="w-50 shrink-0" onValueChange={(nextKeyword) => { setPage(1) @@ -193,8 +199,14 @@ export function AgentLogsPage({ order={sort.order === 'desc' ? '-' : ''} value={sort.field} items={[ - { value: 'created_at', name: t($ => $['agentDetail.logs.filters.sort.lastCreatedTime']) }, - { value: 'updated_at', name: t($ => $['agentDetail.logs.filters.sort.lastUpdatedTime']) }, + { + value: 'created_at', + name: t(($) => $['agentDetail.logs.filters.sort.lastCreatedTime']), + }, + { + value: 'updated_at', + name: t(($) => $['agentDetail.logs.filters.sort.lastUpdatedTime']), + }, ]} onSelect={(nextSortValue) => { setPage(1) @@ -223,8 +235,7 @@ export function AgentLogsPage({ modal swipeDirection="right" onOpenChange={(open) => { - if (!open) - closeLogDetail() + if (!open) closeLogDetail() }} > <DrawerPortal> @@ -232,11 +243,7 @@ export function AgentLogsPage({ <DrawerViewport> <DrawerPopup className="p-0! data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-3 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-150 data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border data-[swipe-direction=right]:border-components-panel-border"> <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> - <AgentLogDetailPanel - agentId={agentId} - log={selectedLog} - onClose={closeLogDetail} - /> + <AgentLogDetailPanel agentId={agentId} log={selectedLog} onClose={closeLogDetail} /> </DrawerContent> </DrawerPopup> </DrawerViewport> @@ -249,10 +256,11 @@ export function AgentLogsPage({ onPageChange={setPage} className="h-14 shrink-0 px-6 py-3" labels={{ - previous: tCommon($ => $['pagination.previous']), - next: tCommon($ => $['pagination.next']), - editPageNumber: (page, totalPages) => tCommon($ => $['pagination.editPageNumber'], { page, totalPages }), - pageNumberInput: tCommon($ => $['pagination.pageNumber']), + previous: tCommon(($) => $['pagination.previous']), + next: tCommon(($) => $['pagination.next']), + editPageNumber: (page, totalPages) => + tCommon(($) => $['pagination.editPageNumber'], { page, totalPages }), + pageNumberInput: tCommon(($) => $['pagination.pageNumber']), }} pageSize={{ value: limit, @@ -261,8 +269,8 @@ export function AgentLogsPage({ setPage(1) setLimit(nextLimit) }, - label: tCommon($ => $['pagination.perPage']), - ariaLabel: tCommon($ => $['pagination.perPage']), + label: tCommon(($) => $['pagination.perPage']), + ariaLabel: tCommon(($) => $['pagination.perPage']), }} /> </AgentDetailSectionSurface> diff --git a/web/features/agent-v2/agent-detail/monitoring/__tests__/metrics.spec.ts b/web/features/agent-v2/agent-detail/monitoring/__tests__/metrics.spec.ts index 695a1d643bf1c6..6569eeb6e35b17 100644 --- a/web/features/agent-v2/agent-detail/monitoring/__tests__/metrics.spec.ts +++ b/web/features/agent-v2/agent-detail/monitoring/__tests__/metrics.spec.ts @@ -16,30 +16,14 @@ const statisticsSummary: AgentStatisticSummaryEnvelopeResponse = { user_satisfaction_rate: 66.67, }, charts: { - average_response_time: [ - { date: '2026-06-16', latency: 1250 }, - ], - average_session_interactions: [ - { date: '2026-06-16', interactions: 1.5 }, - ], - daily_conversations: [ - { date: '2026-06-16', conversation_count: 2 }, - ], - daily_end_users: [ - { date: '2026-06-16', terminal_count: 3 }, - ], - daily_messages: [ - { date: '2026-06-16', message_count: 1250 }, - ], - token_usage: [ - { date: '2026-06-16', token_count: 2500, total_price: '0.005', currency: 'USD' }, - ], - tokens_per_second: [ - { date: '2026-06-16', tps: 4 }, - ], - user_satisfaction_rate: [ - { date: '2026-06-16', rate: 66.67 }, - ], + average_response_time: [{ date: '2026-06-16', latency: 1250 }], + average_session_interactions: [{ date: '2026-06-16', interactions: 1.5 }], + daily_conversations: [{ date: '2026-06-16', conversation_count: 2 }], + daily_end_users: [{ date: '2026-06-16', terminal_count: 3 }], + daily_messages: [{ date: '2026-06-16', message_count: 1250 }], + token_usage: [{ date: '2026-06-16', token_count: 2500, total_price: '0.005', currency: 'USD' }], + tokens_per_second: [{ date: '2026-06-16', tps: 4 }], + user_satisfaction_rate: [{ date: '2026-06-16', rate: 66.67 }], }, } @@ -48,7 +32,7 @@ describe('getAgentMonitoringMetrics', () => { const metrics = getAgentMonitoringMetrics(statisticsSummary) expect(metrics).toHaveLength(6) - expect(metrics.map(metric => metric.id)).toEqual([ + expect(metrics.map((metric) => metric.id)).toEqual([ 'total-messages', 'active-users', 'avg-session-interactions', @@ -56,33 +40,41 @@ describe('getAgentMonitoringMetrics', () => { 'user-satisfaction-rate', 'token-usage', ]) - expect(metrics[0]).toEqual(expect.objectContaining({ - summaryValue: '1.3k', - valueKey: 'message_count', - rows: statisticsSummary.charts.daily_messages, - })) - expect(metrics[2]).toEqual(expect.objectContaining({ - summaryValue: '1.5', - valueKey: 'interactions', - })) - expect(metrics[4]).toEqual(expect.objectContaining({ - summaryValue: '66.67%', - valueKey: 'rate', - yMaxWhenEmpty: 1000, - })) - expect(metrics[5]).toEqual(expect.objectContaining({ - summaryValue: '2.5k', - valueKey: 'token_count', - rows: statisticsSummary.charts.token_usage, - })) + expect(metrics[0]).toEqual( + expect.objectContaining({ + summaryValue: '1.3k', + valueKey: 'message_count', + rows: statisticsSummary.charts.daily_messages, + }), + ) + expect(metrics[2]).toEqual( + expect.objectContaining({ + summaryValue: '1.5', + valueKey: 'interactions', + }), + ) + expect(metrics[4]).toEqual( + expect.objectContaining({ + summaryValue: '66.67%', + valueKey: 'rate', + yMaxWhenEmpty: 1000, + }), + ) + expect(metrics[5]).toEqual( + expect.objectContaining({ + summaryValue: '2.5k', + valueKey: 'token_count', + rows: statisticsSummary.charts.token_usage, + }), + ) }) it('should return zero-valued metrics when statistics data is not loaded yet', () => { const metrics = getAgentMonitoringMetrics() expect(metrics).toHaveLength(6) - expect(metrics.every(metric => metric.rows.length === 0)).toBe(true) - expect(metrics.map(metric => metric.summaryValue)).toEqual(['0', '0', '0', '0', '0%', '0']) + expect(metrics.every((metric) => metric.rows.length === 0)).toBe(true) + expect(metrics.map((metric) => metric.summaryValue)).toEqual(['0', '0', '0', '0', '0%', '0']) }) it('should fill missing daily chart buckets within the selected period', () => { diff --git a/web/features/agent-v2/agent-detail/monitoring/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/monitoring/__tests__/page.spec.tsx index d0bff983dee8f4..74e172751ad487 100644 --- a/web/features/agent-v2/agent-detail/monitoring/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/monitoring/__tests__/page.spec.tsx @@ -1,4 +1,7 @@ -import type { AgentLogSourceListResponse, AgentStatisticSummaryEnvelopeResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentLogSourceListResponse, + AgentStatisticSummaryEnvelopeResponse, +} from '@dify/contracts/api/console/agent/types.gen' import type { EChartsOption } from 'echarts' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor, within } from '@testing-library/react' @@ -29,7 +32,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('echarts-for-react', () => ({ - default: ({ option, style }: { option: EChartsOption, style?: React.CSSProperties }) => { + default: ({ option, style }: { option: EChartsOption; style?: React.CSSProperties }) => { mocks.chartOptions.push(option) return <div data-testid="agent-monitoring-chart" style={style} /> @@ -98,30 +101,14 @@ const statisticsResponse: AgentStatisticSummaryEnvelopeResponse = { user_satisfaction_rate: 66.67, }, charts: { - average_response_time: [ - { date: '2026-06-22', latency: 1250 }, - ], - average_session_interactions: [ - { date: '2026-06-22', interactions: 1.5 }, - ], - daily_conversations: [ - { date: '2026-06-22', conversation_count: 2 }, - ], - daily_end_users: [ - { date: '2026-06-22', terminal_count: 3 }, - ], - daily_messages: [ - { date: '2026-06-22', message_count: 1250 }, - ], - token_usage: [ - { date: '2026-06-22', token_count: 2500, total_price: '0.005', currency: 'USD' }, - ], - tokens_per_second: [ - { date: '2026-06-22', tps: 4 }, - ], - user_satisfaction_rate: [ - { date: '2026-06-22', rate: 66.67 }, - ], + average_response_time: [{ date: '2026-06-22', latency: 1250 }], + average_session_interactions: [{ date: '2026-06-22', interactions: 1.5 }], + daily_conversations: [{ date: '2026-06-22', conversation_count: 2 }], + daily_end_users: [{ date: '2026-06-22', terminal_count: 3 }], + daily_messages: [{ date: '2026-06-22', message_count: 1250 }], + token_usage: [{ date: '2026-06-22', token_count: 2500, total_price: '0.005', currency: 'USD' }], + tokens_per_second: [{ date: '2026-06-22', tps: 4 }], + user_satisfaction_rate: [{ date: '2026-06-22', rate: 66.67 }], }, } @@ -172,8 +159,7 @@ const renderPage = () => { const getLatestStatisticsQueryInput = () => { const latestCall = mocks.statisticsQueryOptions.mock.calls.at(-1) - if (!latestCall) - throw new Error('Expected statistics query options to be called') + if (!latestCall) throw new Error('Expected statistics query options to be called') return latestCall[0] } @@ -223,9 +209,11 @@ describe('AgentMonitoringPage', () => { const firstChartOption = mocks.chartOptions[0] expect(firstChartOption).toBeDefined() - expect(firstChartOption).toEqual(expect.objectContaining({ - grid: { top: 8, right: 36, bottom: 10, left: 25, containLabel: true }, - })) + expect(firstChartOption).toEqual( + expect.objectContaining({ + grid: { top: 8, right: 36, bottom: 10, left: 25, containLabel: true }, + }), + ) expect(firstChartOption?.xAxis).toEqual(expect.any(Array)) }) @@ -254,20 +242,29 @@ describe('AgentMonitoringPage', () => { await user.click(await screen.findByRole('option', { name: /Book Translation/ })) await waitFor(() => { - expect(getLatestStatisticsQueryInput().input.query).toEqual(expect.objectContaining({ - source: 'webapp:webapp-app-id', - })) + expect(getLatestStatisticsQueryInput().input.query).toEqual( + expect.objectContaining({ + source: 'webapp:webapp-app-id', + }), + ) }) const sourceTrigger = screen.getByRole('combobox', { name: /Book Translation/ }) - expect(within(sourceTrigger).getByText(/metadata.sourceLabel/)).toHaveClass('system-sm-regular', 'text-text-tertiary') - expect(within(sourceTrigger).getByText('Book Translation')).toHaveClass('system-sm-medium', 'text-text-secondary') + expect(within(sourceTrigger).getByText(/metadata.sourceLabel/)).toHaveClass( + 'system-sm-regular', + 'text-text-tertiary', + ) + expect(within(sourceTrigger).getByText('Book Translation')).toHaveClass( + 'system-sm-medium', + 'text-text-secondary', + ) }) it('should keep previous statistics visible while a source filter refetches', async () => { const user = userEvent.setup() - let resolveNextStatistics: (value: AgentStatisticSummaryEnvelopeResponse) => void = () => undefined + let resolveNextStatistics: (value: AgentStatisticSummaryEnvelopeResponse) => void = () => + undefined const nextStatisticsPromise = new Promise<AgentStatisticSummaryEnvelopeResponse>((resolve) => { resolveNextStatistics = resolve }) @@ -283,9 +280,11 @@ describe('AgentMonitoringPage', () => { await user.click(await screen.findByRole('option', { name: /Book Translation/ })) await waitFor(() => { - expect(getLatestStatisticsQueryInput().input.query).toEqual(expect.objectContaining({ - source: 'webapp:webapp-app-id', - })) + expect(getLatestStatisticsQueryInput().input.query).toEqual( + expect.objectContaining({ + source: 'webapp:webapp-app-id', + }), + ) }) expect(screen.getByText('1.3k')).toBeInTheDocument() diff --git a/web/features/agent-v2/agent-detail/monitoring/chart-utils.ts b/web/features/agent-v2/agent-detail/monitoring/chart-utils.ts index 2a96a44becc7a5..3047c141e9d93a 100644 --- a/web/features/agent-v2/agent-detail/monitoring/chart-utils.ts +++ b/web/features/agent-v2/agent-detail/monitoring/chart-utils.ts @@ -14,7 +14,7 @@ type AgentMonitoringChartConfig = { colorType: ColorType } -const colorTypeMap: Record<ColorType, { lineColor: string, bgColor: [string, string] }> = { +const colorTypeMap: Record<ColorType, { lineColor: string; bgColor: [string, string] }> = { green: { lineColor: 'rgba(6, 148, 162, 1)', bgColor: ['rgba(6, 148, 162, 0.2)', 'rgba(67, 174, 185, 0.08)'], @@ -49,7 +49,8 @@ const commonColorMap = { const axisDateFormat = 'MMM' -const getChartColors = (chartType: AgentMonitoringChartType) => colorTypeMap[chartTypeConfig[chartType].colorType] +const getChartColors = (chartType: AgentMonitoringChartType) => + colorTypeMap[chartTypeConfig[chartType].colorType] const getMarkLineSeedData = (statisticsLength: number) => { const markLineLength = statisticsLength >= 2 ? statisticsLength - 2 : statisticsLength @@ -60,7 +61,7 @@ const getMarkLineSeedData = (statisticsLength: number) => { const getTooltipContent = ( chartType: AgentMonitoringChartType, yField: string, - params: { name: string, data?: AgentMonitoringChartRow }, + params: { name: string; data?: AgentMonitoringChartRow }, ) => { const row = params.data ?? { date: params.name } const value = row[yField] ?? 0 @@ -78,18 +79,17 @@ const getTooltipContent = ( </div>` } -export const getChartValueField = ( - rows: AgentMonitoringChartRow[], - valueKey?: string, -) => { - if (valueKey) - return valueKey +export const getChartValueField = (rows: AgentMonitoringChartRow[], valueKey?: string) => { + if (valueKey) return valueKey - return Object.keys(rows[0] ?? {}).find(name => name.includes('count')) ?? 'count' + return Object.keys(rows[0] ?? {}).find((name) => name.includes('count')) ?? 'count' } export const getTokenSummary = (rows: AgentMonitoringChartRow[]) => { - const totalPrice = rows.reduce((sum, row) => sum + Number.parseFloat(String(row.total_price ?? '0')), 0) + const totalPrice = rows.reduce( + (sum, row) => sum + Number.parseFloat(String(row.total_price ?? '0')), + 0, + ) return totalPrice.toLocaleString('en-US', { style: 'currency', @@ -124,47 +124,50 @@ export const buildChartOptions = ({ position: 'top', borderWidth: 0, }, - xAxis: [{ - type: 'category', - boundaryGap: false, - axisLabel: { - color: commonColorMap.label, - hideOverlap: true, - overflow: 'break', - formatter(value) { - return dayjs(value).format(axisDateFormat) - }, - }, - axisLine: { show: false }, - axisTick: { show: false }, - splitLine: { - show: true, - lineStyle: { - color: commonColorMap.splitLineLight, - width: 1, - type: [10, 10], + xAxis: [ + { + type: 'category', + boundaryGap: false, + axisLabel: { + color: commonColorMap.label, + hideOverlap: true, + overflow: 'break', + formatter(value) { + return dayjs(value).format(axisDateFormat) + }, }, - interval(index) { - return index === 0 || index === xData.length - 1 + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { + show: true, + lineStyle: { + color: commonColorMap.splitLineLight, + width: 1, + type: [10, 10], + }, + interval(index) { + return index === 0 || index === xData.length - 1 + }, }, }, - }, { - position: 'bottom', - boundaryGap: false, - data: markLineSeedData, - axisLabel: { show: false }, - axisLine: { show: false }, - axisTick: { show: false }, - splitLine: { - show: true, - lineStyle: { - color: commonColorMap.splitLineDark, - }, - interval(_index, value) { - return !!value + { + position: 'bottom', + boundaryGap: false, + data: markLineSeedData, + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { + show: true, + lineStyle: { + color: commonColorMap.splitLineDark, + }, + interval(_index, value) { + return !!value + }, }, }, - }], + ], yAxis: { max: yMax ?? 'dataMax', type: 'value', @@ -194,20 +197,27 @@ export const buildChartOptions = ({ y: 0, x2: 0, y2: 1, - colorStops: [{ - offset: 0, - color: chartColors.bgColor[0], - }, { - offset: 1, - color: chartColors.bgColor[1], - }], + colorStops: [ + { + offset: 0, + color: chartColors.bgColor[0], + }, + { + offset: 1, + color: chartColors.bgColor[1], + }, + ], global: false, }, }, tooltip: { padding: [8, 12, 8, 12], formatter(params) { - return getTooltipContent(chartType, valueKey, params as { name: string, data?: AgentMonitoringChartRow }) + return getTooltipContent( + chartType, + valueKey, + params as { name: string; data?: AgentMonitoringChartRow }, + ) }, }, }, diff --git a/web/features/agent-v2/agent-detail/monitoring/chart.tsx b/web/features/agent-v2/agent-detail/monitoring/chart.tsx index cf32421ddbfa6d..7f6493191d0214 100644 --- a/web/features/agent-v2/agent-detail/monitoring/chart.tsx +++ b/web/features/agent-v2/agent-detail/monitoring/chart.tsx @@ -5,11 +5,7 @@ import type { I18nKeysWithPrefix } from '@/types/i18n' import ReactECharts from 'echarts-for-react' import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' -import { - buildChartOptions, - getChartValueField, - getTokenSummary, -} from './chart-utils' +import { buildChartOptions, getChartValueField, getTokenSummary } from './chart-utils' type AgentMonitoringChartProps = { titleKey: I18nKeysWithPrefix<'agentV2', 'agentDetail.monitoring.'> @@ -23,7 +19,7 @@ type AgentMonitoringChartProps = { } const hasChartData = (rows: AgentMonitoringChartRow[], valueKey: string) => { - return rows.some(row => Number(row[valueKey] ?? 0) !== 0) + return rows.some((row) => Number(row[valueKey] ?? 0) !== 0) } export function AgentMonitoringChart({ @@ -53,31 +49,29 @@ export function AgentMonitoringChart({ <div className="flex h-11 shrink-0 items-center px-6 pt-6 pb-1"> <div className="flex min-w-0 items-center gap-1"> <h3 className="truncate system-md-semibold text-text-secondary"> - {t($ => $[titleKey])} + {t(($) => $[titleKey])} </h3> - <Infotip aria-label={t($ => $[explanationKey])}> - {t($ => $[explanationKey])} - </Infotip> + <Infotip aria-label={t(($) => $[explanationKey])}>{t(($) => $[explanationKey])}</Infotip> </div> </div> <div className="flex h-8 shrink-0 items-start gap-1 px-6 py-1"> - <div className={`truncate text-3xl leading-7 font-normal ${isEmptySummary ? 'text-text-quaternary' : 'text-text-primary'}`}> + <div + className={`truncate text-3xl leading-7 font-normal ${isEmptySummary ? 'text-text-quaternary' : 'text-text-primary'}`} + > {summaryValue} </div> {chartType !== 'tokenUsage' && unitKey && ( <div className="mt-0.5 truncate system-sm-regular text-text-secondary"> - {t($ => $[unitKey])} + {t(($) => $[unitKey])} </div> )} {chartType === 'tokenUsage' && ( <div className="mt-0.5 truncate system-sm-regular text-text-secondary"> - {t($ => $['agentDetail.monitoring.tokenUsageConsumed'])} - {' '} + {t(($) => $['agentDetail.monitoring.tokenUsageConsumed'])}{' '} <span className="text-util-colors-orange-orange-600"> (~ - {tokenSummary} - ) + {tokenSummary}) </span> </div> )} diff --git a/web/features/agent-v2/agent-detail/monitoring/metrics.ts b/web/features/agent-v2/agent-detail/monitoring/metrics.ts index 818ab8dd936d43..29416a7363b289 100644 --- a/web/features/agent-v2/agent-detail/monitoring/metrics.ts +++ b/web/features/agent-v2/agent-detail/monitoring/metrics.ts @@ -26,8 +26,7 @@ type ChartRowValue = number | string | undefined const chartDateFormat = 'YYYY-MM-DD' const toMetricNumber = (value: number) => { - if (Number.isInteger(value)) - return value.toLocaleString() + if (Number.isInteger(value)) return value.toLocaleString() return value.toLocaleString(undefined, { maximumFractionDigits: 2, @@ -35,8 +34,7 @@ const toMetricNumber = (value: number) => { } const getChartRowValue = (row: AgentMonitoringChartRow | undefined, key: string): ChartRowValue => { - if (!row) - return 0 + if (!row) return 0 return row[key] ?? 0 } @@ -47,7 +45,10 @@ const fillChartRows = <T extends { date: string }>( valueKey: string, ) => { const rowsByDate = new Map( - (rows ?? []).map(row => [dayjs(row.date).format(chartDateFormat), row as AgentMonitoringChartRow]), + (rows ?? []).map((row) => [ + dayjs(row.date).format(chartDateFormat), + row as AgentMonitoringChartRow, + ]), ) const start = dayjs(periodQuery.start).startOf('day') const end = dayjs(periodQuery.end).startOf('day') @@ -73,8 +74,7 @@ export const getAgentMonitoringMetrics = ( const summary = data?.summary const charts = data?.charts const toChartRows = <T extends { date: string }>(rows: T[] | undefined, valueKey: string) => { - if (!periodQuery) - return (rows ?? []) as AgentMonitoringChartRow[] + if (!periodQuery) return (rows ?? []) as AgentMonitoringChartRow[] return fillChartRows(rows, periodQuery, valueKey) } diff --git a/web/features/agent-v2/agent-detail/monitoring/page.tsx b/web/features/agent-v2/agent-detail/monitoring/page.tsx index 845500beeb68d6..49360b66da3ef8 100644 --- a/web/features/agent-v2/agent-detail/monitoring/page.tsx +++ b/web/features/agent-v2/agent-detail/monitoring/page.tsx @@ -44,24 +44,24 @@ const getDefaultPeriodQuery = () => { } } -export function AgentMonitoringPage({ - agentId, -}: AgentMonitoringPageProps) { +export function AgentMonitoringPage({ agentId }: AgentMonitoringPageProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const docLink = useDocLink() const [period, setPeriod] = useState(() => ({ - name: t($ => $['agentDetail.monitoring.timeRanges.today']), + name: t(($) => $['agentDetail.monitoring.timeRanges.today']), query: getDefaultPeriodQuery(), })) const [sourceFilter, setSourceFilter] = useState<SourceFilterValue>('all') - const logSourcesQuery = useQuery(consoleQuery.agent.byAgentId.logSources.get.queryOptions({ - input: { - params: { - agent_id: agentId, + const logSourcesQuery = useQuery( + consoleQuery.agent.byAgentId.logSources.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, }, - }, - })) + }), + ) const statisticsQuery = useQuery({ ...consoleQuery.agent.byAgentId.statistics.summary.get.queryOptions({ input: { @@ -76,13 +76,13 @@ export function AgentMonitoringPage({ }), placeholderData: keepPreviousData, }) - const sources = (logSourcesQuery.data?.groups ?? []).flatMap(group => group.sources ?? []) + const sources = (logSourcesQuery.data?.groups ?? []).flatMap((group) => group.sources ?? []) const sourceItems: SourceFilterItem[] = [ { value: 'all' as const, - name: t($ => $['agentDetail.monitoring.sources.all']), + name: t(($) => $['agentDetail.monitoring.sources.all']), }, - ...sources.map(source => ({ + ...sources.map((source) => ({ value: source.id, name: source.app_name, })), @@ -92,36 +92,33 @@ export function AgentMonitoringPage({ const shouldShowError = statisticsQuery.isError && !statisticsQuery.data return ( - <AgentDetailSectionSurface label={t($ => $['agentDetail.sections.monitoring'])}> + <AgentDetailSectionSurface label={t(($) => $['agentDetail.sections.monitoring'])}> <header className="h-26.5 shrink-0 px-6 pt-3 pb-2"> <div className="min-w-0"> <h2 className="system-xl-semibold text-text-primary"> - {t($ => $['agentDetail.monitoring.title'])} + {t(($) => $['agentDetail.monitoring.title'])} </h2> <p className="mt-1 flex min-w-0 flex-wrap items-center gap-x-0.5 system-xs-regular text-text-tertiary"> - <span>{t($ => $['agentDetail.monitoring.description'])}</span> + <span>{t(($) => $['agentDetail.monitoring.description'])}</span> <a href={docLink('/use-dify/monitor/logs')} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-0.5 rounded-sm text-text-accent hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > - {t($ => $['agentDetail.monitoring.learnMore'])} + {t(($) => $['agentDetail.monitoring.learnMore'])} <span aria-hidden className="i-ri-external-link-line size-3" /> </a> </p> </div> <div className="mt-3 flex min-w-0 flex-wrap items-center gap-2"> - <AgentMonitoringTimeRangePicker - value={period} - onChange={setPeriod} - /> + <AgentMonitoringTimeRangePicker value={period} onChange={setPeriod} /> <AgentMonitoringSourceFilter value={sourceFilter} items={sourceItems} - label={t($ => $['agentDetail.metadata.sourceLabel'])} + label={t(($) => $['agentDetail.metadata.sourceLabel'])} onSelect={(item) => { setSourceFilter(item.value) }} @@ -142,7 +139,7 @@ export function AgentMonitoringPage({ {shouldShowError && ( <AgentMonitoringState> <div className="flex items-center justify-center gap-2"> - <span>{t($ => $['agentDetail.monitoring.loadFailed'])}</span> + <span>{t(($) => $['agentDetail.monitoring.loadFailed'])}</span> <Button variant="secondary" size="small" @@ -150,14 +147,14 @@ export function AgentMonitoringPage({ void statisticsQuery.refetch() }} > - {tCommon($ => $['operation.retry'])} + {tCommon(($) => $['operation.retry'])} </Button> </div> </AgentMonitoringState> )} {!shouldShowInitialSkeleton && !shouldShowError && ( <div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2"> - {metrics.map(metric => ( + {metrics.map((metric) => ( <AgentMonitoringChart key={metric.id} titleKey={metric.titleKey} @@ -191,24 +188,24 @@ function AgentMonitoringSourceFilter({ onClear: () => void }) { const { t } = useTranslation('common') - const selectedItem = items.find(item => Object.is(item.value, value)) + const selectedItem = items.find((item) => Object.is(item.value, value)) const selectedName = selectedItem?.name ?? '' const triggerLabel = selectedName ? `${label} ${selectedName}` : label const clearLabel = selectedName - ? `${t($ => $['operation.clear'])} ${triggerLabel}` - : t($ => $['operation.clear']) + ? `${t(($) => $['operation.clear'])} ${triggerLabel}` + : t(($) => $['operation.clear']) return ( <Select value={selectedItem?.value ?? null} - itemToStringLabel={(itemValue: SourceFilterValue) => items.find(item => Object.is(item.value, itemValue))?.name ?? ''} - itemToStringValue={itemValue => String(itemValue)} + itemToStringLabel={(itemValue: SourceFilterValue) => + items.find((item) => Object.is(item.value, itemValue))?.name ?? '' + } + itemToStringValue={(itemValue) => String(itemValue)} onValueChange={(nextValue) => { - if (nextValue === null) - return - const selected = items.find(item => Object.is(item.value, nextValue)) - if (selected) - onSelect(selected) + if (nextValue === null) return + const selected = items.find((item) => Object.is(item.value, nextValue)) + if (selected) onSelect(selected) }} > <div className="relative w-fit max-w-full"> @@ -218,12 +215,8 @@ function AgentMonitoringSourceFilter({ > <span className="flex min-w-0 grow items-center gap-1 text-left"> <span className="flex min-w-0 grow items-center gap-1 px-1"> - <span className="shrink-0 system-sm-regular text-text-tertiary"> - {label} - </span> - <span className="truncate system-sm-medium text-text-secondary"> - {selectedName} - </span> + <span className="shrink-0 system-sm-regular text-text-tertiary">{label}</span> + <span className="truncate system-sm-medium text-text-secondary">{selectedName}</span> </span> </span> </SelectTrigger> @@ -233,7 +226,10 @@ function AgentMonitoringSourceFilter({ className="group/clear absolute top-1/2 right-1.5 flex size-5 -translate-y-1/2 cursor-pointer touch-manipulation items-center justify-center rounded-md border-none bg-transparent p-0 outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid" onClick={onClear} > - <span aria-hidden className="i-ri-close-circle-fill block size-3.5 text-text-quaternary group-hover/clear:text-text-tertiary" /> + <span + aria-hidden + className="i-ri-close-circle-fill block size-3.5 text-text-quaternary group-hover/clear:text-text-tertiary" + /> </button> <SelectContent placement="bottom-start" @@ -241,11 +237,8 @@ function AgentMonitoringSourceFilter({ popupClassName="relative w-61 rounded-xl border-[0.5px] bg-components-panel-bg-blur p-0 text-sm text-text-secondary shadow-lg outline-hidden backdrop-blur-[5px] focus:outline-hidden focus-visible:outline-hidden" listClassName="max-h-72 p-1" > - {items.map(item => ( - <SelectItem - key={item.value} - value={item.value} - > + {items.map((item) => ( + <SelectItem key={item.value} value={item.value}> <SelectItemText title={item.name}>{item.name}</SelectItemText> <SelectItemIndicator /> </SelectItem> @@ -256,11 +249,7 @@ function AgentMonitoringSourceFilter({ ) } -function AgentMonitoringState({ - children, -}: { - children: ReactNode -}) { +function AgentMonitoringState({ children }: { children: ReactNode }) { return ( <div className="flex h-[316px] items-center justify-center rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg px-6 py-8 text-center system-sm-regular text-text-tertiary"> {children} diff --git a/web/features/agent-v2/agent-detail/monitoring/time-range-picker.tsx b/web/features/agent-v2/agent-detail/monitoring/time-range-picker.tsx index c1e6113f899318..12fe68176ce37f 100644 --- a/web/features/agent-v2/agent-detail/monitoring/time-range-picker.tsx +++ b/web/features/agent-v2/agent-detail/monitoring/time-range-picker.tsx @@ -4,7 +4,14 @@ import type { Dayjs } from 'dayjs' import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types' import type { I18nKeysWithPrefix } from '@/types/i18n' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import dayjs from 'dayjs' import { noop } from 'es-toolkit/function' import { useState } from 'react' @@ -45,9 +52,8 @@ const timeRangeOptions: TimeRangeOption[] = [ const getRangePeriod = (option: TimeRangeOption): AgentMonitoringPeriod => { const end = today.endOf('day') - const start = option.days === 0 - ? today.startOf('day') - : today.subtract(option.days, 'day').startOf('day') + const start = + option.days === 0 ? today.startOf('day') : today.subtract(option.days, 'day').startOf('day') return { name: option.value, @@ -81,8 +87,7 @@ function DateRangePart({ )} onClick={handleClickTrigger} onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') - return + if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() event.currentTarget.click() @@ -94,18 +99,22 @@ function DateRangePart({ const availableStartDate = end.subtract(30, 'day') const isStartDateDisabled = (date: Dayjs) => { - if (date.isAfter(today, 'date')) - return true + if (date.isAfter(today, 'date')) return true - return !((date.isAfter(availableStartDate, 'date') || date.isSame(availableStartDate, 'date')) && (date.isBefore(end, 'date') || date.isSame(end, 'date'))) + return !( + (date.isAfter(availableStartDate, 'date') || date.isSame(availableStartDate, 'date')) && + (date.isBefore(end, 'date') || date.isSame(end, 'date')) + ) } const availableEndDate = start.add(30, 'day') const isEndDateDisabled = (date: Dayjs) => { - if (date.isAfter(today, 'date')) - return true + if (date.isAfter(today, 'date')) return true - return !((date.isAfter(start, 'date') || date.isSame(start, 'date')) && (date.isBefore(availableEndDate, 'date') || date.isSame(availableEndDate, 'date'))) + return !( + (date.isAfter(start, 'date') || date.isSame(start, 'date')) && + (date.isBefore(availableEndDate, 'date') || date.isSame(availableEndDate, 'date')) + ) } return ( @@ -144,15 +153,13 @@ export function AgentMonitoringTimeRangePicker({ const [start, setStart] = useState(() => dayjs(value.query.start)) const [end, setEnd] = useState(() => dayjs(value.query.end)) - const selectedOption = timeRangeOptions.find(option => option.value === selectedRange) + const selectedOption = timeRangeOptions.find((option) => option.value === selectedRange) const handleRangeChange = (nextValue: string | null) => { - if (!nextValue) - return + if (!nextValue) return - const option = timeRangeOptions.find(item => item.value === nextValue) - if (!option) - return + const option = timeRangeOptions.find((item) => item.value === nextValue) + if (!option) return const nextPeriod = getRangePeriod(option) setSelectedRange(option.value) @@ -160,19 +167,16 @@ export function AgentMonitoringTimeRangePicker({ setEnd(dayjs(nextPeriod.query.end)) onChange({ ...nextPeriod, - name: t($ => $[option.nameKey]), + name: t(($) => $[option.nameKey]), }) } const handleDateChange = (type: 'start' | 'end') => (date?: Dayjs) => { - if (!date) - return + if (!date) return - if (type === 'start' && date.isSame(start)) - return + if (type === 'start' && date.isSame(start)) return - if (type === 'end' && date.isSame(end)) - return + if (type === 'end' && date.isSame(end)) return const nextStart = type === 'start' ? date : start const nextEnd = type === 'end' ? date : end @@ -197,26 +201,40 @@ export function AgentMonitoringTimeRangePicker({ onValueChange={handleRangeChange} > <SelectTrigger - aria-label={t($ => $['agentDetail.monitoring.timeRangeLabel'])} + aria-label={t(($) => $['agentDetail.monitoring.timeRangeLabel'])} className="mt-0 h-auto w-20 shrink-0 border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden" > <div className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2 pl-3 group-data-popup-open:bg-state-base-hover-alt"> <div className="system-sm-regular text-components-input-text-filled"> - {selectedRange === 'custom' ? t($ => $['agentDetail.monitoring.timeRanges.custom']) : selectedOption ? t($ => $[selectedOption.nameKey]) : value.name} + {selectedRange === 'custom' + ? t(($) => $['agentDetail.monitoring.timeRanges.custom']) + : selectedOption + ? t(($) => $[selectedOption.nameKey]) + : value.name} </div> - <span aria-hidden className="i-ri-arrow-down-s-line size-4 text-text-quaternary group-data-popup-open:text-text-secondary" /> + <span + aria-hidden + className="i-ri-arrow-down-s-line size-4 text-text-quaternary group-data-popup-open:text-text-secondary" + /> </div> </SelectTrigger> <SelectContent className="translate-x-[-24px]" popupClassName="w-50" listClassName="p-1"> - {timeRangeOptions.map(option => ( - <SelectItem key={option.value} value={option.value} className="h-8 py-0 pr-2 pl-7 system-md-regular"> - <SelectItemText className="px-0">{t($ => $[option.nameKey])}</SelectItemText> + {timeRangeOptions.map((option) => ( + <SelectItem + key={option.value} + value={option.value} + className="h-8 py-0 pr-2 pl-7 system-md-regular" + > + <SelectItemText className="px-0">{t(($) => $[option.nameKey])}</SelectItemText> <SelectItemIndicator className="absolute top-2 left-2 ml-0" /> </SelectItem> ))} </SelectContent> </Select> - <span aria-hidden className="i-custom-vender-other-hourglass-shape h-3.5 w-2 text-components-input-bg-normal" /> + <span + aria-hidden + className="i-custom-vender-other-hourglass-shape h-3.5 w-2 text-components-input-bg-normal" + /> <DateRangePart start={start} end={end} diff --git a/web/features/agent-v2/agent-detail/navigation.tsx b/web/features/agent-v2/agent-detail/navigation.tsx index fc871c734c3ba8..8c75a616ccb9e6 100644 --- a/web/features/agent-v2/agent-detail/navigation.tsx +++ b/web/features/agent-v2/agent-detail/navigation.tsx @@ -83,10 +83,7 @@ const getAgentDetailNavigation = (agentId: string): AgentDetailNavItem[] => [ }, ] -export function AgentDetailTop({ - expand = true, - onToggle, -}: AgentDetailTopProps) { +export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps) { const { t: tApp } = useTranslation('app') const { t: tCommon } = useTranslation('common') const setGotoAnythingOpen = useSetGotoAnythingOpen() @@ -111,36 +108,40 @@ export function AgentDetailTop({ <div className="flex min-w-0 flex-1 items-center gap-px"> <Link href="/" - aria-label={tCommon($ => $['mainNav.home'])} + aria-label={tCommon(($) => $['mainNav.home'])} className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 text-text-tertiary transition-colors hover:bg-background-default-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > <span aria-hidden className="i-ri-arrow-left-s-line size-4" /> <span aria-hidden className="i-custom-vender-main-nav-app-home size-4" /> </Link> - <span className="shrink-0 system-md-regular text-text-quaternary"> - / - </span> - <Link href="/agents" className="shrink-0 truncate rounded-lg px-1.5 py-2 system-sm-semibold-uppercase text-text-secondary transition-colors hover:bg-background-default-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"> + <span className="shrink-0 system-md-regular text-text-quaternary">/</span> + <Link + href="/agents" + className="shrink-0 truncate rounded-lg px-1.5 py-2 system-sm-semibold-uppercase text-text-secondary transition-colors hover:bg-background-default-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" + > Agents </Link> </div> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" - aria-label={tApp($ => $['gotoAnything.searchTitle'])} + aria-label={tApp(($) => $['gotoAnything.searchTitle'])} className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={() => setGotoAnythingOpen(true)} > <span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" /> </button> - )} + } /> - <TooltipContent placement="bottom" className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]"> - <span className="px-0.5">{tApp($ => $['gotoAnything.quickAction'])}</span> + <TooltipContent + placement="bottom" + className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]" + > + <span className="px-0.5">{tApp(($) => $['gotoAnything.quickAction'])}</span> <KbdGroup> - {SEARCH_SHORTCUT.map(key => ( + {SEARCH_SHORTCUT.map((key) => ( <Kbd key={key}>{formatForDisplay(key)}</Kbd> ))} </KbdGroup> @@ -158,28 +159,28 @@ export function AgentDetailTop({ ) } -export function AgentDetailSection({ - expand = true, -}: AgentDetailSectionProps) { +export function AgentDetailSection({ expand = true }: AgentDetailSectionProps) { const { t } = useTranslation('agentV2') const pathname = usePathname() const agentId = getAgentIdFromPathname(pathname) - const agentQuery = useQuery(consoleQuery.agent.byAgentId.get.queryOptions({ - input: agentId - ? { - params: { - agent_id: agentId, - }, - } - : skipToken, - })) + const agentQuery = useQuery( + consoleQuery.agent.byAgentId.get.queryOptions({ + input: agentId + ? { + params: { + agent_id: agentId, + }, + } + : skipToken, + }), + ) - if (!agentId) - return null + if (!agentId) return null const navigation = getAgentDetailNavigation(agentId) const agent = agentQuery.data - const imageUrl = (agent?.icon_type === 'image' || agent?.icon_type === 'link') ? agent.icon : undefined + const imageUrl = + agent?.icon_type === 'image' || agent?.icon_type === 'link' ? agent.icon : undefined const iconType = (imageUrl ? 'image' : agent?.icon_type) as AgentIconType | null | undefined return ( @@ -194,16 +195,13 @@ export function AgentDetailSection({ </div> )} <div className={cn('py-2', expand && '-mx-1')}> - <div className={cn( - 'flex h-13 items-center rounded-xl py-1.5 pr-2 pl-1.5', - !expand && 'justify-center', - )} - > - <div className={cn( - 'shrink-0', - expand && 'mr-2', + <div + className={cn( + 'flex h-13 items-center rounded-xl py-1.5 pr-2 pl-1.5', + !expand && 'justify-center', )} - > + > + <div className={cn('shrink-0', expand && 'mr-2')}> <span aria-hidden> <AppIcon size="large" @@ -218,10 +216,10 @@ export function AgentDetailSection({ <div className={cn('flex h-10 min-w-0 flex-1 items-center gap-2', !expand && 'hidden')}> <div className="flex min-w-0 flex-1 flex-col justify-center"> <div className="truncate system-md-semibold text-text-secondary"> - {agent?.name ?? t($ => $['agentDetail.title'])} + {agent?.name ?? t(($) => $['agentDetail.title'])} </div> <div className="truncate system-2xs-medium-uppercase text-text-tertiary"> - {agent?.role ?? t($ => $['agentDetail.type'])} + {agent?.role ?? t(($) => $['agentDetail.type'])} </div> </div> {agent && expand && <AgentDetailSidebarActions agent={agent} />} @@ -240,13 +238,16 @@ export function AgentDetailSection({ )} /> </div> - <nav className={cn('flex flex-col gap-y-0.5 py-2', expand ? 'px-1' : 'px-3')} aria-label={t($ => $['agentDetail.navigationLabel'])}> - {navigation.map(item => ( + <nav + className={cn('flex flex-col gap-y-0.5 py-2', expand ? 'px-1' : 'px-3')} + aria-label={t(($) => $['agentDetail.navigationLabel'])} + > + {navigation.map((item) => ( <NavLink key={item.href} mode={expand ? 'expand' : 'collapse'} iconMap={{ selected: item.activeIcon, normal: item.icon }} - name={t($ => $[item.labelKey])} + name={t(($) => $[item.labelKey])} href={item.href} pathname={pathname} /> diff --git a/web/features/agent-v2/agent-detail/page.tsx b/web/features/agent-v2/agent-detail/page.tsx index 61fcf03381a014..049e63308816bb 100644 --- a/web/features/agent-v2/agent-detail/page.tsx +++ b/web/features/agent-v2/agent-detail/page.tsx @@ -11,21 +11,14 @@ type AgentDetailPageProps = { section: AgentDetailSectionKey } -export function AgentDetailPage({ - agentId, - section, -}: AgentDetailPageProps) { - if (section === 'monitoring') - return <AgentMonitoringPage agentId={agentId} /> +export function AgentDetailPage({ agentId, section }: AgentDetailPageProps) { + if (section === 'monitoring') return <AgentMonitoringPage agentId={agentId} /> - if (section === 'logs') - return <AgentLogsPage agentId={agentId} /> + if (section === 'logs') return <AgentLogsPage agentId={agentId} /> - if (section === 'access') - return <AgentAccessPage agentId={agentId} /> + if (section === 'access') return <AgentAccessPage agentId={agentId} /> - if (section === 'configure') - return <AgentConfigurePage agentId={agentId} /> + if (section === 'configure') return <AgentConfigurePage agentId={agentId} /> return null } diff --git a/web/features/agent-v2/agent-detail/routes.ts b/web/features/agent-v2/agent-detail/routes.ts index 3371b355f674d1..0a8693150ee423 100644 --- a/web/features/agent-v2/agent-detail/routes.ts +++ b/web/features/agent-v2/agent-detail/routes.ts @@ -1,15 +1,12 @@ import type { AgentDetailSectionKey } from './section' -export const getAgentDetailPath = ( - agentId: string, - section: AgentDetailSectionKey, -) => `/agents/${agentId}/${section}` +export const getAgentDetailPath = (agentId: string, section: AgentDetailSectionKey) => + `/agents/${agentId}/${section}` export const getAgentIdFromPathname = (pathname: string) => { const [section, agentId] = pathname.split('/').filter(Boolean) - if (section !== 'agents') - return undefined + if (section !== 'agents') return undefined return agentId } diff --git a/web/features/agent-v2/agent-detail/section-surface.tsx b/web/features/agent-v2/agent-detail/section-surface.tsx index 229512daaa9a8e..d567398f50b008 100644 --- a/web/features/agent-v2/agent-detail/section-surface.tsx +++ b/web/features/agent-v2/agent-detail/section-surface.tsx @@ -21,7 +21,12 @@ export function AgentDetailSectionSurface({ aria-label={label} className={cn('flex h-full min-w-0 flex-1 overflow-hidden py-1 pr-1 pl-0', className)} > - <div className={cn('flex min-w-0 flex-1 flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', panelClassName)}> + <div + className={cn( + 'flex min-w-0 flex-1 flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', + panelClassName, + )} + > {children} </div> </section> diff --git a/web/features/agent-v2/agent-detail/sidebar-actions.tsx b/web/features/agent-v2/agent-detail/sidebar-actions.tsx index 12c5a84728b63e..2eccb188036114 100644 --- a/web/features/agent-v2/agent-detail/sidebar-actions.tsx +++ b/web/features/agent-v2/agent-detail/sidebar-actions.tsx @@ -27,11 +27,7 @@ type AgentDetailSidebarActionAgent = Pick< | 'role' > -export function AgentDetailSidebarActions({ - agent, -}: { - agent: AgentDetailSidebarActionAgent -}) { +export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebarActionAgent }) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const [isEditOpen, setIsEditOpen] = useState(false) @@ -52,12 +48,12 @@ export function AgentDetailSidebarActions({ } const handleEditOpen = () => { - setEditSessionKey(key => key + 1) + setEditSessionKey((key) => key + 1) setIsEditOpen(true) } const handleDuplicateOpen = () => { - setDuplicateSessionKey(key => key + 1) + setDuplicateSessionKey((key) => key + 1) setIsDuplicateOpen(true) } @@ -65,7 +61,7 @@ export function AgentDetailSidebarActions({ <> <DropdownMenu> <DropdownMenuTrigger - aria-label={t($ => $['roster.moreActions'], { name: agent.name })} + aria-label={t(($) => $['roster.moreActions'], { name: agent.name })} className="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary" > <span aria-hidden className="i-ri-more-fill size-4" /> @@ -73,14 +69,11 @@ export function AgentDetailSidebarActions({ <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-40"> <DropdownMenuItem className="gap-2" onClick={handleEditOpen}> <span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" /> - <span>{t($ => $['roster.editInfo'])}</span> + <span>{t(($) => $['roster.editInfo'])}</span> </DropdownMenuItem> - <DropdownMenuItem - className="gap-2" - onClick={handleDuplicateOpen} - > + <DropdownMenuItem className="gap-2" onClick={handleDuplicateOpen}> <span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" /> - <span>{tCommon($ => $['operation.duplicate'])}</span> + <span>{tCommon(($) => $['operation.duplicate'])}</span> </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem @@ -89,7 +82,7 @@ export function AgentDetailSidebarActions({ onClick={() => setIsDeleteOpen(true)} > <span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" /> - <span>{tCommon($ => $['operation.delete'])}</span> + <span>{tCommon(($) => $['operation.delete'])}</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> @@ -105,7 +98,12 @@ export function AgentDetailSidebarActions({ open={isDuplicateOpen} onOpenChange={setIsDuplicateOpen} /> - <DeleteAgentDialog agentId={agent.id} agentName={agent.name} open={isDeleteOpen} onOpenChange={setIsDeleteOpen} /> + <DeleteAgentDialog + agentId={agent.id} + agentName={agent.name} + open={isDeleteOpen} + onOpenChange={setIsDeleteOpen} + /> </> ) } diff --git a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx index 08af8f8fbf36a6..c28d475643a418 100644 --- a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx @@ -21,7 +21,10 @@ vi.mock('@/service/client', () => ({ agent: { byAgentId: { get: { - queryKey: ({ input }: { input: { params: { agent_id: string } } }) => ['agent-detail', input.params.agent_id], + queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [ + 'agent-detail', + input.params.agent_id, + ], }, copy: { post: { @@ -95,10 +98,12 @@ describe('AgentRosterList', () => { vi.clearAllMocks() vi.spyOn(toast, 'error').mockReturnValue('toast-id') vi.spyOn(toast, 'success').mockReturnValue('toast-id') - duplicateAgentMutationFn.mockResolvedValue(createAgent({ - id: 'agent-copy', - name: 'Research Agent copy', - })) + duplicateAgentMutationFn.mockResolvedValue( + createAgent({ + id: 'agent-copy', + name: 'Research Agent copy', + }), + ) }) afterEach(() => { @@ -115,9 +120,13 @@ describe('AgentRosterList', () => { it('uses the Figma-aligned card title and role typography', () => { renderList([createAgent()]) - expect(screen.getByRole('heading', { name: 'Research Agent' })).toHaveClass('system-md-semibold') + expect(screen.getByRole('heading', { name: 'Research Agent' })).toHaveClass( + 'system-md-semibold', + ) expect(screen.getByText('Research Assistant')).toHaveClass('system-xs-regular') - expect(screen.getByText('agentV2.roster.usageStatus.draft')).toHaveClass('system-2xs-medium-uppercase') + expect(screen.getByText('agentV2.roster.usageStatus.draft')).toHaveClass( + 'system-2xs-medium-uppercase', + ) }) it('only renders the draft badge for unpublished agents', () => { @@ -143,22 +152,33 @@ describe('AgentRosterList', () => { it('renders the Figma-aligned empty roster overlay', () => { const { container } = renderList([]) - const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none')) - .find(element => element.className.includes('grid-rows-4')) + const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none')).find( + (element) => element.className.includes('grid-rows-4'), + ) - if (!placeholderGrid) - throw new Error('Expected agent roster placeholder grid to render') + if (!placeholderGrid) throw new Error('Expected agent roster placeholder grid to render') - expect(screen.getByRole('heading', { name: 'agentV2.roster.empty' })).toHaveClass('system-sm-regular', 'text-text-tertiary') + expect(screen.getByRole('heading', { name: 'agentV2.roster.empty' })).toHaveClass( + 'system-sm-regular', + 'text-text-tertiary', + ) expect(container.querySelectorAll('.bg-background-default-lighter')).toHaveLength(16) expect(container.querySelector('.bg-linear-to-b')).toBeInTheDocument() - expect(container.querySelector('.i-ri-robot-2-line')).toHaveClass('size-6', 'text-text-tertiary') + expect(container.querySelector('.i-ri-robot-2-line')).toHaveClass( + 'size-6', + 'text-text-tertiary', + ) expect(placeholderGrid).toHaveClass( 'grid', 'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', 'grid-rows-4', ) - expect(placeholderGrid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'lg:grid-cols-3', 'xl:grid-cols-4') + expect(placeholderGrid).not.toHaveClass( + 'grid-cols-1', + 'sm:grid-cols-2', + 'lg:grid-cols-3', + 'xl:grid-cols-4', + ) }) it('uses the same overlay treatment for empty search results', () => { @@ -172,7 +192,10 @@ describe('AgentRosterList', () => { it('uses the same overlay treatment for loading errors', () => { const { container } = renderList([], { isError: true }) - expect(screen.getByRole('heading', { name: 'agentV2.roster.loadingError' })).toHaveClass('system-sm-regular', 'text-text-tertiary') + expect(screen.getByRole('heading', { name: 'agentV2.roster.loadingError' })).toHaveClass( + 'system-sm-regular', + 'text-text-tertiary', + ) expect(container.querySelectorAll('.bg-background-default-lighter')).toHaveLength(16) expect(container.querySelector('.bg-linear-to-b')).toBeInTheDocument() }) @@ -210,7 +233,9 @@ describe('AgentRosterList', () => { await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ })) await user.click(screen.getByRole('menuitem', { name: /common\.operation\.duplicate/ })) - const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }) + const dialog = await screen.findByRole('dialog', { + name: 'agentV2.roster.duplicateDialog.title', + }) const nameInput = within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.nameLabel.*common\.label\.optional/, }) @@ -236,19 +261,34 @@ describe('AgentRosterList', () => { description: null, }), ]) - queryClient.setQueryData(['agent-detail', 'agent-1'], createAgent({ - description: 'Summarize new market updates.', - role: 'Market Researcher', - })) + queryClient.setQueryData( + ['agent-detail', 'agent-1'], + createAgent({ + description: 'Summarize new market updates.', + role: 'Market Researcher', + }), + ) await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ })) await user.click(screen.getByRole('menuitem', { name: /common\.operation\.duplicate/ })) - const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }) - expect(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.nameLabel/ })).toHaveValue('') - expect(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.nameLabel/ })).toHaveAttribute('placeholder', 'Research Agent copy') - expect(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.descriptionLabel/ })).toHaveValue('Summarize new market updates.') - expect(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ })).toHaveValue('Market Researcher') + const dialog = await screen.findByRole('dialog', { + name: 'agentV2.roster.duplicateDialog.title', + }) + expect( + within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.nameLabel/ }), + ).toHaveValue('') + expect( + within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.nameLabel/ }), + ).toHaveAttribute('placeholder', 'Research Agent copy') + expect( + within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.descriptionLabel/, + }), + ).toHaveValue('Summarize new market updates.') + expect( + within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), + ).toHaveValue('Market Researcher') }) it('duplicates an agent with backend-generated naming when the dialog name is empty', async () => { @@ -258,7 +298,9 @@ describe('AgentRosterList', () => { await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ })) await user.click(screen.getByRole('menuitem', { name: /common\.operation\.duplicate/ })) - const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }) + const dialog = await screen.findByRole('dialog', { + name: 'agentV2.roster.duplicateDialog.title', + }) await user.click(within(dialog).getByRole('button', { name: 'common.operation.duplicate' })) expect(duplicateAgentMutationFn).toHaveBeenCalledWith( @@ -291,10 +333,18 @@ describe('AgentRosterList', () => { await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ })) await user.click(screen.getByRole('menuitem', { name: /common\.operation\.duplicate/ })) - const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }) - const nameInput = within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.nameLabel/ }) - const roleInput = within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }) - const descriptionInput = within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.descriptionLabel/ }) + const dialog = await screen.findByRole('dialog', { + name: 'agentV2.roster.duplicateDialog.title', + }) + const nameInput = within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.nameLabel/, + }) + const roleInput = within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.roleLabel/, + }) + const descriptionInput = within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.descriptionLabel/, + }) await user.clear(nameInput) await user.type(nameInput, ' Market Agent ') await user.clear(roleInput) @@ -333,8 +383,12 @@ describe('AgentRosterList', () => { await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ })) await user.click(screen.getByRole('menuitem', { name: /common\.operation\.duplicate/ })) - const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }) - await user.clear(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ })) + const dialog = await screen.findByRole('dialog', { + name: 'agentV2.roster.duplicateDialog.title', + }) + await user.clear( + within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), + ) await user.click(within(dialog).getByRole('button', { name: 'common.operation.duplicate' })) expect(duplicateAgentMutationFn).toHaveBeenCalledWith( @@ -364,21 +418,35 @@ describe('AgentRosterList', () => { await user.click(screen.getByRole('menuitem', { name: /agentV2\.roster\.editInfo/ })) const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - const nameInput = within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }) + const nameInput = within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.nameLabel', + }) await user.clear(nameInput) await user.type(nameInput, 'Draft Name') await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => { - expect(screen.queryByRole('dialog', { name: 'agentV2.roster.editDialog.title' })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: 'agentV2.roster.editDialog.title' }), + ).not.toBeInTheDocument() }) await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ })) await user.click(screen.getByRole('menuitem', { name: /agentV2\.roster\.editInfo/ })) - const reopenedDialog = await screen.findByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - expect(within(reopenedDialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' })).toHaveValue('Research Agent') - expect(within(reopenedDialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ })).toHaveValue('Research Assistant') - expect(within(reopenedDialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled() + const reopenedDialog = await screen.findByRole('dialog', { + name: 'agentV2.roster.editDialog.title', + }) + expect( + within(reopenedDialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ).toHaveValue('Research Agent') + expect( + within(reopenedDialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.roleLabel/, + }), + ).toHaveValue('Research Assistant') + expect( + within(reopenedDialog).getByRole('button', { name: 'common.operation.save' }), + ).toBeDisabled() }) }) diff --git a/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx index 318f752fc2191f..bb07a3ac588696 100644 --- a/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx @@ -54,23 +54,35 @@ describe('CreateAgentDialog', () => { await user.click(screen.getByRole('button', { name: /agentV2\.roster\.createAgent/ })) const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), ' Research Agent ') - await user.type(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), ' Research Assistant ') - await user.type(within(dialog).getByPlaceholderText('agentV2.roster.createForm.descriptionPlaceholder'), ' Find and summarize market materials. ') + await user.type( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ' Research Agent ', + ) + await user.type( + within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), + ' Research Assistant ', + ) + await user.type( + within(dialog).getByPlaceholderText('agentV2.roster.createForm.descriptionPlaceholder'), + ' Find and summarize market materials. ', + ) await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' })) - expect(mutationMock.mutate).toHaveBeenCalledWith({ - body: { - name: 'Research Agent', - description: 'Find and summarize market materials.', - role: 'Research Assistant', - icon_type: 'emoji', - icon: '🧸', - icon_background: '#F5F3FF', + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + body: { + name: 'Research Agent', + description: 'Find and summarize market materials.', + role: 'Research Assistant', + icon_type: 'emoji', + icon: '🧸', + icon_background: '#F5F3FF', + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] expect(mutationOptions).not.toHaveProperty('onError') }) @@ -82,7 +94,10 @@ describe('CreateAgentDialog', () => { await user.click(screen.getByRole('button', { name: /agentV2\.roster\.createAgent/ })) const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), 'Research Agent') + await user.type( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + 'Research Agent', + ) await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' })) const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] @@ -101,10 +116,15 @@ describe('CreateAgentDialog', () => { await user.click(screen.getByRole('button', { name: /agentV2\.roster\.createAgent/ })) const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) - await user.type(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), 'Research Assistant') + await user.type( + within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), + 'Research Assistant', + ) await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' })) - expect(await within(dialog).findByText('agentV2.roster.createForm.nameRequired')).toBeInTheDocument() + expect( + await within(dialog).findByText('agentV2.roster.createForm.nameRequired'), + ).toBeInTheDocument() expect(toastMock.error).not.toHaveBeenCalled() expect(mutationMock.mutate).not.toHaveBeenCalled() }) @@ -117,12 +137,16 @@ describe('CreateAgentDialog', () => { const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) - expect(within(dialog).getByRole('textbox', { - name: /agentV2\.roster\.createForm\.roleLabel.*common\.label\.optional/, - })).not.toBeRequired() - expect(within(dialog).getByRole('textbox', { - name: /agentV2\.roster\.createForm\.descriptionLabel.*common\.label\.optional/, - })).not.toBeRequired() + expect( + within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.roleLabel.*common\.label\.optional/, + }), + ).not.toBeRequired() + expect( + within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.descriptionLabel.*common\.label\.optional/, + }), + ).not.toBeRequired() }) it('submits an empty role when role is left blank', async () => { @@ -132,21 +156,27 @@ describe('CreateAgentDialog', () => { await user.click(screen.getByRole('button', { name: /agentV2\.roster\.createAgent/ })) const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), 'Research Agent') + await user.type( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + 'Research Agent', + ) await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' })) - expect(mutationMock.mutate).toHaveBeenCalledWith({ - body: { - name: 'Research Agent', - description: '', - role: '', - icon_type: 'emoji', - icon: '🧸', - icon_background: '#F5F3FF', + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + body: { + name: 'Research Agent', + description: '', + role: '', + icon_type: 'emoji', + icon: '🧸', + icon_background: '#F5F3FF', + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) }) it('keeps the form open when the backdrop is clicked', async () => { @@ -163,7 +193,9 @@ describe('CreateAgentDialog', () => { await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => { - expect(screen.queryByRole('dialog', { name: 'agentV2.roster.createDialog.title' })).not.toBeInTheDocument() + expect( + screen.queryByRole('dialog', { name: 'agentV2.roster.createDialog.title' }), + ).not.toBeInTheDocument() }) }) }) diff --git a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx index 84c6c4c0bec213..9087b190deffd6 100644 --- a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx @@ -30,18 +30,17 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({ onSelect, open, }: { - onSelect: (payload: { type: 'emoji', icon: string, background: string }) => void + onSelect: (payload: { type: 'emoji'; icon: string; background: string }) => void open: boolean - }) => open - ? ( - <button - type="button" - onClick={() => onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })} - > - Select brain icon - </button> - ) - : null, + }) => + open ? ( + <button + type="button" + onClick={() => onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })} + > + Select brain icon + </button> + ) : null, })) vi.mock('@/service/client', () => ({ @@ -72,14 +71,7 @@ const createAgent = (overrides: Partial<AgentAppPartial> = {}): AgentAppPartial const renderDialog = (agent = createAgent()) => { const onOpenChange = vi.fn() - render( - <EditAgentDialog - agent={agent} - formKey={0} - open - onOpenChange={onOpenChange} - />, - ) + render(<EditAgentDialog agent={agent} formKey={0} open onOpenChange={onOpenChange} />) return { onOpenChange } } @@ -95,25 +87,33 @@ describe('EditAgentDialog', () => { renderDialog() const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - await user.clear(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' })) - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), ' Market Agent ') + await user.clear( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ) + await user.type( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ' Market Agent ', + ) await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) - expect(mutationMock.mutate).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - body: { - name: 'Market Agent', - description: 'Find and summarize market materials.', - role: 'Research Assistant', - icon_type: 'emoji', - icon: '🧸', - icon_background: '#F5F3FF', + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + body: { + name: 'Market Agent', + description: 'Find and summarize market materials.', + role: 'Research Assistant', + icon_type: 'emoji', + icon: '🧸', + icon_background: '#F5F3FF', + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] expect(mutationOptions).not.toHaveProperty('onError') }) @@ -123,25 +123,33 @@ describe('EditAgentDialog', () => { renderDialog() const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - await user.clear(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ })) - await user.type(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), ' Market Analyst ') + await user.clear( + within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), + ) + await user.type( + within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), + ' Market Analyst ', + ) await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) - expect(mutationMock.mutate).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - body: { - name: 'Research Agent', - description: 'Find and summarize market materials.', - role: 'Market Analyst', - icon_type: 'emoji', - icon: '🧸', - icon_background: '#F5F3FF', + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + body: { + name: 'Research Agent', + description: 'Find and summarize market materials.', + role: 'Market Analyst', + icon_type: 'emoji', + icon: '🧸', + icon_background: '#F5F3FF', + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] expect(mutationOptions).not.toHaveProperty('onError') }) @@ -155,21 +163,24 @@ describe('EditAgentDialog', () => { await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' })) await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) - expect(mutationMock.mutate).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - body: { - name: 'Research Agent', - description: 'Find and summarize market materials.', - role: 'Research Assistant', - icon_type: 'emoji', - icon: '🧠', - icon_background: '#E0F2FE', + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + body: { + name: 'Research Agent', + description: 'Find and summarize market materials.', + role: 'Research Assistant', + icon_type: 'emoji', + icon: '🧠', + icon_background: '#E0F2FE', + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] expect(mutationOptions).not.toHaveProperty('onError') }) @@ -179,13 +190,17 @@ describe('EditAgentDialog', () => { renderDialog() const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - await user.clear(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' })) + await user.clear( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ) const saveButton = within(dialog).getByRole('button', { name: 'common.operation.save' }) expect(saveButton).not.toBeDisabled() await user.click(saveButton) - expect(await within(dialog).findByText('agentV2.roster.createForm.nameRequired')).toBeInTheDocument() + expect( + await within(dialog).findByText('agentV2.roster.createForm.nameRequired'), + ).toBeInTheDocument() expect(toastMock.error).not.toHaveBeenCalled() expect(mutationMock.mutate).not.toHaveBeenCalled() }) @@ -195,12 +210,16 @@ describe('EditAgentDialog', () => { const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - expect(within(dialog).getByRole('textbox', { - name: /agentV2\.roster\.createForm\.roleLabel.*common\.label\.optional/, - })).not.toBeRequired() - expect(within(dialog).getByRole('textbox', { - name: /agentV2\.roster\.createForm\.descriptionLabel.*common\.label\.optional/, - })).not.toBeRequired() + expect( + within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.roleLabel.*common\.label\.optional/, + }), + ).not.toBeRequired() + expect( + within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.descriptionLabel.*common\.label\.optional/, + }), + ).not.toBeRequired() }) it('submits an empty role when the role is cleared', async () => { @@ -208,27 +227,32 @@ describe('EditAgentDialog', () => { renderDialog() const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - await user.clear(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ })) + await user.clear( + within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), + ) const saveButton = within(dialog).getByRole('button', { name: 'common.operation.save' }) expect(saveButton).not.toBeDisabled() await user.click(saveButton) - expect(mutationMock.mutate).toHaveBeenCalledWith({ - params: { - agent_id: 'agent-1', - }, - body: { - name: 'Research Agent', - description: 'Find and summarize market materials.', - role: '', - icon_type: 'emoji', - icon: '🧸', - icon_background: '#F5F3FF', + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + body: { + name: 'Research Agent', + description: 'Find and summarize market materials.', + role: '', + icon_type: 'emoji', + icon: '🧸', + icon_background: '#F5F3FF', + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) }) it('keeps the form open when the backdrop is clicked', async () => { diff --git a/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx b/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx index 33763116c92c04..b3366f6bc6518c 100644 --- a/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx @@ -19,10 +19,7 @@ const renderToolbar = ({ return renderWithNuqs( <QueryClientProvider client={queryClient}> - <RosterToolbar - draftAgents={2} - publishedAgents={1} - /> + <RosterToolbar draftAgents={2} publishedAgents={1} /> </QueryClientProvider>, { searchParams }, ) @@ -33,7 +30,9 @@ describe('RosterToolbar', () => { const user = userEvent.setup() const { onUrlUpdate } = renderToolbar() - const publishedFilter = screen.getByRole('button', { name: /agentV2\.roster\.filters\.published/ }) + const publishedFilter = screen.getByRole('button', { + name: /agentV2\.roster\.filters\.published/, + }) const draftsFilter = screen.getByRole('button', { name: /agentV2\.roster\.filters\.drafts/ }) expect(publishedFilter).toBeEnabled() @@ -41,16 +40,20 @@ describe('RosterToolbar', () => { await user.click(publishedFilter) - expect(onUrlUpdate).toHaveBeenCalledWith(expect.objectContaining({ - queryString: '?filter=published', - })) + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: '?filter=published', + }), + ) }) it('renders stable filter count badges and omits the all count', () => { renderToolbar() const allFilter = screen.getByRole('button', { name: /agentV2\.roster\.filters\.all/ }) - const publishedFilter = screen.getByRole('button', { name: /agentV2\.roster\.filters\.published/ }) + const publishedFilter = screen.getByRole('button', { + name: /agentV2\.roster\.filters\.published/, + }) const draftsFilter = screen.getByRole('button', { name: /agentV2\.roster\.filters\.drafts/ }) expect(allFilter).not.toHaveTextContent('3') @@ -62,15 +65,19 @@ describe('RosterToolbar', () => { const user = userEvent.setup() const { onUrlUpdate } = renderToolbar() - const createdByMeFilter = screen.getByRole('checkbox', { name: 'agentV2.roster.filters.createdByMe' }) + const createdByMeFilter = screen.getByRole('checkbox', { + name: 'agentV2.roster.filters.createdByMe', + }) expect(createdByMeFilter).toHaveAttribute('aria-checked', 'false') await user.click(createdByMeFilter) - expect(onUrlUpdate).toHaveBeenCalledWith(expect.objectContaining({ - queryString: '?created_by_me=true', - })) + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: '?created_by_me=true', + }), + ) }) it('renders sort options and emits the selected sort strategy', async () => { @@ -82,10 +89,14 @@ describe('RosterToolbar', () => { expect(screen.getByText('agentV2.roster.sort.lastModified')).toBeInTheDocument() await user.click(sortSelect) - await user.click(await screen.findByRole('option', { name: 'agentV2.roster.sort.recentlyCreated' })) - - expect(onUrlUpdate).toHaveBeenCalledWith(expect.objectContaining({ - queryString: '?sort_by=recently_created', - })) + await user.click( + await screen.findByRole('option', { name: 'agentV2.roster.sort.recentlyCreated' }), + ) + + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: '?sort_by=recently_created', + }), + ) }) }) diff --git a/web/features/agent-v2/roster/components/agent-form-fields.tsx b/web/features/agent-v2/roster/components/agent-form-fields.tsx index 53114b9647caba..b1c271cb377e49 100644 --- a/web/features/agent-v2/roster/components/agent-form-fields.tsx +++ b/web/features/agent-v2/roster/components/agent-form-fields.tsx @@ -55,44 +55,41 @@ export function AgentFormFields({ className="relative min-w-0 flex-1" validate={(value) => { if (typeof value === 'string' && value.length > 0 && !value.trim()) - return t($ => $['roster.createForm.nameRequired']) + return t(($) => $['roster.createForm.nameRequired']) return null }} > - <FieldLabel> - {t($ => $['roster.createForm.nameLabel'])} - </FieldLabel> + <FieldLabel>{t(($) => $['roster.createForm.nameLabel'])}</FieldLabel> <FieldControl autoComplete="off" // eslint-disable-next-line jsx-a11y/no-autofocus -- Agent roster dialogs open from explicit commands, and the name field is the primary editable control. autoFocus maxLength={255} onValueChange={onNameChange} - placeholder={t($ => $['roster.createForm.namePlaceholder'])} + placeholder={t(($) => $['roster.createForm.namePlaceholder'])} required value={name} /> <div className="absolute top-full left-0 mt-1"> - <FieldError match="valueMissing">{t($ => $['roster.createForm.nameRequired'])}</FieldError> + <FieldError match="valueMissing"> + {t(($) => $['roster.createForm.nameRequired'])} + </FieldError> <FieldError match="customError" /> </div> </Field> - <Field - name="role" - className="relative min-w-0 flex-1" - > + <Field name="role" className="relative min-w-0 flex-1"> <FieldLabel> - {t($ => $['roster.createForm.roleLabel'])} + {t(($) => $['roster.createForm.roleLabel'])} <span className="ml-1 system-xs-regular text-text-tertiary"> - {tCommon($ => $['label.optional'])} + {tCommon(($) => $['label.optional'])} </span> </FieldLabel> <FieldControl autoComplete="off" maxLength={255} onValueChange={onRoleChange} - placeholder={t($ => $['roster.createForm.rolePlaceholder'])} + placeholder={t(($) => $['roster.createForm.rolePlaceholder'])} value={role} /> </Field> @@ -100,16 +97,16 @@ export function AgentFormFields({ </div> <Field name="description"> <FieldLabel> - {t($ => $['roster.createForm.descriptionLabel'])} + {t(($) => $['roster.createForm.descriptionLabel'])} <span className="ml-1 system-xs-regular text-text-tertiary"> - {tCommon($ => $['label.optional'])} + {tCommon(($) => $['label.optional'])} </span> </FieldLabel> <Textarea autoComplete="off" className="h-20 resize-none" onValueChange={onDescriptionChange} - placeholder={t($ => $['roster.createForm.descriptionPlaceholder'])} + placeholder={t(($) => $['roster.createForm.descriptionPlaceholder'])} value={description} /> </Field> diff --git a/web/features/agent-v2/roster/components/agent-form.ts b/web/features/agent-v2/roster/components/agent-form.ts index 93ec852d776560..f726759d5dd54a 100644 --- a/web/features/agent-v2/roster/components/agent-form.ts +++ b/web/features/agent-v2/roster/components/agent-form.ts @@ -6,11 +6,13 @@ export type AgentFormValues = { role?: string } -export type AgentIconSelection = AppIconSelection | { - type: 'link' - icon: string - url: string -} +export type AgentIconSelection = + | AppIconSelection + | { + type: 'link' + icon: string + url: string + } export const defaultAgentIcon = { type: 'emoji', @@ -49,11 +51,9 @@ export const createAgentIconSelection = (agent: AgentIconSource): AgentIconSelec } export const getAgentIconKey = (icon: AgentIconSelection) => { - if (icon.type === 'emoji') - return `${icon.type}:${icon.icon}:${icon.background}` + if (icon.type === 'emoji') return `${icon.type}:${icon.icon}:${icon.background}` - if (icon.type === 'image') - return `${icon.type}:${icon.fileId}` + if (icon.type === 'image') return `${icon.type}:${icon.fileId}` return `${icon.type}:${icon.icon}` } diff --git a/web/features/agent-v2/roster/components/agent-roster-list.tsx b/web/features/agent-v2/roster/components/agent-roster-list.tsx index 60319bb79efc24..94641ca2e79ffc 100644 --- a/web/features/agent-v2/roster/components/agent-roster-list.tsx +++ b/web/features/agent-v2/roster/components/agent-roster-list.tsx @@ -33,13 +33,19 @@ type AgentRosterListProps = { } const skeletonRows = ['primary', 'secondary', 'tertiary'] as const -const emptyPlaceholderCardIds = Array.from({ length: 16 }, (_, index) => `agent-roster-placeholder-card-${index}`) +const emptyPlaceholderCardIds = Array.from( + { length: 16 }, + (_, index) => `agent-roster-placeholder-card-${index}`, +) function AgentRosterSkeleton() { return ( <> - {skeletonRows.map(row => ( - <div key={row} className="relative h-36.5 rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3"> + {skeletonRows.map((row) => ( + <div + key={row} + className="relative h-36.5 rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3" + > <div className="flex items-center gap-3 pt-3.5 pr-4 pb-2 pl-3.5"> <SkeletonRectangle className="my-0 size-12 shrink-0 rounded-full opacity-20" /> <div className="flex min-w-0 flex-1 flex-col gap-1.5 py-1"> @@ -70,7 +76,7 @@ function AgentRosterPlaceholderState({ title }: { title: string }) { className="relative col-span-full min-h-[calc(100vh-142px)] overflow-hidden" > <div className="pointer-events-none absolute inset-0 grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] grid-rows-4 gap-3"> - {emptyPlaceholderCardIds.map(id => ( + {emptyPlaceholderCardIds.map((id) => ( <div key={id} className="rounded-xl bg-background-default-lighter opacity-75" /> ))} </div> @@ -82,7 +88,10 @@ function AgentRosterPlaceholderState({ title }: { title: string }) { <span aria-hidden className="i-ri-robot-2-line size-6 text-text-tertiary" /> </div> </div> - <h2 id="agent-roster-placeholder-title" className="system-sm-regular whitespace-nowrap text-text-tertiary"> + <h2 + id="agent-roster-placeholder-title" + className="system-sm-regular whitespace-nowrap text-text-tertiary" + > {title} </h2> </div> @@ -91,11 +100,7 @@ function AgentRosterPlaceholderState({ title }: { title: string }) { ) } -function AgentRosterItem({ - agent, -}: { - agent: AgentAppPartial -}) { +function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const { formatTime } = useTimestamp() @@ -106,23 +111,28 @@ function AgentRosterItem({ const [isDuplicateOpen, setIsDuplicateOpen] = useState(false) const [duplicateSessionKey, setDuplicateSessionKey] = useState(0) const [isDeleteOpen, setIsDeleteOpen] = useState(false) - const updatedAt = agent.updated_at != null - ? formatTime(agent.updated_at, t($ => $['roster.dateTimeFormat'])) - : null + const updatedAt = + agent.updated_at != null + ? formatTime( + agent.updated_at, + t(($) => $['roster.dateTimeFormat']), + ) + : null const referenceCount = agent.published_reference_count ?? 0 const publishedReferences = agent.published_references ?? [] const hasPublishedReferences = publishedReferences.length > 0 const isDraft = agent.active_config_is_published !== true - const imageUrl = (agent.icon_type === 'image' || agent.icon_type === 'link') ? agent.icon : undefined + const imageUrl = + agent.icon_type === 'image' || agent.icon_type === 'link' ? agent.icon : undefined const iconType = (imageUrl ? 'image' : agent.icon_type) as AgentIconType | null | undefined const handleEditOpen = () => { - setEditSessionKey(key => key + 1) + setEditSessionKey((key) => key + 1) setIsEditOpen(true) } const handleDuplicateOpen = () => { - setDuplicateSessionKey(key => key + 1) + setDuplicateSessionKey((key) => key + 1) setIsDuplicateOpen(true) } @@ -150,9 +160,7 @@ function AgentRosterItem({ <h2 id={nameId} className="truncate system-md-semibold text-text-secondary"> {agent.name} </h2> - <p className="truncate system-xs-regular text-text-tertiary"> - {agent.role} - </p> + <p className="truncate system-xs-regular text-text-tertiary">{agent.role}</p> </div> </div> <div className="px-4 py-1 system-xs-regular text-text-tertiary"> @@ -164,58 +172,61 @@ function AgentRosterItem({ <div className="absolute top-[-0.5px] right-0 flex h-5 items-start overflow-hidden"> <div className="h-5 w-3 bg-background-section-burn [clip-path:polygon(0_0,100%_0,100%_100%)]" /> <div className="flex h-5 items-center bg-background-section-burn pr-2 pl-0.5 system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['roster.usageStatus.draft'])} + {t(($) => $['roster.usageStatus.draft'])} </div> </div> )} </Link> <div className="flex min-w-0 shrink-0 items-center pt-2 pr-3 pb-3 pl-4 system-xs-regular text-text-tertiary"> <div className="flex min-w-0 flex-1 items-center gap-1.5"> - {hasPublishedReferences - ? ( - <AgentWorkflowReferencesDropdown - agentName={agent.name} - publishedReferences={publishedReferences} - referenceCount={referenceCount} - /> - ) - : ( - <div className="flex h-4 shrink-0 items-center gap-1"> - <span aria-hidden className="i-custom-vender-agent-v2-plan size-3 shrink-0 text-text-tertiary" /> - <span className="system-xs-regular text-text-tertiary">{referenceCount}</span> - </div> - )} + {hasPublishedReferences ? ( + <AgentWorkflowReferencesDropdown + agentName={agent.name} + publishedReferences={publishedReferences} + referenceCount={referenceCount} + /> + ) : ( + <div className="flex h-4 shrink-0 items-center gap-1"> + <span + aria-hidden + className="i-custom-vender-agent-v2-plan size-3 shrink-0 text-text-tertiary" + /> + <span className="system-xs-regular text-text-tertiary">{referenceCount}</span> + </div> + )} {updatedAt && ( <> - <span aria-hidden className="shrink-0 text-text-quaternary">·</span> + <span aria-hidden className="shrink-0 text-text-quaternary"> + · + </span> <span className="min-w-0 truncate">{updatedAt}</span> </> )} </div> </div> </div> - <div - className="pointer-events-none absolute top-2 right-2 z-20 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 opacity-0 shadow-lg backdrop-blur-xs transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 has-data-popup-open:pointer-events-auto has-data-popup-open:opacity-100" - > + <div className="pointer-events-none absolute top-2 right-2 z-20 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 opacity-0 shadow-lg backdrop-blur-xs transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 has-data-popup-open:pointer-events-auto has-data-popup-open:opacity-100"> <DropdownMenu modal={false}> <DropdownMenuTrigger - aria-label={t($ => $['roster.moreActions'], { name: agent.name })} + aria-label={t(($) => $['roster.moreActions'], { name: agent.name })} className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1.5 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" > - <span className="sr-only">{t($ => $['roster.moreActions'], { name: agent.name })}</span> + <span className="sr-only"> + {t(($) => $['roster.moreActions'], { name: agent.name })} + </span> <span aria-hidden className="i-ri-more-fill size-4.5 text-text-tertiary" /> </DropdownMenuTrigger> <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-40"> <DropdownMenuItem className="gap-2" onClick={handleEditOpen}> <span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" /> - <span>{t($ => $['roster.editInfo'])}</span> + <span>{t(($) => $['roster.editInfo'])}</span> </DropdownMenuItem> - <DropdownMenuItem - className="gap-2" - onClick={handleDuplicateOpen} - > - <span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" /> - <span>{tCommon($ => $['operation.duplicate'])}</span> + <DropdownMenuItem className="gap-2" onClick={handleDuplicateOpen}> + <span + aria-hidden + className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" + /> + <span>{tCommon(($) => $['operation.duplicate'])}</span> </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem @@ -224,7 +235,7 @@ function AgentRosterItem({ onClick={() => setIsDeleteOpen(true)} > <span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" /> - <span>{tCommon($ => $['operation.delete'])}</span> + <span>{tCommon(($) => $['operation.delete'])}</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> @@ -241,7 +252,12 @@ function AgentRosterItem({ open={isDuplicateOpen} onOpenChange={setIsDuplicateOpen} /> - <DeleteAgentDialog agentId={agent.id} agentName={agent.name} open={isDeleteOpen} onOpenChange={setIsDeleteOpen} /> + <DeleteAgentDialog + agentId={agent.id} + agentName={agent.name} + open={isDeleteOpen} + onOpenChange={setIsDeleteOpen} + /> </article> ) } @@ -260,25 +276,27 @@ export function AgentRosterList({ const { t } = useTranslation('agentV2') return ( - <section aria-label={label} className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5" aria-busy={isFetching || undefined}> + <section + aria-label={label} + className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5" + aria-busy={isFetching || undefined} + > {isPending && <AgentRosterSkeleton />} {!isPending && isError && ( - <AgentRosterPlaceholderState title={t($ => $['roster.loadingError'])} /> + <AgentRosterPlaceholderState title={t(($) => $['roster.loadingError'])} /> )} {!isPending && !isError && agents.length === 0 && ( - <AgentRosterPlaceholderState title={isEmptySearch ? t($ => $['roster.emptySearch']) : t($ => $['roster.empty'])} /> + <AgentRosterPlaceholderState + title={isEmptySearch ? t(($) => $['roster.emptySearch']) : t(($) => $['roster.empty'])} + /> )} - {!isPending && !isError && agents.map(agent => ( - <AgentRosterItem key={agent.id} agent={agent} /> - ))} + {!isPending && + !isError && + agents.map((agent) => <AgentRosterItem key={agent.id} agent={agent} />)} {!isPending && !isError && hasMore && ( <div className="col-span-full flex justify-center pt-1"> - <Button - loading={isFetchingNextPage} - disabled={isFetchingNextPage} - onClick={onLoadMore} - > - {t($ => $['roster.loadMore'])} + <Button loading={isFetchingNextPage} disabled={isFetchingNextPage} onClick={onLoadMore}> + {t(($) => $['roster.loadMore'])} </Button> </div> )} diff --git a/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx b/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx index 498589c74cd987..6e31ca4ac17fc1 100644 --- a/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx +++ b/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx @@ -1,6 +1,9 @@ 'use client' -import type { AgentAppPublishedReferenceResponse, AgentIconType } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentAppPublishedReferenceResponse, + AgentIconType, +} from '@dify/contracts/api/console/agent/types.gen' import { DropdownMenu, DropdownMenuContent, @@ -11,14 +14,15 @@ import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Link from '@/next/link' -const getWorkflowReferenceHref = (reference: AgentAppPublishedReferenceResponse) => `/app/${reference.app_id}/workflow` +const getWorkflowReferenceHref = (reference: AgentAppPublishedReferenceResponse) => + `/app/${reference.app_id}/workflow` -const getWorkflowReferenceIconType = (reference: AgentAppPublishedReferenceResponse): AgentIconType | undefined => { - if (reference.app_icon_type === 'image' || reference.app_icon_type === 'link') - return 'image' +const getWorkflowReferenceIconType = ( + reference: AgentAppPublishedReferenceResponse, +): AgentIconType | undefined => { + if (reference.app_icon_type === 'image' || reference.app_icon_type === 'link') return 'image' - if (reference.app_icon_type === 'emoji') - return 'emoji' + if (reference.app_icon_type === 'emoji') return 'emoji' return undefined } @@ -44,20 +48,32 @@ export function AgentWorkflowReferencesDropdown({ return ( <DropdownMenu modal={false}> <DropdownMenuTrigger - aria-label={t($ => $['roster.references.trigger'], { name: agentName, count: referenceCount })} + aria-label={t(($) => $['roster.references.trigger'], { + name: agentName, + count: referenceCount, + })} className="relative flex h-4 shrink-0 cursor-pointer items-center gap-1 rounded-md outline-hidden before:pointer-events-none before:absolute before:-inset-x-1 before:-inset-y-0.5 before:rounded-md before:content-[''] hover:before:bg-state-base-hover focus-visible:before:ring-2 focus-visible:before:ring-state-accent-solid data-popup-open:before:bg-state-base-hover" > - <span aria-hidden className="i-custom-vender-agent-v2-plan size-3 shrink-0 text-text-tertiary" /> + <span + aria-hidden + className="i-custom-vender-agent-v2-plan size-3 shrink-0 text-text-tertiary" + /> <span className="system-xs-regular text-text-tertiary">{referenceCount}</span> </DropdownMenuTrigger> <DropdownMenuContent placement="bottom-start" sideOffset={4} popupClassName="w-[264px] p-1"> <div className="flex h-7.5 items-center px-2 system-xs-medium text-text-tertiary"> - {t($ => $['roster.references.label'], { name: agentName })} + {t(($) => $['roster.references.label'], { name: agentName })} </div> - {publishedReferences.map(reference => ( + {publishedReferences.map((reference) => ( <DropdownMenuLinkItem key={reference.app_id} - render={<Link href={getWorkflowReferenceHref(reference)} target="_blank" rel="noopener noreferrer" />} + render={ + <Link + href={getWorkflowReferenceHref(reference)} + target="_blank" + rel="noopener noreferrer" + /> + } className="mx-0 h-8 gap-2 px-2 py-1 pr-2.5 system-md-regular text-text-secondary" > <span aria-hidden className="shrink-0"> @@ -70,7 +86,10 @@ export function AgentWorkflowReferencesDropdown({ /> </span> <span className="min-w-0 flex-1 truncate">{reference.app_name}</span> - <span aria-hidden className="i-ri-external-link-line size-3 shrink-0 text-text-tertiary" /> + <span + aria-hidden + className="i-ri-external-link-line size-3 shrink-0 text-text-tertiary" + /> </DropdownMenuLinkItem> ))} </DropdownMenuContent> diff --git a/web/features/agent-v2/roster/components/create-agent-dialog.tsx b/web/features/agent-v2/roster/components/create-agent-dialog.tsx index dc00828418069a..2872a5f8d540ba 100644 --- a/web/features/agent-v2/roster/components/create-agent-dialog.tsx +++ b/web/features/agent-v2/roster/components/create-agent-dialog.tsx @@ -3,7 +3,14 @@ import type { AgentAppCreatePayload } from '@dify/contracts/api/console/agent/types.gen' import type { AgentFormValues, AgentIconSelection } from './agent-form' import { Button } from '@langgenius/dify-ui/button' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '@langgenius/dify-ui/dialog' import { Form } from '@langgenius/dify-ui/form' import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' @@ -30,7 +37,7 @@ export function CreateAgentDialog() { const createAgentMutation = useMutation(consoleQuery.agent.post.mutationOptions()) const resetForm = () => { - setFormKey(key => key + 1) + setFormKey((key) => key + 1) setName('') setDescription('') setRole('') @@ -40,15 +47,13 @@ export function CreateAgentDialog() { const handleOpenChange = (nextOpen: boolean) => { setOpen(nextOpen) - if (!nextOpen) - resetForm() + if (!nextOpen) resetForm() } const handleSubmit = (formValues: AgentFormValues) => { const trimmedName = formValues.name?.trim() ?? '' const trimmedRole = formValues.role?.trim() ?? '' - if (createAgentMutation.isPending) - return + if (createAgentMutation.isPending) return const body = { name: trimmedName, @@ -59,39 +64,35 @@ export function CreateAgentDialog() { icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined, } satisfies AgentAppCreatePayload - createAgentMutation.mutate({ - body, - }, { - onSuccess: (createdAgent) => { - toast.success(t($ => $['roster.createSuccess'])) - handleOpenChange(false) - router.push(getAgentDetailPath(createdAgent.id, 'configure')) + createAgentMutation.mutate( + { + body, }, - }) + { + onSuccess: (createdAgent) => { + toast.success(t(($) => $['roster.createSuccess'])) + handleOpenChange(false) + router.push(getAgentDetailPath(createdAgent.id, 'configure')) + }, + }, + ) } return ( <> <Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal> - <DialogTrigger - render={( - <Button - variant="primary" - className="h-8 gap-0.5 px-3" - /> - )} - > + <DialogTrigger render={<Button variant="primary" className="h-8 gap-0.5 px-3" />}> <span aria-hidden className="i-ri-add-line size-4" /> - <span className="px-0.5 system-sm-medium">{t($ => $['roster.createAgent'])}</span> + <span className="px-0.5 system-sm-medium">{t(($) => $['roster.createAgent'])}</span> </DialogTrigger> <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[520px] flex-col overflow-hidden! p-0!"> <DialogCloseButton /> <div className="shrink-0 pt-6 pr-14 pb-3 pl-6"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['roster.createDialog.title'])} + {t(($) => $['roster.createDialog.title'])} </DialogTitle> <DialogDescription className="sr-only"> - {t($ => $['roster.createDialog.description'])} + {t(($) => $['roster.createDialog.description'])} </DialogDescription> </div> <Form<AgentFormValues> @@ -102,7 +103,7 @@ export function CreateAgentDialog() { <AgentFormFields description={description} icon={agentIcon} - iconAriaLabel={t($ => $['roster.createForm.changeIcon'])} + iconAriaLabel={t(($) => $['roster.createForm.changeIcon'])} name={name} role={role} onDescriptionChange={setDescription} @@ -111,8 +112,13 @@ export function CreateAgentDialog() { onRoleChange={setRole} /> <div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6"> - <Button type="button" className="min-w-18" onClick={() => handleOpenChange(false)} disabled={createAgentMutation.isPending}> - {tCommon($ => $['operation.cancel'])} + <Button + type="button" + className="min-w-18" + onClick={() => handleOpenChange(false)} + disabled={createAgentMutation.isPending} + > + {tCommon(($) => $['operation.cancel'])} </Button> <Button type="submit" @@ -120,7 +126,7 @@ export function CreateAgentDialog() { className="min-w-18" loading={createAgentMutation.isPending} > - {tCommon($ => $['operation.create'])} + {tCommon(($) => $['operation.create'])} </Button> </div> </Form> @@ -128,9 +134,11 @@ export function CreateAgentDialog() { </Dialog> <AppIconPicker open={iconPickerOpen} - initialEmoji={agentIcon.type === 'emoji' - ? { icon: agentIcon.icon, background: agentIcon.background } - : undefined} + initialEmoji={ + agentIcon.type === 'emoji' + ? { icon: agentIcon.icon, background: agentIcon.background } + : undefined + } onOpenChange={setIconPickerOpen} onSelect={setAgentIcon} /> diff --git a/web/features/agent-v2/roster/components/delete-agent-dialog.tsx b/web/features/agent-v2/roster/components/delete-agent-dialog.tsx index 216e8fa314a172..3763d61dd81bf3 100644 --- a/web/features/agent-v2/roster/components/delete-agent-dialog.tsx +++ b/web/features/agent-v2/roster/components/delete-agent-dialog.tsx @@ -32,43 +32,45 @@ export function DeleteAgentDialog({ const deleteAgentMutation = useMutation(consoleQuery.agent.byAgentId.delete.mutationOptions()) const handleDelete = () => { - if (deleteAgentMutation.isPending) - return + if (deleteAgentMutation.isPending) return - deleteAgentMutation.mutate({ - params: { - agent_id: agentId, + deleteAgentMutation.mutate( + { + params: { + agent_id: agentId, + }, }, - }, { - onSuccess: () => { - toast.success(t($ => $['roster.deleteSuccess'])) - onOpenChange(false) + { + onSuccess: () => { + toast.success(t(($) => $['roster.deleteSuccess'])) + onOpenChange(false) + }, + onError: () => { + toast.error(t(($) => $['roster.deleteFailed'])) + }, }, - onError: () => { - toast.error(t($ => $['roster.deleteFailed'])) - }, - }) + ) } return ( <AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialogContent className="p-6"> <AlertDialogTitle className="truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['roster.deleteDialog.title'], { name: agentName })} + {t(($) => $['roster.deleteDialog.title'], { name: agentName })} </AlertDialogTitle> <AlertDialogDescription className="mt-2 system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['roster.deleteDialog.description'])} + {t(($) => $['roster.deleteDialog.description'])} </AlertDialogDescription> <AlertDialogActions className="p-0 pt-6"> <AlertDialogCancelButton disabled={deleteAgentMutation.isPending}> - {tCommon($ => $['operation.cancel'])} + {tCommon(($) => $['operation.cancel'])} </AlertDialogCancelButton> <AlertDialogConfirmButton tone="destructive" loading={deleteAgentMutation.isPending} onClick={handleDelete} > - {tCommon($ => $['operation.delete'])} + {tCommon(($) => $['operation.delete'])} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx b/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx index 61bf996a641c69..745c60a4fd2023 100644 --- a/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx +++ b/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx @@ -1,9 +1,18 @@ 'use client' -import type { AgentAppCopyPayload, AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentAppCopyPayload, + AgentAppPartial, +} from '@dify/contracts/api/console/agent/types.gen' import type { AgentFormValues, AgentIconSelection } from './agent-form' import { Button } from '@langgenius/dify-ui/button' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { Field, FieldControl, FieldLabel } from '@langgenius/dify-ui/field' import { Form } from '@langgenius/dify-ui/form' import { Textarea } from '@langgenius/dify-ui/textarea' @@ -37,20 +46,27 @@ export function DuplicateAgentDialog({ const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const queryClient = useQueryClient() - const latestAgent = queryClient.getQueryData<AgentAppPartial>(consoleQuery.agent.byAgentId.get.queryKey({ - input: { - params: { - agent_id: agent.id, - }, - }, - })) ?? agent + const latestAgent = + queryClient.getQueryData<AgentAppPartial>( + consoleQuery.agent.byAgentId.get.queryKey({ + input: { + params: { + agent_id: agent.id, + }, + }, + }), + ) ?? agent const [renderedFormKey, setRenderedFormKey] = useState(formKey) const [name, setName] = useState('') const [description, setDescription] = useState(latestAgent.description ?? '') const [role, setRole] = useState(latestAgent.role ?? '') const [iconPickerOpen, setIconPickerOpen] = useState(false) - const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => createAgentIconSelection(latestAgent)) - const duplicateAgentMutation = useMutation(consoleQuery.agent.byAgentId.copy.post.mutationOptions()) + const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => + createAgentIconSelection(latestAgent), + ) + const duplicateAgentMutation = useMutation( + consoleQuery.agent.byAgentId.copy.post.mutationOptions(), + ) const defaultCopyName = getDefaultCopyName(latestAgent.name) if (formKey !== renderedFormKey) { @@ -64,27 +80,28 @@ export function DuplicateAgentDialog({ const handleOpenChange = (nextOpen: boolean) => { if (nextOpen) { - const currentAgent = queryClient.getQueryData<AgentAppPartial>(consoleQuery.agent.byAgentId.get.queryKey({ - input: { - params: { - agent_id: agent.id, - }, - }, - })) ?? agent + const currentAgent = + queryClient.getQueryData<AgentAppPartial>( + consoleQuery.agent.byAgentId.get.queryKey({ + input: { + params: { + agent_id: agent.id, + }, + }, + }), + ) ?? agent setName('') setDescription(currentAgent.description ?? '') setRole(currentAgent.role ?? '') setAgentIcon(createAgentIconSelection(currentAgent)) - } - else { + } else { setIconPickerOpen(false) } onOpenChange(nextOpen) } const handleSubmit = (formValues: AgentFormValues) => { - if (duplicateAgentMutation.isPending) - return + if (duplicateAgentMutation.isPending) return const trimmedName = formValues.name?.trim() ?? '' const trimmedRole = formValues.role?.trim() ?? '' @@ -97,17 +114,20 @@ export function DuplicateAgentDialog({ ...(trimmedName ? { name: trimmedName } : {}), } - duplicateAgentMutation.mutate({ - params: { - agent_id: agent.id, + duplicateAgentMutation.mutate( + { + params: { + agent_id: agent.id, + }, + body, }, - body, - }, { - onSuccess: () => { - toast.success(t($ => $['roster.duplicateSuccess'])) - handleOpenChange(false) + { + onSuccess: () => { + toast.success(t(($) => $['roster.duplicateSuccess'])) + handleOpenChange(false) + }, }, - }) + ) } return ( @@ -117,10 +137,10 @@ export function DuplicateAgentDialog({ <DialogCloseButton /> <div className="shrink-0 pt-6 pr-14 pb-3 pl-6"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['roster.duplicateDialog.title'])} + {t(($) => $['roster.duplicateDialog.title'])} </DialogTitle> <DialogDescription className="sr-only"> - {t($ => $['roster.duplicateDialog.description'], { name: latestAgent.name })} + {t(($) => $['roster.duplicateDialog.description'], { name: latestAgent.name })} </DialogDescription> </div> <Form<AgentFormValues> @@ -132,7 +152,9 @@ export function DuplicateAgentDialog({ <div className="flex items-end gap-4 pb-2"> <button type="button" - aria-label={t($ => $['roster.duplicateForm.changeIcon'], { name: latestAgent.name })} + aria-label={t(($) => $['roster.duplicateForm.changeIcon'], { + name: latestAgent.name, + })} className="shrink-0 rounded-full outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" onClick={() => setIconPickerOpen(true)} > @@ -149,9 +171,9 @@ export function DuplicateAgentDialog({ <div className="flex min-w-0 flex-1 gap-3 pb-1"> <Field name="name" className="relative min-w-0 flex-1"> <FieldLabel> - {t($ => $['roster.createForm.nameLabel'])} + {t(($) => $['roster.createForm.nameLabel'])} <span className="ml-1 system-xs-regular text-text-tertiary"> - {tCommon($ => $['label.optional'])} + {tCommon(($) => $['label.optional'])} </span> </FieldLabel> <FieldControl @@ -164,21 +186,18 @@ export function DuplicateAgentDialog({ value={name} /> </Field> - <Field - name="role" - className="relative min-w-0 flex-1" - > + <Field name="role" className="relative min-w-0 flex-1"> <FieldLabel> - {t($ => $['roster.createForm.roleLabel'])} + {t(($) => $['roster.createForm.roleLabel'])} <span className="ml-1 system-xs-regular text-text-tertiary"> - {tCommon($ => $['label.optional'])} + {tCommon(($) => $['label.optional'])} </span> </FieldLabel> <FieldControl autoComplete="off" maxLength={255} onValueChange={setRole} - placeholder={t($ => $['roster.createForm.rolePlaceholder'])} + placeholder={t(($) => $['roster.createForm.rolePlaceholder'])} value={role} /> </Field> @@ -186,23 +205,28 @@ export function DuplicateAgentDialog({ </div> <Field name="description"> <FieldLabel> - {t($ => $['roster.createForm.descriptionLabel'])} + {t(($) => $['roster.createForm.descriptionLabel'])} <span className="ml-1 system-xs-regular text-text-tertiary"> - {tCommon($ => $['label.optional'])} + {tCommon(($) => $['label.optional'])} </span> </FieldLabel> <Textarea autoComplete="off" className="h-20 resize-none" onValueChange={setDescription} - placeholder={t($ => $['roster.createForm.descriptionPlaceholder'])} + placeholder={t(($) => $['roster.createForm.descriptionPlaceholder'])} value={description} /> </Field> </div> <div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6"> - <Button type="button" className="min-w-18" onClick={() => handleOpenChange(false)} disabled={duplicateAgentMutation.isPending}> - {tCommon($ => $['operation.cancel'])} + <Button + type="button" + className="min-w-18" + onClick={() => handleOpenChange(false)} + disabled={duplicateAgentMutation.isPending} + > + {tCommon(($) => $['operation.cancel'])} </Button> <Button type="submit" @@ -210,7 +234,7 @@ export function DuplicateAgentDialog({ className="min-w-18" loading={duplicateAgentMutation.isPending} > - {tCommon($ => $['operation.duplicate'])} + {tCommon(($) => $['operation.duplicate'])} </Button> </div> </Form> @@ -218,9 +242,11 @@ export function DuplicateAgentDialog({ </Dialog> <AppIconPicker open={iconPickerOpen} - initialEmoji={agentIcon.type === 'emoji' - ? { icon: agentIcon.icon, background: agentIcon.background } - : undefined} + initialEmoji={ + agentIcon.type === 'emoji' + ? { icon: agentIcon.icon, background: agentIcon.background } + : undefined + } onOpenChange={setIconPickerOpen} onSelect={setAgentIcon} /> diff --git a/web/features/agent-v2/roster/components/edit-agent-dialog.tsx b/web/features/agent-v2/roster/components/edit-agent-dialog.tsx index c5f38f1b5a97ab..d7d6acdb55410d 100644 --- a/web/features/agent-v2/roster/components/edit-agent-dialog.tsx +++ b/web/features/agent-v2/roster/components/edit-agent-dialog.tsx @@ -1,9 +1,18 @@ 'use client' -import type { AgentAppPartial, AgentAppUpdatePayload } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentAppPartial, + AgentAppUpdatePayload, +} from '@dify/contracts/api/console/agent/types.gen' import type { AgentFormValues, AgentIconSelection } from './agent-form' import { Button } from '@langgenius/dify-ui/button' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { Form } from '@langgenius/dify-ui/form' import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' @@ -34,12 +43,7 @@ const applyIconPayload = (body: AgentAppUpdatePayload, icon: AgentIconSelection) body.icon_background = undefined } -export function EditAgentDialog({ - agent, - formKey, - open, - onOpenChange, -}: EditAgentDialogProps) { +export function EditAgentDialog({ agent, formKey, open, onOpenChange }: EditAgentDialogProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const [renderedFormKey, setRenderedFormKey] = useState(formKey) @@ -47,7 +51,9 @@ export function EditAgentDialog({ const [description, setDescription] = useState(agent.description ?? '') const [role, setRole] = useState(agent.role ?? '') const [iconPickerOpen, setIconPickerOpen] = useState(false) - const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => createAgentIconSelection(agent)) + const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => + createAgentIconSelection(agent), + ) const updateAgentMutation = useMutation(consoleQuery.agent.byAgentId.put.mutationOptions()) if (formKey !== renderedFormKey) { @@ -65,8 +71,7 @@ export function EditAgentDialog({ setDescription(agent.description ?? '') setRole(agent.role ?? '') setAgentIcon(createAgentIconSelection(agent)) - } - else { + } else { setIconPickerOpen(false) } onOpenChange(nextOpen) @@ -76,17 +81,17 @@ export function EditAgentDialog({ const trimmedName = formValues.name?.trim() ?? '' const trimmedDescription = formValues.description?.trim() ?? '' const trimmedRole = formValues.role?.trim() ?? '' - const hasIconChanges = getAgentIconKey(agentIcon) !== getAgentIconKey(createAgentIconSelection(agent)) - const hasFormChanges = trimmedName !== agent.name.trim() - || trimmedDescription !== (agent.description?.trim() ?? '') - || trimmedRole !== (agent.role?.trim() ?? '') - || hasIconChanges + const hasIconChanges = + getAgentIconKey(agentIcon) !== getAgentIconKey(createAgentIconSelection(agent)) + const hasFormChanges = + trimmedName !== agent.name.trim() || + trimmedDescription !== (agent.description?.trim() ?? '') || + trimmedRole !== (agent.role?.trim() ?? '') || + hasIconChanges - if (updateAgentMutation.isPending) - return + if (updateAgentMutation.isPending) return - if (!hasFormChanges) - return + if (!hasFormChanges) return const body: AgentAppUpdatePayload = { name: trimmedName, @@ -98,27 +103,32 @@ export function EditAgentDialog({ applyIconPayload(body, agentIcon) - updateAgentMutation.mutate({ - params: { - agent_id: agent.id, + updateAgentMutation.mutate( + { + params: { + agent_id: agent.id, + }, + body, }, - body, - }, { - onSuccess: () => { - toast.success(t($ => $['roster.updateSuccess'])) - handleOpenChange(false) + { + onSuccess: () => { + toast.success(t(($) => $['roster.updateSuccess'])) + handleOpenChange(false) + }, }, - }) + ) } const trimmedName = name.trim() const trimmedDescription = description.trim() const trimmedRole = role.trim() - const hasIconChanges = getAgentIconKey(agentIcon) !== getAgentIconKey(createAgentIconSelection(agent)) - const hasChanges = trimmedName !== agent.name.trim() - || trimmedDescription !== (agent.description?.trim() ?? '') - || trimmedRole !== (agent.role?.trim() ?? '') - || hasIconChanges + const hasIconChanges = + getAgentIconKey(agentIcon) !== getAgentIconKey(createAgentIconSelection(agent)) + const hasChanges = + trimmedName !== agent.name.trim() || + trimmedDescription !== (agent.description?.trim() ?? '') || + trimmedRole !== (agent.role?.trim() ?? '') || + hasIconChanges return ( <> @@ -127,10 +137,10 @@ export function EditAgentDialog({ <DialogCloseButton /> <div className="shrink-0 pt-6 pr-14 pb-3 pl-6"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['roster.editDialog.title'])} + {t(($) => $['roster.editDialog.title'])} </DialogTitle> <DialogDescription className="sr-only"> - {t($ => $['roster.editDialog.description'])} + {t(($) => $['roster.editDialog.description'])} </DialogDescription> </div> <Form<AgentFormValues> @@ -141,7 +151,7 @@ export function EditAgentDialog({ <AgentFormFields description={description} icon={agentIcon} - iconAriaLabel={t($ => $['roster.editAgent'], { name: agent.name })} + iconAriaLabel={t(($) => $['roster.editAgent'], { name: agent.name })} name={name} role={role} onDescriptionChange={setDescription} @@ -150,8 +160,13 @@ export function EditAgentDialog({ onRoleChange={setRole} /> <div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6"> - <Button type="button" className="min-w-18" onClick={() => handleOpenChange(false)} disabled={updateAgentMutation.isPending}> - {tCommon($ => $['operation.cancel'])} + <Button + type="button" + className="min-w-18" + onClick={() => handleOpenChange(false)} + disabled={updateAgentMutation.isPending} + > + {tCommon(($) => $['operation.cancel'])} </Button> <Button type="submit" @@ -160,7 +175,7 @@ export function EditAgentDialog({ disabled={!hasChanges} loading={updateAgentMutation.isPending} > - {tCommon($ => $['operation.save'])} + {tCommon(($) => $['operation.save'])} </Button> </div> </Form> @@ -168,9 +183,11 @@ export function EditAgentDialog({ </Dialog> <AppIconPicker open={iconPickerOpen} - initialEmoji={agentIcon.type === 'emoji' - ? { icon: agentIcon.icon, background: agentIcon.background } - : undefined} + initialEmoji={ + agentIcon.type === 'emoji' + ? { icon: agentIcon.icon, background: agentIcon.background } + : undefined + } onOpenChange={setIconPickerOpen} onSelect={setAgentIcon} /> diff --git a/web/features/agent-v2/roster/components/roster-filter.ts b/web/features/agent-v2/roster/components/roster-filter.ts index a80025d6f65439..655cfdb5c8001c 100644 --- a/web/features/agent-v2/roster/components/roster-filter.ts +++ b/web/features/agent-v2/roster/components/roster-filter.ts @@ -1,2 +1,2 @@ export const ROSTER_FILTER_VALUES = ['all', 'published', 'drafts'] as const -export type RosterFilterValue = typeof ROSTER_FILTER_VALUES[number] +export type RosterFilterValue = (typeof ROSTER_FILTER_VALUES)[number] diff --git a/web/features/agent-v2/roster/components/roster-sort-select.tsx b/web/features/agent-v2/roster/components/roster-sort-select.tsx index 3b82ca659492a6..396ef02df09c00 100644 --- a/web/features/agent-v2/roster/components/roster-sort-select.tsx +++ b/web/features/agent-v2/roster/components/roster-sort-select.tsx @@ -16,8 +16,9 @@ import { DEFAULT_ROSTER_SORT_BY, rosterSortOptions } from './roster-sort' export function RosterSortSelect() { const { t } = useTranslation('agentV2') const [value, setValue] = useQueryState(rosterQueryParamNames.sortBy, rosterSortByQueryParser) - const selectedOption = rosterSortOptions.find(option => option.value === value) - ?? rosterSortOptions.find(option => option.value === DEFAULT_ROSTER_SORT_BY)! + const selectedOption = + rosterSortOptions.find((option) => option.value === value) ?? + rosterSortOptions.find((option) => option.value === DEFAULT_ROSTER_SORT_BY)! return ( <Select @@ -27,15 +28,15 @@ export function RosterSortSelect() { }} > <SelectTrigger - aria-label={t($ => $['roster.sort.label'])} + aria-label={t(($) => $['roster.sort.label'])} className="h-8 w-fit max-w-45 min-w-0 gap-0 py-1 pr-2.5 pl-2" > <span className="flex min-w-0 items-center gap-1 px-1"> <span className="shrink-0 system-sm-regular text-text-tertiary"> - {t($ => $['roster.sort.label'])} + {t(($) => $['roster.sort.label'])} </span> <span className="truncate system-sm-medium text-text-secondary"> - {t($ => $[selectedOption.labelKey])} + {t(($) => $[selectedOption.labelKey])} </span> </span> </SelectTrigger> @@ -43,14 +44,13 @@ export function RosterSortSelect() { placement="bottom-start" sideOffset={4} popupClassName="w-60" - listProps={{ 'aria-label': t($ => $['roster.sort.optionsLabel']) }} + listProps={{ 'aria-label': t(($) => $['roster.sort.optionsLabel']) }} > - {rosterSortOptions.map(option => ( - <SelectItem - key={option.value} - value={option.value} - > - <SelectItemText title={t($ => $[option.labelKey])}>{t($ => $[option.labelKey])}</SelectItemText> + {rosterSortOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + <SelectItemText title={t(($) => $[option.labelKey])}> + {t(($) => $[option.labelKey])} + </SelectItemText> <SelectItemIndicator /> </SelectItem> ))} diff --git a/web/features/agent-v2/roster/components/roster-sort.ts b/web/features/agent-v2/roster/components/roster-sort.ts index 7ee4604a0a783a..68389c59121a84 100644 --- a/web/features/agent-v2/roster/components/roster-sort.ts +++ b/web/features/agent-v2/roster/components/roster-sort.ts @@ -12,7 +12,10 @@ export const ROSTER_SORT_BY_VALUES = [ export const rosterSortOptions: Array<{ value: RosterSortBy - labelKey: 'roster.sort.lastModified' | 'roster.sort.recentlyCreated' | 'roster.sort.earliestCreated' + labelKey: + | 'roster.sort.lastModified' + | 'roster.sort.recentlyCreated' + | 'roster.sort.earliestCreated' }> = [ { value: 'last_modified', labelKey: 'roster.sort.lastModified' }, { value: 'recently_created', labelKey: 'roster.sort.recentlyCreated' }, diff --git a/web/features/agent-v2/roster/components/roster-toolbar.tsx b/web/features/agent-v2/roster/components/roster-toolbar.tsx index 170fa98e1f7cb4..ea537abf5451fc 100644 --- a/web/features/agent-v2/roster/components/roster-toolbar.tsx +++ b/web/features/agent-v2/roster/components/roster-toolbar.tsx @@ -26,59 +26,43 @@ type RosterFilterItemProps = { value: RosterFilterValue } -function RosterFilterItem({ - count, - label, - value, -}: RosterFilterItemProps) { +function RosterFilterItem({ count, label, value }: RosterFilterItemProps) { return ( - <SegmentedControlItem - value={value} - className="gap-1 data-pressed:text-text-secondary" - > + <SegmentedControlItem value={value} className="gap-1 data-pressed:text-text-secondary"> <span>{label}</span> {count !== undefined && ( <span className="flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary tabular-nums"> - <span className="min-w-px flex-1 text-center"> - {count} - </span> + <span className="min-w-px flex-1 text-center">{count}</span> </span> )} </SegmentedControlItem> ) } -function RosterStatusFilter({ - draftAgents, - publishedAgents, -}: RosterToolbarProps) { +function RosterStatusFilter({ draftAgents, publishedAgents }: RosterToolbarProps) { const { t } = useTranslation('agentV2') const [filter, setFilter] = useQueryState(rosterQueryParamNames.filter, rosterFilterQueryParser) return ( <SegmentedControl - aria-label={t($ => $['roster.filters.label'])} + aria-label={t(($) => $['roster.filters.label'])} className="shrink-0" value={[filter]} onValueChange={(value) => { const nextFilter = value[0] - if (nextFilter) - void setFilter(nextFilter) + if (nextFilter) void setFilter(nextFilter) }} > - <RosterFilterItem - value="all" - label={t($ => $['roster.filters.all'])} - /> + <RosterFilterItem value="all" label={t(($) => $['roster.filters.all'])} /> <RosterFilterItem value="published" - label={t($ => $['roster.filters.published'])} + label={t(($) => $['roster.filters.published'])} count={publishedAgents} /> <RosterFilterItem value="drafts" - label={t($ => $['roster.filters.drafts'])} + label={t(($) => $['roster.filters.drafts'])} count={draftAgents} /> </SegmentedControl> @@ -87,13 +71,16 @@ function RosterStatusFilter({ function RosterSearchFilter() { const { t } = useTranslation('agentV2') - const [keyword, setKeyword] = useQueryState(rosterQueryParamNames.keyword, rosterKeywordQueryParser) + const [keyword, setKeyword] = useQueryState( + rosterQueryParamNames.keyword, + rosterKeywordQueryParser, + ) return ( <SearchInput - aria-label={t($ => $['roster.searchLabel'])} + aria-label={t(($) => $['roster.searchLabel'])} className="h-8 w-50 min-w-0 shrink" - placeholder={t($ => $['roster.searchPlaceholder'])} + placeholder={t(($) => $['roster.searchPlaceholder'])} value={keyword} onValueChange={(value) => { void setKeyword(value) @@ -104,7 +91,10 @@ function RosterSearchFilter() { function RosterCreatedByMeFilter() { const { t } = useTranslation('agentV2') - const [createdByMe, setCreatedByMe] = useQueryState(rosterQueryParamNames.createdByMe, rosterCreatedByMeQueryParser) + const [createdByMe, setCreatedByMe] = useQueryState( + rosterQueryParamNames.createdByMe, + rosterCreatedByMeQueryParser, + ) return ( <label className="flex h-8 shrink-0 cursor-pointer items-center gap-1 rounded-lg bg-components-input-bg-normal px-2 py-1 whitespace-nowrap"> @@ -114,21 +104,17 @@ function RosterCreatedByMeFilter() { void setCreatedByMe(checked === true) }} /> - <span className="p-1 system-sm-regular text-text-tertiary">{t($ => $['roster.filters.createdByMe'])}</span> + <span className="p-1 system-sm-regular text-text-tertiary"> + {t(($) => $['roster.filters.createdByMe'])} + </span> </label> ) } -export function RosterToolbar({ - draftAgents, - publishedAgents, -}: RosterToolbarProps) { +export function RosterToolbar({ draftAgents, publishedAgents }: RosterToolbarProps) { return ( <div className="flex min-w-0 items-center gap-2"> - <RosterStatusFilter - draftAgents={draftAgents} - publishedAgents={publishedAgents} - /> + <RosterStatusFilter draftAgents={draftAgents} publishedAgents={publishedAgents} /> <RosterSearchFilter /> <div className="flex h-4 shrink-0 px-1" aria-hidden="true"> <div className="h-full w-px bg-divider-regular" /> diff --git a/web/features/agent-v2/roster/page.tsx b/web/features/agent-v2/roster/page.tsx index eb24c2ecd63c06..83e52d8e6e5016 100644 --- a/web/features/agent-v2/roster/page.tsx +++ b/web/features/agent-v2/roster/page.tsx @@ -28,15 +28,10 @@ import { const ROSTER_PAGE_SIZE = 30 const isAgentPublished = (agent: AgentAppPartial) => agent.active_config_is_published === true -const getFilteredRosterItems = ( - agents: AgentAppPartial[], - filter: RosterFilterValue, -) => { - if (filter === 'published') - return agents.filter(isAgentPublished) +const getFilteredRosterItems = (agents: AgentAppPartial[], filter: RosterFilterValue) => { + if (filter === 'published') return agents.filter(isAgentPublished) - if (filter === 'drafts') - return agents.filter(agent => !isAgentPublished(agent)) + if (filter === 'drafts') return agents.filter((agent) => !isAgentPublished(agent)) return agents } @@ -45,7 +40,10 @@ export default function RosterPage() { const { t } = useTranslation('agentV2') const [keyword] = useQueryState(rosterQueryParamNames.keyword, rosterKeywordQueryParser) const [rosterFilter] = useQueryState(rosterQueryParamNames.filter, rosterFilterQueryParser) - const [createdByMe] = useQueryState(rosterQueryParamNames.createdByMe, rosterCreatedByMeQueryParser) + const [createdByMe] = useQueryState( + rosterQueryParamNames.createdByMe, + rosterCreatedByMeQueryParser, + ) const [sortBy] = useQueryState(rosterQueryParamNames.sortBy, rosterSortByQueryParser) const debouncedKeyword = useDebounce(keyword.trim(), { wait: 300 }) @@ -66,19 +64,19 @@ export default function RosterPage() { error, } = useInfiniteQuery({ ...consoleQuery.agent.get.infiniteOptions({ - input: pageParam => ({ + input: (pageParam) => ({ query: { ...rosterQueryInput, page: Number(pageParam), }, }), - getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), initialPageParam: 1, placeholderData: keepPreviousData, }), }) - const rosterItems: AgentAppPartial[] = rosterPages?.pages.flatMap(page => page.data) ?? [] + const rosterItems: AgentAppPartial[] = rosterPages?.pages.flatMap((page) => page.data) ?? [] const publishedAgents = rosterItems.filter(isAgentPublished).length const draftAgents = Math.max(rosterItems.length - publishedAgents, 0) const filteredRosterItems = getFilteredRosterItems(rosterItems, rosterFilter) @@ -98,15 +96,12 @@ export default function RosterPage() { rel="noreferrer" className="hidden shrink-0 items-center gap-0.5 rounded-md system-xs-regular text-text-tertiary hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden sm:inline-flex" > - {t($ => $['roster.learnMore'])} + {t(($) => $['roster.learnMore'])} <span aria-hidden className="i-ri-external-link-line size-3" /> </a> </div> <div className="mt-3.5"> - <RosterToolbar - draftAgents={draftAgents} - publishedAgents={publishedAgents} - /> + <RosterToolbar draftAgents={draftAgents} publishedAgents={publishedAgents} /> </div> </div> @@ -122,7 +117,7 @@ export default function RosterPage() { isFetching={isFetching} isFetchingNextPage={isFetchingNextPage} isPending={isPending} - label={t($ => $['roster.listLabel'])} + label={t(($) => $['roster.listLabel'])} onLoadMore={() => fetchNextPage()} /> </ScrollAreaContent> diff --git a/web/features/agent-v2/roster/query-params.ts b/web/features/agent-v2/roster/query-params.ts index 56a58a3e81b79b..b47ac742b49b9c 100644 --- a/web/features/agent-v2/roster/query-params.ts +++ b/web/features/agent-v2/roster/query-params.ts @@ -17,4 +17,5 @@ export const rosterFilterQueryParser = parseAsStringLiteral(ROSTER_FILTER_VALUES export const rosterCreatedByMeQueryParser = parseAsBoolean.withDefault(false) -export const rosterSortByQueryParser = parseAsStringLiteral(ROSTER_SORT_BY_VALUES).withDefault(DEFAULT_ROSTER_SORT_BY) +export const rosterSortByQueryParser = + parseAsStringLiteral(ROSTER_SORT_BY_VALUES).withDefault(DEFAULT_ROSTER_SORT_BY) diff --git a/web/features/deployments/create-guide/index.tsx b/web/features/deployments/create-guide/index.tsx index b09ae6fd294010..929669a2de282d 100644 --- a/web/features/deployments/create-guide/index.tsx +++ b/web/features/deployments/create-guide/index.tsx @@ -12,7 +12,7 @@ export function CreateDeploymentGuide() { const router = useRouter() return ( - <Dialog open onOpenChange={open => !open && router.push('/deployments')}> + <Dialog open onOpenChange={(open) => !open && router.push('/deployments')}> <DialogContent backdropClassName="bg-background-overlay-backdrop backdrop-blur-[6px]" className="top-4 bottom-4 h-auto max-h-none w-[min(calc(100vw-2rem),1120px)] max-w-none translate-y-0 overflow-hidden border-effects-highlight bg-background-default-subtle p-0" @@ -20,10 +20,13 @@ export function CreateDeploymentGuide() { <div className="relative flex h-full min-w-0 grow flex-col overflow-hidden"> <Link href="/deployments" - aria-label={t($ => $['createGuide.nav.back'])} + aria-label={t(($) => $['createGuide.nav.back'])} className="absolute top-3 right-3 z-50 flex h-9 w-9 cursor-pointer items-center justify-center rounded-[10px] bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover" > - <span aria-hidden="true" className="i-ri-close-large-line h-3.5 w-3.5 text-components-button-tertiary-text" /> + <span + aria-hidden="true" + className="i-ri-close-large-line h-3.5 w-3.5 text-components-button-tertiary-text" + /> </Link> <CreateDeploymentGuideProvider> <CreateDeploymentGuideShell /> diff --git a/web/features/deployments/create-guide/link.tsx b/web/features/deployments/create-guide/link.tsx index ae058f9e25a4bf..7fd4dc90f08d95 100644 --- a/web/features/deployments/create-guide/link.tsx +++ b/web/features/deployments/create-guide/link.tsx @@ -8,10 +8,5 @@ const createDeploymentGuideHref = '/deployments/create' type CreateDeploymentGuideLinkProps = Omit<ComponentProps<typeof Link>, 'href'> export function CreateDeploymentGuideLink(props: CreateDeploymentGuideLinkProps) { - return ( - <Link - {...props} - href={createDeploymentGuideHref} - /> - ) + return <Link {...props} href={createDeploymentGuideHref} /> } diff --git a/web/features/deployments/create-guide/state/__tests__/index.spec.ts b/web/features/deployments/create-guide/state/__tests__/index.spec.ts index 72b5f33a515db6..1818482bebff26 100644 --- a/web/features/deployments/create-guide/state/__tests__/index.spec.ts +++ b/web/features/deployments/create-guide/state/__tests__/index.spec.ts @@ -50,45 +50,47 @@ const mockQueryResults = vi.hoisted(() => ({ })) vi.mock('jotai-tanstack-query', () => ({ - atomWithInfiniteQuery: (createOptions: (get: Getter) => InfiniteQueryOptions) => atom((get) => { - const options = createOptions(get) - const queryName = String(options.queryKey?.[0] ?? 'unknown') - const queryResult = mockQueryResults.current.get(queryName) + atomWithInfiniteQuery: (createOptions: (get: Getter) => InfiniteQueryOptions) => + atom((get) => { + const options = createOptions(get) + const queryName = String(options.queryKey?.[0] ?? 'unknown') + const queryResult = mockQueryResults.current.get(queryName) - return { - ...options, - data: { pages: [{ data: [] }] }, - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - isLoading: false, - isPlaceholderData: false, - isSuccess: Boolean(queryResult?.data), - fetchNextPage: vi.fn(), - ...queryResult, - } - }), - atomWithMutation: () => atom(() => ({ - isPending: false, - mutateAsync: vi.fn(), - })), - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => { - const options = createOptions(get) - const queryName = String(options.queryKey?.[0] ?? 'unknown') - const queryResult = options.enabled === false - ? undefined - : mockQueryResults.current.get(queryName) + return { + ...options, + data: { pages: [{ data: [] }] }, + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + isPlaceholderData: false, + isSuccess: Boolean(queryResult?.data), + fetchNextPage: vi.fn(), + ...queryResult, + } + }), + atomWithMutation: () => + atom(() => ({ + isPending: false, + mutateAsync: vi.fn(), + })), + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => + atom((get) => { + const options = createOptions(get) + const queryName = String(options.queryKey?.[0] ?? 'unknown') + const queryResult = + options.enabled === false ? undefined : mockQueryResults.current.get(queryName) - return { - ...options, - data: undefined, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: false, - ...queryResult, - } - }), + return { + ...options, + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + ...queryResult, + } + }), })) vi.mock('@/service/client', () => ({ @@ -141,11 +143,7 @@ vi.mock('@/service/client', () => ({ })) function workflowDsl() { - return [ - 'app:', - ' mode: workflow', - ' name: Imported guide', - ].join('\n') + return ['app:', ' mode: workflow', ' name: Imported guide'].join('\n') } describe('create deployment guide state', () => { diff --git a/web/features/deployments/create-guide/state/environment.ts b/web/features/deployments/create-guide/state/environment.ts index 9d7e27cb144a7d..91792bc28da934 100644 --- a/web/features/deployments/create-guide/state/environment.ts +++ b/web/features/deployments/create-guide/state/environment.ts @@ -2,8 +2,7 @@ import type { Environment } from '@dify/contracts/enterprise/types.gen' export function environmentMatchesIdentifier(environment: Environment, identifier: string) { const normalizedIdentifier = identifier.trim() - if (!normalizedIdentifier) - return false + if (!normalizedIdentifier) return false return environment.id === normalizedIdentifier || environment.displayName === normalizedIdentifier } diff --git a/web/features/deployments/create-guide/state/primitives.ts b/web/features/deployments/create-guide/state/primitives.ts index 3332e68acee25f..9b6382371c30e8 100644 --- a/web/features/deployments/create-guide/state/primitives.ts +++ b/web/features/deployments/create-guide/state/primitives.ts @@ -9,7 +9,7 @@ import { deploymentGuideMethod } from './utils' export const stepAtom = atom<GuideStep>('source') export const methodAtom = atom<GuideMethod>('bindApp') -export const effectiveMethodAtom = atom(get => deploymentGuideMethod(get(methodAtom))) +export const effectiveMethodAtom = atom((get) => deploymentGuideMethod(get(methodAtom))) export const sourceSearchTextAtom = atom('') export const selectedAppAtom = atom<WorkflowSourceApp | undefined>(undefined) @@ -32,6 +32,6 @@ export const submissionUnsupportedDslNodesAtom = atom<UnsupportedDslNode[]>([]) export const isCreatingDeploymentAtom = atom(false) export const isCreatingReleaseOnlyAtom = atom(false) -export const isSubmittingDeploymentGuideAtom = atom(get => ( - get(isCreatingDeploymentAtom) || get(isCreatingReleaseOnlyAtom) -)) +export const isSubmittingDeploymentGuideAtom = atom( + (get) => get(isCreatingDeploymentAtom) || get(isCreatingReleaseOnlyAtom), +) diff --git a/web/features/deployments/create-guide/state/provider.tsx b/web/features/deployments/create-guide/state/provider.tsx index 85885eb3669411..3cb9593f1393cd 100644 --- a/web/features/deployments/create-guide/state/provider.tsx +++ b/web/features/deployments/create-guide/state/provider.tsx @@ -4,12 +4,6 @@ import type { ReactNode } from 'react' import { ScopeProvider } from 'jotai-scope' import { createDeploymentGuideScopedAtoms } from './scoped' -export function CreateDeploymentGuideProvider({ children }: { - children: ReactNode -}) { - return ( - <ScopeProvider atoms={createDeploymentGuideScopedAtoms}> - {children} - </ScopeProvider> - ) +export function CreateDeploymentGuideProvider({ children }: { children: ReactNode }) { + return <ScopeProvider atoms={createDeploymentGuideScopedAtoms}>{children}</ScopeProvider> } diff --git a/web/features/deployments/create-guide/state/queries.ts b/web/features/deployments/create-guide/state/queries.ts index b9c099b9efcf02..6fa3773179f03b 100644 --- a/web/features/deployments/create-guide/state/queries.ts +++ b/web/features/deployments/create-guide/state/queries.ts @@ -7,19 +7,23 @@ import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query' import { selectAtom } from 'jotai/utils' import { encodeDslContent } from '@/features/deployments/shared/domain/dsl' import { consoleQuery } from '@/service/client' -import { effectiveMethodAtom, instanceNameAtom, submissionUnsupportedDslNodesAtom } from './primitives' +import { + effectiveMethodAtom, + instanceNameAtom, + submissionUnsupportedDslNodesAtom, +} from './primitives' import { dslContentAtom, effectiveSelectedAppAtom, sourceReady } from './source' import { DEPLOYMENT_PAGE_SIZE, getNextPageParamFromPagination } from './utils' export const existingInstanceNamesQueryAtom = atomWithInfiniteQuery(() => consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({ - input: pageParam => ({ + input: (pageParam) => ({ query: { pageNumber: Number(pageParam), resultsPerPage: DEPLOYMENT_PAGE_SIZE, }, }), - getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination), + getNextPageParam: (lastPage) => getNextPageParamFromPagination(lastPage.pagination), initialPageParam: 1, placeholderData: keepPreviousData, }), @@ -54,10 +58,22 @@ export const deployableEnvironmentsQueryAtom = atomWithQuery((get) => { }) }) -export const deployableEnvironmentsDataAtom = selectAtom(deployableEnvironmentsQueryAtom, query => query.data) -export const deployableEnvironmentsIsErrorAtom = selectAtom(deployableEnvironmentsQueryAtom, query => query.isError) -export const deployableEnvironmentsIsLoadingAtom = selectAtom(deployableEnvironmentsQueryAtom, query => query.isLoading) -export const deployableEnvironmentsIsFetchingAtom = selectAtom(deployableEnvironmentsQueryAtom, query => query.isFetching) +export const deployableEnvironmentsDataAtom = selectAtom( + deployableEnvironmentsQueryAtom, + (query) => query.data, +) +export const deployableEnvironmentsIsErrorAtom = selectAtom( + deployableEnvironmentsQueryAtom, + (query) => query.isError, +) +export const deployableEnvironmentsIsLoadingAtom = selectAtom( + deployableEnvironmentsQueryAtom, + (query) => query.isLoading, +) +export const deployableEnvironmentsIsFetchingAtom = selectAtom( + deployableEnvironmentsQueryAtom, + (query) => query.isFetching, +) const precheckReleaseQueryAtom = atomWithQuery((get) => { const method = get(effectiveMethodAtom) @@ -67,46 +83,58 @@ const precheckReleaseQueryAtom = atomWithQuery((get) => { const enabled = sourceReady(get) // PrecheckRelease takes exactly one source arm (dsl | sourceAppId). - const precheckReleaseQueryOptions = method === 'importDsl' - ? consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({ - input: encodedDslContent - ? { - body: { - dsl: encodedDslContent, - }, - } - : skipToken, - enabled, - retry: false, - }) - : consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({ - input: effectiveSelectedApp?.id - ? { - body: { - sourceAppId: effectiveSelectedApp.id, - }, - } - : skipToken, - enabled: enabled && Boolean(effectiveSelectedApp?.id), - retry: false, - }) + const precheckReleaseQueryOptions = + method === 'importDsl' + ? consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({ + input: encodedDslContent + ? { + body: { + dsl: encodedDslContent, + }, + } + : skipToken, + enabled, + retry: false, + }) + : consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({ + input: effectiveSelectedApp?.id + ? { + body: { + sourceAppId: effectiveSelectedApp.id, + }, + } + : skipToken, + enabled: enabled && Boolean(effectiveSelectedApp?.id), + retry: false, + }) return precheckReleaseQueryOptions }) -const precheckReleaseDataAtom = selectAtom(precheckReleaseQueryAtom, query => query.data) -const precheckReleaseIsSuccessAtom = selectAtom(precheckReleaseQueryAtom, query => query.isSuccess) -const precheckReleaseIsLoadingAtom = selectAtom(precheckReleaseQueryAtom, query => query.isLoading) -const precheckReleaseIsFetchingAtom = selectAtom(precheckReleaseQueryAtom, query => query.isFetching) +const precheckReleaseDataAtom = selectAtom(precheckReleaseQueryAtom, (query) => query.data) +const precheckReleaseIsSuccessAtom = selectAtom( + precheckReleaseQueryAtom, + (query) => query.isSuccess, +) +const precheckReleaseIsLoadingAtom = selectAtom( + precheckReleaseQueryAtom, + (query) => query.isLoading, +) +const precheckReleaseIsFetchingAtom = selectAtom( + precheckReleaseQueryAtom, + (query) => query.isFetching, +) function precheckReleaseReady(get: Getter) { const precheckRelease = get(precheckReleaseDataAtom) - return sourceReady(get) - && get(precheckReleaseIsSuccessAtom) - && Boolean(precheckRelease?.canCreate) - && (precheckRelease?.unsupportedNodes.length ?? 0) === 0 - && get(submissionUnsupportedDslNodesAtom).length === 0 + return ( + sourceReady(get) && + get(precheckReleaseIsSuccessAtom) && + Boolean(precheckRelease?.canCreate) && + (precheckRelease?.unsupportedNodes.length ?? 0) === 0 && + get(submissionUnsupportedDslNodesAtom).length === 0 + ) } const deploymentOptionsQueryAtom = atomWithQuery((get) => { @@ -117,46 +145,60 @@ const deploymentOptionsQueryAtom = atomWithQuery((get) => { const enabled = precheckReleaseReady(get) // ComputeDeploymentOptions takes exactly one source arm (dsl | sourceAppId | releaseId). - const deploymentOptionsQueryOptions = method === 'importDsl' - ? consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({ - input: encodedDslContent - ? { - body: { - dsl: encodedDslContent, - }, - } - : skipToken, - enabled, - retry: false, - }) - : consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({ - input: effectiveSelectedApp?.id - ? { - body: { - sourceAppId: effectiveSelectedApp.id, - }, - } - : skipToken, - enabled: enabled && Boolean(effectiveSelectedApp?.id), - retry: false, - }) + const deploymentOptionsQueryOptions = + method === 'importDsl' + ? consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({ + input: encodedDslContent + ? { + body: { + dsl: encodedDslContent, + }, + } + : skipToken, + enabled, + retry: false, + }) + : consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({ + input: effectiveSelectedApp?.id + ? { + body: { + sourceAppId: effectiveSelectedApp.id, + }, + } + : skipToken, + enabled: enabled && Boolean(effectiveSelectedApp?.id), + retry: false, + }) return deploymentOptionsQueryOptions }) -export const deploymentOptionsDataAtom = selectAtom(deploymentOptionsQueryAtom, query => query.data) -export const deploymentOptionsIsErrorAtom = selectAtom(deploymentOptionsQueryAtom, query => query.isError) -export const deploymentOptionsIsLoadingAtom = selectAtom(deploymentOptionsQueryAtom, query => query.isLoading) -export const deploymentOptionsIsFetchingAtom = selectAtom(deploymentOptionsQueryAtom, query => query.isFetching) -const deploymentOptionsIsSuccessAtom = selectAtom(deploymentOptionsQueryAtom, query => query.isSuccess) +export const deploymentOptionsDataAtom = selectAtom( + deploymentOptionsQueryAtom, + (query) => query.data, +) +export const deploymentOptionsIsErrorAtom = selectAtom( + deploymentOptionsQueryAtom, + (query) => query.isError, +) +export const deploymentOptionsIsLoadingAtom = selectAtom( + deploymentOptionsQueryAtom, + (query) => query.isLoading, +) +export const deploymentOptionsIsFetchingAtom = selectAtom( + deploymentOptionsQueryAtom, + (query) => query.isFetching, +) +const deploymentOptionsIsSuccessAtom = selectAtom( + deploymentOptionsQueryAtom, + (query) => query.isSuccess, +) export const unsupportedDslNodesAtom = atom((get) => { const submissionUnsupportedDslNodes = get(submissionUnsupportedDslNodesAtom) - if (submissionUnsupportedDslNodes.length > 0) - return submissionUnsupportedDslNodes + if (submissionUnsupportedDslNodes.length > 0) return submissionUnsupportedDslNodes - if (!sourceReady(get)) - return [] + if (!sourceReady(get)) return [] return get(precheckReleaseDataAtom)?.unsupportedNodes ?? [] }) @@ -166,17 +208,18 @@ const precheckReleaseReadyAtom = atom((get) => { }) export const deploymentOptionsReadyAtom = atom((get) => { - return sourceReady(get) - && get(precheckReleaseReadyAtom) - && get(deploymentOptionsIsSuccessAtom) + return sourceReady(get) && get(precheckReleaseReadyAtom) && get(deploymentOptionsIsSuccessAtom) }) export const deploymentOptionsContentCheckedAtom = atom((get) => { - const isLoadingOptions = get(deploymentOptionsIsLoadingAtom) || (get(deploymentOptionsIsFetchingAtom) && !get(deploymentOptionsDataAtom)) - const isCheckingReleaseContent = get(precheckReleaseIsLoadingAtom) || (get(precheckReleaseIsFetchingAtom) && !get(precheckReleaseDataAtom)) - - if (!sourceReady(get) || isCheckingReleaseContent || isLoadingOptions) - return false + const isLoadingOptions = + get(deploymentOptionsIsLoadingAtom) || + (get(deploymentOptionsIsFetchingAtom) && !get(deploymentOptionsDataAtom)) + const isCheckingReleaseContent = + get(precheckReleaseIsLoadingAtom) || + (get(precheckReleaseIsFetchingAtom) && !get(precheckReleaseDataAtom)) + + if (!sourceReady(get) || isCheckingReleaseContent || isLoadingOptions) return false return get(precheckReleaseReadyAtom) && get(deploymentOptionsIsSuccessAtom) }) diff --git a/web/features/deployments/create-guide/state/release.ts b/web/features/deployments/create-guide/state/release.ts index 0fb26fd316745b..0ae13ddf26f2e4 100644 --- a/web/features/deployments/create-guide/state/release.ts +++ b/web/features/deployments/create-guide/state/release.ts @@ -13,27 +13,33 @@ import { selectedEnvironmentIdAtom, stepAtom, } from './primitives' -import { deploymentOptionsContentCheckedAtom, existingInstanceNamesQueryAtom, instanceNameConflictQueryAtom } from './queries' +import { + deploymentOptionsContentCheckedAtom, + existingInstanceNamesQueryAtom, + instanceNameConflictQueryAtom, +} from './queries' import { sourceReady } from './source' export const hasInstanceNameConflictAtom = atom((get) => { const submittedInstanceName = get(instanceNameAtom).trim() const instanceNameConflictQuery = get(instanceNameConflictQueryAtom) const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom) - const existingInstanceNames = existingInstanceNamesQuery.data?.pages.flatMap(page => - page.appInstances.flatMap((appInstance) => { - const name = appInstance.displayName.trim() + const existingInstanceNames = + existingInstanceNamesQuery.data?.pages.flatMap((page) => + page.appInstances.flatMap((appInstance) => { + const name = appInstance.displayName.trim() - return name ? [name] : [] - }), - ) ?? [] + return name ? [name] : [] + }), + ) ?? [] return Boolean( - submittedInstanceName - && ( - existingInstanceNames.includes(submittedInstanceName) - || (instanceNameConflictQuery.data?.appInstances.some(appInstance => appInstance.displayName.trim() === submittedInstanceName) ?? false) - ), + submittedInstanceName && + (existingInstanceNames.includes(submittedInstanceName) || + (instanceNameConflictQuery.data?.appInstances.some( + (appInstance) => appInstance.displayName.trim() === submittedInstanceName, + ) ?? + false)), ) }) @@ -45,10 +51,12 @@ export const releaseCanGoNextAtom = atom((get) => { const submittedInstanceName = get(instanceNameAtom).trim() const instanceNameConflictQuery = get(instanceNameConflictQueryAtom) - return Boolean(get(submittedReleaseReadyAtom)) - && !get(hasInstanceNameConflictAtom) - && !(Boolean(submittedInstanceName) && instanceNameConflictQuery.isLoading) - && get(deploymentOptionsContentCheckedAtom) + return ( + Boolean(get(submittedReleaseReadyAtom)) && + !get(hasInstanceNameConflictAtom) && + !(Boolean(submittedInstanceName) && instanceNameConflictQuery.isLoading) && + get(deploymentOptionsContentCheckedAtom) + ) }) export const setInstanceNameAtom = atom(null, (_get, set, value: string) => { @@ -74,8 +82,7 @@ export const setReleaseDescriptionAtom = atom(null, (_get, set, value: string) = }) export const continueFromReleaseAtom = atom(null, (get, set) => { - if (!get(releaseCanGoNextAtom)) - return + if (!get(releaseCanGoNextAtom)) return set(selectedEnvironmentIdAtom, '') set(manualBindingSelectionsAtom, {}) diff --git a/web/features/deployments/create-guide/state/source.ts b/web/features/deployments/create-guide/state/source.ts index 274fcd3a401882..ab16d3132be1e4 100644 --- a/web/features/deployments/create-guide/state/source.ts +++ b/web/features/deployments/create-guide/state/source.ts @@ -10,7 +10,13 @@ import { dslAppName, isWorkflowDsl } from '@/features/deployments/shared/domain/ import { consoleQuery } from '@/service/client' import { normalizeAppPagination } from '@/service/use-apps' import { AppModeEnum } from '@/types/app' -import { dslFileAtom, dslFileReadVersionAtom, effectiveMethodAtom, selectedAppAtom, sourceSearchTextAtom } from './primitives' +import { + dslFileAtom, + dslFileReadVersionAtom, + effectiveMethodAtom, + selectedAppAtom, + sourceSearchTextAtom, +} from './primitives' import { SOURCE_APPS_PAGE_SIZE } from './utils' const dslFileContentQueryAtom = atomWithQuery((get) => { @@ -26,7 +32,7 @@ const dslFileContentQueryAtom = atomWithQuery((get) => { file?.size ?? 0, file?.lastModified ?? 0, ], - queryFn: async () => file ? await file.text() : '', + queryFn: async () => (file ? await file.text() : ''), enabled: Boolean(file), retry: false, }) @@ -56,25 +62,29 @@ export const dslDefaultAppNameAtom = atom((get) => { export const dslUnsupportedModeAtom = atom((get) => { const dslContent = get(dslContentAtom) - return get(effectiveMethodAtom) === 'importDsl' - && Boolean(dslContent.trim()) - && !get(isReadingDslAtom) - && !get(dslReadErrorAtom) - && !isWorkflowDsl(dslContent) + return ( + get(effectiveMethodAtom) === 'importDsl' && + Boolean(dslContent.trim()) && + !get(isReadingDslAtom) && + !get(dslReadErrorAtom) && + !isWorkflowDsl(dslContent) + ) }) export const importDslReadyAtom = atom((get) => { - return Boolean(get(dslContentAtom).trim()) - && !get(isReadingDslAtom) - && !get(dslReadErrorAtom) - && !get(dslUnsupportedModeAtom) + return ( + Boolean(get(dslContentAtom).trim()) && + !get(isReadingDslAtom) && + !get(dslReadErrorAtom) && + !get(dslUnsupportedModeAtom) + ) }) export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => { const sourceSearchText = get(sourceSearchTextAtom) return consoleQuery.apps.get.infiniteOptions({ - input: pageParam => ({ + input: (pageParam) => ({ query: { page: Number(pageParam), limit: SOURCE_APPS_PAGE_SIZE, @@ -82,10 +92,10 @@ export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => { mode: AppModeEnum.WORKFLOW, }, }), - getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), initialPageParam: 1, placeholderData: keepPreviousData, - select: data => ({ + select: (data) => ({ ...data, pages: data.pages.map(normalizeAppPagination), }), @@ -93,26 +103,36 @@ export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => { }) }) -const sourceAppsDataAtom = selectAtom(sourceAppsQueryAtom, query => query.data) -export const sourceAppsErrorAtom = selectAtom(sourceAppsQueryAtom, query => query.error) -export const sourceAppsFetchNextPageAtom = selectAtom(sourceAppsQueryAtom, query => query.fetchNextPage) -export const sourceAppsHasNextPageAtom = selectAtom(sourceAppsQueryAtom, query => query.hasNextPage) -export const sourceAppsIsFetchingAtom = selectAtom(sourceAppsQueryAtom, query => query.isFetching) -export const sourceAppsIsFetchingNextPageAtom = selectAtom(sourceAppsQueryAtom, query => query.isFetchingNextPage) -export const sourceAppsIsLoadingAtom = selectAtom(sourceAppsQueryAtom, query => query.isLoading) -export const sourceAppsIsPlaceholderDataAtom = selectAtom(sourceAppsQueryAtom, query => query.isPlaceholderData) +const sourceAppsDataAtom = selectAtom(sourceAppsQueryAtom, (query) => query.data) +export const sourceAppsErrorAtom = selectAtom(sourceAppsQueryAtom, (query) => query.error) +export const sourceAppsFetchNextPageAtom = selectAtom( + sourceAppsQueryAtom, + (query) => query.fetchNextPage, +) +export const sourceAppsHasNextPageAtom = selectAtom( + sourceAppsQueryAtom, + (query) => query.hasNextPage, +) +export const sourceAppsIsFetchingAtom = selectAtom(sourceAppsQueryAtom, (query) => query.isFetching) +export const sourceAppsIsFetchingNextPageAtom = selectAtom( + sourceAppsQueryAtom, + (query) => query.isFetchingNextPage, +) +export const sourceAppsIsLoadingAtom = selectAtom(sourceAppsQueryAtom, (query) => query.isLoading) +export const sourceAppsIsPlaceholderDataAtom = selectAtom( + sourceAppsQueryAtom, + (query) => query.isPlaceholderData, +) export const sourceAppsAtom = atom((get) => { - return (get(sourceAppsDataAtom)?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[] + return (get(sourceAppsDataAtom)?.pages.flatMap((page) => page.data) ?? []) as WorkflowSourceApp[] }) export const effectiveSelectedAppAtom = atom((get) => { const selectedApp = get(selectedAppAtom) - if (selectedApp) - return selectedApp + if (selectedApp) return selectedApp - if (get(sourceAppsIsPlaceholderDataAtom)) - return undefined + if (get(sourceAppsIsPlaceholderDataAtom)) return undefined return get(sourceAppsAtom)[0] }) diff --git a/web/features/deployments/create-guide/state/submission.ts b/web/features/deployments/create-guide/state/submission.ts index 9f13688c0b4a41..8dbbfceed2e013 100644 --- a/web/features/deployments/create-guide/state/submission.ts +++ b/web/features/deployments/create-guide/state/submission.ts @@ -59,65 +59,85 @@ export class CreateDeploymentGuideSubmissionBlockedError extends Error { } } -export const createDeploymentGuideSubmissionAtom = atom(null, async (get, set, { - deployToEnvironment, -}: { - deployToEnvironment: boolean -}) => { - const method = get(effectiveMethodAtom) - const dslContent = get(dslContentAtom) - const submittedInstanceName = get(instanceNameAtom).trim() - const submittedReleaseName = get(releaseNameAtom).trim() - const submittedReleaseDescription = get(releaseDescriptionAtom).trim() - - if (get(isSubmittingDeploymentGuideAtom) || !get(submittedReleaseReadyAtom)) - return undefined - - const effectiveSelectedApp = get(effectiveSelectedAppAtom) - const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom) - const deploymentOptions = get(deploymentOptionsDataAtom)?.options - const envVarSlots = get(deploymentTargetEnvVarSlotsAtom) - const envVarValues = get(envVarValuesAtom) - const bindingSlots = get(deploymentTargetBindingSlotsAtom) - const bindingSelections = get(deploymentTargetBindingSelectionsAtom) - const selectedEnvironmentId = get(selectedEnvironmentIdAtom) - const effectiveSelectedEnvironmentId = selectedEnvironmentId || get(deployableEnvironmentsAtom)[0]?.id - const selectedEnvironment = effectiveSelectedEnvironmentId - ? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId)) - : undefined - - if (deployToEnvironment && !selectedEnvironment && !selectedEnvironmentId.trim()) - return undefined - if (method === 'bindApp' && !effectiveSelectedApp?.id) - return undefined - if (method === 'importDsl' && !dslContent.trim()) - return undefined - if (method === 'importDsl' && !isWorkflowDsl(dslContent)) - throw new CreateDeploymentGuideSubmissionBlockedError('unsupportedDslMode') - - set(submissionUnsupportedDslNodesAtom, []) - - try { - if (!deployToEnvironment) { - if (!get(canSkipDeploymentAtom)) - return undefined +export const createDeploymentGuideSubmissionAtom = atom( + null, + async ( + get, + set, + { + deployToEnvironment, + }: { + deployToEnvironment: boolean + }, + ) => { + const method = get(effectiveMethodAtom) + const dslContent = get(dslContentAtom) + const submittedInstanceName = get(instanceNameAtom).trim() + const submittedReleaseName = get(releaseNameAtom).trim() + const submittedReleaseDescription = get(releaseDescriptionAtom).trim() + + if (get(isSubmittingDeploymentGuideAtom) || !get(submittedReleaseReadyAtom)) return undefined + + const effectiveSelectedApp = get(effectiveSelectedAppAtom) + const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom) + const deploymentOptions = get(deploymentOptionsDataAtom)?.options + const envVarSlots = get(deploymentTargetEnvVarSlotsAtom) + const envVarValues = get(envVarValuesAtom) + const bindingSlots = get(deploymentTargetBindingSlotsAtom) + const bindingSelections = get(deploymentTargetBindingSelectionsAtom) + const selectedEnvironmentId = get(selectedEnvironmentIdAtom) + const effectiveSelectedEnvironmentId = + selectedEnvironmentId || get(deployableEnvironmentsAtom)[0]?.id + const selectedEnvironment = effectiveSelectedEnvironmentId + ? get(deployableEnvironmentsAtom).find((env) => + environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId), + ) + : undefined + + if (deployToEnvironment && !selectedEnvironment && !selectedEnvironmentId.trim()) + return undefined + if (method === 'bindApp' && !effectiveSelectedApp?.id) return undefined + if (method === 'importDsl' && !dslContent.trim()) return undefined + if (method === 'importDsl' && !isWorkflowDsl(dslContent)) + throw new CreateDeploymentGuideSubmissionBlockedError('unsupportedDslMode') - set(isCreatingReleaseOnlyAtom, true) + set(submissionUnsupportedDslNodesAtom, []) - try { - const createdAppInstance = await get(createAppInstanceMutationAtom).mutateAsync({ - body: { - displayName: submittedInstanceName, - description: get(instanceDescriptionAtom).trim() || undefined, - }, - }) - const appInstanceId = createdAppInstance.appInstance.id + try { + if (!deployToEnvironment) { + if (!get(canSkipDeploymentAtom)) return undefined + + set(isCreatingReleaseOnlyAtom, true) + + try { + const createdAppInstance = await get(createAppInstanceMutationAtom).mutateAsync({ + body: { + displayName: submittedInstanceName, + description: get(instanceDescriptionAtom).trim() || undefined, + }, + }) + const appInstanceId = createdAppInstance.appInstance.id + + if (method === 'importDsl') { + await get(createReleaseMutationAtom).mutateAsync({ + body: { + appInstanceId, + dsl: encodeDslContent(dslContent), + displayName: submittedReleaseName, + description: submittedReleaseDescription || undefined, + createAppInstance: false, + }, + }) + + return appInstanceId + } + + if (!effectiveSelectedApp?.id) return undefined - if (method === 'importDsl') { await get(createReleaseMutationAtom).mutateAsync({ body: { appInstanceId, - dsl: encodeDslContent(dslContent), + sourceAppId: effectiveSelectedApp.id, displayName: submittedReleaseName, description: submittedReleaseDescription || undefined, createAppInstance: false, @@ -125,97 +145,77 @@ export const createDeploymentGuideSubmissionAtom = atom(null, async (get, set, { }) return appInstanceId + } finally { + set(isCreatingReleaseOnlyAtom, false) } + } + + if (!get(canDeployAtom)) return undefined - if (!effectiveSelectedApp?.id) - return undefined + set(isCreatingDeploymentAtom, true) - await get(createReleaseMutationAtom).mutateAsync({ - body: { - appInstanceId, - sourceAppId: effectiveSelectedApp.id, - displayName: submittedReleaseName, - description: submittedReleaseDescription || undefined, - createAppInstance: false, + try { + const selectedEnvironmentIdentifier = selectedEnvironmentId.trim() + const freshSelectedEnvironment = + selectedEnvironment || + (selectedEnvironmentIdentifier + ? (await deployableEnvironmentsQuery.refetch()).data?.environments.find((environment) => + environmentMatchesIdentifier(environment, selectedEnvironmentIdentifier), + ) + : undefined) + const targetEnvironmentId = freshSelectedEnvironment?.id + if (!targetEnvironmentId) + throw new CreateDeploymentGuideSubmissionBlockedError('deployFailed') + + if (!get(requiredBindingsReadyAtom)) throw new Error('Missing required deployment binding.') + if (!get(requiredEnvVarsReadyAtom)) + throw new Error('Missing required deployment environment variable.') + + const envVars = envVarSlots.flatMap((slot) => envVarInput(slot, envVarValues[slot.key])) + const commonDeploymentRequest = { + newAppInstance: { + displayName: submittedInstanceName, + description: get(instanceDescriptionAtom).trim() || undefined, }, + environmentId: targetEnvironmentId, + releaseName: submittedReleaseName, + releaseDescription: submittedReleaseDescription || undefined, + credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections), + envVars, + idempotencyKey: createDeploymentIdempotencyKey(), + expectedDslDigest: deploymentOptions?.dslDigest, + } satisfies Omit<DeployRequest, 'dsl' | 'sourceAppId'> + const deploymentRequest = + method === 'importDsl' + ? { + ...commonDeploymentRequest, + dsl: encodeDslContent(dslContent), + } + : effectiveSelectedApp?.id + ? { + ...commonDeploymentRequest, + sourceAppId: effectiveSelectedApp.id, + } + : undefined + if (!deploymentRequest) return undefined + + const response = await get(createInitialDeploymentMutationAtom).mutateAsync({ + body: deploymentRequest, }) - return appInstanceId - } - finally { - set(isCreatingReleaseOnlyAtom, false) + return response.appInstance.id + } finally { + set(isCreatingDeploymentAtom, false) } - } - - if (!get(canDeployAtom)) - return undefined + } catch (error) { + const unsupportedError = await unsupportedDslNodeError(error) + if (unsupportedError?.nodes.length) { + set(submissionUnsupportedDslNodesAtom, unsupportedError.nodes) - set(isCreatingDeploymentAtom, true) - - try { - const selectedEnvironmentIdentifier = selectedEnvironmentId.trim() - const freshSelectedEnvironment = selectedEnvironment || ( - selectedEnvironmentIdentifier - ? (await deployableEnvironmentsQuery.refetch()).data?.environments.find(environment => - environmentMatchesIdentifier(environment, selectedEnvironmentIdentifier), - ) - : undefined - ) - const targetEnvironmentId = freshSelectedEnvironment?.id - if (!targetEnvironmentId) - throw new CreateDeploymentGuideSubmissionBlockedError('deployFailed') - - if (!get(requiredBindingsReadyAtom)) - throw new Error('Missing required deployment binding.') - if (!get(requiredEnvVarsReadyAtom)) - throw new Error('Missing required deployment environment variable.') - - const envVars = envVarSlots.flatMap(slot => envVarInput(slot, envVarValues[slot.key])) - const commonDeploymentRequest = { - newAppInstance: { - displayName: submittedInstanceName, - description: get(instanceDescriptionAtom).trim() || undefined, - }, - environmentId: targetEnvironmentId, - releaseName: submittedReleaseName, - releaseDescription: submittedReleaseDescription || undefined, - credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections), - envVars, - idempotencyKey: createDeploymentIdempotencyKey(), - expectedDslDigest: deploymentOptions?.dslDigest, - } satisfies Omit<DeployRequest, 'dsl' | 'sourceAppId'> - const deploymentRequest = method === 'importDsl' - ? { - ...commonDeploymentRequest, - dsl: encodeDslContent(dslContent), - } - : effectiveSelectedApp?.id - ? { - ...commonDeploymentRequest, - sourceAppId: effectiveSelectedApp.id, - } - : undefined - if (!deploymentRequest) return undefined + } - const response = await get(createInitialDeploymentMutationAtom).mutateAsync({ - body: deploymentRequest, - }) - - return response.appInstance.id - } - finally { - set(isCreatingDeploymentAtom, false) + throw error } - } - catch (error) { - const unsupportedError = await unsupportedDslNodeError(error) - if (unsupportedError?.nodes.length) { - set(submissionUnsupportedDslNodesAtom, unsupportedError.nodes) - - return undefined - } - - throw error - } -}) + }, +) diff --git a/web/features/deployments/create-guide/state/target.ts b/web/features/deployments/create-guide/state/target.ts index 57f75389f2d05a..fc89b42004cbe9 100644 --- a/web/features/deployments/create-guide/state/target.ts +++ b/web/features/deployments/create-guide/state/target.ts @@ -1,8 +1,14 @@ 'use client' -import type { EnvVarBindingSlot, EnvVarValueSelection } from '@/features/deployments/shared/components/env-var-bindings' +import type { + EnvVarBindingSlot, + EnvVarValueSelection, +} from '@/features/deployments/shared/components/env-var-bindings' import { atom } from 'jotai' -import { envVarBindingSlotFromContract, envVarBindingValueType } from '@/features/deployments/shared/components/env-var-bindings-utils' +import { + envVarBindingSlotFromContract, + envVarBindingValueType, +} from '@/features/deployments/shared/components/env-var-bindings-utils' import { hasMissingRequiredRuntimeCredentialBinding, runtimeCredentialSlotKey, @@ -10,8 +16,17 @@ import { } from '@/features/deployments/shared/components/runtime-credential-bindings-utils' import { dslEnvVarSlots } from '@/features/deployments/shared/domain/dsl' import { environmentMatchesIdentifier } from './environment' -import { effectiveMethodAtom, envVarValuesAtom, manualBindingSelectionsAtom, selectedEnvironmentIdAtom } from './primitives' -import { deployableEnvironmentsDataAtom, deploymentOptionsDataAtom, deploymentOptionsReadyAtom } from './queries' +import { + effectiveMethodAtom, + envVarValuesAtom, + manualBindingSelectionsAtom, + selectedEnvironmentIdAtom, +} from './primitives' +import { + deployableEnvironmentsDataAtom, + deploymentOptionsDataAtom, + deploymentOptionsReadyAtom, +} from './queries' import { submittedReleaseReadyAtom } from './release' import { dslContentAtom, sourceReady } from './source' import { envVarSelectionReady } from './utils' @@ -19,9 +34,7 @@ import { envVarSelectionReady } from './utils' export const deployableEnvironmentsAtom = atom((get) => { const deployableEnvironments = get(deployableEnvironmentsDataAtom) - return sourceReady(get) - ? deployableEnvironments?.environments ?? [] - : [] + return sourceReady(get) ? (deployableEnvironments?.environments ?? []) : [] }) const deployableEnvironmentsReadyAtom = atom((get) => { @@ -36,7 +49,9 @@ export const deploymentTargetBindingSlotsAtom = atom((get) => { const deploymentOptions = get(deploymentOptionsDataAtom) return sourceReady(get) - ? deploymentOptions?.options?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? [] + ? (deploymentOptions?.options?.credentialSlots?.filter((slot) => + runtimeCredentialSlotKey(slot), + ) ?? []) : [] }) @@ -50,8 +65,12 @@ export const deploymentTargetBindingSelectionsAtom = atom((get) => { export const requiredBindingsReadyAtom = atom((get) => { const bindingSelections = get(deploymentTargetBindingSelectionsAtom) - return get(deploymentTargetBindingSlotsAtom).every(slot => - !hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]), + return get(deploymentTargetBindingSlotsAtom).every( + (slot) => + !hasMissingRequiredRuntimeCredentialBinding( + slot, + bindingSelections[runtimeCredentialSlotKey(slot)], + ), ) }) @@ -62,41 +81,41 @@ export const deploymentTargetEnvVarSlotsAtom = atom((get) => { const dslContent = get(dslContentAtom) // Deployment options own the canonical slot list; DSL metadata only enriches import-DSL defaults. - const deploymentOptionEnvVarSlots = slots?.flatMap((slot): EnvVarBindingSlot[] => { - const bindingSlot = envVarBindingSlotFromContract(slot) - return bindingSlot ? [bindingSlot] : [] - }) ?? [] - const dslEnvVarMetadataSlots = method === 'importDsl' && dslContent - ? dslEnvVarSlots(dslContent).flatMap((slot) => { - const key = slot.key.trim() - if (!key) - return [] - - return [{ - key, - ...(slot.description ? { description: slot.description } : {}), - ...(slot.defaultValue !== undefined ? { defaultValue: slot.defaultValue, hasDefaultValue: true } : {}), - ...(slot.valueType ? { valueType: envVarBindingValueType(slot.valueType) } : {}), - }] - }) - : [] - - if (dslEnvVarMetadataSlots.length === 0) - return deploymentOptionEnvVarSlots - - const metadataByKey = new Map( - dslEnvVarMetadataSlots.map(slot => [slot.key, slot] as const), - ) + const deploymentOptionEnvVarSlots = + slots?.flatMap((slot): EnvVarBindingSlot[] => { + const bindingSlot = envVarBindingSlotFromContract(slot) + return bindingSlot ? [bindingSlot] : [] + }) ?? [] + const dslEnvVarMetadataSlots = + method === 'importDsl' && dslContent + ? dslEnvVarSlots(dslContent).flatMap((slot) => { + const key = slot.key.trim() + if (!key) return [] + + return [ + { + key, + ...(slot.description ? { description: slot.description } : {}), + ...(slot.defaultValue !== undefined + ? { defaultValue: slot.defaultValue, hasDefaultValue: true } + : {}), + ...(slot.valueType ? { valueType: envVarBindingValueType(slot.valueType) } : {}), + }, + ] + }) + : [] + + if (dslEnvVarMetadataSlots.length === 0) return deploymentOptionEnvVarSlots + + const metadataByKey = new Map(dslEnvVarMetadataSlots.map((slot) => [slot.key, slot] as const)) return deploymentOptionEnvVarSlots.map((slot) => { const metadata = metadataByKey.get(slot.key) - if (!metadata) - return slot + if (!metadata) return slot const nextSlot = { ...slot } - if (!nextSlot.description && metadata.description) - nextSlot.description = metadata.description + if (!nextSlot.description && metadata.description) nextSlot.description = metadata.description if (!nextSlot.hasDefaultValue && metadata.defaultValue !== undefined) { nextSlot.defaultValue = metadata.defaultValue nextSlot.hasDefaultValue = true @@ -111,7 +130,7 @@ export const deploymentTargetEnvVarSlotsAtom = atom((get) => { export const requiredEnvVarsReadyAtom = atom((get) => { const envVarValues = get(envVarValuesAtom) - return get(deploymentTargetEnvVarSlotsAtom).every(slot => + return get(deploymentTargetEnvVarSlotsAtom).every((slot) => envVarSelectionReady(slot, envVarValues[slot.key]), ) }) @@ -119,16 +138,18 @@ export const requiredEnvVarsReadyAtom = atom((get) => { export const canDeployAtom = atom((get) => { const effectiveSelectedEnvironmentId = get(effectiveSelectedEnvironmentIdAtom) const selectedEnvironment = effectiveSelectedEnvironmentId - ? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId)) + ? get(deployableEnvironmentsAtom).find((env) => + environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId), + ) : undefined return Boolean( - selectedEnvironment?.id - && get(deployableEnvironmentsReadyAtom) - && get(deploymentOptionsReadyAtom) - && get(requiredBindingsReadyAtom) - && get(requiredEnvVarsReadyAtom) - && get(submittedReleaseReadyAtom), + selectedEnvironment?.id && + get(deployableEnvironmentsReadyAtom) && + get(deploymentOptionsReadyAtom) && + get(requiredBindingsReadyAtom) && + get(requiredEnvVarsReadyAtom) && + get(submittedReleaseReadyAtom), ) }) diff --git a/web/features/deployments/create-guide/state/utils.ts b/web/features/deployments/create-guide/state/utils.ts index fa07e418dfee95..7ad4d1a2c7bc55 100644 --- a/web/features/deployments/create-guide/state/utils.ts +++ b/web/features/deployments/create-guide/state/utils.ts @@ -1,6 +1,9 @@ import type { EnvVarInput, Pagination } from '@dify/contracts/enterprise/types.gen' import type { GuideMethod } from './types' -import type { EnvVarBindingSlot, EnvVarValueSelection } from '@/features/deployments/shared/components/env-var-bindings' +import type { + EnvVarBindingSlot, + EnvVarValueSelection, +} from '@/features/deployments/shared/components/env-var-bindings' import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen' import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags' @@ -15,9 +18,7 @@ export function getNextPageParamFromPagination(pagination?: Pagination) { } export function deploymentGuideMethod(method: GuideMethod): GuideMethod { - return method === 'importDsl' && !isDeploymentDslImportEnabled - ? 'bindApp' - : method + return method === 'importDsl' && !isDeploymentDslImportEnabled ? 'bindApp' : method } const RANDOM_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz' @@ -30,72 +31,77 @@ function randomLetterCombination(length: number) { if (globalThis.crypto) { globalThis.crypto.getRandomValues(randomValues) - } - else { + } else { randomValues.forEach((_, index) => { randomValues[index] = Math.floor(Math.random() * 256) }) } - return Array.from(randomValues, value => RANDOM_SUFFIX_ALPHABET[value % RANDOM_SUFFIX_ALPHABET.length]).join('') + return Array.from( + randomValues, + (value) => RANDOM_SUFFIX_ALPHABET[value % RANDOM_SUFFIX_ALPHABET.length], + ).join('') } export function availableInstanceName(sourceName: string, existingNameSet: Set<string>) { - if (!existingNameSet.has(sourceName)) - return sourceName + if (!existingNameSet.has(sourceName)) return sourceName for (let attempt = 0; attempt < RANDOM_SUFFIX_MAX_ATTEMPTS; attempt++) { const candidate = `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_LENGTH)}` - if (!existingNameSet.has(candidate)) - return candidate + if (!existingNameSet.has(candidate)) return candidate } return `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_FALLBACK_LENGTH)}` } function envVarValueSource(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) { - return selection?.valueSource - ?? (slot.hasDefaultValue + return ( + selection?.valueSource ?? + (slot.hasDefaultValue ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT : slot.hasLastValue ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT : ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL) + ) } -export function envVarSelectionReady(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) { +export function envVarSelectionReady( + slot: EnvVarBindingSlot, + selection: EnvVarValueSelection | undefined, +) { const valueSource = envVarValueSource(slot, selection) if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) return Boolean(slot.hasLastValue) if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) return Boolean(slot.hasDefaultValue) - if (!selection?.value) - return false + if (!selection?.value) return false return slot.valueType !== 'number' || !Number.isNaN(Number(selection.value)) } -export function envVarInput(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined): EnvVarInput[] { +export function envVarInput( + slot: EnvVarBindingSlot, + selection: EnvVarValueSelection | undefined, +): EnvVarInput[] { const valueSource = envVarValueSource(slot, selection) if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) { - return slot.hasLastValue - ? [{ key: slot.key, valueSource }] - : [] + return slot.hasLastValue ? [{ key: slot.key, valueSource }] : [] } if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) { - return slot.hasDefaultValue - ? [{ key: slot.key, valueSource }] - : [] + return slot.hasDefaultValue ? [{ key: slot.key, valueSource }] : [] } if (!selection?.value || (slot.valueType === 'number' && Number.isNaN(Number(selection.value)))) return [] - return [{ - key: slot.key, - value: selection.value, - valueSource, - }] + return [ + { + key: slot.key, + value: selection.value, + valueSource, + }, + ] } diff --git a/web/features/deployments/create-guide/state/workflow.ts b/web/features/deployments/create-guide/state/workflow.ts index ba328039f80f2b..71f5ad2324c0cf 100644 --- a/web/features/deployments/create-guide/state/workflow.ts +++ b/web/features/deployments/create-guide/state/workflow.ts @@ -33,8 +33,7 @@ export const sourceCanGoNextAtom = atom((get) => { }) export const setSourceSearchTextAtom = atom(null, (get, set, value: string) => { - if (get(sourceSearchTextAtom) === value) - return + if (get(sourceSearchTextAtom) === value) return set(sourceSearchTextAtom, value) set(selectedAppAtom, undefined) @@ -52,55 +51,61 @@ export const selectSourceAppAtom = atom(null, (_get, set, app: WorkflowSourceApp set(submissionUnsupportedDslNodesAtom, []) }) -export const continueFromSourceAtom = atom(null, (get, set, { - defaultDslAppName, - defaultReleaseName, -}: { - defaultDslAppName: string - defaultReleaseName: string -}) => { - if (!get(sourceCanGoNextAtom)) - return +export const continueFromSourceAtom = atom( + null, + ( + get, + set, + { + defaultDslAppName, + defaultReleaseName, + }: { + defaultDslAppName: string + defaultReleaseName: string + }, + ) => { + if (!get(sourceCanGoNextAtom)) return + + const method = get(effectiveMethodAtom) + const effectiveSelectedApp = get(effectiveSelectedAppAtom) + if (method === 'bindApp' && effectiveSelectedApp) set(selectSourceAppAtom, effectiveSelectedApp) + + const sourceName = + method === 'importDsl' + ? get(dslDefaultAppNameAtom) || defaultDslAppName + : effectiveSelectedApp?.name + const nextInstanceName = sourceName?.trim() + + if (nextInstanceName) { + const currentInstanceName = get(instanceNameAtom).trim() + const autoFilledInstanceName = get(autoFilledInstanceNameAtom) + const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom) + const existingNameSet = new Set( + existingInstanceNamesQuery.data?.pages.flatMap((page) => + page.appInstances.flatMap((appInstance) => { + const name = appInstance.displayName.trim() + + return name ? [name] : [] + }), + ) ?? [], + ) + + if (!currentInstanceName || currentInstanceName === autoFilledInstanceName) { + const nextAvailableInstanceName = availableInstanceName(nextInstanceName, existingNameSet) + set(instanceNameAtom, nextAvailableInstanceName) + set(autoFilledInstanceNameAtom, nextAvailableInstanceName) + } + } - const method = get(effectiveMethodAtom) - const effectiveSelectedApp = get(effectiveSelectedAppAtom) - if (method === 'bindApp' && effectiveSelectedApp) - set(selectSourceAppAtom, effectiveSelectedApp) - - const sourceName = method === 'importDsl' - ? get(dslDefaultAppNameAtom) || defaultDslAppName - : effectiveSelectedApp?.name - const nextInstanceName = sourceName?.trim() - - if (nextInstanceName) { - const currentInstanceName = get(instanceNameAtom).trim() - const autoFilledInstanceName = get(autoFilledInstanceNameAtom) - const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom) - const existingNameSet = new Set( - existingInstanceNamesQuery.data?.pages.flatMap(page => - page.appInstances.flatMap((appInstance) => { - const name = appInstance.displayName.trim() - - return name ? [name] : [] - }), - ) ?? [], - ) - - if (!currentInstanceName || currentInstanceName === autoFilledInstanceName) { - const nextAvailableInstanceName = availableInstanceName(nextInstanceName, existingNameSet) - set(instanceNameAtom, nextAvailableInstanceName) - set(autoFilledInstanceNameAtom, nextAvailableInstanceName) + const currentReleaseName = get(releaseNameAtom).trim() + const autoFilledReleaseName = get(autoFilledReleaseNameAtom) + if (!currentReleaseName || currentReleaseName === autoFilledReleaseName) { + set(releaseNameAtom, defaultReleaseName) + set(autoFilledReleaseNameAtom, defaultReleaseName) } - } - - const currentReleaseName = get(releaseNameAtom).trim() - const autoFilledReleaseName = get(autoFilledReleaseNameAtom) - if (!currentReleaseName || currentReleaseName === autoFilledReleaseName) { - set(releaseNameAtom, defaultReleaseName) - set(autoFilledReleaseNameAtom, defaultReleaseName) - } - set(stepAtom, 'release') -}) + set(stepAtom, 'release') + }, +) export const selectDslFileAtom = atom(null, (get, set, dslFile?: File) => { set(selectedEnvironmentIdAtom, '') @@ -112,11 +117,14 @@ export const selectDslFileAtom = atom(null, (get, set, dslFile?: File) => { set(dslFileAtom, dslFile) }) -export const selectMethodAtom = atom(null, (_get, set, method: Parameters<typeof deploymentGuideMethod>[0]) => { - set(methodAtom, deploymentGuideMethod(method)) - set(selectedEnvironmentIdAtom, '') - set(manualBindingSelectionsAtom, {}) - set(envVarValuesAtom, {}) - set(submissionUnsupportedDslNodesAtom, []) - set(stepAtom, 'source') -}) +export const selectMethodAtom = atom( + null, + (_get, set, method: Parameters<typeof deploymentGuideMethod>[0]) => { + set(methodAtom, deploymentGuideMethod(method)) + set(selectedEnvironmentIdAtom, '') + set(manualBindingSelectionsAtom, {}) + set(envVarValuesAtom, {}) + set(submissionUnsupportedDslNodesAtom, []) + set(stepAtom, 'source') + }, +) diff --git a/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx b/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx index cb620975f8d563..0b64e3fea992da 100644 --- a/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx +++ b/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx @@ -33,7 +33,7 @@ vi.mock('@/features/deployments/create-guide/state/primitives', async () => { return { dslFileAtom: atom<File | undefined>(undefined), - effectiveMethodAtom: atom(get => get(methodAtom)), + effectiveMethodAtom: atom((get) => get(methodAtom)), methodAtom, sourceSearchTextAtom: atom(''), } @@ -47,7 +47,7 @@ vi.mock('@/features/deployments/create-guide/state/source', async () => { dslUnsupportedModeAtom: atom(false), effectiveSelectedAppAtom: atom(undefined), isReadingDslAtom: atom(false), - sourceAppsAtom: atom(() => mocks.sourceAppsQuery.data.pages.flatMap(page => page.data)), + sourceAppsAtom: atom(() => mocks.sourceAppsQuery.data.pages.flatMap((page) => page.data)), sourceAppsErrorAtom: atom(() => mocks.sourceAppsQuery.error), sourceAppsFetchNextPageAtom: atom(() => mocks.sourceAppsQuery.fetchNextPage), sourceAppsHasNextPageAtom: atom(() => mocks.sourceAppsQuery.hasNextPage), @@ -104,19 +104,27 @@ describe('SourceStepContent', () => { expect(screen.getByText(/createGuide\.methods\.bindApp\.title/)).toBeInTheDocument() expect(screen.queryByText(/createGuide\.methods\.importDsl\.title/)).not.toBeInTheDocument() - expect(screen.queryByText(/createGuide\.methods\.importDsl\.description/)).not.toBeInTheDocument() - expect(screen.getByRole('textbox', { name: /createGuide\.source\.sourceApp/ })).toBeInTheDocument() + expect( + screen.queryByText(/createGuide\.methods\.importDsl\.description/), + ).not.toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: /createGuide\.source\.sourceApp/ }), + ).toBeInTheDocument() }) it('should use infinite scroll to load more source apps', () => { Object.assign(mocks.sourceAppsQuery, { data: { - pages: [{ - data: [{ - id: 'app-1', - name: 'Workflow App', - }], - }], + pages: [ + { + data: [ + { + id: 'app-1', + name: 'Workflow App', + }, + ], + }, + ], }, hasNextPage: true, }) @@ -136,6 +144,8 @@ describe('SourceStepContent', () => { threshold: 0.1, }), ) - expect(screen.queryByRole('button', { name: /createModal\.loadMoreApps/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /createModal\.loadMoreApps/ }), + ).not.toBeInTheDocument() }) }) diff --git a/web/features/deployments/create-guide/ui/layout.tsx b/web/features/deployments/create-guide/ui/layout.tsx index b348d16e9a0e4c..c56f1b3b153d8a 100644 --- a/web/features/deployments/create-guide/ui/layout.tsx +++ b/web/features/deployments/create-guide/ui/layout.tsx @@ -9,27 +9,22 @@ import { TitleTooltip } from '@/features/deployments/shared/components/title-too const GUIDE_PROGRESS_STEPS: GuideStep[] = ['source', 'release', 'target'] -function GuideStepIntro({ activeStep }: { - activeStep: GuideStep -}) { +function GuideStepIntro({ activeStep }: { activeStep: GuideStep }) { const { t } = useTranslation('deployments') let title: string let description: string if (activeStep === 'source') { - title = t($ => $['createGuide.source.title']) - description = t($ => $['createGuide.method.description']) - } - else if (activeStep === 'release') { - title = t($ => $['createGuide.release.title']) - description = t($ => $['createGuide.release.description']) - } - else if (activeStep === 'target') { - title = t($ => $['createGuide.target.title']) - description = t($ => $['createGuide.target.description']) - } - else { + title = t(($) => $['createGuide.source.title']) + description = t(($) => $['createGuide.method.description']) + } else if (activeStep === 'release') { + title = t(($) => $['createGuide.release.title']) + description = t(($) => $['createGuide.release.description']) + } else if (activeStep === 'target') { + title = t(($) => $['createGuide.target.title']) + description = t(($) => $['createGuide.target.description']) + } else { return null } @@ -41,9 +36,7 @@ function GuideStepIntro({ activeStep }: { ) } -function GuideProgress({ activeStep }: { - activeStep: GuideStep -}) { +function GuideProgress({ activeStep }: { activeStep: GuideStep }) { const { t } = useTranslation('deployments') const activeIndex = GUIDE_PROGRESS_STEPS.indexOf(activeStep) @@ -52,7 +45,7 @@ function GuideProgress({ activeStep }: { {GUIDE_PROGRESS_STEPS.map((step, index) => { const isActive = step === activeStep const isComplete = index < activeIndex - const label = t($ => $[`createGuide.steps.${step}`]) + const label = t(($) => $[`createGuide.steps.${step}`]) return ( <TitleTooltip key={step} content={label}> @@ -87,34 +80,25 @@ function GuideProgress({ activeStep }: { ) } -function GuideProgressSummary({ activeStep }: { - activeStep: GuideStep -}) { +function GuideProgressSummary({ activeStep }: { activeStep: GuideStep }) { const { t } = useTranslation('deployments') const activeIndex = GUIDE_PROGRESS_STEPS.indexOf(activeStep) const activeStepNumber = activeIndex + 1 let activeStepLabel: string - if (activeStep === 'source') - activeStepLabel = t($ => $['createGuide.steps.source']) - else if (activeStep === 'release') - activeStepLabel = t($ => $['createGuide.steps.release']) - else if (activeStep === 'target') - activeStepLabel = t($ => $['createGuide.steps.target']) - else - return null + if (activeStep === 'source') activeStepLabel = t(($) => $['createGuide.steps.source']) + else if (activeStep === 'release') activeStepLabel = t(($) => $['createGuide.steps.release']) + else if (activeStep === 'target') activeStepLabel = t(($) => $['createGuide.steps.target']) + else return null - if (activeIndex < 0) - return null + if (activeIndex < 0) return null return ( <div className="flex w-full min-w-0 flex-col gap-2"> <div className="flex min-w-0 items-baseline justify-between gap-3"> <span className="truncate system-sm-medium text-text-secondary">{activeStepLabel}</span> <span className="shrink-0 system-xs-regular text-text-quaternary"> - {activeStepNumber} - / - {GUIDE_PROGRESS_STEPS.length} + {activeStepNumber}/{GUIDE_PROGRESS_STEPS.length} </span> </div> <div className="grid grid-cols-3 gap-1" aria-hidden="true"> @@ -132,7 +116,14 @@ function GuideProgressSummary({ activeStep }: { ) } -export function StepShell({ title, description, descriptionClassName, hideHeader, className, children }: { +export function StepShell({ + title, + description, + descriptionClassName, + hideHeader, + className, + children, +}: { title: string description: string descriptionClassName?: string @@ -141,11 +132,16 @@ export function StepShell({ title, description, descriptionClassName, hideHeader children: ReactNode }) { return ( - <section aria-label={hideHeader ? title : undefined} className={cn('flex min-w-0 flex-col gap-4', className)}> + <section + aria-label={hideHeader ? title : undefined} + className={cn('flex min-w-0 flex-col gap-4', className)} + > {!hideHeader && ( <div className="flex min-w-0 flex-col gap-0.5"> <h2 className="system-md-semibold text-text-primary">{title}</h2> - <p className={cn('system-sm-regular text-text-tertiary', descriptionClassName)}>{description}</p> + <p className={cn('system-sm-regular text-text-tertiary', descriptionClassName)}> + {description} + </p> </div> )} {children} @@ -153,36 +149,39 @@ export function StepShell({ title, description, descriptionClassName, hideHeader ) } -export function GuideCard({ children, actions, contentScrollable = true }: { +export function GuideCard({ + children, + actions, + contentScrollable = true, +}: { children: ReactNode actions: ReactNode contentScrollable?: boolean }) { return ( <div className="flex min-h-0 w-full min-w-0 flex-1 flex-col"> - {contentScrollable - ? ( - <ScrollArea - className="min-h-0 flex-1" - slotClassNames={{ - viewport: 'overscroll-contain', - content: 'min-h-full pt-0.5 pb-6', - }} - > - {children} - </ScrollArea> - ) - : ( - <div className="min-h-0 flex-1 overflow-hidden pt-0.5 pb-6"> - {children} - </div> - )} + {contentScrollable ? ( + <ScrollArea + className="min-h-0 flex-1" + slotClassNames={{ + viewport: 'overscroll-contain', + content: 'min-h-full pt-0.5 pb-6', + }} + > + {children} + </ScrollArea> + ) : ( + <div className="min-h-0 flex-1 overflow-hidden pt-0.5 pb-6">{children}</div> + )} {actions} </div> ) } -export function GuideFrame({ activeStep, children }: { +export function GuideFrame({ + activeStep, + children, +}: { activeStep: GuideStep children: ReactNode }) { @@ -192,12 +191,14 @@ export function GuideFrame({ activeStep, children }: { <div className="relative flex h-full min-h-0 overflow-hidden bg-background-default-subtle"> <div className="flex min-w-0 flex-1 shrink-0 justify-center overflow-hidden"> <section - aria-label={t($ => $['createGuide.title'])} + aria-label={t(($) => $['createGuide.title'])} className="flex h-full w-full max-w-[840px] flex-col px-5 sm:px-8 lg:px-10" > <div className="h-5 sm:h-8 lg:h-12" /> <div className="flex min-w-0 items-start justify-between gap-6 pt-1 pb-4"> - <h1 className="title-2xl-semi-bold text-text-primary">{t($ => $['createGuide.title'])}</h1> + <h1 className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['createGuide.title'])} + </h1> <div className="hidden w-[184px] shrink-0 min-[1120px]:block"> <GuideProgressSummary activeStep={activeStep} /> </div> diff --git a/web/features/deployments/create-guide/ui/release-step.tsx b/web/features/deployments/create-guide/ui/release-step.tsx index 0035736f80847f..b76b465a3d61e9 100644 --- a/web/features/deployments/create-guide/ui/release-step.tsx +++ b/web/features/deployments/create-guide/ui/release-step.tsx @@ -25,15 +25,16 @@ import { import { dslDefaultAppNameAtom } from '@/features/deployments/create-guide/state/source' import { StepShell } from './layout' -const releaseTextareaClassName = 'min-h-16 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs' +const releaseTextareaClassName = + 'min-h-16 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs' export function ReleaseStepContent() { const { t } = useTranslation('deployments') return ( <StepShell - title={t($ => $['createGuide.release.title'])} - description={t($ => $['createGuide.release.description'])} + title={t(($) => $['createGuide.release.title'])} + description={t(($) => $['createGuide.release.description'])} hideHeader > <div className="flex flex-col gap-6"> @@ -50,7 +51,7 @@ function DeploymentInfoSection() { return ( <div className="flex flex-col gap-4"> <h3 className="system-sm-semibold text-text-primary"> - {t($ => $['createGuide.release.deployInfo'])} + {t(($) => $['createGuide.release.deployInfo'])} </h3> <InstanceNameField /> <InstanceDescriptionField /> @@ -64,7 +65,7 @@ function InitialReleaseSection() { return ( <div className="flex flex-col gap-4"> <h3 className="system-sm-semibold text-text-primary"> - {t($ => $['createGuide.release.firstVersion'])} + {t(($) => $['createGuide.release.firstVersion'])} </h3> <ReleaseNameField /> <ReleaseDescriptionField /> @@ -79,24 +80,28 @@ function InstanceNameField() { const method = useAtomValue(effectiveMethodAtom) const selectedApp = useAtomValue(selectedAppAtom) const dslDefaultAppName = useAtomValue(dslDefaultAppNameAtom) - const instanceNamePlaceholder = method === 'importDsl' - ? dslDefaultAppName || t($ => $['createGuide.dsl.defaultAppName']) - : selectedApp?.name + const instanceNamePlaceholder = + method === 'importDsl' + ? dslDefaultAppName || t(($) => $['createGuide.dsl.defaultAppName']) + : selectedApp?.name const hasInstanceNameConflict = useAtomValue(hasInstanceNameConflictAtom) const instanceNameError = hasInstanceNameConflict - ? t($ => $['createGuide.release.instanceNameConflict']) + ? t(($) => $['createGuide.release.instanceNameConflict']) : undefined const instanceNameErrorId = 'create-guide-instance-name-error' return ( <div className="flex flex-col gap-2"> - <label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="create-guide-instance-name"> - {t($ => $['createGuide.release.instanceName'])} + <label + className="system-xs-medium-uppercase text-text-tertiary" + htmlFor="create-guide-instance-name" + > + {t(($) => $['createGuide.release.instanceName'])} </label> <Input id="create-guide-instance-name" value={instanceName} - onChange={event => setInstanceName(event.target.value)} + onChange={(event) => setInstanceName(event.target.value)} placeholder={instanceNamePlaceholder} required aria-invalid={instanceNameError ? true : undefined} @@ -104,7 +109,11 @@ function InstanceNameField() { className="h-9" /> {instanceNameError && ( - <div id={instanceNameErrorId} role="alert" className="system-xs-regular text-text-destructive"> + <div + id={instanceNameErrorId} + role="alert" + className="system-xs-regular text-text-destructive" + > {instanceNameError} </div> )} @@ -120,13 +129,13 @@ function InstanceDescriptionField() { return ( <div className="flex flex-col gap-2"> <OptionalFieldLabel htmlFor="create-guide-instance-description"> - {t($ => $['createGuide.release.instanceDescription'])} + {t(($) => $['createGuide.release.instanceDescription'])} </OptionalFieldLabel> <textarea id="create-guide-instance-description" value={instanceDescription} - onChange={event => setInstanceDescription(event.target.value)} - placeholder={t($ => $['createGuide.release.instanceDescriptionPlaceholder'])} + onChange={(event) => setInstanceDescription(event.target.value)} + placeholder={t(($) => $['createGuide.release.instanceDescriptionPlaceholder'])} className={releaseTextareaClassName} /> </div> @@ -140,14 +149,17 @@ function ReleaseNameField() { return ( <div className="flex flex-col gap-2"> - <label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="create-guide-release-name"> - {t($ => $['createGuide.release.releaseName'])} + <label + className="system-xs-medium-uppercase text-text-tertiary" + htmlFor="create-guide-release-name" + > + {t(($) => $['createGuide.release.releaseName'])} </label> <Input id="create-guide-release-name" value={releaseName} - onChange={event => setReleaseName(event.target.value)} - placeholder={t($ => $['createGuide.release.defaultName'])} + onChange={(event) => setReleaseName(event.target.value)} + placeholder={t(($) => $['createGuide.release.defaultName'])} required className="h-9" /> @@ -163,23 +175,20 @@ function ReleaseDescriptionField() { return ( <div className="flex flex-col gap-2"> <OptionalFieldLabel htmlFor="create-guide-release-description"> - {t($ => $['createGuide.release.releaseDescription'])} + {t(($) => $['createGuide.release.releaseDescription'])} </OptionalFieldLabel> <textarea id="create-guide-release-description" value={releaseDescription} - onChange={event => setReleaseDescription(event.target.value)} - placeholder={t($ => $['createGuide.release.releaseDescriptionPlaceholder'])} + onChange={(event) => setReleaseDescription(event.target.value)} + placeholder={t(($) => $['createGuide.release.releaseDescriptionPlaceholder'])} className={releaseTextareaClassName} /> </div> ) } -function OptionalFieldLabel({ children, htmlFor }: { - children: string - htmlFor: string -}) { +function OptionalFieldLabel({ children, htmlFor }: { children: string; htmlFor: string }) { const { t } = useTranslation('deployments') return ( @@ -187,7 +196,9 @@ function OptionalFieldLabel({ children, htmlFor }: { <label className="system-xs-medium-uppercase text-text-tertiary" htmlFor={htmlFor}> {children} </label> - <span className="system-xs-regular text-text-quaternary">{t($ => $['versions.optional'])}</span> + <span className="system-xs-regular text-text-quaternary"> + {t(($) => $['versions.optional'])} + </span> </div> ) } @@ -201,10 +212,10 @@ export function ReleaseActionButtons() { return ( <> <Button type="button" variant="secondary" onClick={() => setStep('source')}> - {t($ => $['createGuide.actions.back'])} + {t(($) => $['createGuide.actions.back'])} </Button> <Button type="button" variant="primary" disabled={!canGoNext} onClick={continueFromRelease}> - {t($ => $['createGuide.actions.next'])} + {t(($) => $['createGuide.actions.next'])} </Button> </> ) diff --git a/web/features/deployments/create-guide/ui/shell.tsx b/web/features/deployments/create-guide/ui/shell.tsx index ce8cf285d5e0f4..936a74667fcaa0 100644 --- a/web/features/deployments/create-guide/ui/shell.tsx +++ b/web/features/deployments/create-guide/ui/shell.tsx @@ -3,14 +3,8 @@ import { useAtomValue } from 'jotai' import { stepAtom } from '@/features/deployments/create-guide/state/primitives' import { GuideCard, GuideFrame } from './layout' -import { - ReleaseActionButtons, - ReleaseStepContent, -} from './release-step' -import { - SourceActionButtons, - SourceStepContent, -} from './source-step' +import { ReleaseActionButtons, ReleaseStepContent } from './release-step' +import { SourceActionButtons, SourceStepContent } from './source-step' import { TargetBackButton, TargetDeployButton, @@ -23,10 +17,7 @@ export function CreateDeploymentGuideShell() { return ( <GuideFrame activeStep={step}> - <GuideCard - contentScrollable={step !== 'source'} - actions={<CreateDeploymentGuideActionBar />} - > + <GuideCard contentScrollable={step !== 'source'} actions={<CreateDeploymentGuideActionBar />}> <CreateDeploymentGuideStepContent /> </GuideCard> </GuideFrame> diff --git a/web/features/deployments/create-guide/ui/source-step.tsx b/web/features/deployments/create-guide/ui/source-step.tsx index 8565182a1ce8e4..49b1905ea821a5 100644 --- a/web/features/deployments/create-guide/ui/source-step.tsx +++ b/web/features/deployments/create-guide/ui/source-step.tsx @@ -1,6 +1,9 @@ 'use client' -import type { GuideMethod, WorkflowSourceApp } from '@/features/deployments/create-guide/state/types' +import type { + GuideMethod, + WorkflowSourceApp, +} from '@/features/deployments/create-guide/state/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' @@ -54,12 +57,8 @@ export function SourceStepContent() { return ( <div className="flex min-h-0 flex-1 flex-col gap-4"> <SourceMethodSection /> - {method === 'bindApp' && ( - <SourceAppSelectionSection /> - )} - {method === 'importDsl' && ( - <DslUploadSection /> - )} + {method === 'bindApp' && <SourceAppSelectionSection />} + {method === 'importDsl' && <DslUploadSection />} <UnsupportedDslNodesAlert nodes={unsupportedDslNodes} /> </div> ) @@ -72,8 +71,8 @@ function SourceMethodSection() { return ( <StepShell - title={t($ => $['createGuide.steps.method'])} - description={t($ => $['createGuide.method.description'])} + title={t(($) => $['createGuide.steps.method'])} + description={t(($) => $['createGuide.method.description'])} descriptionClassName="lg:hidden" hideHeader > @@ -85,15 +84,15 @@ function SourceMethodSection() { <SourceMethodCard value="bindApp" icon="i-ri-stack-line" - title={t($ => $['createGuide.methods.bindApp.title'])} - description={t($ => $['createGuide.methods.bindApp.description'])} + title={t(($) => $['createGuide.methods.bindApp.title'])} + description={t(($) => $['createGuide.methods.bindApp.description'])} /> {isDeploymentDslImportEnabled && ( <SourceMethodCard value="importDsl" icon="i-ri-file-code-line" - title={t($ => $['createGuide.methods.importDsl.title'])} - description={t($ => $['createGuide.methods.importDsl.description'])} + title={t(($) => $['createGuide.methods.importDsl.title'])} + description={t(($) => $['createGuide.methods.importDsl.description'])} /> )} </RadioGroup> @@ -101,7 +100,13 @@ function SourceMethodSection() { ) } -function SourceMethodCard({ value, icon, title, description, badge }: { +function SourceMethodCard({ + value, + icon, + title, + description, + badge, +}: { value: GuideMethod icon: string title: string @@ -114,10 +119,7 @@ function SourceMethodCard({ value, icon, title, description, badge }: { nativeButton render={<button type="button" />} className={cn( - `relative box-content h-[84px] w-full cursor-pointer rounded-xl border-[0.5px] - border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3 - text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2 - focus-visible:ring-state-accent-solid sm:w-[240px]`, + `relative box-content h-[84px] w-full cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3 text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid sm:w-[240px]`, 'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:shadow-md data-checked:inset-ring-[0.5px] data-checked:inset-ring-components-option-card-option-selected-border', )} > @@ -148,8 +150,8 @@ function SourceAppSelectionSection() { return ( <StepShell - title={t($ => $['createGuide.source.title'])} - description={t($ => $['createGuide.source.description'])} + title={t(($) => $['createGuide.source.title'])} + description={t(($) => $['createGuide.source.description'])} descriptionClassName="lg:hidden" hideHeader className="min-h-0 flex-1" @@ -169,19 +171,22 @@ function SourceSearchInput() { return ( <div className="relative"> - <span className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" aria-hidden="true" /> + <span + className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" + aria-hidden="true" + /> <Input id="create-guide-source-search" - aria-label={t($ => $['createGuide.source.sourceApp'])} + aria-label={t(($) => $['createGuide.source.sourceApp'])} value={sourceSearchText} - onChange={event => setSourceSearchText(event.target.value)} - placeholder={t($ => $['createGuide.source.searchPlaceholder'])} + onChange={(event) => setSourceSearchText(event.target.value)} + placeholder={t(($) => $['createGuide.source.searchPlaceholder'])} className="h-9 pr-8 pl-8" /> {sourceSearchText && ( <button type="button" - aria-label={t($ => $['createGuide.source.clearSearch'])} + aria-label={t(($) => $['createGuide.source.clearSearch'])} onClick={() => setSourceSearchText('')} className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary" > @@ -204,48 +209,55 @@ function SourceAppList() { const sourceAppsIsFetchingNextPage = useAtomValue(sourceAppsIsFetchingNextPageAtom) const sourceAppsIsLoading = useAtomValue(sourceAppsIsLoadingAtom) const sourceAppsIsPlaceholderData = useAtomValue(sourceAppsIsPlaceholderDataAtom) - const sourceAppsLoading = sourceAppsIsLoading || sourceAppsIsPlaceholderData || (sourceAppsIsFetching && sourceApps.length === 0) - const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>({ - error: sourceAppsError, - fetchNextPage: sourceAppsFetchNextPage, - hasNextPage: sourceAppsHasNextPage, - isFetching: sourceAppsIsFetching, - isFetchingNextPage: sourceAppsIsFetchingNextPage, - isLoading: sourceAppsIsLoading, - }, { - enabled: !sourceAppsLoading, - rootMargin: '0px 0px 160px 0px', - threshold: 0.1, - }) + const sourceAppsLoading = + sourceAppsIsLoading || + sourceAppsIsPlaceholderData || + (sourceAppsIsFetching && sourceApps.length === 0) + const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>( + { + error: sourceAppsError, + fetchNextPage: sourceAppsFetchNextPage, + hasNextPage: sourceAppsHasNextPage, + isFetching: sourceAppsIsFetching, + isFetchingNextPage: sourceAppsIsFetchingNextPage, + isLoading: sourceAppsIsLoading, + }, + { + enabled: !sourceAppsLoading, + rootMargin: '0px 0px 160px 0px', + threshold: 0.1, + }, + ) return ( - <div ref={rootRef} className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default"> - {sourceAppsLoading - ? <SourceAppSkeleton /> - : sourceApps.length === 0 - ? ( - <DeploymentStateMessage variant="embedded"> - {t($ => $['createGuide.source.empty'])} - </DeploymentStateMessage> - ) - : ( - <div> - {sourceApps.map(app => ( - <SourceAppOption - key={app.id} - app={app} - selected={effectiveSelectedApp?.id === app.id} - onSelect={() => selectSourceApp(app)} - /> - ))} - {sourceAppsIsFetchingNextPage && ( - <div className="border-t border-divider-subtle px-3 py-2 text-center system-xs-regular text-text-tertiary"> - {t($ => $['createModal.loadingApps'])} - </div> - )} - {sourceAppsHasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />} - </div> - )} + <div + ref={rootRef} + className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default" + > + {sourceAppsLoading ? ( + <SourceAppSkeleton /> + ) : sourceApps.length === 0 ? ( + <DeploymentStateMessage variant="embedded"> + {t(($) => $['createGuide.source.empty'])} + </DeploymentStateMessage> + ) : ( + <div> + {sourceApps.map((app) => ( + <SourceAppOption + key={app.id} + app={app} + selected={effectiveSelectedApp?.id === app.id} + onSelect={() => selectSourceApp(app)} + /> + ))} + {sourceAppsIsFetchingNextPage && ( + <div className="border-t border-divider-subtle px-3 py-2 text-center system-xs-regular text-text-tertiary"> + {t(($) => $['createModal.loadingApps'])} + </div> + )} + {sourceAppsHasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />} + </div> + )} </div> ) } @@ -253,7 +265,7 @@ function SourceAppList() { function SourceAppSkeleton() { return ( <div className="divide-y divide-divider-subtle"> - {sourceAppSkeletonKeys.map(key => ( + {sourceAppSkeletonKeys.map((key) => ( <SkeletonRow key={key} className="h-14 px-3 py-2"> <SkeletonRectangle className="my-0 size-7 animate-pulse rounded-lg" /> <div className="flex min-w-0 grow flex-col gap-1"> @@ -266,7 +278,11 @@ function SourceAppSkeleton() { ) } -function SourceAppOption({ app, onSelect, selected }: { +function SourceAppOption({ + app, + onSelect, + selected, +}: { app: WorkflowSourceApp onSelect: () => void selected: boolean @@ -289,7 +305,14 @@ function SourceAppOption({ app, onSelect, selected }: { imageUrl={app.icon_url} /> <span className="flex min-w-0 grow"> - <span className={cn('truncate system-sm-medium', selected ? 'text-text-accent' : 'text-text-primary')}>{app.name}</span> + <span + className={cn( + 'truncate system-sm-medium', + selected ? 'text-text-accent' : 'text-text-primary', + )} + > + {app.name} + </span> </span> <input type="radio" @@ -317,20 +340,27 @@ function DslUploadSection() { const selectDslFile = useSetAtom(selectDslFileAtom) return ( - <StepShell title={t($ => $['createGuide.dsl.title'])} description={t($ => $['createGuide.dsl.description'])} hideHeader> + <StepShell + title={t(($) => $['createGuide.dsl.title'])} + description={t(($) => $['createGuide.dsl.description'])} + hideHeader + > <div className="flex flex-col gap-4 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-5"> <div className="flex items-start gap-3"> - <span className="mt-0.5 i-ri-upload-cloud-2-line size-5 shrink-0 text-text-tertiary" aria-hidden="true" /> + <span + className="mt-0.5 i-ri-upload-cloud-2-line size-5 shrink-0 text-text-tertiary" + aria-hidden="true" + /> <div className="flex min-w-0 flex-col gap-1"> - <div className="system-sm-semibold text-text-primary">{t($ => $['createGuide.dsl.dropTitle'])}</div> - <div className="system-sm-regular text-text-tertiary">{t($ => $['createGuide.dsl.dropDescription'])}</div> + <div className="system-sm-semibold text-text-primary"> + {t(($) => $['createGuide.dsl.dropTitle'])} + </div> + <div className="system-sm-regular text-text-tertiary"> + {t(($) => $['createGuide.dsl.dropDescription'])} + </div> </div> </div> - <Uploader - className="mt-0" - file={dslFile} - updateFile={selectDslFile} - /> + <Uploader className="mt-0" file={dslFile} updateFile={selectDslFile} /> <DslReadStatus /> </div> </StepShell> @@ -347,17 +377,17 @@ function DslReadStatus() { <> {isReadingDsl && ( <div className="system-xs-regular text-text-tertiary"> - {t($ => $['createGuide.dsl.reading'])} + {t(($) => $['createGuide.dsl.reading'])} </div> )} {dslReadError && ( <div className="system-xs-regular text-text-destructive"> - {t($ => $['createGuide.dsl.readFailed'])} + {t(($) => $['createGuide.dsl.readFailed'])} </div> )} {dslUnsupportedMode && ( <div role="alert" className="system-xs-regular text-text-destructive"> - {t($ => $['createGuide.dsl.unsupportedMode'])} + {t(($) => $['createGuide.dsl.unsupportedMode'])} </div> )} </> @@ -374,12 +404,14 @@ export function SourceActionButtons() { type="button" variant="primary" disabled={!canGoNext} - onClick={() => continueFromSource({ - defaultDslAppName: t($ => $['createGuide.dsl.defaultAppName']), - defaultReleaseName: t($ => $['createGuide.release.defaultName']), - })} + onClick={() => + continueFromSource({ + defaultDslAppName: t(($) => $['createGuide.dsl.defaultAppName']), + defaultReleaseName: t(($) => $['createGuide.release.defaultName']), + }) + } > - {t($ => $['createGuide.actions.next'])} + {t(($) => $['createGuide.actions.next'])} </Button> ) } diff --git a/web/features/deployments/create-guide/ui/target-step.tsx b/web/features/deployments/create-guide/ui/target-step.tsx index 755a76bcb19981..78281844506324 100644 --- a/web/features/deployments/create-guide/ui/target-step.tsx +++ b/web/features/deployments/create-guide/ui/target-step.tsx @@ -40,12 +40,8 @@ import { selectBindingAtom, setEnvVarAtom, } from '@/features/deployments/create-guide/state/target' -import { - EnvVarBindingsPanel, -} from '@/features/deployments/shared/components/env-var-bindings' -import { - RuntimeCredentialBindingsPanel, -} from '@/features/deployments/shared/components/runtime-credential-bindings' +import { EnvVarBindingsPanel } from '@/features/deployments/shared/components/env-var-bindings' +import { RuntimeCredentialBindingsPanel } from '@/features/deployments/shared/components/runtime-credential-bindings' import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip' import { UnsupportedDslNodesAlert } from '@/features/deployments/shared/components/unsupported-dsl-nodes-alert' import { deploymentErrorMessage } from '@/features/deployments/shared/domain/error' @@ -61,8 +57,8 @@ export function TargetStepContent() { return ( <StepShell - title={t($ => $['createGuide.target.title'])} - description={t($ => $['createGuide.target.description'])} + title={t(($) => $['createGuide.target.title'])} + description={t(($) => $['createGuide.target.description'])} hideHeader > <div className="flex flex-col gap-6"> @@ -82,46 +78,44 @@ function TargetEnvironmentSection() { const environmentsIsLoading = useAtomValue(deployableEnvironmentsIsLoadingAtom) const environments = useAtomValue(deployableEnvironmentsAtom) const effectiveSelectedEnvironmentId = useAtomValue(effectiveSelectedEnvironmentIdAtom) - const isEnvironmentLoading = environmentsIsLoading || (environmentsIsFetching && environments.length === 0) + const isEnvironmentLoading = + environmentsIsLoading || (environmentsIsFetching && environments.length === 0) const selectEnvironment = useSetAtom(selectedEnvironmentIdAtom) const hasEnvironmentOptions = environments.length > 0 return ( <div className="flex flex-col gap-3"> - <div className="system-xs-medium-uppercase text-text-tertiary">{t($ => $['createGuide.target.environment'])}</div> - {hasEnvironmentOptions - ? ( - <RadioGroup<string> - value={effectiveSelectedEnvironmentId} - onValueChange={selectEnvironment} - className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-2" - > - {environments.map(environment => ( - <EnvironmentOptionRow - key={environment.id} - environment={environment} - /> - ))} - </RadioGroup> - ) - : isEnvironmentLoading - ? <TargetEnvironmentSkeleton /> - : ( - <div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-quaternary"> - {environmentsIsError - ? t($ => $['createGuide.target.loadEnvironmentsFailed']) - : t($ => $['createGuide.target.noEnvironmentOptions'])} - </div> - )} + <div className="system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['createGuide.target.environment'])} + </div> + {hasEnvironmentOptions ? ( + <RadioGroup<string> + value={effectiveSelectedEnvironmentId} + onValueChange={selectEnvironment} + className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-2" + > + {environments.map((environment) => ( + <EnvironmentOptionRow key={environment.id} environment={environment} /> + ))} + </RadioGroup> + ) : isEnvironmentLoading ? ( + <TargetEnvironmentSkeleton /> + ) : ( + <div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-quaternary"> + {environmentsIsError + ? t(($) => $['createGuide.target.loadEnvironmentsFailed']) + : t(($) => $['createGuide.target.noEnvironmentOptions'])} + </div> + )} </div> ) } -function EnvironmentOptionRow({ environment }: { - environment: Environment -}) { +function EnvironmentOptionRow({ environment }: { environment: Environment }) { const { t } = useTranslation('deployments') - const summary = environment.description.trim() || `${t($ => $[`mode.${environment.mode}`])} · ${t($ => $[`backend.${environment.backend}`])}` + const summary = + environment.description.trim() || + `${t(($) => $[`mode.${environment.mode}`])} · ${t(($) => $[`backend.${environment.backend}`])}` return ( <RadioItem<string> @@ -137,7 +131,9 @@ function EnvironmentOptionRow({ environment }: { > <RadioControl /> <span className="flex min-w-0 grow flex-col gap-1"> - <span className="truncate system-sm-semibold text-text-primary group-data-checked:text-text-accent">{environment.displayName}</span> + <span className="truncate system-sm-semibold text-text-primary group-data-checked:text-text-accent"> + {environment.displayName} + </span> <TitleTooltip content={summary}> <span className="line-clamp-1 system-xs-regular text-text-tertiary group-data-checked:text-text-secondary"> {summary} @@ -157,28 +153,32 @@ function TargetBindingSection() { const bindingSlots = useAtomValue(deploymentTargetBindingSlotsAtom) const bindingSelections = useAtomValue(deploymentTargetBindingSelectionsAtom) const isBindingError = deploymentOptionsIsError - const isBindingLoading = deploymentOptionsIsLoading || (deploymentOptionsIsFetching && !deploymentOptions) + const isBindingLoading = + deploymentOptionsIsLoading || (deploymentOptionsIsFetching && !deploymentOptions) const selectBinding = useSetAtom(selectBindingAtom) const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom) const shouldRender = !(isBindingError && unsupportedDslNodes.length > 0) - if (!shouldRender) - return null + if (!shouldRender) return null if (isBindingLoading || isBindingError) { return ( <div className="overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg"> <div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5"> - <div className="system-xs-medium-uppercase text-text-tertiary">{t($ => $['createGuide.target.bindings'])}</div> - <span className="system-xs-regular text-text-tertiary">{t($ => $['createGuide.target.bindingHint'])}</span> + <div className="system-xs-medium-uppercase text-text-tertiary"> + {t(($) => $['createGuide.target.bindings'])} + </div> + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['createGuide.target.bindingHint'])} + </span> </div> - {isBindingLoading - ? <TargetBindingSkeleton /> - : ( - <div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary"> - {t($ => $['createGuide.target.loadBindingsFailed'])} - </div> - )} + {isBindingLoading ? ( + <TargetBindingSkeleton /> + ) : ( + <div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary"> + {t(($) => $['createGuide.target.loadBindingsFailed'])} + </div> + )} </div> ) } @@ -187,13 +187,15 @@ function TargetBindingSection() { <RuntimeCredentialBindingsPanel slots={bindingSlots} selections={bindingSelections} - title={t($ => $['createGuide.target.bindings'])} - hint={t($ => $['createGuide.target.bindingHint'])} - noBindingRequiredLabel={t($ => $['createGuide.target.noBindingRequired'])} - noCredentialCandidatesLabel={t($ => $['createGuide.target.noCredentialCandidates'])} - selectCredentialLabel={t($ => $['createGuide.target.selectCredential'])} - missingRequiredLabel={t($ => $['createGuide.target.missingRequiredBinding'])} - bindingCountLabel={t($ => $['createGuide.target.bindingCount'], { count: bindingSlots.length })} + title={t(($) => $['createGuide.target.bindings'])} + hint={t(($) => $['createGuide.target.bindingHint'])} + noBindingRequiredLabel={t(($) => $['createGuide.target.noBindingRequired'])} + noCredentialCandidatesLabel={t(($) => $['createGuide.target.noCredentialCandidates'])} + selectCredentialLabel={t(($) => $['createGuide.target.selectCredential'])} + missingRequiredLabel={t(($) => $['createGuide.target.missingRequiredBinding'])} + bindingCountLabel={t(($) => $['createGuide.target.bindingCount'], { + count: bindingSlots.length, + })} onChange={selectBinding} listScrollable={false} className="border-components-option-card-option-border bg-components-option-card-option-bg" @@ -211,28 +213,30 @@ function TargetEnvVarSection() { const deploymentOptionsIsLoading = useAtomValue(deploymentOptionsIsLoadingAtom) const envVarSlots = useAtomValue(deploymentTargetEnvVarSlotsAtom) const isBindingError = deploymentOptionsIsError - const isBindingLoading = deploymentOptionsIsLoading || (deploymentOptionsIsFetching && !deploymentOptions) + const isBindingLoading = + deploymentOptionsIsLoading || (deploymentOptionsIsFetching && !deploymentOptions) - if (isBindingLoading || isBindingError) - return null + if (isBindingLoading || isBindingError) return null return ( <EnvVarBindingsPanel slots={envVarSlots} values={envVarValues} - title={t($ => $['createGuide.target.envVars'])} - hint={t($ => $['createGuide.target.envVarHint'])} - envVarPlaceholder={t($ => $['createGuide.target.envVarPlaceholder'])} - literalSourceLabel={t($ => $['createGuide.target.envVarSource.literal'])} - defaultSourceLabel={t($ => $['createGuide.target.envVarSource.default'])} - lastDeploymentSourceLabel={t($ => $['createGuide.target.envVarSource.lastDeployment'])} + title={t(($) => $['createGuide.target.envVars'])} + hint={t(($) => $['createGuide.target.envVarHint'])} + envVarPlaceholder={t(($) => $['createGuide.target.envVarPlaceholder'])} + literalSourceLabel={t(($) => $['createGuide.target.envVarSource.literal'])} + defaultSourceLabel={t(($) => $['createGuide.target.envVarSource.default'])} + lastDeploymentSourceLabel={t(($) => $['createGuide.target.envVarSource.lastDeployment'])} valueTypeLabels={{ - string: t($ => $['createGuide.target.envVarType.string']), - number: t($ => $['createGuide.target.envVarType.number']), - secret: t($ => $['createGuide.target.envVarType.secret']), + string: t(($) => $['createGuide.target.envVarType.string']), + number: t(($) => $['createGuide.target.envVarType.number']), + secret: t(($) => $['createGuide.target.envVarType.secret']), }} - sourceAriaLabel={key => t($ => $['createGuide.target.envVarSource.ariaLabel'], { key })} - envVarCountLabel={t($ => $['createGuide.target.envVarCount'], { count: envVarSlots.length })} + sourceAriaLabel={(key) => t(($) => $['createGuide.target.envVarSource.ariaLabel'], { key })} + envVarCountLabel={t(($) => $['createGuide.target.envVarCount'], { + count: envVarSlots.length, + })} onChange={setEnvVar} listScrollable={false} className="border-components-option-card-option-border bg-components-option-card-option-bg" @@ -243,7 +247,7 @@ function TargetEnvVarSection() { function TargetEnvironmentSkeleton() { return ( <div className="grid grid-cols-1 gap-3 lg:grid-cols-2"> - {targetEnvironmentSkeletonKeys.map(key => ( + {targetEnvironmentSkeletonKeys.map((key) => ( <SkeletonRow key={key} className="h-17 rounded-xl border border-divider-subtle px-3 py-3"> <SkeletonRectangle className="my-0 size-4 animate-pulse rounded-full" /> <div className="flex min-w-0 grow flex-col gap-1.5"> @@ -259,7 +263,7 @@ function TargetEnvironmentSkeleton() { function TargetBindingSkeleton() { return ( <div className="border-t border-divider-subtle"> - {targetBindingSkeletonKeys.map(key => ( + {targetBindingSkeletonKeys.map((key) => ( <SkeletonRow key={key} className="h-15 px-3 py-3"> <div className="flex min-w-0 grow flex-col gap-1.5"> <SkeletonRectangle className="my-0 h-3.5 w-1/3 animate-pulse" /> @@ -278,8 +282,13 @@ export function TargetBackButton() { const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom) return ( - <Button type="button" variant="secondary" onClick={() => setStep('release')} disabled={isSubmitting}> - {t($ => $['createGuide.actions.back'])} + <Button + type="button" + variant="secondary" + onClick={() => setStep('release')} + disabled={isSubmitting} + > + {t(($) => $['createGuide.actions.back'])} </Button> ) } @@ -292,29 +301,31 @@ export function TargetSkipDeploymentButton() { const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom) const isSkippingDeployment = useAtomValue(isCreatingReleaseOnlyAtom) const label = isSkippingDeployment - ? t($ => $['createGuide.actions.creating']) - : t($ => $['createGuide.actions.skipDeploy']) + ? t(($) => $['createGuide.actions.creating']) + : t(($) => $['createGuide.actions.skipDeploy']) async function handleSkipDeployment() { - if (!canSkipDeployment) - return + if (!canSkipDeployment) return try { const appInstanceId = await submitCreateDeploymentGuide({ deployToEnvironment: false }) - if (appInstanceId) - router.push(`/deployments/${appInstanceId}/overview`) - } - catch (error) { + if (appInstanceId) router.push(`/deployments/${appInstanceId}/overview`) + } catch (error) { await showSubmissionError({ error, - fallbackMessage: t($ => $['createGuide.errors.createReleaseFailed']), - unsupportedDslModeMessage: t($ => $['createGuide.dsl.unsupportedMode']), + fallbackMessage: t(($) => $['createGuide.errors.createReleaseFailed']), + unsupportedDslModeMessage: t(($) => $['createGuide.dsl.unsupportedMode']), }) } } return ( - <Button type="button" variant="secondary" disabled={!canSkipDeployment || isSubmitting} onClick={handleSkipDeployment}> + <Button + type="button" + variant="secondary" + disabled={!canSkipDeployment || isSubmitting} + onClick={handleSkipDeployment} + > {label} </Button> ) @@ -327,30 +338,33 @@ export function TargetDeployButton() { const submitCreateDeploymentGuide = useSetAtom(createDeploymentGuideSubmissionAtom) const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom) const isSkippingDeployment = useAtomValue(isCreatingReleaseOnlyAtom) - const label = isSubmitting && !isSkippingDeployment - ? t($ => $['createGuide.actions.deploying']) - : t($ => $['createGuide.actions.createAndDeploy']) + const label = + isSubmitting && !isSkippingDeployment + ? t(($) => $['createGuide.actions.deploying']) + : t(($) => $['createGuide.actions.createAndDeploy']) async function handleDeploy() { - if (!canDeploy) - return + if (!canDeploy) return try { const appInstanceId = await submitCreateDeploymentGuide({ deployToEnvironment: true }) - if (appInstanceId) - router.push(`/deployments/${appInstanceId}/overview`) - } - catch (error) { + if (appInstanceId) router.push(`/deployments/${appInstanceId}/overview`) + } catch (error) { await showSubmissionError({ error, - fallbackMessage: t($ => $['createGuide.errors.deployFailed']), - unsupportedDslModeMessage: t($ => $['createGuide.dsl.unsupportedMode']), + fallbackMessage: t(($) => $['createGuide.errors.deployFailed']), + unsupportedDslModeMessage: t(($) => $['createGuide.dsl.unsupportedMode']), }) } } return ( - <Button type="button" variant="primary" disabled={!canDeploy || isSubmitting} onClick={handleDeploy}> + <Button + type="button" + variant="primary" + disabled={!canDeploy || isSubmitting} + onClick={handleDeploy} + > {label} </Button> ) @@ -370,5 +384,5 @@ async function showSubmissionError({ return } - toast.error(await deploymentErrorMessage(error) || fallbackMessage) + toast.error((await deploymentErrorMessage(error)) || fallbackMessage) } diff --git a/web/features/deployments/create-release/index.tsx b/web/features/deployments/create-release/index.tsx index 30ef6209fbc6de..6d3dba8c7c5d79 100644 --- a/web/features/deployments/create-release/index.tsx +++ b/web/features/deployments/create-release/index.tsx @@ -39,25 +39,13 @@ function CreateReleaseScopedControl({ return } - if (!isCreatingRelease) - requestCloseDialog() + if (!isCreatingRelease) requestCloseDialog() } return ( - <Dialog - open={open} - onOpenChange={handleDialogOpenChange} - > - <DialogTrigger - render={( - <Button - size={size} - variant={variant} - className={className} - /> - )} - > - {label ?? t($ => $['versions.createRelease'])} + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger render={<Button size={size} variant={variant} className={className} />}> + {label ?? t(($) => $['versions.createRelease'])} </DialogTrigger> <CreateReleaseDialogContent /> </Dialog> @@ -80,10 +68,7 @@ export function CreateReleaseControl({ return ( <ScopeProvider key={appInstanceId} - atoms={[ - [createReleaseAppInstanceIdAtom, appInstanceId], - ...createReleaseLocalAtoms, - ]} + atoms={[[createReleaseAppInstanceIdAtom, appInstanceId], ...createReleaseLocalAtoms]} name="CreateRelease" > <CreateReleaseScopedControl diff --git a/web/features/deployments/create-release/state/__tests__/dsl-enabled.spec.ts b/web/features/deployments/create-release/state/__tests__/dsl-enabled.spec.ts index 46ab9069664c42..baf2436452e8dd 100644 --- a/web/features/deployments/create-release/state/__tests__/dsl-enabled.spec.ts +++ b/web/features/deployments/create-release/state/__tests__/dsl-enabled.spec.ts @@ -53,49 +53,49 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => { return { ...actual, - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => { - const options = createOptions(get) - const queryKey = Array.isArray(options.queryKey) ? options.queryKey[0] : undefined - const queryName = typeof queryKey === 'string' ? queryKey : 'unknown' - const queryResult = options.enabled === false - ? undefined - : mockQueryResults.current.get(queryName) - - mockQueryOptions.current.set(queryName, options) - - return { - ...options, - data: undefined, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: false, - ...queryResult, - } - }), - atomWithInfiniteQuery: (createOptions: (get: Getter) => InfiniteQueryOptions) => atom((get) => { - const options = createOptions(get) - const queryKey = Array.isArray(options.queryKey) ? options.queryKey[0] : undefined - const queryName = typeof queryKey === 'string' ? queryKey : 'unknown' - const queryResult = options.enabled === false - ? undefined - : mockQueryResults.current.get(queryName) - - mockQueryOptions.current.set(queryName, options) - - return { - ...options, - data: undefined, - hasNextPage: false, - isError: false, - isFetching: false, - isFetchingNextPage: false, - isLoading: false, - isPlaceholderData: false, - isSuccess: false, - ...queryResult, - } - }), + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => + atom((get) => { + const options = createOptions(get) + const queryKey = Array.isArray(options.queryKey) ? options.queryKey[0] : undefined + const queryName = typeof queryKey === 'string' ? queryKey : 'unknown' + const queryResult = + options.enabled === false ? undefined : mockQueryResults.current.get(queryName) + + mockQueryOptions.current.set(queryName, options) + + return { + ...options, + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + ...queryResult, + } + }), + atomWithInfiniteQuery: (createOptions: (get: Getter) => InfiniteQueryOptions) => + atom((get) => { + const options = createOptions(get) + const queryKey = Array.isArray(options.queryKey) ? options.queryKey[0] : undefined + const queryName = typeof queryKey === 'string' ? queryKey : 'unknown' + const queryResult = + options.enabled === false ? undefined : mockQueryResults.current.get(queryName) + + mockQueryOptions.current.set(queryName, options) + + return { + ...options, + data: undefined, + hasNextPage: false, + isError: false, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + isPlaceholderData: false, + isSuccess: false, + ...queryResult, + } + }), atomWithMutation: () => atom(() => mockCreateReleaseMutation.current), } }) @@ -113,10 +113,12 @@ vi.mock('@/service/client', () => ({ enterprise: { releaseService: { listReleaseSummaries: { - key: ({ input }: { input?: unknown } = {}) => input === undefined ? ['listReleaseSummaries'] : ['listReleaseSummaries', input], + key: ({ input }: { input?: unknown } = {}) => + input === undefined ? ['listReleaseSummaries'] : ['listReleaseSummaries', input], }, listReleases: { - key: ({ input }: { input?: unknown } = {}) => input === undefined ? ['listReleases'] : ['listReleases', input], + key: ({ input }: { input?: unknown } = {}) => + input === undefined ? ['listReleases'] : ['listReleases', input], }, precheckRelease: { queryOptions: (options: QueryOptions) => ({ @@ -157,11 +159,7 @@ async function mountedStore() { } function workflowDsl() { - return [ - 'app:', - ' mode: workflow', - ' name: Release source', - ].join('\n') + return ['app:', ' mode: workflow', ' name: Release source'].join('\n') } function setDslFileContentResult(overrides: QueryResult = {}) { @@ -201,7 +199,9 @@ describe('create release state with DSL import enabled', () => { store.set(state.openCreateReleaseDialogAtom) store.set(state.createReleaseSourceAppSearchTextAtom, 'customer') - const sourceAppsQuery = store.get(state.createReleaseSourceAppsQueryAtom) as unknown as InfiniteQueryOptions + const sourceAppsQuery = store.get( + state.createReleaseSourceAppsQueryAtom, + ) as unknown as InfiniteQueryOptions expect(sourceAppsQuery.enabled).toBe(true) expect(sourceAppsQuery.input?.(2)).toEqual({ diff --git a/web/features/deployments/create-release/state/__tests__/index.spec.ts b/web/features/deployments/create-release/state/__tests__/index.spec.ts index 00a55a01a187ef..090ca39af73325 100644 --- a/web/features/deployments/create-release/state/__tests__/index.spec.ts +++ b/web/features/deployments/create-release/state/__tests__/index.spec.ts @@ -47,26 +47,26 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => { return { ...actual, - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => { - const options = createOptions(get) - const queryKey = Array.isArray(options.queryKey) ? options.queryKey[0] : undefined - const queryName = typeof queryKey === 'string' ? queryKey : 'unknown' - const queryResult = options.enabled === false - ? undefined - : mockQueryResults.current.get(queryName) - - mockQueryOptions.current.set(queryName, options) - - return { - ...options, - data: undefined, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: false, - ...queryResult, - } - }), + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => + atom((get) => { + const options = createOptions(get) + const queryKey = Array.isArray(options.queryKey) ? options.queryKey[0] : undefined + const queryName = typeof queryKey === 'string' ? queryKey : 'unknown' + const queryResult = + options.enabled === false ? undefined : mockQueryResults.current.get(queryName) + + mockQueryOptions.current.set(queryName, options) + + return { + ...options, + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + ...queryResult, + } + }), atomWithMutation: () => atom(() => mockCreateReleaseMutation.current), } }) @@ -87,7 +87,8 @@ vi.mock('@/service/client', () => ({ enterprise: { releaseService: { listReleaseSummaries: { - key: ({ input }: { input?: unknown } = {}) => input === undefined ? ['listReleaseSummaries'] : ['listReleaseSummaries', input], + key: ({ input }: { input?: unknown } = {}) => + input === undefined ? ['listReleaseSummaries'] : ['listReleaseSummaries', input], queryOptions: ({ enabled, input }: QueryOptions) => ({ enabled, input, @@ -95,7 +96,8 @@ vi.mock('@/service/client', () => ({ }), }, listReleases: { - key: ({ input }: { input?: unknown } = {}) => input === undefined ? ['listReleases'] : ['listReleases', input], + key: ({ input }: { input?: unknown } = {}) => + input === undefined ? ['listReleases'] : ['listReleases', input], queryOptions: ({ enabled, input }: QueryOptions) => ({ enabled, input, @@ -142,7 +144,9 @@ async function mountedStore() { } } -function sourceApp(overrides: Partial<NonNullable<CreateReleaseFormValues['sourceApp']>> = {}): NonNullable<CreateReleaseFormValues['sourceApp']> { +function sourceApp( + overrides: Partial<NonNullable<CreateReleaseFormValues['sourceApp']>> = {}, +): NonNullable<CreateReleaseFormValues['sourceApp']> { return { id: 'source-app-1', name: 'Source App', @@ -152,25 +156,22 @@ function sourceApp(overrides: Partial<NonNullable<CreateReleaseFormValues['sourc } function validationIssueMessage(error: unknown) { - if (!error || typeof error !== 'object' || !('message' in error)) - return undefined + if (!error || typeof error !== 'object' || !('message' in error)) return undefined return typeof error.message === 'string' ? error.message : undefined } function hasValidationIssue(errors: unknown[], message: string) { - return errors.some(error => validationIssueMessage(error) === message) + return errors.some((error) => validationIssueMessage(error) === message) } function workflowDsl() { - return [ - 'app:', - ' mode: workflow', - ' name: Release source', - ].join('\n') + return ['app:', ' mode: workflow', ' name: Release source'].join('\n') } -function setDefaultSourceApp(defaultSourceApp = sourceApp({ id: 'default-source-app', name: 'Default Source App' })) { +function setDefaultSourceApp( + defaultSourceApp = sourceApp({ id: 'default-source-app', name: 'Default Source App' }), +) { mockQueryResults.current.set('listReleases', { data: { releases: [ @@ -187,11 +188,13 @@ function setDefaultSourceApp(defaultSourceApp = sourceApp({ id: 'default-source- }) } -function setPrecheckReleaseResult(overrides: { - canCreate?: boolean - matchedRelease?: unknown - unsupportedNodes?: Array<{ id?: string, type?: string }> -} = {}) { +function setPrecheckReleaseResult( + overrides: { + canCreate?: boolean + matchedRelease?: unknown + unsupportedNodes?: Array<{ id?: string; type?: string }> + } = {}, +) { mockQueryResults.current.set('precheckRelease', { data: { gateCommitId: 'gate-commit-1', @@ -203,14 +206,18 @@ function setPrecheckReleaseResult(overrides: { }) } -function setCachedReleaseSummaries(queryClient: QueryClient, appInstanceId: string, displayNames: string[]) { +function setCachedReleaseSummaries( + queryClient: QueryClient, + appInstanceId: string, + displayNames: string[], +) { queryClient.setQueryData( consoleQuery.enterprise.releaseService.listReleaseSummaries.key({ type: 'query', input: { params: { appInstanceId } }, }), { - releaseSummaries: displayNames.map(displayName => ({ + releaseSummaries: displayNames.map((displayName) => ({ release: { displayName, }, @@ -245,10 +252,12 @@ describe('create release state', () => { await store.set(state.submitCreateReleaseFormAtom) expect(mockCreateReleaseMutation.current.mutateAsync).not.toHaveBeenCalled() - expect(hasValidationIssue( - store.get(state.createReleaseNameFieldAtom).meta?.errors ?? [], - state.RELEASE_NAME_REQUIRED_ERROR, - )).toBe(true) + expect( + hasValidationIssue( + store.get(state.createReleaseNameFieldAtom).meta?.errors ?? [], + state.RELEASE_NAME_REQUIRED_ERROR, + ), + ).toBe(true) unsubscribe() }) diff --git a/web/features/deployments/create-release/state/index.ts b/web/features/deployments/create-release/state/index.ts index 67a670b351a136..ab0eef329c61f8 100644 --- a/web/features/deployments/create-release/state/index.ts +++ b/web/features/deployments/create-release/state/index.ts @@ -10,10 +10,7 @@ import type { UnsupportedDslNode } from '../../shared/domain/error' import type { App } from '@/types/app' import { keepPreviousData, queryOptions, skipToken } from '@tanstack/react-query' import { atom } from 'jotai' -import { - atomWithForm, - createFormAtoms, -} from 'jotai-tanstack-form' +import { atomWithForm, createFormAtoms } from 'jotai-tanstack-form' import { atomWithInfiniteQuery, atomWithMutation, @@ -30,7 +27,8 @@ import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags' export type ReleaseSourceMode = 'sourceApp' | 'dsl' -export type SourceAppPickerValue = Pick<App, 'id' | 'name'> & Partial<Pick<App, 'icon_type' | 'icon' | 'icon_background' | 'icon_url' | 'mode'>> +export type SourceAppPickerValue = Pick<App, 'id' | 'name'> & + Partial<Pick<App, 'icon_type' | 'icon' | 'icon_background' | 'icon_url' | 'mode'>> export type CreateReleaseFormValues = { releaseSourceMode: ReleaseSourceMode @@ -54,19 +52,18 @@ const DEFAULT_SOURCE_RELEASE_PAGE_SIZE = 1 const CREATE_RELEASE_SOURCE_APP_PAGE_SIZE = 20 function deploymentReleaseSourceMode(mode: ReleaseSourceMode): ReleaseSourceMode { - return mode === 'dsl' && !isDeploymentDslImportEnabled - ? 'sourceApp' - : mode + return mode === 'dsl' && !isDeploymentDslImportEnabled ? 'sourceApp' : mode } -function workflowSourceAppPickerValue(value: unknown, fallbackId: string): SourceAppPickerValue | undefined { - if (!value || typeof value !== 'object') - return undefined +function workflowSourceAppPickerValue( + value: unknown, + fallbackId: string, +): SourceAppPickerValue | undefined { + if (!value || typeof value !== 'object') return undefined const record = value as Record<string, unknown> const mode = typeof record.mode === 'string' ? record.mode : undefined - if (mode !== AppModeEnum.WORKFLOW) - return undefined + if (mode !== AppModeEnum.WORKFLOW) return undefined const id = typeof record.id === 'string' && record.id ? record.id : fallbackId const name = typeof record.name === 'string' && record.name ? record.name : id @@ -86,7 +83,9 @@ const createReleaseFormSchema = z.object({ releaseDescription: z.string(), }) -type CreateReleaseSubmit = (value: CreateReleaseFormValues) => Promise<CreateReleaseResponse | undefined> | CreateReleaseResponse | undefined +type CreateReleaseSubmit = ( + value: CreateReleaseFormValues, +) => Promise<CreateReleaseResponse | undefined> | CreateReleaseResponse | undefined type CreateReleaseSubmitMeta = { createRelease: CreateReleaseSubmit @@ -109,11 +108,13 @@ export const createReleaseFormAtom = atomWithForm({ const createReleaseFormAtoms = createFormAtoms(createReleaseFormAtom) export const createReleaseFormIsSubmittingAtom = createReleaseFormAtoms.isSubmittingAtom -export const createReleaseSourceModeFieldAtom = createReleaseFormAtoms.fieldAtom('releaseSourceMode') +export const createReleaseSourceModeFieldAtom = + createReleaseFormAtoms.fieldAtom('releaseSourceMode') export const createReleaseSourceAppFieldAtom = createReleaseFormAtoms.fieldAtom('sourceApp') export const createReleaseDslFileFieldAtom = createReleaseFormAtoms.fieldAtom('dslFile') export const createReleaseNameFieldAtom = createReleaseFormAtoms.fieldAtom('releaseName') -export const createReleaseDescriptionFieldAtom = createReleaseFormAtoms.fieldAtom('releaseDescription') +export const createReleaseDescriptionFieldAtom = + createReleaseFormAtoms.fieldAtom('releaseDescription') // Dialog and source primitives export const createReleaseAppInstanceIdAtom = atom<string | undefined>(undefined) @@ -122,8 +123,7 @@ const createReleaseDslFileReadVersionAtom = atom(0) function requiredAppInstanceId(get: Getter) { const appInstanceId = get(createReleaseAppInstanceIdAtom) - if (!appInstanceId) - throw new Error('Missing create release app instance id.') + if (!appInstanceId) throw new Error('Missing create release app instance id.') return appInstanceId } @@ -167,8 +167,7 @@ const defaultSourceAppQueryAtom = atomWithQuery((get) => { function defaultSourceApp(get: Getter) { const latestSourceAppId = latestReleaseSourceAppId(get) - if (!latestSourceAppId) - return undefined + if (!latestSourceAppId) return undefined return workflowSourceAppPickerValue(get(defaultSourceAppQueryAtom).data, latestSourceAppId) } @@ -179,8 +178,7 @@ function submittedReleaseName(get: Getter) { function cachedReleaseDisplayNames(get: Getter) { const appInstanceId = get(createReleaseAppInstanceIdAtom) - if (!appInstanceId) - return [] + if (!appInstanceId) return [] const queryClient = get(queryClientAtom) const releaseSummaryQueries = queryClient.getQueriesData<ListReleaseSummariesResponse>({ @@ -198,20 +196,19 @@ function cachedReleaseDisplayNames(get: Getter) { return [ ...releaseSummaryQueries.flatMap(([, data]) => { - return data?.releaseSummaries.map(summary => summary.release.displayName) ?? [] + return data?.releaseSummaries.map((summary) => summary.release.displayName) ?? [] }), ...releaseQueries.flatMap(([, data]) => { - return data?.releases.map(release => release.displayName) ?? [] + return data?.releases.map((release) => release.displayName) ?? [] }), ] } export const createReleaseHasNameConflictAtom = atom((get) => { const releaseName = submittedReleaseName(get) - if (!releaseName) - return false + if (!releaseName) return false - return cachedReleaseDisplayNames(get).some(displayName => displayName.trim() === releaseName) + return cachedReleaseDisplayNames(get).some((displayName) => displayName.trim() === releaseName) }) const createReleaseDslFileContentQueryAtom = atomWithQuery((get) => { @@ -227,7 +224,7 @@ const createReleaseDslFileContentQueryAtom = atomWithQuery((get) => { file?.size ?? 0, file?.lastModified ?? 0, ], - queryFn: async () => file ? await file.text() : '', + queryFn: async () => (file ? await file.text() : ''), enabled: Boolean(file), retry: false, }) @@ -248,7 +245,7 @@ export const createReleaseSourceAppsQueryAtom = atomWithInfiniteQuery((get) => { const searchText = get(createReleaseSourceAppSearchTextAtom) return consoleQuery.apps.get.infiniteOptions({ - input: pageParam => ({ + input: (pageParam) => ({ query: { page: Number(pageParam), limit: CREATE_RELEASE_SOURCE_APP_PAGE_SIZE, @@ -256,31 +253,52 @@ export const createReleaseSourceAppsQueryAtom = atomWithInfiniteQuery((get) => { mode: AppModeEnum.WORKFLOW, }, }), - getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), initialPageParam: 1, placeholderData: keepPreviousData, - select: data => ({ + select: (data) => ({ ...data, pages: data.pages.map(normalizeAppPagination), }), enabled: Boolean( - get(createReleaseDialogOpenAtom) - && effectiveCreateReleaseSourceMode(get) === 'sourceApp' - && isDeploymentDslImportEnabled, + get(createReleaseDialogOpenAtom) && + effectiveCreateReleaseSourceMode(get) === 'sourceApp' && + isDeploymentDslImportEnabled, ), }) }) -const createReleaseSourceAppsDataAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.data) -export const createReleaseSourceAppsErrorAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.error) -export const createReleaseSourceAppsFetchNextPageAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.fetchNextPage) -export const createReleaseSourceAppsHasNextPageAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.hasNextPage) -export const createReleaseSourceAppsIsFetchingAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.isFetching) -export const createReleaseSourceAppsIsFetchingNextPageAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.isFetchingNextPage) -export const createReleaseSourceAppsIsLoadingAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.isLoading) +const createReleaseSourceAppsDataAtom = selectAtom( + createReleaseSourceAppsQueryAtom, + (query) => query.data, +) +export const createReleaseSourceAppsErrorAtom = selectAtom( + createReleaseSourceAppsQueryAtom, + (query) => query.error, +) +export const createReleaseSourceAppsFetchNextPageAtom = selectAtom( + createReleaseSourceAppsQueryAtom, + (query) => query.fetchNextPage, +) +export const createReleaseSourceAppsHasNextPageAtom = selectAtom( + createReleaseSourceAppsQueryAtom, + (query) => query.hasNextPage, +) +export const createReleaseSourceAppsIsFetchingAtom = selectAtom( + createReleaseSourceAppsQueryAtom, + (query) => query.isFetching, +) +export const createReleaseSourceAppsIsFetchingNextPageAtom = selectAtom( + createReleaseSourceAppsQueryAtom, + (query) => query.isFetchingNextPage, +) +export const createReleaseSourceAppsIsLoadingAtom = selectAtom( + createReleaseSourceAppsQueryAtom, + (query) => query.isLoading, +) export const createReleaseSourceAppsAtom = atom((get) => { - return get(createReleaseSourceAppsDataAtom)?.pages.flatMap(page => page.data) ?? [] + return get(createReleaseSourceAppsDataAtom)?.pages.flatMap((page) => page.data) ?? [] }) export const createReleaseDslContentAtom = atom((get) => { @@ -288,7 +306,9 @@ export const createReleaseDslContentAtom = atom((get) => { }) export const createReleaseDslReadErrorAtom = atom((get) => { - return Boolean(get(createReleaseDslFileFieldAtom).value && get(createReleaseDslFileContentQueryAtom).isError) + return Boolean( + get(createReleaseDslFileFieldAtom).value && get(createReleaseDslFileContentQueryAtom).isError, + ) }) export const isReadingCreateReleaseDslAtom = atom((get) => { @@ -317,14 +337,12 @@ export const createReleaseEncodedDslContentAtom = atom((get) => { }) export const createReleaseSelectedSourceAppAtom = atom((get) => { - if (effectiveCreateReleaseSourceMode(get) !== 'sourceApp') - return undefined + if (effectiveCreateReleaseSourceMode(get) !== 'sourceApp') return undefined const fieldSourceApp = get(createReleaseSourceAppFieldAtom).value const fallbackSourceApp = defaultSourceApp(get) - if (!isDeploymentDslImportEnabled) - return fallbackSourceApp + if (!isDeploymentDslImportEnabled) return fallbackSourceApp return fieldSourceApp ?? fallbackSourceApp }) @@ -336,13 +354,14 @@ function selectedSourceAppId(get: Getter) { } function hasUnsupportedDslMode(get: Getter) { - if (effectiveCreateReleaseSourceMode(get) !== 'dsl') - return false + if (effectiveCreateReleaseSourceMode(get) !== 'dsl') return false - return get(createReleaseHasDslContentAtom) - && !get(isReadingCreateReleaseDslAtom) - && !get(createReleaseDslReadErrorAtom) - && !get(createReleaseIsWorkflowDslContentAtom) + return ( + get(createReleaseHasDslContentAtom) && + !get(isReadingCreateReleaseDslAtom) && + !get(createReleaseDslReadErrorAtom) && + !get(createReleaseIsWorkflowDslContentAtom) + ) } export const createReleaseHasUnsupportedDslModeAtom = atom((get) => { @@ -352,22 +371,21 @@ export const createReleaseHasUnsupportedDslModeAtom = atom((get) => { function canCheckReleaseSourceContent(get: Getter) { if (effectiveCreateReleaseSourceMode(get) === 'sourceApp') return Boolean(selectedSourceAppId(get)) - if (!isDeploymentDslImportEnabled) - return false + if (!isDeploymentDslImportEnabled) return false return Boolean( - get(createReleaseHasDslContentAtom) - && !get(isReadingCreateReleaseDslAtom) - && !get(createReleaseDslReadErrorAtom) - && !hasUnsupportedDslMode(get), + get(createReleaseHasDslContentAtom) && + !get(isReadingCreateReleaseDslAtom) && + !get(createReleaseDslReadErrorAtom) && + !hasUnsupportedDslMode(get), ) } function canCheckReleaseContent(get: Getter) { return Boolean( - get(createReleaseAppInstanceIdAtom) - && get(createReleaseDialogOpenAtom) - && canCheckReleaseSourceContent(get), + get(createReleaseAppInstanceIdAtom) && + get(createReleaseDialogOpenAtom) && + canCheckReleaseSourceContent(get), ) } @@ -379,23 +397,24 @@ const precheckReleaseQueryAtom = atomWithQuery((get) => { const canCheck = canCheckReleaseContent(get) return consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({ - input: canCheck && appInstanceId - ? releaseSourceMode === 'dsl' - ? { - body: { - appInstanceId, - dsl: get(createReleaseEncodedDslContentAtom), - }, - } - : sourceAppId + input: + canCheck && appInstanceId + ? releaseSourceMode === 'dsl' ? { body: { appInstanceId, - sourceAppId, + dsl: get(createReleaseEncodedDslContentAtom), }, } - : skipToken - : skipToken, + : sourceAppId + ? { + body: { + appInstanceId, + sourceAppId, + }, + } + : skipToken + : skipToken, enabled: canCheck, retry: false, }) @@ -420,7 +439,7 @@ export const createReleaseContentCheckFailedAtom = atom((get) => { export const createReleaseUnsupportedDslNodesAtom = atom((get): UnsupportedDslNode[] => { return canCheckReleaseContent(get) - ? get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? [] + ? (get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? []) : [] }) @@ -428,12 +447,14 @@ export const createReleaseContentReadyAtom = atom((get) => { const canCheck = canCheckReleaseContent(get) const precheckReleaseQuery = get(precheckReleaseQueryAtom) - return canCheck - && precheckReleaseQuery.isSuccess - && !get(isCheckingCreateReleaseContentAtom) - && !get(createReleaseContentCheckFailedAtom) - && Boolean(precheckReleaseQuery.data?.canCreate) - && get(createReleaseUnsupportedDslNodesAtom).length === 0 + return ( + canCheck && + precheckReleaseQuery.isSuccess && + !get(isCheckingCreateReleaseContentAtom) && + !get(createReleaseContentCheckFailedAtom) && + Boolean(precheckReleaseQuery.data?.canCreate) && + get(createReleaseUnsupportedDslNodesAtom).length === 0 + ) }) // Actions @@ -459,32 +480,40 @@ export const closeCreateReleaseDialogAtom = atom(null, (_get, set) => { }) export const requestCloseCreateReleaseDialogAtom = atom(null, (get, set) => { - if (get(createReleaseFormIsSubmittingAtom)) - return + if (get(createReleaseFormIsSubmittingAtom)) return set(closeCreateReleaseDialogAtom) }) -export const selectCreateReleaseSourceModeAtom = atom(null, (_get, set, releaseSourceMode: ReleaseSourceMode) => { - const effectiveReleaseSourceMode = deploymentReleaseSourceMode(releaseSourceMode) - set(createReleaseSourceModeFieldAtom, effectiveReleaseSourceMode) +export const selectCreateReleaseSourceModeAtom = atom( + null, + (_get, set, releaseSourceMode: ReleaseSourceMode) => { + const effectiveReleaseSourceMode = deploymentReleaseSourceMode(releaseSourceMode) + set(createReleaseSourceModeFieldAtom, effectiveReleaseSourceMode) - if (effectiveReleaseSourceMode === 'sourceApp') { - set(resetCreateReleaseDslFileAtom) - return - } + if (effectiveReleaseSourceMode === 'sourceApp') { + set(resetCreateReleaseDslFileAtom) + return + } - set(createReleaseSourceAppFieldAtom, undefined) -}) + set(createReleaseSourceAppFieldAtom, undefined) + }, +) -export const updateCreateReleaseSourceAppAtom = atom(null, (_get, set, sourceApp: CreateReleaseFormValues['sourceApp']) => { - set(createReleaseSourceAppFieldAtom, sourceApp) -}) +export const updateCreateReleaseSourceAppAtom = atom( + null, + (_get, set, sourceApp: CreateReleaseFormValues['sourceApp']) => { + set(createReleaseSourceAppFieldAtom, sourceApp) + }, +) -export const updateCreateReleaseDslFileAtom = atom(null, (get, set, dslFile: CreateReleaseFormValues['dslFile']) => { - set(createReleaseDslFileFieldAtom, dslFile) - set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1) -}) +export const updateCreateReleaseDslFileAtom = atom( + null, + (get, set, dslFile: CreateReleaseFormValues['dslFile']) => { + set(createReleaseDslFileFieldAtom, dslFile) + set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1) + }, +) // Submission const createReleaseMutationAtom = atomWithMutation(() => @@ -510,14 +539,11 @@ const createReleaseSubmissionAtom = atom(null, async (get, set, value: CreateRel const sourceAppId = selectedSourceAppId(get) const submittedReleaseName = value.releaseName.trim() - if (get(isCheckingCreateReleaseContentAtom) || !submittedReleaseName) - return undefined + if (get(isCheckingCreateReleaseContentAtom) || !submittedReleaseName) return undefined - if (get(createReleaseHasNameConflictAtom)) - return undefined + if (get(createReleaseHasNameConflictAtom)) return undefined - if (!canCheckReleaseSourceContent(get) || !get(createReleaseContentReadyAtom)) - return undefined + if (!canCheckReleaseSourceContent(get) || !get(createReleaseContentReadyAtom)) return undefined const appInstanceId = requiredAppInstanceId(get) const commonCreateReleaseRequest = { @@ -539,8 +565,7 @@ const createReleaseSubmissionAtom = atom(null, async (get, set, value: CreateRel }) } - if (!sourceAppId) - return undefined + if (!sourceAppId) return undefined return await get(createReleaseMutationAtom).mutateAsync({ body: { @@ -554,14 +579,15 @@ export const submitCreateReleaseFormAtom = atom(null, (get, set) => { const form = get(createReleaseFormAtom) let submitResponse: CreateReleaseResponse | undefined - return form.api.handleSubmit({ - createRelease: async (value) => { - const response = await set(createReleaseSubmissionAtom, value) - submitResponse = response + return form.api + .handleSubmit({ + createRelease: async (value) => { + const response = await set(createReleaseSubmissionAtom, value) + submitResponse = response - return response - }, - } satisfies CreateReleaseSubmitMeta) + return response + }, + } satisfies CreateReleaseSubmitMeta) .then(() => submitResponse) }) diff --git a/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx index d7bbc829df1467..98445eafbaeba8 100644 --- a/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx +++ b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx @@ -7,12 +7,16 @@ import { SourceAppPicker } from '../source-app-picker' const mocks = vi.hoisted(() => { const sourceAppsQuery = { data: { - pages: [{ - data: [{ - id: 'app-1', - name: 'Workflow App', - }], - }], + pages: [ + { + data: [ + { + id: 'app-1', + name: 'Workflow App', + }, + ], + }, + ], }, error: null, fetchNextPage: vi.fn(), @@ -35,12 +39,16 @@ vi.mock('@/features/deployments/create-release/state', async () => { const { atom } = await import('jotai') return { - createReleaseSourceAppsAtom: atom(() => mocks.sourceAppsQuery.data.pages.flatMap(page => page.data)), + createReleaseSourceAppsAtom: atom(() => + mocks.sourceAppsQuery.data.pages.flatMap((page) => page.data), + ), createReleaseSourceAppsErrorAtom: atom(() => mocks.sourceAppsQuery.error), createReleaseSourceAppsFetchNextPageAtom: atom(() => mocks.sourceAppsQuery.fetchNextPage), createReleaseSourceAppsHasNextPageAtom: atom(() => mocks.sourceAppsQuery.hasNextPage), createReleaseSourceAppsIsFetchingAtom: atom(() => mocks.sourceAppsQuery.isFetching), - createReleaseSourceAppsIsFetchingNextPageAtom: atom(() => mocks.sourceAppsQuery.isFetchingNextPage), + createReleaseSourceAppsIsFetchingNextPageAtom: atom( + () => mocks.sourceAppsQuery.isFetchingNextPage, + ), createReleaseSourceAppsIsLoadingAtom: atom(() => mocks.sourceAppsQuery.isLoading), createReleaseSourceAppSearchTextAtom: atom(''), createReleaseSourceAppsQueryAtom: atom(mocks.sourceAppsQuery), @@ -76,12 +84,16 @@ describe('SourceAppPicker', () => { vi.clearAllMocks() Object.assign(mocks.sourceAppsQuery, { data: { - pages: [{ - data: [{ - id: 'app-1', - name: 'Workflow App', - }], - }], + pages: [ + { + data: [ + { + id: 'app-1', + name: 'Workflow App', + }, + ], + }, + ], }, error: null, fetchNextPage: vi.fn(), @@ -96,7 +108,9 @@ describe('SourceAppPicker', () => { renderSourceAppPicker(true) expect(screen.getByText('Workflow 1')).toBeInTheDocument() - expect(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' })).toBeDisabled() + expect( + screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' }), + ).toBeDisabled() }) it('should use infinite scroll to load more apps when the picker is open', async () => { @@ -137,6 +151,8 @@ describe('SourceAppPicker', () => { }), ) }) - expect(screen.queryByRole('button', { name: /createModal\.loadMoreApps/ })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /createModal\.loadMoreApps/ }), + ).not.toBeInTheDocument() }) }) diff --git a/web/features/deployments/create-release/ui/actions.tsx b/web/features/deployments/create-release/ui/actions.tsx index 1b6890a6be61b1..2a8dddd1e308a5 100644 --- a/web/features/deployments/create-release/ui/actions.tsx +++ b/web/features/deployments/create-release/ui/actions.tsx @@ -40,7 +40,7 @@ export function CreateReleaseActions() { requestCloseDialog() }} > - {t($ => $['versions.cancelCreate'])} + {t(($) => $['versions.cancelCreate'])} </Button> <Button type="submit" @@ -49,7 +49,11 @@ export function CreateReleaseActions() { disabled={!hasReleaseName || !releaseContentReady || hasReleaseNameConflict} loading={isSubmitting} > - {isSubmitting ? t($ => $['versions.creating']) : isCheckingReleaseContent ? t($ => $['versions.checkingReleaseContent']) : t($ => $['versions.create'])} + {isSubmitting + ? t(($) => $['versions.creating']) + : isCheckingReleaseContent + ? t(($) => $['versions.checkingReleaseContent']) + : t(($) => $['versions.create'])} </Button> </div> </div> diff --git a/web/features/deployments/create-release/ui/content-feedback.tsx b/web/features/deployments/create-release/ui/content-feedback.tsx index 48facfc5ae4780..66c1d43543bf44 100644 --- a/web/features/deployments/create-release/ui/content-feedback.tsx +++ b/web/features/deployments/create-release/ui/content-feedback.tsx @@ -20,14 +20,20 @@ export function ReleaseContentFeedback() { <UnsupportedDslNodesAlert nodes={unsupportedDslNodes} /> {matchedRelease && ( - <div role="alert" className="rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-3 py-2 system-sm-regular text-util-colors-warning-warning-700"> - {t($ => $['versions.releaseAlreadyExists'], { name: matchedRelease.displayName })} + <div + role="alert" + className="rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-3 py-2 system-sm-regular text-util-colors-warning-warning-700" + > + {t(($) => $['versions.releaseAlreadyExists'], { name: matchedRelease.displayName })} </div> )} {releaseContentCheckFailed && ( - <div role="alert" className="rounded-lg border border-util-colors-red-red-200 bg-util-colors-red-red-50 px-3 py-2 system-sm-regular text-util-colors-red-red-700"> - {t($ => $['versions.releaseContentCheckFailed'])} + <div + role="alert" + className="rounded-lg border border-util-colors-red-red-200 bg-util-colors-red-red-50 px-3 py-2 system-sm-regular text-util-colors-red-red-700" + > + {t(($) => $['versions.releaseContentCheckFailed'])} </div> )} </> diff --git a/web/features/deployments/create-release/ui/dialog.tsx b/web/features/deployments/create-release/ui/dialog.tsx index faf9ef0fa56297..7e52d7528fd990 100644 --- a/web/features/deployments/create-release/ui/dialog.tsx +++ b/web/features/deployments/create-release/ui/dialog.tsx @@ -1,6 +1,11 @@ 'use client' -import { DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { useAtomValue, useSetAtom } from 'jotai' import { ScopeProvider } from 'jotai-scope' @@ -59,20 +64,18 @@ function CreateReleaseDialogSurface() { async function handleSubmit() { try { const response = await submitCreateReleaseForm() - if (!response) - return + if (!response) return - toast.success(t($ => $['versions.createSuccess'], { name: response.release.displayName })) + toast.success(t(($) => $['versions.createSuccess'], { name: response.release.displayName })) closeDialog() - } - catch (error) { + } catch (error) { if (error instanceof CreateReleaseSubmissionBlockedError) { - toast.error(t($ => $['versions.dslUnsupportedMode'])) + toast.error(t(($) => $['versions.dslUnsupportedMode'])) return } const message = await deploymentErrorMessage(error) - toast.error(message || t($ => $['versions.createFailed'])) + toast.error(message || t(($) => $['versions.createFailed'])) } } @@ -91,10 +94,10 @@ function CreateReleaseDialogSurface() { <div className="border-b border-divider-subtle px-6 py-5 pr-14"> <div className="min-w-0"> <DialogTitle className="title-xl-semi-bold text-text-primary"> - {t($ => $['versions.createRelease'])} + {t(($) => $['versions.createRelease'])} </DialogTitle> <DialogDescription className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $['versions.createReleaseDescription'])} + {t(($) => $['versions.createReleaseDescription'])} </DialogDescription> </div> </div> diff --git a/web/features/deployments/create-release/ui/metadata-fields.tsx b/web/features/deployments/create-release/ui/metadata-fields.tsx index 808fa97a3f4e48..b6e67935f4e94a 100644 --- a/web/features/deployments/create-release/ui/metadata-fields.tsx +++ b/web/features/deployments/create-release/ui/metadata-fields.tsx @@ -18,20 +18,18 @@ const DESCRIPTION_WARN_THRESHOLD = 460 function isValidationIssue(error: unknown): error is { message: string } { return Boolean( - error - && typeof error === 'object' - && 'message' in error - && typeof error.message === 'string', + error && typeof error === 'object' && 'message' in error && typeof error.message === 'string', ) } function hasReleaseNameRequiredError(errors: unknown[]) { return errors.some((error) => { - if (error === RELEASE_NAME_REQUIRED_ERROR) - return true + if (error === RELEASE_NAME_REQUIRED_ERROR) return true if (Array.isArray(error)) - return error.some(issue => isValidationIssue(issue) && issue.message === RELEASE_NAME_REQUIRED_ERROR) + return error.some( + (issue) => isValidationIssue(issue) && issue.message === RELEASE_NAME_REQUIRED_ERROR, + ) return isValidationIssue(error) && error.message === RELEASE_NAME_REQUIRED_ERROR }) @@ -40,15 +38,17 @@ function hasReleaseNameRequiredError(errors: unknown[]) { export function ReleaseMetadataFields() { const { t } = useTranslation('deployments') const [releaseNameField, setReleaseNameField] = useAtom(createReleaseNameFieldAtom) - const [releaseDescriptionField, setReleaseDescriptionField] = useAtom(createReleaseDescriptionFieldAtom) + const [releaseDescriptionField, setReleaseDescriptionField] = useAtom( + createReleaseDescriptionFieldAtom, + ) const hasReleaseNameConflict = useAtomValue(createReleaseHasNameConflictAtom) const releaseNameInputRef = useRef<HTMLInputElement>(null) const releaseNameErrors = releaseNameField.meta?.errors ?? [] const hasReleaseNameRequired = hasReleaseNameRequiredError(releaseNameErrors) const releaseNameError = hasReleaseNameRequired - ? t($ => $['versions.releaseNameRequired']) + ? t(($) => $['versions.releaseNameRequired']) : hasReleaseNameConflict - ? t($ => $['versions.releaseNameConflict']) + ? t(($) => $['versions.releaseNameConflict']) : '' useEffect(() => { @@ -59,13 +59,13 @@ export function ReleaseMetadataFields() { <> <div className="flex flex-col gap-2"> <label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-name"> - {t($ => $['versions.releaseNameLabel'])} + {t(($) => $['versions.releaseNameLabel'])} </label> <Input ref={releaseNameInputRef} id="release-name" name="releaseName" - placeholder={t($ => $['versions.releaseNamePlaceholder'])} + placeholder={t(($) => $['versions.releaseNamePlaceholder'])} maxLength={128} autoComplete="off" value={releaseNameField.value} @@ -77,7 +77,11 @@ export function ReleaseMetadataFields() { className="h-9" /> {releaseNameError && ( - <div id="release-name-error" role="alert" className="system-xs-regular text-text-destructive"> + <div + id="release-name-error" + role="alert" + className="system-xs-regular text-text-destructive" + > {releaseNameError} </div> )} @@ -85,29 +89,32 @@ export function ReleaseMetadataFields() { <div className="flex flex-col gap-2"> <div className="flex items-center justify-between gap-3"> - <label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-description"> - {t($ => $['versions.releaseDescriptionLabel'])} + <label + className="system-xs-medium-uppercase text-text-tertiary" + htmlFor="release-description" + > + {t(($) => $['versions.releaseDescriptionLabel'])} </label> <div className="flex items-center gap-2"> <span className="system-xs-regular text-text-quaternary"> - {t($ => $['versions.optional'])} + {t(($) => $['versions.optional'])} </span> <span className={cn( 'system-xs-regular tabular-nums', - releaseDescriptionField.value.length >= DESCRIPTION_WARN_THRESHOLD ? 'text-util-colors-warning-warning-700' : 'text-text-quaternary', + releaseDescriptionField.value.length >= DESCRIPTION_WARN_THRESHOLD + ? 'text-util-colors-warning-warning-700' + : 'text-text-quaternary', )} > - {releaseDescriptionField.value.length} - / - {DESCRIPTION_MAX_LENGTH} + {releaseDescriptionField.value.length}/{DESCRIPTION_MAX_LENGTH} </span> </div> </div> <Textarea id="release-description" name="releaseDescription" - placeholder={t($ => $['versions.releaseDescriptionPlaceholder'])} + placeholder={t(($) => $['versions.releaseDescriptionPlaceholder'])} maxLength={DESCRIPTION_MAX_LENGTH} autoComplete="off" value={releaseDescriptionField.value} diff --git a/web/features/deployments/create-release/ui/source-app-picker.tsx b/web/features/deployments/create-release/ui/source-app-picker.tsx index 870725dc53db3c..031fb1339f2aff 100644 --- a/web/features/deployments/create-release/ui/source-app-picker.tsx +++ b/web/features/deployments/create-release/ui/source-app-picker.tsx @@ -31,15 +31,17 @@ import { createReleaseSourceAppsIsLoadingAtom, } from '../state' -const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app'] +const SOURCE_APP_PICKER_SKELETON_KEYS = [ + 'first-source-app', + 'second-source-app', + 'third-source-app', +] function sourceAppSearchText(app: App) { return `${app.name} ${app.id}`.toLowerCase() } -function SourceAppTrigger({ app }: { - app?: SourceAppPickerValue -}) { +function SourceAppTrigger({ app }: { app?: SourceAppPickerValue }) { const { t } = useTranslation('deployments') return ( @@ -71,7 +73,7 @@ function SourceAppTrigger({ app }: { : 'system-sm-regular text-components-input-text-placeholder', )} > - {app?.name ?? t($ => $['createModal.appPickerPlaceholder'])} + {app?.name ?? t(($) => $['createModal.appPickerPlaceholder'])} </span> </TitleTooltip> <span @@ -86,14 +88,9 @@ function SourceAppTrigger({ app }: { ) } -function SourceAppOption({ app }: { - app: App -}) { +function SourceAppOption({ app }: { app: App }) { return ( - <ComboboxItem - value={app} - className="mx-0 grid-cols-[minmax(0,1fr)] gap-3 py-1 pr-3 pl-2" - > + <ComboboxItem value={app} className="mx-0 grid-cols-[minmax(0,1fr)] gap-3 py-1 pr-3 pl-2"> <ComboboxItemText className="flex min-w-0 items-center gap-3 px-0"> <AppIcon className="shrink-0" @@ -106,11 +103,7 @@ function SourceAppOption({ app }: { <TitleTooltip content={`${app.name} (${app.id})`}> <span className="flex min-w-0 grow items-center gap-1 truncate system-sm-medium text-components-input-text-filled"> <span className="truncate">{app.name}</span> - <span className="shrink-0 text-text-tertiary"> - ( - {app.id.slice(0, 8)} - ) - </span> + <span className="shrink-0 text-text-tertiary">({app.id.slice(0, 8)})</span> </span> </TitleTooltip> </ComboboxItemText> @@ -121,7 +114,7 @@ function SourceAppOption({ app }: { function SourceAppPickerSkeleton() { return ( <div className="flex flex-col gap-2 px-3 py-3"> - {SOURCE_APP_PICKER_SKELETON_KEYS.map(key => ( + {SOURCE_APP_PICKER_SKELETON_KEYS.map((key) => ( <SkeletonRow key={key} className="h-7 gap-3"> <SkeletonRectangle className="my-0 size-5 animate-pulse rounded-md" /> <SkeletonRectangle className="h-3 w-32 animate-pulse" /> @@ -131,7 +124,11 @@ function SourceAppPickerSkeleton() { ) } -export function SourceAppPicker({ value, onChange, disabled = false }: { +export function SourceAppPicker({ + value, + onChange, + disabled = false, +}: { value?: SourceAppPickerValue onChange: (app: App) => void disabled?: boolean @@ -147,18 +144,21 @@ export function SourceAppPicker({ value, onChange, disabled = false }: { const sourceAppsIsFetching = useAtomValue(createReleaseSourceAppsIsFetchingAtom) const sourceAppsIsFetchingNextPage = useAtomValue(createReleaseSourceAppsIsFetchingNextPageAtom) const sourceAppsIsLoading = useAtomValue(createReleaseSourceAppsIsLoadingAtom) - const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>({ - error: sourceAppsError, - fetchNextPage: sourceAppsFetchNextPage, - hasNextPage: sourceAppsHasNextPage, - isFetching: sourceAppsIsFetching, - isFetchingNextPage: sourceAppsIsFetchingNextPage, - isLoading: sourceAppsIsLoading, - }, { - enabled: isShow && !disabled, - rootMargin: '0px 0px 160px 0px', - threshold: 0.1, - }) + const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>( + { + error: sourceAppsError, + fetchNextPage: sourceAppsFetchNextPage, + hasNextPage: sourceAppsHasNextPage, + isFetching: sourceAppsIsFetching, + isFetchingNextPage: sourceAppsIsFetchingNextPage, + isLoading: sourceAppsIsLoading, + }, + { + enabled: isShow && !disabled, + rootMargin: '0px 0px 160px 0px', + threshold: 0.1, + }, + ) return ( <Combobox<App> @@ -169,26 +169,21 @@ export function SourceAppPicker({ value, onChange, disabled = false }: { setIsShow(disabled ? false : open) }} onInputValueChange={(value) => { - if (!disabled) - setSearchText(value) + if (!disabled) setSearchText(value) }} onValueChange={(app) => { - if (disabled) - return - if (!app) - return + if (disabled) return + if (!app) return onChange(app) setIsShow(false) }} itemToStringLabel={(app) => { - if (!app) - return '' + if (!app) return '' return app.name }} itemToStringValue={(app) => { - if (!app) - return '' + if (!app) return '' return app.id }} @@ -196,7 +191,7 @@ export function SourceAppPicker({ value, onChange, disabled = false }: { disabled={disabled} > <ComboboxTrigger - aria-label={t($ => $['versions.sourceAppOption'])} + aria-label={t(($) => $['versions.sourceAppOption'])} icon={false} className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent" > @@ -210,29 +205,30 @@ export function SourceAppPicker({ value, onChange, disabled = false }: { <div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs"> <div className="p-2 pb-1"> <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <span className="i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> + <span + className="i-ri-search-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> <ComboboxInput - aria-label={t($ => $['createModal.appSearchPlaceholder'])} - placeholder={t($ => $['createModal.appSearchPlaceholder'])} + aria-label={t(($) => $['createModal.appSearchPlaceholder'])} + placeholder={t(($) => $['createModal.appSearchPlaceholder'])} className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary" /> </ComboboxInputGroup> </div> <div ref={rootRef} className="min-h-0 flex-1 overflow-y-auto p-1"> - {(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />} + {(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && apps.length === 0 && ( + <SourceAppPickerSkeleton /> + )} <ComboboxList className="max-h-none p-0"> - {(app: App) => ( - <SourceAppOption key={app.id} app={app} /> - )} + {(app: App) => <SourceAppOption key={app.id} app={app} />} </ComboboxList> {!(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && ( - <ComboboxEmpty> - {t($ => $['createModal.appSearchEmpty'])} - </ComboboxEmpty> + <ComboboxEmpty>{t(($) => $['createModal.appSearchEmpty'])}</ComboboxEmpty> )} {sourceAppsIsFetchingNextPage && apps.length > 0 && ( <div className="px-3 py-2 text-center system-xs-regular text-text-tertiary"> - {t($ => $['createModal.loadingApps'])} + {t(($) => $['createModal.loadingApps'])} </div> )} {sourceAppsHasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />} diff --git a/web/features/deployments/create-release/ui/source-section.tsx b/web/features/deployments/create-release/ui/source-section.tsx index 25f31265c10b9e..c1e7e9efd7498a 100644 --- a/web/features/deployments/create-release/ui/source-section.tsx +++ b/web/features/deployments/create-release/ui/source-section.tsx @@ -31,8 +31,11 @@ export function ReleaseSourceSection() { return ( <div className="flex flex-col gap-2"> <div className="flex flex-wrap items-center justify-between gap-3"> - <label id="release-source-mode-label" className="system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['versions.releaseSourceLabel'])} + <label + id="release-source-mode-label" + className="system-xs-medium-uppercase text-text-tertiary" + > + {t(($) => $['versions.releaseSourceLabel'])} </label> {isDeploymentDslImportEnabled && ( <SegmentedControl<ReleaseSourceMode> @@ -40,8 +43,7 @@ export function ReleaseSourceSection() { value={[releaseSourceMode]} onValueChange={(value) => { const nextMode = selectedReleaseSourceMode(value) - if (!nextMode || nextMode === releaseSourceMode) - return + if (!nextMode || nextMode === releaseSourceMode) return selectReleaseSourceMode(nextMode) }} @@ -49,20 +51,18 @@ export function ReleaseSourceSection() { > <SegmentedControlItem value="sourceApp" className="gap-1.5"> <span className="i-ri-apps-2-line size-4 shrink-0" aria-hidden="true" /> - <span>{t($ => $['versions.sourceAppOption'])}</span> + <span>{t(($) => $['versions.sourceAppOption'])}</span> </SegmentedControlItem> <SegmentedControlItem value="dsl" className="gap-1.5"> <span className="i-ri-upload-cloud-2-line size-4 shrink-0" aria-hidden="true" /> - <span>{t($ => $['versions.manualDslOption'])}</span> + <span>{t(($) => $['versions.manualDslOption'])}</span> </SegmentedControlItem> </SegmentedControl> )} </div> <div className="min-h-12"> - {releaseSourceMode === 'sourceApp' - ? <SourceAppField /> - : <DslFileField />} + {releaseSourceMode === 'sourceApp' ? <SourceAppField /> : <DslFileField />} </div> </div> ) @@ -75,11 +75,7 @@ function SourceAppField() { return ( <div className="flex min-h-12 items-center"> - <SourceAppPicker - value={sourceApp} - onChange={updateSourceApp} - disabled={sourceAppLocked} - /> + <SourceAppPicker value={sourceApp} onChange={updateSourceApp} disabled={sourceAppLocked} /> </div> ) } @@ -102,12 +98,12 @@ function DslFileField() { /> {isReadingDsl && ( <div role="status" className="system-xs-regular text-text-tertiary"> - {t($ => $['versions.dslReading'])} + {t(($) => $['versions.dslReading'])} </div> )} {dslReadError && ( <div role="alert" className="system-xs-regular text-util-colors-red-red-600"> - {t($ => $['versions.dslReadFailed'])} + {t(($) => $['versions.dslReadFailed'])} </div> )} <DslUnsupportedModeError /> @@ -119,12 +115,11 @@ function DslUnsupportedModeError() { const { t } = useTranslation('deployments') const hasUnsupportedDslMode = useAtomValue(createReleaseHasUnsupportedDslModeAtom) - if (!hasUnsupportedDslMode) - return null + if (!hasUnsupportedDslMode) return null return ( <div role="alert" className="system-xs-regular text-util-colors-red-red-600"> - {t($ => $['versions.dslUnsupportedMode'])} + {t(($) => $['versions.dslUnsupportedMode'])} </div> ) } diff --git a/web/features/deployments/deploy-drawer/__tests__/index.spec.tsx b/web/features/deployments/deploy-drawer/__tests__/index.spec.tsx index c82e4f0ec0db75..11e8ac4f1d2e24 100644 --- a/web/features/deployments/deploy-drawer/__tests__/index.spec.tsx +++ b/web/features/deployments/deploy-drawer/__tests__/index.spec.tsx @@ -2,10 +2,7 @@ import { render, screen } from '@testing-library/react' import { createStore, Provider as JotaiProvider } from 'jotai' import { describe, expect, it, vi } from 'vitest' import { DeployDrawer } from '../index' -import { - deployDrawerAppInstanceIdAtom, - deployDrawerOpenAtom, -} from '../state' +import { deployDrawerAppInstanceIdAtom, deployDrawerOpenAtom } from '../state' vi.mock('../ui/form', () => ({ DeployForm: () => <div data-testid="deploy-form" />, diff --git a/web/features/deployments/deploy-drawer/index.tsx b/web/features/deployments/deploy-drawer/index.tsx index e9fb38c48657b6..56feb3478f9b2b 100644 --- a/web/features/deployments/deploy-drawer/index.tsx +++ b/web/features/deployments/deploy-drawer/index.tsx @@ -34,27 +34,27 @@ export function DeployDrawer() { open={open} modal swipeDirection="right" - onOpenChange={next => !next && closeDeployDrawer()} + onOpenChange={(next) => !next && closeDeployDrawer()} > <DrawerPortal> <DrawerBackdrop /> <DrawerViewport> <DrawerPopup className="data-[swipe-direction=right]:w-[640px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)]"> <DrawerCloseButton - aria-label={t($ => $['deployDrawer.close'])} + aria-label={t(($) => $['deployDrawer.close'])} className="absolute top-4 right-5 size-6 rounded-md" /> <DrawerContent className="flex min-h-0 flex-1 flex-col bg-components-panel-bg p-0 pb-0"> - {!drawerAppInstanceId - ? <div className="p-6 text-text-tertiary">{t($ => $['deployDrawer.notFound'])}</div> - : ( - <DeployForm - key={formKey} - appInstanceId={drawerAppInstanceId} - lockedEnvId={drawerEnvironmentId} - presetReleaseId={drawerReleaseId} - /> - )} + {!drawerAppInstanceId ? ( + <div className="p-6 text-text-tertiary">{t(($) => $['deployDrawer.notFound'])}</div> + ) : ( + <DeployForm + key={formKey} + appInstanceId={drawerAppInstanceId} + lockedEnvId={drawerEnvironmentId} + presetReleaseId={drawerReleaseId} + /> + )} </DrawerContent> </DrawerPopup> </DrawerViewport> diff --git a/web/features/deployments/deploy-drawer/state/__tests__/index.spec.ts b/web/features/deployments/deploy-drawer/state/__tests__/index.spec.ts index b2df644f1e76fd..b6bc75e00dcfe6 100644 --- a/web/features/deployments/deploy-drawer/state/__tests__/index.spec.ts +++ b/web/features/deployments/deploy-drawer/state/__tests__/index.spec.ts @@ -74,29 +74,31 @@ const mockRollbackMutation = vi.hoisted<{ current: MutationResult }>(() => ({ })) vi.mock('jotai-tanstack-query', () => ({ - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => { - const options = createOptions(get) - if (options.queryKey?.[0] === 'computeDeploymentOptions') { + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => + atom((get) => { + const options = createOptions(get) + if (options.queryKey?.[0] === 'computeDeploymentOptions') { + return { + ...options, + ...mockDeploymentOptionsQuery.current, + } + } + return { ...options, - ...mockDeploymentOptionsQuery.current, + data: undefined, + isLoading: false, + isFetching: false, + isError: false, } - } - - return { - ...options, - data: undefined, - isLoading: false, - isFetching: false, - isError: false, - } - }), - atomWithMutation: (createOptions: () => MutationOptions) => atom(() => { - const options = createOptions() - return options.mutationKey?.[0] === 'rollback' - ? mockRollbackMutation.current - : mockPromoteMutation.current - }), + }), + atomWithMutation: (createOptions: () => MutationOptions) => + atom(() => { + const options = createOptions() + return options.mutationKey?.[0] === 'rollback' + ? mockRollbackMutation.current + : mockPromoteMutation.current + }), })) vi.mock('@/service/client', () => ({ @@ -104,14 +106,14 @@ vi.mock('@/service/client', () => ({ enterprise: { releaseService: { computeReleaseDeploymentView: { - queryOptions: ({ enabled, input }: { enabled: boolean, input: unknown }) => ({ + queryOptions: ({ enabled, input }: { enabled: boolean; input: unknown }) => ({ enabled, input, queryKey: ['computeReleaseDeploymentView', input], }), }, computeDeploymentOptions: { - queryOptions: ({ enabled, input }: { enabled: boolean, input: unknown }) => ({ + queryOptions: ({ enabled, input }: { enabled: boolean; input: unknown }) => ({ enabled, input, queryKey: ['computeDeploymentOptions', input], @@ -337,7 +339,11 @@ describe('deploy drawer state', () => { envVarSlots: [envVarSlot()], }) - store.set(state.selectDeployBindingAtom, 'langgenius/openai:PLUGIN_CATEGORY_MODEL', 'credential-1') + store.set( + state.selectDeployBindingAtom, + 'langgenius/openai:PLUGIN_CATEGORY_MODEL', + 'credential-1', + ) store.set(state.setDeployEnvVarAtom, 'API_KEY', { value: 'secret', valueSource: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL, @@ -437,9 +443,11 @@ describe('deploy drawer state', () => { it('should submit a promote deployment with selected credentials and env vars', async () => { const state = await loadState() const store = createStore() - mockPromoteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => { - options?.onSuccess?.() - }) + mockPromoteMutate.mockImplementation( + (_variables: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }, + ) store.set(state.deployReadyFormConfigAtom, deployConfig()) setQueryOptions({ credentialSlots: [ diff --git a/web/features/deployments/deploy-drawer/state/index.ts b/web/features/deployments/deploy-drawer/state/index.ts index 9e5bbb502638e2..0ad440ba59cdee 100644 --- a/web/features/deployments/deploy-drawer/state/index.ts +++ b/web/features/deployments/deploy-drawer/state/index.ts @@ -82,9 +82,18 @@ export const releaseDeploymentViewQueryAtom = atomWithQuery((get) => { }) }) -export const releaseDeploymentViewAtom = selectAtom(releaseDeploymentViewQueryAtom, query => query.data) -export const releaseDeploymentViewIsLoadingAtom = selectAtom(releaseDeploymentViewQueryAtom, query => query.isLoading) -export const releaseDeploymentViewIsErrorAtom = selectAtom(releaseDeploymentViewQueryAtom, query => query.isError) +export const releaseDeploymentViewAtom = selectAtom( + releaseDeploymentViewQueryAtom, + (query) => query.data, +) +export const releaseDeploymentViewIsLoadingAtom = selectAtom( + releaseDeploymentViewQueryAtom, + (query) => query.isLoading, +) +export const releaseDeploymentViewIsErrorAtom = selectAtom( + releaseDeploymentViewQueryAtom, + (query) => query.isError, +) const selectedEnvIdAtom = atom<string | undefined>(undefined) const selectedReleaseIdAtom = atom<string | undefined>(undefined) @@ -101,8 +110,7 @@ export const deployReadyFormLocalAtoms = [ function formConfig(get: Getter) { const config = get(deployReadyFormConfigAtom) - if (!config) - throw new Error('Missing deploy ready form config.') + if (!config) throw new Error('Missing deploy ready form config.') return config } @@ -133,7 +141,9 @@ export const deployShowValidationErrorsAtom = atom((get) => { const deployPresetReleaseAtom = atom((get) => { const config = formConfig(get) - return config.presetReleaseId ? config.releases.find(r => r.id === config.presetReleaseId) : undefined + return config.presetReleaseId + ? config.releases.find((r) => r.id === config.presetReleaseId) + : undefined }) export const deployDisplayedReleaseAtom = atom((get): Release | undefined => { @@ -170,7 +180,7 @@ const deploySelectedEnvironmentAtom = atom((get) => { const config = formConfig(get) const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom) return selectedEnvironmentId - ? config.environments.find(env => env.id === selectedEnvironmentId) + ? config.environments.find((env) => env.id === selectedEnvironmentId) : undefined }) @@ -184,7 +194,7 @@ const deploySelectedReleaseAtom = atom((get) => { const config = formConfig(get) const selectedReleaseId = get(deploySelectedReleaseIdAtom) return selectedReleaseId - ? config.releases.find(release => release.id === selectedReleaseId) + ? config.releases.find((release) => release.id === selectedReleaseId) : undefined }) @@ -195,7 +205,7 @@ const deployTargetReleaseAtom = atom((get) => { export const deployLockedEnvironmentAtom = atom((get) => { const config = formConfig(get) return config.lockedEnvId - ? config.environments.find(environment => environment.id === config.lockedEnvId) + ? config.environments.find((environment) => environment.id === config.lockedEnvId) : undefined }) @@ -215,46 +225,64 @@ const releaseDeploymentOptionsQueryAtom = atomWithQuery((get) => { const hasRequiredInput = Boolean(releaseId && selectedEnvironmentId) return consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({ - input: releaseId && selectedEnvironmentId - ? { - body: { - releaseId, - environmentId: selectedEnvironmentId, - }, - } - : skipToken, + input: + releaseId && selectedEnvironmentId + ? { + body: { + releaseId, + environmentId: selectedEnvironmentId, + }, + } + : skipToken, enabled: hasRequiredInput && hasSelectedEnvironment, retry: false, }) }) -const releaseDeploymentOptionsAtom = selectAtom(releaseDeploymentOptionsQueryAtom, query => query.data) -const releaseDeploymentOptionsIsLoadingAtom = selectAtom(releaseDeploymentOptionsQueryAtom, query => query.isLoading) -const releaseDeploymentOptionsIsFetchingAtom = selectAtom(releaseDeploymentOptionsQueryAtom, query => query.isFetching) -const releaseDeploymentOptionsIsErrorAtom = selectAtom(releaseDeploymentOptionsQueryAtom, query => query.isError) +const releaseDeploymentOptionsAtom = selectAtom( + releaseDeploymentOptionsQueryAtom, + (query) => query.data, +) +const releaseDeploymentOptionsIsLoadingAtom = selectAtom( + releaseDeploymentOptionsQueryAtom, + (query) => query.isLoading, +) +const releaseDeploymentOptionsIsFetchingAtom = selectAtom( + releaseDeploymentOptionsQueryAtom, + (query) => query.isFetching, +) +const releaseDeploymentOptionsIsErrorAtom = selectAtom( + releaseDeploymentOptionsQueryAtom, + (query) => query.isError, +) export const deployBindingSlotsAtom = atom((get) => { const deploymentOptions = get(releaseDeploymentOptionsAtom) - return deploymentOptions?.options.credentialSlots.filter(slot => runtimeCredentialSlotKey(slot)) ?? [] + return ( + deploymentOptions?.options.credentialSlots.filter((slot) => runtimeCredentialSlotKey(slot)) ?? + [] + ) }) export const deployEnvVarSlotsAtom = atom((get): EnvVarBindingSlot[] => { const deploymentOptions = get(releaseDeploymentOptionsAtom) - return deploymentOptions?.options.envVarSlots.flatMap((slot): EnvVarBindingSlot[] => { - const bindingSlot = envVarBindingSlotFromContract(slot) - return bindingSlot ? [bindingSlot] : [] - }) ?? [] + return ( + deploymentOptions?.options.envVarSlots.flatMap((slot): EnvVarBindingSlot[] => { + const bindingSlot = envVarBindingSlotFromContract(slot) + return bindingSlot ? [bindingSlot] : [] + }) ?? [] + ) }) export const deployIsBindingOptionsLoadingAtom = atom((get) => { const releaseId = get(deployTargetReleaseIdAtom) return Boolean( - releaseId - && get(deployHasSelectedEnvironmentAtom) - && (get(releaseDeploymentOptionsIsLoadingAtom) || get(releaseDeploymentOptionsIsFetchingAtom)), + releaseId && + get(deployHasSelectedEnvironmentAtom) && + (get(releaseDeploymentOptionsIsLoadingAtom) || get(releaseDeploymentOptionsIsFetchingAtom)), ) }) @@ -266,57 +294,65 @@ const deployIsBindingOptionsReadyAtom = atom((get) => { const releaseId = get(deployTargetReleaseIdAtom) return Boolean( - releaseId - && get(deployHasSelectedEnvironmentAtom) - && get(releaseDeploymentOptionsAtom) - && !get(deployIsBindingOptionsLoadingAtom) - && !get(deployHasBindingOptionsErrorAtom), + releaseId && + get(deployHasSelectedEnvironmentAtom) && + get(releaseDeploymentOptionsAtom) && + !get(deployIsBindingOptionsLoadingAtom) && + !get(deployHasBindingOptionsErrorAtom), ) }) -function deployEnvVarValueSource(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) { - return selection?.valueSource - ?? (slot.hasLastValue +function deployEnvVarValueSource( + slot: EnvVarBindingSlot, + selection: EnvVarValueSelection | undefined, +) { + return ( + selection?.valueSource ?? + (slot.hasLastValue ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT : slot.hasDefaultValue ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT : ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL) + ) } -function deployEnvVarInput(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined): EnvVarInput[] { +function deployEnvVarInput( + slot: EnvVarBindingSlot, + selection: EnvVarValueSelection | undefined, +): EnvVarInput[] { const valueSource = deployEnvVarValueSource(slot, selection) if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) { - return slot.hasLastValue - ? [{ key: slot.key, valueSource }] - : [] + return slot.hasLastValue ? [{ key: slot.key, valueSource }] : [] } if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) { - return slot.hasDefaultValue - ? [{ key: slot.key, valueSource }] - : [] + return slot.hasDefaultValue ? [{ key: slot.key, valueSource }] : [] } if (!selection?.value || (slot.valueType === 'number' && Number.isNaN(Number(selection.value)))) return [] - return [{ - key: slot.key, - value: selection.value, - valueSource, - }] + return [ + { + key: slot.key, + value: selection.value, + valueSource, + }, + ] } -function deployEnvVarSelectionReady(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) { +function deployEnvVarSelectionReady( + slot: EnvVarBindingSlot, + selection: EnvVarValueSelection | undefined, +) { const valueSource = deployEnvVarValueSource(slot, selection) if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) return Boolean(slot.hasLastValue) if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) return Boolean(slot.hasDefaultValue) - if (!selection?.value) - return false + if (!selection?.value) return false return slot.valueType !== 'number' || !Number.isNaN(Number(selection.value)) } @@ -326,27 +362,36 @@ export const deploySelectedBindingsAtom = atom((get) => { }) const deployDeploymentCredentialsAtom = atom((get) => { - return selectedDeploymentRuntimeCredentials(get(deployBindingSlotsAtom), get(deploySelectedBindingsAtom)) + return selectedDeploymentRuntimeCredentials( + get(deployBindingSlotsAtom), + get(deploySelectedBindingsAtom), + ) }) const deployDeploymentEnvVarsAtom = atom((get) => { const envVarValues = get(deployEnvVarValuesAtom) - return get(deployEnvVarSlotsAtom).flatMap(slot => deployEnvVarInput(slot, envVarValues[slot.key])) + return get(deployEnvVarSlotsAtom).flatMap((slot) => + deployEnvVarInput(slot, envVarValues[slot.key]), + ) }) const deployRequiredBindingsReadyAtom = atom((get) => { const selectedBindings = get(deploySelectedBindingsAtom) - return get(deployBindingSlotsAtom).every(slot => - !hasMissingRequiredRuntimeCredentialBinding(slot, selectedBindings[runtimeCredentialSlotKey(slot)]), + return get(deployBindingSlotsAtom).every( + (slot) => + !hasMissingRequiredRuntimeCredentialBinding( + slot, + selectedBindings[runtimeCredentialSlotKey(slot)], + ), ) }) const deployRequiredEnvVarsReadyAtom = atom((get) => { const envVarValues = get(deployEnvVarValuesAtom) - return get(deployEnvVarSlotsAtom).every(slot => + return get(deployEnvVarSlotsAtom).every((slot) => deployEnvVarSelectionReady(slot, envVarValues[slot.key]), ) }) @@ -358,12 +403,15 @@ export const selectDeployBindingAtom = atom(null, (get, set, slot: string, value }) }) -export const setDeployEnvVarAtom = atom(null, (get, set, key: string, value: EnvVarValueSelection) => { - set(deployEnvVarValuesAtom, { - ...get(deployEnvVarValuesAtom), - [key]: value, - }) -}) +export const setDeployEnvVarAtom = atom( + null, + (get, set, key: string, value: EnvVarValueSelection) => { + set(deployEnvVarValuesAtom, { + ...get(deployEnvVarValuesAtom), + [key]: value, + }) + }, +) const promoteReleaseMutationAtom = atomWithMutation(() => consoleQuery.enterprise.deploymentService.promote.mutationOptions(), @@ -379,54 +427,80 @@ export const isDeployReleaseSubmittingAtom = atom((get) => { export const canAttemptDeployAtom = atom((get) => { return Boolean( - get(deploySelectedEnvironmentIdAtom) - && get(deploySelectedEnvironmentAtom) - && get(deployTargetReleaseIdAtom) - && get(deployIsBindingOptionsReadyAtom) - && !get(isDeployReleaseSubmittingAtom), + get(deploySelectedEnvironmentIdAtom) && + get(deploySelectedEnvironmentAtom) && + get(deployTargetReleaseIdAtom) && + get(deployIsBindingOptionsReadyAtom) && + !get(isDeployReleaseSubmittingAtom), ) }) export const canSubmitDeployAtom = atom((get) => { return Boolean( - get(canAttemptDeployAtom) - && get(deployRequiredBindingsReadyAtom) - && get(deployRequiredEnvVarsReadyAtom), + get(canAttemptDeployAtom) && + get(deployRequiredBindingsReadyAtom) && + get(deployRequiredEnvVarsReadyAtom), ) }) -export const deployReleaseSubmissionAtom = atom(null, (get, set, { - deployFailedMessage, -}: { - deployFailedMessage: string -}) => { - const config = formConfig(get) - const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom) - const targetRelease = get(deployTargetReleaseAtom) - const targetReleaseId = get(deployTargetReleaseIdAtom) - - if (!targetReleaseId || !selectedEnvironmentId) - return - - const idempotencyKey = createDeploymentIdempotencyKey() - const currentRelease = config.runtimeRows.find(row => row.environment.id === selectedEnvironmentId)?.currentRelease - const action = releaseDeploymentAction({ - targetRelease, - currentRelease, - releaseRows: config.releases, - isExistingRelease: true, - }) - const mutationOptions = { - onSuccess: () => { - set(closeDeployDrawerAtom) - }, - onError: () => { - toast.error(deployFailedMessage) +export const deployReleaseSubmissionAtom = atom( + null, + ( + get, + set, + { + deployFailedMessage, + }: { + deployFailedMessage: string }, - } + ) => { + const config = formConfig(get) + const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom) + const targetRelease = get(deployTargetReleaseAtom) + const targetReleaseId = get(deployTargetReleaseIdAtom) + + if (!targetReleaseId || !selectedEnvironmentId) return + + const idempotencyKey = createDeploymentIdempotencyKey() + const currentRelease = config.runtimeRows.find( + (row) => row.environment.id === selectedEnvironmentId, + )?.currentRelease + const action = releaseDeploymentAction({ + targetRelease, + currentRelease, + releaseRows: config.releases, + isExistingRelease: true, + }) + const mutationOptions = { + onSuccess: () => { + set(closeDeployDrawerAtom) + }, + onError: () => { + toast.error(deployFailedMessage) + }, + } + + if (action === 'rollback') { + get(rollbackReleaseMutationAtom).mutate( + { + params: { + appInstanceId: config.appInstanceId, + environmentId: selectedEnvironmentId, + }, + body: { + appInstanceId: config.appInstanceId, + environmentId: selectedEnvironmentId, + targetReleaseId, + idempotencyKey, + }, + }, + mutationOptions, + ) + return + } - if (action === 'rollback') { - get(rollbackReleaseMutationAtom).mutate( + const deploymentEnvVars = get(deployDeploymentEnvVarsAtom) + get(promoteReleaseMutationAtom).mutate( { params: { appInstanceId: config.appInstanceId, @@ -435,31 +509,13 @@ export const deployReleaseSubmissionAtom = atom(null, (get, set, { body: { appInstanceId: config.appInstanceId, environmentId: selectedEnvironmentId, - targetReleaseId, + releaseId: targetReleaseId, + credentials: get(deployDeploymentCredentialsAtom), + envVars: deploymentEnvVars.length > 0 ? deploymentEnvVars : undefined, idempotencyKey, }, }, mutationOptions, ) - return - } - - const deploymentEnvVars = get(deployDeploymentEnvVarsAtom) - get(promoteReleaseMutationAtom).mutate( - { - params: { - appInstanceId: config.appInstanceId, - environmentId: selectedEnvironmentId, - }, - body: { - appInstanceId: config.appInstanceId, - environmentId: selectedEnvironmentId, - releaseId: targetReleaseId, - credentials: get(deployDeploymentCredentialsAtom), - envVars: deploymentEnvVars.length > 0 ? deploymentEnvVars : undefined, - idempotencyKey, - }, - }, - mutationOptions, - ) -}) + }, +) diff --git a/web/features/deployments/deploy-drawer/state/release-options.ts b/web/features/deployments/deploy-drawer/state/release-options.ts index 5174f6e620490d..ba743eac90b39d 100644 --- a/web/features/deployments/deploy-drawer/state/release-options.ts +++ b/web/features/deployments/deploy-drawer/state/release-options.ts @@ -1,10 +1,12 @@ import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen' -export function currentReleaseIdForEnvironment(rows: EnvironmentDeployment[], targetEnvironmentId?: string) { - if (!targetEnvironmentId) - return undefined +export function currentReleaseIdForEnvironment( + rows: EnvironmentDeployment[], + targetEnvironmentId?: string, +) { + if (!targetEnvironmentId) return undefined - return rows.find(row => row.environment.id === targetEnvironmentId)?.currentRelease?.id + return rows.find((row) => row.environment.id === targetEnvironmentId)?.currentRelease?.id } export function selectableDeployReleases({ @@ -18,8 +20,7 @@ export function selectableDeployReleases({ currentReleaseId?: string presetReleaseId?: string }) { - if (!lockedEnvId || presetReleaseId || !currentReleaseId) - return releases + if (!lockedEnvId || presetReleaseId || !currentReleaseId) return releases - return releases.filter(release => release.id !== currentReleaseId) + return releases.filter((release) => release.id !== currentReleaseId) } diff --git a/web/features/deployments/deploy-drawer/ui/form-sections.tsx b/web/features/deployments/deploy-drawer/ui/form-sections.tsx index 52e4a434af71b1..5832451e051989 100644 --- a/web/features/deployments/deploy-drawer/ui/form-sections.tsx +++ b/web/features/deployments/deploy-drawer/ui/form-sections.tsx @@ -22,20 +22,18 @@ import { selectDeployEnvironmentAtom, selectDeployReleaseAtom, } from '../state' -import { - DeploymentSelect, - EnvironmentRow, - Field, -} from './select' +import { DeploymentSelect, EnvironmentRow, Field } from './select' export const DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME = 'max-h-none overflow-visible' -function environmentOptionLabel(env: Environment, t: ReturnType<typeof useTranslation<'deployments'>>['t']) { +function environmentOptionLabel( + env: Environment, + t: ReturnType<typeof useTranslation<'deployments'>>['t'], +) { const description = env.description.trim() - if (description) - return `${env.displayName} · ${description}` + if (description) return `${env.displayName} · ${description}` - return `${env.displayName} · ${t($ => $[`mode.${env.mode}`])} · ${t($ => $[`backend.${env.backend}`])}` + return `${env.displayName} · ${t(($) => $[`mode.${env.mode}`])} · ${t(($) => $[`backend.${env.backend}`])}` } export function BindingOptionsPanel({ @@ -72,7 +70,7 @@ export function BindingOptionsPanel({ if (hasError) { return ( <div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4 system-sm-regular text-text-destructive"> - {t($ => $['deployDrawer.bindingOptionsFailed'])} + {t(($) => $['deployDrawer.bindingOptionsFailed'])} </div> ) } @@ -81,12 +79,12 @@ export function BindingOptionsPanel({ <RuntimeCredentialBindingsPanel slots={slots} selections={selections} - title={t($ => $['deployDrawer.runtimeCredentials'])} - hint={t($ => $['deployDrawer.bindingSelectionHint'])} - noBindingRequiredLabel={t($ => $['deployDrawer.noBindingRequired'])} - noCredentialCandidatesLabel={t($ => $['deployDrawer.noCredentialCandidates'])} - selectCredentialLabel={t($ => $['deployDrawer.selectCredential'])} - missingRequiredLabel={t($ => $['deployDrawer.missingRequiredBinding'])} + title={t(($) => $['deployDrawer.runtimeCredentials'])} + hint={t(($) => $['deployDrawer.bindingSelectionHint'])} + noBindingRequiredLabel={t(($) => $['deployDrawer.noBindingRequired'])} + noCredentialCandidatesLabel={t(($) => $['deployDrawer.noCredentialCandidates'])} + selectCredentialLabel={t(($) => $['deployDrawer.selectCredential'])} + missingRequiredLabel={t(($) => $['deployDrawer.missingRequiredBinding'])} bindingCountLabel={bindingCountLabel} showMissingRequired={showMissingRequired} listClassName={DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME} @@ -101,10 +99,10 @@ export function DeployFormHeader() { return ( <div className="shrink-0 border-b border-divider-subtle px-6 py-5 pr-14"> <DrawerTitle className="title-xl-semi-bold text-text-primary"> - {t($ => $['deployDrawer.title'])} + {t(($) => $['deployDrawer.title'])} </DrawerTitle> <DrawerDescription className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $['deployDrawer.description'])} + {t(($) => $['deployDrawer.description'])} </DrawerDescription> </div> ) @@ -120,41 +118,43 @@ export function ReleaseField() { const selectRelease = useSetAtom(selectDeployReleaseAtom) return ( - <Field label={t($ => $['deployDrawer.releaseLabel'])}> - {isExistingRelease && displayedRelease - ? ( - <div className="flex flex-col gap-1"> - <div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2"> - <div className="flex min-w-0 items-center gap-2"> - <span className="shrink-0 font-mono system-sm-semibold text-text-primary">{displayedRelease.displayName}</span> - <span className="shrink-0 system-xs-regular text-text-tertiary">·</span> - <span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{releaseCommit(displayedRelease)}</span> - </div> - <span className="shrink-0 system-xs-regular text-text-quaternary">{formatDate(displayedRelease.createdAt)}</span> - </div> - <span className="system-xs-regular text-text-tertiary"> - {t($ => $['deployDrawer.existingReleaseHint'])} + <Field label={t(($) => $['deployDrawer.releaseLabel'])}> + {isExistingRelease && displayedRelease ? ( + <div className="flex flex-col gap-1"> + <div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2"> + <div className="flex min-w-0 items-center gap-2"> + <span className="shrink-0 font-mono system-sm-semibold text-text-primary"> + {displayedRelease.displayName} + </span> + <span className="shrink-0 system-xs-regular text-text-tertiary">·</span> + <span className="shrink-0 font-mono system-xs-regular text-text-tertiary"> + {releaseCommit(displayedRelease)} </span> </div> - ) - : releases.length === 0 - ? ( - <DeploymentStateMessage variant="compact"> - {emptyLabel ?? t($ => $['deployDrawer.noReleaseAvailable'])} - </DeploymentStateMessage> - ) - : ( - <DeploymentSelect - value={selectedReleaseId} - onChange={selectRelease} - options={releases.map(release => ({ - value: release.id, - label: `${release.displayName} · ${releaseCommit(release)}`, - }))} - ariaLabel={t($ => $['deployDrawer.releaseLabel'])} - placeholder={t($ => $['deployDrawer.selectRelease'])} - /> - )} + <span className="shrink-0 system-xs-regular text-text-quaternary"> + {formatDate(displayedRelease.createdAt)} + </span> + </div> + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['deployDrawer.existingReleaseHint'])} + </span> + </div> + ) : releases.length === 0 ? ( + <DeploymentStateMessage variant="compact"> + {emptyLabel ?? t(($) => $['deployDrawer.noReleaseAvailable'])} + </DeploymentStateMessage> + ) : ( + <DeploymentSelect + value={selectedReleaseId} + onChange={selectRelease} + options={releases.map((release) => ({ + value: release.id, + label: `${release.displayName} · ${releaseCommit(release)}`, + }))} + ariaLabel={t(($) => $['deployDrawer.releaseLabel'])} + placeholder={t(($) => $['deployDrawer.selectRelease'])} + /> + )} </Field> ) } @@ -169,29 +169,27 @@ export function EnvironmentField() { return ( <Field - label={t($ => $['deployDrawer.targetEnv'])} - hint={lockedEnvId ? t($ => $['deployDrawer.lockedHint']) : undefined} + label={t(($) => $['deployDrawer.targetEnv'])} + hint={lockedEnvId ? t(($) => $['deployDrawer.lockedHint']) : undefined} > - {lockedEnv - ? <EnvironmentRow env={lockedEnv} /> - : environments.length === 0 - ? ( - <DeploymentStateMessage variant="compact"> - {t($ => $['deployDrawer.noNewEnvironmentAvailable'])} - </DeploymentStateMessage> - ) - : ( - <DeploymentSelect - value={selectedEnvironmentId} - onChange={selectEnvironment} - options={environments.map(env => ({ - value: env.id, - label: environmentOptionLabel(env, t), - }))} - ariaLabel={t($ => $['deployDrawer.targetEnv'])} - placeholder={t($ => $['deployDrawer.selectEnv'])} - /> - )} + {lockedEnv ? ( + <EnvironmentRow env={lockedEnv} /> + ) : environments.length === 0 ? ( + <DeploymentStateMessage variant="compact"> + {t(($) => $['deployDrawer.noNewEnvironmentAvailable'])} + </DeploymentStateMessage> + ) : ( + <DeploymentSelect + value={selectedEnvironmentId} + onChange={selectEnvironment} + options={environments.map((env) => ({ + value: env.id, + label: environmentOptionLabel(env, t), + }))} + ariaLabel={t(($) => $['deployDrawer.targetEnv'])} + placeholder={t(($) => $['deployDrawer.selectEnv'])} + /> + )} </Field> ) } diff --git a/web/features/deployments/deploy-drawer/ui/form-skeleton.tsx b/web/features/deployments/deploy-drawer/ui/form-skeleton.tsx index 05251b3b1f44e9..722b06b4fd5e47 100644 --- a/web/features/deployments/deploy-drawer/ui/form-skeleton.tsx +++ b/web/features/deployments/deploy-drawer/ui/form-skeleton.tsx @@ -16,7 +16,7 @@ export function DeployFormSkeleton() { <div className="min-h-0 flex-1 overflow-y-auto px-6 py-5"> <div className="flex flex-col gap-5"> - {DEPLOY_FORM_FIELD_SKELETON_KEYS.map(key => ( + {DEPLOY_FORM_FIELD_SKELETON_KEYS.map((key) => ( <SkeletonContainer key={key} className="gap-2"> <SkeletonRectangle className="h-3 w-24 animate-pulse" /> <SkeletonRectangle className="my-0 h-9 w-full animate-pulse rounded-lg" /> diff --git a/web/features/deployments/deploy-drawer/ui/form.tsx b/web/features/deployments/deploy-drawer/ui/form.tsx index aa2dac23995750..18af453bb98ca1 100644 --- a/web/features/deployments/deploy-drawer/ui/form.tsx +++ b/web/features/deployments/deploy-drawer/ui/form.tsx @@ -11,11 +11,32 @@ import { ScopeProvider } from 'jotai-scope' import { useTranslation } from 'react-i18next' import { EnvVarBindingsPanel } from '../../shared/components/env-var-bindings' import { isAvailableDeploymentTarget } from '../../shared/domain/runtime-status' -import { canAttemptDeployAtom, canSubmitDeployAtom, closeDeployDrawerAtom, deployBindingSlotsAtom, deployEnvVarSlotsAtom, deployEnvVarValuesAtom, deployFormAppInstanceIdAtom, deployHasBindingOptionsErrorAtom, deployHasSelectedEnvironmentAtom, deployIsBindingOptionsLoadingAtom, deployReadyFormConfigAtom, deployReadyFormLocalAtoms, deployReleaseSubmissionAtom, deploySelectedBindingsAtom, deployShowValidationErrorsAtom, deployTargetReleaseIdAtom, isDeployReleaseSubmittingAtom, releaseDeploymentViewAtom, releaseDeploymentViewIsErrorAtom, releaseDeploymentViewIsLoadingAtom, selectDeployBindingAtom, setDeployEnvVarAtom, showDeployValidationErrorsAtom } from '../state' import { - currentReleaseIdForEnvironment, - selectableDeployReleases, -} from '../state/release-options' + canAttemptDeployAtom, + canSubmitDeployAtom, + closeDeployDrawerAtom, + deployBindingSlotsAtom, + deployEnvVarSlotsAtom, + deployEnvVarValuesAtom, + deployFormAppInstanceIdAtom, + deployHasBindingOptionsErrorAtom, + deployHasSelectedEnvironmentAtom, + deployIsBindingOptionsLoadingAtom, + deployReadyFormConfigAtom, + deployReadyFormLocalAtoms, + deployReleaseSubmissionAtom, + deploySelectedBindingsAtom, + deployShowValidationErrorsAtom, + deployTargetReleaseIdAtom, + isDeployReleaseSubmittingAtom, + releaseDeploymentViewAtom, + releaseDeploymentViewIsErrorAtom, + releaseDeploymentViewIsLoadingAtom, + selectDeployBindingAtom, + setDeployEnvVarAtom, + showDeployValidationErrorsAtom, +} from '../state' +import { currentReleaseIdForEnvironment, selectableDeployReleases } from '../state/release-options' import { BindingOptionsPanel, DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME, @@ -54,7 +75,7 @@ function DeployRuntimeCredentialBindingsSection() { selections={selectedBindings} isLoading={isBindingOptionsLoading} hasError={hasBindingOptionsError} - bindingCountLabel={t($ => $['deployDrawer.bindingCount'], { count: bindingSlots.length })} + bindingCountLabel={t(($) => $['deployDrawer.bindingCount'], { count: bindingSlots.length })} showMissingRequired={showValidationErrors} onChange={selectBinding} /> @@ -70,28 +91,27 @@ function DeployEnvVarBindingsSection() { const showValidationErrors = useAtomValue(deployShowValidationErrorsAtom) const setDeployEnvVar = useSetAtom(setDeployEnvVarAtom) - if (isBindingOptionsLoading || hasBindingOptionsError) - return null + if (isBindingOptionsLoading || hasBindingOptionsError) return null return ( <EnvVarBindingsPanel slots={envVarSlots} values={envVarValues} - title={t($ => $['deployDrawer.envVars'])} - hint={t($ => $['deployDrawer.envVarHint'])} - envVarPlaceholder={t($ => $['deployDrawer.envVarPlaceholder'])} - literalSourceLabel={t($ => $['deployDrawer.envVarSource.literal'])} - defaultSourceLabel={t($ => $['deployDrawer.envVarSource.default'])} - lastDeploymentSourceLabel={t($ => $['deployDrawer.envVarSource.lastDeployment'])} + title={t(($) => $['deployDrawer.envVars'])} + hint={t(($) => $['deployDrawer.envVarHint'])} + envVarPlaceholder={t(($) => $['deployDrawer.envVarPlaceholder'])} + literalSourceLabel={t(($) => $['deployDrawer.envVarSource.literal'])} + defaultSourceLabel={t(($) => $['deployDrawer.envVarSource.default'])} + lastDeploymentSourceLabel={t(($) => $['deployDrawer.envVarSource.lastDeployment'])} valueTypeLabels={{ - string: t($ => $['deployDrawer.envVarType.string']), - number: t($ => $['deployDrawer.envVarType.number']), - secret: t($ => $['deployDrawer.envVarType.secret']), + string: t(($) => $['deployDrawer.envVarType.string']), + number: t(($) => $['deployDrawer.envVarType.number']), + secret: t(($) => $['deployDrawer.envVarType.secret']), }} - sourceAriaLabel={key => t($ => $['deployDrawer.envVarSource.ariaLabel'], { key })} + sourceAriaLabel={(key) => t(($) => $['deployDrawer.envVarSource.ariaLabel'], { key })} defaultSourcePriority="lastDeployment" - envVarCountLabel={t($ => $['deployDrawer.envVarCount'], { count: envVarSlots.length })} - missingRequiredLabel={t($ => $['deployDrawer.missingRequiredEnvVar'])} + envVarCountLabel={t(($) => $['deployDrawer.envVarCount'], { count: envVarSlots.length })} + missingRequiredLabel={t(($) => $['deployDrawer.missingRequiredEnvVar'])} listClassName={DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME} showMissingRequired={showValidationErrors} onChange={setDeployEnvVar} @@ -103,8 +123,7 @@ function DeployBindingsSection() { const targetReleaseId = useAtomValue(deployTargetReleaseIdAtom) const hasSelectedEnvironment = useAtomValue(deployHasSelectedEnvironmentAtom) - if (!targetReleaseId || !hasSelectedEnvironment) - return null + if (!targetReleaseId || !hasSelectedEnvironment) return null return ( <> @@ -134,23 +153,24 @@ function DeployFooter() { const canAttemptDeploy = useAtomValue(canAttemptDeployAtom) const canDeploy = useAtomValue(canSubmitDeployAtom) const isSubmitting = useAtomValue(isDeployReleaseSubmittingAtom) - const submitLabel = isSubmitting ? t($ => $['deployDrawer.deploying']) : t($ => $['deployDrawer.deploy']) + const submitLabel = isSubmitting + ? t(($) => $['deployDrawer.deploying']) + : t(($) => $['deployDrawer.deploy']) function handleDeploy() { showValidationErrors() - if (!canDeploy) - return + if (!canDeploy) return submitDeployRelease({ - deployFailedMessage: t($ => $['deployDrawer.deployFailed']), + deployFailedMessage: t(($) => $['deployDrawer.deployFailed']), }) } return ( <div className="flex shrink-0 justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle px-6 py-4"> <Button type="button" variant="secondary" onClick={closeDeployDrawer}> - {t($ => $['deployDrawer.cancel'])} + {t(($) => $['deployDrawer.cancel'])} </Button> <Button variant="primary" disabled={!canAttemptDeploy} onClick={handleDeploy}> {submitLabel} @@ -173,19 +193,16 @@ function deployReadyFormStoreKey({ lockedEnvId ?? 'any', presetReleaseId ?? 'new', defaultReleaseId ?? 'none', - environments.map(env => env.id).join(','), - releases.map(release => release.id).join(','), - runtimeRows.map(row => `${row.environment.id}:${row.currentRelease?.id ?? 'none'}`).join(','), + environments.map((env) => env.id).join(','), + releases.map((release) => release.id).join(','), + runtimeRows.map((row) => `${row.environment.id}:${row.currentRelease?.id ?? 'none'}`).join(','), ].join('|') } function DeployReadyForm(config: DeployReadyFormProps) { return ( <ScopeProvider - atoms={[ - [deployReadyFormConfigAtom, config], - ...deployReadyFormLocalAtoms, - ]} + atoms={[[deployReadyFormConfigAtom, config], ...deployReadyFormLocalAtoms]} name="DeployReadyForm" > <div className="flex min-h-0 flex-1 flex-col"> @@ -197,11 +214,7 @@ function DeployReadyForm(config: DeployReadyFormProps) { ) } -function DeployFormContent({ - appInstanceId, - lockedEnvId, - presetReleaseId, -}: DeployFormProps) { +function DeployFormContent({ appInstanceId, lockedEnvId, presetReleaseId }: DeployFormProps) { const { t } = useTranslation('deployments') const deploymentView = useAtomValue(releaseDeploymentViewAtom) const isLoading = useAtomValue(releaseDeploymentViewIsLoadingAtom) @@ -214,7 +227,7 @@ function DeployFormContent({ if (isError) { return ( <div className="p-4 system-sm-regular text-text-destructive"> - {t($ => $['common.loadFailed'])} + {t(($) => $['common.loadFailed'])} </div> ) } @@ -222,15 +235,15 @@ function DeployFormContent({ if (!deploymentView) { return ( <div className="p-4 system-sm-regular text-text-destructive"> - {t($ => $['common.loadFailed'])} + {t(($) => $['common.loadFailed'])} </div> ) } const runtimeRows = deploymentView.environmentDeployments const environments = runtimeRows - .filter(row => lockedEnvId || isAvailableDeploymentTarget(row)) - .map(row => row.environment) + .filter((row) => lockedEnvId || isAvailableDeploymentTarget(row)) + .map((row) => row.environment) const releaseRows = deploymentView.releases const currentReleaseId = currentReleaseIdForEnvironment(runtimeRows, lockedEnvId) const releases = selectableDeployReleases({ @@ -240,9 +253,10 @@ function DeployFormContent({ presetReleaseId, }) const defaultReleaseId = releases[0]?.id - const releaseEmptyLabel = lockedEnvId && !presetReleaseId && currentReleaseId - ? t($ => $['deployDrawer.noOtherReleaseAvailable']) - : undefined + const releaseEmptyLabel = + lockedEnvId && !presetReleaseId && currentReleaseId + ? t(($) => $['deployDrawer.noOtherReleaseAvailable']) + : undefined const readyFormConfig = { appInstanceId, environments, @@ -255,21 +269,14 @@ function DeployFormContent({ } const formKey = deployReadyFormStoreKey(readyFormConfig) - return ( - <DeployReadyForm - key={formKey} - {...readyFormConfig} - /> - ) + return <DeployReadyForm key={formKey} {...readyFormConfig} /> } export function DeployForm(props: DeployFormProps) { return ( <ScopeProvider key={props.appInstanceId} - atoms={[ - [deployFormAppInstanceIdAtom, props.appInstanceId], - ]} + atoms={[[deployFormAppInstanceIdAtom, props.appInstanceId]]} name="DeployForm" > <DeployFormContent {...props} /> diff --git a/web/features/deployments/deploy-drawer/ui/select.tsx b/web/features/deployments/deploy-drawer/ui/select.tsx index 962b241a92c5ce..65e4e2f8f2c7cb 100644 --- a/web/features/deployments/deploy-drawer/ui/select.tsx +++ b/web/features/deployments/deploy-drawer/ui/select.tsx @@ -3,13 +3,24 @@ import type { Environment, EnvironmentStatus } from '@dify/contracts/enterprise/types.gen' import { EnvironmentStatus as EnvironmentStatusEnum } from '@dify/contracts/enterprise/types.gen' import { cn } from '@langgenius/dify-ui/cn' -import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' import { TitleTooltip } from '../../shared/components/title-tooltip' import { ModeBadge } from './status-badge' -export function Field({ label, hint, children }: { +export function Field({ + label, + hint, + children, +}: { label: string hint?: string children: React.ReactNode @@ -40,60 +51,59 @@ type SelectProps = { placeholder?: string } -export function DeploymentSelect({ value, onChange, options, ariaLabel, placeholder }: SelectProps) { +export function DeploymentSelect({ + value, + onChange, + options, + ariaLabel, + placeholder, +}: SelectProps) { const { t } = useTranslation('deployments') - const selectedOption = options.find(option => option.value === value) + const selectedOption = options.find((option) => option.value === value) return ( <Select value={value ?? null} onValueChange={(next) => { - if (!next) - return + if (!next) return onChange(next) }} disabled={options.length === 0} > <SelectTrigger - aria-label={ariaLabel ?? placeholder ?? t($ => $['deployDrawer.defaultSelect'])} + aria-label={ariaLabel ?? placeholder ?? t(($) => $['deployDrawer.defaultSelect'])} className={cn( 'h-8 min-w-0 px-2 text-left system-sm-medium', !selectedOption && 'text-text-quaternary', )} > - {selectedOption?.label ?? placeholder ?? t($ => $['deployDrawer.defaultSelect'])} + {selectedOption?.label ?? placeholder ?? t(($) => $['deployDrawer.defaultSelect'])} </SelectTrigger> <SelectContent popupClassName="w-(--anchor-width)"> - {options.map(opt => opt.value - ? ( - <SelectItem - key={opt.value} - value={opt.value} - disabled={opt.disabled} - > - <TitleTooltip content={opt.disabled ? opt.disabledReason : undefined}> - <SelectItemText>{opt.label}</SelectItemText> - </TitleTooltip> - <SelectItemIndicator /> - </SelectItem> - ) - : null)} + {options.map((opt) => + opt.value ? ( + <SelectItem key={opt.value} value={opt.value} disabled={opt.disabled}> + <TitleTooltip content={opt.disabled ? opt.disabledReason : undefined}> + <SelectItemText>{opt.label}</SelectItemText> + </TitleTooltip> + <SelectItemIndicator /> + </SelectItem> + ) : null, + )} </SelectContent> </Select> ) } -function EnvironmentHealthDot({ status }: { - status: EnvironmentStatus -}) { +function EnvironmentHealthDot({ status }: { status: EnvironmentStatus }) { const { t } = useTranslation('deployments') - const label = t($ => $[`health.${status}`]) + const label = t(($) => $[`health.${status}`]) const isReady = status === EnvironmentStatusEnum.ENVIRONMENT_STATUS_READY return ( <Tooltip> <TooltipTrigger - render={( + render={ <span aria-label={label} className={cn( @@ -109,7 +119,7 @@ function EnvironmentHealthDot({ status }: { )} /> </span> - )} + } /> <TooltipContent>{label}</TooltipContent> </Tooltip> @@ -118,7 +128,7 @@ function EnvironmentHealthDot({ status }: { export function EnvironmentRow({ env }: { env: Environment }) { const { t } = useTranslation('deployments') - const summary = env.description.trim() || t($ => $[`backend.${env.backend}`]) + const summary = env.description.trim() || t(($) => $[`backend.${env.backend}`]) return ( <div className="flex items-center justify-between gap-3 rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2"> diff --git a/web/features/deployments/deploy-drawer/ui/status-badge.tsx b/web/features/deployments/deploy-drawer/ui/status-badge.tsx index e36ad00a1830ae..3a7a898ce94124 100644 --- a/web/features/deployments/deploy-drawer/ui/status-badge.tsx +++ b/web/features/deployments/deploy-drawer/ui/status-badge.tsx @@ -4,19 +4,14 @@ import { EnvironmentMode as EnvironmentModeEnum } from '@dify/contracts/enterpri import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' -const baseBadge = 'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 system-xs-medium whitespace-nowrap' +const baseBadge = + 'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 system-xs-medium whitespace-nowrap' -export function ModeBadge({ mode, className }: { - mode: EnvironmentMode - className?: string -}) { +export function ModeBadge({ mode, className }: { mode: EnvironmentMode; className?: string }) { const { t } = useTranslation('deployments') - const style = mode === EnvironmentModeEnum.ENVIRONMENT_MODE_SHARED - ? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700' - : 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700' - return ( - <span className={cn(baseBadge, style, className)}> - {t($ => $[`mode.${mode}`])} - </span> - ) + const style = + mode === EnvironmentModeEnum.ENVIRONMENT_MODE_SHARED + ? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700' + : 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700' + return <span className={cn(baseBadge, style, className)}>{t(($) => $[`mode.${mode}`])}</span> } diff --git a/web/features/deployments/deployment-actions/__tests__/delete-dialog.spec.tsx b/web/features/deployments/deployment-actions/__tests__/delete-dialog.spec.tsx index 7f01287bece703..0a7ad1d05437e1 100644 --- a/web/features/deployments/deployment-actions/__tests__/delete-dialog.spec.tsx +++ b/web/features/deployments/deployment-actions/__tests__/delete-dialog.spec.tsx @@ -3,10 +3,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ScopeProvider } from 'jotai-scope' import { DeleteDeploymentDialog } from '../delete-dialog' -import { - deleteDeploymentDialogOpenAtom, - deploymentActionAppInstanceAtom, -} from '../state' +import { deleteDeploymentDialogOpenAtom, deploymentActionAppInstanceAtom } from '../state' const deleteMutationMock = vi.hoisted(() => ({ isPending: false, @@ -53,7 +50,9 @@ vi.mock('@/service/client', () => ({ }, })) -function createAppInstance(overrides: Partial<DeploymentActionAppInstance> = {}): DeploymentActionAppInstance { +function createAppInstance( + overrides: Partial<DeploymentActionAppInstance> = {}, +): DeploymentActionAppInstance { return { id: 'app-instance-1', displayName: 'Deployment 1', @@ -101,15 +100,18 @@ describe('DeleteDeploymentDialog', () => { await user.click(screen.getByRole('button', { name: 'deployments.settings.delete' })) - expect(deleteMutationMock.mutate).toHaveBeenCalledWith({ - params: { - appInstanceId: 'app-instance-1', + expect(deleteMutationMock.mutate).toHaveBeenCalledWith( + { + params: { + appInstanceId: 'app-instance-1', + }, }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - onSettled: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + onSettled: expect.any(Function), + }), + ) }) }) }) diff --git a/web/features/deployments/deployment-actions/__tests__/edit-dialog.spec.tsx b/web/features/deployments/deployment-actions/__tests__/edit-dialog.spec.tsx index d74091a87f20af..3303764feb9238 100644 --- a/web/features/deployments/deployment-actions/__tests__/edit-dialog.spec.tsx +++ b/web/features/deployments/deployment-actions/__tests__/edit-dialog.spec.tsx @@ -3,10 +3,7 @@ import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ScopeProvider } from 'jotai-scope' import { EditDeploymentDialog } from '../edit-dialog' -import { - deploymentActionAppInstanceAtom, - editDeploymentDialogOpenAtom, -} from '../state' +import { deploymentActionAppInstanceAtom, editDeploymentDialogOpenAtom } from '../state' const updateMutationMock = vi.hoisted(() => ({ isPending: false, @@ -45,7 +42,9 @@ vi.mock('@/service/client', () => ({ }, })) -function createAppInstance(overrides: Partial<DeploymentActionAppInstance> = {}): DeploymentActionAppInstance { +function createAppInstance( + overrides: Partial<DeploymentActionAppInstance> = {}, +): DeploymentActionAppInstance { return { id: 'app-instance-1', displayName: 'Deployment 1', @@ -91,8 +90,12 @@ describe('EditDeploymentDialog', () => { renderDialog() const dialog = screen.getByRole('dialog', { name: 'deployments.card.menu.editInfo' }) - expect(within(dialog).getByRole('textbox', { name: 'deployments.settings.name' })).toHaveValue('Deployment 1') - expect(within(dialog).getByRole('textbox', { name: 'deployments.settings.description' })).toHaveValue('Initial description') + expect( + within(dialog).getByRole('textbox', { name: 'deployments.settings.name' }), + ).toHaveValue('Deployment 1') + expect( + within(dialog).getByRole('textbox', { name: 'deployments.settings.description' }), + ).toHaveValue('Initial description') }) it('should submit trimmed deployment metadata through the component mutation', async () => { @@ -101,24 +104,35 @@ describe('EditDeploymentDialog', () => { const dialog = screen.getByRole('dialog', { name: 'deployments.card.menu.editInfo' }) await user.clear(within(dialog).getByRole('textbox', { name: 'deployments.settings.name' })) - await user.type(within(dialog).getByRole('textbox', { name: 'deployments.settings.name' }), ' Deployment 2 ') - await user.clear(within(dialog).getByRole('textbox', { name: 'deployments.settings.description' })) - await user.type(within(dialog).getByRole('textbox', { name: 'deployments.settings.description' }), ' Updated description ') + await user.type( + within(dialog).getByRole('textbox', { name: 'deployments.settings.name' }), + ' Deployment 2 ', + ) + await user.clear( + within(dialog).getByRole('textbox', { name: 'deployments.settings.description' }), + ) + await user.type( + within(dialog).getByRole('textbox', { name: 'deployments.settings.description' }), + ' Updated description ', + ) await user.click(within(dialog).getByRole('button', { name: 'deployments.settings.save' })) - expect(updateMutationMock.mutate).toHaveBeenCalledWith({ - params: { - appInstanceId: 'app-instance-1', + expect(updateMutationMock.mutate).toHaveBeenCalledWith( + { + params: { + appInstanceId: 'app-instance-1', + }, + body: { + appInstanceId: 'app-instance-1', + displayName: 'Deployment 2', + description: 'Updated description', + }, }, - body: { - appInstanceId: 'app-instance-1', - displayName: 'Deployment 2', - description: 'Updated description', - }, - }, expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - })) + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) }) }) }) diff --git a/web/features/deployments/deployment-actions/__tests__/index.spec.tsx b/web/features/deployments/deployment-actions/__tests__/index.spec.tsx index 0e0bbb8fe89e6f..7e1ad5f86976c7 100644 --- a/web/features/deployments/deployment-actions/__tests__/index.spec.tsx +++ b/web/features/deployments/deployment-actions/__tests__/index.spec.tsx @@ -24,7 +24,8 @@ vi.mock('../edit-dialog', async () => { vi.mock('../delete-dialog', async () => { const { useAtomValue } = await import('jotai') - const { deleteDeploymentDialogOpenAtom, deploymentActionAppInstanceAtom } = await import('../state') + const { deleteDeploymentDialogOpenAtom, deploymentActionAppInstanceAtom } = + await import('../state') return { DeleteDeploymentDialog: () => { @@ -37,7 +38,9 @@ vi.mock('../delete-dialog', async () => { } }) -function createAppInstance(overrides: Partial<DeploymentActionAppInstance> = {}): DeploymentActionAppInstance { +function createAppInstance( + overrides: Partial<DeploymentActionAppInstance> = {}, +): DeploymentActionAppInstance { return { id: 'app-instance-1', displayName: 'Deployment 1', @@ -72,18 +75,15 @@ describe('DeploymentActionsMenu', () => { it('opens edit and delete dialogs from menu items', async () => { const user = userEvent.setup() - render( - <DeploymentActionsMenu - appInstance={createAppInstance()} - placement="bottom-end" - />, - ) + render(<DeploymentActionsMenu appInstance={createAppInstance()} placement="bottom-end" />) expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false') expect(screen.getByTestId('delete-dialog')).toHaveAttribute('data-open', 'false') await user.click(screen.getByRole('button', { name: 'deployments.card.moreActions' })) - await user.click(await screen.findByRole('menuitem', { name: 'deployments.card.menu.editInfo' })) + await user.click( + await screen.findByRole('menuitem', { name: 'deployments.card.menu.editInfo' }), + ) expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'true') expect(screen.getByTestId('delete-dialog')).toHaveAttribute('data-open', 'false') @@ -93,7 +93,13 @@ describe('DeploymentActionsMenu', () => { expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false') expect(screen.getByTestId('delete-dialog')).toHaveAttribute('data-open', 'true') - expect(editDialogMock).toHaveBeenLastCalledWith({ appInstanceId: 'app-instance-1', open: false }) - expect(deleteDialogMock).toHaveBeenLastCalledWith({ appInstanceId: 'app-instance-1', open: true }) + expect(editDialogMock).toHaveBeenLastCalledWith({ + appInstanceId: 'app-instance-1', + open: false, + }) + expect(deleteDialogMock).toHaveBeenLastCalledWith({ + appInstanceId: 'app-instance-1', + open: true, + }) }) }) diff --git a/web/features/deployments/deployment-actions/delete-dialog.tsx b/web/features/deployments/deployment-actions/delete-dialog.tsx index 7631b70452dae8..47a2cdc569ba29 100644 --- a/web/features/deployments/deployment-actions/delete-dialog.tsx +++ b/web/features/deployments/deployment-actions/delete-dialog.tsx @@ -15,17 +15,16 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/client' -import { - deleteDeploymentDialogOpenAtom, - deploymentActionAppInstanceAtom, -} from './state' +import { deleteDeploymentDialogOpenAtom, deploymentActionAppInstanceAtom } from './state' function DeleteDeploymentDialogContent() { const { t } = useTranslation('deployments') const router = useRouter() const appInstance = useAtomValue(deploymentActionAppInstanceAtom) const setOpen = useSetAtom(deleteDeploymentDialogOpenAtom) - const deleteInstance = useMutation(consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions()) + const deleteInstance = useMutation( + consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions(), + ) const displayName = appInstance.displayName || appInstance.id function handleDelete() { @@ -37,11 +36,11 @@ function DeleteDeploymentDialogContent() { }, { onSuccess: () => { - toast.success(t($ => $['settings.deleted'])) + toast.success(t(($) => $['settings.deleted'])) router.push('/deployments') }, onError: () => { - toast.error(t($ => $['settings.deleteFailed'])) + toast.error(t(($) => $['settings.deleteFailed'])) }, onSettled: () => { setOpen(false) @@ -54,21 +53,18 @@ function DeleteDeploymentDialogContent() { <> <div className="flex flex-col gap-3 px-6 pt-6 pb-2"> <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['settings.deleteConfirmTitle'])} + {t(($) => $['settings.deleteConfirmTitle'])} </AlertDialogTitle> <AlertDialogDescription className="system-sm-regular text-text-tertiary"> - {t($ => $['settings.deleteConfirmDesc'], { name: displayName })} + {t(($) => $['settings.deleteConfirmDesc'], { name: displayName })} </AlertDialogDescription> </div> <AlertDialogActions className="pt-3"> <AlertDialogCancelButton variant="secondary" disabled={deleteInstance.isPending}> - {t($ => $['createModal.cancel'])} + {t(($) => $['createModal.cancel'])} </AlertDialogCancelButton> - <AlertDialogConfirmButton - loading={deleteInstance.isPending} - onClick={handleDelete} - > - {t($ => $['settings.delete'])} + <AlertDialogConfirmButton loading={deleteInstance.isPending} onClick={handleDelete}> + {t(($) => $['settings.delete'])} </AlertDialogConfirmButton> </AlertDialogActions> </> diff --git a/web/features/deployments/deployment-actions/edit-dialog.tsx b/web/features/deployments/deployment-actions/edit-dialog.tsx index 771b7bca9d4bad..fec78827ef80af 100644 --- a/web/features/deployments/deployment-actions/edit-dialog.tsx +++ b/web/features/deployments/deployment-actions/edit-dialog.tsx @@ -1,12 +1,7 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' -import { - Dialog, - DialogCloseButton, - DialogContent, - DialogTitle, -} from '@langgenius/dify-ui/dialog' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Field, FieldControl, FieldError, FieldLabel } from '@langgenius/dify-ui/field' import { Form } from '@langgenius/dify-ui/form' import { Textarea } from '@langgenius/dify-ui/textarea' @@ -15,10 +10,7 @@ import { useMutation } from '@tanstack/react-query' import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' -import { - deploymentActionAppInstanceAtom, - editDeploymentDialogOpenAtom, -} from './state' +import { deploymentActionAppInstanceAtom, editDeploymentDialogOpenAtom } from './state' type EditDeploymentFormValues = { name: string @@ -32,21 +24,22 @@ function normalizedEditDeploymentFormValues(value: EditDeploymentFormValues) { } } -function canSubmitEditDeploymentForm(initialValues: EditDeploymentFormValues, value: EditDeploymentFormValues) { +function canSubmitEditDeploymentForm( + initialValues: EditDeploymentFormValues, + value: EditDeploymentFormValues, +) { const normalizedValues = normalizedEditDeploymentFormValues(value) return Boolean( - normalizedValues.name - && ( - normalizedValues.name !== initialValues.name - || normalizedValues.description !== initialValues.description - ), + normalizedValues.name && + (normalizedValues.name !== initialValues.name || + normalizedValues.description !== initialValues.description), ) } function EditDeploymentForm() { const { t } = useTranslation('deployments') - const nameLabel = t($ => $['settings.name']) + const nameLabel = t(($) => $['settings.name']) const appInstance = useAtomValue(deploymentActionAppInstanceAtom) const appInstanceId = appInstance.id const initialValues = { @@ -54,18 +47,18 @@ function EditDeploymentForm() { description: appInstance.description, } const setOpen = useSetAtom(editDeploymentDialogOpenAtom) - const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions()) + const updateInstance = useMutation( + consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions(), + ) function handleClose() { - if (updateInstance.isPending) - return + if (updateInstance.isPending) return setOpen(false) } function handleSubmit(values: EditDeploymentFormValues) { - if (!canSubmitEditDeploymentForm(initialValues, values)) - return + if (!canSubmitEditDeploymentForm(initialValues, values)) return const normalizedValues = normalizedEditDeploymentFormValues(values) @@ -82,11 +75,11 @@ function EditDeploymentForm() { }, { onSuccess: () => { - toast.success(t($ => $['settings.updated'])) + toast.success(t(($) => $['settings.updated'])) setOpen(false) }, onError: () => { - toast.error(t($ => $['settings.updateFailed'])) + toast.error(t(($) => $['settings.updateFailed'])) }, }, ) @@ -100,22 +93,16 @@ function EditDeploymentForm() { <FieldLabel className="system-xs-medium-uppercase text-text-tertiary"> {nameLabel} </FieldLabel> - <FieldControl - type="text" - required - defaultValue={initialValues.name} - className="h-8" - /> - <FieldError match="valueMissing">{t($ => $['errorMsg.fieldRequired'], { ns: 'common', field: nameLabel })}</FieldError> + <FieldControl type="text" required defaultValue={initialValues.name} className="h-8" /> + <FieldError match="valueMissing"> + {t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: nameLabel })} + </FieldError> </Field> <Field name="description" className="gap-2"> <FieldLabel className="system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['settings.description'])} + {t(($) => $['settings.description'])} </FieldLabel> - <Textarea - defaultValue={initialValues.description} - className="min-h-24" - /> + <Textarea defaultValue={initialValues.description} className="min-h-24" /> </Field> <div className="flex justify-end gap-2 pt-2"> <Button @@ -124,7 +111,7 @@ function EditDeploymentForm() { disabled={updateInstance.isPending} onClick={handleClose} > - {t($ => $['createModal.cancel'])} + {t(($) => $['createModal.cancel'])} </Button> <Button type="submit" @@ -132,7 +119,7 @@ function EditDeploymentForm() { disabled={updateInstance.isPending} loading={updateInstance.isPending} > - {t($ => $['settings.save'])} + {t(($) => $['settings.save'])} </Button> </div> </Form> @@ -148,7 +135,7 @@ function EditDeploymentDialogContent() { <> <div className="border-b border-divider-subtle px-6 py-5"> <DialogTitle className="title-xl-semi-bold text-text-primary"> - {t($ => $['card.menu.editInfo'])} + {t(($) => $['card.menu.editInfo'])} </DialogTitle> </div> <div className="px-6 py-5"> diff --git a/web/features/deployments/deployment-actions/index.tsx b/web/features/deployments/deployment-actions/index.tsx index 3e527663bff937..5622f43d3d7aed 100644 --- a/web/features/deployments/deployment-actions/index.tsx +++ b/web/features/deployments/deployment-actions/index.tsx @@ -53,29 +53,31 @@ function DeploymentActionsMenuContent({ className, '[&:has([data-popup-open])]:pointer-events-auto [&:has([data-popup-open])]:opacity-100', )} - onClick={event => event.stopPropagation()} - onKeyDown={event => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} > <DropdownMenu modal={false}> <DropdownMenuTrigger - aria-label={t($ => $['card.moreActions'])} + aria-label={t(($) => $['card.moreActions'])} className={cn(ACTION_TRIGGER_CLASS_NAME, triggerClassName)} > <span aria-hidden className="i-ri-more-fill size-4" /> </DropdownMenuTrigger> - <DropdownMenuContent placement={placement} sideOffset={sideOffset} popupClassName="min-w-44"> + <DropdownMenuContent + placement={placement} + sideOffset={sideOffset} + popupClassName="min-w-44" + > <DropdownMenuItem className="gap-2 px-3" onClick={openEditDialog}> <span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" /> - <span className="system-sm-regular text-text-secondary">{t($ => $['card.menu.editInfo'])}</span> + <span className="system-sm-regular text-text-secondary"> + {t(($) => $['card.menu.editInfo'])} + </span> </DropdownMenuItem> <DropdownMenuSeparator /> - <DropdownMenuItem - variant="destructive" - className="gap-2 px-3" - onClick={openDeleteDialog} - > + <DropdownMenuItem variant="destructive" className="gap-2 px-3" onClick={openDeleteDialog}> <span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" /> - <span className="system-sm-regular">{t($ => $['card.menu.delete'])}</span> + <span className="system-sm-regular">{t(($) => $['card.menu.delete'])}</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> @@ -85,17 +87,11 @@ function DeploymentActionsMenuContent({ ) } -export function DeploymentActionsMenu({ - appInstance, - ...props -}: DeploymentActionsMenuProps) { +export function DeploymentActionsMenu({ appInstance, ...props }: DeploymentActionsMenuProps) { return ( <ScopeProvider key={appInstance.id} - atoms={[ - [deploymentActionAppInstanceAtom, appInstance], - ...deploymentActionsLocalAtoms, - ]} + atoms={[[deploymentActionAppInstanceAtom, appInstance], ...deploymentActionsLocalAtoms]} name="DeploymentActionsMenu" > <DeploymentActionsMenuContent {...props} /> diff --git a/web/features/deployments/detail/__tests__/index.spec.tsx b/web/features/deployments/detail/__tests__/index.spec.tsx index 81e18849fecbb2..711ed5c949ccc8 100644 --- a/web/features/deployments/detail/__tests__/index.spec.tsx +++ b/web/features/deployments/detail/__tests__/index.spec.tsx @@ -75,7 +75,10 @@ describe('DeploymentDetailTop', () => { ) expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/') - expect(screen.getByRole('link', { name: 'common.menus.deployments' })).toHaveAttribute('href', '/deployments') + expect(screen.getByRole('link', { name: 'common.menus.deployments' })).toHaveAttribute( + 'href', + '/deployments', + ) expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument() }) }) diff --git a/web/features/deployments/detail/__tests__/state.spec.ts b/web/features/deployments/detail/__tests__/state.spec.ts index f7812000655b6b..92ab6c4a91ff35 100644 --- a/web/features/deployments/detail/__tests__/state.spec.ts +++ b/web/features/deployments/detail/__tests__/state.spec.ts @@ -19,19 +19,21 @@ const mockEnvironmentDeploymentsData = vi.hoisted<{ }>(() => ({})) vi.mock('jotai-tanstack-query', () => ({ - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => { - const options = createOptions(get) - return { - ...options, - data: options.queryKey?.[0] === 'listEnvironmentDeployments' - ? mockEnvironmentDeploymentsData.current - : undefined, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: false, - } - }), + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => + atom((get) => { + const options = createOptions(get) + return { + ...options, + data: + options.queryKey?.[0] === 'listEnvironmentDeployments' + ? mockEnvironmentDeploymentsData.current + : undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + } + }), })) vi.mock('@/service/client', () => ({ @@ -61,7 +63,11 @@ async function loadState() { return await import('../state') } -function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1', tab = 'overview') { +function setDeploymentRoute( + store: ReturnType<typeof createStore>, + appInstanceId = 'app-instance-1', + tab = 'overview', +) { store.set(setNextRouteStateAtom, { pathname: `/deployments/${appInstanceId}/${tab}`, params: { appInstanceId }, @@ -116,7 +122,9 @@ describe('deployment detail state', () => { input: { params: { appInstanceId: 'app-instance-1' } }, }) - const environmentDeploymentsQuery = store.get(state.deploymentEnvironmentDeploymentsQueryAtom) as unknown as QueryOptions + const environmentDeploymentsQuery = store.get( + state.deploymentEnvironmentDeploymentsQueryAtom, + ) as unknown as QueryOptions expect(environmentDeploymentsQuery).toMatchObject({ enabled: true, input: { params: { appInstanceId: 'app-instance-1' } }, @@ -150,6 +158,8 @@ describe('deployment detail state', () => { ], } - expect(store.get(state.deploymentRuntimeInstanceRowsAtom).map(row => row.environment.id)).toEqual(['running']) + expect( + store.get(state.deploymentRuntimeInstanceRowsAtom).map((row) => row.environment.id), + ).toEqual(['running']) }) }) diff --git a/web/features/deployments/detail/access/__tests__/state.spec.ts b/web/features/deployments/detail/access/__tests__/state.spec.ts index a989a98618d1a5..96243487e2506d 100644 --- a/web/features/deployments/detail/access/__tests__/state.spec.ts +++ b/web/features/deployments/detail/access/__tests__/state.spec.ts @@ -11,14 +11,15 @@ type QueryOptions = { } vi.mock('jotai-tanstack-query', () => ({ - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ - ...createOptions(get), - data: undefined, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: false, - })), + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => + atom((get) => ({ + ...createOptions(get), + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + })), })) vi.mock('@/service/client', () => ({ @@ -40,7 +41,10 @@ async function loadState() { return await import('../state') } -function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') { +function setDeploymentRoute( + store: ReturnType<typeof createStore>, + appInstanceId = 'app-instance-1', +) { store.set(setNextRouteStateAtom, { pathname: `/deployments/${appInstanceId}/access`, params: { appInstanceId }, diff --git a/web/features/deployments/detail/access/channels/__tests__/section.spec.tsx b/web/features/deployments/detail/access/channels/__tests__/section.spec.tsx index d5c7c45eb17ed5..7b8f855354984b 100644 --- a/web/features/deployments/detail/access/channels/__tests__/section.spec.tsx +++ b/web/features/deployments/detail/access/channels/__tests__/section.spec.tsx @@ -70,8 +70,7 @@ describe('AccessChannelsSection', () => { beforeEach(() => { vi.clearAllMocks() mockUseAtomValue.mockImplementation((atom) => { - if (atom === deploymentRouteAppInstanceIdAtom) - return 'app-instance-1' + if (atom === deploymentRouteAppInstanceIdAtom) return 'app-instance-1' if (atom === accessSettingsAtom) { return { accessChannels: createAccessChannels(), @@ -79,10 +78,8 @@ describe('AccessChannelsSection', () => { cliEndpoint: createEndpoint('https://cli.example.com/entry'), } } - if (atom === accessSettingsIsLoadingAtom) - return false - if (atom === accessSettingsIsErrorAtom) - return false + if (atom === accessSettingsIsLoadingAtom) return false + if (atom === accessSettingsIsErrorAtom) return false return undefined }) }) diff --git a/web/features/deployments/detail/access/channels/section.tsx b/web/features/deployments/detail/access/channels/section.tsx index 4f2ab78f51b642..35d35346f1c939 100644 --- a/web/features/deployments/detail/access/channels/section.tsx +++ b/web/features/deployments/detail/access/channels/section.tsx @@ -9,7 +9,11 @@ import { useTranslation } from 'react-i18next' import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' import { consoleQuery } from '@/service/client' import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' -import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../shared/components/empty-state' +import { + DeploymentEmptyState, + DeploymentNoticeState, + DeploymentStateMessage, +} from '../../../shared/components/empty-state' import { CopyPill, EndpointRow } from '../../../shared/components/endpoint' import { Section } from '../../../shared/components/section' import { @@ -19,29 +23,31 @@ import { } from '../state' import { getUrlOrigin } from './url' -const ACCESS_CHANNEL_SKELETON_SECTIONS = [ - { key: 'webapp' }, - { key: 'cli' }, -] +const ACCESS_CHANNEL_SKELETON_SECTIONS = [{ key: 'webapp' }, { key: 'cli' }] -function AccessChannelsSwitch({ checked, accessChannels, disabled }: { +function AccessChannelsSwitch({ + checked, + accessChannels, + disabled, +}: { checked: boolean accessChannels?: AccessChannels disabled?: boolean }) { const { t } = useTranslation('deployments') const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) - const toggleAccessChannel = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions()) + const toggleAccessChannel = useMutation( + consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions(), + ) return ( <Switch - aria-label={t($ => $['access.channels.title'])} + aria-label={t(($) => $['access.channels.title'])} checked={checked} disabled={disabled || !appInstanceId} loading={toggleAccessChannel.isPending} onCheckedChange={(enabled) => { - if (!appInstanceId) - return + if (!appInstanceId) return toggleAccessChannel.mutate({ params: { appInstanceId }, @@ -59,7 +65,7 @@ function AccessChannelsSwitch({ checked, accessChannels, disabled }: { function AccessChannelsSkeleton() { return ( <div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg"> - {ACCESS_CHANNEL_SKELETON_SECTIONS.map(section => ( + {ACCESS_CHANNEL_SKELETON_SECTIONS.map((section) => ( <SkeletonRow key={section.key} className="flex flex-col gap-3 border-t border-divider-subtle px-4 py-4 first:border-t-0 lg:flex-row lg:items-start" @@ -78,7 +84,11 @@ function AccessChannelsSkeleton() { ) } -function ChannelInfo({ icon, title, description }: { +function ChannelInfo({ + icon, + title, + description, +}: { icon: ReactNode title: string description?: string @@ -92,26 +102,17 @@ function ChannelInfo({ icon, title, description }: { <div className="flex min-w-0 flex-wrap items-center gap-2"> <span className="system-sm-medium text-text-primary">{title}</span> </div> - {description && ( - <div className="system-xs-regular text-text-tertiary">{description}</div> - )} + {description && <div className="system-xs-regular text-text-tertiary">{description}</div>} </div> </div> ) } -function ChannelRow({ info, children }: { - info: ReactNode - children: ReactNode -}) { +function ChannelRow({ info, children }: { info: ReactNode; children: ReactNode }) { return ( <div className="flex flex-col gap-3 border-t border-divider-subtle px-4 py-4 first:border-t-0 lg:flex-row lg:items-start"> - <div className="min-w-0 lg:w-70"> - {info} - </div> - <div className="min-w-0 flex-1"> - {children} - </div> + <div className="min-w-0 lg:w-70">{info}</div> + <div className="min-w-0 flex-1">{children}</div> </div> ) } @@ -126,128 +127,124 @@ export function AccessChannelsSection() { const webAppEndpoints: AccessEndpoint[] | undefined = accessSettings?.webAppEndpoints const cliEndpoint: AccessEndpoint | undefined = accessSettings?.cliEndpoint const runEnabled = accessChannels?.webAppEnabled ?? false - const webappRows = webAppEndpoints?.flatMap((endpoint) => { - const endpointUrl = endpoint.endpointUrl - if (!endpointUrl) - return [] + const webappRows = + webAppEndpoints?.flatMap((endpoint) => { + const endpointUrl = endpoint.endpointUrl + if (!endpointUrl) return [] - return [{ - endpoint, - endpointUrl, - }] - }) ?? [] + return [ + { + endpoint, + endpointUrl, + }, + ] + }) ?? [] const cliDomain = getUrlOrigin(cliEndpoint?.endpointUrl) const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined return ( <Section - title={t($ => $['access.channels.title'])} - action={( - isLoading - ? <SwitchSkeleton /> - : ( - <div className="flex items-center gap-2"> - <span className="system-xs-medium text-text-tertiary"> - {runEnabled ? t($ => $['overview.enabled']) : t($ => $['overview.disabled'])} - </span> - <AccessChannelsSwitch - checked={runEnabled} - accessChannels={accessChannels} - disabled={isError} + title={t(($) => $['access.channels.title'])} + action={ + isLoading ? ( + <SwitchSkeleton /> + ) : ( + <div className="flex items-center gap-2"> + <span className="system-xs-medium text-text-tertiary"> + {runEnabled ? t(($) => $['overview.enabled']) : t(($) => $['overview.disabled'])} + </span> + <AccessChannelsSwitch + checked={runEnabled} + accessChannels={accessChannels} + disabled={isError} + /> + </div> + ) + } + > + {isLoading ? ( + <AccessChannelsSkeleton /> + ) : isError || !appInstanceId ? ( + <DeploymentStateMessage variant="section"> + {t(($) => $['common.loadFailed'])} + </DeploymentStateMessage> + ) : runEnabled ? ( + <div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg"> + <ChannelRow + info={ + <ChannelInfo + icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />} + title={t(($) => $['access.runAccess.webapp'])} + description={t(($) => $['access.runAccess.webappDesc'])} + /> + } + > + {webappRows.length > 0 ? ( + <div className="flex flex-col gap-1.5"> + {webappRows.map(({ endpoint, endpointUrl }) => ( + <EndpointRow + key={`webapp-${endpoint.environment?.id ?? endpointUrl}`} + envName={endpoint.environment?.displayName ?? '—'} + label={t(($) => $['access.runAccess.urlLabel'])} + value={endpointUrl} + openLabel={t(($) => $['access.runAccess.openWebapp'])} + /> + ))} + </div> + ) : ( + <DeploymentNoticeState> + {t(($) => $['access.runAccess.webappEmpty'])} + </DeploymentNoticeState> + )} + </ChannelRow> + <ChannelRow + info={ + <ChannelInfo + icon={<span className="i-ri-terminal-box-line size-3.5" aria-hidden="true" />} + title={t(($) => $['access.cli.title'])} + description={t(($) => $['access.cli.description'])} + /> + } + > + {cliDomain ? ( + <div className="flex flex-wrap items-center gap-2"> + <CopyPill + label={t(($) => $['access.cli.domain'])} + value={cliDomain} + className="min-w-0 flex-1" /> + <a + href={cliDocsUrl} + target="_blank" + rel="noreferrer" + className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover" + > + <span className="i-ri-download-cloud-2-line size-3.5" /> + {t(($) => $['access.cli.install'])} + </a> + <a + href={cliDocsUrl} + target="_blank" + rel="noreferrer" + className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover" + > + <span className="i-ri-book-open-line size-3.5" /> + {t(($) => $['access.cli.docs'])} + </a> </div> - ) + ) : ( + <DeploymentNoticeState>{t(($) => $['access.cli.empty'])}</DeploymentNoticeState> + )} + </ChannelRow> + </div> + ) : ( + <DeploymentEmptyState + variant="section" + icon="i-ri-toggle-line" + title={t(($) => $['access.channels.disabled'])} + description={t(($) => $['access.channels.disabledHint'])} + /> )} - > - {isLoading - ? <AccessChannelsSkeleton /> - : isError || !appInstanceId - ? <DeploymentStateMessage variant="section">{t($ => $['common.loadFailed'])}</DeploymentStateMessage> - : runEnabled - ? ( - <div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg"> - <ChannelRow - info={( - <ChannelInfo - icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />} - title={t($ => $['access.runAccess.webapp'])} - description={t($ => $['access.runAccess.webappDesc'])} - /> - )} - > - {webappRows.length > 0 - ? ( - <div className="flex flex-col gap-1.5"> - {webappRows.map(({ endpoint, endpointUrl }) => ( - <EndpointRow - key={`webapp-${endpoint.environment?.id ?? endpointUrl}`} - envName={endpoint.environment?.displayName ?? '—'} - label={t($ => $['access.runAccess.urlLabel'])} - value={endpointUrl} - openLabel={t($ => $['access.runAccess.openWebapp'])} - /> - ))} - </div> - ) - : ( - <DeploymentNoticeState> - {t($ => $['access.runAccess.webappEmpty'])} - </DeploymentNoticeState> - )} - </ChannelRow> - <ChannelRow - info={( - <ChannelInfo - icon={<span className="i-ri-terminal-box-line size-3.5" aria-hidden="true" />} - title={t($ => $['access.cli.title'])} - description={t($ => $['access.cli.description'])} - /> - )} - > - {cliDomain - ? ( - <div className="flex flex-wrap items-center gap-2"> - <CopyPill - label={t($ => $['access.cli.domain'])} - value={cliDomain} - className="min-w-0 flex-1" - /> - <a - href={cliDocsUrl} - target="_blank" - rel="noreferrer" - className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover" - > - <span className="i-ri-download-cloud-2-line size-3.5" /> - {t($ => $['access.cli.install'])} - </a> - <a - href={cliDocsUrl} - target="_blank" - rel="noreferrer" - className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover" - > - <span className="i-ri-book-open-line size-3.5" /> - {t($ => $['access.cli.docs'])} - </a> - </div> - ) - : ( - <DeploymentNoticeState> - {t($ => $['access.cli.empty'])} - </DeploymentNoticeState> - )} - </ChannelRow> - </div> - ) - : ( - <DeploymentEmptyState - variant="section" - icon="i-ri-toggle-line" - title={t($ => $['access.channels.disabled'])} - description={t($ => $['access.channels.disabledHint'])} - /> - )} </Section> ) } diff --git a/web/features/deployments/detail/access/channels/url.ts b/web/features/deployments/detail/access/channels/url.ts index fe703043087e9f..6825b4bd53e6b5 100644 --- a/web/features/deployments/detail/access/channels/url.ts +++ b/web/features/deployments/detail/access/channels/url.ts @@ -1,10 +1,8 @@ export function getUrlOrigin(url?: string) { - if (!url) - return undefined + if (!url) return undefined try { return new URL(url).origin - } - catch { + } catch { return url } } diff --git a/web/features/deployments/detail/access/permissions/__tests__/access-policy.spec.ts b/web/features/deployments/detail/access/permissions/__tests__/access-policy.spec.ts index b9f69cd27352e5..0dc3b4de175624 100644 --- a/web/features/deployments/detail/access/permissions/__tests__/access-policy.spec.ts +++ b/web/features/deployments/detail/access/permissions/__tests__/access-policy.spec.ts @@ -1,7 +1,4 @@ -import type { - AccessPolicy, - Subject, -} from '@dify/contracts/enterprise/types.gen' +import type { AccessPolicy, Subject } from '@dify/contracts/enterprise/types.gen' import { AccessMode, AccessSubjectType } from '@dify/contracts/enterprise/types.gen' import { describe, expect, it } from 'vitest' import { AccessMode as AppAccessMode, SubjectType } from '@/models/access-control' @@ -55,27 +52,31 @@ describe('access policy mode mapping', () => { describe('access policy subject conversion', () => { it('should normalize resolved group and account subjects', () => { - expect(normalizeResolvedSubject({ - subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, - groupData: { - id: 'group-1', - name: 'Admins', - groupSize: 3, - }, - })).toEqual({ + expect( + normalizeResolvedSubject({ + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, + groupData: { + id: 'group-1', + name: 'Admins', + groupSize: 3, + }, + }), + ).toEqual({ id: 'group-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, name: 'Admins', memberCount: 3, }) - expect(normalizeResolvedSubject({ - subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, - accountData: { - id: 'account-1', - email: 'member@example.com', - }, - })).toEqual({ + expect( + normalizeResolvedSubject({ + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, + accountData: { + id: 'account-1', + email: 'member@example.com', + }, + }), + ).toEqual({ id: 'account-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, name: 'member@example.com', @@ -83,30 +84,34 @@ describe('access policy subject conversion', () => { }) it('should normalize resolved subjects that use app access-control subject types', () => { - expect(normalizeResolvedSubject({ - subjectId: 'group-1', - subjectType: SubjectType.GROUP, - groupData: { - id: 'group-1', - name: 'Admins', - groupSize: 3, - }, - })).toEqual({ + expect( + normalizeResolvedSubject({ + subjectId: 'group-1', + subjectType: SubjectType.GROUP, + groupData: { + id: 'group-1', + name: 'Admins', + groupSize: 3, + }, + }), + ).toEqual({ id: 'group-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, name: 'Admins', memberCount: 3, }) - expect(normalizeResolvedSubject({ - subjectId: 'account-1', - subjectType: SubjectType.ACCOUNT, - accountData: { - id: 'account-1', - name: 'Member', - email: 'member@example.com', - }, - })).toEqual({ + expect( + normalizeResolvedSubject({ + subjectId: 'account-1', + subjectType: SubjectType.ACCOUNT, + accountData: { + id: 'account-1', + name: 'Member', + email: 'member@example.com', + }, + }), + ).toEqual({ id: 'account-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, name: 'Member', @@ -114,25 +119,38 @@ describe('access policy subject conversion', () => { }) it('should ignore unsupported subjects and subjects without ids', () => { - expect(normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP })).toBeUndefined() - expect(normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT })).toBeUndefined() - expect(normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_UNSPECIFIED } as Subject)).toBeUndefined() + expect( + normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP }), + ).toBeUndefined() + expect( + normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT }), + ).toBeUndefined() + expect( + normalizeResolvedSubject({ + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_UNSPECIFIED, + } as Subject), + ).toBeUndefined() }) it('should preserve labels when reading selected subjects from policy', () => { - expect(selectedSubjectsFromPolicy(policy({ - subjects: [ - { subjectId: 'group-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP }, - { subjectId: 'account-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT }, - ], - }), [ - { - id: 'group-1', - subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, - name: 'Admins', - memberCount: 3, - }, - ])).toEqual([ + expect( + selectedSubjectsFromPolicy( + policy({ + subjects: [ + { subjectId: 'group-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP }, + { subjectId: 'account-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT }, + ], + }), + [ + { + id: 'group-1', + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, + name: 'Admins', + memberCount: 3, + }, + ], + ), + ).toEqual([ { id: 'group-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, diff --git a/web/features/deployments/detail/access/permissions/__tests__/permissions.spec.tsx b/web/features/deployments/detail/access/permissions/__tests__/permissions.spec.tsx index 577be5ce086a3c..33a1937b439fb1 100644 --- a/web/features/deployments/detail/access/permissions/__tests__/permissions.spec.tsx +++ b/web/features/deployments/detail/access/permissions/__tests__/permissions.spec.tsx @@ -1,4 +1,8 @@ -import type { AccessPolicy, Environment, EnvironmentAccessPolicy } from '@dify/contracts/enterprise/types.gen' +import type { + AccessPolicy, + Environment, + EnvironmentAccessPolicy, +} from '@dify/contracts/enterprise/types.gen' import type { ReactNode } from 'react' import { AccessMode, AccessSubjectType } from '@dify/contracts/enterprise/types.gen' import { fireEvent, render, screen } from '@testing-library/react' @@ -55,11 +59,7 @@ vi.mock('@/service/client', () => ({ })) function renderWithAtomStore(children: ReactNode) { - return render( - <JotaiProvider store={createStore()}> - {children} - </JotaiProvider>, - ) + return render(<JotaiProvider store={createStore()}>{children}</JotaiProvider>) } function createEnvironment(overrides: Partial<Environment> = {}): Environment { @@ -111,8 +111,7 @@ describe('EnvironmentPermissionRow', () => { beforeEach(() => { vi.clearAllMocks() mockUseAtomValue.mockImplementation((atom) => { - if (atom === deploymentRouteAppInstanceIdAtom) - return 'app-instance-1' + if (atom === deploymentRouteAppInstanceIdAtom) return 'app-instance-1' return undefined }) mockMutate.mockImplementation((_variables: unknown, options?: { onError?: () => void }) => { @@ -128,8 +127,12 @@ describe('EnvironmentPermissionRow', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ })) - fireEvent.click(screen.getByRole('radio', { name: 'app.accessControlDialog.accessItems.anyone' })) + fireEvent.click( + screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ }), + ) + fireEvent.click( + screen.getByRole('radio', { name: 'app.accessControlDialog.accessItems.anyone' }), + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockMutate).toHaveBeenCalled() @@ -148,8 +151,12 @@ describe('EnvironmentPermissionRow', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ })) - fireEvent.click(screen.getByRole('radio', { name: 'app.accessControlDialog.accessItems.anyone' })) + fireEvent.click( + screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ }), + ) + fireEvent.click( + screen.getByRole('radio', { name: 'app.accessControlDialog.accessItems.anyone' }), + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockMutate).toHaveBeenCalledWith( @@ -185,7 +192,9 @@ describe('EnvironmentPermissionRow', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ })) + fireEvent.click( + screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ }), + ) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockMutate).toHaveBeenCalledWith( @@ -225,7 +234,9 @@ describe('EnvironmentPermissionRow', () => { />, ) - const editButton = screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ }) + const editButton = screen.getByRole('button', { + name: /deployments\.access\.permissions\.editAriaLabel/, + }) expect(editButton).toHaveTextContent('deployments.access.permission.specific') expect(editButton).toHaveTextContent('deployments.access.members.groupCount:{"count":1}') @@ -238,17 +249,14 @@ describe('AccessPermissionsSection', () => { beforeEach(() => { vi.clearAllMocks() mockUseAtomValue.mockImplementation((atom) => { - if (atom === deploymentRouteAppInstanceIdAtom) - return 'app-instance-1' + if (atom === deploymentRouteAppInstanceIdAtom) return 'app-instance-1' if (atom === accessSettingsAtom) { return { environmentPolicies: [createEnvironmentAccessPolicy()], } } - if (atom === accessSettingsIsLoadingAtom) - return false - if (atom === accessSettingsIsErrorAtom) - return false + if (atom === accessSettingsIsLoadingAtom) return false + if (atom === accessSettingsIsErrorAtom) return false return undefined }) }) @@ -257,7 +265,11 @@ describe('AccessPermissionsSection', () => { renderWithAtomStore(<AccessPermissionsSection />) expect(screen.getByText('Production')).toBeInTheDocument() - expect(screen.queryByText('deployments.access.permissions.col.environment')).not.toBeInTheDocument() - expect(screen.queryByText('deployments.access.permissions.col.permission')).not.toBeInTheDocument() + expect( + screen.queryByText('deployments.access.permissions.col.environment'), + ).not.toBeInTheDocument() + expect( + screen.queryByText('deployments.access.permissions.col.permission'), + ).not.toBeInTheDocument() }) }) diff --git a/web/features/deployments/detail/access/permissions/access-control-dialog.tsx b/web/features/deployments/detail/access/permissions/access-control-dialog.tsx index 3444043e98d93b..70c48539e3f525 100644 --- a/web/features/deployments/detail/access/permissions/access-control-dialog.tsx +++ b/web/features/deployments/detail/access/permissions/access-control-dialog.tsx @@ -42,11 +42,11 @@ export function DeploymentAccessControlDialog({ }) { const draftKey = [ initialKind, - initialSubjects.map(subject => `${subject.subjectType}:${subject.id}`).join(','), + initialSubjects.map((subject) => `${subject.subjectType}:${subject.id}`).join(','), ].join(':') return ( - <Dialog open={open} disablePointerDismissal onOpenChange={open => !open && onClose()}> + <Dialog open={open} disablePointerDismissal onOpenChange={(open) => !open && onClose()}> <DialogContent className={cn( 'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-shadow', @@ -92,14 +92,11 @@ function DeploymentAccessControlDialogBody({ const confirmDisabled = saving || (specificSelected && specificEmpty) const handleConfirm = () => { - if (confirmDisabled) - return + if (confirmDisabled) return onSubmit( appAccessModeToPermissionKey(currentMenu), - specificSelected - ? subjectsFromAccessControlSelection(specificSelection) - : [], + specificSelected ? subjectsFromAccessControlSelection(specificSelection) : [], ) } @@ -107,10 +104,10 @@ function DeploymentAccessControlDialogBody({ <div className="flex flex-col gap-y-3"> <div className="pt-6 pr-14 pb-3 pl-6"> <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['access.permissions.editTitle'])} + {t(($) => $['access.permissions.editTitle'])} </DialogTitle> <DialogDescription className="mt-1 system-xs-regular text-text-tertiary"> - {t($ => $['access.permissions.editDescription'])} + {t(($) => $['access.permissions.editDescription'])} </DialogDescription> </div> <RadioGroup<AppAccessMode> @@ -122,7 +119,7 @@ function DeploymentAccessControlDialogBody({ > <div className="leading-6"> <p id="access-control-options-label" className="system-sm-medium text-text-tertiary"> - {t($ => $['accessControlDialog.accessLabel'], { ns: 'app' })} + {t(($) => $['accessControlDialog.accessLabel'], { ns: 'app' })} </p> </div> <AccessControlItem type={AppAccessMode.ORGANIZATION}> @@ -130,7 +127,7 @@ function DeploymentAccessControlDialogBody({ <div className="flex grow items-center gap-x-2"> <span className="i-ri-building-line size-4 text-text-primary" aria-hidden="true" /> <p className="system-sm-medium text-text-primary"> - {t($ => $['accessControlDialog.accessItems.organization'], { ns: 'app' })} + {t(($) => $['accessControlDialog.accessItems.organization'], { ns: 'app' })} </p> </div> </div> @@ -146,22 +143,32 @@ function DeploymentAccessControlDialogBody({ <div className="flex items-center gap-x-2 p-3"> <span className="i-ri-global-line size-4 text-text-primary" aria-hidden="true" /> <p className="system-sm-medium text-text-primary"> - {t($ => $['accessControlDialog.accessItems.anyone'], { ns: 'app' })} + {t(($) => $['accessControlDialog.accessItems.anyone'], { ns: 'app' })} </p> </div> </AccessControlItem> </RadioGroup> <div className="flex items-center justify-end gap-x-2 p-6 pt-5"> - <Button disabled={saving} onClick={onClose}>{t($ => $['operation.cancel'], { ns: 'common' })}</Button> - <Button disabled={confirmDisabled || saving} loading={saving} variant="primary" onClick={handleConfirm}> - {t($ => $['operation.confirm'], { ns: 'common' })} + <Button disabled={saving} onClick={onClose}> + {t(($) => $['operation.cancel'], { ns: 'common' })} + </Button> + <Button + disabled={confirmDisabled || saving} + loading={saving} + variant="primary" + onClick={handleConfirm} + > + {t(($) => $['operation.confirm'], { ns: 'common' })} </Button> </div> </div> ) } -function AccessControlItem({ type, children }: PropsWithChildren<{ +function AccessControlItem({ + type, + children, +}: PropsWithChildren<{ type: AppAccessMode }>) { return ( @@ -197,7 +204,9 @@ function SpecificGroupsOrMembersOption({ <div className="flex items-center p-3"> <div className="flex grow items-center gap-x-2"> <span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" /> - <p className="system-sm-medium text-text-primary">{t($ => $['accessControlDialog.accessItems.specific'], { ns: 'app' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $['accessControlDialog.accessItems.specific'], { ns: 'app' })} + </p> </div> </div> ) @@ -208,7 +217,9 @@ function SpecificGroupsOrMembersOption({ <div className="flex items-center gap-x-1 p-3"> <div className="flex grow items-center gap-x-1"> <span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" /> - <p className="system-sm-medium text-text-primary">{t($ => $['accessControlDialog.accessItems.specific'], { ns: 'app' })}</p> + <p className="system-sm-medium text-text-primary"> + {t(($) => $['accessControlDialog.accessItems.specific'], { ns: 'app' })} + </p> </div> <div className="flex items-center gap-x-1"> <AccessSubjectAddButton diff --git a/web/features/deployments/detail/access/permissions/access-policy.ts b/web/features/deployments/detail/access/permissions/access-policy.ts index 11bb183c75df57..a364efb02a79a0 100644 --- a/web/features/deployments/detail/access/permissions/access-policy.ts +++ b/web/features/deployments/detail/access/permissions/access-policy.ts @@ -6,14 +6,8 @@ import type { Subject, } from '@dify/contracts/enterprise/types.gen' import type { AccessSubjectSelectionValue } from './access-subject-selector/types' -import type { - AccessControlAccount, - AccessControlGroup, -} from '@/models/access-control' -import { - AccessMode, - AccessSubjectType, -} from '@dify/contracts/enterprise/types.gen' +import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' +import { AccessMode, AccessSubjectType } from '@dify/contracts/enterprise/types.gen' import { AccessMode as AppAccessMode, SubjectType as AppSubjectType } from '@/models/access-control' export type AccessPermissionKind = 'organization' | 'specific' | 'anyone' @@ -32,45 +26,40 @@ export type SelectableAccessSubject = { } export function accessModeToPermissionKey(mode?: AccessPolicy['mode']): AccessPermissionKind { - if (mode === AccessMode.ACCESS_MODE_PRIVATE) - return 'specific' - if (mode === AccessMode.ACCESS_MODE_PUBLIC) - return 'anyone' + if (mode === AccessMode.ACCESS_MODE_PRIVATE) return 'specific' + if (mode === AccessMode.ACCESS_MODE_PUBLIC) return 'anyone' return 'organization' } export function permissionKeyToAccessMode(key: AccessPermissionKind): AccessPolicyMode { - if (key === 'organization') - return AccessMode.ACCESS_MODE_PRIVATE_ALL - if (key === 'specific') - return AccessMode.ACCESS_MODE_PRIVATE + if (key === 'organization') return AccessMode.ACCESS_MODE_PRIVATE_ALL + if (key === 'specific') return AccessMode.ACCESS_MODE_PRIVATE return AccessMode.ACCESS_MODE_PUBLIC } export function permissionKeyToAppAccessMode(key: AccessPermissionKind): AppAccessMode { - if (key === 'organization') - return AppAccessMode.ORGANIZATION - if (key === 'specific') - return AppAccessMode.SPECIFIC_GROUPS_MEMBERS + if (key === 'organization') return AppAccessMode.ORGANIZATION + if (key === 'specific') return AppAccessMode.SPECIFIC_GROUPS_MEMBERS return AppAccessMode.PUBLIC } export function appAccessModeToPermissionKey(mode: AppAccessMode): AccessPermissionKind { - if (mode === AppAccessMode.SPECIFIC_GROUPS_MEMBERS) - return 'specific' - if (mode === AppAccessMode.PUBLIC) - return 'anyone' + if (mode === AppAccessMode.SPECIFIC_GROUPS_MEMBERS) return 'specific' + if (mode === AppAccessMode.PUBLIC) return 'anyone' return 'organization' } export function normalizeResolvedSubject(subject: Subject): SelectableAccessSubject | undefined { - const isGroupSubject = subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP || subject.subjectType === AppSubjectType.GROUP - const isAccountSubject = subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT || subject.subjectType === AppSubjectType.ACCOUNT + const isGroupSubject = + subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP || + subject.subjectType === AppSubjectType.GROUP + const isAccountSubject = + subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT || + subject.subjectType === AppSubjectType.ACCOUNT if (isGroupSubject) { const id = subject.subjectId || subject.groupData?.id - if (!id) - return undefined + if (!id) return undefined return { id, @@ -82,8 +71,7 @@ export function normalizeResolvedSubject(subject: Subject): SelectableAccessSubj if (isAccountSubject) { const id = subject.subjectId || subject.accountData?.id - if (!id) - return undefined + if (!id) return undefined return { id, @@ -100,28 +88,30 @@ function getSubjectLabel(subject: SelectableAccessSubject) { } export function policySubjects(subjects: SelectableAccessSubject[]): AccessSubject[] { - return subjects.map(subject => ({ + return subjects.map((subject) => ({ subjectId: subject.id, subjectType: subject.subjectType, })) } -export function selectedSubjectsFromPolicy(policy?: AccessPolicy, labelSubjects: SelectableAccessSubject[] = []) { - if (!policy) - return [] +export function selectedSubjectsFromPolicy( + policy?: AccessPolicy, + labelSubjects: SelectableAccessSubject[] = [], +) { + if (!policy) return [] - return policy.subjects - .map((subject): SelectableAccessSubject => { - const matchedSubject = labelSubjects.find(labelSubject => + return policy.subjects.map((subject): SelectableAccessSubject => { + const matchedSubject = labelSubjects.find( + (labelSubject) => labelSubject.id === subject.subjectId && labelSubject.subjectType === subject.subjectType, - ) - return { - id: subject.subjectId, - subjectType: subject.subjectType, - name: matchedSubject?.name, - memberCount: matchedSubject?.memberCount, - } - }) + ) + return { + id: subject.subjectId, + subjectType: subject.subjectType, + name: matchedSubject?.name, + memberCount: matchedSubject?.memberCount, + } + }) } function selectableSubjectToGroup(subject: SelectableAccessSubject): AccessControlGroup { @@ -144,29 +134,37 @@ function selectableSubjectToAccount(subject: SelectableAccessSubject): AccessCon } } -export function accessControlSelectionFromSubjects(subjects: SelectableAccessSubject[]): AccessSubjectSelectionValue { +export function accessControlSelectionFromSubjects( + subjects: SelectableAccessSubject[], +): AccessSubjectSelectionValue { return { groups: subjects - .filter(subject => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP) + .filter((subject) => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP) .map(selectableSubjectToGroup), members: subjects - .filter(subject => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT) + .filter((subject) => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT) .map(selectableSubjectToAccount), } } -export function subjectsFromAccessControlSelection(value: AccessSubjectSelectionValue): SelectableAccessSubject[] { +export function subjectsFromAccessControlSelection( + value: AccessSubjectSelectionValue, +): SelectableAccessSubject[] { return [ - ...value.groups.map((group): SelectableAccessSubject => ({ - id: group.id, - subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, - name: group.name, - memberCount: group.groupSize, - })), - ...value.members.map((member): SelectableAccessSubject => ({ - id: member.id, - subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, - name: member.name || member.email, - })), + ...value.groups.map( + (group): SelectableAccessSubject => ({ + id: group.id, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, + name: group.name, + memberCount: group.groupSize, + }), + ), + ...value.members.map( + (member): SelectableAccessSubject => ({ + id: member.id, + subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT, + name: member.name || member.email, + }), + ), ] } diff --git a/web/features/deployments/detail/access/permissions/access-subject-selector/__tests__/add-button.spec.tsx b/web/features/deployments/detail/access/permissions/access-subject-selector/__tests__/add-button.spec.tsx index 47254b9dc21295..2fc9f23a49df5f 100644 --- a/web/features/deployments/detail/access/permissions/access-subject-selector/__tests__/add-button.spec.tsx +++ b/web/features/deployments/detail/access/permissions/access-subject-selector/__tests__/add-button.spec.tsx @@ -47,18 +47,16 @@ describe('AccessSubjectAddButton', () => { it('should reset expanded group browsing when the add menu reopens', async () => { const user = userEvent.setup() - render( - <AccessSubjectAddButton - selectedGroups={[]} - selectedMembers={[]} - onChange={vi.fn()} - />, - ) + render(<AccessSubjectAddButton selectedGroups={[]} selectedMembers={[]} onChange={vi.fn()} />) const addButton = screen.getByRole('combobox', { name: 'common.operation.add' }) await user.click(addButton) - await user.click(await screen.findByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.expand' })) + await user.click( + await screen.findByRole('button', { + name: 'app.accessControlDialog.operateGroupAndMember.expand', + }), + ) await waitFor(() => { expect(lastSearchParams()).toMatchObject({ groupId: 'group-1' }) diff --git a/web/features/deployments/detail/access/permissions/access-subject-selector/add-button.tsx b/web/features/deployments/detail/access/permissions/access-subject-selector/add-button.tsx index aa49eeb0688db3..a202729947efef 100644 --- a/web/features/deployments/detail/access/permissions/access-subject-selector/add-button.tsx +++ b/web/features/deployments/detail/access/permissions/access-subject-selector/add-button.tsx @@ -47,13 +47,16 @@ export function AccessSubjectAddButton({ const debouncedKeyword = useDebounce(keyword, { wait: 500 }) const lastAvailableGroup = breadcrumbGroups[breadcrumbGroups.length - 1] - const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchAccessSubjects({ - keyword: debouncedKeyword, - groupId: lastAvailableGroup?.id, - resultsPerPage: 10, - }, open && !disabled) + const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchAccessSubjects( + { + keyword: debouncedKeyword, + groupId: lastAvailableGroup?.id, + resultsPerPage: 10, + }, + open && !disabled, + ) const pages = data?.pages ?? [] - const subjects = pages.flatMap(page => page.subjects ?? []) + const subjects = pages.flatMap((page) => page.subjects ?? []) const selectedSubjects = selectionValueToSubjects({ groups: selectedGroups, members: selectedMembers, @@ -65,10 +68,13 @@ export function AccessSubjectAddButton({ useEffect(() => { let observer: IntersectionObserver | undefined if (anchorRef.current) { - observer = new IntersectionObserver((entries) => { - if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && hasMore) - fetchNextPage() - }, { root: scrollRootRef.current, rootMargin: '20px' }) + observer = new IntersectionObserver( + (entries) => { + if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && hasMore) + fetchNextPage() + }, + { root: scrollRootRef.current, rootMargin: '20px' }, + ) observer.observe(anchorRef.current) } return () => observer?.disconnect() @@ -81,8 +87,7 @@ export function AccessSubjectAddButton({ } const handleOpenChange = (nextOpen: boolean) => { - if (nextOpen && disabled) - return + if (nextOpen && disabled) return if (!nextOpen) { closeMenu() return @@ -92,8 +97,7 @@ export function AccessSubjectAddButton({ } const handleInputValueChange = (inputValue: string, details: ComboboxChangeEventDetails) => { - if (!disabled && details.reason !== 'item-press') - setKeyword(inputValue) + if (!disabled && details.reason !== 'item-press') setKeyword(inputValue) } const handleValueChange = (nextSubjects: Subject[]) => { @@ -117,19 +121,18 @@ export function AccessSubjectAddButton({ onValueChange={handleValueChange} > <ComboboxTrigger - aria-label={t($ => $['operation.add'], { ns: 'common' })} + aria-label={t(($) => $['operation.add'], { ns: 'common' })} icon={false} size="small" disabled={disabled} onClick={() => { - if (open) - closeMenu() + if (open) closeMenu() }} className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover" > <span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap"> <span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" /> - <span className="shrink-0">{t($ => $['operation.add'], { ns: 'common' })}</span> + <span className="shrink-0">{t(($) => $['operation.add'], { ns: 'common' })}</span> </span> </ComboboxTrigger> <ComboboxContent @@ -140,55 +143,60 @@ export function AccessSubjectAddButton({ <div ref={scrollRootRef} className="min-h-0 overflow-y-auto"> <div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]"> <ComboboxInputGroup className="h-8 min-h-8 px-2"> - <span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" /> + <span + className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" + aria-hidden="true" + /> <ComboboxInput - aria-label={t($ => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'], { ns: 'app' })} - placeholder={t($ => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'], { ns: 'app' })} + aria-label={t( + ($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'], + { ns: 'app' }, + )} + placeholder={t( + ($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'], + { ns: 'app' }, + )} className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary" /> </ComboboxInputGroup> </div> - {isLoading - ? ( - <ComboboxStatus className="p-1"> - <SubjectOptionsSkeleton /> - </ComboboxStatus> - ) - : ( + {isLoading ? ( + <ComboboxStatus className="p-1"> + <SubjectOptionsSkeleton /> + </ComboboxStatus> + ) : ( + <> + {shouldShowBreadcrumb && ( + <div className="flex h-7 items-center px-2 py-0.5"> + <SelectedGroupsBreadCrumb + selectedGroupsForBreadcrumb={breadcrumbGroups} + onChange={setBreadcrumbGroups} + /> + </div> + )} + {hasResults ? ( <> - {shouldShowBreadcrumb && ( - <div className="flex h-7 items-center px-2 py-0.5"> - <SelectedGroupsBreadCrumb - selectedGroupsForBreadcrumb={breadcrumbGroups} - onChange={setBreadcrumbGroups} + <ComboboxList className="max-h-none p-1"> + {(subject: Subject) => ( + <SubjectItem + key={getSubjectValue(subject)} + subject={subject} + selectedGroups={selectedGroups} + selectedMembers={selectedMembers} + onExpandGroup={(group) => setBreadcrumbGroups([...breadcrumbGroups, group])} /> - </div> - )} - {hasResults - ? ( - <> - <ComboboxList className="max-h-none p-1"> - {(subject: Subject) => ( - <SubjectItem - key={getSubjectValue(subject)} - subject={subject} - selectedGroups={selectedGroups} - selectedMembers={selectedMembers} - onExpandGroup={group => setBreadcrumbGroups([...breadcrumbGroups, group])} - /> - )} - </ComboboxList> - {isFetchingNextPage && <Loading />} - <div ref={anchorRef} className="h-0" /> - </> - ) - : ( - <ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5"> - {t($ => $['accessControlDialog.operateGroupAndMember.noResult'], { ns: 'app' })} - </ComboboxEmpty> - )} + )} + </ComboboxList> + {isFetchingNextPage && <Loading />} + <div ref={anchorRef} className="h-0" /> </> + ) : ( + <ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5"> + {t(($) => $['accessControlDialog.operateGroupAndMember.noResult'], { ns: 'app' })} + </ComboboxEmpty> )} + </> + )} </div> </ComboboxContent> </Combobox> @@ -198,7 +206,7 @@ export function AccessSubjectAddButton({ function SubjectOptionsSkeleton() { return ( <div className="flex flex-col gap-1"> - {[0, 1, 2, 3, 4].map(index => ( + {[0, 1, 2, 3, 4].map((index) => ( <div key={index} className="flex min-h-8 items-center gap-2 rounded-lg p-1 pl-2"> <SkeletonRectangle className="my-0 size-4 shrink-0 animate-pulse rounded-sm" /> <SkeletonRectangle className="my-0 size-5 shrink-0 animate-pulse rounded-full" /> diff --git a/web/features/deployments/detail/access/permissions/access-subject-selector/selection-list.tsx b/web/features/deployments/detail/access/permissions/access-subject-selector/selection-list.tsx index 62a8e58063c3e5..ce29846a79841f 100644 --- a/web/features/deployments/detail/access/permissions/access-subject-selector/selection-list.tsx +++ b/web/features/deployments/detail/access/permissions/access-subject-selector/selection-list.tsx @@ -2,10 +2,7 @@ import type { ReactNode } from 'react' import type { AccessSubjectSelectionProps } from './types' -import type { - AccessControlAccount, - AccessControlGroup, -} from '@/models/access-control' +import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' @@ -24,16 +21,21 @@ export function AccessSubjectSelectionList({ className, }: AccessSubjectSelectionListProps) { return ( - <div className={cn('flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2', className)}> - {loading - ? <AccessSubjectSelectionListSkeleton /> - : ( - <RenderGroupsAndMembers - selectedGroups={selectedGroups} - selectedMembers={selectedMembers} - onChange={onChange} - /> - )} + <div + className={cn( + 'flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2', + className, + )} + > + {loading ? ( + <AccessSubjectSelectionListSkeleton /> + ) : ( + <RenderGroupsAndMembers + selectedGroups={selectedGroups} + selectedMembers={selectedMembers} + onChange={onChange} + /> + )} </div> ) } @@ -42,16 +44,21 @@ function AccessSubjectSelectionListSkeleton() { const { t } = useTranslation() return ( - <div role="status" aria-busy="true" aria-label={t($ => $.loading, { ns: 'common' })} className="flex flex-col gap-y-2"> + <div + role="status" + aria-busy="true" + aria-label={t(($) => $.loading, { ns: 'common' })} + className="flex flex-col gap-y-2" + > <SkeletonRectangle className="my-0 h-3 w-14 animate-pulse" /> <div className="flex flex-row flex-wrap gap-1"> - {[0, 1].map(index => ( + {[0, 1].map((index) => ( <SelectedItemSkeleton key={index} withMeta /> ))} </div> <SkeletonRectangle className="my-0 h-3 w-16 animate-pulse" /> <div className="flex flex-row flex-wrap gap-1"> - {[0, 1, 2].map(index => ( + {[0, 1, 2].map((index) => ( <SelectedItemSkeleton key={index} /> ))} </div> @@ -59,9 +66,7 @@ function AccessSubjectSelectionListSkeleton() { ) } -function SelectedItemSkeleton({ withMeta = false }: { - withMeta?: boolean -}) { +function SelectedItemSkeleton({ withMeta = false }: { withMeta?: boolean }) { return ( <div className="flex items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs"> <SkeletonRectangle className="my-0 size-5 animate-pulse rounded-full" /> @@ -82,7 +87,7 @@ function RenderGroupsAndMembers({ return ( <div className="px-2 pt-5 pb-1.5"> <p className="text-center system-xs-regular text-text-tertiary"> - {t($ => $['accessControlDialog.noGroupsOrMembers'], { ns: 'app' })} + {t(($) => $['accessControlDialog.noGroupsOrMembers'], { ns: 'app' })} </p> </div> ) @@ -91,10 +96,13 @@ function RenderGroupsAndMembers({ return ( <> <p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['accessControlDialog.groups'], { ns: 'app', count: selectedGroups.length ?? 0 })} + {t(($) => $['accessControlDialog.groups'], { + ns: 'app', + count: selectedGroups.length ?? 0, + })} </p> <div className="flex flex-row flex-wrap gap-1"> - {selectedGroups.map(group => ( + {selectedGroups.map((group) => ( <SelectedGroupItem key={group.id} group={group} @@ -105,10 +113,13 @@ function RenderGroupsAndMembers({ ))} </div> <p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['accessControlDialog.members'], { ns: 'app', count: selectedMembers.length ?? 0 })} + {t(($) => $['accessControlDialog.members'], { + ns: 'app', + count: selectedMembers.length ?? 0, + })} </p> <div className="flex flex-row flex-wrap gap-1"> - {selectedMembers.map(member => ( + {selectedMembers.map((member) => ( <SelectedMemberItem key={member.id} member={member} @@ -134,14 +145,19 @@ function SelectedGroupItem({ }: SelectedGroupItemProps) { const handleRemoveGroup = () => { onChange({ - groups: selectedGroups.filter(selectedGroup => selectedGroup.id !== group.id), + groups: selectedGroups.filter((selectedGroup) => selectedGroup.id !== group.id), members: selectedMembers, }) } return ( <SelectedBaseItem - icon={<span className="i-ri-organization-chart h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />} + icon={ + <span + className="i-ri-organization-chart h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" + aria-hidden="true" + /> + } onRemove={handleRemoveGroup} > <p className="system-xs-regular text-text-primary">{group.name}</p> @@ -163,7 +179,7 @@ function SelectedMemberItem({ const handleRemoveMember = () => { onChange({ groups: selectedGroups, - members: selectedMembers.filter(selectedMember => selectedMember.id !== member.id), + members: selectedMembers.filter((selectedMember) => selectedMember.id !== member.id), }) } @@ -197,10 +213,13 @@ function SelectedBaseItem({ icon, onRemove, children }: SelectedBaseItemProps) { <button type="button" className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - aria-label={t($ => $['operation.remove'], { ns: 'common' })} + aria-label={t(($) => $['operation.remove'], { ns: 'common' })} onClick={onRemove} > - <span className="i-ri-close-circle-fill h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" /> + <span + className="i-ri-close-circle-fill h-[14px] w-[14px] text-text-quaternary" + aria-hidden="true" + /> </button> </div> ) diff --git a/web/features/deployments/detail/access/permissions/access-subject-selector/subject-options.tsx b/web/features/deployments/detail/access/permissions/access-subject-selector/subject-options.tsx index d2529ef0091791..0678bb2b2ca898 100644 --- a/web/features/deployments/detail/access/permissions/access-subject-selector/subject-options.tsx +++ b/web/features/deployments/detail/access/permissions/access-subject-selector/subject-options.tsx @@ -11,10 +11,7 @@ import type { import { Avatar } from '@langgenius/dify-ui/avatar' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - ComboboxItem, - ComboboxItemText, -} from '@langgenius/dify-ui/combobox' +import { ComboboxItem, ComboboxItemText } from '@langgenius/dify-ui/combobox' import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { userProfileAtom } from '@/context/account-state' @@ -71,36 +68,39 @@ export function SelectedGroupsBreadCrumb({ return ( <div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5"> - {hasBreadcrumb - ? ( - <button - type="button" - className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={handleReset} - > - {t($ => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })} - </button> - ) - : ( - <span className="system-xs-regular text-text-tertiary">{t($ => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })}</span> - )} + {hasBreadcrumb ? ( + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={handleReset} + > + {t(($) => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })} + </button> + ) : ( + <span className="system-xs-regular text-text-tertiary"> + {t(($) => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })} + </span> + )} {selectedGroupsForBreadcrumb.map((group, index) => { const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1 return ( - <div key={group.id} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary"> + <div + key={group.id} + className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary" + > <span>/</span> - {isLastGroup - ? <span>{group.name}</span> - : ( - <button - type="button" - className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" - onClick={() => handleBreadCrumbClick(index)} - > - {group.name} - </button> - )} + {isLastGroup ? ( + <span>{group.name}</span> + ) : ( + <button + type="button" + className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden" + onClick={() => handleBreadCrumbClick(index)} + > + {group.name} + </button> + )} </div> ) })} @@ -117,7 +117,7 @@ type GroupItemProps = { function GroupItem({ group, subject, selectedGroups, onExpandGroup }: GroupItemProps) { const { t } = useTranslation() - const isChecked = selectedGroups.some(selectedGroup => selectedGroup.id === group.id) + const isChecked = selectedGroups.some((selectedGroup) => selectedGroup.id === group.id) return ( <div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover"> @@ -126,7 +126,10 @@ function GroupItem({ group, subject, selectedGroups, onExpandGroup }: GroupItemP <ComboboxItemText className="flex grow items-center px-0"> <div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid"> <div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]"> - <span className="i-ri-organization-chart h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" /> + <span + className="i-ri-organization-chart h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" + aria-hidden="true" + /> </div> </div> <span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span> @@ -138,10 +141,12 @@ function GroupItem({ group, subject, selectedGroups, onExpandGroup }: GroupItemP disabled={isChecked} variant="ghost-accent" className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1" - onPointerDown={event => event.preventDefault()} + onPointerDown={(event) => event.preventDefault()} onClick={() => onExpandGroup(group)} > - <span className="px-[3px]">{t($ => $['accessControlDialog.operateGroupAndMember.expand'], { ns: 'app' })}</span> + <span className="px-[3px]"> + {t(($) => $['accessControlDialog.operateGroupAndMember.expand'], { ns: 'app' })} + </span> <span className="i-ri-arrow-right-s-line size-4" aria-hidden="true" /> </Button> </div> @@ -157,7 +162,7 @@ type MemberItemProps = { function MemberItem({ member, subject, selectedMembers }: MemberItemProps) { const currentUser = useAtomValue(userProfileAtom) const { t } = useTranslation() - const isChecked = selectedMembers.some(selectedMember => selectedMember.id === member.id) + const isChecked = selectedMembers.some((selectedMember) => selectedMember.id === member.id) return ( <ComboboxBaseItem subject={subject} className="pr-3"> <SelectionBox checked={isChecked} /> @@ -170,9 +175,7 @@ function MemberItem({ member, subject, selectedMembers }: MemberItemProps) { <span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span> {currentUser.email === member.email && ( <span className="system-xs-regular text-text-tertiary"> - ( - {t($ => $.you, { ns: 'common' })} - ) + ({t(($) => $.you, { ns: 'common' })}) </span> )} </ComboboxItemText> diff --git a/web/features/deployments/detail/access/permissions/access-subject-selector/types.ts b/web/features/deployments/detail/access/permissions/access-subject-selector/types.ts index 649b2c22df163f..c0e680144e5755 100644 --- a/web/features/deployments/detail/access/permissions/access-subject-selector/types.ts +++ b/web/features/deployments/detail/access/permissions/access-subject-selector/types.ts @@ -1,7 +1,4 @@ -import type { - AccessControlAccount, - AccessControlGroup, -} from '@/models/access-control' +import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' export type AccessSubjectSelectionValue = { groups: AccessControlGroup[] diff --git a/web/features/deployments/detail/access/permissions/access-subject-selector/utils.ts b/web/features/deployments/detail/access/permissions/access-subject-selector/utils.ts index 70917bf02cfaab..be0f46ed05366e 100644 --- a/web/features/deployments/detail/access/permissions/access-subject-selector/utils.ts +++ b/web/features/deployments/detail/access/permissions/access-subject-selector/utils.ts @@ -25,8 +25,7 @@ function memberToSubject(member: AccessControlAccount): SubjectAccount { } export function getSubjectLabel(subject: Subject) { - if (subject.subjectType === SubjectType.GROUP) - return (subject as SubjectGroup).groupData.name + if (subject.subjectType === SubjectType.GROUP) return (subject as SubjectGroup).groupData.name return (subject as SubjectAccount).accountData.name } @@ -39,14 +38,8 @@ export function isSameSubject(item: Subject, value: Subject) { return item.subjectId === value.subjectId && item.subjectType === value.subjectType } -export function selectionValueToSubjects({ - groups, - members, -}: AccessSubjectSelectionValue) { - return [ - ...groups.map(groupToSubject), - ...members.map(memberToSubject), - ] +export function selectionValueToSubjects({ groups, members }: AccessSubjectSelectionValue) { + return [...groups.map(groupToSubject), ...members.map(memberToSubject)] } export function subjectsToSelectionValue(subjects: Subject[]): AccessSubjectSelectionValue { @@ -54,10 +47,8 @@ export function subjectsToSelectionValue(subjects: Subject[]): AccessSubjectSele const members: AccessControlAccount[] = [] subjects.forEach((subject) => { - if (subject.subjectType === SubjectType.GROUP) - groups.push((subject as SubjectGroup).groupData) - else - members.push((subject as SubjectAccount).accountData) + if (subject.subjectType === SubjectType.GROUP) groups.push((subject as SubjectGroup).groupData) + else members.push((subject as SubjectAccount).accountData) }) return { groups, members } diff --git a/web/features/deployments/detail/access/permissions/environment-permission-row.tsx b/web/features/deployments/detail/access/permissions/environment-permission-row.tsx index ceb19ebc096f4d..7e1747a9276dfd 100644 --- a/web/features/deployments/detail/access/permissions/environment-permission-row.tsx +++ b/web/features/deployments/detail/access/permissions/environment-permission-row.tsx @@ -1,14 +1,7 @@ 'use client' -import type { - AccessPolicy, - Environment, - Subject, -} from '@dify/contracts/enterprise/types.gen' -import type { - AccessPermissionKind, - SelectableAccessSubject, -} from './access-policy' +import type { AccessPolicy, Environment, Subject } from '@dify/contracts/enterprise/types.gen' +import type { AccessPermissionKind, SelectableAccessSubject } from './access-policy' import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' import { useAtomValue } from 'jotai' @@ -48,25 +41,27 @@ export function EnvironmentPermissionRow({ const { t } = useTranslation('deployments') const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) const environmentId = environment.id - const setEnvironmentAccessPolicy = useMutation(consoleQuery.enterprise.accessService.updateAccessPolicy.mutationOptions()) + const setEnvironmentAccessPolicy = useMutation( + consoleQuery.enterprise.accessService.updateAccessPolicy.mutationOptions(), + ) const policy = summaryPolicy const policyKind = accessModeToPermissionKey(policy?.mode) const policyFingerprint = policy - ? `${policy.mode}:${policy.subjects.map(subject => `${subject.subjectType}:${subject.subjectId}`).join(',')}` + ? `${policy.mode}:${policy.subjects.map((subject) => `${subject.subjectType}:${subject.subjectId}`).join(',')}` : 'no-policy' const [draft, setDraft] = useState<AccessPermissionDraft>() const [dialogOpen, setDialogOpen] = useState(false) const subjectLabelCandidates = [ ...(draft?.subjects ?? []), - ...resolvedSubjects - .flatMap((subject) => { - const normalizedSubject = normalizeResolvedSubject(subject) - return normalizedSubject ? [normalizedSubject] : [] - }), + ...resolvedSubjects.flatMap((subject) => { + const normalizedSubject = normalizeResolvedSubject(subject) + return normalizedSubject ? [normalizedSubject] : [] + }), ] const hasDraft = draft?.fingerprint === policyFingerprint const permissionKind = hasDraft && draft ? draft.kind : policyKind - const policySelectedSubjects = policyKind === 'specific' ? selectedSubjectsFromPolicy(policy, subjectLabelCandidates) : [] + const policySelectedSubjects = + policyKind === 'specific' ? selectedSubjectsFromPolicy(policy, subjectLabelCandidates) : [] const subjects = hasDraft && draft ? draft.subjects : policySelectedSubjects const isSaving = setEnvironmentAccessPolicy.isPending const controlsDisabled = disabled || isSaving || !appInstanceId @@ -79,11 +74,9 @@ export function EnvironmentPermissionRow({ onSuccess?: () => void }, ) => { - if (!appInstanceId) - return false + if (!appInstanceId) return false - if (nextKind === 'specific' && nextSubjects.length === 0) - return false + if (nextKind === 'specific' && nextSubjects.length === 0) return false setEnvironmentAccessPolicy.mutate( { @@ -101,7 +94,7 @@ export function EnvironmentPermissionRow({ { onSuccess: options?.onSuccess, onError: () => { - toast.error(t($ => $['access.permission.updateFailed'])) + toast.error(t(($) => $['access.permission.updateFailed'])) }, }, ) @@ -127,9 +120,7 @@ export function EnvironmentPermissionRow({ return ( <div className="flex min-w-0 flex-col gap-2 border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0"> <div className="flex min-w-0 items-center"> - <span className="min-w-0 truncate system-sm-regular text-text-primary"> - {envName} - </span> + <span className="min-w-0 truncate system-sm-regular text-text-primary">{envName}</span> </div> <PermissionSummaryButton value={permissionKind} diff --git a/web/features/deployments/detail/access/permissions/permission-summary-button.tsx b/web/features/deployments/detail/access/permissions/permission-summary-button.tsx index 11b00c3ea63d2c..9ae869afbdb15e 100644 --- a/web/features/deployments/detail/access/permissions/permission-summary-button.tsx +++ b/web/features/deployments/detail/access/permissions/permission-summary-button.tsx @@ -1,15 +1,10 @@ 'use client' -import type { - AccessPermissionKind, - SelectableAccessSubject, -} from './access-policy' +import type { AccessPermissionKind, SelectableAccessSubject } from './access-policy' import { AccessSubjectType } from '@dify/contracts/enterprise/types.gen' import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' -import { - permissionIcon, -} from './access-policy' +import { permissionIcon } from './access-policy' export function PermissionSummaryButton({ value, @@ -27,24 +22,32 @@ export function PermissionSummaryButton({ onClick: () => void }) { const { t } = useTranslation('deployments') - const groupCount = subjects?.filter(subject => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP).length ?? 0 + const groupCount = + subjects?.filter( + (subject) => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP, + ).length ?? 0 const memberCount = (subjects?.length ?? 0) - groupCount const countLabels = [ - ...(groupCount > 0 ? [t($ => $['access.members.groupCount'], { count: groupCount })] : []), - ...(memberCount > 0 ? [t($ => $['access.members.memberCount'], { count: memberCount })] : []), + ...(groupCount > 0 ? [t(($) => $['access.members.groupCount'], { count: groupCount })] : []), + ...(memberCount > 0 ? [t(($) => $['access.members.memberCount'], { count: memberCount })] : []), ] - const specificSubjectLabel = value === 'specific' - ? subjects && subjects.length > 0 - ? countLabels.join(' · ') - : t($ => $['access.permission.specificDesc']) - : undefined - const IconClassName = loading ? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none' : permissionIcon[value] + const specificSubjectLabel = + value === 'specific' + ? subjects && subjects.length > 0 + ? countLabels.join(' · ') + : t(($) => $['access.permission.specificDesc']) + : undefined + const IconClassName = loading + ? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none' + : permissionIcon[value] return ( <button type="button" disabled={disabled} - aria-label={t($ => $['access.permissions.editAriaLabel'], { environment: environmentLabel })} + aria-label={t(($) => $['access.permissions.editAriaLabel'], { + environment: environmentLabel, + })} onClick={onClick} className={cn( 'flex h-9 w-full min-w-0 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pr-2 pl-2.5 outline-hidden hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-active', @@ -57,13 +60,11 @@ export function PermissionSummaryButton({ aria-hidden="true" /> <p className="min-w-0 truncate text-left system-sm-medium text-text-secondary"> - {t($ => $[`access.permission.${value}`])} + {t(($) => $[`access.permission.${value}`])} </p> </div> {specificSubjectLabel && ( - <p className="shrink-0 system-xs-regular text-text-tertiary"> - {specificSubjectLabel} - </p> + <p className="shrink-0 system-xs-regular text-text-tertiary">{specificSubjectLabel}</p> )} <div className="flex size-4 shrink-0 items-center justify-center"> <span className="i-ri-arrow-right-s-line size-4 text-text-quaternary" aria-hidden="true" /> diff --git a/web/features/deployments/detail/access/permissions/section.tsx b/web/features/deployments/detail/access/permissions/section.tsx index 84e9c8c9a92811..40b89649958fb6 100644 --- a/web/features/deployments/detail/access/permissions/section.tsx +++ b/web/features/deployments/detail/access/permissions/section.tsx @@ -5,7 +5,10 @@ import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { SkeletonRectangle } from '@/app/components/base/skeleton' import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' -import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/components/empty-state' +import { + DeploymentEmptyState, + DeploymentStateMessage, +} from '../../../shared/components/empty-state' import { Section } from '../../../shared/components/section' import { accessSettingsAtom, @@ -19,8 +22,11 @@ const ACCESS_PERMISSIONS_SKELETON_KEYS = ['production', 'staging', 'development' function AccessPermissionsSkeleton() { return ( <div className="flex min-w-0 flex-col"> - {ACCESS_PERMISSIONS_SKELETON_KEYS.map(key => ( - <div key={key} className="flex min-w-0 flex-col gap-2 border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0"> + {ACCESS_PERMISSIONS_SKELETON_KEYS.map((key) => ( + <div + key={key} + className="flex min-w-0 flex-col gap-2 border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0" + > <SkeletonRectangle className="h-4 w-32 animate-pulse" /> <SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" /> </div> @@ -35,42 +41,40 @@ export function AccessPermissionsSection() { const accessSettings = useAtomValue(accessSettingsAtom) const isLoading = useAtomValue(accessSettingsIsLoadingAtom) const isError = useAtomValue(accessSettingsIsErrorAtom) - const environmentPolicies: EnvironmentAccessPolicy[] | undefined = accessSettings?.environmentPolicies + const environmentPolicies: EnvironmentAccessPolicy[] | undefined = + accessSettings?.environmentPolicies const policyRows = environmentPolicies ?? [] return ( - <Section - title={t($ => $['access.permissions.title'])} - showDivider={false} - > - {isLoading - ? <AccessPermissionsSkeleton /> - : isError || !appInstanceId - ? <DeploymentStateMessage variant="section">{t($ => $['common.loadFailed'])}</DeploymentStateMessage> - : policyRows.length === 0 - ? ( - <DeploymentEmptyState - variant="section" - icon="i-ri-rocket-line" - title={t($ => $['access.runAccess.noEnvsTitle'])} - description={t($ => $['access.runAccess.noEnvs'])} - /> - ) - : ( - <div className="flex min-w-0 flex-col"> - {policyRows.map((environmentPolicy) => { - const environment = environmentPolicy.environment - return ( - <EnvironmentPermissionRow - key={environment.id} - environment={environment} - summaryPolicy={environmentPolicy.policy} - resolvedSubjects={environmentPolicy.resolvedSubjects} - /> - ) - })} - </div> - )} + <Section title={t(($) => $['access.permissions.title'])} showDivider={false}> + {isLoading ? ( + <AccessPermissionsSkeleton /> + ) : isError || !appInstanceId ? ( + <DeploymentStateMessage variant="section"> + {t(($) => $['common.loadFailed'])} + </DeploymentStateMessage> + ) : policyRows.length === 0 ? ( + <DeploymentEmptyState + variant="section" + icon="i-ri-rocket-line" + title={t(($) => $['access.runAccess.noEnvsTitle'])} + description={t(($) => $['access.runAccess.noEnvs'])} + /> + ) : ( + <div className="flex min-w-0 flex-col"> + {policyRows.map((environmentPolicy) => { + const environment = environmentPolicy.environment + return ( + <EnvironmentPermissionRow + key={environment.id} + environment={environment} + summaryPolicy={environmentPolicy.policy} + resolvedSubjects={environmentPolicy.resolvedSubjects} + /> + ) + })} + </div> + )} </Section> ) } diff --git a/web/features/deployments/detail/access/state.ts b/web/features/deployments/detail/access/state.ts index fea3e11ccf7072..b83d2b059c96ed 100644 --- a/web/features/deployments/detail/access/state.ts +++ b/web/features/deployments/detail/access/state.ts @@ -19,6 +19,12 @@ export const accessSettingsQueryAtom = atomWithQuery((get) => { }) }) -export const accessSettingsAtom = selectAtom(accessSettingsQueryAtom, query => query.data) -export const accessSettingsIsLoadingAtom = selectAtom(accessSettingsQueryAtom, query => query.isLoading) -export const accessSettingsIsErrorAtom = selectAtom(accessSettingsQueryAtom, query => query.isError) +export const accessSettingsAtom = selectAtom(accessSettingsQueryAtom, (query) => query.data) +export const accessSettingsIsLoadingAtom = selectAtom( + accessSettingsQueryAtom, + (query) => query.isLoading, +) +export const accessSettingsIsErrorAtom = selectAtom( + accessSettingsQueryAtom, + (query) => query.isError, +) diff --git a/web/features/deployments/detail/api-tokens/__tests__/state.spec.ts b/web/features/deployments/detail/api-tokens/__tests__/state.spec.ts index fe2d8044d47418..36412b98fe0656 100644 --- a/web/features/deployments/detail/api-tokens/__tests__/state.spec.ts +++ b/web/features/deployments/detail/api-tokens/__tests__/state.spec.ts @@ -11,14 +11,15 @@ type QueryOptions = { } vi.mock('jotai-tanstack-query', () => ({ - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ - ...createOptions(get), - data: undefined, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: false, - })), + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => + atom((get) => ({ + ...createOptions(get), + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + })), })) vi.mock('@/service/client', () => ({ @@ -40,7 +41,10 @@ async function loadState() { return await import('../state') } -function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') { +function setDeploymentRoute( + store: ReturnType<typeof createStore>, + appInstanceId = 'app-instance-1', +) { store.set(setNextRouteStateAtom, { pathname: `/deployments/${appInstanceId}/api-tokens`, params: { appInstanceId }, diff --git a/web/features/deployments/detail/api-tokens/api-keys/__tests__/api-key-generate-menu.spec.tsx b/web/features/deployments/detail/api-tokens/api-keys/__tests__/api-key-generate-menu.spec.tsx index 900836db3cdc3f..db1e9c2d6849ba 100644 --- a/web/features/deployments/detail/api-tokens/api-keys/__tests__/api-key-generate-menu.spec.tsx +++ b/web/features/deployments/detail/api-tokens/api-keys/__tests__/api-key-generate-menu.spec.tsx @@ -45,19 +45,13 @@ describe('ApiKeyGenerateMenu', () => { beforeEach(() => { vi.clearAllMocks() mockUseAtomValue.mockImplementation((atom) => { - if (atom === deploymentRouteAppInstanceIdAtom) - return 'app-instance-1' + if (atom === deploymentRouteAppInstanceIdAtom) return 'app-instance-1' return undefined }) }) it('should show the required name error when submitting an empty name', () => { - render( - <ApiKeyGenerateMenu - environments={[createEnvironment()]} - onCreatedToken={vi.fn()} - />, - ) + render(<ApiKeyGenerateMenu environments={[createEnvironment()]} onCreatedToken={vi.fn()} />) fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.newKey' })) fireEvent.change(screen.getByLabelText('deployments.access.api.nameLabel'), { @@ -70,12 +64,7 @@ describe('ApiKeyGenerateMenu', () => { }) it('should clear the required name error when typing a valid name', () => { - render( - <ApiKeyGenerateMenu - environments={[createEnvironment()]} - onCreatedToken={vi.fn()} - />, - ) + render(<ApiKeyGenerateMenu environments={[createEnvironment()]} onCreatedToken={vi.fn()} />) fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.newKey' })) const nameInput = screen.getByLabelText('deployments.access.api.nameLabel') @@ -96,12 +85,7 @@ describe('ApiKeyGenerateMenu', () => { it('should disable the trigger when route app instance is missing', () => { mockUseAtomValue.mockReturnValue(undefined) - render( - <ApiKeyGenerateMenu - environments={[createEnvironment()]} - onCreatedToken={vi.fn()} - />, - ) + render(<ApiKeyGenerateMenu environments={[createEnvironment()]} onCreatedToken={vi.fn()} />) expect(screen.getByRole('button', { name: 'deployments.access.api.newKey' })).toBeDisabled() }) diff --git a/web/features/deployments/detail/api-tokens/api-keys/api-key-generate-menu.tsx b/web/features/deployments/detail/api-tokens/api-keys/api-key-generate-menu.tsx index 452993576fd4c7..43f11ca307adb2 100644 --- a/web/features/deployments/detail/api-tokens/api-keys/api-key-generate-menu.tsx +++ b/web/features/deployments/detail/api-tokens/api-keys/api-key-generate-menu.tsx @@ -52,23 +52,23 @@ export function ApiKeyGenerateMenu({ const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string>() const [draftName, setDraftName] = useState('') const [nameError, setNameError] = useState(false) - const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions()) + const generateApiKey = useMutation( + consoleQuery.enterprise.accessService.createApiKey.mutationOptions(), + ) const selectableEnvironments = environments const selectedEnvironment = selectedEnvironmentId - ? selectableEnvironments.find(env => env.id === selectedEnvironmentId) + ? selectableEnvironments.find((env) => env.id === selectedEnvironmentId) : undefined const disabled = !appInstanceId || selectableEnvironments.length === 0 const isCreating = generateApiKey.isPending useEffect(() => { - if (createDialogOpen) - nameInputRef.current?.focus() + if (createDialogOpen) nameInputRef.current?.focus() }, [createDialogOpen]) function handleOpenCreateDialog() { const firstEnvironment = selectableEnvironments[0] - if (!firstEnvironment) - return + if (!firstEnvironment) return setSelectedEnvironmentId(firstEnvironment.id) setDraftName(generateApiTokenName()) @@ -83,8 +83,7 @@ export function ApiKeyGenerateMenu({ function handleDraftNameChange(nextDraftName: string) { setDraftName(nextDraftName) - if (nameError && nextDraftName.trim()) - setNameError(false) + if (nameError && nextDraftName.trim()) setNameError(false) } function resetCreateDialog() { @@ -95,8 +94,7 @@ export function ApiKeyGenerateMenu({ } function handleDialogOpenChange(nextOpen: boolean) { - if (nextOpen || isCreating) - return + if (nextOpen || isCreating) return resetCreateDialog() } @@ -125,12 +123,11 @@ export function ApiKeyGenerateMenu({ }, { onSuccess: (response) => { - if (response.token) - onCreatedToken(response.token) + if (response.token) onCreatedToken(response.token) resetCreateDialog() }, onError: () => { - toast.error(t($ => $['access.api.createFailed'])) + toast.error(t(($) => $['access.api.createFailed'])) }, }, ) @@ -145,7 +142,7 @@ export function ApiKeyGenerateMenu({ className={cn('gap-1.5', triggerClassName)} > <span className="i-ri-add-line size-4" aria-hidden="true" /> - {t($ => $['access.api.newKey'])} + {t(($) => $['access.api.newKey'])} </Button> ) @@ -158,10 +155,10 @@ export function ApiKeyGenerateMenu({ <form onSubmit={handleGenerateApiKey}> <div className="border-b border-divider-subtle px-6 py-5 pr-14"> <DialogTitle className="title-xl-semi-bold text-text-primary"> - {t($ => $['access.api.createKeyTitle'])} + {t(($) => $['access.api.createKeyTitle'])} </DialogTitle> <DialogDescription className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $['access.api.description'])} + {t(($) => $['access.api.description'])} </DialogDescription> </div> @@ -171,7 +168,7 @@ export function ApiKeyGenerateMenu({ htmlFor={nameInputId} className="mb-1 block system-sm-medium text-text-secondary" > - {t($ => $['access.api.nameLabel'])} + {t(($) => $['access.api.nameLabel'])} </label> <Input ref={nameInputRef} @@ -180,14 +177,17 @@ export function ApiKeyGenerateMenu({ disabled={isCreating} aria-invalid={nameError || undefined} aria-describedby={nameError ? `${nameInputId}-error` : undefined} - placeholder={t($ => $['access.api.namePlaceholder'])} + placeholder={t(($) => $['access.api.namePlaceholder'])} onChange={(event) => { handleDraftNameChange(event.target.value) }} /> {nameError && ( - <div id={`${nameInputId}-error`} className="mt-1 system-xs-regular text-text-destructive"> - {t($ => $['access.api.nameRequired'])} + <div + id={`${nameInputId}-error`} + className="mt-1 system-xs-regular text-text-destructive" + > + {t(($) => $['access.api.nameRequired'])} </div> )} </div> @@ -196,16 +196,14 @@ export function ApiKeyGenerateMenu({ <Select value={selectedEnvironmentId ?? null} disabled={isCreating} - onValueChange={value => value && handleEnvironmentChange(value)} + onValueChange={(value) => value && handleEnvironmentChange(value)} > <SelectLabel className="mb-1 block system-sm-medium text-text-secondary"> - {t($ => $['access.api.table.environment'])} + {t(($) => $['access.api.table.environment'])} </SelectLabel> - <SelectTrigger> - {selectedEnvironment?.displayName ?? '—'} - </SelectTrigger> + <SelectTrigger>{selectedEnvironment?.displayName ?? '—'}</SelectTrigger> <SelectContent> - {selectableEnvironments.map(env => ( + {selectableEnvironments.map((env) => ( <SelectItem key={env.id} value={env.id}> <SelectItemText>{env.displayName}</SelectItemText> <SelectItemIndicator /> @@ -223,7 +221,7 @@ export function ApiKeyGenerateMenu({ disabled={isCreating} onClick={() => handleDialogOpenChange(false)} > - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </Button> <Button type="submit" @@ -231,7 +229,7 @@ export function ApiKeyGenerateMenu({ loading={isCreating} disabled={isCreating || !selectedEnvironmentId} > - {t($ => $['access.api.createKey'])} + {t(($) => $['access.api.createKey'])} </Button> </div> </form> diff --git a/web/features/deployments/detail/api-tokens/api-keys/api-key-list.tsx b/web/features/deployments/detail/api-tokens/api-keys/api-key-list.tsx index 320c6fd06e2b72..a29d8f6d6b7e10 100644 --- a/web/features/deployments/detail/api-tokens/api-keys/api-key-list.tsx +++ b/web/features/deployments/detail/api-tokens/api-keys/api-key-list.tsx @@ -1,9 +1,6 @@ 'use client' -import type { - ApiKey, - Environment, -} from '@dify/contracts/enterprise/types.gen' +import type { ApiKey, Environment } from '@dify/contracts/enterprise/types.gen' import { AlertDialog, AlertDialogActions, @@ -31,19 +28,11 @@ import { } from '../../../shared/components/detail-table' import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../table-styles' -function ApiKeyName({ apiKey }: { - apiKey: ApiKey -}) { - return ( - <span className="block truncate text-text-primary"> - {apiKey.displayName} - </span> - ) +function ApiKeyName({ apiKey }: { apiKey: ApiKey }) { + return <span className="block truncate text-text-primary">{apiKey.displayName}</span> } -function EnvironmentBadge({ environment }: { - environment?: Environment -}) { +function EnvironmentBadge({ environment }: { environment?: Environment }) { return ( <span className="inline-flex h-5 max-w-36 items-center rounded-md bg-background-section-burn px-1.5 text-xs text-text-tertiary"> <span className="truncate">{environment?.displayName ?? '—'}</span> @@ -51,9 +40,7 @@ function EnvironmentBadge({ environment }: { ) } -function ApiKeyValue({ value }: { - value: string -}) { +function ApiKeyValue({ value }: { value: string }) { return ( <div className="flex h-8 min-w-0 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2"> <div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary"> @@ -63,18 +50,17 @@ function ApiKeyValue({ value }: { ) } -function RevokeApiKeyButton({ apiKey }: { - apiKey: ApiKey -}) { +function RevokeApiKeyButton({ apiKey }: { apiKey: ApiKey }) { const { t } = useTranslation('deployments') const [showRevokeConfirm, setShowRevokeConfirm] = useState(false) - const revokeApiKey = useMutation(consoleQuery.enterprise.accessService.deleteApiKey.mutationOptions()) + const revokeApiKey = useMutation( + consoleQuery.enterprise.accessService.deleteApiKey.mutationOptions(), + ) const isRevoking = revokeApiKey.isPending const apiKeyName = apiKey.displayName function handleRevoke() { - if (isRevoking) - return + if (isRevoking) return revokeApiKey.mutate( { @@ -87,18 +73,17 @@ function RevokeApiKeyButton({ apiKey }: { { onSuccess: () => { setShowRevokeConfirm(false) - toast.success(t($ => $['access.api.revokeSuccess'])) + toast.success(t(($) => $['access.api.revokeSuccess'])) }, onError: () => { - toast.error(t($ => $['access.api.revokeFailed'])) + toast.error(t(($) => $['access.api.revokeFailed'])) }, }, ) } function handleRevokeConfirmOpenChange(open: boolean) { - if (isRevoking) - return + if (isRevoking) return setShowRevokeConfirm(open) } @@ -108,7 +93,7 @@ function RevokeApiKeyButton({ apiKey }: { <button type="button" onClick={() => setShowRevokeConfirm(true)} - aria-label={t($ => $['access.revoke'])} + aria-label={t(($) => $['access.revoke'])} aria-busy={isRevoking} disabled={isRevoking} className={cn( @@ -118,24 +103,33 @@ function RevokeApiKeyButton({ apiKey }: { : 'hover:bg-state-destructive-hover hover:text-text-destructive', )} > - <span className={cn(isRevoking ? 'i-ri-loader-2-line animate-spin' : 'i-ri-delete-bin-line', 'size-3.5')} /> + <span + className={cn( + isRevoking ? 'i-ri-loader-2-line animate-spin' : 'i-ri-delete-bin-line', + 'size-3.5', + )} + /> </button> <AlertDialog open={showRevokeConfirm} onOpenChange={handleRevokeConfirmOpenChange}> <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> - {t($ => $['access.api.revokeConfirmTitle'])} + {t(($) => $['access.api.revokeConfirmTitle'])} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['access.api.revokeConfirmDescription'], { name: apiKeyName })} + {t(($) => $['access.api.revokeConfirmDescription'], { name: apiKeyName })} </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton disabled={isRevoking}> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> - <AlertDialogConfirmButton loading={isRevoking} disabled={isRevoking} onClick={handleRevoke}> - {t($ => $['access.revoke'])} + <AlertDialogConfirmButton + loading={isRevoking} + disabled={isRevoking} + onClick={handleRevoke} + > + {t(($) => $['access.revoke'])} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> @@ -144,10 +138,7 @@ function RevokeApiKeyButton({ apiKey }: { ) } -function ApiKeyMobileRow({ apiKey, environment }: { - apiKey: ApiKey - environment?: Environment -}) { +function ApiKeyMobileRow({ apiKey, environment }: { apiKey: ApiKey; environment?: Environment }) { const { t } = useTranslation('deployments') const displayValue = apiKey.maskedToken @@ -165,7 +156,7 @@ function ApiKeyMobileRow({ apiKey, environment }: { </div> <div className="flex min-w-0 flex-col gap-1"> <span className="system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['access.api.table.key'])} + {t(($) => $['access.api.table.key'])} </span> <ApiKeyValue value={displayValue} /> </div> @@ -174,10 +165,7 @@ function ApiKeyMobileRow({ apiKey, environment }: { ) } -function ApiKeyDesktopRow({ apiKey, environment }: { - apiKey: ApiKey - environment?: Environment -}) { +function ApiKeyDesktopRow({ apiKey, environment }: { apiKey: ApiKey; environment?: Environment }) { const displayValue = apiKey.maskedToken return ( @@ -206,25 +194,36 @@ function ApiKeyTableHeader() { return ( <DetailTableHeader> <DetailTableRow> - <DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.name}>{t($ => $['access.api.table.name'])}</DetailTableHead> - <DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t($ => $['access.api.table.environment'])}</DetailTableHead> - <DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.key}>{t($ => $['access.api.table.key'])}</DetailTableHead> - <DetailTableHead className={`${API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t($ => $['access.api.table.action'])}</DetailTableHead> + <DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.name}> + {t(($) => $['access.api.table.name'])} + </DetailTableHead> + <DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}> + {t(($) => $['access.api.table.environment'])} + </DetailTableHead> + <DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.key}> + {t(($) => $['access.api.table.key'])} + </DetailTableHead> + <DetailTableHead className={`${API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}> + {t(($) => $['access.api.table.action'])} + </DetailTableHead> </DetailTableRow> </DetailTableHeader> ) } -export function ApiKeyList({ apiKeys, environments }: { +export function ApiKeyList({ + apiKeys, + environments, +}: { apiKeys: ApiKey[] environments: Environment[] }) { - const environmentById = new Map(environments.map(environment => [environment.id, environment])) + const environmentById = new Map(environments.map((environment) => [environment.id, environment])) return ( <> <DetailTableCardList className={cn('pc:hidden')}> - {apiKeys.map(apiKey => ( + {apiKeys.map((apiKey) => ( <ApiKeyMobileRow key={apiKey.id} apiKey={apiKey} @@ -236,7 +235,7 @@ export function ApiKeyList({ apiKeys, environments }: { <DetailTable> <ApiKeyTableHeader /> <DetailTableBody> - {apiKeys.map(apiKey => ( + {apiKeys.map((apiKey) => ( <ApiKeyDesktopRow key={apiKey.id} apiKey={apiKey} diff --git a/web/features/deployments/detail/api-tokens/api-keys/created-token-dialog.tsx b/web/features/deployments/detail/api-tokens/api-keys/created-token-dialog.tsx index c667a34158d5b2..9e71316ce0b575 100644 --- a/web/features/deployments/detail/api-tokens/api-keys/created-token-dialog.tsx +++ b/web/features/deployments/detail/api-tokens/api-keys/created-token-dialog.tsx @@ -2,7 +2,13 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { useClipboard } from 'foxact/use-clipboard' import { useTranslation } from 'react-i18next' @@ -19,15 +25,12 @@ function buildCurlExample(apiUrl: string, token: string) { }'` } -function CurlExample({ apiUrl, token }: { - apiUrl: string - token: string -}) { +function CurlExample({ apiUrl, token }: { apiUrl: string; token: string }) { const { t } = useTranslation('deployments') const curlExample = buildCurlExample(apiUrl, token) const { copied, copy } = useClipboard({ onCopyError: () => { - toast.error(t($ => $['access.copyFailed'])) + toast.error(t(($) => $['access.copyFailed'])) }, }) @@ -35,12 +38,12 @@ function CurlExample({ apiUrl, token }: { <div className="min-w-0 overflow-hidden rounded-lg border border-components-input-border-active bg-components-input-bg-normal"> <div className="flex h-8 items-center justify-between gap-2 border-b border-divider-subtle pr-1.5 pl-3"> <div className="min-w-0 truncate system-xs-semibold-uppercase text-text-secondary"> - {t($ => $['access.api.curlExampleTitle'])} + {t(($) => $['access.api.curlExampleTitle'])} </div> <button type="button" onClick={() => copy(curlExample)} - aria-label={t($ => $['access.api.copyCurlExample'])} + aria-label={t(($) => $['access.api.copyCurlExample'])} className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" > <span className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'size-3.5')} /> @@ -53,7 +56,11 @@ function CurlExample({ apiUrl, token }: { ) } -export function CreatedApiTokenDialog({ token, apiUrl, onDismiss }: { +export function CreatedApiTokenDialog({ + token, + apiUrl, + onDismiss, +}: { token: string apiUrl?: string onDismiss: () => void @@ -61,34 +68,30 @@ export function CreatedApiTokenDialog({ token, apiUrl, onDismiss }: { const { t } = useTranslation('deployments') return ( - <Dialog open={Boolean(token)} onOpenChange={open => !open && onDismiss()} disablePointerDismissal> + <Dialog + open={Boolean(token)} + onOpenChange={(open) => !open && onDismiss()} + disablePointerDismissal + > <DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0"> <DialogCloseButton /> <div className="border-b border-divider-subtle px-6 py-5 pr-14"> <DialogTitle className="title-xl-semi-bold text-text-primary"> - {t($ => $['access.api.newTokenTitle'])} + {t(($) => $['access.api.newTokenTitle'])} </DialogTitle> <DialogDescription className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $['access.api.newTokenDescription'])} + {t(($) => $['access.api.newTokenDescription'])} </DialogDescription> </div> <div className="flex flex-col gap-5 px-6 py-5"> - <CopyPill - label={t($ => $['access.api.newTokenLabel'])} - value={token} - /> - {apiUrl && ( - <CurlExample - apiUrl={apiUrl} - token={token} - /> - )} + <CopyPill label={t(($) => $['access.api.newTokenLabel'])} value={token} /> + {apiUrl && <CurlExample apiUrl={apiUrl} token={token} />} </div> <div className="flex justify-end border-t border-divider-subtle bg-background-default-subtle px-6 py-4"> <Button variant="primary" onClick={onDismiss}> - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </Button> </div> </DialogContent> diff --git a/web/features/deployments/detail/api-tokens/api-token-management/section.tsx b/web/features/deployments/detail/api-tokens/api-token-management/section.tsx index aa04032cefcf88..de664a1cf48925 100644 --- a/web/features/deployments/detail/api-tokens/api-token-management/section.tsx +++ b/web/features/deployments/detail/api-tokens/api-token-management/section.tsx @@ -1,16 +1,16 @@ 'use client' -import type { - ApiKey, - Environment, -} from '@dify/contracts/enterprise/types.gen' +import type { ApiKey, Environment } from '@dify/contracts/enterprise/types.gen' import type { ReactNode } from 'react' import { Button } from '@langgenius/dify-ui/button' import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' -import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/components/empty-state' +import { + DeploymentEmptyState, + DeploymentStateMessage, +} from '../../../shared/components/empty-state' import { CopyPill } from '../../../shared/components/endpoint' import { ApiKeyGenerateMenu } from '../api-keys/api-key-generate-menu' import { ApiKeyList } from '../api-keys/api-key-list' @@ -28,7 +28,11 @@ type CreatedApiToken = { token: string } -function ApiKeyListSection({ apiKeys, environments, action }: { +function ApiKeyListSection({ + apiKeys, + environments, + action, +}: { apiKeys: ApiKey[] environments: Environment[] action?: ReactNode @@ -40,7 +44,7 @@ function ApiKeyListSection({ apiKeys, environments, action }: { <div className="flex flex-col gap-2"> <div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="system-xs-semibold-uppercase text-text-tertiary"> - {t($ => $['access.api.keyList'])} + {t(($) => $['access.api.keyList'])} </div> {hasAction && ( <div className="w-full shrink-0 sm:w-auto [&_button]:w-full sm:[&_button]:w-auto"> @@ -48,34 +52,25 @@ function ApiKeyListSection({ apiKeys, environments, action }: { </div> )} </div> - <ApiKeyList - apiKeys={apiKeys} - environments={environments} - /> + <ApiKeyList apiKeys={apiKeys} environments={environments} /> </div> ) } -function DeveloperApiEndpoint({ apiUrl }: { - apiUrl: string -}) { +function DeveloperApiEndpoint({ apiUrl }: { apiUrl: string }) { const { t } = useTranslation('deployments') const [apiDocsOpen, setApiDocsOpen] = useState(false) return ( <div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center"> <CopyPill - label={t($ => $['access.api.endpoint'])} + label={t(($) => $['access.api.endpoint'])} value={apiUrl} className="min-w-0 flex-1" /> - <Button - variant="secondary" - className="shrink-0 gap-1.5" - onClick={() => setApiDocsOpen(true)} - > + <Button variant="secondary" className="shrink-0 gap-1.5" onClick={() => setApiDocsOpen(true)}> <span className="i-ri-file-list-3-line size-3.5" /> - {t($ => $['access.api.docs'])} + {t(($) => $['access.api.docs'])} </Button> <DeveloperApiDocsDrawer open={apiDocsOpen} @@ -98,76 +93,65 @@ export function DeveloperApiSection() { const apiUrl = developerApiSettings?.developerApiUrl.apiUrl const apiKeys: ApiKey[] = developerApiSettings?.apiKeys ?? [] const environments = developerApiSettings?.environments ?? [] - const visibleCreatedApiToken = createdApiToken && createdApiToken.appInstanceId === appInstanceId - ? createdApiToken.token - : undefined - const hasSelectableEnvironment = environments.some(environment => Boolean(environment.id)) + const visibleCreatedApiToken = + createdApiToken && createdApiToken.appInstanceId === appInstanceId + ? createdApiToken.token + : undefined + const hasSelectableEnvironment = environments.some((environment) => Boolean(environment.id)) - if (isLoading) - return <DeveloperApiSkeleton /> + if (isLoading) return <DeveloperApiSkeleton /> if (isError || !appInstanceId) - return <DeploymentStateMessage variant="section">{t($ => $['common.loadFailed'])}</DeploymentStateMessage> + return ( + <DeploymentStateMessage variant="section"> + {t(($) => $['common.loadFailed'])} + </DeploymentStateMessage> + ) if (!apiEnabled) { return ( <DeploymentEmptyState variant="section" icon="i-ri-toggle-line" - title={t($ => $['access.api.disabled'])} - description={t($ => $['access.api.disabledHint'])} + title={t(($) => $['access.api.disabled'])} + description={t(($) => $['access.api.disabledHint'])} /> ) } return ( <div className="flex flex-col gap-4"> - {apiUrl && ( - <DeveloperApiEndpoint - apiUrl={apiUrl} - /> - )} - {hasSelectableEnvironment - ? ( - <ApiKeyGenerateMenu - environments={environments} - triggerVariant="primary" - onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })} - > - {({ trigger }) => apiKeys.length === 0 - ? ( - <DeploymentEmptyState - variant="section" - icon="i-ri-key-2-line" - title={t($ => $['access.api.noKeysTitle'])} - description={t($ => $['access.api.noKeys'])} - action={trigger} - /> - ) - : ( - <ApiKeyListSection - apiKeys={apiKeys} - environments={environments} - action={trigger} - /> - )} - </ApiKeyGenerateMenu> - ) - : apiKeys.length === 0 - ? ( + {apiUrl && <DeveloperApiEndpoint apiUrl={apiUrl} />} + {hasSelectableEnvironment ? ( + <ApiKeyGenerateMenu + environments={environments} + triggerVariant="primary" + onCreatedToken={(token) => setCreatedApiToken({ appInstanceId, token })} + > + {({ trigger }) => + apiKeys.length === 0 ? ( <DeploymentEmptyState variant="section" - icon="i-ri-rocket-line" - title={t($ => $['access.api.emptyTitle'])} - description={t($ => $['access.api.empty'])} + icon="i-ri-key-2-line" + title={t(($) => $['access.api.noKeysTitle'])} + description={t(($) => $['access.api.noKeys'])} + action={trigger} /> + ) : ( + <ApiKeyListSection apiKeys={apiKeys} environments={environments} action={trigger} /> ) - : ( - <ApiKeyListSection - apiKeys={apiKeys} - environments={environments} - /> - )} + } + </ApiKeyGenerateMenu> + ) : apiKeys.length === 0 ? ( + <DeploymentEmptyState + variant="section" + icon="i-ri-rocket-line" + title={t(($) => $['access.api.emptyTitle'])} + description={t(($) => $['access.api.empty'])} + /> + ) : ( + <ApiKeyListSection apiKeys={apiKeys} environments={environments} /> + )} {visibleCreatedApiToken && ( <CreatedApiTokenDialog token={visibleCreatedApiToken} diff --git a/web/features/deployments/detail/api-tokens/api-token-management/skeleton.tsx b/web/features/deployments/detail/api-tokens/api-token-management/skeleton.tsx index fa93d776f0d7e6..0ed084d9557772 100644 --- a/web/features/deployments/detail/api-tokens/api-token-management/skeleton.tsx +++ b/web/features/deployments/detail/api-tokens/api-token-management/skeleton.tsx @@ -43,7 +43,7 @@ function ApiKeyTableSkeleton() { return ( <> <DetailTableCardList className="pc:hidden"> - {DEVELOPER_API_KEY_SKELETON_KEYS.map(key => ( + {DEVELOPER_API_KEY_SKELETON_KEYS.map((key) => ( <DetailTableCard key={key} data-slot="deployment-developer-api-mobile-row-skeleton"> <div className="flex flex-col gap-3 p-4"> <div className="flex items-start justify-between gap-3"> @@ -65,7 +65,7 @@ function ApiKeyTableSkeleton() { <DetailTable> <ApiKeyTableHeaderSkeleton /> <DetailTableBody> - {DEVELOPER_API_KEY_SKELETON_KEYS.map(key => ( + {DEVELOPER_API_KEY_SKELETON_KEYS.map((key) => ( <ApiKeyDesktopRowSkeleton key={key} /> ))} </DetailTableBody> @@ -81,10 +81,18 @@ function ApiKeyTableHeaderSkeleton() { return ( <DetailTableHeader> <DetailTableRow> - <DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.name}>{t($ => $['access.api.table.name'])}</DetailTableHead> - <DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t($ => $['access.api.table.environment'])}</DetailTableHead> - <DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.key}>{t($ => $['access.api.table.key'])}</DetailTableHead> - <DetailTableHead className={`${API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t($ => $['access.api.table.action'])}</DetailTableHead> + <DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.name}> + {t(($) => $['access.api.table.name'])} + </DetailTableHead> + <DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}> + {t(($) => $['access.api.table.environment'])} + </DetailTableHead> + <DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.key}> + {t(($) => $['access.api.table.key'])} + </DetailTableHead> + <DetailTableHead className={`${API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}> + {t(($) => $['access.api.table.action'])} + </DetailTableHead> </DetailTableRow> </DetailTableHeader> ) diff --git a/web/features/deployments/detail/api-tokens/developer-api-header-switch.tsx b/web/features/deployments/detail/api-tokens/developer-api-header-switch.tsx index 2dc2121f522514..9c69863b2c9ff2 100644 --- a/web/features/deployments/detail/api-tokens/developer-api-header-switch.tsx +++ b/web/features/deployments/detail/api-tokens/developer-api-header-switch.tsx @@ -13,24 +13,29 @@ import { developerApiSettingsIsLoadingAtom, } from './state' -function DeveloperApiSwitch({ checked, accessChannels, disabled }: { +function DeveloperApiSwitch({ + checked, + accessChannels, + disabled, +}: { checked: boolean accessChannels?: AccessChannels disabled?: boolean }) { const { t } = useTranslation('deployments') const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) - const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions()) + const toggleDeveloperAPI = useMutation( + consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions(), + ) return ( <Switch - aria-label={t($ => $['access.api.developerTitle'])} + aria-label={t(($) => $['access.api.developerTitle'])} checked={checked} disabled={disabled || !appInstanceId} loading={toggleDeveloperAPI.isPending} onCheckedChange={(enabled) => { - if (!appInstanceId) - return + if (!appInstanceId) return toggleDeveloperAPI.mutate({ params: { appInstanceId }, @@ -53,19 +58,14 @@ export function DeveloperApiHeaderSwitch() { const accessChannels = developerApiSettings?.accessChannels const apiEnabled = accessChannels?.developerApiEnabled ?? false - if (isLoading) - return <SwitchSkeleton /> + if (isLoading) return <SwitchSkeleton /> return ( <div className="flex items-center gap-2"> <span className="system-xs-medium text-text-tertiary"> - {apiEnabled ? t($ => $['overview.enabled']) : t($ => $['overview.disabled'])} + {apiEnabled ? t(($) => $['overview.enabled']) : t(($) => $['overview.disabled'])} </span> - <DeveloperApiSwitch - checked={apiEnabled} - accessChannels={accessChannels} - disabled={isError} - /> + <DeveloperApiSwitch checked={apiEnabled} accessChannels={accessChannels} disabled={isError} /> </div> ) } diff --git a/web/features/deployments/detail/api-tokens/docs/docs-drawer.tsx b/web/features/deployments/detail/api-tokens/docs/docs-drawer.tsx index 9c7834c78c5533..74c5630cd2433c 100644 --- a/web/features/deployments/detail/api-tokens/docs/docs-drawer.tsx +++ b/web/features/deployments/detail/api-tokens/docs/docs-drawer.tsx @@ -24,7 +24,7 @@ import { getDocLanguage } from '@/i18n-config/language' import { AppModeEnum, Theme } from '@/types/app' import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' -type PromptVariable = { key: string, name: string } +type PromptVariable = { key: string; name: string } type WorkflowApiDocAppDetail = Pick<App, 'id' | 'mode' | 'api_base_url'> type WorkflowDocTemplateProps = { @@ -36,35 +36,22 @@ type WorkflowDocTemplateProps = { const EMPTY_VARIABLES: PromptVariable[] = [] const EMPTY_INPUTS: Record<string, string> = {} -function WorkflowDocTemplate({ docLanguage, appDetail, variables, inputs }: WorkflowDocTemplateProps & { +function WorkflowDocTemplate({ + docLanguage, + appDetail, + variables, + inputs, +}: WorkflowDocTemplateProps & { docLanguage: string }) { if (docLanguage === 'zh') { - return ( - <TemplateWorkflowZh - appDetail={appDetail} - variables={variables} - inputs={inputs} - /> - ) + return <TemplateWorkflowZh appDetail={appDetail} variables={variables} inputs={inputs} /> } if (docLanguage === 'ja') { - return ( - <TemplateWorkflowJa - appDetail={appDetail} - variables={variables} - inputs={inputs} - /> - ) + return <TemplateWorkflowJa appDetail={appDetail} variables={variables} inputs={inputs} /> } - return ( - <TemplateWorkflowEn - appDetail={appDetail} - variables={variables} - inputs={inputs} - /> - ) + return <TemplateWorkflowEn appDetail={appDetail} variables={variables} inputs={inputs} /> } export function DeveloperApiDocsDrawer({ @@ -82,8 +69,7 @@ export function DeveloperApiDocsDrawer({ const { theme } = useTheme() const docLanguage = getDocLanguage(locale) - if (!appInstanceId) - return null + if (!appInstanceId) return null const appDetail: WorkflowApiDocAppDetail = { id: appInstanceId, @@ -92,27 +78,22 @@ export function DeveloperApiDocsDrawer({ } return ( - <Drawer - open={open} - modal - swipeDirection="right" - onOpenChange={onOpenChange} - > + <Drawer open={open} modal swipeDirection="right" onOpenChange={onOpenChange}> <DrawerPortal> <DrawerBackdrop /> <DrawerViewport> <DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[840px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-[0.5px]"> <DrawerCloseButton - aria-label={t($ => $['access.api.docsClose'])} + aria-label={t(($) => $['access.api.docsClose'])} className="absolute top-4 right-5 size-6 rounded-md" /> <DrawerContent className="flex min-h-0 flex-1 flex-col bg-components-panel-bg p-0 pb-0"> <div className="shrink-0 border-b border-divider-subtle px-6 py-5 pr-14"> <DrawerTitle className="title-xl-semi-bold text-text-primary"> - {t($ => $['access.api.docsTitle'])} + {t(($) => $['access.api.docsTitle'])} </DrawerTitle> <DrawerDescription className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $['access.api.docsDescription'])} + {t(($) => $['access.api.docsDescription'])} </DrawerDescription> </div> diff --git a/web/features/deployments/detail/api-tokens/state.ts b/web/features/deployments/detail/api-tokens/state.ts index 7fde83b0d13153..475e7c00c69f19 100644 --- a/web/features/deployments/detail/api-tokens/state.ts +++ b/web/features/deployments/detail/api-tokens/state.ts @@ -19,6 +19,15 @@ export const developerApiSettingsQueryAtom = atomWithQuery((get) => { }) }) -export const developerApiSettingsAtom = selectAtom(developerApiSettingsQueryAtom, query => query.data) -export const developerApiSettingsIsLoadingAtom = selectAtom(developerApiSettingsQueryAtom, query => query.isLoading) -export const developerApiSettingsIsErrorAtom = selectAtom(developerApiSettingsQueryAtom, query => query.isError) +export const developerApiSettingsAtom = selectAtom( + developerApiSettingsQueryAtom, + (query) => query.data, +) +export const developerApiSettingsIsLoadingAtom = selectAtom( + developerApiSettingsQueryAtom, + (query) => query.isLoading, +) +export const developerApiSettingsIsErrorAtom = selectAtom( + developerApiSettingsQueryAtom, + (query) => query.isError, +) diff --git a/web/features/deployments/detail/deployment-sidebar.tsx b/web/features/deployments/detail/deployment-sidebar.tsx index fbce17bceca43d..0035b084506dc3 100644 --- a/web/features/deployments/detail/deployment-sidebar.tsx +++ b/web/features/deployments/detail/deployment-sidebar.tsx @@ -79,9 +79,7 @@ const DEPLOYMENT_TABS: TabDef[] = [ const SEARCH_SHORTCUT = ['Mod', 'K'] -function DeploymentIcon({ expand }: { - expand: boolean -}) { +function DeploymentIcon({ expand }: { expand: boolean }) { return ( <div className={cn( @@ -94,7 +92,10 @@ function DeploymentIcon({ expand }: { ) } -function DeploymentDetailInstanceInfo({ appInstanceId, expand }: { +function DeploymentDetailInstanceInfo({ + appInstanceId, + expand, +}: { appInstanceId: string expand: boolean }) { @@ -115,72 +116,70 @@ function DeploymentDetailInstanceInfo({ appInstanceId, expand }: { )} aria-label={!expand ? instanceName : undefined} > - {isLoading - ? ( - <> - <SkeletonRectangle className={cn('my-0 animate-pulse rounded-lg', expand ? 'size-10' : 'size-8')} /> - {expand && ( - <SkeletonContainer className="min-w-0 flex-1 gap-1"> - <SkeletonRectangle className="my-0 h-5 w-32 animate-pulse" /> - <SkeletonRectangle className="my-0 h-3 w-20 animate-pulse" /> - </SkeletonContainer> - )} - </> - ) - : isUnavailable - ? ( - <> - <div className="flex size-8 items-center justify-center rounded-lg bg-components-icon-bg-orange-solid text-text-primary-on-surface"> - <span className="i-ri-rocket-line size-4" /> + {isLoading ? ( + <> + <SkeletonRectangle + className={cn('my-0 animate-pulse rounded-lg', expand ? 'size-10' : 'size-8')} + /> + {expand && ( + <SkeletonContainer className="min-w-0 flex-1 gap-1"> + <SkeletonRectangle className="my-0 h-5 w-32 animate-pulse" /> + <SkeletonRectangle className="my-0 h-3 w-20 animate-pulse" /> + </SkeletonContainer> + )} + </> + ) : isUnavailable ? ( + <> + <div className="flex size-8 items-center justify-center rounded-lg bg-components-icon-bg-orange-solid text-text-primary-on-surface"> + <span className="i-ri-rocket-line size-4" /> + </div> + {expand && ( + <div className="flex min-w-0 flex-1 flex-col items-start justify-center gap-0.5 self-stretch"> + <div className="w-full min-w-0 pr-1"> + <div className="truncate system-md-semibold whitespace-nowrap text-text-secondary"> + {t(($) => $['detail.notFound'])} + </div> + </div> + <TitleTooltip content={appInstanceId}> + <div className="max-w-full truncate font-mono system-2xs-regular text-text-tertiary"> + {appInstanceId} </div> - {expand && ( - <div className="flex min-w-0 flex-1 flex-col items-start justify-center gap-0.5 self-stretch"> - <div className="w-full min-w-0 pr-1"> - <div className="truncate system-md-semibold whitespace-nowrap text-text-secondary"> - {t($ => $['detail.notFound'])} - </div> + </TitleTooltip> + </div> + )} + </> + ) : ( + <> + <DeploymentIcon expand={expand} /> + {expand && ( + <> + <div className="flex min-w-0 flex-1 flex-col items-start justify-center gap-0.5 self-stretch"> + <div className="w-full min-w-0 pr-1"> + <TitleTooltip content={instanceName}> + <div className="truncate system-md-semibold whitespace-nowrap text-text-secondary"> + {instanceName} </div> - <TitleTooltip content={appInstanceId}> - <div className="max-w-full truncate font-mono system-2xs-regular text-text-tertiary"> - {appInstanceId} - </div> - </TitleTooltip> - </div> - )} - </> - ) - : ( - <> - <DeploymentIcon expand={expand} /> - {expand && ( - <> - <div className="flex min-w-0 flex-1 flex-col items-start justify-center gap-0.5 self-stretch"> - <div className="w-full min-w-0 pr-1"> - <TitleTooltip content={instanceName}> - <div className="truncate system-md-semibold whitespace-nowrap text-text-secondary"> - {instanceName} - </div> - </TitleTooltip> - </div> - {app.description && ( - <TitleTooltip content={app.description}> - <div className="line-clamp-2 system-xs-regular text-text-tertiary"> - {app.description} - </div> - </TitleTooltip> - )} + </TitleTooltip> + </div> + {app.description && ( + <TitleTooltip content={app.description}> + <div className="line-clamp-2 system-xs-regular text-text-tertiary"> + {app.description} </div> - <DeploymentActionsMenu - appInstance={app} - placement="bottom-end" - sideOffset={4} - className="shrink-0" - triggerClassName="size-5 rounded-md bg-transparent p-0.5 shadow-none" - /> - </> + </TitleTooltip> )} - </> - )} + </div> + <DeploymentActionsMenu + appInstance={app} + placement="bottom-end" + sideOffset={4} + className="shrink-0" + triggerClassName="size-5 rounded-md bg-transparent p-0.5 shadow-none" + /> + </> + )} + </> + )} </div> ) } @@ -215,39 +214,40 @@ export function DeploymentDetailTop({ <div className="flex min-w-0 flex-1 items-center gap-px"> <Link href="/" - aria-label={t($ => $['mainNav.home'], { ns: 'common' })} + aria-label={t(($) => $['mainNav.home'], { ns: 'common' })} className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 text-text-tertiary transition-colors hover:bg-background-default-hover hover:text-text-secondary" > <span aria-hidden className="i-ri-arrow-left-s-line size-4" /> <span aria-hidden className="i-custom-vender-main-nav-app-home size-4" /> </Link> - <span className="shrink-0 system-md-regular text-text-quaternary"> - / - </span> + <span className="shrink-0 system-md-regular text-text-quaternary">/</span> <Link href="/deployments" className="shrink-0 truncate rounded-lg px-1.5 py-2 system-sm-semibold-uppercase text-text-secondary transition-colors hover:bg-background-default-hover hover:text-text-primary" > - {t($ => $['menus.deployments'], { ns: 'common' })} + {t(($) => $['menus.deployments'], { ns: 'common' })} </Link> </div> <Tooltip> <TooltipTrigger - render={( + render={ <button type="button" - aria-label={t($ => $['gotoAnything.searchTitle'], { ns: 'app' })} + aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })} className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary" onClick={() => setGotoAnythingOpen(true)} > <span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" /> </button> - )} + } /> - <TooltipContent placement="bottom" className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]"> - <span className="px-0.5">{t($ => $['gotoAnything.quickAction'], { ns: 'app' })}</span> + <TooltipContent + placement="bottom" + className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]" + > + <span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span> <KbdGroup> - {SEARCH_SHORTCUT.map(key => ( + {SEARCH_SHORTCUT.map((key) => ( <Kbd key={key}>{formatForDisplay(key)}</Kbd> ))} </KbdGroup> @@ -265,17 +265,12 @@ export function DeploymentDetailTop({ ) } -export function DeploymentDetailSection({ - expand = true, -}: { - expand?: boolean -}) { +export function DeploymentDetailSection({ expand = true }: { expand?: boolean }) { const { t } = useTranslation('deployments') const pathname = usePathname() const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) - if (!appInstanceId) - return null + if (!appInstanceId) return null return ( <div className={cn('flex min-h-0 flex-1 flex-col', expand ? 'px-2 pb-2' : 'pb-2')}> @@ -293,12 +288,12 @@ export function DeploymentDetailSection({ </div> <nav className={cn('flex flex-col gap-y-0.5 py-1', expand ? 'px-1' : 'px-3')}> - {DEPLOYMENT_TABS.filter(tab => !tab.hidden).map(tab => ( + {DEPLOYMENT_TABS.filter((tab) => !tab.hidden).map((tab) => ( <NavLink key={tab.key} mode={expand ? 'expand' : 'collapse'} iconMap={{ selected: tab.selectedIcon, normal: tab.icon }} - name={t($ => $[`tabs.${tab.key}.name`])} + name={t(($) => $[`tabs.${tab.key}.name`])} href={`/deployments/${appInstanceId}/${tab.key}`} pathname={pathname} /> diff --git a/web/features/deployments/detail/detail-sidebar.tsx b/web/features/deployments/detail/detail-sidebar.tsx index 5d5a4937cd71df..b924b792cdffba 100644 --- a/web/features/deployments/detail/detail-sidebar.tsx +++ b/web/features/deployments/detail/detail-sidebar.tsx @@ -7,10 +7,7 @@ export function DeploymentDetailSidebar() { return ( <DetailSidebarFrame renderTop={({ expand, onToggle }) => ( - <DeploymentDetailTop - expand={expand} - onToggle={onToggle} - /> + <DeploymentDetailTop expand={expand} onToggle={onToggle} /> )} renderSection={({ expand }) => <DeploymentDetailSection expand={expand} />} /> diff --git a/web/features/deployments/detail/index.tsx b/web/features/deployments/detail/index.tsx index 8ca811e71607da..e6a722db5cceb1 100644 --- a/web/features/deployments/detail/index.tsx +++ b/web/features/deployments/detail/index.tsx @@ -17,16 +17,15 @@ function MobileDetailTabs() { const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) const activeTab = useAtomValue(deploymentDetailActiveTabAtom) - if (!appInstanceId) - return null + if (!appInstanceId) return null return ( <nav - aria-label={t($ => $['detail.mobileTabs'])} + aria-label={t(($) => $['detail.mobileTabs'])} className="border-b border-divider-subtle bg-components-panel-bg px-4 pc:hidden" > <div className="flex min-w-0 scrollbar-none gap-1 overflow-x-auto py-2"> - {INSTANCE_DETAIL_TAB_KEYS.map(tab => ( + {INSTANCE_DETAIL_TAB_KEYS.map((tab) => ( <Link key={tab} href={`/deployments/${appInstanceId}/${tab}`} @@ -36,7 +35,7 @@ function MobileDetailTabs() { : 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary' }`} > - {t($ => $[`tabs.${tab}.name`])} + {t(($) => $[`tabs.${tab}.name`])} </Link> ))} </div> @@ -44,17 +43,14 @@ function MobileDetailTabs() { ) } -export function InstanceDetail({ children }: { - children: ReactNode -}) { +export function InstanceDetail({ children }: { children: ReactNode }) { const { t } = useTranslation('deployments') const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) const activeTab = useAtomValue(deploymentDetailActiveTabAtom) - useDocumentTitle(t($ => $['documentTitle.detail'])) + useDocumentTitle(t(($) => $['documentTitle.detail'])) - if (!appInstanceId) - return null + if (!appInstanceId) return null return ( <div className="relative m-1 ml-0 flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-lg shadow-xs"> @@ -65,20 +61,26 @@ export function InstanceDetail({ children }: { <div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4"> <div className="min-w-0"> <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2"> - <div className="system-xl-semibold text-text-primary">{t($ => $[`tabs.${activeTab}.name`])}</div> + <div className="system-xl-semibold text-text-primary"> + {t(($) => $[`tabs.${activeTab}.name`])} + </div> {activeTab === 'api-tokens' && ( <div className="shrink-0"> <DeveloperApiHeaderSwitch /> </div> )} </div> - <div className="system-sm-regular text-text-tertiary">{t($ => $[`tabs.${activeTab}.description`])}</div> + <div className="system-sm-regular text-text-tertiary"> + {t(($) => $[`tabs.${activeTab}.description`])} + </div> </div> {(activeTab === 'instances' || activeTab === 'releases') && ( <div className="w-full shrink-0 pt-1 sm:w-auto sm:pt-1.5 [&_button]:w-full sm:[&_button]:w-auto"> - {activeTab === 'instances' - ? <NewDeploymentHeaderAction /> - : <CreateReleaseControl appInstanceId={appInstanceId} size="medium" />} + {activeTab === 'instances' ? ( + <NewDeploymentHeaderAction /> + ) : ( + <CreateReleaseControl appInstanceId={appInstanceId} size="medium" /> + )} </div> )} </div> diff --git a/web/features/deployments/detail/instances/environment-list/deployment-environment-list.tsx b/web/features/deployments/detail/instances/environment-list/deployment-environment-list.tsx index 48f55061c59e80..a2841973e6765d 100644 --- a/web/features/deployments/detail/instances/environment-list/deployment-environment-list.tsx +++ b/web/features/deployments/detail/instances/environment-list/deployment-environment-list.tsx @@ -18,61 +18,51 @@ import { DeploymentRowActions } from '../row-actions/deployment-row-actions' import { DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../table-styles' import { DeploymentStatusSummary } from './deployment-status-summary' -function EnvironmentSummary({ environment }: { +function EnvironmentSummary({ + environment, +}: { environment: EnvironmentDeployment['environment'] }) { - return ( - <span className="block truncate text-text-primary"> - {environment.displayName} - </span> - ) + return <span className="block truncate text-text-primary">{environment.displayName}</span> } -function CurrentReleaseSummary({ release }: { - release: EnvironmentDeployment['currentRelease'] -}) { - if (!release) - return <span className="text-text-quaternary">—</span> +function CurrentReleaseSummary({ release }: { release: EnvironmentDeployment['currentRelease'] }) { + if (!release) return <span className="text-text-quaternary">—</span> const commit = releaseCommit(release) return ( <div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 items-baseline gap-1.5"> - <span className="truncate text-text-primary"> - {release.displayName} - </span> + <span className="truncate text-text-primary">{release.displayName}</span> {commit !== '—' && ( - <span className="shrink-0 font-mono system-xs-regular text-text-tertiary"> - {commit} - </span> + <span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{commit}</span> )} </div> </div> ) } -function CurrentReleaseMobileSummary({ release }: { +function CurrentReleaseMobileSummary({ + release, +}: { release: EnvironmentDeployment['currentRelease'] }) { const { t } = useTranslation('deployments') - if (!release) - return null + if (!release) return null return ( <div className="flex min-w-0 flex-col gap-1"> <span className="system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['deployTab.col.currentRelease'])} + {t(($) => $['deployTab.col.currentRelease'])} </span> <CurrentReleaseSummary release={release} /> </div> ) } -function DeploymentEnvironmentMobileRow({ row }: { - row: EnvironmentDeployment -}) { +function DeploymentEnvironmentMobileRow({ row }: { row: EnvironmentDeployment }) { const envId = row.environment.id const release = row.currentRelease @@ -92,9 +82,7 @@ function DeploymentEnvironmentMobileRow({ row }: { ) } -function DeploymentEnvironmentDesktopRows({ rows }: { - rows: EnvironmentDeployment[] -}) { +function DeploymentEnvironmentDesktopRows({ rows }: { rows: EnvironmentDeployment[] }) { return ( <> {rows.map((row) => { @@ -122,29 +110,36 @@ function DeploymentEnvironmentDesktopRows({ rows }: { ) } -export function DeploymentEnvironmentList({ rows }: { - rows: EnvironmentDeployment[] -}) { +export function DeploymentEnvironmentList({ rows }: { rows: EnvironmentDeployment[] }) { const { t } = useTranslation('deployments') return ( <> <DetailTableCardList className="pc:hidden"> - {rows.map(row => ( - <DeploymentEnvironmentMobileRow - key={row.environment.id} - row={row} - /> + {rows.map((row) => ( + <DeploymentEnvironmentMobileRow key={row.environment.id} row={row} /> ))} </DetailTableCardList> <div className="hidden pc:block"> <DetailTable> <DetailTableHeader> <DetailTableRow> - <DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t($ => $['deployTab.col.environment'])}</DetailTableHead> - <DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.status}>{t($ => $['deployTab.col.status'])}</DetailTableHead> - <DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.currentRelease}>{t($ => $['deployTab.col.currentRelease'])}</DetailTableHead> - <DetailTableHead className={`${DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions} text-right`}>{t($ => $['deployTab.col.actions'])}</DetailTableHead> + <DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}> + {t(($) => $['deployTab.col.environment'])} + </DetailTableHead> + <DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.status}> + {t(($) => $['deployTab.col.status'])} + </DetailTableHead> + <DetailTableHead + className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.currentRelease} + > + {t(($) => $['deployTab.col.currentRelease'])} + </DetailTableHead> + <DetailTableHead + className={`${DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions} text-right`} + > + {t(($) => $['deployTab.col.actions'])} + </DetailTableHead> </DetailTableRow> </DetailTableHeader> <DetailTableBody> diff --git a/web/features/deployments/detail/instances/environment-list/deployment-status-summary.tsx b/web/features/deployments/detail/instances/environment-list/deployment-status-summary.tsx index 0da24cd9e0f4cf..b16f2435863dc7 100644 --- a/web/features/deployments/detail/instances/environment-list/deployment-status-summary.tsx +++ b/web/features/deployments/detail/instances/environment-list/deployment-status-summary.tsx @@ -3,14 +3,10 @@ import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen' import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen' import { useTranslation } from 'react-i18next' -import { - isUndeployedDeploymentRow, -} from '../../../shared/domain/runtime-status' +import { isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status' import { DeploymentStatusBadge } from '../../../shared/ui/deployment-status-badge' -export function DeploymentStatusSummary({ row }: { - row: EnvironmentDeployment -}) { +export function DeploymentStatusSummary({ row }: { row: EnvironmentDeployment }) { const { t } = useTranslation('deployments') const status = row.status @@ -18,7 +14,7 @@ export function DeploymentStatusSummary({ row }: { return ( <DeploymentStatusBadge status={status} - label={t($ => $[`status.${status}`])} + label={t(($) => $[`status.${status}`])} variant="status-dot" /> ) @@ -28,7 +24,7 @@ export function DeploymentStatusSummary({ row }: { return ( <DeploymentStatusBadge status={RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYED} - label={t($ => $[`status.${RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYED}`])} + label={t(($) => $[`status.${RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYED}`])} variant="status-dot" /> ) @@ -40,7 +36,7 @@ export function DeploymentStatusSummary({ row }: { return ( <DeploymentStatusBadge status={RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING} - label={t($ => $[`status.${RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING}`])} + label={t(($) => $[`status.${RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING}`])} variant="status-dot" /> ) @@ -49,7 +45,9 @@ export function DeploymentStatusSummary({ row }: { return ( <DeploymentStatusBadge status={status} - label={t($ => $['deployTab.status.deployingRelease'], { release: targetRelease.displayName })} + label={t(($) => $['deployTab.status.deployingRelease'], { + release: targetRelease.displayName, + })} variant="status-dot" /> ) @@ -60,7 +58,14 @@ export function DeploymentStatusSummary({ row }: { return ( <DeploymentStatusBadge status={status} - label={t($ => $[hasRunningRelease ? 'deployTab.status.runningWithFailed' : 'deployTab.status.deployFailed'])} + label={t( + ($) => + $[ + hasRunningRelease + ? 'deployTab.status.runningWithFailed' + : 'deployTab.status.deployFailed' + ], + )} variant="status-dot" /> ) @@ -71,9 +76,11 @@ export function DeploymentStatusSummary({ row }: { return ( <DeploymentStatusBadge status={status} - label={hasRunningRelease - ? t($ => $['deployTab.status.runningOutOfSync']) - : t($ => $['status.RUNTIME_INSTANCE_STATUS_DRIFTED'])} + label={ + hasRunningRelease + ? t(($) => $['deployTab.status.runningOutOfSync']) + : t(($) => $['status.RUNTIME_INSTANCE_STATUS_DRIFTED']) + } variant="status-dot" /> ) @@ -83,7 +90,7 @@ export function DeploymentStatusSummary({ row }: { return ( <DeploymentStatusBadge status={status} - label={t($ => $[`status.${status}`])} + label={t(($) => $[`status.${status}`])} variant="status-dot" /> ) @@ -93,7 +100,7 @@ export function DeploymentStatusSummary({ row }: { return ( <DeploymentStatusBadge status={status} - label={t($ => $[`status.${status}`])} + label={t(($) => $[`status.${status}`])} variant="status-dot" /> ) @@ -102,7 +109,7 @@ export function DeploymentStatusSummary({ row }: { return ( <DeploymentStatusBadge status={RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY} - label={t($ => $[`status.${RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY}`])} + label={t(($) => $[`status.${RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY}`])} variant="status-dot" /> ) diff --git a/web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx b/web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx index b3b1b9a8e5f1f8..de5fa4d6748512 100644 --- a/web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx +++ b/web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx @@ -23,13 +23,12 @@ export function NewDeploymentButton() { className="gap-1.5" disabled={!appInstanceId} onClick={() => { - if (!appInstanceId) - return + if (!appInstanceId) return openDeployDrawer({ appInstanceId }) }} > <span className="i-ri-rocket-line size-4 shrink-0" aria-hidden="true" /> - {t($ => $['deployTab.newDeployment'])} + {t(($) => $['deployTab.newDeployment'])} </Button> ) } @@ -39,8 +38,7 @@ export function NewDeploymentHeaderAction() { const hasError = useAtomValue(deploymentEnvironmentDeploymentsIsErrorAtom) const rows = useAtomValue(deploymentRuntimeInstanceRowsAtom) - if (isLoading || hasError || rows.length === 0) - return null + if (isLoading || hasError || rows.length === 0) return null return <NewDeploymentButton /> } diff --git a/web/features/deployments/detail/instances/index.tsx b/web/features/deployments/detail/instances/index.tsx index 39441d806d4c3f..be92c485cbe467 100644 --- a/web/features/deployments/detail/instances/index.tsx +++ b/web/features/deployments/detail/instances/index.tsx @@ -30,7 +30,7 @@ function DeploymentEnvironmentListSkeleton() { return ( <> <DetailTableCardList className="pc:hidden"> - {DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => ( + {DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map((key) => ( <DetailTableCard key={key}> <div className="flex flex-col gap-3 p-4"> <div className="flex min-w-0 flex-col gap-1.5"> @@ -53,14 +53,26 @@ function DeploymentEnvironmentListSkeleton() { <DetailTable> <DetailTableHeader> <DetailTableRow> - <DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t($ => $['deployTab.col.environment'])}</DetailTableHead> - <DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.status}>{t($ => $['deployTab.col.status'])}</DetailTableHead> - <DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.currentRelease}>{t($ => $['deployTab.col.currentRelease'])}</DetailTableHead> - <DetailTableHead className={`${DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions} text-right`}>{t($ => $['deployTab.col.actions'])}</DetailTableHead> + <DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}> + {t(($) => $['deployTab.col.environment'])} + </DetailTableHead> + <DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.status}> + {t(($) => $['deployTab.col.status'])} + </DetailTableHead> + <DetailTableHead + className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.currentRelease} + > + {t(($) => $['deployTab.col.currentRelease'])} + </DetailTableHead> + <DetailTableHead + className={`${DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions} text-right`} + > + {t(($) => $['deployTab.col.actions'])} + </DetailTableHead> </DetailTableRow> </DetailTableHeader> <DetailTableBody> - {DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => ( + {DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map((key) => ( <DetailTableRow key={key}> <DetailTableCell> <SkeletonRectangle className="h-3 w-32 animate-pulse" /> @@ -96,22 +108,22 @@ export function DeploymentInstances() { return ( <div className="flex w-full min-w-0 flex-col gap-4 px-6 py-6"> - {isLoading - ? <DeploymentEnvironmentListSkeleton /> - : hasError - ? <DeploymentStateMessage variant="list">{t($ => $['common.loadFailed'])}</DeploymentStateMessage> - : rows.length === 0 - ? ( - <DeploymentEmptyState - icon="i-ri-server-line" - title={t($ => $['deployTab.emptyTitle'])} - description={t($ => $['deployTab.emptyDescription'])} - action={<NewDeploymentButton />} - /> - ) - : ( - <DeploymentEnvironmentList rows={rows} /> - )} + {isLoading ? ( + <DeploymentEnvironmentListSkeleton /> + ) : hasError ? ( + <DeploymentStateMessage variant="list"> + {t(($) => $['common.loadFailed'])} + </DeploymentStateMessage> + ) : rows.length === 0 ? ( + <DeploymentEmptyState + icon="i-ri-server-line" + title={t(($) => $['deployTab.emptyTitle'])} + description={t(($) => $['deployTab.emptyDescription'])} + action={<NewDeploymentButton />} + /> + ) : ( + <DeploymentEnvironmentList rows={rows} /> + )} </div> ) } diff --git a/web/features/deployments/detail/instances/row-actions/deployment-error-dialog.tsx b/web/features/deployments/detail/instances/row-actions/deployment-error-dialog.tsx index 626807835bd2ee..956869613693d5 100644 --- a/web/features/deployments/detail/instances/row-actions/deployment-error-dialog.tsx +++ b/web/features/deployments/detail/instances/row-actions/deployment-error-dialog.tsx @@ -11,27 +11,25 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { useTranslation } from 'react-i18next' -function DeploymentErrorDetails({ error }: { - error?: EnvironmentDeployment['error'] -}) { +function DeploymentErrorDetails({ error }: { error?: EnvironmentDeployment['error'] }) { const { t } = useTranslation('deployments') - const message = error?.message?.trim() || t($ => $['deployTab.panel.unknownError']) + const message = error?.message?.trim() || t(($) => $['deployTab.panel.unknownError']) const metadata = [ - ...(error?.phase ? [{ label: t($ => $['deployTab.errorPhase']), value: error.phase }] : []), - ...(error?.code ? [{ label: t($ => $['deployTab.errorCode']), value: error.code }] : []), + ...(error?.phase ? [{ label: t(($) => $['deployTab.errorPhase']), value: error.phase }] : []), + ...(error?.code ? [{ label: t(($) => $['deployTab.errorCode']), value: error.code }] : []), ] return ( <div className="rounded-xl border border-divider-subtle bg-background-default-subtle p-3"> <div className="system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['deployTab.errorMessage'])} + {t(($) => $['deployTab.errorMessage'])} </div> <div className="mt-1 system-sm-regular break-words whitespace-pre-wrap text-text-secondary"> {message} </div> {metadata.length > 0 && ( <div className="mt-3 flex flex-wrap gap-2"> - {metadata.map(item => ( + {metadata.map((item) => ( <span key={item.label} className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-divider-subtle bg-background-default px-2 py-1 system-xs-regular text-text-tertiary" @@ -46,7 +44,11 @@ function DeploymentErrorDetails({ error }: { ) } -export function DeploymentErrorDialog({ open, row, onOpenChange }: { +export function DeploymentErrorDialog({ + open, + row, + onOpenChange, +}: { open: boolean row: EnvironmentDeployment onOpenChange: (open: boolean) => void @@ -58,16 +60,16 @@ export function DeploymentErrorDialog({ open, row, onOpenChange }: { <AlertDialogContent className="w-120"> <div className="flex flex-col gap-3 px-6 pt-6 pb-2"> <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['deployTab.errorDialogTitle'], { name: row.environment.displayName })} + {t(($) => $['deployTab.errorDialogTitle'], { name: row.environment.displayName })} </AlertDialogTitle> <AlertDialogDescription className="system-sm-regular text-text-tertiary"> - {t($ => $['deployTab.errorDialogDesc'])} + {t(($) => $['deployTab.errorDialogDesc'])} </AlertDialogDescription> <DeploymentErrorDetails error={row.error} /> </div> <AlertDialogActions className="pt-3"> <AlertDialogCancelButton variant="secondary"> - {t($ => $['deployTab.closeError'])} + {t(($) => $['deployTab.closeError'])} </AlertDialogCancelButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/features/deployments/detail/instances/row-actions/deployment-row-actions-menu.tsx b/web/features/deployments/detail/instances/row-actions/deployment-row-actions-menu.tsx index d8e266bc648d5c..7103fb6fd8c62b 100644 --- a/web/features/deployments/detail/instances/row-actions/deployment-row-actions-menu.tsx +++ b/web/features/deployments/detail/instances/row-actions/deployment-row-actions-menu.tsx @@ -38,8 +38,7 @@ export function DeploymentActionsDropdown({ const { t } = useTranslation('deployments') const [open, setOpen] = useState(false) - if (isDeploymentInProgress) - return null + if (isDeploymentInProgress) return null function handleDeployAction(releaseId?: string) { onDeploy(releaseId) @@ -52,8 +51,7 @@ export function DeploymentActionsDropdown({ } function handleRequestUndeploy() { - if (undeployActionDisabled) - return + if (undeployActionDisabled) return onRequestUndeploy() setOpen(false) @@ -62,54 +60,61 @@ export function DeploymentActionsDropdown({ return ( <DropdownMenu modal={false} open={open} onOpenChange={setOpen}> <DropdownMenuTrigger - aria-label={t($ => $['deployTab.moreActions'])} + aria-label={t(($) => $['deployTab.moreActions'])} className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME} > <span aria-hidden className="i-ri-more-fill size-4" /> </DropdownMenuTrigger> {open && ( <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44"> - {isDeployFailed - ? ( - <> - <DropdownMenuItem - className="gap-2 px-3" - onClick={handleViewError} - > - <span aria-hidden className="i-ri-error-warning-line size-4 shrink-0 text-text-tertiary" /> - <span className="system-sm-regular text-text-secondary">{t($ => $['deployTab.viewError'])}</span> - </DropdownMenuItem> - <DropdownMenuItem - className="gap-2 px-3" - onClick={() => handleDeployAction(failedReleaseId)} - > - <span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" /> - <span className="system-sm-regular text-text-secondary"> - {failedReleaseId ? t($ => $['deployTab.retry']) : t($ => $['deployTab.deployOtherVersion'])} - </span> - </DropdownMenuItem> - </> - ) - : ( - <> - {!isUndeployed && currentReleaseId && ( - <DropdownMenuItem - className="gap-2 px-3" - onClick={() => handleDeployAction(currentReleaseId)} - > - <span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" /> - <span className="system-sm-regular text-text-secondary">{t($ => $['deployTab.redeploy'])}</span> - </DropdownMenuItem> - )} - <DropdownMenuItem - className="gap-2 px-3" - onClick={() => handleDeployAction()} - > - <span aria-hidden className="i-ri-rocket-line size-4 shrink-0 text-text-tertiary" /> - <span className="system-sm-regular text-text-secondary">{deployActionLabel}</span> - </DropdownMenuItem> - </> + {isDeployFailed ? ( + <> + <DropdownMenuItem className="gap-2 px-3" onClick={handleViewError}> + <span + aria-hidden + className="i-ri-error-warning-line size-4 shrink-0 text-text-tertiary" + /> + <span className="system-sm-regular text-text-secondary"> + {t(($) => $['deployTab.viewError'])} + </span> + </DropdownMenuItem> + <DropdownMenuItem + className="gap-2 px-3" + onClick={() => handleDeployAction(failedReleaseId)} + > + <span + aria-hidden + className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" + /> + <span className="system-sm-regular text-text-secondary"> + {failedReleaseId + ? t(($) => $['deployTab.retry']) + : t(($) => $['deployTab.deployOtherVersion'])} + </span> + </DropdownMenuItem> + </> + ) : ( + <> + {!isUndeployed && currentReleaseId && ( + <DropdownMenuItem + className="gap-2 px-3" + onClick={() => handleDeployAction(currentReleaseId)} + > + <span + aria-hidden + className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" + /> + <span className="system-sm-regular text-text-secondary"> + {t(($) => $['deployTab.redeploy'])} + </span> + </DropdownMenuItem> )} + <DropdownMenuItem className="gap-2 px-3" onClick={() => handleDeployAction()}> + <span aria-hidden className="i-ri-rocket-line size-4 shrink-0 text-text-tertiary" /> + <span className="system-sm-regular text-text-secondary">{deployActionLabel}</span> + </DropdownMenuItem> + </> + )} {!isUndeployed && ( <> <DropdownMenuSeparator /> @@ -124,7 +129,7 @@ export function DeploymentActionsDropdown({ onClick={handleRequestUndeploy} > <span aria-hidden className="i-ri-logout-box-line size-4 shrink-0" /> - <span className="system-sm-regular">{t($ => $['deployTab.undeploy'])}</span> + <span className="system-sm-regular">{t(($) => $['deployTab.undeploy'])}</span> </DropdownMenuItem> </> )} diff --git a/web/features/deployments/detail/instances/row-actions/deployment-row-actions.tsx b/web/features/deployments/detail/instances/row-actions/deployment-row-actions.tsx index 9d9fa07d7db2b3..a106fff014e15a 100644 --- a/web/features/deployments/detail/instances/row-actions/deployment-row-actions.tsx +++ b/web/features/deployments/detail/instances/row-actions/deployment-row-actions.tsx @@ -10,19 +10,27 @@ import { consoleQuery } from '@/service/client' import { openDeployDrawerAtom } from '../../../deploy-drawer/state' import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' import { createDeploymentIdempotencyKey } from '../../../shared/domain/idempotency' -import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status' +import { + isRuntimeDeploymentInProgress, + isUndeployedDeploymentRow, +} from '../../../shared/domain/runtime-status' import { DeploymentErrorDialog } from './deployment-error-dialog' import { DeploymentActionsDropdown } from './deployment-row-actions-menu' import { UndeployDeploymentDialog } from './undeploy-deployment-dialog' -export function DeploymentRowActions({ envId, row }: { +export function DeploymentRowActions({ + envId, + row, +}: { envId: string row: EnvironmentDeployment }) { const { t } = useTranslation('deployments') const routeAppInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) const openDeployDrawer = useSetAtom(openDeployDrawerAtom) - const undeployDeployment = useMutation(consoleQuery.enterprise.deploymentService.undeploy.mutationOptions()) + const undeployDeployment = useMutation( + consoleQuery.enterprise.deploymentService.undeploy.mutationOptions(), + ) const [showUndeployConfirm, setShowUndeployConfirm] = useState(false) const [showErrorDetail, setShowErrorDetail] = useState(false) const isUndeployed = isUndeployedDeploymentRow(row) @@ -34,11 +42,10 @@ export function DeploymentRowActions({ envId, row }: { const currentReleaseId = row.currentRelease?.id const failedReleaseId = row.desiredRelease?.id ?? row.currentRelease?.id const deployActionLabel = isUndeployed - ? t($ => $['deployDrawer.deploy']) - : t($ => $['deployTab.deployOtherVersion']) + ? t(($) => $['deployDrawer.deploy']) + : t(($) => $['deployTab.deployOtherVersion']) - if (!routeAppInstanceId) - return null + if (!routeAppInstanceId) return null const appInstanceId = routeAppInstanceId @@ -47,8 +54,7 @@ export function DeploymentRowActions({ envId, row }: { } function handleUndeploy() { - if (isUndeployRequesting) - return + if (isUndeployRequesting) return undeployDeployment.mutate( { @@ -71,8 +77,8 @@ export function DeploymentRowActions({ envId, row }: { <div role="presentation" className="flex shrink-0 items-center" - onClick={e => e.stopPropagation()} - onKeyDown={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} > <DeploymentActionsDropdown currentReleaseId={currentReleaseId} @@ -88,11 +94,7 @@ export function DeploymentRowActions({ envId, row }: { /> {isDeployFailed && ( - <DeploymentErrorDialog - open={showErrorDetail} - row={row} - onOpenChange={setShowErrorDetail} - /> + <DeploymentErrorDialog open={showErrorDetail} row={row} onOpenChange={setShowErrorDetail} /> )} {!isUndeployed && !isDeploymentInProgress && ( diff --git a/web/features/deployments/detail/instances/row-actions/undeploy-deployment-dialog.tsx b/web/features/deployments/detail/instances/row-actions/undeploy-deployment-dialog.tsx index 7047a8978c787a..928a04e255bd6d 100644 --- a/web/features/deployments/detail/instances/row-actions/undeploy-deployment-dialog.tsx +++ b/web/features/deployments/detail/instances/row-actions/undeploy-deployment-dialog.tsx @@ -30,8 +30,7 @@ export function UndeployDeploymentDialog({ const { t } = useTranslation('deployments') function handleOpenChange(nextOpen: boolean) { - if (isRequesting) - return + if (isRequesting) return onOpenChange(nextOpen) } @@ -41,22 +40,18 @@ export function UndeployDeploymentDialog({ <AlertDialogContent className="w-120"> <div className="flex flex-col gap-3 px-6 pt-6 pb-2"> <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['deployTab.undeployConfirmTitle'], { name: row.environment.displayName })} + {t(($) => $['deployTab.undeployConfirmTitle'], { name: row.environment.displayName })} </AlertDialogTitle> <AlertDialogDescription className="system-sm-regular text-text-tertiary"> - {t($ => $['deployTab.undeployConfirmDesc'])} + {t(($) => $['deployTab.undeployConfirmDesc'])} </AlertDialogDescription> </div> <AlertDialogActions className="pt-3"> <AlertDialogCancelButton variant="secondary" disabled={isRequesting}> - {t($ => $['deployDrawer.cancel'])} + {t(($) => $['deployDrawer.cancel'])} </AlertDialogCancelButton> - <AlertDialogConfirmButton - loading={isRequesting} - disabled={disabled} - onClick={onConfirm} - > - {t($ => $['deployTab.confirmUndeploy'])} + <AlertDialogConfirmButton loading={isRequesting} disabled={disabled} onClick={onConfirm}> + {t(($) => $['deployTab.confirmUndeploy'])} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/features/deployments/detail/overview/__tests__/state.spec.ts b/web/features/deployments/detail/overview/__tests__/state.spec.ts index 30df06f0855d1d..5a058fadf7314c 100644 --- a/web/features/deployments/detail/overview/__tests__/state.spec.ts +++ b/web/features/deployments/detail/overview/__tests__/state.spec.ts @@ -11,14 +11,15 @@ type QueryOptions = { } vi.mock('jotai-tanstack-query', () => ({ - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ - ...createOptions(get), - data: undefined, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: false, - })), + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => + atom((get) => ({ + ...createOptions(get), + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + })), })) vi.mock('@/service/client', () => ({ @@ -40,7 +41,10 @@ async function loadState() { return await import('../state') } -function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') { +function setDeploymentRoute( + store: ReturnType<typeof createStore>, + appInstanceId = 'app-instance-1', +) { store.set(setNextRouteStateAtom, { pathname: `/deployments/${appInstanceId}/overview`, params: { appInstanceId }, diff --git a/web/features/deployments/detail/overview/access-summary/access-status-section.tsx b/web/features/deployments/detail/overview/access-summary/access-status-section.tsx index e31d33fb0f3b0e..85b62983eeb97a 100644 --- a/web/features/deployments/detail/overview/access-summary/access-status-section.tsx +++ b/web/features/deployments/detail/overview/access-summary/access-status-section.tsx @@ -30,47 +30,48 @@ type AccessStatusItem = { } const ACCESS_STATUS_SKELETON_KEYS = ['webapp', 'cli'] -const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' +const OVERVIEW_CARD_CLASS_NAME = + 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' const OVERVIEW_INTERACTIVE_CARD_CLASS_NAME = cn( OVERVIEW_CARD_CLASS_NAME, 'transition-colors hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg', ) -const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary' +const OVERVIEW_ICON_CLASS_NAME = + 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary' export function AccessStatusSection({ accessChannels }: AccessStatusSectionProps) { const { t } = useTranslation('deployments') const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) - if (!appInstanceId) - return null + if (!appInstanceId) return null const items: AccessStatusItem[] = [ { key: 'webapp', href: `/deployments/${appInstanceId}/access`, icon: 'i-ri-global-line', - label: t($ => $['card.access.webApp']), + label: t(($) => $['card.access.webApp']), enabled: Boolean(accessChannels?.webAppEnabled), - meta: t($ => $['overview.accessMeta.webApp']), + meta: t(($) => $['overview.accessMeta.webApp']), }, { key: 'cli', href: `/deployments/${appInstanceId}/access`, icon: 'i-ri-terminal-box-line', - label: t($ => $['card.access.cli']), + label: t(($) => $['card.access.cli']), enabled: Boolean(accessChannels?.webAppEnabled), - meta: t($ => $['overview.accessMeta.cli']), + meta: t(($) => $['overview.accessMeta.cli']), }, ] return ( <section className="flex min-w-0 flex-col gap-3"> <h3 className="system-sm-semibold text-text-primary"> - {t($ => $['overview.accessStatus'])} + {t(($) => $['overview.accessStatus'])} </h3> <div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-3"> - {items.map(item => ( + {items.map((item) => ( <Link key={item.key} href={item.href} @@ -79,17 +80,12 @@ export function AccessStatusSection({ accessChannels }: AccessStatusSectionProps 'group flex min-h-18 min-w-0 items-start gap-3', )} > - <span - aria-hidden - className={OVERVIEW_ICON_CLASS_NAME} - > + <span aria-hidden className={OVERVIEW_ICON_CLASS_NAME}> <span className={cn('size-4', item.icon)} /> </span> <span className="flex min-w-0 flex-1 flex-col gap-1"> <span className="flex min-w-0 items-center justify-between gap-3"> - <span className="truncate system-sm-medium text-text-primary"> - {item.label} - </span> + <span className="truncate system-sm-medium text-text-primary">{item.label}</span> <span className="flex shrink-0 items-center gap-2"> <StatusBadge enabled={item.enabled} /> <span @@ -98,9 +94,7 @@ export function AccessStatusSection({ accessChannels }: AccessStatusSectionProps /> </span> </span> - <span className="truncate text-xs text-text-tertiary"> - {item.meta} - </span> + <span className="truncate text-xs text-text-tertiary">{item.meta}</span> </span> </Link> ))} @@ -119,14 +113,11 @@ export function ApiTokenSummarySection({ const apiEnabled = Boolean(accessChannels?.developerApiEnabled) const apiKeyCount = apiKeySummary?.apiKeyCount ?? 0 - if (!appInstanceId) - return null + if (!appInstanceId) return null return ( <section className="flex min-w-0 flex-col gap-3"> - <h3 className="system-sm-semibold text-text-primary"> - {t($ => $['overview.api'])} - </h3> + <h3 className="system-sm-semibold text-text-primary">{t(($) => $['overview.api'])}</h3> <Link href={`/deployments/${appInstanceId}/api-tokens`} @@ -141,7 +132,7 @@ export function ApiTokenSummarySection({ <span className="flex min-w-0 flex-1 flex-col gap-2"> <span className="flex min-w-0 items-center justify-between gap-3"> <span className="truncate system-sm-medium text-text-primary"> - {t($ => $['card.access.api'])} + {t(($) => $['card.access.api'])} </span> <span className="flex shrink-0 items-center gap-2"> <StatusBadge enabled={apiEnabled} /> @@ -151,38 +142,40 @@ export function ApiTokenSummarySection({ /> </span> </span> - {apiEnabled - ? ( - <span className="flex min-w-0 flex-wrap gap-2"> - <span className="inline-flex h-6 min-w-0 items-center rounded-md bg-background-section-burn px-2 system-xs-medium text-text-secondary"> - {t($ => $['overview.apiKeysCount'], { count: apiKeyCount })} - </span> - {/* "deployed environments" = envs with a runtime deployment, not envs-with-keys */} - <span className="inline-flex h-6 min-w-0 items-center rounded-md bg-background-section-burn px-2 system-xs-medium text-text-secondary"> - {t($ => $['overview.apiTokenSummary.environments'], { count: deployedEnvironmentCount })} - </span> - </span> - ) - : ( - <span className="truncate text-xs text-text-tertiary"> - {t($ => $['overview.accessMeta.apiTokens'])} - </span> - )} + {apiEnabled ? ( + <span className="flex min-w-0 flex-wrap gap-2"> + <span className="inline-flex h-6 min-w-0 items-center rounded-md bg-background-section-burn px-2 system-xs-medium text-text-secondary"> + {t(($) => $['overview.apiKeysCount'], { count: apiKeyCount })} + </span> + {/* "deployed environments" = envs with a runtime deployment, not envs-with-keys */} + <span className="inline-flex h-6 min-w-0 items-center rounded-md bg-background-section-burn px-2 system-xs-medium text-text-secondary"> + {t(($) => $['overview.apiTokenSummary.environments'], { + count: deployedEnvironmentCount, + })} + </span> + </span> + ) : ( + <span className="truncate text-xs text-text-tertiary"> + {t(($) => $['overview.accessMeta.apiTokens'])} + </span> + )} </span> </Link> </section> ) } -function StatusBadge({ enabled }: { - enabled: boolean -}) { +function StatusBadge({ enabled }: { enabled: boolean }) { const { t } = useTranslation('deployments') return ( <DeploymentStatusBadge - status={enabled ? RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY : RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYED} - label={enabled ? t($ => $['overview.enabled']) : t($ => $['overview.disabled'])} + status={ + enabled + ? RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY + : RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYED + } + label={enabled ? t(($) => $['overview.enabled']) : t(($) => $['overview.disabled'])} /> ) } @@ -192,9 +185,7 @@ export function ApiTokenSummarySectionSkeleton() { return ( <section className="flex min-w-0 flex-col gap-3"> - <h3 className="system-sm-semibold text-text-primary"> - {t($ => $['overview.api'])} - </h3> + <h3 className="system-sm-semibold text-text-primary">{t(($) => $['overview.api'])}</h3> <ApiTokenSummaryCardSkeleton /> </section> ) @@ -227,11 +218,11 @@ export function AccessStatusSectionSkeleton() { return ( <section className="flex min-w-0 flex-col gap-3"> <h3 className="system-sm-semibold text-text-primary"> - {t($ => $['overview.accessStatus'])} + {t(($) => $['overview.accessStatus'])} </h3> <div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-3"> - {ACCESS_STATUS_SKELETON_KEYS.map(key => ( + {ACCESS_STATUS_SKELETON_KEYS.map((key) => ( <div key={key} data-slot="deployment-overview-access-card-skeleton" diff --git a/web/features/deployments/detail/overview/environment-status/__tests__/environment-tile-utils.spec.ts b/web/features/deployments/detail/overview/environment-status/__tests__/environment-tile-utils.spec.ts index 5e42faed083d88..92303105e9b856 100644 --- a/web/features/deployments/detail/overview/environment-status/__tests__/environment-tile-utils.spec.ts +++ b/web/features/deployments/detail/overview/environment-status/__tests__/environment-tile-utils.spec.ts @@ -72,26 +72,30 @@ describe('resolveConfig', () => { }) it('resolves up-to-date and behind environments to drawer actions', () => { - expect(resolveConfig({ - drift: { kind: 'up-to-date' }, - status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY, - hasAnyRelease: true, - latestId: 'release-2', - currentReleaseId: 'release-2', - })).toMatchObject({ + expect( + resolveConfig({ + drift: { kind: 'up-to-date' }, + status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY, + hasAnyRelease: true, + latestId: 'release-2', + currentReleaseId: 'release-2', + }), + ).toMatchObject({ kind: 'latest', status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY, intent: 'drawer', releaseId: 'release-2', }) - expect(resolveConfig({ - drift: { kind: 'behind', steps: 2 }, - status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY, - hasAnyRelease: true, - latestId: 'release-3', - currentReleaseId: 'release-1', - })).toMatchObject({ + expect( + resolveConfig({ + drift: { kind: 'behind', steps: 2 }, + status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY, + hasAnyRelease: true, + latestId: 'release-3', + currentReleaseId: 'release-1', + }), + ).toMatchObject({ kind: 'behind', status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_DRIFTED, intent: 'drawer', @@ -132,13 +136,23 @@ describe('environment tile copy helpers', () => { expect(renderStatus('older', { kind: 'unknown' }, t)).toBe('overview.chip.olderRelease') expect(renderStatus('deploying', { kind: 'unknown' }, t)).toBe('overview.chip.deploying') expect(renderStatus('failed', { kind: 'unknown' }, t)).toBe('overview.chip.failed') - expect(renderStatus('behind', { kind: 'behind', steps: 3 }, t)).toBe('overview.chip.behind:{"count":3}') - - expect(renderDriftTitle('latest', { kind: 'up-to-date' }, t)).toBe('overview.chip.latestTooltip') - expect(renderDriftTitle('behind', { kind: 'behind', steps: 2 }, t)).toBe('overview.chip.behindTooltip:{"count":2}') - expect(renderDriftTitle('older', { kind: 'unknown' }, t)).toBe('overview.chip.olderReleaseTooltip') + expect(renderStatus('behind', { kind: 'behind', steps: 3 }, t)).toBe( + 'overview.chip.behind:{"count":3}', + ) + + expect(renderDriftTitle('latest', { kind: 'up-to-date' }, t)).toBe( + 'overview.chip.latestTooltip', + ) + expect(renderDriftTitle('behind', { kind: 'behind', steps: 2 }, t)).toBe( + 'overview.chip.behindTooltip:{"count":2}', + ) + expect(renderDriftTitle('older', { kind: 'unknown' }, t)).toBe( + 'overview.chip.olderReleaseTooltip', + ) expect(renderDriftTitle('empty', { kind: 'undeployed' }, t)).toBe('overview.chip.emptyTooltip') - expect(renderDriftTitle('deploying', { kind: 'unknown' }, t)).toBe('overview.chip.deployingTooltip') + expect(renderDriftTitle('deploying', { kind: 'unknown' }, t)).toBe( + 'overview.chip.deployingTooltip', + ) expect(renderDriftTitle('failed', { kind: 'unknown' }, t)).toBe('overview.chip.failedTooltip') }) }) diff --git a/web/features/deployments/detail/overview/environment-status/environment-strip.tsx b/web/features/deployments/detail/overview/environment-status/environment-strip.tsx index d7e064c061cccb..140d414b729373 100644 --- a/web/features/deployments/detail/overview/environment-status/environment-strip.tsx +++ b/web/features/deployments/detail/overview/environment-status/environment-strip.tsx @@ -14,7 +14,8 @@ import { hasRuntimeInstanceDeployment } from '../../../shared/domain/runtime-sta import { EnvironmentTile } from './environment-tile' const OVERVIEW_RUNTIME_INSTANCE_LIMIT = 4 -const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' +const OVERVIEW_CARD_CLASS_NAME = + 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' type EnvironmentStripProps = { rows: EnvironmentDeployment[] @@ -32,38 +33,34 @@ export function EnvironmentStrip({ rows, releaseRows }: EnvironmentStripProps) { return ( <section className="flex flex-col gap-3"> <div className="flex min-w-0 items-baseline justify-between gap-3"> - <h3 className="system-sm-semibold text-text-primary">{t($ => $['overview.strip.title'])}</h3> + <h3 className="system-sm-semibold text-text-primary"> + {t(($) => $['overview.strip.title'])} + </h3> {hasRuntimeRows && appInstanceId && ( <Link href={`/deployments/${appInstanceId}/instances`} className="inline-flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary transition-colors hover:text-text-secondary" > - {t($ => $['overview.previousReleases.viewAll'])} + {t(($) => $['overview.previousReleases.viewAll'])} <span aria-hidden className="i-ri-arrow-right-line size-3.5" /> </Link> )} </div> - {!hasRuntimeRows - ? <EnvironmentEmptyState canDeploy={hasRelease} /> - : ( - <div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,360px),1fr))] gap-3"> - {previewRows.map(row => ( - <EnvironmentTile - key={row.environment.id} - row={row} - releaseRows={releaseRows} - /> - ))} - </div> - )} + {!hasRuntimeRows ? ( + <EnvironmentEmptyState canDeploy={hasRelease} /> + ) : ( + <div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,360px),1fr))] gap-3"> + {previewRows.map((row) => ( + <EnvironmentTile key={row.environment.id} row={row} releaseRows={releaseRows} /> + ))} + </div> + )} </section> ) } -function EnvironmentEmptyState({ canDeploy }: { - canDeploy: boolean -}) { +function EnvironmentEmptyState({ canDeploy }: { canDeploy: boolean }) { const { t } = useTranslation('deployments') const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) const openDeployDrawer = useSetAtom(openDeployDrawerAtom) @@ -72,23 +69,27 @@ function EnvironmentEmptyState({ canDeploy }: { <DeploymentEmptyState variant="section" icon="i-ri-server-line" - title={t($ => $['overview.strip.emptyTitle'])} - description={canDeploy ? t($ => $['overview.strip.emptyDeployableDescription']) : t($ => $['overview.strip.emptyDescription'])} + title={t(($) => $['overview.strip.emptyTitle'])} + description={ + canDeploy + ? t(($) => $['overview.strip.emptyDeployableDescription']) + : t(($) => $['overview.strip.emptyDescription']) + } className="min-h-44" - action={canDeploy && appInstanceId - ? ( - <Button - type="button" - variant="primary" - size="medium" - className="gap-1.5" - onClick={() => openDeployDrawer({ appInstanceId })} - > - <span className="i-ri-rocket-line size-4 shrink-0" aria-hidden="true" /> - {t($ => $['overview.strip.deployToNewEnvironment'])} - </Button> - ) - : undefined} + action={ + canDeploy && appInstanceId ? ( + <Button + type="button" + variant="primary" + size="medium" + className="gap-1.5" + onClick={() => openDeployDrawer({ appInstanceId })} + > + <span className="i-ri-rocket-line size-4 shrink-0" aria-hidden="true" /> + {t(($) => $['overview.strip.deployToNewEnvironment'])} + </Button> + ) : undefined + } /> ) } @@ -98,7 +99,7 @@ const SKELETON_KEYS = ['a', 'b', 'c'] function CardSkeletons() { return ( <div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,360px),1fr))] gap-3"> - {SKELETON_KEYS.map(key => ( + {SKELETON_KEYS.map((key) => ( <EnvironmentTileSkeleton key={key} /> ))} </div> @@ -109,7 +110,10 @@ function EnvironmentTileSkeleton() { return ( <article data-slot="deployment-overview-environment-tile-skeleton" - className={cn(OVERVIEW_CARD_CLASS_NAME, 'flex min-h-28 min-w-0 flex-col justify-between gap-4')} + className={cn( + OVERVIEW_CARD_CLASS_NAME, + 'flex min-h-28 min-w-0 flex-col justify-between gap-4', + )} > <div className="flex min-w-0 items-center justify-between gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3"> @@ -141,7 +145,9 @@ export function EnvironmentStripSkeleton() { return ( <section className="flex flex-col gap-3"> - <h3 className="system-sm-semibold text-text-primary">{t($ => $['overview.strip.title'])}</h3> + <h3 className="system-sm-semibold text-text-primary"> + {t(($) => $['overview.strip.title'])} + </h3> <CardSkeletons /> </section> ) diff --git a/web/features/deployments/detail/overview/environment-status/environment-tile-utils.ts b/web/features/deployments/detail/overview/environment-status/environment-tile-utils.ts index 926e7642aa863c..8ba0e33d2a3657 100644 --- a/web/features/deployments/detail/overview/environment-status/environment-tile-utils.ts +++ b/web/features/deployments/detail/overview/environment-status/environment-tile-utils.ts @@ -15,7 +15,13 @@ export type TileConfig = { releaseId?: string } -export function resolveConfig({ drift, status, hasAnyRelease, latestId, currentReleaseId }: { +export function resolveConfig({ + drift, + status, + hasAnyRelease, + latestId, + currentReleaseId, +}: { drift: ReturnType<typeof computeDrift> status: RuntimeInstanceStatusValue hasAnyRelease: boolean @@ -97,15 +103,15 @@ export function renderActionLabel( case 'empty': case 'older': case 'behind': - return t($ => $['overview.cardAction.deployLatest']) + return t(($) => $['overview.cardAction.deployLatest']) case 'latest': - return t($ => $['overview.cardAction.redeploy']) + return t(($) => $['overview.cardAction.redeploy']) case 'deploying': - return t($ => $['overview.cardAction.viewProgress']) + return t(($) => $['overview.cardAction.viewProgress']) case 'failed': return hasCurrentRelease - ? t($ => $['overview.cardAction.redeploy']) - : t($ => $['overview.cardAction.deployLatest']) + ? t(($) => $['overview.cardAction.redeploy']) + : t(($) => $['overview.cardAction.deployLatest']) } } @@ -116,17 +122,19 @@ export function renderStatus( ): string { switch (kind) { case 'empty': - return t($ => $['overview.chip.empty']) + return t(($) => $['overview.chip.empty']) case 'latest': - return t($ => $['overview.chip.latest']) + return t(($) => $['overview.chip.latest']) case 'behind': - return t($ => $['overview.chip.behind'], { count: drift.kind === 'behind' ? drift.steps : 0 }) + return t(($) => $['overview.chip.behind'], { + count: drift.kind === 'behind' ? drift.steps : 0, + }) case 'older': - return t($ => $['overview.chip.olderRelease']) + return t(($) => $['overview.chip.olderRelease']) case 'deploying': - return t($ => $['overview.chip.deploying']) + return t(($) => $['overview.chip.deploying']) case 'failed': - return t($ => $['overview.chip.failed']) + return t(($) => $['overview.chip.failed']) } } @@ -137,16 +145,18 @@ export function renderDriftTitle( ): string { switch (kind) { case 'latest': - return t($ => $['overview.chip.latestTooltip']) + return t(($) => $['overview.chip.latestTooltip']) case 'behind': - return t($ => $['overview.chip.behindTooltip'], { count: drift.kind === 'behind' ? drift.steps : 0 }) + return t(($) => $['overview.chip.behindTooltip'], { + count: drift.kind === 'behind' ? drift.steps : 0, + }) case 'older': - return t($ => $['overview.chip.olderReleaseTooltip']) + return t(($) => $['overview.chip.olderReleaseTooltip']) case 'empty': - return t($ => $['overview.chip.emptyTooltip']) + return t(($) => $['overview.chip.emptyTooltip']) case 'deploying': - return t($ => $['overview.chip.deployingTooltip']) + return t(($) => $['overview.chip.deployingTooltip']) case 'failed': - return t($ => $['overview.chip.failedTooltip']) + return t(($) => $['overview.chip.failedTooltip']) } } diff --git a/web/features/deployments/detail/overview/environment-status/environment-tile.tsx b/web/features/deployments/detail/overview/environment-status/environment-tile.tsx index 24cfe2b3436019..ec45f3b4eb05b4 100644 --- a/web/features/deployments/detail/overview/environment-status/environment-tile.tsx +++ b/web/features/deployments/detail/overview/environment-status/environment-tile.tsx @@ -15,9 +15,7 @@ import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' import { TitleTooltip } from '../../../shared/components/title-tooltip' import { releaseCommit } from '../../../shared/domain/release' import { DeploymentStatusBadge } from '../../../shared/ui/deployment-status-badge' -import { - deploymentStatusLabelKey, -} from '../../../shared/ui/deployment-status-style' +import { deploymentStatusLabelKey } from '../../../shared/ui/deployment-status-style' import { renderActionLabel, renderDriftTitle, @@ -26,12 +24,14 @@ import { } from './environment-tile-utils' import { computeDrift, latestReleaseId } from './overview-drift' -const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' +const OVERVIEW_CARD_CLASS_NAME = + 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' const OVERVIEW_INTERACTIVE_CARD_CLASS_NAME = cn( OVERVIEW_CARD_CLASS_NAME, 'transition-colors hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg', ) -const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary' +const OVERVIEW_ICON_CLASS_NAME = + 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary' type EnvironmentTileProps = { row: EnvironmentDeployment @@ -56,14 +56,13 @@ export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) { const showRelease = config.showRelease && release const commit = releaseCommit(release) const tooltip = isDisabled - ? t($ => $['overview.chip.needsReleaseFirst']) + ? t(($) => $['overview.chip.needsReleaseFirst']) : config.intent === 'navigate' - ? t($ => $['overview.chip.openInDeployTab']) + ? t(($) => $['overview.chip.openInDeployTab']) : undefined function handleDrawerAction() { - if (config.intent === 'disabled' || !appInstanceId) - return + if (config.intent === 'disabled' || !appInstanceId) return openDeployDrawer({ appInstanceId, environmentId: envId, releaseId: config.releaseId }) } @@ -73,30 +72,29 @@ export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) { isDisabled && 'cursor-not-allowed opacity-60', ) const actionLabel = renderActionLabel(config.kind, Boolean(currentReleaseId), t) - const actionControl = config.intent === 'navigate' && appInstanceId - ? ( - <Link - href={`/deployments/${appInstanceId}/instances`} - className={actionClassName} - > - <span className="whitespace-nowrap">{actionLabel}</span> - </Link> - ) - : ( - <button - type="button" - disabled={isDisabled || !appInstanceId} - onClick={handleDrawerAction} - className={actionClassName} - > - <span className="whitespace-nowrap">{actionLabel}</span> - </button> - ) + const actionControl = + config.intent === 'navigate' && appInstanceId ? ( + <Link href={`/deployments/${appInstanceId}/instances`} className={actionClassName}> + <span className="whitespace-nowrap">{actionLabel}</span> + </Link> + ) : ( + <button + type="button" + disabled={isDisabled || !appInstanceId} + onClick={handleDrawerAction} + className={actionClassName} + > + <span className="whitespace-nowrap">{actionLabel}</span> + </button> + ) return ( <article data-slot="deployment-overview-environment-tile" - className={cn(OVERVIEW_INTERACTIVE_CARD_CLASS_NAME, 'flex min-h-28 min-w-0 flex-col justify-between gap-4')} + className={cn( + OVERVIEW_INTERACTIVE_CARD_CLASS_NAME, + 'flex min-h-28 min-w-0 flex-col justify-between gap-4', + )} > <div className="flex min-w-0 items-center justify-between gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3"> @@ -116,7 +114,7 @@ export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) { <div className="flex min-w-0 items-end justify-between gap-3"> <div className="min-w-0"> <div className="system-2xs-medium-uppercase text-text-tertiary"> - {t($ => $['deployTab.col.currentRelease'])} + {t(($) => $['deployTab.col.currentRelease'])} </div> <div className="mt-1 flex min-w-0 items-center gap-2"> <span className="min-w-0 truncate system-sm-semibold text-text-primary"> @@ -130,25 +128,26 @@ export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) { </div> </div> - {tooltip - ? ( - <TitleTooltip content={tooltip}> - <span className="inline-flex max-w-full min-w-0 shrink-0"> - {actionControl} - </span> - </TitleTooltip> - ) - : actionControl} + {tooltip ? ( + <TitleTooltip content={tooltip}> + <span className="inline-flex max-w-full min-w-0 shrink-0">{actionControl}</span> + </TitleTooltip> + ) : ( + actionControl + )} </div> </article> ) } -function RuntimeStatusSignal({ status, t }: { +function RuntimeStatusSignal({ + status, + t, +}: { status: RuntimeInstanceStatusValue t: ReturnType<typeof useTranslation<'deployments'>>['t'] }) { - const label = t($ => $[deploymentStatusLabelKey(status)]) + const label = t(($) => $[deploymentStatusLabelKey(status)]) return ( <TitleTooltip content={label}> @@ -157,7 +156,12 @@ function RuntimeStatusSignal({ status, t }: { ) } -function StatusSignal({ className, config, drift, t }: { +function StatusSignal({ + className, + config, + drift, + t, +}: { className?: string config: TileConfig drift: ReturnType<typeof computeDrift> diff --git a/web/features/deployments/detail/overview/environment-status/overview-drift.ts b/web/features/deployments/detail/overview/environment-status/overview-drift.ts index 79ffc500625e12..78b988c82541bf 100644 --- a/web/features/deployments/detail/overview/environment-status/overview-drift.ts +++ b/web/features/deployments/detail/overview/environment-status/overview-drift.ts @@ -1,26 +1,22 @@ import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen' import { isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status' -export type Drift - = | { kind: 'undeployed' } - | { kind: 'unknown' } - | { kind: 'up-to-date' } - | { kind: 'behind', steps: number } +export type Drift = + | { kind: 'undeployed' } + | { kind: 'unknown' } + | { kind: 'up-to-date' } + | { kind: 'behind'; steps: number } export function computeDrift(row: EnvironmentDeployment): Drift { - if (isUndeployedDeploymentRow(row)) - return { kind: 'undeployed' } + if (isUndeployedDeploymentRow(row)) return { kind: 'undeployed' } - if (!row.currentRelease) - return { kind: 'unknown' } + if (!row.currentRelease) return { kind: 'unknown' } // releasesBehind is server-computed against the full release history (0 == up // to date). It is absent only when undetermined, which renders as unknown. const behind = row.releasesBehind - if (behind == null) - return { kind: 'unknown' } - if (behind === 0) - return { kind: 'up-to-date' } + if (behind == null) return { kind: 'unknown' } + if (behind === 0) return { kind: 'up-to-date' } return { kind: 'behind', steps: behind } } diff --git a/web/features/deployments/detail/overview/index.tsx b/web/features/deployments/detail/overview/index.tsx index 423472f5a08203..61dc5c9ed64998 100644 --- a/web/features/deployments/detail/overview/index.tsx +++ b/web/features/deployments/detail/overview/index.tsx @@ -6,7 +6,12 @@ import Link from '@/next/link' import { deploymentRouteAppInstanceIdAtom } from '../../route-state' import { DeploymentStateMessage } from '../../shared/components/empty-state' import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status' -import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './access-summary/access-status-section' +import { + AccessStatusSection, + AccessStatusSectionSkeleton, + ApiTokenSummarySection, + ApiTokenSummarySectionSkeleton, +} from './access-summary/access-status-section' import { EnvironmentStrip, EnvironmentStripSkeleton } from './environment-status/environment-strip' import { ReleaseHero, ReleaseHeroSkeleton } from './release-summary/release-hero' import { @@ -16,16 +21,10 @@ import { } from './state' function OverviewLayout({ children }: { children: React.ReactNode }) { - return ( - <div className="flex w-full min-w-0 flex-col gap-6 px-6 py-6"> - {children} - </div> - ) + return <div className="flex w-full min-w-0 flex-col gap-6 px-6 py-6">{children}</div> } -function LatestReleaseSection({ children }: { - children: React.ReactNode -}) { +function LatestReleaseSection({ children }: { children: React.ReactNode }) { const { t } = useTranslation('deployments') const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) @@ -33,21 +32,19 @@ function LatestReleaseSection({ children }: { <section className="flex min-w-0 flex-col gap-3"> <div className="flex min-w-0 items-baseline justify-between gap-3"> <h3 className="system-sm-semibold text-text-primary"> - {t($ => $['overview.latestReleaseTitle'])} + {t(($) => $['overview.latestReleaseTitle'])} </h3> {appInstanceId && ( <Link href={`/deployments/${appInstanceId}/releases`} className="inline-flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary transition-colors hover:text-text-secondary" > - {t($ => $['overview.previousReleases.viewAll'])} + {t(($) => $['overview.previousReleases.viewAll'])} <span aria-hidden className="i-ri-arrow-right-line size-3.5" /> </Link> )} </div> - <div className="flex min-w-0 flex-col gap-3"> - {children} - </div> + <div className="flex min-w-0 flex-col gap-3">{children}</div> </section> ) } @@ -71,13 +68,14 @@ export function DeploymentOverview() { const isLoading = useAtomValue(deploymentOverviewIsLoadingAtom) const isError = useAtomValue(deploymentOverviewIsErrorAtom) - if (isLoading) - return <OverviewLoadingSkeleton /> + if (isLoading) return <OverviewLoadingSkeleton /> if (isError) { return ( <OverviewLayout> - <DeploymentStateMessage variant="section">{t($ => $['common.loadFailed'])}</DeploymentStateMessage> + <DeploymentStateMessage variant="section"> + {t(($) => $['common.loadFailed'])} + </DeploymentStateMessage> </OverviewLayout> ) } @@ -85,7 +83,9 @@ export function DeploymentOverview() { if (!overview) { return ( <OverviewLayout> - <DeploymentStateMessage variant="section">{t($ => $['detail.notFound'])}</DeploymentStateMessage> + <DeploymentStateMessage variant="section"> + {t(($) => $['detail.notFound'])} + </DeploymentStateMessage> </OverviewLayout> ) } @@ -101,15 +101,9 @@ export function DeploymentOverview() { return ( <OverviewLayout> - <EnvironmentStrip - rows={runtimeRows} - releaseRows={releaseRows} - /> + <EnvironmentStrip rows={runtimeRows} releaseRows={releaseRows} /> <LatestReleaseSection> - <ReleaseHero - latestRelease={latestRelease} - releaseCount={releaseCount} - /> + <ReleaseHero latestRelease={latestRelease} releaseCount={releaseCount} /> </LatestReleaseSection> <AccessStatusSection accessChannels={accessChannels} /> <ApiTokenSummarySection diff --git a/web/features/deployments/detail/overview/release-summary/release-hero.tsx b/web/features/deployments/detail/overview/release-summary/release-hero.tsx index 790b2dcd78ff9a..c669cf014554bd 100644 --- a/web/features/deployments/detail/overview/release-summary/release-hero.tsx +++ b/web/features/deployments/detail/overview/release-summary/release-hero.tsx @@ -28,8 +28,10 @@ type ReleaseMetaItemProps = { children: ReactNode } -const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' -const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary' +const OVERVIEW_CARD_CLASS_NAME = + 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' +const OVERVIEW_ICON_CLASS_NAME = + 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary' export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) { const { t } = useTranslation('deployments') @@ -41,9 +43,13 @@ export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) { <DeploymentEmptyState variant="section" icon="i-ri-stack-line" - title={t($ => $['overview.hero.empty'])} - description={t($ => $['overview.hero.emptyDescription'])} - action={appInstanceId ? <CreateReleaseControl appInstanceId={appInstanceId} size="medium" /> : undefined} + title={t(($) => $['overview.hero.empty'])} + description={t(($) => $['overview.hero.emptyDescription'])} + action={ + appInstanceId ? ( + <CreateReleaseControl appInstanceId={appInstanceId} size="medium" /> + ) : undefined + } className="min-h-44" /> ) @@ -55,7 +61,12 @@ export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) { const commit = releaseCommit(latestRelease) return ( - <div className={cn(OVERVIEW_CARD_CLASS_NAME, 'flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6')}> + <div + className={cn( + OVERVIEW_CARD_CLASS_NAME, + 'flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6', + )} + > <div className="flex min-w-0 items-center gap-3"> <span aria-hidden className={OVERVIEW_ICON_CLASS_NAME}> <span className="i-ri-stack-fill size-4" /> @@ -66,7 +77,7 @@ export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) { {latestRelease.displayName} </h4> {commit !== '—' && ( - <TitleTooltip content={t($ => $['versions.commitTooltip'], { commit })}> + <TitleTooltip content={t(($) => $['versions.commitTooltip'], { commit })}> <span className="shrink-0 rounded bg-background-section-burn px-1.5 py-0.5 font-mono system-xs-regular text-text-tertiary"> {commit} </span> @@ -74,25 +85,23 @@ export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) { )} </div> <p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 system-xs-regular text-text-tertiary"> - <ReleaseMetaItem label={t($ => $['versions.col.sourceApp'])} showSeparator={false}> + <ReleaseMetaItem label={t(($) => $['versions.col.sourceApp'])} showSeparator={false}> <LatestReleaseSource release={latestRelease} /> </ReleaseMetaItem> {author && ( <ReleaseMetaItem> - {t($ => $['overview.hero.byName'], { name: author })} + {t(($) => $['overview.hero.byName'], { name: author })} </ReleaseMetaItem> )} {ago && ( <ReleaseMetaItem> <TitleTooltip content={createdAtTitle}> - <span> - {ago} - </span> + <span>{ago}</span> </TitleTooltip> </ReleaseMetaItem> )} <ReleaseMetaItem> - {t($ => $['overview.latestRelease.releaseCount'], { count: releaseCount })} + {t(($) => $['overview.latestRelease.releaseCount'], { count: releaseCount })} </ReleaseMetaItem> </p> </div> @@ -105,26 +114,26 @@ function ReleaseMetaItem({ label, showSeparator = true, children }: ReleaseMetaI return ( <span className="inline-flex min-w-0 items-center gap-1.5"> {showSeparator && ( - <span aria-hidden className="text-text-quaternary">·</span> - )} - {label && ( - <span className="shrink-0 text-text-quaternary">{label}</span> + <span aria-hidden className="text-text-quaternary"> + · + </span> )} + {label && <span className="shrink-0 text-text-quaternary">{label}</span>} <span className="min-w-0 truncate">{children}</span> </span> ) } -function LatestReleaseSource({ release }: { - release: Release -}) { +function LatestReleaseSource({ release }: { release: Release }) { const { t } = useTranslation('deployments') const sourceAppId = release.sourceAppId if (!sourceAppId) { return ( <span> - {release.source === ReleaseSource.RELEASE_SOURCE_UPLOAD ? t($ => $['versions.manualDslOption']) : '—'} + {release.source === ReleaseSource.RELEASE_SOURCE_UPLOAD + ? t(($) => $['versions.manualDslOption']) + : '—'} </span> ) } @@ -132,14 +141,14 @@ function LatestReleaseSource({ release }: { return <LatestReleaseSourceLink sourceAppId={sourceAppId} /> } -function LatestReleaseSourceLink({ sourceAppId }: { - sourceAppId: string -}) { - const sourceAppQuery = useQuery(consoleQuery.apps.byAppId.get.queryOptions({ - input: { - params: { app_id: sourceAppId }, - }, - })) +function LatestReleaseSourceLink({ sourceAppId }: { sourceAppId: string }) { + const sourceAppQuery = useQuery( + consoleQuery.apps.byAppId.get.queryOptions({ + input: { + params: { app_id: sourceAppId }, + }, + }), + ) const sourceAppName = sourceAppQuery.data?.name const label = sourceAppName || sourceAppId const title = sourceAppName ? `${sourceAppName} (${sourceAppId})` : sourceAppId diff --git a/web/features/deployments/detail/overview/state.ts b/web/features/deployments/detail/overview/state.ts index ee7cb68c3ce1d8..33b14907e55d64 100644 --- a/web/features/deployments/detail/overview/state.ts +++ b/web/features/deployments/detail/overview/state.ts @@ -19,6 +19,12 @@ export const deploymentOverviewQueryAtom = atomWithQuery((get) => { }) }) -export const deploymentOverviewAtom = selectAtom(deploymentOverviewQueryAtom, query => query.data) -export const deploymentOverviewIsLoadingAtom = selectAtom(deploymentOverviewQueryAtom, query => query.isLoading) -export const deploymentOverviewIsErrorAtom = selectAtom(deploymentOverviewQueryAtom, query => query.isError) +export const deploymentOverviewAtom = selectAtom(deploymentOverviewQueryAtom, (query) => query.data) +export const deploymentOverviewIsLoadingAtom = selectAtom( + deploymentOverviewQueryAtom, + (query) => query.isLoading, +) +export const deploymentOverviewIsErrorAtom = selectAtom( + deploymentOverviewQueryAtom, + (query) => query.isError, +) diff --git a/web/features/deployments/detail/releases/__tests__/state.spec.ts b/web/features/deployments/detail/releases/__tests__/state.spec.ts index 0a2c0dbcdcf7b4..c3693e93893aa2 100644 --- a/web/features/deployments/detail/releases/__tests__/state.spec.ts +++ b/web/features/deployments/detail/releases/__tests__/state.spec.ts @@ -12,14 +12,15 @@ type QueryOptions = { } vi.mock('jotai-tanstack-query', () => ({ - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ - ...createOptions(get), - data: undefined, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: false, - })), + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => + atom((get) => ({ + ...createOptions(get), + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + })), })) vi.mock('@/service/client', () => ({ @@ -41,7 +42,10 @@ async function loadState() { return await import('../state') } -function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') { +function setDeploymentRoute( + store: ReturnType<typeof createStore>, + appInstanceId = 'app-instance-1', +) { store.set(setNextRouteStateAtom, { pathname: `/deployments/${appInstanceId}/overview`, params: { appInstanceId }, diff --git a/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx b/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx index a0f0ff76305b36..b1a257504dc488 100644 --- a/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx +++ b/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx @@ -57,9 +57,8 @@ vi.mock('../delete-release-dialog', async () => { const { deleteReleaseDialogOpenAtom } = await import('../state') return { - DeleteReleaseDialog: () => useAtomValue(deleteReleaseDialogOpenAtom) - ? <div role="dialog">delete confirm</div> - : null, + DeleteReleaseDialog: () => + useAtomValue(deleteReleaseDialogOpenAtom) ? <div role="dialog">delete confirm</div> : null, } }) @@ -92,12 +91,7 @@ describe('DeployReleaseMenu', () => { it('should disable release deletion when deployment usage cannot be checked', () => { const release = createRelease() - render( - <DeployReleaseMenu - releaseId={release.id} - releaseRows={[release]} - />, - ) + render(<DeployReleaseMenu releaseId={release.id} releaseRows={[release]} />) fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) const deleteItem = screen.getByRole('menuitem', { name: 'deployments.versions.deleteRelease' }) diff --git a/web/features/deployments/detail/releases/release-actions/__tests__/state.spec.ts b/web/features/deployments/detail/releases/release-actions/__tests__/state.spec.ts index 5424bd62cd7edf..09450b01303842 100644 --- a/web/features/deployments/detail/releases/release-actions/__tests__/state.spec.ts +++ b/web/features/deployments/detail/releases/release-actions/__tests__/state.spec.ts @@ -11,14 +11,15 @@ type QueryOptions = { } vi.mock('jotai-tanstack-query', () => ({ - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ - ...createOptions(get), - data: undefined, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: false, - })), + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => + atom((get) => ({ + ...createOptions(get), + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + })), })) vi.mock('@/service/client', () => ({ @@ -48,7 +49,10 @@ async function loadState() { return await import('../state') } -function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') { +function setDeploymentRoute( + store: ReturnType<typeof createStore>, + appInstanceId = 'app-instance-1', +) { store.set(setNextRouteStateAtom, { pathname: `/deployments/${appInstanceId}/releases`, params: { appInstanceId }, diff --git a/web/features/deployments/detail/releases/release-actions/delete-release-dialog.tsx b/web/features/deployments/detail/releases/release-actions/delete-release-dialog.tsx index 1a4c9c970b6a26..b43a1e258e502c 100644 --- a/web/features/deployments/detail/releases/release-actions/delete-release-dialog.tsx +++ b/web/features/deployments/detail/releases/release-actions/delete-release-dialog.tsx @@ -11,10 +11,7 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { useAtom, useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' -import { - deleteReleaseDialogOpenAtom, - releaseActionItemAtom, -} from './state' +import { deleteReleaseDialogOpenAtom, releaseActionItemAtom } from './state' export function DeleteReleaseDialog({ isDeleting, @@ -26,38 +23,32 @@ export function DeleteReleaseDialog({ const { t } = useTranslation('deployments') const { releaseId, releaseRows } = useAtomValue(releaseActionItemAtom) const [open, setOpen] = useAtom(deleteReleaseDialogOpenAtom) - const release = releaseRows.find(release => release.id === releaseId) - if (!release) - return null + const release = releaseRows.find((release) => release.id === releaseId) + if (!release) return null return ( <AlertDialog open={open} onOpenChange={(nextOpen) => { - if (isDeleting) - return + if (isDeleting) return setOpen(nextOpen) }} > <AlertDialogContent className="w-120"> <div className="flex flex-col gap-3 px-6 pt-6 pb-2"> <AlertDialogTitle className="title-2xl-semi-bold text-text-primary"> - {t($ => $['versions.deleteConfirmTitle'])} + {t(($) => $['versions.deleteConfirmTitle'])} </AlertDialogTitle> <AlertDialogDescription className="system-sm-regular text-text-tertiary"> - {t($ => $['versions.deleteConfirmDesc'], { name: release.displayName })} + {t(($) => $['versions.deleteConfirmDesc'], { name: release.displayName })} </AlertDialogDescription> </div> <AlertDialogActions className="pt-3"> <AlertDialogCancelButton variant="secondary" disabled={isDeleting}> - {t($ => $['versions.cancelDelete'])} + {t(($) => $['versions.cancelDelete'])} </AlertDialogCancelButton> - <AlertDialogConfirmButton - loading={isDeleting} - disabled={isDeleting} - onClick={onConfirm} - > - {t($ => $['versions.deleteRelease'])} + <AlertDialogConfirmButton loading={isDeleting} disabled={isDeleting} onClick={onConfirm}> + {t(($) => $['versions.deleteRelease'])} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/features/deployments/detail/releases/release-actions/deploy-release-menu-utils.ts b/web/features/deployments/detail/releases/release-actions/deploy-release-menu-utils.ts index 346736db331847..e74f658f3ffb32 100644 --- a/web/features/deployments/detail/releases/release-actions/deploy-release-menu-utils.ts +++ b/web/features/deployments/detail/releases/release-actions/deploy-release-menu-utils.ts @@ -5,7 +5,10 @@ import type { } from '@dify/contracts/enterprise/types.gen' import type { SelectorParam } from 'i18next' import { releaseDeploymentAction } from '../../../shared/domain/release-action' -import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status' +import { + isRuntimeDeploymentInProgress, + isUndeployedDeploymentRow, +} from '../../../shared/domain/runtime-status' export type DeployMenuRowState = 'deploy' | 'rollback' | 'current' | 'deploying' @@ -32,10 +35,8 @@ type DeploymentTranslator = ( const GROUP_ORDER: DeployMenuGroup[] = ['deploy', 'rollback', 'unavailable'] function stateToGroup(state: DeployMenuRowState): DeployMenuGroup { - if (state === 'rollback') - return 'rollback' - if (state === 'deploy') - return 'deploy' + if (state === 'rollback') return 'rollback' + if (state === 'deploy') return 'deploy' return 'unavailable' } @@ -44,8 +45,7 @@ export function releaseUsageCount(releaseId: string, deploymentRows: Environment deploymentRows.forEach((row) => { const usesRelease = row.currentRelease?.id === releaseId || row.desiredRelease?.id === releaseId - if (usesRelease) - environmentIds.add(row.environment.id) + if (usesRelease) environmentIds.add(row.environment.id) }) return environmentIds.size @@ -68,7 +68,7 @@ function buildDeployMenuRow({ }): DeployMenuRow { const envId = env.id const envName = env.displayName - const row = deploymentRows.find(item => item.environment.id === envId) + const row = deploymentRows.find((item) => item.environment.id === envId) const currentRelease = row?.currentRelease const isCurrent = currentRelease?.id === releaseId const isEnvironmentInProgress = isRuntimeDeploymentInProgress(row?.status) @@ -78,8 +78,8 @@ function buildDeployMenuRow({ env, environmentId: envId, state: 'deploying', - label: t($ => $['versions.deployingTo'], { name: envName }), - disabledReason: t($ => $['versions.disabledReason.deploying']), + label: t(($) => $['versions.deployingTo'], { name: envName }), + disabledReason: t(($) => $['versions.disabledReason.deploying']), } } if (isCurrent) { @@ -87,8 +87,8 @@ function buildDeployMenuRow({ env, environmentId: envId, state: 'current', - label: t($ => $['versions.currentOn'], { name: envName }), - disabledReason: t($ => $['versions.disabledReason.current'], { name: envName }), + label: t(($) => $['versions.currentOn'], { name: envName }), + disabledReason: t(($) => $['versions.disabledReason.current'], { name: envName }), } } @@ -104,7 +104,7 @@ function buildDeployMenuRow({ env, environmentId: envId, state: 'deploy', - label: t($ => $['versions.deployTo'], { name: envName }), + label: t(($) => $['versions.deployTo'], { name: envName }), } } if (action === 'rollback') { @@ -112,14 +112,14 @@ function buildDeployMenuRow({ env, environmentId: envId, state: 'rollback', - label: t($ => $['versions.rollbackTo'], { name: envName }), + label: t(($) => $['versions.rollbackTo'], { name: envName }), } } return { env, environmentId: envId, state: 'deploy', - label: t($ => $['versions.deployTo'], { name: envName }), + label: t(($) => $['versions.deployTo'], { name: envName }), } } @@ -138,18 +138,20 @@ export function buildDeployMenuSections({ targetRelease: Release t: DeploymentTranslator }) { - const deploymentRows = environmentDeployments.filter(row => !isUndeployedDeploymentRow(row)) - const menuRows = environments.map(env => buildDeployMenuRow({ - env, - deploymentRows, - releaseRows, - releaseId, - targetRelease, - t, - })) - - return GROUP_ORDER.map(group => ({ + const deploymentRows = environmentDeployments.filter((row) => !isUndeployedDeploymentRow(row)) + const menuRows = environments.map((env) => + buildDeployMenuRow({ + env, + deploymentRows, + releaseRows, + releaseId, + targetRelease, + t, + }), + ) + + return GROUP_ORDER.map((group) => ({ group, - rows: menuRows.filter(row => stateToGroup(row.state) === group), + rows: menuRows.filter((row) => stateToGroup(row.state) === group), })).filter((section): section is DeployMenuSection => section.rows.length > 0) } diff --git a/web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx b/web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx index 4190cd7bcec937..bbfd775773366c 100644 --- a/web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx +++ b/web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx @@ -20,10 +20,7 @@ import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../../../shared/componen import { TitleTooltip } from '../../../shared/components/title-tooltip' import { isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status' import { DeleteReleaseDialog } from './delete-release-dialog' -import { - buildDeployMenuSections, - releaseUsageCount, -} from './deploy-release-menu-utils' +import { buildDeployMenuSections, releaseUsageCount } from './deploy-release-menu-utils' import { EditReleaseDialog } from './edit-release-dialog' import { exportReleaseDsl } from './release-dsl-export' import { @@ -45,9 +42,7 @@ type ExportReleaseDslInput = { appInstanceName?: string } -function DeployReleaseMenuContent({ onDeleted }: { - onDeleted?: () => void -}) { +function DeployReleaseMenuContent({ onDeleted }: { onDeleted?: () => void }) { const { t } = useTranslation('deployments') const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) const openDeployDrawer = useSetAtom(openDeployDrawerAtom) @@ -57,23 +52,29 @@ function DeployReleaseMenuContent({ onDeleted }: { const openEditReleaseDialog = useSetAtom(openEditReleaseDialogAtom) const openDeleteReleaseDialog = useSetAtom(openDeleteReleaseDialogAtom) const environmentDeployments = useAtomValue(deployReleaseMenuEnvironmentDeploymentsAtom) - const environmentDeploymentsIsLoading = useAtomValue(deployReleaseMenuEnvironmentDeploymentsIsLoadingAtom) - const environmentDeploymentsIsError = useAtomValue(deployReleaseMenuEnvironmentDeploymentsIsErrorAtom) + const environmentDeploymentsIsLoading = useAtomValue( + deployReleaseMenuEnvironmentDeploymentsIsLoadingAtom, + ) + const environmentDeploymentsIsError = useAtomValue( + deployReleaseMenuEnvironmentDeploymentsIsErrorAtom, + ) const appInstanceName = useAtomValue(deployReleaseMenuAppInstanceNameAtom) - const deleteRelease = useMutation(consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions()) - const exportReleaseDslMutation = useMutation(mutationOptions({ - mutationKey: ['deployments', 'release-dsl-export'], - mutationFn: (input: ExportReleaseDslInput) => exportReleaseDsl(input), - })) + const deleteRelease = useMutation( + consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions(), + ) + const exportReleaseDslMutation = useMutation( + mutationOptions({ + mutationKey: ['deployments', 'release-dsl-export'], + mutationFn: (input: ExportReleaseDslInput) => exportReleaseDsl(input), + }), + ) const deploymentEnvironmentRows = environmentDeployments?.environmentDeployments ?? [] - const environments = deploymentEnvironmentRows - .map(row => row.environment) - const deploymentRows = deploymentEnvironmentRows.filter(row => !isUndeployedDeploymentRow(row)) - const targetRelease = releaseRows.find(release => release.id === releaseId) + const environments = deploymentEnvironmentRows.map((row) => row.environment) + const deploymentRows = deploymentEnvironmentRows.filter((row) => !isUndeployedDeploymentRow(row)) + const targetRelease = releaseRows.find((release) => release.id === releaseId) - if (!targetRelease) - return null + if (!targetRelease) return null const release = targetRelease const targetReleaseName = release.displayName @@ -84,17 +85,17 @@ function DeployReleaseMenuContent({ onDeleted }: { const isDeletingRelease = deleteRelease.isPending const isExportingDsl = exportReleaseDslMutation.isPending const deleteDisabledReason = isCheckingDeleteUsage - ? t($ => $['versions.disabledReason.checkingDeployments']) + ? t(($) => $['versions.disabledReason.checkingDeployments']) : hasDeleteUsageCheckFailed - ? t($ => $['versions.disabledReason.checkDeploymentsFailed']) + ? t(($) => $['versions.disabledReason.checkDeploymentsFailed']) : isReleaseInUse - ? t($ => $['versions.disabledReason.releaseInUse'], { count: deleteUsageCount }) + ? t(($) => $['versions.disabledReason.releaseInUse'], { count: deleteUsageCount }) : undefined - const deleteActionDisabled = isDeletingRelease || isCheckingDeleteUsage || hasDeleteUsageCheckFailed || isReleaseInUse + const deleteActionDisabled = + isDeletingRelease || isCheckingDeleteUsage || hasDeleteUsageCheckFailed || isReleaseInUse function handleExportDsl() { - if (isExportingDsl) - return + if (isExportingDsl) return exportReleaseDslMutation.mutate( { release, releaseId, appInstanceName }, @@ -103,15 +104,14 @@ function DeployReleaseMenuContent({ onDeleted }: { setOpen(false) }, onError: () => { - toast.error(t($ => $['versions.exportDslFailed'])) + toast.error(t(($) => $['versions.exportDslFailed'])) }, }, ) } function handleDeleteRelease() { - if (deleteActionDisabled) - return + if (deleteActionDisabled) return deleteRelease.mutate( { @@ -122,11 +122,11 @@ function DeployReleaseMenuContent({ onDeleted }: { { onSuccess: () => { setDeleteReleaseDialogOpen(false) - toast.success(t($ => $['versions.deleteSuccess'], { name: targetReleaseName })) + toast.success(t(($) => $['versions.deleteSuccess'], { name: targetReleaseName })) onDeleted?.() }, onError: () => { - toast.error(t($ => $['versions.deleteFailed'])) + toast.error(t(($) => $['versions.deleteFailed'])) }, }, ) @@ -145,64 +145,68 @@ function DeployReleaseMenuContent({ onDeleted }: { <> <DropdownMenu modal={false} open={open} onOpenChange={setOpen}> <DropdownMenuTrigger - aria-label={t($ => $['versions.moreActions'])} + aria-label={t(($) => $['versions.moreActions'])} className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME} > <span aria-hidden className="i-ri-more-fill size-4" /> </DropdownMenuTrigger> {open && ( <DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-60"> - <DropdownMenuItem - className="gap-2 px-3" - onClick={openEditReleaseDialog} - > + <DropdownMenuItem className="gap-2 px-3" onClick={openEditReleaseDialog}> <span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" /> <span className="system-sm-regular text-text-secondary"> - {t($ => $['versions.editRelease'])} + {t(($) => $['versions.editRelease'])} </span> </DropdownMenuItem> <DropdownMenuItem disabled={isExportingDsl} aria-disabled={isExportingDsl} - className={cn( - 'gap-2 px-3', - isExportingDsl && 'cursor-not-allowed opacity-60', - )} + className={cn('gap-2 px-3', isExportingDsl && 'cursor-not-allowed opacity-60')} onClick={handleExportDsl} > - <span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" /> + <span + aria-hidden + className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" + /> <span className="system-sm-regular text-text-secondary"> - {isExportingDsl ? t($ => $['versions.exportingDsl']) : t($ => $['versions.exportDsl'])} + {isExportingDsl + ? t(($) => $['versions.exportingDsl']) + : t(($) => $['versions.exportDsl'])} </span> </DropdownMenuItem> - {groupedRows.length > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />} + {groupedRows.length > 0 && ( + <div className="my-1 border-t border-divider-subtle" aria-hidden /> + )} {groupedRows.map((section, sectionIndex) => ( <div key={section.group}> - {sectionIndex > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />} + {sectionIndex > 0 && ( + <div className="my-1 border-t border-divider-subtle" aria-hidden /> + )} <div className="px-3 pt-1.5 pb-1 system-2xs-medium-uppercase text-text-quaternary"> - {t($ => $[`versions.groupHeader.${section.group}`])} + {t(($) => $[`versions.groupHeader.${section.group}`])} </div> {section.rows.map((row) => { const isDisabled = row.state === 'current' || row.state === 'deploying' return ( - <TitleTooltip key={row.environmentId} content={isDisabled ? row.disabledReason : undefined}> + <TitleTooltip + key={row.environmentId} + content={isDisabled ? row.disabledReason : undefined} + > <DropdownMenuItem disabled={isDisabled} aria-disabled={isDisabled} - className={cn( - 'gap-2 px-3', - isDisabled && 'cursor-not-allowed opacity-60', - )} + className={cn('gap-2 px-3', isDisabled && 'cursor-not-allowed opacity-60')} onClick={() => { - if (isDisabled || !appInstanceId) - return + if (isDisabled || !appInstanceId) return setOpen(false) - openDeployDrawer({ appInstanceId, environmentId: row.environmentId, releaseId }) + openDeployDrawer({ + appInstanceId, + environmentId: row.environmentId, + releaseId, + }) }} > - <span className="system-sm-regular text-text-secondary"> - {row.label} - </span> + <span className="system-sm-regular text-text-secondary">{row.label}</span> </DropdownMenuItem> </TitleTooltip> ) @@ -220,13 +224,12 @@ function DeployReleaseMenuContent({ onDeleted }: { deleteActionDisabled && 'cursor-not-allowed opacity-60', )} onClick={() => { - if (deleteActionDisabled) - return + if (deleteActionDisabled) return openDeleteReleaseDialog() }} > <span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" /> - <span className="system-sm-regular">{t($ => $['versions.deleteRelease'])}</span> + <span className="system-sm-regular">{t(($) => $['versions.deleteRelease'])}</span> </DropdownMenuItem> </TitleTooltip> </DropdownMenuContent> @@ -235,15 +238,16 @@ function DeployReleaseMenuContent({ onDeleted }: { <EditReleaseDialog /> - <DeleteReleaseDialog - isDeleting={isDeletingRelease} - onConfirm={handleDeleteRelease} - /> + <DeleteReleaseDialog isDeleting={isDeletingRelease} onConfirm={handleDeleteRelease} /> </> ) } -export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: { +export function DeployReleaseMenu({ + releaseId, + releaseRows, + onDeleted, +}: { releaseId: string releaseRows: Release[] onDeleted?: () => void @@ -251,10 +255,7 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: { return ( <ScopeProvider key={releaseId} - atoms={[ - [releaseActionItemAtom, { releaseId, releaseRows }], - ...releaseActionLocalAtoms, - ]} + atoms={[[releaseActionItemAtom, { releaseId, releaseRows }], ...releaseActionLocalAtoms]} name="DeploymentReleaseActions" > <DeployReleaseMenuContent onDeleted={onDeleted} /> diff --git a/web/features/deployments/detail/releases/release-actions/edit-release-dialog.tsx b/web/features/deployments/detail/releases/release-actions/edit-release-dialog.tsx index 40c368c5fca8d0..b5292ce5a9c412 100644 --- a/web/features/deployments/detail/releases/release-actions/edit-release-dialog.tsx +++ b/web/features/deployments/detail/releases/release-actions/edit-release-dialog.tsx @@ -17,10 +17,7 @@ import { useMutation } from '@tanstack/react-query' import { useAtom, useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' -import { - editReleaseDialogOpenAtom, - releaseActionItemAtom, -} from './state' +import { editReleaseDialogOpenAtom, releaseActionItemAtom } from './state' type EditReleaseFormValues = { name: string @@ -34,15 +31,16 @@ function normalizedEditReleaseFormValues(value: EditReleaseFormValues) { } } -function canSubmitEditReleaseForm(initialValues: EditReleaseFormValues, value: EditReleaseFormValues) { +function canSubmitEditReleaseForm( + initialValues: EditReleaseFormValues, + value: EditReleaseFormValues, +) { const normalizedValues = normalizedEditReleaseFormValues(value) return Boolean( - normalizedValues.name - && ( - normalizedValues.name !== initialValues.name - || normalizedValues.description !== initialValues.description - ), + normalizedValues.name && + (normalizedValues.name !== initialValues.name || + normalizedValues.description !== initialValues.description), ) } @@ -58,15 +56,14 @@ function EditReleaseForm({ onSubmit: (values: EditReleaseFormValues) => void }) { const { t } = useTranslation('deployments') - const nameLabel = t($ => $['versions.releaseNameLabel']) + const nameLabel = t(($) => $['versions.releaseNameLabel']) const initialValues = { name: release.displayName, description: release.description, } function handleSubmit(values: EditReleaseFormValues) { - if (!canSubmitEditReleaseForm(initialValues, values)) - return + if (!canSubmitEditReleaseForm(initialValues, values)) return onSubmit(normalizedEditReleaseFormValues(values)) } @@ -85,12 +82,14 @@ function EditReleaseForm({ autoComplete="off" className="h-8" /> - <FieldError match="valueMissing">{t($ => $['versions.releaseNameRequired'])}</FieldError> + <FieldError match="valueMissing">{t(($) => $['versions.releaseNameRequired'])}</FieldError> </Field> <Field name="description" className="gap-2"> <FieldLabel className="system-xs-medium-uppercase text-text-tertiary"> - {t($ => $['versions.releaseDescriptionLabel'])} - <span className="ml-1.5 system-xs-regular text-text-quaternary">{t($ => $['versions.optional'])}</span> + {t(($) => $['versions.releaseDescriptionLabel'])} + <span className="ml-1.5 system-xs-regular text-text-quaternary"> + {t(($) => $['versions.optional'])} + </span> </FieldLabel> <Textarea defaultValue={initialValues.description} @@ -100,21 +99,11 @@ function EditReleaseForm({ /> </Field> <div className="flex justify-end gap-2 pt-2"> - <Button - type="button" - variant="secondary" - disabled={isSaving} - onClick={onClose} - > - {t($ => $['versions.cancelEdit'])} + <Button type="button" variant="secondary" disabled={isSaving} onClick={onClose}> + {t(($) => $['versions.cancelEdit'])} </Button> - <Button - type="submit" - variant="primary" - disabled={isSaving} - loading={isSaving} - > - {t($ => $['versions.saveEdit'])} + <Button type="submit" variant="primary" disabled={isSaving} loading={isSaving}> + {t(($) => $['versions.saveEdit'])} </Button> </div> </Form> @@ -125,17 +114,17 @@ export function EditReleaseDialog() { const { t } = useTranslation('deployments') const { releaseId, releaseRows } = useAtomValue(releaseActionItemAtom) const [open, setOpen] = useAtom(editReleaseDialogOpenAtom) - const updateRelease = useMutation(consoleQuery.enterprise.releaseService.updateRelease.mutationOptions()) - const targetRelease = releaseRows.find(release => release.id === releaseId) - if (!targetRelease) - return null + const updateRelease = useMutation( + consoleQuery.enterprise.releaseService.updateRelease.mutationOptions(), + ) + const targetRelease = releaseRows.find((release) => release.id === releaseId) + if (!targetRelease) return null const release = targetRelease const formKey = `${release.id}-${release.displayName}-${release.description}` function handleOpenChange(nextOpen: boolean) { - if (!nextOpen && updateRelease.isPending) - return + if (!nextOpen && updateRelease.isPending) return setOpen(nextOpen) } @@ -154,11 +143,11 @@ export function EditReleaseDialog() { { onSuccess: (data) => { const updatedName = data.release.displayName - toast.success(t($ => $['versions.editSuccess'], { name: updatedName })) + toast.success(t(($) => $['versions.editSuccess'], { name: updatedName })) handleOpenChange(false) }, onError: () => { - toast.error(t($ => $['versions.editFailed'])) + toast.error(t(($) => $['versions.editFailed'])) }, }, ) @@ -170,10 +159,10 @@ export function EditReleaseDialog() { <DialogCloseButton disabled={updateRelease.isPending} /> <div className="border-b border-divider-subtle px-6 py-5 pr-14"> <DialogTitle className="title-xl-semi-bold text-text-primary"> - {t($ => $['versions.editRelease'])} + {t(($) => $['versions.editRelease'])} </DialogTitle> <DialogDescription className="mt-1 system-sm-regular text-text-tertiary"> - {t($ => $['versions.editReleaseDescription'])} + {t(($) => $['versions.editReleaseDescription'])} </DialogDescription> </div> <div className="px-6 py-5"> diff --git a/web/features/deployments/detail/releases/release-actions/release-dsl-export.ts b/web/features/deployments/detail/releases/release-actions/release-dsl-export.ts index fa074bf5f87c4c..2f8a7dd14b6628 100644 --- a/web/features/deployments/detail/releases/release-actions/release-dsl-export.ts +++ b/web/features/deployments/detail/releases/release-actions/release-dsl-export.ts @@ -7,8 +7,7 @@ const INVALID_FILENAME_CHARS_PATTERN = /[\\/:*?"<>|]+/g const FILENAME_SEPARATOR_PATTERN = /[\s-]+/g function sanitizeFileNamePart(value?: string) { - if (!value) - return '' + if (!value) return '' return value .trim() @@ -18,7 +17,10 @@ function sanitizeFileNamePart(value?: string) { .replace(/^-+|-+$/g, '') } -function releaseDslFileName({ release, appInstanceName }: { +function releaseDslFileName({ + release, + appInstanceName, +}: { release: Release appInstanceName?: string }) { @@ -29,7 +31,11 @@ function releaseDslFileName({ release, appInstanceName }: { return `${baseName}.yaml` } -export async function exportReleaseDsl({ release, releaseId, appInstanceName }: { +export async function exportReleaseDsl({ + release, + releaseId, + appInstanceName, +}: { release: Release releaseId: string appInstanceName?: string diff --git a/web/features/deployments/detail/releases/release-actions/release-dsl.ts b/web/features/deployments/detail/releases/release-actions/release-dsl.ts index 488bbd5fb9284f..17bd90f25a3735 100644 --- a/web/features/deployments/detail/releases/release-actions/release-dsl.ts +++ b/web/features/deployments/detail/releases/release-actions/release-dsl.ts @@ -5,8 +5,7 @@ export async function fetchReleaseDsl(releaseId: string) { params: { releaseId }, }) - if (!response.dsl) - throw new Error('Invalid release DSL response') + if (!response.dsl) throw new Error('Invalid release DSL response') return response.dsl } diff --git a/web/features/deployments/detail/releases/release-actions/state.ts b/web/features/deployments/detail/releases/release-actions/state.ts index a4e164ccedf5b6..9d45811deb95d8 100644 --- a/web/features/deployments/detail/releases/release-actions/state.ts +++ b/web/features/deployments/detail/releases/release-actions/state.ts @@ -37,15 +37,15 @@ export const deployReleaseMenuEnvironmentDeploymentsQueryAtom = atomWithQuery((g export const deployReleaseMenuEnvironmentDeploymentsAtom = selectAtom( deployReleaseMenuEnvironmentDeploymentsQueryAtom, - query => query.data, + (query) => query.data, ) export const deployReleaseMenuEnvironmentDeploymentsIsLoadingAtom = selectAtom( deployReleaseMenuEnvironmentDeploymentsQueryAtom, - query => query.isLoading, + (query) => query.isLoading, ) export const deployReleaseMenuEnvironmentDeploymentsIsErrorAtom = selectAtom( deployReleaseMenuEnvironmentDeploymentsQueryAtom, - query => query.isError, + (query) => query.isError, ) export const deployReleaseMenuAppInstanceQueryAtom = atomWithQuery((get) => { @@ -64,7 +64,7 @@ export const deployReleaseMenuAppInstanceQueryAtom = atomWithQuery((get) => { export const deployReleaseMenuAppInstanceNameAtom = selectAtom( deployReleaseMenuAppInstanceQueryAtom, - query => query.data?.appInstance.displayName, + (query) => query.data?.appInstance.displayName, ) export const openEditReleaseDialogAtom = atom(null, (_get, set) => { diff --git a/web/features/deployments/detail/releases/release-history/__tests__/release-history-rows.spec.tsx b/web/features/deployments/detail/releases/release-history/__tests__/release-history-rows.spec.tsx index 2f352fc094306c..c1ad8425a315f6 100644 --- a/web/features/deployments/detail/releases/release-history/__tests__/release-history-rows.spec.tsx +++ b/web/features/deployments/detail/releases/release-history/__tests__/release-history-rows.spec.tsx @@ -33,7 +33,9 @@ vi.mock('@/service/client', () => ({ }, })) -function createReleaseRow(overrides: Partial<ReleaseWithSummaryDeployments> = {}): ReleaseWithSummaryDeployments { +function createReleaseRow( + overrides: Partial<ReleaseWithSummaryDeployments> = {}, +): ReleaseWithSummaryDeployments { return { id: 'release-1', appInstanceId: 'app-instance-1', @@ -54,11 +56,7 @@ function createReleaseRow(overrides: Partial<ReleaseWithSummaryDeployments> = {} describe('ReleaseHistoryRows', () => { it('should render the desktop release list with the knowledge table style', () => { - const { container } = render( - <ReleaseHistoryRows - releaseRows={[createReleaseRow()]} - />, - ) + const { container } = render(<ReleaseHistoryRows releaseRows={[createReleaseRow()]} />) const table = container.querySelector('table') const tableScope = within(table!) @@ -69,7 +67,12 @@ describe('ReleaseHistoryRows', () => { expect(table).toHaveClass('w-full', 'border-collapse', 'border-0', 'text-sm') expect(header).toHaveClass('border-b', 'border-divider-subtle') expect(headerCell).not.toHaveClass('bg-background-section-burn', 'rounded-l-lg') - expect(bodyRow).toHaveClass('h-8', 'border-b', 'border-divider-subtle', 'hover:bg-background-default-hover') + expect(bodyRow).toHaveClass( + 'h-8', + 'border-b', + 'border-divider-subtle', + 'hover:bg-background-default-hover', + ) expect(tableScope.getByText('Initial Release')).toBeInTheDocument() }) @@ -78,11 +81,13 @@ describe('ReleaseHistoryRows', () => { <ReleaseHistoryRows releaseRows={[ createReleaseRow({ - summaryDeployments: [{ - environmentId: 'env-1', - environmentName: 'test-cpu', - status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY, - }], + summaryDeployments: [ + { + environmentId: 'env-1', + environmentName: 'test-cpu', + status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY, + }, + ], }), ]} />, diff --git a/web/features/deployments/detail/releases/release-history/deployed-to-badge.tsx b/web/features/deployments/detail/releases/release-history/deployed-to-badge.tsx index ac6a2c8af5c9a3..cc2896057772ac 100644 --- a/web/features/deployments/detail/releases/release-history/deployed-to-badge.tsx +++ b/web/features/deployments/detail/releases/release-history/deployed-to-badge.tsx @@ -10,12 +10,10 @@ import { deploymentStatusDotTextClassName, } from '../../../shared/ui/deployment-status-style' -export function DeployedToBadge({ item }: { - item: ReleaseDeployment -}) { +export function DeployedToBadge({ item }: { item: ReleaseDeployment }) { const { t } = useTranslation('deployments') const status = item.status - const statusLabel = t($ => $[`versions.deployedStatus.${status}`]) + const statusLabel = t(($) => $[`versions.deployedStatus.${status}`]) const dotStatus = deploymentStatusDotStatus(status) const isInProgress = isRuntimeDeploymentInProgress(status) const textClassName = deploymentStatusDotTextClassName(status) diff --git a/web/features/deployments/detail/releases/release-history/release-deployments.ts b/web/features/deployments/detail/releases/release-history/release-deployments.ts index cb6bfe774545d7..01b12a091f29c7 100644 --- a/web/features/deployments/detail/releases/release-history/release-deployments.ts +++ b/web/features/deployments/detail/releases/release-history/release-deployments.ts @@ -21,11 +21,14 @@ export type ReleaseWithSummaryDeployments = Release & { function dedupeReleaseDeployments(items: ReleaseDeployment[]) { return items.filter((item, index) => { - return items.findIndex(candidate => candidate.environmentId === item.environmentId) === index + return items.findIndex((candidate) => candidate.environmentId === item.environmentId) === index }) } -function releaseSummaryEnvironmentDeployment(environment: Environment, status: RuntimeInstanceStatusValue): ReleaseDeployment { +function releaseSummaryEnvironmentDeployment( + environment: Environment, + status: RuntimeInstanceStatusValue, +): ReleaseDeployment { return { environmentId: environment.id, environmentName: environment.displayName, @@ -36,14 +39,20 @@ function releaseSummaryEnvironmentDeployment(environment: Environment, status: R export function getReleaseSummaryDeployments(summary: ReleaseSummary) { // Each deployed environment carries its runtime status so a failed deployment // surfaces as failed instead of being assumed healthy. - const deployedItems = summary.deployedEnvironments - .map(deployment => releaseSummaryEnvironmentDeployment(deployment.environment, deployment.status)) + const deployedItems = summary.deployedEnvironments.map((deployment) => + releaseSummaryEnvironmentDeployment(deployment.environment, deployment.status), + ) const actionItems = summary.environmentActions - .filter(action => action.kind === ReleaseEnvironmentActionKind.RELEASE_ENVIRONMENT_ACTION_KIND_DEPLOYING) - .map(action => releaseSummaryEnvironmentDeployment(action.environment, RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_DEPLOYING)) + .filter( + (action) => + action.kind === ReleaseEnvironmentActionKind.RELEASE_ENVIRONMENT_ACTION_KIND_DEPLOYING, + ) + .map((action) => + releaseSummaryEnvironmentDeployment( + action.environment, + RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_DEPLOYING, + ), + ) - return dedupeReleaseDeployments([ - ...deployedItems, - ...actionItems, - ]) + return dedupeReleaseDeployments([...deployedItems, ...actionItems]) } diff --git a/web/features/deployments/detail/releases/release-history/release-history-deployments.tsx b/web/features/deployments/detail/releases/release-history/release-history-deployments.tsx index 6ac979f2602ccc..fc5fa1e1f0cea1 100644 --- a/web/features/deployments/detail/releases/release-history/release-history-deployments.tsx +++ b/web/features/deployments/detail/releases/release-history/release-history-deployments.tsx @@ -3,18 +3,10 @@ import type { ReleaseDeployment } from './release-deployments' import { DeployedToBadge } from './deployed-to-badge' -export function ReleaseDeploymentsContent({ - items, -}: { - items: ReleaseDeployment[] -}) { - if (items.length === 0) - return <span className="text-text-quaternary">—</span> +export function ReleaseDeploymentsContent({ items }: { items: ReleaseDeployment[] }) { + if (items.length === 0) return <span className="text-text-quaternary">—</span> - return items.map(item => ( - <DeployedToBadge - key={`${item.environmentId}-${item.status}`} - item={item} - /> + return items.map((item) => ( + <DeployedToBadge key={`${item.environmentId}-${item.status}`} item={item} /> )) } diff --git a/web/features/deployments/detail/releases/release-history/release-history-rows.tsx b/web/features/deployments/detail/releases/release-history/release-history-rows.tsx index 04ff0a2516d81b..87852bdcfd459a 100644 --- a/web/features/deployments/detail/releases/release-history/release-history-rows.tsx +++ b/web/features/deployments/detail/releases/release-history/release-history-rows.tsx @@ -20,68 +20,52 @@ import { DetailTableRow, } from '../../../shared/components/detail-table' import { TitleTooltip } from '../../../shared/components/title-tooltip' -import { - formatDate, - releaseCommit, -} from '../../../shared/domain/release' +import { formatDate, releaseCommit } from '../../../shared/domain/release' import { DeployReleaseMenu } from '../release-actions/deploy-release-menu' -import { - ReleaseDeploymentsContent, -} from './release-history-deployments' +import { ReleaseDeploymentsContent } from './release-history-deployments' import { RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES } from './table-styles' -function ReleaseTitleTooltip({ release }: { - release: Release -}) { +function ReleaseTitleTooltip({ release }: { release: Release }) { const { t } = useTranslation('deployments') return ( <Tooltip> <TooltipTrigger - render={( + render={ <span className="inline-flex max-w-full cursor-default truncate text-text-primary"> {release.displayName} </span> - )} + } /> <TooltipContent> - {t($ => $['versions.commitTooltip'], { commit: releaseCommit(release) })} + {t(($) => $['versions.commitTooltip'], { commit: releaseCommit(release) })} </TooltipContent> </Tooltip> ) } -function CreatedAtCell({ createdAt }: { - createdAt: string -}) { +function CreatedAtCell({ createdAt }: { createdAt: string }) { const { formatTimeFromNow } = useFormatTimeFromNow() const ms = Date.parse(createdAt) - if (Number.isNaN(ms)) - return <>{formatDate(createdAt)}</> + if (Number.isNaN(ms)) return <>{formatDate(createdAt)}</> return ( <Tooltip> - <TooltipTrigger - render={( - <span className="cursor-default"> - {formatTimeFromNow(ms)} - </span> - )} - /> + <TooltipTrigger render={<span className="cursor-default">{formatTimeFromNow(ms)}</span>} /> <TooltipContent>{formatDate(createdAt)}</TooltipContent> </Tooltip> ) } -function ReleaseSourceCell({ release }: { - release: Release -}) { +function ReleaseSourceCell({ release }: { release: Release }) { const { t } = useTranslation('deployments') const sourceAppId = release.sourceAppId if (!sourceAppId) { return ( <span className="text-text-tertiary"> - {release.source === ReleaseSource.RELEASE_SOURCE_UPLOAD ? t($ => $['versions.manualDslOption']) : '—'} + {release.source === ReleaseSource.RELEASE_SOURCE_UPLOAD + ? t(($) => $['versions.manualDslOption']) + : '—'} </span> ) } @@ -89,14 +73,14 @@ function ReleaseSourceCell({ release }: { return <ReleaseSourceLink sourceAppId={sourceAppId} /> } -function ReleaseSourceLink({ sourceAppId }: { - sourceAppId: string -}) { - const sourceAppQuery = useQuery(consoleQuery.apps.byAppId.get.queryOptions({ - input: { - params: { app_id: sourceAppId }, - }, - })) +function ReleaseSourceLink({ sourceAppId }: { sourceAppId: string }) { + const sourceAppQuery = useQuery( + consoleQuery.apps.byAppId.get.queryOptions({ + input: { + params: { app_id: sourceAppId }, + }, + }), + ) const sourceAppName = sourceAppQuery.data?.name const label = sourceAppName || sourceAppId const title = sourceAppName ? `${sourceAppName} (${sourceAppId})` : sourceAppId @@ -116,7 +100,10 @@ function ReleaseSourceLink({ sourceAppId }: { ) } -function ReleaseHistoryMobileRows({ releaseRows, onReleaseDeleted }: { +function ReleaseHistoryMobileRows({ + releaseRows, + onReleaseDeleted, +}: { releaseRows: ReleaseWithSummaryDeployments[] onReleaseDeleted?: () => void }) { @@ -139,11 +126,12 @@ function ReleaseHistoryMobileRows({ releaseRows, onReleaseDeleted }: { <CreatedAtCell createdAt={release.createdAt} /> <span aria-hidden>·</span> <span>{row.createdBy.displayName}</span> - {(release.sourceAppId || release.source === ReleaseSource.RELEASE_SOURCE_UPLOAD) && ( + {(release.sourceAppId || + release.source === ReleaseSource.RELEASE_SOURCE_UPLOAD) && ( <> <span aria-hidden>·</span> <span className="inline-flex max-w-full min-w-0 items-baseline gap-1"> - <span className="shrink-0">{t($ => $['versions.col.sourceApp'])}</span> + <span className="shrink-0">{t(($) => $['versions.col.sourceApp'])}</span> <ReleaseSourceCell release={release} /> </span> </> @@ -160,9 +148,7 @@ function ReleaseHistoryMobileRows({ releaseRows, onReleaseDeleted }: { </div> {hasDeployments && ( <div className="flex min-w-0 flex-wrap items-center gap-1"> - <ReleaseDeploymentsContent - items={row.summaryDeployments} - /> + <ReleaseDeploymentsContent items={row.summaryDeployments} /> </div> )} </div> @@ -173,7 +159,10 @@ function ReleaseHistoryMobileRows({ releaseRows, onReleaseDeleted }: { ) } -export function ReleaseHistoryRows({ releaseRows, onReleaseDeleted }: { +export function ReleaseHistoryRows({ + releaseRows, + onReleaseDeleted, +}: { releaseRows: ReleaseWithSummaryDeployments[] onReleaseDeleted?: () => void }) { @@ -181,20 +170,31 @@ export function ReleaseHistoryRows({ releaseRows, onReleaseDeleted }: { return ( <> - <ReleaseHistoryMobileRows - releaseRows={releaseRows} - onReleaseDeleted={onReleaseDeleted} - /> + <ReleaseHistoryMobileRows releaseRows={releaseRows} onReleaseDeleted={onReleaseDeleted} /> <div className="hidden pc:block"> <DetailTable className="min-w-[840px]"> <DetailTableHeader> <DetailTableRow> - <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>{t($ => $['versions.col.release'])}</DetailTableHead> - <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}>{t($ => $['versions.col.sourceApp'])}</DetailTableHead> - <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt}>{t($ => $['versions.col.createdAt'])}</DetailTableHead> - <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author}>{t($ => $['versions.col.author'])}</DetailTableHead> - <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>{t($ => $['versions.col.deployedTo'])}</DetailTableHead> - <DetailTableHead className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t($ => $['versions.col.action'])}</DetailTableHead> + <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}> + {t(($) => $['versions.col.release'])} + </DetailTableHead> + <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}> + {t(($) => $['versions.col.sourceApp'])} + </DetailTableHead> + <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt}> + {t(($) => $['versions.col.createdAt'])} + </DetailTableHead> + <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author}> + {t(($) => $['versions.col.author'])} + </DetailTableHead> + <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}> + {t(($) => $['versions.col.deployedTo'])} + </DetailTableHead> + <DetailTableHead + className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`} + > + {t(($) => $['versions.col.action'])} + </DetailTableHead> </DetailTableRow> </DetailTableHeader> <DetailTableBody> @@ -210,17 +210,19 @@ export function ReleaseHistoryRows({ releaseRows, onReleaseDeleted }: { <DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}> <ReleaseSourceCell release={release} /> </DetailTableCell> - <DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt} text-text-secondary`}> + <DetailTableCell + className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt} text-text-secondary`} + > <CreatedAtCell createdAt={release.createdAt} /> </DetailTableCell> - <DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author} truncate text-text-secondary`}> + <DetailTableCell + className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author} truncate text-text-secondary`} + > {row.createdBy.displayName} </DetailTableCell> <DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}> <div className="flex flex-wrap gap-1"> - <ReleaseDeploymentsContent - items={row.summaryDeployments} - /> + <ReleaseDeploymentsContent items={row.summaryDeployments} /> </div> </DetailTableCell> <DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action}> diff --git a/web/features/deployments/detail/releases/release-history/release-history-table-skeleton.tsx b/web/features/deployments/detail/releases/release-history/release-history-table-skeleton.tsx index 7c0d7e6d9db037..466a451950eb38 100644 --- a/web/features/deployments/detail/releases/release-history/release-history-table-skeleton.tsx +++ b/web/features/deployments/detail/releases/release-history/release-history-table-skeleton.tsx @@ -31,7 +31,7 @@ export function ReleaseHistoryTableSkeleton() { return ( <> <DetailTableCardList className="pc:hidden"> - {RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => ( + {RELEASE_TABLE_ROW_SKELETON_KEYS.map((key) => ( <DetailTableCard key={key}> <div className="flex flex-col gap-3 p-4"> <div className="flex items-start justify-between gap-3"> @@ -55,16 +55,30 @@ export function ReleaseHistoryTableSkeleton() { <DetailTable className="min-w-[840px]"> <DetailTableHeader> <DetailTableRow> - <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>{t($ => $['versions.col.release'])}</DetailTableHead> - <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}>{t($ => $['versions.col.sourceApp'])}</DetailTableHead> - <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt}>{t($ => $['versions.col.createdAt'])}</DetailTableHead> - <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author}>{t($ => $['versions.col.author'])}</DetailTableHead> - <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>{t($ => $['versions.col.deployedTo'])}</DetailTableHead> - <DetailTableHead className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t($ => $['versions.col.action'])}</DetailTableHead> + <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}> + {t(($) => $['versions.col.release'])} + </DetailTableHead> + <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}> + {t(($) => $['versions.col.sourceApp'])} + </DetailTableHead> + <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt}> + {t(($) => $['versions.col.createdAt'])} + </DetailTableHead> + <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author}> + {t(($) => $['versions.col.author'])} + </DetailTableHead> + <DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}> + {t(($) => $['versions.col.deployedTo'])} + </DetailTableHead> + <DetailTableHead + className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`} + > + {t(($) => $['versions.col.action'])} + </DetailTableHead> </DetailTableRow> </DetailTableHeader> <DetailTableBody> - {RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => ( + {RELEASE_TABLE_ROW_SKELETON_KEYS.map((key) => ( <DetailTableRow key={key}> <DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}> <SkeletonRectangle className="h-3 w-24 animate-pulse" /> diff --git a/web/features/deployments/detail/releases/release-history/release-history-table.tsx b/web/features/deployments/detail/releases/release-history/release-history-table.tsx index d1dcb341418219..5af2834c53b889 100644 --- a/web/features/deployments/detail/releases/release-history/release-history-table.tsx +++ b/web/features/deployments/detail/releases/release-history/release-history-table.tsx @@ -3,7 +3,10 @@ import { Pagination } from '@langgenius/dify-ui/pagination' import { useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' -import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/components/empty-state' +import { + DeploymentEmptyState, + DeploymentStateMessage, +} from '../../../shared/components/empty-state' import { adjustReleaseHistoryPageAfterDeleteAtom, RELEASE_HISTORY_PAGE_SIZE, @@ -26,13 +29,12 @@ export function ReleaseHistoryTable() { const isLoading = useAtomValue(releaseHistoryIsLoadingAtom) const hasError = useAtomValue(releaseHistoryIsErrorAtom) - if (isLoading) - return <ReleaseHistoryTableSkeleton /> + if (isLoading) return <ReleaseHistoryTableSkeleton /> if (hasError) { return ( <DeploymentStateMessage variant="list"> - {t($ => $['common.loadFailed'])} + {t(($) => $['common.loadFailed'])} </DeploymentStateMessage> ) } @@ -40,7 +42,7 @@ export function ReleaseHistoryTable() { if (!releaseHistory) { return ( <DeploymentStateMessage variant="list"> - {t($ => $['common.loadFailed'])} + {t(($) => $['common.loadFailed'])} </DeploymentStateMessage> ) } @@ -57,24 +59,21 @@ export function ReleaseHistoryTable() { return ( <DeploymentEmptyState icon="i-ri-stack-line" - title={t($ => $['versions.emptyTitle'])} - description={t($ => $['versions.emptyDescription'])} + title={t(($) => $['versions.emptyTitle'])} + description={t(($) => $['versions.emptyDescription'])} /> ) } return ( <div className="flex flex-col gap-3"> - <ReleaseHistoryRows - releaseRows={releaseRows} - onReleaseDeleted={handleReleaseDeleted} - /> + <ReleaseHistoryRows releaseRows={releaseRows} onReleaseDeleted={handleReleaseDeleted} /> {totalReleases > RELEASE_HISTORY_PAGE_SIZE && ( <Pagination className="border-y border-divider-subtle" page={currentPage + 1} totalPages={totalReleasePages} - onPageChange={page => setCurrentPage(page - 1)} + onPageChange={(page) => setCurrentPage(page - 1)} /> )} </div> diff --git a/web/features/deployments/detail/releases/state.ts b/web/features/deployments/detail/releases/state.ts index 43500b45ea565b..3e29dd7c2173fc 100644 --- a/web/features/deployments/detail/releases/state.ts +++ b/web/features/deployments/detail/releases/state.ts @@ -30,20 +30,27 @@ export const releaseHistoryQueryAtom = atomWithQuery((get) => { }) }) -export const releaseHistoryAtom = selectAtom(releaseHistoryQueryAtom, query => query.data) -export const releaseHistoryIsLoadingAtom = selectAtom(releaseHistoryQueryAtom, query => query.isLoading) -export const releaseHistoryIsErrorAtom = selectAtom(releaseHistoryQueryAtom, query => query.isError) +export const releaseHistoryAtom = selectAtom(releaseHistoryQueryAtom, (query) => query.data) +export const releaseHistoryIsLoadingAtom = selectAtom( + releaseHistoryQueryAtom, + (query) => query.isLoading, +) +export const releaseHistoryIsErrorAtom = selectAtom( + releaseHistoryQueryAtom, + (query) => query.isError, +) export const setReleaseHistoryCurrentPageAtom = atom(null, (_get, set, page: number) => { set(releaseHistoryCurrentPageAtom, Math.max(page, 0)) }) -export const adjustReleaseHistoryPageAfterDeleteAtom = atom(null, (get, set, remainingRowsOnPage: number) => { - const currentPage = get(releaseHistoryCurrentPageAtom) - if (remainingRowsOnPage === 1 && currentPage > 0) - set(releaseHistoryCurrentPageAtom, currentPage - 1) -}) +export const adjustReleaseHistoryPageAfterDeleteAtom = atom( + null, + (get, set, remainingRowsOnPage: number) => { + const currentPage = get(releaseHistoryCurrentPageAtom) + if (remainingRowsOnPage === 1 && currentPage > 0) + set(releaseHistoryCurrentPageAtom, currentPage - 1) + }, +) -export const releasesLocalAtoms = [ - releaseHistoryCurrentPageAtom, -] as const +export const releasesLocalAtoms = [releaseHistoryCurrentPageAtom] as const diff --git a/web/features/deployments/detail/state.ts b/web/features/deployments/detail/state.ts index cc521f1580445b..6add1bcdd7c636 100644 --- a/web/features/deployments/detail/state.ts +++ b/web/features/deployments/detail/state.ts @@ -16,9 +16,8 @@ import { isInstanceDetailTabKey } from './tabs' export const deploymentDetailActiveTabAtom = atom((get) => { const pathnameSegments = get(nextPathnameAtom).split('/').filter(Boolean) const deploymentsSegmentIndex = pathnameSegments.indexOf('deployments') - const selectedSegment = deploymentsSegmentIndex >= 0 - ? pathnameSegments[deploymentsSegmentIndex + 2] - : undefined + const selectedSegment = + deploymentsSegmentIndex >= 0 ? pathnameSegments[deploymentsSegmentIndex + 2] : undefined return isInstanceDetailTabKey(selectedSegment) ? selectedSegment : 'overview' }) @@ -36,9 +35,18 @@ export const deploymentDetailAppInstanceQueryAtom = atomWithQuery((get) => { }) }) -export const deploymentDetailAppInstanceAtom = selectAtom(deploymentDetailAppInstanceQueryAtom, query => query.data) -export const deploymentDetailAppInstanceIsLoadingAtom = selectAtom(deploymentDetailAppInstanceQueryAtom, query => query.isLoading) -export const deploymentDetailAppInstanceIsErrorAtom = selectAtom(deploymentDetailAppInstanceQueryAtom, query => query.isError) +export const deploymentDetailAppInstanceAtom = selectAtom( + deploymentDetailAppInstanceQueryAtom, + (query) => query.data, +) +export const deploymentDetailAppInstanceIsLoadingAtom = selectAtom( + deploymentDetailAppInstanceQueryAtom, + (query) => query.isLoading, +) +export const deploymentDetailAppInstanceIsErrorAtom = selectAtom( + deploymentDetailAppInstanceQueryAtom, + (query) => query.isError, +) export const deploymentEnvironmentDeploymentsQueryAtom = atomWithQuery((get) => { const appInstanceId = get(deploymentRouteAppInstanceIdAtom) @@ -50,14 +58,28 @@ export const deploymentEnvironmentDeploymentsQueryAtom = atomWithQuery((get) => } : skipToken, enabled: Boolean(appInstanceId), - refetchInterval: query => deploymentStatusPollingInterval(query.state.data?.environmentDeployments), + refetchInterval: (query) => + deploymentStatusPollingInterval(query.state.data?.environmentDeployments), }) }) -export const deploymentEnvironmentDeploymentsAtom = selectAtom(deploymentEnvironmentDeploymentsQueryAtom, query => query.data) -export const deploymentEnvironmentDeploymentsIsLoadingAtom = selectAtom(deploymentEnvironmentDeploymentsQueryAtom, query => query.isLoading) -export const deploymentEnvironmentDeploymentsIsErrorAtom = selectAtom(deploymentEnvironmentDeploymentsQueryAtom, query => query.isError) +export const deploymentEnvironmentDeploymentsAtom = selectAtom( + deploymentEnvironmentDeploymentsQueryAtom, + (query) => query.data, +) +export const deploymentEnvironmentDeploymentsIsLoadingAtom = selectAtom( + deploymentEnvironmentDeploymentsQueryAtom, + (query) => query.isLoading, +) +export const deploymentEnvironmentDeploymentsIsErrorAtom = selectAtom( + deploymentEnvironmentDeploymentsQueryAtom, + (query) => query.isError, +) export const deploymentRuntimeInstanceRowsAtom = atom((get) => { - return get(deploymentEnvironmentDeploymentsAtom)?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? [] + return ( + get(deploymentEnvironmentDeploymentsAtom)?.environmentDeployments.filter( + hasRuntimeInstanceDeployment, + ) ?? [] + ) }) diff --git a/web/features/deployments/detail/tabs.ts b/web/features/deployments/detail/tabs.ts index 59820e9ca398e4..0d6e1fa8bf7b5a 100644 --- a/web/features/deployments/detail/tabs.ts +++ b/web/features/deployments/detail/tabs.ts @@ -1,6 +1,12 @@ -export const INSTANCE_DETAIL_TAB_KEYS = ['overview', 'instances', 'releases', 'access', 'api-tokens'] as const +export const INSTANCE_DETAIL_TAB_KEYS = [ + 'overview', + 'instances', + 'releases', + 'access', + 'api-tokens', +] as const -export type InstanceDetailTabKey = typeof INSTANCE_DETAIL_TAB_KEYS[number] +export type InstanceDetailTabKey = (typeof INSTANCE_DETAIL_TAB_KEYS)[number] const INSTANCE_DETAIL_TAB_KEY_SET = new Set<string>(INSTANCE_DETAIL_TAB_KEYS) diff --git a/web/features/deployments/list/state/index.ts b/web/features/deployments/list/state/index.ts index db52a0f329bf75..30d6474fb13f36 100644 --- a/web/features/deployments/list/state/index.ts +++ b/web/features/deployments/list/state/index.ts @@ -25,16 +25,17 @@ export type DeploymentsListEnvironmentFilterOption = { displayName?: string } -export function DeploymentsListStateBoundary({ children }: { - children: ReactNode -}) { +export function DeploymentsListStateBoundary({ children }: { children: ReactNode }) { const [envFilter] = useQueryState('env', envFilterQueryState) const [keywords] = useQueryState('keywords', keywordsQueryState) - useHydrateAtoms([ - [deploymentsListEnvironmentIdAtom, envFilter], - [deploymentsListKeywordsAtom, keywords], - ] as const, { dangerouslyForceHydrate: true }) + useHydrateAtoms( + [ + [deploymentsListEnvironmentIdAtom, envFilter], + [deploymentsListKeywordsAtom, keywords], + ] as const, + { dangerouslyForceHydrate: true }, + ) return children } @@ -52,45 +53,54 @@ const deploymentsListEnvironmentsQueryAtom = atomWithQuery(() => { }) }) -const deploymentsListEnvironmentsDataAtom = selectAtom(deploymentsListEnvironmentsQueryAtom, query => query.data) +const deploymentsListEnvironmentsDataAtom = selectAtom( + deploymentsListEnvironmentsQueryAtom, + (query) => query.data, +) -export const deploymentsListEnvironmentFilterOptionsAtom = atom((get): DeploymentsListEnvironmentFilterOption[] => { - const environments = get(deploymentsListEnvironmentsDataAtom)?.environments ?? [] +export const deploymentsListEnvironmentFilterOptionsAtom = atom( + (get): DeploymentsListEnvironmentFilterOption[] => { + const environments = get(deploymentsListEnvironmentsDataAtom)?.environments ?? [] - return [ - { - kind: 'all', - value: null, - }, - ...environments.map(environment => ({ - kind: 'environment' as const, - value: environment.id, - displayName: environment.displayName, - })), - ] -}) - -export const deploymentsListSelectedEnvironmentFilterOptionAtom = atom((get): DeploymentsListEnvironmentFilterOption => { - const envFilter = get(deploymentsListEnvironmentIdAtom) - const options = get(deploymentsListEnvironmentFilterOptionsAtom) - const allOption = options[0] ?? { kind: 'all' as const, value: null } - - return options.find(option => option.value === envFilter) - ?? (envFilter - ? { - kind: 'environment', - value: envFilter, - displayName: envFilter, - } - : allOption) -}) + return [ + { + kind: 'all', + value: null, + }, + ...environments.map((environment) => ({ + kind: 'environment' as const, + value: environment.id, + displayName: environment.displayName, + })), + ] + }, +) + +export const deploymentsListSelectedEnvironmentFilterOptionAtom = atom( + (get): DeploymentsListEnvironmentFilterOption => { + const envFilter = get(deploymentsListEnvironmentIdAtom) + const options = get(deploymentsListEnvironmentFilterOptionsAtom) + const allOption = options[0] ?? { kind: 'all' as const, value: null } + + return ( + options.find((option) => option.value === envFilter) ?? + (envFilter + ? { + kind: 'environment', + value: envFilter, + displayName: envFilter, + } + : allOption) + ) + }, +) const deploymentsListQueryAtom = atomWithInfiniteQuery((get) => { const queryKeywords = get(deploymentsListKeywordsAtom).trim() const queryEnvironmentId = get(deploymentsListEnvironmentIdAtom) ?? undefined return consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.infiniteOptions({ - input: pageParam => ({ + input: (pageParam) => ({ query: { pageNumber: Number(pageParam), resultsPerPage: DEPLOYMENTS_LIST_PAGE_SIZE, @@ -107,43 +117,62 @@ const deploymentsListQueryAtom = atomWithInfiniteQuery((get) => { initialPageParam: 1, placeholderData: keepPreviousData, refetchInterval: (query) => { - const rows = query.state.data?.pages.flatMap(page => - page.appInstanceSummaries.flatMap(summary => summary.environmentDeployments), - ) ?? [] + const rows = + query.state.data?.pages.flatMap((page) => + page.appInstanceSummaries.flatMap((summary) => summary.environmentDeployments), + ) ?? [] return deploymentStatusPollingInterval(rows) }, }) }) -const deploymentsListDataAtom = selectAtom(deploymentsListQueryAtom, query => query.data) -export const deploymentsListErrorAtom = selectAtom(deploymentsListQueryAtom, query => query.error) -export const deploymentsListFetchNextPageAtom = selectAtom(deploymentsListQueryAtom, query => query.fetchNextPage) -export const deploymentsListHasNextPageAtom = selectAtom(deploymentsListQueryAtom, query => query.hasNextPage) -export const deploymentsListIsFetchingAtom = selectAtom(deploymentsListQueryAtom, query => query.isFetching) -export const deploymentsListIsFetchingNextPageAtom = selectAtom(deploymentsListQueryAtom, query => query.isFetchingNextPage) -export const deploymentsListIsLoadingAtom = selectAtom(deploymentsListQueryAtom, query => query.isLoading) -const deploymentsListIsErrorAtom = selectAtom(deploymentsListQueryAtom, query => query.isError) +const deploymentsListDataAtom = selectAtom(deploymentsListQueryAtom, (query) => query.data) +export const deploymentsListErrorAtom = selectAtom(deploymentsListQueryAtom, (query) => query.error) +export const deploymentsListFetchNextPageAtom = selectAtom( + deploymentsListQueryAtom, + (query) => query.fetchNextPage, +) +export const deploymentsListHasNextPageAtom = selectAtom( + deploymentsListQueryAtom, + (query) => query.hasNextPage, +) +export const deploymentsListIsFetchingAtom = selectAtom( + deploymentsListQueryAtom, + (query) => query.isFetching, +) +export const deploymentsListIsFetchingNextPageAtom = selectAtom( + deploymentsListQueryAtom, + (query) => query.isFetchingNextPage, +) +export const deploymentsListIsLoadingAtom = selectAtom( + deploymentsListQueryAtom, + (query) => query.isLoading, +) +const deploymentsListIsErrorAtom = selectAtom(deploymentsListQueryAtom, (query) => query.isError) export const deploymentsListRowsAtom = atom((get) => { - return get(deploymentsListDataAtom)?.pages.flatMap(page => page.appInstanceSummaries) ?? [] + return get(deploymentsListDataAtom)?.pages.flatMap((page) => page.appInstanceSummaries) ?? [] }) export const deploymentsListShowSkeletonAtom = atom((get) => { const pages = get(deploymentsListDataAtom)?.pages ?? [] - return get(deploymentsListIsLoadingAtom) || (get(deploymentsListIsFetchingAtom) && pages.length === 0) + return ( + get(deploymentsListIsLoadingAtom) || (get(deploymentsListIsFetchingAtom) && pages.length === 0) + ) }) export const deploymentsListShowEmptyStateAtom = atom((get) => { - return !get(deploymentsListShowSkeletonAtom) - && !get(deploymentsListIsErrorAtom) - && get(deploymentsListRowsAtom).length === 0 + return ( + !get(deploymentsListShowSkeletonAtom) && + !get(deploymentsListIsErrorAtom) && + get(deploymentsListRowsAtom).length === 0 + ) }) export const deploymentsListShowErrorStateAtom = atom((get) => { - return !get(deploymentsListShowSkeletonAtom) - && get(deploymentsListIsErrorAtom) + return !get(deploymentsListShowSkeletonAtom) && get(deploymentsListIsErrorAtom) }) export const deploymentsListHasFilterAtom = atom((get) => { diff --git a/web/features/deployments/list/ui/__tests__/instance-card-sections.spec.tsx b/web/features/deployments/list/ui/__tests__/instance-card-sections.spec.tsx index d1bdfbe5c4358f..26e749805b0d87 100644 --- a/web/features/deployments/list/ui/__tests__/instance-card-sections.spec.tsx +++ b/web/features/deployments/list/ui/__tests__/instance-card-sections.spec.tsx @@ -27,8 +27,7 @@ function closestWithClass(element: HTMLElement, className: string) { let current: HTMLElement | null = element while (current) { - if (current.classList.contains(className)) - return current + if (current.classList.contains(className)) return current current = current.parentElement } @@ -47,9 +46,7 @@ describe('ReleaseMetaTooltip', () => { it('should use compact typography for the release metadata preview', async () => { render( <ReleaseMetaTooltip release={createRelease()} deployed> - <a href="/deployments/app-instance-1/releases"> - Initial release · 14 days ago - </a> + <a href="/deployments/app-instance-1/releases">Initial release · 14 days ago</a> </ReleaseMetaTooltip>, ) diff --git a/web/features/deployments/list/ui/create-deployment-button.tsx b/web/features/deployments/list/ui/create-deployment-button.tsx index 439181636a6c6e..7aa114592857a8 100644 --- a/web/features/deployments/list/ui/create-deployment-button.tsx +++ b/web/features/deployments/list/ui/create-deployment-button.tsx @@ -4,9 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' import { CreateDeploymentGuideLink } from '../../create-guide/link' -export function CreateDeploymentButton({ className }: { - className?: string -}) { +export function CreateDeploymentButton({ className }: { className?: string }) { const { t } = useTranslation('deployments') return ( @@ -17,7 +15,7 @@ export function CreateDeploymentButton({ className }: { )} > <span className="i-ri-add-line size-4 shrink-0" aria-hidden="true" /> - <span>{t($ => $['list.createDeployment'])}</span> + <span>{t(($) => $['list.createDeployment'])}</span> </CreateDeploymentGuideLink> ) } diff --git a/web/features/deployments/list/ui/environment-filter.tsx b/web/features/deployments/list/ui/environment-filter.tsx index fc0807a389b420..6717895b7fcc7d 100644 --- a/web/features/deployments/list/ui/environment-filter.tsx +++ b/web/features/deployments/list/ui/environment-filter.tsx @@ -22,17 +22,19 @@ function EnvironmentOptionIcon() { return <span className="i-ri-server-line size-[14px]" /> } -function EnvironmentFilterOptionIcon({ option }: { +function EnvironmentFilterOptionIcon({ + option, +}: { option: DeploymentsListEnvironmentFilterOption }) { - return option.kind === 'all' - ? <span className="i-ri-apps-2-line size-[14px]" /> - : <EnvironmentOptionIcon /> + return option.kind === 'all' ? ( + <span className="i-ri-apps-2-line size-[14px]" /> + ) : ( + <EnvironmentOptionIcon /> + ) } -export function EnvironmentFilter({ className }: { - className?: string -}) { +export function EnvironmentFilter({ className }: { className?: string }) { const { t } = useTranslation('deployments') const [open, setOpen] = useState(false) const [_envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState) @@ -41,7 +43,9 @@ export function EnvironmentFilter({ className }: { const activeFilter = selectedOption.value function optionText(option: DeploymentsListEnvironmentFilterOption) { - return option.kind === 'all' ? t($ => $['filter.allEnvs']) : option.displayName ?? option.value ?? '' + return option.kind === 'all' + ? t(($) => $['filter.allEnvs']) + : (option.displayName ?? option.value ?? '') } return ( @@ -60,7 +64,12 @@ export function EnvironmentFilter({ className }: { {optionText(selectedOption)} </div> <div className="shrink-0 p-px"> - <span className={cn('i-ri-arrow-down-s-line size-3.5 text-text-tertiary transition-transform', open && 'rotate-180')} /> + <span + className={cn( + 'i-ri-arrow-down-s-line size-3.5 text-text-tertiary transition-transform', + open && 'rotate-180', + )} + /> </div> </DropdownMenuTrigger> {open && ( @@ -70,7 +79,7 @@ export function EnvironmentFilter({ className }: { popupClassName="w-60 rounded-lg border border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs" > <div className="max-h-72 overflow-auto p-1"> - {filterOptions.map(option => ( + {filterOptions.map((option) => ( <DropdownMenuItem key={option.value ?? 'all'} onClick={() => { @@ -85,7 +94,9 @@ export function EnvironmentFilter({ className }: { <span className="shrink-0 text-text-tertiary"> <EnvironmentFilterOptionIcon option={option} /> </span> - <span className="grow truncate text-sm/5 text-text-tertiary">{optionText(option)}</span> + <span className="grow truncate text-sm/5 text-text-tertiary"> + {optionText(option)} + </span> {option.value === activeFilter && ( <span className="i-custom-vender-line-general-check size-4 shrink-0 text-text-secondary" /> )} diff --git a/web/features/deployments/list/ui/instance-card-sections.tsx b/web/features/deployments/list/ui/instance-card-sections.tsx index 20b96793ce4a1e..43c336b27a3683 100644 --- a/web/features/deployments/list/ui/instance-card-sections.tsx +++ b/web/features/deployments/list/ui/instance-card-sections.tsx @@ -9,7 +9,11 @@ import type { ReactElement } from 'react' import { ReleaseSource } from '@dify/contracts/enterprise/types.gen' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card' +import { + PreviewCard, + PreviewCardContent, + PreviewCardTrigger, +} from '@langgenius/dify-ui/preview-card' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' import { SkeletonRectangle } from '@/app/components/base/skeleton' @@ -21,29 +25,40 @@ import { getInstanceTabHref } from './instance-card-utils' const VISIBLE_ENVIRONMENT_COUNT = 3 -function releaseSourceLabel(release: Release | undefined, t: ReturnType<typeof useTranslation<'deployments'>>['t']) { +function releaseSourceLabel( + release: Release | undefined, + t: ReturnType<typeof useTranslation<'deployments'>>['t'], +) { if (release?.source === ReleaseSource.RELEASE_SOURCE_SOURCE_APP || release?.sourceAppId) - return t($ => $['versions.sourceAppOption']) + return t(($) => $['versions.sourceAppOption']) if (release?.source === ReleaseSource.RELEASE_SOURCE_UPLOAD) - return t($ => $['versions.manualDslOption']) + return t(($) => $['versions.manualDslOption']) return '—' } -export function ReleaseMetaTooltip({ release, deployed, children }: { +export function ReleaseMetaTooltip({ + release, + deployed, + children, +}: { release?: Release deployed: boolean children: ReactElement }) { const { t } = useTranslation('deployments') - if (!release) - return children + if (!release) return children const rows = [ - { label: t($ => $['card.tooltip.releaseName']), value: release.displayName }, - { label: t($ => $['card.tooltip.deploymentStatus']), value: deployed ? t($ => $['card.tooltip.deployed']) : t($ => $['card.tooltip.notDeployedShort']) }, - { label: t($ => $['card.tooltip.source']), value: releaseSourceLabel(release, t) }, - { label: t($ => $['card.tooltip.createdAt']), value: formatDate(release.createdAt) }, + { label: t(($) => $['card.tooltip.releaseName']), value: release.displayName }, + { + label: t(($) => $['card.tooltip.deploymentStatus']), + value: deployed + ? t(($) => $['card.tooltip.deployed']) + : t(($) => $['card.tooltip.notDeployedShort']), + }, + { label: t(($) => $['card.tooltip.source']), value: releaseSourceLabel(release, t) }, + { label: t(($) => $['card.tooltip.createdAt']), value: formatDate(release.createdAt) }, ] return ( @@ -51,7 +66,7 @@ export function ReleaseMetaTooltip({ release, deployed, children }: { <PreviewCardTrigger render={children} /> <PreviewCardContent popupClassName="px-3 py-2"> <div className="flex min-w-48 flex-col gap-1 system-xs-regular"> - {rows.map(row => ( + {rows.map((row) => ( <div key={row.label} className="flex justify-between gap-4"> <span className="shrink-0 text-text-tertiary">{row.label}</span> <span className="min-w-0 truncate text-right text-text-secondary">{row.value}</span> @@ -63,30 +78,30 @@ export function ReleaseMetaTooltip({ release, deployed, children }: { ) } -function EnvironmentChip({ row }: { - row: EnvironmentDeployment -}) { +function EnvironmentChip({ row }: { row: EnvironmentDeployment }) { const { t } = useTranslation('deployments') const name = row.environment.displayName const status = row.status - const statusLabel = t($ => $[deploymentStatusLabelKey(status)]) + const statusLabel = t(($) => $[deploymentStatusLabelKey(status)]) const tooltipSummary = [ name, row.currentRelease ? row.currentRelease.displayName : undefined, statusLabel, - ].filter(Boolean).join(' · ') + ] + .filter(Boolean) + .join(' · ') return ( <Tooltip> <TooltipTrigger - render={( + render={ <EnvironmentDeploymentBadge row={row} showStatus={false} summaryLabel={tooltipSummary} className="max-w-44" /> - )} + } /> <TooltipContent> <span className="whitespace-nowrap text-text-secondary">{tooltipSummary}</span> @@ -95,22 +110,20 @@ function EnvironmentChip({ row }: { ) } -function EnvironmentOverflow({ rows }: { - rows: EnvironmentDeployment[] -}) { +function EnvironmentOverflow({ rows }: { rows: EnvironmentDeployment[] }) { const { t } = useTranslation('deployments') return ( <Popover> <PopoverTrigger - render={( + render={ <button type="button" className="inline-flex h-5 cursor-pointer items-center rounded-md bg-background-section-burn px-1.5 system-xs-medium text-text-tertiary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > - {t($ => $['card.envOverflow'], { count: rows.length })} + {t(($) => $['card.envOverflow'], { count: rows.length })} </button> - )} + } /> <PopoverContent popupClassName="px-3 py-2"> <div className="flex min-w-40 flex-col gap-1"> @@ -119,11 +132,15 @@ function EnvironmentOverflow({ rows }: { const summary = [ row.environment.displayName, row.currentRelease ? row.currentRelease.displayName : undefined, - t($ => $[deploymentStatusLabelKey(status)]), - ].filter(Boolean).join(' · ') + t(($) => $[deploymentStatusLabelKey(status)]), + ] + .filter(Boolean) + .join(' · ') return ( - <span key={row.environment.id} className="whitespace-nowrap text-text-secondary">{summary}</span> + <span key={row.environment.id} className="whitespace-nowrap text-text-secondary"> + {summary} + </span> ) })} </div> @@ -145,7 +162,7 @@ export function DeploymentStatusContent({ if (rows.length > 0) { return ( <div className="flex min-w-0 flex-wrap items-center gap-1.5"> - {visibleRows.map(row => ( + {visibleRows.map((row) => ( <EnvironmentChip key={row.environment.id} row={row} /> ))} {overflowRows.length > 0 && <EnvironmentOverflow rows={overflowRows} />} @@ -153,13 +170,16 @@ export function DeploymentStatusContent({ ) } - if (emptyAction) - return <div className="flex min-w-0 items-center">{emptyAction}</div> + if (emptyAction) return <div className="flex min-w-0 items-center">{emptyAction}</div> return null } -export function DeploymentAccessLinks({ appInstanceId, access, isLoading }: { +export function DeploymentAccessLinks({ + appInstanceId, + access, + isLoading, +}: { appInstanceId: string access?: AccessChannels isLoading?: boolean @@ -168,7 +188,11 @@ export function DeploymentAccessLinks({ appInstanceId, access, isLoading }: { if (isLoading) { return ( - <div role="group" aria-label={t($ => $['overview.accessStatus'])} className="flex min-w-0 grow items-center gap-2"> + <div + role="group" + aria-label={t(($) => $['overview.accessStatus'])} + className="flex min-w-0 grow items-center gap-2" + > <SkeletonRectangle className="my-0 size-4 animate-pulse rounded-sm" /> <SkeletonRectangle className="my-0 size-4 animate-pulse rounded-sm" /> </div> @@ -177,46 +201,60 @@ export function DeploymentAccessLinks({ appInstanceId, access, isLoading }: { const links = [ ...(access?.webAppEnabled - ? [{ - key: 'webapp', - href: getInstanceTabHref(appInstanceId, 'access'), - label: t($ => $['card.access.webApp']), - icon: 'i-ri-global-line', - }] + ? [ + { + key: 'webapp', + href: getInstanceTabHref(appInstanceId, 'access'), + label: t(($) => $['card.access.webApp']), + icon: 'i-ri-global-line', + }, + ] : []), ...(access?.webAppEnabled - ? [{ - key: 'cli', - href: getInstanceTabHref(appInstanceId, 'access'), - label: t($ => $['card.access.cli']), - icon: 'i-ri-terminal-box-line', - }] + ? [ + { + key: 'cli', + href: getInstanceTabHref(appInstanceId, 'access'), + label: t(($) => $['card.access.cli']), + icon: 'i-ri-terminal-box-line', + }, + ] : []), ...(access?.developerApiEnabled - ? [{ - key: 'api-tokens', - href: getInstanceTabHref(appInstanceId, 'api-tokens'), - label: t($ => $['card.access.api']), - icon: 'i-ri-code-s-slash-line', - }] + ? [ + { + key: 'api-tokens', + href: getInstanceTabHref(appInstanceId, 'api-tokens'), + label: t(($) => $['card.access.api']), + icon: 'i-ri-code-s-slash-line', + }, + ] : []), ] if (links.length === 0) { return ( - <div role="group" aria-label={t($ => $['overview.accessStatus'])} className="flex min-w-0 grow items-center gap-1.5 text-text-quaternary"> + <div + role="group" + aria-label={t(($) => $['overview.accessStatus'])} + className="flex min-w-0 grow items-center gap-1.5 text-text-quaternary" + > <span aria-hidden className="i-ri-link-unlink size-3.5 shrink-0" /> - <span className="truncate system-xs-regular">{t($ => $['card.access.none'])}</span> + <span className="truncate system-xs-regular">{t(($) => $['card.access.none'])}</span> </div> ) } return ( - <div role="group" aria-label={t($ => $['overview.accessStatus'])} className="flex min-w-0 grow items-center gap-2"> - {links.map(link => ( + <div + role="group" + aria-label={t(($) => $['overview.accessStatus'])} + className="flex min-w-0 grow items-center gap-2" + > + {links.map((link) => ( <Tooltip key={link.key}> <TooltipTrigger - render={( + render={ <Link href={link.href} aria-label={link.label} @@ -226,7 +264,7 @@ export function DeploymentAccessLinks({ appInstanceId, access, isLoading }: { > <span aria-hidden className={cn('size-3.5', link.icon)} /> </Link> - )} + } /> <TooltipContent>{link.label}</TooltipContent> </Tooltip> diff --git a/web/features/deployments/list/ui/instance-card-utils.ts b/web/features/deployments/list/ui/instance-card-utils.ts index dd1b3c40b3dc2c..9fc43487c66a00 100644 --- a/web/features/deployments/list/ui/instance-card-utils.ts +++ b/web/features/deployments/list/ui/instance-card-utils.ts @@ -1,7 +1,4 @@ -import type { - EnvironmentDeployment, - Release, -} from '@dify/contracts/enterprise/types.gen' +import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen' import type { InstanceDetailTabKey } from '../../detail/tabs' import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status' @@ -14,8 +11,7 @@ export function isActiveDeployment(row: EnvironmentDeployment) { } export function isReleaseDeployed(release: Release | undefined, rows: EnvironmentDeployment[]) { - if (!release) - return false + if (!release) return false - return rows.some(row => row.currentRelease?.id === release.id) + return rows.some((row) => row.currentRelease?.id === release.id) } diff --git a/web/features/deployments/list/ui/instance-card.tsx b/web/features/deployments/list/ui/instance-card.tsx index e3b819fbb742f1..23942928e0c773 100644 --- a/web/features/deployments/list/ui/instance-card.tsx +++ b/web/features/deployments/list/ui/instance-card.tsx @@ -1,8 +1,6 @@ 'use client' -import type { - AppInstanceSummary, -} from '@dify/contracts/enterprise/types.gen' +import type { AppInstanceSummary } from '@dify/contracts/enterprise/types.gen' import { Button } from '@langgenius/dify-ui/button' import { useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' @@ -16,15 +14,9 @@ import { DeploymentStatusContent, ReleaseMetaTooltip, } from './instance-card-sections' -import { - getInstanceTabHref, - isActiveDeployment, - isReleaseDeployed, -} from './instance-card-utils' +import { getInstanceTabHref, isActiveDeployment, isReleaseDeployed } from './instance-card-utils' -export function InstanceCard({ summary }: { - summary: AppInstanceSummary -}) { +export function InstanceCard({ summary }: { summary: AppInstanceSummary }) { const { t } = useTranslation('deployments') const { formatTimeFromNow } = useFormatTimeFromNow() const openDeployDrawer = useSetAtom(openDeployDrawerAtom) @@ -45,15 +37,15 @@ export function InstanceCard({ summary }: { ? [ latestRelease.displayName, Number.isNaN(latestReleaseTimeMs) ? undefined : formatTimeFromNow(latestReleaseTimeMs), - ].filter(Boolean).join(' · ') - : t($ => $['card.notDeployed']) + ] + .filter(Boolean) + .join(' · ') + : t(($) => $['card.notDeployed']) const showDeployAction = hasRelease && activeDeploymentRows.length === 0 const showFooterCreateReleaseAction = !hasRelease return ( - <div - className="group relative col-span-1 inline-flex min-h-40 min-w-0 cursor-default flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs transition-[border-color,box-shadow] duration-200 ease-in-out hover:border-components-panel-border-subtle hover:shadow-md" - > + <div className="group relative col-span-1 inline-flex min-h-40 min-w-0 cursor-default flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs transition-[border-color,box-shadow] duration-200 ease-in-out hover:border-components-panel-border-subtle hover:shadow-md"> <DeploymentActionsMenu appInstance={appInstance} placement="bottom-end" @@ -66,56 +58,58 @@ export function InstanceCard({ summary }: { href={detailHref} className="block min-w-0 rounded-t-xl px-4 pt-4 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" > - <h3 className="truncate title-md-semi-bold text-text-primary"> - {appName} - </h3> - {description - ? ( - <p className="mt-2 line-clamp-2 system-xs-regular text-text-tertiary"> - {description} - </p> - ) - : ( - <p className="mt-2 truncate system-xs-regular text-text-quaternary"> - {t($ => $['card.noDescription'])} - </p> - )} + <h3 className="truncate title-md-semi-bold text-text-primary">{appName}</h3> + {description ? ( + <p className="mt-2 line-clamp-2 system-xs-regular text-text-tertiary">{description}</p> + ) : ( + <p className="mt-2 truncate system-xs-regular text-text-quaternary"> + {t(($) => $['card.noDescription'])} + </p> + )} </Link> - <div role="group" aria-label={t($ => $['card.tooltip.deploymentStatus'])} className="min-h-8 px-4 pt-4 pb-3"> + <div + role="group" + aria-label={t(($) => $['card.tooltip.deploymentStatus'])} + className="min-h-8 px-4 pt-4 pb-3" + > <DeploymentStatusContent rows={activeDeploymentRows} - emptyAction={showDeployAction - ? ( - <Button - variant="secondary-accent" - size="small" - className="max-w-full" - onClick={() => openDeployDrawer({ appInstanceId })} - > - <span className="truncate">{t($ => $['card.menu.deploy'])}</span> - </Button> - ) - : undefined} + emptyAction={ + showDeployAction ? ( + <Button + variant="secondary-accent" + size="small" + className="max-w-full" + onClick={() => openDeployDrawer({ appInstanceId })} + > + <span className="truncate">{t(($) => $['card.menu.deploy'])}</span> + </Button> + ) : undefined + } /> </div> <div className="mt-auto flex min-h-11 min-w-0 items-center gap-3 border-t border-divider-subtle px-4 py-2"> - {showFooterCreateReleaseAction - ? ( - <div className="-ml-2 flex min-w-0 grow items-center"> - <CreateReleaseControl - appInstanceId={appInstanceId} - variant="secondary-accent" - label={t($ => $['card.createFirstRelease'])} - className="max-w-full" - /> - </div> - ) - : <DeploymentAccessLinks appInstanceId={appInstanceId} access={access} />} + {showFooterCreateReleaseAction ? ( + <div className="-ml-2 flex min-w-0 grow items-center"> + <CreateReleaseControl + appInstanceId={appInstanceId} + variant="secondary-accent" + label={t(($) => $['card.createFirstRelease'])} + className="max-w-full" + /> + </div> + ) : ( + <DeploymentAccessLinks appInstanceId={appInstanceId} access={access} /> + )} <ReleaseMetaTooltip release={latestRelease} deployed={latestReleaseDeployed}> <Link - href={latestRelease ? getInstanceTabHref(appInstanceId, 'releases') : getInstanceTabHref(appInstanceId, 'instances')} + href={ + latestRelease + ? getInstanceTabHref(appInstanceId, 'releases') + : getInstanceTabHref(appInstanceId, 'instances') + } className="min-w-0 shrink truncate text-right system-xs-medium text-text-secondary hover:text-text-primary" > {releaseMeta} diff --git a/web/features/deployments/list/ui/shell.tsx b/web/features/deployments/list/ui/shell.tsx index b286cee0e418b1..b52ac6eb8dd45d 100644 --- a/web/features/deployments/list/ui/shell.tsx +++ b/web/features/deployments/list/ui/shell.tsx @@ -32,9 +32,7 @@ import { InstanceCard } from './instance-card' const INSTANCE_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth'] -function DeploymentsListState({ children }: { - children: ReactNode -}) { +function DeploymentsListState({ children }: { children: ReactNode }) { return <DeploymentStateMessage variant="page">{children}</DeploymentStateMessage> } @@ -53,15 +51,21 @@ function DeploymentsListEmpty() { <DeploymentEmptyState variant="page" icon={hasFilter ? 'i-ri-search-line' : 'i-ri-rocket-line'} - title={hasFilter ? t($ => $['list.emptyFilteredTitle']) : t($ => $['list.emptyTitle'])} - description={hasFilter ? t($ => $['list.emptyFilteredDescription']) : t($ => $['list.emptyDescription'])} - action={hasFilter - ? ( - <Button variant="secondary" size="small" onClick={clearFilters}> - {t($ => $['list.clearFilters'])} - </Button> - ) - : <CreateDeploymentButton />} + title={hasFilter ? t(($) => $['list.emptyFilteredTitle']) : t(($) => $['list.emptyTitle'])} + description={ + hasFilter + ? t(($) => $['list.emptyFilteredDescription']) + : t(($) => $['list.emptyDescription']) + } + action={ + hasFilter ? ( + <Button variant="secondary" size="small" onClick={clearFilters}> + {t(($) => $['list.clearFilters'])} + </Button> + ) : ( + <CreateDeploymentButton /> + ) + } /> ) } @@ -95,14 +99,10 @@ function InstanceCardSkeleton() { } function DeploymentsListSkeleton() { - return INSTANCE_CARD_SKELETON_KEYS.map(key => ( - <InstanceCardSkeleton key={key} /> - )) + return INSTANCE_CARD_SKELETON_KEYS.map((key) => <InstanceCardSkeleton key={key} />) } -function DeploymentsSearchInput({ className }: { - className?: string -}) { +function DeploymentsSearchInput({ className }: { className?: string }) { const { t } = useTranslation('deployments') const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState) @@ -115,18 +115,21 @@ function DeploymentsSearchInput({ className }: { return ( <div className={cn('relative w-50', className)}> - <span aria-hidden className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" /> + <span + aria-hidden + className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" + /> <Input className="h-8 pr-8 pl-8" - aria-label={t($ => $['filter.searchPlaceholder'])} - placeholder={t($ => $['filter.searchPlaceholder'])} + aria-label={t(($) => $['filter.searchPlaceholder'])} + placeholder={t(($) => $['filter.searchPlaceholder'])} value={keywords} - onChange={e => handleKeywordsChange(e.target.value)} + onChange={(e) => handleKeywordsChange(e.target.value)} /> {keywords && ( <button type="button" - aria-label={t($ => $['list.clearSearch'])} + aria-label={t(($) => $['list.clearSearch'])} className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary" onClick={() => handleKeywordsChange('')} > @@ -142,11 +145,13 @@ function DeploymentsListControls() { return ( <StudioListHeader - title={( + title={ <div className="flex items-center"> - <h1 className="text-[18px]/[21.6px] font-semibold text-text-primary">{t($ => $['menus.deployments'], { ns: 'common' })}</h1> + <h1 className="text-[18px]/[21.6px] font-semibold text-text-primary"> + {t(($) => $['menus.deployments'], { ns: 'common' })} + </h1> </div> - )} + } > <div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3"> <div className="flex min-w-0 items-center justify-between gap-2 sm:justify-start"> @@ -183,25 +188,28 @@ export function DeploymentsListShell() { }) return ( - <div ref={rootRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body"> + <div + ref={rootRef} + className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body" + > <DeploymentsListControls /> - <div className={cn( - 'relative grid grow grid-cols-[repeat(auto-fill,minmax(min(100%,20rem),1fr))] content-start gap-4 px-8 pt-2 pb-8', - showEmptyState && 'overflow-hidden', - )} + <div + className={cn( + 'relative grid grow grid-cols-[repeat(auto-fill,minmax(min(100%,20rem),1fr))] content-start gap-4 px-8 pt-2 pb-8', + showEmptyState && 'overflow-hidden', + )} > - {showSkeleton - ? <DeploymentsListSkeleton /> - : showErrorState - ? <DeploymentsListState>{t($ => $['common.loadFailed'])}</DeploymentsListState> - : showEmptyState - ? <DeploymentsListEmpty /> - : appInstanceSummaries.map(summary => ( - <InstanceCard - key={summary.appInstance.id} - summary={summary} - /> - ))} + {showSkeleton ? ( + <DeploymentsListSkeleton /> + ) : showErrorState ? ( + <DeploymentsListState>{t(($) => $['common.loadFailed'])}</DeploymentsListState> + ) : showEmptyState ? ( + <DeploymentsListEmpty /> + ) : ( + appInstanceSummaries.map((summary) => ( + <InstanceCard key={summary.appInstance.id} summary={summary} /> + )) + )} {deploymentsListIsFetchingNextPage && <DeploymentsListSkeleton />} <div ref={sentinelRef} aria-hidden="true" className="col-span-full h-px" /> </div> diff --git a/web/features/deployments/route-state.ts b/web/features/deployments/route-state.ts index ca62e612a9a4ca..72463fa0fede3b 100644 --- a/web/features/deployments/route-state.ts +++ b/web/features/deployments/route-state.ts @@ -1,9 +1,7 @@ 'use client' import { atom } from 'jotai' -import { - nextParamsAtom, -} from '@/app/components/next-route-state/atoms' +import { nextParamsAtom } from '@/app/components/next-route-state/atoms' export const deploymentRouteAppInstanceIdAtom = atom((get) => { const appInstanceId = get(nextParamsAtom).appInstanceId diff --git a/web/features/deployments/shared/components/__tests__/env-var-bindings-utils.spec.ts b/web/features/deployments/shared/components/__tests__/env-var-bindings-utils.spec.ts index 4541f075e2510d..ab177b3b3557c5 100644 --- a/web/features/deployments/shared/components/__tests__/env-var-bindings-utils.spec.ts +++ b/web/features/deployments/shared/components/__tests__/env-var-bindings-utils.spec.ts @@ -1,10 +1,7 @@ import type { EnvVarSlot } from '@dify/contracts/enterprise/types.gen' import { EnvVarValueType } from '@dify/contracts/enterprise/types.gen' import { describe, expect, it } from 'vitest' -import { - envVarBindingSlotFromContract, - envVarBindingValueType, -} from '../env-var-bindings-utils' +import { envVarBindingSlotFromContract, envVarBindingValueType } from '../env-var-bindings-utils' function slot(overrides: Partial<EnvVarSlot>): EnvVarSlot { return { @@ -28,12 +25,16 @@ describe('env var binding value type normalization', () => { describe('env var contract slot conversion', () => { it('should trim keys and preserve default and last value availability', () => { - expect(envVarBindingSlotFromContract(slot({ - key: ' API_TOKEN ', - valueType: EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET, - defaultValue: '', - lastValue: 'previous', - }))).toMatchObject({ + expect( + envVarBindingSlotFromContract( + slot({ + key: ' API_TOKEN ', + valueType: EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET, + defaultValue: '', + lastValue: 'previous', + }), + ), + ).toMatchObject({ key: 'API_TOKEN', valueType: 'secret', hasDefaultValue: true, @@ -43,7 +44,11 @@ describe('env var contract slot conversion', () => { it('should ignore blank keys and mark absent values as unavailable', () => { expect(envVarBindingSlotFromContract(slot({ key: ' ' }))).toBeUndefined() - expect(envVarBindingSlotFromContract(slot({ key: 'PORT', valueType: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER }))).toMatchObject({ + expect( + envVarBindingSlotFromContract( + slot({ key: 'PORT', valueType: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER }), + ), + ).toMatchObject({ key: 'PORT', valueType: 'number', hasDefaultValue: false, diff --git a/web/features/deployments/shared/components/__tests__/runtime-credential-bindings-utils.spec.ts b/web/features/deployments/shared/components/__tests__/runtime-credential-bindings-utils.spec.ts index 54b67adad22d0a..70513a638cd4ff 100644 --- a/web/features/deployments/shared/components/__tests__/runtime-credential-bindings-utils.spec.ts +++ b/web/features/deployments/shared/components/__tests__/runtime-credential-bindings-utils.spec.ts @@ -1,7 +1,4 @@ -import type { - CredentialCandidate, - CredentialSlot, -} from '@dify/contracts/enterprise/types.gen' +import type { CredentialCandidate, CredentialSlot } from '@dify/contracts/enterprise/types.gen' import { PluginCategory } from '@dify/contracts/enterprise/types.gen' import { describe, expect, it } from 'vitest' import { @@ -95,13 +92,12 @@ describe('runtime credential selection helpers', () => { lastCredentialId: 'stale', }) - expect(selectedRuntimeCredentialSelections( - [firstSlot, secondSlot, thirdSlot], - { + expect( + selectedRuntimeCredentialSelections([firstSlot, secondSlot, thirdSlot], { [runtimeCredentialSlotKey(firstSlot)]: 'credential-1', [runtimeCredentialSlotKey(secondSlot)]: 'stale', - }, - )).toEqual({ + }), + ).toEqual({ [runtimeCredentialSlotKey(firstSlot)]: 'credential-1', [runtimeCredentialSlotKey(secondSlot)]: 'bedrock-1', }) @@ -113,12 +109,11 @@ describe('runtime credential selection helpers', () => { expect(hasMissingRequiredRuntimeCredentialBinding(firstSlot)).toBe(true) expect(hasMissingRequiredRuntimeCredentialBinding(firstSlot, 'credential-1')).toBe(false) - expect(selectedDeploymentRuntimeCredentials( - [firstSlot, secondSlot], - { + expect( + selectedDeploymentRuntimeCredentials([firstSlot, secondSlot], { [runtimeCredentialSlotKey(firstSlot)]: 'credential-1', - }, - )).toEqual([ + }), + ).toEqual([ { providerId: 'langgenius/openai', category: PluginCategory.PLUGIN_CATEGORY_MODEL, diff --git a/web/features/deployments/shared/components/__tests__/title-tooltip.spec.tsx b/web/features/deployments/shared/components/__tests__/title-tooltip.spec.tsx index 290f8c859d805b..131771e5daa2cc 100644 --- a/web/features/deployments/shared/components/__tests__/title-tooltip.spec.tsx +++ b/web/features/deployments/shared/components/__tests__/title-tooltip.spec.tsx @@ -4,26 +4,21 @@ import { describe, expect, it, vi } from 'vitest' import { TitleTooltip } from '../title-tooltip' vi.mock('@langgenius/dify-ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => ( - <div data-testid="tooltip"> - {children} - </div> - ), + Tooltip: ({ children }: { children: ReactNode }) => <div data-testid="tooltip">{children}</div>, TooltipTrigger: ({ render }: { render: ReactNode }) => <>{render}</>, - TooltipContent: ({ children }: { children: ReactNode }) => ( - <div role="tooltip"> - {children} - </div> - ), + TooltipContent: ({ children }: { children: ReactNode }) => <div role="tooltip">{children}</div>, })) -function setElementSize(element: HTMLElement, { - clientWidth, - scrollWidth, -}: { - clientWidth: number - scrollWidth: number -}) { +function setElementSize( + element: HTMLElement, + { + clientWidth, + scrollWidth, + }: { + clientWidth: number + scrollWidth: number + }, +) { Object.defineProperties(element, { clientWidth: { configurable: true, value: clientWidth }, scrollWidth: { configurable: true, value: scrollWidth }, @@ -66,6 +61,8 @@ describe('TitleTooltip', () => { fireEvent.pointerOver(screen.getByRole('button', { name: 'Deploy' })) - expect(screen.getByRole('tooltip')).toHaveTextContent('Disabled until an initial release exists') + expect(screen.getByRole('tooltip')).toHaveTextContent( + 'Disabled until an initial release exists', + ) }) }) diff --git a/web/features/deployments/shared/components/detail-table.tsx b/web/features/deployments/shared/components/detail-table.tsx index eef2e5ee80d6d0..4b2b7491678b9a 100644 --- a/web/features/deployments/shared/components/detail-table.tsx +++ b/web/features/deployments/shared/components/detail-table.tsx @@ -13,7 +13,10 @@ export function DetailTable({ className, containerClassName, ...props }: DetailT > <table data-slot="deployment-detail-table" - className={cn('w-full max-w-full min-w-[700px] border-collapse border-0 text-sm', className)} + className={cn( + 'w-full max-w-full min-w-[700px] border-collapse border-0 text-sm', + className, + )} {...props} /> </div> @@ -24,7 +27,10 @@ export function DetailTableHeader({ className, ...props }: ComponentProps<'thead return ( <thead data-slot="deployment-detail-table-header" - className={cn('h-8 border-b border-divider-subtle system-xs-medium-uppercase text-text-tertiary [&_tr]:border-b-0 [&_tr]:hover:bg-transparent', className)} + className={cn( + 'h-8 border-b border-divider-subtle system-xs-medium-uppercase text-text-tertiary [&_tr]:border-b-0 [&_tr]:hover:bg-transparent', + className, + )} {...props} /> ) @@ -44,7 +50,10 @@ export function DetailTableRow({ className, ...props }: ComponentProps<'tr'>) { return ( <tr data-slot="deployment-detail-table-row" - className={cn('h-8 border-b border-divider-subtle transition-colors hover:bg-background-default-hover', className)} + className={cn( + 'h-8 border-b border-divider-subtle transition-colors hover:bg-background-default-hover', + className, + )} {...props} /> ) @@ -54,7 +63,10 @@ export function DetailTableHead({ className, ...props }: ComponentProps<'th'>) { return ( <th data-slot="deployment-detail-table-head" - className={cn(className, 'box-border max-w-[200px] px-2.5 py-0 text-left align-middle font-medium whitespace-nowrap first:pl-3')} + className={cn( + className, + 'box-border max-w-[200px] px-2.5 py-0 text-left align-middle font-medium whitespace-nowrap first:pl-3', + )} {...props} /> ) @@ -74,7 +86,10 @@ export function DetailTableCardList({ className, ...props }: ComponentProps<'div return ( <div data-slot="deployment-detail-table-card-list" - className={cn('overflow-hidden rounded-lg border border-divider-subtle bg-background-default', className)} + className={cn( + 'overflow-hidden rounded-lg border border-divider-subtle bg-background-default', + className, + )} {...props} /> ) @@ -84,7 +99,10 @@ export function DetailTableCard({ className, ...props }: ComponentProps<'div'>) return ( <div data-slot="deployment-detail-table-card" - className={cn('border-b border-divider-subtle last:border-b-0 hover:bg-background-default-hover', className)} + className={cn( + 'border-b border-divider-subtle last:border-b-0 hover:bg-background-default-hover', + className, + )} {...props} /> ) diff --git a/web/features/deployments/shared/components/empty-state.tsx b/web/features/deployments/shared/components/empty-state.tsx index fe2fe2d3217b5d..3cbcd7bd2c0cfb 100644 --- a/web/features/deployments/shared/components/empty-state.tsx +++ b/web/features/deployments/shared/components/empty-state.tsx @@ -32,15 +32,19 @@ type DeploymentNoticeStateProps = { const emptyStateContainerClassNames: Record<DeploymentEmptyStateVariant, string> = { page: 'col-span-full min-h-80 rounded-xl border border-divider-subtle bg-background-default-subtle px-6 py-12', list: 'min-h-60 rounded-lg border border-divider-subtle bg-background-default-subtle px-6 py-12', - section: 'min-h-36 rounded-lg border border-divider-subtle bg-background-default-subtle px-6 py-8', - compact: 'min-h-14 rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3', + section: + 'min-h-36 rounded-lg border border-divider-subtle bg-background-default-subtle px-6 py-8', + compact: + 'min-h-14 rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3', } const stateMessageClassNames: Record<DeploymentStateMessageVariant, string> = { page: 'col-span-full flex min-h-80 items-center justify-center rounded-xl border border-dashed border-divider-subtle bg-background-default-subtle px-6 py-12 text-center system-sm-regular text-text-tertiary', list: 'flex min-h-36 items-center justify-center rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-6 py-12 text-center system-sm-regular text-text-tertiary', - section: 'flex min-h-24 items-center justify-center rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-4 py-6 text-center system-sm-regular text-text-tertiary', - compact: 'rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-tertiary', + section: + 'flex min-h-24 items-center justify-center rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-4 py-6 text-center system-sm-regular text-text-tertiary', + compact: + 'rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-tertiary', embedded: 'px-4 py-10 text-center system-sm-regular text-text-tertiary', } @@ -79,10 +83,7 @@ export function DeploymentEmptyState({ )} > <span - className={cn( - icon, - isLarge ? 'size-5' : variant === 'section' ? 'size-4.5' : 'size-4', - )} + className={cn(icon, isLarge ? 'size-5' : variant === 'section' ? 'size-4.5' : 'size-4')} aria-hidden="true" /> </span> @@ -109,9 +110,7 @@ export function DeploymentEmptyState({ </p> )} {hasAction && ( - <div className={isLarge ? 'mt-5' : variant === 'compact' ? 'mt-3' : 'mt-4'}> - {action} - </div> + <div className={isLarge ? 'mt-5' : variant === 'compact' ? 'mt-3' : 'mt-4'}>{action}</div> )} </div> ) @@ -122,11 +121,7 @@ export function DeploymentStateMessage({ variant = 'list', className, }: DeploymentStateMessageProps) { - return ( - <div className={cn(stateMessageClassNames[variant], className)}> - {children} - </div> - ) + return <div className={cn(stateMessageClassNames[variant], className)}>{children}</div> } export function DeploymentNoticeState({ @@ -135,8 +130,16 @@ export function DeploymentNoticeState({ className, }: DeploymentNoticeStateProps) { return ( - <div className={cn('flex min-h-9 items-start gap-1.5 rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-xs-regular text-text-tertiary', className)}> - <span className={cn(icon, 'mt-0.5 size-3.5 shrink-0 text-text-quaternary')} aria-hidden="true" /> + <div + className={cn( + 'flex min-h-9 items-start gap-1.5 rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-xs-regular text-text-tertiary', + className, + )} + > + <span + className={cn(icon, 'mt-0.5 size-3.5 shrink-0 text-text-quaternary')} + aria-hidden="true" + /> <span className="min-w-0">{children}</span> </div> ) diff --git a/web/features/deployments/shared/components/endpoint.tsx b/web/features/deployments/shared/components/endpoint.tsx index f8102f73829bc4..e4f7233e0fecc0 100644 --- a/web/features/deployments/shared/components/endpoint.tsx +++ b/web/features/deployments/shared/components/endpoint.tsx @@ -17,7 +17,7 @@ export function CopyPill({ label, value, prefix, className }: CopyPillProps) { const { t } = useTranslation('deployments') const { copied, copy } = useClipboard({ onCopyError: () => { - toast.error(t($ => $['access.copyFailed'])) + toast.error(t(($) => $['access.copyFailed'])) }, }) @@ -39,7 +39,7 @@ export function CopyPill({ label, value, prefix, className }: CopyPillProps) { <button type="button" onClick={() => copy(value)} - aria-label={t($ => $['access.copy'])} + aria-label={t(($) => $['access.copy'])} className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" > <span className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'size-3.5')} /> @@ -58,9 +58,7 @@ type EndpointRowProps = { export function EndpointRow({ envName, label, value, openLabel }: EndpointRowProps) { return ( <div className="grid items-center gap-x-3 gap-y-1.5 sm:grid-cols-[minmax(88px,108px)_minmax(0,1fr)_auto]"> - <span className="min-w-0 truncate system-xs-regular text-text-tertiary"> - {envName} - </span> + <span className="min-w-0 truncate system-xs-regular text-text-tertiary">{envName}</span> <CopyPill label={label} value={value} className="min-w-0" /> {openLabel && ( <a diff --git a/web/features/deployments/shared/components/env-var-bindings-utils.ts b/web/features/deployments/shared/components/env-var-bindings-utils.ts index 422fdfbca5f830..5007bfe3dcd561 100644 --- a/web/features/deployments/shared/components/env-var-bindings-utils.ts +++ b/web/features/deployments/shared/components/env-var-bindings-utils.ts @@ -1,7 +1,9 @@ import type { EnvVarSlot } from '@dify/contracts/enterprise/types.gen' import type { EnvVarBindingSlot } from './env-var-bindings' -export function envVarBindingValueType(valueType?: EnvVarSlot['valueType'] | string): EnvVarBindingSlot['valueType'] { +export function envVarBindingValueType( + valueType?: EnvVarSlot['valueType'] | string, +): EnvVarBindingSlot['valueType'] { switch (valueType) { case 'ENV_VAR_VALUE_TYPE_NUMBER': case 'number': @@ -16,8 +18,7 @@ export function envVarBindingValueType(valueType?: EnvVarSlot['valueType'] | str export function envVarBindingSlotFromContract(slot: EnvVarSlot): EnvVarBindingSlot | undefined { const key = slot.key.trim() - if (!key) - return undefined + if (!key) return undefined return { ...slot, diff --git a/web/features/deployments/shared/components/env-var-bindings.tsx b/web/features/deployments/shared/components/env-var-bindings.tsx index 714e0c956238c9..396514fca85e08 100644 --- a/web/features/deployments/shared/components/env-var-bindings.tsx +++ b/web/features/deployments/shared/components/env-var-bindings.tsx @@ -1,16 +1,11 @@ 'use client' import type { EnvVarInput, EnvVarSlot } from '@dify/contracts/enterprise/types.gen' -import type { - InputHTMLAttributes, -} from 'react' +import type { InputHTMLAttributes } from 'react' import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen' import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' -import { - SegmentedControl, - SegmentedControlItem, -} from '@langgenius/dify-ui/segmented-control' +import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control' import { TitleTooltip } from './title-tooltip' export type EnvVarValueSource = NonNullable<EnvVarInput['valueSource']> @@ -70,11 +65,14 @@ function envVarInputId(index: number, key: string) { return `env-var-binding-${index}-${safeKey}` } -function envVarValueSourceOptions(slot: EnvVarBindingSlot, labels: { - literal: string - defaultValue: string - lastDeployment: string -}): EnvVarValueSourceOption[] { +function envVarValueSourceOptions( + slot: EnvVarBindingSlot, + labels: { + literal: string + defaultValue: string + lastDeployment: string + }, +): EnvVarValueSourceOption[] { const options: EnvVarValueSourceOption[] = [ { value: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL, @@ -119,11 +117,15 @@ export function EnvVarBindingsPanel({ className, listClassName, }: EnvVarBindingsPanelProps) { - if (slots.length === 0) - return null + if (slots.length === 0) return null return ( - <div className={cn('overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle', className)}> + <div + className={cn( + 'overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle', + className, + )} + > <div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5"> <div className="flex min-w-0 items-center gap-2"> <div className="system-xs-medium-uppercase text-text-tertiary">{title}</div> @@ -144,38 +146,51 @@ export function EnvVarBindingsPanel({ const description = slot.description const inputId = envVarInputId(index, slot.key) const selection = values[slot.key] - const defaultValueSource = defaultSourcePriority === 'lastDeployment' && slot.hasLastValue - ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT - : slot.hasDefaultValue - ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT - : slot.hasLastValue - ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT - : ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL + const defaultValueSource = + defaultSourcePriority === 'lastDeployment' && slot.hasLastValue + ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT + : slot.hasDefaultValue + ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT + : slot.hasLastValue + ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT + : ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL const valueSource = selection?.valueSource ?? defaultValueSource - const invalidLiteralNumber = slot.valueType === 'number' && Number.isNaN(Number(selection?.value)) - const missing = showMissingRequired - && valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL - && (!selection?.value || invalidLiteralNumber) + const invalidLiteralNumber = + slot.valueType === 'number' && Number.isNaN(Number(selection?.value)) + const missing = + showMissingRequired && + valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL && + (!selection?.value || invalidLiteralNumber) const sourceOptions = envVarValueSourceOptions(slot, { literal: literalSourceLabel, defaultValue: defaultSourceLabel, lastDeployment: lastDeploymentSourceLabel, }) const isLiteralValue = valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL - const displayValue = valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT - ? slot.defaultValue - : valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT - ? slot.lastValue - : selection?.value + const displayValue = + valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT + ? slot.defaultValue + : valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT + ? slot.lastValue + : selection?.value return ( - <div key={slot.key} className="flex min-w-0 flex-col gap-2 border-b border-divider-subtle px-3 py-3 last:border-b-0"> + <div + key={slot.key} + className="flex min-w-0 flex-col gap-2 border-b border-divider-subtle px-3 py-3 last:border-b-0" + > <div className="flex min-w-0 flex-col gap-2.5"> <div className="flex min-w-0 flex-wrap items-center justify-between gap-2"> <div className="flex min-w-0 items-center gap-1.5"> - <span aria-hidden="true" className="i-custom-vender-line-others-env size-4 shrink-0 text-util-colors-violet-violet-600" /> + <span + aria-hidden="true" + className="i-custom-vender-line-others-env size-4 shrink-0 text-util-colors-violet-violet-600" + /> <TitleTooltip content={slot.key}> - <label className="truncate font-mono system-sm-semibold text-text-primary" htmlFor={inputId}> + <label + className="truncate font-mono system-sm-semibold text-text-primary" + htmlFor={inputId} + > {slot.key} </label> </TitleTooltip> @@ -183,7 +198,10 @@ export function EnvVarBindingsPanel({ {valueTypeLabels[slot.valueType]} </span> {slot.valueType === 'secret' && ( - <span aria-hidden="true" className="i-ri-lock-2-line size-3 shrink-0 text-text-tertiary" /> + <span + aria-hidden="true" + className="i-ri-lock-2-line size-3 shrink-0 text-text-tertiary" + /> )} </div> {sourceOptions.length > 1 && ( @@ -192,8 +210,7 @@ export function EnvVarBindingsPanel({ value={[valueSource]} onValueChange={(value) => { const nextSource = value[0] - if (!nextSource) - return + if (!nextSource) return onChange(slot.key, { value: selection?.value, valueSource: nextSource, @@ -201,8 +218,12 @@ export function EnvVarBindingsPanel({ }} className="shrink-0" > - {sourceOptions.map(option => ( - <SegmentedControlItem key={option.value} value={option.value} className="px-2 system-xs-medium"> + {sourceOptions.map((option) => ( + <SegmentedControlItem + key={option.value} + value={option.value} + className="px-2 system-xs-medium" + > {option.label} </SegmentedControlItem> ))} @@ -220,10 +241,12 @@ export function EnvVarBindingsPanel({ id={inputId} type={ENV_VAR_INPUT_TYPES[slot.valueType]} value={displayValue ?? ''} - onChange={event => onChange(slot.key, { - value: event.target.value, - valueSource: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL, - })} + onChange={(event) => + onChange(slot.key, { + value: event.target.value, + valueSource: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL, + }) + } placeholder={envVarPlaceholder} autoComplete="off" disabled={!isLiteralValue} diff --git a/web/features/deployments/shared/components/runtime-credential-bindings-utils.ts b/web/features/deployments/shared/components/runtime-credential-bindings-utils.ts index d80dc0b53c933b..bccc4946145bc3 100644 --- a/web/features/deployments/shared/components/runtime-credential-bindings-utils.ts +++ b/web/features/deployments/shared/components/runtime-credential-bindings-utils.ts @@ -30,7 +30,7 @@ function titleCaseProviderName(value: string) { return value .split(/[-_]/) .filter(Boolean) - .map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) .join(' ') } @@ -40,8 +40,7 @@ export function runtimeCredentialSlotKey(slot: CredentialSlot) { export function runtimeCredentialProviderName(providerId: string) { const slug = providerSlug(providerId) - if (!slug) - return undefined + if (!slug) return undefined return PROVIDER_DISPLAY_NAMES[slug.toLowerCase()] ?? titleCaseProviderName(slug) } @@ -51,28 +50,27 @@ function runtimeCredentialCandidateLabel(candidate: CredentialCandidate) { const rawLabel = candidate.displayName.trim() || fallback const providerId = candidate.providerId.trim() - const providerSuffixes = [ - ` · ${providerId}`, - ` - ${providerId}`, - ` (${providerId})`, - ] + const providerSuffixes = [` · ${providerId}`, ` - ${providerId}`, ` (${providerId})`] const label = providerSuffixes.reduce((nextLabel, suffix) => { - return nextLabel.endsWith(suffix) - ? nextLabel.slice(0, -suffix.length).trim() - : nextLabel + return nextLabel.endsWith(suffix) ? nextLabel.slice(0, -suffix.length).trim() : nextLabel }, rawLabel) return label || fallback } -export function runtimeCredentialCandidateOptions(slot: CredentialSlot): RuntimeCredentialSelectOption[] { - return slot.candidates.map(candidate => ({ +export function runtimeCredentialCandidateOptions( + slot: CredentialSlot, +): RuntimeCredentialSelectOption[] { + return slot.candidates.map((candidate) => ({ value: candidate.credentialId, label: runtimeCredentialCandidateLabel(candidate), })) } -export function hasMissingRequiredRuntimeCredentialBinding(_slot: CredentialSlot, selectedValue?: string) { +export function hasMissingRequiredRuntimeCredentialBinding( + _slot: CredentialSlot, + selectedValue?: string, +) { return !selectedValue } @@ -85,12 +83,14 @@ export function selectedRuntimeCredentialSelections( const slotKey = runtimeCredentialSlotKey(slot) const candidates = runtimeCredentialCandidateOptions(slot) const existing = manualBindings[slotKey] - if (existing && candidates.some(candidate => candidate.value === existing)) + if (existing && candidates.some((candidate) => candidate.value === existing)) next[slotKey] = existing - else if (slot.lastCredentialId && candidates.some(candidate => candidate.value === slot.lastCredentialId)) + else if ( + slot.lastCredentialId && + candidates.some((candidate) => candidate.value === slot.lastCredentialId) + ) next[slotKey] = slot.lastCredentialId - else if (candidates.length === 1 && candidates[0]) - next[slotKey] = candidates[0].value + else if (candidates.length === 1 && candidates[0]) next[slotKey] = candidates[0].value } return next } @@ -99,17 +99,17 @@ export function selectedDeploymentRuntimeCredentials( slots: CredentialSlot[], selections: RuntimeCredentialBindingSelections, ): CredentialSelectionInput[] { - return slots - .flatMap((slot): CredentialSelectionInput[] => { - const slotKey = runtimeCredentialSlotKey(slot) - const selectedValue = selections[slotKey] - if (!selectedValue) - return [] - - return [{ + return slots.flatMap((slot): CredentialSelectionInput[] => { + const slotKey = runtimeCredentialSlotKey(slot) + const selectedValue = selections[slotKey] + if (!selectedValue) return [] + + return [ + { providerId: slot.providerId, category: slot.category, credentialId: selectedValue, - }] - }) + }, + ] + }) } diff --git a/web/features/deployments/shared/components/runtime-credential-bindings.tsx b/web/features/deployments/shared/components/runtime-credential-bindings.tsx index 6aa4130abd65a4..d6e9103b1c4ebc 100644 --- a/web/features/deployments/shared/components/runtime-credential-bindings.tsx +++ b/web/features/deployments/shared/components/runtime-credential-bindings.tsx @@ -1,8 +1,6 @@ 'use client' -import type { - CredentialSlot, -} from '@dify/contracts/enterprise/types.gen' +import type { CredentialSlot } from '@dify/contracts/enterprise/types.gen' import type { RuntimeCredentialBindingSelections, RuntimeCredentialSelectOption, @@ -55,14 +53,13 @@ function RuntimeCredentialSelect({ placeholder: string onChange: (value: string) => void }) { - const selectedOption = options.find(option => option.value === value) + const selectedOption = options.find((option) => option.value === value) return ( <Select value={value ?? null} onValueChange={(next) => { - if (!next) - return + if (!next) return onChange(next) }} disabled={options.length === 0} @@ -77,7 +74,7 @@ function RuntimeCredentialSelect({ {selectedOption?.label ?? placeholder} </SelectTrigger> <SelectContent popupClassName="w-(--anchor-width)"> - {options.map(option => ( + {options.map((option) => ( <SelectItem key={option.value} value={option.value}> <TitleTooltip content={option.label}> <SelectItemText>{option.label}</SelectItemText> @@ -109,7 +106,12 @@ export function RuntimeCredentialBindingsPanel({ const { t } = useTranslation('plugin') return ( - <div className={cn('overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle', className)}> + <div + className={cn( + 'overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle', + className, + )} + > <div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5"> <div className="flex min-w-0 items-center gap-2"> <div className="system-xs-medium-uppercase text-text-tertiary">{title}</div> @@ -121,77 +123,78 @@ export function RuntimeCredentialBindingsPanel({ </div> <span className="system-xs-regular text-text-tertiary">{hint}</span> </div> - {slots.length === 0 - ? ( - <div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary"> - {noBindingRequiredLabel} - </div> - ) - : ( - <div - className={cn( - 'border-t border-divider-subtle', - listScrollable ? 'max-h-[min(360px,34dvh)] overflow-y-auto' : 'overflow-visible', - listClassName, - )} - > - {slots.map((slot) => { - const slotKey = runtimeCredentialSlotKey(slot) - const candidates = runtimeCredentialCandidateOptions(slot) - const selectedValue = selections[slotKey] - const missing = showMissingRequired && hasMissingRequiredRuntimeCredentialBinding(slot, selectedValue) - const slotName = runtimeCredentialProviderName(slot.providerId) ?? slotKey - const categoryLabel = slot.category === 'PLUGIN_CATEGORY_MODEL' - ? t($ => $['categorySingle.model']) - : slot.category === 'PLUGIN_CATEGORY_TOOL' - ? t($ => $['categorySingle.tool']) - : undefined + {slots.length === 0 ? ( + <div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary"> + {noBindingRequiredLabel} + </div> + ) : ( + <div + className={cn( + 'border-t border-divider-subtle', + listScrollable ? 'max-h-[min(360px,34dvh)] overflow-y-auto' : 'overflow-visible', + listClassName, + )} + > + {slots.map((slot) => { + const slotKey = runtimeCredentialSlotKey(slot) + const candidates = runtimeCredentialCandidateOptions(slot) + const selectedValue = selections[slotKey] + const missing = + showMissingRequired && hasMissingRequiredRuntimeCredentialBinding(slot, selectedValue) + const slotName = runtimeCredentialProviderName(slot.providerId) ?? slotKey + const categoryLabel = + slot.category === 'PLUGIN_CATEGORY_MODEL' + ? t(($) => $['categorySingle.model']) + : slot.category === 'PLUGIN_CATEGORY_TOOL' + ? t(($) => $['categorySingle.tool']) + : undefined - return ( - <div key={slotKey} className="flex flex-col gap-2 border-b border-divider-subtle px-3 py-3 last:border-b-0"> - <div className="flex min-w-0 flex-col gap-2.5"> - <div className="flex min-w-0 flex-col gap-1.5"> - <div className="flex min-w-0 items-center gap-1.5"> - <TitleTooltip content={slotName}> - <span className="truncate system-sm-semibold text-text-primary"> - {slotName} - </span> - </TitleTooltip> - </div> - <div className="flex flex-wrap items-center gap-1.5"> - {categoryLabel && ( - <span className="shrink-0 rounded-md bg-util-colors-blue-light-blue-light-50 px-1.5 py-0.5 system-2xs-medium-uppercase text-util-colors-blue-blue-600"> - {categoryLabel} - </span> - )} - </div> - </div> - {candidates.length === 0 - ? ( - <div className="rounded-lg border border-divider-subtle bg-background-default px-2 py-1.5 system-sm-regular text-text-quaternary"> - {noCredentialCandidatesLabel} - </div> - ) - : ( - <RuntimeCredentialSelect - ariaLabel={slotName} - value={selectedValue} - onChange={value => onChange(slotKey, value)} - options={candidates} - placeholder={selectCredentialLabel} - /> - )} + return ( + <div + key={slotKey} + className="flex flex-col gap-2 border-b border-divider-subtle px-3 py-3 last:border-b-0" + > + <div className="flex min-w-0 flex-col gap-2.5"> + <div className="flex min-w-0 flex-col gap-1.5"> + <div className="flex min-w-0 items-center gap-1.5"> + <TitleTooltip content={slotName}> + <span className="truncate system-sm-semibold text-text-primary"> + {slotName} + </span> + </TitleTooltip> + </div> + <div className="flex flex-wrap items-center gap-1.5"> + {categoryLabel && ( + <span className="shrink-0 rounded-md bg-util-colors-blue-light-blue-light-50 px-1.5 py-0.5 system-2xs-medium-uppercase text-util-colors-blue-blue-600"> + {categoryLabel} + </span> + )} </div> - {missing && ( - <div className="system-xs-regular text-text-destructive"> - {missingRequiredLabel} - </div> - )} </div> - ) - })} - </div> - )} + {candidates.length === 0 ? ( + <div className="rounded-lg border border-divider-subtle bg-background-default px-2 py-1.5 system-sm-regular text-text-quaternary"> + {noCredentialCandidatesLabel} + </div> + ) : ( + <RuntimeCredentialSelect + ariaLabel={slotName} + value={selectedValue} + onChange={(value) => onChange(slotKey, value)} + options={candidates} + placeholder={selectCredentialLabel} + /> + )} + </div> + {missing && ( + <div className="system-xs-regular text-text-destructive"> + {missingRequiredLabel} + </div> + )} + </div> + ) + })} + </div> + )} </div> ) } diff --git a/web/features/deployments/shared/components/section.tsx b/web/features/deployments/shared/components/section.tsx index a02feb9b2cb758..94c67b434e229e 100644 --- a/web/features/deployments/shared/components/section.tsx +++ b/web/features/deployments/shared/components/section.tsx @@ -38,31 +38,26 @@ export function Section({ if (layout === 'row') { return ( - <section className={cn('py-4 first:pt-0 last:pb-0', showDivider && 'border-b border-divider-subtle last:border-b-0')}> + <section + className={cn( + 'py-4 first:pt-0 last:pb-0', + showDivider && 'border-b border-divider-subtle last:border-b-0', + )} + > <div className="flex flex-col gap-3 sm:flex-row sm:gap-x-6"> <div className="flex min-w-0 shrink-0 flex-col sm:w-40 sm:pt-1"> - <div className={titleClassName}> - {title} - </div> - {description && ( - <p className={descriptionClassName}> - {description} - </p> - )} + <div className={titleClassName}>{title}</div> + {description && <p className={descriptionClassName}>{description}</p>} </div> <div className="min-w-0 grow"> - {hasAction - ? ( - <div className="flex min-w-0 items-start gap-3"> - <div className="min-w-0 grow"> - {children} - </div> - <div className="shrink-0"> - {action} - </div> - </div> - ) - : children} + {hasAction ? ( + <div className="flex min-w-0 items-start gap-3"> + <div className="min-w-0 grow">{children}</div> + <div className="shrink-0">{action}</div> + </div> + ) : ( + children + )} </div> </div> </section> @@ -70,27 +65,20 @@ export function Section({ } return ( - <section className={cn('py-6 first:pt-0 last:pb-0', showDivider && 'border-b border-divider-subtle last:border-b-0')}> + <section + className={cn( + 'py-6 first:pt-0 last:pb-0', + showDivider && 'border-b border-divider-subtle last:border-b-0', + )} + > <div className="mb-3 flex min-w-0 flex-col"> <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2"> - <div className={titleClassName}> - {title} - </div> - {hasAction && ( - <div className="shrink-0"> - {action} - </div> - )} + <div className={titleClassName}>{title}</div> + {hasAction && <div className="shrink-0">{action}</div>} </div> - {description && ( - <p className={cn(descriptionClassName, 'max-w-150')}> - {description} - </p> - )} - </div> - <div className="min-w-0"> - {children} + {description && <p className={cn(descriptionClassName, 'max-w-150')}>{description}</p>} </div> + <div className="min-w-0">{children}</div> </section> ) } diff --git a/web/features/deployments/shared/components/title-tooltip.tsx b/web/features/deployments/shared/components/title-tooltip.tsx index f10f2708b27266..3fcabb4e4e69c2 100644 --- a/web/features/deployments/shared/components/title-tooltip.tsx +++ b/web/features/deployments/shared/components/title-tooltip.tsx @@ -14,8 +14,7 @@ function normalizeText(value: string) { } function contentText(content: ReactNode) { - if (typeof content === 'string' || typeof content === 'number') - return String(content) + if (typeof content === 'string' || typeof content === 'number') return String(content) } function isOverflowing(element: HTMLElement) { @@ -24,11 +23,9 @@ function isOverflowing(element: HTMLElement) { function shouldShowTooltipContent(element: HTMLElement, content: ReactNode) { const text = contentText(content) - if (text === undefined) - return true + if (text === undefined) return true - if (normalizeText(text) !== normalizeText(element.textContent ?? '')) - return true + if (normalizeText(text) !== normalizeText(element.textContent ?? '')) return true return isOverflowing(element) } @@ -42,8 +39,7 @@ export function TitleTooltip({ }) { const [shouldShowContent, setShouldShowContent] = useState(false) - if (!content) - return children + if (!content) return children const childProps = children.props as TriggerProps @@ -66,9 +62,7 @@ export function TitleTooltip({ return ( <Tooltip> <TooltipTrigger render={trigger} /> - {shouldShowContent && ( - <TooltipContent>{content}</TooltipContent> - )} + {shouldShowContent && <TooltipContent>{content}</TooltipContent>} </Tooltip> ) } diff --git a/web/features/deployments/shared/components/unsupported-dsl-nodes-alert.tsx b/web/features/deployments/shared/components/unsupported-dsl-nodes-alert.tsx index 04c2e3c9929975..f694d4122f3b45 100644 --- a/web/features/deployments/shared/components/unsupported-dsl-nodes-alert.tsx +++ b/web/features/deployments/shared/components/unsupported-dsl-nodes-alert.tsx @@ -7,38 +7,38 @@ import { useTranslation } from 'react-i18next' import { BlockEnum } from '@/app/components/workflow/types' const workflowBlockTypeSelectors: Record<BlockEnum, SelectorParam<'workflow'>> = { - [BlockEnum.Start]: $ => $['blocks.start'], - [BlockEnum.StartPlaceholder]: $ => $['blocks.start-placeholder'], - [BlockEnum.End]: $ => $['blocks.end'], - [BlockEnum.Answer]: $ => $['blocks.answer'], - [BlockEnum.LLM]: $ => $['blocks.llm'], - [BlockEnum.KnowledgeRetrieval]: $ => $['blocks.knowledge-retrieval'], - [BlockEnum.QuestionClassifier]: $ => $['blocks.question-classifier'], - [BlockEnum.IfElse]: $ => $['blocks.if-else'], - [BlockEnum.Code]: $ => $['blocks.code'], - [BlockEnum.TemplateTransform]: $ => $['blocks.template-transform'], - [BlockEnum.HttpRequest]: $ => $['blocks.http-request'], - [BlockEnum.VariableAssigner]: $ => $['blocks.variable-assigner'], - [BlockEnum.VariableAggregator]: $ => $['blocks.variable-aggregator'], - [BlockEnum.Tool]: $ => $['blocks.tool'], - [BlockEnum.ParameterExtractor]: $ => $['blocks.parameter-extractor'], - [BlockEnum.Iteration]: $ => $['blocks.iteration'], - [BlockEnum.DocExtractor]: $ => $['blocks.document-extractor'], - [BlockEnum.ListFilter]: $ => $['blocks.list-operator'], - [BlockEnum.IterationStart]: $ => $['blocks.iteration-start'], - [BlockEnum.Assigner]: $ => $['blocks.assigner'], - [BlockEnum.Agent]: $ => $['blocks.agent'], - [BlockEnum.AgentV2]: $ => $['blocks.agent-v2'], - [BlockEnum.Loop]: $ => $['blocks.loop'], - [BlockEnum.LoopStart]: $ => $['blocks.loop-start'], - [BlockEnum.LoopEnd]: $ => $['blocks.loop-end'], - [BlockEnum.HumanInput]: $ => $['blocks.human-input'], - [BlockEnum.DataSource]: $ => $['blocks.datasource'], - [BlockEnum.DataSourceEmpty]: $ => $['blocks.datasource-empty'], - [BlockEnum.KnowledgeBase]: $ => $['blocks.knowledge-index'], - [BlockEnum.TriggerSchedule]: $ => $['blocks.trigger-schedule'], - [BlockEnum.TriggerWebhook]: $ => $['blocks.trigger-webhook'], - [BlockEnum.TriggerPlugin]: $ => $['blocks.trigger-plugin'], + [BlockEnum.Start]: ($) => $['blocks.start'], + [BlockEnum.StartPlaceholder]: ($) => $['blocks.start-placeholder'], + [BlockEnum.End]: ($) => $['blocks.end'], + [BlockEnum.Answer]: ($) => $['blocks.answer'], + [BlockEnum.LLM]: ($) => $['blocks.llm'], + [BlockEnum.KnowledgeRetrieval]: ($) => $['blocks.knowledge-retrieval'], + [BlockEnum.QuestionClassifier]: ($) => $['blocks.question-classifier'], + [BlockEnum.IfElse]: ($) => $['blocks.if-else'], + [BlockEnum.Code]: ($) => $['blocks.code'], + [BlockEnum.TemplateTransform]: ($) => $['blocks.template-transform'], + [BlockEnum.HttpRequest]: ($) => $['blocks.http-request'], + [BlockEnum.VariableAssigner]: ($) => $['blocks.variable-assigner'], + [BlockEnum.VariableAggregator]: ($) => $['blocks.variable-aggregator'], + [BlockEnum.Tool]: ($) => $['blocks.tool'], + [BlockEnum.ParameterExtractor]: ($) => $['blocks.parameter-extractor'], + [BlockEnum.Iteration]: ($) => $['blocks.iteration'], + [BlockEnum.DocExtractor]: ($) => $['blocks.document-extractor'], + [BlockEnum.ListFilter]: ($) => $['blocks.list-operator'], + [BlockEnum.IterationStart]: ($) => $['blocks.iteration-start'], + [BlockEnum.Assigner]: ($) => $['blocks.assigner'], + [BlockEnum.Agent]: ($) => $['blocks.agent'], + [BlockEnum.AgentV2]: ($) => $['blocks.agent-v2'], + [BlockEnum.Loop]: ($) => $['blocks.loop'], + [BlockEnum.LoopStart]: ($) => $['blocks.loop-start'], + [BlockEnum.LoopEnd]: ($) => $['blocks.loop-end'], + [BlockEnum.HumanInput]: ($) => $['blocks.human-input'], + [BlockEnum.DataSource]: ($) => $['blocks.datasource'], + [BlockEnum.DataSourceEmpty]: ($) => $['blocks.datasource-empty'], + [BlockEnum.KnowledgeBase]: ($) => $['blocks.knowledge-index'], + [BlockEnum.TriggerSchedule]: ($) => $['blocks.trigger-schedule'], + [BlockEnum.TriggerWebhook]: ($) => $['blocks.trigger-webhook'], + [BlockEnum.TriggerPlugin]: ($) => $['blocks.trigger-plugin'], } function isWorkflowBlockType(type: string): type is BlockEnum { @@ -50,11 +50,9 @@ function translatedNodeType( tDeployments: TFunction<'deployments'>, tWorkflow: TFunction<'workflow'>, ) { - if (!type) - return tDeployments($ => $['unsupportedDslNodes.unknownType']) + if (!type) return tDeployments(($) => $['unsupportedDslNodes.unknownType']) - if (!isWorkflowBlockType(type)) - return type + if (!isWorkflowBlockType(type)) return type return tWorkflow(workflowBlockTypeSelectors[type]) } @@ -64,36 +62,38 @@ function unsupportedNodeTypeLabels( tDeployments: TFunction<'deployments'>, tWorkflow: TFunction<'workflow'>, ) { - return Array.from(new Set(nodes.map(node => translatedNodeType(node.type, tDeployments, tWorkflow)))) + return Array.from( + new Set(nodes.map((node) => translatedNodeType(node.type, tDeployments, tWorkflow))), + ) } function formattedNodeTypeList(labels: string[], language: string) { - if (labels.length === 0) - return '' + if (labels.length === 0) return '' try { return new Intl.ListFormat(language, { type: 'conjunction' }).format(labels) - } - catch { + } catch { return labels.join(', ') } } -export function UnsupportedDslNodesAlert({ nodes, className }: { +export function UnsupportedDslNodesAlert({ + nodes, + className, +}: { nodes: UnsupportedDslNode[] className?: string }) { const { i18n, t } = useTranslation('deployments') const { t: tWorkflow } = useTranslation('workflow') - if (nodes.length === 0) - return null + if (nodes.length === 0) return null const nodeTypeLabels = unsupportedNodeTypeLabels(nodes, t, tWorkflow) const nodeTypes = formattedNodeTypeList(nodeTypeLabels, i18n.language) const description = nodeTypes - ? t($ => $['unsupportedDslNodes.descriptionWithTypes'], { nodeTypes }) - : t($ => $['unsupportedDslNodes.description']) + ? t(($) => $['unsupportedDslNodes.descriptionWithTypes'], { nodeTypes }) + : t(($) => $['unsupportedDslNodes.description']) return ( <div @@ -104,14 +104,15 @@ export function UnsupportedDslNodesAlert({ nodes, className }: { )} > <div className="flex items-start gap-2"> - <span aria-hidden className="mt-0.5 i-ri-error-warning-fill size-4 shrink-0 text-text-destructive" /> + <span + aria-hidden + className="mt-0.5 i-ri-error-warning-fill size-4 shrink-0 text-text-destructive" + /> <div className="min-w-0 grow"> <div className="system-sm-semibold text-text-primary"> - {t($ => $['unsupportedDslNodes.title'])} + {t(($) => $['unsupportedDslNodes.title'])} </div> - <p className="mt-0.5 system-xs-regular text-text-secondary"> - {description} - </p> + <p className="mt-0.5 system-xs-regular text-text-secondary">{description}</p> </div> </div> </div> diff --git a/web/features/deployments/shared/domain/__tests__/dsl.spec.ts b/web/features/deployments/shared/domain/__tests__/dsl.spec.ts index 6980e7028e7950..4c0f48ef6fa4a7 100644 --- a/web/features/deployments/shared/domain/__tests__/dsl.spec.ts +++ b/web/features/deployments/shared/domain/__tests__/dsl.spec.ts @@ -5,7 +5,9 @@ describe('deployment DSL domain', () => { it('should preserve Unicode content through base64 encoding', () => { const content = 'app:\n name: 部署 🚀' - const decoded = new TextDecoder().decode(Uint8Array.from(atob(encodeDslContent(content)), character => character.charCodeAt(0))) + const decoded = new TextDecoder().decode( + Uint8Array.from(atob(encodeDslContent(content)), (character) => character.charCodeAt(0)), + ) expect(decoded).toBe(content) }) @@ -44,11 +46,13 @@ workflow: value: second ` - expect(dslEnvVarSlots(content)).toEqual([{ - key: 'REGION', - defaultValue: 'first', - hasDefaultValue: true, - }]) + expect(dslEnvVarSlots(content)).toEqual([ + { + key: 'REGION', + defaultValue: 'first', + hasDefaultValue: true, + }, + ]) }) it('should omit masked secret defaults', () => { @@ -60,10 +64,12 @@ workflow: value_type: secret ` - expect(dslEnvVarSlots(content)).toEqual([{ - key: 'API_KEY', - valueType: 'secret', - }]) + expect(dslEnvVarSlots(content)).toEqual([ + { + key: 'API_KEY', + valueType: 'secret', + }, + ]) }) it('should normalize unquoted timestamp defaults', () => { @@ -74,11 +80,13 @@ workflow: value: 2026-07-10T12:34:56Z ` - expect(dslEnvVarSlots(content)).toEqual([{ - key: 'START_AT', - defaultValue: '"2026-07-10T12:34:56.000Z"', - hasDefaultValue: true, - }]) + expect(dslEnvVarSlots(content)).toEqual([ + { + key: 'START_AT', + defaultValue: '"2026-07-10T12:34:56.000Z"', + hasDefaultValue: true, + }, + ]) }) it('should apply values inherited through YAML merge keys', () => { @@ -93,13 +101,15 @@ workflow: name: REGION ` - expect(dslEnvVarSlots(content)).toEqual([{ - key: 'REGION', - description: 'Deployment region', - defaultValue: 'ap-southeast-1', - hasDefaultValue: true, - valueType: 'string', - }]) + expect(dslEnvVarSlots(content)).toEqual([ + { + key: 'REGION', + description: 'Deployment region', + defaultValue: 'ap-southeast-1', + hasDefaultValue: true, + valueType: 'string', + }, + ]) }) }) }) diff --git a/web/features/deployments/shared/domain/dsl.ts b/web/features/deployments/shared/domain/dsl.ts index 1326ec72175f93..0da9fafeb074bc 100644 --- a/web/features/deployments/shared/domain/dsl.ts +++ b/web/features/deployments/shared/domain/dsl.ts @@ -39,8 +39,7 @@ export function encodeDslContent(value: string) { function parseDsl(content: string) { try { return loadYaml(content) as DslMetadata | undefined - } - catch { + } catch { return undefined } } @@ -54,29 +53,23 @@ function stringValue(value: unknown) { } function normalizeEnvVarDefaultValue(value: unknown) { - if (value === undefined || value === null) - return undefined + if (value === undefined || value === null) return undefined let normalizedValue: string | undefined if (typeof value === 'string') { normalizedValue = value - } - else if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + } else if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { normalizedValue = String(value) - } - else { + } else { try { normalizedValue = JSON.stringify(value) - } - catch { + } catch { normalizedValue = undefined } } - if (!normalizedValue?.trim()) - return undefined - if (MASKED_SECRET_PLACEHOLDERS.has(normalizedValue)) - return undefined + if (!normalizedValue?.trim()) return undefined + if (MASKED_SECRET_PLACEHOLDERS.has(normalizedValue)) return undefined return normalizedValue } @@ -93,8 +86,7 @@ function envVarDefaultValue(record: DslEnvVarRecord) { function envVarValueType(record: DslEnvVarRecord) { for (const field of ENV_VAR_VALUE_TYPE_FIELDS) { const valueType = stringValue(record[field]) - if (valueType) - return valueType + if (valueType) return valueType } return undefined @@ -116,26 +108,23 @@ export function isWorkflowDsl(content: string) { export function dslEnvVarSlots(content: string): DslEnvVarSlot[] { const environmentVariables = parseDsl(content)?.workflow?.environment_variables - if (!Array.isArray(environmentVariables)) - return [] + if (!Array.isArray(environmentVariables)) return [] const seenKeys = new Set<string>() - return environmentVariables - .flatMap((envVar): DslEnvVarSlot[] => { - if (!isRecord(envVar)) - return [] + return environmentVariables.flatMap((envVar): DslEnvVarSlot[] => { + if (!isRecord(envVar)) return [] - const key = stringValue(envVar.name ?? envVar.key ?? envVar.variable) - if (!key || seenKeys.has(key)) - return [] + const key = stringValue(envVar.name ?? envVar.key ?? envVar.variable) + if (!key || seenKeys.has(key)) return [] - seenKeys.add(key) - const description = stringValue(envVar.description) - const defaultValue = envVarDefaultValue(envVar) - const valueType = envVarValueType(envVar) + seenKeys.add(key) + const description = stringValue(envVar.description) + const defaultValue = envVarDefaultValue(envVar) + const valueType = envVarValueType(envVar) - return [{ + return [ + { key, ...(description ? { description } : {}), ...(valueType ? { valueType } : {}), @@ -145,6 +134,7 @@ export function dslEnvVarSlots(content: string): DslEnvVarSlot[] { hasDefaultValue: true, } : {}), - }] - }) + }, + ] + }) } diff --git a/web/features/deployments/shared/domain/error.ts b/web/features/deployments/shared/domain/error.ts index 819f794ecb9cc6..7240ee9f5d6555 100644 --- a/web/features/deployments/shared/domain/error.ts +++ b/web/features/deployments/shared/domain/error.ts @@ -20,8 +20,7 @@ export type UnsupportedDslNodeError = { } function nonEmptyString(value: unknown) { - if (typeof value !== 'string') - return undefined + if (typeof value !== 'string') return undefined const trimmedValue = value.trim() return trimmedValue || undefined @@ -35,8 +34,7 @@ function formatDeploymentError(data: DeploymentErrorPayload) { const message = nonEmptyString(data.message) ?? nonEmptyString(data.error) const reason = nonEmptyString(data.reason) ?? nonEmptyString(data.code) - if (message && reason) - return `${message} (${reason})` + if (message && reason) return `${message} (${reason})` return message ?? reason } @@ -44,9 +42,8 @@ function formatDeploymentError(data: DeploymentErrorPayload) { async function deploymentErrorData(error: unknown) { if (error instanceof Response && !error.bodyUsed) { try { - return await error.clone().json() as DeploymentErrorPayload - } - catch {} + return (await error.clone().json()) as DeploymentErrorPayload + } catch {} } return undefined @@ -57,8 +54,7 @@ function deploymentErrorReason(data: DeploymentErrorPayload) { } function unsupportedNodesPayload(data: DeploymentErrorPayload) { - if (isRecord(data.metadata)) - return data.metadata.unsupported_nodes + if (isRecord(data.metadata)) return data.metadata.unsupported_nodes return data.unsupported_nodes } @@ -66,20 +62,17 @@ function unsupportedNodesPayload(data: DeploymentErrorPayload) { function parsedJsonValue(value: string) { try { return JSON.parse(value) as unknown - } - catch { + } catch { return undefined } } function unsupportedDslNode(value: unknown) { - if (!isRecord(value)) - return undefined + if (!isRecord(value)) return undefined const id = nonEmptyString(value.id) const type = nonEmptyString(value.type) - if (!id && !type) - return undefined + if (!id && !type) return undefined return { ...(id ? { id } : {}), @@ -89,20 +82,17 @@ function unsupportedDslNode(value: unknown) { function unsupportedDslNodes(value: unknown) { const nodeList = typeof value === 'string' ? parsedJsonValue(value) : value - if (!Array.isArray(nodeList)) - return [] + if (!Array.isArray(nodeList)) return [] const seen = new Set<string>() const nodes: UnsupportedDslNode[] = [] nodeList.forEach((item) => { const node = unsupportedDslNode(item) - if (!node) - return + if (!node) return const key = `${node.id}\u0000${node.type}` - if (seen.has(key)) - return + if (seen.has(key)) return seen.add(key) nodes.push(node) @@ -111,13 +101,13 @@ function unsupportedDslNodes(value: unknown) { return nodes } -export async function unsupportedDslNodeError(error: unknown): Promise<UnsupportedDslNodeError | undefined> { +export async function unsupportedDslNodeError( + error: unknown, +): Promise<UnsupportedDslNodeError | undefined> { const errorData = await deploymentErrorData(error) - if (!errorData) - return undefined + if (!errorData) return undefined - if (deploymentErrorReason(errorData) !== APP_DEPLOY_UNSUPPORTED_DSL_NODE_TYPE) - return undefined + if (deploymentErrorReason(errorData) !== APP_DEPLOY_UNSUPPORTED_DSL_NODE_TYPE) return undefined return { message: formatDeploymentError(errorData), @@ -127,11 +117,9 @@ export async function unsupportedDslNodeError(error: unknown): Promise<Unsupport export async function deploymentErrorMessage(error: unknown) { const errorData = await deploymentErrorData(error) - if (errorData) - return formatDeploymentError(errorData) + if (errorData) return formatDeploymentError(errorData) - if (error instanceof Error) - return nonEmptyString(error.message) + if (error instanceof Error) return nonEmptyString(error.message) return undefined } diff --git a/web/features/deployments/shared/domain/idempotency.ts b/web/features/deployments/shared/domain/idempotency.ts index c9056c38648fe5..be9cff693d9ec1 100644 --- a/web/features/deployments/shared/domain/idempotency.ts +++ b/web/features/deployments/shared/domain/idempotency.ts @@ -1,6 +1,5 @@ export function createDeploymentIdempotencyKey() { - if (globalThis.crypto?.randomUUID) - return globalThis.crypto.randomUUID() + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID() return `deployment-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` } diff --git a/web/features/deployments/shared/domain/release-action.ts b/web/features/deployments/shared/domain/release-action.ts index 3e499b547ba3e1..cb93a5abc6e32e 100644 --- a/web/features/deployments/shared/domain/release-action.ts +++ b/web/features/deployments/shared/domain/release-action.ts @@ -8,11 +8,11 @@ function releaseCreatedAt(release: Release) { } function releaseById(releaseRows: Release[], releaseId?: string) { - return releaseRows.find(release => release.id === releaseId) + return releaseRows.find((release) => release.id === releaseId) } function releaseOrderIndex(releaseRows: Release[], releaseId?: string) { - return releaseRows.findIndex(release => release.id === releaseId) + return releaseRows.findIndex((release) => release.id === releaseId) } function compareReleaseOrder( @@ -20,17 +20,19 @@ function compareReleaseOrder( currentRelease: Release, releaseRows: Release[], ) { - if (!targetRelease) - return undefined - if (targetRelease.id === currentRelease.id) - return 0 + if (!targetRelease) return undefined + if (targetRelease.id === currentRelease.id) return 0 const normalizedTargetRelease = releaseById(releaseRows, targetRelease.id) ?? targetRelease const normalizedCurrentRelease = releaseById(releaseRows, currentRelease.id) ?? currentRelease const targetCreatedAt = releaseCreatedAt(normalizedTargetRelease) const currentCreatedAt = releaseCreatedAt(normalizedCurrentRelease) - if (targetCreatedAt !== undefined && currentCreatedAt !== undefined && targetCreatedAt !== currentCreatedAt) + if ( + targetCreatedAt !== undefined && + currentCreatedAt !== undefined && + targetCreatedAt !== currentCreatedAt + ) return targetCreatedAt > currentCreatedAt ? 1 : -1 const targetIndex = releaseOrderIndex(releaseRows, targetRelease.id) @@ -52,14 +54,11 @@ export function releaseDeploymentAction({ releaseRows: Release[] isExistingRelease?: boolean }): ReleaseDeploymentAction { - if (!currentRelease) - return isExistingRelease ? 'deployExistingRelease' : 'deploy' + if (!currentRelease) return isExistingRelease ? 'deployExistingRelease' : 'deploy' const order = compareReleaseOrder(targetRelease, currentRelease, releaseRows) - if (order === -1) - return 'rollback' - if (order === 1) - return 'promote' + if (order === -1) return 'rollback' + if (order === 1) return 'promote' return targetRelease && targetRelease.id !== currentRelease.id ? 'promote' diff --git a/web/features/deployments/shared/domain/release.ts b/web/features/deployments/shared/domain/release.ts index c5bd71abe82bda..dda5a652b21e70 100644 --- a/web/features/deployments/shared/domain/release.ts +++ b/web/features/deployments/shared/domain/release.ts @@ -2,12 +2,15 @@ import type { Release } from '@dify/contracts/enterprise/types.gen' import { formatTime } from '@/utils/time' export function formatDate(value?: string) { - if (!value) - return '—' + if (!value) return '—' const date = new Date(value) if (Number.isNaN(date.getTime())) - return value.replace('T', ' ').replace(/\.\d+Z?$/, '').replace(/Z$/, '').slice(0, 16) + return value + .replace('T', ' ') + .replace(/\.\d+Z?$/, '') + .replace(/Z$/, '') + .slice(0, 16) return formatTime({ date, dateFormat: 'YYYY-MM-DD HH:mm' }) } diff --git a/web/features/deployments/shared/domain/runtime-status.ts b/web/features/deployments/shared/domain/runtime-status.ts index 9a2540047c138f..8313b83718a753 100644 --- a/web/features/deployments/shared/domain/runtime-status.ts +++ b/web/features/deployments/shared/domain/runtime-status.ts @@ -1,15 +1,20 @@ -import type { EnvironmentDeployment, RuntimeInstanceStatus as RuntimeInstanceStatusValue } from '@dify/contracts/enterprise/types.gen' +import type { + EnvironmentDeployment, + RuntimeInstanceStatus as RuntimeInstanceStatusValue, +} from '@dify/contracts/enterprise/types.gen' import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen' const DEPLOYMENT_STATUS_POLLING_INTERVAL = 3000 export function isUndeployedDeploymentRow(row: EnvironmentDeployment) { const status = row.status - return status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYED - || (status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNSPECIFIED - && !row.currentRelease - && !row.desiredRelease - && !row.currentDeployment) + return ( + status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYED || + (status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNSPECIFIED && + !row.currentRelease && + !row.desiredRelease && + !row.currentDeployment) + ) } export function hasRuntimeInstanceDeployment(row: EnvironmentDeployment) { @@ -21,10 +26,14 @@ export function isAvailableDeploymentTarget(row: EnvironmentDeployment) { } export function isRuntimeDeploymentInProgress(status?: RuntimeInstanceStatusValue) { - return status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_DEPLOYING - || status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING + return ( + status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_DEPLOYING || + status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING + ) } export function deploymentStatusPollingInterval(rows?: EnvironmentDeployment[]) { - return rows?.some(row => isRuntimeDeploymentInProgress(row.status)) ? DEPLOYMENT_STATUS_POLLING_INTERVAL : false + return rows?.some((row) => isRuntimeDeploymentInProgress(row.status)) + ? DEPLOYMENT_STATUS_POLLING_INTERVAL + : false } diff --git a/web/features/deployments/shared/hooks/__tests__/use-infinite-scroll.spec.ts b/web/features/deployments/shared/hooks/__tests__/use-infinite-scroll.spec.ts index 83b0fa7a6d05c7..433f1e6eb64beb 100644 --- a/web/features/deployments/shared/hooks/__tests__/use-infinite-scroll.spec.ts +++ b/web/features/deployments/shared/hooks/__tests__/use-infinite-scroll.spec.ts @@ -44,12 +44,14 @@ function TestInfiniteScroll({ return createElement( 'div', - { 'ref': rootRef, 'data-testid': 'scroll-root' }, - createElement('div', { 'ref': sentinelRef, 'data-testid': 'scroll-sentinel' }), + { ref: rootRef, 'data-testid': 'scroll-root' }, + createElement('div', { ref: sentinelRef, 'data-testid': 'scroll-sentinel' }), ) } -function createInfiniteScrollQuery(overrides: Partial<InfiniteScrollQueryResult> = {}): InfiniteScrollQueryResult { +function createInfiniteScrollQuery( + overrides: Partial<InfiniteScrollQueryResult> = {}, +): InfiniteScrollQueryResult { return { error: null, fetchNextPage: vi.fn(() => Promise.resolve()), @@ -78,9 +80,10 @@ function triggerIntersection(isIntersecting: boolean) { if (!intersectionCallback) throw new Error('Expected IntersectionObserver callback to be registered') - intersectionCallback([ - { isIntersecting } as IntersectionObserverEntry, - ], {} as IntersectionObserver) + intersectionCallback( + [{ isIntersecting } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) } describe('useInfiniteScroll', () => { @@ -88,7 +91,8 @@ describe('useInfiniteScroll', () => { vi.clearAllMocks() intersectionCallback = undefined intersectionOptions = undefined - globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver + globalThis.IntersectionObserver = + MockIntersectionObserver as unknown as typeof IntersectionObserver }) afterAll(() => { @@ -170,9 +174,12 @@ describe('useInfiniteScroll', () => { // The local lock avoids repeated calls while TanStack Query is still resolving fetchNextPage. it('should not request another page while the previous request is pending', async () => { let resolveFetch: (value?: unknown) => void = () => undefined - const fetchNextPage = vi.fn(() => new Promise((resolve) => { - resolveFetch = resolve - })) + const fetchNextPage = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve + }), + ) const query = createInfiniteScrollQuery({ fetchNextPage }) renderInfiniteScroll(query) diff --git a/web/features/deployments/shared/hooks/use-infinite-scroll.ts b/web/features/deployments/shared/hooks/use-infinite-scroll.ts index 46bb8b3f4578ce..349bdce2ee0d48 100644 --- a/web/features/deployments/shared/hooks/use-infinite-scroll.ts +++ b/web/features/deployments/shared/hooks/use-infinite-scroll.ts @@ -82,12 +82,13 @@ export function useInfiniteScroll< isLoading: query.isLoading ?? false, } - const canLoad = enabled - && Boolean(query.hasNextPage) - && !query.isFetchingNextPage - && !(query.isLoading ?? false) - && !query.error - && !(guardOnFetching && (query.isFetching ?? false)) + const canLoad = + enabled && + Boolean(query.hasNextPage) && + !query.isFetchingNextPage && + !(query.isLoading ?? false) && + !query.error && + !(guardOnFetching && (query.isFetching ?? false)) const disconnectObserver = useCallback(() => { observerRef.current?.disconnect() @@ -120,47 +121,53 @@ export function useInfiniteScroll< } const observedTarget = observedTargetRef.current - if (observerRef.current - && observedTarget?.rootEl === rootEl - && observedTarget.sentinelEl === sentinelEl - && observedTarget.rootMargin === rootMargin - && observedTarget.threshold === threshold - && observedTarget.useWindow === useWindow) { + if ( + observerRef.current && + observedTarget?.rootEl === rootEl && + observedTarget.sentinelEl === sentinelEl && + observedTarget.rootMargin === rootMargin && + observedTarget.threshold === threshold && + observedTarget.useWindow === useWindow + ) { return } disconnectObserver() - const observer = new IntersectionObserver(([entry]) => { - const latest = latestRef.current - - if (!entry?.isIntersecting) - return - - if (!latest.enabled - || !latest.hasNextPage - || latest.isLoading - || latest.isFetchingNextPage - || latest.error - || (latest.guardOnFetching && latest.isFetching) - || loadingLockRef.current) { - return - } - - loadingLockRef.current = true - - const nextPage = latest.fetchNextPage({ - cancelRefetch: latest.cancelRefetch, - }) - - void Promise.resolve(nextPage).finally(() => { - loadingLockRef.current = false - }) - }, { - root: useWindow ? null : rootEl, - rootMargin, - threshold, - }) + const observer = new IntersectionObserver( + ([entry]) => { + const latest = latestRef.current + + if (!entry?.isIntersecting) return + + if ( + !latest.enabled || + !latest.hasNextPage || + latest.isLoading || + latest.isFetchingNextPage || + latest.error || + (latest.guardOnFetching && latest.isFetching) || + loadingLockRef.current + ) { + return + } + + loadingLockRef.current = true + + const nextPage = latest.fetchNextPage({ + cancelRefetch: latest.cancelRefetch, + }) + + void Promise.resolve(nextPage).finally(() => { + loadingLockRef.current = false + }) + }, + { + root: useWindow ? null : rootEl, + rootMargin, + threshold, + }, + ) observer.observe(sentinelEl) observerRef.current = observer @@ -173,15 +180,21 @@ export function useInfiniteScroll< } }, [canLoad, disconnectObserver, rootMargin, threshold, useWindow]) - const rootRef = useCallback((node: TRoot | null) => { - rootElRef.current = node - connectObserver() - }, [connectObserver]) - - const sentinelRef = useCallback((node: TTarget | null) => { - sentinelElRef.current = node - connectObserver() - }, [connectObserver]) + const rootRef = useCallback( + (node: TRoot | null) => { + rootElRef.current = node + connectObserver() + }, + [connectObserver], + ) + + const sentinelRef = useCallback( + (node: TTarget | null) => { + sentinelElRef.current = node + connectObserver() + }, + [connectObserver], + ) useEffect(() => { connectObserver() diff --git a/web/features/deployments/shared/ui/__tests__/deployment-status-badge.spec.tsx b/web/features/deployments/shared/ui/__tests__/deployment-status-badge.spec.tsx index 57056eaa90c45b..f502ef3158ef68 100644 --- a/web/features/deployments/shared/ui/__tests__/deployment-status-badge.spec.tsx +++ b/web/features/deployments/shared/ui/__tests__/deployment-status-badge.spec.tsx @@ -14,7 +14,12 @@ describe('DeploymentStatusBadge', () => { const badge = screen.getByText('Running').parentElement - expect(badge).toHaveClass('border', 'rounded-md', 'bg-util-colors-green-green-50', 'system-2xs-medium-uppercase') + expect(badge).toHaveClass( + 'border', + 'rounded-md', + 'bg-util-colors-green-green-50', + 'system-2xs-medium-uppercase', + ) expect(container.querySelector('.bg-util-colors-green-green-500')).not.toBeInTheDocument() expect(container.querySelector('.shadow-status-indicator-green-shadow')).toBeInTheDocument() }) @@ -47,6 +52,8 @@ describe('DeploymentStatusBadge', () => { ) expect(screen.getByText('Deploying')).toHaveClass('text-util-colors-blue-light-blue-light-600') - expect(container.querySelector('.shadow-status-indicator-blue-shadow')).toHaveClass('animate-pulse') + expect(container.querySelector('.shadow-status-indicator-blue-shadow')).toHaveClass( + 'animate-pulse', + ) }) }) diff --git a/web/features/deployments/shared/ui/deployment-status-badge.tsx b/web/features/deployments/shared/ui/deployment-status-badge.tsx index 6989ed0c68395e..e26298f259fb49 100644 --- a/web/features/deployments/shared/ui/deployment-status-badge.tsx +++ b/web/features/deployments/shared/ui/deployment-status-badge.tsx @@ -44,13 +44,8 @@ export function DeploymentStatusBadge({ )} {...props} > - <StatusDot - status={dotStatus} - className={cn('mr-2', isInProgress && 'animate-pulse')} - /> - <span className={cn('truncate', deploymentStatusDotTextClassName(status))}> - {label} - </span> + <StatusDot status={dotStatus} className={cn('mr-2', isInProgress && 'animate-pulse')} /> + <span className={cn('truncate', deploymentStatusDotTextClassName(status))}>{label}</span> </span> ) } @@ -68,10 +63,7 @@ export function DeploymentStatusBadge({ )} {...props} > - <StatusDot - status={dotStatus} - className={cn('shrink-0', isInProgress && 'animate-pulse')} - /> + <StatusDot status={dotStatus} className={cn('shrink-0', isInProgress && 'animate-pulse')} /> <span className="truncate">{label}</span> </span> ) @@ -98,7 +90,7 @@ export function EnvironmentDeploymentBadge({ const toneClassNames = deploymentStatusToneClassNames(status) const dotStatus = deploymentStatusDotStatus(status) const isInProgress = isRuntimeDeploymentInProgress(status) - const statusLabel = t($ => $[deploymentStatusLabelKey(status)]) + const statusLabel = t(($) => $[deploymentStatusLabelKey(status)]) const label = summaryLabel ?? `${name} · ${statusLabel}` const visibleLabel = showStatus ? `${name} · ${statusLabel}` : name @@ -113,10 +105,7 @@ export function EnvironmentDeploymentBadge({ )} {...props} > - <StatusDot - status={dotStatus} - className={cn('shrink-0', isInProgress && 'animate-pulse')} - /> + <StatusDot status={dotStatus} className={cn('shrink-0', isInProgress && 'animate-pulse')} /> <span className="truncate">{visibleLabel}</span> </span> ) diff --git a/web/features/deployments/shared/ui/deployment-status-style.ts b/web/features/deployments/shared/ui/deployment-status-style.ts index 0997d8e3112a76..d234348310d1e8 100644 --- a/web/features/deployments/shared/ui/deployment-status-style.ts +++ b/web/features/deployments/shared/ui/deployment-status-style.ts @@ -35,13 +35,14 @@ const STATUS_DOT_TEXT_CLASS_NAMES: Record<StatusDotStatus, string> = { warning: 'text-util-colors-warning-warning-600', } -const TONE_CLASS_NAMES: Record<DeploymentStatusTone, { badge: string, dot: string }> = { +const TONE_CLASS_NAMES: Record<DeploymentStatusTone, { badge: string; dot: string }> = { danger: { badge: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700', dot: 'bg-util-colors-red-red-500', }, info: { - badge: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700', + badge: + 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700', dot: 'bg-util-colors-blue-blue-500', }, neutral: { @@ -49,16 +50,20 @@ const TONE_CLASS_NAMES: Record<DeploymentStatusTone, { badge: string, dot: strin dot: 'bg-text-quaternary', }, success: { - badge: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700', + badge: + 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700', dot: 'bg-util-colors-green-green-500', }, warning: { - badge: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700', + badge: + 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700', dot: 'bg-util-colors-warning-warning-500', }, } -export function deploymentStatusLabelKey(status: RuntimeInstanceStatusValue): DeploymentStatusLabelKey { +export function deploymentStatusLabelKey( + status: RuntimeInstanceStatusValue, +): DeploymentStatusLabelKey { return `status.${status}` } diff --git a/web/features/system-features/__tests__/system-features.spec.ts b/web/features/system-features/__tests__/system-features.spec.ts index 3e33679a8cca03..ab32b0fba8a881 100644 --- a/web/features/system-features/__tests__/system-features.spec.ts +++ b/web/features/system-features/__tests__/system-features.spec.ts @@ -218,14 +218,15 @@ describe('systemFeaturesQueryOptions', () => { describe('serverSystemFeaturesQueryOptions', () => { it('should prefetch Cloud defaults without calling server system-features when Cloud edition is enabled', async () => { - const { getServerConsoleClientContext, module, systemFeatures } = await loadServerSystemFeaturesModule({ - isCloudEdition: true, - cloudEnv: { - NEXT_PUBLIC_ENABLE_MARKETPLACE: false, - NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: true, - NEXT_PUBLIC_ENABLE_LEARN_APP: true, - }, - }) + const { getServerConsoleClientContext, module, systemFeatures } = + await loadServerSystemFeaturesModule({ + isCloudEdition: true, + cloudEnv: { + NEXT_PUBLIC_ENABLE_MARKETPLACE: false, + NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: true, + NEXT_PUBLIC_ENABLE_LEARN_APP: true, + }, + }) const options = module.serverSystemFeaturesQueryOptions() const data = await options.queryFn?.(queryContext) @@ -245,10 +246,11 @@ describe('serverSystemFeaturesQueryOptions', () => { ...defaultSystemFeatures, enable_marketplace: true, } - const { getServerConsoleClientContext, module, systemFeatures } = await loadServerSystemFeaturesModule({ - isCloudEdition: false, - systemFeaturesResult, - }) + const { getServerConsoleClientContext, module, systemFeatures } = + await loadServerSystemFeaturesModule({ + isCloudEdition: false, + systemFeaturesResult, + }) const options = module.serverSystemFeaturesQueryOptions() const data = await options.queryFn?.(queryContext) diff --git a/web/features/system-features/client.ts b/web/features/system-features/client.ts index 2af36ea9fbe906..aaef4fad356b7c 100644 --- a/web/features/system-features/client.ts +++ b/web/features/system-features/client.ts @@ -34,8 +34,7 @@ export const systemFeaturesQueryOptions = () => { queryFn: async () => { try { return await consoleClient.systemFeatures.get() - } - catch (err) { + } catch (err) { console.error('[systemFeatures] fetch failed, using defaults', err) return defaultSystemFeatures } diff --git a/web/features/system-features/constants.ts b/web/features/system-features/constants.ts index 8c2401d63c046b..bea67e9378fc19 100644 --- a/web/features/system-features/constants.ts +++ b/web/features/system-features/constants.ts @@ -20,4 +20,7 @@ export const InstallationScope = { NONE: 'none', OFFICIAL_ONLY: 'official_only', OFFICIAL_AND_PARTNER: 'official_and_specific_partners', -} as const satisfies Record<string, GetSystemFeaturesResponse['plugin_installation_permission']['plugin_installation_scope']> +} as const satisfies Record< + string, + GetSystemFeaturesResponse['plugin_installation_permission']['plugin_installation_scope'] +> diff --git a/web/features/system-features/server.ts b/web/features/system-features/server.ts index 097bc6eea1a600..c363a95b3bf98e 100644 --- a/web/features/system-features/server.ts +++ b/web/features/system-features/server.ts @@ -26,8 +26,7 @@ export const serverSystemFeaturesQueryOptions = () => { return await serverConsoleClient.systemFeatures.get(undefined, { context: await getServerConsoleClientContext(), }) - } - catch (err) { + } catch (err) { console.error('[systemFeatures] server fetch failed', err) return defaultSystemFeatures } diff --git a/web/features/tag-management/__tests__/dataset-card-tags.spec.tsx b/web/features/tag-management/__tests__/dataset-card-tags.spec.tsx index d4a6562deacbec..61c9b3b0263b67 100644 --- a/web/features/tag-management/__tests__/dataset-card-tags.spec.tsx +++ b/web/features/tag-management/__tests__/dataset-card-tags.spec.tsx @@ -14,15 +14,9 @@ vi.mock('@/features/tag-management/components/tag-selector', () => ({ renderTagSelector(props) return ( <div role="group" aria-label="Tag selector mock"> - <div>{props.value.map(tag => tag.id).join(',')}</div> - <div> - {props.value.length} - {' '} - tags - </div> - <button onClick={props.onOpenTagManagement}> - Open Management - </button> + <div>{props.value.map((tag) => tag.id).join(',')}</div> + <div>{props.value.length} tags</div> + <button onClick={props.onOpenTagManagement}>Open Management</button> </div> ) }, @@ -71,9 +65,11 @@ describe('DatasetCardTags', () => { it('should pass tag binding permission to TagSelector', () => { render(<DatasetCardTags {...defaultProps} canBindOrUnbindTags={true} />) - expect(renderTagSelector).toHaveBeenCalledWith(expect.objectContaining({ - canBindOrUnbindTags: true, - })) + expect(renderTagSelector).toHaveBeenCalledWith( + expect.objectContaining({ + canBindOrUnbindTags: true, + }), + ) }) it('should render with empty tags', () => { @@ -88,8 +84,7 @@ describe('DatasetCardTags', () => { const { container } = render(<DatasetCardTags {...defaultProps} onClick={onClick} />) const wrapper = container.firstElementChild - if (!wrapper) - throw new Error('Expected dataset card tag wrapper') + if (!wrapper) throw new Error('Expected dataset card tag wrapper') fireEvent.click(wrapper) expect(onClick).toHaveBeenCalledTimes(1) @@ -109,16 +104,14 @@ describe('DatasetCardTags', () => { it('should have opacity class when embedding is not available', () => { const { container } = render(<DatasetCardTags {...defaultProps} embeddingAvailable={false} />) const wrapper = container.firstElementChild - if (!wrapper) - throw new Error('Expected dataset card tag wrapper') + if (!wrapper) throw new Error('Expected dataset card tag wrapper') expect(wrapper).toHaveClass('opacity-30') }) it('should not have opacity class when embedding is available', () => { const { container } = render(<DatasetCardTags {...defaultProps} embeddingAvailable={true} />) const wrapper = container.firstElementChild - if (!wrapper) - throw new Error('Expected dataset card tag wrapper') + if (!wrapper) throw new Error('Expected dataset card tag wrapper') expect(wrapper).not.toHaveClass('opacity-30') }) @@ -134,7 +127,9 @@ describe('DatasetCardTags', () => { it('should keep TagSelector visible when tags are empty', () => { const { container } = render(<DatasetCardTags {...defaultProps} tags={[]} />) - const tagSelectorWrapper = screen.getByRole('group', { name: 'Tag selector mock' }).parentElement + const tagSelectorWrapper = screen.getByRole('group', { + name: 'Tag selector mock', + }).parentElement expect(tagSelectorWrapper).toBeInTheDocument() expect(tagSelectorWrapper).toHaveClass('w-full') @@ -144,7 +139,9 @@ describe('DatasetCardTags', () => { it('should keep TagSelector visible when tags exist', () => { const { container } = render(<DatasetCardTags {...defaultProps} />) - const tagSelectorWrapper = screen.getByRole('group', { name: 'Tag selector mock' }).parentElement + const tagSelectorWrapper = screen.getByRole('group', { + name: 'Tag selector mock', + }).parentElement expect(tagSelectorWrapper).toBeInTheDocument() expect(tagSelectorWrapper).toHaveClass('w-full') @@ -159,12 +156,15 @@ describe('DatasetCardTags', () => { }) it('should handle many tags', () => { - const manyTags: Tag[] = Array.from({ length: 20 }, (_, i): Tag => ({ - id: `tag-${i}`, - name: `Tag ${i}`, - type: 'knowledge', - binding_count: '', - })) + const manyTags: Tag[] = Array.from( + { length: 20 }, + (_, i): Tag => ({ + id: `tag-${i}`, + name: `Tag ${i}`, + type: 'knowledge', + binding_count: '', + }), + ) render(<DatasetCardTags {...defaultProps} tags={manyTags} />) expect(screen.getByText('20 tags')).toBeInTheDocument() }) diff --git a/web/features/tag-management/__tests__/tag-filter.spec.tsx b/web/features/tag-management/__tests__/tag-filter.spec.tsx index 0eedc4954bd9b3..720e671fbdc933 100644 --- a/web/features/tag-management/__tests__/tag-filter.spec.tsx +++ b/web/features/tag-management/__tests__/tag-filter.spec.tsx @@ -52,7 +52,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) diff --git a/web/features/tag-management/__tests__/tag-item-editor.spec.tsx b/web/features/tag-management/__tests__/tag-item-editor.spec.tsx index cbb056b04ab64d..63a519125af6a9 100644 --- a/web/features/tag-management/__tests__/tag-item-editor.spec.tsx +++ b/web/features/tag-management/__tests__/tag-item-editor.spec.tsx @@ -6,16 +6,26 @@ import { TagItemEditor } from '../components/tag-item-editor' const tagMocks = vi.hoisted(() => { const record = vi.fn() - const api = vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })) + const api = vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ message, ...options }), + ) return { updateTag: vi.fn(), deleteTag: vi.fn(), record, api: Object.assign(api, { - success: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'success', message, ...options })), - error: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'error', message, ...options })), - warning: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'warning', message, ...options })), - info: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'info', message, ...options })), + success: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'success', message, ...options }), + ), + error: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'error', message, ...options }), + ), + warning: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'warning', message, ...options }), + ), + info: vi.fn((message: unknown, options?: Record<string, unknown>) => + record({ type: 'info', message, ...options }), + ), dismiss: vi.fn(), update: vi.fn(), promise: vi.fn(), @@ -26,7 +36,7 @@ const tagMocks = vi.hoisted(() => { vi.mock('@tanstack/react-query', () => ({ useMutation: (mutationOptions: { mutationFn: (input: unknown) => Promise<unknown> }) => ({ isPending: false, - mutate: (input: unknown, options?: { onSuccess?: () => void, onError?: () => void }) => { + mutate: (input: unknown, options?: { onSuccess?: () => void; onError?: () => void }) => { Promise.resolve(mutationOptions.mutationFn(input)) .then(() => options?.onSuccess?.()) .catch(() => options?.onError?.()) @@ -40,12 +50,19 @@ vi.mock('@/service/client', () => ({ byTagId: { patch: { mutationOptions: () => ({ - mutationFn: ({ params, body }: { params: { tag_id: string }, body: { name: string } }) => tagMocks.updateTag(params.tag_id, body.name), + mutationFn: ({ + params, + body, + }: { + params: { tag_id: string } + body: { name: string } + }) => tagMocks.updateTag(params.tag_id, body.name), }), }, delete: { mutationOptions: () => ({ - mutationFn: ({ params }: { params: { tag_id: string } }) => tagMocks.deleteTag(params.tag_id), + mutationFn: ({ params }: { params: { tag_id: string } }) => + tagMocks.deleteTag(params.tag_id), }), }, }, diff --git a/web/features/tag-management/__tests__/tag-management-modal.spec.tsx b/web/features/tag-management/__tests__/tag-management-modal.spec.tsx index 4edeecc4b6bd09..802f281a541cf3 100644 --- a/web/features/tag-management/__tests__/tag-management-modal.spec.tsx +++ b/web/features/tag-management/__tests__/tag-management-modal.spec.tsx @@ -36,7 +36,7 @@ vi.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: mockUseQueryData.current }), useMutation: (mutationOptions: { mutationFn: (input: unknown) => Promise<unknown> }) => ({ isPending: false, - mutate: (input: unknown, options?: { onSuccess?: () => void, onError?: () => void }) => { + mutate: (input: unknown, options?: { onSuccess?: () => void; onError?: () => void }) => { Promise.resolve(mutationOptions.mutationFn(input)) .then(() => options?.onSuccess?.()) .catch(() => options?.onError?.()) @@ -81,7 +81,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -94,7 +95,11 @@ vi.mock('@/service/client', () => ({ }, post: { mutationOptions: () => ({ - mutationFn: ({ body }: { body: { name: string, type: 'app' | 'knowledge' | 'snippet' } }) => createTag(body.name, body.type), + mutationFn: ({ + body, + }: { + body: { name: string; type: 'app' | 'knowledge' | 'snippet' } + }) => createTag(body.name, body.type), }), }, byTagId: { @@ -137,8 +142,17 @@ describe('TagManagementModal', () => { beforeEach(() => { vi.clearAllMocks() mockUseQueryData.current = mockTags - mockWorkspacePermissionKeys.value = ['app.tag.manage', 'dataset.tag.manage', 'snippets.create_and_modify'] - vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: '' }) + mockWorkspacePermissionKeys.value = [ + 'app.tag.manage', + 'dataset.tag.manage', + 'snippets.create_and_modify', + ] + vi.mocked(createTag).mockResolvedValue({ + id: 'new-tag', + name: 'NewTag', + type: 'app', + binding_count: '', + }) }) describe('Rendering', () => { @@ -315,7 +329,12 @@ describe('TagManagementModal', () => { it('should handle tag creation with knowledge type', async () => { const user = userEvent.setup() - vi.mocked(createTag).mockResolvedValue({ id: 'new-k', name: 'KnowledgeTag', type: 'knowledge', binding_count: '' }) + vi.mocked(createTag).mockResolvedValue({ + id: 'new-k', + name: 'KnowledgeTag', + type: 'knowledge', + binding_count: '', + }) render(<TagManagementModal {...defaultProps} type="knowledge" />) diff --git a/web/features/tag-management/__tests__/tag-search-content.spec.tsx b/web/features/tag-management/__tests__/tag-search-content.spec.tsx index 46257d249a76c5..0a8bc67109b9e3 100644 --- a/web/features/tag-management/__tests__/tag-search-content.spec.tsx +++ b/web/features/tag-management/__tests__/tag-search-content.spec.tsx @@ -52,7 +52,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -71,7 +72,12 @@ const appTags: Tag[] = [ { id: 'tag-3', name: 'API', type: 'app', binding_count: '' }, ] -const knowledgeTag: Tag = { id: 'tag-k1', name: 'KnowledgeDB', type: 'knowledge', binding_count: '' } +const knowledgeTag: Tag = { + id: 'tag-k1', + name: 'KnowledgeDB', + type: 'knowledge', + binding_count: '', +} type PanelHarnessProps = { type?: TagType @@ -95,18 +101,20 @@ const PanelHarness = ({ const [selectedTags, setSelectedTags] = useState<Tag[]>(value) const [inputValue, setInputValue] = useState('') const items = useMemo<TagComboboxItem[]>(() => { - const tags = tagList.filter(tag => tag.type === type) - - if (!inputValue || tags.some(tag => tag.name === inputValue)) - return tags - - return [{ - id: `__create_tag__:${inputValue}`, - name: inputValue, - type, - binding_count: '', - isCreateOption: true, - }, ...tags] + const tags = tagList.filter((tag) => tag.type === type) + + if (!inputValue || tags.some((tag) => tag.name === inputValue)) return tags + + return [ + { + id: `__create_tag__:${inputValue}`, + name: inputValue, + type, + binding_count: '', + isCreateOption: true, + }, + ...tags, + ] }, [inputValue, tagList, type]) return ( @@ -116,8 +124,7 @@ const PanelHarness = ({ value={selectedTags} onValueChange={(nextTags) => { onValueChangeSpy(nextTags) - if (nextTags.some(isCreateTagOption)) - return + if (nextTags.some(isCreateTagOption)) return setSelectedTags(nextTags) }} inputValue={inputValue} @@ -176,7 +183,10 @@ describe('TagSearchContent', () => { expect(input).toHaveValue('') expect(onValueChangeSpy).not.toHaveBeenCalled() - expect(screen.getByRole('option', { name: /Frontend/i })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('option', { name: /Frontend/i })).toHaveAttribute( + 'aria-selected', + 'true', + ) }) it('shows a create option when the query is not an existing tag name', async () => { @@ -204,7 +214,9 @@ describe('TagSearchContent', () => { render(<PanelHarness />) await user.click(screen.getByRole('option', { name: /Backend/i })) - expect(onValueChangeSpy).toHaveBeenLastCalledWith(expect.arrayContaining([expect.objectContaining({ id: 'tag-2' })])) + expect(onValueChangeSpy).toHaveBeenLastCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'tag-2' })]), + ) await user.click(screen.getByRole('option', { name: /Backend/i })) expect(onValueChangeSpy).toHaveBeenLastCalledWith([expect.objectContaining({ id: 'tag-1' })]) @@ -218,12 +230,14 @@ describe('TagSearchContent', () => { await user.type(input, 'BrandNewTag') await user.click(screen.getByRole('option', { name: /BrandNewTag/i })) - expect(onValueChangeSpy).toHaveBeenLastCalledWith(expect.arrayContaining([ - expect.objectContaining({ - isCreateOption: true, - name: 'BrandNewTag', - }), - ])) + expect(onValueChangeSpy).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + isCreateOption: true, + name: 'BrandNewTag', + }), + ]), + ) }) it('renders the empty state when no tags exist and no search is active', () => { @@ -269,9 +283,9 @@ describe('TagSearchContent', () => { await user.click(screen.getByRole('option', { name: /Backend/i })) - expect(onValueChangeSpy).toHaveBeenLastCalledWith(expect.arrayContaining([ - expect.objectContaining({ id: 'tag-2' }), - ])) + expect(onValueChangeSpy).toHaveBeenLastCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'tag-2' })]), + ) expect(screen.queryByRole('button', { name: i18n.manageTags })).not.toBeInTheDocument() }) diff --git a/web/features/tag-management/__tests__/tag-selector.spec.tsx b/web/features/tag-management/__tests__/tag-selector.spec.tsx index 46fc64a7fefa1b..6e0ae63bbe5754 100644 --- a/web/features/tag-management/__tests__/tag-selector.spec.tsx +++ b/web/features/tag-management/__tests__/tag-selector.spec.tsx @@ -70,7 +70,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) @@ -79,7 +80,7 @@ vi.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: mockUseQueryData.current }), useMutation: (mutationOptions: { mutationFn: (input: unknown) => Promise<unknown> }) => ({ isPending: false, - mutate: (input: unknown, options?: { onSuccess?: () => void, onError?: () => void }) => { + mutate: (input: unknown, options?: { onSuccess?: () => void; onError?: () => void }) => { Promise.resolve(mutationOptions.mutationFn(input)) .then(() => options?.onSuccess?.()) .catch(() => options?.onError?.()) @@ -95,7 +96,11 @@ vi.mock('@/service/client', () => ({ }, post: { mutationOptions: () => ({ - mutationFn: ({ body }: { body: { name: string, type: 'app' | 'knowledge' | 'snippet' } }) => createTag(body.name, body.type), + mutationFn: ({ + body, + }: { + body: { name: string; type: 'app' | 'knowledge' | 'snippet' } + }) => createTag(body.name, body.type), }), }, }, @@ -105,16 +110,27 @@ vi.mock('@/service/client', () => ({ vi.mock('../hooks/use-tag-mutations', () => ({ useApplyTagBindingsMutation: () => ({ mutate: ( - { currentTagIds, nextTagIds, targetId, type }: { currentTagIds: string[], nextTagIds: string[], targetId: string, type: 'app' | 'knowledge' }, - options?: { onSuccess?: () => void, onError?: () => void, onSettled?: () => void }, + { + currentTagIds, + nextTagIds, + targetId, + type, + }: { + currentTagIds: string[] + nextTagIds: string[] + targetId: string + type: 'app' | 'knowledge' + }, + options?: { onSuccess?: () => void; onError?: () => void; onSettled?: () => void }, ) => { - const addTagIds = nextTagIds.filter(tagId => !currentTagIds.includes(tagId)) - const removeTagIds = currentTagIds.filter(tagId => !nextTagIds.includes(tagId)) + const addTagIds = nextTagIds.filter((tagId) => !currentTagIds.includes(tagId)) + const removeTagIds = currentTagIds.filter((tagId) => !nextTagIds.includes(tagId)) const operations: Promise<unknown>[] = [] - if (addTagIds.length) - operations.push(Promise.resolve(bindTag(addTagIds, targetId, type))) - operations.push(...removeTagIds.map(tagId => Promise.resolve(unBindTag(tagId, targetId, type)))) + if (addTagIds.length) operations.push(Promise.resolve(bindTag(addTagIds, targetId, type))) + operations.push( + ...removeTagIds.map((tagId) => Promise.resolve(unBindTag(tagId, targetId, type))), + ) Promise.all(operations) .then(() => options?.onSuccess?.()) @@ -147,9 +163,18 @@ const defaultProps = { describe('TagSelector', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspacePermissionKeys.value = ['app.tag.manage', 'dataset.tag.manage', 'snippets.create_and_modify'] + mockWorkspacePermissionKeys.value = [ + 'app.tag.manage', + 'dataset.tag.manage', + 'snippets.create_and_modify', + ] mockUseQueryData.current = appTags - vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: '' }) + vi.mocked(createTag).mockResolvedValue({ + id: 'new-tag', + name: 'NewTag', + type: 'app', + binding_count: '', + }) vi.mocked(bindTag).mockResolvedValue(undefined) vi.mocked(unBindTag).mockResolvedValue(undefined) }) @@ -160,14 +185,25 @@ describe('TagSelector', () => { }) it('renders the no tag trigger when no current tag is visible and binding is unavailable', () => { - render(<TagSelector {...defaultProps} value={[{ id: 'orphan', name: 'Orphan', type: 'app', binding_count: '' }]} />) + render( + <TagSelector + {...defaultProps} + value={[{ id: 'orphan', name: 'Orphan', type: 'app', binding_count: '' }]} + />, + ) expect(screen.queryByText('Orphan')).not.toBeInTheDocument() expect(screen.getByText(i18n.noTag)).toBeInTheDocument() expect(screen.queryByText(i18n.addTag)).not.toBeInTheDocument() }) it('renders the add tag trigger when no current tag is visible and binding is available', () => { - render(<TagSelector {...defaultProps} value={[{ id: 'orphan', name: 'Orphan', type: 'app', binding_count: '' }]} canBindOrUnbindTags />) + render( + <TagSelector + {...defaultProps} + value={[{ id: 'orphan', name: 'Orphan', type: 'app', binding_count: '' }]} + canBindOrUnbindTags + />, + ) expect(screen.queryByText('Orphan')).not.toBeInTheDocument() expect(screen.getByText(i18n.addTag)).toBeInTheDocument() }) @@ -178,7 +214,9 @@ describe('TagSelector', () => { await user.click(screen.getByRole('combobox', { name: /Frontend/i })) - expect(await screen.findByRole('combobox', { name: i18n.selectorPlaceholder })).toBeInTheDocument() + expect( + await screen.findByRole('combobox', { name: i18n.selectorPlaceholder }), + ).toBeInTheDocument() expect(screen.getByText(i18n.manageTags)).toBeInTheDocument() expect(screen.getByRole('option', { name: /Backend/i })).toBeInTheDocument() }) @@ -287,7 +325,10 @@ describe('TagSelector', () => { render(<TagSelector {...defaultProps} type="knowledge" value={[]} />) await user.click(screen.getByRole('combobox', { name: i18n.noTag })) - await user.type(await screen.findByRole('combobox', { name: i18n.selectorPlaceholder }), 'NewKnowledgeTag') + await user.type( + await screen.findByRole('combobox', { name: i18n.selectorPlaceholder }), + 'NewKnowledgeTag', + ) await user.click(await screen.findByRole('option', { name: /NewKnowledgeTag/i })) await waitFor(() => { @@ -304,7 +345,9 @@ describe('TagSelector', () => { await user.click(screen.getByRole('combobox', { name: /Frontend/i })) - expect(screen.queryByRole('combobox', { name: i18n.selectorPlaceholder })).not.toBeInTheDocument() + expect( + screen.queryByRole('combobox', { name: i18n.selectorPlaceholder }), + ).not.toBeInTheDocument() }) it('opens the tag selector with binding capability even without workspace tag management permission', async () => { @@ -315,7 +358,9 @@ describe('TagSelector', () => { await user.click(screen.getByRole('combobox', { name: /Frontend/i })) - expect(await screen.findByRole('combobox', { name: i18n.selectorPlaceholder })).toBeInTheDocument() + expect( + await screen.findByRole('combobox', { name: i18n.selectorPlaceholder }), + ).toBeInTheDocument() expect(screen.queryByRole('button', { name: i18n.manageTags })).not.toBeInTheDocument() }) @@ -342,7 +387,10 @@ describe('TagSelector', () => { render(<TagSelector {...defaultProps} canBindOrUnbindTags />) await user.click(screen.getByRole('combobox', { name: /Frontend/i })) - await user.type(await screen.findByRole('combobox', { name: i18n.selectorPlaceholder }), 'BrandNewTag') + await user.type( + await screen.findByRole('combobox', { name: i18n.selectorPlaceholder }), + 'BrandNewTag', + ) expect(screen.queryByRole('option', { name: /BrandNewTag/i })).not.toBeInTheDocument() expect(createTag).not.toHaveBeenCalled() @@ -351,7 +399,9 @@ describe('TagSelector', () => { it('opens snippet tag selector with snippets create-and-modify permission', async () => { const user = userEvent.setup() mockWorkspacePermissionKeys.value = ['snippets.create_and_modify'] - mockUseQueryData.current = [{ id: 'snippet-tag-1', name: 'Reusable', type: 'snippet', binding_count: '' }] + mockUseQueryData.current = [ + { id: 'snippet-tag-1', name: 'Reusable', type: 'snippet', binding_count: '' }, + ] render( <TagSelector @@ -363,6 +413,8 @@ describe('TagSelector', () => { await user.click(screen.getByRole('combobox', { name: /Reusable/i })) - expect(await screen.findByRole('combobox', { name: i18n.selectorPlaceholder })).toBeInTheDocument() + expect( + await screen.findByRole('combobox', { name: i18n.selectorPlaceholder }), + ).toBeInTheDocument() }) }) diff --git a/web/features/tag-management/__tests__/tag-trigger.spec.tsx b/web/features/tag-management/__tests__/tag-trigger.spec.tsx index eb60f47dfa3f0d..4a4efc0f0f76c4 100644 --- a/web/features/tag-management/__tests__/tag-trigger.spec.tsx +++ b/web/features/tag-management/__tests__/tag-trigger.spec.tsx @@ -57,7 +57,7 @@ describe('Trigger', () => { const tags = ['A', 'B', 'C', 'D', 'E'] render(<TagTrigger tags={tags} />) - tags.forEach(tag => expect(screen.getByText(tag)).toBeInTheDocument()) + tags.forEach((tag) => expect(screen.getByText(tag)).toBeInTheDocument()) expect(screen.getAllByRole('listitem')).toHaveLength(tags.length) }) }) diff --git a/web/features/tag-management/components/__tests__/app-card-tags.spec.tsx b/web/features/tag-management/components/__tests__/app-card-tags.spec.tsx index f6f65d82d7c0b7..c9b45af197442c 100644 --- a/web/features/tag-management/components/__tests__/app-card-tags.spec.tsx +++ b/web/features/tag-management/components/__tests__/app-card-tags.spec.tsx @@ -19,9 +19,13 @@ vi.mock('@/features/tag-management/components/tag-selector', () => ({ return ( <div role="group" aria-label="Tag selector mock"> - <span>{props.value.map(tag => tag.name).join(',')}</span> - <button type="button" onClick={props.onOpenTagManagement}>Manage Tags</button> - <button type="button" onClick={props.onTagsChange}>Tags Changed</button> + <span>{props.value.map((tag) => tag.name).join(',')}</span> + <button type="button" onClick={props.onOpenTagManagement}> + Manage Tags + </button> + <button type="button" onClick={props.onTagsChange}> + Tags Changed + </button> </div> ) }, @@ -43,13 +47,15 @@ describe('AppCardTags', () => { expect(screen.getByRole('group', { name: 'Tag selector mock' })).toBeInTheDocument() expect(screen.getByText('Frontend,Backend')).toBeInTheDocument() - expect(renderTagSelector).toHaveBeenCalledWith(expect.objectContaining({ - placement: 'bottom-start', - targetId: 'app-1', - type: 'app', - value: tags, - canBindOrUnbindTags: undefined, - })) + expect(renderTagSelector).toHaveBeenCalledWith( + expect.objectContaining({ + placement: 'bottom-start', + targetId: 'app-1', + type: 'app', + value: tags, + canBindOrUnbindTags: undefined, + }), + ) }) it('should keep the overflow mask independent from app card hover', () => { @@ -89,17 +95,21 @@ describe('AppCardTags', () => { it('should pass an empty selection when the app has no tags', () => { render(<AppCardTags appId="app-1" tags={[]} />) - expect(renderTagSelector).toHaveBeenCalledWith(expect.objectContaining({ - value: [], - })) + expect(renderTagSelector).toHaveBeenCalledWith( + expect.objectContaining({ + value: [], + }), + ) }) it('should forward app ACL tag binding capability', () => { render(<AppCardTags appId="app-1" tags={tags} canBindOrUnbindTags={false} />) - expect(renderTagSelector).toHaveBeenCalledWith(expect.objectContaining({ - canBindOrUnbindTags: false, - })) + expect(renderTagSelector).toHaveBeenCalledWith( + expect.objectContaining({ + canBindOrUnbindTags: false, + }), + ) }) }) }) diff --git a/web/features/tag-management/components/dataset-card-tags.tsx b/web/features/tag-management/components/dataset-card-tags.tsx index 8075845a793501..3978d5475ef9b4 100644 --- a/web/features/tag-management/components/dataset-card-tags.tsx +++ b/web/features/tag-management/components/dataset-card-tags.tsx @@ -36,12 +36,8 @@ export const DatasetCardTags = ({ onTagsChange={onTagsChange} canBindOrUnbindTags={canBindOrUnbindTags} /> - <div - className="pointer-events-none absolute top-0 right-0 h-full w-20 bg-tag-selector-mask-bg group-focus-within/tag-area:hidden group-hover/tag-area:hidden" - /> - <div - className="pointer-events-none absolute top-0 right-0 h-full w-20 bg-tag-selector-mask-bg group-focus-within/tag-area:hidden group-hover/tag-area:hidden" - /> + <div className="pointer-events-none absolute top-0 right-0 h-full w-20 bg-tag-selector-mask-bg group-focus-within/tag-area:hidden group-hover/tag-area:hidden" /> + <div className="pointer-events-none absolute top-0 right-0 h-full w-20 bg-tag-selector-mask-bg group-focus-within/tag-area:hidden group-hover/tag-area:hidden" /> </div> </div> ) diff --git a/web/features/tag-management/components/tag-filter.tsx b/web/features/tag-management/components/tag-filter.tsx index 132553969e7420..10812c23eb152d 100644 --- a/web/features/tag-management/components/tag-filter.tsx +++ b/web/features/tag-management/components/tag-filter.tsx @@ -9,7 +9,8 @@ import XCircleIcon from '@/app/components/base/icons/src/vender/solid/general/XC import { consoleQuery } from '@/service/client' import { TagSearchContent } from './tag-search-content' -const tagFilterComboboxFilter: NonNullable<ComboboxProps<Tag, true>['filter']> = (tag, query) => tag.name.includes(query) +const tagFilterComboboxFilter: NonNullable<ComboboxProps<Tag, true>['filter']> = (tag, query) => + tag.name.includes(query) const tagToString = (tag: Tag) => tag.name const isSameTag = (item: Tag, value: Tag) => item.id === value.id @@ -33,16 +34,18 @@ export const TagFilter = ({ const [open, setOpen] = useState(false) const [inputValue, setInputValue] = useState('') - const { data: tagList = [] } = useQuery(consoleQuery.tags.get.queryOptions({ - input: { - query: { - type, + const { data: tagList = [] } = useQuery( + consoleQuery.tags.get.queryOptions({ + input: { + query: { + type, + }, }, - }, - })) + }), + ) - const tagById = useMemo(() => new Map(tagList.map(tag => [tag.id, tag])), [tagList]) - const items = useMemo(() => tagList.filter(tag => tag.type === type), [tagList, type]) + const tagById = useMemo(() => new Map(tagList.map((tag) => [tag.id, tag])), [tagList]) + const items = useMemo(() => tagList.filter((tag) => tag.type === type), [tagList, type]) const selectedTags = useMemo(() => { return value.flatMap((tagId) => { const tag = tagById.get(tagId) @@ -52,12 +55,17 @@ export const TagFilter = ({ const firstTagId = value[0] const currentTagName = firstTagId ? tagById.get(firstTagId)?.name : undefined - const placeholderLabel = t($ => $['tag.placeholder'], { ns: 'common' }) - const triggerLabel = selectedTags.length ? selectedTags.map(tag => tag.name).join(', ') : placeholderLabel - const handleValueChange = useCallback((nextTags: Tag[]) => { - const unknownTagIds = value.filter(tagId => !tagById.has(tagId)) - onChange([...unknownTagIds, ...nextTags.map(tag => tag.id)]) - }, [onChange, tagById, value]) + const placeholderLabel = t(($) => $['tag.placeholder'], { ns: 'common' }) + const triggerLabel = selectedTags.length + ? selectedTags.map((tag) => tag.name).join(', ') + : placeholderLabel + const handleValueChange = useCallback( + (nextTags: Tag[]) => { + const unknownTagIds = value.filter((tagId) => !tagById.has(tagId)) + onChange([...unknownTagIds, ...nextTags.map((tag) => tag.id)]) + }, + [onChange, tagById, value], + ) return ( <Combobox @@ -86,7 +94,10 @@ export const TagFilter = ({ <span className="flex w-full min-w-0 items-center gap-1"> {showLeadingIcon && ( <span className="p-px"> - <span className="i-custom-vender-line-financeAndECommerce-tag-01 size-3.5 text-text-tertiary" aria-hidden="true" /> + <span + className="i-custom-vender-line-financeAndECommerce-tag-01 size-3.5 text-text-tertiary" + aria-hidden="true" + /> </span> )} <span className="min-w-0 grow truncate text-[13px] leading-4.5 text-text-tertiary"> @@ -106,14 +117,17 @@ export const TagFilter = ({ {!!value.length && ( <button type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} className="group/clear absolute top-1/2 right-2 -translate-y-1/2 rounded-md border-none bg-transparent p-px outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" onClick={(event) => { event.stopPropagation() onChange([]) }} > - <XCircleIcon className="size-3.5 text-text-tertiary group-hover/clear:text-text-secondary" aria-hidden="true" /> + <XCircleIcon + className="size-3.5 text-text-tertiary group-hover/clear:text-text-secondary" + aria-hidden="true" + /> </button> )} <ComboboxContent @@ -131,6 +145,5 @@ export const TagFilter = ({ </ComboboxContent> </div> </Combobox> - ) } diff --git a/web/features/tag-management/components/tag-item-editor.tsx b/web/features/tag-management/components/tag-item-editor.tsx index c7516b5b67114c..484ae2b2f9382a 100644 --- a/web/features/tag-management/components/tag-item-editor.tsx +++ b/web/features/tag-management/components/tag-item-editor.tsx @@ -10,11 +10,7 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useMutation } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { useState } from 'react' @@ -41,117 +37,135 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => { return } - updateTagMutation.mutate({ - params: { - tag_id: tagId, - }, - body: { - name, + updateTagMutation.mutate( + { + params: { + tag_id: tagId, + }, + body: { + name, + }, }, - }, { - onSuccess: () => { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - setIsEditing(false) - onTagsChange?.() + { + onSuccess: () => { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + setIsEditing(false) + onTagsChange?.() + }, + onError: () => { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + setIsEditing(false) + }, }, - onError: () => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - setIsEditing(false) - }, - }) + ) } const [showRemoveModal, setShowRemoveModal] = useState(false) const removeTag = (tagId: string) => { - if (deleteTagMutation.isPending) - return + if (deleteTagMutation.isPending) return - deleteTagMutation.mutate({ - params: { - tag_id: tagId, + deleteTagMutation.mutate( + { + params: { + tag_id: tagId, + }, }, - }, { - onSuccess: () => { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) - onTagsChange?.() + { + onSuccess: () => { + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + onTagsChange?.() + }, + onError: () => { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + }, }, - onError: () => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - }, - }) + ) } - const { run: handleRemove } = useDebounceFn(() => { - removeTag(tag.id) - }, { wait: 200 }) + const { run: handleRemove } = useDebounceFn( + () => { + removeTag(tag.id) + }, + { wait: 200 }, + ) return ( <> - <div className={cn('flex shrink-0 items-center gap-0.5 rounded-lg border border-components-panel-border py-1 pr-1 pl-2 text-sm/5 text-text-secondary')}> + <div + className={cn( + 'flex shrink-0 items-center gap-0.5 rounded-lg border border-components-panel-border py-1 pr-1 pl-2 text-sm/5 text-text-secondary', + )} + > {!isEditing && ( <> - <div className="text-sm/5 text-text-secondary"> - {tag.name} - </div> + <div className="text-sm/5 text-text-secondary">{tag.name}</div> <Tooltip> <TooltipTrigger> - <div className="shrink-0 px-1 text-sm/4.5 font-medium text-text-tertiary">{tag.binding_count}</div> + <div className="shrink-0 px-1 text-sm/4.5 font-medium text-text-tertiary"> + {tag.binding_count} + </div> </TooltipTrigger> - <TooltipContent>{t($ => $['common.tagBound'], { ns: 'workflow' })}</TooltipContent> + <TooltipContent>{t(($) => $['common.tagBound'], { ns: 'workflow' })}</TooltipContent> </Tooltip> <button type="button" - aria-label={`${t($ => $['operation.edit'], { ns: 'common' })} ${tag.name}`} + aria-label={`${t(($) => $['operation.edit'], { ns: 'common' })} ${tag.name}`} className="group/edit shrink-0 cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={() => setIsEditing(true)} > - <span aria-hidden="true" className="i-ri-edit-line size-3 text-text-tertiary group-hover/edit:text-text-secondary" /> + <span + aria-hidden="true" + className="i-ri-edit-line size-3 text-text-tertiary group-hover/edit:text-text-secondary" + /> </button> <button type="button" - aria-label={`${t($ => $['operation.remove'], { ns: 'common' })} ${tag.name}`} + aria-label={`${t(($) => $['operation.remove'], { ns: 'common' })} ${tag.name}`} className="group/remove shrink-0 cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" onClick={() => { - if (Number(tag.binding_count ?? 0) > 0) - setShowRemoveModal(true) - else - handleRemove() + if (Number(tag.binding_count ?? 0) > 0) setShowRemoveModal(true) + else handleRemove() }} > - <span aria-hidden="true" className="i-ri-delete-bin-line size-3 text-text-tertiary group-hover/remove:text-text-secondary" /> + <span + aria-hidden="true" + className="i-ri-delete-bin-line size-3 text-text-tertiary group-hover/remove:text-text-secondary" + /> </button> </> )} {isEditing && ( <input - aria-label={`${t($ => $['operation.rename'], { ns: 'common' })} ${tag.name}`} + aria-label={`${t(($) => $['operation.rename'], { ns: 'common' })} ${tag.name}`} className="shrink-0 appearance-none caret-primary-600 outline-hidden placeholder:text-text-quaternary" autoFocus defaultValue={tag.name} onKeyDown={(e) => { - if (e.key !== 'Enter' || e.nativeEvent.isComposing) - return + if (e.key !== 'Enter' || e.nativeEvent.isComposing) return e.preventDefault() e.currentTarget.blur() }} - onBlur={e => editTag(tag.id, e.currentTarget.value)} + onBlur={(e) => editTag(tag.id, e.currentTarget.value)} /> )} </div> - <AlertDialog open={showRemoveModal} onOpenChange={open => !open && setShowRemoveModal(false)}> + <AlertDialog + open={showRemoveModal} + onOpenChange={(open) => !open && setShowRemoveModal(false)} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle - title={`${t($ => $['tag.delete'], { ns: 'common' })} "${tag.name}"`} + title={`${t(($) => $['tag.delete'], { ns: 'common' })} "${tag.name}"`} className="w-full truncate title-2xl-semi-bold text-text-primary" > - {`${t($ => $['tag.delete'], { ns: 'common' })} "${tag.name}"`} + {`${t(($) => $['tag.delete'], { ns: 'common' })} "${tag.name}"`} </AlertDialogTitle> <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary"> - {t($ => $['tag.deleteTip'], { ns: 'common' })} + {t(($) => $['tag.deleteTip'], { ns: 'common' })} </AlertDialogDescription> </div> <AlertDialogActions> <AlertDialogCancelButton> - {t($ => $['operation.cancel'], { ns: 'common' })} + {t(($) => $['operation.cancel'], { ns: 'common' })} </AlertDialogCancelButton> <AlertDialogConfirmButton onClick={() => { @@ -159,7 +173,7 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => { setShowRemoveModal(false) }} > - {t($ => $['operation.confirm'], { ns: 'common' })} + {t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/features/tag-management/components/tag-management-modal.tsx b/web/features/tag-management/components/tag-management-modal.tsx index 01635b9d637947..4cfd4800600e20 100644 --- a/web/features/tag-management/components/tag-management-modal.tsx +++ b/web/features/tag-management/components/tag-management-modal.tsx @@ -18,60 +18,78 @@ type TagManagementModalProps = { onClose: () => void onTagsChange?: () => void } -export const TagManagementModal = ({ show, type, onClose, onTagsChange }: TagManagementModalProps) => { +export const TagManagementModal = ({ + show, + type, + onClose, + onTagsChange, +}: TagManagementModalProps) => { const { t } = useTranslation() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type)) - const { data: tagList = [] } = useQuery(consoleQuery.tags.get.queryOptions({ - input: { - query: { - type, + const { data: tagList = [] } = useQuery( + consoleQuery.tags.get.queryOptions({ + input: { + query: { + type, + }, }, - }, - enabled: show && canManageTags, - })) + enabled: show && canManageTags, + }), + ) const createTagMutation = useMutation(consoleQuery.tags.post.mutationOptions()) const [name, setName] = useState<string>('') const createNewTag = () => { - if (!canManageTags) - return - if (!name) - return - if (createTagMutation.isPending) - return + if (!canManageTags) return + if (!name) return + if (createTagMutation.isPending) return - createTagMutation.mutate({ - body: { - name, - type, - }, - }, { - onSuccess: () => { - toast.success(t($ => $['tag.created'], { ns: 'common' })) - setName('') + createTagMutation.mutate( + { + body: { + name, + type, + }, }, - onError: () => { - toast.error(t($ => $['tag.failed'], { ns: 'common' })) + { + onSuccess: () => { + toast.success(t(($) => $['tag.created'], { ns: 'common' })) + setName('') + }, + onError: () => { + toast.error(t(($) => $['tag.failed'], { ns: 'common' })) + }, }, - }) + ) } const handleClose = () => { setName('') onClose() } - if (!canManageTags) - return null + if (!canManageTags) return null return ( - <Dialog open={show} onOpenChange={open => !open && handleClose()}> + <Dialog open={show} onOpenChange={(open) => !open && handleClose()}> <DialogContent className="w-150 max-w-150 rounded-xl p-8"> - <div className="relative pb-2 text-xl/7.5 font-semibold text-text-primary">{t($ => $['tag.manageTags'], { ns: 'common' })}</div> + <div className="relative pb-2 text-xl/7.5 font-semibold text-text-primary"> + {t(($) => $['tag.manageTags'], { ns: 'common' })} + </div> <DialogCloseButton className="top-4 right-4" /> <div className="mt-3 flex flex-wrap gap-2"> - <input aria-label={t($ => $['tag.addNew'], { ns: 'common' }) || ''} className="w-25 shrink-0 appearance-none rounded-lg border border-dashed border-divider-regular bg-transparent px-2 py-1 text-sm/5 text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary focus:border-solid" placeholder={t($ => $['tag.addNew'], { ns: 'common' }) || ''} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && createNewTag()} onBlur={createNewTag} /> - {tagList.map(tag => (<TagItemEditor key={tag.id} tag={tag} onTagsChange={onTagsChange} />))} + <input + aria-label={t(($) => $['tag.addNew'], { ns: 'common' }) || ''} + className="w-25 shrink-0 appearance-none rounded-lg border border-dashed border-divider-regular bg-transparent px-2 py-1 text-sm/5 text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary focus:border-solid" + placeholder={t(($) => $['tag.addNew'], { ns: 'common' }) || ''} + value={name} + onChange={(e) => setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && createNewTag()} + onBlur={createNewTag} + /> + {tagList.map((tag) => ( + <TagItemEditor key={tag.id} tag={tag} onTagsChange={onTagsChange} /> + ))} </div> </DialogContent> </Dialog> diff --git a/web/features/tag-management/components/tag-search-content.tsx b/web/features/tag-management/components/tag-search-content.tsx index 16cc2c5800c1c1..f86854b4fd5c23 100644 --- a/web/features/tag-management/components/tag-search-content.tsx +++ b/web/features/tag-management/components/tag-search-content.tsx @@ -1,6 +1,16 @@ import type { TagType } from '@dify/contracts/api/console/tags/types.gen' import type { TagComboboxItem } from './tag-combobox-item' -import { ComboboxEmpty, ComboboxInput, ComboboxInputGroup, ComboboxItem, ComboboxItemIndicator, ComboboxItemText, ComboboxList, ComboboxSeparator, useComboboxFilteredItems } from '@langgenius/dify-ui/combobox' +import { + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxItem, + ComboboxItemIndicator, + ComboboxItemText, + ComboboxList, + ComboboxSeparator, + useComboboxFilteredItems, +} from '@langgenius/dify-ui/combobox' import { useAtomValue } from 'jotai' import { Fragment } from 'react' import { useTranslation } from 'react-i18next' @@ -30,14 +40,17 @@ export const TagSearchContent = ({ const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type)) const filteredItems = useComboboxFilteredItems<TagComboboxItem>() - const realItemCount = filteredItems.filter(tag => !isCreateTagOption(tag)).length - const placeholder = t($ => $['tag.selectorPlaceholder'], { ns: 'common' }) || '' + const realItemCount = filteredItems.filter((tag) => !isCreateTagOption(tag)).length + const placeholder = t(($) => $['tag.selectorPlaceholder'], { ns: 'common' }) || '' return ( <div className="relative w-full"> <div className="p-2 pb-1"> <ComboboxInputGroup className="border-divider-subtle bg-components-input-bg-normal"> - <span aria-hidden="true" className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" /> + <span + aria-hidden="true" + className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" + /> <ComboboxInput aria-label={placeholder} name={`tag-search-${type}`} @@ -47,10 +60,10 @@ export const TagSearchContent = ({ {inputValue && ( <button type="button" - aria-label={t($ => $['operation.clear'], { ns: 'common' })} + aria-label={t(($) => $['operation.clear'], { ns: 'common' })} className="mr-1.5 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-components-input-bg-hover hover:text-text-secondary focus-visible:bg-components-input-bg-hover focus-visible:text-text-secondary focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-active" onClick={() => onInputValueChange('')} - onPointerDown={event => event.preventDefault()} + onPointerDown={(event) => event.preventDefault()} > <span className="i-ri-close-line size-4" aria-hidden="true" /> </button> @@ -62,13 +75,14 @@ export const TagSearchContent = ({ if (isCreateTagOption(tag) && canManageTags) { return ( <Fragment key={tag.id}> - <ComboboxItem - value={tag} - > + <ComboboxItem value={tag}> <ComboboxItemText className="flex items-center gap-x-1 px-0"> - <span aria-hidden="true" className="i-ri-add-line size-4 shrink-0 text-text-tertiary" /> + <span + aria-hidden="true" + className="i-ri-add-line size-4 shrink-0 text-text-tertiary" + /> <span className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary"> - {`${t($ => $['tag.create'], { ns: 'common' })} `} + {`${t(($) => $['tag.create'], { ns: 'common' })} `} <span className="system-md-medium">{`'${tag.name}'`}</span> </span> </ComboboxItemText> @@ -92,8 +106,13 @@ export const TagSearchContent = ({ </ComboboxList> <ComboboxEmpty className="p-1"> <div className="flex flex-col items-center gap-y-1 p-3"> - <span className="i-custom-vender-line-financeAndECommerce-tag-01 size-6 text-text-quaternary" aria-hidden="true" /> - <div className="system-xs-regular text-text-tertiary">{t($ => $['tag.noTag'], { ns: 'common' })}</div> + <span + className="i-custom-vender-line-financeAndECommerce-tag-01 size-6 text-text-quaternary" + aria-hidden="true" + /> + <div className="system-xs-regular text-text-tertiary"> + {t(($) => $['tag.noTag'], { ns: 'common' })} + </div> </div> </ComboboxEmpty> {canManageTags && ( @@ -108,9 +127,12 @@ export const TagSearchContent = ({ onClose?.() }} > - <span className="i-custom-vender-line-financeAndECommerce-tag-01 size-4 text-text-tertiary" aria-hidden="true" /> + <span + className="i-custom-vender-line-financeAndECommerce-tag-01 size-4 text-text-tertiary" + aria-hidden="true" + /> <span className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary"> - {t($ => $['tag.manageTags'], { ns: 'common' })} + {t(($) => $['tag.manageTags'], { ns: 'common' })} </span> </button> </div> diff --git a/web/features/tag-management/components/tag-selector.tsx b/web/features/tag-management/components/tag-selector.tsx index c908f12f7feebe..9b5ace5b444ad2 100644 --- a/web/features/tag-management/components/tag-selector.tsx +++ b/web/features/tag-management/components/tag-selector.tsx @@ -18,7 +18,10 @@ import { isCreateTagOption } from './tag-combobox-item' import { TagSearchContent } from './tag-search-content' import { TagTrigger } from './tag-trigger' -const TAG_COMBOBOX_FILTER: NonNullable<ComboboxProps<TagComboboxItem, true>['filter']> = (tag, query) => tag.name.includes(query) +const TAG_COMBOBOX_FILTER: NonNullable<ComboboxProps<TagComboboxItem, true>['filter']> = ( + tag, + query, +) => tag.name.includes(query) const tagToString = (tag: TagComboboxItem) => tag.name const isSameTag = (item: TagComboboxItem, value: TagComboboxItem) => item.id === value.id @@ -41,16 +44,26 @@ type TagSelectorRootProps = Omit< | 'onOpenChangeComplete' | 'children' > -type TagSelectorContentProps = Pick<ComponentProps<typeof ComboboxContent>, 'placement' | 'sideOffset' | 'alignOffset' | 'portalProps' | 'positionerProps' | 'popupProps' | 'popupClassName'> +type TagSelectorContentProps = Pick< + ComponentProps<typeof ComboboxContent>, + | 'placement' + | 'sideOffset' + | 'alignOffset' + | 'portalProps' + | 'positionerProps' + | 'popupProps' + | 'popupClassName' +> -type TagSelectorProps = TagSelectorRootProps & TagSelectorContentProps & { - targetId: string - type: TagType - value: Tag[] - canBindOrUnbindTags?: boolean - onOpenTagManagement?: () => void - onTagsChange?: () => void -} +type TagSelectorProps = TagSelectorRootProps & + TagSelectorContentProps & { + targetId: string + type: TagType + value: Tag[] + canBindOrUnbindTags?: boolean + onOpenTagManagement?: () => void + onTagsChange?: () => void + } export const TagSelector = ({ targetId, @@ -76,32 +89,32 @@ export const TagSelector = ({ const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type)) const applyTagBindingsMutation = useApplyTagBindingsMutation() - const { - isPending: isCreatingTag, - mutate: createTag, - } = useMutation(consoleQuery.tags.post.mutationOptions()) - const { data: tagList = [] } = useQuery(consoleQuery.tags.get.queryOptions({ - input: { - query: { - type, + const { isPending: isCreatingTag, mutate: createTag } = useMutation( + consoleQuery.tags.post.mutationOptions(), + ) + const { data: tagList = [] } = useQuery( + consoleQuery.tags.get.queryOptions({ + input: { + query: { + type, + }, }, - }, - })) + }), + ) - const selectedTagIds = useMemo(() => value.map(tag => tag.id), [value]) + const selectedTagIds = useMemo(() => value.map((tag) => tag.id), [value]) const tagNames = useMemo(() => { - if (!value.length) - return [] + if (!value.length) return [] - const tagNameById = new Map(tagList.map(tag => [tag.id, tag.name])) + const tagNameById = new Map(tagList.map((tag) => [tag.id, tag.name])) return value.flatMap((tag) => { const tagName = tagNameById.get(tag.id) return tagName ? [tagName] : [] }) }, [tagList, value]) const emptyTriggerLabel = canBindOrUnbindTags - ? t($ => $['tag.addTag'], { ns: 'common' }) - : t($ => $['tag.noTag'], { ns: 'common' }) + ? t(($) => $['tag.addTag'], { ns: 'common' }) + : t(($) => $['tag.noTag'], { ns: 'common' }) const triggerLabel = tagNames.length ? tagNames.join(', ') : emptyTriggerLabel const items = useMemo<TagComboboxItem[]>(() => { @@ -109,19 +122,17 @@ export const TagSelector = ({ const nextItems: TagComboboxItem[] = [] for (const tag of tagList) { - if (tag.type !== type) - continue + if (tag.type !== type) continue tagIds.add(tag.id) nextItems.push(tag) } for (const tag of value) { - if (tag.type === type && !tagIds.has(tag.id)) - nextItems.push(tag) + if (tag.type === type && !tagIds.has(tag.id)) nextItems.push(tag) } - if (canManageTags && inputValue && nextItems.every(tag => tag.name !== inputValue)) { + if (canManageTags && inputValue && nextItems.every((tag) => tag.name !== inputValue)) { nextItems.push({ id: `__create_tag__:${inputValue}`, name: inputValue, @@ -135,78 +146,97 @@ export const TagSelector = ({ }, [canManageTags, inputValue, tagList, type, value]) const applyTagBindings = useCallback(() => { - const draftTagIds = draftTags.map(tag => tag.id) + const draftTagIds = draftTags.map((tag) => tag.id) const draftTagIdSet = new Set(draftTagIds) - const tagSelectionChanged = selectedTagIds.length !== draftTagIds.length - || selectedTagIds.some(tagId => !draftTagIdSet.has(tagId)) + const tagSelectionChanged = + selectedTagIds.length !== draftTagIds.length || + selectedTagIds.some((tagId) => !draftTagIdSet.has(tagId)) - if (!tagSelectionChanged) - return + if (!tagSelectionChanged) return const toastId = `tag-bindings-${type}-${targetId}` - applyTagBindingsMutation.mutate({ - currentTagIds: selectedTagIds, - nextTagIds: draftTagIds, - targetId, - type, - }, { - onSuccess: () => { - toast.success(t($ => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }), { - id: toastId, - }) - }, - onError: () => { - toast.error(t($ => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }), { - id: toastId, - }) + applyTagBindingsMutation.mutate( + { + currentTagIds: selectedTagIds, + nextTagIds: draftTagIds, + targetId, + type, }, - onSettled: () => { - onTagsChange?.() + { + onSuccess: () => { + toast.success( + t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }), + { + id: toastId, + }, + ) + }, + onError: () => { + toast.error( + t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }), + { + id: toastId, + }, + ) + }, + onSettled: () => { + onTagsChange?.() + }, }, - }) + ) }, [applyTagBindingsMutation, draftTags, onTagsChange, selectedTagIds, t, targetId, type]) - const handleOpenChange = useCallback((nextOpen: boolean) => { - if (nextOpen) { - setDraftTags(value) - } - else { - applyTagBindings() - } + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + setDraftTags(value) + } else { + applyTagBindings() + } - setOpen(nextOpen) - }, [applyTagBindings, value]) + setOpen(nextOpen) + }, + [applyTagBindings, value], + ) - const createNewTag = useCallback((name: string) => { - if (!canManageTags || !name || isCreatingTag) - return + const createNewTag = useCallback( + (name: string) => { + if (!canManageTags || !name || isCreatingTag) return - createTag({ - body: { - name, - type, - }, - }, { - onSuccess: () => { - toast.success(t($ => $['tag.created'], { ns: 'common' })) - setInputValue('') - }, - onError: () => { - toast.error(t($ => $['tag.failed'], { ns: 'common' })) - }, - }) - }, [canManageTags, createTag, isCreatingTag, t, type]) + createTag( + { + body: { + name, + type, + }, + }, + { + onSuccess: () => { + toast.success(t(($) => $['tag.created'], { ns: 'common' })) + setInputValue('') + }, + onError: () => { + toast.error(t(($) => $['tag.failed'], { ns: 'common' })) + }, + }, + ) + }, + [canManageTags, createTag, isCreatingTag, t, type], + ) - const handleValueChange = useCallback((nextTags: TagComboboxItem[]) => { - const createOption = nextTags.find(isCreateTagOption) - if (createOption) { - createNewTag(createOption.name) - return - } + const handleValueChange = useCallback( + (nextTags: TagComboboxItem[]) => { + const createOption = nextTags.find(isCreateTagOption) + if (createOption) { + createNewTag(createOption.name) + return + } - setDraftTags(nextTags.filter(tag => !isCreateTagOption(tag))) - }, [createNewTag]) + setDraftTags(nextTags.filter((tag) => !isCreateTagOption(tag))) + }, + [createNewTag], + ) return ( <Combobox @@ -231,10 +261,7 @@ export const TagSelector = ({ )} icon={false} > - <TagTrigger - tags={tagNames} - canBindOrUnbindTags={canBindOrUnbindTags} - /> + <TagTrigger tags={tagNames} canBindOrUnbindTags={canBindOrUnbindTags} /> </ComboboxTrigger> <ComboboxContent placement={placement} @@ -243,7 +270,10 @@ export const TagSelector = ({ portalProps={portalProps} positionerProps={positionerProps} popupProps={popupProps} - popupClassName={cn('w-(--anchor-width) min-w-60 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]', popupClassName)} + popupClassName={cn( + 'w-(--anchor-width) min-w-60 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]', + popupClassName, + )} > <TagSearchContent type={type} diff --git a/web/features/tag-management/components/tag-trigger.tsx b/web/features/tag-management/components/tag-trigger.tsx index c003f87a090a51..45005855d5c0bb 100644 --- a/web/features/tag-management/components/tag-trigger.tsx +++ b/web/features/tag-management/components/tag-trigger.tsx @@ -6,14 +6,11 @@ type TriggerProps = { canBindOrUnbindTags?: boolean } -export const TagTrigger = ({ - tags, - canBindOrUnbindTags = false, -}: TriggerProps) => { +export const TagTrigger = ({ tags, canBindOrUnbindTags = false }: TriggerProps) => { const { t } = useTranslation() const emptyTagLabel = canBindOrUnbindTags - ? t($ => $['tag.addTag'], { ns: 'common' }) - : t($ => $['tag.noTag'], { ns: 'common' }) + ? t(($) => $['tag.addTag'], { ns: 'common' }) + : t(($) => $['tag.noTag'], { ns: 'common' }) return ( <div @@ -23,35 +20,37 @@ export const TagTrigger = ({ )} role={tags.length ? 'list' : undefined} > - {!tags.length - ? ( - <div className="flex max-w-full min-w-0 items-center gap-x-0.5 rounded-[5px] border border-dashed border-divider-deep bg-components-badge-bg-dimm px-1.25 py-0.75"> - <span aria-hidden="true" className="i-ri-price-tag-3-line size-3 shrink-0 text-text-quaternary" /> - <div className="truncate system-2xs-medium-uppercase text-text-tertiary"> - {emptyTagLabel} + {!tags.length ? ( + <div className="flex max-w-full min-w-0 items-center gap-x-0.5 rounded-[5px] border border-dashed border-divider-deep bg-components-badge-bg-dimm px-1.25 py-0.75"> + <span + aria-hidden="true" + className="i-ri-price-tag-3-line size-3 shrink-0 text-text-quaternary" + /> + <div className="truncate system-2xs-medium-uppercase text-text-tertiary"> + {emptyTagLabel} + </div> + </div> + ) : ( + <> + {tags.map((content) => { + return ( + <div + key={content} + role="listitem" + className="flex max-w-30 min-w-0 shrink-0 items-center gap-x-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.25 py-0.75" + > + <span + aria-hidden="true" + className="i-ri-price-tag-3-line size-3 shrink-0 text-text-quaternary" + /> + <div className="truncate system-2xs-medium-uppercase text-text-tertiary"> + {content} + </div> </div> - </div> - ) - : ( - <> - { - tags.map((content) => { - return ( - <div - key={content} - role="listitem" - className="flex max-w-30 min-w-0 shrink-0 items-center gap-x-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.25 py-0.75" - > - <span aria-hidden="true" className="i-ri-price-tag-3-line size-3 shrink-0 text-text-quaternary" /> - <div className="truncate system-2xs-medium-uppercase text-text-tertiary"> - {content} - </div> - </div> - ) - }) - } - </> - )} + ) + })} + </> + )} </div> ) } diff --git a/web/features/tag-management/hooks/__tests__/use-tag-mutations.spec.tsx b/web/features/tag-management/hooks/__tests__/use-tag-mutations.spec.tsx index 32054cfc700f87..c382fe4ca43574 100644 --- a/web/features/tag-management/hooks/__tests__/use-tag-mutations.spec.tsx +++ b/web/features/tag-management/hooks/__tests__/use-tag-mutations.spec.tsx @@ -4,13 +4,15 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { useApplyTagBindingsMutation } from '../use-tag-mutations' -const { - bindTag, - listKey, - unbindTag, -} = vi.hoisted(() => ({ +const { bindTag, listKey, unbindTag } = vi.hoisted(() => ({ bindTag: vi.fn(), - listKey: vi.fn((options: { type: 'query', input: { query: { type: string } } }) => ['console', 'tags', 'get', 'query', options.input.query.type]), + listKey: vi.fn((options: { type: 'query'; input: { query: { type: string } } }) => [ + 'console', + 'tags', + 'get', + 'query', + options.input.query.type, + ]), unbindTag: vi.fn(), })) @@ -32,23 +34,22 @@ vi.mock('@/service/client', () => ({ }, })) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - mutations: { - retry: false, +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, }, - }, -}) + }) const renderMutationHook = <TResult,>(hook: () => TResult) => { const queryClient = createQueryClient() const wrapper = ({ children }: { children: ReactNode }) => ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) return { diff --git a/web/features/tag-management/hooks/use-tag-mutations.ts b/web/features/tag-management/hooks/use-tag-mutations.ts index 22c7db123db0af..daa2d53825facd 100644 --- a/web/features/tag-management/hooks/use-tag-mutations.ts +++ b/web/features/tag-management/hooks/use-tag-mutations.ts @@ -15,28 +15,32 @@ export const useApplyTagBindingsMutation = () => { return useMutation({ mutationKey: ['tag-bindings', 'apply'], mutationFn: async ({ currentTagIds, nextTagIds, targetId, type }: ApplyTagBindingsInput) => { - const addTagIds = nextTagIds.filter(tagId => !currentTagIds.includes(tagId)) - const removeTagIds = currentTagIds.filter(tagId => !nextTagIds.includes(tagId)) + const addTagIds = nextTagIds.filter((tagId) => !currentTagIds.includes(tagId)) + const removeTagIds = currentTagIds.filter((tagId) => !nextTagIds.includes(tagId)) const operations: Promise<unknown>[] = [] if (addTagIds.length) { - operations.push(consoleClient.tagBindings.post({ - body: { - tag_ids: addTagIds, - target_id: targetId, - type, - }, - })) + operations.push( + consoleClient.tagBindings.post({ + body: { + tag_ids: addTagIds, + target_id: targetId, + type, + }, + }), + ) } if (removeTagIds.length) { - operations.push(consoleClient.tagBindings.remove.post({ - body: { - tag_ids: removeTagIds, - target_id: targetId, - type, - }, - })) + operations.push( + consoleClient.tagBindings.remove.post({ + body: { + tag_ids: removeTagIds, + target_id: targetId, + type, + }, + }), + ) } return Promise.all(operations) diff --git a/web/features/tag-management/utils.ts b/web/features/tag-management/utils.ts index f0d12d534a401c..145d1cfcb5f7b0 100644 --- a/web/features/tag-management/utils.ts +++ b/web/features/tag-management/utils.ts @@ -3,11 +3,9 @@ import type { PermissionKey } from '@/models/access-control' import { SnippetPermission } from '@/app/components/snippets/utils/permission' export const getTagManagePermissionKey = (type: TagType): PermissionKey => { - if (type === 'app') - return 'app.tag.manage' + if (type === 'app') return 'app.tag.manage' - if (type === 'snippet') - return SnippetPermission.CreateAndModify + if (type === 'snippet') return SnippetPermission.CreateAndModify return 'dataset.tag.manage' } diff --git a/web/global.d.ts b/web/global.d.ts index 3ebe55c3bf81bc..f8b3d2656b6b80 100644 --- a/web/global.d.ts +++ b/web/global.d.ts @@ -3,10 +3,10 @@ import './types/jsx' import './types/mdx' import './types/assets' -declare module 'lamejs'; -declare module 'lamejs/src/js/MPEGMode'; -declare module 'lamejs/src/js/Lame'; -declare module 'lamejs/src/js/BitStream'; +declare module 'lamejs' +declare module 'lamejs/src/js/MPEGMode' +declare module 'lamejs/src/js/Lame' +declare module 'lamejs/src/js/BitStream' declare global { // Google Analytics gtag types diff --git a/web/hooks/use-app-favicon.ts b/web/hooks/use-app-favicon.ts index 6d1ba9979cef1b..c6c493bef1f122 100644 --- a/web/hooks/use-app-favicon.ts +++ b/web/hooks/use-app-favicon.ts @@ -12,30 +12,24 @@ type UseAppFaviconOptions = { } export function useAppFavicon(options: UseAppFaviconOptions) { - const { - enable = true, - icon_type = 'emoji', - icon, - icon_background, - icon_url, - } = options + const { enable = true, icon_type = 'emoji', icon, icon_background, icon_url } = options useAsyncEffect(async () => { - if (!enable || (icon_type === 'image' && !icon_url) || (icon_type === 'emoji' && !icon)) - return + if (!enable || (icon_type === 'image' && !icon_url) || (icon_type === 'emoji' && !icon)) return const isValidImageIcon = icon_type === 'image' && icon_url - const link: HTMLLinkElement = document.querySelector('link[rel*="icon"]') || document.createElement('link') + const link: HTMLLinkElement = + document.querySelector('link[rel*="icon"]') || document.createElement('link') link.href = isValidImageIcon ? icon_url - : 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22>' - + `<rect width=%22100%25%22 height=%22100%25%22 fill=%22${encodeURIComponent(icon_background || appDefaultIconBackground)}%22 rx=%2230%22 ry=%2230%22 />` - + `<text x=%2212.5%22 y=%221em%22 font-size=%2275%22>${ + : 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22>' + + `<rect width=%22100%25%22 height=%22100%25%22 fill=%22${encodeURIComponent(icon_background || appDefaultIconBackground)}%22 rx=%2230%22 ry=%2230%22 />` + + `<text x=%2212.5%22 y=%221em%22 font-size=%2275%22>${ icon ? await searchEmoji(icon) : '🤖' - }</text>` - + '</svg>' + }</text>` + + '</svg>' link.rel = 'shortcut icon' link.type = 'image/svg' diff --git a/web/hooks/use-async-window-open.spec.ts b/web/hooks/use-async-window-open.spec.ts index f51d0c6bef194e..d25eff71716d4a 100644 --- a/web/hooks/use-async-window-open.spec.ts +++ b/web/hooks/use-async-window-open.spec.ts @@ -47,7 +47,11 @@ describe('useAsyncWindowOpen', () => { }) }) - expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank', 'width=500,noopener,noreferrer') + expect(openSpy).toHaveBeenCalledWith( + 'https://example.com', + '_blank', + 'width=500,noopener,noreferrer', + ) expect(getUrl).not.toHaveBeenCalled() expect(mockWindow.opener).toBeNull() }) @@ -108,9 +112,12 @@ describe('useAsyncWindowOpen', () => { const error = new Error('fetch failed') await act(async () => { - await result.current(async () => { - throw error - }, { onError }) + await result.current( + async () => { + throw error + }, + { onError }, + ) }) expect(close).toHaveBeenCalled() diff --git a/web/hooks/use-async-window-open.ts b/web/hooks/use-async-window-open.ts index b640fe430c4c37..a1c225d383583c 100644 --- a/web/hooks/use-async-window-open.ts +++ b/web/hooks/use-async-window-open.ts @@ -9,51 +9,50 @@ type AsyncWindowOpenOptions = { onError?: (error: Error) => void } -export const useAsyncWindowOpen = () => useCallback(async (getUrl: GetUrl, options?: AsyncWindowOpenOptions) => { - const { - immediateUrl, - target = '_blank', - features, - onError, - } = options ?? {} - - const secureImmediateFeatures = features ? `${features},noopener,noreferrer` : 'noopener,noreferrer' - - if (immediateUrl) { - const newWindow = window.open(immediateUrl, target, secureImmediateFeatures) +export const useAsyncWindowOpen = () => + useCallback(async (getUrl: GetUrl, options?: AsyncWindowOpenOptions) => { + const { immediateUrl, target = '_blank', features, onError } = options ?? {} + + const secureImmediateFeatures = features + ? `${features},noopener,noreferrer` + : 'noopener,noreferrer' + + if (immediateUrl) { + const newWindow = window.open(immediateUrl, target, secureImmediateFeatures) + if (!newWindow) { + onError?.(new Error('Failed to open new window')) + return + } + try { + newWindow.opener = null + } catch { + /* noop */ + } + return + } + + const newWindow = window.open('about:blank', target, features) if (!newWindow) { onError?.(new Error('Failed to open new window')) return } + try { newWindow.opener = null + } catch { + /* noop */ } - catch { /* noop */ } - return - } - - const newWindow = window.open('about:blank', target, features) - if (!newWindow) { - onError?.(new Error('Failed to open new window')) - return - } - - try { - newWindow.opener = null - } - catch { /* noop */ } - - try { - const url = await getUrl() - if (url) { - newWindow.location.href = url - return + + try { + const url = await getUrl() + if (url) { + newWindow.location.href = url + return + } + newWindow.close() + onError?.(new Error('No url resolved for new window')) + } catch (error) { + newWindow.close() + onError?.(error instanceof Error ? error : new Error(String(error))) } - newWindow.close() - onError?.(new Error('No url resolved for new window')) - } - catch (error) { - newWindow.close() - onError?.(error instanceof Error ? error : new Error(String(error))) - } -}, []) + }, []) diff --git a/web/hooks/use-breakpoints.ts b/web/hooks/use-breakpoints.ts index f31a335c5b2e70..c5a03cfe8b3545 100644 --- a/web/hooks/use-breakpoints.ts +++ b/web/hooks/use-breakpoints.ts @@ -12,10 +12,8 @@ type MediaTypeValue = (typeof MediaType)[keyof typeof MediaType] const useBreakpoints = (): MediaTypeValue => { const [width, setWidth] = React.useState(globalThis.innerWidth) const media = (() => { - if (width <= 640) - return MediaType.mobile - if (width <= 768) - return MediaType.tablet + if (width <= 640) return MediaType.mobile + if (width <= 768) return MediaType.tablet return MediaType.pc })() diff --git a/web/hooks/use-credential-permissions.spec.ts b/web/hooks/use-credential-permissions.spec.ts index 9b9fa6700a3ab3..ea6922b0e226e8 100644 --- a/web/hooks/use-credential-permissions.spec.ts +++ b/web/hooks/use-credential-permissions.spec.ts @@ -35,7 +35,8 @@ vi.mock('@/context/system-features-state', async (importOriginal) => { }) vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + const { createAppContextStateJotaiMock } = + await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateJotaiMock(importOriginal) }) diff --git a/web/hooks/use-document-title.ts b/web/hooks/use-document-title.ts index c98b04341d6bb6..f4c0f8c88b0ab8 100644 --- a/web/hooks/use-document-title.ts +++ b/web/hooks/use-document-title.ts @@ -16,8 +16,7 @@ export default function useDocumentTitle(title: string) { if (systemFeatures.branding.enabled) { titleStr = `${prefix}${systemFeatures.branding.application_title}` favicon = systemFeatures.branding.favicon - } - else { + } else { titleStr = `${prefix}Dify` favicon = `${basePath}/favicon.ico` } @@ -28,9 +27,9 @@ export default function useDocumentTitle(title: string) { if (systemFeatures.branding.favicon) { document .querySelectorAll( - 'link[rel=\'icon\'], link[rel=\'shortcut icon\'], link[rel=\'apple-touch-icon\'], link[rel=\'mask-icon\']', + "link[rel='icon'], link[rel='shortcut icon'], link[rel='apple-touch-icon'], link[rel='mask-icon']", ) - .forEach(n => n.parentNode?.removeChild(n)) + .forEach((n) => n.parentNode?.removeChild(n)) apple = document.createElement('link') apple.rel = 'apple-touch-icon' diff --git a/web/hooks/use-format-time-from-now.spec.ts b/web/hooks/use-format-time-from-now.spec.ts index fc5c5350290e42..a8c402269d7de3 100644 --- a/web/hooks/use-format-time-from-now.spec.ts +++ b/web/hooks/use-format-time-from-now.spec.ts @@ -15,7 +15,6 @@ import type { Mock } from 'vitest' import { renderHook } from '@testing-library/react' // Import after mock to get the mocked version import { useLocale } from '@/context/i18n' - import { useFormatTimeFromNow } from './use-format-time-from-now' vi.mock('@/context/i18n', () => ({ @@ -51,7 +50,7 @@ describe('useFormatTimeFromNow', () => { const { result } = renderHook(() => useFormatTimeFromNow()) const now = Date.now() - const oneHourAgo = now - (60 * 60 * 1000) + const oneHourAgo = now - 60 * 60 * 1000 const formatted = result.current.formatTimeFromNow(oneHourAgo) // Should contain "hour" or "hours" and "ago" @@ -69,7 +68,7 @@ describe('useFormatTimeFromNow', () => { const { result } = renderHook(() => useFormatTimeFromNow()) const now = Date.now() - const fiveSecondsAgo = now - (5 * 1000) + const fiveSecondsAgo = now - 5 * 1000 const formatted = result.current.formatTimeFromNow(fiveSecondsAgo) expect(formatted).toMatch(/second|seconds|few seconds/) @@ -85,7 +84,7 @@ describe('useFormatTimeFromNow', () => { const { result } = renderHook(() => useFormatTimeFromNow()) const now = Date.now() - const threeDaysAgo = now - (3 * 24 * 60 * 60 * 1000) + const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000 const formatted = result.current.formatTimeFromNow(threeDaysAgo) expect(formatted).toMatch(/day|days/) @@ -102,7 +101,7 @@ describe('useFormatTimeFromNow', () => { const { result } = renderHook(() => useFormatTimeFromNow()) const now = Date.now() - const twoHoursFromNow = now + (2 * 60 * 60 * 1000) + const twoHoursFromNow = now + 2 * 60 * 60 * 1000 const formatted = result.current.formatTimeFromNow(twoHoursFromNow) expect(formatted).toMatch(/in/) @@ -121,7 +120,7 @@ describe('useFormatTimeFromNow', () => { const { result } = renderHook(() => useFormatTimeFromNow()) const now = Date.now() - const oneHourAgo = now - (60 * 60 * 1000) + const oneHourAgo = now - 60 * 60 * 1000 const formatted = result.current.formatTimeFromNow(oneHourAgo) // Chinese should contain Chinese characters @@ -138,7 +137,7 @@ describe('useFormatTimeFromNow', () => { const { result } = renderHook(() => useFormatTimeFromNow()) const now = Date.now() - const oneHourAgo = now - (60 * 60 * 1000) + const oneHourAgo = now - 60 * 60 * 1000 const formatted = result.current.formatTimeFromNow(oneHourAgo) // Spanish should contain "hace" (ago) @@ -155,7 +154,7 @@ describe('useFormatTimeFromNow', () => { const { result } = renderHook(() => useFormatTimeFromNow()) const now = Date.now() - const oneHourAgo = now - (60 * 60 * 1000) + const oneHourAgo = now - 60 * 60 * 1000 const formatted = result.current.formatTimeFromNow(oneHourAgo) // French should contain "il y a" (ago) @@ -172,7 +171,7 @@ describe('useFormatTimeFromNow', () => { const { result } = renderHook(() => useFormatTimeFromNow()) const now = Date.now() - const oneHourAgo = now - (60 * 60 * 1000) + const oneHourAgo = now - 60 * 60 * 1000 const formatted = result.current.formatTimeFromNow(oneHourAgo) // Japanese should contain Japanese characters @@ -189,7 +188,7 @@ describe('useFormatTimeFromNow', () => { const { result } = renderHook(() => useFormatTimeFromNow()) const now = Date.now() - const oneHourAgo = now - (60 * 60 * 1000) + const oneHourAgo = now - 60 * 60 * 1000 const formatted = result.current.formatTimeFromNow(oneHourAgo) // Portuguese should contain "há" (ago) @@ -206,7 +205,7 @@ describe('useFormatTimeFromNow', () => { const { result } = renderHook(() => useFormatTimeFromNow()) const now = Date.now() - const oneHourAgo = now - (60 * 60 * 1000) + const oneHourAgo = now - 60 * 60 * 1000 const formatted = result.current.formatTimeFromNow(oneHourAgo) // Should still return a valid string (in English) @@ -241,7 +240,7 @@ describe('useFormatTimeFromNow', () => { const { result } = renderHook(() => useFormatTimeFromNow()) - const farFuture = Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year from now + const farFuture = Date.now() + 365 * 24 * 60 * 60 * 1000 // 1 year from now const formatted = result.current.formatTimeFromNow(farFuture) expect(typeof formatted).toBe('string') @@ -256,7 +255,7 @@ describe('useFormatTimeFromNow', () => { const { result, rerender } = renderHook(() => useFormatTimeFromNow()) const now = Date.now() - const oneHourAgo = now - (60 * 60 * 1000) + const oneHourAgo = now - 60 * 60 * 1000 // First render with English ;(useLocale as Mock).mockReturnValue('en-US') @@ -338,7 +337,7 @@ describe('useFormatTimeFromNow', () => { ] const now = Date.now() - const oneHourAgo = now - (60 * 60 * 1000) + const oneHourAgo = now - 60 * 60 * 1000 locales.forEach((locale) => { ;(useLocale as Mock).mockReturnValue(locale) diff --git a/web/hooks/use-format-time-from-now.ts b/web/hooks/use-format-time-from-now.ts index de63c2a2022486..8d730cf362924c 100644 --- a/web/hooks/use-format-time-from-now.ts +++ b/web/hooks/use-format-time-from-now.ts @@ -29,10 +29,13 @@ dayjs.extend(relativeTime) export const useFormatTimeFromNow = () => { const locale = useLocale() - const formatTimeFromNow = useCallback((time: number) => { - const dayjsLocale = localeMap[locale] ?? 'en' - return dayjs(time).locale(dayjsLocale).fromNow() - }, [locale]) + const formatTimeFromNow = useCallback( + (time: number) => { + const dayjsLocale = localeMap[locale] ?? 'en' + return dayjs(time).locale(dayjsLocale).fromNow() + }, + [locale], + ) return { formatTimeFromNow } } diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts index 8e25096616d7d8..64a41a5aa6c257 100644 --- a/web/hooks/use-import-dsl.ts +++ b/web/hooks/use-import-dsl.ts @@ -1,16 +1,9 @@ -import type { - DSLImportMode, - DSLImportResponse, -} from '@/models/app' +import type { DSLImportMode, DSLImportResponse } from '@/models/app' import type { AppIconType } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' -import { - useCallback, - useRef, - useState, -} from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSetNeedRefreshAppList } from '@/app/components/apps/storage' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' @@ -19,10 +12,7 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { DSLImportStatus } from '@/models/app' import { useRouter } from '@/next/navigation' -import { - importDSL, - importDSLConfirm, -} from '@/service/apps' +import { importDSL, importDSLConfirm } from '@/service/apps' import { useInvalidateAppList } from '@/service/use-apps' import { getRedirection } from '@/utils/app-redirection' @@ -51,131 +41,137 @@ export const useImportDSL = () => { const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled - const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>() + const [versions, setVersions] = useState<{ importedVersion: string; systemVersion: string }>() const importIdRef = useRef<string>('') const setNeedRefresh = useSetNeedRefreshAppList() - const handleImportDSL = useCallback(async ( - payload: DSLPayload, - { - onSuccess, - onPending, - onFailed, - }: ResponseCallback, - ) => { - if (isFetching) - return - setIsFetching(true) + const handleImportDSL = useCallback( + async (payload: DSLPayload, { onSuccess, onPending, onFailed }: ResponseCallback) => { + if (isFetching) return + setIsFetching(true) - try { - const response = await importDSL(payload) + try { + const response = await importDSL(payload) - if (!response) - return + if (!response) return - const { - id, - status, - app_id, - app_mode, - imported_dsl_version, - current_dsl_version, - permission_keys, - } = response + const { + id, + status, + app_id, + app_mode, + imported_dsl_version, + current_dsl_version, + permission_keys, + } = response - if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { - if (!app_id) - return + if ( + status === DSLImportStatus.COMPLETED || + status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ) { + if (!app_id) return - const message = t($ => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'], { ns: 'app' }) - const description = status === DSLImportStatus.COMPLETED_WITH_WARNINGS - ? t($ => $['newApp.appCreateDSLWarning'], { ns: 'app' }) - : undefined + const message = t( + ($) => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'], + { ns: 'app' }, + ) + const description = + status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }) + : undefined - if (status === DSLImportStatus.COMPLETED) - toast.success(message) - else - toast.warning(message, { description }) - onSuccess?.(response) - setNeedRefresh('1') - invalidateAppList() - await handleCheckPluginDependencies(app_id) - getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { - currentUserId, - resourceMaintainer: currentUserId, - workspacePermissionKeys, - isRbacEnabled, - }) - } - else if (status === DSLImportStatus.PENDING) { - setVersions({ - importedVersion: imported_dsl_version ?? '', - systemVersion: current_dsl_version ?? '', - }) - importIdRef.current = id - onPending?.(response) - } - else { - toast.error(t($ => $['newApp.appCreateFailed'], { ns: 'app' })) + if (status === DSLImportStatus.COMPLETED) toast.success(message) + else toast.warning(message, { description }) + onSuccess?.(response) + setNeedRefresh('1') + invalidateAppList() + await handleCheckPluginDependencies(app_id) + getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { + currentUserId, + resourceMaintainer: currentUserId, + workspacePermissionKeys, + isRbacEnabled, + }) + } else if (status === DSLImportStatus.PENDING) { + setVersions({ + importedVersion: imported_dsl_version ?? '', + systemVersion: current_dsl_version ?? '', + }) + importIdRef.current = id + onPending?.(response) + } else { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) + onFailed?.() + } + } catch { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) onFailed?.() + } finally { + setIsFetching(false) } - } - catch { - toast.error(t($ => $['newApp.appCreateFailed'], { ns: 'app' })) - onFailed?.() - } - finally { - setIsFetching(false) - } - }, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, push, setNeedRefresh, invalidateAppList, currentUserId, workspacePermissionKeys]) + }, + [ + isFetching, + t, + handleCheckPluginDependencies, + isRbacEnabled, + push, + setNeedRefresh, + invalidateAppList, + currentUserId, + workspacePermissionKeys, + ], + ) - const handleImportDSLConfirm = useCallback(async ( - { - onSuccess, - onFailed, - }: Pick<ResponseCallback, 'onSuccess' | 'onFailed'>, - ) => { - if (isFetching) - return - setIsFetching(true) - if (!importIdRef.current) - return + const handleImportDSLConfirm = useCallback( + async ({ onSuccess, onFailed }: Pick<ResponseCallback, 'onSuccess' | 'onFailed'>) => { + if (isFetching) return + setIsFetching(true) + if (!importIdRef.current) return - try { - const response = await importDSLConfirm({ - import_id: importIdRef.current, - }) + try { + const response = await importDSLConfirm({ + import_id: importIdRef.current, + }) - const { status, app_id, app_mode, permission_keys } = response - if (!app_id) - return + const { status, app_id, app_mode, permission_keys } = response + if (!app_id) return - if (status === DSLImportStatus.COMPLETED) { - onSuccess?.(response) - toast.success(t($ => $['newApp.appCreated'], { ns: 'app' })) - await handleCheckPluginDependencies(app_id) - setNeedRefresh('1') - invalidateAppList() - getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { - currentUserId, - resourceMaintainer: currentUserId, - workspacePermissionKeys, - isRbacEnabled, - }) - } - else if (status === DSLImportStatus.FAILED) { - toast.error(t($ => $['newApp.appCreateFailed'], { ns: 'app' })) + if (status === DSLImportStatus.COMPLETED) { + onSuccess?.(response) + toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) + await handleCheckPluginDependencies(app_id) + setNeedRefresh('1') + invalidateAppList() + getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { + currentUserId, + resourceMaintainer: currentUserId, + workspacePermissionKeys, + isRbacEnabled, + }) + } else if (status === DSLImportStatus.FAILED) { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) + onFailed?.() + } + } catch { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) onFailed?.() + } finally { + setIsFetching(false) } - } - catch { - toast.error(t($ => $['newApp.appCreateFailed'], { ns: 'app' })) - onFailed?.() - } - finally { - setIsFetching(false) - } - }, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, setNeedRefresh, push, invalidateAppList, currentUserId, workspacePermissionKeys]) + }, + [ + isFetching, + t, + handleCheckPluginDependencies, + isRbacEnabled, + setNeedRefresh, + push, + invalidateAppList, + currentUserId, + workspacePermissionKeys, + ], + ) return { handleImportDSL, diff --git a/web/hooks/use-knowledge.ts b/web/hooks/use-knowledge.ts index eec6c20378dac5..43d3edacb051a1 100644 --- a/web/hooks/use-knowledge.ts +++ b/web/hooks/use-knowledge.ts @@ -8,25 +8,33 @@ type IndexingMethod = I18nKeysByPrefix<'dataset', 'indexingMethod.'> export const useKnowledge = () => { const { t } = useTranslation() - const formatIndexingTechnique = useCallback((indexingTechnique: IndexingTechnique) => { - return t($ => $[`indexingTechnique.${indexingTechnique}`], { ns: 'dataset' }) as string - }, [t]) - - const formatIndexingMethod = useCallback((indexingMethod: IndexingMethod, isEco?: boolean) => { - if (isEco) - return t($ => $['indexingMethod.invertedIndex'], { ns: 'dataset' }) - - return t($ => $[`indexingMethod.${indexingMethod}`], { ns: 'dataset' }) as string - }, [t]) - - const formatIndexingTechniqueAndMethod = useCallback((indexingTechnique: IndexingTechnique, indexingMethod: IndexingMethod) => { - let result = formatIndexingTechnique(indexingTechnique) - - if (indexingMethod) - result += ` · ${formatIndexingMethod(indexingMethod, indexingTechnique === 'economy')}` - - return result - }, [formatIndexingTechnique, formatIndexingMethod]) + const formatIndexingTechnique = useCallback( + (indexingTechnique: IndexingTechnique) => { + return t(($) => $[`indexingTechnique.${indexingTechnique}`], { ns: 'dataset' }) as string + }, + [t], + ) + + const formatIndexingMethod = useCallback( + (indexingMethod: IndexingMethod, isEco?: boolean) => { + if (isEco) return t(($) => $['indexingMethod.invertedIndex'], { ns: 'dataset' }) + + return t(($) => $[`indexingMethod.${indexingMethod}`], { ns: 'dataset' }) as string + }, + [t], + ) + + const formatIndexingTechniqueAndMethod = useCallback( + (indexingTechnique: IndexingTechnique, indexingMethod: IndexingMethod) => { + let result = formatIndexingTechnique(indexingTechnique) + + if (indexingMethod) + result += ` · ${formatIndexingMethod(indexingMethod, indexingTechnique === 'economy')}` + + return result + }, + [formatIndexingTechnique, formatIndexingMethod], + ) return { formatIndexingTechnique, diff --git a/web/hooks/use-metadata.ts b/web/hooks/use-metadata.ts index 7d76bc07764040..e4ce0649635200 100644 --- a/web/hooks/use-metadata.ts +++ b/web/hooks/use-metadata.ts @@ -9,25 +9,24 @@ import { formatFileSize, formatNumber, formatTime } from '@/utils/format' export type inputType = 'input' | 'select' | 'textarea' type MetadataType = DocType | 'originInfo' | 'technicalParameters' -type MetadataMap - = Record< - MetadataType, - { - text: string - allowEdit?: boolean - icon?: React.ReactNode - iconName?: string - subFieldsMap: Record< - string, - { - label: string - inputType?: inputType - field?: string - render?: (value: any, total?: number) => React.ReactNode | string - } - > - } - > +type MetadataMap = Record< + MetadataType, + { + text: string + allowEdit?: boolean + icon?: React.ReactNode + iconName?: string + subFieldsMap: Record< + string, + { + label: string + inputType?: inputType + field?: string + render?: (value: any, total?: number) => React.ReactNode | string + } + > + } +> const fieldPrefix = 'metadata.field' @@ -37,189 +36,302 @@ export const useMetadataMap = (): MetadataMap => { return { book: { - text: t($ => $['metadata.type.book'], { ns: 'datasetDocuments' }), + text: t(($) => $['metadata.type.book'], { ns: 'datasetDocuments' }), iconName: 'bookOpen', subFieldsMap: { - title: { label: t($ => $[`${fieldPrefix}.book.title`], { ns: 'datasetDocuments' }) }, + title: { label: t(($) => $[`${fieldPrefix}.book.title`], { ns: 'datasetDocuments' }) }, language: { - label: t($ => $[`${fieldPrefix}.book.language`], { ns: 'datasetDocuments' }), + label: t(($) => $[`${fieldPrefix}.book.language`], { ns: 'datasetDocuments' }), inputType: 'select', }, - author: { label: t($ => $[`${fieldPrefix}.book.author`], { ns: 'datasetDocuments' }) }, - publisher: { label: t($ => $[`${fieldPrefix}.book.publisher`], { ns: 'datasetDocuments' }) }, - publication_date: { label: t($ => $[`${fieldPrefix}.book.publicationDate`], { ns: 'datasetDocuments' }) }, - isbn: { label: t($ => $[`${fieldPrefix}.book.ISBN`], { ns: 'datasetDocuments' }) }, + author: { label: t(($) => $[`${fieldPrefix}.book.author`], { ns: 'datasetDocuments' }) }, + publisher: { + label: t(($) => $[`${fieldPrefix}.book.publisher`], { ns: 'datasetDocuments' }), + }, + publication_date: { + label: t(($) => $[`${fieldPrefix}.book.publicationDate`], { ns: 'datasetDocuments' }), + }, + isbn: { label: t(($) => $[`${fieldPrefix}.book.ISBN`], { ns: 'datasetDocuments' }) }, category: { - label: t($ => $[`${fieldPrefix}.book.category`], { ns: 'datasetDocuments' }), + label: t(($) => $[`${fieldPrefix}.book.category`], { ns: 'datasetDocuments' }), inputType: 'select', }, }, }, web_page: { - text: t($ => $['metadata.type.webPage'], { ns: 'datasetDocuments' }), + text: t(($) => $['metadata.type.webPage'], { ns: 'datasetDocuments' }), iconName: 'globe', subFieldsMap: { - 'title': { label: t($ => $[`${fieldPrefix}.webPage.title`], { ns: 'datasetDocuments' }) }, - 'url': { label: t($ => $[`${fieldPrefix}.webPage.url`], { ns: 'datasetDocuments' }) }, - 'language': { - label: t($ => $[`${fieldPrefix}.webPage.language`], { ns: 'datasetDocuments' }), + title: { label: t(($) => $[`${fieldPrefix}.webPage.title`], { ns: 'datasetDocuments' }) }, + url: { label: t(($) => $[`${fieldPrefix}.webPage.url`], { ns: 'datasetDocuments' }) }, + language: { + label: t(($) => $[`${fieldPrefix}.webPage.language`], { ns: 'datasetDocuments' }), inputType: 'select', }, - 'author/publisher': { label: t($ => $[`${fieldPrefix}.webPage.authorPublisher`], { ns: 'datasetDocuments' }) }, - 'publish_date': { label: t($ => $[`${fieldPrefix}.webPage.publishDate`], { ns: 'datasetDocuments' }) }, - 'topic/keywords': { label: t($ => $[`${fieldPrefix}.webPage.topicKeywords`], { ns: 'datasetDocuments' }) }, - 'description': { label: t($ => $[`${fieldPrefix}.webPage.description`], { ns: 'datasetDocuments' }) }, + 'author/publisher': { + label: t(($) => $[`${fieldPrefix}.webPage.authorPublisher`], { ns: 'datasetDocuments' }), + }, + publish_date: { + label: t(($) => $[`${fieldPrefix}.webPage.publishDate`], { ns: 'datasetDocuments' }), + }, + 'topic/keywords': { + label: t(($) => $[`${fieldPrefix}.webPage.topicKeywords`], { ns: 'datasetDocuments' }), + }, + description: { + label: t(($) => $[`${fieldPrefix}.webPage.description`], { ns: 'datasetDocuments' }), + }, }, }, paper: { - text: t($ => $['metadata.type.paper'], { ns: 'datasetDocuments' }), + text: t(($) => $['metadata.type.paper'], { ns: 'datasetDocuments' }), iconName: 'graduationHat', subFieldsMap: { - 'title': { label: t($ => $[`${fieldPrefix}.paper.title`], { ns: 'datasetDocuments' }) }, - 'language': { - label: t($ => $[`${fieldPrefix}.paper.language`], { ns: 'datasetDocuments' }), + title: { label: t(($) => $[`${fieldPrefix}.paper.title`], { ns: 'datasetDocuments' }) }, + language: { + label: t(($) => $[`${fieldPrefix}.paper.language`], { ns: 'datasetDocuments' }), inputType: 'select', }, - 'author': { label: t($ => $[`${fieldPrefix}.paper.author`], { ns: 'datasetDocuments' }) }, - 'publish_date': { label: t($ => $[`${fieldPrefix}.paper.publishDate`], { ns: 'datasetDocuments' }) }, + author: { label: t(($) => $[`${fieldPrefix}.paper.author`], { ns: 'datasetDocuments' }) }, + publish_date: { + label: t(($) => $[`${fieldPrefix}.paper.publishDate`], { ns: 'datasetDocuments' }), + }, 'journal/conference_name': { - label: t($ => $[`${fieldPrefix}.paper.journalConferenceName`], { ns: 'datasetDocuments' }), + label: t(($) => $[`${fieldPrefix}.paper.journalConferenceName`], { + ns: 'datasetDocuments', + }), + }, + 'volume/issue/page_numbers': { + label: t(($) => $[`${fieldPrefix}.paper.volumeIssuePage`], { ns: 'datasetDocuments' }), }, - 'volume/issue/page_numbers': { label: t($ => $[`${fieldPrefix}.paper.volumeIssuePage`], { ns: 'datasetDocuments' }) }, - 'doi': { label: t($ => $[`${fieldPrefix}.paper.DOI`], { ns: 'datasetDocuments' }) }, - 'topic/keywords': { label: t($ => $[`${fieldPrefix}.paper.topicsKeywords`], { ns: 'datasetDocuments' }) }, - 'abstract': { - label: t($ => $[`${fieldPrefix}.paper.abstract`], { ns: 'datasetDocuments' }), + doi: { label: t(($) => $[`${fieldPrefix}.paper.DOI`], { ns: 'datasetDocuments' }) }, + 'topic/keywords': { + label: t(($) => $[`${fieldPrefix}.paper.topicsKeywords`], { ns: 'datasetDocuments' }), + }, + abstract: { + label: t(($) => $[`${fieldPrefix}.paper.abstract`], { ns: 'datasetDocuments' }), inputType: 'textarea', }, }, }, social_media_post: { - text: t($ => $['metadata.type.socialMediaPost'], { ns: 'datasetDocuments' }), + text: t(($) => $['metadata.type.socialMediaPost'], { ns: 'datasetDocuments' }), iconName: 'atSign', subFieldsMap: { - 'platform': { label: t($ => $[`${fieldPrefix}.socialMediaPost.platform`], { ns: 'datasetDocuments' }) }, + platform: { + label: t(($) => $[`${fieldPrefix}.socialMediaPost.platform`], { ns: 'datasetDocuments' }), + }, 'author/username': { - label: t($ => $[`${fieldPrefix}.socialMediaPost.authorUsername`], { ns: 'datasetDocuments' }), + label: t(($) => $[`${fieldPrefix}.socialMediaPost.authorUsername`], { + ns: 'datasetDocuments', + }), + }, + publish_date: { + label: t(($) => $[`${fieldPrefix}.socialMediaPost.publishDate`], { + ns: 'datasetDocuments', + }), + }, + post_url: { + label: t(($) => $[`${fieldPrefix}.socialMediaPost.postURL`], { ns: 'datasetDocuments' }), + }, + 'topics/tags': { + label: t(($) => $[`${fieldPrefix}.socialMediaPost.topicsTags`], { + ns: 'datasetDocuments', + }), }, - 'publish_date': { label: t($ => $[`${fieldPrefix}.socialMediaPost.publishDate`], { ns: 'datasetDocuments' }) }, - 'post_url': { label: t($ => $[`${fieldPrefix}.socialMediaPost.postURL`], { ns: 'datasetDocuments' }) }, - 'topics/tags': { label: t($ => $[`${fieldPrefix}.socialMediaPost.topicsTags`], { ns: 'datasetDocuments' }) }, }, }, personal_document: { - text: t($ => $['metadata.type.personalDocument'], { ns: 'datasetDocuments' }), + text: t(($) => $['metadata.type.personalDocument'], { ns: 'datasetDocuments' }), iconName: 'file', subFieldsMap: { - 'title': { label: t($ => $[`${fieldPrefix}.personalDocument.title`], { ns: 'datasetDocuments' }) }, - 'author': { label: t($ => $[`${fieldPrefix}.personalDocument.author`], { ns: 'datasetDocuments' }) }, - 'creation_date': { - label: t($ => $[`${fieldPrefix}.personalDocument.creationDate`], { ns: 'datasetDocuments' }), - }, - 'last_modified_date': { - label: t($ => $[`${fieldPrefix}.personalDocument.lastModifiedDate`], { ns: 'datasetDocuments' }), - }, - 'document_type': { - label: t($ => $[`${fieldPrefix}.personalDocument.documentType`], { ns: 'datasetDocuments' }), + title: { + label: t(($) => $[`${fieldPrefix}.personalDocument.title`], { ns: 'datasetDocuments' }), + }, + author: { + label: t(($) => $[`${fieldPrefix}.personalDocument.author`], { ns: 'datasetDocuments' }), + }, + creation_date: { + label: t(($) => $[`${fieldPrefix}.personalDocument.creationDate`], { + ns: 'datasetDocuments', + }), + }, + last_modified_date: { + label: t(($) => $[`${fieldPrefix}.personalDocument.lastModifiedDate`], { + ns: 'datasetDocuments', + }), + }, + document_type: { + label: t(($) => $[`${fieldPrefix}.personalDocument.documentType`], { + ns: 'datasetDocuments', + }), inputType: 'select', }, 'tags/category': { - label: t($ => $[`${fieldPrefix}.personalDocument.tagsCategory`], { ns: 'datasetDocuments' }), + label: t(($) => $[`${fieldPrefix}.personalDocument.tagsCategory`], { + ns: 'datasetDocuments', + }), }, }, }, business_document: { - text: t($ => $['metadata.type.businessDocument'], { ns: 'datasetDocuments' }), + text: t(($) => $['metadata.type.businessDocument'], { ns: 'datasetDocuments' }), iconName: 'briefcase', subFieldsMap: { - 'title': { label: t($ => $[`${fieldPrefix}.businessDocument.title`], { ns: 'datasetDocuments' }) }, - 'author': { label: t($ => $[`${fieldPrefix}.businessDocument.author`], { ns: 'datasetDocuments' }) }, - 'creation_date': { - label: t($ => $[`${fieldPrefix}.businessDocument.creationDate`], { ns: 'datasetDocuments' }), - }, - 'last_modified_date': { - label: t($ => $[`${fieldPrefix}.businessDocument.lastModifiedDate`], { ns: 'datasetDocuments' }), - }, - 'document_type': { - label: t($ => $[`${fieldPrefix}.businessDocument.documentType`], { ns: 'datasetDocuments' }), + title: { + label: t(($) => $[`${fieldPrefix}.businessDocument.title`], { ns: 'datasetDocuments' }), + }, + author: { + label: t(($) => $[`${fieldPrefix}.businessDocument.author`], { ns: 'datasetDocuments' }), + }, + creation_date: { + label: t(($) => $[`${fieldPrefix}.businessDocument.creationDate`], { + ns: 'datasetDocuments', + }), + }, + last_modified_date: { + label: t(($) => $[`${fieldPrefix}.businessDocument.lastModifiedDate`], { + ns: 'datasetDocuments', + }), + }, + document_type: { + label: t(($) => $[`${fieldPrefix}.businessDocument.documentType`], { + ns: 'datasetDocuments', + }), inputType: 'select', }, 'department/team': { - label: t($ => $[`${fieldPrefix}.businessDocument.departmentTeam`], { ns: 'datasetDocuments' }), + label: t(($) => $[`${fieldPrefix}.businessDocument.departmentTeam`], { + ns: 'datasetDocuments', + }), }, }, }, im_chat_log: { - text: t($ => $['metadata.type.IMChat'], { ns: 'datasetDocuments' }), + text: t(($) => $['metadata.type.IMChat'], { ns: 'datasetDocuments' }), iconName: 'messageTextCircle', subFieldsMap: { - 'chat_platform': { label: t($ => $[`${fieldPrefix}.IMChat.chatPlatform`], { ns: 'datasetDocuments' }) }, + chat_platform: { + label: t(($) => $[`${fieldPrefix}.IMChat.chatPlatform`], { ns: 'datasetDocuments' }), + }, 'chat_participants/group_name': { - label: t($ => $[`${fieldPrefix}.IMChat.chatPartiesGroupName`], { ns: 'datasetDocuments' }), + label: t(($) => $[`${fieldPrefix}.IMChat.chatPartiesGroupName`], { + ns: 'datasetDocuments', + }), }, - 'start_date': { label: t($ => $[`${fieldPrefix}.IMChat.startDate`], { ns: 'datasetDocuments' }) }, - 'end_date': { label: t($ => $[`${fieldPrefix}.IMChat.endDate`], { ns: 'datasetDocuments' }) }, - 'participants': { label: t($ => $[`${fieldPrefix}.IMChat.participants`], { ns: 'datasetDocuments' }) }, - 'topicKeywords': { - label: t($ => $[`${fieldPrefix}.IMChat.topicsKeywords`], { ns: 'datasetDocuments' }), + start_date: { + label: t(($) => $[`${fieldPrefix}.IMChat.startDate`], { ns: 'datasetDocuments' }), + }, + end_date: { + label: t(($) => $[`${fieldPrefix}.IMChat.endDate`], { ns: 'datasetDocuments' }), + }, + participants: { + label: t(($) => $[`${fieldPrefix}.IMChat.participants`], { ns: 'datasetDocuments' }), + }, + topicKeywords: { + label: t(($) => $[`${fieldPrefix}.IMChat.topicsKeywords`], { ns: 'datasetDocuments' }), inputType: 'textarea', }, - 'fileType': { label: t($ => $[`${fieldPrefix}.IMChat.fileType`], { ns: 'datasetDocuments' }) }, + fileType: { + label: t(($) => $[`${fieldPrefix}.IMChat.fileType`], { ns: 'datasetDocuments' }), + }, }, }, wikipedia_entry: { - text: t($ => $['metadata.type.wikipediaEntry'], { ns: 'datasetDocuments' }), + text: t(($) => $['metadata.type.wikipediaEntry'], { ns: 'datasetDocuments' }), allowEdit: false, subFieldsMap: { - 'title': { label: t($ => $[`${fieldPrefix}.wikipediaEntry.title`], { ns: 'datasetDocuments' }) }, - 'language': { - label: t($ => $[`${fieldPrefix}.wikipediaEntry.language`], { ns: 'datasetDocuments' }), + title: { + label: t(($) => $[`${fieldPrefix}.wikipediaEntry.title`], { ns: 'datasetDocuments' }), + }, + language: { + label: t(($) => $[`${fieldPrefix}.wikipediaEntry.language`], { ns: 'datasetDocuments' }), inputType: 'select', }, - 'web_page_url': { label: t($ => $[`${fieldPrefix}.wikipediaEntry.webpageURL`], { ns: 'datasetDocuments' }) }, + web_page_url: { + label: t(($) => $[`${fieldPrefix}.wikipediaEntry.webpageURL`], { + ns: 'datasetDocuments', + }), + }, 'editor/contributor': { - label: t($ => $[`${fieldPrefix}.wikipediaEntry.editorContributor`], { ns: 'datasetDocuments' }), + label: t(($) => $[`${fieldPrefix}.wikipediaEntry.editorContributor`], { + ns: 'datasetDocuments', + }), }, - 'last_edit_date': { - label: t($ => $[`${fieldPrefix}.wikipediaEntry.lastEditDate`], { ns: 'datasetDocuments' }), + last_edit_date: { + label: t(($) => $[`${fieldPrefix}.wikipediaEntry.lastEditDate`], { + ns: 'datasetDocuments', + }), }, 'summary/introduction': { - label: t($ => $[`${fieldPrefix}.wikipediaEntry.summaryIntroduction`], { ns: 'datasetDocuments' }), + label: t(($) => $[`${fieldPrefix}.wikipediaEntry.summaryIntroduction`], { + ns: 'datasetDocuments', + }), inputType: 'textarea', }, }, }, synced_from_notion: { - text: t($ => $['metadata.type.notion'], { ns: 'datasetDocuments' }), + text: t(($) => $['metadata.type.notion'], { ns: 'datasetDocuments' }), allowEdit: false, subFieldsMap: { - 'title': { label: t($ => $[`${fieldPrefix}.notion.title`], { ns: 'datasetDocuments' }) }, - 'language': { label: t($ => $[`${fieldPrefix}.notion.language`], { ns: 'datasetDocuments' }), inputType: 'select' }, - 'author/creator': { label: t($ => $[`${fieldPrefix}.notion.author`], { ns: 'datasetDocuments' }) }, - 'creation_date': { label: t($ => $[`${fieldPrefix}.notion.createdTime`], { ns: 'datasetDocuments' }) }, - 'last_modified_date': { - label: t($ => $[`${fieldPrefix}.notion.lastModifiedTime`], { ns: 'datasetDocuments' }), - }, - 'notion_page_link': { label: t($ => $[`${fieldPrefix}.notion.url`], { ns: 'datasetDocuments' }) }, - 'category/tags': { label: t($ => $[`${fieldPrefix}.notion.tag`], { ns: 'datasetDocuments' }) }, - 'description': { label: t($ => $[`${fieldPrefix}.notion.description`], { ns: 'datasetDocuments' }) }, + title: { label: t(($) => $[`${fieldPrefix}.notion.title`], { ns: 'datasetDocuments' }) }, + language: { + label: t(($) => $[`${fieldPrefix}.notion.language`], { ns: 'datasetDocuments' }), + inputType: 'select', + }, + 'author/creator': { + label: t(($) => $[`${fieldPrefix}.notion.author`], { ns: 'datasetDocuments' }), + }, + creation_date: { + label: t(($) => $[`${fieldPrefix}.notion.createdTime`], { ns: 'datasetDocuments' }), + }, + last_modified_date: { + label: t(($) => $[`${fieldPrefix}.notion.lastModifiedTime`], { ns: 'datasetDocuments' }), + }, + notion_page_link: { + label: t(($) => $[`${fieldPrefix}.notion.url`], { ns: 'datasetDocuments' }), + }, + 'category/tags': { + label: t(($) => $[`${fieldPrefix}.notion.tag`], { ns: 'datasetDocuments' }), + }, + description: { + label: t(($) => $[`${fieldPrefix}.notion.description`], { ns: 'datasetDocuments' }), + }, }, }, synced_from_github: { - text: t($ => $['metadata.type.github'], { ns: 'datasetDocuments' }), + text: t(($) => $['metadata.type.github'], { ns: 'datasetDocuments' }), allowEdit: false, subFieldsMap: { - 'repository_name': { label: t($ => $[`${fieldPrefix}.github.repoName`], { ns: 'datasetDocuments' }) }, - 'repository_description': { label: t($ => $[`${fieldPrefix}.github.repoDesc`], { ns: 'datasetDocuments' }) }, - 'repository_owner/organization': { label: t($ => $[`${fieldPrefix}.github.repoOwner`], { ns: 'datasetDocuments' }) }, - 'code_filename': { label: t($ => $[`${fieldPrefix}.github.fileName`], { ns: 'datasetDocuments' }) }, - 'code_file_path': { label: t($ => $[`${fieldPrefix}.github.filePath`], { ns: 'datasetDocuments' }) }, - 'programming_language': { label: t($ => $[`${fieldPrefix}.github.programmingLang`], { ns: 'datasetDocuments' }) }, - 'github_link': { label: t($ => $[`${fieldPrefix}.github.url`], { ns: 'datasetDocuments' }) }, - 'open_source_license': { label: t($ => $[`${fieldPrefix}.github.license`], { ns: 'datasetDocuments' }) }, - 'commit_date': { label: t($ => $[`${fieldPrefix}.github.lastCommitTime`], { ns: 'datasetDocuments' }) }, - 'commit_author': { - label: t($ => $[`${fieldPrefix}.github.lastCommitAuthor`], { ns: 'datasetDocuments' }), + repository_name: { + label: t(($) => $[`${fieldPrefix}.github.repoName`], { ns: 'datasetDocuments' }), + }, + repository_description: { + label: t(($) => $[`${fieldPrefix}.github.repoDesc`], { ns: 'datasetDocuments' }), + }, + 'repository_owner/organization': { + label: t(($) => $[`${fieldPrefix}.github.repoOwner`], { ns: 'datasetDocuments' }), + }, + code_filename: { + label: t(($) => $[`${fieldPrefix}.github.fileName`], { ns: 'datasetDocuments' }), + }, + code_file_path: { + label: t(($) => $[`${fieldPrefix}.github.filePath`], { ns: 'datasetDocuments' }), + }, + programming_language: { + label: t(($) => $[`${fieldPrefix}.github.programmingLang`], { ns: 'datasetDocuments' }), + }, + github_link: { + label: t(($) => $[`${fieldPrefix}.github.url`], { ns: 'datasetDocuments' }), + }, + open_source_license: { + label: t(($) => $[`${fieldPrefix}.github.license`], { ns: 'datasetDocuments' }), + }, + commit_date: { + label: t(($) => $[`${fieldPrefix}.github.lastCommitTime`], { ns: 'datasetDocuments' }), + }, + commit_author: { + label: t(($) => $[`${fieldPrefix}.github.lastCommitAuthor`], { ns: 'datasetDocuments' }), }, }, }, @@ -227,67 +339,101 @@ export const useMetadataMap = (): MetadataMap => { text: '', allowEdit: false, subFieldsMap: { - 'name': { label: t($ => $[`${fieldPrefix}.originInfo.originalFilename`], { ns: 'datasetDocuments' }) }, - 'data_source_info.upload_file.size': { - label: t($ => $[`${fieldPrefix}.originInfo.originalFileSize`], { ns: 'datasetDocuments' }), - render: value => formatFileSize(value), + name: { + label: t(($) => $[`${fieldPrefix}.originInfo.originalFilename`], { + ns: 'datasetDocuments', + }), }, - 'created_at': { - label: t($ => $[`${fieldPrefix}.originInfo.uploadDate`], { ns: 'datasetDocuments' }), - render: value => formatTimestamp(value, t($ => $['metadata.dateTimeFormat'], { ns: 'datasetDocuments' }) as string), - }, - 'completed_at': { - label: t($ => $[`${fieldPrefix}.originInfo.lastUpdateDate`], { ns: 'datasetDocuments' }), - render: value => formatTimestamp(value, t($ => $['metadata.dateTimeFormat'], { ns: 'datasetDocuments' }) as string), - }, - 'data_source_type': { - label: t($ => $[`${fieldPrefix}.originInfo.source`], { ns: 'datasetDocuments' }), - render: (value: I18nKeysByPrefix<'datasetDocuments', 'metadata.source.'> | 'notion_import') => t($ => $[`metadata.source.${value === 'notion_import' ? 'notion' : value}`], { ns: 'datasetDocuments' }), + 'data_source_info.upload_file.size': { + label: t(($) => $[`${fieldPrefix}.originInfo.originalFileSize`], { + ns: 'datasetDocuments', + }), + render: (value) => formatFileSize(value), + }, + created_at: { + label: t(($) => $[`${fieldPrefix}.originInfo.uploadDate`], { ns: 'datasetDocuments' }), + render: (value) => + formatTimestamp( + value, + t(($) => $['metadata.dateTimeFormat'], { ns: 'datasetDocuments' }) as string, + ), + }, + completed_at: { + label: t(($) => $[`${fieldPrefix}.originInfo.lastUpdateDate`], { + ns: 'datasetDocuments', + }), + render: (value) => + formatTimestamp( + value, + t(($) => $['metadata.dateTimeFormat'], { ns: 'datasetDocuments' }) as string, + ), + }, + data_source_type: { + label: t(($) => $[`${fieldPrefix}.originInfo.source`], { ns: 'datasetDocuments' }), + render: ( + value: I18nKeysByPrefix<'datasetDocuments', 'metadata.source.'> | 'notion_import', + ) => + t(($) => $[`metadata.source.${value === 'notion_import' ? 'notion' : value}`], { + ns: 'datasetDocuments', + }), }, }, }, technicalParameters: { - text: t($ => $['metadata.type.technicalParameters'], { ns: 'datasetDocuments' }), + text: t(($) => $['metadata.type.technicalParameters'], { ns: 'datasetDocuments' }), allowEdit: false, subFieldsMap: { - 'doc_form': { - label: t($ => $[`${fieldPrefix}.technicalParameters.segmentSpecification`], { ns: 'datasetDocuments' }), + doc_form: { + label: t(($) => $[`${fieldPrefix}.technicalParameters.segmentSpecification`], { + ns: 'datasetDocuments', + }), render: (value) => { if (value === ChunkingMode.text) - return t($ => $['chunkingMode.general'], { ns: 'dataset' }) - if (value === ChunkingMode.qa) - return t($ => $['chunkingMode.qa'], { ns: 'dataset' }) + return t(($) => $['chunkingMode.general'], { ns: 'dataset' }) + if (value === ChunkingMode.qa) return t(($) => $['chunkingMode.qa'], { ns: 'dataset' }) if (value === ChunkingMode.parentChild) - return t($ => $['chunkingMode.parentChild'], { ns: 'dataset' }) + return t(($) => $['chunkingMode.parentChild'], { ns: 'dataset' }) return '--' }, }, 'dataset_process_rule.rules.segmentation.max_tokens': { - label: t($ => $[`${fieldPrefix}.technicalParameters.segmentLength`], { ns: 'datasetDocuments' }), - render: value => formatNumber(value), - }, - 'average_segment_length': { - label: t($ => $[`${fieldPrefix}.technicalParameters.avgParagraphLength`], { ns: 'datasetDocuments' }), - render: value => `${formatNumber(value)} characters`, - }, - 'segment_count': { - label: t($ => $[`${fieldPrefix}.technicalParameters.paragraphs`], { ns: 'datasetDocuments' }), - render: value => `${formatNumber(value)} paragraphs`, - }, - 'hit_count': { - label: t($ => $[`${fieldPrefix}.technicalParameters.hitCount`], { ns: 'datasetDocuments' }), + label: t(($) => $[`${fieldPrefix}.technicalParameters.segmentLength`], { + ns: 'datasetDocuments', + }), + render: (value) => formatNumber(value), + }, + average_segment_length: { + label: t(($) => $[`${fieldPrefix}.technicalParameters.avgParagraphLength`], { + ns: 'datasetDocuments', + }), + render: (value) => `${formatNumber(value)} characters`, + }, + segment_count: { + label: t(($) => $[`${fieldPrefix}.technicalParameters.paragraphs`], { + ns: 'datasetDocuments', + }), + render: (value) => `${formatNumber(value)} paragraphs`, + }, + hit_count: { + label: t(($) => $[`${fieldPrefix}.technicalParameters.hitCount`], { + ns: 'datasetDocuments', + }), render: (value, total) => { const v = value || 0 return `${!total ? 0 : ((v / total) * 100).toFixed(2)}% (${v}/${total})` }, }, - 'indexing_latency': { - label: t($ => $[`${fieldPrefix}.technicalParameters.embeddingTime`], { ns: 'datasetDocuments' }), - render: value => formatTime(value), + indexing_latency: { + label: t(($) => $[`${fieldPrefix}.technicalParameters.embeddingTime`], { + ns: 'datasetDocuments', + }), + render: (value) => formatTime(value), }, - 'tokens': { - label: t($ => $[`${fieldPrefix}.technicalParameters.embeddedSpend`], { ns: 'datasetDocuments' }), - render: value => `${formatNumber(value)} tokens`, + tokens: { + label: t(($) => $[`${fieldPrefix}.technicalParameters.embeddedSpend`], { + ns: 'datasetDocuments', + }), + render: (value) => `${formatNumber(value)} tokens`, }, }, }, @@ -299,31 +445,31 @@ const langPrefix = 'metadata.languageMap.' export const useLanguages = () => { const { t } = useTranslation() return { - zh: t($ => $[`${langPrefix}zh`], { ns: 'datasetDocuments' }), - en: t($ => $[`${langPrefix}en`], { ns: 'datasetDocuments' }), - es: t($ => $[`${langPrefix}es`], { ns: 'datasetDocuments' }), - fr: t($ => $[`${langPrefix}fr`], { ns: 'datasetDocuments' }), - de: t($ => $[`${langPrefix}de`], { ns: 'datasetDocuments' }), - ja: t($ => $[`${langPrefix}ja`], { ns: 'datasetDocuments' }), - ko: t($ => $[`${langPrefix}ko`], { ns: 'datasetDocuments' }), - ru: t($ => $[`${langPrefix}ru`], { ns: 'datasetDocuments' }), - ar: t($ => $[`${langPrefix}ar`], { ns: 'datasetDocuments' }), - pt: t($ => $[`${langPrefix}pt`], { ns: 'datasetDocuments' }), - it: t($ => $[`${langPrefix}it`], { ns: 'datasetDocuments' }), - nl: t($ => $[`${langPrefix}nl`], { ns: 'datasetDocuments' }), - pl: t($ => $[`${langPrefix}pl`], { ns: 'datasetDocuments' }), - sv: t($ => $[`${langPrefix}sv`], { ns: 'datasetDocuments' }), - tr: t($ => $[`${langPrefix}tr`], { ns: 'datasetDocuments' }), - he: t($ => $[`${langPrefix}he`], { ns: 'datasetDocuments' }), - hi: t($ => $[`${langPrefix}hi`], { ns: 'datasetDocuments' }), - da: t($ => $[`${langPrefix}da`], { ns: 'datasetDocuments' }), - fi: t($ => $[`${langPrefix}fi`], { ns: 'datasetDocuments' }), - no: t($ => $[`${langPrefix}no`], { ns: 'datasetDocuments' }), - hu: t($ => $[`${langPrefix}hu`], { ns: 'datasetDocuments' }), - el: t($ => $[`${langPrefix}el`], { ns: 'datasetDocuments' }), - cs: t($ => $[`${langPrefix}cs`], { ns: 'datasetDocuments' }), - th: t($ => $[`${langPrefix}th`], { ns: 'datasetDocuments' }), - id: t($ => $[`${langPrefix}id`], { ns: 'datasetDocuments' }), - ro: t($ => $[`${langPrefix}ro`], { ns: 'datasetDocuments' }), + zh: t(($) => $[`${langPrefix}zh`], { ns: 'datasetDocuments' }), + en: t(($) => $[`${langPrefix}en`], { ns: 'datasetDocuments' }), + es: t(($) => $[`${langPrefix}es`], { ns: 'datasetDocuments' }), + fr: t(($) => $[`${langPrefix}fr`], { ns: 'datasetDocuments' }), + de: t(($) => $[`${langPrefix}de`], { ns: 'datasetDocuments' }), + ja: t(($) => $[`${langPrefix}ja`], { ns: 'datasetDocuments' }), + ko: t(($) => $[`${langPrefix}ko`], { ns: 'datasetDocuments' }), + ru: t(($) => $[`${langPrefix}ru`], { ns: 'datasetDocuments' }), + ar: t(($) => $[`${langPrefix}ar`], { ns: 'datasetDocuments' }), + pt: t(($) => $[`${langPrefix}pt`], { ns: 'datasetDocuments' }), + it: t(($) => $[`${langPrefix}it`], { ns: 'datasetDocuments' }), + nl: t(($) => $[`${langPrefix}nl`], { ns: 'datasetDocuments' }), + pl: t(($) => $[`${langPrefix}pl`], { ns: 'datasetDocuments' }), + sv: t(($) => $[`${langPrefix}sv`], { ns: 'datasetDocuments' }), + tr: t(($) => $[`${langPrefix}tr`], { ns: 'datasetDocuments' }), + he: t(($) => $[`${langPrefix}he`], { ns: 'datasetDocuments' }), + hi: t(($) => $[`${langPrefix}hi`], { ns: 'datasetDocuments' }), + da: t(($) => $[`${langPrefix}da`], { ns: 'datasetDocuments' }), + fi: t(($) => $[`${langPrefix}fi`], { ns: 'datasetDocuments' }), + no: t(($) => $[`${langPrefix}no`], { ns: 'datasetDocuments' }), + hu: t(($) => $[`${langPrefix}hu`], { ns: 'datasetDocuments' }), + el: t(($) => $[`${langPrefix}el`], { ns: 'datasetDocuments' }), + cs: t(($) => $[`${langPrefix}cs`], { ns: 'datasetDocuments' }), + th: t(($) => $[`${langPrefix}th`], { ns: 'datasetDocuments' }), + id: t(($) => $[`${langPrefix}id`], { ns: 'datasetDocuments' }), + ro: t(($) => $[`${langPrefix}ro`], { ns: 'datasetDocuments' }), } } diff --git a/web/hooks/use-mitt.ts b/web/hooks/use-mitt.ts index af07dd7d20594d..63e9bd72b4cb4e 100644 --- a/web/hooks/use-mitt.ts +++ b/web/hooks/use-mitt.ts @@ -2,9 +2,7 @@ import type { Emitter, EventType, Handler, WildcardHandler } from 'mitt' import create from 'mitt' import { useEffect, useRef } from 'react' -const merge = <T extends Record<string, any>>( - ...args: Array<T | undefined> -): T => { +const merge = <T extends Record<string, any>>(...args: Array<T | undefined>): T => { return Object.assign({}, ...args) } @@ -24,11 +22,7 @@ type ExtendedOn<Events extends _Events> = { handler: Handler<Events[Key]>, options?: UseSubscribeOption, ): void - ( - type: '*', - handler: WildcardHandler<Events>, - option?: UseSubscribeOption, - ): void + (type: '*', handler: WildcardHandler<Events>, option?: UseSubscribeOption): void } type UseMittReturn<Events extends _Events> = { @@ -40,12 +34,9 @@ const defaultSubscribeOption: UseSubscribeOption = { enabled: true, } -function useMitt<Events extends _Events>( - mitt?: Emitter<Events>, -): UseMittReturn<Events> { +function useMitt<Events extends _Events>(mitt?: Emitter<Events>): UseMittReturn<Events> { const emitterRef = useRef<Emitter<Events> | undefined>(undefined) - if (!emitterRef.current) - emitterRef.current = mitt ?? create<Events>() + if (!emitterRef.current) emitterRef.current = mitt ?? create<Events>() if (mitt && emitterRef.current !== mitt) { emitterRef.current.off('*') diff --git a/web/hooks/use-oauth.ts b/web/hooks/use-oauth.ts index 8fb2707804c568..d0cdc6d12b56bc 100644 --- a/web/hooks/use-oauth.ts +++ b/web/hooks/use-oauth.ts @@ -14,24 +14,31 @@ export const useOAuthCallback = () => { const targetOrigin = window.opener?.origin || '*' if (subscriptionId) { - window.opener.postMessage({ - type: 'oauth_callback', - success: true, - subscriptionId, - }, targetOrigin) - } - else if (error) { - window.opener.postMessage({ - type: 'oauth_callback', - success: false, - error, - errorDescription, - }, targetOrigin) - } - else { - window.opener.postMessage({ - type: 'oauth_callback', - }, targetOrigin) + window.opener.postMessage( + { + type: 'oauth_callback', + success: true, + subscriptionId, + }, + targetOrigin, + ) + } else if (error) { + window.opener.postMessage( + { + type: 'oauth_callback', + success: false, + error, + errorDescription, + }, + targetOrigin, + ) + } else { + window.opener.postMessage( + { + type: 'oauth_callback', + }, + targetOrigin, + ) } window.close() } diff --git a/web/hooks/use-pay.tsx b/web/hooks/use-pay.tsx index 9a1ef8a2bb7ccc..8191a190d3099f 100644 --- a/web/hooks/use-pay.tsx +++ b/web/hooks/use-pay.tsx @@ -26,10 +26,16 @@ const useAnthropicCheckPay = () => { const paymentResult = searchParams.get('payment_result') useEffect(() => { - if (providerName === 'anthropic' && (paymentResult === 'succeeded' || paymentResult === 'cancelled')) { + if ( + providerName === 'anthropic' && + (paymentResult === 'succeeded' || paymentResult === 'cancelled') + ) { setConfirm({ type: paymentResult === 'succeeded' ? 'info' : 'warning', - title: paymentResult === 'succeeded' ? t($ => $['actionMsg.paySucceeded'], { ns: 'common' }) : t($ => $['actionMsg.payCancelled'], { ns: 'common' }), + title: + paymentResult === 'succeeded' + ? t(($) => $['actionMsg.paySucceeded'], { ns: 'common' }) + : t(($) => $['actionMsg.payCancelled'], { ns: 'common' }), }) } }, [providerName, paymentResult, t]) @@ -45,10 +51,16 @@ const useBillingPay = () => { const paymentResult = searchParams.get('payment_result') useEffect(() => { - if (paymentType === 'billing' && (paymentResult === 'succeeded' || paymentResult === 'cancelled')) { + if ( + paymentType === 'billing' && + (paymentResult === 'succeeded' || paymentResult === 'cancelled') + ) { setConfirm({ type: paymentResult === 'succeeded' ? 'info' : 'warning', - title: paymentResult === 'succeeded' ? t($ => $['actionMsg.paySucceeded'], { ns: 'common' }) : t($ => $['actionMsg.payCancelled'], { ns: 'common' }), + title: + paymentResult === 'succeeded' + ? t(($) => $['actionMsg.paySucceeded'], { ns: 'common' }) + : t(($) => $['actionMsg.payCancelled'], { ns: 'common' }), }) } }, [paymentType, paymentResult, t]) @@ -67,8 +79,7 @@ const useCheckNotion = () => { const { data } = useNotionBinding(notionCode, canBinding) useEffect(() => { - if (data) - router.replace('/') + if (data) router.replace('/') }, [data, router]) useEffect(() => { if (type === 'notion') { @@ -77,8 +88,7 @@ const useCheckNotion = () => { type: 'warning', title: notionError, }) - } - else if (notionCode) { + } else if (notionCode) { setCanBinding(true) } } @@ -102,13 +112,15 @@ export const CheckModal = () => { const confirmInfo = anthropicConfirmInfo || notionConfirmInfo || billingConfirmInfo - if (!confirmInfo) - return null + if (!confirmInfo) return null const description = (confirmInfo as { desc?: string }).desc || '' return ( - <AlertDialog open={showPayStatusModal} onOpenChange={open => !open && handleCancelShowPayStatusModal()}> + <AlertDialog + open={showPayStatusModal} + onOpenChange={(open) => !open && handleCancelShowPayStatusModal()} + > <AlertDialogContent> <div className="flex flex-col gap-2 px-6 pt-6 pb-4"> <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary"> @@ -126,8 +138,8 @@ export const CheckModal = () => { onClick={handleCancelShowPayStatusModal} > {confirmInfo.type === 'info' - ? t($ => $['operation.ok'], { ns: 'common' }) - : t($ => $['operation.confirm'], { ns: 'common' })} + ? t(($) => $['operation.ok'], { ns: 'common' }) + : t(($) => $['operation.confirm'], { ns: 'common' })} </AlertDialogConfirmButton> </AlertDialogActions> </AlertDialogContent> diff --git a/web/hooks/use-query-params.spec.tsx b/web/hooks/use-query-params.spec.tsx index 70680776672b86..262aab398f85a0 100644 --- a/web/hooks/use-query-params.spec.tsx +++ b/web/hooks/use-query-params.spec.tsx @@ -342,10 +342,7 @@ describe('useQueryParams hooks', () => { it('should return raw package id when JSON parsing fails', () => { // Arrange - const { result } = renderWithAdapter( - () => usePluginInstallation(), - '?package-ids=org/plugin', - ) + const { result } = renderWithAdapter(() => usePluginInstallation(), '?package-ids=org/plugin') // Act const [state] = result.current diff --git a/web/hooks/use-query-params.ts b/web/hooks/use-query-params.ts index e3d78450362efa..f459881634b6ed 100644 --- a/web/hooks/use-query-params.ts +++ b/web/hooks/use-query-params.ts @@ -34,8 +34,8 @@ import { export const PRICING_MODAL_QUERY_PARAM = 'pricing' export const PRICING_MODAL_QUERY_VALUE = 'open' const parseAsPricingModal = createParser<boolean>({ - parse: value => (value === PRICING_MODAL_QUERY_VALUE ? true : null), - serialize: value => (value ? PRICING_MODAL_QUERY_VALUE : ''), + parse: (value) => (value === PRICING_MODAL_QUERY_VALUE ? true : null), + serialize: (value) => (value ? PRICING_MODAL_QUERY_VALUE : ''), }) .withDefault(false) .withOptions({ history: 'push' }) @@ -50,10 +50,7 @@ const parseAsPricingModal = createParser<boolean>({ * setIsOpen(false) // Removes ?pricing */ export function usePricingModal() { - return useQueryState( - PRICING_MODAL_QUERY_PARAM, - parseAsPricingModal, - ) + return useQueryState(PRICING_MODAL_QUERY_PARAM, parseAsPricingModal) } const settingsTabValues = [...SETTINGS_TAB_VALUES] as SettingsTab[] @@ -121,31 +118,31 @@ const parseAsPackageId = createParser<string>({ return typeof first === 'string' ? first : null } return value - } - catch { + } catch { return value } }, - serialize: value => JSON.stringify([value]), + serialize: (value) => JSON.stringify([value]), }) const parseAsBundleInfo = createParser<BundleInfoQuery>({ parse: (value) => { try { const parsed = JSON.parse(value) as Partial<BundleInfoQuery> - if (parsed - && typeof parsed.org === 'string' - && typeof parsed.name === 'string' - && typeof parsed.version === 'string') { + if ( + parsed && + typeof parsed.org === 'string' && + typeof parsed.name === 'string' && + typeof parsed.version === 'string' + ) { return { org: parsed.org, name: parsed.name, version: parsed.version } } - } - catch { + } catch { return null } return null }, - serialize: value => JSON.stringify(value), + serialize: (value) => JSON.stringify(value), }) /** diff --git a/web/hooks/use-theme.ts b/web/hooks/use-theme.ts index c9c2bdea55fc9b..fb03863dd6571b 100644 --- a/web/hooks/use-theme.ts +++ b/web/hooks/use-theme.ts @@ -5,7 +5,7 @@ const useTheme = () => { const { theme, resolvedTheme, ...rest } = useBaseTheme() return { // only returns 'light' or 'dark' theme - theme: theme === Theme.system ? resolvedTheme as Theme : theme as Theme, + theme: theme === Theme.system ? (resolvedTheme as Theme) : (theme as Theme), ...rest, } } diff --git a/web/hooks/use-timestamp.spec.ts b/web/hooks/use-timestamp.spec.ts index 98c0587f4c83bc..12c04921c9cd90 100644 --- a/web/hooks/use-timestamp.spec.ts +++ b/web/hooks/use-timestamp.spec.ts @@ -25,43 +25,49 @@ const createEmptyQueryWrapper = () => { describe('useTimestamp', () => { describe('formatTime', () => { it('should format unix timestamp correctly', () => { - const { result } = renderHook(() => useTimestamp(), { wrapper: createAccountProfileQueryWrapper() }) + const { result } = renderHook(() => useTimestamp(), { + wrapper: createAccountProfileQueryWrapper(), + }) const timestamp = 1704132000 - expect(result.current.formatTime(timestamp, 'YYYY-MM-DD HH:mm:ss')) - .toBe('2024-01-02 02:00:00') + expect(result.current.formatTime(timestamp, 'YYYY-MM-DD HH:mm:ss')).toBe( + '2024-01-02 02:00:00', + ) }) it('should format with different patterns', () => { - const { result } = renderHook(() => useTimestamp(), { wrapper: createAccountProfileQueryWrapper() }) + const { result } = renderHook(() => useTimestamp(), { + wrapper: createAccountProfileQueryWrapper(), + }) const timestamp = 1704132000 - expect(result.current.formatTime(timestamp, 'MM/DD/YYYY')) - .toBe('01/02/2024') + expect(result.current.formatTime(timestamp, 'MM/DD/YYYY')).toBe('01/02/2024') - expect(result.current.formatTime(timestamp, 'HH:mm')) - .toBe('02:00') + expect(result.current.formatTime(timestamp, 'HH:mm')).toBe('02:00') }) }) describe('formatDate', () => { it('should format date string correctly', () => { - const { result } = renderHook(() => useTimestamp(), { wrapper: createAccountProfileQueryWrapper() }) + const { result } = renderHook(() => useTimestamp(), { + wrapper: createAccountProfileQueryWrapper(), + }) const dateString = '2024-01-01T12:00:00Z' - expect(result.current.formatDate(dateString, 'YYYY-MM-DD HH:mm:ss')) - .toBe('2024-01-01 20:00:00') + expect(result.current.formatDate(dateString, 'YYYY-MM-DD HH:mm:ss')).toBe( + '2024-01-01 20:00:00', + ) }) it('should format with different patterns', () => { - const { result } = renderHook(() => useTimestamp(), { wrapper: createAccountProfileQueryWrapper() }) + const { result } = renderHook(() => useTimestamp(), { + wrapper: createAccountProfileQueryWrapper(), + }) const dateString = '2024-01-01T12:00:00Z' - expect(result.current.formatDate(dateString, 'MM/DD/YYYY')) - .toBe('01/01/2024') + expect(result.current.formatDate(dateString, 'MM/DD/YYYY')).toBe('01/01/2024') - expect(result.current.formatDate(dateString, 'HH:mm')) - .toBe('20:00') + expect(result.current.formatDate(dateString, 'HH:mm')).toBe('20:00') }) }) diff --git a/web/hooks/use-timestamp.ts b/web/hooks/use-timestamp.ts index 1d4b9e759e6bee..38e935e88a0506 100644 --- a/web/hooks/use-timestamp.ts +++ b/web/hooks/use-timestamp.ts @@ -20,18 +20,24 @@ const getBrowserTimezone = () => { const useTimestamp = ({ timezone: timezoneOverride }: UseTimestampOptions = {}) => { const { data: accountTimezone } = useQuery({ ...userProfileQueryOptions(), - select: data => data.profile.timezone ?? undefined, + select: (data) => data.profile.timezone ?? undefined, enabled: timezoneOverride === undefined, }) const resolvedTimezone = timezoneOverride ?? accountTimezone ?? getBrowserTimezone() - const formatTime = useCallback((value: number, format: string) => { - return dayjs.unix(value).tz(resolvedTimezone).format(format) - }, [resolvedTimezone]) - - const formatDate = useCallback((value: string, format: string) => { - return dayjs(value).tz(resolvedTimezone).format(format) - }, [resolvedTimezone]) + const formatTime = useCallback( + (value: number, format: string) => { + return dayjs.unix(value).tz(resolvedTimezone).format(format) + }, + [resolvedTimezone], + ) + + const formatDate = useCallback( + (value: string, format: string) => { + return dayjs(value).tz(resolvedTimezone).format(format) + }, + [resolvedTimezone], + ) return { formatTime, formatDate } } diff --git a/web/i18n-config/__tests__/plural-selector.spec.ts b/web/i18n-config/__tests__/plural-selector.spec.ts index 36ccdd2a1e9e66..bc2e8c34746db6 100644 --- a/web/i18n-config/__tests__/plural-selector.spec.ts +++ b/web/i18n-config/__tests__/plural-selector.spec.ts @@ -20,7 +20,7 @@ describe('i18n selector configuration', () => { }, }, }) - const memberKey: SelectorParam<'app'> = $ => $['accessControlDialog.members'] + const memberKey: SelectorParam<'app'> = ($) => $['accessControlDialog.members'] // Act const singular = instance.t(memberKey, { count: 1 }) diff --git a/web/i18n-config/client.ts b/web/i18n-config/client.ts index 0fc9fbdf25be93..a1b949d499725c 100644 --- a/web/i18n-config/client.ts +++ b/web/i18n-config/client.ts @@ -12,10 +12,11 @@ export function createI18nextInstance(lng: Locale, resources: Resource) { const instance = createInstance() instance .use(initReactI18next) - .use(resourcesToBackend(( - language: Locale, - namespace: NamespaceInFileName | Namespace, - ) => loadI18nResource(language, namespace))) + .use( + resourcesToBackend((language: Locale, namespace: NamespaceInFileName | Namespace) => + loadI18nResource(language, namespace), + ), + ) .init({ ...getInitOptions(), lng, @@ -25,8 +26,7 @@ export function createI18nextInstance(lng: Locale, resources: Resource) { } export const changeLanguage = async (lng?: Locale) => { - if (!lng) - return + if (!lng) return const i18n = getI18n() await i18n.changeLanguage(lng) } diff --git a/web/i18n-config/index.ts b/web/i18n-config/index.ts index 016cc2ba2dc9be..63eabdd6804931 100644 --- a/web/i18n-config/index.ts +++ b/web/i18n-config/index.ts @@ -1,5 +1,4 @@ import type { Locale } from '@/i18n-config/language' - import Cookies from 'js-cookie' import { LOCALE_COOKIE_NAME } from '@/config' import { changeLanguage } from '@/i18n-config/client' @@ -15,16 +14,12 @@ export type { Locale } export const setLocaleOnClient = async (locale: Locale, reloadPage = true) => { Cookies.set(LOCALE_COOKIE_NAME, locale, { expires: 365 }) await changeLanguage(locale) - if (reloadPage) - location.reload() + if (reloadPage) location.reload() } export const renderI18nObject = (obj: Record<string, string>, language: string) => { - if (!obj) - return '' - if (obj?.[language]) - return obj[language] - if (obj?.en_US) - return obj.en_US + if (!obj) return '' + if (obj?.[language]) return obj[language] + if (obj?.en_US) return obj.en_US return Object.values(obj)[0]! } diff --git a/web/i18n-config/language.ts b/web/i18n-config/language.ts index f3b5062e3b2e2a..b1c44ff14f9c43 100644 --- a/web/i18n-config/language.ts +++ b/web/i18n-config/language.ts @@ -1,18 +1,19 @@ import type { DocLanguage } from '@/types/doc-paths' import data from './languages' -export type I18nText = Record<typeof LanguagesSupported[number], string> +export type I18nText = Record<(typeof LanguagesSupported)[number], string> export const languages = data.languages // for compatibility -export type Locale = 'ja_JP' | 'zh_Hans' | 'en_US' | (typeof languages[number])['value'] +export type Locale = 'ja_JP' | 'zh_Hans' | 'en_US' | (typeof languages)[number]['value'] -export const LanguagesSupported: Locale[] = languages.filter(item => item.supported).map(item => item.value) +export const LanguagesSupported: Locale[] = languages + .filter((item) => item.supported) + .map((item) => item.value) export const getLanguage = (locale: Locale): Locale => { - if (['zh-Hans', 'ja-JP'].includes(locale)) - return locale.replace('-', '_') as Locale + if (['zh-Hans', 'ja-JP'].includes(locale)) return locale.replace('-', '_') as Locale return LanguagesSupported[0]!.replace('-', '_') as Locale } @@ -33,16 +34,16 @@ const ACCESS_CONTROL_TEMPLATE_LANGUAGE: Record<string, AccessControlTemplateLang export const localeMap: Record<Locale, string> = { 'en-US': 'en', - 'en_US': 'en', + en_US: 'en', 'zh-Hans': 'zh-cn', - 'zh_Hans': 'zh-cn', + zh_Hans: 'zh-cn', 'zh-Hant': 'zh-tw', 'pt-BR': 'pt-br', 'es-ES': 'es', 'fr-FR': 'fr', 'de-DE': 'de', 'ja-JP': 'ja', - 'ja_JP': 'ja', + ja_JP: 'ja', 'ko-KR': 'ko', 'ru-RU': 'ru', 'it-IT': 'it', @@ -129,7 +130,7 @@ export const NOTICE_I18N = { id_ID: 'Sistem kami tidak akan tersedia dari 19:00 hingga 24:00 UTC pada 28 Agustus untuk pemutakhiran. Untuk pertanyaan, silakan hubungi tim dukungan kami (support@dify.ai). Kami menghargai kesabaran Anda.', tr_TR: - 'Sistemimiz, 28 Ağustos\'ta 19:00 ile 24:00 UTC saatleri arasında güncelleme nedeniyle kullanılamayacaktır. Sorularınız için lütfen destek ekibimizle iletişime geçin (support@dify.ai). Sabrınız için teşekkür ederiz.', + "Sistemimiz, 28 Ağustos'ta 19:00 ile 24:00 UTC saatleri arasında güncelleme nedeniyle kullanılamayacaktır. Sorularınız için lütfen destek ekibimizle iletişime geçin (support@dify.ai). Sabrınız için teşekkür ederiz.", fa_IR: 'سیستم ما از ساعت 19:00 تا 24:00 UTC در تاریخ 28 اوت برای ارتقاء در دسترس نخواهد بود. برای سؤالات، لطفاً با تیم پشتیبانی ما (support@dify.ai) تماس بگیرید. ما برای صبر شما ارزش قائلیم.', sl_SI: diff --git a/web/i18n-config/load-resource.ts b/web/i18n-config/load-resource.ts index 7e483a3f649b57..214a351a31fb34 100644 --- a/web/i18n-config/load-resource.ts +++ b/web/i18n-config/load-resource.ts @@ -18,8 +18,7 @@ const defaultLocale = 'en-US' satisfies Locale const normalizeLocale = (locale: Locale): Locale => { const normalized = legacyLocaleMap[locale] ?? locale - if (LanguagesSupported.includes(normalized)) - return normalized + if (LanguagesSupported.includes(normalized)) return normalized return defaultLocale } @@ -29,7 +28,10 @@ const loadLocaleResources = (locale: Locale): Promise<LocaleResourceModule> => { return import(`./locale-resources/${normalized}.ts`) } -export const loadI18nResource = async (locale: Locale, namespace: Namespace | NamespaceInFileName) => { +export const loadI18nResource = async ( + locale: Locale, + namespace: Namespace | NamespaceInFileName, +) => { const { loadResource } = await loadLocaleResources(locale) return loadResource(kebabCase(namespace)) } diff --git a/web/i18n-config/locale-resources/ar-TN.ts b/web/i18n-config/locale-resources/ar-TN.ts index 64c2fcc107cda0..0233334f0c43bf 100644 --- a/web/i18n-config/locale-resources/ar-TN.ts +++ b/web/i18n-config/locale-resources/ar-TN.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/ar-TN/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/ar-TN/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/de-DE.ts b/web/i18n-config/locale-resources/de-DE.ts index 9fc14e17f47249..d518616ea12b26 100644 --- a/web/i18n-config/locale-resources/de-DE.ts +++ b/web/i18n-config/locale-resources/de-DE.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/de-DE/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/de-DE/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/en-US.ts b/web/i18n-config/locale-resources/en-US.ts index 8e3e04b7a22003..f7d33c53c6d711 100644 --- a/web/i18n-config/locale-resources/en-US.ts +++ b/web/i18n-config/locale-resources/en-US.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/en-US/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/en-US/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/es-ES.ts b/web/i18n-config/locale-resources/es-ES.ts index 48325330290956..ad7d256275ab66 100644 --- a/web/i18n-config/locale-resources/es-ES.ts +++ b/web/i18n-config/locale-resources/es-ES.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/es-ES/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/es-ES/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/fa-IR.ts b/web/i18n-config/locale-resources/fa-IR.ts index c7826be97359b1..d95c6e62ca3184 100644 --- a/web/i18n-config/locale-resources/fa-IR.ts +++ b/web/i18n-config/locale-resources/fa-IR.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/fa-IR/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/fa-IR/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/fr-FR.ts b/web/i18n-config/locale-resources/fr-FR.ts index 7381a3d4f55e3a..e82371bb14391a 100644 --- a/web/i18n-config/locale-resources/fr-FR.ts +++ b/web/i18n-config/locale-resources/fr-FR.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/fr-FR/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/fr-FR/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/hi-IN.ts b/web/i18n-config/locale-resources/hi-IN.ts index e35a06ae657ed5..c81c8c62eb7b8a 100644 --- a/web/i18n-config/locale-resources/hi-IN.ts +++ b/web/i18n-config/locale-resources/hi-IN.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/hi-IN/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/hi-IN/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/id-ID.ts b/web/i18n-config/locale-resources/id-ID.ts index 21d0a4533de791..1b8629e15f241f 100644 --- a/web/i18n-config/locale-resources/id-ID.ts +++ b/web/i18n-config/locale-resources/id-ID.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/id-ID/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/id-ID/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/it-IT.ts b/web/i18n-config/locale-resources/it-IT.ts index 4f5c29d24f1d8b..0920e7b4d20783 100644 --- a/web/i18n-config/locale-resources/it-IT.ts +++ b/web/i18n-config/locale-resources/it-IT.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/it-IT/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/it-IT/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/ja-JP.ts b/web/i18n-config/locale-resources/ja-JP.ts index 15204309262ce3..50732b3c6e3a3d 100644 --- a/web/i18n-config/locale-resources/ja-JP.ts +++ b/web/i18n-config/locale-resources/ja-JP.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/ja-JP/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/ja-JP/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/ko-KR.ts b/web/i18n-config/locale-resources/ko-KR.ts index 34a6b81b450991..60234fbd0337df 100644 --- a/web/i18n-config/locale-resources/ko-KR.ts +++ b/web/i18n-config/locale-resources/ko-KR.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/ko-KR/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/ko-KR/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/nl-NL.ts b/web/i18n-config/locale-resources/nl-NL.ts index b04b85d0dbd47f..56dda25160e975 100644 --- a/web/i18n-config/locale-resources/nl-NL.ts +++ b/web/i18n-config/locale-resources/nl-NL.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/nl-NL/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/nl-NL/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/pl-PL.ts b/web/i18n-config/locale-resources/pl-PL.ts index dfe4f1b4d88058..e0782421886127 100644 --- a/web/i18n-config/locale-resources/pl-PL.ts +++ b/web/i18n-config/locale-resources/pl-PL.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/pl-PL/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/pl-PL/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/pt-BR.ts b/web/i18n-config/locale-resources/pt-BR.ts index 21a907a0296577..d77363b7c6798b 100644 --- a/web/i18n-config/locale-resources/pt-BR.ts +++ b/web/i18n-config/locale-resources/pt-BR.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/pt-BR/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/pt-BR/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/ro-RO.ts b/web/i18n-config/locale-resources/ro-RO.ts index 3e8037571ab5e3..db065496d0b613 100644 --- a/web/i18n-config/locale-resources/ro-RO.ts +++ b/web/i18n-config/locale-resources/ro-RO.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/ro-RO/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/ro-RO/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/ru-RU.ts b/web/i18n-config/locale-resources/ru-RU.ts index fdc154f801ad47..f5034f141ba1c6 100644 --- a/web/i18n-config/locale-resources/ru-RU.ts +++ b/web/i18n-config/locale-resources/ru-RU.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/ru-RU/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/ru-RU/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/sl-SI.ts b/web/i18n-config/locale-resources/sl-SI.ts index 2cf21d2307c71d..aa4e530b180a33 100644 --- a/web/i18n-config/locale-resources/sl-SI.ts +++ b/web/i18n-config/locale-resources/sl-SI.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/sl-SI/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/sl-SI/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/th-TH.ts b/web/i18n-config/locale-resources/th-TH.ts index 615acdb9562efd..a7f3e3224471b4 100644 --- a/web/i18n-config/locale-resources/th-TH.ts +++ b/web/i18n-config/locale-resources/th-TH.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/th-TH/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/th-TH/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/tr-TR.ts b/web/i18n-config/locale-resources/tr-TR.ts index d2c40378dacc51..e4efa9b5a83edb 100644 --- a/web/i18n-config/locale-resources/tr-TR.ts +++ b/web/i18n-config/locale-resources/tr-TR.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/tr-TR/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/tr-TR/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/uk-UA.ts b/web/i18n-config/locale-resources/uk-UA.ts index bcaa998f74c30d..245696237c4078 100644 --- a/web/i18n-config/locale-resources/uk-UA.ts +++ b/web/i18n-config/locale-resources/uk-UA.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/uk-UA/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/uk-UA/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/vi-VN.ts b/web/i18n-config/locale-resources/vi-VN.ts index 73494bb143d422..dfde38233f83e6 100644 --- a/web/i18n-config/locale-resources/vi-VN.ts +++ b/web/i18n-config/locale-resources/vi-VN.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/vi-VN/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/vi-VN/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/zh-Hans.ts b/web/i18n-config/locale-resources/zh-Hans.ts index af65117df304f3..3fecc27d70a9ed 100644 --- a/web/i18n-config/locale-resources/zh-Hans.ts +++ b/web/i18n-config/locale-resources/zh-Hans.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/zh-Hans/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/zh-Hans/${fileNamespace}.json`) diff --git a/web/i18n-config/locale-resources/zh-Hant.ts b/web/i18n-config/locale-resources/zh-Hant.ts index 634ce77b360bd3..30cd2cb64e1be6 100644 --- a/web/i18n-config/locale-resources/zh-Hant.ts +++ b/web/i18n-config/locale-resources/zh-Hant.ts @@ -1 +1,2 @@ -export const loadResource = (fileNamespace: string) => import(`../../i18n/zh-Hant/${fileNamespace}.json`) +export const loadResource = (fileNamespace: string) => + import(`../../i18n/zh-Hant/${fileNamespace}.json`) diff --git a/web/i18n-config/resources.ts b/web/i18n-config/resources.ts index cdcafcb12c33d6..f01dcf1346009c 100644 --- a/web/i18n-config/resources.ts +++ b/web/i18n-config/resources.ts @@ -170,7 +170,7 @@ export const namespaces = [ 'tools', 'workflow', ] as const satisfies ReadonlyArray<keyof Resources> -export type Namespace = typeof namespaces[number] +export type Namespace = (typeof namespaces)[number] -export const namespacesInFileName = namespaces.map(ns => kebabCase(ns)) -export type NamespaceInFileName = typeof namespacesInFileName[number] +export const namespacesInFileName = namespaces.map((ns) => kebabCase(ns)) +export type NamespaceInFileName = (typeof namespacesInFileName)[number] diff --git a/web/i18n-config/server.ts b/web/i18n-config/server.ts index b83d2707667ead..7e88a3c5c0e29e 100644 --- a/web/i18n-config/server.ts +++ b/web/i18n-config/server.ts @@ -20,13 +20,16 @@ const [getI18nInstance, setI18nInstance] = serverOnlyContext<I18nInstance | null const getOrCreateI18next = async (lng: Locale) => { let instance = getI18nInstance() - if (instance) - return instance + if (instance) return instance instance = createInstance() await instance .use(initReactI18next) - .use(resourcesToBackend((language: Locale, namespace: Namespace | NamespaceInFileName) => loadI18nResource(language, namespace))) + .use( + resourcesToBackend((language: Locale, namespace: Namespace | NamespaceInFileName) => + loadI18nResource(language, namespace), + ), + ) .init({ ...getInitOptions(), lng, @@ -38,8 +41,7 @@ const getOrCreateI18next = async (lng: Locale) => { export async function getTranslation<T extends Namespace>(lng: Locale, ns?: T) { const i18nextInstance = await getOrCreateI18next(lng) - if (ns && !i18nextInstance.hasLoadedNamespace(ns)) - await i18nextInstance.loadNamespaces(ns) + if (ns && !i18nextInstance.hasLoadedNamespace(ns)) await i18nextInstance.loadNamespaces(ns) return { t: i18nextInstance.getFixedT(lng, ns), @@ -49,8 +51,7 @@ export async function getTranslation<T extends Namespace>(lng: Locale, ns?: T) { export const getLocaleOnServer = async (): Promise<Locale> => { const cached = getLocaleCache() - if (cached) - return cached + if (cached) return cached const locales: string[] = i18n.locales @@ -61,14 +62,18 @@ export const getLocaleOnServer = async (): Promise<Locale> => { if (!languages.length) { // Negotiator expects plain object so we need to transform headers - const negotiatorHeaders: Record<string, string> = {}; - (await headers()).forEach((value, key) => (negotiatorHeaders[key] = value)) + const negotiatorHeaders: Record<string, string> = {} + ;(await headers()).forEach((value, key) => (negotiatorHeaders[key] = value)) // Use negotiator and intl-localematcher to get best locale languages = new Negotiator({ headers: negotiatorHeaders }).languages() } // Validate languages - if (!Array.isArray(languages) || languages.length === 0 || !languages.every(lang => typeof lang === 'string' && /^[\w-]+$/.test(lang))) + if ( + !Array.isArray(languages) || + languages.length === 0 || + !languages.every((lang) => typeof lang === 'string' && /^[\w-]+$/.test(lang)) + ) languages = [i18n.defaultLocale] // match locale @@ -81,7 +86,7 @@ export const getResources = cache(async (lng: Locale): Promise<Resource> => { const messages = {} as ResourceLanguage await Promise.all( - (namespacesInFileName).map(async (ns) => { + namespacesInFileName.map(async (ns) => { const mod = await loadI18nResource(lng, ns) messages[camelCase(ns)] = mod.default }), diff --git a/web/instrumentation-client.ts b/web/instrumentation-client.ts index 7d61502bc07789..5cd7eec0127ce6 100644 --- a/web/instrumentation-client.ts +++ b/web/instrumentation-client.ts @@ -32,8 +32,7 @@ async function main() { try { localStorage = globalThis.localStorage sessionStorage = globalThis.sessionStorage - } - catch { + } catch { localStorage = new StorageMock() sessionStorage = new StorageMock() } @@ -53,10 +52,7 @@ async function main() { const Sentry = await import('@sentry/react') Sentry.init({ dsn: SENTRY_DSN, - integrations: [ - Sentry.browserTracingIntegration(), - Sentry.replayIntegration(), - ], + integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()], tracesSampleRate: 0.1, replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0, diff --git a/web/knip.config.ts b/web/knip.config.ts index 5e7000163d8f60..fd2cad525f6786 100644 --- a/web/knip.config.ts +++ b/web/knip.config.ts @@ -29,22 +29,14 @@ const config: KnipConfig = { '!**/test-utils.{ts,tsx}!', '!vitest.setup.ts!', ], - ignore: [ - 'public/**', - ], + ignore: ['public/**'], ignoreFiles: [ 'features/agent-v2/agent-detail/configure/components/orchestrate/memory.tsx', 'features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/option-menu.tsx', 'i18n-config/locale-resources/*.ts', ], - ignoreBinaries: [ - 'pbcopy', - 'which', - ], - ignoreDependencies: [ - '@iconify-json/*', - '@storybook/addon-onboarding', - ], + ignoreBinaries: ['pbcopy', 'which'], + ignoreDependencies: ['@iconify-json/*', '@storybook/addon-onboarding'], /// keep-sorted rules: { // TODO: fix these warnings diff --git a/web/models/access-control.ts b/web/models/access-control.ts index 7a5e9341f3c323..c0a179bc677206 100644 --- a/web/models/access-control.ts +++ b/web/models/access-control.ts @@ -5,7 +5,7 @@ export const SubjectType = { ACCOUNT: 'account', } as const -export type SubjectType = typeof SubjectType[keyof typeof SubjectType] +export type SubjectType = (typeof SubjectType)[keyof typeof SubjectType] export const AccessMode = { PUBLIC: 'public', @@ -14,7 +14,7 @@ export const AccessMode = { EXTERNAL_MEMBERS: 'sso_verified', } as const -export type AccessMode = typeof AccessMode[keyof typeof AccessMode] +export type AccessMode = (typeof AccessMode)[keyof typeof AccessMode] export type AccessControlGroup = { id: string @@ -30,8 +30,16 @@ export type AccessControlAccount = { avatarUrl: string } -export type SubjectGroup = { subjectId: string, subjectType: SubjectType, groupData: AccessControlGroup } -export type SubjectAccount = { subjectId: string, subjectType: SubjectType, accountData: AccessControlAccount } +export type SubjectGroup = { + subjectId: string + subjectType: SubjectType + groupData: AccessControlGroup +} +export type SubjectAccount = { + subjectId: string + subjectType: SubjectType + accountData: AccessControlAccount +} export type Subject = SubjectGroup | SubjectAccount @@ -259,8 +267,10 @@ type RemoveResourceAccessPolicyMemberBindingsRequest = { accountIds: string[] } -export type RemoveAppAccessPolicyMemberBindingsRequest = RemoveResourceAccessPolicyMemberBindingsRequest +export type RemoveAppAccessPolicyMemberBindingsRequest = + RemoveResourceAccessPolicyMemberBindingsRequest -export type RemoveDatasetAccessPolicyMemberBindingsRequest = RemoveResourceAccessPolicyMemberBindingsRequest +export type RemoveDatasetAccessPolicyMemberBindingsRequest = + RemoveResourceAccessPolicyMemberBindingsRequest export type ResourceOpenScope = 'all' | 'only_me' | 'specific' diff --git a/web/models/app.ts b/web/models/app.ts index a01c39270174c5..544e1abb067b4c 100644 --- a/web/models/app.ts +++ b/web/models/app.ts @@ -51,15 +51,15 @@ export type DSLImportResponse = { export type UpdateAppSiteCodeResponse = { app_id: string } & SiteConfig export type AppDailyMessagesResponse = { - data: Array<{ date: string, message_count: number }> + data: Array<{ date: string; message_count: number }> } export type AppDailyConversationsResponse = { - data: Array<{ date: string, conversation_count: number }> + data: Array<{ date: string; conversation_count: number }> } export type WorkflowDailyConversationsResponse = { - data: Array<{ date: string, runs: number }> + data: Array<{ date: string; runs: number }> } export type AppStatisticsResponse = { @@ -67,11 +67,11 @@ export type AppStatisticsResponse = { } export type AppDailyEndUsersResponse = { - data: Array<{ date: string, terminal_count: number }> + data: Array<{ date: string; terminal_count: number }> } export type AppTokenCostsResponse = { - data: Array<{ date: string, token_count: number, total_price: number, currency: number }> + data: Array<{ date: string; token_count: number; total_price: number; currency: number }> } export type UpdateAppModelConfigResponse = { result: string } @@ -93,10 +93,12 @@ export type CreateApiKeyResponse = { created_at: string } -export type AppVoicesListResponse = [{ - name: string - value: string -}] +export type AppVoicesListResponse = [ + { + name: string + value: string + }, +] export type WorkflowOnlineUser = { user_id?: string @@ -106,10 +108,12 @@ export type WorkflowOnlineUser = { } export type WorkflowOnlineUsersResponse = { - data: Record<string, WorkflowOnlineUser[]> | Array<{ - app_id: string - users: WorkflowOnlineUser[] - }> + data: + | Record<string, WorkflowOnlineUser[]> + | Array<{ + app_id: string + users: WorkflowOnlineUser[] + }> } export type TracingStatus = { @@ -119,7 +123,17 @@ export type TracingStatus = { export type TracingConfig = { tracing_provider: TracingProvider - tracing_config: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | DatabricksConfig | MLflowConfig | OpikConfig | WeaveConfig | AliyunConfig | TencentConfig + tracing_config: + | ArizeConfig + | PhoenixConfig + | LangSmithConfig + | LangFuseConfig + | DatabricksConfig + | MLflowConfig + | OpikConfig + | WeaveConfig + | AliyunConfig + | TencentConfig } export type WebhookTriggerResponse = { @@ -134,9 +148,9 @@ export type WebhookTriggerResponse = { export type Banner = { id: string content: { - 'category': string - 'title': string - 'description': string + category: string + title: string + description: string 'img-src': string } link: string diff --git a/web/models/common.ts b/web/models/common.ts index 359178c521e389..f4bff08b404147 100644 --- a/web/models/common.ts +++ b/web/models/common.ts @@ -37,7 +37,7 @@ const ProviderName = { Tongyi: 'tongyi', ChatGLM: 'chatglm', } as const -type ProviderName = typeof ProviderName[keyof typeof ProviderName] +type ProviderName = (typeof ProviderName)[keyof typeof ProviderName] type ProviderAzureToken = { openai_api_base?: string openai_api_key?: string @@ -97,7 +97,10 @@ export type NotionPage = DataSourceNotionPage & { workspace_id: string } -export type DataSourceNotionPageMap = Record<string, DataSourceNotionPage & { workspace_id: string }> +export type DataSourceNotionPageMap = Record< + string, + DataSourceNotionPage & { workspace_id: string } +> export type DataSourceNotionWorkspace = { workspace_name: string @@ -112,7 +115,7 @@ export const DataSourceProvider = { jinaReader: 'jinareader', waterCrawl: 'watercrawl', } as const -export type DataSourceProvider = typeof DataSourceProvider[keyof typeof DataSourceProvider] +export type DataSourceProvider = (typeof DataSourceProvider)[keyof typeof DataSourceProvider] export type FileUploadConfigResponse = { batch_count_limit: number @@ -127,19 +130,22 @@ export type FileUploadConfigResponse = { file_upload_limit: number // default is 5 } -export type InvitationResult = { - status: 'success' - email: string - url: string -} | { - status: 'already_member' - email: string - message?: string -} | { - status: 'failed' - email: string - message: string -} +export type InvitationResult = + | { + status: 'success' + email: string + url: string + } + | { + status: 'already_member' + email: string + message?: string + } + | { + status: 'failed' + email: string + message: string + } export type InvitationResponse = CommonResponse & { invitation_results: InvitationResult[] @@ -150,7 +156,7 @@ export type CodeBasedExtensionForm = { label: I18nText variable: string required: boolean - options: { label: I18nText, value: string }[] + options: { label: I18nText; value: string }[] default: string placeholder: string max_length?: number diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 70e1cd7f45814b..d6fe1f6c571540 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -7,7 +7,12 @@ import type { MetadataFilteringVariableType } from '@/app/components/workflow/no import type { AppIconType, AppModeEnum, RetrievalConfig, TransferMethod } from '@/types/app' import type { SegmentImportStatus } from '@/types/dataset' import type { I18nKeysByPrefix } from '@/types/i18n' -import { ExternalKnowledgeBase, General, ParentChild, Qa } from '@/app/components/base/icons/src/public/knowledge/dataset-card' +import { + ExternalKnowledgeBase, + General, + ParentChild, + Qa, +} from '@/app/components/base/icons/src/public/knowledge/dataset-card' import { PermissionLevel } from './permission' export enum DataSourceType { @@ -110,7 +115,7 @@ export type ExternalAPIItem = { endpoint: string api_key: string } - dataset_bindings: { id: string, name: string }[] + dataset_bindings: { id: string; name: string }[] created_by: string created_at: string } @@ -236,7 +241,7 @@ type IndexingEstimateResponse = { total_price: number currency: string total_segments: number - preview: Array<{ content: string, child_chunks: string[], summary?: string }> + preview: Array<{ content: string; child_chunks: string[]; summary?: string }> qa_preview?: QA[] } @@ -298,15 +303,15 @@ type Segmentation = { chunk_overlap?: number } -export type DocumentIndexingStatus - = | 'waiting' - | 'parsing' - | 'cleaning' - | 'splitting' - | 'indexing' - | 'paused' - | 'error' - | 'completed' +export type DocumentIndexingStatus = + | 'waiting' + | 'parsing' + | 'cleaning' + | 'splitting' + | 'indexing' + | 'paused' + | 'error' + | 'completed' export const DisplayStatusList = [ 'queuing', @@ -319,7 +324,7 @@ export const DisplayStatusList = [ 'archived', ] as const -export type DocumentDisplayStatus = typeof DisplayStatusList[number] +export type DocumentDisplayStatus = (typeof DisplayStatusList)[number] export type LegacyDataSourceInfo = { upload_file: { @@ -385,7 +390,12 @@ export type UploadFileIdInfo = { upload_file_id: string } -export type DataSourceInfo = LegacyDataSourceInfo | LocalFileInfo | OnlineDocumentInfo | WebsiteCrawlInfo | UploadFileIdInfo +export type DataSourceInfo = + | LegacyDataSourceInfo + | LocalFileInfo + | OnlineDocumentInfo + | WebsiteCrawlInfo + | UploadFileIdInfo type InitialDocumentDetail = { id: string @@ -449,9 +459,10 @@ export type CreateDocumentReq = DocumentReq & { embedding_model_provider: string } -export type IndexingEstimateParams = DocumentReq & Partial<DataSource> & { - dataset_id: string -} +export type IndexingEstimateParams = DocumentReq & + Partial<DataSource> & { + dataset_id: string + } type DataSource = { type: DataSourceType @@ -525,14 +536,14 @@ type DocMetadata = { [key: string]: string } -type CustomizableDocType - = | 'book' - | 'web_page' - | 'paper' - | 'social_media_post' - | 'personal_document' - | 'business_document' - | 'im_chat_log' +type CustomizableDocType = + | 'book' + | 'web_page' + | 'paper' + | 'social_media_post' + | 'personal_document' + | 'business_document' + | 'im_chat_log' type FixedDocType = 'synced_from_github' | 'synced_from_notion' | 'wikipedia_entry' export type DocType = CustomizableDocType | FixedDocType @@ -787,7 +798,10 @@ export type BatchImportResponse = { job_status: SegmentImportStatus } -export const DOC_FORM_ICON_WITH_BG: Record<ChunkingMode | 'external', React.ComponentType<{ className: string }>> = { +export const DOC_FORM_ICON_WITH_BG: Record< + ChunkingMode | 'external', + React.ComponentType<{ className: string }> +> = { [ChunkingMode.text]: General, [ChunkingMode.qa]: Qa, [ChunkingMode.parentChild]: ParentChild, diff --git a/web/models/debug.ts b/web/models/debug.ts index b069c8252fd028..946bbf6e1a597e 100644 --- a/web/models/debug.ts +++ b/web/models/debug.ts @@ -5,11 +5,15 @@ import type { } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import type { ModelConfig as NodeModelConfig } from '@/app/components/workflow/types' import type { ExternalDataTool } from '@/models/common' +import type { RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets' import type { - RerankingModeEnum, - WeightedScoreEnum, -} from '@/models/datasets' -import type { AgentStrategy, Model, ModelModeType, RETRIEVE_TYPE, ToolItem, TtsAutoPlay } from '@/types/app' + AgentStrategy, + Model, + ModelModeType, + RETRIEVE_TYPE, + ToolItem, + TtsAutoPlay, +} from '@/types/app' export type Inputs = Record<string, string | number | object | boolean> diff --git a/web/models/log.ts b/web/models/log.ts index 08db6d7b0ee010..ff51d0607479b9 100644 --- a/web/models/log.ts +++ b/web/models/log.ts @@ -1,9 +1,6 @@ import type { Viewport } from 'reactflow' import type { Metadata } from '@/app/components/base/chat/chat/type' -import type { - Edge, - Node, -} from '@/app/components/workflow/types' +import type { Edge, Node } from '@/app/components/workflow/types' import type { VisionFile } from '@/types/app' type CompletionParamsType = { @@ -58,7 +55,7 @@ type MessageContent = { conversation_id: string query: string inputs: Record<string, any> - message: { role: string, text: string, files?: VisionFile[] }[] + message: { role: string; text: string; files?: VisionFile[] }[] message_tokens: number answer_tokens: number answer: string @@ -147,7 +144,10 @@ export type CompletionConversationsRequest = { limit: number // The default value is 20 and the range is 1-100 } -export type ChatConversationGeneralDetail = Omit<CompletionConversationGeneralDetail, 'message' | 'annotation'> & { +export type ChatConversationGeneralDetail = Omit< + CompletionConversationGeneralDetail, + 'message' | 'annotation' +> & { summary: string message_count: number annotated: boolean @@ -163,7 +163,10 @@ export type ChatConversationsResponse = { export type ChatConversationsRequest = CompletionConversationsRequest & { message_count: number } -export type ChatConversationFullDetailResponse = Omit<CompletionConversationGeneralDetail, 'message' | 'model_config'> & { +export type ChatConversationFullDetailResponse = Omit< + CompletionConversationGeneralDetail, + 'message' | 'model_config' +> & { message_count: number model_config: { provider: string @@ -352,13 +355,15 @@ export type AgentLogDetailResponse = { files: AgentLogFile[] } -type PauseType = { - type: 'human_input' - form_id: string - backstage_input_url: string -} | { - type: 'breakpoint' -} +type PauseType = + | { + type: 'human_input' + form_id: string + backstage_input_url: string + } + | { + type: 'breakpoint' + } type PauseDetail = { node_id: string diff --git a/web/models/permission.ts b/web/models/permission.ts index 626c8efbdd5936..71e0295e980ef6 100644 --- a/web/models/permission.ts +++ b/web/models/permission.ts @@ -8,4 +8,4 @@ export const PermissionLevel = { partialMembers: 'partial_members', } as const -export type PermissionLevel = typeof PermissionLevel[keyof typeof PermissionLevel] +export type PermissionLevel = (typeof PermissionLevel)[keyof typeof PermissionLevel] diff --git a/web/models/pipeline.ts b/web/models/pipeline.ts index 30389ab5e0b9a9..5d3aa73c3f66b4 100644 --- a/web/models/pipeline.ts +++ b/web/models/pipeline.ts @@ -1,8 +1,18 @@ import type { Viewport } from 'reactflow' import type { DSLImportMode, DSLImportStatus } from './app' -import type { ChunkingMode, DocumentIndexingStatus, FileIndexingEstimateResponse, IconInfo } from './datasets' +import type { + ChunkingMode, + DocumentIndexingStatus, + FileIndexingEstimateResponse, + IconInfo, +} from './datasets' import type { Dependency } from '@/app/components/plugins/types' -import type { Edge, EnvironmentVariable, Node, SupportUploadFileTypes } from '@/app/components/workflow/types' +import type { + Edge, + EnvironmentVariable, + Node, + SupportUploadFileTypes, +} from '@/app/components/workflow/types' import type { TransferMethod } from '@/types/app' import type { NodeRunResult } from '@/types/workflow' import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' diff --git a/web/next.config.ts b/web/next.config.ts index 20665bd84aacf7..011a842e7d605a 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -6,7 +6,7 @@ import { env } from './env' const isDev = process.env.NODE_ENV === 'development' const withMDX = createMDX() const allowedDevOrigins = process.env.NEXT_ALLOWED_DEV_ORIGINS?.split(',') - .map(origin => origin.trim()) + .map((origin) => origin.trim()) .filter(Boolean) const nextConfig: NextConfig = { @@ -39,7 +39,7 @@ const nextConfig: NextConfig = { async headers() { const antiFrame = [ { key: 'X-Frame-Options', value: 'DENY' }, - { key: 'Content-Security-Policy', value: 'frame-ancestors \'none\'' }, + { key: 'Content-Security-Policy', value: "frame-ancestors 'none'" }, ] return [ { source: '/device', headers: antiFrame }, diff --git a/web/plugins/eslint/rules/consistent-placeholders.js b/web/plugins/eslint/rules/consistent-placeholders.js index 441efa8b00d3f4..72bf16964a7ac3 100644 --- a/web/plugins/eslint/rules/consistent-placeholders.js +++ b/web/plugins/eslint/rules/consistent-placeholders.js @@ -4,7 +4,7 @@ import { cleanJsonText } from '../utils.js' function extractPlaceholders(str) { const matches = str.match(/\{\{\w+\}\}/g) || [] - return matches.map(m => m.slice(2, -2)).sort() + return matches.map((m) => m.slice(2, -2)).sort() } function extractTagMarkers(str) { @@ -15,10 +15,8 @@ function extractTagMarkers(str) { const isClosing = fullMatch.startsWith('</') const isSelfClosing = !isClosing && fullMatch.endsWith('/>') - if (isClosing) - return `close:${name}` - if (isSelfClosing) - return `self:${name}` + if (isClosing) return `close:${name}` + if (isSelfClosing) return `self:${name}` return `open:${name}` }) @@ -26,16 +24,13 @@ function extractTagMarkers(str) { } function formatTagMarker(marker) { - if (marker.startsWith('close:')) - return marker.slice('close:'.length) - if (marker.startsWith('self:')) - return marker.slice('self:'.length) + if (marker.startsWith('close:')) return marker.slice('close:'.length) + if (marker.startsWith('self:')) return marker.slice('self:'.length) return marker.slice('open:'.length) } function arraysEqual(arr1, arr2) { - if (arr1.length !== arr2.length) - return false + if (arr1.length !== arr2.length) return false return arr1.every((val, i) => val === arr2[i]) } @@ -44,37 +39,37 @@ function uniqueSorted(items) { } function getJsonLiteralValue(node) { - if (!node) - return undefined + if (!node) return undefined return node.type === 'JSONLiteral' ? node.value : undefined } function buildPlaceholderMessage(key, englishPlaceholders, currentPlaceholders) { - const missing = englishPlaceholders.filter(p => !currentPlaceholders.includes(p)) - const extra = currentPlaceholders.filter(p => !englishPlaceholders.includes(p)) + const missing = englishPlaceholders.filter((p) => !currentPlaceholders.includes(p)) + const extra = currentPlaceholders.filter((p) => !englishPlaceholders.includes(p)) const details = [] - if (missing.length > 0) - details.push(`missing {{${missing.join('}}, {{')}}}`) - if (extra.length > 0) - details.push(`extra {{${extra.join('}}, {{')}}}`) + if (missing.length > 0) details.push(`missing {{${missing.join('}}, {{')}}}`) + if (extra.length > 0) details.push(`extra {{${extra.join('}}, {{')}}}`) - return `Placeholder mismatch with en-US in "${key}": ${details.join('; ')}. ` - + `Expected: {{${englishPlaceholders.join('}}, {{') || 'none'}}}` + return ( + `Placeholder mismatch with en-US in "${key}": ${details.join('; ')}. ` + + `Expected: {{${englishPlaceholders.join('}}, {{') || 'none'}}}` + ) } function buildTagMessage(key, englishTagMarkers, currentTagMarkers) { - const missing = englishTagMarkers.filter(p => !currentTagMarkers.includes(p)) - const extra = currentTagMarkers.filter(p => !englishTagMarkers.includes(p)) + const missing = englishTagMarkers.filter((p) => !currentTagMarkers.includes(p)) + const extra = currentTagMarkers.filter((p) => !englishTagMarkers.includes(p)) const details = [] if (missing.length > 0) details.push(`missing ${uniqueSorted(missing.map(formatTagMarker)).join(', ')}`) - if (extra.length > 0) - details.push(`extra ${uniqueSorted(extra.map(formatTagMarker)).join(', ')}`) + if (extra.length > 0) details.push(`extra ${uniqueSorted(extra.map(formatTagMarker)).join(', ')}`) - return `Trans tag mismatch with en-US in "${key}": ${details.join('; ')}. ` - + `Expected: ${uniqueSorted(englishTagMarkers.map(formatTagMarker)).join(', ') || 'none'}` + return ( + `Trans tag mismatch with en-US in "${key}": ${details.join('; ')}. ` + + `Expected: ${uniqueSorted(englishTagMarkers.map(formatTagMarker)).join(', ') || 'none'}` + ) } export default { @@ -92,28 +87,27 @@ export default { function isTopLevelProperty(node) { const objectNode = node.parent - if (!objectNode || objectNode.type !== 'JSONObjectExpression') - return false + if (!objectNode || objectNode.type !== 'JSONObjectExpression') return false const expressionNode = objectNode.parent - return !!expressionNode - && (expressionNode.type === 'JSONExpressionStatement' - || expressionNode.type === 'Program' - || expressionNode.type === 'JSONProgram') + return ( + !!expressionNode && + (expressionNode.type === 'JSONExpressionStatement' || + expressionNode.type === 'Program' || + expressionNode.type === 'JSONProgram') + ) } return { Program(node) { const { filename } = context - if (!filename.endsWith('.json')) - return + if (!filename.endsWith('.json')) return const parts = normalize(filename).split(sep) const jsonFile = parts.at(-1) const lang = parts.at(-2) - if (lang === 'en-US') - return + if (lang === 'en-US') return state.enabled = true @@ -121,8 +115,7 @@ export default { const englishFilePath = path.join(path.dirname(filename), '..', 'en-US', jsonFile ?? '') const englishText = fs.readFileSync(englishFilePath, 'utf8') state.englishJson = JSON.parse(cleanJsonText(englishText)) - } - catch (error) { + } catch (error) { state.enabled = false context.report({ node, @@ -131,25 +124,20 @@ export default { } }, JSONProperty(node) { - if (!state.enabled) - return + if (!state.enabled) return - if (!state.englishJson || !isTopLevelProperty(node)) - return + if (!state.englishJson || !isTopLevelProperty(node)) return const key = node.key.value ?? node.key.name - if (!key) - return + if (!key) return - if (!Object.prototype.hasOwnProperty.call(state.englishJson, key)) - return + if (!Object.prototype.hasOwnProperty.call(state.englishJson, key)) return const currentNode = node.value ?? node const currentValue = getJsonLiteralValue(currentNode) const englishValue = state.englishJson[key] - if (typeof currentValue !== 'string' || typeof englishValue !== 'string') - return + if (typeof currentValue !== 'string' || typeof englishValue !== 'string') return const currentPlaceholders = extractPlaceholders(currentValue) const englishPlaceholders = extractPlaceholders(englishValue) diff --git a/web/plugins/eslint/rules/no-as-any-in-t.js b/web/plugins/eslint/rules/no-as-any-in-t.js index 5e4ffc8c1cda5b..b8c5e6b0e9fffd 100644 --- a/web/plugins/eslint/rules/no-as-any-in-t.js +++ b/web/plugins/eslint/rules/no-as-any-in-t.js @@ -19,8 +19,7 @@ export default { }, ], messages: { - noAsAnyInT: - 'Avoid using "as any" in t() function calls. Use proper i18n key types instead.', + noAsAnyInT: 'Avoid using "as any" in t() function calls. Use proper i18n key types instead.', noAsInT: 'Avoid using type assertions in t() function calls. Use proper i18n key types instead.', }, @@ -31,13 +30,12 @@ export default { function isTCall(node) { // Direct t() call - if (node.callee.type === 'Identifier' && node.callee.name === 't') - return true + if (node.callee.type === 'Identifier' && node.callee.name === 't') return true // i18n.t() or similar member expression if ( - node.callee.type === 'MemberExpression' - && node.callee.property.type === 'Identifier' - && node.callee.property.name === 't' + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 't' ) { return true } @@ -51,9 +49,9 @@ export default { */ function isAsAny(node) { return ( - node.type === 'TSAsExpression' - && node.typeAnnotation - && node.typeAnnotation.type === 'TSAnyKeyword' + node.type === 'TSAsExpression' && + node.typeAnnotation && + node.typeAnnotation.type === 'TSAnyKeyword' ) } @@ -63,21 +61,18 @@ export default { * @returns {boolean} */ function isAsExpression(node) { - if (node.type !== 'TSAsExpression') - return false + if (node.type !== 'TSAsExpression') return false // Ignore "as const" if (node.typeAnnotation && node.typeAnnotation.type === 'TSTypeReference') { const typeName = node.typeAnnotation.typeName - if (typeName && typeName.type === 'Identifier' && typeName.name === 'const') - return false + if (typeName && typeName.type === 'Identifier' && typeName.name === 'const') return false } return true } return { CallExpression(node) { - if (!isTCall(node) || node.arguments.length === 0) - return + if (!isTCall(node) || node.arguments.length === 0) return const firstArg = node.arguments[0] @@ -89,8 +84,7 @@ export default { messageId: 'noAsInT', }) } - } - else { + } else { // Check only for "as any" if (isAsAny(firstArg)) { context.report({ diff --git a/web/plugins/eslint/rules/no-extra-keys.js b/web/plugins/eslint/rules/no-extra-keys.js index eb47f60934752a..024693417e526b 100644 --- a/web/plugins/eslint/rules/no-extra-keys.js +++ b/web/plugins/eslint/rules/no-extra-keys.js @@ -6,7 +6,7 @@ export default { meta: { type: 'problem', docs: { - description: 'Ensure non-English JSON files don\'t have extra keys not present in en-US', + description: "Ensure non-English JSON files don't have extra keys not present in en-US", }, fixable: 'code', }, @@ -15,8 +15,7 @@ export default { Program(node) { const { filename, sourceCode } = context - if (!filename.endsWith('.json')) - return + if (!filename.endsWith('.json')) return const parts = normalize(filename).split(sep) // e.g., i18n/ar-TN/common.json -> jsonFile = common.json, lang = ar-TN @@ -24,8 +23,7 @@ export default { const lang = parts.at(-2) // Skip English files - if (lang === 'en-US') - return + if (lang === 'en-US') return let currentJson = {} let englishJson = {} @@ -36,8 +34,7 @@ export default { // e.g., i18n/ar-TN/common.json -> i18n/en-US/common.json const englishFilePath = path.join(path.dirname(filename), '..', 'en-US', jsonFile ?? '') englishJson = JSON.parse(fs.readFileSync(englishFilePath, 'utf8')) - } - catch (error) { + } catch (error) { context.report({ node, message: `Error parsing JSON: ${error instanceof Error ? error.message : String(error)}`, @@ -46,7 +43,7 @@ export default { } const extraKeys = Object.keys(currentJson).filter( - key => !Object.prototype.hasOwnProperty.call(englishJson, key), + (key) => !Object.prototype.hasOwnProperty.call(englishJson, key), ) for (const key of extraKeys) { diff --git a/web/plugins/eslint/rules/no-legacy-namespace-prefix.js b/web/plugins/eslint/rules/no-legacy-namespace-prefix.js index 023e6b73d3b274..19cb4d019e6baa 100644 --- a/web/plugins/eslint/rules/no-legacy-namespace-prefix.js +++ b/web/plugins/eslint/rules/no-legacy-namespace-prefix.js @@ -11,7 +11,7 @@ export default { schema: [], messages: { legacyNamespacePrefix: - 'Translation key "{{key}}" should not include namespace prefix. Use t(\'{{localKey}}\') with useTranslation(\'{{ns}}\') instead.', + "Translation key \"{{key}}\" should not include namespace prefix. Use t('{{localKey}}') with useTranslation('{{ns}}') instead.", legacyNamespacePrefixInVariable: 'Variable "{{name}}" contains namespace prefix "{{ns}}". Remove the prefix and use useTranslation(\'{{ns}}\') instead.', }, @@ -33,7 +33,7 @@ export default { // Check if first quasi starts with namespace const extracted = extractNamespace(firstQuasi) if (extracted) { - const fixedQuasis = [extracted.localKey, ...quasis.slice(1).map(q => q.value.raw)] + const fixedQuasis = [extracted.localKey, ...quasis.slice(1).map((q) => q.value.raw)] return { ns: extracted.ns, canFix: true, fixedQuasis, variableToUpdate: null } } @@ -76,23 +76,19 @@ export default { } function hasNsArgument(node) { - if (node.arguments.length < 2) - return false + if (node.arguments.length < 2) return false const secondArg = node.arguments[1] - if (secondArg.type !== 'ObjectExpression') - return false + if (secondArg.type !== 'ObjectExpression') return false return secondArg.properties.some( - prop => prop.type === 'Property' - && prop.key.type === 'Identifier' - && prop.key.name === 'ns', + (prop) => + prop.type === 'Property' && prop.key.type === 'Identifier' && prop.key.name === 'ns', ) } return { // Track variable declarations VariableDeclarator(node) { - if (node.id.type !== 'Identifier' || !node.init) - return + if (node.id.type !== 'Identifier' || !node.init) return // Case 1: Static string literal if (node.init.type === 'Literal' && typeof node.init.value === 'string') { @@ -133,19 +129,15 @@ export default { CallExpression(node) { // Check for t() calls - both direct t() and i18n.t() - const isTCall = ( - node.callee.type === 'Identifier' - && node.callee.name === 't' - ) || ( - node.callee.type === 'MemberExpression' - && node.callee.property.type === 'Identifier' - && node.callee.property.name === 't' - ) + const isTCall = + (node.callee.type === 'Identifier' && node.callee.name === 't') || + (node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 't') if (isTCall && node.arguments.length > 0) { // Skip if already has ns argument - if (hasNsArgument(node)) - return + if (hasNsArgument(node)) return // Unwrap TSAsExpression (e.g., `key as any`) let firstArg = node.arguments[0] @@ -238,8 +230,7 @@ export default { }, 'Program:exit': function (program) { - if (namespacesUsed.size === 0) - return + if (namespacesUsed.size === 0) return // Report variables with namespace prefix (once per variable) for (const [, varInfo] of variablesToFix) { @@ -281,8 +272,7 @@ export default { ) const newTemplate = buildTemplateLiteral(quasis, templateLiteral.expressions) fixes.push(fixer.replaceText(varInfo.node.init, newTemplate)) - } - else { + } else { fixes.push(fixer.replaceText(varInfo.node.init, `'${varInfo.newValue}'`)) } } @@ -308,18 +298,24 @@ export default { if (secondArg.properties.length === 0) { // Empty object: {} -> { ns: 'xxx' } fixes.push(fixer.replaceText(secondArg, `{ ns: '${ns}' }`)) - } - else { + } else { // Non-empty object: { foo } -> { ns: 'xxx', foo } const firstProp = secondArg.properties[0] fixes.push(fixer.insertTextBefore(firstProp, `ns: '${ns}', `)) } - } - else if (hasSecondArg && secondArg.type === 'Literal' && typeof secondArg.value === 'string') { + } else if ( + hasSecondArg && + secondArg.type === 'Literal' && + typeof secondArg.value === 'string' + ) { // Second arg is a string (default value): 'default' -> { ns: 'xxx', defaultValue: 'default' } - fixes.push(fixer.replaceText(secondArg, `{ ns: '${ns}', defaultValue: ${sourceCode.getText(secondArg)} }`)) - } - else if (!hasSecondArg) { + fixes.push( + fixer.replaceText( + secondArg, + `{ ns: '${ns}', defaultValue: ${sourceCode.getText(secondArg)} }`, + ), + ) + } else if (!hasSecondArg) { // No second argument, add new object fixes.push(fixer.insertTextAfter(originalFirstArg, `, { ns: '${ns}' }`)) } @@ -331,46 +327,49 @@ export default { if (extracted) { // Replace key (preserve as any if present) if (hasTsAs) { - fixes.push(fixer.replaceText(originalFirstArg, `'${extracted.localKey}' as any`)) - } - else { + fixes.push( + fixer.replaceText(originalFirstArg, `'${extracted.localKey}' as any`), + ) + } else { fixes.push(fixer.replaceText(firstArg, `'${extracted.localKey}'`)) } // Add ns addNsToArgs(extracted.ns) } - } - else if (firstArg.type === 'TemplateLiteral') { + } else if (firstArg.type === 'TemplateLiteral') { const analysis = analyzeTemplateLiteral(firstArg) if (analysis.canFix && analysis.fixedQuasis) { // For template literals with namespace prefix directly in template - const newTemplate = buildTemplateLiteral(analysis.fixedQuasis, firstArg.expressions) + const newTemplate = buildTemplateLiteral( + analysis.fixedQuasis, + firstArg.expressions, + ) if (hasTsAs) { fixes.push(fixer.replaceText(originalFirstArg, `${newTemplate} as any`)) - } - else { + } else { fixes.push(fixer.replaceText(firstArg, newTemplate)) } addNsToArgs(analysis.ns) - } - else if (analysis.canFix && analysis.variableToUpdate) { + } else if (analysis.canFix && analysis.variableToUpdate) { // Variable's namespace prefix is being removed - const quasis = firstArg.quasis.map(q => q.value.raw) + const quasis = firstArg.quasis.map((q) => q.value.raw) // If variable becomes empty and next quasi starts with '.', remove the dot - if (analysis.variableToUpdate.newValue === '' && quasis.length > 1 && quasis[1].startsWith('.')) { + if ( + analysis.variableToUpdate.newValue === '' && + quasis.length > 1 && + quasis[1].startsWith('.') + ) { quasis[1] = quasis[1].slice(1) } const newTemplate = buildTemplateLiteral(quasis, firstArg.expressions) if (hasTsAs) { fixes.push(fixer.replaceText(originalFirstArg, `${newTemplate} as any`)) - } - else { + } else { fixes.push(fixer.replaceText(firstArg, newTemplate)) } addNsToArgs(analysis.ns) } - } - else if (firstArg.type === 'ConditionalExpression') { + } else if (firstArg.type === 'ConditionalExpression') { const consequent = firstArg.consequent const alternate = firstArg.alternate let ns = null diff --git a/web/plugins/eslint/rules/require-ns-option.js b/web/plugins/eslint/rules/require-ns-option.js index 74621596fd9454..fa060b98168ce6 100644 --- a/web/plugins/eslint/rules/require-ns-option.js +++ b/web/plugins/eslint/rules/require-ns-option.js @@ -8,34 +8,28 @@ export default { schema: [], messages: { missingNsOption: - 'Translation call is missing { ns: \'xxx\' } option. Add a second argument with ns property.', + "Translation call is missing { ns: 'xxx' } option. Add a second argument with ns property.", }, }, create(context) { function hasNsOption(node) { - if (node.arguments.length < 2) - return false + if (node.arguments.length < 2) return false const secondArg = node.arguments[1] - if (secondArg.type !== 'ObjectExpression') - return false + if (secondArg.type !== 'ObjectExpression') return false return secondArg.properties.some( - prop => prop.type === 'Property' - && prop.key.type === 'Identifier' - && prop.key.name === 'ns', + (prop) => + prop.type === 'Property' && prop.key.type === 'Identifier' && prop.key.name === 'ns', ) } return { CallExpression(node) { // Check for t() calls - both direct t() and i18n.t() - const isTCall = ( - node.callee.type === 'Identifier' - && node.callee.name === 't' - ) || ( - node.callee.type === 'MemberExpression' - && node.callee.property.type === 'Identifier' - && node.callee.property.name === 't' - ) + const isTCall = + (node.callee.type === 'Identifier' && node.callee.name === 't') || + (node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 't') if (isTCall && node.arguments.length > 0) { if (!hasNsOption(node)) { diff --git a/web/plugins/eslint/utils.js b/web/plugins/eslint/utils.js index 2030c96b5a5a89..e0a20c8c580237 100644 --- a/web/plugins/eslint/utils.js +++ b/web/plugins/eslint/utils.js @@ -3,8 +3,7 @@ export const cleanJsonText = (text) => { try { JSON.parse(cleaned) return cleaned - } - catch { + } catch { return text } } diff --git a/web/plugins/vite/code-inspector.ts b/web/plugins/vite/code-inspector.ts index 180e8d37cbed9c..7386d156bcb019 100644 --- a/web/plugins/vite/code-inspector.ts +++ b/web/plugins/vite/code-inspector.ts @@ -26,16 +26,12 @@ export const createCodeInspectorPlugin = ({ } const getInspectorRuntimeSnippet = (runtimeFile: string): string => { - if (!fs.existsSync(runtimeFile)) - return '' + if (!fs.existsSync(runtimeFile)) return '' const raw = fs.readFileSync(runtimeFile, 'utf-8') // Strip the helper component default export to avoid duplicate default exports after injection. - return raw.replace( - /\s*export default function CodeInspectorEmptyElement\(\)\s*\{[\s\S]*$/, - '', - ) + return raw.replace(/\s*export default function CodeInspectorEmptyElement\(\)\s*\{[\s\S]*$/, '') } export const createForceInspectorClientInjectionPlugin = ({ @@ -54,16 +50,13 @@ export const createForceInspectorClientInjectionPlugin = ({ apply: 'serve', enforce: 'pre', transform(code, id) { - if (!clientSnippet) - return null + if (!clientSnippet) return null const cleanId = normalizeViteModuleId(id) - if (cleanId !== injectTarget) - return null + if (cleanId !== injectTarget) return null const nextCode = injectClientSnippet(code, 'code-inspector-component', clientSnippet) - if (nextCode === code) - return null + if (nextCode === code) return null return { code: nextCode, map: null } }, diff --git a/web/plugins/vite/custom-i18n-hmr.ts b/web/plugins/vite/custom-i18n-hmr.ts index d3e55b4cc4718b..701a187e9b18e3 100644 --- a/web/plugins/vite/custom-i18n-hmr.ts +++ b/web/plugins/vite/custom-i18n-hmr.ts @@ -68,12 +68,10 @@ if (import.meta.hot) { }, transform(code, id) { const cleanId = normalizeViteModuleId(id) - if (cleanId !== injectTarget) - return null + if (cleanId !== injectTarget) return null const nextCode = injectClientSnippet(code, i18nHmrClientMarker, i18nHmrClientSnippet) - if (nextCode === code) - return null + if (nextCode === code) return null return { code: nextCode, map: null } }, } diff --git a/web/plugins/vite/next-static-image-test.ts b/web/plugins/vite/next-static-image-test.ts index d5323e33125d69..beb67ee45dd7c2 100644 --- a/web/plugins/vite/next-static-image-test.ts +++ b/web/plugins/vite/next-static-image-test.ts @@ -9,17 +9,17 @@ type NextStaticImageTestPluginOptions = { const STATIC_ASSET_RE = /\.(?:svg|png|jpe?g|gif)$/i const EXCLUDED_QUERY_RE = /[?&](?:raw|url)\b/ -export const nextStaticImageTestPlugin = ({ projectRoot }: NextStaticImageTestPluginOptions): Plugin => { +export const nextStaticImageTestPlugin = ({ + projectRoot, +}: NextStaticImageTestPluginOptions): Plugin => { return { name: 'next-static-image-test', enforce: 'pre', load(id) { - if (EXCLUDED_QUERY_RE.test(id)) - return null + if (EXCLUDED_QUERY_RE.test(id)) return null const cleanId = normalizeViteModuleId(id) - if (!cleanId.startsWith(projectRoot) || !STATIC_ASSET_RE.test(cleanId)) - return null + if (!cleanId.startsWith(projectRoot) || !STATIC_ASSET_RE.test(cleanId)) return null const relativePath = path.relative(projectRoot, cleanId).split(path.sep).join('/') const src = `/__static__/${relativePath}` diff --git a/web/plugins/vite/utils.ts b/web/plugins/vite/utils.ts index 67f00074f98555..b4f900dc0cd38a 100644 --- a/web/plugins/vite/utils.ts +++ b/web/plugins/vite/utils.ts @@ -1,19 +1,16 @@ export const normalizeViteModuleId = (id: string): string => { const withoutQuery = id.split('?', 1)[0]! - if (withoutQuery!.startsWith('/@fs/')) - return withoutQuery!.slice('/@fs'.length) + if (withoutQuery!.startsWith('/@fs/')) return withoutQuery!.slice('/@fs'.length) return withoutQuery } export const injectClientSnippet = (code: string, marker: string, snippet: string): string => { - if (code.includes(marker)) - return code + if (code.includes(marker)) return code const useClientMatch = code.match(/(['"])use client\1;?\s*\n/) - if (!useClientMatch) - return `${snippet}\n${code}` + if (!useClientMatch) return `${snippet}\n${code}` const insertAt = (useClientMatch.index ?? 0) + useClientMatch[0].length return `${code.slice(0, insertAt)}\n${snippet}\n${code.slice(insertAt)}` diff --git a/web/proxy.ts b/web/proxy.ts index 8288bea5f23fb5..f945ae9474bc02 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -5,15 +5,18 @@ import { Buffer } from 'node:buffer' import { NextResponse } from 'next/server' import { env } from '@/env' -const NECESSARY_DOMAIN = '*.sentry.io http://localhost:* http://127.0.0.1:* https://analytics.google.com googletagmanager.com *.googletagmanager.com https://www.google-analytics.com https://ungh.cc https://api2.amplitude.com *.amplitude.com' +const NECESSARY_DOMAIN = + '*.sentry.io http://localhost:* http://127.0.0.1:* https://analytics.google.com googletagmanager.com *.googletagmanager.com https://www.google-analytics.com https://ungh.cc https://api2.amplitude.com *.amplitude.com' const CURRENT_PATHNAME_HEADER = 'x-dify-pathname' const CURRENT_SEARCH_HEADER = 'x-dify-search' const EMBEDDABLE_PATH_PREFIXES = ['/chat', '/workflow', '/completion', '/webapp-signin'] const EMBEDDABLE_PATH_SEGMENTS = ['/agent'] export const canEmbedPath = (pathname: string) => - EMBEDDABLE_PATH_PREFIXES.some(prefix => pathname.startsWith(prefix)) - || EMBEDDABLE_PATH_SEGMENTS.some(segment => pathname === segment || pathname.startsWith(`${segment}/`)) + EMBEDDABLE_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix)) || + EMBEDDABLE_PATH_SEGMENTS.some( + (segment) => pathname === segment || pathname.startsWith(`${segment}/`), + ) const wrapResponseWithXFrameOptions = (response: NextResponse, pathname: string) => { // prevent clickjacking: https://owasp.org/www-community/attacks/Clickjacking @@ -29,7 +32,8 @@ export function proxy(request: NextRequest) { requestHeaders.set(CURRENT_PATHNAME_HEADER, pathname) requestHeaders.set(CURRENT_SEARCH_HEADER, search) - const isWhiteListEnabled = !!env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production' + const isWhiteListEnabled = + !!env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production' if (!isWhiteListEnabled) { const response = NextResponse.next({ request: { @@ -60,16 +64,11 @@ export function proxy(request: NextRequest) { upgrade-insecure-requests; ` // Replace newline characters and spaces - const contentSecurityPolicyHeaderValue = cspHeader - .replace(/\s{2,}/g, ' ') - .trim() + const contentSecurityPolicyHeaderValue = cspHeader.replace(/\s{2,}/g, ' ').trim() requestHeaders.set('x-nonce', nonce) - requestHeaders.set( - 'Content-Security-Policy', - contentSecurityPolicyHeaderValue, - ) + requestHeaders.set('Content-Security-Policy', contentSecurityPolicyHeaderValue) const response = NextResponse.next({ request: { @@ -77,10 +76,7 @@ export function proxy(request: NextRequest) { }, }) - response.headers.set( - 'Content-Security-Policy', - contentSecurityPolicyHeaderValue, - ) + response.headers.set('Content-Security-Policy', contentSecurityPolicyHeaderValue) return wrapResponseWithXFrameOptions(response, pathname) } diff --git a/web/public/_offline.html b/web/public/_offline.html index f68a694e3a5b33..95dbfe3390a42c 100644 --- a/web/public/_offline.html +++ b/web/public/_offline.html @@ -1,129 +1,128 @@ -<!DOCTYPE html> +<!doctype html> <html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Dify - Offline - - + +
-
- ⚡ -
-

You're Offline

-

- It looks like you've lost your internet connection. - Some features may not be available until you're back online. -

- +
+

You're Offline

+

+ It looks like you've lost your internet connection. Some features may not be available until + you're back online. +

+
- + - - \ No newline at end of file + + diff --git a/web/public/embed.js b/web/public/embed.js index 945058e975a2c7..ca1f180f3449b6 100644 --- a/web/public/embed.js +++ b/web/public/embed.js @@ -6,13 +6,13 @@ // attention: This JavaScript script must be placed after the element. Otherwise, the script will not work. -(function () { +;(function () { // Constants for DOM element IDs and configuration key - const configKey = "difyChatbotConfig"; - const buttonId = "dify-chatbot-bubble-button"; - const iframeId = "dify-chatbot-bubble-window"; - const config = window[configKey]; - let isExpanded = false; + const configKey = 'difyChatbotConfig' + const buttonId = 'dify-chatbot-bubble-button' + const iframeId = 'dify-chatbot-bubble-window' + const config = window[configKey] + let isExpanded = false // SVG icons for open and close states const svgIcons = ` @@ -21,8 +21,7 @@ - `; - + ` const originalIframeStyleText = ` position: absolute; @@ -77,148 +76,147 @@ let isDragging = false if (!config || !config.token) { - console.error(`${configKey} is empty or token is not provided`); - return; + console.error(`${configKey} is empty or token is not provided`) + return } async function compressAndEncodeBase64(input) { - const uint8Array = new TextEncoder().encode(input); + const uint8Array = new TextEncoder().encode(input) const compressedStream = new Response( - new Blob([uint8Array]) - .stream() - .pipeThrough(new CompressionStream("gzip")) - ).arrayBuffer(); - const compressedUint8Array = new Uint8Array(await compressedStream); - return btoa(String.fromCharCode(...compressedUint8Array)); + new Blob([uint8Array]).stream().pipeThrough(new CompressionStream('gzip')), + ).arrayBuffer() + const compressedUint8Array = new Uint8Array(await compressedStream) + return btoa(String.fromCharCode(...compressedUint8Array)) } async function getCompressedInputsFromConfig() { - const inputs = config?.inputs || {}; - const compressedInputs = {}; + const inputs = config?.inputs || {} + const compressedInputs = {} await Promise.all( Object.entries(inputs).map(async ([key, value]) => { - compressedInputs[key] = await compressAndEncodeBase64(value); - }) - ); - return compressedInputs; + compressedInputs[key] = await compressAndEncodeBase64(value) + }), + ) + return compressedInputs } async function getCompressedSystemVariablesFromConfig() { - const systemVariables = config?.systemVariables || {}; - const compressedSystemVariables = {}; + const systemVariables = config?.systemVariables || {} + const compressedSystemVariables = {} await Promise.all( Object.entries(systemVariables).map(async ([key, value]) => { - compressedSystemVariables[`sys.${key}`] = await compressAndEncodeBase64(value); - }) - ); - return compressedSystemVariables; + compressedSystemVariables[`sys.${key}`] = await compressAndEncodeBase64(value) + }), + ) + return compressedSystemVariables } async function getCompressedUserVariablesFromConfig() { - const userVariables = config?.userVariables || {}; - const compressedUserVariables = {}; + const userVariables = config?.userVariables || {} + const compressedUserVariables = {} await Promise.all( Object.entries(userVariables).map(async ([key, value]) => { - compressedUserVariables[`user.${key}`] = await compressAndEncodeBase64(value); - }) - ); - return compressedUserVariables; + compressedUserVariables[`user.${key}`] = await compressAndEncodeBase64(value) + }), + ) + return compressedUserVariables } const params = new URLSearchParams({ - ...await getCompressedInputsFromConfig(), - ...await getCompressedSystemVariablesFromConfig(), - ...await getCompressedUserVariablesFromConfig() - }); + ...(await getCompressedInputsFromConfig()), + ...(await getCompressedSystemVariablesFromConfig()), + ...(await getCompressedUserVariablesFromConfig()), + }) - const baseUrl = - config.baseUrl || `https://${config.isDev ? "dev." : ""}udify.app`; - const routeSegment = (config.routeSegment || "chatbot").replace(/^\/+|\/+$/g, "") || "chatbot"; - const targetOrigin = new URL(baseUrl).origin; + const baseUrl = config.baseUrl || `https://${config.isDev ? 'dev.' : ''}udify.app` + const routeSegment = (config.routeSegment || 'chatbot').replace(/^\/+|\/+$/g, '') || 'chatbot' + const targetOrigin = new URL(baseUrl).origin // Pass sendOnEnter config as URL parameter if (config.sendOnEnter === false) { - params.set('sendOnEnter', 'false'); + params.set('sendOnEnter', 'false') } // pre-check the length of the URL - const iframeUrl = `${baseUrl}/${routeSegment}/${config.token}?${params}`; + const iframeUrl = `${baseUrl}/${routeSegment}/${config.token}?${params}` // 1) CREATE the iframe immediately, so it can load in the background: - const preloadedIframe = createIframe(); + const preloadedIframe = createIframe() // 2) HIDE it by default: - preloadedIframe.style.display = "none"; + preloadedIframe.style.display = 'none' // 3) APPEND it to the document body right away: - document.body.appendChild(preloadedIframe); + document.body.appendChild(preloadedIframe) // ─── End Fix Snippet if (iframeUrl.length > 2048) { - console.error("The URL is too long, please reduce the number of inputs to prevent the bot from failing to load"); + console.error( + 'The URL is too long, please reduce the number of inputs to prevent the bot from failing to load', + ) } // Function to create the iframe for the chatbot function createIframe() { - const iframe = document.createElement("iframe"); - iframe.allow = "fullscreen;microphone"; - iframe.title = "dify chatbot bubble window"; - iframe.id = iframeId; - iframe.src = iframeUrl; - iframe.style.cssText = originalIframeStyleText; - - return iframe; + const iframe = document.createElement('iframe') + iframe.allow = 'fullscreen;microphone' + iframe.title = 'dify chatbot bubble window' + iframe.id = iframeId + iframe.src = iframeUrl + iframe.style.cssText = originalIframeStyleText + + return iframe } // Function to reset the iframe position function resetIframePosition() { - if (window.innerWidth <= 640) return; + if (window.innerWidth <= 640) return - const targetIframe = document.getElementById(iframeId); - const targetButton = document.getElementById(buttonId); + const targetIframe = document.getElementById(iframeId) + const targetButton = document.getElementById(buttonId) if (targetIframe && targetButton) { - const buttonRect = targetButton.getBoundingClientRect(); + const buttonRect = targetButton.getBoundingClientRect() // We don't necessarily need iframeRect anymore with the center logic - const viewportCenterY = window.innerHeight / 2; - const buttonCenterY = buttonRect.top + buttonRect.height / 2; + const viewportCenterY = window.innerHeight / 2 + const buttonCenterY = buttonRect.top + buttonRect.height / 2 if (buttonCenterY < viewportCenterY) { - targetIframe.style.top = `var(--${buttonId}-bottom, 1rem)`; - targetIframe.style.bottom = 'unset'; + targetIframe.style.top = `var(--${buttonId}-bottom, 1rem)` + targetIframe.style.bottom = 'unset' } else { - targetIframe.style.bottom = `var(--${buttonId}-bottom, 1rem)`; - targetIframe.style.top = 'unset'; + targetIframe.style.bottom = `var(--${buttonId}-bottom, 1rem)` + targetIframe.style.top = 'unset' } - const viewportCenterX = window.innerWidth / 2; - const buttonCenterX = buttonRect.left + buttonRect.width / 2; + const viewportCenterX = window.innerWidth / 2 + const buttonCenterX = buttonRect.left + buttonRect.width / 2 if (buttonCenterX < viewportCenterX) { - targetIframe.style.left = `var(--${buttonId}-right, 1rem)`; - targetIframe.style.right = 'unset'; + targetIframe.style.left = `var(--${buttonId}-right, 1rem)` + targetIframe.style.right = 'unset' } else { - targetIframe.style.right = `var(--${buttonId}-right, 1rem)`; - targetIframe.style.left = 'unset'; + targetIframe.style.right = `var(--${buttonId}-right, 1rem)` + targetIframe.style.left = 'unset' } } } function toggleExpand() { - isExpanded = !isExpanded; + isExpanded = !isExpanded - const targetIframe = document.getElementById(iframeId); - if (!targetIframe) return; + const targetIframe = document.getElementById(iframeId) + if (!targetIframe) return if (isExpanded) { - targetIframe.style.cssText = expandedIframeStyleText; + targetIframe.style.cssText = expandedIframeStyleText } else { - targetIframe.style.cssText = originalIframeStyleText; + targetIframe.style.cssText = originalIframeStyleText } - resetIframePosition(); + resetIframePosition() } window.addEventListener('message', (event) => { - if (event.origin !== targetOrigin) return; + if (event.origin !== targetOrigin) return - const targetIframe = document.getElementById(iframeId); - if (!targetIframe || event.source !== targetIframe.contentWindow) return; + const targetIframe = document.getElementById(iframeId) + if (!targetIframe || event.source !== targetIframe.contentWindow) return if (event.data.type === 'dify-chatbot-iframe-ready') { targetIframe.contentWindow?.postMessage( @@ -229,43 +227,40 @@ isDraggable: !!config.draggable, }, }, - targetOrigin - ); + targetOrigin, + ) } if (event.data.type === 'dify-chatbot-expand-change') { - toggleExpand(); + toggleExpand() } - }); + }) // Function to create the chat button function createButton() { - const containerDiv = document.createElement("div"); + const containerDiv = document.createElement('div') // Apply custom properties from config Object.entries(config.containerProps || {}).forEach(([key, value]) => { - if (key === "className") { - containerDiv.classList.add(...value.split(" ")); - } else if (key === "style") { - if (typeof value === "object") { - Object.assign(containerDiv.style, value); + if (key === 'className') { + containerDiv.classList.add(...value.split(' ')) + } else if (key === 'style') { + if (typeof value === 'object') { + Object.assign(containerDiv.style, value) } else { - containerDiv.style.cssText = value; + containerDiv.style.cssText = value } - } else if (typeof value === "function") { - containerDiv.addEventListener( - key.replace(/^on/, "").toLowerCase(), - value - ); + } else if (typeof value === 'function') { + containerDiv.addEventListener(key.replace(/^on/, '').toLowerCase(), value) } else { - containerDiv[key] = value; + containerDiv[key] = value } - }); + }) - containerDiv.id = buttonId; + containerDiv.id = buttonId // Add styles for the button - const styleSheet = document.createElement("style"); - document.head.appendChild(styleSheet); + const styleSheet = document.createElement('style') + document.head.appendChild(styleSheet) styleSheet.sheet.insertRule(` #${containerDiv.id} { position: fixed; @@ -281,183 +276,186 @@ cursor: pointer; z-index: 2147483647; } - `); + `) // Create display div for the button icon - const displayDiv = document.createElement("div"); + const displayDiv = document.createElement('div') displayDiv.style.cssText = - "position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; z-index: 2147483647;"; - displayDiv.innerHTML = svgIcons; - containerDiv.appendChild(displayDiv); - document.body.appendChild(containerDiv); + 'position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; z-index: 2147483647;' + displayDiv.innerHTML = svgIcons + containerDiv.appendChild(displayDiv) + document.body.appendChild(containerDiv) // Add click event listener to toggle chatbot - containerDiv.addEventListener("click", handleClick); + containerDiv.addEventListener('click', handleClick) // Add touch event listener - containerDiv.addEventListener("touchend", (event) => { - event.preventDefault(); - handleClick(); - }, { passive: false }); + containerDiv.addEventListener( + 'touchend', + (event) => { + event.preventDefault() + handleClick() + }, + { passive: false }, + ) function handleClick() { - if (isDragging) return; + if (isDragging) return - const targetIframe = document.getElementById(iframeId); + const targetIframe = document.getElementById(iframeId) if (!targetIframe) { - containerDiv.appendChild(createIframe()); - resetIframePosition(); - this.title = "Exit (ESC)"; - setSvgIcon("close"); - document.addEventListener("keydown", handleEscKey); - return; + containerDiv.appendChild(createIframe()) + resetIframePosition() + this.title = 'Exit (ESC)' + setSvgIcon('close') + document.addEventListener('keydown', handleEscKey) + return } - targetIframe.style.display = - targetIframe.style.display === "none" ? "block" : "none"; - if (targetIframe.style.display === "none") { - setSvgIcon("open") + targetIframe.style.display = targetIframe.style.display === 'none' ? 'block' : 'none' + if (targetIframe.style.display === 'none') { + setSvgIcon('open') } else { - setSvgIcon("close") + setSvgIcon('close') } - if (targetIframe.style.display === "none") { - document.removeEventListener("keydown", handleEscKey); + if (targetIframe.style.display === 'none') { + document.removeEventListener('keydown', handleEscKey) } else { - document.addEventListener("keydown", handleEscKey); + document.addEventListener('keydown', handleEscKey) } - resetIframePosition(); + resetIframePosition() } // Enable dragging if specified in config if (config.draggable) { - enableDragging(containerDiv, config.dragAxis || "both"); + enableDragging(containerDiv, config.dragAxis || 'both') } } // Function to enable dragging of the chat button function enableDragging(element, axis) { - let startX, startY, startClientX, startClientY; + let startX, startY, startClientX, startClientY - element.addEventListener("mousedown", startDragging); - element.addEventListener("touchstart", startDragging); + element.addEventListener('mousedown', startDragging) + element.addEventListener('touchstart', startDragging) function startDragging(e) { - isDragging = false; - if (e.type === "touchstart") { - startX = e.touches[0].clientX - element.offsetLeft; - startY = e.touches[0].clientY - element.offsetTop; - startClientX = e.touches[0].clientX; - startClientY = e.touches[0].clientY; + isDragging = false + if (e.type === 'touchstart') { + startX = e.touches[0].clientX - element.offsetLeft + startY = e.touches[0].clientY - element.offsetTop + startClientX = e.touches[0].clientX + startClientY = e.touches[0].clientY } else { - startX = e.clientX - element.offsetLeft; - startY = e.clientY - element.offsetTop; - startClientX = e.clientX; - startClientY = e.clientY; + startX = e.clientX - element.offsetLeft + startY = e.clientY - element.offsetTop + startClientX = e.clientX + startClientY = e.clientY } - document.addEventListener("mousemove", drag); - document.addEventListener("touchmove", drag, { passive: false }); - document.addEventListener("mouseup", stopDragging); - document.addEventListener("touchend", stopDragging); - e.preventDefault(); + document.addEventListener('mousemove', drag) + document.addEventListener('touchmove', drag, { passive: false }) + document.addEventListener('mouseup', stopDragging) + document.addEventListener('touchend', stopDragging) + e.preventDefault() } function drag(e) { - const touch = e.type === "touchmove" ? e.touches[0] : e; - const deltaX = touch.clientX - startClientX; - const deltaY = touch.clientY - startClientY; + const touch = e.type === 'touchmove' ? e.touches[0] : e + const deltaX = touch.clientX - startClientX + const deltaY = touch.clientY - startClientY // Determine whether it is a drag operation if (Math.abs(deltaX) > 8 || Math.abs(deltaY) > 8) { - isDragging = true; + isDragging = true } - if (!isDragging) return; + if (!isDragging) return - element.style.transition = "none"; - element.style.cursor = "grabbing"; + element.style.transition = 'none' + element.style.cursor = 'grabbing' // Hide iframe while dragging - const targetIframe = document.getElementById(iframeId); + const targetIframe = document.getElementById(iframeId) if (targetIframe) { - targetIframe.style.display = "none"; - setSvgIcon("open"); + targetIframe.style.display = 'none' + setSvgIcon('open') } - let newLeft, newBottom; - if (e.type === "touchmove") { - newLeft = e.touches[0].clientX - startX; - newBottom = window.innerHeight - e.touches[0].clientY - startY; + let newLeft, newBottom + if (e.type === 'touchmove') { + newLeft = e.touches[0].clientX - startX + newBottom = window.innerHeight - e.touches[0].clientY - startY } else { - newLeft = e.clientX - startX; - newBottom = window.innerHeight - e.clientY - startY; + newLeft = e.clientX - startX + newBottom = window.innerHeight - e.clientY - startY } - const elementRect = element.getBoundingClientRect(); - const maxX = window.innerWidth - elementRect.width; - const maxY = window.innerHeight - elementRect.height; + const elementRect = element.getBoundingClientRect() + const maxX = window.innerWidth - elementRect.width + const maxY = window.innerHeight - elementRect.height // Update position based on drag axis - if (axis === "x" || axis === "both") { + if (axis === 'x' || axis === 'both') { element.style.setProperty( `--${buttonId}-left`, - `${Math.max(0, Math.min(newLeft, maxX))}px` - ); + `${Math.max(0, Math.min(newLeft, maxX))}px`, + ) } - if (axis === "y" || axis === "both") { + if (axis === 'y' || axis === 'both') { element.style.setProperty( `--${buttonId}-bottom`, - `${Math.max(0, Math.min(newBottom, maxY))}px` - ); + `${Math.max(0, Math.min(newBottom, maxY))}px`, + ) } } function stopDragging() { setTimeout(() => { - isDragging = false; - }, 0); - element.style.transition = ""; - element.style.cursor = "pointer"; - - document.removeEventListener("mousemove", drag); - document.removeEventListener("touchmove", drag); - document.removeEventListener("mouseup", stopDragging); - document.removeEventListener("touchend", stopDragging); + isDragging = false + }, 0) + element.style.transition = '' + element.style.cursor = 'pointer' + + document.removeEventListener('mousemove', drag) + document.removeEventListener('touchmove', drag) + document.removeEventListener('mouseup', stopDragging) + document.removeEventListener('touchend', stopDragging) } } // Create the chat button if it doesn't exist if (!document.getElementById(buttonId)) { - createButton(); + createButton() } } - function setSvgIcon(type = "open") { - if (type === "open") { - document.getElementById("openIcon").style.display = "block"; - document.getElementById("closeIcon").style.display = "none"; + function setSvgIcon(type = 'open') { + if (type === 'open') { + document.getElementById('openIcon').style.display = 'block' + document.getElementById('closeIcon').style.display = 'none' } else { - document.getElementById("openIcon").style.display = "none"; - document.getElementById("closeIcon").style.display = "block"; + document.getElementById('openIcon').style.display = 'none' + document.getElementById('closeIcon').style.display = 'block' } } // Add esc Exit keyboard event triggered function handleEscKey(event) { - if (event.key === "Escape") { - const targetIframe = document.getElementById(iframeId); - if (targetIframe && targetIframe.style.display !== "none") { - targetIframe.style.display = "none"; - setSvgIcon("open"); + if (event.key === 'Escape') { + const targetIframe = document.getElementById(iframeId) + if (targetIframe && targetIframe.style.display !== 'none') { + targetIframe.style.display = 'none' + setSvgIcon('open') } } } - document.addEventListener("keydown", handleEscKey); + document.addEventListener('keydown', handleEscKey) // Set the embedChatbot function to run when the body is loaded,Avoid infinite nesting if (config?.dynamicScript) { - embedChatbot(); + embedChatbot() } else { - document.body.onload = embedChatbot; + document.body.onload = embedChatbot } -})(); +})() diff --git a/web/public/manifest.json b/web/public/manifest.json index a9f1f324360742..8a4b80d80dd3ed 100644 --- a/web/public/manifest.json +++ b/web/public/manifest.json @@ -55,4 +55,4 @@ "icons": [{ "src": "/icon-96x96.png", "sizes": "96x96" }] } ] -} \ No newline at end of file +} diff --git a/web/scripts/__tests__/migrate-i18n-selectors.spec.ts b/web/scripts/__tests__/migrate-i18n-selectors.spec.ts index 0b52aad34454f7..84b4d4448ae2d2 100644 --- a/web/scripts/__tests__/migrate-i18n-selectors.spec.ts +++ b/web/scripts/__tests__/migrate-i18n-selectors.spec.ts @@ -334,7 +334,9 @@ vi.mock('#i18n', () => ({ // Assert expect(result.output).toContain(`vi.mock('#i18n', async () => {`) - expect(result.output).toContain(`const { withSelectorKey } = await import('@/test/i18n-mock')`) + expect(result.output).toContain( + `const { withSelectorKey } = await import('@/test/i18n-mock')`, + ) expect(result.output).toContain('t: withSelectorKey((key: string) => key)') expect(transformSource(result.output, 'example.spec.ts')).toEqual({ changes: 0, @@ -359,7 +361,9 @@ vi.mock('#i18n', () => ({ const result = transformSource(source, 'example.spec.ts') // Assert - expect(result.output).toContain('t: withSelectorKey((...args: Parameters) => mockTranslation(...args))') + expect(result.output).toContain( + 't: withSelectorKey((...args: Parameters) => mockTranslation(...args))', + ) expect(transformSource(result.output, 'example.spec.ts')).toEqual({ changes: 0, output: result.output, @@ -418,14 +422,17 @@ vi.mock('react-i18next', () => ({ // Act const result = transformSource(source, 'example.spec.tsx') - const diagnostics = ts.transpileModule(result.output, { - compilerOptions: { jsx: ts.JsxEmit.ReactJSX }, - fileName: 'example.spec.tsx', - reportDiagnostics: true, - }).diagnostics ?? [] + const diagnostics = + ts.transpileModule(result.output, { + compilerOptions: { jsx: ts.JsxEmit.ReactJSX }, + fileName: 'example.spec.tsx', + reportDiagnostics: true, + }).diagnostics ?? [] // Assert - expect(diagnostics.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error)).toEqual([]) + expect( + diagnostics.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error), + ).toEqual([]) expect(result.output).toContain('{components?.Key}') expect(result.output).not.toMatch(/[ \t]+$/m) expect(transformSource(result.output, 'example.spec.tsx')).toEqual({ diff --git a/web/scripts/__tests__/prune-unused-i18n.spec.ts b/web/scripts/__tests__/prune-unused-i18n.spec.ts index bbd474856eb702..09a833f4546bee 100644 --- a/web/scripts/__tests__/prune-unused-i18n.spec.ts +++ b/web/scripts/__tests__/prune-unused-i18n.spec.ts @@ -8,11 +8,7 @@ let webRoot: string function writeJson(relativePath: string, value: Record) { mkdirSync(path.dirname(path.join(webRoot, relativePath)), { recursive: true }) - writeFileSync( - path.join(webRoot, relativePath), - `${JSON.stringify(value, null, 2)}\n`, - 'utf8', - ) + writeFileSync(path.join(webRoot, relativePath), `${JSON.stringify(value, null, 2)}\n`, 'utf8') } function writeSource(relativePath: string, content: string) { @@ -20,10 +16,14 @@ function writeSource(relativePath: string, content: string) { writeFileSync(path.join(webRoot, relativePath), content, 'utf8') } -function sortedUnusedKeysByNamespace(result: Awaited>) { +function sortedUnusedKeysByNamespace( + result: Awaited>, +) { return Object.fromEntries( - Object.entries(result.unusedKeysByNamespace) - .map(([namespace, keys]) => [namespace, [...keys].sort()]), + Object.entries(result.unusedKeysByNamespace).map(([namespace, keys]) => [ + namespace, + [...keys].sort(), + ]), ) } @@ -45,7 +45,9 @@ describe('prune-unused-i18n', () => { 'account.changeEmail.description': 'Description', 'account.changeEmail.unused': 'Unused', }) - writeSource('src/selectors.tsx', ` + writeSource( + 'src/selectors.tsx', + ` import { Trans, useTranslation } from 'react-i18next' export function SelectorExample() { @@ -61,7 +63,8 @@ describe('prune-unused-i18n', () => { ) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -80,14 +83,17 @@ describe('prune-unused-i18n', () => { writeJson('i18n/en-US/common.json', { 'unused.common': 'Unused common key', }) - writeSource('src/default-namespace.tsx', ` + writeSource( + 'src/default-namespace.tsx', + ` import { useTranslation } from 'react-i18next' export function DefaultNamespaceExample(key: string) { const { t } = useTranslation() return t($ => $[key]) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -106,14 +112,17 @@ describe('prune-unused-i18n', () => { members_other: '{{count}} members', unused: 'Unused', }) - writeSource('src/selector-variable.ts', ` + writeSource( + 'src/selector-variable.ts', + ` import type { SelectorParam } from 'i18next' import { createInstance } from 'i18next' const instance = createInstance() const memberKey: SelectorParam<'app'> = $ => $['members'] instance.t(memberKey, { count: 2 }) - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -132,7 +141,9 @@ describe('prune-unused-i18n', () => { second: 'Second', unused: 'Unused', }) - writeSource('src/selector-map.ts', ` + writeSource( + 'src/selector-map.ts', + ` import type { SelectorParam } from 'i18next' import { useTranslation } from 'react-i18next' @@ -151,7 +162,8 @@ describe('prune-unused-i18n', () => { const selector = enabled ? selectors[mode] : undefined return selector ? t(selector) : null } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -169,7 +181,9 @@ describe('prune-unused-i18n', () => { hidden: 'Potentially used by the dynamic selector', used: 'Used', }) - writeSource('src/mixed-selector-map.ts', ` + writeSource( + 'src/mixed-selector-map.ts', + ` import type { SelectorParam } from 'i18next' import { useTranslation } from 'react-i18next' @@ -181,7 +195,8 @@ describe('prune-unused-i18n', () => { } return t(selectors[kind as keyof typeof selectors]) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -197,7 +212,9 @@ describe('prune-unused-i18n', () => { hidden: 'Potentially used by the dynamic selector', used: 'Used', }) - writeSource('src/overridden-selector-map.ts', ` + writeSource( + 'src/overridden-selector-map.ts', + ` import type { SelectorParam } from 'i18next' import { useTranslation } from 'react-i18next' @@ -218,7 +235,8 @@ describe('prune-unused-i18n', () => { } return t(selectors.known) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -234,7 +252,9 @@ describe('prune-unused-i18n', () => { unused: 'Unused', used: 'Used', }) - writeSource('src/computed-selector-map.ts', ` + writeSource( + 'src/computed-selector-map.ts', + ` import { useTranslation } from 'react-i18next' const property = 'label' @@ -246,7 +266,8 @@ describe('prune-unused-i18n', () => { const { t } = useTranslation() return t(selectors[property]) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -264,7 +285,9 @@ describe('prune-unused-i18n', () => { used: 'Used', unused: 'Unused', }) - writeSource('src/unrelated-generic.ts', ` + writeSource( + 'src/unrelated-generic.ts', + ` import { useTranslation } from 'react-i18next' const identity = (value: Value) => value @@ -274,7 +297,8 @@ describe('prune-unused-i18n', () => { identity('not.a.translation.key') return t($ => $['used']) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -292,7 +316,9 @@ describe('prune-unused-i18n', () => { used: 'Used', unused: 'Unused', }) - writeSource('src/translation-adapter.ts', ` + writeSource( + 'src/translation-adapter.ts', + ` import type { SelectorParam } from 'i18next' import { useTranslation } from 'react-i18next' @@ -307,7 +333,8 @@ describe('prune-unused-i18n', () => { const translate: Translate = selector => t(selector) return renderLabel(translate) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -325,10 +352,12 @@ describe('prune-unused-i18n', () => { 'unused.app': 'Unused app key', }) writeJson('i18n/en-US/deployments.json', { - 'unused': 'Unused deployment key', + unused: 'Unused deployment key', 'versions.deployTo': 'Deploy to {{name}}', }) - writeSource('src/destructured-translation.ts', ` + writeSource( + 'src/destructured-translation.ts', + ` import type { SelectorParam } from 'i18next' type DeploymentTranslate = >( @@ -339,7 +368,8 @@ describe('prune-unused-i18n', () => { export function buildLabel({ t }: { t: DeploymentTranslate }) { return t($ => $['versions.deployTo'], { name: 'Production' }) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -362,7 +392,9 @@ describe('prune-unused-i18n', () => { writeJson('i18n/en-US/app.json', { 'unused.app': 'Unused app key', }) - writeSource('src/named-translation-parameter.ts', ` + writeSource( + 'src/named-translation-parameter.ts', + ` import type { SelectorParam } from 'i18next' type AgentTranslate = >( @@ -372,7 +404,8 @@ describe('prune-unused-i18n', () => { export function buildLabel(t: AgentTranslate) { return t($ => $['agentDetail.used']) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -396,7 +429,9 @@ describe('prune-unused-i18n', () => { writeJson('i18n/en-US/app.json', { 'unused.app': 'Unused app key', }) - writeSource('src/branded-translation-parameter.ts', ` + writeSource( + 'src/branded-translation-parameter.ts', + ` type AgentTranslate = { readonly $TFunctionBrand: 'agentV2' (selector: (source: Record) => string): string @@ -409,7 +444,8 @@ describe('prune-unused-i18n', () => { export function destructuredLabel({ t }: { t: AgentTranslate }) { return t($ => $['agentDetail.destructured']) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -432,7 +468,9 @@ describe('prune-unused-i18n', () => { writeJson('i18n/en-US/common.json', { 'unused.common': 'Unused common key', }) - writeSource('src/block-body-adapter.ts', ` + writeSource( + 'src/block-body-adapter.ts', + ` import type { SelectorParam } from 'i18next' type Translate = >( @@ -452,7 +490,8 @@ describe('prune-unused-i18n', () => { export function renderLabel(translate: Translate) { return getStringTranslate(translate)($ => $['used'], { ns: 'app' }) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -471,7 +510,9 @@ describe('prune-unused-i18n', () => { unused: 'Unused', used: 'Used', }) - writeSource('src/named-selector-adapter.ts', ` + writeSource( + 'src/named-selector-adapter.ts', + ` import type { SelectorParam } from 'i18next' type Translate = >( @@ -492,7 +533,8 @@ describe('prune-unused-i18n', () => { export function renderLabel(translate: Translate) { return translateString(translate, $ => $['used']) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -509,9 +551,11 @@ describe('prune-unused-i18n', () => { writeJson('i18n/en-US/plugin.json', { 'source.first': 'First source', 'source.second': 'Second source', - 'unused': 'Unused', + unused: 'Unused', }) - writeSource('src/nested-selector-map.ts', ` + writeSource( + 'src/nested-selector-map.ts', + ` import type { SelectorParam } from 'i18next' import { useTranslation } from 'react-i18next' @@ -543,7 +587,8 @@ describe('prune-unused-i18n', () => { const config = sourceConfigs[source] return t(config.tipSelector, { ns: 'plugin' }) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -561,7 +606,9 @@ describe('prune-unused-i18n', () => { hidden: 'Potentially used', used: 'Used', }) - writeSource('src/untyped-adapter.js', ` + writeSource( + 'src/untyped-adapter.js', + ` import { useTranslation } from 'react-i18next' export function UntypedAdapter() { @@ -569,7 +616,8 @@ describe('prune-unused-i18n', () => { const translate = selector => t(selector) return translate($ => $['used']) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -587,7 +635,9 @@ describe('prune-unused-i18n', () => { writeJson('i18n/en-US/permission-keys.json', { 'server.permission': 'Server permission', }) - writeSource('src/open-key-adapter.ts', ` + writeSource( + 'src/open-key-adapter.ts', + ` import type { SelectorKey } from 'i18next' import { useTranslation } from 'react-i18next' @@ -595,7 +645,8 @@ describe('prune-unused-i18n', () => { const { t } = useTranslation() return (key: string) => t(key as SelectorKey, { ns: 'permissionKeys' }) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -612,16 +663,19 @@ describe('prune-unused-i18n', () => { writeJson('i18n/en-US/plugin.json', { 'voice.language.enUS': 'English', 'voice.language.zhCN': 'Chinese', - 'unrelated': 'Unrelated', + unrelated: 'Unrelated', }) - writeSource('src/dynamic-selector.tsx', ` + writeSource( + 'src/dynamic-selector.tsx', + ` import { useTranslation } from 'react-i18next' export function DynamicSelectorExample(language: string) { const { t } = useTranslation('plugin') return t($ => $[\`voice.language.\${language}\`]) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -642,7 +696,9 @@ describe('prune-unused-i18n', () => { 'status.failed': 'Failed', 'status.unused': 'Unused', }) - writeSource('src/typed-selector.tsx', ` + writeSource( + 'src/typed-selector.tsx', + ` import { useTranslation } from 'react-i18next' type StatusKey = 'status.ready' | 'status.failed' @@ -651,7 +707,8 @@ describe('prune-unused-i18n', () => { const { t } = useTranslation('common') return t($ => $[statusKey]) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -671,14 +728,17 @@ describe('prune-unused-i18n', () => { 'operation.close': 'Close', 'unused.common': 'Unused common', }) - writeSource('src/multi-namespace-selector.tsx', ` + writeSource( + 'src/multi-namespace-selector.tsx', + ` import { useTranslation } from 'react-i18next' export function MultiNamespaceSelectorExample() { const { t } = useTranslation(['app', 'common']) return t($ => $.common['operation.close']) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -700,7 +760,9 @@ describe('prune-unused-i18n', () => { confirm: 'Confirm', unused: 'Unused', }) - writeSource('src/secondary-selector-access.tsx', ` + writeSource( + 'src/secondary-selector-access.tsx', + ` import { useTranslation } from 'react-i18next' export function SecondarySelectorAccessExample() { @@ -708,7 +770,8 @@ describe('prune-unused-i18n', () => { t($ => $.common.close) return t($ => $['common']['confirm']) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -729,14 +792,17 @@ describe('prune-unused-i18n', () => { writeJson('i18n/en-US/common.json', { 'maybe.used': 'Maybe used', }) - writeSource('src/unresolved-namespace-selector.tsx', ` + writeSource( + 'src/unresolved-namespace-selector.tsx', + ` import { useTranslation } from 'react-i18next' export function UnresolvedNamespaceSelectorExample(keyFromServer: string) { const { t } = useTranslation(['app', 'common']) return t($ => $.common[keyFromServer]) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -752,7 +818,7 @@ describe('prune-unused-i18n', () => { // Arrange writeJson('i18n/en-US/app.json', { 'literal.title': 'Title', - 'withDefault': 'With default', + withDefault: 'With default', 'trans.shared': 'Shared app', 'unused.app': 'Unused app', }) @@ -761,7 +827,9 @@ describe('prune-unused-i18n', () => { 'trans.shared': 'Shared common', 'unused.common': 'Unused common', }) - writeSource('src/example.tsx', ` + writeSource( + 'src/example.tsx', + ` import { Trans, useTranslation } from 'react-i18next' export function Example() { @@ -780,7 +848,8 @@ describe('prune-unused-i18n', () => { ) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -801,9 +870,11 @@ describe('prune-unused-i18n', () => { 'voice.language.enUS': 'English', 'voice.language.zhCN': 'Chinese', 'voice.language.unused': 'Fallback language', - 'unrelated': 'Unrelated', + unrelated: 'Unrelated', }) - writeSource('src/dynamic.tsx', ` + writeSource( + 'src/dynamic.tsx', + ` import { useTranslation } from 'react-i18next' const i18nPrefix = 'notice' @@ -815,7 +886,8 @@ describe('prune-unused-i18n', () => { t(\`\${i18nPrefix}.reason.\${deprecatedReasonKey}\`) t(\`voice.language.\${language}\`, 'Fallback', { ns: 'plugin' }) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -837,14 +909,17 @@ describe('prune-unused-i18n', () => { 'maybe.used': 'Maybe used', 'otherwise.unused': 'Otherwise unused', }) - writeSource('src/unresolved.tsx', ` + writeSource( + 'src/unresolved.tsx', + ` import { useTranslation } from 'react-i18next' export function UnresolvedExample(keyFromServer: string) { const { t } = useTranslation('app') return t(keyFromServer) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -861,7 +936,9 @@ describe('prune-unused-i18n', () => { 'duplicateError.value': 'Value', 'outside.unused': 'Outside', }) - writeSource('src/typed-prefix.tsx', ` + writeSource( + 'src/typed-prefix.tsx', + ` import type { I18nKeysByPrefix } from '@/types/i18n' import { useTranslation } from 'react-i18next' @@ -869,7 +946,8 @@ describe('prune-unused-i18n', () => { const { t } = useTranslation() return t(errorKey as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug' }) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -893,7 +971,9 @@ describe('prune-unused-i18n', () => { 'status.failed': 'Failed', 'status.unused': 'Unused', }) - writeSource('src/object-map.tsx', ` + writeSource( + 'src/object-map.tsx', + ` import { useTranslation } from 'react-i18next' const statusI18nKey = { @@ -905,7 +985,8 @@ describe('prune-unused-i18n', () => { const { t } = useTranslation('common') return t(statusI18nKey[status]) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -922,7 +1003,9 @@ describe('prune-unused-i18n', () => { 'mainNav.workspace.searchPlaceholder': 'Search', 'mainNav.workspace.unused': 'Unused', }) - writeSource('src/identity-helper.tsx', ` + writeSource( + 'src/identity-helper.tsx', + ` import { useTranslation } from 'react-i18next' const workspaceSwitchI18nKey = (key: string) => key as 'mainNav.workspace.settings' @@ -931,7 +1014,8 @@ describe('prune-unused-i18n', () => { const { t } = useTranslation() return t(workspaceSwitchI18nKey('mainNav.workspace.searchPlaceholder'), { ns: 'common' }) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -950,14 +1034,17 @@ describe('prune-unused-i18n', () => { 'overview.unused_one': '1 unused', 'overview.unused_other': '{{count}} unused', }) - writeSource('src/plural.tsx', ` + writeSource( + 'src/plural.tsx', + ` import { useTranslation } from 'react-i18next' export function PluralExample(count: number) { const { t } = useTranslation('deployments') return t('overview.environments', { count }) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -976,13 +1063,16 @@ describe('prune-unused-i18n', () => { 'overview.chip.unused_one': '1 unused', 'overview.chip.unused_other': '{{count}} unused', }) - writeSource('src/typed-t-function.ts', ` + writeSource( + 'src/typed-t-function.ts', + ` import type { TFunction } from 'i18next' export function renderStatus(t: TFunction<'deployments'>) { return t('overview.chip.behind', { count: 2 }) } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -999,7 +1089,9 @@ describe('prune-unused-i18n', () => { 'agentDetail.configure.tools.credential.authOne': 'Auth 1', 'agentDetail.configure.tools.unused': 'Unused', }) - writeSource('src/typed-key-field.ts', ` + writeSource( + 'src/typed-key-field.ts', + ` type I18nKeysWithPrefix = 'agentDetail.configure.tools.credential.authOne' | 'agentDetail.configure.tools.unused' @@ -1010,7 +1102,8 @@ describe('prune-unused-i18n', () => { export const tool: Tool = { credentialKey: 'agentDetail.configure.tools.credential.authOne', } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -1028,7 +1121,9 @@ describe('prune-unused-i18n', () => { 'gotoAnything.actions.createChatflowDesc': 'Create a chatflow', 'gotoAnything.actions.unused': 'Unused', }) - writeSource('src/i18next-instance.tsx', ` + writeSource( + 'src/i18next-instance.tsx', + ` import { getI18n } from 'react-i18next' const i18n = getI18n() @@ -1038,7 +1133,8 @@ describe('prune-unused-i18n', () => { i18n.t('gotoAnything.actions.createChatflow', { ns: 'app' }) return tr('gotoAnything.actions.createChatflowDesc') } - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -1052,19 +1148,21 @@ describe('prune-unused-i18n', () => { it('should collect keys from imported and parameterized t functions', async () => { // Arrange writeJson('i18n/en-US/app.json', { - 'noAccessPermission': 'No access', + noAccessPermission: 'No access', 'typeSelector.chatbot': 'Chatbot', 'unused.app': 'Unused app', }) writeJson('i18n/en-US/app-api.json', { - 'pause': 'Pause', + pause: 'Pause', 'unused.api': 'Unused API', }) writeJson('i18n/en-US/tools.json', { 'mcp.server.publishTip': 'Publish first', 'unused.tools': 'Unused tools', }) - writeSource('src/parameterized.tsx', ` + writeSource( + 'src/parameterized.tsx', + ` import type { TFunction } from 'i18next' import { t as globalT } from 'i18next' import { useTranslation } from 'react-i18next' @@ -1082,7 +1180,8 @@ describe('prune-unused-i18n', () => { } globalT('pause', { ns: 'appApi' }) - `) + `, + ) // Act const result = await analyzeUnusedTranslations({ webRoot }) @@ -1107,14 +1206,17 @@ describe('prune-unused-i18n', () => { kept: '保留', unused: '未使用', }) - writeSource('src/example.tsx', ` + writeSource( + 'src/example.tsx', + ` import { useTranslation } from 'react-i18next' export function Example() { const { t } = useTranslation('app') return t('kept') } - `) + `, + ) const result = await analyzeUnusedTranslations({ webRoot }) // Act diff --git a/web/scripts/analyze-i18n-diff.ts b/web/scripts/analyze-i18n-diff.ts index 44e8c675f93d7b..d12cf31970110f 100644 --- a/web/scripts/analyze-i18n-diff.ts +++ b/web/scripts/analyze-i18n-diff.ts @@ -35,8 +35,8 @@ type NestedTranslation = { type AnalysisResult = { file: string missingKeys: string[] - changedValues: { key: string, oldValue: TranslationValue, newValue: TranslationValue }[] - newKeys: { key: string, value: TranslationValue }[] + changedValues: { key: string; oldValue: TranslationValue; newValue: TranslationValue }[] + newKeys: { key: string; value: TranslationValue }[] } /** @@ -51,12 +51,10 @@ function flattenObject(obj: NestedTranslation, prefix = ''): FlatTranslation { if (typeof value === 'string') { result[newKey] = value - } - else if (Array.isArray(value)) { + } else if (Array.isArray(value)) { // Preserve arrays as-is result[newKey] = value as string[] - } - else if (typeof value === 'object' && value !== null) { + } else if (typeof value === 'object' && value !== null) { Object.assign(result, flattenObject(value as NestedTranslation, newKey)) } } @@ -72,8 +70,7 @@ function valuesEqual(a: TranslationValue, b: TranslationValue): boolean { return a === b } if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) - return false + if (a.length !== b.length) return false return a.every((item, index) => item === b[index]) } return false @@ -84,7 +81,7 @@ function valuesEqual(a: TranslationValue, b: TranslationValue): boolean { */ function formatValue(value: TranslationValue): string { if (Array.isArray(value)) { - return `[${value.map(v => `"${v}"`).join(', ')}]` + return `[${value.map((v) => `"${v}"`).join(', ')}]` } return `"${value}"` } @@ -100,8 +97,7 @@ function parseTsContent(content: string): NestedTranslation { .trim() // Remove trailing semicolon if present - if (cleaned.endsWith(';')) - cleaned = cleaned.slice(0, -1) + if (cleaned.endsWith(';')) cleaned = cleaned.slice(0, -1) // Use Function constructor to safely evaluate the object literal // This handles JS object syntax like unquoted keys, template literals, etc. @@ -109,8 +105,7 @@ function parseTsContent(content: string): NestedTranslation { // eslint-disable-next-line no-new-func const fn = new Function(`return (${cleaned})`) return fn() as NestedTranslation - } - catch (e) { + } catch (e) { console.error('Failed to parse TS content:', e) console.error('Content preview:', cleaned.slice(0, 200)) return {} @@ -128,8 +123,7 @@ function getMainBranchFile(filePath: string): string | null { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }) - } - catch { + } catch { return null } } @@ -139,7 +133,7 @@ function getMainBranchFile(filePath: string): string | null { */ function getTranslationFiles(): string[] { const files = fs.readdirSync(I18N_DIR) - return files.filter(f => f.endsWith('.json')).map(f => f.replace('.json', '')) + return files.filter((f) => f.endsWith('.json')).map((f) => f.replace('.json', '')) } /** @@ -157,10 +151,9 @@ function getMainBranchNamespaces(): string[] { return output .trim() .split('\n') - .filter(f => f.endsWith('.ts')) - .map(f => path.basename(f, '.ts')) - } - catch { + .filter((f) => f.endsWith('.ts')) + .map((f) => path.basename(f, '.ts')) + } catch { return [] } } @@ -181,15 +174,15 @@ function checkNamespaceFiles(): NamespaceCheckResult { const currentFiles = fs.readdirSync(I18N_DIR) const currentJsonFiles = currentFiles - .filter(f => f.endsWith('.json')) - .map(f => f.replace('.json', '')) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace('.json', '')) const currentTsFiles = currentFiles - .filter(f => f.endsWith('.ts')) - .map(f => f.replace('.ts', '')) + .filter((f) => f.endsWith('.ts')) + .map((f) => f.replace('.ts', '')) // Check which namespaces from main are missing json files - const missingJsonFiles = mainNamespaces.filter(ns => !currentJsonFiles.includes(ns)) + const missingJsonFiles = mainNamespaces.filter((ns) => !currentJsonFiles.includes(ns)) // ts files should not exist in current branch const unexpectedTsFiles = currentTsFiles @@ -216,7 +209,10 @@ function analyzeFile(baseName: string): AnalysisResult { // Read current branch JSON file const jsonPath = path.join(I18N_DIR, `${baseName}.json`) - const currentContent = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) as Record + const currentContent = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) as Record< + string, + TranslationValue + > // Read main branch TS file const tsContent = getMainBranchFile(`${baseName}.ts`) @@ -264,7 +260,9 @@ function analyzeFile(baseName: string): AnalysisResult { * Main analysis function */ function main() { - console.log('🔍 Analyzing i18n differences between current branch (flat JSON) and main branch (nested TS)...\n') + console.log( + '🔍 Analyzing i18n differences between current branch (flat JSON) and main branch (nested TS)...\n', + ) // Check namespace file consistency first console.log('📂 Checking namespace files...') @@ -283,8 +281,7 @@ function main() { console.log(` - ${ns}.json (was ${ns}.ts in main)`) } hasNamespaceError = true - } - else { + } else { console.log('\n✅ All namespaces from main branch have corresponding JSON files') } @@ -294,8 +291,7 @@ function main() { console.log(` - ${ns}.ts`) } hasNamespaceError = true - } - else { + } else { console.log('✅ No TS files in current branch (all converted to JSON)') } @@ -371,21 +367,28 @@ function main() { // Write detailed report to JSON file const reportPath = path.join(__dirname, '../i18n-analysis-report.json') - fs.writeFileSync(reportPath, JSON.stringify({ - summary: { - totalFiles: files.length, - missingKeys: totalMissing, - changedValues: totalChanged, - newKeys: totalNew, - }, - namespaceCheck: { - mainNamespaces: nsCheck.mainNamespaces, - currentJsonFiles: nsCheck.currentJsonFiles, - missingJsonFiles: nsCheck.missingJsonFiles, - unexpectedTsFiles: nsCheck.unexpectedTsFiles, - }, - details: allResults, - }, null, 2)) + fs.writeFileSync( + reportPath, + JSON.stringify( + { + summary: { + totalFiles: files.length, + missingKeys: totalMissing, + changedValues: totalChanged, + newKeys: totalNew, + }, + namespaceCheck: { + mainNamespaces: nsCheck.mainNamespaces, + currentJsonFiles: nsCheck.currentJsonFiles, + missingJsonFiles: nsCheck.missingJsonFiles, + unexpectedTsFiles: nsCheck.unexpectedTsFiles, + }, + details: allResults, + }, + null, + 2, + ), + ) console.log(`\n📄 Detailed report written to: i18n-analysis-report.json`) diff --git a/web/scripts/check-i18n.js b/web/scripts/check-i18n.js index 6936bcb4520e13..78617fb9e1b58b 100644 --- a/web/scripts/check-i18n.js +++ b/web/scripts/check-i18n.js @@ -8,7 +8,9 @@ const __dirname = path.dirname(__filename) const targetLanguage = 'en-US' -const languages = data.languages.filter(language => language.supported).map(language => language.value) +const languages = data.languages + .filter((language) => language.supported) + .map((language) => language.value) function parseArgs(argv) { const args = { @@ -24,8 +26,7 @@ function parseArgs(argv) { let cursor = startIndex + 1 while (cursor < argv.length && !argv[cursor].startsWith('--')) { const value = argv[cursor].trim() - if (value) - values.push(value) + if (value) values.push(value) cursor++ } return { values, nextIndex: cursor - 1 } @@ -37,7 +38,7 @@ function parseArgs(argv) { return false } - const invalid = values.find(value => value.includes(',')) + const invalid = values.find((value) => value.includes(',')) if (invalid) { args.errors.push(`${flag} expects space-separated values. Example: ${flag} app billing`) return false @@ -66,8 +67,7 @@ function parseArgs(argv) { if (arg === '--file') { const { values, nextIndex } = collectValues(index) - if (validateList(values, '--file')) - args.files.push(...values) + if (validateList(values, '--file')) args.files.push(...values) index = nextIndex continue } @@ -79,8 +79,7 @@ function parseArgs(argv) { if (arg === '--lang') { const { values, nextIndex } = collectValues(index) - if (validateList(values, '--lang')) - args.languages.push(...values) + if (validateList(values, '--lang')) args.languages.push(...values) index = nextIndex continue } @@ -116,13 +115,12 @@ async function getKeysFromLanguage(language) { } // Filter only .json files - const translationFiles = files.filter(file => file.endsWith('.json')) + const translationFiles = files.filter((file) => file.endsWith('.json')) translationFiles.forEach((file) => { const filePath = path.join(folderPath, file) const fileName = file.replace(/\.json$/, '') // Remove file extension - const camelCaseFileName = fileName.replace(/[-_](.)/g, (_, c) => - c.toUpperCase()) // Convert to camel case + const camelCaseFileName = fileName.replace(/[-_](.)/g, (_, c) => c.toUpperCase()) // Convert to camel case try { const content = fs.readFileSync(filePath, 'utf8') @@ -135,10 +133,9 @@ async function getKeysFromLanguage(language) { } // Flat structure: just get all keys directly - const fileKeys = Object.keys(translationObj).map(key => `${camelCaseFileName}.${key}`) + const fileKeys = Object.keys(translationObj).map((key) => `${camelCaseFileName}.${key}`) allKeys.push(...fileKeys) - } - catch (error) { + } catch (error) { console.error(`Error processing file ${filePath}:`, error.message) reject(error) } @@ -160,11 +157,10 @@ async function removeExtraKeysFromFile(language, fileName, extraKeys) { // Filter keys that belong to this file const camelCaseFileName = fileName.replace(/[-_](.)/g, (_, c) => c.toUpperCase()) const fileSpecificKeys = extraKeys - .filter(key => key.startsWith(`${camelCaseFileName}.`)) - .map(key => key.substring(camelCaseFileName.length + 1)) // Remove file prefix + .filter((key) => key.startsWith(`${camelCaseFileName}.`)) + .map((key) => key.substring(camelCaseFileName.length + 1)) // Remove file prefix - if (fileSpecificKeys.length === 0) - return false + if (fileSpecificKeys.length === 0) return false console.log(`🔄 Processing file: ${filePath}`) @@ -180,8 +176,7 @@ async function removeExtraKeysFromFile(language, fileName, extraKeys) { delete translationObj[keyToRemove] console.log(`🗑️ Removed key: ${keyToRemove}`) modified = true - } - else { + } else { console.log(`⚠️ Could not find key: ${keyToRemove}`) } } @@ -195,8 +190,7 @@ async function removeExtraKeysFromFile(language, fileName, extraKeys) { } return false - } - catch (error) { + } catch (error) { console.error(`Error processing file ${filePath}:`, error.message) return false } @@ -214,22 +208,28 @@ async function main() { const allTargetKeys = await getKeysFromLanguage(targetLanguage) // Filter target keys by file if specified - const camelTargetFiles = targetFiles.map(file => file.replace(/[-_](.)/g, (_, c) => c.toUpperCase())) + const camelTargetFiles = targetFiles.map((file) => + file.replace(/[-_](.)/g, (_, c) => c.toUpperCase()), + ) const targetKeys = targetFiles.length - ? allTargetKeys.filter(key => camelTargetFiles.some(file => key.startsWith(`${file}.`))) + ? allTargetKeys.filter((key) => camelTargetFiles.some((file) => key.startsWith(`${file}.`))) : allTargetKeys // Filter languages by target language if specified const languagesToProcess = targetLangs.length ? targetLangs : languages - const allLanguagesKeys = await Promise.all(languagesToProcess.map(language => getKeysFromLanguage(language))) + const allLanguagesKeys = await Promise.all( + languagesToProcess.map((language) => getKeysFromLanguage(language)), + ) // Filter language keys by file if specified const languagesKeys = targetFiles.length - ? allLanguagesKeys.map(keys => keys.filter(key => camelTargetFiles.some(file => key.startsWith(`${file}.`)))) + ? allLanguagesKeys.map((keys) => + keys.filter((key) => camelTargetFiles.some((file) => key.startsWith(`${file}.`))), + ) : allLanguagesKeys - const keysCount = languagesKeys.map(keys => keys.length) + const keysCount = languagesKeys.map((keys) => keys.length) const targetKeysCount = targetKeys.length const comparison = languagesToProcess.reduce((result, language, index) => { @@ -245,12 +245,11 @@ async function main() { for (let index = 0; index < languagesToProcess.length; index++) { const language = languagesToProcess[index] const languageKeys = languagesKeys[index] - const missingKeys = targetKeys.filter(key => !languageKeys.includes(key)) - const extraKeys = languageKeys.filter(key => !targetKeys.includes(key)) + const missingKeys = targetKeys.filter((key) => !languageKeys.includes(key)) + const extraKeys = languageKeys.filter((key) => !targetKeys.includes(key)) console.log(`Missing keys in ${language}:`, missingKeys) - if (missingKeys.length > 0) - hasDiff = true + if (missingKeys.length > 0) hasDiff = true // Show extra keys only when there are extra keys (negative difference) if (extraKeys.length > 0) { @@ -262,21 +261,20 @@ async function main() { // Get all translation files const i18nFolder = path.resolve(__dirname, '../i18n', language) - const files = fs.readdirSync(i18nFolder) - .filter(file => file.endsWith('.json')) - .map(file => file.replace(/\.json$/, '')) - .filter(f => targetFiles.length === 0 || targetFiles.includes(f)) + const files = fs + .readdirSync(i18nFolder) + .filter((file) => file.endsWith('.json')) + .map((file) => file.replace(/\.json$/, '')) + .filter((f) => targetFiles.length === 0 || targetFiles.includes(f)) let totalRemoved = 0 for (const fileName of files) { const removed = await removeExtraKeysFromFile(language, fileName, extraKeys) - if (removed) - totalRemoved++ + if (removed) totalRemoved++ } console.log(`✅ Auto-removal completed for ${language}. Modified ${totalRemoved} files.`) - } - else { + } else { hasDiff = true } } @@ -286,21 +284,17 @@ async function main() { } console.log('🚀 Starting i18n:check script...') - if (targetFiles.length) - console.log(`📁 Checking files: ${targetFiles.join(', ')}`) + if (targetFiles.length) console.log(`📁 Checking files: ${targetFiles.join(', ')}`) - if (targetLangs.length) - console.log(`🌍 Checking languages: ${targetLangs.join(', ')}`) + if (targetLangs.length) console.log(`🌍 Checking languages: ${targetLangs.join(', ')}`) - if (autoRemove) - console.log('🤖 Auto-remove mode: ENABLED') + if (autoRemove) console.log('🤖 Auto-remove mode: ENABLED') const hasDiff = await compareKeysCount() if (hasDiff) { console.error('\n❌ i18n keys are not aligned. Fix issues above.') process.exitCode = 1 - } - else { + } else { console.log('\n✅ All i18n files are in sync') } } @@ -312,13 +306,13 @@ async function bootstrap() { } if (args.errors.length) { - args.errors.forEach(message => console.error(`❌ ${message}`)) + args.errors.forEach((message) => console.error(`❌ ${message}`)) printHelp() process.exit(1) return } - const unknownLangs = targetLangs.filter(lang => !languages.includes(lang)) + const unknownLangs = targetLangs.filter((lang) => !languages.includes(lang)) if (unknownLangs.length) { console.error(`❌ Unsupported languages: ${unknownLangs.join(', ')}`) process.exit(1) diff --git a/web/scripts/check-production-unused-after-knip-fix.mjs b/web/scripts/check-production-unused-after-knip-fix.mjs index fe4625807fb4cf..ec5a0f6a375e58 100644 --- a/web/scripts/check-production-unused-after-knip-fix.mjs +++ b/web/scripts/check-production-unused-after-knip-fix.mjs @@ -20,17 +20,16 @@ function run(command, args, options = {}) { let stdout = '' let stderr = '' - child.stdout.on('data', data => stdout += data) - child.stderr.on('data', data => stderr += data) + child.stdout.on('data', (data) => (stdout += data)) + child.stderr.on('data', (data) => (stderr += data)) child.on('error', reject) - child.on('close', status => resolve({ status, stdout, stderr })) + child.on('close', (status) => resolve({ status, stdout, stderr })) }) } function parseVpLintJson(stdout) { const jsonStart = stdout.indexOf('{') - if (jsonStart === -1) - return { diagnostics: [] } + if (jsonStart === -1) return { diagnostics: [] } return JSON.parse(stdout.slice(jsonStart)) } @@ -39,7 +38,10 @@ function relativeDiagnostic(diagnostic) { const label = diagnostic.labels?.[0] const diagnosticFile = label?.file ?? diagnostic.filename const filePath = diagnosticFile - ? path.relative(webDir, path.isAbsolute(diagnosticFile) ? diagnosticFile : path.join(webDir, diagnosticFile)) + ? path.relative( + webDir, + path.isAbsolute(diagnosticFile) ? diagnosticFile : path.join(webDir, diagnosticFile), + ) : '' const span = label?.span ? `:${label.span.line}:${label.span.column}` : '' @@ -53,8 +55,7 @@ async function ensureCleanWorktree() { throw new Error('Failed to check git status.') } - if (!result.stdout.trim()) - return + if (!result.stdout.trim()) return console.error('This check runs knip --fix and must start from a clean worktree.') console.error('Commit or stash your changes first, then run it again.') @@ -63,14 +64,18 @@ async function ensureCleanWorktree() { } async function restoreWorktree() { - const restoreResult = await run('git', ['restore', '--staged', '--worktree', '.'], { cwd: repoRoot }) + const restoreResult = await run('git', ['restore', '--staged', '--worktree', '.'], { + cwd: repoRoot, + }) if (restoreResult.status !== 0) { process.stdout.write(restoreResult.stdout) process.stderr.write(restoreResult.stderr) throw new Error('Failed to restore tracked files after knip --fix.') } - const cleanResult = await run('git', ['clean', '-fd', '--', 'web', '.eslintcache'], { cwd: repoRoot }) + const cleanResult = await run('git', ['clean', '-fd', '--', 'web', '.eslintcache'], { + cwd: repoRoot, + }) if (cleanResult.status !== 0) { process.stdout.write(cleanResult.stdout) process.stderr.write(cleanResult.stderr) @@ -96,38 +101,41 @@ try { } console.log('Running Vite+ unused checks after knip --fix...') - const lintResult = await run(vp, [ - 'lint', - '-A', - 'all', - '-D', - 'no-unused-vars', - '--format', - 'json', - '--ignore-pattern', - 'public/**', - '--ignore-pattern', - 'coverage/**', - '--ignore-pattern', - '.next/**', - '--ignore-pattern', - '**/__tests__/**', - '--ignore-pattern', - '**/*.spec.ts', - '--ignore-pattern', - '**/*.spec.tsx', - '--ignore-pattern', - '**/*.test.ts', - '--ignore-pattern', - '**/*.test.tsx', - '.', - ], { cwd: webDir }) + const lintResult = await run( + vp, + [ + 'lint', + '-A', + 'all', + '-D', + 'no-unused-vars', + '--format', + 'json', + '--ignore-pattern', + 'public/**', + '--ignore-pattern', + 'coverage/**', + '--ignore-pattern', + '.next/**', + '--ignore-pattern', + '**/__tests__/**', + '--ignore-pattern', + '**/*.spec.ts', + '--ignore-pattern', + '**/*.spec.tsx', + '--ignore-pattern', + '**/*.test.ts', + '--ignore-pattern', + '**/*.test.tsx', + '.', + ], + { cwd: webDir }, + ) let lintOutput try { lintOutput = parseVpLintJson(lintResult.stdout) - } - catch { + } catch { process.stdout.write(lintResult.stdout) process.stderr.write(lintResult.stderr) throw new Error('Failed to parse Vite+ lint JSON output.') @@ -138,20 +146,18 @@ try { if (unusedMessages.length > 0) { hasUnusedMessages = true console.error('Unused declarations remain after applying knip --production --fix.') - console.error('Remove these declarations; if they are only referenced by tests, remove the matching tests too.') - for (const message of unusedMessages) - console.error(message) - } - else { + console.error( + 'Remove these declarations; if they are only referenced by tests, remove the matching tests too.', + ) + for (const message of unusedMessages) console.error(message) + } else { console.log('No Vite+ unused declarations remain after knip --production --fix.') } -} -finally { +} finally { if (shouldRestore) { console.log('Restoring checkout after knip --fix...') await restoreWorktree() } } -if (hasUnusedMessages) - process.exit(1) +if (hasUnusedMessages) process.exit(1) diff --git a/web/scripts/copy-and-start.mjs b/web/scripts/copy-and-start.mjs index 2e048728ec12ee..5c180051423a69 100644 --- a/web/scripts/copy-and-start.mjs +++ b/web/scripts/copy-and-start.mjs @@ -15,8 +15,7 @@ const pathExists = async (path) => { await stat(path) console.debug(`Path exists: ${path}`) return true - } - catch (err) { + } catch (err) { if (err.code === 'ENOENT') { console.warn(`Path does not exist: ${path}`) return false @@ -33,8 +32,7 @@ const STANDALONE_ROOT_CANDIDATES = [ const getStandaloneRoot = async () => { for (const standaloneRoot of STANDALONE_ROOT_CANDIDATES) { const serverScriptPath = path.join(standaloneRoot, 'server.js') - if (await pathExists(serverScriptPath)) - return standaloneRoot + if (await pathExists(serverScriptPath)) return standaloneRoot } throw new Error( @@ -71,13 +69,11 @@ const copyAllDirs = async (standaloneRoot) => { await mkdir(destParent, { recursive: true }) if (await pathExists(src)) { await copyDir(src, dest) - } - else { + } else { console.error(`Error: ${src} directory does not exist. This is a required build artifact.`) process.exit(1) } - } - catch (err) { + } catch (err) { console.error(`Error processing ${src}:`, err.message) process.exit(1) } @@ -101,18 +97,14 @@ const main = async () => { console.debug(`Server script path: ${serverScriptPath}`) console.debug(`Environment variables - PORT: ${port}, HOSTNAME: ${host}`) - const server = spawn( - process.execPath, - [serverScriptPath], - { - env: { - ...process.env, - PORT: port, - HOSTNAME: host, - }, - stdio: 'inherit', + const server = spawn(process.execPath, [serverScriptPath], { + env: { + ...process.env, + PORT: port, + HOSTNAME: host, }, - ) + stdio: 'inherit', + }) server.on('error', (err) => { console.error('Failed to start server:', err) diff --git a/web/scripts/gen-doc-paths.ts b/web/scripts/gen-doc-paths.ts index 907fabe6a9bb6a..40ac60442617da 100644 --- a/web/scripts/gen-doc-paths.ts +++ b/web/scripts/gen-doc-paths.ts @@ -11,7 +11,8 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const DEFAULT_DOCS_JSON_URL = 'https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json' +const DEFAULT_DOCS_JSON_URL = + 'https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json' const DOCS_JSON_URL = process.env.DOCS_JSON_URL || DEFAULT_DOCS_JSON_URL const OUTPUT_PATH = path.resolve(__dirname, '../types/doc-paths.ts') @@ -57,10 +58,12 @@ type DocsJson = { [key: string]: unknown } -const OPENAPI_BASE_URL = (process.env.DOCS_OPENAPI_BASE_URL || new URL('.', DOCS_JSON_URL).toString()).replace(/\/?$/, '/') +const OPENAPI_BASE_URL = ( + process.env.DOCS_OPENAPI_BASE_URL || new URL('.', DOCS_JSON_URL).toString() +).replace(/\/?$/, '/') const DOCS_PRODUCTS = ['cloud', 'self-host'] as const -type DocsProduct = typeof DOCS_PRODUCTS[number] +type DocsProduct = (typeof DOCS_PRODUCTS)[number] type ProductAvailability = Record> function isDocsProduct(segment: string): segment is DocsProduct { @@ -73,11 +76,7 @@ function isDocsProduct(segment: string): segment is DocsProduct { * e.g., "获取知识库列表" -> "获取知识库列表" */ function summaryToSlug(summary: string): string { - return summary - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') + return summary.toLowerCase().replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') } /** @@ -92,44 +91,36 @@ function getFirstPathSegment(apiPath: string): string { /** * Recursively extract OpenAPI file paths from navigation structure */ -function extractOpenAPIPaths(item: NavItem | undefined, paths: Set = new Set()): Set { - if (!item) - return paths +function extractOpenAPIPaths( + item: NavItem | undefined, + paths: Set = new Set(), +): Set { + if (!item) return paths if (Array.isArray(item)) { - for (const el of item) - extractOpenAPIPaths(el, paths) + for (const el of item) extractOpenAPIPaths(el, paths) return paths } if (typeof item === 'object') { - if (item.openapi && typeof item.openapi === 'string') - paths.add(item.openapi) + if (item.openapi && typeof item.openapi === 'string') paths.add(item.openapi) - if (item.pages) - extractOpenAPIPaths(item.pages, paths) + if (item.pages) extractOpenAPIPaths(item.pages, paths) - if (item.groups) - extractOpenAPIPaths(item.groups, paths) + if (item.groups) extractOpenAPIPaths(item.groups, paths) - if (item.dropdowns) - extractOpenAPIPaths(item.dropdowns, paths) + if (item.dropdowns) extractOpenAPIPaths(item.dropdowns, paths) - if (item.languages) - extractOpenAPIPaths(item.languages, paths) + if (item.languages) extractOpenAPIPaths(item.languages, paths) - if (item.products) - extractOpenAPIPaths(item.products, paths) + if (item.products) extractOpenAPIPaths(item.products, paths) - if (item.tabs) - extractOpenAPIPaths(item.tabs, paths) + if (item.tabs) extractOpenAPIPaths(item.tabs, paths) - if (item.menu) - extractOpenAPIPaths(item.menu, paths) + if (item.menu) extractOpenAPIPaths(item.menu, paths) - if (item.versions) - extractOpenAPIPaths(item.versions, paths) + if (item.versions) extractOpenAPIPaths(item.versions, paths) } return paths @@ -148,11 +139,10 @@ async function fetchOpenAPIAndExtractPaths(openapiPath: string): Promise = new Set()): Set { - if (!item) - return paths + if (!item) return paths if (Array.isArray(item)) { - for (const el of item) - extractPaths(el, paths) + for (const el of item) extractPaths(el, paths) return paths } @@ -199,37 +186,28 @@ function extractPaths(item: NavItem | undefined, paths: Set = new Set()) } if (typeof item === 'object') { - if (item.root) - paths.add(item.root) + if (item.root) paths.add(item.root) // Handle pages array - if (item.pages) - extractPaths(item.pages, paths) + if (item.pages) extractPaths(item.pages, paths) // Handle groups array - if (item.groups) - extractPaths(item.groups, paths) + if (item.groups) extractPaths(item.groups, paths) // Handle dropdowns - if (item.dropdowns) - extractPaths(item.dropdowns, paths) + if (item.dropdowns) extractPaths(item.dropdowns, paths) // Handle languages - if (item.languages) - extractPaths(item.languages, paths) + if (item.languages) extractPaths(item.languages, paths) - if (item.products) - extractPaths(item.products, paths) + if (item.products) extractPaths(item.products, paths) - if (item.tabs) - extractPaths(item.tabs, paths) + if (item.tabs) extractPaths(item.tabs, paths) - if (item.menu) - extractPaths(item.menu, paths) + if (item.menu) extractPaths(item.menu, paths) // Handle versions in navigation - if (item.versions) - extractPaths(item.versions, paths) + if (item.versions) extractPaths(item.versions, paths) } return paths @@ -238,21 +216,20 @@ function extractPaths(item: NavItem | undefined, paths: Set = new Set()) function addPathToGroup(groups: Record>, pathWithoutLang: string): void { const parts = pathWithoutLang.split('/') const section = parts[0] - if (!section) - return + if (!section) return - if (!groups[section]) - groups[section] = new Set() + if (!groups[section]) groups[section] = new Set() groups[section]!.add(pathWithoutLang) } -function getProductPathInfo(pathWithoutLang: string): { product: DocsProduct, pathWithoutProduct: string } | undefined { +function getProductPathInfo( + pathWithoutLang: string, +): { product: DocsProduct; pathWithoutProduct: string } | undefined { const parts = pathWithoutLang.split('/') const [product, ...rest] = parts - if (!product || !isDocsProduct(product) || rest.length === 0) - return undefined + if (!product || !isDocsProduct(product) || rest.length === 0) return undefined return { product, @@ -263,19 +240,20 @@ function getProductPathInfo(pathWithoutLang: string): { product: DocsProduct, pa /** * Group paths by their prefix structure */ -function groupPathsBySection(paths: Set): { groups: Record>, productAvailability: ProductAvailability } { +function groupPathsBySection(paths: Set): { + groups: Record> + productAvailability: ProductAvailability +} { const groups: Record> = {} const productAvailability: ProductAvailability = {} for (const fullPath of paths) { // Remove language prefix (en/, zh/, ja/) const withoutLang = fullPath.replace(/^(en|zh|ja)\//, '') - if (!withoutLang || withoutLang === fullPath) - continue + if (!withoutLang || withoutLang === fullPath) continue // Skip non-doc paths (like .json files for OpenAPI) - if (withoutLang.endsWith('.json') || withoutLang === 'None') - continue + if (withoutLang.endsWith('.json') || withoutLang === 'None') continue addPathToGroup(groups, withoutLang) @@ -286,8 +264,7 @@ function groupPathsBySection(paths: Set): { groups: Record): { groups: Record part.charAt(0).toUpperCase() + part.slice(1)) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join('') } @@ -325,8 +302,8 @@ function generateTypeDefinitions( `// Generated at: ${new Date().toISOString()}`, '', '// Language prefixes', - 'export type DocLanguage = \'en\' | \'zh\' | \'ja\'', - 'export type DocsProduct = \'cloud\' | \'self-host\'', + "export type DocLanguage = 'en' | 'zh' | 'ja'", + "export type DocsProduct = 'cloud' | 'self-host'", '', ] @@ -350,8 +327,11 @@ function generateTypeDefinitions( // Add UseDifyNodesPath helper type after UseDifyPath if (section === 'use-dify') { lines.push('// UseDify node paths (without prefix)') - // eslint-disable-next-line no-template-curly-in-string - lines.push('type ExtractNodesPath = T extends `/use-dify/nodes/${infer Path}` ? Path : never') + /* eslint-disable no-template-curly-in-string */ + lines.push( + 'type ExtractNodesPath = T extends `/use-dify/nodes/${infer Path}` ? Path : never', + ) + /* eslint-enable no-template-curly-in-string */ lines.push('export type UseDifyNodesPath = ExtractNodesPath') lines.push('') } @@ -389,8 +369,10 @@ function generateTypeDefinitions( lines.push('// Product availability for productless docs paths') lines.push('export const docPathProductAvailability: Record = {') for (const path of Object.keys(productAvailability).sort()) { - const products = [...productAvailability[path]!].sort((a, b) => DOCS_PRODUCTS.indexOf(a) - DOCS_PRODUCTS.indexOf(b)) - lines.push(` '${path}': [${products.map(product => `'${product}'`).join(', ')}],`) + const products = [...productAvailability[path]!].sort( + (a, b) => DOCS_PRODUCTS.indexOf(a) - DOCS_PRODUCTS.indexOf(b), + ) + lines.push(` '${path}': [${products.map((product) => `'${product}'`).join(', ')}],`) } lines.push('}') lines.push('') @@ -405,7 +387,7 @@ async function main(): Promise { if (!response.ok) throw new Error(`Failed to fetch docs.json: ${response.status} ${response.statusText}`) - const docsJson = await response.json() as DocsJson + const docsJson = (await response.json()) as DocsJson console.log('Successfully fetched docs.json') // Extract paths from navigation @@ -424,13 +406,11 @@ async function main(): Promise { for (const openapiPath of openApiPaths) { const langMatch = /^(en|zh|ja)\//.exec(openapiPath) - if (langMatch?.[1] !== 'en') - continue + if (langMatch?.[1] !== 'en') continue console.log(`Fetching OpenAPI spec: ${openapiPath}`) const pathMap = await fetchOpenAPIAndExtractPaths(openapiPath) - for (const enPath of pathMap.values()) - enApiPaths.push(enPath) + for (const enPath of pathMap.values()) enApiPaths.push(enPath) } // Deduplicate English API paths @@ -445,11 +425,7 @@ async function main(): Promise { console.log(`Found ${Object.keys(productAvailability).length} product-aware paths`) // Generate TypeScript - const tsContent = generateTypeDefinitions( - groups, - productAvailability, - uniqueEnApiPaths, - ) + const tsContent = generateTypeDefinitions(groups, productAvailability, uniqueEnApiPaths) // Write to file await writeFile(OUTPUT_PATH, tsContent, 'utf-8') diff --git a/web/scripts/gen-icons.mjs b/web/scripts/gen-icons.mjs index 4fb5ce5840b256..bb18e78e4812ab 100644 --- a/web/scripts/gen-icons.mjs +++ b/web/scripts/gen-icons.mjs @@ -11,35 +11,35 @@ const svgAssetsDir = path.resolve(__dirname, '../../packages/iconify-collections const generateDir = async (currentPath) => { try { await mkdir(currentPath, { recursive: true }) - } - catch (err) { + } catch (err) { console.error(err.message) } } const processSvgStructure = (svgStructure, replaceFillOrStrokeColor) => { if (svgStructure?.children.length) { - svgStructure.children = svgStructure.children.filter(c => c.type !== 'text') + svgStructure.children = svgStructure.children.filter((c) => c.type !== 'text') svgStructure.children.forEach((child) => { if (child?.name === 'path' && replaceFillOrStrokeColor) { - if (child?.attributes?.stroke) - child.attributes.stroke = 'currentColor' + if (child?.attributes?.stroke) child.attributes.stroke = 'currentColor' - if (child?.attributes.fill) - child.attributes.fill = 'currentColor' + if (child?.attributes.fill) child.attributes.fill = 'currentColor' } - if (child?.children.length) - processSvgStructure(child, replaceFillOrStrokeColor) + if (child?.children.length) processSvgStructure(child, replaceFillOrStrokeColor) }) } } -const generateSvgComponent = async (fileHandle, entry, relativeSegments, replaceFillOrStrokeColor) => { +const generateSvgComponent = async ( + fileHandle, + entry, + relativeSegments, + replaceFillOrStrokeColor, +) => { const currentPath = path.resolve(iconsDir, 'src', ...relativeSegments) try { await access(currentPath) - } - catch { + } catch { await generateDir(currentPath) } @@ -54,7 +54,8 @@ const generateSvgComponent = async (fileHandle, entry, relativeSegments, replace name: fileName, } - const componentRender = template(` + const componentRender = template( + ` // GENERATE BY script // DON NOT EDIT IT MANUALLY @@ -75,16 +76,28 @@ const Icon = ( Icon.displayName = '<%= svgName %>' export default Icon -`.trim()) - - await writeFile(path.resolve(currentPath, `${fileName}.json`), `${JSON.stringify(svgData, '', '\t')}\n`) - await writeFile(path.resolve(currentPath, `${fileName}.tsx`), `${componentRender({ svgName: fileName })}\n`) - - const indexingRender = template(` +`.trim(), + ) + + await writeFile( + path.resolve(currentPath, `${fileName}.json`), + `${JSON.stringify(svgData, '', '\t')}\n`, + ) + await writeFile( + path.resolve(currentPath, `${fileName}.tsx`), + `${componentRender({ svgName: fileName })}\n`, + ) + + const indexingRender = template( + ` export { default as <%= svgName %> } from './<%= svgName %>' -`.trim()) +`.trim(), + ) - await appendFile(path.resolve(currentPath, 'index.ts'), `${indexingRender({ svgName: fileName })}\n`) + await appendFile( + path.resolve(currentPath, 'index.ts'), + `${indexingRender({ svgName: fileName })}\n`, + ) } const generateImageComponent = async (entry, relativeSegments) => { @@ -92,25 +105,30 @@ const generateImageComponent = async (entry, relativeSegments) => { try { await access(currentPath) - } - catch { + } catch { await generateDir(currentPath) } const prefixFileName = camelCase(entry.split('.')[0]) const fileName = prefixFileName.charAt(0).toUpperCase() + prefixFileName.slice(1) - const componentCSSRender = template(` + const componentCSSRender = template( + ` .wrapper { display: inline-flex; background: url(<%= assetPath %>) center center no-repeat; background-size: contain; } -`.trim()) +`.trim(), + ) - await writeFile(path.resolve(currentPath, `${fileName}.module.css`), `${componentCSSRender({ assetPath: path.posix.join('~@/app/components/base/icons/assets', ...relativeSegments, entry) })}\n`) + await writeFile( + path.resolve(currentPath, `${fileName}.module.css`), + `${componentCSSRender({ assetPath: path.posix.join('~@/app/components/base/icons/assets', ...relativeSegments, entry) })}\n`, + ) - const componentRender = template(` + const componentRender = template( + ` // GENERATE BY script // DON NOT EDIT IT MANUALLY @@ -131,13 +149,19 @@ const Icon = ( Icon.displayName = '<%= fileName %>' export default Icon -`.trim()) +`.trim(), + ) - await writeFile(path.resolve(currentPath, `${fileName}.tsx`), `${componentRender({ fileName })}\n`) + await writeFile( + path.resolve(currentPath, `${fileName}.tsx`), + `${componentRender({ fileName })}\n`, + ) - const indexingRender = template(` + const indexingRender = template( + ` export { default as <%= fileName %> } from './<%= fileName %>' -`.trim()) +`.trim(), + ) await appendFile(path.resolve(currentPath, 'index.ts'), `${indexingRender({ fileName })}\n`) } @@ -162,13 +186,12 @@ const walk = async (basePath, entry, relativeSegments, replaceFillOrStrokeColor) if (stat.isFile() && /.+\.png$/.test(entry)) await generateImageComponent(entry, relativeSegments) - } - finally { + } finally { fileHandle?.close() } } -(async () => { +;(async () => { await rm(path.resolve(iconsDir, 'src'), { recursive: true, force: true }) await walk(svgAssetsDir, 'public', [], false) await walk(svgAssetsDir, 'vender', [], true) diff --git a/web/scripts/generate-icons.js b/web/scripts/generate-icons.js index 979fbf059f3242..11e146622badca 100644 --- a/web/scripts/generate-icons.js +++ b/web/scripts/generate-icons.js @@ -28,10 +28,7 @@ async function generateIcons() { for (const { size, name } of sizes) { const outputPath = path.join(outputDir, name) - await sharp(inputPath) - .resize(size, size) - .png() - .toFile(outputPath) + await sharp(inputPath).resize(size, size).png().toFile(outputPath) console.log(`✓ Generated ${name} (${size}x${size})`) } @@ -45,8 +42,7 @@ async function generateIcons() { console.log('✓ Generated apple-touch-icon.png (180x180)') console.log('\n✅ All icons generated successfully!') - } - catch (error) { + } catch (error) { console.error('Error generating icons:', error) process.exit(1) } diff --git a/web/scripts/i18n-prune/core.ts b/web/scripts/i18n-prune/core.ts index c68208420c1751..0c1d4480b85025 100644 --- a/web/scripts/i18n-prune/core.ts +++ b/web/scripts/i18n-prune/core.ts @@ -20,20 +20,25 @@ const SKIPPED_DIRECTORIES = new Set([ 'public', ]) -type StaticValue - = | { kind: 'strings', values: string[] } - | { kind: 'object', properties: Map, unknownKeyValues: StaticValue[], complete: boolean } - | { kind: 'array', elements: StaticValue[] } - | { kind: 'identityFunction' } - | { kind: 'selector', expression: ts.Expression } - | { kind: 'selectors', expressions: ts.Expression[] } - | { kind: 'unknown' } - -type KeyExpressionUsage - = | { kind: 'keys', keys: string[] } - | { kind: 'patterns', patterns: Array<{ prefix: string, suffix: string }> } - | { kind: 'mixed', keys: string[], patterns: Array<{ prefix: string, suffix: string }> } - | { kind: 'unknown', namespace?: string } +type StaticValue = + | { kind: 'strings'; values: string[] } + | { + kind: 'object' + properties: Map + unknownKeyValues: StaticValue[] + complete: boolean + } + | { kind: 'array'; elements: StaticValue[] } + | { kind: 'identityFunction' } + | { kind: 'selector'; expression: ts.Expression } + | { kind: 'selectors'; expressions: ts.Expression[] } + | { kind: 'unknown' } + +type KeyExpressionUsage = + | { kind: 'keys'; keys: string[] } + | { kind: 'patterns'; patterns: Array<{ prefix: string; suffix: string }> } + | { kind: 'mixed'; keys: string[]; patterns: Array<{ prefix: string; suffix: string }> } + | { kind: 'unknown'; namespace?: string } type TranslationFunctionInfo = { namespaces: string[] @@ -122,7 +127,10 @@ function fileNameToNamespace(fileName: string) { } function namespaceToFileName(namespace: string, catalog: Catalog) { - return catalog.fileNameByNamespace.get(namespace) ?? namespace.replace(/[A-Z0-9]/g, char => `-${char.toLowerCase()}`) + return ( + catalog.fileNameByNamespace.get(namespace) ?? + namespace.replace(/[A-Z0-9]/g, (char) => `-${char.toLowerCase()}`) + ) } function isStringNode(node: ts.Node): node is ts.StringLiteral | ts.NoSubstitutionTemplateLiteral { @@ -134,12 +142,10 @@ function uniqueSorted(values: Iterable) { } function normalizeNamespace(namespace: string, catalog: Catalog) { - if (catalog.keysByNamespace.has(namespace)) - return namespace + if (catalog.keysByNamespace.has(namespace)) return namespace const fromFileName = catalog.namespaceByFileName.get(namespace) - if (fromFileName) - return fromFileName + if (fromFileName) return fromFileName const camelNamespace = fileNameToNamespace(namespace) return catalog.keysByNamespace.has(camelNamespace) ? camelNamespace : namespace @@ -152,11 +158,11 @@ function unwrapExpression(expression: ts.Expression): ts.Expression { while (changed) { changed = false if ( - ts.isParenthesizedExpression(current) - || ts.isAsExpression(current) - || ts.isTypeAssertionExpression(current) - || ts.isNonNullExpression(current) - || ts.isSatisfiesExpression(current) + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isNonNullExpression(current) || + ts.isSatisfiesExpression(current) ) { current = current.expression changed = true @@ -171,8 +177,7 @@ function combineStringValues(left: string[], right: string[]) { for (const leftValue of left) { for (const rightValue of right) { combined.push(`${leftValue}${rightValue}`) - if (combined.length > MAX_EXPANDED_VALUES) - return [] + if (combined.length > MAX_EXPANDED_VALUES) return [] } } return uniqueSorted(combined) @@ -192,22 +197,35 @@ function getPropertyNameText(name: ts.PropertyName) { return undefined } -function isFunctionLikeWithParameters(node: ts.Node): node is ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | ts.MethodDeclaration | ts.ConstructorDeclaration { - return ts.isFunctionDeclaration(node) - || ts.isFunctionExpression(node) - || ts.isArrowFunction(node) - || ts.isMethodDeclaration(node) - || ts.isConstructorDeclaration(node) +function isFunctionLikeWithParameters( + node: ts.Node, +): node is + | ts.FunctionDeclaration + | ts.FunctionExpression + | ts.ArrowFunction + | ts.MethodDeclaration + | ts.ConstructorDeclaration { + return ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isMethodDeclaration(node) || + ts.isConstructorDeclaration(node) + ) } function isLikelyTranslationFunctionType(typeNode: ts.TypeNode | undefined) { const typeText = typeNode?.getText() - return Boolean(typeText && (/\bTFunction\b/.test(typeText) || /\buseTranslation\b/.test(typeText))) + return Boolean( + typeText && (/\bTFunction\b/.test(typeText) || /\buseTranslation\b/.test(typeText)), + ) } -function getTranslationFunctionInfoFromType(typeNode: ts.TypeNode | undefined, catalog: Catalog): TranslationFunctionInfo | undefined { - if (!typeNode) - return undefined +function getTranslationFunctionInfoFromType( + typeNode: ts.TypeNode | undefined, + catalog: Catalog, +): TranslationFunctionInfo | undefined { + if (!typeNode) return undefined if (ts.isTypeReferenceNode(typeNode) && /(?:^|\.)TFunction$/.test(typeNode.typeName.getText())) { const namespace = getStringLiteralTypeValue(typeNode.typeArguments?.[0]) @@ -223,15 +241,18 @@ function getTranslationFunctionInfoFromType(typeNode: ts.TypeNode | undefined, c return undefined } -function addTranslationFunction(scope: Scope, name: string, info: TranslationFunctionInfo = { namespaces: [DEFAULT_NAMESPACE], keyPrefix: undefined }) { +function addTranslationFunction( + scope: Scope, + name: string, + info: TranslationFunctionInfo = { namespaces: [DEFAULT_NAMESPACE], keyPrefix: undefined }, +) { scope.translationFunctions.set(name, info) } function lookupValue(name: string, scopes: Scope[]) { for (let index = scopes.length - 1; index >= 0; index--) { const value = scopes[index]!.values.get(name) - if (value) - return value + if (value) return value } return undefined } @@ -239,8 +260,7 @@ function lookupValue(name: string, scopes: Scope[]) { function lookupTranslationFunction(name: string, scopes: Scope[]) { for (let index = scopes.length - 1; index >= 0; index--) { const value = scopes[index]!.translationFunctions.get(name) - if (value) - return value + if (value) return value } return undefined } @@ -248,26 +268,26 @@ function lookupTranslationFunction(name: string, scopes: Scope[]) { function evaluateStaticExpression(expression: ts.Expression, scopes: Scope[]): StaticValue { const unwrapped = unwrapExpression(expression) - if (isStringNode(unwrapped)) - return staticStrings([unwrapped.text]) + if (isStringNode(unwrapped)) return staticStrings([unwrapped.text]) if (ts.isTemplateExpression(unwrapped)) { let values = [unwrapped.head.text] for (const span of unwrapped.templateSpans) { const expressionValue = evaluateStaticExpression(span.expression, scopes) - if (expressionValue.kind !== 'strings') - return unknownStaticValue() + if (expressionValue.kind !== 'strings') return unknownStaticValue() values = combineStringValues(values, expressionValue.values) - if (!values.length) - return unknownStaticValue() + if (!values.length) return unknownStaticValue() - values = values.map(value => `${value}${span.literal.text}`) + values = values.map((value) => `${value}${span.literal.text}`) } return staticStrings(values) } - if (ts.isBinaryExpression(unwrapped) && unwrapped.operatorToken.kind === ts.SyntaxKind.PlusToken) { + if ( + ts.isBinaryExpression(unwrapped) && + unwrapped.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { const left = evaluateStaticExpression(unwrapped.left, scopes) const right = evaluateStaticExpression(unwrapped.right, scopes) if (left.kind === 'strings' && right.kind === 'strings') { @@ -278,8 +298,9 @@ function evaluateStaticExpression(expression: ts.Expression, scopes: Scope[]): S } if ( - ts.isBinaryExpression(unwrapped) - && (unwrapped.operatorToken.kind === ts.SyntaxKind.BarBarToken || unwrapped.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) + ts.isBinaryExpression(unwrapped) && + (unwrapped.operatorToken.kind === ts.SyntaxKind.BarBarToken || + unwrapped.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) ) { const left = evaluateStaticExpression(unwrapped.left, scopes) const right = evaluateStaticExpression(unwrapped.right, scopes) @@ -295,22 +316,28 @@ function evaluateStaticExpression(expression: ts.Expression, scopes: Scope[]): S return staticStrings([...whenTrue.values, ...whenFalse.values]) const trueExpression = unwrapExpression(unwrapped.whenTrue) const falseExpression = unwrapExpression(unwrapped.whenFalse) - if (ts.isIdentifier(falseExpression) && falseExpression.text === 'undefined' && (whenTrue.kind === 'selector' || whenTrue.kind === 'selectors')) + if ( + ts.isIdentifier(falseExpression) && + falseExpression.text === 'undefined' && + (whenTrue.kind === 'selector' || whenTrue.kind === 'selectors') + ) return whenTrue - if (ts.isIdentifier(trueExpression) && trueExpression.text === 'undefined' && (whenFalse.kind === 'selector' || whenFalse.kind === 'selectors')) + if ( + ts.isIdentifier(trueExpression) && + trueExpression.text === 'undefined' && + (whenFalse.kind === 'selector' || whenFalse.kind === 'selectors') + ) return whenFalse return unknownStaticValue() } - if (ts.isIdentifier(unwrapped)) - return lookupValue(unwrapped.text, scopes) ?? unknownStaticValue() + if (ts.isIdentifier(unwrapped)) return lookupValue(unwrapped.text, scopes) ?? unknownStaticValue() if (ts.isPropertyAccessExpression(unwrapped)) { const container = evaluateStaticExpression(unwrapped.expression, scopes) if (container.kind === 'object') { const value = container.properties.get(unwrapped.name.text) - if (value && container.complete && !container.unknownKeyValues.length) - return value + if (value && container.complete && !container.unknownKeyValues.length) return value if (value || !container.complete || container.unknownKeyValues.length) return unknownStaticValue() } @@ -322,9 +349,11 @@ function evaluateStaticExpression(expression: ts.Expression, scopes: Scope[]): S const argument = evaluateStaticExpression(unwrapped.argumentExpression, scopes) if (container.kind === 'object') { if (argument.kind !== 'strings') { - if (!container.complete) - return unknownStaticValue() - return collectIndexedValues([...container.properties.values(), ...container.unknownKeyValues]) + if (!container.complete) return unknownStaticValue() + return collectIndexedValues([ + ...container.properties.values(), + ...container.unknownKeyValues, + ]) } const selectedValues: StaticValue[] = [] @@ -332,20 +361,20 @@ function evaluateStaticExpression(expression: ts.Expression, scopes: Scope[]): S const value = container.properties.get(key) if (value && container.complete && !container.unknownKeyValues.length) selectedValues.push(value) - else - return unknownStaticValue() + else return unknownStaticValue() } return collectIndexedValues(selectedValues) } if (container.kind === 'array') { - if (argument.kind !== 'strings') - return collectIndexedValues(container.elements) - - return collectIndexedValues(argument.values.flatMap((key) => { - const index = Number(key) - return Number.isInteger(index) ? container.elements[index] ?? [] : [] - })) + if (argument.kind !== 'strings') return collectIndexedValues(container.elements) + + return collectIndexedValues( + argument.values.flatMap((key) => { + const index = Number(key) + return Number.isInteger(index) ? (container.elements[index] ?? []) : [] + }), + ) } } @@ -365,43 +394,46 @@ function evaluateStaticExpression(expression: ts.Expression, scopes: Scope[]): S if (propertyNames.kind === 'strings') { for (const propertyName of propertyNames.values) properties.set(propertyName, propertyValue) - } - else { + } else { unknownKeyValues.push(propertyValue) } continue } const propertyName = getPropertyNameText(property.name) - if (propertyName) - properties.set(propertyName, propertyValue) - else - complete = false + if (propertyName) properties.set(propertyName, propertyValue) + else complete = false } return { kind: 'object', properties, unknownKeyValues, complete } } if (ts.isArrayLiteralExpression(unwrapped)) - return { kind: 'array', elements: unwrapped.elements.map(element => evaluateStaticExpression(element, scopes)) } + return { + kind: 'array', + elements: unwrapped.elements.map((element) => evaluateStaticExpression(element, scopes)), + } if (ts.isArrowFunction(unwrapped)) { - if (getSelectorKeyExpression(unwrapped)) - return { kind: 'selector', expression: unwrapped } + if (getSelectorKeyExpression(unwrapped)) return { kind: 'selector', expression: unwrapped } const parameter = unwrapped.parameters[0] const body = ts.isBlock(unwrapped.body) ? undefined : unwrapExpression(unwrapped.body) if ( - parameter - && ts.isIdentifier(parameter.name) - && body - && ts.isIdentifier(body) - && body.text === parameter.name.text + parameter && + ts.isIdentifier(parameter.name) && + body && + ts.isIdentifier(body) && + body.text === parameter.name.text ) { return { kind: 'identityFunction' } } } - if (ts.isCallExpression(unwrapped) && ts.isIdentifier(unwrapped.expression) && unwrapped.arguments.length === 1) { + if ( + ts.isCallExpression(unwrapped) && + ts.isIdentifier(unwrapped.expression) && + unwrapped.arguments.length === 1 + ) { const callee = lookupValue(unwrapped.expression.text, scopes) if (callee?.kind === 'identityFunction') return evaluateStaticExpression(unwrapped.arguments[0]! as ts.Expression, scopes) @@ -411,100 +443,120 @@ function evaluateStaticExpression(expression: ts.Expression, scopes: Scope[]): S } function collectStringValues(value: StaticValue): string[] { - if (value.kind === 'strings') - return value.values + if (value.kind === 'strings') return value.values if (value.kind === 'array') - return uniqueSorted(value.elements.flatMap(element => collectStringValues(element))) + return uniqueSorted(value.elements.flatMap((element) => collectStringValues(element))) if (value.kind === 'object') - return uniqueSorted([...value.properties.values(), ...value.unknownKeyValues].flatMap(property => collectStringValues(property))) + return uniqueSorted( + [...value.properties.values(), ...value.unknownKeyValues].flatMap((property) => + collectStringValues(property), + ), + ) return [] } function collectSelectorExpressions(value: StaticValue): ts.Expression[] { - if (value.kind === 'selector') - return [value.expression] - if (value.kind === 'selectors') - return value.expressions + if (value.kind === 'selector') return [value.expression] + if (value.kind === 'selectors') return value.expressions if (value.kind === 'array') - return value.elements.flatMap(element => collectSelectorExpressions(element)) + return value.elements.flatMap((element) => collectSelectorExpressions(element)) if (value.kind === 'object') - return [...value.properties.values(), ...value.unknownKeyValues].flatMap(property => collectSelectorExpressions(property)) + return [...value.properties.values(), ...value.unknownKeyValues].flatMap((property) => + collectSelectorExpressions(property), + ) return [] } function hasUnknownAggregateValue(value: StaticValue): boolean { - if (value.kind === 'unknown' || value.kind === 'identityFunction') - return true + if (value.kind === 'unknown' || value.kind === 'identityFunction') return true if (value.kind === 'array') - return value.elements.some(element => hasUnknownAggregateValue(element)) + return value.elements.some((element) => hasUnknownAggregateValue(element)) if (value.kind === 'object') - return !value.complete || [...value.properties.values(), ...value.unknownKeyValues].some(property => hasUnknownAggregateValue(property)) + return ( + !value.complete || + [...value.properties.values(), ...value.unknownKeyValues].some((property) => + hasUnknownAggregateValue(property), + ) + ) return false } function collectIndexedValues(values: StaticValue[]): StaticValue { - if (!values.length) - return unknownStaticValue() + if (!values.length) return unknownStaticValue() - if (values.every((value): value is Extract => value.kind === 'object')) { + if ( + values.every( + (value): value is Extract => value.kind === 'object', + ) + ) { const propertyNames = Array.from(values[0]!.properties.keys()) const haveMatchingShape = values.every((value) => { - return value.complete - && !value.unknownKeyValues.length - && value.properties.size === propertyNames.length - && propertyNames.every(propertyName => value.properties.has(propertyName)) + return ( + value.complete && + !value.unknownKeyValues.length && + value.properties.size === propertyNames.length && + propertyNames.every((propertyName) => value.properties.has(propertyName)) + ) }) - if (!haveMatchingShape) - return unknownStaticValue() + if (!haveMatchingShape) return unknownStaticValue() return { kind: 'object', - properties: new Map(propertyNames.map((propertyName) => { - return [propertyName, collectIndexedValues(values.map(value => value.properties.get(propertyName)!))] - })), + properties: new Map( + propertyNames.map((propertyName) => { + return [ + propertyName, + collectIndexedValues(values.map((value) => value.properties.get(propertyName)!)), + ] + }), + ), unknownKeyValues: [], complete: true, } } - if (values.some(value => hasUnknownAggregateValue(value))) - return unknownStaticValue() + if (values.some((value) => hasUnknownAggregateValue(value))) return unknownStaticValue() - const selectors = values.flatMap(value => collectSelectorExpressions(value)) - const strings = values.flatMap(value => collectStringValues(value)) - if (selectors.length && strings.length) - return unknownStaticValue() + const selectors = values.flatMap((value) => collectSelectorExpressions(value)) + const strings = values.flatMap((value) => collectStringValues(value)) + if (selectors.length && strings.length) return unknownStaticValue() if (selectors.length) - return selectors.length === 1 ? { kind: 'selector', expression: selectors[0]! } : { kind: 'selectors', expressions: selectors } + return selectors.length === 1 + ? { kind: 'selector', expression: selectors[0]! } + : { kind: 'selectors', expressions: selectors } return strings.length ? staticStrings(strings) : unknownStaticValue() } function getObjectProperty(objectLiteral: ts.ObjectLiteralExpression, name: string) { return objectLiteral.properties.find((property): property is ts.PropertyAssignment => { - if (!ts.isPropertyAssignment(property)) - return false + if (!ts.isPropertyAssignment(property)) return false const propertyName = getPropertyNameText(property.name) return propertyName === name }) } -function evaluateNamespaceExpression(expression: ts.Expression | undefined, scopes: Scope[], catalog: Catalog) { - if (!expression) - return [] +function evaluateNamespaceExpression( + expression: ts.Expression | undefined, + scopes: Scope[], + catalog: Catalog, +) { + if (!expression) return [] const value = evaluateStaticExpression(expression, scopes) if (value.kind === 'strings') - return uniqueSorted(value.values.map(namespace => normalizeNamespace(namespace, catalog))) + return uniqueSorted(value.values.map((namespace) => normalizeNamespace(namespace, catalog))) if (value.kind === 'array') { const namespaces: string[] = [] for (const element of value.elements) { if (element.kind === 'strings') - namespaces.push(...element.values.map(namespace => normalizeNamespace(namespace, catalog))) + namespaces.push( + ...element.values.map((namespace) => normalizeNamespace(namespace, catalog)), + ) } return uniqueSorted(namespaces) } @@ -513,11 +565,7 @@ function evaluateNamespaceExpression(expression: ts.Expression | undefined, scop } function getStringLiteralTypeValue(typeNode: ts.TypeNode | undefined) { - if ( - typeNode - && ts.isLiteralTypeNode(typeNode) - && ts.isStringLiteral(typeNode.literal) - ) { + if (typeNode && ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) { return typeNode.literal.text } @@ -526,23 +574,18 @@ function getStringLiteralTypeValue(typeNode: ts.TypeNode | undefined) { function evaluateAssertedTypeKeyUsage(expression: ts.Expression): KeyExpressionUsage | undefined { const unwrapped = expression - if (!ts.isAsExpression(unwrapped) && !ts.isTypeAssertionExpression(unwrapped)) - return undefined + if (!ts.isAsExpression(unwrapped) && !ts.isTypeAssertionExpression(unwrapped)) return undefined const literalValue = getStringLiteralTypeValue(unwrapped.type) - if (literalValue) - return { kind: 'keys', keys: [literalValue] } + if (literalValue) return { kind: 'keys', keys: [literalValue] } - if (!ts.isTypeReferenceNode(unwrapped.type)) - return undefined + if (!ts.isTypeReferenceNode(unwrapped.type)) return undefined const typeName = unwrapped.type.typeName.getText() - if (typeName !== 'I18nKeysByPrefix' && typeName !== 'I18nKeysWithPrefix') - return undefined + if (typeName !== 'I18nKeysByPrefix' && typeName !== 'I18nKeysWithPrefix') return undefined const prefix = getStringLiteralTypeValue(unwrapped.type.typeArguments?.[1]) - if (!prefix) - return undefined + if (!prefix) return undefined return { kind: 'patterns', @@ -550,27 +593,25 @@ function evaluateAssertedTypeKeyUsage(expression: ts.Expression): KeyExpressionU } } -function extractStringValuesFromType(type: ts.Type, seen = new Set()): string[] | undefined { - if (seen.has(type)) - return undefined +function extractStringValuesFromType( + type: ts.Type, + seen = new Set(), +): string[] | undefined { + if (seen.has(type)) return undefined seen.add(type) - if (type.isStringLiteral()) - return [type.value] + if (type.isStringLiteral()) return [type.value] if (type.isUnion()) { const values: string[] = [] for (const subtype of type.types) { - if (subtype.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void)) - continue + if (subtype.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void)) continue const subtypeValues = extractStringValuesFromType(subtype, seen) - if (!subtypeValues) - return undefined + if (!subtypeValues) return undefined values.push(...subtypeValues) - if (values.length > MAX_EXPANDED_VALUES) - return undefined + if (values.length > MAX_EXPANDED_VALUES) return undefined } return values.length ? uniqueSorted(values) : undefined } @@ -578,38 +619,42 @@ function extractStringValuesFromType(type: ts.Type, seen = new Set()): return undefined } -function evaluateCheckerKeyUsage(expression: ts.Expression, checker?: ts.TypeChecker): KeyExpressionUsage | undefined { - if (!checker) - return undefined +function evaluateCheckerKeyUsage( + expression: ts.Expression, + checker?: ts.TypeChecker, +): KeyExpressionUsage | undefined { + if (!checker) return undefined const values = extractStringValuesFromType(checker.getTypeAtLocation(expression)) - if (!values?.length) - return undefined + if (!values?.length) return undefined return { kind: 'keys', keys: values } } -function isSelectorParamType(type: ts.Type, checker: ts.TypeChecker, seen = new Set()): boolean { - if (seen.has(type)) - return false +function isSelectorParamType( + type: ts.Type, + checker: ts.TypeChecker, + seen = new Set(), +): boolean { + if (seen.has(type)) return false seen.add(type) - if (type.aliasSymbol?.getName() === 'SelectorParam') - return true + if (type.aliasSymbol?.getName() === 'SelectorParam') return true const constraint = checker.getBaseConstraintOfType(type) - return Boolean(constraint && constraint !== type && isSelectorParamType(constraint, checker, seen)) + return Boolean( + constraint && constraint !== type && isSelectorParamType(constraint, checker, seen), + ) } function selectorNamespacesFromTypes(types: Array, catalog: Catalog) { for (const type of types) { const namespaceType = type?.aliasTypeArguments?.[0] - if (!namespaceType) - continue + if (!namespaceType) continue const namespaces = extractStringValuesFromType(namespaceType) - ?.map(namespace => normalizeNamespace(namespace, catalog)) - .filter(namespace => catalog.keysByNamespace.has(namespace)) + ?.map((namespace) => normalizeNamespace(namespace, catalog)) + .filter((namespace) => catalog.keysByNamespace.has(namespace)) if (namespaces?.length && namespaces.length < catalog.keysByNamespace.size) return uniqueSorted(namespaces) } @@ -624,23 +669,30 @@ function selectorNamespacesFromTFunctionBrand( catalog: Catalog, ) { const brand = type.getProperty('$TFunctionBrand') - if (!brand) - return [] + if (!brand) return [] const brandType = checker.getTypeOfSymbolAtLocation(brand, expression) return uniqueSorted( (extractStringValuesFromType(brandType) ?? []) - .map(namespace => normalizeNamespace(namespace, catalog)) - .filter(namespace => catalog.keysByNamespace.has(namespace)), + .map((namespace) => normalizeNamespace(namespace, catalog)) + .filter((namespace) => catalog.keysByNamespace.has(namespace)), ) } -function getCheckerTranslationFunctionInfo(expression: ts.Node, checker: ts.TypeChecker | undefined, catalog: Catalog): TranslationFunctionInfo | undefined { - if (!checker) - return undefined +function getCheckerTranslationFunctionInfo( + expression: ts.Node, + checker: ts.TypeChecker | undefined, + catalog: Catalog, +): TranslationFunctionInfo | undefined { + if (!checker) return undefined const calleeType = checker.getTypeAtLocation(expression) - const brandedNamespaces = selectorNamespacesFromTFunctionBrand(calleeType, expression, checker, catalog) + const brandedNamespaces = selectorNamespacesFromTFunctionBrand( + calleeType, + expression, + checker, + catalog, + ) if (brandedNamespaces.length) { return { namespaces: brandedNamespaces, @@ -653,8 +705,7 @@ function getCheckerTranslationFunctionInfo(expression: ts.Node, checker: ts.Type for (const [parameterIndex, parameter] of signature.parameters.entries()) { const parameterType = checker.getTypeOfSymbolAtLocation(parameter, expression) const constraint = checker.getBaseConstraintOfType(parameterType) - if (!isSelectorParamType(parameterType, checker)) - continue + if (!isSelectorParamType(parameterType, checker)) continue return { namespaces: selectorNamespacesFromTypes([calleeType, parameterType, constraint], catalog), @@ -667,9 +718,11 @@ function getCheckerTranslationFunctionInfo(expression: ts.Node, checker: ts.Type return undefined } -function combineKeyExpressionUsages(left: KeyExpressionUsage, right: KeyExpressionUsage): KeyExpressionUsage { - if (left.kind === 'unknown' || right.kind === 'unknown') - return { kind: 'unknown' } +function combineKeyExpressionUsages( + left: KeyExpressionUsage, + right: KeyExpressionUsage, +): KeyExpressionUsage { + if (left.kind === 'unknown' || right.kind === 'unknown') return { kind: 'unknown' } const keys = [ ...(left.kind === 'keys' || left.kind === 'mixed' ? left.keys : []), @@ -680,11 +733,9 @@ function combineKeyExpressionUsages(left: KeyExpressionUsage, right: KeyExpressi ...(right.kind === 'patterns' || right.kind === 'mixed' ? right.patterns : []), ] - if (keys.length && patterns.length) - return { kind: 'mixed', keys: uniqueSorted(keys), patterns } + if (keys.length && patterns.length) return { kind: 'mixed', keys: uniqueSorted(keys), patterns } - if (keys.length) - return { kind: 'keys', keys: uniqueSorted(keys) } + if (keys.length) return { kind: 'keys', keys: uniqueSorted(keys) } return { kind: 'patterns', patterns } } @@ -692,19 +743,19 @@ function combineKeyExpressionUsages(left: KeyExpressionUsage, right: KeyExpressi function getSelectorNamespace(source: ts.Expression, parameterName: string) { const unwrapped = unwrapExpression(source) if ( - ts.isPropertyAccessExpression(unwrapped) - && ts.isIdentifier(unwrapped.expression) - && unwrapped.expression.text === parameterName + ts.isPropertyAccessExpression(unwrapped) && + ts.isIdentifier(unwrapped.expression) && + unwrapped.expression.text === parameterName ) { return unwrapped.name.text } if ( - ts.isElementAccessExpression(unwrapped) - && ts.isIdentifier(unwrapped.expression) - && unwrapped.expression.text === parameterName - && unwrapped.argumentExpression - && isStringNode(unwrapExpression(unwrapped.argumentExpression)) + ts.isElementAccessExpression(unwrapped) && + ts.isIdentifier(unwrapped.expression) && + unwrapped.expression.text === parameterName && + unwrapped.argumentExpression && + isStringNode(unwrapExpression(unwrapped.argumentExpression)) ) { return (unwrapExpression(unwrapped.argumentExpression) as ts.StringLiteralLike).text } @@ -714,12 +765,10 @@ function getSelectorNamespace(source: ts.Expression, parameterName: string) { function getSelectorKeyExpression(expression: ts.Expression) { const selector = unwrapExpression(expression) - if (!ts.isArrowFunction(selector) || ts.isBlock(selector.body)) - return undefined + if (!ts.isArrowFunction(selector) || ts.isBlock(selector.body)) return undefined const parameter = selector.parameters[0] - if (!parameter || !ts.isIdentifier(parameter.name)) - return undefined + if (!parameter || !ts.isIdentifier(parameter.name)) return undefined const body = unwrapExpression(selector.body) let keyExpression: ts.Expression | undefined @@ -727,18 +776,15 @@ function getSelectorKeyExpression(expression: ts.Expression) { if (ts.isPropertyAccessExpression(body)) { keyExpression = ts.factory.createStringLiteral(body.name.text) sourceExpression = body.expression - } - else if (ts.isElementAccessExpression(body) && body.argumentExpression) { + } else if (ts.isElementAccessExpression(body) && body.argumentExpression) { keyExpression = body.argumentExpression sourceExpression = body.expression } - if (!keyExpression || !sourceExpression) - return undefined + if (!keyExpression || !sourceExpression) return undefined const source = unwrapExpression(sourceExpression) - if (ts.isIdentifier(source) && source.text === parameter.name.text) - return { keyExpression } + if (ts.isIdentifier(source) && source.text === parameter.name.text) return { keyExpression } const namespace = getSelectorNamespace(source, parameter.name.text) if (namespace) { @@ -752,20 +798,18 @@ function getSelectorKeyExpression(expression: ts.Expression) { } function prependNamespace(usage: KeyExpressionUsage, namespace?: string): KeyExpressionUsage { - if (!namespace) - return usage + if (!namespace) return usage - if (usage.kind === 'unknown') - return { kind: 'unknown', namespace } + if (usage.kind === 'unknown') return { kind: 'unknown', namespace } const prefix = `${namespace}:` if (usage.kind === 'keys') - return { kind: 'keys', keys: usage.keys.map(key => `${prefix}${key}`) } + return { kind: 'keys', keys: usage.keys.map((key) => `${prefix}${key}`) } if (usage.kind === 'patterns') { return { kind: 'patterns', - patterns: usage.patterns.map(pattern => ({ + patterns: usage.patterns.map((pattern) => ({ prefix: `${prefix}${pattern.prefix}`, suffix: pattern.suffix, })), @@ -774,15 +818,19 @@ function prependNamespace(usage: KeyExpressionUsage, namespace?: string): KeyExp return { kind: 'mixed', - keys: usage.keys.map(key => `${prefix}${key}`), - patterns: usage.patterns.map(pattern => ({ + keys: usage.keys.map((key) => `${prefix}${key}`), + patterns: usage.patterns.map((pattern) => ({ prefix: `${prefix}${pattern.prefix}`, suffix: pattern.suffix, })), } } -function evaluateKeyExpression(expression: ts.Expression, scopes: Scope[], checker?: ts.TypeChecker): KeyExpressionUsage { +function evaluateKeyExpression( + expression: ts.Expression, + scopes: Scope[], + checker?: ts.TypeChecker, +): KeyExpressionUsage { const selector = getSelectorKeyExpression(expression) if (selector) { return prependNamespace( @@ -792,23 +840,19 @@ function evaluateKeyExpression(expression: ts.Expression, scopes: Scope[], check } const value = evaluateStaticExpression(expression, scopes) - if (value.kind === 'strings') - return { kind: 'keys', keys: value.values } - if (value.kind === 'selector') - return evaluateKeyExpression(value.expression, scopes, checker) + if (value.kind === 'strings') return { kind: 'keys', keys: value.values } + if (value.kind === 'selector') return evaluateKeyExpression(value.expression, scopes, checker) if (value.kind === 'selectors') { return value.expressions - .map(selectorExpression => evaluateKeyExpression(selectorExpression, scopes, checker)) + .map((selectorExpression) => evaluateKeyExpression(selectorExpression, scopes, checker)) .reduce(combineKeyExpressionUsages) } const assertedTypeUsage = evaluateAssertedTypeKeyUsage(expression) - if (assertedTypeUsage) - return assertedTypeUsage + if (assertedTypeUsage) return assertedTypeUsage const checkerUsage = evaluateCheckerKeyUsage(expression, checker) - if (checkerUsage) - return checkerUsage + if (checkerUsage) return checkerUsage const unwrapped = unwrapExpression(expression) if (ts.isConditionalExpression(unwrapped)) { @@ -818,16 +862,16 @@ function evaluateKeyExpression(expression: ts.Expression, scopes: Scope[], check ) } - if (!ts.isTemplateExpression(unwrapped)) - return { kind: 'unknown' } + if (!ts.isTemplateExpression(unwrapped)) return { kind: 'unknown' } const spans = unwrapped.templateSpans const unknownIndexes = spans - .map((span, index) => evaluateStaticExpression(span.expression, scopes).kind === 'strings' ? -1 : index) - .filter(index => index >= 0) + .map((span, index) => + evaluateStaticExpression(span.expression, scopes).kind === 'strings' ? -1 : index, + ) + .filter((index) => index >= 0) - if (!unknownIndexes.length) - return { kind: 'unknown' } + if (!unknownIndexes.length) return { kind: 'unknown' } const firstUnknownIndex = unknownIndexes[0]! const lastUnknownIndex = unknownIndexes.at(-1)! @@ -836,14 +880,12 @@ function evaluateKeyExpression(expression: ts.Expression, scopes: Scope[], check for (let index = 0; index < firstUnknownIndex; index++) { const span = spans[index]! const spanValue = evaluateStaticExpression(span.expression, scopes) - if (spanValue.kind !== 'strings') - break + if (spanValue.kind !== 'strings') break prefixes = combineStringValues(prefixes, spanValue.values) - if (!prefixes.length) - return { kind: 'unknown' } + if (!prefixes.length) return { kind: 'unknown' } - prefixes = prefixes.map(prefix => `${prefix}${span.literal.text}`) + prefixes = prefixes.map((prefix) => `${prefix}${span.literal.text}`) } let suffixes = [spans[lastUnknownIndex]!.literal.text] @@ -852,33 +894,30 @@ function evaluateKeyExpression(expression: ts.Expression, scopes: Scope[], check const spanValue = evaluateStaticExpression(span.expression, scopes) if (spanValue.kind !== 'strings') { suffixes = [''] - } - else { + } else { suffixes = combineStringValues(suffixes, spanValue.values) - if (!suffixes.length) - suffixes = [''] + if (!suffixes.length) suffixes = [''] } - suffixes = suffixes.map(suffix => `${suffix}${span.literal.text}`) + suffixes = suffixes.map((suffix) => `${suffix}${span.literal.text}`) } return { kind: 'patterns', - patterns: prefixes.flatMap(prefix => suffixes.map(suffix => ({ prefix, suffix }))), + patterns: prefixes.flatMap((prefix) => suffixes.map((suffix) => ({ prefix, suffix }))), } } function prependKeyPrefix(usage: KeyExpressionUsage, keyPrefix?: string): KeyExpressionUsage { - if (!keyPrefix) - return usage + if (!keyPrefix) return usage const prefix = `${keyPrefix}.` if (usage.kind === 'keys') - return { kind: 'keys', keys: usage.keys.map(key => `${prefix}${key}`) } + return { kind: 'keys', keys: usage.keys.map((key) => `${prefix}${key}`) } if (usage.kind === 'patterns') { return { kind: 'patterns', - patterns: usage.patterns.map(pattern => ({ + patterns: usage.patterns.map((pattern) => ({ prefix: `${prefix}${pattern.prefix}`, suffix: pattern.suffix, })), @@ -891,50 +930,61 @@ function prependKeyPrefix(usage: KeyExpressionUsage, keyPrefix?: string): KeyExp function getLastOptionsObject(callExpression: ts.CallExpression) { for (let index = callExpression.arguments.length - 1; index >= 1; index--) { const argument = unwrapExpression(callExpression.arguments[index]! as ts.Expression) - if (ts.isObjectLiteralExpression(argument)) - return argument + if (ts.isObjectLiteralExpression(argument)) return argument } return undefined } -function getUseTranslationInfo(callExpression: ts.CallExpression, scopes: Scope[], catalog: Catalog): TranslationFunctionInfo { - const namespaces = evaluateNamespaceExpression(callExpression.arguments[0] as ts.Expression | undefined, scopes, catalog) +function getUseTranslationInfo( + callExpression: ts.CallExpression, + scopes: Scope[], + catalog: Catalog, +): TranslationFunctionInfo { + const namespaces = evaluateNamespaceExpression( + callExpression.arguments[0] as ts.Expression | undefined, + scopes, + catalog, + ) const options = callExpression.arguments[1] const optionsObject = options ? unwrapExpression(options as ts.Expression) : undefined - const keyPrefixProperty = optionsObject && ts.isObjectLiteralExpression(optionsObject) - ? getObjectProperty(optionsObject, 'keyPrefix') - : undefined + const keyPrefixProperty = + optionsObject && ts.isObjectLiteralExpression(optionsObject) + ? getObjectProperty(optionsObject, 'keyPrefix') + : undefined const keyPrefixValue = keyPrefixProperty ? evaluateStaticExpression(keyPrefixProperty.initializer, scopes) : undefined return { namespaces: callExpression.arguments[0] ? namespaces : [DEFAULT_NAMESPACE], - keyPrefix: keyPrefixValue?.kind === 'strings' && keyPrefixValue.values.length === 1 - ? keyPrefixValue.values[0] - : undefined, + keyPrefix: + keyPrefixValue?.kind === 'strings' && keyPrefixValue.values.length === 1 + ? keyPrefixValue.values[0] + : undefined, } } function addExactKey(collector: UsageCollector, namespace: string, key: string, catalog: Catalog) { - if (!collector.exactKeys.has(namespace)) - collector.exactKeys.set(namespace, new Set()) + if (!collector.exactKeys.has(namespace)) collector.exactKeys.set(namespace, new Set()) const exactKeys = collector.exactKeys.get(namespace)! exactKeys.add(key) const namespaceKeys = catalog.keysByNamespace.get(namespace) - if (!namespaceKeys) - return + if (!namespaceKeys) return for (const suffix of PLURAL_SUFFIXES) { const pluralKey = `${key}${suffix}` - if (namespaceKeys.has(pluralKey)) - exactKeys.add(pluralKey) + if (namespaceKeys.has(pluralKey)) exactKeys.add(pluralKey) } } -function addPattern(collector: UsageCollector, namespace: string, pattern: { prefix: string, suffix: string }, location: UsageLocation) { +function addPattern( + collector: UsageCollector, + namespace: string, + pattern: { prefix: string; suffix: string }, + location: UsageLocation, +) { if (!pattern.prefix && !pattern.suffix) { collector.protectedNamespaces.add(namespace) return @@ -950,12 +1000,10 @@ function addPattern(collector: UsageCollector, namespace: string, pattern: { pre function splitNamespaceKey(key: string, catalog: Catalog) { const separatorIndex = key.indexOf(':') - if (separatorIndex <= 0) - return undefined + if (separatorIndex <= 0) return undefined const namespace = normalizeNamespace(key.slice(0, separatorIndex), catalog) - if (!catalog.keysByNamespace.has(namespace)) - return undefined + if (!catalog.keysByNamespace.has(namespace)) return undefined return { namespace, @@ -966,21 +1014,20 @@ function splitNamespaceKey(key: string, catalog: Catalog) { function inferNamespacesForKey(key: string, catalog: Catalog) { const namespaces: string[] = [] for (const [namespace, keys] of catalog.keysByNamespace.entries()) { - if (keys.has(key) || PLURAL_SUFFIXES.some(suffix => keys.has(`${key}${suffix}`))) + if (keys.has(key) || PLURAL_SUFFIXES.some((suffix) => keys.has(`${key}${suffix}`))) namespaces.push(namespace) } return uniqueSorted(namespaces) } -function patternMatchesKey(pattern: { prefix: string, suffix: string }, key: string) { +function patternMatchesKey(pattern: { prefix: string; suffix: string }, key: string) { return key.startsWith(pattern.prefix) && key.endsWith(pattern.suffix) } -function inferNamespacesForPattern(pattern: { prefix: string, suffix: string }, catalog: Catalog) { +function inferNamespacesForPattern(pattern: { prefix: string; suffix: string }, catalog: Catalog) { const namespaces: string[] = [] for (const [namespace, keys] of catalog.keysByNamespace.entries()) { - if (Array.from(keys).some(key => patternMatchesKey(pattern, key))) - namespaces.push(namespace) + if (Array.from(keys).some((key) => patternMatchesKey(pattern, key))) namespaces.push(namespace) } return uniqueSorted(namespaces) } @@ -1000,9 +1047,10 @@ function recordUnknownUsage( location: UsageLocation, reason: string, ) { - const targetNamespaces = namespaces.length ? namespaces : Array.from(catalog.keysByNamespace.keys()) - for (const namespace of targetNamespaces) - collector.protectedNamespaces.add(namespace) + const targetNamespaces = namespaces.length + ? namespaces + : Array.from(catalog.keysByNamespace.keys()) + for (const namespace of targetNamespaces) collector.protectedNamespaces.add(namespace) collector.unresolvedUsages.push({ ...location, @@ -1022,7 +1070,13 @@ function recordKeyUsage( const targetNamespaces = usage.namespace ? [normalizeNamespace(usage.namespace, catalog)] : namespaces - recordUnknownUsage(collector, targetNamespaces, catalog, location, 'Unable to statically resolve translation key') + recordUnknownUsage( + collector, + targetNamespaces, + catalog, + location, + 'Unable to statically resolve translation key', + ) return } @@ -1034,52 +1088,60 @@ function recordKeyUsage( continue } - const targetNamespaces = namespaces.length ? namespaces : inferNamespacesForKey(rawKey, catalog) + const targetNamespaces = namespaces.length + ? namespaces + : inferNamespacesForKey(rawKey, catalog) if (!targetNamespaces.length) { - collector.unresolvedUsages.push({ ...location, reason: `No namespace contains key "${rawKey}"` }) + collector.unresolvedUsages.push({ + ...location, + reason: `No namespace contains key "${rawKey}"`, + }) continue } - for (const namespace of targetNamespaces) - addExactKey(collector, namespace, rawKey, catalog) + for (const namespace of targetNamespaces) addExactKey(collector, namespace, rawKey, catalog) } } - if (usage.kind === 'keys') - return + if (usage.kind === 'keys') return const patterns = usage.kind === 'mixed' ? usage.patterns : usage.patterns for (const rawPattern of patterns) { const namespacedPattern = splitNamespaceKey(rawPattern.prefix, catalog) if (namespacedPattern) { - addPattern(collector, namespacedPattern.namespace, { - prefix: namespacedPattern.key, - suffix: rawPattern.suffix, - }, location) + addPattern( + collector, + namespacedPattern.namespace, + { + prefix: namespacedPattern.key, + suffix: rawPattern.suffix, + }, + location, + ) continue } - const targetNamespaces = namespaces.length ? namespaces : inferNamespacesForPattern(rawPattern, catalog) - if (!targetNamespaces.length) - continue + const targetNamespaces = namespaces.length + ? namespaces + : inferNamespacesForPattern(rawPattern, catalog) + if (!targetNamespaces.length) continue - for (const namespace of targetNamespaces) - addPattern(collector, namespace, rawPattern, location) + for (const namespace of targetNamespaces) addPattern(collector, namespace, rawPattern, location) } } function getJsxAttribute(element: ts.JsxOpeningLikeElement, name: string) { return element.attributes.properties.find((property): property is ts.JsxAttribute => { - return ts.isJsxAttribute(property) && ts.isIdentifier(property.name) && property.name.text === name + return ( + ts.isJsxAttribute(property) && ts.isIdentifier(property.name) && property.name.text === name + ) }) } function getJsxAttributeExpression(attribute: ts.JsxAttribute | undefined) { - if (!attribute?.initializer) - return undefined + if (!attribute?.initializer) return undefined - if (isStringNode(attribute.initializer)) - return attribute.initializer + if (isStringNode(attribute.initializer)) return attribute.initializer if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression) return attribute.initializer.expression @@ -1094,8 +1156,7 @@ function isSelectorTranslationForwarder( checker: ts.TypeChecker | undefined, catalog: Catalog, ) { - if (!ts.isIdentifier(keyExpression)) - return false + if (!ts.isIdentifier(keyExpression)) return false let current: ts.Node | undefined = node.parent let functionLike: ts.FunctionLikeDeclaration | undefined @@ -1111,26 +1172,33 @@ function isSelectorTranslationForwarder( return ts.isIdentifier(parameter.name) && parameter.name.text === keyExpression.text }) const forwardsSelectorParameter = Boolean( - selectorParameter - && checker - && isSelectorParamType(checker.getTypeAtLocation(selectorParameter), checker), - ) - const calleeIsSelectorCallable = isRegisteredSelectorCallable - || Boolean(getCheckerTranslationFunctionInfo(node.expression, checker, catalog)) - return Boolean( - forwardsSelectorParameter - && calleeIsSelectorCallable, + selectorParameter && + checker && + isSelectorParamType(checker.getTypeAtLocation(selectorParameter), checker), ) + const calleeIsSelectorCallable = + isRegisteredSelectorCallable || + Boolean(getCheckerTranslationFunctionInfo(node.expression, checker, catalog)) + return Boolean(forwardsSelectorParameter && calleeIsSelectorCallable) } -function analyzeSourceFile(filePath: string, catalog: Catalog, collector: UsageCollector, checker?: ts.TypeChecker, sourceFileFromProgram?: ts.SourceFile) { - const sourceFile = sourceFileFromProgram ?? (() => { - const sourceText = fs.readFileSync(filePath, 'utf8') - const scriptKind = filePath.endsWith('.tsx') || filePath.endsWith('.jsx') - ? ts.ScriptKind.TSX - : ts.ScriptKind.TS - return ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, scriptKind) - })() +function analyzeSourceFile( + filePath: string, + catalog: Catalog, + collector: UsageCollector, + checker?: ts.TypeChecker, + sourceFileFromProgram?: ts.SourceFile, +) { + const sourceFile = + sourceFileFromProgram ?? + (() => { + const sourceText = fs.readFileSync(filePath, 'utf8') + const scriptKind = + filePath.endsWith('.tsx') || filePath.endsWith('.jsx') + ? ts.ScriptKind.TSX + : ts.ScriptKind.TS + return ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, scriptKind) + })() const scopes: Scope[] = [{ values: new Map(), translationFunctions: new Map() }] const withScope = (callback: () => void) => { @@ -1143,8 +1211,7 @@ function analyzeSourceFile(filePath: string, catalog: Catalog, collector: UsageC if (isFunctionLikeWithParameters(node)) { withScope(() => { const currentScope = scopes.at(-1)! - for (const parameter of node.parameters) - handleFunctionParameter(parameter, currentScope) + for (const parameter of node.parameters) handleFunctionParameter(parameter, currentScope) ts.forEachChild(node, visit) }) @@ -1156,38 +1223,31 @@ function analyzeSourceFile(filePath: string, catalog: Catalog, collector: UsageC return } - if (ts.isImportDeclaration(node)) - handleImportDeclaration(node) + if (ts.isImportDeclaration(node)) handleImportDeclaration(node) if (ts.isVariableDeclaration(node)) { handleVariableDeclaration(node) } - if (isStringNode(node)) - handleContextualI18nLiteral(node) + if (isStringNode(node)) handleContextualI18nLiteral(node) - if (ts.isCallExpression(node)) - handleCallExpression(node) + if (ts.isCallExpression(node)) handleCallExpression(node) - if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) - handleJsxElement(node) + if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) handleJsxElement(node) ts.forEachChild(node, visit) } function handleImportDeclaration(node: ts.ImportDeclaration) { - if (!ts.isStringLiteral(node.moduleSpecifier) || node.moduleSpecifier.text !== 'i18next') - return + if (!ts.isStringLiteral(node.moduleSpecifier) || node.moduleSpecifier.text !== 'i18next') return const namedBindings = node.importClause?.namedBindings - if (!namedBindings || !ts.isNamedImports(namedBindings)) - return + if (!namedBindings || !ts.isNamedImports(namedBindings)) return const currentScope = scopes.at(-1)! for (const element of namedBindings.elements) { const importedName = element.propertyName?.text ?? element.name.text - if (importedName === 't') - addTranslationFunction(currentScope, element.name.text) + if (importedName === 't') addTranslationFunction(currentScope, element.name.text) } } @@ -1201,16 +1261,15 @@ function analyzeSourceFile(filePath: string, catalog: Catalog, collector: UsageC return } - if (!ts.isObjectBindingPattern(node.name)) - return + if (!ts.isObjectBindingPattern(node.name)) return for (const element of node.name.elements) { - if (!ts.isIdentifier(element.name)) - continue + if (!ts.isIdentifier(element.name)) continue - const propertyName = element.propertyName && ts.isIdentifier(element.propertyName) - ? element.propertyName.text - : undefined + const propertyName = + element.propertyName && ts.isIdentifier(element.propertyName) + ? element.propertyName.text + : undefined if (propertyName === 't' || element.name.text === 't') { const translationInfo = getCheckerTranslationFunctionInfo(element.name, checker, catalog) addTranslationFunction(scope, element.name.text, translationInfo) @@ -1219,61 +1278,67 @@ function analyzeSourceFile(filePath: string, catalog: Catalog, collector: UsageC } function handleContextualI18nLiteral(node: ts.StringLiteral | ts.NoSubstitutionTemplateLiteral) { - if (!checker || (!node.text.includes('.') && !node.text.includes(':'))) - return + if (!checker || (!node.text.includes('.') && !node.text.includes(':'))) return const contextualType = checker.getContextualType(node) - if (!contextualType) - return + if (!contextualType) return if (!/\bI18nKeys?\b|\bI18nKeys?(?:By|With)Prefix\b/.test(checker.typeToString(contextualType))) return const contextualKeys = extractStringValuesFromType(contextualType) - if (!contextualKeys?.includes(node.text)) - return - - recordKeyUsage(collector, { kind: 'keys', keys: [node.text] }, [], catalog, locationFor(node, sourceFile)) + if (!contextualKeys?.includes(node.text)) return + + recordKeyUsage( + collector, + { kind: 'keys', keys: [node.text] }, + [], + catalog, + locationFor(node, sourceFile), + ) } function handleVariableDeclaration(node: ts.VariableDeclaration) { - if (!node.initializer) - return + if (!node.initializer) return const currentScope = scopes.at(-1)! if (ts.isIdentifier(node.name)) currentScope.values.set(node.name.text, evaluateStaticExpression(node.initializer, scopes)) - if (!ts.isObjectBindingPattern(node.name)) - return + if (!ts.isObjectBindingPattern(node.name)) return const initializer = unwrapExpression(node.initializer) const callExpression = ts.isAwaitExpression(initializer) ? unwrapExpression(initializer.expression) : initializer - if (!ts.isCallExpression(callExpression) || !ts.isIdentifier(callExpression.expression)) - return + if (!ts.isCallExpression(callExpression) || !ts.isIdentifier(callExpression.expression)) return const calleeName = callExpression.expression.text - if (calleeName !== 'useTranslation' && calleeName !== 'getTranslation') - return - - const translationInfo = calleeName === 'useTranslation' - ? getUseTranslationInfo(callExpression, scopes, catalog) - : { - namespaces: callExpression.arguments[1] - ? evaluateNamespaceExpression(callExpression.arguments[1] as ts.Expression, scopes, catalog) - : [DEFAULT_NAMESPACE], - keyPrefix: undefined, - } + if (calleeName !== 'useTranslation' && calleeName !== 'getTranslation') return + + const translationInfo = + calleeName === 'useTranslation' + ? getUseTranslationInfo(callExpression, scopes, catalog) + : { + namespaces: callExpression.arguments[1] + ? evaluateNamespaceExpression( + callExpression.arguments[1] as ts.Expression, + scopes, + catalog, + ) + : [DEFAULT_NAMESPACE], + keyPrefix: undefined, + } for (const element of node.name.elements) { - const propertyName = element.propertyName && ts.isIdentifier(element.propertyName) - ? element.propertyName.text - : ts.isIdentifier(element.name) ? element.name.text : undefined - if (propertyName !== 't' || !ts.isIdentifier(element.name)) - continue + const propertyName = + element.propertyName && ts.isIdentifier(element.propertyName) + ? element.propertyName.text + : ts.isIdentifier(element.name) + ? element.name.text + : undefined + if (propertyName !== 't' || !ts.isIdentifier(element.name)) continue currentScope.translationFunctions.set(element.name.text, translationInfo) } @@ -1283,14 +1348,15 @@ function analyzeSourceFile(filePath: string, catalog: Catalog, collector: UsageC const registeredIdentifierTranslationInfo = ts.isIdentifier(node.expression) ? lookupTranslationFunction(node.expression.text, scopes) : undefined - const registeredTranslationInfo = registeredIdentifierTranslationInfo - ?? (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 't' + const registeredTranslationInfo = + registeredIdentifierTranslationInfo ?? + (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 't' ? { namespaces: [DEFAULT_NAMESPACE], keyPrefix: undefined } : undefined) - const translationInfo = registeredTranslationInfo - ?? getCheckerTranslationFunctionInfo(node.expression, checker, catalog) - if (!translationInfo || !node.arguments.length) - return + const translationInfo = + registeredTranslationInfo ?? + getCheckerTranslationFunctionInfo(node.expression, checker, catalog) + if (!translationInfo || !node.arguments.length) return const options = getLastOptionsObject(node) const namespaceProperty = options ? getObjectProperty(options, 'ns') : undefined @@ -1298,14 +1364,24 @@ function analyzeSourceFile(filePath: string, catalog: Catalog, collector: UsageC ? evaluateNamespaceExpression(namespaceProperty.initializer, scopes, catalog) : [] const namespaces = optionNamespaces.length ? optionNamespaces : translationInfo.namespaces - const keyExpression = node.arguments[translationInfo.selectorArgumentIndex ?? 0] as ts.Expression | undefined - if (!keyExpression) - return - const keyUsage = prependKeyPrefix(evaluateKeyExpression(keyExpression, scopes, checker), translationInfo.keyPrefix) + const keyExpression = node.arguments[translationInfo.selectorArgumentIndex ?? 0] as + | ts.Expression + | undefined + if (!keyExpression) return + const keyUsage = prependKeyPrefix( + evaluateKeyExpression(keyExpression, scopes, checker), + translationInfo.keyPrefix, + ) if ( - keyUsage.kind === 'unknown' - && isSelectorTranslationForwarder(node, keyExpression, Boolean(registeredIdentifierTranslationInfo), checker, catalog) + keyUsage.kind === 'unknown' && + isSelectorTranslationForwarder( + node, + keyExpression, + Boolean(registeredIdentifierTranslationInfo), + checker, + catalog, + ) ) { return } @@ -1314,12 +1390,10 @@ function analyzeSourceFile(filePath: string, catalog: Catalog, collector: UsageC } function handleJsxElement(node: ts.JsxOpeningLikeElement) { - if (node.tagName.getText(sourceFile) !== 'Trans') - return + if (node.tagName.getText(sourceFile) !== 'Trans') return const keyExpression = getJsxAttributeExpression(getJsxAttribute(node, 'i18nKey')) - if (!keyExpression) - return + if (!keyExpression) return const namespaceExpression = getJsxAttributeExpression(getJsxAttribute(node, 'ns')) const namespaces = namespaceExpression @@ -1335,19 +1409,27 @@ function analyzeSourceFile(filePath: string, catalog: Catalog, collector: UsageC function getCatalog(webRoot: string, defaultLocale: string, targetFiles: string[] = []): Catalog { const localeDir = path.join(webRoot, 'i18n', defaultLocale) - const targetFileNames = new Set(targetFiles.map(file => file.replace(/\.json$/, ''))) - const targetNamespaces = new Set(targetFiles.map(file => fileNameToNamespace(file.replace(/\.json$/, '')))) + const targetFileNames = new Set(targetFiles.map((file) => file.replace(/\.json$/, ''))) + const targetNamespaces = new Set( + targetFiles.map((file) => fileNameToNamespace(file.replace(/\.json$/, ''))), + ) const keysByNamespace = new Map>() const fileNameByNamespace = new Map() const namespaceByFileName = new Map() - for (const file of fs.readdirSync(localeDir).filter(file => file.endsWith('.json')).sort()) { + for (const file of fs + .readdirSync(localeDir) + .filter((file) => file.endsWith('.json')) + .sort()) { const fileName = file.replace(/\.json$/, '') const namespace = fileNameToNamespace(fileName) if (targetFiles.length && !targetFileNames.has(fileName) && !targetNamespaces.has(namespace)) continue - const content = JSON.parse(fs.readFileSync(path.join(localeDir, file), 'utf8')) as Record + const content = JSON.parse(fs.readFileSync(path.join(localeDir, file), 'utf8')) as Record< + string, + unknown + > keysByNamespace.set(namespace, new Set(Object.keys(content))) fileNameByNamespace.set(namespace, fileName) namespaceByFileName.set(fileName, namespace) @@ -1358,26 +1440,24 @@ function getCatalog(webRoot: string, defaultLocale: string, targetFiles: string[ function listSourceFiles(webRoot: string, explicitSourceFiles?: string[]) { if (explicitSourceFiles?.length) - return explicitSourceFiles.map(file => path.isAbsolute(file) ? file : path.join(webRoot, file)) + return explicitSourceFiles.map((file) => + path.isAbsolute(file) ? file : path.join(webRoot, file), + ) const sourceFiles: string[] = [] const walk = (directory: string) => { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { if (entry.isDirectory()) { - if (!SKIPPED_DIRECTORIES.has(entry.name)) - walk(path.join(directory, entry.name)) + if (!SKIPPED_DIRECTORIES.has(entry.name)) walk(path.join(directory, entry.name)) continue } - if (!entry.isFile()) - continue + if (!entry.isFile()) continue const filePath = path.join(directory, entry.name) - if (filePath.endsWith('.d.ts')) - continue + if (filePath.endsWith('.d.ts')) continue - if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) - sourceFiles.push(filePath) + if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) sourceFiles.push(filePath) } } @@ -1389,8 +1469,7 @@ function mapToRecord(map: Map>) { const record: Record = {} for (const [namespace, values] of map.entries()) { const sortedValues = uniqueSorted(values) - if (sortedValues.length) - record[namespace] = sortedValues + if (sortedValues.length) record[namespace] = sortedValues } return record } @@ -1398,12 +1477,11 @@ function mapToRecord(map: Map>) { function createTypeChecker(webRoot: string, sourceFiles: string[]) { try { const configPath = ts.findConfigFile(webRoot, ts.sys.fileExists, 'tsconfig.json') - const config = configPath - ? ts.readConfigFile(configPath, ts.sys.readFile) - : undefined - const parsedConfig = configPath && config && !config.error - ? ts.parseJsonConfigFileContent(config.config, ts.sys, webRoot) - : undefined + const config = configPath ? ts.readConfigFile(configPath, ts.sys.readFile) : undefined + const parsedConfig = + configPath && config && !config.error + ? ts.parseJsonConfigFileContent(config.config, ts.sys, webRoot) + : undefined const compilerOptions: ts.CompilerOptions = { ...(parsedConfig?.options ?? {}), @@ -1420,15 +1498,20 @@ function createTypeChecker(webRoot: string, sourceFiles: string[]) { return { checker: program.getTypeChecker(), - sourceFileByPath: new Map(program.getSourceFiles().map(sourceFile => [path.resolve(sourceFile.fileName), sourceFile])), + sourceFileByPath: new Map( + program + .getSourceFiles() + .map((sourceFile) => [path.resolve(sourceFile.fileName), sourceFile]), + ), } - } - catch { + } catch { return undefined } } -export async function analyzeUnusedTranslations(options: AnalyzeUnusedTranslationsOptions = {}): Promise { +export async function analyzeUnusedTranslations( + options: AnalyzeUnusedTranslationsOptions = {}, +): Promise { const webRoot = options.webRoot ?? path.resolve(fileURLToPath(new URL('../..', import.meta.url))) const defaultLocale = options.defaultLocale ?? DEFAULT_LOCALE const catalog = getCatalog(webRoot, defaultLocale, options.files) @@ -1442,21 +1525,25 @@ export async function analyzeUnusedTranslations(options: AnalyzeUnusedTranslatio } for (const file of sourceFiles) - analyzeSourceFile(file, catalog, collector, typeChecker?.checker, typeChecker?.sourceFileByPath.get(path.resolve(file))) + analyzeSourceFile( + file, + catalog, + collector, + typeChecker?.checker, + typeChecker?.sourceFileByPath.get(path.resolve(file)), + ) const unusedKeys = new Map>() for (const [namespace, keys] of catalog.keysByNamespace.entries()) { - if (collector.protectedNamespaces.has(namespace)) - continue + if (collector.protectedNamespaces.has(namespace)) continue const exactKeys = collector.exactKeys.get(namespace) ?? new Set() - const patterns = collector.patterns.filter(pattern => pattern.namespace === namespace) + const patterns = collector.patterns.filter((pattern) => pattern.namespace === namespace) for (const key of keys) { - if (exactKeys.has(key) || patterns.some(pattern => patternMatchesKey(pattern, key))) + if (exactKeys.has(key) || patterns.some((pattern) => patternMatchesKey(pattern, key))) continue - if (!unusedKeys.has(namespace)) - unusedKeys.set(namespace, new Set()) + if (!unusedKeys.has(namespace)) unusedKeys.set(namespace, new Set()) unusedKeys.get(namespace)!.add(key) } } @@ -1468,7 +1555,9 @@ export async function analyzeUnusedTranslations(options: AnalyzeUnusedTranslatio usedKeysByNamespace: mapToRecord(collector.exactKeys), allKeysByNamespace: mapToRecord(catalog.keysByNamespace), dynamicKeyPatterns: collector.patterns.sort((left, right) => { - return `${left.namespace}:${left.prefix}:${left.suffix}`.localeCompare(`${right.namespace}:${right.prefix}:${right.suffix}`) + return `${left.namespace}:${left.prefix}:${left.suffix}`.localeCompare( + `${right.namespace}:${right.prefix}:${right.suffix}`, + ) }), protectedNamespaces: uniqueSorted(collector.protectedNamespaces), unresolvedUsages: collector.unresolvedUsages, @@ -1477,41 +1566,45 @@ export async function analyzeUnusedTranslations(options: AnalyzeUnusedTranslatio } function listLocales(webRoot: string) { - return fs.readdirSync(path.join(webRoot, 'i18n'), { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => entry.name) + return fs + .readdirSync(path.join(webRoot, 'i18n'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) .sort() } -export async function removeUnusedTranslations(options: RemoveUnusedTranslationsOptions): Promise { +export async function removeUnusedTranslations( + options: RemoveUnusedTranslationsOptions, +): Promise { const webRoot = options.webRoot ?? options.analysis.webRoot const locales = options.locales?.length ? options.locales : listLocales(webRoot) const removedKeys: RemovedKey[] = [] for (const locale of locales) { for (const [namespace, keys] of Object.entries(options.analysis.unusedKeysByNamespace).sort()) { - const fileName = options.analysis.namespaceFiles[namespace] ?? namespaceToFileName(namespace, { - keysByNamespace: new Map(), - fileNameByNamespace: new Map(Object.entries(options.analysis.namespaceFiles).map(([ns, file]) => [ns, file])), - namespaceByFileName: new Map(), - }) + const fileName = + options.analysis.namespaceFiles[namespace] ?? + namespaceToFileName(namespace, { + keysByNamespace: new Map(), + fileNameByNamespace: new Map( + Object.entries(options.analysis.namespaceFiles).map(([ns, file]) => [ns, file]), + ), + namespaceByFileName: new Map(), + }) const filePath = path.join(webRoot, 'i18n', locale, `${fileName}.json`) - if (!fs.existsSync(filePath)) - continue + if (!fs.existsSync(filePath)) continue const content = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record let modified = false for (const key of keys) { - if (!(key in content)) - continue + if (!(key in content)) continue delete content[key] modified = true removedKeys.push({ locale, namespace, key }) } - if (modified) - fs.writeFileSync(filePath, `${JSON.stringify(content, null, 2)}\n`, 'utf8') + if (modified) fs.writeFileSync(filePath, `${JSON.stringify(content, null, 2)}\n`, 'utf8') } } diff --git a/web/scripts/migrate-i18n-selectors.ts b/web/scripts/migrate-i18n-selectors.ts index c5dc12af2ae34b..a9e4639c159d69 100644 --- a/web/scripts/migrate-i18n-selectors.ts +++ b/web/scripts/migrate-i18n-selectors.ts @@ -15,8 +15,22 @@ export type TransformSourceResult = { } const I18N_MODULES = new Set(['#i18n', 'i18next', 'react-i18next']) -const MOCK_PROVIDER_METHODS = new Set(['mockImplementation', 'mockImplementationOnce', 'mockReturnValue', 'mockReturnValueOnce']) -const SKIPPED_DIRECTORIES = new Set(['.next', '.turbo', '.vinext', 'coverage', 'dist', 'i18n', 'node_modules', 'public']) +const MOCK_PROVIDER_METHODS = new Set([ + 'mockImplementation', + 'mockImplementationOnce', + 'mockReturnValue', + 'mockReturnValueOnce', +]) +const SKIPPED_DIRECTORIES = new Set([ + '.next', + '.turbo', + '.vinext', + 'coverage', + 'dist', + 'i18n', + 'node_modules', + 'public', +]) const SKIPPED_FILES = new Set(['migrate-i18n-selectors.spec.ts']) const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']) const TRANSLATION_FACTORIES = new Set(['getTranslation', 'useTranslation']) @@ -24,11 +38,11 @@ const TRANSLATION_FACTORIES = new Set(['getTranslation', 'useTranslation']) function unwrapExpression(expression: ts.Expression): ts.Expression { let current = expression while ( - ts.isAsExpression(current) - || ts.isNonNullExpression(current) - || ts.isParenthesizedExpression(current) - || ts.isSatisfiesExpression(current) - || ts.isTypeAssertionExpression(current) + ts.isAsExpression(current) || + ts.isNonNullExpression(current) || + ts.isParenthesizedExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isTypeAssertionExpression(current) ) { current = current.expression } @@ -42,7 +56,13 @@ type ImportBinding = { function createSourceAnalysis(source: string, fileName: string, scriptKind: ts.ScriptKind) { const resolvedFileName = path.resolve(fileName) - const sourceFile = ts.createSourceFile(resolvedFileName, source, ts.ScriptTarget.Latest, true, scriptKind) + const sourceFile = ts.createSourceFile( + resolvedFileName, + source, + ts.ScriptTarget.Latest, + true, + scriptKind, + ) const compilerOptions: ts.CompilerOptions = { allowJs: true, jsx: ts.JsxEmit.Preserve, @@ -53,9 +73,10 @@ function createSourceAnalysis(source: string, fileName: string, scriptKind: ts.S const defaultHost = ts.createCompilerHost(compilerOptions) const host: ts.CompilerHost = { ...defaultHost, - fileExists: candidate => path.resolve(candidate) === resolvedFileName, - getSourceFile: candidate => path.resolve(candidate) === resolvedFileName ? sourceFile : undefined, - readFile: candidate => path.resolve(candidate) === resolvedFileName ? source : undefined, + fileExists: (candidate) => path.resolve(candidate) === resolvedFileName, + getSourceFile: (candidate) => + path.resolve(candidate) === resolvedFileName ? sourceFile : undefined, + readFile: (candidate) => (path.resolve(candidate) === resolvedFileName ? source : undefined), } const program = ts.createProgram([resolvedFileName], compilerOptions, host) @@ -64,11 +85,9 @@ function createSourceAnalysis(source: string, fileName: string, scriptKind: ts.S function getImportBinding(declaration: ts.Declaration): ImportBinding | undefined { let current: ts.Node | undefined = declaration - while (current && !ts.isImportDeclaration(current)) - current = current.parent + while (current && !ts.isImportDeclaration(current)) current = current.parent - if (!current || !ts.isStringLiteral(current.moduleSpecifier)) - return undefined + if (!current || !ts.isStringLiteral(current.moduleSpecifier)) return undefined if (ts.isImportSpecifier(declaration)) { return { @@ -114,15 +133,17 @@ function isTranslationFactoryIdentifier(identifier: ts.Identifier, checker: ts.T return getDeclarations(identifier, checker).some((declaration) => { const binding = getImportBinding(declaration) return Boolean( - binding - && TRANSLATION_FACTORIES.has(binding.importedName) - && (I18N_MODULES.has(binding.moduleName) || binding.moduleName.toLowerCase().includes('i18n')), + binding && + TRANSLATION_FACTORIES.has(binding.importedName) && + (I18N_MODULES.has(binding.moduleName) || binding.moduleName.toLowerCase().includes('i18n')), ) }) } function isGetI18nIdentifier(identifier: ts.Identifier, checker: ts.TypeChecker) { - return isImportedBinding(identifier, checker, 'getI18n', moduleName => I18N_MODULES.has(moduleName)) + return isImportedBinding(identifier, checker, 'getI18n', (moduleName) => + I18N_MODULES.has(moduleName), + ) } function isTranslationFactoryCall(expression: ts.Expression, checker: ts.TypeChecker) { @@ -130,16 +151,20 @@ function isTranslationFactoryCall(expression: ts.Expression, checker: ts.TypeChe ? unwrapExpression(expression.expression) : unwrapExpression(expression) - return ts.isCallExpression(unwrapped) - && ts.isIdentifier(unwrapped.expression) - && isTranslationFactoryIdentifier(unwrapped.expression, checker) + return ( + ts.isCallExpression(unwrapped) && + ts.isIdentifier(unwrapped.expression) && + isTranslationFactoryIdentifier(unwrapped.expression, checker) + ) } function getBindingElementPropertyName(declaration: ts.BindingElement) { - if (declaration.propertyName && (ts.isIdentifier(declaration.propertyName) || ts.isStringLiteral(declaration.propertyName))) + if ( + declaration.propertyName && + (ts.isIdentifier(declaration.propertyName) || ts.isStringLiteral(declaration.propertyName)) + ) return declaration.propertyName.text - if (ts.isIdentifier(declaration.name)) - return declaration.name.text + if (ts.isIdentifier(declaration.name)) return declaration.name.text return undefined } @@ -149,8 +174,7 @@ function findAncestor( ): T | undefined { let current: ts.Node | undefined = node.parent while (current) { - if (predicate(current)) - return current + if (predicate(current)) return current current = current.parent } return undefined @@ -158,8 +182,10 @@ function findAncestor( function hasTranslationFunctionType(parameter: ts.ParameterDeclaration, sourceFile: ts.SourceFile) { const typeText = parameter.type?.getText(sourceFile) ?? '' - return /\b(?:TFunction|useTranslation)\b|(?:Translate|Translator)\b/.test(typeText) - || typeText.trim() === 'any' + return ( + /\b(?:TFunction|useTranslation)\b|(?:Translate|Translator)\b/.test(typeText) || + typeText.trim() === 'any' + ) } function isTranslationFunctionDeclaration( @@ -172,8 +198,7 @@ function isTranslationFunctionDeclaration( return importBinding.importedName === 't' && I18N_MODULES.has(importBinding.moduleName) if (ts.isBindingElement(declaration)) { - if (getBindingElementPropertyName(declaration) !== 't') - return false + if (getBindingElementPropertyName(declaration) !== 't') return false const variableDeclaration = findAncestor(declaration, ts.isVariableDeclaration) if (variableDeclaration?.initializer) @@ -185,15 +210,19 @@ function isTranslationFunctionDeclaration( if (ts.isVariableDeclaration(declaration) && declaration.initializer) { const initializer = unwrapExpression(declaration.initializer) - return ts.isPropertyAccessExpression(initializer) - && initializer.name.text === 't' - && isTranslationFactoryCall(initializer.expression, checker) + return ( + ts.isPropertyAccessExpression(initializer) && + initializer.name.text === 't' && + isTranslationFactoryCall(initializer.expression, checker) + ) } - return ts.isParameter(declaration) - && ts.isIdentifier(declaration.name) - && declaration.name.text === 't' - && hasTranslationFunctionType(declaration, sourceFile) + return ( + ts.isParameter(declaration) && + ts.isIdentifier(declaration.name) && + declaration.name.text === 't' && + hasTranslationFunctionType(declaration, sourceFile) + ) } function isTranslationFunctionIdentifier( @@ -201,34 +230,42 @@ function isTranslationFunctionIdentifier( checker: ts.TypeChecker, sourceFile: ts.SourceFile, ) { - return getDeclarations(identifier, checker).some(declaration => ( - isTranslationFunctionDeclaration(declaration, checker, sourceFile) - )) + return getDeclarations(identifier, checker).some((declaration) => + isTranslationFunctionDeclaration(declaration, checker, sourceFile), + ) } function isI18nInstanceIdentifier(identifier: ts.Identifier, checker: ts.TypeChecker) { const declarations = getDeclarations(identifier, checker) - if (!declarations.length) - return identifier.text === 'i18n' || identifier.text === 'i18next' + if (!declarations.length) return identifier.text === 'i18n' || identifier.text === 'i18next' return declarations.some((declaration) => { const importBinding = getImportBinding(declaration) if (importBinding) - return I18N_MODULES.has(importBinding.moduleName) && ['*', 'default', 'i18n', 'i18next'].includes(importBinding.importedName) + return ( + I18N_MODULES.has(importBinding.moduleName) && + ['*', 'default', 'i18n', 'i18next'].includes(importBinding.importedName) + ) if (ts.isBindingElement(declaration)) { const variableDeclaration = findAncestor(declaration, ts.isVariableDeclaration) - return getBindingElementPropertyName(declaration) === 'i18n' - && Boolean(variableDeclaration?.initializer && isTranslationFactoryCall(variableDeclaration.initializer, checker)) + return ( + getBindingElementPropertyName(declaration) === 'i18n' && + Boolean( + variableDeclaration?.initializer && + isTranslationFactoryCall(variableDeclaration.initializer, checker), + ) + ) } - if (!ts.isVariableDeclaration(declaration) || !declaration.initializer) - return false + if (!ts.isVariableDeclaration(declaration) || !declaration.initializer) return false const initializer = unwrapExpression(declaration.initializer) - return ts.isCallExpression(initializer) - && ts.isIdentifier(initializer.expression) - && isGetI18nIdentifier(initializer.expression, checker) + return ( + ts.isCallExpression(initializer) && + ts.isIdentifier(initializer.expression) && + isGetI18nIdentifier(initializer.expression, checker) + ) }) } @@ -237,48 +274,41 @@ function getTypeReferenceName(typeName: ts.EntityName): string { } function hasSelectorType(typeNode: ts.TypeNode | undefined): boolean { - if (!typeNode) - return false + if (!typeNode) return false if (ts.isParenthesizedTypeNode(typeNode) || ts.isTypeOperatorNode(typeNode)) return hasSelectorType(typeNode.type) if (ts.isTypeReferenceNode(typeNode)) return ['SelectorKey', 'SelectorParam'].includes(getTypeReferenceName(typeNode.typeName)) if (ts.isUnionTypeNode(typeNode)) { - const meaningfulTypes = typeNode.types.filter(type => ( - type.kind !== ts.SyntaxKind.UndefinedKeyword - && type.kind !== ts.SyntaxKind.NeverKeyword - && !(ts.isLiteralTypeNode(type) && type.literal.kind === ts.SyntaxKind.NullKeyword) - )) + const meaningfulTypes = typeNode.types.filter( + (type) => + type.kind !== ts.SyntaxKind.UndefinedKeyword && + type.kind !== ts.SyntaxKind.NeverKeyword && + !(ts.isLiteralTypeNode(type) && type.literal.kind === ts.SyntaxKind.NullKeyword), + ) return meaningfulTypes.length > 0 && meaningfulTypes.every(hasSelectorType) } return false } function hasSelectorCollectionType(typeNode: ts.TypeNode | undefined): boolean { - if (!typeNode) - return false + if (!typeNode) return false if (ts.isParenthesizedTypeNode(typeNode) || ts.isTypeOperatorNode(typeNode)) return hasSelectorCollectionType(typeNode.type) - if (ts.isUnionTypeNode(typeNode)) - return typeNode.types.some(hasSelectorCollectionType) - if (ts.isArrayTypeNode(typeNode)) - return hasSelectorType(typeNode.elementType) - if (!ts.isTypeReferenceNode(typeNode)) - return false + if (ts.isUnionTypeNode(typeNode)) return typeNode.types.some(hasSelectorCollectionType) + if (ts.isArrayTypeNode(typeNode)) return hasSelectorType(typeNode.elementType) + if (!ts.isTypeReferenceNode(typeNode)) return false const typeName = getTypeReferenceName(typeNode.typeName) const typeArguments = typeNode.typeArguments ?? [] - if (typeName === 'Record') - return hasSelectorType(typeArguments[1]) - if (typeName === 'Array' || typeName === 'ReadonlyArray') - return hasSelectorType(typeArguments[0]) + if (typeName === 'Record') return hasSelectorType(typeArguments[1]) + if (typeName === 'Array' || typeName === 'ReadonlyArray') return hasSelectorType(typeArguments[0]) return false } function hasCallableType(expression: ts.Expression, checker: ts.TypeChecker) { const type = checker.getTypeAtLocation(expression) - if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) - return false + if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false return checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0 } @@ -291,46 +321,61 @@ function isSelectorCollectionExpression( if (ts.isParenthesizedExpression(expression) || ts.isNonNullExpression(expression)) return isSelectorCollectionExpression(expression.expression, checker, sourceFile, seenSymbols) - if (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression) || ts.isTypeAssertionExpression(expression)) { - if (hasSelectorCollectionType(expression.type)) - return true + if ( + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isTypeAssertionExpression(expression) + ) { + if (hasSelectorCollectionType(expression.type)) return true return isSelectorCollectionExpression(expression.expression, checker, sourceFile, seenSymbols) } if (ts.isObjectLiteralExpression(expression)) { const values = expression.properties.flatMap((property): ts.Expression[] => { - if (ts.isPropertyAssignment(property)) - return [property.initializer] - if (ts.isShorthandPropertyAssignment(property)) - return [property.name] + if (ts.isPropertyAssignment(property)) return [property.initializer] + if (ts.isShorthandPropertyAssignment(property)) return [property.name] return [] }) - return values.length > 0 && values.every(value => ( - isSelectorCompatibleExpression(value, checker, sourceFile, new Set(seenSymbols)) - )) + return ( + values.length > 0 && + values.every((value) => + isSelectorCompatibleExpression(value, checker, sourceFile, new Set(seenSymbols)), + ) + ) } if (ts.isArrayLiteralExpression(expression)) { - return expression.elements.length > 0 && expression.elements.every(element => ( - !ts.isSpreadElement(element) - && isSelectorCompatibleExpression(element, checker, sourceFile, new Set(seenSymbols)) - )) + return ( + expression.elements.length > 0 && + expression.elements.every( + (element) => + !ts.isSpreadElement(element) && + isSelectorCompatibleExpression(element, checker, sourceFile, new Set(seenSymbols)), + ) + ) } - if (!ts.isIdentifier(expression)) - return false + if (!ts.isIdentifier(expression)) return false const symbol = checker.getSymbolAtLocation(expression) - if (!symbol || seenSymbols.has(symbol)) - return false + if (!symbol || seenSymbols.has(symbol)) return false seenSymbols.add(symbol) return (symbol.declarations ?? []).some((declaration) => { if (ts.isVariableDeclaration(declaration)) { - return hasSelectorCollectionType(declaration.type) - || Boolean(declaration.initializer && isSelectorCollectionExpression(declaration.initializer, checker, sourceFile, seenSymbols)) + return ( + hasSelectorCollectionType(declaration.type) || + Boolean( + declaration.initializer && + isSelectorCollectionExpression(declaration.initializer, checker, sourceFile, seenSymbols), + ) + ) } - if (ts.isParameter(declaration) || ts.isPropertyDeclaration(declaration) || ts.isPropertySignature(declaration)) + if ( + ts.isParameter(declaration) || + ts.isPropertyDeclaration(declaration) || + ts.isPropertySignature(declaration) + ) return hasSelectorCollectionType(declaration.type) return false }) @@ -345,48 +390,68 @@ function isSelectorCompatibleExpression( if (ts.isParenthesizedExpression(expression) || ts.isNonNullExpression(expression)) return isSelectorCompatibleExpression(expression.expression, checker, sourceFile, seenSymbols) - if (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression) || ts.isTypeAssertionExpression(expression)) { - if (hasSelectorType(expression.type)) - return true + if ( + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isTypeAssertionExpression(expression) + ) { + if (hasSelectorType(expression.type)) return true return isSelectorCompatibleExpression(expression.expression, checker, sourceFile, seenSymbols) } - if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) - return true + if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) return true - if (hasCallableType(expression, checker)) - return true + if (hasCallableType(expression, checker)) return true if (ts.isConditionalExpression(expression)) { - return isSelectorCompatibleExpression(expression.whenTrue, checker, sourceFile, new Set(seenSymbols)) - && isSelectorCompatibleExpression(expression.whenFalse, checker, sourceFile, new Set(seenSymbols)) + return ( + isSelectorCompatibleExpression( + expression.whenTrue, + checker, + sourceFile, + new Set(seenSymbols), + ) && + isSelectorCompatibleExpression( + expression.whenFalse, + checker, + sourceFile, + new Set(seenSymbols), + ) + ) } if (ts.isElementAccessExpression(expression) || ts.isPropertyAccessExpression(expression)) { return isSelectorCollectionExpression(expression.expression, checker, sourceFile, seenSymbols) } - if (!ts.isIdentifier(expression)) - return false + if (!ts.isIdentifier(expression)) return false const symbol = checker.getSymbolAtLocation(expression) - if (!symbol || seenSymbols.has(symbol)) - return false + if (!symbol || seenSymbols.has(symbol)) return false seenSymbols.add(symbol) return (symbol.declarations ?? []).some((declaration) => { if (ts.isVariableDeclaration(declaration)) { - return hasSelectorType(declaration.type) - || Boolean(declaration.initializer && isSelectorCompatibleExpression(declaration.initializer, checker, sourceFile, seenSymbols)) + return ( + hasSelectorType(declaration.type) || + Boolean( + declaration.initializer && + isSelectorCompatibleExpression(declaration.initializer, checker, sourceFile, seenSymbols), + ) + ) } - if (ts.isParameter(declaration) || ts.isPropertyDeclaration(declaration) || ts.isPropertySignature(declaration)) + if ( + ts.isParameter(declaration) || + ts.isPropertyDeclaration(declaration) || + ts.isPropertySignature(declaration) + ) return hasSelectorType(declaration.type) return false }) } function quoteSelectorKey(key: string) { - return `'${key.replaceAll('\\', '\\\\').replaceAll('\'', '\\\'')}'` + return `'${key.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'` } function isSelectorPropertyName(key: string) { @@ -406,8 +471,7 @@ function selectorAccessFor(expression: ts.Expression, sourceFile: ts.SourceFile) function selectorFor(expression: ts.Expression, sourceFile: ts.SourceFile): string { if (ts.isArrayLiteralExpression(expression)) { const selectors = expression.elements.map((element) => { - if (ts.isSpreadElement(element)) - return element.getText(sourceFile) + if (ts.isSpreadElement(element)) return element.getText(sourceFile) return `$ => ${selectorAccessFor(element, sourceFile)}` }) return `[${selectors.join(', ')}]` @@ -417,14 +481,15 @@ function selectorFor(expression: ts.Expression, sourceFile: ts.SourceFile): stri } function isSelectorAccessRoot(expression: ts.Expression, parameterName: string): boolean { - if (ts.isIdentifier(expression)) - return expression.text === parameterName + if (ts.isIdentifier(expression)) return expression.text === parameterName if (ts.isElementAccessExpression(expression) || ts.isPropertyAccessExpression(expression)) return isSelectorAccessRoot(expression.expression, parameterName) return false } -function isStringExpression(node: ts.Node): node is ts.StringLiteral | ts.NoSubstitutionTemplateLiteral { +function isStringExpression( + node: ts.Node, +): node is ts.StringLiteral | ts.NoSubstitutionTemplateLiteral { return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) } @@ -440,40 +505,41 @@ function isTranslationCall( return false const receiver = unwrapExpression(node.expression.expression) - if (ts.isIdentifier(receiver)) - return isI18nInstanceIdentifier(receiver, checker) + if (ts.isIdentifier(receiver)) return isI18nInstanceIdentifier(receiver, checker) - return ts.isCallExpression(receiver) - && ts.isIdentifier(receiver.expression) - && isGetI18nIdentifier(receiver.expression, checker) + return ( + ts.isCallExpression(receiver) && + ts.isIdentifier(receiver.expression) && + isGetI18nIdentifier(receiver.expression, checker) + ) } function isTransComponent(identifier: ts.Identifier, checker: ts.TypeChecker) { - return isImportedBinding(identifier, checker, 'Trans', moduleName => I18N_MODULES.has(moduleName)) + return isImportedBinding(identifier, checker, 'Trans', (moduleName) => + I18N_MODULES.has(moduleName), + ) } function getJsxAttribute(node: ts.JsxOpeningLikeElement, name: string) { return node.attributes.properties.find((property): property is ts.JsxAttribute => { - return ts.isJsxAttribute(property) - && ts.isIdentifier(property.name) - && property.name.text === name + return ( + ts.isJsxAttribute(property) && ts.isIdentifier(property.name) && property.name.text === name + ) }) } function getPropertyName(node: ts.ObjectLiteralElementLike) { - if (!('name' in node) || !node.name) - return undefined - if (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) - return node.name.text + if (!('name' in node) || !node.name) return undefined + if (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) return node.name.text return undefined } function isI18nMock(node: ts.CallExpression) { if ( - !ts.isPropertyAccessExpression(node.expression) - || !ts.isIdentifier(node.expression.expression) - || node.expression.expression.text !== 'vi' - || node.expression.name.text !== 'mock' + !ts.isPropertyAccessExpression(node.expression) || + !ts.isIdentifier(node.expression.expression) || + node.expression.expression.text !== 'vi' || + node.expression.name.text !== 'mock' ) { return false } @@ -483,10 +549,12 @@ function isI18nMock(node: ts.CallExpression) { } function isVitestValueWrapper(node: ts.CallExpression) { - return ts.isPropertyAccessExpression(node.expression) - && ts.isIdentifier(node.expression.expression) - && node.expression.expression.text === 'vi' - && (node.expression.name.text === 'fn' || node.expression.name.text === 'hoisted') + return ( + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'vi' && + (node.expression.name.text === 'fn' || node.expression.name.text === 'hoisted') + ) } function applyEdits(source: string, edits: TextEdit[]) { @@ -497,9 +565,8 @@ function applyEdits(source: string, edits: TextEdit[]) { } export function transformSource(source: string, fileName: string): TransformSourceResult { - const scriptKind = fileName.endsWith('.tsx') || fileName.endsWith('.jsx') - ? ts.ScriptKind.TSX - : ts.ScriptKind.TS + const scriptKind = + fileName.endsWith('.tsx') || fileName.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS const { checker, sourceFile } = createSourceAnalysis(source, fileName, scriptKind) return transformAnalyzedSource(source, sourceFile, checker) @@ -533,40 +600,39 @@ function transformAnalyzedSource( function isSelectorMockAdapter(expression: ts.Expression) { const adapter = unwrapExpression(expression) - if (!ts.isArrowFunction(adapter) && !ts.isFunctionExpression(adapter)) - return false + if (!ts.isArrowFunction(adapter) && !ts.isFunctionExpression(adapter)) return false const selectorParameter = adapter.parameters[0] - return Boolean(selectorParameter?.type && hasSelectorType(selectorParameter.type)) - || /\b(?:keyFromSelector|resolveI18nKey)\s*\(/.test(adapter.body.getText(sourceFile)) + return ( + Boolean(selectorParameter?.type && hasSelectorType(selectorParameter.type)) || + /\b(?:keyFromSelector|resolveI18nKey)\s*\(/.test(adapter.body.getText(sourceFile)) + ) } function addMockTEdit(node: ts.PropertyAssignment | ts.ShorthandPropertyAssignment) { - if (mockEditStarts.has(node.getStart(sourceFile))) - return + if (mockEditStarts.has(node.getStart(sourceFile))) return if (ts.isPropertyAssignment(node)) { const initializer = unwrapExpression(node.initializer) - if (isSelectorMockAdapter(initializer)) - return + if (isSelectorMockAdapter(initializer)) return - const alreadyWrapped = ts.isCallExpression(initializer) - && ts.isIdentifier(initializer.expression) - && initializer.expression.text === 'withSelectorKey' - if (alreadyWrapped) - return + const alreadyWrapped = + ts.isCallExpression(initializer) && + ts.isIdentifier(initializer.expression) && + initializer.expression.text === 'withSelectorKey' + if (alreadyWrapped) return - const translate = currentMockFactory && ts.isIdentifier(initializer) - ? `(...args: Parameters) => ${initializer.text}(...args)` - : node.initializer.getText(sourceFile) + const translate = + currentMockFactory && ts.isIdentifier(initializer) + ? `(...args: Parameters) => ${initializer.text}(...args)` + : node.initializer.getText(sourceFile) edits.push({ end: node.initializer.end, replacement: `withSelectorKey(${translate})`, start: node.initializer.getStart(sourceFile), }) - } - else { + } else { const translate = currentMockFactory ? `(...args: Parameters) => ${node.name.text}(...args)` : node.name.text @@ -582,28 +648,27 @@ function transformAnalyzedSource( } function addMockTransEdit(node: ts.PropertyAssignment | ts.ShorthandPropertyAssignment) { - if (mockEditStarts.has(node.getStart(sourceFile))) - return + if (mockEditStarts.has(node.getStart(sourceFile))) return if (ts.isPropertyAssignment(node)) { const initializer = unwrapExpression(node.initializer) - const alreadyWrapped = ts.isCallExpression(initializer) - && ts.isIdentifier(initializer.expression) - && initializer.expression.text === 'withSelectorKeyProps' - if (alreadyWrapped) - return + const alreadyWrapped = + ts.isCallExpression(initializer) && + ts.isIdentifier(initializer.expression) && + initializer.expression.text === 'withSelectorKeyProps' + if (alreadyWrapped) return - const render = currentMockFactory && ts.isIdentifier(initializer) - ? `(props: Parameters[0]) => withSelectorKeyProps(${initializer.text})(props)` - : `withSelectorKeyProps(${node.initializer.getText(sourceFile)})` + const render = + currentMockFactory && ts.isIdentifier(initializer) + ? `(props: Parameters[0]) => withSelectorKeyProps(${initializer.text})(props)` + : `withSelectorKeyProps(${node.initializer.getText(sourceFile)})` edits.push({ end: node.initializer.end, replacement: render, start: node.initializer.getStart(sourceFile), }) - } - else { + } else { const render = currentMockFactory ? `(props: Parameters[0]) => withSelectorKeyProps(${node.name.text})(props)` : `withSelectorKeyProps(${node.name.text})` @@ -618,18 +683,19 @@ function transformAnalyzedSource( requireMockHelper('withSelectorKeyProps') } - function collectReturnedExpressions(node: ts.FunctionLikeDeclaration, collect: (expression: ts.Expression) => void) { + function collectReturnedExpressions( + node: ts.FunctionLikeDeclaration, + collect: (expression: ts.Expression) => void, + ) { if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) { collect(node.body) return } - if (!node.body) - return + if (!node.body) return function visitReturn(candidate: ts.Node) { - if (candidate !== node && ts.isFunctionLike(candidate)) - return + if (candidate !== node && ts.isFunctionLike(candidate)) return if (ts.isReturnStatement(candidate) && candidate.expression) { collect(candidate.expression) return @@ -646,37 +712,45 @@ function transformAnalyzedSource( collect: (node: ts.Node) => void, ) { const symbol = checker.getSymbolAtLocation(identifier) - if (!symbol || visitedSymbols.has(symbol)) - return symbol + if (!symbol || visitedSymbols.has(symbol)) return symbol visitedSymbols.add(symbol) for (const declaration of symbol.declarations ?? []) { if (ts.isVariableDeclaration(declaration) && declaration.initializer) collect(declaration.initializer) - else if (ts.isFunctionDeclaration(declaration)) - collect(declaration) + else if (ts.isFunctionDeclaration(declaration)) collect(declaration) } return symbol } function collectMockProviderValue(node: ts.Node) { if (ts.isIdentifier(node)) { - const symbol = followLocalIdentifier(node, visitedMockProviderSymbols, collectMockProviderValue) - if (symbol) - mockProviderSymbols.add(symbol) + const symbol = followLocalIdentifier( + node, + visitedMockProviderSymbols, + collectMockProviderValue, + ) + if (symbol) mockProviderSymbols.add(symbol) return } - if (ts.isParenthesizedExpression(node) - || ts.isAsExpression(node) - || ts.isNonNullExpression(node) - || ts.isSatisfiesExpression(node) - || ts.isTypeAssertionExpression(node)) { + if ( + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isNonNullExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isTypeAssertionExpression(node) + ) { collectMockProviderValue(node.expression) return } - if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) { + if ( + ts.isArrowFunction(node) || + ts.isFunctionExpression(node) || + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) + ) { collectReturnedExpressions(node, collectMockProviderValue) return } @@ -687,21 +761,22 @@ function transformAnalyzedSource( addMockTEdit(property) else if (ts.isShorthandPropertyAssignment(property) && property.name.text === 't') addMockTEdit(property) - else if (ts.isSpreadAssignment(property)) - collectMockProviderValue(property.expression) + else if (ts.isSpreadAssignment(property)) collectMockProviderValue(property.expression) } return } if (ts.isCallExpression(node)) { if (ts.isIdentifier(node.expression)) { - const symbol = followLocalIdentifier(node.expression, visitedMockProviderSymbols, collectMockProviderValue) - if (symbol) - mockProviderSymbols.add(symbol) + const symbol = followLocalIdentifier( + node.expression, + visitedMockProviderSymbols, + collectMockProviderValue, + ) + if (symbol) mockProviderSymbols.add(symbol) } if (isVitestValueWrapper(node)) { - for (const argument of node.arguments) - collectMockProviderValue(argument) + for (const argument of node.arguments) collectMockProviderValue(argument) } return } @@ -712,8 +787,7 @@ function transformAnalyzedSource( return } - if (ts.isAwaitExpression(node)) - collectMockProviderValue(node.expression) + if (ts.isAwaitExpression(node)) collectMockProviderValue(node.expression) } function collectMockModuleValue(node: ts.Node) { @@ -722,16 +796,23 @@ function transformAnalyzedSource( return } - if (ts.isParenthesizedExpression(node) - || ts.isAsExpression(node) - || ts.isNonNullExpression(node) - || ts.isSatisfiesExpression(node) - || ts.isTypeAssertionExpression(node)) { + if ( + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isNonNullExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isTypeAssertionExpression(node) + ) { collectMockModuleValue(node.expression) return } - if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) { + if ( + ts.isArrowFunction(node) || + ts.isFunctionExpression(node) || + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) + ) { collectReturnedExpressions(node, collectMockModuleValue) return } @@ -739,21 +820,22 @@ function transformAnalyzedSource( if (ts.isObjectLiteralExpression(node)) { for (const property of node.properties) { const propertyName = getPropertyName(property) - if ((ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && propertyName === 't') { + if ( + (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && + propertyName === 't' + ) { addMockTEdit(property) - } - else if ((ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && propertyName === 'Trans') { + } else if ( + (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && + propertyName === 'Trans' + ) { addMockTransEdit(property) - } - else if (propertyName === 'useTranslation' || propertyName === 'getI18n') { - if (ts.isPropertyAssignment(property)) - collectMockProviderValue(property.initializer) + } else if (propertyName === 'useTranslation' || propertyName === 'getI18n') { + if (ts.isPropertyAssignment(property)) collectMockProviderValue(property.initializer) else if (ts.isShorthandPropertyAssignment(property)) collectMockProviderValue(property.name) - else if (ts.isMethodDeclaration(property)) - collectMockProviderValue(property) - } - else if (ts.isSpreadAssignment(property)) { + else if (ts.isMethodDeclaration(property)) collectMockProviderValue(property) + } else if (ts.isSpreadAssignment(property)) { collectMockModuleValue(property.expression) } } @@ -762,8 +844,7 @@ function transformAnalyzedSource( if (ts.isCallExpression(node)) { if (isVitestValueWrapper(node)) { - for (const argument of node.arguments) - collectMockModuleValue(argument) + for (const argument of node.arguments) collectMockModuleValue(argument) } return } @@ -774,34 +855,38 @@ function transformAnalyzedSource( return } - if (ts.isAwaitExpression(node)) - collectMockModuleValue(node.expression) + if (ts.isAwaitExpression(node)) collectMockModuleValue(node.expression) } function collectConfiguredMockProviderValues(node: ts.Node) { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { const receiver = unwrapExpression(node.expression.expression) const methodName = node.expression.name.text - const receiverSymbol = ts.isIdentifier(receiver) ? checker.getSymbolAtLocation(receiver) : undefined - if (ts.isIdentifier(receiver) - && MOCK_PROVIDER_METHODS.has(methodName) - && receiverSymbol - && mockProviderSymbols.has(receiverSymbol)) { + const receiverSymbol = ts.isIdentifier(receiver) + ? checker.getSymbolAtLocation(receiver) + : undefined + if ( + ts.isIdentifier(receiver) && + MOCK_PROVIDER_METHODS.has(methodName) && + receiverSymbol && + mockProviderSymbols.has(receiverSymbol) + ) { const value = node.arguments[0] - if (value) - collectMockProviderValue(value) + if (value) collectMockProviderValue(value) } } ts.forEachChild(node, collectConfiguredMockProviderValues) } function visit(node: ts.Node) { - if (ts.isArrowFunction(node) - && node.parameters.length === 1 - && ts.isIdentifier(node.parameters[0]!.name) - && ts.isElementAccessExpression(node.body) - && isSelectorAccessRoot(node.body.expression, node.parameters[0]!.name.text) - && ts.isStringLiteral(node.body.argumentExpression)) { + if ( + ts.isArrowFunction(node) && + node.parameters.length === 1 && + ts.isIdentifier(node.parameters[0]!.name) && + ts.isElementAccessExpression(node.body) && + isSelectorAccessRoot(node.body.expression, node.parameters[0]!.name.text) && + ts.isStringLiteral(node.body.argumentExpression) + ) { const argument = node.body.argumentExpression if (isSelectorPropertyName(argument.text)) { edits.push({ @@ -809,8 +894,7 @@ function transformAnalyzedSource( replacement: `${node.body.expression.getText(sourceFile)}.${argument.text}`, start: node.body.getStart(sourceFile), }) - } - else if (argument.getText(sourceFile).startsWith('"')) { + } else if (argument.getText(sourceFile).startsWith('"')) { edits.push({ end: argument.end, replacement: quoteSelectorKey(argument.text), @@ -826,13 +910,16 @@ function transformAnalyzedSource( currentMockFactory = factory collectMockModuleValue(factory) currentMockFactory = previousMockFactory - } - else if (factory) { + } else if (factory) { collectMockModuleValue(factory) } } - if (ts.isCallExpression(node) && isTranslationCall(node, checker, sourceFile) && node.arguments.length) { + if ( + ts.isCallExpression(node) && + isTranslationCall(node, checker, sourceFile) && + node.arguments.length + ) { const keyExpression = node.arguments[0]! as ts.Expression if (!isSelectorCompatibleExpression(keyExpression, checker, sourceFile)) { edits.push({ @@ -853,8 +940,7 @@ function transformAnalyzedSource( replacement: `{ defaultValue: ${fallback.getText(sourceFile)}${properties ? `, ${properties}` : ''} }`, start: fallback.getStart(sourceFile), }) - } - else if (!options) { + } else if (!options) { edits.push({ end: fallback.end, replacement: `{ defaultValue: ${fallback.getText(sourceFile)} }`, @@ -874,8 +960,7 @@ function transformAnalyzedSource( replacement: `{${selectorFor(initializer, sourceFile)}}`, start: initializer.getStart(sourceFile), }) - } - else if (initializer && ts.isJsxExpression(initializer) && initializer.expression) { + } else if (initializer && ts.isJsxExpression(initializer) && initializer.expression) { if (!isSelectorCompatibleExpression(initializer.expression, checker, sourceFile)) { edits.push({ end: initializer.expression.end, @@ -895,14 +980,19 @@ function transformAnalyzedSource( for (const [factory, helperNames] of mockFactoryImports) { const helpers = Array.from(helperNames).sort() const body = factory.body - const alreadyLoadsHelpers = ts.isBlock(body) && body.statements.some((statement) => { - return ts.isVariableStatement(statement) - && statement.getText(sourceFile).includes(`import('@/test/i18n-mock')`) - }) - if (alreadyLoadsHelpers) - continue + const alreadyLoadsHelpers = + ts.isBlock(body) && + body.statements.some((statement) => { + return ( + ts.isVariableStatement(statement) && + statement.getText(sourceFile).includes(`import('@/test/i18n-mock')`) + ) + }) + if (alreadyLoadsHelpers) continue - const isAsync = factory.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) + const isAsync = factory.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword, + ) if (!isAsync) { edits.push({ end: factory.getStart(sourceFile), @@ -911,9 +1001,12 @@ function transformAnalyzedSource( }) } - const { character: factoryColumn } = sourceFile.getLineAndCharacterOfPosition(factory.getStart(sourceFile)) + const { character: factoryColumn } = sourceFile.getLineAndCharacterOfPosition( + factory.getStart(sourceFile), + ) const factoryLineStart = factory.getStart(sourceFile) - factoryColumn - const leadingWhitespace = source.slice(factoryLineStart, factory.getStart(sourceFile)).match(/^\s*/)?.[0].length ?? 0 + const leadingWhitespace = + source.slice(factoryLineStart, factory.getStart(sourceFile)).match(/^\s*/)?.[0].length ?? 0 const bodyIndent = ' '.repeat(leadingWhitespace + 2) const closingIndent = ' '.repeat(leadingWhitespace) const helperImport = `const { ${helpers.join(', ')} } = await import('@/test/i18n-mock')` @@ -923,24 +1016,22 @@ function transformAnalyzedSource( replacement: `\n${bodyIndent}${helperImport}`, start: body.getStart(sourceFile) + 1, }) - } - else { + } else { const bodyStart = body.getStart(sourceFile) - const nestedEdits = edits.filter(edit => edit.start >= bodyStart && edit.end <= body.end) + const nestedEdits = edits.filter((edit) => edit.start >= bodyStart && edit.end <= body.end) const transformedBody = applyEdits( source.slice(bodyStart, body.end), - nestedEdits.map(edit => ({ + nestedEdits.map((edit) => ({ ...edit, end: edit.end - bodyStart, start: edit.start - bodyStart, })), ) - for (const edit of nestedEdits) - consumedEdits.add(edit) + for (const edit of nestedEdits) consumedEdits.add(edit) const indentedBody = transformedBody .split('\n') - .map((line, index) => index === 0 || line.trim() === '' ? line.trimEnd() : ` ${line}`) + .map((line, index) => (index === 0 || line.trim() === '' ? line.trimEnd() : ` ${line}`)) .join('\n') edits.push({ end: body.end, @@ -952,13 +1043,18 @@ function transformAnalyzedSource( if (neededSelectorMockImports.size) { const imports = sourceFile.statements.filter(ts.isImportDeclaration) const helperImport = imports.find((node) => { - return ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@/test/i18n-mock' + return ( + ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@/test/i18n-mock' + ) }) const namedBindings = helperImport?.importClause?.namedBindings - const existingNames = namedBindings && ts.isNamedImports(namedBindings) - ? namedBindings.elements.map(element => element.name.text) - : [] - const missingNames = Array.from(neededSelectorMockImports).filter(name => !existingNames.includes(name)) + const existingNames = + namedBindings && ts.isNamedImports(namedBindings) + ? namedBindings.elements.map((element) => element.name.text) + : [] + const missingNames = Array.from(neededSelectorMockImports).filter( + (name) => !existingNames.includes(name), + ) if (missingNames.length && namedBindings && ts.isNamedImports(namedBindings)) { edits.push({ @@ -966,8 +1062,7 @@ function transformAnalyzedSource( replacement: `{ ${[...existingNames, ...missingNames].join(', ')} }`, start: namedBindings.getStart(sourceFile), }) - } - else if (missingNames.length) { + } else if (missingNames.length) { const lastImport = imports.at(-1) const position = lastImport?.end ?? 0 const prefix = position ? '\n' : '' @@ -981,7 +1076,10 @@ function transformAnalyzedSource( return { changes: edits.length, - output: applyEdits(source, edits.filter(edit => !consumedEdits.has(edit))), + output: applyEdits( + source, + edits.filter((edit) => !consumedEdits.has(edit)), + ), } } @@ -991,13 +1089,15 @@ async function listSourceFiles(root: string) { async function walk(directory: string) { const entries = await fs.readdir(directory, { withFileTypes: true }) for (const entry of entries) { - if (entry.isDirectory() && SKIPPED_DIRECTORIES.has(entry.name)) - continue + if (entry.isDirectory() && SKIPPED_DIRECTORIES.has(entry.name)) continue const entryPath = path.join(directory, entry.name) - if (entry.isDirectory()) - await walk(entryPath) - else if (entry.isFile() && !SKIPPED_FILES.has(entry.name) && SOURCE_EXTENSIONS.has(path.extname(entry.name))) + if (entry.isDirectory()) await walk(entryPath) + else if ( + entry.isFile() && + !SKIPPED_FILES.has(entry.name) && + SOURCE_EXTENSIONS.has(path.extname(entry.name)) + ) files.push(entryPath) } } @@ -1008,17 +1108,21 @@ async function listSourceFiles(root: string) { function createProjectProgram(root: string) { const configPath = ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json') - if (!configPath) - return undefined + if (!configPath) return undefined const config = ts.readConfigFile(configPath, ts.sys.readFile) - if (config.error) - throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) - - const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(configPath), { - incremental: false, - noEmit: true, - }, configPath) + if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) + + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + path.dirname(configPath), + { + incremental: false, + noEmit: true, + }, + configPath, + ) if (parsed.errors.length) throw new Error(ts.flattenDiagnosticMessageText(parsed.errors[0]!.messageText, '\n')) @@ -1039,17 +1143,16 @@ export async function migrateSelectors(root: string, write: boolean) { for (const file of files) { const source = await fs.readFile(file, 'utf8') const projectSourceFile = projectProgram?.getSourceFile(path.resolve(file)) - const result = projectSourceFile && projectChecker && projectSourceFile.text === source - ? transformAnalyzedSource(source, projectSourceFile, projectChecker) - : transformSource(source, file) - if (!result.changes) - continue + const result = + projectSourceFile && projectChecker && projectSourceFile.text === source + ? transformAnalyzedSource(source, projectSourceFile, projectChecker) + : transformSource(source, file) + if (!result.changes) continue changedFiles++ changedFilePaths.push(file) changes += result.changes - if (write) - await fs.writeFile(file, result.output, 'utf8') + if (write) await fs.writeFile(file, result.output, 'utf8') } return { changedFilePaths, changedFiles, changes } @@ -1058,19 +1161,20 @@ export async function migrateSelectors(root: string, write: boolean) { async function runCli() { const write = process.argv.includes('--write') const verbose = process.argv.includes('--verbose') - const unknownArgs = process.argv.slice(2).filter(arg => arg !== '--verbose' && arg !== '--write') - if (unknownArgs.length) - throw new Error(`Unknown arguments: ${unknownArgs.join(', ')}`) + const unknownArgs = process.argv + .slice(2) + .filter((arg) => arg !== '--verbose' && arg !== '--write') + if (unknownArgs.length) throw new Error(`Unknown arguments: ${unknownArgs.join(', ')}`) const result = await migrateSelectors(process.cwd(), write) const action = write ? 'Migrated' : 'Found' - console.log(`${action} ${result.changes} i18n selector call sites across ${result.changedFiles} files.`) + console.log( + `${action} ${result.changes} i18n selector call sites across ${result.changedFiles} files.`, + ) if (verbose) { - for (const file of result.changedFilePaths) - console.log(path.relative(process.cwd(), file)) + for (const file of result.changedFilePaths) console.log(path.relative(process.cwd(), file)) } - if (!write && result.changes) - process.exitCode = 1 + if (!write && result.changes) process.exitCode = 1 } const entryPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '' diff --git a/web/scripts/prune-unused-i18n.ts b/web/scripts/prune-unused-i18n.ts index 42660e48dc8043..9ec212956fcdbc 100644 --- a/web/scripts/prune-unused-i18n.ts +++ b/web/scripts/prune-unused-i18n.ts @@ -4,10 +4,7 @@ import type { } from './i18n-prune/core' import path from 'node:path' import { pathToFileURL } from 'node:url' -import { - analyzeUnusedTranslations, - removeUnusedTranslations, -} from './i18n-prune/core' +import { analyzeUnusedTranslations, removeUnusedTranslations } from './i18n-prune/core' type CliArgs = { write: boolean @@ -54,16 +51,14 @@ function parseArgs(argv: string[]): CliArgs { } if (arg === '--file') { const { values, nextIndex } = collectValues(argv, index) - if (!values.length) - args.errors.push('--file requires at least one value') + if (!values.length) args.errors.push('--file requires at least one value') args.files.push(...values) index = nextIndex continue } if (arg === '--lang') { const { values, nextIndex } = collectValues(argv, index) - if (!values.length) - args.errors.push('--lang requires at least one value') + if (!values.length) args.errors.push('--lang requires at least one value') args.locales.push(...values) index = nextIndex continue @@ -95,19 +90,25 @@ function countUnusedKeys(result: AnalyzeUnusedTranslationsResult) { return Object.values(result.unusedKeysByNamespace).reduce((total, keys) => total + keys.length, 0) } -function printHumanSummary(result: AnalyzeUnusedTranslationsResult, removed?: RemoveUnusedTranslationsResult) { +function printHumanSummary( + result: AnalyzeUnusedTranslationsResult, + removed?: RemoveUnusedTranslationsResult, +) { const totalUnused = countUnusedKeys(result) console.log(`Found ${totalUnused} unused i18n keys.`) for (const [namespace, keys] of Object.entries(result.unusedKeysByNamespace)) { console.log(`\n${namespace} (${keys.length})`) - for (const key of keys) - console.log(` - ${key}`) + for (const key of keys) console.log(` - ${key}`) } if (result.protectedNamespaces.length) { - console.log(`\nProtected namespaces with unresolved dynamic keys: ${result.protectedNamespaces.join(', ')}`) - console.log('These namespaces were not pruned because at least one key could not be statically resolved.') + console.log( + `\nProtected namespaces with unresolved dynamic keys: ${result.protectedNamespaces.join(', ')}`, + ) + console.log( + 'These namespaces were not pruned because at least one key could not be statically resolved.', + ) } if (result.dynamicKeyPatterns.length) { @@ -120,10 +121,8 @@ function printHumanSummary(result: AnalyzeUnusedTranslationsResult, removed?: Re console.log(` ... ${result.dynamicKeyPatterns.length - 20} more`) } - if (removed) - console.log(`\nRemoved ${removed.removedKeys.length} keys across locale files.`) - else if (totalUnused) - console.log('\nRun again with --write to remove these keys.') + if (removed) console.log(`\nRemoved ${removed.removedKeys.length} keys across locale files.`) + else if (totalUnused) console.log('\nRun again with --write to remove these keys.') } async function runCli() { @@ -134,8 +133,7 @@ async function runCli() { } if (args.errors.length) { - for (const error of args.errors) - console.error(error) + for (const error of args.errors) console.error(error) printHelp() process.exitCode = 1 return @@ -148,13 +146,11 @@ async function runCli() { if (args.json) { console.log(JSON.stringify({ analysis: result, removal: removed }, null, 2)) - } - else { + } else { printHumanSummary(result, removed) } - if (!args.write && countUnusedKeys(result)) - process.exitCode = 1 + if (!args.write && countUnusedKeys(result)) process.exitCode = 1 } const entryPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '' diff --git a/web/service/__tests__/base-request.spec.ts b/web/service/__tests__/base-request.spec.ts index 658f73ee98bcd7..5291ecc70b4ae5 100644 --- a/web/service/__tests__/base-request.spec.ts +++ b/web/service/__tests__/base-request.spec.ts @@ -1,16 +1,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest' const createUnauthorizedResponse = () => - new Response(JSON.stringify({ - code: 'unauthorized', - message: 'Invalid Authorization token.', - status: 401, - }), { - status: 401, - headers: { - 'Content-Type': 'application/json', + new Response( + JSON.stringify({ + code: 'unauthorized', + message: 'Invalid Authorization token.', + status: 401, + }), + { + status: 401, + headers: { + 'Content-Type': 'application/json', + }, }, - }) + ) async function loadServerRequest() { vi.resetModules() diff --git a/web/service/__tests__/server.spec.ts b/web/service/__tests__/server.spec.ts index 0237a62171f00e..9aa0fb442933cd 100644 --- a/web/service/__tests__/server.spec.ts +++ b/web/service/__tests__/server.spec.ts @@ -32,7 +32,9 @@ describe('server console oRPC client', () => { vi.clearAllMocks() vi.unstubAllGlobals() mocks.serverConsoleApiPrefix = undefined - mocks.headers.mockResolvedValue(new Headers({ cookie: 'access_token=abc; csrf_token=csrf-token' })) + mocks.headers.mockResolvedValue( + new Headers({ cookie: 'access_token=abc; csrf_token=csrf-token' }), + ) mocks.cookies.mockResolvedValue({ get: vi.fn(() => ({ value: 'csrf-token' })), }) @@ -44,10 +46,18 @@ describe('server console oRPC client', () => { expect(resolveServerConsoleApiPrefix(undefined, '/console/api')).toBeNull() expect(resolveServerConsoleApiUrl('/account/profile', undefined, '/console/api')).toBeNull() expect( - resolveServerConsoleApiUrl('/account/profile', 'https://api.example.com/console/api', '/console/api'), + resolveServerConsoleApiUrl( + '/account/profile', + 'https://api.example.com/console/api', + '/console/api', + ), ).toBe('https://api.example.com/console/api/account/profile') expect( - resolveServerConsoleApiUrl('/account/profile', undefined, 'https://public.example.com/console/api'), + resolveServerConsoleApiUrl( + '/account/profile', + undefined, + 'https://public.example.com/console/api', + ), ).toBe('https://public.example.com/console/api/account/profile') }) @@ -62,12 +72,14 @@ describe('server console oRPC client', () => { it('should call contracts with forwarded cookies, csrf header, and no-store cache', async () => { const { defaultSystemFeatures } = await import('@/features/system-features/config') - const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(defaultSystemFeatures), { - status: 200, - headers: { - 'content-type': 'application/json', - }, - })) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(defaultSystemFeatures), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ) vi.stubGlobal('fetch', fetchMock) const { getServerConsoleClientContext, serverConsoleClient } = await import('../server') diff --git a/web/service/__tests__/share-human-input-upload.spec.ts b/web/service/__tests__/share-human-input-upload.spec.ts index 18478410614c0d..4fd7ac47aa1e9d 100644 --- a/web/service/__tests__/share-human-input-upload.spec.ts +++ b/web/service/__tests__/share-human-input-upload.spec.ts @@ -78,11 +78,10 @@ describe('human input form upload services', () => { it('should fetch upload token before remote file upload', async () => { const { uploadHumanInputFormRemoteFileInfo } = await import('../share') - mockPostPublic - .mockResolvedValueOnce({ - upload_token: 'hitl-remote-token', - expires_at: Math.floor(Date.now() / 1000) + 60, - }) + mockPostPublic.mockResolvedValueOnce({ + upload_token: 'hitl-remote-token', + expires_at: Math.floor(Date.now() / 1000) + 60, + }) mockUpload.mockResolvedValueOnce({ id: 'remote-file-1', name: 'remote.txt', @@ -94,10 +93,16 @@ describe('human input form upload services', () => { url: 'https://example.com/remote.txt', }) - const response = await uploadHumanInputFormRemoteFileInfo('remote-form-token', 'https://example.com/file.txt') + const response = await uploadHumanInputFormRemoteFileInfo( + 'remote-form-token', + 'https://example.com/file.txt', + ) expect(mockPostPublic).toHaveBeenCalledTimes(1) - expect(mockPostPublic).toHaveBeenNthCalledWith(1, '/form/human_input/remote-form-token/upload-token') + expect(mockPostPublic).toHaveBeenNthCalledWith( + 1, + '/form/human_input/remote-form-token/upload-token', + ) expect(mockUpload).toHaveBeenCalledWith( expect.objectContaining({ data: expect.any(FormData), diff --git a/web/service/__tests__/sse-generator-post.spec.ts b/web/service/__tests__/sse-generator-post.spec.ts index 479a925bb2c0e7..f4b9f7ed36c60c 100644 --- a/web/service/__tests__/sse-generator-post.spec.ts +++ b/web/service/__tests__/sse-generator-post.spec.ts @@ -45,13 +45,19 @@ describe('sseGeneratorPost', () => { const onError = vi.fn() let controller: AbortController | undefined - sseGeneratorPost('/workflow-generate/stream', { mode: 'workflow' }, { - onPlan, - onResult, - onCompleted, - onError, - getAbortController: (c) => { controller = c }, - }) + sseGeneratorPost( + '/workflow-generate/stream', + { mode: 'workflow' }, + { + onPlan, + onResult, + onCompleted, + onError, + getAbortController: (c) => { + controller = c + }, + }, + ) await vi.waitFor(() => expect(onCompleted).toHaveBeenCalledTimes(1)) diff --git a/web/service/__tests__/use-explore.spec.tsx b/web/service/__tests__/use-explore.spec.tsx index b13418433899e2..9aba8c4372f85c 100644 --- a/web/service/__tests__/use-explore.spec.tsx +++ b/web/service/__tests__/use-explore.spec.tsx @@ -54,10 +54,7 @@ describe('useExploreAppList', () => { vi.clearAllMocks() vi.mocked(fetchAppList).mockResolvedValue({ categories: [], - recommended_apps: [ - createApp('app-2', 2), - createApp('app-1', 1), - ], + recommended_apps: [createApp('app-2', 2), createApp('app-1', 1)], }) }) @@ -70,13 +67,15 @@ describe('useExploreAppList', () => { }) it('should fetch localized app list and sort recommended apps by position', async () => { - const { result } = renderHook(() => useExploreAppList({ enabled: true }), { wrapper: createWrapper() }) + const { result } = renderHook(() => useExploreAppList({ enabled: true }), { + wrapper: createWrapper(), + }) await waitFor(() => { expect(fetchAppList).toHaveBeenCalledWith('en-US') }) await waitFor(() => { - expect(result.current.data?.allList.map(app => app.app_id)).toEqual(['app-1', 'app-2']) + expect(result.current.data?.allList.map((app) => app.app_id)).toEqual(['app-1', 'app-2']) }) }) }) diff --git a/web/service/__tests__/use-pipeline.spec.tsx b/web/service/__tests__/use-pipeline.spec.tsx index 411d29ac6a6432..d030a8b151cc3c 100644 --- a/web/service/__tests__/use-pipeline.spec.tsx +++ b/web/service/__tests__/use-pipeline.spec.tsx @@ -22,9 +22,7 @@ const createWrapper = () => { }) return ({ children }: { children: ReactNode }) => ( - - {children} - + {children} ) } @@ -42,10 +40,7 @@ describe('use-pipeline imports', () => { }) it('should import pipeline DSL silently so callers can own error toasts', async () => { - const { result } = renderHook( - () => useImportPipelineDSL(), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useImportPipelineDSL(), { wrapper: createWrapper() }) const request = { mode: DSLImportMode.YAML_CONTENT, yaml_content: 'rag_pipeline: {}', @@ -55,18 +50,11 @@ describe('use-pipeline imports', () => { await result.current.mutateAsync(request) }) - expect(post).toHaveBeenCalledWith( - '/rag/pipelines/imports', - { body: request }, - { silent: true }, - ) + expect(post).toHaveBeenCalledWith('/rag/pipelines/imports', { body: request }, { silent: true }) }) it('should confirm pipeline DSL import silently so callers can own error toasts', async () => { - const { result } = renderHook( - () => useImportPipelineDSLConfirm(), - { wrapper: createWrapper() }, - ) + const { result } = renderHook(() => useImportPipelineDSLConfirm(), { wrapper: createWrapper() }) await act(async () => { await result.current.mutateAsync('import-id') diff --git a/web/service/__tests__/use-plugins.spec.tsx b/web/service/__tests__/use-plugins.spec.tsx index 7066d5fa29c269..d41b0c60ed3d7f 100644 --- a/web/service/__tests__/use-plugins.spec.tsx +++ b/web/service/__tests__/use-plugins.spec.tsx @@ -5,8 +5,16 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { FormTypeEnum } from '@/app/components/base/form/types' -import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' -import { PermissionType, PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types' +import { + AUTO_UPDATE_MODE, + AUTO_UPDATE_STRATEGY, +} from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' +import { + PermissionType, + PluginCategoryEnum, + PluginSource, + TaskStatus, +} from '@/app/components/plugins/types' import { normalizeInstalledPluginDetail, useInstalledPluginList, @@ -16,11 +24,7 @@ import { usePluginTaskList, } from '../use-plugins' -const { - mockGet, - mockPost, - mockWorkspacePermissionKeysAtom, -} = vi.hoisted(() => ({ +const { mockGet, mockPost, mockWorkspacePermissionKeysAtom } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn(), mockWorkspacePermissionKeysAtom: Symbol('workspacePermissionKeysAtom'), @@ -57,8 +61,7 @@ vi.mock('@/context/system-features-state', () => ({ vi.mock('jotai', () => ({ useAtomValue: (atom: unknown) => { - if (atom === mockWorkspacePermissionKeysAtom) - return ['plugin.install'] + if (atom === mockWorkspacePermissionKeysAtom) return ['plugin.install'] throw new Error('Unexpected atom') }, @@ -68,24 +71,21 @@ vi.mock('../use-tools', () => ({ useInvalidateAllBuiltInTools: () => vi.fn(), })) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - mutations: { - retry: false, +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, }, - }, -}) + }) const createWrapper = (queryClient: QueryClient) => { return function Wrapper({ children }: { children: ReactNode }) { - return ( - - {children} - - ) + return {children} } } @@ -255,7 +255,9 @@ describe('normalizeInstalledPluginDetail', () => { }) expect(detail.declaration.trigger.identity.name).toBe('github-trigger') expect(detail.declaration.trigger.events[0]?.parameters[0]?.default).toBe(0) - expect(detail.declaration.trigger.subscription_constructor.parameters[0]?.type).toBe(FormTypeEnum.textNumber) + expect(detail.declaration.trigger.subscription_constructor.parameters[0]?.type).toBe( + FormTypeEnum.textNumber, + ) expect(detail.declaration.trigger.subscription_schema[0]?.name).toBe('repository') }) }) @@ -280,9 +282,11 @@ describe('use-plugins mutations', () => { upgrade_time_of_day: 3600, } let resolvePost: (value: unknown) => void = () => {} - mockPost.mockReturnValue(new Promise((resolve) => { - resolvePost = resolve - })) + mockPost.mockReturnValue( + new Promise((resolve) => { + resolvePost = resolve + }), + ) queryClient.setQueryData(queryKey, { category: PluginCategoryEnum.model, auto_upgrade: previousAutoUpgrade, @@ -327,15 +331,16 @@ describe('use-plugins mutations', () => { debug_permission: PermissionType.admin, } let resolvePost: (value: unknown) => void = () => {} - mockPost.mockReturnValue(new Promise((resolve) => { - resolvePost = resolve - })) + mockPost.mockReturnValue( + new Promise((resolve) => { + resolvePost = resolve + }), + ) queryClient.setQueryData(queryKey, previousPermission) - const { result } = renderHook( - () => useMutationPluginPermissionSettings(), - { wrapper: createWrapper(queryClient) }, - ) + const { result } = renderHook(() => useMutationPluginPermissionSettings(), { + wrapper: createWrapper(queryClient), + }) act(() => { result.current.mutate(nextPermission) @@ -368,9 +373,11 @@ describe('use-plugins mutations', () => { upgrade_time_of_day: 3600, } let rejectPost: (reason?: unknown) => void = () => {} - mockPost.mockReturnValue(new Promise((_resolve, reject) => { - rejectPost = reject - })) + mockPost.mockReturnValue( + new Promise((_resolve, reject) => { + rejectPost = reject + }), + ) queryClient.setQueryData(queryKey, { category: PluginCategoryEnum.model, auto_upgrade: previousAutoUpgrade, @@ -412,9 +419,11 @@ describe('use-plugins mutations', () => { include_plugins: [], } let rejectPost: (reason?: unknown) => void = () => {} - mockPost.mockReturnValue(new Promise((_resolve, reject) => { - rejectPost = reject - })) + mockPost.mockReturnValue( + new Promise((_resolve, reject) => { + rejectPost = reject + }), + ) const { result } = renderHook( () => useMutationPluginAutoUpgradeSettings({ category: PluginCategoryEnum.model }), @@ -450,15 +459,16 @@ describe('use-plugins mutations', () => { debug_permission: PermissionType.admin, } let rejectPost: (reason?: unknown) => void = () => {} - mockPost.mockReturnValue(new Promise((_resolve, reject) => { - rejectPost = reject - })) + mockPost.mockReturnValue( + new Promise((_resolve, reject) => { + rejectPost = reject + }), + ) queryClient.setQueryData(queryKey, previousPermission) - const { result } = renderHook( - () => useMutationPluginPermissionSettings(), - { wrapper: createWrapper(queryClient) }, - ) + const { result } = renderHook(() => useMutationPluginPermissionSettings(), { + wrapper: createWrapper(queryClient), + }) const mutation = result.current.mutateAsync(nextPermission).catch(() => undefined) @@ -484,10 +494,7 @@ describe('useInstalledPluginList', () => { const queryClient = createQueryClient() mockGet.mockResolvedValue({ plugins: [], total: 0 }) - renderHook( - () => useInstalledPluginList(false, 100), - { wrapper: createWrapper(queryClient) }, - ) + renderHook(() => useInstalledPluginList(false, 100), { wrapper: createWrapper(queryClient) }) await waitFor(() => { expect(mockGet).toHaveBeenCalledWith('/workspaces/current/plugin/list?page=1&page_size=100') @@ -498,13 +505,14 @@ describe('useInstalledPluginList', () => { const queryClient = createQueryClient() mockGet.mockResolvedValue({ plugins: [], has_more: false }) - renderHook( - () => useInstalledPluginList(false, 100, { category: PluginCategoryEnum.trigger }), - { wrapper: createWrapper(queryClient) }, - ) + renderHook(() => useInstalledPluginList(false, 100, { category: PluginCategoryEnum.trigger }), { + wrapper: createWrapper(queryClient), + }) await waitFor(() => { - expect(mockGet).toHaveBeenCalledWith('/workspaces/current/plugin/trigger/list?page=1&page_size=100') + expect(mockGet).toHaveBeenCalledWith( + '/workspaces/current/plugin/trigger/list?page=1&page_size=100', + ) }) }) @@ -541,15 +549,11 @@ describe('useInstalledPluginList', () => { const queryClient = createQueryClient() mockGet .mockResolvedValueOnce({ - plugins: [ - { plugin_id: 'trigger-plugin-1' }, - ], + plugins: [{ plugin_id: 'trigger-plugin-1' }], has_more: true, }) .mockResolvedValueOnce({ - plugins: [ - { plugin_id: 'trigger-plugin-2' }, - ], + plugins: [{ plugin_id: 'trigger-plugin-2' }], has_more: false, }) @@ -567,7 +571,9 @@ describe('useInstalledPluginList', () => { }) await waitFor(() => { - expect(mockGet).toHaveBeenCalledWith('/workspaces/current/plugin/trigger/list?page=2&page_size=100') + expect(mockGet).toHaveBeenCalledWith( + '/workspaces/current/plugin/trigger/list?page=2&page_size=100', + ) }) await waitFor(() => { expect(result.current.isLastPage).toBe(true) @@ -593,16 +599,12 @@ describe('useInstalledPluginList', () => { ] mockGet .mockResolvedValueOnce({ - plugins: [ - { plugin_id: 'tool-plugin-1' }, - ], + plugins: [{ plugin_id: 'tool-plugin-1' }], builtin_tools: builtinTools, has_more: true, }) .mockResolvedValueOnce({ - plugins: [ - { plugin_id: 'tool-plugin-2' }, - ], + plugins: [{ plugin_id: 'tool-plugin-2' }], builtin_tools: builtinTools, has_more: false, }) @@ -665,10 +667,9 @@ describe('usePluginTaskList', () => { ], } - const { result } = renderHook( - () => usePluginTaskList(PluginCategoryEnum.tool), - { wrapper: createWrapper(queryClient) }, - ) + const { result } = renderHook(() => usePluginTaskList(PluginCategoryEnum.tool), { + wrapper: createWrapper(queryClient), + }) act(() => { result.current.handleInstallTaskStart({ @@ -724,10 +725,9 @@ describe('usePluginTaskList', () => { }) mockGet.mockResolvedValue({ tasks: [] }) - const { result } = renderHook( - () => usePluginTaskList(PluginCategoryEnum.tool), - { wrapper: createWrapper(queryClient) }, - ) + const { result } = renderHook(() => usePluginTaskList(PluginCategoryEnum.tool), { + wrapper: createWrapper(queryClient), + }) act(() => { result.current.handleInstallTaskStart({ @@ -808,13 +808,14 @@ describe('usePluginTaskList', () => { ], } - const { result } = renderHook( - () => usePluginTaskList(PluginCategoryEnum.tool), - { wrapper: createWrapper(queryClient) }, - ) + const { result } = renderHook(() => usePluginTaskList(PluginCategoryEnum.tool), { + wrapper: createWrapper(queryClient), + }) await waitFor(() => { - expect(queryClient.getQueryData(['plugins', 'pluginTaskList'])).toEqual({ tasks: [staleTask] }) + expect(queryClient.getQueryData(['plugins', 'pluginTaskList'])).toEqual({ + tasks: [staleTask], + }) }) act(() => { @@ -853,10 +854,9 @@ describe('usePluginAutoUpgradeSettings', () => { const queryClient = createQueryClient() mockGet.mockReturnValue(new Promise(() => {})) - const { result } = renderHook( - () => usePluginAutoUpgradeSettings(PluginCategoryEnum.model), - { wrapper: createWrapper(queryClient) }, - ) + const { result } = renderHook(() => usePluginAutoUpgradeSettings(PluginCategoryEnum.model), { + wrapper: createWrapper(queryClient), + }) expect(result.current.data).toBeUndefined() expect(mockGet).toHaveBeenCalledWith('/workspaces/current/plugin/auto-upgrade/fetch', { @@ -880,10 +880,9 @@ describe('usePluginAutoUpgradeSettings', () => { auto_upgrade: backendAutoUpgrade, }) - const { result } = renderHook( - () => usePluginAutoUpgradeSettings(PluginCategoryEnum.tool), - { wrapper: createWrapper(queryClient) }, - ) + const { result } = renderHook(() => usePluginAutoUpgradeSettings(PluginCategoryEnum.tool), { + wrapper: createWrapper(queryClient), + }) await waitFor(() => { expect(result.current.data).toEqual({ diff --git a/web/service/access-control/__tests__/index.spec.tsx b/web/service/access-control/__tests__/index.spec.tsx index 30bf2561786ca0..086fc7e13548df 100644 --- a/web/service/access-control/__tests__/index.spec.tsx +++ b/web/service/access-control/__tests__/index.spec.tsx @@ -40,7 +40,9 @@ const createWrapper = () => { describe('access-control service', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(consoleClient.enterprise.webAppAuth.updateWebAppWhitelistSubjects).mockResolvedValue({}) + vi.mocked(consoleClient.enterprise.webAppAuth.updateWebAppWhitelistSubjects).mockResolvedValue( + {}, + ) }) // Access mode updates keep the legacy webapp whitelist payload contract. @@ -58,7 +60,9 @@ describe('access-control service', () => { }) await waitFor(() => { - expect(consoleClient.enterprise.webAppAuth.updateWebAppWhitelistSubjects).toHaveBeenCalledWith({ + expect( + consoleClient.enterprise.webAppAuth.updateWebAppWhitelistSubjects, + ).toHaveBeenCalledWith({ body: { appId: 'app-1', accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, diff --git a/web/service/access-control/__tests__/use-access-subjects.spec.tsx b/web/service/access-control/__tests__/use-access-subjects.spec.tsx index 1834d0f70098e3..d5f7025d875499 100644 --- a/web/service/access-control/__tests__/use-access-subjects.spec.tsx +++ b/web/service/access-control/__tests__/use-access-subjects.spec.tsx @@ -9,7 +9,8 @@ vi.mock('@/service/client', () => ({ consoleClient: { enterprise: { webAppAuth: { - searchForWhilteListCandidates: (...args: unknown[]) => mockSearchForWhilteListCandidates(...args), + searchForWhilteListCandidates: (...args: unknown[]) => + mockSearchForWhilteListCandidates(...args), }, }, }, @@ -39,11 +40,15 @@ describe('use-access-subjects', () => { it('should search access subject candidates with the generated enterprise client', async () => { renderHook( - () => useSearchAccessSubjects({ - keyword: 'team one', - groupId: 'group-1', - resultsPerPage: 20, - }, true), + () => + useSearchAccessSubjects( + { + keyword: 'team one', + groupId: 'group-1', + resultsPerPage: 20, + }, + true, + ), { wrapper: createWrapper() }, ) diff --git a/web/service/access-control/__tests__/use-app-access-config.spec.tsx b/web/service/access-control/__tests__/use-app-access-config.spec.tsx index 0b0b93ce2d0f59..5ec8412337c638 100644 --- a/web/service/access-control/__tests__/use-app-access-config.spec.tsx +++ b/web/service/access-control/__tests__/use-app-access-config.spec.tsx @@ -20,7 +20,12 @@ const mocks = vi.hoisted(() => ({ queryFn: vi.fn().mockResolvedValue({ data: [], scope: 'specific' }), })), userAccessSettingsKey: vi.fn(() => ['rbac-access-config', 'apps', 'user-access-settings']), - userAccessSettingsQueryKey: vi.fn(() => ['rbac-access-config', 'apps', 'user-access-settings', 'app-1']), + userAccessSettingsQueryKey: vi.fn(() => [ + 'rbac-access-config', + 'apps', + 'user-access-settings', + 'app-1', + ]), updateOpenScope: vi.fn().mockResolvedValue({}), updateUserAccessSettings: vi.fn().mockResolvedValue({}), removeMemberBindings: vi.fn().mockResolvedValue({}), @@ -137,10 +142,15 @@ describe('use-app-access-config', () => { }) it('should update user access settings for an app id', async () => { - const { result } = renderHook(() => useUpdateAppUserAccessSettings('app-1'), { wrapper: createWrapper() }) + const { result } = renderHook(() => useUpdateAppUserAccessSettings('app-1'), { + wrapper: createWrapper(), + }) await act(async () => { - await result.current.mutateAsync({ accountId: 'account-1', accessPolicyIds: ['policy-1', 'policy-2'] }) + await result.current.mutateAsync({ + accountId: 'account-1', + accessPolicyIds: ['policy-1', 'policy-2'], + }) }) expect(mocks.updateUserAccessSettings).toHaveBeenCalledWith({ @@ -157,7 +167,9 @@ describe('use-app-access-config', () => { }) it('should remove app access policy member bindings for account ids', async () => { - const { result } = renderHook(() => useRemoveAppAccessPolicyMemberBindings('app-1'), { wrapper: createWrapper() }) + const { result } = renderHook(() => useRemoveAppAccessPolicyMemberBindings('app-1'), { + wrapper: createWrapper(), + }) await act(async () => { await result.current.mutateAsync({ accessPolicyId: 'policy-1', accountIds: ['account-1'] }) @@ -177,7 +189,9 @@ describe('use-app-access-config', () => { }) it('should update open scope for an app id', async () => { - const { result } = renderHook(() => useUpdateAppOpenScope('app-1'), { wrapper: createWrapper() }) + const { result } = renderHook(() => useUpdateAppOpenScope('app-1'), { + wrapper: createWrapper(), + }) await act(async () => { await result.current.mutateAsync('all') diff --git a/web/service/access-control/__tests__/use-app-access-control.spec.tsx b/web/service/access-control/__tests__/use-app-access-control.spec.tsx index bcf4ebcfaf2cec..46fed31a66edbc 100644 --- a/web/service/access-control/__tests__/use-app-access-control.spec.tsx +++ b/web/service/access-control/__tests__/use-app-access-control.spec.tsx @@ -23,14 +23,16 @@ vi.mock('@/service/share', () => ({ })) vi.mock('@/features/system-features/client', () => ({ - systemFeaturesQueryOptions: () => queryOptions({ - queryKey: ['system-features'], - queryFn: () => Promise.resolve({ - webapp_auth: { - enabled: mockSystemFeatures.webappAuthEnabled, - }, + systemFeaturesQueryOptions: () => + queryOptions({ + queryKey: ['system-features'], + queryFn: () => + Promise.resolve({ + webapp_auth: { + enabled: mockSystemFeatures.webappAuthEnabled, + }, + }), }), - }), })) const createWrapper = () => { @@ -73,21 +75,29 @@ describe('use-app-access-control', () => { }) renderHook( - () => useSearchForWhiteListCandidates({ - keyword: 'team one', - groupId: 'group-1', - resultsPerPage: 20, - }, true), + () => + useSearchForWhiteListCandidates( + { + keyword: 'team one', + groupId: 'group-1', + resultsPerPage: 20, + }, + true, + ), { wrapper: createWrapper() }, ) await waitFor(() => { - expect(get).toHaveBeenCalledWith('/enterprise/webapp/app/subject/search?keyword=team+one&groupId=group-1&resultsPerPage=20&pageNumber=1') + expect(get).toHaveBeenCalledWith( + '/enterprise/webapp/app/subject/search?keyword=team+one&groupId=group-1&resultsPerPage=20&pageNumber=1', + ) }) }) it('should return public access when webapp auth is disabled', async () => { - const { result } = renderHook(() => useGetUserCanAccessApp({ appId: 'app-1' }), { wrapper: createWrapper() }) + const { result } = renderHook(() => useGetUserCanAccessApp({ appId: 'app-1' }), { + wrapper: createWrapper(), + }) await waitFor(() => { expect(result.current.data).toEqual({ result: true }) @@ -98,7 +108,9 @@ describe('use-app-access-control', () => { it('should call share access check when webapp auth is enabled', async () => { mockSystemFeatures.webappAuthEnabled = true - renderHook(() => useGetUserCanAccessApp({ appId: 'app-1', isInstalledApp: false }), { wrapper: createWrapper() }) + renderHook(() => useGetUserCanAccessApp({ appId: 'app-1', isInstalledApp: false }), { + wrapper: createWrapper(), + }) await waitFor(() => { expect(getUserCanAccess).toHaveBeenCalledWith('app-1', false) diff --git a/web/service/access-control/__tests__/use-dataset-access-config.spec.tsx b/web/service/access-control/__tests__/use-dataset-access-config.spec.tsx index 3d504e5378cae8..ac1e87ce77c885 100644 --- a/web/service/access-control/__tests__/use-dataset-access-config.spec.tsx +++ b/web/service/access-control/__tests__/use-dataset-access-config.spec.tsx @@ -20,7 +20,12 @@ const mocks = vi.hoisted(() => ({ queryFn: vi.fn().mockResolvedValue({ data: [], scope: 'specific' }), })), userAccessSettingsKey: vi.fn(() => ['rbac-access-config', 'datasets', 'user-access-settings']), - userAccessSettingsQueryKey: vi.fn(() => ['rbac-access-config', 'datasets', 'user-access-settings', 'dataset-1']), + userAccessSettingsQueryKey: vi.fn(() => [ + 'rbac-access-config', + 'datasets', + 'user-access-settings', + 'dataset-1', + ]), updateOpenScope: vi.fn().mockResolvedValue({}), updateUserAccessSettings: vi.fn().mockResolvedValue({}), removeMemberBindings: vi.fn().mockResolvedValue({}), @@ -122,7 +127,9 @@ describe('use-dataset-access-config', () => { // User access settings mirror the app access-config API shape for datasets. describe('User Access Settings', () => { it('should fetch user access settings for a dataset id', () => { - renderHook(() => useDatasetUserAccessSettings('dataset-1', 'zh'), { wrapper: createWrapper() }) + renderHook(() => useDatasetUserAccessSettings('dataset-1', 'zh'), { + wrapper: createWrapper(), + }) expect(mocks.userAccessSettingsQueryOptions).toHaveBeenCalledWith({ input: { @@ -137,10 +144,15 @@ describe('use-dataset-access-config', () => { }) it('should update user access settings for a dataset id', async () => { - const { result } = renderHook(() => useUpdateDatasetUserAccessSettings('dataset-1'), { wrapper: createWrapper() }) + const { result } = renderHook(() => useUpdateDatasetUserAccessSettings('dataset-1'), { + wrapper: createWrapper(), + }) await act(async () => { - await result.current.mutateAsync({ accountId: 'account-1', accessPolicyIds: ['policy-1', 'policy-2'] }) + await result.current.mutateAsync({ + accountId: 'account-1', + accessPolicyIds: ['policy-1', 'policy-2'], + }) }) expect(mocks.updateUserAccessSettings).toHaveBeenCalledWith({ @@ -157,7 +169,9 @@ describe('use-dataset-access-config', () => { }) it('should remove dataset access policy member bindings for account ids', async () => { - const { result } = renderHook(() => useRemoveDatasetAccessPolicyMemberBindings('dataset-1'), { wrapper: createWrapper() }) + const { result } = renderHook(() => useRemoveDatasetAccessPolicyMemberBindings('dataset-1'), { + wrapper: createWrapper(), + }) await act(async () => { await result.current.mutateAsync({ accessPolicyId: 'policy-1', accountIds: ['account-1'] }) @@ -177,7 +191,9 @@ describe('use-dataset-access-config', () => { }) it('should update open scope for a dataset id', async () => { - const { result } = renderHook(() => useUpdateDatasetOpenScope('dataset-1'), { wrapper: createWrapper() }) + const { result } = renderHook(() => useUpdateDatasetOpenScope('dataset-1'), { + wrapper: createWrapper(), + }) await act(async () => { await result.current.mutateAsync('specific') diff --git a/web/service/access-control/__tests__/use-member-roles.spec.tsx b/web/service/access-control/__tests__/use-member-roles.spec.tsx index 57919ce919fbd3..6328e06b09bcd1 100644 --- a/web/service/access-control/__tests__/use-member-roles.spec.tsx +++ b/web/service/access-control/__tests__/use-member-roles.spec.tsx @@ -10,12 +10,13 @@ vi.mock('@/service/base', () => ({ put: vi.fn(), })) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, -}) +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) const createWrapper = (queryClient = createQueryClient()) => { return ({ children }: { children: ReactNode }) => ( @@ -64,7 +65,9 @@ describe('use-member-roles', () => { const queryClient = createQueryClient() const membersQueryKey = [...commonQueryKeys.members, 'en-US'] queryClient.setQueryData(membersQueryKey, { accounts: [] }) - const { result } = renderHook(() => useUpdateRolesOfMember(), { wrapper: createWrapper(queryClient) }) + const { result } = renderHook(() => useUpdateRolesOfMember(), { + wrapper: createWrapper(queryClient), + }) await act(async () => { await result.current.mutateAsync({ diff --git a/web/service/access-control/__tests__/use-permission-catalog.spec.tsx b/web/service/access-control/__tests__/use-permission-catalog.spec.tsx index 5b32cfab30095a..75afa5bb55cabc 100644 --- a/web/service/access-control/__tests__/use-permission-catalog.spec.tsx +++ b/web/service/access-control/__tests__/use-permission-catalog.spec.tsx @@ -52,7 +52,9 @@ describe('use-permission-catalog', () => { renderHook(() => useDatasetPermissionCatalog(true), { wrapper: createWrapper() }) await waitFor(() => { - expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/role-permissions/catalog/dataset') + expect(get).toHaveBeenCalledWith( + '/workspaces/current/rbac/role-permissions/catalog/dataset', + ) }) }) diff --git a/web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx b/web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx index e7b2ecdce33d21..cc2a197c9bf090 100644 --- a/web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx +++ b/web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx @@ -43,7 +43,9 @@ describe('use-workspace-access-rules', () => { // Queries load workspace-level app and dataset access policies from separate endpoints. describe('Queries', () => { it('should fetch workspace app access rules', async () => { - renderHook(() => useInfiniteWorkspaceAppAccessRules({ page: 1, limit: 20, language: 'zh' }), { wrapper: createWrapper() }) + renderHook(() => useInfiniteWorkspaceAppAccessRules({ page: 1, limit: 20, language: 'zh' }), { + wrapper: createWrapper(), + }) await waitFor(() => { expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/workspace/apps/access-policy', { @@ -53,12 +55,18 @@ describe('use-workspace-access-rules', () => { }) it('should fetch workspace dataset access rules', async () => { - renderHook(() => useInfiniteWorkspaceDatasetAccessRules({ page: 1, limit: 20, language: 'ja' }), { wrapper: createWrapper() }) + renderHook( + () => useInfiniteWorkspaceDatasetAccessRules({ page: 1, limit: 20, language: 'ja' }), + { wrapper: createWrapper() }, + ) await waitFor(() => { - expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/workspace/datasets/access-policy', { - params: { page: 1, limit: 20, language: 'ja' }, - }) + expect(get).toHaveBeenCalledWith( + '/workspaces/current/rbac/workspace/datasets/access-policy', + { + params: { page: 1, limit: 20, language: 'ja' }, + }, + ) }) }) }) @@ -105,14 +113,19 @@ describe('use-workspace-access-rules', () => { it('should copy and delete access rules by id', async () => { const copyHook = renderHook(() => useCopyAccessRule('app'), { wrapper: createWrapper() }) - const deleteHook = renderHook(() => useDeleteAccessRule('dataset'), { wrapper: createWrapper() }) + const deleteHook = renderHook(() => useDeleteAccessRule('dataset'), { + wrapper: createWrapper(), + }) await act(async () => { await copyHook.result.current.mutateAsync('policy-1') await deleteHook.result.current.mutateAsync('policy-2') }) - expect(post).toHaveBeenCalledWith('/workspaces/current/rbac/access-policies/policy-1/copy', {}) + expect(post).toHaveBeenCalledWith( + '/workspaces/current/rbac/access-policies/policy-1/copy', + {}, + ) expect(del).toHaveBeenCalledWith('/workspaces/current/rbac/access-policies/policy-2', {}) }) }) diff --git a/web/service/access-control/__tests__/use-workspace-roles.spec.tsx b/web/service/access-control/__tests__/use-workspace-roles.spec.tsx index 866501da7c21e5..89239becb2fd63 100644 --- a/web/service/access-control/__tests__/use-workspace-roles.spec.tsx +++ b/web/service/access-control/__tests__/use-workspace-roles.spec.tsx @@ -73,7 +73,9 @@ describe('use-workspace-roles', () => { // Role list pagination starts from the provided page and passes query params through. describe('Queries', () => { it('should fetch workspace roles with pagination params', async () => { - renderHook(() => useWorkspaceRoleList({ page: 2, limit: 20, language: 'zh' }), { wrapper: createWrapper() }) + renderHook(() => useWorkspaceRoleList({ page: 2, limit: 20, language: 'zh' }), { + wrapper: createWrapper(), + }) await waitFor(() => { expect(mockServiceBase.get).toHaveBeenCalledWith('/workspaces/current/rbac/roles', { @@ -87,15 +89,20 @@ describe('use-workspace-roles', () => { }) it('should fetch members assigned to a workspace role with pagination params', async () => { - renderHook(() => useGetMembersOfRole({ roleId: 'role-1', page: 2, limit: 1 }), { wrapper: createWrapper() }) + renderHook(() => useGetMembersOfRole({ roleId: 'role-1', page: 2, limit: 1 }), { + wrapper: createWrapper(), + }) await waitFor(() => { - expect(mockServiceBase.get).toHaveBeenCalledWith('/workspaces/current/rbac/roles/role-1/members', { - params: { - page: 2, - limit: 1, + expect(mockServiceBase.get).toHaveBeenCalledWith( + '/workspaces/current/rbac/roles/role-1/members', + { + params: { + page: 2, + limit: 1, + }, }, - }) + ) }) }) }) @@ -157,9 +164,12 @@ describe('use-workspace-roles', () => { }) expect(mockServiceBase.del).toHaveBeenCalledWith('/workspaces/current/rbac/roles/role-1') - expect(mockServiceBase.post).toHaveBeenCalledWith('/workspaces/current/rbac/roles/role-2/copy', { - body: { copy_member: false }, - }) + expect(mockServiceBase.post).toHaveBeenCalledWith( + '/workspaces/current/rbac/roles/role-2/copy', + { + body: { copy_member: false }, + }, + ) }) it('should invalidate members after deleting a workspace role', async () => { diff --git a/web/service/access-control/index.ts b/web/service/access-control/index.ts index 7a14431b51cffa..601f3d6ded4841 100644 --- a/web/service/access-control/index.ts +++ b/web/service/access-control/index.ts @@ -32,7 +32,10 @@ export const useAppWhiteListSubjects = (appId: string | undefined, enabled: bool return useAppWhiteListSubjectsBase(appId, enabled) } -export const useSearchForWhiteListCandidates = (query: SearchForWhiteListCandidatesQuery, enabled: boolean) => { +export const useSearchForWhiteListCandidates = ( + query: SearchForWhiteListCandidatesQuery, + enabled: boolean, +) => { return useSearchForWhiteListCandidatesBase(query, enabled) } diff --git a/web/service/access-control/normalizers.ts b/web/service/access-control/normalizers.ts index 512064d02fe087..e30d844d7078c1 100644 --- a/web/service/access-control/normalizers.ts +++ b/web/service/access-control/normalizers.ts @@ -11,42 +11,51 @@ import type { Role, } from '@/models/access-control' -type GeneratedAccessMatrixItem = import('@dify/contracts/api/console/workspaces/types.gen').AccessMatrixItem +type GeneratedAccessMatrixItem = + import('@dify/contracts/api/console/workspaces/types.gen').AccessMatrixItem type GeneratedAccessPolicy = import('@dify/contracts/api/console/workspaces/types.gen').AccessPolicy -type GeneratedAccessPolicyAccount = import('@dify/contracts/api/console/workspaces/types.gen').AccessPolicyAccount -type GeneratedAccessPolicyRole = import('@dify/contracts/api/console/workspaces/types.gen').AccessPolicyRole -type GeneratedAppAccessMatrix = import('@dify/contracts/api/console/workspaces/types.gen').AppAccessMatrix -type GeneratedDatasetAccessMatrix = import('@dify/contracts/api/console/workspaces/types.gen').DatasetAccessMatrix +type GeneratedAccessPolicyAccount = + import('@dify/contracts/api/console/workspaces/types.gen').AccessPolicyAccount +type GeneratedAccessPolicyRole = + import('@dify/contracts/api/console/workspaces/types.gen').AccessPolicyRole +type GeneratedAppAccessMatrix = + import('@dify/contracts/api/console/workspaces/types.gen').AppAccessMatrix +type GeneratedDatasetAccessMatrix = + import('@dify/contracts/api/console/workspaces/types.gen').DatasetAccessMatrix type GeneratedRbacRole = import('@dify/contracts/api/console/workspaces/types.gen').RbacRole -type GeneratedRbacRoleAccount = import('@dify/contracts/api/console/workspaces/types.gen').RbacRoleAccount -type GeneratedResourceOpenScope = import('@dify/contracts/api/console/workspaces/types.gen').RbacResourceWhitelistScope -type GeneratedResourceUserAccessPoliciesResponse = import('@dify/contracts/api/console/workspaces/types.gen').ResourceUserAccessPoliciesResponse -type GeneratedResourceUserAccessPolicies = NonNullable[number] +type GeneratedRbacRoleAccount = + import('@dify/contracts/api/console/workspaces/types.gen').RbacRoleAccount +type GeneratedResourceOpenScope = + import('@dify/contracts/api/console/workspaces/types.gen').RbacResourceWhitelistScope +type GeneratedResourceUserAccessPoliciesResponse = + import('@dify/contracts/api/console/workspaces/types.gen').ResourceUserAccessPoliciesResponse +type GeneratedResourceUserAccessPolicies = NonNullable< + GeneratedResourceUserAccessPoliciesResponse['data'] +>[number] const normalizeRoleCategory = (category?: string): Role['category'] => { - if (category === 'global_system_default') - return 'global_system_default' + if (category === 'global_system_default') return 'global_system_default' return 'global_custom' } const normalizeRoleType = (type?: string): Role['type'] => { - if (type === 'app' || type === 'dataset') - return type + if (type === 'app' || type === 'dataset') return type return 'workspace' } const normalizeRoleTag = (roleTag?: string): Role['role_tag'] => { - if (roleTag === 'owner') - return 'owner' + if (roleTag === 'owner') return 'owner' return '' } -const normalizeResourceType = (resourceType: string, fallback: AccessPolicyResourceType): AccessPolicyResourceType => { - if (resourceType === 'app' || resourceType === 'dataset') - return resourceType +const normalizeResourceType = ( + resourceType: string, + fallback: AccessPolicyResourceType, +): AccessPolicyResourceType => { + if (resourceType === 'app' || resourceType === 'dataset') return resourceType return fallback } @@ -88,8 +97,7 @@ const normalizeAccessMatrixItem = ( item: GeneratedAccessMatrixItem, fallbackResourceType: AccessPolicyResourceType, ): AccessPolicyWithBindings | null => { - if (!item.policy) - return null + if (!item.policy) return null return { policy: normalizeAccessPolicy(item.policy, fallbackResourceType), @@ -98,7 +106,9 @@ const normalizeAccessMatrixItem = ( } } -const isAccessPolicyWithBindings = (item: AccessPolicyWithBindings | null): item is AccessPolicyWithBindings => { +const isAccessPolicyWithBindings = ( + item: AccessPolicyWithBindings | null, +): item is AccessPolicyWithBindings => { return item !== null } @@ -113,7 +123,9 @@ const normalizeResourceOpenScope = (scope: GeneratedResourceOpenScope): Resource } } -const normalizeAccount = (account: GeneratedRbacRoleAccount): ResourceUserAccessSetting['account'] => ({ +const normalizeAccount = ( + account: GeneratedRbacRoleAccount, +): ResourceUserAccessSetting['account'] => ({ account_id: account.account_id, account_name: account.account_name ?? '', email: account.email ?? '', @@ -135,21 +147,27 @@ const normalizeResourceUserAccessSetting = ( ): ResourceUserAccessSetting => ({ account: normalizeAccount(setting.account), roles: (setting.roles ?? []).map(normalizeRole), - access_policies: (setting.access_policies ?? []).map(policy => normalizeAccessPolicy(policy, fallbackResourceType)), + access_policies: (setting.access_policies ?? []).map((policy) => + normalizeAccessPolicy(policy, fallbackResourceType), + ), }) const normalizeResourceUserAccessPolicies = ( response: GeneratedResourceUserAccessPoliciesResponse, fallbackResourceType: AccessPolicyResourceType, ): GetAppUserAccessSettingsResponse | GetDatasetUserAccessSettingsResponse => ({ - data: (response.data ?? []).map(setting => normalizeResourceUserAccessSetting(setting, fallbackResourceType)), + data: (response.data ?? []).map((setting) => + normalizeResourceUserAccessSetting(setting, fallbackResourceType), + ), scope: normalizeResourceOpenScope(response.scope), }) -export const normalizeAppAccessMatrix = (response: GeneratedAppAccessMatrix): GetAppAccessPolicyByAppIdResponse => ({ +export const normalizeAppAccessMatrix = ( + response: GeneratedAppAccessMatrix, +): GetAppAccessPolicyByAppIdResponse => ({ app_id: response.app_id ?? '', items: (response.items ?? []) - .map(item => normalizeAccessMatrixItem(item, 'app')) + .map((item) => normalizeAccessMatrixItem(item, 'app')) .filter(isAccessPolicyWithBindings), }) @@ -158,7 +176,7 @@ export const normalizeDatasetAccessMatrix = ( ): GetDatasetAccessPolicyByDatasetIdResponse => ({ dataset_id: response.dataset_id ?? '', items: (response.items ?? []) - .map(item => normalizeAccessMatrixItem(item, 'dataset')) + .map((item) => normalizeAccessMatrixItem(item, 'dataset')) .filter(isAccessPolicyWithBindings), }) diff --git a/web/service/access-control/use-access-subjects.ts b/web/service/access-control/use-access-subjects.ts index 4137ff2ec80dcb..f5fc9b4ae3c6d4 100644 --- a/web/service/access-control/use-access-subjects.ts +++ b/web/service/access-control/use-access-subjects.ts @@ -41,8 +41,7 @@ export const useSearchAccessSubjects = (query: SearchAccessSubjectsQuery, enable }, initialPageParam: 1, getNextPageParam: (lastPage) => { - if (lastPage.hasMore) - return lastPage.currPage + 1 + if (lastPage.hasMore) return lastPage.currPage + 1 return undefined }, gcTime: 0, diff --git a/web/service/access-control/use-app-access-config.ts b/web/service/access-control/use-app-access-config.ts index 125e05175f9284..009360320c6954 100644 --- a/web/service/access-control/use-app-access-config.ts +++ b/web/service/access-control/use-app-access-config.ts @@ -28,7 +28,10 @@ export const useAppAccessRules = (appId: string, language: AccessControlTemplate }) } -export const useAppUserAccessSettings = (appId: string, language: AccessControlTemplateLanguage) => { +export const useAppUserAccessSettings = ( + appId: string, + language: AccessControlTemplateLanguage, +) => { return useQuery({ ...appRbacContract.userAccessPolicies.get.queryOptions({ input: { @@ -49,15 +52,16 @@ export const useUpdateAppUserAccessSettings = (appId: string) => { return useMutation({ mutationKey: [NAME_SPACE, 'update-app-user-access-settings', appId], - mutationFn: (payload: UpdateAppUserAccessSettingsRequest) => appRbacClient.users.byTargetAccountId.accessPolicies.put({ - params: { - app_id: appId, - target_account_id: payload.accountId, - }, - body: { - access_policy_ids: payload.accessPolicyIds, - }, - }), + mutationFn: (payload: UpdateAppUserAccessSettingsRequest) => + appRbacClient.users.byTargetAccountId.accessPolicies.put({ + params: { + app_id: appId, + target_account_id: payload.accountId, + }, + body: { + access_policy_ids: payload.accessPolicyIds, + }, + }), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ @@ -76,15 +80,16 @@ export const useRemoveAppAccessPolicyMemberBindings = (appId: string) => { return useMutation({ mutationKey: [NAME_SPACE, 'remove-app-access-policy-member-bindings', appId], - mutationFn: (payload: RemoveAppAccessPolicyMemberBindingsRequest) => appRbacClient.accessPolicies.byPolicyId.memberBindings.delete({ - params: { - app_id: appId, - policy_id: payload.accessPolicyId, - }, - body: { - account_ids: payload.accountIds, - }, - }), + mutationFn: (payload: RemoveAppAccessPolicyMemberBindingsRequest) => + appRbacClient.accessPolicies.byPolicyId.memberBindings.delete({ + params: { + app_id: appId, + policy_id: payload.accessPolicyId, + }, + body: { + account_ids: payload.accountIds, + }, + }), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ @@ -103,14 +108,15 @@ export const useUpdateAppOpenScope = (appId: string) => { return useMutation({ mutationKey: [NAME_SPACE, 'update-app-open-scope', appId], - mutationFn: (openScope: ResourceOpenScope) => appRbacClient.whitelist.put({ - params: { - app_id: appId, - }, - body: { - scope: openScope, - }, - }), + mutationFn: (openScope: ResourceOpenScope) => + appRbacClient.whitelist.put({ + params: { + app_id: appId, + }, + body: { + scope: openScope, + }, + }), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ diff --git a/web/service/access-control/use-app-access-control.ts b/web/service/access-control/use-app-access-control.ts index aafe53ec0f85c5..9c0ab7de260fad 100644 --- a/web/service/access-control/use-app-access-control.ts +++ b/web/service/access-control/use-app-access-control.ts @@ -9,7 +9,10 @@ const NAME_SPACE = 'access-control' export const useAppWhiteListSubjects = (appId: string | undefined, enabled: boolean) => { return useQuery({ queryKey: [NAME_SPACE, 'app-whitelist-subjects', appId], - queryFn: () => get<{ groups: AccessControlGroup[], members: AccessControlAccount[] }>(`/enterprise/webapp/app/subjects?appId=${appId}`), + queryFn: () => + get<{ groups: AccessControlGroup[]; members: AccessControlAccount[] }>( + `/enterprise/webapp/app/subjects?appId=${appId}`, + ), enabled: !!appId && enabled, staleTime: 0, gcTime: 0, @@ -29,26 +32,27 @@ type SearchForWhiteListCandidatesQuery = { resultsPerPage?: number } -export const useSearchForWhiteListCandidates = (query: SearchForWhiteListCandidatesQuery, enabled: boolean) => { +export const useSearchForWhiteListCandidates = ( + query: SearchForWhiteListCandidatesQuery, + enabled: boolean, +) => { const { keyword, groupId, resultsPerPage } = query return useInfiniteQuery({ queryKey: [NAME_SPACE, 'app-whitelist-candidates', keyword, groupId, resultsPerPage], queryFn: ({ pageParam }) => { const params = new URLSearchParams() - if (keyword) - params.append('keyword', keyword) - if (groupId) - params.append('groupId', groupId) - if (resultsPerPage) - params.append('resultsPerPage', `${resultsPerPage}`) + if (keyword) params.append('keyword', keyword) + if (groupId) params.append('groupId', groupId) + if (resultsPerPage) params.append('resultsPerPage', `${resultsPerPage}`) params.append('pageNumber', `${pageParam}`) - return get(`/enterprise/webapp/app/subject/search?${new URLSearchParams(params).toString()}`) + return get( + `/enterprise/webapp/app/subject/search?${new URLSearchParams(params).toString()}`, + ) }, initialPageParam: 1, getNextPageParam: (lastPage) => { - if (lastPage.hasMore) - return lastPage.currPage + 1 + if (lastPage.hasMore) return lastPage.currPage + 1 return undefined }, gcTime: 0, @@ -57,7 +61,15 @@ export const useSearchForWhiteListCandidates = (query: SearchForWhiteListCandida }) } -export const useGetUserCanAccessApp = ({ appId, isInstalledApp = true, enabled }: { appId?: string, isInstalledApp?: boolean, enabled?: boolean }) => { +export const useGetUserCanAccessApp = ({ + appId, + isInstalledApp = true, + enabled, +}: { + appId?: string + isInstalledApp?: boolean + enabled?: boolean +}) => { // useQuery (not useSuspenseQuery) to keep this service hook's call contract // unchanged from the zustand era: callers should not need a Suspense boundary. // First-fetch undefined is bridged via `?? false` so the inner queryKey is stable. @@ -66,10 +78,8 @@ export const useGetUserCanAccessApp = ({ appId, isInstalledApp = true, enabled } return useQuery({ queryKey: [NAME_SPACE, 'user-can-access-app', appId, webappAuthEnabled, isInstalledApp], queryFn: () => { - if (webappAuthEnabled) - return getUserCanAccess(appId!, isInstalledApp) - else - return { result: true } + if (webappAuthEnabled) return getUserCanAccess(appId!, isInstalledApp) + else return { result: true } }, enabled: enabled !== undefined ? enabled : !!appId, staleTime: 0, diff --git a/web/service/access-control/use-dataset-access-config.ts b/web/service/access-control/use-dataset-access-config.ts index c79b47dac1c0b2..6a6dd96e8711c7 100644 --- a/web/service/access-control/use-dataset-access-config.ts +++ b/web/service/access-control/use-dataset-access-config.ts @@ -16,7 +16,11 @@ type DatasetAccessConfigQueryOptions = { enabled?: boolean } -export const useDatasetAccessRules = (datasetId: string, language: AccessControlTemplateLanguage, options?: DatasetAccessConfigQueryOptions) => { +export const useDatasetAccessRules = ( + datasetId: string, + language: AccessControlTemplateLanguage, + options?: DatasetAccessConfigQueryOptions, +) => { return useQuery({ ...datasetRbacContract.accessPolicy.get.queryOptions({ input: { @@ -33,7 +37,11 @@ export const useDatasetAccessRules = (datasetId: string, language: AccessControl }) } -export const useDatasetUserAccessSettings = (datasetId: string, language: AccessControlTemplateLanguage, options?: DatasetAccessConfigQueryOptions) => { +export const useDatasetUserAccessSettings = ( + datasetId: string, + language: AccessControlTemplateLanguage, + options?: DatasetAccessConfigQueryOptions, +) => { return useQuery({ ...datasetRbacContract.userAccessPolicies.get.queryOptions({ input: { @@ -55,15 +63,16 @@ export const useUpdateDatasetUserAccessSettings = (datasetId: string) => { return useMutation({ mutationKey: [NAME_SPACE, 'update-dataset-user-access-settings', datasetId], - mutationFn: (payload: UpdateDatasetUserAccessSettingsRequest) => datasetRbacClient.users.byTargetAccountId.accessPolicies.put({ - params: { - dataset_id: datasetId, - target_account_id: payload.accountId, - }, - body: { - access_policy_ids: payload.accessPolicyIds, - }, - }), + mutationFn: (payload: UpdateDatasetUserAccessSettingsRequest) => + datasetRbacClient.users.byTargetAccountId.accessPolicies.put({ + params: { + dataset_id: datasetId, + target_account_id: payload.accountId, + }, + body: { + access_policy_ids: payload.accessPolicyIds, + }, + }), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ @@ -82,15 +91,16 @@ export const useRemoveDatasetAccessPolicyMemberBindings = (datasetId: string) => return useMutation({ mutationKey: [NAME_SPACE, 'remove-dataset-access-policy-member-bindings', datasetId], - mutationFn: (payload: RemoveDatasetAccessPolicyMemberBindingsRequest) => datasetRbacClient.accessPolicies.byPolicyId.memberBindings.delete({ - params: { - dataset_id: datasetId, - policy_id: payload.accessPolicyId, - }, - body: { - account_ids: payload.accountIds, - }, - }), + mutationFn: (payload: RemoveDatasetAccessPolicyMemberBindingsRequest) => + datasetRbacClient.accessPolicies.byPolicyId.memberBindings.delete({ + params: { + dataset_id: datasetId, + policy_id: payload.accessPolicyId, + }, + body: { + account_ids: payload.accountIds, + }, + }), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ @@ -109,14 +119,15 @@ export const useUpdateDatasetOpenScope = (datasetId: string) => { return useMutation({ mutationKey: [NAME_SPACE, 'update-dataset-open-scope', datasetId], - mutationFn: (openScope: ResourceOpenScope) => datasetRbacClient.whitelist.put({ - params: { - dataset_id: datasetId, - }, - body: { - scope: openScope, - }, - }), + mutationFn: (openScope: ResourceOpenScope) => + datasetRbacClient.whitelist.put({ + params: { + dataset_id: datasetId, + }, + body: { + scope: openScope, + }, + }), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ diff --git a/web/service/access-control/use-member-roles.ts b/web/service/access-control/use-member-roles.ts index 265db742b0e3a4..8a351fc46b747b 100644 --- a/web/service/access-control/use-member-roles.ts +++ b/web/service/access-control/use-member-roles.ts @@ -9,11 +9,12 @@ const NAME_SPACE = 'rbac-member-roles' export const useRolesOfMember = (memberId: string, language: AccessControlTemplateLanguage) => { return useQuery({ queryKey: [NAME_SPACE, 'member-roles', memberId, language], - queryFn: () => get(`/workspaces/current/rbac/members/${memberId}/rbac-roles`, { - params: { - language, - }, - }), + queryFn: () => + get(`/workspaces/current/rbac/members/${memberId}/rbac-roles`, { + params: { + language, + }, + }), }) } @@ -28,9 +29,10 @@ export const useUpdateRolesOfMember = () => { body: { role_ids: roleIds }, }) }, - onSuccess: (_, { memberId }) => Promise.all([ - queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'member-roles', memberId] }), - queryClient.invalidateQueries({ queryKey: commonQueryKeys.members }), - ]), + onSuccess: (_, { memberId }) => + Promise.all([ + queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'member-roles', memberId] }), + queryClient.invalidateQueries({ queryKey: commonQueryKeys.members }), + ]), }) } diff --git a/web/service/access-control/use-permission-catalog.ts b/web/service/access-control/use-permission-catalog.ts index f6b6602d0eeec9..151eef55ab1d6f 100644 --- a/web/service/access-control/use-permission-catalog.ts +++ b/web/service/access-control/use-permission-catalog.ts @@ -1,6 +1,4 @@ -import type { - PermissionGroups, -} from '@/models/access-control' +import type { PermissionGroups } from '@/models/access-control' import { useQuery } from '@tanstack/react-query' import { get } from '../base' @@ -24,7 +22,8 @@ export const useAppPermissionCatalog = (enabled?: boolean) => { export const useDatasetPermissionCatalog = (enabled?: boolean) => { return useQuery({ queryKey: [NAME_SPACE, 'dataset'], - queryFn: () => get('/workspaces/current/rbac/role-permissions/catalog/dataset'), + queryFn: () => + get('/workspaces/current/rbac/role-permissions/catalog/dataset'), enabled: enabled ?? true, }) } diff --git a/web/service/access-control/use-permission-keys.ts b/web/service/access-control/use-permission-keys.ts index 9953b904ce7506..c1b0621a8f42a6 100644 --- a/web/service/access-control/use-permission-keys.ts +++ b/web/service/access-control/use-permission-keys.ts @@ -6,7 +6,7 @@ import { get } from '../base' const NAME_SPACE = 'workspace-permission-keys' const workspacePermissionKeysQueryKey = (workspaceId?: string) => { - return workspaceId ? [NAME_SPACE, workspaceId] as const : [NAME_SPACE] as const + return workspaceId ? ([NAME_SPACE, workspaceId] as const) : ([NAME_SPACE] as const) } export const workspacePermissionKeysQueryOptions = (workspaceId?: string) => { diff --git a/web/service/access-control/use-workspace-access-rules.ts b/web/service/access-control/use-workspace-access-rules.ts index 8d07cd824f5633..fc87f963f8655a 100644 --- a/web/service/access-control/use-workspace-access-rules.ts +++ b/web/service/access-control/use-workspace-access-rules.ts @@ -16,8 +16,10 @@ const NAME_SPACE = 'workspace-access-rules' type WorkspaceAccessRulesQueryParams = Omit const workspaceAccessRulesQueryKeys = { - app: (params?: WorkspaceAccessRulesQueryParams) => params ? [NAME_SPACE, 'app', params] as const : [NAME_SPACE, 'app'] as const, - dataset: (params?: WorkspaceAccessRulesQueryParams) => params ? [NAME_SPACE, 'dataset', params] as const : [NAME_SPACE, 'dataset'] as const, + app: (params?: WorkspaceAccessRulesQueryParams) => + params ? ([NAME_SPACE, 'app', params] as const) : ([NAME_SPACE, 'app'] as const), + dataset: (params?: WorkspaceAccessRulesQueryParams) => + params ? ([NAME_SPACE, 'dataset', params] as const) : ([NAME_SPACE, 'dataset'] as const), } export const useInfiniteWorkspaceAppAccessRules = (params: WorkspaceAccessRulesRequest = {}) => { @@ -25,41 +27,46 @@ export const useInfiniteWorkspaceAppAccessRules = (params: WorkspaceAccessRulesR return useInfiniteQuery({ queryKey: workspaceAccessRulesQueryKeys.app(queryParams), - queryFn: ({ pageParam }) => get('/workspaces/current/rbac/workspace/apps/access-policy', { - params: { - ...queryParams, - page: pageParam, - }, - }), + queryFn: ({ pageParam }) => + get('/workspaces/current/rbac/workspace/apps/access-policy', { + params: { + ...queryParams, + page: pageParam, + }, + }), initialPageParam: page, getNextPageParam: (lastPage) => { const { current_page, total_pages } = lastPage.pagination - if (current_page < total_pages) - return current_page + 1 + if (current_page < total_pages) return current_page + 1 return undefined }, }) } -export const useInfiniteWorkspaceDatasetAccessRules = (params: WorkspaceAccessRulesRequest = {}) => { +export const useInfiniteWorkspaceDatasetAccessRules = ( + params: WorkspaceAccessRulesRequest = {}, +) => { const { page = 1, ...queryParams } = params return useInfiniteQuery({ queryKey: workspaceAccessRulesQueryKeys.dataset(queryParams), - queryFn: ({ pageParam }) => get('/workspaces/current/rbac/workspace/datasets/access-policy', { - params: { - ...queryParams, - page: pageParam, - }, - }), + queryFn: ({ pageParam }) => + get( + '/workspaces/current/rbac/workspace/datasets/access-policy', + { + params: { + ...queryParams, + page: pageParam, + }, + }, + ), initialPageParam: page, getNextPageParam: (lastPage) => { const { current_page, total_pages } = lastPage.pagination - if (current_page < total_pages) - return current_page + 1 + if (current_page < total_pages) return current_page + 1 return undefined }, @@ -83,7 +90,12 @@ export const useCreateAccessRule = () => { }) }, onSuccess: (_, { resourceType }) => { - queryClient.invalidateQueries({ queryKey: resourceType === 'app' ? workspaceAccessRulesQueryKeys.app() : workspaceAccessRulesQueryKeys.dataset() }) + queryClient.invalidateQueries({ + queryKey: + resourceType === 'app' + ? workspaceAccessRulesQueryKeys.app() + : workspaceAccessRulesQueryKeys.dataset(), + }) }, }) } @@ -105,7 +117,12 @@ export const useUpdateAccessRule = () => { }) }, onSuccess: (_, { resourceType }) => { - queryClient.invalidateQueries({ queryKey: resourceType === 'app' ? workspaceAccessRulesQueryKeys.app() : workspaceAccessRulesQueryKeys.dataset() }) + queryClient.invalidateQueries({ + queryKey: + resourceType === 'app' + ? workspaceAccessRulesQueryKeys.app() + : workspaceAccessRulesQueryKeys.dataset(), + }) }, }) } @@ -119,7 +136,12 @@ export const useCopyAccessRule = (resourceType: AccessPolicyResourceType) => { return post(`/workspaces/current/rbac/access-policies/${id}/copy`, {}) }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: resourceType === 'app' ? workspaceAccessRulesQueryKeys.app() : workspaceAccessRulesQueryKeys.dataset() }) + queryClient.invalidateQueries({ + queryKey: + resourceType === 'app' + ? workspaceAccessRulesQueryKeys.app() + : workspaceAccessRulesQueryKeys.dataset(), + }) }, }) } @@ -133,7 +155,12 @@ export const useDeleteAccessRule = (resourceType: AccessPolicyResourceType) => { return del(`/workspaces/current/rbac/access-policies/${id}`, {}) }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: resourceType === 'app' ? workspaceAccessRulesQueryKeys.app() : workspaceAccessRulesQueryKeys.dataset() }) + queryClient.invalidateQueries({ + queryKey: + resourceType === 'app' + ? workspaceAccessRulesQueryKeys.app() + : workspaceAccessRulesQueryKeys.dataset(), + }) }, }) } diff --git a/web/service/access-control/use-workspace-roles.ts b/web/service/access-control/use-workspace-roles.ts index bff4a0d3101d7d..c47485eaa120de 100644 --- a/web/service/access-control/use-workspace-roles.ts +++ b/web/service/access-control/use-workspace-roles.ts @@ -20,18 +20,18 @@ export const useWorkspaceRoleList = (params: RoleListRequest) => { return useInfiniteQuery({ queryKey: [NAME_SPACE, 'workspace-role-list', queryParams], - queryFn: ({ pageParam }) => get('/workspaces/current/rbac/roles', { - params: { - ...queryParams, - page: pageParam, - }, - }), + queryFn: ({ pageParam }) => + get('/workspaces/current/rbac/roles', { + params: { + ...queryParams, + page: pageParam, + }, + }), initialPageParam: page, getNextPageParam: (lastPage) => { const { current_page, total_pages } = lastPage.pagination - if (current_page < total_pages) - return current_page + 1 + if (current_page < total_pages) return current_page + 1 return undefined }, @@ -73,12 +73,12 @@ export const useDeleteWorkspaceRole = () => { return useMutation({ mutationKey: [NAME_SPACE, 'delete-workspace-role'], - mutationFn: (id: string) => - del(`/workspaces/current/rbac/roles/${id}`), - onSuccess: () => Promise.all([ - queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'workspace-role-list'] }), - queryClient.invalidateQueries({ queryKey: commonQueryKeys.members }), - ]), + mutationFn: (id: string) => del(`/workspaces/current/rbac/roles/${id}`), + onSuccess: () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'workspace-role-list'] }), + queryClient.invalidateQueries({ queryKey: commonQueryKeys.members }), + ]), }) } @@ -108,11 +108,12 @@ export const useGetMembersOfRole = (params: GetMembersOfRoleRequest) => { const { roleId, ...paginationParams } = params return useQuery({ queryKey: [NAME_SPACE, 'members-of-role', roleId, paginationParams], - queryFn: () => get(`/workspaces/current/rbac/roles/${roleId}/members`, { - params: { - ...paginationParams, - }, - }), + queryFn: () => + get(`/workspaces/current/rbac/roles/${roleId}/members`, { + params: { + ...paginationParams, + }, + }), enabled: !!roleId, }) } diff --git a/web/service/annotation.spec.ts b/web/service/annotation.spec.ts index 60af578108fc00..06013c446e9293 100644 --- a/web/service/annotation.spec.ts +++ b/web/service/annotation.spec.ts @@ -12,10 +12,15 @@ describe('annotation service', () => { }) it('should preserve zero score threshold when updating annotation status', () => { - updateAnnotationStatus('app-1', AnnotationEnableStatus.enable, { - embedding_model_name: 'model', - embedding_provider_name: 'provider', - }, 0) + updateAnnotationStatus( + 'app-1', + AnnotationEnableStatus.enable, + { + embedding_model_name: 'model', + embedding_provider_name: 'provider', + }, + 0, + ) expect(post).toHaveBeenCalledWith('apps/app-1/annotation-reply/enable', { body: { diff --git a/web/service/annotation.ts b/web/service/annotation.ts index ba8c560b1fb576..5bc29719a2af4e 100644 --- a/web/service/annotation.ts +++ b/web/service/annotation.ts @@ -1,11 +1,21 @@ -import type { AnnotationCreateResponse, AnnotationEnableStatus, AnnotationItemBasic, EmbeddingModelConfig } from '@/app/components/app/annotation/type' +import type { + AnnotationCreateResponse, + AnnotationEnableStatus, + AnnotationItemBasic, + EmbeddingModelConfig, +} from '@/app/components/app/annotation/type' import { ANNOTATION_DEFAULT } from '@/config' import { del, get, post } from './base' export const fetchAnnotationConfig = (appId: string) => { return get(`apps/${appId}/annotation-setting`) } -export const updateAnnotationStatus = (appId: string, action: AnnotationEnableStatus, embeddingModel?: EmbeddingModelConfig, score?: number) => { +export const updateAnnotationStatus = ( + appId: string, + action: AnnotationEnableStatus, + embeddingModel?: EmbeddingModelConfig, + score?: number, +) => { let body: any = { score_threshold: score ?? ANNOTATION_DEFAULT.score_threshold, } @@ -27,7 +37,11 @@ export const updateAnnotationScore = (appId: string, settingId: string, score: n }) } -export const queryAnnotationJobStatus = (appId: string, action: AnnotationEnableStatus, jobId: string) => { +export const queryAnnotationJobStatus = ( + appId: string, + action: AnnotationEnableStatus, + jobId: string, +) => { return get(`apps/${appId}/annotation-reply/${action}/status/${jobId}`) } @@ -43,12 +57,30 @@ export const addAnnotation = (appId: string, body: AnnotationItemBasic) => { return post(`apps/${appId}/annotations`, { body }) } -export const annotationBatchImport = ({ url, body }: { url: string, body: FormData }): Promise<{ job_id: string, job_status: string }> => { - return post<{ job_id: string, job_status: string }>(url, { body }, { bodyStringify: false, deleteContentType: true }) +export const annotationBatchImport = ({ + url, + body, +}: { + url: string + body: FormData +}): Promise<{ job_id: string; job_status: string }> => { + return post<{ job_id: string; job_status: string }>( + url, + { body }, + { bodyStringify: false, deleteContentType: true }, + ) } -export const checkAnnotationBatchImportProgress = ({ jobID, appId }: { jobID: string, appId: string }): Promise<{ job_id: string, job_status: string }> => { - return get<{ job_id: string, job_status: string }>(`/apps/${appId}/annotations/batch-import-status/${jobID}`) +export const checkAnnotationBatchImportProgress = ({ + jobID, + appId, +}: { + jobID: string + appId: string +}): Promise<{ job_id: string; job_status: string }> => { + return get<{ job_id: string; job_status: string }>( + `/apps/${appId}/annotations/batch-import-status/${jobID}`, + ) } export const editAnnotation = (appId: string, annotationId: string, body: AnnotationItemBasic) => { @@ -60,11 +92,15 @@ export const delAnnotation = (appId: string, annotationId: string) => { } export const delAnnotations = (appId: string, annotationIds: string[]) => { - const params = annotationIds.map(id => `annotation_id=${id}`).join('&') + const params = annotationIds.map((id) => `annotation_id=${id}`).join('&') return del(`/apps/${appId}/annotations?${params}`) } -export const fetchHitHistoryList = (appId: string, annotationId: string, params: Record) => { +export const fetchHitHistoryList = ( + appId: string, + annotationId: string, + params: Record, +) => { return get(`apps/${appId}/annotations/${annotationId}/hit-histories`, { params }) } diff --git a/web/service/apps.ts b/web/service/apps.ts index 8b6ca1764866a2..d5db057380e202 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -1,18 +1,47 @@ import type { TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' -import type { AppDetailResponse, AppListResponse, CreateApiKeyResponse, DSLImportMode, DSLImportResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, WebhookTriggerResponse } from '@/models/app' +import type { + AppDetailResponse, + AppListResponse, + CreateApiKeyResponse, + DSLImportMode, + DSLImportResponse, + TracingConfig, + TracingStatus, + UpdateAppModelConfigResponse, + UpdateAppSiteCodeResponse, + WebhookTriggerResponse, +} from '@/models/app' import type { CommonResponse } from '@/models/common' import type { AppIconType, AppModeEnum, ModelConfig } from '@/types/app' import { del, get, patch, post, put } from './base' -export const fetchAppList = ({ url, params }: { url: string, params?: Record }): Promise => { +export const fetchAppList = ({ + url, + params, +}: { + url: string + params?: Record +}): Promise => { return get(url, { params }) } -export const fetchAppDetail = ({ url, id }: { url: string, id: string }): Promise => { +export const fetchAppDetail = ({ + url, + id, +}: { + url: string + id: string +}): Promise => { return get(`${url}/${id}`) } -export const fetchAppDetailDirect = async ({ url, id }: { url: string, id: string }): Promise => { +export const fetchAppDetailDirect = async ({ + url, + id, +}: { + url: string + id: string +}): Promise => { return get(`${url}/${id}`) } @@ -33,7 +62,9 @@ export const createApp = ({ description?: string config?: ModelConfig }): Promise => { - return post('apps', { body: { name, icon_type, icon, icon_background, mode, description, model_config: config } }) + return post('apps', { + body: { name, icon_type, icon, icon_background, mode, description, model_config: config }, + }) } export const updateAppInfo = ({ @@ -55,7 +86,15 @@ export const updateAppInfo = ({ use_icon_as_answer_icon?: boolean max_active_requests?: number | null }): Promise => { - const body = { name, icon_type, icon, icon_background, description, use_icon_as_answer_icon, max_active_requests } + const body = { + name, + icon_type, + icon, + icon_background, + description, + use_icon_as_answer_icon, + max_active_requests, + } return put(`apps/${appID}`, { body }) } @@ -76,55 +115,149 @@ export const copyApp = ({ mode: AppModeEnum description?: string }): Promise => { - return post(`apps/${appID}/copy`, { body: { name, icon_type, icon, icon_background, mode, description } }) + return post(`apps/${appID}/copy`, { + body: { name, icon_type, icon, icon_background, mode, description }, + }) } -export const exportAppConfig = ({ appID, include = false, workflowID }: { appID: string, include?: boolean, workflowID?: string }): Promise<{ data: string }> => { +export const exportAppConfig = ({ + appID, + include = false, + workflowID, +}: { + appID: string + include?: boolean + workflowID?: string +}): Promise<{ data: string }> => { const params = new URLSearchParams({ include_secret: include.toString(), }) - if (workflowID) - params.append('workflow_id', workflowID) + if (workflowID) params.append('workflow_id', workflowID) return get<{ data: string }>(`apps/${appID}/export?${params.toString()}`) } -export const importDSL = ({ mode, yaml_content, yaml_url, app_id, name, description, icon_type, icon, icon_background }: { mode: DSLImportMode, yaml_content?: string, yaml_url?: string, app_id?: string, name?: string, description?: string, icon_type?: AppIconType, icon?: string, icon_background?: string }): Promise => { - return post('apps/imports', { body: { mode, yaml_content, yaml_url, app_id, name, description, icon, icon_type, icon_background } }) +export const importDSL = ({ + mode, + yaml_content, + yaml_url, + app_id, + name, + description, + icon_type, + icon, + icon_background, +}: { + mode: DSLImportMode + yaml_content?: string + yaml_url?: string + app_id?: string + name?: string + description?: string + icon_type?: AppIconType + icon?: string + icon_background?: string +}): Promise => { + return post('apps/imports', { + body: { + mode, + yaml_content, + yaml_url, + app_id, + name, + description, + icon, + icon_type, + icon_background, + }, + }) } -export const importDSLConfirm = ({ import_id }: { import_id: string }): Promise => { +export const importDSLConfirm = ({ + import_id, +}: { + import_id: string +}): Promise => { return post(`apps/imports/${import_id}/confirm`, { body: {} }) } -export const switchApp = ({ appID, name, icon_type, icon, icon_background }: { appID: string, name: string, icon_type: AppIconType, icon: string, icon_background?: string | null }): Promise<{ new_app_id: string, permission_keys: string[] }> => { - return post<{ new_app_id: string, permission_keys: string[] }>(`apps/${appID}/convert-to-workflow`, { body: { name, icon_type, icon, icon_background } }) +export const switchApp = ({ + appID, + name, + icon_type, + icon, + icon_background, +}: { + appID: string + name: string + icon_type: AppIconType + icon: string + icon_background?: string | null +}): Promise<{ new_app_id: string; permission_keys: string[] }> => { + return post<{ new_app_id: string; permission_keys: string[] }>( + `apps/${appID}/convert-to-workflow`, + { body: { name, icon_type, icon, icon_background } }, + ) } export const deleteApp = (appID: string): Promise => { return del(`apps/${appID}`) } -export const updateAppSiteStatus = ({ url, body }: { url: string, body: Record }): Promise => { +export const updateAppSiteStatus = ({ + url, + body, +}: { + url: string + body: Record +}): Promise => { return post(url, { body }) } -export const updateAppSiteAccessToken = ({ url }: { url: string }): Promise => { +export const updateAppSiteAccessToken = ({ + url, +}: { + url: string +}): Promise => { return post(url) } -export const updateAppSiteConfig = ({ url, body }: { url: string, body: Record }): Promise => { +export const updateAppSiteConfig = ({ + url, + body, +}: { + url: string + body: Record +}): Promise => { return post(url, { body }) } -export const updateAppModelConfig = ({ url, body }: { url: string, body: Record }): Promise => { +export const updateAppModelConfig = ({ + url, + body, +}: { + url: string + body: Record +}): Promise => { return post(url, { body }) } -export const delApikey = ({ url, params }: { url: string, params: Record }): Promise => { +export const delApikey = ({ + url, + params, +}: { + url: string + params: Record +}): Promise => { return del(url, params) } -export const createApikey = ({ url, body }: { url: string, body: Record }): Promise => { +export const createApikey = ({ + url, + body, +}: { + url: string + body: Record +}): Promise => { return post(url, body) } @@ -133,12 +266,24 @@ export const fetchTracingStatus = ({ appId }: { appId: string }): Promise(`/apps/${appId}/trace`) } -export const updateTracingStatus = ({ appId, body }: { appId: string, body: Record }): Promise => { +export const updateTracingStatus = ({ + appId, + body, +}: { + appId: string + body: Record +}): Promise => { return post(`/apps/${appId}/trace`, { body }) } // Webhook Trigger -export const fetchWebhookUrl = ({ appId, nodeId }: { appId: string, nodeId: string }): Promise => { +export const fetchWebhookUrl = ({ + appId, + nodeId, +}: { + appId: string + nodeId: string +}): Promise => { return get( `apps/${appId}/workflows/triggers/webhook`, { params: { node_id: nodeId } }, @@ -146,7 +291,13 @@ export const fetchWebhookUrl = ({ appId, nodeId }: { appId: string, nodeId: stri ) } -export const fetchTracingConfig = ({ appId, provider }: { appId: string, provider: TracingProvider }): Promise => { +export const fetchTracingConfig = ({ + appId, + provider, +}: { + appId: string + provider: TracingProvider +}): Promise => { return get(`/apps/${appId}/trace-config`, { params: { tracing_provider: provider, @@ -154,15 +305,33 @@ export const fetchTracingConfig = ({ appId, provider }: { appId: string, provide }) } -export const addTracingConfig = ({ appId, body }: { appId: string, body: TracingConfig }): Promise => { +export const addTracingConfig = ({ + appId, + body, +}: { + appId: string + body: TracingConfig +}): Promise => { return post(`/apps/${appId}/trace-config`, { body }) } -export const updateTracingConfig = ({ appId, body }: { appId: string, body: TracingConfig }): Promise => { +export const updateTracingConfig = ({ + appId, + body, +}: { + appId: string + body: TracingConfig +}): Promise => { return patch(`/apps/${appId}/trace-config`, { body }) } -export const removeTracingConfig = ({ appId, provider }: { appId: string, provider: TracingProvider }): Promise => { +export const removeTracingConfig = ({ + appId, + provider, +}: { + appId: string + provider: TracingProvider +}): Promise => { return del(`/apps/${appId}/trace-config?tracing_provider=${provider}`) } @@ -170,6 +339,12 @@ type PublishToCreatorsPlatformResponse = { redirect_url: string } -export const publishToCreatorsPlatform = ({ appID }: { appID: string }): Promise => { - return post(`apps/${appID}/publish-to-creators-platform`, { body: {} }) +export const publishToCreatorsPlatform = ({ + appID, +}: { + appID: string +}): Promise => { + return post(`apps/${appID}/publish-to-creators-platform`, { + body: {}, + }) } diff --git a/web/service/base.spec.ts b/web/service/base.spec.ts index f448bab096b15a..bdea7fc22eace3 100644 --- a/web/service/base.spec.ts +++ b/web/service/base.spec.ts @@ -27,7 +27,8 @@ describe('handleStream', () => { const onCompleted = vi.fn() const mockReader = { - read: vi.fn() + read: vi + .fn() .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode('data: null\n'), @@ -47,7 +48,7 @@ describe('handleStream', () => { handleStream(mockResponse, onData, onCompleted) - await new Promise(resolve => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 50)) expect(onData).toHaveBeenCalledWith('', true, { conversationId: undefined, @@ -63,7 +64,8 @@ describe('handleStream', () => { const onCompleted = vi.fn() const mockReader = { - read: vi.fn() + read: vi + .fn() .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode('data: "string"\n'), @@ -83,7 +85,7 @@ describe('handleStream', () => { handleStream(mockResponse, onData, onCompleted) - await new Promise(resolve => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 50)) expect(onData).toHaveBeenCalledWith('', true, { conversationId: undefined, @@ -107,7 +109,8 @@ describe('handleStream', () => { } const mockReader = { - read: vi.fn() + read: vi + .fn() .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode(`data: ${JSON.stringify(validMessage)}\n`), @@ -127,7 +130,7 @@ describe('handleStream', () => { handleStream(mockResponse, onData, onCompleted) - await new Promise(resolve => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 50)) expect(onData).toHaveBeenCalledWith('Hello world', true, { event: 'message', @@ -149,7 +152,8 @@ describe('handleStream', () => { } const mockReader = { - read: vi.fn() + read: vi + .fn() .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode(`data: ${JSON.stringify(errorMessage)}\n`), @@ -169,7 +173,7 @@ describe('handleStream', () => { handleStream(mockResponse, onData, onCompleted) - await new Promise(resolve => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 50)) expect(onData).toHaveBeenCalledWith('', false, { conversationId: undefined, @@ -185,7 +189,8 @@ describe('handleStream', () => { const onCompleted = vi.fn() const mockReader = { - read: vi.fn() + read: vi + .fn() .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode('data: {invalid json}\n'), @@ -205,7 +210,7 @@ describe('handleStream', () => { handleStream(mockResponse, onData, onCompleted) - await new Promise(resolve => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 50)) expect(onData).toHaveBeenCalled() expect(onCompleted).toHaveBeenCalled() @@ -223,7 +228,8 @@ describe('handleStream', () => { } const mockReader = { - read: vi.fn() + read: vi + .fn() .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode(`data: ${JSON.stringify(reasoningEvent)}\n`), @@ -251,7 +257,7 @@ describe('handleStream', () => { onReasoning, ) - await new Promise(resolve => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 50)) expect(onReasoning).toHaveBeenCalledWith(reasoningEvent) expect(onData).not.toHaveBeenCalled() @@ -306,13 +312,17 @@ describe('ssePost and sseGet', () => { const onError = vi.fn() vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new TypeError('Network failed')) - await ssePost('/chat-messages', { - body: { - query: 'hello', + await ssePost( + '/chat-messages', + { + body: { + query: 'hello', + }, }, - }, { - onError, - }) + { + onError, + }, + ) await waitFor(() => { expect(onError).toHaveBeenCalledWith('TypeError: Network failed') @@ -325,13 +335,17 @@ describe('ssePost and sseGet', () => { refreshAccessTokenOrReLoginMock.mockRejectedValueOnce(new Error('refresh failed')) vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(null, { status: 401 })) - await ssePost('/chat-messages', { - body: { - query: 'hello', + await ssePost( + '/chat-messages', + { + body: { + query: 'hello', + }, }, - }, { - onError, - }) + { + onError, + }, + ) await waitFor(() => { expect(onError).toHaveBeenCalledWith('Error: refresh failed') @@ -343,9 +357,13 @@ describe('ssePost and sseGet', () => { refreshAccessTokenOrReLoginMock.mockRejectedValueOnce(new Error('resume refresh failed')) vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(null, { status: 401 })) - await sseGet('/workflow/workflow-run-1/events', {}, { - onError, - }) + await sseGet( + '/workflow/workflow-run-1/events', + {}, + { + onError, + }, + ) await waitFor(() => { expect(onError).toHaveBeenCalledWith('Error: resume refresh failed') @@ -368,14 +386,18 @@ describe('ssePost and sseGet', () => { vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(response) - await ssePost('/chat-messages', { - body: { - query: 'hello', + await ssePost( + '/chat-messages', + { + body: { + query: 'hello', + }, }, - }, { - onError, - onCompleted, - }) + { + onError, + onCompleted, + }, + ) await waitFor(() => { expect(onError).toHaveBeenCalledWith('Error: stream lost', 'stream_read_error') diff --git a/web/service/base.ts b/web/service/base.ts index 6df3057fd7b314..8c73f5715b35f4 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -30,7 +30,15 @@ import type { } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' import Cookies from 'js-cookie' -import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_CE_EDITION, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config' +import { + API_PREFIX, + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, + IS_CE_EDITION, + PASSPORT_HEADER_NAME, + PUBLIC_API_PREFIX, + WEB_APP_SHARE_CODE_HEADER_NAME, +} from '@/config' import { asyncRunSafe } from '@/utils' import { isClient } from '@/utils/client' import { basePath } from '@/utils/var' @@ -88,7 +96,9 @@ type IOHumanInputRequired = (humanInputRequired: HumanInputRequiredResponse) => type IOnHumanInputFormFilled = (humanInputFormFilled: HumanInputFormFilledResponse) => void type IOnHumanInputFormTimeout = (humanInputFormTimeout: HumanInputFormTimeoutResponse) => void type IOWorkflowPaused = (workflowPaused: WorkflowPausedResponse) => void -type IOnDataSourceNodeProcessing = (dataSourceNodeProcessing: DataSourceNodeProcessingResponse) => void +type IOnDataSourceNodeProcessing = ( + dataSourceNodeProcessing: DataSourceNodeProcessingResponse, +) => void type IOnDataSourceNodeCompleted = (dataSourceNodeCompleted: DataSourceNodeCompletedResponse) => void type IOnDataSourceNodeError = (dataSourceNodeError: DataSourceNodeErrorResponse) => void @@ -145,11 +155,9 @@ export type IOtherOptions = { } function jumpTo(url: string) { - if (!url || !isClient) - return + if (!url || !isClient) return const targetPath = new URL(url, window.location.origin).pathname - if (targetPath === window.location.pathname) - return + if (targetPath === window.location.pathname) return window.location.href = url } @@ -168,8 +176,7 @@ export const buildSigninUrlWithRedirect = (): string => { } function unicodeToChar(text: string) { - if (!text) - return '' + if (!text) return '' return text.replace(/\\u([0-9a-f]{4})/g, (_match, p1) => { return String.fromCharCode(Number.parseInt(p1, 16)) @@ -178,26 +185,24 @@ function unicodeToChar(text: string) { const WBB_APP_LOGIN_PATH = '/webapp-signin' function requiredWebSSOLogin(message?: string, code?: number) { - if (!isClient) - return + if (!isClient) return const params = new URLSearchParams() // prevent redirect loop - if (window.location.pathname === WBB_APP_LOGIN_PATH) - return - - params.append('redirect_url', encodeURIComponent(`${window.location.pathname}${window.location.search}`)) - if (message) - params.append('message', message) - if (code) - params.append('code', String(code)) + if (window.location.pathname === WBB_APP_LOGIN_PATH) return + + params.append( + 'redirect_url', + encodeURIComponent(`${window.location.pathname}${window.location.search}`), + ) + if (message) params.append('message', message) + if (code) params.append('code', String(code)) window.location.href = `${window.location.origin}${basePath}${WBB_APP_LOGIN_PATH}?${params.toString()}` } function formatURL(url: string, isPublicAPI: boolean) { const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX - if (url.startsWith('http://') || url.startsWith('https://')) - return url + if (url.startsWith('http://') || url.startsWith('https://')) return url const urlWithoutProtocol = url.startsWith('/') ? url : `/${url}` return `${urlPrefix}${urlWithoutProtocol}` } @@ -238,8 +243,7 @@ export const handleStream = ( onReasoning?: IOnReasoning, onUnhandledEvent?: IOnUnhandledEvent, ) => { - if (!response.ok) - throw new Error('Network response was not ok') + if (!response.ok) throw new Error('Network response was not ok') const reader = response.body?.getReader() const decoder = new TextDecoder('utf-8') @@ -258,183 +262,153 @@ export const handleStream = ( function read() { let hasError = false - reader?.read().then((result: ReadableStreamReadResult) => { - if (result.done) { - onCompleted?.() - return - } - buffer += decoder.decode(result.value, { stream: true }) - const lines = buffer.split('\n') - try { - lines.forEach((message) => { - if (message.startsWith('data: ')) { // check if it starts with data: - try { - bufferObj = JSON.parse(message.substring(6)) as Record// remove data: and parse as json - } - catch { - // mute handle message cut off - onData('', isFirstMessage, { - conversationId: bufferObj?.conversation_id, - messageId: bufferObj?.message_id, - }) - return - } - if (!bufferObj || typeof bufferObj !== 'object') { - onData('', isFirstMessage, { - conversationId: undefined, - messageId: '', - errorMessage: 'Invalid response data', - errorCode: 'invalid_data', - }) - hasError = true - onCompleted?.(true, 'Invalid response data') - return - } - if (bufferObj.status === 400 || !bufferObj.event) { - onData('', false, { - conversationId: undefined, - messageId: '', - errorMessage: bufferObj?.message, - errorCode: bufferObj?.code, - }) - hasError = true - onCompleted?.(true, bufferObj?.message) - return - } - if (bufferObj.event === 'message' || bufferObj.event === 'agent_message') { - // can not use format here. Because message is splitted. - onData(unicodeToChar(bufferObj.answer), isFirstMessage, { - event: bufferObj.event, - conversationId: bufferObj.conversation_id, - taskId: bufferObj.task_id, - messageId: bufferObj.id, - }) - isFirstMessage = false - } - else if (bufferObj.event === 'agent_thought') { - onThought?.(bufferObj as ThoughtItem) - } - else if (bufferObj.event === 'message_file') { - onFile?.(bufferObj as VisionFile) - } - else if (bufferObj.event === 'message_end') { - onMessageEnd?.(bufferObj as MessageEnd) - } - else if (bufferObj.event === 'message_replace') { - onMessageReplace?.(bufferObj as MessageReplace) - } - else if (bufferObj.event === 'workflow_started') { - onWorkflowStarted?.(bufferObj as WorkflowStartedResponse) - } - else if (bufferObj.event === 'workflow_finished') { - onWorkflowFinished?.(bufferObj as WorkflowFinishedResponse) - } - else if (bufferObj.event === 'node_started') { - onNodeStarted?.(bufferObj as NodeStartedResponse) - } - else if (bufferObj.event === 'node_finished') { - onNodeFinished?.(bufferObj as NodeFinishedResponse) - } - else if (bufferObj.event === 'iteration_started') { - onIterationStart?.(bufferObj as IterationStartedResponse) - } - else if (bufferObj.event === 'iteration_next') { - onIterationNext?.(bufferObj as IterationNextResponse) - } - else if (bufferObj.event === 'iteration_completed') { - onIterationFinish?.(bufferObj as IterationFinishedResponse) - } - else if (bufferObj.event === 'loop_started') { - onLoopStart?.(bufferObj as LoopStartedResponse) - } - else if (bufferObj.event === 'loop_next') { - onLoopNext?.(bufferObj as LoopNextResponse) - } - else if (bufferObj.event === 'loop_completed') { - onLoopFinish?.(bufferObj as LoopFinishedResponse) - } - else if (bufferObj.event === 'node_retry') { - onNodeRetry?.(bufferObj as NodeFinishedResponse) - } - else if (bufferObj.event === 'parallel_branch_started') { - onParallelBranchStarted?.(bufferObj as ParallelBranchStartedResponse) - } - else if (bufferObj.event === 'parallel_branch_finished') { - onParallelBranchFinished?.(bufferObj as ParallelBranchFinishedResponse) - } - else if (bufferObj.event === 'text_chunk') { - onTextChunk?.(bufferObj as TextChunkResponse) - } - else if (bufferObj.event === 'reasoning_chunk') { - onReasoning?.(bufferObj as ReasoningChunkResponse) - } - else if (bufferObj.event === 'text_replace') { - onTextReplace?.(bufferObj as TextReplaceResponse) - } - else if (bufferObj.event === 'agent_log') { - onAgentLog?.(bufferObj as AgentLogResponse) - } - else if (bufferObj.event === 'tts_message') { - onTTSChunk?.(bufferObj.message_id, bufferObj.audio, bufferObj.audio_type) - } - else if (bufferObj.event === 'tts_message_end') { - onTTSEnd?.(bufferObj.message_id, bufferObj.audio) - } - else if (bufferObj.event === 'human_input_required') { - onHumanInputRequired?.(bufferObj as HumanInputRequiredResponse) - } - else if (bufferObj.event === 'human_input_form_filled') { - onHumanInputFormFilled?.(bufferObj as HumanInputFormFilledResponse) - } - else if (bufferObj.event === 'human_input_form_timeout') { - onHumanInputFormTimeout?.(bufferObj as HumanInputFormTimeoutResponse) - } - else if (bufferObj.event === 'workflow_paused') { - onWorkflowPaused?.(bufferObj as WorkflowPausedResponse) - } - else if (bufferObj.event === 'datasource_processing') { - onDataSourceNodeProcessing?.(bufferObj as DataSourceNodeProcessingResponse) - } - else if (bufferObj.event === 'datasource_completed') { - onDataSourceNodeCompleted?.(bufferObj as DataSourceNodeCompletedResponse) - } - else if (bufferObj.event === 'datasource_error') { - onDataSourceNodeError?.(bufferObj as DataSourceNodeErrorResponse) - } - else { - const unhandledEventError = onUnhandledEvent?.(bufferObj) - if (unhandledEventError) { + reader?.read().then( + (result: ReadableStreamReadResult) => { + if (result.done) { + onCompleted?.() + return + } + buffer += decoder.decode(result.value, { stream: true }) + const lines = buffer.split('\n') + try { + lines.forEach((message) => { + if (message.startsWith('data: ')) { + // check if it starts with data: + try { + bufferObj = JSON.parse(message.substring(6)) as Record // remove data: and parse as json + } catch { + // mute handle message cut off + onData('', isFirstMessage, { + conversationId: bufferObj?.conversation_id, + messageId: bufferObj?.message_id, + }) + return + } + if (!bufferObj || typeof bufferObj !== 'object') { + onData('', isFirstMessage, { + conversationId: undefined, + messageId: '', + errorMessage: 'Invalid response data', + errorCode: 'invalid_data', + }) + hasError = true + onCompleted?.(true, 'Invalid response data') + return + } + if (bufferObj.status === 400 || !bufferObj.event) { onData('', false, { - conversationId: unhandledEventError.conversationId, - messageId: unhandledEventError.messageId ?? '', - errorMessage: unhandledEventError.errorMessage, - errorCode: unhandledEventError.errorCode, + conversationId: undefined, + messageId: '', + errorMessage: bufferObj?.message, + errorCode: bufferObj?.code, }) hasError = true - onCompleted?.(true, unhandledEventError.errorMessage) + onCompleted?.(true, bufferObj?.message) return } - console.warn(`Unknown event: ${bufferObj.event}`, bufferObj) + if (bufferObj.event === 'message' || bufferObj.event === 'agent_message') { + // can not use format here. Because message is splitted. + onData(unicodeToChar(bufferObj.answer), isFirstMessage, { + event: bufferObj.event, + conversationId: bufferObj.conversation_id, + taskId: bufferObj.task_id, + messageId: bufferObj.id, + }) + isFirstMessage = false + } else if (bufferObj.event === 'agent_thought') { + onThought?.(bufferObj as ThoughtItem) + } else if (bufferObj.event === 'message_file') { + onFile?.(bufferObj as VisionFile) + } else if (bufferObj.event === 'message_end') { + onMessageEnd?.(bufferObj as MessageEnd) + } else if (bufferObj.event === 'message_replace') { + onMessageReplace?.(bufferObj as MessageReplace) + } else if (bufferObj.event === 'workflow_started') { + onWorkflowStarted?.(bufferObj as WorkflowStartedResponse) + } else if (bufferObj.event === 'workflow_finished') { + onWorkflowFinished?.(bufferObj as WorkflowFinishedResponse) + } else if (bufferObj.event === 'node_started') { + onNodeStarted?.(bufferObj as NodeStartedResponse) + } else if (bufferObj.event === 'node_finished') { + onNodeFinished?.(bufferObj as NodeFinishedResponse) + } else if (bufferObj.event === 'iteration_started') { + onIterationStart?.(bufferObj as IterationStartedResponse) + } else if (bufferObj.event === 'iteration_next') { + onIterationNext?.(bufferObj as IterationNextResponse) + } else if (bufferObj.event === 'iteration_completed') { + onIterationFinish?.(bufferObj as IterationFinishedResponse) + } else if (bufferObj.event === 'loop_started') { + onLoopStart?.(bufferObj as LoopStartedResponse) + } else if (bufferObj.event === 'loop_next') { + onLoopNext?.(bufferObj as LoopNextResponse) + } else if (bufferObj.event === 'loop_completed') { + onLoopFinish?.(bufferObj as LoopFinishedResponse) + } else if (bufferObj.event === 'node_retry') { + onNodeRetry?.(bufferObj as NodeFinishedResponse) + } else if (bufferObj.event === 'parallel_branch_started') { + onParallelBranchStarted?.(bufferObj as ParallelBranchStartedResponse) + } else if (bufferObj.event === 'parallel_branch_finished') { + onParallelBranchFinished?.(bufferObj as ParallelBranchFinishedResponse) + } else if (bufferObj.event === 'text_chunk') { + onTextChunk?.(bufferObj as TextChunkResponse) + } else if (bufferObj.event === 'reasoning_chunk') { + onReasoning?.(bufferObj as ReasoningChunkResponse) + } else if (bufferObj.event === 'text_replace') { + onTextReplace?.(bufferObj as TextReplaceResponse) + } else if (bufferObj.event === 'agent_log') { + onAgentLog?.(bufferObj as AgentLogResponse) + } else if (bufferObj.event === 'tts_message') { + onTTSChunk?.(bufferObj.message_id, bufferObj.audio, bufferObj.audio_type) + } else if (bufferObj.event === 'tts_message_end') { + onTTSEnd?.(bufferObj.message_id, bufferObj.audio) + } else if (bufferObj.event === 'human_input_required') { + onHumanInputRequired?.(bufferObj as HumanInputRequiredResponse) + } else if (bufferObj.event === 'human_input_form_filled') { + onHumanInputFormFilled?.(bufferObj as HumanInputFormFilledResponse) + } else if (bufferObj.event === 'human_input_form_timeout') { + onHumanInputFormTimeout?.(bufferObj as HumanInputFormTimeoutResponse) + } else if (bufferObj.event === 'workflow_paused') { + onWorkflowPaused?.(bufferObj as WorkflowPausedResponse) + } else if (bufferObj.event === 'datasource_processing') { + onDataSourceNodeProcessing?.(bufferObj as DataSourceNodeProcessingResponse) + } else if (bufferObj.event === 'datasource_completed') { + onDataSourceNodeCompleted?.(bufferObj as DataSourceNodeCompletedResponse) + } else if (bufferObj.event === 'datasource_error') { + onDataSourceNodeError?.(bufferObj as DataSourceNodeErrorResponse) + } else { + const unhandledEventError = onUnhandledEvent?.(bufferObj) + if (unhandledEventError) { + onData('', false, { + conversationId: unhandledEventError.conversationId, + messageId: unhandledEventError.messageId ?? '', + errorMessage: unhandledEventError.errorMessage, + errorCode: unhandledEventError.errorCode, + }) + hasError = true + onCompleted?.(true, unhandledEventError.errorMessage) + return + } + console.warn(`Unknown event: ${bufferObj.event}`, bufferObj) + } } - } - }) - buffer = lines[lines.length - 1]! - } - catch (e) { - onData('', false, { - conversationId: undefined, - messageId: '', - errorMessage: `${e}`, - }) - hasError = true - onCompleted?.(true, e as string) - return - } - if (!hasError) - read() - }, (e: unknown) => { - completeWithError(String(e), 'stream_read_error') - }) + }) + buffer = lines[lines.length - 1]! + } catch (e) { + onData('', false, { + conversationId: undefined, + messageId: '', + errorMessage: `${e}`, + }) + hasError = true + onCompleted?.(true, e as string) + return + } + if (!hasError) read() + }, + (e: unknown) => { + completeWithError(String(e), 'stream_read_error') + }, + ) } read() } @@ -455,7 +429,12 @@ type UploadResponse = { [key: string]: unknown } -export const upload = async (options: UploadOptions, isPublicAPI?: boolean, url?: string, searchParams?: string): Promise => { +export const upload = async ( + options: UploadOptions, + isPublicAPI?: boolean, + url?: string, + searchParams?: string, +): Promise => { const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX const shareCode = globalThis.location.pathname.split('/').slice(-1)[0] const defaultOptions = { @@ -476,21 +455,17 @@ export const upload = async (options: UploadOptions, isPublicAPI?: boolean, url? return new Promise((resolve, reject) => { const xhr = mergedOptions.xhr xhr.open(mergedOptions.method, mergedOptions.url) - for (const key in mergedOptions.headers) - xhr.setRequestHeader(key, mergedOptions.headers[key]!) + for (const key in mergedOptions.headers) xhr.setRequestHeader(key, mergedOptions.headers[key]!) xhr.withCredentials = true xhr.responseType = 'json' xhr.onreadystatechange = function () { if (xhr.readyState === 4) { - if (xhr.status === 201) - resolve(xhr.response) - else - reject(xhr) + if (xhr.status === 201) resolve(xhr.response) + else reject(xhr) } } - if (mergedOptions.onprogress) - xhr.upload.onprogress = mergedOptions.onprogress + if (mergedOptions.onprogress) xhr.upload.onprogress = mergedOptions.onprogress xhr.send(mergedOptions.data) }) } @@ -544,58 +519,59 @@ export const ssePost = async ( const baseOptions = getBaseOptions() const shareCode = globalThis.location.pathname.split('/').slice(-1)[0]! - const options = Object.assign({}, baseOptions, { - method: 'POST', - signal: abortController.signal, - headers: new Headers({ - [CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME())! || '', - [WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode, - [PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode!), - }), - } as RequestInit, fetchOptions) + const options = Object.assign( + {}, + baseOptions, + { + method: 'POST', + signal: abortController.signal, + headers: new Headers({ + [CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME())! || '', + [WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode, + [PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode!), + }), + } as RequestInit, + fetchOptions, + ) options.headers = new Headers(options.headers) const contentType = (options.headers as Headers).get('Content-Type') - if (!contentType) - (options.headers as Headers).set('Content-Type', ContentType.json) + if (!contentType) (options.headers as Headers).set('Content-Type', ContentType.json) getAbortController?.(abortController) const urlWithPrefix = formatURL(url, isPublicAPI) const { body } = options - if (body) - options.body = JSON.stringify(body) + if (body) options.body = JSON.stringify(body) - globalThis.fetch(urlWithPrefix, options as RequestInit) + globalThis + .fetch(urlWithPrefix, options as RequestInit) .then((res) => { if (!/^[23]\d{2}$/.test(String(res.status))) { if (res.status === 401) { if (isPublicAPI) { - res.json().then((data: { code?: string, message?: string }) => { + res.json().then((data: { code?: string; message?: string }) => { if (isPublicAPI) { - if (data.code === 'web_app_access_denied') - requiredWebSSOLogin(data.message, 403) + if (data.code === 'web_app_access_denied') requiredWebSSOLogin(data.message, 403) - if (data.code === 'web_sso_auth_required') - requiredWebSSOLogin() + if (data.code === 'web_sso_auth_required') requiredWebSSOLogin() - if (data.code === 'unauthorized') - requiredWebSSOLogin() + if (data.code === 'unauthorized') requiredWebSSOLogin() } }) + } else { + refreshAccessTokenOrReLogin(TIME_OUT) + .then(() => { + ssePost(url, fetchOptions, otherOptions) + }) + .catch((err) => { + const errorMessage = String(err) + console.error(err) + onError?.(errorMessage) + }) } - else { - refreshAccessTokenOrReLogin(TIME_OUT).then(() => { - ssePost(url, fetchOptions, otherOptions) - }).catch((err) => { - const errorMessage = String(err) - console.error(err) - onError?.(errorMessage) - }) - } - } - else { + } else { res.json().then((data) => { toast.error(data.message || 'Server Error') }) @@ -609,7 +585,10 @@ export const ssePost = async ( if (moreInfo.errorMessage) { onError?.(moreInfo.errorMessage, moreInfo.errorCode) // TypeError: Cannot assign to read only property ... will happen in page leave, so it should be ignored. - if (moreInfo.errorMessage !== 'AbortError: The user aborted a request.' && !moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property')) + if ( + moreInfo.errorMessage !== 'AbortError: The user aborted a request.' && + !moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property') + ) toast.error(moreInfo.errorMessage) return } @@ -651,7 +630,10 @@ export const ssePost = async ( }) .catch((e) => { const errorMessage = String(e) - if (errorMessage !== 'AbortError: The user aborted a request.' && !errorMessage.includes('TypeError: Cannot assign to read only property')) + if ( + errorMessage !== 'AbortError: The user aborted a request.' && + !errorMessage.includes('TypeError: Cannot assign to read only property') + ) toast.error(errorMessage) onError?.(errorMessage) }) @@ -704,53 +686,55 @@ export const sseGet = async ( const baseOptions = getBaseOptions() const shareCode = globalThis.location.pathname.split('/').slice(-1)[0]! - const options = Object.assign({}, baseOptions, { - signal: abortController.signal, - headers: new Headers({ - [CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME())! || '', - [WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode, - [PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode!), - }), - } as RequestInit, fetchOptions) + const options = Object.assign( + {}, + baseOptions, + { + signal: abortController.signal, + headers: new Headers({ + [CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME())! || '', + [WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode, + [PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode!), + }), + } as RequestInit, + fetchOptions, + ) options.headers = new Headers(options.headers) const contentType = (options.headers as Headers).get('Content-Type') - if (!contentType) - (options.headers as Headers).set('Content-Type', ContentType.json) + if (!contentType) (options.headers as Headers).set('Content-Type', ContentType.json) getAbortController?.(abortController) const urlWithPrefix = formatURL(url, isPublicAPI) - globalThis.fetch(urlWithPrefix, options as RequestInit) + globalThis + .fetch(urlWithPrefix, options as RequestInit) .then((res) => { if (!/^[23]\d{2}$/.test(String(res.status))) { if (res.status === 401) { if (isPublicAPI) { - res.json().then((data: { code?: string, message?: string }) => { + res.json().then((data: { code?: string; message?: string }) => { if (isPublicAPI) { - if (data.code === 'web_app_access_denied') - requiredWebSSOLogin(data.message, 403) + if (data.code === 'web_app_access_denied') requiredWebSSOLogin(data.message, 403) - if (data.code === 'web_sso_auth_required') - requiredWebSSOLogin() + if (data.code === 'web_sso_auth_required') requiredWebSSOLogin() - if (data.code === 'unauthorized') - requiredWebSSOLogin() + if (data.code === 'unauthorized') requiredWebSSOLogin() } }) + } else { + refreshAccessTokenOrReLogin(TIME_OUT) + .then(() => { + sseGet(url, fetchOptions, otherOptions) + }) + .catch((err) => { + const errorMessage = String(err) + console.error(err) + onError?.(errorMessage) + }) } - else { - refreshAccessTokenOrReLogin(TIME_OUT).then(() => { - sseGet(url, fetchOptions, otherOptions) - }).catch((err) => { - const errorMessage = String(err) - console.error(err) - onError?.(errorMessage) - }) - } - } - else { + } else { res.json().then((data) => { toast.error(data.message || 'Server Error') }) @@ -764,7 +748,10 @@ export const sseGet = async ( if (moreInfo.errorMessage) { onError?.(moreInfo.errorMessage, moreInfo.errorCode) // TypeError: Cannot assign to read only property ... will happen in page leave, so it should be ignored. - if (moreInfo.errorMessage !== 'AbortError: The user aborted a request.' && !moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property')) + if ( + moreInfo.errorMessage !== 'AbortError: The user aborted a request.' && + !moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property') + ) toast.error(moreInfo.errorMessage) return } @@ -806,7 +793,10 @@ export const sseGet = async ( }) .catch((e) => { const errorMessage = String(e) - if (errorMessage !== 'AbortError: The user aborted a request.' && !errorMessage.includes('TypeError: Cannot assign to read only property')) + if ( + errorMessage !== 'AbortError: The user aborted a request.' && + !errorMessage.includes('TypeError: Cannot assign to read only property') + ) toast.error(errorMessage) onError?.(errorMessage) }) @@ -854,21 +844,32 @@ export const sseGeneratorPost = ( const fail = (e: unknown) => { // Aborts are intentional (modal close / regenerate) — never surface them. - if (e instanceof Error && e.name === 'AbortError') - return + if (e instanceof Error && e.name === 'AbortError') return onError?.(`${e}`) } - globalThis.fetch(urlWithPrefix, options as RequestInit) + globalThis + .fetch(urlWithPrefix, options as RequestInit) .then((res) => { if (!/^[23]\d{2}$/.test(String(res.status))) { if (res.status === 401) { refreshAccessTokenOrReLogin(TIME_OUT) - .then(() => sseGeneratorPost(url, body, { onPlan, onResult, onError, onCompleted, getAbortController })) + .then(() => + sseGeneratorPost(url, body, { + onPlan, + onResult, + onError, + onCompleted, + getAbortController, + }), + ) .catch(() => onError?.('Unauthorized')) return } - res.json().then((data: { message?: string }) => onError?.(data?.message || 'Server Error')).catch(() => onError?.('Server Error')) + res + .json() + .then((data: { message?: string }) => onError?.(data?.message || 'Server Error')) + .catch(() => onError?.('Server Error')) return } @@ -876,33 +877,32 @@ export const sseGeneratorPost = ( const decoder = new TextDecoder('utf-8') let buffer = '' const read = () => { - reader?.read().then(({ done, value }) => { - if (done) { - onCompleted?.() - return - } - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - // Process every complete line; keep the trailing partial in the buffer. - lines.slice(0, -1).forEach((message) => { - if (!message.startsWith('data: ')) + reader + ?.read() + .then(({ done, value }) => { + if (done) { + onCompleted?.() return - let obj: Record - try { - obj = JSON.parse(message.slice(6)) } - catch { - // A chunk boundary split the JSON — it'll re-arrive intact next read. - return - } - if (obj.event === 'plan') - onPlan?.(obj) - else if (obj.event === 'result') - onResult?.(obj) + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + // Process every complete line; keep the trailing partial in the buffer. + lines.slice(0, -1).forEach((message) => { + if (!message.startsWith('data: ')) return + let obj: Record + try { + obj = JSON.parse(message.slice(6)) + } catch { + // A chunk boundary split the JSON — it'll re-arrive intact next read. + return + } + if (obj.event === 'plan') onPlan?.(obj) + else if (obj.event === 'result') onResult?.(obj) + }) + buffer = lines[lines.length - 1] || '' + read() }) - buffer = lines[lines.length - 1] || '' - read() - }).catch(fail) + .catch(fail) } read() }) @@ -910,16 +910,14 @@ export const sseGeneratorPost = ( } // base request -export const request = async(url: string, options = {}, otherOptions?: IOtherOptions) => { +export const request = async (url: string, options = {}, otherOptions?: IOtherOptions) => { try { const otherOptionsForBaseFetch = otherOptions || {} const [err, resp] = await asyncRunSafe(baseFetch(url, options, otherOptionsForBaseFetch)) - if (err === null) - return resp + if (err === null) return resp const errResp: Response = err as any if (errResp.status === 401) { - if (!isClient) - return Promise.reject(err) + if (!isClient) return Promise.reject(err) const [parseErr, errRespData] = await asyncRunSafe(errResp.json()) const loginUrl = `${window.location.origin}${basePath}/signin` @@ -927,8 +925,7 @@ export const request = async(url: string, options = {}, otherOptions?: IOther window.location.href = loginUrl return Promise.reject(err) } - if (/\/login/.test(url)) - return Promise.reject(errRespData) + if (/\/login/.test(url)) return Promise.reject(errRespData) // special code const { code, message } = errRespData // webapp sso @@ -945,10 +942,7 @@ export const request = async(url: string, options = {}, otherOptions?: IOther window.location.reload() return Promise.reject(err) } - const { - isPublicAPI = false, - silent, - } = otherOptionsForBaseFetch + const { isPublicAPI = false, silent } = otherOptionsForBaseFetch if (isPublicAPI && code === 'unauthorized') { requiredWebSSOLogin() return Promise.reject(err) @@ -968,13 +962,11 @@ export const request = async(url: string, options = {}, otherOptions?: IOther // refresh token const [refreshErr] = await asyncRunSafe(refreshAccessTokenOrReLogin(TIME_OUT)) - if (refreshErr === null) - return baseFetch(url, options, otherOptionsForBaseFetch) + if (refreshErr === null) return baseFetch(url, options, otherOptionsForBaseFetch) // /device is the device-flow chooser; logged-out is a valid state // there. Redirecting to /signin loses the user_code context and // the post-login flow lands on /apps instead of returning here. - if (window.location.pathname === `${basePath}/device`) - return Promise.reject(err) + if (window.location.pathname === `${basePath}/device`) return Promise.reject(err) if (window.location.pathname !== `${basePath}/signin` || !IS_CE_EDITION) { jumpTo(buildSigninUrlWithRedirect()) return Promise.reject(err) @@ -985,12 +977,10 @@ export const request = async(url: string, options = {}, otherOptions?: IOther } jumpTo(buildSigninUrlWithRedirect()) return Promise.reject(err) - } - else { + } else { return Promise.reject(err) } - } - catch (error) { + } catch (error) { console.error(error) return Promise.reject(error) } diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index 4e94673f6d5d41..cf853ab75b7558 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -47,7 +47,9 @@ const createTag = (overrides: Partial = {}): Tag => ({ ...overrides, }) -const createApiBasedExtension = (overrides: Partial = {}): ApiBasedExtensionResponse => ({ +const createApiBasedExtension = ( + overrides: Partial = {}, +): ApiBasedExtensionResponse => ({ id: 'extension-1', name: 'Weather', api_endpoint: 'https://api.example.com/weather', @@ -55,17 +57,32 @@ const createApiBasedExtension = (overrides: Partial = ...overrides, }) -type AgentMutationResponse = Parameters['onSuccess']>>[0] -type AgentComposerMutationResponse = Parameters['onSuccess']>>[0] -type AgentPublishMutationResponse = Parameters['onSuccess']>>[0] -type WorkflowAgentComposerMutationResponse = Parameters['onSuccess']>>[0] +type AgentMutationResponse = Parameters< + NonNullable['onSuccess']> +>[0] +type AgentComposerMutationResponse = Parameters< + NonNullable< + ReturnType['onSuccess'] + > +>[0] +type AgentPublishMutationResponse = Parameters< + NonNullable< + ReturnType['onSuccess'] + > +>[0] +type WorkflowAgentComposerMutationResponse = Parameters< + NonNullable< + ReturnType< + typeof ConsoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions + >['onSuccess'] + > +>[0] type RetryFn = (failureCount: number, error: unknown) => boolean const getRetryFn = (queryOptions: object): RetryFn => { const retry = (queryOptions as { retry?: unknown }).retry expect(typeof retry).toBe('function') - if (typeof retry !== 'function') - throw new TypeError('Expected query retry to be a function.') + if (typeof retry !== 'function') throw new TypeError('Expected query retry to be a function.') return retry as RetryFn } @@ -86,7 +103,9 @@ const createAgent = (overrides: Partial = {}): AgentMutat role: overrides.role ?? 'Assistant', }) -const createComposerState = (overrides: Partial = {}): AgentComposerMutationResponse => ({ +const createComposerState = ( + overrides: Partial = {}, +): AgentComposerMutationResponse => ({ active_config_snapshot: { id: 'snapshot-1', version: 1, @@ -110,7 +129,9 @@ const createComposerState = (overrides: Partial = ...overrides, }) -const createAgentPublishResponse = (overrides: Partial = {}): AgentPublishMutationResponse => ({ +const createAgentPublishResponse = ( + overrides: Partial = {}, +): AgentPublishMutationResponse => ({ active_config_snapshot: { id: 'snapshot-1', version: 1, @@ -120,7 +141,9 @@ const createAgentPublishResponse = (overrides: Partial = {}): WorkflowAgentComposerMutationResponse => ({ +const createWorkflowComposerState = ( + overrides: Partial = {}, +): WorkflowAgentComposerMutationResponse => ({ agent: { active_config_snapshot_id: 'snapshot-1', description: 'Agent description', @@ -194,7 +217,9 @@ describe('getBaseURL', () => { // Assert expect(url.href).toBe('http://localhost/api') expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith('Using localhost as base URL in server environment, please configure accordingly.') + expect(warnSpy).toHaveBeenCalledWith( + 'Using localhost as base URL in server environment, please configure accordingly.', + ) }) // Scenario: non-http protocols surface warnings. @@ -235,12 +260,14 @@ describe('consoleQuery transport context', () => { }) it('should forward silent context to the base request transport', async () => { - const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({}), { - status: 200, - headers: { - 'content-type': 'application/json', - }, - })) + const request = vi.fn().mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ) const consoleQuery = await loadConsoleQueryWithRequest(request) const queryOptions = consoleQuery.agent.byAgentId.buildDraft.get.queryOptions({ input: { @@ -253,9 +280,9 @@ describe('consoleQuery transport context', () => { }, }) - await Promise - .resolve(queryOptions.queryFn({ signal: new AbortController().signal } as QueryFunctionContext)) - .catch(() => undefined) + await Promise.resolve( + queryOptions.queryFn({ signal: new AbortController().signal } as QueryFunctionContext), + ).catch(() => undefined) expect(request).toHaveBeenCalledWith( expect.stringContaining('/agent/agent-1/build-draft'), @@ -268,18 +295,23 @@ describe('consoleQuery transport context', () => { }) it('should serialize trial app dataset ids as repeated query params', async () => { - const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({ - data: [], - has_more: false, - limit: 20, - page: 1, - total: 0, - }), { - status: 200, - headers: { - 'content-type': 'application/json', - }, - })) + const request = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: [], + has_more: false, + limit: 20, + page: 1, + total: 0, + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }, + ), + ) const consoleQuery = await loadConsoleQueryWithRequest(request) const queryOptions = consoleQuery.trialApps.byAppId.datasets.get.queryOptions({ input: { @@ -438,13 +470,17 @@ describe('consoleQuery agent mutation defaults', () => { createMutationContext(queryClient), ) - expect(queryClient.getQueryData(consoleQuery.agent.byAgentId.get.queryKey({ - input: { - params: { - agent_id: copiedAgent.id, - }, - }, - }))).toEqual(copiedAgent) + expect( + queryClient.getQueryData( + consoleQuery.agent.byAgentId.get.queryKey({ + input: { + params: { + agent_id: copiedAgent.id, + }, + }, + }), + ), + ).toEqual(copiedAgent) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: consoleQuery.agent.get.key(), }) @@ -468,7 +504,8 @@ describe('consoleQuery agent mutation defaults', () => { }, }) - const mutationOptions = consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.copyFromRoster.post.mutationOptions() + const mutationOptions = + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.copyFromRoster.post.mutationOptions() await mutationOptions.onSuccess?.( composerState, { @@ -484,14 +521,18 @@ describe('consoleQuery agent mutation defaults', () => { createMutationContext(queryClient), ) - expect(queryClient.getQueryData(consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ - input: { - params: { - app_id: 'app-1', - node_id: 'node-1', - }, - }, - }))).toEqual(composerState) + expect( + queryClient.getQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ + input: { + params: { + app_id: 'app-1', + node_id: 'node-1', + }, + }, + }), + ), + ).toEqual(composerState) expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: consoleQuery.agent.get.key(), }) @@ -514,7 +555,8 @@ describe('consoleQuery agent mutation defaults', () => { }, }) - const mutationOptions = consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions() + const mutationOptions = + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions() await mutationOptions.onSuccess?.( composerState, { @@ -531,14 +573,18 @@ describe('consoleQuery agent mutation defaults', () => { createMutationContext(queryClient), ) - expect(queryClient.getQueryData(consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ - input: { - params: { - app_id: 'app-1', - node_id: 'node-1', - }, - }, - }))).toEqual(composerState) + expect( + queryClient.getQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ + input: { + params: { + app_id: 'app-1', + node_id: 'node-1', + }, + }, + }), + ), + ).toEqual(composerState) }) it('should cache workflow composer state and invalidate roster lists after saving inline agent to roster', async () => { @@ -547,7 +593,8 @@ describe('consoleQuery agent mutation defaults', () => { const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const composerState = createWorkflowComposerState() - const mutationOptions = consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions() + const mutationOptions = + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions() await mutationOptions.onSuccess?.( composerState, { @@ -567,14 +614,18 @@ describe('consoleQuery agent mutation defaults', () => { createMutationContext(queryClient), ) - expect(queryClient.getQueryData(consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ - input: { - params: { - app_id: 'app-1', - node_id: 'node-1', - }, - }, - }))).toEqual(composerState) + expect( + queryClient.getQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ + input: { + params: { + app_id: 'app-1', + node_id: 'node-1', + }, + }, + }), + ), + ).toEqual(composerState) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: consoleQuery.agent.get.key(), }) @@ -858,10 +909,7 @@ describe('consoleQuery tag mutation defaults', () => { createMutationContext(queryClient), ) - expect(queryClient.getQueryData(appListKey)).toEqual([ - updatedTag, - otherTag, - ]) + expect(queryClient.getQueryData(appListKey)).toEqual([updatedTag, otherTag]) expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag]) }) diff --git a/web/service/client.ts b/web/service/client.ts index 3adfc82b0ea380..07d65ed251101c 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -16,12 +16,7 @@ import { marketplaceRouterContract } from '@dify/contracts/marketplace' import { createORPCClient, onError } from '@orpc/client' import { OpenAPILink } from '@orpc/openapi-client/fetch' import { createTanstackQueryUtils } from '@orpc/tanstack-query' -import { - API_PREFIX, - APP_VERSION, - IS_MARKETPLACE, - MARKETPLACE_API_PREFIX, -} from '@/config' +import { API_PREFIX, APP_VERSION, IS_MARKETPLACE, MARKETPLACE_API_PREFIX } from '@/config' import { isClient } from '@/utils/client' // eslint-disable-next-line no-restricted-imports import { request } from './base' @@ -39,21 +34,25 @@ function isURL(path: string) { // eslint-disable-next-line no-new new URL(path) return true - } - catch { + } catch { return false } } export function getBaseURL(path: string) { - const url = new URL(path, isURL(path) ? undefined : isClient ? window.location.origin : 'http://localhost') + const url = new URL( + path, + isURL(path) ? undefined : isClient ? window.location.origin : 'http://localhost', + ) if (!isClient && !isURL(path)) { console.warn('Using localhost as base URL in server environment, please configure accordingly.') } if (url.protocol !== 'http:' && url.protocol !== 'https:') { - console.warn(`Unexpected protocol for API requests, expected http or https. Current protocol: ${url.protocol}. Please configure accordingly.`) + console.warn( + `Unexpected protocol for API requests, expected http or https. Current protocol: ${url.protocol}. Please configure accordingly.`, + ) } return url @@ -69,15 +68,11 @@ function createConsoleOpenAPILink(contract: AnyContractRouter): ConsoleClientLin return new OpenAPILink(contract, { url: getBaseURL(API_PREFIX), fetch: (input, init, options) => { - return request( - normalizeConsoleOpenAPIURL(input.url), - init, - { - fetchCompat: true, - request: input, - silent: options.context.silent, - }, - ) + return request(normalizeConsoleOpenAPIURL(input.url), init, { + fetchCompat: true, + request: input, + silent: options.context.silent, + }) }, interceptors: [ onError((error) => { @@ -89,7 +84,7 @@ function createConsoleOpenAPILink(contract: AnyContractRouter): ConsoleClientLin const marketplaceLink = new OpenAPILink(marketplaceRouterContract, { url: MARKETPLACE_API_PREFIX, - headers: () => (getMarketplaceHeaders()), + headers: () => getMarketplaceHeaders(), fetch: (request, init) => { return globalThis.fetch(request, { ...init, @@ -103,8 +98,12 @@ const marketplaceLink = new OpenAPILink(marketplaceRouterContract, { ], }) -export const marketplaceClient: JsonifiedClient> = createORPCClient(marketplaceLink) -export const marketplaceQuery = createTanstackQueryUtils(marketplaceClient, { path: ['marketplace'] }) +export const marketplaceClient: JsonifiedClient< + ContractRouterClient +> = createORPCClient(marketplaceLink) +export const marketplaceQuery = createTanstackQueryUtils(marketplaceClient, { + path: ['marketplace'], +}) const APP_DEPLOY_SOURCE_APPS_PAGE_SIZE = 100 const APP_DEPLOY_READINESS_RETRY_DELAYS = [0, 300, 700, 1200] @@ -123,7 +122,9 @@ type AppDeployInvalidationOptions = { developerApiSettings?: boolean } -type ConsoleQueryUtils = RouterUtils>> +type ConsoleQueryUtils = RouterUtils< + JsonifiedClient> +> function isTagType(type: string | null | undefined): type is TagType { return type === 'app' || type === 'knowledge' || type === 'snippet' @@ -144,7 +145,7 @@ const defaultAppDeployInvalidationOptions = { } satisfies Required function invalidateQueryKeys(client: QueryClient, queryKeys: QueryKey[]) { - return Promise.all(queryKeys.map(queryKey => client.invalidateQueries({ queryKey }))) + return Promise.all(queryKeys.map((queryKey) => client.invalidateQueries({ queryKey }))) } function appInstanceQueryKey(query: ConsoleQueryUtils, appInstanceId: string) { @@ -205,9 +206,8 @@ function cachedReleaseAppInstanceId( queryKey: query.enterprise.releaseService.listReleases.key({ type: 'query' }), }) for (const [, data] of listQueries) { - const appInstanceId = data?.releases?.find(release => release.id === releaseId)?.appInstanceId - if (appInstanceId) - return appInstanceId + const appInstanceId = data?.releases?.find((release) => release.id === releaseId)?.appInstanceId + if (appInstanceId) return appInstanceId } const releaseQueries = client.getQueriesData({ @@ -215,8 +215,7 @@ function cachedReleaseAppInstanceId( }) for (const [, data] of releaseQueries) { const release = data?.release - if (release?.id === releaseId && release.appInstanceId) - return release.appInstanceId + if (release?.id === releaseId && release.appInstanceId) return release.appInstanceId } } @@ -255,7 +254,11 @@ function apiKeysQueryKey(query: ConsoleQueryUtils, appInstanceId: string, enviro }) } -function accessPolicyQueryKey(query: ConsoleQueryUtils, appInstanceId: string, environmentId: string) { +function accessPolicyQueryKey( + query: ConsoleQueryUtils, + appInstanceId: string, + environmentId: string, +) { return query.enterprise.accessService.getAccessPolicy.key({ type: 'query', input: { params: { appInstanceId, environmentId } }, @@ -278,29 +281,29 @@ function invalidateAppDeployQueries( queryKeys.push(query.enterprise.appInstanceService.listAppInstances.key()) if (resolvedOptions.appInstanceSummaries) queryKeys.push(query.enterprise.appInstanceService.listAppInstanceSummaries.key()) - if (resolvedOptions.appInstance) - queryKeys.push(appInstanceQueryKey(query, appInstanceId)) + if (resolvedOptions.appInstance) queryKeys.push(appInstanceQueryKey(query, appInstanceId)) if (resolvedOptions.appInstanceOverview) queryKeys.push(appInstanceOverviewQueryKey(query, appInstanceId)) if (resolvedOptions.environmentDeployments) queryKeys.push(environmentDeploymentsQueryKey(query, appInstanceId)) - if (resolvedOptions.releases) - queryKeys.push(releasesQueryKey(query, appInstanceId)) + if (resolvedOptions.releases) queryKeys.push(releasesQueryKey(query, appInstanceId)) if (resolvedOptions.releaseSummaries) queryKeys.push(releaseSummariesQueryKey(query, appInstanceId)) if (resolvedOptions.releaseDeploymentView) queryKeys.push(releaseDeploymentViewQueryKey(query, appInstanceId)) - if (resolvedOptions.accessChannels) - queryKeys.push(accessChannelsQueryKey(query, appInstanceId)) - if (resolvedOptions.accessSettings) - queryKeys.push(accessSettingsQueryKey(query, appInstanceId)) + if (resolvedOptions.accessChannels) queryKeys.push(accessChannelsQueryKey(query, appInstanceId)) + if (resolvedOptions.accessSettings) queryKeys.push(accessSettingsQueryKey(query, appInstanceId)) if (resolvedOptions.developerApiSettings) queryKeys.push(developerApiSettingsQueryKey(query, appInstanceId)) return invalidateQueryKeys(client, queryKeys) } -function removeAppDeployQueries(query: ConsoleQueryUtils, client: QueryClient, appInstanceId: string) { +function removeAppDeployQueries( + query: ConsoleQueryUtils, + client: QueryClient, + appInstanceId: string, +) { const queryKeys = [ appInstanceQueryKey(query, appInstanceId), appInstanceOverviewQueryKey(query, appInstanceId), @@ -313,7 +316,7 @@ function removeAppDeployQueries(query: ConsoleQueryUtils, client: QueryClient, a developerApiSettingsQueryKey(query, appInstanceId), ] - queryKeys.forEach(queryKey => client.removeQueries({ queryKey })) + queryKeys.forEach((queryKey) => client.removeQueries({ queryKey })) } async function invalidateReleaseMutationQueries( @@ -330,8 +333,7 @@ async function invalidateReleaseMutationQueries( client.removeQueries({ queryKey: releaseDetailQueryKey, }) - } - else { + } else { await client.invalidateQueries({ queryKey: releaseDetailQueryKey, }) @@ -358,81 +360,93 @@ async function invalidateReleaseMutationQueries( const consoleLink = createConsoleDynamicLink(createConsoleOpenAPILink) -export const consoleClient: JsonifiedClient> = createORPCClient(consoleLink) - -export const consoleQuery: RouterUtils = createTanstackQueryUtils(consoleClient, { - path: ['console'], - experimental_defaults: { - apps: { - byAppId: { - workflows: { - draft: { - nodes: { - byNodeId: { - agentComposer: { - put: { - mutationOptions: { - onSuccess: (composerState, variables, _onMutateResult, context) => { - context.client.setQueryData( - consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ - input: { - params: variables.params, - }, - }), - composerState, - ) - }, - }, - }, - copyFromRoster: { - post: { +export const consoleClient: JsonifiedClient< + ContractRouterClient +> = createORPCClient(consoleLink) + +export const consoleQuery: RouterUtils = createTanstackQueryUtils( + consoleClient, + { + path: ['console'], + experimental_defaults: { + apps: { + byAppId: { + workflows: { + draft: { + nodes: { + byNodeId: { + agentComposer: { + put: { mutationOptions: { onSuccess: (composerState, variables, _onMutateResult, context) => { context.client.setQueryData( - consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ - input: { - params: variables.params, + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey( + { + input: { + params: variables.params, + }, }, - }), + ), composerState, ) }, }, }, - }, - saveToRoster: { - post: { - mutationOptions: { - onSuccess: (composerState, variables, _onMutateResult, context) => { - context.client.setQueryData( - consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ - input: { - params: variables.params, - }, - }), - composerState, - ) - context.client.invalidateQueries({ - queryKey: consoleQuery.agent.get.key(), - }) - context.client.invalidateQueries({ - queryKey: consoleQuery.agent.inviteOptions.get.key(), - }) - - const agentId = composerState.binding?.binding_type === 'roster_agent' - ? composerState.binding.agent_id - : undefined - if (agentId) { - context.client.invalidateQueries({ - queryKey: consoleQuery.agent.byAgentId.get.queryKey({ - input: { - params: { - agent_id: agentId, + copyFromRoster: { + post: { + mutationOptions: { + onSuccess: (composerState, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey( + { + input: { + params: variables.params, }, }, - }), + ), + composerState, + ) + }, + }, + }, + }, + saveToRoster: { + post: { + mutationOptions: { + onSuccess: (composerState, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey( + { + input: { + params: variables.params, + }, + }, + ), + composerState, + ) + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.get.key(), + }) + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.inviteOptions.get.key(), }) - } + + const agentId = + composerState.binding?.binding_type === 'roster_agent' + ? composerState.binding.agent_id + : undefined + if (agentId) { + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.byAgentId.get.queryKey({ + input: { + params: { + agent_id: agentId, + }, + }, + }), + }) + } + }, }, }, }, @@ -443,515 +457,554 @@ export const consoleQuery: RouterUtils = createTanstackQue }, }, }, - }, - agent: { - post: { - mutationOptions: { - onSuccess: (_createdAgent, _variables, _onMutateResult, context) => { - context.client.invalidateQueries({ - queryKey: consoleQuery.agent.get.key(), - }) - context.client.invalidateQueries({ - queryKey: consoleQuery.agent.inviteOptions.get.key(), - }) + agent: { + post: { + mutationOptions: { + onSuccess: (_createdAgent, _variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.get.key(), + }) + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.inviteOptions.get.key(), + }) + }, }, }, - }, - byAgentId: { - get: { - queryOptions: { - retry: (failureCount, error) => { - if (error instanceof Response && error.status === 404) - return false - - return failureCount < 3 + byAgentId: { + get: { + queryOptions: { + retry: (failureCount, error) => { + if (error instanceof Response && error.status === 404) return false + + return failureCount < 3 + }, }, }, - }, - copy: { - post: { + copy: { + post: { + mutationOptions: { + onSuccess: (copiedAgent, _variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.agent.byAgentId.get.queryKey({ + input: { + params: { + agent_id: copiedAgent.id, + }, + }, + }), + copiedAgent, + ) + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.get.key(), + }) + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.inviteOptions.get.key(), + }) + }, + }, + }, + }, + put: { mutationOptions: { - onSuccess: (copiedAgent, _variables, _onMutateResult, context) => { + onSuccess: (updatedAgent, variables, _onMutateResult, context) => { + context.client.setQueriesData( + { + queryKey: consoleQuery.agent.get.key({ type: 'query' }), + }, + (oldList: AgentAppPagination | undefined) => { + if (!oldList?.data.some((item) => item.id === updatedAgent.id)) return oldList + + return { + ...oldList, + data: oldList.data.map((item) => + item.id === updatedAgent.id ? updatedAgent : item, + ), + } + }, + ) + context.client.setQueriesData( + { + queryKey: consoleQuery.agent.get.key({ type: 'infinite' }), + }, + (oldList: InfiniteData | undefined) => { + if ( + !oldList?.pages.some((page) => + page.data.some((item) => item.id === updatedAgent.id), + ) + ) + return oldList + + return { + ...oldList, + pages: oldList.pages.map((page) => ({ + ...page, + data: page.data.map((item) => + item.id === updatedAgent.id ? updatedAgent : item, + ), + })), + } + }, + ) context.client.setQueryData( consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { - agent_id: copiedAgent.id, + agent_id: variables.params.agent_id, }, }, }), - copiedAgent, + updatedAgent, ) - context.client.invalidateQueries({ - queryKey: consoleQuery.agent.get.key(), - }) context.client.invalidateQueries({ queryKey: consoleQuery.agent.inviteOptions.get.key(), }) }, }, }, - }, - put: { - mutationOptions: { - onSuccess: (updatedAgent, variables, _onMutateResult, context) => { - context.client.setQueriesData( - { - queryKey: consoleQuery.agent.get.key({ type: 'query' }), - }, - (oldList: AgentAppPagination | undefined) => { - if (!oldList?.data.some(item => item.id === updatedAgent.id)) - return oldList + composer: { + put: { + mutationOptions: { + onSuccess: (_composerState, variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.get.key(), + }) + if (variables.body.save_strategy !== 'save_as_new_version') return - return { - ...oldList, - data: oldList.data.map(item => item.id === updatedAgent.id ? updatedAgent : item), - } - }, - ) - context.client.setQueriesData( - { - queryKey: consoleQuery.agent.get.key({ type: 'infinite' }), - }, - (oldList: InfiniteData | undefined) => { - if (!oldList?.pages.some(page => page.data.some(item => item.id === updatedAgent.id))) - return oldList - - return { - ...oldList, - pages: oldList.pages.map(page => ({ - ...page, - data: page.data.map(item => item.id === updatedAgent.id ? updatedAgent : item), - })), - } + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.inviteOptions.get.key(), + }) + context.client.removeQueries({ + queryKey: consoleQuery.agent.inviteOptions.get.key(), + }) }, - ) - context.client.setQueryData( - consoleQuery.agent.byAgentId.get.queryKey({ - input: { - params: { - agent_id: variables.params.agent_id, - }, - }, - }), - updatedAgent, - ) - context.client.invalidateQueries({ - queryKey: consoleQuery.agent.inviteOptions.get.key(), - }) + }, }, }, - }, - composer: { - put: { - mutationOptions: { - onSuccess: (_composerState, variables, _onMutateResult, context) => { - context.client.invalidateQueries({ - queryKey: consoleQuery.agent.get.key(), - }) - if (variables.body.save_strategy !== 'save_as_new_version') - return - - context.client.invalidateQueries({ - queryKey: consoleQuery.agent.inviteOptions.get.key(), - }) - context.client.removeQueries({ - queryKey: consoleQuery.agent.inviteOptions.get.key(), - }) + publish: { + post: { + mutationOptions: { + onSuccess: (_publishResult, _variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.get.key(), + }) + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.inviteOptions.get.key(), + }) + context.client.removeQueries({ + queryKey: consoleQuery.agent.inviteOptions.get.key(), + }) + }, }, }, }, - }, - publish: { - post: { + delete: { mutationOptions: { - onSuccess: (_publishResult, _variables, _onMutateResult, context) => { + onSuccess: (_data, variables, _onMutateResult, context) => { + context.client.setQueriesData( + { + queryKey: consoleQuery.agent.get.key({ type: 'query' }), + }, + (oldList: AgentAppPagination | undefined) => { + if (!oldList?.data.some((item) => item.id === variables.params.agent_id)) + return oldList + + return { + ...oldList, + data: oldList.data.filter((item) => item.id !== variables.params.agent_id), + total: Math.max(0, oldList.total - 1), + } + }, + ) + context.client.setQueriesData( + { + queryKey: consoleQuery.agent.get.key({ type: 'infinite' }), + }, + (oldList: InfiniteData | undefined) => { + if ( + !oldList?.pages.some((page) => + page.data.some((item) => item.id === variables.params.agent_id), + ) + ) + return oldList + + return { + ...oldList, + pages: oldList.pages.map((page) => { + const total = Math.max(0, page.total - 1) + + return { + ...page, + data: page.data.filter((item) => item.id !== variables.params.agent_id), + has_more: page.page * page.limit < total, + total, + } + }), + } + }, + ) context.client.invalidateQueries({ queryKey: consoleQuery.agent.get.key(), }) context.client.invalidateQueries({ queryKey: consoleQuery.agent.inviteOptions.get.key(), }) - context.client.removeQueries({ - queryKey: consoleQuery.agent.inviteOptions.get.key(), - }) }, }, }, }, - delete: { - mutationOptions: { - onSuccess: (_data, variables, _onMutateResult, context) => { - context.client.setQueriesData( - { - queryKey: consoleQuery.agent.get.key({ type: 'query' }), - }, - (oldList: AgentAppPagination | undefined) => { - if (!oldList?.data.some(item => item.id === variables.params.agent_id)) - return oldList - - return { - ...oldList, - data: oldList.data.filter(item => item.id !== variables.params.agent_id), - total: Math.max(0, oldList.total - 1), - } - }, - ) - context.client.setQueriesData( - { - queryKey: consoleQuery.agent.get.key({ type: 'infinite' }), - }, - (oldList: InfiniteData | undefined) => { - if (!oldList?.pages.some(page => page.data.some(item => item.id === variables.params.agent_id))) - return oldList - - return { - ...oldList, - pages: oldList.pages.map((page) => { - const total = Math.max(0, page.total - 1) - - return { - ...page, - data: page.data.filter(item => item.id !== variables.params.agent_id), - has_more: page.page * page.limit < total, - total, - } - }), - } - }, - ) - context.client.invalidateQueries({ - queryKey: consoleQuery.agent.get.key(), - }) - context.client.invalidateQueries({ - queryKey: consoleQuery.agent.inviteOptions.get.key(), - }) - }, - }, - }, }, - }, - apiBasedExtension: { - post: { - mutationOptions: { - onSuccess: (createdExtension, _variables, _onMutateResult, context) => { - context.client.setQueryData( - consoleQuery.apiBasedExtension.get.queryKey(), - (oldExtensions: ApiBasedExtensionResponse[] | undefined) => - oldExtensions ? [createdExtension, ...oldExtensions] : oldExtensions, - ) - }, - }, - }, - byId: { + apiBasedExtension: { post: { mutationOptions: { - onSuccess: (updatedExtension, variables, _onMutateResult, context) => { + onSuccess: (createdExtension, _variables, _onMutateResult, context) => { context.client.setQueryData( consoleQuery.apiBasedExtension.get.queryKey(), (oldExtensions: ApiBasedExtensionResponse[] | undefined) => - oldExtensions?.map(extension => extension.id === variables.params.id - ? updatedExtension - : extension), + oldExtensions ? [createdExtension, ...oldExtensions] : oldExtensions, ) }, }, }, - delete: { - mutationOptions: { - onSuccess: (_data, variables, _onMutateResult, context) => { - context.client.setQueryData( - consoleQuery.apiBasedExtension.get.queryKey(), - (oldExtensions: ApiBasedExtensionResponse[] | undefined) => - oldExtensions?.filter(extension => extension.id !== variables.params.id), - ) + byId: { + post: { + mutationOptions: { + onSuccess: (updatedExtension, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.apiBasedExtension.get.queryKey(), + (oldExtensions: ApiBasedExtensionResponse[] | undefined) => + oldExtensions?.map((extension) => + extension.id === variables.params.id ? updatedExtension : extension, + ), + ) + }, }, }, - }, - }, - }, - tags: { - post: { - mutationOptions: { - onSuccess: (tag, _variables, _onMutateResult, context) => { - if (!isTagType(tag.type)) - return - - context.client.setQueryData( - consoleQuery.tags.get.queryKey({ - input: { - query: { - type: tag.type, - }, - }, - }), - (oldTags: Tag[] | undefined) => oldTags ? [tag, ...oldTags] : oldTags, - ) + delete: { + mutationOptions: { + onSuccess: (_data, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.apiBasedExtension.get.queryKey(), + (oldExtensions: ApiBasedExtensionResponse[] | undefined) => + oldExtensions?.filter((extension) => extension.id !== variables.params.id), + ) + }, + }, }, }, }, - byTagId: { - patch: { + tags: { + post: { mutationOptions: { - onSuccess: (updatedTag, variables, _onMutateResult, context) => { - context.client.setQueriesData( - { - queryKey: consoleQuery.tags.get.key({ type: 'query' }), - }, - (oldTags: Tag[] | undefined) => oldTags?.map(tag => tag.id === variables.params.tag_id - ? updatedTag - : tag), + onSuccess: (tag, _variables, _onMutateResult, context) => { + if (!isTagType(tag.type)) return + + context.client.setQueryData( + consoleQuery.tags.get.queryKey({ + input: { + query: { + type: tag.type, + }, + }, + }), + (oldTags: Tag[] | undefined) => (oldTags ? [tag, ...oldTags] : oldTags), ) }, }, }, - delete: { - mutationOptions: { - onSuccess: (_data, variables, _onMutateResult, context) => { - context.client.setQueriesData( - { - queryKey: consoleQuery.tags.get.key({ type: 'query' }), - }, - (oldTags: Tag[] | undefined) => oldTags?.filter(tag => tag.id !== variables.params.tag_id), - ) + byTagId: { + patch: { + mutationOptions: { + onSuccess: (updatedTag, variables, _onMutateResult, context) => { + context.client.setQueriesData( + { + queryKey: consoleQuery.tags.get.key({ type: 'query' }), + }, + (oldTags: Tag[] | undefined) => + oldTags?.map((tag) => (tag.id === variables.params.tag_id ? updatedTag : tag)), + ) + }, + }, + }, + delete: { + mutationOptions: { + onSuccess: (_data, variables, _onMutateResult, context) => { + context.client.setQueriesData( + { + queryKey: consoleQuery.tags.get.key({ type: 'query' }), + }, + (oldTags: Tag[] | undefined) => + oldTags?.filter((tag) => tag.id !== variables.params.tag_id), + ) + }, }, }, }, }, - }, - enterprise: { - appInstanceService: { - createAppInstance: { - mutationOptions: { - onSuccess: async (data, _variables, _result, context) => { - const appInstanceId = data.appInstance?.id - if (appInstanceId) { - for (const delay of APP_DEPLOY_READINESS_RETRY_DELAYS) { - if (delay > 0) - await new Promise(resolve => setTimeout(resolve, delay)) - - const listResponse = await context.client - .fetchQuery(consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({ - input: { - query: { - pageNumber: 1, - resultsPerPage: APP_DEPLOY_SOURCE_APPS_PAGE_SIZE, - }, - }, - })) - .catch(() => undefined) + enterprise: { + appInstanceService: { + createAppInstance: { + mutationOptions: { + onSuccess: async (data, _variables, _result, context) => { + const appInstanceId = data.appInstance?.id + if (appInstanceId) { + for (const delay of APP_DEPLOY_READINESS_RETRY_DELAYS) { + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)) + + const listResponse = await context.client + .fetchQuery( + consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({ + input: { + query: { + pageNumber: 1, + resultsPerPage: APP_DEPLOY_SOURCE_APPS_PAGE_SIZE, + }, + }, + }), + ) + .catch(() => undefined) - if (listResponse?.appInstances?.some(app => app.id === appInstanceId)) - break + if (listResponse?.appInstances?.some((app) => app.id === appInstanceId)) break + } } - } - await context.client.invalidateQueries({ - queryKey: consoleQuery.enterprise.appInstanceService.listAppInstances.key(), - }) - await context.client.invalidateQueries({ - queryKey: consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.key(), - }) + await context.client.invalidateQueries({ + queryKey: consoleQuery.enterprise.appInstanceService.listAppInstances.key(), + }) + await context.client.invalidateQueries({ + queryKey: + consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.key(), + }) + }, }, }, - }, - updateAppInstance: { - mutationOptions: { - onSuccess: (_data, variables, _result, context) => { - const appInstanceId = variables.params.appInstanceId - return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId, { - environmentDeployments: false, - releases: false, - accessChannels: false, - }) + updateAppInstance: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + const appInstanceId = variables.params.appInstanceId + return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId, { + environmentDeployments: false, + releases: false, + accessChannels: false, + }) + }, }, }, - }, - deleteAppInstance: { - mutationOptions: { - onSuccess: (_data, variables, _result, context) => { - const appInstanceId = variables.params.appInstanceId - removeAppDeployQueries(consoleQuery, context.client, appInstanceId) + deleteAppInstance: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + const appInstanceId = variables.params.appInstanceId + removeAppDeployQueries(consoleQuery, context.client, appInstanceId) - return Promise.all([ - context.client.invalidateQueries({ - queryKey: consoleQuery.enterprise.appInstanceService.listAppInstances.key(), - }), - context.client.invalidateQueries({ - queryKey: consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.key(), - }), - ]) - }, - }, - }, - }, - releaseService: { - createRelease: { - mutationOptions: { - onSuccess: (data, variables, _result, context) => { - const appInstanceId = data.release?.appInstanceId ?? data.appInstance?.id ?? variables.body.appInstanceId - const { dsl, sourceAppId } = variables.body - if (!appInstanceId) { return Promise.all([ context.client.invalidateQueries({ queryKey: consoleQuery.enterprise.appInstanceService.listAppInstances.key(), }), context.client.invalidateQueries({ - queryKey: consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.key(), + queryKey: + consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.key(), }), ]) - } + }, + }, + }, + }, + releaseService: { + createRelease: { + mutationOptions: { + onSuccess: (data, variables, _result, context) => { + const appInstanceId = + data.release?.appInstanceId ?? + data.appInstance?.id ?? + variables.body.appInstanceId + const { dsl, sourceAppId } = variables.body + if (!appInstanceId) { + return Promise.all([ + context.client.invalidateQueries({ + queryKey: consoleQuery.enterprise.appInstanceService.listAppInstances.key(), + }), + context.client.invalidateQueries({ + queryKey: + consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.key(), + }), + ]) + } - const appDeployInvalidation = invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId, { - environmentDeployments: false, - accessChannels: false, - }) - if (!dsl && !sourceAppId) - return appDeployInvalidation + const appDeployInvalidation = invalidateAppDeployQueries( + consoleQuery, + context.client, + appInstanceId, + { + environmentDeployments: false, + accessChannels: false, + }, + ) + if (!dsl && !sourceAppId) return appDeployInvalidation - return Promise.all([ - appDeployInvalidation, - context.client.invalidateQueries({ - queryKey: precheckReleaseQueryKey(consoleQuery, { - appInstanceId, - ...(dsl ? { dsl } : { sourceAppId }), + return Promise.all([ + appDeployInvalidation, + context.client.invalidateQueries({ + queryKey: precheckReleaseQueryKey(consoleQuery, { + appInstanceId, + ...(dsl ? { dsl } : { sourceAppId }), + }), }), - }), - ]) + ]) + }, }, }, - }, - deleteRelease: { - mutationOptions: { - onSuccess: (_data, variables, _result, context) => { - const releaseId = variables.params.releaseId - const appInstanceId = cachedReleaseAppInstanceId(consoleQuery, context.client, releaseId) + deleteRelease: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + const releaseId = variables.params.releaseId + const appInstanceId = cachedReleaseAppInstanceId( + consoleQuery, + context.client, + releaseId, + ) - return invalidateReleaseMutationQueries(consoleQuery, context.client, releaseId, appInstanceId, { - removeRelease: true, - }) + return invalidateReleaseMutationQueries( + consoleQuery, + context.client, + releaseId, + appInstanceId, + { + removeRelease: true, + }, + ) + }, }, }, - }, - updateRelease: { - mutationOptions: { - onSuccess: (data, variables, _result, context) => { - const releaseId = variables.params.releaseId - const appInstanceId = data.release?.appInstanceId - ?? cachedReleaseAppInstanceId(consoleQuery, context.client, releaseId) - - return invalidateReleaseMutationQueries(consoleQuery, context.client, releaseId, appInstanceId) + updateRelease: { + mutationOptions: { + onSuccess: (data, variables, _result, context) => { + const releaseId = variables.params.releaseId + const appInstanceId = + data.release?.appInstanceId ?? + cachedReleaseAppInstanceId(consoleQuery, context.client, releaseId) + + return invalidateReleaseMutationQueries( + consoleQuery, + context.client, + releaseId, + appInstanceId, + ) + }, }, }, }, - }, - deploymentService: { - deploy: { - mutationOptions: { - onSuccess: (data, _variables, _result, context) => { - // Deploy always creates a new AppInstance, so the reply carries it. - const appInstanceId = data.appInstance?.id ?? data.release?.appInstanceId - if (!appInstanceId) { - return Promise.all([ - context.client.invalidateQueries({ - queryKey: consoleQuery.enterprise.appInstanceService.listAppInstances.key(), - }), - context.client.invalidateQueries({ - queryKey: consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.key(), - }), - ]) - } + deploymentService: { + deploy: { + mutationOptions: { + onSuccess: (data, _variables, _result, context) => { + // Deploy always creates a new AppInstance, so the reply carries it. + const appInstanceId = data.appInstance?.id ?? data.release?.appInstanceId + if (!appInstanceId) { + return Promise.all([ + context.client.invalidateQueries({ + queryKey: consoleQuery.enterprise.appInstanceService.listAppInstances.key(), + }), + context.client.invalidateQueries({ + queryKey: + consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.key(), + }), + ]) + } - return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId) + return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId) + }, }, }, - }, - cancelDeployment: { - mutationOptions: { - onSuccess: (_data, variables, _result, context) => { - const appInstanceId = variables.params.appInstanceId - return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId) + cancelDeployment: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + const appInstanceId = variables.params.appInstanceId + return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId) + }, }, }, - }, - promote: { - mutationOptions: { - onSuccess: (_data, variables, _result, context) => { - const appInstanceId = variables.params.appInstanceId - return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId) + promote: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + const appInstanceId = variables.params.appInstanceId + return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId) + }, }, }, - }, - rollback: { - mutationOptions: { - onSuccess: (_data, variables, _result, context) => { - const appInstanceId = variables.params.appInstanceId - return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId) + rollback: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + const appInstanceId = variables.params.appInstanceId + return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId) + }, }, }, - }, - undeploy: { - mutationOptions: { - onSuccess: (_data, variables, _result, context) => { - const appInstanceId = variables.params.appInstanceId - return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId) + undeploy: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + const appInstanceId = variables.params.appInstanceId + return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId) + }, }, }, }, - }, - accessService: { - createApiKey: { - mutationOptions: { - onSuccess: (_data, variables, _result, context) => { - const appInstanceId = variables.params.appInstanceId - const environmentId = variables.params.environmentId - return invalidateQueryKeys(context.client, [ - appInstanceQueryKey(consoleQuery, appInstanceId), - appInstanceOverviewQueryKey(consoleQuery, appInstanceId), - consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.key(), - accessChannelsQueryKey(consoleQuery, appInstanceId), - developerApiSettingsQueryKey(consoleQuery, appInstanceId), - apiKeysQueryKey(consoleQuery, appInstanceId, environmentId), - ]) + accessService: { + createApiKey: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + const appInstanceId = variables.params.appInstanceId + const environmentId = variables.params.environmentId + return invalidateQueryKeys(context.client, [ + appInstanceQueryKey(consoleQuery, appInstanceId), + appInstanceOverviewQueryKey(consoleQuery, appInstanceId), + consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.key(), + accessChannelsQueryKey(consoleQuery, appInstanceId), + developerApiSettingsQueryKey(consoleQuery, appInstanceId), + apiKeysQueryKey(consoleQuery, appInstanceId, environmentId), + ]) + }, }, }, - }, - deleteApiKey: { - mutationOptions: { - onSuccess: (_data, _variables, _result, context) => { - return invalidateQueryKeys(context.client, [ - consoleQuery.enterprise.accessService.listApiKeys.key({ type: 'query' }), - consoleQuery.enterprise.accessService.getDeveloperApiSettings.key({ type: 'query' }), - consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.key({ type: 'query' }), - consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.key(), - ]) + deleteApiKey: { + mutationOptions: { + onSuccess: (_data, _variables, _result, context) => { + return invalidateQueryKeys(context.client, [ + consoleQuery.enterprise.accessService.listApiKeys.key({ type: 'query' }), + consoleQuery.enterprise.accessService.getDeveloperApiSettings.key({ + type: 'query', + }), + consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.key({ + type: 'query', + }), + consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.key(), + ]) + }, }, }, - }, - updateAccessChannels: { - mutationOptions: { - onSuccess: (_data, variables, _result, context) => { - const appInstanceId = variables.params.appInstanceId - return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId, { - environmentDeployments: false, - releases: false, - }) + updateAccessChannels: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + const appInstanceId = variables.params.appInstanceId + return invalidateAppDeployQueries(consoleQuery, context.client, appInstanceId, { + environmentDeployments: false, + releases: false, + }) + }, }, }, - }, - updateAccessPolicy: { - mutationOptions: { - onSuccess: (_data, variables, _result, context) => { - const { appInstanceId, environmentId } = variables.params - return invalidateQueryKeys(context.client, [ - accessPolicyQueryKey(consoleQuery, appInstanceId, environmentId), - accessChannelsQueryKey(consoleQuery, appInstanceId), - accessSettingsQueryKey(consoleQuery, appInstanceId), - ]) + updateAccessPolicy: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + const { appInstanceId, environmentId } = variables.params + return invalidateQueryKeys(context.client, [ + accessPolicyQueryKey(consoleQuery, appInstanceId, environmentId), + accessChannelsQueryKey(consoleQuery, appInstanceId), + accessSettingsQueryKey(consoleQuery, appInstanceId), + ]) + }, }, }, }, }, }, }, -}) +) diff --git a/web/service/common.ts b/web/service/common.ts index f43c9a13b9146a..730a5e7fe68f03 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -25,10 +25,22 @@ type LoginFail = { message: string } type LoginResponse = LoginSuccess | LoginFail -export const login = ({ url, body }: { url: string, body: Record }): Promise => { +export const login = ({ + url, + body, +}: { + url: string + body: Record +}): Promise => { return post(url, { body }) } -export const webAppLogin = ({ url, body }: { url: string, body: Record }): Promise => { +export const webAppLogin = ({ + url, + body, +}: { + url: string + body: Record +}): Promise => { return post(url, { body }, { isPublicAPI: true }) } @@ -47,34 +59,79 @@ export const fetchInitValidateStatus = (): Promise = export const fetchSetupStatus = (): Promise => { return get('/setup') } -export const updateUserProfile = ({ url, body }: { url: string, body: Record }): Promise => { +export const updateUserProfile = ({ + url, + body, +}: { + url: string + body: Record +}): Promise => { return post(url, { body }) } -export const inviteMember = ({ url, body }: { url: string, body: Record }): Promise => { +export const inviteMember = ({ + url, + body, +}: { + url: string + body: Record +}): Promise => { return post(url, { body }) } -export const deleteMemberOrCancelInvitation = ({ url }: { url: string }): Promise => { +export const deleteMemberOrCancelInvitation = ({ + url, +}: { + url: string +}): Promise => { return del(url) } -export const sendOwnerEmail = (body: { language?: string }): Promise => - post('/workspaces/current/members/send-owner-transfer-confirm-email', { body }) +export const sendOwnerEmail = (body: { + language?: string +}): Promise => + post( + '/workspaces/current/members/send-owner-transfer-confirm-email', + { body }, + ) -export const verifyOwnerEmail = (body: { code: string, token: string }): Promise => - post('/workspaces/current/members/owner-transfer-check', { body }) - -export const ownershipTransfer = (memberID: string, body: { token: string }): Promise => - post(`/workspaces/current/members/${memberID}/owner-transfer`, { body }) +export const verifyOwnerEmail = (body: { + code: string + token: string +}): Promise => + post( + '/workspaces/current/members/owner-transfer-check', + { body }, + ) + +export const ownershipTransfer = ( + memberID: string, + body: { token: string }, +): Promise => + post( + `/workspaces/current/members/${memberID}/owner-transfer`, + { body }, + ) export const fetchFilePreview = ({ fileID }: { fileID: string }): Promise<{ content: string }> => { return get<{ content: string }>(`/files/${fileID}/preview`) } -export const updateCurrentWorkspace = ({ url, body }: { url: string, body: Record }): Promise => { +export const updateCurrentWorkspace = ({ + url, + body, +}: { + url: string + body: Record +}): Promise => { return post(url, { body }) } -export const updateWorkspaceInfo = ({ url, body }: { url: string, body: Record }): Promise => { +export const updateWorkspaceInfo = ({ + url, + body, +}: { + url: string + body: Record +}): Promise => { return post(url, { body }) } @@ -93,11 +150,23 @@ type ActivateMemberBody = { timezone?: string } -export const invitationCheck = ({ url, params }: { url: string, params: { workspace_id?: string, email?: string, token: string } }): Promise => { - return get(url, { params }) +export const invitationCheck = ({ + url, + params, +}: { + url: string + params: { workspace_id?: string; email?: string; token: string } +}): Promise => { + return get(url, { params }) } -export const activateMember = ({ url, body }: { url: string, body: ActivateMemberBody }): Promise => { +export const activateMember = ({ + url, + body, +}: { + url: string + body: ActivateMemberBody +}): Promise => { return post(url, { body }) } @@ -113,7 +182,13 @@ export const fetchDefaultModal = (url: string): Promise<{ data: DefaultModelResp return get<{ data: DefaultModelResponse }>(url) } -export const updateDefaultModel = ({ url, body }: { url: string, body: any }): Promise => { +export const updateDefaultModel = ({ + url, + body, +}: { + url: string + body: any +}): Promise => { return post(url, { body }) } @@ -121,24 +196,55 @@ export const fetchModelParameterRules = (url: string): Promise<{ data: ModelPara return get<{ data: ModelParameterRule[] }>(url) } -export const enableModel = (url: string, body: { model: string, model_type: ModelTypeEnum }): Promise => - patch(url, { body }) - -export const disableModel = (url: string, body: { model: string, model_type: ModelTypeEnum }): Promise => - patch(url, { body }) - -export const sendForgotPasswordEmail = ({ url, body }: { url: string, body: { email: string } }): Promise => +export const enableModel = ( + url: string, + body: { model: string; model_type: ModelTypeEnum }, +): Promise => patch(url, { body }) + +export const disableModel = ( + url: string, + body: { model: string; model_type: ModelTypeEnum }, +): Promise => patch(url, { body }) + +export const sendForgotPasswordEmail = ({ + url, + body, +}: { + url: string + body: { email: string } +}): Promise => post(url, { body }) -export const changePasswordWithToken = ({ url, body }: { url: string, body: { token: string, new_password: string, password_confirm: string } }): Promise => - post(url, { body }) -export const changeWebAppPasswordWithToken = ({ url, body }: { url: string, body: { token: string, new_password: string, password_confirm: string } }): Promise => - post(url, { body }, { isPublicAPI: true }) - -export const uploadRemoteFileInfo = (url: string, isPublic?: boolean, silent?: boolean): Promise<{ id: string, name: string, size: number, mime_type: string, url: string }> => { - return post<{ id: string, name: string, size: number, mime_type: string, url: string }>('/remote-files/upload', { body: { url } }, { isPublicAPI: isPublic, silent }) +export const changePasswordWithToken = ({ + url, + body, +}: { + url: string + body: { token: string; new_password: string; password_confirm: string } +}): Promise => post(url, { body }) +export const changeWebAppPasswordWithToken = ({ + url, + body, +}: { + url: string + body: { token: string; new_password: string; password_confirm: string } +}): Promise => post(url, { body }, { isPublicAPI: true }) + +export const uploadRemoteFileInfo = ( + url: string, + isPublic?: boolean, + silent?: boolean, +): Promise<{ id: string; name: string; size: number; mime_type: string; url: string }> => { + return post<{ id: string; name: string; size: number; mime_type: string; url: string }>( + '/remote-files/upload', + { body: { url } }, + { isPublicAPI: isPublic, silent }, + ) } -export const sendEMailLoginCode = (email: string, language = 'en-US'): Promise => +export const sendEMailLoginCode = ( + email: string, + language = 'en-US', +): Promise => post('/email-code-login', { body: { email, language } }) export const emailLoginWithCode = (data: { @@ -147,52 +253,106 @@ export const emailLoginWithCode = (data: { token: string language: string timezone?: string -}): Promise => - post('/email-code-login/validity', { body: data }) +}): Promise => post('/email-code-login/validity', { body: data }) -export const sendResetPasswordCode = (email: string, language = 'en-US'): Promise => - post('/forgot-password', { body: { email, language } }) +export const sendResetPasswordCode = ( + email: string, + language = 'en-US', +): Promise => + post('/forgot-password', { + body: { email, language }, + }) -export const verifyResetPasswordCode = (body: { email: string, code: string, token: string }): Promise => - post('/forgot-password/validity', { body }) - -export const sendWebAppEMailLoginCode = (email: string, language = 'en-US'): Promise => - post('/email-code-login', { body: { email, language } }, { isPublicAPI: true }) - -export const webAppEmailLoginWithCode = (data: { email: string, code: string, token: string }): Promise => +export const verifyResetPasswordCode = (body: { + email: string + code: string + token: string +}): Promise => + post('/forgot-password/validity', { body }) + +export const sendWebAppEMailLoginCode = ( + email: string, + language = 'en-US', +): Promise => + post( + '/email-code-login', + { body: { email, language } }, + { isPublicAPI: true }, + ) + +export const webAppEmailLoginWithCode = (data: { + email: string + code: string + token: string +}): Promise => post('/email-code-login/validity', { body: data }, { isPublicAPI: true }) -export const sendWebAppResetPasswordCode = (email: string, language = 'en-US'): Promise => - post('/forgot-password', { body: { email, language } }, { isPublicAPI: true }) - -export const verifyWebAppResetPasswordCode = (body: { email: string, code: string, token: string }): Promise => - post('/forgot-password/validity', { body }, { isPublicAPI: true }) +export const sendWebAppResetPasswordCode = ( + email: string, + language = 'en-US', +): Promise => + post( + '/forgot-password', + { body: { email, language } }, + { isPublicAPI: true }, + ) + +export const verifyWebAppResetPasswordCode = (body: { + email: string + code: string + token: string +}): Promise => + post( + '/forgot-password/validity', + { body }, + { isPublicAPI: true }, + ) export const sendDeleteAccountCode = (): Promise => get('/account/delete/verify') -export const verifyDeleteAccountCode = (body: { code: string, token: string }): Promise => +export const verifyDeleteAccountCode = (body: { + code: string + token: string +}): Promise => post('/account/delete', { body }) -export const submitDeleteAccountFeedback = (body: { feedback: string, email: string }): Promise => - post('/account/delete/feedback', { body }) +export const submitDeleteAccountFeedback = (body: { + feedback: string + email: string +}): Promise => post('/account/delete/feedback', { body }) export const getDocDownloadUrl = (doc_name: string): Promise<{ url: string }> => get<{ url: string }>('/compliance/download', { params: { doc_name } }, { silent: true }) -export const sendVerifyCode = (body: { email: string, phase: string, token?: string }): Promise => +export const sendVerifyCode = (body: { + email: string + phase: string + token?: string +}): Promise => post('/account/change-email', { body }) -export const verifyEmail = (body: { email: string, code: string, token: string }): Promise => - post('/account/change-email/validity', { body }) +export const verifyEmail = (body: { + email: string + code: string + token: string +}): Promise => + post( + '/account/change-email/validity', + { body }, + ) -export const resetEmail = (body: { new_email: string, token: string }): Promise => +export const resetEmail = (body: { new_email: string; token: string }): Promise => post('/account/change-email/reset', { body }) export const checkEmailExisted = (body: { email: string }): Promise => post('/account/change-email/check-email-unique', { body }, { silent: true }) -export const getAvatar = async ({ avatar }: { avatar: string }): Promise<{ avatar_url: string }> => { +export const getAvatar = async ({ + avatar, +}: { + avatar: string +}): Promise<{ avatar_url: string }> => { const { consoleClient } = await import('./client') return consoleClient.account.avatar.get({ query: { avatar } }) } diff --git a/web/service/console-link.ts b/web/service/console-link.ts index 34f9419b379d14..388b80e3e5bca8 100644 --- a/web/service/console-link.ts +++ b/web/service/console-link.ts @@ -10,15 +10,16 @@ export function createConsoleDynamicLink( function getRouterLink(path: readonly string[]) { const segment = path[0] - if (!segment) - throw new Error('Console contract path is empty.') + if (!segment) throw new Error('Console contract path is empty.') let routerLinkPromise = routerLinkPromises.get(segment) if (!routerLinkPromise) { - routerLinkPromise = loadConsoleContractForSegment(segment).then(createLink).catch((error) => { - routerLinkPromises.delete(segment) - throw error - }) + routerLinkPromise = loadConsoleContractForSegment(segment) + .then(createLink) + .catch((error) => { + routerLinkPromises.delete(segment) + throw error + }) routerLinkPromises.set(segment, routerLinkPromise) } diff --git a/web/service/console-openapi-url.ts b/web/service/console-openapi-url.ts index e6b71087bb2f5f..3a8ddb4456953b 100644 --- a/web/service/console-openapi-url.ts +++ b/web/service/console-openapi-url.ts @@ -17,33 +17,37 @@ const repeatedQueryArrayRules: readonly QueryArrayCompatibilityRule[] = [ { path: /\/datasets\/[^/]+\/documents\/[^/]+\/segments$/, fields: ['segment_id', 'status'] }, { path: /\/trial-apps\/[^/]+\/datasets$/, fields: ['ids'] }, { path: /\/workspaces\/current\/customized-snippets$/, fields: ['tag_ids', 'creators'] }, - { path: /\/workspaces\/current\/tool-provider\/builtin\/[^/]+\/credential\/info$/, fields: ['include_credential_ids'] }, - { path: /\/workspaces\/current\/tool-provider\/builtin\/[^/]+\/credentials$/, fields: ['include_credential_ids'] }, + { + path: /\/workspaces\/current\/tool-provider\/builtin\/[^/]+\/credential\/info$/, + fields: ['include_credential_ids'], + }, + { + path: /\/workspaces\/current\/tool-provider\/builtin\/[^/]+\/credentials$/, + fields: ['include_credential_ids'], + }, ] const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const getRepeatedQueryArrayFields = (pathname: string) => - repeatedQueryArrayRules.find(rule => rule.path.test(pathname))?.fields ?? [] + repeatedQueryArrayRules.find((rule) => rule.path.test(pathname))?.fields ?? [] const rewriteIndexedQueryArrayParam = (url: URL, field: string) => { const indexedParamPattern = new RegExp(`^${escapeRegExp(field)}\\[(\\d+)\\]$`) - const values: Array<{ index: number, value: string }> = [] + const values: Array<{ index: number; value: string }> = [] const indexedKeys = new Set() url.searchParams.forEach((value, key) => { const match = indexedParamPattern.exec(key) - if (!match) - return + if (!match) return indexedKeys.add(key) values.push({ index: Number(match[1]), value }) }) - if (!values.length) - return false + if (!values.length) return false - indexedKeys.forEach(key => url.searchParams.delete(key)) + indexedKeys.forEach((key) => url.searchParams.delete(key)) values .sort((a, b) => a.index - b.index) .forEach(({ value }) => url.searchParams.append(field, value)) @@ -55,10 +59,9 @@ export function normalizeConsoleOpenAPIURL(url: string | URL) { const normalizedUrl = new URL(url) const repeatedQueryArrayFields = getRepeatedQueryArrayFields(normalizedUrl.pathname) - if (!repeatedQueryArrayFields.length) - return normalizedUrl.href + if (!repeatedQueryArrayFields.length) return normalizedUrl.href - repeatedQueryArrayFields.forEach(field => rewriteIndexedQueryArrayParam(normalizedUrl, field)) + repeatedQueryArrayFields.forEach((field) => rewriteIndexedQueryArrayParam(normalizedUrl, field)) return normalizedUrl.href } diff --git a/web/service/console-router-loader.spec.ts b/web/service/console-router-loader.spec.ts index dc1e519d3c531a..94c892ad7cdecc 100644 --- a/web/service/console-router-loader.spec.ts +++ b/web/service/console-router-loader.spec.ts @@ -18,11 +18,14 @@ describe('loadConsoleContractForSegment', () => { ['notification', 'notification', notification], ['tags', 'tags', tags], ['workspaces', 'workspaces', workspaces], - ] as const)('loads the generated %s contract when generated types are usable', async (segment, contractKey, generatedContract) => { - const contract = await loadConsoleContractForSegment(segment) + ] as const)( + 'loads the generated %s contract when generated types are usable', + async (segment, contractKey, generatedContract) => { + const contract = await loadConsoleContractForSegment(segment) - expect(contract).toHaveProperty(contractKey, generatedContract) - }) + expect(contract).toHaveProperty(contractKey, generatedContract) + }, + ) it('loads the generated enterprise contract independently', async () => { const contract = await loadConsoleContractForSegment('enterprise') diff --git a/web/service/console-router-loader.ts b/web/service/console-router-loader.ts index 8f168249b664ef..58989f3e7d9e71 100644 --- a/web/service/console-router-loader.ts +++ b/web/service/console-router-loader.ts @@ -1,12 +1,12 @@ import type { AnyContractRouter } from '@orpc/contract' import { contractLoaders } from '@dify/contracts/api/console/orpc.gen' -const generatedConsoleContractLoaders: Partial Promise>> = contractLoaders +const generatedConsoleContractLoaders: Partial Promise>> = + contractLoaders async function loadGeneratedConsoleContract(segment: string) { const loader = generatedConsoleContractLoaders[segment] - if (!loader) - return null + if (!loader) return null return loader() } @@ -17,12 +17,10 @@ async function loadEnterpriseContract(): Promise { } export async function loadConsoleContractForSegment(segment: string) { - if (segment === 'enterprise') - return loadEnterpriseContract() + if (segment === 'enterprise') return loadEnterpriseContract() const generatedContract = await loadGeneratedConsoleContract(segment) - if (generatedContract) - return generatedContract + if (generatedContract) return generatedContract throw new Error(`Console contract segment "${segment}" is not configured.`) } diff --git a/web/service/datasets.ts b/web/service/datasets.ts index 8139f3f37a82a2..c41bf09f81fba5 100644 --- a/web/service/datasets.ts +++ b/web/service/datasets.ts @@ -1,8 +1,6 @@ import type { CreateExternalAPIReq } from '@/app/components/datasets/external-api/declarations' import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations' -import type { - CreateApiKeyResponse, -} from '@/models/app' +import type { CreateApiKeyResponse } from '@/models/app' import type { CommonResponse } from '@/models/common' import type { CreateDocumentReq, @@ -54,12 +52,29 @@ export const updateDatasetSetting = ({ body, }: { datasetId: string - body: Partial> + body: Partial< + Pick< + DataSet, + | 'name' + | 'description' + | 'permission' + | 'partial_member_list' + | 'indexing_technique' + | 'retrieval_model' + | 'embedding_model' + | 'embedding_model_provider' + | 'icon_info' + | 'doc_form' + > + > }): Promise => { return patch(`/datasets/${datasetId}`, { body }) } -export const fetchDatasets = ({ url, params }: FetchDatasetsParams): Promise => { +export const fetchDatasets = ({ + url, + params, +}: FetchDatasetsParams): Promise => { const urlParams = qs.stringify(params, { indices: false }) return get(`${url}?${urlParams}`) } @@ -69,36 +84,66 @@ export const createEmptyDataset = ({ name }: { name: string }): Promise } export const checkIsUsedInApp = (id: string): Promise<{ is_using: boolean }> => { - return get<{ is_using: boolean }>(`/datasets/${id}/use-check`, {}, { - silent: true, - }) + return get<{ is_using: boolean }>( + `/datasets/${id}/use-check`, + {}, + { + silent: true, + }, + ) } export const deleteDataset = (datasetID: string): Promise => { return del(`/datasets/${datasetID}`) } -export const fetchExternalAPI = ({ apiTemplateId }: { apiTemplateId: string }): Promise => { +export const fetchExternalAPI = ({ + apiTemplateId, +}: { + apiTemplateId: string +}): Promise => { return get(`/datasets/external-knowledge-api/${apiTemplateId}`) } -export const updateExternalAPI = ({ apiTemplateId, body }: { apiTemplateId: string, body: ExternalAPIItem }): Promise => { +export const updateExternalAPI = ({ + apiTemplateId, + body, +}: { + apiTemplateId: string + body: ExternalAPIItem +}): Promise => { return patch(`/datasets/external-knowledge-api/${apiTemplateId}`, { body }) } -export const deleteExternalAPI = ({ apiTemplateId }: { apiTemplateId: string }): Promise => { +export const deleteExternalAPI = ({ + apiTemplateId, +}: { + apiTemplateId: string +}): Promise => { return del(`/datasets/external-knowledge-api/${apiTemplateId}`) } -export const checkUsageExternalAPI = ({ apiTemplateId }: { apiTemplateId: string }): Promise => { +export const checkUsageExternalAPI = ({ + apiTemplateId, +}: { + apiTemplateId: string +}): Promise => { return get(`/datasets/external-knowledge-api/${apiTemplateId}/use-check`) } -export const createExternalAPI = ({ body }: { body: CreateExternalAPIReq }): Promise => { +export const createExternalAPI = ({ + body, +}: { + body: CreateExternalAPIReq +}): Promise => { return post('/datasets/external-knowledge-api', { body }) } -export const createExternalKnowledgeBase = ({ body }: { body: CreateKnowledgeBaseReq }): Promise => { +export const createExternalKnowledgeBase = ({ + body, +}: { + body: CreateKnowledgeBaseReq +}): Promise => { return post('/datasets/external', { body }) } @@ -106,41 +151,82 @@ export const fetchDefaultProcessRule = ({ url }: { url: string }): Promise(url) } -export const createFirstDocument = ({ body }: { body: CreateDocumentReq }): Promise => { +export const createFirstDocument = ({ + body, +}: { + body: CreateDocumentReq +}): Promise => { return post('/datasets/init', { body }) } -export const createDocument = ({ datasetId, body }: { datasetId: string, body: CreateDocumentReq }): Promise => { +export const createDocument = ({ + datasetId, + body, +}: { + datasetId: string + body: CreateDocumentReq +}): Promise => { return post(`/datasets/${datasetId}/documents`, { body }) } -export const fetchIndexingStatus = ({ datasetId, documentId }: CommonDocReq): Promise => { - return get(`/datasets/${datasetId}/documents/${documentId}/indexing-status`, {}) +export const fetchIndexingStatus = ({ + datasetId, + documentId, +}: CommonDocReq): Promise => { + return get( + `/datasets/${datasetId}/documents/${documentId}/indexing-status`, + {}, + ) } -export const fetchIndexingStatusBatch = ({ datasetId, batchId }: BatchReq): Promise => { - return get(`/datasets/${datasetId}/batch/${batchId}/indexing-status`, {}) +export const fetchIndexingStatusBatch = ({ + datasetId, + batchId, +}: BatchReq): Promise => { + return get( + `/datasets/${datasetId}/batch/${batchId}/indexing-status`, + {}, + ) } -export const renameDocumentName = ({ datasetId, documentId, name }: CommonDocReq & { name: string }): Promise => { +export const renameDocumentName = ({ + datasetId, + documentId, + name, +}: CommonDocReq & { name: string }): Promise => { return post(`/datasets/${datasetId}/documents/${documentId}/rename`, { body: { name }, }) } -export const pauseDocIndexing = ({ datasetId, documentId }: CommonDocReq): Promise => { +export const pauseDocIndexing = ({ + datasetId, + documentId, +}: CommonDocReq): Promise => { return patch(`/datasets/${datasetId}/documents/${documentId}/processing/pause`) } -export const resumeDocIndexing = ({ datasetId, documentId }: CommonDocReq): Promise => { +export const resumeDocIndexing = ({ + datasetId, + documentId, +}: CommonDocReq): Promise => { return patch(`/datasets/${datasetId}/documents/${documentId}/processing/resume`) } -export const fetchDocumentDownloadUrl = ({ datasetId, documentId }: CommonDocReq): Promise => { - return get(`/datasets/${datasetId}/documents/${documentId}/download`, {}) +export const fetchDocumentDownloadUrl = ({ + datasetId, + documentId, +}: CommonDocReq): Promise => { + return get( + `/datasets/${datasetId}/documents/${documentId}/download`, + {}, + ) } -export const downloadDocumentsZip = ({ datasetId, documentIds }: DocumentDownloadZipRequest): Promise => { +export const downloadDocumentsZip = ({ + datasetId, + documentIds, +}: DocumentDownloadZipRequest): Promise => { return post(`/datasets/${datasetId}/documents/download-zip`, { body: { document_ids: documentIds, @@ -149,11 +235,21 @@ export const downloadDocumentsZip = ({ datasetId, documentIds }: DocumentDownloa } // hit testing -export const fetchFileIndexingEstimate = (body: IndexingEstimateParams): Promise => { +export const fetchFileIndexingEstimate = ( + body: IndexingEstimateParams, +): Promise => { return post('/datasets/indexing-estimate', { body }) } -export const fetchNotionPagePreview = ({ pageID, pageType, credentialID }: { pageID: string, pageType: string, credentialID: string }): Promise<{ content: string }> => { +export const fetchNotionPagePreview = ({ + pageID, + pageType, + credentialID, +}: { + pageID: string + pageType: string + credentialID: string +}): Promise<{ content: string }> => { return get<{ content: string }>(`notion/pages/${pageID}/${pageType}/preview`, { params: { credential_id: credentialID, @@ -161,11 +257,23 @@ export const fetchNotionPagePreview = ({ pageID, pageType, credentialID }: { pag }) } -export const delApikey = ({ url, params }: { url: string, params: Record }): Promise => { +export const delApikey = ({ + url, + params, +}: { + url: string + params: Record +}): Promise => { return del(url, params) } -export const createApikey = ({ url, body }: { url: string, body: Record }): Promise => { +export const createApikey = ({ + url, + body, +}: { + url: string + body: Record +}): Promise => { return post(url, body) } @@ -179,13 +287,17 @@ export const createFirecrawlTask = (body: Record): Promise => { - return get(`website/crawl/status/${jobId}`, { - params: { - provider: DataSourceProvider.fireCrawl, + return get( + `website/crawl/status/${jobId}`, + { + params: { + provider: DataSourceProvider.fireCrawl, + }, }, - }, { - silent: true, - }) + { + silent: true, + }, + ) } export const createJinaReaderTask = (body: Record): Promise => { @@ -198,13 +310,17 @@ export const createJinaReaderTask = (body: Record): Promise => { - return get(`website/crawl/status/${jobId}`, { - params: { - provider: 'jinareader', + return get( + `website/crawl/status/${jobId}`, + { + params: { + provider: 'jinareader', + }, }, - }, { - silent: true, - }) + { + silent: true, + }, + ) } export const createWatercrawlTask = (body: Record): Promise => { @@ -217,19 +333,29 @@ export const createWatercrawlTask = (body: Record): Promise => { - return get(`website/crawl/status/${jobId}`, { - params: { - provider: DataSourceProvider.waterCrawl, + return get( + `website/crawl/status/${jobId}`, + { + params: { + provider: DataSourceProvider.waterCrawl, + }, }, - }, { - silent: true, - }) + { + silent: true, + }, + ) } export type FileTypesRes = { allowed_extensions: string[] } -export const retryErrorDocs = ({ datasetId, document_ids }: { datasetId: string, document_ids: string[] }): Promise => { +export const retryErrorDocs = ({ + datasetId, + document_ids, +}: { + datasetId: string + document_ids: string[] +}): Promise => { return post(`/datasets/${datasetId}/retry`, { body: { document_ids } }) } diff --git a/web/service/debug.spec.ts b/web/service/debug.spec.ts index 60de7dbaa8f8aa..3ffe633b28fa58 100644 --- a/web/service/debug.spec.ts +++ b/web/service/debug.spec.ts @@ -102,23 +102,40 @@ describe('debug service — generateWorkflow', () => { }) it('sendCompletionMessage', async () => { - const callbacks = { onData: vi.fn(), onCompleted: vi.fn(), onError: vi.fn(), onMessageReplace: vi.fn() } + const callbacks = { + onData: vi.fn(), + onCompleted: vi.fn(), + onError: vi.fn(), + onMessageReplace: vi.fn(), + } await sendCompletionMessage('app-1', { text: 'hello' }, callbacks) - expect(ssePost).toHaveBeenCalledWith('apps/app-1/completion-messages', { - body: { text: 'hello', response_mode: 'streaming' }, - }, callbacks) + expect(ssePost).toHaveBeenCalledWith( + 'apps/app-1/completion-messages', + { + body: { text: 'hello', response_mode: 'streaming' }, + }, + callbacks, + ) }) it('fetchSuggestedQuestions', async () => { const getAbortController = vi.fn() await fetchSuggestedQuestions('app-1', 'msg-1', getAbortController) - expect(get).toHaveBeenCalledWith('apps/app-1/chat-messages/msg-1/suggested-questions', {}, { getAbortController }) + expect(get).toHaveBeenCalledWith( + 'apps/app-1/chat-messages/msg-1/suggested-questions', + {}, + { getAbortController }, + ) }) it('fetchConversationMessages', async () => { const getAbortController = vi.fn() await fetchConversationMessages('app-1', 'conv-1', getAbortController) - expect(get).toHaveBeenCalledWith('apps/app-1/chat-messages', { params: { conversation_id: 'conv-1' } }, { getAbortController }) + expect(get).toHaveBeenCalledWith( + 'apps/app-1/chat-messages', + { params: { conversation_id: 'conv-1' } }, + { getAbortController }, + ) }) it('generateBasicAppFirstTimeRule', async () => { @@ -132,8 +149,18 @@ describe('debug service — generateWorkflow', () => { }) it('generateWorkflowStream', async () => { - const body = { mode: 'workflow' as const, instruction: 'test', model_config: { provider: 'test', name: 'test', mode: 'chat' } } - const callbacks = { onPlan: vi.fn(), onResult: vi.fn(), onError: vi.fn(), onCompleted: vi.fn(), getAbortController: vi.fn() } + const body = { + mode: 'workflow' as const, + instruction: 'test', + model_config: { provider: 'test', name: 'test', mode: 'chat' }, + } + const callbacks = { + onPlan: vi.fn(), + onResult: vi.fn(), + onError: vi.fn(), + onCompleted: vi.fn(), + getAbortController: vi.fn(), + } vi.mocked(sseGeneratorPost).mockImplementation((_url, _body, options) => { options?.onPlan?.({ title: 'plan' }) @@ -150,17 +177,28 @@ describe('debug service — generateWorkflow', () => { it('fetchWorkflowInstructionSuggestions without getAbortController', async () => { await fetchWorkflowInstructionSuggestions({ mode: 'workflow' }) - expect(post).toHaveBeenCalledWith('/workflow-generate/suggestions', { body: { mode: 'workflow' } }) + expect(post).toHaveBeenCalledWith('/workflow-generate/suggestions', { + body: { mode: 'workflow' }, + }) }) it('fetchWorkflowInstructionSuggestions with getAbortController', async () => { const getAbortController = vi.fn() await fetchWorkflowInstructionSuggestions({ mode: 'workflow' }, { getAbortController }) - expect(post).toHaveBeenCalledWith('/workflow-generate/suggestions', { body: { mode: 'workflow' } }, { getAbortController }) + expect(post).toHaveBeenCalledWith( + '/workflow-generate/suggestions', + { body: { mode: 'workflow' } }, + { getAbortController }, + ) }) it('fetchPromptTemplate', async () => { - await fetchPromptTemplate({ appMode: 'chat' as AppModeEnum, mode: 'chat', modelName: 'gpt-4', hasSetDataSet: true }) + await fetchPromptTemplate({ + appMode: 'chat' as AppModeEnum, + mode: 'chat', + modelName: 'gpt-4', + hasSetDataSet: true, + }) expect(get).toHaveBeenCalledWith('/app/prompt-templates', { params: { app_mode: 'chat', diff --git a/web/service/debug.ts b/web/service/debug.ts index b3e534dbdd8849..a01ca238f66907 100644 --- a/web/service/debug.ts +++ b/web/service/debug.ts @@ -24,21 +24,38 @@ export const stopChatMessageResponding = async (appId: string, taskId: string) = return post(`apps/${appId}/chat-messages/${taskId}/stop`) } -export const sendCompletionMessage = async (appId: string, body: Record, { onData, onCompleted, onError, onMessageReplace }: { - onData: IOnData - onCompleted: IOnCompleted - onError: IOnError - onMessageReplace: IOnMessageReplace -}) => { - return ssePost(`apps/${appId}/completion-messages`, { - body: { - ...body, - response_mode: 'streaming', +export const sendCompletionMessage = async ( + appId: string, + body: Record, + { + onData, + onCompleted, + onError, + onMessageReplace, + }: { + onData: IOnData + onCompleted: IOnCompleted + onError: IOnError + onMessageReplace: IOnMessageReplace + }, +) => { + return ssePost( + `apps/${appId}/completion-messages`, + { + body: { + ...body, + response_mode: 'streaming', + }, }, - }, { onData, onCompleted, onError, onMessageReplace }) + { onData, onCompleted, onError, onMessageReplace }, + ) } -export const fetchSuggestedQuestions = (appId: string, messageId: string, getAbortController?: any) => { +export const fetchSuggestedQuestions = ( + appId: string, + messageId: string, + getAbortController?: any, +) => { return get( `apps/${appId}/chat-messages/${messageId}/suggested-questions`, {}, @@ -48,14 +65,22 @@ export const fetchSuggestedQuestions = (appId: string, messageId: string, getAbo ) } -export const fetchConversationMessages = (appId: string, conversation_id: string, getAbortController?: any) => { - return get(`apps/${appId}/chat-messages`, { - params: { - conversation_id, +export const fetchConversationMessages = ( + appId: string, + conversation_id: string, + getAbortController?: any, +) => { + return get( + `apps/${appId}/chat-messages`, + { + params: { + conversation_id, + }, }, - }, { - getAbortController, - }) + { + getAbortController, + }, + ) } export const generateBasicAppFirstTimeRule = (body: Record) => { @@ -85,19 +110,19 @@ export const generateRule = (body: Record) => { // string interpolation (``workflowGenerator.errors.${code}``) rather than // importing the union. Kept here so the ``GenerateWorkflowResponse`` // definition below documents the contract in one place. -type GenerateWorkflowErrorCode - = | 'INVALID_JSON' - | 'INVALID_SCHEMA' - | 'EMPTY_INSTRUCTION' - | 'EMPTY_PLAN' - | 'UNKNOWN_NODE_REFERENCE' - | 'INVALID_CONTAINER' - | 'UNRESOLVED_REFERENCE' - | 'UNKNOWN_TOOL' - | 'MISSING_TERMINAL' - | 'MISSING_START' - | 'DANGLING_EDGE' - | 'MODEL_ERROR' +type GenerateWorkflowErrorCode = + | 'INVALID_JSON' + | 'INVALID_SCHEMA' + | 'EMPTY_INSTRUCTION' + | 'EMPTY_PLAN' + | 'UNKNOWN_NODE_REFERENCE' + | 'INVALID_CONTAINER' + | 'UNRESOLVED_REFERENCE' + | 'UNKNOWN_TOOL' + | 'MISSING_TERMINAL' + | 'MISSING_START' + | 'DANGLING_EDGE' + | 'MODEL_ERROR' type GenerateWorkflowError = { code: GenerateWorkflowErrorCode | string @@ -139,7 +164,12 @@ export type GenerateWorkflowBody = { mode: 'workflow' | 'advanced-chat' | 'auto' instruction: string ideal_output?: string - model_config: { provider: string, name: string, mode: string, completion_params?: Record } + model_config: { + provider: string + name: string + mode: string + completion_params?: Record + } /** * Existing draft graph for the cmd+k `/refine` flow. When present the * backend refines this graph instead of generating from scratch. Omitted @@ -167,9 +197,13 @@ export const generateWorkflow = (body: GenerateWorkflowBody, options?: GenerateW // otherwise the shared ``post()`` wrapper sees ``undefined`` and that // breaks tests asserting the 2-arg call shape, with no behaviour upside. if (options?.getAbortController) { - return post('/workflow-generate', { body }, { - getAbortController: options.getAbortController, - }) + return post( + '/workflow-generate', + { body }, + { + getAbortController: options.getAbortController, + }, + ) } return post('/workflow-generate', { body }) } @@ -222,8 +256,8 @@ export const generateWorkflowStream = ( callbacks: GenerateWorkflowStreamCallbacks, ) => { return sseGeneratorPost('/workflow-generate/stream', body, { - onPlan: data => callbacks.onPlan?.(data as unknown as WorkflowGenPlan), - onResult: data => callbacks.onResult?.(data as unknown as GenerateWorkflowResponse), + onPlan: (data) => callbacks.onPlan?.(data as unknown as WorkflowGenPlan), + onResult: (data) => callbacks.onResult?.(data as unknown as GenerateWorkflowResponse), onError: callbacks.onError, onCompleted: callbacks.onCompleted, getAbortController: callbacks.getAbortController, @@ -254,9 +288,13 @@ export const fetchWorkflowInstructionSuggestions = ( options?: GenerateWorkflowOptions, ) => { if (options?.getAbortController) { - return post('/workflow-generate/suggestions', { body }, { - getAbortController: options.getAbortController, - }) + return post( + '/workflow-generate/suggestions', + { body }, + { + getAbortController: options.getAbortController, + }, + ) } return post('/workflow-generate/suggestions', { body }) } @@ -266,8 +304,19 @@ export const fetchPromptTemplate = ({ mode, modelName, hasSetDataSet, -}: { appMode: AppModeEnum, mode: ModelModeType, modelName: string, hasSetDataSet: boolean }) => { - return get>('/app/prompt-templates', { +}: { + appMode: AppModeEnum + mode: ModelModeType + modelName: string + hasSetDataSet: boolean +}) => { + return get< + Promise<{ + chat_prompt_config: ChatPromptConfig + completion_prompt_config: CompletionPromptConfig + stop: [] + }> + >('/app/prompt-templates', { params: { app_mode: appMode, model_mode: mode, @@ -280,6 +329,9 @@ export const fetchPromptTemplate = ({ export const fetchTextGenerationMessage = ({ appId, messageId, -}: { appId: string, messageId: string }) => { +}: { + appId: string + messageId: string +}) => { return get>(`/apps/${appId}/messages/${messageId}`) } diff --git a/web/service/device-flow.ts b/web/service/device-flow.ts index 2ae4c4ab924d3c..572d099827cada 100644 --- a/web/service/device-flow.ts +++ b/web/service/device-flow.ts @@ -40,28 +40,22 @@ async function failFromResponse(res: Response): Promise { let serverCode = '' try { const body = await res.clone().json() - if (body && typeof body.error === 'string') - serverCode = body.error + if (body && typeof body.error === 'string') serverCode = body.error + } catch { + /* non-JSON body — fall through to status mapping */ } - catch { /* non-JSON body — fall through to status mapping */ } const code = serverCode || statusFallbackCode(res.status) throw new DeviceFlowError(code, res.status) } function statusFallbackCode(status: number): string { - if (status === 429) - return 'rate_limited' - if (status === 401) - return 'no_session' - if (status === 403) - return 'forbidden' - if (status === 404) - return 'not_found' - if (status === 409) - return 'conflict' - if (status >= 500) - return 'server_error' + if (status === 429) return 'rate_limited' + if (status === 401) return 'no_session' + if (status === 403) return 'forbidden' + if (status === 404) return 'not_found' + if (status === 409) return 'conflict' + if (status >= 500) return 'server_error' return 'unknown' } @@ -81,8 +75,7 @@ export async function deviceLookup(user_code: string): Promise { method: 'GET', credentials: 'include', }) - if (!res.ok) - await failFromResponse(res) + if (!res.ok) await failFromResponse(res) return res.json() } @@ -146,6 +136,5 @@ export async function approveExternal(ctx: ApprovalContext, user_code: string): }, body: JSON.stringify({ user_code }), }) - if (!res.ok) - await failFromResponse(res) + if (!res.ok) await failFromResponse(res) } diff --git a/web/service/explore.spec.ts b/web/service/explore.spec.ts index aa98cdb9510e25..4779caaa3c3693 100644 --- a/web/service/explore.spec.ts +++ b/web/service/explore.spec.ts @@ -1,9 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - fetchAppDetail, - fetchAppList, - fetchInstalledAppMeta, -} from './explore' +import { fetchAppDetail, fetchAppList, fetchInstalledAppMeta } from './explore' const mockExploreAppsGet = vi.hoisted(() => vi.fn()) const mockExploreAppDetailGet = vi.hoisted(() => vi.fn()) @@ -37,16 +33,18 @@ describe('explore service normalizers', () => { it('preserves backend app modes that are not part of the legacy frontend enum', async () => { mockExploreAppsGet.mockResolvedValue({ categories: [], - recommended_apps: [{ - app_id: 'agent-app', - app: { - id: 'agent-app', - name: 'Agent app', - mode: 'agent', - icon: '', - icon_background: '', + recommended_apps: [ + { + app_id: 'agent-app', + app: { + id: 'agent-app', + name: 'Agent app', + mode: 'agent', + icon: '', + icon_background: '', + }, }, - }], + ], }) mockExploreAppDetailGet.mockResolvedValue({ id: 'pipeline-app', @@ -58,11 +56,13 @@ describe('explore service normalizers', () => { }) await expect(fetchAppList()).resolves.toMatchObject({ - recommended_apps: [{ - app: { - mode: 'agent', + recommended_apps: [ + { + app: { + mode: 'agent', + }, }, - }], + ], }) await expect(fetchAppDetail('pipeline-app')).resolves.toMatchObject({ mode: 'rag-pipeline', diff --git a/web/service/explore.ts b/web/service/explore.ts index ffc1fe79b61278..8f474b09316a81 100644 --- a/web/service/explore.ts +++ b/web/service/explore.ts @@ -80,15 +80,16 @@ const isAppIconType = (value: unknown): value is AppIconType => { } const isAccessMode = (value: unknown): value is AccessMode => { - return value === AccessMode.PUBLIC - || value === AccessMode.SPECIFIC_GROUPS_MEMBERS - || value === AccessMode.ORGANIZATION - || value === AccessMode.EXTERNAL_MEMBERS + return ( + value === AccessMode.PUBLIC || + value === AccessMode.SPECIFIC_GROUPS_MEMBERS || + value === AccessMode.ORGANIZATION || + value === AccessMode.EXTERNAL_MEMBERS + ) } const normalizeAccessMode = (value: unknown) => { - if (isAccessMode(value)) - return value + if (isAccessMode(value)) return value throw new Error('Web app access mode response returned an unsupported access mode.') } @@ -101,14 +102,16 @@ const normalizeAppBasicInfo = ( source: RecommendedAppInfoResponse | InstalledAppInfoResponse | null | undefined, fallbackId: string, ): App['app'] => { - const description = source && 'description' in source && typeof source.description === 'string' - ? source.description - : '' - const useIconAsAnswerIcon = source - && 'use_icon_as_answer_icon' in source - && typeof source.use_icon_as_answer_icon === 'boolean' - ? source.use_icon_as_answer_icon - : false + const description = + source && 'description' in source && typeof source.description === 'string' + ? source.description + : '' + const useIconAsAnswerIcon = + source && + 'use_icon_as_answer_icon' in source && + typeof source.use_icon_as_answer_icon === 'boolean' + ? source.use_icon_as_answer_icon + : false return { id: source?.id ?? fallbackId, @@ -149,7 +152,9 @@ const normalizeExploreAppsResponse = (response: GetExploreAppsResponse): Explore } } -const normalizeLearnDifyAppsResponse = (response: GetExploreAppsLearnDifyResponse): LearnDifyAppsResponse => { +const normalizeLearnDifyAppsResponse = ( + response: GetExploreAppsLearnDifyResponse, +): LearnDifyAppsResponse => { return { recommended_apps: response.recommended_apps.map(normalizeRecommendedApp), } @@ -176,7 +181,9 @@ const normalizeInstalledApp = (installedApp: InstalledAppResponse): InstalledApp } } -const normalizeInstalledAppsResponse = (response: InstalledAppListResponse): InstalledAppsResponse => { +const normalizeInstalledAppsResponse = ( + response: InstalledAppListResponse, +): InstalledAppsResponse => { return { installed_apps: response.installed_apps.map(normalizeInstalledApp), } @@ -186,9 +193,9 @@ const normalizeBannerContent = (content: unknown): Banner['content'] => { const record = isRecord(content) ? content : {} return { - 'category': getStringProperty(record, 'category'), - 'title': getStringProperty(record, 'title'), - 'description': getStringProperty(record, 'description'), + category: getStringProperty(record, 'category'), + title: getStringProperty(record, 'title'), + description: getStringProperty(record, 'description'), 'img-src': getStringProperty(record, 'img-src'), } } @@ -213,10 +220,8 @@ const normalizeToolIcons = (value: unknown) => { const result: Record = {} Object.entries(record).forEach(([key, item]) => { - if (typeof item === 'string') - result[key] = item - else if (isRecord(item)) - result[key] = item + if (typeof item === 'string') result[key] = item + else if (isRecord(item)) result[key] = item }) return result @@ -233,8 +238,7 @@ const isTtsAutoPlay = (value: unknown): value is TtsAutoPlay => { } const isUserInputFormItem = (value: unknown): value is ChatConfig['user_input_form'][number] => { - if (!isRecord(value)) - return false + if (!isRecord(value)) return false return [ 'text-input', @@ -246,14 +250,18 @@ const isUserInputFormItem = (value: unknown): value is ChatConfig['user_input_fo 'file-list', 'external_data_tool', 'json_object', - ].some(key => isRecord(getValue(value, key))) + ].some((key) => isRecord(getValue(value, key))) } -const isModel = (value: unknown): value is NonNullable => { +const isModel = ( + value: unknown, +): value is NonNullable => { return isRecord(value) } -const isAnnotationReplyConfig = (value: unknown): value is NonNullable => { +const isAnnotationReplyConfig = ( + value: unknown, +): value is NonNullable => { return isRecord(value) } @@ -334,12 +342,16 @@ const normalizeInstalledAppParametersViewModel = ( prompt_type: PromptMode.simple, user_input_form: response.user_input_form.filter(isUserInputFormItem), more_like_this: normalizeEnabledConfig(response.more_like_this), - suggested_questions_after_answer: normalizeSuggestedQuestionsAfterAnswer(response.suggested_questions_after_answer), + suggested_questions_after_answer: normalizeSuggestedQuestionsAfterAnswer( + response.suggested_questions_after_answer, + ), speech_to_text: normalizeEnabledConfig(response.speech_to_text), text_to_speech: normalizeTextToSpeech(response.text_to_speech), retriever_resource: normalizeEnabledConfig(response.retriever_resource), sensitive_word_avoidance: normalizeEnabledConfig(response.sensitive_word_avoidance), - ...(isAnnotationReplyConfig(response.annotation_reply) ? { annotation_reply: response.annotation_reply } : {}), + ...(isAnnotationReplyConfig(response.annotation_reply) + ? { annotation_reply: response.annotation_reply } + : {}), agent_mode: { enabled: false, tools: [], @@ -351,39 +363,42 @@ const normalizeInstalledAppParametersViewModel = ( } export const fetchAppList = (language?: string) => { - if (!language) - return consoleClient.explore.apps.get({}).then(normalizeExploreAppsResponse) + if (!language) return consoleClient.explore.apps.get({}).then(normalizeExploreAppsResponse) - return consoleClient.explore.apps.get({ - query: { language }, - }).then(normalizeExploreAppsResponse) + return consoleClient.explore.apps + .get({ + query: { language }, + }) + .then(normalizeExploreAppsResponse) } export const fetchLearnDifyAppList = (language?: string) => { if (!language) return consoleClient.explore.apps.learnDify.get({}).then(normalizeLearnDifyAppsResponse) - return consoleClient.explore.apps.learnDify.get({ - query: { language }, - }).then(normalizeLearnDifyAppsResponse) + return consoleClient.explore.apps.learnDify + .get({ + query: { language }, + }) + .then(normalizeLearnDifyAppsResponse) } export const fetchAppDetail = async (id: string): Promise => { const response = await consoleClient.explore.apps.byAppId.get({ params: { app_id: id }, }) - if (!response) - throw new Error('Recommended app not found') + if (!response) throw new Error('Recommended app not found') return normalizeAppDetail(response) } export const fetchInstalledAppList = (appId?: string | null) => { - if (!appId) - return consoleClient.installedApps.get({}).then(normalizeInstalledAppsResponse) + if (!appId) return consoleClient.installedApps.get({}).then(normalizeInstalledAppsResponse) - return consoleClient.installedApps.get({ - query: { app_id: appId }, - }).then(normalizeInstalledAppsResponse) + return consoleClient.installedApps + .get({ + query: { app_id: appId }, + }) + .then(normalizeInstalledAppsResponse) } export const uninstallApp = (id: string) => { @@ -402,30 +417,41 @@ export const updatePinStatus = (id: string, isPinned: boolean) => { } export const getAppAccessModeByAppId = (appId: string) => { - return consoleClient.enterprise.webAppAuth.getWebAppAccessMode({ - query: { appId }, - }).then((response): AppAccessModeResponse => ({ - accessMode: normalizeAccessMode(isRecord(response) ? getValue(response, 'accessMode') : undefined), - })) + return consoleClient.enterprise.webAppAuth + .getWebAppAccessMode({ + query: { appId }, + }) + .then( + (response): AppAccessModeResponse => ({ + accessMode: normalizeAccessMode( + isRecord(response) ? getValue(response, 'accessMode') : undefined, + ), + }), + ) } export const fetchInstalledAppParams = (appId: string) => { - return consoleClient.installedApps.byInstalledAppId.parameters.get({ - params: { installed_app_id: appId }, - }).then(normalizeInstalledAppParametersViewModel) + return consoleClient.installedApps.byInstalledAppId.parameters + .get({ + params: { installed_app_id: appId }, + }) + .then(normalizeInstalledAppParametersViewModel) } export const fetchInstalledAppMeta = (appId: string) => { - return consoleClient.installedApps.byInstalledAppId.meta.get({ - params: { installed_app_id: appId }, - }).then(normalizeAppMeta) + return consoleClient.installedApps.byInstalledAppId.meta + .get({ + params: { installed_app_id: appId }, + }) + .then(normalizeAppMeta) } export const fetchBanners = (language?: string) => { - if (!language) - return consoleClient.explore.banners.get({}).then(normalizeBannersResponse) + if (!language) return consoleClient.explore.banners.get({}).then(normalizeBannersResponse) - return consoleClient.explore.banners.get({ - query: { language }, - }).then(normalizeBannersResponse) + return consoleClient.explore.banners + .get({ + query: { language }, + }) + .then(normalizeBannersResponse) } diff --git a/web/service/fetch.spec.ts b/web/service/fetch.spec.ts index 8f8f06c82b8979..ee8dd0b88e34cd 100644 --- a/web/service/fetch.spec.ts +++ b/web/service/fetch.spec.ts @@ -38,8 +38,7 @@ describe('base', () => { let caughtError: unknown try { await base('/login') - } - catch (error) { + } catch (error) { caughtError = error } diff --git a/web/service/fetch.ts b/web/service/fetch.ts index 597e0be1babc7e..2d5587757bb29a 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -3,7 +3,17 @@ import type { IOtherOptions } from './base' import { toast } from '@langgenius/dify-ui/toast' import Cookies from 'js-cookie' import ky, { HTTPError } from 'ky' -import { API_PREFIX, APP_VERSION, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_MARKETPLACE, MARKETPLACE_API_PREFIX, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config' +import { + API_PREFIX, + APP_VERSION, + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, + IS_MARKETPLACE, + MARKETPLACE_API_PREFIX, + PASSPORT_HEADER_NAME, + PUBLIC_API_PREFIX, + WEB_APP_SHARE_CODE_HEADER_NAME, +} from '@/config' import { getWebAppAccessToken, getWebAppPassport } from './webapp-auth' const TIME_OUT = 100000 @@ -44,13 +54,10 @@ const createResponseFromHTTPError = (error: HTTPError): Response => { headers.delete('content-length') let body: BodyInit | null = null - if (typeof error.data === 'string') - body = error.data - else if (error.data !== undefined) - body = JSON.stringify(error.data) + if (typeof error.data === 'string') body = error.data + else if (error.data !== undefined) body = JSON.stringify(error.data) - if (body !== null && !headers.has('content-type')) - headers.set('content-type', ContentType.json) + if (body !== null && !headers.has('content-type')) headers.set('content-type', ContentType.json) return new Response(body, { status: error.response.status, @@ -66,13 +73,11 @@ const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook try { const data: unknown = await response.clone().json() errorData = data as ResponseError - } - catch {} + } catch {} const shouldNotifyError = response.status !== 401 && errorData && !otherOptions.silent const errorMessage = errorData?.message || errorData?.error - if (shouldNotifyError && errorMessage) - toast.error(errorMessage) + if (shouldNotifyError && errorMessage) toast.error(errorMessage) if (response.status === 403 && errorData?.code === 'already_setup') globalThis.location.href = `${globalThis.location.origin}/signin` @@ -85,19 +90,16 @@ const SHARE_ROUTE_DENY_LIST = new Set(['webapp-signin', 'check-code', 'login']) const resolveShareCode = () => { const pathnameSegments = globalThis.location.pathname.split('/').filter(Boolean) const lastSegment = pathnameSegments.at(-1) || '' - if (lastSegment && !SHARE_ROUTE_DENY_LIST.has(lastSegment)) - return lastSegment + if (lastSegment && !SHARE_ROUTE_DENY_LIST.has(lastSegment)) return lastSegment const redirectParam = new URLSearchParams(globalThis.location.search).get('redirect_url') - if (!redirectParam) - return '' + if (!redirectParam) return '' try { const redirectUrl = new URL(decodeURIComponent(redirectParam), globalThis.location.origin) const redirectSegments = redirectUrl.pathname.split('/').filter(Boolean) const redirectSegment = redirectSegments.at(-1) || '' return SHARE_ROUTE_DENY_LIST.has(redirectSegment) ? '' : redirectSegment - } - catch { + } catch { return '' } } @@ -105,22 +107,17 @@ const resolveShareCode = () => { const beforeRequestPublicWithCode: BeforeRequestHook = ({ request }) => { if (!request.headers.has('Authorization')) { const accessToken = getWebAppAccessToken() - if (accessToken) - request.headers.set('Authorization', `Bearer ${accessToken}`) - else - request.headers.delete('Authorization') + if (accessToken) request.headers.set('Authorization', `Bearer ${accessToken}`) + else request.headers.delete('Authorization') } const shareCode = resolveShareCode() - if (!shareCode) - return + if (!shareCode) return request.headers.set(WEB_APP_SHARE_CODE_HEADER_NAME, shareCode) request.headers.set(PASSPORT_HEADER_NAME, getWebAppPassport(shareCode)) } const baseHooks: Hooks = { - afterResponse: [ - afterResponse204, - ], + afterResponse: [afterResponse204], } const baseClient = ky.create({ @@ -138,15 +135,19 @@ export const getBaseOptions = (): RequestInit => ({ redirect: 'follow', }) -async function base(url: string, options: FetchOptionType = {}, otherOptions: IOtherOptions = {}): Promise { +async function base( + url: string, + options: FetchOptionType = {}, + otherOptions: IOtherOptions = {}, +): Promise { // In fetchCompat mode, skip baseOptions to avoid overriding Request object's method, headers, const baseOptions = otherOptions.fetchCompat - ? { + ? ({ mode: 'cors', credentials: 'include', // always send cookies、HTTP Basic authentication. redirect: 'follow', - } as const - : { + } as const) + : ({ mode: 'cors', credentials: 'include', // always send cookies、HTTP Basic authentication. headers: new Headers({ @@ -154,7 +155,7 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: }), method: 'GET', redirect: 'follow', - } as const + } as const) const { params, body, headers: headersFromProps, ...init } = { ...baseOptions, ...options } const { @@ -171,12 +172,9 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: const headers = new Headers(headersFromProps || {}) let base: string - if (isMarketplaceAPI) - base = MARKETPLACE_API_PREFIX - else if (isPublicAPI) - base = PUBLIC_API_PREFIX - else - base = API_PREFIX + if (isMarketplaceAPI) base = MARKETPLACE_API_PREFIX + else if (isPublicAPI) base = PUBLIC_API_PREFIX + else base = API_PREFIX if (getAbortController) { const abortController = new AbortController() @@ -185,27 +183,21 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: } const fetchPathname = base + (url.startsWith('/') ? url : `/${url}`) - if (!isMarketplaceAPI) - headers.set(CSRF_HEADER_NAME, Cookies.get(CSRF_COOKIE_NAME()) || '') + if (!isMarketplaceAPI) headers.set(CSRF_HEADER_NAME, Cookies.get(CSRF_COOKIE_NAME()) || '') - if (deleteContentType) - headers.delete('Content-Type') + if (deleteContentType) headers.delete('Content-Type') // ! For Marketplace API, help to filter tags added in new version - if (isMarketplaceAPI) - headers.set('X-Dify-Version', !IS_MARKETPLACE ? APP_VERSION : '999.0.0') + if (isMarketplaceAPI) headers.set('X-Dify-Version', !IS_MARKETPLACE ? APP_VERSION : '999.0.0') const client = baseClient.extend({ hooks: { ...baseHooks, beforeRequest: [ - ...baseHooks.beforeRequest || [], + ...(baseHooks.beforeRequest || []), isPublicAPI && beforeRequestPublicWithCode, ].filter((h): h is BeforeRequestHook => Boolean(h)), - afterResponse: [ - ...baseHooks.afterResponse || [], - afterResponseErrorCode(otherOptions), - ], + afterResponse: [...(baseHooks.afterResponse || []), afterResponseErrorCode(otherOptions)], }, }) @@ -214,9 +206,7 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: res = await client(request || fetchPathname, { ...init, headers, - credentials: isMarketplaceAPI - ? 'omit' - : (options.credentials || 'include'), + credentials: isMarketplaceAPI ? 'omit' : options.credentials || 'include', retry: { methods: [], }, @@ -233,24 +223,21 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: return globalThis.fetch(resource, options) }, }) - } - catch (error) { - if (error instanceof HTTPError) - throw createResponseFromHTTPError(error) + } catch (error) { + if (error instanceof HTTPError) throw createResponseFromHTTPError(error) throw error } - if (needAllResponseContent || fetchCompat) - return res as T + if (needAllResponseContent || fetchCompat) return res as T const contentType = res.headers.get('content-type') if ( - contentType - && [ContentType.download, ContentType.audio, ContentType.downloadZip].includes(contentType) + contentType && + [ContentType.download, ContentType.audio, ContentType.downloadZip].includes(contentType) ) { - return await res.blob() as T + return (await res.blob()) as T } - return await res.json() as T + return (await res.json()) as T } /** @@ -267,16 +254,17 @@ export function postWithKeepalive(url: string, body: Record): v // Add Authorization header if an access token is available const accessToken = getWebAppAccessToken() - if (accessToken) - headers.set('Authorization', `Bearer ${accessToken}`) + if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`) - globalThis.fetch(url, { - method: 'POST', - keepalive: true, - credentials: 'include', - headers, - body: JSON.stringify(body), - }).catch(() => {}) + globalThis + .fetch(url, { + method: 'POST', + keepalive: true, + credentials: 'include', + headers, + body: JSON.stringify(body), + }) + .catch(() => {}) } export { base } diff --git a/web/service/knowledge/use-create-dataset.ts b/web/service/knowledge/use-create-dataset.ts index 9c511b5fd6ac19..963b2f09e58c76 100644 --- a/web/service/knowledge/use-create-dataset.ts +++ b/web/service/knowledge/use-create-dataset.ts @@ -20,14 +20,16 @@ import type { import { useMutation } from '@tanstack/react-query' import { groupBy } from 'es-toolkit/compat' import { post } from '../base' -import { createDocument, createFirstDocument, fetchDefaultProcessRule, fetchFileIndexingEstimate } from '../datasets' +import { + createDocument, + createFirstDocument, + fetchDefaultProcessRule, + fetchFileIndexingEstimate, +} from '../datasets' const NAME_SPACE = 'knowledge/create-dataset' -export const getNotionInfo = ( - notionPages: NotionPage[], - credentialId: string, -) => { +export const getNotionInfo = (notionPages: NotionPage[], credentialId: string) => { const workspacesMap = groupBy(notionPages, 'workspace_id') const workspaces = Object.keys(workspacesMap).map((workspaceId) => { return { @@ -52,19 +54,17 @@ export const getNotionInfo = ( }) as NotionInfo[] } -export const getWebsiteInfo = ( - opts: { - websiteCrawlProvider: DataSourceProvider - websiteCrawlJobId: string - websitePages: CrawlResultItem[] - crawlOptions?: CrawlOptions - }, -) => { +export const getWebsiteInfo = (opts: { + websiteCrawlProvider: DataSourceProvider + websiteCrawlJobId: string + websitePages: CrawlResultItem[] + crawlOptions?: CrawlOptions +}) => { const { websiteCrawlProvider, websiteCrawlJobId, websitePages, crawlOptions } = opts return { provider: websiteCrawlProvider, job_id: websiteCrawlJobId, - urls: websitePages.map(page => page.source_url), + urls: websitePages.map((page) => page.source_url), only_main_content: crawlOptions?.only_main_content, } } @@ -91,9 +91,7 @@ const getFileIndexingEstimateParamsForFile = ({ processRule, dataset_id, }: GetFileIndexingEstimateParamsOptionFile): IndexingEstimateParams => { - const fileIds = files - .map(file => file.id) - .filter((id): id is string => Boolean(id)) + const fileIds = files.map((file) => file.id).filter((id): id is string => Boolean(id)) return { info_list: { @@ -217,8 +215,7 @@ export const useCreateFirstDocument = ( mutationOptions: MutationOptions = {}, ) => { return useMutation({ - mutationFn: async (createDocumentReq: CreateDocumentReq, - ) => { + mutationFn: async (createDocumentReq: CreateDocumentReq) => { return createFirstDocument({ body: createDocumentReq }) }, ...mutationOptions, diff --git a/web/service/knowledge/use-dataset.ts b/web/service/knowledge/use-dataset.ts index b606a4bfb95d5a..f2919db02fd484 100644 --- a/web/service/knowledge/use-dataset.ts +++ b/web/service/knowledge/use-dataset.ts @@ -30,14 +30,7 @@ const NAME_SPACE = 'dataset' const datasetListQueryKey = [NAME_SPACE, 'list'] const normalizeDatasetsParams = (params: Partial = {}) => { - const { - page = 1, - limit, - ids, - tag_ids, - include_all, - keyword, - } = params + const { page = 1, limit, ids, tag_ids, include_all, keyword } = params return { page, @@ -66,13 +59,16 @@ export const useInfiniteDatasets = ( return useInfiniteQuery({ queryKey: [...datasetListQueryKey, 'infinite', normalizedParams], queryFn: ({ pageParam = normalizedParams.page }) => { - const queryString = qs.stringify({ - ...normalizedParams, - page: pageParam as number | undefined, - }, { indices: false }) + const queryString = qs.stringify( + { + ...normalizedParams, + page: pageParam as number | undefined, + }, + { indices: false }, + ) return get(`/datasets?${queryString}`) }, - getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), initialPageParam: normalizedParams.page, staleTime: 0, refetchOnMount: 'always', @@ -85,16 +81,19 @@ export const useDatasetList = (params: DatasetListRequest) => { return useInfiniteQuery({ queryKey: [...datasetListQueryKey, initialPage, tag_ids, limit, include_all, keyword], queryFn: ({ pageParam = 1 }) => { - const urlParams = qs.stringify({ - tag_ids, - limit, - include_all, - keyword, - page: pageParam, - }, { indices: false }) + const urlParams = qs.stringify( + { + tag_ids, + limit, + include_all, + keyword, + page: pageParam, + }, + { indices: false }, + ) return get(`/datasets?${urlParams}`) }, - getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : null, + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : null), initialPageParam: initialPage, placeholderData: keepPreviousData, }) @@ -112,8 +111,7 @@ export const useDatasetDetail = (datasetId: string) => { queryFn: () => get(`/datasets/${datasetId}`), enabled: !!datasetId, retry: (failureCount, error) => { - if (error instanceof Response && [403, 404].includes(error.status)) - return false + if (error instanceof Response && [403, 404].includes(error.status)) return false return failureCount < 3 }, @@ -135,7 +133,8 @@ export const useIndexingStatusBatch = ( const { datasetId, batchId } = params return useMutation({ mutationKey: [NAME_SPACE, 'indexing-status-batch', datasetId, batchId], - mutationFn: () => get(`/datasets/${datasetId}/batch/${batchId}/indexing-status`), + mutationFn: () => + get(`/datasets/${datasetId}/batch/${batchId}/indexing-status`), ...mutationOptions, }) } @@ -143,7 +142,8 @@ export const useIndexingStatusBatch = ( export const useProcessRule = (documentId?: string) => { return useQuery({ queryKey: [NAME_SPACE, 'process-rule', documentId], - queryFn: () => get('/datasets/process-rule', { params: { document_id: documentId } }), + queryFn: () => + get('/datasets/process-rule', { params: { document_id: documentId } }), enabled: !!documentId, refetchOnWindowFocus: false, }) @@ -159,14 +159,16 @@ export const useDatasetApiBaseUrl = () => { export const useEnableDatasetServiceApi = () => { return useMutation({ mutationKey: [NAME_SPACE, 'enable-api'], - mutationFn: (datasetId: string) => post(`/datasets/${datasetId}/api-keys/enable`), + mutationFn: (datasetId: string) => + post(`/datasets/${datasetId}/api-keys/enable`), }) } export const useDisableDatasetServiceApi = () => { return useMutation({ mutationKey: [NAME_SPACE, 'disable-api'], - mutationFn: (datasetId: string) => post(`/datasets/${datasetId}/api-keys/disable`), + mutationFn: (datasetId: string) => + post(`/datasets/${datasetId}/api-keys/disable`), }) } @@ -197,7 +199,7 @@ export const useExternalKnowledgeApiList = (options?: { enabled?: boolean }) => export const useDatasetTestingRecords = ( datasetId?: string, - params?: { page: number, limit: number }, + params?: { page: number; limit: number }, options?: { enabled?: boolean }, ) => { return useQuery({ diff --git a/web/service/knowledge/use-document.ts b/web/service/knowledge/use-document.ts index 811ed1aa49f2ef..8ff809e911c673 100644 --- a/web/service/knowledge/use-document.ts +++ b/web/service/knowledge/use-document.ts @@ -1,16 +1,26 @@ import type { UseQueryOptions } from '@tanstack/react-query' -import type { DocumentDownloadResponse, DocumentDownloadZipRequest, MetadataType, SortType } from '../datasets' +import type { + DocumentDownloadResponse, + DocumentDownloadZipRequest, + MetadataType, + SortType, +} from '../datasets' import type { CommonResponse } from '@/models/common' -import type { DocumentDetailResponse, DocumentListResponse, UpdateDocumentBatchParams } from '@/models/datasets' -import { - keepPreviousData, - useMutation, - useQuery, -} from '@tanstack/react-query' +import type { + DocumentDetailResponse, + DocumentListResponse, + UpdateDocumentBatchParams, +} from '@/models/datasets' +import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query' import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter' import { DocumentActionType } from '@/models/datasets' import { del, get, patch, post } from '../base' -import { downloadDocumentsZip, fetchDocumentDownloadUrl, pauseDocIndexing, resumeDocIndexing } from '../datasets' +import { + downloadDocumentsZip, + fetchDocumentDownloadUrl, + pauseDocIndexing, + resumeDocIndexing, +} from '../datasets' import { useInvalid } from '../use-base' const NAME_SPACE = 'knowledge/document' @@ -37,15 +47,14 @@ export const useDocumentList = (payload: { page, limit, } - if (sort) - params.sort = sort - if (normalizedStatus && normalizedStatus !== 'all') - params.status = normalizedStatus + if (sort) params.sort = sort + if (normalizedStatus && normalizedStatus !== 'all') params.status = normalizedStatus return useQuery({ queryKey: [...useDocumentListKey, datasetId, params], - queryFn: () => get(`/datasets/${datasetId}/documents`, { - params, - }), + queryFn: () => + get(`/datasets/${datasetId}/documents`, { + params, + }), placeholderData: keepPreviousData, refetchInterval, }) @@ -69,13 +78,15 @@ export const useInvalidDisabledDocument = () => { const toBatchDocumentsIdParams = (documentIds: string[] | string) => { const ids = Array.isArray(documentIds) ? documentIds : [documentIds] - return ids.map(id => `document_id=${id}`).join('&') + return ids.map((id) => `document_id=${id}`).join('&') } const useDocumentBatchAction = (action: DocumentActionType) => { return useMutation({ mutationFn: ({ datasetId, documentIds, documentId }: UpdateDocumentBatchParams) => { - return patch(`/datasets/${datasetId}/documents/status/${action}/batch?${toBatchDocumentsIdParams(documentId || documentIds!)}`) + return patch( + `/datasets/${datasetId}/documents/status/${action}/batch?${toBatchDocumentsIdParams(documentId || documentIds!)}`, + ) }, }) } @@ -99,7 +110,9 @@ export const useDocumentUnArchive = () => { export const useDocumentDelete = () => { return useMutation({ mutationFn: ({ datasetId, documentIds, documentId }: UpdateDocumentBatchParams) => { - return del(`/datasets/${datasetId}/documents?${toBatchDocumentsIdParams(documentId || documentIds!)}`) + return del( + `/datasets/${datasetId}/documents?${toBatchDocumentsIdParams(documentId || documentIds!)}`, + ) }, }) } @@ -144,7 +157,8 @@ export const useDocumentDetail = (payload: { const { datasetId, documentId, params, refetchInterval } = payload return useQuery({ queryKey: [...useDocumentDetailKey, 'withoutMetaData', datasetId, documentId, params], - queryFn: () => get(`/datasets/${datasetId}/documents/${documentId}`, { params }), + queryFn: () => + get(`/datasets/${datasetId}/documents/${documentId}`, { params }), refetchInterval, }) } @@ -157,7 +171,8 @@ export const useDocumentMetadata = (payload: { const { datasetId, documentId, params } = payload return useQuery({ queryKey: [...useDocumentDetailKey, 'onlyMetaData', datasetId, documentId, params], - queryFn: () => get(`/datasets/${datasetId}/documents/${documentId}`, { params }), + queryFn: () => + get(`/datasets/${datasetId}/documents/${documentId}`, { params }), }) } @@ -168,8 +183,7 @@ export const useInvalidDocumentDetail = () => { export const useDocumentPause = () => { return useMutation({ mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { - if (!datasetId || !documentId) - throw new Error('datasetId and documentId are required') + if (!datasetId || !documentId) throw new Error('datasetId and documentId are required') return pauseDocIndexing({ datasetId, documentId }) as Promise }, }) @@ -178,8 +192,7 @@ export const useDocumentPause = () => { export const useDocumentResume = () => { return useMutation({ mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { - if (!datasetId || !documentId) - throw new Error('datasetId and documentId are required') + if (!datasetId || !documentId) throw new Error('datasetId and documentId are required') return resumeDocIndexing({ datasetId, documentId }) as Promise }, }) @@ -188,9 +201,11 @@ export const useDocumentResume = () => { export const useDocumentDownload = () => { return useMutation({ mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { - if (!datasetId || !documentId) - throw new Error('datasetId and documentId are required') - return fetchDocumentDownloadUrl({ datasetId, documentId }) as Promise + if (!datasetId || !documentId) throw new Error('datasetId and documentId are required') + return fetchDocumentDownloadUrl({ + datasetId, + documentId, + }) as Promise }, }) } @@ -207,7 +222,7 @@ export const useDocumentDownloadZip = () => { export const useDocumentBatchRetryIndex = () => { return useMutation({ - mutationFn: ({ datasetId, documentIds }: { datasetId: string, documentIds: string[] }) => { + mutationFn: ({ datasetId, documentIds }: { datasetId: string; documentIds: string[] }) => { return post(`/datasets/${datasetId}/retry`, { body: { document_ids: documentIds, diff --git a/web/service/knowledge/use-hit-testing.ts b/web/service/knowledge/use-hit-testing.ts index 97442e50e53d9c..94667a912c71f2 100644 --- a/web/service/knowledge/use-hit-testing.ts +++ b/web/service/knowledge/use-hit-testing.ts @@ -12,17 +12,19 @@ const NAME_SPACE = 'hit-testing' export const useHitTesting = (datasetId: string) => { return useMutation({ mutationKey: [NAME_SPACE, 'hit-testing', datasetId], - mutationFn: (params: HitTestingRequest) => post(`/datasets/${datasetId}/hit-testing`, { - body: params, - }), + mutationFn: (params: HitTestingRequest) => + post(`/datasets/${datasetId}/hit-testing`, { + body: params, + }), }) } export const useExternalKnowledgeBaseHitTesting = (datasetId: string) => { return useMutation({ mutationKey: [NAME_SPACE, 'external-knowledge-base-hit-testing', datasetId], - mutationFn: (params: ExternalKnowledgeBaseHitTestingRequest) => post(`/datasets/${datasetId}/external-hit-testing`, { - body: params, - }), + mutationFn: (params: ExternalKnowledgeBaseHitTestingRequest) => + post(`/datasets/${datasetId}/external-hit-testing`, { + body: params, + }), }) } diff --git a/web/service/knowledge/use-import.ts b/web/service/knowledge/use-import.ts index 0fc7bc9dfa7717..4194f79e970f8f 100644 --- a/web/service/knowledge/use-import.ts +++ b/web/service/knowledge/use-import.ts @@ -29,14 +29,9 @@ export const usePreImportNotionPages = ({ export const useInvalidPreImportNotionPages = () => { const queryClient = useQueryClient() - return ({ - datasetId, - credentialId, - }: PreImportNotionPagesParams) => { - queryClient.invalidateQueries( - { - queryKey: [PRE_IMPORT_NOTION_PAGES_QUERY_KEY, datasetId, credentialId], - }, - ) + return ({ datasetId, credentialId }: PreImportNotionPagesParams) => { + queryClient.invalidateQueries({ + queryKey: [PRE_IMPORT_NOTION_PAGES_QUERY_KEY, datasetId, credentialId], + }) } } diff --git a/web/service/knowledge/use-metadata.spec.tsx b/web/service/knowledge/use-metadata.spec.tsx index 0ab48254821d2a..b36c100f196046 100644 --- a/web/service/knowledge/use-metadata.spec.tsx +++ b/web/service/knowledge/use-metadata.spec.tsx @@ -38,13 +38,25 @@ describe('useBatchUpdateDocMetadata', () => { { document_id: 'doc-1', metadata_list: [ - { key: 'title-1', id: '01', name: 'name-1', type: DataType.string, value: 'new title 01' }, + { + key: 'title-1', + id: '01', + name: 'name-1', + type: DataType.string, + value: 'new title 01', + }, ], }, { document_id: 'doc-2', metadata_list: [ - { key: 'title-2', id: '02', name: 'name-1', type: DataType.string, value: 'new title 02' }, + { + key: 'title-2', + id: '02', + name: 'name-1', + type: DataType.string, + value: 'new title 02', + }, ], }, ], diff --git a/web/service/knowledge/use-metadata.ts b/web/service/knowledge/use-metadata.ts index 5f802c2e531d28..adf9631fe7e0de 100644 --- a/web/service/knowledge/use-metadata.ts +++ b/web/service/knowledge/use-metadata.ts @@ -1,4 +1,8 @@ -import type { BuiltInMetadataItem, MetadataBatchEditToServer, MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types' +import type { + BuiltInMetadataItem, + MetadataBatchEditToServer, + MetadataItemWithValueLength, +} from '@/app/components/datasets/metadata/types' import type { DocumentDetailResponse } from '@/models/datasets' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { del, get, patch, post } from '../base' @@ -8,12 +12,17 @@ import { useDocumentListKey, useInvalidDocumentList } from './use-document' const NAME_SPACE = 'dataset-metadata' export const useDatasetMetaData = (datasetId: string) => { - return useQuery<{ doc_metadata: MetadataItemWithValueLength[], built_in_field_enabled: boolean }>({ - queryKey: [NAME_SPACE, 'dataset', datasetId], - queryFn: () => { - return get<{ doc_metadata: MetadataItemWithValueLength[], built_in_field_enabled: boolean }>(`/datasets/${datasetId}/metadata`) + return useQuery<{ doc_metadata: MetadataItemWithValueLength[]; built_in_field_enabled: boolean }>( + { + queryKey: [NAME_SPACE, 'dataset', datasetId], + queryFn: () => { + return get<{ + doc_metadata: MetadataItemWithValueLength[] + built_in_field_enabled: boolean + }>(`/datasets/${datasetId}/metadata`) + }, }, - }) + ) } const useInvalidDatasetMetaData = (datasetId: string) => { @@ -91,11 +100,19 @@ export const useBuiltInMetaDataFields = () => { }) } -export const useDocumentMetaData = ({ datasetId, documentId }: { datasetId: string, documentId: string }) => { +export const useDocumentMetaData = ({ + datasetId, + documentId, +}: { + datasetId: string + documentId: string +}) => { return useQuery({ queryKey: [NAME_SPACE, 'document', datasetId, documentId], queryFn: () => { - return get(`/datasets/${datasetId}/documents/${documentId}`, { params: { metadata: 'only' } }) + return get(`/datasets/${datasetId}/documents/${documentId}`, { + params: { metadata: 'only' }, + }) }, }) } @@ -107,7 +124,7 @@ export const useBatchUpdateDocMetadata = () => { dataset_id: string metadata_list: MetadataBatchEditToServer }) => { - const documentIds = payload.metadata_list.map(item => item.document_id) + const documentIds = payload.metadata_list.map((item) => item.document_id) await post(`/datasets/${payload.dataset_id}/documents/metadata`, { body: { operation_data: payload.metadata_list, @@ -126,11 +143,13 @@ export const useBatchUpdateDocMetadata = () => { }) // meta data in single document - await Promise.all(documentIds.map(documentId => queryClient.invalidateQueries( - { - queryKey: [NAME_SPACE, 'document', payload.dataset_id, documentId], - }, - ))) + await Promise.all( + documentIds.map((documentId) => + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'document', payload.dataset_id, documentId], + }), + ), + ) }, }) } diff --git a/web/service/knowledge/use-segment.ts b/web/service/knowledge/use-segment.ts index c42324ce6c1165..745992b082532d 100644 --- a/web/service/knowledge/use-segment.ts +++ b/web/service/knowledge/use-segment.ts @@ -36,7 +36,9 @@ export const useSegmentList = ( return useQuery({ queryKey: [...useSegmentListKey, datasetId, documentId, params], queryFn: () => { - return get(`/datasets/${datasetId}/documents/${documentId}/segments`, { params }) + return get(`/datasets/${datasetId}/documents/${documentId}/segments`, { + params, + }) }, enabled: !disable, }) @@ -45,9 +47,17 @@ export const useSegmentList = ( export const useUpdateSegment = () => { return useMutation({ mutationKey: [NAME_SPACE, 'update'], - mutationFn: (payload: { datasetId: string, documentId: string, segmentId: string, body: SegmentUpdater }) => { + mutationFn: (payload: { + datasetId: string + documentId: string + segmentId: string + body: SegmentUpdater + }) => { const { datasetId, documentId, segmentId, body } = payload - return patch<{ data: SegmentDetailModel, doc_form: ChunkingMode }>(`/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}`, { body }) + return patch<{ data: SegmentDetailModel; doc_form: ChunkingMode }>( + `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}`, + { body }, + ) }, }) } @@ -55,9 +65,12 @@ export const useUpdateSegment = () => { export const useAddSegment = () => { return useMutation({ mutationKey: [NAME_SPACE, 'add'], - mutationFn: (payload: { datasetId: string, documentId: string, body: SegmentUpdater }) => { + mutationFn: (payload: { datasetId: string; documentId: string; body: SegmentUpdater }) => { const { datasetId, documentId, body } = payload - return post<{ data: SegmentDetailModel, doc_form: ChunkingMode }>(`/datasets/${datasetId}/documents/${documentId}/segment`, { body }) + return post<{ data: SegmentDetailModel; doc_form: ChunkingMode }>( + `/datasets/${datasetId}/documents/${documentId}/segment`, + { body }, + ) }, }) } @@ -65,10 +78,12 @@ export const useAddSegment = () => { export const useEnableSegment = () => { return useMutation({ mutationKey: [NAME_SPACE, 'enable'], - mutationFn: (payload: { datasetId: string, documentId: string, segmentIds: string[] }) => { + mutationFn: (payload: { datasetId: string; documentId: string; segmentIds: string[] }) => { const { datasetId, documentId, segmentIds } = payload - const query = segmentIds.map(id => `segment_id=${id}`).join('&') - return patch(`/datasets/${datasetId}/documents/${documentId}/segment/enable?${query}`) + const query = segmentIds.map((id) => `segment_id=${id}`).join('&') + return patch( + `/datasets/${datasetId}/documents/${documentId}/segment/enable?${query}`, + ) }, }) } @@ -76,10 +91,12 @@ export const useEnableSegment = () => { export const useDisableSegment = () => { return useMutation({ mutationKey: [NAME_SPACE, 'disable'], - mutationFn: (payload: { datasetId: string, documentId: string, segmentIds: string[] }) => { + mutationFn: (payload: { datasetId: string; documentId: string; segmentIds: string[] }) => { const { datasetId, documentId, segmentIds } = payload - const query = segmentIds.map(id => `segment_id=${id}`).join('&') - return patch(`/datasets/${datasetId}/documents/${documentId}/segment/disable?${query}`) + const query = segmentIds.map((id) => `segment_id=${id}`).join('&') + return patch( + `/datasets/${datasetId}/documents/${documentId}/segment/disable?${query}`, + ) }, }) } @@ -87,9 +104,9 @@ export const useDisableSegment = () => { export const useDeleteSegment = () => { return useMutation({ mutationKey: [NAME_SPACE, 'delete'], - mutationFn: (payload: { datasetId: string, documentId: string, segmentIds: string[] }) => { + mutationFn: (payload: { datasetId: string; documentId: string; segmentIds: string[] }) => { const { datasetId, documentId, segmentIds } = payload - const query = segmentIds.map(id => `segment_id=${id}`).join('&') + const query = segmentIds.map((id) => `segment_id=${id}`).join('&') return del(`/datasets/${datasetId}/documents/${documentId}/segments?${query}`) }, }) @@ -115,7 +132,10 @@ export const useChildSegmentList = ( return useQuery({ queryKey: [...useChildSegmentListKey, datasetId, documentId, segmentId, params], queryFn: () => { - return get(`/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks`, { params }) + return get( + `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks`, + { params }, + ) }, enabled: !disable, }) @@ -124,9 +144,16 @@ export const useChildSegmentList = ( export const useDeleteChildSegment = () => { return useMutation({ mutationKey: [NAME_SPACE, 'childChunk', 'delete'], - mutationFn: (payload: { datasetId: string, documentId: string, segmentId: string, childChunkId: string }) => { + mutationFn: (payload: { + datasetId: string + documentId: string + segmentId: string + childChunkId: string + }) => { const { datasetId, documentId, segmentId, childChunkId } = payload - return del(`/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks/${childChunkId}`) + return del( + `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks/${childChunkId}`, + ) }, }) } @@ -134,9 +161,17 @@ export const useDeleteChildSegment = () => { export const useAddChildSegment = () => { return useMutation({ mutationKey: [NAME_SPACE, 'childChunk', 'add'], - mutationFn: (payload: { datasetId: string, documentId: string, segmentId: string, body: { content: string } }) => { + mutationFn: (payload: { + datasetId: string + documentId: string + segmentId: string + body: { content: string } + }) => { const { datasetId, documentId, segmentId, body } = payload - return post<{ data: ChildChunkDetail }>(`/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks`, { body }) + return post<{ data: ChildChunkDetail }>( + `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks`, + { body }, + ) }, }) } @@ -144,9 +179,18 @@ export const useAddChildSegment = () => { export const useUpdateChildSegment = () => { return useMutation({ mutationKey: [NAME_SPACE, 'childChunk', 'update'], - mutationFn: (payload: { datasetId: string, documentId: string, segmentId: string, childChunkId: string, body: { content: string } }) => { + mutationFn: (payload: { + datasetId: string + documentId: string + segmentId: string + childChunkId: string + body: { content: string } + }) => { const { datasetId, documentId, segmentId, childChunkId, body } = payload - return patch<{ data: ChildChunkDetail }>(`/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks/${childChunkId}`, { body }) + return patch<{ data: ChildChunkDetail }>( + `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks/${childChunkId}`, + { body }, + ) }, }) } @@ -154,7 +198,7 @@ export const useUpdateChildSegment = () => { export const useSegmentBatchImport = () => { return useMutation({ mutationKey: [NAME_SPACE, 'batchImport'], - mutationFn: (payload: { url: string, body: { upload_file_id: string } }) => { + mutationFn: (payload: { url: string; body: { upload_file_id: string } }) => { const { url, body } = payload return post(url, { body }) }, diff --git a/web/service/log.ts b/web/service/log.ts index a540cea22c9804..32fdf19138475f 100644 --- a/web/service/log.ts +++ b/web/service/log.ts @@ -13,15 +13,33 @@ import type { NodeTracingListResponse } from '@/types/workflow' import { get, post } from './base' // (Chat Application) Message list in one session -export const fetchChatMessages = ({ url, params }: { url: string, params: ChatMessagesRequest }): Promise => { +export const fetchChatMessages = ({ + url, + params, +}: { + url: string + params: ChatMessagesRequest +}): Promise => { return get(url, { params }) } -export const updateLogMessageFeedbacks = ({ url, body }: { url: string, body: LogMessageFeedbacksRequest }): Promise => { +export const updateLogMessageFeedbacks = ({ + url, + body, +}: { + url: string + body: LogMessageFeedbacksRequest +}): Promise => { return post(url, { body }) } -export const updateLogMessageAnnotations = ({ url, body }: { url: string, body: LogMessageAnnotationsRequest }): Promise => { +export const updateLogMessageAnnotations = ({ + url, + body, +}: { + url: string + body: LogMessageAnnotationsRequest +}): Promise => { return post(url, { body }) } @@ -33,6 +51,12 @@ export const fetchTracingList = ({ url }: { url: string }): Promise(url) } -export const fetchAgentLogDetail = ({ appID, params }: { appID: string, params: AgentLogDetailRequest }): Promise => { +export const fetchAgentLogDetail = ({ + appID, + params, +}: { + appID: string + params: AgentLogDetailRequest +}): Promise => { return get(`/apps/${appID}/agent/logs`, { params }) } diff --git a/web/service/marketplace-templates.ts b/web/service/marketplace-templates.ts index d9ff7f314f794b..2316b4dee98d8d 100644 --- a/web/service/marketplace-templates.ts +++ b/web/service/marketplace-templates.ts @@ -4,7 +4,9 @@ import { marketplaceQuery } from './client' export const useMarketplaceTemplateDetail = (templateId: string | null) => { return useQuery({ - ...marketplaceQuery.templateDetail.queryOptions({ input: { params: { templateId: templateId ?? '' } } }), + ...marketplaceQuery.templateDetail.queryOptions({ + input: { params: { templateId: templateId ?? '' } }, + }), enabled: !!templateId, }) } @@ -12,7 +14,6 @@ export const useMarketplaceTemplateDetail = (templateId: string | null) => { export const fetchMarketplaceTemplateDSL = async (templateId: string): Promise => { const url = `${MARKETPLACE_API_PREFIX}/templates/${templateId}/dsl` const response = await fetch(url) - if (!response.ok) - throw new Error(`Failed to fetch DSL: ${response.statusText}`) + if (!response.ok) throw new Error(`Failed to fetch DSL: ${response.statusText}`) return response.text() } diff --git a/web/service/plugins.ts b/web/service/plugins.ts index 425937f9dcd3d5..ceb3c1bd291689 100644 --- a/web/service/plugins.ts +++ b/web/service/plugins.ts @@ -13,10 +13,14 @@ import { get, getMarketplace, post, upload } from './base' export const uploadFile = async (file: File, isBundle: boolean) => { const formData = new FormData() formData.append(isBundle ? 'bundle' : 'pkg', file) - return upload({ - xhr: new XMLHttpRequest(), - data: formData, - }, false, `/workspaces/current/plugin/upload/${isBundle ? 'bundle' : 'pkg'}`) + return upload( + { + xhr: new XMLHttpRequest(), + data: formData, + }, + false, + `/workspaces/current/plugin/upload/${isBundle ? 'bundle' : 'pkg'}`, + ) } export const updateFromMarketPlace = async (body: Record) => { @@ -25,7 +29,13 @@ export const updateFromMarketPlace = async (body: Record) => { }) } -export const updateFromGitHub = async (repoUrl: string, selectedVersion: string, selectedPackage: string, originalPlugin: string, newPlugin: string) => { +export const updateFromGitHub = async ( + repoUrl: string, + selectedVersion: string, + selectedPackage: string, + originalPlugin: string, + newPlugin: string, +) => { return post('/workspaces/current/plugin/upgrade/github', { body: { repo: repoUrl, @@ -37,7 +47,11 @@ export const updateFromGitHub = async (repoUrl: string, selectedVersion: string, }) } -export const uploadGitHub = async (repoUrl: string, selectedVersion: string, selectedPackage: string) => { +export const uploadGitHub = async ( + repoUrl: string, + selectedVersion: string, + selectedPackage: string, +) => { return post('/workspaces/current/plugin/upload/github', { body: { repo: repoUrl, @@ -48,7 +62,9 @@ export const uploadGitHub = async (repoUrl: string, selectedVersion: string, sel } export const fetchManifestFromMarketPlace = async (uniqueIdentifier: string) => { - return getMarketplace<{ data: { plugin: PluginManifestInMarket, version: { version: string } } }>(`/plugins/identifier?unique_identifier=${uniqueIdentifier}`) + return getMarketplace<{ data: { plugin: PluginManifestInMarket; version: { version: string } } }>( + `/plugins/identifier?unique_identifier=${uniqueIdentifier}`, + ) } export const fetchBundleInfoFromMarketPlace = async ({ @@ -56,14 +72,15 @@ export const fetchBundleInfoFromMarketPlace = async ({ name, version, }: Record) => { - return getMarketplace<{ data: { version: { dependencies: Dependency[] } } }>(`/bundles/${org}/${name}/${version}`) + return getMarketplace<{ data: { version: { dependencies: Dependency[] } } }>( + `/bundles/${org}/${name}/${version}`, + ) } -export const fetchPluginInfoFromMarketPlace = async ({ - org, - name, -}: Record) => { - return getMarketplace<{ data: { plugin: PluginInfoFromMarketPlace, version: { version: string } } }>(`/plugins/${org}/${name}`) +export const fetchPluginInfoFromMarketPlace = async ({ org, name }: Record) => { + return getMarketplace<{ + data: { plugin: PluginInfoFromMarketPlace; version: { version: string } } + }>(`/plugins/${org}/${name}`) } export const checkTaskStatus = async (taskId: string) => { @@ -71,5 +88,7 @@ export const checkTaskStatus = async (taskId: string) => { } export const uninstallPlugin = async (pluginId: string) => { - return post('/workspaces/current/plugin/uninstall', { body: { plugin_installation_id: pluginId } }) + return post('/workspaces/current/plugin/uninstall', { + body: { plugin_installation_id: pluginId }, + }) } diff --git a/web/service/refresh-token.ts b/web/service/refresh-token.ts index 3c69927f27e4dd..80d75ec5368a88 100644 --- a/web/service/refresh-token.ts +++ b/web/service/refresh-token.ts @@ -13,8 +13,7 @@ function waitUntilTokenRefreshed() { setTimeout(() => { _check() }, 1000) - } - else { + } else { resolve() } } @@ -32,10 +31,12 @@ const isRefreshingSignAvailable = function (delta: number) { async function getNewAccessToken(timeout: number): Promise { try { const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY) - if ((isRefreshingSign && isRefreshingSign === '1' && isRefreshingSignAvailable(timeout)) || isRefreshing) { + if ( + (isRefreshingSign && isRefreshingSign === '1' && isRefreshingSignAvailable(timeout)) || + isRefreshing + ) { await waitUntilTokenRefreshed() - } - else { + } else { isRefreshing = true globalThis.localStorage.setItem(LOCAL_STORAGE_KEY, '1') globalThis.localStorage.setItem('last_refresh_time', new Date().getTime().toString()) @@ -46,28 +47,26 @@ async function getNewAccessToken(timeout: number): Promise { // it can lead to an infinite loop if the refresh attempt also returns 401. // To avoid this, handle token refresh separately in a dedicated function // that does not call baseFetch and uses a single retry mechanism. - const [error, ret] = await fetchWithRetry(globalThis.fetch(`${API_PREFIX}/refresh-token`, { - method: 'POST', - credentials: 'include', // Important: include cookies in the request - headers: { - 'Content-Type': 'application/json;utf-8', - }, - // No body needed - refresh token is in cookie - })) + const [error, ret] = await fetchWithRetry( + globalThis.fetch(`${API_PREFIX}/refresh-token`, { + method: 'POST', + credentials: 'include', // Important: include cookies in the request + headers: { + 'Content-Type': 'application/json;utf-8', + }, + // No body needed - refresh token is in cookie + }), + ) if (error) { return Promise.reject(error) - } - else { - if (ret.status === 401) - return Promise.reject(ret) + } else { + if (ret.status === 401) return Promise.reject(ret) } } - } - catch (error) { + } catch (error) { console.error(error) return Promise.reject(error) - } - finally { + } finally { releaseRefreshLock() } } @@ -82,11 +81,15 @@ function releaseRefreshLock() { } export async function refreshAccessTokenOrReLogin(timeout: number) { - if (!isClient) - return Promise.reject(new Error('refresh token is client-only')) + if (!isClient) return Promise.reject(new Error('refresh token is client-only')) - return Promise.race([new Promise((resolve, reject) => setTimeout(() => { - releaseRefreshLock() - reject(new Error('request timeout')) - }, timeout)), getNewAccessToken(timeout)]) + return Promise.race([ + new Promise((resolve, reject) => + setTimeout(() => { + releaseRefreshLock() + reject(new Error('request timeout')) + }, timeout), + ), + getNewAccessToken(timeout), + ]) } diff --git a/web/service/server.ts b/web/service/server.ts index 16d412e28fe7d5..7b4d90786f0b66 100644 --- a/web/service/server.ts +++ b/web/service/server.ts @@ -6,14 +6,9 @@ import { createORPCClient, onError } from '@orpc/client' import { OpenAPILink } from '@orpc/openapi-client/fetch' import { createTanstackQueryUtils } from '@orpc/tanstack-query' import { cache } from 'react' -import { - API_PREFIX, - CSRF_COOKIE_NAME, - CSRF_HEADER_NAME, -} from '@/config' +import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from '@/config' import { SERVER_CONSOLE_API_PREFIX } from '@/config/server' import { createConsoleDynamicLink } from './console-link' - import 'server-only' export type ServerConsoleClientContext = { @@ -21,14 +16,13 @@ export type ServerConsoleClientContext = { csrfToken?: string } -const withTrailingSlash = (value: string) => value.endsWith('/') ? value : `${value}/` -const withoutLeadingSlash = (value: string) => value.startsWith('/') ? value.slice(1) : value +const withTrailingSlash = (value: string) => (value.endsWith('/') ? value : `${value}/`) +const withoutLeadingSlash = (value: string) => (value.startsWith('/') ? value.slice(1) : value) const resolveAbsoluteUrlPrefix = (value: string) => { try { return new URL(value).toString() - } - catch { + } catch { return null } } @@ -44,16 +38,14 @@ export const resolveServerConsoleApiUrl = ( publicApiPrefix = API_PREFIX, ) => { const apiPrefix = resolveServerConsoleApiPrefix(serverConsoleApiPrefix, publicApiPrefix) - if (!apiPrefix) - return null + if (!apiPrefix) return null return new URL(withoutLeadingSlash(pathname), withTrailingSlash(apiPrefix)).toString() } const getServerConsoleApiPrefix = () => { const apiPrefix = resolveServerConsoleApiPrefix() - if (!apiPrefix) - throw new Error('Server console API URL is not configured') + if (!apiPrefix) throw new Error('Server console API URL is not configured') return apiPrefix } @@ -63,10 +55,8 @@ const createServerConsoleRequestHeaders = (context: ServerConsoleClientContext | Accept: 'application/json', }) - if (context?.cookie) - requestHeaders.set('cookie', context.cookie) - if (context?.csrfToken) - requestHeaders.set(CSRF_HEADER_NAME, context.csrfToken) + if (context?.cookie) requestHeaders.set('cookie', context.cookie) + if (context?.csrfToken) requestHeaders.set(CSRF_HEADER_NAME, context.csrfToken) return requestHeaders } @@ -94,23 +84,29 @@ function createServerConsoleOpenAPILink(contract: AnyContractRouter): ServerCons }) } -export const getServerConsoleClientContext = cache(async (): Promise => { - const { cookies, headers } = await import('@/next/headers') - const requestHeaders = await headers() - const cookieStore = await cookies() +export const getServerConsoleClientContext = cache( + async (): Promise => { + const { cookies, headers } = await import('@/next/headers') + const requestHeaders = await headers() + const cookieStore = await cookies() - return { - cookie: requestHeaders.get('cookie') || undefined, - csrfToken: cookieStore.get(CSRF_COOKIE_NAME())?.value, - } -}) + return { + cookie: requestHeaders.get('cookie') || undefined, + csrfToken: cookieStore.get(CSRF_COOKIE_NAME())?.value, + } + }, +) export const getServerConsoleRequestHeaders = async () => createServerConsoleRequestHeaders(await getServerConsoleClientContext()) -const serverConsoleLink = createConsoleDynamicLink(createServerConsoleOpenAPILink) +const serverConsoleLink = createConsoleDynamicLink( + createServerConsoleOpenAPILink, +) -export const serverConsoleClient: JsonifiedClient> = createORPCClient(serverConsoleLink) +export const serverConsoleClient: JsonifiedClient< + ContractRouterClient +> = createORPCClient(serverConsoleLink) export const serverConsoleQuery = createTanstackQueryUtils(serverConsoleClient, { path: ['console'], diff --git a/web/service/share.ts b/web/service/share.ts index 732e32eff1a2a2..9c5168123eeb4f 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -1,20 +1,9 @@ -import type { - IOnCompleted, - IOnData, - IOnError, - IOnMessageReplace, - IOtherOptions, -} from './base' +import type { IOnCompleted, IOnData, IOnError, IOnMessageReplace, IOtherOptions } from './base' import type { FormData as HumanInputFormData } from '@/app/(humanInputLayout)/form/[token]/form' import type { FeedbackType } from '@/app/components/base/chat/chat/type' import type { ChatConfig } from '@/app/components/base/chat/types' import type { AccessMode } from '@/models/access-control' -import type { - AppConversationData, - AppData, - AppMeta, - ConversationItem, -} from '@/models/share' +import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' import { WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config' import { del as consoleDel, @@ -62,26 +51,58 @@ function getAction(action: 'get' | 'post' | 'del' | 'patch', appSourceType: AppS export function getUrl(url: string, appSourceType: AppSourceType, appId: string) { const hasPrefix = appSourceType !== AppSourceType.webApp - return hasPrefix ? `${apiPrefix[appSourceType]}/${appId}/${url.startsWith('/') ? url.slice(1) : url}` : url + return hasPrefix + ? `${apiPrefix[appSourceType]}/${appId}/${url.startsWith('/') ? url.slice(1) : url}` + : url } -export const stopChatMessageResponding = async (appId: string, taskId: string, appSourceType: AppSourceType, installedAppId = '') => { - return getAction('post', appSourceType)(getUrl(`chat-messages/${taskId}/stop`, appSourceType, installedAppId)) +export const stopChatMessageResponding = async ( + appId: string, + taskId: string, + appSourceType: AppSourceType, + installedAppId = '', +) => { + return getAction( + 'post', + appSourceType, + )(getUrl(`chat-messages/${taskId}/stop`, appSourceType, installedAppId)) } -export const sendCompletionMessage = async (body: Record, { onData, onCompleted, onError, onMessageReplace, getAbortController }: { - onData: IOnData - onCompleted: IOnCompleted - onError: IOnError - onMessageReplace: IOnMessageReplace - getAbortController?: (abortController: AbortController) => void -}, appSourceType: AppSourceType, installedAppId = '') => { - return ssePost(getUrl('completion-messages', appSourceType, installedAppId), { - body: { - ...body, - response_mode: 'streaming', +export const sendCompletionMessage = async ( + body: Record, + { + onData, + onCompleted, + onError, + onMessageReplace, + getAbortController, + }: { + onData: IOnData + onCompleted: IOnCompleted + onError: IOnError + onMessageReplace: IOnMessageReplace + getAbortController?: (abortController: AbortController) => void + }, + appSourceType: AppSourceType, + installedAppId = '', +) => { + return ssePost( + getUrl('completion-messages', appSourceType, installedAppId), + { + body: { + ...body, + response_mode: 'streaming', + }, + }, + { + onData, + onCompleted, + isPublicAPI: getIsPublicAPI(appSourceType), + onError, + onMessageReplace, + getAbortController, }, - }, { onData, onCompleted, isPublicAPI: getIsPublicAPI(appSourceType), onError, onMessageReplace, getAbortController }) + ) } export const sendWorkflowMessage = async ( @@ -90,53 +111,118 @@ export const sendWorkflowMessage = async ( appSourceType: AppSourceType, appId = '', ) => { - return ssePost(getUrl('workflows/run', appSourceType, appId), { - body: { - ...body, - response_mode: 'streaming', + return ssePost( + getUrl('workflows/run', appSourceType, appId), + { + body: { + ...body, + response_mode: 'streaming', + }, }, - }, { - ...otherOptions, - isPublicAPI: getIsPublicAPI(appSourceType), - }) + { + ...otherOptions, + isPublicAPI: getIsPublicAPI(appSourceType), + }, + ) } -export const stopWorkflowMessage = async (_appId: string, taskId: string, appSourceType: AppSourceType, installedAppId = '') => { - if (!taskId) - return - return getAction('post', appSourceType)(getUrl(`workflows/tasks/${taskId}/stop`, appSourceType, installedAppId)) +export const stopWorkflowMessage = async ( + _appId: string, + taskId: string, + appSourceType: AppSourceType, + installedAppId = '', +) => { + if (!taskId) return + return getAction( + 'post', + appSourceType, + )(getUrl(`workflows/tasks/${taskId}/stop`, appSourceType, installedAppId)) } export const fetchAppInfo = async () => { return get('/site') as Promise } -export const fetchConversations = async (appSourceType: AppSourceType, installedAppId = '', last_id?: string, pinned?: boolean, limit?: number) => { - return getAction('get', appSourceType)(getUrl('conversations', appSourceType, installedAppId), { params: { limit: limit || 20, ...(last_id ? { last_id } : {}), ...(pinned !== undefined ? { pinned } : {}) } }) as Promise +export const fetchConversations = async ( + appSourceType: AppSourceType, + installedAppId = '', + last_id?: string, + pinned?: boolean, + limit?: number, +) => { + return getAction('get', appSourceType)(getUrl('conversations', appSourceType, installedAppId), { + params: { + limit: limit || 20, + ...(last_id ? { last_id } : {}), + ...(pinned !== undefined ? { pinned } : {}), + }, + }) as Promise } -export const pinConversation = async (appSourceType: AppSourceType, installedAppId = '', id: string) => { - return getAction('patch', appSourceType)(getUrl(`conversations/${id}/pin`, appSourceType, installedAppId)) +export const pinConversation = async ( + appSourceType: AppSourceType, + installedAppId = '', + id: string, +) => { + return getAction( + 'patch', + appSourceType, + )(getUrl(`conversations/${id}/pin`, appSourceType, installedAppId)) } -export const unpinConversation = async (appSourceType: AppSourceType, installedAppId = '', id: string) => { - return getAction('patch', appSourceType)(getUrl(`conversations/${id}/unpin`, appSourceType, installedAppId)) +export const unpinConversation = async ( + appSourceType: AppSourceType, + installedAppId = '', + id: string, +) => { + return getAction( + 'patch', + appSourceType, + )(getUrl(`conversations/${id}/unpin`, appSourceType, installedAppId)) } -export const delConversation = async (appSourceType: AppSourceType, installedAppId = '', id: string) => { - return getAction('del', appSourceType)(getUrl(`conversations/${id}`, appSourceType, installedAppId)) +export const delConversation = async ( + appSourceType: AppSourceType, + installedAppId = '', + id: string, +) => { + return getAction( + 'del', + appSourceType, + )(getUrl(`conversations/${id}`, appSourceType, installedAppId)) } -export const renameConversation = async (appSourceType: AppSourceType, installedAppId = '', id: string, name: string) => { - return getAction('post', appSourceType)(getUrl(`conversations/${id}/name`, appSourceType, installedAppId), { body: { name } }) +export const renameConversation = async ( + appSourceType: AppSourceType, + installedAppId = '', + id: string, + name: string, +) => { + return getAction('post', appSourceType)( + getUrl(`conversations/${id}/name`, appSourceType, installedAppId), + { body: { name } }, + ) } -export const generationConversationName = async (appSourceType: AppSourceType, installedAppId = '', id: string) => { - return getAction('post', appSourceType)(getUrl(`conversations/${id}/name`, appSourceType, installedAppId), { body: { auto_generate: true } }) as Promise +export const generationConversationName = async ( + appSourceType: AppSourceType, + installedAppId = '', + id: string, +) => { + return getAction('post', appSourceType)( + getUrl(`conversations/${id}/name`, appSourceType, installedAppId), + { body: { auto_generate: true } }, + ) as Promise } -export const fetchChatList = async (conversationId: string, appSourceType: AppSourceType, installedAppId = '') => { - return getAction('get', appSourceType)(getUrl('messages', appSourceType, installedAppId), { params: { conversation_id: conversationId, limit: 20, last_id: '' } }) as any +export const fetchChatList = async ( + conversationId: string, + appSourceType: AppSourceType, + installedAppId = '', +) => { + return getAction('get', appSourceType)(getUrl('messages', appSourceType, installedAppId), { + params: { conversation_id: conversationId, limit: 20, last_id: '' }, + }) as any } // Abandoned API interface @@ -146,116 +232,192 @@ export const fetchChatList = async (conversationId: string, appSourceType: AppSo // init value. wait for server update export const fetchAppParams = async (appSourceType: AppSourceType, appId = '') => { - return (getAction('get', appSourceType))(getUrl('parameters', appSourceType, appId)) as Promise + return getAction( + 'get', + appSourceType, + )(getUrl('parameters', appSourceType, appId)) as Promise } export const fetchWebSAMLSSOUrl = async (appCode: string, redirectUrl: string) => { - return (getAction('get', AppSourceType.webApp))(getUrl('/enterprise/sso/saml/login', AppSourceType.webApp, ''), { - params: { - app_code: appCode, - redirect_url: redirectUrl, + return getAction('get', AppSourceType.webApp)( + getUrl('/enterprise/sso/saml/login', AppSourceType.webApp, ''), + { + params: { + app_code: appCode, + redirect_url: redirectUrl, + }, }, - }) as Promise<{ url: string }> + ) as Promise<{ url: string }> } export const fetchWebOIDCSSOUrl = async (appCode: string, redirectUrl: string) => { - return (getAction('get', AppSourceType.webApp))(getUrl('/enterprise/sso/oidc/login', AppSourceType.webApp, ''), { - params: { - app_code: appCode, - redirect_url: redirectUrl, + return getAction('get', AppSourceType.webApp)( + getUrl('/enterprise/sso/oidc/login', AppSourceType.webApp, ''), + { + params: { + app_code: appCode, + redirect_url: redirectUrl, + }, }, - - }) as Promise<{ url: string }> + ) as Promise<{ url: string }> } export const fetchWebOAuth2SSOUrl = async (appCode: string, redirectUrl: string) => { - return (getAction('get', AppSourceType.webApp))(getUrl('/enterprise/sso/oauth2/login', AppSourceType.webApp, ''), { - params: { - app_code: appCode, - redirect_url: redirectUrl, + return getAction('get', AppSourceType.webApp)( + getUrl('/enterprise/sso/oauth2/login', AppSourceType.webApp, ''), + { + params: { + app_code: appCode, + redirect_url: redirectUrl, + }, }, - }) as Promise<{ url: string }> + ) as Promise<{ url: string }> } export const fetchMembersSAMLSSOUrl = async (appCode: string, redirectUrl: string) => { - return (getAction('get', AppSourceType.webApp))(getUrl('/enterprise/sso/members/saml/login', AppSourceType.webApp, ''), { - params: { - app_code: appCode, - redirect_url: redirectUrl, + return getAction('get', AppSourceType.webApp)( + getUrl('/enterprise/sso/members/saml/login', AppSourceType.webApp, ''), + { + params: { + app_code: appCode, + redirect_url: redirectUrl, + }, }, - }) as Promise<{ url: string }> + ) as Promise<{ url: string }> } export const fetchMembersOIDCSSOUrl = async (appCode: string, redirectUrl: string) => { - return (getAction('get', AppSourceType.webApp))(getUrl('/enterprise/sso/members/oidc/login', AppSourceType.webApp, ''), { - params: { - app_code: appCode, - redirect_url: redirectUrl, + return getAction('get', AppSourceType.webApp)( + getUrl('/enterprise/sso/members/oidc/login', AppSourceType.webApp, ''), + { + params: { + app_code: appCode, + redirect_url: redirectUrl, + }, }, - - }) as Promise<{ url: string }> + ) as Promise<{ url: string }> } export const fetchMembersOAuth2SSOUrl = async (appCode: string, redirectUrl: string) => { - return (getAction('get', AppSourceType.webApp))(getUrl('/enterprise/sso/members/oauth2/login', AppSourceType.webApp, ''), { - params: { - app_code: appCode, - redirect_url: redirectUrl, + return getAction('get', AppSourceType.webApp)( + getUrl('/enterprise/sso/members/oauth2/login', AppSourceType.webApp, ''), + { + params: { + app_code: appCode, + redirect_url: redirectUrl, + }, }, - }) as Promise<{ url: string }> + ) as Promise<{ url: string }> } export const fetchAppMeta = async (appSourceType: AppSourceType, installedAppId = '') => { - return (getAction('get', appSourceType))(getUrl('meta', appSourceType, installedAppId)) as Promise + return getAction( + 'get', + appSourceType, + )(getUrl('meta', appSourceType, installedAppId)) as Promise } -export const updateFeedback = async ({ url, body }: { url: string, body: FeedbackType }, appSourceType: AppSourceType, installedAppId = '') => { - return (getAction('post', appSourceType))(getUrl(url, appSourceType, installedAppId), { body }) +export const updateFeedback = async ( + { url, body }: { url: string; body: FeedbackType }, + appSourceType: AppSourceType, + installedAppId = '', +) => { + return getAction('post', appSourceType)(getUrl(url, appSourceType, installedAppId), { body }) } -export const fetchMoreLikeThis = async (messageId: string, appSourceType: AppSourceType, installedAppId = '') => { - return (getAction('get', appSourceType))(getUrl(`/messages/${messageId}/more-like-this`, appSourceType, installedAppId), { - params: { - response_mode: 'blocking', +export const fetchMoreLikeThis = async ( + messageId: string, + appSourceType: AppSourceType, + installedAppId = '', +) => { + return getAction('get', appSourceType)( + getUrl(`/messages/${messageId}/more-like-this`, appSourceType, installedAppId), + { + params: { + response_mode: 'blocking', + }, }, - }) + ) } -export const saveMessage = (messageId: string, appSourceType: AppSourceType, installedAppId = '') => { - return (getAction('post', appSourceType))(getUrl('/saved-messages', appSourceType, installedAppId), { body: { message_id: messageId } }) +export const saveMessage = ( + messageId: string, + appSourceType: AppSourceType, + installedAppId = '', +) => { + return getAction('post', appSourceType)( + getUrl('/saved-messages', appSourceType, installedAppId), + { body: { message_id: messageId } }, + ) } export const fetchSavedMessage = async (appSourceType: AppSourceType, installedAppId = '') => { - return (getAction('get', appSourceType))(getUrl('/saved-messages', appSourceType, installedAppId), {}, { - silent: true, - }) + return getAction('get', appSourceType)( + getUrl('/saved-messages', appSourceType, installedAppId), + {}, + { + silent: true, + }, + ) } -export const removeMessage = (messageId: string, appSourceType: AppSourceType, installedAppId = '') => { - return (getAction('del', appSourceType))(getUrl(`/saved-messages/${messageId}`, appSourceType, installedAppId)) +export const removeMessage = ( + messageId: string, + appSourceType: AppSourceType, + installedAppId = '', +) => { + return getAction( + 'del', + appSourceType, + )(getUrl(`/saved-messages/${messageId}`, appSourceType, installedAppId)) } -export const fetchSuggestedQuestions = (messageId: string, appSourceType: AppSourceType, installedAppId = '') => { - return (getAction('get', appSourceType))(getUrl(`/messages/${messageId}/suggested-questions`, appSourceType, installedAppId)) +export const fetchSuggestedQuestions = ( + messageId: string, + appSourceType: AppSourceType, + installedAppId = '', +) => { + return getAction( + 'get', + appSourceType, + )(getUrl(`/messages/${messageId}/suggested-questions`, appSourceType, installedAppId)) } export const audioToText = (url: string, appSourceType: AppSourceType, body: FormData) => { - return (getAction('post', appSourceType))(url, { body }, { bodyStringify: false, deleteContentType: true }) as Promise<{ text: string }> + return getAction('post', appSourceType)( + url, + { body }, + { bodyStringify: false, deleteContentType: true }, + ) as Promise<{ text: string }> } -export const textToAudioStream = (url: string, appSourceType: AppSourceType, header: { content_type: string }, body: { streaming: boolean, voice?: string, message_id?: string, text?: string | null | undefined }) => { - return (getAction('post', appSourceType))(url, { body, header }, { needAllResponseContent: true }) +export const textToAudioStream = ( + url: string, + appSourceType: AppSourceType, + header: { content_type: string }, + body: { + streaming: boolean + voice?: string + message_id?: string + text?: string | null | undefined + }, +) => { + return getAction('post', appSourceType)(url, { body, header }, { needAllResponseContent: true }) } -export const fetchAccessToken = async ({ userId, appCode }: { userId?: string, appCode: string }) => { +export const fetchAccessToken = async ({ + userId, + appCode, +}: { + userId?: string + appCode: string +}) => { const headers = new Headers() headers.append(WEB_APP_SHARE_CODE_HEADER_NAME, appCode) const accessToken = getWebAppAccessToken() - if (accessToken) - headers.append('Authorization', `Bearer ${accessToken}`) + if (accessToken) headers.append('Authorization', `Bearer ${accessToken}`) const params = new URLSearchParams() - if (userId) - params.append('user_id', userId) + if (userId) params.append('user_id', userId) const url = `/passport?${params.toString()}` return get<{ access_token: string }>(url, { headers }) as Promise<{ access_token: string }> } @@ -321,7 +483,9 @@ const getHumanInputFormUploadToken = async (formToken: string) => { if (cachedToken && cachedToken.expires_at > now + UPLOAD_TOKEN_REFRESH_BUFFER_SECONDS) return cachedToken.upload_token - const tokenResponse = await post(`/form/human_input/${formToken}/upload-token`) + const tokenResponse = await post( + `/form/human_input/${formToken}/upload-token`, + ) humanInputFormUploadTokenCache.set(formToken, tokenResponse) return tokenResponse.upload_token } @@ -333,14 +497,18 @@ const uploadHumanInputFormFile = async ( ) => { const uploadToken = await getHumanInputFormUploadToken(formToken) - return upload({ - xhr: new XMLHttpRequest(), - data: formData, - headers: { - Authorization: `bearer ${uploadToken}`, + return upload( + { + xhr: new XMLHttpRequest(), + data: formData, + headers: { + Authorization: `bearer ${uploadToken}`, + }, + onprogress: onProgress, }, - onprogress: onProgress, - }, true, '/human-input-forms/files') + true, + '/human-input-forms/files', + ) } export const uploadHumanInputFormLocalFile = async ({ @@ -355,21 +523,20 @@ export const uploadHumanInputFormLocalFile = async ({ const onProgress = (e: ProgressEvent) => { if (e.lengthComputable) { - const percent = Math.floor(e.loaded / e.total * 100) + const percent = Math.floor((e.loaded / e.total) * 100) onProgressCallback(percent) } } try { - const response = await uploadHumanInputFormFile( + const response = (await uploadHumanInputFormFile( formToken, formData, onProgress, - ) as HumanInputFormLocalFileUploadResponse + )) as HumanInputFormLocalFileUploadResponse onSuccessCallback(response) - } - catch (error) { + } catch (error) { onErrorCallback(error) } } @@ -381,12 +548,18 @@ export const uploadHumanInputFormRemoteFileInfo = async ( const formData = new FormData() formData.append('url', url) - return uploadHumanInputFormFile(formToken, formData) as Promise + return uploadHumanInputFormFile( + formToken, + formData, + ) as Promise } -export const submitHumanInputForm = (token: string, data: { - inputs: Record - action: string -}) => { +export const submitHumanInputForm = ( + token: string, + data: { + inputs: Record + action: string + }, +) => { return post(`/form/human_input/${token}`, { body: data }) } diff --git a/web/service/sso.ts b/web/service/sso.ts index ba331d4bc51010..6ad885afbc17eb 100644 --- a/web/service/sso.ts +++ b/web/service/sso.ts @@ -1,16 +1,22 @@ import { get } from './base' export const getUserSAMLSSOUrl = (invite_token?: string) => { - const url = invite_token ? `/enterprise/sso/saml/login?invite_token=${invite_token}` : '/enterprise/sso/saml/login' + const url = invite_token + ? `/enterprise/sso/saml/login?invite_token=${invite_token}` + : '/enterprise/sso/saml/login' return get<{ url: string }>(url) } export const getUserOIDCSSOUrl = (invite_token?: string) => { - const url = invite_token ? `/enterprise/sso/oidc/login?invite_token=${invite_token}` : '/enterprise/sso/oidc/login' - return get<{ url: string, state: string }>(url) + const url = invite_token + ? `/enterprise/sso/oidc/login?invite_token=${invite_token}` + : '/enterprise/sso/oidc/login' + return get<{ url: string; state: string }>(url) } export const getUserOAuth2SSOUrl = (invite_token?: string) => { - const url = invite_token ? `/enterprise/sso/oauth2/login?invite_token=${invite_token}` : '/enterprise/sso/oauth2/login' - return get<{ url: string, state: string }>(url) + const url = invite_token + ? `/enterprise/sso/oauth2/login?invite_token=${invite_token}` + : '/enterprise/sso/oauth2/login' + return get<{ url: string; state: string }>(url) } diff --git a/web/service/tools.ts b/web/service/tools.ts index 843fd64c6db982..08b6f54e5c1a27 100644 --- a/web/service/tools.ts +++ b/web/service/tools.ts @@ -34,13 +34,20 @@ export const fetchWorkflowToolList = (appID: string) => { } export const fetchBuiltInToolCredentialSchema = (collectionName: string) => { - return get(`/workspaces/current/tool-provider/builtin/${collectionName}/credentials_schema`) + return get( + `/workspaces/current/tool-provider/builtin/${collectionName}/credentials_schema`, + ) } export const fetchBuiltInToolCredential = (collectionName: string) => { - return get>(`/workspaces/current/tool-provider/builtin/${collectionName}/credentials`) -} -export const updateBuiltInToolCredential = (collectionName: string, credential: Record) => { + return get>( + `/workspaces/current/tool-provider/builtin/${collectionName}/credentials`, + ) +} +export const updateBuiltInToolCredential = ( + collectionName: string, + credential: Record, +) => { return post(`/workspaces/current/tool-provider/builtin/${collectionName}/update`, { body: { credentials: credential, @@ -55,11 +62,14 @@ export const removeBuiltInToolCredential = (collectionName: string) => { } export const parseParamsSchema = (schema: string) => { - return post<{ parameters_schema: CustomParamSchema[], schema_type: string }>('/workspaces/current/tool-provider/api/schema', { - body: { - schema, + return post<{ parameters_schema: CustomParamSchema[]; schema_type: string }>( + '/workspaces/current/tool-provider/api/schema', + { + body: { + schema, + }, }, - }) + ) } export const fetchCustomCollection = (collectionName: string) => { @@ -114,23 +124,30 @@ export const testAPIAvailable = (payload: { }) } -export const createWorkflowToolProvider = (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => { +export const createWorkflowToolProvider = ( + payload: WorkflowToolProviderRequest & { workflow_app_id: string }, +) => { return post('/workspaces/current/tool-provider/workflow/create', { body: { ...payload }, }) } -export const saveWorkflowToolProvider = (payload: WorkflowToolProviderRequest & Partial<{ - workflow_app_id: string - workflow_tool_id: string -}>) => { +export const saveWorkflowToolProvider = ( + payload: WorkflowToolProviderRequest & + Partial<{ + workflow_app_id: string + workflow_tool_id: string + }>, +) => { return post('/workspaces/current/tool-provider/workflow/update', { body: { ...payload }, }) } export const fetchWorkflowToolDetail = (toolID: string) => { - return get(`/workspaces/current/tool-provider/workflow/get?workflow_tool_id=${toolID}`) + return get( + `/workspaces/current/tool-provider/workflow/get?workflow_tool_id=${toolID}`, + ) } export const deleteWorkflowTool = (toolID: string) => { diff --git a/web/service/try-app.ts b/web/service/try-app.ts index 99a2f46aa7ccb7..ea9a5de50d09e4 100644 --- a/web/service/try-app.ts +++ b/web/service/try-app.ts @@ -31,17 +31,17 @@ const getNumber = (value: unknown, fallback: number) => { } const getStringArray = (value: unknown) => { - return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : [] } const getStringRecord = (value: unknown) => { - if (!isRecord(value)) - return undefined + if (!isRecord(value)) return undefined const record: Record = {} Object.entries(value).forEach(([key, item]) => { - if (typeof item === 'string') - record[key] = item + if (typeof item === 'string') record[key] = item }) return record } @@ -53,7 +53,9 @@ const normalizeEnabledConfig = (value: Record, fallback = false } } -const normalizeTextToSpeechConfig = (value: Record): ChatConfig['text_to_speech'] => { +const normalizeTextToSpeechConfig = ( + value: Record, +): ChatConfig['text_to_speech'] => { const autoPlay = getString(value.autoPlay) const config = { ...value } delete config.autoPlay @@ -65,7 +67,9 @@ const normalizeTextToSpeechConfig = (value: Record): ChatConfig } } -const normalizeAnnotationReplyConfig = (value: Record): ChatConfig['annotation_reply'] => { +const normalizeAnnotationReplyConfig = ( + value: Record, +): ChatConfig['annotation_reply'] => { const embeddingModel = isRecord(value.embedding_model) ? value.embedding_model : {} return { id: getString(value.id), @@ -79,8 +83,7 @@ const normalizeAnnotationReplyConfig = (value: Record): ChatCon } const getTransferMethods = (value: unknown, fallback: TransferMethod[]) => { - if (!Array.isArray(value)) - return fallback + if (!Array.isArray(value)) return fallback const methods = value.filter((item): item is TransferMethod => { return typeof item === 'string' && transferMethodValues.has(item) @@ -89,27 +92,37 @@ const getTransferMethods = (value: unknown, fallback: TransferMethod[]) => { } const getSupportUploadFileTypes = (value: unknown): SupportUploadFileTypes[] => { - if (!Array.isArray(value)) - return [] + if (!Array.isArray(value)) return [] return value.filter((item): item is SupportUploadFileTypes => { return typeof item === 'string' && supportUploadFileTypeValues.has(item) }) } -const normalizeVisionSettings = (value: unknown): NonNullable['image'] => { +const normalizeVisionSettings = ( + value: unknown, +): NonNullable['image'] => { const image = isRecord(value) ? value : {} return { enabled: getBoolean(image.enabled), number_limits: getNumber(image.number_limits, 3), detail: getString(image.detail) === Resolution.low ? Resolution.low : Resolution.high, - transfer_methods: getTransferMethods(image.transfer_methods, [TransferMethod.local_file, TransferMethod.remote_url]), + transfer_methods: getTransferMethods(image.transfer_methods, [ + TransferMethod.local_file, + TransferMethod.remote_url, + ]), } } const normalizeFileUploadConfig = (value: Record): ChatConfig['file_upload'] => { - const allowedUploadMethods = getTransferMethods(value.allowed_upload_methods, [TransferMethod.local_file, TransferMethod.remote_url]) - const allowedFileUploadMethods = getTransferMethods(value.allowed_file_upload_methods, allowedUploadMethods) + const allowedUploadMethods = getTransferMethods(value.allowed_upload_methods, [ + TransferMethod.local_file, + TransferMethod.remote_url, + ]) + const allowedFileUploadMethods = getTransferMethods( + value.allowed_file_upload_methods, + allowedUploadMethods, + ) return { image: normalizeVisionSettings(value.image), @@ -170,12 +183,13 @@ const normalizeFileInputForm = (value: Record) => { } } -const normalizeUserInputFormItem = (item: Record): ChatConfig['user_input_form'][number] | null => { +const normalizeUserInputFormItem = ( + item: Record, +): ChatConfig['user_input_form'][number] | null => { if (isRecord(item['text-input'])) return { 'text-input': normalizeTextInputForm(item['text-input']) } - if (isRecord(item.paragraph)) - return { paragraph: normalizeTextInputForm(item.paragraph) } + if (isRecord(item.paragraph)) return { paragraph: normalizeTextInputForm(item.paragraph) } if (isRecord(item.select)) { return { @@ -204,11 +218,9 @@ const normalizeUserInputFormItem = (item: Record): ChatConfig[' } } - if (isRecord(item.file)) - return { file: normalizeFileInputForm(item.file) } + if (isRecord(item.file)) return { file: normalizeFileInputForm(item.file) } - if (isRecord(item['file-list'])) - return { 'file-list': normalizeFileInputForm(item['file-list']) } + if (isRecord(item['file-list'])) return { 'file-list': normalizeFileInputForm(item['file-list']) } if (isRecord(item.external_data_tool)) { return { @@ -231,7 +243,8 @@ const normalizeUserInputFormItem = (item: Record): ChatConfig[' return { json_object: { ...normalizeBaseInputForm(item.json_object), - json_schema: typeof jsonSchema === 'string' || isRecord(jsonSchema) ? jsonSchema : undefined, + json_schema: + typeof jsonSchema === 'string' || isRecord(jsonSchema) ? jsonSchema : undefined, }, } } @@ -239,11 +252,12 @@ const normalizeUserInputFormItem = (item: Record): ChatConfig[' return null } -const normalizeUserInputForm = (items: TryAppParameters['user_input_form']): ChatConfig['user_input_form'] => { +const normalizeUserInputForm = ( + items: TryAppParameters['user_input_form'], +): ChatConfig['user_input_form'] => { return items.reduce((result, item) => { const normalized = normalizeUserInputFormItem(item) - if (normalized) - result.push(normalized) + if (normalized) result.push(normalized) return result }, []) } @@ -252,7 +266,9 @@ const normalizeTryAppParams = (params: TryAppParameters): ChatConfig => { return { opening_statement: params.opening_statement ?? '', suggested_questions: params.suggested_questions, - suggested_questions_after_answer: normalizeEnabledConfig(params.suggested_questions_after_answer), + suggested_questions_after_answer: normalizeEnabledConfig( + params.suggested_questions_after_answer, + ), speech_to_text: normalizeEnabledConfig(params.speech_to_text), text_to_speech: normalizeTextToSpeechConfig(params.text_to_speech), retriever_resource: normalizeEnabledConfig(params.retriever_resource), @@ -285,7 +301,8 @@ export const fetchTryAppFlowPreview = (appId: string) => { } export const fetchTryAppParams = (appId: string) => { - return consoleClient.trialApps.byAppId.parameters.get({ params: { app_id: appId } }) + return consoleClient.trialApps.byAppId.parameters + .get({ params: { app_id: appId } }) .then(normalizeTryAppParams) } diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts index 479844d6916334..585145f21bad9e 100644 --- a/web/service/use-apps.ts +++ b/web/service/use-apps.ts @@ -12,11 +12,7 @@ import type { WorkflowDailyConversationsResponse, } from '@/models/app' import type { App, AppIconType } from '@/types/app' -import { - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { AccessMode } from '@/models/access-control' import { consoleClient, consoleQuery } from '@/service/client' import { AppModeEnum } from '@/types/app' @@ -48,8 +44,7 @@ function isAccessMode(accessMode: string | null | undefined): accessMode is Acce } function normalizeWorkflow(workflow: AppPartial['workflow']): App['workflow'] { - if (!workflow) - return undefined + if (!workflow) return undefined return { id: workflow.id, @@ -109,11 +104,12 @@ export function normalizeAppPagination(response: AppPagination): AppListResponse export const useGenerateRuleTemplate = (type: GeneratorType, disabled?: boolean) => { return useQuery({ queryKey: [NAME_SPACE, 'generate-rule-template', type], - queryFn: () => post<{ data: string }>('instruction-generate/template', { - body: { - type, - }, - }), + queryFn: () => + post<{ data: string }>('instruction-generate/template', { + body: { + type, + }, + }), enabled: !disabled, retry: 0, }) @@ -164,7 +160,7 @@ export const useToggleAppStarMutation = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ appId, isStarred }: { appId: string, isStarred: boolean }) => { + mutationFn: ({ appId, isStarred }: { appId: string; isStarred: boolean }) => { return isStarred ? consoleClient.apps.byAppId.star.delete({ params: { app_id: appId }, @@ -238,7 +234,11 @@ export const useAppTokenCosts = (appId: string, params?: DateRangeParams) => { } export const useWorkflowDailyConversations = (appId: string, params?: DateRangeParams) => { - return useWorkflowStatisticsQuery('daily-conversations', appId, params) + return useWorkflowStatisticsQuery( + 'daily-conversations', + appId, + params, + ) } export const useWorkflowDailyTerminals = (appId: string, params?: DateRangeParams) => { @@ -250,13 +250,20 @@ export const useWorkflowTokenCosts = (appId: string, params?: DateRangeParams) = } export const useWorkflowAverageInteractions = (appId: string, params?: DateRangeParams) => { - return useWorkflowStatisticsQuery('average-app-interactions', appId, params) + return useWorkflowStatisticsQuery( + 'average-app-interactions', + appId, + params, + ) } export const useAppVoices = (appId?: string, language?: string) => { return useQuery({ queryKey: [NAME_SPACE, 'voices', appId, language || 'en-US'], - queryFn: () => get(`/apps/${appId}/text-to-audio/voices`, { params: { language: language || 'en-US' } }), + queryFn: () => + get(`/apps/${appId}/text-to-audio/voices`, { + params: { language: language || 'en-US' }, + }), enabled: !!appId, }) } @@ -272,8 +279,7 @@ export const useAppApiKeys = (appId?: string, options?: { enabled?: boolean }) = export const useInvalidateAppApiKeys = () => { const queryClient = useQueryClient() return (appId?: string) => { - if (!appId) - return + if (!appId) return queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'api-keys', appId], }) diff --git a/web/service/use-base.ts b/web/service/use-base.ts index d9b74e315a7d53..44d270f645e646 100644 --- a/web/service/use-base.ts +++ b/web/service/use-base.ts @@ -9,8 +9,7 @@ import { useCallback } from 'react' export const useInvalid = (key?: QueryKey) => { const queryClient = useQueryClient() return useCallback(() => { - if (!key) - return + if (!key) return queryClient.invalidateQueries({ queryKey: key }) }, [queryClient, key]) } @@ -22,8 +21,7 @@ export const useInvalid = (key?: QueryKey) => { export const useReset = (key?: QueryKey) => { const queryClient = useQueryClient() return useCallback(() => { - if (!key) - return + if (!key) return queryClient.resetQueries({ queryKey: key }) }, [queryClient, key]) } diff --git a/web/service/use-billing.ts b/web/service/use-billing.ts index e7c7c82f647e6c..bb9abba3d7f4ee 100644 --- a/web/service/use-billing.ts +++ b/web/service/use-billing.ts @@ -7,10 +7,11 @@ const currentPlanVectorSpaceQueryKey = ['billing', 'current-plan-vector-space'] export const useBindPartnerStackInfo = () => { return useMutation({ mutationKey: consoleQuery.billing.partners.byPartnerKey.tenants.put.mutationKey(), - mutationFn: (data: { partnerKey: string, clickId: string }) => consoleClient.billing.partners.byPartnerKey.tenants.put({ - params: { partner_key: data.partnerKey }, - body: { click_id: data.clickId }, - }), + mutationFn: (data: { partnerKey: string; clickId: string }) => + consoleClient.billing.partners.byPartnerKey.tenants.put({ + params: { partner_key: data.partnerKey }, + body: { click_id: data.clickId }, + }), }) } diff --git a/web/service/use-common.ts b/web/service/use-common.ts index f02d1f403ddbd6..84be767b3db7a3 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -33,17 +33,21 @@ export const commonQueryKeys = { accountIntegrates: [NAME_SPACE, 'account-integrates'] as const, notionConnection: [NAME_SPACE, 'notion-connection'] as const, codeBasedExtensions: (module?: string) => [NAME_SPACE, 'code-based-extensions', module] as const, - invitationCheck: (params?: { workspace_id?: string, email?: string, token?: string }) => [ - NAME_SPACE, - 'invitation-check', - params?.workspace_id ?? '', - params?.email ?? '', - params?.token ?? '', - ] as const, + invitationCheck: (params?: { workspace_id?: string; email?: string; token?: string }) => + [ + NAME_SPACE, + 'invitation-check', + params?.workspace_id ?? '', + params?.email ?? '', + params?.token ?? '', + ] as const, notionBinding: (code?: string | null) => [NAME_SPACE, 'notion-binding', code] as const, - modelParameterRules: (provider?: string, model?: string) => [NAME_SPACE, 'model-parameter-rules', provider, model] as const, - langGeniusVersion: (currentVersion?: string | null) => [NAME_SPACE, 'langgenius-version', currentVersion] as const, - forgotPasswordValidity: (token?: string | null) => [NAME_SPACE, 'forgot-password-validity', token] as const, + modelParameterRules: (provider?: string, model?: string) => + [NAME_SPACE, 'model-parameter-rules', provider, model] as const, + langGeniusVersion: (currentVersion?: string | null) => + [NAME_SPACE, 'langgenius-version', currentVersion] as const, + forgotPasswordValidity: (token?: string | null) => + [NAME_SPACE, 'forgot-password-validity', token] as const, dataSourceIntegrates: [NAME_SPACE, 'data-source-integrates'] as const, } @@ -58,36 +62,33 @@ export const useGenerateStructuredOutputRules = () => { return useMutation({ mutationKey: [NAME_SPACE, 'generate-structured-output-rules'], mutationFn: (body: StructuredOutputRulesRequestBody) => { - return post( - '/rule-structured-output-generate', - { body }, - ) + return post('/rule-structured-output-generate', { body }) }, }) } -export type MailSendResponse = { data: string, result: string } +export type MailSendResponse = { data: string; result: string } export const useSendMail = () => { return useMutation({ mutationKey: [NAME_SPACE, 'mail-send'], - mutationFn: (body: { email: string, language: string }) => { + mutationFn: (body: { email: string; language: string }) => { return post('/email-register/send-email', { body }) }, }) } -export type MailValidityResponse = { is_valid: boolean, token: string } +export type MailValidityResponse = { is_valid: boolean; token: string } export const useMailValidity = () => { return useMutation({ mutationKey: [NAME_SPACE, 'mail-validity'], - mutationFn: (body: { email: string, code: string, token: string }) => { + mutationFn: (body: { email: string; code: string; token: string }) => { return post('/email-register/validity', { body }) }, }) } -export type MailRegisterResponse = { result: string, data: Record } +export type MailRegisterResponse = { result: string; data: Record } export const useMailRegister = () => { return useMutation({ @@ -118,11 +119,12 @@ type MemberResponse = { export const useMembers = (language?: AccessControlTemplateLanguage) => { return useQuery({ queryKey: [...commonQueryKeys.members, language], - queryFn: () => get('/workspaces/current/members', { - params: { - language, - }, - }), + queryFn: () => + get('/workspaces/current/members', { + params: { + language, + }, + }), }) } @@ -170,7 +172,7 @@ export const useLogout = () => { }) } -type ForgotPasswordValidity = CommonResponse & { is_valid: boolean, email: string, token: string } +type ForgotPasswordValidity = CommonResponse & { is_valid: boolean; email: string; token: string } export const useVerifyForgotPasswordToken = (token?: string | null) => { return useQuery({ queryKey: commonQueryKeys.forgotPasswordValidity(token), @@ -223,14 +225,24 @@ export const useCodeBasedExtensions = (module: string) => { }) } -export const useInvitationCheck = (params?: { workspace_id?: string, email?: string, token?: string }, enabled?: boolean) => { +export const useInvitationCheck = ( + params?: { workspace_id?: string; email?: string; token?: string }, + enabled?: boolean, +) => { return useQuery({ queryKey: commonQueryKeys.invitationCheck(params), - queryFn: () => get<{ - is_valid: boolean - data: { workspace_name: string, email: string, workspace_id: string, account_status?: string, requires_setup?: boolean } - result: string - }>('/activate/check', { params }), + queryFn: () => + get<{ + is_valid: boolean + data: { + workspace_name: string + email: string + workspace_id: string + account_status?: string + requires_setup?: boolean + } + result: string + }>('/activate/check', { params }), enabled: enabled ?? !!params?.token, retry: false, }) @@ -239,7 +251,8 @@ export const useInvitationCheck = (params?: { workspace_id?: string, email?: str export const useNotionBinding = (code?: string | null, enabled?: boolean) => { return useQuery({ queryKey: commonQueryKeys.notionBinding(code), - queryFn: () => get<{ result: string }>('/oauth/data-source/binding/notion', { params: { code } }), + queryFn: () => + get<{ result: string }>('/oauth/data-source/binding/notion', { params: { code } }), enabled: !!code && (enabled ?? true), }) } @@ -247,7 +260,11 @@ export const useNotionBinding = (code?: string | null, enabled?: boolean) => { export const useModelParameterRules = (provider?: string, model?: string, enabled?: boolean) => { return useQuery<{ data: ModelParameterRule[] }>({ queryKey: commonQueryKeys.modelParameterRules(provider, model), - queryFn: () => get<{ data: ModelParameterRule[] }>(`/workspaces/current/model-providers/${provider}/models/parameter-rules`, { params: { model }, silent: true }), + queryFn: () => + get<{ data: ModelParameterRule[] }>( + `/workspaces/current/model-providers/${provider}/models/parameter-rules`, + { params: { model }, silent: true }, + ), enabled: !!provider && !!model && (enabled ?? true), }) } diff --git a/web/service/use-datasource.ts b/web/service/use-datasource.ts index 07fe6cd5634f8c..2e15fe2e30d785 100644 --- a/web/service/use-datasource.ts +++ b/web/service/use-datasource.ts @@ -2,10 +2,7 @@ import type { DataSourceAuth, DataSourceCredential, } from '@/app/components/header/account-setting/data-source-page-new/types' -import { - useMutation, - useQuery, -} from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' import { get } from './base' import { useInvalid } from './use-base' @@ -19,8 +16,7 @@ export const useGetDataSourceListAuth = () => { }) } -export const useInvalidDataSourceListAuth = ( -) => { +export const useInvalidDataSourceListAuth = () => { return useInvalid([NAME_SPACE, 'list']) } @@ -33,24 +29,19 @@ export const useGetDefaultDataSourceListAuth = () => { }) } -export const useInvalidDefaultDataSourceListAuth = ( -) => { +export const useInvalidDefaultDataSourceListAuth = () => { return useInvalid([NAME_SPACE, 'default-list']) } -export const useGetDataSourceOAuthUrl = ( - provider: string, -) => { +export const useGetDataSourceOAuthUrl = (provider: string) => { return useMutation({ mutationKey: [NAME_SPACE, 'oauth-url', provider], mutationFn: (credentialId?: string) => { - return get< - { - authorization_url: string - state: string - context_id: string - } - >(`/oauth/plugin/${provider}/datasource/get-authorization-url?credential_id=${credentialId}`) + return get<{ + authorization_url: string + state: string + context_id: string + }>(`/oauth/plugin/${provider}/datasource/get-authorization-url?credential_id=${credentialId}`) }, }) } @@ -64,7 +55,8 @@ export const useGetDataSourceAuth = ({ }) => { return useQuery({ queryKey: [NAME_SPACE, 'specific-data-source', pluginId, provider], - queryFn: () => get<{ result: DataSourceCredential[] }>(`/auth/plugin/datasource/${pluginId}/${provider}`), + queryFn: () => + get<{ result: DataSourceCredential[] }>(`/auth/plugin/datasource/${pluginId}/${provider}`), retry: 0, }) } diff --git a/web/service/use-education.ts b/web/service/use-education.ts index a75ec6f3c7e851..ca204cffa13c0e 100644 --- a/web/service/use-education.ts +++ b/web/service/use-education.ts @@ -1,8 +1,5 @@ import type { EducationAddParams } from '@/app/education-apply/types' -import { - useMutation, - useQuery, -} from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' import { get, post } from './base' import { useInvalid } from './use-base' @@ -17,11 +14,7 @@ export const useEducationVerify = () => { }) } -export const useEducationAdd = ({ - onSuccess, -}: { - onSuccess?: () => void -}) => { +export const useEducationAdd = ({ onSuccess }: { onSuccess?: () => void }) => { return useMutation({ mutationKey: [NAME_SPACE, 'education-add'], mutationFn: (params: EducationAddParams) => { @@ -41,12 +34,10 @@ type SearchParams = { export const useEducationAutocomplete = () => { return useMutation({ mutationFn: (searchParams: SearchParams) => { - const { - keywords = '', - page = 0, - limit = 40, - } = searchParams - return get<{ data: string[], has_next: boolean, curr_page: number }>(`/account/education/autocomplete?keywords=${keywords}&page=${page}&limit=${limit}`) + const { keywords = '', page = 0, limit = 40 } = searchParams + return get<{ data: string[]; has_next: boolean; curr_page: number }>( + `/account/education/autocomplete?keywords=${keywords}&page=${page}&limit=${limit}`, + ) }, }) } @@ -56,7 +47,9 @@ export const useEducationStatus = (disable?: boolean) => { enabled: !disable, queryKey: [NAME_SPACE, 'education-status'], queryFn: () => { - return get<{ is_student: boolean, allow_refresh: boolean, expire_at: number | null }>('/account/education') + return get<{ is_student: boolean; allow_refresh: boolean; expire_at: number | null }>( + '/account/education', + ) }, retry: false, staleTime: 0, // Data expires immediately, ensuring fresh data on refetch diff --git a/web/service/use-endpoints.ts b/web/service/use-endpoints.ts index 79702068ab2369..14db1f7a1d6ee9 100644 --- a/web/service/use-endpoints.ts +++ b/web/service/use-endpoints.ts @@ -1,11 +1,5 @@ -import type { - EndpointsResponse, -} from '@/app/components/plugins/types' -import { - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query' +import type { EndpointsResponse } from '@/app/components/plugins/types' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { get, post } from './base' const NAME_SPACE = 'endpoints' @@ -13,24 +7,23 @@ const NAME_SPACE = 'endpoints' export const useEndpointList = (pluginID: string) => { return useQuery({ queryKey: [NAME_SPACE, 'list', pluginID], - queryFn: () => get('/workspaces/current/endpoints/list/plugin', { - params: { - plugin_id: pluginID, - page: 1, - page_size: 100, - }, - }), + queryFn: () => + get('/workspaces/current/endpoints/list/plugin', { + params: { + plugin_id: pluginID, + page: 1, + page_size: 100, + }, + }), }) } export const useInvalidateEndpointList = () => { const queryClient = useQueryClient() return (pluginID: string) => { - queryClient.invalidateQueries( - { - queryKey: [NAME_SPACE, 'list', pluginID], - }, - ) + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'list', pluginID], + }) } } @@ -43,7 +36,7 @@ export const useCreateEndpoint = ({ }) => { return useMutation({ mutationKey: [NAME_SPACE, 'create'], - mutationFn: (payload: { pluginUniqueID: string, state: Record }) => { + mutationFn: (payload: { pluginUniqueID: string; state: Record }) => { const { pluginUniqueID, state } = payload const newName = state.name delete state.name @@ -69,7 +62,7 @@ export const useUpdateEndpoint = ({ }) => { return useMutation({ mutationKey: [NAME_SPACE, 'update'], - mutationFn: (payload: { endpointID: string, state: Record }) => { + mutationFn: (payload: { endpointID: string; state: Record }) => { const { endpointID, state } = payload const newName = state.name delete state.name diff --git a/web/service/use-explore.ts b/web/service/use-explore.ts index fecdb2132ceee5..b2a817862b9871 100644 --- a/web/service/use-explore.ts +++ b/web/service/use-explore.ts @@ -22,13 +22,14 @@ type ExploreAppListData = { export const useExploreAppList = (options: { enabled?: boolean } = {}) => { const locale = useLocale() - const exploreAppsInput = locale - ? { query: { language: locale } } - : {} + const exploreAppsInput = locale ? { query: { language: locale } } : {} const exploreAppsLanguage = exploreAppsInput?.query?.language return useQuery({ - queryKey: [...consoleQuery.explore.apps.get.queryKey({ input: exploreAppsInput }), exploreAppsLanguage], + queryKey: [ + ...consoleQuery.explore.apps.get.queryKey({ input: exploreAppsInput }), + exploreAppsLanguage, + ], queryFn: async () => { const { categories, recommended_apps } = await fetchAppList(exploreAppsLanguage) return { @@ -42,13 +43,14 @@ export const useExploreAppList = (options: { enabled?: boolean } = {}) => { export const useLearnDifyAppList = () => { const locale = useLocale() - const learnDifyAppsInput = locale - ? { query: { language: locale } } - : {} + const learnDifyAppsInput = locale ? { query: { language: locale } } : {} const learnDifyAppsLanguage = learnDifyAppsInput?.query?.language return useQuery({ - queryKey: [...consoleQuery.explore.apps.learnDify.get.queryKey({ input: learnDifyAppsInput }), learnDifyAppsLanguage], + queryKey: [ + ...consoleQuery.explore.apps.learnDify.get.queryKey({ input: learnDifyAppsInput }), + learnDifyAppsLanguage, + ], queryFn: async () => { const { recommended_apps } = await fetchLearnDifyAppList(learnDifyAppsLanguage) return [...recommended_apps].sort((a, b) => a.position - b.position) @@ -82,7 +84,8 @@ export const useUpdateAppPinStatus = () => { const client = useQueryClient() return useMutation({ mutationKey: consoleQuery.installedApps.byInstalledAppId.patch.mutationKey(), - mutationFn: ({ appId, isPinned }: { appId: string, isPinned: boolean }) => updatePinStatus(appId, isPinned), + mutationFn: ({ appId, isPinned }: { appId: string; isPinned: boolean }) => + updatePinStatus(appId, isPinned), onSuccess: () => { client.invalidateQueries({ queryKey: consoleQuery.installedApps.get.queryKey({ input: {} }), @@ -102,7 +105,9 @@ export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => { return useQuery({ queryKey: [ - ...consoleQuery.enterprise.webAppAuth.getWebAppAccessMode.queryKey({ input: appAccessModeInput }), + ...consoleQuery.enterprise.webAppAuth.getWebAppAccessMode.queryKey({ + input: appAccessModeInput, + }), webappAuthEnabled, installedAppId, ], @@ -112,8 +117,7 @@ export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => { accessMode: AccessMode.PUBLIC, } } - if (!installedAppId) - return Promise.reject(new Error('App ID is required to get access mode')) + if (!installedAppId) return Promise.reject(new Error('App ID is required to get access mode')) return getAppAccessModeByAppId(installedAppId) }, @@ -126,10 +130,14 @@ export const useGetInstalledAppParams = (appId: string | null) => { const installedAppId = installedAppParamsInput.params.installed_app_id return useQuery({ - queryKey: [...consoleQuery.installedApps.byInstalledAppId.parameters.get.queryKey({ input: installedAppParamsInput }), installedAppId], + queryKey: [ + ...consoleQuery.installedApps.byInstalledAppId.parameters.get.queryKey({ + input: installedAppParamsInput, + }), + installedAppId, + ], queryFn: () => { - if (!installedAppId) - return Promise.reject(new Error('App ID is required to get app params')) + if (!installedAppId) return Promise.reject(new Error('App ID is required to get app params')) return fetchInstalledAppParams(installedAppId) }, enabled: !!installedAppId, @@ -141,10 +149,14 @@ export const useGetInstalledAppMeta = (appId: string | null) => { const installedAppId = installedAppMetaInput.params.installed_app_id return useQuery({ - queryKey: [...consoleQuery.installedApps.byInstalledAppId.meta.get.queryKey({ input: installedAppMetaInput }), installedAppId], + queryKey: [ + ...consoleQuery.installedApps.byInstalledAppId.meta.get.queryKey({ + input: installedAppMetaInput, + }), + installedAppId, + ], queryFn: () => { - if (!installedAppId) - return Promise.reject(new Error('App ID is required to get app meta')) + if (!installedAppId) return Promise.reject(new Error('App ID is required to get app meta')) return fetchInstalledAppMeta(installedAppId) }, enabled: !!installedAppId, diff --git a/web/service/use-flow.ts b/web/service/use-flow.ts index 74aa78ec1068ef..9dc4819a5dfb51 100644 --- a/web/service/use-flow.ts +++ b/web/service/use-flow.ts @@ -15,9 +15,7 @@ type Params = { flowType: FlowType } -const useFLow = ({ - flowType, -}: Params) => { +const useFLow = ({ flowType }: Params) => { return { useInvalidateConversationVarValues: curry(useInvalidateConversationVarValuesInner)(flowType), useInvalidateSysVarValues: curry(useInvalidateSysVarValuesInner)(flowType), diff --git a/web/service/use-log.ts b/web/service/use-log.ts index 359dd14568e8d6..d2d3d542169db8 100644 --- a/web/service/use-log.ts +++ b/web/service/use-log.ts @@ -19,11 +19,12 @@ const NAME_SPACE = 'log' export const useAnnotationsCount = (appId: string) => { return useQuery({ queryKey: [NAME_SPACE, 'annotations-count', appId], - queryFn: () => consoleClient.apps.byAppId.annotations.count.get({ - params: { - app_id: appId, - }, - }), + queryFn: () => + consoleClient.apps.byAppId.annotations.count.get({ + params: { + app_id: appId, + }, + }), enabled: !!appId, }) } @@ -53,7 +54,8 @@ type CompletionConversationsParams = { export const useCompletionConversations = ({ appId, params }: CompletionConversationsParams) => { return useQuery({ queryKey: [NAME_SPACE, 'completion-conversations', appId, params], - queryFn: () => get(`/apps/${appId}/completion-conversations`, { params }), + queryFn: () => + get(`/apps/${appId}/completion-conversations`, { params }), enabled: !!appId, }) } @@ -63,7 +65,10 @@ export const useCompletionConversations = ({ appId, params }: CompletionConversa export const useChatConversationDetail = (appId?: string, conversationId?: string) => { return useQuery({ queryKey: [NAME_SPACE, 'chat-conversation-detail', appId, conversationId], - queryFn: () => get(`/apps/${appId}/chat-conversations/${conversationId}`), + queryFn: () => + get( + `/apps/${appId}/chat-conversations/${conversationId}`, + ), enabled: !!appId && !!conversationId, }) } @@ -73,7 +78,10 @@ export const useChatConversationDetail = (appId?: string, conversationId?: strin export const useCompletionConversationDetail = (appId?: string, conversationId?: string) => { return useQuery({ queryKey: [NAME_SPACE, 'completion-conversation-detail', appId, conversationId], - queryFn: () => get(`/apps/${appId}/completion-conversations/${conversationId}`), + queryFn: () => + get( + `/apps/${appId}/completion-conversations/${conversationId}`, + ), enabled: !!appId && !!conversationId, }) } @@ -100,7 +108,10 @@ type WorkflowPausedDetailsParams = { enabled?: boolean } -export const useWorkflowPausedDetails = ({ workflowRunId, enabled = true }: WorkflowPausedDetailsParams) => { +export const useWorkflowPausedDetails = ({ + workflowRunId, + enabled = true, +}: WorkflowPausedDetailsParams) => { return useQuery({ queryKey: [NAME_SPACE, 'workflow-paused-details', workflowRunId], queryFn: () => get(`/workflow/${workflowRunId}/pause-details`), diff --git a/web/service/use-models.ts b/web/service/use-models.ts index 5b984fc32ce6d9..21e40356d488fe 100644 --- a/web/service/use-models.ts +++ b/web/service/use-models.ts @@ -11,65 +11,69 @@ import { useQuery, // useQueryClient, } from '@tanstack/react-query' -import { - del, - get, - post, - put, -} from './base' +import { del, get, post, put } from './base' const NAME_SPACE = 'models' export const useModelProviderModelList = (provider: string) => { return useQuery({ queryKey: [NAME_SPACE, 'model-list', provider], - queryFn: () => get<{ data: ModelItem[] }>(`/workspaces/current/model-providers/${provider}/models`), + queryFn: () => + get<{ data: ModelItem[] }>(`/workspaces/current/model-providers/${provider}/models`), }) } -export const useGetProviderCredential = (enabled: boolean, provider: string, credentialId?: string) => { +export const useGetProviderCredential = ( + enabled: boolean, + provider: string, + credentialId?: string, +) => { return useQuery({ enabled, queryKey: [NAME_SPACE, 'model-list', provider, credentialId], - queryFn: () => get(`/workspaces/current/model-providers/${provider}/credentials${credentialId ? `?credential_id=${credentialId}` : ''}`), + queryFn: () => + get( + `/workspaces/current/model-providers/${provider}/credentials${credentialId ? `?credential_id=${credentialId}` : ''}`, + ), }) } export const useAddProviderCredential = (provider: string) => { return useMutation({ - mutationFn: (data: ProviderCredential) => post<{ result: string }>(`/workspaces/current/model-providers/${provider}/credentials`, { - body: data, - }), + mutationFn: (data: ProviderCredential) => + post<{ result: string }>(`/workspaces/current/model-providers/${provider}/credentials`, { + body: data, + }), }) } export const useEditProviderCredential = (provider: string) => { return useMutation({ - mutationFn: (data: ProviderCredential) => put<{ result: string }>(`/workspaces/current/model-providers/${provider}/credentials`, { - body: data, - }), + mutationFn: (data: ProviderCredential) => + put<{ result: string }>(`/workspaces/current/model-providers/${provider}/credentials`, { + body: data, + }), }) } export const useDeleteProviderCredential = (provider: string) => { return useMutation({ - mutationFn: (data: { - credential_id: string - }) => del<{ result: string }>(`/workspaces/current/model-providers/${provider}/credentials`, { - body: data, - }), + mutationFn: (data: { credential_id: string }) => + del<{ result: string }>(`/workspaces/current/model-providers/${provider}/credentials`, { + body: data, + }), }) } export const useActiveProviderCredential = (provider: string) => { return useMutation({ - mutationFn: (data: { - credential_id: string - model?: string - model_type?: ModelTypeEnum - }) => post<{ result: string }>(`/workspaces/current/model-providers/${provider}/credentials/switch`, { - body: data, - }), + mutationFn: (data: { credential_id: string; model?: string; model_type?: ModelTypeEnum }) => + post<{ result: string }>( + `/workspaces/current/model-providers/${provider}/credentials/switch`, + { + body: data, + }, + ), }) } @@ -84,7 +88,10 @@ export const useGetModelCredential = ( return useQuery({ enabled, queryKey: [NAME_SPACE, 'model-list', provider, model, modelType, credentialId, configFrom], - queryFn: () => get(`/workspaces/current/model-providers/${provider}/models/credentials?model=${model}&model_type=${modelType}&config_from=${configFrom}${credentialId ? `&credential_id=${credentialId}` : ''}`), + queryFn: () => + get( + `/workspaces/current/model-providers/${provider}/models/credentials?model=${model}&model_type=${modelType}&config_from=${configFrom}${credentialId ? `&credential_id=${credentialId}` : ''}`, + ), staleTime: 0, gcTime: 0, }) @@ -92,52 +99,58 @@ export const useGetModelCredential = ( export const useAddModelCredential = (provider: string) => { return useMutation({ - mutationFn: (data: ModelCredentialPayload) => post<{ result: string }>(`/workspaces/current/model-providers/${provider}/models/credentials`, { - body: data, - }), + mutationFn: (data: ModelCredentialPayload) => + post<{ result: string }>( + `/workspaces/current/model-providers/${provider}/models/credentials`, + { + body: data, + }, + ), }) } export const useEditModelCredential = (provider: string) => { return useMutation({ - mutationFn: (data: ModelCredentialPayload) => put<{ result: string }>(`/workspaces/current/model-providers/${provider}/models/credentials`, { - body: data, - }), + mutationFn: (data: ModelCredentialPayload) => + put<{ result: string }>( + `/workspaces/current/model-providers/${provider}/models/credentials`, + { + body: data, + }, + ), }) } export const useDeleteModelCredential = (provider: string) => { return useMutation({ - mutationFn: (data: { - credential_id: string - model: string - model_type: ModelTypeEnum - }) => del<{ result: string }>(`/workspaces/current/model-providers/${provider}/models/credentials`, { - body: data, - }), + mutationFn: (data: { credential_id: string; model: string; model_type: ModelTypeEnum }) => + del<{ result: string }>( + `/workspaces/current/model-providers/${provider}/models/credentials`, + { + body: data, + }, + ), }) } export const useDeleteModel = (provider: string) => { return useMutation({ - mutationFn: (data: { - model: string - model_type: ModelTypeEnum - }) => del<{ result: string }>(`/workspaces/current/model-providers/${provider}/models`, { - body: data, - }), + mutationFn: (data: { model: string; model_type: ModelTypeEnum }) => + del<{ result: string }>(`/workspaces/current/model-providers/${provider}/models`, { + body: data, + }), }) } export const useActiveModelCredential = (provider: string) => { return useMutation({ - mutationFn: (data: { - credential_id: string - model: string - model_type: ModelTypeEnum - }) => post<{ result: string }>(`/workspaces/current/model-providers/${provider}/models/credentials/switch`, { - body: data, - }), + mutationFn: (data: { credential_id: string; model: string; model_type: ModelTypeEnum }) => + post<{ result: string }>( + `/workspaces/current/model-providers/${provider}/models/credentials/switch`, + { + body: data, + }, + ), }) } @@ -149,8 +162,9 @@ export const useUpdateModelLoadBalancingConfig = (provider: string) => { model_type: ModelTypeEnum load_balancing: ModelLoadBalancingConfig credential_id?: string - }) => post<{ result: string }>(`/workspaces/current/model-providers/${provider}/models`, { - body: data, - }), + }) => + post<{ result: string }>(`/workspaces/current/model-providers/${provider}/models`, { + body: data, + }), }) } diff --git a/web/service/use-oauth.ts b/web/service/use-oauth.ts index 68e157c15ca6db..4ef1b955e175b0 100644 --- a/web/service/use-oauth.ts +++ b/web/service/use-oauth.ts @@ -16,7 +16,12 @@ type OAuthAuthorizeResponse = { export const useOAuthAppInfo = (client_id: string, redirect_uri: string) => { return useQuery({ queryKey: [NAME_SPACE, 'authAppInfo', client_id, redirect_uri], - queryFn: () => post('/oauth/provider', { body: { client_id, redirect_uri } }, { silent: true }), + queryFn: () => + post( + '/oauth/provider', + { body: { client_id, redirect_uri } }, + { silent: true }, + ), enabled: Boolean(client_id && redirect_uri), }) } @@ -24,6 +29,7 @@ export const useOAuthAppInfo = (client_id: string, redirect_uri: string) => { export const useAuthorizeOAuthApp = () => { return useMutation({ mutationKey: [NAME_SPACE, 'authorize'], - mutationFn: (payload: { client_id: string }) => post('/oauth/provider/authorize', { body: payload }), + mutationFn: (payload: { client_id: string }) => + post('/oauth/provider/authorize', { body: payload }), }) } diff --git a/web/service/use-pipeline.ts b/web/service/use-pipeline.ts index 70055fc7186789..c348728f52c43d 100644 --- a/web/service/use-pipeline.ts +++ b/web/service/use-pipeline.ts @@ -69,15 +69,22 @@ export const usePipelineTemplateById = (params: PipelineTemplateByIdRequest, ena } export const useUpdateTemplateInfo = ( - mutationOptions: MutationOptions = {}, + mutationOptions: MutationOptions< + UpdateTemplateInfoResponse, + Error, + UpdateTemplateInfoRequest + > = {}, ) => { return useMutation({ mutationKey: [NAME_SPACE, 'template-update'], mutationFn: (request: UpdateTemplateInfoRequest) => { const { template_id, ...rest } = request - return patch(`/rag/pipeline/customized/templates/${template_id}`, { - body: rest, - }) + return patch( + `/rag/pipeline/customized/templates/${template_id}`, + { + body: rest, + }, + ) }, ...mutationOptions, }) @@ -113,7 +120,11 @@ export const useImportPipelineDSL = ( return useMutation({ mutationKey: [NAME_SPACE, 'dsl-import'], mutationFn: (request: ImportPipelineDSLRequest) => { - return post('/rag/pipelines/imports', { body: request }, { silent: true }) + return post( + '/rag/pipelines/imports', + { body: request }, + { silent: true }, + ) }, ...mutationOptions, }) @@ -125,7 +136,11 @@ export const useImportPipelineDSLConfirm = ( return useMutation({ mutationKey: [NAME_SPACE, 'dsl-import-confirm'], mutationFn: (importId: string) => { - return post(`/rag/pipelines/imports/${importId}/confirm`, {}, { silent: true }) + return post( + `/rag/pipelines/imports/${importId}/confirm`, + {}, + { silent: true }, + ) }, ...mutationOptions, }) @@ -137,22 +152,30 @@ export const useCheckPipelineDependencies = ( return useMutation({ mutationKey: [NAME_SPACE, 'check-dependencies'], mutationFn: (pipelineId: string) => { - return get(`/rag/pipelines/imports/${pipelineId}/check-dependencies`) + return get( + `/rag/pipelines/imports/${pipelineId}/check-dependencies`, + ) }, ...mutationOptions, }) } -export const useDraftPipelineProcessingParams = (params: PipelineProcessingParamsRequest, enabled = true) => { +export const useDraftPipelineProcessingParams = ( + params: PipelineProcessingParamsRequest, + enabled = true, +) => { const { pipeline_id, node_id } = params return useQuery({ queryKey: [NAME_SPACE, 'draft-pipeline-processing-params', pipeline_id, node_id], queryFn: () => { - return get(`/rag/pipelines/${pipeline_id}/workflows/draft/processing/parameters`, { - params: { - node_id, + return get( + `/rag/pipelines/${pipeline_id}/workflows/draft/processing/parameters`, + { + params: { + node_id, + }, }, - }) + ) }, staleTime: 0, enabled, @@ -164,11 +187,14 @@ export const usePublishedPipelineProcessingParams = (params: PipelineProcessingP return useQuery({ queryKey: [NAME_SPACE, 'published-pipeline-processing-params', pipeline_id, node_id], queryFn: () => { - return get(`/rag/pipelines/${pipeline_id}/workflows/published/processing/parameters`, { - params: { - node_id, + return get( + `/rag/pipelines/${pipeline_id}/workflows/published/processing/parameters`, + { + params: { + node_id, + }, }, - }) + ) }, staleTime: 0, }) @@ -198,57 +224,78 @@ export const usePublishedPipelineInfo = (pipelineId: string) => { return useQuery({ queryKey: [...publishedPipelineInfoQueryKeyPrefix, pipelineId], queryFn: () => { - return get(`/rag/pipelines/${pipelineId}/workflows/publish`) + return get( + `/rag/pipelines/${pipelineId}/workflows/publish`, + ) }, enabled: !!pipelineId, }) } export const useRunPublishedPipeline = ( - mutationOptions: MutationOptions = {}, + mutationOptions: MutationOptions< + PublishedPipelineRunPreviewResponse | PublishedPipelineRunResponse, + Error, + PublishedPipelineRunRequest + > = {}, ) => { return useMutation({ mutationKey: [NAME_SPACE, 'run-published-pipeline'], mutationFn: (request: PublishedPipelineRunRequest) => { const { pipeline_id: pipelineId, is_preview, ...rest } = request - return post(`/rag/pipelines/${pipelineId}/workflows/published/run`, { - body: { - ...rest, - is_preview, - response_mode: 'blocking', + return post( + `/rag/pipelines/${pipelineId}/workflows/published/run`, + { + body: { + ...rest, + is_preview, + response_mode: 'blocking', + }, }, - }) + ) }, ...mutationOptions, }) } -export const useDraftPipelinePreProcessingParams = (params: PipelinePreProcessingParamsRequest, enabled = true) => { +export const useDraftPipelinePreProcessingParams = ( + params: PipelinePreProcessingParamsRequest, + enabled = true, +) => { const { pipeline_id, node_id } = params return useQuery({ queryKey: [NAME_SPACE, 'draft-pipeline-pre-processing-params', pipeline_id, node_id], queryFn: () => { - return get(`/rag/pipelines/${pipeline_id}/workflows/draft/pre-processing/parameters`, { - params: { - node_id, + return get( + `/rag/pipelines/${pipeline_id}/workflows/draft/pre-processing/parameters`, + { + params: { + node_id, + }, }, - }) + ) }, staleTime: 0, enabled, }) } -export const usePublishedPipelinePreProcessingParams = (params: PipelinePreProcessingParamsRequest, enabled = true) => { +export const usePublishedPipelinePreProcessingParams = ( + params: PipelinePreProcessingParamsRequest, + enabled = true, +) => { const { pipeline_id, node_id } = params return useQuery({ queryKey: [NAME_SPACE, 'published-pipeline-pre-processing-params', pipeline_id, node_id], queryFn: () => { - return get(`/rag/pipelines/${pipeline_id}/workflows/published/pre-processing/parameters`, { - params: { - node_id, + return get( + `/rag/pipelines/${pipeline_id}/workflows/published/pre-processing/parameters`, + { + params: { + node_id, + }, }, - }) + ) }, staleTime: 0, enabled, @@ -258,11 +305,10 @@ export const usePublishedPipelinePreProcessingParams = (params: PipelinePreProce export const useExportPipelineDSL = () => { return useMutation({ mutationKey: [NAME_SPACE, 'export-pipeline-dsl'], - mutationFn: ({ - pipelineId, - include = false, - }: { pipelineId: string, include?: boolean }) => { - return get(`/rag/pipelines/${pipelineId}/exports?include_secret=${include}`) + mutationFn: ({ pipelineId, include = false }: { pipelineId: string; include?: boolean }) => { + return get( + `/rag/pipelines/${pipelineId}/exports?include_secret=${include}`, + ) }, }) } @@ -297,7 +343,9 @@ export const usePipelineExecutionLog = (params: PipelineExecutionLogRequest) => return useQuery({ queryKey: [NAME_SPACE, 'pipeline-execution-log', dataset_id, document_id], queryFn: () => { - return get(`/datasets/${dataset_id}/documents/${document_id}/pipeline-execution-log`) + return get( + `/datasets/${dataset_id}/documents/${document_id}/pipeline-execution-log`, + ) }, staleTime: 0, }) @@ -336,15 +384,22 @@ export const useConvertDatasetToPipeline = () => { } export const useDatasourceSingleRun = ( - mutationOptions: MutationOptions = {}, + mutationOptions: MutationOptions< + DatasourceNodeSingleRunResponse, + Error, + DatasourceNodeSingleRunRequest + > = {}, ) => { return useMutation({ mutationKey: [NAME_SPACE, 'datasource-node-single-run'], mutationFn: (params: DatasourceNodeSingleRunRequest) => { const { pipeline_id: pipelineId, ...rest } = params - return post(`/rag/pipelines/${pipelineId}/workflows/draft/datasource/variables-inspect`, { - body: rest, - }) + return post( + `/rag/pipelines/${pipelineId}/workflows/draft/datasource/variables-inspect`, + { + body: rest, + }, + ) }, ...mutationOptions, }) diff --git a/web/service/use-plugins-auth.ts b/web/service/use-plugins-auth.ts index e4c729730c3ded..70c46e5457f499 100644 --- a/web/service/use-plugins-auth.ts +++ b/web/service/use-plugins-auth.ts @@ -1,42 +1,31 @@ import type { FormSchema } from '@/app/components/base/form/types' -import type { - Credential, - CredentialTypeEnum, -} from '@/app/components/plugins/plugin-auth/types' -import { - useMutation, - useQuery, -} from '@tanstack/react-query' +import type { Credential, CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' +import { useMutation, useQuery } from '@tanstack/react-query' import { del, get, post } from './base' import { useInvalid } from './use-base' const NAME_SPACE = 'plugins-auth' -export const useGetPluginCredentialInfo = ( - url: string, -) => { +export const useGetPluginCredentialInfo = (url: string) => { return useQuery({ enabled: !!url, queryKey: [NAME_SPACE, 'credential-info', url], - queryFn: () => get<{ - allow_custom_token?: boolean - supported_credential_types: string[] - credentials: Credential[] - is_oauth_custom_client_enabled: boolean - }>(url), + queryFn: () => + get<{ + allow_custom_token?: boolean + supported_credential_types: string[] + credentials: Credential[] + is_oauth_custom_client_enabled: boolean + }>(url), staleTime: 0, }) } -export const useInvalidPluginCredentialInfo = ( - url: string, -) => { +export const useInvalidPluginCredentialInfo = (url: string) => { return useInvalid([NAME_SPACE, 'credential-info', url]) } -export const useSetPluginDefaultCredential = ( - url: string, -) => { +export const useSetPluginDefaultCredential = (url: string) => { return useMutation({ mutationFn: (id: string) => { return post(url, { body: { id } }) @@ -44,9 +33,7 @@ export const useSetPluginDefaultCredential = ( }) } -export const useAddPluginCredential = ( - url: string, -) => { +export const useAddPluginCredential = (url: string) => { return useMutation({ mutationFn: (params: { credentials: Record @@ -60,9 +47,7 @@ export const useAddPluginCredential = ( }) } -export const useUpdatePluginCredential = ( - url: string, -) => { +export const useUpdatePluginCredential = (url: string) => { return useMutation({ mutationFn: (params: { credential_id: string @@ -76,9 +61,7 @@ export const useUpdatePluginCredential = ( }) } -export const useDeletePluginCredential = ( - url: string, -) => { +export const useDeletePluginCredential = (url: string) => { return useMutation({ mutationFn: (params: { credential_id: string }) => { return post(url, { body: params }) @@ -86,9 +69,7 @@ export const useDeletePluginCredential = ( }) } -export const useGetPluginCredentialSchema = ( - url: string, -) => { +export const useGetPluginCredentialSchema = (url: string) => { return useQuery({ enabled: !!url, queryKey: [NAME_SPACE, 'credential-schema', url], @@ -96,49 +77,40 @@ export const useGetPluginCredentialSchema = ( }) } -export const useGetPluginOAuthUrl = ( - url: string, -) => { +export const useGetPluginOAuthUrl = (url: string) => { return useMutation({ mutationKey: [NAME_SPACE, 'oauth-url', url], mutationFn: () => { - return get< - { - authorization_url: string - state: string - context_id: string - } - >(url) + return get<{ + authorization_url: string + state: string + context_id: string + }>(url) }, }) } -export const useGetPluginOAuthClientSchema = ( - url: string, -) => { +export const useGetPluginOAuthClientSchema = (url: string) => { return useQuery({ enabled: !!url, queryKey: [NAME_SPACE, 'oauth-client-schema', url], - queryFn: () => get<{ - schema: FormSchema[] - is_oauth_custom_client_enabled: boolean - is_system_oauth_params_exists?: boolean - client_params?: Record - redirect_uri?: string - }>(url), + queryFn: () => + get<{ + schema: FormSchema[] + is_oauth_custom_client_enabled: boolean + is_system_oauth_params_exists?: boolean + client_params?: Record + redirect_uri?: string + }>(url), staleTime: 0, }) } -export const useInvalidPluginOAuthClientSchema = ( - url: string, -) => { +export const useInvalidPluginOAuthClientSchema = (url: string) => { return useInvalid([NAME_SPACE, 'oauth-client-schema', url]) } -export const useSetPluginOAuthCustomClient = ( - url: string, -) => { +export const useSetPluginOAuthCustomClient = (url: string) => { return useMutation({ mutationFn: (params: { client_params: Record @@ -149,9 +121,7 @@ export const useSetPluginOAuthCustomClient = ( }) } -export const useDeletePluginOAuthCustomClient = ( - url: string, -) => { +export const useDeletePluginOAuthCustomClient = (url: string) => { return useMutation({ mutationFn: () => { return del<{ result: string }>(url) diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 0fa0506fedbe0e..696f698b9d472c 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -9,9 +9,7 @@ import type { FormOption, ModelProvider, } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { - AutoUpdateConfig, -} from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' +import type { AutoUpdateConfig } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' import type { DebugInfo as DebugInfoTypes, Dependency, @@ -33,12 +31,7 @@ import type { VersionInfo, VersionListResponse, } from '@/app/components/plugins/types' -import { - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query' +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { cloneDeep } from 'es-toolkit/object' import { useAtomValue } from 'jotai' import { useCallback, useEffect, useRef } from 'react' @@ -80,30 +73,31 @@ const isRecord = (value: unknown): value is Record => { } const getRecord = (value: unknown, key: string) => { - if (!isRecord(value)) - return undefined + if (!isRecord(value)) return undefined const child = value[key] return isRecord(child) ? child : undefined } const getRecordArray = (value: unknown, key: string) => { - if (!isRecord(value)) - return [] + if (!isRecord(value)) return [] const child = value[key] return Array.isArray(child) ? child.filter(isRecord) : [] } const getStringArray = (value: unknown) => { - return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [] + return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [] } const getI18nValue = (value: object | null | undefined, key: string) => { return value ? getString(Object.entries(value).find(([itemKey]) => itemKey === key)?.[1]) : '' } -const normalizeI18nObject = (value: object | null | undefined, fallback = ''): PluginDeclaration['label'] => { +const normalizeI18nObject = ( + value: object | null | undefined, + fallback = '', +): PluginDeclaration['label'] => { const en = getI18nValue(value, 'en_US') || getI18nValue(value, 'en-US') || fallback const zhHans = getI18nValue(value, 'zh_Hans') || getI18nValue(value, 'zh-Hans') || en const ja = getI18nValue(value, 'ja_JP') || getI18nValue(value, 'ja-JP') || en @@ -133,13 +127,15 @@ const normalizeI18nObject = (value: object | null | undefined, fallback = ''): P 'id-ID': en, 'nl-NL': en, 'ar-TN': en, - 'en_US': en, - 'zh_Hans': zhHans, - 'ja_JP': ja, + en_US: en, + zh_Hans: zhHans, + ja_JP: ja, } } -const normalizePluginCategory = (category: PluginInstallationItemResponse['declaration']['category']): PluginCategoryEnum => { +const normalizePluginCategory = ( + category: PluginInstallationItemResponse['declaration']['category'], +): PluginCategoryEnum => { switch (category) { case PluginCategoryEnum.tool: return PluginCategoryEnum.tool @@ -181,7 +177,9 @@ const normalizePluginMeta = (meta: Record): MetaData => { const normalizeToolCredentialOption = ( option: Record, -): NonNullable['credentials_schema'][number]['options']>[number] => ({ +): NonNullable< + NonNullable['credentials_schema'][number]['options'] +>[number] => ({ label: normalizeI18nObject(getRecord(option, 'label'), getString(option.value)), value: getString(option.value), }) @@ -200,12 +198,10 @@ const normalizeToolCredential = ( }) const normalizePluginToolDeclaration = (value: unknown): PluginDeclaration['tool'] => { - if (!isRecord(value)) - return undefined + if (!isRecord(value)) return undefined const identity = getRecord(value, 'identity') - if (!identity) - return undefined + if (!identity) return undefined const name = getString(identity.name) @@ -223,12 +219,11 @@ const normalizePluginToolDeclaration = (value: unknown): PluginDeclaration['tool } const normalizePluginEndpointDeclaration = (value: unknown): PluginDeclaration['endpoint'] => { - if (!isRecord(value)) - return undefined + if (!isRecord(value)) return undefined return { settings: getRecordArray(value, 'settings').map(normalizeToolCredential), - endpoints: getRecordArray(value, 'endpoints').map(endpoint => ({ + endpoints: getRecordArray(value, 'endpoints').map((endpoint) => ({ path: getString(endpoint.path), method: getString(endpoint.method), hidden: endpoint.hidden === undefined ? undefined : getBoolean(endpoint.hidden), @@ -237,12 +232,9 @@ const normalizePluginEndpointDeclaration = (value: unknown): PluginDeclaration[' } const normalizeParameterDefault = (value: unknown) => { - if (Array.isArray(value)) - return value.filter(item => typeof item === 'string') - if (typeof value === 'string') - return value - if (value === undefined || value === null) - return undefined + if (Array.isArray(value)) return value.filter((item) => typeof item === 'string') + if (typeof value === 'string') return value + if (value === undefined || value === null) return undefined return String(value) } @@ -389,8 +381,7 @@ const normalizePluginTriggerDeclaration = ( value: unknown, fallbackName: string, ): PluginDeclaration['trigger'] => { - if (!isRecord(value)) - return createEmptyTrigger(fallbackName) + if (!isRecord(value)) return createEmptyTrigger(fallbackName) const identity = getRecord(value, 'identity') const subscriptionConstructor = getRecord(value, 'subscription_constructor') @@ -407,12 +398,18 @@ const normalizePluginTriggerDeclaration = ( tags: getStringArray(identity?.tags), }, subscription_constructor: { - credentials_schema: getRecordArray(subscriptionConstructor, 'credentials_schema').map(normalizeCredentialSchema), + credentials_schema: getRecordArray(subscriptionConstructor, 'credentials_schema').map( + normalizeCredentialSchema, + ), oauth_schema: { client_schema: getRecordArray(oauthSchema, 'client_schema').map(normalizeCredentialSchema), - credentials_schema: getRecordArray(oauthSchema, 'credentials_schema').map(normalizeCredentialSchema), + credentials_schema: getRecordArray(oauthSchema, 'credentials_schema').map( + normalizeCredentialSchema, + ), }, - parameters: getRecordArray(subscriptionConstructor, 'parameters').map(normalizeParameterSchema), + parameters: getRecordArray(subscriptionConstructor, 'parameters').map( + normalizeParameterSchema, + ), }, subscription_schema: getRecordArray(value, 'subscription_schema').map(normalizeParameterSchema), } @@ -448,7 +445,9 @@ const normalizePluginDeclaration = (plugin: PluginInstallationItemResponse): Plu } } -export const normalizeInstalledPluginDetail = (plugin: PluginInstallationItemResponse): PluginDetail => { +export const normalizeInstalledPluginDetail = ( + plugin: PluginInstallationItemResponse, +): PluginDetail => { const declaration = normalizePluginDeclaration(plugin) return { @@ -474,32 +473,30 @@ export const normalizeInstalledPluginDetail = (plugin: PluginInstallationItemRes } } -const isUnfinishedPluginTask = (task: PluginTask) => task.status === TaskStatus.pending || task.status === TaskStatus.running +const isUnfinishedPluginTask = (task: PluginTask) => + task.status === TaskStatus.pending || task.status === TaskStatus.running const normalizeStartedPluginTask = (task: PluginTaskStart): PluginTask => ({ ...task, - plugins: task.plugins.map(plugin => ({ + plugins: task.plugins.map((plugin) => ({ ...plugin, taskId: plugin.taskId || task.id, })), }) const upsertStartedPluginTask = (queryClient: QueryClient, response: InstallPackageResponse) => { - if (!response.task) - return + if (!response.task) return const startedTask = normalizeStartedPluginTask(response.task) queryClient.setQueryData(usePluginTaskListKey, (previous) => { - if (!previous) - return { tasks: [startedTask] } + if (!previous) return { tasks: [startedTask] } - const existingTaskIndex = previous.tasks.findIndex(task => task.id === startedTask.id) - if (existingTaskIndex === -1) - return { tasks: [startedTask, ...previous.tasks] } + const existingTaskIndex = previous.tasks.findIndex((task) => task.id === startedTask.id) + if (existingTaskIndex === -1) return { tasks: [startedTask, ...previous.tasks] } return { - tasks: previous.tasks.map(task => task.id === startedTask.id ? startedTask : task), + tasks: previous.tasks.map((task) => (task.id === startedTask.id ? startedTask : task)), } }) } @@ -508,20 +505,17 @@ const preserveLocalUnfinishedPluginTasks = ( previousData: PluginTaskListResponse | undefined, nextData: PluginTaskListResponse, ) => { - if (!previousData) - return nextData + if (!previousData) return nextData - const nextTaskIds = new Set(nextData.tasks.map(task => task.id)) - const missingUnfinishedTasks = previousData.tasks.filter(task => !nextTaskIds.has(task.id) && isUnfinishedPluginTask(task)) - if (!missingUnfinishedTasks.length) - return nextData + const nextTaskIds = new Set(nextData.tasks.map((task) => task.id)) + const missingUnfinishedTasks = previousData.tasks.filter( + (task) => !nextTaskIds.has(task.id) && isUnfinishedPluginTask(task), + ) + if (!missingUnfinishedTasks.length) return nextData return { ...nextData, - tasks: [ - ...missingUnfinishedTasks, - ...nextData.tasks, - ], + tasks: [...missingUnfinishedTasks, ...nextData.tasks], } } @@ -532,14 +526,16 @@ export const useCheckInstalled = ({ pluginIds: string[] enabled: boolean }) => { - return useQuery(consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({ - input: { body: { plugin_ids: pluginIds } }, - enabled, - staleTime: 0, - select: response => ({ - plugins: response.plugins.map(normalizeInstalledPluginDetail), + return useQuery( + consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({ + input: { body: { plugin_ids: pluginIds } }, + enabled, + staleTime: 0, + select: (response) => ({ + plugins: response.plugins.map(normalizeInstalledPluginDetail), + }), }), - })) + ) } export const useInvalidateCheckInstalled = () => { @@ -572,7 +568,7 @@ const useRecommendedMarketplacePlugins = ({ }, }, ) - return response.data.plugins.map(plugin => getFormattedPlugin(plugin)) + return response.data.plugins.map((plugin) => getFormattedPlugin(plugin)) }, enabled, staleTime: 60 * 1000, @@ -580,10 +576,7 @@ const useRecommendedMarketplacePlugins = ({ } export const useFeaturedToolsRecommendations = (enabled: boolean, limit = 15) => { - const { - data: plugins = [], - isLoading, - } = useRecommendedMarketplacePlugins({ + const { data: plugins = [], isLoading } = useRecommendedMarketplacePlugins({ collection: '__recommended-plugins-tools', enabled, limit, @@ -596,10 +589,7 @@ export const useFeaturedToolsRecommendations = (enabled: boolean, limit = 15) => } export const useFeaturedTriggersRecommendations = (enabled: boolean, limit = 15) => { - const { - data: plugins = [], - isLoading, - } = useRecommendedMarketplacePlugins({ + const { data: plugins = [], isLoading } = useRecommendedMarketplacePlugins({ collection: '__recommended-plugins-triggers', enabled, limit, @@ -616,7 +606,11 @@ type UseInstalledPluginListOptions = { refetchOnMount?: boolean | 'always' } -export const useInstalledPluginList = (disable?: boolean, pageSize = 100, options?: UseInstalledPluginListOptions) => { +export const useInstalledPluginList = ( + disable?: boolean, + pageSize = 100, + options?: UseInstalledPluginListOptions, +) => { const category = options?.category const fetchPlugins = async ({ pageParam = 1 }) => { const path = category @@ -624,41 +618,37 @@ export const useInstalledPluginList = (disable?: boolean, pageSize = 100, option : '/workspaces/current/plugin/list' if (category) - return get(`${path}?page=${pageParam}&page_size=${pageSize}`) + return get( + `${path}?page=${pageParam}&page_size=${pageSize}`, + ) - return get(`${path}?page=${pageParam}&page_size=${pageSize}`) + return get( + `${path}?page=${pageParam}&page_size=${pageSize}`, + ) } - const { - data, - error, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - isSuccess, - } = useInfiniteQuery({ - enabled: !disable, - queryKey: category ? [...useInstalledPluginListKey, category] : useInstalledPluginListKey, - queryFn: fetchPlugins, - getNextPageParam: (lastPage, pages) => { - if (category) - return 'has_more' in lastPage && lastPage.has_more ? pages.length + 1 : undefined - - const totalItems = 'total' in lastPage ? lastPage.total : 0 - const currentPage = pages.length - const itemsLoaded = currentPage * pageSize - - if (itemsLoaded >= totalItems) - return - - return currentPage + 1 - }, - initialPageParam: 1, - refetchOnMount: options?.refetchOnMount, - }) + const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isSuccess } = + useInfiniteQuery({ + enabled: !disable, + queryKey: category ? [...useInstalledPluginListKey, category] : useInstalledPluginListKey, + queryFn: fetchPlugins, + getNextPageParam: (lastPage, pages) => { + if (category) + return 'has_more' in lastPage && lastPage.has_more ? pages.length + 1 : undefined + + const totalItems = 'total' in lastPage ? lastPage.total : 0 + const currentPage = pages.length + const itemsLoaded = currentPage * pageSize - const plugins = data?.pages.flatMap(page => page.plugins) ?? [] + if (itemsLoaded >= totalItems) return + + return currentPage + 1 + }, + initialPageParam: 1, + refetchOnMount: options?.refetchOnMount, + }) + + const plugins = data?.pages.flatMap((page) => page.plugins) ?? [] const firstPage = data?.pages[0] const builtinTools = firstPage && 'builtin_tools' in firstPage ? firstPage.builtin_tools : [] const total = data?.pages[0] && 'total' in data.pages[0] ? data.pages[0].total : plugins.length @@ -686,21 +676,23 @@ export const useInvalidateInstalledPluginList = () => { const queryClient = useQueryClient() const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools() return () => { - queryClient.invalidateQueries( - { - queryKey: useInstalledPluginListKey, - }, - ) + queryClient.invalidateQueries({ + queryKey: useInstalledPluginListKey, + }) invalidateAllBuiltInTools() } } -export const useInstallPackageFromMarketPlace = (options?: MutateOptions) => { +export const useInstallPackageFromMarketPlace = ( + options?: MutateOptions, +) => { const queryClient = useQueryClient() return useMutation({ ...options, mutationFn: (uniqueIdentifier: string) => { - return post('/workspaces/current/plugin/install/marketplace', { body: { plugin_unique_identifiers: [uniqueIdentifier] } }) + return post('/workspaces/current/plugin/install/marketplace', { + body: { plugin_unique_identifiers: [uniqueIdentifier] }, + }) }, onSuccess: (data, variables, context, mutation) => { upsertStartedPluginTask(queryClient, data) @@ -709,7 +701,9 @@ export const useInstallPackageFromMarketPlace = (options?: MutateOptions) => { +export const useUpdatePackageFromMarketPlace = ( + options?: MutateOptions, +) => { const queryClient = useQueryClient() return useMutation({ ...options, @@ -728,7 +722,10 @@ export const useUpdatePackageFromMarketPlace = (options?: MutateOptions { return useQuery({ queryKey: [NAME_SPACE, 'pluginDeclaration', pluginUniqueIdentifier], - queryFn: () => get<{ manifest: PluginDeclaration }>('/workspaces/current/plugin/marketplace/pkg', { params: { plugin_unique_identifier: pluginUniqueIdentifier } }), + queryFn: () => + get<{ manifest: PluginDeclaration }>('/workspaces/current/plugin/marketplace/pkg', { + params: { plugin_unique_identifier: pluginUniqueIdentifier }, + }), enabled: !!pluginUniqueIdentifier, }) } @@ -737,7 +734,10 @@ export const useVersionListOfPlugin = (pluginID: string) => { return useQuery<{ data: VersionListResponse }>({ enabled: !!pluginID, queryKey: [NAME_SPACE, 'versions', pluginID], - queryFn: () => getMarketplace<{ data: VersionListResponse }>(`/plugins/${pluginID}/versions`, { params: { page: 1, page_size: 100 } }), + queryFn: () => + getMarketplace<{ data: VersionListResponse }>(`/plugins/${pluginID}/versions`, { + params: { page: 1, page_size: 100 }, + }), }) } @@ -758,7 +758,12 @@ export const useInstallPackageFromLocal = () => { export const useInstallPackageFromGitHub = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ repoUrl, selectedVersion, selectedPackage, uniqueIdentifier }: { + mutationFn: ({ + repoUrl, + selectedVersion, + selectedPackage, + uniqueIdentifier, + }: { repoUrl: string selectedVersion: string selectedPackage: string @@ -779,16 +784,13 @@ export const useInstallPackageFromGitHub = () => { }) } -export const useUploadGitHub = (payload: { - repo: string - version: string - package: string -}) => { +export const useUploadGitHub = (payload: { repo: string; version: string; package: string }) => { return useQuery({ queryKey: [NAME_SPACE, 'uploadGitHub', payload], - queryFn: () => post('/workspaces/current/plugin/upload/github', { - body: payload, - }), + queryFn: () => + post('/workspaces/current/plugin/upload/github', { + body: payload, + }), retry: 0, }) } @@ -809,29 +811,65 @@ export const useInstallOrUpdate = ({ }) => { const { payload, plugin, installedInfo } = data - return Promise.all(payload.map(async (item, i) => { - try { - const orgAndName = `${plugin[i]?.org || plugin[i]?.author}/${plugin[i]?.name}` - const installedPayload = installedInfo[orgAndName] - const isInstalled = !!installedPayload - let uniqueIdentifier = '' - let taskId = '' - let isFinishedInstallation = false - - if (item.type === 'github') { - const data = item as GitHubItemAndMarketPlaceDependency - // From local bundle don't have data.value.github_plugin_unique_identifier - uniqueIdentifier = data.value.github_plugin_unique_identifier! - if (!uniqueIdentifier) { - const { unique_identifier } = await post('/workspaces/current/plugin/upload/github', { - body: { - repo: data.value.repo!, - version: data.value.release! || data.value.version!, - package: data.value.packages! || data.value.package!, - }, - }) - uniqueIdentifier = data.value.github_plugin_unique_identifier! || unique_identifier - // has the same version, but not installed + return Promise.all( + payload.map(async (item, i) => { + try { + const orgAndName = `${plugin[i]?.org || plugin[i]?.author}/${plugin[i]?.name}` + const installedPayload = installedInfo[orgAndName] + const isInstalled = !!installedPayload + let uniqueIdentifier = '' + let taskId = '' + let isFinishedInstallation = false + + if (item.type === 'github') { + const data = item as GitHubItemAndMarketPlaceDependency + // From local bundle don't have data.value.github_plugin_unique_identifier + uniqueIdentifier = data.value.github_plugin_unique_identifier! + if (!uniqueIdentifier) { + const { unique_identifier } = await post( + '/workspaces/current/plugin/upload/github', + { + body: { + repo: data.value.repo!, + version: data.value.release! || data.value.version!, + package: data.value.packages! || data.value.package!, + }, + }, + ) + uniqueIdentifier = data.value.github_plugin_unique_identifier! || unique_identifier + // has the same version, but not installed + if (uniqueIdentifier === installedPayload?.uniqueIdentifier) { + return { + status: TaskStatus.success, + taskId: '', + uniqueIdentifier: '', + } + } + } + if (!isInstalled) { + const response = await post( + '/workspaces/current/plugin/install/github', + { + body: { + repo: data.value.repo!, + version: data.value.release! || data.value.version!, + package: data.value.packages! || data.value.package!, + plugin_unique_identifier: uniqueIdentifier, + }, + }, + ) + upsertStartedPluginTask(queryClient, response) + const { task_id, all_installed } = response + taskId = task_id + isFinishedInstallation = all_installed + } + } + if (item.type === 'marketplace') { + const data = item as GitHubItemAndMarketPlaceDependency + uniqueIdentifier = + data.value.marketplace_plugin_unique_identifier! || + (plugin[i]?.latest_package_identifier ?? '') || + (plugin[i]?.plugin_id ?? '') if (uniqueIdentifier === installedPayload?.uniqueIdentifier) { return { status: TaskStatus.success, @@ -839,109 +877,89 @@ export const useInstallOrUpdate = ({ uniqueIdentifier: '', } } + if (!isInstalled) { + const response = await post( + '/workspaces/current/plugin/install/marketplace', + { + body: { + plugin_unique_identifiers: [uniqueIdentifier], + }, + }, + ) + upsertStartedPluginTask(queryClient, response) + const { task_id, all_installed } = response + taskId = task_id + isFinishedInstallation = all_installed + } } - if (!isInstalled) { - const response = await post('/workspaces/current/plugin/install/github', { - body: { - repo: data.value.repo!, - version: data.value.release! || data.value.version!, - package: data.value.packages! || data.value.package!, - plugin_unique_identifier: uniqueIdentifier, - }, - }) - upsertStartedPluginTask(queryClient, response) - const { task_id, all_installed } = response - taskId = task_id - isFinishedInstallation = all_installed - } - } - if (item.type === 'marketplace') { - const data = item as GitHubItemAndMarketPlaceDependency - uniqueIdentifier = data.value.marketplace_plugin_unique_identifier! || (plugin[i]?.latest_package_identifier ?? '') || (plugin[i]?.plugin_id ?? '') - if (uniqueIdentifier === installedPayload?.uniqueIdentifier) { - return { - status: TaskStatus.success, - taskId: '', - uniqueIdentifier: '', + if (item.type === 'package') { + const data = item as PackageDependency + uniqueIdentifier = data.value.unique_identifier + if (uniqueIdentifier === installedPayload?.uniqueIdentifier) { + return { + status: TaskStatus.success, + taskId: '', + uniqueIdentifier: '', + } + } + if (!isInstalled) { + const response = await post( + '/workspaces/current/plugin/install/pkg', + { + body: { + plugin_unique_identifiers: [uniqueIdentifier], + }, + }, + ) + upsertStartedPluginTask(queryClient, response) + const { task_id, all_installed } = response + taskId = task_id + isFinishedInstallation = all_installed } } - if (!isInstalled) { - const response = await post('/workspaces/current/plugin/install/marketplace', { - body: { - plugin_unique_identifiers: [uniqueIdentifier], - }, - }) - upsertStartedPluginTask(queryClient, response) - const { task_id, all_installed } = response - taskId = task_id - isFinishedInstallation = all_installed + if (isInstalled) { + if (item.type === 'package') { + await uninstallPlugin(installedPayload.installedId) + const response = await post( + '/workspaces/current/plugin/install/pkg', + { + body: { + plugin_unique_identifiers: [uniqueIdentifier], + }, + }, + ) + upsertStartedPluginTask(queryClient, response) + const { task_id, all_installed } = response + taskId = task_id + isFinishedInstallation = all_installed + } else { + const response = await updatePackageFromMarketPlace({ + original_plugin_unique_identifier: installedPayload?.uniqueIdentifier, + new_plugin_unique_identifier: uniqueIdentifier, + }) + const { task_id, all_installed } = response + taskId = task_id + isFinishedInstallation = all_installed + } } - } - if (item.type === 'package') { - const data = item as PackageDependency - uniqueIdentifier = data.value.unique_identifier - if (uniqueIdentifier === installedPayload?.uniqueIdentifier) { + if (isFinishedInstallation) { return { status: TaskStatus.success, taskId: '', uniqueIdentifier: '', } + } else { + return { + status: TaskStatus.running, + taskId, + uniqueIdentifier, + } } - if (!isInstalled) { - const response = await post('/workspaces/current/plugin/install/pkg', { - body: { - plugin_unique_identifiers: [uniqueIdentifier], - }, - }) - upsertStartedPluginTask(queryClient, response) - const { task_id, all_installed } = response - taskId = task_id - isFinishedInstallation = all_installed - } - } - if (isInstalled) { - if (item.type === 'package') { - await uninstallPlugin(installedPayload.installedId) - const response = await post('/workspaces/current/plugin/install/pkg', { - body: { - plugin_unique_identifiers: [uniqueIdentifier], - }, - }) - upsertStartedPluginTask(queryClient, response) - const { task_id, all_installed } = response - taskId = task_id - isFinishedInstallation = all_installed - } - else { - const response = await updatePackageFromMarketPlace({ - original_plugin_unique_identifier: installedPayload?.uniqueIdentifier, - new_plugin_unique_identifier: uniqueIdentifier, - }) - const { task_id, all_installed } = response - taskId = task_id - isFinishedInstallation = all_installed - } - } - if (isFinishedInstallation) { - return { - status: TaskStatus.success, - taskId: '', - uniqueIdentifier: '', - } - } - else { - return { - status: TaskStatus.running, - taskId, - uniqueIdentifier, - } + } catch { + return Promise.resolve({ status: TaskStatus.failed, taskId: '', uniqueIdentifier: '' }) } - } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { - return Promise.resolve({ status: TaskStatus.failed, taskId: '', uniqueIdentifier: '' }) - } - })) + }), + ) }, onSuccess, }) @@ -968,23 +986,33 @@ type MarketplacePluginInfoRequest = { const useReferenceSettingKey = [NAME_SPACE, 'referenceSettings'] const usePluginPermissionSettingsKey = [...useReferenceSettingKey, 'permission'] const usePluginAutoUpgradeSettingsKey = [...useReferenceSettingKey, 'autoUpgrade'] -const pluginAutoUpgradeSettingsQueryKey = (category: PluginCategoryEnum) => [...usePluginAutoUpgradeSettingsKey, category] +const pluginAutoUpgradeSettingsQueryKey = (category: PluginCategoryEnum) => [ + ...usePluginAutoUpgradeSettingsKey, + category, +] const areStringArraysEqual = (left: string[], right: string[]) => { return left.length === right.length && left.every((value, index) => value === right[index]) } const arePermissionsEqual = (left: Permissions | undefined, right: Permissions | undefined) => { - return left?.install_permission === right?.install_permission - && left?.debug_permission === right?.debug_permission + return ( + left?.install_permission === right?.install_permission && + left?.debug_permission === right?.debug_permission + ) } -const areAutoUpgradeSettingsEqual = (left: AutoUpdateConfig | undefined, right: AutoUpdateConfig | undefined) => { - return left?.strategy_setting === right?.strategy_setting - && left?.upgrade_time_of_day === right?.upgrade_time_of_day - && left?.upgrade_mode === right?.upgrade_mode - && areStringArraysEqual(left?.exclude_plugins ?? [], right?.exclude_plugins ?? []) - && areStringArraysEqual(left?.include_plugins ?? [], right?.include_plugins ?? []) +const areAutoUpgradeSettingsEqual = ( + left: AutoUpdateConfig | undefined, + right: AutoUpdateConfig | undefined, +) => { + return ( + left?.strategy_setting === right?.strategy_setting && + left?.upgrade_time_of_day === right?.upgrade_time_of_day && + left?.upgrade_mode === right?.upgrade_mode && + areStringArraysEqual(left?.exclude_plugins ?? [], right?.exclude_plugins ?? []) && + areStringArraysEqual(left?.include_plugins ?? [], right?.include_plugins ?? []) + ) } // legacy plugin permission @@ -1011,10 +1039,10 @@ export const usePluginPermissionSettings = () => { export const usePluginAutoUpgradeSettings = (category: PluginCategoryEnum) => { return useQuery({ queryKey: pluginAutoUpgradeSettingsQueryKey(category), - queryFn: () => get( - '/workspaces/current/plugin/auto-upgrade/fetch', - { params: { category } }, - ), + queryFn: () => + get('/workspaces/current/plugin/auto-upgrade/fetch', { + params: { category }, + }), staleTime: 60 * 1000, }) } @@ -1022,11 +1050,9 @@ export const usePluginAutoUpgradeSettings = (category: PluginCategoryEnum) => { export const useInvalidateReferenceSettings = () => { const queryClient = useQueryClient() return () => { - queryClient.invalidateQueries( - { - queryKey: useReferenceSettingKey, - }, - ) + queryClient.invalidateQueries({ + queryKey: useReferenceSettingKey, + }) } } @@ -1043,7 +1069,9 @@ export const useMutationPluginPermissionSettings = ({ }, onMutate: async (payload) => { await queryClient.cancelQueries({ queryKey: usePluginPermissionSettingsKey }) - const previousPermission = queryClient.getQueryData(usePluginPermissionSettingsKey) + const previousPermission = queryClient.getQueryData( + usePluginPermissionSettingsKey, + ) const hadPreviousPermission = previousPermission !== undefined queryClient.setQueryData(usePluginPermissionSettingsKey, payload) @@ -1053,8 +1081,7 @@ export const useMutationPluginPermissionSettings = ({ onError: (_error, _payload, context) => { if (context?.hadPreviousPermission) queryClient.setQueryData(usePluginPermissionSettingsKey, context.previousPermission) - else - queryClient.removeQueries({ queryKey: usePluginPermissionSettingsKey }) + else queryClient.removeQueries({ queryKey: usePluginPermissionSettingsKey }) }, onSuccess: () => { onSuccess?.() @@ -1083,7 +1110,8 @@ export const useMutationPluginAutoUpgradeSettings = ({ onMutate: async (payload) => { const queryKey = pluginAutoUpgradeSettingsQueryKey(category) await queryClient.cancelQueries({ queryKey }) - const previousAutoUpgrade = queryClient.getQueryData(queryKey) + const previousAutoUpgrade = + queryClient.getQueryData(queryKey) const hadPreviousAutoUpgrade = previousAutoUpgrade !== undefined queryClient.setQueryData(pluginAutoUpgradeSettingsQueryKey(category), { @@ -1095,9 +1123,11 @@ export const useMutationPluginAutoUpgradeSettings = ({ }, onError: (_error, _payload, context) => { if (context?.hadPreviousAutoUpgrade) - queryClient.setQueryData(pluginAutoUpgradeSettingsQueryKey(category), context.previousAutoUpgrade) - else - queryClient.removeQueries({ queryKey: pluginAutoUpgradeSettingsQueryKey(category) }) + queryClient.setQueryData( + pluginAutoUpgradeSettingsQueryKey(category), + context.previousAutoUpgrade, + ) + else queryClient.removeQueries({ queryKey: pluginAutoUpgradeSettingsQueryKey(category) }) }, onSuccess: () => { onSuccess?.() @@ -1121,31 +1151,50 @@ export const useMutationReferenceSettings = ({ const mutations: Array> = [] if (!arePermissionsEqual(payload.permission, currentReferenceSetting?.permission)) - mutations.push(post('/workspaces/current/plugin/permission/change', { body: payload.permission })) - - if (!areAutoUpgradeSettingsEqual(payload.auto_upgrade, currentReferenceSetting?.auto_upgrade)) { - mutations.push(post('/workspaces/current/plugin/auto-upgrade/change', { - body: { - category, - auto_upgrade: payload.auto_upgrade, - }, - })) + mutations.push( + post('/workspaces/current/plugin/permission/change', { body: payload.permission }), + ) + + if ( + !areAutoUpgradeSettingsEqual(payload.auto_upgrade, currentReferenceSetting?.auto_upgrade) + ) { + mutations.push( + post('/workspaces/current/plugin/auto-upgrade/change', { + body: { + category, + auto_upgrade: payload.auto_upgrade, + }, + }), + ) } return Promise.all(mutations) }, onMutate: async (payload) => { - const shouldUpdatePermission = !arePermissionsEqual(payload.permission, currentReferenceSetting?.permission) - const shouldUpdateAutoUpgrade = !areAutoUpgradeSettingsEqual(payload.auto_upgrade, currentReferenceSetting?.auto_upgrade) + const shouldUpdatePermission = !arePermissionsEqual( + payload.permission, + currentReferenceSetting?.permission, + ) + const shouldUpdateAutoUpgrade = !areAutoUpgradeSettingsEqual( + payload.auto_upgrade, + currentReferenceSetting?.auto_upgrade, + ) const autoUpgradeQueryKey = pluginAutoUpgradeSettingsQueryKey(category) await Promise.all([ - shouldUpdatePermission ? queryClient.cancelQueries({ queryKey: usePluginPermissionSettingsKey }) : Promise.resolve(), - shouldUpdateAutoUpgrade ? queryClient.cancelQueries({ queryKey: autoUpgradeQueryKey }) : Promise.resolve(), + shouldUpdatePermission + ? queryClient.cancelQueries({ queryKey: usePluginPermissionSettingsKey }) + : Promise.resolve(), + shouldUpdateAutoUpgrade + ? queryClient.cancelQueries({ queryKey: autoUpgradeQueryKey }) + : Promise.resolve(), ]) - const previousPermission = queryClient.getQueryData(usePluginPermissionSettingsKey) - const previousAutoUpgrade = queryClient.getQueryData(autoUpgradeQueryKey) + const previousPermission = queryClient.getQueryData( + usePluginPermissionSettingsKey, + ) + const previousAutoUpgrade = + queryClient.getQueryData(autoUpgradeQueryKey) const hadPreviousPermission = previousPermission !== undefined const hadPreviousAutoUpgrade = previousAutoUpgrade !== undefined @@ -1175,7 +1224,10 @@ export const useMutationReferenceSettings = ({ queryClient.removeQueries({ queryKey: usePluginPermissionSettingsKey }) if (context?.shouldUpdateAutoUpgrade && context.hadPreviousAutoUpgrade) - queryClient.setQueryData(pluginAutoUpgradeSettingsQueryKey(category), context.previousAutoUpgrade) + queryClient.setQueryData( + pluginAutoUpgradeSettingsQueryKey(category), + context.previousAutoUpgrade, + ) else if (context?.shouldUpdateAutoUpgrade) queryClient.removeQueries({ queryKey: pluginAutoUpgradeSettingsQueryKey(category) }) }, @@ -1187,29 +1239,31 @@ export const useRemoveAutoUpgrade = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: (payload: { plugin_id: string, category: PluginCategoryEnum }) => { + mutationFn: (payload: { plugin_id: string; category: PluginCategoryEnum }) => { return post('/workspaces/current/plugin/auto-upgrade/exclude', { body: payload }) }, onSuccess: (_data, payload) => { - queryClient.invalidateQueries( - { - queryKey: pluginAutoUpgradeSettingsQueryKey(payload.category), - }, - ) + queryClient.invalidateQueries({ + queryKey: pluginAutoUpgradeSettingsQueryKey(payload.category), + }) }, }) } -export const useFetchPluginsInMarketPlaceByIds = (unique_identifiers: string[], options?: QueryOptions<{ data: PluginsFromMarketplaceResponse }>) => { +export const useFetchPluginsInMarketPlaceByIds = ( + unique_identifiers: string[], + options?: QueryOptions<{ data: PluginsFromMarketplaceResponse }>, +) => { return useQuery({ ...options, queryKey: [NAME_SPACE, 'fetchPluginsInMarketPlaceByIds', unique_identifiers], - queryFn: () => postMarketplace<{ data: PluginsFromMarketplaceResponse }>('/plugins/identifier/batch', { - body: { - unique_identifiers, - }, - }), - enabled: unique_identifiers?.filter(i => !!i).length > 0, + queryFn: () => + postMarketplace<{ data: PluginsFromMarketplaceResponse }>('/plugins/identifier/batch', { + body: { + unique_identifiers, + }, + }), + enabled: unique_identifiers?.filter((i) => !!i).length > 0, retry: 0, }) } @@ -1217,16 +1271,17 @@ export const useFetchPluginsInMarketPlaceByIds = (unique_identifiers: string[], export const useFetchPluginsInMarketPlaceByInfo = (infos: MarketplacePluginInfoRequest[]) => { return useQuery({ queryKey: [NAME_SPACE, 'fetchPluginsInMarketPlaceByInfo', infos], - queryFn: () => postMarketplace<{ data: PluginsFromMarketplaceByInfoResponse }>('/plugins/versions/batch', { - body: { - plugin_tuples: infos.map(info => ({ - org: info.organization, - name: info.plugin, - version: info.version, - })), - }, - }), - enabled: infos?.filter(i => !!i).length > 0, + queryFn: () => + postMarketplace<{ data: PluginsFromMarketplaceByInfoResponse }>('/plugins/versions/batch', { + body: { + plugin_tuples: infos.map((info) => ({ + org: info.organization, + name: info.plugin, + version: info.version, + })), + }, + }), + enabled: infos?.filter((i) => !!i).length > 0, retry: 0, }) } @@ -1240,14 +1295,18 @@ export const usePluginTaskList = (category?: PluginCategoryEnum | string) => { const query = useQuery({ enabled: canManagement, queryKey: usePluginTaskListKey, - queryFn: () => get<{ tasks: PluginTask[] }>('/workspaces/current/plugin/tasks?page=1&page_size=100'), - structuralSharing: (previousData, nextData) => preserveLocalUnfinishedPluginTasks( - previousData as PluginTaskListResponse | undefined, - nextData as PluginTaskListResponse, - ), + queryFn: () => + get<{ tasks: PluginTask[] }>('/workspaces/current/plugin/tasks?page=1&page_size=100'), + structuralSharing: (previousData, nextData) => + preserveLocalUnfinishedPluginTasks( + previousData as PluginTaskListResponse | undefined, + nextData as PluginTaskListResponse, + ), refetchInterval: (lastQuery) => { const lastData = lastQuery.state.data - const taskDone = lastData?.tasks.every(task => task.status === TaskStatus.success || task.status === TaskStatus.failed) + const taskDone = lastData?.tasks.every( + (task) => task.status === TaskStatus.success || task.status === TaskStatus.failed, + ) return taskDone ? false : 5000 }, }) @@ -1261,12 +1320,13 @@ export const usePluginTaskList = (category?: PluginCategoryEnum | string) => { return } - if (isRefetching) - return + if (isRefetching) return const lastData = cloneDeep(data) - const taskDone = lastData?.tasks.every(task => task.status === TaskStatus.success || task.status === TaskStatus.failed) - const taskAllFailed = lastData?.tasks.every(task => task.status === TaskStatus.failed) + const taskDone = lastData?.tasks.every( + (task) => task.status === TaskStatus.success || task.status === TaskStatus.failed, + ) + const taskAllFailed = lastData?.tasks.every((task) => task.status === TaskStatus.failed) if (taskDone && lastData?.tasks.length && !taskAllFailed) refreshPluginList(category ? { category } : undefined, !category) }, [category, data, isRefetching, refreshPluginList]) @@ -1275,13 +1335,15 @@ export const usePluginTaskList = (category?: PluginCategoryEnum | string) => { refetch() }, [refetch]) - const handleInstallTaskStart = useCallback((response: InstallPackageResponse) => { - if (response.all_installed) - return + const handleInstallTaskStart = useCallback( + (response: InstallPackageResponse) => { + if (response.all_installed) return - upsertStartedPluginTask(queryClient, response) - refetch() - }, [queryClient, refetch]) + upsertStartedPluginTask(queryClient, response) + refetch() + }, + [queryClient, refetch], + ) return { data, @@ -1294,9 +1356,11 @@ export const usePluginTaskList = (category?: PluginCategoryEnum | string) => { export const useMutationClearTaskPlugin = () => { return useMutation({ - mutationFn: ({ taskId, pluginId }: { taskId: string, pluginId: string }) => { + mutationFn: ({ taskId, pluginId }: { taskId: string; pluginId: string }) => { const encodedPluginId = encodeURIComponent(pluginId) - return post<{ success: boolean }>(`/workspaces/current/plugin/tasks/${taskId}/delete/${encodedPluginId}`) + return post<{ success: boolean }>( + `/workspaces/current/plugin/tasks/${taskId}/delete/${encodedPluginId}`, + ) }, }) } @@ -1305,7 +1369,10 @@ export const usePluginManifestInfo = (pluginUID: string) => { return useQuery({ enabled: !!pluginUID, queryKey: [NAME_SPACE, 'manifest', pluginUID], - queryFn: () => getMarketplace<{ data: { plugin: PluginInfoFromMarketPlace, version: { version: string } } }>(`/plugins/${pluginUID}`), + queryFn: () => + getMarketplace<{ data: { plugin: PluginInfoFromMarketPlace; version: { version: string } } }>( + `/plugins/${pluginUID}`, + ), retry: 0, }) } @@ -1323,13 +1390,13 @@ export const useModelInList = (currentProvider?: ModelProvider, modelId?: string return useQuery({ queryKey: ['modelInList', provider, modelId], queryFn: async () => { - if (!modelId || !provider) - return false + if (!modelId || !provider) return false try { - const modelsData = await fetchModelProviderModelList(`/workspaces/current/model-providers/${provider}/models`) - return !!modelId && modelsData.data.some(item => item.model === modelId) - } - catch { + const modelsData = await fetchModelProviderModelList( + `/workspaces/current/model-providers/${provider}/models`, + ) + return !!modelId && modelsData.data.some((item) => item.model === modelId) + } catch { return false } }, @@ -1341,16 +1408,16 @@ export const usePluginInfo = (providerName?: string) => { return useQuery({ queryKey: ['pluginInfo', providerName], queryFn: async () => { - if (!providerName) - return null + if (!providerName) return null const parts = providerName.split('/') const org = parts[0] const name = parts[1] try { const response = await fetchPluginInfoFromMarketPlace({ org: org!, name: name! }) - return response.data.plugin.category === PluginCategoryEnum.model ? response.data.plugin : null - } - catch { + return response.data.plugin.category === PluginCategoryEnum.model + ? response.data.plugin + : null + } catch { return null } }, @@ -1358,36 +1425,66 @@ export const usePluginInfo = (providerName?: string) => { }) } -export const useFetchDynamicOptions = (plugin_id: string, provider: string, action: string, parameter: string, provider_type?: string, extra?: Record) => { +export const useFetchDynamicOptions = ( + plugin_id: string, + provider: string, + action: string, + parameter: string, + provider_type?: string, + extra?: Record, +) => { return useMutation({ - mutationFn: () => get<{ options: FormOption[] }>('/workspaces/current/plugin/parameters/dynamic-options', { - params: { - plugin_id, - provider, - action, - parameter, - provider_type, - ...extra, - }, - }), + mutationFn: () => + get<{ options: FormOption[] }>('/workspaces/current/plugin/parameters/dynamic-options', { + params: { + plugin_id, + provider, + action, + parameter, + provider_type, + ...extra, + }, + }), }) } -export const usePluginReadme = ({ plugin_unique_identifier, language }: { plugin_unique_identifier: string, language?: string }) => { +export const usePluginReadme = ({ + plugin_unique_identifier, + language, +}: { + plugin_unique_identifier: string + language?: string +}) => { return useQuery({ queryKey: ['pluginReadme', plugin_unique_identifier, language], - queryFn: () => get<{ readme: string }>('/workspaces/current/plugin/readme', { params: { plugin_unique_identifier, language } }, { silent: true }), + queryFn: () => + get<{ readme: string }>( + '/workspaces/current/plugin/readme', + { params: { plugin_unique_identifier, language } }, + { silent: true }, + ), enabled: !!plugin_unique_identifier, retry: 0, }) } -export const usePluginReadmeAsset = ({ file_name, plugin_unique_identifier }: { file_name?: string, plugin_unique_identifier?: string }) => { +export const usePluginReadmeAsset = ({ + file_name, + plugin_unique_identifier, +}: { + file_name?: string + plugin_unique_identifier?: string +}) => { const normalizedFileName = file_name?.replace(/^\.\/_assets\//, '').replace(/^_assets\//, '') const isAssetFile = file_name?.startsWith('./_assets') || file_name?.startsWith('_assets') return useQuery({ queryKey: ['pluginReadmeAsset', plugin_unique_identifier, normalizedFileName], - queryFn: () => get('/workspaces/current/plugin/asset', { params: { plugin_unique_identifier, file_name: normalizedFileName } }, { silent: true }), + queryFn: () => + get( + '/workspaces/current/plugin/asset', + { params: { plugin_unique_identifier, file_name: normalizedFileName } }, + { silent: true }, + ), enabled: !!plugin_unique_identifier && !!isAssetFile, }) } diff --git a/web/service/use-share.spec.tsx b/web/service/use-share.spec.tsx index a56913c5346871..a3b35d6a10f80e 100644 --- a/web/service/use-share.spec.tsx +++ b/web/service/use-share.spec.tsx @@ -34,13 +34,14 @@ const mockFetchConversations = vi.mocked(fetchConversations) const mockFetchChatList = vi.mocked(fetchChatList) const mockGenerationConversationName = vi.mocked(generationConversationName) -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, }, - }, -}) + }) const createWrapper = (queryClient: QueryClient) => { return ({ children }: { children: ReactNode }) => ( @@ -65,7 +66,9 @@ const createConversationItem = (overrides: Partial = {}): Conv ...overrides, }) -const createConversationData = (overrides: Partial = {}): AppConversationData => ({ +const createConversationData = ( + overrides: Partial = {}, +): AppConversationData => ({ data: [createConversationItem()], has_more: false, limit: 20, @@ -95,12 +98,20 @@ describe('useShareConversations', () => { // Assert await waitFor(() => { - expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, undefined, undefined, true, 50) + expect(mockFetchConversations).toHaveBeenCalledWith( + AppSourceType.webApp, + undefined, + undefined, + true, + 50, + ) }) await waitFor(() => { expect(result.current.data).toEqual(response) }) - expect(queryClient.getQueryCache().find({ queryKey: shareQueryKeys.conversationList(params) })).toBeDefined() + expect( + queryClient.getQueryCache().find({ queryKey: shareQueryKeys.conversationList(params) }), + ).toBeDefined() }) it('should not fetch conversations when installed app lacks appId', async () => { @@ -144,7 +155,11 @@ describe('useShareChatList', () => { // Assert await waitFor(() => { - expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.installedApp, 'app-1') + expect(mockFetchChatList).toHaveBeenCalledWith( + 'conversation-1', + AppSourceType.installedApp, + 'app-1', + ) }) await waitFor(() => { expect(result.current.data).toEqual(response) @@ -183,7 +198,12 @@ describe('useShareChatList', () => { appSourceType: AppSourceType.webApp, } const initialResponse = { data: [{ id: '1', content: 'initial' }] } - const updatedResponse = { data: [{ id: '1', content: 'initial' }, { id: '2', content: 'new message' }] } + const updatedResponse = { + data: [ + { id: '1', content: 'initial' }, + { id: '2', content: 'new message' }, + ], + } // First fetch mockFetchChatList.mockResolvedValueOnce(initialResponse) @@ -239,7 +259,11 @@ describe('useShareConversationName', () => { // Assert await waitFor(() => { - expect(mockGenerationConversationName).toHaveBeenCalledWith(AppSourceType.webApp, undefined, 'conversation-2') + expect(mockGenerationConversationName).toHaveBeenCalledWith( + AppSourceType.webApp, + undefined, + 'conversation-2', + ) }) await waitFor(() => { expect(result.current.data).toEqual(response) diff --git a/web/service/use-share.ts b/web/service/use-share.ts index 0facd3a8c43c7b..cec2c2f1e502eb 100644 --- a/web/service/use-share.ts +++ b/web/service/use-share.ts @@ -49,9 +49,11 @@ export const shareQueryKeys = { appParams: [NAME_SPACE, 'appParams'] as const, appMeta: [NAME_SPACE, 'appMeta'] as const, conversations: [NAME_SPACE, 'conversations'] as const, - conversationList: (params: ShareConversationsParams) => [NAME_SPACE, 'conversations', params] as const, + conversationList: (params: ShareConversationsParams) => + [NAME_SPACE, 'conversations', params] as const, chatList: (params: ShareChatListParams) => [NAME_SPACE, 'chatList', params] as const, - conversationName: (params: ShareConversationNameParams) => [NAME_SPACE, 'conversationName', params] as const, + conversationName: (params: ShareConversationNameParams) => + [NAME_SPACE, 'conversationName', params] as const, humanInputForm: (token: string) => [NAME_SPACE, 'humanInputForm', token] as const, } @@ -92,22 +94,25 @@ export const useGetWebAppMeta = () => { }) } -export const useShareConversations = (params: ShareConversationsParams, options: ShareQueryOptions = {}) => { - const { - enabled = true, - refetchOnReconnect, - refetchOnWindowFocus, - } = options - const isEnabled = enabled && params.appSourceType !== AppSourceType.tryApp && (params.appSourceType !== AppSourceType.installedApp || !!params.appId) +export const useShareConversations = ( + params: ShareConversationsParams, + options: ShareQueryOptions = {}, +) => { + const { enabled = true, refetchOnReconnect, refetchOnWindowFocus } = options + const isEnabled = + enabled && + params.appSourceType !== AppSourceType.tryApp && + (params.appSourceType !== AppSourceType.installedApp || !!params.appId) return useQuery({ queryKey: shareQueryKeys.conversationList(params), - queryFn: () => fetchConversations( - params.appSourceType, - params.appId, - params.lastId, - params.pinned, - params.limit, - ), + queryFn: () => + fetchConversations( + params.appSourceType, + params.appId, + params.lastId, + params.pinned, + params.limit, + ), enabled: isEnabled, refetchOnReconnect, refetchOnWindowFocus, @@ -115,12 +120,12 @@ export const useShareConversations = (params: ShareConversationsParams, options: } export const useShareChatList = (params: ShareChatListParams, options: ShareQueryOptions = {}) => { - const { - enabled = true, - refetchOnReconnect, - refetchOnWindowFocus, - } = options - const isEnabled = enabled && params.appSourceType !== AppSourceType.tryApp && (params.appSourceType !== AppSourceType.installedApp || !!params.appId) && !!params.conversationId + const { enabled = true, refetchOnReconnect, refetchOnWindowFocus } = options + const isEnabled = + enabled && + params.appSourceType !== AppSourceType.tryApp && + (params.appSourceType !== AppSourceType.installedApp || !!params.appId) && + !!params.conversationId return useQuery({ queryKey: shareQueryKeys.chatList(params), queryFn: () => fetchChatList(params.conversationId, params.appSourceType, params.appId), @@ -134,16 +139,19 @@ export const useShareChatList = (params: ShareChatListParams, options: ShareQuer }) } -export const useShareConversationName = (params: ShareConversationNameParams, options: ShareQueryOptions = {}) => { - const { - enabled = true, - refetchOnReconnect, - refetchOnWindowFocus, - } = options - const isEnabled = enabled && (params.appSourceType !== AppSourceType.installedApp || !!params.appId) && !!params.conversationId +export const useShareConversationName = ( + params: ShareConversationNameParams, + options: ShareQueryOptions = {}, +) => { + const { enabled = true, refetchOnReconnect, refetchOnWindowFocus } = options + const isEnabled = + enabled && + (params.appSourceType !== AppSourceType.installedApp || !!params.appId) && + !!params.conversationId return useQuery({ queryKey: shareQueryKeys.conversationName(params), - queryFn: () => generationConversationName(params.appSourceType, params.appId, params.conversationId), + queryFn: () => + generationConversationName(params.appSourceType, params.appId, params.conversationId), enabled: isEnabled, refetchOnReconnect, refetchOnWindowFocus, @@ -167,21 +175,16 @@ export class HumanInputFormError extends Error { } export const useGetHumanInputForm = (token: string, options: ShareQueryOptions = {}) => { - const { - enabled = true, - refetchOnReconnect, - refetchOnWindowFocus, - } = options + const { enabled = true, refetchOnReconnect, refetchOnWindowFocus } = options return useQuery({ queryKey: shareQueryKeys.humanInputForm(token), queryFn: async () => { try { return await getHumanInputForm(token) - } - catch (error) { + } catch (error) { const response = error as Response if (response.status && response.json) { - const errorData = await response.json() as { code: string, message: string } + const errorData = (await response.json()) as { code: string; message: string } throw new HumanInputFormError(errorData.code, errorData.message, response.status) } throw error diff --git a/web/service/use-snippet-workflows.ts b/web/service/use-snippet-workflows.ts index 3a93ae0d0ca716..c0469fa0c31bc6 100644 --- a/web/service/use-snippet-workflows.ts +++ b/web/service/use-snippet-workflows.ts @@ -8,15 +8,16 @@ const isNotFoundError = (error: unknown) => { export const fetchSnippetDraftWorkflow = async (snippetId: string) => { try { - return await consoleClient.snippets.bySnippetId.workflows.draft.get({ - params: { snippet_id: snippetId }, - }, { - context: { silent: true }, - }) - } - catch (error) { - if (isNotFoundError(error)) - return undefined + return await consoleClient.snippets.bySnippetId.workflows.draft.get( + { + params: { snippet_id: snippetId }, + }, + { + context: { silent: true }, + }, + ) + } catch (error) { + if (isNotFoundError(error)) return undefined throw error } @@ -50,7 +51,9 @@ const invalidateSnippetWorkflowQueries = async ( queryKey: snippetWorkflowContract.workflowRuns.get.key({ type: 'query' }), }), queryClient.invalidateQueries({ - queryKey: snippetWorkflowContract.workflows.draft.nodes.byNodeId.lastRun.get.key({ type: 'query' }), + queryKey: snippetWorkflowContract.workflows.draft.nodes.byNodeId.lastRun.get.key({ + type: 'query', + }), }), ]) } @@ -70,13 +73,10 @@ export const useSnippetPublishedWorkflow = ( queryFn: async (context) => { try { const publishedWorkflow = await queryOptions.queryFn(context) - if (publishedWorkflow) - onSuccess?.(publishedWorkflow) + if (publishedWorkflow) onSuccess?.(publishedWorkflow) return publishedWorkflow - } - catch (error) { - if (isNotFoundError(error)) - return undefined + } catch (error) { + if (isNotFoundError(error)) return undefined throw error } @@ -88,12 +88,13 @@ export const useSnippetDefaultBlockConfigs = ( snippetId: string, onSuccess?: (nodesDefaultConfigs: unknown) => void, ) => { - const queryOptions = snippetWorkflowContract.workflows.defaultWorkflowBlockConfigs.get.queryOptions({ - input: { - params: { snippet_id: snippetId }, - }, - enabled: !!snippetId, - }) + const queryOptions = + snippetWorkflowContract.workflows.defaultWorkflowBlockConfigs.get.queryOptions({ + input: { + params: { snippet_id: snippetId }, + }, + enabled: !!snippetId, + }) return useQuery({ ...queryOptions, @@ -110,12 +111,13 @@ export const usePublishSnippetWorkflowMutation = (snippetId: string) => { return useMutation({ mutationKey: snippetWorkflowContract.workflows.publish.post.mutationKey(), - mutationFn: ({ params }) => snippetWorkflowClient.workflows.publish.post({ - params: { - snippet_id: params.snippetId, - }, - body: {}, - }), + mutationFn: ({ params }) => + snippetWorkflowClient.workflows.publish.post({ + params: { + snippet_id: params.snippetId, + }, + body: {}, + }), onSuccess: async () => { await invalidateSnippetWorkflowQueries(queryClient, snippetId) }, diff --git a/web/service/use-snippets.ts b/web/service/use-snippets.ts index 9aa04620b50d6d..d0ec5d2f18b6a7 100644 --- a/web/service/use-snippets.ts +++ b/web/service/use-snippets.ts @@ -34,7 +34,10 @@ type SnippetListParams = { is_published?: boolean } -type SnippetSummary = Pick +type SnippetSummary = Pick< + SnippetContract, + 'id' | 'name' | 'description' | 'use_count' | 'tags' | 'updated_at' | 'is_published' +> type SnippetIdInput = { params: { snippetId: string @@ -59,16 +62,14 @@ const DEFAULT_GRAPH: SnippetCanvasData = { } const toMilliseconds = (timestamp?: number) => { - if (!timestamp) - return undefined + if (!timestamp) return undefined return timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000 } const formatTimestamp = (timestamp?: number) => { const milliseconds = toMilliseconds(timestamp) - if (!milliseconds) - return '' + if (!milliseconds) return '' return dayjs(milliseconds).format('YYYY-MM-DD HH:mm') } @@ -95,23 +96,30 @@ const toSnippetDetail = (snippet: SnippetContract): SnippetDetailUIModel => { const toSnippetCanvasData = (workflow?: SnippetWorkflow | null): SnippetCanvasData => { const graph = workflow?.graph - if (!graph || typeof graph !== 'object') - return DEFAULT_GRAPH + if (!graph || typeof graph !== 'object') return DEFAULT_GRAPH const graphRecord = graph as Record return { - nodes: Array.isArray(graphRecord.nodes) ? graphRecord.nodes as SnippetCanvasData['nodes'] : DEFAULT_GRAPH.nodes, - edges: Array.isArray(graphRecord.edges) ? graphRecord.edges as SnippetCanvasData['edges'] : DEFAULT_GRAPH.edges, - viewport: graphRecord.viewport && typeof graphRecord.viewport === 'object' - ? graphRecord.viewport as SnippetCanvasData['viewport'] - : DEFAULT_GRAPH.viewport, + nodes: Array.isArray(graphRecord.nodes) + ? (graphRecord.nodes as SnippetCanvasData['nodes']) + : DEFAULT_GRAPH.nodes, + edges: Array.isArray(graphRecord.edges) + ? (graphRecord.edges as SnippetCanvasData['edges']) + : DEFAULT_GRAPH.edges, + viewport: + graphRecord.viewport && typeof graphRecord.viewport === 'object' + ? (graphRecord.viewport as SnippetCanvasData['viewport']) + : DEFAULT_GRAPH.viewport, } } -export const buildSnippetDetailPayload = (snippet: SnippetContract, workflow?: SnippetWorkflow | null): SnippetDetailPayload => { +export const buildSnippetDetailPayload = ( + snippet: SnippetContract, + workflow?: SnippetWorkflow | null, +): SnippetDetailPayload => { const inputFields = Array.isArray(workflow?.input_fields) - ? workflow.input_fields as SnippetInputFieldUIModel[] + ? (workflow.input_fields as SnippetInputFieldUIModel[]) : [] return { @@ -165,7 +173,10 @@ const toGeneratedSnippetListQuery = (params: SnippetListParams) => { } } -export const useInfiniteSnippetList = (params: SnippetListParams = {}, options?: { enabled?: boolean }) => { +export const useInfiniteSnippetList = ( + params: SnippetListParams = {}, + options?: { enabled?: boolean }, +) => { const normalizedParams = normalizeSnippetListParams(params) return useInfiniteQuery({ @@ -178,7 +189,7 @@ export const useInfiniteSnippetList = (params: SnippetListParams = {}, options?: }, }) }, - getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), initialPageParam: normalizedParams.page, placeholderData: keepPreviousData, ...options, @@ -201,7 +212,7 @@ export const useCreateSnippetMutation = () => { return useMutation({ mutationKey: customizedSnippetsContract.post.mutationKey(), - mutationFn: input => customizedSnippetsClient.post(input), + mutationFn: (input) => customizedSnippetsClient.post(input), onSuccess: () => { invalidateSnippetQueries(queryClient) }, @@ -213,12 +224,13 @@ export const useUpdateSnippetMutation = () => { return useMutation({ mutationKey: customizedSnippetsContract.bySnippetId.patch.mutationKey(), - mutationFn: ({ params, body }) => customizedSnippetsClient.bySnippetId.patch({ - params: { - snippet_id: params.snippetId, - }, - body, - }), + mutationFn: ({ params, body }) => + customizedSnippetsClient.bySnippetId.patch({ + params: { + snippet_id: params.snippetId, + }, + body, + }), onSuccess: () => { invalidateSnippetQueries(queryClient) }, @@ -230,11 +242,12 @@ export const useDeleteSnippetMutation = () => { return useMutation({ mutationKey: customizedSnippetsContract.bySnippetId.delete.mutationKey(), - mutationFn: ({ params }) => customizedSnippetsClient.bySnippetId.delete({ - params: { - snippet_id: params.snippetId, - }, - }), + mutationFn: ({ params }) => + customizedSnippetsClient.bySnippetId.delete({ + params: { + snippet_id: params.snippetId, + }, + }), onSuccess: () => { invalidateSnippetQueries(queryClient) }, @@ -246,11 +259,12 @@ export const useIncrementSnippetUseCountMutation = () => { return useMutation({ mutationKey: customizedSnippetsContract.bySnippetId.useCount.increment.post.mutationKey(), - mutationFn: ({ params }) => customizedSnippetsClient.bySnippetId.useCount.increment.post({ - params: { - snippet_id: params.snippetId, - }, - }), + mutationFn: ({ params }) => + customizedSnippetsClient.bySnippetId.useCount.increment.post({ + params: { + snippet_id: params.snippetId, + }, + }), onSuccess: () => { invalidateSnippetQueries(queryClient) }, @@ -258,7 +272,7 @@ export const useIncrementSnippetUseCountMutation = () => { } export const useExportSnippetMutation = () => { - return useMutation({ + return useMutation({ mutationFn: ({ snippetId, include = false }) => { return customizedSnippetsClient.bySnippetId.export.get({ params: { snippet_id: snippetId }, @@ -271,7 +285,11 @@ export const useExportSnippetMutation = () => { export const useImportSnippetDSLMutation = () => { const queryClient = useQueryClient() - return useMutation({ + return useMutation< + SnippetDSLImportResponse, + Error, + { mode: 'yaml-content' | 'yaml-url'; yamlContent?: string; yamlUrl?: string } + >({ mutationFn: ({ mode, yamlContent, yamlUrl }) => { const body: SnippetImportPayload = { mode, diff --git a/web/service/use-strategy.ts b/web/service/use-strategy.ts index ece4084a64a20c..4b25fdfad414cb 100644 --- a/web/service/use-strategy.ts +++ b/web/service/use-strategy.ts @@ -1,10 +1,6 @@ import type { QueryOptions } from '@tanstack/react-query' -import type { - StrategyPluginDetail, -} from '@/app/components/plugins/types' -import { - useQuery, -} from '@tanstack/react-query' +import type { StrategyPluginDetail } from '@/app/components/plugins/types' +import { useQuery } from '@tanstack/react-query' import { fetchStrategyDetail, fetchStrategyList } from './strategy' import { useInvalid } from './use-base' @@ -22,7 +18,10 @@ export const useInvalidateStrategyProviders = () => { return useInvalid(useStrategyListKey) } -export const useStrategyProviderDetail = (agentProvider: string, options?: QueryOptions) => { +export const useStrategyProviderDetail = ( + agentProvider: string, + options?: QueryOptions, +) => { return useQuery({ ...options, queryKey: [NAME_SPACE, 'detail', agentProvider], diff --git a/web/service/use-tools.ts b/web/service/use-tools.ts index b9eb7a8cabf0ac..6e7488ad0e7f06 100644 --- a/web/service/use-tools.ts +++ b/web/service/use-tools.ts @@ -7,11 +7,7 @@ import type { } from '@/app/components/tools/types' import type { RAGRecommendedPlugins, ToolWithProvider } from '@/app/components/workflow/types' import type { AppIconType } from '@/types/app' -import { - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { CollectionType } from '@/app/components/tools/types' import { del, get, post, put } from './base' import { useInvalid } from './use-base' @@ -117,11 +113,7 @@ export const useCreateMCP = () => { }) } -export const useUpdateMCP = ({ - onSuccess, -}: { - onSuccess?: () => void -}) => { +export const useUpdateMCP = ({ onSuccess }: { onSuccess?: () => void }) => { return useMutation({ mutationKey: [NAME_SPACE, 'update-mcp'], mutationFn: (payload: { @@ -146,11 +138,7 @@ export const useUpdateMCP = ({ }) } -export const useDeleteMCP = ({ - onSuccess, -}: { - onSuccess?: () => void -}) => { +export const useDeleteMCP = ({ onSuccess }: { onSuccess?: () => void }) => { return useMutation({ mutationKey: [NAME_SPACE, 'delete-mcp'], mutationFn: (id: string) => { @@ -168,9 +156,12 @@ export const useAuthorizeMCP = () => { return useMutation({ mutationKey: [NAME_SPACE, 'authorize-mcp'], mutationFn: (payload: { provider_id: string }) => { - return post<{ result?: string, authorization_url?: string }>('/workspaces/current/tool-provider/mcp/auth', { - body: payload, - }) + return post<{ result?: string; authorization_url?: string }>( + '/workspaces/current/tool-provider/mcp/auth', + { + body: payload, + }, + ) }, }) } @@ -179,23 +170,23 @@ export const useMCPTools = (providerID: string) => { return useQuery({ enabled: !!providerID, queryKey: [NAME_SPACE, 'get-MCP-provider-tool', providerID], - queryFn: () => get<{ tools: Tool[] }>(`/workspaces/current/tool-provider/mcp/tools/${providerID}`), + queryFn: () => + get<{ tools: Tool[] }>(`/workspaces/current/tool-provider/mcp/tools/${providerID}`), }) } export const useInvalidateMCPTools = () => { const queryClient = useQueryClient() return (providerID: string) => { - queryClient.invalidateQueries( - { - queryKey: [NAME_SPACE, 'get-MCP-provider-tool', providerID], - }, - ) + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'get-MCP-provider-tool', providerID], + }) } } export const useUpdateMCPTools = () => { return useMutation({ - mutationFn: (providerID: string) => get<{ tools: Tool[] }>(`/workspaces/current/tool-provider/mcp/update/${providerID}`), + mutationFn: (providerID: string) => + get<{ tools: Tool[] }>(`/workspaces/current/tool-provider/mcp/update/${providerID}`), }) } @@ -210,11 +201,9 @@ export const useMCPServerDetail = (appID: string, enabled = true) => { export const useInvalidateMCPServerDetail = () => { const queryClient = useQueryClient() return (appID: string) => { - queryClient.invalidateQueries( - { - queryKey: [NAME_SPACE, 'MCPServerDetail', appID], - }, - ) + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'MCPServerDetail', appID], + }) } } @@ -278,11 +267,12 @@ const useRAGRecommendedPluginListKey = [NAME_SPACE, 'rag-recommended-plugins'] export const useRAGRecommendedPlugins = (type: 'tool' | 'datasource' | 'all' = 'all') => { return useQuery({ queryKey: [...useRAGRecommendedPluginListKey, type], - queryFn: () => get('/rag/pipelines/recommended-plugins', { - params: { - type, - }, - }), + queryFn: () => + get('/rag/pipelines/recommended-plugins', { + params: { + type, + }, + }), }) } @@ -332,11 +322,7 @@ export const useInvalidateAppTriggers = () => { export const useUpdateTriggerStatus = () => { return useMutation({ mutationKey: [NAME_SPACE, 'update-trigger-status'], - mutationFn: (payload: { - appId: string - triggerId: string - enableTrigger: boolean - }) => { + mutationFn: (payload: { appId: string; triggerId: string; enableTrigger: boolean }) => { const { appId, triggerId, enableTrigger } = payload return post(`/apps/${appId}/trigger-enable`, { body: { @@ -348,12 +334,19 @@ export const useUpdateTriggerStatus = () => { }) } -const workflowToolDetailByAppIDKey = (appId: string) => [NAME_SPACE, 'workflowToolDetailByAppID', appId] +const workflowToolDetailByAppIDKey = (appId: string) => [ + NAME_SPACE, + 'workflowToolDetailByAppID', + appId, +] export const useWorkflowToolDetailByAppID = (appId: string, enabled = true) => { return useQuery({ queryKey: workflowToolDetailByAppIDKey(appId), - queryFn: () => get(`/workspaces/current/tool-provider/workflow/get?workflow_app_id=${appId}`), + queryFn: () => + get( + `/workspaces/current/tool-provider/workflow/get?workflow_app_id=${appId}`, + ), enabled: enabled && !!appId, }) } diff --git a/web/service/use-triggers.ts b/web/service/use-triggers.ts index 7d0cb0f3985523..c711ce67b44c55 100644 --- a/web/service/use-triggers.ts +++ b/web/service/use-triggers.ts @@ -1,5 +1,10 @@ import type { FormOption } from '@/app/components/base/form/types' -import type { ParametersSchema, PluginTriggerSubscriptionConstructor, TriggerEvent, TriggerEventParameter } from '@/app/components/plugins/types' +import type { + ParametersSchema, + PluginTriggerSubscriptionConstructor, + TriggerEvent, + TriggerEventParameter, +} from '@/app/components/plugins/types' import type { TriggerLogEntity, TriggerOAuthClientParams, @@ -18,16 +23,24 @@ import { useInvalid } from './use-base' const NAME_SPACE = 'triggers' -type GeneratedEventParameter = import('@dify/contracts/api/console/workspaces/types.gen').EventParameter +type GeneratedEventParameter = + import('@dify/contracts/api/console/workspaces/types.gen').EventParameter type GeneratedI18nObject = import('@dify/contracts/api/console/workspaces/types.gen').I18nObject -type GeneratedProviderConfig = import('@dify/contracts/api/console/workspaces/types.gen').ProviderConfig +type GeneratedProviderConfig = + import('@dify/contracts/api/console/workspaces/types.gen').ProviderConfig type GeneratedRequestLog = import('@dify/contracts/api/console/workspaces/types.gen').RequestLog -type GeneratedSubscriptionBuilder = import('@dify/contracts/api/console/workspaces/types.gen').SubscriptionBuilderApiEntity -type GeneratedTriggerCreationMethod = import('@dify/contracts/api/console/workspaces/types.gen').TriggerCreationMethod -type GeneratedTriggerOAuthConfig = import('@dify/contracts/api/console/workspaces/types.gen').TriggerOAuthClientResponse -type GeneratedTriggerProvider = import('@dify/contracts/api/console/workspaces/types.gen').TriggerProviderApiEntity -type GeneratedTriggerSubscription = import('@dify/contracts/api/console/workspaces/types.gen').TriggerProviderSubscriptionApiEntity -type TriggerProviderApiEntity = import('@/app/components/workflow/block-selector/types').TriggerProviderApiEntity +type GeneratedSubscriptionBuilder = + import('@dify/contracts/api/console/workspaces/types.gen').SubscriptionBuilderApiEntity +type GeneratedTriggerCreationMethod = + import('@dify/contracts/api/console/workspaces/types.gen').TriggerCreationMethod +type GeneratedTriggerOAuthConfig = + import('@dify/contracts/api/console/workspaces/types.gen').TriggerOAuthClientResponse +type GeneratedTriggerProvider = + import('@dify/contracts/api/console/workspaces/types.gen').TriggerProviderApiEntity +type GeneratedTriggerSubscription = + import('@dify/contracts/api/console/workspaces/types.gen').TriggerProviderSubscriptionApiEntity +type TriggerProviderApiEntity = + import('@/app/components/workflow/block-selector/types').TriggerProviderApiEntity const getString = (value: unknown) => { return typeof value === 'string' ? value : '' @@ -38,8 +51,7 @@ const getNumber = (value: unknown) => { } const getObjectString = (value: unknown, key: string) => { - if (!value || typeof value !== 'object') - return '' + if (!value || typeof value !== 'object') return '' return getString(Object.entries(value).find(([entryKey]) => entryKey === key)?.[1]) } @@ -74,9 +86,9 @@ const normalizeI18nObject = (value: GeneratedI18nObject | null | undefined, fall 'id-ID': en, 'nl-NL': en, 'ar-TN': en, - 'en_US': en, - 'zh_Hans': zhHans, - 'ja_JP': ja, + en_US: en, + zh_Hans: zhHans, + ja_JP: ja, } } @@ -110,35 +122,36 @@ const normalizeUnknownI18nObject = (value: unknown, fallback = '') => { 'id-ID': en, 'nl-NL': en, 'ar-TN': en, - 'en_US': en, - 'zh_Hans': zhHans, - 'ja_JP': ja, + en_US: en, + zh_Hans: zhHans, + ja_JP: ja, } } const normalizeParameterDefault = (value: unknown) => { - if (Array.isArray(value)) - return value.filter(item => typeof item === 'string') - if (typeof value === 'string') - return value - if (value === undefined || value === null) - return undefined + if (Array.isArray(value)) return value.filter((item) => typeof item === 'string') + if (typeof value === 'string') return value + if (value === undefined || value === null) return undefined return String(value) } const normalizeProviderOptions = (options: GeneratedProviderConfig['options']) => { - return options?.map(option => ({ - label: normalizeI18nObject(option.label), - value: option.value, - })) ?? [] + return ( + options?.map((option) => ({ + label: normalizeI18nObject(option.label), + value: option.value, + })) ?? [] + ) } const normalizeEventOptions = (options: GeneratedEventParameter['options']) => { - return options?.map(option => ({ - icon: option.icon ?? undefined, - label: normalizeI18nObject(option.label), - value: option.value, - })) ?? [] + return ( + options?.map((option) => ({ + icon: option.icon ?? undefined, + label: normalizeI18nObject(option.label), + value: option.value, + })) ?? [] + ) } const normalizeProviderConfigType = (type: GeneratedProviderConfig['type']): FormTypeEnum => { @@ -278,7 +291,9 @@ const normalizeTriggerEvent = (event: GeneratedTriggerProvider['events'][number] } } -const normalizeSupportedCreationMethod = (method: GeneratedTriggerCreationMethod): SupportedCreationMethods => { +const normalizeSupportedCreationMethod = ( + method: GeneratedTriggerCreationMethod, +): SupportedCreationMethods => { switch (method) { case SupportedCreationMethods.APIKEY: return SupportedCreationMethods.APIKEY @@ -293,20 +308,23 @@ const normalizeSupportedCreationMethod = (method: GeneratedTriggerCreationMethod const normalizeSubscriptionConstructor = ( constructor: GeneratedTriggerProvider['subscription_constructor'], ): PluginTriggerSubscriptionConstructor | null => { - if (!constructor) - return null + if (!constructor) return null return { credentials_schema: (constructor.credentials_schema ?? []).map(normalizeCredentialSchema), oauth_schema: { client_schema: (constructor.oauth_schema?.client_schema ?? []).map(normalizeCredentialSchema), - credentials_schema: (constructor.oauth_schema?.credentials_schema ?? []).map(normalizeCredentialSchema), + credentials_schema: (constructor.oauth_schema?.credentials_schema ?? []).map( + normalizeCredentialSchema, + ), }, parameters: (constructor.parameters ?? []).map(normalizeEventParameterToSchema), } } -export const normalizeTriggerProvider = (provider: GeneratedTriggerProvider): TriggerProviderApiEntity => { +export const normalizeTriggerProvider = ( + provider: GeneratedTriggerProvider, +): TriggerProviderApiEntity => { return { author: provider.author, name: provider.name, @@ -317,14 +335,18 @@ export const normalizeTriggerProvider = (provider: GeneratedTriggerProvider): Tr tags: provider.tags ?? [], plugin_id: provider.plugin_id ?? undefined, plugin_unique_identifier: provider.plugin_unique_identifier ?? '', - supported_creation_methods: (provider.supported_creation_methods ?? []).map(normalizeSupportedCreationMethod), + supported_creation_methods: (provider.supported_creation_methods ?? []).map( + normalizeSupportedCreationMethod, + ), subscription_constructor: normalizeSubscriptionConstructor(provider.subscription_constructor), subscription_schema: (provider.subscription_schema ?? []).map(normalizeProviderConfig), events: provider.events.map(normalizeTriggerEvent), } } -const normalizeCredentialType = (credentialType: GeneratedTriggerSubscription['credential_type']): TriggerCredentialTypeEnum => { +const normalizeCredentialType = ( + credentialType: GeneratedTriggerSubscription['credential_type'], +): TriggerCredentialTypeEnum => { switch (credentialType) { case TriggerCredentialTypeEnum.ApiKey: return TriggerCredentialTypeEnum.ApiKey @@ -336,7 +358,9 @@ const normalizeCredentialType = (credentialType: GeneratedTriggerSubscription['c return TriggerCredentialTypeEnum.Unauthorized } -const normalizeTriggerSubscription = (subscription: GeneratedTriggerSubscription): TriggerSubscription => { +const normalizeTriggerSubscription = ( + subscription: GeneratedTriggerSubscription, +): TriggerSubscription => { return { id: subscription.id, name: subscription.name, @@ -350,7 +374,9 @@ const normalizeTriggerSubscription = (subscription: GeneratedTriggerSubscription } } -const normalizeTriggerSubscriptionBuilder = (builder: GeneratedSubscriptionBuilder): TriggerSubscriptionBuilder => { +const normalizeTriggerSubscriptionBuilder = ( + builder: GeneratedSubscriptionBuilder, +): TriggerSubscriptionBuilder => { return { id: builder.id, name: builder.name, @@ -387,19 +413,16 @@ const normalizeTriggerOAuthConfig = (config: GeneratedTriggerOAuthConfig): Trigg } const normalizeDynamicOptionLabel = (label: unknown, fallback: string) => { - if (typeof label === 'string') - return label + if (typeof label === 'string') return label return normalizeUnknownI18nObject(label, fallback) } const normalizeDynamicOption = (option: unknown): FormOption | null => { - if (!option || typeof option !== 'object') - return null + if (!option || typeof option !== 'object') return null const value = getObjectString(option, 'value') - if (!value) - return null + if (!value) return null const label = Object.entries(option).find(([key]) => key === 'label')?.[1] const icon = getObjectString(option, 'icon') @@ -408,8 +431,7 @@ const normalizeDynamicOption = (option: unknown): FormOption | null => { value, } - if (icon) - normalizedOption.icon = icon + if (icon) normalizedOption.icon = icon return normalizedOption } @@ -418,7 +440,9 @@ const isFormOption = (option: FormOption | null): option is FormOption => { return option !== null } -const normalizeDynamicOptionsResponse = (response: { options: unknown }): { options: FormOption[] } => { +const normalizeDynamicOptionsResponse = (response: { + options: unknown +}): { options: FormOption[] } => { return { options: Array.isArray(response.options) ? response.options.map(normalizeDynamicOption).filter(isFormOption) @@ -428,10 +452,10 @@ const normalizeDynamicOptionsResponse = (response: { options: unknown }): { opti const normalizeLogHeaders = (headers: unknown) => { return { - 'Host': getObjectString(headers, 'Host'), + Host: getObjectString(headers, 'Host'), 'User-Agent': getObjectString(headers, 'User-Agent'), 'Content-Length': getObjectString(headers, 'Content-Length'), - 'Accept': getObjectString(headers, 'Accept'), + Accept: getObjectString(headers, 'Accept'), 'Content-Type': getObjectString(headers, 'Content-Type'), 'X-Forwarded-For': getObjectString(headers, 'X-Forwarded-For'), 'X-Forwarded-Host': getObjectString(headers, 'X-Forwarded-Host'), @@ -439,8 +463,14 @@ const normalizeLogHeaders = (headers: unknown) => { 'X-Github-Delivery': getObjectString(headers, 'X-Github-Delivery'), 'X-Github-Event': getObjectString(headers, 'X-Github-Event'), 'X-Github-Hook-Id': getObjectString(headers, 'X-Github-Hook-Id'), - 'X-Github-Hook-Installation-Target-Id': getObjectString(headers, 'X-Github-Hook-Installation-Target-Id'), - 'X-Github-Hook-Installation-Target-Type': getObjectString(headers, 'X-Github-Hook-Installation-Target-Type'), + 'X-Github-Hook-Installation-Target-Id': getObjectString( + headers, + 'X-Github-Hook-Installation-Target-Id', + ), + 'X-Github-Hook-Installation-Target-Type': getObjectString( + headers, + 'X-Github-Hook-Installation-Target-Type', + ), 'Accept-Encoding': getObjectString(headers, 'Accept-Encoding'), } } @@ -471,7 +501,9 @@ const normalizeTriggerLog = (log: GeneratedRequestLog): TriggerLogEntity => { } } -export const convertToTriggerWithProvider = (provider: TriggerProviderApiEntity): TriggerWithProvider => { +export const convertToTriggerWithProvider = ( + provider: TriggerProviderApiEntity, +): TriggerWithProvider => { return { id: provider.plugin_id || provider.name, name: provider.name, @@ -487,12 +519,12 @@ export const convertToTriggerWithProvider = (provider: TriggerProviderApiEntity) labels: provider.tags || [], plugin_id: provider.plugin_id, plugin_unique_identifier: provider.plugin_unique_identifier || '', - events: provider.events.map(event => ({ + events: provider.events.map((event) => ({ name: event.name, author: provider.author, label: event.identity.label, description: event.description, - parameters: event.parameters.map(param => ({ + parameters: event.parameters.map((param) => ({ name: param.name, label: param.label, human_description: param.description || param.label, @@ -501,10 +533,11 @@ export const convertToTriggerWithProvider = (provider: TriggerProviderApiEntity) llm_description: JSON.stringify(param.description || {}), required: param.required || false, default: param.default ?? '', - options: param.options?.map(option => ({ - label: option.label, - value: option.value, - })) || [], + options: + param.options?.map((option) => ({ + label: option.label, + value: option.value, + })) || [], multiple: param.multiple || false, })), labels: provider.tags || [], @@ -538,19 +571,30 @@ export const useInvalidateAllTriggerPlugins = () => { export const useTriggerProviderInfo = (provider: string, enabled = true) => { return useQuery({ - queryKey: consoleQuery.workspaces.current.triggerProvider.byProvider.info.get.queryKey({ input: { params: { provider } } }), - queryFn: async () => normalizeTriggerProvider( - await consoleClient.workspaces.current.triggerProvider.byProvider.info.get({ params: { provider } }), - ), + queryKey: consoleQuery.workspaces.current.triggerProvider.byProvider.info.get.queryKey({ + input: { params: { provider } }, + }), + queryFn: async () => + normalizeTriggerProvider( + await consoleClient.workspaces.current.triggerProvider.byProvider.info.get({ + params: { provider }, + }), + ), enabled: enabled && !!provider, }) } export const useTriggerSubscriptions = (provider: string, enabled = true) => { return useQuery({ - queryKey: consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.list.get.queryKey({ input: { params: { provider } } }), + queryKey: + consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.list.get.queryKey({ + input: { params: { provider } }, + }), queryFn: async () => { - const response = await consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.list.get({ params: { provider } }) + const response = + await consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.list.get({ + params: { provider }, + }) return response.map(normalizeTriggerSubscription) }, enabled: enabled && !!provider, @@ -559,25 +603,26 @@ export const useTriggerSubscriptions = (provider: string, enabled = true) => { export const useCreateTriggerSubscriptionBuilder = () => { return useMutation({ - mutationKey: consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.builder.create.post.mutationKey(), - mutationFn: (payload: { - provider: string - credential_type?: string - }) => { + mutationKey: + consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.builder.create.post.mutationKey(), + mutationFn: (payload: { provider: string; credential_type?: string }) => { const { provider, ...body } = payload - return consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.builder.create.post({ - params: { provider }, - body, - }).then(response => ({ - subscription_builder: normalizeTriggerSubscriptionBuilder(response.subscription_builder), - })) + return consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.builder.create + .post({ + params: { provider }, + body, + }) + .then((response) => ({ + subscription_builder: normalizeTriggerSubscriptionBuilder(response.subscription_builder), + })) }, }) } export const useUpdateTriggerSubscriptionBuilder = () => { return useMutation({ - mutationKey: consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.builder.update.bySubscriptionBuilderId.post.mutationKey(), + mutationKey: + consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.builder.update.bySubscriptionBuilderId.post.mutationKey(), mutationFn: (payload: { provider: string subscriptionBuilderId: string @@ -587,48 +632,58 @@ export const useUpdateTriggerSubscriptionBuilder = () => { credentials?: Record }) => { const { provider, subscriptionBuilderId, ...body } = payload - return consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.builder.update.bySubscriptionBuilderId.post({ - params: { provider, subscription_builder_id: subscriptionBuilderId }, - body, - }).then(normalizeTriggerSubscriptionBuilder) + return consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.builder.update.bySubscriptionBuilderId + .post({ + params: { provider, subscription_builder_id: subscriptionBuilderId }, + body, + }) + .then(normalizeTriggerSubscriptionBuilder) }, }) } export const useVerifyAndUpdateTriggerSubscriptionBuilder = () => { return useMutation({ - mutationKey: consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.builder.verifyAndUpdate.bySubscriptionBuilderId.post.mutationKey(), + mutationKey: + consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.builder.verifyAndUpdate.bySubscriptionBuilderId.post.mutationKey(), mutationFn: (payload: { provider: string subscriptionBuilderId: string credentials?: Record }) => { const { provider, subscriptionBuilderId, credentials } = payload - return consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.builder.verifyAndUpdate.bySubscriptionBuilderId.post({ - params: { provider, subscription_builder_id: subscriptionBuilderId }, - body: { credentials: credentials ?? {} }, - }, { - context: { silent: true }, - }) + return consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.builder.verifyAndUpdate.bySubscriptionBuilderId.post( + { + params: { provider, subscription_builder_id: subscriptionBuilderId }, + body: { credentials: credentials ?? {} }, + }, + { + context: { silent: true }, + }, + ) }, }) } export const useVerifyTriggerSubscription = () => { return useMutation({ - mutationKey: consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.verify.bySubscriptionId.post.mutationKey(), + mutationKey: + consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.verify.bySubscriptionId.post.mutationKey(), mutationFn: (payload: { provider: string subscriptionId: string credentials?: Record }) => { const { provider, subscriptionId, credentials } = payload - return consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.verify.bySubscriptionId.post({ - params: { provider, subscription_id: subscriptionId }, - body: { credentials: credentials ?? {} }, - }, { - context: { silent: true }, - }) + return consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.verify.bySubscriptionId.post( + { + params: { provider, subscription_id: subscriptionId }, + body: { credentials: credentials ?? {} }, + }, + { + context: { silent: true }, + }, + ) }, }) } @@ -642,24 +697,30 @@ export type BuildTriggerSubscriptionPayload = { export const useBuildTriggerSubscription = () => { return useMutation({ - mutationKey: consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.builder.build.bySubscriptionBuilderId.post.mutationKey(), + mutationKey: + consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.builder.build.bySubscriptionBuilderId.post.mutationKey(), mutationFn: (payload: BuildTriggerSubscriptionPayload) => { const { provider, subscriptionBuilderId, ...body } = payload - return consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.builder.build.bySubscriptionBuilderId.post({ - params: { provider, subscription_builder_id: subscriptionBuilderId }, - body, - }) + return consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.builder.build.bySubscriptionBuilderId.post( + { + params: { provider, subscription_builder_id: subscriptionBuilderId }, + body, + }, + ) }, }) } export const useDeleteTriggerSubscription = () => { return useMutation({ - mutationKey: consoleQuery.workspaces.current.triggerProvider.bySubscriptionId.subscriptions.delete.post.mutationKey(), + mutationKey: + consoleQuery.workspaces.current.triggerProvider.bySubscriptionId.subscriptions.delete.post.mutationKey(), mutationFn: (subscriptionId: string) => { - return consoleClient.workspaces.current.triggerProvider.bySubscriptionId.subscriptions.delete.post({ - params: { subscription_id: subscriptionId }, - }) + return consoleClient.workspaces.current.triggerProvider.bySubscriptionId.subscriptions.delete.post( + { + params: { subscription_id: subscriptionId }, + }, + ) }, }) } @@ -674,13 +735,16 @@ type UpdateTriggerSubscriptionPayload = { export const useUpdateTriggerSubscription = () => { return useMutation({ - mutationKey: consoleQuery.workspaces.current.triggerProvider.bySubscriptionId.subscriptions.update.post.mutationKey(), + mutationKey: + consoleQuery.workspaces.current.triggerProvider.bySubscriptionId.subscriptions.update.post.mutationKey(), mutationFn: (payload: UpdateTriggerSubscriptionPayload) => { const { subscriptionId, ...body } = payload - return consoleClient.workspaces.current.triggerProvider.bySubscriptionId.subscriptions.update.post({ - params: { subscription_id: subscriptionId }, - body, - }) + return consoleClient.workspaces.current.triggerProvider.bySubscriptionId.subscriptions.update.post( + { + params: { subscription_id: subscriptionId }, + body, + }, + ) }, }) } @@ -696,13 +760,19 @@ export const useTriggerSubscriptionBuilderLogs = ( const { enabled = true, refetchInterval = false } = options return useQuery<{ logs: TriggerLogEntity[] }>({ - queryKey: consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.builder.logs.bySubscriptionBuilderId.get.queryKey({ - input: { params: { provider, subscription_builder_id: subscriptionBuilderId } }, - }), + queryKey: + consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.builder.logs.bySubscriptionBuilderId.get.queryKey( + { + input: { params: { provider, subscription_builder_id: subscriptionBuilderId } }, + }, + ), queryFn: async () => { - const response = await consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.builder.logs.bySubscriptionBuilderId.get({ - params: { provider, subscription_builder_id: subscriptionBuilderId }, - }) + const response = + await consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.builder.logs.bySubscriptionBuilderId.get( + { + params: { provider, subscription_builder_id: subscriptionBuilderId }, + }, + ) return { logs: response.logs.map(normalizeTriggerLog), } @@ -715,10 +785,15 @@ export const useTriggerSubscriptionBuilderLogs = ( // ===== OAuth Management ===== export const useTriggerOAuthConfig = (provider: string, enabled = true) => { return useQuery({ - queryKey: consoleQuery.workspaces.current.triggerProvider.byProvider.oauth.client.get.queryKey({ input: { params: { provider } } }), - queryFn: async () => normalizeTriggerOAuthConfig( - await consoleClient.workspaces.current.triggerProvider.byProvider.oauth.client.get({ params: { provider } }), - ), + queryKey: consoleQuery.workspaces.current.triggerProvider.byProvider.oauth.client.get.queryKey({ + input: { params: { provider } }, + }), + queryFn: async () => + normalizeTriggerOAuthConfig( + await consoleClient.workspaces.current.triggerProvider.byProvider.oauth.client.get({ + params: { provider }, + }), + ), enabled: enabled && !!provider, }) } @@ -731,7 +806,8 @@ export type ConfigureTriggerOAuthPayload = { export const useConfigureTriggerOAuth = () => { return useMutation({ - mutationKey: consoleQuery.workspaces.current.triggerProvider.byProvider.oauth.client.post.mutationKey(), + mutationKey: + consoleQuery.workspaces.current.triggerProvider.byProvider.oauth.client.post.mutationKey(), mutationFn: (payload: ConfigureTriggerOAuthPayload) => { const { provider, ...body } = payload return consoleClient.workspaces.current.triggerProvider.byProvider.oauth.client.post({ @@ -744,7 +820,8 @@ export const useConfigureTriggerOAuth = () => { export const useDeleteTriggerOAuth = () => { return useMutation({ - mutationKey: consoleQuery.workspaces.current.triggerProvider.byProvider.oauth.client.delete.mutationKey(), + mutationKey: + consoleQuery.workspaces.current.triggerProvider.byProvider.oauth.client.delete.mutationKey(), mutationFn: (provider: string) => { return consoleClient.workspaces.current.triggerProvider.byProvider.oauth.client.delete({ params: { provider }, @@ -755,13 +832,18 @@ export const useDeleteTriggerOAuth = () => { export const useInitiateTriggerOAuth = () => { return useMutation({ - mutationKey: consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.oauth.authorize.get.mutationKey(), + mutationKey: + consoleQuery.workspaces.current.triggerProvider.byProvider.subscriptions.oauth.authorize.get.mutationKey(), mutationFn: async (provider: string) => { - const response = await consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.oauth.authorize.get({ - params: { provider }, - }, { - context: { silent: true }, - }) + const response = + await consoleClient.workspaces.current.triggerProvider.byProvider.subscriptions.oauth.authorize.get( + { + params: { provider }, + }, + { + context: { silent: true }, + }, + ) return { authorization_url: response.authorization_url, subscription_builder: normalizeTriggerSubscriptionBuilder(response.subscription_builder), @@ -771,51 +853,76 @@ export const useInitiateTriggerOAuth = () => { } // ===== Dynamic Options Support ===== -export const useTriggerPluginDynamicOptions = (payload: { - plugin_id: string - provider: string - action: string - parameter: string - credential_id: string - credentials?: Record - extra?: Record -}, enabled = true) => { +export const useTriggerPluginDynamicOptions = ( + payload: { + plugin_id: string + provider: string + action: string + parameter: string + credential_id: string + credentials?: Record + extra?: Record + }, + enabled = true, +) => { return useQuery<{ options: FormOption[] }>({ - queryKey: [NAME_SPACE, 'dynamic-options', payload.plugin_id, payload.provider, payload.action, payload.parameter, payload.credential_id, payload.credentials, payload.extra], + queryKey: [ + NAME_SPACE, + 'dynamic-options', + payload.plugin_id, + payload.provider, + payload.action, + payload.parameter, + payload.credential_id, + payload.credentials, + payload.extra, + ], queryFn: async () => { if (payload.credentials) { return normalizeDynamicOptionsResponse( - await consoleClient.workspaces.current.plugin.parameters.dynamicOptionsWithCredentials.post({ - body: { + await consoleClient.workspaces.current.plugin.parameters.dynamicOptionsWithCredentials.post( + { + body: { + action: payload.action, + credential_id: payload.credential_id, + credentials: payload.credentials, + parameter: payload.parameter, + plugin_id: payload.plugin_id, + provider: payload.provider, + }, + }, + { + context: { silent: true }, + }, + ), + ) + } + + return normalizeDynamicOptionsResponse( + await consoleClient.workspaces.current.plugin.parameters.dynamicOptions.get( + { + query: { action: payload.action, credential_id: payload.credential_id, - credentials: payload.credentials, parameter: payload.parameter, plugin_id: payload.plugin_id, provider: payload.provider, + provider_type: 'trigger', }, - }, { + }, + { context: { silent: true }, - }), - ) - } - - return normalizeDynamicOptionsResponse( - await consoleClient.workspaces.current.plugin.parameters.dynamicOptions.get({ - query: { - action: payload.action, - credential_id: payload.credential_id, - parameter: payload.parameter, - plugin_id: payload.plugin_id, - provider: payload.provider, - provider_type: 'trigger', }, - }, { - context: { silent: true }, - }), + ), ) }, - enabled: enabled && !!payload.plugin_id && !!payload.provider && !!payload.action && !!payload.parameter && !!payload.credential_id, + enabled: + enabled && + !!payload.plugin_id && + !!payload.provider && + !!payload.action && + !!payload.parameter && + !!payload.credential_id, retry: 0, staleTime: 0, }) diff --git a/web/service/use-try-app.ts b/web/service/use-try-app.ts index 856e17812f576f..e7a9d3426db203 100644 --- a/web/service/use-try-app.ts +++ b/web/service/use-try-app.ts @@ -1,6 +1,11 @@ import { useQuery } from '@tanstack/react-query' import { consoleQuery } from '@/service/client' -import { fetchTryAppDatasets, fetchTryAppFlowPreview, fetchTryAppInfo, fetchTryAppParams } from './try-app' +import { + fetchTryAppDatasets, + fetchTryAppFlowPreview, + fetchTryAppInfo, + fetchTryAppParams, +} from './try-app' export const useGetTryAppInfo = (appId: string) => { return useQuery({ @@ -14,7 +19,9 @@ export const useGetTryAppInfo = (appId: string) => { export const useGetTryAppParams = (appId: string) => { return useQuery({ - queryKey: consoleQuery.trialApps.byAppId.parameters.get.queryKey({ input: { params: { app_id: appId } } }), + queryKey: consoleQuery.trialApps.byAppId.parameters.get.queryKey({ + input: { params: { app_id: appId } }, + }), queryFn: () => { return fetchTryAppParams(appId) }, @@ -24,7 +31,9 @@ export const useGetTryAppParams = (appId: string) => { export const useGetTryAppDataSets = (appId: string, ids: string[]) => { return useQuery({ - queryKey: consoleQuery.trialApps.byAppId.datasets.get.queryKey({ input: { params: { app_id: appId }, query: { ids } } }), + queryKey: consoleQuery.trialApps.byAppId.datasets.get.queryKey({ + input: { params: { app_id: appId }, query: { ids } }, + }), queryFn: () => { return fetchTryAppDatasets(appId, ids) }, @@ -34,7 +43,9 @@ export const useGetTryAppDataSets = (appId: string, ids: string[]) => { export const useGetTryAppFlowPreview = (appId: string, disabled?: boolean) => { return useQuery({ - queryKey: consoleQuery.trialApps.byAppId.workflows.get.queryKey({ input: { params: { app_id: appId } } }), + queryKey: consoleQuery.trialApps.byAppId.workflows.get.queryKey({ + input: { params: { app_id: appId } }, + }), enabled: !disabled, queryFn: () => { return fetchTryAppFlowPreview(appId) diff --git a/web/service/use-workflow.ts b/web/service/use-workflow.ts index b9620aae4093e2..d75e3d2ed41312 100644 --- a/web/service/use-workflow.ts +++ b/web/service/use-workflow.ts @@ -49,15 +49,16 @@ export const useInvalidateWorkflowRunHistory = () => { export const useInvalidateAppWorkflow = () => { const queryClient = useQueryClient() return (appID: string) => { - queryClient.invalidateQueries( - { - queryKey: [NAME_SPACE, 'publish', appID], - }, - ) + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'publish', appID], + }) } } -export const useWorkflowConfig = (url: string, onSuccess: (v: T) => void) => { +export const useWorkflowConfig = ( + url: string, + onSuccess: (v: T) => void, +) => { return useQuery({ enabled: !!url, queryKey: [NAME_SPACE, 'config', url], @@ -77,15 +78,16 @@ export const useWorkflowVersionHistory = (params: FetchWorkflowDraftPageParams) return useInfiniteQuery({ enabled: !!url, queryKey: [...WorkflowVersionHistoryKey, url, initialPage, limit, userId, namedOnly], - queryFn: ({ pageParam = 1 }) => get(url, { - params: { - page: pageParam, - limit, - user_id: userId || '', - named_only: !!namedOnly, - }, - }), - getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : null, + queryFn: ({ pageParam = 1 }) => + get(url, { + params: { + page: pageParam, + limit, + user_id: userId || '', + named_only: !!namedOnly, + }, + }), + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : null), initialPageParam: initialPage, }) } @@ -97,12 +99,13 @@ export const useResetWorkflowVersionHistory = () => { export const useUpdateWorkflow = () => { return useMutation({ mutationKey: [NAME_SPACE, 'update'], - mutationFn: (params: UpdateWorkflowParams) => patch(params.url, { - body: { - marked_name: params.title, - marked_comment: params.releaseNotes, - }, - }), + mutationFn: (params: UpdateWorkflowParams) => + patch(params.url, { + body: { + marked_name: params.title, + marked_comment: params.releaseNotes, + }, + }), }) } @@ -116,31 +119,42 @@ export const useDeleteWorkflow = () => { export const useRestoreWorkflow = () => { return useMutation({ mutationKey: [NAME_SPACE, 'restore'], - mutationFn: (url: string) => post(url, {}, { silent: true }), + mutationFn: (url: string) => + post(url, {}, { silent: true }), }) } export const usePublishWorkflow = () => { return useMutation({ mutationKey: [NAME_SPACE, 'publish'], - mutationFn: (params: PublishWorkflowParams) => post(params.url, { - body: { - marked_name: params.title, - marked_comment: params.releaseNotes, - }, - }), + mutationFn: (params: PublishWorkflowParams) => + post(params.url, { + body: { + marked_name: params.title, + marked_comment: params.releaseNotes, + }, + }), }) } const useLastRunKey = [NAME_SPACE, 'last-run'] -export const useLastRun = (flowType: FlowType, flowId: string, nodeId: string, enabled: boolean) => { +export const useLastRun = ( + flowType: FlowType, + flowId: string, + nodeId: string, + enabled: boolean, +) => { return useQuery({ enabled, queryKey: [...useLastRunKey, flowType, flowId, nodeId], queryFn: async () => { - return get(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/last-run`, {}, { - silent: true, - }) + return get( + `${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/last-run`, + {}, + { + silent: true, + }, + ) }, retry: 0, }) @@ -160,7 +174,9 @@ export const useConversationVarValues = (flowType?: FlowType, flowId?: string) = enabled: !!flowId, queryKey: [NAME_SPACE, flowType, 'conversation var values', flowId], queryFn: async () => { - const { items } = (await get(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/conversation-variables`)) as { items: VarInInspect[] } + const { items } = (await get( + `${getFlowPrefix(flowType)}/${flowId}/workflows/draft/conversation-variables`, + )) as { items: VarInInspect[] } return items }, }) @@ -193,7 +209,9 @@ export const useSysVarValues = (flowType?: FlowType, flowId?: string) => { enabled: !!flowId, queryKey: [NAME_SPACE, flowType, 'sys var values', flowId], queryFn: async () => { - const { items } = (await get(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/system-variables`)) as { items: VarInInspect[] } + const { items } = (await get( + `${getFlowPrefix(flowType)}/${flowId}/workflows/draft/system-variables`, + )) as { items: VarInInspect[] } return items }, }) @@ -234,11 +252,7 @@ export const useDeleteInspectVar = (flowType: FlowType, flowId: string) => { export const useEditInspectorVar = (flowType: FlowType, flowId: string) => { return useMutation({ mutationKey: [NAME_SPACE, flowType, 'edit inspector var', flowId], - mutationFn: async ({ varId, ...rest }: { - varId: string - name?: string - value?: any - }) => { + mutationFn: async ({ varId, ...rest }: { varId: string; name?: string; value?: any }) => { return patch(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/variables/${varId}`, { body: rest, }) @@ -249,14 +263,22 @@ export const useEditInspectorVar = (flowType: FlowType, flowId: string) => { export const useTestEmailSender = () => { return useMutation({ mutationKey: [NAME_SPACE, 'test email sender'], - mutationFn: async (data: { appID: string, nodeID: string, deliveryID: string, inputs: Record }) => { + mutationFn: async (data: { + appID: string + nodeID: string + deliveryID: string + inputs: Record + }) => { const { appID, nodeID, deliveryID, inputs } = data - return post(`/apps/${appID}/workflows/draft/human-input/nodes/${nodeID}/delivery-test`, { - body: { - delivery_method_id: deliveryID, - inputs, + return post( + `/apps/${appID}/workflows/draft/human-input/nodes/${nodeID}/delivery-test`, + { + body: { + delivery_method_id: deliveryID, + inputs, + }, }, - }) + ) }, }) } diff --git a/web/service/utils.spec.ts b/web/service/utils.spec.ts index aa49f084caa927..8018c79c3ee903 100644 --- a/web/service/utils.spec.ts +++ b/web/service/utils.spec.ts @@ -137,7 +137,9 @@ describe('Service Utils', () => { } expect(determineEndpoint(FlowType.appFlow, 'app-1')).toBe('/apps/app-1') - expect(determineEndpoint(FlowType.ragPipeline, 'pipeline-1')).toBe('/rag/pipelines/pipeline-1') + expect(determineEndpoint(FlowType.ragPipeline, 'pipeline-1')).toBe( + '/rag/pipelines/pipeline-1', + ) expect(determineEndpoint(FlowType.snippet, 'snippet-1')).toBe('/snippets/snippet-1') expect(determineEndpoint(undefined, 'fallback')).toBe('/apps/fallback') }) diff --git a/web/service/webapp-auth.ts b/web/service/webapp-auth.ts index 8f144571db71fa..f5d602c7b01297 100644 --- a/web/service/webapp-auth.ts +++ b/web/service/webapp-auth.ts @@ -34,9 +34,10 @@ export async function webAppLoginStatus(shareCode: string, userId?: string) { // always need to check login to prevent passport from being outdated // check remotely, the access token could be in cookie (enterprise SSO redirected with https) const params = new URLSearchParams({ app_code: shareCode }) - if (userId) - params.append('user_id', userId) - const { logged_in, app_logged_in } = await getPublic(`/login/status?${params.toString()}`) + if (userId) params.append('user_id', userId) + const { logged_in, app_logged_in } = await getPublic( + `/login/status?${params.toString()}`, + ) return { userLoggedIn: logged_in, appLoggedIn: app_logged_in, diff --git a/web/service/workflow.ts b/web/service/workflow.ts index c7492e5e267ae3..1667e40aa6ada1 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -1,5 +1,9 @@ import type { WorkflowFeaturesConfigPayload } from '@dify/contracts/api/console/apps/types.gen' -import type { BlockEnum, ConversationVariable, EnvironmentVariable } from '@/app/components/workflow/types' +import type { + BlockEnum, + ConversationVariable, + EnvironmentVariable, +} from '@/app/components/workflow/types' import type { CommonResponse } from '@/models/common' import type { FlowType } from '@/types/common' import type { @@ -19,26 +23,53 @@ export const fetchWorkflowDraft = (url: string) => { return get(url, {}, { silent: true }) as Promise } -export const syncWorkflowDraft = ({ url, params }: { +export const syncWorkflowDraft = ({ + url, + params, +}: { url: string - params: Pick + params: Pick< + FetchWorkflowDraftResponse, + 'graph' | 'features' | 'environment_variables' | 'conversation_variables' + > }) => { - return post(url, { body: params }, { silent: true }) + return post( + url, + { body: params }, + { silent: true }, + ) } export const fetchNodesDefaultConfigs = (url: string) => { return get(url) } -export const singleNodeRun = (flowType: FlowType, flowId: string, nodeId: string, params: object) => { - return post(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/run`, { body: params }) +export const singleNodeRun = ( + flowType: FlowType, + flowId: string, + nodeId: string, + params: object, +) => { + return post(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/run`, { + body: params, + }) } -export const getIterationSingleNodeRunUrl = (flowType: FlowType, isChatFlow: boolean, flowId: string, nodeId: string) => { +export const getIterationSingleNodeRunUrl = ( + flowType: FlowType, + isChatFlow: boolean, + flowId: string, + nodeId: string, +) => { return `${getFlowPrefix(flowType)}/${flowId}/${isChatFlow ? 'advanced-chat/' : ''}workflows/draft/iteration/nodes/${nodeId}/run` } -export const getLoopSingleNodeRunUrl = (flowType: FlowType, isChatFlow: boolean, flowId: string, nodeId: string) => { +export const getLoopSingleNodeRunUrl = ( + flowType: FlowType, + isChatFlow: boolean, + flowId: string, + nodeId: string, +) => { return `${getFlowPrefix(flowType)}/${flowId}/${isChatFlow ? 'advanced-chat/' : ''}workflows/draft/loop/nodes/${nodeId}/run` } @@ -72,16 +103,22 @@ export const fetchCurrentValueOfConversationVariable = ({ return get(url, { params }) } -const fetchAllInspectVarsOnePage = async (flowType: FlowType, flowId: string, page: number): Promise<{ total: number, items: VarInInspect[] }> => { +const fetchAllInspectVarsOnePage = async ( + flowType: FlowType, + flowId: string, + page: number, +): Promise<{ total: number; items: VarInInspect[] }> => { return get(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/variables`, { params: { page, limit: 100 }, }) } -export const fetchAllInspectVars = async (flowType: FlowType, flowId: string): Promise => { +export const fetchAllInspectVars = async ( + flowType: FlowType, + flowId: string, +): Promise => { const res = await fetchAllInspectVarsOnePage(flowType, flowId, 1) const { items, total } = res - if (total <= 100) - return items + if (total <= 100) return items const pageCount = Math.ceil(total / 100) const promises = [] @@ -95,12 +132,21 @@ export const fetchAllInspectVars = async (flowType: FlowType, flowId: string): P return items } -export const fetchNodeInspectVars = async (flowType: FlowType, flowId: string, nodeId: string): Promise => { - const { items } = (await get(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/variables`)) as { items: VarInInspect[] } +export const fetchNodeInspectVars = async ( + flowType: FlowType, + flowId: string, + nodeId: string, +): Promise => { + const { items } = (await get( + `${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/variables`, + )) as { items: VarInInspect[] } return items } -export const updateEnvironmentVariables = ({ appId, environmentVariables }: { +export const updateEnvironmentVariables = ({ + appId, + environmentVariables, +}: { appId: string environmentVariables: EnvironmentVariable[] }) => { @@ -110,7 +156,10 @@ export const updateEnvironmentVariables = ({ appId, environmentVariables }: { }) } -export const updateConversationVariables = ({ appId, conversationVariables }: { +export const updateConversationVariables = ({ + appId, + conversationVariables, +}: { appId: string conversationVariables: ConversationVariable[] }) => { @@ -120,7 +169,10 @@ export const updateConversationVariables = ({ appId, conversationVariables }: { }) } -export const updateFeatures = ({ appId, features }: { +export const updateFeatures = ({ + appId, + features, +}: { appId: string features: WorkflowDraftFeaturesPayload }) => { @@ -130,10 +182,13 @@ export const updateFeatures = ({ appId, features }: { }) } -export const submitHumanInputForm = (token: string, data: { - inputs: Record - action: string -}) => { +export const submitHumanInputForm = ( + token: string, + data: { + inputs: Record + action: string + }, +) => { return post(`/form/human_input/${token}`, { body: data }) } diff --git a/web/test/__tests__/i18n-mock.spec.ts b/web/test/__tests__/i18n-mock.spec.ts index 85ee200e1f54e9..2a04d2d77df841 100644 --- a/web/test/__tests__/i18n-mock.spec.ts +++ b/web/test/__tests__/i18n-mock.spec.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { createI18nextMock, createReactI18nextMock, withSelectorKey, withSelectorKeyProps } from '../i18n-mock' +import { + createI18nextMock, + createReactI18nextMock, + withSelectorKey, + withSelectorKeyProps, +} from '../i18n-mock' describe('createReactI18nextMock', () => { describe('Selector Keys', () => { @@ -9,7 +14,7 @@ describe('createReactI18nextMock', () => { const t = withSelectorKey(legacyT, 'app') // Act - const result = t($ => $['accessControlDialog.title'], 'translated') + const result = t(($) => $['accessControlDialog.title'], 'translated') // Assert expect(result).toBe('translated:accessControlDialog.title') @@ -21,7 +26,7 @@ describe('createReactI18nextMock', () => { const Trans = withSelectorKeyProps(LegacyTrans) // Act - const result = Trans<'app'>({ i18nKey: $ => $['accessControlDialog.title'] }) + const result = Trans<'app'>({ i18nKey: ($) => $['accessControlDialog.title'] }) // Assert expect(result).toBe('accessControlDialog.title') @@ -29,7 +34,10 @@ describe('createReactI18nextMock', () => { it('should preserve additional Trans props and optional i18n keys', () => { // Arrange - const LegacyTrans = ({ components, i18nKey }: { + const LegacyTrans = ({ + components, + i18nKey, + }: { components: { link: string } i18nKey?: string }) => `${i18nKey}:${components.link}` @@ -38,7 +46,7 @@ describe('createReactI18nextMock', () => { // Act const result = Trans<'app'>({ components: { link: 'upgrade' }, - i18nKey: $ => $['accessControlDialog.title'], + i18nKey: ($) => $['accessControlDialog.title'], }) // Assert @@ -51,7 +59,7 @@ describe('createReactI18nextMock', () => { const { t } = useTranslation('app') // Act - const result = t($ => $['accessControlDialog.title']) + const result = t(($) => $['accessControlDialog.title']) // Assert expect(result).toBe('app.accessControlDialog.title') @@ -63,7 +71,7 @@ describe('createReactI18nextMock', () => { const { t } = useTranslation('common') // Act - const result = t($ => $['operation.close']) + const result = t(($) => $['operation.close']) // Assert expect(result).toBe('common.operation.close') @@ -75,7 +83,7 @@ describe('createReactI18nextMock', () => { const { t } = useTranslation(['app', 'common'] as const) // Act - const result = t($ => $.common!['operation.close']) + const result = t(($) => $.common!['operation.close']) // Assert expect(result).toBe('common.operation.close') @@ -89,14 +97,14 @@ describe('createReactI18nextMock', () => { // Act const element = Trans<'app'>({ - i18nKey: $ => $['accessControlDialog.title'], + i18nKey: ($) => $['accessControlDialog.title'], ns: 'app', }) // Assert expect(element.props).toMatchObject({ 'data-i18n-key': 'app.accessControlDialog.title', - 'children': 'Access control', + children: 'Access control', }) }) }) @@ -112,7 +120,7 @@ describe('createReactI18nextMock', () => { it('should create a standalone i18next mock without namespace formatting', () => { const i18n = createI18nextMock() - expect(i18n.t($ => $['operation.close'], { ns: 'common' })).toBe('operation.close') + expect(i18n.t(($) => $['operation.close'], { ns: 'common' })).toBe('operation.close') }) }) }) diff --git a/web/test/i18n-mock.ts b/web/test/i18n-mock.ts index 9b16ae5b113bc2..af7ddc404949e0 100644 --- a/web/test/i18n-mock.ts +++ b/web/test/i18n-mock.ts @@ -12,16 +12,15 @@ type TranslationFunction = { (key: TranslationSelector, ...args: Args): Result (key: unknown, ...args: Args): Result } -type SelectorI18nKeyProps = Omit - & (Props extends { i18nKey: unknown } +type SelectorI18nKeyProps = Omit & + (Props extends { i18nKey: unknown } ? { i18nKey: TranslationKey } - : { i18nKey?: TranslationKey }) - & (Props extends { ns: unknown } ? { ns: Ns } : { ns?: Ns }) + : { i18nKey?: TranslationKey }) & + (Props extends { ns: unknown } ? { ns: Ns } : { ns?: Ns }) function splitNamespacedKey(key: string) { const separatorIndex = key.indexOf(':') - if (separatorIndex < 1) - return { key } + if (separatorIndex < 1) return { key } return { key: key.slice(separatorIndex + 1), @@ -34,10 +33,8 @@ const selectorPath = Symbol('selectorPath') function createSelectorSource(path: readonly string[] = []): TranslationSelectorSource { return new Proxy({} as TranslationSelectorSource, { get: (_, property) => { - if (property === selectorPath) - return path - if (typeof property !== 'string') - return undefined + if (property === selectorPath) return path + if (typeof property !== 'string') return undefined return createSelectorSource([...path, property]) }, }) @@ -49,8 +46,7 @@ function getSelectorPath(selector: TranslationSelector) { throw new TypeError('Translation selectors must return a translation key') const path = Reflect.get(selected, selectorPath) as readonly string[] | undefined - if (!path?.length) - throw new TypeError('Translation selectors must return a translation key') + if (!path?.length) throw new TypeError('Translation selectors must return a translation key') return path } @@ -64,8 +60,7 @@ function resolveSelectorKey(selector: TranslationSelector, namespace?: Translati } function resolveI18nKey(key: TranslationKey, namespace?: TranslationNamespace) { - if (typeof key === 'string') - return key + if (typeof key === 'string') return key return resolveSelectorKey(key, namespace) } @@ -90,14 +85,14 @@ export function withSelectorKey( translate: (key: string, ...args: Args) => Result, namespace?: TranslationNamespace, ) { - const t = (key: unknown, ...args: Args) => translate(resolveI18nKey(key as TranslationKey, namespace), ...args) + const t = (key: unknown, ...args: Args) => + translate(resolveI18nKey(key as TranslationKey, namespace), ...args) return t as TranslationFunction } -export function withSelectorKeyProps< - Props extends object, - Result, ->(render: (props: Props) => Result) { +export function withSelectorKeyProps( + render: (props: Props) => Result, +) { return (props: SelectorI18nKeyProps) => { const i18nKey = props.i18nKey const resolvedProps = { @@ -115,7 +110,11 @@ export function withSelectorKeyProps< function createTFunction( translations: TranslationMap, defaultNs?: Ns, - config: { includeDefaultNamespace: boolean, includeOptionNamespace: boolean, includeInterpolationOptions: boolean } = { + config: { + includeDefaultNamespace: boolean + includeOptionNamespace: boolean + includeInterpolationOptions: boolean + } = { includeDefaultNamespace: true, includeOptionNamespace: true, includeInterpolationOptions: true, @@ -123,24 +122,34 @@ function createTFunction( ) { const t = (translationKey: unknown, options?: Record) => { const optionNs = options?.ns as TranslationNamespace | undefined - const namespace = ((config.includeOptionNamespace ? optionNs : undefined) ?? (config.includeDefaultNamespace ? defaultNs : undefined)) as Ns | undefined - const { key, namespace: keyNamespace } = resolveTranslationKey(translationKey as TranslationKey, namespace) + const namespace = ((config.includeOptionNamespace ? optionNs : undefined) ?? + (config.includeDefaultNamespace ? defaultNs : undefined)) as Ns | undefined + const { key, namespace: keyNamespace } = resolveTranslationKey( + translationKey as TranslationKey, + namespace, + ) // Check custom translations first (without namespace) - if (translations[key] !== undefined) - return translations[key] - - const ns = keyNamespace ?? getPrimaryNamespace((config.includeOptionNamespace ? optionNs : undefined) ?? (config.includeDefaultNamespace ? defaultNs : undefined)) + if (translations[key] !== undefined) return translations[key] + + const ns = + keyNamespace ?? + getPrimaryNamespace( + (config.includeOptionNamespace ? optionNs : undefined) ?? + (config.includeDefaultNamespace ? defaultNs : undefined), + ) const fullKey = ns ? `${ns}.${key}` : key // Check custom translations with namespace - if (translations[fullKey] !== undefined) - return translations[fullKey] + if (translations[fullKey] !== undefined) return translations[fullKey] // Serialize params (excluding ns) for test assertions const params = { ...options } delete params.ns - const suffix = config.includeInterpolationOptions && Object.keys(params).length > 0 ? `:${JSON.stringify(params)}` : '' + const suffix = + config.includeInterpolationOptions && Object.keys(params).length > 0 + ? `:${JSON.stringify(params)}` + : '' return `${fullKey}${suffix}` } return t as TranslationFunction<[options?: Record], ReturnType> @@ -158,9 +167,7 @@ function createTFunction( * 'operation.confirm': 'Confirm', * })) */ -function createUseTranslationMock( - translations: TranslationMap = {}, -) { +function createUseTranslationMock(translations: TranslationMap = {}) { const tCache = new Map() const i18n = { language: 'en-US', @@ -168,7 +175,7 @@ function createUseTranslationMock( } return { useTranslation: (defaultNs?: Ns) => { - const cacheKey = typeof defaultNs === 'string' ? defaultNs : defaultNs?.join(',') ?? '' + const cacheKey = typeof defaultNs === 'string' ? defaultNs : (defaultNs?.join(',') ?? '') if (!tCache.has(cacheKey)) { tCache.set(cacheKey, createTFunction(translations, defaultNs)) } @@ -185,7 +192,11 @@ function createUseTranslationMock( */ function createTransMock(translations: TranslationMap = {}) { return { - Trans: ({ i18nKey, ns, children }: { + Trans: ({ + i18nKey, + ns, + children, + }: { i18nKey: TranslationKey ns?: Ns children?: React.ReactNode @@ -208,9 +219,7 @@ function createTransMock(translations: TranslationMap = {}) { * 'modal.title': 'My Modal', * })) */ -export function createReactI18nextMock( - translations: TranslationMap = {}, -) { +export function createReactI18nextMock(translations: TranslationMap = {}) { const useTranslationMock = createUseTranslationMock(translations) const i18nextMock = createI18nextMock(translations) return { @@ -223,9 +232,7 @@ export function createReactI18nextMock( } } -export function createI18nextMock( - translations: TranslationMap = {}, -) { +export function createI18nextMock(translations: TranslationMap = {}) { return { t: createTFunction(translations, undefined, { includeDefaultNamespace: false, diff --git a/web/themes/manual-dark.css b/web/themes/manual-dark.css index 6c975af6a39c0d..64d69becda8e4b 100644 --- a/web/themes/manual-dark.css +++ b/web/themes/manual-dark.css @@ -1,92 +1,190 @@ -html[data-theme="dark"] { - --color-monaco-sticky-header-bg: var(--color-components-panel-bg); - --color-monaco-sticky-header-bg-hover: var(--color-components-panel-on-panel-item-bg-hover); - --color-monaco-sticky-header-border: var(--color-components-panel-border); - --vscode-editorStickyScroll-background: var(--color-monaco-sticky-header-bg); - --vscode-editorStickyScrollHover-background: var(--color-monaco-sticky-header-bg-hover); - --vscode-editorStickyScroll-border: var(--color-monaco-sticky-header-border); - --vscode-editorStickyScroll-shadow: rgba(0, 0, 0, 0.6); - --color-chatbot-bg: linear-gradient(180deg, - rgba(34, 34, 37, 0.9) 0%, - rgba(29, 29, 32, 0.9) 90.48%); - --color-chat-bubble-bg: linear-gradient(180deg, - rgb(42, 43, 48) 0%, - rgb(37, 38, 42) 100%); - --color-chat-input-mask: linear-gradient(180deg, - rgba(24, 24, 27, 0.04) 0%, - rgba(24, 24, 27, 0.60) 100%); - --color-workflow-process-bg: linear-gradient(90deg, - rgba(24, 24, 27, 0.25) 0%, - rgba(24, 24, 27, 0.04) 100%); - --color-workflow-process-paused-bg: linear-gradient(90deg, - rgba(247, 144, 9, 0.14) 0%, - rgba(247, 144, 9, 0.00) 100%); - --color-workflow-process-failed-bg: linear-gradient(90deg, - rgba(240, 68, 56, 0.14) 0%, - rgba(240, 68, 56, 0.00) 100%); - --color-workflow-run-failed-bg: linear-gradient(98deg, - rgba(240, 68, 56, 0.12) 0%, - rgba(0, 0, 0, 0) 26.01%); - --color-workflow-batch-failed-bg: linear-gradient(92deg, - rgba(240, 68, 56, 0.3) 0%, - rgba(0, 0, 0, 0) 100%); - --color-marketplace-divider-bg: linear-gradient(90deg, - rgba(200, 206, 218, 0.14) 0%, - rgba(0, 0, 0, 0) 100%); - --color-marketplace-plugin-empty: linear-gradient(180deg, - rgba(0, 0, 0, 0) 0%, - #222225 100%); - --color-toast-success-bg: linear-gradient(92deg, - rgba(23, 178, 106, 0.3) 0%, - rgba(0, 0, 0, 0) 100%); - --color-toast-warning-bg: linear-gradient(92deg, - rgba(247, 144, 9, 0.3) 0%, - rgba(0, 0, 0, 0) 100%); - --color-toast-error-bg: linear-gradient(92deg, - rgba(240, 68, 56, 0.3) 0%, - rgba(0, 0, 0, 0) 100%); - --color-toast-info-bg: linear-gradient(92deg, - rgba(11, 165, 236, 0.3) 0%); - --color-account-teams-bg: linear-gradient(271deg, - rgba(34, 34, 37, 0.9) -0.1%, - rgba(29, 29, 32, 0.9) 98.26%); - --color-app-detail-bg: linear-gradient(169deg, - #1D1D20 1.18%, - #222225 99.52%); - --color-app-detail-overlay-bg: linear-gradient(270deg, - rgba(0, 0, 0, 0.00) 0%, - rgba(24, 24, 27, 0.02) 8%, - rgba(24, 24, 27, 0.54) 100%); - --color-dataset-warning-message-bg: linear-gradient(92deg, rgba(247, 144, 9, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%); - --color-dataset-chunk-process-success-bg: linear-gradient(92deg, rgba(23, 178, 106, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%); - --color-dataset-chunk-process-error-bg: linear-gradient(92deg, rgba(240, 68, 56, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%); - --color-dataset-chunk-detail-card-hover-bg: linear-gradient(180deg, #1D1D20 0%, #222225 100%); - --color-dataset-child-chunk-expand-btn-bg: linear-gradient(90deg, rgba(24, 24, 27, 0.25) 0%, rgba(24, 24, 27, 0.04) 100%); - --color-dataset-option-card-blue-gradient: linear-gradient(90deg, #24252E 0%, #1E1E21 100%); - --color-dataset-option-card-purple-gradient: linear-gradient(90deg, #25242E 0%, #1E1E21 100%); - --color-dataset-option-card-orange-gradient: linear-gradient(90deg, #2B2322 0%, #1E1E21 100%); - --color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(34, 34, 37, 0.00) 0%, #222225 100%); - --mask-top2bottom-gray-50-to-transparent: linear-gradient(180deg, - rgba(29, 29, 32, 0.9) 0%, - rgba(29, 29, 32, 0.08) 100%); - --color-line-divider-bg: linear-gradient(90deg, rgba(200, 206, 218, 0.14) 0%, rgba(0, 0, 0, 0) 100%); - --color-access-app-icon-mask-bg: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.03) 100%); - --color-premium-yearly-tip-text-background: linear-gradient(91deg, #FDB022 2.18%, #F79009 108.79%); - --color-premium-badge-background: linear-gradient(95deg, rgba(103, 111, 131, 0.90) 0%, rgba(73, 84, 100, 0.90) 105.58%), var(--util-colors-gray-gray-200, #18222F); - --color-premium-text-background: linear-gradient(92deg, rgba(249, 250, 251, 0.95) 0%, rgba(233, 235, 240, 0.95) 97.78%); - --color-premium-badge-border-highlight-color: #ffffff33; - --color-price-enterprise-background: linear-gradient(180deg, rgba(185, 211, 234, 0.00) 0%, rgba(180, 209, 234, 0.92) 100%); - --color-grid-mask-background: linear-gradient(0deg, rgba(0, 0, 0, 0.00) 0%, rgba(24, 24, 25, 0.1) 62.25%, rgba(24, 24, 25, 0.10) 100%); - --color-node-data-source-bg: linear-gradient(100deg, var(--workflow-block-wrapper-bg-1, rgba(39, 39, 43, 1)) 0%, var(--workflow-block-wrapper-bg-2, rgba(39, 39, 43, 0.2)) 100%); - --color-tag-selector-mask-bg: linear-gradient(90deg, rgba(34, 34, 37, 0) 0%, rgba(34, 34, 37, 1) 100%); - --color-tag-selector-mask-hover-bg: linear-gradient(90deg, rgba(39, 39, 43, 0) 0%, rgba(39, 39, 43, 1) 100%); - --color-pipeline-template-card-hover-bg: linear-gradient(0deg, rgba(58, 58, 64, 1) 60.27%, rgba(58, 58, 64, 0) 100%); - --color-pipeline-add-documents-title-bg: linear-gradient(92deg, rgba(54, 191, 250, 1) 0%, rgba(41, 109, 255, 1) 97.78%); - --color-background-gradient-bg-fill-chat-bubble-bg-3: #27314d; - --color-billing-plan-title-bg: linear-gradient(95deg, #0A68FF 29.47%, #03F 105.31%); - --color-billing-plan-card-premium-bg: linear-gradient(180deg, #F90 0%, rgba(255, 153, 0, 0.00) 100%); - --color-billing-plan-card-enterprise-bg: linear-gradient(180deg, #03F 0%, rgba(0, 51, 255, 0.00) 100%); - --color-knowledge-pipeline-creation-footer-bg: linear-gradient(90deg, rgba(34, 34, 37, 1) 4.89%, rgba(0, 0, 0, 0) 100%); - --color-progress-bar-indeterminate-stripe: repeating-linear-gradient(-55deg, #3A3A40, #3A3A40 2px, transparent 2px, transparent 5px); - --color-chat-answer-human-input-form-divider-bg: linear-gradient(0deg, rgba(200, 206, 218, 0.14) 0%, rgba(0, 0, 0, 0) 212.5%); +html[data-theme='dark'] { + --color-monaco-sticky-header-bg: var(--color-components-panel-bg); + --color-monaco-sticky-header-bg-hover: var(--color-components-panel-on-panel-item-bg-hover); + --color-monaco-sticky-header-border: var(--color-components-panel-border); + --vscode-editorStickyScroll-background: var(--color-monaco-sticky-header-bg); + --vscode-editorStickyScrollHover-background: var(--color-monaco-sticky-header-bg-hover); + --vscode-editorStickyScroll-border: var(--color-monaco-sticky-header-border); + --vscode-editorStickyScroll-shadow: rgba(0, 0, 0, 0.6); + --color-chatbot-bg: linear-gradient( + 180deg, + rgba(34, 34, 37, 0.9) 0%, + rgba(29, 29, 32, 0.9) 90.48% + ); + --color-chat-bubble-bg: linear-gradient(180deg, rgb(42, 43, 48) 0%, rgb(37, 38, 42) 100%); + --color-chat-input-mask: linear-gradient( + 180deg, + rgba(24, 24, 27, 0.04) 0%, + rgba(24, 24, 27, 0.6) 100% + ); + --color-workflow-process-bg: linear-gradient( + 90deg, + rgba(24, 24, 27, 0.25) 0%, + rgba(24, 24, 27, 0.04) 100% + ); + --color-workflow-process-paused-bg: linear-gradient( + 90deg, + rgba(247, 144, 9, 0.14) 0%, + rgba(247, 144, 9, 0) 100% + ); + --color-workflow-process-failed-bg: linear-gradient( + 90deg, + rgba(240, 68, 56, 0.14) 0%, + rgba(240, 68, 56, 0) 100% + ); + --color-workflow-run-failed-bg: linear-gradient( + 98deg, + rgba(240, 68, 56, 0.12) 0%, + rgba(0, 0, 0, 0) 26.01% + ); + --color-workflow-batch-failed-bg: linear-gradient( + 92deg, + rgba(240, 68, 56, 0.3) 0%, + rgba(0, 0, 0, 0) 100% + ); + --color-marketplace-divider-bg: linear-gradient( + 90deg, + rgba(200, 206, 218, 0.14) 0%, + rgba(0, 0, 0, 0) 100% + ); + --color-marketplace-plugin-empty: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, #222225 100%); + --color-toast-success-bg: linear-gradient( + 92deg, + rgba(23, 178, 106, 0.3) 0%, + rgba(0, 0, 0, 0) 100% + ); + --color-toast-warning-bg: linear-gradient( + 92deg, + rgba(247, 144, 9, 0.3) 0%, + rgba(0, 0, 0, 0) 100% + ); + --color-toast-error-bg: linear-gradient(92deg, rgba(240, 68, 56, 0.3) 0%, rgba(0, 0, 0, 0) 100%); + --color-toast-info-bg: linear-gradient(92deg, rgba(11, 165, 236, 0.3) 0%); + --color-account-teams-bg: linear-gradient( + 271deg, + rgba(34, 34, 37, 0.9) -0.1%, + rgba(29, 29, 32, 0.9) 98.26% + ); + --color-app-detail-bg: linear-gradient(169deg, #1d1d20 1.18%, #222225 99.52%); + --color-app-detail-overlay-bg: linear-gradient( + 270deg, + rgba(0, 0, 0, 0) 0%, + rgba(24, 24, 27, 0.02) 8%, + rgba(24, 24, 27, 0.54) 100% + ); + --color-dataset-warning-message-bg: linear-gradient( + 92deg, + rgba(247, 144, 9, 0.3) 0%, + rgba(0, 0, 0, 0) 100% + ); + --color-dataset-chunk-process-success-bg: linear-gradient( + 92deg, + rgba(23, 178, 106, 0.3) 0%, + rgba(0, 0, 0, 0) 100% + ); + --color-dataset-chunk-process-error-bg: linear-gradient( + 92deg, + rgba(240, 68, 56, 0.3) 0%, + rgba(0, 0, 0, 0) 100% + ); + --color-dataset-chunk-detail-card-hover-bg: linear-gradient(180deg, #1d1d20 0%, #222225 100%); + --color-dataset-child-chunk-expand-btn-bg: linear-gradient( + 90deg, + rgba(24, 24, 27, 0.25) 0%, + rgba(24, 24, 27, 0.04) 100% + ); + --color-dataset-option-card-blue-gradient: linear-gradient(90deg, #24252e 0%, #1e1e21 100%); + --color-dataset-option-card-purple-gradient: linear-gradient(90deg, #25242e 0%, #1e1e21 100%); + --color-dataset-option-card-orange-gradient: linear-gradient(90deg, #2b2322 0%, #1e1e21 100%); + --color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(34, 34, 37, 0) 0%, #222225 100%); + --mask-top2bottom-gray-50-to-transparent: linear-gradient( + 180deg, + rgba(29, 29, 32, 0.9) 0%, + rgba(29, 29, 32, 0.08) 100% + ); + --color-line-divider-bg: linear-gradient( + 90deg, + rgba(200, 206, 218, 0.14) 0%, + rgba(0, 0, 0, 0) 100% + ); + --color-access-app-icon-mask-bg: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0.03) 100% + ); + --color-premium-yearly-tip-text-background: linear-gradient( + 91deg, + #fdb022 2.18%, + #f79009 108.79% + ); + --color-premium-badge-background: + linear-gradient(95deg, rgba(103, 111, 131, 0.9) 0%, rgba(73, 84, 100, 0.9) 105.58%), + var(--util-colors-gray-gray-200, #18222f); + --color-premium-text-background: linear-gradient( + 92deg, + rgba(249, 250, 251, 0.95) 0%, + rgba(233, 235, 240, 0.95) 97.78% + ); + --color-premium-badge-border-highlight-color: #ffffff33; + --color-price-enterprise-background: linear-gradient( + 180deg, + rgba(185, 211, 234, 0) 0%, + rgba(180, 209, 234, 0.92) 100% + ); + --color-grid-mask-background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0) 0%, + rgba(24, 24, 25, 0.1) 62.25%, + rgba(24, 24, 25, 0.1) 100% + ); + --color-node-data-source-bg: linear-gradient( + 100deg, + var(--workflow-block-wrapper-bg-1, rgba(39, 39, 43, 1)) 0%, + var(--workflow-block-wrapper-bg-2, rgba(39, 39, 43, 0.2)) 100% + ); + --color-tag-selector-mask-bg: linear-gradient( + 90deg, + rgba(34, 34, 37, 0) 0%, + rgba(34, 34, 37, 1) 100% + ); + --color-tag-selector-mask-hover-bg: linear-gradient( + 90deg, + rgba(39, 39, 43, 0) 0%, + rgba(39, 39, 43, 1) 100% + ); + --color-pipeline-template-card-hover-bg: linear-gradient( + 0deg, + rgba(58, 58, 64, 1) 60.27%, + rgba(58, 58, 64, 0) 100% + ); + --color-pipeline-add-documents-title-bg: linear-gradient( + 92deg, + rgba(54, 191, 250, 1) 0%, + rgba(41, 109, 255, 1) 97.78% + ); + --color-background-gradient-bg-fill-chat-bubble-bg-3: #27314d; + --color-billing-plan-title-bg: linear-gradient(95deg, #0a68ff 29.47%, #03f 105.31%); + --color-billing-plan-card-premium-bg: linear-gradient(180deg, #f90 0%, rgba(255, 153, 0, 0) 100%); + --color-billing-plan-card-enterprise-bg: linear-gradient( + 180deg, + #03f 0%, + rgba(0, 51, 255, 0) 100% + ); + --color-knowledge-pipeline-creation-footer-bg: linear-gradient( + 90deg, + rgba(34, 34, 37, 1) 4.89%, + rgba(0, 0, 0, 0) 100% + ); + --color-progress-bar-indeterminate-stripe: repeating-linear-gradient( + -55deg, + #3a3a40, + #3a3a40 2px, + transparent 2px, + transparent 5px + ); + --color-chat-answer-human-input-form-divider-bg: linear-gradient( + 0deg, + rgba(200, 206, 218, 0.14) 0%, + rgba(0, 0, 0, 0) 212.5% + ); } diff --git a/web/themes/manual-light.css b/web/themes/manual-light.css index 4cc633d3bc6cce..f966238cf4d6f2 100644 --- a/web/themes/manual-light.css +++ b/web/themes/manual-light.css @@ -1,85 +1,193 @@ -html[data-theme="light"] { - --color-chatbot-bg: linear-gradient(180deg, - rgba(249, 250, 251, 0.9) 0%, - rgba(242, 244, 247, 0.9) 90.48%); - --color-chat-bubble-bg: linear-gradient(180deg, - #fff 0%, - rgba(255, 255, 255, 0.6) 100%); - --color-chat-input-mask: linear-gradient(180deg, - rgba(255, 255, 255, 0.01) 0%, - #F2F4F7 100%); - --color-workflow-process-bg: linear-gradient(90deg, - rgba(200, 206, 218, 0.2) 0%, - rgba(200, 206, 218, 0.04) 100%); - --color-workflow-process-paused-bg: linear-gradient(90deg, - #FFFAEB 0%, - rgba(255, 250, 235, 0.00) 100%); - --color-workflow-process-failed-bg: linear-gradient(90deg, - #FEF3F2 0%, - rgba(254, 243, 242, 0.00) 100%); - --color-workflow-run-failed-bg: linear-gradient(98deg, - rgba(240, 68, 56, 0.10) 0%, - rgba(255, 255, 255, 0) 26.01%); - --color-workflow-batch-failed-bg: linear-gradient(92deg, - rgba(240, 68, 56, 0.25) 0%, - rgba(255, 255, 255, 0) 100%); - --color-marketplace-divider-bg: linear-gradient(90deg, - rgba(16, 24, 40, 0.08) 0%, - rgba(255, 255, 255, 0) 100%); - --color-marketplace-plugin-empty: linear-gradient(180deg, - rgba(255, 255, 255, 0) 0%, - #fcfcfd 100%); - --color-toast-success-bg: linear-gradient(92deg, - rgba(23, 178, 106, 0.25) 0%, - rgba(255, 255, 255, 0) 100%); - --color-toast-warning-bg: linear-gradient(92deg, - rgba(247, 144, 9, 0.25) 0%, - rgba(255, 255, 255, 0) 100%); - --color-toast-error-bg: linear-gradient(92deg, - rgba(240, 68, 56, 0.25) 0%, - rgba(255, 255, 255, 0) 100%); - --color-toast-info-bg: linear-gradient(92deg, - rgba(11, 165, 236, 0.25) 0%); - --color-account-teams-bg: linear-gradient(271deg, - rgba(249, 250, 251, 0.9) -0.1%, - rgba(242, 244, 247, 0.9) 98.26%); - --color-app-detail-bg: linear-gradient(169deg, - #F2F4F7 1.18%, - #F9FAFB 99.52%); - --color-app-detail-overlay-bg: linear-gradient(270deg, - rgba(0, 0, 0, 0.00) 0%, - rgba(16, 24, 40, 0.01) 8%, - rgba(16, 24, 40, 0.18) 100%); - --color-dataset-warning-message-bg: linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%); - --color-dataset-chunk-process-success-bg: linear-gradient(92deg, rgba(23, 178, 106, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%); - --color-dataset-chunk-process-error-bg: linear-gradient(92deg, rgba(240, 68, 56, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%); - --color-dataset-chunk-detail-card-hover-bg: linear-gradient(180deg, #F2F4F7 0%, #F9FAFB 100%); - --color-dataset-child-chunk-expand-btn-bg: linear-gradient(90deg, rgba(200, 206, 218, 0.20) 0%, rgba(200, 206, 218, 0.04) 100%); - --color-dataset-option-card-blue-gradient: linear-gradient(90deg, #F2F4F7 0%, #F9FAFB 100%); - --color-dataset-option-card-purple-gradient: linear-gradient(90deg, #F0EEFA 0%, #F9FAFB 100%); - --color-dataset-option-card-orange-gradient: linear-gradient(90deg, #F8F2EE 0%, #F9FAFB 100%); - --color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, #FCFCFD 100%); - --mask-top2bottom-gray-50-to-transparent: linear-gradient(180deg, - rgba(242, 244, 247, 0.9) 0%, - rgba(242, 244, 247, 0.05) 100%); - --color-line-divider-bg: linear-gradient(90deg, rgba(16, 24, 40, 0.08) 0%, rgba(255, 255, 255, 0) 100%); - --color-access-app-icon-mask-bg: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.08) 100%); - --color-premium-yearly-tip-text-background: linear-gradient(91deg, #F79009 2.18%, #DC6803 108.79%); - --color-premium-badge-background: linear-gradient(95deg, rgba(152, 162, 178, 0.90) 0%, rgba(103, 111, 131, 0.90) 105.58%); - --color-premium-text-background: linear-gradient(92deg, rgba(252, 252, 253, 0.95) 0%, rgba(242, 244, 247, 0.95) 97.78%); - --color-premium-badge-border-highlight-color: #fffffff2; - --color-price-enterprise-background: linear-gradient(180deg, rgba(185, 211, 234, 0.00) 0%, rgba(180, 209, 234, 0.92) 100%); - --color-grid-mask-background: linear-gradient(0deg, #FFF 0%, rgba(217, 217, 217, 0.10) 62.25%, rgba(217, 217, 217, 0.10) 100%); - --color-node-data-source-bg: linear-gradient(100deg, var(--workflow-block-wrapper-bg-1, #E9EBF0) 0%, var(--workflow-block-wrapper-bg-2, rgba(233, 235, 240, 0.20)) 100%); - --color-tag-selector-mask-bg: linear-gradient(90deg, rgba(252, 252, 253, 0) 0%, rgba(252, 252, 253, 1) 100%); - --color-tag-selector-mask-hover-bg: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%); - --color-pipeline-template-card-hover-bg: linear-gradient(0deg, rgba(249, 250, 251, 1) 60.27%, rgba(249, 250, 251, 0) 100%); - --color-pipeline-add-documents-title-bg: linear-gradient(92deg, rgba(11, 165, 236, 0.95) 0%, rgba(21, 90, 239, 0.95) 97.78%); - --color-background-gradient-bg-fill-chat-bubble-bg-3: #e1effe; - --color-billing-plan-title-bg: linear-gradient(95deg, #03F 29.47%, #03F 105.31%); - --color-billing-plan-card-premium-bg: linear-gradient(180deg, #F90 0%, rgba(255, 153, 0, 0.00) 100%); - --color-billing-plan-card-enterprise-bg: linear-gradient(180deg, #03F 0%, rgba(0, 51, 255, 0.00) 100%); - --color-knowledge-pipeline-creation-footer-bg: linear-gradient(90deg, #FCFCFD 4.89%, rgba(255, 255, 255, 0.00) 100%); - --color-progress-bar-indeterminate-stripe: repeating-linear-gradient(-55deg, #D0D5DD, #D0D5DD 2px, transparent 2px, transparent 5px); - --color-chat-answer-human-input-form-divider-bg: linear-gradient(0deg, rgba(16, 24, 40, 0.08) 0%, rgba(255, 255, 255, 0.00) 212.5%); +html[data-theme='light'] { + --color-chatbot-bg: linear-gradient( + 180deg, + rgba(249, 250, 251, 0.9) 0%, + rgba(242, 244, 247, 0.9) 90.48% + ); + --color-chat-bubble-bg: linear-gradient(180deg, #fff 0%, rgba(255, 255, 255, 0.6) 100%); + --color-chat-input-mask: linear-gradient(180deg, rgba(255, 255, 255, 0.01) 0%, #f2f4f7 100%); + --color-workflow-process-bg: linear-gradient( + 90deg, + rgba(200, 206, 218, 0.2) 0%, + rgba(200, 206, 218, 0.04) 100% + ); + --color-workflow-process-paused-bg: linear-gradient( + 90deg, + #fffaeb 0%, + rgba(255, 250, 235, 0) 100% + ); + --color-workflow-process-failed-bg: linear-gradient( + 90deg, + #fef3f2 0%, + rgba(254, 243, 242, 0) 100% + ); + --color-workflow-run-failed-bg: linear-gradient( + 98deg, + rgba(240, 68, 56, 0.1) 0%, + rgba(255, 255, 255, 0) 26.01% + ); + --color-workflow-batch-failed-bg: linear-gradient( + 92deg, + rgba(240, 68, 56, 0.25) 0%, + rgba(255, 255, 255, 0) 100% + ); + --color-marketplace-divider-bg: linear-gradient( + 90deg, + rgba(16, 24, 40, 0.08) 0%, + rgba(255, 255, 255, 0) 100% + ); + --color-marketplace-plugin-empty: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 0%, + #fcfcfd 100% + ); + --color-toast-success-bg: linear-gradient( + 92deg, + rgba(23, 178, 106, 0.25) 0%, + rgba(255, 255, 255, 0) 100% + ); + --color-toast-warning-bg: linear-gradient( + 92deg, + rgba(247, 144, 9, 0.25) 0%, + rgba(255, 255, 255, 0) 100% + ); + --color-toast-error-bg: linear-gradient( + 92deg, + rgba(240, 68, 56, 0.25) 0%, + rgba(255, 255, 255, 0) 100% + ); + --color-toast-info-bg: linear-gradient(92deg, rgba(11, 165, 236, 0.25) 0%); + --color-account-teams-bg: linear-gradient( + 271deg, + rgba(249, 250, 251, 0.9) -0.1%, + rgba(242, 244, 247, 0.9) 98.26% + ); + --color-app-detail-bg: linear-gradient(169deg, #f2f4f7 1.18%, #f9fafb 99.52%); + --color-app-detail-overlay-bg: linear-gradient( + 270deg, + rgba(0, 0, 0, 0) 0%, + rgba(16, 24, 40, 0.01) 8%, + rgba(16, 24, 40, 0.18) 100% + ); + --color-dataset-warning-message-bg: linear-gradient( + 92deg, + rgba(247, 144, 9, 0.25) 0%, + rgba(255, 255, 255, 0) 100% + ); + --color-dataset-chunk-process-success-bg: linear-gradient( + 92deg, + rgba(23, 178, 106, 0.25) 0%, + rgba(255, 255, 255, 0) 100% + ); + --color-dataset-chunk-process-error-bg: linear-gradient( + 92deg, + rgba(240, 68, 56, 0.25) 0%, + rgba(255, 255, 255, 0) 100% + ); + --color-dataset-chunk-detail-card-hover-bg: linear-gradient(180deg, #f2f4f7 0%, #f9fafb 100%); + --color-dataset-child-chunk-expand-btn-bg: linear-gradient( + 90deg, + rgba(200, 206, 218, 0.2) 0%, + rgba(200, 206, 218, 0.04) 100% + ); + --color-dataset-option-card-blue-gradient: linear-gradient(90deg, #f2f4f7 0%, #f9fafb 100%); + --color-dataset-option-card-purple-gradient: linear-gradient(90deg, #f0eefa 0%, #f9fafb 100%); + --color-dataset-option-card-orange-gradient: linear-gradient(90deg, #f8f2ee 0%, #f9fafb 100%); + --color-dataset-chunk-list-mask-bg: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 0%, + #fcfcfd 100% + ); + --mask-top2bottom-gray-50-to-transparent: linear-gradient( + 180deg, + rgba(242, 244, 247, 0.9) 0%, + rgba(242, 244, 247, 0.05) 100% + ); + --color-line-divider-bg: linear-gradient( + 90deg, + rgba(16, 24, 40, 0.08) 0%, + rgba(255, 255, 255, 0) 100% + ); + --color-access-app-icon-mask-bg: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.12) 0%, + rgba(255, 255, 255, 0.08) 100% + ); + --color-premium-yearly-tip-text-background: linear-gradient( + 91deg, + #f79009 2.18%, + #dc6803 108.79% + ); + --color-premium-badge-background: linear-gradient( + 95deg, + rgba(152, 162, 178, 0.9) 0%, + rgba(103, 111, 131, 0.9) 105.58% + ); + --color-premium-text-background: linear-gradient( + 92deg, + rgba(252, 252, 253, 0.95) 0%, + rgba(242, 244, 247, 0.95) 97.78% + ); + --color-premium-badge-border-highlight-color: #fffffff2; + --color-price-enterprise-background: linear-gradient( + 180deg, + rgba(185, 211, 234, 0) 0%, + rgba(180, 209, 234, 0.92) 100% + ); + --color-grid-mask-background: linear-gradient( + 0deg, + #fff 0%, + rgba(217, 217, 217, 0.1) 62.25%, + rgba(217, 217, 217, 0.1) 100% + ); + --color-node-data-source-bg: linear-gradient( + 100deg, + var(--workflow-block-wrapper-bg-1, #e9ebf0) 0%, + var(--workflow-block-wrapper-bg-2, rgba(233, 235, 240, 0.2)) 100% + ); + --color-tag-selector-mask-bg: linear-gradient( + 90deg, + rgba(252, 252, 253, 0) 0%, + rgba(252, 252, 253, 1) 100% + ); + --color-tag-selector-mask-hover-bg: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 100% + ); + --color-pipeline-template-card-hover-bg: linear-gradient( + 0deg, + rgba(249, 250, 251, 1) 60.27%, + rgba(249, 250, 251, 0) 100% + ); + --color-pipeline-add-documents-title-bg: linear-gradient( + 92deg, + rgba(11, 165, 236, 0.95) 0%, + rgba(21, 90, 239, 0.95) 97.78% + ); + --color-background-gradient-bg-fill-chat-bubble-bg-3: #e1effe; + --color-billing-plan-title-bg: linear-gradient(95deg, #03f 29.47%, #03f 105.31%); + --color-billing-plan-card-premium-bg: linear-gradient(180deg, #f90 0%, rgba(255, 153, 0, 0) 100%); + --color-billing-plan-card-enterprise-bg: linear-gradient( + 180deg, + #03f 0%, + rgba(0, 51, 255, 0) 100% + ); + --color-knowledge-pipeline-creation-footer-bg: linear-gradient( + 90deg, + #fcfcfd 4.89%, + rgba(255, 255, 255, 0) 100% + ); + --color-progress-bar-indeterminate-stripe: repeating-linear-gradient( + -55deg, + #d0d5dd, + #d0d5dd 2px, + transparent 2px, + transparent 5px + ); + --color-chat-answer-human-input-form-divider-bg: linear-gradient( + 0deg, + rgba(16, 24, 40, 0.08) 0%, + rgba(255, 255, 255, 0) 212.5% + ); } diff --git a/web/themes/markdown.css b/web/themes/markdown.css index a26f7582d5efb1..fa48a1b1e00386 100644 --- a/web/themes/markdown.css +++ b/web/themes/markdown.css @@ -1,45 +1,45 @@ -html[data-theme="light"], -html[data-theme="dark"] { - --color-prettylights-syntax-comment: #6e7781; - --color-prettylights-syntax-constant: #0550ae; - --color-prettylights-syntax-entity: #8250df; - --color-prettylights-syntax-storage-modifier-import: #24292f; - --color-prettylights-syntax-entity-tag: #116329; - --color-prettylights-syntax-keyword: #cf222e; - --color-prettylights-syntax-string: #0a3069; - --color-prettylights-syntax-variable: #953800; - --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; - --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; - --color-prettylights-syntax-invalid-illegal-bg: #82071e; - --color-prettylights-syntax-carriage-return-text: #f6f8fa; - --color-prettylights-syntax-carriage-return-bg: #cf222e; - --color-prettylights-syntax-string-regexp: #116329; - --color-prettylights-syntax-markup-list: #3b2300; - --color-prettylights-syntax-markup-heading: #0550ae; - --color-prettylights-syntax-markup-italic: #24292f; - --color-prettylights-syntax-markup-bold: #24292f; - --color-prettylights-syntax-markup-deleted-text: #82071e; - --color-prettylights-syntax-markup-deleted-bg: #ffebe9; - --color-prettylights-syntax-markup-inserted-text: #116329; - --color-prettylights-syntax-markup-inserted-bg: #dafbe1; - --color-prettylights-syntax-markup-changed-text: #953800; - --color-prettylights-syntax-markup-changed-bg: #ffd8b5; - --color-prettylights-syntax-markup-ignored-text: #eaeef2; - --color-prettylights-syntax-markup-ignored-bg: #0550ae; - --color-prettylights-syntax-meta-diff-range: #8250df; - --color-prettylights-syntax-brackethighlighter-angle: #57606a; - --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f; - --color-prettylights-syntax-constant-other-reference-link: #0a3069; - --color-fg-default: #24292f; - --color-fg-muted: #57606a; - --color-fg-subtle: #6e7781; - --color-canvas-default: transparent; - --color-canvas-subtle: #f6f8fa; - --color-border-default: #d0d7de; - --color-border-muted: hsla(210, 18%, 87%, 1); - --color-neutral-muted: rgba(175, 184, 193, 0.2); - --color-accent-fg: #0969da; - --color-accent-emphasis: #0969da; - --color-attention-subtle: #fff8c5; - --color-danger-fg: #cf222e; - } +html[data-theme='light'], +html[data-theme='dark'] { + --color-prettylights-syntax-comment: #6e7781; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-entity: #8250df; + --color-prettylights-syntax-storage-modifier-import: #24292f; + --color-prettylights-syntax-entity-tag: #116329; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #24292f; + --color-prettylights-syntax-markup-bold: #24292f; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #ffebe9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #eaeef2; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-brackethighlighter-angle: #57606a; + --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; + --color-fg-default: #24292f; + --color-fg-muted: #57606a; + --color-fg-subtle: #6e7781; + --color-canvas-default: transparent; + --color-canvas-subtle: #f6f8fa; + --color-border-default: #d0d7de; + --color-border-muted: hsla(210, 18%, 87%, 1); + --color-neutral-muted: rgba(175, 184, 193, 0.2); + --color-accent-fg: #0969da; + --color-accent-emphasis: #0969da; + --color-attention-subtle: #fff8c5; + --color-danger-fg: #cf222e; +} diff --git a/web/tsconfig.json b/web/tsconfig.json index 3d6909c79daf22..ecf469529708dc 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -3,18 +3,11 @@ "compilerOptions": { "incremental": true, "paths": { - "@/*": [ - "./*" - ], - "~@/*": [ - "./*" - ] + "@/*": ["./*"], + "~@/*": ["./*"] }, "resolveJsonModule": true, - "types": [ - "vitest/globals", - "node" - ], + "types": ["vitest/globals", "node"], "allowImportingTsExtensions": true, "allowJs": true }, @@ -25,7 +18,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } diff --git a/web/tsslint.config.ts b/web/tsslint.config.ts index 1a0406d7d872e9..c547a09c598b81 100644 --- a/web/tsslint.config.ts +++ b/web/tsslint.config.ts @@ -4,8 +4,8 @@ import { defineConfig, importESLintRules } from '@tsslint/config' export default defineConfig({ rules: { - ...await importESLintRules({ + ...(await importESLintRules({ 'react-x/no-leaked-conditional-rendering': 'error', - }), + })), }, }) diff --git a/web/types/app.ts b/web/types/app.ts index c55745e76f1aad..0a30b962856477 100644 --- a/web/types/app.ts +++ b/web/types/app.ts @@ -4,11 +4,14 @@ import type { UploadFileSetting } from '@/app/components/workflow/types' import type { LanguagesSupported } from '@/i18n-config/language' import type { AccessMode } from '@/models/access-control' import type { ExternalDataTool } from '@/models/common' +import type { RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets' import type { - RerankingModeEnum, - WeightedScoreEnum, -} from '@/models/datasets' -import type { AnnotationReplyConfig, ChatPromptConfig, CompletionPromptConfig, DatasetConfigs, PromptMode } from '@/models/debug' + AnnotationReplyConfig, + ChatPromptConfig, + CompletionPromptConfig, + DatasetConfigs, + PromptMode, +} from '@/models/debug' import type { WorkflowKind } from '@/types/workflow' export type Theme = 'light' | 'dark' | 'system' @@ -31,12 +34,12 @@ export const RETRIEVE_TYPE = { multiWay: 'multiple' as RETRIEVE_TYPE, } as const -export type RETRIEVE_METHOD - = | 'semantic_search' - | 'full_text_search' - | 'hybrid_search' - | 'invertedIndex' - | 'keyword_search' +export type RETRIEVE_METHOD = + | 'semantic_search' + | 'full_text_search' + | 'hybrid_search' + | 'invertedIndex' + | 'keyword_search' export const RETRIEVE_METHOD = { semantic: 'semantic_search' as RETRIEVE_METHOD, fullText: 'full_text_search' as RETRIEVE_METHOD, @@ -48,12 +51,7 @@ export const RETRIEVE_METHOD = { /** * App modes */ -export type AppModeEnum - = | 'completion' - | 'workflow' - | 'chat' - | 'advanced-chat' - | 'agent-chat' +export type AppModeEnum = 'completion' | 'workflow' | 'chat' | 'advanced-chat' | 'agent-chat' export const AppModeEnum = { COMPLETION: 'completion' as AppModeEnum, WORKFLOW: 'workflow' as AppModeEnum, @@ -61,7 +59,13 @@ export const AppModeEnum = { ADVANCED_CHAT: 'advanced-chat' as AppModeEnum, AGENT_CHAT: 'agent-chat' as AppModeEnum, } as const -export const AppModes = [AppModeEnum.COMPLETION, AppModeEnum.WORKFLOW, AppModeEnum.CHAT, AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT] as const +export const AppModes = [ + AppModeEnum.COMPLETION, + AppModeEnum.WORKFLOW, + AppModeEnum.CHAT, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.AGENT_CHAT, +] as const /** * Variable type @@ -111,9 +115,10 @@ type CheckboxTypeFormItem = Omit & { default?: string | boolean } -type FileTypeFormItem = Omit & Partial & { - max_length?: number -} +type FileTypeFormItem = Omit & + Partial & { + max_length?: number + } type ExternalDataToolFormItem = ExternalDataTool & { label: string @@ -129,25 +134,34 @@ type JsonObjectFormItem = Omit & { /** * User Input Form Item */ -export type UserInputFormItem = { - 'text-input': TextTypeFormItem -} | { - select: SelectTypeFormItem -} | { - paragraph: TextTypeFormItem -} | { - number: NumberTypeFormItem -} | { - checkbox: CheckboxTypeFormItem -} | { - file: FileTypeFormItem -} | { - 'file-list': FileTypeFormItem -} | { - external_data_tool: ExternalDataToolFormItem -} | { - json_object: JsonObjectFormItem -} +export type UserInputFormItem = + | { + 'text-input': TextTypeFormItem + } + | { + select: SelectTypeFormItem + } + | { + paragraph: TextTypeFormItem + } + | { + number: NumberTypeFormItem + } + | { + checkbox: CheckboxTypeFormItem + } + | { + file: FileTypeFormItem + } + | { + 'file-list': FileTypeFormItem + } + | { + external_data_tool: ExternalDataToolFormItem + } + | { + json_object: JsonObjectFormItem + } export type AgentTool = { provider_id: string @@ -162,18 +176,21 @@ export type AgentTool = { credential_id?: string } -export type ToolItem = { - dataset: { - enabled: boolean - id: string - } -} | { - 'sensitive-word-avoidance': { - enabled: boolean - words: string[] - canned_response: string - } -} | AgentTool +export type ToolItem = + | { + dataset: { + enabled: boolean + id: string + } + } + | { + 'sensitive-word-avoidance': { + enabled: boolean + words: string[] + canned_response: string + } + } + | AgentTool export type AgentStrategy = 'function_call' | 'react' export const AgentStrategy = { @@ -295,7 +312,7 @@ export type ModelConfig = { updated_at?: number } -export type Language = typeof LanguagesSupported[number] +export type Language = (typeof LanguagesSupported)[number] /** * Web Application Configuration @@ -417,7 +434,7 @@ export type App = { updated_at: number updated_by?: string } - deleted_tools?: Array<{ type: string, provider_id: string, tool_name: string }> + deleted_tools?: Array<{ type: string; provider_id: string; tool_name: string }> /** access control */ access_mode: AccessMode max_active_requests?: number | null diff --git a/web/types/dataset.ts b/web/types/dataset.ts index 167a7400988518..637c9df706a3b2 100644 --- a/web/types/dataset.ts +++ b/web/types/dataset.ts @@ -5,4 +5,4 @@ export const segmentImportStatus = { error: 'error', } as const -export type SegmentImportStatus = typeof segmentImportStatus[keyof typeof segmentImportStatus] +export type SegmentImportStatus = (typeof segmentImportStatus)[keyof typeof segmentImportStatus] diff --git a/web/types/doc-paths.ts b/web/types/doc-paths.ts index 002faadca48136..6fdea3cc0fdea2 100644 --- a/web/types/doc-paths.ts +++ b/web/types/doc-paths.ts @@ -202,8 +202,7 @@ type ExtractNodesPath = T extends `/use-dify/nodes/${infer Path}` ? Path : ne export type UseDifyNodesPath = ExtractNodesPath // Home paths -type HomePath = - | '/home' +type HomePath = '/home' // Learn paths type LearnPath = @@ -225,8 +224,7 @@ type LearnPath = | '/learn/tutorials/workflow-101/lesson-10' // QuickStart paths -type QuickStartPath = - | '/quick-start' +type QuickStartPath = '/quick-start' // ApiReference paths type ApiReferencePath = @@ -531,9 +529,7 @@ type DocPathWithoutLangBase = | ApiEndpointReferencePath // Combined path without language prefix (supports optional #anchor) -export type DocPathWithoutLang = - | DocPathWithoutLangBase - | `${DocPathWithoutLangBase}#${string}` +export type DocPathWithoutLang = DocPathWithoutLangBase | `${DocPathWithoutLangBase}#${string}` // Product availability for productless docs paths export const docPathProductAvailability: Record = { diff --git a/web/types/i18n.d.ts b/web/types/i18n.d.ts index cb9eab4648de34..7ee528d1a3a1e8 100644 --- a/web/types/i18n.d.ts +++ b/web/types/i18n.d.ts @@ -11,10 +11,7 @@ declare module 'i18next' { } } -export type I18nKeysByPrefix< - NS extends Namespace, - Prefix extends string = '', -> = Prefix extends '' +export type I18nKeysByPrefix = Prefix extends '' ? keyof Resources[NS] : keyof Resources[NS] extends infer K ? K extends `${Prefix}${infer Rest}` @@ -22,9 +19,6 @@ export type I18nKeysByPrefix< : never : never -export type I18nKeysWithPrefix< - NS extends Namespace, - Prefix extends string = '', -> = Prefix extends '' +export type I18nKeysWithPrefix = Prefix extends '' ? keyof Resources[NS] : Extract diff --git a/web/types/model-provider.ts b/web/types/model-provider.ts index 7be0ccc58f2160..c269175090e249 100644 --- a/web/types/model-provider.ts +++ b/web/types/model-provider.ts @@ -11,4 +11,5 @@ export const ModelProviderQuotaGetPaid = { DEEPSEEK: 'langgenius/deepseek/deepseek', TONGYI: 'langgenius/tongyi/tongyi', } as const -export type ModelProviderQuotaGetPaid = typeof ModelProviderQuotaGetPaid[keyof typeof ModelProviderQuotaGetPaid] +export type ModelProviderQuotaGetPaid = + (typeof ModelProviderQuotaGetPaid)[keyof typeof ModelProviderQuotaGetPaid] diff --git a/web/types/workflow.ts b/web/types/workflow.ts index ca0883b25aa056..5d19452bb27d49 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -104,7 +104,8 @@ export type NodeTracing = { details?: NodeTracing[][] // iteration or loop detail retryDetail?: NodeTracing[] // retry detail retry_index?: number - parallelDetail?: { // parallel detail. if is in parallel, this field will be set + parallelDetail?: { + // parallel detail. if is in parallel, this field will be set isParallelStartNode?: boolean parallelTitle?: string branchTitle?: string @@ -425,7 +426,7 @@ export type NodesDefaultConfigsResponse = { }[] export type ConversationVariableResponse = { - data: (ConversationVariable & { updated_at: number, created_at: number })[] + data: (ConversationVariable & { updated_at: number; created_at: number })[] has_more: boolean limit: number total: number @@ -472,7 +473,7 @@ export const VarInInspectType = { node: 'node', system: 'sys', } as const -export type VarInInspectType = typeof VarInInspectType[keyof typeof VarInInspectType] +export type VarInInspectType = (typeof VarInInspectType)[keyof typeof VarInInspectType] type FullContent = { size_bytes: number diff --git a/web/utils/__tests__/create-app-tracking.spec.ts b/web/utils/__tests__/create-app-tracking.spec.ts index acf3fd679278d3..02bb63674691e4 100644 --- a/web/utils/__tests__/create-app-tracking.spec.ts +++ b/web/utils/__tests__/create-app-tracking.spec.ts @@ -30,20 +30,26 @@ describe('create-app-tracking', () => { }) it('should accept any non-empty utm_source and keep raw values', () => { - expect(extractExternalCreateAppAttribution({ - searchParams: new URLSearchParams('utm_source=newsletter'), - })).toEqual({ utmSource: 'newsletter' }) - - expect(extractExternalCreateAppAttribution({ - utmInfo: { utm_source: 'dify_blog', slug: 'launch-week' }, - })).toEqual({ + expect( + extractExternalCreateAppAttribution({ + searchParams: new URLSearchParams('utm_source=newsletter'), + }), + ).toEqual({ utmSource: 'newsletter' }) + + expect( + extractExternalCreateAppAttribution({ + utmInfo: { utm_source: 'dify_blog', slug: 'launch-week' }, + }), + ).toEqual({ utmSource: 'dify_blog', slug: 'launch-week', }) - expect(extractExternalCreateAppAttribution({ - searchParams: new URLSearchParams('utm_source=random&slug=x'), - })).toEqual({ + expect( + extractExternalCreateAppAttribution({ + searchParams: new URLSearchParams('utm_source=random&slug=x'), + }), + ).toEqual({ utmSource: 'random', slug: 'x', }) @@ -65,10 +71,12 @@ describe('create-app-tracking', () => { utmSource: 'community', slug: 'partner-launch', }) - expect(window.sessionStorage.getItem('create_app_external_attribution')).toBe(JSON.stringify({ - utmSource: 'community', - slug: 'partner-launch', - })) + expect(window.sessionStorage.getItem('create_app_external_attribution')).toBe( + JSON.stringify({ + utmSource: 'community', + slug: 'partner-launch', + }), + ) }) it('should ignore malformed utm cookies', () => { @@ -83,10 +91,16 @@ describe('create-app-tracking', () => { describe('buildCreateAppEventPayload', () => { it('should build payloads with source, normalized app mode, and timestamp', () => { - expect(buildCreateAppEventPayload({ - source: 'studio_blank', - appMode: AppModeEnum.ADVANCED_CHAT, - }, null, new Date(2026, 3, 13, 14, 5, 9))).toEqual({ + expect( + buildCreateAppEventPayload( + { + source: 'studio_blank', + appMode: AppModeEnum.ADVANCED_CHAT, + }, + null, + new Date(2026, 3, 13, 14, 5, 9), + ), + ).toEqual({ source: 'studio_blank', app_mode: 'chatflow', time: '04-13-14:05:09', @@ -94,10 +108,16 @@ describe('create-app-tracking', () => { }) it('should map agent mode into the canonical app mode bucket', () => { - expect(buildCreateAppEventPayload({ - source: 'studio_blank', - appMode: AppModeEnum.AGENT_CHAT, - }, null, new Date(2026, 3, 13, 9, 8, 7))).toEqual({ + expect( + buildCreateAppEventPayload( + { + source: 'studio_blank', + appMode: AppModeEnum.AGENT_CHAT, + }, + null, + new Date(2026, 3, 13, 9, 8, 7), + ), + ).toEqual({ source: 'studio_blank', app_mode: 'agent', time: '04-13-09:08:07', @@ -105,10 +125,16 @@ describe('create-app-tracking', () => { }) it('should map the current backend agent mode into the canonical app mode bucket', () => { - expect(buildCreateAppEventPayload({ - source: 'explore_template_list', - appMode: 'agent', - }, null, new Date(2026, 3, 13, 9, 8, 8))).toEqual({ + expect( + buildCreateAppEventPayload( + { + source: 'explore_template_list', + appMode: 'agent', + }, + null, + new Date(2026, 3, 13, 9, 8, 8), + ), + ).toEqual({ source: 'explore_template_list', app_mode: 'agent', time: '04-13-09:08:08', @@ -116,19 +142,31 @@ describe('create-app-tracking', () => { }) it('should fold legacy non-agent modes into chatflow', () => { - expect(buildCreateAppEventPayload({ - source: 'studio_blank', - appMode: AppModeEnum.CHAT, - }, null, new Date(2026, 3, 13, 8, 0, 1))).toEqual({ + expect( + buildCreateAppEventPayload( + { + source: 'studio_blank', + appMode: AppModeEnum.CHAT, + }, + null, + new Date(2026, 3, 13, 8, 0, 1), + ), + ).toEqual({ source: 'studio_blank', app_mode: 'chatflow', time: '04-13-08:00:01', }) - expect(buildCreateAppEventPayload({ - source: 'studio_blank', - appMode: AppModeEnum.COMPLETION, - }, null, new Date(2026, 3, 13, 8, 0, 2))).toEqual({ + expect( + buildCreateAppEventPayload( + { + source: 'studio_blank', + appMode: AppModeEnum.COMPLETION, + }, + null, + new Date(2026, 3, 13, 8, 0, 2), + ), + ).toEqual({ source: 'studio_blank', app_mode: 'chatflow', time: '04-13-08:00:02', @@ -136,10 +174,16 @@ describe('create-app-tracking', () => { }) it('should map workflow mode into the workflow bucket', () => { - expect(buildCreateAppEventPayload({ - source: 'studio_blank', - appMode: AppModeEnum.WORKFLOW, - }, null, new Date(2026, 3, 13, 7, 6, 5))).toEqual({ + expect( + buildCreateAppEventPayload( + { + source: 'studio_blank', + appMode: AppModeEnum.WORKFLOW, + }, + null, + new Date(2026, 3, 13, 7, 6, 5), + ), + ).toEqual({ source: 'studio_blank', app_mode: 'workflow', time: '04-13-07:06:05', @@ -147,11 +191,17 @@ describe('create-app-tracking', () => { }) it('should include template_id for template sources', () => { - expect(buildCreateAppEventPayload({ - source: 'studio_template_list', - appMode: AppModeEnum.CHAT, - templateId: 'template-1', - }, null, new Date(2026, 3, 13, 8, 0, 1))).toEqual({ + expect( + buildCreateAppEventPayload( + { + source: 'studio_template_list', + appMode: AppModeEnum.CHAT, + templateId: 'template-1', + }, + null, + new Date(2026, 3, 13, 8, 0, 1), + ), + ).toEqual({ source: 'studio_template_list', app_mode: 'chatflow', time: '04-13-08:00:01', @@ -160,18 +210,20 @@ describe('create-app-tracking', () => { }) it('should prefer external attribution when present', () => { - expect(buildCreateAppEventPayload( - { - source: 'studio_template_list', - appMode: AppModeEnum.WORKFLOW, - templateId: 'template-1', - }, - { - utmSource: 'linkedin', - slug: 'agent-launch', - }, - new Date(2026, 3, 13, 7, 6, 5), - )).toEqual({ + expect( + buildCreateAppEventPayload( + { + source: 'studio_template_list', + appMode: AppModeEnum.WORKFLOW, + templateId: 'template-1', + }, + { + utmSource: 'linkedin', + slug: 'agent-launch', + }, + new Date(2026, 3, 13, 7, 6, 5), + ), + ).toEqual({ source: 'external', app_mode: 'workflow', time: '04-13-07:06:05', @@ -182,10 +234,16 @@ describe('create-app-tracking', () => { }) it('should not build external payloads without attribution', () => { - expect(buildCreateAppEventPayload({ - source: 'external', - appMode: AppModeEnum.WORKFLOW, - }, null, new Date(2026, 3, 13, 7, 6, 5))).toBeNull() + expect( + buildCreateAppEventPayload( + { + source: 'external', + appMode: AppModeEnum.WORKFLOW, + }, + null, + new Date(2026, 3, 13, 7, 6, 5), + ), + ).toBeNull() }) }) @@ -198,7 +256,9 @@ describe('create-app-tracking', () => { promise: Promise.resolve(), } as ReturnType) - await expect(trackCreateApp({ source: 'studio_blank', appMode: AppModeEnum.ADVANCED_CHAT })).resolves.toBeUndefined() + await expect( + trackCreateApp({ source: 'studio_blank', appMode: AppModeEnum.ADVANCED_CHAT }), + ).resolves.toBeUndefined() expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', { source: 'studio_blank', @@ -213,7 +273,11 @@ describe('create-app-tracking', () => { searchParams: new URLSearchParams('utm_source=newsletter&slug=how-to-build-rag-agent'), }) - trackCreateApp({ source: 'studio_template_list', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' }) + trackCreateApp({ + source: 'studio_template_list', + appMode: AppModeEnum.WORKFLOW, + templateId: 'template-1', + }) expect(amplitude.trackEvent).toHaveBeenNthCalledWith(1, 'create_app', { source: 'external', @@ -224,7 +288,11 @@ describe('create-app-tracking', () => { slug: 'how-to-build-rag-agent', }) - trackCreateApp({ source: 'studio_template_list', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' }) + trackCreateApp({ + source: 'studio_template_list', + appMode: AppModeEnum.WORKFLOW, + templateId: 'template-1', + }) expect(amplitude.trackEvent).toHaveBeenNthCalledWith(2, 'create_app', { source: 'studio_template_list', @@ -243,7 +311,11 @@ describe('create-app-tracking', () => { window.history.replaceState({}, '', '/explore') - trackCreateApp({ source: 'explore_template_preview', appMode: AppModeEnum.CHAT, templateId: 'template-2' }) + trackCreateApp({ + source: 'explore_template_preview', + appMode: AppModeEnum.CHAT, + templateId: 'template-2', + }) expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', { source: 'external', @@ -271,8 +343,7 @@ describe('create-app-tracking', () => { app_mode: 'agent', time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/), }) - } - finally { + } finally { Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow, @@ -281,10 +352,13 @@ describe('create-app-tracking', () => { }) it('should consume snake_case sessionStorage attribution and map legacy utm_campaign to slug', () => { - window.sessionStorage.setItem('create_app_external_attribution', JSON.stringify({ - utm_source: 'community', - utm_campaign: 'launch-week', - })) + window.sessionStorage.setItem( + 'create_app_external_attribution', + JSON.stringify({ + utm_source: 'community', + utm_campaign: 'launch-week', + }), + ) trackCreateApp({ source: 'studio_blank', appMode: AppModeEnum.CHAT }) @@ -299,7 +373,11 @@ describe('create-app-tracking', () => { }) it('should not track external source without remembered attribution', () => { - trackCreateApp({ source: 'external', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' }) + trackCreateApp({ + source: 'external', + appMode: AppModeEnum.WORKFLOW, + templateId: 'template-1', + }) expect(amplitude.trackEvent).not.toHaveBeenCalled() }) diff --git a/web/utils/app-redirection.spec.ts b/web/utils/app-redirection.spec.ts index d134e94f0ab5ad..d263f974a73e33 100644 --- a/web/utils/app-redirection.spec.ts +++ b/web/utils/app-redirection.spec.ts @@ -21,38 +21,62 @@ describe('app-redirection', () => { }) it('returns workflow path for workflow mode when app ACL can access layout', () => { - const app = { id: 'app-123', mode: AppModeEnum.WORKFLOW, permission_keys: [AppACLPermission.ViewLayout] } + const app = { + id: 'app-123', + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.ViewLayout], + } const result = getRedirectionPath(app) expect(result).toBe('/app/app-123/workflow') }) it('returns workflow path for advanced-chat mode when app ACL can access layout', () => { - const app = { id: 'app-123', mode: AppModeEnum.ADVANCED_CHAT, permission_keys: [AppACLPermission.TestAndRun] } + const app = { + id: 'app-123', + mode: AppModeEnum.ADVANCED_CHAT, + permission_keys: [AppACLPermission.TestAndRun], + } const result = getRedirectionPath(app) expect(result).toBe('/app/app-123/workflow') }) it('returns configuration path for chat mode when app ACL can access layout', () => { - const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.Edit] } + const app = { + id: 'app-123', + mode: AppModeEnum.CHAT, + permission_keys: [AppACLPermission.Edit], + } const result = getRedirectionPath(app) expect(result).toBe('/app/app-123/configuration') }) it('returns configuration path for completion mode when app ACL can access layout', () => { - const app = { id: 'app-123', mode: AppModeEnum.COMPLETION, permission_keys: [AppACLPermission.ViewLayout] } + const app = { + id: 'app-123', + mode: AppModeEnum.COMPLETION, + permission_keys: [AppACLPermission.ViewLayout], + } const result = getRedirectionPath(app) expect(result).toBe('/app/app-123/configuration') }) it('returns configuration path for agent-chat mode when app ACL can access layout', () => { - const app = { id: 'app-456', mode: AppModeEnum.AGENT_CHAT, permission_keys: [AppACLPermission.ViewLayout] } + const app = { + id: 'app-456', + mode: AppModeEnum.AGENT_CHAT, + permission_keys: [AppACLPermission.ViewLayout], + } const result = getRedirectionPath(app) expect(result).toBe('/app/app-456/configuration') }) it('handles different app IDs', () => { const app1 = { id: 'abc-123', mode: AppModeEnum.CHAT, permission_keys: [] } - const app2 = { id: 'xyz-789', mode: AppModeEnum.WORKFLOW, permission_keys: [AppACLPermission.ViewLayout] } + const app2 = { + id: 'xyz-789', + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.ViewLayout], + } expect(getRedirectionPath(app1)).toBe('/app/abc-123/develop') expect(getRedirectionPath(app2)).toBe('/app/xyz-789/workflow') @@ -61,33 +85,51 @@ describe('app-redirection', () => { it('returns layout path when the app maintainer has app.create_and_management permission without app ACL keys', () => { const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [] } - expect(getRedirectionPath(app, { - currentUserId: 'user-1', - resourceMaintainer: 'user-1', - workspacePermissionKeys: ['app.create_and_management'], - })).toBe('/app/app-123/configuration') + expect( + getRedirectionPath(app, { + currentUserId: 'user-1', + resourceMaintainer: 'user-1', + workspacePermissionKeys: ['app.create_and_management'], + }), + ).toBe('/app/app-123/configuration') }) it('returns access config path when app ACL can only configure access', () => { - const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.AccessConfig] } + const app = { + id: 'app-123', + mode: AppModeEnum.CHAT, + permission_keys: [AppACLPermission.AccessConfig], + } expect(getRedirectionPath(app, { isRbacEnabled: true })).toBe('/app/app-123/access-config') }) it('returns develop path for access config only apps when RBAC is disabled', () => { - const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.AccessConfig] } + const app = { + id: 'app-123', + mode: AppModeEnum.CHAT, + permission_keys: [AppACLPermission.AccessConfig], + } expect(getRedirectionPath(app, { isRbacEnabled: false })).toBe('/app/app-123/develop') }) it('returns overview path when app ACL can only monitor the app', () => { - const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.Monitor] } + const app = { + id: 'app-123', + mode: AppModeEnum.CHAT, + permission_keys: [AppACLPermission.Monitor], + } expect(getRedirectionPath(app)).toBe('/app/app-123/overview') }) it('returns logs path when app ACL can only access logs and annotations', () => { - const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.LogAndAnnotation] } + const app = { + id: 'app-123', + mode: AppModeEnum.CHAT, + permission_keys: [AppACLPermission.LogAndAnnotation], + } expect(getRedirectionPath(app)).toBe('/app/app-123/logs') }) @@ -111,7 +153,11 @@ describe('app-redirection', () => { }) it('calls redirection function with workflow path when app ACL can access layout', () => { - const app = { id: 'app-123', mode: AppModeEnum.WORKFLOW, permission_keys: [AppACLPermission.ViewLayout] } + const app = { + id: 'app-123', + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.ViewLayout], + } const mockRedirect = vi.fn() getRedirection(app, mockRedirect) @@ -121,7 +167,11 @@ describe('app-redirection', () => { }) it('calls redirection function with configuration path for chat mode with layout access', () => { - const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.ViewLayout] } + const app = { + id: 'app-123', + mode: AppModeEnum.CHAT, + permission_keys: [AppACLPermission.ViewLayout], + } const mockRedirect = vi.fn() getRedirection(app, mockRedirect) @@ -131,7 +181,11 @@ describe('app-redirection', () => { }) it('works with different redirection functions', () => { - const app = { id: 'app-123', mode: AppModeEnum.WORKFLOW, permission_keys: [AppACLPermission.ViewLayout] } + const app = { + id: 'app-123', + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.ViewLayout], + } const paths: string[] = [] const customRedirect = (path: string) => paths.push(path) diff --git a/web/utils/app-redirection.ts b/web/utils/app-redirection.ts index 88427d89432600..e17d64db8acc05 100644 --- a/web/utils/app-redirection.ts +++ b/web/utils/app-redirection.ts @@ -17,18 +17,14 @@ export const getRedirectionPath = ( if (appACLCapabilities.canAccessLayout) { if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) return `/app/${app.id}/workflow` - else - return `/app/${app.id}/configuration` + else return `/app/${app.id}/configuration` } - if (appACLCapabilities.canMonitor) - return `/app/${app.id}/overview` + if (appACLCapabilities.canMonitor) return `/app/${app.id}/overview` - if (appACLCapabilities.canAccessLogAndAnnotation) - return `/app/${app.id}/logs` + if (appACLCapabilities.canAccessLogAndAnnotation) return `/app/${app.id}/logs` - if (appACLCapabilities.canAccessConfig) - return `/app/${app.id}/access-config` + if (appACLCapabilities.canAccessConfig) return `/app/${app.id}/access-config` return `/app/${app.id}/develop` } diff --git a/web/utils/clipboard.spec.ts b/web/utils/clipboard.spec.ts index 4dbeb4fe6f66c7..f93eff91e49797 100644 --- a/web/utils/clipboard.spec.ts +++ b/web/utils/clipboard.spec.ts @@ -9,7 +9,6 @@ * while gracefully handling permissions and API availability. */ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' - import { writeTextToClipboard } from './clipboard' describe('Clipboard Utilities', () => { diff --git a/web/utils/clipboard.ts b/web/utils/clipboard.ts index 41406d4540f996..4e6eda4b4dc8e9 100644 --- a/web/utils/clipboard.ts +++ b/web/utils/clipboard.ts @@ -3,8 +3,7 @@ export async function writeTextToClipboard(text: string): Promise { try { await navigator.clipboard.writeText(text) return - } - catch { + } catch { // Fall through to the legacy path when browsers deny Clipboard API access. } } @@ -21,22 +20,18 @@ async function fallbackCopyTextToClipboard(text: string): Promise { textArea.select() try { const successful = document.execCommand('copy') - if (successful) - return Promise.resolve() + if (successful) return Promise.resolve() return Promise.reject(new Error('document.execCommand failed')) - } - catch (err) { + } catch (err) { return Promise.reject(convertAnyToError(err)) - } - finally { + } finally { document.body.removeChild(textArea) } } function convertAnyToError(err: unknown): Error { - if (err instanceof Error) - return err + if (err instanceof Error) return err return new Error(`Caught: ${String(err)}`) } diff --git a/web/utils/completion-params.spec.ts b/web/utils/completion-params.spec.ts index e56957de8f05fb..2ce6c1d6517f47 100644 --- a/web/utils/completion-params.spec.ts +++ b/web/utils/completion-params.spec.ts @@ -1,4 +1,7 @@ -import type { FormValue, ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + FormValue, + ModelParameterRule, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { mergeValidCompletionParams } from './completion-params' describe('completion-params', () => { @@ -21,7 +24,14 @@ describe('completion-params', () => { it('validates int type parameter within range', () => { const rules: ModelParameterRule[] = [ - { name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大 Token 数' }, required: false }, + { + name: 'max_tokens', + type: 'int', + min: 1, + max: 4096, + label: { en_US: 'Max Tokens', zh_Hans: '最大 Token 数' }, + required: false, + }, ] const oldParams: FormValue = { max_tokens: 100 } const result = mergeValidCompletionParams(oldParams, rules) @@ -32,7 +42,14 @@ describe('completion-params', () => { it('removes int parameter below minimum', () => { const rules: ModelParameterRule[] = [ - { name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大 Token 数' }, required: false }, + { + name: 'max_tokens', + type: 'int', + min: 1, + max: 4096, + label: { en_US: 'Max Tokens', zh_Hans: '最大 Token 数' }, + required: false, + }, ] const oldParams: FormValue = { max_tokens: 0 } const result = mergeValidCompletionParams(oldParams, rules) @@ -43,7 +60,14 @@ describe('completion-params', () => { it('removes int parameter above maximum', () => { const rules: ModelParameterRule[] = [ - { name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大 Token 数' }, required: false }, + { + name: 'max_tokens', + type: 'int', + min: 1, + max: 4096, + label: { en_US: 'Max Tokens', zh_Hans: '最大 Token 数' }, + required: false, + }, ] const oldParams: FormValue = { max_tokens: 5000 } const result = mergeValidCompletionParams(oldParams, rules) @@ -54,7 +78,14 @@ describe('completion-params', () => { it('removes int parameter with invalid type', () => { const rules: ModelParameterRule[] = [ - { name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大 Token 数' }, required: false }, + { + name: 'max_tokens', + type: 'int', + min: 1, + max: 4096, + label: { en_US: 'Max Tokens', zh_Hans: '最大 Token 数' }, + required: false, + }, ] const oldParams: FormValue = { max_tokens: 'not a number' as any } const result = mergeValidCompletionParams(oldParams, rules) @@ -65,7 +96,14 @@ describe('completion-params', () => { it('validates float type parameter', () => { const rules: ModelParameterRule[] = [ - { name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false }, + { + name: 'temperature', + type: 'float', + min: 0, + max: 2, + label: { en_US: 'Temperature', zh_Hans: '温度' }, + required: false, + }, ] const oldParams: FormValue = { temperature: 0.7 } const result = mergeValidCompletionParams(oldParams, rules) @@ -76,7 +114,14 @@ describe('completion-params', () => { it('validates float at boundary values', () => { const rules: ModelParameterRule[] = [ - { name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false }, + { + name: 'temperature', + type: 'float', + min: 0, + max: 2, + label: { en_US: 'Temperature', zh_Hans: '温度' }, + required: false, + }, ] const result1 = mergeValidCompletionParams({ temperature: 0 }, rules) @@ -88,7 +133,12 @@ describe('completion-params', () => { it('validates boolean type parameter', () => { const rules: ModelParameterRule[] = [ - { name: 'stream', type: 'boolean', label: { en_US: 'Stream', zh_Hans: '流' }, required: false }, + { + name: 'stream', + type: 'boolean', + label: { en_US: 'Stream', zh_Hans: '流' }, + required: false, + }, ] const oldParams: FormValue = { stream: true } const result = mergeValidCompletionParams(oldParams, rules) @@ -99,7 +149,12 @@ describe('completion-params', () => { it('removes boolean parameter with invalid type', () => { const rules: ModelParameterRule[] = [ - { name: 'stream', type: 'boolean', label: { en_US: 'Stream', zh_Hans: '流' }, required: false }, + { + name: 'stream', + type: 'boolean', + label: { en_US: 'Stream', zh_Hans: '流' }, + required: false, + }, ] const oldParams: FormValue = { stream: 'yes' as any } const result = mergeValidCompletionParams(oldParams, rules) @@ -110,7 +165,12 @@ describe('completion-params', () => { it('validates string type parameter', () => { const rules: ModelParameterRule[] = [ - { name: 'model', type: 'string', label: { en_US: 'Model', zh_Hans: '模型' }, required: false }, + { + name: 'model', + type: 'string', + label: { en_US: 'Model', zh_Hans: '模型' }, + required: false, + }, ] const oldParams: FormValue = { model: 'gpt-4' } const result = mergeValidCompletionParams(oldParams, rules) @@ -121,7 +181,13 @@ describe('completion-params', () => { it('validates string parameter with options', () => { const rules: ModelParameterRule[] = [ - { name: 'model', type: 'string', options: ['gpt-3.5-turbo', 'gpt-4'], label: { en_US: 'Model', zh_Hans: '模型' }, required: false }, + { + name: 'model', + type: 'string', + options: ['gpt-3.5-turbo', 'gpt-4'], + label: { en_US: 'Model', zh_Hans: '模型' }, + required: false, + }, ] const oldParams: FormValue = { model: 'gpt-4' } const result = mergeValidCompletionParams(oldParams, rules) @@ -132,7 +198,13 @@ describe('completion-params', () => { it('removes string parameter with invalid option', () => { const rules: ModelParameterRule[] = [ - { name: 'model', type: 'string', options: ['gpt-3.5-turbo', 'gpt-4'], label: { en_US: 'Model', zh_Hans: '模型' }, required: false }, + { + name: 'model', + type: 'string', + options: ['gpt-3.5-turbo', 'gpt-4'], + label: { en_US: 'Model', zh_Hans: '模型' }, + required: false, + }, ] const oldParams: FormValue = { model: 'invalid-model' } const result = mergeValidCompletionParams(oldParams, rules) @@ -143,7 +215,12 @@ describe('completion-params', () => { it('validates text type parameter', () => { const rules: ModelParameterRule[] = [ - { name: 'prompt', type: 'text', label: { en_US: 'Prompt', zh_Hans: '提示' }, required: false }, + { + name: 'prompt', + type: 'text', + label: { en_US: 'Prompt', zh_Hans: '提示' }, + required: false, + }, ] const oldParams: FormValue = { prompt: 'Hello world' } const result = mergeValidCompletionParams(oldParams, rules) @@ -154,7 +231,14 @@ describe('completion-params', () => { it('removes unsupported parameters', () => { const rules: ModelParameterRule[] = [ - { name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false }, + { + name: 'temperature', + type: 'float', + min: 0, + max: 2, + label: { en_US: 'Temperature', zh_Hans: '温度' }, + required: false, + }, ] const oldParams: FormValue = { temperature: 0.7, unsupported_param: 'value' } const result = mergeValidCompletionParams(oldParams, rules) @@ -183,9 +267,29 @@ describe('completion-params', () => { it('handles multiple parameters with mixed validity', () => { const rules: ModelParameterRule[] = [ - { name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false }, - { name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大 Token 数' }, required: false }, - { name: 'model', type: 'string', options: ['gpt-4'], label: { en_US: 'Model', zh_Hans: '模型' }, required: false }, + { + name: 'temperature', + type: 'float', + min: 0, + max: 2, + label: { en_US: 'Temperature', zh_Hans: '温度' }, + required: false, + }, + { + name: 'max_tokens', + type: 'int', + min: 1, + max: 4096, + label: { en_US: 'Max Tokens', zh_Hans: '最大 Token 数' }, + required: false, + }, + { + name: 'model', + type: 'string', + options: ['gpt-4'], + label: { en_US: 'Model', zh_Hans: '模型' }, + required: false, + }, ] const oldParams: FormValue = { temperature: 0.7, @@ -218,7 +322,12 @@ describe('completion-params', () => { it('removes parameter with unsupported rule type', () => { const rules: ModelParameterRule[] = [ - { name: 'custom', type: 'unknown_type', label: { en_US: 'Custom', zh_Hans: '自定义' }, required: false } as any, + { + name: 'custom', + type: 'unknown_type', + label: { en_US: 'Custom', zh_Hans: '自定义' }, + required: false, + } as any, ] const oldParams: FormValue = { custom: 'value' } const result = mergeValidCompletionParams(oldParams, rules) diff --git a/web/utils/completion-params.ts b/web/utils/completion-params.ts index 27c34cc8ea2e1f..f5e3e2de0fad87 100644 --- a/web/utils/completion-params.ts +++ b/web/utils/completion-params.ts @@ -1,12 +1,14 @@ -import type { FormValue, ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + FormValue, + ModelParameterRule, +} from '@/app/components/header/account-setting/model-provider-page/declarations' export const mergeValidCompletionParams = ( oldParams: FormValue | undefined, rules: ModelParameterRule[], isAdvancedMode: boolean = false, -): { params: FormValue, removedDetails: Record } => { - if (!oldParams || Object.keys(oldParams).length === 0) - return { params: {}, removedDetails: {} } +): { params: FormValue; removedDetails: Record } => { + if (!oldParams || Object.keys(oldParams).length === 0) return { params: {}, removedDetails: {} } const ruleMap: Record = {} rules.forEach((r) => { @@ -81,7 +83,7 @@ export const fetchAndMergeValidCompletionParams = async ( modelId: string, oldParams: FormValue | undefined, isAdvancedMode: boolean = false, -): Promise<{ params: FormValue, removedDetails: Record }> => { +): Promise<{ params: FormValue; removedDetails: Record }> => { const { fetchModelParameterRules } = await import('@/service/common') const url = `/workspaces/current/model-providers/${provider}/models/parameter-rules?model=${modelId}` const { data: parameterRules } = await fetchModelParameterRules(url) diff --git a/web/utils/create-app-tracking.ts b/web/utils/create-app-tracking.ts index 71e53ee8ff9ba0..030e6e7c6cb5aa 100644 --- a/web/utils/create-app-tracking.ts +++ b/web/utils/create-app-tracking.ts @@ -20,14 +20,14 @@ type SearchParamReader = { type OriginalCreateAppMode = 'workflow' | 'chatflow' | 'agent' -type CreateAppSource - = | 'external' - | 'explore_template_list' - | 'explore_template_preview' - | 'studio_blank' - | 'studio_template_list' - | 'studio_template_preview' - | 'studio_upload' +type CreateAppSource = + | 'external' + | 'explore_template_list' + | 'explore_template_preview' + | 'studio_blank' + | 'studio_template_list' + | 'studio_template_preview' + | 'studio_upload' export type TrackCreateAppParams = { source: CreateAppSource @@ -50,20 +50,17 @@ const getObjectStringValue = (value: unknown) => { } const getSearchParamValue = (searchParams?: SearchParamReader | null, key?: string) => { - if (!searchParams || !key) - return undefined + if (!searchParams || !key) return undefined return normalizeString(searchParams.get(key)) } const parseJSONRecord = (value?: string | null): Record | null => { - if (!value) - return null + if (!value) return null try { const parsed = JSON.parse(value) - return parsed && typeof parsed === 'object' ? parsed as Record : null - } - catch { + return parsed && typeof parsed === 'object' ? (parsed as Record) : null + } catch { return null } } @@ -79,11 +76,9 @@ const formatCreateAppTime = (date: Date) => { } const mapOriginalCreateAppMode = (appMode: string): OriginalCreateAppMode => { - if (appMode === AppModeEnum.WORKFLOW) - return 'workflow' + if (appMode === AppModeEnum.WORKFLOW) return 'workflow' - if (appMode === AppModeEnum.AGENT_CHAT || appMode === 'agent') - return 'agent' + if (appMode === AppModeEnum.AGENT_CHAT || appMode === 'agent') return 'agent' return 'chatflow' } @@ -95,15 +90,16 @@ export const extractExternalCreateAppAttribution = ({ searchParams?: SearchParamReader | null utmInfo?: Record | null }) => { - const rawSource = getSearchParamValue(searchParams, 'utm_source') ?? getObjectStringValue(utmInfo?.utm_source) + const rawSource = + getSearchParamValue(searchParams, 'utm_source') ?? getObjectStringValue(utmInfo?.utm_source) - if (!rawSource) - return null + if (!rawSource) return null - const slug = getSearchParamValue(searchParams, 'slug') - ?? getSearchParamValue(searchParams, 'utm_campaign') - ?? getObjectStringValue(utmInfo?.slug) - ?? getObjectStringValue(utmInfo?.utm_campaign) + const slug = + getSearchParamValue(searchParams, 'slug') ?? + getSearchParamValue(searchParams, 'utm_campaign') ?? + getObjectStringValue(utmInfo?.slug) ?? + getObjectStringValue(utmInfo?.utm_campaign) return { utmSource: rawSource, @@ -112,15 +108,18 @@ export const extractExternalCreateAppAttribution = ({ } const readRememberedExternalCreateAppAttribution = (): ExternalCreateAppAttribution | null => { - const attribution = parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)) - const rawSource = getObjectStringValue(attribution?.utmSource) ?? getObjectStringValue(attribution?.utm_source) + const attribution = parseJSONRecord( + window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY), + ) + const rawSource = + getObjectStringValue(attribution?.utmSource) ?? getObjectStringValue(attribution?.utm_source) - if (!rawSource) - return null + if (!rawSource) return null - const slug = getObjectStringValue(attribution?.slug) - ?? getObjectStringValue(attribution?.utmCampaign) - ?? getObjectStringValue(attribution?.utm_campaign) + const slug = + getObjectStringValue(attribution?.slug) ?? + getObjectStringValue(attribution?.utmCampaign) ?? + getObjectStringValue(attribution?.utm_campaign) return { utmSource: rawSource, @@ -129,7 +128,10 @@ const readRememberedExternalCreateAppAttribution = (): ExternalCreateAppAttribut } const writeRememberedExternalCreateAppAttribution = (attribution: ExternalCreateAppAttribution) => { - window.sessionStorage.setItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY, JSON.stringify(attribution)) + window.sessionStorage.setItem( + CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY, + JSON.stringify(attribution), + ) } const clearRememberedExternalCreateAppAttribution = () => { @@ -155,8 +157,7 @@ export const rememberCreateAppExternalAttribution = ({ } const resolveCurrentExternalCreateAppAttribution = () => { - if (typeof window === 'undefined') - return null + if (typeof window === 'undefined') return null return readRememberedExternalCreateAppAttribution() } @@ -168,8 +169,7 @@ export const buildCreateAppEventPayload = ( ) => { const source = externalAttribution ? 'external' : params.source - if (source === 'external' && !externalAttribution) - return null + if (source === 'external' && !externalAttribution) return null return { source, @@ -189,16 +189,13 @@ export const trackCreateApp = (params: TrackCreateAppParams): Promise | un const externalAttribution = resolveCurrentExternalCreateAppAttribution() const payload = buildCreateAppEventPayload(params, externalAttribution) - if (!payload) - return + if (!payload) return - if (externalAttribution) - clearRememberedExternalCreateAppAttribution() + if (externalAttribution) clearRememberedExternalCreateAppAttribution() const trackingResult = trackEvent('create_app', payload) - if (!trackingResult) - return + if (!trackingResult) return const flushResult = flushEvents() diff --git a/web/utils/download.ts b/web/utils/download.ts index 80b563bf938d57..3be92efd43af1d 100644 --- a/web/utils/download.ts +++ b/web/utils/download.ts @@ -6,28 +6,29 @@ type DownloadUrlOptions = { } const triggerDownload = ({ url, fileName, rel, target }: DownloadUrlOptions) => { - if (!url) - return + if (!url) return const anchor = document.createElement('a') anchor.href = url - if (fileName) - anchor.download = fileName - if (rel) - anchor.rel = rel - if (target) - anchor.target = target + if (fileName) anchor.download = fileName + if (rel) anchor.rel = rel + if (target) anchor.target = target anchor.style.display = 'none' document.body.appendChild(anchor) anchor.click() anchor.remove() } -export const downloadUrl = ({ url, fileName, rel = 'noopener noreferrer', target }: DownloadUrlOptions) => { +export const downloadUrl = ({ + url, + fileName, + rel = 'noopener noreferrer', + target, +}: DownloadUrlOptions) => { triggerDownload({ url, fileName, rel, target }) } -export const downloadBlob = ({ data, fileName }: { data: Blob, fileName: string }) => { +export const downloadBlob = ({ data, fileName }: { data: Blob; fileName: string }) => { const url = window.URL.createObjectURL(data) triggerDownload({ url, fileName, rel: 'noopener noreferrer' }) window.URL.revokeObjectURL(url) diff --git a/web/utils/draft-07.json b/web/utils/draft-07.json index 99389d7ab4f522..b4d7bf5efabee5 100644 --- a/web/utils/draft-07.json +++ b/web/utils/draft-07.json @@ -25,15 +25,7 @@ ] }, "simpleTypes": { - "enum": [ - "array", - "boolean", - "integer", - "null", - "number", - "object", - "string" - ] + "enum": ["array", "boolean", "integer", "null", "number", "object", "string"] }, "stringArray": { "type": "array", @@ -44,10 +36,7 @@ "default": [] } }, - "type": [ - "object", - "boolean" - ], + "type": ["object", "boolean"], "properties": { "$id": { "type": "string", diff --git a/web/utils/emoji.spec.ts b/web/utils/emoji.spec.ts index 9ee12e94301d4d..988c0fd837cd05 100644 --- a/web/utils/emoji.spec.ts +++ b/web/utils/emoji.spec.ts @@ -51,11 +51,7 @@ describe('Emoji Utilities', () => { it('should extract native from first skin', async () => { const mockEmojis = [ { - skins: [ - { native: '👍' }, - { native: '👍🏻' }, - { native: '👍🏼' }, - ], + skins: [{ native: '👍' }, { native: '👍🏻' }, { native: '👍🏼' }], }, ] ;(SearchIndex.search as Mock).mockResolvedValue(mockEmojis) @@ -65,10 +61,7 @@ describe('Emoji Utilities', () => { }) it('should handle multiple search terms', async () => { - const mockEmojis = [ - { skins: [{ native: '❤️' }] }, - { skins: [{ native: '💙' }] }, - ] + const mockEmojis = [{ skins: [{ native: '❤️' }] }, { skins: [{ native: '💙' }] }] ;(SearchIndex.search as Mock).mockResolvedValue(mockEmojis) const result = await searchEmoji('heart love') diff --git a/web/utils/emoji.ts b/web/utils/emoji.ts index ec961dd69a6f8f..8ec0bfdaa824f6 100644 --- a/web/utils/emoji.ts +++ b/web/utils/emoji.ts @@ -2,7 +2,7 @@ import type { Emoji } from '@emoji-mart/data' import { SearchIndex } from 'emoji-mart' export async function searchEmoji(value: string) { - const emojis: Emoji[] = await SearchIndex.search(value) || [] + const emojis: Emoji[] = (await SearchIndex.search(value)) || [] const results = emojis.map((emoji) => { return emoji.skins[0]!.native diff --git a/web/utils/encryption.ts b/web/utils/encryption.ts index 622132fb9323da..ebfc7bc8c73a94 100644 --- a/web/utils/encryption.ts +++ b/web/utils/encryption.ts @@ -19,8 +19,7 @@ function encryptField(plaintext: string): string { const utf8Bytes = new TextEncoder().encode(plaintext) const base64 = btoa(String.fromCharCode(...utf8Bytes)) return base64 - } - catch (error) { + } catch (error) { console.error('Field encoding failed:', error) // If encoding fails, throw error to prevent sending plaintext throw new Error('Encoding failed. Please check your input.') diff --git a/web/utils/error-parser.ts b/web/utils/error-parser.ts index 56acdd9cea566e..b10617a38077d7 100644 --- a/web/utils/error-parser.ts +++ b/web/utils/error-parser.ts @@ -17,12 +17,10 @@ export const parsePluginErrorMessage = async (error: any): Promise => { try { const body = await error.clone().json() rawMessage = body?.message || error.statusText || 'Unknown error' - } - catch { + } catch { rawMessage = error.statusText || 'Unknown error' } - } - else { + } else { rawMessage = error?.message || error?.toString() || 'Unknown error' } @@ -37,13 +35,10 @@ export const parsePluginErrorMessage = async (error: any): Promise => { try { const errorData = JSON.parse(match[1]!) // Return the inner message if exists - if (errorData.message) - return errorData.message + if (errorData.message) return errorData.message // Fallback to error_type if message not available - if (errorData.error_type) - return errorData.error_type - } - catch (parseError) { + if (errorData.error_type) return errorData.error_type + } catch (parseError) { console.warn('Failed to parse plugin error JSON:', parseError) } } diff --git a/web/utils/format.ts b/web/utils/format.ts index 1e6df2ea8c4531..ee81068e6325ce 100644 --- a/web/utils/format.ts +++ b/web/utils/format.ts @@ -30,8 +30,7 @@ import 'dayjs/locale/zh-tw' * @example formatNumber(0.0000008) will return '0.0000008' */ export const formatNumber = (num: number | string) => { - if (!num) - return num + if (!num) return num const n = typeof num === 'string' ? Number(num) : num let numStr: string @@ -47,15 +46,13 @@ export const formatNumber = (num: number | string) => { const mantissa = str.split('e')[0] const mantissaDecimalPart = mantissa!.split('.')[1] precision = exponent + (mantissaDecimalPart?.length || 0) - } - else { + } else { // Decimal notation: count decimal places const decimalPart = str.split('.')[1] precision = decimalPart?.length || 0 } numStr = n.toFixed(precision) - } - else { + } else { numStr = n.toString() } @@ -71,16 +68,14 @@ export const formatNumber = (num: number | string) => { * @example formatFileSize(1024 * 1024) will return '1.00 MB' */ export const formatFileSize = (fileSize: number) => { - if (!fileSize) - return fileSize + if (!fileSize) return fileSize const units = ['', 'K', 'M', 'G', 'T', 'P'] let index = 0 while (fileSize >= 1024 && index < units.length) { fileSize = fileSize / 1024 index++ } - if (index === 0) - return `${fileSize.toFixed(2)} bytes` + if (index === 0) return `${fileSize.toFixed(2)} bytes` return `${fileSize.toFixed(2)} ${units[index]}B` } @@ -90,8 +85,7 @@ export const formatFileSize = (fileSize: number) => { * @example formatTime(60 * 60) will return '1.00 h' */ export const formatTime = (seconds: number) => { - if (!seconds) - return seconds + if (!seconds) return seconds const units = ['sec', 'min', 'h'] let index = 0 while (seconds >= 60 && index < units.length) { @@ -114,8 +108,7 @@ export const formatTime = (seconds: number) => { */ export const formatNumberAbbreviated = (num: number) => { // If less than 1000, return as-is - if (num < 1000) - return num.toString() + if (num < 1000) return num.toString() // Define thresholds and suffixes const units = [ @@ -159,13 +152,17 @@ export const formatToLocalTime = (time: Dayjs, local: Locale, format: string) => * @example getFileExtension('.hidden.txt') will return 'txt' */ export const getFileExtension = (fileName: string): string => { - if (!fileName) - return '' + if (!fileName) return '' // Handle hidden files (starting with dot) by finding dot after the first character const dotIndex = fileName.indexOf('.', fileName.startsWith('.') ? 1 : 0) - if (dotIndex === -1 || dotIndex === fileName.length - 1) - return '' + if (dotIndex === -1 || dotIndex === fileName.length - 1) return '' - return fileName.slice(dotIndex + 1).split('.').pop()?.toLowerCase() ?? '' + return ( + fileName + .slice(dotIndex + 1) + .split('.') + .pop() + ?.toLowerCase() ?? '' + ) } diff --git a/web/utils/gtag.ts b/web/utils/gtag.ts index 5f199f1fbcd591..2a79df0105dd27 100644 --- a/web/utils/gtag.ts +++ b/web/utils/gtag.ts @@ -5,12 +5,9 @@ import { isServer } from '@/utils/client' * @param eventName - event name * @param eventParams - event params */ -export const sendGAEvent = ( - eventName: string, - eventParams?: GtagEventParams, -): void => { +export const sendGAEvent = (eventName: string, eventParams?: GtagEventParams): void => { if (isServer || typeof (window as any).gtag !== 'function') { return } - (window as any).gtag('event', eventName, eventParams) + ;(window as any).gtag('event', eventName, eventParams) } diff --git a/web/utils/index.spec.ts b/web/utils/index.spec.ts index 97e5c0001be7e2..5f99026b77e47e 100644 --- a/web/utils/index.spec.ts +++ b/web/utils/index.spec.ts @@ -349,10 +349,8 @@ describe('fetchWithRetry extended', () => { let attempts = 0 const eventuallySucceed = new Promise((resolve, reject) => { attempts++ - if (attempts < 2) - reject(new Error('not yet')) - else - resolve('success') + if (attempts < 2) reject(new Error('not yet')) + else resolve('success') }) const [error] = await fetchWithRetry(eventuallySucceed, 3) @@ -408,7 +406,9 @@ describe('correctToolProvider extended', () => { it('should handle special tool providers', () => { expect(correctToolProvider('stepfun', false)).toBe('langgenius/stepfun_tool/stepfun') expect(correctToolProvider('jina', false)).toBe('langgenius/jina_tool/jina') - expect(correctToolProvider('siliconflow', false)).toBe('langgenius/siliconflow_tool/siliconflow') + expect(correctToolProvider('siliconflow', false)).toBe( + 'langgenius/siliconflow_tool/siliconflow', + ) expect(correctToolProvider('gitee_ai', false)).toBe('langgenius/gitee_ai_tool/gitee_ai') }) diff --git a/web/utils/index.ts b/web/utils/index.ts index 8702b4df5f7d4d..a0234e0aac5429 100644 --- a/web/utils/index.ts +++ b/web/utils/index.ts @@ -1,14 +1,13 @@ import { escape } from 'es-toolkit/string' export const sleep = (ms: number) => { - return new Promise(resolve => setTimeout(resolve, ms)) + return new Promise((resolve) => setTimeout(resolve, ms)) } export async function asyncRunSafe(fn: Promise): Promise<[Error] | [null, T]> { try { return [null, await fn] - } - catch (e: any) { + } catch (e: any) { return [e || new Error('unknown error')] } } @@ -17,59 +16,54 @@ export const getTextWidthWithCanvas = (text: string, font?: string) => { const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') if (ctx) { - ctx.font = font ?? '12px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"' + ctx.font = + font ?? + '12px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"' return Number(ctx.measureText(text).width.toFixed(2)) } return 0 } export const getPurifyHref = (href: string) => { - if (!href) - return '' + if (!href) return '' return escape(href) } -export async function fetchWithRetry(fn: Promise, retries = 3): Promise<[Error] | [null, T]> { +export async function fetchWithRetry( + fn: Promise, + retries = 3, +): Promise<[Error] | [null, T]> { const [error, res] = await asyncRunSafe(fn) if (error) { if (retries > 0) { const res = await fetchWithRetry(fn, retries - 1) return res - } - else { - if (error instanceof Error) - return [error] + } else { + if (error instanceof Error) return [error] return [new Error('unknown error')] } - } - else { + } else { return [null, res] } } export const correctModelProvider = (provider: string) => { - if (!provider) - return '' + if (!provider) return '' - if (provider.includes('/')) - return provider + if (provider.includes('/')) return provider - if (['google'].includes(provider)) - return 'langgenius/gemini/google' + if (['google'].includes(provider)) return 'langgenius/gemini/google' return `langgenius/${provider}/${provider}` } export const correctToolProvider = (provider: string, toolInCollectionList?: boolean) => { - if (!provider) - return '' + if (!provider) return '' - if (toolInCollectionList) - return provider + if (toolInCollectionList) return provider - if (provider.includes('/')) - return provider + if (provider.includes('/')) return provider if (['stepfun', 'jina', 'siliconflow', 'gitee_ai'].includes(provider)) return `langgenius/${provider}_tool/${provider}` @@ -78,7 +72,9 @@ export const correctToolProvider = (provider: string, toolInCollectionList?: boo } export const canFindTool = (providerId: string, oldToolId?: string) => { - return providerId === oldToolId - || providerId === `langgenius/${oldToolId}/${oldToolId}` - || providerId === `langgenius/${oldToolId}_tool/${oldToolId}` + return ( + providerId === oldToolId || + providerId === `langgenius/${oldToolId}/${oldToolId}` || + providerId === `langgenius/${oldToolId}_tool/${oldToolId}` + ) } diff --git a/web/utils/model-config.spec.ts b/web/utils/model-config.spec.ts index de3a3ea59130bd..de70928ffd6a36 100644 --- a/web/utils/model-config.spec.ts +++ b/web/utils/model-config.spec.ts @@ -20,15 +20,13 @@ import { } from './model-config' const getTextInput = (item: UserInputFormItem | undefined) => { - if (!item || !('text-input' in item)) - throw new Error('Expected text-input user input form item') + if (!item || !('text-input' in item)) throw new Error('Expected text-input user input form item') return item['text-input'] } const getSelectInput = (item: UserInputFormItem | undefined) => { - if (!item || !('select' in item)) - throw new Error('Expected select user input form item') + if (!item || !('select' in item)) throw new Error('Expected select user input form item') return item.select } diff --git a/web/utils/model-config.ts b/web/utils/model-config.ts index ee5322a7a069e6..1fcb14276d457a 100644 --- a/web/utils/model-config.ts +++ b/web/utils/model-config.ts @@ -22,21 +22,23 @@ const getNumber = (value: unknown) => { } const getDefaultValue = (value: unknown) => { - return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : undefined + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' + ? value + : undefined } const getStringArray = (value: unknown) => { - return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : [] } const getStringRecord = (value: unknown) => { - if (!isRecord(value)) - return undefined + if (!isRecord(value)) return undefined const record: Record = {} Object.entries(value).forEach(([key, item]) => { - if (typeof item === 'string') - record[key] = item + if (typeof item === 'string') record[key] = item }) return record } @@ -46,36 +48,31 @@ const getRecord = (value: unknown) => { } const getInputFormContent = (item: Record) => { - if (isRecord(item.paragraph)) - return { type: 'paragraph', content: item.paragraph } + if (isRecord(item.paragraph)) return { type: 'paragraph', content: item.paragraph } - if (isRecord(item['text-input'])) - return { type: 'string', content: item['text-input'] } + if (isRecord(item['text-input'])) return { type: 'string', content: item['text-input'] } - if (isRecord(item.number)) - return { type: 'number', content: item.number } + if (isRecord(item.number)) return { type: 'number', content: item.number } - if (isRecord(item.checkbox)) - return { type: 'boolean', content: item.checkbox } + if (isRecord(item.checkbox)) return { type: 'boolean', content: item.checkbox } - if (isRecord(item.file)) - return { type: 'file', content: item.file } + if (isRecord(item.file)) return { type: 'file', content: item.file } - if (isRecord(item['file-list'])) - return { type: 'file-list', content: item['file-list'] } + if (isRecord(item['file-list'])) return { type: 'file-list', content: item['file-list'] } if (isRecord(item.external_data_tool)) return { type: getString(item.external_data_tool.type), content: item.external_data_tool } - if (isRecord(item.json_object)) - return { type: 'json_object', content: item.json_object } + if (isRecord(item.json_object)) return { type: 'json_object', content: item.json_object } return { type: 'select', content: getRecord(item.select) } } -export const userInputsFormToPromptVariables = (useInputs: Record[] | null, dataset_query_variable?: string) => { - if (!useInputs) - return [] +export const userInputsFormToPromptVariables = ( + useInputs: Record[] | null, + dataset_query_variable?: string, +) => { + if (!useInputs) return [] const promptVariables: PromptVariable[] = [] useInputs.forEach((item) => { const { type, content } = getInputFormContent(item) @@ -94,8 +91,7 @@ export const userInputsFormToPromptVariables = (useInputs: Record { const userInputs: UserInputFormItem[] = [] - promptVariables.filter(({ key, name }) => { - return key && key.trim() && name && name.trim() - }).forEach((item) => { - if (item.type === 'string') { - userInputs.push({ - 'text-input': { - label: item.name, - variable: item.key, - required: item.required !== false, // default true - max_length: item.max_length, - default: '', - hide: item.hide, - }, - }) - return - } - if (item.type === 'paragraph') { - userInputs.push({ - paragraph: { - label: item.name, - variable: item.key, - required: item.required !== false, // default true - max_length: item.max_length, - default: '', - hide: item.hide, - }, - }) - return - } - if (item.type === 'number') { - userInputs.push({ - number: { - label: item.name, - variable: item.key, - required: item.required !== false, // default true - default: '', - hide: item.hide, - }, - }) - } - else if (item.type === 'checkbox') { - userInputs.push({ - checkbox: { - label: item.name, - variable: item.key, - required: item.required !== false, // default true - default: '', - hide: item.hide, - }, - }) - } - else if (item.type === 'select') { - userInputs.push({ - select: { - label: item.name, - variable: item.key, - required: item.required !== false, // default true - options: item.options, - default: getString(item.default), - hide: item.hide, - }, - }) - } - else { - userInputs.push({ - external_data_tool: { - label: item.name, - variable: item.key, - enabled: item.enabled, - type: item.type, - config: getStringRecord(item.config), - required: item.required, - icon: item.icon, - icon_background: item.icon_background, - hide: item.hide, - }, - }) - } - }) + promptVariables + .filter(({ key, name }) => { + return key && key.trim() && name && name.trim() + }) + .forEach((item) => { + if (item.type === 'string') { + userInputs.push({ + 'text-input': { + label: item.name, + variable: item.key, + required: item.required !== false, // default true + max_length: item.max_length, + default: '', + hide: item.hide, + }, + }) + return + } + if (item.type === 'paragraph') { + userInputs.push({ + paragraph: { + label: item.name, + variable: item.key, + required: item.required !== false, // default true + max_length: item.max_length, + default: '', + hide: item.hide, + }, + }) + return + } + if (item.type === 'number') { + userInputs.push({ + number: { + label: item.name, + variable: item.key, + required: item.required !== false, // default true + default: '', + hide: item.hide, + }, + }) + } else if (item.type === 'checkbox') { + userInputs.push({ + checkbox: { + label: item.name, + variable: item.key, + required: item.required !== false, // default true + default: '', + hide: item.hide, + }, + }) + } else if (item.type === 'select') { + userInputs.push({ + select: { + label: item.name, + variable: item.key, + required: item.required !== false, // default true + options: item.options, + default: getString(item.default), + hide: item.hide, + }, + }) + } else { + userInputs.push({ + external_data_tool: { + label: item.name, + variable: item.key, + enabled: item.enabled, + type: item.type, + config: getStringRecord(item.config), + required: item.required, + icon: item.icon, + icon_background: item.icon_background, + hide: item.hide, + }, + }) + } + }) return userInputs } -export const formatBooleanInputs = (useInputs?: PromptVariable[] | null, inputs?: Record | null) => { - if (!useInputs) - return inputs +export const formatBooleanInputs = ( + useInputs?: PromptVariable[] | null, + inputs?: Record | null, +) => { + if (!useInputs) return inputs const res = { ...inputs } useInputs.forEach((item) => { const isBooleanInput = item.type === 'checkbox' diff --git a/web/utils/object.ts b/web/utils/object.ts index cf5d718ff260cf..35e8476ab3cde2 100644 --- a/web/utils/object.ts +++ b/web/utils/object.ts @@ -1,5 +1,7 @@ -export function ObjectFromEntries>(entries: T): { [K in T[number]as K[0]]: K[1] } { - return Object.fromEntries(entries) as { [K in T[number]as K[0]]: K[1] } +export function ObjectFromEntries>( + entries: T, +): { [K in T[number] as K[0]]: K[1] } { + return Object.fromEntries(entries) as { [K in T[number] as K[0]]: K[1] } } export function ObjectKeys>(obj: T): (keyof T)[] { diff --git a/web/utils/permission.spec.ts b/web/utils/permission.spec.ts index 735c3336489630..53d846a319a2b6 100644 --- a/web/utils/permission.spec.ts +++ b/web/utils/permission.spec.ts @@ -46,7 +46,9 @@ describe('permission', () => { it('keeps monitor, tracing config, and log/annotation permissions independent', () => { const monitorCapabilities = getAppACLCapabilities([AppACLPermission.Monitor]) const tracingCapabilities = getAppACLCapabilities([AppACLPermission.TracingConfig]) - const logAndAnnotationCapabilities = getAppACLCapabilities([AppACLPermission.LogAndAnnotation]) + const logAndAnnotationCapabilities = getAppACLCapabilities([ + AppACLPermission.LogAndAnnotation, + ]) expect(monitorCapabilities.canMonitor).toBe(true) expect(monitorCapabilities.canConfigureTracing).toBe(false) @@ -68,10 +70,9 @@ describe('permission', () => { }) it('should return false when app ACL contains preview permission and another permission', () => { - expect(hasOnlyAppPreviewPermission([ - AppACLPermission.Preview, - AppACLPermission.ViewLayout, - ])).toBe(false) + expect( + hasOnlyAppPreviewPermission([AppACLPermission.Preview, AppACLPermission.ViewLayout]), + ).toBe(false) }) }) @@ -81,10 +82,12 @@ describe('permission', () => { }) it('should return false when dataset ACL contains preview permission and another permission', () => { - expect(hasOnlyDatasetPreviewPermission([ - DatasetACLPermission.Preview, - DatasetACLPermission.Readonly, - ])).toBe(false) + expect( + hasOnlyDatasetPreviewPermission([ + DatasetACLPermission.Preview, + DatasetACLPermission.Readonly, + ]), + ).toBe(false) }) }) diff --git a/web/utils/permission.ts b/web/utils/permission.ts index 90017c7ff8fc36..7da5d448986f33 100644 --- a/web/utils/permission.ts +++ b/web/utils/permission.ts @@ -72,22 +72,28 @@ type DatasetACLCapabilities = { canAccessConfig: boolean } -export const hasPermission = (permissionKeys: readonly PermissionKey[] | null | undefined, permissionKeySet: PermissionKey | PermissionKey[]) => { - if (!permissionKeys) - return false +export const hasPermission = ( + permissionKeys: readonly PermissionKey[] | null | undefined, + permissionKeySet: PermissionKey | PermissionKey[], +) => { + if (!permissionKeys) return false if (Array.isArray(permissionKeySet)) { - return permissionKeySet.some(key => permissionKeys.includes(key)) + return permissionKeySet.some((key) => permissionKeys.includes(key)) } const singlePermissionKey = permissionKeySet return permissionKeys.includes(singlePermissionKey) } -export const hasOnlyAppPreviewPermission = (permissionKeys: readonly PermissionKey[] | null | undefined) => { +export const hasOnlyAppPreviewPermission = ( + permissionKeys: readonly PermissionKey[] | null | undefined, +) => { return permissionKeys?.length === 1 && permissionKeys[0] === AppACLPermission.Preview } -export const hasOnlyDatasetPreviewPermission = (permissionKeys: readonly PermissionKey[] | null | undefined) => { +export const hasOnlyDatasetPreviewPermission = ( + permissionKeys: readonly PermissionKey[] | null | undefined, +) => { return permissionKeys?.length === 1 && permissionKeys[0] === DatasetACLPermission.Preview } @@ -95,11 +101,12 @@ const shouldGrantMaintainerPermissions = ( options: ResourceMaintainerPermissionOptions | undefined, createPermissionKey: PermissionKey, ) => { - if (!options?.currentUserId || !options?.resourceMaintainer) - return false + if (!options?.currentUserId || !options?.resourceMaintainer) return false - return options.currentUserId === options.resourceMaintainer - && hasPermission(options.workspacePermissionKeys, createPermissionKey) + return ( + options.currentUserId === options.resourceMaintainer && + hasPermission(options.workspacePermissionKeys, createPermissionKey) + ) } const hasResourcePermission = ( @@ -112,10 +119,25 @@ export const getAppACLCapabilities = ( permissionKeys: readonly PermissionKey[] | null | undefined, options?: ResourceMaintainerPermissionOptions, ): AppACLCapabilities => { - const hasMaintainerPermissions = shouldGrantMaintainerPermissions(options, 'app.create_and_management') - const canViewLayout = hasResourcePermission(permissionKeys, AppACLPermission.ViewLayout, hasMaintainerPermissions) - const canTestAndRun = hasResourcePermission(permissionKeys, AppACLPermission.TestAndRun, hasMaintainerPermissions) - const canEdit = hasResourcePermission(permissionKeys, AppACLPermission.Edit, hasMaintainerPermissions) + const hasMaintainerPermissions = shouldGrantMaintainerPermissions( + options, + 'app.create_and_management', + ) + const canViewLayout = hasResourcePermission( + permissionKeys, + AppACLPermission.ViewLayout, + hasMaintainerPermissions, + ) + const canTestAndRun = hasResourcePermission( + permissionKeys, + AppACLPermission.TestAndRun, + hasMaintainerPermissions, + ) + const canEdit = hasResourcePermission( + permissionKeys, + AppACLPermission.Edit, + hasMaintainerPermissions, + ) return { canViewLayout, @@ -124,13 +146,43 @@ export const getAppACLCapabilities = ( canAccessLayout: canViewLayout || canTestAndRun || canEdit, canComment: canViewLayout || canEdit, canPreviewApp: canViewLayout || canTestAndRun, - canImportExportDSL: hasResourcePermission(permissionKeys, AppACLPermission.ImportExportDSL, hasMaintainerPermissions), - canDelete: hasResourcePermission(permissionKeys, AppACLPermission.Delete, hasMaintainerPermissions), - canReleaseAndVersion: hasResourcePermission(permissionKeys, AppACLPermission.ReleaseAndVersion, hasMaintainerPermissions), - canMonitor: hasResourcePermission(permissionKeys, AppACLPermission.Monitor, hasMaintainerPermissions), - canConfigureTracing: hasResourcePermission(permissionKeys, AppACLPermission.TracingConfig, hasMaintainerPermissions), - canAccessLogAndAnnotation: hasResourcePermission(permissionKeys, AppACLPermission.LogAndAnnotation, hasMaintainerPermissions), - canAccessConfig: Boolean(options?.isRbacEnabled) && hasResourcePermission(permissionKeys, AppACLPermission.AccessConfig, hasMaintainerPermissions), + canImportExportDSL: hasResourcePermission( + permissionKeys, + AppACLPermission.ImportExportDSL, + hasMaintainerPermissions, + ), + canDelete: hasResourcePermission( + permissionKeys, + AppACLPermission.Delete, + hasMaintainerPermissions, + ), + canReleaseAndVersion: hasResourcePermission( + permissionKeys, + AppACLPermission.ReleaseAndVersion, + hasMaintainerPermissions, + ), + canMonitor: hasResourcePermission( + permissionKeys, + AppACLPermission.Monitor, + hasMaintainerPermissions, + ), + canConfigureTracing: hasResourcePermission( + permissionKeys, + AppACLPermission.TracingConfig, + hasMaintainerPermissions, + ), + canAccessLogAndAnnotation: hasResourcePermission( + permissionKeys, + AppACLPermission.LogAndAnnotation, + hasMaintainerPermissions, + ), + canAccessConfig: + Boolean(options?.isRbacEnabled) && + hasResourcePermission( + permissionKeys, + AppACLPermission.AccessConfig, + hasMaintainerPermissions, + ), } } @@ -138,19 +190,68 @@ export const getDatasetACLCapabilities = ( permissionKeys: readonly PermissionKey[] | null | undefined, options?: ResourceMaintainerPermissionOptions, ): DatasetACLCapabilities => { - const hasMaintainerPermissions = shouldGrantMaintainerPermissions(options, 'dataset.create_and_management') + const hasMaintainerPermissions = shouldGrantMaintainerPermissions( + options, + 'dataset.create_and_management', + ) return { - canReadonly: hasResourcePermission(permissionKeys, DatasetACLPermission.Readonly, hasMaintainerPermissions), - canEdit: hasResourcePermission(permissionKeys, DatasetACLPermission.Edit, hasMaintainerPermissions), - canImportExportDSL: hasResourcePermission(permissionKeys, DatasetACLPermission.ImportExportDSL, hasMaintainerPermissions), - canPipelineTest: hasResourcePermission(permissionKeys, DatasetACLPermission.PipelineTest, hasMaintainerPermissions), - canDocumentDownload: hasResourcePermission(permissionKeys, DatasetACLPermission.DocumentDownload, hasMaintainerPermissions), - canRetrievalRecall: hasResourcePermission(permissionKeys, DatasetACLPermission.RetrievalRecall, hasMaintainerPermissions), - canUse: hasResourcePermission(permissionKeys, DatasetACLPermission.Use, hasMaintainerPermissions), - canDeleteFile: hasResourcePermission(permissionKeys, DatasetACLPermission.DeleteFile, hasMaintainerPermissions), - canPipelineRelease: hasResourcePermission(permissionKeys, DatasetACLPermission.PipelineRelease, hasMaintainerPermissions), - canDelete: hasResourcePermission(permissionKeys, DatasetACLPermission.Delete, hasMaintainerPermissions), - canAccessConfig: Boolean(options?.isRbacEnabled) && hasResourcePermission(permissionKeys, DatasetACLPermission.AccessConfig, hasMaintainerPermissions), + canReadonly: hasResourcePermission( + permissionKeys, + DatasetACLPermission.Readonly, + hasMaintainerPermissions, + ), + canEdit: hasResourcePermission( + permissionKeys, + DatasetACLPermission.Edit, + hasMaintainerPermissions, + ), + canImportExportDSL: hasResourcePermission( + permissionKeys, + DatasetACLPermission.ImportExportDSL, + hasMaintainerPermissions, + ), + canPipelineTest: hasResourcePermission( + permissionKeys, + DatasetACLPermission.PipelineTest, + hasMaintainerPermissions, + ), + canDocumentDownload: hasResourcePermission( + permissionKeys, + DatasetACLPermission.DocumentDownload, + hasMaintainerPermissions, + ), + canRetrievalRecall: hasResourcePermission( + permissionKeys, + DatasetACLPermission.RetrievalRecall, + hasMaintainerPermissions, + ), + canUse: hasResourcePermission( + permissionKeys, + DatasetACLPermission.Use, + hasMaintainerPermissions, + ), + canDeleteFile: hasResourcePermission( + permissionKeys, + DatasetACLPermission.DeleteFile, + hasMaintainerPermissions, + ), + canPipelineRelease: hasResourcePermission( + permissionKeys, + DatasetACLPermission.PipelineRelease, + hasMaintainerPermissions, + ), + canDelete: hasResourcePermission( + permissionKeys, + DatasetACLPermission.Delete, + hasMaintainerPermissions, + ), + canAccessConfig: + Boolean(options?.isRbacEnabled) && + hasResourcePermission( + permissionKeys, + DatasetACLPermission.AccessConfig, + hasMaintainerPermissions, + ), } } diff --git a/web/utils/plugin-version-feature.ts b/web/utils/plugin-version-feature.ts index 51d366bf9c34fc..c31ebef9e86ae9 100644 --- a/web/utils/plugin-version-feature.ts +++ b/web/utils/plugin-version-feature.ts @@ -3,8 +3,7 @@ import { isEqualOrLaterThanVersion } from './semver' const SUPPORT_MCP_VERSION = '0.0.2' export const isSupportMCP = (version?: string): boolean => { - if (!version) - return false + if (!version) return false return isEqualOrLaterThanVersion(version, SUPPORT_MCP_VERSION) } diff --git a/web/utils/query-atoms.ts b/web/utils/query-atoms.ts index 543a51499c2d17..0bb012d2f1e1db 100644 --- a/web/utils/query-atoms.ts +++ b/web/utils/query-atoms.ts @@ -29,9 +29,7 @@ export function atomWithResolvedSuspenseQuery< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( - getOptions: ( - get: Getter, - ) => AtomWithSuspenseQueryOptions, + getOptions: (get: Getter) => AtomWithSuspenseQueryOptions, getQueryClient?: (get: Getter) => QueryClient, ): WritableAtom, [], void> { const queryAtom = atomWithSuspenseQuery(getOptions, getQueryClient) diff --git a/web/utils/semver.spec.ts b/web/utils/semver.spec.ts index 42d6a3fb5420f0..38c29f6ae34556 100644 --- a/web/utils/semver.spec.ts +++ b/web/utils/semver.spec.ts @@ -1,4 +1,9 @@ -import { compareVersion, getLatestVersion, isEarlierThanVersion, isEqualOrLaterThanVersion } from './semver' +import { + compareVersion, + getLatestVersion, + isEarlierThanVersion, + isEqualOrLaterThanVersion, +} from './semver' describe('semver utilities', () => { describe('getLatestVersion', () => { diff --git a/web/utils/time.ts b/web/utils/time.ts index 0a26a24652a039..c35865c203b2a8 100644 --- a/web/utils/time.ts +++ b/web/utils/time.ts @@ -8,7 +8,7 @@ export const isAfter = (date: ConfigType, compare: ConfigType) => { return dayjs(date).isAfter(dayjs(compare)) } -export const formatTime = ({ date, dateFormat }: { date: ConfigType, dateFormat: string }) => { +export const formatTime = ({ date, dateFormat }: { date: ConfigType; dateFormat: string }) => { return dayjs(date).format(dateFormat) } diff --git a/web/utils/timezone.ts b/web/utils/timezone.ts index af21fd8840ecbe..19abbc8c2167d3 100644 --- a/web/utils/timezone.ts +++ b/web/utils/timezone.ts @@ -7,8 +7,7 @@ type Item = { export const timezones: Item[] = tz export const getBrowserTimezone = () => { - if (typeof Intl === 'undefined') - return undefined + if (typeof Intl === 'undefined') return undefined return new Intl.DateTimeFormat().resolvedOptions().timeZone || undefined } diff --git a/web/utils/tool-call.ts b/web/utils/tool-call.ts index 8d34cbdcb682c9..61a099b1acd641 100644 --- a/web/utils/tool-call.ts +++ b/web/utils/tool-call.ts @@ -1,7 +1,12 @@ import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' export const supportFunctionCall = (features: ModelFeatureEnum[] = []): boolean => { - if (!features || !features.length) - return false - return features.some(feature => [ModelFeatureEnum.toolCall, ModelFeatureEnum.multiToolCall, ModelFeatureEnum.streamToolCall].includes(feature)) + if (!features || !features.length) return false + return features.some((feature) => + [ + ModelFeatureEnum.toolCall, + ModelFeatureEnum.multiToolCall, + ModelFeatureEnum.streamToolCall, + ].includes(feature), + ) } diff --git a/web/utils/urlValidation.spec.ts b/web/utils/urlValidation.spec.ts index af06b24fa6c47b..52163f2c682615 100644 --- a/web/utils/urlValidation.spec.ts +++ b/web/utils/urlValidation.spec.ts @@ -3,19 +3,27 @@ import { validateRedirectUrl } from './urlValidation' describe('URL Validation', () => { describe('validateRedirectUrl', () => { it('should reject data: protocol', () => { - expect(() => validateRedirectUrl('data:text/html,')).toThrow('Authorization URL must be HTTP or HTTPS') + expect(() => validateRedirectUrl('data:text/html,')).toThrow( + 'Authorization URL must be HTTP or HTTPS', + ) }) it('should reject file: protocol', () => { - expect(() => validateRedirectUrl('file:///etc/passwd')).toThrow('Authorization URL must be HTTP or HTTPS') + expect(() => validateRedirectUrl('file:///etc/passwd')).toThrow( + 'Authorization URL must be HTTP or HTTPS', + ) }) it('should reject ftp: protocol', () => { - expect(() => validateRedirectUrl('ftp://example.com')).toThrow('Authorization URL must be HTTP or HTTPS') + expect(() => validateRedirectUrl('ftp://example.com')).toThrow( + 'Authorization URL must be HTTP or HTTPS', + ) }) it('should reject vbscript: protocol', () => { - expect(() => validateRedirectUrl('vbscript:msgbox(1)')).toThrow('Authorization URL must be HTTP or HTTPS') + expect(() => validateRedirectUrl('vbscript:msgbox(1)')).toThrow( + 'Authorization URL must be HTTP or HTTPS', + ) }) it('should reject malformed URLs', () => { @@ -26,7 +34,9 @@ describe('URL Validation', () => { it('should handle URLs with query parameters', () => { expect(() => validateRedirectUrl('https://example.com?param=value')).not.toThrow() - expect(() => validateRedirectUrl('https://example.com?redirect=http://evil.com')).not.toThrow() + expect(() => + validateRedirectUrl('https://example.com?redirect=http://evil.com'), + ).not.toThrow() }) it('should handle URLs with fragments', () => { diff --git a/web/utils/urlValidation.ts b/web/utils/urlValidation.ts index 196c6105c592ed..01ae0f70f1de10 100644 --- a/web/utils/urlValidation.ts +++ b/web/utils/urlValidation.ts @@ -10,12 +10,8 @@ export function validateRedirectUrl(url: string): void { const parsedUrl = new URL(url) if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') throw new Error('Authorization URL must be HTTP or HTTPS') - } - catch (error) { - if ( - error instanceof Error - && error.message === 'Authorization URL must be HTTP or HTTPS' - ) { + } catch (error) { + if (error instanceof Error && error.message === 'Authorization URL must be HTTP or HTTPS') { throw error } // If URL parsing fails, it's also invalid @@ -34,8 +30,7 @@ export function isPrivateOrLocalAddress(url: string): boolean { const hostname = urlObj.hostname.toLowerCase() // Check for localhost - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') - return true + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true // Check for private IP ranges const ipv4Regex = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/ @@ -43,23 +38,18 @@ export function isPrivateOrLocalAddress(url: string): boolean { if (ipv4Match) { const [, a, b] = ipv4Match.map(Number) // 10.0.0.0/8 - if (a === 10) - return true + if (a === 10) return true // 172.16.0.0/12 - if (a === 172 && b! >= 16 && b! <= 31) - return true + if (a === 172 && b! >= 16 && b! <= 31) return true // 192.168.0.0/16 - if (a === 192 && b === 168) - return true + if (a === 192 && b === 168) return true // 169.254.0.0/16 (link-local) - if (a === 169 && b === 254) - return true + if (a === 169 && b === 254) return true } // Check for .local domains return hostname.endsWith('.local') - } - catch { + } catch { return false } } diff --git a/web/utils/validators.spec.ts b/web/utils/validators.spec.ts index b09955d12e1f98..ac7061a6123ce6 100644 --- a/web/utils/validators.spec.ts +++ b/web/utils/validators.spec.ts @@ -98,8 +98,8 @@ describe('Validators', () => { } const errors = forbidBooleanProperties(schema) expect(errors).toHaveLength(2) - expect(errors.some(e => e.includes('user.name'))).toBe(true) - expect(errors.some(e => e.includes('user.profile.bio'))).toBe(true) + expect(errors.some((e) => e.includes('user.name'))).toBe(true) + expect(errors.some((e) => e.includes('user.profile.bio'))).toBe(true) }) it('should handle schema without properties', () => { diff --git a/web/utils/validators.ts b/web/utils/validators.ts index 79d1bdc2744152..0d30fe6257437f 100644 --- a/web/utils/validators.ts +++ b/web/utils/validators.ts @@ -9,8 +9,7 @@ type Draft07ValidationResult = Pick export const draft07Validator = (schema: any): Draft07ValidationResult => { try { return validator.validate(schema, draft07Schema as unknown as Schema) - } - catch { + } catch { // The jsonschema library may throw URL errors in browser environments // when resolving schema $id URIs. Return empty errors since structural // validation is handled separately by preValidateSchema (#34841). @@ -24,11 +23,8 @@ export const forbidBooleanProperties = (schema: any, path: string[] = []): strin if (schema && typeof schema === 'object' && schema.properties) { for (const [key, val] of Object.entries(schema.properties)) { if (typeof val === 'boolean') { - errors.push( - `Error: Property '${[...path, key].join('.')}' must not be a boolean schema`, - ) - } - else if (typeof val === 'object') { + errors.push(`Error: Property '${[...path, key].join('.')}' must not be a boolean schema`) + } else if (typeof val === 'object') { errors = errors.concat(forbidBooleanProperties(val, [...path, key])) } } diff --git a/web/utils/var.spec.ts b/web/utils/var.spec.ts index d5ff8b0687e312..0930545d4ea179 100644 --- a/web/utils/var.spec.ts +++ b/web/utils/var.spec.ts @@ -210,7 +210,11 @@ describe('Variable Utilities', () => { }) it('should include provided source without double encoding', () => { - const url = getMarketplaceUrl('/plugins', { category: 'ai' }, { source: 'https://example.com/app' }) + const url = getMarketplaceUrl( + '/plugins', + { category: 'ai' }, + { source: 'https://example.com/app' }, + ) expect(url).toContain('source=https%3A%2F%2Fexample.com') expect(url).not.toContain('source=https%253A%252F%252Fexample.com') }) @@ -223,8 +227,7 @@ describe('Variable Utilities', () => { const url = getMarketplaceUrl('/plugins', { category: 'ai' }) expect(url).toContain('category=ai') expect(url).not.toContain('source=') - } - finally { + } finally { vi.stubGlobal('window', originalWindow) } }) diff --git a/web/utils/var.ts b/web/utils/var.ts index b1fdd775790899..0a6a1a586b10f6 100644 --- a/web/utils/var.ts +++ b/web/utils/var.ts @@ -7,7 +7,13 @@ import { QUERY_PLACEHOLDER_TEXT, } from '@/app/components/base/prompt-editor/constants' import { InputVarType } from '@/app/components/workflow/types' -import { getMaxVarNameLength, MARKETPLACE_URL_PREFIX, MAX_VAR_KEY_LENGTH, VAR_ITEM_TEMPLATE, VAR_ITEM_TEMPLATE_IN_WORKFLOW } from '@/config' +import { + getMaxVarNameLength, + MARKETPLACE_URL_PREFIX, + MAX_VAR_KEY_LENGTH, + VAR_ITEM_TEMPLATE, + VAR_ITEM_TEMPLATE_IN_WORKFLOW, +} from '@/config' import { env } from '@/env' const otherAllowedRegex = /^\w+$/ @@ -53,36 +59,35 @@ export const getNewVarInWorkflow = (key: string, type = InputVarType.textInput): type VarKeyErrorMessageKey = I18nKeysByPrefix<'appDebug', 'varKeyError.'> -export const checkKey = (key: string, canBeEmpty?: boolean, _keys?: string[]): true | VarKeyErrorMessageKey => { - if (key.length === 0 && !canBeEmpty) - return 'canNoBeEmpty' +export const checkKey = ( + key: string, + canBeEmpty?: boolean, + _keys?: string[], +): true | VarKeyErrorMessageKey => { + if (key.length === 0 && !canBeEmpty) return 'canNoBeEmpty' - if (canBeEmpty && key === '') - return true + if (canBeEmpty && key === '') return true - if (key.length > MAX_VAR_KEY_LENGTH) - return 'tooLong' + if (key.length > MAX_VAR_KEY_LENGTH) return 'tooLong' if (otherAllowedRegex.test(key)) { - if (/\d/.test(key[0]!)) - return 'notStartWithNumber' + if (/\d/.test(key[0]!)) return 'notStartWithNumber' return true } return 'notValid' } -type CheckKeysResult - = | { isValid: true, errorKey: '', errorMessageKey: '' } - | { isValid: false, errorKey: string, errorMessageKey: VarKeyErrorMessageKey } +type CheckKeysResult = + | { isValid: true; errorKey: ''; errorMessageKey: '' } + | { isValid: false; errorKey: string; errorMessageKey: VarKeyErrorMessageKey } export const checkKeys = (keys: string[], canBeEmpty?: boolean): CheckKeysResult => { let isValid = true let errorKey = '' let errorMessageKey: VarKeyErrorMessageKey | '' = '' keys.forEach((key) => { - if (!isValid) - return + if (!isValid) return const res = checkKey(key, canBeEmpty) if (res !== true) { @@ -97,30 +102,36 @@ export const checkKeys = (keys: string[], canBeEmpty?: boolean): CheckKeysResult export const hasDuplicateStr = (strArr: string[]) => { const strObj: Record = {} strArr.forEach((str) => { - if (strObj[str]) - strObj[str] += 1 - else - strObj[str] = 1 + if (strObj[str]) strObj[str] += 1 + else strObj[str] = 1 }) - return !!Object.keys(strObj).find(key => strObj[key]! > 1) + return !!Object.keys(strObj).find((key) => strObj[key]! > 1) } const varRegex = /\{\{([a-z_]\w*)\}\}/gi export const getVars = (value: string) => { - if (!value) - return [] - - const keys = value.match(varRegex)?.filter((item) => { - return ![CONTEXT_PLACEHOLDER_TEXT, HISTORY_PLACEHOLDER_TEXT, QUERY_PLACEHOLDER_TEXT, PRE_PROMPT_PLACEHOLDER_TEXT].includes(item) - }).map((item) => { - return item.replace('{{', '').replace('}}', '') - }).filter(key => key.length <= MAX_VAR_KEY_LENGTH) || [] + if (!value) return [] + + const keys = + value + .match(varRegex) + ?.filter((item) => { + return ![ + CONTEXT_PLACEHOLDER_TEXT, + HISTORY_PLACEHOLDER_TEXT, + QUERY_PLACEHOLDER_TEXT, + PRE_PROMPT_PLACEHOLDER_TEXT, + ].includes(item) + }) + .map((item) => { + return item.replace('{{', '').replace('}}', '') + }) + .filter((key) => key.length <= MAX_VAR_KEY_LENGTH) || [] const keyObj: Record = {} // remove duplicate keys const res: string[] = [] keys.forEach((key) => { - if (keyObj[key]) - return + if (keyObj[key]) return keyObj[key] = true res.push(key) @@ -137,35 +148,37 @@ type MarketplaceUrlOptions = { } const getUrlOrigin = (url?: string) => { - if (!url) - return undefined + if (!url) return undefined try { return new URL(url).origin - } - catch { + } catch { return undefined } } const marketplaceSource = getUrlOrigin(env.NEXT_PUBLIC_WEB_PREFIX) -export function getMarketplaceUrl(path: string, params?: Record, options?: MarketplaceUrlOptions) { +export function getMarketplaceUrl( + path: string, + params?: Record, + options?: MarketplaceUrlOptions, +) { const searchParams = new URLSearchParams() const source = getUrlOrigin(options?.source) ?? marketplaceSource - if (source) - searchParams.set('source', source) + if (source) searchParams.set('source', source) if (params) { Object.keys(params).forEach((key) => { const value = params[key] - if (value !== undefined && value !== null) - searchParams.append(key, value) + if (value !== undefined && value !== null) searchParams.append(key, value) }) } const queryString = searchParams.toString() - return queryString ? `${MARKETPLACE_URL_PREFIX}${path}?${queryString}` : `${MARKETPLACE_URL_PREFIX}${path}` + return queryString + ? `${MARKETPLACE_URL_PREFIX}${path}?${queryString}` + : `${MARKETPLACE_URL_PREFIX}${path}` } export const replaceSpaceWithUnderscoreInVarNameInput = (input: HTMLInputElement) => { @@ -174,6 +187,5 @@ export const replaceSpaceWithUnderscoreInVarNameInput = (input: HTMLInputElement input.value = input.value.replaceAll(' ', '_') - if (start !== null && end !== null) - input.setSelectionRange(start, end) + if (start !== null && end !== null) input.setSelectionRange(start, end) } diff --git a/web/utils/yaml.spec.ts b/web/utils/yaml.spec.ts index 41ebf472f95855..34fbc8f293ade2 100644 --- a/web/utils/yaml.spec.ts +++ b/web/utils/yaml.spec.ts @@ -2,17 +2,18 @@ import { loadYaml } from './yaml' describe('loadYaml', () => { it('should reject duplicate mapping keys', () => { - expect(() => loadYaml('name: first\nname: second\n')) - .toThrow(/duplicated mapping key/) + expect(() => loadYaml('name: first\nname: second\n')).toThrow(/duplicated mapping key/) }) it('should reject complex mapping keys', () => { - expect(() => loadYaml('? [name, region]\n: deployment\n')) - .toThrow(/does not support complex keys/) + expect(() => loadYaml('? [name, region]\n: deployment\n')).toThrow( + /does not support complex keys/, + ) }) it('should parse explicit YAML sets as JavaScript sets', () => { - expect(loadYaml('features: !!set\n workflow:\n chat:\n')) - .toEqual({ features: new Set(['workflow', 'chat']) }) + expect(loadYaml('features: !!set\n workflow:\n chat:\n')).toEqual({ + features: new Set(['workflow', 'chat']), + }) }) }) diff --git a/web/utils/yaml.ts b/web/utils/yaml.ts index 92de446e09b2b6..173980f8217153 100644 --- a/web/utils/yaml.ts +++ b/web/utils/yaml.ts @@ -10,7 +10,14 @@ import { YAMLException, } from 'js-yaml' -const YAML_LOAD_SCHEMA = CORE_SCHEMA.withTags(binaryTag, mergeTag, omapTag, pairsTag, setTag, timestampTag) +const YAML_LOAD_SCHEMA = CORE_SCHEMA.withTags( + binaryTag, + mergeTag, + omapTag, + pairsTag, + setTag, + timestampTag, +) export function loadYaml(input: string) { const documents = loadAll(input, { schema: YAML_LOAD_SCHEMA }) diff --git a/web/vite.config.ts b/web/vite.config.ts index ff6acfdf503124..c7354b76ec1cc5 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,6 +1,9 @@ import { fileURLToPath } from 'node:url' import { defineConfig, lazyPlugins } from 'vite-plus' -import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector.ts' +import { + createCodeInspectorPlugin, + createForceInspectorClientInjectionPlugin, +} from './plugins/vite/code-inspector.ts' import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr.ts' import { getRootClientInjectTarget } from './plugins/vite/inject-target.ts' import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test.ts' @@ -11,8 +14,9 @@ const rootClientInjectTarget = getRootClientInjectTarget(projectRoot) export default defineConfig(({ mode }) => { const isTest = mode === 'test' - const isStorybook = process.env.STORYBOOK === 'true' - || process.argv.some(arg => arg.toLowerCase().includes('storybook')) + const isStorybook = + process.env.STORYBOOK === 'true' || + process.argv.some((arg) => arg.toLowerCase().includes('storybook')) return { plugins: lazyPlugins(async () => { @@ -27,25 +31,20 @@ export default defineConfig(({ mode }) => { name: 'mdx-stub', enforce: 'pre', transform(_: string, id: string) { - if (id.endsWith('.mdx')) - return { code: 'export default () => null', map: null } + if (id.endsWith('.mdx')) return { code: 'export default () => null', map: null } }, }, ] } - if (isStorybook) - return [react()] + if (isStorybook) return [react()] - const [ - { default: tailwindcss }, - { default: vinext }, - { default: Inspect }, - ] = await Promise.all([ - import('@tailwindcss/vite'), - import('vinext'), - import('vite-plugin-inspect'), - ]) + const [{ default: tailwindcss }, { default: vinext }, { default: Inspect }] = + await Promise.all([ + import('@tailwindcss/vite'), + import('vinext'), + import('vite-plugin-inspect'), + ]) return [ Inspect(), diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts index acd6dd4edb0bf2..b9ef82b3d0f34c 100644 --- a/web/vitest.setup.ts +++ b/web/vitest.setup.ts @@ -12,7 +12,7 @@ if (typeof expect.extend === 'function') { expect.extend(jestDomMatchers) } -( +;( globalThis as typeof globalThis & { BASE_UI_ANIMATIONS_DISABLED: boolean } @@ -23,8 +23,7 @@ if (typeof window !== 'undefined') { if (typeof Element !== 'undefined' && !Element.prototype.getAnimations) Element.prototype.getAnimations = () => [] - if (!document.getAnimations) - document.getAnimations = () => [] + if (!document.getAnimations) document.getAnimations = () => [] } if (typeof globalThis.ResizeObserver === 'undefined') { @@ -50,11 +49,21 @@ if (typeof globalThis.IntersectionObserver === 'undefined') { readonly rootMargin: string = '' readonly scrollMargin: string = '' readonly thresholds: ReadonlyArray = [] - constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) { /* noop */ } - observe(_target: Element) { /* noop */ } - unobserve(_target: Element) { /* noop */ } - disconnect() { /* noop */ } - takeRecords(): IntersectionObserverEntry[] { return [] } + constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) { + /* noop */ + } + observe(_target: Element) { + /* noop */ + } + unobserve(_target: Element) { + /* noop */ + } + disconnect() { + /* noop */ + } + takeRecords(): IntersectionObserverEntry[] { + return [] + } } } @@ -107,7 +116,8 @@ vi.mock('@floating-ui/react', async () => { const actual = await vi.importActual('@floating-ui/react') return { ...actual, - FloatingPortal: ({ children }: { children: React.ReactNode }) => React.createElement('div', { 'data-floating-ui-portal': true }, children), + FloatingPortal: ({ children }: { children: React.ReactNode }) => + React.createElement('div', { 'data-floating-ui-portal': true }, children), } }) @@ -134,13 +144,13 @@ vi.mock('@monaco-editor/react', () => { getPosition: vi.fn(() => ({ lineNumber: 1, column: 1 })), deltaDecorations: vi.fn(() => []), focus: vi.fn(() => { - focusListeners.forEach(listener => listener()) + focusListeners.forEach((listener) => listener()) }), setPosition: vi.fn(), revealLine: vi.fn(), trigger: vi.fn(), __blur: () => { - blurListeners.forEach(listener => listener()) + blurListeners.forEach((listener) => listener()) }, } } @@ -155,7 +165,12 @@ vi.mock('@monaco-editor/react', () => { startColumn: number endLineNumber: number endColumn: number - constructor(startLineNumber: number, startColumn: number, endLineNumber: number, endColumn: number) { + constructor( + startLineNumber: number, + startColumn: number, + endLineNumber: number, + endColumn: number, + ) { this.startLineNumber = startLineNumber this.startColumn = startColumn this.endLineNumber = endLineNumber @@ -176,8 +191,7 @@ vi.mock('@monaco-editor/react', () => { options?: { readOnly?: boolean } }) => { const editorRef = React.useRef | null>(null) - if (!editorRef.current) - editorRef.current = createEditorMock() + if (!editorRef.current) editorRef.current = createEditorMock() React.useEffect(() => { onMount?.(editorRef.current!, monacoMock) @@ -185,11 +199,11 @@ vi.mock('@monaco-editor/react', () => { return React.createElement('textarea', { 'data-testid': 'monaco-editor', - 'readOnly': options?.readOnly, + readOnly: options?.readOnly, value, - 'onChange': (event: React.ChangeEvent) => onChange?.(event.target.value), - 'onFocus': () => editorRef.current?.focus(), - 'onBlur': () => editorRef.current?.__blur(), + onChange: (event: React.ChangeEvent) => onChange?.(event.target.value), + onFocus: () => editorRef.current?.focus(), + onBlur: () => editorRef.current?.__blur(), }) } @@ -216,9 +230,11 @@ const createMockLocalStorage = () => { delete storage[key] }), clear: vi.fn(() => { - Object.keys(storage).forEach(key => delete storage[key]) + Object.keys(storage).forEach((key) => delete storage[key]) }), - get storage() { return { ...storage } }, + get storage() { + return { ...storage } + }, } }